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

@@ -104,6 +104,10 @@ pub struct NpcFile {
#[serde(default)]
pub faction: Option<String>,
#[serde(default)]
pub race: Option<String>,
#[serde(default)]
pub class: Option<String>,
#[serde(default)]
pub respawn_secs: Option<u64>,
#[serde(default)]
pub dialogue: Option<NpcDialogue>,
@@ -244,6 +248,10 @@ pub struct RaceFile {
#[serde(default)]
pub metarace: Option<String>,
#[serde(default)]
pub hidden: bool,
#[serde(default)]
pub default_class: Option<String>,
#[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<String>,
pub fixed_race: Option<String>,
pub fixed_class: Option<String>,
pub respawn_secs: Option<u64>,
pub greeting: Option<String>,
pub combat: Option<NpcCombatStats>,
@@ -437,6 +449,8 @@ pub struct Race {
pub name: String,
pub description: String,
pub metarace: Option<String>,
pub hidden: bool,
pub default_class: Option<String>,
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<String>,
@@ -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());