Add SQLite persistence, per-player NPC attitude system, character creation, and combat

- Add trait-based DB layer (db.rs) with SQLite backend for easy future swapping
- Player state persisted to SQLite: stats, inventory, equipment, room position
- Returning players skip chargen and resume where they left off
- Replace boolean hostile flag with 5-tier attitude system (friendly/neutral/wary/aggressive/hostile)
- Per-player NPC attitudes stored in DB, shift on kills with faction propagation
- Add character creation flow (chargen.rs) with data-driven races and classes from TOML
- Add turn-based combat system (combat.rs) with XP, leveling, and NPC respawn
- Add commands: take, drop, inventory, equip, use, examine, talk, attack, flee, stats
- Add world data: 5 races, 4 classes, hostile NPCs (rat, thief), new items

Made-with: Cursor
This commit is contained in:
AI Agent
2026-03-14 13:58:22 -06:00
parent c82f57a720
commit 680f48477e
28 changed files with 1797 additions and 673 deletions

View File

@@ -1,70 +1,249 @@
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Instant;
use tokio::sync::Mutex;
use russh::server::Handle;
use russh::ChannelId;
use crate::world::World;
use crate::db::{GameDb, SavedPlayer};
use crate::world::{Attitude, Object, World};
#[derive(Clone)]
pub struct PlayerStats {
pub max_hp: i32,
pub hp: i32,
pub attack: i32,
pub defense: i32,
pub level: i32,
pub xp: i32,
pub xp_to_next: i32,
}
pub struct Player {
pub name: String,
pub race_id: String,
pub class_id: String,
pub room_id: String,
pub stats: PlayerStats,
pub inventory: Vec<Object>,
pub equipped_weapon: Option<Object>,
pub equipped_armor: Option<Object>,
}
impl Player {
pub fn effective_attack(&self) -> i32 {
let bonus = self.equipped_weapon.as_ref().and_then(|w| w.stats.damage).unwrap_or(0);
self.stats.attack + bonus
}
pub fn effective_defense(&self) -> i32 {
let bonus = self.equipped_armor.as_ref().and_then(|a| a.stats.armor).unwrap_or(0);
self.stats.defense + bonus
}
}
pub struct CombatState {
pub npc_id: String,
}
pub struct NpcInstance {
pub hp: i32,
pub alive: bool,
pub death_time: Option<Instant>,
}
pub struct PlayerConnection {
pub player: Player,
pub channel: ChannelId,
pub handle: Handle,
pub combat: Option<CombatState>,
}
pub struct GameState {
pub world: World,
pub db: Arc<dyn GameDb>,
pub players: HashMap<usize, PlayerConnection>,
pub npc_instances: HashMap<String, NpcInstance>,
}
pub type SharedState = Arc<Mutex<GameState>>;
impl GameState {
pub fn new(world: World) -> Self {
GameState {
world,
players: HashMap::new(),
pub fn new(world: World, db: Arc<dyn GameDb>) -> Self {
let mut npc_instances = HashMap::new();
for npc in world.npcs.values() {
if let Some(ref combat) = npc.combat {
npc_instances.insert(npc.id.clone(), NpcInstance {
hp: combat.max_hp, alive: true, death_time: None,
});
}
}
GameState { world, db, players: HashMap::new(), npc_instances }
}
pub fn spawn_room(&self) -> &str {
&self.world.spawn_room
}
pub fn add_player(&mut self, id: usize, name: String, channel: ChannelId, handle: Handle) {
// Get effective attitude of an NPC towards a specific player
pub fn npc_attitude_toward(&self, npc_id: &str, player_name: &str) -> Attitude {
if let Some(val) = self.db.get_attitude(player_name, npc_id) {
return Attitude::from_value(val);
}
self.world.get_npc(npc_id)
.map(|n| n.base_attitude)
.unwrap_or(Attitude::Neutral)
}
pub fn npc_attitude_value(&self, npc_id: &str, player_name: &str) -> i32 {
if let Some(val) = self.db.get_attitude(player_name, npc_id) {
return val;
}
self.world.get_npc(npc_id)
.map(|n| n.base_attitude.default_value())
.unwrap_or(0)
}
pub fn shift_attitude(&self, npc_id: &str, player_name: &str, delta: i32) {
let current = self.npc_attitude_value(npc_id, player_name);
let new_val = (current + delta).clamp(-100, 100);
self.db.save_attitude(player_name, npc_id, new_val);
}
// Shift attitude for all NPCs in the same faction
pub fn shift_faction_attitude(&self, faction: &str, player_name: &str, delta: i32) {
for npc in self.world.npcs.values() {
if npc.faction.as_deref() == Some(faction) {
self.shift_attitude(&npc.id, player_name, delta);
}
}
}
pub fn create_new_player(
&mut self, id: usize, name: String, race_id: String, class_id: String,
channel: ChannelId, handle: Handle,
) {
let room_id = self.world.spawn_room.clone();
self.players.insert(
id,
PlayerConnection {
player: Player { name, room_id },
channel,
handle,
let race = self.world.races.iter().find(|r| r.id == race_id);
let class = self.world.classes.iter().find(|c| c.id == class_id);
let (base_hp, base_atk, base_def) = match class {
Some(c) => (c.base_stats.max_hp, c.base_stats.attack, c.base_stats.defense),
None => (100, 10, 10),
};
let (con_mod, str_mod, dex_mod) = match race {
Some(r) => (r.stats.constitution, r.stats.strength, r.stats.dexterity),
None => (0, 0, 0),
};
let max_hp = base_hp + con_mod * 5;
let attack = base_atk + str_mod + dex_mod / 2;
let defense = base_def + con_mod / 2;
let stats = PlayerStats {
max_hp, hp: max_hp, attack, defense, level: 1, xp: 0, xp_to_next: 100,
};
self.players.insert(id, PlayerConnection {
player: Player { name, race_id, class_id, room_id, stats, inventory: Vec::new(), equipped_weapon: None, equipped_armor: None },
channel, handle, combat: None,
});
}
pub fn load_existing_player(
&mut self, id: usize, saved: SavedPlayer, channel: ChannelId, handle: Handle,
) {
let inventory: Vec<Object> = serde_json::from_str(&saved.inventory_json).unwrap_or_default();
let equipped_weapon: Option<Object> = saved.equipped_weapon_json.as_deref().and_then(|j| serde_json::from_str(j).ok());
let equipped_armor: Option<Object> = saved.equipped_armor_json.as_deref().and_then(|j| serde_json::from_str(j).ok());
// Validate room still exists, else spawn
let room_id = if self.world.rooms.contains_key(&saved.room_id) {
saved.room_id
} else {
self.world.spawn_room.clone()
};
let stats = PlayerStats {
max_hp: saved.max_hp, hp: saved.hp, attack: saved.attack, defense: saved.defense,
level: saved.level, xp: saved.xp, xp_to_next: saved.level * 100,
};
self.players.insert(id, PlayerConnection {
player: Player {
name: saved.name, race_id: saved.race_id, class_id: saved.class_id,
room_id, stats, inventory, equipped_weapon, equipped_armor,
},
);
channel, handle, combat: None,
});
}
pub fn save_player_to_db(&self, player_id: usize) {
if let Some(conn) = self.players.get(&player_id) {
let p = &conn.player;
let inv_json = serde_json::to_string(&p.inventory).unwrap_or_else(|_| "[]".into());
let weapon_json = p.equipped_weapon.as_ref().map(|w| serde_json::to_string(w).unwrap_or_else(|_| "null".into()));
let armor_json = p.equipped_armor.as_ref().map(|a| serde_json::to_string(a).unwrap_or_else(|_| "null".into()));
self.db.save_player(&SavedPlayer {
name: p.name.clone(), race_id: p.race_id.clone(), class_id: p.class_id.clone(),
room_id: p.room_id.clone(), level: p.stats.level, xp: p.stats.xp,
hp: p.stats.hp, max_hp: p.stats.max_hp, attack: p.stats.attack,
defense: p.stats.defense, inventory_json: inv_json,
equipped_weapon_json: weapon_json, equipped_armor_json: armor_json,
});
}
}
pub fn remove_player(&mut self, id: usize) -> Option<PlayerConnection> {
self.save_player_to_db(id);
self.players.remove(&id)
}
pub fn players_in_room(&self, room_id: &str, exclude_id: usize) -> Vec<&PlayerConnection> {
self.players
.iter()
self.players.iter()
.filter(|(&id, conn)| conn.player.room_id == room_id && id != exclude_id)
.map(|(_, conn)| conn)
.collect()
.map(|(_, conn)| conn).collect()
}
pub fn all_player_names(&self) -> Vec<&str> {
self.players
.values()
.map(|c| c.player.name.as_str())
.collect()
pub fn check_respawns(&mut self) {
let now = Instant::now();
for (npc_id, instance) in self.npc_instances.iter_mut() {
if instance.alive { continue; }
let npc = match self.world.npcs.get(npc_id) { Some(n) => n, None => continue };
let respawn_secs = match npc.respawn_secs { Some(s) => s, None => continue };
if let Some(death_time) = instance.death_time {
if now.duration_since(death_time).as_secs() >= respawn_secs {
if let Some(ref combat) = npc.combat {
instance.hp = combat.max_hp;
instance.alive = true;
instance.death_time = None;
}
}
}
}
}
pub fn check_level_up(&mut self, player_id: usize) -> Option<String> {
let conn = self.players.get_mut(&player_id)?;
let player = &mut conn.player;
if player.stats.xp < player.stats.xp_to_next { return None; }
player.stats.xp -= player.stats.xp_to_next;
player.stats.level += 1;
player.stats.xp_to_next = player.stats.level * 100;
let class = self.world.classes.iter().find(|c| c.id == player.class_id);
let (hp_g, atk_g, def_g) = match class {
Some(c) => (c.growth.hp_per_level, c.growth.attack_per_level, c.growth.defense_per_level),
None => (10, 2, 1),
};
player.stats.max_hp += hp_g;
player.stats.hp = player.stats.max_hp;
player.stats.attack += atk_g;
player.stats.defense += def_g;
Some(format!("You are now level {}! HP:{} ATK:{} DEF:{}", player.stats.level, player.stats.max_hp, player.stats.attack, player.stats.defense))
}
}