Files
echo-nexus/src/lib/game/beacon.ts
T
Super_Z 191434e6ad 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)
2026-06-23 23:57:41 +00:00

1291 lines
41 KiB
TypeScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 回响星核 / Echo Nexus — 深空信标
// v0.5:每日挑战 + 本地排行榜
// v0.8:周挑战 + 信标链(连续完成奖励)
// v0.8.2:限时挑战(每 4 小时刷新,填补日挑战空档)
// 一个自包含的"每日 + 限时 + 周挑战 + 连续完成链"元系统,纯 localStorage 持久化。
/** 每日挑战类型 */
export type BeaconChallengeType =
| "decode" // 解码 N 颗晶体
| "expedition" // 完成 N 次探险
| "pulse" // 发起 N 次脉冲
| "boss" // 击破 N 处 BOSS
| "insight"; // 累计 N 洞见
/** 挑战难度档位 */
export type BeaconDifficulty = "routine" | "anomaly" | "singular";
/** 一条排行榜记录 */
export interface BeaconScoreEntry {
/** 提交时间戳 */
timestamp: number;
/** 日期 keyYYYY-MM-DD)或周 keyYYYY-Www */
dateKey: string;
/** 挑战类型 */
challenge: BeaconChallengeType;
/** 难度 */
difficulty: BeaconDifficulty;
/** 完成度(0-11 = 完成) */
progress: number;
/** 最终得分 */
score: number;
/** 完成时长(秒),未完成则记 0 */
durationSec: number;
/** v0.8:是否为周挑战记录(日挑战默认 false / undefined */
isWeekly?: boolean;
/** v0.8.2:是否为限时挑战记录 */
isTimed?: boolean;
}
/** 每日挑战定义 */
export interface BeaconDailyChallenge {
/** 日期 keyYYYY-MM-DDUTC */
dateKey: string;
/** 挑战类型 */
type: BeaconChallengeType;
/** 难度 */
difficulty: BeaconDifficulty;
/** 目标数值 */
goal: number;
/** 奖励:完成时洞见 */
rewardInsight: number;
/** 奖励:完成时接触进度 */
rewardContact: number;
/** 使用的种子(可复现) */
seed: number;
/** 友好标题 */
title: string;
/** 描述 */
desc: string;
}
/** 难度配置 */
export const BEACON_DIFFICULTY: Record<
BeaconDifficulty,
{ label: string; color: string; glow: string; mult: number; icon: string }
> = {
routine: {
label: "常规信标",
color: "#34d399",
glow: "rgba(52,211,153,0.45)",
mult: 1,
icon: "◍",
},
anomaly: {
label: "异常波动",
color: "#fbbf24",
glow: "rgba(251,191,36,0.45)",
mult: 1.6,
icon: "◈",
},
singular: {
label: "奇点回响",
color: "#f43f5e",
glow: "rgba(244,63,94,0.5)",
mult: 2.4,
icon: "✶",
},
};
/** 挑战类型元信息 */
export const BEACON_TYPE_META: Record<
BeaconChallengeType,
{ label: string; icon: string; unit: string; verb: string }
> = {
decode: { label: "解码协议", icon: "❖", unit: "颗", verb: "解码记忆晶体" },
expedition: { label: "远征指令", icon: "⬢", unit: "次", verb: "完成遗迹探险" },
pulse: { label: "脉冲任务", icon: "✺", unit: "次", verb: "发起脉冲扫描" },
boss: { label: "猎杀契约", icon: "☠", unit: "处", verb: "击破维度 BOSS" },
insight: { label: "洞见采集", icon: "✦", unit: "点", verb: "累计获取洞见" },
};
/** 取今日日期 key(UTC,保证全球同一天同一挑战) */
export function getTodayKey(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");
return `${y}-${m}-${d}`;
}
/** 把日期 key 转成数值种子 */
function dateKeyToSeed(dateKey: string): number {
let h = 2166136261 >>> 0;
for (let i = 0; i < dateKey.length; i++) {
h ^= dateKey.charCodeAt(i);
h = Math.imul(h, 16777619) >>> 0;
}
return h >>> 0;
}
/** mulberry32 PRNG(可复现) */
function mulberry32(seed: number): () => number {
let a = seed >>> 0;
return () => {
a |= 0;
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
/** 生成今日每日挑战(确定性:同一天同一种子 → 同一挑战) */
export function generateDailyChallenge(now: Date = new Date()): BeaconDailyChallenge {
const dateKey = getTodayKey(now);
const seed = dateKeyToSeed(dateKey);
const rng = mulberry32(seed);
// 选类型(5 种)
const types: BeaconChallengeType[] = ["decode", "expedition", "pulse", "boss", "insight"];
const type = types[Math.floor(rng() * types.length)];
// 选难度(加权:常规 55% / 异常 33% / 奇点 12%
const dr = rng();
const difficulty: BeaconDifficulty =
dr < 0.55 ? "routine" : dr < 0.88 ? "anomaly" : "singular";
const diffMult = BEACON_DIFFICULTY[difficulty].mult;
// 按类型定 goal 与奖励
let goal = 0;
let rewardInsight = 0;
let rewardContact = 0;
let title = "";
let desc = "";
switch (type) {
case "decode":
goal = Math.round((6 + Math.floor(rng() * 10)) * diffMult); // 6-15 × mult
rewardInsight = Math.round(goal * 4 * diffMult);
rewardContact = goal * 0.8;
title = `解码 ${goal} 颗记忆晶体`;
desc = `深空信标要求解码 ${goal} 颗晶体。谐振风暴期间效率更高。`;
break;
case "expedition":
goal = Math.max(1, Math.round((1 + Math.floor(rng() * 3)) * diffMult)); // 1-3 × mult
rewardInsight = Math.round(goal * 12 * diffMult);
rewardContact = goal * 1.5;
title = `完成 ${goal} 次遗迹探险`;
desc = `派出探险队完成 ${goal} 次远征,无论胜负均计入。`;
break;
case "pulse":
goal = Math.round((20 + Math.floor(rng() * 30)) * diffMult); // 20-49 × mult
rewardInsight = Math.round(goal * 1.2 * diffMult);
rewardContact = goal * 0.3;
title = `发起 ${goal} 次脉冲扫描`;
desc = `主动点击晶体发起 ${goal} 次脉冲。连击可叠加加成。`;
break;
case "boss":
goal = Math.max(1, Math.round(diffMult)); // 奇点至少 2-3
rewardInsight = Math.round(goal * 30 * diffMult);
rewardContact = goal * 3;
title = `击破 ${goal} 处维度 BOSS`;
desc = `在探险终点击破 ${goal} 处 BOSS。提升探险力后再挑战。`;
break;
case "insight":
goal = Math.round((40 + Math.floor(rng() * 80)) * diffMult); // 40-119 × mult
rewardInsight = 0; // 洞见挑战不给洞见,给接触
rewardContact = goal * 0.1;
title = `累计获取 ${goal} 洞见`;
desc = `通过解码、探险、星潮等途径累计 ${goal} 洞见。`;
break;
}
return {
dateKey,
type,
difficulty,
goal,
rewardInsight,
rewardContact,
seed,
title,
desc,
};
}
/** 计算挑战得分(用于排行榜) */
export function computeBeaconScore(
challenge: BeaconDailyChallenge,
progress: number,
durationSec: number
): number {
const completion = Math.min(1, progress / challenge.goal);
const diffMult = BEACON_DIFFICULTY[challenge.difficulty].mult;
// 基础分 = 完成度 × 难度 × 1000;完成时长越短加分越多(上限 +500)
const base = completion * 1000 * diffMult;
const speedBonus =
completion >= 1 && durationSec > 0 ? Math.max(0, 500 - durationSec * 0.5) : 0;
return Math.round(base + speedBonus);
}
/** 排行榜 localStorage key */
export const BEACON_LEADERBOARD_KEY = "echo-nexus-beacon-lb-v1";
/** 每日进度 localStorage key(记录今日进度 + 是否领奖) */
export const BEACON_PROGRESS_KEY = "echo-nexus-beacon-prog-v1";
/** 排行榜上限 */
export const BEACON_LB_MAX = 20;
/** 今日进度记录 */
export interface BeaconDailyProgress {
dateKey: string;
progress: number;
startedAt: number;
completedAt: number | null;
claimed: boolean;
durationSec: number;
}
/** 读取本地排行榜(按分数降序) */
export function loadLeaderboard(): BeaconScoreEntry[] {
if (typeof localStorage === "undefined") return [];
try {
const raw = localStorage.getItem(BEACON_LEADERBOARD_KEY);
if (!raw) return [];
const arr = JSON.parse(raw) as BeaconScoreEntry[];
return arr.sort((a, b) => b.score - a.score).slice(0, BEACON_LB_MAX);
} catch {
return [];
}
}
/** 写入一条排行榜记录 */
export function pushLeaderboardEntry(entry: BeaconScoreEntry): BeaconScoreEntry[] {
const lb = loadLeaderboard();
lb.push(entry);
lb.sort((a, b) => b.score - a.score);
const trimmed = lb.slice(0, BEACON_LB_MAX);
if (typeof localStorage !== "undefined") {
localStorage.setItem(BEACON_LEADERBOARD_KEY, JSON.stringify(trimmed));
}
return trimmed;
}
/** 读取今日进度(若 dateKey 不匹配则重置) */
export function loadDailyProgress(now: Date = new Date()): BeaconDailyProgress {
const todayKey = getTodayKey(now);
if (typeof localStorage === "undefined") {
return { dateKey: todayKey, progress: 0, startedAt: Date.now(), completedAt: null, claimed: false, durationSec: 0 };
}
try {
const raw = localStorage.getItem(BEACON_PROGRESS_KEY);
if (!raw) {
return { dateKey: todayKey, progress: 0, startedAt: Date.now(), completedAt: null, claimed: false, durationSec: 0 };
}
const prog = JSON.parse(raw) as BeaconDailyProgress;
if (prog.dateKey !== todayKey) {
// 新的一天,重置
return { dateKey: todayKey, progress: 0, startedAt: Date.now(), completedAt: null, claimed: false, durationSec: 0 };
}
return prog;
} catch {
return { dateKey: todayKey, progress: 0, startedAt: Date.now(), completedAt: null, claimed: false, durationSec: 0 };
}
}
/** 保存今日进度 */
export function saveDailyProgress(prog: BeaconDailyProgress): void {
if (typeof localStorage === "undefined") return;
localStorage.setItem(BEACON_PROGRESS_KEY, JSON.stringify(prog));
}
/** 增量更新进度,返回新进度 + 是否刚完成 */
export function addBeaconProgress(
current: BeaconDailyProgress,
challenge: BeaconDailyChallenge,
delta: number
): { progress: BeaconDailyProgress; 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: BeaconDailyProgress = {
...current,
progress: newProgressVal,
completedAt,
durationSec,
};
saveDailyProgress(next);
return { progress: next, justCompleted };
}
/** 领取奖励:返回奖励数值 + 推送排行榜 */
export function claimBeaconReward(
challenge: BeaconDailyChallenge,
progress: BeaconDailyProgress
): { rewardInsight: number; rewardContact: number; score: number; leaderboard: BeaconScoreEntry[] } {
if (progress.claimed || progress.completedAt === null) {
return { rewardInsight: 0, rewardContact: 0, score: 0, leaderboard: loadLeaderboard() };
}
const score = computeBeaconScore(challenge, progress.progress, progress.durationSec);
const entry: BeaconScoreEntry = {
timestamp: Date.now(),
dateKey: challenge.dateKey,
challenge: challenge.type,
difficulty: challenge.difficulty,
progress: progress.progress / challenge.goal,
score,
durationSec: progress.durationSec,
};
const leaderboard = pushLeaderboardEntry(entry);
const updated: BeaconDailyProgress = { ...progress, claimed: true };
saveDailyProgress(updated);
return {
rewardInsight: challenge.rewardInsight,
rewardContact: challenge.rewardContact,
score,
leaderboard,
entry,
};
}
/** 距离 UTC 次日 0 点的毫秒数(用于倒计时) */
export function msUntilNextDay(now: Date = new Date()): number {
const next = Date.UTC(
now.getUTCFullYear(),
now.getUTCMonth(),
now.getUTCDate() + 1,
0,
0,
0
);
return Math.max(0, next - now.getTime());
}
/** 格式化倒计时为 HH:MM:SS */
export function formatCountdown(ms: number): string {
const total = Math.max(0, Math.floor(ms / 1000));
const h = String(Math.floor(total / 3600)).padStart(2, "0");
const m = String(Math.floor((total % 3600) / 60)).padStart(2, "0");
const s = String(total % 60).padStart(2, "0");
return `${h}:${m}:${s}`;
}
// ===========================================================================
// 周挑战(WEEKLY CHALLENGE)— v0.8
// 每周一 UTC 0 点刷新一个"周挑战",目标更大、奖励更好,与日挑战并行存在。
// 同一周同一种子 → 同一挑战(确定性)。难度强制 anomaly/singular(加权 60/40)。
// ===========================================================================
/** 周挑战 localStorage key */
export const BEACON_WEEKLY_KEY = "echo-nexus-beacon-weekly-v1";
/** 周挑战定义 */
export interface BeaconWeeklyChallenge {
/** ISO 周键(YYYY-Www,如 "2026-W26" */
weekKey: string;
/** 挑战类型 */
type: BeaconChallengeType;
/** 难度(固定 anomaly 或 singular */
difficulty: "anomaly" | "singular";
/** 目标数值(日挑战的 3-5 倍) */
goal: number;
/** 奖励:完成时洞见 */
rewardInsight: number;
/** 奖励:完成时接触进度 */
rewardContact: number;
/** 使用的种子(可复现) */
seed: number;
/** 友好标题 */
title: string;
/** 描述 */
desc: string;
}
/** 周挑战进度 */
export interface BeaconWeeklyProgress {
weekKey: string;
progress: number;
startedAt: number;
completedAt: number | null;
claimed: boolean;
durationSec: number;
}
/**
* 计算 ISO 8601 周键(YYYY-Www),如 "2026-W26"。
* 规则:周一为一周开始;第一周是该年至少含 4 天的第一周(即包含第一个周四)。
*/
export function getWeekKey(now: Date = new Date()): string {
// 取 UTC 日期,避免时区偏移
const tmp = new Date(
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())
);
// ISO 周以周一为起点:(getUTCDay + 6) % 7 → 0=Mon, 6=Sun
const dayNum = (tmp.getUTCDay() + 6) % 7;
// 把日期调整到本周四(ISO 周归属年份由该周四所在的年份决定)
tmp.setUTCDate(tmp.getUTCDate() - dayNum + 3);
const isoYear = tmp.getUTCFullYear();
const yearStart = Date.UTC(isoYear, 0, 1);
const weekNum = Math.ceil(((tmp.getTime() - yearStart) / 86400000 + 1) / 7);
return `${isoYear}-W${String(weekNum).padStart(2, "0")}`;
}
/** weekKey → 数值种子(FNV-1a 哈希) */
function weekKeyToSeed(weekKey: string): number {
let h = 2166136261 >>> 0;
for (let i = 0; i < weekKey.length; i++) {
h ^= weekKey.charCodeAt(i);
h = Math.imul(h, 16777619) >>> 0;
}
return h >>> 0;
}
/**
* 生成本周周挑战(确定性:同一周同一种子 → 同一挑战)。
* 难度强制 anomaly60%)或 singular40%),goal 为日挑战基准 ×3-5 倍。
*/
export function generateWeeklyChallenge(
now: Date = new Date()
): BeaconWeeklyChallenge {
const weekKey = getWeekKey(now);
const seed = weekKeyToSeed(weekKey);
const rng = mulberry32(seed);
const types: BeaconChallengeType[] = [
"decode",
"expedition",
"pulse",
"boss",
"insight",
];
const type = types[Math.floor(rng() * types.length)];
// 难度加权:anomaly 60% / singular 40%
const dr = rng();
const difficulty: "anomaly" | "singular" = dr < 0.6 ? "anomaly" : "singular";
const diffMult = BEACON_DIFFICULTY[difficulty].mult;
// 3-5 倍
const mult = 3 + Math.floor(rng() * 3);
let goal = 0;
let rewardInsight = 0;
let rewardContact = 0;
let title = "";
let desc = "";
switch (type) {
case "decode":
// 日基准 6-15 × mult(3-5) → 18-75
goal = Math.max(18, Math.round((6 + Math.floor(rng() * 10)) * diffMult * mult));
rewardInsight = Math.round(goal * 5 * diffMult);
rewardContact = goal * 1.2;
title = `周界解码 · ${goal} 颗晶体`;
desc = `本周深空信标要求解码 ${goal} 颗记忆晶体,强度为日挑战的 ${mult} 倍。`;
break;
case "expedition":
// 日基准 1-3 × mult(3-5) → 3-15
goal = Math.max(3, Math.round((1 + Math.floor(rng() * 3)) * diffMult * mult));
rewardInsight = Math.round(goal * 14 * diffMult);
rewardContact = goal * 2;
title = `周界远征 · ${goal} 次探险`;
desc = `本周派出探险队完成 ${goal} 次远征,无论胜负均计入。`;
break;
case "pulse":
// 日基准 20-49 × mult(3-5) → 60-245
goal = Math.max(60, Math.round((20 + Math.floor(rng() * 30)) * diffMult * mult));
rewardInsight = Math.round(goal * 1.5 * diffMult);
rewardContact = goal * 0.4;
title = `周界脉冲 · ${goal} 次扫描`;
desc = `本周发起 ${goal} 次脉冲扫描。连击可叠加加成。`;
break;
case "boss":
// 日基准 1 × mult(3-5) → 3-12singular mult=2.4 时上限 12
goal = Math.max(3, Math.round(mult * diffMult));
rewardInsight = Math.round(goal * 35 * diffMult);
rewardContact = goal * 4;
title = `周界猎杀 · ${goal} 处 BOSS`;
desc = `本周在探险终点击破 ${goal} 处维度 BOSS。提升探险力后再挑战。`;
break;
case "insight":
// 日基准 40-119 × mult(3-5) → 120-595
goal = Math.max(120, Math.round((40 + Math.floor(rng() * 80)) * diffMult * mult));
rewardInsight = 0; // 洞见挑战不给洞见,给接触
rewardContact = goal * 0.15;
title = `周界洞见 · ${goal} 点`;
desc = `本周通过解码、探险、星潮等途径累计 ${goal} 洞见。`;
break;
}
return {
weekKey,
type,
difficulty,
goal,
rewardInsight,
rewardContact,
seed,
title,
desc,
};
}
/** 读取本周进度(若 weekKey 不匹配则重置) */
export function loadWeeklyProgress(now: Date = new Date()): BeaconWeeklyProgress {
const weekKey = getWeekKey(now);
const empty: BeaconWeeklyProgress = {
weekKey,
progress: 0,
startedAt: Date.now(),
completedAt: null,
claimed: false,
durationSec: 0,
};
if (typeof localStorage === "undefined") return empty;
try {
const raw = localStorage.getItem(BEACON_WEEKLY_KEY);
if (!raw) return empty;
const prog = JSON.parse(raw) as BeaconWeeklyProgress;
if (prog.weekKey !== weekKey) return empty; // 新的一周,重置
return prog;
} catch {
return empty;
}
}
/** 保存本周进度 */
export function saveWeeklyProgress(prog: BeaconWeeklyProgress): void {
if (typeof localStorage === "undefined") return;
localStorage.setItem(BEACON_WEEKLY_KEY, JSON.stringify(prog));
}
/** 增量更新周挑战进度,返回新进度 + 是否刚完成 */
export function addWeeklyProgress(
current: BeaconWeeklyProgress,
challenge: BeaconWeeklyChallenge,
delta: number
): { progress: BeaconWeeklyProgress; 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: BeaconWeeklyProgress = {
...current,
progress: newProgressVal,
completedAt,
durationSec,
};
saveWeeklyProgress(next);
return { progress: next, justCompleted };
}
/** 领取周挑战奖励:返回奖励数值 + 推送排行榜 */
export function claimWeeklyReward(
challenge: BeaconWeeklyChallenge,
progress: BeaconWeeklyProgress
): {
rewardInsight: number;
rewardContact: number;
score: number;
leaderboard: BeaconScoreEntry[];
} {
if (progress.claimed || progress.completedAt === null) {
return {
rewardInsight: 0,
rewardContact: 0,
score: 0,
leaderboard: loadLeaderboard(),
};
}
// 借用 computeBeaconScore:把 weekly 包装成 daily 接口(dateKey 字段不影响计分逻辑)
const score = computeBeaconScore(
{ ...(challenge as unknown as BeaconDailyChallenge), dateKey: challenge.weekKey },
progress.progress,
progress.durationSec
);
const entry: BeaconScoreEntry = {
timestamp: Date.now(),
dateKey: challenge.weekKey,
challenge: challenge.type,
difficulty: challenge.difficulty,
progress: progress.progress / challenge.goal,
score,
durationSec: progress.durationSec,
isWeekly: true,
};
const leaderboard = pushLeaderboardEntry(entry);
const updated: BeaconWeeklyProgress = { ...progress, claimed: true };
saveWeeklyProgress(updated);
return {
rewardInsight: challenge.rewardInsight,
rewardContact: challenge.rewardContact,
score,
leaderboard,
entry,
};
}
/** 距离下周一 UTC 0 点的毫秒数(用于周挑战倒计时) */
export function msUntilNextWeek(now: Date = new Date()): number {
const dayNum = (now.getUTCDay() + 6) % 7; // 0 = Mon
const mondayThisWeek = Date.UTC(
now.getUTCFullYear(),
now.getUTCMonth(),
now.getUTCDate() - dayNum,
0,
0,
0
);
const nextMonday = mondayThisWeek + 7 * 86400000;
return Math.max(0, nextMonday - now.getTime());
}
// ===========================================================================
// 信标链(BEACON CHAIN)— v0.8
// 连续完成日挑战形成"链",达成里程碑(3/7/14/30 天)领取递增大奖。
// 断链有宽容机制(1 天 miss 不断链,用"信标续命"概念),每条链只能用 1 次。
// ===========================================================================
/** 信标链 localStorage key */
export const BEACON_CHAIN_KEY = "echo-nexus-beacon-chain-v1";
/** 里程碑天数 */
export const BEACON_CHAIN_MILESTONES = [3, 7, 14, 30] as const;
/** 里程碑奖励配置 */
export interface BeaconChainReward {
milestone: number;
rewardInsight: number;
rewardContact: number;
label: string;
}
export const BEACON_CHAIN_REWARDS: BeaconChainReward[] = [
{ milestone: 3, rewardInsight: 50, rewardContact: 5, label: "三日谐振" },
{ milestone: 7, rewardInsight: 120, rewardContact: 12, label: "七日回响" },
{ milestone: 14, rewardInsight: 280, rewardContact: 28, label: "半月星潮" },
{ milestone: 30, rewardInsight: 680, rewardContact: 68, label: "满月飞升" },
];
/** 信标链状态 */
export interface BeaconChainState {
/** 上次完成日(YYYY-MM-DD */
lastCompletedDateKey: string;
/** 当前连续天数 */
currentStreak: number;
/** 历史最长 */
longestStreak: number;
/** 累计完成总数 */
totalCompletions: number;
/** 本周期已用续命数(上限 1) */
graceUsed: number;
/** 已领取的里程碑数组 */
milestonesClaimed: number[];
}
/** 读取信标链状态 */
export function loadChainState(): BeaconChainState {
const fresh = (): BeaconChainState => ({
lastCompletedDateKey: "",
currentStreak: 0,
longestStreak: 0,
totalCompletions: 0,
graceUsed: 0,
milestonesClaimed: [],
});
if (typeof localStorage === "undefined") return fresh();
try {
const raw = localStorage.getItem(BEACON_CHAIN_KEY);
if (!raw) return fresh();
const s = JSON.parse(raw) as Partial<BeaconChainState>;
return {
lastCompletedDateKey: s.lastCompletedDateKey ?? "",
currentStreak: s.currentStreak ?? 0,
longestStreak: s.longestStreak ?? 0,
totalCompletions: s.totalCompletions ?? 0,
graceUsed: s.graceUsed ?? 0,
milestonesClaimed: Array.isArray(s.milestonesClaimed)
? [...s.milestonesClaimed]
: [],
};
} catch {
return fresh();
}
}
/** 保存信标链状态 */
export function saveChainState(state: BeaconChainState): void {
if (typeof localStorage === "undefined") return;
localStorage.setItem(BEACON_CHAIN_KEY, JSON.stringify(state));
}
/** dateKey → UTC 0 点时间戳 */
function dateKeyToTimestamp(dateKey: string): number {
const [y, m, d] = dateKey.split("-").map(Number);
return Date.UTC(y, m - 1, d, 0, 0, 0);
}
/** 计算 b - a 相差的天数(UTC 0 点对齐) */
function dateKeyDiffDays(a: string, b: string): number {
if (!a || !b) return Number.MAX_SAFE_INTEGER;
return Math.round((dateKeyToTimestamp(b) - dateKeyToTimestamp(a)) / 86400000);
}
/**
* 记录一次日挑战完成(核心逻辑)。
* - dateKey === lastCompletedDateKey:同一天重复完成,忽略,返回 newMilestones: []
* - dateKey 是 lastCompletedDateKey 的次日:currentStreak++
* - dateKey 是 lastCompletedDateKey 的后两天(隔了一天 miss)且 graceUsed < 1:续命一次,currentStreak++
* - 其他:currentStreak = 1(断链重来),graceUsed = 0
* 更新 longestStreak 与 totalCompletions。
* @returns { state, newMilestones } 刚达成但未领取的里程碑数组
*/
export function recordChainCompletion(dateKey: string): {
state: BeaconChainState;
newMilestones: number[];
} {
const state = loadChainState();
// 同一天重复完成:忽略
if (state.lastCompletedDateKey === dateKey) {
return { state, newMilestones: [] };
}
let next: BeaconChainState;
if (state.lastCompletedDateKey === "") {
// 首次完成
next = {
...state,
lastCompletedDateKey: dateKey,
currentStreak: 1,
longestStreak: Math.max(state.longestStreak, 1),
totalCompletions: state.totalCompletions + 1,
};
} else {
const diff = dateKeyDiffDays(state.lastCompletedDateKey, dateKey);
if (diff === 1) {
// 次日:链 +1
const newStreak = state.currentStreak + 1;
next = {
...state,
lastCompletedDateKey: dateKey,
currentStreak: newStreak,
longestStreak: Math.max(state.longestStreak, newStreak),
totalCompletions: state.totalCompletions + 1,
};
} else if (diff === 2 && state.graceUsed < 1) {
// 隔一天 miss,续命一次
const newStreak = state.currentStreak + 1;
next = {
...state,
lastCompletedDateKey: dateKey,
currentStreak: newStreak,
longestStreak: Math.max(state.longestStreak, newStreak),
totalCompletions: state.totalCompletions + 1,
graceUsed: state.graceUsed + 1,
};
} else {
// 断链重来
next = {
...state,
lastCompletedDateKey: dateKey,
currentStreak: 1,
totalCompletions: state.totalCompletions + 1,
graceUsed: 0,
longestStreak: Math.max(state.longestStreak, 1),
};
}
}
// 检查新里程碑(刚达成但未领取)
const newMilestones: number[] = [];
for (const m of BEACON_CHAIN_MILESTONES) {
if (
next.currentStreak >= m &&
!next.milestonesClaimed.includes(m)
) {
newMilestones.push(m);
}
}
saveChainState(next);
return { state: next, newMilestones };
}
/** 领取里程碑奖励,加入 milestonesClaimed */
export function claimChainMilestone(milestone: number): {
rewardInsight: number;
rewardContact: number;
label: string;
state: BeaconChainState;
} {
const reward = BEACON_CHAIN_REWARDS.find((r) => r.milestone === milestone);
const state = loadChainState();
if (
!reward ||
state.milestonesClaimed.includes(milestone) ||
state.currentStreak < milestone
) {
return { rewardInsight: 0, rewardContact: 0, label: "", state };
}
const next: BeaconChainState = {
...state,
milestonesClaimed: [...state.milestonesClaimed, milestone],
};
saveChainState(next);
return {
rewardInsight: reward.rewardInsight,
rewardContact: reward.rewardContact,
label: reward.label,
state: next,
};
}
/** 返回下一个目标里程碑(如 streak=5 → 7streak=30+ → null */
export function getNextMilestone(streak: number): number | null {
for (const m of BEACON_CHAIN_MILESTONES) {
if (streak < m) return m;
}
return null;
}
/**
* 返回信标链进度信息(用于 UI 进度条)。
* - current:当前连续天数
* - next:下一个目标里程碑(null 表示已通关全部)
* - progressPct:当前进度(基于上一个里程碑 → 下一个里程碑)
*/
export function getChainProgress(streak: number): {
current: number;
next: number | null;
prev: number;
progressPct: number;
} {
const next = getNextMilestone(streak);
let prev = 0;
for (const m of BEACON_CHAIN_MILESTONES) {
if (streak >= m) prev = m;
}
if (next === null) {
return { current: streak, next: null, prev, progressPct: 100 };
}
const span = next - prev;
const done = streak - prev;
const pct = span > 0 ? Math.round((done / span) * 100) : 100;
return {
current: streak,
next,
prev,
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;
}
}