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[]; } /** 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 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 = ""; // A new login may create a new personal period, even for the same account. // Wait for its fresh list instead of displaying the prior login's cached 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(); } 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; } } }