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:
388
src/commands.rs
388
src/commands.rs
@@ -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"),
|
||||
|
||||
Reference in New Issue
Block a user