MatchMaster/tools/test-new-player-ab.cjs

305 lines
18 KiB
JavaScript

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('only B clearing level 15 routes the next-level button home', () => {
for (const [group, level, help, expected] of [['B', 15, 0, 1], ['A', 15, 0, 0], ['B', 16, 0, 0], ['B', 15, 15, 0]]) {
const c = config(level, group), cc = runtime(c); c.GM_INFO.otherLevel = help;
cc.fx.GameTool.maxLevel = () => false;
cc.fx.AudioManager = { _instance: { playEffect() {} } };
const Map = loadMembers(mapFile, ['winLevel'], { cc, sp: { Skeleton: 'skeleton' }, setTimeout() {} });
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, expected);
}
});
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;
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');
});