import Utils from "../Pay/Utils"; export interface ActivityPeriod { activityId: string; periodId: string; configVersion: string; startsAt: number; endsAt: number; unlockLevel: number; purchaseEnabled?: boolean; } interface Schedule { schemaVersion: number; serverNow: number; windowEndsAt: number; refreshAt: number; activities: ActivityPeriod[]; openingRules?: Array<{ activityId: string; enabled: boolean; unlockLevel: number; cooldownMs: number; lastEndedAt: number | null; hasOpenPeriod: boolean }>; } /** Entry schedules include personal CloudRise windows; gameplay remains in its activity service. */ export default class ActivityScheduleService { private static environment = ""; private static uid = ""; private static token = ""; private static generation = 0; private static snapshot: Schedule = null; private static offset = 0; private static nextRefreshAt = 0; private static task: Promise = null; private static openingTask: Promise = null; private static signature = ""; private static listeners: Array<() => void> = []; private static valid(data: any): data is Schedule { return !!data && data.schemaVersion === 1 && Number.isFinite(data.serverNow) && Number.isFinite(data.windowEndsAt) && data.windowEndsAt > data.serverNow && Number.isFinite(data.refreshAt) && Array.isArray(data.activities) && data.activities.every(item => item && typeof item.activityId === "string" && item.activityId && typeof item.periodId === "string" && item.periodId && typeof item.configVersion === "string" && Number.isFinite(item.startsAt) && Number.isFinite(item.endsAt) && item.startsAt < item.endsAt && Number.isFinite(item.unlockLevel) && item.unlockLevel >= 0); } private static ensure(): void { const environment = Utils.apiBaseUrl(); const info = cc.fx.GameConfig.GM_INFO; const uid = String(info.userId || ""), token = String(info.token || ""); if (environment === this.environment && uid === this.uid && token === this.token) return; this.environment = environment; this.uid = uid; this.token = token; this.generation++; this.snapshot = null; this.offset = 0; this.nextRefreshAt = 0; this.task = null; this.signature = ""; this.openingTask = null; // Wait for a fresh account-specific list before showing or requesting a personal period. } private static cacheKey(): string { return "activitySchedule:v2:" + this.environment + ":" + this.uid; } static now(): number { this.ensure(); return Date.now() + this.offset; } static hasSchedule(): boolean { return !!this.data(); } private static data(): Schedule { this.ensure(); return this.snapshot && this.now() < this.snapshot.windowEndsAt ? this.snapshot : null; } /** Recalculate from timestamps: the response's phase becomes stale at the next boundary. */ static active(activityId: string): ActivityPeriod { const data = this.data(), now = this.now(); return data && data.activities.filter(item => item.activityId === activityId && item.startsAt <= now && now < item.endsAt) .sort((a, b) => b.startsAt - a.startsAt || a.periodId.localeCompare(b.periodId))[0] || null; } static periods(activityId: string): ActivityPeriod[] { const data = this.data(); return data ? data.activities.filter(item => item.activityId === activityId) : []; } static subscribe(listener: () => void): () => void { this.listeners.push(listener); return () => { this.listeners = this.listeners.filter(item => item !== listener); }; } private static notify(force = false): void { const data = this.data(), now = this.now(); const signature = JSON.stringify(data ? data.activities.filter(item => item.startsAt <= now && now < item.endsAt) : null); if (!force && signature === this.signature) return; this.signature = signature; this.listeners.slice().forEach(listener => listener()); } /** Called by the home lifecycle; evaluate local time boundaries between requests. */ static tick(): void { this.notify(); void this.sync(); } /** HomeScene owns opening. Periodic polling and level reports only read activity state. */ static async checkCloudRiseOpening(isHomeActive: () => boolean): Promise { this.ensure(); const generation = this.generation; if (!await this.sync(true)) return false; this.ensure(); if (generation !== this.generation || !isHomeActive()) return false; const data = this.data(), rule = data && Array.isArray(data.openingRules) && data.openingRules.find(item => item.activityId === "cloudRise"); const levelAmount = Number(cc.fx.GameConfig.GM_INFO.level); if (!rule || rule.enabled !== true || rule.hasOpenPeriod !== false || this.active("cloudRise") || !Number.isSafeInteger(levelAmount) || !Number.isSafeInteger(rule.unlockLevel) || levelAmount < rule.unlockLevel || !Number.isFinite(rule.cooldownMs) || rule.cooldownMs < 0 || rule.lastEndedAt !== null && (!Number.isFinite(rule.lastEndedAt) || this.now() - rule.lastEndedAt <= rule.cooldownMs)) return false; if (this.openingTask) return this.openingTask; const task = (async () => { const res: any = await new Promise(resolve => { try { Utils.POST("cloudRise/index", { uid: this.uid, action: "open_period", levelAmount }, resolve); } catch (_) { resolve(null); } }); this.ensure(); if (generation !== this.generation || !res || res.code !== 1 || !res.data) return false; const period = res.data.period, serverNow = res.data.serverNow; const opened: Schedule = { ...data, serverNow, activities: data.activities.filter(item => item.activityId !== "cloudRise") .concat({ ...period, activityId: "cloudRise" }) }; if (!Number.isFinite(serverNow) || !this.valid(opened)) return false; const offset = serverNow - Date.now(); // A list already in flight may predate opening; don't let it overwrite the success response. if (this.task) await this.task; this.ensure(); if (generation !== this.generation) return false; const latest = this.data() || opened; this.snapshot = { ...latest, serverNow: Date.now() + offset, activities: latest.activities.filter(item => item.activityId !== "cloudRise") .concat({ ...period, activityId: "cloudRise" }), openingRules: (latest.openingRules || data.openingRules).map(item => item.activityId === "cloudRise" ? { ...item, hasOpenPeriod: true } : item) }; this.offset = offset; this.nextRefreshAt = this.now() + 30000; try { cc.fx.StorageMessage.setStorage(this.cacheKey(), { receivedAt: Date.now(), data: this.snapshot }); } catch (_) {} this.notify(true); return true; })(); this.openingTask = task; try { return await task; } finally { if (this.openingTask === task) this.openingTask = null; } } static async sync(force = false): Promise { this.ensure(); if (!this.uid || !this.token) return false; if (this.task) return this.task; if (!force && this.now() < this.nextRefreshAt) return this.hasSchedule(); const generation = this.generation; // Every actual request replaces the previous schedule, including failed refreshes. this.snapshot = null; try { cc.fx.StorageMessage.removeStorage(this.cacheKey()); } catch (_) { /* Storage failures must not block requesting the current schedule. */ } const task = (async () => { const res: any = await new Promise(resolve => { try { Utils.POST("activityConfig/list", { uid: this.uid }, resolve); } catch (_) { resolve(null); } }); this.ensure(); if (generation !== this.generation) return false; this.nextRefreshAt = this.now() + 30000; if (!res || res.code !== 1 || !this.valid(res.data)) return false; const receivedAt = Date.now(); this.snapshot = res.data; this.offset = res.data.serverNow - receivedAt; // Bound polling to 1–30 seconds, including a stale refreshAt returned by a proxy. this.nextRefreshAt = Math.max(this.now() + 1000, Math.min(res.data.refreshAt, this.now() + 30000)); try { cc.fx.StorageMessage.setStorage(this.cacheKey(), { receivedAt, data: this.snapshot }); } catch (_) { /* Storage quota failures do not invalidate a successful response. */ } this.notify(true); return true; })(); this.task = task; // Publish invalidation only after reserving the request, so listeners cannot start a duplicate. this.notify(); try { return await task; } finally { if (this.task === task) this.task = null; } } }