v0.8.2: 工单大清理 - 编年史分页+限时挑战+星潮深化+云排行榜+手写叙事+socket多人星潮+UI打磨
工单 #8 编年史上限+分页: - engine.ts: slice(-50)→slice(-200) - ChronicleDialog.tsx: 加分页(每页10条)+上一页/下一页+页码显示 工单 #5 限时挑战 (subagent 10-a): - beacon.ts: BeaconTimedChallenge + getTimedSlotKey(4h时段) + generateTimedChallenge - BeaconPanel.tsx: amber主题限时区块 + 倒计时 + <30min紧急状态 - gameStore: trackBeacon 加 timedJustCompleted + claimTimedBeacon action 工单 #3 星潮类型深化 (subagent 10-a): - starTide.ts: +3种星潮 surge(emerald)/eclipse(rose)/prism(fuchsia) - TideModifiers: +targetLenBonus/bossWinRateBonus/autoDecodeIntervalMult - decode.ts: generatePuzzle 加 targetLenBonus 参数 - achievements: ach_tides_all 阈值 6→9 工单 #4/P2 云排行榜 (subagent 10-b): - mini-services/leaderboard-service/ (端口3030, Hono+bun, 内存1000条) - API: GET/POST /api/leaderboard + /stats + CORS + 防刷 - beacon.ts: fetchCloudLeaderboard/submitCloudScore - BeaconPanel: 本地Top20/全球Top100 双tab + YOU徽章高亮 工单 #9 手写叙事 (subagent 10-c): - chronicle.ts: EPOCH_LORE 5纪元×3节点=15段手写叙事(80-150字/段) - buildLore 优先手写节点, fallback 模板, 11个变量替换 P3 socket 多人星潮 (subagent 10-c): - mini-services/star-tide-service/ (端口3031, socket.io) - 每10-15min广播global-tide, 60s持续, 6种类型权重 - useGlobalTide hook + triggerGlobalTide action - StarTideIndicator 加 🌐 全球星潮标记 P2 UI打磨: - page.tsx: CrystalOrb 区加装饰全息环(3层旋转) + 四角标记 + 顶部状态条 + 底部铭文 QA: lint零错误 + dev HTTP200 + VLM 8/10 + 2个mini-service运行中(3030/3031)
This commit is contained in:
+417
-1
@@ -1,7 +1,8 @@
|
||||
// 回响星核 / Echo Nexus — 深空信标
|
||||
// v0.5:每日挑战 + 本地排行榜
|
||||
// v0.8:周挑战 + 信标链(连续完成奖励)
|
||||
// 一个自包含的"每日挑战 + 周挑战 + 连续完成链"元系统,纯 localStorage 持久化。
|
||||
// v0.8.2:限时挑战(每 4 小时刷新,填补日挑战空档)
|
||||
// 一个自包含的"每日 + 限时 + 周挑战 + 连续完成链"元系统,纯 localStorage 持久化。
|
||||
|
||||
/** 每日挑战类型 */
|
||||
export type BeaconChallengeType =
|
||||
@@ -32,6 +33,8 @@ export interface BeaconScoreEntry {
|
||||
durationSec: number;
|
||||
/** v0.8:是否为周挑战记录(日挑战默认 false / undefined) */
|
||||
isWeekly?: boolean;
|
||||
/** v0.8.2:是否为限时挑战记录 */
|
||||
isTimed?: boolean;
|
||||
}
|
||||
|
||||
/** 每日挑战定义 */
|
||||
@@ -334,6 +337,7 @@ export function claimBeaconReward(
|
||||
rewardContact: challenge.rewardContact,
|
||||
score,
|
||||
leaderboard,
|
||||
entry,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -614,6 +618,7 @@ export function claimWeeklyReward(
|
||||
rewardContact: challenge.rewardContact,
|
||||
score,
|
||||
leaderboard,
|
||||
entry,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -872,3 +877,414 @@ export function getChainProgress(streak: number): {
|
||||
progressPct: Math.min(100, Math.max(0, pct)),
|
||||
};
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// 限时挑战(TIMED CHALLENGE)— v0.8.2
|
||||
// 每 4 小时刷新一个"短周期挑战",目标较小、奖励较少,填补日挑战的空档。
|
||||
// 时段按 UTC 0/4/8/12/16/20 点切分,同时段同种子 → 同一挑战(确定性)。
|
||||
// 难度固定 routine,goal = 日挑战基准 × 0.3-0.5。
|
||||
// ===========================================================================
|
||||
|
||||
/** 限时挑战 localStorage key */
|
||||
export const BEACON_TIMED_KEY = "echo-nexus-beacon-timed-v1";
|
||||
|
||||
/** 限时挑战定义 */
|
||||
export interface BeaconTimedChallenge {
|
||||
/** 时段 key(如 "timed_2026-06-24_14" 表示 14-18 点时段) */
|
||||
slotKey: string;
|
||||
/** 挑战类型 */
|
||||
type: BeaconChallengeType;
|
||||
/** 难度(固定 routine) */
|
||||
difficulty: BeaconDifficulty;
|
||||
/** 目标数值(日挑战的 30-50%) */
|
||||
goal: number;
|
||||
/** 奖励:完成时洞见 */
|
||||
rewardInsight: number;
|
||||
/** 奖励:完成时接触进度 */
|
||||
rewardContact: number;
|
||||
/** 使用的种子(可复现) */
|
||||
seed: number;
|
||||
/** 友好标题 */
|
||||
title: string;
|
||||
/** 描述 */
|
||||
desc: string;
|
||||
/** 时段结束时间戳(UTC,用于倒计时) */
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
/** 限时挑战进度 */
|
||||
export interface BeaconTimedProgress {
|
||||
slotKey: string;
|
||||
progress: number;
|
||||
startedAt: number;
|
||||
completedAt: number | null;
|
||||
claimed: boolean;
|
||||
durationSec: number;
|
||||
}
|
||||
|
||||
/** 时段长度(毫秒) */
|
||||
export const TIMED_SLOT_MS = 4 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* 取当前 4 小时时段 key(UTC,0/4/8/12/16/20 点切分)。
|
||||
* 格式:"timed_YYYY-MM-DD_HH",如 "timed_2026-06-24_14"。
|
||||
*/
|
||||
export function getTimedSlotKey(now: Date = new Date()): string {
|
||||
const y = now.getUTCFullYear();
|
||||
const m = String(now.getUTCMonth() + 1).padStart(2, "0");
|
||||
const d = String(now.getUTCDate()).padStart(2, "0");
|
||||
const hour = now.getUTCHours();
|
||||
const slotStart = Math.floor(hour / 4) * 4; // 0, 4, 8, 12, 16, 20
|
||||
return `timed_${y}-${m}-${d}_${String(slotStart).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
/** slotKey → 数值种子(FNV-1a 哈希) */
|
||||
function timedSlotKeyToSeed(slotKey: string): number {
|
||||
let h = 2166136261 >>> 0;
|
||||
for (let i = 0; i < slotKey.length; i++) {
|
||||
h ^= slotKey.charCodeAt(i);
|
||||
h = Math.imul(h, 16777619) >>> 0;
|
||||
}
|
||||
return h >>> 0;
|
||||
}
|
||||
|
||||
/** 计算时段开始时间戳(UTC) */
|
||||
function timedSlotStartMs(slotKey: string): number {
|
||||
// 解析 "timed_YYYY-MM-DD_HH"
|
||||
const match = slotKey.match(/^timed_(\d{4})-(\d{2})-(\d{2})_(\d{2})$/);
|
||||
if (!match) return Date.now();
|
||||
const [, y, m, d, h] = match;
|
||||
return Date.UTC(Number(y), Number(m) - 1, Number(d), Number(h), 0, 0);
|
||||
}
|
||||
|
||||
/** 距离下一个 4 小时时段的毫秒数(用于倒计时) */
|
||||
export function msUntilNextTimedSlot(now: Date = new Date()): number {
|
||||
const hour = now.getUTCHours();
|
||||
const slotStart = Math.floor(hour / 4) * 4;
|
||||
const slotStartMs = Date.UTC(
|
||||
now.getUTCFullYear(),
|
||||
now.getUTCMonth(),
|
||||
now.getUTCDate(),
|
||||
slotStart,
|
||||
0,
|
||||
0
|
||||
);
|
||||
const nextSlotMs = slotStartMs + TIMED_SLOT_MS;
|
||||
return Math.max(0, nextSlotMs - now.getTime());
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成当前时段的限时挑战(确定性:同时段同种子 → 同一挑战)。
|
||||
* 难度固定 routine,goal = 日挑战基准 × 0.3-0.5(向下取整,最小 1)。
|
||||
*/
|
||||
export function generateTimedChallenge(
|
||||
now: Date = new Date()
|
||||
): BeaconTimedChallenge {
|
||||
const slotKey = getTimedSlotKey(now);
|
||||
const seed = timedSlotKeyToSeed(slotKey);
|
||||
const rng = mulberry32(seed);
|
||||
const expiresAt = timedSlotStartMs(slotKey) + TIMED_SLOT_MS;
|
||||
|
||||
const types: BeaconChallengeType[] = [
|
||||
"decode",
|
||||
"expedition",
|
||||
"pulse",
|
||||
"boss",
|
||||
"insight",
|
||||
];
|
||||
const type = types[Math.floor(rng() * types.length)];
|
||||
|
||||
// 难度固定 routine
|
||||
const difficulty: BeaconDifficulty = "routine";
|
||||
const diffMult = BEACON_DIFFICULTY[difficulty].mult; // 1
|
||||
|
||||
// goal = 日挑战基准 × 0.3-0.5
|
||||
const frac = 0.3 + rng() * 0.2; // 0.3-0.5
|
||||
|
||||
let goal = 0;
|
||||
let rewardInsight = 0;
|
||||
let rewardContact = 0;
|
||||
let title = "";
|
||||
let desc = "";
|
||||
|
||||
switch (type) {
|
||||
case "decode":
|
||||
// 日基准 6-15 × 0.3-0.5 → 2-7
|
||||
goal = Math.max(2, Math.round((6 + Math.floor(rng() * 10)) * diffMult * frac));
|
||||
rewardInsight = Math.round(goal * 3 * diffMult);
|
||||
rewardContact = goal * 0.6;
|
||||
title = `限时解码 ${goal} 颗晶体`;
|
||||
desc = `4 小时内解码 ${goal} 颗记忆晶体。短周期,快速完成。`;
|
||||
break;
|
||||
case "expedition":
|
||||
// 日基准 1-3 × 0.3-0.5 → 1(多数情况为 1)
|
||||
goal = Math.max(1, Math.round((1 + Math.floor(rng() * 3)) * diffMult * frac));
|
||||
rewardInsight = Math.round(goal * 9 * diffMult);
|
||||
rewardContact = goal * 1.2;
|
||||
title = `限时远征 ${goal} 次探险`;
|
||||
desc = `4 小时内完成 ${goal} 次遗迹探险。`;
|
||||
break;
|
||||
case "pulse":
|
||||
// 日基准 20-49 × 0.3-0.5 → 6-24
|
||||
goal = Math.max(6, Math.round((20 + Math.floor(rng() * 30)) * diffMult * frac));
|
||||
rewardInsight = Math.round(goal * 1 * diffMult);
|
||||
rewardContact = goal * 0.25;
|
||||
title = `限时脉冲 ${goal} 次扫描`;
|
||||
desc = `4 小时内发起 ${goal} 次脉冲扫描。`;
|
||||
break;
|
||||
case "boss":
|
||||
// 日基准 1 × 0.3-0.5 → 1(向下取整为 1)
|
||||
goal = Math.max(1, Math.round(diffMult * frac));
|
||||
rewardInsight = Math.round(goal * 25 * diffMult);
|
||||
rewardContact = goal * 2.5;
|
||||
title = `限时猎杀 ${goal} 处 BOSS`;
|
||||
desc = `4 小时内击破 ${goal} 处维度 BOSS。`;
|
||||
break;
|
||||
case "insight":
|
||||
// 日基准 40-119 × 0.3-0.5 → 12-59
|
||||
goal = Math.max(12, Math.round((40 + Math.floor(rng() * 80)) * diffMult * frac));
|
||||
rewardInsight = 0; // 洞见挑战不给洞见,给接触
|
||||
rewardContact = goal * 0.08;
|
||||
title = `限时洞见 ${goal} 点`;
|
||||
desc = `4 小时内累计获取 ${goal} 洞见。`;
|
||||
break;
|
||||
}
|
||||
|
||||
return {
|
||||
slotKey,
|
||||
type,
|
||||
difficulty,
|
||||
goal,
|
||||
rewardInsight,
|
||||
rewardContact,
|
||||
seed,
|
||||
title,
|
||||
desc,
|
||||
expiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
/** 读取当前时段进度(若 slotKey 不匹配则重置) */
|
||||
export function loadTimedProgress(now: Date = new Date()): BeaconTimedProgress {
|
||||
const slotKey = getTimedSlotKey(now);
|
||||
const empty: BeaconTimedProgress = {
|
||||
slotKey,
|
||||
progress: 0,
|
||||
startedAt: Date.now(),
|
||||
completedAt: null,
|
||||
claimed: false,
|
||||
durationSec: 0,
|
||||
};
|
||||
if (typeof localStorage === "undefined") return empty;
|
||||
try {
|
||||
const raw = localStorage.getItem(BEACON_TIMED_KEY);
|
||||
if (!raw) return empty;
|
||||
const prog = JSON.parse(raw) as BeaconTimedProgress;
|
||||
if (prog.slotKey !== slotKey) return empty; // 新时段,重置
|
||||
return prog;
|
||||
} catch {
|
||||
return empty;
|
||||
}
|
||||
}
|
||||
|
||||
/** 保存当前时段进度 */
|
||||
export function saveTimedProgress(prog: BeaconTimedProgress): void {
|
||||
if (typeof localStorage === "undefined") return;
|
||||
localStorage.setItem(BEACON_TIMED_KEY, JSON.stringify(prog));
|
||||
}
|
||||
|
||||
/** 增量更新限时挑战进度,返回新进度 + 是否刚完成 */
|
||||
export function addTimedProgress(
|
||||
current: BeaconTimedProgress,
|
||||
challenge: BeaconTimedChallenge,
|
||||
delta: number
|
||||
): { progress: BeaconTimedProgress; 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: BeaconTimedProgress = {
|
||||
...current,
|
||||
progress: newProgressVal,
|
||||
completedAt,
|
||||
durationSec,
|
||||
};
|
||||
saveTimedProgress(next);
|
||||
return { progress: next, justCompleted };
|
||||
}
|
||||
|
||||
/** 领取限时挑战奖励:返回奖励数值 + 推送排行榜 */
|
||||
export function claimTimedReward(
|
||||
challenge: BeaconTimedChallenge,
|
||||
progress: BeaconTimedProgress
|
||||
): {
|
||||
rewardInsight: number;
|
||||
rewardContact: number;
|
||||
score: number;
|
||||
leaderboard: BeaconScoreEntry[];
|
||||
} {
|
||||
if (progress.claimed || progress.completedAt === null) {
|
||||
return {
|
||||
rewardInsight: 0,
|
||||
rewardContact: 0,
|
||||
score: 0,
|
||||
leaderboard: loadLeaderboard(),
|
||||
};
|
||||
}
|
||||
// 借用 computeBeaconScore
|
||||
const score = computeBeaconScore(
|
||||
{ ...(challenge as unknown as BeaconDailyChallenge), dateKey: challenge.slotKey },
|
||||
progress.progress,
|
||||
progress.durationSec
|
||||
);
|
||||
const entry: BeaconScoreEntry = {
|
||||
timestamp: Date.now(),
|
||||
dateKey: challenge.slotKey,
|
||||
challenge: challenge.type,
|
||||
difficulty: challenge.difficulty,
|
||||
progress: progress.progress / challenge.goal,
|
||||
score,
|
||||
durationSec: progress.durationSec,
|
||||
isTimed: true,
|
||||
};
|
||||
const leaderboard = pushLeaderboardEntry(entry);
|
||||
const updated: BeaconTimedProgress = { ...progress, claimed: true };
|
||||
saveTimedProgress(updated);
|
||||
return {
|
||||
rewardInsight: challenge.rewardInsight,
|
||||
rewardContact: challenge.rewardContact,
|
||||
score,
|
||||
leaderboard,
|
||||
entry,
|
||||
};
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// 云排行榜(CLOUD LEADERBOARD)— v0.8.2 P2/#4
|
||||
// 把信标本机排行榜升级为云端 Top100,玩家间可比。
|
||||
// 通过 mini-service(端口 3030,Hono + Bun)提供 API:
|
||||
// GET /api/leaderboard → { entries, total }
|
||||
// POST /api/leaderboard body { entry } → { entries, total, rank }
|
||||
// GET /api/leaderboard/stats → { totalSubmissions, uniquePlayers, topScore }
|
||||
// 前端请求必须用相对路径 + ?XTransformPort=3030(Caddy 网关转发规则)。
|
||||
// 本地排行榜(loadLeaderboard / pushLeaderboardEntry)保留作为离线 fallback。
|
||||
// ===========================================================================
|
||||
|
||||
/** 云排行榜服务端口(Caddy 通过 ?XTransformPort=3030 转发) */
|
||||
export const BEACON_CLOUD_PORT = 3030;
|
||||
|
||||
/** 上次成功提交到云端的 entry 时间戳(用于在全球榜中高亮"我的"记录) */
|
||||
export const BEACON_CLOUD_LAST_SUBMIT_KEY = "echo-nexus-beacon-cloud-last-submit-v1";
|
||||
|
||||
/** 云排行榜 API 基础 URL(相对路径 + 网关端口参数,禁止 localhost:3030) */
|
||||
const CLOUD_API_BASE = `/api/leaderboard?XTransformPort=${BEACON_CLOUD_PORT}`;
|
||||
|
||||
/** 云排行榜统计 API */
|
||||
const CLOUD_STATS_URL = `/api/leaderboard/stats?XTransformPort=${BEACON_CLOUD_PORT}`;
|
||||
|
||||
/** 云端排行榜条目(与本地 BeaconScoreEntry 兼容,宽松化以接受历史数据) */
|
||||
export interface CloudLeaderboardResponse {
|
||||
entries: BeaconScoreEntry[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
/** 提交响应(包含本次提交的全球名次) */
|
||||
export interface CloudSubmitResponse {
|
||||
entries: BeaconScoreEntry[];
|
||||
total: number;
|
||||
rank: number;
|
||||
}
|
||||
|
||||
/** 云端统计响应 */
|
||||
export interface CloudStatsResponse {
|
||||
totalSubmissions: number;
|
||||
uniquePlayers: number;
|
||||
topScore: number;
|
||||
}
|
||||
|
||||
/** 读取本地保存的"上次提交时间戳"(用于全球榜高亮) */
|
||||
export function loadLastCloudSubmitTimestamp(): number {
|
||||
if (typeof localStorage === "undefined") return 0;
|
||||
try {
|
||||
const raw = localStorage.getItem(BEACON_CLOUD_LAST_SUBMIT_KEY);
|
||||
return raw ? Number(raw) || 0 : 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/** 保存"上次提交时间戳"到本地 */
|
||||
function saveLastCloudSubmitTimestamp(timestamp: number): void {
|
||||
if (typeof localStorage === "undefined") return;
|
||||
try {
|
||||
localStorage.setItem(BEACON_CLOUD_LAST_SUBMIT_KEY, String(timestamp));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 拉取全球排行榜 Top100。
|
||||
* 失败时返回空数组(调用方负责展示错误状态),不抛异常。
|
||||
*/
|
||||
export async function fetchCloudLeaderboard(): Promise<BeaconScoreEntry[]> {
|
||||
try {
|
||||
const res = await fetch(CLOUD_API_BASE, {
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
if (!res.ok) return [];
|
||||
const data = (await res.json()) as CloudLeaderboardResponse;
|
||||
if (!data || !Array.isArray(data.entries)) return [];
|
||||
return data.entries;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交一条分数到云端。
|
||||
* 成功时返回 rank(>=1);失败/被限流时返回 -1(静默,不影响本地)。
|
||||
* 自动记录 timestamp 到 localStorage 供全球榜高亮。
|
||||
*/
|
||||
export async function submitCloudScore(entry: BeaconScoreEntry): Promise<number> {
|
||||
try {
|
||||
const res = await fetch(CLOUD_API_BASE, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: JSON.stringify({ entry }),
|
||||
});
|
||||
if (!res.ok) return -1;
|
||||
const data = (await res.json()) as CloudSubmitResponse;
|
||||
if (data && typeof data.rank === "number" && data.rank >= 1) {
|
||||
saveLastCloudSubmitTimestamp(entry.timestamp);
|
||||
}
|
||||
return data?.rank ?? -1;
|
||||
} catch {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 拉取云端统计(用于 UI 展示总提交数 / 玩家数 / 最高分)。
|
||||
* 失败时返回 null。
|
||||
*/
|
||||
export async function fetchCloudStats(): Promise<CloudStatsResponse | null> {
|
||||
try {
|
||||
const res = await fetch(CLOUD_STATS_URL, {
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
return (await res.json()) as CloudStatsResponse;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user