diff --git a/combat/README.md b/combat/README.md index 983536e..76e9a49 100644 --- a/combat/README.md +++ b/combat/README.md @@ -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 (~1–5s) 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 ~20–30s** — monster matchups (a skilled player) resolve in roughly + 16–38s (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 ~20–30s 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). diff --git a/combat/combat-core/src/actor.rs b/combat/combat-core/src/actor.rs index d82928c..18ff88e 100644 --- a/combat/combat-core/src/actor.rs +++ b/combat/combat-core/src/actor.rs @@ -159,6 +159,9 @@ pub struct Actor { pub statuses: Vec, pub casting: Option, 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, tick_ra statuses: Vec::new(), casting: None, stagger: StaggerState::Normal, + next_action_at: 0, } } diff --git a/combat/combat-core/src/config.rs b/combat/combat-core/src/config.rs index 8e8773a..ecc2a94 100644 --- a/combat/combat-core/src/config.rs +++ b/combat/combat-core/src/config.rs @@ -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, diff --git a/combat/combat-core/src/controller.rs b/combat/combat-core/src/controller.rs index e9b5559..9853c7e 100644 --- a/combat/combat-core/src/controller.rs +++ b/combat/combat-core/src/controller.rs @@ -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], diff --git a/combat/combat-core/src/engine.rs b/combat/combat-core/src/engine.rs index 7febaaf..d8c05a4 100644 --- a/combat/combat-core/src/engine.rs +++ b/combat/combat-core/src/engine.rs @@ -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 diff --git a/combat/combat-sim/src/main.rs b/combat/combat-sim/src/main.rs index 1f8029a..b275f22 100644 --- a/combat/combat-sim/src/main.rs +++ b/combat/combat-sim/src/main.rs @@ -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) { 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); diff --git a/combat/combat-web/game.js b/combat/combat-web/game.js index 22496de..d5b71ce 100644 --- a/combat/combat-web/game.js +++ b/combat/combat-web/game.js @@ -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 || []) { diff --git a/combat/configs/default.json b/combat/configs/default.json index aeaa0a5..f5660d6 100644 --- a/combat/configs/default.json +++ b/combat/configs/default.json @@ -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"]