// 回响星核 / 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; 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 { 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 { 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 { 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; } }