// Browser network role: talks to the authoritative Node server over a WebSocket // and exposes high-level events so the screens do not care about the transport. export const DEFAULT_PORT = 27015; export const DEFAULT_PATH = "/ws"; export class Network { constructor() { this.socket = null; this.isAdmin = false; this.localPlayerId = 0; this.players = {}; this._handlers = {}; this._loginFailure = ""; this._connectResolve = null; this._connectReject = null; } on(event, handler) { if (!this._handlers[event]) this._handlers[event] = []; this._handlers[event].push(handler); } _emit(event, ...args) { for (const handler of this._handlers[event] || []) handler(...args); } isClientConnected() { return this.localPlayerId !== 0; } defaultServerUrl() { const location = window.location; if (location.protocol === "http:" || location.protocol === "https:") { const scheme = location.protocol === "https:" ? "wss" : "ws"; return `${scheme}://${location.host}${DEFAULT_PATH}`; } return `ws://127.0.0.1:${DEFAULT_PORT}${DEFAULT_PATH}`; } // Accepts a bare hostname or a full ws(s):// or http(s):// URL. normalizeUrl(host, port = DEFAULT_PORT) { let url = String(host || "").trim().replace(/\/+$/, ""); if (url.startsWith("https://")) url = "wss://" + url.slice("https://".length); else if (url.startsWith("http://")) url = "ws://" + url.slice("http://".length); else if (!url.startsWith("ws://") && !url.startsWith("wss://")) { url = `ws://${url}:${port}`; } const afterScheme = url.slice(url.indexOf("://") + 3); if (!afterScheme.includes("/")) url += DEFAULT_PATH; return url; } connect(url) { this.close(); this._loginFailure = ""; this.localPlayerId = 0; this.isAdmin = false; return new Promise((resolve, reject) => { let socket; try { socket = new WebSocket(url); } catch (error) { reject(error); return; } this.socket = socket; this._connectResolve = resolve; this._connectReject = reject; socket.onopen = () => { if (this._connectResolve) { this._connectResolve(); this._connectResolve = null; this._connectReject = null; } }; socket.onerror = () => { if (this._connectReject) { this._connectReject(new Error("connection failed")); this._connectResolve = null; this._connectReject = null; } }; socket.onclose = () => { const failure = this._loginFailure; this._loginFailure = ""; this.localPlayerId = 0; this.isAdmin = false; if (this._connectReject) { this._connectReject(new Error("connection closed")); this._connectResolve = null; this._connectReject = null; return; } if (failure) this._emit("session_failed", failure); else this._emit("server_disconnected"); }; socket.onmessage = (event) => { let message; try { message = JSON.parse(event.data); } catch { return; } this._handleMessage(message); }; }); } requestGameList(url) { return this.connect(url).then(() => { this._send({ t: "request_games" }); }); } createGame(name, password) { return this.connect(this.defaultServerUrl()).then(() => { this._send({ t: "login", name, password }); }); } joinGame(url, name, password) { return this.connect(url).then(() => { this._send({ t: "login", name, password }); }); } sendGameSetup(setup) { this._send({ t: "configure_game", setup }); } claimCiv(index) { this._send({ t: "claim_civ", civ: index }); } sendOrder(order) { this._send({ t: "order", order }); } close() { if (!this.socket) return; const socket = this.socket; this.socket = null; socket.onopen = null; socket.onerror = null; socket.onclose = null; socket.onmessage = null; try { socket.close(); } catch { // ignore } } _send(message) { if (this.socket && this.socket.readyState === WebSocket.OPEN) { this.socket.send(JSON.stringify(message)); } } _handleMessage(message) { switch (message.t) { case "assigned": this.localPlayerId = message.playerId; this.isAdmin = !!message.admin; this._emit("session_started", message); break; case "players": this.players = message.players || {}; this._emit("players_updated", this.players); break; case "game_list": this._emit("game_list_received", message.games || []); break; case "login_rejected": this._loginFailure = message.reason || "Login failed."; this._emit("session_failed", this._loginFailure); this.close(); break; case "game_configured": this._emit("game_configured"); break; case "choose_civ": this._emit("choose_civ_received", message.civs || []); break; case "civ_assigned": this._emit("civ_assigned", message.civ); break; case "game_state": this._emit("game_state_received", message.state); break; default: break; } } }