reikhelm/combat/combat-core/src/controller.rs
Parley Hatch 57dba5e149 feat(combat): order-of-magnitude slowdown — fights now ~20-30s
Fights were ~3-5s; the user wanted 20-30s. Tuned the table (and added one
mechanic) so they land there, re-validated against the sim throughout.

- Engine: a `global_cooldown` (the genre swing timer, §10 "attack delay") — an
  actor can't start a new action until it elapses, so no button-masher can
  out-DPS the intended tempo. Config-driven (`global_cooldown` ticks; 0 = off).
  Actor gains `next_action_at`; engine gates decisions on it.
- Controller: ScriptedPlayer now pressures with its heavy proactively when
  healthy (not only on the exact overrun frame), so the sim measures what a real
  player experiences instead of an unrealistically patient bound.
- Config rebalance: damage cut + flattened, fatigue/regen/recovery co-tuned, and
  the kit made roughly **DPS-neutral** with auto-attack (each ability's cooldown
  matched to auto's damage-per-second). Under the global cooldown that's the key
  insight: abilities win through stagger leverage + timing, not raw throughput —
  otherwise strong abilities trivialize the pace and flat ones aren't worth their
  fill (the guardrail kept inverting until this landed). Auto-attacks (difficulty
  0) also no longer fizzle.
- Front-end: cache-bust the config fetch so edits are always picked up; widened
  the sim's archetype sweep to all six monsters.

Result (sim, skilled play ≈ real play): monster fights 16-38s (centered ~24s);
guardrail steep and green (even-con auto 0% → skill20 2% → skill50 12% →
skill80 44% → skill100 78%); all six monsters winnable. Verified live in-browser:
realistic play lands ~15-30s and a clean mid-fight reads perfectly on the dual bar.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 13:34:10 -06:00

442 lines
15 KiB
Rust

//! 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<ActorId> },
}
/// 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<Action>,
/// The actor the passive auto-attack swings at.
pub target: Option<ActorId>,
/// 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<RefCell<…>>` is right.
pub type HumanHandle = std::rc::Rc<std::cell::RefCell<HumanIntent>>;
/// 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"
}
}