MatchMaster/tools/coin-madness/check-api.cjs

221 lines
16 KiB
JavaScript

// Protocol tests with an in-memory server. Never logs in, pays or writes real balances.
const assert = require('node:assert/strict');
const fs = require('node:fs');
const ts = require('typescript');
const compile = file => ts.transpileModule(fs.readFileSync(file, 'utf8'), {
compilerOptions: { target: ts.ScriptTarget.ES2017, module: ts.ModuleKind.CommonJS }
}).outputText;
const code = compile('assets/Script/coin_madness/GoldMinerService.ts');
function fixture(storage = new Map(), server = {}) {
const calls = [], toasts = [], game = { uid: 'alice', token: 'token-a', coin: 1000, iosCanPay: true };
const time = Date.parse('2026-09-18T04:00:00Z');
const periodId = 'goldMiner:2026-09-17';
const info = { serverTime: time, status: 'unlocked', periodId,
startsAt: Date.parse('2026-09-16T16:00:00Z'), endsAt: Date.parse('2026-09-20T16:00:00Z'),
priceFen: 300, productId: 'server-product', progressWins: 5, maxTarget: 5,
tasks: [1, 2].map((n, i) => ({ taskId: 'task-' + n, targetWins: n + 1,
itemsSnapshot: [{ type: 'coin', count: n * 100 }], claimStatus: i ? 'locked' : 'claimable' })) };
const claimed = server.claimed || new Set();
let saves = 0, confirms = 0, payCalls = 0, walletTail = Promise.resolve();
const cc = { sys: { localStorage: { getItem: k => storage.get(k), setItem: (k, v) => storage.set(k, v), removeItem: k => storage.delete(k) } },
game: { on() {} }, fx: { GameConfig: { GM_INFO: game }, GameTool: { getWechatGameVersion: () => server.version || '体验版' }, StorageMessage: { setStorage() {} } } };
const wx = { getSystemInfoSync: () => ({ platform: server.ios ? 'ios' : 'android' }),
showToast: options => toasts.push(options),
requestMidasPaymentGameItem: options => { payCalls++; assert.equal(options.signData, 'untouched-signature'); if (server.nativeError) options.fail(server.nativeError); else if (server.cancelPay) options.fail({errMsg:'requestMidasPaymentGameItem:fail cancel'}); else options.success(); },
openCustomerServiceConversation: options => { payCalls++; assert.equal(options.sessionFrom, 'signed-ticket'); if (server.customerError) options.fail(server.customerError); else options.success(); } };
const Utils = { serverUrl: () => server.baseUrl || 'https://test/', testHttpip: 'https://test/', httpip: 'https://production/',
GoKEFu(body, callback, onCreated) {
calls.push({path:'Utils.GoKEFu',body}); onCreated('legacy-1'); payCalls++;
callback(server.customerError ? 'fail' : 'success', server.customerError);
},
withCoinSave(work) { const result = walletTail.then(work); walletTail = result.catch(() => {}); return result; },
POSTJSON(path, body, callback) {
calls.push({ path, body: JSON.parse(JSON.stringify(body)) });
const ok = data => callback({ code: 1, data });
const fail = errorCode => callback({ code: 0, errorCode, msg: errorCode });
if (server.intercept && server.intercept(path, body, ok, fail, callback)) return;
if (path === 'activityConfig/list') return ok({schemaVersion:1,serverNow:time,refreshAt:time+30000,activities:[]});
if (body.action === 'confirm_settlement_delivery') { if(server.failConfirm)return fail('RETRYABLE'); return ok({settlementId:body.settlementId,confirmedGrantIds:body.grantIds,deliveryStatus:'client_saved'}); }
if (path === 'userCoin') {
if (body.action === 'read') return ok({ coinAmount: server.balance == null ? 1000 : server.balance });
saves++; server.balance = body.coinAmount;
if (server.unknownSave) return fail('NETWORK_UNKNOWN');
return ok({ coinAmount: body.coinAmount });
}
if (path.includes('orderPaySig')) return ok({ outTradeNo: 'order-1', signData: 'untouched-signature', signature: 's', paySig: 'p' });
if (path.includes('getPayInfo')) { info.status = 'unlocked'; return ok({ pay_state: 2, rewardDelivery: 'goldMiner.claim',
goldMiner: { fulfillmentRoute: server.route || 'current_period', targetPeriodId: periodId } }); }
if (body.action === 'info') return ok(JSON.parse(JSON.stringify(info)));
if (body.action === 'create_order') return ok({ outTradeNo: 'legacy-1', sessionFrom: 'signed-ticket' });
if (body.action === 'claim') return ok({ grantId: 'grant-' + body.taskId, taskId: body.taskId,
items: server.items || [{ type: 'coin', count: body.taskId === 'task-1' ? 100 : 200 }],
claimStatus: claimed.has(body.taskId) ? 'claimed' : 'issuing', requiresClientDelivery: !claimed.has(body.taskId) });
if (body.action === 'confirm_delivery') {
confirms++;
if (server.failConfirm) return fail('RETRYABLE');
claimed.add(body.taskId);
info.tasks.find(t => t.taskId === body.taskId).claimStatus = 'claimed';
if (body.taskId === 'task-1') info.tasks[1].claimStatus = 'claimable';
return ok({ claimStatus: 'claimed' });
}
if (body.action === 'settlements') return ok({ items: [], nextCursor: null });
if (body.action === 'ack_settlement') return ok({ acknowledged: true });
throw new Error('Unexpected endpoint: ' + path + ' ' + JSON.stringify(body));
} };
const exports = {};
new Function('require', 'exports', 'cc', 'wx', 'CC_PREVIEW', code)(() => ({ default: Utils }), exports, cc, wx, false);
return { client: exports.default.current(), cc, game, info, server, calls, toasts, storage, claimed, periodId,
counts: () => ({ saves, confirms, payCalls }) };
}
module.exports = { fixture };
if (require.main === module) (async () => {
let f = fixture();
await f.client.refresh();
assert.equal(f.client.view().state.config.stages[0].taskId, 'task-1');
assert.equal(f.client.view().state.config.priceCents, 300);
const results = await Promise.all([f.client.request('claim', { periodId: f.periodId, stage: 0 }), f.client.request('claim', { periodId: f.periodId, stage: 0 })]);
assert(results.every(r => r.code === 1)); assert.equal(f.game.coin, 1100); assert.equal(f.counts().saves, 1);
const route = f.calls.filter(c => c.body.action === 'claim' || c.path === 'userCoin' || c.body.action === 'confirm_delivery');
assert.deepEqual(route.slice(0, 3).map(c => c.body.action), ['claim', 'save', 'confirm_delivery']);
assert.equal(route[2].body.grantId, 'grant-task-1');
console.log('PASS: server-driven UI, serialized duplicate claims and ordered delivery.');
f = fixture(new Map(), { failConfirm: true }); await f.client.refresh();
assert.equal((await f.client.request('claim', { periodId: f.periodId, stage: 0 })).code, 0);
assert.equal(f.game.coin, 1100);
f.server.failConfirm = false;
await f.client.request('claim', { periodId: f.periodId, stage: 0 });
assert.equal(f.counts().saves, 1); assert.equal(f.counts().confirms, 2);
console.log('PASS: confirmation retry never adds or saves the reward again.');
f = fixture(new Map(), { unknownSave: true }); await f.client.refresh();
await f.client.request('claim', { periodId: f.periodId, stage: 0 });
await f.client.request('claim', { periodId: f.periodId, stage: 0 });
assert.equal(f.counts().saves, 1); assert.equal(f.counts().confirms, 0);
const restored = fixture(f.storage); await restored.client.refresh();
assert.equal(restored.counts().saves, 0); assert.equal(restored.counts().confirms, 0);
assert.match(restored.client.message, /核对/);
console.log('PASS: ambiguous wallet save stays blocked across restart.');
f = fixture(new Map(), { items: [{ type: 'unknown', count: 1 }] }); await f.client.refresh();
assert.equal((await f.client.request('claim', { periodId: f.periodId, stage: 0 })).code, 0);
assert.deepEqual(f.counts(), { saves: 0, confirms: 0, payCalls: 0 });
f = fixture(); f.info.tasks[0].claimStatus = 'issuing'; await f.client.refresh();
assert.equal(f.counts().saves, 0); assert.match(f.client.message, /核对/);
console.log('PASS: unsupported items and unjournaled issuing grants are not credited or confirmed.');
f = fixture();
let pages = 0, acknowledged = false;
f.server.intercept = (path, body, ok) => {
if (body.action === 'settlements') {
pages++;
if (!body.afterId) ok({ items: [], nextCursor: 'page-2' });
else ok({ items: [{ settlementId: 'settlement-1', periodId: 'goldMiner:2026-09-10', rewards: [1,2].map(n=>({taskId:'task-'+n,grantId:'grant-task-'+n,items:[{type:'coin',count:n*100}]})),
itemsSummary: [{ type: 'coin', count: 300 }], deliveryStatus: 'pending', acknowledgedAt: acknowledged ? 1 : null }], nextCursor: null });
return true;
}
if (body.action === 'confirm_settlement_delivery') { acknowledged = true; ok({settlementId:body.settlementId,confirmedGrantIds:body.grantIds}); return true; }
};
await f.client.refresh(); assert.equal(pages, 2); assert.equal(f.game.coin, 1300);
assert.equal(f.client.settlementNotice, 300); assert(acknowledged);
await f.client.refresh(); assert.equal(f.counts().saves, 2);
assert(!f.calls.some(c => c.body.action === 'claim' || c.body.action === 'ack_settlement'));
f.client.dismissNotice(); assert.equal(f.client.settlementNotice, 0);
console.log('PASS: empty settlement page, old-period delivery, no summary double-credit and acknowledgment.');
f = fixture(); await f.client.refresh();
await f.client.request('claim', { periodId: f.periodId, stage: 0 });
f.server.intercept = (path, body, ok) => {
if (body.action !== 'settlements') return false;
ok({ items: [{ settlementId: 'mixed', periodId: f.periodId, rewards: [1,2].map(n=>({taskId:'task-'+n,grantId:'grant-task-'+n,items:[{type:'coin',count:n*100}]})),
deliveryStatus: 'partial', acknowledgedAt: null }], nextCursor: null }); return true;
};
await f.client.refresh();
assert.equal(f.game.coin, 1300); assert.equal(f.client.settlementNotice, 200);
console.log('PASS: settlement receipt excludes coins already claimed manually.');
f = fixture(new Map(), { failConfirm: true });
f.server.intercept = (path, body, ok) => {
if (body.action !== 'settlements') return false;
ok({ items: [{ settlementId: 'recover', periodId: 'goldMiner:2026-09-10', rewards: [{taskId:'task-1',grantId:'grant-task-1',items:[{type:'coin',count:100}]}],
deliveryStatus: 'pending', acknowledgedAt: null }], nextCursor: null }); return true;
};
await f.client.refresh(); assert.equal(f.counts().saves, 1); assert.equal(f.client.settlementNotice, 0);
f.server.failConfirm = false; await f.client.refresh();
assert.equal(f.counts().saves, 1); assert.equal(f.client.settlementNotice, 100);
console.log('PASS: settlement confirmation recovery preserves the receipt without another wallet write.');
for (const legacy of [false, true]) {
f = fixture(new Map(), { ios: legacy }); f.game.iosCanPay = !legacy;
f.info.status = 'purchasable'; await f.client.refresh();
assert.equal((await f.client.request('purchase', { periodId: f.periodId })).code, 1);
assert.equal(f.counts().payCalls, 1); assert.equal(f.counts().saves, 0);
assert.equal(f.toasts[0].title, '购买成功');
const create = f.calls.find(c => c.path.includes('orderPaySig') || c.path === 'Utils.GoKEFu');
assert.equal(create.body[legacy ? 'payment_name' : 'itemid'], 'server-product');
if (!legacy) assert(create.body.createRequestId);
assert.equal(create.body[legacy ? 'price' : 'itemPrice'], 300);
assert.equal(create.body[legacy ? 'payment_count' : 'itemCount'], 1);
assert(f.calls.some(c => c.path.endsWith(legacy ? 'iosgetPayInfo' : 'getPayInfo')));
}
console.log('PASS: native and signed customer-service purchases query backend without granting coins.');
f = fixture(new Map(), { cancelPay: true }); f.info.status = 'purchasable'; await f.client.refresh();
assert.equal((await f.client.request('purchase', {periodId:f.periodId})).code, 0);
assert.deepEqual(f.toasts.map(t=>t.title), ['已取消支付']);
f = fixture(); f.info.status = 'purchasable'; await f.client.refresh();
f.server.intercept = (path, body, ok, fail) => { if(path.includes('getPayInfo')) { fail('NETWORK_UNKNOWN'); return true; } };
await f.client.request('purchase', {periodId:f.periodId});
assert.deepEqual(f.toasts.map(t=>t.title), ['支付结果确认中']);
console.log('PASS: native cancellation shows failure; unconfirmed payment never shows success or failure.');
f = fixture(); await f.client.refresh();
const win = f.client.victory(42, false), endless = f.client.victory(1000, true), next = f.client.victory(1000, true);
assert.equal(win.periodId, f.periodId); assert.equal(win.clearedMainLevel, 42);
assert.equal(endless.mode, 'endless'); assert(next.endlessSequence > endless.endlessSequence);
f.client.victoryResult(win, { code: 1, data: { goldMiner: { code: 0, errorCode: 'PERIOD_ENDED' } } });
assert.equal(f.client.ledger.events[win.eventId].state, 'PERIOD_ENDED');
assert(!f.calls.some(c => c.path === 'userLevel'));
f.game.uid = 'bob'; const count = f.calls.length;
assert.equal((await f.client.request('claim', { periodId: f.periodId, stage: 0 })).code, 0);
assert.equal(f.calls.length, count);
console.log('PASS: persistent victory identifiers, independent inner result and account isolation.');
// Exercise the actual JSON transport and ordinary coin-save serialization.
const requests = [], user = { uid: 'a', token: 'secret', coin: 1000 };
const fakeCC = { fx: { GameConfig: { GM_INFO: user }, GameTool: { getWechatGameVersion: () => '体验版' },
StorageMessage: { getStorage: () => 'a' } }, loader: { getXMLHttpRequest() {
const req = { headers: {}, open(method, url) { this.url = url; }, setRequestHeader(k, v) { this.headers[k] = v; }, send(body) { this.body = body; requests.push(this); } };
return req;
} } };
const exports = {};
// Isolate the transport/wallet section: an unrelated pre-existing getGacha identifier contains U+200C.
const transportSource = fs.readFileSync('assets/Script/module/Pay/Utils.ts', 'utf8').split(' static getGacha')[0] + '\n}';
const transportCode = ts.transpileModule(transportSource, { compilerOptions: { target: ts.ScriptTarget.ES2017, module: ts.ModuleKind.CommonJS } }).outputText;
new Function('require', 'exports', 'cc', 'wx', transportCode)(() => ({}), exports, fakeCC, {});
const RealUtils = exports.default;
RealUtils.POSTJSON('userLevel', { goldMiner: { eventId: 'event-1' } }, () => {});
assert.equal(requests[0].headers['Content-Type'], 'application/json');
assert.equal(JSON.parse(requests[0].body).goldMiner.eventId, 'event-1');
assert.equal(JSON.parse(requests[0].body).token, 'secret');
RealUtils.POSTJSON('activityConfig/list', {}, () => {}, false);
assert.deepEqual(JSON.parse(requests[1].body), {});
assert(requests[0].url.startsWith(RealUtils.testHttpip));
for (const [status, responseText, reason] of [[200, '', '空内容'], [500, 'error', 'HTTP 500'], [200, '<html>', '非JSON'], [200, 'null', '无效数据']]) {
let result;
RealUtils.POSTJSON('userLevel', {}, value => result = value);
const req = requests[requests.length - 1];
Object.assign(req, { readyState: 4, status, responseText }); req.onreadystatechange();
assert.equal(result.errorCode, 'NETWORK_UNKNOWN');
assert(result.msg.includes(reason)); assert(result.msg.includes('userLevel'));
assert(!result.msg.includes('secret'));
}
let release;
const held = RealUtils.withCoinSave(() => new Promise(resolve => release = resolve));
let savedBalance;
RealUtils.saveUserCoinNow = callback => { savedBalance = user.coin; callback({ code: 1 }); };
RealUtils.setUserCoin(() => {}); await Promise.resolve(); assert.equal(savedBalance, undefined);
user.coin = 1100; release(); await held; await RealUtils.coinSaveTail;
assert.equal(savedBalance, 1100);
console.log('PASS: actual JSON transport preserves nested events; normal wallet writes wait and use latest balance.');
})().catch(error => { console.error(error); process.exitCode = 1; });