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 preparing = false; private static starting = false; 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 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 { const uid = this.uid; return new Promise(resolve => { try { Utils.POST("cloudRise/cloudRise", { uid, ...body }, resolve); } catch (_) { resolve({ code: 0, msg: "活动暂时无法连接,请重试" }); } }); } 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"; if (before && after && (after.success_num > before.success_num || lost)) { const item = { key: afterRun.id + ":" + after.stage + ":" + after.success_num + (lost ? ":lost" : ""), before: JSON.parse(JSON.stringify(before)), after: JSON.parse(JSON.stringify(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 { 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 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) { 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 { if (this.starting) return false; this.starting = true; try { if (!await this.sync()) return false; // The main game uploads level progress separately. Flush before choosing the next center. const levelSaved: any = await new Promise(resolve => Utils.setUserLevel(resolve)); if (!levelSaved || levelSaved.code !== 1) { this.error = "关卡进度待同步,请重试"; this.emit(); 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"; this.journal.queue.push(body); this.save(); return await this.sync(); } finally { this.starting = 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 && !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 { 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.pending(); 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);