mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-13 22:57:09 +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();
|
||||
|
||||
Reference in New Issue
Block a user