Files
OpenFrontIO/tests/client/render/frame/derive/player-status.test.ts
evanpelle 4cd22a9b5c rename render/ files to UpperCamelCase to match client convention
The render/ tree was the only place in the client still using kebab-case
filenames. Brings ~80 files in line with the rest of src/client/
(BuildPreviewController, TransformHandler, etc.). Directories kept as
they were (name-pass/, fx-pass/, passes/, utils/, debug/) since the
codebase already mixes those.

Two collisions surfaced and got resolved: render/types/ is a directory,
not a file, so its imports kept the lowercase form; and the sed pass
incidentally normalized core/pathfinding imports, which had to be
reverted since that file is actually lowercase on disk despite some
imports having referenced it as ./Types under macOS case-insensitive
resolution.
2026-05-17 21:21:05 -07:00

335 lines
10 KiB
TypeScript

/**
* computePlayerStatus has two modes:
*
* - Replay mode (no localPlayerID): only crown / traitor / disconnected /
* nukeActive flags are populated. All relative flags are false.
* - Live mode (localPlayerID 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,
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,
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 localPlayerID)", () => {
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 (localPlayerID 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(), {
localPlayerID: 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(), {
localPlayerID: 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(),
{ localPlayerID: 1 },
);
expect(status.get(2)?.embargo).toBe(true);
// They embargo me.
status = computePlayerStatus(
playersMap(ps({ smallID: 1 }), ps({ smallID: 2, embargoes: [1] })),
unitsMap(),
{ localPlayerID: 1 },
);
expect(status.get(2)?.embargo).toBe(true);
// Neither.
status = computePlayerStatus(
playersMap(ps({ smallID: 1 }), ps({ smallID: 2, tilesOwned: 1 })),
unitsMap(),
{ localPlayerID: 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(), {
localPlayerID: 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, { localPlayerID: 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, {
localPlayerID: 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, {
localPlayerID: 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(), {
localPlayerID: 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("localPlayerID = 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(), {
localPlayerID: 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(), {
localPlayerID: 1,
});
expect(status.get(2)?.allianceReq).toBe(false);
expect(status.get(2)?.allianceFraction).toBe(0);
});
});