mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-30 19:02:44 +00:00
05181d7479
Added src/core/worker/SharedTileRing.ts, which defines a SharedArrayBuffer-backed ring buffer (SharedTileRingBuffers/SharedTileRingViews) and helpers pushTileUpdate (worker-side writer) and drainTileUpdates (main-thread reader) using Atomics. Extended GameRunner (src/core/GameRunner.ts) with an optional tileUpdateSink?: (update: bigint) => void; when provided, tile updates are sent to the sink instead of being packed into GameUpdateViewData.packedTileUpdates (those become an empty BigUint64Array in this mode). Extended the worker protocol (src/core/worker/WorkerMessages.ts) so the init message can optionally carry sharedTileRingHeader and sharedTileRingData (the two SABs for the ring). Updated WorkerClient (src/core/worker/WorkerClient.ts) to accept optional SharedTileRingBuffers in its constructor and, during initialize(), include those SABs in the init message sent to Worker.worker.ts. Updated the worker implementation (src/core/worker/Worker.worker.ts) to build sharedTileRing views from the SABs on "init" and pass a (update: bigint) => pushTileUpdate(...) sink into createGameRunner, so every tile update from the simulation goes into the shared ring instead of the message payload. Updated the client side (src/client/ClientGameRunner.ts): In createClientGame, when SharedArrayBuffer/Atomics are available and crossOriginIsolated is true, it allocates the ring (createSharedTileRingBuffers with capacity 262144), creates views, passes buffers into WorkerClient, and keeps the views in ClientGameRunner. ClientGameRunner now has a tileRingViews field and, in mergeGameUpdates, if the ring is present it ignores gu.packedTileUpdates and instead drains up to MAX_TILE_UPDATES_PER_RENDER (100 000) tile updates from the SAB ring into combinedPackedTileUpdates before constructing the merged GameUpdateViewData passed to GameView.update. If SABs are unavailable, it falls back to the original behaviour (packing gu.packedTileUpdates from messages), so the feature is opt-in and backward compatible. Net effect: when the page is cross-origin isolated, the worker no longer sends per-tick BigUint64Array tile payloads via postMessage; it streams tile updates into a shared ring buffer, and the main thread pulls and applies them at its own pace while still batching non-tile updates and rendering exactly as before. If you’d like, I can next run the existing tests/build in your environment to sanity-check everything.
294 lines
8.1 KiB
TypeScript
294 lines
8.1 KiB
TypeScript
import { placeName } from "../client/graphics/NameBoxCalculator";
|
|
import { getConfig } from "./configuration/ConfigLoader";
|
|
import { Executor } from "./execution/ExecutionManager";
|
|
import { WinCheckExecution } from "./execution/WinCheckExecution";
|
|
import {
|
|
AllPlayers,
|
|
Attack,
|
|
Cell,
|
|
Game,
|
|
GameUpdates,
|
|
NameViewData,
|
|
Nation,
|
|
Player,
|
|
PlayerActions,
|
|
PlayerBorderTiles,
|
|
PlayerID,
|
|
PlayerInfo,
|
|
PlayerProfile,
|
|
PlayerType,
|
|
} from "./game/Game";
|
|
import { createGame } from "./game/GameImpl";
|
|
import { TileRef } from "./game/GameMap";
|
|
import { GameMapLoader } from "./game/GameMapLoader";
|
|
import {
|
|
ErrorUpdate,
|
|
GameUpdateType,
|
|
GameUpdateViewData,
|
|
} from "./game/GameUpdates";
|
|
import { loadTerrainMap as loadGameMap } from "./game/TerrainMapLoader";
|
|
import { PseudoRandom } from "./PseudoRandom";
|
|
import { ClientID, GameStartInfo, Turn } from "./Schemas";
|
|
import { sanitize, simpleHash } from "./Util";
|
|
import { censorNameWithClanTag } from "./validations/username";
|
|
|
|
export async function createGameRunner(
|
|
gameStart: GameStartInfo,
|
|
clientID: ClientID,
|
|
mapLoader: GameMapLoader,
|
|
callBack: (gu: GameUpdateViewData | ErrorUpdate) => void,
|
|
tileUpdateSink?: (update: bigint) => void,
|
|
): Promise<GameRunner> {
|
|
const config = await getConfig(gameStart.config, null);
|
|
const gameMap = await loadGameMap(
|
|
gameStart.config.gameMap,
|
|
gameStart.config.gameMapSize,
|
|
mapLoader,
|
|
);
|
|
const random = new PseudoRandom(simpleHash(gameStart.gameID));
|
|
|
|
const humans = gameStart.players.map((p) => {
|
|
return new PlayerInfo(
|
|
p.clientID === clientID
|
|
? sanitize(p.username)
|
|
: censorNameWithClanTag(p.username),
|
|
PlayerType.Human,
|
|
p.clientID,
|
|
random.nextID(),
|
|
);
|
|
});
|
|
|
|
const nations = gameStart.config.disableNPCs
|
|
? []
|
|
: gameMap.nations.map(
|
|
(n) =>
|
|
new Nation(
|
|
new Cell(n.coordinates[0], n.coordinates[1]),
|
|
new PlayerInfo(
|
|
n.name,
|
|
PlayerType.FakeHuman,
|
|
null,
|
|
random.nextID(),
|
|
n.strength,
|
|
),
|
|
),
|
|
);
|
|
|
|
const game: Game = createGame(
|
|
humans,
|
|
nations,
|
|
gameMap.gameMap,
|
|
gameMap.miniGameMap,
|
|
config,
|
|
);
|
|
|
|
const gr = new GameRunner(
|
|
game,
|
|
new Executor(game, gameStart.gameID, clientID),
|
|
callBack,
|
|
tileUpdateSink,
|
|
);
|
|
gr.init();
|
|
return gr;
|
|
}
|
|
|
|
export class GameRunner {
|
|
private turns: Turn[] = [];
|
|
private currTurn = 0;
|
|
private isExecuting = false;
|
|
|
|
private playerViewData: Record<PlayerID, NameViewData> = {};
|
|
|
|
constructor(
|
|
public game: Game,
|
|
private execManager: Executor,
|
|
private callBack: (gu: GameUpdateViewData | ErrorUpdate) => void,
|
|
private tileUpdateSink?: (update: bigint) => void,
|
|
) {}
|
|
|
|
init() {
|
|
if (this.game.config().isRandomSpawn()) {
|
|
this.game.addExecution(...this.execManager.spawnPlayers());
|
|
}
|
|
if (this.game.config().bots() > 0) {
|
|
this.game.addExecution(
|
|
...this.execManager.spawnBots(this.game.config().numBots()),
|
|
);
|
|
}
|
|
if (this.game.config().spawnNPCs()) {
|
|
this.game.addExecution(...this.execManager.fakeHumanExecutions());
|
|
}
|
|
this.game.addExecution(new WinCheckExecution());
|
|
}
|
|
|
|
public addTurn(turn: Turn): void {
|
|
this.turns.push(turn);
|
|
}
|
|
|
|
public executeNextTick() {
|
|
if (this.isExecuting) {
|
|
return;
|
|
}
|
|
if (this.currTurn >= this.turns.length) {
|
|
return;
|
|
}
|
|
this.isExecuting = true;
|
|
|
|
this.game.addExecution(
|
|
...this.execManager.createExecs(this.turns[this.currTurn]),
|
|
);
|
|
this.currTurn++;
|
|
|
|
let updates: GameUpdates;
|
|
let tickExecutionDuration: number = 0;
|
|
|
|
try {
|
|
const startTime = performance.now();
|
|
updates = this.game.executeNextTick();
|
|
const endTime = performance.now();
|
|
tickExecutionDuration = endTime - startTime;
|
|
} catch (error: unknown) {
|
|
if (error instanceof Error) {
|
|
console.error("Game tick error:", error.message);
|
|
this.callBack({
|
|
errMsg: error.message,
|
|
stack: error.stack,
|
|
} as ErrorUpdate);
|
|
} else {
|
|
console.error("Game tick error:", error);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (this.game.inSpawnPhase() && this.game.ticks() % 2 === 0) {
|
|
this.game
|
|
.players()
|
|
.filter(
|
|
(p) =>
|
|
p.type() === PlayerType.Human || p.type() === PlayerType.FakeHuman,
|
|
)
|
|
.forEach(
|
|
(p) => (this.playerViewData[p.id()] = placeName(this.game, p)),
|
|
);
|
|
}
|
|
|
|
if (this.game.ticks() < 3 || this.game.ticks() % 30 === 0) {
|
|
this.game.players().forEach((p) => {
|
|
this.playerViewData[p.id()] = placeName(this.game, p);
|
|
});
|
|
}
|
|
|
|
// Many tiles are updated; either publish them via a shared sink or pack
|
|
// them into the view data.
|
|
let packedTileUpdates: BigUint64Array;
|
|
const tileUpdates = updates[GameUpdateType.Tile];
|
|
if (this.tileUpdateSink !== undefined) {
|
|
for (const u of tileUpdates) {
|
|
this.tileUpdateSink(u.update);
|
|
}
|
|
packedTileUpdates = new BigUint64Array();
|
|
} else {
|
|
const raw = tileUpdates.map((u) => u.update);
|
|
packedTileUpdates = new BigUint64Array(raw);
|
|
}
|
|
updates[GameUpdateType.Tile] = [];
|
|
|
|
this.callBack({
|
|
tick: this.game.ticks(),
|
|
packedTileUpdates,
|
|
updates: updates,
|
|
playerNameViewData: this.playerViewData,
|
|
tickExecutionDuration: tickExecutionDuration,
|
|
});
|
|
this.isExecuting = false;
|
|
}
|
|
|
|
public playerActions(
|
|
playerID: PlayerID,
|
|
x?: number,
|
|
y?: number,
|
|
): PlayerActions {
|
|
const player = this.game.player(playerID);
|
|
const tile =
|
|
x !== undefined && y !== undefined ? this.game.ref(x, y) : null;
|
|
const actions = {
|
|
canAttack: tile !== null && player.canAttack(tile),
|
|
buildableUnits: player.buildableUnits(tile),
|
|
canSendEmojiAllPlayers: player.canSendEmoji(AllPlayers),
|
|
canEmbargoAll: player.canEmbargoAll(),
|
|
} as PlayerActions;
|
|
|
|
if (tile !== null && this.game.hasOwner(tile)) {
|
|
const other = this.game.owner(tile) as Player;
|
|
actions.interaction = {
|
|
sharedBorder: player.sharesBorderWith(other),
|
|
canSendEmoji: player.canSendEmoji(other),
|
|
canTarget: player.canTarget(other),
|
|
canSendAllianceRequest: player.canSendAllianceRequest(other),
|
|
canBreakAlliance: player.isAlliedWith(other),
|
|
canDonateGold: player.canDonateGold(other),
|
|
canDonateTroops: player.canDonateTroops(other),
|
|
canEmbargo: !player.hasEmbargoAgainst(other),
|
|
};
|
|
const alliance = player.allianceWith(other as Player);
|
|
if (alliance) {
|
|
actions.interaction.allianceExpiresAt = alliance.expiresAt();
|
|
}
|
|
}
|
|
|
|
return actions;
|
|
}
|
|
|
|
public playerProfile(playerID: number): PlayerProfile {
|
|
const player = this.game.playerBySmallID(playerID);
|
|
if (!player.isPlayer()) {
|
|
throw new Error(`player with id ${playerID} not found`);
|
|
}
|
|
return player.playerProfile();
|
|
}
|
|
public playerBorderTiles(playerID: PlayerID): PlayerBorderTiles {
|
|
const player = this.game.player(playerID);
|
|
if (!player.isPlayer()) {
|
|
throw new Error(`player with id ${playerID} not found`);
|
|
}
|
|
return {
|
|
borderTiles: player.borderTiles(),
|
|
} as PlayerBorderTiles;
|
|
}
|
|
|
|
public attackAveragePosition(
|
|
playerID: number,
|
|
attackID: string,
|
|
): Cell | null {
|
|
const player = this.game.playerBySmallID(playerID);
|
|
if (!player.isPlayer()) {
|
|
throw new Error(`player with id ${playerID} not found`);
|
|
}
|
|
|
|
const condition = (a: Attack) => a.id() === attackID;
|
|
const attack =
|
|
player.outgoingAttacks().find(condition) ??
|
|
player.incomingAttacks().find(condition);
|
|
if (attack === undefined) {
|
|
return null;
|
|
}
|
|
|
|
return attack.averagePosition();
|
|
}
|
|
|
|
public bestTransportShipSpawn(
|
|
playerID: PlayerID,
|
|
targetTile: TileRef,
|
|
): TileRef | false {
|
|
const player = this.game.player(playerID);
|
|
if (!player.isPlayer()) {
|
|
throw new Error(`player with id ${playerID} not found`);
|
|
}
|
|
return player.bestTransportShipSpawn(targetTile);
|
|
}
|
|
|
|
public hasPendingTurns(): boolean {
|
|
return this.currTurn < this.turns.length;
|
|
}
|
|
}
|