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 plain = value => JSON.parse(JSON.stringify(value)); const configFile = 'assets/Script/module/Config/GameConfig.ts'; const shopFile = 'assets/shop/script/shop.ts'; const coins = [1200, 6800, 16000, 32000, 84000, 168000]; const prices = [600, 3000, 6800, 12800, 32800, 64800]; const bundles = [[8,1500,2,0], [18,3000,5,1800], [36,6000,10,7200], [68,10000,20,21600], [98,20000,25,43200]]; test('layer3 selects one shop and remains independent of layer1', () => { const Config = loadMembers(configFile, ['isShopB', 'getShopPrefabPath']); for (const [info, expected] of [ [{}, 'shop'], [{abTests:{layer_one:2,layer_three:1}}, 'shop'], [{abTests:{layer_three:2}}, 'shop2'], [{abTestAssignments:{layer_3:{group:'B'}}}, 'shop2'], [{abTestAssignments:{layer_3:{group:'A'}},abTests:{layer_three:2}}, 'shop'], [{abTestAssignments:{layer_3:{group:'B',enabled:false}},abTests:{layer_three:2}}, 'shop'], [{abTestAssignments:{experiment:{layer:3,group:2}}}, 'shop2'], ]) { Config.GM_INFO = info; assert.equal(Config.getShopPrefabPath(), 'prefab/' + expected); } }); function rewardFixture() { const info = { uid:'player', shopDouble:Object.fromEntries(coins.map((_,i)=>['gold_'+(i+1),false])) }; const storage = new Map(), calls = { coins:[], props:[], health:[], rewards:[], double:[] }; const cc = { find:()=>null, fx:{ GameConfig:{GM_INFO:info}, StorageMessage:{ getStorage:key=>storage.get(key), setStorage:(key,value)=>storage.set(key,value) } } }; const Tool = loadMembers('assets/Script/module/Tool/GameTool.ts', ['shopBuy'], { cc, ...catalog, Utils:{setShopDouble:(_,data)=>calls.double.push(plain(data))}, MapConroler:{_instance:{SceneManager:{openRewardWindow:data=>calls.rewards.push(plain(data))}}}, MiniGameSdk:{API:{showToast(){},shushu_SetSuperProperties(){}}}, console:{log(){}} }); const tool = cc.fx.GameTool = Object.assign(new Tool(), { changeCoin:n=>calls.coins.push(n), getShopProp:(data,compensate)=>calls.props.push([plain(data),compensate]), setUserPowerTime:(...args)=>calls.health.push(args), shushu_Track(){} }); return {tool,info,calls}; } test('all new gold amounts retain old double flags; duplicate order does not reward twice', () => { for (let i=0;i<6;i++) { const f=rewardFixture(), id='new_gold_'+(i+1), key='gold_'+(i+1); f.tool.shopBuy(id,false,{outTradeNo:'first'}); f.tool.shopBuy(id,true,{outTradeNo:'first'}); f.tool.shopBuy(id,false,{outTradeNo:'second'}); assert.deepEqual(f.calls.coins,[coins[i]*2,coins[i]]); assert.equal(f.info.shopDouble[key],true); assert.equal(f.calls.double.length,1); const old=rewardFixture(); old.info.shopDouble[key]=true; old.tool.shopBuy(id,true,{outTradeNo:'old-used'}); assert.deepEqual(old.calls.coins,[coins[i]]); assert.equal(catalog.getNewShopProduct(id).price,prices[i]); } }); test('five new bundles grant exact rewards; 8 yuan has no health reward; compensation deduplicates', () => { for (const [yuan,coin,count,seconds] of bundles) { const f=rewardFixture(), id='new_unlimited_health_bundle_'+yuan; f.tool.shopBuy(id,true,{outTradeNo:'bundle'}); f.tool.shopBuy(id,false,{outTradeNo:'bundle'}); assert.deepEqual(f.calls.coins,[coin]); assert.deepEqual(f.calls.props,[[{hammer:count,freeze:count,magic_wand:count,price:yuan*100},true]]); assert.deepEqual(f.calls.health,seconds?[[seconds,id,true]]:[]); assert.deepEqual(f.calls.rewards[0],[{type:'coin',count:coin},{type:'hammer',count}, {type:'freeze',count},{type:'magic',count},...(seconds?[{type:'infinite_health',count:seconds}]:[])]); } }); test('A gold rewards and shared first-purchase state remain compatible with B', () => { for (const [i,coin] of [1200,8000,16000,32000,100000,240000].entries()) { const f=rewardFixture(); f.tool.shopBuy('gold_'+(i+1),false); f.tool.shopBuy('new_gold_'+(i+1),false,{outTradeNo:'b'}); assert.deepEqual(f.calls.coins,[coin*2,coins[i]]); } }); test('authored prefabs retain correct purchase IDs, coin list references and no hp in 8 yuan bundle', () => { for (const variant of ['shop','shop2']) { const data=JSON.parse(fs.readFileSync(`assets/shop/prefab/${variant}.prefab`,'utf8')); const ids=data.filter(x=>x.__type__==='cc.ClickEvent'&&x.handler==='buyProduct').map(x=>x.customEventData); const expected=variant==='shop2'?catalog.SHOP_B_GOLD.concat(catalog.SHOP_B_BUNDLES).map(x=>x.id) :catalog.SHOP_A_GOLD.map(x=>x.id).concat([10,20,30].map(x=>'unlimited_health_bundle_'+x)); assert.deepEqual(ids.slice().sort(),Array.from(expected).sort()); const script=data.find(x=>x.itemList&&x.doubleInfo!==undefined)||data.find(x=>x.itemList); const content=data[script.itemList.__id__], coin=data[content._children[3].__id__]; assert.equal(coin._name,'Coin'); if(variant==='shop2') { const pack=content._children.map(ref=>data[ref.__id__]).find(node=>node._name==='Pack'), first=data[pack._children[0].__id__]; assert.equal(pack._children.length,5); assert.equal(first._children.some(ref=>data[ref.__id__]._name==='hp'),false); } } }); test('coin UI preserves B authored numbers and sprite positions while A still renders dynamically', () => { for(const b of [false,true]) { const nodes=Array.from({length:8},()=>({children:Array.from({length:8},()=>({active:true,getComponent:()=>({}),children:[{position:{x:0,y:0}}],x:0,y:0}))})); const rendered=new Map(), render=(n,a,z,p,node)=>rendered.set(node,n); const cc={Sprite:'sprite',v2:(x,y)=>({x,y}),fx:{GameConfig:{isShopB:()=>b}}}; const Shop=loadMembers(shopFile,['initCoin'],{cc,...catalog,console:{log(){}},NumberToImage:{numberToImageNodesShop:render,numberToImageNodes:render}}); const shop=new Shop(); shop.itemList={children:[null,null,null,{children:nodes}]}; shop.doubleInfo={gold_1:false,gold_2:true}; shop.initCoin(); const expected=b?catalog.SHOP_B_GOLD:catalog.SHOP_A_GOLD; expected.forEach((p,i)=>{ const children=nodes[i+2].children; assert.equal(rendered.get(children[2]),b?undefined:p.price/100); assert.equal(rendered.get(children[3]),b?undefined:p.coin); assert.equal(rendered.get(children[5]),b?undefined:p.coin); assert.equal(children[6].x,b?0:(i%2===0?-34:-42)); assert.equal(children[6].y,0); }); assert.equal(nodes[2].children[5].active,true);assert.equal(nodes[3].children[5].active,false); } }); test('Android purchase pins its order across another popup payment and refreshes new bundle health', () => { const calls={}, info={iosCanPay:true,coin:0}; const Utils={buyProp:(id,n,price,platform,product,done)=>{calls.price=price;calls.pay=done;}, getPayInfo:(done,order)=>{calls.poll=done;calls.polled=order;}, setPayInfo:(done,order)=>{calls.ack=order;done({code:1});}}; const cc={fx:{GameConfig:{GM_INFO:info},GameTool:{shushu_Track(){},shopBuy:(id,comp,context)=>calls.grant=[id,context.outTradeNo]}},sys:{}}; const Shop=loadMembers(shopFile,['buyProduct'],{cc,Utils,...catalog,console:{log(){},error(){}}, wx:{getSystemInfoSync:()=>({platform:'android'})},MiniGameSdk:{API:{showToast(){},yinli_Pay(){}}}, NumberToImage:{numberToImageNodesCoin(){}},setTimeout:fn=>fn()}); const shop=Object.assign(new Shop(),{btn_Touch:true,openLoad(){},closeLoad(){},updatePower(){calls.health=true;}, node:{parent:{getComponent:name=>name==='JiaZai'?{updateCoin(){}}:null}}}); shop.buyProduct(null,'new_unlimited_health_bundle_18'); assert.equal(calls.price,1800); calls.pay({outTradeNo:'shop-order'}); Utils.outTradeNo='other-popup-order'; calls.poll({code:1,data:{pay_state:2}}); assert.equal(calls.polled,'shop-order');assert.equal(calls.ack,'shop-order'); assert.deepEqual(Array.from(calls.grant),['new_unlimited_health_bundle_18','shop-order']);assert.equal(calls.health,true); }); test('login compensation recognizes every B SKU even when currently assigned to A', () => { const ids=catalog.SHOP_B_GOLD.concat(catalog.SHOP_B_BUNDLES).map(x=>x.id), granted=[]; const info={allOutTradeNo:ids.map((id,i)=>({itemid:id,outTradeNo:'order-'+i,goodsPrice:1}))}; const cc={fx:{GameConfig:{GM_INFO:info},GameTool:{shopBuy:(id,comp,ctx)=>granted.push([id,comp,ctx.outTradeNo]),shushu_Track(){}}}}; const Home=loadMembers('assets/Script/JiaZai.ts',['getOrder'],{cc,...catalog,wx:{},console:{log(){},warn(){},error(){}}, Utils:{getShopDouble:done=>done({code:1,data:{shopDouble:{}}}),setPayInfo:(done)=>done({code:1,data:'ok'})}, MiniGameSdk:{API:{yinli_Pay(){},shushu_SetSuperProperties(){}}}}); const home=Object.assign(new Home(),{deferHomePopupWhileTransfer:()=>false,isHomeRuntimeAlive:()=>false}); home.getOrder(); assert.deepEqual(granted,Array.from(ids,(id,i)=>[id,true,'order-'+i]));assert.equal(info.allOutTradeNo.length,0); }); test('home and gameplay open the selected prefab, reuse it, and replace a cached old variant', () => { for(const file of ['assets/Script/JiaZai.ts','assets/Script/SceneManager.ts']) { const Config=loadMembers(configFile,['isShopB','getShopPrefabPath']); Config.GM_INFO={canIos:true,abTests:{layer_three:1}}; Config.isGameplayEntryPreparing=()=>false; const selected=[], calls=[]; const cc={isValid:()=>true,error:assert.fail,fx:{GameConfig:Config,GameTool:{shushu_Track(){},tryReactivateStarterPack(){}}}, instantiate:prefab=>{selected.push(prefab);return {active:true,destroy(){this.destroyed=true;},getComponent:()=>({init:data=>calls.push(data)})};}}; const globals={ cc,console:{log(){}},MapConroler:{_instance:null},Utils:{getShopDouble:done=>done({code:1,data:{shopDouble:{gold_2:true}}})} }; const Type=loadMembers(file,['openShop','clearInvalidShopPrefabCaches'].filter(name=>file.includes('SceneManager')||name==='openShop'),globals); globals.JiaZai=Type;globals.SceneManager=Type; Type.cachedShopPrefab='prefab-A';Type.cachedShop2Prefab='prefab-B'; const controller=Object.assign(new Type(),{node:{addChild(){}},vibrateButtonClick(){}, ensureHomePopupPrefab:(bundle,path,key)=>{assert.equal(bundle,'shop');assert.equal(path,Config.getShopPrefabPath());assert.equal(Type[key],Config.isShopB()?'prefab-B':'prefab-A');return true;}, isHomeRuntimeAlive:()=>true,isPopupRuntimeAlive:()=>true}); controller.openShop();const old=controller.shopNode;controller.openShop(); Config.GM_INFO.abTests.layer_three=2;controller.openShop();controller.openShop(); assert.deepEqual(selected,['prefab-A','prefab-B']);assert.equal(old.destroyed,true); assert.equal(calls.length,4);assert.equal(calls.every(data=>data.gold_2===true),true); } }); test('tab buttons address Pack and type by name in both authored node orders', () => { const Shop=loadMembers(shopFile,['showCoinList','showPackList']); for(const file of ['shop','shop2']) { const data=JSON.parse(fs.readFileSync(`assets/shop/prefab/${file}.prefab`,'utf8')); function node(id) { const source=data[id]; return {...source,active:source._active,opacity:source._opacity, children:source._children.map(ref=>node(ref.__id__)),getChildByName(name){return this.children.find(n=>n._name===name);}}; } const shop=new Shop(); shop.itemList=node(data.find(x=>x.itemList).itemList.__id__); const pack=shop.itemList.getChildByName('Pack'), before=pack.children.map(n=>n.opacity); const title=shop.itemList.getChildByName('jinbi'), active=title?.active; shop.showCoinList();assert.equal(pack.active,false);assert.equal(shop.itemList.getChildByName('Coin').active,true); assert.deepEqual(pack.children.map(n=>n.opacity),before);assert.equal(title?.active,active); shop.showPackList();assert.equal(pack.active,true);assert.equal(shop.itemList.getChildByName('Coin').active,true); } }); function activityFixture() { const calls=[], widget={top:931,updateAlignment(){calls.push(this.top);}}; const info={canIos:true,level:30,starterPackPurchased:false,starter_packTime:Date.now()/1000+60, jungleTreasureAvailable:true,jungleTreasureState:{expiresAt:Date.now()+60000,tierStates:[1,0]}}; const config={GM_INFO:info,getFeatureUnlockLevel:()=>16,getJungleTreasureUnlockLevel:()=>25}; const cc={Widget:'widget',find:()=>({}),fx:{GameConfig:config}}; const rules=loadModule('assets/Script/module/Config/StarterPack.ts',{}, {cc}); const Shop=loadMembers(shopFile,['refreshActivityEntries','isJungleTreasureUnavailable','activityContentTop'],{ cc,...rules,ShopActivityCarousel:'carousel' }); const carousel={setEntries(names,reset){this.names=Array.from(names);this.reset=reset;}}; const background={active:true}; const shop=Object.assign(new Shop(),{node:{getComponent:()=>carousel,getChildByName:name=>name==='shop_3'?background:({getComponent:()=>widget})}}); return {shop,info,config,carousel,widget,calls,cc,background}; } test('B activity filters all eight availability combinations and restores Widget top', () => { const f=activityFixture(); for(let mask=0;mask<8;mask++) { f.info.starterPackPurchased=!(mask&1); f.info.jungleTreasureAvailable=!!(mask&2); f.config.getFeatureUnlockLevel=()=>mask&4?16:99; f.shop.refreshActivityEntries(true); assert.deepEqual(f.carousel.names,['newbieGift','jungle','banre'].filter((_,i)=>mask&(1< { const f=activityFixture(); f.info.level=14;f.shop.refreshActivityEntries();assert.deepEqual(f.carousel.names,[]); f.info.level=15;f.shop.refreshActivityEntries();assert.deepEqual(f.carousel.names,['newbieGift']); f.info.level=16;f.config.getFeatureUnlockLevel=()=>20; f.shop.refreshActivityEntries();assert.deepEqual(f.carousel.names,['newbieGift']); f.info.level=30;f.info.starter_packTime=Date.now()/1000-1; f.info.jungleTreasureState={expiresAt:Date.now()+60000,tierStates:[2,2]}; f.shop.refreshActivityEntries();assert.deepEqual(f.carousel.names,['banre']); f.info.starter_packTime=Date.now()/1000+60; f.info.jungleTreasureState=JSON.stringify({expiresAt:Date.now()-1,tierStates:[2,1,0]}); f.shop.refreshActivityEntries();assert.deepEqual(f.carousel.names,['newbieGift','jungle','banre']); f.info.starterPackPurchased=true; f.info.jungleTreasureState={expiresAt:Date.now()-1,tierStates:[2,0,1]}; f.shop.refreshActivityEntries();assert.deepEqual(f.carousel.names,['banre']); f.cc.find=()=>null;f.carousel.names=['untouched'];f.shop.refreshActivityEntries(); assert.deepEqual(f.carousel.names,['untouched'],'A prefab without activity is unchanged'); });