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 = null; private static nextPlaySyncAt = 0; private static starting = false; private static matchPreparation: Promise = null; private static cancelledMatches = new Set(); private static listeners: Array<() => void> = []; private static offset = 0; private static pendingRewards: { [key: string]: number } = {}; static error = ""; static matchRetryExhausted = false; 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.nextPlaySyncAt = 0; 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(); } /** Home dismisses timeout presentation only; reward recovery and ordinary results stay intact. */ static dismissExpiryPresentation(): void { this.ensure(); const items = this.journal.eliminations || []; const remaining = items.filter(item => item.after.status !== "expired"); if (remaining.length !== items.length) { this.journal.eliminations = remaining; this.save(); } if (this.run() && this.run().status === "expired") this.acknowledgeResult(); } private static async request(body: any, retryNetwork = false): Promise { const uid = this.uid; // Match the project's bounded retry policy; keep the original operation IDs for idempotency. for (let retry = 0; ; retry++) { if (this.uid !== uid || String(cc.fx.GameConfig.GM_INFO.userId || "") !== uid) return { code: 0, msg: "账号已切换" }; if (body.matchId && this.cancelledMatches.has(body.matchId) && body.action !== "cancel_match") return { code: 0, msg: "匹配已取消" }; const response: any = await new Promise(resolve => { try { Utils.POST("cloudRise/index", { uid, ...body }, resolve); } catch (_) { resolve({ code: 0, msg: "活动暂时无法连接,请重试", networkError: true }); } }); if (!retryNetwork || !response?.networkError) return response; if (retry >= 5) { this.matchRetryExhausted = true; return response; } await new Promise(resolve => setTimeout(resolve, 3000)); } } private static async accept(data: any): Promise { 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(retryNetwork = false): Promise { this.ensure(); if (!this.uid) return false; if (this.syncing) { const ok = await this.syncing; if (ok || !retryNetwork) return ok; } 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, retryNetwork); if (this.uid !== uid) return false; if (retryNetwork && this.matchRetryExhausted) { this.error = res?.msg; this.emit(); 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" }, retryNetwork); 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" }, retryNetwork); 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 { 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 { this.ensure(); const uid = this.uid; const prior = this.matchPreparation; const task = (async () => { if (prior) await prior; this.matchRetryExhausted = false; if (this.cancelledMatches.has(matchId) || !await this.sync(true)) 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(true); 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 { this.ensure(); this.matchRetryExhausted = false; if (this.cancelledMatches.has(matchId)) return false; this.journal.queue.push({ action: "confirm_match", matchId }); this.save(); if (!await this.sync(true)) return false; if (this.run()?.stages.some(stage => stage.matchId === matchId)) return true; this.error = "匹配已失效,请重新开始"; this.emit(); return false; } /** Record locally before play, and retry uploads without delaying board input. */ 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; const stage = this.stage(); const finishes = this.journal.queue.filter(item => item.action === "finish" && run && item.runId === run.id && item.stage === run.stage); // Do not enroll further boards after a locally recorded loss or final stage step. // The pending records remain ordered and are reconciled by the normal sync queue. const locallyFinished = finishes.some(item => item.outcome !== "win") || stage && stage.success_num + finishes.length >= stage.target; if (run && run.status === "playing" && this.now() < run.expiresAt && !locallyFinished) this.createAttempt(run); if ((!this.initialized || this.pending()) && Date.now() >= this.nextPlaySyncAt) { this.nextPlaySyncAt = Date.now() + 5000; void this.sync().catch(error => { this.error = String(error); cc.warn("百人赛后台同步失败", error); }); } return true; } 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);