// 回响星核 / 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]}`; } /** 叙事模板片段 —— 根据本周目数据动态拼装 */ 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 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, }, perksThisAscension, tidesThisRun: deltas.newTides, lore, milestones, }; } /** * 从已存储的 ChronicleEntry 重新生成叙事 lore(v0.5 修复历史条目中 tide_ruins 等原始键名显示问题)。 * 在显示时调用,保证历史与未来条目命名一致。 */ export function regenerateLoreFromEntry(entry: ChronicleEntry): string { return buildLore({ ascensionNumber: entry.ascensionNumber, epochName: entry.epochName, durationSec: entry.durationSec, techsThisRun: entry.summary.techsUnlocked, expThisRun: entry.summary.expeditionsCompleted, 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; } // 重新生成 lore 以包含天赋名 const perksNames = perks .map((id) => PERK_NAME_MAP[id] || id) .join("、"); const appendText = perks.length > 0 ? ` 飞升之际,星图觉醒「${perksNames}」,铭刻于永恒回响。` : ""; // 去重:原 lore 若已含「飞升之际」段则不再追加 const hasAwakenSeg = entry.lore.includes("飞升之际"); const lore = hasAwakenSeg ? entry.lore : entry.lore + appendText; 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 };