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
+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],