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

## Description:

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).

## Please complete the following:

- [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-15 17:20:30 -07:00
committed by GitHub
parent a639a6aa6b
commit f6e1b8fbae
18 changed files with 181 additions and 2 deletions
+5
View File
@@ -813,6 +813,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";
@@ -68,6 +69,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
@@ -1304,6 +1304,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,
+6
View File
@@ -44,6 +44,8 @@ export function diffPlayerUpdate(
prev.playerType === next.playerType &&
prev.isAlive === next.isAlive &&
prev.isDisconnected === next.isDisconnected &&
prev.killedBy === next.killedBy &&
prev.deathPosition === next.deathPosition &&
prev.isTraitor === next.isTraitor &&
prev.traitorRemainingTicks === next.traitorRemainingTicks &&
prev.inDoomsdayClock === next.inDoomsdayClock &&
@@ -89,6 +91,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(
@@ -157,6 +161,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
@@ -307,6 +307,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(),
@@ -318,6 +323,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();