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

167 lines
9.1 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[];
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<boolean> = null;
private static openingTask: 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 = "";
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<boolean> {
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<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; }
}
}