Files
MatchMaster/tools/test-stamina-pack.cjs
T

369 lines
20 KiB
JavaScript

const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const { loadMembers, loadModule } = require('./new-player-ab-test-support.cjs');
const catalog = loadModule('assets/Script/module/Config/ShopCatalog.ts');
const minute = 60000;
const noon = Date.UTC(2026, 9, 12, 12);
const plain = value => JSON.parse(JSON.stringify(value));
function fixture({ deferCustomerService = false } = {}) {
let now = noon;
const storage = new Map(), listeners = new Map(), showListeners = new Set();
const calls = { coins: [], health: [], rewards: [], paid: [], ios: [], iosQueries: [], acknowledgements: [], queries: [] };
const overlays = { Loading: { active: false }, ConfirmBox: { active: false } };
const info = { uid: 'player', iosCanPay: true, iosStaminaPackOrder: '', shopDouble: {} };
const cc = {
fx: { GameConfig: { GM_INFO: info }, AudioManager: { _instance: { playEffect() {} } }, StorageMessage: {
getStorage: key => storage.get(key), setStorage: (key, value) => storage.set(key, plain(value)),
} },
isValid: value => !!value && !value.destroyed && !value.node?.destroyed,
find: () => null,
director: {
on: (event, cb, target) => listeners.set(event, cb.bind(target)),
off: event => listeners.delete(event),
emit: event => { const cb = listeners.get(event); if (cb) cb(); },
},
};
const DateMock = { now: () => now };
const state = loadModule('assets/Script/module/Config/StaminaPackState.ts', {}, { cc, Date: DateMock });
const sdk = { API: { showToast() {}, yinli_Pay() {}, shushu_SetSuperProperties() {} } };
const Tool = loadMembers('assets/Script/module/Tool/GameTool.ts', ['shopBuy'], {
cc, ...catalog, ...state, MiniGameSdk: sdk,
MapConroler: { _instance: { SceneManager: { openRewardWindow: data => calls.rewards.push(plain(data)) } } },
});
cc.fx.GameTool = Object.assign(new Tool(), {
changeCoin: n => calls.coins.push(n), setUserPowerTime: (...args) => calls.health.push(args),
shushu_Track() {}, tryReactivateStarterPack() {},
});
const Utils = {
buyProp: (...args) => { calls.paid.push(args.slice(0, 5)); calls.pay = args[5]; },
getPayInfo: (cb, order, options) => { calls.queries.push({ order, options }); calls.polledOrder = order; calls.poll = cb; },
setPayInfo: (cb, order) => { calls.acknowledgements.push(order); calls.ack = cb; },
GoKEFu: (data, cb) => {
calls.ios.push(data); info.iosStaminaPackOrder = 'ios-order'; calls.customerService = cb;
if (!deferCustomerService) cb('success');
},
getIosPayInfo: (order, cb) => { calls.iosQueries.push(order); calls.polledOrder = order; calls.iosPoll = cb; },
};
const Panel = loadMembers('assets/Script/StaminaPack.ts', [
'paying', 'checkingIosOrder', 'uid', 'pendingOrder', 'confirmedOrder', 'setPaying', 'refreshCountdown', 'closePop', 'closeAfterPurchase',
'buyProduct', 'iosOldPay', 'checkIosOrder', 'completePurchase', 'tryOpen',
'openConfirmBox', 'closeConfirmBox', 'againGet', 'checkNativeOrder', 'finishNativeOrder',
'onLoad', 'onDestroy', 'onShowListener',
], { cc, ...catalog, ...state, MiniGameSdk: sdk, Utils, Date: DateMock,
LoadingCatAnimation: { play: node => { node.active = true; }, stop: node => { node.active = false; } },
wx: {
getSystemInfoSync: () => ({ platform: calls.platform || 'android' }),
onShow: cb => showListeners.add(cb), offShow: cb => showListeners.delete(cb),
},
GameManager: { _instance: { staminaPackPrefab: {} } },
});
const panel = Object.assign(new Panel(), {
uid: info.uid, buyButton: { interactable: true, node: { on() {} } },
closeButton: { node: { on() {} } }, timeLabel: {}, schedule() {},
node: {
active: true, addComponent() {}, getChildByName: name => overlays[name],
destroy() { this.destroyed = true; panel.onDestroy(); },
},
});
listeners.set('stamina-pack-granted', () => panel.closeAfterPurchase());
return { state, info, calls, cc, panel, Panel, storage, overlays, setTime: t => { now = t; },
show: () => { for (const listener of showListeners) listener(); } };
}
test('first display starts 10-minute offer; reopening preserves deadline and survives module reload', () => {
const f = fixture();
assert.equal(f.state.canShowStaminaPack(), true);
f.state.beginStaminaPackOffer();
f.setTime(noon + 5 * minute);
f.state.beginStaminaPackOffer();
assert.deepEqual(plain(f.state.getStaminaPackRecord()), { offerEnd: noon + 10 * minute, cooldownEnd: noon + 40 * minute });
const reloaded = loadModule('assets/Script/module/Config/StaminaPackState.ts', {}, { cc: f.cc });
assert.equal(reloaded.canShowStaminaPack(noon + 9 * minute), true);
assert.equal(reloaded.canShowStaminaPack(noon + 10 * minute), false);
});
test('early close preserves timestamps; late close replaces cooldown with close time plus 30 minutes', () => {
const f = fixture(); f.state.beginStaminaPackOffer();
f.state.closeStaminaPackOffer(noon + 5 * minute);
assert.equal(f.state.getStaminaPackRecord().cooldownEnd, noon + 40 * minute);
f.state.closeStaminaPackOffer(noon + 25 * minute);
assert.equal(f.state.getStaminaPackRecord().cooldownEnd, noon + 55 * minute);
assert.equal(f.state.canShowStaminaPack(noon + 54 * minute), false);
assert.equal(f.state.canShowStaminaPack(noon + 55 * minute), true);
});
test('after an absent close, the preset cooldown expires; next actual trigger starts a fresh offer', () => {
const f = fixture(); f.state.beginStaminaPackOffer();
f.setTime(noon + 60 * minute);
assert.equal(f.state.canShowStaminaPack(), true);
f.state.beginStaminaPackOffer();
assert.equal(f.state.getStaminaPackRecord().offerEnd, noon + 70 * minute);
assert.equal(f.state.getStaminaPackRecord().cooldownEnd, noon + 100 * minute);
});
test('zero countdown leaves the visible offer purchasable', () => {
const f = fixture(); f.state.beginStaminaPackOffer(); f.setTime(noon + 25 * minute);
f.panel.refreshCountdown();
assert.equal(f.panel.timeLabel.string, '00:00');
assert.equal(f.panel.node.destroyed, undefined);
f.panel.buyProduct();
assert.deepEqual(plain(f.calls.paid), [['StaminaPack', 1, 800, 'Android', 'StaminaPack']]);
});
test('native payment waits for acknowledgement, then closes before RewardWindow and clears offer time', () => {
const f = fixture(); f.state.beginStaminaPackOffer();
f.panel.buyProduct(); f.panel.buyProduct();
assert.equal(f.overlays.Loading.active, true);
assert.equal(f.calls.paid.length, 1);
assert.equal(f.calls.coins.length, 0);
f.calls.pay({ outTradeNo: 'order-1' });
assert.equal(f.calls.polledOrder, 'order-1');
f.calls.poll({ code: 1, data: { pay_state: 2 } });
assert.equal(f.overlays.Loading.active, true, 'loading covers delivery acknowledgement');
assert.deepEqual(f.calls.acknowledgements, ['order-1']);
assert.equal(f.calls.coins.length, 0);
f.setTime(noon + 11 * minute);
f.calls.ack({ code: 1 });
assert.equal(f.overlays.Loading.active, false);
assert.equal(f.panel.node.active, false);
assert.equal(f.panel.node.destroyed, true);
assert.deepEqual(f.calls.coins, [1200]);
assert.deepEqual(f.calls.health, [[3600, 'StaminaPack', false]]);
assert.deepEqual(f.calls.rewards, [[{ type: 'coin', count: 1200 }, { type: 'infinite_health', count: 3600 }]]);
assert.deepEqual(plain(f.state.getStaminaPackRecord()), { offerEnd: 0, cooldownEnd: noon + 491 * minute });
f.state.closeStaminaPackOffer(noon + 12 * minute);
assert.equal(f.state.getStaminaPackRecord().cooldownEnd, noon + 491 * minute);
});
test('query timeout shows confirmation and retries only the original order without duplicate payment', () => {
const f = fixture(); f.panel.buyProduct(); f.calls.pay({ outTradeNo: 'retry-order' });
assert.equal(f.calls.queries[0].options.timeoutMs, 30000);
f.calls.poll({ code: 0, data: { pay_state: -1 } });
assert.equal(f.overlays.Loading.active, false);
assert.equal(f.overlays.ConfirmBox.active, true);
assert.equal(f.calls.coins.length, 0);
f.panel.againGet(); f.panel.againGet(); f.panel.buyProduct();
assert.equal(f.overlays.Loading.active, true);
assert.equal(f.overlays.ConfirmBox.active, false);
assert.deepEqual(f.calls.queries.map(q => q.order), ['retry-order', 'retry-order']);
assert.equal(f.calls.paid.length, 1);
f.calls.poll({ code: 1, data: { pay_state: 2 } }); f.calls.ack({ code: 1 });
assert.deepEqual(f.calls.coins, [1200]);
});
test('failed delivery acknowledgement retries delivery without requerying or creating an order', () => {
const f = fixture(); f.panel.buyProduct(); f.calls.pay({ outTradeNo: 'grant-retry' });
f.calls.poll({ code: 1, data: { pay_state: 2 } }); f.calls.ack({ code: 0 });
assert.equal(f.overlays.ConfirmBox.active, true);
assert.equal(f.calls.coins.length, 0);
f.panel.againGet(); f.panel.againGet();
assert.equal(f.overlays.Loading.active, true);
assert.deepEqual(f.calls.acknowledgements, ['grant-retry', 'grant-retry']);
assert.equal(f.calls.queries.length, 1);
assert.equal(f.calls.paid.length, 1);
f.calls.ack({ code: 1 });
assert.deepEqual(f.calls.coins, [1200]);
});
test('legacy iOS timeout and network error retain the order for confirmation retry', () => {
for (const response of [{ code: 2 }, { code: 0, networkError: true }]) {
const f = fixture(); f.info.iosStaminaPackOrder = 'ios-retry';
f.panel.checkIosOrder();
assert.equal(f.overlays.Loading.active, true);
f.calls.iosPoll(response);
assert.equal(f.info.iosStaminaPackOrder, 'ios-retry');
assert.equal(f.overlays.Loading.active, false);
assert.equal(f.overlays.ConfirmBox.active, true);
f.panel.againGet();
assert.equal(f.calls.polledOrder, 'ios-retry');
assert.equal(f.overlays.ConfirmBox.active, false);
assert.equal(f.overlays.Loading.active, true);
f.calls.iosPoll({ code: 1, data: { payment_name: 'StaminaPack' } });
assert.equal(f.info.iosStaminaPackOrder, '');
assert.deepEqual(f.calls.coins, [1200]);
assert.equal(f.calls.paid.length + f.calls.ios.length, 0);
}
});
test('compensation starts eight hours at grant, duplicate grant cannot extend cooldown or grant twice', () => {
const f = fixture(); f.setTime(noon + 60 * minute);
f.cc.fx.GameTool.shopBuy('StaminaPack', true, { outTradeNo: 'pending' });
f.setTime(noon + 120 * minute);
f.cc.fx.GameTool.shopBuy('StaminaPack', true, { outTradeNo: 'pending' });
assert.equal(f.state.getStaminaPackRecord().cooldownEnd, noon + 540 * minute);
assert.deepEqual(f.calls.coins, [1200]);
assert.deepEqual(f.calls.health, [[3600, 'StaminaPack', true]]);
assert.equal(f.calls.rewards.length, 1);
assert.equal(f.state.canShowStaminaPack(noon + 539 * minute), false);
assert.equal(f.state.canShowStaminaPack(noon + 540 * minute), true);
});
test('cancel, unconfirmed payment and failed acknowledgement never start purchase cooldown', () => {
for (const stage of ['cancel', 'unconfirmed', 'ack']) {
const f = fixture(); f.state.beginStaminaPackOffer(); f.panel.buyProduct();
if (stage === 'cancel') f.calls.pay({ err: true, errCode: -2 });
else {
f.calls.pay({ outTradeNo: 'failed' });
f.calls.poll({ code: 1, data: { pay_state: stage === 'ack' ? 2 : 1 } });
if (stage === 'ack') f.calls.ack({ code: 0 });
}
assert.equal(f.state.getStaminaPackRecord().offerEnd, noon + 10 * minute);
assert.equal(f.calls.coins.length, 0);
assert.equal(f.panel.buyButton.interactable, true);
assert.equal(f.overlays.Loading.active, false);
assert.equal(f.overlays.ConfirmBox.active, stage === 'ack');
}
});
test('closing before acknowledgement does not lose delivery or overwrite its eight-hour cooldown', () => {
const f = fixture(); f.state.beginStaminaPackOffer(); f.panel.buyProduct();
f.calls.pay({ outTradeNo: 'late' }); f.calls.poll({ code: 1, data: { pay_state: 2 } });
f.setTime(noon + 25 * minute); f.panel.closePop();
f.setTime(noon + 26 * minute); f.calls.ack({ code: 1 });
assert.equal(f.state.getStaminaPackRecord().cooldownEnd, noon + 506 * minute);
assert.deepEqual(f.calls.coins, [1200]);
});
test('legacy iOS uses existing customer service and query; success grants the same product', () => {
const f = fixture(); f.calls.platform = 'ios'; f.info.iosCanPay = false;
f.state.beginStaminaPackOffer(); f.panel.buyProduct();
assert.deepEqual(plain(f.calls.ios), [{ price: 800, payment_name: 'StaminaPack', payment_count: 1 }]);
f.panel.checkIosOrder(); f.panel.checkIosOrder();
f.calls.iosPoll({ code: 1, data: { payment_name: 'StaminaPack' } });
assert.equal(f.info.iosStaminaPackOrder, '');
assert.deepEqual(f.calls.coins, [1200]);
assert.equal(f.state.getStaminaPackRecord().offerEnd, 0);
});
for (const fallback of [false, true]) {
for (const callbackFirst of [false, true]) {
test(`legacy iOS return delivers once: ${fallback ? 'error 16 fallback' : 'direct customer service'}, ${callbackFirst ? 'callback first' : 'onShow first'}`, () => {
const f = fixture({ deferCustomerService: true });
f.calls.platform = 'ios'; f.info.iosCanPay = fallback;
f.panel.onLoad(); f.panel.buyProduct();
if (fallback) f.calls.pay({ errCode: 16, outTradeNo: 'native-failed' });
assert.equal(f.calls.ios.length, 1);
assert.equal(f.panel.pendingOrder, '');
assert.equal(f.overlays.Loading.active, true);
assert.deepEqual(f.calls.iosQueries, []);
if (callbackFirst) {
f.calls.customerService('success');
assert.deepEqual(f.calls.iosQueries, [], 'opening customer service must not query before payment');
}
f.show();
assert.deepEqual(f.calls.iosQueries, ['ios-order']);
if (!callbackFirst) f.calls.customerService('success');
assert.equal(f.panel.buyButton.interactable, false, 'late customer callback cannot unlock a running query');
assert.equal(f.overlays.Loading.active, true);
f.show(); f.panel.buyProduct();
assert.deepEqual(f.calls.iosQueries, ['ios-order'], 'foreground and button events cannot duplicate the query');
f.calls.iosPoll({ code: 1, data: { payment_name: 'StaminaPack' } });
f.show();
assert.equal(f.info.iosStaminaPackOrder, '');
assert.deepEqual(f.calls.coins, [1200]);
assert.deepEqual(f.calls.health, [[3600, 'StaminaPack', false]]);
assert.equal(f.calls.rewards.length, 1);
assert.equal(f.calls.ios.length, 1);
assert.equal(f.calls.paid.length, fallback ? 1 : 0);
});
}
}
test('legacy iOS return retains a network-failed order and releases the query lock for retry', () => {
const f = fixture({ deferCustomerService: true });
f.calls.platform = 'ios'; f.info.iosCanPay = false;
f.panel.onLoad(); f.panel.buyProduct(); f.show();
assert.equal(f.calls.iosQueries.length, 1);
f.calls.customerService('success');
f.calls.iosPoll({ code: 0, networkError: true });
assert.equal(f.info.iosStaminaPackOrder, 'ios-order');
assert.equal(f.overlays.ConfirmBox.active, true);
assert.equal(f.panel.buyButton.interactable, true);
f.panel.againGet(); f.show();
assert.deepEqual(f.calls.iosQueries, ['ios-order', 'ios-order']);
f.calls.iosPoll({ code: 1, data: { payment_name: 'StaminaPack' } });
assert.deepEqual(f.calls.coins, [1200]);
});
test('failed customer service opening restores the button without querying or granting', () => {
const f = fixture({ deferCustomerService: true });
f.calls.platform = 'ios'; f.info.iosCanPay = false;
f.panel.onLoad(); f.panel.buyProduct(); f.calls.customerService('fail'); f.show();
assert.equal(f.info.iosStaminaPackOrder, '');
assert.equal(f.panel.buyButton.interactable, true);
assert.equal(f.overlays.Loading.active, false);
assert.deepEqual(f.calls.iosQueries, []);
assert.deepEqual(f.calls.coins, []);
});
test('an old account panel cannot query another account order or grant after an account switch', () => {
const f = fixture(); f.panel.onLoad();
f.info.uid = 'other'; f.info.iosStaminaPackOrder = 'other-order';
f.show();
assert.deepEqual(f.calls.iosQueries, []);
f.info.uid = 'player'; f.info.iosStaminaPackOrder = 'player-order';
f.show();
assert.equal(f.panel.checkingIosOrder, true);
f.info.uid = 'other'; f.info.iosStaminaPackOrder = 'other-order';
f.calls.iosPoll({ code: 1, data: { payment_name: 'StaminaPack' } });
assert.equal(f.panel.checkingIosOrder, false);
assert.equal(f.info.iosStaminaPackOrder, 'other-order');
assert.deepEqual(f.calls.coins, []);
});
test('a changed account ignores the outstanding purchase callback', () => {
const f = fixture(); f.panel.buyProduct(); f.info.uid = 'other';
f.calls.pay({ outTradeNo: 'old-account' });
assert.equal(f.calls.polledOrder, undefined);
assert.equal(f.calls.coins.length, 0);
});
test('home close triggers only without successful coin refill; programmatic start-game close does not', () => {
let opened = 0;
const cc = { find: () => null, fx: { AudioManager: { _instance: { playEffect() {} } } } };
const Health = loadMembers('assets/Script/heathnum.ts', ['boughtHealth', 'closePop'], {
cc, StaminaPack: { tryOpen: () => opened++ }, console: { warn() {} },
});
const h = new Health(); h.heath = { destroy() {} };
h.closePop(); assert.equal(opened, 0);
h.closePop({}); assert.equal(opened, 1);
h.boughtHealth = true; h.closePop({}); assert.equal(opened, 1);
});
test('game retry keeps toast, selects pack when eligible, otherwise uses the original shop', async () => {
for (const eligible of [true, false]) {
let shops = 0, packs = 0, toasts = 0;
const cc = { fx: { AudioManager: { _instance: { playEffect() {} } },
GameTool: { getUserPowerTime: () => false }, GameConfig: { GM_INFO: { hp: 0 } } } };
const MapConroler = { _instance: { powerState: false } };
const Map = loadMembers('assets/Script/Map.ts', ['againLevel'], {
cc, MapConroler, StaminaPack: { tryOpen() { packs++; return eligible; } },
MiniGameSdk: { API: { showToast: () => toasts++ } }, setTimeout: cb => cb(),
});
const map = Object.assign(new Map(), { isMapRuntimeAlive: () => true, openShop: () => shops++ });
await map.againLevel();
assert.equal(toasts, 1); assert.equal(packs, 1); assert.equal(shops, eligible ? 0 : 1);
}
});
test('prefab bindings resolve the authored countdown/buttons and startup references the prefab', () => {
const prefab = JSON.parse(fs.readFileSync('assets/prefab/pop/heathpop2.prefab'));
const component = prefab.find(v => v.timeLabel && v.buyButton && v.closeButton);
assert.equal(prefab[component.timeLabel.__id__].__type__, 'cc.Label');
assert.equal(prefab[component.buyButton.__id__].__type__, 'cc.Button');
assert.equal(prefab[prefab[component.closeButton.__id__].node.__id__]._name, 'close');
const loading = prefab.find(v => v._name === 'Loading');
const confirm = prefab.find(v => v._name === 'ConfirmBox');
assert.equal(loading._active, false); assert.equal(confirm._active, false);
assert.equal(loading._parent.__id__, component.node.__id__);
assert.equal(confirm._parent.__id__, component.node.__id__);
const retry = prefab.find(v => v.__type__ === 'cc.ClickEvent' && v.handler === 'againGet');
assert.equal(retry.target.__id__, component.node.__id__);
assert.equal(retry._componentId, component.__type__);
const scene = JSON.parse(fs.readFileSync('assets/StartScene/LoadScene.fire'));
const meta = JSON.parse(fs.readFileSync('assets/prefab/pop/heathpop2.prefab.meta'));
assert.equal(scene.find(v => v.staminaPackPrefab).staminaPackPrefab.__uuid__, meta.uuid);
});