mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-04 20:32:03 +00:00
78ef7b56fd
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._
216 lines
5.9 KiB
TypeScript
216 lines
5.9 KiB
TypeScript
/**
|
|
* Stub builders for GameView/PlayerView/UnitView unit tests.
|
|
*
|
|
* These tests don't go through the full game setup (which creates a worker
|
|
* and runs the simulation) — they exercise the view classes directly with
|
|
* minimal stubs for their dependencies.
|
|
*/
|
|
|
|
import { colord } from "colord";
|
|
import { Theme } from "../../src/client/theme/ThemeProvider";
|
|
import { GameView } from "../../src/client/view/GameView";
|
|
import { PlayerView } from "../../src/client/view/PlayerView";
|
|
import { Config } from "../../src/core/configuration/Config";
|
|
import {
|
|
NameViewData,
|
|
PlayerType,
|
|
Team,
|
|
UnitType,
|
|
} from "../../src/core/game/Game";
|
|
import { GameMapImpl } from "../../src/core/game/GameMap";
|
|
import {
|
|
GameUpdateType,
|
|
GameUpdateViewData,
|
|
PlayerUpdate,
|
|
UnitUpdate,
|
|
} from "../../src/core/game/GameUpdates";
|
|
import { TerrainMapData } from "../../src/core/game/TerrainMapLoader";
|
|
import { Player, PlayerCosmetics } from "../../src/core/Schemas";
|
|
import { WorkerClient } from "../../src/core/worker/WorkerClient";
|
|
|
|
/** Theme stub — returns deterministic colors so PlayerView's color math works. */
|
|
export function stubTheme(): Theme {
|
|
const white = colord("#ffffff");
|
|
const grey = colord("#808080");
|
|
const defended = { light: white, dark: grey };
|
|
return {
|
|
teamColor: () => white,
|
|
territoryColor: () => white,
|
|
structureColors: () => defended,
|
|
borderColor: () => grey,
|
|
defendedBorderColors: () => defended,
|
|
focusedBorderColor: () => grey,
|
|
spawnHighlightColor: () => white,
|
|
};
|
|
}
|
|
|
|
/** Minimum Config stub for view tests. Extend as test needs grow. */
|
|
export function stubConfig(overrides: Partial<Config> = {}): Config {
|
|
const theme = stubTheme();
|
|
const cfg = {
|
|
theme: () => theme,
|
|
SAMCooldown: () => 120,
|
|
SiloCooldown: () => 75,
|
|
deleteUnitCooldown: () => 0,
|
|
spawnImmunityDuration: () => 0,
|
|
nationSpawnImmunityDuration: () => 0,
|
|
unitInfo: () => ({ maxHealth: 100, constructionDuration: 20 }),
|
|
disableAlliances: () => false,
|
|
allianceDuration: () => 100,
|
|
deletionMarkDuration: () => 300,
|
|
doomsdayClockConfig: () => ({ warnSeconds: 15 }),
|
|
nukeMagnitudes: () => ({ inner: 0, outer: 0 }),
|
|
nukeAllianceBreakThreshold: () => 0,
|
|
userSettings: () => ({}),
|
|
...overrides,
|
|
} as unknown as Config;
|
|
return cfg;
|
|
}
|
|
|
|
/** WorkerClient stub. View classes only call worker.* in async methods we don't exercise. */
|
|
export function stubWorker(): WorkerClient {
|
|
return {} as unknown as WorkerClient;
|
|
}
|
|
|
|
/** Build TerrainMapData wrapping a fresh GameMapImpl of the given size. */
|
|
export function stubTerrainMap(width = 10, height = 10): TerrainMapData {
|
|
const terrain = new Uint8Array(width * height);
|
|
const gameMap = new GameMapImpl(width, height, terrain, 0);
|
|
return {
|
|
nations: [],
|
|
additionalNations: [],
|
|
gameMap,
|
|
miniGameMap: gameMap,
|
|
} as unknown as TerrainMapData;
|
|
}
|
|
|
|
export interface GameViewStubOptions {
|
|
width?: number;
|
|
height?: number;
|
|
myClientID?: string;
|
|
myUsername?: string;
|
|
myClanTag?: string | null;
|
|
humans?: Player[];
|
|
config?: Config;
|
|
}
|
|
|
|
/** Construct a GameView with minimal dependencies. */
|
|
export function makeGameView(opts: GameViewStubOptions = {}): GameView {
|
|
return new GameView(
|
|
stubWorker(),
|
|
opts.config ?? stubConfig(),
|
|
stubTerrainMap(opts.width ?? 10, opts.height ?? 10),
|
|
opts.myClientID,
|
|
opts.myUsername ?? "tester",
|
|
opts.myClanTag ?? null,
|
|
"test-game",
|
|
opts.humans ?? [],
|
|
);
|
|
}
|
|
|
|
// ── Synthetic update builders ──
|
|
|
|
export function makePlayerUpdate(
|
|
overrides: Partial<PlayerUpdate> = {},
|
|
): PlayerUpdate {
|
|
return {
|
|
type: GameUpdateType.Player,
|
|
clientID: "client-a",
|
|
name: "Alice",
|
|
displayName: "Alice",
|
|
id: "player-a",
|
|
smallID: 1,
|
|
playerType: PlayerType.Human,
|
|
isAlive: true,
|
|
isDisconnected: false,
|
|
tilesOwned: 0,
|
|
gold: 0n,
|
|
troops: 100,
|
|
allies: [],
|
|
embargoes: new Set(),
|
|
isTraitor: false,
|
|
targets: [],
|
|
outgoingEmojis: [],
|
|
outgoingAttacks: [],
|
|
incomingAttacks: [],
|
|
outgoingAllianceRequests: [],
|
|
alliances: [],
|
|
hasSpawned: true,
|
|
betrayals: 0,
|
|
lastDeleteUnitTick: 0,
|
|
isLobbyCreator: false,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
export function makeUnitUpdate(
|
|
overrides: Partial<UnitUpdate> = {},
|
|
): UnitUpdate {
|
|
return {
|
|
type: GameUpdateType.Unit,
|
|
unitType: UnitType.Warship,
|
|
troops: 0,
|
|
id: 1,
|
|
ownerID: 1,
|
|
pos: 0,
|
|
lastPos: 0,
|
|
isActive: true,
|
|
reachedTarget: false,
|
|
targetable: true,
|
|
markedForDeletion: false,
|
|
missileTimerQueue: [],
|
|
level: 1,
|
|
hasTrainStation: false,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
export function makeNameViewData(
|
|
overrides: Partial<NameViewData> = {},
|
|
): NameViewData {
|
|
return { x: 0, y: 0, size: 12, ...overrides };
|
|
}
|
|
|
|
export interface PlayerViewStubOptions {
|
|
game?: GameView;
|
|
data?: Partial<PlayerUpdate>;
|
|
nameData?: NameViewData;
|
|
cosmetics?: PlayerCosmetics;
|
|
}
|
|
|
|
/** Construct a PlayerView with minimal dependencies. */
|
|
export function makePlayerView(opts: PlayerViewStubOptions = {}): PlayerView {
|
|
return new PlayerView(
|
|
opts.game ?? makeGameView(),
|
|
makePlayerUpdate(opts.data),
|
|
opts.nameData ?? makeNameViewData(),
|
|
opts.cosmetics ?? {},
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Build a GameUpdateViewData with no updates and an empty packed tile delta.
|
|
* Caller can fill in updates[GameUpdateType.X] arrays as needed.
|
|
*/
|
|
export function makeEmptyGu(
|
|
tick: number,
|
|
overrides: Partial<GameUpdateViewData> = {},
|
|
): GameUpdateViewData {
|
|
const updates = Object.fromEntries(
|
|
Object.values(GameUpdateType)
|
|
.filter((v): v is number => typeof v === "number")
|
|
.map((k) => [k, []]),
|
|
) as unknown as GameUpdateViewData["updates"];
|
|
return {
|
|
tick,
|
|
updates,
|
|
packedTileUpdates: new Uint32Array(0),
|
|
// playerNameViewData deliberately absent — production omits it on every
|
|
// tick between placement rebuilds, so the stub default must exercise the
|
|
// absent path. Tests that need placements set it explicitly.
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
export { Team };
|