mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-07 13:06:01 +00:00
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:
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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, {});
|
||||
|
||||
Reference in New Issue
Block a user