From a2321eb824b53ed908d41a33300f0fbcbed44fed Mon Sep 17 00:00:00 2001 From: Zixer1 <99333209+Zixer1@users.noreply.github.com> Date: Mon, 13 Jul 2026 14:10:06 -0400 Subject: [PATCH] balance(doomsday): floor the drain at 5% of max instead of wiping to zero (#4587) ## Description: The Doomsday Clock drain wiped a caught side's troops (and warship health) to **zero**, an effective death sentence, so a brief dip below the bar was unrecoverable. Floor the drain at **5% of each max** (`drainFloorPercent`) for both troops and warships: a doomed side is crippled, not eliminated, and recovers if it climbs back above the bar. The rising territory bar still forces a finish (a 5%-strength side is trivial to conquer, and the leader is drain-exempt). - Config: `drainFloorPercent` (5), applied to both drains. - Execution: clamp troop + warship-health removal to what sits above the floor (integer-only, deterministic). - HUD: the Collapsing rate is shown net of the floor. - Tests: drain-to-zero / warship-scuttle cases updated to the floor + added coverage. ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory --- src/client/components/DoomsdayClockPanel.ts | 11 ++- src/core/configuration/Config.ts | 17 ++-- src/core/execution/DoomsdayClockExecution.ts | 59 +++++++------ tests/DoomsdayClockExecution.test.ts | 87 ++++++++++++-------- 4 files changed, 106 insertions(+), 68 deletions(-) diff --git a/src/client/components/DoomsdayClockPanel.ts b/src/client/components/DoomsdayClockPanel.ts index 1d515d368..4e1c6d6d2 100644 --- a/src/client/components/DoomsdayClockPanel.ts +++ b/src/client/components/DoomsdayClockPanel.ts @@ -138,15 +138,18 @@ export class DoomsdayClockPanel extends LitElement { let statusClass = ""; let detail = zoneDetail; if (live && draining && me) { - // Drain is a % of max-troop capacity, capped at current troops; show the - // actual per-second loss (renderTroops handles the /10 display unit). + // Drain is a % of max-troop capacity that stops at the floor + // (drainFloorPercent of max); show the actual per-second loss, i.e. only + // what sits above the floor (renderTroops handles the /10 display unit). + const maxTroops = this.game.config().maxTroops(me); + const floor = Math.floor((maxTroops * sd.drainFloorPercent) / 100); const chunk = doomsdayClockDrain( - this.game.config().maxTroops(me), + maxTroops, secondsUnder - sd.warnSeconds, sd, ); status = translateText("doomsday_clock.collapsing", { - rate: renderTroops(Math.min(me.troops(), chunk)), + rate: renderTroops(Math.max(0, Math.min(me.troops() - floor, chunk))), }); statusClass = "text-red-400 font-bold"; } else if (live && flagged) { diff --git a/src/core/configuration/Config.ts b/src/core/configuration/Config.ts index 5186cb9b8..070a75c2d 100644 --- a/src/core/configuration/Config.ts +++ b/src/core/configuration/Config.ts @@ -84,21 +84,27 @@ export const SAM_CONSTRUCTION_TICKS = 30 * 10; // Doomsday Clock tunables (anti-stall). Off unless enabled in GameConfig. // Times in seconds. The required map share rises in waves (levels + times in // DoomsdayClock.ts, chosen by `speed`). A side caught below the bar gets a -// warnSeconds cooldown ("Danger, decay in Xs"), then troops bleed to zero: the -// warn (30s) + the linear drain (~90s from full troops, sooner with fewer troops -// or a shrinking territory) make ~2 minutes from caught to wiped out. +// warnSeconds cooldown ("Danger, decay in Xs"), then troops bleed DOWN TO A +// FLOOR (drainFloorPercent of max), not to zero: the warn (30s) + the linear +// drain (~90s from full troops, sooner with fewer troops or a shrinking +// territory) make ~2 minutes from caught to the floor. A doomed side is crippled +// to 5% of max, not eliminated, so a brief dip below the bar is recoverable (the +// drain stops the moment it climbs back); the rising bar still guarantees a +// finish by squeezing territory and leaving the doomed side easy to conquer. const DOOMSDAY_CLOCK_DEFAULTS = { enabled: false, speed: "normal" as DoomsdayClockSpeed, warnSeconds: 30, // cooldown (the flashing danger cue) before decay begins drainStartPercent: 2, // starts bleeding at once (already beats troop income) drainMaxPercent: 5, - drainRampSeconds: 90, // ramps LINEARLY to the max over this long (~1:30 to zero) + drainRampSeconds: 90, // ramps LINEARLY to the max over this long + drainFloorPercent: 5, // drain stops here: crippled to 5% of max, never wiped // Warships bleed on their OWN gentler start + a STEEP (convex) ramp to a much // higher ceiling. A ship caught when its side is first doomed lasts about as // long as troops (the low start + no income ≈ the troop net rate), but the rate // curves up sharply (warshipDrainCurveExponent), so once a side has been under - // the clock the full ramp, ships sink in ~2s (50%/s). Ships only. + // the clock the full ramp, ships drop to the same floor in ~2s (50%/s), not + // sunk. Ships only. warshipDrainStartPercent: 1, warshipDrainMaxPercent: 50, warshipDrainCurveExponent: 8, // >1 = convex: stays gentle early, then spikes @@ -138,6 +144,7 @@ export class Config { drainStartPercent: d.drainStartPercent, drainMaxPercent: d.drainMaxPercent, drainRampSeconds: d.drainRampSeconds, + drainFloorPercent: d.drainFloorPercent, warshipDrainStartPercent: d.warshipDrainStartPercent, warshipDrainMaxPercent: d.warshipDrainMaxPercent, warshipDrainCurveExponent: d.warshipDrainCurveExponent, diff --git a/src/core/execution/DoomsdayClockExecution.ts b/src/core/execution/DoomsdayClockExecution.ts index b507fdc2e..63692cbec 100644 --- a/src/core/execution/DoomsdayClockExecution.ts +++ b/src/core/execution/DoomsdayClockExecution.ts @@ -22,8 +22,11 @@ import { * * A side below the bar is marked (inDoomsdayClock -> blinking skull on the client) * and, after the warn window, every member bleeds an escalating percentage of - * their troops until the side recovers or hits zero. Climbing back above the bar - * clears the mark and stops the drain. + * their troops (and warship health) until the side recovers or reaches the floor + * (drainFloorPercent of each max). The drain cripples, it does not wipe: a doomed + * side settles at 5% of max, not zero, so a brief dip below the bar is + * recoverable. Climbing back above the bar clears the mark and stops the drain; + * the rising bar still forces a finish by squeezing territory. * * Deterministic: integer-only. The threshold is one floored integer ratio (see * DoomsdayClock.ts) and the drain a floored percentage, no floating-point. Off @@ -76,9 +79,9 @@ export class DoomsdayClockExecution implements Execution { // The leading side (the crown holder in FFA, the top team otherwise) is // never doomed. Doomsday Clock culls the challengers toward the leader, so the // leader always keeps its army: the game can never freeze with every - // remaining side bled to zero, and the final wave squeezes out everyone but - // the leader -> a single winner. First side with the most tiles wins ties - // (deterministic: sides are built in a fixed order). + // remaining side crippled at the floor, and the final wave squeezes out + // everyone but the leader -> a single winner. First side with the most tiles + // wins ties (deterministic: sides are built in a fixed order). const sideTiles = sides.map((members) => members.reduce((sum, m) => sum + m.numTilesOwned(), 0), ); @@ -105,28 +108,38 @@ export class DoomsdayClockExecution implements Execution { const secondsUnder = Math.floor(m.doomsdayClockTicks() / 10); if (secondsUnder >= cfg.warnSeconds) { const secondsPastWarn = secondsUnder - cfg.warnSeconds; - const chunk = doomsdayClockDrain( - mg.config().maxTroops(m), - secondsPastWarn, - cfg, + // The drain floors at drainFloorPercent of each max: a doomed side is + // crippled to 5%, not wiped to zero, so recovery is possible if it + // climbs back above the bar. Clamp the removal to what sits above the + // floor (integer-only, so the lockstep sim stays deterministic). + const maxTroops = mg.config().maxTroops(m); + const troopFloor = Math.floor( + (maxTroops * cfg.drainFloorPercent) / 100, + ); + const chunk = doomsdayClockDrain(maxTroops, secondsPastWarn, cfg); + m.removeTroops( + Math.min(chunk, Math.max(0, m.troops() - troopFloor)), ); - m.removeTroops(chunk); // caps at current troops // The navy bleeds on the same ramp but toward warshipDrainCfg's far - // higher ceiling (see above), so a doomed side's fleet is scuttled - // fast at full attrition. A percentage of each warship's (veterancy- - // adjusted) max health; passing no attacker makes each destruction - // environmental, never a credited kill (see UnitImpl.delete). Healing - // is suppressed for flagged owners in WarshipExecution.healWarship so - // the decay actually lands. + // higher ceiling (see above), so a doomed side's fleet is battered + // fast at full attrition, down to the SAME floor (drainFloorPercent of + // each warship's veterancy-adjusted max health) rather than sunk. No + // attacker is passed, so any loss is environmental, never a credited + // kill (see UnitImpl.delete). Healing is suppressed for flagged owners + // in WarshipExecution.healWarship so the decay actually lands. for (const ws of m.units(UnitType.Warship)) { - ws.modifyHealth( - -doomsdayClockDrain( - ws.maxHealth(), - secondsPastWarn, - warshipDrainCfg, - cfg.warshipDrainCurveExponent, - ), + const shipFloor = Math.floor( + (ws.maxHealth() * cfg.drainFloorPercent) / 100, ); + const removable = Math.max(0, ws.health() - shipFloor); + if (removable <= 0) continue; + const dmg = doomsdayClockDrain( + ws.maxHealth(), + secondsPastWarn, + warshipDrainCfg, + cfg.warshipDrainCurveExponent, + ); + ws.modifyHealth(-Math.min(dmg, removable)); } } } diff --git a/tests/DoomsdayClockExecution.test.ts b/tests/DoomsdayClockExecution.test.ts index 61da62b9e..1a6d1d851 100644 --- a/tests/DoomsdayClockExecution.test.ts +++ b/tests/DoomsdayClockExecution.test.ts @@ -42,6 +42,7 @@ function sdConfig(over: Partial = {}): SDConfig { drainStartPercent: 10, drainMaxPercent: 80, drainRampSeconds: 3, + drainFloorPercent: 5, // drain stops at 5% of max (matches production default) warshipDrainStartPercent: 10, // fixture: same start as troops (linear curve below) warshipDrainMaxPercent: 100, // ships ramp to a higher ceiling than troops warshipDrainCurveExponent: 1, // fixture uses a linear ramp for exact numbers @@ -234,13 +235,18 @@ describe("DoomsdayClockExecution (logic)", () => { expect(b.troops()).toBe(570); }); - it("drains an unrecovered player all the way to zero", () => { + it("drains an unrecovered player down to the 5% floor, never zero", () => { + // b (maxTroops 1000) is caught and never recovers. The drain cripples it to + // drainFloorPercent of max (5% of 1000 = 50) and stops there, not at zero. const { game, b } = twoPlayerGame(400, 50); const exec = makeExec(game); for (let t = WAVE_TICK; t <= WAVE_TICK + 1000; t += 10) runAt(exec, game, t); - expect(b.troops()).toBe(0); + expect(b.troops()).toBe(50); // 5% floor, not wiped expect(b.inDoomsdayClock()).toBe(true); + // Holds at the floor on further ticks (no drift below it). + runAt(exec, game, WAVE_TICK + 1010); + expect(b.troops()).toBe(50); }); it("clears the mark and stops draining when a player climbs back above the bar", () => { @@ -319,8 +325,8 @@ describe("DoomsdayClockExecution (logic)", () => { // --------------------------------------------------------------------------- // Warship decay: a flagged (sub-threshold, non-leader) side's warships bleed HP // on the troop start + ramp but toward a much higher ceiling, so at full -// attrition they sink in ~2s. Destroyed with no attacker (never a credited -// kill); the leader's fleet is spared. +// attrition they drop to the 5% floor in ~2s (never sunk). Damage carries no +// attacker (environmental, never a credited kill); the leader's fleet is spared. // --------------------------------------------------------------------------- describe("DoomsdayClockExecution (warship decay)", () => { @@ -348,28 +354,40 @@ describe("DoomsdayClockExecution (warship decay)", () => { expect(ship.health()).toBe(900); // warship: identical at the start (same 10%) }); - it("scuttles a warship in one tick at full attrition (its own high ceiling)", () => { - // Once the side is fully ramped, a fresh full-HP warship is destroyed in a - // single tick at warshipDrainMaxPercent (100% here). The troop max (80%) - // would leave it at 200 HP, so destruction proves the ship uses its own - // higher ceiling, not the troop rate. + it("batters a warship down to the 5% floor at full attrition, never sunk", () => { + // Once the side is fully ramped, a fresh full-HP warship would take its whole + // ceiling (100% here) in one tick, but the floor (5% of maxHealth 1000 = 50) + // clamps it: the ship drops to 50 HP and survives instead of being destroyed. const { game, b } = warshipGame([]); const exec = makeExec(game); runAt(exec, game, WAVE_TICK); // flag b (starts the side's attrition clock) const fresh = new FakeWarship(1000, 1000); b.warships = [fresh]; // appears once the side is fully ramped runAt(exec, game, WAVE_TICK + 70); // secondsPastWarn 6 >= ramp 3 -> max - expect(fresh.destroyed).toBe(true); + expect(fresh.health()).toBe(50); // 5% floor + expect(fresh.destroyed).toBe(false); // crippled, not sunk }); - it("destroys warships with no attacker, so decay never scores a kill", () => { - const ship = new FakeWarship(50, 1000); // less HP than one tick of drain + it("decays HP with no attacker (environmental) and stops at the floor", () => { + const ship = new FakeWarship(1000, 1000); const { game } = warshipGame([ship]); const exec = makeExec(game); runAt(exec, game, WAVE_TICK); - runAt(exec, game, WAVE_TICK + 10); // 10% of 1000 = 100 dmg > 50 hp - expect(ship.destroyed).toBe(true); + runAt(exec, game, WAVE_TICK + 10); // 10% of 1000 = 100 dmg + expect(ship.health()).toBe(900); // took damage expect(ship.attackerWasPassed).toBe(false); // environmental, no kill credit + expect(ship.destroyed).toBe(false); + }); + + it("leaves a warship already at the floor untouched", () => { + const ship = new FakeWarship(50, 1000); // exactly the 5% floor + const { game } = warshipGame([ship]); + const exec = makeExec(game); + runAt(exec, game, WAVE_TICK); + runAt(exec, game, WAVE_TICK + 70); // full attrition + expect(ship.health()).toBe(50); // nothing left above the floor to remove + expect(ship.destroyed).toBe(false); + expect(ship.attackerWasPassed).toBe(false); }); it("spares the leader's warships", () => { @@ -700,16 +718,16 @@ describe("DoomsdayClockExecution (integration)", () => { }); // --------------------------------------------------------------------------- -// Default-config wipe time. Uses the resolved DOOMSDAY_CLOCK_DEFAULTS (no drain -// overrides) with real troop income (PlayerExecution) flowing every tick, so it -// pins the advertised "~1 minute from caught to wiped". A pure-drain analysis -// (ignoring income) under-counts this to ~45s; income offsets the early bleed. +// Default-config floor behavior. Uses the resolved DOOMSDAY_CLOCK_DEFAULTS (no +// drain overrides) with real troop income (PlayerExecution) flowing every tick. +// The drain cripples a caught side to drainFloorPercent (5%) of its max and +// stops there: it never reaches zero, and income keeps it hovering at the floor. // --------------------------------------------------------------------------- describe("DoomsdayClockExecution (default drain, with income)", () => { - it("wipes a full-troop side in ~2 minutes (warn + linear drain)", async () => { - // Only enabled + speed set -> drain uses the defaults (warn 30s, 2%->5% /90s). - // veryfast is chosen purely so the bar rises fast enough to catch the sliver. + it("cripples a full-troop side down to ~5% of max, never wiped", async () => { + // Only enabled + speed set -> drain uses the defaults (warn 30s, 2%->5% /90s, + // floor 5%). veryfast is chosen so the bar rises fast enough to catch the sliver. const game = await setup( "plains", { @@ -730,7 +748,7 @@ describe("DoomsdayClockExecution (default drain, with income)", () => { game.addExecution(new DoomsdayClockExecution()); // Run until the rising bar catches the sliver, then fill it to a full stack - // so we measure the worst-case (longest) wipe from that moment. + // so the drain works from the worst case (a full army down to the floor). let caughtTick = -1; for (let i = 0; i < 8000; i++) { // grace is 600s now -> the bar only rises after ~6000 ticks @@ -741,20 +759,17 @@ describe("DoomsdayClockExecution (default drain, with income)", () => { } } expect(caughtTick).toBeGreaterThan(0); - small.setTroops(game.config().maxTroops(small)); + const maxTroops = game.config().maxTroops(small); + const floor = Math.floor((maxTroops * 5) / 100); + small.setTroops(maxTroops); - let zeroTick = -1; - for (let i = 0; i < 2500; i++) { - game.executeNextTick(); - if (small.troops() <= 0) { - zeroTick = game.ticks(); - break; - } - } - expect(zeroTick).toBeGreaterThan(0); - const seconds = (zeroTick - caughtTick) / 10; - // ~30s warn + the slower drain, income included: about two minutes. - expect(seconds).toBeGreaterThan(90); - expect(seconds).toBeLessThan(150); + // Run well past the warn + ramp (30s + 90s) so the drain reaches steady state. + for (let i = 0; i < 2500; i++) game.executeNextTick(); + + expect(small.inDoomsdayClock()).toBe(true); + expect(small.troops()).toBeGreaterThan(0); // never wiped to zero + expect(small.troops()).toBeGreaterThanOrEqual(floor - 1); // never below the 5% floor + // Settles at the floor (income keeps it a little above, within one drain step). + expect(small.troops()).toBeLessThan(floor + Math.floor(maxTroops * 0.05)); }); });