mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-17 15:29:26 +00:00
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:
@@ -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(),
|
||||
},
|
||||
];
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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],
|
||||
|
||||
Reference in New Issue
Block a user