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>
This commit is contained in:
Parley Hatch 2026-06-03 13:34:10 -06:00
parent 902a233229
commit 57dba5e149
8 changed files with 83 additions and 45 deletions

View file

@ -126,28 +126,34 @@ open http://127.0.0.1:8013/combat-web/
With the shipped `configs/default.json` (these numbers are tuned placeholders — the
whole point is that they're a table you edit):
- **Duration tent** — fights are trivially short at the con extremes (~15s) and
longest/most-contested at even con (~30s). The intended "trivial → satisfying →
lethal" shape.
- **Skill matters** — at even con, win rate climbs monotonically with policy:
auto-only **~1%** → skill20 **10%** → skill50 **24%** → skill80 **29%**
skill100 **34%**.
- **Anti-turtle guardrail is green** — in the contested band the full kit beats
auto-attack-only decisively (≈29% vs ≈1% at even con). Spending on the
interesting verbs is worth it, which is the whole thesis.
- **Mirror matches are fair** — even-con skill-vs-skill is ≈ symmetric.
- **Fights last ~2030s** — monster matchups (a skilled player) resolve in roughly
1638s (melee bruisers faster, casters longer). The pace is set by the **global
cooldown** (the swing timer, §10): you act ~once per 1.5s, so no button-masher
can out-DPS the intended tempo.
- **Skill matters** — at even con, win rate climbs steeply and monotonically with
policy: auto-only **0%** → skill20 **2%** → skill50 **12%** → skill80 **44%**
skill100 **78%**.
- **Anti-turtle guardrail is green** — the full kit dominates auto-attack-only
(78% vs 0% at even con). Spending on the interesting verbs is worth it, which is
the whole thesis.
- **All six monsters are winnable** by skilled play; the Golem and Troll demand it.
Getting there was the sim working as designed: the *first* default numbers
produced a one-tick cliff and 82% mutual-KO draws, and revealed the §15 deepest
risk live — a poke skill that was strictly worse than the free auto-attack, so
auto-only out-won the full kit. Tuning the table (con curve, competency coupling,
ability efficiency, damage variance) moved it to the curves above. **That loop —
sim → measure → tune → re-sim — is the deliverable.**
Getting there was the sim working as designed. The *first* default numbers
produced a one-tick cliff and 82% mutual-KO draws and revealed the §15 deepest
risk live — a poke skill strictly worse than the free auto-attack, so auto-only
out-won the full kit. Slowing the fights to a ~2030s feel surfaced the next
lesson: under a global cooldown, any ability that just out-damages auto-attack
either trivializes the pace or isn't worth its fill, so the kit is tuned
**DPS-neutral** — abilities match auto's damage-per-second and win through
*stagger leverage and timing*, not raw throughput. **That loop — sim → measure →
tune → re-sim — is the deliverable.**
## Tuning
Everything balance-relevant lives in `configs/default.json`. Editing it is the
primary balancing act; only structural rule changes need a recompile. The levers
the feel rides on (spec §15): the **con curve** shape, the **regen vs. drain vs.
spend** rates (co-tuned or even-con fights are un-loseable or stagger-locked), and
keeping abilities ahead of auto-attack on damage-per-resource or utility.
the feel rides on (spec §15): the **`global_cooldown`** (raise it → slower, more
deliberate fights; the cleanest uniform pace knob), the **con curve** shape, the
**regen vs. drain vs. spend** rates (co-tuned or even-con fights are un-loseable
or stagger-locked), and keeping abilities ahead of auto-attack on *stagger /
utility* (under the global cooldown, raw DPS just trivializes the pace).

View file

@ -159,6 +159,9 @@ pub struct Actor {
pub statuses: Vec<Status>,
pub casting: Option<Cast>,
pub stagger: StaggerState,
/// Earliest tick this actor may begin a new action (the global-cooldown /
/// swing-timer gate, §10). `0` until the first action.
pub next_action_at: u64,
}
impl Actor {
@ -494,6 +497,7 @@ pub fn compile_actor(id: ActorId, sb: &StatBlock, loadout: Vec<Ability>, tick_ra
statuses: Vec::new(),
casting: None,
stagger: StaggerState::Normal,
next_action_at: 0,
}
}

View file

@ -94,6 +94,11 @@ pub struct CombatConfig {
/// Fixed tick rate (ticks/second, §10).
#[serde(default = "default_tick_rate")]
pub tick_rate: u32,
/// Global cooldown (ticks): the minimum gap between an actor's actions — the
/// genre's swing timer (§10 "attack delay"). Bounds how fast *any* policy can
/// act, so a button-masher can't out-DPS the intended pace. `0` disables it.
#[serde(default)]
pub global_cooldown: u32,
/// ± spread on every landed fill hit, bp. `1_500` == ±15%. The genre's damage
/// range; also the symmetry-breaker that turns a deterministic mirror match
/// from a coin-flip-of-draws into smooth win-rate curves.
@ -128,6 +133,7 @@ impl Default for CombatConfig {
fn default() -> Self {
Self {
tick_rate: 10,
global_cooldown: 0,
damage_variance_bp: 0,
regen: RegenConfig::default(),
stagger: StaggerConfig::default(),
@ -145,6 +151,7 @@ impl Default for CombatConfig {
#[derive(Clone, Debug)]
pub struct Rules {
pub tick_rate: u32,
pub global_cooldown: u32,
pub damage_variance_bp: i64,
pub stagger: StaggerParams,
pub regen: RegenParams,
@ -191,6 +198,7 @@ impl CombatConfig {
Rules {
tick_rate,
global_cooldown: self.global_cooldown,
damage_variance_bp: self.damage_variance_bp.max(0),
stagger,
regen,

View file

@ -257,10 +257,20 @@ impl Controller for ScriptedPlayer {
}
}
// 3. Punish a spent enemy with the heavy if it would overrun them.
// 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);
if would_overrun(view, est, 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],

View file

@ -205,6 +205,11 @@ impl Encounter {
}
continue;
}
// Global cooldown / swing timer: still mid-recovery from the last
// action, so no new one can start this tick (§10 attack delay).
if self.tick < self.actors[idx].next_action_at {
continue;
}
let action = {
let view = View {
me: &self.actors[idx],
@ -252,6 +257,8 @@ impl Encounter {
let fill_before = self.actors[idx].vigor;
self.actors[idx].pay(ability.cost);
self.recorder.spent(source, ability.cost.fill);
// Open the global cooldown — no new action until it elapses.
self.actors[idx].next_action_at = self.tick + self.rules.global_cooldown as u64;
if ability.cooldown > 0 {
self.actors[idx]
.cooldowns

View file

@ -367,7 +367,7 @@ fn con_sweep(config: &CombatConfig, rules: &Rules, seeds: u64, max_ticks: u64, o
/// A skilled player against the three archetypes, to feel out asymmetric matchups.
fn archetype_sweep(config: &CombatConfig, rules: &Rules, seeds: u64, max_ticks: u64, out: &mut Vec<FightRow>) {
let player_level = 10;
let enemies = ["golem", "rat", "battlemage"];
let enemies = ["rat", "skeleton", "battlemage", "golem", "troll", "wraith"];
let ctrl = PlayerCtrl::Skill(80);
for enemy_name in enemies {
let enemy_level = config.stat_blocks.get(enemy_name).map(|b| b.level).unwrap_or(player_level);

View file

@ -34,7 +34,9 @@ const round = (x) => Math.round(x);
async function boot() {
await init();
cfgText = await (await fetch("../configs/default.json")).text();
// Cache-bust so config edits are always picked up (the browser otherwise
// serves a stale fetch and the game runs old numbers).
cfgText = await (await fetch("../configs/default.json?v=" + Date.now())).text();
const cfg = JSON.parse(cfgText);
tickMs = Math.max(20, Math.round(1000 / (cfg.tick_rate || 10)));
for (const a of cfg.abilities || []) {

View file

@ -2,6 +2,7 @@
"_comment": "The combat 'table' (spec §11). Magnitudes are displayed units; ratios/percentages are basis points (10000 = 1.0 = 100%); durations are ticks. Editing this file is the primary balancing act. tick_rate ticks = 1 second.",
"tick_rate": 10,
"global_cooldown": 15,
"damage_variance_bp": 1500,
"regen": {
@ -10,11 +11,11 @@
},
"stagger": {
"daze_threshold_bp": 10000,
"unconscious_threshold_bp": 15000,
"daze_threshold_bp": 13000,
"unconscious_threshold_bp": 20000,
"daze_duration": 8,
"revive_daze": 12,
"fatigue_ratio_bp": 2000
"fatigue_ratio_bp": 2300
},
"competency": {
@ -34,7 +35,7 @@
"base_fill_projectile": 2,
"base_fill_beam": 3,
"base_fill_aoe": 4,
"fatigue_ratio_bp": 2000,
"fatigue_ratio_bp": 2300,
"difficulty_per_fill_bp": 10000
},
@ -54,7 +55,7 @@
"name": "Auto-Attack",
"school": "martial",
"delivery": "melee",
"effects": [ { "kind": "fill_damage", "magnitude_bp": 800, "cost_fill": 0 } ],
"effects": [ { "kind": "fill_damage", "magnitude_bp": 560, "cost_fill": 0 } ],
"cooldown": 10,
"cost_override": { "fill": 0, "fatigue": 1 }
},
@ -63,33 +64,33 @@
"name": "Quick Jab",
"school": "martial",
"delivery": "melee",
"effects": [ { "kind": "fill_damage", "magnitude_bp": 1700, "cost_fill": 6 } ],
"cooldown": 6
"effects": [ { "kind": "fill_damage", "magnitude_bp": 1100, "cost_fill": 5 } ],
"cooldown": 20
},
{
"id": "heavy_strike",
"name": "Heavy Strike",
"school": "martial",
"delivery": "melee",
"effects": [ { "kind": "fill_damage", "magnitude_bp": 4500, "cost_fill": 26 } ],
"cooldown": 22
"effects": [ { "kind": "fill_damage", "magnitude_bp": 2800, "cost_fill": 14 } ],
"cooldown": 45
},
{
"id": "fireball",
"name": "Fireball",
"school": "invocation",
"delivery": "projectile",
"effects": [ { "kind": "fill_damage", "magnitude_bp": 3500, "cost_fill": 24 } ],
"effects": [ { "kind": "fill_damage", "magnitude_bp": 2800, "cost_fill": 14 } ],
"potency_bp": 10000,
"cast_time": 12,
"cooldown": 20
"cooldown": 40
},
{
"id": "curse_of_weariness",
"name": "Curse of Weariness",
"school": "affliction",
"delivery": "projectile",
"effects": [ { "kind": "ceiling_damage", "magnitude_bp": 1400, "cost_fill": 16 } ],
"effects": [ { "kind": "ceiling_damage", "magnitude_bp": 1250, "cost_fill": 10 } ],
"cast_time": 8,
"cooldown": 40
},
@ -98,7 +99,7 @@
"name": "Venom",
"school": "affliction",
"delivery": "touch",
"effects": [ { "kind": "dot", "per_tick_bp": 250, "duration": 20, "cost_fill": 12 } ],
"effects": [ { "kind": "dot", "per_tick_bp": 260, "duration": 20, "cost_fill": 10 } ],
"cooldown": 30
},
{
@ -115,15 +116,15 @@
"name": "Second Wind",
"school": "restoration",
"delivery": "self_cast",
"effects": [ { "kind": "recovery", "magnitude_bp": 3000, "cost_fill": 3 } ],
"cooldown": 45
"effects": [ { "kind": "recovery", "magnitude_bp": 700, "cost_fill": 3 } ],
"cooldown": 65
},
{
"id": "mend",
"name": "Mend",
"school": "restoration",
"delivery": "self_cast",
"effects": [ { "kind": "hot", "per_tick_bp": 200, "duration": 15, "cost_fill": 6 } ],
"effects": [ { "kind": "hot", "per_tick_bp": 70, "duration": 15, "cost_fill": 6 } ],
"cast_time": 10,
"cooldown": 45
},
@ -144,8 +145,8 @@
"team": 0,
"level": 10,
"max_vigor": 150,
"regen_idle_per_sec": 12,
"regen_combat_per_sec": 7,
"regen_idle_per_sec": 9,
"regen_combat_per_sec": 5,
"armor_bp": 2500,
"competency": { "martial": 50, "restoration": 30 },
"loadout": ["auto_attack", "quick_jab", "heavy_strike", "second_wind", "guard"]
@ -197,10 +198,10 @@
"troll": {
"name": "Cave Troll",
"team": 1,
"level": 11,
"max_vigor": 220,
"regen_idle_per_sec": 20,
"regen_combat_per_sec": 12,
"level": 10,
"max_vigor": 190,
"regen_idle_per_sec": 8,
"regen_combat_per_sec": 4,
"armor_bp": 2000,
"competency": { "martial": 30, "restoration": 20 },
"loadout": ["auto_attack", "heavy_strike", "second_wind"]