Files
mudserver/src/chargen.rs
AI Agent 7b6829b1e8 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
2026-03-14 16:32:27 -06:00

230 lines
8.4 KiB
Rust

use crate::ansi;
use crate::world::World;
#[derive(Clone)]
pub enum ChargenStep {
AwaitingRace,
AwaitingClass,
Done { race_id: String, class_id: String },
}
pub struct ChargenState {
pub step: ChargenStep,
pub race_id: Option<String>,
}
impl ChargenState {
pub fn new() -> Self {
ChargenState {
step: ChargenStep::AwaitingRace,
race_id: None,
}
}
pub fn prompt_text(&self, world: &World) -> String {
match &self.step {
ChargenStep::AwaitingRace => {
let mut out = String::new();
out.push_str(&format!(
"\r\n{}\r\n\r\n",
ansi::bold("=== Choose Your Race ===")
));
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",
ansi::BOLD,
i + 1,
ansi::RESET,
ansi::color(ansi::CYAN, &race.name),
if mods.is_empty() {
String::new()
} else {
ansi::system_msg(&format!("({})", mods))
},
ansi::color(ansi::DIM, &race.description),
));
let mut extras = Vec::new();
if race.size != "medium" {
extras.push(format!("Size: {}", race.size));
}
if !race.traits.is_empty() {
extras.push(format!("Traits: {}", race.traits.join(", ")));
}
if race.natural_armor > 0 {
extras.push(format!("Natural armor: {}", race.natural_armor));
}
if !race.natural_attacks.is_empty() {
let atks: Vec<String> = race.natural_attacks.iter()
.map(|a| format!("{} ({}dmg {})", a.name, a.damage, a.damage_type))
.collect();
extras.push(format!("Natural attacks: {}", atks.join(", ")));
}
if !race.vision.is_empty() {
extras.push(format!("Vision: {}", race.vision.join(", ")));
}
if !extras.is_empty() {
out.push_str(&format!(
" {}\r\n",
ansi::color(ansi::DIM, &extras.join(" | "))
));
}
}
out.push_str(&format!(
"\r\n{}",
ansi::color(ansi::YELLOW, "Enter number or name: ")
));
out
}
ChargenStep::AwaitingClass => {
let mut out = String::new();
out.push_str(&format!(
"\r\n{}\r\n\r\n",
ansi::bold("=== Choose Your Class ===")
));
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)))
.unwrap_or_default();
out.push_str(&format!(
" {}{}.{} {} {}{}\r\n {}\r\n {}HP:{} {}ATK:{} {}DEF:{}{}\r\n",
ansi::BOLD,
i + 1,
ansi::RESET,
ansi::color(ansi::CYAN, &class.name),
ansi::system_msg(&format!(
"(+{}hp/+{}atk/+{}def per level)",
class.growth.hp_per_level,
class.growth.attack_per_level,
class.growth.defense_per_level,
)),
guild_info,
ansi::color(ansi::DIM, &class.description),
ansi::GREEN,
class.base_stats.max_hp,
ansi::RED,
class.base_stats.attack,
ansi::BLUE,
class.base_stats.defense,
ansi::RESET,
));
}
out.push_str(&format!(
"\r\n{}",
ansi::color(ansi::YELLOW, "Enter number or name: ")
));
out
}
ChargenStep::Done { .. } => String::new(),
}
}
pub fn handle_input(&mut self, input: &str, world: &World) -> Result<String, String> {
let input = input.trim();
match &self.step {
ChargenStep::AwaitingRace => {
let race = find_by_input(
input,
&world.races.iter().filter(|r| !r.hidden).map(|r| (r.id.clone(), r.name.clone())).collect::<Vec<_>>(),
);
match race {
Some((id, name)) => {
self.race_id = Some(id);
self.step = ChargenStep::AwaitingClass;
Ok(format!(
"\r\n{}\r\n",
ansi::system_msg(&format!("Race selected: {name}"))
))
}
None => Err(format!(
"{}\r\n",
ansi::error_msg("Invalid choice. Enter a number or name.")
)),
}
}
ChargenStep::AwaitingClass => {
let class = find_by_input(
input,
&world
.classes
.iter()
.filter(|c| !c.hidden)
.map(|c| (c.id.clone(), c.name.clone()))
.collect::<Vec<_>>(),
);
match class {
Some((id, name)) => {
let race_id = self.race_id.clone().unwrap();
self.step = ChargenStep::Done {
race_id,
class_id: id,
};
Ok(format!(
"\r\n{}\r\n",
ansi::system_msg(&format!("Class selected: {name}"))
))
}
None => Err(format!(
"{}\r\n",
ansi::error_msg("Invalid choice. Enter a number or name.")
)),
}
}
ChargenStep::Done { .. } => Ok(String::new()),
}
}
pub fn is_done(&self) -> bool {
matches!(self.step, ChargenStep::Done { .. })
}
pub fn result(&self) -> Option<(String, String)> {
match &self.step {
ChargenStep::Done { race_id, class_id } => {
Some((race_id.clone(), class_id.clone()))
}
_ => None,
}
}
}
fn find_by_input(input: &str, options: &[(String, String)]) -> Option<(String, String)> {
if let Ok(num) = input.parse::<usize>() {
if num >= 1 && num <= options.len() {
return Some(options[num - 1].clone());
}
}
let lower = input.to_lowercase();
options
.iter()
.find(|(_, name)| name.to_lowercase() == lower)
.cloned()
}
fn format_stat_mods(stats: &crate::world::StatModifiers) -> String {
let mut parts = Vec::new();
let fields = [
("STR", stats.strength),
("DEX", stats.dexterity),
("CON", stats.constitution),
("INT", stats.intelligence),
("WIS", stats.wisdom),
("PER", stats.perception),
("CHA", stats.charisma),
];
for (label, val) in fields {
if val != 0 {
parts.push(format!(
"{}{} {}",
if val > 0 { "+" } else { "" },
val,
label
));
}
}
parts.join(", ")
}