Files
OpenFrontIO/tests/client/render/frame/derive/nuke-telegraphs.test.ts
T
Zixer1andGitHub 78ef7b56fd 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._
2026-07-02 18:42:03 -07:00

179 lines
4.4 KiB
TypeScript

/**
* extractNukeTelegraphs colors each telegraph by who launched the nuke:
*
* - relation 0 (self): the local player owns the nuke
* - relation 1 (friendly): an ally or teammate owns it (via relation matrix)
* - relation 2 (enemy): everyone else, and everything in replay / spectator
* mode (no local player)
*/
import { describe, expect, it } from "vitest";
import {
extractNukeTelegraphs,
TELEGRAPH_ENEMY,
TELEGRAPH_FRIENDLY,
TELEGRAPH_SELF,
} from "../../../../../src/client/render/frame/derive/NukeTelegraphs";
import { buildRelationMatrix } from "../../../../../src/client/render/frame/derive/RelationMatrix";
import type {
PlayerState,
UnitState,
} from "../../../../../src/client/render/types";
import {
UT_ATOM_BOMB,
UT_WARSHIP,
} from "../../../../../src/client/render/types";
const MAP_W = 100;
function ps(overrides: Partial<PlayerState> = {}): PlayerState {
return {
smallID: 1,
isAlive: true,
isDisconnected: false,
tilesOwned: 0,
gold: 0,
troops: 0,
isTraitor: false,
traitorRemainingTicks: 0,
inDoomsdayClock: false,
markedDoomsdayClockTick: -1,
betrayals: 0,
hasSpawned: true,
lastDeleteUnitTick: 0,
allies: [],
embargoes: [],
targets: [],
outgoingAttacks: [],
incomingAttacks: [],
outgoingAllianceRequests: [],
alliances: [],
outgoingEmojis: [],
...overrides,
};
}
function nuke(overrides: Partial<UnitState> = {}): UnitState {
return {
id: 1,
unitType: UT_ATOM_BOMB,
ownerID: 1,
lastOwnerID: null,
pos: 0,
lastPos: 0,
isActive: true,
reachedTarget: false,
retreating: false,
targetable: true,
markedForDeletion: false,
health: null,
underConstruction: false,
targetUnitId: null,
targetTile: 305,
troops: 0,
missileTimerQueue: [],
level: 1,
veterancy: 0,
hasTrainStation: false,
trainType: null,
loaded: null,
constructionStartTick: null,
...overrides,
};
}
function units(...us: UnitState[]): Map<number, UnitState> {
return new Map(us.map((u) => [u.id, u]));
}
describe("extractNukeTelegraphs", () => {
it("computes target x/y and blast radii", () => {
const [t] = extractNukeTelegraphs(units(nuke({ targetTile: 305 })), MAP_W);
expect(t).toMatchObject({ x: 5, y: 3, innerRadius: 12, outerRadius: 30 });
});
it("skips inactive nukes, nukes without a target, and non-nuke units", () => {
const result = extractNukeTelegraphs(
units(
nuke({ id: 1, isActive: false }),
nuke({ id: 2, targetTile: null }),
nuke({ id: 3, unitType: UT_WARSHIP }),
),
MAP_W,
);
expect(result).toHaveLength(0);
});
it("marks the local player's own nukes as self", () => {
const rel = buildRelationMatrix(new Map([[1, ps({ smallID: 1 })]]));
const [t] = extractNukeTelegraphs(
units(nuke({ ownerID: 1 })),
MAP_W,
1,
rel.matrix,
rel.size,
);
expect(t.relation).toBe(TELEGRAPH_SELF);
});
it("marks an ally's nuke as friendly", () => {
const rel = buildRelationMatrix(
new Map([
[1, ps({ smallID: 1, allies: [2] })],
[2, ps({ smallID: 2 })],
]),
);
const [t] = extractNukeTelegraphs(
units(nuke({ ownerID: 2 })),
MAP_W,
1,
rel.matrix,
rel.size,
);
expect(t.relation).toBe(TELEGRAPH_FRIENDLY);
});
it("marks a teammate's nuke as friendly", () => {
const rel = buildRelationMatrix(
new Map([
[1, ps({ smallID: 1 })],
[2, ps({ smallID: 2 })],
]),
new Map([
[1, "red"],
[2, "red"],
]),
);
const [t] = extractNukeTelegraphs(
units(nuke({ ownerID: 2 })),
MAP_W,
1,
rel.matrix,
rel.size,
);
expect(t.relation).toBe(TELEGRAPH_FRIENDLY);
});
it("marks everyone else's nukes as enemy", () => {
const rel = buildRelationMatrix(
new Map([
[1, ps({ smallID: 1 })],
[2, ps({ smallID: 2 })],
]),
);
const [t] = extractNukeTelegraphs(
units(nuke({ ownerID: 2 })),
MAP_W,
1,
rel.matrix,
rel.size,
);
expect(t.relation).toBe(TELEGRAPH_ENEMY);
});
it("marks everything as enemy without a local player (replay/spectator)", () => {
const [t] = extractNukeTelegraphs(units(nuke({ ownerID: 1 })), MAP_W);
expect(t.relation).toBe(TELEGRAPH_ENEMY);
});
});