工单 #8 编年史上限+分页: - engine.ts: slice(-50)→slice(-200) - ChronicleDialog.tsx: 加分页(每页10条)+上一页/下一页+页码显示 工单 #5 限时挑战 (subagent 10-a): - beacon.ts: BeaconTimedChallenge + getTimedSlotKey(4h时段) + generateTimedChallenge - BeaconPanel.tsx: amber主题限时区块 + 倒计时 + <30min紧急状态 - gameStore: trackBeacon 加 timedJustCompleted + claimTimedBeacon action 工单 #3 星潮类型深化 (subagent 10-a): - starTide.ts: +3种星潮 surge(emerald)/eclipse(rose)/prism(fuchsia) - TideModifiers: +targetLenBonus/bossWinRateBonus/autoDecodeIntervalMult - decode.ts: generatePuzzle 加 targetLenBonus 参数 - achievements: ach_tides_all 阈值 6→9 工单 #4/P2 云排行榜 (subagent 10-b): - mini-services/leaderboard-service/ (端口3030, Hono+bun, 内存1000条) - API: GET/POST /api/leaderboard + /stats + CORS + 防刷 - beacon.ts: fetchCloudLeaderboard/submitCloudScore - BeaconPanel: 本地Top20/全球Top100 双tab + YOU徽章高亮 工单 #9 手写叙事 (subagent 10-c): - chronicle.ts: EPOCH_LORE 5纪元×3节点=15段手写叙事(80-150字/段) - buildLore 优先手写节点, fallback 模板, 11个变量替换 P3 socket 多人星潮 (subagent 10-c): - mini-services/star-tide-service/ (端口3031, socket.io) - 每10-15min广播global-tide, 60s持续, 6种类型权重 - useGlobalTide hook + triggerGlobalTide action - StarTideIndicator 加 🌐 全球星潮标记 P2 UI打磨: - page.tsx: CrystalOrb 区加装饰全息环(3层旋转) + 四角标记 + 顶部状态条 + 底部铭文 QA: lint零错误 + dev HTTP200 + VLM 8/10 + 2个mini-service运行中(3030/3031)
680 lines
29 KiB
TypeScript
Executable File
680 lines
29 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]}`;
|
||
}
|
||
|
||
// ============================================================================
|
||
// 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<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;
|
||
}
|
||
// 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<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 };
|