const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const { loadMembers, withNewPlayerConfig, ServerUtils, loadModule, resolveFile } = require('./new-player-ab-test-support.cjs'); const configFile = 'assets/Script/module/Config/GameConfig.ts'; const mapFile = 'assets/Script/Map.ts'; const homeFile = 'assets/Script/JiaZai.ts'; const assignment = group => ({ layer_1: { name: 'new1_50', group, enabled: true } }); const config = (level = 0, group = 'B') => withNewPlayerConfig({ GM_INFO: { level, otherLevel: 0, abTestAssignments: assignment(group), canIos: true, addLevel: 0, winStreak: 0, } }); const json = file => JSON.parse(fs.readFileSync(resolveFile(file), 'utf8')); test('all assignments use B unlocks and the level-15 sign-in trigger', () => { const service = loadModule('server/laf-cloud/functions/SignInActivity.ts', { '@lafjs/cloud': { default: { database: () => ({}) } }, crypto: require('node:crypto'), }); for (const raw of [ assignment('B'), assignment(2), assignment('2'), assignment('A'), assignment(1), assignment('1'), assignment(0), {}, undefined, { layer_1: { name: 'another', group: 'B' } }, { layer_1: { name: 'new1_50', group: 'B', enabled: false } }, ]) { const c = config(); c.GM_INFO.abTestAssignments = raw; assert.equal(c.isNewPlayerB(), true); assert.deepEqual(['hammer', 'time', 'magic'].map(prop => c.getPropUnlockLevel(prop)), [8, 14, 18]); assert.deepEqual(['month', 'pass', 'daily', 'winStreak'].map(feature => c.getFeatureUnlockLevel(feature)), [20, 20, 35, 50]); assert.equal(service.getUserSignInTriggerLevel({ abTests: raw }, { triggerLevel: 23 }), 15); assert.equal(service.getUserSignInTriggerLevel({ abTests: JSON.stringify(raw) }, { triggerLevel: 23 }), 15); } assert.equal(service.getUserSignInTriggerLevel({ abTests: 'invalid' }, { triggerLevel: 23 }), 15); }); test('mainline, help and endless use Json for every assignment, including 50/51', async () => { const requests = []; const cc = { JsonAsset: 'json', assetManager: { loadBundle(_name, _options, done) { done(null, {}); }, loadAny(request, _options, done) { requests.push(request.path); done(null, { json: { path: request.path } }); }, } }; const Loader = loadMembers(configFile, ['loadLevelFromBundle'], { cc }); for (const group of ['A', 'B', 0]) { const c = config(0, group); withNewPlayerConfig(Loader); Loader.GM_INFO = c.GM_INFO; for (const level of [1, 15, 50, 51, 52, 100]) { assert.equal(c.getLevelConfigPath(level), `Json/level${level}`); await Loader.loadLevelFromBundle(level); assert.equal(requests.at(-1), `Json/level${level}`); await Loader.loadLevelFromBundle(level, false); assert.equal(requests.at(-1), `Json/level${level}`); } } }); test('level introductions and pass rates use A data while prop guides use B unlocks', () => { const c = config(0, 'A'); c.defaultNewLevel = json('assets/resources/Json/NEW_LEVEL.json').NEW_LEVEL; c.defaultPassRate = json('assets/resources/Json/PASS_RATE.json'); c.defaultNewGuide = json('assets/resources/Json/NEW_GUIDE.json').NEW_GUIDE; for (const raw of [assignment('A'), assignment('B'), {}, undefined, { layer_1: { name: 'new1_50', group: 'B', enabled: false } }]) for (const level of [0, 49, 50, 100]) { c.GM_INFO.abTestAssignments = raw; c.GM_INFO.level = level; assert.equal(c.NEW_LEVEL, c.defaultNewLevel); assert.equal(c.PASS_RATE, c.defaultPassRate); assert.deepEqual(Array.from(c.NEW_LEVEL.slice(0, 4), row => row.level), [7, 15, 25, 35]); assert.deepEqual(Array.from(c.NEW_GUIDE, row => row.level), [8, 14, 18]); } }); class Node { constructor(name) { Object.assign(this, { name, children: [], components: {}, active: true }); } addChild(node) { node.parent = this; this.children.push(node); } getChildByName(name) { return this.children.find(node => node.name === name); } getComponent(type) { return this.components[type]; } } function inflate(data, id) { const raw = data[id], node = new Node(raw._name); node.active = raw._active; for (const ref of raw._components || []) { const component = data[ref.__id__]; node.components[component.__type__] = { ...component, string: component._string }; } for (const ref of raw._children || []) node.addChild(inflate(data, ref.__id__)); return node; } function runtime(c) { const cc = { Button: 'cc.Button', Label: 'cc.Label', Sprite: 'cc.Sprite', fx: { GameConfig: c, StorageMessage: { getStorage: () => ({ authorize: true }) }, GameTool: {} }, instantiate(node) { const copy = new Node(node.name); copy.components = structuredClone(node.components); return copy; }, tween() { const chain = { to() { return chain; }, delay() { return chain; }, start() {} }; return chain; }, }; return cc; } test('A and unassigned users follow the B daily-quest visibility and unlock boundaries', () => { const c = config(), cc = runtime(c); const data = json('assets/Scene/HomeScene.fire'); const topId = data.findIndex(n => n._name === 'Top' && n.__type__ === 'cc.Node'); const Home = loadMembers(homeFile, ['refreshNewPlayerActivityButtons', 'setNewPlayerActivityLock', 'setJungleTreasureHomeButtonVisible'], { cc }); for (const [group, raw] of [['A', assignment('A')], ['1', assignment(1)], ['empty', {}], ['missing', undefined]]) { c.GM_INFO.abTestAssignments = raw; for (const level of [0, 19, 20, 34, 35, 100]) { c.GM_INFO.level = level; const top = inflate(data, topId); const home = Object.assign(new Home(), { node: { getChildByName: () => ({ getChildByName: () => top }) }, getJungleTreasureHomeButton: () => top.getChildByName('jungle'), refreshHomeActivityEntryLayout() {}, }); home.refreshNewPlayerActivityButtons(); const day = top.getChildByName('day'), context = `${group}, cleared ${level}`; assert.equal(day.active, level >= 20, `${context}: visible`); assert.equal(day.getComponent(cc.Button).interactable, level >= 35, `${context}: clickable`); assert.equal(day.getChildByName('mask').active, level < 35, `${context}: locked`); } } }); test('Jungle stays hidden until unlocked while other home entries retain their level-20 previews', () => { const c = config(), cc = runtime(c); const data = json('assets/Scene/HomeScene.fire'); const top = inflate(data, data.findIndex(n => n._name === 'Top' && n.__type__ === 'cc.Node')); const Home = loadMembers(homeFile, ['refreshNewPlayerActivityButtons', 'setNewPlayerActivityLock', 'setJungleTreasureHomeButtonVisible'], { cc }); const home = Object.assign(new Home(), { node: { getChildByName: () => ({ getChildByName: () => top }) }, getJungleTreasureHomeButton: () => top.getChildByName('jungle'), refreshHomeActivityEntryLayout() {} }); for (const level of [19, 20, 34, 35, 39, 40, 49, 50]) { c.GM_INFO.level = level; home.refreshNewPlayerActivityButtons(); home.setJungleTreasureHomeButtonVisible(true); for (const [name, unlock] of [['day', 36], ['jungle', 51], ['hammer', 51]]) { const button = top.getChildByName(name), locked = level < unlock - 1; assert.equal(button.active, name === 'jungle' ? !locked : level >= 20, `${level}: ${name} visible`); assert.equal(button.getComponent(cc.Button).interactable, !locked, `${level}: ${name} clickable`); const mask = button.getChildByName('mask'); assert.equal(mask.active, locked, `${level}: ${name} mask`); assert.equal(mask.getChildByName('New Label').getComponent(cc.Label).string, String(unlock)); if (name === 'jungle') { assert.equal(button.getChildByName('timeBg').active, !locked); assert.equal(button.getChildByName('time').active, !locked); } } } home.setJungleTreasureHomeButtonVisible(false); assert.equal(top.getChildByName('jungle').active, false, 'unlocked activity still follows the original visibility result'); }); test('unlocked Jungle login clears the scene mask for every AB group', () => { const c = config(), cc = runtime(c); const data = json('assets/Scene/HomeScene.fire'); const topId = data.findIndex(n => n._name === 'Top' && n.__type__ === 'cc.Node'); const Home = loadMembers(homeFile, ['initializeJungleTreasureEntryFromLogin', 'canOpenJungleTreasure', 'getJungleTreasureHomeButton', 'setJungleTreasureHomeButtonVisible', 'setNewPlayerActivityLock', 'refreshJungleTreasureHomeButton'], { cc }); for (const [group, raw] of [['A', assignment('A')], ['empty', {}], ['missing', undefined], ['B', assignment('B')]]) { c.GM_INFO.abTestAssignments = raw; for (const level of [65, 50, '65']) { c.GM_INFO.level = level; const top = inflate(data, topId); let requests = 0; const home = Object.assign(new Home(), { node: { getChildByName: () => ({ getChildByName: () => top }) }, deferHomePopupWhileTransfer: () => false, refreshHomeActivityEntryLayout() {}, refreshJungleTreasureHomeRedDot() {}, requestJungleTreasureData() { requests++; this.refreshJungleTreasureHomeButton([0]); }, }); home.initializeJungleTreasureEntryFromLogin(); const jungle = top.getChildByName('jungle'), context = `${group}, cleared ${level}`; assert.equal(requests, 1, `${context}: activity loaded`); assert.equal(jungle.active, true, `${context}: visible`); assert.equal(jungle.getChildByName('mask').active, false, `${context}: unlocked`); assert.equal(jungle.getComponent(cc.Button).interactable, true, `${context}: clickable`); for (const name of ['time', 'timeBg']) assert.equal(jungle.getChildByName(name).active, true); home.refreshJungleTreasureHomeButton([2]); assert.equal(jungle.active, false, `${context}: completed activity hidden`); } } }); test('locked Jungle does not request or start the activity', () => { const c = config(20), cc = runtime(c); const Home = loadMembers(homeFile, ['initializeJungleTreasureEntryFromLogin', 'canOpenJungleTreasure'], { cc }); let reads = 0; const home = Object.assign(new Home(), { deferHomePopupWhileTransfer: () => false, setJungleTreasureHomeButtonVisible() {}, refreshJungleTreasureHomeRedDot() {}, requestJungleTreasureData() { reads++; } }); for (const level of [20, 35, 39, 40, 49]) { c.GM_INFO.level = level; home.initializeJungleTreasureEntryFromLogin(); } assert.equal(reads, 0); c.GM_INFO.level = 50; home.initializeJungleTreasureEntryFromLogin(); assert.equal(reads, 1); }); test('front-end Career is hidden without counting through 15 and reused node appears at 16', () => { const c = config(), cc = runtime(c); const data = json('assets/win/prefab/Win.prefab'); const root = inflate(data, data.findIndex(n => n.__type__ === 'cc.Node' && n._children?.some(ref => data[ref.__id__]._name === 'Career'))); const Map = loadMembers(mapFile, ['createCareer'], { cc, NumberToImage: { numberToImageNodes6() {} } }); const map = Object.assign(new Map(), { getWinRoot: () => root, settlementAnimationTime: value => value }); for (const level of [1, 14, 15]) { c.GM_INFO.level = level; map.createCareer(); assert.equal(c.GM_INFO.addLevel, 0); for (const name of ['Career', 'Rank', 'Ruzhi']) assert.equal(root.getChildByName(name).active, false); } c.GM_INFO.level = 16; map.createCareer(); assert.equal(c.GM_INFO.addLevel, 1); assert.equal(root.getChildByName('Career').active, true); }); test('server counts only victories after 15, including batches crossing the boundary', async () => { const cases = [ ['B', 0, 1, 0], ['B', 14, 15, 0], ['B', 15, 16, 1], ['B', 13, 18, 3], ['B', 15, 20, 5], ['B', 18, 18, 0], ['B', 50, 51, 1], ['A', 14, 15, 0], [0, 0, 1, 0], [2, 14, 15, 0], ['2', 15, 16, 1], ].map(([group, oldLevel, level, expected]) => [assignment(group), oldLevel, level, expected]); cases.push( [JSON.stringify(assignment('B')), 14, 15, 0], [JSON.stringify(assignment('A')), 14, 15, 0], [undefined, 14, 15, 0], ['invalid', 14, 15, 0], [{ layer_1: { name: 'another', group: 'B' } }, 14, 15, 0], [{ layer_1: { name: 'new1_50', group: 'B', enabled: false } }, 14, 15, 0], ); for (const [abTests, oldLevel, level, expected] of cases) for (const oldAdd of [0, 7]) { const user = { _id: 'user', levelAmount: oldLevel, addLevel: oldAdd, abTests, film: 100, isWhite: true, address: '浙江' }; let update; const cloud = { database: () => ({ collection: () => ({ where: () => ({ getOne: async () => ({ data: user }), update: async payload => { update = payload; return { updated: 1 }; } }) }) }) }; const api = loadModule('server/laf-cloud/functions/userLevel.ts', { '@lafjs/cloud': { default: cloud }, '@/Utils': { default: ServerUtils }, '@/goldMiner/service': {}, '@/goldMiner/config': {}, ip2region: { default: class { search() { return { province: '浙江省' }; } } }, }, { process: { env: { SRANK_ID: 'rank' } } }).default; const result = await api({ body: { uid: 'user', action: 'save', levelAmount: level }, headers: {} }); assert.equal(result.code, 1); assert.equal(update.addLevel, oldAdd + expected, `${JSON.stringify(abTests)}: ${oldLevel} -> ${level}; existing ${oldAdd}`); assert.equal(update.film, 100 + level - oldLevel, 'film still follows all cleared levels'); assert.equal(update.levelAmount, level); } }); test('seven-day activity uses level 15 for every assignment', () => { const service = loadModule('server/laf-cloud/functions/SignInActivity.ts', { '@lafjs/cloud': { default: { database: () => ({}) } }, crypto: require('node:crypto'), }); for (const group of ['A', 'B', 0, '2']) { assert.equal(service.getUserSignInTriggerLevel({ abTests: assignment(group) }, { triggerLevel: 23 }), 15); } }); test('all users activate seven-day activity at cleared level 15 with the original duration', async () => { const activity = json('server/laf-cloud/sign-in-activity-config.json'); const cloud = { database: () => ({ collection: () => ({ where: () => ({ get: async () => ({ data: [activity] }), update: async () => ({ updated: 1 }), }) }) }) }; const service = loadModule('server/laf-cloud/functions/SignInActivity.ts', { '@lafjs/cloud': { default: cloud }, crypto: require('node:crypto'), }); for (const [group, level, expected] of [['B', 14, false], ['B', 15, true], ['A', 14, false], ['A', 15, true], [0, 15, true]]) { const result = await service.activateSignInActivity({ _id: 'player', levelAmount: level, abTests: assignment(group) }); assert.equal(result.activated, expected); if (expected) assert.equal(result.state.endAt - result.state.startAt, activity.durationDays * 86400000); } }); test('clearing level 15 continues to the next game, including new-player B', () => { for (const [group, level, help] of [['B', 15, 0], ['A', 15, 0], ['B', 16, 0], ['B', 15, 15]]) { const c = config(level, group), cc = runtime(c); c.GM_INFO.otherLevel = help; const nextGames = []; c.LEVEL_INFO_init = (...args) => nextGames.push(args); cc.fx.GameTool.maxLevel = () => false; cc.fx.AudioManager = { _instance: { playEffect() {} } }; const Map = loadMembers(mapFile, ['winLevel'], { cc, sp: { Skeleton: 'skeleton' }, setTimeout(callback) { callback(); } }); const node = { getChildByName() { return node; }, getComponent() { return { setAnimation() {} }; } }; node.parent = node; let homes = 0; const button = { _touch: true, setTouch(value) { this._touch = value; } }; const map = Object.assign(new Map(), { node, isMapRuntimeAlive: () => true, clearLayerTwoWinCoinFly() {}, uploadToCloud() {}, getSettlementNextButton: () => ({ getComponent: () => button }), returnHome() { homes++; } }); map.winLevel(); assert.equal(homes, 0); assert.deepEqual(nextGames, [[true, 0, false]]); } }); test('B special hammer remains unusable until level 51 despite ten accumulated wins', () => { const c = config(49), cc = runtime(c); c.GM_INFO.winStreak = 10; c.usesRainbowWinStreakUI = () => false; const Map = loadMembers(mapFile, ['useHammerSpecial'], { cc }); let checks = 0; const map = new Map(); Object.defineProperty(map, 'gameOver', { get() { checks++; return true; } }); map.useHammerSpecial(); assert.equal(checks, 0); c.GM_INFO.level = 50; map.useHammerSpecial(); assert.equal(checks, 1); }); test('10-win progress and failure reset retain their original behavior before unlock', () => { const c = config(30), cc = runtime(c); cc.fx.StorageMessage.setStorage = () => {}; const Tool = loadMembers('assets/Script/module/Tool/GameTool.ts', ['setWinStreak'], { cc, Utils: { setWinStreak() {} } }); const tool = new Tool(); for (let i = 0; i < 10; i++) tool.setWinStreak('sucess'); assert.equal(c.GM_INFO.winStreak, 10); assert.equal(c.GM_INFO.winStreakFirst, true); assert.equal(c.isWinStreakUnlocked(), false); assert.equal(c.shouldShowWinStreakGuide(), false); c.GM_INFO.level = 50; assert.equal(c.isWinStreakUnlocked(), true); assert.equal(c.shouldShowWinStreakGuide(), true); c.GM_INFO.level = 30; tool.setWinStreak('fail'); assert.equal(c.GM_INFO.winStreak, 0); c.GM_INFO.level = 50; assert.equal(c.shouldShowWinStreakGuide(), false, 'a stale first-guide flag cannot revive a cleared streak'); });