// 回响星核 / Echo Nexus — 全球星潮同步 mini-service // P3/工单 #10-c:socket.io 多人同步星潮 // 端口固定 3031;Caddy 通过 ?XTransformPort=3031 转发。 // 前端连接:io("/?XTransformPort=3031"),path 默认 "/"。 // // 逻辑: // - 服务端每 10-15 分钟(随机间隔)随机选一个星潮类型, // 广播 `global-tide` 事件 { type, name, startedAt, endsAt, durationSec: 60 } // - 客户端连接后立即收到 `tide-state` 事件,包含当前进行中的星潮(如有) // + 下次星潮时间戳 + 服务器时间(用于时钟同步) // - 客户端可随时 emit `get-next-tide`,服务端响应 `next-tide-info` // { current, nextTideAt, serverTime, remainingMs } // - 全球星潮结束时广播 `tide-ended` { endedAt, nextTideAt } // - `admin-trigger-tide`(dev 用):立即触发一次全球星潮,便于 QA 验证 // // 离线容错:服务端宕机时,前端 useGlobalTide hook 会自动重连; // 在断连期间,本地 tickTide 继续按原节奏工作,玩家不会被卡住。 import { createServer } from "http"; import { Server } from "socket.io"; // --------------------------------------------------------------------------- // 配置 // --------------------------------------------------------------------------- /** 全球星潮持续秒数(与前端 triggerGlobalTide 保持一致) */ const DURATION_SEC = 60; /** 首次星潮延迟(毫秒)—— 默认 60s 便于 QA 验证;可通过 env FIRST_TIDE_DELAY_MS 覆盖 */ const FIRST_TIDE_DELAY_MS = Number(process.env.FIRST_TIDE_DELAY_MS) || 60 * 1000; /** 后续星潮最小间隔(10 分钟) */ const MIN_GAP_MS = 10 * 60 * 1000; /** 后续星潮最大间隔(15 分钟) */ const MAX_GAP_MS = 15 * 60 * 1000; /** 固定端口 3031 */ const PORT = 3031; // --------------------------------------------------------------------------- // 星潮类型表(与前端 TIDE_EVENTS 一致:crystal/resonance/ruins/void/core/silence) // --------------------------------------------------------------------------- type TideType = "crystal" | "resonance" | "ruins" | "void" | "core" | "silence"; const TIDE_META: Record = { crystal: { name: "晶体潮", weight: 22 }, resonance: { name: "谐振风暴", weight: 20 }, ruins: { name: "遗迹共振", weight: 16 }, void: { name: "虚空低语", weight: 18 }, core: { name: "星核悸动", weight: 14 }, silence: { name: "寂静期", weight: 10 }, }; const TIDE_TYPES = Object.keys(TIDE_META) as TideType[]; const TIDE_TOTAL_WEIGHT = TIDE_TYPES.reduce((s, t) => s + TIDE_META[t].weight, 0); function rollTide(): TideType { let r = Math.random() * TIDE_TOTAL_WEIGHT; for (const t of TIDE_TYPES) { r -= TIDE_META[t].weight; if (r <= 0) return t; } return "crystal"; } function randomGap(): number { return MIN_GAP_MS + Math.floor(Math.random() * (MAX_GAP_MS - MIN_GAP_MS)); } // --------------------------------------------------------------------------- // 全局状态 // --------------------------------------------------------------------------- interface GlobalTideState { type: TideType; name: string; startedAt: number; endsAt: number; durationSec: number; } let currentTide: GlobalTideState | null = null; let nextTideAt: number = Date.now() + FIRST_TIDE_DELAY_MS; let tideStartTimer: ReturnType | null = null; let tideEndTimer: ReturnType | null = null; // --------------------------------------------------------------------------- // 星潮调度 // --------------------------------------------------------------------------- function startTide(): void { // 若已有进行中的星潮,先结束再开(理论上不应发生,防御性) if (currentTide) { endTide(); } // 清掉残留的 tideEndTimer,避免泄漏 if (tideEndTimer) { clearTimeout(tideEndTimer); tideEndTimer = null; } const type = rollTide(); const now = Date.now(); currentTide = { type, name: TIDE_META[type].name, startedAt: now, endsAt: now + DURATION_SEC * 1000, durationSec: DURATION_SEC, }; console.log( `[global-tide] ✦ ${currentTide.name} (${type}) 已降临 · 持续 ${DURATION_SEC}s · 结束于 ${new Date(currentTide.endsAt).toLocaleTimeString()}` ); io.emit("global-tide", currentTide); // 调度结束 → endTide 会自己 clearTimeout(tideEndTimer); // 结束后自动 scheduleNextTide tideEndTimer = setTimeout(() => { endTide(); scheduleNextTide(); }, DURATION_SEC * 1000); } function endTide(): void { if (!currentTide) return; console.log(`[global-tide] ◌ ${currentTide.name} 已退去`); currentTide = null; io.emit("tide-ended", { endedAt: Date.now(), nextTideAt, }); if (tideEndTimer) { clearTimeout(tideEndTimer); tideEndTimer = null; } } function scheduleNextTide(): void { // 清掉残留的 tideStartTimer,避免泄漏 if (tideStartTimer) { clearTimeout(tideStartTimer); tideStartTimer = null; } const gap = randomGap(); nextTideAt = Date.now() + gap; console.log( `[global-tide] 下次星潮将在 ${Math.round(gap / 1000)}s 后 · ${new Date(nextTideAt).toLocaleTimeString()}` ); tideStartTimer = setTimeout(() => { startTide(); }, gap); } // --------------------------------------------------------------------------- // socket.io // --------------------------------------------------------------------------- // 注意:socket.io `path: '/'` 会拦截所有 HTTP 请求(包括 /health), // 因此本服务不提供独立 HTTP 健康检查端点;QA 用 `curl http://localhost:3031/` // 收到 `{"code":0,"message":"Transport unknown"}` 即表示 socket.io 在线。 const httpServer = createServer(); const io = new Server(httpServer, { // 路径必须为 "/",Caddy 据此转发;同时这也是 catch-all, // 任何 URL 都会被 socket.io 接管(前端默认 /socket.io/ 也会被捕获) path: "/", cors: { origin: "*", methods: ["GET", "POST"] }, pingTimeout: 60000, pingInterval: 25000, }); io.on("connection", (socket) => { console.log(`[socket] 客户端连接: ${socket.id}(当前在线 ${io.engine.clientsCount})`); // 立即推送当前状态 socket.emit("tide-state", { current: currentTide, nextTideAt, serverTime: Date.now(), }); // 客户端查询下次星潮剩余时间 socket.on("get-next-tide", () => { socket.emit("next-tide-info", { current: currentTide, nextTideAt, serverTime: Date.now(), remainingMs: Math.max(0, nextTideAt - Date.now()), }); }); // dev/QA 用:手动触发一次全球星潮 socket.on("admin-trigger-tide", () => { console.log(`[socket] admin-trigger-tide by ${socket.id}`); // 取消尚未触发的下次星潮定时器(startTide 会负责调度本次星潮的结束 + 下次开始) if (tideStartTimer) { clearTimeout(tideStartTimer); tideStartTimer = null; } startTide(); // startTide 内部已 setTimeout(DURATION_SEC) → endTide + scheduleNextTide }); socket.on("disconnect", (reason) => { console.log(`[socket] 客户端断开: ${socket.id}(${reason})`); }); socket.on("error", (err) => { console.error(`[socket] 错误 (${socket.id}):`, err); }); }); // --------------------------------------------------------------------------- // 启动 // --------------------------------------------------------------------------- httpServer.listen(PORT, () => { console.log(`[star-tide-service] 监听端口 ${PORT}`); console.log( `[star-tide-service] 首次星潮将在 ${Math.round(FIRST_TIDE_DELAY_MS / 1000)}s 后 · ${new Date(nextTideAt).toLocaleTimeString()}` ); console.log(`[star-tide-service] 后续星潮间隔:${MIN_GAP_MS / 1000 / 60}-${MAX_GAP_MS / 1000 / 60} 分钟随机`); console.log(`[star-tide-service] QA 验证:curl http://localhost:${PORT}/ → {"code":0,"message":"Transport unknown"}`); // 调度首次星潮(startTide 内部会在 60s 后自动 endTide + scheduleNextTide,无需在此重复调度) tideStartTimer = setTimeout(() => { startTide(); }, FIRST_TIDE_DELAY_MS); }); // --------------------------------------------------------------------------- // 优雅退出 // --------------------------------------------------------------------------- function shutdown(signal: string) { console.log(`[star-tide-service] 收到 ${signal},正在关闭…`); if (tideStartTimer) clearTimeout(tideStartTimer); if (tideEndTimer) clearTimeout(tideEndTimer); io.close(() => { httpServer.close(() => { console.log("[star-tide-service] 已关闭"); process.exit(0); }); }); } process.on("SIGTERM", () => shutdown("SIGTERM")); process.on("SIGINT", () => shutdown("SIGINT"));