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:
415
src/world.rs
415
src/world.rs
@@ -1,7 +1,63 @@
|
||||
use serde::Deserialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
||||
// --- Attitude system ---
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Attitude {
|
||||
Friendly, // 50 to 100
|
||||
Neutral, // 10 to 49
|
||||
Wary, // -24 to 9
|
||||
Aggressive, // -74 to -25 (will attack if provoked)
|
||||
Hostile, // -100 to -75 (attacks on sight)
|
||||
}
|
||||
|
||||
impl Attitude {
|
||||
pub fn default_value(self) -> i32 {
|
||||
match self {
|
||||
Attitude::Friendly => 75,
|
||||
Attitude::Neutral => 30,
|
||||
Attitude::Wary => 0,
|
||||
Attitude::Aggressive => -50,
|
||||
Attitude::Hostile => -90,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_value(v: i32) -> Self {
|
||||
match v {
|
||||
50..=i32::MAX => Attitude::Friendly,
|
||||
10..=49 => Attitude::Neutral,
|
||||
-24..=9 => Attitude::Wary,
|
||||
-74..=-25 => Attitude::Aggressive,
|
||||
_ => Attitude::Hostile,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Attitude::Friendly => "friendly",
|
||||
Attitude::Neutral => "neutral",
|
||||
Attitude::Wary => "wary",
|
||||
Attitude::Aggressive => "aggressive",
|
||||
Attitude::Hostile => "hostile",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn will_attack(self) -> bool {
|
||||
matches!(self, Attitude::Hostile)
|
||||
}
|
||||
|
||||
pub fn can_be_attacked(self) -> bool {
|
||||
matches!(self, Attitude::Hostile | Attitude::Aggressive)
|
||||
}
|
||||
|
||||
pub fn will_talk(self) -> bool {
|
||||
matches!(self, Attitude::Friendly | Attitude::Neutral | Attitude::Wary)
|
||||
}
|
||||
}
|
||||
|
||||
// --- On-disk TOML schemas ---
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -13,8 +69,6 @@ pub struct Manifest {
|
||||
#[derive(Deserialize)]
|
||||
pub struct RegionFile {
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -25,13 +79,50 @@ pub struct RoomFile {
|
||||
pub exits: HashMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct NpcDialogue {
|
||||
#[serde(default)]
|
||||
pub greeting: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct NpcCombatFile {
|
||||
pub max_hp: i32,
|
||||
pub attack: i32,
|
||||
pub defense: i32,
|
||||
#[serde(default)]
|
||||
pub xp_reward: i32,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct NpcFile {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub room: String,
|
||||
#[serde(default = "default_attitude")]
|
||||
pub base_attitude: Attitude,
|
||||
#[serde(default)]
|
||||
pub dialogue: Option<String>,
|
||||
pub faction: Option<String>,
|
||||
#[serde(default)]
|
||||
pub respawn_secs: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub dialogue: Option<NpcDialogue>,
|
||||
#[serde(default)]
|
||||
pub combat: Option<NpcCombatFile>,
|
||||
}
|
||||
|
||||
fn default_attitude() -> Attitude {
|
||||
Attitude::Neutral
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
pub struct ObjectStatsFile {
|
||||
#[serde(default)]
|
||||
pub damage: Option<i32>,
|
||||
#[serde(default)]
|
||||
pub armor: Option<i32>,
|
||||
#[serde(default)]
|
||||
pub heal_amount: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -42,6 +133,62 @@ pub struct ObjectFile {
|
||||
pub room: Option<String>,
|
||||
#[serde(default)]
|
||||
pub kind: Option<String>,
|
||||
#[serde(default)]
|
||||
pub takeable: bool,
|
||||
#[serde(default)]
|
||||
pub stats: Option<ObjectStatsFile>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default, Clone)]
|
||||
pub struct StatModifiers {
|
||||
#[serde(default)]
|
||||
pub strength: i32,
|
||||
#[serde(default)]
|
||||
pub dexterity: i32,
|
||||
#[serde(default)]
|
||||
pub constitution: i32,
|
||||
#[serde(default)]
|
||||
pub intelligence: i32,
|
||||
#[serde(default)]
|
||||
pub wisdom: i32,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct RaceFile {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
#[serde(default)]
|
||||
pub stats: StatModifiers,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default, Clone)]
|
||||
pub struct ClassBaseStats {
|
||||
#[serde(default)]
|
||||
pub max_hp: i32,
|
||||
#[serde(default)]
|
||||
pub attack: i32,
|
||||
#[serde(default)]
|
||||
pub defense: i32,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default, Clone)]
|
||||
pub struct ClassGrowth {
|
||||
#[serde(default)]
|
||||
pub hp_per_level: i32,
|
||||
#[serde(default)]
|
||||
pub attack_per_level: i32,
|
||||
#[serde(default)]
|
||||
pub defense_per_level: i32,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ClassFile {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
#[serde(default)]
|
||||
pub base_stats: ClassBaseStats,
|
||||
#[serde(default)]
|
||||
pub growth: ClassGrowth,
|
||||
}
|
||||
|
||||
// --- Runtime types ---
|
||||
@@ -56,20 +203,60 @@ pub struct Room {
|
||||
pub objects: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct NpcCombatStats {
|
||||
pub max_hp: i32,
|
||||
pub attack: i32,
|
||||
pub defense: i32,
|
||||
pub xp_reward: i32,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Npc {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub room: String,
|
||||
pub dialogue: Option<String>,
|
||||
pub base_attitude: Attitude,
|
||||
pub faction: Option<String>,
|
||||
pub respawn_secs: Option<u64>,
|
||||
pub greeting: Option<String>,
|
||||
pub combat: Option<NpcCombatStats>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct ObjectStats {
|
||||
pub damage: Option<i32>,
|
||||
pub armor: Option<i32>,
|
||||
pub heal_amount: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct Object {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub room: Option<String>,
|
||||
pub kind: Option<String>,
|
||||
pub takeable: bool,
|
||||
pub stats: ObjectStats,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Race {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub stats: StatModifiers,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Class {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub base_stats: ClassBaseStats,
|
||||
pub growth: ClassGrowth,
|
||||
}
|
||||
|
||||
pub struct World {
|
||||
@@ -78,204 +265,102 @@ pub struct World {
|
||||
pub rooms: HashMap<String, Room>,
|
||||
pub npcs: HashMap<String, Npc>,
|
||||
pub objects: HashMap<String, Object>,
|
||||
pub races: Vec<Race>,
|
||||
pub classes: Vec<Class>,
|
||||
}
|
||||
|
||||
impl World {
|
||||
pub fn load(world_dir: &Path) -> Result<Self, String> {
|
||||
let manifest_path = world_dir.join("manifest.toml");
|
||||
let manifest: Manifest = load_toml(&manifest_path)?;
|
||||
let manifest: Manifest = load_toml(&world_dir.join("manifest.toml"))?;
|
||||
|
||||
let mut rooms = HashMap::new();
|
||||
let mut npcs = HashMap::new();
|
||||
let mut objects = HashMap::new();
|
||||
|
||||
let entries = std::fs::read_dir(world_dir)
|
||||
.map_err(|e| format!("Cannot read world dir {}: {e}", world_dir.display()))?;
|
||||
let mut races = Vec::new();
|
||||
load_entities_from_dir(&world_dir.join("races"), "race", &mut |id, content| {
|
||||
let rf: RaceFile = toml::from_str(content).map_err(|e| format!("Bad race {id}: {e}"))?;
|
||||
races.push(Race { id, name: rf.name, description: rf.description, stats: rf.stats });
|
||||
Ok(())
|
||||
})?;
|
||||
|
||||
let mut classes = Vec::new();
|
||||
load_entities_from_dir(&world_dir.join("classes"), "class", &mut |id, content| {
|
||||
let cf: ClassFile = toml::from_str(content).map_err(|e| format!("Bad class {id}: {e}"))?;
|
||||
classes.push(Class { id, name: cf.name, description: cf.description, base_stats: cf.base_stats, growth: cf.growth });
|
||||
Ok(())
|
||||
})?;
|
||||
|
||||
let entries = std::fs::read_dir(world_dir)
|
||||
.map_err(|e| format!("Cannot read world dir: {e}"))?;
|
||||
let mut region_dirs: Vec<_> = entries
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false))
|
||||
.filter(|e| { let n = e.file_name().to_string_lossy().to_string(); n != "races" && n != "classes" })
|
||||
.collect();
|
||||
region_dirs.sort_by_key(|e| e.file_name());
|
||||
|
||||
for entry in region_dirs {
|
||||
let region_name = entry.file_name().to_string_lossy().to_string();
|
||||
let region_path = entry.path();
|
||||
|
||||
let region_toml = region_path.join("region.toml");
|
||||
if !region_toml.exists() {
|
||||
log::debug!("Skipping directory without region.toml: {}", region_path.display());
|
||||
continue;
|
||||
}
|
||||
|
||||
let _region_meta: RegionFile = load_toml(®ion_toml)?;
|
||||
if !region_path.join("region.toml").exists() { continue; }
|
||||
let _rm: RegionFile = load_toml(®ion_path.join("region.toml"))?;
|
||||
log::info!("Loading region: {region_name}");
|
||||
|
||||
load_entities_from_dir(
|
||||
®ion_path.join("rooms"),
|
||||
®ion_name,
|
||||
&mut |id, content| {
|
||||
let rf: RoomFile = toml::from_str(content)
|
||||
.map_err(|e| format!("Bad room {id}: {e}"))?;
|
||||
rooms.insert(
|
||||
id.clone(),
|
||||
Room {
|
||||
id: id.clone(),
|
||||
region: region_name.clone(),
|
||||
name: rf.name,
|
||||
description: rf.description,
|
||||
exits: rf.exits,
|
||||
npcs: Vec::new(),
|
||||
objects: Vec::new(),
|
||||
},
|
||||
);
|
||||
Ok(())
|
||||
},
|
||||
)?;
|
||||
load_entities_from_dir(®ion_path.join("rooms"), ®ion_name, &mut |id, content| {
|
||||
let rf: RoomFile = toml::from_str(content).map_err(|e| format!("Bad room {id}: {e}"))?;
|
||||
rooms.insert(id.clone(), Room { id: id.clone(), region: region_name.clone(), name: rf.name, description: rf.description, exits: rf.exits, npcs: Vec::new(), objects: Vec::new() });
|
||||
Ok(())
|
||||
})?;
|
||||
|
||||
load_entities_from_dir(
|
||||
®ion_path.join("npcs"),
|
||||
®ion_name,
|
||||
&mut |id, content| {
|
||||
let nf: NpcFile = toml::from_str(content)
|
||||
.map_err(|e| format!("Bad npc {id}: {e}"))?;
|
||||
npcs.insert(
|
||||
id.clone(),
|
||||
Npc {
|
||||
id: id.clone(),
|
||||
name: nf.name,
|
||||
description: nf.description,
|
||||
room: nf.room.clone(),
|
||||
dialogue: nf.dialogue,
|
||||
},
|
||||
);
|
||||
Ok(())
|
||||
},
|
||||
)?;
|
||||
load_entities_from_dir(®ion_path.join("npcs"), ®ion_name, &mut |id, content| {
|
||||
let nf: NpcFile = toml::from_str(content).map_err(|e| format!("Bad npc {id}: {e}"))?;
|
||||
let combat = nf.combat.map(|c| NpcCombatStats { max_hp: c.max_hp, attack: c.attack, defense: c.defense, xp_reward: c.xp_reward });
|
||||
let greeting = nf.dialogue.and_then(|d| d.greeting);
|
||||
npcs.insert(id.clone(), Npc { id: id.clone(), name: nf.name, description: nf.description, room: nf.room, base_attitude: nf.base_attitude, faction: nf.faction, respawn_secs: nf.respawn_secs, greeting, combat });
|
||||
Ok(())
|
||||
})?;
|
||||
|
||||
load_entities_from_dir(
|
||||
®ion_path.join("objects"),
|
||||
®ion_name,
|
||||
&mut |id, content| {
|
||||
let of: ObjectFile = toml::from_str(content)
|
||||
.map_err(|e| format!("Bad object {id}: {e}"))?;
|
||||
objects.insert(
|
||||
id.clone(),
|
||||
Object {
|
||||
id: id.clone(),
|
||||
name: of.name,
|
||||
description: of.description,
|
||||
room: of.room,
|
||||
kind: of.kind,
|
||||
},
|
||||
);
|
||||
Ok(())
|
||||
},
|
||||
)?;
|
||||
load_entities_from_dir(®ion_path.join("objects"), ®ion_name, &mut |id, content| {
|
||||
let of: ObjectFile = toml::from_str(content).map_err(|e| format!("Bad object {id}: {e}"))?;
|
||||
let stats = of.stats.unwrap_or_default();
|
||||
objects.insert(id.clone(), Object { id: id.clone(), name: of.name, description: of.description, room: of.room, kind: of.kind, takeable: of.takeable, stats: ObjectStats { damage: stats.damage, armor: stats.armor, heal_amount: stats.heal_amount } });
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
|
||||
// Place NPCs and objects into their rooms
|
||||
for npc in npcs.values() {
|
||||
if let Some(room) = rooms.get_mut(&npc.room) {
|
||||
room.npcs.push(npc.id.clone());
|
||||
}
|
||||
}
|
||||
for obj in objects.values() {
|
||||
if let Some(ref room_id) = obj.room {
|
||||
if let Some(room) = rooms.get_mut(room_id) {
|
||||
room.objects.push(obj.id.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
for npc in npcs.values() { if let Some(room) = rooms.get_mut(&npc.room) { room.npcs.push(npc.id.clone()); } }
|
||||
for obj in objects.values() { if let Some(ref rid) = obj.room { if let Some(room) = rooms.get_mut(rid) { room.objects.push(obj.id.clone()); } } }
|
||||
|
||||
// Validate
|
||||
if !rooms.contains_key(&manifest.spawn_room) {
|
||||
return Err(format!(
|
||||
"Spawn room '{}' not found in loaded rooms",
|
||||
manifest.spawn_room
|
||||
));
|
||||
}
|
||||
if !rooms.contains_key(&manifest.spawn_room) { return Err(format!("Spawn room '{}' not found", manifest.spawn_room)); }
|
||||
for room in rooms.values() { for (dir, target) in &room.exits { if !rooms.contains_key(target) { return Err(format!("Room '{}' exit '{dir}' -> unknown '{target}'", room.id)); } } }
|
||||
if races.is_empty() { return Err("No races defined".into()); }
|
||||
if classes.is_empty() { return Err("No classes defined".into()); }
|
||||
|
||||
for room in rooms.values() {
|
||||
for (dir, target) in &room.exits {
|
||||
if !rooms.contains_key(target) {
|
||||
return Err(format!(
|
||||
"Room '{}' exit '{dir}' points to unknown room '{target}'",
|
||||
room.id
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log::info!(
|
||||
"World '{}' loaded: {} rooms, {} npcs, {} objects",
|
||||
manifest.name,
|
||||
rooms.len(),
|
||||
npcs.len(),
|
||||
objects.len()
|
||||
);
|
||||
|
||||
Ok(World {
|
||||
name: manifest.name,
|
||||
spawn_room: manifest.spawn_room,
|
||||
rooms,
|
||||
npcs,
|
||||
objects,
|
||||
})
|
||||
log::info!("World '{}': {} rooms, {} npcs, {} objects, {} races, {} classes", manifest.name, rooms.len(), npcs.len(), objects.len(), races.len(), classes.len());
|
||||
Ok(World { name: manifest.name, spawn_room: manifest.spawn_room, rooms, npcs, objects, races, classes })
|
||||
}
|
||||
|
||||
pub fn get_room(&self, id: &str) -> Option<&Room> {
|
||||
self.rooms.get(id)
|
||||
}
|
||||
|
||||
pub fn get_npc(&self, id: &str) -> Option<&Npc> {
|
||||
self.npcs.get(id)
|
||||
}
|
||||
|
||||
pub fn get_object(&self, id: &str) -> Option<&Object> {
|
||||
self.objects.get(id)
|
||||
}
|
||||
pub fn get_room(&self, id: &str) -> Option<&Room> { self.rooms.get(id) }
|
||||
pub fn get_npc(&self, id: &str) -> Option<&Npc> { self.npcs.get(id) }
|
||||
pub fn get_object(&self, id: &str) -> Option<&Object> { self.objects.get(id) }
|
||||
}
|
||||
|
||||
fn load_toml<T: serde::de::DeserializeOwned>(path: &Path) -> Result<T, String> {
|
||||
let content = std::fs::read_to_string(path)
|
||||
.map_err(|e| format!("Cannot read {}: {e}", path.display()))?;
|
||||
let content = std::fs::read_to_string(path).map_err(|e| format!("Cannot read {}: {e}", path.display()))?;
|
||||
toml::from_str(&content).map_err(|e| format!("Bad TOML in {}: {e}", path.display()))
|
||||
}
|
||||
|
||||
fn load_entities_from_dir(
|
||||
dir: &Path,
|
||||
region: &str,
|
||||
handler: &mut dyn FnMut(String, &str) -> Result<(), String>,
|
||||
) -> Result<(), String> {
|
||||
if !dir.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut entries: Vec<_> = std::fs::read_dir(dir)
|
||||
.map_err(|e| format!("Cannot read {}: {e}", dir.display()))?
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| {
|
||||
e.path()
|
||||
.extension()
|
||||
.map(|ext| ext == "toml")
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.collect();
|
||||
fn load_entities_from_dir(dir: &Path, prefix: &str, handler: &mut dyn FnMut(String, &str) -> Result<(), String>) -> Result<(), String> {
|
||||
if !dir.exists() { return Ok(()); }
|
||||
let mut entries: Vec<_> = std::fs::read_dir(dir).map_err(|e| format!("Cannot read {}: {e}", dir.display()))?
|
||||
.filter_map(|e| e.ok()).filter(|e| e.path().extension().map(|ext| ext == "toml").unwrap_or(false)).collect();
|
||||
entries.sort_by_key(|e| e.file_name());
|
||||
|
||||
for entry in entries {
|
||||
let stem = entry
|
||||
.path()
|
||||
.file_stem()
|
||||
.unwrap()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
let id = format!("{region}:{stem}");
|
||||
let content = std::fs::read_to_string(entry.path())
|
||||
.map_err(|e| format!("Cannot read {}: {e}", entry.path().display()))?;
|
||||
let stem = entry.path().file_stem().unwrap().to_string_lossy().to_string();
|
||||
let id = format!("{prefix}:{stem}");
|
||||
let content = std::fs::read_to_string(entry.path()).map_err(|e| format!("Cannot read {}: {e}", entry.path().display()))?;
|
||||
handler(id, &content)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user