diff --git a/src/core/StatsSchemas.ts b/src/core/StatsSchemas.ts index 8543f4bab..c43770c70 100644 --- a/src/core/StatsSchemas.ts +++ b/src/core/StatsSchemas.ts @@ -108,6 +108,12 @@ export const PlayerStatsSchema = z attacks: AtLeastOneNumberSchema.optional(), betrayals: BigIntStringSchema.optional(), killedAt: BigIntStringSchema.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. + kills: z + .array(z.object({ victim: z.string(), tick: BigIntStringSchema })) + .optional(), conquests: AtLeastOneNumberSchema.optional(), boats: z.partialRecord(BoatUnitSchema, AtLeastOneNumberSchema).optional(), bombs: z.partialRecord(BombUnitSchema, AtLeastOneNumberSchema).optional(), diff --git a/src/core/game/GameImpl.ts b/src/core/game/GameImpl.ts index 04a1473a0..59d7dd382 100644 --- a/src/core/game/GameImpl.ts +++ b/src/core/game/GameImpl.ts @@ -851,6 +851,10 @@ export class GameImpl implements Game { setWinner(winner: Player | Team, allPlayersStats: AllPlayersStats): void { this._winner = winner; + // OFM: snapshot final tiles for standings (bots skipped in recordFinalTiles). + for (const player of this.players()) { + this.stats().recordFinalTiles(player, player.numTilesOwned()); + } this.addUpdate({ type: GameUpdateType.Win, winner: this.makeWinner(winner), @@ -1251,6 +1255,9 @@ export class GameImpl implements Game { this.stats().goldWar(conqueror, conquered, goldCaptured); } + // OFM: per-kill log for standings (humans-only filtered in recordKill). + this.stats().recordKill(conqueror, conquered, this.ticks()); + this.addUpdate({ type: GameUpdateType.ConquestEvent, conquerorId: conqueror.id(), diff --git a/src/core/game/Stats.ts b/src/core/game/Stats.ts index 7e508002c..5b27ffe1b 100644 --- a/src/core/game/Stats.ts +++ b/src/core/game/Stats.ts @@ -102,6 +102,12 @@ export interface Stats { // player was killed (0 tiles) playerKilled(player: Player, tick: number): void; + // Record tiles owned at game end (final standings). + recordFinalTiles(player: Player, tiles: number | bigint): void; + + // Record that player eliminated human victim at tick (OFM kill scoring). + recordKill(player: Player, victim: Player, tick: number | bigint): void; + // Player's train arrives at any station, generating gold trainSelfTrade(player: Player, gold: number | bigint): void; diff --git a/src/core/game/StatsImpl.ts b/src/core/game/StatsImpl.ts index c2195bf72..d0a6a9bf4 100644 --- a/src/core/game/StatsImpl.ts +++ b/src/core/game/StatsImpl.ts @@ -286,6 +286,22 @@ export class StatsImpl implements Stats { this._addPlayerKilled(player, tick); } + recordFinalTiles(player: Player, tiles: BigIntLike): void { + const p = this._makePlayerStats(player); + if (p === undefined) return; + p.finalTiles = _bigint(tiles); + } + + recordKill(player: Player, victim: Player, tick: BigIntLike): void { + if (victim.type() !== PlayerType.Human) return; + const victimId = victim.clientID(); + if (victimId === null) return; + const p = this._makePlayerStats(player); + if (p === undefined) return; + p.kills ??= []; + p.kills.push({ victim: victimId, tick: _bigint(tick) }); + } + trainSelfTrade(player: Player, gold: BigIntLike): void { this._addGold(player, GOLD_INDEX_TRAIN_SELF, gold); } diff --git a/tests/Stats.test.ts b/tests/Stats.test.ts index a60f91812..863831c93 100644 --- a/tests/Stats.test.ts +++ b/tests/Stats.test.ts @@ -239,10 +239,41 @@ describe("Stats", () => { }); }); + test("recordKill", () => { + stats.recordKill(player1, player2, 30); + stats.recordKill(player1, player2, 35); + expect(stats.getPlayerStats(player1)?.kills).toStrictEqual([ + { victim: "client2", tick: 30n }, + { victim: "client2", tick: 35n }, + ]); + expect(stats.getPlayerStats(player2)?.kills).toBeUndefined(); + }); + test("stringify", () => { stats.unitLose(player1, UnitType.Port); expect(JSON.stringify(stats.stats(), replacer)).toBe( '{"client1":{"units":{"port":["0","0","0","1"]}}}', ); }); + + test("recordFinalTiles", () => { + stats.recordFinalTiles(player1, 42); + expect(stats.getPlayerStats(player1)?.finalTiles).toBe(42n); + }); + + test("setWinner snapshots finalTiles for each player", () => { + let count = 0; + game.map().forEachTile((tile) => { + if (count >= 5) return; + if (!game.map().isLand(tile)) return; + player1.conquer(tile); + count++; + }); + game.setWinner(player1, game.stats().stats()); + const tiles = player1.numTilesOwned(); + expect(tiles).toBeGreaterThan(0); + expect(game.stats().getPlayerStats(player1)?.finalTiles).toBe( + BigInt(tiles), + ); + }); });