mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-04 16:06:04 +00:00
feat(doomsday-clock): battle-royale style zone gamemode (#4469)
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._
This commit is contained in:
@@ -14,6 +14,7 @@
|
||||
"embargo": 6,
|
||||
"nukeRed": 7,
|
||||
"nukeWhite": 8,
|
||||
"allianceFaded": 9
|
||||
"allianceFaded": 9,
|
||||
"doomsdayClock": 10
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 99 KiB |
@@ -0,0 +1,19 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 32 32">
|
||||
<!-- Skull scaled to ~85% and nudged up so it clears the name below it. -->
|
||||
<g transform="translate(16 13) scale(0.85) translate(-16 -15)">
|
||||
<!-- cranium + jaw as one outline -->
|
||||
<path d="M16 1.2C9.2 1.2 3.8 6.3 3.8 13.2c0 3.7 1.7 6.6 4 8.5.6.5.9 1.2.9 2v2.6c0 1.4 1.1 2.5 2.5 2.5h9.6c1.4 0 2.5-1.1 2.5-2.5v-2.6c0-.8.3-1.5.9-2 2.3-1.9 4-4.8 4-8.5C28.2 6.3 22.8 1.2 16 1.2Z"
|
||||
fill="#F4F4F4" stroke="#191919" stroke-width="1.4" stroke-linejoin="round"/>
|
||||
<!-- eye sockets -->
|
||||
<ellipse cx="11.1" cy="13.3" rx="3.7" ry="4.3" fill="#191919"/>
|
||||
<ellipse cx="20.9" cy="13.3" rx="3.7" ry="4.3" fill="#191919"/>
|
||||
<!-- nasal cavity -->
|
||||
<path d="M16 17.6l-2 3.7h4z" fill="#191919"/>
|
||||
<!-- teeth -->
|
||||
<g stroke="#191919" stroke-width="1.4" stroke-linecap="round">
|
||||
<line x1="13" y1="24.4" x2="13" y2="28.2"/>
|
||||
<line x1="16" y1="24.4" x2="16" y2="28.4"/>
|
||||
<line x1="19" y1="24.4" x2="19" y2="28.2"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -400,6 +400,25 @@
|
||||
"discord_user_header": {
|
||||
"avatar_alt": "Avatar"
|
||||
},
|
||||
"doomsday_clock": {
|
||||
"collapsing": "Collapsing -{rate}/s",
|
||||
"decay_in": "Decay in {secs}s",
|
||||
"final": "Final zone, hold {pct}%",
|
||||
"growing": "Rising to {pct}%",
|
||||
"hold": "Hold ≥ {pct}%",
|
||||
"next_wave": "Next {pct}% in {time}",
|
||||
"stable": "Stable",
|
||||
"title": "Doomsday Clock",
|
||||
"unstable": "Unstable",
|
||||
"you": "You {pct}%",
|
||||
"your_team": "{team}: {pct}%"
|
||||
},
|
||||
"doomsday_clock_speed": {
|
||||
"fast": "Fast",
|
||||
"normal": "Normal",
|
||||
"slow": "Slow",
|
||||
"veryfast": "Very Fast"
|
||||
},
|
||||
"effects": {
|
||||
"button_title": "Pick an effect!",
|
||||
"nukeType": {
|
||||
@@ -740,6 +759,7 @@
|
||||
"disable_alliances": "Disable alliances",
|
||||
"donate_gold": "Donate gold",
|
||||
"donate_troops": "Donate troops",
|
||||
"doomsday_clock": "Doomsday Clock",
|
||||
"empty_team": "Empty",
|
||||
"empty_teams": "Empty Teams",
|
||||
"enables_title": "Disable Units",
|
||||
@@ -1244,6 +1264,7 @@
|
||||
"bots_disabled": "Disabled",
|
||||
"compact_map": "Compact Map",
|
||||
"disable_alliances": "Disable alliances",
|
||||
"doomsday_clock": "Doomsday Clock",
|
||||
"enables_title": "Disable Units",
|
||||
"gold_multiplier": "Gold multiplier",
|
||||
"gold_multiplier_placeholder": "2.0x",
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
translateText,
|
||||
} from "../client/Utils";
|
||||
import { EventBus } from "../core/EventBus";
|
||||
import { DoomsdayClockSpeed } from "../core/game/DoomsdayClock";
|
||||
import {
|
||||
Difficulty,
|
||||
GameMapSize,
|
||||
@@ -79,6 +80,8 @@ export class HostLobbyModal extends BaseModal {
|
||||
@state() private startingGold: boolean = false;
|
||||
@state() private startingGoldValue: number | undefined = undefined;
|
||||
@state() private disableAlliances: boolean = false;
|
||||
@state() private doomsdayClock: boolean = false;
|
||||
@state() private doomsdayClockSpeed: DoomsdayClockSpeed = "normal";
|
||||
@state() private anonymizeNames: boolean = false;
|
||||
@state() private nameReveals: string[] = [];
|
||||
@state() private whitelistEnabled: boolean = false;
|
||||
@@ -423,6 +426,11 @@ export class HostLobbyModal extends BaseModal {
|
||||
labelKey: "host_modal.water_nukes",
|
||||
checked: this.waterNukes,
|
||||
},
|
||||
{
|
||||
labelKey: "host_modal.doomsday_clock",
|
||||
checked: this.doomsdayClock,
|
||||
doomsdayClockSpeed: this.doomsdayClockSpeed,
|
||||
},
|
||||
{
|
||||
labelKey: "host_modal.host_cheats",
|
||||
checked: this.hostCheatsEnabled,
|
||||
@@ -453,6 +461,8 @@ export class HostLobbyModal extends BaseModal {
|
||||
@map-selected=${this.handleConfigMapSelected}
|
||||
@random-map-selected=${this.handleConfigRandomMapSelected}
|
||||
@difficulty-selected=${this.handleConfigDifficultySelected}
|
||||
@doomsday-clock-speed-selected=${this
|
||||
.handleConfigDoomsdayClockSpeedSelected}
|
||||
@game-mode-selected=${this.handleConfigGameModeSelected}
|
||||
@team-count-selected=${this.handleConfigTeamCountSelected}
|
||||
@bots-changed=${this.handleBotsChange}
|
||||
@@ -603,6 +613,8 @@ export class HostLobbyModal extends BaseModal {
|
||||
this.startingGold = false;
|
||||
this.startingGoldValue = undefined;
|
||||
this.disableAlliances = false;
|
||||
this.doomsdayClock = false;
|
||||
this.doomsdayClockSpeed = "normal";
|
||||
this.anonymizeNames = false;
|
||||
this.nameReveals = [];
|
||||
this.whitelistEnabled = false;
|
||||
@@ -652,6 +664,12 @@ export class HostLobbyModal extends BaseModal {
|
||||
void this.handleDifficultySelection(customEvent.detail.difficulty);
|
||||
};
|
||||
|
||||
private handleConfigDoomsdayClockSpeedSelected = (e: Event) => {
|
||||
const customEvent = e as CustomEvent<{ speed: DoomsdayClockSpeed }>;
|
||||
this.doomsdayClockSpeed = customEvent.detail.speed;
|
||||
this.putGameConfig();
|
||||
};
|
||||
|
||||
private handleConfigGameModeSelected = (e: Event) => {
|
||||
const customEvent = e as CustomEvent<{ mode: GameMode }>;
|
||||
void this.handleGameModeSelection(customEvent.detail.mode);
|
||||
@@ -703,6 +721,10 @@ export class HostLobbyModal extends BaseModal {
|
||||
this.waterNukes = checked;
|
||||
this.putGameConfig();
|
||||
break;
|
||||
case "host_modal.doomsday_clock":
|
||||
this.doomsdayClock = checked;
|
||||
this.putGameConfig();
|
||||
break;
|
||||
case "host_modal.host_cheats":
|
||||
this.hostCheatsEnabled = checked;
|
||||
this.putGameConfig();
|
||||
@@ -1086,6 +1108,12 @@ export class HostLobbyModal extends BaseModal {
|
||||
? Math.round(this.startingGoldValue * 1_000_000)
|
||||
: null,
|
||||
disableAlliances: this.disableAlliances || null,
|
||||
// Send {enabled:false} (not undefined) when off: undefined is dropped
|
||||
// by JSON.stringify, so the server's "!== undefined" merge would keep a
|
||||
// previously-enabled config and the toggle could never turn off.
|
||||
doomsdayClock: this.doomsdayClock
|
||||
? { enabled: true, speed: this.doomsdayClockSpeed }
|
||||
: { enabled: false },
|
||||
anonymizeNames: this.anonymizeNames,
|
||||
nameReveals: this.nameReveals,
|
||||
allowedPublicIds: this.whitelistEnabled
|
||||
|
||||
@@ -3,6 +3,7 @@ import { customElement, state } from "lit/decorators.js";
|
||||
import { translateText } from "../client/Utils";
|
||||
import { UserMeResponse } from "../core/ApiSchemas";
|
||||
import { assetUrl } from "../core/AssetUrls";
|
||||
import { DoomsdayClockSpeed } from "../core/game/DoomsdayClock";
|
||||
import {
|
||||
Difficulty,
|
||||
GameMapSize,
|
||||
@@ -61,6 +62,8 @@ const DEFAULT_OPTIONS = {
|
||||
disabledUnits: [] as UnitType[],
|
||||
disableAlliances: false,
|
||||
waterNukes: false,
|
||||
doomsdayClock: false,
|
||||
doomsdayClockSpeed: "normal" as DoomsdayClockSpeed,
|
||||
} as const;
|
||||
|
||||
// A map earns achievements only if it has nations to conquer — the same rule
|
||||
@@ -144,6 +147,9 @@ export class SinglePlayerModal extends BaseModal {
|
||||
];
|
||||
@state() private disableAlliances: boolean = DEFAULT_OPTIONS.disableAlliances;
|
||||
@state() private waterNukes: boolean = DEFAULT_OPTIONS.waterNukes;
|
||||
@state() private doomsdayClock: boolean = DEFAULT_OPTIONS.doomsdayClock;
|
||||
@state() private doomsdayClockSpeed: DoomsdayClockSpeed =
|
||||
DEFAULT_OPTIONS.doomsdayClockSpeed;
|
||||
|
||||
private mapLoader = terrainMapFileLoader;
|
||||
|
||||
@@ -442,6 +448,11 @@ export class SinglePlayerModal extends BaseModal {
|
||||
labelKey: "single_modal.water_nukes",
|
||||
checked: this.waterNukes,
|
||||
},
|
||||
{
|
||||
labelKey: "single_modal.doomsday_clock",
|
||||
checked: this.doomsdayClock,
|
||||
doomsdayClockSpeed: this.doomsdayClockSpeed,
|
||||
},
|
||||
],
|
||||
inputCards,
|
||||
},
|
||||
@@ -453,6 +464,8 @@ export class SinglePlayerModal extends BaseModal {
|
||||
@map-selected=${this.handleConfigMapSelected}
|
||||
@random-map-selected=${this.handleConfigRandomMapSelected}
|
||||
@difficulty-selected=${this.handleConfigDifficultySelected}
|
||||
@doomsday-clock-speed-selected=${this
|
||||
.handleConfigDoomsdayClockSpeedSelected}
|
||||
@game-mode-selected=${this.handleConfigGameModeSelected}
|
||||
@team-count-selected=${this.handleConfigTeamCountSelected}
|
||||
@bots-changed=${this.handleBotsChange}
|
||||
@@ -499,6 +512,10 @@ export class SinglePlayerModal extends BaseModal {
|
||||
this.startingGold !== DEFAULT_OPTIONS.startingGold ||
|
||||
this.disableAlliances !== DEFAULT_OPTIONS.disableAlliances ||
|
||||
this.waterNukes !== DEFAULT_OPTIONS.waterNukes ||
|
||||
this.doomsdayClock !== DEFAULT_OPTIONS.doomsdayClock ||
|
||||
// Pace only matters when the mode is on (startGame drops it when off).
|
||||
(this.doomsdayClock &&
|
||||
this.doomsdayClockSpeed !== DEFAULT_OPTIONS.doomsdayClockSpeed) ||
|
||||
this.disabledUnits.length > 0
|
||||
);
|
||||
}
|
||||
@@ -527,6 +544,8 @@ export class SinglePlayerModal extends BaseModal {
|
||||
this.startingGoldValue = DEFAULT_OPTIONS.startingGoldValue;
|
||||
this.disableAlliances = DEFAULT_OPTIONS.disableAlliances;
|
||||
this.waterNukes = DEFAULT_OPTIONS.waterNukes;
|
||||
this.doomsdayClock = DEFAULT_OPTIONS.doomsdayClock;
|
||||
this.doomsdayClockSpeed = DEFAULT_OPTIONS.doomsdayClockSpeed;
|
||||
}
|
||||
|
||||
protected onOpen(): void {
|
||||
@@ -563,6 +582,11 @@ export class SinglePlayerModal extends BaseModal {
|
||||
this.handleDifficultySelection(customEvent.detail.difficulty);
|
||||
};
|
||||
|
||||
private handleConfigDoomsdayClockSpeedSelected = (e: Event) => {
|
||||
const customEvent = e as CustomEvent<{ speed: DoomsdayClockSpeed }>;
|
||||
this.doomsdayClockSpeed = customEvent.detail.speed;
|
||||
};
|
||||
|
||||
private handleConfigGameModeSelected = (e: Event) => {
|
||||
const customEvent = e as CustomEvent<{ mode: GameMode }>;
|
||||
this.handleGameModeSelection(customEvent.detail.mode);
|
||||
@@ -612,6 +636,9 @@ export class SinglePlayerModal extends BaseModal {
|
||||
case "single_modal.water_nukes":
|
||||
this.waterNukes = checked;
|
||||
break;
|
||||
case "single_modal.doomsday_clock":
|
||||
this.doomsdayClock = checked;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
@@ -820,6 +847,14 @@ export class SinglePlayerModal extends BaseModal {
|
||||
: {}),
|
||||
...(this.disableAlliances ? { disableAlliances: true } : {}),
|
||||
...(this.waterNukes ? { waterNukes: true } : {}),
|
||||
...(this.doomsdayClock
|
||||
? {
|
||||
doomsdayClock: {
|
||||
enabled: true,
|
||||
speed: this.doomsdayClockSpeed,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
lobbyCreatedAt: Date.now(), // ms; server should be authoritative in MP
|
||||
},
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { assetUrl } from "../../core/AssetUrls";
|
||||
import {
|
||||
doomsdayClockDrain,
|
||||
doomsdayClockSideRequiredTiles,
|
||||
doomsdayClockWaveState,
|
||||
} from "../../core/game/DoomsdayClock";
|
||||
import { GameMode, PlayerType, Team } from "../../core/game/Game";
|
||||
import { themeProvider } from "../theme/ThemeProvider";
|
||||
import { renderTroops, translateText } from "../Utils";
|
||||
import { GameView } from "../view";
|
||||
|
||||
const doomsdayClockIcon = assetUrl("images/DoomsdayClockSkull.svg");
|
||||
|
||||
/**
|
||||
* The Doomsday Clock readout: a self-contained panel showing the rising bar, the
|
||||
* side's share vs the threshold, the stage (Stable/Unstable/Collapsing) and the
|
||||
* wave countdown. Embedded by game-right-sidebar so it stacks (centered) under
|
||||
* the game timer; it hides itself when the mode is off, after a winner, or for a
|
||||
* spectator/eliminated player.
|
||||
*/
|
||||
@customElement("doomsday-clock-panel")
|
||||
export class DoomsdayClockPanel extends LitElement {
|
||||
@property({ attribute: false }) game!: GameView;
|
||||
@property({ attribute: false }) hasWinner = false;
|
||||
// Bumped by the parent each tick so the countdown + bar advance every second.
|
||||
@property({ attribute: false }) refreshKey = 0;
|
||||
|
||||
// Light DOM so Tailwind classes apply and it stacks in the parent's flex.
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
private secondsToHms(d: number): string {
|
||||
const pad = (n: number) => (n < 10 ? `0${n}` : n);
|
||||
const h = Math.floor(d / 3600);
|
||||
const m = Math.floor((d % 3600) / 60);
|
||||
const s = Math.floor((d % 3600) % 60);
|
||||
return h !== 0 ? `${pad(h)}:${pad(m)}:${pad(s)}` : `${pad(m)}:${pad(s)}`;
|
||||
}
|
||||
|
||||
// The player's "side" (matching the sim): themselves in FFA, their whole team
|
||||
// otherwise. Returns the combined tiles and the headcount (the sim scales the
|
||||
// threshold by headcount, so the HUD needs it too).
|
||||
private sideStats(me: ReturnType<GameView["myPlayer"]>): {
|
||||
tiles: number;
|
||||
size: number;
|
||||
} {
|
||||
if (!me) return { tiles: 0, size: 1 };
|
||||
const ffa = this.game.config().gameConfig().gameMode === GameMode.FFA;
|
||||
const myTeam = me.team();
|
||||
if (ffa || myTeam === null) return { tiles: me.numTilesOwned(), size: 1 };
|
||||
const mates = this.game
|
||||
.playerViews()
|
||||
.filter(
|
||||
(p) =>
|
||||
p.team() === myTeam && p.isAlive() && p.type() !== PlayerType.Bot,
|
||||
);
|
||||
return {
|
||||
tiles: mates.reduce((sum, p) => sum + p.numTilesOwned(), 0),
|
||||
size: mates.length,
|
||||
};
|
||||
}
|
||||
|
||||
// Localized team name (e.g. "Red"), matching TeamStats; falls back to the raw
|
||||
// team id for numbered teams.
|
||||
private teamDisplayName(team: Team): string {
|
||||
const key = `team_colors.${team.toLowerCase()}`;
|
||||
const translated = translateText(key);
|
||||
return translated !== key ? translated : team;
|
||||
}
|
||||
|
||||
// The team's on-map color as a hex string, for the readout label.
|
||||
private teamColor(team: Team): string {
|
||||
return themeProvider.current().teamColor(team).toHex();
|
||||
}
|
||||
|
||||
render() {
|
||||
const sd = this.game?.config().doomsdayClockConfig();
|
||||
const me = this.game?.myPlayer();
|
||||
// Personal readout: no meaning when off, after a winner, or for a spectator
|
||||
// / eliminated player (a 0-tile "me" would also pulse red-alert forever).
|
||||
const visible =
|
||||
!!sd?.enabled && !this.hasWinner && (me?.isAlive() ?? false);
|
||||
this.style.display = visible ? "block" : "none";
|
||||
if (!visible || !me || !sd) return html``;
|
||||
|
||||
const elapsed = Math.floor(this.game.elapsedGameSeconds());
|
||||
const land = this.game.numLandTiles() - this.game.numTilesWithFallout();
|
||||
const myTeam = me.team() ?? null;
|
||||
const { tiles: yourTiles, size: mySize } = this.sideStats(me);
|
||||
// Threshold is scaled by the side's headcount (same as the sim).
|
||||
const requiredTiles = doomsdayClockSideRequiredTiles(
|
||||
sd.speed,
|
||||
land,
|
||||
elapsed,
|
||||
mySize,
|
||||
);
|
||||
const wave = doomsdayClockWaveState(sd.speed, elapsed);
|
||||
// Wave readout percentages scale by headcount too (capped at the whole map).
|
||||
const scalePct = (p: number) => Math.min(100, p * mySize);
|
||||
// Match the sim: no land -> no bar, no percentages (avoid div-by-zero / >100%).
|
||||
const requiredPct = land > 0 ? (requiredTiles / land) * 100 : 0;
|
||||
const yourPct = land > 0 ? (yourTiles / land) * 100 : 0;
|
||||
const flagged = me?.inDoomsdayClock() ?? false;
|
||||
const secondsUnder = Math.floor((me?.doomsdayClockTicks() ?? 0) / 10);
|
||||
const draining = flagged && secondsUnder >= sd.warnSeconds;
|
||||
// Safe but within 10% (relative) of the bar: e.g. at 9% when the bar is 10%,
|
||||
// or 0.9% when it's 1%. About to be caught, so it blinks red too.
|
||||
const nearDanger =
|
||||
!flagged && requiredTiles > 0 && yourPct <= requiredPct * 1.1;
|
||||
// In danger (caught/draining) or about to be: everything red.
|
||||
const redAlert = flagged || nearDanger;
|
||||
|
||||
// The zone's own progress, independent of your status. Shown while stable
|
||||
// AND while collapsing, so you can still see the bar rising as you bleed.
|
||||
const zoneDetail = wave.done
|
||||
? translateText("doomsday_clock.final", {
|
||||
pct: scalePct(wave.currentPercent),
|
||||
})
|
||||
: wave.growing
|
||||
? translateText("doomsday_clock.growing", {
|
||||
pct: scalePct(wave.targetPercent),
|
||||
})
|
||||
: translateText("doomsday_clock.next_wave", {
|
||||
pct: scalePct(wave.targetPercent),
|
||||
time: this.secondsToHms(wave.secondsToNextGrowth),
|
||||
});
|
||||
|
||||
// Status word + detail line.
|
||||
let status: string;
|
||||
let statusClass: string;
|
||||
let detail: string;
|
||||
if (draining && me) {
|
||||
// Drain is a % of max-troop capacity, capped at current troops; show the
|
||||
// actual per-second loss (renderTroops handles the /10 display unit).
|
||||
const chunk = doomsdayClockDrain(
|
||||
this.game.config().maxTroops(me),
|
||||
secondsUnder - sd.warnSeconds,
|
||||
sd,
|
||||
);
|
||||
status = translateText("doomsday_clock.collapsing", {
|
||||
rate: renderTroops(Math.min(me.troops(), chunk)),
|
||||
});
|
||||
statusClass = "text-red-400 font-bold";
|
||||
detail = zoneDetail; // keep the zone readout visible while collapsing
|
||||
} else if (flagged) {
|
||||
// Caught below a wave: count down the cooldown before decay begins.
|
||||
status = translateText("doomsday_clock.unstable");
|
||||
statusClass = "text-red-400 font-bold";
|
||||
detail = translateText("doomsday_clock.decay_in", {
|
||||
secs: Math.max(0, sd.warnSeconds - secondsUnder),
|
||||
});
|
||||
} else {
|
||||
status = translateText("doomsday_clock.stable");
|
||||
statusClass = nearDanger ? "text-orange-300 font-bold" : "text-green-400";
|
||||
detail = zoneDetail;
|
||||
}
|
||||
|
||||
// Panel edge cue: red pulse when in/near danger, orange pulse in the 10s
|
||||
// window around a wave firing.
|
||||
const edge = redAlert
|
||||
? "sd-pulse-red"
|
||||
: wave.waveFlash
|
||||
? "sd-pulse-orange"
|
||||
: "";
|
||||
const panel =
|
||||
"w-fit flex flex-col gap-1.5 py-2 px-4 bg-gray-800/92 backdrop-blur-sm shadow-xs min-[1200px]:rounded-lg rounded-bl-lg text-white text-sm";
|
||||
|
||||
return html`
|
||||
<style>
|
||||
@keyframes sd-red {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(248, 113, 113, 0);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 3px rgba(248, 113, 113, 0.95);
|
||||
}
|
||||
}
|
||||
@keyframes sd-orange {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(251, 146, 60, 0);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 3px rgba(251, 146, 60, 0.9);
|
||||
}
|
||||
}
|
||||
.sd-pulse-red {
|
||||
animation: sd-red 1s ease-in-out infinite;
|
||||
}
|
||||
.sd-pulse-orange {
|
||||
animation: sd-orange 1.8s ease-in-out infinite;
|
||||
}
|
||||
</style>
|
||||
<div class="${panel} ${edge}">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<span
|
||||
class="flex items-center gap-1.5 font-bold tracking-wide text-red-400"
|
||||
>
|
||||
<img src=${doomsdayClockIcon} alt="" width="20" height="20" />
|
||||
${translateText("doomsday_clock.title")}
|
||||
</span>
|
||||
<span class=${statusClass}>${status}</span>
|
||||
</div>
|
||||
<div class="relative h-2.5 w-52 overflow-hidden rounded bg-gray-600/60">
|
||||
<!-- your held share (green) vs the target threshold (red bar): the gap
|
||||
between them shows how far you are from safe. -->
|
||||
<div
|
||||
class="absolute inset-y-0 left-0 bg-green-400"
|
||||
style="width:${Math.min(100, yourPct)}%"
|
||||
></div>
|
||||
<div
|
||||
class="absolute inset-y-0 w-0.5 bg-red-500"
|
||||
style="left:${Math.min(100, requiredPct)}%"
|
||||
></div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-3 text-gray-300">
|
||||
<span>
|
||||
${translateText("doomsday_clock.hold", {
|
||||
pct: requiredPct.toFixed(1),
|
||||
})}
|
||||
</span>
|
||||
${myTeam !== null
|
||||
? html`<span style=${`color:${this.teamColor(myTeam)}`}>
|
||||
${translateText("doomsday_clock.your_team", {
|
||||
team: this.teamDisplayName(myTeam),
|
||||
pct: yourPct.toFixed(1),
|
||||
})}
|
||||
</span>`
|
||||
: html`<span class=${redAlert ? "text-red-300" : "text-green-300"}>
|
||||
${translateText("doomsday_clock.you", {
|
||||
pct: yourPct.toFixed(1),
|
||||
})}
|
||||
</span>`}
|
||||
</div>
|
||||
${detail
|
||||
? html`<div class="text-xs text-gray-400">${detail}</div>`
|
||||
: ""}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,10 @@ import {
|
||||
svg,
|
||||
} from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import {
|
||||
DOOMSDAY_CLOCK_SPEEDS,
|
||||
DoomsdayClockSpeed,
|
||||
} from "../../core/game/DoomsdayClock";
|
||||
import {
|
||||
Difficulty,
|
||||
Duos,
|
||||
@@ -65,7 +69,13 @@ function renderTextCardButton(
|
||||
cardExtraClass: string,
|
||||
): TemplateResult {
|
||||
return html`
|
||||
<button class="${cardClass(active, cardExtraClass)}" @click=${onClick}>
|
||||
<button
|
||||
class="${cardClass(
|
||||
active,
|
||||
cardExtraClass,
|
||||
)} flex items-center justify-center"
|
||||
@click=${onClick}
|
||||
>
|
||||
<span class="${CARD_LABEL_CLASS} ${stateTextClass(active)}">
|
||||
${label}
|
||||
</span>
|
||||
@@ -175,6 +185,8 @@ export interface ToggleOptionConfig {
|
||||
labelKey: string;
|
||||
checked: boolean;
|
||||
hidden?: boolean;
|
||||
// When set, this toggle's card expands to a pace dropdown while it is checked.
|
||||
doomsdayClockSpeed?: DoomsdayClockSpeed;
|
||||
}
|
||||
|
||||
export interface GameConfigSettingsData {
|
||||
@@ -265,6 +277,11 @@ export class GameConfigSettings extends LitElement {
|
||||
this.emit("difficulty-selected", { difficulty });
|
||||
};
|
||||
|
||||
private handleDoomsdayClockSpeedChange = (e: Event) => {
|
||||
const speed = (e.target as HTMLSelectElement).value as DoomsdayClockSpeed;
|
||||
this.emit("doomsday-clock-speed-selected", { speed });
|
||||
};
|
||||
|
||||
private handleGameModeSelect = (mode: GameMode) => {
|
||||
this.emit("game-mode-selected", { mode });
|
||||
};
|
||||
@@ -304,6 +321,10 @@ export class GameConfigSettings extends LitElement {
|
||||
private renderOptionToggle(toggle: ToggleOptionConfig): TemplateResult {
|
||||
if (toggle.hidden) return html``;
|
||||
|
||||
if (toggle.doomsdayClockSpeed !== undefined) {
|
||||
return this.renderDoomsdayClockToggle(toggle);
|
||||
}
|
||||
|
||||
return renderTextCardButton(
|
||||
translateText(toggle.labelKey),
|
||||
toggle.checked,
|
||||
@@ -312,6 +333,47 @@ export class GameConfigSettings extends LitElement {
|
||||
);
|
||||
}
|
||||
|
||||
// Same toggle card as the others, but when on it grows to hold the pace
|
||||
// dropdown. The card toggles on click; the dropdown stops propagation so
|
||||
// changing the pace doesn't flip the toggle.
|
||||
private renderDoomsdayClockToggle(
|
||||
toggle: ToggleOptionConfig,
|
||||
): TemplateResult {
|
||||
const selected = toggle.doomsdayClockSpeed;
|
||||
return html`
|
||||
<div
|
||||
class="${cardClass(
|
||||
toggle.checked,
|
||||
// Centered label; when checked the dropdown is added below it so the
|
||||
// label shifts up and the dropdown is reachable.
|
||||
"p-4 flex flex-col items-center justify-center gap-2 text-center",
|
||||
)}"
|
||||
@click=${() => this.handleOptionToggle(toggle)}
|
||||
>
|
||||
<span class="${CARD_LABEL_CLASS} ${stateTextClass(toggle.checked)}">
|
||||
${translateText(toggle.labelKey)}
|
||||
</span>
|
||||
${toggle.checked
|
||||
? html`
|
||||
<select
|
||||
class="bg-white/10 border border-white/20 rounded-lg px-2 py-1 text-white text-xs"
|
||||
@click=${(e: Event) => e.stopPropagation()}
|
||||
@change=${this.handleDoomsdayClockSpeedChange}
|
||||
>
|
||||
${DOOMSDAY_CLOCK_SPEEDS.map(
|
||||
(speed) => html`
|
||||
<option value=${speed} ?selected=${selected === speed}>
|
||||
${translateText(`doomsday_clock_speed.${speed}`)}
|
||||
</option>
|
||||
`,
|
||||
)}
|
||||
</select>
|
||||
`
|
||||
: nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderUnitTypeOptions(disabledUnits: UnitType[]): TemplateResult[] {
|
||||
return unitOptions.map(({ type, translationKey }) => {
|
||||
const isEnabled = !disabledUnits.includes(type);
|
||||
|
||||
@@ -18,6 +18,7 @@ const nukeWhiteIcon = assetUrl("images/NukeIconWhite.svg");
|
||||
const questionMarkIcon = assetUrl("images/QuestionMarkIcon.svg");
|
||||
const targetIcon = assetUrl("images/TargetIcon.svg");
|
||||
const traitorIcon = assetUrl("images/TraitorIcon.svg");
|
||||
const doomsdayClockIcon = assetUrl("images/DoomsdayClockSkull.svg");
|
||||
|
||||
let allianceIconTemplate: HTMLDivElement | undefined;
|
||||
|
||||
@@ -25,6 +26,7 @@ export const ALLIANCE_ICON_ID = "alliance" as const;
|
||||
const ALLIANCE_PROGRESS_OVERLAY_CLASS = "alliance-progress-overlay";
|
||||
const ALLIANCE_QUESTION_MARK_CLASS = "alliance-question-mark";
|
||||
export const TRAITOR_ICON_ID = "traitor" as const;
|
||||
export const DOOMSDAY_CLOCK_ICON_ID = "doomsday-clock" as const;
|
||||
const CROWN_ICON_ID = "crown" as const;
|
||||
const DISCONNECTED_ICON_ID = "disconnected" as const;
|
||||
const ALLIANCE_REQUEST_ICON_ID = "alliance-request" as const;
|
||||
@@ -45,7 +47,8 @@ export type PlayerIconId =
|
||||
| typeof TARGET_ICON_ID
|
||||
| typeof EMOJI_ICON_ID
|
||||
| typeof EMBARGO_ICON_ID
|
||||
| typeof NUKE_ICON_ID;
|
||||
| typeof NUKE_ICON_ID
|
||||
| typeof DOOMSDAY_CLOCK_ICON_ID;
|
||||
|
||||
export type PlayerIconKind = typeof IMAGE_ICON_KIND | typeof EMOJI_ICON_KIND;
|
||||
|
||||
@@ -123,6 +126,15 @@ export function getPlayerIcons(
|
||||
});
|
||||
}
|
||||
|
||||
// Doomsday Clock skull (anti-stall: below the rising territory bar)
|
||||
if (player.inDoomsdayClock()) {
|
||||
icons.push({
|
||||
id: DOOMSDAY_CLOCK_ICON_ID,
|
||||
kind: IMAGE_ICON_KIND,
|
||||
src: doomsdayClockIcon,
|
||||
});
|
||||
}
|
||||
|
||||
// Disconnected icon
|
||||
if (player.isDisconnected()) {
|
||||
icons.push({
|
||||
|
||||
@@ -3,6 +3,7 @@ import { customElement, state } from "lit/decorators.js";
|
||||
import { assetUrl } from "../../../core/AssetUrls";
|
||||
import { EventBus } from "../../../core/EventBus";
|
||||
import { GameType } from "../../../core/game/Game";
|
||||
import "../../components/DoomsdayClockPanel";
|
||||
import { Controller } from "../../Controller";
|
||||
import { crazyGamesSDK } from "../../CrazyGamesSDK";
|
||||
import { TogglePauseIntentEvent } from "../../InputHandler";
|
||||
@@ -50,6 +51,12 @@ export class GameRightSidebar extends LitElement implements Controller {
|
||||
private immunityBarVisible = false;
|
||||
|
||||
createRenderRoot() {
|
||||
// Stack the timer bar + doomsday-clock readout, centers aligned (the narrower
|
||||
// one sits centered under the wider one).
|
||||
this.style.display = "flex";
|
||||
this.style.flexDirection = "column";
|
||||
this.style.alignItems = "center";
|
||||
this.style.gap = "6px";
|
||||
return this;
|
||||
}
|
||||
|
||||
@@ -263,6 +270,11 @@ export class GameRightSidebar extends LitElement implements Controller {
|
||||
<img src=${exitIcon} alt="exit" width="20" height="20" />
|
||||
</div>
|
||||
</aside>
|
||||
<doomsday-clock-panel
|
||||
.game=${this.game}
|
||||
.hasWinner=${this.hasWinner}
|
||||
.refreshKey=${this.timer}
|
||||
></doomsday-clock-panel>
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -37,6 +37,12 @@ export interface ComputePlayerStatusOptions {
|
||||
* Predicate testing if the local player considers `sid` a transitive target.
|
||||
*/
|
||||
isTransitiveTarget?: (sid: number) => boolean;
|
||||
/**
|
||||
* Ticks a side must be below the bar before it drains (doomsday clock). Past
|
||||
* this the skull holds steady instead of blinking. Omit to treat any flagged
|
||||
* side as draining.
|
||||
*/
|
||||
doomsdayClockWarnTicks?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -98,6 +104,13 @@ export function computePlayerStatus(
|
||||
const crown = sid === crownSmallID;
|
||||
const traitor = ps.isTraitor;
|
||||
const disconnected = ps.isDisconnected;
|
||||
const inDoomsdayClock = ps.inDoomsdayClock;
|
||||
// Past the warn grace the side is actively bleeding troops; the skull holds
|
||||
// steady then (vs blinking while merely in danger).
|
||||
const doomsdayClockDraining =
|
||||
inDoomsdayClock &&
|
||||
(opts.tick ?? 0) - ps.markedDoomsdayClockTick >=
|
||||
(opts.doomsdayClockWarnTicks ?? 0);
|
||||
const traitorRemainingTicks = ps.traitorRemainingTicks;
|
||||
|
||||
// Relative flags
|
||||
@@ -151,6 +164,7 @@ export function computePlayerStatus(
|
||||
crown ||
|
||||
traitor ||
|
||||
disconnected ||
|
||||
inDoomsdayClock ||
|
||||
traitorRemainingTicks > 0 ||
|
||||
nukeActive ||
|
||||
alliance ||
|
||||
@@ -163,6 +177,8 @@ export function computePlayerStatus(
|
||||
crown,
|
||||
traitor,
|
||||
disconnected,
|
||||
inDoomsdayClock,
|
||||
doomsdayClockDraining,
|
||||
alliance,
|
||||
allianceReq,
|
||||
target,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/**
|
||||
* StatusIconProgram — instanced status icons above player names.
|
||||
*
|
||||
* Renders up to 8 status icons per player (crown, traitor, disconnected,
|
||||
* alliance, alliance request, target, embargo, nuke). Each instance reads
|
||||
* individual float flags from pd5/pd6 to decide whether to draw.
|
||||
* Renders up to 9 status icons per player (crown, traitor, disconnected,
|
||||
* alliance, alliance request, target, embargo, nuke, doomsday-clock skull). Each
|
||||
* instance reads individual float flags to decide whether to draw.
|
||||
*
|
||||
* Owns: shader program, uniform locations, status atlas texture.
|
||||
* The shared playerDataTex is passed in but not owned/deleted.
|
||||
@@ -19,7 +19,7 @@ import type { ParsedAtlas } from "./Types";
|
||||
|
||||
const statusAtlasUrl = assetUrl("atlases/status-atlas.png");
|
||||
|
||||
const MAX_STATUS_ICONS = 8;
|
||||
const MAX_STATUS_ICONS = 9;
|
||||
|
||||
export class StatusIconProgram {
|
||||
private gl: WebGL2RenderingContext;
|
||||
|
||||
@@ -75,6 +75,8 @@ export interface PlayerSlot {
|
||||
embargo: boolean;
|
||||
nukeActive: boolean;
|
||||
nukeTargetsMe: boolean;
|
||||
inDoomsdayClock: boolean;
|
||||
doomsdayClockDraining: boolean;
|
||||
traitorRemainingTicks: number;
|
||||
allianceFraction: number;
|
||||
allianceRemainingTicks: number;
|
||||
|
||||
@@ -342,6 +342,8 @@ export class NamePass {
|
||||
embargo: false,
|
||||
nukeActive: false,
|
||||
nukeTargetsMe: false,
|
||||
inDoomsdayClock: false,
|
||||
doomsdayClockDraining: false,
|
||||
traitorRemainingTicks: 0,
|
||||
allianceFraction: 0,
|
||||
allianceRemainingTicks: 0,
|
||||
@@ -453,6 +455,8 @@ export class NamePass {
|
||||
const crown = sd?.crown ?? false;
|
||||
const traitor = sd?.traitor ?? false;
|
||||
const disconnected = sd?.disconnected ?? false;
|
||||
const inDoomsdayClock = sd?.inDoomsdayClock ?? false;
|
||||
const doomsdayClockDraining = sd?.doomsdayClockDraining ?? false;
|
||||
const alliance = sd?.alliance ?? false;
|
||||
const allianceReq = sd?.allianceReq ?? false;
|
||||
const target = sd?.target ?? false;
|
||||
@@ -467,6 +471,8 @@ export class NamePass {
|
||||
crown !== slot.crown ||
|
||||
traitor !== slot.traitor ||
|
||||
disconnected !== slot.disconnected ||
|
||||
inDoomsdayClock !== slot.inDoomsdayClock ||
|
||||
doomsdayClockDraining !== slot.doomsdayClockDraining ||
|
||||
alliance !== slot.alliance ||
|
||||
allianceReq !== slot.allianceReq ||
|
||||
target !== slot.target ||
|
||||
@@ -480,6 +486,8 @@ export class NamePass {
|
||||
slot.crown = crown;
|
||||
slot.traitor = traitor;
|
||||
slot.disconnected = disconnected;
|
||||
slot.inDoomsdayClock = inDoomsdayClock;
|
||||
slot.doomsdayClockDraining = doomsdayClockDraining;
|
||||
slot.alliance = alliance;
|
||||
slot.allianceReq = allianceReq;
|
||||
slot.target = target;
|
||||
@@ -565,11 +573,16 @@ export class NamePass {
|
||||
d[off + 14] = nameShade;
|
||||
d[off + 15] = slot.nameHalfWidth;
|
||||
|
||||
// Column 4: flagLayerIdx, emojiAtlasIdx, [free], [free]
|
||||
// Column 4: flagLayerIdx, emojiAtlasIdx, smallID, doomsdayClock state
|
||||
// (0 none, 1 danger -> blinking skull, 2 draining -> steady skull).
|
||||
d[off + 16] = slot.flagLayerIdx;
|
||||
d[off + 17] = slot.emojiAtlasIdx;
|
||||
d[off + 18] = slot.static.smallID;
|
||||
d[off + 19] = 0;
|
||||
d[off + 19] = slot.doomsdayClockDraining
|
||||
? 2.0
|
||||
: slot.inDoomsdayClock
|
||||
? 1.0
|
||||
: 0.0;
|
||||
|
||||
// Column 5: crown, traitor, disconnected, alliance
|
||||
d[off + 20] = slot.crown ? 1.0 : 0.0;
|
||||
@@ -644,7 +657,8 @@ export class NamePass {
|
||||
slot.allianceReq ||
|
||||
slot.target ||
|
||||
slot.embargo ||
|
||||
slot.nukeActive;
|
||||
slot.nukeActive ||
|
||||
slot.inDoomsdayClock;
|
||||
if (slot.emojiAtlasIdx >= 0) {
|
||||
top = wy - lineH * ns.emojiRowOffset;
|
||||
} else if (hasStatus) {
|
||||
|
||||
@@ -45,11 +45,12 @@ out float vHoverAlpha;
|
||||
|
||||
// Status flag float array — indexed by icon slot.
|
||||
// Slot mapping: 0=crown, 1=traitor, 2=disconnected, 3=alliance,
|
||||
// 4=allianceReq, 5=target, 6=embargo, 7=nukeActive
|
||||
float statusFlag[8];
|
||||
// 4=allianceReq, 5=target, 6=embargo, 7=nukeActive, 8=doomsdayClock
|
||||
float statusFlag[9];
|
||||
|
||||
// Read status flags from pd5/pd6 into the statusFlag array.
|
||||
// Read status flags from pd4.w/pd5/pd6 into the statusFlag array.
|
||||
void readStatusFlags(int playerIdx) {
|
||||
vec4 pd4 = texelFetch(uPlayerData, ivec2(4, playerIdx), 0);
|
||||
vec4 pd5 = texelFetch(uPlayerData, ivec2(5, playerIdx), 0);
|
||||
vec4 pd6 = texelFetch(uPlayerData, ivec2(6, playerIdx), 0);
|
||||
statusFlag[0] = pd5.x; // crown
|
||||
@@ -60,6 +61,7 @@ void readStatusFlags(int playerIdx) {
|
||||
statusFlag[5] = pd6.y; // target
|
||||
statusFlag[6] = pd6.z; // embargo
|
||||
statusFlag[7] = pd6.w; // nukeActive
|
||||
statusFlag[8] = pd4.w; // doomsdayClock
|
||||
}
|
||||
|
||||
// Count active icons with index < pos.
|
||||
@@ -85,9 +87,9 @@ vec4 cellUV(int idx) {
|
||||
}
|
||||
|
||||
void main() {
|
||||
// Decode instance ID → playerIdx + iconSlot (0..7)
|
||||
int playerIdx = gl_InstanceID / 8;
|
||||
int iconSlot = gl_InstanceID - playerIdx * 8;
|
||||
// Decode instance ID → playerIdx + iconSlot (0..8)
|
||||
int playerIdx = gl_InstanceID / 9;
|
||||
int iconSlot = gl_InstanceID - playerIdx * 9;
|
||||
|
||||
// Read player data
|
||||
vec4 pd0 = texelFetch(uPlayerData, ivec2(0, playerIdx), 0); // srcX, srcY, srcScale, startTime
|
||||
@@ -163,7 +165,7 @@ void main() {
|
||||
|
||||
// Count active icons and position of this one (left-to-right)
|
||||
int totalActive = 0;
|
||||
for (int i = 0; i < 8; i++) {
|
||||
for (int i = 0; i < 9; i++) {
|
||||
if (statusFlag[i] > 0.5) totalActive++;
|
||||
}
|
||||
int myIndex = countBelow(iconSlot);
|
||||
@@ -184,6 +186,9 @@ void main() {
|
||||
if (iconSlot == 7) {
|
||||
atlasIdx = (pd7.x > 0.5) ? 7 : 8;
|
||||
}
|
||||
if (iconSlot == 8) {
|
||||
atlasIdx = 10; // doomsday-clock skull
|
||||
}
|
||||
|
||||
// Only the alliance icon (slot 3) gets the dark outline.
|
||||
vOutline = (iconSlot == 3) ? 1.0 : 0.0;
|
||||
@@ -263,5 +268,13 @@ void main() {
|
||||
}
|
||||
}
|
||||
|
||||
// Doomsday Clock skull: slot 8. Blinks ~2 Hz while in danger (flag 1.0); holds
|
||||
// steady once the side is draining (flag 2.0).
|
||||
if (iconSlot == 8) {
|
||||
vFlashAlpha = (statusFlag[8] < 1.5)
|
||||
? 0.35 + 0.65 * (0.5 + 0.5 * cos(uTime * 2.0 * 6.2832))
|
||||
: 1.0;
|
||||
}
|
||||
|
||||
vDiscard = 0;
|
||||
}
|
||||
|
||||
@@ -62,6 +62,8 @@ export interface PlayerState {
|
||||
troops: number;
|
||||
isTraitor: boolean;
|
||||
traitorRemainingTicks: number;
|
||||
inDoomsdayClock: boolean;
|
||||
markedDoomsdayClockTick: number;
|
||||
betrayals: number;
|
||||
hasSpawned: boolean;
|
||||
/** TileRef the player picked as their spawn (undefined if not yet spawned). */
|
||||
@@ -194,6 +196,8 @@ export interface PlayerStatusData {
|
||||
embargo: boolean;
|
||||
nukeActive: boolean;
|
||||
nukeTargetsMe: boolean;
|
||||
inDoomsdayClock: boolean;
|
||||
doomsdayClockDraining: boolean;
|
||||
traitorRemainingTicks: number;
|
||||
allianceFraction: number;
|
||||
allianceRemainingTicks: number;
|
||||
|
||||
@@ -559,6 +559,8 @@ export class GameView implements GameMap {
|
||||
allianceDuration: this._config.allianceDuration(),
|
||||
isTransitiveTarget: (sid) =>
|
||||
this._myPlayer?.hasTransitiveTarget(sid) ?? false,
|
||||
doomsdayClockWarnTicks:
|
||||
this._config.doomsdayClockConfig().warnSeconds * 10,
|
||||
});
|
||||
// Relations + clusters depend only on allies/embargoes/teams, which
|
||||
// change rarely (teams only when a player is added) — recompute only
|
||||
|
||||
@@ -81,6 +81,8 @@ function stateFromUpdate(pu: PlayerUpdate): PlayerState {
|
||||
troops: pu.troops!,
|
||||
isTraitor: pu.isTraitor!,
|
||||
traitorRemainingTicks: Math.max(0, pu.traitorRemainingTicks ?? 0),
|
||||
inDoomsdayClock: pu.inDoomsdayClock ?? false,
|
||||
markedDoomsdayClockTick: pu.markedDoomsdayClockTick ?? -1,
|
||||
betrayals: pu.betrayals!,
|
||||
hasSpawned: pu.hasSpawned!,
|
||||
spawnTile: pu.spawnTile,
|
||||
@@ -590,6 +592,14 @@ export class PlayerView {
|
||||
getTraitorRemainingTicks(): number {
|
||||
return this.state.traitorRemainingTicks;
|
||||
}
|
||||
inDoomsdayClock(): boolean {
|
||||
return this.state.inDoomsdayClock;
|
||||
}
|
||||
doomsdayClockTicks(): number {
|
||||
return this.inDoomsdayClock()
|
||||
? this.game.ticks() - this.state.markedDoomsdayClockTick
|
||||
: 0;
|
||||
}
|
||||
betrayals(): number {
|
||||
return this.state.betrayals;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { placeName, placeSpawnName } from "../client/hud/NameBoxCalculator";
|
||||
import { Config } from "./configuration/Config";
|
||||
import { DoomsdayClockExecution } from "./execution/DoomsdayClockExecution";
|
||||
import { Executor } from "./execution/ExecutionManager";
|
||||
import { RecomputeRailClusterExecution } from "./execution/RecomputeRailClusterExecution";
|
||||
import { SpawnTimerExecution } from "./execution/SpawnTimerExecution";
|
||||
@@ -112,6 +113,9 @@ export class GameRunner {
|
||||
);
|
||||
}
|
||||
this.game.addExecution(new WinCheckExecution());
|
||||
if (this.game.config().doomsdayClockConfig().enabled) {
|
||||
this.game.addExecution(new DoomsdayClockExecution());
|
||||
}
|
||||
if (!this.game.config().isUnitDisabled(UnitType.Factory)) {
|
||||
this.game.addExecution(
|
||||
new RecomputeRailClusterExecution(this.game.railNetwork()),
|
||||
|
||||
@@ -249,6 +249,16 @@ const TeamCountConfigSchema = z.union([
|
||||
]);
|
||||
export type TeamCountConfig = z.infer<typeof TeamCountConfigSchema>;
|
||||
|
||||
// Doomsday Clock (anti-stall). Below a rising share of the map a player (or, in
|
||||
// team modes, their whole team) gets skulled and their troops drain to zero. The
|
||||
// required share rises in discrete waves per the `speed` preset (see
|
||||
// DoomsdayClock.ts). Only `enabled` and `speed` are wire-configurable; the
|
||||
// drain/warn tuning lives in DOOMSDAY_CLOCK_DEFAULTS (Config.ts).
|
||||
export const DoomsdayClockConfigSchema = z.object({
|
||||
enabled: z.boolean().optional(),
|
||||
speed: z.enum(["slow", "normal", "fast", "veryfast"]).optional(),
|
||||
});
|
||||
|
||||
export const GameConfigSchema = z.object({
|
||||
gameMap: z.enum(GameMapType),
|
||||
difficulty: z.enum(Difficulty),
|
||||
@@ -258,6 +268,7 @@ export const GameConfigSchema = z.object({
|
||||
gameMode: z.enum(GameMode),
|
||||
rankedType: z.enum(RankedType).optional(), // Only set for ranked games.
|
||||
gameMapSize: z.enum(GameMapSize),
|
||||
doomsdayClock: DoomsdayClockConfigSchema.optional(),
|
||||
publicGameModifiers: z
|
||||
.object({
|
||||
isCompact: z.boolean().optional(),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { z } from "zod";
|
||||
import { PlayerView } from "../../client/view";
|
||||
import { AssetManifest } from "../AssetUrls";
|
||||
import { DoomsdayClockSpeed } from "../game/DoomsdayClock";
|
||||
import {
|
||||
Difficulty,
|
||||
Game,
|
||||
@@ -80,6 +81,21 @@ export const JwksSchema = z.object({
|
||||
/** SAM launcher construction duration in ticks (non-instant-build). */
|
||||
export const SAM_CONSTRUCTION_TICKS = 30 * 10;
|
||||
|
||||
// Doomsday Clock tunables (anti-stall). Off unless enabled in GameConfig.
|
||||
// Times in seconds. The required map share rises in waves (levels + times in
|
||||
// DoomsdayClock.ts, chosen by `speed`). A side caught below the bar gets a
|
||||
// warnSeconds cooldown ("Danger, decay in Xs"), then troops bleed to zero: the
|
||||
// warn (10s) + the linear drain (~55s from full troops, sooner with fewer troops
|
||||
// or a shrinking territory) make ~1 minute from caught to wiped out.
|
||||
const DOOMSDAY_CLOCK_DEFAULTS = {
|
||||
enabled: false,
|
||||
speed: "normal" as DoomsdayClockSpeed,
|
||||
warnSeconds: 10, // cooldown before decay after the bar catches you
|
||||
drainStartPercent: 2, // starts bleeding at once (already beats troop income)
|
||||
drainMaxPercent: 6,
|
||||
drainRampSeconds: 50, // ramps LINEARLY to the max over this long
|
||||
};
|
||||
|
||||
export class Config {
|
||||
private unitInfoCache = new Map<UnitType, UnitInfo>();
|
||||
constructor(
|
||||
@@ -101,6 +117,21 @@ export class Config {
|
||||
traitorDuration(): number {
|
||||
return 30 * 10; // 30 seconds
|
||||
}
|
||||
|
||||
// Doomsday Clock config, resolved against defaults. One read per tick.
|
||||
doomsdayClockConfig(): typeof DOOMSDAY_CLOCK_DEFAULTS {
|
||||
const c = this._gameConfig.doomsdayClock;
|
||||
const d = DOOMSDAY_CLOCK_DEFAULTS;
|
||||
return {
|
||||
enabled: c?.enabled ?? d.enabled,
|
||||
speed: c?.speed ?? d.speed,
|
||||
// Drain/warn tuning is internal (not wire-configurable): always defaults.
|
||||
warnSeconds: d.warnSeconds,
|
||||
drainStartPercent: d.drainStartPercent,
|
||||
drainMaxPercent: d.drainMaxPercent,
|
||||
drainRampSeconds: d.drainRampSeconds,
|
||||
};
|
||||
}
|
||||
spawnImmunityDuration(): Tick {
|
||||
return (
|
||||
this._gameConfig.spawnImmunityDuration ?? DEFAULT_SPAWN_IMMUNITY_TICKS
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import {
|
||||
doomsdayClockDrain,
|
||||
doomsdayClockSideRequiredTiles,
|
||||
} from "../game/DoomsdayClock";
|
||||
import {
|
||||
Execution,
|
||||
Game,
|
||||
GameMode,
|
||||
Player,
|
||||
PlayerType,
|
||||
Team,
|
||||
} 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 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 until the side recovers or hits zero. Climbing back above the bar
|
||||
* clears the mark and stops the drain.
|
||||
*
|
||||
* 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;
|
||||
|
||||
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();
|
||||
|
||||
// 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 bled to zero, 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];
|
||||
// Threshold scales with the side's headcount: a team of N must hold N× a
|
||||
// solo player's share (FFA sides are size 1, unscaled).
|
||||
const required = doomsdayClockSideRequiredTiles(
|
||||
cfg.speed,
|
||||
land,
|
||||
elapsed,
|
||||
members.length,
|
||||
);
|
||||
// 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 chunk = doomsdayClockDrain(
|
||||
mg.config().maxTroops(m),
|
||||
secondsUnder - cfg.warnSeconds,
|
||||
cfg,
|
||||
);
|
||||
m.removeTroops(chunk); // caps at current troops
|
||||
}
|
||||
}
|
||||
} 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<Team, Player[]>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* Doomsday Clock threshold math, shared by the authoritative sim
|
||||
* (DoomsdayClockExecution) and the client HUD readout so the two always agree.
|
||||
*
|
||||
* The required share of the map rises in WAVES (a battle-royale zone): one flat
|
||||
* grace at the very start, then each wave grows the share up LINEARLY over
|
||||
* rampSeconds to its level, followed by a flat pauseSeconds hold before the next
|
||||
* wave. So the bar climbs smoothly and briefly rests, it never jumps. Levels
|
||||
* track the ofstats FFA territory median and are the same for every preset; the
|
||||
* presets only change the pace (slower or faster). A side below the bar gets a
|
||||
* warn countdown, then bleeds troops. Integer-only and floored, deterministic.
|
||||
*/
|
||||
|
||||
export type DoomsdayClockSpeed = "slow" | "normal" | "fast" | "veryfast";
|
||||
|
||||
/** In selector order. */
|
||||
export const DOOMSDAY_CLOCK_SPEEDS: DoomsdayClockSpeed[] = [
|
||||
"slow",
|
||||
"normal",
|
||||
"fast",
|
||||
"veryfast",
|
||||
];
|
||||
|
||||
interface WaveSchedule {
|
||||
/** Flat 0% for this long at the very start (the one grace period). */
|
||||
graceSeconds: number;
|
||||
/** Each wave grows its share up linearly over this long. */
|
||||
rampSeconds: number;
|
||||
/** Flat hold after each ramp before the next one starts. */
|
||||
pauseSeconds: number;
|
||||
/** Share (basis points, 100 = 1%) reached at the end of each ramp, ascending. */
|
||||
levels: number[];
|
||||
}
|
||||
|
||||
// Grace once, then a repeating cycle of [ramp up over rampSeconds] + [hold for
|
||||
// pauseSeconds]. The share rises linearly during each ramp and is flat during
|
||||
// the grace and every pause. Easy to tune: change grace, ramp, pause, or levels.
|
||||
// Same levels everywhere (the ofstats FFA territory median, then a final 55%
|
||||
// squeeze); the presets only change the pace. The median run is 3/5/10/20/30%;
|
||||
// normal hits it dead on at 10/15/20/25/30 min. The 6th wave (55%) only one side
|
||||
// can hold, so, together with the crown exemption, it forces out everyone but
|
||||
// the leader for a single winner. slow is ~20% slower, fast ~30% faster, very
|
||||
// fast 50% faster.
|
||||
const LEVELS = [300, 500, 1000, 2000, 3000, 5500]; // 3, 5, 10, 20, 30, 55%
|
||||
const SCHEDULES: Record<DoomsdayClockSpeed, WaveSchedule> = {
|
||||
// grace 5:30, 4:30 ramps + 30s pauses -> 3/5/10/20/30/55% at 10/15/20/25/30/35 min.
|
||||
normal: {
|
||||
graceSeconds: 330,
|
||||
rampSeconds: 270,
|
||||
pauseSeconds: 30,
|
||||
levels: LEVELS,
|
||||
},
|
||||
// grace 6:30, 5:30 ramps -> reaches at 12/18/24/30/36/42 min.
|
||||
slow: {
|
||||
graceSeconds: 390,
|
||||
rampSeconds: 330,
|
||||
pauseSeconds: 30,
|
||||
levels: LEVELS,
|
||||
},
|
||||
// grace 4:30, 2:50 ramps -> reaches at 7:20/10:40/14/17:20/20:40/24 min.
|
||||
fast: {
|
||||
graceSeconds: 270,
|
||||
rampSeconds: 170,
|
||||
pauseSeconds: 30,
|
||||
levels: LEVELS,
|
||||
},
|
||||
// grace 3:00, 2:00 ramps -> reaches at 5/7:30/10/12:30/15/17:30 min.
|
||||
veryfast: {
|
||||
graceSeconds: 180,
|
||||
rampSeconds: 120,
|
||||
pauseSeconds: 30,
|
||||
levels: LEVELS,
|
||||
},
|
||||
};
|
||||
|
||||
function schedule(speed: DoomsdayClockSpeed): WaveSchedule {
|
||||
return SCHEDULES[speed] ?? SCHEDULES.normal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Required share of the map (basis points) at `elapsed` game seconds: 0 through
|
||||
* the grace, then a linear ramp to each successive level with a flat pause after
|
||||
* each. Integer-only (floored) so every client agrees.
|
||||
*/
|
||||
function requiredBasisPoints(
|
||||
speed: DoomsdayClockSpeed,
|
||||
elapsed: number,
|
||||
): number {
|
||||
const s = schedule(speed);
|
||||
if (elapsed <= s.graceSeconds) return 0;
|
||||
const cycle = s.rampSeconds + s.pauseSeconds;
|
||||
const t = elapsed - s.graceSeconds;
|
||||
const i = Math.floor(t / cycle);
|
||||
if (i >= s.levels.length) return s.levels[s.levels.length - 1];
|
||||
const into = t - i * cycle;
|
||||
const prev = i === 0 ? 0 : s.levels[i - 1];
|
||||
const target = s.levels[i];
|
||||
if (into >= s.rampSeconds) return target; // in the pause: hold
|
||||
return prev + Math.floor(((target - prev) * into) / s.rampSeconds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Base minimum tiles one player must own at `elapsed` game seconds. One floored
|
||||
* integer ratio, so every client agrees.
|
||||
*/
|
||||
export function doomsdayClockRequiredTiles(
|
||||
speed: DoomsdayClockSpeed,
|
||||
land: number,
|
||||
elapsed: number,
|
||||
): number {
|
||||
if (land <= 0) return 0;
|
||||
return Math.floor((requiredBasisPoints(speed, elapsed) * land) / 10000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Threshold a whole side must hold: the base per-player share scaled by the
|
||||
* side's headcount, so a team of N must hold N× what a solo player holds (FFA
|
||||
* sides are size 1, i.e. unscaled). Capped at the whole map. Shared by the sim
|
||||
* and the HUD so the two always agree.
|
||||
*/
|
||||
export function doomsdayClockSideRequiredTiles(
|
||||
speed: DoomsdayClockSpeed,
|
||||
land: number,
|
||||
elapsed: number,
|
||||
sideSize: number,
|
||||
): number {
|
||||
const base = doomsdayClockRequiredTiles(speed, land, elapsed);
|
||||
return Math.min(land, base * Math.max(1, sideSize));
|
||||
}
|
||||
|
||||
export interface DoomsdayClockWaveState {
|
||||
/** Required share right now, as a percent of the map (ramps during a wave). */
|
||||
currentPercent: number;
|
||||
/** The share the current (or next) ramp climbs to. */
|
||||
targetPercent: number;
|
||||
/** True while the share is actively ramping up. */
|
||||
growing: boolean;
|
||||
/** Seconds until the next ramp begins (0 while growing or once done). */
|
||||
secondsToNextGrowth: number;
|
||||
/** Within 5s before or after a ramp starting (the orange cue window). */
|
||||
waveFlash: boolean;
|
||||
/** True once the final level has been reached. */
|
||||
done: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Display-only companion for the HUD: the live share, whether it is ramping or
|
||||
* holding, and the cue window. Lives here so the schedule is defined once.
|
||||
*/
|
||||
export function doomsdayClockWaveState(
|
||||
speed: DoomsdayClockSpeed,
|
||||
elapsed: number,
|
||||
): DoomsdayClockWaveState {
|
||||
const s = schedule(speed);
|
||||
const currentPercent = requiredBasisPoints(speed, elapsed) / 100;
|
||||
const cycle = s.rampSeconds + s.pauseSeconds;
|
||||
const n = s.levels.length;
|
||||
const last = s.levels[n - 1] / 100;
|
||||
|
||||
// Grace: flat 0; the first ramp starts at graceSeconds.
|
||||
if (elapsed <= s.graceSeconds) {
|
||||
return {
|
||||
currentPercent: 0,
|
||||
targetPercent: s.levels[0] / 100,
|
||||
growing: false,
|
||||
secondsToNextGrowth: s.graceSeconds - elapsed,
|
||||
waveFlash: s.graceSeconds - elapsed <= 5,
|
||||
done: false,
|
||||
};
|
||||
}
|
||||
|
||||
const t = elapsed - s.graceSeconds;
|
||||
const i = Math.floor(t / cycle);
|
||||
if (i >= n) {
|
||||
return {
|
||||
currentPercent,
|
||||
targetPercent: last,
|
||||
growing: false,
|
||||
secondsToNextGrowth: 0,
|
||||
waveFlash: false,
|
||||
done: true,
|
||||
};
|
||||
}
|
||||
|
||||
const into = t - i * cycle;
|
||||
const growing = into < s.rampSeconds;
|
||||
const isLast = i === n - 1;
|
||||
const nextRampStart = s.graceSeconds + (i + 1) * cycle;
|
||||
return {
|
||||
currentPercent,
|
||||
targetPercent: (growing || isLast ? s.levels[i] : s.levels[i + 1]) / 100,
|
||||
growing,
|
||||
secondsToNextGrowth: growing || isLast ? 0 : nextRampStart - elapsed,
|
||||
// 5s into a ramp (just started) or 5s before the next ramp begins.
|
||||
waveFlash: into <= 5 || (!isLast && nextRampStart - elapsed <= 5),
|
||||
done: isLast && !growing,
|
||||
};
|
||||
}
|
||||
|
||||
export interface DoomsdayClockDrainConfig {
|
||||
drainStartPercent: number;
|
||||
drainMaxPercent: number;
|
||||
drainRampSeconds: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Troops a skulled side loses this second: a LINEAR ramp from drainStartPercent
|
||||
* up to drainMaxPercent over drainRampSeconds. It is a percentage of the side's
|
||||
* MAX troop capacity (not current), so it outpaces troop income from the first
|
||||
* second and accelerates as it grows, driving the side to zero in ~55s from full
|
||||
* troops (sooner with fewer troops or a shrinking territory). The caller caps it
|
||||
* at the side's current troops (removeTroops does, and the HUD shows
|
||||
* min(current, this)). Shared by the sim and the HUD.
|
||||
*/
|
||||
export function doomsdayClockDrain(
|
||||
maxTroops: number,
|
||||
secondsPastWarn: number,
|
||||
cfg: DoomsdayClockDrainConfig,
|
||||
): number {
|
||||
const t = Math.max(0, secondsPastWarn);
|
||||
const r = cfg.drainRampSeconds;
|
||||
const span = cfg.drainMaxPercent - cfg.drainStartPercent;
|
||||
const pct =
|
||||
r <= 0 || t >= r
|
||||
? cfg.drainMaxPercent
|
||||
: cfg.drainStartPercent + Math.floor((span * t) / r);
|
||||
return Math.max(1, Math.floor((maxTroops * pct) / 100));
|
||||
}
|
||||
@@ -552,6 +552,11 @@ export interface Player {
|
||||
isAlive(): boolean;
|
||||
isTraitor(): boolean;
|
||||
markTraitor(): void;
|
||||
// Doomsday Clock (anti-stall): marked when below the rising territory bar.
|
||||
inDoomsdayClock(): boolean;
|
||||
doomsdayClockTicks(): number;
|
||||
enterDoomsdayClock(): void;
|
||||
clearDoomsdayClock(): void;
|
||||
largestClusterBoundingBox: { min: Cell; max: Cell } | null;
|
||||
lastTileChange(): Tick;
|
||||
|
||||
|
||||
@@ -58,6 +58,14 @@ export function diffPlayerUpdate(
|
||||
"traitorRemainingTicks",
|
||||
prev.traitorRemainingTicks === next.traitorRemainingTicks,
|
||||
);
|
||||
setIfDifferent(
|
||||
"inDoomsdayClock",
|
||||
prev.inDoomsdayClock === next.inDoomsdayClock,
|
||||
);
|
||||
setIfDifferent(
|
||||
"markedDoomsdayClockTick",
|
||||
prev.markedDoomsdayClockTick === next.markedDoomsdayClockTick,
|
||||
);
|
||||
setIfDifferent("hasSpawned", prev.hasSpawned === next.hasSpawned);
|
||||
setIfDifferent("spawnTile", prev.spawnTile === next.spawnTile);
|
||||
setIfDifferent("betrayals", prev.betrayals === next.betrayals);
|
||||
@@ -119,6 +127,11 @@ export function applyStateUpdate(target: PlayerState, pu: PlayerUpdate): void {
|
||||
if (pu.traitorRemainingTicks !== undefined) {
|
||||
target.traitorRemainingTicks = Math.max(0, pu.traitorRemainingTicks);
|
||||
}
|
||||
if (pu.inDoomsdayClock !== undefined)
|
||||
target.inDoomsdayClock = pu.inDoomsdayClock;
|
||||
if (pu.markedDoomsdayClockTick !== undefined) {
|
||||
target.markedDoomsdayClockTick = pu.markedDoomsdayClockTick;
|
||||
}
|
||||
if (pu.betrayals !== undefined) target.betrayals = pu.betrayals;
|
||||
if (pu.hasSpawned !== undefined) target.hasSpawned = pu.hasSpawned;
|
||||
if (pu.spawnTile !== undefined) target.spawnTile = pu.spawnTile;
|
||||
|
||||
@@ -233,6 +233,8 @@ export interface PlayerUpdate {
|
||||
embargoes?: Set<PlayerID>;
|
||||
isTraitor?: boolean;
|
||||
traitorRemainingTicks?: number;
|
||||
inDoomsdayClock?: boolean;
|
||||
markedDoomsdayClockTick?: number;
|
||||
targets?: number[];
|
||||
outgoingEmojis?: EmojiMessage[];
|
||||
outgoingAttacks?: AttackUpdate[];
|
||||
|
||||
@@ -98,6 +98,7 @@ export class PlayerImpl implements Player {
|
||||
private _troops: bigint;
|
||||
|
||||
markedTraitorTick = -1;
|
||||
markedDoomsdayClockTick = -1;
|
||||
private _betrayalCount: number = 0;
|
||||
|
||||
private embargoes = new Map<PlayerID, Embargo>();
|
||||
@@ -315,6 +316,8 @@ export class PlayerImpl implements Player {
|
||||
embargoes: embargoes,
|
||||
isTraitor: this.isTraitor(),
|
||||
traitorRemainingTicks: this.getTraitorRemainingTicks(),
|
||||
inDoomsdayClock: this.inDoomsdayClock(),
|
||||
markedDoomsdayClockTick: this.markedDoomsdayClockTick,
|
||||
targets: targets,
|
||||
outgoingEmojis: outgoingEmojis,
|
||||
outgoingAttacks: outgoingAttacks,
|
||||
@@ -741,6 +744,30 @@ export class PlayerImpl implements Player {
|
||||
this.mg.stats().betray(this);
|
||||
}
|
||||
|
||||
// A dead player is never "in doomsday clock": nothing clears the mark on death
|
||||
// (the execution only processes alive contenders), so gate on isAlive() to
|
||||
// avoid a stuck skull/panel and per-tick update churn for eliminated players.
|
||||
inDoomsdayClock(): boolean {
|
||||
return this.isAlive() && this.markedDoomsdayClockTick >= 0;
|
||||
}
|
||||
|
||||
// Ticks spent continuously below the doomsday-clock bar (0 when not marked or dead).
|
||||
doomsdayClockTicks(): number {
|
||||
return this.inDoomsdayClock()
|
||||
? this.mg.ticks() - this.markedDoomsdayClockTick
|
||||
: 0;
|
||||
}
|
||||
|
||||
enterDoomsdayClock(): void {
|
||||
if (this.markedDoomsdayClockTick < 0) {
|
||||
this.markedDoomsdayClockTick = this.mg.ticks();
|
||||
}
|
||||
}
|
||||
|
||||
clearDoomsdayClock(): void {
|
||||
this.markedDoomsdayClockTick = -1;
|
||||
}
|
||||
|
||||
betrayals(): number {
|
||||
return this._betrayalCount;
|
||||
}
|
||||
|
||||
@@ -252,6 +252,9 @@ export class GameServer {
|
||||
if (gameConfig.waterNukes !== undefined) {
|
||||
this.gameConfig.waterNukes = gameConfig.waterNukes ?? undefined;
|
||||
}
|
||||
if (gameConfig.doomsdayClock !== undefined) {
|
||||
this.gameConfig.doomsdayClock = gameConfig.doomsdayClock;
|
||||
}
|
||||
if (gameConfig.anonymizeNames !== undefined) {
|
||||
this.gameConfig.anonymizeNames = gameConfig.anonymizeNames;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,617 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -23,6 +23,8 @@ function makePlayerState(overrides: Partial<PlayerState> = {}): PlayerState {
|
||||
troops: 100,
|
||||
isTraitor: false,
|
||||
traitorRemainingTicks: 0,
|
||||
inDoomsdayClock: false,
|
||||
markedDoomsdayClockTick: -1,
|
||||
betrayals: 0,
|
||||
hasSpawned: true,
|
||||
lastDeleteUnitTick: 0,
|
||||
|
||||
@@ -36,6 +36,8 @@ function ps(overrides: Partial<PlayerState> = {}): PlayerState {
|
||||
troops: 0,
|
||||
isTraitor: false,
|
||||
traitorRemainingTicks: 0,
|
||||
inDoomsdayClock: false,
|
||||
markedDoomsdayClockTick: -1,
|
||||
betrayals: 0,
|
||||
hasSpawned: true,
|
||||
lastDeleteUnitTick: 0,
|
||||
|
||||
@@ -32,6 +32,8 @@ function ps(overrides: Partial<PlayerState> = {}): PlayerState {
|
||||
troops: 0,
|
||||
isTraitor: false,
|
||||
traitorRemainingTicks: 0,
|
||||
inDoomsdayClock: false,
|
||||
markedDoomsdayClockTick: -1,
|
||||
betrayals: 0,
|
||||
hasSpawned: true,
|
||||
lastDeleteUnitTick: 0,
|
||||
|
||||
@@ -58,6 +58,7 @@ export function stubConfig(overrides: Partial<Config> = {}): Config {
|
||||
disableAlliances: () => false,
|
||||
allianceDuration: () => 100,
|
||||
deletionMarkDuration: () => 300,
|
||||
doomsdayClockConfig: () => ({ warnSeconds: 15 }),
|
||||
nukeMagnitudes: () => ({ inner: 0, outer: 0 }),
|
||||
nukeAllianceBreakThreshold: () => 0,
|
||||
userSettings: () => ({}),
|
||||
|
||||
Reference in New Issue
Block a user