222 lines
12 KiB
TypeScript
222 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 => { ActivityScheduleService.foregroundTask = null; };
|
|
/** Also called by Home; both listeners share one request and resource check cycle. */
|
|
static onForeground = (): Promise<boolean> => {
|
|
// Creator's ES5 compiler captures module-level this for static arrow fields.
|
|
ActivityScheduleService.ensure();
|
|
if (!ActivityScheduleService.foregroundTask) {
|
|
ActivityBundleLoader.beginCycle();
|
|
ActivityScheduleService.foregroundTask = ActivityScheduleService.sync(true);
|
|
}
|
|
return ActivityScheduleService.foregroundTask;
|
|
};
|
|
/** Candidate identity and server date only; HomePopupQueue owns presentation frequency. */
|
|
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);
|
|
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 (_) {}
|
|
}
|
|
/** Ordinary Pyramid candidate for each login; the queue owns frequency. */
|
|
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);
|
|
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; }
|
|
}
|
|
}
|