300 lines
16 KiB
JavaScript
300 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__].sharedMaterials = component._materials;
|
|
}
|
|
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' },
|
|
});
|
|
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;
|
|
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] : []);
|
|
}
|
|
}
|
|
});
|