import { doomsdayClockDrain, doomsdayClockRequiredTiles, } from "../game/DoomsdayClock"; import { Execution, Game, GameMode, Player, PlayerType, Team, UnitType, } from "../game/Game"; /** * Doomsday Clock (anti-stall). Once armed, every side must hold a rising * share of the whole map: each player in FFA, each whole team in team modes (so * a team is judged on its combined territory and every member shares the fate). * The bar is the SAME for every side regardless of headcount — the clock shrinks * the number of viable SIDES, and sides are the unit of elimination/victory, so * a team faces exactly the bar a solo player faces. (It was previously scaled by * member count, which saturated at "hold the whole map" right after the grace in * real team lobbies and dropped whenever a teammate died — deciding games at * ~minute 12 on the slow preset instead of acting as a late-game backstop.) * The bar rises in discrete waves (battle-royale zone), stepping up to each * wave's level (chosen by the speed preset, see DoomsdayClock.ts) and holding. As * it rises the bottom is cut, which forces consolidation and guarantees a finish. * * 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 (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 * unless enabled in the GameConfig. Runs once per second (every 10 ticks), like * WinCheckExecution. */ export class DoomsdayClockExecution implements Execution { private active = true; private mg: Game | null = null; init(mg: Game, ticks: number): void { this.mg = mg; } tick(ticks: number): void { if (ticks % 10 !== 0) return; // once per second if (this.mg === null) throw new Error("Not initialized"); const mg = this.mg; const cfg = mg.config().doomsdayClockConfig(); if (!cfg.enabled) return; // Warships bleed on their OWN gentler start + higher ceiling, and (via the // curve exponent passed to the drain below) a STEEP convex ramp: a ship // caught when its side is first doomed lasts ~as long as troops, but a side // that has been under the clock the full ramp loses ships in ~2s. const warshipDrainCfg = { ...cfg, drainStartPercent: cfg.warshipDrainStartPercent, drainMaxPercent: cfg.warshipDrainMaxPercent, }; const elapsed = mg.elapsedGameSeconds(); // Humans and Nations are subject to it; the small map bots are not (the // !== Bot idiom used across the codebase). players() already returns only // alive players. const contenders = mg.players().filter((p) => p.type() !== PlayerType.Bot); // The bar applies per side: each player in FFA, each whole team otherwise. const ffa = mg.config().gameConfig().gameMode === GameMode.FFA; const sides = this.sides(contenders, ffa); // A winner is already inevitable (one side left): idle. Before the first // wave the bar is 0, so nobody is flagged anyway. if (sides.length < 2) { for (const p of contenders) p.clearDoomsdayClock(); return; } const land = mg.numLandTiles() - mg.numTilesWithFallout(); // One bar for every side this tick (solo or team — headcount never scales it). const required = doomsdayClockRequiredTiles(cfg.speed, land, elapsed); // 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 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), ); let leaderIdx = 0; for (let i = 1; i < sideTiles.length; i++) { if (sideTiles[i] > sideTiles[leaderIdx]) leaderIdx = i; } for (let i = 0; i < sides.length; i++) { const members = sides[i]; // A non-leading side below the bar skulls and drains every member; the // leader (and any side above the bar) clears them all. if (i !== leaderIdx && sideTiles[i] < required) { for (const m of members) { m.enterDoomsdayClock(); const secondsUnder = Math.floor(m.doomsdayClockTicks() / 10); if (secondsUnder >= cfg.warnSeconds) { const secondsPastWarn = secondsUnder - cfg.warnSeconds; // 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)), ); // The navy bleeds on the same ramp but toward warshipDrainCfg's far // 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)) { 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)); } } } } else { for (const m of members) m.clearDoomsdayClock(); } } } /** Group contenders into sides: singletons in FFA, by team otherwise. */ private sides(contenders: Player[], ffa: boolean): Player[][] { if (ffa) return contenders.map((p) => [p]); const byTeam = new Map(); for (const p of contenders) { const team = p.team(); if (team === null) continue; const members = byTeam.get(team); if (members) members.push(p); else byTeam.set(team, [p]); } return Array.from(byTeam.values()); } isActive(): boolean { return this.active; } activeDuringSpawnPhase(): boolean { return false; } }