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