diff --git a/AGENTS.md b/AGENTS.md index c72779c..a157e10 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -84,11 +84,21 @@ src/ 2. Currently: hostile NPCs auto-engage players in their room 3. Add new behaviors there (e.g. NPC movement, dialogue triggers) +### New NPC +1. Create `world//npcs/.toml` +2. Optional `race` and `class` fields pin the NPC to a specific race/class +3. If omitted, race is randomly chosen from non-hidden races at spawn time +4. If class is omitted, the race's `default_class` is used; if that's also unset, a random non-hidden class is picked +5. Race/class are re-rolled on each respawn for NPCs without fixed values +6. For animals: use `race = "race:beast"` and `class = "class:creature"` + ### New race 1. Create `world/races/.toml` — see `dragon.toml` for a complex example 2. Required: `name`, `description` 3. All other fields have sensible defaults via `#[serde(default)]` 4. Key sections: `[stats]` (7 stats), `[body]` (size, weight, slots), `[natural]` (armor, attacks), `[resistances]`, `[regen]`, `[misc]`, `[guild_compatibility]` +5. Set `hidden = true` for NPC-only races (e.g., `beast`) to exclude from character creation +6. Set `default_class` to reference a class ID for the race's default NPC class 5. `traits` and `disadvantages` are free-form string arrays 6. If `[body] slots` is empty, defaults to humanoid slots 7. Natural attacks: use `[natural.attacks.]` with `damage`, `type`, optional `cooldown_ticks` diff --git a/TESTING.md b/TESTING.md index 177cfd5..10e6f5d 100644 --- a/TESTING.md +++ b/TESTING.md @@ -151,6 +151,20 @@ Run through these checks before every commit to ensure consistent feature covera - [ ] Server remains responsive to immediate commands between ticks - [ ] Multiple players in separate combats are processed independently per tick +## NPC Race & Class +- [ ] NPCs with fixed race/class in TOML show that race/class +- [ ] NPCs without race get a random non-hidden race at spawn +- [ ] NPCs without class: race default_class used, or random non-hidden if no default +- [ ] `look ` shows NPC race and class +- [ ] `examine ` shows NPC race and class +- [ ] Rat shows "Beast Creature" (fixed race/class) +- [ ] Barkeep shows a random race + Peasant (no fixed race, human default class) +- [ ] Thief shows random race + Rogue (no fixed race, fixed class) +- [ ] Guard shows random race + Warrior (no fixed race, fixed class) +- [ ] On NPC respawn, race/class re-rolled if not fixed in TOML +- [ ] Hidden races (Beast) do not appear in character creation +- [ ] Hidden classes (Peasant, Creature) do not appear in character creation + ## Race System - [ ] Existing races (Human, Elf, Dwarf, Orc, Halfling) load with expanded fields - [ ] Dragon race loads with custom body, natural attacks, resistances, traits diff --git a/src/chargen.rs b/src/chargen.rs index 2e43295..3785846 100644 --- a/src/chargen.rs +++ b/src/chargen.rs @@ -29,7 +29,8 @@ impl ChargenState { "\r\n{}\r\n\r\n", ansi::bold("=== Choose Your Race ===") )); - for (i, race) in world.races.iter().enumerate() { + let visible_races: Vec<_> = world.races.iter().filter(|r| !r.hidden).collect(); + for (i, race) in visible_races.iter().enumerate() { let mods = format_stat_mods(&race.stats); out.push_str(&format!( " {}{}.{} {} {}\r\n {}\r\n", @@ -82,7 +83,8 @@ impl ChargenState { "\r\n{}\r\n\r\n", ansi::bold("=== Choose Your Class ===") )); - for (i, class) in world.classes.iter().enumerate() { + let visible_classes: Vec<_> = world.classes.iter().filter(|c| !c.hidden).collect(); + for (i, class) in visible_classes.iter().enumerate() { let guild_info = class.guild.as_ref() .and_then(|gid| world.guilds.get(gid)) .map(|g| format!(" → joins {}", ansi::color(ansi::YELLOW, &g.name))) @@ -126,7 +128,7 @@ impl ChargenState { ChargenStep::AwaitingRace => { let race = find_by_input( input, - &world.races.iter().map(|r| (r.id.clone(), r.name.clone())).collect::>(), + &world.races.iter().filter(|r| !r.hidden).map(|r| (r.id.clone(), r.name.clone())).collect::>(), ); match race { Some((id, name)) => { @@ -149,6 +151,7 @@ impl ChargenState { &world .classes .iter() + .filter(|c| !c.hidden) .map(|c| (c.id.clone(), c.name.clone())) .collect::>(), ); diff --git a/src/commands.rs b/src/commands.rs index 821d75f..7701214 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -254,17 +254,23 @@ async fn cmd_look(pid: usize, target: &str, state: &SharedState) -> CommandResul for nid in &room.npcs { if let Some(npc) = st.world.get_npc(nid) { if npc.name.to_lowercase().contains(&low) { - let alive = st.npc_instances.get(nid).map(|i| i.alive).unwrap_or(true); + let inst = st.npc_instances.get(nid); + let alive = inst.map(|i| i.alive).unwrap_or(true); let att = st.npc_attitude_toward(nid, &pname); let mut out = format!( "\r\n{}\r\n {}\r\n", ansi::bold(&npc.name), npc.description ); + if let Some(inst) = inst { + let rname = st.world.races.iter().find(|r| r.id == inst.race_id).map(|r| r.name.as_str()).unwrap_or("???"); + let cname = st.world.classes.iter().find(|c| c.id == inst.class_id).map(|c| c.name.as_str()).unwrap_or("???"); + out.push_str(&format!(" {} {}\r\n", ansi::color(ansi::CYAN, rname), ansi::color(ansi::DIM, cname))); + } if !alive { out.push_str(&format!(" {}\r\n", ansi::color(ansi::RED, "(dead)"))); } else if let Some(ref c) = npc.combat { - let hp = st.npc_instances.get(nid).map(|i| i.hp).unwrap_or(c.max_hp); + let hp = inst.map(|i| i.hp).unwrap_or(c.max_hp); out.push_str(&format!( " HP: {}/{} | ATK: {} | DEF: {}\r\n", hp, c.max_hp, c.attack, c.defense @@ -897,14 +903,20 @@ async fn cmd_examine(pid: usize, target: &str, state: &SharedState) -> CommandRe for nid in &room.npcs { if let Some(npc) = st.world.get_npc(nid) { if npc.name.to_lowercase().contains(&low) { - let alive = st.npc_instances.get(nid).map(|i| i.alive).unwrap_or(true); + let inst = st.npc_instances.get(nid); + let alive = inst.map(|i| i.alive).unwrap_or(true); let att = st.npc_attitude_toward(nid, pname); let mut out = format!("\r\n{}\r\n {}\r\n", ansi::bold(&npc.name), npc.description); + if let Some(inst) = inst { + let rname = st.world.races.iter().find(|r| r.id == inst.race_id).map(|r| r.name.as_str()).unwrap_or("???"); + let cname = st.world.classes.iter().find(|c| c.id == inst.class_id).map(|c| c.name.as_str()).unwrap_or("???"); + out.push_str(&format!(" {} {}\r\n", ansi::color(ansi::CYAN, rname), ansi::color(ansi::DIM, cname))); + } if !alive { out.push_str(&format!(" {}\r\n", ansi::color(ansi::RED, "(dead)"))); } else if let Some(ref c) = npc.combat { - let hp = st.npc_instances.get(nid).map(|i| i.hp).unwrap_or(c.max_hp); + let hp = inst.map(|i| i.hp).unwrap_or(c.max_hp); out.push_str(&format!( " HP: {}/{} | ATK: {} | DEF: {}\r\n", hp, c.max_hp, c.attack, c.defense diff --git a/src/game.rs b/src/game.rs index 183ae5d..5994cc2 100644 --- a/src/game.rs +++ b/src/game.rs @@ -7,7 +7,7 @@ use russh::server::Handle; use russh::ChannelId; use crate::db::{GameDb, SavedPlayer}; -use crate::world::{Attitude, Object, World}; +use crate::world::{Attitude, Class, Object, Race, World}; #[derive(Clone)] pub struct PlayerStats { @@ -92,6 +92,8 @@ pub struct NpcInstance { pub hp: i32, pub alive: bool, pub death_time: Option, + pub race_id: String, + pub class_id: String, } pub struct PlayerConnection { @@ -141,31 +143,83 @@ pub struct GameState { pub type SharedState = Arc>; -impl GameState { - pub fn new(world: World, db: Arc) -> 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, - }, - ); +pub fn resolve_npc_race_class( + fixed_race: &Option, + fixed_class: &Option, + world: &World, + rng: &mut XorShift64, +) -> (String, String) { + let race_id = match fixed_race { + Some(rid) if world.races.iter().any(|r| r.id == *rid) => rid.clone(), + _ => { + // Pick a random non-hidden race + let candidates: Vec<&Race> = world.races.iter().filter(|r| !r.hidden).collect(); + if candidates.is_empty() { + world.races.first().map(|r| r.id.clone()).unwrap_or_default() + } else { + let idx = rng.next_range(0, candidates.len() as i32) as usize; + candidates[idx].id.clone() } } + }; + + let class_id = match fixed_class { + Some(cid) if world.classes.iter().any(|c| c.id == *cid) => cid.clone(), + _ => { + let race = world.races.iter().find(|r| r.id == race_id); + // Try race default_class first + if let Some(ref dc) = race.and_then(|r| r.default_class.clone()) { + if world.classes.iter().any(|c| c.id == *dc) { + return (race_id, dc.clone()); + } + } + // No default → pick random non-hidden class compatible with race + let restricted = race + .map(|r| &r.guild_compatibility.restricted) + .cloned() + .unwrap_or_default(); + let candidates: Vec<&Class> = world.classes.iter() + .filter(|c| !c.hidden) + .filter(|c| { + c.guild.as_ref().map(|gid| !restricted.contains(gid)).unwrap_or(true) + }) + .collect(); + if candidates.is_empty() { + world.classes.first().map(|c| c.id.clone()).unwrap_or_default() + } else { + let idx = rng.next_range(0, candidates.len() as i32) as usize; + candidates[idx].id.clone() + } + } + }; + + (race_id, class_id) +} + +impl GameState { + pub fn new(world: World, db: Arc) -> Self { let seed = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_nanos() as u64; + let mut rng = XorShift64::new(seed); + let mut npc_instances = HashMap::new(); + for npc in world.npcs.values() { + let (race_id, class_id) = resolve_npc_race_class( + &npc.fixed_race, &npc.fixed_class, &world, &mut rng, + ); + let hp = npc.combat.as_ref().map(|c| c.max_hp).unwrap_or(20); + npc_instances.insert( + npc.id.clone(), + NpcInstance { hp, alive: true, death_time: None, race_id, class_id }, + ); + } GameState { world, db, players: HashMap::new(), npc_instances, - rng: XorShift64::new(seed), + rng, tick_count: 0, } } @@ -398,11 +452,14 @@ impl GameState { 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) { + let npc_ids: Vec = self.npc_instances.keys().cloned().collect(); + for npc_id in npc_ids { + let instance = match self.npc_instances.get(&npc_id) { + Some(i) => i, + None => continue, + }; + if instance.alive { continue; } + let npc = match self.world.npcs.get(&npc_id) { Some(n) => n, None => continue, }; @@ -410,13 +467,22 @@ impl GameState { 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; - } + let should_respawn = instance.death_time + .map(|dt| now.duration_since(dt).as_secs() >= respawn_secs) + .unwrap_or(false); + if should_respawn { + let hp = npc.combat.as_ref().map(|c| c.max_hp).unwrap_or(20); + let fixed_race = npc.fixed_race.clone(); + let fixed_class = npc.fixed_class.clone(); + let (race_id, class_id) = resolve_npc_race_class( + &fixed_race, &fixed_class, &self.world, &mut self.rng, + ); + if let Some(inst) = self.npc_instances.get_mut(&npc_id) { + inst.hp = hp; + inst.alive = true; + inst.death_time = None; + inst.race_id = race_id; + inst.class_id = class_id; } } } diff --git a/src/world.rs b/src/world.rs index f9df0de..2c7c9da 100644 --- a/src/world.rs +++ b/src/world.rs @@ -104,6 +104,10 @@ pub struct NpcFile { #[serde(default)] pub faction: Option, #[serde(default)] + pub race: Option, + #[serde(default)] + pub class: Option, + #[serde(default)] pub respawn_secs: Option, #[serde(default)] pub dialogue: Option, @@ -244,6 +248,10 @@ pub struct RaceFile { #[serde(default)] pub metarace: Option, #[serde(default)] + pub hidden: bool, + #[serde(default)] + pub default_class: Option, + #[serde(default)] pub stats: StatModifiers, #[serde(default)] pub body: BodyFile, @@ -290,6 +298,8 @@ pub struct ClassFile { pub name: String, pub description: String, #[serde(default)] + pub hidden: bool, + #[serde(default)] pub base_stats: ClassBaseStats, #[serde(default)] pub growth: ClassGrowth, @@ -395,6 +405,8 @@ pub struct Npc { pub room: String, pub base_attitude: Attitude, pub faction: Option, + pub fixed_race: Option, + pub fixed_class: Option, pub respawn_secs: Option, pub greeting: Option, pub combat: Option, @@ -437,6 +449,8 @@ pub struct Race { pub name: String, pub description: String, pub metarace: Option, + pub hidden: bool, + pub default_class: Option, pub stats: StatModifiers, pub size: String, pub weight: i32, @@ -462,6 +476,7 @@ pub struct Class { pub id: String, pub name: String, pub description: String, + pub hidden: bool, pub base_stats: ClassBaseStats, pub growth: ClassGrowth, pub guild: Option, @@ -535,6 +550,8 @@ impl World { races.push(Race { id, name: rf.name, description: rf.description, metarace: rf.metarace, + hidden: rf.hidden, + default_class: rf.default_class, stats: rf.stats, size: rf.body.size, weight: rf.body.weight, @@ -560,7 +577,7 @@ impl World { 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, guild: cf.guild }); + classes.push(Class { id, name: cf.name, description: cf.description, hidden: cf.hidden, base_stats: cf.base_stats, growth: cf.growth, guild: cf.guild }); Ok(()) })?; @@ -623,7 +640,12 @@ impl World { let combat = Some(nf.combat.map(|c| NpcCombatStats { max_hp: c.max_hp, attack: c.attack, defense: c.defense, xp_reward: c.xp_reward }) .unwrap_or(NpcCombatStats { max_hp: 20, attack: 4, defense: 2, xp_reward: 5 })); 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 }); + 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, + fixed_race: nf.race, fixed_class: nf.class, + respawn_secs: nf.respawn_secs, greeting, combat, + }); Ok(()) })?; @@ -644,8 +666,8 @@ impl World { 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()); } + if races.iter().filter(|r| !r.hidden).count() == 0 { return Err("No playable (non-hidden) races defined".into()); } + if classes.iter().filter(|c| !c.hidden).count() == 0 { return Err("No playable (non-hidden) classes defined".into()); } log::info!("World '{}': {} rooms, {} npcs, {} objects, {} races, {} classes, {} guilds, {} spells", manifest.name, rooms.len(), npcs.len(), objects.len(), races.len(), classes.len(), guilds.len(), spells.len()); diff --git a/world/classes/creature.toml b/world/classes/creature.toml new file mode 100644 index 0000000..b59342b --- /dev/null +++ b/world/classes/creature.toml @@ -0,0 +1,13 @@ +name = "Creature" +description = "A wild thing that fights on instinct alone." +hidden = true + +[base_stats] +max_hp = 30 +attack = 6 +defense = 2 + +[growth] +hp_per_level = 4 +attack_per_level = 2 +defense_per_level = 0 diff --git a/world/classes/peasant.toml b/world/classes/peasant.toml new file mode 100644 index 0000000..675b7e9 --- /dev/null +++ b/world/classes/peasant.toml @@ -0,0 +1,13 @@ +name = "Peasant" +description = "A common folk with no particular training or aptitude for adventure." +hidden = true + +[base_stats] +max_hp = 50 +attack = 4 +defense = 4 + +[growth] +hp_per_level = 5 +attack_per_level = 1 +defense_per_level = 1 diff --git a/world/races/beast.toml b/world/races/beast.toml new file mode 100644 index 0000000..8e8ecee --- /dev/null +++ b/world/races/beast.toml @@ -0,0 +1,40 @@ +name = "Beast" +description = "A wild creature driven by instinct and survival." +metarace = "animal" +hidden = true +default_class = "class:creature" + +[stats] +strength = 0 +dexterity = 1 +constitution = 1 +intelligence = -4 +wisdom = -2 +perception = 2 +charisma = -3 + +[body] +size = "small" +weight = 10 +slots = [] + +[natural] +armor = 0 + +[natural.attacks.bite] +damage = 4 +type = "physical" + +traits = ["feral", "keen_senses"] +disadvantages = ["no_speech", "no_equipment"] + +[regen] +hp = 1.2 +mana = 0.0 +endurance = 1.5 + +[misc] +lifespan = 15 +diet = "omnivore" +xp_rate = 1.0 +vision = ["normal", "low_light"] diff --git a/world/races/dwarf.toml b/world/races/dwarf.toml index 70489ac..f95696d 100644 --- a/world/races/dwarf.toml +++ b/world/races/dwarf.toml @@ -1,5 +1,6 @@ name = "Dwarf" description = "Stout and unyielding, dwarves are born of stone and stubbornness." +default_class = "class:peasant" [stats] strength = 1 diff --git a/world/races/elf.toml b/world/races/elf.toml index 8ffeb0e..06750fe 100644 --- a/world/races/elf.toml +++ b/world/races/elf.toml @@ -1,5 +1,6 @@ name = "Elf" description = "Graceful and keen-eyed, elves possess an innate affinity for magic." +default_class = "class:peasant" [stats] strength = -1 diff --git a/world/races/halfling.toml b/world/races/halfling.toml index d9c11a2..a3bd98c 100644 --- a/world/races/halfling.toml +++ b/world/races/halfling.toml @@ -1,5 +1,6 @@ name = "Halfling" description = "Small and nimble, halflings slip through danger with uncanny luck." +default_class = "class:peasant" [stats] strength = -2 diff --git a/world/races/human.toml b/world/races/human.toml index 16b74f0..7947352 100644 --- a/world/races/human.toml +++ b/world/races/human.toml @@ -1,5 +1,6 @@ name = "Human" description = "Versatile and adaptable, humans excel through sheer determination." +default_class = "class:peasant" [stats] strength = 0 diff --git a/world/races/orc.toml b/world/races/orc.toml index ab9bbbd..89cd0c4 100644 --- a/world/races/orc.toml +++ b/world/races/orc.toml @@ -1,5 +1,6 @@ name = "Orc" description = "Powerful and fierce, orcs channel raw fury into everything they do." +default_class = "class:peasant" [stats] strength = 3 diff --git a/world/town/npcs/guard.toml b/world/town/npcs/guard.toml index 6a3ec34..ce7597b 100644 --- a/world/town/npcs/guard.toml +++ b/world/town/npcs/guard.toml @@ -2,6 +2,7 @@ name = "Town Guard" description = "A bored-looking guard in dented chainmail. He leans on his spear and watches passersby." room = "town:gate" base_attitude = "neutral" +class = "class:warrior" [dialogue] greeting = "Move along. Nothing to see here." diff --git a/world/town/npcs/rat.toml b/world/town/npcs/rat.toml index dce808d..4ac0b46 100644 --- a/world/town/npcs/rat.toml +++ b/world/town/npcs/rat.toml @@ -2,6 +2,8 @@ name = "Giant Rat" description = "A mangy rat the size of a small dog. Its eyes gleam with feral hunger." room = "town:cellar" base_attitude = "hostile" +race = "race:beast" +class = "class:creature" respawn_secs = 60 [combat] diff --git a/world/town/npcs/thief.toml b/world/town/npcs/thief.toml index 186eb09..514c97a 100644 --- a/world/town/npcs/thief.toml +++ b/world/town/npcs/thief.toml @@ -3,6 +3,7 @@ description = "A cloaked figure lurking in the darkness, fingers twitching near room = "town:dark_alley" base_attitude = "aggressive" faction = "underworld" +class = "class:rogue" respawn_secs = 90 [combat]