170 lines
7.8 KiB
TypeScript
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 })),
|
|
})) };
|
|
}
|