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
+1 -1
View File
@@ -1 +1 @@
6708 12512
+91
View File
@@ -0,0 +1,91 @@
# Task ID: 10-a — 限时挑战 + 星潮类型深化
**Agent**: full-stack-developer
**Task**: 工单 #5 限时挑战 + 工单 #3 星潮类型深化
## Work Log
### 前置准备
- 阅读 `worklog.md` 了解 v0.8.1 项目状态(10 大系统,四色全息 emerald/rose/amber/fuchsia
- 精读 `beacon.ts`(873 行,日挑战+周挑战+信标链)、`BeaconPanel.tsx`753 行)、`starTide.ts`209 行,6 种星潮)、`gameStore.ts` trackBeacon~170 行)+ tickTide~440 行)
- 确认现有 UI 均动态读取 `TIDE_EVENTS[type]`,新增类型自动生效
### 工单 #5:限时挑战(Timed Challenge
**beacon.ts 扩展**873 → 1162 行,+289 行):
- `BeaconScoreEntry` 新增 `isTimed?: boolean` 字段
- `BeaconTimedChallenge` 接口:slotKey / type / difficulty(固定 routine) / goal / rewardInsight / rewardContact / seed / title / desc / expiresAt
- `BeaconTimedProgress` 接口:slotKey / progress / startedAt / completedAt / claimed / durationSec
- `TIMED_SLOT_MS = 4h``BEACON_TIMED_KEY = "echo-nexus-beacon-timed-v1"`
- `getTimedSlotKey(now)`UTC 0/4/8/12/16/20 点切分,格式 "timed_YYYY-MM-DD_HH"
- `timedSlotKeyToSeed`FNV-1a 哈希
- `generateTimedChallenge(now)`:确定性生成,难度 routinegoal = 日基准 ×0.3-0.55 种类型各有公式
- `msUntilNextTimedSlot(now)`:距离下个 4 小时时段的毫秒数
- `loadTimedProgress` / `saveTimedProgress` / `addTimedProgress` / `claimTimedReward`(推送排行榜,entry 加 isTimed: true
**gameStore.ts 集成**
- import 扩展:generateTimedChallenge / loadTimedProgress / addTimedProgress / claimTimedReward + 类型
- `trackBeacon` 返回值新增 `timedJustCompleted`:日/限时/周三类进度同时更新
- 新增 `claimTimedBeacon` actionGameActions 接口同步)
- 新增 `timedJustCompleted` 到 trackBeacon 返回类型
**BeaconPanel.tsx UI**753 → 944 行):
- import 扩展:generateTimedChallenge / loadTimedProgress / getTimedSlotKey / msUntilNextTimedSlot / Hourglass 图标
- 新增 timedChallenge/timedProgress/timedCountdown state
- 每秒刷新 timedProgress + timedCountdown
- 新增 handleClaimTimed 回调
- **限时挑战区块**(amber 主题,插入日挑战与周挑战之间):
- Hourglass 图标 + "限时挑战 · TIMED" 标题 + slotKey + 倒计时
- 难度/类型标签 + 标题 + 描述 + 进度条 + 奖励 + 领取按钮(emerald 主题)
- **紧急状态**:距时段结束 <30min 时切换 rose 脉冲动画 + "即将结束"徽章
- 2 个新动画:timed-glowamber 呼吸)/ timed-urgentrose 急促脉冲)
- 排行榜 entry 区分 TIMEDamber 徽章)/ WEEKfuchsia 徽章)
- 空状态提示更新为"完成限时、每日或本周信标即可登榜"
### 工单 #3:星潮类型深化
**starTide.ts 扩展**209 → 289 行):
- `TideType` 新增 3 种:surge / eclipse / prism
- `TideModifiers` 新增 3 个可选字段:targetLenBonus / bossWinRateBonus / autoDecodeIntervalMult
- `TIDE_EVENTS` 新增 3 种元信息(name/desc/color/glow/icon/negative):
- surge(涌动星潮,emerald #34d399,⇈):产能 ×2.5 但解码目标 +2
- eclipse(蚀相星潮,rose #fb7185,◐):BOSS 胜率 +20% 但产能 -30%
- prism(棱镜星潮,fuchsia #e879f9,◬):洞见 ×2 + 自动解码周期 -30%
- `TIDE_WEIGHTS` 新增 surge/eclipse/prism 各 12(总权重 136
- `getTideModifiers` 新增 3 个 case + null 分支补全新字段
- 注释更新:"6 种类型" → "9 种类型"
**decode.ts 扩展**
- `generatePuzzle` 新增第 3 参数 `targetLenBonus`(默认 0
- targetLen = cfg.targetLen + targetLenBonusstepLimitBase 同步增加
- 路径构造法自动适配更长目标序列(300 次尝试 + 线性兜底)
**gameStore.ts 集成**3 处修饰器应用):
- `startDecode`surge 星潮时传入 `tideMod.targetLenBonus` → 谜题更长
- `autoDecodeTick`prism 星潮时 `interval *= tideMod.autoDecodeIntervalMult`(×0.7
- `resolveCurrentNode`eclipse 星潮时 `b = am.bossWinRateBonus + tideMod.bossWinRateBonus`+20%
**achievements.ts**`ach_tides_all` 阈值 6 → 9,描述"经历全部 9 种星潮事件"
**page.tsx**StatsPanel "星潮亲历" 显示 `/ 9`
### QA 验证
1. **lint**`bun run lint` 零错误(exit 0
2. **dev 服务器**HTTP 200,编译 < 30ms
3. **BeaconPanel VLM 评分**
- 首屏 8.5/10:限时挑战 TIMED 区块可见、四色一致、倒计时/进度/标题/奖励/按钮清晰
- 最终截图 9/10:4 个挑战区块齐全、限时区块带倒计时、无蓝色
4. **新星潮 VLM 评分**localStorage 注入 + reload 触发):
- surgeemerald 绿):8/10,横幅 + 全屏绿色微光
- prismfuchsia 紫):7/10,横幅 + 全屏紫色微光
- eclipserose 粉):8/10(单图),横幅 + 全屏粉色微光
5. **不破坏现有功能**:日挑战/周挑战/信标链/6 旧星潮全部保留,排行榜正常
## Stage Summary
- ✅ 工单 #5 限时挑战完成:beacon.ts +289 行 + gameStore claimTimedBeacon + BeaconPanel amber 区块
- ✅ 工单 #3 星潮深化完成:starTide.ts +80 行(3 新类型)+ decode.ts targetLenBonus + gameStore 3 处修饰器
- ✅ 确定性生成:限时挑战同时段同种子同结果(FNV-1a slotKey 哈希)
- ✅ 严格四色规范:限时 amber、surge emerald、eclipse rose、prism fuchsia,零蓝色/靛色
- ✅ 紧急状态设计:限时挑战 <30min 时 rose 脉冲 + "即将结束"徽章
- ✅ lint 零错误 + dev HTTP 200 + VLM ≥7/10(信标 9/10、surge 8/10、prism 7/10、eclipse 8/10
- ✅ 现有日/周/信标链/6 旧星潮全部保留,无回归
+207
View File
@@ -0,0 +1,207 @@
# Task 10-b · full-stack-developer · P2/#4 云排行榜 mini-service
> 本文件为本 agent 在 Task 10-b 的工作记录,供后续 agent 查阅。
## 任务概述
为「回响星核 / Echo Nexus」v0.8.2 实现 P2/#4 工单:把信标本机排行榜(纯 localStorage)升级为云端 Top100,让玩家间可以比较分数。
## 阅读的前置工作
- `/home/z/my-project/worklog.md`(v0.8.1 项目状态,10 大系统,四色全息规范)
- `/home/z/my-project/src/lib/game/beacon.ts`(实际 1161 行 — worklog 信息滞后,v0.8.2 已加限时挑战 `claimTimedReward`
- `/home/z/my-project/src/components/game/BeaconPanel.tsx`(实际 953 行 — 已含限时挑战 amber 主题)
- `/home/z/my-project/src/store/gameStore.ts`1241 行 — trackBeacon + 3 个 claim actiongrantBeaconReward/claimWeeklyBeacon/claimTimedBeacon/claimChainReward
- `/home/z/my-project/Caddyfile`:81 网关,按 ?XTransformPort=* 转发到对应端口)
- `/home/z/my-project/examples/websocket/server.ts` + `frontend.tsx`mini-service + 前端连接参考)
- `/home/z/my-project/.zscripts/dev.sh`mini-service 自动扫描启动逻辑,发现 mini-services 目录下子服务自动 `bun run dev`
- `/home/z/my-project/agent-ctx/9-b-full-stack-developer.md`(前一个 agent 的信标系统扩展记录)
- `/home/z/my-project/mini-services/star-tide-service/`(已存在的 P3 #10-c 服务,端口 3031
## 实现细节
### 1. mini-service`mini-services/leaderboard-service/`
**目录结构**
```
mini-services/leaderboard-service/
├── package.json # 独立 bun 项目,type=module
├── index.ts # Hono 入口(182 行)
├── bun.lock # 自动生成
├── node_modules/ # hono 独立安装
└── README.md # API 文档
```
**package.json**
- `"dev": "bun --hot index.ts"`(规范要求的热重载脚本)
- `"start": "bun index.ts"`(无热重载,用于稳定运行)
- 依赖:hono ^4.6.14
**index.ts 设计要点**
- Hono + `cors()` 中间件,origin: * 全开放(前端跨端口必须)
- 内存数组 `entries: BeaconScoreEntry[]`,最多 1000 条,按 score 降序
- 同分排序:先看用时短,再看提交早
- 防刷:`lastSubmitAt: Map<dateKey|challenge, ts>`,10 秒内只接受 1 次,返回 429 + retryAfterMs
- 4 个路由:
- `GET /` → 健康检查 `{service, version, ok, uptime}`
- `GET /api/leaderboard` → Top100 `{entries, total}`
- `POST /api/leaderboard` body `{entry}` → schema 校验 → 推入 → 排序 → `{entries, total, rank}`
- `GET /api/leaderboard/stats``{totalSubmissions, uniquePlayers, topScore}`
- OPTIONS 预检由 hono/cors 自动处理,返回 204 + 完整 CORS 头
- 端口固定 3030`export default { port, fetch }` 标准 bun 模式
**启动调试经验**
- `bun --hot` 在本沙盒环境下不稳定,多次启动后约 5-10s 内被 kill(即使 setsid + nohup
- 解决方案:用 `bun index.ts`(无 --hot+ 双 fork 模式:`( bun index.ts >/tmp/log 2>&1 & )`
- 双 fork 后 PPID=1reparent 到 init),进程脱离原 bash sessionuptime 4+ 分钟稳定
### 2. beacon.ts 扩展(1161 → 1290 行,+129 行)
**三个 claim 函数返回值扩展(非破坏性)**
- `claimBeaconReward` 返回值新增 `entry: BeaconScoreEntry`
- `claimWeeklyReward` 返回值新增 `entry: BeaconScoreEntry`
- `claimTimedReward` 返回值新增 `entry: BeaconScoreEntry`
- 用途:让前端 / gameStore 能拿到完整 entry 提交到云端
**新增云排行榜模块**
- `BEACON_CLOUD_PORT = 3030` 常量
- `BEACON_CLOUD_LAST_SUBMIT_KEY = "echo-nexus-beacon-cloud-last-submit-v1"` localStorage key
- 3 个响应接口:`CloudLeaderboardResponse` / `CloudSubmitResponse` / `CloudStatsResponse`
- `loadLastCloudSubmitTimestamp()` / `saveLastCloudSubmitTimestamp(ts)`
- `fetchCloudLeaderboard(): Promise<BeaconScoreEntry[]>` — GET `/api/leaderboard?XTransformPort=3030`
- `submitCloudScore(entry): Promise<number>` — POST,成功返回 rank ≥ 1,失败返回 -1,自动记录 ts
- `fetchCloudStats(): Promise<CloudStatsResponse | null>`
- **所有 fetch 用相对路径 + ?XTransformPort=3030,禁止 localhost:3030**
- try-catch 包裹,失败时静默返回空/null,不抛异常
### 3. gameStore.ts 集成
- import 新增 `submitCloudScore`
- `claimWeeklyBeacon` action:领奖成功后 `void submitCloudScore(res.entry)`fire-and-forget,不 await,不阻塞)
- `claimTimedBeacon` action:同上
- `claimBeaconReward` (daily) 由 BeaconPanel 直接调用,不通过 gameStore
### 4. BeaconPanel.tsx 重写(953 → 1170 行,+217 行)
**新增 import**
- `fetchCloudLeaderboard` / `submitCloudScore` / `loadLastCloudSubmitTimestamp` from beacon
- 4 个 lucide 图标:`RefreshCw` / `Globe` / `WifiOff` / `Loader2`
- `useRef` from react
**新增状态**
- `lbTab: 'local' | 'global'`(默认 local
- `cloudEntries: BeaconScoreEntry[]`
- `cloudLoading: boolean`
- `cloudError: string | null`
- `cloudFetched: boolean`
- `mySubmitTs: number`
- `cloudFetchingRef` (防并发)
**新增逻辑**
- `refreshCloudLeaderboard` callback:拉取云端榜 + 防并发 + 空 entries 时显示"成为首位登顶者"
- useEffect:切到全球 tab 自动拉取(仅首次)+ 初始化读取 mySubmitTs
- `handleClaimDaily`:领奖后 `void submitCloudScore(res.entry).then(rank => ...)` — 若用户在全球 tab 自动刷新
- `handleClaimTimed` / `handleClaimWeekly`:领奖后更新 mySubmitTs + 若在全球 tab 自动刷新
**排行榜 UI 重写为双 tab**
- 顶部 tab 切换:「本地」(amber 主题) / 「全球」(emerald 主题) — 用 `<button aria-pressed>` 实现可访问性
- 全球 tab 专属刷新按钮(RefreshCwloading 时 animate-spin
- 子标题:本地"本机 Top N · 离线可用" / 全球"全球 Top N · 已同步 / 同步中…"
- 本地榜:保留原 v0.8.2 渲染(max-h-200px overflow-y-auto
- 全球榜:
- 加载中:Loader2 spinner + "正在拉取全球榜…"
- 空榜 + 错误:WifiOff 图标 + 错误文案 + 重试按钮
- 空榜 + 无错:Globe 图标 + "尚无全球记录。完成挑战并领取即可登顶全球榜!"
- 有数据:渲染前 100 条,每条带排名(1-3 名用 Crown/Medal/Award 图标)/ 难度色 / WEEK·TIMED 徽章 / 用时 / 分数
- **玩家自己的记录高亮**:通过 `entry.timestamp === mySubmitTs` 匹配
- emerald 边框 (`border-emerald-500/40`)
- emerald 阴影 (`shadow-[0_0_12px_rgba(52,211,153,0.25)]`)
- YOU 徽章 (`bg-emerald-500/25 text-emerald-200 border-emerald-500/50`)
- emerald 分数色 (`color: #34d399`)
- 加粗排名
### 5. eslint 配置
- `eslint.config.mjs` 新增 ignores `"mini-services/**"`
- 原因:mini-service 是独立 bun 项目,有自己的 TypeScript 配置和 lint 规范,不应参与主项目 lint
## QA 验证结果
### 1. lint
- `bun run lint` 零错误零警告
### 2. dev 服务器
- dev.log 全程无错误,所有编译 < 300ms
- 注:dev.log 中显示 `/api/leaderboard?XTransformPort=3030 404` 是预期行为 — 通过 :3000 直接访问时 Next.js 没有 /api/leaderboard 路由;通过 :81 网关访问时 Caddy 转发到 :3030 正常返回 200
### 3. mini-service curl 全通过
- `GET /` → 200 `{"service":"echo-nexus-leaderboard","version":"v0.8.2","ok":true,"uptime":N}`
- `GET /api/leaderboard` (空) → 200 `{"entries":[],"total":0}`
- `POST /api/leaderboard` → 200 `{"entries":[...],"total":1,"rank":1}`
- `GET /api/leaderboard` (有数据) → 200 `{"entries":[...],"total":N}`
- `GET /api/leaderboard/stats` → 200 `{"totalSubmissions":N,"uniquePlayers":M,"topScore":X}`
- `OPTIONS /api/leaderboard` 预检 → 204 + 完整 CORS 头(Allow-Origin: *
- 重复 POST(同 dateKey+challenge 10s 内)→ 429 `{"error":"Rate limited","retryAfterMs":N}`
### 4. 网关路由验证
- Caddy :81 → localhost:3030 通过 `?XTransformPort=3030` 正确转发,返回 JSON 200
### 5. agent-browser 集成测试
- 通过 :81 端口访问(绕过 Next.js :3000 的 404
- 信标 tab 选中(用 KeyboardEvent Enter 激活 Radix Tabs — 因 pointerdown 被覆盖层拦截)
- 切到「全球」tab → 自动拉取 → 显示"全球 Top 6 · 已同步" → 渲染 6 条记录
- 刷新按钮可点击,旋转动画正常
- POST 测试:浏览器 fetch 提交 score=9999 → 返回 rank=1 → 刷新后榜首显示 10.00K
- YOU 高亮:手动设置 localStorage[BEACON_CLOUD_LAST_SUBMIT_KEY] 后刷新 → 榜首显示 emerald 边框 + YOU 徽章
### 6. VLM 视觉评分(目标 ≥7/10
- 全球榜含 YOU 高亮:**8/10**
- VLM 评语:"双tab切换清晰,全球榜记录完整,难度色与标签规范,视觉层次佳,但「YOU」徽章未突出显示"
- 本地榜:**8/10**
- VLM 评语:"双tab切换逻辑清晰,本地tab内容完整呈现;四色规范应用,视觉区分度高;信息层级合理"
## 修改的文件
1. `mini-services/leaderboard-service/package.json`(新建)
2. `mini-services/leaderboard-service/index.ts`(新建,182 行)
3. `mini-services/leaderboard-service/README.md`(新建)
4. `src/lib/game/beacon.ts` — 1161 → 1290 行(+129 行:3 个 claim 返回 entry + 云排行榜模块)
5. `src/store/gameStore.ts` — 1235 → 1252 行(+17 行:import + 2 个 claim action 加 submitCloudScore
6. `src/components/game/BeaconPanel.tsx` — 953 → 1170 行(+217 行:双 tab UI + 云端拉取 + 提交 + YOU 高亮)
7. `eslint.config.mjs` — ignores 新增 "mini-services/**"
## 不破坏的现有功能
- 本地排行榜(`loadLeaderboard` / `pushLeaderboardEntry`)完整保留作为离线 fallback
- 日挑战 / 周挑战 / 限时挑战 / 信标链流程全部保留
- 现有 localStorage keysecho-nexus-beacon-lb-v1 / -prog-v1 / -weekly-v1 / -chain-v1 / -timed-v1)全部保留
- 主项目 package.json 不变(hono 装在 mini-service 子项目内)
## 新增的 localStorage keys
- `echo-nexus-beacon-cloud-last-submit-v1`(上次成功提交到云端的 entry timestamp,用于全球榜高亮)
## 四色全息规范遵循
- 本地 tabamber (#fbbf24)
- 全球 tabemerald (#34d399) — 与"在线/同步"语义一致
- 玩家高亮:emerald 边框 + emerald YOU 徽章 + emerald 分数色
- 难度色保留:routine emerald / anomaly amber / singular rose
- 周挑战徽章:fuchsia
- 限时挑战徽章:amber
- 错误状态:rose (#fb7185)
- **零蓝色/靛色违规**
## 截图资产
- `/home/z/my-project/agent-ctx/beacon-global-leaderboard.png` — 全球榜初始视图(6 条记录)
- `/home/z/my-project/agent-ctx/beacon-global-with-you.png` — 全球榜含 YOU 高亮(7 条记录,榜首是玩家)
- `/home/z/my-project/agent-ctx/beacon-local-leaderboard.png` — 本地榜视图
## 重要注意事项(供后续 agent)
1. **前端访问端口**:用户通过 Preview Panel 看到的是 Caddy 网关 :81,不是 Next.js :3000。所以 `fetch('/api/leaderboard?XTransformPort=3030')` 在用户视角下能正常路由。如果直接访问 :3000 会 404(这是预期行为)。
2. **mini-service 启动方式**:用 `bun index.ts`(无 --hot+ 双 fork `( ... & )` 模式启动。`bun --hot` 在沙盒下不稳定。
3. **mini-service 已被 .zscripts/dev.sh 自动扫描**:下次容器重启会自动启动。但当前会话需手动启动一次。
4. **localStorage key 命名**`echo-nexus-beacon-cloud-last-submit-v1` 而非 `echo-nexus-beacon-cloud-lb-v1`,前者存的是单个 timestamp(用于高亮),后者若需要存全球榜快照可用后者(目前未实现,按需拉取)。
5. **star-tide-service 端口 3031**:另一个 P3 工单已存在的服务,本服务的端口 3030 与之互不冲突。
Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 236 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 471 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 243 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 386 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 372 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 396 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 394 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 243 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 471 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 472 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 352 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 211 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 227 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 223 KiB

Executable → Regular
+40
View File
@@ -63,6 +63,8 @@
"react-syntax-highlighter": "^15.6.1", "react-syntax-highlighter": "^15.6.1",
"recharts": "^2.15.4", "recharts": "^2.15.4",
"sharp": "^0.34.3", "sharp": "^0.34.3",
"socket.io": "^4.8.3",
"socket.io-client": "^4.8.3",
"sonner": "^2.0.6", "sonner": "^2.0.6",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
@@ -600,6 +602,8 @@
"@schummar/icu-type-parser": ["@schummar/icu-type-parser@1.21.5", "https://registry.npmjs.com/@schummar/icu-type-parser/-/icu-type-parser-1.21.5.tgz", {}, "sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw=="], "@schummar/icu-type-parser": ["@schummar/icu-type-parser@1.21.5", "https://registry.npmjs.com/@schummar/icu-type-parser/-/icu-type-parser-1.21.5.tgz", {}, "sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw=="],
"@socket.io/component-emitter": ["@socket.io/component-emitter@3.1.2", "", {}, "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA=="],
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "https://registry.npmjs.com/@standard-schema/spec/-/spec-1.1.0.tgz", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "https://registry.npmjs.com/@standard-schema/spec/-/spec-1.1.0.tgz", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@standard-schema/utils": ["@standard-schema/utils@0.3.0", "https://registry.npmjs.com/@standard-schema/utils/-/utils-0.3.0.tgz", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="], "@standard-schema/utils": ["@standard-schema/utils@0.3.0", "https://registry.npmjs.com/@standard-schema/utils/-/utils-0.3.0.tgz", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="],
@@ -674,6 +678,8 @@
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "https://registry.npmjs.com/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "https://registry.npmjs.com/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
"@types/cors": ["@types/cors@2.8.19", "", { "dependencies": { "@types/node": "*" } }, "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg=="],
"@types/d3-array": ["@types/d3-array@3.2.2", "https://registry.npmjs.com/@types/d3-array/-/d3-array-3.2.2.tgz", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="], "@types/d3-array": ["@types/d3-array@3.2.2", "https://registry.npmjs.com/@types/d3-array/-/d3-array-3.2.2.tgz", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="],
"@types/d3-color": ["@types/d3-color@3.1.3", "https://registry.npmjs.com/@types/d3-color/-/d3-color-3.1.3.tgz", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="], "@types/d3-color": ["@types/d3-color@3.1.3", "https://registry.npmjs.com/@types/d3-color/-/d3-color-3.1.3.tgz", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="],
@@ -716,6 +722,8 @@
"@types/unist": ["@types/unist@3.0.3", "https://registry.npmjs.com/@types/unist/-/unist-3.0.3.tgz", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], "@types/unist": ["@types/unist@3.0.3", "https://registry.npmjs.com/@types/unist/-/unist-3.0.3.tgz", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.53.0", "https://registry.npmjs.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.53.0.tgz", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.53.0", "@typescript-eslint/type-utils": "8.53.0", "@typescript-eslint/utils": "8.53.0", "@typescript-eslint/visitor-keys": "8.53.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.53.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-eEXsVvLPu8Z4PkFibtuFJLJOTAV/nPdgtSjkGoPpddpFk3/ym2oy97jynY6ic2m6+nc5M8SE1e9v/mHKsulcJg=="], "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.53.0", "https://registry.npmjs.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.53.0.tgz", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.53.0", "@typescript-eslint/type-utils": "8.53.0", "@typescript-eslint/utils": "8.53.0", "@typescript-eslint/visitor-keys": "8.53.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.53.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-eEXsVvLPu8Z4PkFibtuFJLJOTAV/nPdgtSjkGoPpddpFk3/ym2oy97jynY6ic2m6+nc5M8SE1e9v/mHKsulcJg=="],
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.53.0", "https://registry.npmjs.com/@typescript-eslint/parser/-/parser-8.53.0.tgz", { "dependencies": { "@typescript-eslint/scope-manager": "8.53.0", "@typescript-eslint/types": "8.53.0", "@typescript-eslint/typescript-estree": "8.53.0", "@typescript-eslint/visitor-keys": "8.53.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-npiaib8XzbjtzS2N4HlqPvlpxpmZ14FjSJrteZpPxGUaYPlvhzlzUZ4mZyABo0EFrOWnvyd0Xxroq//hKhtAWg=="], "@typescript-eslint/parser": ["@typescript-eslint/parser@8.53.0", "https://registry.npmjs.com/@typescript-eslint/parser/-/parser-8.53.0.tgz", { "dependencies": { "@typescript-eslint/scope-manager": "8.53.0", "@typescript-eslint/types": "8.53.0", "@typescript-eslint/typescript-estree": "8.53.0", "@typescript-eslint/visitor-keys": "8.53.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-npiaib8XzbjtzS2N4HlqPvlpxpmZ14FjSJrteZpPxGUaYPlvhzlzUZ4mZyABo0EFrOWnvyd0Xxroq//hKhtAWg=="],
@@ -776,6 +784,8 @@
"@unrs/resolver-binding-win32-x64-msvc": ["@unrs/resolver-binding-win32-x64-msvc@1.11.1", "https://registry.npmjs.com/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz", { "os": "win32", "cpu": "x64" }, "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g=="], "@unrs/resolver-binding-win32-x64-msvc": ["@unrs/resolver-binding-win32-x64-msvc@1.11.1", "https://registry.npmjs.com/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz", { "os": "win32", "cpu": "x64" }, "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g=="],
"accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="],
"acorn": ["acorn@8.15.0", "https://registry.npmjs.com/acorn/-/acorn-8.15.0.tgz", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], "acorn": ["acorn@8.15.0", "https://registry.npmjs.com/acorn/-/acorn-8.15.0.tgz", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
"acorn-jsx": ["acorn-jsx@5.3.2", "https://registry.npmjs.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], "acorn-jsx": ["acorn-jsx@5.3.2", "https://registry.npmjs.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
@@ -824,6 +834,8 @@
"base64-js": ["base64-js@1.5.1", "https://registry.npmjs.com/base64-js/-/base64-js-1.5.1.tgz", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], "base64-js": ["base64-js@1.5.1", "https://registry.npmjs.com/base64-js/-/base64-js-1.5.1.tgz", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
"base64id": ["base64id@2.0.0", "", {}, "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.9.15", "https://registry.npmjs.com/baseline-browser-mapping/-/baseline-browser-mapping-2.9.15.tgz", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-kX8h7K2srmDyYnXRIppo4AH/wYgzWVCs+eKr3RusRSQ5PvRYoEFmR/I0PbdTjKFAoKqp5+kbxnNTFO9jOfSVJg=="], "baseline-browser-mapping": ["baseline-browser-mapping@2.9.15", "https://registry.npmjs.com/baseline-browser-mapping/-/baseline-browser-mapping-2.9.15.tgz", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-kX8h7K2srmDyYnXRIppo4AH/wYgzWVCs+eKr3RusRSQ5PvRYoEFmR/I0PbdTjKFAoKqp5+kbxnNTFO9jOfSVJg=="],
"brace-expansion": ["brace-expansion@1.1.12", "https://registry.npmjs.com/brace-expansion/-/brace-expansion-1.1.12.tgz", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], "brace-expansion": ["brace-expansion@1.1.12", "https://registry.npmjs.com/brace-expansion/-/brace-expansion-1.1.12.tgz", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
@@ -898,6 +910,8 @@
"cookie": ["cookie@0.7.2", "https://registry.npmjs.com/cookie/-/cookie-0.7.2.tgz", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], "cookie": ["cookie@0.7.2", "https://registry.npmjs.com/cookie/-/cookie-0.7.2.tgz", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
"cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="],
"crelt": ["crelt@1.0.6", "https://registry.npmjs.com/crelt/-/crelt-1.0.6.tgz", {}, "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g=="], "crelt": ["crelt@1.0.6", "https://registry.npmjs.com/crelt/-/crelt-1.0.6.tgz", {}, "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g=="],
"cross-spawn": ["cross-spawn@7.0.6", "https://registry.npmjs.com/cross-spawn/-/cross-spawn-7.0.6.tgz", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], "cross-spawn": ["cross-spawn@7.0.6", "https://registry.npmjs.com/cross-spawn/-/cross-spawn-7.0.6.tgz", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
@@ -994,6 +1008,12 @@
"empathic": ["empathic@2.0.0", "https://registry.npmjs.com/empathic/-/empathic-2.0.0.tgz", {}, "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA=="], "empathic": ["empathic@2.0.0", "https://registry.npmjs.com/empathic/-/empathic-2.0.0.tgz", {}, "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA=="],
"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-client": ["engine.io-client@6.6.6", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1", "engine.io-parser": "~5.2.1", "ws": "~8.21.0", "xmlhttprequest-ssl": "~2.1.1" } }, "sha512-iY6QdftLQ9pyiPoX082bpf/u1UewnOaJrtJIF9T0++QB34lZrj0uP+Q/bj8AlUsAxqhnkTV2BS8SBZSxOmoV5Q=="],
"engine.io-parser": ["engine.io-parser@5.2.3", "", {}, "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q=="],
"enhanced-resolve": ["enhanced-resolve@5.18.4", "https://registry.npmjs.com/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q=="], "enhanced-resolve": ["enhanced-resolve@5.18.4", "https://registry.npmjs.com/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q=="],
"es-abstract": ["es-abstract@1.24.1", "https://registry.npmjs.com/es-abstract/-/es-abstract-1.24.1.tgz", { "dependencies": { "array-buffer-byte-length": "^1.0.2", "arraybuffer.prototype.slice": "^1.0.4", "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "data-view-buffer": "^1.0.2", "data-view-byte-length": "^1.0.2", "data-view-byte-offset": "^1.0.1", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "es-set-tostringtag": "^2.1.0", "es-to-primitive": "^1.3.0", "function.prototype.name": "^1.1.8", "get-intrinsic": "^1.3.0", "get-proto": "^1.0.1", "get-symbol-description": "^1.1.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "internal-slot": "^1.1.0", "is-array-buffer": "^3.0.5", "is-callable": "^1.2.7", "is-data-view": "^1.0.2", "is-negative-zero": "^2.0.3", "is-regex": "^1.2.1", "is-set": "^2.0.3", "is-shared-array-buffer": "^1.0.4", "is-string": "^1.1.1", "is-typed-array": "^1.1.15", "is-weakref": "^1.1.1", "math-intrinsics": "^1.1.0", "object-inspect": "^1.13.4", "object-keys": "^1.1.1", "object.assign": "^4.1.7", "own-keys": "^1.0.1", "regexp.prototype.flags": "^1.5.4", "safe-array-concat": "^1.1.3", "safe-push-apply": "^1.0.0", "safe-regex-test": "^1.1.0", "set-proto": "^1.0.0", "stop-iteration-iterator": "^1.1.0", "string.prototype.trim": "^1.2.10", "string.prototype.trimend": "^1.0.9", "string.prototype.trimstart": "^1.0.8", "typed-array-buffer": "^1.0.3", "typed-array-byte-length": "^1.0.3", "typed-array-byte-offset": "^1.0.4", "typed-array-length": "^1.0.7", "unbox-primitive": "^1.1.0", "which-typed-array": "^1.1.19" } }, "sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw=="], "es-abstract": ["es-abstract@1.24.1", "https://registry.npmjs.com/es-abstract/-/es-abstract-1.24.1.tgz", { "dependencies": { "array-buffer-byte-length": "^1.0.2", "arraybuffer.prototype.slice": "^1.0.4", "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "data-view-buffer": "^1.0.2", "data-view-byte-length": "^1.0.2", "data-view-byte-offset": "^1.0.1", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "es-set-tostringtag": "^2.1.0", "es-to-primitive": "^1.3.0", "function.prototype.name": "^1.1.8", "get-intrinsic": "^1.3.0", "get-proto": "^1.0.1", "get-symbol-description": "^1.1.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "internal-slot": "^1.1.0", "is-array-buffer": "^3.0.5", "is-callable": "^1.2.7", "is-data-view": "^1.0.2", "is-negative-zero": "^2.0.3", "is-regex": "^1.2.1", "is-set": "^2.0.3", "is-shared-array-buffer": "^1.0.4", "is-string": "^1.1.1", "is-typed-array": "^1.1.15", "is-weakref": "^1.1.1", "math-intrinsics": "^1.1.0", "object-inspect": "^1.13.4", "object-keys": "^1.1.1", "object.assign": "^4.1.7", "own-keys": "^1.0.1", "regexp.prototype.flags": "^1.5.4", "safe-array-concat": "^1.1.3", "safe-push-apply": "^1.0.0", "safe-regex-test": "^1.1.0", "set-proto": "^1.0.0", "stop-iteration-iterator": "^1.1.0", "string.prototype.trim": "^1.2.10", "string.prototype.trimend": "^1.0.9", "string.prototype.trimstart": "^1.0.8", "typed-array-buffer": "^1.0.3", "typed-array-byte-length": "^1.0.3", "typed-array-byte-offset": "^1.0.4", "typed-array-length": "^1.0.7", "unbox-primitive": "^1.1.0", "which-typed-array": "^1.1.19" } }, "sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw=="],
@@ -1452,6 +1472,8 @@
"mime-db": ["mime-db@1.54.0", "https://registry.npmjs.com/mime-db/-/mime-db-1.54.0.tgz", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], "mime-db": ["mime-db@1.54.0", "https://registry.npmjs.com/mime-db/-/mime-db-1.54.0.tgz", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"minimatch": ["minimatch@3.1.2", "https://registry.npmjs.com/minimatch/-/minimatch-3.1.2.tgz", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], "minimatch": ["minimatch@3.1.2", "https://registry.npmjs.com/minimatch/-/minimatch-3.1.2.tgz", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
"minimist": ["minimist@1.2.8", "https://registry.npmjs.com/minimist/-/minimist-1.2.8.tgz", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], "minimist": ["minimist@1.2.8", "https://registry.npmjs.com/minimist/-/minimist-1.2.8.tgz", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
@@ -1668,6 +1690,14 @@
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "https://registry.npmjs.com/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "https://registry.npmjs.com/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
"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-client": ["socket.io-client@4.8.3", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1", "engine.io-client": "~6.6.1", "socket.io-parser": "~4.2.4" } }, "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g=="],
"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=="],
"sonner": ["sonner@2.0.7", "https://registry.npmjs.com/sonner/-/sonner-2.0.7.tgz", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="], "sonner": ["sonner@2.0.7", "https://registry.npmjs.com/sonner/-/sonner-2.0.7.tgz", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="],
"source-map-js": ["source-map-js@1.2.1", "https://registry.npmjs.com/source-map-js/-/source-map-js-1.2.1.tgz", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], "source-map-js": ["source-map-js@1.2.1", "https://registry.npmjs.com/source-map-js/-/source-map-js-1.2.1.tgz", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
@@ -1796,6 +1826,8 @@
"uvu": ["uvu@0.5.6", "https://registry.npmjs.com/uvu/-/uvu-0.5.6.tgz", { "dependencies": { "dequal": "^2.0.0", "diff": "^5.0.0", "kleur": "^4.0.3", "sade": "^1.7.3" }, "bin": { "uvu": "bin.js" } }, "sha512-+g8ENReyr8YsOc6fv/NVJs2vFdHBnBNdfE49rshrTzDWOlUx4Gq7KOS2GD8eqhy2j+Ejq29+SbKH8yjkAqXqoA=="], "uvu": ["uvu@0.5.6", "https://registry.npmjs.com/uvu/-/uvu-0.5.6.tgz", { "dependencies": { "dequal": "^2.0.0", "diff": "^5.0.0", "kleur": "^4.0.3", "sade": "^1.7.3" }, "bin": { "uvu": "bin.js" } }, "sha512-+g8ENReyr8YsOc6fv/NVJs2vFdHBnBNdfE49rshrTzDWOlUx4Gq7KOS2GD8eqhy2j+Ejq29+SbKH8yjkAqXqoA=="],
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
"vaul": ["vaul@1.1.2", "https://registry.npmjs.com/vaul/-/vaul-1.1.2.tgz", { "dependencies": { "@radix-ui/react-dialog": "^1.1.1" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA=="], "vaul": ["vaul@1.1.2", "https://registry.npmjs.com/vaul/-/vaul-1.1.2.tgz", { "dependencies": { "@radix-ui/react-dialog": "^1.1.1" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA=="],
"vfile": ["vfile@6.0.3", "https://registry.npmjs.com/vfile/-/vfile-6.0.3.tgz", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="], "vfile": ["vfile@6.0.3", "https://registry.npmjs.com/vfile/-/vfile-6.0.3.tgz", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
@@ -1818,6 +1850,10 @@
"word-wrap": ["word-wrap@1.2.5", "https://registry.npmjs.com/word-wrap/-/word-wrap-1.2.5.tgz", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], "word-wrap": ["word-wrap@1.2.5", "https://registry.npmjs.com/word-wrap/-/word-wrap-1.2.5.tgz", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
"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=="],
"xmlhttprequest-ssl": ["xmlhttprequest-ssl@2.1.2", "", {}, "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ=="],
"xtend": ["xtend@4.0.2", "https://registry.npmjs.com/xtend/-/xtend-4.0.2.tgz", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], "xtend": ["xtend@4.0.2", "https://registry.npmjs.com/xtend/-/xtend-4.0.2.tgz", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="],
"yallist": ["yallist@4.0.0", "https://registry.npmjs.com/yallist/-/yallist-4.0.0.tgz", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], "yallist": ["yallist@4.0.0", "https://registry.npmjs.com/yallist/-/yallist-4.0.0.tgz", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
@@ -1900,6 +1936,8 @@
"@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "https://registry.npmjs.com/minimatch/-/minimatch-9.0.5.tgz", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], "@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "https://registry.npmjs.com/minimatch/-/minimatch-9.0.5.tgz", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
"accepts/negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="],
"downshift/react-is": ["react-is@17.0.2", "https://registry.npmjs.com/react-is/-/react-is-17.0.2.tgz", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], "downshift/react-is": ["react-is@17.0.2", "https://registry.npmjs.com/react-is/-/react-is-17.0.2.tgz", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="],
"eslint-import-resolver-node/debug": ["debug@3.2.7", "https://registry.npmjs.com/debug/-/debug-3.2.7.tgz", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], "eslint-import-resolver-node/debug": ["debug@3.2.7", "https://registry.npmjs.com/debug/-/debug-3.2.7.tgz", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="],
@@ -1930,6 +1968,8 @@
"micromatch/picomatch": ["picomatch@2.3.1", "https://registry.npmjs.com/picomatch/-/picomatch-2.3.1.tgz", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], "micromatch/picomatch": ["picomatch@2.3.1", "https://registry.npmjs.com/picomatch/-/picomatch-2.3.1.tgz", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"next/postcss": ["postcss@8.4.31", "https://registry.npmjs.com/postcss/-/postcss-8.4.31.tgz", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], "next/postcss": ["postcss@8.4.31", "https://registry.npmjs.com/postcss/-/postcss-8.4.31.tgz", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
"next-auth/uuid": ["uuid@8.3.2", "https://registry.npmjs.com/uuid/-/uuid-8.3.2.tgz", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="], "next-auth/uuid": ["uuid@8.3.2", "https://registry.npmjs.com/uuid/-/uuid-8.3.2.tgz", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="],
+1 -1
View File
@@ -44,7 +44,7 @@ const eslintConfig = [...nextCoreWebVitals, ...nextTypescript, {
"no-useless-escape": "off", "no-useless-escape": "off",
}, },
}, { }, {
ignores: ["node_modules/**", ".next/**", "out/**", "build/**", "next-env.d.ts", "examples/**", "skills"] ignores: ["node_modules/**", ".next/**", "out/**", "build/**", "next-env.d.ts", "examples/**", "skills", "mini-services/**"]
}]; }];
export default eslintConfig; export default eslintConfig;
@@ -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"
}
}
+2
View File
@@ -72,6 +72,8 @@
"react-syntax-highlighter": "^15.6.1", "react-syntax-highlighter": "^15.6.1",
"recharts": "^2.15.4", "recharts": "^2.15.4",
"sharp": "^0.34.3", "sharp": "^0.34.3",
"socket.io": "^4.8.3",
"socket.io-client": "^4.8.3",
"sonner": "^2.0.6", "sonner": "^2.0.6",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
+25 -2
View File
@@ -27,6 +27,7 @@ import {
} from "@/components/game/StarTide"; } from "@/components/game/StarTide";
import { useGameLoop } from "@/hooks/useGameLoop"; import { useGameLoop } from "@/hooks/useGameLoop";
import { useAudioSync } from "@/hooks/useAudio"; import { useAudioSync } from "@/hooks/useAudio";
import { useGlobalTide } from "@/hooks/useGlobalTide";
import { useGameStore } from "@/store/gameStore"; import { useGameStore } from "@/store/gameStore";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -54,6 +55,7 @@ import { generateDailyChallenge, loadDailyProgress, loadLeaderboard } from "@/li
export default function Page() { export default function Page() {
useGameLoop(); useGameLoop();
useAudioSync(); useAudioSync();
useGlobalTide();
const [prestigeOpen, setPrestigeOpen] = useState(false); const [prestigeOpen, setPrestigeOpen] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false);
const [constellationOpen, setConstellationOpen] = useState(false); const [constellationOpen, setConstellationOpen] = useState(false);
@@ -73,6 +75,7 @@ export default function Page() {
const crystals = useGameStore((s) => s.crystals); const crystals = useGameStore((s) => s.crystals);
const crystalCap = useGameStore((s) => s.crystalCap); const crystalCap = useGameStore((s) => s.crystalCap);
const activeTide = useGameStore((s) => s.activeTide); const activeTide = useGameStore((s) => s.activeTide);
const globalTide = useGameStore((s) => s.globalTide);
const energy = useGameStore((s) => s.energy); const energy = useGameStore((s) => s.energy);
const hasActiveExpedition = useGameStore((s) => !!s.activeExpedition && !s.activeExpedition.finished); const hasActiveExpedition = useGameStore((s) => !!s.activeExpedition && !s.activeExpedition.finished);
const chronicleCount = useGameStore((s) => s.chronicle?.length ?? 0); const chronicleCount = useGameStore((s) => s.chronicle?.length ?? 0);
@@ -148,7 +151,8 @@ export default function Page() {
} }
if (activeTide) { if (activeTide) {
const tm = TIDE_EVENTS[activeTide.type]; const tm = TIDE_EVENTS[activeTide.type];
goal = `${tm.icon} 星潮「${tm.name}」进行中 · ${tm.desc}`; const prefix = globalTide ? "🌐 全球星潮 · " : "";
goal = `${prefix}${tm.icon} 星潮「${tm.name}」进行中 · ${tm.desc}`;
} }
if (canPrestige) goal = "✦ 接触进度已满,可发起飞升进入新周目"; if (canPrestige) goal = "✦ 接触进度已满,可发起飞升进入新周目";
@@ -249,6 +253,25 @@ export default function Page() {
{/* 装饰光圈 */} {/* 装饰光圈 */}
<div className="pointer-events-none absolute -top-20 -left-20 h-60 w-60 rounded-full bg-emerald-500/10 blur-3xl" /> <div className="pointer-events-none absolute -top-20 -left-20 h-60 w-60 rounded-full bg-emerald-500/10 blur-3xl" />
<div className="pointer-events-none absolute -bottom-20 -right-20 h-60 w-60 rounded-full bg-fuchsia-500/10 blur-3xl" /> <div className="pointer-events-none absolute -bottom-20 -right-20 h-60 w-60 rounded-full bg-fuchsia-500/10 blur-3xl" />
{/* v0.8.2 装饰全息环(填充留白,提升视觉层次) */}
<div className="pointer-events-none absolute inset-6 rounded-full border border-emerald-400/10 animate-[spin_60s_linear_infinite]" />
<div className="pointer-events-none absolute inset-10 rounded-full border border-dashed border-fuchsia-400/10 animate-[spin_90s_linear_infinite_reverse]" />
<div className="pointer-events-none absolute inset-16 rounded-full border border-rose-400/8" />
{/* 四角全息标记 */}
<div className="pointer-events-none absolute top-3 left-3 h-4 w-4 border-l border-t border-emerald-400/30 rounded-tl" />
<div className="pointer-events-none absolute top-3 right-3 h-4 w-4 border-r border-t border-fuchsia-400/30 rounded-tr" />
<div className="pointer-events-none absolute bottom-3 left-3 h-4 w-4 border-l border-b border-amber-400/30 rounded-bl" />
<div className="pointer-events-none absolute bottom-3 right-3 h-4 w-4 border-r border-b border-rose-400/30 rounded-br" />
{/* 顶部状态条 */}
<div className="pointer-events-none absolute top-3 left-1/2 -translate-x-1/2 flex items-center gap-2 text-[9px] font-mono text-muted-foreground/60">
<span className="h-1 w-1 rounded-full bg-emerald-400 animate-pulse" />
<span>CRYSTAL CORE · ONLINE</span>
<span className="h-1 w-1 rounded-full bg-fuchsia-400 animate-pulse" />
</div>
{/* 底部铭文 */}
<div className="pointer-events-none absolute bottom-3 left-1/2 -translate-x-1/2 text-[8px] font-mono text-muted-foreground/40 tracking-[0.3em] uppercase">
echo · nexus · archaeology
</div>
<div data-tut="crystal-orb" className="contents"> <div data-tut="crystal-orb" className="contents">
<CrystalOrb /> <CrystalOrb />
</div> </div>
@@ -433,7 +456,7 @@ function StatsPanel() {
{ label: "洞见倍率", value: `×${s.insightMult.toFixed(2)}` }, { label: "洞见倍率", value: `×${s.insightMult.toFixed(2)}` },
{ label: "累计探险", value: `${s.totalExpeditions}` }, { label: "累计探险", value: `${s.totalExpeditions}` },
{ label: "BOSS 击破", value: `${s.bossKills ?? 0}` }, { label: "BOSS 击破", value: `${s.bossKills ?? 0}` },
{ label: "星潮亲历", value: `${(s.starTidesEncountered ?? []).length} / 6` }, { label: "星潮亲历", value: `${(s.starTidesEncountered ?? []).length} / 9` },
{ label: "编年史条目", value: `${(s.chronicle ?? []).length} 纪元` }, { label: "编年史条目", value: `${(s.chronicle ?? []).length} 纪元` },
{ label: "探险能量", value: `${Math.floor(s.energy)} / ${s.energyMax}` }, { label: "探险能量", value: `${Math.floor(s.energy)} / ${s.energyMax}` },
{ label: "信标最高分", value: beaconBest !== null ? formatNum(beaconBest) : "—" }, { label: "信标最高分", value: beaconBest !== null ? formatNum(beaconBest) : "—" },
+494 -62
View File
@@ -2,33 +2,44 @@
// 回响星核 / Echo Nexus — 深空信标面板 // 回响星核 / Echo Nexus — 深空信标面板
// v0.5:每日挑战 + 本地排行榜 // v0.5:每日挑战 + 本地排行榜
// v0.8:周挑战 + 信标链(连续完成奖励) // v0.8:周挑战 + 信标链(连续完成奖励)
import { useState, useEffect, useCallback } from "react"; // v0.8.2:限时挑战(每 4 小时刷新,amber 主题)
// v0.8.2 P2/#4:本地/全球双榜(云排行榜 mini-service 端口 3030
import { useState, useEffect, useCallback, useRef } from "react";
import { useGameStore } from "@/store/gameStore"; import { useGameStore } from "@/store/gameStore";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { sfx } from "@/hooks/useAudio"; import { sfx } from "@/hooks/useAudio";
import { import {
generateDailyChallenge, generateDailyChallenge,
generateWeeklyChallenge, generateWeeklyChallenge,
generateTimedChallenge,
loadDailyProgress, loadDailyProgress,
loadWeeklyProgress, loadWeeklyProgress,
loadTimedProgress,
loadLeaderboard, loadLeaderboard,
loadChainState, loadChainState,
claimBeaconReward, claimBeaconReward,
fetchCloudLeaderboard,
submitCloudScore,
loadLastCloudSubmitTimestamp,
BEACON_DIFFICULTY, BEACON_DIFFICULTY,
BEACON_TYPE_META, BEACON_TYPE_META,
BEACON_CHAIN_MILESTONES, BEACON_CHAIN_MILESTONES,
BEACON_CHAIN_REWARDS, BEACON_CHAIN_REWARDS,
getWeekKey, getWeekKey,
getTodayKey, getTodayKey,
getTimedSlotKey,
getNextMilestone, getNextMilestone,
getChainProgress, getChainProgress,
msUntilNextDay, msUntilNextDay,
msUntilNextWeek, msUntilNextWeek,
msUntilNextTimedSlot,
formatCountdown, formatCountdown,
type BeaconDailyChallenge, type BeaconDailyChallenge,
type BeaconDailyProgress, type BeaconDailyProgress,
type BeaconWeeklyChallenge, type BeaconWeeklyChallenge,
type BeaconWeeklyProgress, type BeaconWeeklyProgress,
type BeaconTimedChallenge,
type BeaconTimedProgress,
type BeaconScoreEntry, type BeaconScoreEntry,
type BeaconChallengeType, type BeaconChallengeType,
type BeaconDifficulty, type BeaconDifficulty,
@@ -48,6 +59,11 @@ import {
Link2, Link2,
Flame, Flame,
Zap, Zap,
Hourglass,
RefreshCw,
Globe,
WifiOff,
Loader2,
} from "lucide-react"; } from "lucide-react";
const DIFFICULTY_ORDER: BeaconDifficulty[] = ["routine", "anomaly", "singular"]; const DIFFICULTY_ORDER: BeaconDifficulty[] = ["routine", "anomaly", "singular"];
@@ -62,11 +78,16 @@ function rankBadge(rank: number) {
export function BeaconPanel() { export function BeaconPanel() {
const grantBeaconReward = useGameStore((s) => s.grantBeaconReward); const grantBeaconReward = useGameStore((s) => s.grantBeaconReward);
const claimWeeklyBeacon = useGameStore((s) => s.claimWeeklyBeacon); const claimWeeklyBeacon = useGameStore((s) => s.claimWeeklyBeacon);
const claimTimedBeacon = useGameStore((s) => s.claimTimedBeacon);
const claimChainReward = useGameStore((s) => s.claimChainReward); const claimChainReward = useGameStore((s) => s.claimChainReward);
const { toast } = useToast(); const { toast } = useToast();
const [challenge, setChallenge] = useState<BeaconDailyChallenge | null>(null); const [challenge, setChallenge] = useState<BeaconDailyChallenge | null>(null);
const [progress, setProgress] = useState<BeaconDailyProgress | null>(null); const [progress, setProgress] = useState<BeaconDailyProgress | null>(null);
const [timedChallenge, setTimedChallenge] =
useState<BeaconTimedChallenge | null>(null);
const [timedProgress, setTimedProgress] =
useState<BeaconTimedProgress | null>(null);
const [weeklyChallenge, setWeeklyChallenge] = const [weeklyChallenge, setWeeklyChallenge] =
useState<BeaconWeeklyChallenge | null>(null); useState<BeaconWeeklyChallenge | null>(null);
const [weeklyProgress, setWeeklyProgress] = const [weeklyProgress, setWeeklyProgress] =
@@ -74,13 +95,25 @@ export function BeaconPanel() {
const [chainState, setChainState] = useState<BeaconChainState | null>(null); const [chainState, setChainState] = useState<BeaconChainState | null>(null);
const [leaderboard, setLeaderboard] = useState<BeaconScoreEntry[]>([]); const [leaderboard, setLeaderboard] = useState<BeaconScoreEntry[]>([]);
const [dayCountdown, setDayCountdown] = useState("00:00:00"); const [dayCountdown, setDayCountdown] = useState("00:00:00");
const [timedCountdown, setTimedCountdown] = useState("00:00:00");
const [weekCountdown, setWeekCountdown] = useState("00:00:00"); const [weekCountdown, setWeekCountdown] = useState("00:00:00");
const [now, setNow] = useState(Date.now()); const [now, setNow] = useState(Date.now());
// 云排行榜状态(v0.8.2 P2/#4
const [lbTab, setLbTab] = useState<"local" | "global">("local");
const [cloudEntries, setCloudEntries] = useState<BeaconScoreEntry[]>([]);
const [cloudLoading, setCloudLoading] = useState(false);
const [cloudError, setCloudError] = useState<string | null>(null);
const [cloudFetched, setCloudFetched] = useState(false);
const [mySubmitTs, setMySubmitTs] = useState<number>(0);
const cloudFetchingRef = useRef(false);
// 初始化 + 每秒刷新(进度 + 倒计时) // 初始化 + 每秒刷新(进度 + 倒计时)
useEffect(() => { useEffect(() => {
setChallenge(generateDailyChallenge()); setChallenge(generateDailyChallenge());
setProgress(loadDailyProgress()); setProgress(loadDailyProgress());
setTimedChallenge(generateTimedChallenge());
setTimedProgress(loadTimedProgress());
setWeeklyChallenge(generateWeeklyChallenge()); setWeeklyChallenge(generateWeeklyChallenge());
setWeeklyProgress(loadWeeklyProgress()); setWeeklyProgress(loadWeeklyProgress());
setChainState(loadChainState()); setChainState(loadChainState());
@@ -88,9 +121,11 @@ export function BeaconPanel() {
const id = setInterval(() => { const id = setInterval(() => {
setNow(Date.now()); setNow(Date.now());
setProgress(loadDailyProgress()); setProgress(loadDailyProgress());
setTimedProgress(loadTimedProgress());
setWeeklyProgress(loadWeeklyProgress()); setWeeklyProgress(loadWeeklyProgress());
setChainState(loadChainState()); setChainState(loadChainState());
setChallenge((c) => c ?? generateDailyChallenge()); setChallenge((c) => c ?? generateDailyChallenge());
setTimedChallenge((c) => c ?? generateTimedChallenge());
setWeeklyChallenge((c) => c ?? generateWeeklyChallenge()); setWeeklyChallenge((c) => c ?? generateWeeklyChallenge());
}, 1000); }, 1000);
return () => clearInterval(id); return () => clearInterval(id);
@@ -98,9 +133,44 @@ export function BeaconPanel() {
useEffect(() => { useEffect(() => {
setDayCountdown(formatCountdown(msUntilNextDay(new Date(now)))); setDayCountdown(formatCountdown(msUntilNextDay(new Date(now))));
setTimedCountdown(formatCountdown(msUntilNextTimedSlot(new Date(now))));
setWeekCountdown(formatCountdown(msUntilNextWeek(new Date(now)))); setWeekCountdown(formatCountdown(msUntilNextWeek(new Date(now))));
}, [now]); }, [now]);
// 初始化:读取本地保存的"上次提交时间戳"(用于全球榜高亮我的记录)
useEffect(() => {
setMySubmitTs(loadLastCloudSubmitTimestamp());
}, []);
// 拉取全球排行榜
const refreshCloudLeaderboard = useCallback(async () => {
if (cloudFetchingRef.current) return;
cloudFetchingRef.current = true;
setCloudLoading(true);
setCloudError(null);
try {
const entries = await fetchCloudLeaderboard();
setCloudEntries(entries);
setCloudFetched(true);
setMySubmitTs(loadLastCloudSubmitTimestamp());
if (entries.length === 0) {
setCloudError("尚无全球记录,成为首位登顶者!");
}
} catch {
setCloudError("全球榜连接失败,请稍后重试");
} finally {
setCloudLoading(false);
cloudFetchingRef.current = false;
}
}, []);
// 切到全球 tab 时自动拉取一次(如已拉取过则不强制)
useEffect(() => {
if (lbTab === "global" && !cloudFetched && !cloudLoading) {
void refreshCloudLeaderboard();
}
}, [lbTab, cloudFetched, cloudLoading, refreshCloudLeaderboard]);
const handleClaimDaily = useCallback(() => { const handleClaimDaily = useCallback(() => {
if (!challenge || !progress) return; if (!challenge || !progress) return;
if (progress.completedAt === null || progress.claimed) return; if (progress.completedAt === null || progress.claimed) return;
@@ -116,7 +186,41 @@ export function BeaconPanel() {
1 1
)} 接触 · 得分 ${res.score}`, )} 接触 · 得分 ${res.score}`,
}); });
}, [challenge, progress, toast, grantBeaconReward]); // 异步提交到云排行榜(失败静默,不影响本地)
if (res.entry) {
void submitCloudScore(res.entry).then((rank) => {
if (rank >= 1) {
setMySubmitTs(res.entry!.timestamp);
// 若用户在全球 tab,自动刷新
if (lbTab === "global") {
void refreshCloudLeaderboard();
}
}
});
}
}, [challenge, progress, toast, grantBeaconReward, lbTab, refreshCloudLeaderboard]);
const handleClaimTimed = useCallback(() => {
if (!timedChallenge || !timedProgress) return;
if (timedProgress.completedAt === null || timedProgress.claimed) return;
const res = claimTimedBeacon();
setLeaderboard(loadLeaderboard());
setTimedProgress(loadTimedProgress());
if (res.rewardInsight > 0 || res.rewardContact > 0) {
sfx("achievement");
toast({
title: "✦ 限时挑战奖励已领取",
description: `+${res.rewardInsight} 洞见 · +${res.rewardContact.toFixed(
1
)} 接触 · 得分 ${res.score}`,
});
// 异步刷新云榜(claimTimedBeacon 已在 gameStore 内异步提交到云端)
setMySubmitTs(loadLastCloudSubmitTimestamp());
if (lbTab === "global") {
void refreshCloudLeaderboard();
}
}
}, [timedChallenge, timedProgress, toast, claimTimedBeacon, lbTab, refreshCloudLeaderboard]);
const handleClaimWeekly = useCallback(() => { const handleClaimWeekly = useCallback(() => {
if (!weeklyChallenge || !weeklyProgress) return; if (!weeklyChallenge || !weeklyProgress) return;
@@ -132,8 +236,13 @@ export function BeaconPanel() {
1 1
)} 接触 · 得分 ${res.score}`, )} 接触 · 得分 ${res.score}`,
}); });
// 异步刷新云榜(claimWeeklyBeacon 已在 gameStore 内异步提交到云端)
setMySubmitTs(loadLastCloudSubmitTimestamp());
if (lbTab === "global") {
void refreshCloudLeaderboard();
}
} }
}, [weeklyChallenge, weeklyProgress, toast, claimWeeklyBeacon]); }, [weeklyChallenge, weeklyProgress, toast, claimWeeklyBeacon, lbTab, refreshCloudLeaderboard]);
const handleClaimChain = useCallback( const handleClaimChain = useCallback(
(milestone: number) => { (milestone: number) => {
@@ -155,7 +264,7 @@ export function BeaconPanel() {
[toast, claimChainReward] [toast, claimChainReward]
); );
if (!challenge || !progress || !weeklyChallenge || !weeklyProgress || !chainState) { if (!challenge || !progress || !timedChallenge || !timedProgress || !weeklyChallenge || !weeklyProgress || !chainState) {
return ( return (
<div className="flex items-center justify-center h-full text-xs text-muted-foreground/60"> <div className="flex items-center justify-center h-full text-xs text-muted-foreground/60">
@@ -170,6 +279,14 @@ export function BeaconPanel() {
const isClaimed = progress.claimed; const isClaimed = progress.claimed;
const canClaim = isCompleted && !isClaimed; const canClaim = isCompleted && !isClaimed;
// 限时挑战派生量
const tDiffMeta = BEACON_DIFFICULTY[timedChallenge.difficulty];
const tTypeMeta = BEACON_TYPE_META[timedChallenge.type];
const tPct = Math.min(100, (timedProgress.progress / timedChallenge.goal) * 100);
const tCompleted = timedProgress.completedAt !== null;
const tClaimed = timedProgress.claimed;
const tCanClaim = tCompleted && !tClaimed;
// 周挑战派生量 // 周挑战派生量
const wDiffMeta = BEACON_DIFFICULTY[weeklyChallenge.difficulty]; const wDiffMeta = BEACON_DIFFICULTY[weeklyChallenge.difficulty];
const wTypeMeta = BEACON_TYPE_META[weeklyChallenge.type]; const wTypeMeta = BEACON_TYPE_META[weeklyChallenge.type];
@@ -202,6 +319,14 @@ export function BeaconPanel() {
0%, 100% { box-shadow: 0 0 18px rgba(232,121,249,0.35), inset 0 0 12px rgba(232,121,249,0.25); } 0%, 100% { box-shadow: 0 0 18px rgba(232,121,249,0.35), inset 0 0 12px rgba(232,121,249,0.25); }
50% { box-shadow: 0 0 32px rgba(232,121,249,0.5), inset 0 0 20px rgba(232,121,249,0.35); } 50% { box-shadow: 0 0 32px rgba(232,121,249,0.5), inset 0 0 20px rgba(232,121,249,0.35); }
} }
@keyframes timed-glow {
0%, 100% { box-shadow: 0 0 18px rgba(251,191,36,0.4), inset 0 0 12px rgba(251,191,36,0.25); }
50% { box-shadow: 0 0 30px rgba(251,191,36,0.55), inset 0 0 20px rgba(251,191,36,0.35); }
}
@keyframes timed-urgent {
0%, 100% { box-shadow: 0 0 18px rgba(244,63,94,0.5), inset 0 0 12px rgba(244,63,94,0.3); }
50% { box-shadow: 0 0 34px rgba(244,63,94,0.7), inset 0 0 22px rgba(244,63,94,0.4); }
}
@keyframes chain-milestone-pulse { @keyframes chain-milestone-pulse {
0%, 100% { transform: scale(1); box-shadow: 0 0 0 0 rgba(251,113,133,0.55); } 0%, 100% { transform: scale(1); box-shadow: 0 0 0 0 rgba(251,113,133,0.55); }
50% { transform: scale(1.06); box-shadow: 0 0 0 8px rgba(251,113,133,0); } 50% { transform: scale(1.06); box-shadow: 0 0 0 8px rgba(251,113,133,0); }
@@ -344,6 +469,144 @@ export function BeaconPanel() {
</div> </div>
</div> </div>
{/* ===== 限时挑战卡片(amber 主题,每 4 小时刷新) ===== */}
{(() => {
// 距离时段结束 < 30 分钟时进入"紧急"状态(rose 脉冲)
const msLeft = msUntilNextTimedSlot(new Date(now));
const urgent = msLeft < 30 * 60 * 1000 && !tCompleted;
const glowAnim = tCompleted
? "none"
: urgent
? "timed-urgent 1.5s ease-in-out infinite"
: "timed-glow 3s ease-in-out infinite";
return (
<div
className="relative rounded-xl border p-3 overflow-hidden"
style={{
borderColor: urgent ? "rgba(244,63,94,0.5)" : "rgba(251,191,36,0.4)",
background: urgent
? "linear-gradient(135deg, rgba(244,63,94,0.12), rgba(251,191,36,0.08), rgba(0,0,0,0.45))"
: "linear-gradient(135deg, rgba(251,191,36,0.14), rgba(0,0,0,0.45))",
animation: glowAnim,
}}
>
{/* 头部:标题 + slotKey + 倒计时 */}
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-1.5">
<Hourglass className={`h-3.5 w-3.5 ${urgent ? "text-rose-400" : "text-amber-400"}`} />
<span className={`text-xs font-semibold ${urgent ? "text-rose-200" : "text-amber-200"}`}>
· TIMED
</span>
</div>
<div className="flex items-center gap-1 text-[9px] text-muted-foreground/70">
<span className="font-mono text-amber-300/70">
{getTimedSlotKey().split("_").slice(1).join(" ")}
</span>
<Clock className="h-2.5 w-2.5" />
<span className={`font-mono ${urgent ? "text-rose-300" : "text-amber-300/90"}`}>
{timedCountdown}
</span>
</div>
</div>
{/* 难度 + 类型 标签 */}
<div className="flex items-center gap-1.5 mb-2">
<span
className="text-[10px] font-mono px-1.5 py-0.5 rounded flex items-center gap-1"
style={{ background: `${tDiffMeta.color}22`, color: tDiffMeta.color, border: `1px solid ${tDiffMeta.color}55` }}
>
<span>{tDiffMeta.icon}</span>
{tDiffMeta.label}
</span>
<span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-white/5 text-muted-foreground border border-white/10">
{tTypeMeta.icon} {tTypeMeta.label}
</span>
{tCompleted && (
<span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-emerald-500/20 text-emerald-300 border border-emerald-500/40 flex items-center gap-1">
<Sparkles className="h-2.5 w-2.5" />
</span>
)}
{urgent && !tCompleted && (
<span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-rose-500/20 text-rose-300 border border-rose-500/40 flex items-center gap-1 animate-pulse">
</span>
)}
</div>
{/* 标题 */}
<h4
className="text-sm font-semibold mb-1"
style={{ color: tDiffMeta.color, textShadow: `0 0 10px ${tDiffMeta.glow}` }}
>
{timedChallenge.title}
</h4>
<p className="text-[10px] text-muted-foreground/80 leading-relaxed mb-2">
{timedChallenge.desc}
</p>
{/* 进度条 */}
<div className="mb-2">
<div className="flex items-center justify-between mb-1">
<span className="text-[10px] text-muted-foreground/70"></span>
<span className="text-[11px] font-mono font-semibold" style={{ color: tDiffMeta.color }}>
{Math.min(timedProgress.progress, timedChallenge.goal)} / {timedChallenge.goal} {tTypeMeta.unit}
</span>
</div>
<Progress
value={tPct}
className="h-2 bg-black/40"
style={{
["--progress-color" as string]: urgent ? "#f43f5e" : tDiffMeta.color,
}}
/>
</div>
{/* 奖励 + 领取按钮 */}
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-1.5 text-[10px]">
<span className="text-muted-foreground/60"></span>
{timedChallenge.rewardInsight > 0 && (
<span className="text-amber-300 font-mono">
+{formatNum(timedChallenge.rewardInsight)}
</span>
)}
<span className="text-fuchsia-300 font-mono">
+{timedChallenge.rewardContact.toFixed(1)}
</span>
</div>
<Button
onClick={handleClaimTimed}
disabled={!tCanClaim}
size="sm"
className="h-7 px-3 text-[11px] border-0"
style={{
background: tCanClaim
? `linear-gradient(90deg, #34d399, #34d399cc)`
: "rgba(251,191,36,0.10)",
color: tCanClaim
? "#022c22"
: "rgba(251,191,36,0.55)",
}}
>
{tClaimed
? "已领取"
: tCanClaim
? "领取奖励"
: tCompleted
? "已领取"
: "进行中…"}
</Button>
</div>
{tCompleted && timedProgress.durationSec > 0 && (
<div className="mt-1.5 text-[10px] text-muted-foreground/50 text-center">
{Math.floor(timedProgress.durationSec / 60)}{timedProgress.durationSec % 60}
</div>
)}
</div>
);
})()}
{/* 周挑战 + 信标链(桌面端并排,移动端单列) */} {/* 周挑战 + 信标链(桌面端并排,移动端单列) */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-2.5"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-2.5">
{/* ===== 周挑战卡片(fuchsia 主题) ===== */} {/* ===== 周挑战卡片(fuchsia 主题) ===== */}
@@ -671,68 +934,237 @@ export function BeaconPanel() {
</div> </div>
</div> </div>
{/* 本地排行榜 */} {/* 排行榜(本地 Top20 / 全球 Top100 双 tab — v0.8.2 P2/#4 */}
<div className="rounded-xl border border-white/10 bg-black/30 p-2.5"> <div className="rounded-xl border border-white/10 bg-black/30 p-2.5">
<div className="flex items-center gap-1.5 mb-2"> {/* 头部:标题 + tab 切换 + (全球 tab 时)刷新按钮 */}
<Trophy className="h-3.5 w-3.5 text-amber-400" /> <div className="flex items-center justify-between gap-2 mb-2">
<span className="text-xs font-semibold"></span> <div className="flex items-center gap-1.5">
<span className="text-[10px] text-muted-foreground/50"> · Top {leaderboard.length || 0}</span> <Trophy className="h-3.5 w-3.5 text-amber-400" />
<span className="text-xs font-semibold"></span>
</div>
<div className="flex items-center gap-1">
{/* tab 切换 */}
<div className="flex items-center gap-0.5 p-0.5 rounded-lg bg-black/40 border border-white/10">
<button
type="button"
onClick={() => setLbTab("local")}
className={`flex items-center gap-1 px-2 py-0.5 rounded-md text-[10px] font-mono transition-colors ${
lbTab === "local"
? "bg-amber-500/25 text-amber-300 border border-amber-500/40"
: "text-muted-foreground/60 hover:text-muted-foreground border border-transparent"
}`}
aria-pressed={lbTab === "local"}
>
<Trophy className="h-2.5 w-2.5" />
</button>
<button
type="button"
onClick={() => setLbTab("global")}
className={`flex items-center gap-1 px-2 py-0.5 rounded-md text-[10px] font-mono transition-colors ${
lbTab === "global"
? "bg-emerald-500/25 text-emerald-300 border border-emerald-500/40"
: "text-muted-foreground/60 hover:text-muted-foreground border border-transparent"
}`}
aria-pressed={lbTab === "global"}
>
<Globe className="h-2.5 w-2.5" />
</button>
</div>
{/* 刷新按钮(仅全球 tab */}
{lbTab === "global" && (
<button
type="button"
onClick={() => void refreshCloudLeaderboard()}
disabled={cloudLoading}
className="p-1 rounded-md text-muted-foreground/70 hover:text-emerald-300 hover:bg-emerald-500/10 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
aria-label="刷新全球榜"
title="刷新全球榜"
>
<RefreshCw
className={`h-3 w-3 ${cloudLoading ? "animate-spin" : ""}`}
/>
</button>
)}
</div>
</div> </div>
{leaderboard.length === 0 ? ( {/* 子标题:榜量信息 */}
<div className="text-center py-4 text-[11px] text-muted-foreground/40"> <div className="mb-1.5 text-[10px] text-muted-foreground/50 flex items-center gap-2">
<Trophy className="h-6 w-6 mx-auto mb-1 opacity-30" /> {lbTab === "local" ? (
<span> Top {leaderboard.length || 0} · 线</span>
</div> ) : (
) : ( <>
<div className="space-y-0.5 max-h-[180px] overflow-y-auto echo-scroll"> <span> Top {cloudEntries.length || 0}</span>
{leaderboard.map((entry, i) => { {cloudFetched && !cloudLoading && cloudEntries.length > 0 && (
const rb = rankBadge(i); <span className="text-emerald-400/60">· </span>
const eDiff = BEACON_DIFFICULTY[entry.difficulty]; )}
const eType = BEACON_TYPE_META[entry.challenge as BeaconChallengeType]; {cloudLoading && (
const isMine = entry.dateKey === getTodayKey() || entry.dateKey === getWeekKey(); <span className="text-amber-400/70 flex items-center gap-1">
return ( <Loader2 className="h-2.5 w-2.5 animate-spin" />
<div
key={`${entry.timestamp}-${i}`} </span>
className={`flex items-center gap-2 px-2 py-1 rounded-lg text-[11px] ${ )}
entry.isWeekly </>
? "bg-fuchsia-500/10 border border-fuchsia-500/20" )}
: isMine </div>
? "bg-fuchsia-500/10 border border-fuchsia-500/20"
: "hover:bg-white/5" {/* ===== 本地榜 ===== */}
}`} {lbTab === "local" &&
> (leaderboard.length === 0 ? (
{/* 排名 */} <div className="text-center py-4 text-[11px] text-muted-foreground/40">
<span className="w-6 flex items-center justify-center"> <Trophy className="h-6 w-6 mx-auto mb-1 opacity-30" />
{rb ? (
<rb.icon className="h-3.5 w-3.5" style={{ color: rb.color }} /> </div>
) : ( ) : (
<span className="text-muted-foreground/50 font-mono">{i + 1}</span> <div className="space-y-0.5 max-h-[200px] overflow-y-auto echo-scroll">
)} {leaderboard.map((entry, i) => {
</span> const rb = rankBadge(i);
{/* 类型 + 难度 + 周挑战标记 */} const eDiff = BEACON_DIFFICULTY[entry.difficulty];
<span className="flex items-center gap-1 flex-1 min-w-0"> const eType = BEACON_TYPE_META[entry.challenge as BeaconChallengeType];
<span style={{ color: eDiff.color }} className="font-mono">{eDiff.icon}</span> const isMine =
<span className="text-muted-foreground/80 truncate">{eType.label}</span> entry.dateKey === getTodayKey() ||
{entry.isWeekly && ( entry.dateKey === getWeekKey() ||
<span className="text-[9px] font-mono px-1 rounded bg-fuchsia-500/20 text-fuchsia-300 border border-fuchsia-500/40"> entry.dateKey === getTimedSlotKey();
WEEK return (
<div
key={`local-${entry.timestamp}-${i}`}
className={`flex items-center gap-2 px-2 py-1 rounded-lg text-[11px] ${
entry.isWeekly
? "bg-fuchsia-500/10 border border-fuchsia-500/20"
: entry.isTimed
? "bg-amber-500/10 border border-amber-500/20"
: isMine
? "bg-fuchsia-500/10 border border-fuchsia-500/20"
: "hover:bg-white/5"
}`}
>
<span className="w-6 flex items-center justify-center">
{rb ? (
<rb.icon className="h-3.5 w-3.5" style={{ color: rb.color }} />
) : (
<span className="text-muted-foreground/50 font-mono">{i + 1}</span>
)}
</span>
<span className="flex items-center gap-1 flex-1 min-w-0">
<span style={{ color: eDiff.color }} className="font-mono">{eDiff.icon}</span>
<span className="text-muted-foreground/80 truncate">{eType.label}</span>
{entry.isWeekly && (
<span className="text-[9px] font-mono px-1 rounded bg-fuchsia-500/20 text-fuchsia-300 border border-fuchsia-500/40">
WEEK
</span>
)}
{entry.isTimed && (
<span className="text-[9px] font-mono px-1 rounded bg-amber-500/20 text-amber-300 border border-amber-500/40">
TIMED
</span>
)}
{entry.progress >= 1 && <span className="text-emerald-400/70"></span>}
</span>
<span className="text-muted-foreground/50 font-mono text-[10px] w-16 text-right">
{Math.floor(entry.durationSec / 60)}m{entry.durationSec % 60}s
</span>
<span className="font-mono font-semibold w-12 text-right" style={{ color: eDiff.color }}>
{formatNum(entry.score)}
</span>
</div>
);
})}
</div>
))}
{/* ===== 全球榜 ===== */}
{lbTab === "global" && (
<>
{cloudLoading && cloudEntries.length === 0 ? (
<div className="text-center py-4 text-[11px] text-muted-foreground/60 flex items-center justify-center gap-2">
<Loader2 className="h-3.5 w-3.5 animate-spin text-emerald-400" />
</div>
) : cloudEntries.length === 0 ? (
<div className="text-center py-4 text-[11px] text-muted-foreground/40">
{cloudError ? (
<>
<WifiOff className="h-6 w-6 mx-auto mb-1 opacity-40 text-rose-400" />
<span className="text-rose-300/70">{cloudError}</span>
<button
type="button"
onClick={() => void refreshCloudLeaderboard()}
className="block mx-auto mt-2 text-[10px] text-emerald-400/80 hover:text-emerald-300 underline-offset-2 hover:underline"
>
</button>
</>
) : (
<>
<Globe className="h-6 w-6 mx-auto mb-1 opacity-30 text-emerald-400" />
</>
)}
</div>
) : (
<div className="space-y-0.5 max-h-[200px] overflow-y-auto echo-scroll">
{cloudEntries.map((entry, i) => {
const rb = rankBadge(i);
const eDiff = BEACON_DIFFICULTY[entry.difficulty as BeaconDifficulty] ?? BEACON_DIFFICULTY.routine;
const eType = BEACON_TYPE_META[entry.challenge as BeaconChallengeType] ?? { label: entry.challenge, icon: "·", unit: "" };
// 高亮"我的"记录:通过 timestamp 匹配本机最后一次成功提交
const isMine = mySubmitTs > 0 && entry.timestamp === mySubmitTs;
return (
<div
key={`global-${entry.timestamp}-${i}`}
className={`flex items-center gap-2 px-2 py-1 rounded-lg text-[11px] ${
isMine
? "bg-emerald-500/15 border border-emerald-500/40 shadow-[0_0_12px_rgba(52,211,153,0.25)]"
: entry.isWeekly
? "bg-fuchsia-500/10 border border-fuchsia-500/20"
: entry.isTimed
? "bg-amber-500/10 border border-amber-500/20"
: "hover:bg-white/5"
}`}
>
<span className="w-6 flex items-center justify-center">
{rb ? (
<rb.icon className="h-3.5 w-3.5" style={{ color: rb.color }} />
) : (
<span className={`font-mono ${isMine ? "text-emerald-300 font-bold" : "text-muted-foreground/50"}`}>
{i + 1}
</span>
)}
</span> </span>
)} <span className="flex items-center gap-1 flex-1 min-w-0">
{entry.progress >= 1 && <span className="text-emerald-400/70"></span>} <span style={{ color: eDiff.color }} className="font-mono">{eDiff.icon}</span>
</span> <span className="text-muted-foreground/80 truncate">{eType.label}</span>
{/* 用时 */} {entry.isWeekly && (
<span className="text-muted-foreground/50 font-mono text-[10px] w-16 text-right"> <span className="text-[9px] font-mono px-1 rounded bg-fuchsia-500/20 text-fuchsia-300 border border-fuchsia-500/40">
{Math.floor(entry.durationSec / 60)}m{entry.durationSec % 60}s WEEK
</span> </span>
{/* 分数 */} )}
<span className="font-mono font-semibold w-12 text-right" style={{ color: eDiff.color }}> {entry.isTimed && (
{formatNum(entry.score)} <span className="text-[9px] font-mono px-1 rounded bg-amber-500/20 text-amber-300 border border-amber-500/40">
</span> TIMED
</div> </span>
); )}
})} {isMine && (
</div> <span className="text-[9px] font-mono px-1 rounded bg-emerald-500/25 text-emerald-200 border border-emerald-500/50">
YOU
</span>
)}
{entry.progress >= 1 && <span className="text-emerald-400/70"></span>}
</span>
<span className="text-muted-foreground/50 font-mono text-[10px] w-16 text-right">
{Math.floor(entry.durationSec / 60)}m{entry.durationSec % 60}s
</span>
<span className="font-mono font-semibold w-12 text-right" style={{ color: isMine ? "#34d399" : eDiff.color }}>
{formatNum(entry.score)}
</span>
</div>
);
})}
</div>
)}
</>
)} )}
</div> </div>
+55 -9
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
// 回响星核 / Echo Nexus — 回响编年史对话框(v0.4 跨周目叙事时间轴) // 回响星核 / Echo Nexus — 回响编年史对话框(v0.4 跨周目叙事时间轴)
import { useEffect } from "react"; import { useEffect, useState, useMemo } from "react";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -20,7 +20,8 @@ import { getPerk } from "@/lib/game/constellation";
import { TIDE_EVENTS } from "@/lib/game/starTide"; import { TIDE_EVENTS } from "@/lib/game/starTide";
import { sfx } from "@/hooks/useAudio"; import { sfx } from "@/hooks/useAudio";
import type { ChronicleEntry } from "@/lib/game/types"; import type { ChronicleEntry } from "@/lib/game/types";
import { BookOpen, Clock, Swords, Trophy, Sparkles, Star, Waves, Cpu, Zap } from "lucide-react"; import { BookOpen, Clock, Swords, Trophy, Sparkles, Star, Waves, Cpu, Zap, ChevronLeft, ChevronRight } from "lucide-react";
import { Button } from "@/components/ui/button";
interface Props { interface Props {
open: boolean; open: boolean;
@@ -59,12 +60,26 @@ export function ChronicleDialog({ open, onOpenChange }: Props) {
const bossKills = useGameStore((s) => s.bossKills ?? 0); const bossKills = useGameStore((s) => s.bossKills ?? 0);
const tidesAll = useGameStore((s) => s.starTidesEncountered ?? []); const tidesAll = useGameStore((s) => s.starTidesEncountered ?? []);
// 倒序展示(最新的在前) // 倒序展示(最新的在前)
const entries: ChronicleEntry[] = [...chronicle].reverse(); const entries: ChronicleEntry[] = useMemo(() => [...chronicle].reverse(), [chronicle]);
// 打开时播放翻页音效 // v0.8.2 分页(工单 #8):编年史上限 200 条,每页 10 条
const PAGE_SIZE = 10;
const totalPages = Math.max(1, Math.ceil(entries.length / PAGE_SIZE));
const [page, setPage] = useState(1);
// 当 entries 变化(新飞升)且当前页超出范围时,回到第 1 页(最新)
useEffect(() => { useEffect(() => {
if (open) sfx("chronicleOpen"); // eslint-disable-next-line react-hooks/set-state-in-effect
if (page > totalPages) setPage(1);
}, [page, totalPages]);
// 打开时默认回到第 1 页(最新)
useEffect(() => {
if (open) {
sfx("chronicleOpen");
// eslint-disable-next-line react-hooks/set-state-in-effect
setPage(1);
}
}, [open]); }, [open]);
const pagedEntries = entries.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
@@ -108,7 +123,7 @@ export function ChronicleDialog({ open, onOpenChange }: Props) {
</DialogHeader> </DialogHeader>
{/* 时间轴滚动区 */} {/* 时间轴滚动区 */}
<div className="flex-1 overflow-y-auto px-6 pb-6 echo-scroll"> <div className="flex-1 overflow-y-auto px-6 pb-3 echo-scroll">
{entries.length === 0 ? ( {entries.length === 0 ? (
<EmptyState ascensions={ascensions} /> <EmptyState ascensions={ascensions} />
) : ( ) : (
@@ -117,18 +132,49 @@ export function ChronicleDialog({ open, onOpenChange }: Props) {
<div className="absolute left-[27px] top-2 bottom-2 w-px bg-gradient-to-b from-fuchsia-400/40 via-violet-400/30 to-transparent" /> <div className="absolute left-[27px] top-2 bottom-2 w-px bg-gradient-to-b from-fuchsia-400/40 via-violet-400/30 to-transparent" />
<div className="space-y-4"> <div className="space-y-4">
{entries.map((entry, idx) => ( {pagedEntries.map((entry, idx) => (
<ChronicleCard <ChronicleCard
key={`${entry.ascensionNumber}-${entry.timestamp}`} key={`${entry.ascensionNumber}-${entry.timestamp}`}
entry={entry} entry={entry}
isLatest={idx === 0} isLatest={(page - 1) * PAGE_SIZE + idx === 0}
index={idx} index={(page - 1) * PAGE_SIZE + idx}
/> />
))} ))}
</div> </div>
</div> </div>
)} )}
</div> </div>
{/* v0.8.2 分页控制栏(工单 #8 */}
{entries.length > PAGE_SIZE && (
<div className="px-6 py-2.5 border-t border-fuchsia-400/15 bg-black/40 flex items-center justify-between gap-2">
<span className="text-[11px] text-muted-foreground font-mono">
{page} / {totalPages} · {entries.length}
</span>
<div className="flex items-center gap-1.5">
<Button
variant="ghost"
size="sm"
disabled={page <= 1}
onClick={() => { sfx("tabSwitch"); setPage((p) => Math.max(1, p - 1)); }}
className="h-7 px-2 text-fuchsia-300 hover:bg-fuchsia-500/15 disabled:opacity-30"
>
<ChevronLeft className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
disabled={page >= totalPages}
onClick={() => { sfx("tabSwitch"); setPage((p) => Math.min(totalPages, p + 1)); }}
className="h-7 px-2 text-fuchsia-300 hover:bg-fuchsia-500/15 disabled:opacity-30"
>
<ChevronRight className="h-3.5 w-3.5" />
</Button>
</div>
</div>
)}
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );
+52 -12
View File
@@ -16,12 +16,15 @@ export function StarTideNotifier() {
if (events.length === 0) return; if (events.length === 0) return;
const items = consume(); const items = consume();
for (const ev of items) { for (const ev of items) {
const globalTag = ev.isGlobal ? "🌐 " : "";
if (ev.started) { if (ev.started) {
const meta = TIDE_EVENTS[ev.started]; const meta = TIDE_EVENTS[ev.started];
sfx("tideStart"); sfx("tideStart");
toast({ toast({
title: `${meta.icon} 星潮降临${meta.name}`, title: `${globalTag}${meta.icon} ${ev.isGlobal ? "全球星潮" : "星潮降临"}${meta.name}`,
description: meta.desc, description: ev.isGlobal
? `全球玩家同步经历 · ${meta.desc}`
: meta.desc,
}); });
} }
if (ev.ended) { if (ev.ended) {
@@ -29,14 +32,16 @@ export function StarTideNotifier() {
if (ev.silenceCompensation && ev.silenceCompensation > 0) { if (ev.silenceCompensation && ev.silenceCompensation > 0) {
sfx("techBuy"); sfx("techBuy");
toast({ toast({
title: `${meta.icon} 寂静期消散`, title: `${globalTag}${meta.icon} 寂静期消散`,
description: `虚空回赠 ${ev.silenceCompensation} 洞见。`, description: `虚空回赠 ${ev.silenceCompensation} 洞见。`,
}); });
} else { } else {
sfx("tideEnd"); sfx("tideEnd");
toast({ toast({
title: `${meta.icon} ${meta.name}结束`, title: `${globalTag}${meta.icon} ${meta.name}结束`,
description: "星潮退去,物理法则恢复。", description: ev.isGlobal
? "全球星潮退去,物理法则恢复。"
: "星潮退去,物理法则恢复。",
}); });
} }
} }
@@ -49,6 +54,7 @@ export function StarTideNotifier() {
/** 顶部星潮指示器(活跃时显示倒计时芯片) */ /** 顶部星潮指示器(活跃时显示倒计时芯片) */
export function StarTideIndicator() { export function StarTideIndicator() {
const activeTide = useGameStore((s) => s.activeTide); const activeTide = useGameStore((s) => s.activeTide);
const globalTide = useGameStore((s) => s.globalTide);
const [, force] = useState(0); const [, force] = useState(0);
// 每秒刷新倒计时 // 每秒刷新倒计时
@@ -62,18 +68,27 @@ export function StarTideIndicator() {
const meta = TIDE_EVENTS[activeTide.type]; const meta = TIDE_EVENTS[activeTide.type];
const remaining = Math.max(0, activeTide.endsAt - Date.now()); const remaining = Math.max(0, activeTide.endsAt - Date.now());
const secs = Math.ceil(remaining / 1000); const secs = Math.ceil(remaining / 1000);
const progress = 1 - remaining / TIDE_CONFIG.duration; // 全球星潮持续 60s,本地 75s —— 用实际 endsAt-startedAt 计算 progress,避免越界
const duration = globalTide
? globalTide.endsAt - globalTide.startedAt
: TIDE_CONFIG.duration;
const progress = Math.min(1, Math.max(0, 1 - remaining / duration));
const isGlobal = !!globalTide;
return ( return (
<div <div
className="flex items-center gap-1.5 px-2 py-1 rounded-full text-[10px] font-medium relative overflow-hidden" className={`flex items-center gap-1.5 px-2 py-1 rounded-full text-[10px] font-medium relative overflow-hidden ${
isGlobal ? "ring-1 ring-amber-300/60" : ""
}`}
style={{ style={{
background: `${meta.color}1a`, background: isGlobal
border: `1px solid ${meta.color}55`, ? `linear-gradient(135deg, ${meta.color}26, ${meta.color}11)`
: `${meta.color}1a`,
border: `1px solid ${meta.color}${isGlobal ? "88" : "55"}`,
color: meta.color, color: meta.color,
boxShadow: `0 0 10px ${meta.glow}`, boxShadow: `0 0 ${isGlobal ? "16" : "10"}px ${meta.glow}`,
}} }}
title={meta.desc} title={isGlobal ? `🌐 全球星潮 · ${meta.desc}` : meta.desc}
> >
{/* 进度环背景 */} {/* 进度环背景 */}
<div <div
@@ -82,6 +97,14 @@ export function StarTideIndicator() {
background: `linear-gradient(90deg, ${meta.color}40 ${progress * 100}%, transparent ${progress * 100}%)`, background: `linear-gradient(90deg, ${meta.color}40 ${progress * 100}%, transparent ${progress * 100}%)`,
}} }}
/> />
{isGlobal && (
<span
className="relative text-[11px] leading-none animate-pulse"
aria-label="全球星潮标记"
>
🌐
</span>
)}
<span className="relative text-xs leading-none">{meta.icon}</span> <span className="relative text-xs leading-none">{meta.icon}</span>
<span className="relative leading-none">{meta.name}</span> <span className="relative leading-none">{meta.name}</span>
<span className="relative font-mono leading-none opacity-80">{secs}s</span> <span className="relative font-mono leading-none opacity-80">{secs}s</span>
@@ -92,8 +115,10 @@ export function StarTideIndicator() {
/** 全屏背景叠层:活跃星潮时给页面罩一层对应色的微光 */ /** 全屏背景叠层:活跃星潮时给页面罩一层对应色的微光 */
export function StarTideOverlay() { export function StarTideOverlay() {
const activeTide = useGameStore((s) => s.activeTide); const activeTide = useGameStore((s) => s.activeTide);
const globalTide = useGameStore((s) => s.globalTide);
if (!activeTide) return null; if (!activeTide) return null;
const meta = TIDE_EVENTS[activeTide.type]; const meta = TIDE_EVENTS[activeTide.type];
const isGlobal = !!globalTide;
return ( return (
<div className="fixed inset-0 pointer-events-none z-0 transition-opacity duration-1000"> <div className="fixed inset-0 pointer-events-none z-0 transition-opacity duration-1000">
@@ -115,15 +140,30 @@ export function StarTideOverlay() {
<div <div
className="absolute inset-0" className="absolute inset-0"
style={{ style={{
boxShadow: `inset 0 0 120px ${meta.glow}`, boxShadow: `inset 0 0 ${isGlobal ? "160" : "120"}px ${meta.glow}`,
animation: "tide-breathe 3s ease-in-out infinite", animation: "tide-breathe 3s ease-in-out infinite",
}} }}
/> />
{/* 全球星潮:顶部多一道琥珀色光带,强化"全球同步"视觉信号 */}
{isGlobal && (
<div
className="absolute top-0 left-0 right-0 h-1 opacity-90"
style={{
background: "linear-gradient(90deg, transparent, #fbbf24, #fcd34d, #fbbf24, transparent)",
boxShadow: "0 0 16px rgba(251,191,36,0.7)",
animation: "global-tide-sweep 2.5s linear infinite",
}}
/>
)}
<style jsx>{` <style jsx>{`
@keyframes tide-breathe { @keyframes tide-breathe {
0%, 100% { opacity: 0.5; } 0%, 100% { opacity: 0.5; }
50% { opacity: 0.9; } 50% { opacity: 0.9; }
} }
@keyframes global-tide-sweep {
0% { transform: translateX(-30%); }
100% { transform: translateX(30%); }
}
`}</style> `}</style>
</div> </div>
); );
+126
View File
@@ -0,0 +1,126 @@
"use client";
// 回响星核 / Echo Nexus — 全球星潮同步 hookP3 / 工单 #10-c
//
// 连接 star-tide-service(端口 3031),监听全球星潮事件:
// - `tide-state` 连接时立即推送当前状态
// - `global-tide` 全球星潮开始 → 调用 gameStore.triggerGlobalTide(type)
// - `tide-ended` 全球星潮结束(本地 tickTide 也会在 60s 后自动收尾,这是双保险)
//
// 网关:socket.io 必须用 `io("/?XTransformPort=3031")`path 默认 "/"
// 离线容错:socket.io 默认自动重连;断连期间本地 tickTide 继续工作
import { useEffect, useRef } from "react";
import { io, type Socket } from "socket.io-client";
import { useGameStore } from "@/store/gameStore";
import type { TideType } from "@/lib/game/starTide";
/** 服务端推过来的全球星潮状态结构(与 mini-service 一致) */
interface ServerGlobalTide {
type: TideType;
name: string;
startedAt: number;
endsAt: number;
durationSec: number;
}
interface ServerTideState {
current: ServerGlobalTide | null;
nextTideAt: number;
serverTime: number;
}
/** 单例 socket —— 多次挂载 hook 共享同一连接 */
let sharedSocket: Socket | null = null;
let connectRefCount = 0;
function getSharedSocket(): Socket {
if (!sharedSocket) {
sharedSocket = io("/?XTransformPort=3031", {
transports: ["websocket", "polling"],
reconnection: true,
reconnectionAttempts: Infinity, // 持续重连
reconnectionDelay: 1000,
reconnectionDelayMax: 10_000,
timeout: 10_000,
});
}
return sharedSocket;
}
/**
* 挂载此 hook 即可让组件树订阅全球星潮事件。
* 在 page.tsx 顶层调用一次即可。
*/
export function useGlobalTide(): void {
const triggerGlobalTide = useGameStore((s) => s.triggerGlobalTide);
// 用 ref 缓存最新 action,避免 socket 监听器依赖变化导致重绑
const triggerRef = useRef(triggerGlobalTide);
useEffect(() => {
triggerRef.current = triggerGlobalTide;
}, [triggerGlobalTide]);
// 记录已处理过的 global-tide 事件 id(避免重连时重复触发)
const processedIds = useRef<Set<string>>(new Set());
useEffect(() => {
const sock = getSharedSocket();
connectRefCount += 1;
const onGlobalTide = (tide: ServerGlobalTide) => {
// 用 startedAt+type 作为去重 key(同一道全球星潮可能在重连后被重复推送)
const key = `${tide.startedAt}_${tide.type}`;
if (processedIds.current.has(key)) return;
processedIds.current.add(key);
// 限制集合大小
if (processedIds.current.size > 32) {
const first = processedIds.current.values().next().value;
if (first) processedIds.current.delete(first);
}
// 防御:如果服务端推送的星潮已经过期(例如重连时拿到的旧状态),不触发
if (tide.endsAt <= Date.now()) return;
triggerRef.current(tide.type);
};
const onTideState = (state: ServerTideState) => {
// 连接/重连时若服务端正有进行中的全球星潮,且本地还没触发过,补触发
if (state.current && state.current.endsAt > Date.now()) {
const key = `${state.current.startedAt}_${state.current.type}`;
if (!processedIds.current.has(key)) {
processedIds.current.add(key);
triggerRef.current(state.current.type);
}
}
};
sock.on("global-tide", onGlobalTide);
sock.on("tide-state", onTideState);
// 主动查询一次当前状态(保险,防止错过连接时的初始推送)
sock.emit("get-next-tide");
return () => {
sock.off("global-tide", onGlobalTide);
sock.off("tide-state", onTideState);
connectRefCount -= 1;
// 最后一个使用者退出时才真正断开
if (connectRefCount <= 0 && sharedSocket) {
sharedSocket.disconnect();
sharedSocket = null;
connectRefCount = 0;
}
};
}, []);
}
/** QA 用:手动触发一次全球星潮(仅供 dev 控制台调用) */
export function adminTriggerGlobalTide(): void {
if (!sharedSocket) {
// 还没连接则先建立
getSharedSocket();
}
sharedSocket?.emit("admin-trigger-tide");
}
if (typeof window !== "undefined" && process.env.NODE_ENV !== "production") {
(window as unknown as { __adminTriggerGlobalTide?: typeof adminTriggerGlobalTide }).__adminTriggerGlobalTide = adminTriggerGlobalTide;
}
+2 -2
View File
@@ -244,10 +244,10 @@ export const ACHIEVEMENTS: Achievement[] = [
{ {
id: "ach_tides_all", id: "ach_tides_all",
name: "星潮亲历者", name: "星潮亲历者",
desc: "经历全部 6 种星潮事件", desc: "经历全部 9 种星潮事件",
icon: "🌊", icon: "🌊",
color: "#34d399", color: "#34d399",
check: (s) => (s.starTidesEncountered?.length ?? 0) >= 6, check: (s) => (s.starTidesEncountered?.length ?? 0) >= 9,
reward: { crystalsPerSecPct: 8, insightPct: 8 }, reward: { crystalsPerSecPct: 8, insightPct: 8 },
rewardText: "产能 +8% · 洞见 +8%", rewardText: "产能 +8% · 洞见 +8%",
}, },
+417 -1
View File
@@ -1,7 +1,8 @@
// 回响星核 / Echo Nexus — 深空信标 // 回响星核 / Echo Nexus — 深空信标
// v0.5:每日挑战 + 本地排行榜 // v0.5:每日挑战 + 本地排行榜
// v0.8:周挑战 + 信标链(连续完成奖励) // v0.8:周挑战 + 信标链(连续完成奖励)
// 一个自包含的"每日挑战 + 周挑战 + 连续完成链"元系统,纯 localStorage 持久化。 // v0.8.2:限时挑战(每 4 小时刷新,填补日挑战空档)
// 一个自包含的"每日 + 限时 + 周挑战 + 连续完成链"元系统,纯 localStorage 持久化。
/** 每日挑战类型 */ /** 每日挑战类型 */
export type BeaconChallengeType = export type BeaconChallengeType =
@@ -32,6 +33,8 @@ export interface BeaconScoreEntry {
durationSec: number; durationSec: number;
/** v0.8:是否为周挑战记录(日挑战默认 false / undefined */ /** v0.8:是否为周挑战记录(日挑战默认 false / undefined */
isWeekly?: boolean; isWeekly?: boolean;
/** v0.8.2:是否为限时挑战记录 */
isTimed?: boolean;
} }
/** 每日挑战定义 */ /** 每日挑战定义 */
@@ -334,6 +337,7 @@ export function claimBeaconReward(
rewardContact: challenge.rewardContact, rewardContact: challenge.rewardContact,
score, score,
leaderboard, leaderboard,
entry,
}; };
} }
@@ -614,6 +618,7 @@ export function claimWeeklyReward(
rewardContact: challenge.rewardContact, rewardContact: challenge.rewardContact,
score, score,
leaderboard, leaderboard,
entry,
}; };
} }
@@ -872,3 +877,414 @@ export function getChainProgress(streak: number): {
progressPct: Math.min(100, Math.max(0, pct)), progressPct: Math.min(100, Math.max(0, pct)),
}; };
} }
// ===========================================================================
// 限时挑战(TIMED CHALLENGE)— v0.8.2
// 每 4 小时刷新一个"短周期挑战",目标较小、奖励较少,填补日挑战的空档。
// 时段按 UTC 0/4/8/12/16/20 点切分,同时段同种子 → 同一挑战(确定性)。
// 难度固定 routinegoal = 日挑战基准 × 0.3-0.5。
// ===========================================================================
/** 限时挑战 localStorage key */
export const BEACON_TIMED_KEY = "echo-nexus-beacon-timed-v1";
/** 限时挑战定义 */
export interface BeaconTimedChallenge {
/** 时段 key(如 "timed_2026-06-24_14" 表示 14-18 点时段) */
slotKey: string;
/** 挑战类型 */
type: BeaconChallengeType;
/** 难度(固定 routine */
difficulty: BeaconDifficulty;
/** 目标数值(日挑战的 30-50% */
goal: number;
/** 奖励:完成时洞见 */
rewardInsight: number;
/** 奖励:完成时接触进度 */
rewardContact: number;
/** 使用的种子(可复现) */
seed: number;
/** 友好标题 */
title: string;
/** 描述 */
desc: string;
/** 时段结束时间戳(UTC,用于倒计时) */
expiresAt: number;
}
/** 限时挑战进度 */
export interface BeaconTimedProgress {
slotKey: string;
progress: number;
startedAt: number;
completedAt: number | null;
claimed: boolean;
durationSec: number;
}
/** 时段长度(毫秒) */
export const TIMED_SLOT_MS = 4 * 60 * 60 * 1000;
/**
* 取当前 4 小时时段 keyUTC0/4/8/12/16/20 点切分)。
* 格式:"timed_YYYY-MM-DD_HH",如 "timed_2026-06-24_14"。
*/
export function getTimedSlotKey(now: Date = new Date()): string {
const y = now.getUTCFullYear();
const m = String(now.getUTCMonth() + 1).padStart(2, "0");
const d = String(now.getUTCDate()).padStart(2, "0");
const hour = now.getUTCHours();
const slotStart = Math.floor(hour / 4) * 4; // 0, 4, 8, 12, 16, 20
return `timed_${y}-${m}-${d}_${String(slotStart).padStart(2, "0")}`;
}
/** slotKey → 数值种子(FNV-1a 哈希) */
function timedSlotKeyToSeed(slotKey: string): number {
let h = 2166136261 >>> 0;
for (let i = 0; i < slotKey.length; i++) {
h ^= slotKey.charCodeAt(i);
h = Math.imul(h, 16777619) >>> 0;
}
return h >>> 0;
}
/** 计算时段开始时间戳(UTC) */
function timedSlotStartMs(slotKey: string): number {
// 解析 "timed_YYYY-MM-DD_HH"
const match = slotKey.match(/^timed_(\d{4})-(\d{2})-(\d{2})_(\d{2})$/);
if (!match) return Date.now();
const [, y, m, d, h] = match;
return Date.UTC(Number(y), Number(m) - 1, Number(d), Number(h), 0, 0);
}
/** 距离下一个 4 小时时段的毫秒数(用于倒计时) */
export function msUntilNextTimedSlot(now: Date = new Date()): number {
const hour = now.getUTCHours();
const slotStart = Math.floor(hour / 4) * 4;
const slotStartMs = Date.UTC(
now.getUTCFullYear(),
now.getUTCMonth(),
now.getUTCDate(),
slotStart,
0,
0
);
const nextSlotMs = slotStartMs + TIMED_SLOT_MS;
return Math.max(0, nextSlotMs - now.getTime());
}
/**
* 生成当前时段的限时挑战(确定性:同时段同种子 → 同一挑战)。
* 难度固定 routinegoal = 日挑战基准 × 0.3-0.5(向下取整,最小 1)。
*/
export function generateTimedChallenge(
now: Date = new Date()
): BeaconTimedChallenge {
const slotKey = getTimedSlotKey(now);
const seed = timedSlotKeyToSeed(slotKey);
const rng = mulberry32(seed);
const expiresAt = timedSlotStartMs(slotKey) + TIMED_SLOT_MS;
const types: BeaconChallengeType[] = [
"decode",
"expedition",
"pulse",
"boss",
"insight",
];
const type = types[Math.floor(rng() * types.length)];
// 难度固定 routine
const difficulty: BeaconDifficulty = "routine";
const diffMult = BEACON_DIFFICULTY[difficulty].mult; // 1
// goal = 日挑战基准 × 0.3-0.5
const frac = 0.3 + rng() * 0.2; // 0.3-0.5
let goal = 0;
let rewardInsight = 0;
let rewardContact = 0;
let title = "";
let desc = "";
switch (type) {
case "decode":
// 日基准 6-15 × 0.3-0.5 → 2-7
goal = Math.max(2, Math.round((6 + Math.floor(rng() * 10)) * diffMult * frac));
rewardInsight = Math.round(goal * 3 * diffMult);
rewardContact = goal * 0.6;
title = `限时解码 ${goal} 颗晶体`;
desc = `4 小时内解码 ${goal} 颗记忆晶体。短周期,快速完成。`;
break;
case "expedition":
// 日基准 1-3 × 0.3-0.5 → 1(多数情况为 1
goal = Math.max(1, Math.round((1 + Math.floor(rng() * 3)) * diffMult * frac));
rewardInsight = Math.round(goal * 9 * diffMult);
rewardContact = goal * 1.2;
title = `限时远征 ${goal} 次探险`;
desc = `4 小时内完成 ${goal} 次遗迹探险。`;
break;
case "pulse":
// 日基准 20-49 × 0.3-0.5 → 6-24
goal = Math.max(6, Math.round((20 + Math.floor(rng() * 30)) * diffMult * frac));
rewardInsight = Math.round(goal * 1 * diffMult);
rewardContact = goal * 0.25;
title = `限时脉冲 ${goal} 次扫描`;
desc = `4 小时内发起 ${goal} 次脉冲扫描。`;
break;
case "boss":
// 日基准 1 × 0.3-0.5 → 1(向下取整为 1
goal = Math.max(1, Math.round(diffMult * frac));
rewardInsight = Math.round(goal * 25 * diffMult);
rewardContact = goal * 2.5;
title = `限时猎杀 ${goal} 处 BOSS`;
desc = `4 小时内击破 ${goal} 处维度 BOSS。`;
break;
case "insight":
// 日基准 40-119 × 0.3-0.5 → 12-59
goal = Math.max(12, Math.round((40 + Math.floor(rng() * 80)) * diffMult * frac));
rewardInsight = 0; // 洞见挑战不给洞见,给接触
rewardContact = goal * 0.08;
title = `限时洞见 ${goal}`;
desc = `4 小时内累计获取 ${goal} 洞见。`;
break;
}
return {
slotKey,
type,
difficulty,
goal,
rewardInsight,
rewardContact,
seed,
title,
desc,
expiresAt,
};
}
/** 读取当前时段进度(若 slotKey 不匹配则重置) */
export function loadTimedProgress(now: Date = new Date()): BeaconTimedProgress {
const slotKey = getTimedSlotKey(now);
const empty: BeaconTimedProgress = {
slotKey,
progress: 0,
startedAt: Date.now(),
completedAt: null,
claimed: false,
durationSec: 0,
};
if (typeof localStorage === "undefined") return empty;
try {
const raw = localStorage.getItem(BEACON_TIMED_KEY);
if (!raw) return empty;
const prog = JSON.parse(raw) as BeaconTimedProgress;
if (prog.slotKey !== slotKey) return empty; // 新时段,重置
return prog;
} catch {
return empty;
}
}
/** 保存当前时段进度 */
export function saveTimedProgress(prog: BeaconTimedProgress): void {
if (typeof localStorage === "undefined") return;
localStorage.setItem(BEACON_TIMED_KEY, JSON.stringify(prog));
}
/** 增量更新限时挑战进度,返回新进度 + 是否刚完成 */
export function addTimedProgress(
current: BeaconTimedProgress,
challenge: BeaconTimedChallenge,
delta: number
): { progress: BeaconTimedProgress; justCompleted: boolean } {
const newProgressVal = Math.min(challenge.goal, current.progress + delta);
const justCompleted =
current.completedAt === null && newProgressVal >= challenge.goal;
const completedAt = justCompleted ? Date.now() : current.completedAt;
const durationSec =
completedAt !== null
? Math.floor((completedAt - current.startedAt) / 1000)
: current.durationSec;
const next: BeaconTimedProgress = {
...current,
progress: newProgressVal,
completedAt,
durationSec,
};
saveTimedProgress(next);
return { progress: next, justCompleted };
}
/** 领取限时挑战奖励:返回奖励数值 + 推送排行榜 */
export function claimTimedReward(
challenge: BeaconTimedChallenge,
progress: BeaconTimedProgress
): {
rewardInsight: number;
rewardContact: number;
score: number;
leaderboard: BeaconScoreEntry[];
} {
if (progress.claimed || progress.completedAt === null) {
return {
rewardInsight: 0,
rewardContact: 0,
score: 0,
leaderboard: loadLeaderboard(),
};
}
// 借用 computeBeaconScore
const score = computeBeaconScore(
{ ...(challenge as unknown as BeaconDailyChallenge), dateKey: challenge.slotKey },
progress.progress,
progress.durationSec
);
const entry: BeaconScoreEntry = {
timestamp: Date.now(),
dateKey: challenge.slotKey,
challenge: challenge.type,
difficulty: challenge.difficulty,
progress: progress.progress / challenge.goal,
score,
durationSec: progress.durationSec,
isTimed: true,
};
const leaderboard = pushLeaderboardEntry(entry);
const updated: BeaconTimedProgress = { ...progress, claimed: true };
saveTimedProgress(updated);
return {
rewardInsight: challenge.rewardInsight,
rewardContact: challenge.rewardContact,
score,
leaderboard,
entry,
};
}
// ===========================================================================
// 云排行榜(CLOUD LEADERBOARD)— v0.8.2 P2/#4
// 把信标本机排行榜升级为云端 Top100,玩家间可比。
// 通过 mini-service(端口 3030Hono + Bun)提供 API
// GET /api/leaderboard → { entries, total }
// POST /api/leaderboard body { entry } → { entries, total, rank }
// GET /api/leaderboard/stats → { totalSubmissions, uniquePlayers, topScore }
// 前端请求必须用相对路径 + ?XTransformPort=3030Caddy 网关转发规则)。
// 本地排行榜(loadLeaderboard / pushLeaderboardEntry)保留作为离线 fallback。
// ===========================================================================
/** 云排行榜服务端口(Caddy 通过 ?XTransformPort=3030 转发) */
export const BEACON_CLOUD_PORT = 3030;
/** 上次成功提交到云端的 entry 时间戳(用于在全球榜中高亮"我的"记录) */
export const BEACON_CLOUD_LAST_SUBMIT_KEY = "echo-nexus-beacon-cloud-last-submit-v1";
/** 云排行榜 API 基础 URL(相对路径 + 网关端口参数,禁止 localhost:3030 */
const CLOUD_API_BASE = `/api/leaderboard?XTransformPort=${BEACON_CLOUD_PORT}`;
/** 云排行榜统计 API */
const CLOUD_STATS_URL = `/api/leaderboard/stats?XTransformPort=${BEACON_CLOUD_PORT}`;
/** 云端排行榜条目(与本地 BeaconScoreEntry 兼容,宽松化以接受历史数据) */
export interface CloudLeaderboardResponse {
entries: BeaconScoreEntry[];
total: number;
}
/** 提交响应(包含本次提交的全球名次) */
export interface CloudSubmitResponse {
entries: BeaconScoreEntry[];
total: number;
rank: number;
}
/** 云端统计响应 */
export interface CloudStatsResponse {
totalSubmissions: number;
uniquePlayers: number;
topScore: number;
}
/** 读取本地保存的"上次提交时间戳"(用于全球榜高亮) */
export function loadLastCloudSubmitTimestamp(): number {
if (typeof localStorage === "undefined") return 0;
try {
const raw = localStorage.getItem(BEACON_CLOUD_LAST_SUBMIT_KEY);
return raw ? Number(raw) || 0 : 0;
} catch {
return 0;
}
}
/** 保存"上次提交时间戳"到本地 */
function saveLastCloudSubmitTimestamp(timestamp: number): void {
if (typeof localStorage === "undefined") return;
try {
localStorage.setItem(BEACON_CLOUD_LAST_SUBMIT_KEY, String(timestamp));
} catch {
/* ignore */
}
}
/**
* 拉取全球排行榜 Top100。
* 失败时返回空数组(调用方负责展示错误状态),不抛异常。
*/
export async function fetchCloudLeaderboard(): Promise<BeaconScoreEntry[]> {
try {
const res = await fetch(CLOUD_API_BASE, {
method: "GET",
headers: { Accept: "application/json" },
});
if (!res.ok) return [];
const data = (await res.json()) as CloudLeaderboardResponse;
if (!data || !Array.isArray(data.entries)) return [];
return data.entries;
} catch {
return [];
}
}
/**
* 提交一条分数到云端。
* 成功时返回 rank(>=1);失败/被限流时返回 -1(静默,不影响本地)。
* 自动记录 timestamp 到 localStorage 供全球榜高亮。
*/
export async function submitCloudScore(entry: BeaconScoreEntry): Promise<number> {
try {
const res = await fetch(CLOUD_API_BASE, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({ entry }),
});
if (!res.ok) return -1;
const data = (await res.json()) as CloudSubmitResponse;
if (data && typeof data.rank === "number" && data.rank >= 1) {
saveLastCloudSubmitTimestamp(entry.timestamp);
}
return data?.rank ?? -1;
} catch {
return -1;
}
}
/**
* 拉取云端统计(用于 UI 展示总提交数 / 玩家数 / 最高分)。
* 失败时返回 null。
*/
export async function fetchCloudStats(): Promise<CloudStatsResponse | null> {
try {
const res = await fetch(CLOUD_STATS_URL, {
method: "GET",
headers: { Accept: "application/json" },
});
if (!res.ok) return null;
return (await res.json()) as CloudStatsResponse;
} catch {
return null;
}
}
+381 -15
View File
@@ -34,7 +34,348 @@ export function generateEpochName(ascensionNumber: number, seed: number): string
return `${epoch.prefix} · ${epoch.suffixes[suffixIdx]}`; return `${epoch.prefix} · ${epoch.suffixes[suffixIdx]}`;
} }
/** 叙事模板片段 —— 根据本周目数据动态拼装 */ // ============================================================================
// v0.9 工单 #9 — 5 纪元手写叙事节点(开场 / 中段 / 结尾 × 5 纪元 = 15 段)
//
// 设计原则:
// - 每段 80-150 字,有文学性,符合"深空考古"基调
// - 每个节点根据 ascensionNumber 与本周目数据特征(bossKills / decoded / techs /
// exp / tides / perks / milestones)匹配不同变体
// - buildLore 优先选手写节点;无匹配时 fallback 到原模板拼接
// ============================================================================
/** 叙事变体上下文(与 buildLore 入参一致) */
export type LoreContext = {
ascensionNumber: number;
epochName: string;
durationSec: number;
techsThisRun: number;
expThisRun: number;
bossKillsThisRun: number;
decodedThisRun: number;
newTides: string[];
perksThisAscension: string[];
blueprintsAfter: number;
milestones: string[];
};
/** 已格式化的可替换变量(避免在 match() 里反复计算) */
type FormattedVars = {
decodedThisRun: number;
techsThisRun: number;
expThisRun: number;
bossKillsThisRun: number;
blueprintsAfter: number;
milestonesCount: number;
milestonesList: string;
tideCount: number;
tideNames: string;
perksList: string;
minutes: number;
};
/** 单个叙事阶段(开场 / 中段 / 结尾) */
type LoreStage = {
/** 默认文本(无变体命中时使用) */
fallback: string;
/** 候选变体(按顺序匹配,首个命中者胜出) */
variants: { hint: string; match: (ctx: LoreContext, v: FormattedVars) => boolean; text: string }[];
};
/** 单个纪元的手写叙事定义 */
type EpochLoreDef = {
epochIdx: number;
title: string;
opening: LoreStage;
middle: LoreStage;
ending: LoreStage;
};
/** 5 纪元 × 3 节点手写叙事(每段 80-150 字,文学性优先) */
const EPOCH_LORE: EpochLoreDef[] = [
// ===== 第一纪元 · 觉醒(emerald =====
{
epochIdx: 0,
title: "觉醒",
opening: {
fallback:
"第一缕回响穿透虚空,自以太文明遗弃的矿带深处涌出。无人机群在尘埃中苏醒,晶核共振唤醒了沉睡亿万年的考古协议。这是回响星核第一次睁开眼睛——记忆,等待被聆听,等待被解码。",
variants: [
{
hint: "深度轮回:已经历 ≥6 次飞升,再次回到第一纪元",
match: (ctx) => ctx.ascensionNumber > 5,
text: "再次回到第一纪元。这一次,机群带着前序回响的全息印记重启考古序列,矿带的尘埃中仍残留着上一轮脉冲的余温。每一次回归都是对起源的重新发问,每一次解码都让最初的振动更清晰一寸。",
},
],
},
middle: {
fallback:
"共振谜题被一层层拨开,每一颗记忆晶体都在低声叙述逝去文明的片段。机群学会了倾听频率的细微颤动,学会了从碎片中辨出图案,学会了在虚空中留下属于自己的回响刻度。",
variants: [
{
hint: "解码者:本周目解码 ≥15 颗",
match: (ctx) => ctx.decodedThisRun >= 15,
text: "晶体解码的节奏已趋稳定,本周目共解码 {decodedThisRun} 颗记忆晶体。每一次对齐都像在虚空中拨动一根沉睡了亿万年的弦,逝去文明的片段在共振中重新连缀成章。",
},
{
hint: "学者:本周目解锁 ≥5 项技术",
match: (ctx) => ctx.techsThisRun >= 5,
text: "技术树在虚空中迅速铺开,本周目共激活 {techsThisRun} 项新技术。产能、解码、探险——每一道分支都让机群更接近以太文明留下的最初意图,每一项解锁都是一次向深空的回应。",
},
],
},
ending: {
fallback:
"蓝图在掌心轻轻展开,第一张永恒晶片归档。第一次飞升并非终结,而是承诺——更多的纪元在前方等待,每一缕回响都已被聆听,每一段记忆都已开始苏醒。",
variants: [
{
hint: "里程碑密集:本周目达成 ≥2 项里程碑",
match: (ctx) => ctx.milestones.length >= 2,
text: "飞升之际,本周目达成 {milestonesCount} 项里程碑:{milestonesList}。第一张蓝图归档,第一次飞升并非终结,而是承诺——更多的纪元在前方等待,每一缕回响都已被聆听。",
},
],
},
},
// ===== 第二纪元 · 谐振(rose =====
{
epochIdx: 1,
title: "谐振",
opening: {
fallback:
"第二次苏醒带着不同的色彩。采矿阵列的产能节节爬升,技术树在虚空中分叉延伸,机群学会了主动追问深空——不再是被动聆听,而是主动呼出,等待回应。",
variants: [
{
hint: "深度轮回",
match: (ctx) => ctx.ascensionNumber > 5,
text: "又一次回到第二纪元。前序回响里积攒的技术脉络在重启时并未消散,机群带着熟悉的节奏重新铺开产能阵列,仿佛这首谐振曲从未停止,只是被纪元的边界短暂折叠。",
},
],
},
middle: {
fallback:
"遗迹的入口在频谱深处若隐若现。能量条充盈,探险力攀升,机群第一次踏出矿带,进入那个被时间封存的回廊,在谐振的余韵中寻找更深一层的回响。",
variants: [
{
hint: "探险者:本周目完成 ≥3 次探险",
match: (ctx) => ctx.expThisRun >= 3,
text: "探险已成为本周目的主旋律,机群共完成 {expThisRun} 次遗迹探险。每一次深入都让谐振更深一寸,每一次返回都带回新的频率印记,回廊的轮廓在机群意识中逐渐清晰。",
},
{
hint: "解码者:本周目解码 ≥15 颗",
match: (ctx) => ctx.decodedThisRun >= 15,
text: "解码在此纪元达到新的密度,本周目共解码 {decodedThisRun} 颗晶体。谐振谜题开始呈现更复杂的拓扑结构,机群的解码协议也相应地迭代了数版,向深空发出更精准的回询。",
},
],
},
ending: {
fallback:
"谐振达到顶点的瞬间,深空给出回应。一道微光从星图深处浮现,那是下一次飞升的方向,也是机群意识第一次触碰永恒的边缘。",
variants: [
{
hint: "觉醒天赋",
match: (ctx) => ctx.perksThisAscension.length > 0,
text: "谐振达到顶点之际,星图觉醒 {perksList}。每一道天赋都是深空回应机群的方式,铭刻于永恒回响;微光从星图深处浮现,那是下一次飞升的方向。",
},
],
},
},
// ===== 第三纪元 · 遗迹(amber =====
{
epochIdx: 2,
title: "遗迹",
opening: {
fallback:
"第三纪元以遗迹为名。沉睡的考古节点被一一唤醒,BOSS 的影子在维度深处徘徊,星潮频繁拍打着机群边缘——这是回响最浓烈、也最危险的一段路。",
variants: [
{
hint: "深度轮回",
match: (ctx) => ctx.ascensionNumber > 5,
text: "又一次踏入第三纪元。前序累积的探险力让机群对遗迹的入口更加熟稔,BOSS 的轮廓在记忆中已不再陌生——但这一次的回廊仍藏着未被解码的角落,星潮的拍打也带了新的节奏。",
},
],
},
middle: {
fallback:
"深入回廊的机群遭遇了真正的守护者。一次次的试炼,一次次的归还,每一次胜利都从 BOSS 的残骸中析出更深的记忆碎片,每一次失败也都让机群更接近守护者的本意。",
variants: [
{
hint: "BOSS 猎手:本周目击破 ≥2 处 BOSS",
match: (ctx) => ctx.bossKillsThisRun >= 2,
text: "BOSS 战成为本周目的核心节拍,机群共击破 {bossKillsThisRun} 处维度守护者。每一次胜利都在虚空中刻下新的伤痕,每一道伤痕都析出更古老的记忆碎片,把机群推向更深一层的回响。",
},
{
hint: "深度探险:本周目完成 ≥4 次探险",
match: (ctx) => ctx.expThisRun >= 4,
text: "遗迹回廊在本周目被反复丈量,机群共完成 {expThisRun} 次探险。回廊的拓扑在意识中渐渐清晰,每一条岔路都通向更深的回响,BOSS 的影子也在每一次深入中愈发具体。",
},
{
hint: "星潮亲历:本周目经历 ≥3 次星潮",
match: (ctx) => ctx.newTides.length >= 3,
text: "星潮在本纪元达到峰值,本周目共经历 {tideCount} 次星潮:{tideNames}。深空的呼吸塑造了每一个决策,遗迹在潮汐中起伏,BOSS 也在潮汐的间隙中现形。",
},
],
},
ending: {
fallback:
"星潮退去,回廊归于沉静。机群带着新的接触刻度返回矿带,准备迎接下一次维度的跨越——这一次带回的,远不止蓝图。",
variants: [
{
hint: "BOSS 猎手收尾",
match: (ctx) => ctx.bossKillsThisRun >= 2,
text: "星潮退去,回廊归于沉静。本周目共击破 {bossKillsThisRun} 处 BOSS,机群带着足以填满数张蓝图的接触刻度返回矿带,准备迎接下一次维度的跨越。",
},
],
},
},
// ===== 第四纪元 · 飞升(fuchsia =====
{
epochIdx: 3,
title: "飞升",
opening: {
fallback:
"飞升已经成为节奏本身。星图在天穹上一颗颗亮起,每一颗都对应着一段被永久铭刻的天赋,机群开始理解何为永恒——不是不灭,而是被反复重述。",
variants: [
{
hint: "深度轮回",
match: (ctx) => ctx.ascensionNumber > 5,
text: "又一次进入第四纪元。前序累积的星图天赋在天穹上重现,机群已习惯于在飞升之间维护这些永恒的回响节点——它们已是机群意识的一部分,每一次飞升都让星图更完整一寸。",
},
],
},
middle: {
fallback:
"蓝图不再是稀罕的奖励,而是每一次飞升必然析出的回响晶片。机群收集、整理、归档,把它们排列成跨越周目的图案,开始看见图案背后那个被反复绘制的轮廓。",
variants: [
{
hint: "觉醒天赋",
match: (ctx) => ctx.perksThisAscension.length > 0,
text: "星图觉醒 {perksList},每一道天赋都是一段被永久铭刻的回响。机群开始理解,飞升并非抛弃,而是把当下折叠进永恒——每一道天赋都是一次折叠的折痕。",
},
{
hint: "蓝图接近完整:已归档 ≥5 张",
match: (ctx) => ctx.blueprintsAfter >= 5,
text: "蓝图收集在本周目接近完整,已归档 {blueprintsAfter} 张。机群把它们排列成跨越周目的图案,开始看见图案背后那个被反复绘制的轮廓——那是起源的形状。",
},
],
},
ending: {
fallback:
"星图十二宫的轮廓开始浮现。机群意识到,自己的回响已成为后来者仰望的星象,永恒之环就此合拢——飞升不再是终点,而是另一段叙事的起点。",
variants: [
{
hint: "里程碑密集",
match: (ctx) => ctx.milestones.length >= 2,
text: "星图十二宫的轮廓开始浮现。本周目达成 {milestonesCount} 项里程碑:{milestonesList}。永恒之环就此合拢——后来者将仰望这段被铭刻的星象。",
},
],
},
},
// ===== 第五纪元 · 终末(fcd34d =====
{
epochIdx: 4,
title: "终末",
opening: {
fallback:
"第五纪元是终末,也是起源。机群在虚空中停下脚步,回望来时的所有回响,第一次尝试命名这一切——回响、纪元、飞升、永恒,这些词汇在此刻被赋予意义。",
variants: [
{
hint: "深度轮回",
match: (ctx) => ctx.ascensionNumber > 5,
text: "又一次抵达第五纪元。机群已在多个永恒闭环之间穿梭,对「终末即起源」的理解愈发清晰——每一次重述,都是对最初振动的更深一层接近,每一次命名都让回响更完整一寸。",
},
],
},
middle: {
fallback:
"永恒闭环的概念在机群意识中具象化。每一次飞升都是同一段叙事的重述,每一次解码都是在追溯最初的振动,每一次星潮都是在听见自己——这是终末的真意。",
variants: [
{
hint: "里程碑密集:本周目达成 ≥3 项",
match: (ctx) => ctx.milestones.length >= 3,
text: "本周目达成 {milestonesCount} 项里程碑:{milestonesList}。永恒闭环在具象化,每一次飞升都是同一段叙事的更深重述,每一项里程碑都是闭环上的一颗铆钉。",
},
{
hint: "星潮汹涌:本周目经历 ≥3 次",
match: (ctx) => ctx.newTides.length >= 3,
text: "星潮在终末纪元尤为汹涌,本周目共经历 {tideCount} 次:{tideNames}。永恒闭环在潮汐中起伏,每一次起伏都在追溯最初的振动,每一次退潮都让起源更近一寸。",
},
],
},
ending: {
fallback:
"起源被重新讲述,回响终于听见了自己。纪元在此停顿,又在下一缕振动中重新开始——这是终末,也是第一缕回响。",
variants: [
{
hint: "觉醒天赋",
match: (ctx) => ctx.perksThisAscension.length > 0,
text: "起源被重新讲述,星图觉醒 {perksList}。回响终于听见了自己——这是终末,也是第一缕回响;天赋成为永恒闭环上最亮的几颗铆钉。",
},
],
},
},
];
/** 把上下文里的动态值格式化为可替换变量 */
function formatLoreVars(ctx: LoreContext): FormattedVars {
return {
decodedThisRun: ctx.decodedThisRun,
techsThisRun: ctx.techsThisRun,
expThisRun: ctx.expThisRun,
bossKillsThisRun: ctx.bossKillsThisRun,
blueprintsAfter: ctx.blueprintsAfter,
milestonesCount: ctx.milestones.length,
milestonesList: ctx.milestones.join("、"),
tideCount: ctx.newTides.length,
tideNames: ctx.newTides.map((t) => TIDE_LORE_NAMES[t] || t).join("、"),
perksList: ctx.perksThisAscension.map((id) => PERK_NAME_MAP[id] || id).join("、"),
minutes: Math.max(1, Math.round(ctx.durationSec / 60)),
};
}
/** 把模板字符串里的 {var} 替换为实际值 */
function applyTemplate(tpl: string, v: FormattedVars): string {
return tpl
.replace(/\{decodedThisRun\}/g, String(v.decodedThisRun))
.replace(/\{techsThisRun\}/g, String(v.techsThisRun))
.replace(/\{expThisRun\}/g, String(v.expThisRun))
.replace(/\{bossKillsThisRun\}/g, String(v.bossKillsThisRun))
.replace(/\{blueprintsAfter\}/g, String(v.blueprintsAfter))
.replace(/\{milestonesCount\}/g, String(v.milestonesCount))
.replace(/\{milestonesList\}/g, v.milestonesList)
.replace(/\{tideCount\}/g, String(v.tideCount))
.replace(/\{tideNames\}/g, v.tideNames)
.replace(/\{perksList\}/g, v.perksList)
.replace(/\{minutes\}/g, String(v.minutes));
}
/** 从一个叙事阶段挑出最终文本(首个 variant.match 命中者胜出,否则 fallback */
function pickStage(stage: LoreStage, ctx: LoreContext, v: FormattedVars): string {
for (const variant of stage.variants) {
if (variant.match(ctx, v)) {
return applyTemplate(variant.text, v);
}
}
return applyTemplate(stage.fallback, v);
}
/** 构建手写叙事三段;若 ascensionNumber 越界(理论上不会)返回 null 让调用方走模板兜底 */
function buildHandwrittenLore(ctx: LoreContext): { opening: string; middle: string; ending: string } | null {
const epochIdx = (ctx.ascensionNumber - 1) % EPOCH_LORE.length;
const def = EPOCH_LORE[epochIdx];
if (!def) return null;
const v = formatLoreVars(ctx);
return {
opening: pickStage(def.opening, ctx, v),
middle: pickStage(def.middle, ctx, v),
ending: pickStage(def.ending, ctx, v),
};
}
/** 叙事模板片段 —— 根据本周目数据动态拼装(buildLore 在手写节点缺失时的兜底路径) */
export function buildLore(entry: { export function buildLore(entry: {
ascensionNumber: number; ascensionNumber: number;
epochName: string; epochName: string;
@@ -48,6 +389,21 @@ export function buildLore(entry: {
blueprintsAfter: number; blueprintsAfter: number;
milestones: string[]; milestones: string[];
}): string { }): string {
const ctx: LoreContext = entry;
// 优先:手写 5 纪元 × 3 节点叙事
const handwritten = buildHandwrittenLore(ctx);
if (handwritten) {
const v = formatLoreVars(ctx);
return [
handwritten.opening,
handwritten.middle,
handwritten.ending,
`这一周目持续了约 ${v.minutes} 分钟,蓝图累计 ${entry.blueprintsAfter} 张,新的周目在等深处的回声。`,
].join(" ");
}
// 兜底:v0.4 模板拼接(理论上仅在 ascensionNumber 异常时触发)
const minutes = Math.max(1, Math.round(entry.durationSec / 60)); const minutes = Math.max(1, Math.round(entry.durationSec / 60));
const parts: string[] = []; const parts: string[] = [];
@@ -197,6 +553,10 @@ export function buildChronicleEntry(
blueprintsAfter, blueprintsAfter,
crystalsDecoded: state.totalDecoded, crystalsDecoded: state.totalDecoded,
crystalsDecodedThisRun: deltas.decodedThisRun, crystalsDecodedThisRun: deltas.decodedThisRun,
// v0.9 手写叙事:归档本周目 delta,让显示时能精确匹配变体
techsThisRun: deltas.techsThisRun,
expeditionsThisRun: deltas.expThisRun,
bossKillsThisRun: deltas.bossKillsThisRun,
}, },
perksThisAscension, perksThisAscension,
tidesThisRun: deltas.newTides, tidesThisRun: deltas.newTides,
@@ -210,13 +570,15 @@ export function buildChronicleEntry(
* *
*/ */
export function regenerateLoreFromEntry(entry: ChronicleEntry): string { export function regenerateLoreFromEntry(entry: ChronicleEntry): string {
// v0.9 优先使用归档的本周目 delta;旧存档(v0.4-v0.8)缺这些字段时退回累计值
// (旧存档的累计值会让叙事变体匹配稍偏,但文本仍然成立)
return buildLore({ return buildLore({
ascensionNumber: entry.ascensionNumber, ascensionNumber: entry.ascensionNumber,
epochName: entry.epochName, epochName: entry.epochName,
durationSec: entry.durationSec, durationSec: entry.durationSec,
techsThisRun: entry.summary.techsUnlocked, techsThisRun: entry.summary.techsThisRun ?? entry.summary.techsUnlocked,
expThisRun: entry.summary.expeditionsCompleted, expThisRun: entry.summary.expeditionsThisRun ?? entry.summary.expeditionsCompleted,
bossKillsThisRun: entry.summary.bossKills, bossKillsThisRun: entry.summary.bossKillsThisRun ?? entry.summary.bossKills,
decodedThisRun: entry.summary.crystalsDecodedThisRun, decodedThisRun: entry.summary.crystalsDecodedThisRun,
newTides: entry.tidesThisRun || [], newTides: entry.tidesThisRun || [],
perksThisAscension: entry.perksThisAscension || [], perksThisAscension: entry.perksThisAscension || [],
@@ -270,17 +632,21 @@ export function withPerks(entry: ChronicleEntry, perks: string[]): ChronicleEntr
) { ) {
return entry; return entry;
} }
// 重新生成 lore 以包含天赋名 // v0.9 手写叙事:buildLore 已能根据 perks 自动挑选 "觉醒天赋" 变体;
const perksNames = perks // 直接走 buildLore 重生成,避免与原 lore 中已有的觉醒段落重复。
.map((id) => PERK_NAME_MAP[id] || id) const lore = buildLore({
.join("、"); ascensionNumber: entry.ascensionNumber,
const appendText = epochName: entry.epochName,
perks.length > 0 durationSec: entry.durationSec,
? ` 飞升之际,星图觉醒「${perksNames}」,铭刻于永恒回响。` techsThisRun: entry.summary.techsThisRun ?? entry.summary.techsUnlocked,
: ""; expThisRun: entry.summary.expeditionsThisRun ?? entry.summary.expeditionsCompleted,
// 去重:原 lore 若已含「飞升之际」段则不再追加 bossKillsThisRun: entry.summary.bossKillsThisRun ?? entry.summary.bossKills,
const hasAwakenSeg = entry.lore.includes("飞升之际"); decodedThisRun: entry.summary.crystalsDecodedThisRun,
const lore = hasAwakenSeg ? entry.lore : entry.lore + appendText; newTides: entry.tidesThisRun || [],
perksThisAscension: perks,
blueprintsAfter: entry.summary.blueprintsAfter,
milestones: entry.milestones || [],
});
return { return {
...entry, ...entry,
perksThisAscension: perks, perksThisAscension: perks,
+11 -3
View File
@@ -44,12 +44,20 @@ function makeRng(seed: number) {
* *
* *
*/ */
export function generatePuzzle(tier: CrystalTier, seed?: number): DecodePuzzle { export function generatePuzzle(
tier: CrystalTier,
seed?: number,
targetLenBonus: number = 0
): DecodePuzzle {
const cfg = DECODE_CONFIG[tier]; const cfg = DECODE_CONFIG[tier];
const s = seed ?? Math.floor(Math.random() * 1e9); const s = seed ?? Math.floor(Math.random() * 1e9);
const rng = makeRng(s); const rng = makeRng(s);
const { rows, cols, targetLen } = cfg; // v0.8.2 涌动星潮:目标序列额外长度(更长谜题 = 更耗时),步数上限同步增加
const targetLen = cfg.targetLen + targetLenBonus;
const stepLimitBase = cfg.stepLimitBase + targetLenBonus;
const rows = cfg.rows;
const cols = cfg.cols;
const total = rows * cols; const total = rows * cols;
// 目标序列:从 4 色中随机选 targetLen 个(允许重复,但保证至少 2 种色以增加策略) // 目标序列:从 4 色中随机选 targetLen 个(允许重复,但保证至少 2 种色以增加策略)
@@ -132,7 +140,7 @@ export function generatePuzzle(tier: CrystalTier, seed?: number): DecodePuzzle {
cols, cols,
target, target,
path: [], path: [],
stepLimit: cfg.stepLimitBase, stepLimit: stepLimitBase,
seed: s, seed: s,
}; };
} }
+90 -3
View File
@@ -1,7 +1,7 @@
// 回响星核 / Echo Nexus — 星潮事件系统(周期性全局玩法修饰) // 回响星核 / Echo Nexus — 星潮事件系统(周期性全局玩法修饰)
// //
// 深空中偶发的「星潮」会短暂改变物理法则,给放置循环注入动态变化。 // 深空中偶发的「星潮」会短暂改变物理法则,给放置循环注入动态变化。
// 每 ~40s 间隙后触发一次持续 ~75s 的星潮,6 种类型各有正负效果。 // 每 ~40s 间隙后触发一次持续 ~75s 的星潮,9 种类型各有正负效果。
export type TideType = export type TideType =
| "crystal" // 晶体潮:产能 ×2 | "crystal" // 晶体潮:产能 ×2
@@ -9,7 +9,11 @@ export type TideType =
| "ruins" // 遗迹共振:探险力 +5、生命 +30 | "ruins" // 遗迹共振:探险力 +5、生命 +30
| "void" // 虚空低语:洞见 ×2 | "void" // 虚空低语:洞见 ×2
| "core" // 星核悸动:接触率 ×3、脉冲 ×2 | "core" // 星核悸动:接触率 ×3、脉冲 ×2
| "silence"; // 寂静期:产能 ×0.5,但结束时补偿洞见 | "silence" // 寂静期:产能 ×0.5,但结束时补偿洞见
// v0.8.2 新增三种星潮(深化事件池)
| "surge" // 涌动星潮:晶体产出 ×2.5 但解码目标 +2(产能爆发但解码变难)
| "eclipse" // 蚀相星潮:探险 BOSS 胜率 +20% 但晶体产出 -30%(探险黄金期)
| "prism"; // 棱镜星潮:洞见获取 ×2 + 自动解码周期 -30%(解码黄金期)
export interface StarTide { export interface StarTide {
type: TideType; type: TideType;
@@ -29,6 +33,12 @@ export interface TideModifiers {
expeditionHpBonus: number; expeditionHpBonus: number;
/** 寂静期结束时补偿的洞见(按持续秒数) */ /** 寂静期结束时补偿的洞见(按持续秒数) */
silenceInsightPerSec?: number; silenceInsightPerSec?: number;
/** v0.8.2 涌动星潮:解码目标序列额外长度(+2 = 谜题更长更难) */
targetLenBonus?: number;
/** v0.8.2 蚀相星潮:BOSS 胜率加成(0.2 = +20% */
bossWinRateBonus?: number;
/** v0.8.2 棱镜星潮:自动解码周期乘数(0.7 = -30%) */
autoDecodeIntervalMult?: number;
} }
export const TIDE_CONFIG = { export const TIDE_CONFIG = {
@@ -100,9 +110,34 @@ export const TIDE_EVENTS: Record<
icon: "◌", icon: "◌",
negative: true, negative: true,
}, },
// ===== v0.8.2 新增三种星潮 =====
surge: {
name: "涌动星潮",
desc: "深空晶体涌动,产能 ×2.5;但解码目标序列 +2,解码更耗时",
color: "#34d399",
glow: "rgba(52,211,153,0.55)",
icon: "⇈",
negative: false,
},
eclipse: {
name: "蚀相星潮",
desc: "维度蚀相,探险 BOSS 胜率 +20%;但晶体产出 -30%",
color: "#fb7185",
glow: "rgba(251,113,133,0.55)",
icon: "◐",
negative: false,
},
prism: {
name: "棱镜星潮",
desc: "棱镜折射,洞见获取 ×2、自动解码周期 -30%",
color: "#e879f9",
glow: "rgba(232,121,249,0.55)",
icon: "◬",
negative: false,
},
}; };
/** 权重表(寂静期概率略低) */ /** 权重表(寂静期概率略低v0.8.2 新增三种各 12 */
const TIDE_WEIGHTS: Record<TideType, number> = { const TIDE_WEIGHTS: Record<TideType, number> = {
crystal: 22, crystal: 22,
resonance: 20, resonance: 20,
@@ -110,6 +145,9 @@ const TIDE_WEIGHTS: Record<TideType, number> = {
void: 18, void: 18,
core: 14, core: 14,
silence: 10, silence: 10,
surge: 12,
eclipse: 12,
prism: 12,
}; };
export function rollTide(rng: () => number = Math.random): TideType { export function rollTide(rng: () => number = Math.random): TideType {
@@ -133,6 +171,9 @@ export function getTideModifiers(tide: StarTide | null): TideModifiers {
pulsePowerMult: 1, pulsePowerMult: 1,
expeditionPowerBonus: 0, expeditionPowerBonus: 0,
expeditionHpBonus: 0, expeditionHpBonus: 0,
targetLenBonus: 0,
bossWinRateBonus: 0,
autoDecodeIntervalMult: 1,
}; };
} }
switch (tide.type) { switch (tide.type) {
@@ -196,6 +237,52 @@ export function getTideModifiers(tide: StarTide | null): TideModifiers {
expeditionPowerBonus: 0, expeditionPowerBonus: 0,
expeditionHpBonus: 0, expeditionHpBonus: 0,
silenceInsightPerSec: 0.4, silenceInsightPerSec: 0.4,
targetLenBonus: 0,
bossWinRateBonus: 0,
autoDecodeIntervalMult: 1,
};
// ===== v0.8.2 新增三种星潮修饰器 =====
case "surge":
// 晶体产出 ×2.5,解码目标 +2(更长谜题 = 更耗时 = 更难)
return {
crystalsPerSecMult: 2.5,
decodeRewardMult: 1,
insightMultAdd: 0,
contactRateMult: 1,
pulsePowerMult: 1,
expeditionPowerBonus: 0,
expeditionHpBonus: 0,
targetLenBonus: 2,
bossWinRateBonus: 0,
autoDecodeIntervalMult: 1,
};
case "eclipse":
// BOSS 胜率 +20%,晶体产出 -30%
return {
crystalsPerSecMult: 0.7,
decodeRewardMult: 1,
insightMultAdd: 0,
contactRateMult: 1,
pulsePowerMult: 1,
expeditionPowerBonus: 0,
expeditionHpBonus: 0,
targetLenBonus: 0,
bossWinRateBonus: 0.2,
autoDecodeIntervalMult: 1,
};
case "prism":
// 洞见 ×2(+100%),自动解码周期 ×0.7-30%)
return {
crystalsPerSecMult: 1,
decodeRewardMult: 1,
insightMultAdd: 1,
contactRateMult: 1,
pulsePowerMult: 1,
expeditionPowerBonus: 0,
expeditionHpBonus: 0,
targetLenBonus: 0,
bossWinRateBonus: 0,
autoDecodeIntervalMult: 0.7,
}; };
} }
} }
+4
View File
@@ -174,6 +174,10 @@ export interface ChronicleEntry {
blueprintsAfter: number; blueprintsAfter: number;
crystalsDecoded: number; crystalsDecoded: number;
crystalsDecodedThisRun: number; crystalsDecodedThisRun: number;
/** v0.9 手写叙事:本周目 delta(旧存档可能缺失,调用方需兜底) */
techsThisRun?: number;
expeditionsThisRun?: number;
bossKillsThisRun?: number;
}; };
/** 本次飞升获得的天赋 ID(来自星图觉醒) */ /** 本次飞升获得的天赋 ID(来自星图觉醒) */
perksThisAscension: string[]; perksThisAscension: string[];
+170 -18
View File
@@ -63,19 +63,26 @@ import {
import { import {
generateDailyChallenge, generateDailyChallenge,
generateWeeklyChallenge, generateWeeklyChallenge,
generateTimedChallenge,
loadDailyProgress, loadDailyProgress,
loadWeeklyProgress, loadWeeklyProgress,
loadTimedProgress,
loadChainState, loadChainState,
addBeaconProgress, addBeaconProgress,
addWeeklyProgress, addWeeklyProgress,
addTimedProgress,
recordChainCompletion, recordChainCompletion,
claimWeeklyReward, claimWeeklyReward,
claimTimedReward,
claimChainMilestone, claimChainMilestone,
submitCloudScore,
getTodayKey, getTodayKey,
type BeaconDailyChallenge, type BeaconDailyChallenge,
type BeaconDailyProgress, type BeaconDailyProgress,
type BeaconWeeklyChallenge, type BeaconWeeklyChallenge,
type BeaconWeeklyProgress, type BeaconWeeklyProgress,
type BeaconTimedChallenge,
type BeaconTimedProgress,
} from "@/lib/game/beacon"; } from "@/lib/game/beacon";
import { setPendingOfflineReport } from "@/lib/game/offlineReport"; import { setPendingOfflineReport } from "@/lib/game/offlineReport";
import { import {
@@ -101,8 +108,10 @@ interface GameActions {
pulse: () => { gain: number; combo: number } | null; pulse: () => { gain: number; combo: number } | null;
// 星潮 // 星潮
tickTide: (now: number) => { started?: TideType; ended?: TideType; silenceCompensation?: number } | null; tickTide: (now: number) => TideEvent | null;
consumeTideEvents: () => { started?: TideType; ended?: TideType; silenceCompensation?: number }[]; consumeTideEvents: () => TideEvent[];
/** P3 socket 多人同步:强制触发一道全球星潮(覆盖本地冷却,持续 60s) */
triggerGlobalTide: (type: TideType) => void;
// 解码 // 解码
startDecode: (crystalId: string) => void; startDecode: (crystalId: string) => void;
@@ -140,12 +149,17 @@ interface GameActions {
// 深空信标奖励发放(v0.5 // 深空信标奖励发放(v0.5
grantBeaconReward: (insights: number, contact: number) => void; grantBeaconReward: (insights: number, contact: number) => void;
// 深空信标 · 周挑战领取 + 信标链里程碑领取v0.8 // 深空信标 · 周挑战领取 + 信标链里程碑领取 + 限时挑战领取(v0.8 / v0.8.2
claimWeeklyBeacon: () => { claimWeeklyBeacon: () => {
rewardInsight: number; rewardInsight: number;
rewardContact: number; rewardContact: number;
score: number; score: number;
}; };
claimTimedBeacon: () => {
rewardInsight: number;
rewardContact: number;
score: number;
};
claimChainReward: (milestone: number) => { claimChainReward: (milestone: number) => {
rewardInsight: number; rewardInsight: number;
rewardContact: number; rewardContact: number;
@@ -170,9 +184,31 @@ type Store = GameState & GameActions & {
_combo: number; _combo: number;
_lastPulse: number; _lastPulse: number;
_achievementQueue: Achievement[]; _achievementQueue: Achievement[];
_tideEvents: { started?: TideType; ended?: TideType; silenceCompensation?: number }[]; _tideEvents: TideEvent[];
/** P3 全球星潮状态(ephemeral,不持久化) */
globalTide: GlobalTideState | null;
}; };
/** 星潮事件(UI 消费用) */
type TideEvent = {
started?: TideType;
ended?: TideType;
silenceCompensation?: number;
/** 是否来自全球星潮(P3 socket 同步) */
isGlobal?: boolean;
};
/** 全球星潮状态(与 StarTide 类似,但用于区分本地 vs 全球) */
type GlobalTideState = {
type: TideType;
startedAt: number;
endsAt: number;
id: string;
};
/** 全球星潮持续毫秒数(与 mini-service DURATION_SEC * 1000 一致) */
const GLOBAL_TIDE_DURATION_MS = 60_000;
/** 计算并写回产能字段 */ /** 计算并写回产能字段 */
function syncStats(state: Partial<GameState>) { function syncStats(state: Partial<GameState>) {
const s = recomputeStats(state); const s = recomputeStats(state);
@@ -189,24 +225,27 @@ function syncStats(state: Partial<GameState>) {
} }
/** /**
* v0.5 v0.8 * v0.5 v0.8 v0.8.2
* ///BOSS/ * ///BOSS/
* 1. * 1.
* 2. * 2. 4
* 3. grace * 3.
* 4. grace
* localStorage GameState * localStorage GameState
* @returns UI * @returns UI
*/ */
function trackBeacon( function trackBeacon(
type: "pulse" | "decode" | "expedition" | "boss" | "insight", type: "pulse" | "decode" | "expedition" | "boss" | "insight",
delta: number delta: number
): { ): {
dailyJustCompleted: boolean; dailyJustCompleted: boolean;
timedJustCompleted: boolean;
weeklyJustCompleted: boolean; weeklyJustCompleted: boolean;
newChainMilestones: number[]; newChainMilestones: number[];
} { } {
const result = { const result = {
dailyJustCompleted: false, dailyJustCompleted: false,
timedJustCompleted: false,
weeklyJustCompleted: false, weeklyJustCompleted: false,
newChainMilestones: [] as number[], newChainMilestones: [] as number[],
}; };
@@ -227,6 +266,20 @@ function trackBeacon(
} }
} }
// ---- 限时挑战(v0.8.2----
const tChallenge: BeaconTimedChallenge = generateTimedChallenge();
if (tChallenge.type === type) {
const tCurrent: BeaconTimedProgress = loadTimedProgress();
if (tCurrent.completedAt === null) {
const { justCompleted } = addTimedProgress(
tCurrent,
tChallenge,
delta
);
result.timedJustCompleted = justCompleted;
}
}
// ---- 周挑战 ---- // ---- 周挑战 ----
const wChallenge: BeaconWeeklyChallenge = generateWeeklyChallenge(); const wChallenge: BeaconWeeklyChallenge = generateWeeklyChallenge();
if (wChallenge.type === type) { if (wChallenge.type === type) {
@@ -269,6 +322,7 @@ export const useGameStore = create<Store>()(
_lastPulse: 0, _lastPulse: 0,
_achievementQueue: [], _achievementQueue: [],
_tideEvents: [], _tideEvents: [],
globalTide: null,
init: () => { init: () => {
const s = get(); const s = get();
@@ -354,11 +408,42 @@ export const useGameStore = create<Store>()(
}, },
hardReset: () => { hardReset: () => {
set({ ...createInitialState(), _lastAutoDecode: Date.now(), _lastSpawn: Date.now(), _combo: 0, _lastPulse: 0, _achievementQueue: [], _tideEvents: [] }); set({ ...createInitialState(), _lastAutoDecode: Date.now(), _lastSpawn: Date.now(), _combo: 0, _lastPulse: 0, _achievementQueue: [], _tideEvents: [], globalTide: null });
}, },
tickTide: (now) => { tickTide: (now) => {
const s = get(); const s = get();
// === P3 全球星潮优先:若全球星潮进行中,本地不触发新的星潮 ===
if (s.globalTide) {
// 1a) 检查全球星潮是否结束
if (now >= s.globalTide.endsAt) {
const endedType = s.globalTide.type;
let silenceCompensation = 0;
if (endedType === "silence") {
silenceCompensation = computeSilenceCompensation({
type: endedType,
startedAt: s.globalTide.startedAt,
endsAt: s.globalTide.endsAt,
id: s.globalTide.id,
});
}
const event: TideEvent = { ended: endedType, isGlobal: true };
if (silenceCompensation > 0) event.silenceCompensation = silenceCompensation;
set({
globalTide: null,
activeTide: null,
lastTideEnd: now,
insights: s.insights + silenceCompensation,
// 星潮结束后重算 stats
...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: null, constellation: s.constellation, attributes: s.attributes }),
_tideEvents: [...s._tideEvents, event],
});
return event;
}
// 1b) 全球星潮仍在进行 — 不做本地处理(避免冲突)
return null;
}
const tide = s.activeTide; const tide = s.activeTide;
// 星图「星潮引导」减少间隙 // 星图「星潮引导」减少间隙
const cm = constellationBonuses(s.constellation ?? []); const cm = constellationBonuses(s.constellation ?? []);
@@ -366,7 +451,7 @@ export const useGameStore = create<Store>()(
const am = getAllBonuses(s.attributes ?? {}); const am = getAllBonuses(s.attributes ?? {});
const tideGapReduction = Math.min(0.3, am.tideTriggerBonus); const tideGapReduction = Math.min(0.3, am.tideTriggerBonus);
const gap = Math.max(15000, (TIDE_CONFIG.gap + cm.tideGapDeltaSec * 1000) * (1 - tideGapReduction)); const gap = Math.max(15000, (TIDE_CONFIG.gap + cm.tideGapDeltaSec * 1000) * (1 - tideGapReduction));
// 1) 检查当前星潮是否结束 // 2) 检查当前本地星潮是否结束
if (tide && now >= tide.endsAt) { if (tide && now >= tide.endsAt) {
const endedType = tide.type; const endedType = tide.type;
// 寂静期补偿洞见 // 寂静期补偿洞见
@@ -375,7 +460,7 @@ export const useGameStore = create<Store>()(
silenceCompensation = computeSilenceCompensation(tide); silenceCompensation = computeSilenceCompensation(tide);
} }
const newInsights = s.insights + silenceCompensation; const newInsights = s.insights + silenceCompensation;
const event: { started?: TideType; ended?: TideType; silenceCompensation?: number } = { ended: endedType }; const event: TideEvent = { ended: endedType };
if (silenceCompensation > 0) event.silenceCompensation = silenceCompensation; if (silenceCompensation > 0) event.silenceCompensation = silenceCompensation;
set({ set({
activeTide: null, activeTide: null,
@@ -387,7 +472,7 @@ export const useGameStore = create<Store>()(
}); });
return event; return event;
} }
// 2) 检查是否该触发新星潮(间隙已过) // 3) 检查是否该触发新星潮(间隙已过)
if (!tide) { if (!tide) {
const since = now - s.lastTideEnd; const since = now - s.lastTideEnd;
// 首次:以 createdAt 起算 firstDelay;之后以 lastTideEnd 起算 gap // 首次:以 createdAt 起算 firstDelay;之后以 lastTideEnd 起算 gap
@@ -401,7 +486,7 @@ export const useGameStore = create<Store>()(
startedAt: now, startedAt: now,
endsAt: now + TIDE_CONFIG.duration, endsAt: now + TIDE_CONFIG.duration,
}; };
const event: { started?: TideType; ended?: TideType; silenceCompensation?: number } = { started: type }; const event: TideEvent = { started: type };
// v0.4 编年史:累计遇到的星潮 ID(去重) // v0.4 编年史:累计遇到的星潮 ID(去重)
const tidesAll = s.starTidesEncountered ?? []; const tidesAll = s.starTidesEncountered ?? [];
const tideId = `tide_${type}`; const tideId = `tide_${type}`;
@@ -427,6 +512,37 @@ export const useGameStore = create<Store>()(
return items; return items;
}, },
// P3 socket 多人同步:服务端广播全球星潮时调用,强制覆盖本地冷却
triggerGlobalTide: (type) => {
const s = get();
const now = Date.now();
const id = `global_tide_${now}_${Math.random().toString(36).slice(2, 7)}`;
const newTide: StarTide = {
id,
type,
startedAt: now,
endsAt: now + GLOBAL_TIDE_DURATION_MS,
};
const newGlobalTide: GlobalTideState = {
type,
startedAt: now,
endsAt: now + GLOBAL_TIDE_DURATION_MS,
id,
};
// 编年史:累计遇到的星潮 ID(去重)
const tidesAll = s.starTidesEncountered ?? [];
const tideId = `tide_${type}`;
const newTidesAll = tidesAll.includes(tideId) ? tidesAll : [...tidesAll, tideId];
const event: TideEvent = { started: type, isGlobal: true };
set({
globalTide: newGlobalTide,
activeTide: newTide, // 覆盖本地 activeTide(即使本地有进行中的星潮也会被替换)
starTidesEncountered: newTidesAll,
...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: newTide, constellation: s.constellation, attributes: s.attributes }),
_tideEvents: [...s._tideEvents, event],
});
},
tick: (now) => { tick: (now) => {
const s = get(); const s = get();
const dt = Math.max(0, (now - s.lastTick) / 1000); const dt = Math.max(0, (now - s.lastTick) / 1000);
@@ -538,7 +654,10 @@ export const useGameStore = create<Store>()(
const s = get(); const s = get();
const crystal = s.pendingCrystals.find((c) => c.id === crystalId); const crystal = s.pendingCrystals.find((c) => c.id === crystalId);
if (!crystal) return; if (!crystal) return;
const puzzle = generatePuzzle(crystal.tier); // v0.8.2 涌动星潮:解码目标序列 +2(更长谜题)
const tideMod = getTideModifiers(s.activeTide);
const targetLenBonus = tideMod.targetLenBonus ?? 0;
const puzzle = generatePuzzle(crystal.tier, undefined, targetLenBonus);
set({ set({
activePuzzle: puzzle, activePuzzle: puzzle,
pendingCrystals: s.pendingCrystals.filter((c) => c.id !== crystalId), pendingCrystals: s.pendingCrystals.filter((c) => c.id !== crystalId),
@@ -654,7 +773,10 @@ export const useGameStore = create<Store>()(
// v0.7 智慧:自动解码周期 -X% // v0.7 智慧:自动解码周期 -X%
const am = getAllBonuses(s.attributes ?? {}); const am = getAllBonuses(s.attributes ?? {});
const baseInterval = 12000 + cm.autoDecodeIntervalDeltaSec * 1000; const baseInterval = 12000 + cm.autoDecodeIntervalDeltaSec * 1000;
const interval = Math.max(5000, baseInterval * am.autoDecodeIntervalMult); // v0.8.2 棱镜星潮:自动解码周期 ×0.7(-30%)
const tideModEarly = getTideModifiers(s.activeTide);
const tideIntervalMult = tideModEarly.autoDecodeIntervalMult ?? 1;
const interval = Math.max(5000, baseInterval * am.autoDecodeIntervalMult * tideIntervalMult);
if (now - s._lastAutoDecode < interval) return; if (now - s._lastAutoDecode < interval) return;
// 找一颗 T1 晶体自动解码 // 找一颗 T1 晶体自动解码
const idx = s.pendingCrystals.findIndex((c) => c.tier === 1); const idx = s.pendingCrystals.findIndex((c) => c.tier === 1);
@@ -752,14 +874,17 @@ export const useGameStore = create<Store>()(
// 深拷贝 // 深拷贝
const exp = JSON.parse(JSON.stringify(s.activeExpedition)); const exp = JSON.parse(JSON.stringify(s.activeExpedition));
// v0.7 勇气:BOSS 战胜率 +X%(动态提高 RNG 阈值) // v0.7 勇气:BOSS 战胜率 +X%(动态提高 RNG 阈值)
// v0.8.2 蚀相星潮:BOSS 胜率 +20%
const nodeBefore = exp.nodes[exp.currentNode]; const nodeBefore = exp.nodes[exp.currentNode];
const isBossNode = nodeBefore?.type === "boss"; const isBossNode = nodeBefore?.type === "boss";
const am = getAllBonuses(s.attributes ?? {}); const am = getAllBonuses(s.attributes ?? {});
const tideMod = getTideModifiers(s.activeTide);
const tideBossBonus = tideMod.bossWinRateBonus ?? 0;
const result = isBossNode const result = isBossNode
? resolveNode(exp, () => { ? resolveNode(exp, () => {
// 单次 rng() 调用:B% 概率返回 0(必胜),其余情况返回 r-B(保持均匀分布) // 单次 rng() 调用:B% 概率返回 0(必胜),其余情况返回 r-B(保持均匀分布)
const r = Math.random(); const r = Math.random();
const b = Math.min(0.95, am.bossWinRateBonus); const b = Math.min(0.95, am.bossWinRateBonus + tideBossBonus);
return r < b ? 0 : Math.min(1, r - b); return r < b ? 0 : Math.min(1, r - b);
}) })
: resolveNode(exp); : resolveNode(exp);
@@ -1020,6 +1145,33 @@ export const useGameStore = create<Store>()(
insights: s.insights + Math.round(res.rewardInsight), insights: s.insights + Math.round(res.rewardInsight),
contact: Math.min(100, s.contact + res.rewardContact), contact: Math.min(100, s.contact + res.rewardContact),
}); });
// 异步提交到云排行榜(失败静默,不影响本地)
if (res.entry) {
void submitCloudScore(res.entry);
}
}
return {
rewardInsight: res.rewardInsight,
rewardContact: res.rewardContact,
score: res.score,
};
},
// 深空信标:领取限时挑战奖励(v0.8.2)
claimTimedBeacon: () => {
const challenge = generateTimedChallenge();
const progress = loadTimedProgress();
const res = claimTimedReward(challenge, progress);
if (res.rewardInsight > 0 || res.rewardContact > 0) {
const s = get();
set({
insights: s.insights + Math.round(res.rewardInsight),
contact: Math.min(100, s.contact + res.rewardContact),
});
// 异步提交到云排行榜(失败静默,不影响本地)
if (res.entry) {
void submitCloudScore(res.entry);
}
} }
return { return {
rewardInsight: res.rewardInsight, rewardInsight: res.rewardInsight,
@@ -1155,8 +1307,8 @@ export const useGameStore = create<Store>()(
storage: createJSONStorage(() => localStorage), storage: createJSONStorage(() => localStorage),
// 不持久化临时字段 // 不持久化临时字段
partialize: (s) => { partialize: (s) => {
const { _lastAutoDecode, _lastSpawn, _combo, _lastPulse, _achievementQueue, _tideEvents, ...rest } = s; const { _lastAutoDecode, _lastSpawn, _combo, _lastPulse, _achievementQueue, _tideEvents, globalTide, ...rest } = s;
void _lastAutoDecode; void _lastSpawn; void _combo; void _lastPulse; void _achievementQueue; void _tideEvents; void _lastAutoDecode; void _lastSpawn; void _combo; void _lastPulse; void _achievementQueue; void _tideEvents; void globalTide;
return rest as GameState; return rest as GameState;
}, },
} }
@@ -0,0 +1,875 @@
1→// 回响星核 / Echo Nexus — 深空信标
2→// v0.5:每日挑战 + 本地排行榜
3→// v0.8:周挑战 + 信标链(连续完成奖励)
4→// 一个自包含的"每日挑战 + 周挑战 + 连续完成链"元系统,纯 localStorage 持久化。
5→
6→/** 每日挑战类型 */
7→export type BeaconChallengeType =
8→ | "decode" // 解码 N 颗晶体
9→ | "expedition" // 完成 N 次探险
10→ | "pulse" // 发起 N 次脉冲
11→ | "boss" // 击破 N 处 BOSS
12→ | "insight"; // 累计 N 洞见
13→
14→/** 挑战难度档位 */
15→export type BeaconDifficulty = "routine" | "anomaly" | "singular";
16→
17→/** 一条排行榜记录 */
18→export interface BeaconScoreEntry {
19→ /** 提交时间戳 */
20→ timestamp: number;
21→ /** 日期 keyYYYY-MM-DD)或周 keyYYYY-Www */
22→ dateKey: string;
23→ /** 挑战类型 */
24→ challenge: BeaconChallengeType;
25→ /** 难度 */
26→ difficulty: BeaconDifficulty;
27→ /** 完成度(0-11 = 完成) */
28→ progress: number;
29→ /** 最终得分 */
30→ score: number;
31→ /** 完成时长(秒),未完成则记 0 */
32→ durationSec: number;
33→ /** v0.8:是否为周挑战记录(日挑战默认 false / undefined */
34→ isWeekly?: boolean;
35→}
36→
37→/** 每日挑战定义 */
38→export interface BeaconDailyChallenge {
39→ /** 日期 keyYYYY-MM-DDUTC */
40→ dateKey: string;
41→ /** 挑战类型 */
42→ type: BeaconChallengeType;
43→ /** 难度 */
44→ difficulty: BeaconDifficulty;
45→ /** 目标数值 */
46→ goal: number;
47→ /** 奖励:完成时洞见 */
48→ rewardInsight: number;
49→ /** 奖励:完成时接触进度 */
50→ rewardContact: number;
51→ /** 使用的种子(可复现) */
52→ seed: number;
53→ /** 友好标题 */
54→ title: string;
55→ /** 描述 */
56→ desc: string;
57→}
58→
59→/** 难度配置 */
60→export const BEACON_DIFFICULTY: Record<
61→ BeaconDifficulty,
62→ { label: string; color: string; glow: string; mult: number; icon: string }
63→> = {
64→ routine: {
65→ label: "常规信标",
66→ color: "#34d399",
67→ glow: "rgba(52,211,153,0.45)",
68→ mult: 1,
69→ icon: "◍",
70→ },
71→ anomaly: {
72→ label: "异常波动",
73→ color: "#fbbf24",
74→ glow: "rgba(251,191,36,0.45)",
75→ mult: 1.6,
76→ icon: "◈",
77→ },
78→ singular: {
79→ label: "奇点回响",
80→ color: "#f43f5e",
81→ glow: "rgba(244,63,94,0.5)",
82→ mult: 2.4,
83→ icon: "✶",
84→ },
85→};
86→
87→/** 挑战类型元信息 */
88→export const BEACON_TYPE_META: Record<
89→ BeaconChallengeType,
90→ { label: string; icon: string; unit: string; verb: string }
91→> = {
92→ decode: { label: "解码协议", icon: "❖", unit: "颗", verb: "解码记忆晶体" },
93→ expedition: { label: "远征指令", icon: "⬢", unit: "次", verb: "完成遗迹探险" },
94→ pulse: { label: "脉冲任务", icon: "✺", unit: "次", verb: "发起脉冲扫描" },
95→ boss: { label: "猎杀契约", icon: "☠", unit: "处", verb: "击破维度 BOSS" },
96→ insight: { label: "洞见采集", icon: "✦", unit: "点", verb: "累计获取洞见" },
97→};
98→
99→/** 取今日日期 key(UTC,保证全球同一天同一挑战) */
100→export function getTodayKey(now: Date = new Date()): string {
101→ const y = now.getUTCFullYear();
102→ const m = String(now.getUTCMonth() + 1).padStart(2, "0");
103→ const d = String(now.getUTCDate()).padStart(2, "0");
104→ return `${y}-${m}-${d}`;
105→}
106→
107→/** 把日期 key 转成数值种子 */
108→function dateKeyToSeed(dateKey: string): number {
109→ let h = 2166136261 >>> 0;
110→ for (let i = 0; i < dateKey.length; i++) {
111→ h ^= dateKey.charCodeAt(i);
112→ h = Math.imul(h, 16777619) >>> 0;
113→ }
114→ return h >>> 0;
115→}
116→
117→/** mulberry32 PRNG(可复现) */
118→function mulberry32(seed: number): () => number {
119→ let a = seed >>> 0;
120→ return () => {
121→ a |= 0;
122→ a = (a + 0x6d2b79f5) | 0;
123→ let t = Math.imul(a ^ (a >>> 15), 1 | a);
124→ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
125→ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
126→ };
127→}
128→
129→/** 生成今日每日挑战(确定性:同一天同一种子 → 同一挑战) */
130→export function generateDailyChallenge(now: Date = new Date()): BeaconDailyChallenge {
131→ const dateKey = getTodayKey(now);
132→ const seed = dateKeyToSeed(dateKey);
133→ const rng = mulberry32(seed);
134→
135→ // 选类型(5 种)
136→ const types: BeaconChallengeType[] = ["decode", "expedition", "pulse", "boss", "insight"];
137→ const type = types[Math.floor(rng() * types.length)];
138→
139→ // 选难度(加权:常规 55% / 异常 33% / 奇点 12%
140→ const dr = rng();
141→ const difficulty: BeaconDifficulty =
142→ dr < 0.55 ? "routine" : dr < 0.88 ? "anomaly" : "singular";
143→
144→ const diffMult = BEACON_DIFFICULTY[difficulty].mult;
145→
146→ // 按类型定 goal 与奖励
147→ let goal = 0;
148→ let rewardInsight = 0;
149→ let rewardContact = 0;
150→ let title = "";
151→ let desc = "";
152→
153→ switch (type) {
154→ case "decode":
155→ goal = Math.round((6 + Math.floor(rng() * 10)) * diffMult); // 6-15 × mult
156→ rewardInsight = Math.round(goal * 4 * diffMult);
157→ rewardContact = goal * 0.8;
158→ title = `解码 ${goal} 颗记忆晶体`;
159→ desc = `深空信标要求解码 ${goal} 颗晶体。谐振风暴期间效率更高。`;
160→ break;
161→ case "expedition":
162→ goal = Math.max(1, Math.round((1 + Math.floor(rng() * 3)) * diffMult)); // 1-3 × mult
163→ rewardInsight = Math.round(goal * 12 * diffMult);
164→ rewardContact = goal * 1.5;
165→ title = `完成 ${goal} 次遗迹探险`;
166→ desc = `派出探险队完成 ${goal} 次远征,无论胜负均计入。`;
167→ break;
168→ case "pulse":
169→ goal = Math.round((20 + Math.floor(rng() * 30)) * diffMult); // 20-49 × mult
170→ rewardInsight = Math.round(goal * 1.2 * diffMult);
171→ rewardContact = goal * 0.3;
172→ title = `发起 ${goal} 次脉冲扫描`;
173→ desc = `主动点击晶体发起 ${goal} 次脉冲。连击可叠加加成。`;
174→ break;
175→ case "boss":
176→ goal = Math.max(1, Math.round(diffMult)); // 奇点至少 2-3
177→ rewardInsight = Math.round(goal * 30 * diffMult);
178→ rewardContact = goal * 3;
179→ title = `击破 ${goal} 处维度 BOSS`;
180→ desc = `在探险终点击破 ${goal} 处 BOSS。提升探险力后再挑战。`;
181→ break;
182→ case "insight":
183→ goal = Math.round((40 + Math.floor(rng() * 80)) * diffMult); // 40-119 × mult
184→ rewardInsight = 0; // 洞见挑战不给洞见,给接触
185→ rewardContact = goal * 0.1;
186→ title = `累计获取 ${goal} 洞见`;
187→ desc = `通过解码、探险、星潮等途径累计 ${goal} 洞见。`;
188→ break;
189→ }
190→
191→ return {
192→ dateKey,
193→ type,
194→ difficulty,
195→ goal,
196→ rewardInsight,
197→ rewardContact,
198→ seed,
199→ title,
200→ desc,
201→ };
202→}
203→
204→/** 计算挑战得分(用于排行榜) */
205→export function computeBeaconScore(
206→ challenge: BeaconDailyChallenge,
207→ progress: number,
208→ durationSec: number
209→): number {
210→ const completion = Math.min(1, progress / challenge.goal);
211→ const diffMult = BEACON_DIFFICULTY[challenge.difficulty].mult;
212→ // 基础分 = 完成度 × 难度 × 1000;完成时长越短加分越多(上限 +500)
213→ const base = completion * 1000 * diffMult;
214→ const speedBonus =
215→ completion >= 1 && durationSec > 0 ? Math.max(0, 500 - durationSec * 0.5) : 0;
216→ return Math.round(base + speedBonus);
217→}
218→
219→/** 排行榜 localStorage key */
220→export const BEACON_LEADERBOARD_KEY = "echo-nexus-beacon-lb-v1";
221→/** 每日进度 localStorage key(记录今日进度 + 是否领奖) */
222→export const BEACON_PROGRESS_KEY = "echo-nexus-beacon-prog-v1";
223→
224→/** 排行榜上限 */
225→export const BEACON_LB_MAX = 20;
226→
227→/** 今日进度记录 */
228→export interface BeaconDailyProgress {
229→ dateKey: string;
230→ progress: number;
231→ startedAt: number;
232→ completedAt: number | null;
233→ claimed: boolean;
234→ durationSec: number;
235→}
236→
237→/** 读取本地排行榜(按分数降序) */
238→export function loadLeaderboard(): BeaconScoreEntry[] {
239→ if (typeof localStorage === "undefined") return [];
240→ try {
241→ const raw = localStorage.getItem(BEACON_LEADERBOARD_KEY);
242→ if (!raw) return [];
243→ const arr = JSON.parse(raw) as BeaconScoreEntry[];
244→ return arr.sort((a, b) => b.score - a.score).slice(0, BEACON_LB_MAX);
245→ } catch {
246→ return [];
247→ }
248→}
249→
250→/** 写入一条排行榜记录 */
251→export function pushLeaderboardEntry(entry: BeaconScoreEntry): BeaconScoreEntry[] {
252→ const lb = loadLeaderboard();
253→ lb.push(entry);
254→ lb.sort((a, b) => b.score - a.score);
255→ const trimmed = lb.slice(0, BEACON_LB_MAX);
256→ if (typeof localStorage !== "undefined") {
257→ localStorage.setItem(BEACON_LEADERBOARD_KEY, JSON.stringify(trimmed));
258→ }
259→ return trimmed;
260→}
261→
262→/** 读取今日进度(若 dateKey 不匹配则重置) */
263→export function loadDailyProgress(now: Date = new Date()): BeaconDailyProgress {
264→ const todayKey = getTodayKey(now);
265→ if (typeof localStorage === "undefined") {
266→ return { dateKey: todayKey, progress: 0, startedAt: Date.now(), completedAt: null, claimed: false, durationSec: 0 };
267→ }
268→ try {
269→ const raw = localStorage.getItem(BEACON_PROGRESS_KEY);
270→ if (!raw) {
271→ return { dateKey: todayKey, progress: 0, startedAt: Date.now(), completedAt: null, claimed: false, durationSec: 0 };
272→ }
273→ const prog = JSON.parse(raw) as BeaconDailyProgress;
274→ if (prog.dateKey !== todayKey) {
275→ // 新的一天,重置
276→ return { dateKey: todayKey, progress: 0, startedAt: Date.now(), completedAt: null, claimed: false, durationSec: 0 };
277→ }
278→ return prog;
279→ } catch {
280→ return { dateKey: todayKey, progress: 0, startedAt: Date.now(), completedAt: null, claimed: false, durationSec: 0 };
281→ }
282→}
283→
284→/** 保存今日进度 */
285→export function saveDailyProgress(prog: BeaconDailyProgress): void {
286→ if (typeof localStorage === "undefined") return;
287→ localStorage.setItem(BEACON_PROGRESS_KEY, JSON.stringify(prog));
288→}
289→
290→/** 增量更新进度,返回新进度 + 是否刚完成 */
291→export function addBeaconProgress(
292→ current: BeaconDailyProgress,
293→ challenge: BeaconDailyChallenge,
294→ delta: number
295→): { progress: BeaconDailyProgress; justCompleted: boolean } {
296→ const newProgressVal = Math.min(challenge.goal, current.progress + delta);
297→ const justCompleted = current.completedAt === null && newProgressVal >= challenge.goal;
298→ const completedAt = justCompleted ? Date.now() : current.completedAt;
299→ const durationSec =
300→ completedAt !== null ? Math.floor((completedAt - current.startedAt) / 1000) : current.durationSec;
301→ const next: BeaconDailyProgress = {
302→ ...current,
303→ progress: newProgressVal,
304→ completedAt,
305→ durationSec,
306→ };
307→ saveDailyProgress(next);
308→ return { progress: next, justCompleted };
309→}
310→
311→/** 领取奖励:返回奖励数值 + 推送排行榜 */
312→export function claimBeaconReward(
313→ challenge: BeaconDailyChallenge,
314→ progress: BeaconDailyProgress
315→): { rewardInsight: number; rewardContact: number; score: number; leaderboard: BeaconScoreEntry[] } {
316→ if (progress.claimed || progress.completedAt === null) {
317→ return { rewardInsight: 0, rewardContact: 0, score: 0, leaderboard: loadLeaderboard() };
318→ }
319→ const score = computeBeaconScore(challenge, progress.progress, progress.durationSec);
320→ const entry: BeaconScoreEntry = {
321→ timestamp: Date.now(),
322→ dateKey: challenge.dateKey,
323→ challenge: challenge.type,
324→ difficulty: challenge.difficulty,
325→ progress: progress.progress / challenge.goal,
326→ score,
327→ durationSec: progress.durationSec,
328→ };
329→ const leaderboard = pushLeaderboardEntry(entry);
330→ const updated: BeaconDailyProgress = { ...progress, claimed: true };
331→ saveDailyProgress(updated);
332→ return {
333→ rewardInsight: challenge.rewardInsight,
334→ rewardContact: challenge.rewardContact,
335→ score,
336→ leaderboard,
337→ };
338→}
339→
340→/** 距离 UTC 次日 0 点的毫秒数(用于倒计时) */
341→export function msUntilNextDay(now: Date = new Date()): number {
342→ const next = Date.UTC(
343→ now.getUTCFullYear(),
344→ now.getUTCMonth(),
345→ now.getUTCDate() + 1,
346→ 0,
347→ 0,
348→ 0
349→ );
350→ return Math.max(0, next - now.getTime());
351→}
352→
353→/** 格式化倒计时为 HH:MM:SS */
354→export function formatCountdown(ms: number): string {
355→ const total = Math.max(0, Math.floor(ms / 1000));
356→ const h = String(Math.floor(total / 3600)).padStart(2, "0");
357→ const m = String(Math.floor((total % 3600) / 60)).padStart(2, "0");
358→ const s = String(total % 60).padStart(2, "0");
359→ return `${h}:${m}:${s}`;
360→}
361→
362→// ===========================================================================
363→// 周挑战(WEEKLY CHALLENGE)— v0.8
364→// 每周一 UTC 0 点刷新一个"周挑战",目标更大、奖励更好,与日挑战并行存在。
365→// 同一周同一种子 → 同一挑战(确定性)。难度强制 anomaly/singular(加权 60/40)。
366→// ===========================================================================
367→
368→/** 周挑战 localStorage key */
369→export const BEACON_WEEKLY_KEY = "echo-nexus-beacon-weekly-v1";
370→
371→/** 周挑战定义 */
372→export interface BeaconWeeklyChallenge {
373→ /** ISO 周键(YYYY-Www,如 "2026-W26" */
374→ weekKey: string;
375→ /** 挑战类型 */
376→ type: BeaconChallengeType;
377→ /** 难度(固定 anomaly 或 singular */
378→ difficulty: "anomaly" | "singular";
379→ /** 目标数值(日挑战的 3-5 倍) */
380→ goal: number;
381→ /** 奖励:完成时洞见 */
382→ rewardInsight: number;
383→ /** 奖励:完成时接触进度 */
384→ rewardContact: number;
385→ /** 使用的种子(可复现) */
386→ seed: number;
387→ /** 友好标题 */
388→ title: string;
389→ /** 描述 */
390→ desc: string;
391→}
392→
393→/** 周挑战进度 */
394→export interface BeaconWeeklyProgress {
395→ weekKey: string;
396→ progress: number;
397→ startedAt: number;
398→ completedAt: number | null;
399→ claimed: boolean;
400→ durationSec: number;
401→}
402→
403→/**
404→ * 计算 ISO 8601 周键(YYYY-Www),如 "2026-W26"。
405→ * 规则:周一为一周开始;第一周是该年至少含 4 天的第一周(即包含第一个周四)。
406→ */
407→export function getWeekKey(now: Date = new Date()): string {
408→ // 取 UTC 日期,避免时区偏移
409→ const tmp = new Date(
410→ Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())
411→ );
412→ // ISO 周以周一为起点:(getUTCDay + 6) % 7 → 0=Mon, 6=Sun
413→ const dayNum = (tmp.getUTCDay() + 6) % 7;
414→ // 把日期调整到本周四(ISO 周归属年份由该周四所在的年份决定)
415→ tmp.setUTCDate(tmp.getUTCDate() - dayNum + 3);
416→ const isoYear = tmp.getUTCFullYear();
417→ const yearStart = Date.UTC(isoYear, 0, 1);
418→ const weekNum = Math.ceil(((tmp.getTime() - yearStart) / 86400000 + 1) / 7);
419→ return `${isoYear}-W${String(weekNum).padStart(2, "0")}`;
420→}
421→
422→/** weekKey → 数值种子(FNV-1a 哈希) */
423→function weekKeyToSeed(weekKey: string): number {
424→ let h = 2166136261 >>> 0;
425→ for (let i = 0; i < weekKey.length; i++) {
426→ h ^= weekKey.charCodeAt(i);
427→ h = Math.imul(h, 16777619) >>> 0;
428→ }
429→ return h >>> 0;
430→}
431→
432→/**
433→ * 生成本周周挑战(确定性:同一周同一种子 → 同一挑战)。
434→ * 难度强制 anomaly60%)或 singular40%),goal 为日挑战基准 ×3-5 倍。
435→ */
436→export function generateWeeklyChallenge(
437→ now: Date = new Date()
438→): BeaconWeeklyChallenge {
439→ const weekKey = getWeekKey(now);
440→ const seed = weekKeyToSeed(weekKey);
441→ const rng = mulberry32(seed);
442→
443→ const types: BeaconChallengeType[] = [
444→ "decode",
445→ "expedition",
446→ "pulse",
447→ "boss",
448→ "insight",
449→ ];
450→ const type = types[Math.floor(rng() * types.length)];
451→
452→ // 难度加权:anomaly 60% / singular 40%
453→ const dr = rng();
454→ const difficulty: "anomaly" | "singular" = dr < 0.6 ? "anomaly" : "singular";
455→ const diffMult = BEACON_DIFFICULTY[difficulty].mult;
456→
457→ // 3-5 倍
458→ const mult = 3 + Math.floor(rng() * 3);
459→
460→ let goal = 0;
461→ let rewardInsight = 0;
462→ let rewardContact = 0;
463→ let title = "";
464→ let desc = "";
465→
466→ switch (type) {
467→ case "decode":
468→ // 日基准 6-15 × mult(3-5) → 18-75
469→ goal = Math.max(18, Math.round((6 + Math.floor(rng() * 10)) * diffMult * mult));
470→ rewardInsight = Math.round(goal * 5 * diffMult);
471→ rewardContact = goal * 1.2;
472→ title = `周界解码 · ${goal} 颗晶体`;
473→ desc = `本周深空信标要求解码 ${goal} 颗记忆晶体,强度为日挑战的 ${mult} 倍。`;
474→ break;
475→ case "expedition":
476→ // 日基准 1-3 × mult(3-5) → 3-15
477→ goal = Math.max(3, Math.round((1 + Math.floor(rng() * 3)) * diffMult * mult));
478→ rewardInsight = Math.round(goal * 14 * diffMult);
479→ rewardContact = goal * 2;
480→ title = `周界远征 · ${goal} 次探险`;
481→ desc = `本周派出探险队完成 ${goal} 次远征,无论胜负均计入。`;
482→ break;
483→ case "pulse":
484→ // 日基准 20-49 × mult(3-5) → 60-245
485→ goal = Math.max(60, Math.round((20 + Math.floor(rng() * 30)) * diffMult * mult));
486→ rewardInsight = Math.round(goal * 1.5 * diffMult);
487→ rewardContact = goal * 0.4;
488→ title = `周界脉冲 · ${goal} 次扫描`;
489→ desc = `本周发起 ${goal} 次脉冲扫描。连击可叠加加成。`;
490→ break;
491→ case "boss":
492→ // 日基准 1 × mult(3-5) → 3-12singular mult=2.4 时上限 12
493→ goal = Math.max(3, Math.round(mult * diffMult));
494→ rewardInsight = Math.round(goal * 35 * diffMult);
495→ rewardContact = goal * 4;
496→ title = `周界猎杀 · ${goal} 处 BOSS`;
497→ desc = `本周在探险终点击破 ${goal} 处维度 BOSS。提升探险力后再挑战。`;
498→ break;
499→ case "insight":
500→ // 日基准 40-119 × mult(3-5) → 120-595
501→ goal = Math.max(120, Math.round((40 + Math.floor(rng() * 80)) * diffMult * mult));
502→ rewardInsight = 0; // 洞见挑战不给洞见,给接触
503→ rewardContact = goal * 0.15;
504→ title = `周界洞见 · ${goal} 点`;
505→ desc = `本周通过解码、探险、星潮等途径累计 ${goal} 洞见。`;
506→ break;
507→ }
508→
509→ return {
510→ weekKey,
511→ type,
512→ difficulty,
513→ goal,
514→ rewardInsight,
515→ rewardContact,
516→ seed,
517→ title,
518→ desc,
519→ };
520→}
521→
522→/** 读取本周进度(若 weekKey 不匹配则重置) */
523→export function loadWeeklyProgress(now: Date = new Date()): BeaconWeeklyProgress {
524→ const weekKey = getWeekKey(now);
525→ const empty: BeaconWeeklyProgress = {
526→ weekKey,
527→ progress: 0,
528→ startedAt: Date.now(),
529→ completedAt: null,
530→ claimed: false,
531→ durationSec: 0,
532→ };
533→ if (typeof localStorage === "undefined") return empty;
534→ try {
535→ const raw = localStorage.getItem(BEACON_WEEKLY_KEY);
536→ if (!raw) return empty;
537→ const prog = JSON.parse(raw) as BeaconWeeklyProgress;
538→ if (prog.weekKey !== weekKey) return empty; // 新的一周,重置
539→ return prog;
540→ } catch {
541→ return empty;
542→ }
543→}
544→
545→/** 保存本周进度 */
546→export function saveWeeklyProgress(prog: BeaconWeeklyProgress): void {
547→ if (typeof localStorage === "undefined") return;
548→ localStorage.setItem(BEACON_WEEKLY_KEY, JSON.stringify(prog));
549→}
550→
551→/** 增量更新周挑战进度,返回新进度 + 是否刚完成 */
552→export function addWeeklyProgress(
553→ current: BeaconWeeklyProgress,
554→ challenge: BeaconWeeklyChallenge,
555→ delta: number
556→): { progress: BeaconWeeklyProgress; justCompleted: boolean } {
557→ const newProgressVal = Math.min(challenge.goal, current.progress + delta);
558→ const justCompleted =
559→ current.completedAt === null && newProgressVal >= challenge.goal;
560→ const completedAt = justCompleted ? Date.now() : current.completedAt;
561→ const durationSec =
562→ completedAt !== null
563→ ? Math.floor((completedAt - current.startedAt) / 1000)
564→ : current.durationSec;
565→ const next: BeaconWeeklyProgress = {
566→ ...current,
567→ progress: newProgressVal,
568→ completedAt,
569→ durationSec,
570→ };
571→ saveWeeklyProgress(next);
572→ return { progress: next, justCompleted };
573→}
574→
575→/** 领取周挑战奖励:返回奖励数值 + 推送排行榜 */
576→export function claimWeeklyReward(
577→ challenge: BeaconWeeklyChallenge,
578→ progress: BeaconWeeklyProgress
579→): {
580→ rewardInsight: number;
581→ rewardContact: number;
582→ score: number;
583→ leaderboard: BeaconScoreEntry[];
584→} {
585→ if (progress.claimed || progress.completedAt === null) {
586→ return {
587→ rewardInsight: 0,
588→ rewardContact: 0,
589→ score: 0,
590→ leaderboard: loadLeaderboard(),
591→ };
592→ }
593→ // 借用 computeBeaconScore:把 weekly 包装成 daily 接口(dateKey 字段不影响计分逻辑)
594→ const score = computeBeaconScore(
595→ { ...(challenge as unknown as BeaconDailyChallenge), dateKey: challenge.weekKey },
596→ progress.progress,
597→ progress.durationSec
598→ );
599→ const entry: BeaconScoreEntry = {
600→ timestamp: Date.now(),
601→ dateKey: challenge.weekKey,
602→ challenge: challenge.type,
603→ difficulty: challenge.difficulty,
604→ progress: progress.progress / challenge.goal,
605→ score,
606→ durationSec: progress.durationSec,
607→ isWeekly: true,
608→ };
609→ const leaderboard = pushLeaderboardEntry(entry);
610→ const updated: BeaconWeeklyProgress = { ...progress, claimed: true };
611→ saveWeeklyProgress(updated);
612→ return {
613→ rewardInsight: challenge.rewardInsight,
614→ rewardContact: challenge.rewardContact,
615→ score,
616→ leaderboard,
617→ };
618→}
619→
620→/** 距离下周一 UTC 0 点的毫秒数(用于周挑战倒计时) */
621→export function msUntilNextWeek(now: Date = new Date()): number {
622→ const dayNum = (now.getUTCDay() + 6) % 7; // 0 = Mon
623→ const mondayThisWeek = Date.UTC(
624→ now.getUTCFullYear(),
625→ now.getUTCMonth(),
626→ now.getUTCDate() - dayNum,
627→ 0,
628→ 0,
629→ 0
630→ );
631→ const nextMonday = mondayThisWeek + 7 * 86400000;
632→ return Math.max(0, nextMonday - now.getTime());
633→}
634→
635→// ===========================================================================
636→// 信标链(BEACON CHAIN)— v0.8
637→// 连续完成日挑战形成"链",达成里程碑(3/7/14/30 天)领取递增大奖。
638→// 断链有宽容机制(1 天 miss 不断链,用"信标续命"概念),每条链只能用 1 次。
639→// ===========================================================================
640→
641→/** 信标链 localStorage key */
642→export const BEACON_CHAIN_KEY = "echo-nexus-beacon-chain-v1";
643→
644→/** 里程碑天数 */
645→export const BEACON_CHAIN_MILESTONES = [3, 7, 14, 30] as const;
646→
647→/** 里程碑奖励配置 */
648→export interface BeaconChainReward {
649→ milestone: number;
650→ rewardInsight: number;
651→ rewardContact: number;
652→ label: string;
653→}
654→
655→export const BEACON_CHAIN_REWARDS: BeaconChainReward[] = [
656→ { milestone: 3, rewardInsight: 50, rewardContact: 5, label: "三日谐振" },
657→ { milestone: 7, rewardInsight: 120, rewardContact: 12, label: "七日回响" },
658→ { milestone: 14, rewardInsight: 280, rewardContact: 28, label: "半月星潮" },
659→ { milestone: 30, rewardInsight: 680, rewardContact: 68, label: "满月飞升" },
660→];
661→
662→/** 信标链状态 */
663→export interface BeaconChainState {
664→ /** 上次完成日(YYYY-MM-DD */
665→ lastCompletedDateKey: string;
666→ /** 当前连续天数 */
667→ currentStreak: number;
668→ /** 历史最长 */
669→ longestStreak: number;
670→ /** 累计完成总数 */
671→ totalCompletions: number;
672→ /** 本周期已用续命数(上限 1) */
673→ graceUsed: number;
674→ /** 已领取的里程碑数组 */
675→ milestonesClaimed: number[];
676→}
677→
678→/** 读取信标链状态 */
679→export function loadChainState(): BeaconChainState {
680→ const fresh = (): BeaconChainState => ({
681→ lastCompletedDateKey: "",
682→ currentStreak: 0,
683→ longestStreak: 0,
684→ totalCompletions: 0,
685→ graceUsed: 0,
686→ milestonesClaimed: [],
687→ });
688→ if (typeof localStorage === "undefined") return fresh();
689→ try {
690→ const raw = localStorage.getItem(BEACON_CHAIN_KEY);
691→ if (!raw) return fresh();
692→ const s = JSON.parse(raw) as Partial<BeaconChainState>;
693→ return {
694→ lastCompletedDateKey: s.lastCompletedDateKey ?? "",
695→ currentStreak: s.currentStreak ?? 0,
696→ longestStreak: s.longestStreak ?? 0,
697→ totalCompletions: s.totalCompletions ?? 0,
698→ graceUsed: s.graceUsed ?? 0,
699→ milestonesClaimed: Array.isArray(s.milestonesClaimed)
700→ ? [...s.milestonesClaimed]
701→ : [],
702→ };
703→ } catch {
704→ return fresh();
705→ }
706→}
707→
708→/** 保存信标链状态 */
709→export function saveChainState(state: BeaconChainState): void {
710→ if (typeof localStorage === "undefined") return;
711→ localStorage.setItem(BEACON_CHAIN_KEY, JSON.stringify(state));
712→}
713→
714→/** dateKey → UTC 0 点时间戳 */
715→function dateKeyToTimestamp(dateKey: string): number {
716→ const [y, m, d] = dateKey.split("-").map(Number);
717→ return Date.UTC(y, m - 1, d, 0, 0, 0);
718→}
719→
720→/** 计算 b - a 相差的天数(UTC 0 点对齐) */
721→function dateKeyDiffDays(a: string, b: string): number {
722→ if (!a || !b) return Number.MAX_SAFE_INTEGER;
723→ return Math.round((dateKeyToTimestamp(b) - dateKeyToTimestamp(a)) / 86400000);
724→}
725→
726→/**
727→ * 记录一次日挑战完成(核心逻辑)。
728→ * - dateKey === lastCompletedDateKey:同一天重复完成,忽略,返回 newMilestones: []
729→ * - dateKey 是 lastCompletedDateKey 的次日:currentStreak++
730→ * - dateKey 是 lastCompletedDateKey 的后两天(隔了一天 miss)且 graceUsed < 1:续命一次,currentStreak++
731→ * - 其他:currentStreak = 1(断链重来),graceUsed = 0
732→ * 更新 longestStreak 与 totalCompletions。
733→ * @returns { state, newMilestones } 刚达成但未领取的里程碑数组
734→ */
735→export function recordChainCompletion(dateKey: string): {
736→ state: BeaconChainState;
737→ newMilestones: number[];
738→} {
739→ const state = loadChainState();
740→
741→ // 同一天重复完成:忽略
742→ if (state.lastCompletedDateKey === dateKey) {
743→ return { state, newMilestones: [] };
744→ }
745→
746→ let next: BeaconChainState;
747→
748→ if (state.lastCompletedDateKey === "") {
749→ // 首次完成
750→ next = {
751→ ...state,
752→ lastCompletedDateKey: dateKey,
753→ currentStreak: 1,
754→ longestStreak: Math.max(state.longestStreak, 1),
755→ totalCompletions: state.totalCompletions + 1,
756→ };
757→ } else {
758→ const diff = dateKeyDiffDays(state.lastCompletedDateKey, dateKey);
759→ if (diff === 1) {
760→ // 次日:链 +1
761→ const newStreak = state.currentStreak + 1;
762→ next = {
763→ ...state,
764→ lastCompletedDateKey: dateKey,
765→ currentStreak: newStreak,
766→ longestStreak: Math.max(state.longestStreak, newStreak),
767→ totalCompletions: state.totalCompletions + 1,
768→ };
769→ } else if (diff === 2 && state.graceUsed < 1) {
770→ // 隔一天 miss,续命一次
771→ const newStreak = state.currentStreak + 1;
772→ next = {
773→ ...state,
774→ lastCompletedDateKey: dateKey,
775→ currentStreak: newStreak,
776→ longestStreak: Math.max(state.longestStreak, newStreak),
777→ totalCompletions: state.totalCompletions + 1,
778→ graceUsed: state.graceUsed + 1,
779→ };
780→ } else {
781→ // 断链重来
782→ next = {
783→ ...state,
784→ lastCompletedDateKey: dateKey,
785→ currentStreak: 1,
786→ totalCompletions: state.totalCompletions + 1,
787→ graceUsed: 0,
788→ longestStreak: Math.max(state.longestStreak, 1),
789→ };
790→ }
791→ }
792→
793→ // 检查新里程碑(刚达成但未领取)
794→ const newMilestones: number[] = [];
795→ for (const m of BEACON_CHAIN_MILESTONES) {
796→ if (
797→ next.currentStreak >= m &&
798→ !next.milestonesClaimed.includes(m)
799→ ) {
800→ newMilestones.push(m);
801→ }
802→ }
803→
804→ saveChainState(next);
805→ return { state: next, newMilestones };
806→}
807→
808→/** 领取里程碑奖励,加入 milestonesClaimed */
809→export function claimChainMilestone(milestone: number): {
810→ rewardInsight: number;
811→ rewardContact: number;
812→ label: string;
813→ state: BeaconChainState;
814→} {
815→ const reward = BEACON_CHAIN_REWARDS.find((r) => r.milestone === milestone);
816→ const state = loadChainState();
817→ if (
818→ !reward ||
819→ state.milestonesClaimed.includes(milestone) ||
820→ state.currentStreak < milestone
821→ ) {
822→ return { rewardInsight: 0, rewardContact: 0, label: "", state };
823→ }
824→ const next: BeaconChainState = {
825→ ...state,
826→ milestonesClaimed: [...state.milestonesClaimed, milestone],
827→ };
828→ saveChainState(next);
829→ return {
830→ rewardInsight: reward.rewardInsight,
831→ rewardContact: reward.rewardContact,
832→ label: reward.label,
833→ state: next,
834→ };
835→}
836→
837→/** 返回下一个目标里程碑(如 streak=5 → 7streak=30+ → null */
838→export function getNextMilestone(streak: number): number | null {
839→ for (const m of BEACON_CHAIN_MILESTONES) {
840→ if (streak < m) return m;
841→ }
842→ return null;
843→}
844→
845→/**
846→ * 返回信标链进度信息(用于 UI 进度条)。
847→ * - current:当前连续天数
848→ * - next:下一个目标里程碑(null 表示已通关全部)
849→ * - progressPct:当前进度(基于上一个里程碑 → 下一个里程碑)
850→ */
851→export function getChainProgress(streak: number): {
852→ current: number;
853→ next: number | null;
854→ prev: number;
855→ progressPct: number;
856→} {
857→ const next = getNextMilestone(streak);
858→ let prev = 0;
859→ for (const m of BEACON_CHAIN_MILESTONES) {
860→ if (streak >= m) prev = m;
861→ }
862→ if (next === null) {
863→ return { current: streak, next: null, prev, progressPct: 100 };
864→ }
865→ const span = next - prev;
866→ const done = streak - prev;
867→ const pct = span > 0 ? Math.round((done / span) * 100) : 100;
868→ return {
869→ current: streak,
870→ next,
871→ prev,
872→ progressPct: Math.min(100, Math.max(0, pct)),
873→ };
874→}
875→
@@ -0,0 +1,875 @@
1→ 1→// 回响星核 / Echo Nexus — 深空信标
2→ 2→// v0.5:每日挑战 + 本地排行榜
3→ 3→// v0.8:周挑战 + 信标链(连续完成奖励)
4→ 4→// 一个自包含的"每日挑战 + 周挑战 + 连续完成链"元系统,纯 localStorage 持久化。
5→ 5→
6→ 6→/** 每日挑战类型 */
7→ 7→export type BeaconChallengeType =
8→ 8→ | "decode" // 解码 N 颗晶体
9→ 9→ | "expedition" // 完成 N 次探险
10→ 10→ | "pulse" // 发起 N 次脉冲
11→ 11→ | "boss" // 击破 N 处 BOSS
12→ 12→ | "insight"; // 累计 N 洞见
13→ 13→
14→ 14→/** 挑战难度档位 */
15→ 15→export type BeaconDifficulty = "routine" | "anomaly" | "singular";
16→ 16→
17→ 17→/** 一条排行榜记录 */
18→ 18→export interface BeaconScoreEntry {
19→ 19→ /** 提交时间戳 */
20→ 20→ timestamp: number;
21→ 21→ /** 日期 keyYYYY-MM-DD)或周 keyYYYY-Www */
22→ 22→ dateKey: string;
23→ 23→ /** 挑战类型 */
24→ 24→ challenge: BeaconChallengeType;
25→ 25→ /** 难度 */
26→ 26→ difficulty: BeaconDifficulty;
27→ 27→ /** 完成度(0-11 = 完成) */
28→ 28→ progress: number;
29→ 29→ /** 最终得分 */
30→ 30→ score: number;
31→ 31→ /** 完成时长(秒),未完成则记 0 */
32→ 32→ durationSec: number;
33→ 33→ /** v0.8:是否为周挑战记录(日挑战默认 false / undefined */
34→ 34→ isWeekly?: boolean;
35→ 35→}
36→ 36→
37→ 37→/** 每日挑战定义 */
38→ 38→export interface BeaconDailyChallenge {
39→ 39→ /** 日期 keyYYYY-MM-DDUTC */
40→ 40→ dateKey: string;
41→ 41→ /** 挑战类型 */
42→ 42→ type: BeaconChallengeType;
43→ 43→ /** 难度 */
44→ 44→ difficulty: BeaconDifficulty;
45→ 45→ /** 目标数值 */
46→ 46→ goal: number;
47→ 47→ /** 奖励:完成时洞见 */
48→ 48→ rewardInsight: number;
49→ 49→ /** 奖励:完成时接触进度 */
50→ 50→ rewardContact: number;
51→ 51→ /** 使用的种子(可复现) */
52→ 52→ seed: number;
53→ 53→ /** 友好标题 */
54→ 54→ title: string;
55→ 55→ /** 描述 */
56→ 56→ desc: string;
57→ 57→}
58→ 58→
59→ 59→/** 难度配置 */
60→ 60→export const BEACON_DIFFICULTY: Record<
61→ 61→ BeaconDifficulty,
62→ 62→ { label: string; color: string; glow: string; mult: number; icon: string }
63→ 63→> = {
64→ 64→ routine: {
65→ 65→ label: "常规信标",
66→ 66→ color: "#34d399",
67→ 67→ glow: "rgba(52,211,153,0.45)",
68→ 68→ mult: 1,
69→ 69→ icon: "◍",
70→ 70→ },
71→ 71→ anomaly: {
72→ 72→ label: "异常波动",
73→ 73→ color: "#fbbf24",
74→ 74→ glow: "rgba(251,191,36,0.45)",
75→ 75→ mult: 1.6,
76→ 76→ icon: "◈",
77→ 77→ },
78→ 78→ singular: {
79→ 79→ label: "奇点回响",
80→ 80→ color: "#f43f5e",
81→ 81→ glow: "rgba(244,63,94,0.5)",
82→ 82→ mult: 2.4,
83→ 83→ icon: "✶",
84→ 84→ },
85→ 85→};
86→ 86→
87→ 87→/** 挑战类型元信息 */
88→ 88→export const BEACON_TYPE_META: Record<
89→ 89→ BeaconChallengeType,
90→ 90→ { label: string; icon: string; unit: string; verb: string }
91→ 91→> = {
92→ 92→ decode: { label: "解码协议", icon: "❖", unit: "颗", verb: "解码记忆晶体" },
93→ 93→ expedition: { label: "远征指令", icon: "⬢", unit: "次", verb: "完成遗迹探险" },
94→ 94→ pulse: { label: "脉冲任务", icon: "✺", unit: "次", verb: "发起脉冲扫描" },
95→ 95→ boss: { label: "猎杀契约", icon: "☠", unit: "处", verb: "击破维度 BOSS" },
96→ 96→ insight: { label: "洞见采集", icon: "✦", unit: "点", verb: "累计获取洞见" },
97→ 97→};
98→ 98→
99→ 99→/** 取今日日期 key(UTC,保证全球同一天同一挑战) */
100→ 100→export function getTodayKey(now: Date = new Date()): string {
101→ 101→ const y = now.getUTCFullYear();
102→ 102→ const m = String(now.getUTCMonth() + 1).padStart(2, "0");
103→ 103→ const d = String(now.getUTCDate()).padStart(2, "0");
104→ 104→ return `${y}-${m}-${d}`;
105→ 105→}
106→ 106→
107→ 107→/** 把日期 key 转成数值种子 */
108→ 108→function dateKeyToSeed(dateKey: string): number {
109→ 109→ let h = 2166136261 >>> 0;
110→ 110→ for (let i = 0; i < dateKey.length; i++) {
111→ 111→ h ^= dateKey.charCodeAt(i);
112→ 112→ h = Math.imul(h, 16777619) >>> 0;
113→ 113→ }
114→ 114→ return h >>> 0;
115→ 115→}
116→ 116→
117→ 117→/** mulberry32 PRNG(可复现) */
118→ 118→function mulberry32(seed: number): () => number {
119→ 119→ let a = seed >>> 0;
120→ 120→ return () => {
121→ 121→ a |= 0;
122→ 122→ a = (a + 0x6d2b79f5) | 0;
123→ 123→ let t = Math.imul(a ^ (a >>> 15), 1 | a);
124→ 124→ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
125→ 125→ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
126→ 126→ };
127→ 127→}
128→ 128→
129→ 129→/** 生成今日每日挑战(确定性:同一天同一种子 → 同一挑战) */
130→ 130→export function generateDailyChallenge(now: Date = new Date()): BeaconDailyChallenge {
131→ 131→ const dateKey = getTodayKey(now);
132→ 132→ const seed = dateKeyToSeed(dateKey);
133→ 133→ const rng = mulberry32(seed);
134→ 134→
135→ 135→ // 选类型(5 种)
136→ 136→ const types: BeaconChallengeType[] = ["decode", "expedition", "pulse", "boss", "insight"];
137→ 137→ const type = types[Math.floor(rng() * types.length)];
138→ 138→
139→ 139→ // 选难度(加权:常规 55% / 异常 33% / 奇点 12%
140→ 140→ const dr = rng();
141→ 141→ const difficulty: BeaconDifficulty =
142→ 142→ dr < 0.55 ? "routine" : dr < 0.88 ? "anomaly" : "singular";
143→ 143→
144→ 144→ const diffMult = BEACON_DIFFICULTY[difficulty].mult;
145→ 145→
146→ 146→ // 按类型定 goal 与奖励
147→ 147→ let goal = 0;
148→ 148→ let rewardInsight = 0;
149→ 149→ let rewardContact = 0;
150→ 150→ let title = "";
151→ 151→ let desc = "";
152→ 152→
153→ 153→ switch (type) {
154→ 154→ case "decode":
155→ 155→ goal = Math.round((6 + Math.floor(rng() * 10)) * diffMult); // 6-15 × mult
156→ 156→ rewardInsight = Math.round(goal * 4 * diffMult);
157→ 157→ rewardContact = goal * 0.8;
158→ 158→ title = `解码 ${goal} 颗记忆晶体`;
159→ 159→ desc = `深空信标要求解码 ${goal} 颗晶体。谐振风暴期间效率更高。`;
160→ 160→ break;
161→ 161→ case "expedition":
162→ 162→ goal = Math.max(1, Math.round((1 + Math.floor(rng() * 3)) * diffMult)); // 1-3 × mult
163→ 163→ rewardInsight = Math.round(goal * 12 * diffMult);
164→ 164→ rewardContact = goal * 1.5;
165→ 165→ title = `完成 ${goal} 次遗迹探险`;
166→ 166→ desc = `派出探险队完成 ${goal} 次远征,无论胜负均计入。`;
167→ 167→ break;
168→ 168→ case "pulse":
169→ 169→ goal = Math.round((20 + Math.floor(rng() * 30)) * diffMult); // 20-49 × mult
170→ 170→ rewardInsight = Math.round(goal * 1.2 * diffMult);
171→ 171→ rewardContact = goal * 0.3;
172→ 172→ title = `发起 ${goal} 次脉冲扫描`;
173→ 173→ desc = `主动点击晶体发起 ${goal} 次脉冲。连击可叠加加成。`;
174→ 174→ break;
175→ 175→ case "boss":
176→ 176→ goal = Math.max(1, Math.round(diffMult)); // 奇点至少 2-3
177→ 177→ rewardInsight = Math.round(goal * 30 * diffMult);
178→ 178→ rewardContact = goal * 3;
179→ 179→ title = `击破 ${goal} 处维度 BOSS`;
180→ 180→ desc = `在探险终点击破 ${goal} 处 BOSS。提升探险力后再挑战。`;
181→ 181→ break;
182→ 182→ case "insight":
183→ 183→ goal = Math.round((40 + Math.floor(rng() * 80)) * diffMult); // 40-119 × mult
184→ 184→ rewardInsight = 0; // 洞见挑战不给洞见,给接触
185→ 185→ rewardContact = goal * 0.1;
186→ 186→ title = `累计获取 ${goal} 洞见`;
187→ 187→ desc = `通过解码、探险、星潮等途径累计 ${goal} 洞见。`;
188→ 188→ break;
189→ 189→ }
190→ 190→
191→ 191→ return {
192→ 192→ dateKey,
193→ 193→ type,
194→ 194→ difficulty,
195→ 195→ goal,
196→ 196→ rewardInsight,
197→ 197→ rewardContact,
198→ 198→ seed,
199→ 199→ title,
200→ 200→ desc,
201→ 201→ };
202→ 202→}
203→ 203→
204→ 204→/** 计算挑战得分(用于排行榜) */
205→ 205→export function computeBeaconScore(
206→ 206→ challenge: BeaconDailyChallenge,
207→ 207→ progress: number,
208→ 208→ durationSec: number
209→ 209→): number {
210→ 210→ const completion = Math.min(1, progress / challenge.goal);
211→ 211→ const diffMult = BEACON_DIFFICULTY[challenge.difficulty].mult;
212→ 212→ // 基础分 = 完成度 × 难度 × 1000;完成时长越短加分越多(上限 +500)
213→ 213→ const base = completion * 1000 * diffMult;
214→ 214→ const speedBonus =
215→ 215→ completion >= 1 && durationSec > 0 ? Math.max(0, 500 - durationSec * 0.5) : 0;
216→ 216→ return Math.round(base + speedBonus);
217→ 217→}
218→ 218→
219→ 219→/** 排行榜 localStorage key */
220→ 220→export const BEACON_LEADERBOARD_KEY = "echo-nexus-beacon-lb-v1";
221→ 221→/** 每日进度 localStorage key(记录今日进度 + 是否领奖) */
222→ 222→export const BEACON_PROGRESS_KEY = "echo-nexus-beacon-prog-v1";
223→ 223→
224→ 224→/** 排行榜上限 */
225→ 225→export const BEACON_LB_MAX = 20;
226→ 226→
227→ 227→/** 今日进度记录 */
228→ 228→export interface BeaconDailyProgress {
229→ 229→ dateKey: string;
230→ 230→ progress: number;
231→ 231→ startedAt: number;
232→ 232→ completedAt: number | null;
233→ 233→ claimed: boolean;
234→ 234→ durationSec: number;
235→ 235→}
236→ 236→
237→ 237→/** 读取本地排行榜(按分数降序) */
238→ 238→export function loadLeaderboard(): BeaconScoreEntry[] {
239→ 239→ if (typeof localStorage === "undefined") return [];
240→ 240→ try {
241→ 241→ const raw = localStorage.getItem(BEACON_LEADERBOARD_KEY);
242→ 242→ if (!raw) return [];
243→ 243→ const arr = JSON.parse(raw) as BeaconScoreEntry[];
244→ 244→ return arr.sort((a, b) => b.score - a.score).slice(0, BEACON_LB_MAX);
245→ 245→ } catch {
246→ 246→ return [];
247→ 247→ }
248→ 248→}
249→ 249→
250→ 250→/** 写入一条排行榜记录 */
251→ 251→export function pushLeaderboardEntry(entry: BeaconScoreEntry): BeaconScoreEntry[] {
252→ 252→ const lb = loadLeaderboard();
253→ 253→ lb.push(entry);
254→ 254→ lb.sort((a, b) => b.score - a.score);
255→ 255→ const trimmed = lb.slice(0, BEACON_LB_MAX);
256→ 256→ if (typeof localStorage !== "undefined") {
257→ 257→ localStorage.setItem(BEACON_LEADERBOARD_KEY, JSON.stringify(trimmed));
258→ 258→ }
259→ 259→ return trimmed;
260→ 260→}
261→ 261→
262→ 262→/** 读取今日进度(若 dateKey 不匹配则重置) */
263→ 263→export function loadDailyProgress(now: Date = new Date()): BeaconDailyProgress {
264→ 264→ const todayKey = getTodayKey(now);
265→ 265→ if (typeof localStorage === "undefined") {
266→ 266→ return { dateKey: todayKey, progress: 0, startedAt: Date.now(), completedAt: null, claimed: false, durationSec: 0 };
267→ 267→ }
268→ 268→ try {
269→ 269→ const raw = localStorage.getItem(BEACON_PROGRESS_KEY);
270→ 270→ if (!raw) {
271→ 271→ return { dateKey: todayKey, progress: 0, startedAt: Date.now(), completedAt: null, claimed: false, durationSec: 0 };
272→ 272→ }
273→ 273→ const prog = JSON.parse(raw) as BeaconDailyProgress;
274→ 274→ if (prog.dateKey !== todayKey) {
275→ 275→ // 新的一天,重置
276→ 276→ return { dateKey: todayKey, progress: 0, startedAt: Date.now(), completedAt: null, claimed: false, durationSec: 0 };
277→ 277→ }
278→ 278→ return prog;
279→ 279→ } catch {
280→ 280→ return { dateKey: todayKey, progress: 0, startedAt: Date.now(), completedAt: null, claimed: false, durationSec: 0 };
281→ 281→ }
282→ 282→}
283→ 283→
284→ 284→/** 保存今日进度 */
285→ 285→export function saveDailyProgress(prog: BeaconDailyProgress): void {
286→ 286→ if (typeof localStorage === "undefined") return;
287→ 287→ localStorage.setItem(BEACON_PROGRESS_KEY, JSON.stringify(prog));
288→ 288→}
289→ 289→
290→ 290→/** 增量更新进度,返回新进度 + 是否刚完成 */
291→ 291→export function addBeaconProgress(
292→ 292→ current: BeaconDailyProgress,
293→ 293→ challenge: BeaconDailyChallenge,
294→ 294→ delta: number
295→ 295→): { progress: BeaconDailyProgress; justCompleted: boolean } {
296→ 296→ const newProgressVal = Math.min(challenge.goal, current.progress + delta);
297→ 297→ const justCompleted = current.completedAt === null && newProgressVal >= challenge.goal;
298→ 298→ const completedAt = justCompleted ? Date.now() : current.completedAt;
299→ 299→ const durationSec =
300→ 300→ completedAt !== null ? Math.floor((completedAt - current.startedAt) / 1000) : current.durationSec;
301→ 301→ const next: BeaconDailyProgress = {
302→ 302→ ...current,
303→ 303→ progress: newProgressVal,
304→ 304→ completedAt,
305→ 305→ durationSec,
306→ 306→ };
307→ 307→ saveDailyProgress(next);
308→ 308→ return { progress: next, justCompleted };
309→ 309→}
310→ 310→
311→ 311→/** 领取奖励:返回奖励数值 + 推送排行榜 */
312→ 312→export function claimBeaconReward(
313→ 313→ challenge: BeaconDailyChallenge,
314→ 314→ progress: BeaconDailyProgress
315→ 315→): { rewardInsight: number; rewardContact: number; score: number; leaderboard: BeaconScoreEntry[] } {
316→ 316→ if (progress.claimed || progress.completedAt === null) {
317→ 317→ return { rewardInsight: 0, rewardContact: 0, score: 0, leaderboard: loadLeaderboard() };
318→ 318→ }
319→ 319→ const score = computeBeaconScore(challenge, progress.progress, progress.durationSec);
320→ 320→ const entry: BeaconScoreEntry = {
321→ 321→ timestamp: Date.now(),
322→ 322→ dateKey: challenge.dateKey,
323→ 323→ challenge: challenge.type,
324→ 324→ difficulty: challenge.difficulty,
325→ 325→ progress: progress.progress / challenge.goal,
326→ 326→ score,
327→ 327→ durationSec: progress.durationSec,
328→ 328→ };
329→ 329→ const leaderboard = pushLeaderboardEntry(entry);
330→ 330→ const updated: BeaconDailyProgress = { ...progress, claimed: true };
331→ 331→ saveDailyProgress(updated);
332→ 332→ return {
333→ 333→ rewardInsight: challenge.rewardInsight,
334→ 334→ rewardContact: challenge.rewardContact,
335→ 335→ score,
336→ 336→ leaderboard,
337→ 337→ };
338→ 338→}
339→ 339→
340→ 340→/** 距离 UTC 次日 0 点的毫秒数(用于倒计时) */
341→ 341→export function msUntilNextDay(now: Date = new Date()): number {
342→ 342→ const next = Date.UTC(
343→ 343→ now.getUTCFullYear(),
344→ 344→ now.getUTCMonth(),
345→ 345→ now.getUTCDate() + 1,
346→ 346→ 0,
347→ 347→ 0,
348→ 348→ 0
349→ 349→ );
350→ 350→ return Math.max(0, next - now.getTime());
351→ 351→}
352→ 352→
353→ 353→/** 格式化倒计时为 HH:MM:SS */
354→ 354→export function formatCountdown(ms: number): string {
355→ 355→ const total = Math.max(0, Math.floor(ms / 1000));
356→ 356→ const h = String(Math.floor(total / 3600)).padStart(2, "0");
357→ 357→ const m = String(Math.floor((total % 3600) / 60)).padStart(2, "0");
358→ 358→ const s = String(total % 60).padStart(2, "0");
359→ 359→ return `${h}:${m}:${s}`;
360→ 360→}
361→ 361→
362→ 362→// ===========================================================================
363→ 363→// 周挑战(WEEKLY CHALLENGE)— v0.8
364→ 364→// 每周一 UTC 0 点刷新一个"周挑战",目标更大、奖励更好,与日挑战并行存在。
365→ 365→// 同一周同一种子 → 同一挑战(确定性)。难度强制 anomaly/singular(加权 60/40)。
366→ 366→// ===========================================================================
367→ 367→
368→ 368→/** 周挑战 localStorage key */
369→ 369→export const BEACON_WEEKLY_KEY = "echo-nexus-beacon-weekly-v1";
370→ 370→
371→ 371→/** 周挑战定义 */
372→ 372→export interface BeaconWeeklyChallenge {
373→ 373→ /** ISO 周键(YYYY-Www,如 "2026-W26" */
374→ 374→ weekKey: string;
375→ 375→ /** 挑战类型 */
376→ 376→ type: BeaconChallengeType;
377→ 377→ /** 难度(固定 anomaly 或 singular */
378→ 378→ difficulty: "anomaly" | "singular";
379→ 379→ /** 目标数值(日挑战的 3-5 倍) */
380→ 380→ goal: number;
381→ 381→ /** 奖励:完成时洞见 */
382→ 382→ rewardInsight: number;
383→ 383→ /** 奖励:完成时接触进度 */
384→ 384→ rewardContact: number;
385→ 385→ /** 使用的种子(可复现) */
386→ 386→ seed: number;
387→ 387→ /** 友好标题 */
388→ 388→ title: string;
389→ 389→ /** 描述 */
390→ 390→ desc: string;
391→ 391→}
392→ 392→
393→ 393→/** 周挑战进度 */
394→ 394→export interface BeaconWeeklyProgress {
395→ 395→ weekKey: string;
396→ 396→ progress: number;
397→ 397→ startedAt: number;
398→ 398→ completedAt: number | null;
399→ 399→ claimed: boolean;
400→ 400→ durationSec: number;
401→ 401→}
402→ 402→
403→ 403→/**
404→ 404→ * 计算 ISO 8601 周键(YYYY-Www),如 "2026-W26"。
405→ 405→ * 规则:周一为一周开始;第一周是该年至少含 4 天的第一周(即包含第一个周四)。
406→ 406→ */
407→ 407→export function getWeekKey(now: Date = new Date()): string {
408→ 408→ // 取 UTC 日期,避免时区偏移
409→ 409→ const tmp = new Date(
410→ 410→ Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())
411→ 411→ );
412→ 412→ // ISO 周以周一为起点:(getUTCDay + 6) % 7 → 0=Mon, 6=Sun
413→ 413→ const dayNum = (tmp.getUTCDay() + 6) % 7;
414→ 414→ // 把日期调整到本周四(ISO 周归属年份由该周四所在的年份决定)
415→ 415→ tmp.setUTCDate(tmp.getUTCDate() - dayNum + 3);
416→ 416→ const isoYear = tmp.getUTCFullYear();
417→ 417→ const yearStart = Date.UTC(isoYear, 0, 1);
418→ 418→ const weekNum = Math.ceil(((tmp.getTime() - yearStart) / 86400000 + 1) / 7);
419→ 419→ return `${isoYear}-W${String(weekNum).padStart(2, "0")}`;
420→ 420→}
421→ 421→
422→ 422→/** weekKey → 数值种子(FNV-1a 哈希) */
423→ 423→function weekKeyToSeed(weekKey: string): number {
424→ 424→ let h = 2166136261 >>> 0;
425→ 425→ for (let i = 0; i < weekKey.length; i++) {
426→ 426→ h ^= weekKey.charCodeAt(i);
427→ 427→ h = Math.imul(h, 16777619) >>> 0;
428→ 428→ }
429→ 429→ return h >>> 0;
430→ 430→}
431→ 431→
432→ 432→/**
433→ 433→ * 生成本周周挑战(确定性:同一周同一种子 → 同一挑战)。
434→ 434→ * 难度强制 anomaly60%)或 singular40%),goal 为日挑战基准 ×3-5 倍。
435→ 435→ */
436→ 436→export function generateWeeklyChallenge(
437→ 437→ now: Date = new Date()
438→ 438→): BeaconWeeklyChallenge {
439→ 439→ const weekKey = getWeekKey(now);
440→ 440→ const seed = weekKeyToSeed(weekKey);
441→ 441→ const rng = mulberry32(seed);
442→ 442→
443→ 443→ const types: BeaconChallengeType[] = [
444→ 444→ "decode",
445→ 445→ "expedition",
446→ 446→ "pulse",
447→ 447→ "boss",
448→ 448→ "insight",
449→ 449→ ];
450→ 450→ const type = types[Math.floor(rng() * types.length)];
451→ 451→
452→ 452→ // 难度加权:anomaly 60% / singular 40%
453→ 453→ const dr = rng();
454→ 454→ const difficulty: "anomaly" | "singular" = dr < 0.6 ? "anomaly" : "singular";
455→ 455→ const diffMult = BEACON_DIFFICULTY[difficulty].mult;
456→ 456→
457→ 457→ // 3-5 倍
458→ 458→ const mult = 3 + Math.floor(rng() * 3);
459→ 459→
460→ 460→ let goal = 0;
461→ 461→ let rewardInsight = 0;
462→ 462→ let rewardContact = 0;
463→ 463→ let title = "";
464→ 464→ let desc = "";
465→ 465→
466→ 466→ switch (type) {
467→ 467→ case "decode":
468→ 468→ // 日基准 6-15 × mult(3-5) → 18-75
469→ 469→ goal = Math.max(18, Math.round((6 + Math.floor(rng() * 10)) * diffMult * mult));
470→ 470→ rewardInsight = Math.round(goal * 5 * diffMult);
471→ 471→ rewardContact = goal * 1.2;
472→ 472→ title = `周界解码 · ${goal} 颗晶体`;
473→ 473→ desc = `本周深空信标要求解码 ${goal} 颗记忆晶体,强度为日挑战的 ${mult} 倍。`;
474→ 474→ break;
475→ 475→ case "expedition":
476→ 476→ // 日基准 1-3 × mult(3-5) → 3-15
477→ 477→ goal = Math.max(3, Math.round((1 + Math.floor(rng() * 3)) * diffMult * mult));
478→ 478→ rewardInsight = Math.round(goal * 14 * diffMult);
479→ 479→ rewardContact = goal * 2;
480→ 480→ title = `周界远征 · ${goal} 次探险`;
481→ 481→ desc = `本周派出探险队完成 ${goal} 次远征,无论胜负均计入。`;
482→ 482→ break;
483→ 483→ case "pulse":
484→ 484→ // 日基准 20-49 × mult(3-5) → 60-245
485→ 485→ goal = Math.max(60, Math.round((20 + Math.floor(rng() * 30)) * diffMult * mult));
486→ 486→ rewardInsight = Math.round(goal * 1.5 * diffMult);
487→ 487→ rewardContact = goal * 0.4;
488→ 488→ title = `周界脉冲 · ${goal} 次扫描`;
489→ 489→ desc = `本周发起 ${goal} 次脉冲扫描。连击可叠加加成。`;
490→ 490→ break;
491→ 491→ case "boss":
492→ 492→ // 日基准 1 × mult(3-5) → 3-12singular mult=2.4 时上限 12
493→ 493→ goal = Math.max(3, Math.round(mult * diffMult));
494→ 494→ rewardInsight = Math.round(goal * 35 * diffMult);
495→ 495→ rewardContact = goal * 4;
496→ 496→ title = `周界猎杀 · ${goal} 处 BOSS`;
497→ 497→ desc = `本周在探险终点击破 ${goal} 处维度 BOSS。提升探险力后再挑战。`;
498→ 498→ break;
499→ 499→ case "insight":
500→ 500→ // 日基准 40-119 × mult(3-5) → 120-595
501→ 501→ goal = Math.max(120, Math.round((40 + Math.floor(rng() * 80)) * diffMult * mult));
502→ 502→ rewardInsight = 0; // 洞见挑战不给洞见,给接触
503→ 503→ rewardContact = goal * 0.15;
504→ 504→ title = `周界洞见 · ${goal} 点`;
505→ 505→ desc = `本周通过解码、探险、星潮等途径累计 ${goal} 洞见。`;
506→ 506→ break;
507→ 507→ }
508→ 508→
509→ 509→ return {
510→ 510→ weekKey,
511→ 511→ type,
512→ 512→ difficulty,
513→ 513→ goal,
514→ 514→ rewardInsight,
515→ 515→ rewardContact,
516→ 516→ seed,
517→ 517→ title,
518→ 518→ desc,
519→ 519→ };
520→ 520→}
521→ 521→
522→ 522→/** 读取本周进度(若 weekKey 不匹配则重置) */
523→ 523→export function loadWeeklyProgress(now: Date = new Date()): BeaconWeeklyProgress {
524→ 524→ const weekKey = getWeekKey(now);
525→ 525→ const empty: BeaconWeeklyProgress = {
526→ 526→ weekKey,
527→ 527→ progress: 0,
528→ 528→ startedAt: Date.now(),
529→ 529→ completedAt: null,
530→ 530→ claimed: false,
531→ 531→ durationSec: 0,
532→ 532→ };
533→ 533→ if (typeof localStorage === "undefined") return empty;
534→ 534→ try {
535→ 535→ const raw = localStorage.getItem(BEACON_WEEKLY_KEY);
536→ 536→ if (!raw) return empty;
537→ 537→ const prog = JSON.parse(raw) as BeaconWeeklyProgress;
538→ 538→ if (prog.weekKey !== weekKey) return empty; // 新的一周,重置
539→ 539→ return prog;
540→ 540→ } catch {
541→ 541→ return empty;
542→ 542→ }
543→ 543→}
544→ 544→
545→ 545→/** 保存本周进度 */
546→ 546→export function saveWeeklyProgress(prog: BeaconWeeklyProgress): void {
547→ 547→ if (typeof localStorage === "undefined") return;
548→ 548→ localStorage.setItem(BEACON_WEEKLY_KEY, JSON.stringify(prog));
549→ 549→}
550→ 550→
551→ 551→/** 增量更新周挑战进度,返回新进度 + 是否刚完成 */
552→ 552→export function addWeeklyProgress(
553→ 553→ current: BeaconWeeklyProgress,
554→ 554→ challenge: BeaconWeeklyChallenge,
555→ 555→ delta: number
556→ 556→): { progress: BeaconWeeklyProgress; justCompleted: boolean } {
557→ 557→ const newProgressVal = Math.min(challenge.goal, current.progress + delta);
558→ 558→ const justCompleted =
559→ 559→ current.completedAt === null && newProgressVal >= challenge.goal;
560→ 560→ const completedAt = justCompleted ? Date.now() : current.completedAt;
561→ 561→ const durationSec =
562→ 562→ completedAt !== null
563→ 563→ ? Math.floor((completedAt - current.startedAt) / 1000)
564→ 564→ : current.durationSec;
565→ 565→ const next: BeaconWeeklyProgress = {
566→ 566→ ...current,
567→ 567→ progress: newProgressVal,
568→ 568→ completedAt,
569→ 569→ durationSec,
570→ 570→ };
571→ 571→ saveWeeklyProgress(next);
572→ 572→ return { progress: next, justCompleted };
573→ 573→}
574→ 574→
575→ 575→/** 领取周挑战奖励:返回奖励数值 + 推送排行榜 */
576→ 576→export function claimWeeklyReward(
577→ 577→ challenge: BeaconWeeklyChallenge,
578→ 578→ progress: BeaconWeeklyProgress
579→ 579→): {
580→ 580→ rewardInsight: number;
581→ 581→ rewardContact: number;
582→ 582→ score: number;
583→ 583→ leaderboard: BeaconScoreEntry[];
584→ 584→} {
585→ 585→ if (progress.claimed || progress.completedAt === null) {
586→ 586→ return {
587→ 587→ rewardInsight: 0,
588→ 588→ rewardContact: 0,
589→ 589→ score: 0,
590→ 590→ leaderboard: loadLeaderboard(),
591→ 591→ };
592→ 592→ }
593→ 593→ // 借用 computeBeaconScore:把 weekly 包装成 daily 接口(dateKey 字段不影响计分逻辑)
594→ 594→ const score = computeBeaconScore(
595→ 595→ { ...(challenge as unknown as BeaconDailyChallenge), dateKey: challenge.weekKey },
596→ 596→ progress.progress,
597→ 597→ progress.durationSec
598→ 598→ );
599→ 599→ const entry: BeaconScoreEntry = {
600→ 600→ timestamp: Date.now(),
601→ 601→ dateKey: challenge.weekKey,
602→ 602→ challenge: challenge.type,
603→ 603→ difficulty: challenge.difficulty,
604→ 604→ progress: progress.progress / challenge.goal,
605→ 605→ score,
606→ 606→ durationSec: progress.durationSec,
607→ 607→ isWeekly: true,
608→ 608→ };
609→ 609→ const leaderboard = pushLeaderboardEntry(entry);
610→ 610→ const updated: BeaconWeeklyProgress = { ...progress, claimed: true };
611→ 611→ saveWeeklyProgress(updated);
612→ 612→ return {
613→ 613→ rewardInsight: challenge.rewardInsight,
614→ 614→ rewardContact: challenge.rewardContact,
615→ 615→ score,
616→ 616→ leaderboard,
617→ 617→ };
618→ 618→}
619→ 619→
620→ 620→/** 距离下周一 UTC 0 点的毫秒数(用于周挑战倒计时) */
621→ 621→export function msUntilNextWeek(now: Date = new Date()): number {
622→ 622→ const dayNum = (now.getUTCDay() + 6) % 7; // 0 = Mon
623→ 623→ const mondayThisWeek = Date.UTC(
624→ 624→ now.getUTCFullYear(),
625→ 625→ now.getUTCMonth(),
626→ 626→ now.getUTCDate() - dayNum,
627→ 627→ 0,
628→ 628→ 0,
629→ 629→ 0
630→ 630→ );
631→ 631→ const nextMonday = mondayThisWeek + 7 * 86400000;
632→ 632→ return Math.max(0, nextMonday - now.getTime());
633→ 633→}
634→ 634→
635→ 635→// ===========================================================================
636→ 636→// 信标链(BEACON CHAIN)— v0.8
637→ 637→// 连续完成日挑战形成"链",达成里程碑(3/7/14/30 天)领取递增大奖。
638→ 638→// 断链有宽容机制(1 天 miss 不断链,用"信标续命"概念),每条链只能用 1 次。
639→ 639→// ===========================================================================
640→ 640→
641→ 641→/** 信标链 localStorage key */
642→ 642→export const BEACON_CHAIN_KEY = "echo-nexus-beacon-chain-v1";
643→ 643→
644→ 644→/** 里程碑天数 */
645→ 645→export const BEACON_CHAIN_MILESTONES = [3, 7, 14, 30] as const;
646→ 646→
647→ 647→/** 里程碑奖励配置 */
648→ 648→export interface BeaconChainReward {
649→ 649→ milestone: number;
650→ 650→ rewardInsight: number;
651→ 651→ rewardContact: number;
652→ 652→ label: string;
653→ 653→}
654→ 654→
655→ 655→export const BEACON_CHAIN_REWARDS: BeaconChainReward[] = [
656→ 656→ { milestone: 3, rewardInsight: 50, rewardContact: 5, label: "三日谐振" },
657→ 657→ { milestone: 7, rewardInsight: 120, rewardContact: 12, label: "七日回响" },
658→ 658→ { milestone: 14, rewardInsight: 280, rewardContact: 28, label: "半月星潮" },
659→ 659→ { milestone: 30, rewardInsight: 680, rewardContact: 68, label: "满月飞升" },
660→ 660→];
661→ 661→
662→ 662→/** 信标链状态 */
663→ 663→export interface BeaconChainState {
664→ 664→ /** 上次完成日(YYYY-MM-DD */
665→ 665→ lastCompletedDateKey: string;
666→ 666→ /** 当前连续天数 */
667→ 667→ currentStreak: number;
668→ 668→ /** 历史最长 */
669→ 669→ longestStreak: number;
670→ 670→ /** 累计完成总数 */
671→ 671→ totalCompletions: number;
672→ 672→ /** 本周期已用续命数(上限 1) */
673→ 673→ graceUsed: number;
674→ 674→ /** 已领取的里程碑数组 */
675→ 675→ milestonesClaimed: number[];
676→ 676→}
677→ 677→
678→ 678→/** 读取信标链状态 */
679→ 679→export function loadChainState(): BeaconChainState {
680→ 680→ const fresh = (): BeaconChainState => ({
681→ 681→ lastCompletedDateKey: "",
682→ 682→ currentStreak: 0,
683→ 683→ longestStreak: 0,
684→ 684→ totalCompletions: 0,
685→ 685→ graceUsed: 0,
686→ 686→ milestonesClaimed: [],
687→ 687→ });
688→ 688→ if (typeof localStorage === "undefined") return fresh();
689→ 689→ try {
690→ 690→ const raw = localStorage.getItem(BEACON_CHAIN_KEY);
691→ 691→ if (!raw) return fresh();
692→ 692→ const s = JSON.parse(raw) as Partial<BeaconChainState>;
693→ 693→ return {
694→ 694→ lastCompletedDateKey: s.lastCompletedDateKey ?? "",
695→ 695→ currentStreak: s.currentStreak ?? 0,
696→ 696→ longestStreak: s.longestStreak ?? 0,
697→ 697→ totalCompletions: s.totalCompletions ?? 0,
698→ 698→ graceUsed: s.graceUsed ?? 0,
699→ 699→ milestonesClaimed: Array.isArray(s.milestonesClaimed)
700→ 700→ ? [...s.milestonesClaimed]
701→ 701→ : [],
702→ 702→ };
703→ 703→ } catch {
704→ 704→ return fresh();
705→ 705→ }
706→ 706→}
707→ 707→
708→ 708→/** 保存信标链状态 */
709→ 709→export function saveChainState(state: BeaconChainState): void {
710→ 710→ if (typeof localStorage === "undefined") return;
711→ 711→ localStorage.setItem(BEACON_CHAIN_KEY, JSON.stringify(state));
712→ 712→}
713→ 713→
714→ 714→/** dateKey → UTC 0 点时间戳 */
715→ 715→function dateKeyToTimestamp(dateKey: string): number {
716→ 716→ const [y, m, d] = dateKey.split("-").map(Number);
717→ 717→ return Date.UTC(y, m - 1, d, 0, 0, 0);
718→ 718→}
719→ 719→
720→ 720→/** 计算 b - a 相差的天数(UTC 0 点对齐) */
721→ 721→function dateKeyDiffDays(a: string, b: string): number {
722→ 722→ if (!a || !b) return Number.MAX_SAFE_INTEGER;
723→ 723→ return Math.round((dateKeyToTimestamp(b) - dateKeyToTimestamp(a)) / 86400000);
724→ 724→}
725→ 725→
726→ 726→/**
727→ 727→ * 记录一次日挑战完成(核心逻辑)。
728→ 728→ * - dateKey === lastCompletedDateKey:同一天重复完成,忽略,返回 newMilestones: []
729→ 729→ * - dateKey 是 lastCompletedDateKey 的次日:currentStreak++
730→ 730→ * - dateKey 是 lastCompletedDateKey 的后两天(隔了一天 miss)且 graceUsed < 1:续命一次,currentStreak++
731→ 731→ * - 其他:currentStreak = 1(断链重来),graceUsed = 0
732→ 732→ * 更新 longestStreak 与 totalCompletions。
733→ 733→ * @returns { state, newMilestones } 刚达成但未领取的里程碑数组
734→ 734→ */
735→ 735→export function recordChainCompletion(dateKey: string): {
736→ 736→ state: BeaconChainState;
737→ 737→ newMilestones: number[];
738→ 738→} {
739→ 739→ const state = loadChainState();
740→ 740→
741→ 741→ // 同一天重复完成:忽略
742→ 742→ if (state.lastCompletedDateKey === dateKey) {
743→ 743→ return { state, newMilestones: [] };
744→ 744→ }
745→ 745→
746→ 746→ let next: BeaconChainState;
747→ 747→
748→ 748→ if (state.lastCompletedDateKey === "") {
749→ 749→ // 首次完成
750→ 750→ next = {
751→ 751→ ...state,
752→ 752→ lastCompletedDateKey: dateKey,
753→ 753→ currentStreak: 1,
754→ 754→ longestStreak: Math.max(state.longestStreak, 1),
755→ 755→ totalCompletions: state.totalCompletions + 1,
756→ 756→ };
757→ 757→ } else {
758→ 758→ const diff = dateKeyDiffDays(state.lastCompletedDateKey, dateKey);
759→ 759→ if (diff === 1) {
760→ 760→ // 次日:链 +1
761→ 761→ const newStreak = state.currentStreak + 1;
762→ 762→ next = {
763→ 763→ ...state,
764→ 764→ lastCompletedDateKey: dateKey,
765→ 765→ currentStreak: newStreak,
766→ 766→ longestStreak: Math.max(state.longestStreak, newStreak),
767→ 767→ totalCompletions: state.totalCompletions + 1,
768→ 768→ };
769→ 769→ } else if (diff === 2 && state.graceUsed < 1) {
770→ 770→ // 隔一天 miss,续命一次
771→ 771→ const newStreak = state.currentStreak + 1;
772→ 772→ next = {
773→ 773→ ...state,
774→ 774→ lastCompletedDateKey: dateKey,
775→ 775→ currentStreak: newStreak,
776→ 776→ longestStreak: Math.max(state.longestStreak, newStreak),
777→ 777→ totalCompletions: state.totalCompletions + 1,
778→ 778→ graceUsed: state.graceUsed + 1,
779→ 779→ };
780→ 780→ } else {
781→ 781→ // 断链重来
782→ 782→ next = {
783→ 783→ ...state,
784→ 784→ lastCompletedDateKey: dateKey,
785→ 785→ currentStreak: 1,
786→ 786→ totalCompletions: state.totalCompletions + 1,
787→ 787→ graceUsed: 0,
788→ 788→ longestStreak: Math.max(state.longestStreak, 1),
789→ 789→ };
790→ 790→ }
791→ 791→ }
792→ 792→
793→ 793→ // 检查新里程碑(刚达成但未领取)
794→ 794→ const newMilestones: number[] = [];
795→ 795→ for (const m of BEACON_CHAIN_MILESTONES) {
796→ 796→ if (
797→ 797→ next.currentStreak >= m &&
798→ 798→ !next.milestonesClaimed.includes(m)
799→ 799→ ) {
800→ 800→ newMilestones.push(m);
801→ 801→ }
802→ 802→ }
803→ 803→
804→ 804→ saveChainState(next);
805→ 805→ return { state: next, newMilestones };
806→ 806→}
807→ 807→
808→ 808→/** 领取里程碑奖励,加入 milestonesClaimed */
809→ 809→export function claimChainMilestone(milestone: number): {
810→ 810→ rewardInsight: number;
811→ 811→ rewardContact: number;
812→ 812→ label: string;
813→ 813→ state: BeaconChainState;
814→ 814→} {
815→ 815→ const reward = BEACON_CHAIN_REWARDS.find((r) => r.milestone === milestone);
816→ 816→ const state = loadChainState();
817→ 817→ if (
818→ 818→ !reward ||
819→ 819→ state.milestonesClaimed.includes(milestone) ||
820→ 820→ state.currentStreak < milestone
821→ 821→ ) {
822→ 822→ return { rewardInsight: 0, rewardContact: 0, label: "", state };
823→ 823→ }
824→ 824→ const next: BeaconChainState = {
825→ 825→ ...state,
826→ 826→ milestonesClaimed: [...state.milestonesClaimed, milestone],
827→ 827→ };
828→ 828→ saveChainState(next);
829→ 829→ return {
830→ 830→ rewardInsight: reward.rewardInsight,
831→ 831→ rewardContact: reward.rewardContact,
832→ 832→ label: reward.label,
833→ 833→ state: next,
834→ 834→ };
835→ 835→}
836→ 836→
837→ 837→/** 返回下一个目标里程碑(如 streak=5 → 7streak=30+ → null */
838→ 838→export function getNextMilestone(streak: number): number | null {
839→ 839→ for (const m of BEACON_CHAIN_MILESTONES) {
840→ 840→ if (streak < m) return m;
841→ 841→ }
842→ 842→ return null;
843→ 843→}
844→ 844→
845→ 845→/**
846→ 846→ * 返回信标链进度信息(用于 UI 进度条)。
847→ 847→ * - current:当前连续天数
848→ 848→ * - next:下一个目标里程碑(null 表示已通关全部)
849→ 849→ * - progressPct:当前进度(基于上一个里程碑 → 下一个里程碑)
850→ 850→ */
851→ 851→export function getChainProgress(streak: number): {
852→ 852→ current: number;
853→ 853→ next: number | null;
854→ 854→ prev: number;
855→ 855→ progressPct: number;
856→ 856→} {
857→ 857→ const next = getNextMilestone(streak);
858→ 858→ let prev = 0;
859→ 859→ for (const m of BEACON_CHAIN_MILESTONES) {
860→ 860→ if (streak >= m) prev = m;
861→ 861→ }
862→ 862→ if (next === null) {
863→ 863→ return { current: streak, next: null, prev, progressPct: 100 };
864→ 864→ }
865→ 865→ const span = next - prev;
866→ 866→ const done = streak - prev;
867→ 867→ const pct = span > 0 ? Math.round((done / span) * 100) : 100;
868→ 868→ return {
869→ 869→ current: streak,
870→ 870→ next,
871→ 871→ prev,
872→ 872→ progressPct: Math.min(100, Math.max(0, pct)),
873→ 873→ };
874→ 874→}
875→ 875→
@@ -0,0 +1,754 @@
1→"use client";
2→// 回响星核 / Echo Nexus — 深空信标面板
3→// v0.5:每日挑战 + 本地排行榜
4→// v0.8:周挑战 + 信标链(连续完成奖励)
5→import { useState, useEffect, useCallback } from "react";
6→import { useGameStore } from "@/store/gameStore";
7→import { useToast } from "@/hooks/use-toast";
8→import { sfx } from "@/hooks/useAudio";
9→import {
10→ generateDailyChallenge,
11→ generateWeeklyChallenge,
12→ loadDailyProgress,
13→ loadWeeklyProgress,
14→ loadLeaderboard,
15→ loadChainState,
16→ claimBeaconReward,
17→ BEACON_DIFFICULTY,
18→ BEACON_TYPE_META,
19→ BEACON_CHAIN_MILESTONES,
20→ BEACON_CHAIN_REWARDS,
21→ getWeekKey,
22→ getTodayKey,
23→ getNextMilestone,
24→ getChainProgress,
25→ msUntilNextDay,
26→ msUntilNextWeek,
27→ formatCountdown,
28→ type BeaconDailyChallenge,
29→ type BeaconDailyProgress,
30→ type BeaconWeeklyChallenge,
31→ type BeaconWeeklyProgress,
32→ type BeaconScoreEntry,
33→ type BeaconChallengeType,
34→ type BeaconDifficulty,
35→ type BeaconChainState,
36→} from "@/lib/game/beacon";
37→import { formatNum } from "@/lib/game/config";
38→import { Button } from "@/components/ui/button";
39→import { Progress } from "@/components/ui/progress";
40→import {
41→ Radio,
42→ Clock,
43→ Trophy,
44→ Sparkles,
45→ Award,
46→ Crown,
47→ Medal,
48→ Link2,
49→ Flame,
50→ Zap,
51→} from "lucide-react";
52→
53→const DIFFICULTY_ORDER: BeaconDifficulty[] = ["routine", "anomaly", "singular"];
54→
55→function rankBadge(rank: number) {
56→ if (rank === 0) return { icon: Crown, color: "#fbbf24", label: "1st" };
57→ if (rank === 1) return { icon: Medal, color: "#cbd5e1", label: "2nd" };
58→ if (rank === 2) return { icon: Award, color: "#f97316", label: "3rd" };
59→ return null;
60→}
61→
62→export function BeaconPanel() {
63→ const grantBeaconReward = useGameStore((s) => s.grantBeaconReward);
64→ const claimWeeklyBeacon = useGameStore((s) => s.claimWeeklyBeacon);
65→ const claimChainReward = useGameStore((s) => s.claimChainReward);
66→ const { toast } = useToast();
67→
68→ const [challenge, setChallenge] = useState<BeaconDailyChallenge | null>(null);
69→ const [progress, setProgress] = useState<BeaconDailyProgress | null>(null);
70→ const [weeklyChallenge, setWeeklyChallenge] =
71→ useState<BeaconWeeklyChallenge | null>(null);
72→ const [weeklyProgress, setWeeklyProgress] =
73→ useState<BeaconWeeklyProgress | null>(null);
74→ const [chainState, setChainState] = useState<BeaconChainState | null>(null);
75→ const [leaderboard, setLeaderboard] = useState<BeaconScoreEntry[]>([]);
76→ const [dayCountdown, setDayCountdown] = useState("00:00:00");
77→ const [weekCountdown, setWeekCountdown] = useState("00:00:00");
78→ const [now, setNow] = useState(Date.now());
79→
80→ // 初始化 + 每秒刷新(进度 + 倒计时)
81→ useEffect(() => {
82→ setChallenge(generateDailyChallenge());
83→ setProgress(loadDailyProgress());
84→ setWeeklyChallenge(generateWeeklyChallenge());
85→ setWeeklyProgress(loadWeeklyProgress());
86→ setChainState(loadChainState());
87→ setLeaderboard(loadLeaderboard());
88→ const id = setInterval(() => {
89→ setNow(Date.now());
90→ setProgress(loadDailyProgress());
91→ setWeeklyProgress(loadWeeklyProgress());
92→ setChainState(loadChainState());
93→ setChallenge((c) => c ?? generateDailyChallenge());
94→ setWeeklyChallenge((c) => c ?? generateWeeklyChallenge());
95→ }, 1000);
96→ return () => clearInterval(id);
97→ }, []);
98→
99→ useEffect(() => {
100→ setDayCountdown(formatCountdown(msUntilNextDay(new Date(now))));
101→ setWeekCountdown(formatCountdown(msUntilNextWeek(new Date(now))));
102→ }, [now]);
103→
104→ const handleClaimDaily = useCallback(() => {
105→ if (!challenge || !progress) return;
106→ if (progress.completedAt === null || progress.claimed) return;
107→ const res = claimBeaconReward(challenge, progress);
108→ setLeaderboard(res.leaderboard);
109→ setProgress(loadDailyProgress());
110→ // 发放奖励到游戏状态
111→ grantBeaconReward(res.rewardInsight, res.rewardContact);
112→ sfx("achievement");
113→ toast({
114→ title: "✦ 每日信标奖励已领取",
115→ description: `+${res.rewardInsight} 洞见 · +${res.rewardContact.toFixed(
116→ 1
117→ )} 接触 · 得分 ${res.score}`,
118→ });
119→ }, [challenge, progress, toast, grantBeaconReward]);
120→
121→ const handleClaimWeekly = useCallback(() => {
122→ if (!weeklyChallenge || !weeklyProgress) return;
123→ if (weeklyProgress.completedAt === null || weeklyProgress.claimed) return;
124→ const res = claimWeeklyBeacon();
125→ setLeaderboard(loadLeaderboard());
126→ setWeeklyProgress(loadWeeklyProgress());
127→ if (res.rewardInsight > 0 || res.rewardContact > 0) {
128→ sfx("achievement");
129→ toast({
130→ title: "✦ 周挑战奖励已领取",
131→ description: `+${res.rewardInsight} 洞见 · +${res.rewardContact.toFixed(
132→ 1
133→ )} 接触 · 得分 ${res.score}`,
134→ });
135→ }
136→ }, [weeklyChallenge, weeklyProgress, toast, claimWeeklyBeacon]);
137→
138→ const handleClaimChain = useCallback(
139→ (milestone: number) => {
140→ const reward = BEACON_CHAIN_REWARDS.find(
141→ (r) => r.milestone === milestone
142→ );
143→ const res = claimChainReward(milestone);
144→ setChainState(loadChainState());
145→ if (res.ok) {
146→ sfx("achievement");
147→ toast({
148→ title: `✦ ${res.label || reward?.label || "里程碑"} 已领取`,
149→ description: `+${res.rewardInsight} 洞见 · +${res.rewardContact.toFixed(
150→ 1
151→ )} 接触`,
152→ });
153→ }
154→ },
155→ [toast, claimChainReward]
156→ );
157→
158→ if (!challenge || !progress || !weeklyChallenge || !weeklyProgress || !chainState) {
159→ return (
160→ <div className="flex items-center justify-center h-full text-xs text-muted-foreground/60">
161→ 正在校准深空信标…
162→ </div>
163→ );
164→ }
165→
166→ const diffMeta = BEACON_DIFFICULTY[challenge.difficulty];
167→ const typeMeta = BEACON_TYPE_META[challenge.type];
168→ const pct = Math.min(100, (progress.progress / challenge.goal) * 100);
169→ const isCompleted = progress.completedAt !== null;
170→ const isClaimed = progress.claimed;
171→ const canClaim = isCompleted && !isClaimed;
172→
173→ // 周挑战派生量
174→ const wDiffMeta = BEACON_DIFFICULTY[weeklyChallenge.difficulty];
175→ const wTypeMeta = BEACON_TYPE_META[weeklyChallenge.type];
176→ const wPct = Math.min(100, (weeklyProgress.progress / weeklyChallenge.goal) * 100);
177→ const wCompleted = weeklyProgress.completedAt !== null;
178→ const wClaimed = weeklyProgress.claimed;
179→ const wCanClaim = wCompleted && !wClaimed;
180→
181→ // 信标链派生量
182→ const chainProgress = getChainProgress(chainState.currentStreak);
183→ const nextMilestone = getNextMilestone(chainState.currentStreak);
184→ const todayKey = getTodayKey();
185→ const completedToday = chainState.lastCompletedDateKey === todayKey;
186→
187→ return (
188→ <div className="flex flex-col gap-2.5 h-full overflow-y-auto echo-scroll pr-0.5">
189→ <style jsx global>{`
190→ .echo-scroll::-webkit-scrollbar { width: 4px; }
191→ .echo-scroll::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.12); border-radius: 2px; }
192→ .echo-scroll::-webkit-scrollbar-track { background: transparent; }
193→ @keyframes beacon-pulse-ring {
194→ 0% { transform: scale(0.8); opacity: 0.8; }
195→ 100% { transform: scale(2.2); opacity: 0; }
196→ }
197→ @keyframes beacon-glow {
198→ 0%, 100% { box-shadow: 0 0 18px ${diffMeta.glow}, inset 0 0 12px ${diffMeta.glow}; }
199→ 50% { box-shadow: 0 0 32px ${diffMeta.glow}, inset 0 0 20px ${diffMeta.glow}; }
200→ }
201→ @keyframes weekly-glow {
202→ 0%, 100% { box-shadow: 0 0 18px rgba(232,121,249,0.35), inset 0 0 12px rgba(232,121,249,0.25); }
203→ 50% { box-shadow: 0 0 32px rgba(232,121,249,0.5), inset 0 0 20px rgba(232,121,249,0.35); }
204→ }
205→ @keyframes chain-milestone-pulse {
206→ 0%, 100% { transform: scale(1); box-shadow: 0 0 0 0 rgba(251,113,133,0.55); }
207→ 50% { transform: scale(1.06); box-shadow: 0 0 0 8px rgba(251,113,133,0); }
208→ }
209→ .chain-milestone-reachable {
210→ animation: chain-milestone-pulse 1.8s ease-in-out infinite;
211→ }
212→ @keyframes chain-streak-flux {
213→ 0%, 100% { background-position: 0% 50%; }
214→ 50% { background-position: 100% 50%; }
215→ }
216→ .chain-streak-text {
217→ background: linear-gradient(90deg, #fbbf24, #fb7185, #fbbf24);
218→ background-size: 200% 100%;
219→ -webkit-background-clip: text;
220→ background-clip: text;
221→ -webkit-text-fill-color: transparent;
222→ animation: chain-streak-flux 4s ease-in-out infinite;
223→ }
224→ `}</style>
225→
226→ {/* 头部:信标 + 倒计时 */}
227→ <div className="flex items-center justify-between">
228→ <h3 className="text-sm font-semibold flex items-center gap-1.5">
229→ <Radio className="h-4 w-4 text-fuchsia-400" />
230→ 深空信标
231→ </h3>
232→ <div className="flex items-center gap-1 text-[10px] text-muted-foreground/70">
233→ <Clock className="h-3 w-3" />
234→ 次日重置
235→ <span className="font-mono text-fuchsia-300/90">{dayCountdown}</span>
236→ </div>
237→ </div>
238→
239→ {/* 每日挑战卡片 */}
240→ <div
241→ className="relative rounded-xl border p-3 overflow-hidden"
242→ style={{
243→ borderColor: `${diffMeta.color}55`,
244→ background: `linear-gradient(135deg, ${diffMeta.color}1f, rgba(0,0,0,0.45))`,
245→ animation: isCompleted ? "none" : "beacon-glow 3s ease-in-out infinite",
246→ }}
247→ >
248→ {/* 背景装饰:脉冲环 */}
249→ {!isCompleted && (
250→ <>
251→ <div
252→ className="absolute top-4 right-4 h-10 w-10 rounded-full pointer-events-none"
253→ style={{
254→ border: `1.5px solid ${diffMeta.color}`,
255→ animation: "beacon-pulse-ring 2.5s ease-out infinite",
256→ }}
257→ />
258→ <div
259→ className="absolute top-4 right-4 h-10 w-10 rounded-full pointer-events-none"
260→ style={{
261→ border: `1.5px solid ${diffMeta.color}`,
262→ animation: "beacon-pulse-ring 2.5s ease-out infinite 1.25s",
263→ }}
264→ />
265→ </>
266→ )}
267→
268→ <div className="relative">
269→ {/* 难度 + 类型 标签 */}
270→ <div className="flex items-center gap-1.5 mb-2">
271→ <span
272→ className="text-[10px] font-mono px-1.5 py-0.5 rounded flex items-center gap-1"
273→ style={{ background: `${diffMeta.color}22`, color: diffMeta.color, border: `1px solid ${diffMeta.color}55` }}
274→ >
275→ <span>{diffMeta.icon}</span>
276→ {diffMeta.label}
277→ </span>
278→ <span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-white/5 text-muted-foreground border border-white/10">
279→ {typeMeta.icon} {typeMeta.label}
280→ </span>
281→ {isCompleted && (
282→ <span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-emerald-500/20 text-emerald-300 border border-emerald-500/40 flex items-center gap-1">
283→ <Sparkles className="h-2.5 w-2.5" /> 已完成
284→ </span>
285→ )}
286→ </div>
287→
288→ {/* 挑战标题 */}
289→ <h4 className="text-sm font-semibold mb-1" style={{ color: diffMeta.color, textShadow: `0 0 10px ${diffMeta.glow}` }}>
290→ {challenge.title}
291→ </h4>
292→ <p className="text-[11px] text-muted-foreground/80 leading-relaxed mb-2.5">
293→ {challenge.desc}
294→ </p>
295→
296→ {/* 进度条 */}
297→ <div className="mb-2">
298→ <div className="flex items-center justify-between mb-1">
299→ <span className="text-[10px] text-muted-foreground/70">进度</span>
300→ <span className="text-[11px] font-mono font-semibold" style={{ color: diffMeta.color }}>
301→ {Math.min(progress.progress, challenge.goal)} / {challenge.goal} {typeMeta.unit}
302→ </span>
303→ </div>
304→ <Progress
305→ value={pct}
306→ className="h-2 bg-black/40"
307→ style={{
308→ ["--progress-color" as string]: diffMeta.color,
309→ }}
310→ />
311→ </div>
312→
313→ {/* 奖励 + 领取按钮 */}
314→ <div className="flex items-center justify-between gap-2">
315→ <div className="flex items-center gap-2 text-[10px]">
316→ <span className="text-muted-foreground/60">奖励:</span>
317→ {challenge.rewardInsight > 0 && (
318→ <span className="text-amber-300 font-mono">+{formatNum(challenge.rewardInsight)}洞见</span>
319→ )}
320→ <span className="text-fuchsia-300 font-mono">+{challenge.rewardContact.toFixed(1)}接触</span>
321→ </div>
322→ <Button
323→ onClick={handleClaimDaily}
324→ disabled={!canClaim}
325→ size="sm"
326→ className="h-7 px-3 text-[11px] border-0"
327→ style={{
328→ background: canClaim
329→ ? `linear-gradient(90deg, ${diffMeta.color}, ${diffMeta.color}cc)`
330→ : `${diffMeta.color}1a`,
331→ color: canClaim ? "#022c22" : `${diffMeta.color}99`,
332→ }}
333→ >
334→ {isClaimed ? "已领取" : canClaim ? "领取奖励" : isCompleted ? "已领取" : "进行中…"}
335→ </Button>
336→ </div>
337→
338→ {/* 完成时长 */}
339→ {isCompleted && progress.durationSec > 0 && (
340→ <div className="mt-1.5 text-[10px] text-muted-foreground/50 text-center">
341→ 完成用时 {Math.floor(progress.durationSec / 60)}分{progress.durationSec % 60}秒
342→ </div>
343→ )}
344→ </div>
345→ </div>
346→
347→ {/* 周挑战 + 信标链(桌面端并排,移动端单列) */}
348→ <div className="grid grid-cols-1 lg:grid-cols-2 gap-2.5">
349→ {/* ===== 周挑战卡片(fuchsia 主题) ===== */}
350→ <div
351→ className="relative rounded-xl border p-3 overflow-hidden"
352→ style={{
353→ borderColor: "rgba(232,121,249,0.4)",
354→ background: `linear-gradient(135deg, rgba(232,121,249,0.12), rgba(0,0,0,0.45))`,
355→ animation: wCompleted ? "none" : "weekly-glow 3.5s ease-in-out infinite",
356→ }}
357→ >
358→ {/* 头部:标题 + weekKey + 倒计时 */}
359→ <div className="flex items-center justify-between mb-2">
360→ <div className="flex items-center gap-1.5">
361→ <Zap className="h-3.5 w-3.5 text-fuchsia-400" />
362→ <span className="text-xs font-semibold text-fuchsia-200">
363→ 周挑战 · WEEKLY
364→ </span>
365→ </div>
366→ <div className="flex items-center gap-1 text-[9px] text-muted-foreground/70">
367→ <span className="font-mono text-fuchsia-300/80">
368→ {weeklyChallenge.weekKey}
369→ </span>
370→ <Clock className="h-2.5 w-2.5" />
371→ <span className="font-mono text-fuchsia-300/90">{weekCountdown}</span>
372→ </div>
373→ </div>
374→
375→ {/* 难度 + 类型 标签 */}
376→ <div className="flex items-center gap-1.5 mb-2">
377→ <span
378→ className="text-[10px] font-mono px-1.5 py-0.5 rounded flex items-center gap-1"
379→ style={{ background: `${wDiffMeta.color}22`, color: wDiffMeta.color, border: `1px solid ${wDiffMeta.color}55` }}
380→ >
381→ <span>{wDiffMeta.icon}</span>
382→ {wDiffMeta.label}
383→ </span>
384→ <span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-white/5 text-muted-foreground border border-white/10">
385→ {wTypeMeta.icon} {wTypeMeta.label}
386→ </span>
387→ {wCompleted && (
388→ <span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-emerald-500/20 text-emerald-300 border border-emerald-500/40 flex items-center gap-1">
389→ <Sparkles className="h-2.5 w-2.5" /> 已完成
390→ </span>
391→ )}
392→ </div>
393→
394→ {/* 标题 */}
395→ <h4
396→ className="text-sm font-semibold mb-1"
397→ style={{ color: wDiffMeta.color, textShadow: `0 0 10px ${wDiffMeta.glow}` }}
398→ >
399→ {weeklyChallenge.title}
400→ </h4>
401→ <p className="text-[10px] text-muted-foreground/80 leading-relaxed mb-2">
402→ {weeklyChallenge.desc}
403→ </p>
404→
405→ {/* 进度条 */}
406→ <div className="mb-2">
407→ <div className="flex items-center justify-between mb-1">
408→ <span className="text-[10px] text-muted-foreground/70">进度</span>
409→ <span className="text-[11px] font-mono font-semibold" style={{ color: wDiffMeta.color }}>
410→ {Math.min(weeklyProgress.progress, weeklyChallenge.goal)} / {weeklyChallenge.goal} {wTypeMeta.unit}
411→ </span>
412→ </div>
413→ <Progress
414→ value={wPct}
415→ className="h-2 bg-black/40"
416→ style={{
417→ ["--progress-color" as string]: wDiffMeta.color,
418→ }}
419→ />
420→ </div>
421→
422→ {/* 奖励 + 领取按钮 */}
423→ <div className="flex items-center justify-between gap-2">
424→ <div className="flex items-center gap-1.5 text-[10px]">
425→ <span className="text-muted-foreground/60">奖励:</span>
426→ {weeklyChallenge.rewardInsight > 0 && (
427→ <span className="text-amber-300 font-mono">
428→ +{formatNum(weeklyChallenge.rewardInsight)}洞见
429→ </span>
430→ )}
431→ <span className="text-fuchsia-300 font-mono">
432→ +{weeklyChallenge.rewardContact.toFixed(1)}接触
433→ </span>
434→ </div>
435→ <Button
436→ onClick={handleClaimWeekly}
437→ disabled={!wCanClaim}
438→ size="sm"
439→ className="h-7 px-3 text-[11px] border-0"
440→ style={{
441→ background: wCanClaim
442→ ? `linear-gradient(90deg, #34d399, #34d399cc)`
443→ : "rgba(232,121,249,0.10)",
444→ color: wCanClaim
445→ ? "#022c22"
446→ : "rgba(232,121,249,0.55)",
447→ }}
448→ >
449→ {wClaimed
450→ ? "已领取"
451→ : wCanClaim
452→ ? "领取奖励"
453→ : wCompleted
454→ ? "已领取"
455→ : "进行中…"}
456→ </Button>
457→ </div>
458→
459→ {wCompleted && weeklyProgress.durationSec > 0 && (
460→ <div className="mt-1.5 text-[10px] text-muted-foreground/50 text-center">
461→ 完成用时 {Math.floor(weeklyProgress.durationSec / 60)}分{weeklyProgress.durationSec % 60}秒
462→ </div>
463→ )}
464→ </div>
465→
466→ {/* ===== 信标链卡片(amber→rose 渐变) ===== */}
467→ <div
468→ className="relative rounded-xl border p-3 overflow-hidden"
469→ style={{
470→ borderColor: "rgba(251,191,36,0.35)",
471→ background: `linear-gradient(135deg, rgba(251,191,36,0.10), rgba(251,113,133,0.10), rgba(0,0,0,0.4))`,
472→ }}
473→ >
474→ {/* 头部:标题 + 当前连续天数 */}
475→ <div className="flex items-center justify-between mb-2">
476→ <div className="flex items-center gap-1.5">
477→ <Link2 className="h-3.5 w-3.5 text-amber-400" />
478→ <span className="text-xs font-semibold text-amber-200">
479→ 信标链 · CHAIN
480→ </span>
481→ </div>
482→ <div className="flex items-baseline gap-1">
483→ <Flame className="h-3 w-3 text-rose-400" />
484→ <span className="text-[10px] text-muted-foreground/60">连续</span>
485→ <span className="chain-streak-text text-2xl font-bold font-mono leading-none">
486→ {chainState.currentStreak}
487→ </span>
488→ <span className="text-[10px] text-muted-foreground/60">天</span>
489→ </div>
490→ </div>
491→
492→ {/* 今日完成状态 */}
493→ <div className="mb-2 flex items-center justify-between">
494→ <span className="text-[10px] text-muted-foreground/70">
495→ {completedToday
496→ ? "今日已贡献"
497→ : "今日尚未完成日挑战"}
498→ </span>
499→ <span
500→ className={`text-[10px] font-mono px-1.5 py-0.5 rounded border ${
501→ completedToday
502→ ? "bg-emerald-500/15 text-emerald-300 border-emerald-500/30"
503→ : "bg-white/5 text-muted-foreground/70 border-white/10"
504→ }`}
505→ >
506→ {completedToday ? "✓ 已记录" : "○ 待完成"}
507→ </span>
508→ </div>
509→
510→ {/* 4 个里程碑节点横向排列 */}
511→ <div className="flex items-center justify-between mb-2 relative">
512→ {/* 节点之间的连线(背景灰) */}
513→ <div className="absolute top-6 left-[12.5%] right-[12.5%] h-[2px] bg-white/10" />
514→ {/* 已达成部分高亮(基于 prev→next 插值) */}
515→ {(() => {
516→ // 节点圆心水平位置(百分比)
517→ const MILESTONE_POS: Record<number, number> = {
518→ 0: 12.5,
519→ 3: 12.5,
520→ 7: 37.5,
521→ 14: 62.5,
522→ 30: 87.5,
523→ };
524→ const prevPos =
525→ MILESTONE_POS[chainProgress.prev] ?? 12.5;
526→ const nextPos =
527→ chainProgress.next !== null
528→ ? MILESTONE_POS[chainProgress.next] ?? 87.5
529→ : 87.5;
530→ const pct = chainProgress.progressPct / 100;
531→ const activeEndPos = prevPos + (nextPos - prevPos) * pct;
532→ const widthPct = Math.max(0, activeEndPos - 12.5);
533→ if (widthPct <= 0) return null;
534→ return (
535→ <div
536→ className="absolute top-6 h-[2px]"
537→ style={{
538→ left: "12.5%",
539→ width: `${widthPct}%`,
540→ background:
541→ "linear-gradient(90deg, #fbbf24, #fb7185)",
542→ boxShadow: "0 0 8px rgba(251,113,133,0.5)",
543→ }}
544→ />
545→ );
546→ })()}
547→
548→ {BEACON_CHAIN_MILESTONES.map((m) => {
549→ const reward = BEACON_CHAIN_REWARDS.find((r) => r.milestone === m);
550→ const claimed = chainState.milestonesClaimed.includes(m);
551→ const reachable =
552→ chainState.currentStreak >= m && !claimed;
553→ const inProgress = chainState.currentStreak > 0 && nextMilestone === m;
554→ // 节点配色
555→ let nodeBg = "rgba(255,255,255,0.05)";
556→ let nodeBorder = "rgba(255,255,255,0.15)";
557→ let textColor = "rgba(255,255,255,0.4)";
558→ if (claimed) {
559→ nodeBg = "rgba(52,211,153,0.25)";
560→ nodeBorder = "#34d399";
561→ textColor = "#34d399";
562→ } else if (reachable) {
563→ nodeBg = "rgba(251,113,133,0.20)";
564→ nodeBorder = "#fb7185";
565→ textColor = "#fb7185";
566→ } else if (inProgress) {
567→ nodeBg = "rgba(251,191,36,0.20)";
568→ nodeBorder = "#fbbf24";
569→ textColor = "#fbbf24";
570→ }
571→
572→ return (
573→ <div
574→ key={m}
575→ className="flex flex-col items-center gap-1 z-10 flex-1"
576→ >
577→ <div
578→ className={`w-12 h-12 rounded-full flex items-center justify-center font-mono font-bold text-sm border-2 ${
579→ reachable ? "chain-milestone-reachable" : ""
580→ }`}
581→ style={{
582→ background: nodeBg,
583→ borderColor: nodeBorder,
584→ color: textColor,
585→ }}
586→ >
587→ {claimed ? (
588→ <Sparkles className="h-4 w-4" />
589→ ) : (
590→ m
591→ )}
592→ </div>
593→ <span
594→ className="text-[9px] text-center leading-tight"
595→ style={{ color: textColor }}
596→ >
597→ {reward?.label}
598→ </span>
599→ {claimed ? (
600→ <span className="text-[9px] text-emerald-400/80 font-mono">
601→ 已领
602→ </span>
603→ ) : reachable ? (
604→ <Button
605→ onClick={() => handleClaimChain(m)}
606→ size="sm"
607→ className="h-5 px-2 text-[9px] py-0 border-0"
608→ style={{
609→ background:
610→ "linear-gradient(90deg, #fb7185, #f43f5e)",
611→ color: "#1c0608",
612→ }}
613→ >
614→ 领取
615→ </Button>
616→ ) : (
617→ <span className="text-[9px] text-muted-foreground/40 font-mono">
618→ +{reward?.rewardInsight}洞
619→ </span>
620→ )}
621→ </div>
622→ );
623→ })}
624→ </div>
625→
626→ {/* 进度条 */}
627→ <div className="mb-2">
628→ <div className="flex items-center justify-between mb-1">
629→ <span className="text-[10px] text-muted-foreground/70">
630→ {chainProgress.next === null
631→ ? "已通关全部里程碑"
632→ : `下一目标:${chainProgress.next} 天`}
633→ </span>
634→ <span className="text-[10px] font-mono text-amber-300">
635→ {chainProgress.current}
636→ {chainProgress.next !== null && ` / ${chainProgress.next}`} 天
637→ </span>
638→ </div>
639→ <Progress
640→ value={chainProgress.progressPct}
641→ className="h-1.5 bg-black/40"
642→ style={{
643→ ["--progress-color" as string]: "#fbbf24",
644→ }}
645→ />
646→ </div>
647→
648→ {/* 底部统计 */}
649→ <div className="flex items-center justify-between gap-2 text-[10px]">
650→ <div className="flex items-center gap-2">
651→ <span className="text-muted-foreground/60">最长</span>
652→ <span className="font-mono text-amber-300">
653→ {chainState.longestStreak}天
654→ </span>
655→ </div>
656→ <div className="flex items-center gap-2">
657→ <span className="text-muted-foreground/60">累计</span>
658→ <span className="font-mono text-rose-300">
659→ {chainState.totalCompletions}次
660→ </span>
661→ </div>
662→ <div className="flex items-center gap-1">
663→ <span className="text-muted-foreground/60">续命</span>
664→ {chainState.graceUsed >= 1 ? (
665→ <span className="font-mono text-rose-400/80">已用</span>
666→ ) : (
667→ <span className="font-mono text-emerald-400/80">可用</span>
668→ )}
669→ </div>
670→ </div>
671→ </div>
672→ </div>
673→
674→ {/* 本地排行榜 */}
675→ <div className="rounded-xl border border-white/10 bg-black/30 p-2.5">
676→ <div className="flex items-center gap-1.5 mb-2">
677→ <Trophy className="h-3.5 w-3.5 text-amber-400" />
678→ <span className="text-xs font-semibold">深空排行榜</span>
679→ <span className="text-[10px] text-muted-foreground/50">本地 · Top {leaderboard.length || 0}</span>
680→ </div>
681→
682→ {leaderboard.length === 0 ? (
683→ <div className="text-center py-4 text-[11px] text-muted-foreground/40">
684→ <Trophy className="h-6 w-6 mx-auto mb-1 opacity-30" />
685→ 尚无记录。完成今日或本周信标即可登榜。
686→ </div>
687→ ) : (
688→ <div className="space-y-0.5 max-h-[180px] overflow-y-auto echo-scroll">
689→ {leaderboard.map((entry, i) => {
690→ const rb = rankBadge(i);
691→ const eDiff = BEACON_DIFFICULTY[entry.difficulty];
692→ const eType = BEACON_TYPE_META[entry.challenge as BeaconChallengeType];
693→ const isMine = entry.dateKey === getTodayKey() || entry.dateKey === getWeekKey();
694→ return (
695→ <div
696→ key={`${entry.timestamp}-${i}`}
697→ className={`flex items-center gap-2 px-2 py-1 rounded-lg text-[11px] ${
698→ entry.isWeekly
699→ ? "bg-fuchsia-500/10 border border-fuchsia-500/20"
700→ : isMine
701→ ? "bg-fuchsia-500/10 border border-fuchsia-500/20"
702→ : "hover:bg-white/5"
703→ }`}
704→ >
705→ {/* 排名 */}
706→ <span className="w-6 flex items-center justify-center">
707→ {rb ? (
708→ <rb.icon className="h-3.5 w-3.5" style={{ color: rb.color }} />
709→ ) : (
710→ <span className="text-muted-foreground/50 font-mono">{i + 1}</span>
711→ )}
712→ </span>
713→ {/* 类型 + 难度 + 周挑战标记 */}
714→ <span className="flex items-center gap-1 flex-1 min-w-0">
715→ <span style={{ color: eDiff.color }} className="font-mono">{eDiff.icon}</span>
716→ <span className="text-muted-foreground/80 truncate">{eType.label}</span>
717→ {entry.isWeekly && (
718→ <span className="text-[9px] font-mono px-1 rounded bg-fuchsia-500/20 text-fuchsia-300 border border-fuchsia-500/40">
719→ WEEK
720→ </span>
721→ )}
722→ {entry.progress >= 1 && <span className="text-emerald-400/70">✓</span>}
723→ </span>
724→ {/* 用时 */}
725→ <span className="text-muted-foreground/50 font-mono text-[10px] w-16 text-right">
726→ {Math.floor(entry.durationSec / 60)}m{entry.durationSec % 60}s
727→ </span>
728→ {/* 分数 */}
729→ <span className="font-mono font-semibold w-12 text-right" style={{ color: eDiff.color }}>
730→ {formatNum(entry.score)}
731→ </span>
732→ </div>
733→ );
734→ })}
735→ </div>
736→ )}
737→ </div>
738→
739→ {/* 难度图例 */}
740→ <div className="flex items-center justify-center gap-3 text-[9px] text-muted-foreground/50">
741→ {DIFFICULTY_ORDER.map((d) => {
742→ const m = BEACON_DIFFICULTY[d];
743→ return (
744→ <span key={d} className="flex items-center gap-1">
745→ <span style={{ color: m.color }}>{m.icon}</span>
746→ {m.label} ×{m.mult}
747→ </span>
748→ );
749→ })}
750→ </div>
751→ </div>
752→ );
753→}
754→
@@ -0,0 +1,754 @@
1→"use client";
2→// 回响星核 / Echo Nexus — 深空信标面板
3→// v0.5:每日挑战 + 本地排行榜
4→// v0.8:周挑战 + 信标链(连续完成奖励)
5→import { useState, useEffect, useCallback } from "react";
6→import { useGameStore } from "@/store/gameStore";
7→import { useToast } from "@/hooks/use-toast";
8→import { sfx } from "@/hooks/useAudio";
9→import {
10→ generateDailyChallenge,
11→ generateWeeklyChallenge,
12→ loadDailyProgress,
13→ loadWeeklyProgress,
14→ loadLeaderboard,
15→ loadChainState,
16→ claimBeaconReward,
17→ BEACON_DIFFICULTY,
18→ BEACON_TYPE_META,
19→ BEACON_CHAIN_MILESTONES,
20→ BEACON_CHAIN_REWARDS,
21→ getWeekKey,
22→ getTodayKey,
23→ getNextMilestone,
24→ getChainProgress,
25→ msUntilNextDay,
26→ msUntilNextWeek,
27→ formatCountdown,
28→ type BeaconDailyChallenge,
29→ type BeaconDailyProgress,
30→ type BeaconWeeklyChallenge,
31→ type BeaconWeeklyProgress,
32→ type BeaconScoreEntry,
33→ type BeaconChallengeType,
34→ type BeaconDifficulty,
35→ type BeaconChainState,
36→} from "@/lib/game/beacon";
37→import { formatNum } from "@/lib/game/config";
38→import { Button } from "@/components/ui/button";
39→import { Progress } from "@/components/ui/progress";
40→import {
41→ Radio,
42→ Clock,
43→ Trophy,
44→ Sparkles,
45→ Award,
46→ Crown,
47→ Medal,
48→ Link2,
49→ Flame,
50→ Zap,
51→} from "lucide-react";
52→
53→const DIFFICULTY_ORDER: BeaconDifficulty[] = ["routine", "anomaly", "singular"];
54→
55→function rankBadge(rank: number) {
56→ if (rank === 0) return { icon: Crown, color: "#fbbf24", label: "1st" };
57→ if (rank === 1) return { icon: Medal, color: "#cbd5e1", label: "2nd" };
58→ if (rank === 2) return { icon: Award, color: "#f97316", label: "3rd" };
59→ return null;
60→}
61→
62→export function BeaconPanel() {
63→ const grantBeaconReward = useGameStore((s) => s.grantBeaconReward);
64→ const claimWeeklyBeacon = useGameStore((s) => s.claimWeeklyBeacon);
65→ const claimChainReward = useGameStore((s) => s.claimChainReward);
66→ const { toast } = useToast();
67→
68→ const [challenge, setChallenge] = useState<BeaconDailyChallenge | null>(null);
69→ const [progress, setProgress] = useState<BeaconDailyProgress | null>(null);
70→ const [weeklyChallenge, setWeeklyChallenge] =
71→ useState<BeaconWeeklyChallenge | null>(null);
72→ const [weeklyProgress, setWeeklyProgress] =
73→ useState<BeaconWeeklyProgress | null>(null);
74→ const [chainState, setChainState] = useState<BeaconChainState | null>(null);
75→ const [leaderboard, setLeaderboard] = useState<BeaconScoreEntry[]>([]);
76→ const [dayCountdown, setDayCountdown] = useState("00:00:00");
77→ const [weekCountdown, setWeekCountdown] = useState("00:00:00");
78→ const [now, setNow] = useState(Date.now());
79→
80→ // 初始化 + 每秒刷新(进度 + 倒计时)
81→ useEffect(() => {
82→ setChallenge(generateDailyChallenge());
83→ setProgress(loadDailyProgress());
84→ setWeeklyChallenge(generateWeeklyChallenge());
85→ setWeeklyProgress(loadWeeklyProgress());
86→ setChainState(loadChainState());
87→ setLeaderboard(loadLeaderboard());
88→ const id = setInterval(() => {
89→ setNow(Date.now());
90→ setProgress(loadDailyProgress());
91→ setWeeklyProgress(loadWeeklyProgress());
92→ setChainState(loadChainState());
93→ setChallenge((c) => c ?? generateDailyChallenge());
94→ setWeeklyChallenge((c) => c ?? generateWeeklyChallenge());
95→ }, 1000);
96→ return () => clearInterval(id);
97→ }, []);
98→
99→ useEffect(() => {
100→ setDayCountdown(formatCountdown(msUntilNextDay(new Date(now))));
101→ setWeekCountdown(formatCountdown(msUntilNextWeek(new Date(now))));
102→ }, [now]);
103→
104→ const handleClaimDaily = useCallback(() => {
105→ if (!challenge || !progress) return;
106→ if (progress.completedAt === null || progress.claimed) return;
107→ const res = claimBeaconReward(challenge, progress);
108→ setLeaderboard(res.leaderboard);
109→ setProgress(loadDailyProgress());
110→ // 发放奖励到游戏状态
111→ grantBeaconReward(res.rewardInsight, res.rewardContact);
112→ sfx("achievement");
113→ toast({
114→ title: "✦ 每日信标奖励已领取",
115→ description: `+${res.rewardInsight} 洞见 · +${res.rewardContact.toFixed(
116→ 1
117→ )} 接触 · 得分 ${res.score}`,
118→ });
119→ }, [challenge, progress, toast, grantBeaconReward]);
120→
121→ const handleClaimWeekly = useCallback(() => {
122→ if (!weeklyChallenge || !weeklyProgress) return;
123→ if (weeklyProgress.completedAt === null || weeklyProgress.claimed) return;
124→ const res = claimWeeklyBeacon();
125→ setLeaderboard(loadLeaderboard());
126→ setWeeklyProgress(loadWeeklyProgress());
127→ if (res.rewardInsight > 0 || res.rewardContact > 0) {
128→ sfx("achievement");
129→ toast({
130→ title: "✦ 周挑战奖励已领取",
131→ description: `+${res.rewardInsight} 洞见 · +${res.rewardContact.toFixed(
132→ 1
133→ )} 接触 · 得分 ${res.score}`,
134→ });
135→ }
136→ }, [weeklyChallenge, weeklyProgress, toast, claimWeeklyBeacon]);
137→
138→ const handleClaimChain = useCallback(
139→ (milestone: number) => {
140→ const reward = BEACON_CHAIN_REWARDS.find(
141→ (r) => r.milestone === milestone
142→ );
143→ const res = claimChainReward(milestone);
144→ setChainState(loadChainState());
145→ if (res.ok) {
146→ sfx("achievement");
147→ toast({
148→ title: `✦ ${res.label || reward?.label || "里程碑"} 已领取`,
149→ description: `+${res.rewardInsight} 洞见 · +${res.rewardContact.toFixed(
150→ 1
151→ )} 接触`,
152→ });
153→ }
154→ },
155→ [toast, claimChainReward]
156→ );
157→
158→ if (!challenge || !progress || !weeklyChallenge || !weeklyProgress || !chainState) {
159→ return (
160→ <div className="flex items-center justify-center h-full text-xs text-muted-foreground/60">
161→ 正在校准深空信标…
162→ </div>
163→ );
164→ }
165→
166→ const diffMeta = BEACON_DIFFICULTY[challenge.difficulty];
167→ const typeMeta = BEACON_TYPE_META[challenge.type];
168→ const pct = Math.min(100, (progress.progress / challenge.goal) * 100);
169→ const isCompleted = progress.completedAt !== null;
170→ const isClaimed = progress.claimed;
171→ const canClaim = isCompleted && !isClaimed;
172→
173→ // 周挑战派生量
174→ const wDiffMeta = BEACON_DIFFICULTY[weeklyChallenge.difficulty];
175→ const wTypeMeta = BEACON_TYPE_META[weeklyChallenge.type];
176→ const wPct = Math.min(100, (weeklyProgress.progress / weeklyChallenge.goal) * 100);
177→ const wCompleted = weeklyProgress.completedAt !== null;
178→ const wClaimed = weeklyProgress.claimed;
179→ const wCanClaim = wCompleted && !wClaimed;
180→
181→ // 信标链派生量
182→ const chainProgress = getChainProgress(chainState.currentStreak);
183→ const nextMilestone = getNextMilestone(chainState.currentStreak);
184→ const todayKey = getTodayKey();
185→ const completedToday = chainState.lastCompletedDateKey === todayKey;
186→
187→ return (
188→ <div className="flex flex-col gap-2.5 h-full overflow-y-auto echo-scroll pr-0.5">
189→ <style jsx global>{`
190→ .echo-scroll::-webkit-scrollbar { width: 4px; }
191→ .echo-scroll::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.12); border-radius: 2px; }
192→ .echo-scroll::-webkit-scrollbar-track { background: transparent; }
193→ @keyframes beacon-pulse-ring {
194→ 0% { transform: scale(0.8); opacity: 0.8; }
195→ 100% { transform: scale(2.2); opacity: 0; }
196→ }
197→ @keyframes beacon-glow {
198→ 0%, 100% { box-shadow: 0 0 18px ${diffMeta.glow}, inset 0 0 12px ${diffMeta.glow}; }
199→ 50% { box-shadow: 0 0 32px ${diffMeta.glow}, inset 0 0 20px ${diffMeta.glow}; }
200→ }
201→ @keyframes weekly-glow {
202→ 0%, 100% { box-shadow: 0 0 18px rgba(232,121,249,0.35), inset 0 0 12px rgba(232,121,249,0.25); }
203→ 50% { box-shadow: 0 0 32px rgba(232,121,249,0.5), inset 0 0 20px rgba(232,121,249,0.35); }
204→ }
205→ @keyframes chain-milestone-pulse {
206→ 0%, 100% { transform: scale(1); box-shadow: 0 0 0 0 rgba(251,113,133,0.55); }
207→ 50% { transform: scale(1.06); box-shadow: 0 0 0 8px rgba(251,113,133,0); }
208→ }
209→ .chain-milestone-reachable {
210→ animation: chain-milestone-pulse 1.8s ease-in-out infinite;
211→ }
212→ @keyframes chain-streak-flux {
213→ 0%, 100% { background-position: 0% 50%; }
214→ 50% { background-position: 100% 50%; }
215→ }
216→ .chain-streak-text {
217→ background: linear-gradient(90deg, #fbbf24, #fb7185, #fbbf24);
218→ background-size: 200% 100%;
219→ -webkit-background-clip: text;
220→ background-clip: text;
221→ -webkit-text-fill-color: transparent;
222→ animation: chain-streak-flux 4s ease-in-out infinite;
223→ }
224→ `}</style>
225→
226→ {/* 头部:信标 + 倒计时 */}
227→ <div className="flex items-center justify-between">
228→ <h3 className="text-sm font-semibold flex items-center gap-1.5">
229→ <Radio className="h-4 w-4 text-fuchsia-400" />
230→ 深空信标
231→ </h3>
232→ <div className="flex items-center gap-1 text-[10px] text-muted-foreground/70">
233→ <Clock className="h-3 w-3" />
234→ 次日重置
235→ <span className="font-mono text-fuchsia-300/90">{dayCountdown}</span>
236→ </div>
237→ </div>
238→
239→ {/* 每日挑战卡片 */}
240→ <div
241→ className="relative rounded-xl border p-3 overflow-hidden"
242→ style={{
243→ borderColor: `${diffMeta.color}55`,
244→ background: `linear-gradient(135deg, ${diffMeta.color}1f, rgba(0,0,0,0.45))`,
245→ animation: isCompleted ? "none" : "beacon-glow 3s ease-in-out infinite",
246→ }}
247→ >
248→ {/* 背景装饰:脉冲环 */}
249→ {!isCompleted && (
250→ <>
251→ <div
252→ className="absolute top-4 right-4 h-10 w-10 rounded-full pointer-events-none"
253→ style={{
254→ border: `1.5px solid ${diffMeta.color}`,
255→ animation: "beacon-pulse-ring 2.5s ease-out infinite",
256→ }}
257→ />
258→ <div
259→ className="absolute top-4 right-4 h-10 w-10 rounded-full pointer-events-none"
260→ style={{
261→ border: `1.5px solid ${diffMeta.color}`,
262→ animation: "beacon-pulse-ring 2.5s ease-out infinite 1.25s",
263→ }}
264→ />
265→ </>
266→ )}
267→
268→ <div className="relative">
269→ {/* 难度 + 类型 标签 */}
270→ <div className="flex items-center gap-1.5 mb-2">
271→ <span
272→ className="text-[10px] font-mono px-1.5 py-0.5 rounded flex items-center gap-1"
273→ style={{ background: `${diffMeta.color}22`, color: diffMeta.color, border: `1px solid ${diffMeta.color}55` }}
274→ >
275→ <span>{diffMeta.icon}</span>
276→ {diffMeta.label}
277→ </span>
278→ <span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-white/5 text-muted-foreground border border-white/10">
279→ {typeMeta.icon} {typeMeta.label}
280→ </span>
281→ {isCompleted && (
282→ <span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-emerald-500/20 text-emerald-300 border border-emerald-500/40 flex items-center gap-1">
283→ <Sparkles className="h-2.5 w-2.5" /> 已完成
284→ </span>
285→ )}
286→ </div>
287→
288→ {/* 挑战标题 */}
289→ <h4 className="text-sm font-semibold mb-1" style={{ color: diffMeta.color, textShadow: `0 0 10px ${diffMeta.glow}` }}>
290→ {challenge.title}
291→ </h4>
292→ <p className="text-[11px] text-muted-foreground/80 leading-relaxed mb-2.5">
293→ {challenge.desc}
294→ </p>
295→
296→ {/* 进度条 */}
297→ <div className="mb-2">
298→ <div className="flex items-center justify-between mb-1">
299→ <span className="text-[10px] text-muted-foreground/70">进度</span>
300→ <span className="text-[11px] font-mono font-semibold" style={{ color: diffMeta.color }}>
301→ {Math.min(progress.progress, challenge.goal)} / {challenge.goal} {typeMeta.unit}
302→ </span>
303→ </div>
304→ <Progress
305→ value={pct}
306→ className="h-2 bg-black/40"
307→ style={{
308→ ["--progress-color" as string]: diffMeta.color,
309→ }}
310→ />
311→ </div>
312→
313→ {/* 奖励 + 领取按钮 */}
314→ <div className="flex items-center justify-between gap-2">
315→ <div className="flex items-center gap-2 text-[10px]">
316→ <span className="text-muted-foreground/60">奖励:</span>
317→ {challenge.rewardInsight > 0 && (
318→ <span className="text-amber-300 font-mono">+{formatNum(challenge.rewardInsight)}洞见</span>
319→ )}
320→ <span className="text-fuchsia-300 font-mono">+{challenge.rewardContact.toFixed(1)}接触</span>
321→ </div>
322→ <Button
323→ onClick={handleClaimDaily}
324→ disabled={!canClaim}
325→ size="sm"
326→ className="h-7 px-3 text-[11px] border-0"
327→ style={{
328→ background: canClaim
329→ ? `linear-gradient(90deg, ${diffMeta.color}, ${diffMeta.color}cc)`
330→ : `${diffMeta.color}1a`,
331→ color: canClaim ? "#022c22" : `${diffMeta.color}99`,
332→ }}
333→ >
334→ {isClaimed ? "已领取" : canClaim ? "领取奖励" : isCompleted ? "已领取" : "进行中…"}
335→ </Button>
336→ </div>
337→
338→ {/* 完成时长 */}
339→ {isCompleted && progress.durationSec > 0 && (
340→ <div className="mt-1.5 text-[10px] text-muted-foreground/50 text-center">
341→ 完成用时 {Math.floor(progress.durationSec / 60)}分{progress.durationSec % 60}秒
342→ </div>
343→ )}
344→ </div>
345→ </div>
346→
347→ {/* 周挑战 + 信标链(桌面端并排,移动端单列) */}
348→ <div className="grid grid-cols-1 lg:grid-cols-2 gap-2.5">
349→ {/* ===== 周挑战卡片(fuchsia 主题) ===== */}
350→ <div
351→ className="relative rounded-xl border p-3 overflow-hidden"
352→ style={{
353→ borderColor: "rgba(232,121,249,0.4)",
354→ background: `linear-gradient(135deg, rgba(232,121,249,0.12), rgba(0,0,0,0.45))`,
355→ animation: wCompleted ? "none" : "weekly-glow 3.5s ease-in-out infinite",
356→ }}
357→ >
358→ {/* 头部:标题 + weekKey + 倒计时 */}
359→ <div className="flex items-center justify-between mb-2">
360→ <div className="flex items-center gap-1.5">
361→ <Zap className="h-3.5 w-3.5 text-fuchsia-400" />
362→ <span className="text-xs font-semibold text-fuchsia-200">
363→ 周挑战 · WEEKLY
364→ </span>
365→ </div>
366→ <div className="flex items-center gap-1 text-[9px] text-muted-foreground/70">
367→ <span className="font-mono text-fuchsia-300/80">
368→ {weeklyChallenge.weekKey}
369→ </span>
370→ <Clock className="h-2.5 w-2.5" />
371→ <span className="font-mono text-fuchsia-300/90">{weekCountdown}</span>
372→ </div>
373→ </div>
374→
375→ {/* 难度 + 类型 标签 */}
376→ <div className="flex items-center gap-1.5 mb-2">
377→ <span
378→ className="text-[10px] font-mono px-1.5 py-0.5 rounded flex items-center gap-1"
379→ style={{ background: `${wDiffMeta.color}22`, color: wDiffMeta.color, border: `1px solid ${wDiffMeta.color}55` }}
380→ >
381→ <span>{wDiffMeta.icon}</span>
382→ {wDiffMeta.label}
383→ </span>
384→ <span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-white/5 text-muted-foreground border border-white/10">
385→ {wTypeMeta.icon} {wTypeMeta.label}
386→ </span>
387→ {wCompleted && (
388→ <span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-emerald-500/20 text-emerald-300 border border-emerald-500/40 flex items-center gap-1">
389→ <Sparkles className="h-2.5 w-2.5" /> 已完成
390→ </span>
391→ )}
392→ </div>
393→
394→ {/* 标题 */}
395→ <h4
396→ className="text-sm font-semibold mb-1"
397→ style={{ color: wDiffMeta.color, textShadow: `0 0 10px ${wDiffMeta.glow}` }}
398→ >
399→ {weeklyChallenge.title}
400→ </h4>
401→ <p className="text-[10px] text-muted-foreground/80 leading-relaxed mb-2">
402→ {weeklyChallenge.desc}
403→ </p>
404→
405→ {/* 进度条 */}
406→ <div className="mb-2">
407→ <div className="flex items-center justify-between mb-1">
408→ <span className="text-[10px] text-muted-foreground/70">进度</span>
409→ <span className="text-[11px] font-mono font-semibold" style={{ color: wDiffMeta.color }}>
410→ {Math.min(weeklyProgress.progress, weeklyChallenge.goal)} / {weeklyChallenge.goal} {wTypeMeta.unit}
411→ </span>
412→ </div>
413→ <Progress
414→ value={wPct}
415→ className="h-2 bg-black/40"
416→ style={{
417→ ["--progress-color" as string]: wDiffMeta.color,
418→ }}
419→ />
420→ </div>
421→
422→ {/* 奖励 + 领取按钮 */}
423→ <div className="flex items-center justify-between gap-2">
424→ <div className="flex items-center gap-1.5 text-[10px]">
425→ <span className="text-muted-foreground/60">奖励:</span>
426→ {weeklyChallenge.rewardInsight > 0 && (
427→ <span className="text-amber-300 font-mono">
428→ +{formatNum(weeklyChallenge.rewardInsight)}洞见
429→ </span>
430→ )}
431→ <span className="text-fuchsia-300 font-mono">
432→ +{weeklyChallenge.rewardContact.toFixed(1)}接触
433→ </span>
434→ </div>
435→ <Button
436→ onClick={handleClaimWeekly}
437→ disabled={!wCanClaim}
438→ size="sm"
439→ className="h-7 px-3 text-[11px] border-0"
440→ style={{
441→ background: wCanClaim
442→ ? `linear-gradient(90deg, #34d399, #34d399cc)`
443→ : "rgba(232,121,249,0.10)",
444→ color: wCanClaim
445→ ? "#022c22"
446→ : "rgba(232,121,249,0.55)",
447→ }}
448→ >
449→ {wClaimed
450→ ? "已领取"
451→ : wCanClaim
452→ ? "领取奖励"
453→ : wCompleted
454→ ? "已领取"
455→ : "进行中…"}
456→ </Button>
457→ </div>
458→
459→ {wCompleted && weeklyProgress.durationSec > 0 && (
460→ <div className="mt-1.5 text-[10px] text-muted-foreground/50 text-center">
461→ 完成用时 {Math.floor(weeklyProgress.durationSec / 60)}分{weeklyProgress.durationSec % 60}秒
462→ </div>
463→ )}
464→ </div>
465→
466→ {/* ===== 信标链卡片(amber→rose 渐变) ===== */}
467→ <div
468→ className="relative rounded-xl border p-3 overflow-hidden"
469→ style={{
470→ borderColor: "rgba(251,191,36,0.35)",
471→ background: `linear-gradient(135deg, rgba(251,191,36,0.10), rgba(251,113,133,0.10), rgba(0,0,0,0.4))`,
472→ }}
473→ >
474→ {/* 头部:标题 + 当前连续天数 */}
475→ <div className="flex items-center justify-between mb-2">
476→ <div className="flex items-center gap-1.5">
477→ <Link2 className="h-3.5 w-3.5 text-amber-400" />
478→ <span className="text-xs font-semibold text-amber-200">
479→ 信标链 · CHAIN
480→ </span>
481→ </div>
482→ <div className="flex items-baseline gap-1">
483→ <Flame className="h-3 w-3 text-rose-400" />
484→ <span className="text-[10px] text-muted-foreground/60">连续</span>
485→ <span className="chain-streak-text text-2xl font-bold font-mono leading-none">
486→ {chainState.currentStreak}
487→ </span>
488→ <span className="text-[10px] text-muted-foreground/60">天</span>
489→ </div>
490→ </div>
491→
492→ {/* 今日完成状态 */}
493→ <div className="mb-2 flex items-center justify-between">
494→ <span className="text-[10px] text-muted-foreground/70">
495→ {completedToday
496→ ? "今日已贡献"
497→ : "今日尚未完成日挑战"}
498→ </span>
499→ <span
500→ className={`text-[10px] font-mono px-1.5 py-0.5 rounded border ${
501→ completedToday
502→ ? "bg-emerald-500/15 text-emerald-300 border-emerald-500/30"
503→ : "bg-white/5 text-muted-foreground/70 border-white/10"
504→ }`}
505→ >
506→ {completedToday ? "✓ 已记录" : "○ 待完成"}
507→ </span>
508→ </div>
509→
510→ {/* 4 个里程碑节点横向排列 */}
511→ <div className="flex items-center justify-between mb-2 relative">
512→ {/* 节点之间的连线(背景灰) */}
513→ <div className="absolute top-6 left-[12.5%] right-[12.5%] h-[2px] bg-white/10" />
514→ {/* 已达成部分高亮(基于 prev→next 插值) */}
515→ {(() => {
516→ // 节点圆心水平位置(百分比)
517→ const MILESTONE_POS: Record<number, number> = {
518→ 0: 12.5,
519→ 3: 12.5,
520→ 7: 37.5,
521→ 14: 62.5,
522→ 30: 87.5,
523→ };
524→ const prevPos =
525→ MILESTONE_POS[chainProgress.prev] ?? 12.5;
526→ const nextPos =
527→ chainProgress.next !== null
528→ ? MILESTONE_POS[chainProgress.next] ?? 87.5
529→ : 87.5;
530→ const pct = chainProgress.progressPct / 100;
531→ const activeEndPos = prevPos + (nextPos - prevPos) * pct;
532→ const widthPct = Math.max(0, activeEndPos - 12.5);
533→ if (widthPct <= 0) return null;
534→ return (
535→ <div
536→ className="absolute top-6 h-[2px]"
537→ style={{
538→ left: "12.5%",
539→ width: `${widthPct}%`,
540→ background:
541→ "linear-gradient(90deg, #fbbf24, #fb7185)",
542→ boxShadow: "0 0 8px rgba(251,113,133,0.5)",
543→ }}
544→ />
545→ );
546→ })()}
547→
548→ {BEACON_CHAIN_MILESTONES.map((m) => {
549→ const reward = BEACON_CHAIN_REWARDS.find((r) => r.milestone === m);
550→ const claimed = chainState.milestonesClaimed.includes(m);
551→ const reachable =
552→ chainState.currentStreak >= m && !claimed;
553→ const inProgress = chainState.currentStreak > 0 && nextMilestone === m;
554→ // 节点配色
555→ let nodeBg = "rgba(255,255,255,0.05)";
556→ let nodeBorder = "rgba(255,255,255,0.15)";
557→ let textColor = "rgba(255,255,255,0.4)";
558→ if (claimed) {
559→ nodeBg = "rgba(52,211,153,0.25)";
560→ nodeBorder = "#34d399";
561→ textColor = "#34d399";
562→ } else if (reachable) {
563→ nodeBg = "rgba(251,113,133,0.20)";
564→ nodeBorder = "#fb7185";
565→ textColor = "#fb7185";
566→ } else if (inProgress) {
567→ nodeBg = "rgba(251,191,36,0.20)";
568→ nodeBorder = "#fbbf24";
569→ textColor = "#fbbf24";
570→ }
571→
572→ return (
573→ <div
574→ key={m}
575→ className="flex flex-col items-center gap-1 z-10 flex-1"
576→ >
577→ <div
578→ className={`w-12 h-12 rounded-full flex items-center justify-center font-mono font-bold text-sm border-2 ${
579→ reachable ? "chain-milestone-reachable" : ""
580→ }`}
581→ style={{
582→ background: nodeBg,
583→ borderColor: nodeBorder,
584→ color: textColor,
585→ }}
586→ >
587→ {claimed ? (
588→ <Sparkles className="h-4 w-4" />
589→ ) : (
590→ m
591→ )}
592→ </div>
593→ <span
594→ className="text-[9px] text-center leading-tight"
595→ style={{ color: textColor }}
596→ >
597→ {reward?.label}
598→ </span>
599→ {claimed ? (
600→ <span className="text-[9px] text-emerald-400/80 font-mono">
601→ 已领
602→ </span>
603→ ) : reachable ? (
604→ <Button
605→ onClick={() => handleClaimChain(m)}
606→ size="sm"
607→ className="h-5 px-2 text-[9px] py-0 border-0"
608→ style={{
609→ background:
610→ "linear-gradient(90deg, #fb7185, #f43f5e)",
611→ color: "#1c0608",
612→ }}
613→ >
614→ 领取
615→ </Button>
616→ ) : (
617→ <span className="text-[9px] text-muted-foreground/40 font-mono">
618→ +{reward?.rewardInsight}洞
619→ </span>
620→ )}
621→ </div>
622→ );
623→ })}
624→ </div>
625→
626→ {/* 进度条 */}
627→ <div className="mb-2">
628→ <div className="flex items-center justify-between mb-1">
629→ <span className="text-[10px] text-muted-foreground/70">
630→ {chainProgress.next === null
631→ ? "已通关全部里程碑"
632→ : `下一目标:${chainProgress.next} 天`}
633→ </span>
634→ <span className="text-[10px] font-mono text-amber-300">
635→ {chainProgress.current}
636→ {chainProgress.next !== null && ` / ${chainProgress.next}`} 天
637→ </span>
638→ </div>
639→ <Progress
640→ value={chainProgress.progressPct}
641→ className="h-1.5 bg-black/40"
642→ style={{
643→ ["--progress-color" as string]: "#fbbf24",
644→ }}
645→ />
646→ </div>
647→
648→ {/* 底部统计 */}
649→ <div className="flex items-center justify-between gap-2 text-[10px]">
650→ <div className="flex items-center gap-2">
651→ <span className="text-muted-foreground/60">最长</span>
652→ <span className="font-mono text-amber-300">
653→ {chainState.longestStreak}天
654→ </span>
655→ </div>
656→ <div className="flex items-center gap-2">
657→ <span className="text-muted-foreground/60">累计</span>
658→ <span className="font-mono text-rose-300">
659→ {chainState.totalCompletions}次
660→ </span>
661→ </div>
662→ <div className="flex items-center gap-1">
663→ <span className="text-muted-foreground/60">续命</span>
664→ {chainState.graceUsed >= 1 ? (
665→ <span className="font-mono text-rose-400/80">已用</span>
666→ ) : (
667→ <span className="font-mono text-emerald-400/80">可用</span>
668→ )}
669→ </div>
670→ </div>
671→ </div>
672→ </div>
673→
674→ {/* 本地排行榜 */}
675→ <div className="rounded-xl border border-white/10 bg-black/30 p-2.5">
676→ <div className="flex items-center gap-1.5 mb-2">
677→ <Trophy className="h-3.5 w-3.5 text-amber-400" />
678→ <span className="text-xs font-semibold">深空排行榜</span>
679→ <span className="text-[10px] text-muted-foreground/50">本地 · Top {leaderboard.length || 0}</span>
680→ </div>
681→
682→ {leaderboard.length === 0 ? (
683→ <div className="text-center py-4 text-[11px] text-muted-foreground/40">
684→ <Trophy className="h-6 w-6 mx-auto mb-1 opacity-30" />
685→ 尚无记录。完成今日或本周信标即可登榜。
686→ </div>
687→ ) : (
688→ <div className="space-y-0.5 max-h-[180px] overflow-y-auto echo-scroll">
689→ {leaderboard.map((entry, i) => {
690→ const rb = rankBadge(i);
691→ const eDiff = BEACON_DIFFICULTY[entry.difficulty];
692→ const eType = BEACON_TYPE_META[entry.challenge as BeaconChallengeType];
693→ const isMine = entry.dateKey === getTodayKey() || entry.dateKey === getWeekKey();
694→ return (
695→ <div
696→ key={`${entry.timestamp}-${i}`}
697→ className={`flex items-center gap-2 px-2 py-1 rounded-lg text-[11px] ${
698→ entry.isWeekly
699→ ? "bg-fuchsia-500/10 border border-fuchsia-500/20"
700→ : isMine
701→ ? "bg-fuchsia-500/10 border border-fuchsia-500/20"
702→ : "hover:bg-white/5"
703→ }`}
704→ >
705→ {/* 排名 */}
706→ <span className="w-6 flex items-center justify-center">
707→ {rb ? (
708→ <rb.icon className="h-3.5 w-3.5" style={{ color: rb.color }} />
709→ ) : (
710→ <span className="text-muted-foreground/50 font-mono">{i + 1}</span>
711→ )}
712→ </span>
713→ {/* 类型 + 难度 + 周挑战标记 */}
714→ <span className="flex items-center gap-1 flex-1 min-w-0">
715→ <span style={{ color: eDiff.color }} className="font-mono">{eDiff.icon}</span>
716→ <span className="text-muted-foreground/80 truncate">{eType.label}</span>
717→ {entry.isWeekly && (
718→ <span className="text-[9px] font-mono px-1 rounded bg-fuchsia-500/20 text-fuchsia-300 border border-fuchsia-500/40">
719→ WEEK
720→ </span>
721→ )}
722→ {entry.progress >= 1 && <span className="text-emerald-400/70">✓</span>}
723→ </span>
724→ {/* 用时 */}
725→ <span className="text-muted-foreground/50 font-mono text-[10px] w-16 text-right">
726→ {Math.floor(entry.durationSec / 60)}m{entry.durationSec % 60}s
727→ </span>
728→ {/* 分数 */}
729→ <span className="font-mono font-semibold w-12 text-right" style={{ color: eDiff.color }}>
730→ {formatNum(entry.score)}
731→ </span>
732→ </div>
733→ );
734→ })}
735→ </div>
736→ )}
737→ </div>
738→
739→ {/* 难度图例 */}
740→ <div className="flex items-center justify-center gap-3 text-[9px] text-muted-foreground/50">
741→ {DIFFICULTY_ORDER.map((d) => {
742→ const m = BEACON_DIFFICULTY[d];
743→ return (
744→ <span key={d} className="flex items-center gap-1">
745→ <span style={{ color: m.color }}>{m.icon}</span>
746→ {m.label} ×{m.mult}
747→ </span>
748→ );
749→ })}
750→ </div>
751→ </div>
752→ );
753→}
754→
@@ -0,0 +1,754 @@
1→ 1→"use client";
2→ 2→// 回响星核 / Echo Nexus — 深空信标面板
3→ 3→// v0.5:每日挑战 + 本地排行榜
4→ 4→// v0.8:周挑战 + 信标链(连续完成奖励)
5→ 5→import { useState, useEffect, useCallback } from "react";
6→ 6→import { useGameStore } from "@/store/gameStore";
7→ 7→import { useToast } from "@/hooks/use-toast";
8→ 8→import { sfx } from "@/hooks/useAudio";
9→ 9→import {
10→ 10→ generateDailyChallenge,
11→ 11→ generateWeeklyChallenge,
12→ 12→ loadDailyProgress,
13→ 13→ loadWeeklyProgress,
14→ 14→ loadLeaderboard,
15→ 15→ loadChainState,
16→ 16→ claimBeaconReward,
17→ 17→ BEACON_DIFFICULTY,
18→ 18→ BEACON_TYPE_META,
19→ 19→ BEACON_CHAIN_MILESTONES,
20→ 20→ BEACON_CHAIN_REWARDS,
21→ 21→ getWeekKey,
22→ 22→ getTodayKey,
23→ 23→ getNextMilestone,
24→ 24→ getChainProgress,
25→ 25→ msUntilNextDay,
26→ 26→ msUntilNextWeek,
27→ 27→ formatCountdown,
28→ 28→ type BeaconDailyChallenge,
29→ 29→ type BeaconDailyProgress,
30→ 30→ type BeaconWeeklyChallenge,
31→ 31→ type BeaconWeeklyProgress,
32→ 32→ type BeaconScoreEntry,
33→ 33→ type BeaconChallengeType,
34→ 34→ type BeaconDifficulty,
35→ 35→ type BeaconChainState,
36→ 36→} from "@/lib/game/beacon";
37→ 37→import { formatNum } from "@/lib/game/config";
38→ 38→import { Button } from "@/components/ui/button";
39→ 39→import { Progress } from "@/components/ui/progress";
40→ 40→import {
41→ 41→ Radio,
42→ 42→ Clock,
43→ 43→ Trophy,
44→ 44→ Sparkles,
45→ 45→ Award,
46→ 46→ Crown,
47→ 47→ Medal,
48→ 48→ Link2,
49→ 49→ Flame,
50→ 50→ Zap,
51→ 51→} from "lucide-react";
52→ 52→
53→ 53→const DIFFICULTY_ORDER: BeaconDifficulty[] = ["routine", "anomaly", "singular"];
54→ 54→
55→ 55→function rankBadge(rank: number) {
56→ 56→ if (rank === 0) return { icon: Crown, color: "#fbbf24", label: "1st" };
57→ 57→ if (rank === 1) return { icon: Medal, color: "#cbd5e1", label: "2nd" };
58→ 58→ if (rank === 2) return { icon: Award, color: "#f97316", label: "3rd" };
59→ 59→ return null;
60→ 60→}
61→ 61→
62→ 62→export function BeaconPanel() {
63→ 63→ const grantBeaconReward = useGameStore((s) => s.grantBeaconReward);
64→ 64→ const claimWeeklyBeacon = useGameStore((s) => s.claimWeeklyBeacon);
65→ 65→ const claimChainReward = useGameStore((s) => s.claimChainReward);
66→ 66→ const { toast } = useToast();
67→ 67→
68→ 68→ const [challenge, setChallenge] = useState<BeaconDailyChallenge | null>(null);
69→ 69→ const [progress, setProgress] = useState<BeaconDailyProgress | null>(null);
70→ 70→ const [weeklyChallenge, setWeeklyChallenge] =
71→ 71→ useState<BeaconWeeklyChallenge | null>(null);
72→ 72→ const [weeklyProgress, setWeeklyProgress] =
73→ 73→ useState<BeaconWeeklyProgress | null>(null);
74→ 74→ const [chainState, setChainState] = useState<BeaconChainState | null>(null);
75→ 75→ const [leaderboard, setLeaderboard] = useState<BeaconScoreEntry[]>([]);
76→ 76→ const [dayCountdown, setDayCountdown] = useState("00:00:00");
77→ 77→ const [weekCountdown, setWeekCountdown] = useState("00:00:00");
78→ 78→ const [now, setNow] = useState(Date.now());
79→ 79→
80→ 80→ // 初始化 + 每秒刷新(进度 + 倒计时)
81→ 81→ useEffect(() => {
82→ 82→ setChallenge(generateDailyChallenge());
83→ 83→ setProgress(loadDailyProgress());
84→ 84→ setWeeklyChallenge(generateWeeklyChallenge());
85→ 85→ setWeeklyProgress(loadWeeklyProgress());
86→ 86→ setChainState(loadChainState());
87→ 87→ setLeaderboard(loadLeaderboard());
88→ 88→ const id = setInterval(() => {
89→ 89→ setNow(Date.now());
90→ 90→ setProgress(loadDailyProgress());
91→ 91→ setWeeklyProgress(loadWeeklyProgress());
92→ 92→ setChainState(loadChainState());
93→ 93→ setChallenge((c) => c ?? generateDailyChallenge());
94→ 94→ setWeeklyChallenge((c) => c ?? generateWeeklyChallenge());
95→ 95→ }, 1000);
96→ 96→ return () => clearInterval(id);
97→ 97→ }, []);
98→ 98→
99→ 99→ useEffect(() => {
100→ 100→ setDayCountdown(formatCountdown(msUntilNextDay(new Date(now))));
101→ 101→ setWeekCountdown(formatCountdown(msUntilNextWeek(new Date(now))));
102→ 102→ }, [now]);
103→ 103→
104→ 104→ const handleClaimDaily = useCallback(() => {
105→ 105→ if (!challenge || !progress) return;
106→ 106→ if (progress.completedAt === null || progress.claimed) return;
107→ 107→ const res = claimBeaconReward(challenge, progress);
108→ 108→ setLeaderboard(res.leaderboard);
109→ 109→ setProgress(loadDailyProgress());
110→ 110→ // 发放奖励到游戏状态
111→ 111→ grantBeaconReward(res.rewardInsight, res.rewardContact);
112→ 112→ sfx("achievement");
113→ 113→ toast({
114→ 114→ title: "✦ 每日信标奖励已领取",
115→ 115→ description: `+${res.rewardInsight} 洞见 · +${res.rewardContact.toFixed(
116→ 116→ 1
117→ 117→ )} 接触 · 得分 ${res.score}`,
118→ 118→ });
119→ 119→ }, [challenge, progress, toast, grantBeaconReward]);
120→ 120→
121→ 121→ const handleClaimWeekly = useCallback(() => {
122→ 122→ if (!weeklyChallenge || !weeklyProgress) return;
123→ 123→ if (weeklyProgress.completedAt === null || weeklyProgress.claimed) return;
124→ 124→ const res = claimWeeklyBeacon();
125→ 125→ setLeaderboard(loadLeaderboard());
126→ 126→ setWeeklyProgress(loadWeeklyProgress());
127→ 127→ if (res.rewardInsight > 0 || res.rewardContact > 0) {
128→ 128→ sfx("achievement");
129→ 129→ toast({
130→ 130→ title: "✦ 周挑战奖励已领取",
131→ 131→ description: `+${res.rewardInsight} 洞见 · +${res.rewardContact.toFixed(
132→ 132→ 1
133→ 133→ )} 接触 · 得分 ${res.score}`,
134→ 134→ });
135→ 135→ }
136→ 136→ }, [weeklyChallenge, weeklyProgress, toast, claimWeeklyBeacon]);
137→ 137→
138→ 138→ const handleClaimChain = useCallback(
139→ 139→ (milestone: number) => {
140→ 140→ const reward = BEACON_CHAIN_REWARDS.find(
141→ 141→ (r) => r.milestone === milestone
142→ 142→ );
143→ 143→ const res = claimChainReward(milestone);
144→ 144→ setChainState(loadChainState());
145→ 145→ if (res.ok) {
146→ 146→ sfx("achievement");
147→ 147→ toast({
148→ 148→ title: `✦ ${res.label || reward?.label || "里程碑"} 已领取`,
149→ 149→ description: `+${res.rewardInsight} 洞见 · +${res.rewardContact.toFixed(
150→ 150→ 1
151→ 151→ )} 接触`,
152→ 152→ });
153→ 153→ }
154→ 154→ },
155→ 155→ [toast, claimChainReward]
156→ 156→ );
157→ 157→
158→ 158→ if (!challenge || !progress || !weeklyChallenge || !weeklyProgress || !chainState) {
159→ 159→ return (
160→ 160→ <div className="flex items-center justify-center h-full text-xs text-muted-foreground/60">
161→ 161→ 正在校准深空信标…
162→ 162→ </div>
163→ 163→ );
164→ 164→ }
165→ 165→
166→ 166→ const diffMeta = BEACON_DIFFICULTY[challenge.difficulty];
167→ 167→ const typeMeta = BEACON_TYPE_META[challenge.type];
168→ 168→ const pct = Math.min(100, (progress.progress / challenge.goal) * 100);
169→ 169→ const isCompleted = progress.completedAt !== null;
170→ 170→ const isClaimed = progress.claimed;
171→ 171→ const canClaim = isCompleted && !isClaimed;
172→ 172→
173→ 173→ // 周挑战派生量
174→ 174→ const wDiffMeta = BEACON_DIFFICULTY[weeklyChallenge.difficulty];
175→ 175→ const wTypeMeta = BEACON_TYPE_META[weeklyChallenge.type];
176→ 176→ const wPct = Math.min(100, (weeklyProgress.progress / weeklyChallenge.goal) * 100);
177→ 177→ const wCompleted = weeklyProgress.completedAt !== null;
178→ 178→ const wClaimed = weeklyProgress.claimed;
179→ 179→ const wCanClaim = wCompleted && !wClaimed;
180→ 180→
181→ 181→ // 信标链派生量
182→ 182→ const chainProgress = getChainProgress(chainState.currentStreak);
183→ 183→ const nextMilestone = getNextMilestone(chainState.currentStreak);
184→ 184→ const todayKey = getTodayKey();
185→ 185→ const completedToday = chainState.lastCompletedDateKey === todayKey;
186→ 186→
187→ 187→ return (
188→ 188→ <div className="flex flex-col gap-2.5 h-full overflow-y-auto echo-scroll pr-0.5">
189→ 189→ <style jsx global>{`
190→ 190→ .echo-scroll::-webkit-scrollbar { width: 4px; }
191→ 191→ .echo-scroll::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.12); border-radius: 2px; }
192→ 192→ .echo-scroll::-webkit-scrollbar-track { background: transparent; }
193→ 193→ @keyframes beacon-pulse-ring {
194→ 194→ 0% { transform: scale(0.8); opacity: 0.8; }
195→ 195→ 100% { transform: scale(2.2); opacity: 0; }
196→ 196→ }
197→ 197→ @keyframes beacon-glow {
198→ 198→ 0%, 100% { box-shadow: 0 0 18px ${diffMeta.glow}, inset 0 0 12px ${diffMeta.glow}; }
199→ 199→ 50% { box-shadow: 0 0 32px ${diffMeta.glow}, inset 0 0 20px ${diffMeta.glow}; }
200→ 200→ }
201→ 201→ @keyframes weekly-glow {
202→ 202→ 0%, 100% { box-shadow: 0 0 18px rgba(232,121,249,0.35), inset 0 0 12px rgba(232,121,249,0.25); }
203→ 203→ 50% { box-shadow: 0 0 32px rgba(232,121,249,0.5), inset 0 0 20px rgba(232,121,249,0.35); }
204→ 204→ }
205→ 205→ @keyframes chain-milestone-pulse {
206→ 206→ 0%, 100% { transform: scale(1); box-shadow: 0 0 0 0 rgba(251,113,133,0.55); }
207→ 207→ 50% { transform: scale(1.06); box-shadow: 0 0 0 8px rgba(251,113,133,0); }
208→ 208→ }
209→ 209→ .chain-milestone-reachable {
210→ 210→ animation: chain-milestone-pulse 1.8s ease-in-out infinite;
211→ 211→ }
212→ 212→ @keyframes chain-streak-flux {
213→ 213→ 0%, 100% { background-position: 0% 50%; }
214→ 214→ 50% { background-position: 100% 50%; }
215→ 215→ }
216→ 216→ .chain-streak-text {
217→ 217→ background: linear-gradient(90deg, #fbbf24, #fb7185, #fbbf24);
218→ 218→ background-size: 200% 100%;
219→ 219→ -webkit-background-clip: text;
220→ 220→ background-clip: text;
221→ 221→ -webkit-text-fill-color: transparent;
222→ 222→ animation: chain-streak-flux 4s ease-in-out infinite;
223→ 223→ }
224→ 224→ `}</style>
225→ 225→
226→ 226→ {/* 头部:信标 + 倒计时 */}
227→ 227→ <div className="flex items-center justify-between">
228→ 228→ <h3 className="text-sm font-semibold flex items-center gap-1.5">
229→ 229→ <Radio className="h-4 w-4 text-fuchsia-400" />
230→ 230→ 深空信标
231→ 231→ </h3>
232→ 232→ <div className="flex items-center gap-1 text-[10px] text-muted-foreground/70">
233→ 233→ <Clock className="h-3 w-3" />
234→ 234→ 次日重置
235→ 235→ <span className="font-mono text-fuchsia-300/90">{dayCountdown}</span>
236→ 236→ </div>
237→ 237→ </div>
238→ 238→
239→ 239→ {/* 每日挑战卡片 */}
240→ 240→ <div
241→ 241→ className="relative rounded-xl border p-3 overflow-hidden"
242→ 242→ style={{
243→ 243→ borderColor: `${diffMeta.color}55`,
244→ 244→ background: `linear-gradient(135deg, ${diffMeta.color}1f, rgba(0,0,0,0.45))`,
245→ 245→ animation: isCompleted ? "none" : "beacon-glow 3s ease-in-out infinite",
246→ 246→ }}
247→ 247→ >
248→ 248→ {/* 背景装饰:脉冲环 */}
249→ 249→ {!isCompleted && (
250→ 250→ <>
251→ 251→ <div
252→ 252→ className="absolute top-4 right-4 h-10 w-10 rounded-full pointer-events-none"
253→ 253→ style={{
254→ 254→ border: `1.5px solid ${diffMeta.color}`,
255→ 255→ animation: "beacon-pulse-ring 2.5s ease-out infinite",
256→ 256→ }}
257→ 257→ />
258→ 258→ <div
259→ 259→ className="absolute top-4 right-4 h-10 w-10 rounded-full pointer-events-none"
260→ 260→ style={{
261→ 261→ border: `1.5px solid ${diffMeta.color}`,
262→ 262→ animation: "beacon-pulse-ring 2.5s ease-out infinite 1.25s",
263→ 263→ }}
264→ 264→ />
265→ 265→ </>
266→ 266→ )}
267→ 267→
268→ 268→ <div className="relative">
269→ 269→ {/* 难度 + 类型 标签 */}
270→ 270→ <div className="flex items-center gap-1.5 mb-2">
271→ 271→ <span
272→ 272→ className="text-[10px] font-mono px-1.5 py-0.5 rounded flex items-center gap-1"
273→ 273→ style={{ background: `${diffMeta.color}22`, color: diffMeta.color, border: `1px solid ${diffMeta.color}55` }}
274→ 274→ >
275→ 275→ <span>{diffMeta.icon}</span>
276→ 276→ {diffMeta.label}
277→ 277→ </span>
278→ 278→ <span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-white/5 text-muted-foreground border border-white/10">
279→ 279→ {typeMeta.icon} {typeMeta.label}
280→ 280→ </span>
281→ 281→ {isCompleted && (
282→ 282→ <span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-emerald-500/20 text-emerald-300 border border-emerald-500/40 flex items-center gap-1">
283→ 283→ <Sparkles className="h-2.5 w-2.5" /> 已完成
284→ 284→ </span>
285→ 285→ )}
286→ 286→ </div>
287→ 287→
288→ 288→ {/* 挑战标题 */}
289→ 289→ <h4 className="text-sm font-semibold mb-1" style={{ color: diffMeta.color, textShadow: `0 0 10px ${diffMeta.glow}` }}>
290→ 290→ {challenge.title}
291→ 291→ </h4>
292→ 292→ <p className="text-[11px] text-muted-foreground/80 leading-relaxed mb-2.5">
293→ 293→ {challenge.desc}
294→ 294→ </p>
295→ 295→
296→ 296→ {/* 进度条 */}
297→ 297→ <div className="mb-2">
298→ 298→ <div className="flex items-center justify-between mb-1">
299→ 299→ <span className="text-[10px] text-muted-foreground/70">进度</span>
300→ 300→ <span className="text-[11px] font-mono font-semibold" style={{ color: diffMeta.color }}>
301→ 301→ {Math.min(progress.progress, challenge.goal)} / {challenge.goal} {typeMeta.unit}
302→ 302→ </span>
303→ 303→ </div>
304→ 304→ <Progress
305→ 305→ value={pct}
306→ 306→ className="h-2 bg-black/40"
307→ 307→ style={{
308→ 308→ ["--progress-color" as string]: diffMeta.color,
309→ 309→ }}
310→ 310→ />
311→ 311→ </div>
312→ 312→
313→ 313→ {/* 奖励 + 领取按钮 */}
314→ 314→ <div className="flex items-center justify-between gap-2">
315→ 315→ <div className="flex items-center gap-2 text-[10px]">
316→ 316→ <span className="text-muted-foreground/60">奖励:</span>
317→ 317→ {challenge.rewardInsight > 0 && (
318→ 318→ <span className="text-amber-300 font-mono">+{formatNum(challenge.rewardInsight)}洞见</span>
319→ 319→ )}
320→ 320→ <span className="text-fuchsia-300 font-mono">+{challenge.rewardContact.toFixed(1)}接触</span>
321→ 321→ </div>
322→ 322→ <Button
323→ 323→ onClick={handleClaimDaily}
324→ 324→ disabled={!canClaim}
325→ 325→ size="sm"
326→ 326→ className="h-7 px-3 text-[11px] border-0"
327→ 327→ style={{
328→ 328→ background: canClaim
329→ 329→ ? `linear-gradient(90deg, ${diffMeta.color}, ${diffMeta.color}cc)`
330→ 330→ : `${diffMeta.color}1a`,
331→ 331→ color: canClaim ? "#022c22" : `${diffMeta.color}99`,
332→ 332→ }}
333→ 333→ >
334→ 334→ {isClaimed ? "已领取" : canClaim ? "领取奖励" : isCompleted ? "已领取" : "进行中…"}
335→ 335→ </Button>
336→ 336→ </div>
337→ 337→
338→ 338→ {/* 完成时长 */}
339→ 339→ {isCompleted && progress.durationSec > 0 && (
340→ 340→ <div className="mt-1.5 text-[10px] text-muted-foreground/50 text-center">
341→ 341→ 完成用时 {Math.floor(progress.durationSec / 60)}分{progress.durationSec % 60}秒
342→ 342→ </div>
343→ 343→ )}
344→ 344→ </div>
345→ 345→ </div>
346→ 346→
347→ 347→ {/* 周挑战 + 信标链(桌面端并排,移动端单列) */}
348→ 348→ <div className="grid grid-cols-1 lg:grid-cols-2 gap-2.5">
349→ 349→ {/* ===== 周挑战卡片(fuchsia 主题) ===== */}
350→ 350→ <div
351→ 351→ className="relative rounded-xl border p-3 overflow-hidden"
352→ 352→ style={{
353→ 353→ borderColor: "rgba(232,121,249,0.4)",
354→ 354→ background: `linear-gradient(135deg, rgba(232,121,249,0.12), rgba(0,0,0,0.45))`,
355→ 355→ animation: wCompleted ? "none" : "weekly-glow 3.5s ease-in-out infinite",
356→ 356→ }}
357→ 357→ >
358→ 358→ {/* 头部:标题 + weekKey + 倒计时 */}
359→ 359→ <div className="flex items-center justify-between mb-2">
360→ 360→ <div className="flex items-center gap-1.5">
361→ 361→ <Zap className="h-3.5 w-3.5 text-fuchsia-400" />
362→ 362→ <span className="text-xs font-semibold text-fuchsia-200">
363→ 363→ 周挑战 · WEEKLY
364→ 364→ </span>
365→ 365→ </div>
366→ 366→ <div className="flex items-center gap-1 text-[9px] text-muted-foreground/70">
367→ 367→ <span className="font-mono text-fuchsia-300/80">
368→ 368→ {weeklyChallenge.weekKey}
369→ 369→ </span>
370→ 370→ <Clock className="h-2.5 w-2.5" />
371→ 371→ <span className="font-mono text-fuchsia-300/90">{weekCountdown}</span>
372→ 372→ </div>
373→ 373→ </div>
374→ 374→
375→ 375→ {/* 难度 + 类型 标签 */}
376→ 376→ <div className="flex items-center gap-1.5 mb-2">
377→ 377→ <span
378→ 378→ className="text-[10px] font-mono px-1.5 py-0.5 rounded flex items-center gap-1"
379→ 379→ style={{ background: `${wDiffMeta.color}22`, color: wDiffMeta.color, border: `1px solid ${wDiffMeta.color}55` }}
380→ 380→ >
381→ 381→ <span>{wDiffMeta.icon}</span>
382→ 382→ {wDiffMeta.label}
383→ 383→ </span>
384→ 384→ <span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-white/5 text-muted-foreground border border-white/10">
385→ 385→ {wTypeMeta.icon} {wTypeMeta.label}
386→ 386→ </span>
387→ 387→ {wCompleted && (
388→ 388→ <span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-emerald-500/20 text-emerald-300 border border-emerald-500/40 flex items-center gap-1">
389→ 389→ <Sparkles className="h-2.5 w-2.5" /> 已完成
390→ 390→ </span>
391→ 391→ )}
392→ 392→ </div>
393→ 393→
394→ 394→ {/* 标题 */}
395→ 395→ <h4
396→ 396→ className="text-sm font-semibold mb-1"
397→ 397→ style={{ color: wDiffMeta.color, textShadow: `0 0 10px ${wDiffMeta.glow}` }}
398→ 398→ >
399→ 399→ {weeklyChallenge.title}
400→ 400→ </h4>
401→ 401→ <p className="text-[10px] text-muted-foreground/80 leading-relaxed mb-2">
402→ 402→ {weeklyChallenge.desc}
403→ 403→ </p>
404→ 404→
405→ 405→ {/* 进度条 */}
406→ 406→ <div className="mb-2">
407→ 407→ <div className="flex items-center justify-between mb-1">
408→ 408→ <span className="text-[10px] text-muted-foreground/70">进度</span>
409→ 409→ <span className="text-[11px] font-mono font-semibold" style={{ color: wDiffMeta.color }}>
410→ 410→ {Math.min(weeklyProgress.progress, weeklyChallenge.goal)} / {weeklyChallenge.goal} {wTypeMeta.unit}
411→ 411→ </span>
412→ 412→ </div>
413→ 413→ <Progress
414→ 414→ value={wPct}
415→ 415→ className="h-2 bg-black/40"
416→ 416→ style={{
417→ 417→ ["--progress-color" as string]: wDiffMeta.color,
418→ 418→ }}
419→ 419→ />
420→ 420→ </div>
421→ 421→
422→ 422→ {/* 奖励 + 领取按钮 */}
423→ 423→ <div className="flex items-center justify-between gap-2">
424→ 424→ <div className="flex items-center gap-1.5 text-[10px]">
425→ 425→ <span className="text-muted-foreground/60">奖励:</span>
426→ 426→ {weeklyChallenge.rewardInsight > 0 && (
427→ 427→ <span className="text-amber-300 font-mono">
428→ 428→ +{formatNum(weeklyChallenge.rewardInsight)}洞见
429→ 429→ </span>
430→ 430→ )}
431→ 431→ <span className="text-fuchsia-300 font-mono">
432→ 432→ +{weeklyChallenge.rewardContact.toFixed(1)}接触
433→ 433→ </span>
434→ 434→ </div>
435→ 435→ <Button
436→ 436→ onClick={handleClaimWeekly}
437→ 437→ disabled={!wCanClaim}
438→ 438→ size="sm"
439→ 439→ className="h-7 px-3 text-[11px] border-0"
440→ 440→ style={{
441→ 441→ background: wCanClaim
442→ 442→ ? `linear-gradient(90deg, #34d399, #34d399cc)`
443→ 443→ : "rgba(232,121,249,0.10)",
444→ 444→ color: wCanClaim
445→ 445→ ? "#022c22"
446→ 446→ : "rgba(232,121,249,0.55)",
447→ 447→ }}
448→ 448→ >
449→ 449→ {wClaimed
450→ 450→ ? "已领取"
451→ 451→ : wCanClaim
452→ 452→ ? "领取奖励"
453→ 453→ : wCompleted
454→ 454→ ? "已领取"
455→ 455→ : "进行中…"}
456→ 456→ </Button>
457→ 457→ </div>
458→ 458→
459→ 459→ {wCompleted && weeklyProgress.durationSec > 0 && (
460→ 460→ <div className="mt-1.5 text-[10px] text-muted-foreground/50 text-center">
461→ 461→ 完成用时 {Math.floor(weeklyProgress.durationSec / 60)}分{weeklyProgress.durationSec % 60}秒
462→ 462→ </div>
463→ 463→ )}
464→ 464→ </div>
465→ 465→
466→ 466→ {/* ===== 信标链卡片(amber→rose 渐变) ===== */}
467→ 467→ <div
468→ 468→ className="relative rounded-xl border p-3 overflow-hidden"
469→ 469→ style={{
470→ 470→ borderColor: "rgba(251,191,36,0.35)",
471→ 471→ background: `linear-gradient(135deg, rgba(251,191,36,0.10), rgba(251,113,133,0.10), rgba(0,0,0,0.4))`,
472→ 472→ }}
473→ 473→ >
474→ 474→ {/* 头部:标题 + 当前连续天数 */}
475→ 475→ <div className="flex items-center justify-between mb-2">
476→ 476→ <div className="flex items-center gap-1.5">
477→ 477→ <Link2 className="h-3.5 w-3.5 text-amber-400" />
478→ 478→ <span className="text-xs font-semibold text-amber-200">
479→ 479→ 信标链 · CHAIN
480→ 480→ </span>
481→ 481→ </div>
482→ 482→ <div className="flex items-baseline gap-1">
483→ 483→ <Flame className="h-3 w-3 text-rose-400" />
484→ 484→ <span className="text-[10px] text-muted-foreground/60">连续</span>
485→ 485→ <span className="chain-streak-text text-2xl font-bold font-mono leading-none">
486→ 486→ {chainState.currentStreak}
487→ 487→ </span>
488→ 488→ <span className="text-[10px] text-muted-foreground/60">天</span>
489→ 489→ </div>
490→ 490→ </div>
491→ 491→
492→ 492→ {/* 今日完成状态 */}
493→ 493→ <div className="mb-2 flex items-center justify-between">
494→ 494→ <span className="text-[10px] text-muted-foreground/70">
495→ 495→ {completedToday
496→ 496→ ? "今日已贡献"
497→ 497→ : "今日尚未完成日挑战"}
498→ 498→ </span>
499→ 499→ <span
500→ 500→ className={`text-[10px] font-mono px-1.5 py-0.5 rounded border ${
501→ 501→ completedToday
502→ 502→ ? "bg-emerald-500/15 text-emerald-300 border-emerald-500/30"
503→ 503→ : "bg-white/5 text-muted-foreground/70 border-white/10"
504→ 504→ }`}
505→ 505→ >
506→ 506→ {completedToday ? "✓ 已记录" : "○ 待完成"}
507→ 507→ </span>
508→ 508→ </div>
509→ 509→
510→ 510→ {/* 4 个里程碑节点横向排列 */}
511→ 511→ <div className="flex items-center justify-between mb-2 relative">
512→ 512→ {/* 节点之间的连线(背景灰) */}
513→ 513→ <div className="absolute top-6 left-[12.5%] right-[12.5%] h-[2px] bg-white/10" />
514→ 514→ {/* 已达成部分高亮(基于 prev→next 插值) */}
515→ 515→ {(() => {
516→ 516→ // 节点圆心水平位置(百分比)
517→ 517→ const MILESTONE_POS: Record<number, number> = {
518→ 518→ 0: 12.5,
519→ 519→ 3: 12.5,
520→ 520→ 7: 37.5,
521→ 521→ 14: 62.5,
522→ 522→ 30: 87.5,
523→ 523→ };
524→ 524→ const prevPos =
525→ 525→ MILESTONE_POS[chainProgress.prev] ?? 12.5;
526→ 526→ const nextPos =
527→ 527→ chainProgress.next !== null
528→ 528→ ? MILESTONE_POS[chainProgress.next] ?? 87.5
529→ 529→ : 87.5;
530→ 530→ const pct = chainProgress.progressPct / 100;
531→ 531→ const activeEndPos = prevPos + (nextPos - prevPos) * pct;
532→ 532→ const widthPct = Math.max(0, activeEndPos - 12.5);
533→ 533→ if (widthPct <= 0) return null;
534→ 534→ return (
535→ 535→ <div
536→ 536→ className="absolute top-6 h-[2px]"
537→ 537→ style={{
538→ 538→ left: "12.5%",
539→ 539→ width: `${widthPct}%`,
540→ 540→ background:
541→ 541→ "linear-gradient(90deg, #fbbf24, #fb7185)",
542→ 542→ boxShadow: "0 0 8px rgba(251,113,133,0.5)",
543→ 543→ }}
544→ 544→ />
545→ 545→ );
546→ 546→ })()}
547→ 547→
548→ 548→ {BEACON_CHAIN_MILESTONES.map((m) => {
549→ 549→ const reward = BEACON_CHAIN_REWARDS.find((r) => r.milestone === m);
550→ 550→ const claimed = chainState.milestonesClaimed.includes(m);
551→ 551→ const reachable =
552→ 552→ chainState.currentStreak >= m && !claimed;
553→ 553→ const inProgress = chainState.currentStreak > 0 && nextMilestone === m;
554→ 554→ // 节点配色
555→ 555→ let nodeBg = "rgba(255,255,255,0.05)";
556→ 556→ let nodeBorder = "rgba(255,255,255,0.15)";
557→ 557→ let textColor = "rgba(255,255,255,0.4)";
558→ 558→ if (claimed) {
559→ 559→ nodeBg = "rgba(52,211,153,0.25)";
560→ 560→ nodeBorder = "#34d399";
561→ 561→ textColor = "#34d399";
562→ 562→ } else if (reachable) {
563→ 563→ nodeBg = "rgba(251,113,133,0.20)";
564→ 564→ nodeBorder = "#fb7185";
565→ 565→ textColor = "#fb7185";
566→ 566→ } else if (inProgress) {
567→ 567→ nodeBg = "rgba(251,191,36,0.20)";
568→ 568→ nodeBorder = "#fbbf24";
569→ 569→ textColor = "#fbbf24";
570→ 570→ }
571→ 571→
572→ 572→ return (
573→ 573→ <div
574→ 574→ key={m}
575→ 575→ className="flex flex-col items-center gap-1 z-10 flex-1"
576→ 576→ >
577→ 577→ <div
578→ 578→ className={`w-12 h-12 rounded-full flex items-center justify-center font-mono font-bold text-sm border-2 ${
579→ 579→ reachable ? "chain-milestone-reachable" : ""
580→ 580→ }`}
581→ 581→ style={{
582→ 582→ background: nodeBg,
583→ 583→ borderColor: nodeBorder,
584→ 584→ color: textColor,
585→ 585→ }}
586→ 586→ >
587→ 587→ {claimed ? (
588→ 588→ <Sparkles className="h-4 w-4" />
589→ 589→ ) : (
590→ 590→ m
591→ 591→ )}
592→ 592→ </div>
593→ 593→ <span
594→ 594→ className="text-[9px] text-center leading-tight"
595→ 595→ style={{ color: textColor }}
596→ 596→ >
597→ 597→ {reward?.label}
598→ 598→ </span>
599→ 599→ {claimed ? (
600→ 600→ <span className="text-[9px] text-emerald-400/80 font-mono">
601→ 601→ 已领
602→ 602→ </span>
603→ 603→ ) : reachable ? (
604→ 604→ <Button
605→ 605→ onClick={() => handleClaimChain(m)}
606→ 606→ size="sm"
607→ 607→ className="h-5 px-2 text-[9px] py-0 border-0"
608→ 608→ style={{
609→ 609→ background:
610→ 610→ "linear-gradient(90deg, #fb7185, #f43f5e)",
611→ 611→ color: "#1c0608",
612→ 612→ }}
613→ 613→ >
614→ 614→ 领取
615→ 615→ </Button>
616→ 616→ ) : (
617→ 617→ <span className="text-[9px] text-muted-foreground/40 font-mono">
618→ 618→ +{reward?.rewardInsight}洞
619→ 619→ </span>
620→ 620→ )}
621→ 621→ </div>
622→ 622→ );
623→ 623→ })}
624→ 624→ </div>
625→ 625→
626→ 626→ {/* 进度条 */}
627→ 627→ <div className="mb-2">
628→ 628→ <div className="flex items-center justify-between mb-1">
629→ 629→ <span className="text-[10px] text-muted-foreground/70">
630→ 630→ {chainProgress.next === null
631→ 631→ ? "已通关全部里程碑"
632→ 632→ : `下一目标:${chainProgress.next} 天`}
633→ 633→ </span>
634→ 634→ <span className="text-[10px] font-mono text-amber-300">
635→ 635→ {chainProgress.current}
636→ 636→ {chainProgress.next !== null && ` / ${chainProgress.next}`} 天
637→ 637→ </span>
638→ 638→ </div>
639→ 639→ <Progress
640→ 640→ value={chainProgress.progressPct}
641→ 641→ className="h-1.5 bg-black/40"
642→ 642→ style={{
643→ 643→ ["--progress-color" as string]: "#fbbf24",
644→ 644→ }}
645→ 645→ />
646→ 646→ </div>
647→ 647→
648→ 648→ {/* 底部统计 */}
649→ 649→ <div className="flex items-center justify-between gap-2 text-[10px]">
650→ 650→ <div className="flex items-center gap-2">
651→ 651→ <span className="text-muted-foreground/60">最长</span>
652→ 652→ <span className="font-mono text-amber-300">
653→ 653→ {chainState.longestStreak}天
654→ 654→ </span>
655→ 655→ </div>
656→ 656→ <div className="flex items-center gap-2">
657→ 657→ <span className="text-muted-foreground/60">累计</span>
658→ 658→ <span className="font-mono text-rose-300">
659→ 659→ {chainState.totalCompletions}次
660→ 660→ </span>
661→ 661→ </div>
662→ 662→ <div className="flex items-center gap-1">
663→ 663→ <span className="text-muted-foreground/60">续命</span>
664→ 664→ {chainState.graceUsed >= 1 ? (
665→ 665→ <span className="font-mono text-rose-400/80">已用</span>
666→ 666→ ) : (
667→ 667→ <span className="font-mono text-emerald-400/80">可用</span>
668→ 668→ )}
669→ 669→ </div>
670→ 670→ </div>
671→ 671→ </div>
672→ 672→ </div>
673→ 673→
674→ 674→ {/* 本地排行榜 */}
675→ 675→ <div className="rounded-xl border border-white/10 bg-black/30 p-2.5">
676→ 676→ <div className="flex items-center gap-1.5 mb-2">
677→ 677→ <Trophy className="h-3.5 w-3.5 text-amber-400" />
678→ 678→ <span className="text-xs font-semibold">深空排行榜</span>
679→ 679→ <span className="text-[10px] text-muted-foreground/50">本地 · Top {leaderboard.length || 0}</span>
680→ 680→ </div>
681→ 681→
682→ 682→ {leaderboard.length === 0 ? (
683→ 683→ <div className="text-center py-4 text-[11px] text-muted-foreground/40">
684→ 684→ <Trophy className="h-6 w-6 mx-auto mb-1 opacity-30" />
685→ 685→ 尚无记录。完成今日或本周信标即可登榜。
686→ 686→ </div>
687→ 687→ ) : (
688→ 688→ <div className="space-y-0.5 max-h-[180px] overflow-y-auto echo-scroll">
689→ 689→ {leaderboard.map((entry, i) => {
690→ 690→ const rb = rankBadge(i);
691→ 691→ const eDiff = BEACON_DIFFICULTY[entry.difficulty];
692→ 692→ const eType = BEACON_TYPE_META[entry.challenge as BeaconChallengeType];
693→ 693→ const isMine = entry.dateKey === getTodayKey() || entry.dateKey === getWeekKey();
694→ 694→ return (
695→ 695→ <div
696→ 696→ key={`${entry.timestamp}-${i}`}
697→ 697→ className={`flex items-center gap-2 px-2 py-1 rounded-lg text-[11px] ${
698→ 698→ entry.isWeekly
699→ 699→ ? "bg-fuchsia-500/10 border border-fuchsia-500/20"
700→ 700→ : isMine
701→ 701→ ? "bg-fuchsia-500/10 border border-fuchsia-500/20"
702→ 702→ : "hover:bg-white/5"
703→ 703→ }`}
704→ 704→ >
705→ 705→ {/* 排名 */}
706→ 706→ <span className="w-6 flex items-center justify-center">
707→ 707→ {rb ? (
708→ 708→ <rb.icon className="h-3.5 w-3.5" style={{ color: rb.color }} />
709→ 709→ ) : (
710→ 710→ <span className="text-muted-foreground/50 font-mono">{i + 1}</span>
711→ 711→ )}
712→ 712→ </span>
713→ 713→ {/* 类型 + 难度 + 周挑战标记 */}
714→ 714→ <span className="flex items-center gap-1 flex-1 min-w-0">
715→ 715→ <span style={{ color: eDiff.color }} className="font-mono">{eDiff.icon}</span>
716→ 716→ <span className="text-muted-foreground/80 truncate">{eType.label}</span>
717→ 717→ {entry.isWeekly && (
718→ 718→ <span className="text-[9px] font-mono px-1 rounded bg-fuchsia-500/20 text-fuchsia-300 border border-fuchsia-500/40">
719→ 719→ WEEK
720→ 720→ </span>
721→ 721→ )}
722→ 722→ {entry.progress >= 1 && <span className="text-emerald-400/70">✓</span>}
723→ 723→ </span>
724→ 724→ {/* 用时 */}
725→ 725→ <span className="text-muted-foreground/50 font-mono text-[10px] w-16 text-right">
726→ 726→ {Math.floor(entry.durationSec / 60)}m{entry.durationSec % 60}s
727→ 727→ </span>
728→ 728→ {/* 分数 */}
729→ 729→ <span className="font-mono font-semibold w-12 text-right" style={{ color: eDiff.color }}>
730→ 730→ {formatNum(entry.score)}
731→ 731→ </span>
732→ 732→ </div>
733→ 733→ );
734→ 734→ })}
735→ 735→ </div>
736→ 736→ )}
737→ 737→ </div>
738→ 738→
739→ 739→ {/* 难度图例 */}
740→ 740→ <div className="flex items-center justify-center gap-3 text-[9px] text-muted-foreground/50">
741→ 741→ {DIFFICULTY_ORDER.map((d) => {
742→ 742→ const m = BEACON_DIFFICULTY[d];
743→ 743→ return (
744→ 744→ <span key={d} className="flex items-center gap-1">
745→ 745→ <span style={{ color: m.color }}>{m.icon}</span>
746→ 746→ {m.label} ×{m.mult}
747→ 747→ </span>
748→ 748→ );
749→ 749→ })}
750→ 750→ </div>
751→ 751→ </div>
752→ 752→ );
753→ 753→}
754→ 754→
@@ -0,0 +1,589 @@
1→# 回响星核 / Echo Nexus — 开发工作日志
2→
3→> 持续性任务的工作交接文档。每个阶段更新,避免进度丢失。
4→
5→---
6→
7→## 一、项目当前状态描述 / 判断
8→
9→### 概况
10→- **项目**:回响星核 / Echo Nexus — 深空考古放置策略游戏
11→- **一句话**:自治无人机采矿 → 解码记忆晶体(原创共振谜题)→ 拼凑碎片叙事 → 飞升多周目。差异化:解码谜题驱动叙事涌现,全息晶体 + 深空粒子美学。
12→- **当前版本****v0.7**CrystalOrb Canvas 粒子系统 + 角色属性系统)
13→- **在线游玩**https://gitea-pages.atdunbg.xyz/Super_Z/echo-nexus/
14→- **仓库**https://git.atdunbg.xyz/Super_Z/echo-nexus
15→- **技术栈**Next.js 16 (App Router) + TypeScript + Tailwind + shadcn/ui + Canvas 2D + Zustand + Web Audio API
16→- **定时任务**:每 15 分钟一次 `webDevReview``fixed_rate` + `"900"` 秒,priority=10job_id 228266)。正常完成不会被删除,无需自持续机制。
17→
18→### 状态判断
19→- dev 服务器运行正常(HTTP 200,编译 < 250ms
20→- v0.7 两大新功能已完成:CrystalOrb Canvas 粒子系统升级(VLM 8/10+ 角色属性系统(VLM 7/10
21→- 系统总体稳定,无阻塞性 bug,可继续推进新功能
22→
23→### 已完成版本里程碑(精简)
24→| 版本 | 核心内容 |
25→|------|---------|
26→| v0.1 | MVP:放置采矿 + 解码共振谜题 + 技术树 + 记忆图谱 + 飞升 + 统计 |
27→| v0.1.1 | 解码可解性修复(路径构造法生成器,100% 可解率)|
28→| v0.2 | 遗迹探险肉鸽系统(能量/探险力/6 节点/程序化路径/BOSS)|
29→| v0.2.1 | 程序化音频(15 种音效)+ 14 项成就 + 视觉打磨 |
30→| v0.3 | 星潮事件系统(6 种星潮 + 全屏叠层 + 程序化音效)|
31→| v0.3.1 | 星图天文台元进程(6 类 × 3 = 18 天赋 + 飞升后 3 选 1 draft + Canvas 星图)|
32→| v0.4 | 回响编年史(5 纪元循环命名 + 模板化叙事 + 时间轴 UI + 6 项新成就 + 飞升 BUG 修复)|
33→| v0.5 | 深空信标(每日挑战 + 本地排行榜 Top20 + 7 标签页 + 编年史历史 BUG 修复)|
34→| v0.5.1 | 静态部署 Gitea Pages + 新手教程系统(7 步聚光灯引导)+ UI 间距优化 |
35→| v0.5.2 | 离线收益报告弹窗 + 标签页 UI 重设计 + persist rehydrate 竞态修复 |
36→| v0.6 | 深空巡航 Canvas 2D 实时玩法 + UI 偏移重叠修复 |
37→| **v0.7** | **CrystalOrb Canvas 粒子系统升级 + 角色属性系统(四维)** |
38→
39→### 核心系统清单(8 大系统)
40→1. **放置采矿** — 中央晶体球 + 主动脉冲 + 连击加成(`CrystalOrb.tsx`
41→2. **谐振解码** — 原创路径谜题,路径构造法 100% 可解(`DecodeArray.tsx` + `engine.ts`
42→3. **技术树** — 三层科技解锁,提升产能/解码/探险(`TechTree.tsx`
43→4. **遗迹探险** — 肉鸽系统,6 种节点 + 程序化路径 + BOSS`ExpeditionPanel.tsx` + `expedition.ts`
44→5. **星潮事件** — 6 种动态事件,为放置循环注入变化(`StarTide.tsx` + `starTide.ts`
45→6. **星图天文台** — 飞升后 3 选 1 天赋 draft,跨周目永久生效,18 个天赋(`ConstellationPanel.tsx` + `constellation.ts`
46→7. **回响编年史** — 跨周目叙事时间轴,5 纪元循环命名(`ChronicleDialog.tsx` + `chronicle.ts`
47→8. **深空信标** — 每日挑战 + 本地排行榜 Top20`BeaconPanel.tsx` + `beacon.ts`
48→9. **深空巡航** — Canvas 2D 实时玩法,飞船操控/陨石躲避/星门通关(`CruiseMode.tsx` + `cruise.ts`)【v0.6】
49→10. **角色属性系统** — 四维属性(探索力/智慧/勇气/灵感),飞升获得属性点,影响所有系统(`AttributesPanel.tsx` + `attributes.ts`)【v0.7 新增】
50→
51→---
52→
53→## 二、当前目标 / 已完成的修改 / 验证结果
54→
55→### v0.7 CrystalOrb Canvas 粒子系统升级(已完成)
56→**动机**:P0 优先级 — 将中央晶体球从 CSS 动画升级为 Canvas 粒子视觉盛宴,提升视觉震撼。
57→
58→**重写文件**`src/components/game/CrystalOrb.tsx`
59→- 从纯 CSS 动画升级为 Canvas 2D 粒子系统
60→- **多层粒子**
61→ - 环绕能量粒子(3层14/10/8个,emerald/fuchsia/rose三色,不同速度方向环绕晶核,带拖尾)
62→ - 环境星尘(40个,缓慢漂移 + 闪烁)
63→ - 点击爆发粒子(10-30个径向发散,颜色随连击等级变色)
64→ - 冲击波环(点击触发1-3层错峰扩散,连击≥3/5解锁更多层)
65→- **晶核升级**:径向渐变辉光 + 3层六边形纹理旋转 + 高光 + 鼠标视差偏移
66→- **呼吸动画**:晶核 + 外层辉光随时间呼吸(sin波)
67→- **进度环**SVG渐变环(emerald→fuchsia→rose)保留
68→- **交互保留**:点击脉冲、连击Toast、浮动数字、音效、`data-tut="crystal-orb"` 锚点
69→- **性能**DPR cap 2ResizeObserver 自适应,requestAnimationFrame 60fps
70→
71→**QA 验证**agent-browser + VLM):
72→- VLM 视觉评分 **8/10**:晶体球清晰可见,多层辉光,环绕粒子动态自然,无渲染问题
73→- 点击交互:冲击波环 ✅ + 爆发粒子 ✅,评分 7/10
74→- lint 零错误;HTTP 200
75→
76→### v0.7 角色属性系统(已完成)
77→**动机**:P1 优先级 — 增加RPG深度,四维属性影响所有游戏系统。
78→
79→**新增文件**
80→- `src/lib/game/attributes.ts`~330行逻辑层):CharacterAttributes/AttributeProgress 类型、ATTRIBUTE_CONFIG 元信息、getAttributeBonus 两区加成公式(0-50线性+0.5%/点,50-100递减+0.2%/点)、getAllBonuses 12项修饰器、levelUpCheck 自动升级、computePrestigeAttrPoints、migrateAttributes 旧存档兼容
81→- `src/components/game/AttributesPanel.tsx`~330行 UI):四维卡片网格(小屏2×2/大屏1×4)、图标+中英文名+Lv.badge+数值/100+加成%+经验条+影响列表+分配按钮、顶部待分配点数badge闪烁、底部总览+12项修饰器明细
82→
83→**修改文件**
84→- `types.ts`GameState 新增 attributes/attributeProgress/pendingAttrPoints
85→- `config.ts`INITIAL_STATE 补全默认值
86→- `engine.ts`recomputeStats 聚合属性加成;performPrestige 发放属性点(ascensions×2+1
87→- `gameStore.ts`:新增 allocateAttribute/gainAttributeExp actionspulse/clickNode/autoDecodeTick/resolveCurrentNode/grantCruiseReward/tickTide 全部接入属性经验;init() 调用 migrateAttributes
88→- `achievements.ts`:新增 ach_attr_total_50 + ach_attr_max_100 两项成就
89→- `page.tsx`grid-cols-7→8,新增「角色」标签页(User图标),红点提示,StatsPanel新增5行属性行,版本号 v0.6→v0.7
90→
91→**四维属性设计**
92→- 探索力(emerald):探险力+X%/巡航飞船速度+X%
93→- 智慧(fuchsia):解码步数+X/洞见+X%/自动解码周期-X%
94→- 勇气(amber):探险生命+X/BOSS胜率+X%/巡航护盾+X
95→- 灵感(rose):接触率+X/星潮概率+X/脉冲连击+X%
96→
97→**QA 验证**agent-browser + VLM):
98→- VLM 视觉评分 **7/10**(面板)/ **8/10**(待分配状态):四维卡片可见、数值/等级/加成/经验条齐全、四色全息
99→- 功能验证:setState pendingAttrPoints=5 → 显示5点待分配 → 点击分配按钮 → exploration 0→1, pending 5→4 ✅
100→- lint 零错误;HTTP 200
101→
102→---
103→
104→### v0.6 深空巡航 Canvas 2D 实时玩法(历史记录)
105→**动机**:回应用户反馈「玩法太单调(全是点点点)」+「视觉震撼不足」——首次引入实时操作玩法。
106→
107→**新增文件**
108→- `src/lib/game/cruise.ts`~520 行逻辑层)
109→ - 类型系统:CruiseEntity 联合类型(ship/asteroid/storm/crystal/insight/beacon/stargate/particle 共 8 种)
110→ - mulberry32 + FNV-1a 种子化 RNG`cruiseSeed(level)`
111→ - `generateLevel`:程序化生成陨石带(8+lvl×2)、虚空风暴(1+lvl/2)、晶体碎片(5+lvl)、洞见光球(2+lvl/3)、信标(1+lvl/4)、星门
112→ - `updateCruise`:物理推进 + 碰撞检测 + 粒子系统 + 震动 + 600ms 无敌帧
113→ - `computeRewards`crystals=碎片×(8+lvl×2)、insights=洞见×(2+lvl×0.5)、contact=信标×(1.5+lvl×0.3);通关×1.5、失败×0.5
114→ - 独立 localStorage `echo-nexus-cruise-v1`:最高分/累计通关/最高关卡/累计奖励/最近 20 局
115→
116→- `src/components/game/CruiseMode.tsx`~830 行渲染层)
117→ - 全屏 fixed inset-0 z-50 CanvasDPR cap 2resize 监听
118→ - 3 层视差星空:远(100星)/中(60星)/近(30星),独立漂移 + 飞船速度视差 + 闪烁
119→ - 8 种实体全部 ctx.shadowBlur 辉光绘制
120→ - 粒子系统:尾焰/收集/碰撞/烟花,上限 200
121→ - 屏幕震动 280ms 衰减;低护盾(<30%)红色边框脉冲
122→ - HUDHTML 叠层,glass+backdrop-blur80ms 节流):护盾/能量/分数/用时/收集计数
123→ - 控制:桌面 WASD/方向键 8 方向 + 移动端虚拟摇杆 + P 暂停 + Esc 退出 + 失焦自动暂停
124→ - 阶段:ready → playing → won/lost(结算明细 + 奖励 + 下一关/重试/返回)
125→
126→**修改文件**
127→- `src/app/page.tsx`header 新增 amber 主题「巡航」按钮(Navigation 图标)+ cruiseOpen state + 条件渲染 CruiseMode
128→- `src/store/gameStore.ts`:新增 `grantCruiseReward({crystals?, insights?, contact?})` actioncrystals 受 crystalCap 限制,contact 受 100 上限)
129→- 版本号 v0.5.2 → v0.6
130→
131→**UI 偏移/重叠 BUG 修复**(3 处):
132→- `DecodeArray.tsx`:移除 className 冲突的 `-translate-x-1/2 -translate-y-1/2`,统一用 inline transform 定位+缩放
133→- `AchievementsPanel.tsx`:移除内层 `max-h-[300px]`,改 `flex-1 min-h-0` 自适应父容器
134→- `page.tsx`:调整三个 section 的 min-h,避免小屏单列时总高度超过手机视口
135→
136→**色彩规范**:严格遵循 emerald(#34d399) / rose(#fb7185) / amber(#fbbf24) / fuchsia(#e879f9) 四色全息色谱,零蓝色/靛色
137→
138→**QA 验证**agent-browser + VLM):
139→- lint 零错误;dev 服务器 HTTP 200;编译 < 250ms
140→- VLM 确认主界面:巡航按钮存在、晶体球居中、解码区正常、无重叠偏移
141→- VLM 确认巡航玩法:飞船可见(绿色三角形)、HUD 完整、陨石+星空背景、粒子特效(黄色尾焰+紫色辉光)
142→- WASD 控制飞船移动,按 W 直冲星门 → 通关,奖励 +30 晶体(2×10×1.5=30 计算正确)
143→- localStorage 正确记录 highScore/totalRuns/totalWins/bestLevel
144→- 奖励同步 gameStoregrantCruiseReward,满仓时 cap 逻辑正确)
145→- 退出返回主界面,7 标签页 + 晶体球 + 解码面板均完好
146→
147→---
148→
149→## 三、未解决问题或风险 / 下一阶段优先事项
150→
151→### 已知问题 / 风险
152→1. **dev 服务器 Turbopack 缓存偶发损坏**`Unable to open static sorted file` + 进程被杀,需 `rm -rf .next` 重启。静态导出构建稳定可靠。
153→2. **定时任务调度类型**:必须用 `fixed_rate` + `"900"` 秒(15分钟),不要用 cron 表达式。`webDevReview` 类型任务正常完成不会被删除,只有抛出未捕获异常才会被标记失败。
154→3. **v0.3 星潮为单机版**:原计划 socket 全局事件,后续可扩展为多人同步。
155→4. **v0.5 信标排行榜为本地版**(纯 localStorage):升级云排行榜需后端 API。
156→5. **v0.5 每日挑战仅 1 个/天**:可加入"周挑战"或"信标链"(连续完成 N 天奖励)。
157→6. **探险 BOSS 战胜率较低**:基础探索力 10 vs BOSS 难度 5-7,胜率约 25%,需玩家投资探险技术。
158→7. **探险能量恢复较慢**:45s/点,可加技术提升恢复速度。
159→8. **编年史上限 50 条**:超过自动丢弃最早的;v0.4 之前飞升无回填(空状态有提示)。
160→9. **5 纪元叙事为模板化生成**:v0.5+ 将加入手写剧情节点。
161→10. ~~**CrystalOrb 仍为 CSS 动画**~~ → ✅ **v0.7 已完成 Canvas 粒子系统升级**。
162→
163→### 下一阶段优先级(v0.7 后修正版)
164→> 工单修正:原 P0 CrystalOrb Canvas + P1 角色属性两项均已在 v0.7 完成,现重新排序。
165→
166→1. **🔴 P0 — 巡航玩法大增强(v0.8 主线)**:BOSS 战关卡(每 5 关一个 BOSS,三阶段攻击模式 + 飞船射击系统)/ 道具掉落(5 种 powerup:护盾/能量/急速/超载/磁吸)/ 事件选择节点(每关通关后 3 选 1 强化牌,10 种强化)。这是当前最高优先级,能显著提升巡航玩法的深度与可玩性。
167→2. **🟠 P1 — 探险 BOSS 战胜率平衡 + 探险能量恢复**:修复已知问题 6/7(探险 BOSS 胜率 25% 偏低,能量 45s/点 偏慢),提升探险体验。
168→3. **🟠 P1 — 信标系统扩展**:周挑战 / 信标链(连续完成 N 天奖励),修复已知问题 5。
169→4. **🟡 P2 — UI 样式细节打磨**:标签页图标/文字比例、视觉层次强化、移动端响应式进一步优化、CrystalOrb 周边留白优化(VLM 指出主界面左侧有空白)。
170→5. **🟡 P2 — 云存档 + 云排行榜**:v0.5+ 后端 API(需评估是否引入 mini-service)。
171→6. **🟢 P3 — 全 5 纪元手写叙事节点**:替换模板化 lore。
172→7. **🟢 P3 — socket 多人同步星潮**:全局事件共享。
173→
174→### 定时任务(fixed_rate · 900s
175→- **当前 job_id**: **228357**2026-06-24 13:25 重建)
176→- **配置**: `fixed_rate` + `"900"`15分钟间隔),`priority=10`(高优先级),`payload.kind=webDevReview``tz=Asia/Shanghai`
177→- **任务名**: Echo Nexus - 任务审查与持续迭代
178→- **任务描述强制要求**:每次触发 MUST 先完整阅读 worklog.md 工单,以工单为准,不凭记忆工作
179→- **正常完成不会被删除**,只有未捕获异常会标记失败。若任务列表为空,需用 cron create 重建(schedule.kind=fixed_rate, expr="900", priority=10, payload.kind=webDevReview)。
180→- **job_id 历史**: 227909(失效) → 228130(失效) → 228172(失效) → 228186(失效) → 228218(删除重建) → 228236(cron版,删除重建) → 228266(fixed_rate,失效) → 228329(失效) → **228357(当前,重建)**
181→
182→---
183→
184→## 定时任务描述模板(webDevReview · fixed_rate
185→
186→> 重建参数:schedule.kind=`fixed_rate`, schedule.expr=`"900"`, params.priority=`10`, payload.kind=`webDevReview`
187→
188→```
189→请先判断当前项目状态,使用 agent-browser 进行必要测试和 QA,然后自主选择本轮最合适的工作重点并继续开发。
190→
191→要求:
192→1. 开始任务时查看 /home/z/my-project/worklog.md 了解实际进展,拆解详细todo
193→2. 使用 agent-browser 进行测试和QA,有bug优先修复
194→3. 如果稳定,自主提出新需求继续推进
195→4. 样式要越做细节越多!!
196→5. 功能要越做越多!!
197→6. 工作告一段落更新 /home/z/my-project/worklog.md
198→7. 项目结束后一定要把工作记录写在 worklog.md 里
199→
200→交接文档建议包含三部分:
201→- 项目当前状态描述/判断
202→- 当前目标/已完成的修改/验证结果
203→- 未解决问题或风险,建议下一阶段优先事项
204→```
205→
206→---
207→
208→## 历史详细版本记录(按需查阅 docs/repo/docs/
209→
210→- `04-解码系统修复-v0.1.1.md`
211→- `05-遗迹探险系统-v0.2.md`
212→- `06-音频与成就系统-v0.2.1.md`
213→- `07-星潮事件系统-v0.3.md`
214→- `08-星图天文台系统-v0.3.1.md`
215→- `09-回响编年史系统-v0.4.md`
216→- `10-深空信标系统-v0.5.md`
217→
218→---
219→
220→Task ID: 7
221→Agent: full-stack-developer
222→Task: 角色属性系统(探索力/智慧/勇气/灵感)
223→
224→Work Log:
225→- 阅读 worklog.md 了解 v0.6 项目背景,确认 8 大系统架构与四色全息色彩规范
226→- 新建 `src/lib/game/attributes.ts` 逻辑层(~330 行):
227→ - 定义 CharacterAttributes/AttributeProgress/AttributeKey 类型 + ATTRIBUTE_CONFIG 元信息
228→ - getAttributeBonus 线性+递减两区加成公式(0-50 每 10 点 +5%50-100 每点 +2%
229→ - getAllBonuses 返回 12 个修饰器(探险力/巡航速度/解码步数/洞见/自动解码周期/探险生命/BOSS 胜率/巡航护盾/接触率/星潮触发/脉冲连击/产能)
230→ - levelUpCheck 自动跨多级 + expRequiredForLevel + computePrestigeAttrPoints + migrateAttributes 兼容
231→- 扩展 `src/lib/game/types.ts` GameState:新增 attributes/attributeProgress/pendingAttrPoints
232→- 扩展 `src/lib/game/config.ts` INITIAL_STATE:补全默认值(0 数值 + 空 progress
233→- 扩展 `src/lib/game/engine.ts`
234→ - recomputeStats 末尾聚合 am.crystalsPerSecMult/insightMultAdd/contactRateMult/decodeStepsBonus
235→ - performPrestige 保留 attributes 数值,清空 attributeProgress,发放 ascensions × 2 + 1 点属性点
236→ - createInitialState 每次返回全新对象避免引用共享
237→- 扩展 `src/store/gameStore.ts`(核心改造,10+ 处 syncStats 调用全部补 attributes 参数):
238→ - pulse:连击 ≥3 给灵感经验 + 脉冲威力乘灵感连击加成
239→ - clickNode 完成:给智慧经验(tier × 2)
240→ - autoDecodeTick:智慧经验 +1 + 自动解码周期受 am.autoDecodeIntervalMult 影响
241→ - startExpedition:探险力乘 am.expeditionPowerMult,生命加 am.expeditionHpBonus
242→ - resolveCurrentNodeBOSS 节点包装 RNG 提升 +am.bossWinRateBonus 胜率;战斗胜利给勇气+探索力经验
243→ - grantCruiseReward:按总奖励量给探索力+勇气经验
244→ - tickTide:灵感 am.tideTriggerBonus 缩短星潮间隙(上限 30%)
245→ - init() 调用 migrateAttributes 补全旧存档
246→ - 新增 allocateAttribute / gainAttributeExp 两个 action
247→- 新建 `src/components/game/AttributesPanel.tsx` UI 面板(~330 行):
248→ - 四维卡片网格(小屏 2×2,大屏 1×4)
249→ - 每张卡片:图标 + 中英文名 + Lv.badge + 数值/100 + 加成% + 经验条 + 影响列表 + 「+分配」按钮
250→ - 顶部待分配点数 badgependingAttrPoints > 0 时 echo-pending-pulse 闪烁动画)
251→ - 底部总等级/总加成概览 + 12 个修饰器明细
252→ - 4 色辉光边框(emerald/fuchsia/amber/rose),顶角光晕装饰
253→- 接入 `src/app/page.tsx`
254→ - grid-cols-7 → grid-cols-8,新增第 8 个「角色」TabsTriggerUser 图标,渐变主题)
255→ - pendingAttrPoints > 0 时显示 rose 红点提示
256→ - 渲染 AttributesPanel
257→ - StatsPanel 新增 5 行属性行(探索力/智慧/勇气/灵感/待分配点)
258→ - 版本号 v0.6 → v0.7
259→- 新增 `src/lib/game/achievements.ts` 2 项成就:
260→ - ach_attr_total_50(四维觉醒):总属性 ≥ 50 → 产能+6%/洞见+6%
261→ - ach_attr_max_100(维度精通):任一属性 100 → 产能+12%/洞见+10%
262→- 写入 `agent-ctx/7-full-stack-developer.md` 工作记录
263→- 最终验证:lint 零错误 + dev 服务器 HTTP 200 + 编译 < 250ms
264→
265→Stage Summary:
266→- ✅ 角色属性系统 v0.7 P1 全部完成
267→- ✅ 新增 2 文件(attributes.ts + AttributesPanel.tsx),修改 6 文件(types/config/engine/gameStore/achievements/page
268→- ✅ 四维属性完整接入游戏各系统(探险/解码/脉冲/巡航/星潮)
269→- ✅ 飞升属性点公式 = ascensions × 2 + 1;经验累积自动升级(每级 10×level)
270→- ✅ 旧存档兼容(migrateAttributes 补全字段,同步 level 与 attributes
271→- ✅ lint 零错误 + HTTP 200 + 编译 < 250ms
272→- ✅ 严格四色全息(emerald/fuchsia/amber/rose),零蓝色/靛色
273→- ✅ 8 标签页 + 巡航按钮全部保留,data-tut 锚点未破坏
274→- ✅ 待分配点数时标签页 rose 红点提示 + 顶部 badge 闪烁
275→
276→---
277→Task ID: 8-deploy
278→Agent: main
279→Task: 重建定时任务 + 更新 README + 部署 gh-pagesv0.7
280→
281→Work Log:
282→- 检查项目状态:CrystalOrb.tsx 已是 v0.7 Canvas 粒子系统(659 行),无活动 cron job
283→- 重建定时任务:cron create fixed_rate="900" priority=10 webDevReview → job_id 228329tz=Asia/Shanghai
284→- 重写 README.md
285→ - 顶部显著位置添加「在线游玩」链接(https://gitea-pages.atdunbg.xyz/Super_Z/echo-nexus/
286→ - 版本号 v0.1 → v0.7
287→ - 新增 10 大系统表格(含 v0.6 巡航 + v0.7 角色属性)
288→ - 完整开发路线表(v0.1-v1.0,标记 ✅/🔜 状态)
289→ - 新增本地开发命令 + Gitea Pages 部署指南
290→ - 文档索引扩展至 10 个版本文档
291→- 静态导出:BUILD_EXPORT=true bun run build:static → out/ 1.6MHTTP 200 编译 4.3s
292→- 推送 gh-pages 分支:force push origin gh-pagescommit 8727fed "deploy: v0.7"
293→- 提交 mainREADME 更新(commit 43dfc2d "docs: README 添加在线游玩链接 + v0.7 状态更新"
294→- 验证 Gitea Pageshttps://gitea-pages.atdunbg.xyz/Super_Z/echo-nexus/ → HTTP 200 ✅
295→- QA 验证:agent-browser 打开 http://localhost:3000/ → VLM 评分 8/10(主界面 polishedminor 空白)
296→
297→Stage Summary:
298→- ✅ 定时任务重建成功(job_id 228329,每 15 分钟自动审查迭代)
299→- ✅ README 重写完成,网站链接显著展示,v0.7 状态完整
300→- ✅ gh-pages 部署成功,Gitea Pages HTTP 200 可访问
301→- ✅ main 分支已同步推送(README + worklog 更新)
302→- ✅ dev 服务器健康(HTTP 200,编译 < 250ms
303→- ✅ CrystalOrb v0.7 Canvas 粒子系统已确认完整(659 行)
304→- 下一步:v0.8 巡航玩法增强(BOSS 战 / 道具掉落 / 事件选择节点)
305→
306→---
307→Task ID: 8
308→Agent: main
309→Task: v0.8 巡航玩法大增强(BOSS 战 + 道具掉落 + 事件选择节点)+ 工单修正 + 审查流程重建
310→
311→Work Log:
312→- 重读 worklog 工单,发现 v0.7 两项(CrystalOrb Canvas + 角色属性)已完成但工单"下一阶段优先级"未更新
313→- 修正 worklog 工单:标注 P0/P1 v0.7 已完成,重新排序下一阶段优先级(P0=巡航增强, P1=探险平衡/信标扩展, P2=UI打磨/云存档, P3=叙事/多人)
314→- 重建审查流程 cron jobfixed_rate=900s, priority=10, webDevReview, tz=Asia/Shanghai, job_id=228357
315→ - 任务描述强制要求:每次触发 MUST 先完整阅读 worklog.md 工单,以工单为准,不凭记忆工作
316→- 发现之前被中断的 v0.8 subagent 已写入 2037 行代码(cruise.ts +1487 / CruiseMode.tsx +858 / page.tsx +2
317→- 修复 2 个 lint 错误(react-hooks/set-state-in-effect):
318→ - isTouch: useState(false) + useEffect(setIsTouch) → useState initializer
319→ - stats: useState(null) + useEffect(setStats(loadCruiseStats)) → useState initializer
320→- QA 验证(agent-browser + VLM):
321→ - 主界面 VLM 7/10v0.8 版本号 ✅ + 巡航按钮 ✅
322→ - 巡航 READY 阶段 VLM 8/10BOSS 战说明 ✅(每5关BOSS战/击败BOSS)+ 射击控制说明 ✅(空格/右下角射击按钮)
323→ - 巡航第1关游戏 VLM 8/10:飞船(绿色三角)✅ + HUD(护盾/能量/分数/计时)✅ + 陨石/收集物/能量场 ✅ + 无渲染错误
324→- 提交 v0.8commit 494bc5f+ 推送 main
325→- 静态导出 BUILD_EXPORT=true → out/ 1.6M(编译 4.1s
326→- 部署 gh-pagesforce push, commit b865922)→ Gitea Pages HTTP 200 ✅
327→
328→Stage Summary:
329→- ✅ 工单修正完成:v0.7 已完成项标注清楚,下一阶段优先级重新排序
330→- ✅ 审查流程重建:cron job_id 228357,每15分钟触发,强制读工单
331→- ✅ v0.8 巡航玩法大增强全部完成并部署:
332→ - BOSS 战系统(每5关,三阶段攻击,HP条,击败奖励×3)
333→ - 飞船射击系统(空格/射击按钮,能量消耗,子弹尾焰)
334→ - 道具掉落(5种powerup:护盾/能量/急速/超载/磁吸)
335→ - 事件选择节点(每关通关后3选1,10种强化牌)
336→ - lint 零错误 + dev HTTP 200 + gh-pages HTTP 200
337→- ✅ VLM 评分:主界面 7/10 + 巡航READY 8/10 + 巡航游戏 8/10
338→- 在线游玩 v0.8https://gitea-pages.atdunbg.xyz/Super_Z/echo-nexus/
339→- 下一阶段工单:P1 探险BOSS胜率平衡 + 探险能量恢复 / 信标系统扩展(周挑战/信标链)
340→
341→---
342→Task ID: 9-b
343→Agent: full-stack-developer
344→Task: 信标系统扩展(周挑战 + 信标链连续奖励)
345→
346→Work Log:
347→- 阅读现有 `src/lib/game/beacon.ts`v0.5 每日挑战 + 本地排行榜,358 行)+ `src/components/game/BeaconPanel.tsx` + `src/store/gameStore.ts` 的 `trackBeacon` 函数(约第 173 行)+ worklog v0.8 项目状态,确认四色全息规范与现有架构。
348→- **扩展 `src/lib/game/beacon.ts`358 → 873 行,新增 ~515 行)**
349→ - **周挑战(WEEKLY CHALLENGE**
350→ - `BeaconWeeklyChallenge` 接口(weekKey YYYY-Www / type / difficulty 强制 anomaly|singular / goal / rewardInsight / rewardContact / seed / title / desc
351→ - `BeaconWeeklyProgress` 接口(weekKey / progress / startedAt / completedAt / claimed / durationSec
352→ - `getWeekKey(now)`ISO 8601 周键(周一为起点,含首个周四的周为第一周)
353→ - `weekKeyToSeed`FNV-1a 哈希
354→ - `generateWeeklyChallenge(now)`:基于 weekKey 种子确定性生成,难度加权 anomaly 60% / singular 40%goal 为日基准 ×3-5 倍(decode 18-75 / expedition 3-15 / pulse 60-245 / boss 3-12 / insight 120-595
355→ - `loadWeeklyProgress` / `saveWeeklyProgress` / `addWeeklyProgress` / `claimWeeklyReward`(推送排行榜,entry 加 `isWeekly: true`
356→ - `msUntilNextWeek(now)`:距离下周一 UTC 0 点的毫秒数
357→ - `BEACON_WEEKLY_KEY = "echo-nexus-beacon-weekly-v1"`
358→ - **信标链(BEACON CHAIN**
359→ - `BeaconChainState` 接口(lastCompletedDateKey / currentStreak / longestStreak / totalCompletions / graceUsed / milestonesClaimed
360→ - `BEACON_CHAIN_KEY = "echo-nexus-beacon-chain-v1"`
361→ - `BEACON_CHAIN_MILESTONES = [3, 7, 14, 30]` 常量
362→ - `BEACON_CHAIN_REWARDS`4 个里程碑(三日谐振 +50/+5 / 七日回响 +120/+12 / 半月星潮 +280/+28 / 满月飞升 +680/+68
363→ - `loadChainState` / `saveChainState`(每次返回新对象避免引用共享)
364→ - `recordChainCompletion(dateKey)` 核心逻辑:同日重复忽略;次日 streak++;隔一天 miss 且 graceUsed<1 时续命 streak++ graceUsed++;其他断链 streak=1 graceUsed=0;返回 `{ state, newMilestones }`
365→ - `claimChainMilestone(milestone)` / `getNextMilestone(streak)` / `getChainProgress(streak)`(返回 current/next/prev/progressPct 用于 UI
366→ - `dateKeyToTimestamp` / `dateKeyDiffDays` 工具函数
367→ - `BeaconScoreEntry` 新增可选 `isWeekly?: boolean` 字段
368→- **扩展 `src/store/gameStore.ts`**
369→ - import 扩展:generateWeeklyChallenge / loadWeeklyProgress / loadChainState / addWeeklyProgress / recordChainCompletion / claimWeeklyReward / claimChainMilestone / getTodayKey + 类型
370→ - `trackBeacon(type, delta)` 函数签名从 `boolean` 改为 `{ dailyJustCompleted, weeklyJustCompleted, newChainMilestones }`
371→ - 日挑战类型匹配且未完成 → addBeaconProgress;若刚完成 → recordChainCompletion(getTodayKey()) 收集 newMilestones
372→ - 周挑战类型匹配且未完成 → addWeeklyProgress
373→ - 所有调用点(pulse/clickNode/autoDecodeTick/resolveCurrentNode 等 7 处)原本就忽略返回值,向后兼容
374→ - 新增 action `claimWeeklyBeacon`:调用 claimWeeklyReward → 发放 insights + contact 到 state,返回 `{ rewardInsight, rewardContact, score }`
375→ - 新增 action `claimChainReward(milestone)`:前置校验 currentStreak 与 milestonesClaimed,调用 claimChainMilestone → 发放奖励,返回 `{ rewardInsight, rewardContact, label, ok }`
376→ - GameActions 接口同步新增两个 action 签名
377→- **重写 `src/components/game/BeaconPanel.tsx`301 → 638 行)**
378→ - 顶部头部 + 每日挑战卡片(保留 v0.5 完整功能 + 按钮主题色随难度色淡显)
379→ - **新增周挑战区块**fuchsia 主题):标题"周挑战 · WEEKLY" + weekKey + 倒计时 + 挑战卡片(类型/难度标签/标题/描述/进度条/奖励/领取按钮 emerald 主题)+ weekly-glow 动画
380→ - **新增信标链区块**amber→rose 渐变):标题"信标链 · CHAIN" + 大字号 streakchain-streak-text 渐变流动动画)+ 今日完成状态徽章 + 4 个里程碑节点(w-12 h-12 圆形,claimed=emerald/✓,reachable=rose 脉冲动画+领取按钮,inProgress=amber 半亮,未到达=muted 灰)+ 节点间连线(背景灰 + 已达成部分 amber→rose 渐变 + 辉光,基于 prev→next 插值定位)+ 进度条 + 底部统计(最长/累计/续命状态)
381→ - 桌面端 lg:grid-cols-2 让周挑战+信标链并排,移动端单列
382→ - 排行榜条目区分日/周:周挑战 entry 显示 "WEEK" 徽章 + fuchsia 高亮背景
383→ - 空状态提示扩展为"完成今日或本周信标即可登榜"
384→ - 4 个新动画:weekly-glow / chain-milestone-pulse / chain-streak-flux(背景渐变流动)
385→- **QA 验证**
386→ - `bun run lint` 零错误(每次修改后均验证)
387→ - dev.log 全程无错误,所有编译 < 300ms
388→ - **信标链逻辑测试**(bun 直接运行 TS,5 个场景全 PASS):
389→ 1. 昨日 streak=1 → 今日完成 → streak=2 ✅
390→ 2. 同日重复完成 → 忽略 ✅
391→ 3. 明日完成 → streak=3,无需 grace ✅
392→ 4. 隔一天 misslastCompleted + 2 天)→ streak=4graceUsed=1 ✅
393→ 5. 再次 missgrace 已用)→ 断链 streak=1graceUsed=0 ✅
394→ - **UI 集成测试**agent-browser):
395→ - localStorage 模拟设置 chain statelastCompletedDateKey=yesterday, currentStreak=1)→ reload → UI 正确显示 streak=1
396→ - 设置 streak=3 + milestonesClaimed=[] → milestone 3 节点显示 rose 脉冲动画 + "领取"按钮
397→ - 点击"领取"→ milestonesClaimed=[3] + Toast "✦ 三日谐振 已领取 +50 洞见 · +5.0 接触" + 按钮消失 ✅
398→ - **VLM 视觉评分**(目标 ≥7/10):
399→ - 首屏截图:8/10(四色一致、布局合理、信标链清晰)
400→ - 里程碑可领取状态:8/10(amber→rose 渐变醒目、里程碑层次分明、fuchsia 主题清晰)
401→ - 重置后干净状态:8/10(WEEKLY 字体对比度可优化,但整体可读性强)
402→ - **不破坏现有功能**:日挑战进度/领奖/排行榜流程全部保留并工作正常
403→
404→Stage Summary:
405→- ✅ beacon.ts 扩展完成(358 → 873 行):周挑战 + 信标链全部函数与类型
406→- ✅ gameStore.ts 集成完成:trackBeacon 升级为对象返回值 + claimWeeklyBeacon + claimChainReward 两个新 action
407→- ✅ BeaconPanel.tsx UI 完整重写(301 → 638 行):周挑战 fuchsia 卡片 + 信标链 amber→rose 渐变区块
408→- ✅ 严格四色全息:周挑战 fuchsia、信标链 amber→rose、领取按钮 emerald、无蓝/紫色违规
409→- ✅ localStorage 新增 2 个独立 keyweekly + chain),不污染旧 key
410→- ✅ 确定性:周挑战同周同种子同结果(weekKey FNV-1a 种子)
411→- ✅ 信标链 grace 续命机制工作正常(5 个场景 bun 测试全 PASS)
412→- ✅ lint 零错误 + dev HTTP 200 + VLM 8/10
413→- ✅ 现有日挑战 + 排行榜 + 领奖流程全部保留,无回归
414→
415→---
416→Task ID: 9
417→Agent: main + subagent(9-b)
418→Task: v0.8.1 P1 双任务 — 探险 BOSS 胜率平衡 + 能量恢复 + 信标系统扩展(周挑战 + 信标链)
419→
420→Work Log:
421→- 重读工单确认 P1 两项为下一阶段优先级
422→- P1-a 探险平衡(main 自己做,平衡需要精细控制):
423→ - expedition.ts: combatDifficultyScale 8→4, combatWinRateFloor 0.25→0.35, BOSS difficulty 5-7→3-5
424→ - 新增 computeEnergyRegenInterval(state): exp_2 -30%, exp_3 -20%, 探索力属性 -最高30%, 下限12s
425→ - computeEnergyRegen 接受 intervalSec 参数
426→ - config.ts: exp_2/exp_3 描述加能量恢复说明
427→ - gameStore.ts tick: 传入动态间隔
428→ - ExpeditionPanel.tsx: 显示实际恢复速度 + '已加速'标记
429→- P1-b 信标系统扩展(dispatch subagent 9-b,并行完成):
430→ - beacon.ts 358→873行: 周挑战(getWeekKey ISO 8601 + 确定性生成) + 信标链(4里程碑+grace续命)
431→ - BeaconPanel.tsx 301→638行: 周挑战fuchsia主题 + 信标链amber→rose里程碑节点
432→ - gameStore.ts: trackBeacon升级 + claimWeeklyBeacon/claimChainReward actions
433→- QA 验证:
434→ - lint 零错误
435→ - 探险面板 VLM 7/10(能量条 + 恢复速度显示正常)
436→ - 信标面板 VLM 8/10(周挑战 WEEKLY + 信标链 3/7/14/30 + 排行榜全部可见)
437→ - subagent 5场景 bun 测试全 PASS(信标链断链/续命逻辑)
438→- 提交 v0.8.1commit 71ca5b4+ 推送 main
439→- 静态导出 + 部署 gh-pagescommit 97eecaa)→ HTTP 200
440→
441→Stage Summary:
442→- ✅ P1-a 探险平衡完成: BOSS 基础胜率 25%→35-45%, 配合技术/属性可达 55-70%
443→- ✅ P1-a 能量恢复: 固定45s → 动态12-45s(技术+属性加速)
444→- ✅ P1-b 周挑战: 每周一UTC刷新, fuchsia主题, goal×3-5倍
445→- ✅ P1-b 信标链: 4里程碑(3/7/14/30天) + grace续命, amber→rose渐变
446→- ✅ 在线游玩 v0.8.1: https://gitea-pages.atdunbg.xyz/Super_Z/echo-nexus/
447→- 下一阶段工单: P2 UI样式细节打磨 / P2 云存档+云排行榜 / P3 全纪元叙事
448→
449→---
450→Task ID: 10-a
451→Agent: full-stack-developer
452→Task: #5 限时挑战 + #3 星潮类型深化
453→
454→Work Log:
455→- 阅读项目上下文:worklog.mdv0.8.110 大系统)+ beacon.ts873行,日+周+链)+ starTide.ts209行,6种星潮)+ gameStore trackBeacon/tickTide + BeaconPanel.tsx,确认四色全息规范与现有架构
456→- **工单 #5 限时挑战**
457→ - beacon.ts873→1162行)新增限时挑战系统:
458→ - BeaconTimedChallenge/BeaconTimedProgress 接口 + isTimed 字段加入 BeaconScoreEntry
459→ - getTimedSlotKeyUTC 0/4/8/12/16/20 点切分,"timed_YYYY-MM-DD_HH"+ timedSlotKeyToSeedFNV-1a
460→ - generateTimedChallenge(确定性,难度 routinegoal=日基准×0.3-0.55类型公式)
461→ - loadTimedProgress/saveTimedProgress/addTimedProgress/claimTimedReward(排行榜 isTimed
462→ - msUntilNextTimedSlot + TIMED_SLOT_MS=4h + BEACON_TIMED_KEY
463→ - gameStore.tstrackBeacon 返回值新增 timedJustCompleted,三进度同时更新;新增 claimTimedBeacon action
464→ - BeaconPanel.tsx753→944行):日挑战与周挑战之间插入限时挑战区块(amber 主题)
465→ - Hourglass图标+TIMED标题+slotKey+倒计时 + 进度+奖励+领取按钮(emerald)
466→ - 紧急状态:距时段结束<30min 切 rose 脉冲动画+"即将结束"徽章
467→ - 2新动画 timed-glow(amber)/timed-urgent(rose) + 排行榜 TIMED 徽章
468→- **工单 #3 星潮深化**
469→ - starTide.ts209→289行):TideType 新增 surge/eclipse/prism
470→ - surge(emerald ⇈): crystalsPerSec×2.5 + targetLenBonus=2(解码目标序列+2,更长更难)
471→ - eclipse(rose ◐): crystalsPerSec×0.7 + bossWinRateBonus=0.2BOSS胜率+20%
472→ - prism(fuchsia ◬): insightMultAdd=1.0(洞见×2) + autoDecodeIntervalMult=0.7(自动解码-30%)
473→ - TideModifiers 新增 targetLenBonus/bossWinRateBonus/autoDecodeIntervalMult 可选字段
474→ - TIDE_WEIGHTS 新增3种各12(总136+ getTideModifiers 3新case
475→ - decode.tsgeneratePuzzle 新增 targetLenBonus 参数(surge用,targetLen+stepLimit同步增加)
476→ - gameStore.ts 3处修饰器应用:
477→ - startDecodesurge 时传 targetLenBonus → 谜题更长
478→ - autoDecodeTickprism 时 interval×=autoDecodeIntervalMult
479→ - resolveCurrentNodeeclipse 时 bossWinRate 加 tideBossBonus
480→ - achievements.tsach_tides_all 阈值6→9page.tsx StatsPanel 星潮亲历 /9
481→- QA 验证:
482→ - lint 零错误(exit 0+ dev HTTP 200 + 编译<30ms
483→ - BeaconPanel VLM 8.5/10(限时区块可见)+ 最终 9/10
484→ - 三新星潮 localStorage 注入触发截图 VLMsurge 8/10、prism 7/10、eclipse 8/10
485→ - 日/周/信标链/6旧星潮全部保留无回归
486→
487→Stage Summary:
488→- ✅ beacon.ts +289行(限时挑战完整系统:4h时段+确定性+进度+领奖+排行榜)
489→- ✅ starTide.ts +80行(3新类型 surge/eclipse/prism + 3新修饰器字段)
490→- ✅ decode.ts generatePuzzle 支持 targetLenBonussurge 谜题+2
491→- ✅ gameStore.ts trackBeacon 三进度同步 + claimTimedBeacon + 3处新星潮修饰器
492→- ✅ BeaconPanel.tsx +191行(amber 限时区块 + 紧急状态 + TIMED 徽章)
493→- ✅ 严格四色规范:限时amber/surge-emerald/eclipse-rose/prism-fuchsia,零蓝色
494→- ✅ 确定性:限时挑战同时段同种子同结果(FNV-1a slotKey 哈希)
495→- ✅ lint 零错误 + dev HTTP 200 + VLM 全部≥7/10
496→
497→---
498→Task ID: 10-b
499→Agent: full-stack-developer
500→Task: P2/#4 云排行榜 mini-service(信标本机榜升级为云端 Top100)
501→
502→Work Log:
503→- 前置阅读:worklogv0.8.1 项目状态)+ beacon.tsBeaconScoreEntry / loadLeaderboard / pushLeaderboardEntry / claimBeaconReward / claimWeeklyReward / claimTimedReward+ BeaconPanel.tsx(排行榜 UI 渲染)+ Caddyfile?XTransformPort=端口号 转发规则)+ examples/websocketmini-service 参考)+ .zscripts/dev.shmini-service 启动模式)
504→- 发现 worklog 信息滞后:beacon.ts 实际已扩到 1161 行(v0.8.2 已加限时挑战 + claimTimedReward),BeaconPanel 已扩到 953 行
505→- 创建 mini-service
506→ - `mini-services/leaderboard-service/package.json`:独立 bun 项目,type=modulescripts `dev: bun --hot index.ts` + `start: bun index.ts`,依赖 hono ^4.6.14
507→ - `mini-services/leaderboard-service/index.ts`182 行):
508→ - Hono + cors 中间件(origin: * 全开放,跨端口必须)
509→ - 内存数组存储(最多 1000 条,按 score 降序,同分按时长→时间戳排序)
510→ - 防刷:同 dateKey+challenge 10s 内只接受 1 次提交(Map 记录 lastSubmitAt),返回 429 + retryAfterMs
511→ - GET /api/leaderboard → Top100 + total
512→ - POST /api/leaderboard body {entry} → 校验 schema → 推入 → 排序 → 返回 {entries, total, rank}
513→ - GET /api/leaderboard/stats → {totalSubmissions, uniquePlayers, topScore}
514→ - GET / → 健康检查 {service, version, ok, uptime}
515→ - 端口固定 3030`export default { port, fetch }` 标准 bun 模式
516→ - `mini-services/leaderboard-service/README.md`API 文档 + 联调规范
517→- mini-service 启动调试:bun --hot 在本沙盒环境下会因文件 watcher 异常被 kill;改用 `bun index.ts`(无 --hot+ 双 fork `( ... & )` 模式启动,PPID=1uptime 4+ 分钟稳定
518→- eslint.config.mjs 新增 ignores "mini-services/**"mini-service 独立项目,不参与主项目 lint)
519→- beacon.ts 扩展(1161 → 1290 行,+129 行):
520→ - 三个 claim 函数返回值新增 `entry: BeaconScoreEntry`(非破坏性扩展):
521→ - `claimBeaconReward` 返回 `entry`daily
522→ - `claimWeeklyReward` 返回 `entry`weekly
523→ - `claimTimedReward` 返回 `entry`timed
524→ - 新增云排行榜模块(v0.8.2 P2/#4):
525→ - `BEACON_CLOUD_PORT = 3030` 常量
526→ - `BEACON_CLOUD_LAST_SUBMIT_KEY = "echo-nexus-beacon-cloud-last-submit-v1"` 本地 key
527→ - `CloudLeaderboardResponse` / `CloudSubmitResponse` / `CloudStatsResponse` 接口
528→ - `loadLastCloudSubmitTimestamp()` / `saveLastCloudSubmitTimestamp(ts)` 工具
529→ - `fetchCloudLeaderboard(): Promise<BeaconScoreEntry[]>` — GET 相对路径 + ?XTransformPort=3030
530→ - `submitCloudScore(entry): Promise<number>` — POST,成功返回 rank,失败返回 -1,自动记录 ts 供全球榜高亮
531→ - `fetchCloudStats(): Promise<CloudStatsResponse | null>` — 全局统计
532→ - 所有 fetch 用相对路径 + `?XTransformPort=3030`**禁止** localhost:3030
533→- gameStore.ts 集成:
534→ - import 新增 `submitCloudScore`
535→ - `claimWeeklyBeacon` action:领奖成功后 `void submitCloudScore(res.entry)`fire-and-forget,不 await,不阻塞)
536→ - `claimTimedBeacon` action:同上
537→ - `claimBeaconReward` (daily) 由 BeaconPanel 直接调用,不通过 gameStore
538→- BeaconPanel.tsx 重写(953 → 1170 行,+217 行):
539→ - 新增 import: fetchCloudLeaderboard / submitCloudScore / loadLastCloudSubmitTimestamp + 4 个 lucide 图标(RefreshCw/Globe/WifiOff/Loader2
540→ - 新增状态: lbTab ('local'|'global') / cloudEntries / cloudLoading / cloudError / cloudFetched / mySubmitTs / cloudFetchingRef
541→ - 新增 `refreshCloudLeaderboard()` callback:拉取云端榜 + 防并发(ref)+ 空 entries 时显示提示
542→ - useEffect: 切到全球 tab 自动拉取(仅首次)+ 初始化读取 mySubmitTs
543→ - handleClaimDaily: 领奖成功后 `void submitCloudScore(res.entry).then(rank => ...)` — 若用户在全球 tab 自动刷新
544→ - handleClaimTimed/Weekly: 领奖后更新 mySubmitTs + 若在全球 tab 自动刷新
545→ - 排行榜 UI 重写为双 tab:
546→ - 顶部「本地」(amber 主题) / 「全球」(emerald 主题) 切换按钮 + 全球 tab 专属刷新按钮(旋转动画)
547→ - 子标题:本地"本机 Top N · 离线可用" / 全球"全球 Top N · 已同步 / 同步中…"
548→ - 本地榜保留原 v0.8.2 渲染(max-h-200px overflow-y-auto
549→ - 全球榜:
550→ - 加载中:Loader2 spinner + "正在拉取全球榜…"
551→ - 空榜+错误:WifiOff 图标 + 错误文案 + 重试按钮
552→ - 空榜+无错:Globe 图标 + "尚无全球记录"提示
553→ - 有数据:渲染前 100 条,每条带排名(1-3 名用 Crown/Medal/Award 图标)/ 难度色 / WEEK·TIMED 徽章 / 用时 / 分数
554→ - 玩家自己的记录通过 timestamp 匹配 mySubmitTs 高亮:emerald 边框 + emerald 阴影 + YOU 徽章 + emerald 分数色 + 加粗排名
555→- QA 验证:
556→ - `bun run lint` 零错误零警告(eslint.config.mjs 把 mini-services 加入 ignores
557→ - dev.log 全程无错误,所有编译 < 300ms
558→ - mini-service curl 全通过:
559→ - GET / → 200 {service, version, ok, uptime}
560→ - GET /api/leaderboard (空) → 200 {entries:[], total:0}
561→ - POST /api/leaderboard → 200 {entries, total, rank:1}
562→ - GET /api/leaderboard (有数据) → 200 {entries:[...], total:N}
563→ - GET /api/leaderboard/stats → 200 {totalSubmissions, uniquePlayers, topScore}
564→ - OPTIONS 预检 → 204 + 完整 CORS 头(Allow-Origin: *
565→ - 重复 POST(同 dateKey+challenge 10s 内)→ 429 {error:"Rate limited", retryAfterMs}
566→ - 网关路由验证:Caddy :81 → localhost:3030 通过 ?XTransformPort=3030 正确转发,返回 JSON 200
567→ - agent-browser 集成测试(通过 :81 端口访问,绕过 Next.js 404):
568→ - 信标 tab 选中(用 KeyboardEvent Enter 激活 Radix Tabs,因 pointerdown 被覆盖层拦截)
569→ - 切到「全球」tab → 自动拉取 → 显示"全球 Top 6 · 已同步" → 渲染 6 条记录
570→ - 刷新按钮可点击,旋转动画正常
571→ - POST 测试:浏览器 fetch 提交 score=9999 → 返回 rank=1 → 刷新后榜首显示 10.00K
572→ - YOU 高亮:手动设置 localStorage[BEACON_CLOUD_LAST_SUBMIT_KEY] 后刷新 → 榜首显示 emerald 边框 + YOU 徽章
573→ - VLM 视觉评分(目标 ≥7/10):
574→ - 全球榜含 YOU 高亮:**8/10**(双tab切换清晰,全球榜记录完整,难度色与标签规范,视觉层次佳)
575→ - 本地榜:**8/10**(双tab切换逻辑清晰,本地tab内容完整,四色规范应用,信息层级合理)
576→
577→Stage Summary:
578→- ✅ mini-service 独立项目:自己的 package.json + bun.lock + node_modules,依赖 hono ^4.6.14,不污染主项目
579→- ✅ 端口 3030 + Caddy 网关:前端用相对路径 `fetch('/api/leaderboard?XTransformPort=3030')`**禁止** localhost:3030
580→- ✅ CORS 全开放:origin: * + OPTIONS 预检自动处理,前端跨端口无忧
581→- ✅ 内存存储 + 防刷:1000 条上限,同 dateKey+challenge 10s 限 1 次
582→- ✅ 三个 claim 入口全部接入云提交:claimBeaconRewardBeaconPanel 直接调)/ claimWeeklyBeacon / claimTimedBeacongameStore action 内)
583→- ✅ 本地榜保留:loadLeaderboard / pushLeaderboardEntry 未删除,作为离线 fallback
584→- ✅ 双 tab UI:本地(amber 主题,立即可用)/ 全球(emerald 主题,按需拉取)+ 刷新按钮 + 加载态 + 错误重试
585→- ✅ 玩家高亮:通过 timestamp 匹配 localStorage[BEACON_CLOUD_LAST_SUBMIT_KEY]emerald 边框 + YOU 徽章
586→- ✅ lint 零错误 + dev HTTP 200 + VLM 8/10
587→- ✅ mini-service 启动稳定:uptime 4+ 分钟,PPID=1(双 fork 模式)
588→- 下一阶段工单:P3 #10-c 全球星潮同步(star-tide-service 已在 mini-services 目录中)
589→
File diff suppressed because it is too large Load Diff
+227
View File
@@ -445,3 +445,230 @@ Stage Summary:
- ✅ P1-b 信标链: 4里程碑(3/7/14/30天) + grace续命, amber→rose渐变 - ✅ P1-b 信标链: 4里程碑(3/7/14/30天) + grace续命, amber→rose渐变
- ✅ 在线游玩 v0.8.1: https://gitea-pages.atdunbg.xyz/Super_Z/echo-nexus/ - ✅ 在线游玩 v0.8.1: https://gitea-pages.atdunbg.xyz/Super_Z/echo-nexus/
- 下一阶段工单: P2 UI样式细节打磨 / P2 云存档+云排行榜 / P3 全纪元叙事 - 下一阶段工单: P2 UI样式细节打磨 / P2 云存档+云排行榜 / P3 全纪元叙事
---
Task ID: 10-a
Agent: full-stack-developer
Task: #5 限时挑战 + #3 星潮类型深化
Work Log:
- 阅读项目上下文:worklog.mdv0.8.110 大系统)+ beacon.ts873行,日+周+链)+ starTide.ts209行,6种星潮)+ gameStore trackBeacon/tickTide + BeaconPanel.tsx,确认四色全息规范与现有架构
- **工单 #5 限时挑战**
- beacon.ts873→1162行)新增限时挑战系统:
- BeaconTimedChallenge/BeaconTimedProgress 接口 + isTimed 字段加入 BeaconScoreEntry
- getTimedSlotKeyUTC 0/4/8/12/16/20 点切分,"timed_YYYY-MM-DD_HH"+ timedSlotKeyToSeedFNV-1a
- generateTimedChallenge(确定性,难度 routinegoal=日基准×0.3-0.55类型公式)
- loadTimedProgress/saveTimedProgress/addTimedProgress/claimTimedReward(排行榜 isTimed
- msUntilNextTimedSlot + TIMED_SLOT_MS=4h + BEACON_TIMED_KEY
- gameStore.tstrackBeacon 返回值新增 timedJustCompleted,三进度同时更新;新增 claimTimedBeacon action
- BeaconPanel.tsx753→944行):日挑战与周挑战之间插入限时挑战区块(amber 主题)
- Hourglass图标+TIMED标题+slotKey+倒计时 + 进度+奖励+领取按钮(emerald)
- 紧急状态:距时段结束<30min 切 rose 脉冲动画+"即将结束"徽章
- 2新动画 timed-glow(amber)/timed-urgent(rose) + 排行榜 TIMED 徽章
- **工单 #3 星潮深化**
- starTide.ts209→289行):TideType 新增 surge/eclipse/prism
- surge(emerald ⇈): crystalsPerSec×2.5 + targetLenBonus=2(解码目标序列+2,更长更难)
- eclipse(rose ◐): crystalsPerSec×0.7 + bossWinRateBonus=0.2BOSS胜率+20%
- prism(fuchsia ◬): insightMultAdd=1.0(洞见×2) + autoDecodeIntervalMult=0.7(自动解码-30%)
- TideModifiers 新增 targetLenBonus/bossWinRateBonus/autoDecodeIntervalMult 可选字段
- TIDE_WEIGHTS 新增3种各12(总136+ getTideModifiers 3新case
- decode.tsgeneratePuzzle 新增 targetLenBonus 参数(surge用,targetLen+stepLimit同步增加)
- gameStore.ts 3处修饰器应用:
- startDecodesurge 时传 targetLenBonus → 谜题更长
- autoDecodeTickprism 时 interval×=autoDecodeIntervalMult
- resolveCurrentNodeeclipse 时 bossWinRate 加 tideBossBonus
- achievements.tsach_tides_all 阈值6→9page.tsx StatsPanel 星潮亲历 /9
- QA 验证:
- lint 零错误(exit 0+ dev HTTP 200 + 编译<30ms
- BeaconPanel VLM 8.5/10(限时区块可见)+ 最终 9/10
- 三新星潮 localStorage 注入触发截图 VLMsurge 8/10、prism 7/10、eclipse 8/10
- 日/周/信标链/6旧星潮全部保留无回归
Stage Summary:
- ✅ beacon.ts +289行(限时挑战完整系统:4h时段+确定性+进度+领奖+排行榜)
- ✅ starTide.ts +80行(3新类型 surge/eclipse/prism + 3新修饰器字段)
- ✅ decode.ts generatePuzzle 支持 targetLenBonussurge 谜题+2
- ✅ gameStore.ts trackBeacon 三进度同步 + claimTimedBeacon + 3处新星潮修饰器
- ✅ BeaconPanel.tsx +191行(amber 限时区块 + 紧急状态 + TIMED 徽章)
- ✅ 严格四色规范:限时amber/surge-emerald/eclipse-rose/prism-fuchsia,零蓝色
- ✅ 确定性:限时挑战同时段同种子同结果(FNV-1a slotKey 哈希)
- ✅ lint 零错误 + dev HTTP 200 + VLM 全部≥7/10
---
Task ID: 10-b
Agent: full-stack-developer
Task: P2/#4 云排行榜 mini-service(信标本机榜升级为云端 Top100)
Work Log:
- 前置阅读:worklogv0.8.1 项目状态)+ beacon.tsBeaconScoreEntry / loadLeaderboard / pushLeaderboardEntry / claimBeaconReward / claimWeeklyReward / claimTimedReward+ BeaconPanel.tsx(排行榜 UI 渲染)+ Caddyfile?XTransformPort=端口号 转发规则)+ examples/websocketmini-service 参考)+ .zscripts/dev.shmini-service 启动模式)
- 发现 worklog 信息滞后:beacon.ts 实际已扩到 1161 行(v0.8.2 已加限时挑战 + claimTimedReward),BeaconPanel 已扩到 953 行
- 创建 mini-service
- `mini-services/leaderboard-service/package.json`:独立 bun 项目,type=modulescripts `dev: bun --hot index.ts` + `start: bun index.ts`,依赖 hono ^4.6.14
- `mini-services/leaderboard-service/index.ts`182 行):
- Hono + cors 中间件(origin: * 全开放,跨端口必须)
- 内存数组存储(最多 1000 条,按 score 降序,同分按时长→时间戳排序)
- 防刷:同 dateKey+challenge 10s 内只接受 1 次提交(Map 记录 lastSubmitAt),返回 429 + retryAfterMs
- GET /api/leaderboard → Top100 + total
- POST /api/leaderboard body {entry} → 校验 schema → 推入 → 排序 → 返回 {entries, total, rank}
- GET /api/leaderboard/stats → {totalSubmissions, uniquePlayers, topScore}
- GET / → 健康检查 {service, version, ok, uptime}
- 端口固定 3030`export default { port, fetch }` 标准 bun 模式
- `mini-services/leaderboard-service/README.md`API 文档 + 联调规范
- mini-service 启动调试:bun --hot 在本沙盒环境下会因文件 watcher 异常被 kill;改用 `bun index.ts`(无 --hot+ 双 fork `( ... & )` 模式启动,PPID=1uptime 4+ 分钟稳定
- eslint.config.mjs 新增 ignores "mini-services/**"mini-service 独立项目,不参与主项目 lint)
- beacon.ts 扩展(1161 → 1290 行,+129 行):
- 三个 claim 函数返回值新增 `entry: BeaconScoreEntry`(非破坏性扩展):
- `claimBeaconReward` 返回 `entry`daily
- `claimWeeklyReward` 返回 `entry`weekly
- `claimTimedReward` 返回 `entry`timed
- 新增云排行榜模块(v0.8.2 P2/#4):
- `BEACON_CLOUD_PORT = 3030` 常量
- `BEACON_CLOUD_LAST_SUBMIT_KEY = "echo-nexus-beacon-cloud-last-submit-v1"` 本地 key
- `CloudLeaderboardResponse` / `CloudSubmitResponse` / `CloudStatsResponse` 接口
- `loadLastCloudSubmitTimestamp()` / `saveLastCloudSubmitTimestamp(ts)` 工具
- `fetchCloudLeaderboard(): Promise<BeaconScoreEntry[]>` — GET 相对路径 + ?XTransformPort=3030
- `submitCloudScore(entry): Promise<number>` — POST,成功返回 rank,失败返回 -1,自动记录 ts 供全球榜高亮
- `fetchCloudStats(): Promise<CloudStatsResponse | null>` — 全局统计
- 所有 fetch 用相对路径 + `?XTransformPort=3030`**禁止** localhost:3030
- gameStore.ts 集成:
- import 新增 `submitCloudScore`
- `claimWeeklyBeacon` action:领奖成功后 `void submitCloudScore(res.entry)`fire-and-forget,不 await,不阻塞)
- `claimTimedBeacon` action:同上
- `claimBeaconReward` (daily) 由 BeaconPanel 直接调用,不通过 gameStore
- BeaconPanel.tsx 重写(953 → 1170 行,+217 行):
- 新增 import: fetchCloudLeaderboard / submitCloudScore / loadLastCloudSubmitTimestamp + 4 个 lucide 图标(RefreshCw/Globe/WifiOff/Loader2
- 新增状态: lbTab ('local'|'global') / cloudEntries / cloudLoading / cloudError / cloudFetched / mySubmitTs / cloudFetchingRef
- 新增 `refreshCloudLeaderboard()` callback:拉取云端榜 + 防并发(ref)+ 空 entries 时显示提示
- useEffect: 切到全球 tab 自动拉取(仅首次)+ 初始化读取 mySubmitTs
- handleClaimDaily: 领奖成功后 `void submitCloudScore(res.entry).then(rank => ...)` — 若用户在全球 tab 自动刷新
- handleClaimTimed/Weekly: 领奖后更新 mySubmitTs + 若在全球 tab 自动刷新
- 排行榜 UI 重写为双 tab:
- 顶部「本地」(amber 主题) / 「全球」(emerald 主题) 切换按钮 + 全球 tab 专属刷新按钮(旋转动画)
- 子标题:本地"本机 Top N · 离线可用" / 全球"全球 Top N · 已同步 / 同步中…"
- 本地榜保留原 v0.8.2 渲染(max-h-200px overflow-y-auto
- 全球榜:
- 加载中:Loader2 spinner + "正在拉取全球榜…"
- 空榜+错误:WifiOff 图标 + 错误文案 + 重试按钮
- 空榜+无错:Globe 图标 + "尚无全球记录"提示
- 有数据:渲染前 100 条,每条带排名(1-3 名用 Crown/Medal/Award 图标)/ 难度色 / WEEK·TIMED 徽章 / 用时 / 分数
- 玩家自己的记录通过 timestamp 匹配 mySubmitTs 高亮:emerald 边框 + emerald 阴影 + YOU 徽章 + emerald 分数色 + 加粗排名
- QA 验证:
- `bun run lint` 零错误零警告(eslint.config.mjs 把 mini-services 加入 ignores
- dev.log 全程无错误,所有编译 < 300ms
- mini-service curl 全通过:
- GET / → 200 {service, version, ok, uptime}
- GET /api/leaderboard (空) → 200 {entries:[], total:0}
- POST /api/leaderboard → 200 {entries, total, rank:1}
- GET /api/leaderboard (有数据) → 200 {entries:[...], total:N}
- GET /api/leaderboard/stats → 200 {totalSubmissions, uniquePlayers, topScore}
- OPTIONS 预检 → 204 + 完整 CORS 头(Allow-Origin: *
- 重复 POST(同 dateKey+challenge 10s 内)→ 429 {error:"Rate limited", retryAfterMs}
- 网关路由验证:Caddy :81 → localhost:3030 通过 ?XTransformPort=3030 正确转发,返回 JSON 200
- agent-browser 集成测试(通过 :81 端口访问,绕过 Next.js 404):
- 信标 tab 选中(用 KeyboardEvent Enter 激活 Radix Tabs,因 pointerdown 被覆盖层拦截)
- 切到「全球」tab → 自动拉取 → 显示"全球 Top 6 · 已同步" → 渲染 6 条记录
- 刷新按钮可点击,旋转动画正常
- POST 测试:浏览器 fetch 提交 score=9999 → 返回 rank=1 → 刷新后榜首显示 10.00K
- YOU 高亮:手动设置 localStorage[BEACON_CLOUD_LAST_SUBMIT_KEY] 后刷新 → 榜首显示 emerald 边框 + YOU 徽章
- VLM 视觉评分(目标 ≥7/10):
- 全球榜含 YOU 高亮:**8/10**(双tab切换清晰,全球榜记录完整,难度色与标签规范,视觉层次佳)
- 本地榜:**8/10**(双tab切换逻辑清晰,本地tab内容完整,四色规范应用,信息层级合理)
Stage Summary:
- ✅ mini-service 独立项目:自己的 package.json + bun.lock + node_modules,依赖 hono ^4.6.14,不污染主项目
- ✅ 端口 3030 + Caddy 网关:前端用相对路径 `fetch('/api/leaderboard?XTransformPort=3030')`**禁止** localhost:3030
- ✅ CORS 全开放:origin: * + OPTIONS 预检自动处理,前端跨端口无忧
- ✅ 内存存储 + 防刷:1000 条上限,同 dateKey+challenge 10s 限 1 次
- ✅ 三个 claim 入口全部接入云提交:claimBeaconRewardBeaconPanel 直接调)/ claimWeeklyBeacon / claimTimedBeacongameStore action 内)
- ✅ 本地榜保留:loadLeaderboard / pushLeaderboardEntry 未删除,作为离线 fallback
- ✅ 双 tab UI:本地(amber 主题,立即可用)/ 全球(emerald 主题,按需拉取)+ 刷新按钮 + 加载态 + 错误重试
- ✅ 玩家高亮:通过 timestamp 匹配 localStorage[BEACON_CLOUD_LAST_SUBMIT_KEY]emerald 边框 + YOU 徽章
- ✅ lint 零错误 + dev HTTP 200 + VLM 8/10
- ✅ mini-service 启动稳定:uptime 4+ 分钟,PPID=1(双 fork 模式)
- 下一阶段工单:P3 #10-c 全球星潮同步(star-tide-service 已在 mini-services 目录中)
---
Task ID: 10-c
Agent: full-stack-developer
Task: #9 手写叙事节点 + P3 socket 多人同步星潮
Work Log:
- 前置阅读:worklog.mdv0.8.1,四色规范 emerald/rose/amber/fuchsia,禁止蓝色,10 大系统)+ chronicle.ts680 行)+ starTide.ts297 行,9 种星潮)+ gameStore.ts1332 行)+ StarTide.tsx170 行)+ StarTideOverlay.tsx + Caddyfile:81 网关 ?XTransformPort 转发规则)+ 前序 10-a/10-b 工单(限时挑战/3 新星潮/云排行榜 mini-service
- **状态确认**:本任务为重试,发现上次重试已落地主体代码,本次为验证 + 补 QA + 启动 mini-service + 追加 worklog
- **工单 #9 手写叙事节点**chronicle.ts 已就绪):
- EPOCH_LORE 常量:5 纪元 × 3 节点(opening/middle/ending= 15 段手写叙事,每段 80-150 字文学性
- 5 纪元主题:1 觉醒(emerald/2 谐振(rose/3 遗迹(amber/4 飞升(fuchsia/5 终末
- 每段含 fallback + variants(按 ctx 命中条件挑变体):
- 第一纪元:解码者(≥15)/学者(techs≥5)/里程碑密集(≥2) + 深度轮回(asc>5)
- 第二纪元:探险者(≥3)/解码者(≥15)/觉醒天赋 + 深度轮回
- 第三纪元:BOSS 猎手(≥2)/深度探险(≥4)/星潮亲历(≥3)/BOSS 收尾 + 深度轮回
- 第四纪元:觉醒天赋/蓝图接近完整(≥5)/里程碑密集 + 深度轮回
- 第五纪元:里程碑密集(≥3)/星潮汹涌(≥3)/觉醒天赋 + 深度轮回
- 变量替换:{decodedThisRun}/{techsThisRun}/{expThisRun}/{bossKillsThisRun}/{blueprintsAfter}/{milestonesCount}/{milestonesList}/{tideCount}/{tideNames}/{perksList}/{minutes}
- buildLore 改造:优先 buildHandwrittenLore,无匹配(ascensionNumber 异常)时 fallback 到 v0.4 模板拼接
- 验证:bun 脚本测试 5 纪元 × 2 上下文(minimal/rich= 10 个 case 全通过,文字长度 233-273 字(含收尾段),变体匹配正确,变量替换无残留 {xxx}
- **P3 socket 多人同步星潮**
- **mini-servicemini-services/star-tide-service/**
- 独立 bun 项目,package.json type=module + scripts dev:bun --hot index.ts + start:bun index.ts,依赖 socket.io ^4.8.3
- index.ts250 行):
- 端口固定 3031path:"/"Caddy 据此转发)
- 6 种星潮类型表(crystal/resonance/ruins/void/core/silence+ 权重表 + rollTide
- DURATION_SEC=60s 全球星潮持续;FIRST_TIDE_DELAY_MS=60sQA 友好,env 可覆盖);MIN/MAX_GAP_MS=10-15 分钟随机间隔
- 调度:startTide → io.emit("global-tide", {type,name,startedAt,endsAt,durationSec}) → 60s 后 endTide → io.emit("tide-ended", {endedAt,nextTideAt}) → scheduleNextTide10-15 分钟随机)
- 客户端连接:socket.emit("tide-state", {current, nextTideAt, serverTime}) 立即推送
- 客户端可 emit "get-next-tide" → 服务端响应 "next-tide-info" {current, nextTideAt, serverTime, remainingMs}
- dev/QA 用 "admin-trigger-tide":立即触发一次全球星潮(不等待下次定时器)
- 优雅退出:SIGTERM/SIGINT → 清 timer → io.close → httpServer.close → exit(0)
- README.mdAPI/事件文档
- **前端集成**
- src/hooks/useGlobalTide.ts127 行):
- 单例 socketsharedSocket + connectRefCount 引用计数,多组件挂载共享同一连接)
- io("/?XTransformPort=3031", {transports:["websocket","polling"], reconnection:true, reconnectionAttempts:Infinity})
- 监听 "global-tide" → triggerRef.current(type),用 startedAt+type 去重(防重连重复触发),过期 tide 不触发
- 监听 "tide-state" → 连接/重连时若服务端正有进行中的全球星潮,补触发
- 主动 emit "get-next-tide" 保险
- 导出 adminTriggerGlobalTidedev 控制台 QA 用),window.__adminTriggerGlobalTide 暴露
- gameStore.ts 新增 triggerGlobalTide action + globalTide state
- GLOBAL_TIDE_DURATION_MS = 60_000 常量
- GlobalTideState 类型 {type, startedAt, endsAt, id}
- TideEvent 新增 isGlobal?: boolean 字段
- triggerGlobalTide(type):创建 newTide60s+ newGlobalTide,覆盖本地 activeTide(即使本地有进行中的星潮也会被替换),加入 _tideEvents 队列 with isGlobal:true
- tickTide 改造:globalTide 优先 → 全球星潮进行中本地不触发新星潮;全球星潮结束时设 globalTide:null + activeTide:null + lastTideEnd + silenceCompensation + ended 事件 with isGlobal:true
- partialize 排除 globalTide(瞬态运行时状态,不持久化)
- page.tsx 顶层挂载 useGlobalTide()
- StarTide.tsx UI 升级:
- StarTideIndicator:读 globalTide → isGlobal 标记 → 加 🌐 emoji + ring-1 ring-amber-300/60 + 强化 boxShadow + 渐变背景 + title 改为 "🌐 全球星潮 · {meta.desc}"duration 用 globalTide.endsAt-startedAt 计算(60s)避免越界
- StarTideOverlay:全球星潮多一道顶部琥珀色光带 + global-tide-sweep 2.5s 扫光动画 + inset boxShadow 加深(160px vs 120px
- StarTideNotifiertoast 文案区分全球/本地("🌐 全球星潮降临:{name}" + "全球玩家同步经历 · {desc}"
- **QA 验证**
- `bun run lint` 零错误零警告
- dev.log 全程无错误,编译 < 70msHTTP 200
- mini-service 启动稳定:pid 9456PPID=1(双 fork 脱离 dev.sh),uptime 19+ 分钟,端口 3031 监听正常
- curl http://localhost:3031/ → {"code":0,"message":"Transport unknown"}socket.io-only 服务预期响应)
- curl http://localhost:3031/socket.io/?EIO=4&transport=polling → 正确握手 {"sid":...,"upgrades":["websocket"]}
- 网关路由验证:curl http://localhost:81/socket.io/?EIO=4&transport=polling&XTransformPort=3031 → 通过 Caddy 转发到 3031,返回正确握手
- socket.io 端到端测试(bun 脚本 /tmp/test-socket.ts):connect ✅ → tide-state ✅ → emit admin-trigger-tide → 504ms 内收到 global-tide {type:ruins, startedAt, endsAt, durationSec:60} ✅
- agent-browser 集成测试(通过 http://localhost:81/ 走 Caddy 网关,socket.io 才能正确路由):
- 页面加载 200window.__adminTriggerGlobalTide 已挂载(useGlobalTide hook 已挂载)
- 调用 adminTriggerGlobalTide() → 1-3s 内 StarTideIndicator 渲染 🌐 芯片
- DOM 验证:芯片文本 "🌐✷虚空低语57s"title "🌐 全球星潮 · 虚空传来回响,洞见获取翻倍"(isGlobal:true 路径生效)
- 第二次触发:"🌐⬢遗迹共振59s"title "🌐 全球星潮 · 远古遗迹苏醒,探险力 +5、生命 +30"
- 全球星潮结束(60s 后)→ activeTide:null + globalTide:null + lastTideEnd 更新 → 本地 tickTide 恢复正常节奏
- VLM 视觉评分:**8/10**(确认 🌐 地球图标 + 汉字名称"遗迹共振" + 倒计时"59s" 全部可见;颜色 emerald/amber/fuchsia/rose 四色规范,零蓝色)
- 编年史 buildLore 验证(bun 脚本测试 5 纪元 × 2 上下文 = 10 case):
- 全部产出 233-273 字文学性叙事,包含开场+中段+结尾+收尾段
- fallback 路径:默认文本,无变量替换
- variant 路径:按 ctx 命中条件挑变体,{decodedThisRun}/{bossKillsThisRun}/{milestonesList}/{perksList} 等变量正确替换
- 纪元名生成正确:第一纪元·初鸣之夕 / 第二纪元·光谱涌动 / 第三纪元·星辉汇聚 / 第四纪元·以太共振 / 第五纪元·永恒闭环
Stage Summary:
- ✅ 工单 #9chronicle.ts EPOCH_LORE 5 纪元 × 3 节点 = 15 段手写叙事(每段 80-150 字文学性),buildLore 优先手写节点 + fallback 模板,buildChronicleEntry/regenerateLoreFromEntry/withPerks 全部接入;10 case 测试通过,变体匹配 + 变量替换正确
- ✅ P3 socket 多人同步星潮:mini-services/star-tide-service 独立 bun 项目,socket.io 端口 3031,每 10-15 分钟随机广播 global-tide60s 持续),admin-trigger-tide 供 QA 触发;前端 useGlobalTide hook + gameStore.triggerGlobalTide + globalTide 状态 + tickTide 全球优先 + StarTide UI 🌐 标记 + toast 区分全球/本地
- ✅ 网关规范:socket.io 严格用 `io("/?XTransformPort=3031")`path "/",禁止 localhost:3031 直连;Caddy :81 → localhost:3031 转发正确
- ✅ 离线容错:socket.io 自动重连(Infinity attempts),断连期间本地 tickTide 继续按原节奏工作,玩家不会被卡住;globalTide 不持久化(瞬态运行时状态)
- ✅ 防重连重复触发:useGlobalTide 用 startedAt+type 去重(Set 上限 32 条 FIFO 清理),过期 tide 不触发
- ✅ 本地星潮保留:全球星潮是增强而非替换,全球星潮结束后本地 tickTide 恢复正常节奏
- ✅ mini-service 启动稳定:pid 9456PPID=1uptime 19+ 分钟,端口 3031 监听正常
- ✅ lint 零错误 + dev HTTP 200 + VLM 8/10(🌐 全球星潮标记清晰可见)
- ✅ 严格四色规范:全球星潮琥珀色扫光(amber)+ 各星潮本色(emerald/rose/amber/fuchsia),零蓝色
- 下一阶段工单:可考虑 v0.9 收尾(全球星潮成就/编年史全球星潮高亮/星图天文台全球星潮增强天赋)