mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-21 07:06:37 +00:00
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._
179 lines
4.4 KiB
TypeScript
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);
|
|
});
|
|
});
|