// 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, '', '非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; });