//! Abilities (spec §6): skills and spells are **one thing**. "Kick" = melee //! delivery + a fill-damage effect. "Fireball" = projectile delivery + a fire //! (fill-damage) effect with a wind-up. Both resolve through the same composed //! two-axis cost grid. //! //! An [`AbilitySpec`] is authored data; [`Ability`] is its compiled form with an //! **absolute** [`AbilityCost`] baked in by [`compose_cost`]. The only structural //! axis separating a skill from a spell is `cast_time` (instant vs. wind-up). use crate::effect::{EffectSpec, Valence}; use crate::fixed::{apply_bp, units, Milli}; use serde::{Deserialize, Serialize}; /// Delivery / shape — sets targeting and a base cost contribution. #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum Delivery { /// Affects only the caster (recovery, self-buffs). SelfCast, /// One adjacent target, no wind-up cost premium. Touch, /// One target in melee — the auto-attack / weapon-skill shape. Melee, /// One target at range; the canonical spell shape. Projectile, /// One target, channel-flavored ranged. Beam, /// Up to `n` targets in an area. Aoe { n: u32 }, } impl Delivery { /// Whether this delivery can reach more than one enemy (informational; the /// authoritative target count is [`AbilitySpec::targets`]). pub fn max_targets(&self) -> u32 { match self { Delivery::Aoe { n } => *n, _ => 1, } } } /// Coefficients turning a spec into a cost — "the table" for cost (spec §11). All /// base costs are in **displayed units** of fill; fatigue defaults to a ratio of /// the composed fill unless an ability overrides it. #[derive(Clone, Debug, Serialize, Deserialize)] pub struct CostModel { pub base_fill_self: i64, pub base_fill_touch: i64, pub base_fill_melee: i64, pub base_fill_projectile: i64, pub base_fill_beam: i64, pub base_fill_aoe: i64, /// Default fatigue ("spend leaves a mark", §3): `fatigue = fill × this_bp`. /// Applied when an ability does not author `fatigue_units`. pub fatigue_ratio_bp: i64, /// Difficulty per unit of composed fill cost (drives fizzle, §9), in bp. /// `difficulty = round(fill_units × this_bp / 10_000)` when not authored. pub difficulty_per_fill_bp: i64, } impl Default for CostModel { fn default() -> Self { Self { base_fill_self: 0, base_fill_touch: 1, base_fill_melee: 1, base_fill_projectile: 2, base_fill_beam: 3, base_fill_aoe: 4, fatigue_ratio_bp: 2_000, // 20% of every point spent becomes fatigue difficulty_per_fill_bp: 10_000, // difficulty ≈ fill cost in units } } } impl CostModel { fn base_fill(&self, d: Delivery) -> i64 { match d { Delivery::SelfCast => self.base_fill_self, Delivery::Touch => self.base_fill_touch, Delivery::Melee => self.base_fill_melee, Delivery::Projectile => self.base_fill_projectile, Delivery::Beam => self.base_fill_beam, Delivery::Aoe { .. } => self.base_fill_aoe, } } } /// An authored, explicit cost in displayed units (used by `cost_override` for /// shapes the composer can't express cleanly, e.g. auto-attack's `(0, small)`). #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct RawCost { pub fill: i64, pub fatigue: i64, } /// The compiled, absolute cost of an ability, in milli-units. `fatigue` is the /// **net** ceiling change from paying (positive = a mark; recovery's net gain /// comes from its `Recovery` effect, not from a negative cost here). #[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] pub struct AbilityCost { pub fill: Milli, pub fatigue: Milli, } /// Authored ability data (config). Compiled to [`Ability`] via [`compile`]. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct AbilitySpec { pub id: String, pub name: String, /// Competency school this draws on for fizzle/effectiveness (§9). pub school: String, pub delivery: Delivery, #[serde(default)] pub effects: Vec, /// Scalar on effect magnitude *and* cost. bp; `10_000` == ×1.0. #[serde(default = "default_potency")] pub potency_bp: i64, #[serde(default = "default_targets")] pub targets: u32, /// Wind-up in ticks. `0` == instant (a skill); `>0` == a spell. #[serde(default)] pub cast_time: u32, /// Ticks before this ability is reusable. #[serde(default)] pub cooldown: u32, /// Explicit cost in displayed units; bypasses the composer when set. #[serde(default)] pub cost_override: Option, /// Explicit fatigue in displayed units; overrides only the fatigue column. #[serde(default)] pub fatigue_units: Option, /// Explicit difficulty (drives fizzle); derived from cost when absent. #[serde(default)] pub difficulty: Option, } fn default_potency() -> i64 { 10_000 } fn default_targets() -> u32 { 1 } /// A compiled ability: spec data plus its baked-in absolute cost and difficulty. #[derive(Clone, Debug, PartialEq, Eq)] pub struct Ability { pub id: String, pub name: String, pub school: String, pub delivery: Delivery, pub effects: Vec, pub potency_bp: i64, pub targets: u32, pub cast_time: u32, pub cooldown: u32, pub cost: AbilityCost, pub difficulty: i64, } impl Ability { /// Whether this ability only ever helps its targets (all effects friendly) — /// used by controllers/targeting to route a heal at an ally rather than a foe. pub fn is_support(&self) -> bool { !self.effects.is_empty() && self.effects.iter().all(|e| e.valence() == Valence::Friendly) } /// Whether this ability has any hostile effect (it attacks someone). pub fn is_offensive(&self) -> bool { self.effects.iter().any(|e| e.valence() == Valence::Hostile) } } /// Compile a spec into an [`Ability`], composing its absolute cost. pub fn compile(spec: &AbilitySpec, model: &CostModel) -> Ability { let cost = compose_cost(spec, model); let difficulty = spec.difficulty.unwrap_or_else(|| { // Derived difficulty ≈ composed fill cost in units, scaled. let fill_units = cost.fill / crate::fixed::UNIT; crate::fixed::mul_div_round(fill_units, model.difficulty_per_fill_bp, 10_000) }); Ability { id: spec.id.clone(), name: spec.name.clone(), school: spec.school.clone(), delivery: spec.delivery, effects: spec.effects.clone(), potency_bp: spec.potency_bp, targets: spec.targets.max(1), cast_time: spec.cast_time, cooldown: spec.cooldown, cost, difficulty: difficulty.max(0), } } /// Compose an absolute `(fill, fatigue)` cost (spec §6): base delivery cost plus /// each effect's contribution, scaled by potency, multiplied by target count. /// /// `fill = (base_delivery + Σ effect.cost_fill) × potency × targets`, then /// `fatigue = fill × fatigue_ratio` unless authored. An explicit `cost_override` /// short-circuits the whole thing. pub fn compose_cost(spec: &AbilitySpec, model: &CostModel) -> AbilityCost { if let Some(o) = spec.cost_override { return AbilityCost { fill: units(o.fill), fatigue: units(o.fatigue), }; } let targets = spec.targets.max(1) as i64; let base_units: i64 = model.base_fill(spec.delivery) + spec.effects.iter().map(|e| e.cost_fill).sum::(); // Scale by potency in milli-space, then multiply by target count. let mut fill = apply_bp(units(base_units), spec.potency_bp); fill *= targets; let fatigue = match spec.fatigue_units { Some(f) => units(f) * targets, // authored fatigue still scales with targets None => apply_bp(fill, model.fatigue_ratio_bp), }; AbilityCost { fill, fatigue } } #[cfg(test)] mod tests { use super::*; use crate::effect::EffectKind; use crate::fixed::UNIT; fn fill_dmg(mag: i64, cost: i64) -> EffectSpec { EffectSpec::new(EffectKind::FillDamage { magnitude_bp: mag }, cost) } #[test] fn composes_a_simple_skill_cost() { let model = CostModel::default(); let spec = AbilitySpec { id: "kick".into(), name: "Kick".into(), school: "martial".into(), delivery: Delivery::Melee, effects: vec![fill_dmg(1_000, 4)], // melee base 1 + effect 4 = 5 fill potency_bp: 10_000, targets: 1, cast_time: 0, cooldown: 0, cost_override: None, fatigue_units: None, difficulty: None, }; let ab = compile(&spec, &model); assert_eq!(ab.cost.fill, 5 * UNIT); // 20% fatigue ratio -> 1.0 unit fatigue. assert_eq!(ab.cost.fatigue, UNIT); assert!(ab.is_offensive()); } #[test] fn potency_and_targets_scale_cost() { let model = CostModel::default(); let base = AbilitySpec { id: "nuke".into(), name: "Nuke".into(), school: "invocation".into(), delivery: Delivery::Aoe { n: 6 }, effects: vec![fill_dmg(2_000, 6)], // aoe base 4 + 6 = 10 fill at 1x,1 target potency_bp: 20_000, // ×2 potency targets: 6, // ×6 targets cast_time: 20, cooldown: 0, cost_override: None, fatigue_units: None, difficulty: None, }; let ab = compile(&base, &model); // (10 units × 2.0) × 6 = 120 units fill. assert_eq!(ab.cost.fill, 120 * UNIT); // difficulty derived from fill units (120) × 1.0. assert_eq!(ab.difficulty, 120); } #[test] fn cost_override_makes_auto_attack_shape() { let model = CostModel::default(); let spec = AbilitySpec { id: "swing".into(), name: "Swing".into(), school: "martial".into(), delivery: Delivery::Melee, effects: vec![fill_dmg(1_000, 0)], potency_bp: 10_000, targets: 1, cast_time: 0, cooldown: 10, cost_override: Some(RawCost { fill: 0, fatigue: 1 }), // (0 fill, +small) fatigue_units: None, difficulty: None, }; let ab = compile(&spec, &model); assert_eq!(ab.cost.fill, 0); assert_eq!(ab.cost.fatigue, UNIT); } #[test] fn support_ability_is_detected() { let model = CostModel::default(); let spec = AbilitySpec { id: "mend".into(), name: "Mend".into(), school: "restoration".into(), delivery: Delivery::SelfCast, effects: vec![EffectSpec::new(EffectKind::Recovery { magnitude_bp: 1_500 }, 2)], potency_bp: 10_000, targets: 1, cast_time: 0, cooldown: 30, cost_override: None, fatigue_units: None, difficulty: None, }; let ab = compile(&spec, &model); assert!(ab.is_support()); assert!(!ab.is_offensive()); } }