Ofm tournament - Log Final standings and Per-Kill eliminations (#4350)

**Add approved & assigned issue number here:**

Resolves #4349

## Description:

The infra related PR is linked to this one and would need to be pushed
first (376)

Two changes for organized/tournament matches:

1. **Final standings.** `setWinner` snapshots each player's tiles owned
at game end into `PlayerStats` (`finalTiles`). It's a deterministic
integer captured in the sim, so it's replay-safe and rides into the
existing game record. This lets standings be derived directly (winner,
then surviving players by territory, then eliminated players by when
they died) without re-simulating, which matters because a domination win
ends with many players still alive.

2. **Per-kill log**. Records, per player, which humans they eliminated
and at what tick (kills on PlayerStats). This lets standings attribute
each kill to the victim's final placement, and gives a deterministic
kill graph for integrity review. Hooked once in conquerPlayer (the
single elimination funnel), humans only. Additive optional field that
rides the existing game record, no archive or wire changes.

These are off by default with no effect on normal play.

## Please complete the following:

- [x] I have added screenshots for all UI updates (no UI changes in this
PR)
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file (no new user-facing text)
- [x] I have added relevant tests to the test directory

## Please put your Discord username so you can be contacted if a bug or
regression is found:

zixer._
This commit is contained in:
Zixer1
2026-06-19 15:27:20 -04:00
committed by GitHub
parent ff5eb78689
commit 6e892839e8
5 changed files with 66 additions and 0 deletions
+6
View File
@@ -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(),
+7
View File
@@ -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(),
+6
View File
@@ -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;
+16
View File
@@ -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);
}
+31
View File
@@ -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),
);
});
});