314 lines
11 KiB
TypeScript
Executable File
314 lines
11 KiB
TypeScript
Executable File
// 回响星核 / 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<string, string> = Object.fromEntries(
|
||
CONSTELLATION_PERKS.map((p) => [p.id, p.name])
|
||
);
|
||
|
||
/** 星潮 ID → 叙事用名(从 TIDE_EVENTS 复用,避免硬编码错位) */
|
||
const TIDE_LORE_NAMES: Record<string, string> = 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<GameState>): {
|
||
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<string, number> {
|
||
const breakdown: Record<string, number> = {};
|
||
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 };
|