feat(doomsday-clock): decay warships alongside troops for doomed sides (#4499)

## Description: 

Follow-up to #4469.

The Doomsday Clock drains a doomed side's troops but leaves its navy
untouched, so a coastal or island turtle can sit below the bar
indefinitely on warship defense, exactly the stall the clock is meant to
break.

This decays the warships of a flagged (sub-threshold, non-leader) side
on the same ramp as its troops:

- Each warship loses a percentage of its (veterancy-adjusted) max health
per second, reusing `doomsdayClockDrain`, so the fleet and the army
bleed in lockstep and reach zero together (~55s from full at the default
rate).
- Destruction passes **no attacker**, so it routes through
`UnitImpl.delete` as an environmental loss: no kill credit, no
boat-destroy stats, no veterancy granted. Scoring integrity is
preserved.
- Healing is suppressed for a flagged owner
(`WarshipExecution.healWarship` early-returns), so the decay actually
sinks the fleet instead of being out-healed at a port. Inert when the
mode is off, since the mark is never set.
- The leader's fleet is spared, same as its troops.

No new config: warships reuse the existing drain curve. No HUD change,
since warships count as part of the side's forces alongside troops.

Tested: 4 new unit tests (same-ramp decay, no-kill-credit destruction,
leader spared, warn-window grace), the full `DoomsdayClockExecution` and
`Warship` suites, the whole test suite (1784 passing), `build-prod`, and
a headless full-game sim run (resolves cleanly with the decay live,
deterministic).
This commit is contained in:
Zixer1
2026-07-03 18:07:28 -04:00
committed by evanpelle
parent d3e3ec1fd9
commit 34223bd56d
5 changed files with 179 additions and 1 deletions
+5
View File
@@ -94,6 +94,10 @@ const DOOMSDAY_CLOCK_DEFAULTS = {
drainStartPercent: 2, // starts bleeding at once (already beats troop income)
drainMaxPercent: 6,
drainRampSeconds: 50, // ramps LINEARLY to the max over this long
// Warships bleed on the same start + ramp but to a much higher ceiling than
// troops, so a fleet at full attrition sinks in ~2s (50% of a ship's max
// health per second) instead of riding out the gentle troop rate. Ships only.
warshipDrainMaxPercent: 50,
};
export class Config {
@@ -130,6 +134,7 @@ export class Config {
drainStartPercent: d.drainStartPercent,
drainMaxPercent: d.drainMaxPercent,
drainRampSeconds: d.drainRampSeconds,
warshipDrainMaxPercent: d.warshipDrainMaxPercent,
};
}
spawnImmunityDuration(): Tick {
+26 -1
View File
@@ -9,6 +9,7 @@ import {
Player,
PlayerType,
Team,
UnitType,
} from "../game/Game";
/**
@@ -43,6 +44,13 @@ export class DoomsdayClockExecution implements Execution {
const mg = this.mg;
const cfg = mg.config().doomsdayClockConfig();
if (!cfg.enabled) return;
// Warships bleed on the same start + ramp as troops but toward a much higher
// ceiling (warshipDrainMaxPercent), so a fleet at full attrition sinks in
// ~2s. Only the max differs from the troop drain.
const warshipDrainCfg = {
...cfg,
drainMaxPercent: cfg.warshipDrainMaxPercent,
};
const elapsed = mg.elapsedGameSeconds();
// Humans and Nations are subject to it; the small map bots are not (the
@@ -94,12 +102,29 @@ export class DoomsdayClockExecution implements Execution {
m.enterDoomsdayClock();
const secondsUnder = Math.floor(m.doomsdayClockTicks() / 10);
if (secondsUnder >= cfg.warnSeconds) {
const secondsPastWarn = secondsUnder - cfg.warnSeconds;
const chunk = doomsdayClockDrain(
mg.config().maxTroops(m),
secondsUnder - cfg.warnSeconds,
secondsPastWarn,
cfg,
);
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.
for (const ws of m.units(UnitType.Warship)) {
ws.modifyHealth(
-doomsdayClockDrain(
ws.maxHealth(),
secondsPastWarn,
warshipDrainCfg,
),
);
}
}
}
} else {
+4
View File
@@ -122,6 +122,10 @@ export class WarshipExecution implements Execution {
private healWarship(): void {
const owner = this.warship.owner();
// A doomed side (below the Doomsday Clock bar) cannot repair its navy, so the
// decay in DoomsdayClockExecution actually sinks warships instead of being
// out-healed at a port. Inert when the mode is off: the mark is never set.
if (owner.inDoomsdayClock()) return;
const passiveHealing = this.mg.config().warshipPassiveHealing();
const passiveHealingRange = this.mg.config().warshipPassiveHealingRange();
const passiveHealingRangeSquared =
+105
View File
@@ -40,12 +40,37 @@ function sdConfig(over: Partial<SDConfig> = {}): SDConfig {
drainStartPercent: 10,
drainMaxPercent: 80,
drainRampSeconds: 3,
warshipDrainMaxPercent: 100, // ships ramp to a higher ceiling than troops
...over,
};
}
// A stand-in warship: tracks HP and whether a destroyer (kill credit) was ever
// passed to modifyHealth. Doomsday decay must pass none, so destruction is
// environmental and never scores a kill (see UnitImpl.delete).
class FakeWarship {
destroyed = false;
attackerWasPassed = false;
constructor(
private hp: number,
private readonly hpMax: number,
) {}
maxHealth(): number {
return this.hpMax;
}
health(): number {
return this.hp;
}
modifyHealth(delta: number, attacker?: unknown): void {
if (attacker !== undefined) this.attackerWasPassed = true;
this.hp = Math.max(0, Math.min(this.hpMax, this.hp + delta));
if (this.hp === 0) this.destroyed = true;
}
}
class FakePlayer {
markedTick = -1;
warships: FakeWarship[] = [];
readonly troopMax: number;
constructor(
private game: FakeGame,
@@ -97,6 +122,11 @@ class FakePlayer {
clearDoomsdayClock(): void {
this.markedTick = -1;
}
// The exec calls units(UnitType.Warship); we ignore the filter and hand back
// this side's warships.
units(..._types: unknown[]): FakeWarship[] {
return this.warships;
}
}
class FakeGame {
@@ -282,6 +312,81 @@ 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.
// ---------------------------------------------------------------------------
describe("DoomsdayClockExecution (warship decay)", () => {
// b (100 tiles) is below the 200 bar at WAVE_TICK and is flagged; a (400) is
// the leader and is spared. maxTroops == warship maxHealth == 1000, so at the
// start of the ramp troop and warship losses are numerically identical; they
// diverge later as warships climb to their higher ceiling.
function warshipGame(bShips: FakeWarship[], aShips: FakeWarship[] = []) {
const game = new FakeGame(1000, sdConfig(), []);
const a = new FakePlayer(game, 400, 1000); // leader, above the bar
const b = new FakePlayer(game, 100, 1000); // below the bar
a.warships = aShips;
b.warships = bShips;
game.ps = [a, b];
return { game, a, b };
}
it("matches the troop drain at the start of the ramp", () => {
const ship = new FakeWarship(1000, 1000);
const { game, b } = warshipGame([ship]);
const exec = makeExec(game);
runAt(exec, game, WAVE_TICK); // flag b (within the warn window)
runAt(exec, game, WAVE_TICK + 10); // 1s under -> 0s past warn -> drainStart 10%
expect(b.troops()).toBe(900); // troops: 10% of 1000
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.
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);
});
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
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);
expect(ship.attackerWasPassed).toBe(false); // environmental, no kill credit
});
it("spares the leader's warships", () => {
const leaderShip = new FakeWarship(1000, 1000);
const { game } = warshipGame([new FakeWarship(1000, 1000)], [leaderShip]);
const exec = makeExec(game);
runAt(exec, game, WAVE_TICK);
runAt(exec, game, WAVE_TICK + 30); // well past the warn window
expect(leaderShip.health()).toBe(1000);
});
it("does not damage warships during the warn window", () => {
const ship = new FakeWarship(1000, 1000);
const { game, b } = warshipGame([ship]);
const exec = makeExec(game);
runAt(exec, game, WAVE_TICK); // flagged this tick, 0s under -> within warn
expect(b.inDoomsdayClock()).toBe(true);
expect(ship.health()).toBe(1000);
});
});
// ---------------------------------------------------------------------------
// Team modes: the bar applies to a whole team's combined territory, and every
// member shares the fate (skull + drain together).
+39
View File
@@ -66,6 +66,45 @@ describe("Warship", () => {
expect(warship.health()).toBe(maxHealth - 9);
});
test("Warship does not heal while its owner is doomed (Doomsday Clock)", async () => {
const maxHealth = game.config().unitInfo(UnitType.Warship).maxHealth;
if (typeof maxHealth !== "number") {
expect(typeof maxHealth).toBe("number");
throw new Error("unreachable");
}
player1.buildUnit(UnitType.Port, game.ref(coastX, 10), {});
const warship = player1.buildUnit(
UnitType.Warship,
game.ref(coastX + 1, 10),
{
patrolTile: game.ref(coastX + 1, 10),
},
);
game.addExecution(new WarshipExecution(warship));
// inDoomsdayClock() requires isAlive() (owns >=1 tile); a flagged player
// always does, so give this one a tile to mirror a real game.
player1.conquer(game.ref(coastX, 10));
game.executeNextTick();
// Damaged next to a port, it heals normally (+1 passive heal per tick).
warship.modifyHealth(-10);
expect(warship.health()).toBe(maxHealth - 10);
game.executeNextTick();
expect(warship.health()).toBe(maxHealth - 9);
// Once the owner is flagged by the clock, healing is suppressed even next to
// a port, so the decay in DoomsdayClockExecution can actually sink the fleet.
player1.enterDoomsdayClock();
game.executeNextTick();
expect(warship.health()).toBe(maxHealth - 9); // no heal while doomed
// Climbing back above the bar clears the mark and healing resumes.
player1.clearDoomsdayClock();
game.executeNextTick();
expect(warship.health()).toBe(maxHealth - 8);
});
test("Warship captures trade if player has port", async () => {
const portTile = game.ref(coastX, 10);
player1.buildUnit(UnitType.Port, portTile, {});