Implement guild system with multi-guild, spells, and combat casting

- Guild data model: world/guilds/*.toml defines guilds with stat growth,
  resource type (mana/endurance), spell lists, level caps, and race
  restrictions. Spells are separate files in world/spells/*.toml.

- Player resources: mana and endurance pools added to PlayerStats with
  DB migration. Passive regen for mana/endurance when out of combat.

- Guild commands: guild list/info/join/leave with multi-guild support.
  spells/skills command shows available spells per guild level.

- Spell casting: cast command works in and out of combat. Offensive
  spells queue as CombatAction::Cast and resolve on tick. Heal/utility
  spells resolve immediately out of combat. Mana/endurance costs,
  cooldowns, and damage types all enforced.

- Chargen integration: classes reference a guild via TOML field. On
  character creation, player auto-joins the seeded guild at level 1.
  Class selection shows "→ joins <Guild>" hint.

- Look command: now accepts optional target argument to inspect NPCs
  (stats/attitude), objects, exits, players, or inventory items.

- 4 guilds (warriors/rogues/mages/clerics) and 16 spells created for
  the existing class archetypes.

Made-with: Cursor
This commit is contained in:
AI Agent
2026-03-14 16:13:10 -06:00
parent bdd1a85ea2
commit 598360ac95
33 changed files with 1082 additions and 48 deletions

View File

@@ -67,8 +67,9 @@ pub async fn execute(
if conn.combat.is_some()
&& !matches!(
cmd.as_str(),
"attack" | "a" | "defend" | "def" | "flee" | "use" | "look" | "l"
| "stats" | "st" | "inventory" | "inv" | "i" | "quit" | "exit"
"attack" | "a" | "defend" | "def" | "flee" | "use" | "cast" | "c"
| "look" | "l" | "stats" | "st" | "inventory" | "inv" | "i"
| "spells" | "skills" | "quit" | "exit"
)
{
drop(st);
@@ -78,7 +79,7 @@ pub async fn execute(
&format!(
"{}\r\n{}",
ansi::error_msg(
"You're in combat! Use 'attack', 'defend', 'flee', 'use', 'look', 'stats', or 'inventory'."
"You're in combat! Use 'attack', 'defend', 'flee', 'cast', 'use', 'look', 'stats', or 'inventory'."
),
ansi::prompt()
),
@@ -105,6 +106,9 @@ pub async fn execute(
"attack" | "a" => cmd_attack(player_id, &args, state).await,
"defend" | "def" => cmd_defend(player_id, state).await,
"flee" => cmd_flee(player_id, state).await,
"cast" | "c" => cmd_cast(player_id, &args, state).await,
"spells" | "skills" => cmd_spells(player_id, state).await,
"guild" => cmd_guild(player_id, &args, state).await,
"stats" | "st" => cmd_stats(player_id, state).await,
"admin" => cmd_admin(player_id, &args, state).await,
"help" | "h" | "?" => cmd_help(player_id, state).await,
@@ -1188,6 +1192,352 @@ async fn cmd_flee(pid: usize, state: &SharedState) -> CommandResult {
}
}
async fn cmd_cast(pid: usize, target: &str, state: &SharedState) -> CommandResult {
if target.is_empty() {
return simple(&format!("{}\r\n", ansi::error_msg("Cast what? Usage: cast <spell> [target]")));
}
let mut st = state.lock().await;
let (spell_parts, _npc_target) = match target.split_once(' ') {
Some((s, t)) => (s.to_lowercase(), Some(t.to_string())),
None => (target.to_lowercase(), None::<String>),
};
// Find spell by name match across all guilds the player belongs to
let player_guilds: Vec<(String, i32)> = match st.players.get(&pid) {
Some(c) => c.player.guilds.iter().map(|(k, v)| (k.clone(), *v)).collect(),
None => return simple("Error\r\n"),
};
let mut found_spell: Option<String> = None;
for (gid, glvl) in &player_guilds {
let available = st.world.spells_for_guild(gid, *glvl);
for spell in available {
if spell.name.to_lowercase().contains(&spell_parts) || spell.id.ends_with(&format!(":{}", spell_parts)) {
found_spell = Some(spell.id.clone());
break;
}
}
if found_spell.is_some() { break; }
}
let spell_id = match found_spell {
Some(id) => id,
None => return simple(&format!("{}\r\n", ansi::error_msg(&format!("You don't know a spell called '{}'.", spell_parts)))),
};
let spell = match st.world.get_spell(&spell_id) {
Some(s) => s.clone(),
None => return simple("Error\r\n"),
};
let conn = match st.players.get(&pid) {
Some(c) => c,
None => return simple("Error\r\n"),
};
// Check cooldown
if let Some(&cd) = conn.player.cooldowns.get(&spell_id) {
if cd > 0 {
return simple(&format!("{}\r\n", ansi::error_msg(&format!("{} is on cooldown ({} ticks remaining).", spell.name, cd))));
}
}
// Check resource cost
if spell.cost_mana > 0 && conn.player.stats.mana < spell.cost_mana {
return simple(&format!("{}\r\n", ansi::error_msg(&format!("Not enough mana for {} (need {}, have {}).", spell.name, spell.cost_mana, conn.player.stats.mana))));
}
if spell.cost_endurance > 0 && conn.player.stats.endurance < spell.cost_endurance {
return simple(&format!("{}\r\n", ansi::error_msg(&format!("Not enough endurance for {} (need {}, have {}).", spell.name, spell.cost_endurance, conn.player.stats.endurance))));
}
let in_combat = conn.combat.is_some();
let _ = conn;
if in_combat {
// Queue the cast as a combat action
if let Some(conn) = st.players.get_mut(&pid) {
if let Some(ref mut combat) = conn.combat {
combat.action = Some(CombatAction::Cast(spell_id.clone()));
}
}
return CommandResult {
output: format!("{}\r\n",
ansi::system_msg(&format!("You begin casting {}... (resolves next tick)", spell.name))
),
broadcasts: Vec::new(),
kick_targets: Vec::new(),
quit: false,
};
}
// Out of combat: resolve immediately for heal/utility spells
if spell.spell_type == "heal" {
if let Some(conn) = st.players.get_mut(&pid) {
conn.player.stats.mana -= spell.cost_mana;
conn.player.stats.endurance -= spell.cost_endurance;
let old_hp = conn.player.stats.hp;
conn.player.stats.hp = (conn.player.stats.hp + spell.heal).min(conn.player.stats.max_hp);
let healed = conn.player.stats.hp - old_hp;
if spell.cooldown_ticks > 0 {
conn.player.cooldowns.insert(spell_id, spell.cooldown_ticks);
}
let _ = conn;
st.save_player_to_db(pid);
return CommandResult {
output: format!("You cast {}! Restored {} HP.\r\n",
ansi::color(ansi::CYAN, &spell.name),
ansi::color(ansi::GREEN, &healed.to_string()),
),
broadcasts: Vec::new(),
kick_targets: Vec::new(),
quit: false,
};
}
}
if spell.spell_type == "utility" {
if let Some(conn) = st.players.get_mut(&pid) {
conn.player.stats.mana -= spell.cost_mana;
conn.player.stats.endurance -= spell.cost_endurance;
if spell.cooldown_ticks > 0 {
conn.player.cooldowns.insert(spell_id, spell.cooldown_ticks);
}
let pname = conn.player.name.clone();
let eff_clone = spell.effect.clone();
let eff_dur = spell.effect_duration;
let eff_mag = spell.effect_magnitude;
let _ = conn;
if let Some(ref eff) = eff_clone {
st.db.save_effect(&pname, eff, eff_dur, eff_mag);
}
st.save_player_to_db(pid);
return CommandResult {
output: format!("You cast {}!\r\n", ansi::color(ansi::CYAN, &spell.name)),
broadcasts: Vec::new(),
kick_targets: Vec::new(),
quit: false,
};
}
}
// Offensive spells require a target / combat
simple(&format!("{}\r\n", ansi::error_msg(&format!("{} is an offensive spell — enter combat first.", spell.name))))
}
async fn cmd_spells(pid: usize, state: &SharedState) -> CommandResult {
let st = state.lock().await;
let conn = match st.players.get(&pid) {
Some(c) => c,
None => return simple("Error\r\n"),
};
if conn.player.guilds.is_empty() {
return simple(&format!("{}\r\n", ansi::system_msg("You haven't joined any guilds.")));
}
let mut out = format!("\r\n{}\r\n", ansi::bold("=== Known Spells & Skills ==="));
let mut guild_list: Vec<_> = conn.player.guilds.iter().collect();
guild_list.sort_by_key(|(id, _)| (*id).clone());
for (gid, glvl) in &guild_list {
let gname = st.world.get_guild(gid)
.map(|g| g.name.as_str())
.unwrap_or("???");
out.push_str(&format!("\r\n {} (level {}):\r\n", ansi::color(ansi::YELLOW, gname), glvl));
let spells = st.world.spells_for_guild(gid, **glvl);
if spells.is_empty() {
out.push_str(&format!(" {}\r\n", ansi::system_msg("(no spells at this level)")));
} else {
for spell in &spells {
let cost = if spell.cost_mana > 0 {
format!("{}mp", spell.cost_mana)
} else if spell.cost_endurance > 0 {
format!("{}ep", spell.cost_endurance)
} else {
"free".into()
};
let cd = if spell.cooldown_ticks > 0 {
format!(" cd:{}t", spell.cooldown_ticks)
} else {
String::new()
};
let player_cd = conn.player.cooldowns.get(&spell.id).copied().unwrap_or(0);
let cd_str = if player_cd > 0 {
format!(" {}", ansi::color(ansi::RED, &format!("[{} ticks]", player_cd)))
} else {
String::new()
};
out.push_str(&format!(
" {} {} [{}{}]{}\r\n {}\r\n",
ansi::color(ansi::CYAN, &spell.name),
ansi::system_msg(&format!("({})", spell.spell_type)),
cost, cd, cd_str,
ansi::color(ansi::DIM, &spell.description),
));
}
}
}
CommandResult {
output: out,
broadcasts: Vec::new(),
kick_targets: Vec::new(),
quit: false,
}
}
async fn cmd_guild(pid: usize, args: &str, state: &SharedState) -> CommandResult {
let (subcmd, subargs) = match args.split_once(' ') {
Some((c, a)) => (c.to_lowercase(), a.trim().to_string()),
None => (args.to_lowercase(), String::new()),
};
match subcmd.as_str() {
"list" | "ls" | "" => {
let st = state.lock().await;
let mut out = format!("\r\n{}\r\n", ansi::bold("=== Available Guilds ==="));
let mut guild_list: Vec<_> = st.world.guilds.values().collect();
guild_list.sort_by_key(|g| &g.name);
for g in &guild_list {
let resource = &g.resource;
out.push_str(&format!(
" {} {} (max level: {}, resource: {})\r\n {}\r\n",
ansi::color(ansi::CYAN, &g.name),
ansi::system_msg(&format!("[{}]", g.id)),
g.max_level,
resource,
ansi::color(ansi::DIM, &g.description),
));
}
if guild_list.is_empty() {
out.push_str(&format!(" {}\r\n", ansi::system_msg("No guilds defined.")));
}
CommandResult { output: out, broadcasts: Vec::new(), kick_targets: Vec::new(), quit: false }
}
"info" => {
if subargs.is_empty() {
return simple(&format!("{}\r\n", ansi::error_msg("Usage: guild info <guild>")));
}
let st = state.lock().await;
let low = subargs.to_lowercase();
let guild = st.world.guilds.values().find(|g| g.name.to_lowercase().contains(&low) || g.id.ends_with(&format!(":{}", low)));
match guild {
Some(g) => {
let mut out = format!("\r\n{}\r\n {}\r\n", ansi::bold(&g.name), g.description);
out.push_str(&format!(" Max level: {} | Resource: {}\r\n", g.max_level, g.resource));
if g.min_player_level > 0 {
out.push_str(&format!(" Requires player level: {}\r\n", g.min_player_level));
}
let gr = &g.growth;
out.push_str(&format!(" Growth/lvl: +{}hp +{}mp +{}ep +{}atk +{}def\r\n",
gr.hp_per_level, gr.mana_per_level, gr.endurance_per_level,
gr.attack_per_level, gr.defense_per_level));
if !g.spells.is_empty() {
out.push_str(&format!(" Spells ({}):\r\n", g.spells.len()));
for sid in &g.spells {
if let Some(sp) = st.world.get_spell(sid) {
out.push_str(&format!(" {} (lvl {}) — {}\r\n",
ansi::color(ansi::CYAN, &sp.name),
sp.min_guild_level,
ansi::color(ansi::DIM, &sp.description),
));
}
}
}
CommandResult { output: out, broadcasts: Vec::new(), kick_targets: Vec::new(), quit: false }
}
None => simple(&format!("{}\r\n", ansi::error_msg(&format!("Guild '{}' not found.", subargs)))),
}
}
"join" => {
if subargs.is_empty() {
return simple(&format!("{}\r\n", ansi::error_msg("Usage: guild join <guild>")));
}
let mut st = state.lock().await;
let low = subargs.to_lowercase();
let guild = st.world.guilds.values()
.find(|g| g.name.to_lowercase().contains(&low) || g.id.ends_with(&format!(":{}", low)))
.cloned();
match guild {
Some(g) => {
let conn = match st.players.get(&pid) {
Some(c) => c,
None => return simple("Error\r\n"),
};
if conn.player.guilds.contains_key(&g.id) {
return simple(&format!("{}\r\n", ansi::error_msg(&format!("You're already in the {}.", g.name))));
}
if g.min_player_level > 0 && conn.player.stats.level < g.min_player_level {
return simple(&format!("{}\r\n", ansi::error_msg(&format!("You need player level {} to join {}.", g.min_player_level, g.name))));
}
// Race restriction check
if !g.race_restricted.is_empty() && g.race_restricted.contains(&conn.player.race_id) {
return simple(&format!("{}\r\n", ansi::error_msg(&format!("Your race cannot join the {}.", g.name))));
}
let pname = conn.player.name.clone();
let gid = g.id.clone();
let gname = g.name.clone();
let _ = conn;
if let Some(conn) = st.players.get_mut(&pid) {
conn.player.guilds.insert(gid.clone(), 1);
// Add base resources from guild
conn.player.stats.max_mana += g.base_mana;
conn.player.stats.mana += g.base_mana;
conn.player.stats.max_endurance += g.base_endurance;
conn.player.stats.endurance += g.base_endurance;
}
st.db.save_guild_membership(&pname, &gid, 1);
st.save_player_to_db(pid);
CommandResult {
output: format!("{}\r\n", ansi::system_msg(&format!("You have joined the {}!", gname))),
broadcasts: Vec::new(), kick_targets: Vec::new(), quit: false,
}
}
None => simple(&format!("{}\r\n", ansi::error_msg(&format!("Guild '{}' not found.", subargs)))),
}
}
"leave" => {
if subargs.is_empty() {
return simple(&format!("{}\r\n", ansi::error_msg("Usage: guild leave <guild>")));
}
let mut st = state.lock().await;
let low = subargs.to_lowercase();
let guild = st.world.guilds.values()
.find(|g| g.name.to_lowercase().contains(&low) || g.id.ends_with(&format!(":{}", low)))
.cloned();
match guild {
Some(g) => {
let conn = match st.players.get(&pid) {
Some(c) => c,
None => return simple("Error\r\n"),
};
if !conn.player.guilds.contains_key(&g.id) {
return simple(&format!("{}\r\n", ansi::error_msg(&format!("You're not in the {}.", g.name))));
}
let pname = conn.player.name.clone();
let gid = g.id.clone();
let gname = g.name.clone();
let _ = conn;
if let Some(conn) = st.players.get_mut(&pid) {
conn.player.guilds.remove(&gid);
}
st.db.remove_guild_membership(&pname, &gid);
st.save_player_to_db(pid);
CommandResult {
output: format!("{}\r\n", ansi::system_msg(&format!("You have left the {}.", gname))),
broadcasts: Vec::new(), kick_targets: Vec::new(), quit: false,
}
}
None => simple(&format!("{}\r\n", ansi::error_msg(&format!("Guild '{}' not found.", subargs)))),
}
}
_ => simple(&format!("{}\r\nUsage: guild list | guild info <name> | guild join <name> | guild leave <name>\r\n",
ansi::error_msg(&format!("Unknown guild subcommand: '{subcmd}'.")))),
}
}
async fn cmd_stats(pid: usize, state: &SharedState) -> CommandResult {
let st = state.lock().await;
let conn = match st.players.get(&pid) {
@@ -1256,12 +1606,41 @@ async fn cmd_stats(pid: usize, state: &SharedState) -> CommandResult {
ansi::color(ansi::DIM, "Level:"),
s.level
));
if s.max_mana > 0 {
out.push_str(&format!(
" {} {}{}/{}{}\r\n",
ansi::color(ansi::DIM, "Mana:"),
ansi::BLUE, s.mana, s.max_mana, ansi::RESET,
));
}
if s.max_endurance > 0 {
out.push_str(&format!(
" {} {}{}/{}{}\r\n",
ansi::color(ansi::DIM, "Endurance:"),
ansi::YELLOW, s.endurance, s.max_endurance, ansi::RESET,
));
}
out.push_str(&format!(
" {} {}/{}\r\n",
ansi::color(ansi::DIM, "XP:"),
s.xp,
s.xp_to_next
));
if !p.guilds.is_empty() {
out.push_str(&format!(" {}\r\n", ansi::color(ansi::DIM, "Guilds:")));
let mut guild_list: Vec<_> = p.guilds.iter().collect();
guild_list.sort_by_key(|(id, _)| (*id).clone());
for (gid, glvl) in &guild_list {
let gname = st.world.get_guild(gid)
.map(|g| g.name.as_str())
.unwrap_or("???");
out.push_str(&format!(
" {} (level {})\r\n",
ansi::color(ansi::CYAN, gname),
glvl,
));
}
}
// Show combat status
if let Some(ref combat) = conn.combat {
@@ -1349,6 +1728,9 @@ async fn cmd_help(pid: usize, state: &SharedState) -> CommandResult {
("attack <target>, a", "Engage/attack a hostile NPC (tick-based)"),
("defend, def", "Defend next tick (reduces incoming damage)"),
("flee", "Attempt to flee combat (tick-based)"),
("cast <spell>, c", "Cast a spell (tick-based in combat)"),
("spells / skills", "List your available spells"),
("guild list/info/join/leave", "Guild management"),
("stats, st", "View your character stats"),
("help, h, ?", "Show this help"),
("quit, exit", "Leave the game"),