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>
442 lines
15 KiB
Rust
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"
|
|
}
|
|
}
|