v0.8.2: 工单大清理 - 编年史分页+限时挑战+星潮深化+云排行榜+手写叙事+socket多人星潮+UI打磨

工单 #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)
This commit is contained in:
2026-06-23 23:57:41 +00:00
parent 6d7c52daa9
commit 191434e6ad
55 changed files with 9026 additions and 129 deletions
+2 -2
View File
@@ -244,10 +244,10 @@ export const ACHIEVEMENTS: Achievement[] = [
{
id: "ach_tides_all",
name: "星潮亲历者",
desc: "经历全部 6 种星潮事件",
desc: "经历全部 9 种星潮事件",
icon: "🌊",
color: "#34d399",
check: (s) => (s.starTidesEncountered?.length ?? 0) >= 6,
check: (s) => (s.starTidesEncountered?.length ?? 0) >= 9,
reward: { crystalsPerSecPct: 8, insightPct: 8 },
rewardText: "产能 +8% · 洞见 +8%",
},
+417 -1
View File
@@ -1,7 +1,8 @@
// 回响星核 / Echo Nexus — 深空信标
// v0.5:每日挑战 + 本地排行榜
// v0.8:周挑战 + 信标链(连续完成奖励)
// 一个自包含的"每日挑战 + 周挑战 + 连续完成链"元系统,纯 localStorage 持久化。
// v0.8.2:限时挑战(每 4 小时刷新,填补日挑战空档)
// 一个自包含的"每日 + 限时 + 周挑战 + 连续完成链"元系统,纯 localStorage 持久化。
/** 每日挑战类型 */
export type BeaconChallengeType =
@@ -32,6 +33,8 @@ export interface BeaconScoreEntry {
durationSec: number;
/** v0.8:是否为周挑战记录(日挑战默认 false / undefined */
isWeekly?: boolean;
/** v0.8.2:是否为限时挑战记录 */
isTimed?: boolean;
}
/** 每日挑战定义 */
@@ -334,6 +337,7 @@ export function claimBeaconReward(
rewardContact: challenge.rewardContact,
score,
leaderboard,
entry,
};
}
@@ -614,6 +618,7 @@ export function claimWeeklyReward(
rewardContact: challenge.rewardContact,
score,
leaderboard,
entry,
};
}
@@ -872,3 +877,414 @@ export function getChainProgress(streak: number): {
progressPct: Math.min(100, Math.max(0, pct)),
};
}
// ===========================================================================
// 限时挑战(TIMED CHALLENGE)— v0.8.2
// 每 4 小时刷新一个"短周期挑战",目标较小、奖励较少,填补日挑战的空档。
// 时段按 UTC 0/4/8/12/16/20 点切分,同时段同种子 → 同一挑战(确定性)。
// 难度固定 routinegoal = 日挑战基准 × 0.3-0.5。
// ===========================================================================
/** 限时挑战 localStorage key */
export const BEACON_TIMED_KEY = "echo-nexus-beacon-timed-v1";
/** 限时挑战定义 */
export interface BeaconTimedChallenge {
/** 时段 key(如 "timed_2026-06-24_14" 表示 14-18 点时段) */
slotKey: string;
/** 挑战类型 */
type: BeaconChallengeType;
/** 难度(固定 routine */
difficulty: BeaconDifficulty;
/** 目标数值(日挑战的 30-50% */
goal: number;
/** 奖励:完成时洞见 */
rewardInsight: number;
/** 奖励:完成时接触进度 */
rewardContact: number;
/** 使用的种子(可复现) */
seed: number;
/** 友好标题 */
title: string;
/** 描述 */
desc: string;
/** 时段结束时间戳(UTC,用于倒计时) */
expiresAt: number;
}
/** 限时挑战进度 */
export interface BeaconTimedProgress {
slotKey: string;
progress: number;
startedAt: number;
completedAt: number | null;
claimed: boolean;
durationSec: number;
}
/** 时段长度(毫秒) */
export const TIMED_SLOT_MS = 4 * 60 * 60 * 1000;
/**
* 取当前 4 小时时段 keyUTC0/4/8/12/16/20 点切分)。
* 格式:"timed_YYYY-MM-DD_HH",如 "timed_2026-06-24_14"。
*/
export function getTimedSlotKey(now: Date = new Date()): string {
const y = now.getUTCFullYear();
const m = String(now.getUTCMonth() + 1).padStart(2, "0");
const d = String(now.getUTCDate()).padStart(2, "0");
const hour = now.getUTCHours();
const slotStart = Math.floor(hour / 4) * 4; // 0, 4, 8, 12, 16, 20
return `timed_${y}-${m}-${d}_${String(slotStart).padStart(2, "0")}`;
}
/** slotKey → 数值种子(FNV-1a 哈希) */
function timedSlotKeyToSeed(slotKey: string): number {
let h = 2166136261 >>> 0;
for (let i = 0; i < slotKey.length; i++) {
h ^= slotKey.charCodeAt(i);
h = Math.imul(h, 16777619) >>> 0;
}
return h >>> 0;
}
/** 计算时段开始时间戳(UTC) */
function timedSlotStartMs(slotKey: string): number {
// 解析 "timed_YYYY-MM-DD_HH"
const match = slotKey.match(/^timed_(\d{4})-(\d{2})-(\d{2})_(\d{2})$/);
if (!match) return Date.now();
const [, y, m, d, h] = match;
return Date.UTC(Number(y), Number(m) - 1, Number(d), Number(h), 0, 0);
}
/** 距离下一个 4 小时时段的毫秒数(用于倒计时) */
export function msUntilNextTimedSlot(now: Date = new Date()): number {
const hour = now.getUTCHours();
const slotStart = Math.floor(hour / 4) * 4;
const slotStartMs = Date.UTC(
now.getUTCFullYear(),
now.getUTCMonth(),
now.getUTCDate(),
slotStart,
0,
0
);
const nextSlotMs = slotStartMs + TIMED_SLOT_MS;
return Math.max(0, nextSlotMs - now.getTime());
}
/**
* 生成当前时段的限时挑战(确定性:同时段同种子 → 同一挑战)。
* 难度固定 routinegoal = 日挑战基准 × 0.3-0.5(向下取整,最小 1)。
*/
export function generateTimedChallenge(
now: Date = new Date()
): BeaconTimedChallenge {
const slotKey = getTimedSlotKey(now);
const seed = timedSlotKeyToSeed(slotKey);
const rng = mulberry32(seed);
const expiresAt = timedSlotStartMs(slotKey) + TIMED_SLOT_MS;
const types: BeaconChallengeType[] = [
"decode",
"expedition",
"pulse",
"boss",
"insight",
];
const type = types[Math.floor(rng() * types.length)];
// 难度固定 routine
const difficulty: BeaconDifficulty = "routine";
const diffMult = BEACON_DIFFICULTY[difficulty].mult; // 1
// goal = 日挑战基准 × 0.3-0.5
const frac = 0.3 + rng() * 0.2; // 0.3-0.5
let goal = 0;
let rewardInsight = 0;
let rewardContact = 0;
let title = "";
let desc = "";
switch (type) {
case "decode":
// 日基准 6-15 × 0.3-0.5 → 2-7
goal = Math.max(2, Math.round((6 + Math.floor(rng() * 10)) * diffMult * frac));
rewardInsight = Math.round(goal * 3 * diffMult);
rewardContact = goal * 0.6;
title = `限时解码 ${goal} 颗晶体`;
desc = `4 小时内解码 ${goal} 颗记忆晶体。短周期,快速完成。`;
break;
case "expedition":
// 日基准 1-3 × 0.3-0.5 → 1(多数情况为 1
goal = Math.max(1, Math.round((1 + Math.floor(rng() * 3)) * diffMult * frac));
rewardInsight = Math.round(goal * 9 * diffMult);
rewardContact = goal * 1.2;
title = `限时远征 ${goal} 次探险`;
desc = `4 小时内完成 ${goal} 次遗迹探险。`;
break;
case "pulse":
// 日基准 20-49 × 0.3-0.5 → 6-24
goal = Math.max(6, Math.round((20 + Math.floor(rng() * 30)) * diffMult * frac));
rewardInsight = Math.round(goal * 1 * diffMult);
rewardContact = goal * 0.25;
title = `限时脉冲 ${goal} 次扫描`;
desc = `4 小时内发起 ${goal} 次脉冲扫描。`;
break;
case "boss":
// 日基准 1 × 0.3-0.5 → 1(向下取整为 1
goal = Math.max(1, Math.round(diffMult * frac));
rewardInsight = Math.round(goal * 25 * diffMult);
rewardContact = goal * 2.5;
title = `限时猎杀 ${goal} 处 BOSS`;
desc = `4 小时内击破 ${goal} 处维度 BOSS。`;
break;
case "insight":
// 日基准 40-119 × 0.3-0.5 → 12-59
goal = Math.max(12, Math.round((40 + Math.floor(rng() * 80)) * diffMult * frac));
rewardInsight = 0; // 洞见挑战不给洞见,给接触
rewardContact = goal * 0.08;
title = `限时洞见 ${goal}`;
desc = `4 小时内累计获取 ${goal} 洞见。`;
break;
}
return {
slotKey,
type,
difficulty,
goal,
rewardInsight,
rewardContact,
seed,
title,
desc,
expiresAt,
};
}
/** 读取当前时段进度(若 slotKey 不匹配则重置) */
export function loadTimedProgress(now: Date = new Date()): BeaconTimedProgress {
const slotKey = getTimedSlotKey(now);
const empty: BeaconTimedProgress = {
slotKey,
progress: 0,
startedAt: Date.now(),
completedAt: null,
claimed: false,
durationSec: 0,
};
if (typeof localStorage === "undefined") return empty;
try {
const raw = localStorage.getItem(BEACON_TIMED_KEY);
if (!raw) return empty;
const prog = JSON.parse(raw) as BeaconTimedProgress;
if (prog.slotKey !== slotKey) return empty; // 新时段,重置
return prog;
} catch {
return empty;
}
}
/** 保存当前时段进度 */
export function saveTimedProgress(prog: BeaconTimedProgress): void {
if (typeof localStorage === "undefined") return;
localStorage.setItem(BEACON_TIMED_KEY, JSON.stringify(prog));
}
/** 增量更新限时挑战进度,返回新进度 + 是否刚完成 */
export function addTimedProgress(
current: BeaconTimedProgress,
challenge: BeaconTimedChallenge,
delta: number
): { progress: BeaconTimedProgress; justCompleted: boolean } {
const newProgressVal = Math.min(challenge.goal, current.progress + delta);
const justCompleted =
current.completedAt === null && newProgressVal >= challenge.goal;
const completedAt = justCompleted ? Date.now() : current.completedAt;
const durationSec =
completedAt !== null
? Math.floor((completedAt - current.startedAt) / 1000)
: current.durationSec;
const next: BeaconTimedProgress = {
...current,
progress: newProgressVal,
completedAt,
durationSec,
};
saveTimedProgress(next);
return { progress: next, justCompleted };
}
/** 领取限时挑战奖励:返回奖励数值 + 推送排行榜 */
export function claimTimedReward(
challenge: BeaconTimedChallenge,
progress: BeaconTimedProgress
): {
rewardInsight: number;
rewardContact: number;
score: number;
leaderboard: BeaconScoreEntry[];
} {
if (progress.claimed || progress.completedAt === null) {
return {
rewardInsight: 0,
rewardContact: 0,
score: 0,
leaderboard: loadLeaderboard(),
};
}
// 借用 computeBeaconScore
const score = computeBeaconScore(
{ ...(challenge as unknown as BeaconDailyChallenge), dateKey: challenge.slotKey },
progress.progress,
progress.durationSec
);
const entry: BeaconScoreEntry = {
timestamp: Date.now(),
dateKey: challenge.slotKey,
challenge: challenge.type,
difficulty: challenge.difficulty,
progress: progress.progress / challenge.goal,
score,
durationSec: progress.durationSec,
isTimed: true,
};
const leaderboard = pushLeaderboardEntry(entry);
const updated: BeaconTimedProgress = { ...progress, claimed: true };
saveTimedProgress(updated);
return {
rewardInsight: challenge.rewardInsight,
rewardContact: challenge.rewardContact,
score,
leaderboard,
entry,
};
}
// ===========================================================================
// 云排行榜(CLOUD LEADERBOARD)— v0.8.2 P2/#4
// 把信标本机排行榜升级为云端 Top100,玩家间可比。
// 通过 mini-service(端口 3030Hono + Bun)提供 API
// GET /api/leaderboard → { entries, total }
// POST /api/leaderboard body { entry } → { entries, total, rank }
// GET /api/leaderboard/stats → { totalSubmissions, uniquePlayers, topScore }
// 前端请求必须用相对路径 + ?XTransformPort=3030Caddy 网关转发规则)。
// 本地排行榜(loadLeaderboard / pushLeaderboardEntry)保留作为离线 fallback。
// ===========================================================================
/** 云排行榜服务端口(Caddy 通过 ?XTransformPort=3030 转发) */
export const BEACON_CLOUD_PORT = 3030;
/** 上次成功提交到云端的 entry 时间戳(用于在全球榜中高亮"我的"记录) */
export const BEACON_CLOUD_LAST_SUBMIT_KEY = "echo-nexus-beacon-cloud-last-submit-v1";
/** 云排行榜 API 基础 URL(相对路径 + 网关端口参数,禁止 localhost:3030 */
const CLOUD_API_BASE = `/api/leaderboard?XTransformPort=${BEACON_CLOUD_PORT}`;
/** 云排行榜统计 API */
const CLOUD_STATS_URL = `/api/leaderboard/stats?XTransformPort=${BEACON_CLOUD_PORT}`;
/** 云端排行榜条目(与本地 BeaconScoreEntry 兼容,宽松化以接受历史数据) */
export interface CloudLeaderboardResponse {
entries: BeaconScoreEntry[];
total: number;
}
/** 提交响应(包含本次提交的全球名次) */
export interface CloudSubmitResponse {
entries: BeaconScoreEntry[];
total: number;
rank: number;
}
/** 云端统计响应 */
export interface CloudStatsResponse {
totalSubmissions: number;
uniquePlayers: number;
topScore: number;
}
/** 读取本地保存的"上次提交时间戳"(用于全球榜高亮) */
export function loadLastCloudSubmitTimestamp(): number {
if (typeof localStorage === "undefined") return 0;
try {
const raw = localStorage.getItem(BEACON_CLOUD_LAST_SUBMIT_KEY);
return raw ? Number(raw) || 0 : 0;
} catch {
return 0;
}
}
/** 保存"上次提交时间戳"到本地 */
function saveLastCloudSubmitTimestamp(timestamp: number): void {
if (typeof localStorage === "undefined") return;
try {
localStorage.setItem(BEACON_CLOUD_LAST_SUBMIT_KEY, String(timestamp));
} catch {
/* ignore */
}
}
/**
* 拉取全球排行榜 Top100。
* 失败时返回空数组(调用方负责展示错误状态),不抛异常。
*/
export async function fetchCloudLeaderboard(): Promise<BeaconScoreEntry[]> {
try {
const res = await fetch(CLOUD_API_BASE, {
method: "GET",
headers: { Accept: "application/json" },
});
if (!res.ok) return [];
const data = (await res.json()) as CloudLeaderboardResponse;
if (!data || !Array.isArray(data.entries)) return [];
return data.entries;
} catch {
return [];
}
}
/**
* 提交一条分数到云端。
* 成功时返回 rank(>=1);失败/被限流时返回 -1(静默,不影响本地)。
* 自动记录 timestamp 到 localStorage 供全球榜高亮。
*/
export async function submitCloudScore(entry: BeaconScoreEntry): Promise<number> {
try {
const res = await fetch(CLOUD_API_BASE, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({ entry }),
});
if (!res.ok) return -1;
const data = (await res.json()) as CloudSubmitResponse;
if (data && typeof data.rank === "number" && data.rank >= 1) {
saveLastCloudSubmitTimestamp(entry.timestamp);
}
return data?.rank ?? -1;
} catch {
return -1;
}
}
/**
* 拉取云端统计(用于 UI 展示总提交数 / 玩家数 / 最高分)。
* 失败时返回 null。
*/
export async function fetchCloudStats(): Promise<CloudStatsResponse | null> {
try {
const res = await fetch(CLOUD_STATS_URL, {
method: "GET",
headers: { Accept: "application/json" },
});
if (!res.ok) return null;
return (await res.json()) as CloudStatsResponse;
} catch {
return null;
}
}
+381 -15
View File
@@ -34,7 +34,348 @@ export function generateEpochName(ascensionNumber: number, seed: number): string
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;
@@ -48,6 +389,21 @@ export function buildLore(entry: {
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[] = [];
@@ -197,6 +553,10 @@ export function buildChronicleEntry(
blueprintsAfter,
crystalsDecoded: state.totalDecoded,
crystalsDecodedThisRun: deltas.decodedThisRun,
// v0.9 手写叙事:归档本周目 delta,让显示时能精确匹配变体
techsThisRun: deltas.techsThisRun,
expeditionsThisRun: deltas.expThisRun,
bossKillsThisRun: deltas.bossKillsThisRun,
},
perksThisAscension,
tidesThisRun: deltas.newTides,
@@ -210,13 +570,15 @@ export function buildChronicleEntry(
* 在显示时调用,保证历史与未来条目命名一致。
*/
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.techsUnlocked,
expThisRun: entry.summary.expeditionsCompleted,
bossKillsThisRun: entry.summary.bossKills,
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 || [],
@@ -270,17 +632,21 @@ export function withPerks(entry: ChronicleEntry, perks: string[]): ChronicleEntr
) {
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;
// 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,
+11 -3
View File
@@ -44,12 +44,20 @@ function makeRng(seed: number) {
* 这样至少存在一条解(即构造的路径),玩家不会遇到「怎么都过不了」的死局。
* 同时由于其余节点随机,玩家可能发现别的解路径,保留探索乐趣。
*/
export function generatePuzzle(tier: CrystalTier, seed?: number): DecodePuzzle {
export function generatePuzzle(
tier: CrystalTier,
seed?: number,
targetLenBonus: number = 0
): DecodePuzzle {
const cfg = DECODE_CONFIG[tier];
const s = seed ?? Math.floor(Math.random() * 1e9);
const rng = makeRng(s);
const { rows, cols, targetLen } = cfg;
// v0.8.2 涌动星潮:目标序列额外长度(更长谜题 = 更耗时),步数上限同步增加
const targetLen = cfg.targetLen + targetLenBonus;
const stepLimitBase = cfg.stepLimitBase + targetLenBonus;
const rows = cfg.rows;
const cols = cfg.cols;
const total = rows * cols;
// 目标序列:从 4 色中随机选 targetLen 个(允许重复,但保证至少 2 种色以增加策略)
@@ -132,7 +140,7 @@ export function generatePuzzle(tier: CrystalTier, seed?: number): DecodePuzzle {
cols,
target,
path: [],
stepLimit: cfg.stepLimitBase,
stepLimit: stepLimitBase,
seed: s,
};
}
+90 -3
View File
@@ -1,7 +1,7 @@
// 回响星核 / Echo Nexus — 星潮事件系统(周期性全局玩法修饰)
//
// 深空中偶发的「星潮」会短暂改变物理法则,给放置循环注入动态变化。
// 每 ~40s 间隙后触发一次持续 ~75s 的星潮,6 种类型各有正负效果。
// 每 ~40s 间隙后触发一次持续 ~75s 的星潮,9 种类型各有正负效果。
export type TideType =
| "crystal" // 晶体潮:产能 ×2
@@ -9,7 +9,11 @@ export type TideType =
| "ruins" // 遗迹共振:探险力 +5、生命 +30
| "void" // 虚空低语:洞见 ×2
| "core" // 星核悸动:接触率 ×3、脉冲 ×2
| "silence"; // 寂静期:产能 ×0.5,但结束时补偿洞见
| "silence" // 寂静期:产能 ×0.5,但结束时补偿洞见
// v0.8.2 新增三种星潮(深化事件池)
| "surge" // 涌动星潮:晶体产出 ×2.5 但解码目标 +2(产能爆发但解码变难)
| "eclipse" // 蚀相星潮:探险 BOSS 胜率 +20% 但晶体产出 -30%(探险黄金期)
| "prism"; // 棱镜星潮:洞见获取 ×2 + 自动解码周期 -30%(解码黄金期)
export interface StarTide {
type: TideType;
@@ -29,6 +33,12 @@ export interface TideModifiers {
expeditionHpBonus: number;
/** 寂静期结束时补偿的洞见(按持续秒数) */
silenceInsightPerSec?: number;
/** v0.8.2 涌动星潮:解码目标序列额外长度(+2 = 谜题更长更难) */
targetLenBonus?: number;
/** v0.8.2 蚀相星潮:BOSS 胜率加成(0.2 = +20% */
bossWinRateBonus?: number;
/** v0.8.2 棱镜星潮:自动解码周期乘数(0.7 = -30%) */
autoDecodeIntervalMult?: number;
}
export const TIDE_CONFIG = {
@@ -100,9 +110,34 @@ export const TIDE_EVENTS: Record<
icon: "◌",
negative: true,
},
// ===== v0.8.2 新增三种星潮 =====
surge: {
name: "涌动星潮",
desc: "深空晶体涌动,产能 ×2.5;但解码目标序列 +2,解码更耗时",
color: "#34d399",
glow: "rgba(52,211,153,0.55)",
icon: "⇈",
negative: false,
},
eclipse: {
name: "蚀相星潮",
desc: "维度蚀相,探险 BOSS 胜率 +20%;但晶体产出 -30%",
color: "#fb7185",
glow: "rgba(251,113,133,0.55)",
icon: "◐",
negative: false,
},
prism: {
name: "棱镜星潮",
desc: "棱镜折射,洞见获取 ×2、自动解码周期 -30%",
color: "#e879f9",
glow: "rgba(232,121,249,0.55)",
icon: "◬",
negative: false,
},
};
/** 权重表(寂静期概率略低) */
/** 权重表(寂静期概率略低v0.8.2 新增三种各 12 */
const TIDE_WEIGHTS: Record<TideType, number> = {
crystal: 22,
resonance: 20,
@@ -110,6 +145,9 @@ const TIDE_WEIGHTS: Record<TideType, number> = {
void: 18,
core: 14,
silence: 10,
surge: 12,
eclipse: 12,
prism: 12,
};
export function rollTide(rng: () => number = Math.random): TideType {
@@ -133,6 +171,9 @@ export function getTideModifiers(tide: StarTide | null): TideModifiers {
pulsePowerMult: 1,
expeditionPowerBonus: 0,
expeditionHpBonus: 0,
targetLenBonus: 0,
bossWinRateBonus: 0,
autoDecodeIntervalMult: 1,
};
}
switch (tide.type) {
@@ -196,6 +237,52 @@ export function getTideModifiers(tide: StarTide | null): TideModifiers {
expeditionPowerBonus: 0,
expeditionHpBonus: 0,
silenceInsightPerSec: 0.4,
targetLenBonus: 0,
bossWinRateBonus: 0,
autoDecodeIntervalMult: 1,
};
// ===== v0.8.2 新增三种星潮修饰器 =====
case "surge":
// 晶体产出 ×2.5,解码目标 +2(更长谜题 = 更耗时 = 更难)
return {
crystalsPerSecMult: 2.5,
decodeRewardMult: 1,
insightMultAdd: 0,
contactRateMult: 1,
pulsePowerMult: 1,
expeditionPowerBonus: 0,
expeditionHpBonus: 0,
targetLenBonus: 2,
bossWinRateBonus: 0,
autoDecodeIntervalMult: 1,
};
case "eclipse":
// BOSS 胜率 +20%,晶体产出 -30%
return {
crystalsPerSecMult: 0.7,
decodeRewardMult: 1,
insightMultAdd: 0,
contactRateMult: 1,
pulsePowerMult: 1,
expeditionPowerBonus: 0,
expeditionHpBonus: 0,
targetLenBonus: 0,
bossWinRateBonus: 0.2,
autoDecodeIntervalMult: 1,
};
case "prism":
// 洞见 ×2(+100%),自动解码周期 ×0.7-30%)
return {
crystalsPerSecMult: 1,
decodeRewardMult: 1,
insightMultAdd: 1,
contactRateMult: 1,
pulsePowerMult: 1,
expeditionPowerBonus: 0,
expeditionHpBonus: 0,
targetLenBonus: 0,
bossWinRateBonus: 0,
autoDecodeIntervalMult: 0.7,
};
}
}
+4
View File
@@ -174,6 +174,10 @@ export interface ChronicleEntry {
blueprintsAfter: number;
crystalsDecoded: number;
crystalsDecodedThisRun: number;
/** v0.9 手写叙事:本周目 delta(旧存档可能缺失,调用方需兜底) */
techsThisRun?: number;
expeditionsThisRun?: number;
bossKillsThisRun?: number;
};
/** 本次飞升获得的天赋 ID(来自星图觉醒) */
perksThisAscension: string[];