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

194
src/chargen.rs Normal file
View File

@@ -0,0 +1,194 @@
use crate::ansi;
use crate::world::World;
#[derive(Clone)]
pub enum ChargenStep {
AwaitingRace,
AwaitingClass,
Done { race_id: String, class_id: String },
}
pub struct ChargenState {
pub step: ChargenStep,
pub race_id: Option<String>,
}
impl ChargenState {
pub fn new() -> Self {
ChargenState {
step: ChargenStep::AwaitingRace,
race_id: None,
}
}
pub fn prompt_text(&self, world: &World) -> String {
match &self.step {
ChargenStep::AwaitingRace => {
let mut out = String::new();
out.push_str(&format!(
"\r\n{}\r\n\r\n",
ansi::bold("=== Choose Your Race ===")
));
for (i, race) in world.races.iter().enumerate() {
let mods = format_stat_mods(&race.stats);
out.push_str(&format!(
" {}{}.{} {} {}\r\n {}\r\n",
ansi::BOLD,
i + 1,
ansi::RESET,
ansi::color(ansi::CYAN, &race.name),
if mods.is_empty() {
String::new()
} else {
ansi::system_msg(&format!("({})", mods))
},
ansi::color(ansi::DIM, &race.description),
));
}
out.push_str(&format!(
"\r\n{}",
ansi::color(ansi::YELLOW, "Enter number or name: ")
));
out
}
ChargenStep::AwaitingClass => {
let mut out = String::new();
out.push_str(&format!(
"\r\n{}\r\n\r\n",
ansi::bold("=== Choose Your Class ===")
));
for (i, class) in world.classes.iter().enumerate() {
out.push_str(&format!(
" {}{}.{} {} {}\r\n {}\r\n {}HP:{} {}ATK:{} {}DEF:{}{}\r\n",
ansi::BOLD,
i + 1,
ansi::RESET,
ansi::color(ansi::CYAN, &class.name),
ansi::system_msg(&format!(
"(+{}hp/+{}atk/+{}def per level)",
class.growth.hp_per_level,
class.growth.attack_per_level,
class.growth.defense_per_level,
)),
ansi::color(ansi::DIM, &class.description),
ansi::GREEN,
class.base_stats.max_hp,
ansi::RED,
class.base_stats.attack,
ansi::BLUE,
class.base_stats.defense,
ansi::RESET,
));
}
out.push_str(&format!(
"\r\n{}",
ansi::color(ansi::YELLOW, "Enter number or name: ")
));
out
}
ChargenStep::Done { .. } => String::new(),
}
}
pub fn handle_input(&mut self, input: &str, world: &World) -> Result<String, String> {
let input = input.trim();
match &self.step {
ChargenStep::AwaitingRace => {
let race = find_by_input(
input,
&world.races.iter().map(|r| (r.id.clone(), r.name.clone())).collect::<Vec<_>>(),
);
match race {
Some((id, name)) => {
self.race_id = Some(id);
self.step = ChargenStep::AwaitingClass;
Ok(format!(
"\r\n{}\r\n",
ansi::system_msg(&format!("Race selected: {name}"))
))
}
None => Err(format!(
"{}\r\n",
ansi::error_msg("Invalid choice. Enter a number or name.")
)),
}
}
ChargenStep::AwaitingClass => {
let class = find_by_input(
input,
&world
.classes
.iter()
.map(|c| (c.id.clone(), c.name.clone()))
.collect::<Vec<_>>(),
);
match class {
Some((id, name)) => {
let race_id = self.race_id.clone().unwrap();
self.step = ChargenStep::Done {
race_id,
class_id: id,
};
Ok(format!(
"\r\n{}\r\n",
ansi::system_msg(&format!("Class selected: {name}"))
))
}
None => Err(format!(
"{}\r\n",
ansi::error_msg("Invalid choice. Enter a number or name.")
)),
}
}
ChargenStep::Done { .. } => Ok(String::new()),
}
}
pub fn is_done(&self) -> bool {
matches!(self.step, ChargenStep::Done { .. })
}
pub fn result(&self) -> Option<(String, String)> {
match &self.step {
ChargenStep::Done { race_id, class_id } => {
Some((race_id.clone(), class_id.clone()))
}
_ => None,
}
}
}
fn find_by_input(input: &str, options: &[(String, String)]) -> Option<(String, String)> {
if let Ok(num) = input.parse::<usize>() {
if num >= 1 && num <= options.len() {
return Some(options[num - 1].clone());
}
}
let lower = input.to_lowercase();
options
.iter()
.find(|(_, name)| name.to_lowercase() == lower)
.cloned()
}
fn format_stat_mods(stats: &crate::world::StatModifiers) -> String {
let mut parts = Vec::new();
let fields = [
("STR", stats.strength),
("DEX", stats.dexterity),
("CON", stats.constitution),
("INT", stats.intelligence),
("WIS", stats.wisdom),
];
for (label, val) in fields {
if val != 0 {
parts.push(format!(
"{}{} {}",
if val > 0 { "+" } else { "" },
val,
label
));
}
}
parts.join(", ")
}