mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-04 21:06:09 +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._
392 lines
12 KiB
TypeScript
392 lines
12 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import type { PlayerState } from "../src/client/render/types";
|
|
import { PlayerType } from "../src/core/game/Game";
|
|
import {
|
|
applyStateUpdate,
|
|
diffPlayerUpdate,
|
|
packAttackTroopDeltas,
|
|
} from "../src/core/game/GameUpdateUtils";
|
|
import {
|
|
AttackUpdate,
|
|
GameUpdateType,
|
|
PlayerUpdate,
|
|
} from "../src/core/game/GameUpdates";
|
|
import { makePlayerUpdate } from "./util/viewStubs";
|
|
|
|
function makePlayerState(overrides: Partial<PlayerState> = {}): PlayerState {
|
|
return {
|
|
smallID: 1,
|
|
isAlive: true,
|
|
isDisconnected: false,
|
|
tilesOwned: 0,
|
|
gold: 0,
|
|
troops: 100,
|
|
isTraitor: false,
|
|
traitorRemainingTicks: 0,
|
|
inDoomsdayClock: false,
|
|
markedDoomsdayClockTick: -1,
|
|
betrayals: 0,
|
|
hasSpawned: true,
|
|
lastDeleteUnitTick: 0,
|
|
allies: [],
|
|
embargoes: [],
|
|
targets: [],
|
|
outgoingAttacks: [],
|
|
incomingAttacks: [],
|
|
outgoingAllianceRequests: [],
|
|
alliances: [],
|
|
outgoingEmojis: [],
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe("diffPlayerUpdate", () => {
|
|
it("returns null when prev and next are identical", () => {
|
|
const prev = makePlayerUpdate();
|
|
const next = makePlayerUpdate();
|
|
expect(diffPlayerUpdate(prev, next)).toBeNull();
|
|
});
|
|
|
|
it("returns a diff with only changed primitives plus type+id", () => {
|
|
const prev = makePlayerUpdate({ betrayals: 0 });
|
|
const next = makePlayerUpdate({ betrayals: 1 });
|
|
const diff = diffPlayerUpdate(prev, next);
|
|
expect(diff).not.toBeNull();
|
|
expect(diff).toEqual({
|
|
type: GameUpdateType.Player,
|
|
id: "player-a",
|
|
betrayals: 1,
|
|
});
|
|
});
|
|
|
|
it("includes every changed primitive in a single diff", () => {
|
|
const prev = makePlayerUpdate({ betrayals: 0, isTraitor: false });
|
|
const next = makePlayerUpdate({ betrayals: 1, isTraitor: true });
|
|
const diff = diffPlayerUpdate(prev, next)!;
|
|
expect(diff.betrayals).toBe(1);
|
|
expect(diff.isTraitor).toBe(true);
|
|
expect(diff.hasSpawned).toBeUndefined();
|
|
});
|
|
|
|
it("ignores tilesOwned/gold/troops — they travel via packedPlayerUpdates", () => {
|
|
const prev = makePlayerUpdate({ gold: 100n, troops: 50, tilesOwned: 5 });
|
|
const next = makePlayerUpdate({ gold: 200n, troops: 75, tilesOwned: 9 });
|
|
expect(diffPlayerUpdate(prev, next)).toBeNull();
|
|
});
|
|
|
|
it("detects allies array additions", () => {
|
|
const prev = makePlayerUpdate({ allies: [2, 3] });
|
|
const next = makePlayerUpdate({ allies: [2, 3, 4] });
|
|
const diff = diffPlayerUpdate(prev, next)!;
|
|
expect(diff.allies).toEqual([2, 3, 4]);
|
|
});
|
|
|
|
it("ignores allies array when contents are equal (different identity)", () => {
|
|
const prev = makePlayerUpdate({ allies: [2, 3] });
|
|
const next = makePlayerUpdate({ allies: [2, 3] });
|
|
expect(diffPlayerUpdate(prev, next)).toBeNull();
|
|
});
|
|
|
|
it("treats allies reorder as a change (order is significant)", () => {
|
|
const prev = makePlayerUpdate({ allies: [2, 3] });
|
|
const next = makePlayerUpdate({ allies: [3, 2] });
|
|
const diff = diffPlayerUpdate(prev, next)!;
|
|
expect(diff.allies).toEqual([3, 2]);
|
|
});
|
|
|
|
it("detects embargo set membership changes", () => {
|
|
const prev = makePlayerUpdate({ embargoes: new Set(["x", "y"]) });
|
|
const next = makePlayerUpdate({ embargoes: new Set(["x", "y", "z"]) });
|
|
const diff = diffPlayerUpdate(prev, next)!;
|
|
expect(diff.embargoes).toEqual(new Set(["x", "y", "z"]));
|
|
});
|
|
|
|
it("ignores embargo set when membership is equal regardless of object identity", () => {
|
|
const prev = makePlayerUpdate({ embargoes: new Set(["x", "y"]) });
|
|
const next = makePlayerUpdate({ embargoes: new Set(["y", "x"]) });
|
|
expect(diffPlayerUpdate(prev, next)).toBeNull();
|
|
});
|
|
|
|
it("detects outgoingAttacks membership/retreating changes", () => {
|
|
const prev = makePlayerUpdate({
|
|
outgoingAttacks: [
|
|
{ attackerID: 1, targetID: 2, troops: 10, id: "a", retreating: false },
|
|
],
|
|
});
|
|
const next = makePlayerUpdate({
|
|
outgoingAttacks: [
|
|
{ attackerID: 1, targetID: 2, troops: 10, id: "a", retreating: true },
|
|
],
|
|
});
|
|
const diff = diffPlayerUpdate(prev, next)!;
|
|
expect(diff.outgoingAttacks).toEqual(next.outgoingAttacks);
|
|
});
|
|
|
|
it("ignores attack troop-count changes — they travel via packedAttackUpdates", () => {
|
|
const prev = makePlayerUpdate({
|
|
outgoingAttacks: [
|
|
{ attackerID: 1, targetID: 2, troops: 10, id: "a", retreating: false },
|
|
],
|
|
});
|
|
const next = makePlayerUpdate({
|
|
outgoingAttacks: [
|
|
{ attackerID: 1, targetID: 2, troops: 20, id: "a", retreating: false },
|
|
],
|
|
});
|
|
expect(diffPlayerUpdate(prev, next)).toBeNull();
|
|
});
|
|
|
|
it("detects alliance list changes", () => {
|
|
const prev = makePlayerUpdate({ alliances: [] });
|
|
const next = makePlayerUpdate({
|
|
alliances: [
|
|
{
|
|
id: 1,
|
|
other: "player-b",
|
|
createdAt: 10,
|
|
expiresAt: 110,
|
|
hasExtensionRequest: false,
|
|
},
|
|
],
|
|
});
|
|
const diff = diffPlayerUpdate(prev, next)!;
|
|
expect(diff.alliances).toEqual(next.alliances);
|
|
});
|
|
|
|
it("treats undefined→number transition as a change", () => {
|
|
const prev = makePlayerUpdate({ traitorRemainingTicks: undefined });
|
|
const next = makePlayerUpdate({ traitorRemainingTicks: 5 });
|
|
const diff = diffPlayerUpdate(prev, next)!;
|
|
expect(diff.traitorRemainingTicks).toBe(5);
|
|
});
|
|
|
|
it("treats number→undefined transition as a change", () => {
|
|
const prev = makePlayerUpdate({ traitorRemainingTicks: 5 });
|
|
const next = makePlayerUpdate({ traitorRemainingTicks: undefined });
|
|
const diff = diffPlayerUpdate(prev, next);
|
|
expect(diff).not.toBeNull();
|
|
expect("traitorRemainingTicks" in diff!).toBe(true);
|
|
expect(diff!.traitorRemainingTicks).toBeUndefined();
|
|
});
|
|
|
|
it("always includes type and id on a non-null diff", () => {
|
|
const prev = makePlayerUpdate({ betrayals: 0 });
|
|
const next = makePlayerUpdate({ betrayals: 1 });
|
|
const diff = diffPlayerUpdate(prev, next)!;
|
|
expect(diff.type).toBe(GameUpdateType.Player);
|
|
expect(diff.id).toBe(next.id);
|
|
});
|
|
});
|
|
|
|
describe("packAttackTroopDeltas", () => {
|
|
const attack = (
|
|
troops: number,
|
|
id = "a",
|
|
retreating = false,
|
|
): AttackUpdate => ({
|
|
attackerID: 1,
|
|
targetID: 2,
|
|
troops,
|
|
id,
|
|
retreating,
|
|
});
|
|
|
|
it("emits [owner, direction, index, troops] quads for changed troop counts", () => {
|
|
const out: number[] = [];
|
|
packAttackTroopDeltas(
|
|
[attack(10, "a"), attack(20, "b")],
|
|
[attack(10, "a"), attack(15, "b")],
|
|
7,
|
|
1,
|
|
out,
|
|
);
|
|
expect(out).toEqual([7, 1, 1, 15]);
|
|
});
|
|
|
|
it("emits nothing when arrays are not membership-equal (diff resends them)", () => {
|
|
const out: number[] = [];
|
|
packAttackTroopDeltas(
|
|
[attack(10, "a")],
|
|
[attack(15, "a"), attack(5, "b")],
|
|
7,
|
|
0,
|
|
out,
|
|
);
|
|
expect(out).toEqual([]);
|
|
});
|
|
|
|
it("a retreat flip suppresses quads even when troops also changed", () => {
|
|
// retreating is part of membership equality, so the whole array resends
|
|
// (with fresh troops) and patches must NOT be emitted — a tick resends
|
|
// or patches, never both.
|
|
const out: number[] = [];
|
|
packAttackTroopDeltas(
|
|
[attack(10, "a", false)],
|
|
[attack(5, "a", true)],
|
|
7,
|
|
0,
|
|
out,
|
|
);
|
|
expect(out).toEqual([]);
|
|
});
|
|
|
|
it("emits nothing for identical references or missing arrays", () => {
|
|
const out: number[] = [];
|
|
const arr = [attack(10)];
|
|
packAttackTroopDeltas(arr, arr, 7, 0, out);
|
|
packAttackTroopDeltas(undefined, arr, 7, 0, out);
|
|
packAttackTroopDeltas(arr, undefined, 7, 0, out);
|
|
expect(out).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("applyStateUpdate", () => {
|
|
it("applies every field from a full update", () => {
|
|
const target = makePlayerState();
|
|
const pu = makePlayerUpdate({
|
|
gold: 500n,
|
|
troops: 999,
|
|
tilesOwned: 42,
|
|
allies: [7, 8],
|
|
targets: [9],
|
|
outgoingAllianceRequests: ["player-b"],
|
|
isAlive: false,
|
|
isTraitor: true,
|
|
traitorRemainingTicks: 3,
|
|
betrayals: 2,
|
|
hasSpawned: true,
|
|
lastDeleteUnitTick: 50,
|
|
});
|
|
applyStateUpdate(target, pu);
|
|
expect(target.gold).toBe(500);
|
|
expect(target.troops).toBe(999);
|
|
expect(target.tilesOwned).toBe(42);
|
|
expect(target.allies).toEqual([7, 8]);
|
|
expect(target.targets).toEqual([9]);
|
|
expect(target.outgoingAllianceRequests).toEqual(["player-b"]);
|
|
expect(target.isAlive).toBe(false);
|
|
expect(target.isTraitor).toBe(true);
|
|
expect(target.traitorRemainingTicks).toBe(3);
|
|
expect(target.betrayals).toBe(2);
|
|
expect(target.lastDeleteUnitTick).toBe(50);
|
|
});
|
|
|
|
it("converts bigint gold to number", () => {
|
|
const target = makePlayerState({ gold: 0 });
|
|
applyStateUpdate(target, {
|
|
type: GameUpdateType.Player,
|
|
id: "p",
|
|
gold: 9_999_999_999n,
|
|
});
|
|
expect(target.gold).toBe(9_999_999_999);
|
|
expect(typeof target.gold).toBe("number");
|
|
});
|
|
|
|
it("clamps negative traitorRemainingTicks to zero", () => {
|
|
const target = makePlayerState({ traitorRemainingTicks: 5 });
|
|
applyStateUpdate(target, {
|
|
type: GameUpdateType.Player,
|
|
id: "p",
|
|
traitorRemainingTicks: -10,
|
|
});
|
|
expect(target.traitorRemainingTicks).toBe(0);
|
|
});
|
|
|
|
it("only mutates fields present on the partial update", () => {
|
|
const target = makePlayerState({ gold: 100, troops: 50, tilesOwned: 7 });
|
|
const partial: PlayerUpdate = {
|
|
type: GameUpdateType.Player,
|
|
id: "p",
|
|
gold: 200n,
|
|
};
|
|
applyStateUpdate(target, partial);
|
|
expect(target.gold).toBe(200);
|
|
expect(target.troops).toBe(50);
|
|
expect(target.tilesOwned).toBe(7);
|
|
});
|
|
|
|
it("leaves array fields untouched when omitted", () => {
|
|
const original = [1, 2, 3];
|
|
const target = makePlayerState({ allies: original });
|
|
applyStateUpdate(target, { type: GameUpdateType.Player, id: "p" });
|
|
expect(target.allies).toBe(original);
|
|
});
|
|
|
|
it("detaches array fields by slicing (no shared reference with wire payload)", () => {
|
|
const wireAllies = [1, 2, 3];
|
|
const wireTargets = [9];
|
|
const wireRequests = ["player-b"];
|
|
const target = makePlayerState();
|
|
applyStateUpdate(target, {
|
|
type: GameUpdateType.Player,
|
|
id: "p",
|
|
allies: wireAllies,
|
|
targets: wireTargets,
|
|
outgoingAllianceRequests: wireRequests,
|
|
});
|
|
expect(target.allies).toEqual(wireAllies);
|
|
expect(target.allies).not.toBe(wireAllies);
|
|
expect(target.targets).not.toBe(wireTargets);
|
|
expect(target.outgoingAllianceRequests).not.toBe(wireRequests);
|
|
});
|
|
|
|
it("does not touch smallID even when present (identity field)", () => {
|
|
const target = makePlayerState({ smallID: 42 });
|
|
applyStateUpdate(target, {
|
|
type: GameUpdateType.Player,
|
|
id: "p",
|
|
smallID: 999,
|
|
});
|
|
expect(target.smallID).toBe(42);
|
|
});
|
|
|
|
it("merges several partial updates into a cumulative state", () => {
|
|
const target = makePlayerState();
|
|
applyStateUpdate(target, {
|
|
type: GameUpdateType.Player,
|
|
id: "p",
|
|
gold: 100n,
|
|
});
|
|
applyStateUpdate(target, {
|
|
type: GameUpdateType.Player,
|
|
id: "p",
|
|
troops: 250,
|
|
});
|
|
applyStateUpdate(target, {
|
|
type: GameUpdateType.Player,
|
|
id: "p",
|
|
isAlive: false,
|
|
});
|
|
expect(target.gold).toBe(100);
|
|
expect(target.troops).toBe(250);
|
|
expect(target.isAlive).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("diff + apply round-trip", () => {
|
|
it("emitting full first + diff second reconstructs final state", () => {
|
|
// tilesOwned/gold/troops round-trip via packedPlayerUpdates instead
|
|
// (covered in tests/client/view/GameView.test.ts).
|
|
const v0 = makePlayerUpdate({ betrayals: 0, allies: [] });
|
|
const v1 = makePlayerUpdate({ betrayals: 2, allies: [2] });
|
|
|
|
// Initial state: receiver applies the full update.
|
|
const target = makePlayerState();
|
|
applyStateUpdate(target, v0);
|
|
|
|
// Subsequent tick: emitter sends only the diff.
|
|
const diff = diffPlayerUpdate(v0, v1)!;
|
|
expect(diff).not.toBeNull();
|
|
applyStateUpdate(target, diff);
|
|
|
|
expect(target.betrayals).toBe(2);
|
|
expect(target.allies).toEqual([2]);
|
|
});
|
|
|
|
it("no-change tick produces null diff so receiver state is untouched", () => {
|
|
const v0 = makePlayerUpdate({ gold: 100n, playerType: PlayerType.Human });
|
|
const v1 = makePlayerUpdate({ gold: 100n, playerType: PlayerType.Human });
|
|
expect(diffPlayerUpdate(v0, v1)).toBeNull();
|
|
});
|
|
});
|