//! Controllers (spec §5): the pluggable *intent* layer. The [`Actor`] is inert //! state; a `Controller` decides what it tries to do each tick. Players and //! monsters run the identical combat math — only the controller differs, so a //! ladder of scripted policies of varying skill is exactly how we measure the //! gradual→brutal difficulty curve (§12). //! //! A controller returns an [`Action`]; the engine validates affordability, //! cooldown, and fizzle, then resolves it. Controllers only ever *read* the //! world (`&View`) and draw from a deterministic, per-decision forked RNG, so the //! whole decision step is reproducible (§10 step 0). use crate::actor::{Actor, ActorId}; use crate::ability::Ability; use crate::config::Rules; use crate::effect::EffectKind; use crate::fixed::{apply_bp, Milli, BP_ONE}; use crate::mitigation::MitigationStage; use crate::rng::Rng; /// What an actor tries to do this tick. The engine has final say (it may block an /// unaffordable/on-cooldown choice and record the reason). #[derive(Clone, Debug, PartialEq, Eq)] pub enum Action { /// Wait — regen and watch. Idle, /// Begin `ability_id` against `targets` (resolved from the actor's loadout). Use { ability_id: String, targets: Vec }, } /// A read-only snapshot the controller reasons over. pub struct View<'a> { pub me: &'a Actor, pub actors: &'a [Actor], pub tick: u64, pub rules: &'a Rules, } impl<'a> View<'a> { /// Conscious enemies (different team, still in the fight). pub fn enemies(&self) -> Vec<&'a Actor> { self.actors .iter() .filter(|a| a.team != self.me.team && a.is_active()) .collect() } /// Conscious allies other than me. pub fn allies(&self) -> Vec<&'a Actor> { self.actors .iter() .filter(|a| a.team == self.me.team && a.id != self.me.id && a.is_active()) .collect() } /// The enemy closest to going down (lowest cap, ties broken by lowest fill /// then id) — the focus-fire / punish target. pub fn weakest_enemy(&self) -> Option<&'a Actor> { self.enemies() .into_iter() .min_by_key(|a| (a.cap(), a.vigor, a.id)) } /// An enemy mid-cast (a wind-up worth reacting to defensively). pub fn enemy_casting(&self) -> Option<&'a Actor> { self.enemies().into_iter().find(|a| a.casting.is_some()) } fn usable(&self, ab: &Ability) -> bool { ab.cost.fill <= self.me.vigor && !self.me.on_cooldown(&ab.id, self.tick) } } // --- ability introspection (role inference, no manual tagging) ------------- fn is_recovery(ab: &Ability) -> bool { ab.effects.iter().any(|e| { matches!(e.kind, EffectKind::Recovery { .. } | EffectKind::Hot { .. }) }) } fn is_active_defense(ab: &Ability) -> bool { ab.effects.iter().any(|e| { matches!( e.kind, EffectKind::MitigationBuff { stage: MitigationStage::ActiveDefense, .. } ) }) } /// Total fill-damage magnitude (bp of target max_vigor) this ability throws, /// folding in potency — the rough "how big a hit" used to spot a punish. fn fill_damage_bp(ab: &Ability) -> i64 { let raw: i64 = ab .effects .iter() .filter_map(|e| match e.kind { EffectKind::FillDamage { magnitude_bp } => Some(magnitude_bp), EffectKind::Dot { per_tick_bp, duration } => Some(per_tick_bp * duration as i64), _ => None, }) .sum(); apply_bp(raw, ab.potency_bp) } /// The actor's auto-attack: the cheapest offensive ability (fill cost), ties to /// the lowest id-stable order. Falls back to any offensive ability. fn auto_attack(me: &Actor) -> Option<&Ability> { me.loadout .iter() .filter(|a| a.is_offensive()) .min_by_key(|a| (a.cost.fill, a.name.clone())) } /// The biggest affordable, off-cooldown offensive hit. fn best_heavy<'a>(view: &View<'a>) -> Option<&'a Ability> { view.me .loadout .iter() .filter(|a| a.is_offensive() && view.usable(a)) .max_by_key(|a| (fill_damage_bp(a), a.cost.fill)) } /// A usable recovery ability, if any. fn usable_recovery<'a>(view: &View<'a>) -> Option<&'a Ability> { view.me .loadout .iter() .find(|a| is_recovery(a) && view.usable(a)) } /// A usable active-defense ability, if any. fn usable_defense<'a>(view: &View<'a>) -> Option<&'a Ability> { view.me .loadout .iter() .find(|a| is_active_defense(a) && view.usable(a)) } /// Estimate the landed magnitude of `ab` against `target` for punish timing — /// con + armor only (defenses/buffs are unknowable to the attacker). Good enough /// to judge "would this overrun their capacity?". fn estimate_landed(view: &View, ab: &Ability, target: &Actor) -> Milli { let pre = apply_bp(target.max_vigor, fill_damage_bp(ab).max(0)); let eff_level = view .me .effective_level(&ab.school, view.rules.competency_level_per_point_bp); let con = view.rules.con_mult_bp(eff_level, target.level); let after_con = apply_bp(pre, con); apply_bp(after_con, (BP_ONE - target.armor_bp).max(0)) } /// Whether `landed` would push `target` past the daze threshold (overruns /// capacity) — the moment to strike (§4/§5). fn would_overrun(view: &View, landed: Milli, target: &Actor) -> bool { let lhs = landed as i128 * BP_ONE as i128; let rhs = target.vigor as i128 * view.rules.stagger.daze_threshold_bp as i128; lhs > rhs } /// The decision interface. `decide` is pure w.r.t. the world (`&View`) and draws /// only from the deterministic `rng`. pub trait Controller: std::fmt::Debug { fn decide(&mut self, view: &View, rng: &mut Rng) -> Action; fn label(&self) -> &str; } /// Dumb AI **and** the §12 auto-attack-only guardrail policy: only ever swings, /// on cooldown, at the weakest enemy. Never touches an ability. If the fraction /// of fights this wins is high, the interesting verbs are a tax (thesis broken). #[derive(Debug, Clone)] pub struct SwingOnCooldown; impl Controller for SwingOnCooldown { fn decide(&mut self, view: &View, _rng: &mut Rng) -> Action { let Some(target) = view.weakest_enemy() else { return Action::Idle; }; match auto_attack(view.me) { Some(ab) if view.usable(ab) => Action::Use { ability_id: ab.id.clone(), targets: vec![target.id], }, _ => Action::Idle, } } fn label(&self) -> &str { "swing_on_cooldown" } } /// A "player" policy with a **skill** knob (0..100). It knows the right play — /// recover when low, defend a telegraphed cast, punish a spent enemy with the /// heavy, poke otherwise — but executes the optimal line only `skill`% of the /// time, otherwise falling back to a plain swing. Sweeping `skill` traces the /// difficulty curve (§5/§12). #[derive(Debug, Clone)] pub struct ScriptedPlayer { /// 0 = flails, 100 = always optimal. pub skill: i64, /// Recover when cap drops below this fraction of max (bp). pub recover_below_bp: i64, } impl ScriptedPlayer { pub fn new(skill: i64) -> Self { Self { skill: skill.clamp(0, 100), recover_below_bp: 3_500 } } fn plays_well(&self, rng: &mut Rng) -> bool { rng.chance_bp(self.skill * 100) // skill% as bp } } impl Controller for ScriptedPlayer { fn decide(&mut self, view: &View, rng: &mut Rng) -> Action { let Some(target) = view.weakest_enemy() else { return Action::Idle; }; let optimal = self.plays_well(rng); let swing = || -> Action { match auto_attack(view.me) { Some(ab) if view.usable(ab) => Action::Use { ability_id: ab.id.clone(), targets: vec![target.id], }, _ => Action::Idle, } }; if !optimal { // Misplay: just swing (or idle if it can't). return swing(); } // 1. Survival first: low ceiling → recover. let cap_frac = if view.me.max_vigor > 0 { (view.me.cap() as i128 * BP_ONE as i128 / view.me.max_vigor as i128) as i64 } else { 0 }; if cap_frac < self.recover_below_bp { if let Some(rec) = usable_recovery(view) { return Action::Use { ability_id: rec.id.clone(), targets: vec![view.me.id], }; } } // 2. React to a telegraphed cast: defend. if view.enemy_casting().is_some() { if let Some(def) = usable_defense(view) { return Action::Use { ability_id: def.id.clone(), targets: vec![view.me.id], }; } } // 3. Pressure with the heaviest affordable hit. A spent enemy means a // punish (the hit overruns their capacity → stagger); otherwise it's // just proactive damage while our own fill is healthy enough to spend. // Real players lead with their big button — hoarding it for the exact // overrun frame is not representative. let fill_frac = if view.me.max_vigor > 0 { (view.me.vigor as i128 * BP_ONE as i128 / view.me.max_vigor as i128) as i64 } else { 0 }; if let Some(heavy) = best_heavy(view) { let est = estimate_landed(view, heavy, target); let punish = would_overrun(view, est, target); if punish || fill_frac > 4_500 { return Action::Use { ability_id: heavy.id.clone(), targets: vec![target.id], }; } } // 4. Otherwise poke with the cheapest usable offensive ability. swing() } fn label(&self) -> &str { "scripted_player" } } /// The hold-and-punish AI (§5): conserve, watch the target's fill, and unload the /// biggest hit exactly when they've spent low enough to overrun their capacity. /// Defends telegraphed casts. The timing meta-game, played by the machine. #[derive(Debug, Clone)] pub struct HoldAndPunish { /// Pure-poke fallback below this cap fraction (don't sit at death's door). pub panic_below_bp: i64, } impl Default for HoldAndPunish { fn default() -> Self { Self { panic_below_bp: 3_000 } } } impl Controller for HoldAndPunish { fn decide(&mut self, view: &View, _rng: &mut Rng) -> Action { let Some(target) = view.weakest_enemy() else { return Action::Idle; }; // Self-preservation: if our own ceiling is collapsing, recover. let cap_frac = if view.me.max_vigor > 0 { (view.me.cap() as i128 * BP_ONE as i128 / view.me.max_vigor as i128) as i64 } else { 0 }; if cap_frac < self.panic_below_bp { if let Some(rec) = usable_recovery(view) { return Action::Use { ability_id: rec.id.clone(), targets: vec![view.me.id], }; } } // Defend a telegraphed cast. if view.enemy_casting().is_some() { if let Some(def) = usable_defense(view) { return Action::Use { ability_id: def.id.clone(), targets: vec![view.me.id], }; } } // The haymaker: fire the heaviest hit the instant it would overrun. if let Some(heavy) = best_heavy(view) { let est = estimate_landed(view, heavy, target); if would_overrun(view, est, target) { return Action::Use { ability_id: heavy.id.clone(), targets: vec![target.id], }; } } // Not the moment — chip with a cheap swing, conserving the bar for the // punish window rather than burning the heavy early. match auto_attack(view.me) { Some(ab) if view.usable(ab) => Action::Use { ability_id: ab.id.clone(), targets: vec![target.id], }, _ => Action::Idle, } } fn label(&self) -> &str { "hold_and_punish" } } /// Input-driven controller for a real-time front-end (spec §5 "player controller"). /// /// Intent lives behind a shared [`HumanHandle`] the UI mutates between ticks: a /// queued one-shot action (a clicked ability) takes priority; otherwise, if /// auto-attack is engaged, it swings at the current target. The engine drives it /// like any other controller, so the player runs the identical combat math. #[derive(Debug, Default)] pub struct HumanIntent { /// A one-shot action set by the UI; consumed on the next decision. pub queued: Option, /// The actor the passive auto-attack swings at. pub target: Option, /// Whether auto-attack is engaged (classic always-on melee). pub auto_attack: bool, } /// A shared handle to a player's intent. The UI holds one clone, the controller /// the other. Single-threaded (front-end / WASM), so `Rc>` is right. pub type HumanHandle = std::rc::Rc>; /// The player's controller. Build with [`HumanController::new`], then hand its /// [`HumanController::handle`] to the UI to push intent. #[derive(Debug, Clone)] pub struct HumanController { handle: HumanHandle, } impl Default for HumanController { fn default() -> Self { Self::new() } } impl HumanController { pub fn new() -> Self { Self { handle: std::rc::Rc::new(std::cell::RefCell::new(HumanIntent { queued: None, target: None, auto_attack: true, })), } } /// A clone of the shared intent handle for the UI to mutate. pub fn handle(&self) -> HumanHandle { self.handle.clone() } } impl Controller for HumanController { fn decide(&mut self, view: &View, _rng: &mut Rng) -> Action { let mut intent = self.handle.borrow_mut(); if let Some(action) = intent.queued.take() { return action; } if intent.auto_attack { // Prefer the explicitly chosen target if it's still a live enemy, // else fall back to the weakest enemy. let target = intent .target .and_then(|t| view.actors.get(t as usize)) .filter(|a| a.team != view.me.team && a.is_active()) .map(|a| a.id) .or_else(|| view.weakest_enemy().map(|a| a.id)); if let Some(t) = target { if let Some(ab) = auto_attack(view.me) { if view.usable(ab) { return Action::Use { ability_id: ab.id.clone(), targets: vec![t] }; } } } } Action::Idle } fn label(&self) -> &str { "human" } }