mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 06:10:42 +00:00
Cut worker→main bandwidth ~3.3× by switching PlayerUpdate to deltas (#3967)
## Description: Cut worker→main bandwidth ~3.3× by switching PlayerUpdate from a full per-tick snapshot to a field-level diff. PlayerImpl.toUpdate() now caches the last sent update and returns only changed fields, or null if nothing changed. The client-side applyStateUpdate() merges instead of overwriting. Per-tick total dropped from ~297 KB to ~89 KB; the Player bucket alone went from 258 KB/tick to 50 KB/tick. Diff/apply logic lives in a new GameUpdateUtils.ts module with unit tests. ## 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 - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: evan
This commit is contained in:
+22
-13
@@ -304,42 +304,51 @@ export class GameView implements GameMap {
|
||||
|
||||
// Pass 1: ensure every player exists with up-to-date PlayerState. We need
|
||||
// all smallIDs registered before pass 2 can translate embargo PlayerIDs.
|
||||
// PlayerUpdate is now partial: only `id` is guaranteed; everything else
|
||||
// is present only when its value changed since the last emission.
|
||||
gu.updates[GameUpdateType.Player].forEach((pu) => {
|
||||
// First-emission (new player) — must have all static fields populated.
|
||||
// Subsequent emissions for an existing player carry only changed fields.
|
||||
const existing = this._players.get(pu.id);
|
||||
|
||||
// Replace the local player's name/displayName with their own stored values.
|
||||
// This way the user does not know they are being censored.
|
||||
if (pu.clientID === this._myClientID) {
|
||||
// This way the user does not know they are being censored. clientID is
|
||||
// static — present only on first emission — so this branch only runs once.
|
||||
if (pu.clientID !== undefined && pu.clientID === this._myClientID) {
|
||||
pu.name = this._myUsername;
|
||||
pu.displayName = myDisplayName;
|
||||
}
|
||||
|
||||
this.smallIDToID.set(pu.smallID, pu.id);
|
||||
let player = this._players.get(pu.id);
|
||||
if (player !== undefined) {
|
||||
player.applyUpdate(pu);
|
||||
if (pu.smallID !== undefined) {
|
||||
this.smallIDToID.set(pu.smallID, pu.id);
|
||||
}
|
||||
|
||||
if (existing !== undefined) {
|
||||
existing.applyUpdate(pu);
|
||||
const nextNameData = gu.playerNameViewData[pu.id];
|
||||
if (nextNameData !== undefined) {
|
||||
player.nameData = nextNameData;
|
||||
existing.nameData = nextNameData;
|
||||
}
|
||||
} else {
|
||||
player = new PlayerView(
|
||||
const player = new PlayerView(
|
||||
this,
|
||||
pu,
|
||||
gu.playerNameViewData[pu.id],
|
||||
// First check human by clientID, then check nation by name.
|
||||
this._cosmetics.get(pu.clientID ?? "") ??
|
||||
this._cosmetics.get(pu.name) ??
|
||||
this._cosmetics.get(pu.name!) ??
|
||||
{},
|
||||
);
|
||||
this._players.set(pu.id, player);
|
||||
this._playerStates.set(pu.smallID, player.state);
|
||||
this._playerStates.set(pu.smallID!, player.state);
|
||||
}
|
||||
});
|
||||
|
||||
// Pass 2: translate engine embargoes (Set<PlayerID>) → renderer-format
|
||||
// stringified smallIDs. We could do this only on changes, but embargo sets
|
||||
// are typically small (<50 entries per player). Pass through all in case
|
||||
// any pu in this tick referenced a player created in this same tick.
|
||||
// smallIDs. Only re-translate when embargoes changed (field present);
|
||||
// unchanged sets stay at the previously-computed renderer-format list.
|
||||
gu.updates[GameUpdateType.Player].forEach((pu) => {
|
||||
if (pu.embargoes === undefined) return;
|
||||
const player = this._players.get(pu.id);
|
||||
if (player === undefined) return;
|
||||
const smallIDs: number[] = [];
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
UnitType,
|
||||
} from "../../core/game/Game";
|
||||
import { TileRef } from "../../core/game/GameMap";
|
||||
import { applyStateUpdate } from "../../core/game/GameUpdateUtils";
|
||||
import {
|
||||
AllianceView,
|
||||
AttackUpdate,
|
||||
@@ -50,16 +51,19 @@ function gamePlayerTypeToEnum(t: PlayerType): PlayerTypeEnum {
|
||||
}
|
||||
}
|
||||
|
||||
// First-emission updates from the engine always include every field; these
|
||||
// builders assert non-null for that contract. Subsequent diffs are partial
|
||||
// and flow through applyStateUpdate() below.
|
||||
function staticFromUpdate(pu: PlayerUpdate): PlayerStatic {
|
||||
return {
|
||||
smallID: pu.smallID,
|
||||
smallID: pu.smallID!,
|
||||
id: pu.id,
|
||||
name: pu.name,
|
||||
displayName: pu.displayName,
|
||||
clientID: pu.clientID,
|
||||
playerType: gamePlayerTypeToEnum(pu.playerType),
|
||||
name: pu.name!,
|
||||
displayName: pu.displayName!,
|
||||
clientID: pu.clientID ?? null,
|
||||
playerType: gamePlayerTypeToEnum(pu.playerType!),
|
||||
team: pu.team ?? null,
|
||||
isLobbyCreator: pu.isLobbyCreator,
|
||||
isLobbyCreator: pu.isLobbyCreator!,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -68,51 +72,28 @@ function stateFromUpdate(pu: PlayerUpdate): PlayerState {
|
||||
// smallIDs (numbers). GameView fills these in via setEmbargoes() because
|
||||
// it has the PlayerID → smallID lookup table.
|
||||
return {
|
||||
smallID: pu.smallID,
|
||||
isAlive: pu.isAlive,
|
||||
isDisconnected: pu.isDisconnected,
|
||||
tilesOwned: pu.tilesOwned,
|
||||
gold: Number(pu.gold),
|
||||
troops: pu.troops,
|
||||
isTraitor: pu.isTraitor,
|
||||
smallID: pu.smallID!,
|
||||
isAlive: pu.isAlive!,
|
||||
isDisconnected: pu.isDisconnected!,
|
||||
tilesOwned: pu.tilesOwned!,
|
||||
gold: Number(pu.gold!),
|
||||
troops: pu.troops!,
|
||||
isTraitor: pu.isTraitor!,
|
||||
traitorRemainingTicks: Math.max(0, pu.traitorRemainingTicks ?? 0),
|
||||
betrayals: pu.betrayals,
|
||||
hasSpawned: pu.hasSpawned,
|
||||
lastDeleteUnitTick: pu.lastDeleteUnitTick,
|
||||
allies: pu.allies.slice(),
|
||||
betrayals: pu.betrayals!,
|
||||
hasSpawned: pu.hasSpawned!,
|
||||
lastDeleteUnitTick: pu.lastDeleteUnitTick!,
|
||||
allies: pu.allies!.slice(),
|
||||
embargoes: [],
|
||||
targets: pu.targets.slice(),
|
||||
outgoingAttacks: pu.outgoingAttacks,
|
||||
incomingAttacks: pu.incomingAttacks,
|
||||
outgoingAllianceRequests: pu.outgoingAllianceRequests.slice(),
|
||||
alliances: pu.alliances,
|
||||
outgoingEmojis: pu.outgoingEmojis,
|
||||
targets: pu.targets!.slice(),
|
||||
outgoingAttacks: pu.outgoingAttacks!,
|
||||
incomingAttacks: pu.incomingAttacks!,
|
||||
outgoingAllianceRequests: pu.outgoingAllianceRequests!.slice(),
|
||||
alliances: pu.alliances!,
|
||||
outgoingEmojis: pu.outgoingEmojis!,
|
||||
};
|
||||
}
|
||||
|
||||
function applyStateUpdate(target: PlayerState, pu: PlayerUpdate): void {
|
||||
// smallID is identity — never changes for a given PlayerView.
|
||||
target.isAlive = pu.isAlive;
|
||||
target.isDisconnected = pu.isDisconnected;
|
||||
target.tilesOwned = pu.tilesOwned;
|
||||
target.gold = Number(pu.gold);
|
||||
target.troops = pu.troops;
|
||||
target.isTraitor = pu.isTraitor;
|
||||
target.traitorRemainingTicks = Math.max(0, pu.traitorRemainingTicks ?? 0);
|
||||
target.betrayals = pu.betrayals;
|
||||
target.hasSpawned = pu.hasSpawned;
|
||||
target.lastDeleteUnitTick = pu.lastDeleteUnitTick;
|
||||
// Slice() to detach from the wire object — accumulated state mustn't share
|
||||
// mutable arrays with per-tick update payloads.
|
||||
target.allies = pu.allies.slice();
|
||||
target.targets = pu.targets.slice();
|
||||
target.outgoingAllianceRequests = pu.outgoingAllianceRequests.slice();
|
||||
target.outgoingAttacks = pu.outgoingAttacks;
|
||||
target.incomingAttacks = pu.incomingAttacks;
|
||||
target.alliances = pu.alliances;
|
||||
target.outgoingEmojis = pu.outgoingEmojis;
|
||||
}
|
||||
|
||||
export class PlayerView {
|
||||
public anonymousName: string | null = null;
|
||||
private decoder?: PatternDecoder;
|
||||
@@ -144,10 +125,11 @@ export class PlayerView {
|
||||
this.state = stateFromUpdate(data);
|
||||
this.static = staticFromUpdate(data);
|
||||
|
||||
// First emission always carries name + playerType (see staticFromUpdate).
|
||||
if (data.clientID === game.myClientID()) {
|
||||
this.anonymousName = data.name;
|
||||
this.anonymousName = data.name!;
|
||||
} else {
|
||||
this.anonymousName = createRandomName(data.name, data.playerType);
|
||||
this.anonymousName = createRandomName(data.name!, data.playerType!);
|
||||
}
|
||||
|
||||
const theme = this.game.config().theme();
|
||||
|
||||
@@ -828,7 +828,7 @@ export interface Player {
|
||||
executeRetreat(attackID: string): void;
|
||||
|
||||
// Misc
|
||||
toUpdate(): PlayerUpdate;
|
||||
toUpdate(): PlayerUpdate | null;
|
||||
playerProfile(): PlayerProfile;
|
||||
// WARNING: this operation is expensive.
|
||||
bestTransportShipSpawn(tile: TileRef): TileRef | false;
|
||||
|
||||
@@ -455,8 +455,8 @@ export class GameImpl implements Game {
|
||||
this.execs.push(...inited);
|
||||
this.unInitExecs = unInited;
|
||||
for (const player of this._players.values()) {
|
||||
// Players change each to so always add them
|
||||
this.addUpdate(player.toUpdate());
|
||||
const update = player.toUpdate();
|
||||
if (update !== null) this.addUpdate(update);
|
||||
}
|
||||
if (this.ticks() % 10 === 0) {
|
||||
this.addUpdate({
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import type { PlayerState } from "../../client/render/types";
|
||||
import { 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 `===`.
|
||||
*/
|
||||
export function diffPlayerUpdate(
|
||||
prev: PlayerUpdate,
|
||||
next: PlayerUpdate,
|
||||
): PlayerUpdate | 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("tilesOwned", prev.tilesOwned === next.tilesOwned);
|
||||
setIfDifferent("gold", prev.gold === next.gold);
|
||||
setIfDifferent("troops", prev.troops === next.troops);
|
||||
setIfDifferent("isTraitor", prev.isTraitor === next.isTraitor);
|
||||
setIfDifferent(
|
||||
"traitorRemainingTicks",
|
||||
prev.traitorRemainingTicks === next.traitorRemainingTicks,
|
||||
);
|
||||
setIfDifferent("hasSpawned", prev.hasSpawned === next.hasSpawned);
|
||||
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",
|
||||
jsonEqual(prev.outgoingEmojis, next.outgoingEmojis),
|
||||
);
|
||||
setIfDifferent(
|
||||
"outgoingAttacks",
|
||||
jsonEqual(prev.outgoingAttacks, next.outgoingAttacks),
|
||||
);
|
||||
setIfDifferent(
|
||||
"incomingAttacks",
|
||||
jsonEqual(prev.incomingAttacks, next.incomingAttacks),
|
||||
);
|
||||
setIfDifferent("alliances", jsonEqual(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.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.betrayals !== undefined) target.betrayals = pu.betrayals;
|
||||
if (pu.hasSpawned !== undefined) target.hasSpawned = pu.hasSpawned;
|
||||
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;
|
||||
}
|
||||
|
||||
function jsonEqual(a: unknown, b: unknown): boolean {
|
||||
if (a === b) return true;
|
||||
return JSON.stringify(a) === JSON.stringify(b);
|
||||
}
|
||||
@@ -164,35 +164,43 @@ export interface AttackUpdate {
|
||||
retreating: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Player snapshot delivered worker -> main thread.
|
||||
*
|
||||
* Only `type` and `id` are guaranteed. Every other field is omitted when its
|
||||
* value matches the previous emission for the same player. The first emission
|
||||
* for a player always includes all fields; consumers must handle subsequent
|
||||
* partial updates by merging into local state, not overwriting.
|
||||
*/
|
||||
export interface PlayerUpdate {
|
||||
type: GameUpdateType.Player;
|
||||
nameViewData?: NameViewData;
|
||||
clientID: ClientID | null;
|
||||
name: string;
|
||||
displayName: string;
|
||||
id: PlayerID;
|
||||
nameViewData?: NameViewData;
|
||||
clientID?: ClientID | null;
|
||||
name?: string;
|
||||
displayName?: string;
|
||||
team?: Team;
|
||||
smallID: number;
|
||||
playerType: PlayerType;
|
||||
isAlive: boolean;
|
||||
isDisconnected: boolean;
|
||||
tilesOwned: number;
|
||||
gold: Gold;
|
||||
troops: number;
|
||||
allies: number[];
|
||||
embargoes: Set<PlayerID>;
|
||||
isTraitor: boolean;
|
||||
smallID?: number;
|
||||
playerType?: PlayerType;
|
||||
isAlive?: boolean;
|
||||
isDisconnected?: boolean;
|
||||
tilesOwned?: number;
|
||||
gold?: Gold;
|
||||
troops?: number;
|
||||
allies?: number[];
|
||||
embargoes?: Set<PlayerID>;
|
||||
isTraitor?: boolean;
|
||||
traitorRemainingTicks?: number;
|
||||
targets: number[];
|
||||
outgoingEmojis: EmojiMessage[];
|
||||
outgoingAttacks: AttackUpdate[];
|
||||
incomingAttacks: AttackUpdate[];
|
||||
outgoingAllianceRequests: PlayerID[];
|
||||
alliances: AllianceView[];
|
||||
hasSpawned: boolean;
|
||||
betrayals: number;
|
||||
lastDeleteUnitTick: Tick;
|
||||
isLobbyCreator: boolean;
|
||||
targets?: number[];
|
||||
outgoingEmojis?: EmojiMessage[];
|
||||
outgoingAttacks?: AttackUpdate[];
|
||||
incomingAttacks?: AttackUpdate[];
|
||||
outgoingAllianceRequests?: PlayerID[];
|
||||
alliances?: AllianceView[];
|
||||
hasSpawned?: boolean;
|
||||
betrayals?: number;
|
||||
lastDeleteUnitTick?: Tick;
|
||||
isLobbyCreator?: boolean;
|
||||
}
|
||||
|
||||
export interface AllianceView {
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
} from "./Game";
|
||||
import { GameImpl } from "./GameImpl";
|
||||
import { andFN, manhattanDistFN, TileRef } from "./GameMap";
|
||||
import { diffPlayerUpdate } from "./GameUpdateUtils";
|
||||
import {
|
||||
AllianceView,
|
||||
AttackUpdate,
|
||||
@@ -105,6 +106,13 @@ export class PlayerImpl implements Player {
|
||||
private _spawnTile: TileRef | undefined;
|
||||
private _isDisconnected = false;
|
||||
|
||||
/**
|
||||
* Last PlayerUpdate emitted for this player on the worker→main channel.
|
||||
* Used by GameImpl's tick loop to compute field-level diffs. Undefined on
|
||||
* first emission (full snapshot sent).
|
||||
*/
|
||||
public lastSentUpdate: PlayerUpdate | undefined;
|
||||
|
||||
constructor(
|
||||
private mg: GameImpl,
|
||||
private _smallID: number,
|
||||
@@ -119,7 +127,24 @@ export class PlayerImpl implements Player {
|
||||
|
||||
largestClusterBoundingBox: { min: Cell; max: Cell } | null;
|
||||
|
||||
toUpdate(): PlayerUpdate {
|
||||
/**
|
||||
* Build a PlayerUpdate for the worker→main wire.
|
||||
*
|
||||
* The first call for a player returns the full snapshot. Subsequent calls
|
||||
* return only fields that changed since the previous call (a partial
|
||||
* `{ type, id, ...changedFields }`), or `null` if nothing changed.
|
||||
*
|
||||
* `lastSentUpdate` is updated to the full snapshot on every call.
|
||||
*/
|
||||
toUpdate(): PlayerUpdate | null {
|
||||
const full = this.toFullUpdate();
|
||||
const prev = this.lastSentUpdate;
|
||||
this.lastSentUpdate = full;
|
||||
if (prev === undefined) return full;
|
||||
return diffPlayerUpdate(prev, full);
|
||||
}
|
||||
|
||||
private toFullUpdate(): PlayerUpdate {
|
||||
const outgoingAllianceRequests = this.outgoingAllianceRequests().map((ar) =>
|
||||
ar.recipient().id(),
|
||||
);
|
||||
|
||||
@@ -80,7 +80,7 @@ describe("Disconnected", () => {
|
||||
test("should include disconnected state in player update", () => {
|
||||
player1.markDisconnected(true);
|
||||
const update = player1.toUpdate();
|
||||
expect(update.isDisconnected).toBe(true);
|
||||
expect(update?.isDisconnected).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -153,8 +153,9 @@ describe("Disconnected", () => {
|
||||
test("should maintain disconnected state in player updates across ticks", () => {
|
||||
player1.markDisconnected(true);
|
||||
executeTicks(game, 3);
|
||||
const update = player1.toUpdate();
|
||||
expect(update.isDisconnected).toBe(true);
|
||||
// toUpdate() returns diffs after the first call, so query engine state
|
||||
// directly rather than the wire payload (which only carries changed fields).
|
||||
expect(player1.isDisconnected()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { PlayerState } from "../src/client/render/types";
|
||||
import { PlayerType } from "../src/core/game/Game";
|
||||
import {
|
||||
applyStateUpdate,
|
||||
diffPlayerUpdate,
|
||||
} from "../src/core/game/GameUpdateUtils";
|
||||
import { GameUpdateType, PlayerUpdate } from "../src/core/game/GameUpdates";
|
||||
import { makePlayerUpdate } from "./util/viewStubs";
|
||||
|
||||
function makePlayerState(overrides: Partial<PlayerState> = {}): PlayerState {
|
||||
return {
|
||||
smallID: 1,
|
||||
isAlive: true,
|
||||
isDisconnected: false,
|
||||
tilesOwned: 0,
|
||||
gold: 0,
|
||||
troops: 100,
|
||||
isTraitor: false,
|
||||
traitorRemainingTicks: 0,
|
||||
betrayals: 0,
|
||||
hasSpawned: true,
|
||||
lastDeleteUnitTick: 0,
|
||||
allies: [],
|
||||
embargoes: [],
|
||||
targets: [],
|
||||
outgoingAttacks: [],
|
||||
incomingAttacks: [],
|
||||
outgoingAllianceRequests: [],
|
||||
alliances: [],
|
||||
outgoingEmojis: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("diffPlayerUpdate", () => {
|
||||
it("returns null when prev and next are identical", () => {
|
||||
const prev = makePlayerUpdate();
|
||||
const next = makePlayerUpdate();
|
||||
expect(diffPlayerUpdate(prev, next)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns a diff with only changed primitives plus type+id", () => {
|
||||
const prev = makePlayerUpdate({ gold: 100n });
|
||||
const next = makePlayerUpdate({ gold: 250n });
|
||||
const diff = diffPlayerUpdate(prev, next);
|
||||
expect(diff).not.toBeNull();
|
||||
expect(diff).toEqual({
|
||||
type: GameUpdateType.Player,
|
||||
id: "player-a",
|
||||
gold: 250n,
|
||||
});
|
||||
});
|
||||
|
||||
it("includes every changed primitive in a single diff", () => {
|
||||
const prev = makePlayerUpdate({ gold: 100n, troops: 50, tilesOwned: 5 });
|
||||
const next = makePlayerUpdate({ gold: 200n, troops: 75, tilesOwned: 5 });
|
||||
const diff = diffPlayerUpdate(prev, next)!;
|
||||
expect(diff.gold).toBe(200n);
|
||||
expect(diff.troops).toBe(75);
|
||||
expect(diff.tilesOwned).toBeUndefined();
|
||||
});
|
||||
|
||||
it("detects allies array additions", () => {
|
||||
const prev = makePlayerUpdate({ allies: [2, 3] });
|
||||
const next = makePlayerUpdate({ allies: [2, 3, 4] });
|
||||
const diff = diffPlayerUpdate(prev, next)!;
|
||||
expect(diff.allies).toEqual([2, 3, 4]);
|
||||
});
|
||||
|
||||
it("ignores allies array when contents are equal (different identity)", () => {
|
||||
const prev = makePlayerUpdate({ allies: [2, 3] });
|
||||
const next = makePlayerUpdate({ allies: [2, 3] });
|
||||
expect(diffPlayerUpdate(prev, next)).toBeNull();
|
||||
});
|
||||
|
||||
it("treats allies reorder as a change (order is significant)", () => {
|
||||
const prev = makePlayerUpdate({ allies: [2, 3] });
|
||||
const next = makePlayerUpdate({ allies: [3, 2] });
|
||||
const diff = diffPlayerUpdate(prev, next)!;
|
||||
expect(diff.allies).toEqual([3, 2]);
|
||||
});
|
||||
|
||||
it("detects embargo set membership changes", () => {
|
||||
const prev = makePlayerUpdate({ embargoes: new Set(["x", "y"]) });
|
||||
const next = makePlayerUpdate({ embargoes: new Set(["x", "y", "z"]) });
|
||||
const diff = diffPlayerUpdate(prev, next)!;
|
||||
expect(diff.embargoes).toEqual(new Set(["x", "y", "z"]));
|
||||
});
|
||||
|
||||
it("ignores embargo set when membership is equal regardless of object identity", () => {
|
||||
const prev = makePlayerUpdate({ embargoes: new Set(["x", "y"]) });
|
||||
const next = makePlayerUpdate({ embargoes: new Set(["y", "x"]) });
|
||||
expect(diffPlayerUpdate(prev, next)).toBeNull();
|
||||
});
|
||||
|
||||
it("detects outgoingAttacks element changes", () => {
|
||||
const prev = makePlayerUpdate({
|
||||
outgoingAttacks: [
|
||||
{ attackerID: 1, targetID: 2, troops: 10, id: "a", retreating: false },
|
||||
],
|
||||
});
|
||||
const next = makePlayerUpdate({
|
||||
outgoingAttacks: [
|
||||
{ attackerID: 1, targetID: 2, troops: 20, id: "a", retreating: false },
|
||||
],
|
||||
});
|
||||
const diff = diffPlayerUpdate(prev, next)!;
|
||||
expect(diff.outgoingAttacks).toEqual(next.outgoingAttacks);
|
||||
});
|
||||
|
||||
it("detects alliance list changes", () => {
|
||||
const prev = makePlayerUpdate({ alliances: [] });
|
||||
const next = makePlayerUpdate({
|
||||
alliances: [
|
||||
{
|
||||
id: 1,
|
||||
other: "player-b",
|
||||
createdAt: 10,
|
||||
expiresAt: 110,
|
||||
hasExtensionRequest: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
const diff = diffPlayerUpdate(prev, next)!;
|
||||
expect(diff.alliances).toEqual(next.alliances);
|
||||
});
|
||||
|
||||
it("treats undefined→number transition as a change", () => {
|
||||
const prev = makePlayerUpdate({ traitorRemainingTicks: undefined });
|
||||
const next = makePlayerUpdate({ traitorRemainingTicks: 5 });
|
||||
const diff = diffPlayerUpdate(prev, next)!;
|
||||
expect(diff.traitorRemainingTicks).toBe(5);
|
||||
});
|
||||
|
||||
it("treats number→undefined transition as a change", () => {
|
||||
const prev = makePlayerUpdate({ traitorRemainingTicks: 5 });
|
||||
const next = makePlayerUpdate({ traitorRemainingTicks: undefined });
|
||||
const diff = diffPlayerUpdate(prev, next);
|
||||
expect(diff).not.toBeNull();
|
||||
expect("traitorRemainingTicks" in diff!).toBe(true);
|
||||
expect(diff!.traitorRemainingTicks).toBeUndefined();
|
||||
});
|
||||
|
||||
it("always includes type and id on a non-null diff", () => {
|
||||
const prev = makePlayerUpdate({ gold: 100n });
|
||||
const next = makePlayerUpdate({ gold: 200n });
|
||||
const diff = diffPlayerUpdate(prev, next)!;
|
||||
expect(diff.type).toBe(GameUpdateType.Player);
|
||||
expect(diff.id).toBe(next.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyStateUpdate", () => {
|
||||
it("applies every field from a full update", () => {
|
||||
const target = makePlayerState();
|
||||
const pu = makePlayerUpdate({
|
||||
gold: 500n,
|
||||
troops: 999,
|
||||
tilesOwned: 42,
|
||||
allies: [7, 8],
|
||||
targets: [9],
|
||||
outgoingAllianceRequests: ["player-b"],
|
||||
isAlive: false,
|
||||
isTraitor: true,
|
||||
traitorRemainingTicks: 3,
|
||||
betrayals: 2,
|
||||
hasSpawned: true,
|
||||
lastDeleteUnitTick: 50,
|
||||
});
|
||||
applyStateUpdate(target, pu);
|
||||
expect(target.gold).toBe(500);
|
||||
expect(target.troops).toBe(999);
|
||||
expect(target.tilesOwned).toBe(42);
|
||||
expect(target.allies).toEqual([7, 8]);
|
||||
expect(target.targets).toEqual([9]);
|
||||
expect(target.outgoingAllianceRequests).toEqual(["player-b"]);
|
||||
expect(target.isAlive).toBe(false);
|
||||
expect(target.isTraitor).toBe(true);
|
||||
expect(target.traitorRemainingTicks).toBe(3);
|
||||
expect(target.betrayals).toBe(2);
|
||||
expect(target.lastDeleteUnitTick).toBe(50);
|
||||
});
|
||||
|
||||
it("converts bigint gold to number", () => {
|
||||
const target = makePlayerState({ gold: 0 });
|
||||
applyStateUpdate(target, {
|
||||
type: GameUpdateType.Player,
|
||||
id: "p",
|
||||
gold: 9_999_999_999n,
|
||||
});
|
||||
expect(target.gold).toBe(9_999_999_999);
|
||||
expect(typeof target.gold).toBe("number");
|
||||
});
|
||||
|
||||
it("clamps negative traitorRemainingTicks to zero", () => {
|
||||
const target = makePlayerState({ traitorRemainingTicks: 5 });
|
||||
applyStateUpdate(target, {
|
||||
type: GameUpdateType.Player,
|
||||
id: "p",
|
||||
traitorRemainingTicks: -10,
|
||||
});
|
||||
expect(target.traitorRemainingTicks).toBe(0);
|
||||
});
|
||||
|
||||
it("only mutates fields present on the partial update", () => {
|
||||
const target = makePlayerState({ gold: 100, troops: 50, tilesOwned: 7 });
|
||||
const partial: PlayerUpdate = {
|
||||
type: GameUpdateType.Player,
|
||||
id: "p",
|
||||
gold: 200n,
|
||||
};
|
||||
applyStateUpdate(target, partial);
|
||||
expect(target.gold).toBe(200);
|
||||
expect(target.troops).toBe(50);
|
||||
expect(target.tilesOwned).toBe(7);
|
||||
});
|
||||
|
||||
it("leaves array fields untouched when omitted", () => {
|
||||
const original = [1, 2, 3];
|
||||
const target = makePlayerState({ allies: original });
|
||||
applyStateUpdate(target, { type: GameUpdateType.Player, id: "p" });
|
||||
expect(target.allies).toBe(original);
|
||||
});
|
||||
|
||||
it("detaches array fields by slicing (no shared reference with wire payload)", () => {
|
||||
const wireAllies = [1, 2, 3];
|
||||
const wireTargets = [9];
|
||||
const wireRequests = ["player-b"];
|
||||
const target = makePlayerState();
|
||||
applyStateUpdate(target, {
|
||||
type: GameUpdateType.Player,
|
||||
id: "p",
|
||||
allies: wireAllies,
|
||||
targets: wireTargets,
|
||||
outgoingAllianceRequests: wireRequests,
|
||||
});
|
||||
expect(target.allies).toEqual(wireAllies);
|
||||
expect(target.allies).not.toBe(wireAllies);
|
||||
expect(target.targets).not.toBe(wireTargets);
|
||||
expect(target.outgoingAllianceRequests).not.toBe(wireRequests);
|
||||
});
|
||||
|
||||
it("does not touch smallID even when present (identity field)", () => {
|
||||
const target = makePlayerState({ smallID: 42 });
|
||||
applyStateUpdate(target, {
|
||||
type: GameUpdateType.Player,
|
||||
id: "p",
|
||||
smallID: 999,
|
||||
});
|
||||
expect(target.smallID).toBe(42);
|
||||
});
|
||||
|
||||
it("merges several partial updates into a cumulative state", () => {
|
||||
const target = makePlayerState();
|
||||
applyStateUpdate(target, {
|
||||
type: GameUpdateType.Player,
|
||||
id: "p",
|
||||
gold: 100n,
|
||||
});
|
||||
applyStateUpdate(target, {
|
||||
type: GameUpdateType.Player,
|
||||
id: "p",
|
||||
troops: 250,
|
||||
});
|
||||
applyStateUpdate(target, {
|
||||
type: GameUpdateType.Player,
|
||||
id: "p",
|
||||
isAlive: false,
|
||||
});
|
||||
expect(target.gold).toBe(100);
|
||||
expect(target.troops).toBe(250);
|
||||
expect(target.isAlive).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("diff + apply round-trip", () => {
|
||||
it("emitting full first + diff second reconstructs final state", () => {
|
||||
const v0 = makePlayerUpdate({
|
||||
gold: 0n,
|
||||
troops: 100,
|
||||
tilesOwned: 0,
|
||||
allies: [],
|
||||
});
|
||||
const v1 = makePlayerUpdate({
|
||||
gold: 200n,
|
||||
troops: 150,
|
||||
tilesOwned: 5,
|
||||
allies: [2],
|
||||
});
|
||||
|
||||
// Initial state: receiver applies the full update.
|
||||
const target = makePlayerState();
|
||||
applyStateUpdate(target, v0);
|
||||
|
||||
// Subsequent tick: emitter sends only the diff.
|
||||
const diff = diffPlayerUpdate(v0, v1)!;
|
||||
expect(diff).not.toBeNull();
|
||||
applyStateUpdate(target, diff);
|
||||
|
||||
expect(target.gold).toBe(200);
|
||||
expect(target.troops).toBe(150);
|
||||
expect(target.tilesOwned).toBe(5);
|
||||
expect(target.allies).toEqual([2]);
|
||||
});
|
||||
|
||||
it("no-change tick produces null diff so receiver state is untouched", () => {
|
||||
const v0 = makePlayerUpdate({ gold: 100n, playerType: PlayerType.Human });
|
||||
const v1 = makePlayerUpdate({ gold: 100n, playerType: PlayerType.Human });
|
||||
expect(diffPlayerUpdate(v0, v1)).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user