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