工单 #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)
1291 lines
41 KiB
TypeScript
Executable File
1291 lines
41 KiB
TypeScript
Executable File
// 回响星核 / 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;
|
||
/** 日期 key(YYYY-MM-DD)或周 key(YYYY-Www) */
|
||
dateKey: string;
|
||
/** 挑战类型 */
|
||
challenge: BeaconChallengeType;
|
||
/** 难度 */
|
||
difficulty: BeaconDifficulty;
|
||
/** 完成度(0-1,1 = 完成) */
|
||
progress: number;
|
||
/** 最终得分 */
|
||
score: number;
|
||
/** 完成时长(秒),未完成则记 0 */
|
||
durationSec: number;
|
||
/** v0.8:是否为周挑战记录(日挑战默认 false / undefined) */
|
||
isWeekly?: boolean;
|
||
/** v0.8.2:是否为限时挑战记录 */
|
||
isTimed?: boolean;
|
||
}
|
||
|
||
/** 每日挑战定义 */
|
||
export interface BeaconDailyChallenge {
|
||
/** 日期 key(YYYY-MM-DD,UTC) */
|
||
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;
|
||
}
|
||
|
||
/**
|
||
* 生成本周周挑战(确定性:同一周同一种子 → 同一挑战)。
|
||
* 难度强制 anomaly(60%)或 singular(40%),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-12(singular 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 → 7;streak=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 点切分,同时段同种子 → 同一挑战(确定性)。
|
||
// 难度固定 routine,goal = 日挑战基准 × 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 小时时段 key(UTC,0/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());
|
||
}
|
||
|
||
/**
|
||
* 生成当前时段的限时挑战(确定性:同时段同种子 → 同一挑战)。
|
||
* 难度固定 routine,goal = 日挑战基准 × 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(端口 3030,Hono + Bun)提供 API:
|
||
// GET /api/leaderboard → { entries, total }
|
||
// POST /api/leaderboard body { entry } → { entries, total, rank }
|
||
// GET /api/leaderboard/stats → { totalSubmissions, uniquePlayers, topScore }
|
||
// 前端请求必须用相对路径 + ?XTransformPort=3030(Caddy 网关转发规则)。
|
||
// 本地排行榜(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;
|
||
}
|
||
}
|