diff --git a/src/client/render/frame/derive/player-status.ts b/src/client/render/frame/derive/player-status.ts index 2a165e007..ec98808bc 100644 --- a/src/client/render/frame/derive/player-status.ts +++ b/src/client/render/frame/derive/player-status.ts @@ -7,29 +7,70 @@ const NUKE_ACTIVE_TYPES: ReadonlySet = 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, units: ReadonlyMap, + opts: ComputePlayerStatusOptions = {}, ): Map { const result = new Map(); + 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(); + const nukeAimedAtMe = new Set(); 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, }); diff --git a/src/client/view/GameView.ts b/src/client/view/GameView.ts index 497ad7463..4daf46d8b 100644 --- a/src/client/view/GameView.ts +++ b/src/client/view/GameView.ts @@ -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; diff --git a/tests/client/render/frame/derive/player-status.test.ts b/tests/client/render/frame/derive/player-status.test.ts new file mode 100644 index 000000000..e511555b9 --- /dev/null +++ b/tests/client/render/frame/derive/player-status.test.ts @@ -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 { + 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 { + 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 { + return new Map(players.map((p) => [p.smallID, p])); +} + +function unitsMap(...us: UnitState[]): Map { + 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); + }); +});