MatchMaster/assets/cloud_rise_home/scripts/CloudRiseRuntime.ts
2026-09-21 12:44:48 +08:00

305 lines
18 KiB
TypeScript

import Bridge from "../../Script/module/Config/CloudRiseService";
import Utils from "../../Script/module/Pay/Utils";
interface Attempt { runId: string; stage: number; attemptId: string; outcome?: string }
interface Journal { snapshot: any; queue: any[]; attempt: Attempt | null; seenResults: string[]; eliminations?: any[] }
/** One durable per-user journal, independent from the ten-win activity's storage keys. */
export default class CloudRiseRuntime {
private static uid = "";
private static journal: Journal = null;
private static initialized = false;
private static syncing: Promise<boolean> = null;
private static preparing = false;
private static starting = false;
private static matchPreparation: Promise<boolean> = null;
private static cancelledMatches = new Set<string>();
private static listeners: Array<() => void> = [];
private static offset = 0;
private static pendingRewards: { [key: string]: number } = {};
static error = "";
private static ensure(): void {
// Keep requests and the local activity journal keyed by users.onlyId.
const uid = String(cc.fx.GameConfig.GM_INFO.userId || "");
if (this.journal && this.uid === uid) return;
this.uid = uid; this.initialized = false; this.syncing = null; this.pendingRewards = {};
this.journal = cc.fx.StorageMessage.getStorage("cloudRise:" + uid)
|| { snapshot: null, queue: [], attempt: null, seenResults: [] };
// A new runtime with an unfinished round follows the existing ten-win recovery rule.
// Already recorded wins/losses remain in the outbox and must never become interruptions.
const attempt = this.journal.attempt;
if (attempt && !attempt.outcome) {
attempt.outcome = "interrupted";
this.journal.queue.push({ action: "finish", ...attempt });
this.save();
}
}
private static save(): void { cc.fx.StorageMessage.setStorage("cloudRise:" + this.uid, this.journal); }
static subscribe(listener: () => void): () => void {
this.listeners.push(listener);
return () => { this.listeners = this.listeners.filter(fn => fn !== listener); };
}
private static emit(): void { this.listeners.slice().forEach(fn => fn()); }
static data(): any { this.ensure(); return this.journal.snapshot; }
static run(): any { return Bridge.currentRun(this.data()); }
static stage(): any { const run = this.run(); return run && run.stages[run.stages.length - 1]; }
static now(): number { return Date.now() + this.offset; }
static pending(): boolean { this.ensure(); return this.journal.queue.length > 0; }
static hasPendingLoss(): boolean { this.ensure(); return this.journal.queue.some(item => item.action === "finish" && item.outcome === "lose"); }
static visible(): boolean {
return Bridge.visible();
}
static resultKey(): string {
const run = this.run(), stage = this.stage();
return run && stage && (stage.status !== "playing" || run.status === "expired")
? run.id + ":" + stage.stage + ":" + run.status : "";
}
static unseenResult(): boolean { const key = this.resultKey(); return !!key && !this.journal.seenResults.includes(key); }
static acknowledgeResult(): void {
const key = this.resultKey();
if (key && !this.journal.seenResults.includes(key)) { this.journal.seenResults.push(key); this.save(); }
}
static elimination(): any { this.ensure(); return (this.journal.eliminations || [])[0] || null; }
static acknowledgeElimination(key: string): void {
this.ensure();
this.journal.eliminations = (this.journal.eliminations || []).filter(item => item.key !== key);
this.save(); this.emit();
}
private static request(body: any): Promise<any> {
const uid = this.uid;
return new Promise(resolve => {
try { Utils.POST("cloudRise/index", { uid, ...body }, resolve); }
catch (_) { resolve({ code: 0, msg: "活动暂时无法连接,请重试" }); }
});
}
private static async accept(data: any): Promise<boolean> {
const uid = this.uid;
const beforeRun = this.run(), before = this.stage(), afterRun = data.run;
const after = before && afterRun && afterRun.id === (beforeRun && beforeRun.id)
&& afterRun.stages.find(stage => stage.stage === before.stage);
// Persist confirmed advances and final losses once; polling cannot replay either outcome.
const lost = before && after && before.status === "playing" && after.status === "lost";
const expired = before && after && beforeRun.status !== "expired" && afterRun.status === "expired";
if (before && after && (after.success_num > before.success_num || lost || expired)) {
const item = { key: afterRun.id + ":" + after.stage + ":" + after.success_num + (expired ? ":expired" : lost ? ":lost" : ""),
before: JSON.parse(JSON.stringify(before)), after: JSON.parse(JSON.stringify(expired
? { ...after, status: "expired", survivors: after.status === "won"
? Math.max(0, after.survivors - 1) : after.survivors } : after)) };
if (!this.journal.eliminations) this.journal.eliminations = [];
if (!this.journal.eliminations.some(old => old.key === item.key)) this.journal.eliminations.push(item);
}
this.offset = data.serverNow - Date.now();
this.journal.snapshot = data;
// A settled previous period is recovery-only, never the displayed activity.
const run = data.run || data.previousRun;
for (const stage of run && run.stages || []) {
if (stage.status !== "won") continue;
const key = run.id + ":" + stage.stage;
if (stage.rewardSaved && !this.pendingRewards[key]) continue;
const reward = this.pendingRewards[key] || Math.ceil(Math.ceil(stage.pool / stage.survivors) / 10) * 10;
if (!Number.isSafeInteger(reward) || reward <= 0) {
this.error = "阶段奖励数据无效,请刷新重试"; this.save(); this.emit(); return false;
}
this.pendingRewards[key] = reward;
// Compute on the client; the server only persists this balance and the stage receipt.
const balance = Math.floor(cc.fx.GameConfig.GM_INFO.coin) + reward;
const res = await this.request({ action: "save_reward", runId: run.id,
stage: stage.stage, reward, coinAmount: balance });
if (String(cc.fx.GameConfig.GM_INFO.userId || "") !== uid || this.uid !== uid) return false;
if (!res || res.code !== 1) {
this.error = res && res.msg || "阶段奖励待保存,请联网重试"; this.save(); this.emit(); return false;
}
cc.fx.GameConfig.GM_INFO.coin += reward;
cc.fx.StorageMessage.setStorage("coin", { coin: cc.fx.GameConfig.GM_INFO.coin, timestamp: Date.now() });
delete this.pendingRewards[key];
this.journal.snapshot = res.data;
// Preserve any ordinary coin changes that happened while the report was in flight.
if (cc.fx.GameConfig.GM_INFO.coin !== balance) Utils.setUserCoin(() => {});
}
this.error = ""; this.save(); this.emit(); return true;
}
private static acceptClosed(res: any): boolean {
if (!res || res.code !== 0 || res.msg !== "活动未开启") return false;
// The server confirms there is no personal run; discard stale eligibility and rejected signup work.
this.journal.snapshot = null; this.journal.queue = []; this.journal.attempt = null;
this.pendingRewards = {}; this.initialized = true;
this.error = res.msg; this.save(); this.emit(); return true;
}
static async sync(): Promise<boolean> {
this.ensure();
if (!this.uid) return false;
if (this.syncing) return this.syncing;
const uid = this.uid;
this.syncing = (async () => {
while (true) {
while (this.journal.queue.length) {
const item = this.journal.queue[0];
const res = await this.request(item);
if (this.uid !== uid) return false;
if (!res || res.code !== 1) {
if (this.acceptClosed(res)) return false;
// Resolve terminal/stale requests without discarding an uncertain result.
const check = await this.request({ action: "status" });
if (this.uid !== uid) return false;
if (this.acceptClosed(check)) return false;
if (check && check.code === 1) {
const current = check.data.run;
const matching = check.data.matching;
if (item.action === "prepare_match" || item.action === "confirm_match"
&& (current?.stages.some(stage => stage.matchId === item.matchId) || matching?.id !== item.matchId)) {
if (!await this.accept(check.data)) return false;
this.journal.queue.shift(); this.save(); continue;
}
const terminal = current && ["failed", "expired", "completed"].includes(current.status);
const periodChanged = !check.data.period || check.data.period.periodId !== item.periodId;
const rejectedStart = item.action === "start" && (periodChanged || !check.data.available
&& (!current || current.periodId !== item.periodId || terminal));
const staleRun = item.runId && (!current || current.id !== item.runId);
if (rejectedStart || staleRun || (terminal && current.id === item.runId)) {
if (!await this.accept(check.data)) return false; this.journal.queue.shift(); this.journal.attempt = null;
this.save(); continue;
}
}
this.error = res && res.msg || "活动进度待同步,请联网重试"; this.emit(); return false;
}
// A fresh enrollment may reuse the period-derived run id after an admin reset.
// Its result presentations must not inherit acknowledgements from the old run.
if (item.action === "start" && res.data.run && res.data.run.periodId === item.periodId
|| item.action === "confirm_match" && res.data.run?.stage === 1 && this.run()?.status !== "playing") {
const prefix = res.data.run.id + ":";
this.journal.seenResults = this.journal.seenResults.filter(key => !key.startsWith(prefix));
}
if (!await this.accept(res.data)) return false;
this.journal.queue.shift();
if (item.action === "finish" && this.journal.attempt && this.journal.attempt.attemptId === item.attemptId) this.journal.attempt = null;
if (res.data.run && ["failed", "expired", "completed"].includes(res.data.run.status)) this.journal.attempt = null;
this.save();
}
const res = await this.request({ action: "status" });
if (this.uid !== uid) return false;
if (this.acceptClosed(res)) return false;
if (!res || res.code !== 1) { this.error = res && res.msg || "活动暂时无法连接"; this.emit(); return false; }
if (!await this.accept(res.data)) return false; this.initialized = true;
// A board result can arrive while the final status request is in flight.
if (!this.journal.queue.length) return true;
}
})();
try { return await this.syncing; } finally { if (this.uid === uid) this.syncing = null; }
}
/** Only the activity page calls this. No automatic advancement after a stage is won. */
static async startStage(): Promise<boolean> {
if (this.starting) return false;
this.starting = true;
try {
if (!await this.sync()) return false;
const data = this.data(), run = data.run;
let body: any;
if (Bridge.challengeActive(run) && run.status === "waiting") body = { action: "start_stage", runId: run.id, stage: run.stage + 1 };
else if (data.available && Bridge.registrationOpen(data)) body = { action: "start", periodId: data.period.periodId };
else return Bridge.challengeActive(run) && run.status === "playing";
body.levelAmount = Number(cc.fx.GameConfig.GM_INFO.level) || 0;
this.journal.queue.push(body); this.save();
return await this.sync();
} finally { this.starting = false; }
}
/** Preparing opponents reserves no participation; only the 100/100 confirmation enrolls. */
static async prepareMatch(matchId: string): Promise<boolean> {
this.ensure();
const uid = this.uid;
const prior = this.matchPreparation;
const task = (async () => {
if (prior) await prior;
if (this.cancelledMatches.has(matchId) || !await this.sync()) return false;
if (this.uid !== uid || String(cc.fx.GameConfig.GM_INFO.userId || "") !== uid || this.cancelledMatches.has(matchId)) return false;
const data = this.data(), run = this.run();
const stage = Bridge.challengeActive(run) && run.status === "waiting" ? run.stage + 1 : 1;
// GM_INFO.level counts completed levels; the server matches from levelAmount + 1.
this.journal.queue.push({ action: "prepare_match", matchId, stage,
levelAmount: Number(cc.fx.GameConfig.GM_INFO.level) || 0,
...(stage === 1 ? { periodId: data.period && data.period.periodId } : { runId: run.id }) });
this.save();
const ok = await this.sync();
if (this.cancelledMatches.has(matchId)) return false;
if (ok && this.data()?.matching?.id === matchId) return true;
this.error = this.error || "匹配准备失败,请重试"; this.emit(); return false;
})();
this.matchPreparation = task;
try { return await task; } finally { if (this.matchPreparation === task) this.matchPreparation = null; }
}
static cancelMatch(matchId: string): void {
this.ensure(); this.cancelledMatches.add(matchId);
// Keep cancellation durable and ordered after an uncertain prepare response.
this.journal.queue.push({ action: "cancel_match", matchId }); this.save();
void this.sync();
}
static async confirmMatch(matchId: string): Promise<boolean> {
this.ensure();
if (this.cancelledMatches.has(matchId)) return false;
this.journal.queue.push({ action: "confirm_match", matchId }); this.save();
if (!await this.sync()) return false;
if (this.run()?.stages.some(stage => stage.matchId === matchId)) return true;
this.error = "匹配已失效,请重新开始"; this.emit(); return false;
}
/** Persist the first action before play; defer only while earlier activity work needs reconciliation. */
static prepareRound(resume: () => void): boolean {
this.ensure();
const run = this.run(), attempt = this.journal.attempt;
if (cc.fx.GameConfig.GM_INFO.otherLevel !== 0) return true;
if (attempt && !attempt.outcome) return true;
if (this.initialized && (!run || run.status !== "playing")
&& this.journal.queue.every(item => ["prepare_match", "cancel_match"].includes(item.action))) return true;
if (this.initialized && !this.pending() && (!run || run.status !== "playing" || this.now() >= run.expiresAt)) return true;
if (this.initialized && !this.pending() && run && run.status === "playing") {
this.createAttempt(run); void this.sync(); return true;
}
if (this.preparing) return false;
this.preparing = true;
this.beginRound().then(ok => {
this.preparing = false;
if (ok) resume();
else cc.warn(this.error || "百人赛等待同步");
}).catch(error => { this.preparing = false; this.error = String(error); this.emit(); });
return false;
}
private static async beginRound(): Promise<boolean> {
if (!await this.sync()) {
// Unavailable/undeployed activity must not block users with no enrolled run.
const run = this.run();
const allow = (!run || run.status !== "playing")
&& this.journal.queue.every(item => ["prepare_match", "cancel_match"].includes(item.action));
if (allow) this.initialized = true;
return allow;
}
const run = this.run();
if (!run || run.status !== "playing" || this.now() >= run.expiresAt) return true;
if (!this.journal.attempt) {
this.createAttempt(run);
}
return this.sync();
}
private static createAttempt(run: any): void {
const attempt = { runId: run.id, stage: run.stage,
attemptId: Date.now().toString(36) + "-" + Math.random().toString(36).slice(2) };
this.journal.attempt = attempt;
this.journal.queue.push({ action: "begin", ...attempt }); this.save();
}
static finish(won: boolean): void {
this.ensure();
const attempt = this.journal.attempt;
if (!attempt || attempt.outcome) return;
attempt.outcome = won ? "win" : "lose";
this.journal.queue.push({ action: "finish", ...attempt }); this.save(); this.emit();
void this.sync();
}
}
Bridge.install(CloudRiseRuntime);