const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const { loadMembers, withNewPlayerConfig, ServerUtils, loadModule } = 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(file, 'utf8')); test('front and back agree on B/2; A, missing, disabled and unrelated experiments keep defaults', () => { const service = loadModule('server/laf-cloud/functions/SignInActivity.ts', { '@lafjs/cloud': { default: { database: () => ({}) } }, crypto: require('node:crypto'), }); for (const [raw, expected] of [ [assignment('B'), true], [assignment(2), true], [assignment('2'), true], [assignment('A'), false], [assignment(1), false], [assignment('1'), false], [assignment(0), false], [{}, false], [undefined, false], [{ layer_1: { name: 'another', group: 'B' } }, false], [{ layer_1: { name: 'new1_50', group: 'B', enabled: false } }, false], ]) { const c = config(); c.GM_INFO.abTestAssignments = raw; assert.equal(c.isNewPlayerB(), expected); assert.equal(service.getUserSignInTriggerLevel({ abTests: raw }, { triggerLevel: 23 }), expected ? 15 : 23); assert.equal(service.getUserSignInTriggerLevel({ abTests: JSON.stringify(raw) }, { triggerLevel: 23 }), expected ? 15 : 23); } assert.equal(service.getUserSignInTriggerLevel({ abTests: 'invalid' }, { triggerLevel: 23 }), 23); }); test('B mainline switches at 50/51; A, help and endless keep Json', 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]) { const expected = group === 'B' && level <= 50 ? 'Json2' : 'Json'; assert.equal(c.getLevelConfigPath(level), `${expected}/level${level}`); await Loader.loadLevelFromBundle(level); assert.equal(requests.at(-1), `${expected}/level${level}`); await Loader.loadLevelFromBundle(level, false); assert.equal(requests.at(-1), `Json/level${level}`); } } }); test('both introduction tables select B after login and keep NEW_LEVEL2 beyond 50', () => { const c = config(0, 'A'); c.defaultNewLevel = json('assets/resources/Json/NEW_LEVEL.json').NEW_LEVEL; c.newPlayerNewLevel = json('assets/resources/Json/NEW_LEVEL2.json').NEW_LEVEL; c.defaultNewGuide = json('assets/resources/Json/NEW_GUIDE.json').NEW_GUIDE; 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, 11, 16]); c.GM_INFO.abTestAssignments = assignment('B'); for (const level of [0, 49, 50, 100]) { c.GM_INFO.level = level; assert.equal(c.NEW_LEVEL, c.newPlayerNewLevel); assert.deepEqual(Array.from(c.NEW_LEVEL.slice(0, 4), row => row.level), [6, 11, 24, 30]); 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('daily quests stay visible and unlocked for A and users without AB assignments', () => { 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'], { 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 }) }, }); home.refreshNewPlayerActivityButtons(); const day = top.getChildByName('day'), context = `${group}, cleared ${level}`; assert.equal(day.active, true, `${context}: visible`); assert.equal(day.getComponent(cc.Button).interactable, true, `${context}: clickable`); assert.equal(day.getChildByName('mask').active, false, `${context}: unlocked`); } } }); test('home entries are hidden before 20, locked with labels at 20 and unlock at 35/40/50', () => { 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(); if (level >= 40) home.setJungleTreasureHomeButtonVisible(true); for (const [name, unlock] of [['day', 36], ['jungle', 41], ['hammer', 51]]) { const button = top.getChildByName(name), locked = level < unlock - 1; assert.equal(button.active, 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); c.getJungleTreasureUnlockLevel = () => 40; 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, 40, '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('displaying a locked Jungle entry does not request or start the activity', () => { const c = config(20), cc = runtime(c); c.getJungleTreasureUnlockLevel = () => 40; 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]) { c.GM_INFO.level = level; home.initializeJungleTreasureEntryFromLogin(); } assert.equal(reads, 0); c.GM_INFO.level = 40; 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, 1], [0, 0, 1, 1], [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, 1], [undefined, 14, 15, 1], ['invalid', 14, 15, 1], [{ layer_1: { name: 'another', group: 'B' } }, 14, 15, 1], [{ layer_1: { name: 'new1_50', group: 'B', enabled: false } }, 14, 15, 1], ); for (const [abTests, oldLevel, level, expected] of cases) { const user = { _id: 'user', levelAmount: oldLevel, addLevel: 0, 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 }, 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, expected, `${JSON.stringify(abTests)}: ${oldLevel} -> ${level}`); assert.equal(update.film, 100 + level - oldLevel, 'film still follows all cleared levels'); assert.equal(update.levelAmount, level); } }); test('seven-day activity uses B=15 while other players keep the configured trigger', () => { 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 }), ['B', '2'].includes(group) ? 15 : 23); } }); test('B seven-day activity activates at cleared level 15 and retains 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', 15, false], ['A', 23, 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'); });