Files
echo-nexus/tool-results/read_1782247263000_ec7aa6aa7dd7.txt
T

843 lines
46 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
1→ 1→"use client";
2→ 2→// 回响星核 / Echo Nexus — Zustand 游戏状态管理
3→ 3→import { create } from "zustand";
4→ 4→import { persist, createJSONStorage } from "zustand/middleware";
5→ 5→import type {
6→ 6→ GameState,
7→ 7→ Crystal,
8→ 8→ CrystalTier,
9→ 9→ DecodePuzzle,
10→ 10→ ExpeditionResult,
11→ 11→} from "@/lib/game/types";
12→ 12→import {
13→ 13→ INITIAL_STATE,
14→ 14→ TECH_TREE,
15→ 15→ CRYSTAL_VALUE,
16→ 16→ CONTACT,
17→ 17→ CRYSTAL_SPAWN,
18→ 18→ FRAGMENTS,
19→ 19→ PRESTIGE,
20→ 20→} from "@/lib/game/config";
21→ 21→import {
22→ 22→ createInitialState,
23→ 23→ recomputeStats,
24→ 24→ decodeRewards,
25→ 25→ rollCrystalTierWithBonus,
26→ 26→ computeNewBlueprints,
27→ 27→ performPrestige,
28→ 28→} from "@/lib/game/engine";
29→ 29→import {
30→ 30→ generatePuzzle,
31→ 31→ tryClickNode,
32→ 32→ isSolvable,
33→ 33→ resetPuzzle as resetPuz,
34→ 34→} from "@/lib/game/decode";
35→ 35→import {
36→ 36→ generateExpedition,
37→ 37→ resolveNode,
38→ 38→ advanceExpedition,
39→ 39→ computeExpeditionPower,
40→ 40→ computeExpeditionHp,
41→ 41→ computeEnergyRegen,
42→ 42→ EXPEDITION_CONFIG,
43→ 43→} from "@/lib/game/expedition";
44→ 44→import { ACHIEVEMENTS, type Achievement } from "@/lib/game/achievements";
45→ 45→import {
46→ 46→ TIDE_CONFIG,
47→ 47→ rollTide,
48→ 48→ getTideModifiers,
49→ 49→ computeSilenceCompensation,
50→ 50→ type StarTide,
51→ 51→ type TideType,
52→ 52→} from "@/lib/game/starTide";
53→ 53→import {
54→ 54→ getPerk,
55→ 55→ constellationBonuses,
56→ 56→ rollPerkChoices,
57→ 57→} from "@/lib/game/constellation";
58→ 58→import {
59→ 59→ migrateChronicleFields,
60→ 60→ withPerks,
61→ 61→} from "@/lib/game/chronicle";
62→ 62→import {
63→ 63→ generateDailyChallenge,
64→ 64→ loadDailyProgress,
65→ 65→ addBeaconProgress,
66→ 66→ type BeaconDailyChallenge,
67→ 67→ type BeaconDailyProgress,
68→ 68→} from "@/lib/game/beacon";
69→ 69→import { setPendingOfflineReport } from "@/lib/game/offlineReport";
70→ 70→
71→ 71→interface GameActions {
72→ 72→ // 生命周期
73→ 73→ init: () => void;
74→ 74→ loadOnline: () => void;
75→ 75→ hardReset: () => void;
76→ 76→
77→ 77→ // 主循环
78→ 78→ tick: (now: number) => void;
79→ 79→ pulse: () => { gain: number; combo: number } | null;
80→ 80→
81→ 81→ // 星潮
82→ 82→ tickTide: (now: number) => { started?: TideType; ended?: TideType; silenceCompensation?: number } | null;
83→ 83→ consumeTideEvents: () => { started?: TideType; ended?: TideType; silenceCompensation?: number }[];
84→ 84→
85→ 85→ // 解码
86→ 86→ startDecode: (crystalId: string) => void;
87→ 87→ clickNode: (nodeId: number) => { ok: boolean; finished: boolean; solvable?: boolean; failReason?: string };
88→ 88→ undoStep: () => void;
89→ 89→ retryPuzzle: () => void;
90→ 90→ abandonPuzzle: () => void;
91→ 91→ /** 自动解码 T1(技术解锁后由 tick 调用) */
92→ 92→ autoDecodeTick: () => void;
93→ 93→
94→ 94→ // 探险
95→ 95→ startExpedition: () => { ok: boolean; reason?: string };
96→ 96→ resolveCurrentNode: () => ExpeditionResult | null;
97→ 97→ advanceNode: () => void;
98→ 98→ abortExpedition: () => void;
99→ 99→
100→ 100→ // 技术
101→ 101→ buyTech: (techId: string) => boolean;
102→ 102→
103→ 103→ // 飞升
104→ 104→ doPrestige: () => { newBp: number } | null;
105→ 105→
106→ 106→ // 星图天文台
107→ 107→ chooseConstellationPerk: (perkId: string) => boolean;
108→ 108→ rerollPerkChoices: () => void;
109→ 109→
110→ 110→ // 成就
111→ 111→ checkAchievements: () => Achievement[];
112→ 112→ consumeAchievementQueue: () => Achievement[];
113→ 113→
114→ 114→ // 设置
115→ 115→ toggleTheme: () => void;
116→ 116→ toggleSound: () => void;
117→ 117→
118→ 118→ // 深空信标奖励发放(v0.5)
119→ 119→ grantBeaconReward: (insights: number, contact: number) => void;
120→ 120→
121→ 121→ // 深空巡航奖励发放(v0.6 — 实时 Canvas 玩法)
122→ 122→ grantCruiseReward: (rewards: { crystals?: number; insights?: number; contact?: number }) => void;
123→ 123→
124→ 124→ // 派生
125→ 125→ canPrestige: () => boolean;
126→ 126→}
127→ 127→
128→ 128→type Store = GameState & GameActions & {
129→ 129→ _lastAutoDecode: number;
130→ 130→ _lastSpawn: number;
131→ 131→ _combo: number;
132→ 132→ _lastPulse: number;
133→ 133→ _achievementQueue: Achievement[];
134→ 134→ _tideEvents: { started?: TideType; ended?: TideType; silenceCompensation?: number }[];
135→ 135→};
136→ 136→
137→ 137→/** 计算并写回产能字段 */
138→ 138→function syncStats(state: Partial<GameState>) {
139→ 139→ const s = recomputeStats(state);
140→ 140→ return {
141→ 141→ crystalsPerSec: s.crystalsPerSec,
142→ 142→ crystalCap: s.crystalCap,
143→ 143→ pulsePower: s.pulsePower,
144→ 144→ offlineEff: s.offlineEff,
145→ 145→ insightMult: s.insightMult,
146→ 146→ contactRateMult: s.contactRateMult,
147→ 147→ autoDecode: s.autoDecode,
148→ 148→ decodeStepsBonus: s.decodeStepsBonus,
149→ 149→ };
150→ 150→}
151→ 151→
152→ 152→/**
153→ 153→ * 深空信标进度追踪(v0.5)。
154→ 154→ * 在游戏关键动作(脉冲/解码/探险/BOSS/洞见)后调用,按今日挑战类型增量更新进度。
155→ 155→ * 进度独立存储于 localStorageecho-nexus-beacon-prog-v1),不污染 GameState。
156→ 156→ * @returns 若刚完成则返回 true(供 UI 触发通知)
157→ 157→ */
158→ 158→function trackBeacon(
159→ 159→ type: "pulse" | "decode" | "expedition" | "boss" | "insight",
160→ 160→ delta: number
161→ 161→): boolean {
162→ 162→ if (typeof window === "undefined") return false;
163→ 163→ try {
164→ 164→ const challenge: BeaconDailyChallenge = generateDailyChallenge();
165→ 165→ if (challenge.type !== type) return false;
166→ 166→ const current: BeaconDailyProgress = loadDailyProgress();
167→ 167→ if (current.completedAt !== null) return false; // 已完成不再累加
168→ 168→ const { justCompleted } = addBeaconProgress(current, challenge, delta);
169→ 169→ return justCompleted;
170→ 170→ } catch {
171→ 171→ return false;
172→ 172→ }
173→ 173→}
174→ 174→
175→ 175→/** 检查并解锁叙事碎片 */
176→ 176→function checkFragments(state: GameState): string[] {
177→ 177→ const unlocked: string[] = [];
178→ 178→ for (const f of FRAGMENTS) {
179→ 179→ if (!state.fragments[f.id] && state.totalDecoded >= f.threshold) {
180→ 180→ state.fragments[f.id] = true;
181→ 181→ unlocked.push(f.id);
182→ 182→ }
183→ 183→ }
184→ 184→ return unlocked;
185→ 185→}
186→ 186→
187→ 187→export const useGameStore = create<Store>()(
188→ 188→ persist(
189→ 189→ (set, get) => ({
190→ 190→ ...createInitialState(),
191→ 191→ _lastAutoDecode: Date.now(),
192→ 192→ _lastSpawn: Date.now(),
193→ 193→ _combo: 0,
194→ 194→ _lastPulse: 0,
195→ 195→ _achievementQueue: [],
196→ 196→ _tideEvents: [],
197→ 197→
198→ 198→ init: () => {
199→ 199→ const s = get();
200→ 200→ const now = Date.now();
201→ 201→ // 兼容旧存档:若存在不可解的活跃谜题(旧算法生成),直接清掉
202→ 202→ let activePuzzle = s.activePuzzle;
203→ 203→ if (activePuzzle && !isSolvable(activePuzzle)) {
204→ 204→ // 把晶体放回队列,避免玩家卡死
205→ 205→ const crystal: Crystal = {
206→ 206→ id: `c_${now}_rec`,
207→ 207→ tier: activePuzzle.tier,
208→ 208→ value: CRYSTAL_VALUE[activePuzzle.tier].crystals,
209→ 209→ createdAt: now,
210→ 210→ };
211→ 211→ activePuzzle = null;
212→ 212→ set({
213→ 213→ pendingCrystals: [...s.pendingCrystals, crystal].slice(-CRYSTAL_SPAWN.maxPending),
214→ 214→ });
215→ 215→ }
216→ 216→ // 兼容旧存档:补全 achievements / 星潮 / 星图 字段
217→ 217→ const achievements = s.achievements ?? {};
218→ 218→ const activeTide = s.activeTide ?? null;
219→ 219→ const constellation = s.constellation ?? [];
220→ 220→ const pendingPerkChoices = s.pendingPerkChoices ?? null;
221→ 221→ // v0.4 编年史兼容:补全 chronicle / runStart / bossKills / starTidesEncountered
222→ 222→ const migrated = migrateChronicleFields(s);
223→ 223→ // 星图「能量共振」天赋 +1 能量上限
224→ 224→ const cm = constellationBonuses(constellation);
225→ 225→ const energyMax = (INITIAL_STATE.energyMax) + cm.energyMaxBonus;
226→ 226→ // lastTideEnd:0 表示从未触发过星潮(首次游玩),保留 0 让 firstDelay 生效
227→ 227→ const lastTideEndRaw = s.lastTideEnd ?? 0;
228→ 228→ // 若旧存档有已过期的星潮,清掉
229→ 229→ const tide = activeTide && activeTide.endsAt > now ? activeTide : null;
230→ 230→ // 首次进入:补发离线收益
231→ 231→ const elapsed = Math.max(0, (now - s.lastTick) / 1000);
232→ 232→ if (elapsed > 5) {
233→ 233→ const cap = 8 * 3600;
234→ 234→ const secs = Math.min(elapsed, cap);
235→ 235→ const gain = s.crystalsPerSec * secs * s.offlineEff;
236→ 236→ const crystalsBefore = s.crystals;
237→ 237→ const crystalsAfter = Math.min(s.crystalCap, s.crystals + gain);
238→ 238→ // 设置离线报告(v0.5.2):通知 UI 弹出"欢迎回来"对话框
239→ 239→ setPendingOfflineReport({
240→ 240→ elapsedSec: secs,
241→ 241→ rawElapsedSec: elapsed,
242→ 242→ gain: crystalsAfter - crystalsBefore,
243→ 243→ rate: s.crystalsPerSec,
244→ 244→ eff: s.offlineEff,
245→ 245→ capped: elapsed > cap,
246→ 246→ crystalsBefore,
247→ 247→ crystalsAfter,
248→ 248→ crystalCap: s.crystalCap,
249→ 249→ });
250→ 250→ set({
251→ 251→ crystals: crystalsAfter,
252→ 252→ lastTick: now,
253→ 253→ activePuzzle,
254→ 254→ achievements,
255→ 255→ activeTide: tide,
256→ 256→ lastTideEnd: tide ? lastTideEndRaw : lastTideEndRaw,
257→ 257→ constellation,
258→ 258→ pendingPerkChoices,
259→ 259→ energyMax,
260→ 260→ chronicle: migrated.chronicle,
261→ 261→ runStart: migrated.runStart,
262→ 262→ bossKills: migrated.bossKills,
263→ 263→ starTidesEncountered: migrated.starTidesEncountered,
264→ 264→ ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements, activeTide: tide, constellation }),
265→ 265→ });
266→ 266→ } else {
267→ 267→ 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, ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements, activeTide: tide, constellation }) });
268→ 268→ }
269→ 269→ },
270→ 270→
271→ 271→ loadOnline: () => {
272→ 272→ const s = get();
273→ 273→ set({ ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation }) });
274→ 274→ },
275→ 275→
276→ 276→ hardReset: () => {
277→ 277→ set({ ...createInitialState(), _lastAutoDecode: Date.now(), _lastSpawn: Date.now(), _combo: 0, _lastPulse: 0, _achievementQueue: [], _tideEvents: [] });
278→ 278→ },
279→ 279→
280→ 280→ tickTide: (now) => {
281→ 281→ const s = get();
282→ 282→ const tide = s.activeTide;
283→ 283→ // 星图「星潮引导」减少间隙
284→ 284→ const cm = constellationBonuses(s.constellation ?? []);
285→ 285→ const gap = Math.max(15000, TIDE_CONFIG.gap + cm.tideGapDeltaSec * 1000);
286→ 286→ // 1) 检查当前星潮是否结束
287→ 287→ if (tide && now >= tide.endsAt) {
288→ 288→ const endedType = tide.type;
289→ 289→ // 寂静期补偿洞见
290→ 290→ let silenceCompensation = 0;
291→ 291→ if (tide.type === "silence") {
292→ 292→ silenceCompensation = computeSilenceCompensation(tide);
293→ 293→ }
294→ 294→ const newInsights = s.insights + silenceCompensation;
295→ 295→ const event: { started?: TideType; ended?: TideType; silenceCompensation?: number } = { ended: endedType };
296→ 296→ if (silenceCompensation > 0) event.silenceCompensation = silenceCompensation;
297→ 297→ set({
298→ 298→ activeTide: null,
299→ 299→ lastTideEnd: now,
300→ 300→ insights: newInsights,
301→ 301→ // 星潮结束后重算 stats(移除 contactRate/insight 修饰)
302→ 302→ ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: null, constellation: s.constellation }),
303→ 303→ _tideEvents: [...s._tideEvents, event],
304→ 304→ });
305→ 305→ return event;
306→ 306→ }
307→ 307→ // 2) 检查是否该触发新星潮(间隙已过)
308→ 308→ if (!tide) {
309→ 309→ const since = now - s.lastTideEnd;
310→ 310→ // 首次:以 createdAt 起算 firstDelay;之后以 lastTideEnd 起算 gap
311→ 311→ const firstStart = s.lastTideEnd === 0 || s.lastTideEnd <= s.createdAt + 1000;
312→ 312→ const need = firstStart ? TIDE_CONFIG.firstDelay : gap;
313→ 313→ if (since >= need) {
314→ 314→ const type = rollTide();
315→ 315→ const newTide: StarTide = {
316→ 316→ id: `tide_${now}_${Math.random().toString(36).slice(2, 7)}`,
317→ 317→ type,
318→ 318→ startedAt: now,
319→ 319→ endsAt: now + TIDE_CONFIG.duration,
320→ 320→ };
321→ 321→ const event: { started?: TideType; ended?: TideType; silenceCompensation?: number } = { started: type };
322→ 322→ // v0.4 编年史:累计遇到的星潮 ID(去重)
323→ 323→ const tidesAll = s.starTidesEncountered ?? [];
324→ 324→ const tideId = `tide_${type}`;
325→ 325→ const newTidesAll = tidesAll.includes(tideId) ? tidesAll : [...tidesAll, tideId];
326→ 326→ set({
327→ 327→ activeTide: newTide,
328→ 328→ starTidesEncountered: newTidesAll,
329→ 329→ // 星潮开始后重算 stats(应用 contactRate/insight 修饰)
330→ 330→ ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: newTide, constellation: s.constellation }),
331→ 331→ _tideEvents: [...s._tideEvents, event],
332→ 332→ });
333→ 333→ return event;
334→ 334→ }
335→ 335→ }
336→ 336→ return null;
337→ 337→ },
338→ 338→
339→ 339→ consumeTideEvents: () => {
340→ 340→ const s = get();
341→ 341→ if (s._tideEvents.length === 0) return [];
342→ 342→ const items = s._tideEvents;
343→ 343→ set({ _tideEvents: [] });
344→ 344→ return items;
345→ 345→ },
346→ 346→
347→ 347→ tick: (now) => {
348→ 348→ const s = get();
349→ 349→ const dt = Math.max(0, (now - s.lastTick) / 1000);
350→ 350→ if (dt <= 0) return;
351→ 351→
352→ 352→ // 星潮产能修饰(即时乘)
353→ 353→ const tideMod = getTideModifiers(s.activeTide);
354→ 354→ const effCps = s.crystalsPerSec * tideMod.crystalsPerSecMult;
355→ 355→ // 产能累加(仅闲置产能受仓库上限;探险奖励可超出)
356→ 356→ const newCrystals =
357→ 357→ s.crystals >= s.crystalCap
358→ 358→ ? s.crystals // 已达/超上限,不再自动产出
359→ 359→ : Math.min(s.crystalCap, s.crystals + effCps * dt);
360→ 360→
361→ 361→ // 自动产出待解码晶体(每 baseInterval 秒一颗,受技术/飞升略提速)
362→ 362→ const bpBoost = 1 + s.blueprints.length * 0.03;
363→ 363→ const spawnInterval = (CRYSTAL_SPAWN.baseIntervalSec / bpBoost) * 1000;
364→ 364→ let pending = s.pendingCrystals;
365→ 365→ let lastSpawn = s._lastSpawn;
366→ 366→ if (
367→ 367→ now - lastSpawn > spawnInterval &&
368→ 368→ pending.length < CRYSTAL_SPAWN.maxPending
369→ 369→ ) {
370→ 370→ // 星图「晶体富集」提升 T2/T3 概率
371→ 371→ const cm = constellationBonuses(s.constellation ?? []);
372→ 372→ const tier = rollCrystalTierWithBonus(cm.t2t3BonusRate);
373→ 373→ const crystal: Crystal = {
374→ 374→ id: `c_${now}_${Math.random().toString(36).slice(2, 7)}`,
375→ 375→ tier,
376→ 376→ value: CRYSTAL_VALUE[tier].crystals,
377→ 377→ createdAt: now,
378→ 378→ };
379→ 379→ pending = [...pending, crystal];
380→ 380→ lastSpawn = now;
381→ 381→ }
382→ 382→
383→ 383→ // 能量恢复(探险系统)
384→ 384→ let energy = s.energy;
385→ 385→ let lastEnergyTick = s.lastEnergyTick;
386→ 386→ if (energy < s.energyMax) {
387→ 387→ const regen = computeEnergyRegen(lastEnergyTick, now, energy, s.energyMax);
388→ 388→ energy = regen.energy;
389→ 389→ lastEnergyTick = regen.lastTick;
390→ 390→ } else {
391→ 391→ lastEnergyTick = now;
392→ 392→ }
393→ 393→
394→ 394→ set({
395→ 395→ crystals: newCrystals,
396→ 396→ lastTick: now,
397→ 397→ pendingCrystals: pending,
398→ 398→ _lastSpawn: lastSpawn,
399→ 399→ energy,
400→ 400→ lastEnergyTick,
401→ 401→ });
402→ 402→ },
403→ 403→
404→ 404→ pulse: () => {
405→ 405→ const s = get();
406→ 406→ const now = Date.now();
407→ 407→ // 连击
408→ 408→ let combo = 1;
409→ 409→ if (now - s._lastPulse < 1500) {
410→ 410→ combo = Math.min(10, s._combo + 1);
411→ 411→ }
412→ 412→ const mult = 1 + (combo - 1) * 0.15;
413→ 413→ // 星潮脉冲威力修饰
414→ 414→ const tideMod = getTideModifiers(s.activeTide);
415→ 415→ const gain = s.pulsePower * mult * tideMod.pulsePowerMult;
416→ 416→ set({
417→ 417→ crystals: Math.min(s.crystalCap, s.crystals + gain),
418→ 418→ _combo: combo,
419→ 419→ _lastPulse: now,
420→ 420→ });
421→ 421→ // 深空信标:脉冲任务进度 +1
422→ 422→ trackBeacon("pulse", 1);
423→ 423→ return { gain, combo };
424→ 424→ },
425→ 425→
426→ 426→ startDecode: (crystalId) => {
427→ 427→ const s = get();
428→ 428→ const crystal = s.pendingCrystals.find((c) => c.id === crystalId);
429→ 429→ if (!crystal) return;
430→ 430→ const puzzle = generatePuzzle(crystal.tier);
431→ 431→ set({
432→ 432→ activePuzzle: puzzle,
433→ 433→ pendingCrystals: s.pendingCrystals.filter((c) => c.id !== crystalId),
434→ 434→ });
435→ 435→ },
436→ 436→
437→ 437→ clickNode: (nodeId) => {
438→ 438→ const s = get();
439→ 439→ if (!s.activePuzzle) return { ok: false, finished: false, failReason: "无活跃谜题" };
440→ 440→ // 深拷贝谜题
441→ 441→ const puzzle: DecodePuzzle = JSON.parse(JSON.stringify(s.activePuzzle));
442→ 442→ const res = tryClickNode(puzzle, nodeId);
443→ 443→ if (res.ok) {
444→ 444→ if (res.finished) {
445→ 445→ // 结算奖励(星潮解码奖励修饰 + 星图「二周目经验」)
446→ 446→ const tideMod = getTideModifiers(s.activeTide);
447→ 447→ const cm = constellationBonuses(s.constellation ?? []);
448→ 448→ const base = decodeRewards(puzzle.tier, s.insightMult, s.contactRateMult);
449→ 449→ const finalMult = tideMod.decodeRewardMult * cm.decodeRewardMult;
450→ 450→ const rewards = {
451→ 451→ crystals: Math.round(base.crystals * finalMult),
452→ 452→ insights: Math.round(base.insights * finalMult),
453→ 453→ contact: +(base.contact * finalMult).toFixed(2),
454→ 454→ };
455→ 455→ const newTotal = s.totalDecoded + 1;
456→ 456→ const newContact = Math.min(100, s.contact + rewards.contact);
457→ 457→ const newInsights = s.insights + rewards.insights;
458→ 458→ const newCrystals = s.crystals + rewards.crystals;
459→ 459→ // 解锁碎片
460→ 460→ const tentative: GameState = { ...s, totalDecoded: newTotal, fragments: { ...s.fragments } };
461→ 461→ const unlocked = checkFragments(tentative);
462→ 462→ set({
463→ 463→ activePuzzle: null,
464→ 464→ crystals: newCrystals,
465→ 465→ insights: newInsights,
466→ 466→ contact: newContact,
467→ 467→ totalDecoded: newTotal,
468→ 468→ fragments: tentative.fragments,
469→ 469→ });
470→ 470→ // 深空信标:解码 +1,洞见累计
471→ 471→ trackBeacon("decode", 1);
472→ 472→ trackBeacon("insight", rewards.insights);
473→ 473→ return { ok: true, finished: true, failReason: unlocked.join(",") || undefined };
474→ 474→ }
475→ 475→ // 点击成功但未完成:检测当前局面是否仍可解
476→ 476→ const solvable = isSolvable(puzzle);
477→ 477→ set({ activePuzzle: puzzle });
478→ 478→ return { ok: true, finished: false, solvable };
479→ 479→ }
480→ 480→ return res;
481→ 481→ },
482→ 482→
483→ 483→ undoStep: () => {
484→ 484→ const s = get();
485→ 485→ if (!s.activePuzzle || s.activePuzzle.path.length === 0) return;
486→ 486→ const puzzle: DecodePuzzle = JSON.parse(JSON.stringify(s.activePuzzle));
487→ 487→ const lastId = puzzle.path.pop();
488→ 488→ if (lastId !== undefined) {
489→ 489→ const node = puzzle.grid.find((n) => n.id === lastId);
490→ 490→ if (node) node.used = false;
491→ 491→ }
492→ 492→ set({ activePuzzle: puzzle });
493→ 493→ },
494→ 494→
495→ 495→ retryPuzzle: () => {
496→ 496→ const s = get();
497→ 497→ if (!s.activePuzzle) return;
498→ 498→ set({ activePuzzle: resetPuz(s.activePuzzle) });
499→ 499→ },
500→ 500→
501→ 501→ abandonPuzzle: () => {
502→ 502→ const s = get();
503→ 503→ if (!s.activePuzzle) return;
504→ 504→ // 晶体放回队列末尾
505→ 505→ const crystal: Crystal = {
506→ 506→ id: `c_${Date.now()}_ret`,
507→ 507→ tier: s.activePuzzle.tier,
508→ 508→ value: CRYSTAL_VALUE[s.activePuzzle.tier].crystals,
509→ 509→ createdAt: Date.now(),
510→ 510→ };
511→ 511→ set({
512→ 512→ activePuzzle: null,
513→ 513→ pendingCrystals: [...s.pendingCrystals, crystal].slice(-CRYSTAL_SPAWN.maxPending),
514→ 514→ });
515→ 515→ },
516→ 516→
517→ 517→ autoDecodeTick: () => {
518→ 518→ const s = get();
519→ 519→ if (!s.autoDecode) return;
520→ 520→ const now = Date.now();
521→ 521→ // 星图「自动校准」减少自动解码周期
522→ 522→ const cm = constellationBonuses(s.constellation ?? []);
523→ 523→ const interval = Math.max(5000, 12000 + cm.autoDecodeIntervalDeltaSec * 1000);
524→ 524→ if (now - s._lastAutoDecode < interval) return;
525→ 525→ // 找一颗 T1 晶体自动解码
526→ 526→ const idx = s.pendingCrystals.findIndex((c) => c.tier === 1);
527→ 527→ if (idx < 0) return;
528→ 528→ const crystal = s.pendingCrystals[idx];
529→ 529→ const tideMod = getTideModifiers(s.activeTide);
530→ 530→ const base = decodeRewards(1, s.insightMult, s.contactRateMult);
531→ 531→ const finalMult = tideMod.decodeRewardMult * cm.decodeRewardMult;
532→ 532→ const rewards = {
533→ 533→ crystals: Math.round(base.crystals * finalMult),
534→ 534→ insights: Math.round(base.insights * finalMult),
535→ 535→ contact: +(base.contact * finalMult).toFixed(2),
536→ 536→ };
537→ 537→ const newTotal = s.totalDecoded + 1;
538→ 538→ const tentative: GameState = { ...s, totalDecoded: newTotal, fragments: { ...s.fragments } };
539→ 539→ checkFragments(tentative);
540→ 540→ set({
541→ 541→ pendingCrystals: s.pendingCrystals.filter((c) => c.id !== crystal.id),
542→ 542→ crystals: s.crystals + rewards.crystals,
543→ 543→ insights: s.insights + rewards.insights,
544→ 544→ contact: Math.min(100, s.contact + rewards.contact),
545→ 545→ totalDecoded: newTotal,
546→ 546→ fragments: tentative.fragments,
547→ 547→ _lastAutoDecode: now,
548→ 548→ });
549→ 549→ // 深空信标:自动解码也算进度
550→ 550→ trackBeacon("decode", 1);
551→ 551→ trackBeacon("insight", rewards.insights);
552→ 552→ },
553→ 553→
554→ 554→ buyTech: (techId) => {
555→ 555→ const s = get();
556→ 556→ const node = TECH_TREE.find((t) => t.id === techId);
557→ 557→ if (!node) return false;
558→ 558→ const cur = s.tech[techId] ?? 0;
559→ 559→ if (cur >= 1) return false; // v0.1 每节点 1 级
560→ 560→ if (s.insights < node.cost) return false;
561→ 561→ const newTech = { ...s.tech, [techId]: 1 };
562→ 562→ set({
563→ 563→ insights: s.insights - node.cost,
564→ 564→ tech: newTech,
565→ 565→ ...syncStats({ tech: newTech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation }),
566→ 566→ });
567→ 567→ return true;
568→ 568→ },
569→ 569→
570→ 570→ // ============ 探险系统 ============
571→ 571→ startExpedition: () => {
572→ 572→ const s = get();
573→ 573→ if (s.activeExpedition && !s.activeExpedition.finished) {
574→ 574→ return { ok: false, reason: "已有进行中的探险" };
575→ 575→ }
576→ 576→ if (s.energy < EXPEDITION_CONFIG.energyCost) {
577→ 577→ return { ok: false, reason: "能量不足" };
578→ 578→ }
579→ 579→ const tideMod = getTideModifiers(s.activeTide);
580→ 580→ const power = computeExpeditionPower(s) + tideMod.expeditionPowerBonus;
581→ 581→ const hp = computeExpeditionHp(s) + tideMod.expeditionHpBonus;
582→ 582→ const seed = Math.floor(Math.random() * 1e9);
583→ 583→ const expedition = generateExpedition(seed, power, hp);
584→ 584→ set({
585→ 585→ activeExpedition: expedition,
586→ 586→ energy: s.energy - EXPEDITION_CONFIG.energyCost,
587→ 587→ totalExpeditions: s.totalExpeditions + 1,
588→ 588→ });
589→ 589→ return { ok: true };
590→ 590→ },
591→ 591→
592→ 592→ resolveCurrentNode: () => {
593→ 593→ const s = get();
594→ 594→ if (!s.activeExpedition || s.activeExpedition.finished) return null;
595→ 595→ // 深拷贝
596→ 596→ const exp = JSON.parse(JSON.stringify(s.activeExpedition));
597→ 597→ const result = resolveNode(exp);
598→ 598→ // 累计奖励
599→ 599→ if (result.crystals) exp.rewards.crystals += result.crystals;
600→ 600→ if (result.insights) exp.rewards.insights += result.insights;
601→ 601→ if (result.contact) exp.rewards.contact += result.contact;
602→ 602→ if (result.fragments) exp.rewards.fragments.push(...result.fragments);
603→ 603→ if (result.hpDelta) exp.hp = Math.max(0, Math.min(exp.maxHp, exp.hp + result.hpDelta));
604→ 604→ // 实时入账(玩家立即获得)
605→ 605→ const newCrystals = s.crystals + (result.crystals || 0);
606→ 606→ const newInsights = s.insights + (result.insights || 0);
607→ 607→ const newContact = Math.min(100, s.contact + (result.contact || 0));
608→ 608→ // 碎片解锁
609→ 609→ const newFragments = { ...s.fragments };
610→ 610→ if (result.fragments) {
611→ 611→ for (const fid of result.fragments) newFragments[fid] = true;
612→ 612→ }
613→ 613→ // 日志
614→ 614→ const logEntry = {
615→ 615→ expeditionId: exp.id,
616→ 616→ nodeType: exp.nodes[exp.currentNode]?.type || "combat",
617→ 617→ result: result.log,
618→ 618→ rewards: [
619→ 619→ result.crystals ? `+${result.crystals}晶体` : "",
620→ 620→ result.insights ? `+${result.insights}洞见` : "",
621→ 621→ result.contact ? `+${result.contact.toFixed(1)}接触` : "",
622→ 622→ result.hpDelta ? `${result.hpDelta > 0 ? "+" : ""}${result.hpDelta}生命` : "",
623→ 623→ ].filter(Boolean).join(" "),
624→ 624→ timestamp: Date.now(),
625→ 625→ };
626→ 626→ const newLog = [logEntry, ...s.expeditionLog].slice(0, 30);
627→ 627→
628→ 628→ if (result.ended) {
629→ 629→ // 探险结束(胜利或失败)
630→ 630→ exp.finished = true;
631→ 631→ }
632→ 632→
633→ 633→ // v0.4 编年史:击破 BOSS 时累计计数
634→ 634→ let bossKills = s.bossKills ?? 0;
635→ 635→ if (
636→ 636→ result.ended &&
637→ 637→ result.endReason === "victory" &&
638→ 638→ exp.nodes[exp.currentNode]?.type === "boss"
639→ 639→ ) {
640→ 640→ bossKills = bossKills + 1;
641→ 641→ }
642→ 642→
643→ 643→ set({
644→ 644→ activeExpedition: exp,
645→ 645→ crystals: newCrystals,
646→ 646→ insights: newInsights,
647→ 647→ contact: newContact,
648→ 648→ fragments: newFragments,
649→ 649→ expeditionLog: newLog,
650→ 650→ bossKills,
651→ 651→ });
652→ 652→ // 深空信标:探险完成(胜负都计)+ 洞见累计 + BOSS 击破
653→ 653→ if (result.ended) {
654→ 654→ trackBeacon("expedition", 1);
655→ 655→ if (result.endReason === "victory" && exp.nodes[exp.currentNode]?.type === "boss") {
656→ 656→ trackBeacon("boss", 1);
657→ 657→ }
658→ 658→ }
659→ 659→ if (result.insights) trackBeacon("insight", result.insights);
660→ 660→ return result;
661→ 661→ },
662→ 662→
663→ 663→ advanceNode: () => {
664→ 664→ const s = get();
665→ 665→ if (!s.activeExpedition || s.activeExpedition.finished) return;
666→ 666→ const exp = JSON.parse(JSON.stringify(s.activeExpedition));
667→ 667→ const node = exp.nodes[exp.currentNode];
668→ 668→ if (!node || !node.cleared) return; // 当前节点未结算不能前进
669→ 669→ if (exp.currentNode >= exp.nodes.length - 1) return;
670→ 670→ exp.currentNode++;
671→ 671→ set({ activeExpedition: exp });
672→ 672→ },
673→ 673→
674→ 674→ abortExpedition: () => {
675→ 675→ const s = get();
676→ 676→ if (!s.activeExpedition) return;
677→ 677→ const exp = JSON.parse(JSON.stringify(s.activeExpedition));
678→ 678→ exp.finished = true;
679→ 679→ const logEntry = {
680→ 680→ expeditionId: exp.id,
681→ 681→ nodeType: "rest" as const,
682→ 682→ result: "探险队主动撤退,保留已获奖励。",
683→ 683→ rewards: "",
684→ 684→ timestamp: Date.now(),
685→ 685→ };
686→ 686→ set({
687→ 687→ activeExpedition: exp,
688→ 688→ expeditionLog: [logEntry, ...s.expeditionLog].slice(0, 30),
689→ 689→ });
690→ 690→ },
691→ 691→
692→ 692→ doPrestige: () => {
693→ 693→ const s = get();
694→ 694→ if (s.contact < CONTACT.prestigeMin) return null;
695→ 695→ const newBp = computeNewBlueprints(s);
696→ 696→ const next = performPrestige(s);
697→ 697→ // 星图「能量共振」提升上限
698→ 698→ const cm = constellationBonuses(next.constellation ?? []);
699→ 699→ const energyMax = INITIAL_STATE.energyMax + cm.energyMaxBonus;
700→ 700→ set({
701→ 701→ ...next,
702→ 702→ energyMax,
703→ 703→ ...syncStats({ tech: next.tech, blueprints: next.blueprints, achievements: next.achievements, activeTide: next.activeTide, constellation: next.constellation }),
704→ 704→ _lastAutoDecode: Date.now(),
705→ 705→ _lastSpawn: Date.now(),
706→ 706→ _combo: 0,
707→ 707→ _lastPulse: 0,
708→ 708→ _tideEvents: [],
709→ 709→ });
710→ 710→ return { newBp };
711→ 711→ },
712→ 712→
713→ 713→ chooseConstellationPerk: (perkId) => {
714→ 714→ const s = get();
715→ 715→ if (!s.pendingPerkChoices || !s.pendingPerkChoices.includes(perkId)) return false;
716→ 716→ const perk = getPerk(perkId);
717→ 717→ if (!perk) return false;
718→ 718→ if (s.constellation?.includes(perkId)) return false;
719→ 719→ const newConstellation = [...(s.constellation ?? []), perkId];
720→ 720→ const cm = constellationBonuses(newConstellation);
721→ 721→ const energyMax = INITIAL_STATE.energyMax + cm.energyMaxBonus;
722→ 722→ // v0.4 编年史:回填最近一条 entry 的 perksThisAscension
723→ 723→ const chronicle = s.chronicle ?? [];
724→ 724→ let newChronicle = chronicle;
725→ 725→ if (chronicle.length > 0) {
726→ 726→ const lastEntry = chronicle[chronicle.length - 1];
727→ 727→ const updatedLast = withPerks(lastEntry, [perkId]);
728→ 728→ newChronicle = [...chronicle.slice(0, -1), updatedLast];
729→ 729→ }
730→ 730→ set({
731→ 731→ constellation: newConstellation,
732→ 732→ pendingPerkChoices: null,
733→ 733→ energyMax,
734→ 734→ chronicle: newChronicle,
735→ 735→ ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: newConstellation }),
736→ 736→ });
737→ 737→ return true;
738→ 738→ },
739→ 739→
740→ 740→ rerollPerkChoices: () => {
741→ 741→ const s = get();
742→ 742→ if (!s.pendingPerkChoices) return;
743→ 743→ const choices = rollPerkChoices(s.constellation ?? []);
744→ 744→ if (choices.length > 0) set({ pendingPerkChoices: choices });
745→ 745→ },
746→ 746→
747→ 747→ checkAchievements: () => {
748→ 748→ const s = get();
749→ 749→ const newlyUnlocked: Achievement[] = [];
750→ 750→ const updated = { ...s.achievements };
751→ 751→ let crystals = s.crystals;
752→ 752→ let insights = s.insights;
753→ 753→ let contact = s.contact;
754→ 754→ let statsDirty = false;
755→ 755→ for (const a of ACHIEVEMENTS) {
756→ 756→ if (updated[a.id]) continue;
757→ 757→ if (a.check(s)) {
758→ 758→ updated[a.id] = true;
759→ 759→ newlyUnlocked.push(a);
760→ 760→ // 发放即时奖励
761→ 761→ if (a.reward.crystals) crystals += a.reward.crystals;
762→ 762→ if (a.reward.insights) insights += a.reward.insights;
763→ 763→ if (a.reward.contact) contact = Math.min(100, contact + a.reward.contact);
764→ 764→ if (a.reward.crystalsPerSecPct || a.reward.insightPct) statsDirty = true;
765→ 765→ }
766→ 766→ }
767→ 767→ if (newlyUnlocked.length === 0) return [];
768→ 768→ set({
769→ 769→ achievements: updated,
770→ 770→ crystals,
771→ 771→ insights,
772→ 772→ contact,
773→ 773→ ...(statsDirty
774→ 774→ ? syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: updated, activeTide: s.activeTide, constellation: s.constellation })
775→ 775→ : {}),
776→ 776→ _achievementQueue: [...s._achievementQueue, ...newlyUnlocked],
777→ 777→ });
778→ 778→ return newlyUnlocked;
779→ 779→ },
780→ 780→
781→ 781→ consumeAchievementQueue: () => {
782→ 782→ const s = get();
783→ 783→ if (s._achievementQueue.length === 0) return [];
784→ 784→ const items = s._achievementQueue;
785→ 785→ set({ _achievementQueue: [] });
786→ 786→ return items;
787→ 787→ },
788→ 788→
789→ 789→ canPrestige: () => get().contact >= CONTACT.prestigeMin,
790→ 790→
791→ 791→ toggleTheme: () => set({ theme: get().theme === "dark" ? "light" : "dark" }),
792→ 792→ toggleSound: () => set({ soundOn: !get().soundOn }),
793→ 793→
794→ 794→ // 深空信标:发放每日挑战奖励(v0.5)
795→ 795→ grantBeaconReward: (insights, contact) => {
796→ 796→ const s = get();
797→ 797→ set({
798→ 798→ insights: s.insights + Math.round(insights),
799→ 799→ contact: Math.min(100, s.contact + contact),
800→ 800→ });
801→ 801→ },
802→ 802→
803→ 803→ // 深空巡航:发放实时玩法奖励(v0.6)
804→ 804→ grantCruiseReward: (rewards) => {
805→ 805→ const s = get();
806→ 806→ const addCrystals = rewards.crystals ?? 0;
807→ 807→ const addInsights = rewards.insights ?? 0;
808→ 808→ const addContact = rewards.contact ?? 0;
809→ 809→ set({
810→ 810→ crystals: Math.min(s.crystalCap, s.crystals + addCrystals),
811→ 811→ insights: s.insights + Math.round(addInsights),
812→ 812→ contact: Math.min(100, s.contact + addContact),
813→ 813→ });
814→ 814→ },
815→ 815→ }),
816→ 816→ {
817→ 817→ name: "echo-nexus-save-v1",
818→ 818→ storage: createJSONStorage(() => localStorage),
819→ 819→ // 不持久化临时字段
820→ 820→ partialize: (s) => {
821→ 821→ const { _lastAutoDecode, _lastSpawn, _combo, _lastPulse, _achievementQueue, _tideEvents, ...rest } = s;
822→ 822→ void _lastAutoDecode; void _lastSpawn; void _combo; void _lastPulse; void _achievementQueue; void _tideEvents;
823→ 823→ return rest as GameState;
824→ 824→ },
825→ 825→ }
826→ 826→ )
827→ 827→);
828→ 828→
829→ 829→/** 选择器:未解锁碎片中下一个门槛 */
830→ 830→export function nextFragmentThreshold(totalDecoded: number): number | null {
831→ 831→ for (const f of FRAGMENTS) {
832→ 832→ if (totalDecoded < f.threshold) return f.threshold;
833→ 833→ }
834→ 834→ return null;
835→ 835→}
836→ 836→
837→ 837→export { FRAGMENTS, PRESTIGE, TECH_TREE };
838→ 838→
839→ 839→// 开发期调试:暴露 store 到 window,便于 QA 测试
840→ 840→if (typeof window !== "undefined" && process.env.NODE_ENV !== "production") {
841→ 841→ (window as unknown as { __gameStore?: typeof useGameStore }).__gameStore = useGameStore;
842→ 842→}
843→ 843→