MatchMaster/tools/test-prop-guide.cjs
guanchao 8fb2ef1be8 Merge branch 'feat-cloudRise'
# Conflicts:
#	assets/Script/Map.ts
#	assets/UI/UI/start/startUI2.plist
#	assets/UI/UI/start/startUI2.plist.meta
#	assets/UI/UI/start/startUI2.png
#	assets/UI/UI/start/startUI2.png.meta
#	settings/project.json
#	tools/test-prop-guide.cjs
2026-09-24 17:12:25 +08:00

312 lines
16 KiB
JavaScript

// Run: node --test tools/test-prop-guide.cjs
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const ts = require('typescript');
const { withNewPlayerConfig } = require('./new-player-ab-test-support.cjs');
const root = path.resolve(__dirname, '..');
const read = file => JSON.parse(fs.readFileSync(path.join(root, file), 'utf8'));
function loadClass(file, names, globals) {
const source = ts.createSourceFile(file, fs.readFileSync(path.join(root, file), 'utf8'), ts.ScriptTarget.Latest, true);
const declaration = source.statements.find(ts.isClassDeclaration);
const members = names.map(name => {
const member = declaration.members.find(node => node.name && node.name.getText(source) === name);
assert.ok(member, name);
return member.getText(source);
});
return vm.runInNewContext(ts.transpileModule(`class Controller { ${members.join('\n')} }\nController;`, {
compilerOptions: { target: ts.ScriptTarget.ES2017 },
}).outputText, globals);
}
class Node {
static EventType = Object.fromEntries(['START', 'MOVE', 'END', 'CANCEL'].map(key => [`TOUCH_${key}`, key]));
constructor(name) { this.name = name; this.children = []; this.listeners = []; this.components = {}; this.zIndex = 0; this.x = this.y = 0; }
set parent(value) {
if (this._parent) this._parent.children.splice(this._parent.children.indexOf(this), 1);
this._parent = value;
if (value) value.children.push(this);
}
get parent() { return this._parent; }
getChildByName(name) { return this.children.find(child => child.name === name); }
getComponent(type) { return this.components[type]; }
addComponent(type) { return this.components[type] = {}; }
get position() { return { x: this.x, y: this.y }; }
setPosition(x, y) { this.x = typeof x === 'object' ? x.x : x; this.y = typeof x === 'object' ? x.y : y; }
convertToWorldSpaceAR(point) {
const result = { x: point.x + this.x, y: point.y + this.y };
return this.parent ? this.parent.convertToWorldSpaceAR(result) : result;
}
convertToNodeSpaceAR(point) {
const origin = this.convertToWorldSpaceAR({ x: 0, y: 0 });
return { x: point.x - origin.x, y: point.y - origin.y };
}
getSiblingIndex() { return this.parent.children.indexOf(this); }
setSiblingIndex(index) {
const siblings = this.parent.children;
siblings.splice(siblings.indexOf(this), 1); siblings.splice(index, 0, this);
}
on(type, fn, target, capture = false) { this.listeners.push({ type, fn, target, capture }); }
off(type, fn, target, capture = false) {
this.listeners = this.listeners.filter(l => !(l.type === type && l.fn === fn && l.target === target && l.capture === capture));
}
emit(type, event) { for (const l of [...this.listeners]) if (l.type === type) l.fn.call(l.target, event); }
click() {
if (!this.active || this.destroyed) return;
for (const event of this.getComponent('cc.Button').clickEvents) {
const component = event.target.getComponent(event.component);
assert.ok(component, `registered component ${event.component}`);
component[event.handler]({}, event.customEventData);
}
}
removeFromParent() { this.parent = null; }
destroy() { this.destroyed = true; }
}
function inflate(data, id) {
const raw = data[id], node = new Node(raw._name);
node.active = raw._active;
if (raw._trs) node.setPosition(raw._trs.array[0], raw._trs.array[1]);
for (const ref of raw._components || []) {
const component = data[ref.__id__];
node.components[component.__type__] = { ...component, updateAlignment() { }, setState() { } };
if (component.__type__ === 'cc.Sprite') {
node.components[component.__type__].getMaterial = index => component._materials[index] || null;
}
}
for (const ref of raw._children || []) inflate(data, ref.__id__).parent = node;
return node;
}
function fixture(level, group = 'A') {
const prefab = read('assets/prefab/pop/GuideMask.prefab');
const scene = read('assets/Scene/GameScene.fire');
const gameNode = inflate(scene, scene.findIndex(node => node._name === 'GameNode'));
const info = {
level: level - 1, otherLevel: 0, hammerAmount: 3, freezeAmount: 3, magicAmount: 3,
tasks: { useProp: { value: 0, target: 0 } }
};
info.abTestAssignments = { layer_1: { name: 'new1_50', group } };
const cc = {
Node, Button: 'cc.Button', Widget: 'cc.Widget', Sprite: { toString: () => 'cc.Sprite', State: { NORMAL: 0, GRAY: 1 } },
v2: (x = 0, y = 0) => ({ x, y }),
find: (path, node) => path.split('/').reduce((parent, name) => parent.getChildByName(name), node),
Component: { EventHandler: class { } }, js: { getClassName: () => 'MapConroler' },
error() { }, isValid: value => !!value && !value.destroyed, instantiate: () => inflate(prefab, 1),
fx: {
GameConfig: { GM_INFO: info }, StorageMessage: { getStorage: () => null },
GameTool: { setUserProp() { }, shushu_Track() { } },
AudioManager: { _instance: { playEffect() { } } }
},
};
withNewPlayerConfig(cc.fx.GameConfig);
const Map = loadClass('assets/Script/Map.ts', ['propGuideMask', 'guideHammerPending', 'showPropGuideMask', 'blockPropGuideTouch',
'setPropGuideTouchBlocked', 'destroyPropGuideMask', 'useHammer', 'cancleHammer', 'costHammer', 'useTimeProp', 'useMagic'], {
cc, GuideHand: 'GuideHand', MiniGameSdk: { API: { showToast() { } } }, NumberToImage: { getTimeMargi3() { } }, sp: { Skeleton: 'sp.Skeleton' },
CloudRiseService: { prepareRound: () => true },
});
const map = new Map();
Object.assign(map, {
node: gameNode.getChildByName('Map'), guideMaskPrefab: prefab,
timeNumber: 100, pause: false, freezeVersion: 0, magicMask: { active: false }, hammerMask: {}, freezeMask: {},
hasUnlockedBlockTarget: () => true, stopBoom() { }, setPropNum() { }, iceTrue: () => false, isPauseOpen: () => false,
runMagic() { this.magicMask.active = true; },
icetimeNode: [{}, {}, { getChildByName: () => ({ getComponent: () => ({ setAnimation() { }, setCompleteListener() { } }) }) }],
});
map.node.components.MapConroler = map;
const cancelNode = gameNode.getChildByName('Mask').getChildByName('bottom').getChildByName('destroyBtn');
cancelNode.getComponent('cc.Button').clickEvents = cancelNode.getComponent('cc.Button').clickEvents.map(ref => {
const event = scene[ref.__id__];
assert.equal(event.handler, 'cancleHammer');
assert.equal(scene[event.target.__id__]._name, 'Map');
return { ...event, target: map.node, component: 'MapConroler' };
});
for (const name of ['destroyBtn', 'timeBtn', 'magicBtn']) {
gameNode.getChildByName('Bottom').getChildByName(name).components.btnControl = {
_touch: true, setTouch(value) { this._touch = value; },
};
}
return { cc, map, info, gameNode, Map };
}
for (const [level, name, amount] of [[8, 'destroyBtn', 'hammerAmount'], [11, 'timeBtn', 'freezeAmount'], [16, 'magicBtn', 'magicAmount']]) {
test(`level ${level}: correct button, below Map, blocks board, uses prop and destroys overlay`, () => {
const { map, info, gameNode } = fixture(level);
map.showPropGuideMask(level);
const guide = map.propGuideMask;
assert.ok(guide.getComponent('cc.BlockInputEvents')._enabled);
assert.ok(gameNode.getChildByName('Mask').getSiblingIndex() < guide.getSiblingIndex());
assert.ok(guide.getSiblingIndex() < map.node.getSiblingIndex());
assert.equal(guide.zIndex, map.node.zIndex);
const buttons = guide.getChildByName('bottom').children;
assert.deepEqual(buttons.filter(button => button.active).map(button => button.name), [name]);
assert.equal(map.node.listeners.length, 4);
for (const listener of map.node.listeners) {
assert.equal(listener.capture, true);
let stopped = false;
map.node.emit(listener.type, { stopPropagation() { stopped = true; } });
assert.equal(stopped, true);
}
map.showPropGuideMask(level);
assert.equal(map.propGuideMask, guide);
const activeButton = buttons.find(button => button.active);
const rippleSprite = gameNode.getChildByName('Bottom').getChildByName('rippleShrink').getComponent('cc.Sprite');
assert.ok(rippleSprite.getMaterial(0));
assert.equal(activeButton.getChildByName('hand').getComponent('GuideHand').rippleMaterial, rippleSprite.getMaterial(0));
assert.equal(activeButton.getComponent('cc.Button').clickEvents[0].customEventData, 'guide');
activeButton.click();
assert.equal(map.propGuideMask, null);
assert.equal(guide.parent, null);
assert.equal(guide.destroyed, true);
assert.equal(map.node.listeners.length, 0);
assert.equal(map.guideHammerPending, level === 8);
if (level === 8) {
assert.equal(map.hammer, true); // Hammer is charged when a block is selected.
assert.equal(map.hammerMask.active, true);
assert.equal(info[amount], 3);
} else assert.equal(info[amount], 2);
if (level === 11) { assert.equal(map.freezeMask.active, true); assert.equal(map.timeNumber, 120); }
if (level === 16) assert.equal(map.magicMask.active, true);
});
}
for (const group of [1, 2, 3]) {
test(`streak group ${group}: prop guides follow live button positions across different parents`, () => {
for (const [level, name] of [[8, 'destroyBtn'], [11, 'timeBtn'], [16, 'magicBtn']]) {
const { cc, map, gameNode } = fixture(level);
cc.fx.GameConfig.GM_INFO.abTests = { layer_two: group };
cc.fx.GameConfig.isRainbowWinStreak = loadClass('assets/Script/module/Config/GameConfig.ts', ['isRainbowWinStreak'], {}).isRainbowWinStreak;
cc.fx.GameConfig.isOldRainbowWinStreak = loadClass('assets/Script/module/Config/GameConfig.ts', ['isOldRainbowWinStreak'], {}).isOldRainbowWinStreak;
cc.fx.GameConfig.usesRainbowWinStreakUI = loadClass('assets/Script/module/Config/GameConfig.ts', ['usesRainbowWinStreakUI'], {}).usesRainbowWinStreakUI;
cc.fx.GameConfig.isRainbowRewardActive = loadClass('assets/Script/module/Config/GameConfig.ts', ['isRainbowRewardActive'], {}).isRainbowRewardActive;
const Layout = loadClass('assets/Script/Map.ts', ['initBottomLayout'], { cc });
Layout.prototype.initBottomLayout.call(map);
const mainBottom = gameNode.getChildByName('Bottom');
// Simulate Widget updating the live bottom before the guide opens.
mainBottom.getComponent(cc.Widget).updateAlignment = () => mainBottom.setPosition(25, -830);
map.showPropGuideMask(level);
const source = mainBottom.getChildByName(name);
const guideButton = map.propGuideMask.getChildByName('bottom').getChildByName(name);
const actual = guideButton.convertToWorldSpaceAR(cc.v2());
const expected = source.convertToWorldSpaceAR(cc.v2());
assert.ok(Math.abs(actual.x - expected.x) < 1e-9, name + ' x');
assert.ok(Math.abs(actual.y - expected.y) < 1e-9, name + ' y');
assert.equal(mainBottom.x, 25, 'source Widget aligned before sampling position');
}
});
}
test('rejected prop use keeps guide and board blocking; cleanup removes both', () => {
const { map } = fixture(8);
map.hasUnlockedBlockTarget = () => false;
map.showPropGuideMask(8);
const guide = map.propGuideMask;
guide.getChildByName('bottom').getChildByName('destroyBtn').click();
assert.equal(map.propGuideMask, guide);
assert.equal(map.guideHammerPending, false);
assert.equal(map.node.listeners.length, 4);
map.destroyPropGuideMask();
map.destroyPropGuideMask();
assert.equal(guide.parent, null);
assert.equal(guide.destroyed, true);
assert.equal(map.node.listeners.length, 0);
});
test('guide hammer cannot be cancelled after GuideMask is destroyed; striking clears the restriction', () => {
const { map, info, gameNode } = fixture(8);
map.showPropGuideMask(8);
const guide = map.propGuideMask;
guide.getChildByName('bottom').getChildByName('destroyBtn').click();
assert.equal(guide.destroyed, true);
const cancelButton = gameNode.getChildByName('Mask').getChildByName('bottom').getChildByName('destroyBtn');
cancelButton.click();
cancelButton.click();
assert.equal(map.hammer, true);
assert.equal(map.ishammer, true);
assert.equal(map.hammerMask.active, true);
assert.equal(gameNode.getChildByName('Mask').active, true);
assert.equal(map.pause, false);
assert.equal(info.hammerAmount, 3);
map.costHammer(); // The valid block hit calls this existing consumption entry point.
assert.equal(info.hammerAmount, 2);
assert.equal(map.guideHammerPending, false);
map.cancleHammer();
map.lastHammerTime = null;
assert.equal(map.useHammer(), true);
map.cancleHammer();
assert.equal(map.hammer, false);
assert.equal(map.hammerMask.active, false);
assert.equal(map.pause, false);
assert.equal(info.hammerAmount, 2);
});
test('ordinary hammer is cancellable even on level 8', () => {
const { map, info, gameNode } = fixture(8);
assert.equal(map.useHammer(), true);
assert.equal(map.guideHammerPending, false);
map.cancleHammer();
assert.equal(map.hammer, false);
assert.equal(map.ishammer, false);
assert.equal(map.hammerMask.active, false);
assert.equal(gameNode.getChildByName('Mask').active, false);
assert.equal(map.pause, false);
assert.equal(gameNode.getChildByName('Bottom').getChildByName('destroyBtn').getComponent('btnControl')._touch, true);
assert.equal(info.hammerAmount, 3);
});
test('B guides move to 8/14/18 with the matching prop button', () => {
for (const [level, name, handler] of [[8, 'destroyBtn', 'useHammer'], [14, 'timeBtn', 'useTimeProp'], [18, 'magicBtn', 'useMagic']]) {
const { map } = fixture(level, 'B');
map.showPropGuideMask(level);
const button = map.propGuideMask.getChildByName('bottom').getChildByName(name);
assert.equal(button.active, true);
assert.equal(button.getComponent('cc.Button').clickEvents[0].handler, handler);
}
for (const level of [11, 16]) {
const { map } = fixture(level, 'B'); map.showPropGuideMask(level); assert.equal(map.propGuideMask, null);
}
});
test('ordinary levels, stale callbacks and friend levels do not show guide', () => {
for (const level of [1, 7, 9, 10, 12, 15, 17]) {
const { map } = fixture(level); map.showPropGuideMask(level); assert.equal(map.propGuideMask, null);
}
const { map, info } = fixture(8);
map.showPropGuideMask(11); assert.equal(map.propGuideMask, null);
info.otherLevel = 8;
map.showPropGuideMask(8); assert.equal(map.propGuideMask, null);
});
test('GameScene references the real GuideMask prefab for build inclusion', () => {
const scene = read('assets/Scene/GameScene.fire');
const map = scene.find(component => component.MapBlockPrefab);
assert.equal(map.guideMaskPrefab.__uuid__, read('assets/prefab/pop/GuideMask.prefab.meta').uuid);
});
test('old introduction hands off only after animation, and never for streak guide or a replaced scene', () => {
for (const [level, flag] of [[8, 'hammerFirst'], [11, 'freezeFirst'], [16, 'magicAFirst'], [18, 'winStreakFirst']]) {
for (const replaced of [false, true]) {
const { cc, map, info, Map } = fixture(level);
info[flag] = true; Map._instance = map;
let complete, shown = [];
map.showPropGuideMask = level => shown.push(level);
cc.tween = () => ({ to() { return this; }, call(fn) { complete = fn; return this; }, start() { } });
cc.Vec3 = { ZERO: {} };
const Guide = loadClass('assets/Script/ItemGuide.ts', ['closeGuide'], { cc, MapConroler: Map });
const guide = new Guide();
guide.node = { children: [{ getComponent: () => ({}) }], convertToNodeSpaceAR: value => value };
guide.itemGuide = {};
guide.targetNode = Array.from({ length: 3 }, () => ({ convertToWorldSpaceAR: () => ({}) }));
guide.closeGuide();
assert.deepEqual(shown, []);
if (replaced) Map._instance = null;
complete();
assert.deepEqual(shown, !replaced && level !== 18 ? [level] : []);
}
}
});