/** cloudRise matching and stage rules. Reward calculation belongs to the client. */ export const TARGETS = [5, 7, 9]; export const POOLS = [10000, 15000, 20000]; export const DURATION = 24 * 60 * 60 * 1000; export const MAX_START_LEVEL = 1960; export interface LevelStat { level: number; enter_total: number; pass_rate: number | null } export interface Profile { username: string; useravatar: string; useravatarIcon: string } export interface Opponent extends Profile { start_level: number; success_num: number } export interface Stage { stage: number; start_level: number; target: number; pool: number; success_num: number; round: number; status: string; opponents: Opponent[]; attemptId: string | null; results: { id: string; outcome: string }[]; survivors: number; reward: number; rewardSaved?: boolean; startedAt: number; endedAt?: number; reason?: string; } export interface Run { id: string; periodId: string; status: string; stage: number; startedAt: number; expiresAt: number; durationHours?: number; pools?: number[]; stages: Stage[]; } export interface State { version: number; playedPeriods: string[]; run: Run | null } export function initialState(): State { return { version: 1, playedPeriods: [], run: null }; } export function active(run: Run | null): boolean { return !!run && (run.status === "playing" || run.status === "waiting"); } export function currentStage(run: Run): Stage { return run.stages[run.stages.length - 1]; } /** Sum to ten standard deviations; omitted positive tail is below floating point precision. */ export function centralMass(k: number, sigma: number): number { let total = 0, center = 0; const lower = Math.max(1, Math.floor(k - 10 * sigma)); const upper = Math.ceil(k + 10 * sigma); for (let i = lower; i <= upper; i++) { const weight = Math.exp(-0.5 * ((i - k) / sigma) ** 2); total += weight; if (i >= k - 100 && i <= k + 100) center += weight; } return center / total; } const sigmas = new Map(); export function sigmaFor(k: number): number { if (!Number.isSafeInteger(k) || k < 1) throw new Error("无效的起始关卡"); if (sigmas.has(k)) return sigmas.get(k)!; let lo = 1, hi = 512; for (let n = 0; n < 40; n++) { const mid = (lo + hi) / 2; if (centralMass(k, mid) > 0.5) lo = mid; else hi = mid; } const result = (lo + hi) / 2; // Bound the warm-function cache, without changing the distribution. if (sigmas.size > 2048) sigmas.clear(); sigmas.set(k, result); return result; } export function startWeights(k: number, stats: LevelStat[]): number[] { const sigma = sigmaFor(k); const entries = new Map(stats.map(s => [s.level, s.enter_total])); // Log weights avoid underflow if the real player's progress is above the sampled range. const logs = Array.from({ length: MAX_START_LEVEL }, (_, i) => { const count = entries.get(i + 1) || 0; return count > 0 ? Math.log(count) - 0.5 * ((i + 1 - k) / sigma) ** 2 : -Infinity; }); const max = Math.max(...logs); if (!Number.isFinite(max)) throw new Error("缺少有效进入人数数据"); return logs.map(w => Math.exp(w - max)); } export function weightedIndex(weights: number[], random: () => number): number { const total = weights.reduce((a, b) => a + b, 0); if (!(total > 0)) throw new Error("抽样权重为空"); let ticket = random() * total; for (let i = 0; i < weights.length; i++) { ticket -= weights[i]; if (ticket < 0) return i; } return weights.length - 1; } export function rateAt(level: number, stats: LevelStat[]): number { // Input rows may be unordered. Never turn missing rates into zero or one. let nearest = 0, rate: number | null = null; for (const row of stats) { if (row.level <= level && row.level > nearest && row.pass_rate !== null) { nearest = row.level; rate = row.pass_rate; } } if (rate === null || rate < 0 || rate > 1) throw new Error("缺少有效通关率数据"); return rate; } /** Histogram counts preserve the probability of every record in the multiset. */ export function sampleScore(start: number, target: number, counts: number[] | undefined, stats: LevelStat[], random: () => number): number { if (counts && counts.reduce((a, b) => a + b, 0) > 0) return weightedIndex(counts, random); let won = 0; while (won < target && random() < rateAt(start + won, stats)) won++; return won; } export function matchStage(stage: number, start: number, now: number, stats: LevelStat[], samples: Record, profiles: Profile[], random: () => number, rewardPool = POOLS[stage - 1]): Stage { if (!TARGETS[stage - 1]) throw new Error("无效阶段"); if (!profiles.length) throw new Error("暂无符合条件的参赛资料,请稍后再试"); const target = TARGETS[stage - 1], weights = startWeights(start, stats); const opponents: Opponent[] = []; for (let n = 0; n < 99; n++) { const start_level = weightedIndex(weights, random) + 1; const success_num = sampleScore(start_level, target, samples[String(start_level)], stats, random); // Profiles are independent of gameplay data; a small pool can reuse display identities. const profile = profiles[Math.floor(random() * profiles.length)]; opponents.push({ ...profile, start_level, success_num }); } return { stage, start_level: start, target, pool: rewardPool, success_num: 0, round: 0, status: "playing", opponents, attemptId: null, results: [], survivors: 100, reward: 0, startedAt: now }; } export function expire(run: Run | null, now: number): boolean { if (!active(run) || now < run!.expiresAt) return false; run!.status = "expired"; const stage = currentStage(run!); if (stage.status === "playing") { // Resolve the current round's opponents like a loss, without fabricating an attempt or sample. stage.round = stage.success_num + 1; stage.survivors = stage.opponents.filter(o => o.success_num >= stage.round).length; stage.status = "expired"; stage.endedAt = now; stage.attemptId = null; } return true; } export function beginAttempt(run: Run, id: string): void { if (typeof id !== "string" || !id || id.length > 100) throw new Error("无效的关卡尝试标识"); const stage = currentStage(run); if (stage.results.some(r => r.id === id)) return; if (run.status !== "playing") throw new Error("当前阶段未开始或已经结束"); if (stage.attemptId && stage.attemptId !== id) throw new Error("上一关尚未结算"); stage.attemptId = id; } export function finishAttempt(run: Run, id: string, outcome: string, now: number): number { if (typeof id !== "string" || !id || id.length > 100) throw new Error("无效的关卡尝试标识"); const stage = currentStage(run); if (stage.results.some(r => r.id === id)) return 0; if (run.status !== "playing" || stage.attemptId !== id) throw new Error("关卡结算与当前尝试不匹配"); if (outcome !== "win" && outcome !== "lose" && outcome !== "interrupted") throw new Error("无效的关卡结果"); stage.results.push({ id, outcome }); stage.attemptId = null; stage.round = stage.success_num + 1; stage.survivors = stage.opponents.filter(o => o.success_num >= stage.round).length + (outcome === "win" ? 1 : 0); if (outcome !== "win") { stage.status = "lost"; stage.reason = outcome; stage.endedAt = now; run.status = "failed"; return 0; } stage.success_num++; if (stage.success_num === stage.target) { stage.status = "won"; stage.endedAt = now; // A won stage is eligible for a client-calculated reward report. run.status = stage.stage === 3 ? "completed" : "waiting"; return 0; } return 0; } export function publicRun(run: Run | null): any { if (!run) return null; return { ...run, stages: run.stages.map(s => ({ ...s, opponents: s.opponents.map(o => ({ username: o.username, useravatar: o.useravatar, useravatarIcon: o.useravatarIcon, alive: o.success_num >= s.round })), })) }; }