MatchMaster/assets/Script/module/Config/ActivityScheduleService.ts
2026-09-21 12:44:48 +08:00

118 lines
5.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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; }
}
}