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 18:42:03 -07:00
committed by GitHub
parent ad760a0f3d
commit 78ef7b56fd
33 changed files with 1593 additions and 17 deletions
+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;
}