diff --git a/src/client/controllers/LiveStatsController.ts b/src/client/controllers/LiveStatsController.ts index 0f1a5b00d..c494a1dab 100644 --- a/src/client/controllers/LiveStatsController.ts +++ b/src/client/controllers/LiveStatsController.ts @@ -52,6 +52,8 @@ export class LiveStatsController implements Controller { gold: p.gold().toString(), isAlive: p.isAlive(), team: p.team(), + killedBy: p.killedBy(), + deathPosition: p.deathPosition(), }, ]; }) diff --git a/src/client/render/types/Renderer.ts b/src/client/render/types/Renderer.ts index 67b72b166..ec13e1ce2 100644 --- a/src/client/render/types/Renderer.ts +++ b/src/client/render/types/Renderer.ts @@ -57,6 +57,8 @@ export interface PlayerState { smallID: number; isAlive: boolean; isDisconnected: boolean; + killedBy: string | null; + deathPosition: number | null; tilesOwned: number; gold: number; troops: number; diff --git a/src/client/view/PlayerView.ts b/src/client/view/PlayerView.ts index 26f545055..b13266b01 100644 --- a/src/client/view/PlayerView.ts +++ b/src/client/view/PlayerView.ts @@ -76,6 +76,8 @@ function stateFromUpdate(pu: PlayerUpdate): PlayerState { smallID: pu.smallID!, isAlive: pu.isAlive!, isDisconnected: pu.isDisconnected!, + killedBy: pu.killedBy ?? null, + deathPosition: pu.deathPosition ?? null, tilesOwned: pu.tilesOwned!, gold: Number(pu.gold!), troops: pu.troops!, @@ -452,6 +454,12 @@ export class PlayerView { isAlive(): boolean { return this.state.isAlive; } + killedBy(): string | null { + return this.state.killedBy; + } + deathPosition(): number | null { + return this.state.deathPosition; + } isPlayer(): this is PlayerView { return true; } diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index f2c526728..a0d28de7a 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -730,6 +730,11 @@ export const PlayerLiveStatsSchema = z.object({ gold: z.string(), isAlive: z.boolean(), team: z.string().nullable(), + // OFM live standings: the eliminator's clientID and the finishing place at + // elimination, both null while the player is still alive. Deterministic sim + // values, so clients agree on them for the majority vote. + killedBy: ID.nullable(), + deathPosition: z.number().int().positive().nullable(), }); // A full live snapshot of a running game at a given turn. Reported by clients diff --git a/src/core/StatsSchemas.ts b/src/core/StatsSchemas.ts index c43770c70..dce68e5f3 100644 --- a/src/core/StatsSchemas.ts +++ b/src/core/StatsSchemas.ts @@ -108,6 +108,11 @@ export const PlayerStatsSchema = z attacks: AtLeastOneNumberSchema.optional(), betrayals: BigIntStringSchema.optional(), killedAt: BigIntStringSchema.optional(), + // OFM live standings: the eliminator's clientID (null = eliminated by a + // non-client, e.g. a bot/nation) and finishing place at elimination. Both + // first-write-wins. Surfaced live on the PlayerUpdate (not just at game end). + killedBy: z.string().nullable().optional(), + deathPosition: z.number().optional(), // Tiles owned at game end, for OFM standings (set on setWinner). finalTiles: BigIntStringSchema.optional(), // Humans this player eliminated (victim clientID + tick), for OFM kill scoring. diff --git a/src/core/execution/PlayerExecution.ts b/src/core/execution/PlayerExecution.ts index 9b123a1e9..ab1959137 100644 --- a/src/core/execution/PlayerExecution.ts +++ b/src/core/execution/PlayerExecution.ts @@ -4,6 +4,7 @@ import { Execution, Game, Player, + PlayerType, Structures, UnitType, } from "../game/Game"; @@ -71,6 +72,16 @@ export class PlayerExecution implements Execution { if (!this.player.isAlive()) { this.removeOnDeath(); this.active = false; + // OFM live standings: finishing place = non-bot players still standing when + // we fell, + 1 (we are the last of them). players() is alive-only and we + // just dropped to zero tiles, so it already excludes us. Bots are fill, not + // competitors, so they don't count. Deterministic (same on every client). + // Fallback path: conquest deaths are stamped in GameImpl.conquerPlayer (so a + // game-ending tick still records it); recordDeathPosition is first-write-wins. + const stillStanding = this.mg + .players() + .filter((p) => p.type() !== PlayerType.Bot).length; + this.mg.stats().recordDeathPosition(this.player, stillStanding + 1); this.mg.stats().playerKilled(this.player, ticks); return; } diff --git a/src/core/game/GameImpl.ts b/src/core/game/GameImpl.ts index 59d7dd382..7d1c15145 100644 --- a/src/core/game/GameImpl.ts +++ b/src/core/game/GameImpl.ts @@ -1257,6 +1257,20 @@ export class GameImpl implements Game { // OFM: per-kill log for standings (humans-only filtered in recordKill). this.stats().recordKill(conqueror, conquered, this.ticks()); + // OFM live standings: attribute the elimination so the live snapshot can + // credit the kill (null when the conqueror has no client, e.g. a bot/nation), + // and stamp the finishing place NOW rather than deferring to PlayerExecution: + // if the game ends this tick (winner declared) the conquered player's + // execution may never run again. Exclude the conquered player from the alive + // count. PlayerExecution keeps the same stamp as a fallback for non-conquest + // deaths; recordDeathPosition is first-write-wins, so this value sticks. + this.stats().recordKilledBy(conquered, conqueror.clientID()); + this.stats().recordDeathPosition( + conquered, + this.players().filter( + (p) => p !== conquered && p.type() !== PlayerType.Bot, + ).length + 1, + ); this.addUpdate({ type: GameUpdateType.ConquestEvent, diff --git a/src/core/game/GameUpdateUtils.ts b/src/core/game/GameUpdateUtils.ts index b4bc1912f..7f01fafd0 100644 --- a/src/core/game/GameUpdateUtils.ts +++ b/src/core/game/GameUpdateUtils.ts @@ -52,6 +52,8 @@ export function diffPlayerUpdate( setIfDifferent("playerType", prev.playerType === next.playerType); setIfDifferent("isAlive", prev.isAlive === next.isAlive); setIfDifferent("isDisconnected", prev.isDisconnected === next.isDisconnected); + setIfDifferent("killedBy", prev.killedBy === next.killedBy); + setIfDifferent("deathPosition", prev.deathPosition === next.deathPosition); // tilesOwned / gold / troops intentionally absent — see EXCEPTION above. setIfDifferent("isTraitor", prev.isTraitor === next.isTraitor); setIfDifferent( @@ -120,6 +122,8 @@ export function applyStateUpdate(target: PlayerState, pu: PlayerUpdate): void { if (pu.isAlive !== undefined) target.isAlive = pu.isAlive; if (pu.isDisconnected !== undefined) target.isDisconnected = pu.isDisconnected; + if (pu.killedBy !== undefined) target.killedBy = pu.killedBy; + if (pu.deathPosition !== undefined) target.deathPosition = pu.deathPosition; if (pu.tilesOwned !== undefined) target.tilesOwned = pu.tilesOwned; if (pu.gold !== undefined) target.gold = Number(pu.gold); if (pu.troops !== undefined) target.troops = pu.troops; diff --git a/src/core/game/GameUpdates.ts b/src/core/game/GameUpdates.ts index d6decf3b1..fd93aa47e 100644 --- a/src/core/game/GameUpdates.ts +++ b/src/core/game/GameUpdates.ts @@ -226,6 +226,8 @@ export interface PlayerUpdate { playerType?: PlayerType; isAlive?: boolean; isDisconnected?: boolean; + killedBy?: ClientID | null; + deathPosition?: number | null; tilesOwned?: number; gold?: Gold; troops?: number; diff --git a/src/core/game/PlayerImpl.ts b/src/core/game/PlayerImpl.ts index f0c085988..8e87039d7 100644 --- a/src/core/game/PlayerImpl.ts +++ b/src/core/game/PlayerImpl.ts @@ -298,6 +298,11 @@ export class PlayerImpl implements Player { } } + // OFM live standings: elimination info is stored on the player's stats + // (set live in the sim via mg.stats()), surfaced here so it rides the live + // PlayerUpdate every tick rather than only appearing in the game-end record. + const deathStats = this.mg.stats().getPlayerStats(this); + return { type: GameUpdateType.Player, clientID: this.clientID(), @@ -309,6 +314,8 @@ export class PlayerImpl implements Player { playerType: this.type(), isAlive: this.isAlive(), isDisconnected: this.isDisconnected(), + killedBy: deathStats?.killedBy ?? null, + deathPosition: deathStats?.deathPosition ?? null, tilesOwned: this.numTilesOwned(), gold: this._gold, troops: this.troops(), diff --git a/src/core/game/Stats.ts b/src/core/game/Stats.ts index 5b27ffe1b..63ded90bc 100644 --- a/src/core/game/Stats.ts +++ b/src/core/game/Stats.ts @@ -1,4 +1,4 @@ -import { AllPlayersStats } from "../Schemas"; +import { AllPlayersStats, ClientID } from "../Schemas"; import { NukeType, OtherUnitType, PlayerStats } from "../StatsSchemas"; import { Player, TerraNullius } from "./Game"; @@ -102,6 +102,11 @@ export interface Stats { // player was killed (0 tiles) playerKilled(player: Player, tick: number): void; + // OFM live standings: who eliminated the player (null = non-client killer) and + // their finishing place. Both first-write-wins. + recordKilledBy(victim: Player, killerClientID: ClientID | null): void; + recordDeathPosition(victim: Player, position: number): void; + // Record tiles owned at game end (final standings). recordFinalTiles(player: Player, tiles: number | bigint): void; diff --git a/src/core/game/StatsImpl.ts b/src/core/game/StatsImpl.ts index d0a6a9bf4..441bb8c22 100644 --- a/src/core/game/StatsImpl.ts +++ b/src/core/game/StatsImpl.ts @@ -1,4 +1,4 @@ -import { AllPlayersStats } from "../Schemas"; +import { AllPlayersStats, ClientID } from "../Schemas"; import { ATTACK_INDEX_CANCEL, ATTACK_INDEX_RECV, @@ -292,6 +292,20 @@ export class StatsImpl implements Stats { p.finalTiles = _bigint(tiles); } + recordKilledBy(victim: Player, killerClientID: ClientID | null): void { + const p = this._makePlayerStats(victim); + if (p === undefined) return; + // First write wins; `undefined` means unstamped, and `null` is a valid + // recorded value (eliminated by a non-client killer). + if (p.killedBy === undefined) p.killedBy = killerClientID; + } + + recordDeathPosition(victim: Player, position: number): void { + const p = this._makePlayerStats(victim); + if (p === undefined) return; + p.deathPosition ??= position; // first write wins + } + recordKill(player: Player, victim: Player, tick: BigIntLike): void { if (victim.type() !== PlayerType.Human) return; const victimId = victim.clientID(); diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts index e6caa4322..896082730 100644 --- a/src/server/GameServer.ts +++ b/src/server/GameServer.ts @@ -1465,6 +1465,10 @@ export class GameServer { // current connection status. null until the first consensus. public liveStats(): { turn: number; + // The winner's clientID once the game is decided (player win), else null. + // Server-side (from the winner vote), so the live board can seat the winner + // without waiting for the post-game record. + winner: string | null; players: (PlayerLiveStats & { username: string | null; publicID: string | null; @@ -1474,8 +1478,10 @@ export class GameServer { if (this.latestLiveStats === null) { return null; } + const w = this.winner?.winner; return { turn: this.latestLiveStats.turn, + winner: w?.[0] === "player" ? w[1] : null, players: this.latestLiveStats.players.map((p) => { const client = this.allClients.get(p.clientID); return { diff --git a/tests/GameUpdateUtils.test.ts b/tests/GameUpdateUtils.test.ts index 34901ba1d..41c730690 100644 --- a/tests/GameUpdateUtils.test.ts +++ b/tests/GameUpdateUtils.test.ts @@ -18,6 +18,8 @@ function makePlayerState(overrides: Partial = {}): PlayerState { smallID: 1, isAlive: true, isDisconnected: false, + killedBy: null, + deathPosition: null, tilesOwned: 0, gold: 0, troops: 100, @@ -68,6 +70,14 @@ describe("diffPlayerUpdate", () => { expect(diff.hasSpawned).toBeUndefined(); }); + it("emits killedBy + deathPosition when a player is eliminated", () => { + const prev = makePlayerUpdate({ killedBy: null, deathPosition: null }); + const next = makePlayerUpdate({ killedBy: "client-b", deathPosition: 3 }); + const diff = diffPlayerUpdate(prev, next)!; + expect(diff.killedBy).toBe("client-b"); + expect(diff.deathPosition).toBe(3); + }); + 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 }); @@ -241,6 +251,16 @@ describe("packAttackTroopDeltas", () => { }); describe("applyStateUpdate", () => { + it("applies killedBy + deathPosition on elimination", () => { + const target = makePlayerState(); + applyStateUpdate( + target, + makePlayerUpdate({ killedBy: "client-b", deathPosition: 3 }), + ); + expect(target.killedBy).toBe("client-b"); + expect(target.deathPosition).toBe(3); + }); + it("applies every field from a full update", () => { const target = makePlayerState(); const pu = makePlayerUpdate({ diff --git a/tests/LiveStandingsFields.test.ts b/tests/LiveStandingsFields.test.ts new file mode 100644 index 000000000..b40ebe1d2 --- /dev/null +++ b/tests/LiveStandingsFields.test.ts @@ -0,0 +1,64 @@ +import { Game, PlayerInfo, PlayerType } from "../src/core/game/Game"; +import { setup } from "./util/Setup"; + +// OFM live standings: killedBy + deathPosition are stored on the player's stats +// (mg.stats()) and surfaced on the live PlayerUpdate every tick, so the admin +// bot can score placement + kills off the live snapshot instead of waiting for +// the post-game record. +describe("OFM live standings fields", () => { + let game: Game; + + beforeEach(async () => { + game = await setup("ocean_and_land"); + }); + + function addHuman(id: string, clientID: string | null) { + game.addPlayer(new PlayerInfo(id, PlayerType.Human, clientID, id)); + return game.player(id); + } + + test("conquest stamps killedBy + deathPosition into stats", () => { + const conqueror = addHuman("conqueror", "conqueror_client"); + const victim = addHuman("victim", "victim_client"); + + game.conquerPlayer(conqueror, victim); + + const stats = game.stats().getPlayerStats(victim); + expect(stats?.killedBy).toBe("conqueror_client"); + expect(typeof stats?.deathPosition).toBe("number"); + }); + + test("stamped fields ride the live PlayerUpdate", () => { + const conqueror = addHuman("conqueror", "conqueror_client"); + const victim = addHuman("victim", "victim_client"); + + game.conquerPlayer(conqueror, victim); + + const update = victim.toUpdate(); + expect(update?.killedBy).toBe("conqueror_client"); + expect(typeof update?.deathPosition).toBe("number"); + }); + + test("a non-client killer records killedBy as null (not unstamped)", () => { + // A conqueror with no clientID (e.g. a bot/nation): killedBy is a recorded + // null, distinct from the alive/unstamped case (also null, see below). + game.addPlayer( + new PlayerInfo("botkiller", PlayerType.Bot, null, "botkiller"), + ); + const killer = game.player("botkiller"); + const victim = addHuman("victim2", "victim2_client"); + + game.conquerPlayer(killer, victim); + + const update = victim.toUpdate(); + expect(update?.killedBy).toBeNull(); + expect(typeof update?.deathPosition).toBe("number"); + }); + + test("an alive player has null killedBy + deathPosition on its update", () => { + const alive = addHuman("alive", "alive_client"); + const update = alive.toUpdate(); + expect(update?.killedBy).toBeNull(); + expect(update?.deathPosition).toBeNull(); + }); +}); diff --git a/tests/client/render/frame/derive/nuke-telegraphs.test.ts b/tests/client/render/frame/derive/nuke-telegraphs.test.ts index 4bc3f6dd8..25c66eeac 100644 --- a/tests/client/render/frame/derive/nuke-telegraphs.test.ts +++ b/tests/client/render/frame/derive/nuke-telegraphs.test.ts @@ -31,6 +31,8 @@ function ps(overrides: Partial = {}): PlayerState { smallID: 1, isAlive: true, isDisconnected: false, + killedBy: null, + deathPosition: null, tilesOwned: 0, gold: 0, troops: 0, diff --git a/tests/client/render/frame/derive/player-status.test.ts b/tests/client/render/frame/derive/player-status.test.ts index 2efc1f4ef..e155dd3b0 100644 --- a/tests/client/render/frame/derive/player-status.test.ts +++ b/tests/client/render/frame/derive/player-status.test.ts @@ -27,6 +27,8 @@ function ps(overrides: Partial = {}): PlayerState { smallID: 1, isAlive: true, isDisconnected: false, + killedBy: null, + deathPosition: null, tilesOwned: 0, gold: 0, troops: 0, diff --git a/tests/server/LiveStats.test.ts b/tests/server/LiveStats.test.ts index 03bf62471..09e0b2b1e 100644 --- a/tests/server/LiveStats.test.ts +++ b/tests/server/LiveStats.test.ts @@ -63,6 +63,8 @@ describe("GameServer.handleLiveStats", () => { gold: "100", isAlive: true, team: null, + killedBy: null, + deathPosition: null, }, ]; @@ -89,6 +91,7 @@ describe("GameServer.handleLiveStats", () => { // 2 of 3 IPs -> consensus. expect(game.liveStats()).toEqual({ turn: 100, + winner: null, players: [ { ...players[0], @@ -151,6 +154,7 @@ describe("GameServer.handleLiveStats", () => { report(game, clients[1], 200, snapshot(42)); expect(game.liveStats()).toEqual({ turn: 200, + winner: null, players: [ { ...snapshot(42)[0],