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:
165
src/world.rs
165
src/world.rs
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user