Files
MatchMaster/assets/Script/module/Config/ActivityScheduleService.ts
T
2026-10-12 15:00:49 +08:00

223 lines
12 KiB
TypeScript

import Utils from "../Pay/Utils";
import ActivityBundleLoader from "./ActivityBundleLoader";
export interface PyramidDailyReminder { key: string; day: number; periodId: string; }
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 playerId = "";
private static token = "";
private static generation = 0;
private static snapshot: Schedule = null;
private static offset = 0;
private static task: Promise<boolean> = null;
private static openingTask: Promise<boolean> = null;
private static signature = "";
private static listeners: Array<() => void> = [];
private static firstHomeAfterLogin = false;
private static lifecycleRegistered = false;
private static foregroundTask: Promise<boolean> = null;
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 || ""), playerId = String(info.uid || "");
if (environment === this.environment && uid === this.uid && token === this.token && playerId === this.playerId) return;
this.playerId = playerId;
this.environment = environment; this.uid = uid; this.token = token; this.generation++;
this.snapshot = null; this.offset = 0; this.task = null; this.signature = "";
this.openingTask = null;
this.firstHomeAfterLogin = false; this.foregroundTask = 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(); }
static afterLogin(): Promise<boolean> {
this.ensure(); this.firstHomeAfterLogin = true;
if (!this.lifecycleRegistered) {
this.lifecycleRegistered = true;
cc.game.on(cc.game.EVENT_SHOW, this.onForeground, this);
cc.game.on(cc.game.EVENT_HIDE, this.onBackground, this);
}
ActivityBundleLoader.beginCycle();
return this.sync(true);
}
/** The first Home consumes login's request/result; subsequent visits refresh unlock conditions. */
static enterHome(): Promise<boolean> {
this.ensure();
if (this.firstHomeAfterLogin) { this.firstHomeAfterLogin = false; return this.sync(false); }
ActivityBundleLoader.beginCycle();
return this.sync(true);
}
static onBackground = (): void => { this.foregroundTask = null; };
/** Also called by Home; both listeners share one request and resource check cycle. */
static onForeground = (): Promise<boolean> => {
this.ensure();
if (!this.foregroundTask) {
ActivityBundleLoader.beginCycle();
this.foregroundTask = this.sync(true);
}
return this.foregroundTask;
};
/** 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 (_) {}
}
/** Same daily policy as GoldMiner: record only an actually displayed popup. */
static pyramidReminder(): PyramidDailyReminder {
const period = this.active("pyramid");
if (!this.playerId || !this.token || !period || Number(cc.fx.GameConfig.GM_INFO.level) < period.unlockLevel) return null;
const key = "pyramidDailyReminder:" + this.environment + ":" + this.playerId;
const day = Math.floor((this.now() + 8 * 3600000) / 86400000);
try { if (cc.fx.StorageMessage.getStorage(key) === day) return null; } catch (_) {}
return { key, day, periodId: period.periodId };
}
static markPyramidReminderShown(reminder: PyramidDailyReminder): void {
const current = this.pyramidReminder();
if (!current || current.key !== reminder.key || current.day !== reminder.day || current.periodId !== reminder.periodId) 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(); }
/** HomeScene owns opening. Periodic polling and level reports only read activity state. */
static async checkCloudRiseOpening(isHomeActive: () => boolean, refresh = true): Promise<boolean> {
this.ensure();
const generation = this.generation;
if (!await this.sync(refresh)) 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;
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.hasSchedule()) return true;
const generation = this.generation;
// Keep the current schedule while refreshing; only a valid response replaces it.
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;
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;
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;
// Recheck time boundaries without invalidating an in-flight request's schedule.
this.notify();
try { return await task; }
finally { if (this.task === task) this.task = null; }
}
}