1→ 1→// 回响星核 / Echo Nexus — 深空信标 2→ 2→// v0.5:每日挑战 + 本地排行榜 3→ 3→// v0.8:周挑战 + 信标链(连续完成奖励) 4→ 4→// 一个自包含的"每日挑战 + 周挑战 + 连续完成链"元系统,纯 localStorage 持久化。 5→ 5→ 6→ 6→/** 每日挑战类型 */ 7→ 7→export type BeaconChallengeType = 8→ 8→ | "decode" // 解码 N 颗晶体 9→ 9→ | "expedition" // 完成 N 次探险 10→ 10→ | "pulse" // 发起 N 次脉冲 11→ 11→ | "boss" // 击破 N 处 BOSS 12→ 12→ | "insight"; // 累计 N 洞见 13→ 13→ 14→ 14→/** 挑战难度档位 */ 15→ 15→export type BeaconDifficulty = "routine" | "anomaly" | "singular"; 16→ 16→ 17→ 17→/** 一条排行榜记录 */ 18→ 18→export interface BeaconScoreEntry { 19→ 19→ /** 提交时间戳 */ 20→ 20→ timestamp: number; 21→ 21→ /** 日期 key(YYYY-MM-DD)或周 key(YYYY-Www) */ 22→ 22→ dateKey: string; 23→ 23→ /** 挑战类型 */ 24→ 24→ challenge: BeaconChallengeType; 25→ 25→ /** 难度 */ 26→ 26→ difficulty: BeaconDifficulty; 27→ 27→ /** 完成度(0-1,1 = 完成) */ 28→ 28→ progress: number; 29→ 29→ /** 最终得分 */ 30→ 30→ score: number; 31→ 31→ /** 完成时长(秒),未完成则记 0 */ 32→ 32→ durationSec: number; 33→ 33→ /** v0.8:是否为周挑战记录(日挑战默认 false / undefined) */ 34→ 34→ isWeekly?: boolean; 35→ 35→} 36→ 36→ 37→ 37→/** 每日挑战定义 */ 38→ 38→export interface BeaconDailyChallenge { 39→ 39→ /** 日期 key(YYYY-MM-DD,UTC) */ 40→ 40→ dateKey: string; 41→ 41→ /** 挑战类型 */ 42→ 42→ type: BeaconChallengeType; 43→ 43→ /** 难度 */ 44→ 44→ difficulty: BeaconDifficulty; 45→ 45→ /** 目标数值 */ 46→ 46→ goal: number; 47→ 47→ /** 奖励:完成时洞见 */ 48→ 48→ rewardInsight: number; 49→ 49→ /** 奖励:完成时接触进度 */ 50→ 50→ rewardContact: number; 51→ 51→ /** 使用的种子(可复现) */ 52→ 52→ seed: number; 53→ 53→ /** 友好标题 */ 54→ 54→ title: string; 55→ 55→ /** 描述 */ 56→ 56→ desc: string; 57→ 57→} 58→ 58→ 59→ 59→/** 难度配置 */ 60→ 60→export const BEACON_DIFFICULTY: Record< 61→ 61→ BeaconDifficulty, 62→ 62→ { label: string; color: string; glow: string; mult: number; icon: string } 63→ 63→> = { 64→ 64→ routine: { 65→ 65→ label: "常规信标", 66→ 66→ color: "#34d399", 67→ 67→ glow: "rgba(52,211,153,0.45)", 68→ 68→ mult: 1, 69→ 69→ icon: "◍", 70→ 70→ }, 71→ 71→ anomaly: { 72→ 72→ label: "异常波动", 73→ 73→ color: "#fbbf24", 74→ 74→ glow: "rgba(251,191,36,0.45)", 75→ 75→ mult: 1.6, 76→ 76→ icon: "◈", 77→ 77→ }, 78→ 78→ singular: { 79→ 79→ label: "奇点回响", 80→ 80→ color: "#f43f5e", 81→ 81→ glow: "rgba(244,63,94,0.5)", 82→ 82→ mult: 2.4, 83→ 83→ icon: "✶", 84→ 84→ }, 85→ 85→}; 86→ 86→ 87→ 87→/** 挑战类型元信息 */ 88→ 88→export const BEACON_TYPE_META: Record< 89→ 89→ BeaconChallengeType, 90→ 90→ { label: string; icon: string; unit: string; verb: string } 91→ 91→> = { 92→ 92→ decode: { label: "解码协议", icon: "❖", unit: "颗", verb: "解码记忆晶体" }, 93→ 93→ expedition: { label: "远征指令", icon: "⬢", unit: "次", verb: "完成遗迹探险" }, 94→ 94→ pulse: { label: "脉冲任务", icon: "✺", unit: "次", verb: "发起脉冲扫描" }, 95→ 95→ boss: { label: "猎杀契约", icon: "☠", unit: "处", verb: "击破维度 BOSS" }, 96→ 96→ insight: { label: "洞见采集", icon: "✦", unit: "点", verb: "累计获取洞见" }, 97→ 97→}; 98→ 98→ 99→ 99→/** 取今日日期 key(UTC,保证全球同一天同一挑战) */ 100→ 100→export function getTodayKey(now: Date = new Date()): string { 101→ 101→ const y = now.getUTCFullYear(); 102→ 102→ const m = String(now.getUTCMonth() + 1).padStart(2, "0"); 103→ 103→ const d = String(now.getUTCDate()).padStart(2, "0"); 104→ 104→ return `${y}-${m}-${d}`; 105→ 105→} 106→ 106→ 107→ 107→/** 把日期 key 转成数值种子 */ 108→ 108→function dateKeyToSeed(dateKey: string): number { 109→ 109→ let h = 2166136261 >>> 0; 110→ 110→ for (let i = 0; i < dateKey.length; i++) { 111→ 111→ h ^= dateKey.charCodeAt(i); 112→ 112→ h = Math.imul(h, 16777619) >>> 0; 113→ 113→ } 114→ 114→ return h >>> 0; 115→ 115→} 116→ 116→ 117→ 117→/** mulberry32 PRNG(可复现) */ 118→ 118→function mulberry32(seed: number): () => number { 119→ 119→ let a = seed >>> 0; 120→ 120→ return () => { 121→ 121→ a |= 0; 122→ 122→ a = (a + 0x6d2b79f5) | 0; 123→ 123→ let t = Math.imul(a ^ (a >>> 15), 1 | a); 124→ 124→ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; 125→ 125→ return ((t ^ (t >>> 14)) >>> 0) / 4294967296; 126→ 126→ }; 127→ 127→} 128→ 128→ 129→ 129→/** 生成今日每日挑战(确定性:同一天同一种子 → 同一挑战) */ 130→ 130→export function generateDailyChallenge(now: Date = new Date()): BeaconDailyChallenge { 131→ 131→ const dateKey = getTodayKey(now); 132→ 132→ const seed = dateKeyToSeed(dateKey); 133→ 133→ const rng = mulberry32(seed); 134→ 134→ 135→ 135→ // 选类型(5 种) 136→ 136→ const types: BeaconChallengeType[] = ["decode", "expedition", "pulse", "boss", "insight"]; 137→ 137→ const type = types[Math.floor(rng() * types.length)]; 138→ 138→ 139→ 139→ // 选难度(加权:常规 55% / 异常 33% / 奇点 12%) 140→ 140→ const dr = rng(); 141→ 141→ const difficulty: BeaconDifficulty = 142→ 142→ dr < 0.55 ? "routine" : dr < 0.88 ? "anomaly" : "singular"; 143→ 143→ 144→ 144→ const diffMult = BEACON_DIFFICULTY[difficulty].mult; 145→ 145→ 146→ 146→ // 按类型定 goal 与奖励 147→ 147→ let goal = 0; 148→ 148→ let rewardInsight = 0; 149→ 149→ let rewardContact = 0; 150→ 150→ let title = ""; 151→ 151→ let desc = ""; 152→ 152→ 153→ 153→ switch (type) { 154→ 154→ case "decode": 155→ 155→ goal = Math.round((6 + Math.floor(rng() * 10)) * diffMult); // 6-15 × mult 156→ 156→ rewardInsight = Math.round(goal * 4 * diffMult); 157→ 157→ rewardContact = goal * 0.8; 158→ 158→ title = `解码 ${goal} 颗记忆晶体`; 159→ 159→ desc = `深空信标要求解码 ${goal} 颗晶体。谐振风暴期间效率更高。`; 160→ 160→ break; 161→ 161→ case "expedition": 162→ 162→ goal = Math.max(1, Math.round((1 + Math.floor(rng() * 3)) * diffMult)); // 1-3 × mult 163→ 163→ rewardInsight = Math.round(goal * 12 * diffMult); 164→ 164→ rewardContact = goal * 1.5; 165→ 165→ title = `完成 ${goal} 次遗迹探险`; 166→ 166→ desc = `派出探险队完成 ${goal} 次远征,无论胜负均计入。`; 167→ 167→ break; 168→ 168→ case "pulse": 169→ 169→ goal = Math.round((20 + Math.floor(rng() * 30)) * diffMult); // 20-49 × mult 170→ 170→ rewardInsight = Math.round(goal * 1.2 * diffMult); 171→ 171→ rewardContact = goal * 0.3; 172→ 172→ title = `发起 ${goal} 次脉冲扫描`; 173→ 173→ desc = `主动点击晶体发起 ${goal} 次脉冲。连击可叠加加成。`; 174→ 174→ break; 175→ 175→ case "boss": 176→ 176→ goal = Math.max(1, Math.round(diffMult)); // 奇点至少 2-3 177→ 177→ rewardInsight = Math.round(goal * 30 * diffMult); 178→ 178→ rewardContact = goal * 3; 179→ 179→ title = `击破 ${goal} 处维度 BOSS`; 180→ 180→ desc = `在探险终点击破 ${goal} 处 BOSS。提升探险力后再挑战。`; 181→ 181→ break; 182→ 182→ case "insight": 183→ 183→ goal = Math.round((40 + Math.floor(rng() * 80)) * diffMult); // 40-119 × mult 184→ 184→ rewardInsight = 0; // 洞见挑战不给洞见,给接触 185→ 185→ rewardContact = goal * 0.1; 186→ 186→ title = `累计获取 ${goal} 洞见`; 187→ 187→ desc = `通过解码、探险、星潮等途径累计 ${goal} 洞见。`; 188→ 188→ break; 189→ 189→ } 190→ 190→ 191→ 191→ return { 192→ 192→ dateKey, 193→ 193→ type, 194→ 194→ difficulty, 195→ 195→ goal, 196→ 196→ rewardInsight, 197→ 197→ rewardContact, 198→ 198→ seed, 199→ 199→ title, 200→ 200→ desc, 201→ 201→ }; 202→ 202→} 203→ 203→ 204→ 204→/** 计算挑战得分(用于排行榜) */ 205→ 205→export function computeBeaconScore( 206→ 206→ challenge: BeaconDailyChallenge, 207→ 207→ progress: number, 208→ 208→ durationSec: number 209→ 209→): number { 210→ 210→ const completion = Math.min(1, progress / challenge.goal); 211→ 211→ const diffMult = BEACON_DIFFICULTY[challenge.difficulty].mult; 212→ 212→ // 基础分 = 完成度 × 难度 × 1000;完成时长越短加分越多(上限 +500) 213→ 213→ const base = completion * 1000 * diffMult; 214→ 214→ const speedBonus = 215→ 215→ completion >= 1 && durationSec > 0 ? Math.max(0, 500 - durationSec * 0.5) : 0; 216→ 216→ return Math.round(base + speedBonus); 217→ 217→} 218→ 218→ 219→ 219→/** 排行榜 localStorage key */ 220→ 220→export const BEACON_LEADERBOARD_KEY = "echo-nexus-beacon-lb-v1"; 221→ 221→/** 每日进度 localStorage key(记录今日进度 + 是否领奖) */ 222→ 222→export const BEACON_PROGRESS_KEY = "echo-nexus-beacon-prog-v1"; 223→ 223→ 224→ 224→/** 排行榜上限 */ 225→ 225→export const BEACON_LB_MAX = 20; 226→ 226→ 227→ 227→/** 今日进度记录 */ 228→ 228→export interface BeaconDailyProgress { 229→ 229→ dateKey: string; 230→ 230→ progress: number; 231→ 231→ startedAt: number; 232→ 232→ completedAt: number | null; 233→ 233→ claimed: boolean; 234→ 234→ durationSec: number; 235→ 235→} 236→ 236→ 237→ 237→/** 读取本地排行榜(按分数降序) */ 238→ 238→export function loadLeaderboard(): BeaconScoreEntry[] { 239→ 239→ if (typeof localStorage === "undefined") return []; 240→ 240→ try { 241→ 241→ const raw = localStorage.getItem(BEACON_LEADERBOARD_KEY); 242→ 242→ if (!raw) return []; 243→ 243→ const arr = JSON.parse(raw) as BeaconScoreEntry[]; 244→ 244→ return arr.sort((a, b) => b.score - a.score).slice(0, BEACON_LB_MAX); 245→ 245→ } catch { 246→ 246→ return []; 247→ 247→ } 248→ 248→} 249→ 249→ 250→ 250→/** 写入一条排行榜记录 */ 251→ 251→export function pushLeaderboardEntry(entry: BeaconScoreEntry): BeaconScoreEntry[] { 252→ 252→ const lb = loadLeaderboard(); 253→ 253→ lb.push(entry); 254→ 254→ lb.sort((a, b) => b.score - a.score); 255→ 255→ const trimmed = lb.slice(0, BEACON_LB_MAX); 256→ 256→ if (typeof localStorage !== "undefined") { 257→ 257→ localStorage.setItem(BEACON_LEADERBOARD_KEY, JSON.stringify(trimmed)); 258→ 258→ } 259→ 259→ return trimmed; 260→ 260→} 261→ 261→ 262→ 262→/** 读取今日进度(若 dateKey 不匹配则重置) */ 263→ 263→export function loadDailyProgress(now: Date = new Date()): BeaconDailyProgress { 264→ 264→ const todayKey = getTodayKey(now); 265→ 265→ if (typeof localStorage === "undefined") { 266→ 266→ return { dateKey: todayKey, progress: 0, startedAt: Date.now(), completedAt: null, claimed: false, durationSec: 0 }; 267→ 267→ } 268→ 268→ try { 269→ 269→ const raw = localStorage.getItem(BEACON_PROGRESS_KEY); 270→ 270→ if (!raw) { 271→ 271→ return { dateKey: todayKey, progress: 0, startedAt: Date.now(), completedAt: null, claimed: false, durationSec: 0 }; 272→ 272→ } 273→ 273→ const prog = JSON.parse(raw) as BeaconDailyProgress; 274→ 274→ if (prog.dateKey !== todayKey) { 275→ 275→ // 新的一天,重置 276→ 276→ return { dateKey: todayKey, progress: 0, startedAt: Date.now(), completedAt: null, claimed: false, durationSec: 0 }; 277→ 277→ } 278→ 278→ return prog; 279→ 279→ } catch { 280→ 280→ return { dateKey: todayKey, progress: 0, startedAt: Date.now(), completedAt: null, claimed: false, durationSec: 0 }; 281→ 281→ } 282→ 282→} 283→ 283→ 284→ 284→/** 保存今日进度 */ 285→ 285→export function saveDailyProgress(prog: BeaconDailyProgress): void { 286→ 286→ if (typeof localStorage === "undefined") return; 287→ 287→ localStorage.setItem(BEACON_PROGRESS_KEY, JSON.stringify(prog)); 288→ 288→} 289→ 289→ 290→ 290→/** 增量更新进度,返回新进度 + 是否刚完成 */ 291→ 291→export function addBeaconProgress( 292→ 292→ current: BeaconDailyProgress, 293→ 293→ challenge: BeaconDailyChallenge, 294→ 294→ delta: number 295→ 295→): { progress: BeaconDailyProgress; justCompleted: boolean } { 296→ 296→ const newProgressVal = Math.min(challenge.goal, current.progress + delta); 297→ 297→ const justCompleted = current.completedAt === null && newProgressVal >= challenge.goal; 298→ 298→ const completedAt = justCompleted ? Date.now() : current.completedAt; 299→ 299→ const durationSec = 300→ 300→ completedAt !== null ? Math.floor((completedAt - current.startedAt) / 1000) : current.durationSec; 301→ 301→ const next: BeaconDailyProgress = { 302→ 302→ ...current, 303→ 303→ progress: newProgressVal, 304→ 304→ completedAt, 305→ 305→ durationSec, 306→ 306→ }; 307→ 307→ saveDailyProgress(next); 308→ 308→ return { progress: next, justCompleted }; 309→ 309→} 310→ 310→ 311→ 311→/** 领取奖励:返回奖励数值 + 推送排行榜 */ 312→ 312→export function claimBeaconReward( 313→ 313→ challenge: BeaconDailyChallenge, 314→ 314→ progress: BeaconDailyProgress 315→ 315→): { rewardInsight: number; rewardContact: number; score: number; leaderboard: BeaconScoreEntry[] } { 316→ 316→ if (progress.claimed || progress.completedAt === null) { 317→ 317→ return { rewardInsight: 0, rewardContact: 0, score: 0, leaderboard: loadLeaderboard() }; 318→ 318→ } 319→ 319→ const score = computeBeaconScore(challenge, progress.progress, progress.durationSec); 320→ 320→ const entry: BeaconScoreEntry = { 321→ 321→ timestamp: Date.now(), 322→ 322→ dateKey: challenge.dateKey, 323→ 323→ challenge: challenge.type, 324→ 324→ difficulty: challenge.difficulty, 325→ 325→ progress: progress.progress / challenge.goal, 326→ 326→ score, 327→ 327→ durationSec: progress.durationSec, 328→ 328→ }; 329→ 329→ const leaderboard = pushLeaderboardEntry(entry); 330→ 330→ const updated: BeaconDailyProgress = { ...progress, claimed: true }; 331→ 331→ saveDailyProgress(updated); 332→ 332→ return { 333→ 333→ rewardInsight: challenge.rewardInsight, 334→ 334→ rewardContact: challenge.rewardContact, 335→ 335→ score, 336→ 336→ leaderboard, 337→ 337→ }; 338→ 338→} 339→ 339→ 340→ 340→/** 距离 UTC 次日 0 点的毫秒数(用于倒计时) */ 341→ 341→export function msUntilNextDay(now: Date = new Date()): number { 342→ 342→ const next = Date.UTC( 343→ 343→ now.getUTCFullYear(), 344→ 344→ now.getUTCMonth(), 345→ 345→ now.getUTCDate() + 1, 346→ 346→ 0, 347→ 347→ 0, 348→ 348→ 0 349→ 349→ ); 350→ 350→ return Math.max(0, next - now.getTime()); 351→ 351→} 352→ 352→ 353→ 353→/** 格式化倒计时为 HH:MM:SS */ 354→ 354→export function formatCountdown(ms: number): string { 355→ 355→ const total = Math.max(0, Math.floor(ms / 1000)); 356→ 356→ const h = String(Math.floor(total / 3600)).padStart(2, "0"); 357→ 357→ const m = String(Math.floor((total % 3600) / 60)).padStart(2, "0"); 358→ 358→ const s = String(total % 60).padStart(2, "0"); 359→ 359→ return `${h}:${m}:${s}`; 360→ 360→} 361→ 361→ 362→ 362→// =========================================================================== 363→ 363→// 周挑战(WEEKLY CHALLENGE)— v0.8 364→ 364→// 每周一 UTC 0 点刷新一个"周挑战",目标更大、奖励更好,与日挑战并行存在。 365→ 365→// 同一周同一种子 → 同一挑战(确定性)。难度强制 anomaly/singular(加权 60/40)。 366→ 366→// =========================================================================== 367→ 367→ 368→ 368→/** 周挑战 localStorage key */ 369→ 369→export const BEACON_WEEKLY_KEY = "echo-nexus-beacon-weekly-v1"; 370→ 370→ 371→ 371→/** 周挑战定义 */ 372→ 372→export interface BeaconWeeklyChallenge { 373→ 373→ /** ISO 周键(YYYY-Www,如 "2026-W26") */ 374→ 374→ weekKey: string; 375→ 375→ /** 挑战类型 */ 376→ 376→ type: BeaconChallengeType; 377→ 377→ /** 难度(固定 anomaly 或 singular) */ 378→ 378→ difficulty: "anomaly" | "singular"; 379→ 379→ /** 目标数值(日挑战的 3-5 倍) */ 380→ 380→ goal: number; 381→ 381→ /** 奖励:完成时洞见 */ 382→ 382→ rewardInsight: number; 383→ 383→ /** 奖励:完成时接触进度 */ 384→ 384→ rewardContact: number; 385→ 385→ /** 使用的种子(可复现) */ 386→ 386→ seed: number; 387→ 387→ /** 友好标题 */ 388→ 388→ title: string; 389→ 389→ /** 描述 */ 390→ 390→ desc: string; 391→ 391→} 392→ 392→ 393→ 393→/** 周挑战进度 */ 394→ 394→export interface BeaconWeeklyProgress { 395→ 395→ weekKey: string; 396→ 396→ progress: number; 397→ 397→ startedAt: number; 398→ 398→ completedAt: number | null; 399→ 399→ claimed: boolean; 400→ 400→ durationSec: number; 401→ 401→} 402→ 402→ 403→ 403→/** 404→ 404→ * 计算 ISO 8601 周键(YYYY-Www),如 "2026-W26"。 405→ 405→ * 规则:周一为一周开始;第一周是该年至少含 4 天的第一周(即包含第一个周四)。 406→ 406→ */ 407→ 407→export function getWeekKey(now: Date = new Date()): string { 408→ 408→ // 取 UTC 日期,避免时区偏移 409→ 409→ const tmp = new Date( 410→ 410→ Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()) 411→ 411→ ); 412→ 412→ // ISO 周以周一为起点:(getUTCDay + 6) % 7 → 0=Mon, 6=Sun 413→ 413→ const dayNum = (tmp.getUTCDay() + 6) % 7; 414→ 414→ // 把日期调整到本周四(ISO 周归属年份由该周四所在的年份决定) 415→ 415→ tmp.setUTCDate(tmp.getUTCDate() - dayNum + 3); 416→ 416→ const isoYear = tmp.getUTCFullYear(); 417→ 417→ const yearStart = Date.UTC(isoYear, 0, 1); 418→ 418→ const weekNum = Math.ceil(((tmp.getTime() - yearStart) / 86400000 + 1) / 7); 419→ 419→ return `${isoYear}-W${String(weekNum).padStart(2, "0")}`; 420→ 420→} 421→ 421→ 422→ 422→/** weekKey → 数值种子(FNV-1a 哈希) */ 423→ 423→function weekKeyToSeed(weekKey: string): number { 424→ 424→ let h = 2166136261 >>> 0; 425→ 425→ for (let i = 0; i < weekKey.length; i++) { 426→ 426→ h ^= weekKey.charCodeAt(i); 427→ 427→ h = Math.imul(h, 16777619) >>> 0; 428→ 428→ } 429→ 429→ return h >>> 0; 430→ 430→} 431→ 431→ 432→ 432→/** 433→ 433→ * 生成本周周挑战(确定性:同一周同一种子 → 同一挑战)。 434→ 434→ * 难度强制 anomaly(60%)或 singular(40%),goal 为日挑战基准 ×3-5 倍。 435→ 435→ */ 436→ 436→export function generateWeeklyChallenge( 437→ 437→ now: Date = new Date() 438→ 438→): BeaconWeeklyChallenge { 439→ 439→ const weekKey = getWeekKey(now); 440→ 440→ const seed = weekKeyToSeed(weekKey); 441→ 441→ const rng = mulberry32(seed); 442→ 442→ 443→ 443→ const types: BeaconChallengeType[] = [ 444→ 444→ "decode", 445→ 445→ "expedition", 446→ 446→ "pulse", 447→ 447→ "boss", 448→ 448→ "insight", 449→ 449→ ]; 450→ 450→ const type = types[Math.floor(rng() * types.length)]; 451→ 451→ 452→ 452→ // 难度加权:anomaly 60% / singular 40% 453→ 453→ const dr = rng(); 454→ 454→ const difficulty: "anomaly" | "singular" = dr < 0.6 ? "anomaly" : "singular"; 455→ 455→ const diffMult = BEACON_DIFFICULTY[difficulty].mult; 456→ 456→ 457→ 457→ // 3-5 倍 458→ 458→ const mult = 3 + Math.floor(rng() * 3); 459→ 459→ 460→ 460→ let goal = 0; 461→ 461→ let rewardInsight = 0; 462→ 462→ let rewardContact = 0; 463→ 463→ let title = ""; 464→ 464→ let desc = ""; 465→ 465→ 466→ 466→ switch (type) { 467→ 467→ case "decode": 468→ 468→ // 日基准 6-15 × mult(3-5) → 18-75 469→ 469→ goal = Math.max(18, Math.round((6 + Math.floor(rng() * 10)) * diffMult * mult)); 470→ 470→ rewardInsight = Math.round(goal * 5 * diffMult); 471→ 471→ rewardContact = goal * 1.2; 472→ 472→ title = `周界解码 · ${goal} 颗晶体`; 473→ 473→ desc = `本周深空信标要求解码 ${goal} 颗记忆晶体,强度为日挑战的 ${mult} 倍。`; 474→ 474→ break; 475→ 475→ case "expedition": 476→ 476→ // 日基准 1-3 × mult(3-5) → 3-15 477→ 477→ goal = Math.max(3, Math.round((1 + Math.floor(rng() * 3)) * diffMult * mult)); 478→ 478→ rewardInsight = Math.round(goal * 14 * diffMult); 479→ 479→ rewardContact = goal * 2; 480→ 480→ title = `周界远征 · ${goal} 次探险`; 481→ 481→ desc = `本周派出探险队完成 ${goal} 次远征,无论胜负均计入。`; 482→ 482→ break; 483→ 483→ case "pulse": 484→ 484→ // 日基准 20-49 × mult(3-5) → 60-245 485→ 485→ goal = Math.max(60, Math.round((20 + Math.floor(rng() * 30)) * diffMult * mult)); 486→ 486→ rewardInsight = Math.round(goal * 1.5 * diffMult); 487→ 487→ rewardContact = goal * 0.4; 488→ 488→ title = `周界脉冲 · ${goal} 次扫描`; 489→ 489→ desc = `本周发起 ${goal} 次脉冲扫描。连击可叠加加成。`; 490→ 490→ break; 491→ 491→ case "boss": 492→ 492→ // 日基准 1 × mult(3-5) → 3-12(singular mult=2.4 时上限 12) 493→ 493→ goal = Math.max(3, Math.round(mult * diffMult)); 494→ 494→ rewardInsight = Math.round(goal * 35 * diffMult); 495→ 495→ rewardContact = goal * 4; 496→ 496→ title = `周界猎杀 · ${goal} 处 BOSS`; 497→ 497→ desc = `本周在探险终点击破 ${goal} 处维度 BOSS。提升探险力后再挑战。`; 498→ 498→ break; 499→ 499→ case "insight": 500→ 500→ // 日基准 40-119 × mult(3-5) → 120-595 501→ 501→ goal = Math.max(120, Math.round((40 + Math.floor(rng() * 80)) * diffMult * mult)); 502→ 502→ rewardInsight = 0; // 洞见挑战不给洞见,给接触 503→ 503→ rewardContact = goal * 0.15; 504→ 504→ title = `周界洞见 · ${goal} 点`; 505→ 505→ desc = `本周通过解码、探险、星潮等途径累计 ${goal} 洞见。`; 506→ 506→ break; 507→ 507→ } 508→ 508→ 509→ 509→ return { 510→ 510→ weekKey, 511→ 511→ type, 512→ 512→ difficulty, 513→ 513→ goal, 514→ 514→ rewardInsight, 515→ 515→ rewardContact, 516→ 516→ seed, 517→ 517→ title, 518→ 518→ desc, 519→ 519→ }; 520→ 520→} 521→ 521→ 522→ 522→/** 读取本周进度(若 weekKey 不匹配则重置) */ 523→ 523→export function loadWeeklyProgress(now: Date = new Date()): BeaconWeeklyProgress { 524→ 524→ const weekKey = getWeekKey(now); 525→ 525→ const empty: BeaconWeeklyProgress = { 526→ 526→ weekKey, 527→ 527→ progress: 0, 528→ 528→ startedAt: Date.now(), 529→ 529→ completedAt: null, 530→ 530→ claimed: false, 531→ 531→ durationSec: 0, 532→ 532→ }; 533→ 533→ if (typeof localStorage === "undefined") return empty; 534→ 534→ try { 535→ 535→ const raw = localStorage.getItem(BEACON_WEEKLY_KEY); 536→ 536→ if (!raw) return empty; 537→ 537→ const prog = JSON.parse(raw) as BeaconWeeklyProgress; 538→ 538→ if (prog.weekKey !== weekKey) return empty; // 新的一周,重置 539→ 539→ return prog; 540→ 540→ } catch { 541→ 541→ return empty; 542→ 542→ } 543→ 543→} 544→ 544→ 545→ 545→/** 保存本周进度 */ 546→ 546→export function saveWeeklyProgress(prog: BeaconWeeklyProgress): void { 547→ 547→ if (typeof localStorage === "undefined") return; 548→ 548→ localStorage.setItem(BEACON_WEEKLY_KEY, JSON.stringify(prog)); 549→ 549→} 550→ 550→ 551→ 551→/** 增量更新周挑战进度,返回新进度 + 是否刚完成 */ 552→ 552→export function addWeeklyProgress( 553→ 553→ current: BeaconWeeklyProgress, 554→ 554→ challenge: BeaconWeeklyChallenge, 555→ 555→ delta: number 556→ 556→): { progress: BeaconWeeklyProgress; justCompleted: boolean } { 557→ 557→ const newProgressVal = Math.min(challenge.goal, current.progress + delta); 558→ 558→ const justCompleted = 559→ 559→ current.completedAt === null && newProgressVal >= challenge.goal; 560→ 560→ const completedAt = justCompleted ? Date.now() : current.completedAt; 561→ 561→ const durationSec = 562→ 562→ completedAt !== null 563→ 563→ ? Math.floor((completedAt - current.startedAt) / 1000) 564→ 564→ : current.durationSec; 565→ 565→ const next: BeaconWeeklyProgress = { 566→ 566→ ...current, 567→ 567→ progress: newProgressVal, 568→ 568→ completedAt, 569→ 569→ durationSec, 570→ 570→ }; 571→ 571→ saveWeeklyProgress(next); 572→ 572→ return { progress: next, justCompleted }; 573→ 573→} 574→ 574→ 575→ 575→/** 领取周挑战奖励:返回奖励数值 + 推送排行榜 */ 576→ 576→export function claimWeeklyReward( 577→ 577→ challenge: BeaconWeeklyChallenge, 578→ 578→ progress: BeaconWeeklyProgress 579→ 579→): { 580→ 580→ rewardInsight: number; 581→ 581→ rewardContact: number; 582→ 582→ score: number; 583→ 583→ leaderboard: BeaconScoreEntry[]; 584→ 584→} { 585→ 585→ if (progress.claimed || progress.completedAt === null) { 586→ 586→ return { 587→ 587→ rewardInsight: 0, 588→ 588→ rewardContact: 0, 589→ 589→ score: 0, 590→ 590→ leaderboard: loadLeaderboard(), 591→ 591→ }; 592→ 592→ } 593→ 593→ // 借用 computeBeaconScore:把 weekly 包装成 daily 接口(dateKey 字段不影响计分逻辑) 594→ 594→ const score = computeBeaconScore( 595→ 595→ { ...(challenge as unknown as BeaconDailyChallenge), dateKey: challenge.weekKey }, 596→ 596→ progress.progress, 597→ 597→ progress.durationSec 598→ 598→ ); 599→ 599→ const entry: BeaconScoreEntry = { 600→ 600→ timestamp: Date.now(), 601→ 601→ dateKey: challenge.weekKey, 602→ 602→ challenge: challenge.type, 603→ 603→ difficulty: challenge.difficulty, 604→ 604→ progress: progress.progress / challenge.goal, 605→ 605→ score, 606→ 606→ durationSec: progress.durationSec, 607→ 607→ isWeekly: true, 608→ 608→ }; 609→ 609→ const leaderboard = pushLeaderboardEntry(entry); 610→ 610→ const updated: BeaconWeeklyProgress = { ...progress, claimed: true }; 611→ 611→ saveWeeklyProgress(updated); 612→ 612→ return { 613→ 613→ rewardInsight: challenge.rewardInsight, 614→ 614→ rewardContact: challenge.rewardContact, 615→ 615→ score, 616→ 616→ leaderboard, 617→ 617→ }; 618→ 618→} 619→ 619→ 620→ 620→/** 距离下周一 UTC 0 点的毫秒数(用于周挑战倒计时) */ 621→ 621→export function msUntilNextWeek(now: Date = new Date()): number { 622→ 622→ const dayNum = (now.getUTCDay() + 6) % 7; // 0 = Mon 623→ 623→ const mondayThisWeek = Date.UTC( 624→ 624→ now.getUTCFullYear(), 625→ 625→ now.getUTCMonth(), 626→ 626→ now.getUTCDate() - dayNum, 627→ 627→ 0, 628→ 628→ 0, 629→ 629→ 0 630→ 630→ ); 631→ 631→ const nextMonday = mondayThisWeek + 7 * 86400000; 632→ 632→ return Math.max(0, nextMonday - now.getTime()); 633→ 633→} 634→ 634→ 635→ 635→// =========================================================================== 636→ 636→// 信标链(BEACON CHAIN)— v0.8 637→ 637→// 连续完成日挑战形成"链",达成里程碑(3/7/14/30 天)领取递增大奖。 638→ 638→// 断链有宽容机制(1 天 miss 不断链,用"信标续命"概念),每条链只能用 1 次。 639→ 639→// =========================================================================== 640→ 640→ 641→ 641→/** 信标链 localStorage key */ 642→ 642→export const BEACON_CHAIN_KEY = "echo-nexus-beacon-chain-v1"; 643→ 643→ 644→ 644→/** 里程碑天数 */ 645→ 645→export const BEACON_CHAIN_MILESTONES = [3, 7, 14, 30] as const; 646→ 646→ 647→ 647→/** 里程碑奖励配置 */ 648→ 648→export interface BeaconChainReward { 649→ 649→ milestone: number; 650→ 650→ rewardInsight: number; 651→ 651→ rewardContact: number; 652→ 652→ label: string; 653→ 653→} 654→ 654→ 655→ 655→export const BEACON_CHAIN_REWARDS: BeaconChainReward[] = [ 656→ 656→ { milestone: 3, rewardInsight: 50, rewardContact: 5, label: "三日谐振" }, 657→ 657→ { milestone: 7, rewardInsight: 120, rewardContact: 12, label: "七日回响" }, 658→ 658→ { milestone: 14, rewardInsight: 280, rewardContact: 28, label: "半月星潮" }, 659→ 659→ { milestone: 30, rewardInsight: 680, rewardContact: 68, label: "满月飞升" }, 660→ 660→]; 661→ 661→ 662→ 662→/** 信标链状态 */ 663→ 663→export interface BeaconChainState { 664→ 664→ /** 上次完成日(YYYY-MM-DD) */ 665→ 665→ lastCompletedDateKey: string; 666→ 666→ /** 当前连续天数 */ 667→ 667→ currentStreak: number; 668→ 668→ /** 历史最长 */ 669→ 669→ longestStreak: number; 670→ 670→ /** 累计完成总数 */ 671→ 671→ totalCompletions: number; 672→ 672→ /** 本周期已用续命数(上限 1) */ 673→ 673→ graceUsed: number; 674→ 674→ /** 已领取的里程碑数组 */ 675→ 675→ milestonesClaimed: number[]; 676→ 676→} 677→ 677→ 678→ 678→/** 读取信标链状态 */ 679→ 679→export function loadChainState(): BeaconChainState { 680→ 680→ const fresh = (): BeaconChainState => ({ 681→ 681→ lastCompletedDateKey: "", 682→ 682→ currentStreak: 0, 683→ 683→ longestStreak: 0, 684→ 684→ totalCompletions: 0, 685→ 685→ graceUsed: 0, 686→ 686→ milestonesClaimed: [], 687→ 687→ }); 688→ 688→ if (typeof localStorage === "undefined") return fresh(); 689→ 689→ try { 690→ 690→ const raw = localStorage.getItem(BEACON_CHAIN_KEY); 691→ 691→ if (!raw) return fresh(); 692→ 692→ const s = JSON.parse(raw) as Partial; 693→ 693→ return { 694→ 694→ lastCompletedDateKey: s.lastCompletedDateKey ?? "", 695→ 695→ currentStreak: s.currentStreak ?? 0, 696→ 696→ longestStreak: s.longestStreak ?? 0, 697→ 697→ totalCompletions: s.totalCompletions ?? 0, 698→ 698→ graceUsed: s.graceUsed ?? 0, 699→ 699→ milestonesClaimed: Array.isArray(s.milestonesClaimed) 700→ 700→ ? [...s.milestonesClaimed] 701→ 701→ : [], 702→ 702→ }; 703→ 703→ } catch { 704→ 704→ return fresh(); 705→ 705→ } 706→ 706→} 707→ 707→ 708→ 708→/** 保存信标链状态 */ 709→ 709→export function saveChainState(state: BeaconChainState): void { 710→ 710→ if (typeof localStorage === "undefined") return; 711→ 711→ localStorage.setItem(BEACON_CHAIN_KEY, JSON.stringify(state)); 712→ 712→} 713→ 713→ 714→ 714→/** dateKey → UTC 0 点时间戳 */ 715→ 715→function dateKeyToTimestamp(dateKey: string): number { 716→ 716→ const [y, m, d] = dateKey.split("-").map(Number); 717→ 717→ return Date.UTC(y, m - 1, d, 0, 0, 0); 718→ 718→} 719→ 719→ 720→ 720→/** 计算 b - a 相差的天数(UTC 0 点对齐) */ 721→ 721→function dateKeyDiffDays(a: string, b: string): number { 722→ 722→ if (!a || !b) return Number.MAX_SAFE_INTEGER; 723→ 723→ return Math.round((dateKeyToTimestamp(b) - dateKeyToTimestamp(a)) / 86400000); 724→ 724→} 725→ 725→ 726→ 726→/** 727→ 727→ * 记录一次日挑战完成(核心逻辑)。 728→ 728→ * - dateKey === lastCompletedDateKey:同一天重复完成,忽略,返回 newMilestones: [] 729→ 729→ * - dateKey 是 lastCompletedDateKey 的次日:currentStreak++ 730→ 730→ * - dateKey 是 lastCompletedDateKey 的后两天(隔了一天 miss)且 graceUsed < 1:续命一次,currentStreak++ 731→ 731→ * - 其他:currentStreak = 1(断链重来),graceUsed = 0 732→ 732→ * 更新 longestStreak 与 totalCompletions。 733→ 733→ * @returns { state, newMilestones } 刚达成但未领取的里程碑数组 734→ 734→ */ 735→ 735→export function recordChainCompletion(dateKey: string): { 736→ 736→ state: BeaconChainState; 737→ 737→ newMilestones: number[]; 738→ 738→} { 739→ 739→ const state = loadChainState(); 740→ 740→ 741→ 741→ // 同一天重复完成:忽略 742→ 742→ if (state.lastCompletedDateKey === dateKey) { 743→ 743→ return { state, newMilestones: [] }; 744→ 744→ } 745→ 745→ 746→ 746→ let next: BeaconChainState; 747→ 747→ 748→ 748→ if (state.lastCompletedDateKey === "") { 749→ 749→ // 首次完成 750→ 750→ next = { 751→ 751→ ...state, 752→ 752→ lastCompletedDateKey: dateKey, 753→ 753→ currentStreak: 1, 754→ 754→ longestStreak: Math.max(state.longestStreak, 1), 755→ 755→ totalCompletions: state.totalCompletions + 1, 756→ 756→ }; 757→ 757→ } else { 758→ 758→ const diff = dateKeyDiffDays(state.lastCompletedDateKey, dateKey); 759→ 759→ if (diff === 1) { 760→ 760→ // 次日:链 +1 761→ 761→ const newStreak = state.currentStreak + 1; 762→ 762→ next = { 763→ 763→ ...state, 764→ 764→ lastCompletedDateKey: dateKey, 765→ 765→ currentStreak: newStreak, 766→ 766→ longestStreak: Math.max(state.longestStreak, newStreak), 767→ 767→ totalCompletions: state.totalCompletions + 1, 768→ 768→ }; 769→ 769→ } else if (diff === 2 && state.graceUsed < 1) { 770→ 770→ // 隔一天 miss,续命一次 771→ 771→ const newStreak = state.currentStreak + 1; 772→ 772→ next = { 773→ 773→ ...state, 774→ 774→ lastCompletedDateKey: dateKey, 775→ 775→ currentStreak: newStreak, 776→ 776→ longestStreak: Math.max(state.longestStreak, newStreak), 777→ 777→ totalCompletions: state.totalCompletions + 1, 778→ 778→ graceUsed: state.graceUsed + 1, 779→ 779→ }; 780→ 780→ } else { 781→ 781→ // 断链重来 782→ 782→ next = { 783→ 783→ ...state, 784→ 784→ lastCompletedDateKey: dateKey, 785→ 785→ currentStreak: 1, 786→ 786→ totalCompletions: state.totalCompletions + 1, 787→ 787→ graceUsed: 0, 788→ 788→ longestStreak: Math.max(state.longestStreak, 1), 789→ 789→ }; 790→ 790→ } 791→ 791→ } 792→ 792→ 793→ 793→ // 检查新里程碑(刚达成但未领取) 794→ 794→ const newMilestones: number[] = []; 795→ 795→ for (const m of BEACON_CHAIN_MILESTONES) { 796→ 796→ if ( 797→ 797→ next.currentStreak >= m && 798→ 798→ !next.milestonesClaimed.includes(m) 799→ 799→ ) { 800→ 800→ newMilestones.push(m); 801→ 801→ } 802→ 802→ } 803→ 803→ 804→ 804→ saveChainState(next); 805→ 805→ return { state: next, newMilestones }; 806→ 806→} 807→ 807→ 808→ 808→/** 领取里程碑奖励,加入 milestonesClaimed */ 809→ 809→export function claimChainMilestone(milestone: number): { 810→ 810→ rewardInsight: number; 811→ 811→ rewardContact: number; 812→ 812→ label: string; 813→ 813→ state: BeaconChainState; 814→ 814→} { 815→ 815→ const reward = BEACON_CHAIN_REWARDS.find((r) => r.milestone === milestone); 816→ 816→ const state = loadChainState(); 817→ 817→ if ( 818→ 818→ !reward || 819→ 819→ state.milestonesClaimed.includes(milestone) || 820→ 820→ state.currentStreak < milestone 821→ 821→ ) { 822→ 822→ return { rewardInsight: 0, rewardContact: 0, label: "", state }; 823→ 823→ } 824→ 824→ const next: BeaconChainState = { 825→ 825→ ...state, 826→ 826→ milestonesClaimed: [...state.milestonesClaimed, milestone], 827→ 827→ }; 828→ 828→ saveChainState(next); 829→ 829→ return { 830→ 830→ rewardInsight: reward.rewardInsight, 831→ 831→ rewardContact: reward.rewardContact, 832→ 832→ label: reward.label, 833→ 833→ state: next, 834→ 834→ }; 835→ 835→} 836→ 836→ 837→ 837→/** 返回下一个目标里程碑(如 streak=5 → 7;streak=30+ → null) */ 838→ 838→export function getNextMilestone(streak: number): number | null { 839→ 839→ for (const m of BEACON_CHAIN_MILESTONES) { 840→ 840→ if (streak < m) return m; 841→ 841→ } 842→ 842→ return null; 843→ 843→} 844→ 844→ 845→ 845→/** 846→ 846→ * 返回信标链进度信息(用于 UI 进度条)。 847→ 847→ * - current:当前连续天数 848→ 848→ * - next:下一个目标里程碑(null 表示已通关全部) 849→ 849→ * - progressPct:当前进度(基于上一个里程碑 → 下一个里程碑) 850→ 850→ */ 851→ 851→export function getChainProgress(streak: number): { 852→ 852→ current: number; 853→ 853→ next: number | null; 854→ 854→ prev: number; 855→ 855→ progressPct: number; 856→ 856→} { 857→ 857→ const next = getNextMilestone(streak); 858→ 858→ let prev = 0; 859→ 859→ for (const m of BEACON_CHAIN_MILESTONES) { 860→ 860→ if (streak >= m) prev = m; 861→ 861→ } 862→ 862→ if (next === null) { 863→ 863→ return { current: streak, next: null, prev, progressPct: 100 }; 864→ 864→ } 865→ 865→ const span = next - prev; 866→ 866→ const done = streak - prev; 867→ 867→ const pct = span > 0 ? Math.round((done / span) * 100) : 100; 868→ 868→ return { 869→ 869→ current: streak, 870→ 870→ next, 871→ 871→ prev, 872→ 872→ progressPct: Math.min(100, Math.max(0, pct)), 873→ 873→ }; 874→ 874→} 875→ 875→