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

@@ -15,6 +15,10 @@ This is a Rust MUD server that accepts SSH connections. The architecture separat
- **Races are deeply data-driven**: body shape, equipment slots, natural weapons/armor, resistances, traits, regen rates — all defined in TOML. A dragon has different slots than a human.
- **Equipment is slot-based**: each race defines available body slots. Items declare their slot. The engine validates compatibility at equip time.
- **Items magically resize** — no size restrictions. A dragon can wield a human sword.
- **Guilds are data-driven**: defined in `world/guilds/*.toml`. Players can join multiple guilds. Guilds grant spells, stat growth, and resource pools.
- **Spells are separate files**: defined in `world/spells/*.toml`, referenced by guild TOML. A spell can be shared across guilds.
- **Classes seed initial guild**: each class TOML can reference a `guild` field. On character creation, the player auto-joins that guild at level 1.
- **Resources (mana/endurance)**: players have mana and endurance pools, derived from guild base values + racial stat modifiers. Regenerates out of combat.
## Architecture
@@ -25,7 +29,7 @@ src/
├── ssh.rs russh server/handler: connection lifecycle, chargen flow, command dispatch
├── game.rs Core runtime state: Player, GameState, SharedState, XorShift64 RNG
├── commands.rs Player command parsing and execution (immediate + queued actions)
├── combat.rs Tick-based combat resolution: attack, defend, flee, item use
├── combat.rs Tick-based combat resolution: attack, defend, flee, item use, cast
├── tick.rs Background tick engine: NPC AI, combat rounds, effects, respawns, regen
├── admin.rs Admin command implementations
├── chargen.rs Character creation state machine
@@ -46,11 +50,12 @@ src/
## How Combat Works
1. Player types `attack <npc>` → enters `CombatState` with `action: Some(Attack)`
2. Player can queue a different action before the tick fires (`defend`, `flee`, `use <item>`)
2. Player can queue a different action before the tick fires (`defend`, `flee`, `use <item>`, `cast <spell>`)
3. Tick engine iterates all players in combat, calls `combat::resolve_combat_tick()`
4. Resolution: execute player action → NPC counter-attacks → check death → clear action
5. If no action was queued, default is `Attack`
6. NPC auto-aggro: hostile NPCs initiate combat with players in their room on each tick
7. `cast <spell>` in combat queues `CombatAction::Cast(spell_id)`, resolved on tick: deducts mana/endurance, applies cooldown, deals damage or heals
## How Status Effects Work
@@ -90,6 +95,21 @@ src/
8. Resistances: damage_type → multiplier (0.0 = immune, 1.0 = normal, 1.5 = vulnerable)
9. `xp_rate` modifies XP gain (< 1.0 = slower leveling, for powerful races)
### New guild
1. Create `world/guilds/<name>.toml`
2. Required: `name`, `description`
3. Key fields: `max_level`, `resource` ("mana" or "endurance"), `base_mana`, `base_endurance`, `spells` (list of spell IDs), `min_player_level`, `race_restricted` (list of race IDs that can't join)
4. `[growth]` section: `hp_per_level`, `mana_per_level`, `endurance_per_level`, `attack_per_level`, `defense_per_level`
5. **TOML ordering matters**: put `spells`, `min_player_level`, `race_restricted` BEFORE any `[section]` headers
6. To link a class to a guild, add `guild = "guild:<filename_stem>"` to the class TOML
### New spell
1. Create `world/spells/<name>.toml`
2. Required: `name`, `description`
3. Key fields: `spell_type` ("offensive"/"heal"/"utility"), `damage`, `heal`, `damage_type`, `cost_mana`, `cost_endurance`, `cooldown_ticks`, `min_guild_level`
4. Optional: `effect` (status effect kind), `effect_duration`, `effect_magnitude`
5. Reference the spell ID (`spell:<filename_stem>`) from a guild's `spells` list
### New equipment slot
1. Add the slot name to a race's `[body] slots` array
2. Create objects with `slot = "<slot_name>"` in their TOML

View File

@@ -29,6 +29,14 @@ Run through these checks before every commit to ensure consistent feature covera
- [ ] On reconnect, expired effects are gone; active effects resume
- [ ] Verify with: `sqlite3 mudserver.db "SELECT * FROM players;"`
## Look Command
- [ ] `look` with no args shows the room (NPCs, objects, exits, players)
- [ ] `look <npc>` shows NPC description, stats, and attitude
- [ ] `look <object>` shows object description (floor or inventory)
- [ ] `look <exit>` shows where the exit leads
- [ ] `look <player>` shows player race, level, combat status
- [ ] `look <invalid>` shows "You don't see X here."
## Movement & Navigation
- [ ] `go north`, `n`, `south`, `s`, etc. all work
- [ ] Invalid direction shows error
@@ -93,10 +101,48 @@ Run through these checks before every commit to ensure consistent feature covera
- [ ] Negative status effects cleared on player death/respawn
- [ ] Status effects on offline players resolve by wall-clock time on next login
## Guilds
- [ ] `guild list` shows all available guilds with descriptions
- [ ] `guild info <name>` shows guild details, growth stats, and spell list
- [ ] `guild join <name>` adds player to guild at level 1
- [ ] `guild join` grants base mana/endurance from guild
- [ ] `guild leave <name>` removes guild membership
- [ ] Cannot join a guild twice
- [ ] Cannot leave a guild you're not in
- [ ] Race-restricted guilds reject restricted races
- [ ] Player level requirement enforced on `guild join`
- [ ] Multi-guild: player can be in multiple guilds simultaneously
- [ ] Guild membership persists across logout/login (stored in player_guilds table)
- [ ] `stats` shows guild memberships with levels
## Spells & Casting
- [ ] `spells` lists known spells grouped by guild with cost and cooldown
- [ ] Spells filtered by guild level (only shows spells at or below current guild level)
- [ ] `cast <spell>` out of combat: heal/utility resolves immediately
- [ ] `cast <spell>` out of combat: offensive spells blocked ("enter combat first")
- [ ] `cast <spell>` in combat: queues as CombatAction, resolves on tick
- [ ] Spell resolves: offensive deals damage to NPC, shows damage + type
- [ ] Spell resolves: heal restores HP, shows amount healed
- [ ] Spell resolves: utility applies status effect
- [ ] Mana deducted on cast; blocked if insufficient ("Not enough mana")
- [ ] Endurance deducted on cast; blocked if insufficient
- [ ] Cooldowns applied after casting; blocked if on cooldown ("X ticks remaining")
- [ ] Cooldowns tick down each server tick
- [ ] NPC can die from spell damage (awards XP, ends combat)
- [ ] Spell partial name matching works ("magic" matches "Magic Missile")
## Chargen & Guild Seeding
- [ ] Class selection shows "→ joins <Guild Name>" when class has a guild
- [ ] Creating a character with a class that references a guild auto-joins that guild
- [ ] Starting mana/endurance calculated from guild base + racial stat modifiers
- [ ] Guild membership saved to DB at character creation
## Passive Regeneration
- [ ] Players out of combat slowly regenerate HP over ticks
- [ ] Players out of combat slowly regenerate mana over ticks
- [ ] Players out of combat slowly regenerate endurance over ticks
- [ ] Regeneration does not occur while in combat
- [ ] HP does not exceed max_hp
- [ ] HP/mana/endurance do not exceed their maximums
## Tick Engine
- [ ] Tick runs at configured interval (~3 seconds)

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,35 +223,62 @@ 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;
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 {
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,
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> {

View File

@@ -1,5 +1,6 @@
name = "Cleric"
description = "Devout healers and protectors, clerics channel divine power to mend and shield."
guild = "guild:clerics_guild"
[base_stats]
max_hp = 100

View File

@@ -1,5 +1,6 @@
name = "Mage"
description = "Wielders of arcane power, mages trade resilience for devastating force."
guild = "guild:mages_guild"
[base_stats]
max_hp = 70

View File

@@ -1,5 +1,6 @@
name = "Rogue"
description = "Quick and cunning, rogues strike from the shadows with lethal precision."
guild = "guild:rogues_guild"
[base_stats]
max_hp = 85

View File

@@ -1,5 +1,6 @@
name = "Warrior"
description = "Masters of arms and armor, warriors lead the charge and hold the line."
guild = "guild:warriors_guild"
[base_stats]
max_hp = 120

View File

@@ -0,0 +1,16 @@
name = "Clerics Guild"
description = "Channels of divine power, the Clerics Guild teaches the sacred arts of healing, protection, and righteous combat. A balance of support and resilience."
max_level = 50
resource = "mana"
base_mana = 40
base_endurance = 20
spells = ["spell:heal", "spell:smite", "spell:divine_shield", "spell:purify"]
min_player_level = 0
race_restricted = []
[growth]
hp_per_level = 6
mana_per_level = 5
endurance_per_level = 2
attack_per_level = 1
defense_per_level = 2

View File

@@ -0,0 +1,16 @@
name = "Mages Guild"
description = "Wielders of arcane forces, the Mages Guild unlocks the mysteries of magical power. Members sacrifice physical resilience for devastating magical attacks."
max_level = 50
resource = "mana"
base_mana = 60
base_endurance = 0
spells = ["spell:magic_missile", "spell:fireball", "spell:frost_shield", "spell:arcane_blast"]
min_player_level = 0
race_restricted = []
[growth]
hp_per_level = 3
mana_per_level = 8
endurance_per_level = 0
attack_per_level = 1
defense_per_level = 0

View File

@@ -0,0 +1,16 @@
name = "Rogues Guild"
description = "Silent and deadly, the Rogues Guild teaches the arts of stealth, subterfuge, and precision strikes. Members trade raw power for cunning and speed."
max_level = 50
resource = "endurance"
base_mana = 0
base_endurance = 40
spells = ["spell:backstab", "spell:poison_blade", "spell:evasion", "spell:shadow_step"]
min_player_level = 0
race_restricted = []
[growth]
hp_per_level = 5
mana_per_level = 0
endurance_per_level = 6
attack_per_level = 3
defense_per_level = 0

View File

@@ -0,0 +1,16 @@
name = "Warriors Guild"
description = "Masters of martial combat, the Warriors Guild trains its members in the art of physical warfare. Emphasizes strength, endurance, and weapon mastery."
max_level = 50
resource = "endurance"
base_mana = 0
base_endurance = 50
spells = ["spell:power_strike", "spell:battle_cry", "spell:shield_wall", "spell:whirlwind"]
min_player_level = 0
race_restricted = []
[growth]
hp_per_level = 8
mana_per_level = 0
endurance_per_level = 5
attack_per_level = 2
defense_per_level = 1

View File

@@ -0,0 +1,8 @@
name = "Arcane Blast"
description = "Channel raw magical energy into an overwhelming burst of destructive force."
spell_type = "offensive"
damage = 35
damage_type = "magical"
cost_mana = 35
cooldown_ticks = 4
min_guild_level = 8

View File

@@ -0,0 +1,8 @@
name = "Backstab"
description = "Strike from the shadows with a precise, deadly attack that exploits your target's vulnerabilities."
spell_type = "offensive"
damage = 22
damage_type = "physical"
cost_endurance = 12
cooldown_ticks = 3
min_guild_level = 1

View File

@@ -0,0 +1,9 @@
name = "Battle Cry"
description = "A thunderous war shout that steels your resolve, boosting regeneration temporarily."
spell_type = "utility"
cost_endurance = 10
cooldown_ticks = 10
min_guild_level = 3
effect = "regen"
effect_duration = 5
effect_magnitude = 4

View File

@@ -0,0 +1,9 @@
name = "Divine Shield"
description = "Invoke the protection of the gods, wrapping yourself in a shimmering barrier of holy light."
spell_type = "utility"
cost_mana = 20
cooldown_ticks = 10
min_guild_level = 5
effect = "defense_up"
effect_duration = 5
effect_magnitude = 10

View File

@@ -0,0 +1,9 @@
name = "Evasion"
description = "Enter a heightened state of awareness, dodging incoming attacks with uncanny agility."
spell_type = "utility"
cost_endurance = 15
cooldown_ticks = 8
min_guild_level = 5
effect = "defense_up"
effect_duration = 3
effect_magnitude = 8

View File

@@ -0,0 +1,8 @@
name = "Fireball"
description = "Conjure a sphere of roaring flame and launch it at your foe, dealing massive fire damage."
spell_type = "offensive"
damage = 28
damage_type = "fire"
cost_mana = 25
cooldown_ticks = 3
min_guild_level = 5

View File

@@ -0,0 +1,9 @@
name = "Frost Shield"
description = "Encase yourself in a barrier of magical ice, absorbing damage and chilling attackers."
spell_type = "utility"
cost_mana = 20
cooldown_ticks = 8
min_guild_level = 3
effect = "defense_up"
effect_duration = 4
effect_magnitude = 12

7
world/spells/heal.toml Normal file
View File

@@ -0,0 +1,7 @@
name = "Heal"
description = "Channel divine energy to mend wounds and restore health."
spell_type = "heal"
heal = 25
cost_mana = 15
cooldown_ticks = 2
min_guild_level = 1

View File

@@ -0,0 +1,8 @@
name = "Magic Missile"
description = "Hurl bolts of pure arcane energy at your target. Simple but reliable."
spell_type = "offensive"
damage = 15
damage_type = "magical"
cost_mana = 10
cooldown_ticks = 1
min_guild_level = 1

View File

@@ -0,0 +1,11 @@
name = "Poison Blade"
description = "Coat your weapon with a virulent toxin, inflicting lingering poison on your target."
spell_type = "offensive"
damage = 8
damage_type = "poison"
cost_endurance = 10
cooldown_ticks = 6
min_guild_level = 3
effect = "poison"
effect_duration = 4
effect_magnitude = 3

View File

@@ -0,0 +1,8 @@
name = "Power Strike"
description = "A devastating overhead blow that channels raw strength into a single crushing attack."
spell_type = "offensive"
damage = 18
damage_type = "physical"
cost_endurance = 15
cooldown_ticks = 2
min_guild_level = 1

10
world/spells/purify.toml Normal file
View File

@@ -0,0 +1,10 @@
name = "Purify"
description = "Cleanse your body and soul of all harmful effects through divine grace."
spell_type = "heal"
heal = 10
cost_mana = 20
cooldown_ticks = 6
min_guild_level = 3
effect = "cleanse"
effect_duration = 0
effect_magnitude = 0

View File

@@ -0,0 +1,8 @@
name = "Shadow Step"
description = "Vanish into the shadows and reappear behind your foe, landing a devastating precision strike."
spell_type = "offensive"
damage = 30
damage_type = "physical"
cost_endurance = 25
cooldown_ticks = 5
min_guild_level = 8

View File

@@ -0,0 +1,9 @@
name = "Shield Wall"
description = "Brace yourself behind your weapon, drastically reducing incoming damage for a short time."
spell_type = "utility"
cost_endurance = 20
cooldown_ticks = 8
min_guild_level = 5
effect = "defense_up"
effect_duration = 4
effect_magnitude = 10

8
world/spells/smite.toml Normal file
View File

@@ -0,0 +1,8 @@
name = "Smite"
description = "Call down holy wrath upon your enemy, dealing radiant damage that burns the unholy."
spell_type = "offensive"
damage = 18
damage_type = "holy"
cost_mana = 15
cooldown_ticks = 2
min_guild_level = 1

View File

@@ -0,0 +1,8 @@
name = "Whirlwind"
description = "Spin in a deadly arc, striking all nearby foes with tremendous force."
spell_type = "offensive"
damage = 25
damage_type = "physical"
cost_endurance = 30
cooldown_ticks = 4
min_guild_level = 8