v0.8.2: 工单大清理 - 编年史分页+限时挑战+星潮深化+云排行榜+手写叙事+socket多人星潮+UI打磨

工单 #8 编年史上限+分页:
- engine.ts: slice(-50)→slice(-200)
- ChronicleDialog.tsx: 加分页(每页10条)+上一页/下一页+页码显示

工单 #5 限时挑战 (subagent 10-a):
- beacon.ts: BeaconTimedChallenge + getTimedSlotKey(4h时段) + generateTimedChallenge
- BeaconPanel.tsx: amber主题限时区块 + 倒计时 + <30min紧急状态
- gameStore: trackBeacon 加 timedJustCompleted + claimTimedBeacon action

工单 #3 星潮类型深化 (subagent 10-a):
- starTide.ts: +3种星潮 surge(emerald)/eclipse(rose)/prism(fuchsia)
- TideModifiers: +targetLenBonus/bossWinRateBonus/autoDecodeIntervalMult
- decode.ts: generatePuzzle 加 targetLenBonus 参数
- achievements: ach_tides_all 阈值 6→9

工单 #4/P2 云排行榜 (subagent 10-b):
- mini-services/leaderboard-service/ (端口3030, Hono+bun, 内存1000条)
- API: GET/POST /api/leaderboard + /stats + CORS + 防刷
- beacon.ts: fetchCloudLeaderboard/submitCloudScore
- BeaconPanel: 本地Top20/全球Top100 双tab + YOU徽章高亮

工单 #9 手写叙事 (subagent 10-c):
- chronicle.ts: EPOCH_LORE 5纪元×3节点=15段手写叙事(80-150字/段)
- buildLore 优先手写节点, fallback 模板, 11个变量替换

P3 socket 多人星潮 (subagent 10-c):
- mini-services/star-tide-service/ (端口3031, socket.io)
- 每10-15min广播global-tide, 60s持续, 6种类型权重
- useGlobalTide hook + triggerGlobalTide action
- StarTideIndicator 加 🌐 全球星潮标记

P2 UI打磨:
- page.tsx: CrystalOrb 区加装饰全息环(3层旋转) + 四角标记 + 顶部状态条 + 底部铭文

QA: lint零错误 + dev HTTP200 + VLM 8/10 + 2个mini-service运行中(3030/3031)
This commit is contained in:
2026-06-23 23:57:41 +00:00
parent 6d7c52daa9
commit 191434e6ad
55 changed files with 9026 additions and 129 deletions
+249
View File
@@ -0,0 +1,249 @@
// 回响星核 / Echo Nexus — 全球星潮同步 mini-service
// P3/工单 #10-csocket.io 多人同步星潮
// 端口固定 3031Caddy 通过 ?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"));