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:
10
AGENTS.md
10
AGENTS.md
@@ -84,11 +84,21 @@ src/
|
|||||||
2. Currently: hostile NPCs auto-engage players in their room
|
2. Currently: hostile NPCs auto-engage players in their room
|
||||||
3. Add new behaviors there (e.g. NPC movement, dialogue triggers)
|
3. Add new behaviors there (e.g. NPC movement, dialogue triggers)
|
||||||
|
|
||||||
|
### New NPC
|
||||||
|
1. Create `world/<region>/npcs/<name>.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
|
### New race
|
||||||
1. Create `world/races/<name>.toml` — see `dragon.toml` for a complex example
|
1. Create `world/races/<name>.toml` — see `dragon.toml` for a complex example
|
||||||
2. Required: `name`, `description`
|
2. Required: `name`, `description`
|
||||||
3. All other fields have sensible defaults via `#[serde(default)]`
|
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]`
|
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
|
5. `traits` and `disadvantages` are free-form string arrays
|
||||||
6. If `[body] slots` is empty, defaults to humanoid slots
|
6. If `[body] slots` is empty, defaults to humanoid slots
|
||||||
7. Natural attacks: use `[natural.attacks.<name>]` with `damage`, `type`, optional `cooldown_ticks`
|
7. Natural attacks: use `[natural.attacks.<name>]` with `damage`, `type`, optional `cooldown_ticks`
|
||||||
|
|||||||
14
TESTING.md
14
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
|
- [ ] Server remains responsive to immediate commands between ticks
|
||||||
- [ ] Multiple players in separate combats are processed independently per tick
|
- [ ] 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 <npc>` shows NPC race and class
|
||||||
|
- [ ] `examine <npc>` 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
|
## Race System
|
||||||
- [ ] Existing races (Human, Elf, Dwarf, Orc, Halfling) load with expanded fields
|
- [ ] Existing races (Human, Elf, Dwarf, Orc, Halfling) load with expanded fields
|
||||||
- [ ] Dragon race loads with custom body, natural attacks, resistances, traits
|
- [ ] Dragon race loads with custom body, natural attacks, resistances, traits
|
||||||
|
|||||||
@@ -29,7 +29,8 @@ impl ChargenState {
|
|||||||
"\r\n{}\r\n\r\n",
|
"\r\n{}\r\n\r\n",
|
||||||
ansi::bold("=== Choose Your Race ===")
|
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);
|
let mods = format_stat_mods(&race.stats);
|
||||||
out.push_str(&format!(
|
out.push_str(&format!(
|
||||||
" {}{}.{} {} {}\r\n {}\r\n",
|
" {}{}.{} {} {}\r\n {}\r\n",
|
||||||
@@ -82,7 +83,8 @@ impl ChargenState {
|
|||||||
"\r\n{}\r\n\r\n",
|
"\r\n{}\r\n\r\n",
|
||||||
ansi::bold("=== Choose Your Class ===")
|
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()
|
let guild_info = class.guild.as_ref()
|
||||||
.and_then(|gid| world.guilds.get(gid))
|
.and_then(|gid| world.guilds.get(gid))
|
||||||
.map(|g| format!(" → joins {}", ansi::color(ansi::YELLOW, &g.name)))
|
.map(|g| format!(" → joins {}", ansi::color(ansi::YELLOW, &g.name)))
|
||||||
@@ -126,7 +128,7 @@ impl ChargenState {
|
|||||||
ChargenStep::AwaitingRace => {
|
ChargenStep::AwaitingRace => {
|
||||||
let race = find_by_input(
|
let race = find_by_input(
|
||||||
input,
|
input,
|
||||||
&world.races.iter().map(|r| (r.id.clone(), r.name.clone())).collect::<Vec<_>>(),
|
&world.races.iter().filter(|r| !r.hidden).map(|r| (r.id.clone(), r.name.clone())).collect::<Vec<_>>(),
|
||||||
);
|
);
|
||||||
match race {
|
match race {
|
||||||
Some((id, name)) => {
|
Some((id, name)) => {
|
||||||
@@ -149,6 +151,7 @@ impl ChargenState {
|
|||||||
&world
|
&world
|
||||||
.classes
|
.classes
|
||||||
.iter()
|
.iter()
|
||||||
|
.filter(|c| !c.hidden)
|
||||||
.map(|c| (c.id.clone(), c.name.clone()))
|
.map(|c| (c.id.clone(), c.name.clone()))
|
||||||
.collect::<Vec<_>>(),
|
.collect::<Vec<_>>(),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -254,17 +254,23 @@ async fn cmd_look(pid: usize, target: &str, state: &SharedState) -> CommandResul
|
|||||||
for nid in &room.npcs {
|
for nid in &room.npcs {
|
||||||
if let Some(npc) = st.world.get_npc(nid) {
|
if let Some(npc) = st.world.get_npc(nid) {
|
||||||
if npc.name.to_lowercase().contains(&low) {
|
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 att = st.npc_attitude_toward(nid, &pname);
|
||||||
let mut out = format!(
|
let mut out = format!(
|
||||||
"\r\n{}\r\n {}\r\n",
|
"\r\n{}\r\n {}\r\n",
|
||||||
ansi::bold(&npc.name),
|
ansi::bold(&npc.name),
|
||||||
npc.description
|
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 {
|
if !alive {
|
||||||
out.push_str(&format!(" {}\r\n", ansi::color(ansi::RED, "(dead)")));
|
out.push_str(&format!(" {}\r\n", ansi::color(ansi::RED, "(dead)")));
|
||||||
} else if let Some(ref c) = npc.combat {
|
} 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!(
|
out.push_str(&format!(
|
||||||
" HP: {}/{} | ATK: {} | DEF: {}\r\n",
|
" HP: {}/{} | ATK: {} | DEF: {}\r\n",
|
||||||
hp, c.max_hp, c.attack, c.defense
|
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 {
|
for nid in &room.npcs {
|
||||||
if let Some(npc) = st.world.get_npc(nid) {
|
if let Some(npc) = st.world.get_npc(nid) {
|
||||||
if npc.name.to_lowercase().contains(&low) {
|
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 att = st.npc_attitude_toward(nid, pname);
|
||||||
let mut out =
|
let mut out =
|
||||||
format!("\r\n{}\r\n {}\r\n", ansi::bold(&npc.name), npc.description);
|
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 {
|
if !alive {
|
||||||
out.push_str(&format!(" {}\r\n", ansi::color(ansi::RED, "(dead)")));
|
out.push_str(&format!(" {}\r\n", ansi::color(ansi::RED, "(dead)")));
|
||||||
} else if let Some(ref c) = npc.combat {
|
} 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!(
|
out.push_str(&format!(
|
||||||
" HP: {}/{} | ATK: {} | DEF: {}\r\n",
|
" HP: {}/{} | ATK: {} | DEF: {}\r\n",
|
||||||
hp, c.max_hp, c.attack, c.defense
|
hp, c.max_hp, c.attack, c.defense
|
||||||
|
|||||||
120
src/game.rs
120
src/game.rs
@@ -7,7 +7,7 @@ use russh::server::Handle;
|
|||||||
use russh::ChannelId;
|
use russh::ChannelId;
|
||||||
|
|
||||||
use crate::db::{GameDb, SavedPlayer};
|
use crate::db::{GameDb, SavedPlayer};
|
||||||
use crate::world::{Attitude, Object, World};
|
use crate::world::{Attitude, Class, Object, Race, World};
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct PlayerStats {
|
pub struct PlayerStats {
|
||||||
@@ -92,6 +92,8 @@ pub struct NpcInstance {
|
|||||||
pub hp: i32,
|
pub hp: i32,
|
||||||
pub alive: bool,
|
pub alive: bool,
|
||||||
pub death_time: Option<Instant>,
|
pub death_time: Option<Instant>,
|
||||||
|
pub race_id: String,
|
||||||
|
pub class_id: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct PlayerConnection {
|
pub struct PlayerConnection {
|
||||||
@@ -141,31 +143,83 @@ pub struct GameState {
|
|||||||
|
|
||||||
pub type SharedState = Arc<Mutex<GameState>>;
|
pub type SharedState = Arc<Mutex<GameState>>;
|
||||||
|
|
||||||
|
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 {
|
impl GameState {
|
||||||
pub fn new(world: World, db: Arc<dyn GameDb>) -> Self {
|
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,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let seed = std::time::SystemTime::now()
|
let seed = std::time::SystemTime::now()
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.as_nanos() as u64;
|
.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 {
|
GameState {
|
||||||
world,
|
world,
|
||||||
db,
|
db,
|
||||||
players: HashMap::new(),
|
players: HashMap::new(),
|
||||||
npc_instances,
|
npc_instances,
|
||||||
rng: XorShift64::new(seed),
|
rng,
|
||||||
tick_count: 0,
|
tick_count: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -398,11 +452,14 @@ impl GameState {
|
|||||||
|
|
||||||
pub fn check_respawns(&mut self) {
|
pub fn check_respawns(&mut self) {
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
for (npc_id, instance) in self.npc_instances.iter_mut() {
|
let npc_ids: Vec<String> = self.npc_instances.keys().cloned().collect();
|
||||||
if instance.alive {
|
for npc_id in npc_ids {
|
||||||
continue;
|
let instance = match self.npc_instances.get(&npc_id) {
|
||||||
}
|
Some(i) => i,
|
||||||
let npc = match self.world.npcs.get(npc_id) {
|
None => continue,
|
||||||
|
};
|
||||||
|
if instance.alive { continue; }
|
||||||
|
let npc = match self.world.npcs.get(&npc_id) {
|
||||||
Some(n) => n,
|
Some(n) => n,
|
||||||
None => continue,
|
None => continue,
|
||||||
};
|
};
|
||||||
@@ -410,13 +467,22 @@ impl GameState {
|
|||||||
Some(s) => s,
|
Some(s) => s,
|
||||||
None => continue,
|
None => continue,
|
||||||
};
|
};
|
||||||
if let Some(death_time) = instance.death_time {
|
let should_respawn = instance.death_time
|
||||||
if now.duration_since(death_time).as_secs() >= respawn_secs {
|
.map(|dt| now.duration_since(dt).as_secs() >= respawn_secs)
|
||||||
if let Some(ref combat) = npc.combat {
|
.unwrap_or(false);
|
||||||
instance.hp = combat.max_hp;
|
if should_respawn {
|
||||||
instance.alive = true;
|
let hp = npc.combat.as_ref().map(|c| c.max_hp).unwrap_or(20);
|
||||||
instance.death_time = None;
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
30
src/world.rs
30
src/world.rs
@@ -104,6 +104,10 @@ pub struct NpcFile {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub faction: Option<String>,
|
pub faction: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
pub race: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub class: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
pub respawn_secs: Option<u64>,
|
pub respawn_secs: Option<u64>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub dialogue: Option<NpcDialogue>,
|
pub dialogue: Option<NpcDialogue>,
|
||||||
@@ -244,6 +248,10 @@ pub struct RaceFile {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub metarace: Option<String>,
|
pub metarace: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
pub hidden: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub default_class: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
pub stats: StatModifiers,
|
pub stats: StatModifiers,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub body: BodyFile,
|
pub body: BodyFile,
|
||||||
@@ -290,6 +298,8 @@ pub struct ClassFile {
|
|||||||
pub name: String,
|
pub name: String,
|
||||||
pub description: String,
|
pub description: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
pub hidden: bool,
|
||||||
|
#[serde(default)]
|
||||||
pub base_stats: ClassBaseStats,
|
pub base_stats: ClassBaseStats,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub growth: ClassGrowth,
|
pub growth: ClassGrowth,
|
||||||
@@ -395,6 +405,8 @@ pub struct Npc {
|
|||||||
pub room: String,
|
pub room: String,
|
||||||
pub base_attitude: Attitude,
|
pub base_attitude: Attitude,
|
||||||
pub faction: Option<String>,
|
pub faction: Option<String>,
|
||||||
|
pub fixed_race: Option<String>,
|
||||||
|
pub fixed_class: Option<String>,
|
||||||
pub respawn_secs: Option<u64>,
|
pub respawn_secs: Option<u64>,
|
||||||
pub greeting: Option<String>,
|
pub greeting: Option<String>,
|
||||||
pub combat: Option<NpcCombatStats>,
|
pub combat: Option<NpcCombatStats>,
|
||||||
@@ -437,6 +449,8 @@ pub struct Race {
|
|||||||
pub name: String,
|
pub name: String,
|
||||||
pub description: String,
|
pub description: String,
|
||||||
pub metarace: Option<String>,
|
pub metarace: Option<String>,
|
||||||
|
pub hidden: bool,
|
||||||
|
pub default_class: Option<String>,
|
||||||
pub stats: StatModifiers,
|
pub stats: StatModifiers,
|
||||||
pub size: String,
|
pub size: String,
|
||||||
pub weight: i32,
|
pub weight: i32,
|
||||||
@@ -462,6 +476,7 @@ pub struct Class {
|
|||||||
pub id: String,
|
pub id: String,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub description: String,
|
pub description: String,
|
||||||
|
pub hidden: bool,
|
||||||
pub base_stats: ClassBaseStats,
|
pub base_stats: ClassBaseStats,
|
||||||
pub growth: ClassGrowth,
|
pub growth: ClassGrowth,
|
||||||
pub guild: Option<String>,
|
pub guild: Option<String>,
|
||||||
@@ -535,6 +550,8 @@ impl World {
|
|||||||
races.push(Race {
|
races.push(Race {
|
||||||
id, name: rf.name, description: rf.description,
|
id, name: rf.name, description: rf.description,
|
||||||
metarace: rf.metarace,
|
metarace: rf.metarace,
|
||||||
|
hidden: rf.hidden,
|
||||||
|
default_class: rf.default_class,
|
||||||
stats: rf.stats,
|
stats: rf.stats,
|
||||||
size: rf.body.size,
|
size: rf.body.size,
|
||||||
weight: rf.body.weight,
|
weight: rf.body.weight,
|
||||||
@@ -560,7 +577,7 @@ impl World {
|
|||||||
let mut classes = Vec::new();
|
let mut classes = Vec::new();
|
||||||
load_entities_from_dir(&world_dir.join("classes"), "class", &mut |id, content| {
|
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}"))?;
|
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(())
|
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 })
|
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 }));
|
.unwrap_or(NpcCombatStats { max_hp: 20, attack: 4, defense: 2, xp_reward: 5 }));
|
||||||
let greeting = nf.dialogue.and_then(|d| d.greeting);
|
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(())
|
Ok(())
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
@@ -644,8 +666,8 @@ impl World {
|
|||||||
|
|
||||||
if !rooms.contains_key(&manifest.spawn_room) { return Err(format!("Spawn room '{}' not found", 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)); } } }
|
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 races.iter().filter(|r| !r.hidden).count() == 0 { return Err("No playable (non-hidden) races defined".into()); }
|
||||||
if classes.is_empty() { return Err("No classes 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",
|
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());
|
manifest.name, rooms.len(), npcs.len(), objects.len(), races.len(), classes.len(), guilds.len(), spells.len());
|
||||||
|
|||||||
13
world/classes/creature.toml
Normal file
13
world/classes/creature.toml
Normal file
@@ -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
|
||||||
13
world/classes/peasant.toml
Normal file
13
world/classes/peasant.toml
Normal file
@@ -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
|
||||||
40
world/races/beast.toml
Normal file
40
world/races/beast.toml
Normal file
@@ -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"]
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
name = "Dwarf"
|
name = "Dwarf"
|
||||||
description = "Stout and unyielding, dwarves are born of stone and stubbornness."
|
description = "Stout and unyielding, dwarves are born of stone and stubbornness."
|
||||||
|
default_class = "class:peasant"
|
||||||
|
|
||||||
[stats]
|
[stats]
|
||||||
strength = 1
|
strength = 1
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
name = "Elf"
|
name = "Elf"
|
||||||
description = "Graceful and keen-eyed, elves possess an innate affinity for magic."
|
description = "Graceful and keen-eyed, elves possess an innate affinity for magic."
|
||||||
|
default_class = "class:peasant"
|
||||||
|
|
||||||
[stats]
|
[stats]
|
||||||
strength = -1
|
strength = -1
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
name = "Halfling"
|
name = "Halfling"
|
||||||
description = "Small and nimble, halflings slip through danger with uncanny luck."
|
description = "Small and nimble, halflings slip through danger with uncanny luck."
|
||||||
|
default_class = "class:peasant"
|
||||||
|
|
||||||
[stats]
|
[stats]
|
||||||
strength = -2
|
strength = -2
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
name = "Human"
|
name = "Human"
|
||||||
description = "Versatile and adaptable, humans excel through sheer determination."
|
description = "Versatile and adaptable, humans excel through sheer determination."
|
||||||
|
default_class = "class:peasant"
|
||||||
|
|
||||||
[stats]
|
[stats]
|
||||||
strength = 0
|
strength = 0
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
name = "Orc"
|
name = "Orc"
|
||||||
description = "Powerful and fierce, orcs channel raw fury into everything they do."
|
description = "Powerful and fierce, orcs channel raw fury into everything they do."
|
||||||
|
default_class = "class:peasant"
|
||||||
|
|
||||||
[stats]
|
[stats]
|
||||||
strength = 3
|
strength = 3
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ name = "Town Guard"
|
|||||||
description = "A bored-looking guard in dented chainmail. He leans on his spear and watches passersby."
|
description = "A bored-looking guard in dented chainmail. He leans on his spear and watches passersby."
|
||||||
room = "town:gate"
|
room = "town:gate"
|
||||||
base_attitude = "neutral"
|
base_attitude = "neutral"
|
||||||
|
class = "class:warrior"
|
||||||
|
|
||||||
[dialogue]
|
[dialogue]
|
||||||
greeting = "Move along. Nothing to see here."
|
greeting = "Move along. Nothing to see here."
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ name = "Giant Rat"
|
|||||||
description = "A mangy rat the size of a small dog. Its eyes gleam with feral hunger."
|
description = "A mangy rat the size of a small dog. Its eyes gleam with feral hunger."
|
||||||
room = "town:cellar"
|
room = "town:cellar"
|
||||||
base_attitude = "hostile"
|
base_attitude = "hostile"
|
||||||
|
race = "race:beast"
|
||||||
|
class = "class:creature"
|
||||||
respawn_secs = 60
|
respawn_secs = 60
|
||||||
|
|
||||||
[combat]
|
[combat]
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ description = "A cloaked figure lurking in the darkness, fingers twitching near
|
|||||||
room = "town:dark_alley"
|
room = "town:dark_alley"
|
||||||
base_attitude = "aggressive"
|
base_attitude = "aggressive"
|
||||||
faction = "underworld"
|
faction = "underworld"
|
||||||
|
class = "class:rogue"
|
||||||
respawn_secs = 90
|
respawn_secs = 90
|
||||||
|
|
||||||
[combat]
|
[combat]
|
||||||
|
|||||||
Reference in New Issue
Block a user