t2-mapper/relay/server.ts

381 lines
11 KiB
TypeScript

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<string, { ok: boolean; detail?: string }> = {};
// 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 lastWarriorName: string | undefined;
let retryCount = 0;
let retryTimer: ReturnType<typeof setTimeout> | 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,
warriorName?: string,
): Promise<void> {
if (gameConnection) {
gameConnection.disconnect();
}
gameConnection = new GameConnection(address, { warriorName });
// 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, lastWarriorName);
}
}, 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<void> {
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, warriorName: message.warriorName },
"Join server requested",
);
if (gameConnection) {
relayLog.info("Disconnecting existing game connection");
gameConnection.disconnect();
}
if (retryTimer) {
clearTimeout(retryTimer);
retryTimer = null;
}
retryCount = 0;
lastJoinAddress = message.address;
lastWarriorName = message.warriorName;
await connectToServer(ws, message.address, message.warriorName);
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));
}
}