mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-20 04:20:05 +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._
366 lines
11 KiB
TypeScript
366 lines
11 KiB
TypeScript
/**
|
|
* computePlayerStatus has two modes:
|
|
*
|
|
* - Replay mode (no localPlayerSmallID): only crown / traitor / disconnected /
|
|
* nukeActive flags are populated. All relative flags are false.
|
|
* - Live mode (localPlayerSmallID set): also fills alliance / target / embargo,
|
|
* and nukeTargetsMe if a tileState buffer is supplied.
|
|
*
|
|
* The function only emits an entry per player when at least one flag is true
|
|
* (the NamePass treats missing entries as "all flags off"). Tests assert
|
|
* both presence and absence of entries.
|
|
*/
|
|
|
|
import { describe, expect, it } from "vitest";
|
|
import { computePlayerStatus } from "../../../../../src/client/render/frame/derive/PlayerStatus";
|
|
import type {
|
|
PlayerState,
|
|
UnitState,
|
|
} from "../../../../../src/client/render/types";
|
|
import {
|
|
UT_ATOM_BOMB,
|
|
UT_WARSHIP,
|
|
} from "../../../../../src/client/render/types";
|
|
|
|
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 unit(overrides: Partial<UnitState> = {}): UnitState {
|
|
return {
|
|
id: 1,
|
|
unitType: UT_WARSHIP,
|
|
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: null,
|
|
troops: 0,
|
|
missileTimerQueue: [],
|
|
level: 1,
|
|
veterancy: 0,
|
|
hasTrainStation: false,
|
|
trainType: null,
|
|
loaded: null,
|
|
constructionStartTick: null,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function playersMap(...players: PlayerState[]): Map<number, PlayerState> {
|
|
return new Map(players.map((p) => [p.smallID, p]));
|
|
}
|
|
|
|
function unitsMap(...us: UnitState[]): Map<number, UnitState> {
|
|
return new Map(us.map((u) => [u.id, u]));
|
|
}
|
|
|
|
describe("computePlayerStatus — replay mode (no localPlayerSmallID)", () => {
|
|
it("returns empty map when no flags are set", () => {
|
|
const players = playersMap(ps({ smallID: 1 }));
|
|
const status = computePlayerStatus(players, unitsMap());
|
|
expect(status.size).toBe(0);
|
|
});
|
|
|
|
it("crown goes to the alive player with most tiles owned", () => {
|
|
const players = playersMap(
|
|
ps({ smallID: 1, tilesOwned: 100 }),
|
|
ps({ smallID: 2, tilesOwned: 500 }), // king
|
|
ps({ smallID: 3, tilesOwned: 250 }),
|
|
);
|
|
const status = computePlayerStatus(players, unitsMap());
|
|
expect(status.get(2)?.crown).toBe(true);
|
|
// Players 1 and 3 don't have crown and no other flags → no entry emitted.
|
|
expect(status.has(1)).toBe(false);
|
|
expect(status.has(3)).toBe(false);
|
|
});
|
|
|
|
it("dead players don't get the crown even if they had the most tiles", () => {
|
|
const players = playersMap(
|
|
ps({ smallID: 1, tilesOwned: 1000, isAlive: false }),
|
|
ps({ smallID: 2, tilesOwned: 100 }),
|
|
);
|
|
const status = computePlayerStatus(players, unitsMap());
|
|
expect(status.get(2)?.crown).toBe(true);
|
|
expect(status.has(1)).toBe(false);
|
|
});
|
|
|
|
it("traitor + traitorRemainingTicks flow through", () => {
|
|
const players = playersMap(
|
|
ps({ smallID: 1, isTraitor: true, traitorRemainingTicks: 42 }),
|
|
);
|
|
const status = computePlayerStatus(players, unitsMap());
|
|
expect(status.get(1)?.traitor).toBe(true);
|
|
expect(status.get(1)?.traitorRemainingTicks).toBe(42);
|
|
});
|
|
|
|
it("disconnected flag flows through", () => {
|
|
const players = playersMap(ps({ smallID: 1, isDisconnected: true }));
|
|
const status = computePlayerStatus(players, unitsMap());
|
|
expect(status.get(1)?.disconnected).toBe(true);
|
|
});
|
|
|
|
it("nukeActive: any in-flight nuke marks its owner", () => {
|
|
const players = playersMap(ps({ smallID: 1 }), ps({ smallID: 2 }));
|
|
const units = unitsMap(
|
|
unit({ id: 10, ownerID: 2, unitType: UT_ATOM_BOMB, isActive: true }),
|
|
);
|
|
const status = computePlayerStatus(players, units);
|
|
expect(status.get(2)?.nukeActive).toBe(true);
|
|
expect(status.has(1)).toBe(false);
|
|
});
|
|
|
|
it("inactive nukes don't trigger nukeActive", () => {
|
|
const players = playersMap(ps({ smallID: 1 }));
|
|
const units = unitsMap(
|
|
unit({ id: 10, ownerID: 1, unitType: UT_ATOM_BOMB, isActive: false }),
|
|
);
|
|
const status = computePlayerStatus(players, units);
|
|
expect(status.has(1)).toBe(false);
|
|
});
|
|
|
|
it("relative flags (alliance/target/embargo/nukeTargetsMe) are always false in replay mode", () => {
|
|
const players = playersMap(
|
|
ps({ smallID: 1, allies: [2], targets: [2], embargoes: [2] }),
|
|
ps({ smallID: 2, tilesOwned: 1 }), // crown so an entry exists
|
|
);
|
|
const status = computePlayerStatus(players, unitsMap());
|
|
expect(status.get(2)?.alliance).toBe(false);
|
|
expect(status.get(2)?.target).toBe(false);
|
|
expect(status.get(2)?.embargo).toBe(false);
|
|
expect(status.get(2)?.nukeTargetsMe).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("computePlayerStatus — live mode (localPlayerSmallID set)", () => {
|
|
it("alliance: local has them as ally → alliance true", () => {
|
|
const players = playersMap(
|
|
ps({ smallID: 1, allies: [2] }), // me
|
|
ps({ smallID: 2 }),
|
|
);
|
|
const status = computePlayerStatus(players, unitsMap(), {
|
|
localPlayerSmallID: 1,
|
|
});
|
|
expect(status.get(2)?.alliance).toBe(true);
|
|
});
|
|
|
|
it("target: local has them in targets → target true", () => {
|
|
const players = playersMap(
|
|
ps({ smallID: 1, targets: [2] }), // me
|
|
ps({ smallID: 2 }),
|
|
);
|
|
const status = computePlayerStatus(players, unitsMap(), {
|
|
localPlayerSmallID: 1,
|
|
});
|
|
expect(status.get(2)?.target).toBe(true);
|
|
});
|
|
|
|
it("embargo is bilateral: true if I embargo them OR they embargo me", () => {
|
|
// I embargo them.
|
|
let status = computePlayerStatus(
|
|
playersMap(ps({ smallID: 1, embargoes: [2] }), ps({ smallID: 2 })),
|
|
unitsMap(),
|
|
{ localPlayerSmallID: 1 },
|
|
);
|
|
expect(status.get(2)?.embargo).toBe(true);
|
|
|
|
// They embargo me.
|
|
status = computePlayerStatus(
|
|
playersMap(ps({ smallID: 1 }), ps({ smallID: 2, embargoes: [1] })),
|
|
unitsMap(),
|
|
{ localPlayerSmallID: 1 },
|
|
);
|
|
expect(status.get(2)?.embargo).toBe(true);
|
|
|
|
// Neither.
|
|
status = computePlayerStatus(
|
|
playersMap(ps({ smallID: 1 }), ps({ smallID: 2, tilesOwned: 1 })),
|
|
unitsMap(),
|
|
{ localPlayerSmallID: 1 },
|
|
);
|
|
// Player 2 only has crown — embargo should be false.
|
|
expect(status.get(2)?.embargo).toBe(false);
|
|
});
|
|
|
|
it("relative flags are NOT set for the local player itself (no self-relationships)", () => {
|
|
const players = playersMap(
|
|
ps({
|
|
smallID: 1,
|
|
tilesOwned: 100,
|
|
allies: [1],
|
|
targets: [1],
|
|
embargoes: [1],
|
|
}),
|
|
ps({ smallID: 2 }),
|
|
);
|
|
const status = computePlayerStatus(players, unitsMap(), {
|
|
localPlayerSmallID: 1,
|
|
});
|
|
// Player 1 (local) gets crown but no relative flags vs. self.
|
|
expect(status.get(1)?.crown).toBe(true);
|
|
expect(status.get(1)?.alliance).toBe(false);
|
|
expect(status.get(1)?.target).toBe(false);
|
|
expect(status.get(1)?.embargo).toBe(false);
|
|
});
|
|
|
|
it("nukeTargetsMe: requires tileState — without it, stays false", () => {
|
|
const players = playersMap(ps({ smallID: 1 }), ps({ smallID: 2 }));
|
|
const units = unitsMap(
|
|
unit({
|
|
id: 10,
|
|
ownerID: 2,
|
|
unitType: UT_ATOM_BOMB,
|
|
isActive: true,
|
|
targetTile: 5,
|
|
}),
|
|
);
|
|
const status = computePlayerStatus(players, units, {
|
|
localPlayerSmallID: 1,
|
|
});
|
|
expect(status.get(2)?.nukeActive).toBe(true);
|
|
expect(status.get(2)?.nukeTargetsMe).toBe(false);
|
|
});
|
|
|
|
it("nukeTargetsMe: true when nuke targets a tile owned by local player", () => {
|
|
const players = playersMap(ps({ smallID: 1 }), ps({ smallID: 2 }));
|
|
const units = unitsMap(
|
|
unit({
|
|
id: 10,
|
|
ownerID: 2,
|
|
unitType: UT_ATOM_BOMB,
|
|
isActive: true,
|
|
targetTile: 5,
|
|
}),
|
|
);
|
|
// tileState[5] low 12 bits = 1 (local player's smallID).
|
|
const tileState = new Uint16Array(16);
|
|
tileState[5] = 1;
|
|
|
|
const status = computePlayerStatus(players, units, {
|
|
localPlayerSmallID: 1,
|
|
tileState,
|
|
});
|
|
expect(status.get(2)?.nukeTargetsMe).toBe(true);
|
|
});
|
|
|
|
it("nukeTargetsMe: false when nuke targets a tile owned by someone else", () => {
|
|
const players = playersMap(
|
|
ps({ smallID: 1 }),
|
|
ps({ smallID: 2 }),
|
|
ps({ smallID: 3 }),
|
|
);
|
|
const units = unitsMap(
|
|
unit({
|
|
id: 10,
|
|
ownerID: 2,
|
|
unitType: UT_ATOM_BOMB,
|
|
isActive: true,
|
|
targetTile: 5,
|
|
}),
|
|
);
|
|
// tileState[5] = player 3, not me.
|
|
const tileState = new Uint16Array(16);
|
|
tileState[5] = 3;
|
|
|
|
const status = computePlayerStatus(players, units, {
|
|
localPlayerSmallID: 1,
|
|
tileState,
|
|
});
|
|
expect(status.get(2)?.nukeTargetsMe).toBe(false);
|
|
});
|
|
|
|
it("entry is emitted when only a relative flag is true (even with no base flags)", () => {
|
|
const players = playersMap(
|
|
ps({ smallID: 1, allies: [2] }), // me
|
|
ps({ smallID: 2 }), // no other flags
|
|
);
|
|
const status = computePlayerStatus(players, unitsMap(), {
|
|
localPlayerSmallID: 1,
|
|
});
|
|
// Without local-mode, player 2 wouldn't get an entry — alliance is the
|
|
// only reason it shows up here.
|
|
expect(status.get(2)).toBeDefined();
|
|
expect(status.get(2)?.alliance).toBe(true);
|
|
});
|
|
|
|
it("localPlayerSmallID = 0 (no local player) behaves like replay mode", () => {
|
|
const players = playersMap(
|
|
ps({ smallID: 1, allies: [2] }),
|
|
ps({ smallID: 2, tilesOwned: 1 }),
|
|
);
|
|
const status = computePlayerStatus(players, unitsMap(), {
|
|
localPlayerSmallID: 0,
|
|
});
|
|
expect(status.get(2)?.alliance).toBe(false);
|
|
});
|
|
|
|
it("allianceReq and allianceFraction are not computed (deferred)", () => {
|
|
const players = playersMap(
|
|
ps({ smallID: 1, allies: [2] }),
|
|
ps({ smallID: 2 }),
|
|
);
|
|
const status = computePlayerStatus(players, unitsMap(), {
|
|
localPlayerSmallID: 1,
|
|
});
|
|
expect(status.get(2)?.allianceReq).toBe(false);
|
|
expect(status.get(2)?.allianceFraction).toBe(0);
|
|
});
|
|
|
|
it("allianceFraction and allianceRemainingTicks come from the alliance expiry", () => {
|
|
const players = playersMap(
|
|
ps({ smallID: 1, allies: [2] }),
|
|
ps({
|
|
smallID: 2,
|
|
alliances: [
|
|
{
|
|
id: 1,
|
|
other: "me",
|
|
createdAt: 100,
|
|
expiresAt: 700,
|
|
hasExtensionRequest: false,
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
const status = computePlayerStatus(players, unitsMap(), {
|
|
localPlayerSmallID: 1,
|
|
localPlayerID: "me",
|
|
tick: 400,
|
|
allianceDuration: 600,
|
|
});
|
|
expect(status.get(2)?.allianceFraction).toBe(0.5);
|
|
expect(status.get(2)?.allianceRemainingTicks).toBe(300);
|
|
});
|
|
});
|