// 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]; } 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() {} }; } 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: 'cc.Sprite', 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, MiniGameSdk: { API: { showToast() {} } }, NumberToImage: { getTimeMargi3() {} }, sp: { Skeleton: 'sp.Skeleton' }, }); 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); 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; 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] : []); } } });