mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-05 12:52:05 +00:00
78ef7b56fd
Resolves Issue #4463 ## Description: An optional game mode that (almost) guarantees a finish instead of letting late-game stalemates drag on. Originally called sudden death, renamed to Doomsday clock Once enabled, every side (each player in FFA, each whole team in team modes) must hold a rising share of the map. A side below the bar is skulled; after a short warn its troops bleed to zero, forcing consolidation to a winner. ### How it works - **Rising zone:** a grace period, then the required share ramps up linearly to each level with 30s pauses between (a battle-royale "zone"). Levels track the ofstats FFA territory median (3/5/10/20/30%). - **Four speed presets** (slow / normal / fast / very fast) change only the pace: normal ends ~30 min, very fast ~15. - **Troop decay:** a linear ramp as a % of max capacity, ~50s from caught to zero (10s warn + ~50s ≈ 1 min total). - **UI:** a HUD panel (live share vs target, wave/decay countdowns, red/orange cues) and an on-map skull above flagged players (blinks in danger, steady while draining). ### Notes for review - Off by default; no effect on existing games. However, as discussed we can add it to the modifier pool for public games to see how popular the gamemode is vs normal play. - Sim is deterministic (integer-only, in `src/core`), covered by unit + integration tests. - One-line addition to `GameServer.updateGameConfig` so the setting survives the host → server → client round-trip. - Status is packed into the existing name-pass data slot (`pd4.w`: 0/1/2 = none/danger/draining); the skull is composited into the icon atlas at load. ### Testing `npm test`, `npm run lint`, `npx prettier --check .`, `npm run build-prod` all pass. ### UI: <img width="243" height="100" alt="Image" src="https://github.com/user-attachments/assets/c4c9eeb0-4feb-437d-9aac-b2786a841b74" /> Dropdown between slow, normal, fast, very fast Before zone: <img width="302" height="175" alt="Image" src="https://github.com/user-attachments/assets/7359a1ea-4951-446d-a23c-0711fe06cc5d" /> Zone started, player not affected the pannel also blinks orange for 10s: <img width="297" height="175" alt="Image" src="https://github.com/user-attachments/assets/fcc565a5-d5d0-47a7-97ea-d0ba9d9ad899" /> Player affected, grace period (Danger): <img width="314" height="170" alt="Image" src="https://github.com/user-attachments/assets/ff96d21e-96f3-4ef9-8190-48eecc7aac0f" /> Skull icon blinking over player (everyone sees it) - older screenshot, the clipping has been fixed <img width="462" height="145" alt="Image" src="https://github.com/user-attachments/assets/53899211-33b1-40e1-83f2-77f2096f0cad" /> Player affected, grace period ended (Draining): <img width="360" height="159" alt="Image" src="https://github.com/user-attachments/assets/4b226d57-da4d-4866-ab5f-db48e4ed1ea2" /> Skull icon no longer blinking, everyone can see you are in a state of decay, and troops are draining: <img width="732" height="146" alt="image" src="https://github.com/user-attachments/assets/cd10fedb-6e87-4dfc-9fbf-55d3945a7901" /> Skull is visible like alliances icon also on player tab <img width="558" height="81" alt="Image" src="https://github.com/user-attachments/assets/6acdbe91-bdd0-40c7-942b-3990d4dae87f" /> (just UI example, best way to see it is to hop on a solo game and play against AI) ## 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 ## Please put your Discord username so you can be contacted if a bug or regression is found: zixer._
618 lines
23 KiB
TypeScript
618 lines
23 KiB
TypeScript
import { DoomsdayClockExecution } from "../src/core/execution/DoomsdayClockExecution";
|
|
import { PlayerExecution } from "../src/core/execution/PlayerExecution";
|
|
import {
|
|
doomsdayClockDrain,
|
|
doomsdayClockRequiredTiles,
|
|
doomsdayClockSideRequiredTiles,
|
|
doomsdayClockWaveState,
|
|
} from "../src/core/game/DoomsdayClock";
|
|
import {
|
|
Game,
|
|
GameMode,
|
|
Player,
|
|
PlayerType,
|
|
Team,
|
|
} from "../src/core/game/Game";
|
|
import { TileRef } from "../src/core/game/GameMap";
|
|
import { playerInfo, setup } from "./util/Setup";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Unit tests: the flag / warn / drain / grouping logic is pure arithmetic over
|
|
// a side's combined numTilesOwned() + each member's troops(). We drive it through
|
|
// tiny fakes so the numbers are exact. The wave schedule + drain are covered by
|
|
// the pure-function tests further down; the end-to-end integration against the
|
|
// real simulation is the final test.
|
|
//
|
|
// The exec reads the real "veryfast" waves. WAVE_TICK sits in the 20% hold
|
|
// window (elapsed 750-780), so the bar is a stable 20% of the map (land 1000 ->
|
|
// bar 200) while the drain/flag logic is exercised.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const WAVE_TICK = 7600; // elapsed 760s -> veryfast 20% hold (bar 200 @ land 1000)
|
|
|
|
type SDConfig = ReturnType<ReturnType<Game["config"]>["doomsdayClockConfig"]>;
|
|
|
|
function sdConfig(over: Partial<SDConfig> = {}): SDConfig {
|
|
return {
|
|
enabled: true,
|
|
speed: "veryfast", // waves rise to 30% by 15:00
|
|
warnSeconds: 1,
|
|
drainStartPercent: 10,
|
|
drainMaxPercent: 80,
|
|
drainRampSeconds: 3,
|
|
...over,
|
|
};
|
|
}
|
|
|
|
class FakePlayer {
|
|
markedTick = -1;
|
|
readonly troopMax: number;
|
|
constructor(
|
|
private game: FakeGame,
|
|
public tiles: number,
|
|
public troopCount: number,
|
|
private kind: PlayerType = PlayerType.Human,
|
|
private alive: boolean = true,
|
|
private teamId: Team | null = null,
|
|
) {
|
|
this.troopMax = troopCount; // capacity = starting troops in tests
|
|
}
|
|
type(): PlayerType {
|
|
return this.kind;
|
|
}
|
|
maxTroops(): number {
|
|
return this.troopMax;
|
|
}
|
|
isAlive(): boolean {
|
|
return this.alive;
|
|
}
|
|
team(): Team | null {
|
|
return this.teamId;
|
|
}
|
|
kill(): void {
|
|
this.alive = false;
|
|
}
|
|
numTilesOwned(): number {
|
|
return this.tiles;
|
|
}
|
|
troops(): number {
|
|
return this.troopCount;
|
|
}
|
|
removeTroops(n: number): number {
|
|
const removed = Math.min(this.troopCount, n);
|
|
this.troopCount -= removed;
|
|
return removed;
|
|
}
|
|
// Mirrors PlayerImpl: a dead player is never in doomsday clock (the mark is
|
|
// never cleared on death, so both are gated on isAlive()).
|
|
inDoomsdayClock(): boolean {
|
|
return this.alive && this.markedTick >= 0;
|
|
}
|
|
doomsdayClockTicks(): number {
|
|
return this.inDoomsdayClock() ? this.game.now - this.markedTick : 0;
|
|
}
|
|
enterDoomsdayClock(): void {
|
|
if (this.markedTick < 0) this.markedTick = this.game.now;
|
|
}
|
|
clearDoomsdayClock(): void {
|
|
this.markedTick = -1;
|
|
}
|
|
}
|
|
|
|
class FakeGame {
|
|
now = 0;
|
|
gameMode: GameMode = GameMode.FFA;
|
|
constructor(
|
|
public land: number,
|
|
public sd: SDConfig,
|
|
public ps: FakePlayer[],
|
|
) {}
|
|
ticks(): number {
|
|
return this.now;
|
|
}
|
|
elapsedGameSeconds(): number {
|
|
return Math.floor(this.now / 10);
|
|
}
|
|
players(): FakePlayer[] {
|
|
return this.ps.filter((p) => p.isAlive()); // match GameImpl.players(): alive only
|
|
}
|
|
numLandTiles(): number {
|
|
return this.land;
|
|
}
|
|
numTilesWithFallout(): number {
|
|
return 0;
|
|
}
|
|
config() {
|
|
return {
|
|
doomsdayClockConfig: () => this.sd,
|
|
gameConfig: () => ({ gameMode: this.gameMode }),
|
|
maxTroops: (p: FakePlayer) => p.maxTroops(),
|
|
};
|
|
}
|
|
}
|
|
|
|
// Advance the fake clock to a given tick (multiple of 10) and run the exec once.
|
|
function runAt(
|
|
exec: DoomsdayClockExecution,
|
|
game: FakeGame,
|
|
tick: number,
|
|
): void {
|
|
game.now = tick;
|
|
exec.tick(tick);
|
|
}
|
|
|
|
function makeExec(game: FakeGame): DoomsdayClockExecution {
|
|
const exec = new DoomsdayClockExecution();
|
|
exec.init(game as unknown as Game, 0);
|
|
return exec;
|
|
}
|
|
|
|
describe("DoomsdayClockExecution (logic)", () => {
|
|
// land 1000, veryfast 20% wave -> bar = 200 at WAVE_TICK.
|
|
function twoPlayerGame(
|
|
aTiles: number,
|
|
bTiles: number,
|
|
over: Partial<SDConfig> = {},
|
|
) {
|
|
const game = new FakeGame(1000, sdConfig(over), []);
|
|
const a = new FakePlayer(game, aTiles, 1000);
|
|
const b = new FakePlayer(game, bTiles, 1000);
|
|
game.ps = [a, b];
|
|
return { game, a, b };
|
|
}
|
|
|
|
it("does nothing when disabled", () => {
|
|
const { game, b } = twoPlayerGame(400, 100, { enabled: false });
|
|
const exec = makeExec(game);
|
|
runAt(exec, game, WAVE_TICK);
|
|
expect(b.inDoomsdayClock()).toBe(false);
|
|
expect(b.troops()).toBe(1000);
|
|
});
|
|
|
|
it("does nothing before the first wave", () => {
|
|
// veryfast grace runs to 180s; before it the bar is 0, nobody below it.
|
|
const { game, b } = twoPlayerGame(400, 100);
|
|
const exec = makeExec(game);
|
|
runAt(exec, game, 500); // elapsed 50s < 180s (grace)
|
|
expect(b.inDoomsdayClock()).toBe(false);
|
|
expect(b.troops()).toBe(1000);
|
|
});
|
|
|
|
it("flags a player below the bar and spares one above it", () => {
|
|
const { game, a, b } = twoPlayerGame(400, 100);
|
|
const exec = makeExec(game);
|
|
runAt(exec, game, WAVE_TICK); // bar = 200
|
|
expect(a.inDoomsdayClock()).toBe(false);
|
|
expect(b.inDoomsdayClock()).toBe(true);
|
|
});
|
|
|
|
it("warns before draining, then drains harder over time", () => {
|
|
const { game, b } = twoPlayerGame(400, 100); // b below the 200 bar
|
|
const exec = makeExec(game);
|
|
runAt(exec, game, WAVE_TICK); // flagged this tick, 0s under -> within the warn
|
|
expect(b.inDoomsdayClock()).toBe(true);
|
|
expect(b.troops()).toBe(1000); // no drain yet
|
|
|
|
runAt(exec, game, WAVE_TICK + 10); // 1s under -> 10% of max(1000) = 100
|
|
expect(b.troops()).toBe(900);
|
|
|
|
runAt(exec, game, WAVE_TICK + 20); // 2s under -> 33% of max(1000) = 330 (linear)
|
|
expect(b.troops()).toBe(570);
|
|
});
|
|
|
|
it("drains an unrecovered player all the way to 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.inDoomsdayClock()).toBe(true);
|
|
});
|
|
|
|
it("clears the mark and stops draining when a player climbs back above the bar", () => {
|
|
const { game, b } = twoPlayerGame(400, 100);
|
|
const exec = makeExec(game);
|
|
runAt(exec, game, WAVE_TICK);
|
|
runAt(exec, game, WAVE_TICK + 10); // drained once
|
|
const afterDrain = b.troops();
|
|
expect(b.inDoomsdayClock()).toBe(true);
|
|
|
|
b.tiles = 400; // recovered above the bar
|
|
runAt(exec, game, WAVE_TICK + 20);
|
|
expect(b.inDoomsdayClock()).toBe(false);
|
|
expect(b.troops()).toBe(afterDrain); // drain stopped
|
|
});
|
|
|
|
it("drops the mark once a flagged player dies (no stuck panel or churn)", () => {
|
|
// Nothing clears the mark on death, so inDoomsdayClock()/doomsdayClockTicks()
|
|
// must gate on isAlive() to avoid a permanently "Draining" panel and a
|
|
// per-tick update delta for an eliminated player.
|
|
const { game, b } = twoPlayerGame(400, 100);
|
|
const exec = makeExec(game);
|
|
runAt(exec, game, WAVE_TICK);
|
|
expect(b.inDoomsdayClock()).toBe(true);
|
|
|
|
b.kill();
|
|
expect(b.inDoomsdayClock()).toBe(false);
|
|
expect(b.doomsdayClockTicks()).toBe(0);
|
|
});
|
|
|
|
it("never dooms the leading side, even below the bar (no all-drained stalemate)", () => {
|
|
// Both sides below the 200 bar; the larger (a) is the crown, so it is spared
|
|
// and keeps its army to close the game instead of everyone bleeding to zero.
|
|
const { game, a, b } = twoPlayerGame(150, 100);
|
|
const exec = makeExec(game);
|
|
runAt(exec, game, WAVE_TICK);
|
|
expect(a.inDoomsdayClock()).toBe(false); // leader, spared
|
|
expect(b.inDoomsdayClock()).toBe(true); // challenger, doomed
|
|
runAt(exec, game, WAVE_TICK + 30);
|
|
expect(a.troops()).toBe(1000); // never drained
|
|
expect(b.troops()).toBeLessThan(1000); // bled
|
|
});
|
|
|
|
it("applies to nations like players and excludes map bots", () => {
|
|
const game = new FakeGame(1000, sdConfig(), []);
|
|
const leader = new FakePlayer(game, 400, 1000, PlayerType.Human);
|
|
const human = new FakePlayer(game, 100, 1000, PlayerType.Human);
|
|
const nation = new FakePlayer(game, 50, 1000, PlayerType.Nation);
|
|
const bot = new FakePlayer(game, 5, 1000, PlayerType.Bot);
|
|
game.ps = [leader, human, nation, bot];
|
|
const exec = makeExec(game);
|
|
// Bar 200; leader (400) is crown-exempt; human (100) and nation (50) are
|
|
// below it; the bot is exempt by type.
|
|
runAt(exec, game, WAVE_TICK);
|
|
expect(human.inDoomsdayClock()).toBe(true);
|
|
expect(nation.inDoomsdayClock()).toBe(true); // a nation is treated like a player
|
|
expect(bot.inDoomsdayClock()).toBe(false); // map bots are never subject to it
|
|
expect(leader.inDoomsdayClock()).toBe(false); // the crown is never doomed
|
|
runAt(exec, game, WAVE_TICK + 10);
|
|
expect(nation.troops()).toBeLessThan(1000); // drained like a player
|
|
expect(bot.troops()).toBe(1000); // untouched
|
|
});
|
|
|
|
it("is deterministic: identical scenarios give identical drains", () => {
|
|
const run = () => {
|
|
const { game, b } = twoPlayerGame(400, 100);
|
|
const exec = makeExec(game);
|
|
for (let t = WAVE_TICK; t <= WAVE_TICK + 200; t += 10)
|
|
runAt(exec, game, t);
|
|
return b.troops();
|
|
};
|
|
expect(run()).toBe(run());
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Team modes: the bar applies to a whole team's combined territory, and every
|
|
// member shares the fate (skull + drain together).
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("DoomsdayClockExecution (teams)", () => {
|
|
function teamGame(teams: { team: string; tiles: number[] }[]) {
|
|
// base bar 200 @ land 1000; a team's threshold = 200 x its member count.
|
|
const game = new FakeGame(1000, sdConfig(), []);
|
|
game.gameMode = GameMode.Team;
|
|
const players: FakePlayer[] = [];
|
|
for (const t of teams) {
|
|
for (const tiles of t.tiles) {
|
|
players.push(
|
|
new FakePlayer(game, tiles, 1000, PlayerType.Human, true, t.team),
|
|
);
|
|
}
|
|
}
|
|
game.ps = players;
|
|
return { game, players };
|
|
}
|
|
|
|
it("judges a team on combined territory and skulls every member when below", () => {
|
|
// Both teams size 2 -> threshold 200x2=400. Red 250+250=500 safe;
|
|
// Blue 50+50=100 below -> both Blue skulled.
|
|
const { game, players } = teamGame([
|
|
{ team: "Red", tiles: [250, 250] },
|
|
{ team: "Blue", tiles: [50, 50] },
|
|
]);
|
|
const [red1, red2, blue1, blue2] = players;
|
|
const exec = makeExec(game);
|
|
runAt(exec, game, WAVE_TICK);
|
|
expect(red1.inDoomsdayClock()).toBe(false);
|
|
expect(red2.inDoomsdayClock()).toBe(false);
|
|
expect(blue1.inDoomsdayClock()).toBe(true);
|
|
expect(blue2.inDoomsdayClock()).toBe(true);
|
|
runAt(exec, game, WAVE_TICK + 10); // past the warn -> both Blue members drain
|
|
expect(blue1.troops()).toBeLessThan(1000);
|
|
expect(blue2.troops()).toBeLessThan(1000);
|
|
expect(red1.troops()).toBe(1000); // safe team untouched
|
|
});
|
|
|
|
it("spares a tiny member whose team is collectively above the bar", () => {
|
|
// Size 2 -> threshold 400. Red 400+40=440 -> safe, so the 40-tile member
|
|
// is NOT skulled.
|
|
const { game, players } = teamGame([
|
|
{ team: "Red", tiles: [400, 40] },
|
|
{ team: "Blue", tiles: [50, 50] },
|
|
]);
|
|
const [, redTiny, blue1] = players;
|
|
const exec = makeExec(game);
|
|
runAt(exec, game, WAVE_TICK);
|
|
expect(redTiny.inDoomsdayClock()).toBe(false); // team is collectively safe
|
|
expect(blue1.inDoomsdayClock()).toBe(true);
|
|
});
|
|
|
|
it("scales the threshold by team size (a bigger team must hold more)", () => {
|
|
// base bar 200. Red is 3 members -> threshold 600; Blue is 1 -> threshold 200.
|
|
// Blue leads on tiles (crown-exempt), so Red is squeezed purely by its size.
|
|
const { game, players } = teamGame([
|
|
{ team: "Red", tiles: [200, 200, 100] }, // 500 combined, < 600, not leader
|
|
{ team: "Blue", tiles: [700] }, // leader, and 700 >= 200 -> safe
|
|
]);
|
|
const [red1, red2, red3, blue1] = players;
|
|
const exec = makeExec(game);
|
|
runAt(exec, game, WAVE_TICK);
|
|
expect(red1.inDoomsdayClock()).toBe(true); // 500 < 200x3
|
|
expect(red2.inDoomsdayClock()).toBe(true);
|
|
expect(red3.inDoomsdayClock()).toBe(true);
|
|
expect(blue1.inDoomsdayClock()).toBe(false); // leader
|
|
});
|
|
|
|
it("idles when only one team remains", () => {
|
|
const { game, players } = teamGame([{ team: "Red", tiles: [50, 50] }]);
|
|
const exec = makeExec(game);
|
|
runAt(exec, game, WAVE_TICK);
|
|
expect(players[0].inDoomsdayClock()).toBe(false);
|
|
expect(players[1].inDoomsdayClock()).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// The shared wave schedule + drain: pure integer functions, so we assert the
|
|
// exact thresholds and wave cues the sim and the HUD both depend on.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("doomsdayClockRequiredTiles (ramping waves)", () => {
|
|
const land = 10000;
|
|
|
|
it("is 0 through the grace, ramps linearly, then holds during the pause", () => {
|
|
// normal: grace 330s, then a 270s ramp 0->3%, then a 30s hold, ...
|
|
expect(doomsdayClockRequiredTiles("normal", land, 200)).toBe(0); // in the grace
|
|
expect(doomsdayClockRequiredTiles("normal", land, 330)).toBe(0); // grace ends
|
|
expect(doomsdayClockRequiredTiles("normal", land, 465)).toBe(150); // halfway up -> 1.5%
|
|
expect(doomsdayClockRequiredTiles("normal", land, 600)).toBe(300); // ramp done -> 3%
|
|
expect(doomsdayClockRequiredTiles("normal", land, 615)).toBe(300); // pause holds 3%
|
|
expect(doomsdayClockRequiredTiles("normal", land, 630)).toBe(300); // next ramp starts at 3%
|
|
expect(doomsdayClockRequiredTiles("normal", land, 9999)).toBe(5500); // final 55%
|
|
});
|
|
|
|
it("passes 30% then reaches the final 55% squeeze per preset", () => {
|
|
// 30% waypoint, then the 6th wave to 55% one cycle later.
|
|
expect(doomsdayClockRequiredTiles("normal", land, 1800)).toBe(3000); // 30% @ 30:00
|
|
expect(doomsdayClockRequiredTiles("normal", land, 2100)).toBe(5500); // 55% @ 35:00
|
|
expect(doomsdayClockRequiredTiles("fast", land, 1440)).toBe(5500); // 55% @ 24:00
|
|
expect(doomsdayClockRequiredTiles("veryfast", land, 1050)).toBe(5500); // 55% @ 17:30
|
|
expect(doomsdayClockRequiredTiles("slow", land, 2520)).toBe(5500); // 55% @ 42:00
|
|
});
|
|
|
|
it("never decreases, and is zero for no land", () => {
|
|
let prev = 0;
|
|
for (let t = 0; t <= 2400; t += 5) {
|
|
const r = doomsdayClockRequiredTiles("normal", land, t);
|
|
expect(r).toBeGreaterThanOrEqual(prev);
|
|
prev = r;
|
|
}
|
|
expect(doomsdayClockRequiredTiles("normal", 0, 1800)).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe("doomsdayClockSideRequiredTiles (headcount scaling)", () => {
|
|
const land = 10000;
|
|
|
|
it("scales the base share by side size and caps at the whole map", () => {
|
|
// veryfast at 900s is the final 30% wave -> base 3000 tiles.
|
|
expect(doomsdayClockRequiredTiles("veryfast", land, 900)).toBe(3000);
|
|
expect(doomsdayClockSideRequiredTiles("veryfast", land, 900, 1)).toBe(3000); // solo
|
|
expect(doomsdayClockSideRequiredTiles("veryfast", land, 900, 2)).toBe(6000); // 2x
|
|
expect(doomsdayClockSideRequiredTiles("veryfast", land, 900, 4)).toBe(
|
|
10000,
|
|
); // capped
|
|
expect(doomsdayClockSideRequiredTiles("veryfast", land, 900, 0)).toBe(3000); // min size 1
|
|
});
|
|
});
|
|
|
|
describe("doomsdayClockWaveState", () => {
|
|
it("reports the live share and target while ramping", () => {
|
|
const s = doomsdayClockWaveState("normal", 465); // mid the first ramp (0->3%)
|
|
expect(s.currentPercent).toBe(1.5);
|
|
expect(s.targetPercent).toBe(3);
|
|
expect(s.growing).toBe(true);
|
|
expect(s.secondsToNextGrowth).toBe(0);
|
|
expect(s.done).toBe(false);
|
|
});
|
|
|
|
it("counts down to the next ramp during a pause", () => {
|
|
const s = doomsdayClockWaveState("normal", 615); // in the first pause (600-630)
|
|
expect(s.growing).toBe(false);
|
|
expect(s.currentPercent).toBe(3); // held at the level just reached
|
|
expect(s.targetPercent).toBe(5); // next ramp climbs to 5%
|
|
expect(s.secondsToNextGrowth).toBe(15); // next ramp starts at 630
|
|
});
|
|
|
|
it("counts down through the grace", () => {
|
|
const s = doomsdayClockWaveState("normal", 200);
|
|
expect(s.currentPercent).toBe(0);
|
|
expect(s.targetPercent).toBe(3);
|
|
expect(s.secondsToNextGrowth).toBe(130); // first ramp at 330
|
|
});
|
|
|
|
it("flags the 10s window (5s each side) around a ramp starting", () => {
|
|
// veryfast first ramp starts at 180s.
|
|
expect(doomsdayClockWaveState("veryfast", 176).waveFlash).toBe(true); // 4s before
|
|
expect(doomsdayClockWaveState("veryfast", 184).waveFlash).toBe(true); // 4s after
|
|
expect(doomsdayClockWaveState("veryfast", 250).waveFlash).toBe(false); // mid-ramp
|
|
});
|
|
|
|
it("marks done after the last ramp", () => {
|
|
const s = doomsdayClockWaveState("veryfast", 1100); // past the final ramp (@1050) = 55%
|
|
expect(s.done).toBe(true);
|
|
expect(s.currentPercent).toBe(55);
|
|
expect(s.secondsToNextGrowth).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe("doomsdayClockDrain", () => {
|
|
const cfg = {
|
|
drainStartPercent: 10,
|
|
drainMaxPercent: 80,
|
|
drainRampSeconds: 3,
|
|
};
|
|
|
|
it("starts gentle and grows linearly, capping at the max", () => {
|
|
expect(doomsdayClockDrain(1000, 0, cfg)).toBe(100); // 10%
|
|
expect(doomsdayClockDrain(1000, 1, cfg)).toBe(330); // 33%
|
|
expect(doomsdayClockDrain(1000, 2, cfg)).toBe(560); // 56%
|
|
expect(doomsdayClockDrain(1000, 3, cfg)).toBe(800); // capped at 80%
|
|
expect(doomsdayClockDrain(1000, 100, cfg)).toBe(800);
|
|
// linear: each step before the cap removes the same amount more
|
|
const d0 = doomsdayClockDrain(1000, 0, cfg);
|
|
const d1 = doomsdayClockDrain(1000, 1, cfg);
|
|
const d2 = doomsdayClockDrain(1000, 2, cfg);
|
|
expect(d1 - d0).toBe(d2 - d1);
|
|
});
|
|
|
|
it("removes at least one troop and never less", () => {
|
|
expect(doomsdayClockDrain(1, 0, cfg)).toBe(1); // floor(0.1) -> min 1
|
|
});
|
|
|
|
it("treats time before the warn window as zero", () => {
|
|
expect(doomsdayClockDrain(1000, -5, cfg)).toBe(100); // clamped to start %
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Integration: real simulation. We give one player a slice above the bar and
|
|
// another a sliver below it, then run real ticks. The drain is isolated from
|
|
// normal troop dynamics by comparing the enabled run vs the disabled run.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function giveLandTiles(game: Game, player: Player, n: number): number {
|
|
let count = 0;
|
|
for (let y = 0; y < game.height() && count < n; y++) {
|
|
for (let x = 0; x < game.width() && count < n; x++) {
|
|
const t: TileRef = game.ref(x, y);
|
|
if (game.isLand(t) && !game.owner(t).isPlayer()) {
|
|
player.conquer(t);
|
|
count++;
|
|
}
|
|
}
|
|
}
|
|
return count;
|
|
}
|
|
|
|
describe("DoomsdayClockExecution (integration)", () => {
|
|
// Steepest preset; we run past its grace (180s) into the waves. Drain tuning
|
|
// is internal now, so this exercises the default drain (warn 10s, 2%->6%/50s).
|
|
const SD = {
|
|
enabled: true,
|
|
speed: "veryfast" as const,
|
|
};
|
|
const TICKS = 3000; // 300s of game time -> veryfast holding its 3% wave
|
|
|
|
async function buildGame(enabled: boolean) {
|
|
const game = await setup(
|
|
"plains",
|
|
{ instantBuild: true, doomsdayClock: { ...SD, enabled } },
|
|
[
|
|
playerInfo("big", PlayerType.Human),
|
|
playerInfo("small", PlayerType.Human),
|
|
],
|
|
);
|
|
const big = game.player("big");
|
|
const small = game.player("small");
|
|
// Size the slices to the bar at the point we stop.
|
|
const bar = doomsdayClockRequiredTiles(
|
|
"veryfast",
|
|
game.numLandTiles(),
|
|
TICKS / 10,
|
|
);
|
|
giveLandTiles(game, big, bar + 50); // above the bar
|
|
giveLandTiles(game, small, 3); // a sliver, below the bar
|
|
big.setTroops(50_000);
|
|
small.setTroops(50_000);
|
|
// setup() builds the game via createGame, not GameRunner, so the execution
|
|
// GameRunner normally registers must be added here.
|
|
game.addExecution(new DoomsdayClockExecution());
|
|
for (let i = 0; i < TICKS; i++) game.executeNextTick();
|
|
return { big, small };
|
|
}
|
|
|
|
it("skulls the player below the bar, spares the one above, and drains them", async () => {
|
|
const on = await buildGame(true);
|
|
const off = await buildGame(false);
|
|
|
|
expect(on.small.inDoomsdayClock()).toBe(true);
|
|
expect(on.big.inDoomsdayClock()).toBe(false);
|
|
expect(off.small.inDoomsdayClock()).toBe(false);
|
|
|
|
// The drain is the difference vs the disabled run (isolates it from the
|
|
// normal troop dynamics both runs share).
|
|
expect(on.small.troops()).toBeLessThan(off.small.troops());
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("DoomsdayClockExecution (default drain, with income)", () => {
|
|
it("wipes a full-troop side in ~1 minute (warn + linear drain)", async () => {
|
|
// Only enabled + speed set -> drain uses the defaults (warn 10s, 2%->6% /50s).
|
|
// veryfast is chosen purely so the bar rises fast enough to catch the sliver.
|
|
const game = await setup(
|
|
"plains",
|
|
{
|
|
instantBuild: true,
|
|
doomsdayClock: { enabled: true, speed: "veryfast" },
|
|
},
|
|
[
|
|
playerInfo("big", PlayerType.Human),
|
|
playerInfo("small", PlayerType.Human),
|
|
],
|
|
);
|
|
const big = game.player("big");
|
|
const small = game.player("small");
|
|
giveLandTiles(game, big, 4000); // safely above the bar
|
|
giveLandTiles(game, small, 3); // a sliver, caught once the bar rises
|
|
game.addExecution(new PlayerExecution(big));
|
|
game.addExecution(new PlayerExecution(small)); // income every tick
|
|
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.
|
|
let caughtTick = -1;
|
|
for (let i = 0; i < 3000; i++) {
|
|
game.executeNextTick();
|
|
if (small.inDoomsdayClock()) {
|
|
caughtTick = game.ticks();
|
|
break;
|
|
}
|
|
}
|
|
expect(caughtTick).toBeGreaterThan(0);
|
|
small.setTroops(game.config().maxTroops(small));
|
|
|
|
let zeroTick = -1;
|
|
for (let i = 0; i < 1500; i++) {
|
|
game.executeNextTick();
|
|
if (small.troops() <= 0) {
|
|
zeroTick = game.ticks();
|
|
break;
|
|
}
|
|
}
|
|
expect(zeroTick).toBeGreaterThan(0);
|
|
const seconds = (zeroTick - caughtTick) / 10;
|
|
// ~10s warn + ~50s drain, income included: about a minute (NOT ~45s).
|
|
expect(seconds).toBeGreaterThan(50);
|
|
expect(seconds).toBeLessThan(85);
|
|
});
|
|
});
|