const clamp = (value, min, max) => Math.min(max, Math.max(min, value)) export const PHASES = [ { id: 'tidepool', name: '潮池纪元', chapter: 'CHAPTER 01', resourceName: '生命质', glyph: '◌', accent: '#58f3c4', headline: '在无名之海,学会记住自己', story: '雷暴越过汐原的海面。某些分子开始复制,而你在复制之间醒来。', transition: '陆冠纪元开始:生命第一次离开海洋。', milestones: [ { id: 'membrane', name: '脂质膜', description: '为脆弱的化学反应围起第一道边界。', cost: 12, effect: { auto: 0.45, insight: 1 }, signal: '边界诞生,内与外第一次有了意义。' }, { id: 'photosynthesis', name: '光合作用', description: '把暮阳的光转译成稳定能量。', cost: 38, effect: { click: 2, auto: 0.8, balance: 5, insight: 1 }, signal: '海水泛起绿色,天空开始改变颜色。' }, { id: 'nucleus', name: '遗传核心', description: '保存更长的记忆,允许生命变得复杂。', cost: 90, effect: { auto: 2.2, insight: 2 }, signal: '无数偶然被写成可以继承的秩序。' }, ], }, { id: 'landcrown', name: '陆冠纪元', chapter: 'CHAPTER 02', resourceName: '生境力', glyph: '⌁', accent: '#b8f36b', headline: '让海的记忆覆盖大陆', story: '风吹过尚未命名的大陆。生命带着海盐,缓慢爬向干燥的光。', transition: '炉城纪元开始:智慧生命点燃了第一簇火。', milestones: [ { id: 'roots', name: '根系网络', description: '植物固定土壤,为整个陆地生态供能。', cost: 70, effect: { auto: 3.5, balance: 8, insight: 2 }, signal: '绿色沿河谷蔓延,大陆拥有了呼吸。' }, { id: 'symmetry', name: '灵巧肢体', description: '感知、移动与协作带来新的生存方式。', cost: 180, effect: { click: 4, auto: 4.5, insight: 2 }, signal: '生命不再只等待环境,而开始寻找环境。' }, { id: 'sentience', name: '群体心智', description: '记忆跨越个体,语言跨越世代。', cost: 420, effect: { auto: 7, balance: 4, insight: 4 }, signal: '它们第一次抬头观察星空,并提出了为什么。' }, ], }, { id: 'forgecity', name: '炉城纪元', chapter: 'CHAPTER 03', resourceName: '文明火种', glyph: '⌬', accent: '#ffb45b', headline: '火焰照亮城市,也照见代价', story: '城市沿海岸升起。天文学家同时发现:暮阳正在变得不再稳定。', transition: '星种纪元开始:整个文明把目光投向大气层之外。', milestones: [ { id: 'agriculture', name: '共生农业', description: '让聚落增长,而不是透支脚下的土壤。', cost: 280, effect: { auto: 15, balance: 12, insight: 4 }, signal: '文明学会收获,也学会留下下一季的种子。' }, { id: 'observatory', name: '暮阳观测阵列', description: '确认超级耀斑将在八百年内剥离大气。', cost: 700, effect: { click: 10, auto: 20, insight: 6 }, signal: '长夜被算出了日期,世界第一次拥有共同的倒计时。' }, { id: 'covenant', name: '行星公约', description: '守园者与远航者共同签署星种计划。', cost: 1500, effect: { auto: 35, balance: 10, insight: 8 }, signal: '延续生命与守护故乡,不再是两条相反的道路。' }, ], }, { id: 'starseed', name: '星种纪元', chapter: 'CHAPTER 04', resourceName: '星航能量', glyph: '✦', accent: '#77b8ff', headline: '在长夜之前,把潮声送往群星', story: '轨道船坞进入最后施工期。暮阳的第一轮风暴已经抵达外层行星。', transition: '', milestones: [ { id: 'archive', name: '生命档案', description: '保存微生物、种子、胚胎与文明记忆。', cost: 1100, effect: { auto: 55, archive: 45, insight: 8 }, signal: '一整颗行星的生命,被浓缩成可以远航的花园。' }, { id: 'canopy', name: '磁场天幕', description: '为汐原抵挡恒星风,给故乡留下未来。', cost: 2500, effect: { auto: 90, shield: 65, balance: 15, insight: 10 }, signal: '人造极光覆盖天空,故乡在风暴中继续呼吸。' }, { id: 'ark', name: '星种方舟', description: '完成驶向新潮的最后一艘生态方舟。', cost: 5200, effect: { auto: 140, archive: 55, shield: 35, insight: 14 }, signal: '倒计时开始。另一片海正在航线尽头等待。' }, ], }, ] const ALL_MILESTONE_IDS = new Set(PHASES.flatMap((phase) => phase.milestones.map((milestone) => milestone.id))) function safeNumber(value, fallback, min = 0, max = Number.MAX_SAFE_INTEGER, integer = false) { if (typeof value !== 'number' || !Number.isFinite(value)) return fallback const normalized = integer ? Math.floor(value) : value return clamp(normalized, min, max) } export function createInitialState(now = Date.now()) { return { version: 1, phase: 0, essence: 0, clickPower: 1, autoRate: 0, insight: 0, balance: 50, shield: 0, archive: 0, completedMilestones: [], storyLog: ['潮声第一次被听见。'], launched: false, seedCount: 0, lastUpdated: now, offlineSeconds: 0, } } export function collectPulse(state) { if (state.launched) return state return { ...state, essence: state.essence + state.clickPower, } } export function buyMilestone(state, milestoneId) { const milestone = PHASES[state.phase]?.milestones.find((item) => item.id === milestoneId) if (!milestone || state.completedMilestones.includes(milestoneId) || state.essence < milestone.cost) { return state } const effect = milestone.effect return { ...state, essence: state.essence - milestone.cost, clickPower: state.clickPower + (effect.click ?? 0), autoRate: state.autoRate + (effect.auto ?? 0), insight: state.insight + (effect.insight ?? 0), balance: clamp(state.balance + (effect.balance ?? 0), 0, 100), shield: clamp(state.shield + (effect.shield ?? 0), 0, 100), archive: clamp(state.archive + (effect.archive ?? 0), 0, 100), completedMilestones: [...state.completedMilestones, milestoneId], storyLog: [...state.storyLog, milestone.signal].slice(-8), } } export function canAdvance(state) { const phase = PHASES[state.phase] return Boolean( phase && state.phase < PHASES.length - 1 && phase.milestones.every((milestone) => state.completedMilestones.includes(milestone.id)), ) } export function advancePhase(state) { if (!canAdvance(state)) return state const current = PHASES[state.phase] return { ...state, phase: state.phase + 1, essence: 0, storyLog: [...state.storyLog, current.transition].slice(-8), } } export function elapsedSeconds(from, to) { if (!Number.isFinite(from) || !Number.isFinite(to)) return 0 return clamp((to - from) / 1000, 0, 14_400) } export function tick(state, seconds, now = Date.now()) { const elapsed = Math.max(0, Number.isFinite(seconds) ? seconds : 0) if (state.launched) return state const essence = safeNumber(state.essence, 0) const autoRate = safeNumber(state.autoRate, 0) return { ...state, essence: essence + autoRate * elapsed, lastUpdated: now, } } export function applyOfflineProgress(state, now = Date.now()) { if (state.launched) return state const elapsed = elapsedSeconds(state.lastUpdated, now) return { ...tick(state, elapsed, now), offlineSeconds: elapsed, } } export function isReadyToLaunch(state) { const finalPhase = PHASES.at(-1) return Boolean( state.phase === PHASES.length - 1 && !state.launched && finalPhase.milestones.every((milestone) => state.completedMilestones.includes(milestone.id)), ) } export function launchSeed(state) { if (!isReadyToLaunch(state)) return state const endingGrade = state.balance >= 80 && state.shield === 100 && state.archive === 100 ? '双生花园' : state.shield >= 80 ? '故乡守望' : '远航火种' return { ...state, launched: true, seedCount: state.seedCount + 1, endingTitle: '两片海的黎明', endingGrade, storyLog: [...state.storyLog, '另一片没有名字的海里,有东西第一次记住了自己。'].slice(-8), } } export function serializeGame(state) { return JSON.stringify({ ...state, lastUpdated: Date.now() }) } export function restoreGame(serialized, now = Date.now()) { try { const parsed = JSON.parse(serialized) if (!parsed || parsed.version !== 1 || !Number.isInteger(parsed.phase)) { return createInitialState(now) } const initial = createInitialState(now) const completedMilestones = Array.isArray(parsed.completedMilestones) ? [...new Set(parsed.completedMilestones.filter((id) => typeof id === 'string' && ALL_MILESTONE_IDS.has(id)))] : [] const storyLog = Array.isArray(parsed.storyLog) ? parsed.storyLog.filter((entry) => typeof entry === 'string' && entry.trim()).slice(-8) : [] const phase = clamp(parsed.phase, 0, PHASES.length - 1) const launched = parsed.launched === true && phase === PHASES.length - 1 && PHASES.at(-1).milestones.every((milestone) => completedMilestones.includes(milestone.id)) const normalized = { ...initial, phase, essence: safeNumber(parsed.essence, initial.essence), clickPower: safeNumber(parsed.clickPower, initial.clickPower, 1), autoRate: safeNumber(parsed.autoRate, initial.autoRate), insight: safeNumber(parsed.insight, initial.insight, 0, Number.MAX_SAFE_INTEGER, true), balance: safeNumber(parsed.balance, initial.balance, 0, 100), shield: safeNumber(parsed.shield, initial.shield, 0, 100), archive: safeNumber(parsed.archive, initial.archive, 0, 100), seedCount: safeNumber(parsed.seedCount, initial.seedCount, 0, Number.MAX_SAFE_INTEGER, true), completedMilestones, storyLog: storyLog.length ? storyLog : initial.storyLog, launched, endingTitle: launched && typeof parsed.endingTitle === 'string' ? parsed.endingTitle : undefined, endingGrade: launched && typeof parsed.endingGrade === 'string' ? parsed.endingGrade : undefined, lastUpdated: safeNumber(parsed.lastUpdated, now, 0, now), offlineSeconds: 0, } return applyOfflineProgress(normalized, now) } catch { return createInitialState(now) } }