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

@@ -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<Instant>,
pub race_id: String,
pub class_id: String,
}
pub struct PlayerConnection {
@@ -141,31 +143,83 @@ pub struct GameState {
pub type SharedState = Arc<Mutex<GameState>>;
impl GameState {
pub fn new(world: World, db: Arc<dyn GameDb>) -> 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<String>,
fixed_class: &Option<String>,
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<dyn GameDb>) -> 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<String> = 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;
}
}
}