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

@@ -83,8 +83,12 @@ impl ChargenState {
ansi::bold("=== Choose Your Class ===")
));
for (i, class) in world.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",
" {}{}.{} {} {}{}\r\n {}\r\n {}HP:{} {}ATK:{} {}DEF:{}{}\r\n",
ansi::BOLD,
i + 1,
ansi::RESET,
@@ -95,6 +99,7 @@ impl ChargenState {
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,

View File

@@ -132,6 +132,95 @@ pub fn resolve_combat_tick(
));
}
}
CombatAction::Cast(ref spell_id) => {
let spell = state.world.get_spell(spell_id).cloned();
if let Some(spell) = spell {
if let Some(conn) = state.players.get_mut(&player_id) {
if spell.cost_mana > conn.player.stats.mana {
out.push_str(&format!(
" {} Not enough mana for {}!\r\n",
ansi::color(ansi::RED, "!!"), spell.name,
));
} else if spell.cost_endurance > conn.player.stats.endurance {
out.push_str(&format!(
" {} Not enough endurance for {}!\r\n",
ansi::color(ansi::RED, "!!"), spell.name,
));
} else {
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.clone(), spell.cooldown_ticks);
}
if spell.spell_type == "heal" {
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;
out.push_str(&format!(
" {} You cast {}! Restored {} HP.\r\n",
ansi::color(ansi::GREEN, "++"),
ansi::color(ansi::CYAN, &spell.name),
ansi::bold(&healed.to_string()),
));
} else {
// Offensive spell
let spell_dmg = spell.damage + state.rng.next_range(1, 4);
let new_npc_hp = (npc_hp_before - spell_dmg).max(0);
out.push_str(&format!(
" {} You cast {} on {} for {} {} damage!\r\n",
ansi::color(ansi::MAGENTA, "**"),
ansi::color(ansi::CYAN, &spell.name),
ansi::color(ansi::RED, &npc_template.name),
ansi::bold(&spell_dmg.to_string()),
spell.damage_type,
));
if new_npc_hp <= 0 {
if let Some(inst) = state.npc_instances.get_mut(&npc_id) {
inst.alive = false;
inst.hp = 0;
inst.death_time = Some(Instant::now());
}
npc_died = true;
xp_gained = npc_combat.xp_reward;
out.push_str(&format!(
" {} {} collapses! You gain {} XP.\r\n",
ansi::color(ansi::GREEN, "**"),
ansi::color(ansi::RED, &npc_template.name),
ansi::bold(&xp_gained.to_string()),
));
conn.combat = None;
conn.player.stats.xp += xp_gained;
} else {
if let Some(inst) = state.npc_instances.get_mut(&npc_id) {
inst.hp = new_npc_hp;
}
out.push_str(&format!(
" {} {} HP: {}/{}\r\n",
ansi::color(ansi::DIM, " "),
npc_template.name, new_npc_hp, npc_combat.max_hp,
));
}
}
// Apply status effect from spell
if let Some(ref eff) = spell.effect {
if spell.spell_type == "offensive" {
// Could apply effect to NPC in future, for now just note it
} else {
let pname = conn.player.name.clone();
state.db.save_effect(&pname, eff, spell.effect_duration, spell.effect_magnitude);
}
}
}
}
} else {
out.push_str(&format!(
" {} Spell not found.\r\n",
ansi::color(ansi::RED, "!!"),
));
}
}
CombatAction::UseItem(idx) => {
if let Some(conn) = state.players.get_mut(&player_id) {
if idx < conn.player.inventory.len() {

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"),

View File

@@ -15,6 +15,10 @@ pub struct SavedPlayer {
pub defense: i32,
pub inventory_json: String,
pub equipped_json: String,
pub mana: i32,
pub max_mana: i32,
pub endurance: i32,
pub max_endurance: i32,
pub is_admin: bool,
}
@@ -50,6 +54,10 @@ pub trait GameDb: Send + Sync {
fn load_all_effects(&self) -> Vec<StatusEffectRow>;
fn tick_all_effects(&self) -> Vec<StatusEffectRow>;
fn clear_effects(&self, player_name: &str);
fn load_guild_memberships(&self, player_name: &str) -> Vec<(String, i32)>;
fn save_guild_membership(&self, player_name: &str, guild_id: &str, level: i32);
fn remove_guild_membership(&self, player_name: &str, guild_id: &str);
}
// --- SQLite implementation ---
@@ -101,6 +109,13 @@ impl SqliteDb {
remaining_ticks INTEGER NOT NULL,
magnitude INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (player_name, kind)
);
CREATE TABLE IF NOT EXISTS player_guilds (
player_name TEXT NOT NULL,
guild_id TEXT NOT NULL,
level INTEGER NOT NULL DEFAULT 1,
PRIMARY KEY (player_name, guild_id)
);",
)
.map_err(|e| format!("Failed to create tables: {e}"))?;
@@ -145,6 +160,19 @@ impl SqliteDb {
);
}
// Migration: add mana/endurance columns
let has_mana: bool = conn
.prepare("SELECT COUNT(*) FROM pragma_table_info('players') WHERE name='mana'")
.and_then(|mut s| s.query_row([], |r| r.get::<_, i32>(0)))
.map(|c| c > 0)
.unwrap_or(false);
if !has_mana {
let _ = conn.execute("ALTER TABLE players ADD COLUMN mana INTEGER NOT NULL DEFAULT 0", []);
let _ = conn.execute("ALTER TABLE players ADD COLUMN max_mana INTEGER NOT NULL DEFAULT 0", []);
let _ = conn.execute("ALTER TABLE players ADD COLUMN endurance INTEGER NOT NULL DEFAULT 0", []);
let _ = conn.execute("ALTER TABLE players ADD COLUMN max_endurance INTEGER NOT NULL DEFAULT 0", []);
}
log::info!("Database opened: {}", path.display());
Ok(SqliteDb {
conn: std::sync::Mutex::new(conn),
@@ -157,7 +185,8 @@ impl GameDb for SqliteDb {
let conn = self.conn.lock().unwrap();
conn.query_row(
"SELECT name, race_id, class_id, room_id, level, xp, hp, max_hp,
attack, defense, inventory_json, equipped_json, is_admin
attack, defense, inventory_json, equipped_json, is_admin,
mana, max_mana, endurance, max_endurance
FROM players WHERE name = ?1",
[name],
|row| {
@@ -175,6 +204,10 @@ impl GameDb for SqliteDb {
inventory_json: row.get(10)?,
equipped_json: row.get::<_, String>(11).unwrap_or_else(|_| "{}".into()),
is_admin: row.get::<_, i32>(12)? != 0,
mana: row.get::<_, i32>(13).unwrap_or(0),
max_mana: row.get::<_, i32>(14).unwrap_or(0),
endurance: row.get::<_, i32>(15).unwrap_or(0),
max_endurance: row.get::<_, i32>(16).unwrap_or(0),
})
},
)
@@ -185,28 +218,21 @@ impl GameDb for SqliteDb {
let conn = self.conn.lock().unwrap();
let _ = conn.execute(
"INSERT INTO players (name, race_id, class_id, room_id, level, xp, hp, max_hp,
attack, defense, inventory_json, equipped_json, is_admin)
VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13)
attack, defense, inventory_json, equipped_json, is_admin,
mana, max_mana, endurance, max_endurance)
VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16,?17)
ON CONFLICT(name) DO UPDATE SET
room_id=excluded.room_id, level=excluded.level, xp=excluded.xp,
hp=excluded.hp, max_hp=excluded.max_hp, attack=excluded.attack,
defense=excluded.defense, inventory_json=excluded.inventory_json,
equipped_json=excluded.equipped_json,
is_admin=excluded.is_admin",
equipped_json=excluded.equipped_json, is_admin=excluded.is_admin,
mana=excluded.mana, max_mana=excluded.max_mana,
endurance=excluded.endurance, max_endurance=excluded.max_endurance",
rusqlite::params![
p.name,
p.race_id,
p.class_id,
p.room_id,
p.level,
p.xp,
p.hp,
p.max_hp,
p.attack,
p.defense,
p.inventory_json,
p.equipped_json,
p.is_admin as i32,
p.name, p.race_id, p.class_id, p.room_id, p.level, p.xp,
p.hp, p.max_hp, p.attack, p.defense,
p.inventory_json, p.equipped_json, p.is_admin as i32,
p.mana, p.max_mana, p.endurance, p.max_endurance,
],
);
}
@@ -216,6 +242,7 @@ impl GameDb for SqliteDb {
let _ = conn.execute("DELETE FROM players WHERE name = ?1", [name]);
let _ = conn.execute("DELETE FROM npc_attitudes WHERE player_name = ?1", [name]);
let _ = conn.execute("DELETE FROM status_effects WHERE player_name = ?1", [name]);
let _ = conn.execute("DELETE FROM player_guilds WHERE player_name = ?1", [name]);
}
fn set_admin(&self, name: &str, is_admin: bool) -> bool {
@@ -234,7 +261,8 @@ impl GameDb for SqliteDb {
let mut stmt = conn
.prepare(
"SELECT name, race_id, class_id, room_id, level, xp, hp, max_hp,
attack, defense, inventory_json, equipped_json, is_admin
attack, defense, inventory_json, equipped_json, is_admin,
mana, max_mana, endurance, max_endurance
FROM players ORDER BY name",
)
.unwrap();
@@ -253,6 +281,10 @@ impl GameDb for SqliteDb {
inventory_json: row.get(10)?,
equipped_json: row.get::<_, String>(11).unwrap_or_else(|_| "{}".into()),
is_admin: row.get::<_, i32>(12)? != 0,
mana: row.get::<_, i32>(13).unwrap_or(0),
max_mana: row.get::<_, i32>(14).unwrap_or(0),
endurance: row.get::<_, i32>(15).unwrap_or(0),
max_endurance: row.get::<_, i32>(16).unwrap_or(0),
})
})
.unwrap()
@@ -397,4 +429,32 @@ impl GameDb for SqliteDb {
let conn = self.conn.lock().unwrap();
let _ = conn.execute("DELETE FROM status_effects WHERE player_name = ?1", [player_name]);
}
fn load_guild_memberships(&self, player_name: &str) -> Vec<(String, i32)> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn
.prepare("SELECT guild_id, level FROM player_guilds WHERE player_name = ?1")
.unwrap();
stmt.query_map([player_name], |row| Ok((row.get(0)?, row.get(1)?)))
.unwrap()
.filter_map(|r| r.ok())
.collect()
}
fn save_guild_membership(&self, player_name: &str, guild_id: &str, level: i32) {
let conn = self.conn.lock().unwrap();
let _ = conn.execute(
"INSERT INTO player_guilds (player_name, guild_id, level) VALUES (?1, ?2, ?3)
ON CONFLICT(player_name, guild_id) DO UPDATE SET level=excluded.level",
rusqlite::params![player_name, guild_id, level],
);
}
fn remove_guild_membership(&self, player_name: &str, guild_id: &str) {
let conn = self.conn.lock().unwrap();
let _ = conn.execute(
"DELETE FROM player_guilds WHERE player_name = ?1 AND guild_id = ?2",
rusqlite::params![player_name, guild_id],
);
}
}

