diff --git a/agent-ctx/9-b-full-stack-developer.md b/agent-ctx/9-b-full-stack-developer.md new file mode 100644 index 000000000..0ab00b1e6 --- /dev/null +++ b/agent-ctx/9-b-full-stack-developer.md @@ -0,0 +1,141 @@ +# Task 9-b · full-stack-developer · 信标系统扩展(周挑战 + 信标链) + +> 本文件为本 agent 在 Task 9-b 的工作记录,供后续 agent 查阅。 + +## 任务概述 + +为「回响星核 / Echo Nexus」v0.8 扩展深空信标系统,新增两大功能: +1. **周挑战(Weekly Challenge)** — 每周一 UTC 0 点刷新,目标更大、奖励更好,与日挑战并行 +2. **信标链(Beacon Chain)** — 连续完成日挑战形成"链",达成里程碑(3/7/14/30 天)领取递增大奖,含 grace 续命机制 + +## 阅读的前置工作 + +- `/home/z/my-project/worklog.md`(v0.8 项目状态,10 大系统,四色全息规范) +- `/home/z/my-project/src/lib/game/beacon.ts`(v0.5 原版 358 行:每日挑战 + 本地排行榜) +- `/home/z/my-project/src/components/game/BeaconPanel.tsx`(原版 301 行) +- `/home/z/my-project/src/store/gameStore.ts` 第 173 行 `trackBeacon` 函数 + 7 处调用点(pulse/clickNode/autoDecodeTick/resolveCurrentNode 等) + +## 实现细节 + +### 1. beacon.ts 扩展(358 → 873 行) + +**周挑战(WEEKLY CHALLENGE)section**: +- `BeaconWeeklyChallenge` 接口 + `BeaconWeeklyProgress` 接口 +- `getWeekKey(now)`:ISO 8601 周键(YYYY-Www,周一为起点,含首个周四的周为第一周) +- `weekKeyToSeed`:FNV-1a 哈希 +- `generateWeeklyChallenge(now)`:基于 weekKey 种子确定性生成 + - 难度加权:anomaly 60% / singular 40% + - mult = 3 + floor(rng() * 3) → 3-5 倍 + - goal 范围:decode 18-75 / expedition 3-15 / pulse 60-245 / boss 3-12 / insight 120-595 +- `loadWeeklyProgress` / `saveWeeklyProgress` / `addWeeklyProgress` / `claimWeeklyReward` +- `msUntilNextWeek(now)` +- `BEACON_WEEKLY_KEY = "echo-nexus-beacon-weekly-v1"` + +**信标链(BEACON CHAIN)section**: +- `BeaconChainState` 接口(lastCompletedDateKey / currentStreak / longestStreak / totalCompletions / graceUsed / milestonesClaimed) +- `BEACON_CHAIN_KEY = "echo-nexus-beacon-chain-v1"` +- `BEACON_CHAIN_MILESTONES = [3, 7, 14, 30] as const` +- `BEACON_CHAIN_REWARDS`:4 个里程碑 + - 3 天:+50 洞见 / +5 接触 / "三日谐振" + - 7 天:+120 洞见 / +12 接触 / "七日回响" + - 14 天:+280 洞见 / +28 接触 / "半月星潮" + - 30 天:+680 洞见 / +68 接触 / "满月飞升" +- `loadChainState` / `saveChainState`(每次返回新对象避免引用共享 bug) +- `recordChainCompletion(dateKey)` 核心逻辑: + - 同日重复完成 → 忽略 + - 次日 → streak++ + - 隔一天 miss 且 graceUsed<1 → 续命 streak++ graceUsed++ + - 其他 → 断链 streak=1 graceUsed=0 + - 返回 `{ state, newMilestones }` +- `claimChainMilestone(milestone)` / `getNextMilestone(streak)` / `getChainProgress(streak)` +- `dateKeyToTimestamp` / `dateKeyDiffDays` 工具 + +`BeaconScoreEntry` 新增可选 `isWeekly?: boolean` 字段,向后兼容。 + +### 2. gameStore.ts 集成 + +- import 扩展:新增 generateWeeklyChallenge / loadWeeklyProgress / loadChainState / addWeeklyProgress / recordChainCompletion / claimWeeklyReward / claimChainMilestone / getTodayKey + 类型 +- `trackBeacon(type, delta)` 返回值从 `boolean` 升级为 `{ dailyJustCompleted, weeklyJustCompleted, newChainMilestones }`: + - 同时更新日挑战进度 + 周挑战进度 + - 日挑战刚完成时调用 `recordChainCompletion(getTodayKey())` + - 所有 7 处调用点原本忽略返回值,向后兼容 +- 新增 action `claimWeeklyBeacon()`:调用 claimWeeklyReward → 发放奖励到 state +- 新增 action `claimChainReward(milestone)`:前置校验 → claimChainMilestone → 发放奖励 +- GameActions 接口同步扩展 + +### 3. BeaconPanel.tsx 重写(301 → 638 行) + +- 头部 + 每日挑战卡片(v0.5 保留)+ 难度色按钮主题 +- **周挑战区块**(fuchsia 主题):标题 + weekKey + 倒计时 + 卡片(标签/标题/描述/进度/奖励/领取按钮 emerald)+ weekly-glow 动画 +- **信标链区块**(amber→rose 渐变): + - 标题"信标链 · CHAIN" + 大字号 streak(chain-streak-text 流动渐变动画) + - 今日完成状态徽章 + - 4 个里程碑节点(w-12 h-12 圆形): + - claimed: emerald 实心 + ✓ + - reachable: rose 脉冲动画 + "领取"按钮 + - inProgress (next milestone): amber 半亮 + - 未到达: muted 灰 + - 节点间连线:背景灰 + 已达成部分 amber→rose 渐变 + 辉光,基于 prev→next 插值定位 + - 进度条 + 底部统计(最长链/累计完成/续命状态) +- 桌面端 lg:grid-cols-2 让周挑战 + 信标链并排,移动端单列 +- 排行榜区分日/周:周挑战 entry 显示 "WEEK" 徽章 + fuchsia 高亮背景 +- 4 个新 CSS 动画:weekly-glow / chain-milestone-pulse / chain-streak-flux + +## QA 验证结果 + +### 1. lint +- `bun run lint` 零错误(每次修改后均验证) + +### 2. dev 服务器 +- dev.log 全程无错误,所有编译 < 300ms,HTTP 200 + +### 3. 信标链逻辑测试(bun 直接运行 TS,5 个场景全 PASS) +1. ✅ 昨日 streak=1 → 今日完成 → streak=2(normal increment) +2. ✅ 同日重复完成 → 忽略 +3. ✅ 明日完成 → streak=3,无需 grace +4. ✅ 隔一天 miss(lastCompleted + 2 天)→ streak=4,graceUsed=1(续命触发) +5. ✅ 再次 miss(grace 已用)→ 断链 streak=1,graceUsed=0 + +### 4. UI 集成测试(agent-browser) +- localStorage 模拟设置 chain state(lastCompletedDateKey=yesterday, currentStreak=1)→ reload → UI 正确显示 streak=1 +- 设置 streak=3 + milestonesClaimed=[] → milestone 3 节点显示 rose 脉冲动画 + "领取"按钮 +- 点击"领取"→ milestonesClaimed=[3] + Toast "✦ 三日谐振 已领取 +50 洞见 · +5.0 接触" + 按钮消失 ✅ + +### 5. VLM 视觉评分(目标 ≥7/10) +- 首屏截图:**8/10**(四色一致、布局合理、信标链清晰) +- 里程碑可领取状态:**8/10**(amber→rose 渐变醒目、里程碑层次分明、fuchsia 主题清晰) +- 重置后干净状态:**8/10**(WEEKLY 字体对比度可优化,但整体可读性强) + +## 修改的文件 + +1. `src/lib/game/beacon.ts` — 358 → 873 行(+515 行) +2. `src/store/gameStore.ts` — trackBeacon 升级 + 2 个新 action(+~80 行) +3. `src/components/game/BeaconPanel.tsx` — 301 → 638 行(+337 行,重写) + +## 不破坏的现有功能 + +- 日挑战进度追踪与领奖流程 +- 本地排行榜 Top20 +- 现有 5 种挑战类型 + 3 档难度 +- 现有 localStorage keys(echo-nexus-beacon-lb-v1 / echo-nexus-beacon-prog-v1) + +## 新增的 localStorage keys + +- `echo-nexus-beacon-weekly-v1`(周挑战进度) +- `echo-nexus-beacon-chain-v1`(信标链状态) + +## 四色全息规范遵循 + +- 周挑战主题:**fuchsia** (#e879f9) +- 信标链主题:**amber → rose** 渐变 (#fbbf24 → #fb7185) +- 领取按钮:**emerald** (#34d399) +- 难度色:routine emerald / anomaly amber / singular rose +- **零蓝色/靛色违规** + +## 截图资产 + +- `/home/z/my-project/agent-ctx/beacon-panel-v0.8.png` — 初次进入信标页 +- `/home/z/my-project/agent-ctx/beacon-chain-streak1.png` — chain streak=1 状态 +- `/home/z/my-project/agent-ctx/beacon-chain-milestone3.png` — chain streak=3 milestone 可领取 +- `/home/z/my-project/agent-ctx/beacon-milestone3-claimable.png` — milestone 3 领取前 +- `/home/z/my-project/agent-ctx/beacon-final-fresh.png` — 重置后干净状态 diff --git a/agent-ctx/beacon-chain-milestone3.png b/agent-ctx/beacon-chain-milestone3.png new file mode 100644 index 000000000..16da37df6 Binary files /dev/null and b/agent-ctx/beacon-chain-milestone3.png differ diff --git a/agent-ctx/beacon-chain-streak1.png b/agent-ctx/beacon-chain-streak1.png new file mode 100644 index 000000000..99ec9f5e9 Binary files /dev/null and b/agent-ctx/beacon-chain-streak1.png differ diff --git a/agent-ctx/beacon-final-fresh.png b/agent-ctx/beacon-final-fresh.png new file mode 100644 index 000000000..b1beafb80 Binary files /dev/null and b/agent-ctx/beacon-final-fresh.png differ diff --git a/agent-ctx/beacon-milestone3-claimable.png b/agent-ctx/beacon-milestone3-claimable.png new file mode 100644 index 000000000..a6d048d0e Binary files /dev/null and b/agent-ctx/beacon-milestone3-claimable.png differ diff --git a/agent-ctx/beacon-panel-v0.8.png b/agent-ctx/beacon-panel-v0.8.png new file mode 100644 index 000000000..20e6c6c7d Binary files /dev/null and b/agent-ctx/beacon-panel-v0.8.png differ diff --git a/src/components/game/BeaconPanel.tsx b/src/components/game/BeaconPanel.tsx index 0295e60b1..24da8746a 100644 --- a/src/components/game/BeaconPanel.tsx +++ b/src/components/game/BeaconPanel.tsx @@ -1,29 +1,54 @@ "use client"; -// 回响星核 / Echo Nexus — 深空信标面板(v0.5 每日挑战 + 本地排行榜) +// 回响星核 / Echo Nexus — 深空信标面板 +// v0.5:每日挑战 + 本地排行榜 +// v0.8:周挑战 + 信标链(连续完成奖励) import { useState, useEffect, useCallback } from "react"; import { useGameStore } from "@/store/gameStore"; import { useToast } from "@/hooks/use-toast"; import { sfx } from "@/hooks/useAudio"; import { generateDailyChallenge, + generateWeeklyChallenge, loadDailyProgress, + loadWeeklyProgress, loadLeaderboard, + loadChainState, claimBeaconReward, - msUntilNextDay, - formatCountdown, - getTodayKey, BEACON_DIFFICULTY, BEACON_TYPE_META, + BEACON_CHAIN_MILESTONES, + BEACON_CHAIN_REWARDS, + getWeekKey, + getTodayKey, + getNextMilestone, + getChainProgress, + msUntilNextDay, + msUntilNextWeek, + formatCountdown, type BeaconDailyChallenge, type BeaconDailyProgress, + type BeaconWeeklyChallenge, + type BeaconWeeklyProgress, type BeaconScoreEntry, type BeaconChallengeType, type BeaconDifficulty, + type BeaconChainState, } from "@/lib/game/beacon"; import { formatNum } from "@/lib/game/config"; import { Button } from "@/components/ui/button"; import { Progress } from "@/components/ui/progress"; -import { Radio, Clock, Trophy, Sparkles, Award, Crown, Medal } from "lucide-react"; +import { + Radio, + Clock, + Trophy, + Sparkles, + Award, + Crown, + Medal, + Link2, + Flame, + Zap, +} from "lucide-react"; const DIFFICULTY_ORDER: BeaconDifficulty[] = ["routine", "anomaly", "singular"]; @@ -35,34 +60,48 @@ function rankBadge(rank: number) { } export function BeaconPanel() { - const insights = useGameStore((s) => s.insights); const grantBeaconReward = useGameStore((s) => s.grantBeaconReward); + const claimWeeklyBeacon = useGameStore((s) => s.claimWeeklyBeacon); + const claimChainReward = useGameStore((s) => s.claimChainReward); const { toast } = useToast(); const [challenge, setChallenge] = useState(null); const [progress, setProgress] = useState(null); + const [weeklyChallenge, setWeeklyChallenge] = + useState(null); + const [weeklyProgress, setWeeklyProgress] = + useState(null); + const [chainState, setChainState] = useState(null); const [leaderboard, setLeaderboard] = useState([]); - const [countdown, setCountdown] = useState("00:00:00"); + const [dayCountdown, setDayCountdown] = useState("00:00:00"); + const [weekCountdown, setWeekCountdown] = useState("00:00:00"); const [now, setNow] = useState(Date.now()); // 初始化 + 每秒刷新(进度 + 倒计时) useEffect(() => { setChallenge(generateDailyChallenge()); setProgress(loadDailyProgress()); + setWeeklyChallenge(generateWeeklyChallenge()); + setWeeklyProgress(loadWeeklyProgress()); + setChainState(loadChainState()); setLeaderboard(loadLeaderboard()); const id = setInterval(() => { setNow(Date.now()); setProgress(loadDailyProgress()); + setWeeklyProgress(loadWeeklyProgress()); + setChainState(loadChainState()); setChallenge((c) => c ?? generateDailyChallenge()); + setWeeklyChallenge((c) => c ?? generateWeeklyChallenge()); }, 1000); return () => clearInterval(id); }, []); useEffect(() => { - setCountdown(formatCountdown(msUntilNextDay(new Date(now)))); + setDayCountdown(formatCountdown(msUntilNextDay(new Date(now)))); + setWeekCountdown(formatCountdown(msUntilNextWeek(new Date(now)))); }, [now]); - const handleClaim = useCallback(() => { + const handleClaimDaily = useCallback(() => { if (!challenge || !progress) return; if (progress.completedAt === null || progress.claimed) return; const res = claimBeaconReward(challenge, progress); @@ -72,12 +111,51 @@ export function BeaconPanel() { grantBeaconReward(res.rewardInsight, res.rewardContact); sfx("achievement"); toast({ - title: "✦ 信标奖励已领取", - description: `+${res.rewardInsight} 洞见 · +${res.rewardContact.toFixed(1)} 接触 · 得分 ${res.score}`, + title: "✦ 每日信标奖励已领取", + description: `+${res.rewardInsight} 洞见 · +${res.rewardContact.toFixed( + 1 + )} 接触 · 得分 ${res.score}`, }); }, [challenge, progress, toast, grantBeaconReward]); - if (!challenge || !progress) { + const handleClaimWeekly = useCallback(() => { + if (!weeklyChallenge || !weeklyProgress) return; + if (weeklyProgress.completedAt === null || weeklyProgress.claimed) return; + const res = claimWeeklyBeacon(); + setLeaderboard(loadLeaderboard()); + setWeeklyProgress(loadWeeklyProgress()); + if (res.rewardInsight > 0 || res.rewardContact > 0) { + sfx("achievement"); + toast({ + title: "✦ 周挑战奖励已领取", + description: `+${res.rewardInsight} 洞见 · +${res.rewardContact.toFixed( + 1 + )} 接触 · 得分 ${res.score}`, + }); + } + }, [weeklyChallenge, weeklyProgress, toast, claimWeeklyBeacon]); + + const handleClaimChain = useCallback( + (milestone: number) => { + const reward = BEACON_CHAIN_REWARDS.find( + (r) => r.milestone === milestone + ); + const res = claimChainReward(milestone); + setChainState(loadChainState()); + if (res.ok) { + sfx("achievement"); + toast({ + title: `✦ ${res.label || reward?.label || "里程碑"} 已领取`, + description: `+${res.rewardInsight} 洞见 · +${res.rewardContact.toFixed( + 1 + )} 接触`, + }); + } + }, + [toast, claimChainReward] + ); + + if (!challenge || !progress || !weeklyChallenge || !weeklyProgress || !chainState) { return (
正在校准深空信标… @@ -92,6 +170,20 @@ export function BeaconPanel() { const isClaimed = progress.claimed; const canClaim = isCompleted && !isClaimed; + // 周挑战派生量 + const wDiffMeta = BEACON_DIFFICULTY[weeklyChallenge.difficulty]; + const wTypeMeta = BEACON_TYPE_META[weeklyChallenge.type]; + const wPct = Math.min(100, (weeklyProgress.progress / weeklyChallenge.goal) * 100); + const wCompleted = weeklyProgress.completedAt !== null; + const wClaimed = weeklyProgress.claimed; + const wCanClaim = wCompleted && !wClaimed; + + // 信标链派生量 + const chainProgress = getChainProgress(chainState.currentStreak); + const nextMilestone = getNextMilestone(chainState.currentStreak); + const todayKey = getTodayKey(); + const completedToday = chainState.lastCompletedDateKey === todayKey; + return (
{/* 头部:信标 + 倒计时 */} @@ -117,7 +232,7 @@ export function BeaconPanel() {
次日重置 - {countdown} + {dayCountdown}
@@ -205,14 +320,15 @@ export function BeaconPanel() { +{challenge.rewardContact.toFixed(1)}接触
+ + + {wCompleted && weeklyProgress.durationSec > 0 && ( +
+ 完成用时 {Math.floor(weeklyProgress.durationSec / 60)}分{weeklyProgress.durationSec % 60}秒 +
+ )} + + + {/* ===== 信标链卡片(amber→rose 渐变) ===== */} +
+ {/* 头部:标题 + 当前连续天数 */} +
+
+ + + 信标链 · CHAIN + +
+
+ + 连续 + + {chainState.currentStreak} + + +
+
+ + {/* 今日完成状态 */} +
+ + {completedToday + ? "今日已贡献" + : "今日尚未完成日挑战"} + + + {completedToday ? "✓ 已记录" : "○ 待完成"} + +
+ + {/* 4 个里程碑节点横向排列 */} +
+ {/* 节点之间的连线(背景灰) */} +
+ {/* 已达成部分高亮(基于 prev→next 插值) */} + {(() => { + // 节点圆心水平位置(百分比) + const MILESTONE_POS: Record = { + 0: 12.5, + 3: 12.5, + 7: 37.5, + 14: 62.5, + 30: 87.5, + }; + const prevPos = + MILESTONE_POS[chainProgress.prev] ?? 12.5; + const nextPos = + chainProgress.next !== null + ? MILESTONE_POS[chainProgress.next] ?? 87.5 + : 87.5; + const pct = chainProgress.progressPct / 100; + const activeEndPos = prevPos + (nextPos - prevPos) * pct; + const widthPct = Math.max(0, activeEndPos - 12.5); + if (widthPct <= 0) return null; + return ( +
+ ); + })()} + + {BEACON_CHAIN_MILESTONES.map((m) => { + const reward = BEACON_CHAIN_REWARDS.find((r) => r.milestone === m); + const claimed = chainState.milestonesClaimed.includes(m); + const reachable = + chainState.currentStreak >= m && !claimed; + const inProgress = chainState.currentStreak > 0 && nextMilestone === m; + // 节点配色 + let nodeBg = "rgba(255,255,255,0.05)"; + let nodeBorder = "rgba(255,255,255,0.15)"; + let textColor = "rgba(255,255,255,0.4)"; + if (claimed) { + nodeBg = "rgba(52,211,153,0.25)"; + nodeBorder = "#34d399"; + textColor = "#34d399"; + } else if (reachable) { + nodeBg = "rgba(251,113,133,0.20)"; + nodeBorder = "#fb7185"; + textColor = "#fb7185"; + } else if (inProgress) { + nodeBg = "rgba(251,191,36,0.20)"; + nodeBorder = "#fbbf24"; + textColor = "#fbbf24"; + } + + return ( +
+
+ {claimed ? ( + + ) : ( + m + )} +
+ + {reward?.label} + + {claimed ? ( + + 已领 + + ) : reachable ? ( + + ) : ( + + +{reward?.rewardInsight}洞 + + )} +
+ ); + })} +
+ + {/* 进度条 */} +
+
+ + {chainProgress.next === null + ? "已通关全部里程碑" + : `下一目标:${chainProgress.next} 天`} + + + {chainProgress.current} + {chainProgress.next !== null && ` / ${chainProgress.next}`} 天 + +
+ +
+ + {/* 底部统计 */} +
+
+ 最长 + + {chainState.longestStreak}天 + +
+
+ 累计 + + {chainState.totalCompletions}次 + +
+
+ 续命 + {chainState.graceUsed >= 1 ? ( + 已用 + ) : ( + 可用 + )} +
+
+
+
+ {/* 本地排行榜 */}
@@ -239,7 +682,7 @@ export function BeaconPanel() { {leaderboard.length === 0 ? (
- 尚无记录。完成今日信标即可登榜。 + 尚无记录。完成今日或本周信标即可登榜。
) : (
@@ -247,12 +690,16 @@ export function BeaconPanel() { const rb = rankBadge(i); const eDiff = BEACON_DIFFICULTY[entry.difficulty]; const eType = BEACON_TYPE_META[entry.challenge as BeaconChallengeType]; - const isMine = entry.dateKey === getTodayKey(); + const isMine = entry.dateKey === getTodayKey() || entry.dateKey === getWeekKey(); return (
{/* 排名 */} @@ -263,10 +710,15 @@ export function BeaconPanel() { {i + 1} )} - {/* 类型 + 难度 */} + {/* 类型 + 难度 + 周挑战标记 */} {eDiff.icon} {eType.label} + {entry.isWeekly && ( + + WEEK + + )} {entry.progress >= 1 && } {/* 用时 */} diff --git a/src/components/game/ExpeditionPanel.tsx b/src/components/game/ExpeditionPanel.tsx index e57c85e36..70816e702 100644 --- a/src/components/game/ExpeditionPanel.tsx +++ b/src/components/game/ExpeditionPanel.tsx @@ -4,7 +4,7 @@ import { useState } from "react"; import { useGameStore } from "@/store/gameStore"; import { useToast } from "@/hooks/use-toast"; import { sfx } from "@/hooks/useAudio"; -import { EXPEDITION_CONFIG, combatWinRate } from "@/lib/game/expedition"; +import { EXPEDITION_CONFIG, combatWinRate, computeEnergyRegenInterval } from "@/lib/game/expedition"; import { formatNum } from "@/lib/game/config"; import { Button } from "@/components/ui/button"; import { Progress } from "@/components/ui/progress"; @@ -42,6 +42,8 @@ export function ExpeditionPanel() { const lastEnergyTick = useGameStore((s) => s.lastEnergyTick); const expeditionLog = useGameStore((s) => s.expeditionLog); const totalExpeditions = useGameStore((s) => s.totalExpeditions); + // v0.8.1 动态能量恢复间隔(技术 + 探索力属性缩短) + const regenIntervalSec = useGameStore((s) => computeEnergyRegenInterval(s)); const startExpedition = useGameStore((s) => s.startExpedition); const resolveCurrentNode = useGameStore((s) => s.resolveCurrentNode); const advanceNode = useGameStore((s) => s.advanceNode); @@ -49,10 +51,11 @@ export function ExpeditionPanel() { const { toast } = useToast(); const [lastLog, setLastLog] = useState(null); - // 能量恢复进度 + // 能量恢复进度(v0.8.1 动态间隔) const now = Date.now(); - const regenMs = EXPEDITION_CONFIG.energyRegenSec * 1000; + const regenMs = regenIntervalSec * 1000; const regenProgress = energy >= energyMax ? 100 : Math.min(100, ((now - lastEnergyTick) / regenMs) * 100); + const regenBoosted = regenIntervalSec < EXPEDITION_CONFIG.energyRegenSec; const handleStart = () => { const res = startExpedition(); @@ -126,6 +129,9 @@ export function ExpeditionPanel() { />

下一点能量约 {Math.ceil((regenMs - (now - lastEnergyTick)) / 1000)}s 后恢复 + {regenBoosted && ( + ⚡ {regenIntervalSec.toFixed(0)}s/点 (已加速) + )}

) : ( diff --git a/src/lib/game/beacon.ts b/src/lib/game/beacon.ts index 733ffdb3f..84ff11b64 100644 --- a/src/lib/game/beacon.ts +++ b/src/lib/game/beacon.ts @@ -1,6 +1,7 @@ -// 回响星核 / Echo Nexus — 深空信标(v0.5 每日挑战 + 本地排行榜) -// 一个自包含的"每日挑战"元系统:基于日期种子的固定挑战 + 本地排行榜。 -// 不依赖后端,纯 localStorage 持久化,给放置循环注入"今日目标"动机。 +// 回响星核 / Echo Nexus — 深空信标 +// v0.5:每日挑战 + 本地排行榜 +// v0.8:周挑战 + 信标链(连续完成奖励) +// 一个自包含的"每日挑战 + 周挑战 + 连续完成链"元系统,纯 localStorage 持久化。 /** 每日挑战类型 */ export type BeaconChallengeType = @@ -17,7 +18,7 @@ export type BeaconDifficulty = "routine" | "anomaly" | "singular"; export interface BeaconScoreEntry { /** 提交时间戳 */ timestamp: number; - /** 日期 key(YYYY-MM-DD) */ + /** 日期 key(YYYY-MM-DD)或周 key(YYYY-Www) */ dateKey: string; /** 挑战类型 */ challenge: BeaconChallengeType; @@ -29,6 +30,8 @@ export interface BeaconScoreEntry { score: number; /** 完成时长(秒),未完成则记 0 */ durationSec: number; + /** v0.8:是否为周挑战记录(日挑战默认 false / undefined) */ + isWeekly?: boolean; } /** 每日挑战定义 */ @@ -355,3 +358,517 @@ export function formatCountdown(ms: number): string { const s = String(total % 60).padStart(2, "0"); return `${h}:${m}:${s}`; } + +// =========================================================================== +// 周挑战(WEEKLY CHALLENGE)— v0.8 +// 每周一 UTC 0 点刷新一个"周挑战",目标更大、奖励更好,与日挑战并行存在。 +// 同一周同一种子 → 同一挑战(确定性)。难度强制 anomaly/singular(加权 60/40)。 +// =========================================================================== + +/** 周挑战 localStorage key */ +export const BEACON_WEEKLY_KEY = "echo-nexus-beacon-weekly-v1"; + +/** 周挑战定义 */ +export interface BeaconWeeklyChallenge { + /** ISO 周键(YYYY-Www,如 "2026-W26") */ + weekKey: string; + /** 挑战类型 */ + type: BeaconChallengeType; + /** 难度(固定 anomaly 或 singular) */ + difficulty: "anomaly" | "singular"; + /** 目标数值(日挑战的 3-5 倍) */ + goal: number; + /** 奖励:完成时洞见 */ + rewardInsight: number; + /** 奖励:完成时接触进度 */ + rewardContact: number; + /** 使用的种子(可复现) */ + seed: number; + /** 友好标题 */ + title: string; + /** 描述 */ + desc: string; +} + +/** 周挑战进度 */ +export interface BeaconWeeklyProgress { + weekKey: string; + progress: number; + startedAt: number; + completedAt: number | null; + claimed: boolean; + durationSec: number; +} + +/** + * 计算 ISO 8601 周键(YYYY-Www),如 "2026-W26"。 + * 规则:周一为一周开始;第一周是该年至少含 4 天的第一周(即包含第一个周四)。 + */ +export function getWeekKey(now: Date = new Date()): string { + // 取 UTC 日期,避免时区偏移 + const tmp = new Date( + Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()) + ); + // ISO 周以周一为起点:(getUTCDay + 6) % 7 → 0=Mon, 6=Sun + const dayNum = (tmp.getUTCDay() + 6) % 7; + // 把日期调整到本周四(ISO 周归属年份由该周四所在的年份决定) + tmp.setUTCDate(tmp.getUTCDate() - dayNum + 3); + const isoYear = tmp.getUTCFullYear(); + const yearStart = Date.UTC(isoYear, 0, 1); + const weekNum = Math.ceil(((tmp.getTime() - yearStart) / 86400000 + 1) / 7); + return `${isoYear}-W${String(weekNum).padStart(2, "0")}`; +} + +/** weekKey → 数值种子(FNV-1a 哈希) */ +function weekKeyToSeed(weekKey: string): number { + let h = 2166136261 >>> 0; + for (let i = 0; i < weekKey.length; i++) { + h ^= weekKey.charCodeAt(i); + h = Math.imul(h, 16777619) >>> 0; + } + return h >>> 0; +} + +/** + * 生成本周周挑战(确定性:同一周同一种子 → 同一挑战)。 + * 难度强制 anomaly(60%)或 singular(40%),goal 为日挑战基准 ×3-5 倍。 + */ +export function generateWeeklyChallenge( + now: Date = new Date() +): BeaconWeeklyChallenge { + const weekKey = getWeekKey(now); + const seed = weekKeyToSeed(weekKey); + const rng = mulberry32(seed); + + const types: BeaconChallengeType[] = [ + "decode", + "expedition", + "pulse", + "boss", + "insight", + ]; + const type = types[Math.floor(rng() * types.length)]; + + // 难度加权:anomaly 60% / singular 40% + const dr = rng(); + const difficulty: "anomaly" | "singular" = dr < 0.6 ? "anomaly" : "singular"; + const diffMult = BEACON_DIFFICULTY[difficulty].mult; + + // 3-5 倍 + const mult = 3 + Math.floor(rng() * 3); + + let goal = 0; + let rewardInsight = 0; + let rewardContact = 0; + let title = ""; + let desc = ""; + + switch (type) { + case "decode": + // 日基准 6-15 × mult(3-5) → 18-75 + goal = Math.max(18, Math.round((6 + Math.floor(rng() * 10)) * diffMult * mult)); + rewardInsight = Math.round(goal * 5 * diffMult); + rewardContact = goal * 1.2; + title = `周界解码 · ${goal} 颗晶体`; + desc = `本周深空信标要求解码 ${goal} 颗记忆晶体,强度为日挑战的 ${mult} 倍。`; + break; + case "expedition": + // 日基准 1-3 × mult(3-5) → 3-15 + goal = Math.max(3, Math.round((1 + Math.floor(rng() * 3)) * diffMult * mult)); + rewardInsight = Math.round(goal * 14 * diffMult); + rewardContact = goal * 2; + title = `周界远征 · ${goal} 次探险`; + desc = `本周派出探险队完成 ${goal} 次远征,无论胜负均计入。`; + break; + case "pulse": + // 日基准 20-49 × mult(3-5) → 60-245 + goal = Math.max(60, Math.round((20 + Math.floor(rng() * 30)) * diffMult * mult)); + rewardInsight = Math.round(goal * 1.5 * diffMult); + rewardContact = goal * 0.4; + title = `周界脉冲 · ${goal} 次扫描`; + desc = `本周发起 ${goal} 次脉冲扫描。连击可叠加加成。`; + break; + case "boss": + // 日基准 1 × mult(3-5) → 3-12(singular mult=2.4 时上限 12) + goal = Math.max(3, Math.round(mult * diffMult)); + rewardInsight = Math.round(goal * 35 * diffMult); + rewardContact = goal * 4; + title = `周界猎杀 · ${goal} 处 BOSS`; + desc = `本周在探险终点击破 ${goal} 处维度 BOSS。提升探险力后再挑战。`; + break; + case "insight": + // 日基准 40-119 × mult(3-5) → 120-595 + goal = Math.max(120, Math.round((40 + Math.floor(rng() * 80)) * diffMult * mult)); + rewardInsight = 0; // 洞见挑战不给洞见,给接触 + rewardContact = goal * 0.15; + title = `周界洞见 · ${goal} 点`; + desc = `本周通过解码、探险、星潮等途径累计 ${goal} 洞见。`; + break; + } + + return { + weekKey, + type, + difficulty, + goal, + rewardInsight, + rewardContact, + seed, + title, + desc, + }; +} + +/** 读取本周进度(若 weekKey 不匹配则重置) */ +export function loadWeeklyProgress(now: Date = new Date()): BeaconWeeklyProgress { + const weekKey = getWeekKey(now); + const empty: BeaconWeeklyProgress = { + weekKey, + progress: 0, + startedAt: Date.now(), + completedAt: null, + claimed: false, + durationSec: 0, + }; + if (typeof localStorage === "undefined") return empty; + try { + const raw = localStorage.getItem(BEACON_WEEKLY_KEY); + if (!raw) return empty; + const prog = JSON.parse(raw) as BeaconWeeklyProgress; + if (prog.weekKey !== weekKey) return empty; // 新的一周,重置 + return prog; + } catch { + return empty; + } +} + +/** 保存本周进度 */ +export function saveWeeklyProgress(prog: BeaconWeeklyProgress): void { + if (typeof localStorage === "undefined") return; + localStorage.setItem(BEACON_WEEKLY_KEY, JSON.stringify(prog)); +} + +/** 增量更新周挑战进度,返回新进度 + 是否刚完成 */ +export function addWeeklyProgress( + current: BeaconWeeklyProgress, + challenge: BeaconWeeklyChallenge, + delta: number +): { progress: BeaconWeeklyProgress; 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: BeaconWeeklyProgress = { + ...current, + progress: newProgressVal, + completedAt, + durationSec, + }; + saveWeeklyProgress(next); + return { progress: next, justCompleted }; +} + +/** 领取周挑战奖励:返回奖励数值 + 推送排行榜 */ +export function claimWeeklyReward( + challenge: BeaconWeeklyChallenge, + progress: BeaconWeeklyProgress +): { + rewardInsight: number; + rewardContact: number; + score: number; + leaderboard: BeaconScoreEntry[]; +} { + if (progress.claimed || progress.completedAt === null) { + return { + rewardInsight: 0, + rewardContact: 0, + score: 0, + leaderboard: loadLeaderboard(), + }; + } + // 借用 computeBeaconScore:把 weekly 包装成 daily 接口(dateKey 字段不影响计分逻辑) + const score = computeBeaconScore( + { ...(challenge as unknown as BeaconDailyChallenge), dateKey: challenge.weekKey }, + progress.progress, + progress.durationSec + ); + const entry: BeaconScoreEntry = { + timestamp: Date.now(), + dateKey: challenge.weekKey, + challenge: challenge.type, + difficulty: challenge.difficulty, + progress: progress.progress / challenge.goal, + score, + durationSec: progress.durationSec, + isWeekly: true, + }; + const leaderboard = pushLeaderboardEntry(entry); + const updated: BeaconWeeklyProgress = { ...progress, claimed: true }; + saveWeeklyProgress(updated); + return { + rewardInsight: challenge.rewardInsight, + rewardContact: challenge.rewardContact, + score, + leaderboard, + }; +} + +/** 距离下周一 UTC 0 点的毫秒数(用于周挑战倒计时) */ +export function msUntilNextWeek(now: Date = new Date()): number { + const dayNum = (now.getUTCDay() + 6) % 7; // 0 = Mon + const mondayThisWeek = Date.UTC( + now.getUTCFullYear(), + now.getUTCMonth(), + now.getUTCDate() - dayNum, + 0, + 0, + 0 + ); + const nextMonday = mondayThisWeek + 7 * 86400000; + return Math.max(0, nextMonday - now.getTime()); +} + +// =========================================================================== +// 信标链(BEACON CHAIN)— v0.8 +// 连续完成日挑战形成"链",达成里程碑(3/7/14/30 天)领取递增大奖。 +// 断链有宽容机制(1 天 miss 不断链,用"信标续命"概念),每条链只能用 1 次。 +// =========================================================================== + +/** 信标链 localStorage key */ +export const BEACON_CHAIN_KEY = "echo-nexus-beacon-chain-v1"; + +/** 里程碑天数 */ +export const BEACON_CHAIN_MILESTONES = [3, 7, 14, 30] as const; + +/** 里程碑奖励配置 */ +export interface BeaconChainReward { + milestone: number; + rewardInsight: number; + rewardContact: number; + label: string; +} + +export const BEACON_CHAIN_REWARDS: BeaconChainReward[] = [ + { milestone: 3, rewardInsight: 50, rewardContact: 5, label: "三日谐振" }, + { milestone: 7, rewardInsight: 120, rewardContact: 12, label: "七日回响" }, + { milestone: 14, rewardInsight: 280, rewardContact: 28, label: "半月星潮" }, + { milestone: 30, rewardInsight: 680, rewardContact: 68, label: "满月飞升" }, +]; + +/** 信标链状态 */ +export interface BeaconChainState { + /** 上次完成日(YYYY-MM-DD) */ + lastCompletedDateKey: string; + /** 当前连续天数 */ + currentStreak: number; + /** 历史最长 */ + longestStreak: number; + /** 累计完成总数 */ + totalCompletions: number; + /** 本周期已用续命数(上限 1) */ + graceUsed: number; + /** 已领取的里程碑数组 */ + milestonesClaimed: number[]; +} + +/** 读取信标链状态 */ +export function loadChainState(): BeaconChainState { + const fresh = (): BeaconChainState => ({ + lastCompletedDateKey: "", + currentStreak: 0, + longestStreak: 0, + totalCompletions: 0, + graceUsed: 0, + milestonesClaimed: [], + }); + if (typeof localStorage === "undefined") return fresh(); + try { + const raw = localStorage.getItem(BEACON_CHAIN_KEY); + if (!raw) return fresh(); + const s = JSON.parse(raw) as Partial; + return { + lastCompletedDateKey: s.lastCompletedDateKey ?? "", + currentStreak: s.currentStreak ?? 0, + longestStreak: s.longestStreak ?? 0, + totalCompletions: s.totalCompletions ?? 0, + graceUsed: s.graceUsed ?? 0, + milestonesClaimed: Array.isArray(s.milestonesClaimed) + ? [...s.milestonesClaimed] + : [], + }; + } catch { + return fresh(); + } +} + +/** 保存信标链状态 */ +export function saveChainState(state: BeaconChainState): void { + if (typeof localStorage === "undefined") return; + localStorage.setItem(BEACON_CHAIN_KEY, JSON.stringify(state)); +} + +/** dateKey → UTC 0 点时间戳 */ +function dateKeyToTimestamp(dateKey: string): number { + const [y, m, d] = dateKey.split("-").map(Number); + return Date.UTC(y, m - 1, d, 0, 0, 0); +} + +/** 计算 b - a 相差的天数(UTC 0 点对齐) */ +function dateKeyDiffDays(a: string, b: string): number { + if (!a || !b) return Number.MAX_SAFE_INTEGER; + return Math.round((dateKeyToTimestamp(b) - dateKeyToTimestamp(a)) / 86400000); +} + +/** + * 记录一次日挑战完成(核心逻辑)。 + * - dateKey === lastCompletedDateKey:同一天重复完成,忽略,返回 newMilestones: [] + * - dateKey 是 lastCompletedDateKey 的次日:currentStreak++ + * - dateKey 是 lastCompletedDateKey 的后两天(隔了一天 miss)且 graceUsed < 1:续命一次,currentStreak++ + * - 其他:currentStreak = 1(断链重来),graceUsed = 0 + * 更新 longestStreak 与 totalCompletions。 + * @returns { state, newMilestones } 刚达成但未领取的里程碑数组 + */ +export function recordChainCompletion(dateKey: string): { + state: BeaconChainState; + newMilestones: number[]; +} { + const state = loadChainState(); + + // 同一天重复完成:忽略 + if (state.lastCompletedDateKey === dateKey) { + return { state, newMilestones: [] }; + } + + let next: BeaconChainState; + + if (state.lastCompletedDateKey === "") { + // 首次完成 + next = { + ...state, + lastCompletedDateKey: dateKey, + currentStreak: 1, + longestStreak: Math.max(state.longestStreak, 1), + totalCompletions: state.totalCompletions + 1, + }; + } else { + const diff = dateKeyDiffDays(state.lastCompletedDateKey, dateKey); + if (diff === 1) { + // 次日:链 +1 + const newStreak = state.currentStreak + 1; + next = { + ...state, + lastCompletedDateKey: dateKey, + currentStreak: newStreak, + longestStreak: Math.max(state.longestStreak, newStreak), + totalCompletions: state.totalCompletions + 1, + }; + } else if (diff === 2 && state.graceUsed < 1) { + // 隔一天 miss,续命一次 + const newStreak = state.currentStreak + 1; + next = { + ...state, + lastCompletedDateKey: dateKey, + currentStreak: newStreak, + longestStreak: Math.max(state.longestStreak, newStreak), + totalCompletions: state.totalCompletions + 1, + graceUsed: state.graceUsed + 1, + }; + } else { + // 断链重来 + next = { + ...state, + lastCompletedDateKey: dateKey, + currentStreak: 1, + totalCompletions: state.totalCompletions + 1, + graceUsed: 0, + longestStreak: Math.max(state.longestStreak, 1), + }; + } + } + + // 检查新里程碑(刚达成但未领取) + const newMilestones: number[] = []; + for (const m of BEACON_CHAIN_MILESTONES) { + if ( + next.currentStreak >= m && + !next.milestonesClaimed.includes(m) + ) { + newMilestones.push(m); + } + } + + saveChainState(next); + return { state: next, newMilestones }; +} + +/** 领取里程碑奖励,加入 milestonesClaimed */ +export function claimChainMilestone(milestone: number): { + rewardInsight: number; + rewardContact: number; + label: string; + state: BeaconChainState; +} { + const reward = BEACON_CHAIN_REWARDS.find((r) => r.milestone === milestone); + const state = loadChainState(); + if ( + !reward || + state.milestonesClaimed.includes(milestone) || + state.currentStreak < milestone + ) { + return { rewardInsight: 0, rewardContact: 0, label: "", state }; + } + const next: BeaconChainState = { + ...state, + milestonesClaimed: [...state.milestonesClaimed, milestone], + }; + saveChainState(next); + return { + rewardInsight: reward.rewardInsight, + rewardContact: reward.rewardContact, + label: reward.label, + state: next, + }; +} + +/** 返回下一个目标里程碑(如 streak=5 → 7;streak=30+ → null) */ +export function getNextMilestone(streak: number): number | null { + for (const m of BEACON_CHAIN_MILESTONES) { + if (streak < m) return m; + } + return null; +} + +/** + * 返回信标链进度信息(用于 UI 进度条)。 + * - current:当前连续天数 + * - next:下一个目标里程碑(null 表示已通关全部) + * - progressPct:当前进度(基于上一个里程碑 → 下一个里程碑) + */ +export function getChainProgress(streak: number): { + current: number; + next: number | null; + prev: number; + progressPct: number; +} { + const next = getNextMilestone(streak); + let prev = 0; + for (const m of BEACON_CHAIN_MILESTONES) { + if (streak >= m) prev = m; + } + if (next === null) { + return { current: streak, next: null, prev, progressPct: 100 }; + } + const span = next - prev; + const done = streak - prev; + const pct = span > 0 ? Math.round((done / span) * 100) : 100; + return { + current: streak, + next, + prev, + progressPct: Math.min(100, Math.max(0, pct)), + }; +} diff --git a/src/lib/game/config.ts b/src/lib/game/config.ts index 929bfaee2..7a7b8e4fc 100644 --- a/src/lib/game/config.ts +++ b/src/lib/game/config.ts @@ -197,7 +197,7 @@ export const TECH_TREE: TechNode[] = [ branch: "expedition", level: 2, name: "遗迹图谱", - desc: "探险力 +5,探险生命 +20", + desc: "探险力 +5,探险生命 +20,能量恢复 +30%", cost: 60, effect: { kind: "crystalCap", value: 200 }, }, @@ -206,7 +206,7 @@ export const TECH_TREE: TechNode[] = [ branch: "expedition", level: 3, name: "维度信标", - desc: "探险力 +8,接触进度转化率 +50%", + desc: "探险力 +8,接触进度转化率 +50%,能量恢复 +20%", cost: 180, effect: { kind: "contactRate", value: 0.5 }, }, diff --git a/src/lib/game/expedition.ts b/src/lib/game/expedition.ts index ae529f906..f08273286 100644 --- a/src/lib/game/expedition.ts +++ b/src/lib/game/expedition.ts @@ -35,10 +35,14 @@ export const EXPEDITION_CONFIG = { baseHp: 100, /** 探险力基础 */ basePower: 10, - /** 战斗:胜率 = clamp(power / (power + difficulty*8), 0.25, 0.95) */ - combatDifficultyScale: 8, - /** 能量恢复间隔(秒),每 interval 恢复 1 点 */ + /** 战斗:胜率 = clamp(power / (power + difficulty*scale), floor, 0.95) + * v0.8.1 平衡:scale 8→4,floor 0.25→0.35,让基础探险力也能有合理胜率 */ + combatDifficultyScale: 4, + combatWinRateFloor: 0.35, + /** 能量恢复基础间隔(秒),可被技术/属性缩短 */ energyRegenSec: 45, + /** 能量恢复最短间隔(秒,技术+属性全满时) */ + energyRegenMinSec: 12, }; /** 节点类型权重(boss 固定末位,其余按权重随机) */ @@ -147,7 +151,7 @@ export function generateExpedition( title: flavor.titles[fi], desc: flavor.descs[fi], cleared: false, - difficulty: isBoss ? 5 + Math.floor(rng() * 3) : 1 + Math.floor(rng() * 4), + difficulty: isBoss ? 3 + Math.floor(rng() * 3) : 1 + Math.floor(rng() * 4), position: i, }); } @@ -203,10 +207,13 @@ export function computeExpeditionHp(state: GameState): number { return hp; } -/** 战斗胜率 */ +/** 战斗胜率 + * v0.8.1 平衡:scale 4 + floor 0.35,让基础探险力 10 对 BOSS(diff 3-5) 有 33-45% 胜率, + * 配合技术/属性后可达 55-70%,告别"BOSS 必败"体验 */ export function combatWinRate(power: number, difficulty: number): number { const scale = EXPEDITION_CONFIG.combatDifficultyScale; - return Math.max(0.25, Math.min(0.95, power / (power + difficulty * scale))); + const floor = EXPEDITION_CONFIG.combatWinRateFloor; + return Math.max(floor, Math.min(0.95, power / (power + difficulty * scale))); } /** 结算当前节点(自动结算,返回结果与日志) */ @@ -358,14 +365,32 @@ export function advanceExpedition(expedition: Expedition): ExpeditionResult { return { log: `前进至节点 ${expedition.currentNode + 1}`, ended: false }; } -/** 计算能量恢复(基于时间) */ +/** 计算实际能量恢复间隔(秒),由技术树 + 角色属性缩短 + * v0.8.1:exp_2 解锁 -30%,exp_3 解锁 -20%,探索力属性 -最高30%,下限 12s */ +export function computeEnergyRegenInterval(state: GameState): number { + const base = EXPEDITION_CONFIG.energyRegenSec; + let mult = 1; + // 技术:遗迹图谱(exp_2)+ 维度信标(exp_3) + if (state.tech?.exp_2) mult *= 0.7; + if (state.tech?.exp_3) mult *= 0.8; + // 角色属性:探索力(exploration)每点缩短少量,高探索力最高 -30% + const exploration = state.attributes?.exploration ?? 0; + const explorationBonus = exploration >= 50 + ? 0.15 + (Math.min(100, exploration) - 50) * 0.003 // 50点15%,100点30% + : exploration * 0.003; + mult *= 1 - explorationBonus; + return Math.max(EXPEDITION_CONFIG.energyRegenMinSec, base * mult); +} + +/** 计算能量恢复(基于时间,支持动态间隔) */ export function computeEnergyRegen( lastTick: number, now: number, current: number, - max: number + max: number, + intervalSec: number = EXPEDITION_CONFIG.energyRegenSec ): { energy: number; lastTick: number } { - const interval = EXPEDITION_CONFIG.energyRegenSec * 1000; + const interval = intervalSec * 1000; const elapsed = now - lastTick; const gained = Math.floor(elapsed / interval); if (gained <= 0) return { energy: current, lastTick }; diff --git a/src/store/gameStore.ts b/src/store/gameStore.ts index b5bb7e62d..4436cd371 100644 --- a/src/store/gameStore.ts +++ b/src/store/gameStore.ts @@ -39,6 +39,7 @@ import { computeExpeditionPower, computeExpeditionHp, computeEnergyRegen, + computeEnergyRegenInterval, EXPEDITION_CONFIG, } from "@/lib/game/expedition"; import { ACHIEVEMENTS, type Achievement } from "@/lib/game/achievements"; @@ -61,10 +62,20 @@ import { } from "@/lib/game/chronicle"; import { generateDailyChallenge, + generateWeeklyChallenge, loadDailyProgress, + loadWeeklyProgress, + loadChainState, addBeaconProgress, + addWeeklyProgress, + recordChainCompletion, + claimWeeklyReward, + claimChainMilestone, + getTodayKey, type BeaconDailyChallenge, type BeaconDailyProgress, + type BeaconWeeklyChallenge, + type BeaconWeeklyProgress, } from "@/lib/game/beacon"; import { setPendingOfflineReport } from "@/lib/game/offlineReport"; import { @@ -129,6 +140,19 @@ interface GameActions { // 深空信标奖励发放(v0.5) grantBeaconReward: (insights: number, contact: number) => void; + // 深空信标 · 周挑战领取 + 信标链里程碑领取(v0.8) + claimWeeklyBeacon: () => { + rewardInsight: number; + rewardContact: number; + score: number; + }; + claimChainReward: (milestone: number) => { + rewardInsight: number; + rewardContact: number; + label: string; + ok: boolean; + }; + // 深空巡航奖励发放(v0.6 — 实时 Canvas 玩法) grantCruiseReward: (rewards: { crystals?: number; insights?: number; contact?: number }) => void; @@ -165,25 +189,61 @@ function syncStats(state: Partial) { } /** - * 深空信标进度追踪(v0.5)。 - * 在游戏关键动作(脉冲/解码/探险/BOSS/洞见)后调用,按今日挑战类型增量更新进度。 - * 进度独立存储于 localStorage(echo-nexus-beacon-prog-v1),不污染 GameState。 - * @returns 若刚完成则返回 true(供 UI 触发通知) + * 深空信标进度追踪(v0.5 → v0.8 扩展)。 + * 在游戏关键动作(脉冲/解码/探险/BOSS/洞见)后调用,同时更新: + * 1. 日挑战进度(按今日挑战类型增量) + * 2. 周挑战进度(按本周挑战类型增量) + * 3. 信标链:日挑战刚完成时记录一次完成(含 grace 续命逻辑) + * 进度独立存储于 localStorage,不污染 GameState。 + * @returns 三类状态变更供 UI 触发通知 */ function trackBeacon( type: "pulse" | "decode" | "expedition" | "boss" | "insight", delta: number -): boolean { - if (typeof window === "undefined") return false; +): { + dailyJustCompleted: boolean; + weeklyJustCompleted: boolean; + newChainMilestones: number[]; +} { + const result = { + dailyJustCompleted: false, + weeklyJustCompleted: false, + newChainMilestones: [] as number[], + }; + if (typeof window === "undefined") return result; try { + // ---- 日挑战 ---- const challenge: BeaconDailyChallenge = generateDailyChallenge(); - if (challenge.type !== type) return false; - const current: BeaconDailyProgress = loadDailyProgress(); - if (current.completedAt !== null) return false; // 已完成不再累加 - const { justCompleted } = addBeaconProgress(current, challenge, delta); - return justCompleted; + if (challenge.type === type) { + const current: BeaconDailyProgress = loadDailyProgress(); + if (current.completedAt === null) { + const { justCompleted } = addBeaconProgress(current, challenge, delta); + result.dailyJustCompleted = justCompleted; + // 日挑战刚完成 → 更新信标链 + if (justCompleted) { + const { newMilestones } = recordChainCompletion(getTodayKey()); + result.newChainMilestones = newMilestones; + } + } + } + + // ---- 周挑战 ---- + const wChallenge: BeaconWeeklyChallenge = generateWeeklyChallenge(); + if (wChallenge.type === type) { + const wCurrent: BeaconWeeklyProgress = loadWeeklyProgress(); + if (wCurrent.completedAt === null) { + const { justCompleted } = addWeeklyProgress( + wCurrent, + wChallenge, + delta + ); + result.weeklyJustCompleted = justCompleted; + } + } + + return result; } catch { - return false; + return result; } } @@ -403,11 +463,12 @@ export const useGameStore = create()( lastSpawn = now; } - // 能量恢复(探险系统) + // 能量恢复(探险系统,v0.8.1 动态间隔) let energy = s.energy; let lastEnergyTick = s.lastEnergyTick; if (energy < s.energyMax) { - const regen = computeEnergyRegen(lastEnergyTick, now, energy, s.energyMax); + const intervalSec = computeEnergyRegenInterval(s); + const regen = computeEnergyRegen(lastEnergyTick, now, energy, s.energyMax, intervalSec); energy = regen.energy; lastEnergyTick = regen.lastTick; } else { @@ -948,6 +1009,56 @@ export const useGameStore = create()( }); }, + // 深空信标:领取周挑战奖励(v0.8) + claimWeeklyBeacon: () => { + const challenge = generateWeeklyChallenge(); + const progress = loadWeeklyProgress(); + const res = claimWeeklyReward(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), + }); + } + return { + rewardInsight: res.rewardInsight, + rewardContact: res.rewardContact, + score: res.score, + }; + }, + + // 深空信标:领取信标链里程碑奖励(v0.8) + claimChainReward: (milestone) => { + // loadChainState 仅用于前置校验,真正的状态修改由 claimChainMilestone 完成 + const pre = loadChainState(); + if ( + pre.currentStreak < milestone || + pre.milestonesClaimed.includes(milestone) + ) { + return { + rewardInsight: 0, + rewardContact: 0, + label: "", + ok: false, + }; + } + const res = claimChainMilestone(milestone); + 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), + }); + } + return { + rewardInsight: res.rewardInsight, + rewardContact: res.rewardContact, + label: res.label, + ok: res.rewardInsight > 0 || res.rewardContact > 0, + }; + }, + // 深空巡航:发放实时玩法奖励(v0.6) grantCruiseReward: (rewards) => { const s = get(); diff --git a/tool-results/read_1782252803890_5147dd89b136.txt b/tool-results/read_1782252803890_5147dd89b136.txt new file mode 100644 index 000000000..6f88a077d --- /dev/null +++ b/tool-results/read_1782252803890_5147dd89b136.txt @@ -0,0 +1,1069 @@ + 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→ EXPEDITION_CONFIG, + 43→} from "@/lib/game/expedition"; + 44→import { ACHIEVEMENTS, type Achievement } from "@/lib/game/achievements"; + 45→import { + 46→ TIDE_CONFIG, + 47→ rollTide, + 48→ getTideModifiers, + 49→ computeSilenceCompensation, + 50→ type StarTide, + 51→ type TideType, + 52→} from "@/lib/game/starTide"; + 53→import { + 54→ getPerk, + 55→ constellationBonuses, + 56→ rollPerkChoices, + 57→} from "@/lib/game/constellation"; + 58→import { + 59→ migrateChronicleFields, + 60→ withPerks, + 61→} from "@/lib/game/chronicle"; + 62→import { + 63→ generateDailyChallenge, + 64→ loadDailyProgress, + 65→ addBeaconProgress, + 66→ type BeaconDailyChallenge, + 67→ type BeaconDailyProgress, + 68→} from "@/lib/game/beacon"; + 69→import { setPendingOfflineReport } from "@/lib/game/offlineReport"; + 70→import { + 71→ ATTRIBUTE_HARD_CAP, + 72→ migrateAttributes, + 73→ levelUpCheck, + 74→ getAllBonuses, + 75→ createInitialAttributes, + 76→ createInitialAttributeProgress, + 77→ type AttributeKey, + 78→ type CharacterAttributes, + 79→ type AttributeProgress, + 80→} from "@/lib/game/attributes"; + 81→ + 82→interface GameActions { + 83→ // 生命周期 + 84→ init: () => void; + 85→ loadOnline: () => void; + 86→ hardReset: () => void; + 87→ + 88→ // 主循环 + 89→ tick: (now: number) => void; + 90→ pulse: () => { gain: number; combo: number } | null; + 91→ + 92→ // 星潮 + 93→ tickTide: (now: number) => { started?: TideType; ended?: TideType; silenceCompensation?: number } | null; + 94→ consumeTideEvents: () => { started?: TideType; ended?: TideType; silenceCompensation?: number }[]; + 95→ + 96→ // 解码 + 97→ startDecode: (crystalId: string) => void; + 98→ clickNode: (nodeId: number) => { ok: boolean; finished: boolean; solvable?: boolean; failReason?: string }; + 99→ undoStep: () => void; + 100→ retryPuzzle: () => void; + 101→ abandonPuzzle: () => void; + 102→ /** 自动解码 T1(技术解锁后由 tick 调用) */ + 103→ autoDecodeTick: () => void; + 104→ + 105→ // 探险 + 106→ startExpedition: () => { ok: boolean; reason?: string }; + 107→ resolveCurrentNode: () => ExpeditionResult | null; + 108→ advanceNode: () => void; + 109→ abortExpedition: () => void; + 110→ + 111→ // 技术 + 112→ buyTech: (techId: string) => boolean; + 113→ + 114→ // 飞升 + 115→ doPrestige: () => { newBp: number } | null; + 116→ + 117→ // 星图天文台 + 118→ chooseConstellationPerk: (perkId: string) => boolean; + 119→ rerollPerkChoices: () => void; + 120→ + 121→ // 成就 + 122→ checkAchievements: () => Achievement[]; + 123→ consumeAchievementQueue: () => Achievement[]; + 124→ + 125→ // 设置 + 126→ toggleTheme: () => void; + 127→ toggleSound: () => void; + 128→ + 129→ // 深空信标奖励发放(v0.5) + 130→ grantBeaconReward: (insights: number, contact: number) => void; + 131→ + 132→ // 深空巡航奖励发放(v0.6 — 实时 Canvas 玩法) + 133→ grantCruiseReward: (rewards: { crystals?: number; insights?: number; contact?: number }) => void; + 134→ + 135→ // 角色属性(v0.7 P1) + 136→ allocateAttribute: (attr: AttributeKey, points?: number) => { ok: boolean; leveledUp?: number }; + 137→ gainAttributeExp: (attr: AttributeKey, amount: number) => { leveledUp: number; newLevel: number }; + 138→ + 139→ // 派生 + 140→ canPrestige: () => boolean; + 141→} + 142→ + 143→type Store = GameState & GameActions & { + 144→ _lastAutoDecode: number; + 145→ _lastSpawn: number; + 146→ _combo: number; + 147→ _lastPulse: number; + 148→ _achievementQueue: Achievement[]; + 149→ _tideEvents: { started?: TideType; ended?: TideType; silenceCompensation?: number }[]; + 150→}; + 151→ + 152→/** 计算并写回产能字段 */ + 153→function syncStats(state: Partial) { + 154→ const s = recomputeStats(state); + 155→ return { + 156→ crystalsPerSec: s.crystalsPerSec, + 157→ crystalCap: s.crystalCap, + 158→ pulsePower: s.pulsePower, + 159→ offlineEff: s.offlineEff, + 160→ insightMult: s.insightMult, + 161→ contactRateMult: s.contactRateMult, + 162→ autoDecode: s.autoDecode, + 163→ decodeStepsBonus: s.decodeStepsBonus, + 164→ }; + 165→} + 166→ + 167→/** + 168→ * 深空信标进度追踪(v0.5)。 + 169→ * 在游戏关键动作(脉冲/解码/探险/BOSS/洞见)后调用,按今日挑战类型增量更新进度。 + 170→ * 进度独立存储于 localStorage(echo-nexus-beacon-prog-v1),不污染 GameState。 + 171→ * @returns 若刚完成则返回 true(供 UI 触发通知) + 172→ */ + 173→function trackBeacon( + 174→ type: "pulse" | "decode" | "expedition" | "boss" | "insight", + 175→ delta: number + 176→): boolean { + 177→ if (typeof window === "undefined") return false; + 178→ try { + 179→ const challenge: BeaconDailyChallenge = generateDailyChallenge(); + 180→ if (challenge.type !== type) return false; + 181→ const current: BeaconDailyProgress = loadDailyProgress(); + 182→ if (current.completedAt !== null) return false; // 已完成不再累加 + 183→ const { justCompleted } = addBeaconProgress(current, challenge, delta); + 184→ return justCompleted; + 185→ } catch { + 186→ return false; + 187→ } + 188→} + 189→ + 190→/** 检查并解锁叙事碎片 */ + 191→function checkFragments(state: GameState): string[] { + 192→ const unlocked: string[] = []; + 193→ for (const f of FRAGMENTS) { + 194→ if (!state.fragments[f.id] && state.totalDecoded >= f.threshold) { + 195→ state.fragments[f.id] = true; + 196→ unlocked.push(f.id); + 197→ } + 198→ } + 199→ return unlocked; + 200→} + 201→ + 202→export const useGameStore = create()( + 203→ persist( + 204→ (set, get) => ({ + 205→ ...createInitialState(), + 206→ _lastAutoDecode: Date.now(), + 207→ _lastSpawn: Date.now(), + 208→ _combo: 0, + 209→ _lastPulse: 0, + 210→ _achievementQueue: [], + 211→ _tideEvents: [], + 212→ + 213→ init: () => { + 214→ const s = get(); + 215→ const now = Date.now(); + 216→ // 兼容旧存档:若存在不可解的活跃谜题(旧算法生成),直接清掉 + 217→ let activePuzzle = s.activePuzzle; + 218→ if (activePuzzle && !isSolvable(activePuzzle)) { + 219→ // 把晶体放回队列,避免玩家卡死 + 220→ const crystal: Crystal = { + 221→ id: `c_${now}_rec`, + 222→ tier: activePuzzle.tier, + 223→ value: CRYSTAL_VALUE[activePuzzle.tier].crystals, + 224→ createdAt: now, + 225→ }; + 226→ activePuzzle = null; + 227→ set({ + 228→ pendingCrystals: [...s.pendingCrystals, crystal].slice(-CRYSTAL_SPAWN.maxPending), + 229→ }); + 230→ } + 231→ // 兼容旧存档:补全 achievements / 星潮 / 星图 字段 + 232→ const achievements = s.achievements ?? {}; + 233→ const activeTide = s.activeTide ?? null; + 234→ const constellation = s.constellation ?? []; + 235→ const pendingPerkChoices = s.pendingPerkChoices ?? null; + 236→ // v0.4 编年史兼容:补全 chronicle / runStart / bossKills / starTidesEncountered + 237→ const migrated = migrateChronicleFields(s); + 238→ // v0.7 角色属性兼容:补全 attributes / attributeProgress / pendingAttrPoints + 239→ const attrMigrated = migrateAttributes(s); + 240→ // 星图「能量共振」天赋 +1 能量上限 + 241→ const cm = constellationBonuses(constellation); + 242→ const energyMax = (INITIAL_STATE.energyMax) + cm.energyMaxBonus; + 243→ // lastTideEnd:0 表示从未触发过星潮(首次游玩),保留 0 让 firstDelay 生效 + 244→ const lastTideEndRaw = s.lastTideEnd ?? 0; + 245→ // 若旧存档有已过期的星潮,清掉 + 246→ const tide = activeTide && activeTide.endsAt > now ? activeTide : null; + 247→ // 首次进入:补发离线收益 + 248→ const elapsed = Math.max(0, (now - s.lastTick) / 1000); + 249→ if (elapsed > 5) { + 250→ const cap = 8 * 3600; + 251→ const secs = Math.min(elapsed, cap); + 252→ const gain = s.crystalsPerSec * secs * s.offlineEff; + 253→ const crystalsBefore = s.crystals; + 254→ const crystalsAfter = Math.min(s.crystalCap, s.crystals + gain); + 255→ // 设置离线报告(v0.5.2):通知 UI 弹出"欢迎回来"对话框 + 256→ setPendingOfflineReport({ + 257→ elapsedSec: secs, + 258→ rawElapsedSec: elapsed, + 259→ gain: crystalsAfter - crystalsBefore, + 260→ rate: s.crystalsPerSec, + 261→ eff: s.offlineEff, + 262→ capped: elapsed > cap, + 263→ crystalsBefore, + 264→ crystalsAfter, + 265→ crystalCap: s.crystalCap, + 266→ }); + 267→ set({ + 268→ crystals: crystalsAfter, + 269→ lastTick: now, + 270→ activePuzzle, + 271→ achievements, + 272→ activeTide: tide, + 273→ lastTideEnd: tide ? lastTideEndRaw : lastTideEndRaw, + 274→ constellation, + 275→ pendingPerkChoices, + 276→ energyMax, + 277→ chronicle: migrated.chronicle, + 278→ runStart: migrated.runStart, + 279→ bossKills: migrated.bossKills, + 280→ starTidesEncountered: migrated.starTidesEncountered, + 281→ attributes: attrMigrated.attributes, + 282→ attributeProgress: attrMigrated.attributeProgress, + 283→ pendingAttrPoints: attrMigrated.pendingAttrPoints, + 284→ ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements, activeTide: tide, constellation, attributes: attrMigrated.attributes }), + 285→ }); + 286→ } else { + 287→ 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 }) }); + 288→ } + 289→ }, + 290→ + 291→ loadOnline: () => { + 292→ const s = get(); + 293→ set({ ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation, attributes: s.attributes }) }); + 294→ }, + 295→ + 296→ hardReset: () => { + 297→ set({ ...createInitialState(), _lastAutoDecode: Date.now(), _lastSpawn: Date.now(), _combo: 0, _lastPulse: 0, _achievementQueue: [], _tideEvents: [] }); + 298→ }, + 299→ + 300→ tickTide: (now) => { + 301→ const s = get(); + 302→ const tide = s.activeTide; + 303→ // 星图「星潮引导」减少间隙 + 304→ const cm = constellationBonuses(s.constellation ?? []); + 305→ // v0.7 灵感:星潮触发概率 +X%(缩短间隙) + 306→ const am = getAllBonuses(s.attributes ?? {}); + 307→ const tideGapReduction = Math.min(0.3, am.tideTriggerBonus); + 308→ const gap = Math.max(15000, (TIDE_CONFIG.gap + cm.tideGapDeltaSec * 1000) * (1 - tideGapReduction)); + 309→ // 1) 检查当前星潮是否结束 + 310→ if (tide && now >= tide.endsAt) { + 311→ const endedType = tide.type; + 312→ // 寂静期补偿洞见 + 313→ let silenceCompensation = 0; + 314→ if (tide.type === "silence") { + 315→ silenceCompensation = computeSilenceCompensation(tide); + 316→ } + 317→ const newInsights = s.insights + silenceCompensation; + 318→ const event: { started?: TideType; ended?: TideType; silenceCompensation?: number } = { ended: endedType }; + 319→ if (silenceCompensation > 0) event.silenceCompensation = silenceCompensation; + 320→ set({ + 321→ activeTide: null, + 322→ lastTideEnd: now, + 323→ insights: newInsights, + 324→ // 星潮结束后重算 stats(移除 contactRate/insight 修饰) + 325→ ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: null, constellation: s.constellation, attributes: s.attributes }), + 326→ _tideEvents: [...s._tideEvents, event], + 327→ }); + 328→ return event; + 329→ } + 330→ // 2) 检查是否该触发新星潮(间隙已过) + 331→ if (!tide) { + 332→ const since = now - s.lastTideEnd; + 333→ // 首次:以 createdAt 起算 firstDelay;之后以 lastTideEnd 起算 gap + 334→ const firstStart = s.lastTideEnd === 0 || s.lastTideEnd <= s.createdAt + 1000; + 335→ const need = firstStart ? TIDE_CONFIG.firstDelay : gap; + 336→ if (since >= need) { + 337→ const type = rollTide(); + 338→ const newTide: StarTide = { + 339→ id: `tide_${now}_${Math.random().toString(36).slice(2, 7)}`, + 340→ type, + 341→ startedAt: now, + 342→ endsAt: now + TIDE_CONFIG.duration, + 343→ }; + 344→ const event: { started?: TideType; ended?: TideType; silenceCompensation?: number } = { started: type }; + 345→ // v0.4 编年史:累计遇到的星潮 ID(去重) + 346→ const tidesAll = s.starTidesEncountered ?? []; + 347→ const tideId = `tide_${type}`; + 348→ const newTidesAll = tidesAll.includes(tideId) ? tidesAll : [...tidesAll, tideId]; + 349→ set({ + 350→ activeTide: newTide, + 351→ starTidesEncountered: newTidesAll, + 352→ // 星潮开始后重算 stats(应用 contactRate/insight 修饰) + 353→ ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: newTide, constellation: s.constellation, attributes: s.attributes }), + 354→ _tideEvents: [...s._tideEvents, event], + 355→ }); + 356→ return event; + 357→ } + 358→ } + 359→ return null; + 360→ }, + 361→ + 362→ consumeTideEvents: () => { + 363→ const s = get(); + 364→ if (s._tideEvents.length === 0) return []; + 365→ const items = s._tideEvents; + 366→ set({ _tideEvents: [] }); + 367→ return items; + 368→ }, + 369→ + 370→ tick: (now) => { + 371→ const s = get(); + 372→ const dt = Math.max(0, (now - s.lastTick) / 1000); + 373→ if (dt <= 0) return; + 374→ + 375→ // 星潮产能修饰(即时乘) + 376→ const tideMod = getTideModifiers(s.activeTide); + 377→ const effCps = s.crystalsPerSec * tideMod.crystalsPerSecMult; + 378→ // 产能累加(仅闲置产能受仓库上限;探险奖励可超出) + 379→ const newCrystals = + 380→ s.crystals >= s.crystalCap + 381→ ? s.crystals // 已达/超上限,不再自动产出 + 382→ : Math.min(s.crystalCap, s.crystals + effCps * dt); + 383→ + 384→ // 自动产出待解码晶体(每 baseInterval 秒一颗,受技术/飞升略提速) + 385→ const bpBoost = 1 + s.blueprints.length * 0.03; + 386→ const spawnInterval = (CRYSTAL_SPAWN.baseIntervalSec / bpBoost) * 1000; + 387→ let pending = s.pendingCrystals; + 388→ let lastSpawn = s._lastSpawn; + 389→ if ( + 390→ now - lastSpawn > spawnInterval && + 391→ pending.length < CRYSTAL_SPAWN.maxPending + 392→ ) { + 393→ // 星图「晶体富集」提升 T2/T3 概率 + 394→ const cm = constellationBonuses(s.constellation ?? []); + 395→ const tier = rollCrystalTierWithBonus(cm.t2t3BonusRate); + 396→ const crystal: Crystal = { + 397→ id: `c_${now}_${Math.random().toString(36).slice(2, 7)}`, + 398→ tier, + 399→ value: CRYSTAL_VALUE[tier].crystals, + 400→ createdAt: now, + 401→ }; + 402→ pending = [...pending, crystal]; + 403→ lastSpawn = now; + 404→ } + 405→ + 406→ // 能量恢复(探险系统) + 407→ let energy = s.energy; + 408→ let lastEnergyTick = s.lastEnergyTick; + 409→ if (energy < s.energyMax) { + 410→ const regen = computeEnergyRegen(lastEnergyTick, now, energy, s.energyMax); + 411→ energy = regen.energy; + 412→ lastEnergyTick = regen.lastTick; + 413→ } else { + 414→ lastEnergyTick = now; + 415→ } + 416→ + 417→ set({ + 418→ crystals: newCrystals, + 419→ lastTick: now, + 420→ pendingCrystals: pending, + 421→ _lastSpawn: lastSpawn, + 422→ energy, + 423→ lastEnergyTick, + 424→ }); + 425→ }, + 426→ + 427→ pulse: () => { + 428→ const s = get(); + 429→ const now = Date.now(); + 430→ // 连击 + 431→ let combo = 1; + 432→ if (now - s._lastPulse < 1500) { + 433→ combo = Math.min(10, s._combo + 1); + 434→ } + 435→ const mult = 1 + (combo - 1) * 0.15; + 436→ // 星潮脉冲威力修饰 + 437→ const tideMod = getTideModifiers(s.activeTide); + 438→ // v0.7 灵感:脉冲连击加成 +X% + 439→ const am = getAllBonuses(s.attributes ?? {}); + 440→ const comboBonusMult = 1 + am.pulseComboBonus * Math.max(0, combo - 1); + 441→ const gain = s.pulsePower * mult * tideMod.pulsePowerMult * comboBonusMult; + 442→ set({ + 443→ crystals: Math.min(s.crystalCap, s.crystals + gain), + 444→ _combo: combo, + 445→ _lastPulse: now, + 446→ }); + 447→ // 深空信标:脉冲任务进度 +1 + 448→ trackBeacon("pulse", 1); + 449→ // v0.7 角色属性:连击 ≥3 给灵感经验 + 450→ if (combo >= 3) { + 451→ const expGain = 1 + Math.floor(combo / 2); // 3 连击=2, 5 连击=3, 10 连击=6 + 452→ // 内联经验获取(避免递归调用 set) + 453→ const prog = s.attributeProgress?.inspiration ?? { exp: 0, level: s.attributes?.inspiration ?? 0 }; + 454→ const nextExp = prog.exp + expGain; + 455→ const lvlResult = levelUpCheck( + 456→ { exp: nextExp, level: s.attributes?.inspiration ?? 0 }, + 457→ ATTRIBUTE_HARD_CAP + 458→ ); + 459→ const newAttributes: CharacterAttributes = { + 460→ ...(s.attributes ?? createInitialAttributes()), + 461→ inspiration: lvlResult.newProgress.level, + 462→ }; + 463→ const newProgress: AttributeProgress = { + 464→ ...(s.attributeProgress ?? createInitialAttributeProgress()), + 465→ inspiration: lvlResult.newProgress, + 466→ }; + 467→ set({ + 468→ attributes: newAttributes, + 469→ attributeProgress: newProgress, + 470→ ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation, attributes: newAttributes }), + 471→ }); + 472→ } + 473→ return { gain, combo }; + 474→ }, + 475→ + 476→ startDecode: (crystalId) => { + 477→ const s = get(); + 478→ const crystal = s.pendingCrystals.find((c) => c.id === crystalId); + 479→ if (!crystal) return; + 480→ const puzzle = generatePuzzle(crystal.tier); + 481→ set({ + 482→ activePuzzle: puzzle, + 483→ pendingCrystals: s.pendingCrystals.filter((c) => c.id !== crystalId), + 484→ }); + 485→ }, + 486→ + 487→ clickNode: (nodeId) => { + 488→ const s = get(); + 489→ if (!s.activePuzzle) return { ok: false, finished: false, failReason: "无活跃谜题" }; + 490→ // 深拷贝谜题 + 491→ const puzzle: DecodePuzzle = JSON.parse(JSON.stringify(s.activePuzzle)); + 492→ const res = tryClickNode(puzzle, nodeId); + 493→ if (res.ok) { + 494→ if (res.finished) { + 495→ // 结算奖励(星潮解码奖励修饰 + 星图「二周目经验」) + 496→ const tideMod = getTideModifiers(s.activeTide); + 497→ const cm = constellationBonuses(s.constellation ?? []); + 498→ const base = decodeRewards(puzzle.tier, s.insightMult, s.contactRateMult); + 499→ const finalMult = tideMod.decodeRewardMult * cm.decodeRewardMult; + 500→ const rewards = { + 501→ crystals: Math.round(base.crystals * finalMult), + 502→ insights: Math.round(base.insights * finalMult), + 503→ contact: +(base.contact * finalMult).toFixed(2), + 504→ }; + 505→ const newTotal = s.totalDecoded + 1; + 506→ const newContact = Math.min(100, s.contact + rewards.contact); + 507→ const newInsights = s.insights + rewards.insights; + 508→ const newCrystals = s.crystals + rewards.crystals; + 509→ // 解锁碎片 + 510→ const tentative: GameState = { ...s, totalDecoded: newTotal, fragments: { ...s.fragments } }; + 511→ const unlocked = checkFragments(tentative); + 512→ // v0.7 角色属性:完成解码给智慧经验(tier 越高经验越多) + 513→ const wisdomExpGain = puzzle.tier * 2; + 514→ const curAttrs = s.attributes ?? createInitialAttributes(); + 515→ const curProg = s.attributeProgress ?? createInitialAttributeProgress(); + 516→ const progEntry = curProg.wisdom ?? { exp: 0, level: curAttrs.wisdom }; + 517→ const wisdomLvl = levelUpCheck( + 518→ { exp: progEntry.exp + wisdomExpGain, level: curAttrs.wisdom }, + 519→ ATTRIBUTE_HARD_CAP + 520→ ); + 521→ const newAttributes: CharacterAttributes = { + 522→ ...curAttrs, + 523→ wisdom: wisdomLvl.newProgress.level, + 524→ }; + 525→ const newProgress: AttributeProgress = { + 526→ ...curProg, + 527→ wisdom: wisdomLvl.newProgress, + 528→ }; + 529→ set({ + 530→ activePuzzle: null, + 531→ crystals: newCrystals, + 532→ insights: newInsights, + 533→ contact: newContact, + 534→ totalDecoded: newTotal, + 535→ fragments: tentative.fragments, + 536→ attributes: newAttributes, + 537→ attributeProgress: newProgress, + 538→ ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation, attributes: newAttributes }), + 539→ }); + 540→ // 深空信标:解码 +1,洞见累计 + 541→ trackBeacon("decode", 1); + 542→ trackBeacon("insight", rewards.insights); + 543→ return { ok: true, finished: true, failReason: unlocked.join(",") || undefined }; + 544→ } + 545→ // 点击成功但未完成:检测当前局面是否仍可解 + 546→ const solvable = isSolvable(puzzle); + 547→ set({ activePuzzle: puzzle }); + 548→ return { ok: true, finished: false, solvable }; + 549→ } + 550→ return res; + 551→ }, + 552→ + 553→ undoStep: () => { + 554→ const s = get(); + 555→ if (!s.activePuzzle || s.activePuzzle.path.length === 0) return; + 556→ const puzzle: DecodePuzzle = JSON.parse(JSON.stringify(s.activePuzzle)); + 557→ const lastId = puzzle.path.pop(); + 558→ if (lastId !== undefined) { + 559→ const node = puzzle.grid.find((n) => n.id === lastId); + 560→ if (node) node.used = false; + 561→ } + 562→ set({ activePuzzle: puzzle }); + 563→ }, + 564→ + 565→ retryPuzzle: () => { + 566→ const s = get(); + 567→ if (!s.activePuzzle) return; + 568→ set({ activePuzzle: resetPuz(s.activePuzzle) }); + 569→ }, + 570→ + 571→ abandonPuzzle: () => { + 572→ const s = get(); + 573→ if (!s.activePuzzle) return; + 574→ // 晶体放回队列末尾 + 575→ const crystal: Crystal = { + 576→ id: `c_${Date.now()}_ret`, + 577→ tier: s.activePuzzle.tier, + 578→ value: CRYSTAL_VALUE[s.activePuzzle.tier].crystals, + 579→ createdAt: Date.now(), + 580→ }; + 581→ set({ + 582→ activePuzzle: null, + 583→ pendingCrystals: [...s.pendingCrystals, crystal].slice(-CRYSTAL_SPAWN.maxPending), + 584→ }); + 585→ }, + 586→ + 587→ autoDecodeTick: () => { + 588→ const s = get(); + 589→ if (!s.autoDecode) return; + 590→ const now = Date.now(); + 591→ // 星图「自动校准」减少自动解码周期 + 592→ const cm = constellationBonuses(s.constellation ?? []); + 593→ // v0.7 智慧:自动解码周期 -X% + 594→ const am = getAllBonuses(s.attributes ?? {}); + 595→ const baseInterval = 12000 + cm.autoDecodeIntervalDeltaSec * 1000; + 596→ const interval = Math.max(5000, baseInterval * am.autoDecodeIntervalMult); + 597→ if (now - s._lastAutoDecode < interval) return; + 598→ // 找一颗 T1 晶体自动解码 + 599→ const idx = s.pendingCrystals.findIndex((c) => c.tier === 1); + 600→ if (idx < 0) return; + 601→ const crystal = s.pendingCrystals[idx]; + 602→ const tideMod = getTideModifiers(s.activeTide); + 603→ const base = decodeRewards(1, s.insightMult, s.contactRateMult); + 604→ const finalMult = tideMod.decodeRewardMult * cm.decodeRewardMult; + 605→ const rewards = { + 606→ crystals: Math.round(base.crystals * finalMult), + 607→ insights: Math.round(base.insights * finalMult), + 608→ contact: +(base.contact * finalMult).toFixed(2), + 609→ }; + 610→ const newTotal = s.totalDecoded + 1; + 611→ const tentative: GameState = { ...s, totalDecoded: newTotal, fragments: { ...s.fragments } }; + 612→ checkFragments(tentative); + 613→ // v0.7 角色属性:自动解码给智慧经验(少量) + 614→ const curAttrs = s.attributes ?? createInitialAttributes(); + 615→ const curProg = s.attributeProgress ?? createInitialAttributeProgress(); + 616→ const progEntry = curProg.wisdom ?? { exp: 0, level: curAttrs.wisdom }; + 617→ const wisdomLvl = levelUpCheck( + 618→ { exp: progEntry.exp + 1, level: curAttrs.wisdom }, + 619→ ATTRIBUTE_HARD_CAP + 620→ ); + 621→ const newAttributes: CharacterAttributes = { + 622→ ...curAttrs, + 623→ wisdom: wisdomLvl.newProgress.level, + 624→ }; + 625→ const newProgress: AttributeProgress = { + 626→ ...curProg, + 627→ wisdom: wisdomLvl.newProgress, + 628→ }; + 629→ set({ + 630→ pendingCrystals: s.pendingCrystals.filter((c) => c.id !== crystal.id), + 631→ crystals: s.crystals + rewards.crystals, + 632→ insights: s.insights + rewards.insights, + 633→ contact: Math.min(100, s.contact + rewards.contact), + 634→ totalDecoded: newTotal, + 635→ fragments: tentative.fragments, + 636→ _lastAutoDecode: now, + 637→ attributes: newAttributes, + 638→ attributeProgress: newProgress, + 639→ ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation, attributes: newAttributes }), + 640→ }); + 641→ // 深空信标:自动解码也算进度 + 642→ trackBeacon("decode", 1); + 643→ trackBeacon("insight", rewards.insights); + 644→ }, + 645→ + 646→ buyTech: (techId) => { + 647→ const s = get(); + 648→ const node = TECH_TREE.find((t) => t.id === techId); + 649→ if (!node) return false; + 650→ const cur = s.tech[techId] ?? 0; + 651→ if (cur >= 1) return false; // v0.1 每节点 1 级 + 652→ if (s.insights < node.cost) return false; + 653→ const newTech = { ...s.tech, [techId]: 1 }; + 654→ set({ + 655→ insights: s.insights - node.cost, + 656→ tech: newTech, + 657→ ...syncStats({ tech: newTech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation, attributes: s.attributes }), + 658→ }); + 659→ return true; + 660→ }, + 661→ + 662→ // ============ 探险系统 ============ + 663→ startExpedition: () => { + 664→ const s = get(); + 665→ if (s.activeExpedition && !s.activeExpedition.finished) { + 666→ return { ok: false, reason: "已有进行中的探险" }; + 667→ } + 668→ if (s.energy < EXPEDITION_CONFIG.energyCost) { + 669→ return { ok: false, reason: "能量不足" }; + 670→ } + 671→ const tideMod = getTideModifiers(s.activeTide); + 672→ // v0.7 角色属性:探索力 +X% 探险力,勇气 +X 探险生命 + 673→ const am = getAllBonuses(s.attributes ?? {}); + 674→ const basePower = computeExpeditionPower(s) + tideMod.expeditionPowerBonus; + 675→ const baseHp = computeExpeditionHp(s) + tideMod.expeditionHpBonus; + 676→ const power = Math.round(basePower * am.expeditionPowerMult); + 677→ const hp = baseHp + am.expeditionHpBonus; + 678→ const seed = Math.floor(Math.random() * 1e9); + 679→ const expedition = generateExpedition(seed, power, hp); + 680→ set({ + 681→ activeExpedition: expedition, + 682→ energy: s.energy - EXPEDITION_CONFIG.energyCost, + 683→ totalExpeditions: s.totalExpeditions + 1, + 684→ }); + 685→ return { ok: true }; + 686→ }, + 687→ + 688→ resolveCurrentNode: () => { + 689→ const s = get(); + 690→ if (!s.activeExpedition || s.activeExpedition.finished) return null; + 691→ // 深拷贝 + 692→ const exp = JSON.parse(JSON.stringify(s.activeExpedition)); + 693→ // v0.7 勇气:BOSS 战胜率 +X%(动态提高 RNG 阈值) + 694→ const nodeBefore = exp.nodes[exp.currentNode]; + 695→ const isBossNode = nodeBefore?.type === "boss"; + 696→ const am = getAllBonuses(s.attributes ?? {}); + 697→ const result = isBossNode + 698→ ? resolveNode(exp, () => { + 699→ // 单次 rng() 调用:B% 概率返回 0(必胜),其余情况返回 r-B(保持均匀分布) + 700→ const r = Math.random(); + 701→ const b = Math.min(0.95, am.bossWinRateBonus); + 702→ return r < b ? 0 : Math.min(1, r - b); + 703→ }) + 704→ : resolveNode(exp); + 705→ // 累计奖励 + 706→ if (result.crystals) exp.rewards.crystals += result.crystals; + 707→ if (result.insights) exp.rewards.insights += result.insights; + 708→ if (result.contact) exp.rewards.contact += result.contact; + 709→ if (result.fragments) exp.rewards.fragments.push(...result.fragments); + 710→ if (result.hpDelta) exp.hp = Math.max(0, Math.min(exp.maxHp, exp.hp + result.hpDelta)); + 711→ // 实时入账(玩家立即获得) + 712→ const newCrystals = s.crystals + (result.crystals || 0); + 713→ const newInsights = s.insights + (result.insights || 0); + 714→ const newContact = Math.min(100, s.contact + (result.contact || 0)); + 715→ // 碎片解锁 + 716→ const newFragments = { ...s.fragments }; + 717→ if (result.fragments) { + 718→ for (const fid of result.fragments) newFragments[fid] = true; + 719→ } + 720→ // 日志 + 721→ const logEntry = { + 722→ expeditionId: exp.id, + 723→ nodeType: nodeBefore?.type || "combat", + 724→ result: result.log, + 725→ rewards: [ + 726→ result.crystals ? `+${result.crystals}晶体` : "", + 727→ result.insights ? `+${result.insights}洞见` : "", + 728→ result.contact ? `+${result.contact.toFixed(1)}接触` : "", + 729→ result.hpDelta ? `${result.hpDelta > 0 ? "+" : ""}${result.hpDelta}生命` : "", + 730→ ].filter(Boolean).join(" "), + 731→ timestamp: Date.now(), + 732→ }; + 733→ const newLog = [logEntry, ...s.expeditionLog].slice(0, 30); + 734→ + 735→ if (result.ended) { + 736→ // 探险结束(胜利或失败) + 737→ exp.finished = true; + 738→ } + 739→ + 740→ // v0.4 编年史:击破 BOSS 时累计计数 + 741→ let bossKills = s.bossKills ?? 0; + 742→ let bossKilledThisNode = false; + 743→ if ( + 744→ result.ended && + 745→ result.endReason === "victory" && + 746→ nodeBefore?.type === "boss" + 747→ ) { + 748→ bossKills = bossKills + 1; + 749→ bossKilledThisNode = true; + 750→ } + 751→ + 752→ // v0.7 角色属性:战斗胜利给勇气+探索力经验;BOSS 额外奖励 + 753→ let newAttributes = s.attributes ?? createInitialAttributes(); + 754→ let newProgress = s.attributeProgress ?? createInitialAttributeProgress(); + 755→ let statsNeedResync = false; + 756→ // 战斗类节点(combat/boss)且胜利:勇气 + 探索力经验 + 757→ const isCombatVictory = + 758→ (nodeBefore?.type === "combat" || nodeBefore?.type === "boss") && + 759→ !result.ended; // 中途战斗胜利(未结束探险) + 760→ const isExpeditionVictory = + 761→ result.ended && result.endReason === "victory"; + 762→ if (isCombatVictory || bossKilledThisNode || isExpeditionVictory) { + 763→ const courageGain = bossKilledThisNode ? 8 : 2; + 764→ const explorationGain = bossKilledThisNode ? 6 : isExpeditionVictory ? 4 : 1; + 765→ const courageLvl = levelUpCheck( + 766→ { exp: newProgress.courage.exp + courageGain, level: newAttributes.courage }, + 767→ ATTRIBUTE_HARD_CAP + 768→ ); + 769→ const explLvl = levelUpCheck( + 770→ { exp: newProgress.exploration.exp + explorationGain, level: newAttributes.exploration }, + 771→ ATTRIBUTE_HARD_CAP + 772→ ); + 773→ newAttributes = { + 774→ ...newAttributes, + 775→ courage: courageLvl.newProgress.level, + 776→ exploration: explLvl.newProgress.level, + 777→ }; + 778→ newProgress = { + 779→ ...newProgress, + 780→ courage: courageLvl.newProgress, + 781→ exploration: explLvl.newProgress, + 782→ }; + 783→ statsNeedResync = true; + 784→ } + 785→ + 786→ set({ + 787→ activeExpedition: exp, + 788→ crystals: newCrystals, + 789→ insights: newInsights, + 790→ contact: newContact, + 791→ fragments: newFragments, + 792→ expeditionLog: newLog, + 793→ bossKills, + 794→ attributes: newAttributes, + 795→ attributeProgress: newProgress, + 796→ ...(statsNeedResync + 797→ ? syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation, attributes: newAttributes }) + 798→ : {}), + 799→ }); + 800→ // 深空信标:探险完成(胜负都计)+ 洞见累计 + BOSS 击破 + 801→ if (result.ended) { + 802→ trackBeacon("expedition", 1); + 803→ if (bossKilledThisNode) { + 804→ trackBeacon("boss", 1); + 805→ } + 806→ } + 807→ if (result.insights) trackBeacon("insight", result.insights); + 808→ return result; + 809→ }, + 810→ + 811→ advanceNode: () => { + 812→ const s = get(); + 813→ if (!s.activeExpedition || s.activeExpedition.finished) return; + 814→ const exp = JSON.parse(JSON.stringify(s.activeExpedition)); + 815→ const node = exp.nodes[exp.currentNode]; + 816→ if (!node || !node.cleared) return; // 当前节点未结算不能前进 + 817→ if (exp.currentNode >= exp.nodes.length - 1) return; + 818→ exp.currentNode++; + 819→ set({ activeExpedition: exp }); + 820→ }, + 821→ + 822→ abortExpedition: () => { + 823→ const s = get(); + 824→ if (!s.activeExpedition) return; + 825→ const exp = JSON.parse(JSON.stringify(s.activeExpedition)); + 826→ exp.finished = true; + 827→ const logEntry = { + 828→ expeditionId: exp.id, + 829→ nodeType: "rest" as const, + 830→ result: "探险队主动撤退,保留已获奖励。", + 831→ rewards: "", + 832→ timestamp: Date.now(), + 833→ }; + 834→ set({ + 835→ activeExpedition: exp, + 836→ expeditionLog: [logEntry, ...s.expeditionLog].slice(0, 30), + 837→ }); + 838→ }, + 839→ + 840→ doPrestige: () => { + 841→ const s = get(); + 842→ if (s.contact < CONTACT.prestigeMin) return null; + 843→ const newBp = computeNewBlueprints(s); + 844→ const next = performPrestige(s); + 845→ // 星图「能量共振」提升上限 + 846→ const cm = constellationBonuses(next.constellation ?? []); + 847→ const energyMax = INITIAL_STATE.energyMax + cm.energyMaxBonus; + 848→ set({ + 849→ ...next, + 850→ energyMax, + 851→ ...syncStats({ tech: next.tech, blueprints: next.blueprints, achievements: next.achievements, activeTide: next.activeTide, constellation: next.constellation, attributes: next.attributes }), + 852→ _lastAutoDecode: Date.now(), + 853→ _lastSpawn: Date.now(), + 854→ _combo: 0, + 855→ _lastPulse: 0, + 856→ _tideEvents: [], + 857→ }); + 858→ return { newBp }; + 859→ }, + 860→ + 861→ chooseConstellationPerk: (perkId) => { + 862→ const s = get(); + 863→ if (!s.pendingPerkChoices || !s.pendingPerkChoices.includes(perkId)) return false; + 864→ const perk = getPerk(perkId); + 865→ if (!perk) return false; + 866→ if (s.constellation?.includes(perkId)) return false; + 867→ const newConstellation = [...(s.constellation ?? []), perkId]; + 868→ const cm = constellationBonuses(newConstellation); + 869→ const energyMax = INITIAL_STATE.energyMax + cm.energyMaxBonus; + 870→ // v0.4 编年史:回填最近一条 entry 的 perksThisAscension + 871→ const chronicle = s.chronicle ?? []; + 872→ let newChronicle = chronicle; + 873→ if (chronicle.length > 0) { + 874→ const lastEntry = chronicle[chronicle.length - 1]; + 875→ const updatedLast = withPerks(lastEntry, [perkId]); + 876→ newChronicle = [...chronicle.slice(0, -1), updatedLast]; + 877→ } + 878→ set({ + 879→ constellation: newConstellation, + 880→ pendingPerkChoices: null, + 881→ energyMax, + 882→ chronicle: newChronicle, + 883→ ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: newConstellation, attributes: s.attributes }), + 884→ }); + 885→ return true; + 886→ }, + 887→ + 888→ rerollPerkChoices: () => { + 889→ const s = get(); + 890→ if (!s.pendingPerkChoices) return; + 891→ const choices = rollPerkChoices(s.constellation ?? []); + 892→ if (choices.length > 0) set({ pendingPerkChoices: choices }); + 893→ }, + 894→ + 895→ checkAchievements: () => { + 896→ const s = get(); + 897→ const newlyUnlocked: Achievement[] = []; + 898→ const updated = { ...s.achievements }; + 899→ let crystals = s.crystals; + 900→ let insights = s.insights; + 901→ let contact = s.contact; + 902→ let statsDirty = false; + 903→ for (const a of ACHIEVEMENTS) { + 904→ if (updated[a.id]) continue; + 905→ if (a.check(s)) { + 906→ updated[a.id] = true; + 907→ newlyUnlocked.push(a); + 908→ // 发放即时奖励 + 909→ if (a.reward.crystals) crystals += a.reward.crystals; + 910→ if (a.reward.insights) insights += a.reward.insights; + 911→ if (a.reward.contact) contact = Math.min(100, contact + a.reward.contact); + 912→ if (a.reward.crystalsPerSecPct || a.reward.insightPct) statsDirty = true; + 913→ } + 914→ } + 915→ if (newlyUnlocked.length === 0) return []; + 916→ set({ + 917→ achievements: updated, + 918→ crystals, + 919→ insights, + 920→ contact, + 921→ ...(statsDirty + 922→ ? syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: updated, activeTide: s.activeTide, constellation: s.constellation, attributes: s.attributes }) + 923→ : {}), + 924→ _achievementQueue: [...s._achievementQueue, ...newlyUnlocked], + 925→ }); + 926→ return newlyUnlocked; + 927→ }, + 928→ + 929→ consumeAchievementQueue: () => { + 930→ const s = get(); + 931→ if (s._achievementQueue.length === 0) return []; + 932→ const items = s._achievementQueue; + 933→ set({ _achievementQueue: [] }); + 934→ return items; + 935→ }, + 936→ + 937→ canPrestige: () => get().contact >= CONTACT.prestigeMin, + 938→ + 939→ toggleTheme: () => set({ theme: get().theme === "dark" ? "light" : "dark" }), + 940→ toggleSound: () => set({ soundOn: !get().soundOn }), + 941→ + 942→ // 深空信标:发放每日挑战奖励(v0.5) + 943→ grantBeaconReward: (insights, contact) => { + 944→ const s = get(); + 945→ set({ + 946→ insights: s.insights + Math.round(insights), + 947→ contact: Math.min(100, s.contact + contact), + 948→ }); + 949→ }, + 950→ + 951→ // 深空巡航:发放实时玩法奖励(v0.6) + 952→ grantCruiseReward: (rewards) => { + 953→ const s = get(); + 954→ const addCrystals = rewards.crystals ?? 0; + 955→ const addInsights = rewards.insights ?? 0; + 956→ const addContact = rewards.contact ?? 0; + 957→ // v0.7 角色属性:巡航通关给探索力+勇气经验(按晶体奖励量缩放) + 958→ const totalReward = addCrystals + addInsights * 10 + addContact * 10; + 959→ const expBase = Math.max(2, Math.floor(totalReward / 30)); + 960→ const curAttrs = s.attributes ?? createInitialAttributes(); + 961→ const curProg = s.attributeProgress ?? createInitialAttributeProgress(); + 962→ const explLvl = levelUpCheck( + 963→ { exp: curProg.exploration.exp + expBase, level: curAttrs.exploration }, + 964→ ATTRIBUTE_HARD_CAP + 965→ ); + 966→ const courageLvl = levelUpCheck( + 967→ { exp: curProg.courage.exp + Math.floor(expBase * 0.6), level: curAttrs.courage }, + 968→ ATTRIBUTE_HARD_CAP + 969→ ); + 970→ const newAttributes: CharacterAttributes = { + 971→ ...curAttrs, + 972→ exploration: explLvl.newProgress.level, + 973→ courage: courageLvl.newProgress.level, + 974→ }; + 975→ const newProgress: AttributeProgress = { + 976→ ...curProg, + 977→ exploration: explLvl.newProgress, + 978→ courage: courageLvl.newProgress, + 979→ }; + 980→ set({ + 981→ crystals: Math.min(s.crystalCap, s.crystals + addCrystals), + 982→ insights: s.insights + Math.round(addInsights), + 983→ contact: Math.min(100, s.contact + addContact), + 984→ attributes: newAttributes, + 985→ attributeProgress: newProgress, + 986→ ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation, attributes: newAttributes }), + 987→ }); + 988→ }, + 989→ + 990→ // ============ 角色属性系统(v0.7 P1) ============ + 991→ allocateAttribute: (attr, points = 1) => { + 992→ const s = get(); + 993→ const cur = s.attributes ?? createInitialAttributes(); + 994→ const curVal = cur[attr] ?? 0; + 995→ if (curVal >= ATTRIBUTE_HARD_CAP) { + 996→ return { ok: false, leveledUp: 0 }; + 997→ } + 998→ if ((s.pendingAttrPoints ?? 0) < points) { + 999→ return { ok: false, leveledUp: 0 }; + 1000→ } + 1001→ const alloc = Math.min(points, ATTRIBUTE_HARD_CAP - curVal, s.pendingAttrPoints); + 1002→ const newVal = curVal + alloc; + 1003→ const newAttributes: CharacterAttributes = { ...cur, [attr]: newVal }; + 1004→ // 同步经验进度 level 字段(保持一致) + 1005→ const curProg = s.attributeProgress ?? createInitialAttributeProgress(); + 1006→ const oldProg = curProg[attr] ?? { exp: 0, level: curVal }; + 1007→ const newProgress: AttributeProgress = { + 1008→ ...curProg, + 1009→ [attr]: { exp: oldProg.exp, level: newVal }, + 1010→ }; + 1011→ set({ + 1012→ attributes: newAttributes, + 1013→ attributeProgress: newProgress, + 1014→ pendingAttrPoints: s.pendingAttrPoints - alloc, + 1015→ ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation, attributes: newAttributes }), + 1016→ }); + 1017→ return { ok: true, leveledUp: alloc }; + 1018→ }, + 1019→ + 1020→ gainAttributeExp: (attr, amount) => { + 1021→ const s = get(); + 1022→ const cur = s.attributes ?? createInitialAttributes(); + 1023→ const curProg = s.attributeProgress ?? createInitialAttributeProgress(); + 1024→ const progEntry = curProg[attr] ?? { exp: 0, level: cur[attr] }; + 1025→ const result = levelUpCheck( + 1026→ { exp: progEntry.exp + amount, level: cur[attr] }, + 1027→ ATTRIBUTE_HARD_CAP + 1028→ ); + 1029→ const newAttributes: CharacterAttributes = { ...cur, [attr]: result.newProgress.level }; + 1030→ const newProgress: AttributeProgress = { + 1031→ ...curProg, + 1032→ [attr]: result.newProgress, + 1033→ }; + 1034→ set({ + 1035→ attributes: newAttributes, + 1036→ attributeProgress: newProgress, + 1037→ ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation, attributes: newAttributes }), + 1038→ }); + 1039→ return { leveledUp: result.levelsGained, newLevel: result.newProgress.level }; + 1040→ }, + 1041→ }), + 1042→ { + 1043→ name: "echo-nexus-save-v1", + 1044→ storage: createJSONStorage(() => localStorage), + 1045→ // 不持久化临时字段 + 1046→ partialize: (s) => { + 1047→ const { _lastAutoDecode, _lastSpawn, _combo, _lastPulse, _achievementQueue, _tideEvents, ...rest } = s; + 1048→ void _lastAutoDecode; void _lastSpawn; void _combo; void _lastPulse; void _achievementQueue; void _tideEvents; + 1049→ return rest as GameState; + 1050→ }, + 1051→ } + 1052→ ) + 1053→); + 1054→ + 1055→/** 选择器:未解锁碎片中下一个门槛 */ + 1056→export function nextFragmentThreshold(totalDecoded: number): number | null { + 1057→ for (const f of FRAGMENTS) { + 1058→ if (totalDecoded < f.threshold) return f.threshold; + 1059→ } + 1060→ return null; + 1061→} + 1062→ + 1063→export { FRAGMENTS, PRESTIGE, TECH_TREE }; + 1064→ + 1065→// 开发期调试:暴露 store 到 window,便于 QA 测试 + 1066→if (typeof window !== "undefined" && process.env.NODE_ENV !== "production") { + 1067→ (window as unknown as { __gameStore?: typeof useGameStore }).__gameStore = useGameStore; + 1068→} + 1069→ \ No newline at end of file diff --git a/worklog.md b/worklog.md index 0e1e0b736..ad1ae23d0 100644 --- a/worklog.md +++ b/worklog.md @@ -337,3 +337,77 @@ Stage Summary: - ✅ VLM 评分:主界面 7/10 + 巡航READY 8/10 + 巡航游戏 8/10 - 在线游玩 v0.8:https://gitea-pages.atdunbg.xyz/Super_Z/echo-nexus/ - 下一阶段工单:P1 探险BOSS胜率平衡 + 探险能量恢复 / 信标系统扩展(周挑战/信标链) + +--- +Task ID: 9-b +Agent: full-stack-developer +Task: 信标系统扩展(周挑战 + 信标链连续奖励) + +Work Log: +- 阅读现有 `src/lib/game/beacon.ts`(v0.5 每日挑战 + 本地排行榜,358 行)+ `src/components/game/BeaconPanel.tsx` + `src/store/gameStore.ts` 的 `trackBeacon` 函数(约第 173 行)+ worklog v0.8 项目状态,确认四色全息规范与现有架构。 +- **扩展 `src/lib/game/beacon.ts`(358 → 873 行,新增 ~515 行)**: + - **周挑战(WEEKLY CHALLENGE)**: + - `BeaconWeeklyChallenge` 接口(weekKey YYYY-Www / type / difficulty 强制 anomaly|singular / goal / rewardInsight / rewardContact / seed / title / desc) + - `BeaconWeeklyProgress` 接口(weekKey / progress / startedAt / completedAt / claimed / durationSec) + - `getWeekKey(now)`:ISO 8601 周键(周一为起点,含首个周四的周为第一周) + - `weekKeyToSeed`:FNV-1a 哈希 + - `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) + - `loadWeeklyProgress` / `saveWeeklyProgress` / `addWeeklyProgress` / `claimWeeklyReward`(推送排行榜,entry 加 `isWeekly: true`) + - `msUntilNextWeek(now)`:距离下周一 UTC 0 点的毫秒数 + - `BEACON_WEEKLY_KEY = "echo-nexus-beacon-weekly-v1"` + - **信标链(BEACON CHAIN)**: + - `BeaconChainState` 接口(lastCompletedDateKey / currentStreak / longestStreak / totalCompletions / graceUsed / milestonesClaimed) + - `BEACON_CHAIN_KEY = "echo-nexus-beacon-chain-v1"` + - `BEACON_CHAIN_MILESTONES = [3, 7, 14, 30]` 常量 + - `BEACON_CHAIN_REWARDS`:4 个里程碑(三日谐振 +50/+5 / 七日回响 +120/+12 / 半月星潮 +280/+28 / 满月飞升 +680/+68) + - `loadChainState` / `saveChainState`(每次返回新对象避免引用共享) + - `recordChainCompletion(dateKey)` 核心逻辑:同日重复忽略;次日 streak++;隔一天 miss 且 graceUsed<1 时续命 streak++ graceUsed++;其他断链 streak=1 graceUsed=0;返回 `{ state, newMilestones }` + - `claimChainMilestone(milestone)` / `getNextMilestone(streak)` / `getChainProgress(streak)`(返回 current/next/prev/progressPct 用于 UI) + - `dateKeyToTimestamp` / `dateKeyDiffDays` 工具函数 + - `BeaconScoreEntry` 新增可选 `isWeekly?: boolean` 字段 +- **扩展 `src/store/gameStore.ts`**: + - import 扩展:generateWeeklyChallenge / loadWeeklyProgress / loadChainState / addWeeklyProgress / recordChainCompletion / claimWeeklyReward / claimChainMilestone / getTodayKey + 类型 + - `trackBeacon(type, delta)` 函数签名从 `boolean` 改为 `{ dailyJustCompleted, weeklyJustCompleted, newChainMilestones }`: + - 日挑战类型匹配且未完成 → addBeaconProgress;若刚完成 → recordChainCompletion(getTodayKey()) 收集 newMilestones + - 周挑战类型匹配且未完成 → addWeeklyProgress + - 所有调用点(pulse/clickNode/autoDecodeTick/resolveCurrentNode 等 7 处)原本就忽略返回值,向后兼容 + - 新增 action `claimWeeklyBeacon`:调用 claimWeeklyReward → 发放 insights + contact 到 state,返回 `{ rewardInsight, rewardContact, score }` + - 新增 action `claimChainReward(milestone)`:前置校验 currentStreak 与 milestonesClaimed,调用 claimChainMilestone → 发放奖励,返回 `{ rewardInsight, rewardContact, label, ok }` + - GameActions 接口同步新增两个 action 签名 +- **重写 `src/components/game/BeaconPanel.tsx`(301 → 638 行)**: + - 顶部头部 + 每日挑战卡片(保留 v0.5 完整功能 + 按钮主题色随难度色淡显) + - **新增周挑战区块**(fuchsia 主题):标题"周挑战 · WEEKLY" + weekKey + 倒计时 + 挑战卡片(类型/难度标签/标题/描述/进度条/奖励/领取按钮 emerald 主题)+ weekly-glow 动画 + - **新增信标链区块**(amber→rose 渐变):标题"信标链 · CHAIN" + 大字号 streak(chain-streak-text 渐变流动动画)+ 今日完成状态徽章 + 4 个里程碑节点(w-12 h-12 圆形,claimed=emerald/✓,reachable=rose 脉冲动画+领取按钮,inProgress=amber 半亮,未到达=muted 灰)+ 节点间连线(背景灰 + 已达成部分 amber→rose 渐变 + 辉光,基于 prev→next 插值定位)+ 进度条 + 底部统计(最长/累计/续命状态) + - 桌面端 lg:grid-cols-2 让周挑战+信标链并排,移动端单列 + - 排行榜条目区分日/周:周挑战 entry 显示 "WEEK" 徽章 + fuchsia 高亮背景 + - 空状态提示扩展为"完成今日或本周信标即可登榜" + - 4 个新动画:weekly-glow / chain-milestone-pulse / chain-streak-flux(背景渐变流动) +- **QA 验证**: + - `bun run lint` 零错误(每次修改后均验证) + - dev.log 全程无错误,所有编译 < 300ms + - **信标链逻辑测试**(bun 直接运行 TS,5 个场景全 PASS): + 1. 昨日 streak=1 → 今日完成 → streak=2 ✅ + 2. 同日重复完成 → 忽略 ✅ + 3. 明日完成 → streak=3,无需 grace ✅ + 4. 隔一天 miss(lastCompleted + 2 天)→ streak=4,graceUsed=1 ✅ + 5. 再次 miss(grace 已用)→ 断链 streak=1,graceUsed=0 ✅ + - **UI 集成测试**(agent-browser): + - localStorage 模拟设置 chain state(lastCompletedDateKey=yesterday, currentStreak=1)→ reload → UI 正确显示 streak=1 + - 设置 streak=3 + milestonesClaimed=[] → milestone 3 节点显示 rose 脉冲动画 + "领取"按钮 + - 点击"领取"→ milestonesClaimed=[3] + Toast "✦ 三日谐振 已领取 +50 洞见 · +5.0 接触" + 按钮消失 ✅ + - **VLM 视觉评分**(目标 ≥7/10): + - 首屏截图:8/10(四色一致、布局合理、信标链清晰) + - 里程碑可领取状态:8/10(amber→rose 渐变醒目、里程碑层次分明、fuchsia 主题清晰) + - 重置后干净状态:8/10(WEEKLY 字体对比度可优化,但整体可读性强) + - **不破坏现有功能**:日挑战进度/领奖/排行榜流程全部保留并工作正常 + +Stage Summary: +- ✅ beacon.ts 扩展完成(358 → 873 行):周挑战 + 信标链全部函数与类型 +- ✅ gameStore.ts 集成完成:trackBeacon 升级为对象返回值 + claimWeeklyBeacon + claimChainReward 两个新 action +- ✅ BeaconPanel.tsx UI 完整重写(301 → 638 行):周挑战 fuchsia 卡片 + 信标链 amber→rose 渐变区块 +- ✅ 严格四色全息:周挑战 fuchsia、信标链 amber→rose、领取按钮 emerald、无蓝/紫色违规 +- ✅ localStorage 新增 2 个独立 key(weekly + chain),不污染旧 key +- ✅ 确定性:周挑战同周同种子同结果(weekKey FNV-1a 种子) +- ✅ 信标链 grace 续命机制工作正常(5 个场景 bun 测试全 PASS) +- ✅ lint 零错误 + dev HTTP 200 + VLM 8/10 +- ✅ 现有日挑战 + 排行榜 + 领奖流程全部保留,无回归