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
@@ -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
@@ -59,6 +59,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!,
@@ -454,6 +456,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;
}