diff --git a/src/client/render/frame/derive/PlayerStatus.ts b/src/client/render/frame/derive/PlayerStatus.ts index ec98808bc..f351134d6 100644 --- a/src/client/render/frame/derive/PlayerStatus.ts +++ b/src/client/render/frame/derive/PlayerStatus.ts @@ -14,31 +14,42 @@ 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; + localPlayerSmallID?: number; + /** + * Local player string ID for matching outgoing alliance requests. + */ + localPlayerID?: string; /** * 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; + /** + * Current game tick to evaluate alliance progress. + */ + tick?: number; + /** + * Static duration of an alliance to evaluate fraction. + */ + allianceDuration?: number; + /** + * Predicate testing if the local player considers `sid` a transitive target. + */ + isTransitiveTarget?: (sid: number) => boolean; } /** * Compute per-player status flags for the name/status-icon pass. * - * Without `opts.localPlayerID`: replay-path mode. Crown/traitor/disconnected/ + * Without `opts.localPlayerSmallID`: 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 + * With `opts.localPlayerSmallID`: 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, @@ -46,29 +57,11 @@ export function computePlayerStatus( opts: ComputePlayerStatusOptions = {}, ): Map { const result = new Map(); - const localPlayerID = opts.localPlayerID ?? 0; - const tileState = opts.tileState; + const localPlayerSmallID = opts.localPlayerSmallID ?? 0; + const localPlayerID = opts.localPlayerID ?? ""; const localPlayer = - localPlayerID > 0 ? players.get(localPlayerID) : undefined; - - // 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)) 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); - } - } - } + localPlayerSmallID > 0 ? players.get(localPlayerSmallID) : undefined; + const tileState = opts.tileState; // Crown: alive player with most tiles owned. let crownSmallID = -1; @@ -81,13 +74,6 @@ 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 sid = ps.smallID; @@ -95,20 +81,70 @@ export function computePlayerStatus( const traitor = ps.isTraitor; const disconnected = ps.isDisconnected; const traitorRemainingTicks = ps.traitorRemainingTicks; - 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. + // Relative flags + let nukeActive = false; + let nukeTargetsMe = false; 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); + let allianceReq = false; + let allianceFraction = 0; + + // Nukes: show during replay too, except the nukeTargetsMe flag + for (const u of units.values()) { + if ( + u.ownerID === sid && + u.isActive && + NUKE_ACTIVE_TYPES.has(u.unitType) + ) { + nukeActive = true; + if ( + localPlayerSmallID > 0 && + tileState !== undefined && + u.targetTile !== null && + (tileState[u.targetTile] & OWNER_MASK) === localPlayerSmallID + ) { + nukeTargetsMe = true; + } + if (nukeTargetsMe) break; + } + } + + // Flags which are only meaningful when there's a local player, + // and we're not looking at the local player itself. + if (localPlayer !== undefined && sid !== localPlayerSmallID) { + alliance = localPlayer.allies.includes(sid); + allianceReq = ps.outgoingAllianceRequests.includes(localPlayerID); + target = opts.isTransitiveTarget + ? opts.isTransitiveTarget(sid) + : localPlayer.targets.includes(sid); // Embargo is bilateral: either side embargoes the other. - embargo = myEmbargoes!.has(sid) || ps.embargoes.includes(localPlayerID); - nukeTargetsMe = nukeAimedAtMe.has(sid); + embargo = + localPlayer.embargoes.includes(sid) || + ps.embargoes.includes(localPlayerSmallID); + + if ( + alliance && + opts.tick !== undefined && + opts.allianceDuration !== undefined && + opts.localPlayerID + ) { + const foundAlliance = ps.alliances.find( + (a) => a.other === opts.localPlayerID, + ); + if (foundAlliance) { + // e.g. expiresAt = 100, tick = 60, diff = 40. duration = 100. fraction = 0.4. + const remainingTicks = Math.max( + 0, + foundAlliance.expiresAt - opts.tick, + ); + allianceFraction = Math.max( + 0, + Math.min(1, remainingTicks / Math.max(1, opts.allianceDuration)), + ); + } + } } if ( @@ -118,6 +154,7 @@ export function computePlayerStatus( traitorRemainingTicks > 0 || nukeActive || alliance || + allianceReq || target || embargo || nukeTargetsMe @@ -127,13 +164,13 @@ export function computePlayerStatus( traitor, disconnected, alliance, - allianceReq: false, + allianceReq, target, embargo, nukeActive, nukeTargetsMe, traitorRemainingTicks, - allianceFraction: 0, + allianceFraction, }); } } diff --git a/src/client/view/GameView.ts b/src/client/view/GameView.ts index 3543076af..2b35a4a8c 100644 --- a/src/client/view/GameView.ts +++ b/src/client/view/GameView.ts @@ -474,9 +474,15 @@ 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, { - localPlayerID: this._myPlayer?.smallID() ?? 0, + localPlayerSmallID: this._myPlayer?.smallID() ?? 0, + localPlayerID: this._myPlayer?.id() ?? "", tileState: this._map.tileStateBuffer(), + tick: gu.tick, + allianceDuration: this._config.allianceDuration(), + isTransitiveTarget: (sid) => + this._myPlayer?.hasTransitiveTarget(sid) ?? false, }); const rel = buildRelationMatrix(this._playerStates); f.relationMatrix = rel.matrix; diff --git a/src/client/view/PlayerView.ts b/src/client/view/PlayerView.ts index 24a8abe25..f4e488dfc 100644 --- a/src/client/view/PlayerView.ts +++ b/src/client/view/PlayerView.ts @@ -522,6 +522,32 @@ export class PlayerView { return result; } + hasTransitiveTarget(sid: number): boolean { + if (this.state.targets.includes(sid)) return true; + + for (const allyID of this.state.allies) { + const ally = this.game.playerBySmallID(allyID) as PlayerView; + if (ally && ally.state.targets.includes(sid)) { + return true; + } + } + + const myTeam = this.static.team; + if (myTeam !== null) { + for (const p of this.game.playerViews()) { + if ( + p !== this && + p.static.team === myTeam && + p.state.targets.includes(sid) + ) { + return true; + } + } + } + + return false; + } + isTraitor(): boolean { return this.state.isTraitor; } diff --git a/tests/client/render/frame/derive/player-status.test.ts b/tests/client/render/frame/derive/player-status.test.ts index 16a4b7ed8..4d452d97e 100644 --- a/tests/client/render/frame/derive/player-status.test.ts +++ b/tests/client/render/frame/derive/player-status.test.ts @@ -1,9 +1,9 @@ /** * computePlayerStatus has two modes: * - * - Replay mode (no localPlayerID): only crown / traitor / disconnected / + * - Replay mode (no localPlayerSmallID): only crown / traitor / disconnected / * nukeActive flags are populated. All relative flags are false. - * - Live mode (localPlayerID set): also fills alliance / target / embargo, + * - Live mode (localPlayerSmallID 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 @@ -83,7 +83,7 @@ function unitsMap(...us: UnitState[]): Map { return new Map(us.map((u) => [u.id, u])); } -describe("computePlayerStatus — replay mode (no localPlayerID)", () => { +describe("computePlayerStatus — replay mode (no localPlayerSmallID)", () => { it("returns empty map when no flags are set", () => { const players = playersMap(ps({ smallID: 1 })); const status = computePlayerStatus(players, unitsMap()); @@ -160,14 +160,14 @@ describe("computePlayerStatus — replay mode (no localPlayerID)", () => { }); }); -describe("computePlayerStatus — live mode (localPlayerID set)", () => { +describe("computePlayerStatus — live mode (localPlayerSmallID 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, + localPlayerSmallID: 1, }); expect(status.get(2)?.alliance).toBe(true); }); @@ -178,7 +178,7 @@ describe("computePlayerStatus — live mode (localPlayerID set)", () => { ps({ smallID: 2 }), ); const status = computePlayerStatus(players, unitsMap(), { - localPlayerID: 1, + localPlayerSmallID: 1, }); expect(status.get(2)?.target).toBe(true); }); @@ -188,7 +188,7 @@ describe("computePlayerStatus — live mode (localPlayerID set)", () => { let status = computePlayerStatus( playersMap(ps({ smallID: 1, embargoes: [2] }), ps({ smallID: 2 })), unitsMap(), - { localPlayerID: 1 }, + { localPlayerSmallID: 1 }, ); expect(status.get(2)?.embargo).toBe(true); @@ -196,7 +196,7 @@ describe("computePlayerStatus — live mode (localPlayerID set)", () => { status = computePlayerStatus( playersMap(ps({ smallID: 1 }), ps({ smallID: 2, embargoes: [1] })), unitsMap(), - { localPlayerID: 1 }, + { localPlayerSmallID: 1 }, ); expect(status.get(2)?.embargo).toBe(true); @@ -204,7 +204,7 @@ describe("computePlayerStatus — live mode (localPlayerID set)", () => { status = computePlayerStatus( playersMap(ps({ smallID: 1 }), ps({ smallID: 2, tilesOwned: 1 })), unitsMap(), - { localPlayerID: 1 }, + { localPlayerSmallID: 1 }, ); // Player 2 only has crown — embargo should be false. expect(status.get(2)?.embargo).toBe(false); @@ -222,7 +222,7 @@ describe("computePlayerStatus — live mode (localPlayerID set)", () => { ps({ smallID: 2 }), ); const status = computePlayerStatus(players, unitsMap(), { - localPlayerID: 1, + localPlayerSmallID: 1, }); // Player 1 (local) gets crown but no relative flags vs. self. expect(status.get(1)?.crown).toBe(true); @@ -242,7 +242,9 @@ describe("computePlayerStatus — live mode (localPlayerID set)", () => { targetTile: 5, }), ); - const status = computePlayerStatus(players, units, { localPlayerID: 1 }); + const status = computePlayerStatus(players, units, { + localPlayerSmallID: 1, + }); expect(status.get(2)?.nukeActive).toBe(true); expect(status.get(2)?.nukeTargetsMe).toBe(false); }); @@ -263,7 +265,7 @@ describe("computePlayerStatus — live mode (localPlayerID set)", () => { tileState[5] = 1; const status = computePlayerStatus(players, units, { - localPlayerID: 1, + localPlayerSmallID: 1, tileState, }); expect(status.get(2)?.nukeTargetsMe).toBe(true); @@ -289,7 +291,7 @@ describe("computePlayerStatus — live mode (localPlayerID set)", () => { tileState[5] = 3; const status = computePlayerStatus(players, units, { - localPlayerID: 1, + localPlayerSmallID: 1, tileState, }); expect(status.get(2)?.nukeTargetsMe).toBe(false); @@ -301,7 +303,7 @@ describe("computePlayerStatus — live mode (localPlayerID set)", () => { ps({ smallID: 2 }), // no other flags ); const status = computePlayerStatus(players, unitsMap(), { - localPlayerID: 1, + localPlayerSmallID: 1, }); // Without local-mode, player 2 wouldn't get an entry — alliance is the // only reason it shows up here. @@ -309,13 +311,13 @@ describe("computePlayerStatus — live mode (localPlayerID set)", () => { expect(status.get(2)?.alliance).toBe(true); }); - it("localPlayerID = 0 (no local player) behaves like replay mode", () => { + it("localPlayerSmallID = 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, + localPlayerSmallID: 0, }); expect(status.get(2)?.alliance).toBe(false); }); @@ -326,7 +328,7 @@ describe("computePlayerStatus — live mode (localPlayerID set)", () => { ps({ smallID: 2 }), ); const status = computePlayerStatus(players, unitsMap(), { - localPlayerID: 1, + localPlayerSmallID: 1, }); expect(status.get(2)?.allianceReq).toBe(false); expect(status.get(2)?.allianceFraction).toBe(0);