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) { 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()( 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→