250 lines
8.7 KiB
TypeScript
250 lines
8.7 KiB
TypeScript
// 回响星核 / 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<TideType, { name: string; weight: number }> = {
|
||
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<typeof setTimeout> | null = null;
|
||
let tideEndTimer: ReturnType<typeof setTimeout> | 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"));
|