View File

@@ -18,6 +18,10 @@ pub struct PlayerStats {
pub level: i32,
pub xp: i32,
pub xp_to_next: i32,
pub max_mana: i32,
pub mana: i32,
pub max_endurance: i32,
pub endurance: i32,
}
pub struct Player {
@@ -28,6 +32,8 @@ pub struct Player {
pub stats: PlayerStats,
pub inventory: Vec<Object>,
pub equipped: HashMap<String, Object>,
pub guilds: HashMap<String, i32>,
pub cooldowns: HashMap<String, i32>,
pub is_admin: bool,
}
@@ -73,6 +79,7 @@ pub enum CombatAction {
Defend,
Flee,
UseItem(usize),
Cast(String),
}
pub struct CombatState {
@@ -234,6 +241,25 @@ impl GameState {
let attack = base_atk + str_mod + dex_mod / 2;
let defense = base_def + con_mod / 2;
// Compute starting mana/endurance from initial guild
let initial_guild_id = class.and_then(|c| c.guild.clone());
let (base_mana, base_endurance) = initial_guild_id.as_ref()
.and_then(|gid| self.world.guilds.get(gid))
.map(|g| (g.base_mana, g.base_endurance))
.unwrap_or((0, 0));
let int_mod = race.map(|r| r.stats.intelligence).unwrap_or(0);
let wis_mod = race.map(|r| r.stats.wisdom).unwrap_or(0);
let max_mana = base_mana + int_mod * 5 + wis_mod * 3;
let max_endurance = base_endurance + con_mod * 3 + str_mod * 2;
let mut guilds = HashMap::new();
if let Some(ref gid) = initial_guild_id {
if self.world.guilds.contains_key(gid) {
guilds.insert(gid.clone(), 1);
self.db.save_guild_membership(&name, gid, 1);
}
}
let stats = PlayerStats {
max_hp,
hp: max_hp,
@@ -242,6 +268,10 @@ impl GameState {
level: 1,
xp: 0,
xp_to_next: 100,
max_mana,
mana: max_mana,
max_endurance,
endurance: max_endurance,
};
self.players.insert(
@@ -255,6 +285,8 @@ impl GameState {
stats,
inventory: Vec::new(),
equipped: HashMap::new(),
guilds,
cooldowns: HashMap::new(),
is_admin: false,
},
channel,
@@ -282,6 +314,9 @@ impl GameState {
self.world.spawn_room.clone()
};
let guilds_vec = self.db.load_guild_memberships(&saved.name);
let guilds: HashMap<String, i32> = guilds_vec.into_iter().collect();
let stats = PlayerStats {
max_hp: saved.max_hp,
hp: saved.hp,
@@ -290,6 +325,10 @@ impl GameState {
level: saved.level,
xp: saved.xp,
xp_to_next: saved.level * 100,
max_mana: saved.max_mana,
mana: saved.mana,
max_endurance: saved.max_endurance,
endurance: saved.endurance,
};
self.players.insert(
@@ -303,6 +342,8 @@ impl GameState {
stats,
inventory,
equipped,
guilds,
cooldowns: HashMap::new(),
is_admin: saved.is_admin,
},
channel,
@@ -333,6 +374,10 @@ impl GameState {
defense: p.stats.defense,
inventory_json: inv_json,
equipped_json,
mana: p.stats.mana,
max_mana: p.stats.max_mana,
endurance: p.stats.endurance,
max_endurance: p.stats.max_endurance,
is_admin: p.is_admin,
});
}

View File

@@ -223,33 +223,60 @@ pub async fn run_tick_engine(state: SharedState) {
}
}
// --- Tick cooldowns for all online players ---
let cd_pids: Vec<usize> = st.players.keys().copied().collect();
for pid in cd_pids {
if let Some(conn) = st.players.get_mut(&pid) {
conn.player.cooldowns.retain(|_, cd| {
*cd -= 1;
*cd > 0
});
}
}
// --- Passive regen for online players not in combat ---
if tick % REGEN_EVERY_N_TICKS == 0 {
let regen_pids: Vec<usize> = st
.players
.iter()
.filter(|(_, c)| {
c.combat.is_none() && c.player.stats.hp < c.player.stats.max_hp
})
.filter(|(_, c)| c.combat.is_none())
.map(|(&id, _)| id)
.collect();
for pid in regen_pids {
if let Some(conn) = st.players.get_mut(&pid) {
let heal =
(conn.player.stats.max_hp * REGEN_PERCENT / 100).max(1);
let old = conn.player.stats.hp;
conn.player.stats.hp =
(conn.player.stats.hp + heal).min(conn.player.stats.max_hp);
let healed = conn.player.stats.hp - old;
if healed > 0 {
messages.entry(pid).or_default().push_str(&format!(
"\r\n {} You recover {} HP. ({}/{})\r\n",
ansi::color(ansi::DIM, "~~"),
healed,
conn.player.stats.hp,
conn.player.stats.max_hp,
));
let s = &mut conn.player.stats;
let mut regen_msg = String::new();
// HP regen
if s.hp < s.max_hp {
let heal = (s.max_hp * REGEN_PERCENT / 100).max(1);
let old = s.hp;
s.hp = (s.hp + heal).min(s.max_hp);
let healed = s.hp - old;
if healed > 0 {
regen_msg.push_str(&format!(
"\r\n {} You recover {} HP. ({}/{})",
ansi::color(ansi::DIM, "~~"), healed, s.hp, s.max_hp,
));
}
}
// Mana regen
if s.mana < s.max_mana {
let regen = (s.max_mana * REGEN_PERCENT / 100).max(1);
s.mana = (s.mana + regen).min(s.max_mana);
}
// Endurance regen
if s.endurance < s.max_endurance {
let regen = (s.max_endurance * REGEN_PERCENT / 100).max(1);
s.endurance = (s.endurance + regen).min(s.max_endurance);
}
if !regen_msg.is_empty() {
regen_msg.push_str("\r\n");
messages.entry(pid).or_default().push_str(&regen_msg);
}
}
st.save_player_to_db(pid);

View File

@@ -263,7 +263,7 @@ pub struct RaceFile {
pub misc: RaceMiscFile,
}
// --- Class TOML schema ---
// --- Class TOML schema (starter class — seeds initial guild on character creation) ---
#[derive(Deserialize, Default, Clone)]
pub struct ClassBaseStats {
@@ -293,6 +293,78 @@ pub struct ClassFile {
pub base_stats: ClassBaseStats,
#[serde(default)]
pub growth: ClassGrowth,
#[serde(default)]
pub guild: Option<String>,
}
// --- Guild TOML schema ---
#[derive(Deserialize, Default, Clone)]
pub struct GuildGrowth {
#[serde(default)]
pub hp_per_level: i32,
#[serde(default)]
pub mana_per_level: i32,
#[serde(default)]
pub endurance_per_level: i32,
#[serde(default)]
pub attack_per_level: i32,
#[serde(default)]
pub defense_per_level: i32,
}
#[derive(Deserialize)]
pub struct GuildFile {
pub name: String,
pub description: String,
#[serde(default)]
pub max_level: i32,
#[serde(default)]
pub resource: Option<String>,
#[serde(default)]
pub base_mana: i32,
#[serde(default)]
pub base_endurance: i32,
#[serde(default)]
pub growth: GuildGrowth,
#[serde(default)]
pub spells: Vec<String>,
#[serde(default)]
pub min_player_level: i32,
#[serde(default)]
pub race_restricted: Vec<String>,
}
// --- Spell TOML schema ---
#[derive(Deserialize)]
pub struct SpellFile {
pub name: String,
pub description: String,
#[serde(default)]
pub spell_type: Option<String>,
#[serde(default)]
pub damage: i32,
#[serde(default)]
pub heal: i32,
#[serde(default)]
pub damage_type: Option<String>,
#[serde(default)]
pub cost_mana: i32,
#[serde(default)]
pub cost_endurance: i32,
#[serde(default)]
pub cooldown_ticks: i32,
#[serde(default)]
pub casting_ticks: i32,
#[serde(default)]
pub min_guild_level: i32,
#[serde(default)]
pub effect: Option<String>,
#[serde(default)]
pub effect_duration: i32,
#[serde(default)]
pub effect_magnitude: i32,
}
// --- Runtime types ---
@@ -392,6 +464,41 @@ pub struct Class {
pub description: String,
pub base_stats: ClassBaseStats,
pub growth: ClassGrowth,
pub guild: Option<String>,
}
#[derive(Clone)]
pub struct Guild {
pub id: String,
pub name: String,
pub description: String,
pub max_level: i32,
pub resource: String,
pub base_mana: i32,
pub base_endurance: i32,
pub growth: GuildGrowth,
pub spells: Vec<String>,
pub min_player_level: i32,
pub race_restricted: Vec<String>,
}
#[derive(Clone)]
pub struct Spell {
pub id: String,
pub name: String,
pub description: String,
pub spell_type: String,
pub damage: i32,
pub heal: i32,
pub damage_type: String,
pub cost_mana: i32,
pub cost_endurance: i32,
pub cooldown_ticks: i32,
pub casting_ticks: i32,
pub min_guild_level: i32,
pub effect: Option<String>,
pub effect_duration: i32,
pub effect_magnitude: i32,
}
pub struct World {
@@ -402,6 +509,8 @@ pub struct World {
pub objects: HashMap<String, Object>,
pub races: Vec<Race>,
pub classes: Vec<Class>,
pub guilds: HashMap<String, Guild>,
pub spells: HashMap<String, Spell>,
}
impl World {
@@ -451,7 +560,39 @@ impl World {
let mut classes = Vec::new();
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}"))?;
classes.push(Class { id, name: cf.name, description: cf.description, base_stats: cf.base_stats, growth: cf.growth });
classes.push(Class { id, name: cf.name, description: cf.description, base_stats: cf.base_stats, growth: cf.growth, guild: cf.guild });
Ok(())
})?;
let mut guilds = HashMap::new();
load_entities_from_dir(&world_dir.join("guilds"), "guild", &mut |id, content| {
let gf: GuildFile = toml::from_str(content).map_err(|e| format!("Bad guild {id}: {e}"))?;
guilds.insert(id.clone(), Guild {
id, name: gf.name, description: gf.description,
max_level: if gf.max_level == 0 { 50 } else { gf.max_level },
resource: gf.resource.unwrap_or_else(|| "mana".into()),
base_mana: gf.base_mana, base_endurance: gf.base_endurance,
growth: gf.growth, spells: gf.spells,
min_player_level: gf.min_player_level,
race_restricted: gf.race_restricted,
});
Ok(())
})?;
let mut spells = HashMap::new();
load_entities_from_dir(&world_dir.join("spells"), "spell", &mut |id, content| {
let sf: SpellFile = toml::from_str(content).map_err(|e| format!("Bad spell {id}: {e}"))?;
spells.insert(id.clone(), Spell {
id, name: sf.name, description: sf.description,
spell_type: sf.spell_type.unwrap_or_else(|| "offensive".into()),
damage: sf.damage, heal: sf.heal,
damage_type: sf.damage_type.unwrap_or_else(|| "magical".into()),
cost_mana: sf.cost_mana, cost_endurance: sf.cost_endurance,
cooldown_ticks: sf.cooldown_ticks, casting_ticks: sf.casting_ticks,
min_guild_level: sf.min_guild_level,
effect: sf.effect, effect_duration: sf.effect_duration,
effect_magnitude: sf.effect_magnitude,
});
Ok(())
})?;
@@ -460,7 +601,7 @@ impl World {
let mut region_dirs: Vec<_> = entries
.filter_map(|e| e.ok())
.filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false))
.filter(|e| { let n = e.file_name().to_string_lossy().to_string(); n != "races" && n != "classes" })
.filter(|e| { let n = e.file_name().to_string_lossy().to_string(); n != "races" && n != "classes" && n != "guilds" && n != "spells" })
.collect();
region_dirs.sort_by_key(|e| e.file_name());
@@ -506,13 +647,27 @@ impl World {
if races.is_empty() { return Err("No races defined".into()); }
if classes.is_empty() { return Err("No classes defined".into()); }
log::info!("World '{}': {} rooms, {} npcs, {} objects, {} races, {} classes", manifest.name, rooms.len(), npcs.len(), objects.len(), races.len(), classes.len());
Ok(World { name: manifest.name, spawn_room: manifest.spawn_room, rooms, npcs, objects, races, classes })
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());
Ok(World { name: manifest.name, spawn_room: manifest.spawn_room, rooms, npcs, objects, races, classes, guilds, spells })
}
pub fn get_room(&self, id: &str) -> Option<&Room> { self.rooms.get(id) }
pub fn get_npc(&self, id: &str) -> Option<&Npc> { self.npcs.get(id) }
pub fn get_object(&self, id: &str) -> Option<&Object> { self.objects.get(id) }
pub fn get_guild(&self, id: &str) -> Option<&Guild> { self.guilds.get(id) }
pub fn get_spell(&self, id: &str) -> Option<&Spell> { self.spells.get(id) }
pub fn spells_for_guild(&self, guild_id: &str, guild_level: i32) -> Vec<&Spell> {
let guild = match self.guilds.get(guild_id) {
Some(g) => g,
None => return Vec::new(),
};
guild.spells.iter()
.filter_map(|sid| self.spells.get(sid))
.filter(|s| s.min_guild_level <= guild_level)
.collect()
}
}
fn load_toml<T: serde::de::DeserializeOwned>(path: &Path) -> Result<T, String> {