工单 #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)
875 lines
43 KiB
Plaintext
875 lines
43 KiB
Plaintext
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→ |