305 lines
18 KiB
TypeScript
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);
|