import { createReadStream, existsSync, statSync } from "node:fs"; import { createServer } from "node:http"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { GameDatabaseError, openGameDatabase } from "./database.mjs"; import { createArenaService } from "./arena-service.mjs"; import { createDungeonRaceService } from "./dungeon-race-service.mjs"; import { publicManastormAdminConfig } from "./manastorm-config.mjs"; import { createOnlineSessionManager } from "./online-session.mjs"; import { upgradeArenaWebSocket } from "./websocket.mjs"; import { createBundledContentHandler } from "./bundled-content.mjs"; import { createBalanceLabJobService } from "./balance-lab-jobs.mjs"; const MIME_TYPES = new Map([ [".css", "text/css; charset=utf-8"], [".glb", "model/gltf-binary"], [".html", "text/html; charset=utf-8"], [".ico", "image/x-icon"], [".jpeg", "image/jpeg"], [".jpg", "image/jpeg"], [".js", "text/javascript; charset=utf-8"], [".json", "application/json; charset=utf-8"], [".ktx2", "image/ktx2"], [".mp3", "audio/mpeg"], [".ogg", "audio/ogg"], [".png", "image/png"], [".sqlite", "application/vnd.sqlite3"], [".svg", "image/svg+xml"], [".wasm", "application/wasm"], [".webm", "video/webm"], [".webp", "image/webp"], ]); function json(response, status, body, extraHeaders = {}) { const content = Buffer.from(JSON.stringify(body)); response.writeHead(status, { "Content-Type": "application/json; charset=utf-8", "Content-Length": content.length, "Cache-Control": "no-store", ...extraHeaders, }); response.end(content); } function parseAllowedOrigins(value) { return new Set(String(value ?? "").split(",").map((entry) => entry.trim()).filter(Boolean)); } function booleanFlag(value) { return ["1", "true", "yes", "on"].includes(String(value ?? "").trim().toLowerCase()); } function acceptsEncoding(value, requested) { const encodings = new Map(String(value ?? "").split(",").map((entry) => { const [name, ...parameters] = entry.trim().toLowerCase().split(";"); const quality = parameters.find((part) => part.trim().startsWith("q=")); return [name, quality ? Number(quality.trim().slice(2)) : 1]; })); return (encodings.get(requested) ?? encodings.get("*") ?? 0) > 0; } function bearerToken(request) { return /^Bearer\s+(.+)$/i.exec(String(request.headers.authorization ?? ""))?.[1] ?? ""; } async function readJson(request, limit) { const chunks = []; let size = 0; for await (const chunk of request) { size += chunk.length; if (size > limit) throw new GameDatabaseError("Request body is too large.", 413, "body_too_large"); chunks.push(chunk); } if (size === 0) return {}; try { return JSON.parse(Buffer.concat(chunks).toString("utf8")); } catch { throw new GameDatabaseError("Request body must be valid JSON.", 400, "invalid_json"); } } function resolveStaticFile(staticDir, pathname, spaFallback = true) { let decoded; try { decoded = decodeURIComponent(pathname); } catch { return null; } const relative = decoded.replace(/^\/+/, ""); const candidate = path.resolve(staticDir, relative || "index.html"); if (candidate !== staticDir && !candidate.startsWith(`${staticDir}${path.sep}`)) return null; if (existsSync(candidate) && statSync(candidate).isFile()) return candidate; if (spaFallback && !path.extname(relative)) { const index = path.join(staticDir, "index.html"); if (existsSync(index)) return index; } return null; } function parseRange(value, size) { const match = /^bytes=(\d*)-(\d*)$/.exec(String(value ?? "")); if (!match) return null; let start = match[1] ? Number(match[1]) : null; let end = match[2] ? Number(match[2]) : null; if (start === null && end !== null) { start = Math.max(0, size - end); end = size - 1; } else { start ??= 0; end ??= size - 1; } if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 0 || end < start || start >= size) return null; return { start, end: Math.min(end, size - 1) }; } function serveStatic(request, response, staticDir, pathname, options = {}) { if (request.method !== "GET" && request.method !== "HEAD") return false; const filePath = resolveStaticFile(staticDir, pathname, options.spaFallback !== false); if (!filePath) return false; const stats = statSync(filePath); const extension = path.extname(filePath).toLowerCase(); const range = request.headers.range ? parseRange(request.headers.range, stats.size) : null; const canEncode = !range && !request.headers.range; const encoding = canEncode && existsSync(`${filePath}.br`) && acceptsEncoding(request.headers["accept-encoding"], "br") ? "br" : canEncode && existsSync(`${filePath}.gz`) && acceptsEncoding(request.headers["accept-encoding"], "gzip") ? "gzip" : null; const encodedPath = encoding ? `${filePath}.${encoding === "br" ? "br" : "gz"}` : null; const servedPath = encodedPath ?? filePath; const servedStats = encodedPath ? statSync(encodedPath) : stats; const headers = { "Content-Type": MIME_TYPES.get(extension) ?? "application/octet-stream", "Accept-Ranges": "bytes", "Cache-Control": options.immutable ? "public, max-age=31536000, immutable" : ["index.html", "content-cache-sw.js"].includes(path.basename(filePath)) || options.noCache ? "no-cache" : "public, max-age=3600", "X-Content-Type-Options": "nosniff", ...(encodedPath ? { "Content-Encoding": encoding, Vary: "Accept-Encoding" } : {}), }; if (request.headers.range && !range) { response.writeHead(416, { ...headers, "Content-Range": `bytes */${stats.size}` }); response.end(); return true; } if (range) { response.writeHead(206, { ...headers, "Content-Length": range.end - range.start + 1, "Content-Range": `bytes ${range.start}-${range.end}/${stats.size}`, }); if (request.method === "HEAD") response.end(); else createReadStream(filePath, range).pipe(response); return true; } response.writeHead(200, { ...headers, "Content-Length": servedStats.size }); if (request.method === "HEAD") response.end(); else createReadStream(servedPath).pipe(response); return true; } function originHeaders(request, allowedOrigins) { const origin = String(request.headers.origin ?? ""); if (!origin || !allowedOrigins.has(origin)) return {}; return { "Access-Control-Allow-Origin": origin, "Access-Control-Allow-Credentials": "true", Vary: "Origin" }; } function createAuthLimiter() { const buckets = new Map(); return (key) => { const now = Date.now(); const recent = (buckets.get(key) ?? []).filter((timestamp) => now - timestamp < 60_000); if (recent.length >= 12) return false; recent.push(now); buckets.set(key, recent); if (buckets.size > 1_000) { for (const [entry, timestamps] of buckets) { if (!timestamps.some((timestamp) => now - timestamp < 60_000)) buckets.delete(entry); } } return true; }; } export function createGameServer(options = {}) { const staticDir = path.resolve(options.staticDir ?? process.env.STATIC_DIR ?? "dist"); const contentDir = path.resolve(options.contentDir ?? process.env.CONTENT_DIR ?? "content"); const serveBundledContent = createBundledContentHandler(staticDir); const allowedOrigins = parseAllowedOrigins(options.corsOrigins ?? process.env.CORS_ORIGINS); const bodyLimit = Math.max(1024, Number(options.bodyLimit ?? process.env.MAX_JSON_BODY_BYTES ?? 5 * 1024 * 1024)); const trustProxy = String(options.trustProxy ?? process.env.TRUST_PROXY ?? "").toLowerCase() === "true"; const enableShelvedModes = options.enableShelvedModes ?? booleanFlag(process.env.HEALER_MAN_ENABLE_SHELVED_MODES); const gameDatabase = options.database ?? openGameDatabase(options); const balanceLabJobs = options.balanceLabJobs ?? createBalanceLabJobService({ dataDir: options.dataDir ?? path.dirname(gameDatabase.databasePath), projectRoot: options.projectRoot, pythonExecutable: options.pythonExecutable, cooldownPolicyProvider: () => gameDatabase.getCompanionCooldownPolicy(), readinessPolicyProvider: () => gameDatabase.getCompanionSpecReadiness(), }); const onlineSessions = createOnlineSessionManager(); const arenaService = createArenaService({ database: gameDatabase, autoPump: enableShelvedModes }); const dungeonRaceService = createDungeonRaceService({ database: gameDatabase }); const allowAuthRequest = createAuthLimiter(); const allowReportRequest = createAuthLimiter(); const allowDungeonRunRequest = createAuthLimiter(); gameDatabase.pruneExpiredSessions(); const server = createServer(async (request, response) => { const cors = originHeaders(request, allowedOrigins); const url = new URL(request.url ?? "/", "http://localhost"); response.setHeader("X-Frame-Options", "DENY"); response.setHeader("Referrer-Policy", "same-origin"); for (const [name, value] of Object.entries(cors)) response.setHeader(name, value); if (request.method === "OPTIONS") { if (request.headers.origin && Object.keys(cors).length === 0) { json(response, 403, { error: "Origin is not allowed.", code: "origin_not_allowed" }); return; } response.writeHead(204, { ...cors, "Access-Control-Allow-Headers": "Authorization, Content-Type", "Access-Control-Allow-Methods": "GET, HEAD, POST, PUT, DELETE, OPTIONS", "Access-Control-Max-Age": "86400", }); response.end(); return; } try { if (url.pathname === "/api/health" && request.method === "GET") { json(response, 200, { ok: true, service: "healer-man", time: new Date().toISOString() }); return; } if (url.pathname === "/api/dungeon-route-approvals" && request.method === "GET") { json(response, 200, gameDatabase.listDungeonRouteApprovals()); return; } if (url.pathname === "/api/gm/dungeon-routes" && request.method === "GET") { const account = gameDatabase.authenticate(bearerToken(request)); json(response, 200, gameDatabase.listDungeonRouteReviews(account)); return; } const dungeonRouteReviewMatch = /^\/api\/gm\/dungeon-routes\/([^/]+)\/([^/]+)\/review$/.exec(url.pathname); if (dungeonRouteReviewMatch && request.method === "PUT") { const account = gameDatabase.authenticate(bearerToken(request)); const body = await readJson(request, 64 * 1024); const review = gameDatabase.putDungeonRouteReview( account, decodeURIComponent(dungeonRouteReviewMatch[1]), decodeURIComponent(dungeonRouteReviewMatch[2]), body, ); json(response, 200, { review }); return; } if (url.pathname === "/api/gm/dungeon-sim/replays" && request.method === "GET") { const account = gameDatabase.authenticate(bearerToken(request)); json(response, 200, gameDatabase.listDungeonSimulationReplays(account, { dungeonId: url.searchParams.get("dungeon"), })); return; } if (url.pathname === "/api/gm/dungeon-sim/replays" && request.method === "POST") { const account = gameDatabase.authenticate(bearerToken(request)); const replay = gameDatabase.saveDungeonSimulationReplay(account, await readJson(request, 64 * 1024 * 1024)); json(response, 201, { replay }); return; } const dungeonSimulationReportMatch = /^\/api\/gm\/dungeon-sim\/replays\/([^/]+)\/report$/.exec(url.pathname); if (dungeonSimulationReportMatch && request.method === "GET") { const account = gameDatabase.authenticate(bearerToken(request)); json(response, 200, gameDatabase.getDungeonSimulationReport(account, dungeonSimulationReportMatch[1]), { "Content-Disposition": `attachment; filename="dungeon-simulation-${dungeonSimulationReportMatch[1]}.json"`, }); return; } const dungeonSimulationReplayMatch = /^\/api\/gm\/dungeon-sim\/replays\/([^/]+)$/.exec(url.pathname); if (dungeonSimulationReplayMatch && request.method === "GET") { const account = gameDatabase.authenticate(bearerToken(request)); json(response, 200, gameDatabase.getDungeonSimulationReplay(account, dungeonSimulationReplayMatch[1])); return; } if (dungeonSimulationReplayMatch && request.method === "DELETE") { const account = gameDatabase.authenticate(bearerToken(request)); json(response, 200, gameDatabase.deleteDungeonSimulationReplay(account, dungeonSimulationReplayMatch[1])); return; } if (!enableShelvedModes && ( url.pathname === "/api/manastorm-config" || url.pathname.startsWith("/api/gm/manastorm-config") || url.pathname === "/api/arena" || url.pathname.startsWith("/api/arena/") )) { json(response, 503, { error: "This feature is temporarily unavailable.", code: "feature_disabled" }); return; } if ((url.pathname === "/api/auth/register" || url.pathname === "/api/auth/login") && request.method === "POST") { const forwarded = trustProxy ? String(request.headers["x-forwarded-for"] ?? "").split(",")[0]?.trim() : ""; if (!allowAuthRequest(forwarded || request.socket.remoteAddress || "unknown")) { throw new GameDatabaseError("Too many login attempts. Try again shortly.", 429, "rate_limited"); } const body = await readJson(request, 64 * 1024); const result = url.pathname.endsWith("register") ? await gameDatabase.register(body.username, body.password) : await gameDatabase.login(body.username, body.password); json(response, url.pathname.endsWith("register") ? 201 : 200, result); return; } if (url.pathname === "/api/auth/logout" && request.method === "POST") { gameDatabase.logout(bearerToken(request)); json(response, 200, { ok: true }); return; } if (url.pathname === "/api/me" && request.method === "GET") { json(response, 200, { account: gameDatabase.authenticate(bearerToken(request)) }); return; } if (url.pathname === "/api/dungeon-races/leaderboard" && request.method === "GET") { json(response, 200, gameDatabase.listDungeonRaceLeaderboard({ view: url.searchParams.get("view") ?? "wins", period: url.searchParams.get("period") ?? "30d", dungeonId: url.searchParams.get("dungeon") || null, limit: Number(url.searchParams.get("limit") ?? 20), offset: Number(url.searchParams.get("offset") ?? 0), })); return; } if (url.pathname === "/api/dungeon-races/queue" && request.method === "POST") { const account = gameDatabase.authenticate(bearerToken(request)); json(response, 200, dungeonRaceService.queue(account, await readJson(request, 4 * 1024))); return; } if (url.pathname === "/api/dungeon-races/queue" && request.method === "DELETE") { const account = gameDatabase.authenticate(bearerToken(request)); json(response, 200, dungeonRaceService.leave(account)); return; } if (url.pathname === "/api/dungeon-races/state" && request.method === "GET") { const account = gameDatabase.authenticate(bearerToken(request)); json(response, 200, dungeonRaceService.state(account)); return; } const dungeonRaceMatch = /^\/api\/dungeon-races\/(race-[A-Za-z0-9-]+)\/(ready|finish|forfeit)$/.exec(url.pathname); if (dungeonRaceMatch && request.method === "POST") { const account = gameDatabase.authenticate(bearerToken(request)); const [, id, action] = dungeonRaceMatch; json(response, 200, action === "ready" ? dungeonRaceService.ready(account, id) : action === "finish" ? dungeonRaceService.finish(account, id, await readJson(request, 4 * 1024)) : dungeonRaceService.leave(account, id)); return; } if (url.pathname === "/api/dungeon-leaderboards" && request.method === "GET") { json(response, 200, gameDatabase.listDungeonLeaderboard({ dungeonId: url.searchParams.get("dungeon"), difficultyId: url.searchParams.get("difficulty"), clear: url.searchParams.get("clear"), bossDifficulty: url.searchParams.has("bossDifficulty") ? Number(url.searchParams.get("bossDifficulty")) : undefined, offset: url.searchParams.get("offset"), limit: url.searchParams.get("limit") })); return; } if (url.pathname === "/api/dungeon-runs" && request.method === "POST") { const token = bearerToken(request); const account = token ? gameDatabase.authenticate(token) : null; if (!allowDungeonRunRequest(account?.id ?? request.socket.remoteAddress)) throw new GameDatabaseError("Too many run submissions. Please try again shortly.", 429, "rate_limited"); json(response, 201, gameDatabase.submitDungeonRun(account, await readJson(request, 32 * 1024))); return; } const dungeonRunMatch = /^\/api\/dungeon-runs\/([A-Za-z0-9_-]{8,100})$/.exec(url.pathname); if (dungeonRunMatch && request.method === "GET") { json(response, 200, gameDatabase.getDungeonRun(dungeonRunMatch[1])); return; } if (url.pathname === "/api/boss-testing/reviews" && request.method === "GET") { json(response, 200, gameDatabase.listBossTestingReviews()); return; } if (url.pathname === "/api/boss-testing/reviews" && request.method === "POST") { const token = bearerToken(request); const account = token ? gameDatabase.authenticate(token) : null; if (!allowReportRequest(account?.id ?? request.socket.remoteAddress)) { throw new GameDatabaseError("Too many updates. Please wait a minute and try again.", 429, "rate_limited"); } json(response, 200, { review: gameDatabase.saveBossTestingReview(account, await readJson(request, 8 * 1024)) }); return; } if (url.pathname === "/api/randomized-creature-readiness" && request.method === "GET") { json(response, 200, gameDatabase.listRandomizedCreatureReadiness()); return; } if (url.pathname === "/api/gm/randomized-creature-readiness" && request.method === "PUT") { const account = gameDatabase.authenticate(bearerToken(request)); json(response, 200, { review: gameDatabase.saveRandomizedCreatureReadiness( account, await readJson(request, 8 * 1024), ) }); return; } if (url.pathname === "/api/dungeon-reports" && request.method === "GET") { json(response, 200, gameDatabase.listDungeonReports({ status: url.searchParams.get("status"), dungeonId: url.searchParams.get("dungeon"), })); return; } if (url.pathname === "/api/dungeon-reports" && request.method === "POST") { const token = bearerToken(request); const account = token ? gameDatabase.authenticate(token) : null; if (!allowReportRequest(account?.id ?? request.socket.remoteAddress)) { throw new GameDatabaseError("Too many reports. Please wait a minute and try again.", 429, "rate_limited"); } const body = await readJson(request, 8 * 1024); json(response, 201, { report: gameDatabase.submitDungeonReport(account, body) }); return; } const reportMatch = /^\/api\/dungeon-reports\/([^/]+)$/.exec(url.pathname); if (reportMatch && request.method === "PATCH") { const account = gameDatabase.authenticate(bearerToken(request)); const body = await readJson(request, 8 * 1024); json(response, 200, { report: gameDatabase.updateDungeonReport(account, reportMatch[1], body) }); return; } if (url.pathname === "/api/cloud-save" && request.method === "GET") { const account = gameDatabase.authenticate(bearerToken(request)); json(response, 200, gameDatabase.getCloudSave(account.id)); return; } if (url.pathname === "/api/cloud-save" && request.method === "PUT") { const account = gameDatabase.authenticate(bearerToken(request)); const body = await readJson(request, bodyLimit); json(response, 200, gameDatabase.putCloudSave(account.id, body.data)); return; } if (url.pathname === "/api/online-group" && request.method === "GET") { const account = gameDatabase.authenticate(bearerToken(request)); json(response, 200, gameDatabase.onlineGroupState(account.id)); return; } if (url.pathname === "/api/online-group/invite" && request.method === "POST") { const account = gameDatabase.authenticate(bearerToken(request)); const body = await readJson(request, 64 * 1024); json(response, 201, gameDatabase.inviteOnlineGroupMember(account, body.username)); return; } if (url.pathname === "/api/online-group/invite/respond" && request.method === "POST") { const account = gameDatabase.authenticate(bearerToken(request)); const body = await readJson(request, 64 * 1024); json(response, 200, gameDatabase.respondToOnlineGroupInvite(account.id, body.inviteId, body.accept === true)); return; } if (url.pathname === "/api/online-group/leave" && request.method === "POST") { const account = gameDatabase.authenticate(bearerToken(request)); json(response, 200, gameDatabase.leaveOnlineGroup(account.id)); return; } if (url.pathname === "/api/online-group/remove" && request.method === "POST") { const account = gameDatabase.authenticate(bearerToken(request)); const body = await readJson(request, 64 * 1024); json(response, 200, gameDatabase.removeOnlineGroupMember(account.id, body.accountId)); return; } if (url.pathname === "/api/online-group/member" && request.method === "PUT") { const account = gameDatabase.authenticate(bearerToken(request)); const body = await readJson(request, 64 * 1024); json(response, 200, gameDatabase.updateOnlineGroupMember(account.id, body.character, body.role)); return; } if (url.pathname === "/api/online-group/activity" && request.method === "POST") { const account = gameDatabase.authenticate(bearerToken(request)); const body = await readJson(request, 64 * 1024); if (!enableShelvedModes && body.type === "manastorm") { json(response, 503, { error: "Manastorm groups are temporarily unavailable.", code: "feature_disabled" }); return; } json(response, 200, gameDatabase.startOnlineGroupActivity(account.id, body)); return; } if (url.pathname === "/api/online-group/activity/clear" && request.method === "POST") { const account = gameDatabase.authenticate(bearerToken(request)); json(response, 200, gameDatabase.clearOnlineGroupActivity(account.id)); return; } if (url.pathname === "/api/online-session/sync" && request.method === "POST") { const account = gameDatabase.authenticate(bearerToken(request)); const body = await readJson(request, bodyLimit); const groupSnapshot = gameDatabase.onlineGroupState(account.id); json(response, 200, onlineSessions.sync(account, groupSnapshot, body)); return; } if (url.pathname === "/api/arena/state" && request.method === "GET") { const account = gameDatabase.authenticate(bearerToken(request)); json(response, 200, arenaService.arenaState(account.id, url.searchParams.get("characterId"))); return; } const arenaLoadoutMatch = /^\/api\/arena\/loadouts\/(\d+)$/.exec(url.pathname); if (arenaLoadoutMatch && request.method === "PUT") { const account = gameDatabase.authenticate(bearerToken(request)); const body = await readJson(request, 64 * 1024); json(response, 200, gameDatabase.putArenaLoadout(account.id, body.characterId, arenaLoadoutMatch[1], body)); return; } if (arenaLoadoutMatch && request.method === "DELETE") { const account = gameDatabase.authenticate(bearerToken(request)); const body = await readJson(request, 16 * 1024); json(response, 200, gameDatabase.deleteArenaLoadout(account.id, body.characterId, arenaLoadoutMatch[1])); return; } if (url.pathname === "/api/arena/queue" && request.method === "POST") { const account = gameDatabase.authenticate(bearerToken(request)); const body = await readJson(request, 128 * 1024); json(response, 201, arenaService.enqueue(account, body)); return; } if (url.pathname === "/api/arena/queue" && request.method === "DELETE") { const account = gameDatabase.authenticate(bearerToken(request)); json(response, 200, arenaService.leaveQueue(account.id)); return; } if (url.pathname === "/api/arena/ready" && request.method === "POST") { const account = gameDatabase.authenticate(bearerToken(request)); json(response, 200, arenaService.ready(account.id)); return; } if (url.pathname === "/api/arena/socket-ticket" && request.method === "POST") { const account = gameDatabase.authenticate(bearerToken(request)); json(response, 201, arenaService.socketTicket(account.id)); return; } if (url.pathname === "/api/manastorm-config" && request.method === "GET") { const current = gameDatabase.getManastormConfig(); json(response, 200, { revision: current.revision, updatedAt: current.updatedAt, config: publicManastormAdminConfig(current.config), }); return; } if (url.pathname === "/api/gm/manastorm-config" && request.method === "GET") { const account = gameDatabase.authenticate(bearerToken(request)); if (!gameDatabase.isGameMaster(account)) { throw new GameDatabaseError("Game Master access is required.", 403, "gm_required"); } const current = gameDatabase.getManastormConfig(); json(response, 200, { revision: current.revision, updatedAt: current.updatedAt, config: current.config, }); return; } if (url.pathname === "/api/gm/manastorm-config" && request.method === "PUT") { const account = gameDatabase.authenticate(bearerToken(request)); if (!gameDatabase.isGameMaster(account)) { console.warn(`Denied Manastorm GM write for ${account.username} (${account.id})`); throw new GameDatabaseError("Game Master access is required.", 403, "gm_required"); } const body = await readJson(request, bodyLimit); const result = gameDatabase.putManastormConfig(account, body.expectedRevision, body.config); console.info(`Manastorm configuration revision ${result.revision} published by ${account.username}`); json(response, 200, result); return; } if (url.pathname === "/api/companion-cooldown-policy" && request.method === "GET") { const current = gameDatabase.getCompanionCooldownPolicy(); json(response, 200, { revision: current.revision, updatedAt: current.updatedAt, config: current.config, }); return; } if (url.pathname === "/api/gm/companion-cooldown-policy" && request.method === "GET") { const account = gameDatabase.authenticate(bearerToken(request)); if (!gameDatabase.isGameMaster(account)) { throw new GameDatabaseError("Game Master access is required.", 403, "gm_required"); } json(response, 200, gameDatabase.getCompanionCooldownPolicy()); return; } if (url.pathname === "/api/gm/companion-cooldown-policy" && request.method === "PUT") { const account = gameDatabase.authenticate(bearerToken(request)); if (!gameDatabase.isGameMaster(account)) { throw new GameDatabaseError("Game Master access is required.", 403, "gm_required"); } const body = await readJson(request, bodyLimit); const result = gameDatabase.putCompanionCooldownPolicy(account, body.expectedRevision, body.config); console.info(`Companion cooldown policy revision ${result.revision} published by ${account.username}`); json(response, 200, result); return; } if (url.pathname === "/api/party-buff-policy" && request.method === "GET") { const current = gameDatabase.getPartyBuffPolicy(); json(response, 200, { revision: current.revision, updatedAt: current.updatedAt, config: current.config }); return; } if (url.pathname === "/api/dungeon-queue-policy" && request.method === "GET") { const current = gameDatabase.getDungeonQueuePolicy(); json(response, 200, { revision: current.revision, updatedAt: current.updatedAt, config: current.config }); return; } if (url.pathname === "/api/gm/dungeon-queue-policy" && request.method === "GET") { const account = gameDatabase.authenticate(bearerToken(request)); if (!gameDatabase.isGameMaster(account)) throw new GameDatabaseError("Game Master access is required.", 403, "gm_required"); json(response, 200, gameDatabase.getDungeonQueuePolicy()); return; } if (url.pathname === "/api/gm/dungeon-queue-policy" && request.method === "PUT") { const account = gameDatabase.authenticate(bearerToken(request)); if (!gameDatabase.isGameMaster(account)) throw new GameDatabaseError("Game Master access is required.", 403, "gm_required"); const body = await readJson(request, bodyLimit); json(response, 200, gameDatabase.putDungeonQueuePolicy(account, body.expectedRevision, body.config)); return; } if (url.pathname === "/api/gm/party-buff-policy" && request.method === "GET") { const account = gameDatabase.authenticate(bearerToken(request)); if (!gameDatabase.isGameMaster(account)) throw new GameDatabaseError("Game Master access is required.", 403, "gm_required"); json(response, 200, gameDatabase.getPartyBuffPolicy()); return; } if (url.pathname === "/api/gm/party-buff-policy" && request.method === "PUT") { const account = gameDatabase.authenticate(bearerToken(request)); if (!gameDatabase.isGameMaster(account)) throw new GameDatabaseError("Game Master access is required.", 403, "gm_required"); const body = await readJson(request, bodyLimit); const result = gameDatabase.putPartyBuffPolicy(account, body.expectedRevision, body.config); json(response, 200, result); return; } if (url.pathname === "/api/companion-spec-readiness" && request.method === "GET") { const current = gameDatabase.getCompanionSpecReadiness(); json(response, 200, { revision: current.revision, updatedAt: current.updatedAt, config: current.config, }); return; } if (url.pathname === "/api/gm/companion-spec-readiness" && request.method === "GET") { const account = gameDatabase.authenticate(bearerToken(request)); if (!gameDatabase.isGameMaster(account)) { throw new GameDatabaseError("Game Master access is required.", 403, "gm_required"); } json(response, 200, gameDatabase.getCompanionSpecReadiness()); return; } if (url.pathname === "/api/gm/companion-spec-readiness" && request.method === "PUT") { const account = gameDatabase.authenticate(bearerToken(request)); if (!gameDatabase.isGameMaster(account)) { throw new GameDatabaseError("Game Master access is required.", 403, "gm_required"); } const body = await readJson(request, bodyLimit); const result = gameDatabase.putCompanionSpecReadiness(account, body.expectedRevision, body.config); console.info(`Companion readiness revision ${result.revision} published by ${account.username}`); json(response, 200, result); return; } if (url.pathname === "/api/gm/balance-lab/jobs" && request.method === "GET") { const account = gameDatabase.authenticate(bearerToken(request)); if (!gameDatabase.isGameMaster(account)) { throw new GameDatabaseError("Game Master access is required.", 403, "gm_required"); } json(response, 200, { jobs: balanceLabJobs.list() }); return; } if (url.pathname === "/api/gm/balance-lab/runtime" && request.method === "GET") { const account = gameDatabase.authenticate(bearerToken(request)); if (!gameDatabase.isGameMaster(account)) { throw new GameDatabaseError("Game Master access is required.", 403, "gm_required"); } json(response, 200, { runtime: balanceLabJobs.runtime() }); return; } if (url.pathname === "/api/gm/balance-lab/jobs/batch" && request.method === "POST") { const account = gameDatabase.authenticate(bearerToken(request)); if (!gameDatabase.isGameMaster(account)) { throw new GameDatabaseError("Game Master access is required.", 403, "gm_required"); } const jobs = balanceLabJobs.startMany(await readJson(request, 4 * 1024 * 1024), account); console.info(`Balance Lab batch of ${jobs.length} jobs queued by ${account.username}`); json(response, 202, { jobs }); return; } if (url.pathname === "/api/gm/balance-lab/jobs" && request.method === "POST") { const account = gameDatabase.authenticate(bearerToken(request)); if (!gameDatabase.isGameMaster(account)) { throw new GameDatabaseError("Game Master access is required.", 403, "gm_required"); } const job = balanceLabJobs.start(await readJson(request, 32 * 1024), account); console.info(`Balance Lab job ${job.id} queued by ${account.username}`); json(response, 202, { job }); return; } const balanceLabArtifactMatch = /^\/api\/gm\/balance-lab\/jobs\/([0-9a-f-]{36})\/artifacts\/([A-Za-z0-9._-]+)$/.exec(url.pathname); if (balanceLabArtifactMatch && request.method === "GET") { const account = gameDatabase.authenticate(bearerToken(request)); if (!gameDatabase.isGameMaster(account)) { throw new GameDatabaseError("Game Master access is required.", 403, "gm_required"); } const artifact = balanceLabJobs.artifact(balanceLabArtifactMatch[1], balanceLabArtifactMatch[2]); response.writeHead(200, { "Content-Type": artifact.contentType, "Content-Length": artifact.size, "Cache-Control": "no-store", "Content-Disposition": `inline; filename="${balanceLabArtifactMatch[2]}"`, "X-Content-Type-Options": "nosniff", }); artifact.stream().pipe(response); return; } const balanceLabJobMatch = /^\/api\/gm\/balance-lab\/jobs\/([0-9a-f-]{36})$/.exec(url.pathname); if (balanceLabJobMatch && request.method === "GET") { const account = gameDatabase.authenticate(bearerToken(request)); if (!gameDatabase.isGameMaster(account)) { throw new GameDatabaseError("Game Master access is required.", 403, "gm_required"); } json(response, 200, { job: balanceLabJobs.get(balanceLabJobMatch[1]) }); return; } if (url.pathname.startsWith("/api/")) { json(response, 404, { error: "API route not found.", code: "not_found" }); return; } if (serveBundledContent(request, response, url.pathname)) return; if (url.pathname === "/content/bootstrap-manifest.json" || url.pathname.startsWith("/content/bundled/")) { if (serveStatic(request, response, staticDir, url.pathname, { spaFallback: false, immutable: url.pathname.startsWith("/content/bundled/"), noCache: url.pathname === "/content/bootstrap-manifest.json", })) return; } if (url.pathname === "/content" || url.pathname.startsWith("/content/")) { const contentPath = url.pathname.slice("/content".length) || "/manifest.json"; const served = serveStatic(request, response, contentDir, contentPath, { spaFallback: false, immutable: contentPath.startsWith("/objects/"), noCache: !contentPath.startsWith("/objects/"), }); if (served) return; json(response, 404, { error: "Content file not found.", code: "content_not_found" }); return; } const immutableViteAsset = /^\/assets\/.+-[A-Za-z0-9_-]{8,}\.[A-Za-z0-9]+$/.test(url.pathname); if (serveStatic(request, response, staticDir, url.pathname, { immutable: immutableViteAsset })) return; json(response, 404, { error: "Not found.", code: "not_found" }); } catch (error) { const known = error instanceof GameDatabaseError; const status = known ? error.status : 500; if (status >= 500) console.error(error); if (!response.headersSent) json(response, status, { error: known ? error.message : "The server could not complete the request.", code: known ? error.code : "internal_error", }); else response.destroy(error instanceof Error ? error : undefined); } }); server.on("upgrade", (request, socket, head) => { if (!enableShelvedModes && new URL(request.url ?? "/", "http://localhost").pathname === "/ws/arena") { const body = Buffer.from(JSON.stringify({ error: "This feature is temporarily unavailable.", code: "feature_disabled", })); socket.end(`HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\nContent-Type: application/json\r\nContent-Length: ${body.length}\r\n\r\n${body}`); return; } if (!upgradeArenaWebSocket(request, socket, head, arenaService)) { socket.end("HTTP/1.1 404 Not Found\r\nConnection: close\r\nContent-Length: 0\r\n\r\n"); } }); server.on("close", () => { balanceLabJobs.close?.(); onlineSessions.clear(); arenaService.close(); dungeonRaceService.close(); gameDatabase.close(); }); return { server, database: gameDatabase, arenaService, dungeonRaceService, balanceLabJobs, staticDir, contentDir, enableShelvedModes }; } if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { const host = process.env.HOST ?? "0.0.0.0"; const port = Number(process.env.PORT ?? 4173); const runtime = createGameServer(); runtime.server.listen(port, host, () => { console.log(`Healer Man server listening on http://${host}:${port}`); console.log(`Serving ${runtime.staticDir}`); console.log(`Content directory: ${runtime.contentDir}`); console.log(`SQLite database: ${runtime.database.databasePath}`); }); }