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:
@@ -0,0 +1,875 @@
|
||||
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→
|
||||
Reference in New Issue
Block a user