Merge branch 'main' of https://git.sparkus.cn/yangzhao/MatchMaster
This commit is contained in:
commit
9bf06c7630
|
|
@ -1,13 +0,0 @@
|
|||
{
|
||||
"ver": "1.1.3",
|
||||
"uuid": "d682dfb5-ffb3-4397-98b7-bc758e1a1fd0",
|
||||
"importer": "folder",
|
||||
"isBundle": false,
|
||||
"bundleName": "",
|
||||
"priority": 1,
|
||||
"compressionType": {},
|
||||
"optimizeHotUpdate": {},
|
||||
"inlineSpriteFrames": {},
|
||||
"isRemoteBundle": {},
|
||||
"subMetas": {}
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ import Art from './BattlePassArt';
|
|||
import NumberToImage from './NumberToImage';
|
||||
import { settlementRewards, settlementSteps } from './module/Config/BattlePassSettlementModel';
|
||||
import { MiniGameSdk } from './Sdk/MiniGameSdk';
|
||||
import BattlePassSettlementCheck from './module/Config/BattlePassSettlementCheck';
|
||||
|
||||
const { ccclass } = cc._decorator;
|
||||
@ccclass
|
||||
|
|
@ -20,6 +21,10 @@ export default class BattlePassSettlement extends cc.Component {
|
|||
private finished: () => void;
|
||||
private iosOrder = '';
|
||||
private recoveredRewards: any[] = [];
|
||||
private check: BattlePassSettlementCheck;
|
||||
private readVersion = 0;
|
||||
private checkedThrough = 0;
|
||||
private retryDelay = 5;
|
||||
private back = (e: cc.Event.EventKeyboard) => {
|
||||
if (e.keyCode === cc.macro.KEY.escape || e.keyCode === 6) this.close();
|
||||
};
|
||||
|
|
@ -31,29 +36,48 @@ export default class BattlePassSettlement extends cc.Component {
|
|||
// Resolve paid entitlements before selecting frozen rewards. Other login
|
||||
// orders remain in the home queue until this entire sequence is closed.
|
||||
const gm = cc.fx.GameConfig.GM_INFO;
|
||||
this.check = BattlePassSettlementCheck.forPlayer(gm);
|
||||
// Usually observed by the existing homepage read. Also cover failed
|
||||
// or locked entry reads so the login cache never lacks a deadline.
|
||||
if (!this.check.hasSchedule()) {
|
||||
const period: any = await new Promise((resolve, reject) => Utils.POSTJSON('passCheck', {
|
||||
uid: gm.uid || Utils.uid, action: 'read'
|
||||
}, res => res.code === 1 ? resolve(res) : reject(res)));
|
||||
this.check.observeStart(Number(period.data.time));
|
||||
if (!this.check.hasSchedule()) throw { msg: '战令周期时间不可用' };
|
||||
}
|
||||
if (!this.isCurrentRun()) return;
|
||||
for (const order of (gm.allOutTradeNo || []).slice()) {
|
||||
if (!/^battlepass(?:_30|_12)?$/.test(order.itemid)) continue;
|
||||
if (this.check.reviewOrders.has(order.outTradeNo)) continue;
|
||||
try {
|
||||
const paid = await Utils.completePassOrder(order.outTradeNo);
|
||||
if (!this.isCurrentRun()) return;
|
||||
if (!paid.data.alreadyGranted) {
|
||||
if (paid.data.postSeason) this.recoveredRewards.push(...(paid.data.rewards || []));
|
||||
else if (paid.data.bonus) this.recoveredRewards.push({ type: 'coin', count: 3000 }, { type: 'pass_key', count: 20 });
|
||||
}
|
||||
gm.allOutTradeNo = gm.allOutTradeNo.filter(o => o.outTradeNo !== order.outTradeNo);
|
||||
} catch (e) {
|
||||
// if (e.code !== 410) throw e;
|
||||
if (e.code !== 410) throw e;
|
||||
// Paid duplicates/obsolete orders remain available for manual review.
|
||||
this.check.reviewOrders.add(order.outTradeNo);
|
||||
cc.warn('战令订单需要人工处理', order.outTradeNo, e.msg);
|
||||
}
|
||||
}
|
||||
this.readVersion = this.check.version;
|
||||
const readStarted = Date.now();
|
||||
const result = await Utils.settlementRequest('read');
|
||||
if (!cc.isValid(this.node)) return;
|
||||
if (!this.isCurrentRun()) return;
|
||||
this.serverTime = result.data.serverTime;
|
||||
this.check.syncClock(this.serverTime);
|
||||
// A request spanning the cutoff may contain a pre-cutoff snapshot.
|
||||
this.checkedThrough = this.serverTime - Math.max(0, Date.now() - readStarted);
|
||||
this.latest = Math.max(0, ...result.data.rows.map(r => r.end));
|
||||
this.rows = settlementSteps(result.data.rows, this.latest, this.serverTime);
|
||||
if (!this.rows.length && !this.recoveredRewards.length) { done(); this.destroy(); return; }
|
||||
if (!this.rows.length && !this.recoveredRewards.length) { this.finish(); return; }
|
||||
await Art.load();
|
||||
if (!cc.isValid(this.node)) return;
|
||||
if (!this.isCurrentRun()) return;
|
||||
this.root = cc.instantiate(Art.settlementPrefab); this.root.active = false; this.root.parent = this.node; this.root.zIndex = 1000;
|
||||
this.panel = this.root.getChildByName('panel');
|
||||
const fit = Math.min(this.node.width / this.root.width, this.node.height / this.root.height);
|
||||
|
|
@ -68,15 +92,30 @@ export default class BattlePassSettlement extends cc.Component {
|
|||
if (typeof wx !== 'undefined') wx.onShow(this.resume);
|
||||
this.next();
|
||||
} catch (e) {
|
||||
if (!this.isCurrentRun()) return;
|
||||
this.error(e);
|
||||
if (this.root && cc.isValid(this.root)) this.root.destroy();
|
||||
cc.systemEvent.off(cc.SystemEvent.EventType.KEY_DOWN, this.back, this);
|
||||
// @ts-ignore
|
||||
if (typeof wx !== 'undefined') wx.offShow(this.resume);
|
||||
// Keep the queue reserved until the settlement check can complete.
|
||||
this.scheduleOnce(() => this.run(done), 5);
|
||||
this.scheduleOnce(() => this.run(done), this.retryDelay);
|
||||
this.retryDelay = Math.min(this.retryDelay * 2, 60);
|
||||
}
|
||||
}
|
||||
private isCurrentRun(): boolean {
|
||||
return cc.isValid(this, true) && cc.isValid(this.node, true) &&
|
||||
this.check === BattlePassSettlementCheck.forPlayer(cc.fx.GameConfig.GM_INFO);
|
||||
}
|
||||
private finish() {
|
||||
// Cache only after every popup and reward animation has completed. A
|
||||
// destroyed scene or failed request must leave the login check pending.
|
||||
if (!this.isCurrentRun()) return;
|
||||
this.check.complete(this.checkedThrough, this.readVersion);
|
||||
if (this.root && cc.isValid(this.root)) this.root.destroy();
|
||||
this.destroy();
|
||||
this.finished();
|
||||
}
|
||||
private error(e: any) {
|
||||
cc.log(e.msg || e.message || '网络异常,请重试');
|
||||
// MiniGameSdk.API.showToast(e.msg || e.message || '网络异常,请重试');
|
||||
|
|
@ -89,7 +128,7 @@ export default class BattlePassSettlement extends cc.Component {
|
|||
this.playRewards(rewards, () => this.next());
|
||||
return;
|
||||
}
|
||||
this.root.destroy(); this.finished(); this.destroy(); return;
|
||||
this.finish(); return;
|
||||
}
|
||||
if (!this.row.claimed && !this.row.emptySeen) {
|
||||
const rewards = settlementRewards(this.row, cc.fx.GameConfig.PASS_CHECK);
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
// - https://docs.cocos.com/creator/manual/en/scripting/life-cycle-callbacks.html
|
||||
|
||||
import Utils from "./module/Pay/Utils";
|
||||
import BattlePassSettlementCheck from './module/Config/BattlePassSettlementCheck';
|
||||
import CloudRiseService from "./module/Config/CloudRiseService";
|
||||
import ActivityScheduleService from "./module/Config/ActivityScheduleService";
|
||||
import GoldMinerService from './coin_madness/GoldMinerService';
|
||||
|
|
@ -339,6 +340,7 @@ export default class GameManager extends cc.Component {
|
|||
if (data.data.token) {
|
||||
cc.fx.GameConfig.GM_INFO.token = data.data.token;
|
||||
}
|
||||
BattlePassSettlementCheck.resetLogin();
|
||||
const wucaiType = Number(data.data.wucai_type);
|
||||
cc.fx.GameConfig.GM_INFO.wucai_type = Number.isInteger(wucaiType) ? wucaiType : -1;
|
||||
cc.fx.GameConfig.GM_INFO.wucai_preview = data.data.wucai_preview || null;
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import MapConroler from "./Map";
|
|||
import { DRAWABLE_CAT_IDS, getOwnedDrawableCats, getCatDrawCost } from "./module/Tool/CatDrawRules";
|
||||
import HomePopupQueue from "../home_popup_queue/HomePopupQueue";
|
||||
import BattlePassSettlement from './BattlePassSettlement';
|
||||
import BattlePassSettlementCheck from './module/Config/BattlePassSettlementCheck';
|
||||
import CloudRiseView from "./CloudRiseView";
|
||||
import HomeActivitySchedule from "./HomeActivitySchedule";
|
||||
import CoinMadnessHost from './coin_madness/CoinMadnessHost';
|
||||
|
|
@ -871,6 +872,7 @@ export default class JiaZai extends cc.Component {
|
|||
|
||||
onDestroy() {
|
||||
cc.game.off(cc.game.EVENT_SHOW, this.checkBattlePassSettlement, this);
|
||||
this.unschedule(this.checkBattlePassSettlement);
|
||||
MiniProgramBenefitsBridge.instance.detach(this.node);
|
||||
this.homeAutomaticTasks = [];
|
||||
this.homePopupOpenRequest++;
|
||||
|
|
@ -986,8 +988,9 @@ export default class JiaZai extends cc.Component {
|
|||
// 9. 检查月卡过期状态
|
||||
// ============================================
|
||||
start() {
|
||||
// 与黄金矿工一致:每次进入首页及游戏回到前台时重新检查。
|
||||
// 沿用首页/回前台入口;每秒只检查本地截止时间,不轮询接口。
|
||||
this.checkBattlePassSettlement();
|
||||
this.schedule(this.checkBattlePassSettlement, 1);
|
||||
cc.game.on(cc.game.EVENT_SHOW, this.checkBattlePassSettlement, this);
|
||||
if (!this.node.getComponent(HomeActivitySchedule)) this.node.addComponent(HomeActivitySchedule);
|
||||
CloudRiseView.mount(this.node, () => this.startGame());
|
||||
|
|
@ -4806,8 +4809,12 @@ export default class JiaZai extends cc.Component {
|
|||
/** 与黄金矿工使用相同显示时机,仍优先处理旧赛季结算。 */
|
||||
private checkBattlePassSettlement(): void {
|
||||
if (!this.isHomeRuntimeAlive() || !this.node.activeInHierarchy || cc.fx.GameConfig.isGameplayEntryPreparing()) return;
|
||||
const gm = cc.fx.GameConfig.GM_INFO;
|
||||
if (!BattlePassSettlementCheck.forPlayer(gm).needsCheck(gm.allOutTradeNo || [])) return;
|
||||
HomePopupQueue.get(this.node).enqueue('battlePassSettlement', -100, done => {
|
||||
if (!this.isHomeRuntimeAlive() || !this.node.activeInHierarchy || cc.fx.GameConfig.isGameplayEntryPreparing()) { done(); return; }
|
||||
const info = cc.fx.GameConfig.GM_INFO;
|
||||
if (!BattlePassSettlementCheck.forPlayer(info).needsCheck(info.allOutTradeNo || [])) { done(); return; }
|
||||
const settlement = this.node.getComponent(BattlePassSettlement);
|
||||
(cc.isValid(settlement, true) ? settlement : this.node.addComponent(BattlePassSettlement)).run(() => {
|
||||
if (this.isHomeRuntimeAlive() && this.node.activeInHierarchy && !cc.fx.GameConfig.isGameplayEntryPreparing()) {
|
||||
|
|
|
|||
54
assets/Script/module/Config/BattlePassSettlementCheck.ts
Normal file
54
assets/Script/module/Config/BattlePassSettlementCheck.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { PASS_PERIOD } from './BattlePassModel';
|
||||
|
||||
/** Memory-only login cache; scene destruction must not reset a completed check. */
|
||||
export default class BattlePassSettlementCheck {
|
||||
private static player: any;
|
||||
private static identity = '';
|
||||
private static current: BattlePassSettlementCheck;
|
||||
|
||||
static forPlayer(player: any): BattlePassSettlementCheck {
|
||||
const identity = String(player.uid || '') + ':' + String(player.token || '');
|
||||
if (this.player !== player || this.identity !== identity || !this.current) {
|
||||
this.player = player;
|
||||
this.identity = identity;
|
||||
this.current = new BattlePassSettlementCheck();
|
||||
}
|
||||
return this.current;
|
||||
}
|
||||
|
||||
static resetLogin() { this.current = null; }
|
||||
|
||||
private periodEnd = 0;
|
||||
private checkedAt = 0;
|
||||
private checkedVersion = -1;
|
||||
private offset = 0;
|
||||
version = 0;
|
||||
readonly reviewOrders = new Set<string>();
|
||||
|
||||
hasSchedule(): boolean { return this.periodEnd > 0; }
|
||||
|
||||
observeStart(start: number) {
|
||||
if (Number.isFinite(start) && start > 0) this.periodEnd = start + PASS_PERIOD;
|
||||
}
|
||||
|
||||
syncClock(serverTime: number) {
|
||||
if (Number.isFinite(serverTime) && serverTime > 0) this.offset = serverTime - Date.now();
|
||||
}
|
||||
|
||||
invalidate() { this.version++; }
|
||||
|
||||
needsCheck(orders: any[] = []): boolean {
|
||||
if (orders.some(order => /^battlepass(?:_30|_12)?$/.test(order.itemid) && !this.reviewOrders.has(order.outTradeNo))) return true;
|
||||
if (!this.checkedAt || this.checkedVersion !== this.version) return true;
|
||||
if (!this.periodEnd) return false;
|
||||
// Derive the first boundary after the last read, even if another API has
|
||||
// already advanced the global season while we were in GameScene.
|
||||
const nextEnd = this.periodEnd + (Math.floor((this.checkedAt - this.periodEnd) / PASS_PERIOD) + 1) * PASS_PERIOD;
|
||||
return Date.now() + this.offset >= nextEnd;
|
||||
}
|
||||
|
||||
complete(serverTime: number, version: number) {
|
||||
this.checkedAt = serverTime;
|
||||
this.checkedVersion = version;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"ver": "1.1.0",
|
||||
"uuid": "d98a4d7a-9c08-45b2-b8c8-e44b23896e55",
|
||||
"importer": "typescript",
|
||||
"isPlugin": false,
|
||||
"loadPluginInWeb": true,
|
||||
"loadPluginInNative": true,
|
||||
"loadPluginInEditor": false,
|
||||
"subMetas": {}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { MiniGameSdk } from "../../Sdk/MiniGameSdk";
|
||||
import { normalizePass, mergePassInfo, legacyPassInfo, legacyExperience, passTier, PASS_PERIOD } from '../Config/BattlePassModel';
|
||||
import { settlementRewards, settlementClaimFlags } from '../Config/BattlePassSettlementModel';
|
||||
import BattlePassSettlementCheck from '../Config/BattlePassSettlementCheck';
|
||||
//#region 测试
|
||||
/** 测试 */
|
||||
export default class Utils {
|
||||
|
|
@ -1514,8 +1515,11 @@ export default class Utils {
|
|||
if (callBack) callBack({ code: 0, msg: '战令配置尚未加载,请稍后重试' });
|
||||
return;
|
||||
}
|
||||
const settlementCheck = BattlePassSettlementCheck.forPlayer(cc.fx.GameConfig.GM_INFO);
|
||||
Utils.POST("passCheck", { uid: cc.fx.GameConfig.GM_INFO.uid || Utils.uid, action: 'read' }, res => {
|
||||
if (res.code !== 1) { if (callBack) callBack(res); return; }
|
||||
// time is the idcount period origin, not the user's archive end or server now.
|
||||
settlementCheck.observeStart(Number(res.data.time));
|
||||
Utils.passRequest('read').then(extra => {
|
||||
const raw = typeof extra.data.legacy === 'string' ? JSON.parse(extra.data.legacy) : extra.data.legacy;
|
||||
let pass = mergePassInfo(raw, extra.data.extra, cc.fx.GameConfig.PASS_CHECK);
|
||||
|
|
@ -1604,6 +1608,11 @@ export default class Utils {
|
|||
});
|
||||
}
|
||||
static async completePassOrder(outTradeNo: string): Promise<any> {
|
||||
const check = BattlePassSettlementCheck.forPlayer(cc.fx.GameConfig.GM_INFO);
|
||||
try { return await Utils.deliverPassOrder(outTradeNo); }
|
||||
finally { check.invalidate(); }
|
||||
}
|
||||
private static async deliverPassOrder(outTradeNo: string): Promise<any> {
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
const order = await Utils.passRequest('order', { outTradeNo });
|
||||
if (order.data.postSeason) {
|
||||
|
|
|
|||
|
|
@ -1,13 +0,0 @@
|
|||
{
|
||||
"ver": "1.1.3",
|
||||
"uuid": "fa6df961-bba5-49c6-b2a1-6228f3ca160e",
|
||||
"importer": "folder",
|
||||
"isBundle": false,
|
||||
"bundleName": "",
|
||||
"priority": 1,
|
||||
"compressionType": {},
|
||||
"optimizeHotUpdate": {},
|
||||
"inlineSpriteFrames": {},
|
||||
"isRemoteBundle": {},
|
||||
"subMetas": {}
|
||||
}
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
{
|
||||
"ver": "1.1.3",
|
||||
"uuid": "3863fc2a-22f0-4ff5-bca0-ab32cd195ca1",
|
||||
"importer": "folder",
|
||||
"isBundle": false,
|
||||
"bundleName": "",
|
||||
"priority": 1,
|
||||
"compressionType": {},
|
||||
"optimizeHotUpdate": {},
|
||||
"inlineSpriteFrames": {},
|
||||
"isRemoteBundle": {},
|
||||
"subMetas": {}
|
||||
}
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
{
|
||||
"ver": "1.1.3",
|
||||
"uuid": "c5c018c1-b95c-4bf8-8bcc-2f017b07719d",
|
||||
"importer": "folder",
|
||||
"isBundle": false,
|
||||
"bundleName": "",
|
||||
"priority": 1,
|
||||
"compressionType": {},
|
||||
"optimizeHotUpdate": {},
|
||||
"inlineSpriteFrames": {},
|
||||
"isRemoteBundle": {},
|
||||
"subMetas": {}
|
||||
}
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
{
|
||||
"ver": "1.1.3",
|
||||
"uuid": "162b023e-0bbd-4aca-a325-55598baf604d",
|
||||
"importer": "folder",
|
||||
"isBundle": false,
|
||||
"bundleName": "",
|
||||
"priority": 1,
|
||||
"compressionType": {},
|
||||
"optimizeHotUpdate": {},
|
||||
"inlineSpriteFrames": {},
|
||||
"isRemoteBundle": {},
|
||||
"subMetas": {}
|
||||
}
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
{
|
||||
"ver": "1.1.3",
|
||||
"uuid": "63fea74b-4a7d-432d-922a-1577658321ab",
|
||||
"importer": "folder",
|
||||
"isBundle": false,
|
||||
"bundleName": "",
|
||||
"priority": 1,
|
||||
"compressionType": {},
|
||||
"optimizeHotUpdate": {},
|
||||
"inlineSpriteFrames": {},
|
||||
"isRemoteBundle": {},
|
||||
"subMetas": {}
|
||||
}
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
{
|
||||
"ver": "1.1.3",
|
||||
"uuid": "ca7daeb9-1930-47d6-989f-6dbdc501cb3a",
|
||||
"importer": "folder",
|
||||
"isBundle": false,
|
||||
"bundleName": "",
|
||||
"priority": 1,
|
||||
"compressionType": {},
|
||||
"optimizeHotUpdate": {},
|
||||
"inlineSpriteFrames": {},
|
||||
"isRemoteBundle": {},
|
||||
"subMetas": {}
|
||||
}
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
{
|
||||
"ver": "1.1.3",
|
||||
"uuid": "1381ff15-3157-44af-a14e-daddf0b2eaf3",
|
||||
"importer": "folder",
|
||||
"isBundle": false,
|
||||
"bundleName": "",
|
||||
"priority": 1,
|
||||
"compressionType": {},
|
||||
"optimizeHotUpdate": {},
|
||||
"inlineSpriteFrames": {},
|
||||
"isRemoteBundle": {},
|
||||
"subMetas": {}
|
||||
}
|
||||
|
|
@ -25,7 +25,15 @@ BattlePassSettlement:旧赛季结算队列、图1/2/3/4交互和上期18元购
|
|||
BattlePassArt:图片文案、万圣节数字/南瓜/时钟、图6购买成功展示。
|
||||
BattlePassSettlementModel:使用当前最新 PASS_CHECK 表计算奖励并合并同类项。
|
||||
HomePopupQueue:结算优先级-100,完整结算/奖励动画结束后才继续原有弹窗。
|
||||
触发时机与黄金矿工一致:首页 start 检查一次,cc.game.EVENT_SHOW 回前台重新检查,销毁首页时移除监听;不新增 onShow,不再由战令等级刷新重复触发。检查仍使用服务端结算状态,同一期已处理的弹窗不重复展示。
|
||||
触发入口沿用黄金矿工:首页 start、cc.game.EVENT_SHOW;不新增 onShow。
|
||||
每次登录首次进入首页读取结算;成功完成整条弹窗/奖励动画流程后缓存结果。
|
||||
缓存仅保留在本次登录内,跨 HomeScene/GameScene 保留,账号/token变化或重新登录重置。
|
||||
首页每秒仅检查本地时间和缓存,不轮询接口;周期依据 passCheck/read.time(idcount 周期起点)+30天,使用结算返回的 serverTime 校准本地时间。
|
||||
到期时请求一次;在战斗/后台跨过到期点则回首页/前台补查。其他接口先推进到新周期也不会漏掉旧周期检查。
|
||||
缺少周期信息时补读一次 passCheck/read,读取失败不写成功缓存。
|
||||
待恢复战令订单、支付完成/失败使缓存失效;网络失败按5/10/20/40/60秒重试,最多间隔60秒,成功后停止。
|
||||
410人工处理订单在本次登录内不反复重试,保留订单记录;重新登录可再次检查。
|
||||
销毁首页移除监听和本地计时;未完成弹窗流程不缓存,结算优先级仍为-100。
|
||||
登录支付恢复先补齐战令权益再读取快照,其他订单和奖励弹窗延后。
|
||||
|
||||
规则
|
||||
|
|
@ -46,6 +54,7 @@ tools/import-battle-pass-art.cjs 可重建新增资源/meta及预制体图片引
|
|||
主界面背景有hash保护,避免覆盖后续美术调整。
|
||||
|
||||
测试命令(客户端目录)
|
||||
node --test tools/test-battle-pass-settlement-check.cjs
|
||||
node --test tools/test-battle-pass-settlement.cjs tools/test-battle-pass.cjs
|
||||
node --test tools/test-battle-pass-ui.cjs
|
||||
新增模块使用 creator.d.ts 的定向TypeScript检查通过。
|
||||
|
|
|
|||
139
tools/test-battle-pass-settlement-check.cjs
Normal file
139
tools/test-battle-pass-settlement-check.cjs
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
const test = require('node:test'), assert = require('node:assert/strict');
|
||||
const fs = require('node:fs'), vm = require('node:vm'), ts = require('typescript');
|
||||
function load(file, deps = {}, globals = {}) {
|
||||
const exports = {};
|
||||
vm.runInNewContext(ts.transpileModule(fs.readFileSync(file, 'utf8'), {
|
||||
compilerOptions: { target: 99, module: 1, experimentalDecorators: true }
|
||||
}).outputText, { exports, require: name => deps[name], Date, console, ...globals });
|
||||
return exports;
|
||||
}
|
||||
const model = load('assets/Script/module/Config/BattlePassModel.ts');
|
||||
function fixture() {
|
||||
let now = 1800000000000;
|
||||
class Clock extends Date { static now() { return now; } }
|
||||
const Check = load('assets/Script/module/Config/BattlePassSettlementCheck.ts', { './BattlePassModel': model }, { Date: Clock }).default;
|
||||
const gm = { uid: 'u', token: 't', allOutTradeNo: [] };
|
||||
const cache = Check.forPlayer(gm), end = now + 60000;
|
||||
cache.observeStart(end - model.PASS_PERIOD);
|
||||
return { Check, cache, gm, end, Clock, now: () => now, advance: value => now += value };
|
||||
}
|
||||
test('login catch-up is mandatory even with 30 days remaining; same-period result is cached', () => {
|
||||
const f = fixture();
|
||||
assert.equal(f.cache.needsCheck(), true);
|
||||
f.cache.complete(f.now(), f.cache.version);
|
||||
for (let i = 0; i < 100; i++) assert.equal(f.Check.forPlayer(f.gm).needsCheck(), false);
|
||||
f.advance(59999); assert.equal(f.cache.needsCheck(), false);
|
||||
f.advance(1); assert.equal(f.cache.needsCheck(), true);
|
||||
f.cache.complete(f.now(), f.cache.version);
|
||||
assert.equal(f.cache.needsCheck(), false);
|
||||
});
|
||||
test('new-season reads before returning home cannot hide a missed cutoff', () => {
|
||||
const f = fixture(); f.cache.complete(f.now(), 0);
|
||||
f.advance(60001 + model.PASS_PERIOD * 2);
|
||||
f.cache.observeStart(f.end + model.PASS_PERIOD * 2);
|
||||
assert.equal(f.cache.needsCheck(), true);
|
||||
f.cache.complete(f.now(), 0);
|
||||
assert.equal(f.cache.needsCheck(), false);
|
||||
});
|
||||
test('deadline uses server-clock offset, not an inaccurate device clock', () => {
|
||||
const f = fixture(), serverNow = f.now();
|
||||
f.advance(3600000); f.cache.syncClock(serverNow); f.cache.complete(serverNow, 0);
|
||||
assert.equal(f.cache.needsCheck(), false);
|
||||
f.advance(60000); assert.equal(f.cache.needsCheck(), true);
|
||||
});
|
||||
test('account/token changes and a new login with the same token reset the cache', () => {
|
||||
const f = fixture(); f.cache.complete(f.now(), 0);
|
||||
f.gm.token = 'new'; assert.equal(f.Check.forPlayer(f.gm).needsCheck(), true);
|
||||
f.Check.forPlayer(f.gm).complete(f.now(), 0);
|
||||
f.gm.uid = 'other'; assert.equal(f.Check.forPlayer(f.gm).needsCheck(), true);
|
||||
f.Check.forPlayer(f.gm).complete(f.now(), 0);
|
||||
f.Check.resetLogin(); assert.equal(f.Check.forPlayer(f.gm).needsCheck(), true);
|
||||
f.cache.complete(f.now(), 0); assert.equal(f.Check.forPlayer(f.gm).needsCheck(), true, 'old async completion cannot cache a new login');
|
||||
});
|
||||
test('payment invalidation during a read survives its later completion; pending orders bypass cache', () => {
|
||||
const f = fixture(); f.cache.invalidate(); f.cache.complete(f.now(), 0);
|
||||
assert.equal(f.cache.needsCheck(), true);
|
||||
f.cache.complete(f.now(), 1);
|
||||
assert.equal(f.cache.needsCheck([{ itemid: 'other', outTradeNo: 'a' }]), false);
|
||||
const order = { itemid: 'battlepass_12', outTradeNo: 'b' };
|
||||
assert.equal(f.cache.needsCheck([order]), true);
|
||||
f.cache.reviewOrders.add('b'); assert.equal(f.cache.needsCheck([order]), false);
|
||||
});
|
||||
|
||||
function runFixture() {
|
||||
const f = fixture(), calls = [], retries = [];
|
||||
let error, paidError, readResponse, pendingRead;
|
||||
const cc = { _decorator: { ccclass: T => T }, Component: class {
|
||||
scheduleOnce(fn, delay) { retries.push({ fn, delay }); }
|
||||
destroy() { this.destroyed = true; }
|
||||
}, isValid: value => !!value && !value.destroyed, log() {}, warn() {},
|
||||
systemEvent: { off() {} }, SystemEvent: { EventType: { KEY_DOWN: 'key' } },
|
||||
fx: { GameConfig: { GM_INFO: f.gm } } };
|
||||
const utils = {
|
||||
POSTJSON(url, body, callback) { calls.push(url); callback({ code: 1, data: { time: f.end - model.PASS_PERIOD } }); },
|
||||
async completePassOrder() { calls.push('payment'); if (paidError) throw paidError; f.cache.invalidate(); return { data: { alreadyGranted: true } }; },
|
||||
async settlementRequest() { calls.push('read'); if (error) throw error; if (pendingRead) return pendingRead; return readResponse || { data: { serverTime: f.now(), rows: [] } }; }
|
||||
};
|
||||
const C = load('assets/Script/BattlePassSettlement.ts', {
|
||||
'./module/Pay/Utils': { default: utils },
|
||||
'./module/Config/BattlePassSettlementCheck': { default: f.Check },
|
||||
'./module/Config/BattlePassSettlementModel': { settlementSteps: rows => rows },
|
||||
'./BattlePassArt': { default: { load: async () => { throw Error('art failed'); } } }
|
||||
}, { cc, Date: f.Clock }).default;
|
||||
const view = new C(); view.node = {}; let done = 0;
|
||||
return { ...f, view, calls, retries, utils, run: () => view.run(() => done++), done: () => done,
|
||||
fail: value => error = value, paidFail: value => paidError = value,
|
||||
rows: rows => readResponse = { data: { serverTime: f.now(), rows } },
|
||||
defer: () => new Promise(resolve => { pendingRead = new Promise(r => resolve(r)); }) };
|
||||
}
|
||||
test('successful empty read caches, and an unavailable period is fetched before caching', async () => {
|
||||
const f = runFixture(); f.Check.resetLogin(); await f.run();
|
||||
assert.deepEqual(f.calls, ['passCheck', 'read']); assert.equal(f.done(), 1);
|
||||
assert.equal(f.Check.forPlayer(f.gm).needsCheck(), false);
|
||||
});
|
||||
test('network failure is not cached; retry backoff caps at 60 seconds and stops after success', async () => {
|
||||
const f = runFixture(); f.fail({ code: 0 }); await f.run();
|
||||
for (let i = 0; i < 5; i++) await f.retries[i].fn();
|
||||
assert.deepEqual(f.retries.map(r => r.delay), [5, 10, 20, 40, 60, 60]);
|
||||
assert.equal(f.done(), 0); assert.equal(f.cache.needsCheck(), true);
|
||||
f.fail(null); await f.retries[5].fn();
|
||||
assert.equal(f.done(), 1); assert.equal(f.cache.needsCheck(), false); assert.equal(f.retries.length, 6);
|
||||
});
|
||||
test('transient paid-order failure retries; 410 is reviewed once per login without deleting the order', async () => {
|
||||
const f = runFixture(); f.gm.allOutTradeNo = [{ itemid: 'battlepass', outTradeNo: 'paid' }];
|
||||
f.paidFail({ code: 500 }); await f.run(); assert.deepEqual(f.calls, ['payment']);
|
||||
assert.equal(f.cache.needsCheck(), true);
|
||||
f.paidFail({ code: 410 }); await f.retries[0].fn();
|
||||
assert.equal(f.cache.needsCheck(f.gm.allOutTradeNo), false); assert.equal(f.gm.allOutTradeNo.length, 1);
|
||||
f.view.destroyed = false; await f.run();
|
||||
assert.equal(f.calls.filter(v => v === 'payment').length, 2);
|
||||
});
|
||||
test('late read after scene destruction and failed popup construction never cache completion', async () => {
|
||||
const f = runFixture(), resolve = await f.defer(), run = f.run();
|
||||
f.view.node.destroyed = true; resolve({ data: { serverTime: f.now(), rows: [] } }); await run;
|
||||
assert.equal(f.done(), 0); assert.equal(f.cache.needsCheck(), true);
|
||||
const g = runFixture(); g.rows([{ end: g.end - model.PASS_PERIOD }]); await g.run();
|
||||
assert.equal(g.done(), 0); assert.equal(g.cache.needsCheck(), true); assert.equal(g.retries.length, 1);
|
||||
});
|
||||
test('a read crossing the deadline triggers one follow-up instead of caching the pre-expiry snapshot', async () => {
|
||||
const f = runFixture(), resolve = await f.defer(), run = f.run();
|
||||
f.advance(60001); resolve({ data: { serverTime: f.now(), rows: [] } }); await run;
|
||||
assert.equal(f.cache.needsCheck(), true);
|
||||
});
|
||||
test('a relogin discards the previous session response without caching or releasing its UI', async () => {
|
||||
const f = runFixture(), resolve = await f.defer(), run = f.run();
|
||||
f.Check.resetLogin(); resolve({ data: { serverTime: f.now(), rows: [] } }); await run;
|
||||
assert.equal(f.done(), 0); assert.equal(f.Check.forPlayer(f.gm).needsCheck(), true);
|
||||
});
|
||||
test('payment wrapper invalidates on both success and failure', async () => {
|
||||
const f = fixture(), source = ts.createSourceFile('Utils.ts', fs.readFileSync('assets/Script/module/Pay/Utils.ts', 'utf8'), 99, true);
|
||||
const method = source.statements.find(ts.isClassDeclaration).members.find(m => m.name?.getText(source) === 'completePassOrder').getText(source);
|
||||
const context = { cc: { fx: { GameConfig: { GM_INFO: f.gm } } }, BattlePassSettlementCheck: f.Check };
|
||||
vm.runInNewContext(ts.transpileModule('class Utils {' + method + '} globalThis.Utils=Utils;', { compilerOptions: { target: 99 } }).outputText, context);
|
||||
for (const failed of [false, true]) {
|
||||
f.cache.complete(f.now(), f.cache.version);
|
||||
context.Utils.deliverPassOrder = async () => { if (failed) throw Error('lost response'); return 'ok'; };
|
||||
if (failed) await assert.rejects(context.Utils.completePassOrder('paid')); else await context.Utils.completePassOrder('paid');
|
||||
assert.equal(f.cache.needsCheck(), true);
|
||||
}
|
||||
});
|
||||
|
|
@ -138,6 +138,7 @@ function prefabTree(file) {
|
|||
}
|
||||
|
||||
function homeSettlementFixture() {
|
||||
const Check=load('assets/Script/module/Config/BattlePassSettlementCheck.ts',{'./BattlePassModel':model}).default;
|
||||
const source=ts.createSourceFile('JiaZai.ts',fs.readFileSync('assets/Script/JiaZai.ts','utf8'),99,true);
|
||||
const members=source.statements.find(ts.isClassDeclaration).members;
|
||||
const method=name=>members.find(n=>n.name?.getText(source)===name);
|
||||
|
|
@ -147,14 +148,15 @@ function homeSettlementFixture() {
|
|||
game:{EVENT_SHOW:'show',on(event,fn,owner){listeners.push({event,fn,owner});},
|
||||
off(event,fn,owner){const i=listeners.findIndex(l=>l.event===event&&l.fn===fn&&l.owner===owner);if(i>=0)listeners.splice(i,1);}}};
|
||||
const Queue=load('assets/home_popup_queue/HomePopupQueue.ts',{}, {cc}).default;
|
||||
class Settlement { run(done){runs.push({view:this,done});} }
|
||||
class Settlement { run(done){const cache=Check.forPlayer(cc.fx.GameConfig.GM_INFO);const version=cache.version;runs.push({view:this,done:()=>{cache.complete(Date.now(),version);done();}});} }
|
||||
// Exercise the actual lifecycle statements, without unrelated homepage UI setup.
|
||||
const lifecycle=name=>name+'(){'+method(name).body.statements.filter(s=>s.getText(source).includes('this.checkBattlePassSettlement')).map(s=>s.getText(source)).join('\n')+'}';
|
||||
const exports={};vm.runInNewContext(ts.transpileModule('export class Home {'+method('checkBattlePassSettlement').getText(source)+method('checkAndSetPlayerPassLevel').getText(source)+lifecycle('start')+lifecycle('onDestroy')+'}',{compilerOptions:{target:99,module:1}}).outputText,
|
||||
{exports,cc,HomePopupQueue:Queue,BattlePassSettlement:Settlement,canClaimPass:model.canClaimPass,
|
||||
{exports,cc,HomePopupQueue:Queue,BattlePassSettlement:Settlement,BattlePassSettlementCheck:Check,canClaimPass:model.canClaimPass,
|
||||
Utils:{getPassCheckInfo:callback=>passReads.push(callback)}});
|
||||
const create=()=>{
|
||||
const home=new exports.Home(),components=[];
|
||||
home.schedule=(fn,seconds)=>{assert.equal(seconds,1);home.tick=()=>fn.call(home);};home.unschedule=()=>{home.tick=null;};
|
||||
home.isHomeRuntimeAlive=()=>!home.destroyed;
|
||||
const red={active:true},button={active:false,getChildByName:name=>name==='red'?red:null};
|
||||
const top={getChildByName:name=>name==='passBtn'?button:null},load={getChildByName:name=>name==='Top'?top:null};
|
||||
|
|
@ -162,7 +164,7 @@ function homeSettlementFixture() {
|
|||
home.start();const queue=Queue.get(home.node);queue.hasVisiblePopup=()=>false;
|
||||
return {home,queue,button,red};
|
||||
};
|
||||
return {create,runs,listeners,passReads,cc,source,method,show:()=>listeners.slice().forEach(l=>l.fn.call(l.owner))};
|
||||
return {create,runs,listeners,passReads,cc,source,method,Check,show:()=>listeners.slice().forEach(l=>l.fn.call(l.owner))};
|
||||
}
|
||||
|
||||
test('finishing old-season settlement refreshes the hidden home entry and current-season progress without reentering',()=>{
|
||||
|
|
@ -198,18 +200,20 @@ test('battle-pass checks use the same start and foreground hooks as gold miner,
|
|||
assert.equal(f.method('onShow'),undefined);
|
||||
for(const name of ['onLoad','checkAndSetPlayerPassLevel'])assert.doesNotMatch(f.method(name).getText(f.source),/battlePassSettlement|checkBattlePassSettlement/);
|
||||
for(let visit=0;visit<3;visit++) {
|
||||
const {home,queue}=f.create();queue.update();assert.equal(f.runs.length,visit+1);
|
||||
f.runs.at(-1).done();home.onDestroy();home.destroyed=true;
|
||||
const {home,queue}=f.create();queue.update();assert.equal(f.runs.length,1);
|
||||
if(visit===0)f.runs.at(-1).done();home.onDestroy();home.destroyed=true;
|
||||
assert.equal(f.listeners.length,0);
|
||||
}
|
||||
f.show();assert.equal(f.runs.length,3);
|
||||
f.show();assert.equal(f.runs.length,1);
|
||||
});
|
||||
test('foreground checks deduplicate queued/running settlement and recheck after completion',()=>{
|
||||
test('foreground checks deduplicate queued/running settlement and bypass completed cache only when invalidated',()=>{
|
||||
const f=homeSettlementFixture(),{queue}=f.create();
|
||||
f.show();f.show();queue.update();assert.equal(f.runs.length,1);
|
||||
f.show();queue.update();assert.equal(f.runs.length,1);
|
||||
// Cocos destruction is deferred; a completed component must not be reused.
|
||||
f.runs[0].view.destroyed=true;f.runs[0].done();
|
||||
f.show();queue.update();assert.equal(f.runs.length,1);
|
||||
f.Check.forPlayer(f.cc.fx.GameConfig.GM_INFO).invalidate();
|
||||
f.show();queue.update();assert.equal(f.runs.length,2);
|
||||
assert.notEqual(f.runs[0].view,f.runs[1].view);
|
||||
});
|
||||
|
|
@ -222,6 +226,15 @@ test('home settlement check skips inactive/leaving scenes, including queued task
|
|||
f.cc.fx.GameConfig.isGameplayEntryPreparing=()=>false;
|
||||
f.show();home.destroyed=true;queue.update();assert.equal(f.runs.length,0);
|
||||
});
|
||||
test('home local timer triggers the cutoff once and stops on scene destruction',()=>{
|
||||
const f=homeSettlementFixture(),{home,queue}=f.create();queue.update();f.runs[0].done();
|
||||
for(let i=0;i<60;i++){home.tick();queue.update();}assert.equal(f.runs.length,1);
|
||||
const cache=f.Check.forPlayer(f.cc.fx.GameConfig.GM_INFO);
|
||||
cache.observeStart(Date.now()-model.PASS_PERIOD-1000);cache.complete(Date.now()-2000,cache.version);
|
||||
home.tick();f.show();queue.update();assert.equal(f.runs.length,2);
|
||||
f.runs[1].done();home.tick();queue.update();assert.equal(f.runs.length,2);
|
||||
home.onDestroy();assert.equal(home.tick,null);assert.equal(f.listeners.length,0);
|
||||
});
|
||||
const find=(path,root)=>path.split('/').reduce((n,p)=>n.getChildByName(p),root);
|
||||
test('figures 1/2/3 match figure 5 title size and the offer uses the glossy green button',()=>{
|
||||
const panel=prefabTree('assets/passCheck/prefab/PassSettlement.prefab').getChildByName('panel');
|
||||
|
|
|
|||
|
|
@ -47,9 +47,10 @@ function fixture(owned = 18) {
|
|||
const old = body => legacy({ body: { uid: 'u', token: 't', ...body } });
|
||||
const cc = { fx: { GameConfig: { PASS_CHECK: rows, GM_INFO: { uid: 'u', coin: 100 } },
|
||||
StorageMessage: { setStorage() {}, getStorage() {} } } };
|
||||
const context = { cc, ...model, Date, console, setTimeout, clearTimeout };
|
||||
const BattlePassSettlementCheck = load('assets/Script/module/Config/BattlePassSettlementCheck.ts', { './BattlePassModel': model }).default;
|
||||
const context = { cc, ...model, BattlePassSettlementCheck, Date, console, setTimeout, clearTimeout };
|
||||
const source = ts.createSourceFile('Utils.ts', fs.readFileSync('assets/Script/module/Pay/Utils.ts', 'utf8'), 99, true);
|
||||
const names = ['passGiftPending', 'applyPassGift', 'getPassCheckInfo', 'setPassCheckInfo', 'passRequest', 'passUpload', 'addPassProgress', 'completePassOrder'];
|
||||
const names = ['passGiftPending', 'applyPassGift', 'getPassCheckInfo', 'setPassCheckInfo', 'passRequest', 'passUpload', 'addPassProgress', 'completePassOrder', 'deliverPassOrder'];
|
||||
const methods = source.statements.find(ts.isClassDeclaration).members.filter(n => names.includes(n.name?.getText(source))).map(n => n.getText(source)).join('\n');
|
||||
vm.runInNewContext(ts.transpileModule('class Utils {' + methods + '} globalThis.Utils=Utils;', { compilerOptions: { target: ts.ScriptTarget.ES2020 } }).outputText, context);
|
||||
context.Utils.POST = (url, body, callback) => (url === 'passCheck' ? old(body) : call(body)).then(callback);
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user