diff --git a/assets/Script/GameManager.ts b/assets/Script/GameManager.ts index ae465c2e..e40d8155 100644 --- a/assets/Script/GameManager.ts +++ b/assets/Script/GameManager.ts @@ -33,6 +33,9 @@ export default class GameManager extends cc.Component { text: string = 'hello'; static _instance: GameManager = null; + @property(cc.Prefab) + staminaPackPrefab: cc.Prefab = null; + @property({ type: [cc.Prefab], tooltip: "方块数组" }) Block_Array: Array = []; diff --git a/assets/Script/Map.ts b/assets/Script/Map.ts index 3674d839..5653ca4a 100644 --- a/assets/Script/Map.ts +++ b/assets/Script/Map.ts @@ -12,6 +12,7 @@ import { applyRainbowElimination } from "./RainbowCut"; // - https://docs.cocos.com/creator/manual/en/scripting/life-cycle-callbacks.html import GameManager from "./GameManager"; +import StaminaPack from "./StaminaPack"; import CloudRiseService from "./module/Config/CloudRiseService"; import CloudRiseView from "./CloudRiseView"; import { LQCollide } from "./lq_collide_system/lq_collide"; @@ -5192,7 +5193,8 @@ export default class MapConroler extends cc.Component { if (cc.fx.GameConfig.GM_INFO.hp < 1 && MapConroler._instance.powerState == false) { MiniGameSdk.API.showToast("体力值不足"); setTimeout(() => { - this.openShop(); + if (!this.isMapRuntimeAlive()) return; + if (!StaminaPack.tryOpen()) this.openShop(); }, 500); return; } diff --git a/assets/Script/StaminaPack.ts b/assets/Script/StaminaPack.ts new file mode 100644 index 00000000..809cb53b --- /dev/null +++ b/assets/Script/StaminaPack.ts @@ -0,0 +1,152 @@ +import GameManager from "./GameManager"; +import Utils from "./module/Pay/Utils"; +import { STAMINA_PACK } from "./module/Config/ShopCatalog"; +import { beginStaminaPackOffer, canShowStaminaPack, closeStaminaPackOffer, getStaminaPackRecord } from "./module/Config/StaminaPackState"; +import { MiniGameSdk } from "./Sdk/MiniGameSdk"; + +declare const wx: any; +const { ccclass, property } = cc._decorator; + +@ccclass +export default class StaminaPack extends cc.Component { + @property(cc.Label) timeLabel: cc.Label = null; + @property(cc.Button) buyButton: cc.Button = null; + @property(cc.Button) closeButton: cc.Button = null; + + private paying = false; + private uid = ""; + private onShowListener: () => void = null; + + static tryOpen(): boolean { + const canvas = cc.find("Canvas"); + if (!canvas) return false; + if (canvas.getChildByName("heathpop2")) return true; + if (!canShowStaminaPack()) return false; + const prefab = GameManager._instance && GameManager._instance.staminaPackPrefab; + if (!prefab) return false; + const node = cc.instantiate(prefab); + beginStaminaPackOffer(); + canvas.addChild(node); + node.zIndex = 1001; + return true; + } + + onLoad() { + this.uid = cc.fx.GameConfig.GM_INFO.uid; + this.node.addComponent(cc.BlockInputEvents); + this.buyButton.node.on("click", this.buyProduct, this); + this.closeButton.node.on("click", this.closePop, this); + cc.director.on("stamina-pack-granted", this.closeAfterPurchase, this); + this.refreshCountdown(); + this.schedule(this.refreshCountdown, 1); + if (typeof wx !== "undefined") { + this.onShowListener = () => this.checkIosOrder(); + wx.onShow(this.onShowListener); + } + this.checkIosOrder(); + } + + private refreshCountdown() { + const seconds = Math.max(0, Math.ceil((getStaminaPackRecord().offerEnd - Date.now()) / 1000)); + this.timeLabel.string = Math.floor(seconds / 60).toString().padStart(2, "0") + + ":" + (seconds % 60).toString().padStart(2, "0"); + } + + closePop() { + cc.fx.AudioManager._instance.playEffect("anniu_little", null); + closeStaminaPackOffer(); + this.node.destroy(); + } + + private closeAfterPurchase() { + this.node.active = false; + this.node.destroy(); + } + + private setPaying(value: boolean) { + this.paying = value; + if (cc.isValid(this, true)) this.buyButton.interactable = !value; + } + + buyProduct() { + if (this.paying) return; + cc.fx.AudioManager._instance.playEffect("anniu_little", null); + if (cc.fx.GameConfig.GM_INFO.iosStaminaPackOrder) { + this.checkIosOrder(); + return; + } + const systemType = typeof wx !== "undefined" && wx.getSystemInfoSync().platform === "ios" ? "ios" : "Android"; + this.setPaying(true); + if (systemType === "ios" && !cc.fx.GameConfig.GM_INFO.iosCanPay) { + this.iosOldPay(); + return; + } + Utils.buyProp(STAMINA_PACK.id, 1, STAMINA_PACK.price, systemType, STAMINA_PACK.id, res => { + if (this.uid !== cc.fx.GameConfig.GM_INFO.uid) return; + if (res?.errCode === 16) { this.iosOldPay(); return; } + if (!res || res.err || !res.outTradeNo) { + this.setPaying(false); + MiniGameSdk.API.showToast(res?.errCode === -2 ? "取消充值" : "充值失败"); + return; + } + const order = res.outTradeNo; + Utils.getPayInfo(data => { + if (this.uid !== cc.fx.GameConfig.GM_INFO.uid) return; + if (data?.code !== 1 || data.data?.pay_state !== 2) { + this.setPaying(false); + return; + } + Utils.setPayInfo(result => { + if (this.uid !== cc.fx.GameConfig.GM_INFO.uid) return; + this.setPaying(false); + if (result?.code === 1) this.completePurchase(order); + else MiniGameSdk.API.showToast("网络异常,充值奖励将在登录后再次发放"); + }, order); + }, order); + }); + } + + private iosOldPay() { + Utils.GoKEFu({ price: STAMINA_PACK.price, payment_name: STAMINA_PACK.id, payment_count: 1 }, result => { + if (this.uid !== cc.fx.GameConfig.GM_INFO.uid) return; + this.setPaying(false); + if (result !== "success") { + cc.fx.GameConfig.GM_INFO.iosStaminaPackOrder = ""; + MiniGameSdk.API.showToast("未能打开支付,请重试"); + } + }); + } + + private checkIosOrder() { + const order = cc.fx.GameConfig.GM_INFO.iosStaminaPackOrder; + if (!order || this.paying) return; + this.setPaying(true); + Utils.getIosPayInfo(order, result => { + if (this.uid !== cc.fx.GameConfig.GM_INFO.uid) return; + this.setPaying(false); + if (result?.code === 1 && result.data?.payment_name === STAMINA_PACK.id) { + cc.fx.GameConfig.GM_INFO.iosStaminaPackOrder = ""; + this.completePurchase(order); + } else if (result?.code === 0) { + cc.fx.GameConfig.GM_INFO.iosStaminaPackOrder = ""; + MiniGameSdk.API.showToast(result.msg || "充值失败"); + } + }); + } + + private completePurchase(order: string) { + // 正常发货与登录补单共用 shopBuy,统一去重、计冷却并打开奖励窗。 + cc.fx.GameTool.shopBuy(STAMINA_PACK.id, false, { outTradeNo: order }); + MiniGameSdk.API.yinli_Pay(STAMINA_PACK.price, order, "购买金币道具:" + STAMINA_PACK.id); + const canvas = cc.find("Canvas"); + const home = canvas && canvas.getComponent("JiaZai"); + if (home) { home.updateCoin(); home.setHealthInfo(false); } + const scene = canvas && canvas.getComponent("SceneManager"); + if (scene) scene.refreshJungleRewardDisplay(); + } + + onDestroy() { + cc.director.off("stamina-pack-granted", this.closeAfterPurchase, this); + if (this.onShowListener) wx.offShow(this.onShowListener); + } +} diff --git a/assets/Script/StaminaPack.ts.meta b/assets/Script/StaminaPack.ts.meta new file mode 100644 index 00000000..7bc79239 --- /dev/null +++ b/assets/Script/StaminaPack.ts.meta @@ -0,0 +1,10 @@ +{ + "ver": "1.1.0", + "uuid": "6aa5f0f0-c22b-4dee-b311-c9ae0af032c3", + "importer": "typescript", + "isPlugin": false, + "loadPluginInWeb": true, + "loadPluginInNative": true, + "loadPluginInEditor": false, + "subMetas": {} +} \ No newline at end of file diff --git a/assets/Script/heathnum.ts b/assets/Script/heathnum.ts index f4529f1e..33a08670 100644 --- a/assets/Script/heathnum.ts +++ b/assets/Script/heathnum.ts @@ -1,6 +1,7 @@ import NumberToImage from "./NumberToImage"; import JiaZai from "./JiaZai"; import { MiniGameSdk } from "./Sdk/MiniGameSdk"; +import StaminaPack from "./StaminaPack"; const HEALTH_REFILL_PRICES = [1000, 2000, 4000, 8000]; const HEALTH_REFILL_STORAGE_PREFIX = "home_health_refill_"; @@ -30,6 +31,7 @@ export default class NewClass extends cc.Component { coin: cc.Node = null; btn_Touch: boolean = true; + private boughtHealth = false; onLoad() { this.btn_Touch = true; @@ -118,6 +120,7 @@ export default class NewClass extends cc.Component { cc.fx.GameConfig.GM_INFO.hp = cc.fx.GameConfig.GM_INFO.hp_Max; cc.fx.GameConfig.GM_INFO.min_Time = 0; this.recordHealthRefill(refillRecord); + this.boughtHealth = true; const parentNode = cc.find("Canvas"); // 假设 JiaZai 挂在 Canvas 节点 if (parentNode) { const jiazaiComp = parentNode.getComponent(JiaZai); @@ -184,6 +187,7 @@ export default class NewClass extends cc.Component { console.warn("JiaZai 节点未找到"); } this.heath.destroy(); + if (event && !this.boughtHealth) StaminaPack.tryOpen(); } // update (dt) {} diff --git a/assets/Script/module/Config/GameConfig.ts b/assets/Script/module/Config/GameConfig.ts index 771f27ac..748f36bb 100644 --- a/assets/Script/module/Config/GameConfig.ts +++ b/assets/Script/module/Config/GameConfig.ts @@ -568,6 +568,7 @@ export class GameConfig { otherLevel: number; //其他被帮助用户等级 helpLevel: number; //帮助用户等级 iosShopOrder: string; //ios订单号商城 + iosStaminaPackOrder: string; iosJungleOrder: string; //iOS 客服旧支付的丛林宝藏订单号 iosMonthOrder: string; //ios订单号月卡 iosReviveOrder: string; //ios订单号复活 @@ -770,7 +771,7 @@ export class GameConfig { vibrateOpen: true, //震动 coinnum: 0, //每局的金币数 paid_user: false, //是否是付费用户 - version: "1.0.82", //版本号 + version: "1.0.84", //版本号 shushu_DistinctId: "", //数数访客ID shushu_AccountId: "", //数数账号ID uid: "", //用户和后端唯一id @@ -788,6 +789,7 @@ export class GameConfig { otherLevel: 0, //其他被帮助用户等级 helpLevel: 0, //帮助用户等级 iosShopOrder: "", //ios订单号商城 + iosStaminaPackOrder: "", iosJungleOrder: "", //iOS 客服旧支付的丛林宝藏订单号 iosMonthOrder: "", //ios订单号月卡 iosReviveOrder: "", //ios订单号复活 diff --git a/assets/Script/module/Config/ShopCatalog.ts b/assets/Script/module/Config/ShopCatalog.ts index f511f3c5..a9e96d64 100644 --- a/assets/Script/module/Config/ShopCatalog.ts +++ b/assets/Script/module/Config/ShopCatalog.ts @@ -35,7 +35,10 @@ export const SHOP_B_BUNDLES: ShopProduct[] = [ { id: "new_unlimited_health_bundle_98", price: 9800, coin: 20000, hammer: 25, freeze: 25, magic: 25, infiniteHealth: 43200 }, ]; +export const STAMINA_PACK: ShopProduct = { id: "StaminaPack", price: 800, coin: 1200, infiniteHealth: 3600 }; + // 按订单商品 ID 查奖励,补单不能使用玩家当前的 AB 分组决定数值。 export function getNewShopProduct(id: string): ShopProduct { + if (id === STAMINA_PACK.id) return STAMINA_PACK; return SHOP_B_GOLD.concat(SHOP_B_BUNDLES).find(product => product.id === id); } diff --git a/assets/Script/module/Config/StaminaPackState.ts b/assets/Script/module/Config/StaminaPackState.ts new file mode 100644 index 00000000..de879635 --- /dev/null +++ b/assets/Script/module/Config/StaminaPackState.ts @@ -0,0 +1,40 @@ +const MINUTE = 60 * 1000; + +export interface StaminaPackRecord { + offerEnd: number; + cooldownEnd: number; +} + +function storageKey(): string { + return "StaminaPack_" + (cc.fx.GameConfig.GM_INFO.uid || "guest"); +} + +export function getStaminaPackRecord(): StaminaPackRecord { + return cc.fx.StorageMessage.getStorage(storageKey()) || { offerEnd: 0, cooldownEnd: 0 }; +} + +export function canShowStaminaPack(now = Date.now()): boolean { + const record = getStaminaPackRecord(); + return record.offerEnd > now || record.cooldownEnd <= now; +} + +export function beginStaminaPackOffer(now = Date.now()): void { + const record = getStaminaPackRecord(); + if (record.offerEnd > now || record.cooldownEnd > now) return; + cc.fx.StorageMessage.setStorage(storageKey(), { + offerEnd: now + 10 * MINUTE, + cooldownEnd: now + 40 * MINUTE, + }); +} + +export function closeStaminaPackOffer(now = Date.now()): void { + const record = getStaminaPackRecord(); + // 发货后 offerEnd 为 0,不能被关闭回调覆盖八小时冷却。 + if (record.offerEnd > 0 && now >= record.offerEnd) { + cc.fx.StorageMessage.setStorage(storageKey(), { ...record, cooldownEnd: now + 30 * MINUTE }); + } +} + +export function recordStaminaPackPurchase(now = Date.now()): void { + cc.fx.StorageMessage.setStorage(storageKey(), { offerEnd: 0, cooldownEnd: now + 8 * 60 * MINUTE }); +} diff --git a/assets/Script/module/Config/StaminaPackState.ts.meta b/assets/Script/module/Config/StaminaPackState.ts.meta new file mode 100644 index 00000000..3415a321 --- /dev/null +++ b/assets/Script/module/Config/StaminaPackState.ts.meta @@ -0,0 +1,10 @@ +{ + "ver": "1.1.0", + "uuid": "1034f13d-c1e0-4aa6-93c4-5f8e762f35e5", + "importer": "typescript", + "isPlugin": false, + "loadPluginInWeb": true, + "loadPluginInNative": true, + "loadPluginInEditor": false, + "subMetas": {} +} \ No newline at end of file diff --git a/assets/Script/module/Pay/Utils.ts b/assets/Script/module/Pay/Utils.ts index 569314fb..2d9275e6 100644 --- a/assets/Script/module/Pay/Utils.ts +++ b/assets/Script/module/Pay/Utils.ts @@ -394,6 +394,9 @@ export default class Utils { else if (/^gold_miner(?:_\d+)?$/.test(String(iosPayInfo.payment_name || ""))) { // The activity persists its order separately; do not overwrite shop orders. } + else if (iosPayInfo.payment_name === "StaminaPack") { + cc.fx.GameConfig.GM_INFO.iosStaminaPackOrder = order; + } else { cc.fx.GameConfig.GM_INFO.iosShopOrder = null; cc.fx.GameConfig.GM_INFO.iosShopOrder = order; diff --git a/assets/Script/module/Tool/GameTool.ts b/assets/Script/module/Tool/GameTool.ts index 57174610..5c5b09a3 100644 --- a/assets/Script/module/Tool/GameTool.ts +++ b/assets/Script/module/Tool/GameTool.ts @@ -7,6 +7,7 @@ import SceneManager from "../../SceneManager"; import { MiniGameSdk } from "../../Sdk/MiniGameSdk"; import Utils from "../Pay/Utils"; import { getNewShopProduct } from "../Config/ShopCatalog"; +import { recordStaminaPackPurchase } from "../Config/StaminaPackState"; import BlockAssetManager from "./BlockAssetManager"; import HomePopupQueue from "../../../home_popup_queue/HomePopupQueue"; import { @@ -16,7 +17,7 @@ import { //@ts-ignore //最大工具类 各种公共方法,以及处理上传,获取后端接口数据 var GameTool = { - MAX_NORMAL_LEVEL: 2000, + MAX_NORMAL_LEVEL: 2020, starterPackReactivationUid: "", starterPackQueuedReason: "", MEMORY_WARNING_EVENT: "game-memory-warning", @@ -1073,6 +1074,10 @@ var GameTool = { rewardData.push({ type: "infinite_health", count: newProduct.infiniteHealth }); } if (newOrderKey) cc.fx.StorageMessage.setStorage(newOrderKey, true); + if (productId === "StaminaPack") { + recordStaminaPackPurchase(); + cc.director.emit("stamina-pack-granted"); + } if (shopComp) { shopComp.initCoin(); shopComp.refreshJungleRewardDisplay(); diff --git a/assets/StartScene/LoadScene.fire b/assets/StartScene/LoadScene.fire index bbf17e73..2b72daf1 100644 --- a/assets/StartScene/LoadScene.fire +++ b/assets/StartScene/LoadScene.fire @@ -163,6 +163,9 @@ "__id__": 16 }, "text": "hello", + "staminaPackPrefab": { + "__uuid__": "1cf82c85-a0c6-42f7-8a9d-e7ed164806d2" + }, "Block_Array": [], "Wall_Prefab": [], "_id": "de33dd/4JKb4L0+kEatmTv" diff --git a/assets/prefab/pop/heathpop2.prefab b/assets/prefab/pop/heathpop2.prefab index 8cdc8618..e58f038c 100644 --- a/assets/prefab/pop/heathpop2.prefab +++ b/assets/prefab/pop/heathpop2.prefab @@ -64,10 +64,13 @@ "_components": [ { "__id__": 56 + }, + { + "__id__": 57 } ], "_prefab": { - "__id__": 57 + "__id__": 58 }, "_opacity": 255, "_color": { @@ -1348,7 +1351,7 @@ }, "_contentSize": { "__type__": "cc.Size", - "width": 48, + "width": 44, "height": 50 }, "_anchorPoint": { @@ -1401,7 +1404,7 @@ "_srcBlendFactor": 770, "_dstBlendFactor": 771, "_spriteFrame": { - "__uuid__": "933f0942-c2ec-4c1e-b113-0183547628af" + "__uuid__": "bc900d16-5289-4513-afcf-7fe0973dc79e" }, "_type": 0, "_sizeMode": 1, @@ -2092,6 +2095,25 @@ "_originalHeight": 1920, "_id": "" }, + { + "__type__": "6aa5fDwwitN7rMRya4K8DLD", + "_name": "", + "_objFlags": 0, + "node": { + "__id__": 1 + }, + "_enabled": true, + "timeLabel": { + "__id__": 19 + }, + "buyButton": { + "__id__": 53 + }, + "closeButton": { + "__id__": 12 + }, + "_id": "" + }, { "__type__": "cc.PrefabInfo", "root": { diff --git a/assets/res/shop.meta b/assets/res/shop.meta index 5145ffee..6181b701 100644 --- a/assets/res/shop.meta +++ b/assets/res/shop.meta @@ -1,6 +1,6 @@ { "ver": "1.1.3", - "uuid": "d8e7122e-869b-4bf7-9676-50bb2ebe08b9", + "uuid": "4d9cb8fa-c6ad-47b5-ad32-2fb2a7ca3f3f", "importer": "folder", "isBundle": false, "bundleName": "", diff --git a/tools/test-stamina-pack.cjs b/tools/test-stamina-pack.cjs new file mode 100644 index 00000000..bca64f48 --- /dev/null +++ b/tools/test-stamina-pack.cjs @@ -0,0 +1,210 @@ +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() { + let now = noon; + const storage = new Map(), listeners = new Map(); + const calls = { coins: [], health: [], rewards: [], paid: [], ios: [], acknowledgements: [] }; + 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, + find: () => null, + director: { 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) => { 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'; cb('success'); }, + getIosPayInfo: (order, cb) => { calls.polledOrder = order; calls.iosPoll = cb; }, + }; + const Panel = loadMembers('assets/Script/StaminaPack.ts', [ + 'paying', 'uid', 'setPaying', 'refreshCountdown', 'closePop', 'closeAfterPurchase', + 'buyProduct', 'iosOldPay', 'checkIosOrder', 'completePurchase', 'tryOpen', + ], { cc, ...catalog, ...state, MiniGameSdk: sdk, Utils, Date: DateMock, + wx: { getSystemInfoSync: () => ({ platform: calls.platform || 'android' }) }, + GameManager: { _instance: { staminaPackPrefab: {} } }, + }); + const panel = Object.assign(new Panel(), { + uid: info.uid, buyButton: { interactable: true }, timeLabel: {}, + node: { active: true, destroy() { this.destroyed = true; } }, + }); + listeners.set('stamina-pack-granted', () => panel.closeAfterPurchase()); + return { state, info, calls, cc, panel, Panel, storage, setTime: t => { now = t; } }; +} + +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.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.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.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('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); + } +}); + +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); +}); + +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 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); +});