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
@@ -0,0 +1,93 @@
# Echo Nexus · 云排行榜 mini-service
回响星核 / Echo Nexus 的 P2 云排行榜服务(v0.8.2 / Task 10-b)。
把信标本机排行榜(`localStorage`)升级为云端 Top100,玩家间可比。
## 技术栈
- **运行时**Bun(原生 TypeScript,无需编译)
- **框架**Hono(轻量 web 框架,bun 原生支持)
- **存储**:内存数组,重启清空(够用,符合 P2 规格)
- **端口**:固定 `3030`Caddy 通过 `?XTransformPort=3030` 转发)
## 目录结构
```
mini-services/leaderboard-service/
├── package.json # 独立 bun 项目
├── index.ts # Hono 入口
└── README.md
```
## API
所有响应自动 CORS 开放,OPTIONS 预检由 Hono 自动处理。
| 方法 | 路径 | 入参 | 返回 |
|---|---|---|---|
| GET | `/` | — | `{ service, version, ok, uptime }` 健康检查 |
| GET | `/api/leaderboard` | — | `{ entries: BeaconScoreEntry[], total }` Top100 |
| POST | `/api/leaderboard` | body `{ entry: BeaconScoreEntry }` | `{ entries, total, rank }` rank=1..N 或 -1 |
| GET | `/api/leaderboard/stats` | — | `{ totalSubmissions, uniquePlayers, topScore }` |
### `BeaconScoreEntry` 结构
```ts
{
timestamp: number, // 提交时间戳
dateKey: string, // YYYY-MM-DD 或 YYYY-Www
challenge: string, // decode | expedition | pulse | boss | insight
difficulty: string, // routine | anomaly | singular
progress: number, // 0-11 = 完成
score: number, // 最终得分
durationSec: number, // 完成时长(秒)
isWeekly?: boolean, // 周挑战记录
isTimed?: boolean // 限时挑战记录(预留)
}
```
## 存储 & 防刷规则
- **容量**:最多 1000 条,按分数降序;超过自动丢弃尾部。
- **Top 榜**API 返回前 100 条。
- **同分排序**:先看用时短,再看提交早。
- **防刷**:同 `dateKey + challenge` 10 秒内只接受 1 次提交,返回 `429`
## 启动
```bash
cd mini-services/leaderboard-service
bun run dev
# 等价于 bun --hot index.ts,文件变更自动重启
```
## 联调(前端请求规范)
前端必须用相对路径 + `?XTransformPort=3030`
```ts
// 拉取全球 Top100
const res = await fetch("/api/leaderboard?XTransformPort=3030");
const { entries, total } = await res.json();
// 提交一条记录
const res = await fetch("/api/leaderboard?XTransformPort=3030", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ entry }),
});
const { entries, total, rank } = await res.json();
```
**禁止** `fetch("http://localhost:3030/...")` —— 不通过 Caddy 网关,会被浏览器同源策略拦截。
## 部署形态
独立 bun 项目,与主 Next.js 项目解耦:
- 主项目端口 3000Next.js dev
- 本服务端口 3030Hono + Bun
- Caddy 网关端口 81(按 `?XTransformPort=3030` 转发到本服务)
服务重启会清空数据,符合 P2 阶段规格(不需要持久化)。
@@ -0,0 +1,15 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "echo-nexus-leaderboard-service",
"dependencies": {
"hono": "^4.6.14",
},
},
},
"packages": {
"hono": ["hono@4.12.27", "", {}, "sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q=="],
}
}
+182
View File
@@ -0,0 +1,182 @@
// 回响星核 / Echo Nexus — 云排行榜 mini-service
// P2/#4:把信标本机排行榜升级为云排行榜。
// 端口固定 3030Caddy 通过 ?XTransformPort=3030 转发。
// 内存存储(重启清空),Hono + Bun 原生。
//
// API
// GET /api/leaderboard → { entries: BeaconScoreEntry[], total }
// POST /api/leaderboard body { entry } → { entries, total, rank }
// GET /api/leaderboard/stats → { totalSubmissions, uniquePlayers, topScore }
// GET / → 健康检查
//
// 所有响应自动 CORS 开放(hono/cors),OPTIONS 预检 hono 自动处理。
import { Hono } from "hono";
import { cors } from "hono/cors";
// ---------------------------------------------------------------------------
// 类型(与前端 BeaconScoreEntry 一致,但这里宽松化以接受历史数据)
// ---------------------------------------------------------------------------
interface BeaconScoreEntry {
timestamp: number;
dateKey: string;
challenge: string;
difficulty: string;
progress: number;
score: number;
durationSec: number;
isWeekly?: boolean;
isTimed?: boolean;
}
// ---------------------------------------------------------------------------
// 内存存储
// ---------------------------------------------------------------------------
const entries: BeaconScoreEntry[] = [];
const MAX_ENTRIES = 1000;
const TOP_N = 100;
// 简单防刷:同 dateKey+challenge 10 秒内只接受 1 次提交
const lastSubmitAt = new Map<string, number>();
const RATE_LIMIT_MS = 10_000;
function sortEntries() {
entries.sort((a, b) => {
if (b.score !== a.score) return b.score - a.score;
// 同分情况下,用时短者靠前
if (a.durationSec !== b.durationSec) return a.durationSec - b.durationSec;
// 仍相同,按时间戳升序(先提交靠前)
return a.timestamp - b.timestamp;
});
while (entries.length > MAX_ENTRIES) entries.pop();
}
function findRank(entry: BeaconScoreEntry): number {
const idx = entries.findIndex(
(e) =>
e.timestamp === entry.timestamp &&
e.dateKey === entry.dateKey &&
e.score === entry.score
);
return idx >= 0 ? idx + 1 : -1;
}
// ---------------------------------------------------------------------------
// Hono 实例
// ---------------------------------------------------------------------------
const app = new Hono();
// 全局 CORS 开放(前端跨端口访问)
app.use(
"*",
cors({
origin: "*",
allowMethods: ["GET", "POST", "OPTIONS"],
allowHeaders: ["Content-Type", "X-Requested-With"],
exposeHeaders: ["Content-Length"],
maxAge: 600,
})
);
// 健康检查
app.get("/", (c) =>
c.json({
service: "echo-nexus-leaderboard",
version: "v0.8.2",
ok: true,
uptime: Math.floor(process.uptime?.() ?? 0),
})
);
// GET /api/leaderboard — 返回 Top100
app.get("/api/leaderboard", (c) => {
sortEntries();
const top = entries.slice(0, TOP_N);
return c.json({ entries: top, total: entries.length });
});
// POST /api/leaderboard — 插入一条记录,返回新榜 + 排名
app.post("/api/leaderboard", async (c) => {
let body: { entry?: unknown };
try {
body = await c.req.json();
} catch {
return c.json({ error: "Invalid JSON body" }, 400);
}
if (!body || !body.entry || typeof body.entry !== "object") {
return c.json({ error: "Missing entry in body" }, 400);
}
const e = body.entry as Partial<BeaconScoreEntry>;
if (
typeof e.timestamp !== "number" ||
typeof e.dateKey !== "string" ||
typeof e.challenge !== "string" ||
typeof e.difficulty !== "string" ||
typeof e.score !== "number"
) {
return c.json({ error: "Entry schema mismatch" }, 400);
}
// 防刷:同 dateKey+challenge 10 秒内只接受 1 次
const key = `${e.dateKey}|${e.challenge}`;
const now = Date.now();
const last = lastSubmitAt.get(key) ?? 0;
if (now - last < RATE_LIMIT_MS) {
return c.json(
{
error: "Rate limited",
retryAfterMs: RATE_LIMIT_MS - (now - last),
rank: -1,
},
429
);
}
lastSubmitAt.set(key, now);
// 规范化 entry(去多余字段,补默认值)
const entry: BeaconScoreEntry = {
timestamp: e.timestamp,
dateKey: e.dateKey,
challenge: e.challenge,
difficulty: e.difficulty,
progress: typeof e.progress === "number" ? e.progress : 0,
score: Math.max(0, Math.floor(e.score)),
durationSec: typeof e.durationSec === "number" ? e.durationSec : 0,
isWeekly: e.isWeekly === true,
isTimed: e.isTimed === true,
};
entries.push(entry);
sortEntries();
const rank = findRank(entry);
return c.json({
entries: entries.slice(0, TOP_N),
total: entries.length,
rank,
});
});
// GET /api/leaderboard/stats — 全局统计
app.get("/api/leaderboard/stats", (c) => {
sortEntries();
const uniqueDateKeys = new Set(entries.map((e) => e.dateKey));
return c.json({
totalSubmissions: entries.length,
uniquePlayers: uniqueDateKeys.size,
topScore: entries.length > 0 ? entries[0].score : 0,
});
});
// ---------------------------------------------------------------------------
// 启动
// ---------------------------------------------------------------------------
const PORT = 3030;
console.log(`[leaderboard-service] listening on :${PORT}`);
const server = {
port: PORT,
fetch: app.fetch,
};
export default server;
@@ -0,0 +1,13 @@
{
"name": "echo-nexus-leaderboard-service",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "bun --hot index.ts",
"start": "bun index.ts"
},
"dependencies": {
"hono": "^4.6.14"
}
}
+75
View File
@@ -0,0 +1,75 @@
# star-tide-service · 全球星潮同步 mini-service
> 回响星核 / Echo Nexus · P3 工单 #10-c
>
> socket.io 多人同步星潮 —— 所有在线玩家同时经历同一道全球星潮 60 秒。
## 设计
- **端口**:固定 `3031`
- **网关**Caddy 通过 `?XTransformPort=3031` 转发;前端用 `io("/?XTransformPort=3031")`
- **运行时**bun + socket.io 4
- **存储**:纯内存(无状态,重启不影响前端游戏)
## 星潮节奏
- 首次星潮:服务端启动后 **60 秒**(可由 env `FIRST_TIDE_DELAY_MS` 覆盖,便于 QA
- 后续星潮:**10-15 分钟随机间隔**
- 每次持续:**60 秒**
- 类型:从 6 种星潮按权重随机抽取(与前端 `TIDE_EVENTS` 一致)
- 晶体潮 22 / 谐振风暴 20 / 遗迹共振 16 / 虚空低语 18 / 星核悸动 14 / 寂静期 10
## socket.io 事件
### 服务端 → 客户端
| 事件 | 载荷 | 触发时机 |
|------|------|---------|
| `tide-state` | `{ current: GlobalTideState \| null, nextTideAt, serverTime }` | 客户端连接时立即推送 |
| `global-tide` | `{ type, name, startedAt, endsAt, durationSec }` | 全球星潮开始 |
| `tide-ended` | `{ endedAt, nextTideAt }` | 全球星潮结束 |
| `next-tide-info` | `{ current, nextTideAt, serverTime, remainingMs }` | 响应客户端 `get-next-tide` |
### 客户端 → 服务端
| 事件 | 载荷 | 行为 |
|------|------|------|
| `get-next-tide` | — | 查询下次星潮剩余时间 |
| `admin-trigger-tide` | — | **dev/QA 用**:立即触发一次全球星潮 |
## HTTP 端点
socket.io `path: '/'` 拦截所有 HTTP 请求,因此本服务不提供独立 HTTP 健康检查端点。
- `GET /` —— socket.io 默认握手响应(`{"code":0,"message":"Transport unknown"}` 表示服务在线)
## 启动
```bash
cd mini-services/star-tide-service
bun run dev # bun --hot index.ts,热重载
# 或
bun run start # bun index.ts,无热重载
```
## QA 验证
```bash
# socket.io 在线验证(收到 JSON 响应即表示服务在线)
curl http://localhost:3031/
# 预期:{"code":0,"message":"Transport unknown"}
```
前端可在浏览器 console 触发全球星潮(QA 用):
```js
const sock = io("/?XTransformPort=3031");
sock.on("tide-state", (s) => console.log("当前状态:", s));
sock.emit("admin-trigger-tide"); // 立即触发一次全球星潮
```
## 离线容错
- 服务端宕机 → 前端 `useGlobalTide` 自动重连(socket.io 默认行为)
- 断连期间 → 前端本地 `tickTide` 继续按原节奏工作,玩家不会卡住
- 全球星潮进行中 → 前端本地 `tickTide` 暂停触发(避免冲突)
+57
View File
@@ -0,0 +1,57 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "echo-nexus-star-tide-service",
"dependencies": {
"socket.io": "^4.8.3",
},
},
},
"packages": {
"@socket.io/component-emitter": ["@socket.io/component-emitter@3.1.2", "", {}, "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA=="],
"@types/cors": ["@types/cors@2.8.19", "", { "dependencies": { "@types/node": "*" } }, "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg=="],
"@types/node": ["@types/node@26.0.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-vf2YFi1iY9lHGwNJMs01biZFbKJkrZR1T6/MlzjhJLPdntOHLhTrDSnSVcdtvjihi4VQNlrFRIxLsDBlQpAipA=="],
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
"accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="],
"base64id": ["base64id@2.0.0", "", {}, "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog=="],
"cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
"cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"engine.io": ["engine.io@6.6.9", "", { "dependencies": { "@types/cors": "^2.8.12", "@types/node": ">=10.0.0", "@types/ws": "^8.5.12", "accepts": "~1.3.4", "base64id": "2.0.0", "cookie": "~0.7.2", "cors": "~2.8.5", "debug": "~4.4.1", "engine.io-parser": "~5.2.1", "ws": "~8.21.0" } }, "sha512-clKkw4C7nJ22mGgoVcCg6V/W/TxdNyIOTr89k2ONZu81qqkddPFDF0LXcbAwhzPD8DjkiRCjzuiO6Y+fkpD4vg=="],
"engine.io-parser": ["engine.io-parser@5.2.3", "", {}, "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q=="],
"mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="],
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
"socket.io": ["socket.io@4.8.3", "", { "dependencies": { "accepts": "~1.3.4", "base64id": "~2.0.0", "cors": "~2.8.5", "debug": "~4.4.1", "engine.io": "~6.6.0", "socket.io-adapter": "~2.5.2", "socket.io-parser": "~4.2.4" } }, "sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A=="],
"socket.io-adapter": ["socket.io-adapter@2.5.8", "", { "dependencies": { "debug": "~4.4.1", "ws": "~8.21.0" } }, "sha512-6Oy52pbg+kvdCVvjcN+FnY7BvxZ7cIHNScbvztT/It5d0vbwoJoVZmF2gjJmnV0/4WlXRfG15zc45ySk9Ah8bw=="],
"socket.io-parser": ["socket.io-parser@4.2.6", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1" } }, "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg=="],
"undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="],
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
"ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="],
}
}
+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"));
@@ -0,0 +1,13 @@
{
"name": "echo-nexus-star-tide-service",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "bun --hot index.ts",
"start": "bun index.ts"
},
"dependencies": {
"socket.io": "^4.8.3"
}
}