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:
parent
902a233229
commit
57dba5e149
8 changed files with 83 additions and 45 deletions
|
|
@ -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
|
With the shipped `configs/default.json` (these numbers are tuned placeholders — the
|
||||||
whole point is that they're a table you edit):
|
whole point is that they're a table you edit):
|
||||||
|
|
||||||
- **Duration tent** — fights are trivially short at the con extremes (~1–5s) and
|
- **Fights last ~20–30s** — monster matchups (a skilled player) resolve in roughly
|
||||||
longest/most-contested at even con (~30s). The intended "trivial → satisfying →
|
16–38s (melee bruisers faster, casters longer). The pace is set by the **global
|
||||||
lethal" shape.
|
cooldown** (the swing timer, §10): you act ~once per 1.5s, so no button-masher
|
||||||
- **Skill matters** — at even con, win rate climbs monotonically with policy:
|
can out-DPS the intended tempo.
|
||||||
auto-only **~1%** → skill20 **10%** → skill50 **24%** → skill80 **29%** →
|
- **Skill matters** — at even con, win rate climbs steeply and monotonically with
|
||||||
skill100 **34%**.
|
policy: auto-only **0%** → skill20 **2%** → skill50 **12%** → skill80 **44%** →
|
||||||
- **Anti-turtle guardrail is green** — in the contested band the full kit beats
|
skill100 **78%**.
|
||||||
auto-attack-only decisively (≈29% vs ≈1% at even con). Spending on the
|
- **Anti-turtle guardrail is green** — the full kit dominates auto-attack-only
|
||||||
interesting verbs is worth it, which is the whole thesis.
|
(78% vs 0% at even con). Spending on the interesting verbs is worth it, which is
|
||||||
- **Mirror matches are fair** — even-con skill-vs-skill is ≈ symmetric.
|
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
|
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
|
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
|
risk live — a poke skill strictly worse than the free auto-attack, so auto-only
|
||||||
auto-only out-won the full kit. Tuning the table (con curve, competency coupling,
|
out-won the full kit. Slowing the fights to a ~20–30s feel surfaced the next
|
||||||
ability efficiency, damage variance) moved it to the curves above. **That loop —
|
lesson: under a global cooldown, any ability that just out-damages auto-attack
|
||||||
sim → measure → tune → re-sim — is the deliverable.**
|
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
|
## Tuning
|
||||||
|
|
||||||
Everything balance-relevant lives in `configs/default.json`. Editing it is the
|
Everything balance-relevant lives in `configs/default.json`. Editing it is the
|
||||||
primary balancing act; only structural rule changes need a recompile. The levers
|
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.
|
the feel rides on (spec §15): the **`global_cooldown`** (raise it → slower, more
|
||||||
spend** rates (co-tuned or even-con fights are un-loseable or stagger-locked), and
|
deliberate fights; the cleanest uniform pace knob), the **con curve** shape, the
|
||||||
keeping abilities ahead of auto-attack on damage-per-resource or utility.
|
**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).
|
||||||
|
|
|
||||||
|
|
@ -159,6 +159,9 @@ pub struct Actor {
|
||||||
pub statuses: Vec<Status>,
|
pub statuses: Vec<Status>,
|
||||||
pub casting: Option<Cast>,
|
pub casting: Option<Cast>,
|
||||||
pub stagger: StaggerState,
|
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 {
|
impl Actor {
|
||||||
|
|
@ -494,6 +497,7 @@ pub fn compile_actor(id: ActorId, sb: &StatBlock, loadout: Vec<Ability>, tick_ra
|
||||||
statuses: Vec::new(),
|
statuses: Vec::new(),
|
||||||
casting: None,
|
casting: None,
|
||||||
stagger: StaggerState::Normal,
|
stagger: StaggerState::Normal,
|
||||||
|
next_action_at: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,11 @@ pub struct CombatConfig {
|
||||||
/// Fixed tick rate (ticks/second, §10).
|
/// Fixed tick rate (ticks/second, §10).
|
||||||
#[serde(default = "default_tick_rate")]
|
#[serde(default = "default_tick_rate")]
|
||||||
pub tick_rate: u32,
|
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
|
/// ± 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
|
/// range; also the symmetry-breaker that turns a deterministic mirror match
|
||||||
/// from a coin-flip-of-draws into smooth win-rate curves.
|
/// from a coin-flip-of-draws into smooth win-rate curves.
|
||||||
|
|
@ -128,6 +133,7 @@ impl Default for CombatConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
tick_rate: 10,
|
tick_rate: 10,
|
||||||
|
global_cooldown: 0,
|
||||||
damage_variance_bp: 0,
|
damage_variance_bp: 0,
|
||||||
regen: RegenConfig::default(),
|
regen: RegenConfig::default(),
|
||||||
stagger: StaggerConfig::default(),
|
stagger: StaggerConfig::default(),
|
||||||
|
|
@ -145,6 +151,7 @@ impl Default for CombatConfig {
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct Rules {
|
pub struct Rules {
|
||||||
pub tick_rate: u32,
|
pub tick_rate: u32,
|
||||||
|
pub global_cooldown: u32,
|
||||||
pub damage_variance_bp: i64,
|
pub damage_variance_bp: i64,
|
||||||
pub stagger: StaggerParams,
|
pub stagger: StaggerParams,
|
||||||
pub regen: RegenParams,
|
pub regen: RegenParams,
|
||||||
|
|
@ -191,6 +198,7 @@ impl CombatConfig {
|
||||||
|
|
||||||
Rules {
|
Rules {
|
||||||
tick_rate,
|
tick_rate,
|
||||||
|
global_cooldown: self.global_cooldown,
|
||||||
damage_variance_bp: self.damage_variance_bp.max(0),
|
damage_variance_bp: self.damage_variance_bp.max(0),
|
||||||
stagger,
|
stagger,
|
||||||
regen,
|
regen,
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
if let Some(heavy) = best_heavy(view) {
|
||||||
let est = estimate_landed(view, heavy, target);
|
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 {
|
return Action::Use {
|
||||||
ability_id: heavy.id.clone(),
|
ability_id: heavy.id.clone(),
|
||||||
targets: vec![target.id],
|
targets: vec![target.id],
|
||||||
|
|
|
||||||
|
|
@ -205,6 +205,11 @@ impl Encounter {
|
||||||
}
|
}
|
||||||
continue;
|
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 action = {
|
||||||
let view = View {
|
let view = View {
|
||||||
me: &self.actors[idx],
|
me: &self.actors[idx],
|
||||||
|
|
@ -252,6 +257,8 @@ impl Encounter {
|
||||||
let fill_before = self.actors[idx].vigor;
|
let fill_before = self.actors[idx].vigor;
|
||||||
self.actors[idx].pay(ability.cost);
|
self.actors[idx].pay(ability.cost);
|
||||||
self.recorder.spent(source, ability.cost.fill);
|
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 {
|
if ability.cooldown > 0 {
|
||||||
self.actors[idx]
|
self.actors[idx]
|
||||||
.cooldowns
|
.cooldowns
|
||||||
|
|
|
||||||
|
|
@ -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.
|
/// 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>) {
|
fn archetype_sweep(config: &CombatConfig, rules: &Rules, seeds: u64, max_ticks: u64, out: &mut Vec<FightRow>) {
|
||||||
let player_level = 10;
|
let player_level = 10;
|
||||||
let enemies = ["golem", "rat", "battlemage"];
|
let enemies = ["rat", "skeleton", "battlemage", "golem", "troll", "wraith"];
|
||||||
let ctrl = PlayerCtrl::Skill(80);
|
let ctrl = PlayerCtrl::Skill(80);
|
||||||
for enemy_name in enemies {
|
for enemy_name in enemies {
|
||||||
let enemy_level = config.stat_blocks.get(enemy_name).map(|b| b.level).unwrap_or(player_level);
|
let enemy_level = config.stat_blocks.get(enemy_name).map(|b| b.level).unwrap_or(player_level);
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,9 @@ const round = (x) => Math.round(x);
|
||||||
|
|
||||||
async function boot() {
|
async function boot() {
|
||||||
await init();
|
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);
|
const cfg = JSON.parse(cfgText);
|
||||||
tickMs = Math.max(20, Math.round(1000 / (cfg.tick_rate || 10)));
|
tickMs = Math.max(20, Math.round(1000 / (cfg.tick_rate || 10)));
|
||||||
for (const a of cfg.abilities || []) {
|
for (const a of cfg.abilities || []) {
|
||||||
|
|
|
||||||
|
|
@ -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.",
|
"_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,
|
"tick_rate": 10,
|
||||||
|
"global_cooldown": 15,
|
||||||
"damage_variance_bp": 1500,
|
"damage_variance_bp": 1500,
|
||||||
|
|
||||||
"regen": {
|
"regen": {
|
||||||
|
|
@ -10,11 +11,11 @@
|
||||||
},
|
},
|
||||||
|
|
||||||
"stagger": {
|
"stagger": {
|
||||||
"daze_threshold_bp": 10000,
|
"daze_threshold_bp": 13000,
|
||||||
"unconscious_threshold_bp": 15000,
|
"unconscious_threshold_bp": 20000,
|
||||||
"daze_duration": 8,
|
"daze_duration": 8,
|
||||||
"revive_daze": 12,
|
"revive_daze": 12,
|
||||||
"fatigue_ratio_bp": 2000
|
"fatigue_ratio_bp": 2300
|
||||||
},
|
},
|
||||||
|
|
||||||
"competency": {
|
"competency": {
|
||||||
|
|
@ -34,7 +35,7 @@
|
||||||
"base_fill_projectile": 2,
|
"base_fill_projectile": 2,
|
||||||
"base_fill_beam": 3,
|
"base_fill_beam": 3,
|
||||||
"base_fill_aoe": 4,
|
"base_fill_aoe": 4,
|
||||||
"fatigue_ratio_bp": 2000,
|
"fatigue_ratio_bp": 2300,
|
||||||
"difficulty_per_fill_bp": 10000
|
"difficulty_per_fill_bp": 10000
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -54,7 +55,7 @@
|
||||||
"name": "Auto-Attack",
|
"name": "Auto-Attack",
|
||||||
"school": "martial",
|
"school": "martial",
|
||||||
"delivery": "melee",
|
"delivery": "melee",
|
||||||
"effects": [ { "kind": "fill_damage", "magnitude_bp": 800, "cost_fill": 0 } ],
|
"effects": [ { "kind": "fill_damage", "magnitude_bp": 560, "cost_fill": 0 } ],
|
||||||
"cooldown": 10,
|
"cooldown": 10,
|
||||||
"cost_override": { "fill": 0, "fatigue": 1 }
|
"cost_override": { "fill": 0, "fatigue": 1 }
|
||||||
},
|
},
|
||||||
|
|
@ -63,33 +64,33 @@
|
||||||
"name": "Quick Jab",
|
"name": "Quick Jab",
|
||||||
"school": "martial",
|
"school": "martial",
|
||||||
"delivery": "melee",
|
"delivery": "melee",
|
||||||
"effects": [ { "kind": "fill_damage", "magnitude_bp": 1700, "cost_fill": 6 } ],
|
"effects": [ { "kind": "fill_damage", "magnitude_bp": 1100, "cost_fill": 5 } ],
|
||||||
"cooldown": 6
|
"cooldown": 20
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "heavy_strike",
|
"id": "heavy_strike",
|
||||||
"name": "Heavy Strike",
|
"name": "Heavy Strike",
|
||||||
"school": "martial",
|
"school": "martial",
|
||||||
"delivery": "melee",
|
"delivery": "melee",
|
||||||
"effects": [ { "kind": "fill_damage", "magnitude_bp": 4500, "cost_fill": 26 } ],
|
"effects": [ { "kind": "fill_damage", "magnitude_bp": 2800, "cost_fill": 14 } ],
|
||||||
"cooldown": 22
|
"cooldown": 45
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "fireball",
|
"id": "fireball",
|
||||||
"name": "Fireball",
|
"name": "Fireball",
|
||||||
"school": "invocation",
|
"school": "invocation",
|
||||||
"delivery": "projectile",
|
"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,
|
"potency_bp": 10000,
|
||||||
"cast_time": 12,
|
"cast_time": 12,
|
||||||
"cooldown": 20
|
"cooldown": 40
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "curse_of_weariness",
|
"id": "curse_of_weariness",
|
||||||
"name": "Curse of Weariness",
|
"name": "Curse of Weariness",
|
||||||
"school": "affliction",
|
"school": "affliction",
|
||||||
"delivery": "projectile",
|
"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,
|
"cast_time": 8,
|
||||||
"cooldown": 40
|
"cooldown": 40
|
||||||
},
|
},
|
||||||
|
|
@ -98,7 +99,7 @@
|
||||||
"name": "Venom",
|
"name": "Venom",
|
||||||
"school": "affliction",
|
"school": "affliction",
|
||||||
"delivery": "touch",
|
"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
|
"cooldown": 30
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -115,15 +116,15 @@
|
||||||
"name": "Second Wind",
|
"name": "Second Wind",
|
||||||
"school": "restoration",
|
"school": "restoration",
|
||||||
"delivery": "self_cast",
|
"delivery": "self_cast",
|
||||||
"effects": [ { "kind": "recovery", "magnitude_bp": 3000, "cost_fill": 3 } ],
|
"effects": [ { "kind": "recovery", "magnitude_bp": 700, "cost_fill": 3 } ],
|
||||||
"cooldown": 45
|
"cooldown": 65
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "mend",
|
"id": "mend",
|
||||||
"name": "Mend",
|
"name": "Mend",
|
||||||
"school": "restoration",
|
"school": "restoration",
|
||||||
"delivery": "self_cast",
|
"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,
|
"cast_time": 10,
|
||||||
"cooldown": 45
|
"cooldown": 45
|
||||||
},
|
},
|
||||||
|
|
@ -144,8 +145,8 @@
|
||||||
"team": 0,
|
"team": 0,
|
||||||
"level": 10,
|
"level": 10,
|
||||||
"max_vigor": 150,
|
"max_vigor": 150,
|
||||||
"regen_idle_per_sec": 12,
|
"regen_idle_per_sec": 9,
|
||||||
"regen_combat_per_sec": 7,
|
"regen_combat_per_sec": 5,
|
||||||
"armor_bp": 2500,
|
"armor_bp": 2500,
|
||||||
"competency": { "martial": 50, "restoration": 30 },
|
"competency": { "martial": 50, "restoration": 30 },
|
||||||
"loadout": ["auto_attack", "quick_jab", "heavy_strike", "second_wind", "guard"]
|
"loadout": ["auto_attack", "quick_jab", "heavy_strike", "second_wind", "guard"]
|
||||||
|
|
@ -197,10 +198,10 @@
|
||||||
"troll": {
|
"troll": {
|
||||||
"name": "Cave Troll",
|
"name": "Cave Troll",
|
||||||
"team": 1,
|
"team": 1,
|
||||||
"level": 11,
|
"level": 10,
|
||||||
"max_vigor": 220,
|
"max_vigor": 190,
|
||||||
"regen_idle_per_sec": 20,
|
"regen_idle_per_sec": 8,
|
||||||
"regen_combat_per_sec": 12,
|
"regen_combat_per_sec": 4,
|
||||||
"armor_bp": 2000,
|
"armor_bp": 2000,
|
||||||
"competency": { "martial": 30, "restoration": 20 },
|
"competency": { "martial": 30, "restoration": 20 },
|
||||||
"loadout": ["auto_attack", "heavy_strike", "second_wind"]
|
"loadout": ["auto_attack", "heavy_strike", "second_wind"]
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue