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
230 lines
8.4 KiB
Rust
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(", ")
|
|
}
|