工单 #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)
183 lines
5.3 KiB
TypeScript
183 lines
5.3 KiB
TypeScript
// 回响星核 / Echo Nexus — 云排行榜 mini-service
|
||
// P2/#4:把信标本机排行榜升级为云排行榜。
|
||
// 端口固定 3030;Caddy 通过 ?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;
|