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-16 10:38:51 -07:00
committed by evanpelle
parent 81d701adc4
commit d3ecb5f073
4 changed files with 106 additions and 68 deletions
+51 -36
View File
@@ -42,6 +42,7 @@ function sdConfig(over: Partial<SDConfig> = {}): 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));
});
});