import { z } from "zod"; import { EventBus, GameEvent } from "../core/EventBus"; import { AllPlayers, GameType, Gold, PlayerID, Tick, UnitType, } from "../core/game/Game"; import { TileRef } from "../core/game/GameMap"; import { PlayerView } from "../core/game/GameView"; import { AllPlayersStats, ClientHashMessage, ClientIntentMessage, ClientJoinMessage, ClientMessage, ClientPingMessage, ClientSendWinnerMessage, Intent, ServerMessage, ServerMessageSchema, Winner, } from "../core/Schemas"; import { replacer } from "../core/Util"; import { LobbyConfig } from "./ClientGameRunner"; import { LocalServer } from "./LocalServer"; export class PauseGameEvent implements GameEvent { constructor(public readonly paused: boolean) {} } export class SendAllianceRequestIntentEvent implements GameEvent { constructor( public readonly requestor: PlayerView, public readonly recipient: PlayerView, ) {} } export class SendBreakAllianceIntentEvent implements GameEvent { constructor( public readonly requestor: PlayerView, public readonly recipient: PlayerView, ) {} } export class SendUpgradeStructureIntentEvent implements GameEvent { constructor( public readonly unitId: number, public readonly unitType: UnitType, ) {} } export class SendAllianceReplyIntentEvent implements GameEvent { constructor( // The original alliance requestor public readonly requestor: PlayerView, public readonly recipient: PlayerView, public readonly accepted: boolean, ) {} } export class SendAllianceExtensionIntentEvent implements GameEvent { constructor(public readonly recipient: PlayerView) {} } export class SendSpawnIntentEvent implements GameEvent { constructor(public readonly tile: TileRef) {} } export class SendAttackIntentEvent implements GameEvent { constructor( public readonly targetID: PlayerID | null, public readonly troops: number, ) {} } export class SendBoatAttackIntentEvent implements GameEvent { constructor( public readonly targetID: PlayerID | null, public readonly dst: TileRef, public readonly troops: number, public readonly src: TileRef | null = null, ) {} } export class BuildUnitIntentEvent implements GameEvent { constructor( public readonly unit: UnitType, public readonly tile: TileRef, ) {} } export class SendTargetPlayerIntentEvent implements GameEvent { constructor(public readonly targetID: PlayerID) {} } export class SendEmojiIntentEvent implements GameEvent { constructor( public readonly recipient: PlayerView | typeof AllPlayers, public readonly emoji: number, ) {} } export class SendDonateGoldIntentEvent implements GameEvent { constructor( public readonly recipient: PlayerView, public readonly gold: Gold | null, ) {} } export class SendDonateTroopsIntentEvent implements GameEvent { constructor( public readonly recipient: PlayerView, public readonly troops: number | null, ) {} } export class SendQuickChatEvent implements GameEvent { constructor( public readonly recipient: PlayerView, public readonly quickChatKey: string, public readonly target?: PlayerID, ) {} } export class SendEmbargoIntentEvent implements GameEvent { constructor( public readonly target: PlayerView, public readonly action: "start" | "stop", ) {} } export class CancelAttackIntentEvent implements GameEvent { constructor(public readonly attackID: string) {} } export class CancelBoatIntentEvent implements GameEvent { constructor(public readonly unitID: number) {} } export class SendWinnerEvent implements GameEvent { constructor( public readonly winner: Winner, public readonly allPlayersStats: AllPlayersStats, ) {} } export class SendHashEvent implements GameEvent { constructor( public readonly tick: Tick, public readonly hash: number, ) {} } export class MoveWarshipIntentEvent implements GameEvent { constructor( public readonly unitId: number, public readonly tile: number, ) {} } export class SendKickPlayerIntentEvent implements GameEvent { constructor(public readonly target: string) {} } export class Transport { private socket: WebSocket | null = null; private localServer: LocalServer; private buffer: string[] = []; private onconnect: () => void; private onmessage: (msg: ServerMessage) => void; private pingInterval: number | null = null; public readonly isLocal: boolean; constructor( private lobbyConfig: LobbyConfig, private eventBus: EventBus, ) { // If gameRecord is not null, we are replaying an archived game. // For multiplayer games, GameConfig is not known until game starts. this.isLocal = lobbyConfig.gameRecord !== undefined || lobbyConfig.gameStartInfo?.config.gameType === GameType.Singleplayer; this.eventBus.on(SendAllianceRequestIntentEvent, (e) => this.onSendAllianceRequest(e), ); this.eventBus.on(SendAllianceReplyIntentEvent, (e) => this.onAllianceRequestReplyUIEvent(e), ); this.eventBus.on(SendAllianceExtensionIntentEvent, (e) => this.onSendAllianceExtensionIntent(e), ); this.eventBus.on(SendBreakAllianceIntentEvent, (e) => this.onBreakAllianceRequestUIEvent(e), ); this.eventBus.on(SendSpawnIntentEvent, (e) => this.onSendSpawnIntentEvent(e), ); this.eventBus.on(SendAttackIntentEvent, (e) => this.onSendAttackIntent(e)); this.eventBus.on(SendUpgradeStructureIntentEvent, (e) => this.onSendUpgradeStructureIntent(e), ); this.eventBus.on(SendBoatAttackIntentEvent, (e) => this.onSendBoatAttackIntent(e), ); this.eventBus.on(SendTargetPlayerIntentEvent, (e) => this.onSendTargetPlayerIntent(e), ); this.eventBus.on(SendEmojiIntentEvent, (e) => this.onSendEmojiIntent(e)); this.eventBus.on(SendDonateGoldIntentEvent, (e) => this.onSendDonateGoldIntent(e), ); this.eventBus.on(SendDonateTroopsIntentEvent, (e) => this.onSendDonateTroopIntent(e), ); this.eventBus.on(SendQuickChatEvent, (e) => this.onSendQuickChatIntent(e)); this.eventBus.on(SendEmbargoIntentEvent, (e) => this.onSendEmbargoIntent(e), ); this.eventBus.on(BuildUnitIntentEvent, (e) => this.onBuildUnitIntent(e)); this.eventBus.on(PauseGameEvent, (e) => this.onPauseGameEvent(e)); this.eventBus.on(SendWinnerEvent, (e) => this.onSendWinnerEvent(e)); this.eventBus.on(SendHashEvent, (e) => this.onSendHashEvent(e)); this.eventBus.on(CancelAttackIntentEvent, (e) => this.onCancelAttackIntentEvent(e), ); this.eventBus.on(CancelBoatIntentEvent, (e) => this.onCancelBoatIntentEvent(e), ); this.eventBus.on(MoveWarshipIntentEvent, (e) => { this.onMoveWarshipEvent(e); }); this.eventBus.on(SendKickPlayerIntentEvent, (e) => this.onSendKickPlayerIntent(e), ); } private startPing() { if (this.isLocal) return; this.pingInterval ??= window.setInterval(() => { if (this.socket !== null && this.socket.readyState === WebSocket.OPEN) { this.sendMsg({ type: "ping", } satisfies ClientPingMessage); } }, 5 * 1000); } private stopPing() { if (this.pingInterval) { window.clearInterval(this.pingInterval); this.pingInterval = null; } } public connect( onconnect: () => void, onmessage: (message: ServerMessage) => void, ) { if (this.isLocal) { this.connectLocal(onconnect, onmessage); } else { this.connectRemote(onconnect, onmessage); } } private connectLocal( onconnect: () => void, onmessage: (message: ServerMessage) => void, ) { this.localServer = new LocalServer( this.lobbyConfig, onconnect, onmessage, this.lobbyConfig.gameRecord !== undefined, this.eventBus, ); this.localServer.start(); } private connectRemote( onconnect: () => void, onmessage: (message: ServerMessage) => void, ) { this.startPing(); this.killExistingSocket(); const wsHost = window.location.host; const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:"; const workerPath = this.lobbyConfig.serverConfig.workerPath( this.lobbyConfig.gameID, ); this.socket = new WebSocket(`${wsProtocol}//${wsHost}/${workerPath}`); this.onconnect = onconnect; this.onmessage = onmessage; this.socket.onopen = () => { console.log("Connected to game server!"); if (this.socket === null) { console.error("socket is null"); return; } while (this.buffer.length > 0) { console.log("sending dropped message"); const msg = this.buffer.pop(); if (msg === undefined) { console.warn("msg is undefined"); continue; } this.socket.send(msg); } onconnect(); }; this.socket.onmessage = (event: MessageEvent) => { try { const parsed = JSON.parse(event.data); const result = ServerMessageSchema.safeParse(parsed); if (!result.success) { const error = z.prettifyError(result.error); console.error("Error parsing server message", error); return; } this.onmessage(result.data); } catch (e) { console.error("Error in onmessage handler:", e, event.data); return; } }; this.socket.onerror = (err) => { console.error("Socket encountered error: ", err, "Closing socket"); if (this.socket === null) return; this.socket.close(); }; this.socket.onclose = (event: CloseEvent) => { console.log( `WebSocket closed. Code: ${event.code}, Reason: ${event.reason}`, ); if (event.code === 1002) { // TODO: make this a modal alert(`connection refused: ${event.reason}`); } else if (event.code !== 1000) { console.log(`recieved error code ${event.code}, reconnecting`); this.reconnect(); } }; } public reconnect() { this.connect(this.onconnect, this.onmessage); } public turnComplete() { if (this.isLocal) { this.localServer.turnComplete(); } } joinGame(numTurns: number) { this.sendMsg({ type: "join", gameID: this.lobbyConfig.gameID, clientID: this.lobbyConfig.clientID, lastTurn: numTurns, token: this.lobbyConfig.token, username: this.lobbyConfig.playerName, flag: this.lobbyConfig.flag, patternName: this.lobbyConfig.patternName, } satisfies ClientJoinMessage); } leaveGame(saveFullGame: boolean = false) { if (this.isLocal) { this.localServer.endGame(saveFullGame); return; } this.stopPing(); if (this.socket === null) return; if (this.socket.readyState === WebSocket.OPEN) { console.log("on stop: leaving game"); this.socket.close(); } else { console.log( "WebSocket is not open. Current state:", this.socket.readyState, ); console.error("attempting reconnect"); } this.socket.onclose = (event: CloseEvent) => {}; } private onSendAllianceRequest(event: SendAllianceRequestIntentEvent) { this.sendIntent({ type: "allianceRequest", clientID: this.lobbyConfig.clientID, recipient: event.recipient.id(), }); } private onAllianceRequestReplyUIEvent(event: SendAllianceReplyIntentEvent) { this.sendIntent({ type: "allianceRequestReply", clientID: this.lobbyConfig.clientID, requestor: event.requestor.id(), accept: event.accepted, }); } private onBreakAllianceRequestUIEvent(event: SendBreakAllianceIntentEvent) { this.sendIntent({ type: "breakAlliance", clientID: this.lobbyConfig.clientID, recipient: event.recipient.id(), }); } private onSendAllianceExtensionIntent( event: SendAllianceExtensionIntentEvent, ) { this.sendIntent({ type: "allianceExtension", clientID: this.lobbyConfig.clientID, recipient: event.recipient.id(), }); } private onSendSpawnIntentEvent(event: SendSpawnIntentEvent) { this.sendIntent({ type: "spawn", clientID: this.lobbyConfig.clientID, tile: event.tile, }); } private onSendAttackIntent(event: SendAttackIntentEvent) { this.sendIntent({ type: "attack", clientID: this.lobbyConfig.clientID, targetID: event.targetID, troops: event.troops, }); } private onSendBoatAttackIntent(event: SendBoatAttackIntentEvent) { this.sendIntent({ type: "boat", clientID: this.lobbyConfig.clientID, targetID: event.targetID, troops: event.troops, dst: event.dst, src: event.src, }); } private onSendUpgradeStructureIntent(event: SendUpgradeStructureIntentEvent) { this.sendIntent({ type: "upgrade_structure", unit: event.unitType, clientID: this.lobbyConfig.clientID, unitId: event.unitId, }); } private onSendTargetPlayerIntent(event: SendTargetPlayerIntentEvent) { this.sendIntent({ type: "targetPlayer", clientID: this.lobbyConfig.clientID, target: event.targetID, }); } private onSendEmojiIntent(event: SendEmojiIntentEvent) { this.sendIntent({ type: "emoji", clientID: this.lobbyConfig.clientID, recipient: event.recipient === AllPlayers ? AllPlayers : event.recipient.id(), emoji: event.emoji, }); } private onSendDonateGoldIntent(event: SendDonateGoldIntentEvent) { this.sendIntent({ type: "donate_gold", clientID: this.lobbyConfig.clientID, recipient: event.recipient.id(), gold: event.gold, }); } private onSendDonateTroopIntent(event: SendDonateTroopsIntentEvent) { this.sendIntent({ type: "donate_troops", clientID: this.lobbyConfig.clientID, recipient: event.recipient.id(), troops: event.troops, }); } private onSendQuickChatIntent(event: SendQuickChatEvent) { this.sendIntent({ type: "quick_chat", clientID: this.lobbyConfig.clientID, recipient: event.recipient.id(), quickChatKey: event.quickChatKey, target: event.target, }); } private onSendEmbargoIntent(event: SendEmbargoIntentEvent) { this.sendIntent({ type: "embargo", clientID: this.lobbyConfig.clientID, targetID: event.target.id(), action: event.action, }); } private onBuildUnitIntent(event: BuildUnitIntentEvent) { this.sendIntent({ type: "build_unit", clientID: this.lobbyConfig.clientID, unit: event.unit, tile: event.tile, }); } private onPauseGameEvent(event: PauseGameEvent) { if (!this.isLocal) { console.log(`cannot pause multiplayer games`); return; } if (event.paused) { this.localServer.pause(); } else { this.localServer.resume(); } } private onSendWinnerEvent(event: SendWinnerEvent) { if (this.isLocal || this.socket?.readyState === WebSocket.OPEN) { this.sendMsg({ type: "winner", winner: event.winner, allPlayersStats: event.allPlayersStats, } satisfies ClientSendWinnerMessage); } else { console.log( "WebSocket is not open. Current state:", this.socket?.readyState, ); console.log("attempting reconnect"); } } private onSendHashEvent(event: SendHashEvent) { if (this.isLocal || this.socket?.readyState === WebSocket.OPEN) { this.sendMsg({ type: "hash", turnNumber: event.tick, hash: event.hash, } satisfies ClientHashMessage); } else { console.log( "WebSocket is not open. Current state:", this.socket!.readyState, ); console.log("attempting reconnect"); } } private onCancelAttackIntentEvent(event: CancelAttackIntentEvent) { this.sendIntent({ type: "cancel_attack", clientID: this.lobbyConfig.clientID, attackID: event.attackID, }); } private onCancelBoatIntentEvent(event: CancelBoatIntentEvent) { this.sendIntent({ type: "cancel_boat", clientID: this.lobbyConfig.clientID, unitID: event.unitID, }); } private onMoveWarshipEvent(event: MoveWarshipIntentEvent) { this.sendIntent({ type: "move_warship", clientID: this.lobbyConfig.clientID, unitId: event.unitId, tile: event.tile, }); } private onSendKickPlayerIntent(event: SendKickPlayerIntentEvent) { this.sendIntent({ type: "kick_player", clientID: this.lobbyConfig.clientID, target: event.target, }); } private sendIntent(intent: Intent) { if (this.isLocal || this.socket?.readyState === WebSocket.OPEN) { const msg = { type: "intent", intent: intent, } satisfies ClientIntentMessage; this.sendMsg(msg); } else { console.log( "WebSocket is not open. Current state:", this.socket?.readyState, ); console.log("attempting reconnect"); } } private sendMsg(msg: ClientMessage) { if (this.isLocal) { // Forward message to local server this.localServer.onMessage(msg); return; } else if (this.socket === null) { // Socket missing, do nothing return; } const str = JSON.stringify(msg, replacer); if (this.socket.readyState === WebSocket.CLOSED) { // Buffer message console.warn("socket not ready, closing and trying later"); this.socket.close(); this.socket = null; this.connectRemote(this.onconnect, this.onmessage); this.buffer.push(str); } else { // Send the message directly this.socket.send(str); } } private killExistingSocket(): void { if (this.socket === null) { return; } // Remove all event listeners this.socket.onmessage = null; this.socket.onopen = null; this.socket.onclose = null; this.socket.onerror = null; // Close the connection if it's still open if (this.socket.readyState === WebSocket.OPEN) { this.socket.close(); } this.socket = null; } }