v0.8.2: 工单大清理 - 编年史分页+限时挑战+星潮深化+云排行榜+手写叙事+socket多人星潮+UI打磨

工单 #8 编年史上限+分页:
- engine.ts: slice(-50)→slice(-200)
- ChronicleDialog.tsx: 加分页(每页10条)+上一页/下一页+页码显示

工单 #5 限时挑战 (subagent 10-a):
- beacon.ts: BeaconTimedChallenge + getTimedSlotKey(4h时段) + generateTimedChallenge
- BeaconPanel.tsx: amber主题限时区块 + 倒计时 + <30min紧急状态
- gameStore: trackBeacon 加 timedJustCompleted + claimTimedBeacon action

工单 #3 星潮类型深化 (subagent 10-a):
- starTide.ts: +3种星潮 surge(emerald)/eclipse(rose)/prism(fuchsia)
- TideModifiers: +targetLenBonus/bossWinRateBonus/autoDecodeIntervalMult
- decode.ts: generatePuzzle 加 targetLenBonus 参数
- achievements: ach_tides_all 阈值 6→9

工单 #4/P2 云排行榜 (subagent 10-b):
- mini-services/leaderboard-service/ (端口3030, Hono+bun, 内存1000条)
- API: GET/POST /api/leaderboard + /stats + CORS + 防刷
- beacon.ts: fetchCloudLeaderboard/submitCloudScore
- BeaconPanel: 本地Top20/全球Top100 双tab + YOU徽章高亮

工单 #9 手写叙事 (subagent 10-c):
- chronicle.ts: EPOCH_LORE 5纪元×3节点=15段手写叙事(80-150字/段)
- buildLore 优先手写节点, fallback 模板, 11个变量替换

P3 socket 多人星潮 (subagent 10-c):
- mini-services/star-tide-service/ (端口3031, socket.io)
- 每10-15min广播global-tide, 60s持续, 6种类型权重
- useGlobalTide hook + triggerGlobalTide action
- StarTideIndicator 加 🌐 全球星潮标记

P2 UI打磨:
- page.tsx: CrystalOrb 区加装饰全息环(3层旋转) + 四角标记 + 顶部状态条 + 底部铭文

QA: lint零错误 + dev HTTP200 + VLM 8/10 + 2个mini-service运行中(3030/3031)
This commit is contained in:
2026-06-23 23:57:41 +00:00
parent 6d7c52daa9
commit 191434e6ad
55 changed files with 9026 additions and 129 deletions
@@ -0,0 +1,875 @@
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→ /** 日期 keyYYYY-MM-DD)或周 keyYYYY-Www */
22→ 22→ dateKey: string;
23→ 23→ /** 挑战类型 */
24→ 24→ challenge: BeaconChallengeType;
25→ 25→ /** 难度 */
26→ 26→ difficulty: BeaconDifficulty;
27→ 27→ /** 完成度(0-11 = 完成) */
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→ /** 日期 keyYYYY-MM-DDUTC */
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→ * 难度强制 anomaly60%)或 singular40%),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-12singular 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<BeaconChainState>;
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 → 7streak=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→