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:
2026-06-23 22:34:52 +00:00
parent a3aa381656
commit 71ca5b48a9
14 changed files with 2446 additions and 51 deletions
+521 -4
View File
@@ -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;
/** 日期 keyYYYY-MM-DD */
/** 日期 keyYYYY-MM-DD或周 keyYYYY-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;
}
/**
* 生成本周周挑战(确定性:同一周同一种子 → 同一挑战)。
* 难度强制 anomaly60%)或 singular40%),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-12singular 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 → 7streak=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)),
};
}