118 lines
5.9 KiB
TypeScript
118 lines
5.9 KiB
TypeScript
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<boolean> = 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<boolean> {
|
||
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; }
|
||
}
|
||
}
|