feat(livestats): per-player killedBy + deathPosition + winner for live standings (#4593)

Enriches the admin-bot live-stats snapshot
(`/api/adminbot/game/:id/stats`) with the per-player fields a semi-live
standings board needs, so it can score kills + placement live instead of
waiting for the post-game record.

Per player in `liveStats.players[]`:
- **`killedBy`** — the eliminator's clientID (null while alive /
non-client killer). Invert → live kill points.
- **`deathPosition`** — finishing place at elimination = non-bot players
still standing + 1 (frozen at "last of the then-standing"). Null while
alive → live placement.

The **full roster** is reported (dead players are retained in the client
view), plus top-level **`winner`** (the decided winner's clientID, else
null).

Deterministic / consensus-safe: `killedBy` (stamped at `recordKill`) and
`deathPosition` (stamped in `PlayerExecution`) are pure sim values, so
in-sync clients agree on the majority vote; `winner` is added
server-side in `GameServer.liveStats()` from the winner vote (like the
existing publicID/username/connected enrichment).

- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
This commit is contained in:
Zixer1
2026-07-16 10:39:55 -07:00
committed by evanpelle
parent 0a2a85423c
commit 92a0801298
18 changed files with 179 additions and 2 deletions
@@ -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(),
},
];
})
+2
View File
@@ -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;
+8
View File
@@ -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;
}
+5
View File
@@ -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
+5
View File
@@ -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.
+11
View File
@@ -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;
}
+14
View File
@@ -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,
+4
View File
@@ -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;
+2
View File
@@ -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;
+7
View File
@@ -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(),
+6 -1
View File
@@ -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;
+15 -1
View File
@@ -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();
+6
View File
@@ -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 {
+20
View File
@@ -18,6 +18,8 @@ function makePlayerState(overrides: Partial<PlayerState> = {}): 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({
+64
View File
@@ -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();
});
});
@@ -31,6 +31,8 @@ function ps(overrides: Partial<PlayerState> = {}): PlayerState {
smallID: 1,
isAlive: true,
isDisconnected: false,
killedBy: null,
deathPosition: null,
tilesOwned: 0,
gold: 0,
troops: 0,
@@ -27,6 +27,8 @@ function ps(overrides: Partial<PlayerState> = {}): PlayerState {
smallID: 1,
isAlive: true,
isDisconnected: false,
killedBy: null,
deathPosition: null,
tilesOwned: 0,
gold: 0,
troops: 0,
+4
View File
@@ -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],