diff --git a/.zscripts/dev.pid b/.zscripts/dev.pid index b0ddb5802..5f8236cd9 100755 --- a/.zscripts/dev.pid +++ b/.zscripts/dev.pid @@ -1 +1 @@ -6708 +12512 diff --git a/agent-ctx/10-a-full-stack-developer.md b/agent-ctx/10-a-full-stack-developer.md new file mode 100644 index 000000000..2427f0c4f --- /dev/null +++ b/agent-ctx/10-a-full-stack-developer.md @@ -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)`:确定性生成,难度 routine,goal = 日基准 ×0.3-0.5,5 种类型各有公式 +- `msUntilNextTimedSlot(now)`:距离下个 4 小时时段的毫秒数 +- `loadTimedProgress` / `saveTimedProgress` / `addTimedProgress` / `claimTimedReward`(推送排行榜,entry 加 isTimed: true) + +**gameStore.ts 集成**: +- import 扩展:generateTimedChallenge / loadTimedProgress / addTimedProgress / claimTimedReward + 类型 +- `trackBeacon` 返回值新增 `timedJustCompleted`:日/限时/周三类进度同时更新 +- 新增 `claimTimedBeacon` action(GameActions 接口同步) +- 新增 `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-glow(amber 呼吸)/ timed-urgent(rose 急促脉冲) +- 排行榜 entry 区分 TIMED(amber 徽章)/ WEEK(fuchsia 徽章) +- 空状态提示更新为"完成限时、每日或本周信标即可登榜" + +### 工单 #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 + targetLenBonus,stepLimitBase 同步增加 +- 路径构造法自动适配更长目标序列(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 触发): + - surge(emerald 绿):8/10,横幅 + 全屏绿色微光 + - prism(fuchsia 紫):7/10,横幅 + 全屏紫色微光 + - eclipse(rose 粉):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 旧星潮全部保留,无回归 diff --git a/agent-ctx/10-b-full-stack-developer.md b/agent-ctx/10-b-full-stack-developer.md new file mode 100644 index 000000000..298b1061d --- /dev/null +++ b/agent-ctx/10-b-full-stack-developer.md @@ -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 action:grantBeaconReward/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`,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=1(reparent 到 init),进程脱离原 bash session,uptime 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` — GET `/api/leaderboard?XTransformPort=3030` +- `submitCloudScore(entry): Promise` — POST,成功返回 rank ≥ 1,失败返回 -1,自动记录 ts +- `fetchCloudStats(): Promise` +- **所有 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 主题) — 用 ` + + + {tCompleted && timedProgress.durationSec > 0 && ( +
+ 完成用时 {Math.floor(timedProgress.durationSec / 60)}分{timedProgress.durationSec % 60}秒 +
+ )} + + ); + })()} + {/* 周挑战 + 信标链(桌面端并排,移动端单列) */}
{/* ===== 周挑战卡片(fuchsia 主题) ===== */} @@ -671,68 +934,237 @@ export function BeaconPanel() {
- {/* 本地排行榜 */} + {/* 排行榜(本地 Top20 / 全球 Top100 双 tab — v0.8.2 P2/#4) */}
-
- - 深空排行榜 - 本地 · Top {leaderboard.length || 0} + {/* 头部:标题 + tab 切换 + (全球 tab 时)刷新按钮 */} +
+
+ + 深空排行榜 +
+
+ {/* tab 切换 */} +
+ + +
+ {/* 刷新按钮(仅全球 tab) */} + {lbTab === "global" && ( + + )} +
- {leaderboard.length === 0 ? ( -
- - 尚无记录。完成今日或本周信标即可登榜。 -
- ) : ( -
- {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 ( -
- {/* 排名 */} - - {rb ? ( - - ) : ( - {i + 1} - )} - - {/* 类型 + 难度 + 周挑战标记 */} - - {eDiff.icon} - {eType.label} - {entry.isWeekly && ( - - WEEK + {/* 子标题:榜量信息 */} +
+ {lbTab === "local" ? ( + 本机 Top {leaderboard.length || 0} · 离线可用 + ) : ( + <> + 全球 Top {cloudEntries.length || 0} + {cloudFetched && !cloudLoading && cloudEntries.length > 0 && ( + · 已同步 + )} + {cloudLoading && ( + + + 同步中… + + )} + + )} +
+ + {/* ===== 本地榜 ===== */} + {lbTab === "local" && + (leaderboard.length === 0 ? ( +
+ + 尚无记录。完成限时、每日或本周信标即可登榜。 +
+ ) : ( +
+ {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 ( +
+ + {rb ? ( + + ) : ( + {i + 1} + )} + + + {eDiff.icon} + {eType.label} + {entry.isWeekly && ( + + WEEK + + )} + {entry.isTimed && ( + + TIMED + + )} + {entry.progress >= 1 && } + + + {Math.floor(entry.durationSec / 60)}m{entry.durationSec % 60}s + + + {formatNum(entry.score)} + +
+ ); + })} +
+ ))} + + {/* ===== 全球榜 ===== */} + {lbTab === "global" && ( + <> + {cloudLoading && cloudEntries.length === 0 ? ( +
+ + 正在拉取全球榜… +
+ ) : cloudEntries.length === 0 ? ( +
+ {cloudError ? ( + <> + + {cloudError} + + + ) : ( + <> + + 尚无全球记录。完成挑战并领取即可登顶全球榜! + + )} +
+ ) : ( +
+ {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 ( +
+ + {rb ? ( + + ) : ( + + {i + 1} + + )} - )} - {entry.progress >= 1 && } - - {/* 用时 */} - - {Math.floor(entry.durationSec / 60)}m{entry.durationSec % 60}s - - {/* 分数 */} - - {formatNum(entry.score)} - -
- ); - })} -
+ + {eDiff.icon} + {eType.label} + {entry.isWeekly && ( + + WEEK + + )} + {entry.isTimed && ( + + TIMED + + )} + {isMine && ( + + YOU + + )} + {entry.progress >= 1 && } + + + {Math.floor(entry.durationSec / 60)}m{entry.durationSec % 60}s + + + {formatNum(entry.score)} + +
+ ); + })} +
+ )} + )}
diff --git a/src/components/game/ChronicleDialog.tsx b/src/components/game/ChronicleDialog.tsx index deeba2ec7..22ad19f71 100755 --- a/src/components/game/ChronicleDialog.tsx +++ b/src/components/game/ChronicleDialog.tsx @@ -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 ( @@ -108,7 +123,7 @@ export function ChronicleDialog({ open, onOpenChange }: Props) { {/* 时间轴滚动区 */} -
+
{entries.length === 0 ? ( ) : ( @@ -117,18 +132,49 @@ export function ChronicleDialog({ open, onOpenChange }: Props) {
- {entries.map((entry, idx) => ( + {pagedEntries.map((entry, idx) => ( ))}
)}
+ + {/* v0.8.2 分页控制栏(工单 #8) */} + {entries.length > PAGE_SIZE && ( +
+ + 第 {page} / {totalPages} 页 · 共 {entries.length} 条飞升记录 + +
+ + +
+
+ )}
); diff --git a/src/components/game/StarTide.tsx b/src/components/game/StarTide.tsx index a5e3d4fdc..62cf51f7f 100755 --- a/src/components/game/StarTide.tsx +++ b/src/components/game/StarTide.tsx @@ -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 (
{/* 进度环背景 */}
+ {isGlobal && ( + + 🌐 + + )} {meta.icon} {meta.name} {secs}s @@ -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 (
@@ -115,15 +140,30 @@ export function StarTideOverlay() {
+ {/* 全球星潮:顶部多一道琥珀色光带,强化"全球同步"视觉信号 */} + {isGlobal && ( +
+ )}
); diff --git a/src/hooks/useGlobalTide.ts b/src/hooks/useGlobalTide.ts new file mode 100644 index 000000000..54513d18f --- /dev/null +++ b/src/hooks/useGlobalTide.ts @@ -0,0 +1,126 @@ +"use client"; +// 回响星核 / Echo Nexus — 全球星潮同步 hook(P3 / 工单 #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>(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; +} diff --git a/src/lib/game/achievements.ts b/src/lib/game/achievements.ts index 3bbccaed7..5aba9c7c6 100755 --- a/src/lib/game/achievements.ts +++ b/src/lib/game/achievements.ts @@ -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%", }, diff --git a/src/lib/game/beacon.ts b/src/lib/game/beacon.ts index 84ff11b64..36ed831c8 100755 --- a/src/lib/game/beacon.ts +++ b/src/lib/game/beacon.ts @@ -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 点切分,同时段同种子 → 同一挑战(确定性)。 +// 难度固定 routine,goal = 日挑战基准 × 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 小时时段 key(UTC,0/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()); +} + +/** + * 生成当前时段的限时挑战(确定性:同时段同种子 → 同一挑战)。 + * 难度固定 routine,goal = 日挑战基准 × 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(端口 3030,Hono + Bun)提供 API: +// GET /api/leaderboard → { entries, total } +// POST /api/leaderboard body { entry } → { entries, total, rank } +// GET /api/leaderboard/stats → { totalSubmissions, uniquePlayers, topScore } +// 前端请求必须用相对路径 + ?XTransformPort=3030(Caddy 网关转发规则)。 +// 本地排行榜(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 { + 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 { + 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 { + 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; + } +} diff --git a/src/lib/game/chronicle.ts b/src/lib/game/chronicle.ts index bf52454bd..5f793e4c9 100755 --- a/src/lib/game/chronicle.ts +++ b/src/lib/game/chronicle.ts @@ -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, diff --git a/src/lib/game/decode.ts b/src/lib/game/decode.ts index 8cc554da1..8711db6c6 100755 --- a/src/lib/game/decode.ts +++ b/src/lib/game/decode.ts @@ -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, }; } diff --git a/src/lib/game/starTide.ts b/src/lib/game/starTide.ts index a9e735a25..e37d192c0 100755 --- a/src/lib/game/starTide.ts +++ b/src/lib/game/starTide.ts @@ -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 = { crystal: 22, resonance: 20, @@ -110,6 +145,9 @@ const TIDE_WEIGHTS: Record = { 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, }; } } diff --git a/src/lib/game/types.ts b/src/lib/game/types.ts index 020dc5bbb..4c20af1a5 100755 --- a/src/lib/game/types.ts +++ b/src/lib/game/types.ts @@ -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[]; diff --git a/src/store/gameStore.ts b/src/store/gameStore.ts index 4436cd371..c7fe08bb8 100755 --- a/src/store/gameStore.ts +++ b/src/store/gameStore.ts @@ -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) { const s = recomputeStats(state); @@ -189,24 +225,27 @@ function syncStats(state: Partial) { } /** - * 深空信标进度追踪(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()( _lastPulse: 0, _achievementQueue: [], _tideEvents: [], + globalTide: null, init: () => { const s = get(); @@ -354,11 +408,42 @@ export const useGameStore = create()( }, 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()( 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()( 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()( }); return event; } - // 2) 检查是否该触发新星潮(间隙已过) + // 3) 检查是否该触发新星潮(间隙已过) if (!tide) { const since = now - s.lastTideEnd; // 首次:以 createdAt 起算 firstDelay;之后以 lastTideEnd 起算 gap @@ -401,7 +486,7 @@ export const useGameStore = create()( 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()( 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()( 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()( // 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()( // 深拷贝 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()( 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()( 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; }, } diff --git a/tool-results/read_1782256580802_d4075a53770a.txt b/tool-results/read_1782256580802_d4075a53770a.txt new file mode 100644 index 000000000..a71e3dc1c --- /dev/null +++ b/tool-results/read_1782256580802_d4075a53770a.txt @@ -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→ /** 日期 key(YYYY-MM-DD)或周 key(YYYY-Www) */ + 22→ dateKey: string; + 23→ /** 挑战类型 */ + 24→ challenge: BeaconChallengeType; + 25→ /** 难度 */ + 26→ difficulty: BeaconDifficulty; + 27→ /** 完成度(0-1,1 = 完成) */ + 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→ /** 日期 key(YYYY-MM-DD,UTC) */ + 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→ * 难度强制 anomaly(60%)或 singular(40%),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-12(singular 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; + 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 → 7;streak=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→ \ No newline at end of file diff --git a/tool-results/read_1782256585530_72767ecc7b24.txt b/tool-results/read_1782256585530_72767ecc7b24.txt new file mode 100644 index 000000000..d40a5d8ef --- /dev/null +++ b/tool-results/read_1782256585530_72767ecc7b24.txt @@ -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→ /** 日期 key(YYYY-MM-DD)或周 key(YYYY-Www) */ + 22→ 22→ dateKey: string; + 23→ 23→ /** 挑战类型 */ + 24→ 24→ challenge: BeaconChallengeType; + 25→ 25→ /** 难度 */ + 26→ 26→ difficulty: BeaconDifficulty; + 27→ 27→ /** 完成度(0-1,1 = 完成) */ + 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→ /** 日期 key(YYYY-MM-DD,UTC) */ + 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→ * 难度强制 anomaly(60%)或 singular(40%),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-12(singular 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; + 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 → 7;streak=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→ \ No newline at end of file diff --git a/tool-results/read_1782256608776_485e6e3a1bfb.txt b/tool-results/read_1782256608776_485e6e3a1bfb.txt new file mode 100644 index 000000000..c0153ff3f --- /dev/null +++ b/tool-results/read_1782256608776_485e6e3a1bfb.txt @@ -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(null); + 69→ const [progress, setProgress] = useState(null); + 70→ const [weeklyChallenge, setWeeklyChallenge] = + 71→ useState(null); + 72→ const [weeklyProgress, setWeeklyProgress] = + 73→ useState(null); + 74→ const [chainState, setChainState] = useState(null); + 75→ const [leaderboard, setLeaderboard] = useState([]); + 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→
+ 161→ 正在校准深空信标… + 162→
+ 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→
+ 189→ + 225→ + 226→ {/* 头部:信标 + 倒计时 */} + 227→
+ 228→

+ 229→ + 230→ 深空信标 + 231→

+ 232→
+ 233→ + 234→ 次日重置 + 235→ {dayCountdown} + 236→
+ 237→
+ 238→ + 239→ {/* 每日挑战卡片 */} + 240→
+ 248→ {/* 背景装饰:脉冲环 */} + 249→ {!isCompleted && ( + 250→ <> + 251→
+ 258→
+ 265→ + 266→ )} + 267→ + 268→
+ 269→ {/* 难度 + 类型 标签 */} + 270→
+ 271→ + 275→ {diffMeta.icon} + 276→ {diffMeta.label} + 277→ + 278→ + 279→ {typeMeta.icon} {typeMeta.label} + 280→ + 281→ {isCompleted && ( + 282→ + 283→ 已完成 + 284→ + 285→ )} + 286→
+ 287→ + 288→ {/* 挑战标题 */} + 289→

+ 290→ {challenge.title} + 291→

+ 292→

+ 293→ {challenge.desc} + 294→

+ 295→ + 296→ {/* 进度条 */} + 297→
+ 298→
+ 299→ 进度 + 300→ + 301→ {Math.min(progress.progress, challenge.goal)} / {challenge.goal} {typeMeta.unit} + 302→ + 303→
+ 304→ + 311→
+ 312→ + 313→ {/* 奖励 + 领取按钮 */} + 314→
+ 315→
+ 316→ 奖励: + 317→ {challenge.rewardInsight > 0 && ( + 318→ +{formatNum(challenge.rewardInsight)}洞见 + 319→ )} + 320→ +{challenge.rewardContact.toFixed(1)}接触 + 321→
+ 322→ + 336→
+ 337→ + 338→ {/* 完成时长 */} + 339→ {isCompleted && progress.durationSec > 0 && ( + 340→
+ 341→ 完成用时 {Math.floor(progress.durationSec / 60)}分{progress.durationSec % 60}秒 + 342→
+ 343→ )} + 344→
+ 345→
+ 346→ + 347→ {/* 周挑战 + 信标链(桌面端并排,移动端单列) */} + 348→
+ 349→ {/* ===== 周挑战卡片(fuchsia 主题) ===== */} + 350→
+ 358→ {/* 头部:标题 + weekKey + 倒计时 */} + 359→
+ 360→
+ 361→ + 362→ + 363→ 周挑战 · WEEKLY + 364→ + 365→
+ 366→
+ 367→ + 368→ {weeklyChallenge.weekKey} + 369→ + 370→ + 371→ {weekCountdown} + 372→
+ 373→
+ 374→ + 375→ {/* 难度 + 类型 标签 */} + 376→
+ 377→ + 381→ {wDiffMeta.icon} + 382→ {wDiffMeta.label} + 383→ + 384→ + 385→ {wTypeMeta.icon} {wTypeMeta.label} + 386→ + 387→ {wCompleted && ( + 388→ + 389→ 已完成 + 390→ + 391→ )} + 392→
+ 393→ + 394→ {/* 标题 */} + 395→

+ 399→ {weeklyChallenge.title} + 400→

+ 401→

+ 402→ {weeklyChallenge.desc} + 403→

+ 404→ + 405→ {/* 进度条 */} + 406→
+ 407→
+ 408→ 进度 + 409→ + 410→ {Math.min(weeklyProgress.progress, weeklyChallenge.goal)} / {weeklyChallenge.goal} {wTypeMeta.unit} + 411→ + 412→
+ 413→ + 420→
+ 421→ + 422→ {/* 奖励 + 领取按钮 */} + 423→
+ 424→
+ 425→ 奖励: + 426→ {weeklyChallenge.rewardInsight > 0 && ( + 427→ + 428→ +{formatNum(weeklyChallenge.rewardInsight)}洞见 + 429→ + 430→ )} + 431→ + 432→ +{weeklyChallenge.rewardContact.toFixed(1)}接触 + 433→ + 434→
+ 435→ + 457→
+ 458→ + 459→ {wCompleted && weeklyProgress.durationSec > 0 && ( + 460→
+ 461→ 完成用时 {Math.floor(weeklyProgress.durationSec / 60)}分{weeklyProgress.durationSec % 60}秒 + 462→
+ 463→ )} + 464→
+ 465→ + 466→ {/* ===== 信标链卡片(amber→rose 渐变) ===== */} + 467→
+ 474→ {/* 头部:标题 + 当前连续天数 */} + 475→
+ 476→
+ 477→ + 478→ + 479→ 信标链 · CHAIN + 480→ + 481→
+ 482→
+ 483→ + 484→ 连续 + 485→ + 486→ {chainState.currentStreak} + 487→ + 488→ + 489→
+ 490→
+ 491→ + 492→ {/* 今日完成状态 */} + 493→
+ 494→ + 495→ {completedToday + 496→ ? "今日已贡献" + 497→ : "今日尚未完成日挑战"} + 498→ + 499→ + 506→ {completedToday ? "✓ 已记录" : "○ 待完成"} + 507→ + 508→
+ 509→ + 510→ {/* 4 个里程碑节点横向排列 */} + 511→
+ 512→ {/* 节点之间的连线(背景灰) */} + 513→
+ 514→ {/* 已达成部分高亮(基于 prev→next 插值) */} + 515→ {(() => { + 516→ // 节点圆心水平位置(百分比) + 517→ const MILESTONE_POS: Record = { + 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→
+ 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→
+ 577→
+ 587→ {claimed ? ( + 588→ + 589→ ) : ( + 590→ m + 591→ )} + 592→
+ 593→ + 597→ {reward?.label} + 598→ + 599→ {claimed ? ( + 600→ + 601→ 已领 + 602→ + 603→ ) : reachable ? ( + 604→ + 616→ ) : ( + 617→ + 618→ +{reward?.rewardInsight}洞 + 619→ + 620→ )} + 621→
+ 622→ ); + 623→ })} + 624→
+ 625→ + 626→ {/* 进度条 */} + 627→
+ 628→
+ 629→ + 630→ {chainProgress.next === null + 631→ ? "已通关全部里程碑" + 632→ : `下一目标:${chainProgress.next} 天`} + 633→ + 634→ + 635→ {chainProgress.current} + 636→ {chainProgress.next !== null && ` / ${chainProgress.next}`} 天 + 637→ + 638→
+ 639→ + 646→
+ 647→ + 648→ {/* 底部统计 */} + 649→
+ 650→
+ 651→ 最长 + 652→ + 653→ {chainState.longestStreak}天 + 654→ + 655→
+ 656→
+ 657→ 累计 + 658→ + 659→ {chainState.totalCompletions}次 + 660→ + 661→
+ 662→
+ 663→ 续命 + 664→ {chainState.graceUsed >= 1 ? ( + 665→ 已用 + 666→ ) : ( + 667→ 可用 + 668→ )} + 669→
+ 670→
+ 671→
+ 672→
+ 673→ + 674→ {/* 本地排行榜 */} + 675→
+ 676→
+ 677→ + 678→ 深空排行榜 + 679→ 本地 · Top {leaderboard.length || 0} + 680→
+ 681→ + 682→ {leaderboard.length === 0 ? ( + 683→
+ 684→ + 685→ 尚无记录。完成今日或本周信标即可登榜。 + 686→
+ 687→ ) : ( + 688→
+ 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→
+ 705→ {/* 排名 */} + 706→ + 707→ {rb ? ( + 708→ + 709→ ) : ( + 710→ {i + 1} + 711→ )} + 712→ + 713→ {/* 类型 + 难度 + 周挑战标记 */} + 714→ + 715→ {eDiff.icon} + 716→ {eType.label} + 717→ {entry.isWeekly && ( + 718→ + 719→ WEEK + 720→ + 721→ )} + 722→ {entry.progress >= 1 && } + 723→ + 724→ {/* 用时 */} + 725→ + 726→ {Math.floor(entry.durationSec / 60)}m{entry.durationSec % 60}s + 727→ + 728→ {/* 分数 */} + 729→ + 730→ {formatNum(entry.score)} + 731→ + 732→
+ 733→ ); + 734→ })} + 735→
+ 736→ )} + 737→
+ 738→ + 739→ {/* 难度图例 */} + 740→
+ 741→ {DIFFICULTY_ORDER.map((d) => { + 742→ const m = BEACON_DIFFICULTY[d]; + 743→ return ( + 744→ + 745→ {m.icon} + 746→ {m.label} ×{m.mult} + 747→ + 748→ ); + 749→ })} + 750→
+ 751→
+ 752→ ); + 753→} + 754→ \ No newline at end of file diff --git a/tool-results/read_1782256627716_485e6e3a1bfb.txt b/tool-results/read_1782256627716_485e6e3a1bfb.txt new file mode 100644 index 000000000..c0153ff3f --- /dev/null +++ b/tool-results/read_1782256627716_485e6e3a1bfb.txt @@ -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(null); + 69→ const [progress, setProgress] = useState(null); + 70→ const [weeklyChallenge, setWeeklyChallenge] = + 71→ useState(null); + 72→ const [weeklyProgress, setWeeklyProgress] = + 73→ useState(null); + 74→ const [chainState, setChainState] = useState(null); + 75→ const [leaderboard, setLeaderboard] = useState([]); + 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→
+ 161→ 正在校准深空信标… + 162→
+ 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→
+ 189→ + 225→ + 226→ {/* 头部:信标 + 倒计时 */} + 227→
+ 228→

