MatchMaster/assets/Script/module/Config/CloudRiseService.ts
2026-09-24 16:50:43 +08:00

236 lines
14 KiB
TypeScript

import Utils from "../Pay/Utils";
/** The home bundle is shipped in the main package; matching/stages load independently. */
export default class CloudRiseService {
private static runtime: any = null;
private static bundleTask: Promise<cc.AssetManager.Bundle> = null;
private static artBundleTask: Promise<cc.AssetManager.Bundle> = null;
private static prefabTask: Promise<cc.Prefab> = null;
private static pageTasks: { [bundle: string]: Promise<cc.Prefab> } = {};
private static checkTask: Promise<boolean> = null;
private static snapshot: any = null;
private static uid = "";
private static offset = 0;
private static message = "";
private static autoDisplay = false;
private static publicPreloadPeriod = "";
private static nextPlaySyncAt = 0;
private static listeners: Array<() => void> = [];
static install(runtime: any): void { this.runtime = runtime; runtime.subscribe(() => this.emit()); }
private static ensureUser(): void {
// Login stores users.onlyId in userId; GM_INFO.uid is the legacy Mongo identifier.
const uid = String(cc.fx.GameConfig.GM_INFO.userId || "");
if (uid === this.uid) return;
this.uid = uid; this.snapshot = null; this.checkTask = null; this.message = ""; this.autoDisplay = false; this.publicPreloadPeriod = "";
this.nextPlaySyncAt = 0;
}
static get error(): string { return this.runtime ? this.runtime.error : this.message; }
static data(): any { this.ensureUser(); return this.runtime ? this.runtime.data() : this.snapshot; }
static run(): any { return this.currentRun(this.data()); }
/** Also handles responses from older servers that return a settled run alongside the new period. */
static currentRun(data: any): any {
const run = data && data.run, period = data && data.period;
if (run && period && period.periodId !== run.periodId
&& ["completed", "failed", "expired"].includes(run.status)
&& (run.stages || []).every(stage => stage.status !== "won" || stage.rewardSaved)) return null;
return run || null;
}
static stage(): any { const run = this.run(); return run && run.stages[run.stages.length - 1]; }
static now(): number { return this.runtime ? this.runtime.now() : Date.now() + this.offset; }
static activityOpen(data: any = this.data(), now: number = this.now()): boolean {
const period = data && data.period;
return !!(period && period.startsAt <= now && now < period.endsAt);
}
static registrationOpen(data: any = this.data(), now: number = this.now()): boolean {
return this.activityOpen(data, now);
}
/** GM_INFO.level is the number of completed main levels, not the next level number. */
static lockedUntilLevel(data: any = this.data()): number {
if (this.currentRun(data)) return 0;
const unlockLevel = Number(data && data.period && data.period.unlockLevel) || 0;
return (Number(cc.fx.GameConfig.GM_INFO.level) || 0) < unlockLevel ? unlockLevel : 0;
}
static challengeActive(run: any = this.run()): boolean {
return !!(run && ["playing", "waiting"].includes(run.status) && this.now() < run.expiresAt);
}
static challengeExpired(run: any = this.run()): boolean {
return !!(run && (run.status === "expired" || ["playing", "waiting"].includes(run.status) && this.now() >= run.expiresAt));
}
static visible(): boolean {
const data = this.data(), run = this.currentRun(data);
// A terminal personal run consumes this period's entry, even while signup is still open.
// Pending results/reward retries are handled by their presentation queue, not by the home entry.
return run ? this.challengeActive(run) : this.activityOpen(data);
}
/** Public time windows drive the entry, but never replace the private gameplay snapshot. */
static homeEntryVisible(period: { periodId: string; unlockLevel?: number }, hasSchedule: boolean, now: number): boolean {
const data = this.data(), run = data && data.run;
const unlockLevel = Number(period && period.unlockLevel || data && data.period && data.period.unlockLevel) || 0;
if ((Number(cc.fx.GameConfig.GM_INFO.level) || 0) < unlockLevel) return false;
const personalNow = hasSchedule ? now : this.now();
if (run && ["playing", "waiting"].includes(run.status) && personalNow < run.expiresAt) return true;
if (!hasSchedule || !period) return false;
// A settled old run must not suppress a newly published period's entry.
return !run || run.periodId !== period.periodId;
}
/** Periodic requests recover personal work; they never discover public activity windows. */
static needsPersonalSync(): boolean {
this.ensureUser();
if (!this.uid) return false;
const saved = !this.runtime && cc.fx.StorageMessage.getStorage("cloudRise:" + this.uid);
const data = this.data() || saved && saved.snapshot, run = data && data.run;
return !!(run && (["playing", "waiting"].includes(run.status)
|| (run.stages || []).some(stage => stage.status === "won" && !stage.rewardSaved)))
|| !!(this.runtime ? this.runtime.pending() || this.unseenResult() || this.elimination()
: saved && (saved.attempt || saved.queue && saved.queue.length || saved.eliminations && saved.eliminations.length));
}
static resultKey(): string { return this.runtime ? this.runtime.resultKey() : ""; }
static unseenResult(): boolean { return !!this.runtime && this.runtime.unseenResult(); }
static allowsAutomaticResults(): boolean { this.ensureUser(); return this.autoDisplay; }
static acknowledgeResult(): void { if (this.runtime) this.runtime.acknowledgeResult(); }
static elimination(): any { return this.runtime ? this.runtime.elimination() : null; }
static hasPendingLoss(): boolean { return !!this.runtime && this.runtime.hasPendingLoss(); }
static acknowledgeElimination(key: string): void { if (this.runtime) this.runtime.acknowledgeElimination(key); }
static dismissExpiryPresentation(): void { if (this.runtime) this.runtime.dismissExpiryPresentation(); }
static subscribe(fn: () => void): () => void {
this.listeners.push(fn); return () => { this.listeners = this.listeners.filter(item => item !== fn); };
}
private static emit(): void { this.listeners.slice().forEach(fn => fn()); }
/** Automatic loading requires fresh server eligibility, never just a cached run. */
static shouldPreload(data: any): boolean {
if (!data) return false;
const now = Math.max(Number(data.serverNow) || 0, this.now());
const run = data.run;
return !!(run && (run.stages || []).some(stage => stage.status === "won" && !stage.rewardSaved))
|| !!(run && ["playing", "waiting"].includes(run.status) && run.expiresAt > now)
|| !!(data.available && this.registrationOpen(data, now) && !this.lockedUntilLevel(data));
}
private static bundle(): Promise<cc.AssetManager.Bundle> {
if (!this.bundleTask) this.bundleTask = new Promise<cc.AssetManager.Bundle>((resolve, reject) => {
cc.assetManager.loadBundle("cloud_rise_home", (error, bundle) => error ? reject(error) : resolve(bundle));
}).catch(error => { this.bundleTask = null; throw error; });
return this.bundleTask;
}
static pagePrefab(): Promise<cc.Prefab> {
if (!this.prefabTask) this.prefabTask = this.bundle().then(bundle => new Promise<cc.Prefab>((resolve, reject) => {
bundle.load("prefab/CloudRise", cc.Prefab, (error, prefab: cc.Prefab) => {
if (error) reject(error); else { prefab.addRef(); resolve(prefab); }
});
})).catch(error => { this.prefabTask = null; throw error; });
return this.prefabTask;
}
private static artBundle(): Promise<cc.AssetManager.Bundle> {
if (!this.artBundleTask) this.artBundleTask = Promise.all([this.bundle(), this.prepareActivityVersion()])
.then(() => new Promise<cc.AssetManager.Bundle>((resolve, reject) => {
cc.assetManager.loadBundle("art", (error, bundle) => error ? reject(error) : resolve(bundle));
})).catch(error => { this.artBundleTask = null; throw error; });
return this.artBundleTask;
}
static pageFragment(page: "matching" | "stage", stage = 1): Promise<cc.Prefab> {
if (page === "stage" && ![1, 2, 3].includes(stage)) return Promise.reject(new Error("无效的百人赛阶段"));
const name = page === "matching" ? "matching" : "stage" + stage;
if (!this.pageTasks[name]) this.pageTasks[name] = this.artBundle().then(() => new Promise<cc.Prefab>((resolve, reject) => {
cc.assetManager.loadBundle(name, (error, bundle) => {
if (error) { reject(error); return; }
bundle.load("prefab/CloudRise", cc.Prefab, (error, prefab: cc.Prefab) => {
if (error) reject(error); else { prefab.addRef(); resolve(prefab); }
});
});
})).catch(error => { delete this.pageTasks[name]; throw error; });
return this.pageTasks[name];
}
/** Select the remote release only; each bundle and its resources load on demand. */
private static prepareActivityVersion(): Promise<void> {
const prepare = typeof window !== "undefined" && (window as any).__prepareCloudRiseVersion;
return prepare ? prepare().then(() => {}) : Promise.resolve();
}
private static preloadActivity(): void {
const data = this.data();
if (!this.shouldPreload(data) && (!this.unseenResult() || this.challengeExpired())) return;
void Promise.all([this.pagePrefab(), this.prepareActivityVersion()]).then(() => {
const latest = this.data(), run = this.run();
// Warm only matching and its shared dependencies when another match is possible.
if (run && run.status === "waiting" && this.challengeActive(run)
|| latest && latest.available && this.registrationOpen(latest) && !this.lockedUntilLevel(latest)) {
return this.pageFragment("matching").then(() => {});
}
}).catch(error => cc.warn("百人赛预加载失败,打开时可重试", error));
}
/** Warm upcoming UI at its public start without fetching private status or enrolling the player. */
static preloadPublicEntry(period: { periodId: string; unlockLevel: number }): void {
this.ensureUser();
if (!this.uid || !period || this.publicPreloadPeriod === period.periodId
|| (Number(cc.fx.GameConfig.GM_INFO.level) || 0) < period.unlockLevel) return;
const run = this.run();
if (run && run.status === "playing") return;
this.publicPreloadPeriod = period.periodId;
const uid = this.uid;
void Promise.all([this.pagePrefab(), this.prepareActivityVersion()]).then(() => {
if (String(cc.fx.GameConfig.GM_INFO.userId || "") !== uid) return;
return this.pageFragment("matching");
}).catch(error => cc.warn("百人赛预加载失败,打开时可重试", error));
}
/** One login recovery also discovers runs started on another device, without a local journal. */
static afterLogin(): void { void this.sync(); }
static async sync(): Promise<boolean> {
this.ensureUser();
if (!this.uid) return false;
if (this.runtime) {
const uid = this.uid;
const ok = await this.runtime.sync();
if (ok && String(cc.fx.GameConfig.GM_INFO.userId || "") === uid && (this.shouldPreload(this.runtime.data()) || this.unseenResult())) {
this.autoDisplay = true;
this.preloadActivity();
}
return ok;
}
if (this.checkTask) return this.checkTask;
const uid = this.uid;
const task = (async () => {
try {
const res: any = await new Promise(resolve => {
try { Utils.POST("cloudRise/index", { uid, action: "status" }, resolve); }
catch (_) { resolve(null); }
});
if (String(cc.fx.GameConfig.GM_INFO.userId || "") !== uid) return false;
if (!res || res.code !== 1) {
if (res && res.msg === "活动未开启") { this.snapshot = null; this.autoDisplay = false; }
this.message = res && res.msg || "活动暂时无法连接"; this.emit(); return false;
}
this.snapshot = res.data; this.offset = res.data.serverNow - Date.now(); this.message = "";
this.emit();
if (!this.shouldPreload(res.data)) return true;
this.autoDisplay = true;
await this.bundle();
if (String(cc.fx.GameConfig.GM_INFO.userId || "") !== uid) return false;
if (!this.runtime) throw new Error("百人赛分包未注册业务模块");
// The runtime's first sync installs its snapshot; only then decide what to warm.
const ok = await this.runtime.sync();
if (ok) this.preloadActivity();
return ok;
} catch (error) { this.message = "活动加载失败,请重试"; cc.warn(error); this.emit(); return false; }
})();
this.checkTask = task;
try { return await task; } finally { if (this.checkTask === task) this.checkTask = null; }
}
static async startStage(): Promise<boolean> {
if (!await this.sync()) return false;
return !!this.runtime && this.runtime.startStage();
}
/** Activity recovery is best effort; it must never gate board input. */
static prepareRound(_resume?: () => void): boolean {
try {
this.ensureUser();
if (this.runtime) { this.runtime.prepareRound(); return true; }
if (cc.fx.GameConfig.GM_INFO.otherLevel !== 0) return true;
if (Date.now() >= this.nextPlaySyncAt) {
this.nextPlaySyncAt = Date.now() + 5000;
// Do not create an attempt after an asynchronous reply: this board may already be finished.
void this.sync().catch(error => cc.warn("百人赛后台同步失败", error));
}
} catch (error) { cc.warn("百人赛操作记录待重试", error); }
return true;
}
static finish(won: boolean): void { if (this.runtime) this.runtime.finish(won); }
}