181 lines
10 KiB
TypeScript
181 lines
10 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[];
|
||
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(); }
|
||
/** One successfully displayed home reminder per account/environment and Beijing calendar day. */
|
||
static cloudRiseReminder(): { key: string; day: number } {
|
||
const period = this.active("cloudRise");
|
||
if (!this.uid || !period || Number(cc.fx.GameConfig.GM_INFO.level) < period.unlockLevel) return null;
|
||
const key = "cloudRiseDailyReminder:" + this.environment + ":" + this.uid;
|
||
const day = Math.floor((this.now() + 8 * 3600000) / 86400000);
|
||
try { if (cc.fx.StorageMessage.getStorage(key) === day) return null; } catch (_) {}
|
||
return { key, day };
|
||
}
|
||
static markCloudRiseReminderShown(reminder: { key: string; day: number }): void {
|
||
const current = this.cloudRiseReminder();
|
||
if (!current || current.key !== reminder.key || current.day !== reminder.day) return;
|
||
try { cc.fx.StorageMessage.setStorage(reminder.key, reminder.day); } catch (_) {}
|
||
}
|
||
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; }
|
||
}
|
||
}
|