From 8a320f184c2d38d7b7945df22a75b0d76c31f237 Mon Sep 17 00:00:00 2001 From: evanpelle Date: Mon, 6 Jan 2025 15:20:17 -0800 Subject: [PATCH] build worker messaging system --- src/client/ClientGameRunner.ts | 19 ++---- src/core/GameRunner.ts | 2 +- src/core/GameView.ts | 3 +- src/core/game/Game.ts | 4 -- src/core/game/GameImpl.ts | 3 +- src/core/worker/Worker.worker.ts | 93 +++++++++++++++++++++---- src/core/worker/WorkerClient.ts | 108 ++++++++++++++++++++++-------- src/core/worker/WorkerMessages.ts | 54 +++++++++++++++ 8 files changed, 226 insertions(+), 60 deletions(-) create mode 100644 src/core/worker/WorkerMessages.ts diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index fb4d779d6..57d49df90 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -1,5 +1,5 @@ import { Executor } from "../core/execution/ExecutionManager"; -import { Cell, MutableGame, PlayerEvent, PlayerID, MutablePlayer, TileEvent, Player, Game, UnitEvent, Tile, PlayerType, GameMap, Difficulty, GameType } from "../core/game/Game"; +import { Cell, MutableGame, PlayerID, MutablePlayer, TileEvent, Player, Game, UnitEvent, Tile, PlayerType, GameMap, Difficulty, GameType } from "../core/game/Game"; import { createGame } from "../core/game/GameImpl"; import { EventBus } from "../core/EventBus"; import { createRenderer, GameRenderer } from "./graphics/GameRenderer"; @@ -74,10 +74,10 @@ export async function createClientGame(lobbyConfig: LobbyConfig, gameConfig: Gam const config = getConfig(gameConfig) const terrainMap = await loadTerrainMap(gameConfig.gameMap); - const gameView = new GameView(config, terrainMap.map) - const worker = new WorkerClient(lobbyConfig.gameID, gameConfig) await worker.initialize() + const gameView = new GameView(worker, config, terrainMap.map) + consolex.log('going to init path finder') consolex.log('inited path finder') @@ -118,7 +118,6 @@ export class ClientGameRunner { public start() { consolex.log('starting client game') this.isActive = true - this.eventBus.on(PlayerEvent, (e) => this.playerEvent(e)) this.eventBus.on(MouseUpEvent, (e) => this.inputEvent(e)) this.renderer.initialize() @@ -168,13 +167,6 @@ export class ClientGameRunner { this.transport.leaveGame() } - private playerEvent(event: PlayerEvent) { - if (event.player.clientID() == this.clientID) { - consolex.log('setting name') - this.myPlayer = event.player - } - } - private inputEvent(event: MouseUpEvent) { if (!this.isActive) { return @@ -193,7 +185,10 @@ export class ClientGameRunner { return } if (this.myPlayer == null) { - return + this.myPlayer = this.gameView.playerByClientID(this.clientID) + if (this.myPlayer == null) { + return + } } const owner = tile.owner() diff --git a/src/core/GameRunner.ts b/src/core/GameRunner.ts index 8fa779307..d4d1d678b 100644 --- a/src/core/GameRunner.ts +++ b/src/core/GameRunner.ts @@ -29,7 +29,7 @@ export class GameRunner { private playerToName = new Map() constructor( - private game: MutableGame, + public game: MutableGame, private eventBus: EventBus, private execManager: Executor, private callBack: (gu: GameUpdateViewData) => void diff --git a/src/core/GameView.ts b/src/core/GameView.ts index 418f8ba5a..b0c501d48 100644 --- a/src/core/GameView.ts +++ b/src/core/GameView.ts @@ -3,6 +3,7 @@ import { Config } from "./configuration/Config"; import { Alliance, AllianceRequest, AllPlayers, Cell, DefenseBonus, EmojiMessage, Execution, ExecutionView, Game, Gold, MutableTile, Nation, PlayerID, PlayerInfo, PlayerType, Relation, TerrainMap, TerrainTile, TerrainType, TerraNullius, Tick, UnitInfo, UnitType } from "./game/Game"; import { ClientID } from "./Schemas"; import { TerraNulliusImpl } from './game/TerraNulliusImpl'; +import { WorkerClient } from './worker/WorkerClient'; export interface ViewSerializable { toViewData(): T; @@ -254,7 +255,7 @@ export class GameView { private tiles: TileView[][] = [] private smallIDToID = new Map() - constructor(private _config: Config, private _terrainMap: TerrainMap) { + constructor(private worker: WorkerClient, private _config: Config, private _terrainMap: TerrainMap) { // Initialize the 2D array this.tiles = Array(_terrainMap.width()).fill(null).map(() => Array(_terrainMap.height()).fill(null)); diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index 43e8b7565..684ffd81d 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -361,10 +361,6 @@ export class TileEvent implements GameEvent { constructor(public readonly tile: Tile, public readonly borderOnlyChange: boolean = false) { } } -export class PlayerEvent implements GameEvent { - constructor(public readonly player: Player) { } -} - export class UnitEvent implements GameEvent { constructor(public readonly unit: Unit, public oldTile: Tile) { } } diff --git a/src/core/game/GameImpl.ts b/src/core/game/GameImpl.ts index 1c698e2fc..327072fc0 100644 --- a/src/core/game/GameImpl.ts +++ b/src/core/game/GameImpl.ts @@ -1,7 +1,7 @@ import { info } from "console"; import { Config } from "../configuration/Config"; import { EventBus } from "../EventBus"; -import { Cell, Execution, MutableGame, Game, MutablePlayer, PlayerEvent, PlayerID, PlayerInfo, Player, TerraNullius, Tile, TileEvent, Unit, UnitEvent as UnitEvent, PlayerType, MutableAllianceRequest, AllianceRequestReplyEvent, AllianceRequestEvent, BrokeAllianceEvent, MutableAlliance, Alliance, AllianceExpiredEvent, Nation, UnitType, UnitInfo, TerrainMap, DefenseBonus, MutableTile } from "./Game"; +import { Cell, Execution, MutableGame, Game, MutablePlayer, PlayerID, PlayerInfo, Player, TerraNullius, Tile, TileEvent, Unit, UnitEvent as UnitEvent, PlayerType, MutableAllianceRequest, AllianceRequestReplyEvent, AllianceRequestEvent, BrokeAllianceEvent, MutableAlliance, Alliance, AllianceExpiredEvent, Nation, UnitType, UnitInfo, TerrainMap, DefenseBonus, MutableTile } from "./Game"; import { TerrainMapImpl } from "./TerrainMapLoader"; import { PlayerImpl } from "./PlayerImpl"; import { TerraNulliusImpl } from "./TerraNulliusImpl"; @@ -250,7 +250,6 @@ export class GameImpl implements MutableGame { let player = new PlayerImpl(this, this.nextID, playerInfo, manpower) this.nextID++ this._players.set(playerInfo.id, player) - this.eventBus.emit(new PlayerEvent(player)) return player } diff --git a/src/core/worker/Worker.worker.ts b/src/core/worker/Worker.worker.ts index 69eced1f2..2aae32b39 100644 --- a/src/core/worker/Worker.worker.ts +++ b/src/core/worker/Worker.worker.ts @@ -1,27 +1,94 @@ import { createGameRunner, GameRunner } from "../GameRunner"; import { GameUpdateViewData } from "../GameView"; +import { + MainThreadMessage, + WorkerMessage, + InitializedMessage, + SharesBorderResultMessage +} from './WorkerMessages'; -let gameRunner: Promise = null - +const ctx: Worker = self as any; +let gameRunner: Promise | null = null; function gameUpdate(gu: GameUpdateViewData) { - self.postMessage({ + sendMessage({ type: "game_update", gameUpdate: gu - }) + }); } -self.onmessage = (e) => { - switch (e.data.type) { +function sendMessage(message: WorkerMessage) { + ctx.postMessage(message); +} + +ctx.addEventListener('message', async (e: MessageEvent) => { + const message = e.data; + + switch (message.type) { case 'init': - gameRunner = createGameRunner(e.data.gameID, e.data.gameConfig, gameUpdate).then(gr => { - self.postMessage({ - type: 'initialized' + try { + gameRunner = createGameRunner( + message.gameID, + message.gameConfig, + gameUpdate + ).then(gr => { + sendMessage({ + type: 'initialized', + id: message.id + } as InitializedMessage); + return gr; }); - return gr; - }); + } catch (error) { + console.error('Failed to initialize game runner:', error); + throw error; + } break; + case 'turn': - gameRunner.then(gr => gr.addTurn(e.data.turn)) + if (!gameRunner) { + throw new Error('Game runner not initialized'); + } + + try { + const gr = await gameRunner; + await gr.addTurn(message.turn); + } catch (error) { + console.error('Failed to process turn:', error); + throw error; + } + break; + + case 'shares_border': + if (!gameRunner) { + throw new Error('Game runner not initialized'); + } + + try { + const game = (await gameRunner).game + const result = game.player(message.player1) + .sharesBorderWith(game.player(message.player2)) + + sendMessage({ + type: 'shares_border_result', + id: message.id, + result + } as SharesBorderResultMessage); + } catch (error) { + console.error('Failed to check borders:', error); + throw error; + } + break; + + default: + console.warn('Unknown message :', message); } -}; \ No newline at end of file +}); + +// Error handling +ctx.addEventListener('error', (error) => { + console.error('Worker error:', error); +}); + +ctx.addEventListener('unhandledrejection', (event) => { + console.error('Unhandled promise rejection in worker:', event); +}); \ No newline at end of file diff --git a/src/core/worker/WorkerClient.ts b/src/core/worker/WorkerClient.ts index 543e951d2..5d3ba0894 100644 --- a/src/core/worker/WorkerClient.ts +++ b/src/core/worker/WorkerClient.ts @@ -1,40 +1,69 @@ -import { consolex } from "../Consolex"; -import { Cell, Game, GameMap, TerrainTile, TerrainType, Tile } from "../game/Game"; +import { PlayerID } from "../game/Game"; import { GameUpdateViewData } from "../GameView"; -import { AStar, PathFindResultType } from "../pathfinding/AStar"; -import { MiniAStar } from "../pathfinding/MiniAStar"; import { GameConfig, GameID, Turn } from "../Schemas"; import { generateID } from "../Util"; - +import { WorkerMessage } from "./WorkerMessages"; export class WorkerClient { private worker: Worker; private isInitialized = false; + private messageHandlers: Map void>; + private gameUpdateCallback?: (update: GameUpdateViewData) => void; constructor(private gameID: GameID, private gameConfig: GameConfig) { - // Create a new worker using webpack worker-loader - // The import.meta.url ensures webpack can properly bundle the worker this.worker = new Worker(new URL('./Worker.worker.ts', import.meta.url)); + this.messageHandlers = new Map(); + + // Set up global message handler + this.worker.addEventListener('message', this.handleWorkerMessage.bind(this)); + } + + private handleWorkerMessage(event: MessageEvent) { + const message = event.data; + + switch (message.type) { + case 'game_update': + if (this.gameUpdateCallback && message.gameUpdate) { + this.gameUpdateCallback(message.gameUpdate); + } + break; + + case 'initialized': + case 'shares_border_result': + if (message.id && this.messageHandlers.has(message.id)) { + const handler = this.messageHandlers.get(message.id)!; + handler(message); + this.messageHandlers.delete(message.id); + } + break; + } } initialize(): Promise { return new Promise((resolve, reject) => { + const messageId = generateID() + + this.messageHandlers.set(messageId, (message) => { + if (message.type === 'initialized') { + this.isInitialized = true; + resolve(); + } + }); + this.worker.postMessage({ type: 'init', + id: messageId, gameID: this.gameID, gameConfig: this.gameConfig }); - const handler = (e: MessageEvent) => { - if (e.data.type === 'initialized') { - this.isInitialized = true; - this.worker.removeEventListener('message', handler) - resolve(); - return + // Add timeout for initialization + setTimeout(() => { + if (!this.isInitialized) { + this.messageHandlers.delete(messageId); + reject(new Error('Worker initialization timeout')); } - }; - - this.worker.addEventListener('message', handler); + }, 5000); // 5 second timeout }); } @@ -42,23 +71,48 @@ export class WorkerClient { if (!this.isInitialized) { throw new Error('Failed to initialize pathfinder'); } - const handler = (e: MessageEvent) => { - if (e.data.type == "game_update") { - gameUpdate(e.data.gameUpdate) - } - } - this.worker.addEventListener('message', handler); + this.gameUpdateCallback = gameUpdate; } sendTurn(turn: Turn) { + if (!this.isInitialized) { + throw new Error('Worker not initialized'); + } + this.worker.postMessage({ - type: "turn", - turn: turn - }) + type: 'turn', + turn + }); } + sharesBorderWith(p1: PlayerID, p2: PlayerID): Promise { + return new Promise((resolve, reject) => { + if (!this.isInitialized) { + reject(new Error('Worker not initialized')); + return; + } + + const messageId = generateID() + + this.messageHandlers.set(messageId, (message) => { + if (message.type === 'shares_border_result' && message.result !== undefined) { + resolve(message.result); + } + }); + + this.worker.postMessage({ + type: 'shares_border', + id: messageId, + player1: p1, + player2: p2 + }); + }); + } + + cleanup() { this.worker.terminate(); + this.messageHandlers.clear(); + this.gameUpdateCallback = undefined; } -} - +} \ No newline at end of file diff --git a/src/core/worker/WorkerMessages.ts b/src/core/worker/WorkerMessages.ts new file mode 100644 index 000000000..c93da57a3 --- /dev/null +++ b/src/core/worker/WorkerMessages.ts @@ -0,0 +1,54 @@ +import { GameUpdateViewData } from "../GameView"; +import { GameConfig, GameID, Turn } from "../Schemas"; +import { PlayerID } from "../game/Game"; + +export type WorkerMessageType = + | 'init' + | 'initialized' + | 'turn' + | 'game_update' + | 'shares_border' + | 'shares_border_result'; + +// Base interface for all messages +interface BaseWorkerMessage { + type: WorkerMessageType; + id?: string; +} + +// Messages from main thread to worker +export interface InitMessage extends BaseWorkerMessage { + type: 'init'; + gameID: GameID; + gameConfig: GameConfig; +} + +export interface TurnMessage extends BaseWorkerMessage { + type: 'turn'; + turn: Turn; +} + +export interface SharesBorderMessage extends BaseWorkerMessage { + type: 'shares_border'; + player1: PlayerID; + player2: PlayerID; +} + +// Messages from worker to main thread +export interface InitializedMessage extends BaseWorkerMessage { + type: 'initialized'; +} + +export interface GameUpdateMessage extends BaseWorkerMessage { + type: 'game_update'; + gameUpdate: GameUpdateViewData; +} + +export interface SharesBorderResultMessage extends BaseWorkerMessage { + type: 'shares_border_result'; + result: boolean; +} + +// Union types for type safety +export type MainThreadMessage = InitMessage | TurnMessage | SharesBorderMessage; +export type WorkerMessage = InitializedMessage | GameUpdateMessage | SharesBorderResultMessage; \ No newline at end of file