import http from "node:http"; import fs from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { WebSocketServer, WebSocket } from "ws"; import { queryServerList } from "./masterQuery.js"; import { GameConnection } from "./gameConnection.js"; import { loadCredentials } from "./auth.js"; import { relayLog } from "./logger.js"; import type { ClientMessage, ServerMessage, ServerInfo } from "./types.js"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); /** Base path for game files (extracted VL2 contents). */ const GAME_BASE_PATH = process.env.GAME_BASE_PATH || path.resolve(__dirname, "..", "docs", "base"); const MANIFEST_PATH = process.env.MANIFEST_PATH || path.resolve(GAME_BASE_PATH, "..", "..", "public", "manifest.json"); const RELAY_PORT = parseInt(process.env.RELAY_PORT || "8765", 10); const MASTER_SERVER = process.env.T2_MASTER_SERVER || "master.tribesnext.com"; /** HTTP server for health checks; WebSocket upgrades are handled separately. */ const httpServer = http.createServer(async (req, res) => { if (req.url === "/health") { const checks: Record = {}; // Check game assets directory. try { const stat = await fs.stat(GAME_BASE_PATH); const entries = await fs.readdir(GAME_BASE_PATH); checks.gameAssets = { ok: stat.isDirectory() && entries.length > 0, detail: `${entries.length} entries in ${GAME_BASE_PATH}`, }; } catch { checks.gameAssets = { ok: false, detail: `${GAME_BASE_PATH} not found` }; } // Check manifest. try { const raw = await fs.readFile(MANIFEST_PATH, "utf-8"); const manifest = JSON.parse(raw); const count = Object.keys(manifest.resources ?? {}).length; checks.manifest = { ok: count > 0, detail: `${count} resources` }; } catch { checks.manifest = { ok: false, detail: `${MANIFEST_PATH} not found` }; } // Check credentials. const creds = loadCredentials(); checks.credentials = { ok: creds !== null, detail: creds ? "loaded" : "missing or incomplete", }; const allOk = Object.values(checks).every((c) => c.ok); res.writeHead(allOk ? 200 : 503, { "Content-Type": "application/json" }); res.end(JSON.stringify({ status: allOk ? "ok" : "degraded", checks }, null, 2)); return; } res.writeHead(404); res.end(); }); const wss = new WebSocketServer({ server: httpServer }); httpServer.listen(RELAY_PORT, "0.0.0.0", () => { relayLog.info({ port: RELAY_PORT }, "Relay server listening"); }); /** Cached server list from the most recent master query. */ let cachedServers: ServerInfo[] = []; wss.on("connection", (ws) => { relayLog.info("Browser client connected"); let gameConnection: GameConnection | null = null; let lastJoinAddress: string | null = null; let retryCount = 0; let retryTimer: ReturnType | null = null; const MAX_RETRIES = 3; const RETRY_DELAY_MS = 6000; const RETRYABLE_REASONS = ["Server is cycling mission"]; async function connectToServer(ws: WebSocket, address: string): Promise { if (gameConnection) { gameConnection.disconnect(); } gameConnection = new GameConnection(address); // Set mapName from the cached server list if available. const cachedServer = cachedServers.find( (s) => s.address === address, ); if (cachedServer?.mapName) { gameConnection.setMapName(cachedServer.mapName); } gameConnection.on("status", (status, statusMessage) => { relayLog.info( { status, statusMessage, connectSequence: gameConnection?.connectSequence, mapName: gameConnection?.mapName, }, "Game connection status changed", ); // Auto-retry on retryable disconnect reasons. if ( status === "disconnected" && statusMessage && RETRYABLE_REASONS.some((r) => statusMessage.includes(r)) && retryCount < MAX_RETRIES && lastJoinAddress === address ) { retryCount++; relayLog.info( { attempt: retryCount, maxRetries: MAX_RETRIES, delay: RETRY_DELAY_MS }, "Retryable disconnect — will reconnect", ); sendToClient(ws, { type: "status", status: "connecting", message: `${statusMessage} — retrying (${retryCount}/${MAX_RETRIES})...`, connectSequence: gameConnection?.connectSequence, mapName: gameConnection?.mapName, }); retryTimer = setTimeout(() => { retryTimer = null; if (lastJoinAddress === address && ws.readyState === WebSocket.OPEN) { connectToServer(ws, address); } }, RETRY_DELAY_MS); return; } sendToClient(ws, { type: "status", status, message: statusMessage, connectSequence: gameConnection?.connectSequence, mapName: gameConnection?.mapName, }); }); gameConnection.on("ping", (ms) => { sendToClient(ws, { type: "ping", ms }); }); let forwardedPackets = 0; gameConnection.on("packet", (packetData) => { forwardedPackets++; if (ws.readyState === WebSocket.OPEN) { ws.send(packetData, { binary: true }); } else { relayLog.warn( { wsState: ws.readyState, total: forwardedPackets }, "Dropped game packet — WebSocket not open", ); } if (forwardedPackets <= 5 || forwardedPackets % 500 === 0) { relayLog.debug( { bytes: packetData.length, total: forwardedPackets }, "Forwarded game packet to browser", ); } }); gameConnection.on("error", (err) => { relayLog.error({ err }, "Game connection error"); sendToClient(ws, { type: "error", message: err.message, }); }); gameConnection.on("close", () => { relayLog.info("Game connection closed"); gameConnection = null; }); await gameConnection.connect(); } ws.on("message", async (data, isBinary) => { try { if (isBinary) { return; } const message: ClientMessage = JSON.parse(data.toString()); await handleClientMessage(ws, message); } catch (e) { const err = e instanceof Error ? e.message : String(e); relayLog.error({ err: e }, "Error handling client message"); sendToClient(ws, { type: "error", message: err }); } }); ws.on("close", () => { relayLog.info("Browser client disconnected"); if (retryTimer) { clearTimeout(retryTimer); retryTimer = null; } if (gameConnection) { gameConnection.disconnect(); gameConnection = null; } }); async function handleClientMessage( ws: WebSocket, message: ClientMessage, ): Promise { switch (message.type) { case "listServers": { relayLog.info("Querying master server for server list"); try { const servers = await queryServerList(MASTER_SERVER); cachedServers = servers; relayLog.info( { count: servers.length }, "Returning server list to browser", ); sendToClient(ws, { type: "serverList", servers }); } catch (e) { relayLog.error({ err: e }, "Master query failed"); sendToClient(ws, { type: "error", message: `Master query failed: ${e}`, }); } break; } case "joinServer": { relayLog.info({ address: message.address }, "Join server requested"); if (gameConnection) { relayLog.info("Disconnecting existing game connection"); gameConnection.disconnect(); } if (retryTimer) { clearTimeout(retryTimer); retryTimer = null; } retryCount = 0; lastJoinAddress = message.address; await connectToServer(ws, message.address); break; } case "disconnect": { relayLog.info("Disconnect requested"); if (retryTimer) { clearTimeout(retryTimer); retryTimer = null; } if (gameConnection) { gameConnection.disconnect(); gameConnection = null; } break; } case "sendCommand": { if (gameConnection) { const authEvents = [ "t2csri_pokeClient", "t2csri_getChallengeChunk", "t2csri_decryptChallenge", ]; if (authEvents.includes(message.command)) { relayLog.debug( { event: message.command }, "Forwarding auth event from browser", ); gameConnection.handleAuthEvent( message.command, message.args, ); } else { relayLog.debug( { command: message.command }, "Forwarding command to server", ); gameConnection.sendCommand(message.command, ...message.args); } } break; } case "sendCRCResponse": { if (gameConnection) { relayLog.debug("Forwarding CRC response from browser (legacy echo)"); gameConnection.handleCRCChallenge( message.crcValue, message.field1, message.field2, ); } break; } case "sendCRCCompute": { if (gameConnection) { relayLog.info( { datablocks: message.datablocks.length, includeTextures: message.includeTextures }, "Computing CRC from game files", ); gameConnection.computeAndSendCRC( message.seed, message.field2, message.datablocks, message.includeTextures, GAME_BASE_PATH, ); } break; } case "sendGhostAck": { if (gameConnection) { relayLog.debug("Forwarding ghost ack from browser"); gameConnection.handleGhostAlwaysDone( message.sequence, message.ghostCount, ); } break; } case "wsPing": { sendToClient(ws, { type: "wsPong", ts: message.ts }); break; } case "sendMove": { if (gameConnection) { gameConnection.sendMove({ x: message.move.x, y: message.move.y, z: message.move.z, yaw: message.move.yaw, pitch: message.move.pitch, roll: message.move.roll, freeLook: message.move.freeLook, trigger: message.move.trigger, }); } break; } } } }); function sendToClient(ws: WebSocket, message: ServerMessage): void { if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify(message)); } }