+ 229→ + 230→ 深空信标 + 231→

+ 232→
+ 233→ + 234→ 次日重置 + 235→ {dayCountdown} + 236→
+ 237→
+ 238→ + 239→ {/* 每日挑战卡片 */} + 240→
+ 248→ {/* 背景装饰:脉冲环 */} + 249→ {!isCompleted && ( + 250→ <> + 251→
+ 258→
+ 265→ + 266→ )} + 267→ + 268→
+ 269→ {/* 难度 + 类型 标签 */} + 270→
+ 271→ + 275→ {diffMeta.icon} + 276→ {diffMeta.label} + 277→ + 278→ + 279→ {typeMeta.icon} {typeMeta.label} + 280→ + 281→ {isCompleted && ( + 282→ + 283→ 已完成 + 284→ + 285→ )} + 286→
+ 287→ + 288→ {/* 挑战标题 */} + 289→

+ 290→ {challenge.title} + 291→

+ 292→

+ 293→ {challenge.desc} + 294→

+ 295→ + 296→ {/* 进度条 */} + 297→
+ 298→
+ 299→ 进度 + 300→ + 301→ {Math.min(progress.progress, challenge.goal)} / {challenge.goal} {typeMeta.unit} + 302→ + 303→
+ 304→ + 311→
+ 312→ + 313→ {/* 奖励 + 领取按钮 */} + 314→
+ 315→
+ 316→ 奖励: + 317→ {challenge.rewardInsight > 0 && ( + 318→ +{formatNum(challenge.rewardInsight)}洞见 + 319→ )} + 320→ +{challenge.rewardContact.toFixed(1)}接触 + 321→
+ 322→ + 336→
+ 337→ + 338→ {/* 完成时长 */} + 339→ {isCompleted && progress.durationSec > 0 && ( + 340→
+ 341→ 完成用时 {Math.floor(progress.durationSec / 60)}分{progress.durationSec % 60}秒 + 342→
+ 343→ )} + 344→
+ 345→
+ 346→ + 347→ {/* 周挑战 + 信标链(桌面端并排,移动端单列) */} + 348→
+ 349→ {/* ===== 周挑战卡片(fuchsia 主题) ===== */} + 350→
+ 358→ {/* 头部:标题 + weekKey + 倒计时 */} + 359→
+ 360→
+ 361→ + 362→ + 363→ 周挑战 · WEEKLY + 364→ + 365→
+ 366→
+ 367→ + 368→ {weeklyChallenge.weekKey} + 369→ + 370→ + 371→ {weekCountdown} + 372→
+ 373→
+ 374→ + 375→ {/* 难度 + 类型 标签 */} + 376→
+ 377→ + 381→ {wDiffMeta.icon} + 382→ {wDiffMeta.label} + 383→ + 384→ + 385→ {wTypeMeta.icon} {wTypeMeta.label} + 386→ + 387→ {wCompleted && ( + 388→ + 389→ 已完成 + 390→ + 391→ )} + 392→
+ 393→ + 394→ {/* 标题 */} + 395→

+ 399→ {weeklyChallenge.title} + 400→

+ 401→

+ 402→ {weeklyChallenge.desc} + 403→

+ 404→ + 405→ {/* 进度条 */} + 406→
+ 407→
+ 408→ 进度 + 409→ + 410→ {Math.min(weeklyProgress.progress, weeklyChallenge.goal)} / {weeklyChallenge.goal} {wTypeMeta.unit} + 411→ + 412→
+ 413→ + 420→
+ 421→ + 422→ {/* 奖励 + 领取按钮 */} + 423→
+ 424→
+ 425→ 奖励: + 426→ {weeklyChallenge.rewardInsight > 0 && ( + 427→ + 428→ +{formatNum(weeklyChallenge.rewardInsight)}洞见 + 429→ + 430→ )} + 431→ + 432→ +{weeklyChallenge.rewardContact.toFixed(1)}接触 + 433→ + 434→
+ 435→ + 457→
+ 458→ + 459→ {wCompleted && weeklyProgress.durationSec > 0 && ( + 460→
+ 461→ 完成用时 {Math.floor(weeklyProgress.durationSec / 60)}分{weeklyProgress.durationSec % 60}秒 + 462→
+ 463→ )} + 464→
+ 465→ + 466→ {/* ===== 信标链卡片(amber→rose 渐变) ===== */} + 467→
+ 474→ {/* 头部:标题 + 当前连续天数 */} + 475→
+ 476→
+ 477→ + 478→ + 479→ 信标链 · CHAIN + 480→ + 481→
+ 482→
+ 483→ + 484→ 连续 + 485→ + 486→ {chainState.currentStreak} + 487→ + 488→ + 489→
+ 490→
+ 491→ + 492→ {/* 今日完成状态 */} + 493→
+ 494→ + 495→ {completedToday + 496→ ? "今日已贡献" + 497→ : "今日尚未完成日挑战"} + 498→ + 499→ + 506→ {completedToday ? "✓ 已记录" : "○ 待完成"} + 507→ + 508→
+ 509→ + 510→ {/* 4 个里程碑节点横向排列 */} + 511→
+ 512→ {/* 节点之间的连线(背景灰) */} + 513→
+ 514→ {/* 已达成部分高亮(基于 prev→next 插值) */} + 515→ {(() => { + 516→ // 节点圆心水平位置(百分比) + 517→ const MILESTONE_POS: Record = { + 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→
+ 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→
+ 577→
+ 587→ {claimed ? ( + 588→ + 589→ ) : ( + 590→ m + 591→ )} + 592→
+ 593→ + 597→ {reward?.label} + 598→ + 599→ {claimed ? ( + 600→ + 601→ 已领 + 602→ + 603→ ) : reachable ? ( + 604→ + 616→ ) : ( + 617→ + 618→ +{reward?.rewardInsight}洞 + 619→ + 620→ )} + 621→
+ 622→ ); + 623→ })} + 624→
+ 625→ + 626→ {/* 进度条 */} + 627→
+ 628→
+ 629→ + 630→ {chainProgress.next === null + 631→ ? "已通关全部里程碑" + 632→ : `下一目标:${chainProgress.next} 天`} + 633→ + 634→ + 635→ {chainProgress.current} + 636→ {chainProgress.next !== null && ` / ${chainProgress.next}`} 天 + 637→ + 638→
+ 639→ + 646→
+ 647→ + 648→ {/* 底部统计 */} + 649→
+ 650→
+ 651→ 最长 + 652→ + 653→ {chainState.longestStreak}天 + 654→ + 655→
+ 656→
+ 657→ 累计 + 658→ + 659→ {chainState.totalCompletions}次 + 660→ + 661→
+ 662→
+ 663→ 续命 + 664→ {chainState.graceUsed >= 1 ? ( + 665→ 已用 + 666→ ) : ( + 667→ 可用 + 668→ )} + 669→
+ 670→
+ 671→
+ 672→
+ 673→ + 674→ {/* 本地排行榜 */} + 675→
+ 676→
+ 677→ + 678→ 深空排行榜 + 679→ 本地 · Top {leaderboard.length || 0} + 680→
+ 681→ + 682→ {leaderboard.length === 0 ? ( + 683→
+ 684→ + 685→ 尚无记录。完成今日或本周信标即可登榜。 + 686→
+ 687→ ) : ( + 688→
+ 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→
+ 705→ {/* 排名 */} + 706→ + 707→ {rb ? ( + 708→ + 709→ ) : ( + 710→ {i + 1} + 711→ )} + 712→ + 713→ {/* 类型 + 难度 + 周挑战标记 */} + 714→ + 715→ {eDiff.icon} + 716→ {eType.label} + 717→ {entry.isWeekly && ( + 718→ + 719→ WEEK + 720→ + 721→ )} + 722→ {entry.progress >= 1 && } + 723→ + 724→ {/* 用时 */} + 725→ + 726→ {Math.floor(entry.durationSec / 60)}m{entry.durationSec % 60}s + 727→ + 728→ {/* 分数 */} + 729→ + 730→ {formatNum(entry.score)} + 731→ + 732→
+ 733→ ); + 734→ })} + 735→
+ 736→ )} + 737→
+ 738→ + 739→ {/* 难度图例 */} + 740→
+ 741→ {DIFFICULTY_ORDER.map((d) => { + 742→ const m = BEACON_DIFFICULTY[d]; + 743→ return ( + 744→ + 745→ {m.icon} + 746→ {m.label} ×{m.mult} + 747→ + 748→ ); + 749→ })} + 750→
+ 751→
+ 752→ ); + 753→} + 754→ \ No newline at end of file diff --git a/tool-results/read_1782256630875_5c5d543c6f6a.txt b/tool-results/read_1782256630875_5c5d543c6f6a.txt new file mode 100644 index 000000000..82b226323 --- /dev/null +++ b/tool-results/read_1782256630875_5c5d543c6f6a.txt @@ -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(null); + 69→ 69→ const [progress, setProgress] = useState(null); + 70→ 70→ const [weeklyChallenge, setWeeklyChallenge] = + 71→ 71→ useState(null); + 72→ 72→ const [weeklyProgress, setWeeklyProgress] = + 73→ 73→ useState(null); + 74→ 74→ const [chainState, setChainState] = useState(null); + 75→ 75→ const [leaderboard, setLeaderboard] = useState([]); + 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→
+ 161→ 161→ 正在校准深空信标… + 162→ 162→
+ 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→
+ 189→ 189→ + 225→ 225→ + 226→ 226→ {/* 头部:信标 + 倒计时 */} + 227→ 227→
+ 228→ 228→

