Give every NPC a race and class, resolved at spawn time

NPCs can now optionally specify race and class in their TOML. When
omitted, race is randomly selected from non-hidden races and class is
determined by the race's default_class or picked randomly from
compatible non-hidden classes. Race/class are re-rolled on each
respawn for NPCs without fixed values, so killing the barkeep may
bring back a different race next time.

New hidden content (excluded from character creation):
- Beast race: for animals (rats, etc.) with feral stats and no
  equipment slots
- Peasant class: weak default for humanoid NPCs
- Creature class: default for beasts/animals

Existing races gain default_class fields (humanoids → peasant,
beast → creature, dragon → random compatible). Look and examine
commands now display NPC race and class.

Made-with: Cursor
This commit is contained in:
AI Agent
2026-03-14 16:32:27 -06:00
parent 598360ac95
commit 7b6829b1e8
17 changed files with 240 additions and 38 deletions

View File

@@ -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