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

154
src/combat.rs Normal file
View File

@@ -0,0 +1,154 @@
use std::time::Instant;
use crate::ansi;
use crate::game::GameState;
pub struct CombatRoundResult {
pub output: String,
pub npc_died: bool,
pub player_died: bool,
pub xp_gained: i32,
}
pub fn do_attack(player_id: usize, npc_id: &str, state: &mut GameState) -> Option<CombatRoundResult> {
let npc_template = state.world.get_npc(npc_id)?.clone();
let npc_combat = npc_template.combat.as_ref()?;
let instance = state.npc_instances.get(npc_id)?;
if !instance.alive {
return None;
}
let npc_hp_before = instance.hp;
let conn = state.players.get(&player_id)?;
let p_atk = conn.player.effective_attack();
let p_def = conn.player.effective_defense();
// Player attacks NPC
let roll: i32 = (simple_random() % 6) as i32 + 1;
let player_dmg = (p_atk - npc_combat.defense / 2 + roll).max(1);
let new_npc_hp = (npc_hp_before - player_dmg).max(0);
let mut out = String::new();
out.push_str(&format!(
" {} You strike {} for {} damage!{}\r\n",
ansi::color(ansi::YELLOW, ">>"),
ansi::color(ansi::RED, &npc_template.name),
ansi::bold(&player_dmg.to_string()),
ansi::RESET,
));
let mut npc_died = false;
let mut player_died = false;
let mut xp_gained = 0;
if new_npc_hp <= 0 {
// NPC dies
if let Some(inst) = state.npc_instances.get_mut(npc_id) {
inst.alive = false;
inst.hp = 0;
inst.death_time = Some(Instant::now());
}
npc_died = true;
xp_gained = npc_combat.xp_reward;
out.push_str(&format!(
" {} {} collapses! You gain {} XP.\r\n",
ansi::color(ansi::GREEN, "**"),
ansi::color(ansi::RED, &npc_template.name),
ansi::bold(&xp_gained.to_string()),
));
// Clear combat state
if let Some(conn) = state.players.get_mut(&player_id) {
conn.combat = None;
conn.player.stats.xp += xp_gained;
}
} else {
// Update NPC HP
if let Some(inst) = state.npc_instances.get_mut(npc_id) {
inst.hp = new_npc_hp;
}
out.push_str(&format!(
" {} {} HP: {}/{}\r\n",
ansi::color(ansi::DIM, " "),
npc_template.name,
new_npc_hp,
npc_combat.max_hp,
));
// NPC attacks player
let npc_roll: i32 = (simple_random() % 6) as i32 + 1;
let npc_dmg = (npc_combat.attack - p_def / 2 + npc_roll).max(1);
if let Some(conn) = state.players.get_mut(&player_id) {
conn.player.stats.hp = (conn.player.stats.hp - npc_dmg).max(0);
let hp = conn.player.stats.hp;
let max_hp = conn.player.stats.max_hp;
out.push_str(&format!(
" {} {} strikes you for {} damage!\r\n",
ansi::color(ansi::RED, "<<"),
ansi::color(ansi::RED, &npc_template.name),
ansi::bold(&npc_dmg.to_string()),
));
let hp_color = if hp * 3 < max_hp {
ansi::RED
} else if hp * 3 < max_hp * 2 {
ansi::YELLOW
} else {
ansi::GREEN
};
out.push_str(&format!(
" {} Your HP: {}{}/{}{}\r\n",
ansi::color(ansi::DIM, " "),
hp_color,
hp,
max_hp,
ansi::RESET,
));
if hp <= 0 {
player_died = true;
conn.combat = None;
}
}
}
Some(CombatRoundResult {
output: out,
npc_died,
player_died,
xp_gained,
})
}
pub fn player_death_respawn(player_id: usize, state: &mut GameState) -> String {
let spawn_room = state.spawn_room().to_string();
if let Some(conn) = state.players.get_mut(&player_id) {
conn.player.stats.hp = conn.player.stats.max_hp;
conn.player.room_id = spawn_room;
conn.combat = None;
}
format!(
"\r\n{}\r\n{}\r\n{}\r\n",
ansi::color(ansi::RED, " ╔═══════════════════════════╗"),
ansi::color(ansi::RED, " ║ YOU HAVE DIED! ║"),
ansi::color(ansi::RED, " ╚═══════════════════════════╝"),
) + &format!(
"{}\r\n",
ansi::system_msg("You awaken at the town square, fully healed.")
)
}
fn simple_random() -> u32 {
use std::time::SystemTime;
let d = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap_or_default();
((d.as_nanos() >> 4) ^ (d.as_nanos() >> 16)) as u32
}