v0.8.1: 探险平衡 + 信标系统扩展(周挑战 + 信标链)
P1-a 探险 BOSS 胜率平衡 + 能量恢复(expedition.ts + config.ts + gameStore.ts + ExpeditionPanel.tsx): - combatWinRate: scale 8→4, floor 0.25→0.35(基础力10对BOSS胜率 25%→35-45%) - BOSS 难度 5-7→3-5(配合新公式基础胜率达 45-55%) - computeEnergyRegenInterval(state): 动态恢复间隔 - exp_2 解锁 -30%, exp_3 解锁 -20%, 探索力属性 -最高30% - 下限 12s(原固定 45s) - computeEnergyRegen 接受 intervalSec 参数 - gameStore tick 传入动态间隔 - config.ts: exp_2/exp_3 描述加'能量恢复 +30%/+20%' - ExpeditionPanel: 显示实际恢复速度 + '已加速'标记 P1-b 信标系统扩展(beacon.ts + BeaconPanel.tsx + gameStore.ts)[subagent 9-b]: - 周挑战: getWeekKey ISO 8601 + FNV-1a 种子确定性生成 - 难度强制 anomaly 60%/singular 40%, goal 日挑战×3-5倍 - 完整进度/领奖/排行榜推送(isWeekly标记) - 信标链: 4里程碑(3/7/14/30天) + grace续命机制(每链1次) - recordChainCompletion 核心断链/续命逻辑 - 奖励 50→680洞见递增 - BeaconPanel: 周挑战fuchsia主题 + 信标链amber→rose渐变里程碑节点 - gameStore: trackBeacon升级 + claimWeeklyBeacon/claimChainReward actions - VLM 8/10, lint零错误, 5场景bun测试全PASS
This commit is contained in:
+521
-4
@@ -1,6 +1,7 @@
|
||||
// 回响星核 / Echo Nexus — 深空信标(v0.5 每日挑战 + 本地排行榜)
|
||||
// 一个自包含的"每日挑战"元系统:基于日期种子的固定挑战 + 本地排行榜。
|
||||
// 不依赖后端,纯 localStorage 持久化,给放置循环注入"今日目标"动机。
|
||||
// 回响星核 / Echo Nexus — 深空信标
|
||||
// v0.5:每日挑战 + 本地排行榜
|
||||
// v0.8:周挑战 + 信标链(连续完成奖励)
|
||||
// 一个自包含的"每日挑战 + 周挑战 + 连续完成链"元系统,纯 localStorage 持久化。
|
||||
|
||||
/** 每日挑战类型 */
|
||||
export type BeaconChallengeType =
|
||||
@@ -17,7 +18,7 @@ export type BeaconDifficulty = "routine" | "anomaly" | "singular";
|
||||
export interface BeaconScoreEntry {
|
||||
/** 提交时间戳 */
|
||||
timestamp: number;
|
||||
/** 日期 key(YYYY-MM-DD) */
|
||||
/** 日期 key(YYYY-MM-DD)或周 key(YYYY-Www) */
|
||||
dateKey: string;
|
||||
/** 挑战类型 */
|
||||
challenge: BeaconChallengeType;
|
||||
@@ -29,6 +30,8 @@ export interface BeaconScoreEntry {
|
||||
score: number;
|
||||
/** 完成时长(秒),未完成则记 0 */
|
||||
durationSec: number;
|
||||
/** v0.8:是否为周挑战记录(日挑战默认 false / undefined) */
|
||||
isWeekly?: boolean;
|
||||
}
|
||||
|
||||
/** 每日挑战定义 */
|
||||
@@ -355,3 +358,517 @@ export function formatCountdown(ms: number): string {
|
||||
const s = String(total % 60).padStart(2, "0");
|
||||
return `${h}:${m}:${s}`;
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// 周挑战(WEEKLY CHALLENGE)— v0.8
|
||||
// 每周一 UTC 0 点刷新一个"周挑战",目标更大、奖励更好,与日挑战并行存在。
|
||||
// 同一周同一种子 → 同一挑战(确定性)。难度强制 anomaly/singular(加权 60/40)。
|
||||
// ===========================================================================
|
||||
|
||||
/** 周挑战 localStorage key */
|
||||
export const BEACON_WEEKLY_KEY = "echo-nexus-beacon-weekly-v1";
|
||||
|
||||
/** 周挑战定义 */
|
||||
export interface BeaconWeeklyChallenge {
|
||||
/** ISO 周键(YYYY-Www,如 "2026-W26") */
|
||||
weekKey: string;
|
||||
/** 挑战类型 */
|
||||
type: BeaconChallengeType;
|
||||
/** 难度(固定 anomaly 或 singular) */
|
||||
difficulty: "anomaly" | "singular";
|
||||
/** 目标数值(日挑战的 3-5 倍) */
|
||||
goal: number;
|
||||
/** 奖励:完成时洞见 */
|
||||
rewardInsight: number;
|
||||
/** 奖励:完成时接触进度 */
|
||||
rewardContact: number;
|
||||
/** 使用的种子(可复现) */
|
||||
seed: number;
|
||||
/** 友好标题 */
|
||||
title: string;
|
||||
/** 描述 */
|
||||
desc: string;
|
||||
}
|
||||
|
||||
/** 周挑战进度 */
|
||||
export interface BeaconWeeklyProgress {
|
||||
weekKey: string;
|
||||
progress: number;
|
||||
startedAt: number;
|
||||
completedAt: number | null;
|
||||
claimed: boolean;
|
||||
durationSec: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算 ISO 8601 周键(YYYY-Www),如 "2026-W26"。
|
||||
* 规则:周一为一周开始;第一周是该年至少含 4 天的第一周(即包含第一个周四)。
|
||||
*/
|
||||
export function getWeekKey(now: Date = new Date()): string {
|
||||
// 取 UTC 日期,避免时区偏移
|
||||
const tmp = new Date(
|
||||
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())
|
||||
);
|
||||
// ISO 周以周一为起点:(getUTCDay + 6) % 7 → 0=Mon, 6=Sun
|
||||
const dayNum = (tmp.getUTCDay() + 6) % 7;
|
||||
// 把日期调整到本周四(ISO 周归属年份由该周四所在的年份决定)
|
||||
tmp.setUTCDate(tmp.getUTCDate() - dayNum + 3);
|
||||
const isoYear = tmp.getUTCFullYear();
|
||||
const yearStart = Date.UTC(isoYear, 0, 1);
|
||||
const weekNum = Math.ceil(((tmp.getTime() - yearStart) / 86400000 + 1) / 7);
|
||||
return `${isoYear}-W${String(weekNum).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
/** weekKey → 数值种子(FNV-1a 哈希) */
|
||||
function weekKeyToSeed(weekKey: string): number {
|
||||
let h = 2166136261 >>> 0;
|
||||
for (let i = 0; i < weekKey.length; i++) {
|
||||
h ^= weekKey.charCodeAt(i);
|
||||
h = Math.imul(h, 16777619) >>> 0;
|
||||
}
|
||||
return h >>> 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成本周周挑战(确定性:同一周同一种子 → 同一挑战)。
|
||||
* 难度强制 anomaly(60%)或 singular(40%),goal 为日挑战基准 ×3-5 倍。
|
||||
*/
|
||||
export function generateWeeklyChallenge(
|
||||
now: Date = new Date()
|
||||
): BeaconWeeklyChallenge {
|
||||
const weekKey = getWeekKey(now);
|
||||
const seed = weekKeyToSeed(weekKey);
|
||||
const rng = mulberry32(seed);
|
||||
|
||||
const types: BeaconChallengeType[] = [
|
||||
"decode",
|
||||
"expedition",
|
||||
"pulse",
|
||||
"boss",
|
||||
"insight",
|
||||
];
|
||||
const type = types[Math.floor(rng() * types.length)];
|
||||
|
||||
// 难度加权:anomaly 60% / singular 40%
|
||||
const dr = rng();
|
||||
const difficulty: "anomaly" | "singular" = dr < 0.6 ? "anomaly" : "singular";
|
||||
const diffMult = BEACON_DIFFICULTY[difficulty].mult;
|
||||
|
||||
// 3-5 倍
|
||||
const mult = 3 + Math.floor(rng() * 3);
|
||||
|
||||
let goal = 0;
|
||||
let rewardInsight = 0;
|
||||
let rewardContact = 0;
|
||||
let title = "";
|
||||
let desc = "";
|
||||
|
||||
switch (type) {
|
||||
case "decode":
|
||||
// 日基准 6-15 × mult(3-5) → 18-75
|
||||
goal = Math.max(18, Math.round((6 + Math.floor(rng() * 10)) * diffMult * mult));
|
||||
rewardInsight = Math.round(goal * 5 * diffMult);
|
||||
rewardContact = goal * 1.2;
|
||||
title = `周界解码 · ${goal} 颗晶体`;
|
||||
desc = `本周深空信标要求解码 ${goal} 颗记忆晶体,强度为日挑战的 ${mult} 倍。`;
|
||||
break;
|
||||
case "expedition":
|
||||
// 日基准 1-3 × mult(3-5) → 3-15
|
||||
goal = Math.max(3, Math.round((1 + Math.floor(rng() * 3)) * diffMult * mult));
|
||||
rewardInsight = Math.round(goal * 14 * diffMult);
|
||||
rewardContact = goal * 2;
|
||||
title = `周界远征 · ${goal} 次探险`;
|
||||
desc = `本周派出探险队完成 ${goal} 次远征,无论胜负均计入。`;
|
||||
break;
|
||||
case "pulse":
|
||||
// 日基准 20-49 × mult(3-5) → 60-245
|
||||
goal = Math.max(60, Math.round((20 + Math.floor(rng() * 30)) * diffMult * mult));
|
||||
rewardInsight = Math.round(goal * 1.5 * diffMult);
|
||||
rewardContact = goal * 0.4;
|
||||
title = `周界脉冲 · ${goal} 次扫描`;
|
||||
desc = `本周发起 ${goal} 次脉冲扫描。连击可叠加加成。`;
|
||||
break;
|
||||
case "boss":
|
||||
// 日基准 1 × mult(3-5) → 3-12(singular mult=2.4 时上限 12)
|
||||
goal = Math.max(3, Math.round(mult * diffMult));
|
||||
rewardInsight = Math.round(goal * 35 * diffMult);
|
||||
rewardContact = goal * 4;
|
||||
title = `周界猎杀 · ${goal} 处 BOSS`;
|
||||
desc = `本周在探险终点击破 ${goal} 处维度 BOSS。提升探险力后再挑战。`;
|
||||
break;
|
||||
case "insight":
|
||||
// 日基准 40-119 × mult(3-5) → 120-595
|
||||
goal = Math.max(120, Math.round((40 + Math.floor(rng() * 80)) * diffMult * mult));
|
||||
rewardInsight = 0; // 洞见挑战不给洞见,给接触
|
||||
rewardContact = goal * 0.15;
|
||||
title = `周界洞见 · ${goal} 点`;
|
||||
desc = `本周通过解码、探险、星潮等途径累计 ${goal} 洞见。`;
|
||||
break;
|
||||
}
|
||||
|
||||
return {
|
||||
weekKey,
|
||||
type,
|
||||
difficulty,
|
||||
goal,
|
||||
rewardInsight,
|
||||
rewardContact,
|
||||
seed,
|
||||
title,
|
||||
desc,
|
||||
};
|
||||
}
|
||||
|
||||
/** 读取本周进度(若 weekKey 不匹配则重置) */
|
||||
export function loadWeeklyProgress(now: Date = new Date()): BeaconWeeklyProgress {
|
||||
const weekKey = getWeekKey(now);
|
||||
const empty: BeaconWeeklyProgress = {
|
||||
weekKey,
|
||||
progress: 0,
|
||||
startedAt: Date.now(),
|
||||
completedAt: null,
|
||||
claimed: false,
|
||||
durationSec: 0,
|
||||
};
|
||||
if (typeof localStorage === "undefined") return empty;
|
||||
try {
|
||||
const raw = localStorage.getItem(BEACON_WEEKLY_KEY);
|
||||
if (!raw) return empty;
|
||||
const prog = JSON.parse(raw) as BeaconWeeklyProgress;
|
||||
if (prog.weekKey !== weekKey) return empty; // 新的一周,重置
|
||||
return prog;
|
||||
} catch {
|
||||
return empty;
|
||||
}
|
||||
}
|
||||
|
||||
/** 保存本周进度 */
|
||||
export function saveWeeklyProgress(prog: BeaconWeeklyProgress): void {
|
||||
if (typeof localStorage === "undefined") return;
|
||||
localStorage.setItem(BEACON_WEEKLY_KEY, JSON.stringify(prog));
|
||||
}
|
||||
|
||||
/** 增量更新周挑战进度,返回新进度 + 是否刚完成 */
|
||||
export function addWeeklyProgress(
|
||||
current: BeaconWeeklyProgress,
|
||||
challenge: BeaconWeeklyChallenge,
|
||||
delta: number
|
||||
): { progress: BeaconWeeklyProgress; justCompleted: boolean } {
|
||||
const newProgressVal = Math.min(challenge.goal, current.progress + delta);
|
||||
const justCompleted =
|
||||
current.completedAt === null && newProgressVal >= challenge.goal;
|
||||
const completedAt = justCompleted ? Date.now() : current.completedAt;
|
||||
const durationSec =
|
||||
completedAt !== null
|
||||
? Math.floor((completedAt - current.startedAt) / 1000)
|
||||
: current.durationSec;
|
||||
const next: BeaconWeeklyProgress = {
|
||||
...current,
|
||||
progress: newProgressVal,
|
||||
completedAt,
|
||||
durationSec,
|
||||
};
|
||||
saveWeeklyProgress(next);
|
||||
return { progress: next, justCompleted };
|
||||
}
|
||||
|
||||
/** 领取周挑战奖励:返回奖励数值 + 推送排行榜 */
|
||||
export function claimWeeklyReward(
|
||||
challenge: BeaconWeeklyChallenge,
|
||||
progress: BeaconWeeklyProgress
|
||||
): {
|
||||
rewardInsight: number;
|
||||
rewardContact: number;
|
||||
score: number;
|
||||
leaderboard: BeaconScoreEntry[];
|
||||
} {
|
||||
if (progress.claimed || progress.completedAt === null) {
|
||||
return {
|
||||
rewardInsight: 0,
|
||||
rewardContact: 0,
|
||||
score: 0,
|
||||
leaderboard: loadLeaderboard(),
|
||||
};
|
||||
}
|
||||
// 借用 computeBeaconScore:把 weekly 包装成 daily 接口(dateKey 字段不影响计分逻辑)
|
||||
const score = computeBeaconScore(
|
||||
{ ...(challenge as unknown as BeaconDailyChallenge), dateKey: challenge.weekKey },
|
||||
progress.progress,
|
||||
progress.durationSec
|
||||
);
|
||||
const entry: BeaconScoreEntry = {
|
||||
timestamp: Date.now(),
|
||||
dateKey: challenge.weekKey,
|
||||
challenge: challenge.type,
|
||||
difficulty: challenge.difficulty,
|
||||
progress: progress.progress / challenge.goal,
|
||||
score,
|
||||
durationSec: progress.durationSec,
|
||||
isWeekly: true,
|
||||
};
|
||||
const leaderboard = pushLeaderboardEntry(entry);
|
||||
const updated: BeaconWeeklyProgress = { ...progress, claimed: true };
|
||||
saveWeeklyProgress(updated);
|
||||
return {
|
||||
rewardInsight: challenge.rewardInsight,
|
||||
rewardContact: challenge.rewardContact,
|
||||
score,
|
||||
leaderboard,
|
||||
};
|
||||
}
|
||||
|
||||
/** 距离下周一 UTC 0 点的毫秒数(用于周挑战倒计时) */
|
||||
export function msUntilNextWeek(now: Date = new Date()): number {
|
||||
const dayNum = (now.getUTCDay() + 6) % 7; // 0 = Mon
|
||||
const mondayThisWeek = Date.UTC(
|
||||
now.getUTCFullYear(),
|
||||
now.getUTCMonth(),
|
||||
now.getUTCDate() - dayNum,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
);
|
||||
const nextMonday = mondayThisWeek + 7 * 86400000;
|
||||
return Math.max(0, nextMonday - now.getTime());
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// 信标链(BEACON CHAIN)— v0.8
|
||||
// 连续完成日挑战形成"链",达成里程碑(3/7/14/30 天)领取递增大奖。
|
||||
// 断链有宽容机制(1 天 miss 不断链,用"信标续命"概念),每条链只能用 1 次。
|
||||
// ===========================================================================
|
||||
|
||||
/** 信标链 localStorage key */
|
||||
export const BEACON_CHAIN_KEY = "echo-nexus-beacon-chain-v1";
|
||||
|
||||
/** 里程碑天数 */
|
||||
export const BEACON_CHAIN_MILESTONES = [3, 7, 14, 30] as const;
|
||||
|
||||
/** 里程碑奖励配置 */
|
||||
export interface BeaconChainReward {
|
||||
milestone: number;
|
||||
rewardInsight: number;
|
||||
rewardContact: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export const BEACON_CHAIN_REWARDS: BeaconChainReward[] = [
|
||||
{ milestone: 3, rewardInsight: 50, rewardContact: 5, label: "三日谐振" },
|
||||
{ milestone: 7, rewardInsight: 120, rewardContact: 12, label: "七日回响" },
|
||||
{ milestone: 14, rewardInsight: 280, rewardContact: 28, label: "半月星潮" },
|
||||
{ milestone: 30, rewardInsight: 680, rewardContact: 68, label: "满月飞升" },
|
||||
];
|
||||
|
||||
/** 信标链状态 */
|
||||
export interface BeaconChainState {
|
||||
/** 上次完成日(YYYY-MM-DD) */
|
||||
lastCompletedDateKey: string;
|
||||
/** 当前连续天数 */
|
||||
currentStreak: number;
|
||||
/** 历史最长 */
|
||||
longestStreak: number;
|
||||
/** 累计完成总数 */
|
||||
totalCompletions: number;
|
||||
/** 本周期已用续命数(上限 1) */
|
||||
graceUsed: number;
|
||||
/** 已领取的里程碑数组 */
|
||||
milestonesClaimed: number[];
|
||||
}
|
||||
|
||||
/** 读取信标链状态 */
|
||||
export function loadChainState(): BeaconChainState {
|
||||
const fresh = (): BeaconChainState => ({
|
||||
lastCompletedDateKey: "",
|
||||
currentStreak: 0,
|
||||
longestStreak: 0,
|
||||
totalCompletions: 0,
|
||||
graceUsed: 0,
|
||||
milestonesClaimed: [],
|
||||
});
|
||||
if (typeof localStorage === "undefined") return fresh();
|
||||
try {
|
||||
const raw = localStorage.getItem(BEACON_CHAIN_KEY);
|
||||
if (!raw) return fresh();
|
||||
const s = JSON.parse(raw) as Partial<BeaconChainState>;
|
||||
return {
|
||||
lastCompletedDateKey: s.lastCompletedDateKey ?? "",
|
||||
currentStreak: s.currentStreak ?? 0,
|
||||
longestStreak: s.longestStreak ?? 0,
|
||||
totalCompletions: s.totalCompletions ?? 0,
|
||||
graceUsed: s.graceUsed ?? 0,
|
||||
milestonesClaimed: Array.isArray(s.milestonesClaimed)
|
||||
? [...s.milestonesClaimed]
|
||||
: [],
|
||||
};
|
||||
} catch {
|
||||
return fresh();
|
||||
}
|
||||
}
|
||||
|
||||
/** 保存信标链状态 */
|
||||
export function saveChainState(state: BeaconChainState): void {
|
||||
if (typeof localStorage === "undefined") return;
|
||||
localStorage.setItem(BEACON_CHAIN_KEY, JSON.stringify(state));
|
||||
}
|
||||
|
||||
/** dateKey → UTC 0 点时间戳 */
|
||||
function dateKeyToTimestamp(dateKey: string): number {
|
||||
const [y, m, d] = dateKey.split("-").map(Number);
|
||||
return Date.UTC(y, m - 1, d, 0, 0, 0);
|
||||
}
|
||||
|
||||
/** 计算 b - a 相差的天数(UTC 0 点对齐) */
|
||||
function dateKeyDiffDays(a: string, b: string): number {
|
||||
if (!a || !b) return Number.MAX_SAFE_INTEGER;
|
||||
return Math.round((dateKeyToTimestamp(b) - dateKeyToTimestamp(a)) / 86400000);
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录一次日挑战完成(核心逻辑)。
|
||||
* - dateKey === lastCompletedDateKey:同一天重复完成,忽略,返回 newMilestones: []
|
||||
* - dateKey 是 lastCompletedDateKey 的次日:currentStreak++
|
||||
* - dateKey 是 lastCompletedDateKey 的后两天(隔了一天 miss)且 graceUsed < 1:续命一次,currentStreak++
|
||||
* - 其他:currentStreak = 1(断链重来),graceUsed = 0
|
||||
* 更新 longestStreak 与 totalCompletions。
|
||||
* @returns { state, newMilestones } 刚达成但未领取的里程碑数组
|
||||
*/
|
||||
export function recordChainCompletion(dateKey: string): {
|
||||
state: BeaconChainState;
|
||||
newMilestones: number[];
|
||||
} {
|
||||
const state = loadChainState();
|
||||
|
||||
// 同一天重复完成:忽略
|
||||
if (state.lastCompletedDateKey === dateKey) {
|
||||
return { state, newMilestones: [] };
|
||||
}
|
||||
|
||||
let next: BeaconChainState;
|
||||
|
||||
if (state.lastCompletedDateKey === "") {
|
||||
// 首次完成
|
||||
next = {
|
||||
...state,
|
||||
lastCompletedDateKey: dateKey,
|
||||
currentStreak: 1,
|
||||
longestStreak: Math.max(state.longestStreak, 1),
|
||||
totalCompletions: state.totalCompletions + 1,
|
||||
};
|
||||
} else {
|
||||
const diff = dateKeyDiffDays(state.lastCompletedDateKey, dateKey);
|
||||
if (diff === 1) {
|
||||
// 次日:链 +1
|
||||
const newStreak = state.currentStreak + 1;
|
||||
next = {
|
||||
...state,
|
||||
lastCompletedDateKey: dateKey,
|
||||
currentStreak: newStreak,
|
||||
longestStreak: Math.max(state.longestStreak, newStreak),
|
||||
totalCompletions: state.totalCompletions + 1,
|
||||
};
|
||||
} else if (diff === 2 && state.graceUsed < 1) {
|
||||
// 隔一天 miss,续命一次
|
||||
const newStreak = state.currentStreak + 1;
|
||||
next = {
|
||||
...state,
|
||||
lastCompletedDateKey: dateKey,
|
||||
currentStreak: newStreak,
|
||||
longestStreak: Math.max(state.longestStreak, newStreak),
|
||||
totalCompletions: state.totalCompletions + 1,
|
||||
graceUsed: state.graceUsed + 1,
|
||||
};
|
||||
} else {
|
||||
// 断链重来
|
||||
next = {
|
||||
...state,
|
||||
lastCompletedDateKey: dateKey,
|
||||
currentStreak: 1,
|
||||
totalCompletions: state.totalCompletions + 1,
|
||||
graceUsed: 0,
|
||||
longestStreak: Math.max(state.longestStreak, 1),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 检查新里程碑(刚达成但未领取)
|
||||
const newMilestones: number[] = [];
|
||||
for (const m of BEACON_CHAIN_MILESTONES) {
|
||||
if (
|
||||
next.currentStreak >= m &&
|
||||
!next.milestonesClaimed.includes(m)
|
||||
) {
|
||||
newMilestones.push(m);
|
||||
}
|
||||
}
|
||||
|
||||
saveChainState(next);
|
||||
return { state: next, newMilestones };
|
||||
}
|
||||
|
||||
/** 领取里程碑奖励,加入 milestonesClaimed */
|
||||
export function claimChainMilestone(milestone: number): {
|
||||
rewardInsight: number;
|
||||
rewardContact: number;
|
||||
label: string;
|
||||
state: BeaconChainState;
|
||||
} {
|
||||
const reward = BEACON_CHAIN_REWARDS.find((r) => r.milestone === milestone);
|
||||
const state = loadChainState();
|
||||
if (
|
||||
!reward ||
|
||||
state.milestonesClaimed.includes(milestone) ||
|
||||
state.currentStreak < milestone
|
||||
) {
|
||||
return { rewardInsight: 0, rewardContact: 0, label: "", state };
|
||||
}
|
||||
const next: BeaconChainState = {
|
||||
...state,
|
||||
milestonesClaimed: [...state.milestonesClaimed, milestone],
|
||||
};
|
||||
saveChainState(next);
|
||||
return {
|
||||
rewardInsight: reward.rewardInsight,
|
||||
rewardContact: reward.rewardContact,
|
||||
label: reward.label,
|
||||
state: next,
|
||||
};
|
||||
}
|
||||
|
||||
/** 返回下一个目标里程碑(如 streak=5 → 7;streak=30+ → null) */
|
||||
export function getNextMilestone(streak: number): number | null {
|
||||
for (const m of BEACON_CHAIN_MILESTONES) {
|
||||
if (streak < m) return m;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回信标链进度信息(用于 UI 进度条)。
|
||||
* - current:当前连续天数
|
||||
* - next:下一个目标里程碑(null 表示已通关全部)
|
||||
* - progressPct:当前进度(基于上一个里程碑 → 下一个里程碑)
|
||||
*/
|
||||
export function getChainProgress(streak: number): {
|
||||
current: number;
|
||||
next: number | null;
|
||||
prev: number;
|
||||
progressPct: number;
|
||||
} {
|
||||
const next = getNextMilestone(streak);
|
||||
let prev = 0;
|
||||
for (const m of BEACON_CHAIN_MILESTONES) {
|
||||
if (streak >= m) prev = m;
|
||||
}
|
||||
if (next === null) {
|
||||
return { current: streak, next: null, prev, progressPct: 100 };
|
||||
}
|
||||
const span = next - prev;
|
||||
const done = streak - prev;
|
||||
const pct = span > 0 ? Math.round((done / span) * 100) : 100;
|
||||
return {
|
||||
current: streak,
|
||||
next,
|
||||
prev,
|
||||
progressPct: Math.min(100, Math.max(0, pct)),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user