+ 229→ 229→ + 230→ 230→ 深空信标 + 231→ 231→

+ 232→ 232→
+ 233→ 233→ + 234→ 234→ 次日重置 + 235→ 235→ {dayCountdown} + 236→ 236→
+ 237→ 237→
+ 238→ 238→ + 239→ 239→ {/* 每日挑战卡片 */} + 240→ 240→
+ 248→ 248→ {/* 背景装饰:脉冲环 */} + 249→ 249→ {!isCompleted && ( + 250→ 250→ <> + 251→ 251→
+ 258→ 258→
+ 265→ 265→ + 266→ 266→ )} + 267→ 267→ + 268→ 268→
+ 269→ 269→ {/* 难度 + 类型 标签 */} + 270→ 270→
+ 271→ 271→ + 275→ 275→ {diffMeta.icon} + 276→ 276→ {diffMeta.label} + 277→ 277→ + 278→ 278→ + 279→ 279→ {typeMeta.icon} {typeMeta.label} + 280→ 280→ + 281→ 281→ {isCompleted && ( + 282→ 282→ + 283→ 283→ 已完成 + 284→ 284→ + 285→ 285→ )} + 286→ 286→
+ 287→ 287→ + 288→ 288→ {/* 挑战标题 */} + 289→ 289→

+ 290→ 290→ {challenge.title} + 291→ 291→

+ 292→ 292→

+ 293→ 293→ {challenge.desc} + 294→ 294→

+ 295→ 295→ + 296→ 296→ {/* 进度条 */} + 297→ 297→
+ 298→ 298→
+ 299→ 299→ 进度 + 300→ 300→ + 301→ 301→ {Math.min(progress.progress, challenge.goal)} / {challenge.goal} {typeMeta.unit} + 302→ 302→ + 303→ 303→
+ 304→ 304→ + 311→ 311→
+ 312→ 312→ + 313→ 313→ {/* 奖励 + 领取按钮 */} + 314→ 314→
+ 315→ 315→
+ 316→ 316→ 奖励: + 317→ 317→ {challenge.rewardInsight > 0 && ( + 318→ 318→ +{formatNum(challenge.rewardInsight)}洞见 + 319→ 319→ )} + 320→ 320→ +{challenge.rewardContact.toFixed(1)}接触 + 321→ 321→
+ 322→ 322→ + 336→ 336→
+ 337→ 337→ + 338→ 338→ {/* 完成时长 */} + 339→ 339→ {isCompleted && progress.durationSec > 0 && ( + 340→ 340→
+ 341→ 341→ 完成用时 {Math.floor(progress.durationSec / 60)}分{progress.durationSec % 60}秒 + 342→ 342→
+ 343→ 343→ )} + 344→ 344→
+ 345→ 345→
+ 346→ 346→ + 347→ 347→ {/* 周挑战 + 信标链(桌面端并排,移动端单列) */} + 348→ 348→
+ 349→ 349→ {/* ===== 周挑战卡片(fuchsia 主题) ===== */} + 350→ 350→
+ 358→ 358→ {/* 头部:标题 + weekKey + 倒计时 */} + 359→ 359→
+ 360→ 360→
+ 361→ 361→ + 362→ 362→ + 363→ 363→ 周挑战 · WEEKLY + 364→ 364→ + 365→ 365→
+ 366→ 366→
+ 367→ 367→ + 368→ 368→ {weeklyChallenge.weekKey} + 369→ 369→ + 370→ 370→ + 371→ 371→ {weekCountdown} + 372→ 372→
+ 373→ 373→
+ 374→ 374→ + 375→ 375→ {/* 难度 + 类型 标签 */} + 376→ 376→
+ 377→ 377→ + 381→ 381→ {wDiffMeta.icon} + 382→ 382→ {wDiffMeta.label} + 383→ 383→ + 384→ 384→ + 385→ 385→ {wTypeMeta.icon} {wTypeMeta.label} + 386→ 386→ + 387→ 387→ {wCompleted && ( + 388→ 388→ + 389→ 389→ 已完成 + 390→ 390→ + 391→ 391→ )} + 392→ 392→
+ 393→ 393→ + 394→ 394→ {/* 标题 */} + 395→ 395→

+ 399→ 399→ {weeklyChallenge.title} + 400→ 400→

+ 401→ 401→

+ 402→ 402→ {weeklyChallenge.desc} + 403→ 403→

