From e279afa31d692c1924373d941ec9b8667ee88251 Mon Sep 17 00:00:00 2001 From: guanchao <18612113337@163.com> Date: Thu, 15 Oct 2026 15:37:27 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E9=83=A8=E5=88=86=E6=B4=BB=E5=8A=A8?= =?UTF-8?q?=E6=9C=AA=E4=B8=8A=E6=8A=A5yinli=5FPay?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/Script/BattlePassSettlement.ts | 13 +- assets/Script/JiaZai.ts | 8 + .../Script/coin_madness/GoldMinerService.ts | 160 +++++++------ assets/Script/module/Config/PyramidService.ts | 19 +- assets/Script/module/Pay/Utils.ts | 55 +++++ .../jungle_treasure/script/jungle_Manager.ts | 14 +- tools/coin-madness/check-api.cjs | 7 +- tools/test-payment-reporting.cjs | 217 ++++++++++++++++++ tools/test-pyramid-client.cjs | 6 +- 9 files changed, 423 insertions(+), 76 deletions(-) create mode 100644 tools/test-payment-reporting.cjs diff --git a/assets/Script/BattlePassSettlement.ts b/assets/Script/BattlePassSettlement.ts index b6e0b93d..a22e4d64 100644 --- a/assets/Script/BattlePassSettlement.ts +++ b/assets/Script/BattlePassSettlement.ts @@ -36,6 +36,7 @@ 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; + const { uid, token } = gm; 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. @@ -52,6 +53,11 @@ export default class BattlePassSettlement extends cc.Component { if (this.check.reviewOrders.has(order.outTradeNo)) continue; try { const paid = await Utils.completePassOrder(order.outTradeNo); + if (uid !== cc.fx.GameConfig.GM_INFO.uid || token !== cc.fx.GameConfig.GM_INFO.token) return; + try { + MiniGameSdk.API.yinli_Pay(Number(order.goodsPrice) * Number(order.itemCount || 1), order.outTradeNo, + '补发充值成功' + order.itemid); + } catch (error) { cc.warn('战令补单付费上报失败', error); } if (!this.isCurrentRun()) return; if (!paid.data.alreadyGranted) { if (paid.data.postSeason) this.recoveredRewards.push(...(paid.data.rewards || [])); @@ -259,8 +265,13 @@ export default class BattlePassSettlement extends cc.Component { Utils.getIosPayInfo(this.iosOrder, res => { if (res.code === 1) this.fulfill(this.iosOrder); else this.busy = false; }); } private async fulfill(order: string) { + const { uid, token } = cc.fx.GameConfig.GM_INFO; try { - const result = await Utils.completePassOrder(order); this.iosOrder = ''; cc.fx.GameConfig.GM_INFO.iosPassCheckOrder = null; + const result = await Utils.completePassOrder(order); + if (uid !== cc.fx.GameConfig.GM_INFO.uid || token !== cc.fx.GameConfig.GM_INFO.token) return; + this.iosOrder = ''; cc.fx.GameConfig.GM_INFO.iosPassCheckOrder = null; + try { MiniGameSdk.API.yinli_Pay(1800, order, '购买上期战令:battlepass'); } + catch (error) { cc.warn('上期战令付费上报失败', error); } if (!cc.isValid(this.root)) return; this.playRewards(result.data.rewards, () => { this.busy = false; this.next(); }); } catch (e) { this.busy = false; this.error(e); } diff --git a/assets/Script/JiaZai.ts b/assets/Script/JiaZai.ts index 4a006440..6242c71e 100644 --- a/assets/Script/JiaZai.ts +++ b/assets/Script/JiaZai.ts @@ -2667,6 +2667,7 @@ export default class JiaZai extends cc.Component { const canUpdateHome = () => this.isHomeRuntimeAlive() && !cc.fx.GameConfig.isGameplayEntryPreparing(); //@ts-ignore if ((typeof wx !== 'undefined' && wx !== null) || (typeof tt !== 'undefined' && tt !== null)) { + void Utils.recoverJunglePayments(cc.fx.GameConfig.GM_INFO.allOutTradeNo || []); // 判断设备系统 let systemType = "Android"; try { @@ -2703,7 +2704,14 @@ export default class JiaZai extends cc.Component { } let order = cc.fx.GameConfig.GM_INFO.allOutTradeNo[index]; if (/^battlepass(?:_30|_12)?$/.test(order.itemid)) { + const { uid, token } = cc.fx.GameConfig.GM_INFO; Utils.completePassOrder(order.outTradeNo).then(result => { + if (uid === cc.fx.GameConfig.GM_INFO.uid && token === cc.fx.GameConfig.GM_INFO.token) { + try { + MiniGameSdk.API.yinli_Pay(Number(order.goodsPrice) * Number(order.itemCount || 1), order.outTradeNo, + order.itemid); + } catch (error) { console.warn('战令补单付费上报失败', error); } + } if (canUpdateHome()) { this.updateCoin(); this.checkAndSetPlayerPassLevel(); diff --git a/assets/Script/coin_madness/GoldMinerService.ts b/assets/Script/coin_madness/GoldMinerService.ts index 9264633b..2ecb8ded 100644 --- a/assets/Script/coin_madness/GoldMinerService.ts +++ b/assets/Script/coin_madness/GoldMinerService.ts @@ -1,4 +1,5 @@ import Utils from '../module/Pay/Utils'; +import { MiniGameSdk } from '../Sdk/MiniGameSdk'; declare const wx: any; export interface GoldMinerDailyReminder { key: string; day: number; periodId: string; } @@ -49,13 +50,13 @@ export default class GoldMinerService { || ['purchasable', 'purchase_disabled', 'unlocked'].indexOf(info.status) < 0) return null; const key = 'goldMinerDailyReminder:' + this.scope; const day = Math.floor((now + 8 * 3600000) / 86400000); - try { if (cc.sys.localStorage.getItem(key) === String(day)) return null; } catch (_) {} + try { if (cc.sys.localStorage.getItem(key) === String(day)) return null; } catch (_) { } return { key, day, periodId: info.periodId }; } markDailyReminderShown(reminder: GoldMinerDailyReminder): void { const current = this.dailyReminder(); if (!current || current.key !== reminder.key || current.day !== reminder.day || current.periodId !== reminder.periodId) return; - try { cc.sys.localStorage.setItem(reminder.key, String(reminder.day)); } catch (_) {} + try { cc.sys.localStorage.setItem(reminder.key, String(reminder.day)); } catch (_) { } } private persist() { cc.sys.localStorage.setItem('goldMiner:v1:' + this.scope, JSON.stringify(this.ledger)); } private id(prefix: string) { return prefix + '-' + Date.now() + '-' + Math.random().toString(36).slice(2, 12); } @@ -75,8 +76,10 @@ export default class GoldMinerService { this.iosPaymentLog('request exception', { url, code: error.code, errCode: error.errCode, message: error.message }); throw error; } - this.iosPaymentLog('response', { url, action: body && body.action, durationMs: Date.now() - startedAt, - code: response && response.code, errorCode: response && response.errorCode, response }); + this.iosPaymentLog('response', { + url, action: body && body.action, durationMs: Date.now() - startedAt, + code: response && response.code, errorCode: response && response.errorCode, response + }); if (!this.active()) throw new Error('账号已切换,请重新打开活动'); if (!response || response.code !== 1) { const error: any = new Error(response && (response.msg || response.message || response.errorCode) || '网络请求失败,请重试'); @@ -126,17 +129,23 @@ export default class GoldMinerService { view(granted = 0): any { const info = this.info; if (!info) return null; - const stages = info.tasks.map(task => ({ taskId: task.taskId, wins: task.targetWins, + const stages = info.tasks.map(task => ({ + taskId: task.taskId, wins: task.targetWins, coins: task.itemsSnapshot.filter(item => item.type === 'coin').reduce((sum, item) => sum + item.count, 0), - claimStatus: task.claimStatus })); + claimStatus: task.claimStatus + })); const claimed = info.tasks.map(task => task.claimStatus === 'claimed'); const next = claimed.indexOf(false); - return { serverNow: Date.now() + this.offset, granted, status: info.status, state: { - config: { periodId: info.periodId, startsAt: info.startsAt, endsAt: info.endsAt, - priceCents: info.priceFen, stages }, - totalWins: info.progressWins || 0, paidAt: info.status === 'unlocked' ? 1 : 0, - claimed, stage: next < 0 ? stages.length : next, settled: false, - } }; + return { + serverNow: Date.now() + this.offset, granted, status: info.status, state: { + config: { + periodId: info.periodId, startsAt: info.startsAt, endsAt: info.endsAt, + priceCents: info.priceFen, stages + }, + totalWins: info.progressWins || 0, paidAt: info.status === 'unlocked' ? 1 : 0, + claimed, stage: next < 0 ? stages.length : next, settled: false, + } + }; } get settlementNotice() { return Object.keys(this.ledger.notices).reduce((sum, id) => sum + this.ledger.notices[id], 0); } dismissNotice() { this.ledger.notices = {}; this.persist(); } @@ -243,12 +252,14 @@ export default class GoldMinerService { } } private friendly(error: any) { - const messages = { PAYMENT_PENDING: '支付结果确认中,请稍后刷新', + const messages = { + PAYMENT_PENDING: '支付结果确认中,请稍后刷新', NOT_PAID: '请先解锁活动', PERIOD_ENDED: '本期活动已结束', PERIOD_SETTLEMENT_REQUIRED: '本期已结束,奖励将通过补发到账', PURCHASE_DISABLED: '本期暂不可购买', PAYMENT_CHANNEL_CONFLICT: '请使用本期订单原有的支付渠道', UNAUTHORIZED: '登录已失效,请重新进入游戏', TARGET_NOT_REACHED: '通关目标尚未达成', ORDER_CLOSED: '订单已关闭,请联系客服处理', MANUAL_REVIEW: '订单需要核对,请联系客服,勿重复付款', - PAYMENT_CANCELLED: '已取消支付,再次点击解锁将重新下单' }; + PAYMENT_CANCELLED: '已取消支付,再次点击解锁将重新下单' + }; return messages[error.code] || error.message || '活动请求失败,请稍后重试'; } private async purchase(periodId: string, fallbackToCustomerService = false) { @@ -257,13 +268,18 @@ export default class GoldMinerService { if (!Number.isSafeInteger(this.info.priceFen) || this.info.priceFen <= 0) throw new Error('活动价格异常,请刷新活动'); // Each explicit purchase starts a fresh order request, regardless of the previous attempt. const ios = wx.getSystemInfoSync().platform === 'ios'; - const order = { createRequestId: this.id('buy'), - channel: ios && (fallbackToCustomerService || !cc.fx.GameConfig.GM_INFO.iosCanPay) ? 'legacy_ios' : 'native', ios } as any; + const order = { + createRequestId: this.id('buy'), + price: this.info.priceFen, productId: this.info.productId, + channel: ios && (fallbackToCustomerService || !cc.fx.GameConfig.GM_INFO.iosCanPay) ? 'legacy_ios' : 'native', ios + } as any; this.iosPaymentLog('purchase route', { periodId, ...order, iosCanPay: cc.fx.GameConfig.GM_INFO.iosCanPay, fallbackToCustomerService }); // Keep the native order recoverable until the server accepts the channel switch. if (!fallbackToCustomerService) { this.ledger.orders[periodId] = order; this.persist(); } - const body = { itemid: this.info.productId, createRequestId: order.createRequestId, - itemCount: 1, itemPrice: this.info.priceFen }; + const body = { + itemid: this.info.productId, createRequestId: order.createRequestId, + itemCount: 1, itemPrice: this.info.priceFen + }; let payment; try { payment = order.channel === 'legacy_ios' @@ -286,55 +302,61 @@ export default class GoldMinerService { } const ticket = ++this.polling; order.paymentState = 'pending'; this.persist(); - try { await new Promise((resolve, reject) => { - let done = false; - const api = order.channel === 'legacy_ios' ? 'openCustomerServiceConversation' : 'requestMidasPaymentGameItem'; - this.iosPaymentLog('wx call', { api, outTradeNo: order.outTradeNo }); - const finish = (ok: boolean, detail: any = {}) => { - if (done) return; done = true; clearTimeout(timer); - this.iosPaymentLog('wx callback', { api, outTradeNo: order.outTradeNo, success: ok, errCode: detail.errCode, response: detail }); - if (ok) resolve(); - else { - const cancelled = /cancel/i.test(detail.errMsg || ''); - order.paymentState = cancelled ? 'cancelled' : 'failed'; this.persist(); - const error: any = new Error('微信支付未拉起或未完成:' + (detail.errMsg || detail.message || '未知错误') - + (detail.errCode != null ? '(' + detail.errCode + ')' : '')); - error.errCode = detail.errCode; - if (Number(detail.errCode) === 2860 || /PRODUCT ID NOT EXIST OR NOT PUBLISH/i.test(detail.errMsg || '')) { - error.message = '微信支付商品不存在或未发布(2860)。请核对微信支付后台的商品 ID、发布状态及支付环境。'; - if (this.testAvailable()) { - error.message += '\n活动商品 ID:' + String(body.itemid); - try { - const signed = JSON.parse(payment.signData); - error.message += '\n签名商品 ID:' + String(signed.productId) - + '\n支付环境 env:' + String(signed.env) - + '\n支付应用 offerId:' + String(signed.offerId) - + '\n分区 zoneId:' + String(signed.zoneId); - } catch (_) { /* Keep the original payment payload untouched. */ } + try { + await new Promise((resolve, reject) => { + let done = false; + const api = order.channel === 'legacy_ios' ? 'openCustomerServiceConversation' : 'requestMidasPaymentGameItem'; + this.iosPaymentLog('wx call', { api, outTradeNo: order.outTradeNo }); + const finish = (ok: boolean, detail: any = {}) => { + if (done) return; done = true; clearTimeout(timer); + this.iosPaymentLog('wx callback', { api, outTradeNo: order.outTradeNo, success: ok, errCode: detail.errCode, response: detail }); + if (ok) resolve(); + else { + const cancelled = /cancel/i.test(detail.errMsg || ''); + order.paymentState = cancelled ? 'cancelled' : 'failed'; this.persist(); + const error: any = new Error('微信支付未拉起或未完成:' + (detail.errMsg || detail.message || '未知错误') + + (detail.errCode != null ? '(' + detail.errCode + ')' : '')); + error.errCode = detail.errCode; + if (Number(detail.errCode) === 2860 || /PRODUCT ID NOT EXIST OR NOT PUBLISH/i.test(detail.errMsg || '')) { + error.message = '微信支付商品不存在或未发布(2860)。请核对微信支付后台的商品 ID、发布状态及支付环境。'; + if (this.testAvailable()) { + error.message += '\n活动商品 ID:' + String(body.itemid); + try { + const signed = JSON.parse(payment.signData); + error.message += '\n签名商品 ID:' + String(signed.productId) + + '\n支付环境 env:' + String(signed.env) + + '\n支付应用 offerId:' + String(signed.offerId) + + '\n分区 zoneId:' + String(signed.zoneId); + } catch (_) { /* Keep the original payment payload untouched. */ } + } } + if (cancelled) error.code = 'PAYMENT_CANCELLED'; + reject(error); } - if (cancelled) error.code = 'PAYMENT_CANCELLED'; - reject(error); - } - }; - const timer = setTimeout(() => { - if (done) return; done = true; - this.iosPaymentLog('wx timeout', { api, outTradeNo: order.outTradeNo, timeoutMs: 120000 }); - const error: any = new Error('支付结果确认中,请稍后刷新'); error.paymentPending = true; reject(error); - }, 120000); - const fail = (error: any) => finish(false, error || {}); - try { if (order.channel === 'legacy_ios') { - Utils.GoKEFu({ price: this.info.priceFen, payment_name: this.info.productId, payment_count: 1 }, - (result, detail) => result === 'success' ? finish(true, { result }) : fail(detail || { errMsg: '客服会话打开失败' }), - outTradeNo => { - order.outTradeNo = outTradeNo; this.persist(); - this.iosPaymentLog('customer order created', { outTradeNo, productId: this.info.productId, priceFen: this.info.priceFen }); - }); - } else { - wx.requestMidasPaymentGameItem({ signData: payment.signData, signature: payment.signature, paySig: payment.paySig, - success: res => finish(true, res), fail }); - } } catch (error) { fail(error); } - }); } catch (error) { + }; + const timer = setTimeout(() => { + if (done) return; done = true; + this.iosPaymentLog('wx timeout', { api, outTradeNo: order.outTradeNo, timeoutMs: 120000 }); + const error: any = new Error('支付结果确认中,请稍后刷新'); error.paymentPending = true; reject(error); + }, 120000); + const fail = (error: any) => finish(false, error || {}); + try { + if (order.channel === 'legacy_ios') { + Utils.GoKEFu({ price: this.info.priceFen, payment_name: this.info.productId, payment_count: 1 }, + (result, detail) => result === 'success' ? finish(true, { result }) : fail(detail || { errMsg: '客服会话打开失败' }), + outTradeNo => { + order.outTradeNo = outTradeNo; this.persist(); + this.iosPaymentLog('customer order created', { outTradeNo, productId: this.info.productId, priceFen: this.info.priceFen }); + }); + } else { + wx.requestMidasPaymentGameItem({ + signData: payment.signData, signature: payment.signature, paySig: payment.paySig, + success: res => finish(true, res), fail + }); + } + } catch (error) { fail(error); } + }); + } catch (error) { if (ios && order.channel === 'native' && Number(error.errCode) === 16 && error.code !== 'PAYMENT_CANCELLED') { this.iosPaymentLog('fallback to legacy_ios', { errCode: error.errCode, message: error.message, outTradeNo: order.outTradeNo }); @@ -361,6 +383,14 @@ export default class GoldMinerService { // Only the documented PAYMENT_PENDING response is eligible for rapid retries. throw error; } + if (!order.done) { + try { + const price = Number(data.goodsPrice ?? order.price) * Number(data.itemCount ?? 1); + const productId = data.itemid || order.productId; + if (!Number.isSafeInteger(price) || price <= 0 || !productId) throw new Error('订单上报信息缺失'); + MiniGameSdk.API.yinli_Pay(price, order.outTradeNo, productId); + } catch (error) { console.warn('金币狂热付费上报失败', error); } + } order.done = true; order.route = data.goldMiner.fulfillmentRoute; order.targetPeriodId = data.goldMiner.targetPeriodId; this.persist(); this.message = order.route === 'carry_next' ? '购买资格将在 ' + order.targetPeriodId.replace('goldMiner:', '') + ' 开始的一期生效' : '活动权益已确认'; diff --git a/assets/Script/module/Config/PyramidService.ts b/assets/Script/module/Config/PyramidService.ts index 829c3139..c33785eb 100644 --- a/assets/Script/module/Config/PyramidService.ts +++ b/assets/Script/module/Config/PyramidService.ts @@ -1,6 +1,7 @@ import Utils from "../Pay/Utils"; import ActivityScheduleService from "./ActivityScheduleService"; import ActivityBundleLoader from "./ActivityBundleLoader"; +import { MiniGameSdk } from "../../Sdk/MiniGameSdk"; const FIELDS: { [id: string]: string } = { "1001": "coinAmount", "2001": "freezeAmount", "2002": "hammerAmount", "2003": "magicAmount", "2005": "userPowerTime" }; const LOCAL: { [field: string]: string } = { coinAmount: "coin", freezeAmount: "freezeAmount", hammerAmount: "hammerAmount", magicAmount: "magicAmount", userPowerTime: "userPowerTime" }; @@ -192,6 +193,14 @@ export default class PyramidService { cc.warn("[Pyramid] 支付结果查询失败,继续重试", error); } if (result && (result.paymentStatus === "succeeded" || result.paymentStatus === "closed")) { + if (result.paymentStatus === "succeeded") { + try { + const price = Number(result.goodsPrice ?? pending.price) * Number(result.itemCount ?? 1); + const productId = result.itemid || pending.productId; + if (!Number.isSafeInteger(price) || price <= 0 || !productId) throw new Error("订单上报信息缺失"); + MiniGameSdk.API.yinli_Pay(price, pending.orderId, productId); + } catch (error) { cc.warn("[Pyramid] 付费上报失败", error); } + } delete this.journal.confirmingOrder; this.paymentRetryBlocked = false; this.save(); await this.refresh(); return; } if (attempt < this.paymentPollDelays.length) @@ -213,7 +222,10 @@ export default class PyramidService { } private static async openIosCustomerPayment(platform: any, state: any, level: any, generation: number, orderId: string): Promise { - const pending = { orderId, periodId: state.periodId, level: state.currentLevel, channel: "ios_customer" }; + const pending = { + orderId, periodId: state.periodId, level: state.currentLevel, channel: "ios_customer", + price: level.itemPrice, productId: level.itemId + }; this.journal.confirmingOrder = pending; this.save(); this.emit(); const opened: any = await new Promise(resolve => Utils.GoKEFu({ outTradeNo: orderId, price: level.itemPrice, payment_name: level.itemId, payment_count: 1, @@ -260,7 +272,10 @@ export default class PyramidService { // if (result.paymentFailed) { platform.showToast({ title: "取消充值", icon: "none" }); return; } if (result.err) throw Object.assign(new Error(result.err), result); // Only the SDK success callback creates durable payment confirmation work. - this.journal.confirmingOrder = { orderId: result.outTradeNo, periodId: state.periodId, level: state.currentLevel }; + this.journal.confirmingOrder = { + orderId: result.outTradeNo, periodId: state.periodId, level: state.currentLevel, + price: level.itemPrice, productId: level.itemId + }; this.paymentRetryBlocked = false; this.save(); this.emit(); await this.confirmPayment(true); }); } diff --git a/assets/Script/module/Pay/Utils.ts b/assets/Script/module/Pay/Utils.ts index 15ea4ee4..1933bb33 100644 --- a/assets/Script/module/Pay/Utils.ts +++ b/assets/Script/module/Pay/Utils.ts @@ -260,6 +260,7 @@ export default class Utils { // callBack("请求支付超时"); // }, timeoutDuration); const orderNumber = res.data.outTradeNo; + if (/^jungle_treasure_[1-8]$/.test(id)) Utils.rememberJunglePayment(orderNumber, id, price * count, false); try { //@ts-ignore wx.requestMidasPaymentGameItem({ @@ -424,6 +425,7 @@ export default class Utils { else if (/^jungle_treasure_\d+$/.test(String(iosPayInfo.payment_name || ""))) { cc.fx.GameConfig.GM_INFO.iosJungleOrder = null; cc.fx.GameConfig.GM_INFO.iosJungleOrder = order; + Utils.rememberJunglePayment(order, iosPayInfo.payment_name, iosPayInfo.price * iosPayInfo.payment_count, true); } else if (/^gold_miner(?:_\d+)?$/.test(String(iosPayInfo.payment_name || ""))) { // The activity persists its order separately; do not overwrite shop orders. @@ -473,6 +475,59 @@ export default class Utils { }); } + private static jungleRecovery: Promise = null; + static junglePaymentKey(): string { + return "junglePayments:v1:" + this.apiBaseUrl() + ":" + String(cc.fx.GameConfig.GM_INFO.uid || ""); + } + static rememberJunglePayment(order: string, productId: string, price: number, ios: boolean): void { + const key = this.junglePaymentKey(), orders = cc.fx.StorageMessage.getStorage(key) || {}; + orders[order] = { productId, price, ios }; + cc.fx.StorageMessage.setStorage(key, orders); + } + static forgetJunglePayment(order: string): void { + const key = this.junglePaymentKey(), orders = cc.fx.StorageMessage.getStorage(key) || {}; + delete orders[order]; cc.fx.StorageMessage.setStorage(key, orders); + } + /** Login recovery only; the activity still reports its normal payment callbacks. */ + static recoverJunglePayments(loginOrders: any[]): Promise { + if (this.jungleRecovery) return this.jungleRecovery; + const key = this.junglePaymentKey(), uid = cc.fx.GameConfig.GM_INFO.uid, token = cc.fx.GameConfig.GM_INFO.token; + for (const order of loginOrders) { + if (/^jungle_treasure_[1-8]$/.test(order.itemid)) + this.rememberJunglePayment(order.outTradeNo, order.itemid, Number(order.goodsPrice) * Number(order.itemCount || 1), order.type === "ios"); + } + const orders = cc.fx.StorageMessage.getStorage(key) || {}; + const current = () => key === this.junglePaymentKey() && token === cc.fx.GameConfig.GM_INFO.token; + this.jungleRecovery = (async () => { + for (const order of Object.keys(orders)) { + if (!current()) return; + try { + const payment = orders[order]; + const query = (route: string): Promise => new Promise(resolve => this.POST(route, { uid, outTradeNo: order }, resolve)); + let response = await query("wx/getPayInfo"); + if (!current()) return; + let paid = response?.code === 1 && response.data?.pay_state === 2; + if (!paid && payment.ios) { + response = await query("wx/iosgetPayInfo"); + if (!current()) return; + paid = response?.code === 1; + } + if (!paid) continue; + const completed: any = await new Promise(resolve => this.setPayInfo(resolve, order)); + if (!current()) return; + // The activity may have reported while the query was in flight. + if (completed?.code !== 1 || !(cc.fx.StorageMessage.getStorage(key) || {})[order]) continue; + if (!Number.isSafeInteger(payment.price) || payment.price <= 0) throw new Error("订单金额缺失"); + MiniGameSdk.API.yinli_Pay(payment.price, order, "补发充值成功" + payment.productId); + this.forgetJunglePayment(order); + } catch (error) { console.warn("丛林宝藏付费补报待重试", order, error); } + } + })().then(() => { this.jungleRecovery = null; }, error => { + this.jungleRecovery = null; console.warn("丛林宝藏付费补报待重试", error); + }); + return this.jungleRecovery; + } + //#region ios获取支付结果 /** ios获取支付结果 */ static getIosPayInfo(order, callBack) { diff --git a/assets/jungle_treasure/script/jungle_Manager.ts b/assets/jungle_treasure/script/jungle_Manager.ts index 933fabc7..b0b992d2 100644 --- a/assets/jungle_treasure/script/jungle_Manager.ts +++ b/assets/jungle_treasure/script/jungle_Manager.ts @@ -587,7 +587,12 @@ export default class JungleManager extends cc.Component { return; } const name = "购买丛林宝藏礼包:" + tier.productId; - MiniGameSdk.API.yinli_Pay(this.pendingPaymentPrice, orderNo, name); + if ((cc.fx.StorageMessage.getStorage(Utils.junglePaymentKey()) || {})[orderNo]) { + try { + MiniGameSdk.API.yinli_Pay(this.pendingPaymentPrice, orderNo, name); + Utils.forgetJunglePayment(orderNo); + } catch (error) { cc.warn("丛林宝藏付费上报失败", error); } + } this.refreshAfterPayment(tier.id); } @@ -690,7 +695,12 @@ export default class JungleManager extends cc.Component { if (response && response.code === 1) { gameInfo.iosJungleOrder = ""; const name = "购买丛林宝藏礼包:" + productId; - MiniGameSdk.API.yinli_Pay(price, orderNo, name); + if ((cc.fx.StorageMessage.getStorage(Utils.junglePaymentKey()) || {})[orderNo]) { + try { + MiniGameSdk.API.yinli_Pay(price, orderNo, name); + Utils.forgetJunglePayment(orderNo); + } catch (error) { cc.warn("丛林宝藏付费上报失败", error); } + } this.refreshAfterPayment(this.resolvePendingTierId(productId)); return; } diff --git a/tools/coin-madness/check-api.cjs b/tools/coin-madness/check-api.cjs index c9528420..c8f43cf8 100644 --- a/tools/coin-madness/check-api.cjs +++ b/tools/coin-madness/check-api.cjs @@ -7,7 +7,7 @@ const compile = file => ts.transpileModule(fs.readFileSync(file, 'utf8'), { }).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 calls = [], toasts = [], payments = [], 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, @@ -63,8 +63,9 @@ function fixture(storage = new Map(), server = {}) { 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, + new Function('require', 'exports', 'cc', 'wx', 'CC_PREVIEW', code)(() => ({ default: Utils, + MiniGameSdk: { API: { yinli_Pay: (...args) => payments.push(args) } } }), exports, cc, wx, false); + return { client: exports.default.current(), cc, game, info, server, calls, toasts, payments, storage, claimed, periodId, counts: () => ({ saves, confirms, payCalls }) }; } module.exports = { fixture }; diff --git a/tools/test-payment-reporting.cjs b/tools/test-payment-reporting.cjs new file mode 100644 index 00000000..98d2be35 --- /dev/null +++ b/tools/test-payment-reporting.cjs @@ -0,0 +1,217 @@ +// In-memory payment responses only: no network, real payments or analytics sends. +const test = require('node:test'), assert = require('node:assert/strict'); +const fs = require('node:fs'), path = require('node:path'), vm = require('node:vm'), ts = require('typescript'); +const quiet = { log() {}, warn() {}, error() {} }; +const compile = source => ts.transpileModule(source, { compilerOptions: { target: 7, module: 1, experimentalDecorators: true } }).outputText; +function load(file, dependencies, globals = {}) { + const context = { exports: {}, require: name => dependencies[name] || { default: {} }, console: quiet, ...globals }; + vm.runInNewContext(compile(fs.readFileSync(file, 'utf8')), context); + return context.exports.default; +} +function methods(file, names) { + const source = ts.createSourceFile(file, fs.readFileSync(file, 'utf8'), 99, true); + return source.statements.find(ts.isClassDeclaration).members + .filter(m => m.name && names.includes(m.name.getText(source))).map(m => m.getText(source)).join('\n'); +} +const pyramidContext = { require, __dirname, structuredClone, queueMicrotask, setTimeout, clearTimeout, console: quiet }; +vm.runInNewContext(fs.readFileSync(path.join(__dirname, 'test-pyramid-client.cjs'), 'utf8') + .split("test('collect_pyramid reports")[0] + ';globalThis.makeFixture = fixture;', pyramidContext); + +for (const channel of ['success', 'ios-success', 'fallback']) { + for (const scenario of ['immediate', 'retry', 'restart']) test(`Pyramid ${channel}: ${scenario} reports original order once`, async () => { + const f = pyramidContext.makeFixture(); + f.state.canPay = true; f.state.canDraw = false; f.mode(channel); await f.S.sync(); + if (scenario === 'immediate') f.paid(); + await f.S.buy(); + let service = f.S; + if (scenario !== 'immediate') { + assert.equal(f.payments.length, 0); assert.ok(service.data().pendingOrder); + f.state.levels[0].itemPrice = 9900; // A refreshed offer cannot change the old order's amount. + f.paid(); if (scenario === 'restart') service = f.restart(); await service.sync(true); + } + assert.equal(service.data().pendingOrder, null); assert.equal(f.payments.length, 1); + assert.deepEqual(Array.from(f.payments[0]), [100, f.calls.find(c => c.outTradeNo).outTradeNo, '购买金字塔:pay_test']); + await service.sync(true); await f.restart().sync(true); assert.equal(f.payments.length, 1); + }); +} +test('Pyramid closed, cancelled and unconfirmed orders do not report', async () => { + for (const mode of ['cancel', 'success']) { + const f = pyramidContext.makeFixture(); f.state.canPay = true; f.mode(mode); await f.S.sync(); await f.S.buy(); + assert.equal(f.payments.length, 0); + const post = f.U.POST; + f.U.POST = (route, body, cb, options) => body.action === 'query' + ? cb({ code: 1, data: { paymentStatus: 'closed' } }) : post.call(f.U, route, body, cb, options); + await f.S.sync(true); assert.equal(f.payments.length, 0); + } +}); +test('Pyramid legacy pending record uses server order metadata after period change', async () => { + const f = pyramidContext.makeFixture(); + f.storage.set('pyramid:v1:local/:u', { confirmingOrder: { orderId: 'old', periodId: 'ended', level: 2 } }); + const post = f.U.POST; + f.U.POST = (route, body, cb, options) => body.action === 'query' + ? cb({ code: 1, data: { paymentStatus: 'succeeded', goodsPrice: '1800', itemCount: 1, itemid: 'old-product' } }) + : post.call(f.U, route, body, cb, options); + await f.S.sync(); assert.deepEqual(Array.from(f.payments[0]), [1800, 'old', '购买金字塔:old-product']); +}); + +const { fixture: goldFixture } = require('./coin-madness/check-api.cjs'); +for (const channel of ['android', 'ios', 'customer']) test(`GoldMiner ${channel} reports purchase and not a later refresh`, async () => { + const f = goldFixture(new Map(), { ios: channel !== 'android' }); + f.client.iosPaymentLog = () => {}; + if (channel === 'customer') f.game.iosCanPay = false; + f.info.status = 'purchasable'; + assert.equal((await f.client.request('purchase', { periodId: f.periodId })).code, 1); + assert.equal(f.payments.length, 1); assert.equal(f.payments[0][0], 300); + assert.equal(f.payments[0][1], channel === 'customer' ? 'legacy-1' : 'order-1'); + await f.client.refresh(); assert.equal(f.payments.length, 1); +}); +test('GoldMiner restart reports legacy order from server price, not current activity price', async () => { + const storage = new Map(), first = goldFixture(storage); + first.client.ledger.orders.old = { outTradeNo: 'old-order', ios: false }; + first.client.persist(); + const f = goldFixture(storage, { intercept(route, body, ok) { + if (route.includes('getPayInfo')) { + ok({ pay_state: 2, goodsPrice: 1800, itemCount: 1, itemid: 'gold_miner_old', + rewardDelivery: 'goldMiner.claim', goldMiner: { fulfillmentRoute: 'carry_next', targetPeriodId: 'goldMiner:next' } }); + return true; + } + } }); + f.info.priceFen = 9900; await f.client.refresh(); + assert.deepEqual(f.payments, [[1800, 'old-order', '购买金币狂热:gold_miner_old']]); +}); +test('GoldMiner cancellation and pending confirmation do not report', async () => { + const cancelled = goldFixture(new Map(), { cancelPay: true }); cancelled.info.status = 'purchasable'; + await cancelled.client.request('purchase', { periodId: cancelled.periodId }); assert.equal(cancelled.payments.length, 0); + const pending = goldFixture(new Map(), { intercept(route, body, ok, fail) { + if (route.includes('getPayInfo')) { fail('PAYMENT_PENDING'); return true; } + } }); + await assert.rejects(pending.client.queryOrder({ outTradeNo: 'pending', ios: false })); + assert.equal(pending.payments.length, 0); +}); + +function passFixture(platform = 'android', direct = true) { + const payments = [], delivered = [], gm = { uid: 'u', iosCanPay: direct, allOutTradeNo: [] }; + const check = { hasSchedule: () => true, reviewOrders: new Set(), syncClock() {} }; + const utils = { + buyProp(a, b, c, d, e, cb) { cb({ outTradeNo: 'pass-order' }); }, + getPayInfo(cb) { cb({ code: 1, data: { pay_state: 2 } }); }, + GoKEFu(a, cb, created) { created('pass-order'); cb('success'); }, + getIosPayInfo(a, cb) { cb({ code: 1 }); }, + async completePassOrder(order) { delivered.push(order); return { data: { rewards: [] } }; }, + async settlementRequest() { return { data: { serverTime: Date.now(), rows: [] } }; } + }; + const sdk = { API: { yinli_Pay: (...args) => payments.push(args) } }; + const View = load('assets/Script/BattlePassSettlement.ts', { + './module/Pay/Utils': { default: utils }, './Sdk/MiniGameSdk': { MiniGameSdk: sdk }, + './module/Config/BattlePassSettlementCheck': { default: { forPlayer: () => check } }, + './module/Config/BattlePassSettlementModel': { settlementSteps: rows => rows } + }, { cc: { _decorator: { ccclass: T => T }, Component: class {}, isValid: () => true, + warn() {}, fx: { GameConfig: { GM_INFO: gm } } }, wx: { getSystemInfoSync: () => ({ platform }) } }); + const view = new View(); view.root = {}; view.row = { offer: 'shown', end: 1 }; + view.playRewards = (r, cb) => cb(); view.next = () => {}; view.finish = () => {}; view.isCurrentRun = () => true; + return { view, utils, payments, delivered, gm, sdk }; +} +for (const [platform, direct] of [['android', true], ['ios', true], ['ios', false]]) { + test(`post-season pass ${platform}/${direct} reports the paid order`, async () => { + const f = passFixture(platform, direct); await f.view.purchase(); if (!direct) f.view.checkIos(); + await new Promise(setImmediate); + assert.deepEqual(f.delivered, ['pass-order']); + assert.deepEqual(f.payments, [[1800, 'pass-order', '购买上期战令:battlepass']]); + }); +} +for (const [itemid, price] of [['battlepass', 1800], ['battlepass_30', 3000], ['battlepass_12', 1200]]) { + test(`settlement recovery reports ${itemid} with its actual amount`, async () => { + const f = passFixture(); f.gm.allOutTradeNo = [{ outTradeNo: 'recovered', itemid, goodsPrice: String(price), itemCount: 1 }]; + await f.view.run(() => {}); + assert.deepEqual(f.payments, [[price, 'recovered', '补发充值成功' + itemid]]); assert.equal(f.gm.allOutTradeNo.length, 0); + }); + test(`home recovery reports ${itemid} after fulfillment`, async () => { + const f = passFixture(); f.gm.allOutTradeNo = [{ outTradeNo: 'recovered', itemid, goodsPrice: String(price) }]; + Object.assign(f.utils, { recoverJunglePayments() {}, getShopDouble(cb) { cb({ code: 1, data: { shopDouble: {} } }); } }); + const context = { exports: {}, Utils: f.utils, MiniGameSdk: f.sdk, console: quiet, + HomePopupQueue: { get: () => ({ has: () => false }) }, wx: { getSystemInfoSync: () => ({ platform: 'android' }) }, + cc: { sys: { platform: 'wx', WECHAT_GAME: 'wx' }, fx: { GameConfig: { GM_INFO: f.gm, isGameplayEntryPreparing: () => false } } } }; + vm.runInNewContext(compile('class Home {' + methods('assets/Script/JiaZai.ts', ['getOrder']) + '};exports.Home=Home'), context); + const home = new context.exports.Home(); home.deferHomePopupWhileTransfer = () => false; home.isHomeRuntimeAlive = () => false; + home.getOrder(); await new Promise(setImmediate); + assert.deepEqual(f.payments, [[price, 'recovered', '补发充值成功' + itemid]]); + }); +} +test('SDK failure cannot block post-season rewards', async () => { + const f = passFixture(); let rewards = 0; f.view.playRewards = () => rewards++; + f.sdk.API.yinli_Pay = () => { throw Error('SDK offline'); }; await f.view.fulfill('paid'); assert.equal(rewards, 1); +}); +test('post-season pending payment, failed fulfillment and stale-account results do not report', async () => { + const pending = passFixture(); pending.utils.getPayInfo = cb => cb({ code: 0, data: { pay_state: -1 } }); + pending.view.error = () => {}; await pending.view.purchase(); assert.equal(pending.payments.length, 0); + const failed = passFixture(); failed.utils.completePassOrder = async () => { throw Error('network'); }; + failed.view.error = () => {}; await failed.view.fulfill('failed'); assert.equal(failed.payments.length, 0); + const stale = passFixture(); let resolve; stale.utils.completePassOrder = () => new Promise(done => resolve = done); + const completion = stale.view.fulfill('old-account'); stale.gm.uid = 'other'; resolve({ data: { rewards: [] } }); + await completion; assert.equal(stale.payments.length, 0); +}); + +function jungleFixture(storage = new Map()) { + const payments = [], calls = [], game = { uid: 'u', token: 't' }; + const cc = { warn() {}, fx: { GameConfig: { GM_INFO: game }, GameTool: { shushu_Track() {} }, StorageMessage: { + getStorage: key => structuredClone(storage.get(key)), setStorage: (key, value) => storage.set(key, structuredClone(value)) + } } }; + const sdk = { API: { yinli_Pay: (...args) => payments.push(args) } }; + const context = { exports: {}, cc, MiniGameSdk: sdk, console: quiet, + wx: { requestMidasPaymentGameItem() {}, openCustomerServiceConversation() {} } }; + const names = ['junglePaymentKey', 'rememberJunglePayment', 'forgetJunglePayment', 'recoverJunglePayments', 'buyProp', 'GoKEFu']; + vm.runInNewContext(compile('class Utils {static apiBaseUrl(){return "test/";} static createPaymentOrderNo(){return "customer-order";}' + + methods('assets/Script/module/Pay/Utils.ts', names) + '};exports.Utils=Utils;'), context); + const U = context.exports.Utils; + U.POST = (route, body, cb) => { calls.push({ route, body }); cb({ code: 1, data: route.includes('orderPaySig') + ? { outTradeNo: 'direct-order', signData: 's', signature: 's', paySig: 's' } : { pay_state: 2 } }); }; + U.setPayInfo = (cb, order) => { calls.push({ route: 'complete', order }); cb({ code: 1 }); }; + return { U, payments, calls, storage, game, cc, sdk }; +} +test('Jungle journals direct and customer orders before opening payment, then recovers on restart', async () => { + const first = jungleFixture(); + first.U.buyProp('jungle_treasure_1', 1, 600, 'android', 'jungle_treasure_1', () => {}); + first.U.GoKEFu({ payment_name: 'jungle_treasure_2', price: 1200, payment_count: 1 }, () => {}); + assert.equal(Object.keys(first.storage.get(first.U.junglePaymentKey())).length, 2); assert.equal(first.payments.length, 0); + const f = jungleFixture(first.storage); await f.U.recoverJunglePayments([]); + assert.deepEqual(f.payments, [[600, 'direct-order', '补发充值成功jungle_treasure_1'], [1200, 'customer-order', '补发充值成功jungle_treasure_2']]); + await f.U.recoverJunglePayments([]); assert.equal(f.payments.length, 2); +}); +test('Jungle legacy login order is reported and acknowledged without granting shop rewards', async () => { + const f = jungleFixture(); await f.U.recoverJunglePayments([{ outTradeNo: 'old', itemid: 'jungle_treasure_3', goodsPrice: '1800', type: 'ios' }]); + assert.deepEqual(f.payments, [[1800, 'old', '补发充值成功jungle_treasure_3']]); + assert.equal(f.calls.filter(c => c.route === 'complete').length, 1); +}); +test('Jungle pending orders are retained; customer confirmation can report on a later login', async () => { + const f = jungleFixture(); f.U.rememberJunglePayment('pending', 'jungle_treasure_1', 600, true); + f.U.POST = (route, body, cb) => cb({ code: 0 }); await f.U.recoverJunglePayments([]); + assert.equal(f.payments.length, 0); assert.ok(f.storage.get(f.U.junglePaymentKey()).pending); + f.U.POST = (route, body, cb) => cb({ code: route === 'wx/iosgetPayInfo' ? 1 : 0 }); + await f.U.recoverJunglePayments([]); assert.equal(f.payments.length, 1); +}); +test('Jungle failed acknowledgment and SDK exception leave the order available for retry', async () => { + const f = jungleFixture(); f.U.rememberJunglePayment('paid', 'jungle_treasure_1', 600, false); + f.U.setPayInfo = cb => cb({ code: 0 }); await f.U.recoverJunglePayments([]); assert.equal(f.payments.length, 0); + f.U.setPayInfo = cb => cb({ code: 1 }); const report = f.sdk.API.yinli_Pay; + f.sdk.API.yinli_Pay = () => { throw Error('offline'); }; await f.U.recoverJunglePayments([]); + assert.ok(f.storage.get(f.U.junglePaymentKey()).paid); + f.sdk.API.yinli_Pay = report; await f.U.recoverJunglePayments([]); assert.equal(f.payments.length, 1); +}); +test('Jungle recovery ignores stale-account replies and callbacks already reported by the activity', async () => { + for (const switchAccount of [true, false]) { + const f = jungleFixture(); f.U.rememberJunglePayment('paid', 'jungle_treasure_1', 600, false); let reply; + f.U.POST = (route, body, cb) => reply = cb; const pending = f.U.recoverJunglePayments([]); + if (switchAccount) f.game.uid = 'another'; else f.U.forgetJunglePayment('paid'); + reply({ code: 1, data: { pay_state: 2 } }); await pending; assert.equal(f.payments.length, 0); + } +}); +test('Jungle normal callback clears the journal and does not re-report an order completed by recovery', async () => { + const f = jungleFixture(); + const context = { exports: {}, Utils: f.U, MiniGameSdk: f.sdk, cc: f.cc }; + vm.runInNewContext(compile('class Jungle {' + methods('assets/jungle_treasure/script/jungle_Manager.ts', ['completeDirectPayResultPolling']) + '};exports.Jungle=Jungle;'), context); + const view = new context.exports.Jungle(); view.directPayTier = { id: 1, productId: 'jungle_treasure_1' }; + view.directPayOrderNo = 'paid'; view.pendingPaymentPrice = 600; view.stopDirectPayResultPolling = () => {}; view.refreshAfterPayment = () => {}; + f.U.rememberJunglePayment('paid', 'jungle_treasure_1', 600, false); + view.completeDirectPayResultPolling(); await f.U.recoverJunglePayments([]); assert.equal(f.payments.length, 1); + view.completeDirectPayResultPolling(); assert.equal(f.payments.length, 1); +}); diff --git a/tools/test-pyramid-client.cjs b/tools/test-pyramid-client.cjs index 354c4a90..8f0e583d 100644 --- a/tools/test-pyramid-client.cjs +++ b/tools/test-pyramid-client.cjs @@ -2,7 +2,7 @@ const test=require('node:test'),assert=require('node:assert/strict'),fs=require( const root=path.resolve(__dirname,'..'),copy=x=>x===undefined?x:structuredClone(x); function fixture(){ const info={uid:'u',userId:123,level:50,token:'t',coin:100,freezeAmount:0,hammerAmount:0,magicAmount:0,userPowerTime:0}; - const storage=new Map(),calls=[],toasts=[],paymentCalls=[],customerCalls=[],showListeners=[],events=[];let dropSave=false,dropDraw=false,dropConfirm=false,delay=null,paymentMode='cancel',paymentStatus='pending',orderCount=0,orderFailure=false; + const storage=new Map(),calls=[],toasts=[],paymentCalls=[],customerCalls=[],showListeners=[],events=[],payments=[];let dropSave=false,dropDraw=false,dropConfirm=false,delay=null,paymentMode='cancel',paymentStatus='pending',orderCount=0,orderFailure=false; const grant={grantId:'g',periodId:'p',source:'draw',goods:[{id:1001,count:20}],rewardIds:['r'],status:'pending'}; const state={periodId:'p',offerTier:3,status:'active',startAt:Date.now()-1000,endAt:Date.now()+100000,currentLevel:1,canPay:false,canDraw:true,canClaimFinal:false,drawCount:1,pendingOrder:null,pendingGrants:[],levels:[{level:1,itemId:'pay_test',itemPrice:100,rewards:[{rewardId:'r'}]}],serverTime:Date.now()}; let inventory={coinAmount:100,freezeAmount:0,hammerAmount:0,magicAmount:0,userPowerTime:0},saved=false,confirmed=false,drawId=null; @@ -40,10 +40,10 @@ function fixture(){ const schedule={...context,exports:{},require:name=>({default:name==='./ActivityBundleLoader'?{beginCycle(){}}:U})}; vm.runInNewContext(ts.transpileModule(fs.readFileSync(path.join(root,'assets/Script/module/Config/ActivityScheduleService.ts'),'utf8'),{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2017}}).outputText,schedule); currentSchedule=schedule.exports.default; - const fresh={...context,exports:{},require:name=>({default:name==='./ActivityScheduleService'?schedule.exports.default:U})}; + const fresh={...context,exports:{},require:name=>({MiniGameSdk:{API:{yinli_Pay:(...args)=>payments.push(args)}},default:name==='./ActivityScheduleService'?schedule.exports.default:U})}; vm.runInNewContext(serviceCode,fresh);return fresh.exports.default; }; - return {S:restart(),get schedule(){return currentSchedule;},restart,U,info,calls,toasts,paymentCalls,customerCalls,storage,state,platform,events,grant,cc,get inventory(){return inventory;},mode:v=>paymentMode=v,failOrder:()=>orderFailure=true,paid:()=>{paymentStatus='succeeded';state.canPay=false;state.canDraw=true;state.drawCount=1;},show:()=>showListeners.forEach(fn=>fn()),dropSave:()=>dropSave=true,dropDraw:()=>dropDraw=true,dropConfirm:()=>dropConfirm=true,hold:()=>delay=[],release:()=>{const q=delay;delay=null;q.forEach(f=>f());}}; + return {S:restart(),get schedule(){return currentSchedule;},restart,U,info,calls,toasts,paymentCalls,customerCalls,storage,state,platform,events,payments,grant,cc,get inventory(){return inventory;},mode:v=>paymentMode=v,failOrder:()=>orderFailure=true,paid:()=>{paymentStatus='succeeded';state.canPay=false;state.canDraw=true;state.drawCount=1;},show:()=>showListeners.forEach(fn=>fn()),dropSave:()=>dropSave=true,dropDraw:()=>dropDraw=true,dropConfirm:()=>dropConfirm=true,hold:()=>delay=[],release:()=>{const q=delay;delay=null;q.forEach(f=>f());}}; } test('collect_pyramid reports only after inventory save, using the original slot after advancing',async()=>{