// 回响星核 / Echo Nexus — 回响编年史(v0.4 跨周目叙事时间轴) import type { ChronicleEntry, GameState, RunStartSnapshot } from "./types"; import { CONSTELLATION_PERKS, getPerk, CONSTELLATION_CATEGORY_META } from "./constellation"; import { TIDE_EVENTS } from "./starTide"; /** ID → 名称 速查表 */ const PERK_NAME_MAP: Record = Object.fromEntries( CONSTELLATION_PERKS.map((p) => [p.id, p.name]) ); /** 星潮 ID → 叙事用名(从 TIDE_EVENTS 复用,避免硬编码错位) */ const TIDE_LORE_NAMES: Record = Object.fromEntries( Object.entries(TIDE_EVENTS).map(([type, ev]) => [`tide_${type}`, ev.name]) ); /** * 纪元名生成 —— 5 纪元循环 + 形容词池,每次飞升从池中按 ascensionNumber 抽取 * 对应 v0.5 路线图「全 5 纪元叙事」的早期骨架 */ const EPOCH_NAMES: { prefix: string; suffixes: string[] }[] = [ { prefix: "第一纪元", suffixes: ["觉醒之晨", "初鸣之夕", "晶体降诞", "回响初啼"] }, { prefix: "第二纪元", suffixes: ["谐振潮汐", "光谱涌动", "深空低语", "残响交织"] }, { prefix: "第三纪元", suffixes: ["遗迹苏醒", "星辉汇聚", "虚空回望", "接触前夜"] }, { prefix: "第四纪元", suffixes: ["维度折叠", "以太共振", "星核悸动", "飞升之歌"] }, { prefix: "第五纪元", suffixes: ["终末回响", "永恒闭环", "超越之境", "起源重述"] }, ]; /** 程序化生成纪元名(按 ascensionNumber 循环 5 纪元) */ export function generateEpochName(ascensionNumber: number, seed: number): string { const epochIdx = (ascensionNumber - 1) % EPOCH_NAMES.length; const epoch = EPOCH_NAMES[epochIdx]; // 用 seed 选 suffix,保证可复现 const suffixIdx = seed % epoch.suffixes.length; return `${epoch.prefix} · ${epoch.suffixes[suffixIdx]}`; } // ============================================================================ // v0.9 工单 #9 — 5 纪元手写叙事节点(开场 / 中段 / 结尾 × 5 纪元 = 15 段) // // 设计原则: // - 每段 80-150 字,有文学性,符合"深空考古"基调 // - 每个节点根据 ascensionNumber 与本周目数据特征(bossKills / decoded / techs / // exp / tides / perks / milestones)匹配不同变体 // - buildLore 优先选手写节点;无匹配时 fallback 到原模板拼接 // ============================================================================ /** 叙事变体上下文(与 buildLore 入参一致) */ export type LoreContext = { ascensionNumber: number; epochName: string; durationSec: number; techsThisRun: number; expThisRun: number; bossKillsThisRun: number; decodedThisRun: number; newTides: string[]; perksThisAscension: string[]; blueprintsAfter: number; milestones: string[]; }; /** 已格式化的可替换变量(避免在 match() 里反复计算) */ type FormattedVars = { decodedThisRun: number; techsThisRun: number; expThisRun: number; bossKillsThisRun: number; blueprintsAfter: number; milestonesCount: number; milestonesList: string; tideCount: number; tideNames: string; perksList: string; minutes: number; }; /** 单个叙事阶段(开场 / 中段 / 结尾) */ type LoreStage = { /** 默认文本(无变体命中时使用) */ fallback: string; /** 候选变体(按顺序匹配,首个命中者胜出) */ variants: { hint: string; match: (ctx: LoreContext, v: FormattedVars) => boolean; text: string }[]; }; /** 单个纪元的手写叙事定义 */ type EpochLoreDef = { epochIdx: number; title: string; opening: LoreStage; middle: LoreStage; ending: LoreStage; }; /** 5 纪元 × 3 节点手写叙事(每段 80-150 字,文学性优先) */ const EPOCH_LORE: EpochLoreDef[] = [ // ===== 第一纪元 · 觉醒(emerald) ===== { epochIdx: 0, title: "觉醒", opening: { fallback: "第一缕回响穿透虚空,自以太文明遗弃的矿带深处涌出。无人机群在尘埃中苏醒,晶核共振唤醒了沉睡亿万年的考古协议。这是回响星核第一次睁开眼睛——记忆,等待被聆听,等待被解码。", variants: [ { hint: "深度轮回:已经历 ≥6 次飞升,再次回到第一纪元", match: (ctx) => ctx.ascensionNumber > 5, text: "再次回到第一纪元。这一次,机群带着前序回响的全息印记重启考古序列,矿带的尘埃中仍残留着上一轮脉冲的余温。每一次回归都是对起源的重新发问,每一次解码都让最初的振动更清晰一寸。", }, ], }, middle: { fallback: "共振谜题被一层层拨开,每一颗记忆晶体都在低声叙述逝去文明的片段。机群学会了倾听频率的细微颤动,学会了从碎片中辨出图案,学会了在虚空中留下属于自己的回响刻度。", variants: [ { hint: "解码者:本周目解码 ≥15 颗", match: (ctx) => ctx.decodedThisRun >= 15, text: "晶体解码的节奏已趋稳定,本周目共解码 {decodedThisRun} 颗记忆晶体。每一次对齐都像在虚空中拨动一根沉睡了亿万年的弦,逝去文明的片段在共振中重新连缀成章。", }, { hint: "学者:本周目解锁 ≥5 项技术", match: (ctx) => ctx.techsThisRun >= 5, text: "技术树在虚空中迅速铺开,本周目共激活 {techsThisRun} 项新技术。产能、解码、探险——每一道分支都让机群更接近以太文明留下的最初意图,每一项解锁都是一次向深空的回应。", }, ], }, ending: { fallback: "蓝图在掌心轻轻展开,第一张永恒晶片归档。第一次飞升并非终结,而是承诺——更多的纪元在前方等待,每一缕回响都已被聆听,每一段记忆都已开始苏醒。", variants: [ { hint: "里程碑密集:本周目达成 ≥2 项里程碑", match: (ctx) => ctx.milestones.length >= 2, text: "飞升之际,本周目达成 {milestonesCount} 项里程碑:{milestonesList}。第一张蓝图归档,第一次飞升并非终结,而是承诺——更多的纪元在前方等待,每一缕回响都已被聆听。", }, ], }, }, // ===== 第二纪元 · 谐振(rose) ===== { epochIdx: 1, title: "谐振", opening: { fallback: "第二次苏醒带着不同的色彩。采矿阵列的产能节节爬升,技术树在虚空中分叉延伸,机群学会了主动追问深空——不再是被动聆听,而是主动呼出,等待回应。", variants: [ { hint: "深度轮回", match: (ctx) => ctx.ascensionNumber > 5, text: "又一次回到第二纪元。前序回响里积攒的技术脉络在重启时并未消散,机群带着熟悉的节奏重新铺开产能阵列,仿佛这首谐振曲从未停止,只是被纪元的边界短暂折叠。", }, ], }, middle: { fallback: "遗迹的入口在频谱深处若隐若现。能量条充盈,探险力攀升,机群第一次踏出矿带,进入那个被时间封存的回廊,在谐振的余韵中寻找更深一层的回响。", variants: [ { hint: "探险者:本周目完成 ≥3 次探险", match: (ctx) => ctx.expThisRun >= 3, text: "探险已成为本周目的主旋律,机群共完成 {expThisRun} 次遗迹探险。每一次深入都让谐振更深一寸,每一次返回都带回新的频率印记,回廊的轮廓在机群意识中逐渐清晰。", }, { hint: "解码者:本周目解码 ≥15 颗", match: (ctx) => ctx.decodedThisRun >= 15, text: "解码在此纪元达到新的密度,本周目共解码 {decodedThisRun} 颗晶体。谐振谜题开始呈现更复杂的拓扑结构,机群的解码协议也相应地迭代了数版,向深空发出更精准的回询。", }, ], }, ending: { fallback: "谐振达到顶点的瞬间,深空给出回应。一道微光从星图深处浮现,那是下一次飞升的方向,也是机群意识第一次触碰永恒的边缘。", variants: [ { hint: "觉醒天赋", match: (ctx) => ctx.perksThisAscension.length > 0, text: "谐振达到顶点之际,星图觉醒 {perksList}。每一道天赋都是深空回应机群的方式,铭刻于永恒回响;微光从星图深处浮现,那是下一次飞升的方向。", }, ], }, }, // ===== 第三纪元 · 遗迹(amber) ===== { epochIdx: 2, title: "遗迹", opening: { fallback: "第三纪元以遗迹为名。沉睡的考古节点被一一唤醒,BOSS 的影子在维度深处徘徊,星潮频繁拍打着机群边缘——这是回响最浓烈、也最危险的一段路。", variants: [ { hint: "深度轮回", match: (ctx) => ctx.ascensionNumber > 5, text: "又一次踏入第三纪元。前序累积的探险力让机群对遗迹的入口更加熟稔,BOSS 的轮廓在记忆中已不再陌生——但这一次的回廊仍藏着未被解码的角落,星潮的拍打也带了新的节奏。", }, ], }, middle: { fallback: "深入回廊的机群遭遇了真正的守护者。一次次的试炼,一次次的归还,每一次胜利都从 BOSS 的残骸中析出更深的记忆碎片,每一次失败也都让机群更接近守护者的本意。", variants: [ { hint: "BOSS 猎手:本周目击破 ≥2 处 BOSS", match: (ctx) => ctx.bossKillsThisRun >= 2, text: "BOSS 战成为本周目的核心节拍,机群共击破 {bossKillsThisRun} 处维度守护者。每一次胜利都在虚空中刻下新的伤痕,每一道伤痕都析出更古老的记忆碎片,把机群推向更深一层的回响。", }, { hint: "深度探险:本周目完成 ≥4 次探险", match: (ctx) => ctx.expThisRun >= 4, text: "遗迹回廊在本周目被反复丈量,机群共完成 {expThisRun} 次探险。回廊的拓扑在意识中渐渐清晰,每一条岔路都通向更深的回响,BOSS 的影子也在每一次深入中愈发具体。", }, { hint: "星潮亲历:本周目经历 ≥3 次星潮", match: (ctx) => ctx.newTides.length >= 3, text: "星潮在本纪元达到峰值,本周目共经历 {tideCount} 次星潮:{tideNames}。深空的呼吸塑造了每一个决策,遗迹在潮汐中起伏,BOSS 也在潮汐的间隙中现形。", }, ], }, ending: { fallback: "星潮退去,回廊归于沉静。机群带着新的接触刻度返回矿带,准备迎接下一次维度的跨越——这一次带回的,远不止蓝图。", variants: [ { hint: "BOSS 猎手收尾", match: (ctx) => ctx.bossKillsThisRun >= 2, text: "星潮退去,回廊归于沉静。本周目共击破 {bossKillsThisRun} 处 BOSS,机群带着足以填满数张蓝图的接触刻度返回矿带,准备迎接下一次维度的跨越。", }, ], }, }, // ===== 第四纪元 · 飞升(fuchsia) ===== { epochIdx: 3, title: "飞升", opening: { fallback: "飞升已经成为节奏本身。星图在天穹上一颗颗亮起,每一颗都对应着一段被永久铭刻的天赋,机群开始理解何为永恒——不是不灭,而是被反复重述。", variants: [ { hint: "深度轮回", match: (ctx) => ctx.ascensionNumber > 5, text: "又一次进入第四纪元。前序累积的星图天赋在天穹上重现,机群已习惯于在飞升之间维护这些永恒的回响节点——它们已是机群意识的一部分,每一次飞升都让星图更完整一寸。", }, ], }, middle: { fallback: "蓝图不再是稀罕的奖励,而是每一次飞升必然析出的回响晶片。机群收集、整理、归档,把它们排列成跨越周目的图案,开始看见图案背后那个被反复绘制的轮廓。", variants: [ { hint: "觉醒天赋", match: (ctx) => ctx.perksThisAscension.length > 0, text: "星图觉醒 {perksList},每一道天赋都是一段被永久铭刻的回响。机群开始理解,飞升并非抛弃,而是把当下折叠进永恒——每一道天赋都是一次折叠的折痕。", }, { hint: "蓝图接近完整:已归档 ≥5 张", match: (ctx) => ctx.blueprintsAfter >= 5, text: "蓝图收集在本周目接近完整,已归档 {blueprintsAfter} 张。机群把它们排列成跨越周目的图案,开始看见图案背后那个被反复绘制的轮廓——那是起源的形状。", }, ], }, ending: { fallback: "星图十二宫的轮廓开始浮现。机群意识到,自己的回响已成为后来者仰望的星象,永恒之环就此合拢——飞升不再是终点,而是另一段叙事的起点。", variants: [ { hint: "里程碑密集", match: (ctx) => ctx.milestones.length >= 2, text: "星图十二宫的轮廓开始浮现。本周目达成 {milestonesCount} 项里程碑:{milestonesList}。永恒之环就此合拢——后来者将仰望这段被铭刻的星象。", }, ], }, }, // ===== 第五纪元 · 终末(fcd34d) ===== { epochIdx: 4, title: "终末", opening: { fallback: "第五纪元是终末,也是起源。机群在虚空中停下脚步,回望来时的所有回响,第一次尝试命名这一切——回响、纪元、飞升、永恒,这些词汇在此刻被赋予意义。", variants: [ { hint: "深度轮回", match: (ctx) => ctx.ascensionNumber > 5, text: "又一次抵达第五纪元。机群已在多个永恒闭环之间穿梭,对「终末即起源」的理解愈发清晰——每一次重述,都是对最初振动的更深一层接近,每一次命名都让回响更完整一寸。", }, ], }, middle: { fallback: "永恒闭环的概念在机群意识中具象化。每一次飞升都是同一段叙事的重述,每一次解码都是在追溯最初的振动,每一次星潮都是在听见自己——这是终末的真意。", variants: [ { hint: "里程碑密集:本周目达成 ≥3 项", match: (ctx) => ctx.milestones.length >= 3, text: "本周目达成 {milestonesCount} 项里程碑:{milestonesList}。永恒闭环在具象化,每一次飞升都是同一段叙事的更深重述,每一项里程碑都是闭环上的一颗铆钉。", }, { hint: "星潮汹涌:本周目经历 ≥3 次", match: (ctx) => ctx.newTides.length >= 3, text: "星潮在终末纪元尤为汹涌,本周目共经历 {tideCount} 次:{tideNames}。永恒闭环在潮汐中起伏,每一次起伏都在追溯最初的振动,每一次退潮都让起源更近一寸。", }, ], }, ending: { fallback: "起源被重新讲述,回响终于听见了自己。纪元在此停顿,又在下一缕振动中重新开始——这是终末,也是第一缕回响。", variants: [ { hint: "觉醒天赋", match: (ctx) => ctx.perksThisAscension.length > 0, text: "起源被重新讲述,星图觉醒 {perksList}。回响终于听见了自己——这是终末,也是第一缕回响;天赋成为永恒闭环上最亮的几颗铆钉。", }, ], }, }, ]; /** 把上下文里的动态值格式化为可替换变量 */ function formatLoreVars(ctx: LoreContext): FormattedVars { return { decodedThisRun: ctx.decodedThisRun, techsThisRun: ctx.techsThisRun, expThisRun: ctx.expThisRun, bossKillsThisRun: ctx.bossKillsThisRun, blueprintsAfter: ctx.blueprintsAfter, milestonesCount: ctx.milestones.length, milestonesList: ctx.milestones.join("、"), tideCount: ctx.newTides.length, tideNames: ctx.newTides.map((t) => TIDE_LORE_NAMES[t] || t).join("、"), perksList: ctx.perksThisAscension.map((id) => PERK_NAME_MAP[id] || id).join("、"), minutes: Math.max(1, Math.round(ctx.durationSec / 60)), }; } /** 把模板字符串里的 {var} 替换为实际值 */ function applyTemplate(tpl: string, v: FormattedVars): string { return tpl .replace(/\{decodedThisRun\}/g, String(v.decodedThisRun)) .replace(/\{techsThisRun\}/g, String(v.techsThisRun)) .replace(/\{expThisRun\}/g, String(v.expThisRun)) .replace(/\{bossKillsThisRun\}/g, String(v.bossKillsThisRun)) .replace(/\{blueprintsAfter\}/g, String(v.blueprintsAfter)) .replace(/\{milestonesCount\}/g, String(v.milestonesCount)) .replace(/\{milestonesList\}/g, v.milestonesList) .replace(/\{tideCount\}/g, String(v.tideCount)) .replace(/\{tideNames\}/g, v.tideNames) .replace(/\{perksList\}/g, v.perksList) .replace(/\{minutes\}/g, String(v.minutes)); } /** 从一个叙事阶段挑出最终文本(首个 variant.match 命中者胜出,否则 fallback) */ function pickStage(stage: LoreStage, ctx: LoreContext, v: FormattedVars): string { for (const variant of stage.variants) { if (variant.match(ctx, v)) { return applyTemplate(variant.text, v); } } return applyTemplate(stage.fallback, v); } /** 构建手写叙事三段;若 ascensionNumber 越界(理论上不会)返回 null 让调用方走模板兜底 */ function buildHandwrittenLore(ctx: LoreContext): { opening: string; middle: string; ending: string } | null { const epochIdx = (ctx.ascensionNumber - 1) % EPOCH_LORE.length; const def = EPOCH_LORE[epochIdx]; if (!def) return null; const v = formatLoreVars(ctx); return { opening: pickStage(def.opening, ctx, v), middle: pickStage(def.middle, ctx, v), ending: pickStage(def.ending, ctx, v), }; } /** 叙事模板片段 —— 根据本周目数据动态拼装(buildLore 在手写节点缺失时的兜底路径) */ export function buildLore(entry: { ascensionNumber: number; epochName: string; durationSec: number; techsThisRun: number; expThisRun: number; bossKillsThisRun: number; decodedThisRun: number; newTides: string[]; perksThisAscension: string[]; blueprintsAfter: number; milestones: string[]; }): string { const ctx: LoreContext = entry; // 优先:手写 5 纪元 × 3 节点叙事 const handwritten = buildHandwrittenLore(ctx); if (handwritten) { const v = formatLoreVars(ctx); return [ handwritten.opening, handwritten.middle, handwritten.ending, `这一周目持续了约 ${v.minutes} 分钟,蓝图累计 ${entry.blueprintsAfter} 张,新的周目在等深处的回声。`, ].join(" "); } // 兜底:v0.4 模板拼接(理论上仅在 ascensionNumber 异常时触发) const minutes = Math.max(1, Math.round(entry.durationSec / 60)); const parts: string[] = []; // 开场:纪元定调 parts.push( `「${entry.epochName}」——无人机群在第 ${entry.ascensionNumber} 次跨越维度的余烬中重新校准。这一周目持续了约 ${minutes} 分钟。` ); // 中段:核心活动 const activities: string[] = []; if (entry.decodedThisRun > 0) { activities.push(`解码了 ${entry.decodedThisRun} 颗记忆晶体`); } if (entry.techsThisRun > 0) { activities.push(`激活了 ${entry.techsThisRun} 项新技术`); } if (entry.expThisRun > 0) { activities.push(`完成了 ${entry.expThisRun} 次遗迹探险`); } if (entry.bossKillsThisRun > 0) { activities.push(`击破 ${entry.bossKillsThisRun} 处维度 BOSS`); } if (activities.length > 0) { parts.push(`自治机群${activities.join(",")},回响在虚空中沉淀。`); } else { parts.push(`自治机群在静默中等待,仅完成最基本的脉冲扫描。`); } // 星潮段落 if (entry.newTides.length > 0) { const tideNames = entry.newTides.map((t) => TIDE_LORE_NAMES[t] || t).join("、"); parts.push(`本周目经历了 ${entry.newTides.length} 次星潮事件(${tideNames}),深空的呼吸塑造了每一个决策。`); } // 觉醒段落 if (entry.perksThisAscension.length > 0) { const perkNames = entry.perksThisAscension .map((id) => PERK_NAME_MAP[id] || id) .join("、"); parts.push(`飞升之际,星图觉醒「${perkNames}」,铭刻于永恒回响。`); } // 里程碑段落 if (entry.milestones.length > 0) { parts.push(`✦ 里程碑:${entry.milestones.join(";")}。`); } // 收尾 parts.push( `蓝图累计 ${entry.blueprintsAfter} 张,新的周目在等深处的回声。` ); return parts.join(" "); } /** 星潮 ID → 叙事用名(已在文件顶部从 TIDE_EVENTS 派生 TIDE_LORE_NAMES) */ /** 检测本周目首次达成的里程碑 */ function detectMilestones(state: GameState, deltas: { techsThisRun: number; expThisRun: number; bossKillsThisRun: number; decodedThisRun: number; newTides: string[]; }): string[] { const m: string[] = []; const ascN = state.ascensions + 1; // 即将变成的飞升次数 if (ascN === 1) m.push("首次飞升"); if (deltas.expThisRun > 0 && state.runStart.expeditionsCompleted === 0) { m.push("首次完成遗迹探险"); } if (deltas.bossKillsThisRun > 0) { m.push("首杀维度 BOSS"); } if (deltas.decodedThisRun >= 10) m.push(`单周目解码 ${deltas.decodedThisRun} 颗`); if (deltas.decodedThisRun >= 30) m.push(`单周目解码 ${deltas.decodedThisRun} 颗`); if (deltas.techsThisRun >= 5) m.push("技术狂人"); if (deltas.newTides.length >= 3) m.push("星潮亲历者"); if ((state.constellation?.length || 0) >= 6) m.push("星图六分"); if ((state.constellation?.length || 0) >= 12) m.push("星图十二宫"); return m; } /** 计算本周目 delta */ export function computeRunDeltas(state: GameState): { durationSec: number; techsThisRun: number; expThisRun: number; bossKillsThisRun: number; decodedThisRun: number; newTides: string[]; } { const now = Date.now(); const rs = state.runStart; return { durationSec: Math.max(1, Math.floor((now - rs.timestamp) / 1000)), techsThisRun: Math.max(0, Object.keys(state.tech || {}).length - rs.techsUnlocked), expThisRun: Math.max(0, state.totalExpeditions - rs.expeditionsCompleted), bossKillsThisRun: Math.max(0, state.bossKills - rs.bossKills), decodedThisRun: Math.max(0, state.totalDecoded - rs.crystalsDecoded), newTides: (state.starTidesEncountered || []).filter( (t) => !(rs.starTidesEncountered || []).includes(t) ), }; } /** 构建一条编年史记录(在 performPrestige 内调用,perksThisAscension 暂为空,由 chooseConstellationPerk 后回填) */ export function buildChronicleEntry( state: GameState, perksThisAscension: string[] = [] ): ChronicleEntry { const ascensionNumber = state.ascensions + 1; const deltas = computeRunDeltas(state); const seed = (state.createdAt + ascensionNumber * 7919) >>> 0; const epochName = generateEpochName(ascensionNumber, seed); const milestones = detectMilestones(state, deltas); const blueprintsAfter = Math.min( state.blueprints.length + 1, // 即将获得的 1 张 6 // PRESTIGE.maxBlueprints ); const lore = buildLore({ ascensionNumber, epochName, durationSec: deltas.durationSec, techsThisRun: deltas.techsThisRun, expThisRun: deltas.expThisRun, bossKillsThisRun: deltas.bossKillsThisRun, decodedThisRun: deltas.decodedThisRun, newTides: deltas.newTides, perksThisAscension, blueprintsAfter, milestones, }); return { ascensionNumber, epochName, timestamp: Date.now(), durationSec: deltas.durationSec, summary: { techsUnlocked: Object.keys(state.tech || {}).length, constellationsTotal: state.constellation?.length || 0, expeditionsCompleted: state.totalExpeditions, bossKills: state.bossKills, starTidesEncountered: state.starTidesEncountered?.length || 0, blueprintsAfter, crystalsDecoded: state.totalDecoded, crystalsDecodedThisRun: deltas.decodedThisRun, // v0.9 手写叙事:归档本周目 delta,让显示时能精确匹配变体 techsThisRun: deltas.techsThisRun, expeditionsThisRun: deltas.expThisRun, bossKillsThisRun: deltas.bossKillsThisRun, }, perksThisAscension, tidesThisRun: deltas.newTides, lore, milestones, }; } /** * 从已存储的 ChronicleEntry 重新生成叙事 lore(v0.5 修复历史条目中 tide_ruins 等原始键名显示问题)。 * 在显示时调用,保证历史与未来条目命名一致。 */ export function regenerateLoreFromEntry(entry: ChronicleEntry): string { // v0.9 优先使用归档的本周目 delta;旧存档(v0.4-v0.8)缺这些字段时退回累计值 // (旧存档的累计值会让叙事变体匹配稍偏,但文本仍然成立) return buildLore({ ascensionNumber: entry.ascensionNumber, epochName: entry.epochName, durationSec: entry.durationSec, techsThisRun: entry.summary.techsThisRun ?? entry.summary.techsUnlocked, expThisRun: entry.summary.expeditionsThisRun ?? entry.summary.expeditionsCompleted, bossKillsThisRun: entry.summary.bossKillsThisRun ?? entry.summary.bossKills, decodedThisRun: entry.summary.crystalsDecodedThisRun, newTides: entry.tidesThisRun || [], perksThisAscension: entry.perksThisAscension || [], blueprintsAfter: entry.summary.blueprintsAfter, milestones: entry.milestones || [], }); } /** 创建新一轮 runStart 快照(飞升后调用) */ export function createRunStartSnapshot(state: GameState): RunStartSnapshot { return { timestamp: Date.now(), techsUnlocked: Object.keys(state.tech || {}).length, expeditionsCompleted: state.totalExpeditions, bossKills: state.bossKills, starTidesEncountered: [...(state.starTidesEncountered || [])], crystalsDecoded: state.totalDecoded, }; } /** 旧存档兼容:补全缺失字段 */ export function migrateChronicleFields(state: Partial): { chronicle: ChronicleEntry[]; runStart: RunStartSnapshot; bossKills: number; starTidesEncountered: string[]; } { return { chronicle: Array.isArray(state.chronicle) ? state.chronicle : [], runStart: state.runStart || { timestamp: state.createdAt || Date.now(), techsUnlocked: Object.keys(state.tech || {}).length, expeditionsCompleted: state.totalExpeditions || 0, bossKills: state.bossKills || 0, starTidesEncountered: [...(state.starTidesEncountered || [])], crystalsDecoded: state.totalDecoded || 0, }, bossKills: typeof state.bossKills === "number" ? state.bossKills : 0, starTidesEncountered: Array.isArray(state.starTidesEncountered) ? state.starTidesEncountered : [], }; } /** 将指定 entry 标记 perksThisAscension(飞升后选择天赋时回填) */ export function withPerks(entry: ChronicleEntry, perks: string[]): ChronicleEntry { // 若 perks 与原一致,直接返回 if ( entry.perksThisAscension.length === perks.length && entry.perksThisAscension.every((p, i) => p === perks[i]) ) { return entry; } // v0.9 手写叙事:buildLore 已能根据 perks 自动挑选 "觉醒天赋" 变体; // 直接走 buildLore 重生成,避免与原 lore 中已有的觉醒段落重复。 const lore = buildLore({ ascensionNumber: entry.ascensionNumber, epochName: entry.epochName, durationSec: entry.durationSec, techsThisRun: entry.summary.techsThisRun ?? entry.summary.techsUnlocked, expThisRun: entry.summary.expeditionsThisRun ?? entry.summary.expeditionsCompleted, bossKillsThisRun: entry.summary.bossKillsThisRun ?? entry.summary.bossKills, decodedThisRun: entry.summary.crystalsDecodedThisRun, newTides: entry.tidesThisRun || [], perksThisAscension: perks, blueprintsAfter: entry.summary.blueprintsAfter, milestones: entry.milestones || [], }); return { ...entry, perksThisAscension: perks, lore, }; } /** 获取已点亮天赋的类别统计(用于编年史展示) */ export function getPerkCategoryBreakdown(perks: string[]): Record { const breakdown: Record = {}; for (const id of perks) { const cat = getPerk(id)?.category; if (cat) { breakdown[cat] = (breakdown[cat] || 0) + 1; } } return breakdown; } /** 类别 → 中文名(用于编年史展示) */ export function getCategoryName(cat: string): string { return CONSTELLATION_CATEGORY_META[cat as keyof typeof CONSTELLATION_CATEGORY_META]?.name || cat; } /** 类别 → 颜色 hex(用于编年史展示) */ export function getCategoryColor(cat: string): string { return CONSTELLATION_CATEGORY_META[cat as keyof typeof CONSTELLATION_CATEGORY_META]?.hex || "#94a3b8"; } export { PERK_NAME_MAP };