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); } });