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
This commit is contained in:
Zixer1
2026-07-13 11:10:06 -07:00
committed by GitHub
parent d07f7d46dd
commit a2321eb824
4 changed files with 106 additions and 68 deletions
+36 -23
View File
@@ -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));
}
}
}