server/laf-cloud/functions/cloudRise/rules.ts
2026-09-24 17:59:52 +08:00

170 lines
7.8 KiB
TypeScript

/** 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<number, number>();
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<string, number[]>, 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 })),
})) };
}