Files
MatchMaster/tools/test-free-prop-task-progress.cjs
T

243 lines
13 KiB
JavaScript

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 { stripTypeScriptTypes } = require('node:module');
const root = path.resolve(__dirname, '..');
// Optional working-tree snapshots let the same behavior tests reproduce the original bug.
const gameSource = process.env.FREE_PROP_BASELINE || path.join(root, 'assets/Script');
const mapSource = fs.readFileSync(path.join(gameSource, 'Map.ts'), 'utf8');
const blockSource = fs.readFileSync(path.join(gameSource, 'Block.ts'), 'utf8');
const trackerSource = fs.readFileSync(path.join(root, 'assets/Script/module/Task/DailyTaskProgressTracker.ts'), 'utf8');
// Run complete production methods, excluding unrelated scene initialization and decorators.
function method(source, name) {
const start = source.search(new RegExp(`^ (?:private )?${name}\\(`, 'm'));
assert.notEqual(start, -1, `production method ${name} exists`);
const body = source.slice(start);
const end = body.search(/^ }\r?$/m);
assert.notEqual(end, -1, `production method ${name} closes`);
return body.slice(0, end + 5);
}
const methods = ['useHammerSpecial', 'useHammer', 'cancleHammer', 'costHammer', 'useTimeProp',
'useMagic', 'runMagic', 'isSpawnLockedNode', 'hasUnlockedBlockTarget'];
const code = stripTypeScriptTypes([
trackerSource.replace(/^export type /gm, 'type ').replace('export default class', 'class'),
`class MapConroler { ${methods.map(name => method(mapSource, name)).join('\n')} }`,
`class Block { ${['touchStart','recordDailyTaskClear','initBlocks'].map(name=>method(blockSource,name)).join('\n')} }`,
'globalThis.classes = { MapConroler, Block, DailyTaskProgressTracker };',
].join('\n'));
const enumSource = blockSource.match(/export enum BlockType \{([\s\S]*?)^}/m)[1];
const BlockType = Object.fromEntries([...enumSource.matchAll(/"([^"]+)"\s*=\s*(\d+)/g)]
.map(([, name, value]) => [name, Number(value)]));
function node(name = '') {
const children = new Map();
const button = { _touch: true, setTouch(value) { this._touch = value; } };
return { name, active: false, children: [],
getChildByName(key) { if (!children.has(key)) children.set(key, node(key)); return children.get(key); },
getComponent(type) {
if (type === 'btnControl') return button;
if (type === 'SceneManager') return { openPropBuy() {} };
return { setAnimation() {}, setCompleteListener() {}, stopSystem() {}, resetSystem() {} };
},
setPosition() {}, stopAllActions() {},
};
}
function vector(x, y, z = 0) {
return { x, y, z, sub(v) { return vector(x - v.x, y - v.y, z - v.z); },
mag() { return Math.hypot(x, y, z); } };
}
function setup({ otherLevel = 0, winOnEliminate = false } = {}) {
const timers = [], propWrites = [], toasts = [];
const info = { otherLevel, hammerAmount: 3, freezeAmount: 3, magicAmount: 3,
tasks: { version: 2, dailyTasks: [
['all', 'use_prop'], ['hammer', 'use_specific_prop', 'hammer'],
['freeze', 'use_specific_prop', 'freeze'], ['magic_wand', 'use_specific_prop', 'magic_wand'],
['no_prop', 'no_prop_pass'], ['pass', 'pass_levels'],
].map(([taskId, metric, propType]) => ({ taskId, metric, params: { propType }, value: 0, target: 9, state: 0 })) } };
const storage = { hammerAmount: 3, freezeAmount: 3, magicAmount: 3 };
const cc = { isValid: Boolean, v2: vector, v3: vector, ParticleSystem: 'ParticleSystem',
Intersection: { pointInPolygon: point => point.inside !== false },
fx: { GameConfig: { GM_INFO: info }, AudioManager: { _instance: { playEffect() {} } },
StorageMessage: { getStorage: () => storage, setStorage() {} },
GameTool: { setUserProp: (...args) => propWrites.push(args), shushu_Track() {}, shuffleArray: array => array },
} };
const context = { cc, BlockType, Date, console: { log() {} }, sp: { Skeleton: 'Skeleton' },
MiniGameSdk: { API: { showToast: message => toasts.push(message) } },
NumberToImage: { getTimeMargi3() {} },
setTimeout(callback, delay) { timers.push({ callback, delay }); return timers.length; }, clearTimeout() {},
};
vm.runInNewContext(code, context);
const { MapConroler, Block, DailyTaskProgressTracker: tracker } = context.classes;
const map = new MapConroler();
MapConroler._instance = map;
const parent = node('game'); parent.parent = node(); parent.parent.parent = node();
map.node = node('map'); map.node.parent = parent;
Object.assign(map, { gameOver: false, gameWin: false, touchIng: false, timeNumber: 100, pause: false,
hammer: false, hammerSpecial: false, hammerMask: node(), magicMask: node(), freezeMask: node(),
winStreakBtn: { node: node() }, destroyBtn: { node: node() }, icetimeNode: [node(), node(), node()],
freezeVersion: 0, iceTrue: () => false, isPauseOpen: () => false, stopBoom() {}, startBoom() {},
setPropNum() {}, usePause() {}, scheduleOnce() {}, getSmoothPath: points => points.slice(0, 2),
lockArray: [], lockArray2: [], freezeArray: [], questionArray: [], magicEffectIndex: 0,
initMagicEffects() {
const effect = node(); const inner = node(); inner.children = [node(), node(), node(), node()];
effect.children = [inner]; this.magicEffect1 = effect; this.magicEffect2 = effect;
},
});
const eliminations = [];
function makeBlock() {
const block = new Block();
Object.assign(block, { type: BlockType['普通块'], flowerType: 0, block_Info: {},
collider: { world: { points: [] } }, over: false, spawnLocked: false,
eliminate2() {
eliminations.push(this);
map.hammer = false;
if (winOnEliminate) tracker.recordLevelWon();
},
eliminate() { eliminations.push(this); },
});
const blockNode = node(); blockNode.getComponent = () => block; block.node = blockNode;
return block;
}
const block = makeBlock(); const secondBlock = makeBlock();
map.blocks = [block.node, secondBlock.node];
tracker.beginLevel();
const value = id => info.tasks.dailyTasks.find(task => task.taskId === id).value;
const touch = (target = block, inside = true) => target.touchStart({ getLocation: () => ({ inside }) });
const runShortTimers = () => { for (const timer of timers.splice(0)) if (timer.delay <= 300) timer.callback(); };
return { map, block, secondBlock, tracker, info, value, touch, eliminations, propWrites, toasts, runShortTimers };
}
test('size tasks count occupied cells for straight and L shapes, independent of bounding boxes', () => {
for (const [shape,cells] of [[0,1],[1,2],[2,2],[3,3],[4,3],[19,3],[20,3],[21,3],[22,3],[5,4]]) {
const h=setup();
h.info.tasks.dailyTasks=[1,2,3].map(cellCount=>({taskId:'size'+cellCount,metric:'clear_size',params:{cellCount},target:20,value:0,state:0}));
Object.assign(h.block,{block_Info:{block:shape},heng:2,shu:2,color:10});
h.block.initBlocks();h.block.recordDailyTaskClear();h.block.recordDailyTaskClear();
h.tracker.recordLevelWon();
for(const task of h.info.tasks.dailyTasks)assert.equal(task.value,task.params.cellCount===cells?1:0,'shape '+shape);
}
});
test('same bounding boxes with different occupied cell counts never merge progress', () => {
const h=setup();
h.info.tasks.dailyTasks=[3,4].map(cellCount=>({taskId:'size'+cellCount,metric:'clear_size',params:{cellCount},target:20,value:0,state:0}));
h.tracker.record({metric:'clear_size',width:2,height:2,cellCount:3});
h.tracker.record({metric:'clear_size',width:2,height:2,cellCount:4});
h.tracker.recordLevelWon();
assert.deepEqual(h.info.tasks.dailyTasks.map(t=>t.value),[1,1]);
});
for (const free of [true, false]) {
test(`${free ? 'free streak' : 'inventory'} hammer counts once after a valid hit and survives level completion`, () => {
const s = setup();
if (free) s.map.useHammerSpecial(); else s.map.useHammer();
assert.equal(s.value('all'), 0, 'selecting the hammer has not used it yet');
s.touch();
s.tracker.recordLevelWon();
assert.equal(s.eliminations.length, 1);
assert.equal(s.value('all'), 1);
assert.equal(s.value('hammer'), 1);
assert.equal(s.value('no_prop'), 0);
assert.equal(s.value('pass'), 1);
assert.equal(s.info.hammerAmount, free ? 3 : 2);
assert.equal(s.propWrites.length, free ? 0 : 1);
if (free) assert.equal(s.map.use_frenzy, true);
});
test(`${free ? 'free streak' : 'inventory'} hammer is recorded before a final block can trigger a win`, () => {
const s = setup({ winOnEliminate: true });
if (free) s.map.useHammerSpecial(); else s.map.useHammer();
s.touch();
assert.equal(s.value('no_prop'), 0);
assert.equal(s.value('hammer'), 1);
assert.equal(s.value('pass'), 1);
});
test(`canceling the ${free ? 'free' : 'inventory'} hammer does not count or consume it`, () => {
const s = setup();
if (free) s.map.useHammerSpecial(); else s.map.useHammer();
s.map.cancleHammer();
s.tracker.recordLevelWon();
assert.equal(s.value('all'), 0);
assert.equal(s.value('hammer'), 0);
assert.equal(s.value('no_prop'), 1);
assert.equal(s.info.hammerAmount, 3);
assert.equal(s.propWrites.length, 0);
});
}
for (const [name, change] of [
['spawn-locked', s => { s.block.spawnLocked = true; }],
['already cleared', s => { s.block.over = true; }],
['frozen', s => { s.block.type = BlockType['冻结块']; }],
['question', s => { s.block.type = BlockType['问号块']; }],
['floor', s => { s.block.block_Info = { floor: true, floorMove: true }; }],
['flower floor', s => { s.block.flowerType = 2; }],
['single-color floor', s => { s.block.type = BlockType['单色地块']; }],
['missing collider', s => { s.block.collider.world = null; }],
['another drag', s => { s.map.touchIng = true; }],
['game over', s => { s.map.gameOver = true; }],
]) {
test(`hammer rejects ${name} targets without counting or consuming a prop`, () => {
for (const free of [true, false]) {
const s = setup();
if (free) s.map.useHammerSpecial(); else s.map.useHammer();
change(s); s.touch();
assert.equal(s.eliminations.length, 0);
assert.equal(s.value('all'), 0);
assert.equal(s.info.hammerAmount, 3);
}
});
}
test('touching outside the hammer target does not count', () => {
const s = setup(); s.map.useHammerSpecial(); s.touch(s.block, false);
assert.equal(s.value('all'), 0);
assert.equal(s.eliminations.length, 0);
});
for (const [prop, entry, inventory] of [['freeze', 'useTimeProp', 'freezeAmount'], ['magic_wand', 'useMagic', 'magicAmount']]) {
test(`gifted inventory ${prop} counts exactly once and prevents no-prop completion`, () => {
const s = setup(); s.info[inventory] = 1;
s.map[entry](); s.runShortTimers(); s.tracker.recordLevelWon();
assert.equal(s.value('all'), 1);
assert.equal(s.value(prop), 1);
assert.equal(s.value('no_prop'), 0);
assert.equal(s.info[inventory], 0);
assert.equal(s.propWrites.length, 1);
if (prop === 'magic_wand') assert.equal(s.eliminations.length, 2, 'two eliminated targets count as one wand');
});
for (const [condition, change] of [
['empty inventory', s => { s.info[inventory] = 0; }],
['game over', s => { s.map.gameOver = true; }],
['game won', s => { s.map.gameWin = true; }],
['no time', s => { s.map.timeNumber = 1; }],
['button disabled', s => s.map.node.parent.getChildByName('Bottom').getChildByName(prop === 'freeze' ? 'timeBtn' : 'magicBtn').getComponent('btnControl').setTouch(false)],
]) {
test(`${prop} does not count when ${condition}`, () => {
const s = setup(); change(s); const before = s.info[inventory];
s.map[entry]();
assert.equal(s.value('all'), 0);
assert.equal(s.value(prop), 0);
assert.equal(s.info[inventory], before);
assert.equal(s.propWrites.length, 0);
});
}
}
test('an already active freeze does not count again', () => {
const s = setup(); s.map.pause = true; s.map.useTimeProp();
assert.equal(s.value('freeze'), 0); assert.equal(s.info.freezeAmount, 3);
});
test('a wand cannot count when every target is spawn-locked', () => {
const s = setup(); s.block.spawnLocked = true; s.secondBlock.spawnLocked = true; s.map.useMagic();
assert.equal(s.value('magic_wand'), 0); assert.equal(s.info.magicAmount, 3);
});
test('props used in other-level modes retain the existing daily-task exclusion', () => {
for (const entry of ['useHammerSpecial', 'useHammer', 'useTimeProp', 'useMagic']) {
const s = setup({ otherLevel: 1 }); s.map[entry]();
if (entry.startsWith('useHammer')) s.touch();
assert.equal(s.value('all'), 0);
}
});