Files
OpenFrontIO/src/core/game/GameUpdateUtils.ts
T
Zixer1andGitHub f6e1b8fbae 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
2026-07-15 17:20:30 -07:00

325 lines
12 KiB
TypeScript

import type { PlayerState } from "../../client/render/types";
import type { EmojiMessage } from "./Game";
import {
AllianceView,
AttackUpdate,
GameUpdateType,
PlayerUpdate,
} from "./GameUpdates";
/**
* Build a partial PlayerUpdate containing only fields whose value differs
* between `prev` and `next`. Returns null if nothing changed.
*
* `type` and `id` are always included on the returned diff. Array/object
* fields are compared by structural equality (length + per-element);
* `embargoes` is compared as a set; primitive fields by `===`.
*
* WARNING: this diff is field-by-field by design (no JSON.stringify, for
* perf — see tests/perf/DiffPlayerUpdatePerf.ts). When you add a field to
* PlayerUpdate, you MUST add a matching setIfDifferent(...) line here, and an
* apply line in applyStateUpdate below. A field missing here is never diffed,
* so its changes silently never reach the main thread after the first update.
*
* EXCEPTION: tilesOwned / gold / troops are deliberately NOT diffed here.
* They change for nearly every alive player every tick, so they travel on
* the transferable `GameUpdateViewData.packedPlayerUpdates` channel instead
* (see PlayerImpl.toUpdate) and appear in PlayerUpdate objects only on a
* player's first (full) emission.
*/
export function diffPlayerUpdate(
prev: PlayerUpdate,
next: PlayerUpdate,
): PlayerUpdate | null {
// Fast path: this runs for every player every tick and usually finds no
// changes (tilesOwned/gold/troops travel separately) — return without
// allocating anything. The comparisons repeat below only for the rare
// changed player.
if (
prev.clientID === next.clientID &&
prev.name === next.name &&
prev.displayName === next.displayName &&
prev.team === next.team &&
prev.smallID === next.smallID &&
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 &&
prev.markedDoomsdayClockTick === next.markedDoomsdayClockTick &&
prev.hasSpawned === next.hasSpawned &&
prev.spawnTile === next.spawnTile &&
prev.betrayals === next.betrayals &&
prev.lastDeleteUnitTick === next.lastDeleteUnitTick &&
prev.isLobbyCreator === next.isLobbyCreator &&
numberArrayEqual(prev.allies, next.allies) &&
numberArrayEqual(prev.targets, next.targets) &&
stringArrayEqual(
prev.outgoingAllianceRequests,
next.outgoingAllianceRequests,
) &&
stringSetEqual(prev.embargoes, next.embargoes) &&
emojiArrayEqual(prev.outgoingEmojis, next.outgoingEmojis) &&
attackArrayMembershipEqual(prev.outgoingAttacks, next.outgoingAttacks) &&
attackArrayMembershipEqual(prev.incomingAttacks, next.incomingAttacks) &&
allianceArrayEqual(prev.alliances, next.alliances)
) {
return null;
}
const diff: PlayerUpdate = { type: GameUpdateType.Player, id: next.id };
let changed = false;
const setIfDifferent = <K extends keyof PlayerUpdate>(
key: K,
equal: boolean,
) => {
if (!equal) {
(diff[key] as PlayerUpdate[K]) = next[key] as PlayerUpdate[K];
changed = true;
}
};
setIfDifferent("clientID", prev.clientID === next.clientID);
setIfDifferent("name", prev.name === next.name);
setIfDifferent("displayName", prev.displayName === next.displayName);
setIfDifferent("team", prev.team === next.team);
setIfDifferent("smallID", prev.smallID === next.smallID);
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(
"traitorRemainingTicks",
prev.traitorRemainingTicks === next.traitorRemainingTicks,
);
setIfDifferent(
"inDoomsdayClock",
prev.inDoomsdayClock === next.inDoomsdayClock,
);
setIfDifferent(
"markedDoomsdayClockTick",
prev.markedDoomsdayClockTick === next.markedDoomsdayClockTick,
);
setIfDifferent("hasSpawned", prev.hasSpawned === next.hasSpawned);
setIfDifferent("spawnTile", prev.spawnTile === next.spawnTile);
setIfDifferent("betrayals", prev.betrayals === next.betrayals);
setIfDifferent(
"lastDeleteUnitTick",
prev.lastDeleteUnitTick === next.lastDeleteUnitTick,
);
setIfDifferent("isLobbyCreator", prev.isLobbyCreator === next.isLobbyCreator);
setIfDifferent("allies", numberArrayEqual(prev.allies, next.allies));
setIfDifferent("targets", numberArrayEqual(prev.targets, next.targets));
setIfDifferent(
"outgoingAllianceRequests",
stringArrayEqual(
prev.outgoingAllianceRequests,
next.outgoingAllianceRequests,
),
);
setIfDifferent("embargoes", stringSetEqual(prev.embargoes, next.embargoes));
setIfDifferent(
"outgoingEmojis",
emojiArrayEqual(prev.outgoingEmojis, next.outgoingEmojis),
);
// Attack arrays are compared WITHOUT troop counts: troops change every
// tick for every active attack and travel via packedAttackUpdates (see
// packAttackTroopDeltas below). The arrays are only resent when
// membership/order/retreating changes.
setIfDifferent(
"outgoingAttacks",
attackArrayMembershipEqual(prev.outgoingAttacks, next.outgoingAttacks),
);
setIfDifferent(
"incomingAttacks",
attackArrayMembershipEqual(prev.incomingAttacks, next.incomingAttacks),
);
setIfDifferent(
"alliances",
allianceArrayEqual(prev.alliances, next.alliances),
);
return changed ? diff : null;
}
/**
* Merge a partial PlayerUpdate into a long-lived PlayerState in place.
*
* Only fields present on `pu` are applied; `undefined` means "no change since
* last emission". The first emission per player carries every field, so the
* target state is fully populated after one merge of the initial update.
*/
export function applyStateUpdate(target: PlayerState, pu: PlayerUpdate): void {
// smallID is identity — never changes for a given player.
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;
if (pu.isTraitor !== undefined) target.isTraitor = pu.isTraitor;
if (pu.traitorRemainingTicks !== undefined) {
target.traitorRemainingTicks = Math.max(0, pu.traitorRemainingTicks);
}
if (pu.inDoomsdayClock !== undefined)
target.inDoomsdayClock = pu.inDoomsdayClock;
if (pu.markedDoomsdayClockTick !== undefined) {
target.markedDoomsdayClockTick = pu.markedDoomsdayClockTick;
}
if (pu.betrayals !== undefined) target.betrayals = pu.betrayals;
if (pu.hasSpawned !== undefined) target.hasSpawned = pu.hasSpawned;
if (pu.spawnTile !== undefined) target.spawnTile = pu.spawnTile;
if (pu.lastDeleteUnitTick !== undefined) {
target.lastDeleteUnitTick = pu.lastDeleteUnitTick;
}
// Slice() to detach from the wire object — accumulated state mustn't share
// mutable arrays with per-tick update payloads.
if (pu.allies !== undefined) target.allies = pu.allies.slice();
if (pu.targets !== undefined) target.targets = pu.targets.slice();
if (pu.outgoingAllianceRequests !== undefined) {
target.outgoingAllianceRequests = pu.outgoingAllianceRequests.slice();
}
if (pu.outgoingAttacks !== undefined) {
target.outgoingAttacks = pu.outgoingAttacks;
}
if (pu.incomingAttacks !== undefined) {
target.incomingAttacks = pu.incomingAttacks;
}
if (pu.alliances !== undefined) target.alliances = pu.alliances;
if (pu.outgoingEmojis !== undefined)
target.outgoingEmojis = pu.outgoingEmojis;
}
function numberArrayEqual(a?: number[], b?: number[]): boolean {
if (a === b) return true;
if (!a || !b) return false;
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
return true;
}
function stringArrayEqual(a?: string[], b?: string[]): boolean {
if (a === b) return true;
if (!a || !b) return false;
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
return true;
}
function stringSetEqual(a?: Set<string>, b?: Set<string>): boolean {
if (a === b) return true;
if (!a || !b) return false;
if (a.size !== b.size) return false;
for (const v of a) if (!b.has(v)) return false;
return true;
}
/**
* Attack-array equality ignoring troop counts: same attacks, same order,
* same retreating flags. When this holds, only troop counts can differ, and
* those travel as packed quads (packAttackTroopDeltas) addressed by index —
* which stays valid precisely because any membership/order change makes
* this false and resends the whole array.
*/
function attackArrayMembershipEqual(
a?: AttackUpdate[],
b?: AttackUpdate[],
): boolean {
if (a === b) return true;
if (!a || !b) return false;
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
const x = a[i];
const y = b[i];
if (
x.attackerID !== y.attackerID ||
x.targetID !== y.targetID ||
x.id !== y.id ||
x.retreating !== y.retreating
) {
return false;
}
}
return true;
}
/**
* Direction lane of a `packedAttackUpdates` quad: which of the owner's attack
* arrays the index addresses. Encoder (PlayerImpl.toUpdate →
* packAttackTroopDeltas) and decoder (client GameView.update) must both use
* these.
*/
export const ATTACK_DELTA_OUTGOING = 0;
export const ATTACK_DELTA_INCOMING = 1;
/**
* Push a `[ownerSmallID, direction, index, troops]` quad onto `out` for each
* attack whose troop count changed between `prev` and `next`. No-op when the
* arrays are not membership-equal — diffPlayerUpdate resends the whole array
* that tick (carrying fresh troop counts), so patches would be redundant and
* their indexes unreliable.
*/
export function packAttackTroopDeltas(
prev: AttackUpdate[] | undefined,
next: AttackUpdate[] | undefined,
ownerSmallID: number,
direction: typeof ATTACK_DELTA_OUTGOING | typeof ATTACK_DELTA_INCOMING,
out: number[],
): void {
if (prev === next || !prev || !next) return;
if (!attackArrayMembershipEqual(prev, next)) return;
for (let i = 0; i < next.length; i++) {
if (prev[i].troops !== next[i].troops) {
out.push(ownerSmallID, direction, i, next[i].troops);
}
}
}
function allianceArrayEqual(a?: AllianceView[], b?: AllianceView[]): boolean {
if (a === b) return true;
if (!a || !b) return false;
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
const x = a[i];
const y = b[i];
if (
x.id !== y.id ||
x.other !== y.other ||
x.createdAt !== y.createdAt ||
x.expiresAt !== y.expiresAt ||
x.hasExtensionRequest !== y.hasExtensionRequest
) {
return false;
}
}
return true;
}
function emojiArrayEqual(a?: EmojiMessage[], b?: EmojiMessage[]): boolean {
if (a === b) return true;
if (!a || !b) return false;
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
const x = a[i];
const y = b[i];
if (
x.message !== y.message ||
x.senderID !== y.senderID ||
x.recipientID !== y.recipientID ||
x.createdAt !== y.createdAt
) {
return false;
}
}
return true;
}