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