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
+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;
}
}