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:
24
AGENTS.md
24
AGENTS.md
@@ -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.
|
- **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.
|
- **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.
|
- **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
|
## Architecture
|
||||||
|
|
||||||
@@ -25,7 +29,7 @@ src/
|
|||||||
├── ssh.rs russh server/handler: connection lifecycle, chargen flow, command dispatch
|
├── ssh.rs russh server/handler: connection lifecycle, chargen flow, command dispatch
|
||||||
├── game.rs Core runtime state: Player, GameState, SharedState, XorShift64 RNG
|
├── game.rs Core runtime state: Player, GameState, SharedState, XorShift64 RNG
|
||||||
├── commands.rs Player command parsing and execution (immediate + queued actions)
|
├── 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
|
├── tick.rs Background tick engine: NPC AI, combat rounds, effects, respawns, regen
|
||||||
├── admin.rs Admin command implementations
|
├── admin.rs Admin command implementations
|
||||||
├── chargen.rs Character creation state machine
|
├── chargen.rs Character creation state machine
|
||||||
@@ -46,11 +50,12 @@ src/
|
|||||||
## How Combat Works
|
## How Combat Works
|
||||||
|
|
||||||
1. Player types `attack <npc>` → enters `CombatState` with `action: Some(Attack)`
|
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()`
|
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
|
4. Resolution: execute player action → NPC counter-attacks → check death → clear action
|
||||||
5. If no action was queued, default is `Attack`
|
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
|
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
|
## How Status Effects Work
|
||||||
|
|
||||||
@@ -90,6 +95,21 @@ src/
|
|||||||
8. Resistances: damage_type → multiplier (0.0 = immune, 1.0 = normal, 1.5 = vulnerable)
|
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)
|
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
|
### New equipment slot
|
||||||
1. Add the slot name to a race's `[body] slots` array
|
1. Add the slot name to a race's `[body] slots` array
|
||||||
2. Create objects with `slot = "<slot_name>"` in their TOML
|
2. Create objects with `slot = "<slot_name>"` in their TOML
|
||||||
|
|||||||
48
TESTING.md
48
TESTING.md
@@ -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
|
- [ ] On reconnect, expired effects are gone; active effects resume
|
||||||
- [ ] Verify with: `sqlite3 mudserver.db "SELECT * FROM players;"`
|
- [ ] 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
|
## Movement & Navigation
|
||||||
- [ ] `go north`, `n`, `south`, `s`, etc. all work
|
- [ ] `go north`, `n`, `south`, `s`, etc. all work
|
||||||
- [ ] Invalid direction shows error
|
- [ ] 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
|
- [ ] Negative status effects cleared on player death/respawn
|
||||||
- [ ] Status effects on offline players resolve by wall-clock time on next login
|
- [ ] 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
|
## Passive Regeneration
|
||||||
- [ ] Players out of combat slowly regenerate HP over ticks
|
- [ ] 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
|
- [ ] Regeneration does not occur while in combat
|
||||||
- [ ] HP does not exceed max_hp
|
- [ ] HP/mana/endurance do not exceed their maximums
|
||||||
|
|
||||||
## Tick Engine
|
## Tick Engine
|
||||||
- [ ] Tick runs at configured interval (~3 seconds)
|
- [ ] Tick runs at configured interval (~3 seconds)
|
||||||
|
|||||||
@@ -83,8 +83,12 @@ impl ChargenState {
|
|||||||
ansi::bold("=== Choose Your Class ===")
|
ansi::bold("=== Choose Your Class ===")
|
||||||
));
|
));
|
||||||
for (i, class) in world.classes.iter().enumerate() {
|
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!(
|
out.push_str(&format!(
|
||||||
" {}{}.{} {} {}\r\n {}\r\n {}HP:{} {}ATK:{} {}DEF:{}{}\r\n",
|
" {}{}.{} {} {}{}\r\n {}\r\n {}HP:{} {}ATK:{} {}DEF:{}{}\r\n",
|
||||||
ansi::BOLD,
|
ansi::BOLD,
|
||||||
i + 1,
|
i + 1,
|
||||||
ansi::RESET,
|
ansi::RESET,
|
||||||
@@ -95,6 +99,7 @@ impl ChargenState {
|
|||||||
class.growth.attack_per_level,
|
class.growth.attack_per_level,
|
||||||
class.growth.defense_per_level,
|
class.growth.defense_per_level,
|
||||||
)),
|
)),
|
||||||
|
guild_info,
|
||||||
ansi::color(ansi::DIM, &class.description),
|
ansi::color(ansi::DIM, &class.description),
|
||||||
ansi::GREEN,
|
ansi::GREEN,
|
||||||
class.base_stats.max_hp,
|
class.base_stats.max_hp,
|
||||||
|
|||||||
@@ -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) => {
|
CombatAction::UseItem(idx) => {
|
||||||
if let Some(conn) = state.players.get_mut(&player_id) {
|
if let Some(conn) = state.players.get_mut(&player_id) {
|
||||||
if idx < conn.player.inventory.len() {
|
if idx < conn.player.inventory.len() {
|
||||||
|
|||||||
388
src/commands.rs
388
src/commands.rs
@@ -67,8 +67,9 @@ pub async fn execute(
|
|||||||
if conn.combat.is_some()
|
if conn.combat.is_some()
|
||||||
&& !matches!(
|
&& !matches!(
|
||||||
cmd.as_str(),
|
cmd.as_str(),
|
||||||
"attack" | "a" | "defend" | "def" | "flee" | "use" | "look" | "l"
|
"attack" | "a" | "defend" | "def" | "flee" | "use" | "cast" | "c"
|
||||||
| "stats" | "st" | "inventory" | "inv" | "i" | "quit" | "exit"
|
| "look" | "l" | "stats" | "st" | "inventory" | "inv" | "i"
|
||||||
|
| "spells" | "skills" | "quit" | "exit"
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
drop(st);
|
drop(st);
|
||||||
@@ -78,7 +79,7 @@ pub async fn execute(
|
|||||||
&format!(
|
&format!(
|
||||||
"{}\r\n{}",
|
"{}\r\n{}",
|
||||||
ansi::error_msg(
|
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()
|
ansi::prompt()
|
||||||
),
|
),
|
||||||
@@ -105,6 +106,9 @@ pub async fn execute(
|
|||||||
"attack" | "a" => cmd_attack(player_id, &args, state).await,
|
"attack" | "a" => cmd_attack(player_id, &args, state).await,
|
||||||
"defend" | "def" => cmd_defend(player_id, state).await,
|
"defend" | "def" => cmd_defend(player_id, state).await,
|
||||||
"flee" => cmd_flee(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,
|
"stats" | "st" => cmd_stats(player_id, state).await,
|
||||||
"admin" => cmd_admin(player_id, &args, state).await,
|
"admin" => cmd_admin(player_id, &args, state).await,
|
||||||
"help" | "h" | "?" => cmd_help(player_id, 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 {
|
async fn cmd_stats(pid: usize, state: &SharedState) -> CommandResult {
|
||||||
let st = state.lock().await;
|
let st = state.lock().await;
|
||||||
let conn = match st.players.get(&pid) {
|
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:"),
|
ansi::color(ansi::DIM, "Level:"),
|
||||||
s.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!(
|
out.push_str(&format!(
|
||||||
" {} {}/{}\r\n",
|
" {} {}/{}\r\n",
|
||||||
ansi::color(ansi::DIM, "XP:"),
|
ansi::color(ansi::DIM, "XP:"),
|
||||||
s.xp,
|
s.xp,
|
||||||
s.xp_to_next
|
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
|
// Show combat status
|
||||||
if let Some(ref combat) = conn.combat {
|
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)"),
|
("attack <target>, a", "Engage/attack a hostile NPC (tick-based)"),
|
||||||
("defend, def", "Defend next tick (reduces incoming damage)"),
|
("defend, def", "Defend next tick (reduces incoming damage)"),
|
||||||
("flee", "Attempt to flee combat (tick-based)"),
|
("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"),
|
("stats, st", "View your character stats"),
|
||||||
("help, h, ?", "Show this help"),
|
("help, h, ?", "Show this help"),
|
||||||
("quit, exit", "Leave the game"),
|
("quit, exit", "Leave the game"),
|
||||||
|
|||||||
98
src/db.rs
98
src/db.rs
@@ -15,6 +15,10 @@ pub struct SavedPlayer {
|
|||||||
pub defense: i32,
|
pub defense: i32,
|
||||||
pub inventory_json: String,
|
pub inventory_json: String,
|
||||||
pub equipped_json: String,
|
pub equipped_json: String,
|
||||||
|
pub mana: i32,
|
||||||
|
pub max_mana: i32,
|
||||||
|
pub endurance: i32,
|
||||||
|
pub max_endurance: i32,
|
||||||
pub is_admin: bool,
|
pub is_admin: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,6 +54,10 @@ pub trait GameDb: Send + Sync {
|
|||||||
fn load_all_effects(&self) -> Vec<StatusEffectRow>;
|
fn load_all_effects(&self) -> Vec<StatusEffectRow>;
|
||||||
fn tick_all_effects(&self) -> Vec<StatusEffectRow>;
|
fn tick_all_effects(&self) -> Vec<StatusEffectRow>;
|
||||||
fn clear_effects(&self, player_name: &str);
|
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 ---
|
// --- SQLite implementation ---
|
||||||
@@ -101,6 +109,13 @@ impl SqliteDb {
|
|||||||
remaining_ticks INTEGER NOT NULL,
|
remaining_ticks INTEGER NOT NULL,
|
||||||
magnitude INTEGER NOT NULL DEFAULT 0,
|
magnitude INTEGER NOT NULL DEFAULT 0,
|
||||||
PRIMARY KEY (player_name, kind)
|
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}"))?;
|
.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());
|
log::info!("Database opened: {}", path.display());
|
||||||
Ok(SqliteDb {
|
Ok(SqliteDb {
|
||||||
conn: std::sync::Mutex::new(conn),
|
conn: std::sync::Mutex::new(conn),
|
||||||
@@ -157,7 +185,8 @@ impl GameDb for SqliteDb {
|
|||||||
let conn = self.conn.lock().unwrap();
|
let conn = self.conn.lock().unwrap();
|
||||||
conn.query_row(
|
conn.query_row(
|
||||||
"SELECT name, race_id, class_id, room_id, level, xp, hp, max_hp,
|
"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",
|
FROM players WHERE name = ?1",
|
||||||
[name],
|
[name],
|
||||||
|row| {
|
|row| {
|
||||||
@@ -175,6 +204,10 @@ impl GameDb for SqliteDb {
|
|||||||
inventory_json: row.get(10)?,
|
inventory_json: row.get(10)?,
|
||||||
equipped_json: row.get::<_, String>(11).unwrap_or_else(|_| "{}".into()),
|
equipped_json: row.get::<_, String>(11).unwrap_or_else(|_| "{}".into()),
|
||||||
is_admin: row.get::<_, i32>(12)? != 0,
|
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 = self.conn.lock().unwrap();
|
||||||
let _ = conn.execute(
|
let _ = conn.execute(
|
||||||
"INSERT INTO players (name, race_id, class_id, room_id, level, xp, hp, max_hp,
|
"INSERT INTO players (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,
|
||||||
VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13)
|
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
|
ON CONFLICT(name) DO UPDATE SET
|
||||||
room_id=excluded.room_id, level=excluded.level, xp=excluded.xp,
|
room_id=excluded.room_id, level=excluded.level, xp=excluded.xp,
|
||||||
hp=excluded.hp, max_hp=excluded.max_hp, attack=excluded.attack,
|
hp=excluded.hp, max_hp=excluded.max_hp, attack=excluded.attack,
|
||||||
defense=excluded.defense, inventory_json=excluded.inventory_json,
|
defense=excluded.defense, inventory_json=excluded.inventory_json,
|
||||||
equipped_json=excluded.equipped_json,
|
equipped_json=excluded.equipped_json, is_admin=excluded.is_admin,
|
||||||
is_admin=excluded.is_admin",
|
mana=excluded.mana, max_mana=excluded.max_mana,
|
||||||
|
endurance=excluded.endurance, max_endurance=excluded.max_endurance",
|
||||||
rusqlite::params![
|
rusqlite::params![
|
||||||
p.name,
|
p.name, p.race_id, p.class_id, p.room_id, p.level, p.xp,
|
||||||
p.race_id,
|
p.hp, p.max_hp, p.attack, p.defense,
|
||||||
p.class_id,
|
p.inventory_json, p.equipped_json, p.is_admin as i32,
|
||||||
p.room_id,
|
p.mana, p.max_mana, p.endurance, p.max_endurance,
|
||||||
p.level,
|
|
||||||
p.xp,
|
|
||||||
p.hp,
|
|
||||||
p.max_hp,
|
|
||||||
p.attack,
|
|
||||||
p.defense,
|
|
||||||
p.inventory_json,
|
|
||||||
p.equipped_json,
|
|
||||||
p.is_admin as i32,
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -216,6 +242,7 @@ impl GameDb for SqliteDb {
|
|||||||
let _ = conn.execute("DELETE FROM players WHERE name = ?1", [name]);
|
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 npc_attitudes WHERE player_name = ?1", [name]);
|
||||||
let _ = conn.execute("DELETE FROM status_effects 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 {
|
fn set_admin(&self, name: &str, is_admin: bool) -> bool {
|
||||||
@@ -234,7 +261,8 @@ impl GameDb for SqliteDb {
|
|||||||
let mut stmt = conn
|
let mut stmt = conn
|
||||||
.prepare(
|
.prepare(
|
||||||
"SELECT name, race_id, class_id, room_id, level, xp, hp, max_hp,
|
"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",
|
FROM players ORDER BY name",
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -253,6 +281,10 @@ impl GameDb for SqliteDb {
|
|||||||
inventory_json: row.get(10)?,
|
inventory_json: row.get(10)?,
|
||||||
equipped_json: row.get::<_, String>(11).unwrap_or_else(|_| "{}".into()),
|
equipped_json: row.get::<_, String>(11).unwrap_or_else(|_| "{}".into()),
|
||||||
is_admin: row.get::<_, i32>(12)? != 0,
|
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()
|
.unwrap()
|
||||||
@@ -397,4 +429,32 @@ impl GameDb for SqliteDb {
|
|||||||
let conn = self.conn.lock().unwrap();
|
let conn = self.conn.lock().unwrap();
|
||||||
let _ = conn.execute("DELETE FROM status_effects WHERE player_name = ?1", [player_name]);
|
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],
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
45
src/game.rs
45
src/game.rs
@@ -18,6 +18,10 @@ pub struct PlayerStats {
|
|||||||
pub level: i32,
|
pub level: i32,
|
||||||
pub xp: i32,
|
pub xp: i32,
|
||||||
pub xp_to_next: i32,
|
pub xp_to_next: i32,
|
||||||
|
pub max_mana: i32,
|
||||||
|
pub mana: i32,
|
||||||
|
pub max_endurance: i32,
|
||||||
|
pub endurance: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct Player {
|
pub struct Player {
|
||||||
@@ -28,6 +32,8 @@ pub struct Player {
|
|||||||
pub stats: PlayerStats,
|
pub stats: PlayerStats,
|
||||||
pub inventory: Vec<Object>,
|
pub inventory: Vec<Object>,
|
||||||
pub equipped: HashMap<String, Object>,
|
pub equipped: HashMap<String, Object>,
|
||||||
|
pub guilds: HashMap<String, i32>,
|
||||||
|
pub cooldowns: HashMap<String, i32>,
|
||||||
pub is_admin: bool,
|
pub is_admin: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,6 +79,7 @@ pub enum CombatAction {
|
|||||||
Defend,
|
Defend,
|
||||||
Flee,
|
Flee,
|
||||||
UseItem(usize),
|
UseItem(usize),
|
||||||
|
Cast(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct CombatState {
|
pub struct CombatState {
|
||||||
@@ -234,6 +241,25 @@ impl GameState {
|
|||||||
let attack = base_atk + str_mod + dex_mod / 2;
|
let attack = base_atk + str_mod + dex_mod / 2;
|
||||||
let defense = base_def + con_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 {
|
let stats = PlayerStats {
|
||||||
max_hp,
|
max_hp,
|
||||||
hp: max_hp,
|
hp: max_hp,
|
||||||
@@ -242,6 +268,10 @@ impl GameState {
|
|||||||
level: 1,
|
level: 1,
|
||||||
xp: 0,
|
xp: 0,
|
||||||
xp_to_next: 100,
|
xp_to_next: 100,
|
||||||
|
max_mana,
|
||||||
|
mana: max_mana,
|
||||||
|
max_endurance,
|
||||||
|
endurance: max_endurance,
|
||||||
};
|
};
|
||||||
|
|
||||||
self.players.insert(
|
self.players.insert(
|
||||||
@@ -255,6 +285,8 @@ impl GameState {
|
|||||||
stats,
|
stats,
|
||||||
inventory: Vec::new(),
|
inventory: Vec::new(),
|
||||||
equipped: HashMap::new(),
|
equipped: HashMap::new(),
|
||||||
|
guilds,
|
||||||
|
cooldowns: HashMap::new(),
|
||||||
is_admin: false,
|
is_admin: false,
|
||||||
},
|
},
|
||||||
channel,
|
channel,
|
||||||
@@ -282,6 +314,9 @@ impl GameState {
|
|||||||
self.world.spawn_room.clone()
|
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 {
|
let stats = PlayerStats {
|
||||||
max_hp: saved.max_hp,
|
max_hp: saved.max_hp,
|
||||||
hp: saved.hp,
|
hp: saved.hp,
|
||||||
@@ -290,6 +325,10 @@ impl GameState {
|
|||||||
level: saved.level,
|
level: saved.level,
|
||||||
xp: saved.xp,
|
xp: saved.xp,
|
||||||
xp_to_next: saved.level * 100,
|
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(
|
self.players.insert(
|
||||||
@@ -303,6 +342,8 @@ impl GameState {
|
|||||||
stats,
|
stats,
|
||||||
inventory,
|
inventory,
|
||||||
equipped,
|
equipped,
|
||||||
|
guilds,
|
||||||
|
cooldowns: HashMap::new(),
|
||||||
is_admin: saved.is_admin,
|
is_admin: saved.is_admin,
|
||||||
},
|
},
|
||||||
channel,
|
channel,
|
||||||
@@ -333,6 +374,10 @@ impl GameState {
|
|||||||
defense: p.stats.defense,
|
defense: p.stats.defense,
|
||||||
inventory_json: inv_json,
|
inventory_json: inv_json,
|
||||||
equipped_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,
|
is_admin: p.is_admin,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
61
src/tick.rs
61
src/tick.rs
@@ -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 ---
|
// --- Passive regen for online players not in combat ---
|
||||||
if tick % REGEN_EVERY_N_TICKS == 0 {
|
if tick % REGEN_EVERY_N_TICKS == 0 {
|
||||||
let regen_pids: Vec<usize> = st
|
let regen_pids: Vec<usize> = st
|
||||||
.players
|
.players
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|(_, c)| {
|
.filter(|(_, c)| c.combat.is_none())
|
||||||
c.combat.is_none() && c.player.stats.hp < c.player.stats.max_hp
|
|
||||||
})
|
|
||||||
.map(|(&id, _)| id)
|
.map(|(&id, _)| id)
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
for pid in regen_pids {
|
for pid in regen_pids {
|
||||||
if let Some(conn) = st.players.get_mut(&pid) {
|
if let Some(conn) = st.players.get_mut(&pid) {
|
||||||
let heal =
|
let s = &mut conn.player.stats;
|
||||||
(conn.player.stats.max_hp * REGEN_PERCENT / 100).max(1);
|
let mut regen_msg = String::new();
|
||||||
let old = conn.player.stats.hp;
|
|
||||||
conn.player.stats.hp =
|
// HP regen
|
||||||
(conn.player.stats.hp + heal).min(conn.player.stats.max_hp);
|
if s.hp < s.max_hp {
|
||||||
let healed = conn.player.stats.hp - old;
|
let heal = (s.max_hp * REGEN_PERCENT / 100).max(1);
|
||||||
if healed > 0 {
|
let old = s.hp;
|
||||||
messages.entry(pid).or_default().push_str(&format!(
|
s.hp = (s.hp + heal).min(s.max_hp);
|
||||||
"\r\n {} You recover {} HP. ({}/{})\r\n",
|
let healed = s.hp - old;
|
||||||
ansi::color(ansi::DIM, "~~"),
|
if healed > 0 {
|
||||||
healed,
|
regen_msg.push_str(&format!(
|
||||||
conn.player.stats.hp,
|
"\r\n {} You recover {} HP. ({}/{})",
|
||||||
conn.player.stats.max_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(®en_msg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
st.save_player_to_db(pid);
|
st.save_player_to_db(pid);
|
||||||
|
|||||||
165
src/world.rs
165
src/world.rs
@@ -263,7 +263,7 @@ pub struct RaceFile {
|
|||||||
pub misc: RaceMiscFile,
|
pub misc: RaceMiscFile,
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Class TOML schema ---
|
// --- Class TOML schema (starter class — seeds initial guild on character creation) ---
|
||||||
|
|
||||||
#[derive(Deserialize, Default, Clone)]
|
#[derive(Deserialize, Default, Clone)]
|
||||||
pub struct ClassBaseStats {
|
pub struct ClassBaseStats {
|
||||||
@@ -293,6 +293,78 @@ pub struct ClassFile {
|
|||||||
pub base_stats: ClassBaseStats,
|
pub base_stats: ClassBaseStats,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub growth: ClassGrowth,
|
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 ---
|
// --- Runtime types ---
|
||||||
@@ -392,6 +464,41 @@ pub struct Class {
|
|||||||
pub description: String,
|
pub description: String,
|
||||||
pub base_stats: ClassBaseStats,
|
pub base_stats: ClassBaseStats,
|
||||||
pub growth: ClassGrowth,
|
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 {
|
pub struct World {
|
||||||
@@ -402,6 +509,8 @@ pub struct World {
|
|||||||
pub objects: HashMap<String, Object>,
|
pub objects: HashMap<String, Object>,
|
||||||
pub races: Vec<Race>,
|
pub races: Vec<Race>,
|
||||||
pub classes: Vec<Class>,
|
pub classes: Vec<Class>,
|
||||||
|
pub guilds: HashMap<String, Guild>,
|
||||||
|
pub spells: HashMap<String, Spell>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl World {
|
impl World {
|
||||||
@@ -451,7 +560,39 @@ impl World {
|
|||||||
let mut classes = Vec::new();
|
let mut classes = Vec::new();
|
||||||
load_entities_from_dir(&world_dir.join("classes"), "class", &mut |id, content| {
|
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}"))?;
|
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(())
|
Ok(())
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
@@ -460,7 +601,7 @@ impl World {
|
|||||||
let mut region_dirs: Vec<_> = entries
|
let mut region_dirs: Vec<_> = entries
|
||||||
.filter_map(|e| e.ok())
|
.filter_map(|e| e.ok())
|
||||||
.filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false))
|
.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();
|
.collect();
|
||||||
region_dirs.sort_by_key(|e| e.file_name());
|
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 races.is_empty() { return Err("No races defined".into()); }
|
||||||
if classes.is_empty() { return Err("No classes 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());
|
log::info!("World '{}': {} rooms, {} npcs, {} objects, {} races, {} classes, {} guilds, {} spells",
|
||||||
Ok(World { name: manifest.name, spawn_room: manifest.spawn_room, rooms, npcs, objects, races, classes })
|
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_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_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_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> {
|
fn load_toml<T: serde::de::DeserializeOwned>(path: &Path) -> Result<T, String> {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
name = "Cleric"
|
name = "Cleric"
|
||||||
description = "Devout healers and protectors, clerics channel divine power to mend and shield."
|
description = "Devout healers and protectors, clerics channel divine power to mend and shield."
|
||||||
|
guild = "guild:clerics_guild"
|
||||||
|
|
||||||
[base_stats]
|
[base_stats]
|
||||||
max_hp = 100
|
max_hp = 100
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
name = "Mage"
|
name = "Mage"
|
||||||
description = "Wielders of arcane power, mages trade resilience for devastating force."
|
description = "Wielders of arcane power, mages trade resilience for devastating force."
|
||||||
|
guild = "guild:mages_guild"
|
||||||
|
|
||||||
[base_stats]
|
[base_stats]
|
||||||
max_hp = 70
|
max_hp = 70
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
name = "Rogue"
|
name = "Rogue"
|
||||||
description = "Quick and cunning, rogues strike from the shadows with lethal precision."
|
description = "Quick and cunning, rogues strike from the shadows with lethal precision."
|
||||||
|
guild = "guild:rogues_guild"
|
||||||
|
|
||||||
[base_stats]
|
[base_stats]
|
||||||
max_hp = 85
|
max_hp = 85
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
name = "Warrior"
|
name = "Warrior"
|
||||||
description = "Masters of arms and armor, warriors lead the charge and hold the line."
|
description = "Masters of arms and armor, warriors lead the charge and hold the line."
|
||||||
|
guild = "guild:warriors_guild"
|
||||||
|
|
||||||
[base_stats]
|
[base_stats]
|
||||||
max_hp = 120
|
max_hp = 120
|
||||||
|
|||||||
16
world/guilds/clerics_guild.toml
Normal file
16
world/guilds/clerics_guild.toml
Normal 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
|
||||||
16
world/guilds/mages_guild.toml
Normal file
16
world/guilds/mages_guild.toml
Normal 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
|
||||||
16
world/guilds/rogues_guild.toml
Normal file
16
world/guilds/rogues_guild.toml
Normal 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
|
||||||
16
world/guilds/warriors_guild.toml
Normal file
16
world/guilds/warriors_guild.toml
Normal 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
|
||||||
8
world/spells/arcane_blast.toml
Normal file
8
world/spells/arcane_blast.toml
Normal 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
|
||||||
8
world/spells/backstab.toml
Normal file
8
world/spells/backstab.toml
Normal 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
|
||||||
9
world/spells/battle_cry.toml
Normal file
9
world/spells/battle_cry.toml
Normal 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
|
||||||
9
world/spells/divine_shield.toml
Normal file
9
world/spells/divine_shield.toml
Normal 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
|
||||||
9
world/spells/evasion.toml
Normal file
9
world/spells/evasion.toml
Normal 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
|
||||||
8
world/spells/fireball.toml
Normal file
8
world/spells/fireball.toml
Normal 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
|
||||||
9
world/spells/frost_shield.toml
Normal file
9
world/spells/frost_shield.toml
Normal 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
7
world/spells/heal.toml
Normal 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
|
||||||
8
world/spells/magic_missile.toml
Normal file
8
world/spells/magic_missile.toml
Normal 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
|
||||||
11
world/spells/poison_blade.toml
Normal file
11
world/spells/poison_blade.toml
Normal 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
|
||||||
8
world/spells/power_strike.toml
Normal file
8
world/spells/power_strike.toml
Normal 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
10
world/spells/purify.toml
Normal 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
|
||||||
8
world/spells/shadow_step.toml
Normal file
8
world/spells/shadow_step.toml
Normal 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
|
||||||
9
world/spells/shield_wall.toml
Normal file
9
world/spells/shield_wall.toml
Normal 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
8
world/spells/smite.toml
Normal 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
|
||||||
8
world/spells/whirlwind.toml
Normal file
8
world/spells/whirlwind.toml
Normal 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
|
||||||
Reference in New Issue
Block a user