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",
"recharts": "^2.15.4",
"sharp": "^0.34.3",
"socket.io": "^4.8.3",
"socket.io-client": "^4.8.3",
"sonner": "^2.0.6",
"tailwind-merge": "^3.3.1",
"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=="],
"@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/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=="],
"@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-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/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/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=="],
"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-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=="],
"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=="],
"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=="],
"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=="],
"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=="],
"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=="],
"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-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=="],
"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=="],
"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=="],
"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=="],
"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=="],
"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=="],
"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=="],
"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=="],
"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=="],
"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=="],
"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-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",
},
}, {
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;
@@ -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",
"recharts": "^2.15.4",
"sharp": "^0.34.3",
"socket.io": "^4.8.3",
"socket.io-client": "^4.8.3",
"sonner": "^2.0.6",
"tailwind-merge": "^3.3.1",
"tailwindcss-animate": "^1.0.7",
+25 -2
View File
@@ -27,6 +27,7 @@ import {
} from "@/components/game/StarTide";
import { useGameLoop } from "@/hooks/useGameLoop";
import { useAudioSync } from "@/hooks/useAudio";
import { useGlobalTide } from "@/hooks/useGlobalTide";
import { useGameStore } from "@/store/gameStore";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
@@ -54,6 +55,7 @@ import { generateDailyChallenge, loadDailyProgress, loadLeaderboard } from "@/li
export default function Page() {
useGameLoop();
useAudioSync();
useGlobalTide();
const [prestigeOpen, setPrestigeOpen] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const [constellationOpen, setConstellationOpen] = useState(false);
@@ -73,6 +75,7 @@ export default function Page() {
const crystals = useGameStore((s) => s.crystals);
const crystalCap = useGameStore((s) => s.crystalCap);
const activeTide = useGameStore((s) => s.activeTide);
const globalTide = useGameStore((s) => s.globalTide);
const energy = useGameStore((s) => s.energy);
const hasActiveExpedition = useGameStore((s) => !!s.activeExpedition && !s.activeExpedition.finished);
const chronicleCount = useGameStore((s) => s.chronicle?.length ?? 0);
@@ -148,7 +151,8 @@ export default function Page() {
}
if (activeTide) {
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 = "✦ 接触进度已满,可发起飞升进入新周目";
@@ -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 -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">
<CrystalOrb />
</div>
@@ -433,7 +456,7 @@ function StatsPanel() {
{ label: "洞见倍率", value: `×${s.insightMult.toFixed(2)}` },
{ label: "累计探险", value: `${s.totalExpeditions}` },
{ 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: `${Math.floor(s.energy)} / ${s.energyMax}` },
{ label: "信标最高分", value: beaconBest !== null ? formatNum(beaconBest) : "—" },
+494 -62
View File
@@ -2,33 +2,44 @@
// 回响星核 / Echo Nexus — 深空信标面板
// v0.5:每日挑战 + 本地排行榜
// 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 { useToast } from "@/hooks/use-toast";
import { sfx } from "@/hooks/useAudio";
import {
generateDailyChallenge,
generateWeeklyChallenge,
generateTimedChallenge,
loadDailyProgress,
loadWeeklyProgress,
loadTimedProgress,
loadLeaderboard,
loadChainState,
claimBeaconReward,
fetchCloudLeaderboard,
submitCloudScore,
loadLastCloudSubmitTimestamp,
BEACON_DIFFICULTY,
BEACON_TYPE_META,
BEACON_CHAIN_MILESTONES,
BEACON_CHAIN_REWARDS,
getWeekKey,
getTodayKey,
getTimedSlotKey,
getNextMilestone,
getChainProgress,
msUntilNextDay,
msUntilNextWeek,
msUntilNextTimedSlot,
formatCountdown,
type BeaconDailyChallenge,
type BeaconDailyProgress,
type BeaconWeeklyChallenge,
type BeaconWeeklyProgress,
type BeaconTimedChallenge,
type BeaconTimedProgress,
type BeaconScoreEntry,
type BeaconChallengeType,
type BeaconDifficulty,
@@ -48,6 +59,11 @@ import {
Link2,
Flame,
Zap,
Hourglass,
RefreshCw,
Globe,
WifiOff,
Loader2,
} from "lucide-react";
const DIFFICULTY_ORDER: BeaconDifficulty[] = ["routine", "anomaly", "singular"];
@@ -62,11 +78,16 @@ function rankBadge(rank: number) {
export function BeaconPanel() {
const grantBeaconReward = useGameStore((s) => s.grantBeaconReward);
const claimWeeklyBeacon = useGameStore((s) => s.claimWeeklyBeacon);
const claimTimedBeacon = useGameStore((s) => s.claimTimedBeacon);
const claimChainReward = useGameStore((s) => s.claimChainReward);
const { toast } = useToast();
const [challenge, setChallenge] = useState<BeaconDailyChallenge | 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] =
useState<BeaconWeeklyChallenge | null>(null);
const [weeklyProgress, setWeeklyProgress] =
@@ -74,13 +95,25 @@ export function BeaconPanel() {
const [chainState, setChainState] = useState<BeaconChainState | null>(null);
const [leaderboard, setLeaderboard] = useState<BeaconScoreEntry[]>([]);
const [dayCountdown, setDayCountdown] = useState("00:00:00");
const [timedCountdown, setTimedCountdown] = useState("00:00:00");
const [weekCountdown, setWeekCountdown] = useState("00:00:00");
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(() => {
setChallenge(generateDailyChallenge());
setProgress(loadDailyProgress());
setTimedChallenge(generateTimedChallenge());
setTimedProgress(loadTimedProgress());
setWeeklyChallenge(generateWeeklyChallenge());
setWeeklyProgress(loadWeeklyProgress());
setChainState(loadChainState());
@@ -88,9 +121,11 @@ export function BeaconPanel() {
const id = setInterval(() => {
setNow(Date.now());
setProgress(loadDailyProgress());
setTimedProgress(loadTimedProgress());
setWeeklyProgress(loadWeeklyProgress());
setChainState(loadChainState());
setChallenge((c) => c ?? generateDailyChallenge());
setTimedChallenge((c) => c ?? generateTimedChallenge());
setWeeklyChallenge((c) => c ?? generateWeeklyChallenge());
}, 1000);
return () => clearInterval(id);
@@ -98,9 +133,44 @@ export function BeaconPanel() {
useEffect(() => {
setDayCountdown(formatCountdown(msUntilNextDay(new Date(now))));
setTimedCountdown(formatCountdown(msUntilNextTimedSlot(new Date(now))));
setWeekCountdown(formatCountdown(msUntilNextWeek(new Date(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(() => {
if (!challenge || !progress) return;
if (progress.completedAt === null || progress.claimed) return;
@@ -116,7 +186,41 @@ export function BeaconPanel() {
1
)} 接触 · 得分 ${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(() => {
if (!weeklyChallenge || !weeklyProgress) return;
@@ -132,8 +236,13 @@ export function BeaconPanel() {
1
)} 接触 · 得分 ${res.score}`,
});
// 异步刷新云榜(claimWeeklyBeacon 已在 gameStore 内异步提交到云端)
setMySubmitTs(loadLastCloudSubmitTimestamp());
if (lbTab === "global") {
void refreshCloudLeaderboard();
}
}
}, [weeklyChallenge, weeklyProgress, toast, claimWeeklyBeacon]);
}, [weeklyChallenge, weeklyProgress, toast, claimWeeklyBeacon, lbTab, refreshCloudLeaderboard]);
const handleClaimChain = useCallback(
(milestone: number) => {
@@ -155,7 +264,7 @@ export function BeaconPanel() {
[toast, claimChainReward]
);
if (!challenge || !progress || !weeklyChallenge || !weeklyProgress || !chainState) {
if (!challenge || !progress || !timedChallenge || !timedProgress || !weeklyChallenge || !weeklyProgress || !chainState) {
return (
<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 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 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); }
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 {
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); }
@@ -344,6 +469,144 @@ export function BeaconPanel() {
</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">
{/* ===== 周挑战卡片(fuchsia 主题) ===== */}
@@ -671,68 +934,237 @@ export function BeaconPanel() {
</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="flex items-center gap-1.5 mb-2">
<Trophy className="h-3.5 w-3.5 text-amber-400" />
<span className="text-xs font-semibold"></span>
<span className="text-[10px] text-muted-foreground/50"> · Top {leaderboard.length || 0}</span>
{/* 头部:标题 + tab 切换 + (全球 tab 时)刷新按钮 */}
<div className="flex items-center justify-between gap-2 mb-2">
<div className="flex items-center gap-1.5">
<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>
{leaderboard.length === 0 ? (
<div className="text-center py-4 text-[11px] text-muted-foreground/40">
<Trophy className="h-6 w-6 mx-auto mb-1 opacity-30" />
</div>
) : (
<div className="space-y-0.5 max-h-[180px] overflow-y-auto echo-scroll">
{leaderboard.map((entry, i) => {
const rb = rankBadge(i);
const eDiff = BEACON_DIFFICULTY[entry.difficulty];
const eType = BEACON_TYPE_META[entry.challenge as BeaconChallengeType];
const isMine = entry.dateKey === getTodayKey() || entry.dateKey === getWeekKey();
return (
<div
key={`${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"
: 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
{/* 子标题:榜量信息 */}
<div className="mb-1.5 text-[10px] text-muted-foreground/50 flex items-center gap-2">
{lbTab === "local" ? (
<span> Top {leaderboard.length || 0} · 线</span>
) : (
<>
<span> Top {cloudEntries.length || 0}</span>
{cloudFetched && !cloudLoading && cloudEntries.length > 0 && (
<span className="text-emerald-400/60">· </span>
)}
{cloudLoading && (
<span className="text-amber-400/70 flex items-center gap-1">
<Loader2 className="h-2.5 w-2.5 animate-spin" />
</span>
)}
</>
)}
</div>
{/* ===== 本地榜 ===== */}
{lbTab === "local" &&
(leaderboard.length === 0 ? (
<div className="text-center py-4 text-[11px] text-muted-foreground/40">
<Trophy className="h-6 w-6 mx-auto mb-1 opacity-30" />
</div>
) : (
<div className="space-y-0.5 max-h-[200px] overflow-y-auto echo-scroll">
{leaderboard.map((entry, i) => {
const rb = rankBadge(i);
const eDiff = BEACON_DIFFICULTY[entry.difficulty];
const eType = BEACON_TYPE_META[entry.challenge as BeaconChallengeType];
const isMine =
entry.dateKey === getTodayKey() ||
entry.dateKey === getWeekKey() ||
entry.dateKey === getTimedSlotKey();
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>
)}
{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>
<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>
)}
{isMine && (
<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>
+55 -9
View File
@@ -1,6 +1,6 @@
"use client";
// 回响星核 / Echo Nexus — 回响编年史对话框(v0.4 跨周目叙事时间轴)
import { useEffect } from "react";
import { useEffect, useState, useMemo } from "react";
import {
Dialog,
DialogContent,
@@ -20,7 +20,8 @@ import { getPerk } from "@/lib/game/constellation";
import { TIDE_EVENTS } from "@/lib/game/starTide";
import { sfx } from "@/hooks/useAudio";
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 {
open: boolean;
@@ -59,12 +60,26 @@ export function ChronicleDialog({ open, onOpenChange }: Props) {
const bossKills = useGameStore((s) => s.bossKills ?? 0);
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(() => {
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]);
const pagedEntries = entries.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
@@ -108,7 +123,7 @@ export function ChronicleDialog({ open, onOpenChange }: Props) {
</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 ? (
<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="space-y-4">
{entries.map((entry, idx) => (
{pagedEntries.map((entry, idx) => (
<ChronicleCard
key={`${entry.ascensionNumber}-${entry.timestamp}`}
entry={entry}
isLatest={idx === 0}
index={idx}
isLatest={(page - 1) * PAGE_SIZE + idx === 0}
index={(page - 1) * PAGE_SIZE + idx}
/>
))}
</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>
</Dialog>
);
+52 -12
View File
@@ -16,12 +16,15 @@ export function StarTideNotifier() {
if (events.length === 0) return;
const items = consume();
for (const ev of items) {
const globalTag = ev.isGlobal ? "🌐 " : "";
if (ev.started) {
const meta = TIDE_EVENTS[ev.started];
sfx("tideStart");
toast({
title: `${meta.icon} 星潮降临${meta.name}`,
description: meta.desc,
title: `${globalTag}${meta.icon} ${ev.isGlobal ? "全球星潮" : "星潮降临"}${meta.name}`,
description: ev.isGlobal
? `全球玩家同步经历 · ${meta.desc}`
: meta.desc,
});
}
if (ev.ended) {
@@ -29,14 +32,16 @@ export function StarTideNotifier() {
if (ev.silenceCompensation && ev.silenceCompensation > 0) {
sfx("techBuy");
toast({
title: `${meta.icon} 寂静期消散`,
title: `${globalTag}${meta.icon} 寂静期消散`,
description: `虚空回赠 ${ev.silenceCompensation} 洞见。`,
});
} else {
sfx("tideEnd");
toast({
title: `${meta.icon} ${meta.name}结束`,
description: "星潮退去,物理法则恢复。",
title: `${globalTag}${meta.icon} ${meta.name}结束`,
description: ev.isGlobal
? "全球星潮退去,物理法则恢复。"
: "星潮退去,物理法则恢复。",
});
}
}
@@ -49,6 +54,7 @@ export function StarTideNotifier() {
/** 顶部星潮指示器(活跃时显示倒计时芯片) */
export function StarTideIndicator() {
const activeTide = useGameStore((s) => s.activeTide);
const globalTide = useGameStore((s) => s.globalTide);
const [, force] = useState(0);
// 每秒刷新倒计时
@@ -62,18 +68,27 @@ export function StarTideIndicator() {
const meta = TIDE_EVENTS[activeTide.type];
const remaining = Math.max(0, activeTide.endsAt - Date.now());
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 (
<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={{
background: `${meta.color}1a`,
border: `1px solid ${meta.color}55`,
background: isGlobal
? `linear-gradient(135deg, ${meta.color}26, ${meta.color}11)`
: `${meta.color}1a`,
border: `1px solid ${meta.color}${isGlobal ? "88" : "55"}`,
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
@@ -82,6 +97,14 @@ export function StarTideIndicator() {
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 leading-none">{meta.name}</span>
<span className="relative font-mono leading-none opacity-80">{secs}s</span>
@@ -92,8 +115,10 @@ export function StarTideIndicator() {
/** 全屏背景叠层:活跃星潮时给页面罩一层对应色的微光 */
export function StarTideOverlay() {
const activeTide = useGameStore((s) => s.activeTide);
const globalTide = useGameStore((s) => s.globalTide);
if (!activeTide) return null;
const meta = TIDE_EVENTS[activeTide.type];
const isGlobal = !!globalTide;
return (
<div className="fixed inset-0 pointer-events-none z-0 transition-opacity duration-1000">
@@ -115,15 +140,30 @@ export function StarTideOverlay() {
<div
className="absolute inset-0"
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",
}}
/>
{/* 全球星潮:顶部多一道琥珀色光带,强化"全球同步"视觉信号 */}
{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>{`
@keyframes tide-breathe {
0%, 100% { opacity: 0.5; }
50% { opacity: 0.9; }
}
@keyframes global-tide-sweep {
0% { transform: translateX(-30%); }
100% { transform: translateX(30%); }
}
`}</style>
</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",
name: "星潮亲历者",
desc: "经历全部 6 种星潮事件",
desc: "经历全部 9 种星潮事件",
icon: "🌊",
color: "#34d399",
check: (s) => (s.starTidesEncountered?.length ?? 0) >= 6,
check: (s) => (s.starTidesEncountered?.length ?? 0) >= 9,
reward: { crystalsPerSecPct: 8, insightPct: 8 },
rewardText: "产能 +8% · 洞见 +8%",
},
+417 -1
View File
@@ -1,7 +1,8 @@
// 回响星核 / Echo Nexus — 深空信标
// v0.5:每日挑战 + 本地排行榜
// v0.8:周挑战 + 信标链(连续完成奖励)
// 一个自包含的"每日挑战 + 周挑战 + 连续完成链"元系统,纯 localStorage 持久化。
// v0.8.2:限时挑战(每 4 小时刷新,填补日挑战空档)
// 一个自包含的"每日 + 限时 + 周挑战 + 连续完成链"元系统,纯 localStorage 持久化。
/** 每日挑战类型 */
export type BeaconChallengeType =
@@ -32,6 +33,8 @@ export interface BeaconScoreEntry {
durationSec: number;
/** v0.8:是否为周挑战记录(日挑战默认 false / undefined */
isWeekly?: boolean;
/** v0.8.2:是否为限时挑战记录 */
isTimed?: boolean;
}
/** 每日挑战定义 */
@@ -334,6 +337,7 @@ export function claimBeaconReward(
rewardContact: challenge.rewardContact,
score,
leaderboard,
entry,
};
}
@@ -614,6 +618,7 @@ export function claimWeeklyReward(
rewardContact: challenge.rewardContact,
score,
leaderboard,
entry,
};
}
@@ -872,3 +877,414 @@ export function getChainProgress(streak: number): {
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]}`;
}
/** 叙事模板片段 —— 根据本周目数据动态拼装 */
// ============================================================================
// 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: {
ascensionNumber: number;
epochName: string;
@@ -48,6 +389,21 @@ export function buildLore(entry: {
blueprintsAfter: number;
milestones: 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 parts: string[] = [];
@@ -197,6 +553,10 @@ export function buildChronicleEntry(
blueprintsAfter,
crystalsDecoded: state.totalDecoded,
crystalsDecodedThisRun: deltas.decodedThisRun,
// v0.9 手写叙事:归档本周目 delta,让显示时能精确匹配变体
techsThisRun: deltas.techsThisRun,
expeditionsThisRun: deltas.expThisRun,
bossKillsThisRun: deltas.bossKillsThisRun,
},
perksThisAscension,
tidesThisRun: deltas.newTides,
@@ -210,13 +570,15 @@ export function buildChronicleEntry(
* 在显示时调用,保证历史与未来条目命名一致。
*/
export function regenerateLoreFromEntry(entry: ChronicleEntry): string {
// v0.9 优先使用归档的本周目 delta;旧存档(v0.4-v0.8)缺这些字段时退回累计值
// (旧存档的累计值会让叙事变体匹配稍偏,但文本仍然成立)
return buildLore({
ascensionNumber: entry.ascensionNumber,
epochName: entry.epochName,
durationSec: entry.durationSec,
techsThisRun: entry.summary.techsUnlocked,
expThisRun: entry.summary.expeditionsCompleted,
bossKillsThisRun: entry.summary.bossKills,
techsThisRun: entry.summary.techsThisRun ?? entry.summary.techsUnlocked,
expThisRun: entry.summary.expeditionsThisRun ?? entry.summary.expeditionsCompleted,
bossKillsThisRun: entry.summary.bossKillsThisRun ?? entry.summary.bossKills,
decodedThisRun: entry.summary.crystalsDecodedThisRun,
newTides: entry.tidesThisRun || [],
perksThisAscension: entry.perksThisAscension || [],
@@ -270,17 +632,21 @@ export function withPerks(entry: ChronicleEntry, perks: string[]): ChronicleEntr
) {
return entry;
}
// 重新生成 lore 以包含天赋名
const perksNames = perks
.map((id) => PERK_NAME_MAP[id] || id)
.join("、");
const appendText =
perks.length > 0
? ` 飞升之际,星图觉醒「${perksNames}」,铭刻于永恒回响。`
: "";
// 去重:原 lore 若已含「飞升之际」段则不再追加
const hasAwakenSeg = entry.lore.includes("飞升之际");
const lore = hasAwakenSeg ? entry.lore : entry.lore + appendText;
// v0.9 手写叙事:buildLore 已能根据 perks 自动挑选 "觉醒天赋" 变体;
// 直接走 buildLore 重生成,避免与原 lore 中已有的觉醒段落重复。
const lore = buildLore({
ascensionNumber: entry.ascensionNumber,
epochName: entry.epochName,
durationSec: entry.durationSec,
techsThisRun: entry.summary.techsThisRun ?? entry.summary.techsUnlocked,
expThisRun: entry.summary.expeditionsThisRun ?? entry.summary.expeditionsCompleted,
bossKillsThisRun: entry.summary.bossKillsThisRun ?? entry.summary.bossKills,
decodedThisRun: entry.summary.crystalsDecodedThisRun,
newTides: entry.tidesThisRun || [],
perksThisAscension: perks,
blueprintsAfter: entry.summary.blueprintsAfter,
milestones: entry.milestones || [],
});
return {
...entry,
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 s = seed ?? Math.floor(Math.random() * 1e9);
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;
// 目标序列:从 4 色中随机选 targetLen 个(允许重复,但保证至少 2 种色以增加策略)
@@ -132,7 +140,7 @@ export function generatePuzzle(tier: CrystalTier, seed?: number): DecodePuzzle {
cols,
target,
path: [],
stepLimit: cfg.stepLimitBase,
stepLimit: stepLimitBase,
seed: s,
};
}
+90 -3
View File
@@ -1,7 +1,7 @@
// 回响星核 / Echo Nexus — 星潮事件系统(周期性全局玩法修饰)
//
// 深空中偶发的「星潮」会短暂改变物理法则,给放置循环注入动态变化。
// 每 ~40s 间隙后触发一次持续 ~75s 的星潮,6 种类型各有正负效果。
// 每 ~40s 间隙后触发一次持续 ~75s 的星潮,9 种类型各有正负效果。
export type TideType =
| "crystal" // 晶体潮:产能 ×2
@@ -9,7 +9,11 @@ export type TideType =
| "ruins" // 遗迹共振:探险力 +5、生命 +30
| "void" // 虚空低语:洞见 ×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 {
type: TideType;
@@ -29,6 +33,12 @@ export interface TideModifiers {
expeditionHpBonus: 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 = {
@@ -100,9 +110,34 @@ export const TIDE_EVENTS: Record<
icon: "◌",
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> = {
crystal: 22,
resonance: 20,
@@ -110,6 +145,9 @@ const TIDE_WEIGHTS: Record<TideType, number> = {
void: 18,
core: 14,
silence: 10,
surge: 12,
eclipse: 12,
prism: 12,
};
export function rollTide(rng: () => number = Math.random): TideType {
@@ -133,6 +171,9 @@ export function getTideModifiers(tide: StarTide | null): TideModifiers {
pulsePowerMult: 1,
expeditionPowerBonus: 0,
expeditionHpBonus: 0,
targetLenBonus: 0,
bossWinRateBonus: 0,
autoDecodeIntervalMult: 1,
};
}
switch (tide.type) {
@@ -196,6 +237,52 @@ export function getTideModifiers(tide: StarTide | null): TideModifiers {
expeditionPowerBonus: 0,
expeditionHpBonus: 0,
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;
crystalsDecoded: number;
crystalsDecodedThisRun: number;
/** v0.9 手写叙事:本周目 delta(旧存档可能缺失,调用方需兜底) */
techsThisRun?: number;
expeditionsThisRun?: number;
bossKillsThisRun?: number;
};
/** 本次飞升获得的天赋 ID(来自星图觉醒) */
perksThisAscension: string[];
+170 -18
View File
@@ -63,19 +63,26 @@ import {
import {
generateDailyChallenge,
generateWeeklyChallenge,
generateTimedChallenge,
loadDailyProgress,
loadWeeklyProgress,
loadTimedProgress,
loadChainState,
addBeaconProgress,
addWeeklyProgress,
addTimedProgress,
recordChainCompletion,
claimWeeklyReward,
claimTimedReward,
claimChainMilestone,
submitCloudScore,
getTodayKey,
type BeaconDailyChallenge,
type BeaconDailyProgress,
type BeaconWeeklyChallenge,
type BeaconWeeklyProgress,
type BeaconTimedChallenge,
type BeaconTimedProgress,
} from "@/lib/game/beacon";
import { setPendingOfflineReport } from "@/lib/game/offlineReport";
import {
@@ -101,8 +108,10 @@ interface GameActions {
pulse: () => { gain: number; combo: number } | null;
// 星潮
tickTide: (now: number) => { started?: TideType; ended?: TideType; silenceCompensation?: number } | null;
consumeTideEvents: () => { started?: TideType; ended?: TideType; silenceCompensation?: number }[];
tickTide: (now: number) => TideEvent | null;
consumeTideEvents: () => TideEvent[];
/** P3 socket 多人同步:强制触发一道全球星潮(覆盖本地冷却,持续 60s) */
triggerGlobalTide: (type: TideType) => void;
// 解码
startDecode: (crystalId: string) => void;
@@ -140,12 +149,17 @@ interface GameActions {
// 深空信标奖励发放(v0.5
grantBeaconReward: (insights: number, contact: number) => void;
// 深空信标 · 周挑战领取 + 信标链里程碑领取v0.8
// 深空信标 · 周挑战领取 + 信标链里程碑领取 + 限时挑战领取(v0.8 / v0.8.2
claimWeeklyBeacon: () => {
rewardInsight: number;
rewardContact: number;
score: number;
};
claimTimedBeacon: () => {
rewardInsight: number;
rewardContact: number;
score: number;
};
claimChainReward: (milestone: number) => {
rewardInsight: number;
rewardContact: number;
@@ -170,9 +184,31 @@ type Store = GameState & GameActions & {
_combo: number;
_lastPulse: number;
_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>) {
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/
* 1.
* 2.
* 3. grace
* 2. 4
* 3.
* 4. grace
* localStorage GameState
* @returns UI
* @returns UI
*/
function trackBeacon(
type: "pulse" | "decode" | "expedition" | "boss" | "insight",
delta: number
): {
dailyJustCompleted: boolean;
timedJustCompleted: boolean;
weeklyJustCompleted: boolean;
newChainMilestones: number[];
} {
const result = {
dailyJustCompleted: false,
timedJustCompleted: false,
weeklyJustCompleted: false,
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();
if (wChallenge.type === type) {
@@ -269,6 +322,7 @@ export const useGameStore = create<Store>()(
_lastPulse: 0,
_achievementQueue: [],
_tideEvents: [],
globalTide: null,
init: () => {
const s = get();
@@ -354,11 +408,42 @@ export const useGameStore = create<Store>()(
},
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) => {
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 cm = constellationBonuses(s.constellation ?? []);
@@ -366,7 +451,7 @@ export const useGameStore = create<Store>()(
const am = getAllBonuses(s.attributes ?? {});
const tideGapReduction = Math.min(0.3, am.tideTriggerBonus);
const gap = Math.max(15000, (TIDE_CONFIG.gap + cm.tideGapDeltaSec * 1000) * (1 - tideGapReduction));
// 1) 检查当前星潮是否结束
// 2) 检查当前本地星潮是否结束
if (tide && now >= tide.endsAt) {
const endedType = tide.type;
// 寂静期补偿洞见
@@ -375,7 +460,7 @@ export const useGameStore = create<Store>()(
silenceCompensation = computeSilenceCompensation(tide);
}
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;
set({
activeTide: null,
@@ -387,7 +472,7 @@ export const useGameStore = create<Store>()(
});
return event;
}
// 2) 检查是否该触发新星潮(间隙已过)
// 3) 检查是否该触发新星潮(间隙已过)
if (!tide) {
const since = now - s.lastTideEnd;
// 首次:以 createdAt 起算 firstDelay;之后以 lastTideEnd 起算 gap
@@ -401,7 +486,7 @@ export const useGameStore = create<Store>()(
startedAt: now,
endsAt: now + TIDE_CONFIG.duration,
};
const event: { started?: TideType; ended?: TideType; silenceCompensation?: number } = { started: type };
const event: TideEvent = { started: type };
// v0.4 编年史:累计遇到的星潮 ID(去重)
const tidesAll = s.starTidesEncountered ?? [];
const tideId = `tide_${type}`;
@@ -427,6 +512,37 @@ export const useGameStore = create<Store>()(
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) => {
const s = get();
const dt = Math.max(0, (now - s.lastTick) / 1000);
@@ -538,7 +654,10 @@ export const useGameStore = create<Store>()(
const s = get();
const crystal = s.pendingCrystals.find((c) => c.id === crystalId);
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({
activePuzzle: puzzle,
pendingCrystals: s.pendingCrystals.filter((c) => c.id !== crystalId),
@@ -654,7 +773,10 @@ export const useGameStore = create<Store>()(
// v0.7 智慧:自动解码周期 -X%
const am = getAllBonuses(s.attributes ?? {});
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;
// 找一颗 T1 晶体自动解码
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));
// v0.7 勇气:BOSS 战胜率 +X%(动态提高 RNG 阈值)
// v0.8.2 蚀相星潮:BOSS 胜率 +20%
const nodeBefore = exp.nodes[exp.currentNode];
const isBossNode = nodeBefore?.type === "boss";
const am = getAllBonuses(s.attributes ?? {});
const tideMod = getTideModifiers(s.activeTide);
const tideBossBonus = tideMod.bossWinRateBonus ?? 0;
const result = isBossNode
? resolveNode(exp, () => {
// 单次 rng() 调用:B% 概率返回 0(必胜),其余情况返回 r-B(保持均匀分布)
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);
})
: resolveNode(exp);
@@ -1020,6 +1145,33 @@ export const useGameStore = create<Store>()(
insights: s.insights + Math.round(res.rewardInsight),
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 {
rewardInsight: res.rewardInsight,
@@ -1155,8 +1307,8 @@ export const useGameStore = create<Store>()(
storage: createJSONStorage(() => localStorage),
// 不持久化临时字段
partialize: (s) => {
const { _lastAutoDecode, _lastSpawn, _combo, _lastPulse, _achievementQueue, _tideEvents, ...rest } = s;
void _lastAutoDecode; void _lastSpawn; void _combo; void _lastPulse; void _achievementQueue; void _tideEvents;
const { _lastAutoDecode, _lastSpawn, _combo, _lastPulse, _achievementQueue, _tideEvents, globalTide, ...rest } = s;
void _lastAutoDecode; void _lastSpawn; void _combo; void _lastPulse; void _achievementQueue; void _tideEvents; void globalTide;
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渐变
- ✅ 在线游玩 v0.8.1: https://gitea-pages.atdunbg.xyz/Super_Z/echo-nexus/
- 下一阶段工单: 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 收尾(全球星潮成就/编年史全球星潮高亮/星图天文台全球星潮增强天赋)