243 lines
13 KiB
JavaScript
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);
|
|
}
|
|
});
|