export type DailyTaskEvent = { metric: string; amount?: number; cellCount?: number; width?: number; height?: number; colorId?: number; propType?: string; }; /** 新版日任务统一进度入口。任务仍按现有架构由客户端累计并批量上传。 */ export default class DailyTaskProgressTracker { private static saveTimer: any = null; private static onlineStartedAt = 0; private static onlineTimer: any = null; private static pendingOnlineSeconds = 0; private static unsavedOnlineRecovery: { dayKey: string, values: { [taskId: string]: number } } = null; private static firstAttemptValid = true; private static usedPropInLevel = false; private static pendingClearEvents: { [key: string]: DailyTaskEvent } = {}; private static debugPaused = false; private static waitingForPeriod = false; private static pendingPeriodEvents: DailyTaskEvent[] = []; static setDebugPaused(paused: boolean): void { this.debugPaused = paused; if (this.onlineStartedAt) this.onlineStartedAt = Date.now(); if (paused) this.pendingClearEvents = {}; } static changePeriod(discardPending = false): void { this.unsavedOnlineRecovery = null; if (discardPending) { this.pendingOnlineSeconds = 0; this.pendingPeriodEvents = []; this.waitingForPeriod = false; } // 等待新周期时已清理过旧消除事件,保留之后新产生的事件。 if (!this.waitingForPeriod) this.pendingClearEvents = {}; } static confirmSaved(data: any): void { const recovery = this.unsavedOnlineRecovery; if (!recovery || data.dayKey !== recovery.dayKey) return; if (Object.keys(recovery.values).every(id => { const task = data.dailyTasks.find(task => task.taskId === id); return task && (task.state === 2 || Number(task.value) >= recovery.values[id]); })) this.unsavedOnlineRecovery = null; } static beginLevel(): void { this.firstAttemptValid = true; this.usedPropInLevel = false; this.pendingClearEvents = {}; } static invalidateAttempt(): void { this.firstAttemptValid = false; } static recordPropUsed(propType: string): void { this.usedPropInLevel = true; this.record({ metric: "use_prop" }); this.record({ metric: "use_specific_prop", propType }); } static recordShare(): void { this.record({ metric: "share" }); this.flushNow(); } static recordLevelWon(): void { this.commitClearProgress(); this.record({ metric: "pass_levels" }); this.record({ metric: "win_streak" }); if (this.firstAttemptValid) this.record({ metric: "first_try_pass" }); if (!this.usedPropInLevel) this.record({ metric: "no_prop_pass" }); } /** 任务结构版本的统一入口;升级协议时集中调整此处的兼容规则。 */ static isValidTaskData(data: any): boolean { return !!data && data.version === 2 && Array.isArray(data.dailyTasks); } static setTaskData(data: any): void { if (!this.isValidTaskData(data)) return; let changed = false; const recovery = this.unsavedOnlineRecovery; if (recovery && recovery.dayKey === data.dayKey) { // 补计保存成功前,后续任务读取的旧响应不能撤销本次补计。 data.dailyTasks.forEach((task: any) => { const value = Math.min(Number(task.target) || 0, recovery.values[task.taskId] || 0); if (task.metric !== "online_seconds" || task.state === 2 || value <= (Number(task.value) || 0)) return; task.value = value; if (value >= task.target) task.state = 1; changed = true; }); } else if (recovery) { this.unsavedOnlineRecovery = null; } cc.fx.GameConfig.GM_INFO.tasks = data; if (this.isExpired()) return; if (this.waitingForPeriod) { this.waitingForPeriod = false; const events = this.pendingPeriodEvents; this.pendingPeriodEvents = []; events.forEach(event => { if (this.applyEvent(data, event)) changed = true; }); } // 只补计加载前已经结算的整秒;先清空,重复接收同一份任务数据也不会重复补计。 const seconds = this.pendingOnlineSeconds; this.pendingOnlineSeconds = 0; if (seconds > 0 && this.applyEvent(data, { metric: "online_seconds", amount: seconds })) { const values = {}; data.dailyTasks.forEach((task: any) => { if (task.metric === "online_seconds" && task.state !== 2) values[task.taskId] = task.value; }); this.unsavedOnlineRecovery = { dayKey: data.dayKey, values }; changed = true; } if (changed) this.scheduleSave(); } static getTaskData(): any { const tasks: any = cc.fx.GameConfig.GM_INFO.tasks; return this.isValidTaskData(tasks) ? tasks : null; } static record(event: DailyTaskEvent): void { if (this.debugPaused) return; if (cc.fx.GameConfig.GM_INFO.otherLevel != 0 && this.isLevelMetric(event.metric)) return; if (this.isExpired()) { this.waitForPeriod(); if (this.isClearMetric(event.metric)) this.queueClearProgress(event); else this.pendingPeriodEvents.push({ ...event }); cc.fx.GameTool.getDailyQuestsInfo(() => {}); return; } const data = this.getTaskData(); if (!data) { if (event.metric === "online_seconds") { this.pendingOnlineSeconds += Math.max(0, Number(event.amount == null ? 1 : event.amount) || 0); } return; } if (this.isClearMetric(event.metric)) { if (data.dailyTasks.some((task: any) => task.state !== 2 && this.matches(task, event))) { this.queueClearProgress(event); } return; } if (this.applyEvent(data, event)) this.scheduleSave(); } private static applyEvent(data: any, event: DailyTaskEvent): boolean { let changed = false; data.dailyTasks.forEach((task: any) => { if (task.state === 2 || !this.matches(task, event)) return; const amount = Math.max(0, Number(event.amount == null ? 1 : event.amount) || 0); const next = Math.min(Number(task.target) || 0, (Number(task.value) || 0) + amount); if (next === task.value) return; task.value = next; if (task.value >= task.target) { task.state = 1; this.trackTask(task, "complete"); } changed = true; }); return changed; } static trackTask(task: any, status: "complete" | "claim"): void { if (cc.fx.GameTool && cc.fx.GameTool.shushu_Track) { cc.fx.GameTool.shushu_Track("daily_task", { id: task.trackingId || task.taskId, status, ...(task.metric === "clear_color" ? { colorId: task.params.colorId } : {}), }); } } static resetWinStreak(): void { if (this.debugPaused) return; this.pendingClearEvents = {}; const data = this.getTaskData(); if (!data) return; let changed = false; data.dailyTasks.forEach((task: any) => { if (task.metric === "win_streak" && task.state !== 2 && task.value !== 0) { task.value = 0; task.state = 0; if (cc.fx.GameTool.markDailyTaskReset) cc.fx.GameTool.markDailyTaskReset(task.taskId); changed = true; } }); if (changed) this.scheduleSave(); } private static queueClearProgress(event: DailyTaskEvent): void { const key = `${event.metric}:${event.cellCount || ""}:${event.width || ""}:${event.height || ""}:${event.colorId || ""}`; const amount = Math.max(0, Number(event.amount == null ? 1 : event.amount) || 0); const pending = this.pendingClearEvents[key]; if (pending) pending.amount = (Number(pending.amount) || 0) + amount; else this.pendingClearEvents[key] = { ...event, amount }; } private static commitClearProgress(): void { if (this.debugPaused) return; if (this.isExpired()) { this.waitForPeriod(); this.pendingPeriodEvents.push(...Object.keys(this.pendingClearEvents).map(key => this.pendingClearEvents[key])); this.pendingClearEvents = {}; cc.fx.GameTool.getDailyQuestsInfo(() => {}); return; } const data = this.getTaskData(); const events = Object.keys(this.pendingClearEvents).map(key => this.pendingClearEvents[key]); this.pendingClearEvents = {}; if (!data) return; let changed = false; events.forEach(event => { if (this.applyEvent(data, event)) changed = true; }); if (changed) this.scheduleSave(); } static startOnline(): void { if (this.onlineStartedAt) return; this.onlineStartedAt = Date.now(); if (!this.onlineTimer) { this.onlineTimer = setInterval(() => this.flushOnline(), 30000); } } static stopOnline(): void { this.flushOnline(); if (this.onlineTimer) clearInterval(this.onlineTimer); this.onlineTimer = null; this.onlineStartedAt = 0; } static flushNow(save = true): void { if (this.debugPaused) return; this.flushOnline(); if (this.saveTimer) clearTimeout(this.saveTimer); this.saveTimer = null; if (save) this.save(); } private static flushOnline(): void { if (!this.onlineStartedAt) return; const now = Date.now(); let seconds = Math.floor((now - this.onlineStartedAt) / 1000); if (seconds <= 0) return; this.onlineStartedAt += seconds * 1000; if (this.isExpired()) { // 旧日期已经结束的时间不能补进新日期;只缓冲零点之后的部分。 const data = this.getTaskData(); const serverNow = cc.fx.GameTool.getDailyTaskServerTime(); const elapsedAfterReset = Math.max(0, Math.floor((serverNow - Number(data.dayEndsAt)) / 1000)); seconds = Math.min(seconds, elapsedAfterReset); } if (seconds <= 0) return; this.record({ metric: "online_seconds", amount: seconds }); } private static matches(task: any, event: DailyTaskEvent): boolean { if (task.metric !== event.metric) return false; const params = task.params || {}; if (event.metric === "clear_size") { if (params.cellCount != null) return Number(event.cellCount) === Number(params.cellCount); const same = Number(event.width) === Number(params.width) && Number(event.height) === Number(params.height); const rotated = Number(event.width) === Number(params.height) && Number(event.height) === Number(params.width); return same || rotated; } if (event.metric === "clear_color") return Number(event.colorId) === Number(params.colorId); if (event.metric === "use_specific_prop") return event.propType === params.propType; return true; } private static isLevelMetric(metric: string): boolean { return ["clear_blocks", "clear_size", "clear_color", "pass_levels", "no_prop_pass", "first_try_pass", "win_streak"].indexOf(metric) >= 0; } private static isClearMetric(metric: string): boolean { return ["clear_blocks", "clear_size", "clear_color"].indexOf(metric) >= 0; } private static scheduleSave(): void { if (cc.fx.GameTool && cc.fx.GameTool.markDailyTasksDirty) cc.fx.GameTool.markDailyTasksDirty(); if (this.saveTimer) return; this.saveTimer = setTimeout(() => { this.saveTimer = null; this.save(); }, 1000); } private static save(): void { if (this.debugPaused) return; if (!this.getTaskData() || !cc.fx.GameTool || !cc.fx.GameTool.setDailyQuestInfo) return; const recovery = this.unsavedOnlineRecovery; cc.fx.GameTool.setDailyQuestInfo((response: any) => { if (response && response.code === 1) { if (this.unsavedOnlineRecovery === recovery) { this.unsavedOnlineRecovery = null; } else if (this.unsavedOnlineRecovery) { // 同步期间又出现补计时,保留这批尚未确认的时间。 const data = this.getTaskData(); if (data && data.dayKey === this.unsavedOnlineRecovery.dayKey) this.setTaskData(data); } } }); } private static isExpired(): boolean { return !!(cc.fx.GameTool && cc.fx.GameTool.isDailyTaskCacheExpired && cc.fx.GameTool.isDailyTaskCacheExpired()); } private static waitForPeriod(): void { if (this.waitingForPeriod) return; this.waitingForPeriod = true; this.pendingClearEvents = {}; } }