+ 404→ 404→ + 405→ 405→ {/* 进度条 */} + 406→ 406→
+ 407→ 407→
+ 408→ 408→ 进度 + 409→ 409→ + 410→ 410→ {Math.min(weeklyProgress.progress, weeklyChallenge.goal)} / {weeklyChallenge.goal} {wTypeMeta.unit} + 411→ 411→ + 412→ 412→
+ 413→ 413→ + 420→ 420→
+ 421→ 421→ + 422→ 422→ {/* 奖励 + 领取按钮 */} + 423→ 423→
+ 424→ 424→
+ 425→ 425→ 奖励: + 426→ 426→ {weeklyChallenge.rewardInsight > 0 && ( + 427→ 427→ + 428→ 428→ +{formatNum(weeklyChallenge.rewardInsight)}洞见 + 429→ 429→ + 430→ 430→ )} + 431→ 431→ + 432→ 432→ +{weeklyChallenge.rewardContact.toFixed(1)}接触 + 433→ 433→ + 434→ 434→
+ 435→ 435→ + 457→ 457→
+ 458→ 458→ + 459→ 459→ {wCompleted && weeklyProgress.durationSec > 0 && ( + 460→ 460→
+ 461→ 461→ 完成用时 {Math.floor(weeklyProgress.durationSec / 60)}分{weeklyProgress.durationSec % 60}秒 + 462→ 462→
+ 463→ 463→ )} + 464→ 464→
+ 465→ 465→ + 466→ 466→ {/* ===== 信标链卡片(amber→rose 渐变) ===== */} + 467→ 467→
+ 474→ 474→ {/* 头部:标题 + 当前连续天数 */} + 475→ 475→
+ 476→ 476→
+ 477→ 477→ + 478→ 478→ + 479→ 479→ 信标链 · CHAIN + 480→ 480→ + 481→ 481→
+ 482→ 482→
+ 483→ 483→ + 484→ 484→ 连续 + 485→ 485→ + 486→ 486→ {chainState.currentStreak} + 487→ 487→ + 488→ 488→ + 489→ 489→
+ 490→ 490→
+ 491→ 491→ + 492→ 492→ {/* 今日完成状态 */} + 493→ 493→
+ 494→ 494→ + 495→ 495→ {completedToday + 496→ 496→ ? "今日已贡献" + 497→ 497→ : "今日尚未完成日挑战"} + 498→ 498→ + 499→ 499→ + 506→ 506→ {completedToday ? "✓ 已记录" : "○ 待完成"} + 507→ 507→ + 508→ 508→
+ 509→ 509→ + 510→ 510→ {/* 4 个里程碑节点横向排列 */} + 511→ 511→
+ 512→ 512→ {/* 节点之间的连线(背景灰) */} + 513→ 513→
+ 514→ 514→ {/* 已达成部分高亮(基于 prev→next 插值) */} + 515→ 515→ {(() => { + 516→ 516→ // 节点圆心水平位置(百分比) + 517→ 517→ const MILESTONE_POS: Record = { + 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→
+ 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→
+ 577→ 577→
+ 587→ 587→ {claimed ? ( + 588→ 588→ + 589→ 589→ ) : ( + 590→ 590→ m + 591→ 591→ )} + 592→ 592→
+ 593→ 593→ + 597→ 597→ {reward?.label} + 598→ 598→ + 599→ 599→ {claimed ? ( + 600→ 600→ + 601→ 601→ 已领 + 602→ 602→ + 603→ 603→ ) : reachable ? ( + 604→ 604→ + 616→ 616→ ) : ( + 617→ 617→ + 618→ 618→ +{reward?.rewardInsight}洞 + 619→ 619→ + 620→ 620→ )} + 621→ 621→
+ 622→ 622→ ); + 623→ 623→ })} + 624→ 624→
+ 625→ 625→ + 626→ 626→ {/* 进度条 */} + 627→ 627→
+ 628→ 628→
+ 629→ 629→ + 630→ 630→ {chainProgress.next === null + 631→ 631→ ? "已通关全部里程碑" + 632→ 632→ : `下一目标:${chainProgress.next} 天`} + 633→ 633→ + 634→ 634→ + 635→ 635→ {chainProgress.current} + 636→ 636→ {chainProgress.next !== null && ` / ${chainProgress.next}`} 天 + 637→ 637→ + 638→ 638→
+ 639→ 639→ + 646→ 646→
+ 647→ 647→ + 648→ 648→ {/* 底部统计 */} + 649→ 649→
+ 650→ 650→
+ 651→ 651→ 最长 + 652→ 652→ + 653→ 653→ {chainState.longestStreak}天 + 654→ 654→ + 655→ 655→
+ 656→ 656→
+ 657→ 657→ 累计 + 658→ 658→ + 659→ 659→ {chainState.totalCompletions}次 + 660→ 660→ + 661→ 661→
+ 662→ 662→
+ 663→ 663→ 续命 + 664→ 664→ {chainState.graceUsed >= 1 ? ( + 665→ 665→ 已用 + 666→ 666→ ) : ( + 667→ 667→ 可用 + 668→ 668→ )} + 669→ 669→
+ 670→ 670→
+ 671→ 671→
+ 672→ 672→
+ 673→ 673→ + 674→ 674→ {/* 本地排行榜 */} + 675→ 675→
+ 676→ 676→
+ 677→ 677→ + 678→ 678→ 深空排行榜 + 679→ 679→ 本地 · Top {leaderboard.length || 0} + 680→ 680→
+ 681→ 681→ + 682→ 682→ {leaderboard.length === 0 ? ( + 683→ 683→
+ 684→ 684→ + 685→ 685→ 尚无记录。完成今日或本周信标即可登榜。 + 686→ 686→
+ 687→ 687→ ) : ( + 688→ 688→
+ 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→
+ 705→ 705→ {/* 排名 */} + 706→ 706→ + 707→ 707→ {rb ? ( + 708→ 708→ + 709→ 709→ ) : ( + 710→ 710→ {i + 1} + 711→ 711→ )} + 712→ 712→ + 713→ 713→ {/* 类型 + 难度 + 周挑战标记 */} + 714→ 714→ + 715→ 715→ {eDiff.icon} + 716→ 716→ {eType.label} + 717→ 717→ {entry.isWeekly && ( + 718→ 718→ + 719→ 719→ WEEK + 720→ 720→ + 721→ 721→ )} + 722→ 722→ {entry.progress >= 1 && } + 723→ 723→ + 724→ 724→ {/* 用时 */} + 725→ 725→ + 726→ 726→ {Math.floor(entry.durationSec / 60)}m{entry.durationSec % 60}s + 727→ 727→ + 728→ 728→ {/* 分数 */} + 729→ 729→ + 730→ 730→ {formatNum(entry.score)} + 731→ 731→ + 732→ 732→
+ 733→ 733→ ); + 734→ 734→ })} + 735→ 735→
+ 736→ 736→ )} + 737→ 737→
+ 738→ 738→ + 739→ 739→ {/* 难度图例 */} + 740→ 740→
+ 741→ 741→ {DIFFICULTY_ORDER.map((d) => { + 742→ 742→ const m = BEACON_DIFFICULTY[d]; + 743→ 743→ return ( + 744→ 744→ + 745→ 745→ {m.icon} + 746→ 746→ {m.label} ×{m.mult} + 747→ 747→ + 748→ 748→ ); + 749→ 749→ })} + 750→ 750→
+ 751→ 751→
+ 752→ 752→ ); + 753→ 753→} + 754→ 754→ \ No newline at end of file diff --git a/tool-results/read_1782258471273_f44d3343726b.txt b/tool-results/read_1782258471273_f44d3343726b.txt new file mode 100644 index 000000000..453d215d9 --- /dev/null +++ b/tool-results/read_1782258471273_f44d3343726b.txt @@ -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=10,job_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 2,ResizeObserver 自适应,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 actions;pulse/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 Canvas,DPR cap 2,resize 监听 + 118→ - 3 层视差星空:远(100星)/中(60星)/近(30星),独立漂移 + 飞船速度视差 + 闪烁 + 119→ - 8 种实体全部 ctx.shadowBlur 辉光绘制 + 120→ - 粒子系统:尾焰/收集/碰撞/烟花,上限 200 + 121→ - 屏幕震动 280ms 衰减;低护盾(<30%)红色边框脉冲 + 122→ - HUD(HTML 叠层,glass+backdrop-blur,80ms 节流):护盾/能量/分数/用时/收集计数 + 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?})` action(crystals 受 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→- 奖励同步 gameStore(grantCruiseReward,满仓时 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→ - resolveCurrentNode:BOSS 节点包装 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→ - 顶部待分配点数 badge(pendingAttrPoints > 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 个「角色」TabsTrigger(User 图标,渐变主题) + 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-pages(v0.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 228329(tz=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.6M(HTTP 200 编译 4.3s) + 292→- 推送 gh-pages 分支:force push origin gh-pages(commit 8727fed "deploy: v0.7") + 293→- 提交 main:README 更新(commit 43dfc2d "docs: README 添加在线游玩链接 + v0.7 状态更新") + 294→- 验证 Gitea Pages:https://gitea-pages.atdunbg.xyz/Super_Z/echo-nexus/ → HTTP 200 ✅ + 295→- QA 验证:agent-browser 打开 http://localhost:3000/ → VLM 评分 8/10(主界面 polished,minor 空白) + 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 job:fixed_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/10:v0.8 版本号 ✅ + 巡航按钮 ✅ + 322→ - 巡航 READY 阶段 VLM 8/10:BOSS 战说明 ✅(每5关BOSS战/击败BOSS)+ 射击控制说明 ✅(空格/右下角射击按钮) + 323→ - 巡航第1关游戏 VLM 8/10:飞船(绿色三角)✅ + HUD(护盾/能量/分数/计时)✅ + 陨石/收集物/能量场 ✅ + 无渲染错误 + 324→- 提交 v0.8(commit 494bc5f)+ 推送 main + 325→- 静态导出 BUILD_EXPORT=true → out/ 1.6M(编译 4.1s) + 326→- 部署 gh-pages(force 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.8:https://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" + 大字号 streak(chain-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. 隔一天 miss(lastCompleted + 2 天)→ streak=4,graceUsed=1 ✅ + 393→ 5. 再次 miss(grace 已用)→ 断链 streak=1,graceUsed=0 ✅ + 394→ - **UI 集成测试**(agent-browser): + 395→ - localStorage 模拟设置 chain state(lastCompletedDateKey=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 个独立 key(weekly + 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.1(commit 71ca5b4)+ 推送 main + 439→- 静态导出 + 部署 gh-pages(commit 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.md(v0.8.1,10 大系统)+ beacon.ts(873行,日+周+链)+ starTide.ts(209行,6种星潮)+ gameStore trackBeacon/tickTide + BeaconPanel.tsx,确认四色全息规范与现有架构 + 456→- **工单 #5 限时挑战**: + 457→ - beacon.ts(873→1162行)新增限时挑战系统: + 458→ - BeaconTimedChallenge/BeaconTimedProgress 接口 + isTimed 字段加入 BeaconScoreEntry + 459→ - getTimedSlotKey(UTC 0/4/8/12/16/20 点切分,"timed_YYYY-MM-DD_HH")+ timedSlotKeyToSeed(FNV-1a) + 460→ - generateTimedChallenge(确定性,难度 routine,goal=日基准×0.3-0.5,5类型公式) + 461→ - loadTimedProgress/saveTimedProgress/addTimedProgress/claimTimedReward(排行榜 isTimed) + 462→ - msUntilNextTimedSlot + TIMED_SLOT_MS=4h + BEACON_TIMED_KEY + 463→ - gameStore.ts:trackBeacon 返回值新增 timedJustCompleted,三进度同时更新;新增 claimTimedBeacon action + 464→ - BeaconPanel.tsx(753→944行):日挑战与周挑战之间插入限时挑战区块(amber 主题) + 465→ - Hourglass图标+TIMED标题+slotKey+倒计时 + 进度+奖励+领取按钮(emerald) + 466→ - 紧急状态:距时段结束<30min 切 rose 脉冲动画+"即将结束"徽章 + 467→ - 2新动画 timed-glow(amber)/timed-urgent(rose) + 排行榜 TIMED 徽章 + 468→- **工单 #3 星潮深化**: + 469→ - starTide.ts(209→289行):TideType 新增 surge/eclipse/prism + 470→ - surge(emerald ⇈): crystalsPerSec×2.5 + targetLenBonus=2(解码目标序列+2,更长更难) + 471→ - eclipse(rose ◐): crystalsPerSec×0.7 + bossWinRateBonus=0.2(BOSS胜率+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.ts:generatePuzzle 新增 targetLenBonus 参数(surge用,targetLen+stepLimit同步增加) + 476→ - gameStore.ts 3处修饰器应用: + 477→ - startDecode:surge 时传 targetLenBonus → 谜题更长 + 478→ - autoDecodeTick:prism 时 interval×=autoDecodeIntervalMult + 479→ - resolveCurrentNode:eclipse 时 bossWinRate 加 tideBossBonus + 480→ - achievements.ts:ach_tides_all 阈值6→9;page.tsx StatsPanel 星潮亲历 /9 + 481→- QA 验证: + 482→ - lint 零错误(exit 0)+ dev HTTP 200 + 编译<30ms + 483→ - BeaconPanel VLM 8.5/10(限时区块可见)+ 最终 9/10 + 484→ - 三新星潮 localStorage 注入触发截图 VLM:surge 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 支持 targetLenBonus(surge 谜题+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→- 前置阅读:worklog(v0.8.1 项目状态)+ beacon.ts(BeaconScoreEntry / loadLeaderboard / pushLeaderboardEntry / claimBeaconReward / claimWeeklyReward / claimTimedReward)+ BeaconPanel.tsx(排行榜 UI 渲染)+ Caddyfile(?XTransformPort=端口号 转发规则)+ examples/websocket(mini-service 参考)+ .zscripts/dev.sh(mini-service 启动模式) + 504→- 发现 worklog 信息滞后:beacon.ts 实际已扩到 1161 行(v0.8.2 已加限时挑战 + claimTimedReward),BeaconPanel 已扩到 953 行 + 505→- 创建 mini-service: + 506→ - `mini-services/leaderboard-service/package.json`:独立 bun 项目,type=module,scripts `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=1,uptime 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` — GET 相对路径 + ?XTransformPort=3030 + 530→ - `submitCloudScore(entry): Promise` — POST,成功返回 rank,失败返回 -1,自动记录 ts 供全球榜高亮 + 531→ - `fetchCloudStats(): Promise` — 全局统计 + 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 入口全部接入云提交:claimBeaconReward(BeaconPanel 直接调)/ claimWeeklyBeacon / claimTimedBeacon(gameStore 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→ \ No newline at end of file diff --git a/tool-results/read_1782258478445_369d01d4d5f2.txt b/tool-results/read_1782258478445_369d01d4d5f2.txt new file mode 100644 index 000000000..b6fff38b3 --- /dev/null +++ b/tool-results/read_1782258478445_369d01d4d5f2.txt @@ -0,0 +1,1332 @@ + 1→"use client"; + 2→// 回响星核 / Echo Nexus — Zustand 游戏状态管理 + 3→import { create } from "zustand"; + 4→import { persist, createJSONStorage } from "zustand/middleware"; + 5→import type { + 6→ GameState, + 7→ Crystal, + 8→ CrystalTier, + 9→ DecodePuzzle, + 10→ ExpeditionResult, + 11→} from "@/lib/game/types"; + 12→import { + 13→ INITIAL_STATE, + 14→ TECH_TREE, + 15→ CRYSTAL_VALUE, + 16→ CONTACT, + 17→ CRYSTAL_SPAWN, + 18→ FRAGMENTS, + 19→ PRESTIGE, + 20→} from "@/lib/game/config"; + 21→import { + 22→ createInitialState, + 23→ recomputeStats, + 24→ decodeRewards, + 25→ rollCrystalTierWithBonus, + 26→ computeNewBlueprints, + 27→ performPrestige, + 28→} from "@/lib/game/engine"; + 29→import { + 30→ generatePuzzle, + 31→ tryClickNode, + 32→ isSolvable, + 33→ resetPuzzle as resetPuz, + 34→} from "@/lib/game/decode"; + 35→import { + 36→ generateExpedition, + 37→ resolveNode, + 38→ advanceExpedition, + 39→ computeExpeditionPower, + 40→ computeExpeditionHp, + 41→ computeEnergyRegen, + 42→ computeEnergyRegenInterval, + 43→ EXPEDITION_CONFIG, + 44→} from "@/lib/game/expedition"; + 45→import { ACHIEVEMENTS, type Achievement } from "@/lib/game/achievements"; + 46→import { + 47→ TIDE_CONFIG, + 48→ rollTide, + 49→ getTideModifiers, + 50→ computeSilenceCompensation, + 51→ type StarTide, + 52→ type TideType, + 53→} from "@/lib/game/starTide"; + 54→import { + 55→ getPerk, + 56→ constellationBonuses, + 57→ rollPerkChoices, + 58→} from "@/lib/game/constellation"; + 59→import { + 60→ migrateChronicleFields, + 61→ withPerks, + 62→} from "@/lib/game/chronicle"; + 63→import { + 64→ generateDailyChallenge, + 65→ generateWeeklyChallenge, + 66→ generateTimedChallenge, + 67→ loadDailyProgress, + 68→ loadWeeklyProgress, + 69→ loadTimedProgress, + 70→ loadChainState, + 71→ addBeaconProgress, + 72→ addWeeklyProgress, + 73→ addTimedProgress, + 74→ recordChainCompletion, + 75→ claimWeeklyReward, + 76→ claimTimedReward, + 77→ claimChainMilestone, + 78→ submitCloudScore, + 79→ getTodayKey, + 80→ type BeaconDailyChallenge, + 81→ type BeaconDailyProgress, + 82→ type BeaconWeeklyChallenge, + 83→ type BeaconWeeklyProgress, + 84→ type BeaconTimedChallenge, + 85→ type BeaconTimedProgress, + 86→} from "@/lib/game/beacon"; + 87→import { setPendingOfflineReport } from "@/lib/game/offlineReport"; + 88→import { + 89→ ATTRIBUTE_HARD_CAP, + 90→ migrateAttributes, + 91→ levelUpCheck, + 92→ getAllBonuses, + 93→ createInitialAttributes, + 94→ createInitialAttributeProgress, + 95→ type AttributeKey, + 96→ type CharacterAttributes, + 97→ type AttributeProgress, + 98→} from "@/lib/game/attributes"; + 99→ + 100→interface GameActions { + 101→ // 生命周期 + 102→ init: () => void; + 103→ loadOnline: () => void; + 104→ hardReset: () => void; + 105→ + 106→ // 主循环 + 107→ tick: (now: number) => void; + 108→ pulse: () => { gain: number; combo: number } | null; + 109→ + 110→ // 星潮 + 111→ tickTide: (now: number) => TideEvent | null; + 112→ consumeTideEvents: () => TideEvent[]; + 113→ /** P3 socket 多人同步:强制触发一道全球星潮(覆盖本地冷却,持续 60s) */ + 114→ triggerGlobalTide: (type: TideType) => void; + 115→ + 116→ // 解码 + 117→ startDecode: (crystalId: string) => void; + 118→ clickNode: (nodeId: number) => { ok: boolean; finished: boolean; solvable?: boolean; failReason?: string }; + 119→ undoStep: () => void; + 120→ retryPuzzle: () => void; + 121→ abandonPuzzle: () => void; + 122→ /** 自动解码 T1(技术解锁后由 tick 调用) */ + 123→ autoDecodeTick: () => void; + 124→ + 125→ // 探险 + 126→ startExpedition: () => { ok: boolean; reason?: string }; + 127→ resolveCurrentNode: () => ExpeditionResult | null; + 128→ advanceNode: () => void; + 129→ abortExpedition: () => void; + 130→ + 131→ // 技术 + 132→ buyTech: (techId: string) => boolean; + 133→ + 134→ // 飞升 + 135→ doPrestige: () => { newBp: number } | null; + 136→ + 137→ // 星图天文台 + 138→ chooseConstellationPerk: (perkId: string) => boolean; + 139→ rerollPerkChoices: () => void; + 140→ + 141→ // 成就 + 142→ checkAchievements: () => Achievement[]; + 143→ consumeAchievementQueue: () => Achievement[]; + 144→ + 145→ // 设置 + 146→ toggleTheme: () => void; + 147→ toggleSound: () => void; + 148→ + 149→ // 深空信标奖励发放(v0.5) + 150→ grantBeaconReward: (insights: number, contact: number) => void; + 151→ + 152→ // 深空信标 · 周挑战领取 + 信标链里程碑领取 + 限时挑战领取(v0.8 / v0.8.2) + 153→ claimWeeklyBeacon: () => { + 154→ rewardInsight: number; + 155→ rewardContact: number; + 156→ score: number; + 157→ }; + 158→ claimTimedBeacon: () => { + 159→ rewardInsight: number; + 160→ rewardContact: number; + 161→ score: number; + 162→ }; + 163→ claimChainReward: (milestone: number) => { + 164→ rewardInsight: number; + 165→ rewardContact: number; + 166→ label: string; + 167→ ok: boolean; + 168→ }; + 169→ + 170→ // 深空巡航奖励发放(v0.6 — 实时 Canvas 玩法) + 171→ grantCruiseReward: (rewards: { crystals?: number; insights?: number; contact?: number }) => void; + 172→ + 173→ // 角色属性(v0.7 P1) + 174→ allocateAttribute: (attr: AttributeKey, points?: number) => { ok: boolean; leveledUp?: number }; + 175→ gainAttributeExp: (attr: AttributeKey, amount: number) => { leveledUp: number; newLevel: number }; + 176→ + 177→ // 派生 + 178→ canPrestige: () => boolean; + 179→} + 180→ + 181→type Store = GameState & GameActions & { + 182→ _lastAutoDecode: number; + 183→ _lastSpawn: number; + 184→ _combo: number; + 185→ _lastPulse: number; + 186→ _achievementQueue: Achievement[]; + 187→ _tideEvents: TideEvent[]; + 188→ /** P3 全球星潮状态(ephemeral,不持久化) */ + 189→ globalTide: GlobalTideState | null; + 190→}; + 191→ + 192→/** 星潮事件(UI 消费用) */ + 193→type TideEvent = { + 194→ started?: TideType; + 195→ ended?: TideType; + 196→ silenceCompensation?: number; + 197→ /** 是否来自全球星潮(P3 socket 同步) */ + 198→ isGlobal?: boolean; + 199→}; + 200→ + 201→/** 全球星潮状态(与 StarTide 类似,但用于区分本地 vs 全球) */ + 202→type GlobalTideState = { + 203→ type: TideType; + 204→ startedAt: number; + 205→ endsAt: number; + 206→ id: string; + 207→}; + 208→ + 209→/** 全球星潮持续毫秒数(与 mini-service DURATION_SEC * 1000 一致) */ + 210→const GLOBAL_TIDE_DURATION_MS = 60_000; + 211→ + 212→/** 计算并写回产能字段 */ + 213→function syncStats(state: Partial) { + 214→ const s = recomputeStats(state); + 215→ return { + 216→ crystalsPerSec: s.crystalsPerSec, + 217→ crystalCap: s.crystalCap, + 218→ pulsePower: s.pulsePower, + 219→ offlineEff: s.offlineEff, + 220→ insightMult: s.insightMult, + 221→ contactRateMult: s.contactRateMult, + 222→ autoDecode: s.autoDecode, + 223→ decodeStepsBonus: s.decodeStepsBonus, + 224→ }; + 225→} + 226→ + 227→/** + 228→ * 深空信标进度追踪(v0.5 → v0.8 → v0.8.2 扩展)。 + 229→ * 在游戏关键动作(脉冲/解码/探险/BOSS/洞见)后调用,同时更新: + 230→ * 1. 日挑战进度(按今日挑战类型增量) + 231→ * 2. 限时挑战进度(按当前 4 小时时段挑战类型增量) + 232→ * 3. 周挑战进度(按本周挑战类型增量) + 233→ * 4. 信标链:日挑战刚完成时记录一次完成(含 grace 续命逻辑) + 234→ * 进度独立存储于 localStorage,不污染 GameState。 + 235→ * @returns 四类状态变更供 UI 触发通知 + 236→ */ + 237→function trackBeacon( + 238→ type: "pulse" | "decode" | "expedition" | "boss" | "insight", + 239→ delta: number + 240→): { + 241→ dailyJustCompleted: boolean; + 242→ timedJustCompleted: boolean; + 243→ weeklyJustCompleted: boolean; + 244→ newChainMilestones: number[]; + 245→} { + 246→ const result = { + 247→ dailyJustCompleted: false, + 248→ timedJustCompleted: false, + 249→ weeklyJustCompleted: false, + 250→ newChainMilestones: [] as number[], + 251→ }; + 252→ if (typeof window === "undefined") return result; + 253→ try { + 254→ // ---- 日挑战 ---- + 255→ const challenge: BeaconDailyChallenge = generateDailyChallenge(); + 256→ if (challenge.type === type) { + 257→ const current: BeaconDailyProgress = loadDailyProgress(); + 258→ if (current.completedAt === null) { + 259→ const { justCompleted } = addBeaconProgress(current, challenge, delta); + 260→ result.dailyJustCompleted = justCompleted; + 261→ // 日挑战刚完成 → 更新信标链 + 262→ if (justCompleted) { + 263→ const { newMilestones } = recordChainCompletion(getTodayKey()); + 264→ result.newChainMilestones = newMilestones; + 265→ } + 266→ } + 267→ } + 268→ + 269→ // ---- 限时挑战(v0.8.2)---- + 270→ const tChallenge: BeaconTimedChallenge = generateTimedChallenge(); + 271→ if (tChallenge.type === type) { + 272→ const tCurrent: BeaconTimedProgress = loadTimedProgress(); + 273→ if (tCurrent.completedAt === null) { + 274→ const { justCompleted } = addTimedProgress( + 275→ tCurrent, + 276→ tChallenge, + 277→ delta + 278→ ); + 279→ result.timedJustCompleted = justCompleted; + 280→ } + 281→ } + 282→ + 283→ // ---- 周挑战 ---- + 284→ const wChallenge: BeaconWeeklyChallenge = generateWeeklyChallenge(); + 285→ if (wChallenge.type === type) { + 286→ const wCurrent: BeaconWeeklyProgress = loadWeeklyProgress(); + 287→ if (wCurrent.completedAt === null) { + 288→ const { justCompleted } = addWeeklyProgress( + 289→ wCurrent, + 290→ wChallenge, + 291→ delta + 292→ ); + 293→ result.weeklyJustCompleted = justCompleted; + 294→ } + 295→ } + 296→ + 297→ return result; + 298→ } catch { + 299→ return result; + 300→ } + 301→} + 302→ + 303→/** 检查并解锁叙事碎片 */ + 304→function checkFragments(state: GameState): string[] { + 305→ const unlocked: string[] = []; + 306→ for (const f of FRAGMENTS) { + 307→ if (!state.fragments[f.id] && state.totalDecoded >= f.threshold) { + 308→ state.fragments[f.id] = true; + 309→ unlocked.push(f.id); + 310→ } + 311→ } + 312→ return unlocked; + 313→} + 314→ + 315→export const useGameStore = create()( + 316→ persist( + 317→ (set, get) => ({ + 318→ ...createInitialState(), + 319→ _lastAutoDecode: Date.now(), + 320→ _lastSpawn: Date.now(), + 321→ _combo: 0, + 322→ _lastPulse: 0, + 323→ _achievementQueue: [], + 324→ _tideEvents: [], + 325→ globalTide: null, + 326→ + 327→ init: () => { + 328→ const s = get(); + 329→ const now = Date.now(); + 330→ // 兼容旧存档:若存在不可解的活跃谜题(旧算法生成),直接清掉 + 331→ let activePuzzle = s.activePuzzle; + 332→ if (activePuzzle && !isSolvable(activePuzzle)) { + 333→ // 把晶体放回队列,避免玩家卡死 + 334→ const crystal: Crystal = { + 335→ id: `c_${now}_rec`, + 336→ tier: activePuzzle.tier, + 337→ value: CRYSTAL_VALUE[activePuzzle.tier].crystals, + 338→ createdAt: now, + 339→ }; + 340→ activePuzzle = null; + 341→ set({ + 342→ pendingCrystals: [...s.pendingCrystals, crystal].slice(-CRYSTAL_SPAWN.maxPending), + 343→ }); + 344→ } + 345→ // 兼容旧存档:补全 achievements / 星潮 / 星图 字段 + 346→ const achievements = s.achievements ?? {}; + 347→ const activeTide = s.activeTide ?? null; + 348→ const constellation = s.constellation ?? []; + 349→ const pendingPerkChoices = s.pendingPerkChoices ?? null; + 350→ // v0.4 编年史兼容:补全 chronicle / runStart / bossKills / starTidesEncountered + 351→ const migrated = migrateChronicleFields(s); + 352→ // v0.7 角色属性兼容:补全 attributes / attributeProgress / pendingAttrPoints + 353→ const attrMigrated = migrateAttributes(s); + 354→ // 星图「能量共振」天赋 +1 能量上限 + 355→ const cm = constellationBonuses(constellation); + 356→ const energyMax = (INITIAL_STATE.energyMax) + cm.energyMaxBonus; + 357→ // lastTideEnd:0 表示从未触发过星潮(首次游玩),保留 0 让 firstDelay 生效 + 358→ const lastTideEndRaw = s.lastTideEnd ?? 0; + 359→ // 若旧存档有已过期的星潮,清掉 + 360→ const tide = activeTide && activeTide.endsAt > now ? activeTide : null; + 361→ // 首次进入:补发离线收益 + 362→ const elapsed = Math.max(0, (now - s.lastTick) / 1000); + 363→ if (elapsed > 5) { + 364→ const cap = 8 * 3600; + 365→ const secs = Math.min(elapsed, cap); + 366→ const gain = s.crystalsPerSec * secs * s.offlineEff; + 367→ const crystalsBefore = s.crystals; + 368→ const crystalsAfter = Math.min(s.crystalCap, s.crystals + gain); + 369→ // 设置离线报告(v0.5.2):通知 UI 弹出"欢迎回来"对话框 + 370→ setPendingOfflineReport({ + 371→ elapsedSec: secs, + 372→ rawElapsedSec: elapsed, + 373→ gain: crystalsAfter - crystalsBefore, + 374→ rate: s.crystalsPerSec, + 375→ eff: s.offlineEff, + 376→ capped: elapsed > cap, + 377→ crystalsBefore, + 378→ crystalsAfter, + 379→ crystalCap: s.crystalCap, + 380→ }); + 381→ set({ + 382→ crystals: crystalsAfter, + 383→ lastTick: now, + 384→ activePuzzle, + 385→ achievements, + 386→ activeTide: tide, + 387→ lastTideEnd: tide ? lastTideEndRaw : lastTideEndRaw, + 388→ constellation, + 389→ pendingPerkChoices, + 390→ energyMax, + 391→ chronicle: migrated.chronicle, + 392→ runStart: migrated.runStart, + 393→ bossKills: migrated.bossKills, + 394→ starTidesEncountered: migrated.starTidesEncountered, + 395→ attributes: attrMigrated.attributes, + 396→ attributeProgress: attrMigrated.attributeProgress, + 397→ pendingAttrPoints: attrMigrated.pendingAttrPoints, + 398→ ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements, activeTide: tide, constellation, attributes: attrMigrated.attributes }), + 399→ }); + 400→ } else { + 401→ set({ lastTick: now, activePuzzle, achievements, activeTide: tide, lastTideEnd: tide ? lastTideEndRaw : lastTideEndRaw, constellation, pendingPerkChoices, energyMax, chronicle: migrated.chronicle, runStart: migrated.runStart, bossKills: migrated.bossKills, starTidesEncountered: migrated.starTidesEncountered, attributes: attrMigrated.attributes, attributeProgress: attrMigrated.attributeProgress, pendingAttrPoints: attrMigrated.pendingAttrPoints, ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements, activeTide: tide, constellation, attributes: attrMigrated.attributes }) }); + 402→ } + 403→ }, + 404→ + 405→ loadOnline: () => { + 406→ const s = get(); + 407→ set({ ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation, attributes: s.attributes }) }); + 408→ }, + 409→ + 410→ hardReset: () => { + 411→ set({ ...createInitialState(), _lastAutoDecode: Date.now(), _lastSpawn: Date.now(), _combo: 0, _lastPulse: 0, _achievementQueue: [], _tideEvents: [], globalTide: null }); + 412→ }, + 413→ + 414→ tickTide: (now) => { + 415→ const s = get(); + 416→ // === P3 全球星潮优先:若全球星潮进行中,本地不触发新的星潮 === + 417→ if (s.globalTide) { + 418→ // 1a) 检查全球星潮是否结束 + 419→ if (now >= s.globalTide.endsAt) { + 420→ const endedType = s.globalTide.type; + 421→ let silenceCompensation = 0; + 422→ if (endedType === "silence") { + 423→ silenceCompensation = computeSilenceCompensation({ + 424→ type: endedType, + 425→ startedAt: s.globalTide.startedAt, + 426→ endsAt: s.globalTide.endsAt, + 427→ id: s.globalTide.id, + 428→ }); + 429→ } + 430→ const event: TideEvent = { ended: endedType, isGlobal: true }; + 431→ if (silenceCompensation > 0) event.silenceCompensation = silenceCompensation; + 432→ set({ + 433→ globalTide: null, + 434→ activeTide: null, + 435→ lastTideEnd: now, + 436→ insights: s.insights + silenceCompensation, + 437→ // 星潮结束后重算 stats + 438→ ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: null, constellation: s.constellation, attributes: s.attributes }), + 439→ _tideEvents: [...s._tideEvents, event], + 440→ }); + 441→ return event; + 442→ } + 443→ // 1b) 全球星潮仍在进行 — 不做本地处理(避免冲突) + 444→ return null; + 445→ } + 446→ + 447→ const tide = s.activeTide; + 448→ // 星图「星潮引导」减少间隙 + 449→ const cm = constellationBonuses(s.constellation ?? []); + 450→ // v0.7 灵感:星潮触发概率 +X%(缩短间隙) + 451→ const am = getAllBonuses(s.attributes ?? {}); + 452→ const tideGapReduction = Math.min(0.3, am.tideTriggerBonus); + 453→ const gap = Math.max(15000, (TIDE_CONFIG.gap + cm.tideGapDeltaSec * 1000) * (1 - tideGapReduction)); + 454→ // 2) 检查当前本地星潮是否结束 + 455→ if (tide && now >= tide.endsAt) { + 456→ const endedType = tide.type; + 457→ // 寂静期补偿洞见 + 458→ let silenceCompensation = 0; + 459→ if (tide.type === "silence") { + 460→ silenceCompensation = computeSilenceCompensation(tide); + 461→ } + 462→ const newInsights = s.insights + silenceCompensation; + 463→ const event: TideEvent = { ended: endedType }; + 464→ if (silenceCompensation > 0) event.silenceCompensation = silenceCompensation; + 465→ set({ + 466→ activeTide: null, + 467→ lastTideEnd: now, + 468→ insights: newInsights, + 469→ // 星潮结束后重算 stats(移除 contactRate/insight 修饰) + 470→ ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: null, constellation: s.constellation, attributes: s.attributes }), + 471→ _tideEvents: [...s._tideEvents, event], + 472→ }); + 473→ return event; + 474→ } + 475→ // 3) 检查是否该触发新星潮(间隙已过) + 476→ if (!tide) { + 477→ const since = now - s.lastTideEnd; + 478→ // 首次:以 createdAt 起算 firstDelay;之后以 lastTideEnd 起算 gap + 479→ const firstStart = s.lastTideEnd === 0 || s.lastTideEnd <= s.createdAt + 1000; + 480→ const need = firstStart ? TIDE_CONFIG.firstDelay : gap; + 481→ if (since >= need) { + 482→ const type = rollTide(); + 483→ const newTide: StarTide = { + 484→ id: `tide_${now}_${Math.random().toString(36).slice(2, 7)}`, + 485→ type, + 486→ startedAt: now, + 487→ endsAt: now + TIDE_CONFIG.duration, + 488→ }; + 489→ const event: TideEvent = { started: type }; + 490→ // v0.4 编年史:累计遇到的星潮 ID(去重) + 491→ const tidesAll = s.starTidesEncountered ?? []; + 492→ const tideId = `tide_${type}`; + 493→ const newTidesAll = tidesAll.includes(tideId) ? tidesAll : [...tidesAll, tideId]; + 494→ set({ + 495→ activeTide: newTide, + 496→ starTidesEncountered: newTidesAll, + 497→ // 星潮开始后重算 stats(应用 contactRate/insight 修饰) + 498→ ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: newTide, constellation: s.constellation, attributes: s.attributes }), + 499→ _tideEvents: [...s._tideEvents, event], + 500→ }); + 501→ return event; + 502→ } + 503→ } + 504→ return null; + 505→ }, + 506→ + 507→ consumeTideEvents: () => { + 508→ const s = get(); + 509→ if (s._tideEvents.length === 0) return []; + 510→ const items = s._tideEvents; + 511→ set({ _tideEvents: [] }); + 512→ return items; + 513→ }, + 514→ + 515→ // P3 socket 多人同步:服务端广播全球星潮时调用,强制覆盖本地冷却 + 516→ triggerGlobalTide: (type) => { + 517→ const s = get(); + 518→ const now = Date.now(); + 519→ const id = `global_tide_${now}_${Math.random().toString(36).slice(2, 7)}`; + 520→ const newTide: StarTide = { + 521→ id, + 522→ type, + 523→ startedAt: now, + 524→ endsAt: now + GLOBAL_TIDE_DURATION_MS, + 525→ }; + 526→ const newGlobalTide: GlobalTideState = { + 527→ type, + 528→ startedAt: now, + 529→ endsAt: now + GLOBAL_TIDE_DURATION_MS, + 530→ id, + 531→ }; + 532→ // 编年史:累计遇到的星潮 ID(去重) + 533→ const tidesAll = s.starTidesEncountered ?? []; + 534→ const tideId = `tide_${type}`; + 535→ const newTidesAll = tidesAll.includes(tideId) ? tidesAll : [...tidesAll, tideId]; + 536→ const event: TideEvent = { started: type, isGlobal: true }; + 537→ set({ + 538→ globalTide: newGlobalTide, + 539→ activeTide: newTide, // 覆盖本地 activeTide(即使本地有进行中的星潮也会被替换) + 540→ starTidesEncountered: newTidesAll, + 541→ ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: newTide, constellation: s.constellation, attributes: s.attributes }), + 542→ _tideEvents: [...s._tideEvents, event], + 543→ }); + 544→ }, + 545→ + 546→ tick: (now) => { + 547→ const s = get(); + 548→ const dt = Math.max(0, (now - s.lastTick) / 1000); + 549→ if (dt <= 0) return; + 550→ + 551→ // 星潮产能修饰(即时乘) + 552→ const tideMod = getTideModifiers(s.activeTide); + 553→ const effCps = s.crystalsPerSec * tideMod.crystalsPerSecMult; + 554→ // 产能累加(仅闲置产能受仓库上限;探险奖励可超出) + 555→ const newCrystals = + 556→ s.crystals >= s.crystalCap + 557→ ? s.crystals // 已达/超上限,不再自动产出 + 558→ : Math.min(s.crystalCap, s.crystals + effCps * dt); + 559→ + 560→ // 自动产出待解码晶体(每 baseInterval 秒一颗,受技术/飞升略提速) + 561→ const bpBoost = 1 + s.blueprints.length * 0.03; + 562→ const spawnInterval = (CRYSTAL_SPAWN.baseIntervalSec / bpBoost) * 1000; + 563→ let pending = s.pendingCrystals; + 564→ let lastSpawn = s._lastSpawn; + 565→ if ( + 566→ now - lastSpawn > spawnInterval && + 567→ pending.length < CRYSTAL_SPAWN.maxPending + 568→ ) { + 569→ // 星图「晶体富集」提升 T2/T3 概率 + 570→ const cm = constellationBonuses(s.constellation ?? []); + 571→ const tier = rollCrystalTierWithBonus(cm.t2t3BonusRate); + 572→ const crystal: Crystal = { + 573→ id: `c_${now}_${Math.random().toString(36).slice(2, 7)}`, + 574→ tier, + 575→ value: CRYSTAL_VALUE[tier].crystals, + 576→ createdAt: now, + 577→ }; + 578→ pending = [...pending, crystal]; + 579→ lastSpawn = now; + 580→ } + 581→ + 582→ // 能量恢复(探险系统,v0.8.1 动态间隔) + 583→ let energy = s.energy; + 584→ let lastEnergyTick = s.lastEnergyTick; + 585→ if (energy < s.energyMax) { + 586→ const intervalSec = computeEnergyRegenInterval(s); + 587→ const regen = computeEnergyRegen(lastEnergyTick, now, energy, s.energyMax, intervalSec); + 588→ energy = regen.energy; + 589→ lastEnergyTick = regen.lastTick; + 590→ } else { + 591→ lastEnergyTick = now; + 592→ } + 593→ + 594→ set({ + 595→ crystals: newCrystals, + 596→ lastTick: now, + 597→ pendingCrystals: pending, + 598→ _lastSpawn: lastSpawn, + 599→ energy, + 600→ lastEnergyTick, + 601→ }); + 602→ }, + 603→ + 604→ pulse: () => { + 605→ const s = get(); + 606→ const now = Date.now(); + 607→ // 连击 + 608→ let combo = 1; + 609→ if (now - s._lastPulse < 1500) { + 610→ combo = Math.min(10, s._combo + 1); + 611→ } + 612→ const mult = 1 + (combo - 1) * 0.15; + 613→ // 星潮脉冲威力修饰 + 614→ const tideMod = getTideModifiers(s.activeTide); + 615→ // v0.7 灵感:脉冲连击加成 +X% + 616→ const am = getAllBonuses(s.attributes ?? {}); + 617→ const comboBonusMult = 1 + am.pulseComboBonus * Math.max(0, combo - 1); + 618→ const gain = s.pulsePower * mult * tideMod.pulsePowerMult * comboBonusMult; + 619→ set({ + 620→ crystals: Math.min(s.crystalCap, s.crystals + gain), + 621→ _combo: combo, + 622→ _lastPulse: now, + 623→ }); + 624→ // 深空信标:脉冲任务进度 +1 + 625→ trackBeacon("pulse", 1); + 626→ // v0.7 角色属性:连击 ≥3 给灵感经验 + 627→ if (combo >= 3) { + 628→ const expGain = 1 + Math.floor(combo / 2); // 3 连击=2, 5 连击=3, 10 连击=6 + 629→ // 内联经验获取(避免递归调用 set) + 630→ const prog = s.attributeProgress?.inspiration ?? { exp: 0, level: s.attributes?.inspiration ?? 0 }; + 631→ const nextExp = prog.exp + expGain; + 632→ const lvlResult = levelUpCheck( + 633→ { exp: nextExp, level: s.attributes?.inspiration ?? 0 }, + 634→ ATTRIBUTE_HARD_CAP + 635→ ); + 636→ const newAttributes: CharacterAttributes = { + 637→ ...(s.attributes ?? createInitialAttributes()), + 638→ inspiration: lvlResult.newProgress.level, + 639→ }; + 640→ const newProgress: AttributeProgress = { + 641→ ...(s.attributeProgress ?? createInitialAttributeProgress()), + 642→ inspiration: lvlResult.newProgress, + 643→ }; + 644→ set({ + 645→ attributes: newAttributes, + 646→ attributeProgress: newProgress, + 647→ ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation, attributes: newAttributes }), + 648→ }); + 649→ } + 650→ return { gain, combo }; + 651→ }, + 652→ + 653→ startDecode: (crystalId) => { + 654→ const s = get(); + 655→ const crystal = s.pendingCrystals.find((c) => c.id === crystalId); + 656→ if (!crystal) return; + 657→ // v0.8.2 涌动星潮:解码目标序列 +2(更长谜题) + 658→ const tideMod = getTideModifiers(s.activeTide); + 659→ const targetLenBonus = tideMod.targetLenBonus ?? 0; + 660→ const puzzle = generatePuzzle(crystal.tier, undefined, targetLenBonus); + 661→ set({ + 662→ activePuzzle: puzzle, + 663→ pendingCrystals: s.pendingCrystals.filter((c) => c.id !== crystalId), + 664→ }); + 665→ }, + 666→ + 667→ clickNode: (nodeId) => { + 668→ const s = get(); + 669→ if (!s.activePuzzle) return { ok: false, finished: false, failReason: "无活跃谜题" }; + 670→ // 深拷贝谜题 + 671→ const puzzle: DecodePuzzle = JSON.parse(JSON.stringify(s.activePuzzle)); + 672→ const res = tryClickNode(puzzle, nodeId); + 673→ if (res.ok) { + 674→ if (res.finished) { + 675→ // 结算奖励(星潮解码奖励修饰 + 星图「二周目经验」) + 676→ const tideMod = getTideModifiers(s.activeTide); + 677→ const cm = constellationBonuses(s.constellation ?? []); + 678→ const base = decodeRewards(puzzle.tier, s.insightMult, s.contactRateMult); + 679→ const finalMult = tideMod.decodeRewardMult * cm.decodeRewardMult; + 680→ const rewards = { + 681→ crystals: Math.round(base.crystals * finalMult), + 682→ insights: Math.round(base.insights * finalMult), + 683→ contact: +(base.contact * finalMult).toFixed(2), + 684→ }; + 685→ const newTotal = s.totalDecoded + 1; + 686→ const newContact = Math.min(100, s.contact + rewards.contact); + 687→ const newInsights = s.insights + rewards.insights; + 688→ const newCrystals = s.crystals + rewards.crystals; + 689→ // 解锁碎片 + 690→ const tentative: GameState = { ...s, totalDecoded: newTotal, fragments: { ...s.fragments } }; + 691→ const unlocked = checkFragments(tentative); + 692→ // v0.7 角色属性:完成解码给智慧经验(tier 越高经验越多) + 693→ const wisdomExpGain = puzzle.tier * 2; + 694→ const curAttrs = s.attributes ?? createInitialAttributes(); + 695→ const curProg = s.attributeProgress ?? createInitialAttributeProgress(); + 696→ const progEntry = curProg.wisdom ?? { exp: 0, level: curAttrs.wisdom }; + 697→ const wisdomLvl = levelUpCheck( + 698→ { exp: progEntry.exp + wisdomExpGain, level: curAttrs.wisdom }, + 699→ ATTRIBUTE_HARD_CAP + 700→ ); + 701→ const newAttributes: CharacterAttributes = { + 702→ ...curAttrs, + 703→ wisdom: wisdomLvl.newProgress.level, + 704→ }; + 705→ const newProgress: AttributeProgress = { + 706→ ...curProg, + 707→ wisdom: wisdomLvl.newProgress, + 708→ }; + 709→ set({ + 710→ activePuzzle: null, + 711→ crystals: newCrystals, + 712→ insights: newInsights, + 713→ contact: newContact, + 714→ totalDecoded: newTotal, + 715→ fragments: tentative.fragments, + 716→ attributes: newAttributes, + 717→ attributeProgress: newProgress, + 718→ ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation, attributes: newAttributes }), + 719→ }); + 720→ // 深空信标:解码 +1,洞见累计 + 721→ trackBeacon("decode", 1); + 722→ trackBeacon("insight", rewards.insights); + 723→ return { ok: true, finished: true, failReason: unlocked.join(",") || undefined }; + 724→ } + 725→ // 点击成功但未完成:检测当前局面是否仍可解 + 726→ const solvable = isSolvable(puzzle); + 727→ set({ activePuzzle: puzzle }); + 728→ return { ok: true, finished: false, solvable }; + 729→ } + 730→ return res; + 731→ }, + 732→ + 733→ undoStep: () => { + 734→ const s = get(); + 735→ if (!s.activePuzzle || s.activePuzzle.path.length === 0) return; + 736→ const puzzle: DecodePuzzle = JSON.parse(JSON.stringify(s.activePuzzle)); + 737→ const lastId = puzzle.path.pop(); + 738→ if (lastId !== undefined) { + 739→ const node = puzzle.grid.find((n) => n.id === lastId); + 740→ if (node) node.used = false; + 741→ } + 742→ set({ activePuzzle: puzzle }); + 743→ }, + 744→ + 745→ retryPuzzle: () => { + 746→ const s = get(); + 747→ if (!s.activePuzzle) return; + 748→ set({ activePuzzle: resetPuz(s.activePuzzle) }); + 749→ }, + 750→ + 751→ abandonPuzzle: () => { + 752→ const s = get(); + 753→ if (!s.activePuzzle) return; + 754→ // 晶体放回队列末尾 + 755→ const crystal: Crystal = { + 756→ id: `c_${Date.now()}_ret`, + 757→ tier: s.activePuzzle.tier, + 758→ value: CRYSTAL_VALUE[s.activePuzzle.tier].crystals, + 759→ createdAt: Date.now(), + 760→ }; + 761→ set({ + 762→ activePuzzle: null, + 763→ pendingCrystals: [...s.pendingCrystals, crystal].slice(-CRYSTAL_SPAWN.maxPending), + 764→ }); + 765→ }, + 766→ + 767→ autoDecodeTick: () => { + 768→ const s = get(); + 769→ if (!s.autoDecode) return; + 770→ const now = Date.now(); + 771→ // 星图「自动校准」减少自动解码周期 + 772→ const cm = constellationBonuses(s.constellation ?? []); + 773→ // v0.7 智慧:自动解码周期 -X% + 774→ const am = getAllBonuses(s.attributes ?? {}); + 775→ const baseInterval = 12000 + cm.autoDecodeIntervalDeltaSec * 1000; + 776→ // v0.8.2 棱镜星潮:自动解码周期 ×0.7(-30%) + 777→ const tideModEarly = getTideModifiers(s.activeTide); + 778→ const tideIntervalMult = tideModEarly.autoDecodeIntervalMult ?? 1; + 779→ const interval = Math.max(5000, baseInterval * am.autoDecodeIntervalMult * tideIntervalMult); + 780→ if (now - s._lastAutoDecode < interval) return; + 781→ // 找一颗 T1 晶体自动解码 + 782→ const idx = s.pendingCrystals.findIndex((c) => c.tier === 1); + 783→ if (idx < 0) return; + 784→ const crystal = s.pendingCrystals[idx]; + 785→ const tideMod = getTideModifiers(s.activeTide); + 786→ const base = decodeRewards(1, s.insightMult, s.contactRateMult); + 787→ const finalMult = tideMod.decodeRewardMult * cm.decodeRewardMult; + 788→ const rewards = { + 789→ crystals: Math.round(base.crystals * finalMult), + 790→ insights: Math.round(base.insights * finalMult), + 791→ contact: +(base.contact * finalMult).toFixed(2), + 792→ }; + 793→ const newTotal = s.totalDecoded + 1; + 794→ const tentative: GameState = { ...s, totalDecoded: newTotal, fragments: { ...s.fragments } }; + 795→ checkFragments(tentative); + 796→ // v0.7 角色属性:自动解码给智慧经验(少量) + 797→ const curAttrs = s.attributes ?? createInitialAttributes(); + 798→ const curProg = s.attributeProgress ?? createInitialAttributeProgress(); + 799→ const progEntry = curProg.wisdom ?? { exp: 0, level: curAttrs.wisdom }; + 800→ const wisdomLvl = levelUpCheck( + 801→ { exp: progEntry.exp + 1, level: curAttrs.wisdom }, + 802→ ATTRIBUTE_HARD_CAP + 803→ ); + 804→ const newAttributes: CharacterAttributes = { + 805→ ...curAttrs, + 806→ wisdom: wisdomLvl.newProgress.level, + 807→ }; + 808→ const newProgress: AttributeProgress = { + 809→ ...curProg, + 810→ wisdom: wisdomLvl.newProgress, + 811→ }; + 812→ set({ + 813→ pendingCrystals: s.pendingCrystals.filter((c) => c.id !== crystal.id), + 814→ crystals: s.crystals + rewards.crystals, + 815→ insights: s.insights + rewards.insights, + 816→ contact: Math.min(100, s.contact + rewards.contact), + 817→ totalDecoded: newTotal, + 818→ fragments: tentative.fragments, + 819→ _lastAutoDecode: now, + 820→ attributes: newAttributes, + 821→ attributeProgress: newProgress, + 822→ ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation, attributes: newAttributes }), + 823→ }); + 824→ // 深空信标:自动解码也算进度 + 825→ trackBeacon("decode", 1); + 826→ trackBeacon("insight", rewards.insights); + 827→ }, + 828→ + 829→ buyTech: (techId) => { + 830→ const s = get(); + 831→ const node = TECH_TREE.find((t) => t.id === techId); + 832→ if (!node) return false; + 833→ const cur = s.tech[techId] ?? 0; + 834→ if (cur >= 1) return false; // v0.1 每节点 1 级 + 835→ if (s.insights < node.cost) return false; + 836→ const newTech = { ...s.tech, [techId]: 1 }; + 837→ set({ + 838→ insights: s.insights - node.cost, + 839→ tech: newTech, + 840→ ...syncStats({ tech: newTech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation, attributes: s.attributes }), + 841→ }); + 842→ return true; + 843→ }, + 844→ + 845→ // ============ 探险系统 ============ + 846→ startExpedition: () => { + 847→ const s = get(); + 848→ if (s.activeExpedition && !s.activeExpedition.finished) { + 849→ return { ok: false, reason: "已有进行中的探险" }; + 850→ } + 851→ if (s.energy < EXPEDITION_CONFIG.energyCost) { + 852→ return { ok: false, reason: "能量不足" }; + 853→ } + 854→ const tideMod = getTideModifiers(s.activeTide); + 855→ // v0.7 角色属性:探索力 +X% 探险力,勇气 +X 探险生命 + 856→ const am = getAllBonuses(s.attributes ?? {}); + 857→ const basePower = computeExpeditionPower(s) + tideMod.expeditionPowerBonus; + 858→ const baseHp = computeExpeditionHp(s) + tideMod.expeditionHpBonus; + 859→ const power = Math.round(basePower * am.expeditionPowerMult); + 860→ const hp = baseHp + am.expeditionHpBonus; + 861→ const seed = Math.floor(Math.random() * 1e9); + 862→ const expedition = generateExpedition(seed, power, hp); + 863→ set({ + 864→ activeExpedition: expedition, + 865→ energy: s.energy - EXPEDITION_CONFIG.energyCost, + 866→ totalExpeditions: s.totalExpeditions + 1, + 867→ }); + 868→ return { ok: true }; + 869→ }, + 870→ + 871→ resolveCurrentNode: () => { + 872→ const s = get(); + 873→ if (!s.activeExpedition || s.activeExpedition.finished) return null; + 874→ // 深拷贝 + 875→ const exp = JSON.parse(JSON.stringify(s.activeExpedition)); + 876→ // v0.7 勇气:BOSS 战胜率 +X%(动态提高 RNG 阈值) + 877→ // v0.8.2 蚀相星潮:BOSS 胜率 +20% + 878→ const nodeBefore = exp.nodes[exp.currentNode]; + 879→ const isBossNode = nodeBefore?.type === "boss"; + 880→ const am = getAllBonuses(s.attributes ?? {}); + 881→ const tideMod = getTideModifiers(s.activeTide); + 882→ const tideBossBonus = tideMod.bossWinRateBonus ?? 0; + 883→ const result = isBossNode + 884→ ? resolveNode(exp, () => { + 885→ // 单次 rng() 调用:B% 概率返回 0(必胜),其余情况返回 r-B(保持均匀分布) + 886→ const r = Math.random(); + 887→ const b = Math.min(0.95, am.bossWinRateBonus + tideBossBonus); + 888→ return r < b ? 0 : Math.min(1, r - b); + 889→ }) + 890→ : resolveNode(exp); + 891→ // 累计奖励 + 892→ if (result.crystals) exp.rewards.crystals += result.crystals; + 893→ if (result.insights) exp.rewards.insights += result.insights; + 894→ if (result.contact) exp.rewards.contact += result.contact; + 895→ if (result.fragments) exp.rewards.fragments.push(...result.fragments); + 896→ if (result.hpDelta) exp.hp = Math.max(0, Math.min(exp.maxHp, exp.hp + result.hpDelta)); + 897→ // 实时入账(玩家立即获得) + 898→ const newCrystals = s.crystals + (result.crystals || 0); + 899→ const newInsights = s.insights + (result.insights || 0); + 900→ const newContact = Math.min(100, s.contact + (result.contact || 0)); + 901→ // 碎片解锁 + 902→ const newFragments = { ...s.fragments }; + 903→ if (result.fragments) { + 904→ for (const fid of result.fragments) newFragments[fid] = true; + 905→ } + 906→ // 日志 + 907→ const logEntry = { + 908→ expeditionId: exp.id, + 909→ nodeType: nodeBefore?.type || "combat", + 910→ result: result.log, + 911→ rewards: [ + 912→ result.crystals ? `+${result.crystals}晶体` : "", + 913→ result.insights ? `+${result.insights}洞见` : "", + 914→ result.contact ? `+${result.contact.toFixed(1)}接触` : "", + 915→ result.hpDelta ? `${result.hpDelta > 0 ? "+" : ""}${result.hpDelta}生命` : "", + 916→ ].filter(Boolean).join(" "), + 917→ timestamp: Date.now(), + 918→ }; + 919→ const newLog = [logEntry, ...s.expeditionLog].slice(0, 30); + 920→ + 921→ if (result.ended) { + 922→ // 探险结束(胜利或失败) + 923→ exp.finished = true; + 924→ } + 925→ + 926→ // v0.4 编年史:击破 BOSS 时累计计数 + 927→ let bossKills = s.bossKills ?? 0; + 928→ let bossKilledThisNode = false; + 929→ if ( + 930→ result.ended && + 931→ result.endReason === "victory" && + 932→ nodeBefore?.type === "boss" + 933→ ) { + 934→ bossKills = bossKills + 1; + 935→ bossKilledThisNode = true; + 936→ } + 937→ + 938→ // v0.7 角色属性:战斗胜利给勇气+探索力经验;BOSS 额外奖励 + 939→ let newAttributes = s.attributes ?? createInitialAttributes(); + 940→ let newProgress = s.attributeProgress ?? createInitialAttributeProgress(); + 941→ let statsNeedResync = false; + 942→ // 战斗类节点(combat/boss)且胜利:勇气 + 探索力经验 + 943→ const isCombatVictory = + 944→ (nodeBefore?.type === "combat" || nodeBefore?.type === "boss") && + 945→ !result.ended; // 中途战斗胜利(未结束探险) + 946→ const isExpeditionVictory = + 947→ result.ended && result.endReason === "victory"; + 948→ if (isCombatVictory || bossKilledThisNode || isExpeditionVictory) { + 949→ const courageGain = bossKilledThisNode ? 8 : 2; + 950→ const explorationGain = bossKilledThisNode ? 6 : isExpeditionVictory ? 4 : 1; + 951→ const courageLvl = levelUpCheck( + 952→ { exp: newProgress.courage.exp + courageGain, level: newAttributes.courage }, + 953→ ATTRIBUTE_HARD_CAP + 954→ ); + 955→ const explLvl = levelUpCheck( + 956→ { exp: newProgress.exploration.exp + explorationGain, level: newAttributes.exploration }, + 957→ ATTRIBUTE_HARD_CAP + 958→ ); + 959→ newAttributes = { + 960→ ...newAttributes, + 961→ courage: courageLvl.newProgress.level, + 962→ exploration: explLvl.newProgress.level, + 963→ }; + 964→ newProgress = { + 965→ ...newProgress, + 966→ courage: courageLvl.newProgress, + 967→ exploration: explLvl.newProgress, + 968→ }; + 969→ statsNeedResync = true; + 970→ } + 971→ + 972→ set({ + 973→ activeExpedition: exp, + 974→ crystals: newCrystals, + 975→ insights: newInsights, + 976→ contact: newContact, + 977→ fragments: newFragments, + 978→ expeditionLog: newLog, + 979→ bossKills, + 980→ attributes: newAttributes, + 981→ attributeProgress: newProgress, + 982→ ...(statsNeedResync + 983→ ? syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation, attributes: newAttributes }) + 984→ : {}), + 985→ }); + 986→ // 深空信标:探险完成(胜负都计)+ 洞见累计 + BOSS 击破 + 987→ if (result.ended) { + 988→ trackBeacon("expedition", 1); + 989→ if (bossKilledThisNode) { + 990→ trackBeacon("boss", 1); + 991→ } + 992→ } + 993→ if (result.insights) trackBeacon("insight", result.insights); + 994→ return result; + 995→ }, + 996→ + 997→ advanceNode: () => { + 998→ const s = get(); + 999→ if (!s.activeExpedition || s.activeExpedition.finished) return; + 1000→ const exp = JSON.parse(JSON.stringify(s.activeExpedition)); + 1001→ const node = exp.nodes[exp.currentNode]; + 1002→ if (!node || !node.cleared) return; // 当前节点未结算不能前进 + 1003→ if (exp.currentNode >= exp.nodes.length - 1) return; + 1004→ exp.currentNode++; + 1005→ set({ activeExpedition: exp }); + 1006→ }, + 1007→ + 1008→ abortExpedition: () => { + 1009→ const s = get(); + 1010→ if (!s.activeExpedition) return; + 1011→ const exp = JSON.parse(JSON.stringify(s.activeExpedition)); + 1012→ exp.finished = true; + 1013→ const logEntry = { + 1014→ expeditionId: exp.id, + 1015→ nodeType: "rest" as const, + 1016→ result: "探险队主动撤退,保留已获奖励。", + 1017→ rewards: "", + 1018→ timestamp: Date.now(), + 1019→ }; + 1020→ set({ + 1021→ activeExpedition: exp, + 1022→ expeditionLog: [logEntry, ...s.expeditionLog].slice(0, 30), + 1023→ }); + 1024→ }, + 1025→ + 1026→ doPrestige: () => { + 1027→ const s = get(); + 1028→ if (s.contact < CONTACT.prestigeMin) return null; + 1029→ const newBp = computeNewBlueprints(s); + 1030→ const next = performPrestige(s); + 1031→ // 星图「能量共振」提升上限 + 1032→ const cm = constellationBonuses(next.constellation ?? []); + 1033→ const energyMax = INITIAL_STATE.energyMax + cm.energyMaxBonus; + 1034→ set({ + 1035→ ...next, + 1036→ energyMax, + 1037→ ...syncStats({ tech: next.tech, blueprints: next.blueprints, achievements: next.achievements, activeTide: next.activeTide, constellation: next.constellation, attributes: next.attributes }), + 1038→ _lastAutoDecode: Date.now(), + 1039→ _lastSpawn: Date.now(), + 1040→ _combo: 0, + 1041→ _lastPulse: 0, + 1042→ _tideEvents: [], + 1043→ }); + 1044→ return { newBp }; + 1045→ }, + 1046→ + 1047→ chooseConstellationPerk: (perkId) => { + 1048→ const s = get(); + 1049→ if (!s.pendingPerkChoices || !s.pendingPerkChoices.includes(perkId)) return false; + 1050→ const perk = getPerk(perkId); + 1051→ if (!perk) return false; + 1052→ if (s.constellation?.includes(perkId)) return false; + 1053→ const newConstellation = [...(s.constellation ?? []), perkId]; + 1054→ const cm = constellationBonuses(newConstellation); + 1055→ const energyMax = INITIAL_STATE.energyMax + cm.energyMaxBonus; + 1056→ // v0.4 编年史:回填最近一条 entry 的 perksThisAscension + 1057→ const chronicle = s.chronicle ?? []; + 1058→ let newChronicle = chronicle; + 1059→ if (chronicle.length > 0) { + 1060→ const lastEntry = chronicle[chronicle.length - 1]; + 1061→ const updatedLast = withPerks(lastEntry, [perkId]); + 1062→ newChronicle = [...chronicle.slice(0, -1), updatedLast]; + 1063→ } + 1064→ set({ + 1065→ constellation: newConstellation, + 1066→ pendingPerkChoices: null, + 1067→ energyMax, + 1068→ chronicle: newChronicle, + 1069→ ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: newConstellation, attributes: s.attributes }), + 1070→ }); + 1071→ return true; + 1072→ }, + 1073→ + 1074→ rerollPerkChoices: () => { + 1075→ const s = get(); + 1076→ if (!s.pendingPerkChoices) return; + 1077→ const choices = rollPerkChoices(s.constellation ?? []); + 1078→ if (choices.length > 0) set({ pendingPerkChoices: choices }); + 1079→ }, + 1080→ + 1081→ checkAchievements: () => { + 1082→ const s = get(); + 1083→ const newlyUnlocked: Achievement[] = []; + 1084→ const updated = { ...s.achievements }; + 1085→ let crystals = s.crystals; + 1086→ let insights = s.insights; + 1087→ let contact = s.contact; + 1088→ let statsDirty = false; + 1089→ for (const a of ACHIEVEMENTS) { + 1090→ if (updated[a.id]) continue; + 1091→ if (a.check(s)) { + 1092→ updated[a.id] = true; + 1093→ newlyUnlocked.push(a); + 1094→ // 发放即时奖励 + 1095→ if (a.reward.crystals) crystals += a.reward.crystals; + 1096→ if (a.reward.insights) insights += a.reward.insights; + 1097→ if (a.reward.contact) contact = Math.min(100, contact + a.reward.contact); + 1098→ if (a.reward.crystalsPerSecPct || a.reward.insightPct) statsDirty = true; + 1099→ } + 1100→ } + 1101→ if (newlyUnlocked.length === 0) return []; + 1102→ set({ + 1103→ achievements: updated, + 1104→ crystals, + 1105→ insights, + 1106→ contact, + 1107→ ...(statsDirty + 1108→ ? syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: updated, activeTide: s.activeTide, constellation: s.constellation, attributes: s.attributes }) + 1109→ : {}), + 1110→ _achievementQueue: [...s._achievementQueue, ...newlyUnlocked], + 1111→ }); + 1112→ return newlyUnlocked; + 1113→ }, + 1114→ + 1115→ consumeAchievementQueue: () => { + 1116→ const s = get(); + 1117→ if (s._achievementQueue.length === 0) return []; + 1118→ const items = s._achievementQueue; + 1119→ set({ _achievementQueue: [] }); + 1120→ return items; + 1121→ }, + 1122→ + 1123→ canPrestige: () => get().contact >= CONTACT.prestigeMin, + 1124→ + 1125→ toggleTheme: () => set({ theme: get().theme === "dark" ? "light" : "dark" }), + 1126→ toggleSound: () => set({ soundOn: !get().soundOn }), + 1127→ + 1128→ // 深空信标:发放每日挑战奖励(v0.5) + 1129→ grantBeaconReward: (insights, contact) => { + 1130→ const s = get(); + 1131→ set({ + 1132→ insights: s.insights + Math.round(insights), + 1133→ contact: Math.min(100, s.contact + contact), + 1134→ }); + 1135→ }, + 1136→ + 1137→ // 深空信标:领取周挑战奖励(v0.8) + 1138→ claimWeeklyBeacon: () => { + 1139→ const challenge = generateWeeklyChallenge(); + 1140→ const progress = loadWeeklyProgress(); + 1141→ const res = claimWeeklyReward(challenge, progress); + 1142→ if (res.rewardInsight > 0 || res.rewardContact > 0) { + 1143→ const s = get(); + 1144→ set({ + 1145→ insights: s.insights + Math.round(res.rewardInsight), + 1146→ contact: Math.min(100, s.contact + res.rewardContact), + 1147→ }); + 1148→ // 异步提交到云排行榜(失败静默,不影响本地) + 1149→ if (res.entry) { + 1150→ void submitCloudScore(res.entry); + 1151→ } + 1152→ } + 1153→ return { + 1154→ rewardInsight: res.rewardInsight, + 1155→ rewardContact: res.rewardContact, + 1156→ score: res.score, + 1157→ }; + 1158→ }, + 1159→ + 1160→ // 深空信标:领取限时挑战奖励(v0.8.2) + 1161→ claimTimedBeacon: () => { + 1162→ const challenge = generateTimedChallenge(); + 1163→ const progress = loadTimedProgress(); + 1164→ const res = claimTimedReward(challenge, progress); + 1165→ if (res.rewardInsight > 0 || res.rewardContact > 0) { + 1166→ const s = get(); + 1167→ set({ + 1168→ insights: s.insights + Math.round(res.rewardInsight), + 1169→ contact: Math.min(100, s.contact + res.rewardContact), + 1170→ }); + 1171→ // 异步提交到云排行榜(失败静默,不影响本地) + 1172→ if (res.entry) { + 1173→ void submitCloudScore(res.entry); + 1174→ } + 1175→ } + 1176→ return { + 1177→ rewardInsight: res.rewardInsight, + 1178→ rewardContact: res.rewardContact, + 1179→ score: res.score, + 1180→ }; + 1181→ }, + 1182→ + 1183→ // 深空信标:领取信标链里程碑奖励(v0.8) + 1184→ claimChainReward: (milestone) => { + 1185→ // loadChainState 仅用于前置校验,真正的状态修改由 claimChainMilestone 完成 + 1186→ const pre = loadChainState(); + 1187→ if ( + 1188→ pre.currentStreak < milestone || + 1189→ pre.milestonesClaimed.includes(milestone) + 1190→ ) { + 1191→ return { + 1192→ rewardInsight: 0, + 1193→ rewardContact: 0, + 1194→ label: "", + 1195→ ok: false, + 1196→ }; + 1197→ } + 1198→ const res = claimChainMilestone(milestone); + 1199→ if (res.rewardInsight > 0 || res.rewardContact > 0) { + 1200→ const s = get(); + 1201→ set({ + 1202→ insights: s.insights + Math.round(res.rewardInsight), + 1203→ contact: Math.min(100, s.contact + res.rewardContact), + 1204→ }); + 1205→ } + 1206→ return { + 1207→ rewardInsight: res.rewardInsight, + 1208→ rewardContact: res.rewardContact, + 1209→ label: res.label, + 1210→ ok: res.rewardInsight > 0 || res.rewardContact > 0, + 1211→ }; + 1212→ }, + 1213→ + 1214→ // 深空巡航:发放实时玩法奖励(v0.6) + 1215→ grantCruiseReward: (rewards) => { + 1216→ const s = get(); + 1217→ const addCrystals = rewards.crystals ?? 0; + 1218→ const addInsights = rewards.insights ?? 0; + 1219→ const addContact = rewards.contact ?? 0; + 1220→ // v0.7 角色属性:巡航通关给探索力+勇气经验(按晶体奖励量缩放) + 1221→ const totalReward = addCrystals + addInsights * 10 + addContact * 10; + 1222→ const expBase = Math.max(2, Math.floor(totalReward / 30)); + 1223→ const curAttrs = s.attributes ?? createInitialAttributes(); + 1224→ const curProg = s.attributeProgress ?? createInitialAttributeProgress(); + 1225→ const explLvl = levelUpCheck( + 1226→ { exp: curProg.exploration.exp + expBase, level: curAttrs.exploration }, + 1227→ ATTRIBUTE_HARD_CAP + 1228→ ); + 1229→ const courageLvl = levelUpCheck( + 1230→ { exp: curProg.courage.exp + Math.floor(expBase * 0.6), level: curAttrs.courage }, + 1231→ ATTRIBUTE_HARD_CAP + 1232→ ); + 1233→ const newAttributes: CharacterAttributes = { + 1234→ ...curAttrs, + 1235→ exploration: explLvl.newProgress.level, + 1236→ courage: courageLvl.newProgress.level, + 1237→ }; + 1238→ const newProgress: AttributeProgress = { + 1239→ ...curProg, + 1240→ exploration: explLvl.newProgress, + 1241→ courage: courageLvl.newProgress, + 1242→ }; + 1243→ set({ + 1244→ crystals: Math.min(s.crystalCap, s.crystals + addCrystals), + 1245→ insights: s.insights + Math.round(addInsights), + 1246→ contact: Math.min(100, s.contact + addContact), + 1247→ attributes: newAttributes, + 1248→ attributeProgress: newProgress, + 1249→ ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation, attributes: newAttributes }), + 1250→ }); + 1251→ }, + 1252→ + 1253→ // ============ 角色属性系统(v0.7 P1) ============ + 1254→ allocateAttribute: (attr, points = 1) => { + 1255→ const s = get(); + 1256→ const cur = s.attributes ?? createInitialAttributes(); + 1257→ const curVal = cur[attr] ?? 0; + 1258→ if (curVal >= ATTRIBUTE_HARD_CAP) { + 1259→ return { ok: false, leveledUp: 0 }; + 1260→ } + 1261→ if ((s.pendingAttrPoints ?? 0) < points) { + 1262→ return { ok: false, leveledUp: 0 }; + 1263→ } + 1264→ const alloc = Math.min(points, ATTRIBUTE_HARD_CAP - curVal, s.pendingAttrPoints); + 1265→ const newVal = curVal + alloc; + 1266→ const newAttributes: CharacterAttributes = { ...cur, [attr]: newVal }; + 1267→ // 同步经验进度 level 字段(保持一致) + 1268→ const curProg = s.attributeProgress ?? createInitialAttributeProgress(); + 1269→ const oldProg = curProg[attr] ?? { exp: 0, level: curVal }; + 1270→ const newProgress: AttributeProgress = { + 1271→ ...curProg, + 1272→ [attr]: { exp: oldProg.exp, level: newVal }, + 1273→ }; + 1274→ set({ + 1275→ attributes: newAttributes, + 1276→ attributeProgress: newProgress, + 1277→ pendingAttrPoints: s.pendingAttrPoints - alloc, + 1278→ ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation, attributes: newAttributes }), + 1279→ }); + 1280→ return { ok: true, leveledUp: alloc }; + 1281→ }, + 1282→ + 1283→ gainAttributeExp: (attr, amount) => { + 1284→ const s = get(); + 1285→ const cur = s.attributes ?? createInitialAttributes(); + 1286→ const curProg = s.attributeProgress ?? createInitialAttributeProgress(); + 1287→ const progEntry = curProg[attr] ?? { exp: 0, level: cur[attr] }; + 1288→ const result = levelUpCheck( + 1289→ { exp: progEntry.exp + amount, level: cur[attr] }, + 1290→ ATTRIBUTE_HARD_CAP + 1291→ ); + 1292→ const newAttributes: CharacterAttributes = { ...cur, [attr]: result.newProgress.level }; + 1293→ const newProgress: AttributeProgress = { + 1294→ ...curProg, + 1295→ [attr]: result.newProgress, + 1296→ }; + 1297→ set({ + 1298→ attributes: newAttributes, + 1299→ attributeProgress: newProgress, + 1300→ ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation, attributes: newAttributes }), + 1301→ }); + 1302→ return { leveledUp: result.levelsGained, newLevel: result.newProgress.level }; + 1303→ }, + 1304→ }), + 1305→ { + 1306→ name: "echo-nexus-save-v1", + 1307→ storage: createJSONStorage(() => localStorage), + 1308→ // 不持久化临时字段 + 1309→ partialize: (s) => { + 1310→ const { _lastAutoDecode, _lastSpawn, _combo, _lastPulse, _achievementQueue, _tideEvents, globalTide, ...rest } = s; + 1311→ void _lastAutoDecode; void _lastSpawn; void _combo; void _lastPulse; void _achievementQueue; void _tideEvents; void globalTide; + 1312→ return rest as GameState; + 1313→ }, + 1314→ } + 1315→ ) + 1316→); + 1317→ + 1318→/** 选择器:未解锁碎片中下一个门槛 */ + 1319→export function nextFragmentThreshold(totalDecoded: number): number | null { + 1320→ for (const f of FRAGMENTS) { + 1321→ if (totalDecoded < f.threshold) return f.threshold; + 1322→ } + 1323→ return null; + 1324→} + 1325→ + 1326→export { FRAGMENTS, PRESTIGE, TECH_TREE }; + 1327→ + 1328→// 开发期调试:暴露 store 到 window,便于 QA 测试 + 1329→if (typeof window !== "undefined" && process.env.NODE_ENV !== "production") { + 1330→ (window as unknown as { __gameStore?: typeof useGameStore }).__gameStore = useGameStore; + 1331→} + 1332→ \ No newline at end of file diff --git a/worklog.md b/worklog.md index 1cce4cd65..b6535816a 100755 --- a/worklog.md +++ b/worklog.md @@ -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.md(v0.8.1,10 大系统)+ beacon.ts(873行,日+周+链)+ starTide.ts(209行,6种星潮)+ gameStore trackBeacon/tickTide + BeaconPanel.tsx,确认四色全息规范与现有架构 +- **工单 #5 限时挑战**: + - beacon.ts(873→1162行)新增限时挑战系统: + - BeaconTimedChallenge/BeaconTimedProgress 接口 + isTimed 字段加入 BeaconScoreEntry + - getTimedSlotKey(UTC 0/4/8/12/16/20 点切分,"timed_YYYY-MM-DD_HH")+ timedSlotKeyToSeed(FNV-1a) + - generateTimedChallenge(确定性,难度 routine,goal=日基准×0.3-0.5,5类型公式) + - loadTimedProgress/saveTimedProgress/addTimedProgress/claimTimedReward(排行榜 isTimed) + - msUntilNextTimedSlot + TIMED_SLOT_MS=4h + BEACON_TIMED_KEY + - gameStore.ts:trackBeacon 返回值新增 timedJustCompleted,三进度同时更新;新增 claimTimedBeacon action + - BeaconPanel.tsx(753→944行):日挑战与周挑战之间插入限时挑战区块(amber 主题) + - Hourglass图标+TIMED标题+slotKey+倒计时 + 进度+奖励+领取按钮(emerald) + - 紧急状态:距时段结束<30min 切 rose 脉冲动画+"即将结束"徽章 + - 2新动画 timed-glow(amber)/timed-urgent(rose) + 排行榜 TIMED 徽章 +- **工单 #3 星潮深化**: + - starTide.ts(209→289行):TideType 新增 surge/eclipse/prism + - surge(emerald ⇈): crystalsPerSec×2.5 + targetLenBonus=2(解码目标序列+2,更长更难) + - eclipse(rose ◐): crystalsPerSec×0.7 + bossWinRateBonus=0.2(BOSS胜率+20%) + - prism(fuchsia ◬): insightMultAdd=1.0(洞见×2) + autoDecodeIntervalMult=0.7(自动解码-30%) + - TideModifiers 新增 targetLenBonus/bossWinRateBonus/autoDecodeIntervalMult 可选字段 + - TIDE_WEIGHTS 新增3种各12(总136)+ getTideModifiers 3新case + - decode.ts:generatePuzzle 新增 targetLenBonus 参数(surge用,targetLen+stepLimit同步增加) + - gameStore.ts 3处修饰器应用: + - startDecode:surge 时传 targetLenBonus → 谜题更长 + - autoDecodeTick:prism 时 interval×=autoDecodeIntervalMult + - resolveCurrentNode:eclipse 时 bossWinRate 加 tideBossBonus + - achievements.ts:ach_tides_all 阈值6→9;page.tsx StatsPanel 星潮亲历 /9 +- QA 验证: + - lint 零错误(exit 0)+ dev HTTP 200 + 编译<30ms + - BeaconPanel VLM 8.5/10(限时区块可见)+ 最终 9/10 + - 三新星潮 localStorage 注入触发截图 VLM:surge 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 支持 targetLenBonus(surge 谜题+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: +- 前置阅读:worklog(v0.8.1 项目状态)+ beacon.ts(BeaconScoreEntry / loadLeaderboard / pushLeaderboardEntry / claimBeaconReward / claimWeeklyReward / claimTimedReward)+ BeaconPanel.tsx(排行榜 UI 渲染)+ Caddyfile(?XTransformPort=端口号 转发规则)+ examples/websocket(mini-service 参考)+ .zscripts/dev.sh(mini-service 启动模式) +- 发现 worklog 信息滞后:beacon.ts 实际已扩到 1161 行(v0.8.2 已加限时挑战 + claimTimedReward),BeaconPanel 已扩到 953 行 +- 创建 mini-service: + - `mini-services/leaderboard-service/package.json`:独立 bun 项目,type=module,scripts `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=1,uptime 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` — GET 相对路径 + ?XTransformPort=3030 + - `submitCloudScore(entry): Promise` — POST,成功返回 rank,失败返回 -1,自动记录 ts 供全球榜高亮 + - `fetchCloudStats(): Promise` — 全局统计 + - 所有 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 入口全部接入云提交:claimBeaconReward(BeaconPanel 直接调)/ claimWeeklyBeacon / claimTimedBeacon(gameStore 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.md(v0.8.1,四色规范 emerald/rose/amber/fuchsia,禁止蓝色,10 大系统)+ chronicle.ts(680 行)+ starTide.ts(297 行,9 种星潮)+ gameStore.ts(1332 行)+ StarTide.tsx(170 行)+ 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-service(mini-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.ts(250 行): + - 端口固定 3031,path:"/"(Caddy 据此转发) + - 6 种星潮类型表(crystal/resonance/ruins/void/core/silence)+ 权重表 + rollTide + - DURATION_SEC=60s 全球星潮持续;FIRST_TIDE_DELAY_MS=60s(QA 友好,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}) → scheduleNextTide(10-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.md:API/事件文档 + - **前端集成**: + - src/hooks/useGlobalTide.ts(127 行): + - 单例 socket(sharedSocket + 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" 保险 + - 导出 adminTriggerGlobalTide(dev 控制台 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):创建 newTide(60s)+ 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) + - StarTideNotifier:toast 文案区分全球/本地("🌐 全球星潮降临:{name}" + "全球玩家同步经历 · {desc}") +- **QA 验证**: + - `bun run lint` 零错误零警告 + - dev.log 全程无错误,编译 < 70ms,HTTP 200 + - mini-service 启动稳定:pid 9456,PPID=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 才能正确路由): + - 页面加载 200,window.__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: +- ✅ 工单 #9:chronicle.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-tide(60s 持续),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 9456,PPID=1,uptime 19+ 分钟,端口 3031 监听正常 +- ✅ lint 零错误 + dev HTTP 200 + VLM 8/10(🌐 全球星潮标记清晰可见) +- ✅ 严格四色规范:全球星潮琥珀色扫光(amber)+ 各星潮本色(emerald/rose/amber/fuchsia),零蓝色 +- 下一阶段工单:可考虑 v0.9 收尾(全球星潮成就/编年史全球星潮高亮/星图天文台全球星潮增强天赋)