make computePlayerStatus live-aware so status icons render

The replay-path computePlayerStatus left alliance/target/embargo/
nukeTargetsMe at false, which meant the WebGL NamePass had no data
for those status icons after we switched names off canvas2D — they
just stopped appearing.

Add an opts param taking localPlayerID + tileState. When localPlayerID
is set, fill the relative flags by checking the local player's
allies/targets/embargoes against each other player's smallID;
embargo is bilateral (either side). nukeTargetsMe walks active nukes
and checks their targetTile's owner via the tileState buffer.

Plumb localPlayerID = myPlayer?.smallID() and tileState from
populateFrame so the live path uses the new mode. Emit an entry when
only a relative flag is true (previously could be dropped if no base
flag was set).

allianceReq and allianceFraction stay deferred (need local PlayerID
string for outgoing requests and current tick for fraction).

18 new tests covering both modes — replay (relative flags forced
false), and live (alliance one-way, target one-way, embargo bilateral,
self-flags suppressed, nukeTargetsMe with/without tileState,
relative-flag-alone emits, localPlayerID=0 falls back to replay,
allianceReq/allianceFraction stay deferred).
This commit is contained in:
evanpelle
2026-05-16 19:21:49 -07:00
parent 3481beba8a
commit 45246f2085
3 changed files with 421 additions and 17 deletions
+83 -16
View File
@@ -7,29 +7,70 @@ const NUKE_ACTIVE_TYPES: ReadonlySet<string> = new Set([
UT_MIRV_WARHEAD,
]);
const OWNER_MASK = 0xfff;
export interface ComputePlayerStatusOptions {
/**
* Local player smallID for computing relative flags. Omit (or set to 0)
* for replay mode — relative flags will all be false.
*/
localPlayerID?: number;
/**
* Tile state buffer (the same Uint16Array exposed via FrameData.tileState).
* Used to determine if a nuke's target tile is owned by the local player
* for the `nukeTargetsMe` flag. If omitted, `nukeTargetsMe` stays false.
*/
tileState?: Uint16Array;
}
/**
* Compute per-player status flags for the name/status-icon pass.
*
* This is the replay-path version — no local player concept.
* All relative flags (alliance, allianceReq, target, embargo, nukeTargetsMe)
* are always false. The live path uses the shim's own computePlayerStatus
* which has local-player awareness.
* Without `opts.localPlayerID`: replay-path mode. Crown/traitor/disconnected/
* nukeActive are populated; relative flags (alliance/target/embargo/
* nukeTargetsMe) are all false.
*
* With `opts.localPlayerID`: live mode. Relative flags compare each player
* against the local player's state to determine alliance/target/embargo;
* if `opts.tileState` is also given, `nukeTargetsMe` is set for players
* whose in-flight nuke is targeting one of the local player's tiles.
*
* `allianceReq` and `allianceFraction` are not computed yet — they need
* additional context (the local player's PlayerID string for outgoing
* requests, and the current tick for fraction). Left as `false`/`0` until
* those use cases need them.
*/
export function computePlayerStatus(
players: ReadonlyMap<number, PlayerState>,
units: ReadonlyMap<number, UnitState>,
opts: ComputePlayerStatusOptions = {},
): Map<number, PlayerStatusData> {
const result = new Map<number, PlayerStatusData>();
const localPlayerID = opts.localPlayerID ?? 0;
const tileState = opts.tileState;
const localPlayer =
localPlayerID > 0 ? players.get(localPlayerID) : undefined;
// Nuke owners: players who have an active nuke in flight
// Nuke owners: players who have an active nuke in flight.
// Also collect which of those nukes target a tile owned by the local player.
const nukeOwners = new Set<number>();
const nukeAimedAtMe = new Set<number>();
for (const u of units.values()) {
if (u.isActive && NUKE_ACTIVE_TYPES.has(u.unitType)) {
nukeOwners.add(u.ownerID);
if (!u.isActive || !NUKE_ACTIVE_TYPES.has(u.unitType)) continue;
nukeOwners.add(u.ownerID);
if (
localPlayer !== undefined &&
tileState !== undefined &&
u.targetTile !== null
) {
const tileOwner = tileState[u.targetTile] & OWNER_MASK;
if (tileOwner === localPlayerID) {
nukeAimedAtMe.add(u.ownerID);
}
}
}
// Crown: alive player with most tiles
// Crown: alive player with most tiles owned.
let crownSmallID = -1;
let maxTiles = 0;
for (const ps of players.values()) {
@@ -40,31 +81,57 @@ export function computePlayerStatus(
}
}
// Relative-flag sets seeded from the local player's state. Looking them
// up once outside the per-player loop is O(1) per player rather than O(n)
// per .includes(); doesn't matter at small scale but keeps the loop tidy.
const allySet = localPlayer ? new Set(localPlayer.allies) : null;
const targetSet = localPlayer ? new Set(localPlayer.targets) : null;
const myEmbargoes = localPlayer ? new Set(localPlayer.embargoes) : null;
for (const ps of players.values()) {
if (!ps.isAlive) continue;
const crown = ps.smallID === crownSmallID;
const sid = ps.smallID;
const crown = sid === crownSmallID;
const traitor = ps.isTraitor;
const disconnected = ps.isDisconnected;
const traitorRemainingTicks = ps.traitorRemainingTicks;
const nukeActive = nukeOwners.has(ps.smallID);
const nukeActive = nukeOwners.has(sid);
// Relative flags — only meaningful when there's a local player AND we're
// not looking at the local player itself.
let alliance = false;
let target = false;
let embargo = false;
let nukeTargetsMe = false;
if (localPlayer !== undefined && sid !== localPlayerID) {
alliance = allySet!.has(sid);
target = targetSet!.has(sid);
// Embargo is bilateral: either side embargoes the other.
embargo = myEmbargoes!.has(sid) || ps.embargoes.includes(localPlayerID);
nukeTargetsMe = nukeAimedAtMe.has(sid);
}
if (
crown ||
traitor ||
disconnected ||
traitorRemainingTicks > 0 ||
nukeActive
nukeActive ||
alliance ||
target ||
embargo ||
nukeTargetsMe
) {
result.set(ps.smallID, {
result.set(sid, {
crown,
traitor,
disconnected,
alliance: false,
alliance,
allianceReq: false,
target: false,
embargo: false,
target,
embargo,
nukeActive,
nukeTargetsMe: false,
nukeTargetsMe,
traitorRemainingTicks,
allianceFraction: 0,
});
+4 -1
View File
@@ -465,7 +465,10 @@ export class GameView implements GameMap {
f.railroadDirty = this.railroadCache.railroadDirty;
f.trailDirtyRowMin = this.trailManager.dirtyRowMin;
f.trailDirtyRowMax = this.trailManager.dirtyRowMax;
f.playerStatus = computePlayerStatus(this._playerStates, this._unitStates);
f.playerStatus = computePlayerStatus(this._playerStates, this._unitStates, {
localPlayerID: this._myPlayer?.smallID() ?? 0,
tileState: this._map.tileStateBuffer(),
});
const rel = buildRelationMatrix(this._playerStates);
f.relationMatrix = rel.matrix;
f.relationSize = rel.size;
@@ -0,0 +1,334 @@
/**
* 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/player-status";
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);
});
});