Files
OpenFrontIO/tests/LiveStandingsFields.test.ts
T
Zixer1andevanpelle 92a0801298 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
2026-07-16 10:39:55 -07:00

65 lines
2.3 KiB
TypeScript

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();
});
});