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:
Zixer1
2026-07-02 21:42:03 -04:00
committed by GitHub
parent ad760a0f3d
commit 78ef7b56fd
33 changed files with 1593 additions and 17 deletions
+28
View File
@@ -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
+35
View File
@@ -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
},
+245
View File
@@ -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>
`;
}
}
+63 -1
View File
@@ -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);
+13 -1
View File
@@ -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({
+12
View File
@@ -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;
+17 -3
View File
@@ -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;
}
+4
View File
@@ -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;
+2
View File
@@ -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
+10
View File
@@ -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;
}
+4
View File
@@ -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()),
+11
View File
@@ -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(),
+31
View File
@@ -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;
}
}
+228
View File
@@ -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));
}
+5
View File
@@ -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;
+13
View File
@@ -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;
+2
View File
@@ -233,6 +233,8 @@ export interface PlayerUpdate {
embargoes?: Set<PlayerID>;
isTraitor?: boolean;
traitorRemainingTicks?: number;
inDoomsdayClock?: boolean;
markedDoomsdayClockTick?: number;
targets?: number[];
outgoingEmojis?: EmojiMessage[];
outgoingAttacks?: AttackUpdate[];
+27
View File
@@ -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;
}
+3
View File
@@ -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;
}