Files
OpenFrontIO/src/core/game/GameUpdates.ts
T
evanpelleandClaude Opus 4.8 be87c7658f Speed up diffPlayerUpdate with typed field comparisons
diffPlayerUpdate runs once per player per tick on the worker thread. The
array/object fields (outgoingAttacks, incomingAttacks, alliances,
outgoingEmojis) were compared via JSON.stringify — two string allocations per
field, run on every call even when nothing changed. This made the cost flat at
~3.4µs/call regardless of what actually changed.

Replace jsonEqual with three typed structural comparators (attackArrayEqual,
allianceArrayEqual, emojiArrayEqual) that short-circuit on reference/length,
compare known fields with ===, early-exit on the first difference, and
allocate nothing — matching the existing numberArrayEqual/stringArrayEqual
style. ~9-10x faster across all cases (276k -> 2.4M ops/sec when unchanged).

Add tests/perf/DiffPlayerUpdatePerf.ts (BEFORE/AFTER benchmark, run via
npm run perf) and warnings on PlayerUpdate and diffPlayerUpdate noting that new
fields must be wired into the diff/apply functions or their changes are
silently dropped after the first emission.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 15:59:58 -07:00

329 lines
7.3 KiB
TypeScript

import { AllPlayersStats, ClientID, Winner } from "../Schemas";
import {
EmojiMessage,
GameUpdates,
Gold,
MessageType,
NameViewData,
PlayerID,
PlayerType,
Team,
Tick,
TrainType,
TransportShipState,
UnitType,
WarshipState,
} from "./Game";
import { TileRef } from "./GameMap";
export interface GameUpdateViewData {
tick: number;
updates: GameUpdates;
/**
* Packed tile updates as `[tileRef, state]` uint32 pairs.
*
* `tileRef` is a `TileRef` (fits in uint32), and `state` is the packed per-tile
* state (`uint16`) stored in a `uint32` lane.
*/
packedTileUpdates: Uint32Array;
/**
* Optional packed motion plan records.
*
* When present, this buffer is expected to be transferred worker -> main
* (similar to `packedTileUpdates`) to avoid structured-clone copies.
*/
packedMotionPlans?: Uint32Array;
playerNameViewData: Record<string, NameViewData>;
tickExecutionDuration?: number;
pendingTurns?: number;
}
export interface ErrorUpdate {
errMsg: string;
stack?: string;
}
export enum GameUpdateType {
// Tile updates are delivered via `packedTileUpdates` on the outer GameUpdateViewData.
Tile,
Unit,
Player,
DisplayEvent,
DisplayChatEvent,
AllianceRequest,
AllianceRequestReply,
BrokeAlliance,
AllianceExpired,
AllianceExtension,
TargetPlayer,
Emoji,
Win,
Hash,
UnitIncoming,
BonusEvent,
RailroadDestructionEvent,
RailroadConstructionEvent,
RailroadSnapEvent,
ConquestEvent,
EmbargoEvent,
SpawnPhaseEnd,
GamePaused,
DonateEvent,
}
export type GameUpdate =
| UnitUpdate
| PlayerUpdate
| AllianceRequestUpdate
| AllianceRequestReplyUpdate
| BrokeAllianceUpdate
| AllianceExpiredUpdate
| DisplayMessageUpdate
| DisplayChatMessageUpdate
| TargetPlayerUpdate
| EmojiUpdate
| WinUpdate
| HashUpdate
| UnitIncomingUpdate
| AllianceExtensionUpdate
| BonusEventUpdate
| RailroadConstructionUpdate
| RailroadDestructionUpdate
| RailroadSnapUpdate
| ConquestUpdate
| EmbargoUpdate
| SpawnPhaseEndUpdate
| GamePausedUpdate
| DonateEventUpdate;
export interface BonusEventUpdate {
type: GameUpdateType.BonusEvent;
player: PlayerID;
tile: TileRef;
gold: number;
troops: number;
}
export interface RailroadConstructionUpdate {
type: GameUpdateType.RailroadConstructionEvent;
id: number;
tiles: TileRef[];
}
export interface RailroadDestructionUpdate {
type: GameUpdateType.RailroadDestructionEvent;
id: number;
}
export interface RailroadSnapUpdate {
type: GameUpdateType.RailroadSnapEvent;
originalId: number;
newId1: number;
newId2: number;
tiles1: TileRef[];
tiles2: TileRef[];
}
export interface ConquestUpdate {
type: GameUpdateType.ConquestEvent;
conquerorId: PlayerID;
conqueredId: PlayerID;
gold: Gold;
}
export interface DonateEventUpdate {
type: GameUpdateType.DonateEvent;
donationType: "troops" | "gold";
senderId: PlayerID;
recipientId: PlayerID;
amount: bigint;
}
export interface UnitUpdate {
type: GameUpdateType.Unit;
unitType: UnitType;
troops: number;
id: number;
ownerID: number;
lastOwnerID?: number;
// TODO: make these tilerefs
pos: TileRef;
lastPos: TileRef;
isActive: boolean;
reachedTarget: boolean;
warshipState?: WarshipState;
transportShipState?: TransportShipState;
targetable: boolean;
markedForDeletion: number | false;
targetUnitId?: number; // Only for trade ships
targetTile?: TileRef; // Only for nukes
health?: number;
underConstruction?: boolean;
missileTimerQueue: number[];
level: number;
hasTrainStation: boolean;
trainType?: TrainType; // Only for trains
loaded?: boolean; // Only for trains
}
export interface AttackUpdate {
attackerID: number;
targetID: number;
troops: number;
id: string;
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.
*
* When adding a field here, also wire it into diffPlayerUpdate() and
* applyStateUpdate() in GameUpdateUtils.ts — otherwise it is only ever sent on
* the first emission and later changes are silently dropped.
*/
export interface PlayerUpdate {
type: GameUpdateType.Player;
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;
traitorRemainingTicks?: number;
targets?: number[];
outgoingEmojis?: EmojiMessage[];
outgoingAttacks?: AttackUpdate[];
incomingAttacks?: AttackUpdate[];
outgoingAllianceRequests?: PlayerID[];
alliances?: AllianceView[];
hasSpawned?: boolean;
spawnTile?: TileRef;
betrayals?: number;
lastDeleteUnitTick?: Tick;
isLobbyCreator?: boolean;
}
export interface AllianceView {
id: number;
other: PlayerID;
createdAt: Tick;
expiresAt: Tick;
hasExtensionRequest: boolean;
}
export interface AllianceRequestUpdate {
type: GameUpdateType.AllianceRequest;
requestorID: number;
recipientID: number;
createdAt: Tick;
}
export interface AllianceRequestReplyUpdate {
type: GameUpdateType.AllianceRequestReply;
request: AllianceRequestUpdate;
accepted: boolean;
}
export interface BrokeAllianceUpdate {
type: GameUpdateType.BrokeAlliance;
traitorID: number;
betrayedID: number;
allianceID: number;
}
export interface AllianceExpiredUpdate {
type: GameUpdateType.AllianceExpired;
player1ID: number;
player2ID: number;
}
export interface AllianceExtensionUpdate {
type: GameUpdateType.AllianceExtension;
playerID: number;
allianceID: number;
}
export interface TargetPlayerUpdate {
type: GameUpdateType.TargetPlayer;
playerID: number;
targetID: number;
}
export interface EmojiUpdate {
type: GameUpdateType.Emoji;
emoji: EmojiMessage;
}
export interface DisplayMessageUpdate {
type: GameUpdateType.DisplayEvent;
message: string;
messageType: MessageType;
goldAmount?: bigint;
playerID: number | null;
params?: Record<string, string | number>;
unitID?: number;
focusPlayerID?: number;
}
export type DisplayChatMessageUpdate = {
type: GameUpdateType.DisplayChatEvent;
key: string;
category: string;
target: string | undefined;
playerID: number | null;
isFrom: boolean;
recipient: string;
};
export interface WinUpdate {
type: GameUpdateType.Win;
allPlayersStats: AllPlayersStats;
winner: Winner;
}
export interface HashUpdate {
type: GameUpdateType.Hash;
tick: Tick;
hash: number;
}
export interface UnitIncomingUpdate {
type: GameUpdateType.UnitIncoming;
unitID: number;
message: string;
messageType: MessageType;
playerID: number;
}
export interface EmbargoUpdate {
type: GameUpdateType.EmbargoEvent;
event: "start" | "stop";
playerID: number;
embargoedID: number;
}
export interface SpawnPhaseEndUpdate {
type: GameUpdateType.SpawnPhaseEnd;
startTick: Tick;
}
export interface GamePausedUpdate {
type: GameUpdateType.GamePaused;
paused: boolean;
}