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

1180 lines
56 KiB
Plaintext
Executable File
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→"use client";
2→// 回响星核 / Echo Nexus — Zustand 游戏状态管理
3→import { create } from "zustand";
4→import { persist, createJSONStorage } from "zustand/middleware";
5→import type {
6→ GameState,
7→ Crystal,
8→ CrystalTier,
9→ DecodePuzzle,
10→ ExpeditionResult,
11→} from "@/lib/game/types";
12→import {
13→ INITIAL_STATE,
14→ TECH_TREE,
15→ CRYSTAL_VALUE,
16→ CONTACT,
17→ CRYSTAL_SPAWN,
18→ FRAGMENTS,
19→ PRESTIGE,
20→} from "@/lib/game/config";
21→import {
22→ createInitialState,
23→ recomputeStats,
24→ decodeRewards,
25→ rollCrystalTierWithBonus,
26→ computeNewBlueprints,
27→ performPrestige,
28→} from "@/lib/game/engine";
29→import {
30→ generatePuzzle,
31→ tryClickNode,
32→ isSolvable,
33→ resetPuzzle as resetPuz,
34→} from "@/lib/game/decode";
35→import {
36→ generateExpedition,
37→ resolveNode,
38→ advanceExpedition,
39→ computeExpeditionPower,
40→ computeExpeditionHp,
41→ computeEnergyRegen,
42→ computeEnergyRegenInterval,
43→ EXPEDITION_CONFIG,
44→} from "@/lib/game/expedition";
45→import { ACHIEVEMENTS, type Achievement } from "@/lib/game/achievements";
46→import {
47→ TIDE_CONFIG,
48→ rollTide,
49→ getTideModifiers,
50→ computeSilenceCompensation,
51→ type StarTide,
52→ type TideType,
53→} from "@/lib/game/starTide";
54→import {
55→ getPerk,
56→ constellationBonuses,
57→ rollPerkChoices,
58→} from "@/lib/game/constellation";
59→import {
60→ migrateChronicleFields,
61→ withPerks,
62→} from "@/lib/game/chronicle";
63→import {
64→ generateDailyChallenge,
65→ generateWeeklyChallenge,
66→ loadDailyProgress,
67→ loadWeeklyProgress,
68→ loadChainState,
69→ addBeaconProgress,
70→ addWeeklyProgress,
71→ recordChainCompletion,
72→ claimWeeklyReward,
73→ claimChainMilestone,
74→ getTodayKey,
75→ type BeaconDailyChallenge,
76→ type BeaconDailyProgress,
77→ type BeaconWeeklyChallenge,
78→ type BeaconWeeklyProgress,
79→} from "@/lib/game/beacon";
80→import { setPendingOfflineReport } from "@/lib/game/offlineReport";
81→import {
82→ ATTRIBUTE_HARD_CAP,
83→ migrateAttributes,
84→ levelUpCheck,
85→ getAllBonuses,
86→ createInitialAttributes,
87→ createInitialAttributeProgress,
88→ type AttributeKey,
89→ type CharacterAttributes,
90→ type AttributeProgress,
91→} from "@/lib/game/attributes";
92→
93→interface GameActions {
94→ // 生命周期
95→ init: () => void;
96→ loadOnline: () => void;
97→ hardReset: () => void;
98→
99→ // 主循环
100→ tick: (now: number) => void;
101→ pulse: () => { gain: number; combo: number } | null;
102→
103→ // 星潮
104→ tickTide: (now: number) => { started?: TideType; ended?: TideType; silenceCompensation?: number } | null;
105→ consumeTideEvents: () => { started?: TideType; ended?: TideType; silenceCompensation?: number }[];
106→
107→ // 解码
108→ startDecode: (crystalId: string) => void;
109→ clickNode: (nodeId: number) => { ok: boolean; finished: boolean; solvable?: boolean; failReason?: string };
110→ undoStep: () => void;
111→ retryPuzzle: () => void;
112→ abandonPuzzle: () => void;
113→ /** 自动解码 T1(技术解锁后由 tick 调用) */
114→ autoDecodeTick: () => void;
115→
116→ // 探险
117→ startExpedition: () => { ok: boolean; reason?: string };
118→ resolveCurrentNode: () => ExpeditionResult | null;
119→ advanceNode: () => void;
120→ abortExpedition: () => void;
121→
122→ // 技术
123→ buyTech: (techId: string) => boolean;
124→
125→ // 飞升
126→ doPrestige: () => { newBp: number } | null;
127→
128→ // 星图天文台
129→ chooseConstellationPerk: (perkId: string) => boolean;
130→ rerollPerkChoices: () => void;
131→
132→ // 成就
133→ checkAchievements: () => Achievement[];
134→ consumeAchievementQueue: () => Achievement[];
135→
136→ // 设置
137→ toggleTheme: () => void;
138→ toggleSound: () => void;
139→
140→ // 深空信标奖励发放(v0.5)
141→ grantBeaconReward: (insights: number, contact: number) => void;
142→
143→ // 深空信标 · 周挑战领取 + 信标链里程碑领取(v0.8)
144→ claimWeeklyBeacon: () => {
145→ rewardInsight: number;
146→ rewardContact: number;
147→ score: number;
148→ };
149→ claimChainReward: (milestone: number) => {
150→ rewardInsight: number;
151→ rewardContact: number;
152→ label: string;
153→ ok: boolean;
154→ };
155→
156→ // 深空巡航奖励发放(v0.6 — 实时 Canvas 玩法)
157→ grantCruiseReward: (rewards: { crystals?: number; insights?: number; contact?: number }) => void;
158→
159→ // 角色属性(v0.7 P1
160→ allocateAttribute: (attr: AttributeKey, points?: number) => { ok: boolean; leveledUp?: number };
161→ gainAttributeExp: (attr: AttributeKey, amount: number) => { leveledUp: number; newLevel: number };
162→
163→ // 派生
164→ canPrestige: () => boolean;
165→}
166→
167→type Store = GameState & GameActions & {
168→ _lastAutoDecode: number;
169→ _lastSpawn: number;
170→ _combo: number;
171→ _lastPulse: number;
172→ _achievementQueue: Achievement[];
173→ _tideEvents: { started?: TideType; ended?: TideType; silenceCompensation?: number }[];
174→};
175→
176→/** 计算并写回产能字段 */
177→function syncStats(state: Partial<GameState>) {
178→ const s = recomputeStats(state);
179→ return {
180→ crystalsPerSec: s.crystalsPerSec,
181→ crystalCap: s.crystalCap,
182→ pulsePower: s.pulsePower,
183→ offlineEff: s.offlineEff,
184→ insightMult: s.insightMult,
185→ contactRateMult: s.contactRateMult,
186→ autoDecode: s.autoDecode,
187→ decodeStepsBonus: s.decodeStepsBonus,
188→ };
189→}
190→
191→/**
192→ * 深空信标进度追踪(v0.5 → v0.8 扩展)。
193→ * 在游戏关键动作(脉冲/解码/探险/BOSS/洞见)后调用,同时更新:
194→ * 1. 日挑战进度(按今日挑战类型增量)
195→ * 2. 周挑战进度(按本周挑战类型增量)
196→ * 3. 信标链:日挑战刚完成时记录一次完成(含 grace 续命逻辑)
197→ * 进度独立存储于 localStorage,不污染 GameState。
198→ * @returns 三类状态变更供 UI 触发通知
199→ */
200→function trackBeacon(
201→ type: "pulse" | "decode" | "expedition" | "boss" | "insight",
202→ delta: number
203→): {
204→ dailyJustCompleted: boolean;
205→ weeklyJustCompleted: boolean;
206→ newChainMilestones: number[];
207→} {
208→ const result = {
209→ dailyJustCompleted: false,
210→ weeklyJustCompleted: false,
211→ newChainMilestones: [] as number[],
212→ };
213→ if (typeof window === "undefined") return result;
214→ try {
215→ // ---- 日挑战 ----
216→ const challenge: BeaconDailyChallenge = generateDailyChallenge();
217→ if (challenge.type === type) {
218→ const current: BeaconDailyProgress = loadDailyProgress();
219→ if (current.completedAt === null) {
220→ const { justCompleted } = addBeaconProgress(current, challenge, delta);
221→ result.dailyJustCompleted = justCompleted;
222→ // 日挑战刚完成 → 更新信标链
223→ if (justCompleted) {
224→ const { newMilestones } = recordChainCompletion(getTodayKey());
225→ result.newChainMilestones = newMilestones;
226→ }
227→ }
228→ }
229→
230→ // ---- 周挑战 ----
231→ const wChallenge: BeaconWeeklyChallenge = generateWeeklyChallenge();
232→ if (wChallenge.type === type) {
233→ const wCurrent: BeaconWeeklyProgress = loadWeeklyProgress();
234→ if (wCurrent.completedAt === null) {
235→ const { justCompleted } = addWeeklyProgress(
236→ wCurrent,
237→ wChallenge,
238→ delta
239→ );
240→ result.weeklyJustCompleted = justCompleted;
241→ }
242→ }
243→
244→ return result;
245→ } catch {
246→ return result;
247→ }
248→}
249→
250→/** 检查并解锁叙事碎片 */
251→function checkFragments(state: GameState): string[] {
252→ const unlocked: string[] = [];
253→ for (const f of FRAGMENTS) {
254→ if (!state.fragments[f.id] && state.totalDecoded >= f.threshold) {
255→ state.fragments[f.id] = true;
256→ unlocked.push(f.id);
257→ }
258→ }
259→ return unlocked;
260→}
261→
262→export const useGameStore = create<Store>()(
263→ persist(
264→ (set, get) => ({
265→ ...createInitialState(),
266→ _lastAutoDecode: Date.now(),
267→ _lastSpawn: Date.now(),
268→ _combo: 0,
269→ _lastPulse: 0,
270→ _achievementQueue: [],
271→ _tideEvents: [],
272→
273→ init: () => {
274→ const s = get();
275→ const now = Date.now();
276→ // 兼容旧存档:若存在不可解的活跃谜题(旧算法生成),直接清掉
277→ let activePuzzle = s.activePuzzle;
278→ if (activePuzzle && !isSolvable(activePuzzle)) {
279→ // 把晶体放回队列,避免玩家卡死
280→ const crystal: Crystal = {
281→ id: `c_${now}_rec`,
282→ tier: activePuzzle.tier,
283→ value: CRYSTAL_VALUE[activePuzzle.tier].crystals,
284→ createdAt: now,
285→ };
286→ activePuzzle = null;
287→ set({
288→ pendingCrystals: [...s.pendingCrystals, crystal].slice(-CRYSTAL_SPAWN.maxPending),
289→ });
290→ }
291→ // 兼容旧存档:补全 achievements / 星潮 / 星图 字段
292→ const achievements = s.achievements ?? {};
293→ const activeTide = s.activeTide ?? null;
294→ const constellation = s.constellation ?? [];
295→ const pendingPerkChoices = s.pendingPerkChoices ?? null;
296→ // v0.4 编年史兼容:补全 chronicle / runStart / bossKills / starTidesEncountered
297→ const migrated = migrateChronicleFields(s);
298→ // v0.7 角色属性兼容:补全 attributes / attributeProgress / pendingAttrPoints
299→ const attrMigrated = migrateAttributes(s);
300→ // 星图「能量共振」天赋 +1 能量上限
301→ const cm = constellationBonuses(constellation);
302→ const energyMax = (INITIAL_STATE.energyMax) + cm.energyMaxBonus;
303→ // lastTideEnd:0 表示从未触发过星潮(首次游玩),保留 0 让 firstDelay 生效
304→ const lastTideEndRaw = s.lastTideEnd ?? 0;
305→ // 若旧存档有已过期的星潮,清掉
306→ const tide = activeTide && activeTide.endsAt > now ? activeTide : null;
307→ // 首次进入:补发离线收益
308→ const elapsed = Math.max(0, (now - s.lastTick) / 1000);
309→ if (elapsed > 5) {
310→ const cap = 8 * 3600;
311→ const secs = Math.min(elapsed, cap);
312→ const gain = s.crystalsPerSec * secs * s.offlineEff;
313→ const crystalsBefore = s.crystals;
314→ const crystalsAfter = Math.min(s.crystalCap, s.crystals + gain);
315→ // 设置离线报告(v0.5.2):通知 UI 弹出"欢迎回来"对话框
316→ setPendingOfflineReport({
317→ elapsedSec: secs,
318→ rawElapsedSec: elapsed,
319→ gain: crystalsAfter - crystalsBefore,
320→ rate: s.crystalsPerSec,
321→ eff: s.offlineEff,
322→ capped: elapsed > cap,
323→ crystalsBefore,
324→ crystalsAfter,
325→ crystalCap: s.crystalCap,
326→ });
327→ set({
328→ crystals: crystalsAfter,
329→ lastTick: now,
330→ activePuzzle,
331→ achievements,
332→ activeTide: tide,
333→ lastTideEnd: tide ? lastTideEndRaw : lastTideEndRaw,
334→ constellation,
335→ pendingPerkChoices,
336→ energyMax,
337→ chronicle: migrated.chronicle,
338→ runStart: migrated.runStart,
339→ bossKills: migrated.bossKills,
340→ starTidesEncountered: migrated.starTidesEncountered,
341→ attributes: attrMigrated.attributes,
342→ attributeProgress: attrMigrated.attributeProgress,
343→ pendingAttrPoints: attrMigrated.pendingAttrPoints,
344→ ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements, activeTide: tide, constellation, attributes: attrMigrated.attributes }),
345→ });
346→ } else {
347→ 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 }) });
348→ }
349→ },
350→
351→ loadOnline: () => {
352→ const s = get();
353→ set({ ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation, attributes: s.attributes }) });
354→ },
355→
356→ hardReset: () => {
357→ set({ ...createInitialState(), _lastAutoDecode: Date.now(), _lastSpawn: Date.now(), _combo: 0, _lastPulse: 0, _achievementQueue: [], _tideEvents: [] });
358→ },
359→
360→ tickTide: (now) => {
361→ const s = get();
362→ const tide = s.activeTide;
363→ // 星图「星潮引导」减少间隙
364→ const cm = constellationBonuses(s.constellation ?? []);
365→ // v0.7 灵感:星潮触发概率 +X%(缩短间隙)
366→ const am = getAllBonuses(s.attributes ?? {});
367→ const tideGapReduction = Math.min(0.3, am.tideTriggerBonus);
368→ const gap = Math.max(15000, (TIDE_CONFIG.gap + cm.tideGapDeltaSec * 1000) * (1 - tideGapReduction));
369→ // 1) 检查当前星潮是否结束
370→ if (tide && now >= tide.endsAt) {
371→ const endedType = tide.type;
372→ // 寂静期补偿洞见
373→ let silenceCompensation = 0;
374→ if (tide.type === "silence") {
375→ silenceCompensation = computeSilenceCompensation(tide);
376→ }
377→ const newInsights = s.insights + silenceCompensation;
378→ const event: { started?: TideType; ended?: TideType; silenceCompensation?: number } = { ended: endedType };
379→ if (silenceCompensation > 0) event.silenceCompensation = silenceCompensation;
380→ set({
381→ activeTide: null,
382→ lastTideEnd: now,
383→ insights: newInsights,
384→ // 星潮结束后重算 stats(移除 contactRate/insight 修饰)
385→ ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: null, constellation: s.constellation, attributes: s.attributes }),
386→ _tideEvents: [...s._tideEvents, event],
387→ });
388→ return event;
389→ }
390→ // 2) 检查是否该触发新星潮(间隙已过)
391→ if (!tide) {
392→ const since = now - s.lastTideEnd;
393→ // 首次:以 createdAt 起算 firstDelay;之后以 lastTideEnd 起算 gap
394→ const firstStart = s.lastTideEnd === 0 || s.lastTideEnd <= s.createdAt + 1000;
395→ const need = firstStart ? TIDE_CONFIG.firstDelay : gap;
396→ if (since >= need) {
397→ const type = rollTide();
398→ const newTide: StarTide = {
399→ id: `tide_${now}_${Math.random().toString(36).slice(2, 7)}`,
400→ type,
401→ startedAt: now,
402→ endsAt: now + TIDE_CONFIG.duration,
403→ };
404→ const event: { started?: TideType; ended?: TideType; silenceCompensation?: number } = { started: type };
405→ // v0.4 编年史:累计遇到的星潮 ID(去重)
406→ const tidesAll = s.starTidesEncountered ?? [];
407→ const tideId = `tide_${type}`;
408→ const newTidesAll = tidesAll.includes(tideId) ? tidesAll : [...tidesAll, tideId];
409→ set({
410→ activeTide: newTide,
411→ starTidesEncountered: newTidesAll,
412→ // 星潮开始后重算 stats(应用 contactRate/insight 修饰)
413→ ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: newTide, constellation: s.constellation, attributes: s.attributes }),
414→ _tideEvents: [...s._tideEvents, event],
415→ });
416→ return event;
417→ }
418→ }
419→ return null;
420→ },
421→
422→ consumeTideEvents: () => {
423→ const s = get();
424→ if (s._tideEvents.length === 0) return [];
425→ const items = s._tideEvents;
426→ set({ _tideEvents: [] });
427→ return items;
428→ },
429→
430→ tick: (now) => {
431→ const s = get();
432→ const dt = Math.max(0, (now - s.lastTick) / 1000);
433→ if (dt <= 0) return;
434→
435→ // 星潮产能修饰(即时乘)
436→ const tideMod = getTideModifiers(s.activeTide);
437→ const effCps = s.crystalsPerSec * tideMod.crystalsPerSecMult;
438→ // 产能累加(仅闲置产能受仓库上限;探险奖励可超出)
439→ const newCrystals =
440→ s.crystals >= s.crystalCap
441→ ? s.crystals // 已达/超上限,不再自动产出
442→ : Math.min(s.crystalCap, s.crystals + effCps * dt);
443→
444→ // 自动产出待解码晶体(每 baseInterval 秒一颗,受技术/飞升略提速)
445→ const bpBoost = 1 + s.blueprints.length * 0.03;
446→ const spawnInterval = (CRYSTAL_SPAWN.baseIntervalSec / bpBoost) * 1000;
447→ let pending = s.pendingCrystals;
448→ let lastSpawn = s._lastSpawn;
449→ if (
450→ now - lastSpawn > spawnInterval &&
451→ pending.length < CRYSTAL_SPAWN.maxPending
452→ ) {
453→ // 星图「晶体富集」提升 T2/T3 概率
454→ const cm = constellationBonuses(s.constellation ?? []);
455→ const tier = rollCrystalTierWithBonus(cm.t2t3BonusRate);
456→ const crystal: Crystal = {
457→ id: `c_${now}_${Math.random().toString(36).slice(2, 7)}`,
458→ tier,
459→ value: CRYSTAL_VALUE[tier].crystals,
460→ createdAt: now,
461→ };
462→ pending = [...pending, crystal];
463→ lastSpawn = now;
464→ }
465→
466→ // 能量恢复(探险系统,v0.8.1 动态间隔)
467→ let energy = s.energy;
468→ let lastEnergyTick = s.lastEnergyTick;
469→ if (energy < s.energyMax) {
470→ const intervalSec = computeEnergyRegenInterval(s);
471→ const regen = computeEnergyRegen(lastEnergyTick, now, energy, s.energyMax, intervalSec);
472→ energy = regen.energy;
473→ lastEnergyTick = regen.lastTick;
474→ } else {
475→ lastEnergyTick = now;
476→ }
477→
478→ set({
479→ crystals: newCrystals,
480→ lastTick: now,
481→ pendingCrystals: pending,
482→ _lastSpawn: lastSpawn,
483→ energy,
484→ lastEnergyTick,
485→ });
486→ },
487→
488→ pulse: () => {
489→ const s = get();
490→ const now = Date.now();
491→ // 连击
492→ let combo = 1;
493→ if (now - s._lastPulse < 1500) {
494→ combo = Math.min(10, s._combo + 1);
495→ }
496→ const mult = 1 + (combo - 1) * 0.15;
497→ // 星潮脉冲威力修饰
498→ const tideMod = getTideModifiers(s.activeTide);
499→ // v0.7 灵感:脉冲连击加成 +X%
500→ const am = getAllBonuses(s.attributes ?? {});
501→ const comboBonusMult = 1 + am.pulseComboBonus * Math.max(0, combo - 1);
502→ const gain = s.pulsePower * mult * tideMod.pulsePowerMult * comboBonusMult;
503→ set({
504→ crystals: Math.min(s.crystalCap, s.crystals + gain),
505→ _combo: combo,
506→ _lastPulse: now,
507→ });
508→ // 深空信标:脉冲任务进度 +1
509→ trackBeacon("pulse", 1);
510→ // v0.7 角色属性:连击 ≥3 给灵感经验
511→ if (combo >= 3) {
512→ const expGain = 1 + Math.floor(combo / 2); // 3 连击=2, 5 连击=3, 10 连击=6
513→ // 内联经验获取(避免递归调用 set)
514→ const prog = s.attributeProgress?.inspiration ?? { exp: 0, level: s.attributes?.inspiration ?? 0 };
515→ const nextExp = prog.exp + expGain;
516→ const lvlResult = levelUpCheck(
517→ { exp: nextExp, level: s.attributes?.inspiration ?? 0 },
518→ ATTRIBUTE_HARD_CAP
519→ );
520→ const newAttributes: CharacterAttributes = {
521→ ...(s.attributes ?? createInitialAttributes()),
522→ inspiration: lvlResult.newProgress.level,
523→ };
524→ const newProgress: AttributeProgress = {
525→ ...(s.attributeProgress ?? createInitialAttributeProgress()),
526→ inspiration: lvlResult.newProgress,
527→ };
528→ set({
529→ attributes: newAttributes,
530→ attributeProgress: newProgress,
531→ ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation, attributes: newAttributes }),
532→ });
533→ }
534→ return { gain, combo };
535→ },
536→
537→ startDecode: (crystalId) => {
538→ const s = get();
539→ const crystal = s.pendingCrystals.find((c) => c.id === crystalId);
540→ if (!crystal) return;
541→ const puzzle = generatePuzzle(crystal.tier);
542→ set({
543→ activePuzzle: puzzle,
544→ pendingCrystals: s.pendingCrystals.filter((c) => c.id !== crystalId),
545→ });
546→ },
547→
548→ clickNode: (nodeId) => {
549→ const s = get();
550→ if (!s.activePuzzle) return { ok: false, finished: false, failReason: "无活跃谜题" };
551→ // 深拷贝谜题
552→ const puzzle: DecodePuzzle = JSON.parse(JSON.stringify(s.activePuzzle));
553→ const res = tryClickNode(puzzle, nodeId);
554→ if (res.ok) {
555→ if (res.finished) {
556→ // 结算奖励(星潮解码奖励修饰 + 星图「二周目经验」)
557→ const tideMod = getTideModifiers(s.activeTide);
558→ const cm = constellationBonuses(s.constellation ?? []);
559→ const base = decodeRewards(puzzle.tier, s.insightMult, s.contactRateMult);
560→ const finalMult = tideMod.decodeRewardMult * cm.decodeRewardMult;
561→ const rewards = {
562→ crystals: Math.round(base.crystals * finalMult),
563→ insights: Math.round(base.insights * finalMult),
564→ contact: +(base.contact * finalMult).toFixed(2),
565→ };
566→ const newTotal = s.totalDecoded + 1;
567→ const newContact = Math.min(100, s.contact + rewards.contact);
568→ const newInsights = s.insights + rewards.insights;
569→ const newCrystals = s.crystals + rewards.crystals;
570→ // 解锁碎片
571→ const tentative: GameState = { ...s, totalDecoded: newTotal, fragments: { ...s.fragments } };
572→ const unlocked = checkFragments(tentative);
573→ // v0.7 角色属性:完成解码给智慧经验(tier 越高经验越多)
574→ const wisdomExpGain = puzzle.tier * 2;
575→ const curAttrs = s.attributes ?? createInitialAttributes();
576→ const curProg = s.attributeProgress ?? createInitialAttributeProgress();
577→ const progEntry = curProg.wisdom ?? { exp: 0, level: curAttrs.wisdom };
578→ const wisdomLvl = levelUpCheck(
579→ { exp: progEntry.exp + wisdomExpGain, level: curAttrs.wisdom },
580→ ATTRIBUTE_HARD_CAP
581→ );
582→ const newAttributes: CharacterAttributes = {
583→ ...curAttrs,
584→ wisdom: wisdomLvl.newProgress.level,
585→ };
586→ const newProgress: AttributeProgress = {
587→ ...curProg,
588→ wisdom: wisdomLvl.newProgress,
589→ };
590→ set({
591→ activePuzzle: null,
592→ crystals: newCrystals,
593→ insights: newInsights,
594→ contact: newContact,
595→ totalDecoded: newTotal,
596→ fragments: tentative.fragments,
597→ attributes: newAttributes,
598→ attributeProgress: newProgress,
599→ ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation, attributes: newAttributes }),
600→ });
601→ // 深空信标:解码 +1,洞见累计
602→ trackBeacon("decode", 1);
603→ trackBeacon("insight", rewards.insights);
604→ return { ok: true, finished: true, failReason: unlocked.join(",") || undefined };
605→ }
606→ // 点击成功但未完成:检测当前局面是否仍可解
607→ const solvable = isSolvable(puzzle);
608→ set({ activePuzzle: puzzle });
609→ return { ok: true, finished: false, solvable };
610→ }
611→ return res;
612→ },
613→
614→ undoStep: () => {
615→ const s = get();
616→ if (!s.activePuzzle || s.activePuzzle.path.length === 0) return;
617→ const puzzle: DecodePuzzle = JSON.parse(JSON.stringify(s.activePuzzle));
618→ const lastId = puzzle.path.pop();
619→ if (lastId !== undefined) {
620→ const node = puzzle.grid.find((n) => n.id === lastId);
621→ if (node) node.used = false;
622→ }
623→ set({ activePuzzle: puzzle });
624→ },
625→
626→ retryPuzzle: () => {
627→ const s = get();
628→ if (!s.activePuzzle) return;
629→ set({ activePuzzle: resetPuz(s.activePuzzle) });
630→ },
631→
632→ abandonPuzzle: () => {
633→ const s = get();
634→ if (!s.activePuzzle) return;
635→ // 晶体放回队列末尾
636→ const crystal: Crystal = {
637→ id: `c_${Date.now()}_ret`,
638→ tier: s.activePuzzle.tier,
639→ value: CRYSTAL_VALUE[s.activePuzzle.tier].crystals,
640→ createdAt: Date.now(),
641→ };
642→ set({
643→ activePuzzle: null,
644→ pendingCrystals: [...s.pendingCrystals, crystal].slice(-CRYSTAL_SPAWN.maxPending),
645→ });
646→ },
647→
648→ autoDecodeTick: () => {
649→ const s = get();
650→ if (!s.autoDecode) return;
651→ const now = Date.now();
652→ // 星图「自动校准」减少自动解码周期
653→ const cm = constellationBonuses(s.constellation ?? []);
654→ // v0.7 智慧:自动解码周期 -X%
655→ const am = getAllBonuses(s.attributes ?? {});
656→ const baseInterval = 12000 + cm.autoDecodeIntervalDeltaSec * 1000;
657→ const interval = Math.max(5000, baseInterval * am.autoDecodeIntervalMult);
658→ if (now - s._lastAutoDecode < interval) return;
659→ // 找一颗 T1 晶体自动解码
660→ const idx = s.pendingCrystals.findIndex((c) => c.tier === 1);
661→ if (idx < 0) return;
662→ const crystal = s.pendingCrystals[idx];
663→ const tideMod = getTideModifiers(s.activeTide);
664→ const base = decodeRewards(1, s.insightMult, s.contactRateMult);
665→ const finalMult = tideMod.decodeRewardMult * cm.decodeRewardMult;
666→ const rewards = {
667→ crystals: Math.round(base.crystals * finalMult),
668→ insights: Math.round(base.insights * finalMult),
669→ contact: +(base.contact * finalMult).toFixed(2),
670→ };
671→ const newTotal = s.totalDecoded + 1;
672→ const tentative: GameState = { ...s, totalDecoded: newTotal, fragments: { ...s.fragments } };
673→ checkFragments(tentative);
674→ // v0.7 角色属性:自动解码给智慧经验(少量)
675→ const curAttrs = s.attributes ?? createInitialAttributes();
676→ const curProg = s.attributeProgress ?? createInitialAttributeProgress();
677→ const progEntry = curProg.wisdom ?? { exp: 0, level: curAttrs.wisdom };
678→ const wisdomLvl = levelUpCheck(
679→ { exp: progEntry.exp + 1, level: curAttrs.wisdom },
680→ ATTRIBUTE_HARD_CAP
681→ );
682→ const newAttributes: CharacterAttributes = {
683→ ...curAttrs,
684→ wisdom: wisdomLvl.newProgress.level,
685→ };
686→ const newProgress: AttributeProgress = {
687→ ...curProg,
688→ wisdom: wisdomLvl.newProgress,
689→ };
690→ set({
691→ pendingCrystals: s.pendingCrystals.filter((c) => c.id !== crystal.id),
692→ crystals: s.crystals + rewards.crystals,
693→ insights: s.insights + rewards.insights,
694→ contact: Math.min(100, s.contact + rewards.contact),
695→ totalDecoded: newTotal,
696→ fragments: tentative.fragments,
697→ _lastAutoDecode: now,
698→ attributes: newAttributes,
699→ attributeProgress: newProgress,
700→ ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation, attributes: newAttributes }),
701→ });
702→ // 深空信标:自动解码也算进度
703→ trackBeacon("decode", 1);
704→ trackBeacon("insight", rewards.insights);
705→ },
706→
707→ buyTech: (techId) => {
708→ const s = get();
709→ const node = TECH_TREE.find((t) => t.id === techId);
710→ if (!node) return false;
711→ const cur = s.tech[techId] ?? 0;
712→ if (cur >= 1) return false; // v0.1 每节点 1 级
713→ if (s.insights < node.cost) return false;
714→ const newTech = { ...s.tech, [techId]: 1 };
715→ set({
716→ insights: s.insights - node.cost,
717→ tech: newTech,
718→ ...syncStats({ tech: newTech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation, attributes: s.attributes }),
719→ });
720→ return true;
721→ },
722→
723→ // ============ 探险系统 ============
724→ startExpedition: () => {
725→ const s = get();
726→ if (s.activeExpedition && !s.activeExpedition.finished) {
727→ return { ok: false, reason: "已有进行中的探险" };
728→ }
729→ if (s.energy < EXPEDITION_CONFIG.energyCost) {
730→ return { ok: false, reason: "能量不足" };
731→ }
732→ const tideMod = getTideModifiers(s.activeTide);
733→ // v0.7 角色属性:探索力 +X% 探险力,勇气 +X 探险生命
734→ const am = getAllBonuses(s.attributes ?? {});
735→ const basePower = computeExpeditionPower(s) + tideMod.expeditionPowerBonus;
736→ const baseHp = computeExpeditionHp(s) + tideMod.expeditionHpBonus;
737→ const power = Math.round(basePower * am.expeditionPowerMult);
738→ const hp = baseHp + am.expeditionHpBonus;
739→ const seed = Math.floor(Math.random() * 1e9);
740→ const expedition = generateExpedition(seed, power, hp);
741→ set({
742→ activeExpedition: expedition,
743→ energy: s.energy - EXPEDITION_CONFIG.energyCost,
744→ totalExpeditions: s.totalExpeditions + 1,
745→ });
746→ return { ok: true };
747→ },
748→
749→ resolveCurrentNode: () => {
750→ const s = get();
751→ if (!s.activeExpedition || s.activeExpedition.finished) return null;
752→ // 深拷贝
753→ const exp = JSON.parse(JSON.stringify(s.activeExpedition));
754→ // v0.7 勇气:BOSS 战胜率 +X%(动态提高 RNG 阈值)
755→ const nodeBefore = exp.nodes[exp.currentNode];
756→ const isBossNode = nodeBefore?.type === "boss";
757→ const am = getAllBonuses(s.attributes ?? {});
758→ const result = isBossNode
759→ ? resolveNode(exp, () => {
760→ // 单次 rng() 调用:B% 概率返回 0(必胜),其余情况返回 r-B(保持均匀分布)
761→ const r = Math.random();
762→ const b = Math.min(0.95, am.bossWinRateBonus);
763→ return r < b ? 0 : Math.min(1, r - b);
764→ })
765→ : resolveNode(exp);
766→ // 累计奖励
767→ if (result.crystals) exp.rewards.crystals += result.crystals;
768→ if (result.insights) exp.rewards.insights += result.insights;
769→ if (result.contact) exp.rewards.contact += result.contact;
770→ if (result.fragments) exp.rewards.fragments.push(...result.fragments);
771→ if (result.hpDelta) exp.hp = Math.max(0, Math.min(exp.maxHp, exp.hp + result.hpDelta));
772→ // 实时入账(玩家立即获得)
773→ const newCrystals = s.crystals + (result.crystals || 0);
774→ const newInsights = s.insights + (result.insights || 0);
775→ const newContact = Math.min(100, s.contact + (result.contact || 0));
776→ // 碎片解锁
777→ const newFragments = { ...s.fragments };
778→ if (result.fragments) {
779→ for (const fid of result.fragments) newFragments[fid] = true;
780→ }
781→ // 日志
782→ const logEntry = {
783→ expeditionId: exp.id,
784→ nodeType: nodeBefore?.type || "combat",
785→ result: result.log,
786→ rewards: [
787→ result.crystals ? `+${result.crystals}晶体` : "",
788→ result.insights ? `+${result.insights}洞见` : "",
789→ result.contact ? `+${result.contact.toFixed(1)}接触` : "",
790→ result.hpDelta ? `${result.hpDelta > 0 ? "+" : ""}${result.hpDelta}生命` : "",
791→ ].filter(Boolean).join(" "),
792→ timestamp: Date.now(),
793→ };
794→ const newLog = [logEntry, ...s.expeditionLog].slice(0, 30);
795→
796→ if (result.ended) {
797→ // 探险结束(胜利或失败)
798→ exp.finished = true;
799→ }
800→
801→ // v0.4 编年史:击破 BOSS 时累计计数
802→ let bossKills = s.bossKills ?? 0;
803→ let bossKilledThisNode = false;
804→ if (
805→ result.ended &&
806→ result.endReason === "victory" &&
807→ nodeBefore?.type === "boss"
808→ ) {
809→ bossKills = bossKills + 1;
810→ bossKilledThisNode = true;
811→ }
812→
813→ // v0.7 角色属性:战斗胜利给勇气+探索力经验;BOSS 额外奖励
814→ let newAttributes = s.attributes ?? createInitialAttributes();
815→ let newProgress = s.attributeProgress ?? createInitialAttributeProgress();
816→ let statsNeedResync = false;
817→ // 战斗类节点(combat/boss)且胜利:勇气 + 探索力经验
818→ const isCombatVictory =
819→ (nodeBefore?.type === "combat" || nodeBefore?.type === "boss") &&
820→ !result.ended; // 中途战斗胜利(未结束探险)
821→ const isExpeditionVictory =
822→ result.ended && result.endReason === "victory";
823→ if (isCombatVictory || bossKilledThisNode || isExpeditionVictory) {
824→ const courageGain = bossKilledThisNode ? 8 : 2;
825→ const explorationGain = bossKilledThisNode ? 6 : isExpeditionVictory ? 4 : 1;
826→ const courageLvl = levelUpCheck(
827→ { exp: newProgress.courage.exp + courageGain, level: newAttributes.courage },
828→ ATTRIBUTE_HARD_CAP
829→ );
830→ const explLvl = levelUpCheck(
831→ { exp: newProgress.exploration.exp + explorationGain, level: newAttributes.exploration },
832→ ATTRIBUTE_HARD_CAP
833→ );
834→ newAttributes = {
835→ ...newAttributes,
836→ courage: courageLvl.newProgress.level,
837→ exploration: explLvl.newProgress.level,
838→ };
839→ newProgress = {
840→ ...newProgress,
841→ courage: courageLvl.newProgress,
842→ exploration: explLvl.newProgress,
843→ };
844→ statsNeedResync = true;
845→ }
846→
847→ set({
848→ activeExpedition: exp,
849→ crystals: newCrystals,
850→ insights: newInsights,
851→ contact: newContact,
852→ fragments: newFragments,
853→ expeditionLog: newLog,
854→ bossKills,
855→ attributes: newAttributes,
856→ attributeProgress: newProgress,
857→ ...(statsNeedResync
858→ ? syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation, attributes: newAttributes })
859→ : {}),
860→ });
861→ // 深空信标:探险完成(胜负都计)+ 洞见累计 + BOSS 击破
862→ if (result.ended) {
863→ trackBeacon("expedition", 1);
864→ if (bossKilledThisNode) {
865→ trackBeacon("boss", 1);
866→ }
867→ }
868→ if (result.insights) trackBeacon("insight", result.insights);
869→ return result;
870→ },
871→
872→ advanceNode: () => {
873→ const s = get();
874→ if (!s.activeExpedition || s.activeExpedition.finished) return;
875→ const exp = JSON.parse(JSON.stringify(s.activeExpedition));
876→ const node = exp.nodes[exp.currentNode];
877→ if (!node || !node.cleared) return; // 当前节点未结算不能前进
878→ if (exp.currentNode >= exp.nodes.length - 1) return;
879→ exp.currentNode++;
880→ set({ activeExpedition: exp });
881→ },
882→
883→ abortExpedition: () => {
884→ const s = get();
885→ if (!s.activeExpedition) return;
886→ const exp = JSON.parse(JSON.stringify(s.activeExpedition));
887→ exp.finished = true;
888→ const logEntry = {
889→ expeditionId: exp.id,
890→ nodeType: "rest" as const,
891→ result: "探险队主动撤退,保留已获奖励。",
892→ rewards: "",
893→ timestamp: Date.now(),
894→ };
895→ set({
896→ activeExpedition: exp,
897→ expeditionLog: [logEntry, ...s.expeditionLog].slice(0, 30),
898→ });
899→ },
900→
901→ doPrestige: () => {
902→ const s = get();
903→ if (s.contact < CONTACT.prestigeMin) return null;
904→ const newBp = computeNewBlueprints(s);
905→ const next = performPrestige(s);
906→ // 星图「能量共振」提升上限
907→ const cm = constellationBonuses(next.constellation ?? []);
908→ const energyMax = INITIAL_STATE.energyMax + cm.energyMaxBonus;
909→ set({
910→ ...next,
911→ energyMax,
912→ ...syncStats({ tech: next.tech, blueprints: next.blueprints, achievements: next.achievements, activeTide: next.activeTide, constellation: next.constellation, attributes: next.attributes }),
913→ _lastAutoDecode: Date.now(),
914→ _lastSpawn: Date.now(),
915→ _combo: 0,
916→ _lastPulse: 0,
917→ _tideEvents: [],
918→ });
919→ return { newBp };
920→ },
921→
922→ chooseConstellationPerk: (perkId) => {
923→ const s = get();
924→ if (!s.pendingPerkChoices || !s.pendingPerkChoices.includes(perkId)) return false;
925→ const perk = getPerk(perkId);
926→ if (!perk) return false;
927→ if (s.constellation?.includes(perkId)) return false;
928→ const newConstellation = [...(s.constellation ?? []), perkId];
929→ const cm = constellationBonuses(newConstellation);
930→ const energyMax = INITIAL_STATE.energyMax + cm.energyMaxBonus;
931→ // v0.4 编年史:回填最近一条 entry 的 perksThisAscension
932→ const chronicle = s.chronicle ?? [];
933→ let newChronicle = chronicle;
934→ if (chronicle.length > 0) {
935→ const lastEntry = chronicle[chronicle.length - 1];
936→ const updatedLast = withPerks(lastEntry, [perkId]);
937→ newChronicle = [...chronicle.slice(0, -1), updatedLast];
938→ }
939→ set({
940→ constellation: newConstellation,
941→ pendingPerkChoices: null,
942→ energyMax,
943→ chronicle: newChronicle,
944→ ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: newConstellation, attributes: s.attributes }),
945→ });
946→ return true;
947→ },
948→
949→ rerollPerkChoices: () => {
950→ const s = get();
951→ if (!s.pendingPerkChoices) return;
952→ const choices = rollPerkChoices(s.constellation ?? []);
953→ if (choices.length > 0) set({ pendingPerkChoices: choices });
954→ },
955→
956→ checkAchievements: () => {
957→ const s = get();
958→ const newlyUnlocked: Achievement[] = [];
959→ const updated = { ...s.achievements };
960→ let crystals = s.crystals;
961→ let insights = s.insights;
962→ let contact = s.contact;
963→ let statsDirty = false;
964→ for (const a of ACHIEVEMENTS) {
965→ if (updated[a.id]) continue;
966→ if (a.check(s)) {
967→ updated[a.id] = true;
968→ newlyUnlocked.push(a);
969→ // 发放即时奖励
970→ if (a.reward.crystals) crystals += a.reward.crystals;
971→ if (a.reward.insights) insights += a.reward.insights;
972→ if (a.reward.contact) contact = Math.min(100, contact + a.reward.contact);
973→ if (a.reward.crystalsPerSecPct || a.reward.insightPct) statsDirty = true;
974→ }
975→ }
976→ if (newlyUnlocked.length === 0) return [];
977→ set({
978→ achievements: updated,
979→ crystals,
980→ insights,
981→ contact,
982→ ...(statsDirty
983→ ? syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: updated, activeTide: s.activeTide, constellation: s.constellation, attributes: s.attributes })
984→ : {}),
985→ _achievementQueue: [...s._achievementQueue, ...newlyUnlocked],
986→ });
987→ return newlyUnlocked;
988→ },
989→
990→ consumeAchievementQueue: () => {
991→ const s = get();
992→ if (s._achievementQueue.length === 0) return [];
993→ const items = s._achievementQueue;
994→ set({ _achievementQueue: [] });
995→ return items;
996→ },
997→
998→ canPrestige: () => get().contact >= CONTACT.prestigeMin,
999→
1000→ toggleTheme: () => set({ theme: get().theme === "dark" ? "light" : "dark" }),
1001→ toggleSound: () => set({ soundOn: !get().soundOn }),
1002→
1003→ // 深空信标:发放每日挑战奖励(v0.5)
1004→ grantBeaconReward: (insights, contact) => {
1005→ const s = get();
1006→ set({
1007→ insights: s.insights + Math.round(insights),
1008→ contact: Math.min(100, s.contact + contact),
1009→ });
1010→ },
1011→
1012→ // 深空信标:领取周挑战奖励(v0.8)
1013→ claimWeeklyBeacon: () => {
1014→ const challenge = generateWeeklyChallenge();
1015→ const progress = loadWeeklyProgress();
1016→ const res = claimWeeklyReward(challenge, progress);
1017→ if (res.rewardInsight > 0 || res.rewardContact > 0) {
1018→ const s = get();
1019→ set({
1020→ insights: s.insights + Math.round(res.rewardInsight),
1021→ contact: Math.min(100, s.contact + res.rewardContact),
1022→ });
1023→ }
1024→ return {
1025→ rewardInsight: res.rewardInsight,
1026→ rewardContact: res.rewardContact,
1027→ score: res.score,
1028→ };
1029→ },
1030→
1031→ // 深空信标:领取信标链里程碑奖励(v0.8)
1032→ claimChainReward: (milestone) => {
1033→ // loadChainState 仅用于前置校验,真正的状态修改由 claimChainMilestone 完成
1034→ const pre = loadChainState();
1035→ if (
1036→ pre.currentStreak < milestone ||
1037→ pre.milestonesClaimed.includes(milestone)
1038→ ) {
1039→ return {
1040→ rewardInsight: 0,
1041→ rewardContact: 0,
1042→ label: "",
1043→ ok: false,
1044→ };
1045→ }
1046→ const res = claimChainMilestone(milestone);
1047→ if (res.rewardInsight > 0 || res.rewardContact > 0) {
1048→ const s = get();
1049→ set({
1050→ insights: s.insights + Math.round(res.rewardInsight),
1051→ contact: Math.min(100, s.contact + res.rewardContact),
1052→ });
1053→ }
1054→ return {
1055→ rewardInsight: res.rewardInsight,
1056→ rewardContact: res.rewardContact,
1057→ label: res.label,
1058→ ok: res.rewardInsight > 0 || res.rewardContact > 0,
1059→ };
1060→ },
1061→
1062→ // 深空巡航:发放实时玩法奖励(v0.6)
1063→ grantCruiseReward: (rewards) => {
1064→ const s = get();
1065→ const addCrystals = rewards.crystals ?? 0;
1066→ const addInsights = rewards.insights ?? 0;
1067→ const addContact = rewards.contact ?? 0;
1068→ // v0.7 角色属性:巡航通关给探索力+勇气经验(按晶体奖励量缩放)
1069→ const totalReward = addCrystals + addInsights * 10 + addContact * 10;
1070→ const expBase = Math.max(2, Math.floor(totalReward / 30));
1071→ const curAttrs = s.attributes ?? createInitialAttributes();
1072→ const curProg = s.attributeProgress ?? createInitialAttributeProgress();
1073→ const explLvl = levelUpCheck(
1074→ { exp: curProg.exploration.exp + expBase, level: curAttrs.exploration },
1075→ ATTRIBUTE_HARD_CAP
1076→ );
1077→ const courageLvl = levelUpCheck(
1078→ { exp: curProg.courage.exp + Math.floor(expBase * 0.6), level: curAttrs.courage },
1079→ ATTRIBUTE_HARD_CAP
1080→ );
1081→ const newAttributes: CharacterAttributes = {
1082→ ...curAttrs,
1083→ exploration: explLvl.newProgress.level,
1084→ courage: courageLvl.newProgress.level,
1085→ };
1086→ const newProgress: AttributeProgress = {
1087→ ...curProg,
1088→ exploration: explLvl.newProgress,
1089→ courage: courageLvl.newProgress,
1090→ };
1091→ set({
1092→ crystals: Math.min(s.crystalCap, s.crystals + addCrystals),
1093→ insights: s.insights + Math.round(addInsights),
1094→ contact: Math.min(100, s.contact + addContact),
1095→ attributes: newAttributes,
1096→ attributeProgress: newProgress,
1097→ ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation, attributes: newAttributes }),
1098→ });
1099→ },
1100→
1101→ // ============ 角色属性系统(v0.7 P1 ============
1102→ allocateAttribute: (attr, points = 1) => {
1103→ const s = get();
1104→ const cur = s.attributes ?? createInitialAttributes();
1105→ const curVal = cur[attr] ?? 0;
1106→ if (curVal >= ATTRIBUTE_HARD_CAP) {
1107→ return { ok: false, leveledUp: 0 };
1108→ }
1109→ if ((s.pendingAttrPoints ?? 0) < points) {
1110→ return { ok: false, leveledUp: 0 };
1111→ }
1112→ const alloc = Math.min(points, ATTRIBUTE_HARD_CAP - curVal, s.pendingAttrPoints);
1113→ const newVal = curVal + alloc;
1114→ const newAttributes: CharacterAttributes = { ...cur, [attr]: newVal };
1115→ // 同步经验进度 level 字段(保持一致)
1116→ const curProg = s.attributeProgress ?? createInitialAttributeProgress();
1117→ const oldProg = curProg[attr] ?? { exp: 0, level: curVal };
1118→ const newProgress: AttributeProgress = {
1119→ ...curProg,
1120→ [attr]: { exp: oldProg.exp, level: newVal },
1121→ };
1122→ set({
1123→ attributes: newAttributes,
1124→ attributeProgress: newProgress,
1125→ pendingAttrPoints: s.pendingAttrPoints - alloc,
1126→ ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation, attributes: newAttributes }),
1127→ });
1128→ return { ok: true, leveledUp: alloc };
1129→ },
1130→
1131→ gainAttributeExp: (attr, amount) => {
1132→ const s = get();
1133→ const cur = s.attributes ?? createInitialAttributes();
1134→ const curProg = s.attributeProgress ?? createInitialAttributeProgress();
1135→ const progEntry = curProg[attr] ?? { exp: 0, level: cur[attr] };
1136→ const result = levelUpCheck(
1137→ { exp: progEntry.exp + amount, level: cur[attr] },
1138→ ATTRIBUTE_HARD_CAP
1139→ );
1140→ const newAttributes: CharacterAttributes = { ...cur, [attr]: result.newProgress.level };
1141→ const newProgress: AttributeProgress = {
1142→ ...curProg,
1143→ [attr]: result.newProgress,
1144→ };
1145→ set({
1146→ attributes: newAttributes,
1147→ attributeProgress: newProgress,
1148→ ...syncStats({ tech: s.tech, blueprints: s.blueprints, achievements: s.achievements, activeTide: s.activeTide, constellation: s.constellation, attributes: newAttributes }),
1149→ });
1150→ return { leveledUp: result.levelsGained, newLevel: result.newProgress.level };
1151→ },
1152→ }),
1153→ {
1154→ name: "echo-nexus-save-v1",
1155→ storage: createJSONStorage(() => localStorage),
1156→ // 不持久化临时字段
1157→ partialize: (s) => {
1158→ const { _lastAutoDecode, _lastSpawn, _combo, _lastPulse, _achievementQueue, _tideEvents, ...rest } = s;
1159→ void _lastAutoDecode; void _lastSpawn; void _combo; void _lastPulse; void _achievementQueue; void _tideEvents;
1160→ return rest as GameState;
1161→ },
1162→ }
1163→ )
1164→);
1165→
1166→/** 选择器:未解锁碎片中下一个门槛 */
1167→export function nextFragmentThreshold(totalDecoded: number): number | null {
1168→ for (const f of FRAGMENTS) {
1169→ if (totalDecoded < f.threshold) return f.threshold;
1170→ }
1171→ return null;
1172→}
1173→
1174→export { FRAGMENTS, PRESTIGE, TECH_TREE };
1175→
1176→// 开发期调试:暴露 store 到 window,便于 QA 测试
1177→if (typeof window !== "undefined" && process.env.NODE_ENV !== "production") {
1178→ (window as unknown as { __gameStore?: typeof useGameStore }).__gameStore = useGameStore;
1179→}
1180→