diff --git a/src/core/configuration/Config.ts b/src/core/configuration/Config.ts index 9f96f7ce8..8e74003fc 100644 --- a/src/core/configuration/Config.ts +++ b/src/core/configuration/Config.ts @@ -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 { diff --git a/src/core/execution/DoomsdayClockExecution.ts b/src/core/execution/DoomsdayClockExecution.ts index 202afab70..8ebd9b599 100644 --- a/src/core/execution/DoomsdayClockExecution.ts +++ b/src/core/execution/DoomsdayClockExecution.ts @@ -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 { diff --git a/src/core/execution/WarshipExecution.ts b/src/core/execution/WarshipExecution.ts index bd29e5521..af2fd8c5a 100644 --- a/src/core/execution/WarshipExecution.ts +++ b/src/core/execution/WarshipExecution.ts @@ -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 = diff --git a/tests/DoomsdayClockExecution.test.ts b/tests/DoomsdayClockExecution.test.ts index c3e131f0f..b4900ff49 100644 --- a/tests/DoomsdayClockExecution.test.ts +++ b/tests/DoomsdayClockExecution.test.ts @@ -40,12 +40,37 @@ function sdConfig(over: Partial = {}): 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). diff --git a/tests/Warship.test.ts b/tests/Warship.test.ts index ea2dcd051..f8ada609e 100644 --- a/tests/Warship.test.ts +++ b/tests/Warship.test.ts @@ -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, {});