127 lines
11 KiB
JavaScript
127 lines
11 KiB
JavaScript
// Read-only audit of the actual local game methods. Engine nodes are test doubles;
|
|
// this is not a Cocos scene, renderer, physics or device replay.
|
|
// node scripts/audit-spawn-gates.mjs [game-root] [output-directory]
|
|
import assert from 'node:assert/strict';
|
|
import { readFileSync, readdirSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
import { stripTypeScriptTypes } from 'node:module';
|
|
import { createHash } from 'node:crypto';
|
|
import { execFileSync } from 'node:child_process';
|
|
import path from 'node:path';
|
|
import { Editor, emptyLevel, validate, position, anchor, SHAPES } from '../colorBlock-level-editor/src/model.js';
|
|
import { fromCocos } from '../colorBlock-level-generator/src/adapters/cocos.js';
|
|
import { parseLevel as compareParse } from '../colorBlock-similarity-checker/src/core/model.js';
|
|
|
|
const game = path.resolve(process.argv[2] || '/Users/ruoruo/Documents/MatchMaster/MatchMaster');
|
|
const output = path.resolve(process.argv[3] || 'outputs/spawn-gate-audit-20261015');
|
|
const sources = Object.fromEntries(['Map', 'SpawnGate'].map(name => [name, readFileSync(path.join(game, 'assets/Script', name + '.ts'), 'utf8')]));
|
|
const states = { Waiting: 0, Opening: 1, Revealing: 2, Closing: 3 };
|
|
const cc = { isValid: value => !!value && !value.destroyed, v2: (x,y) => ({x,y}), Sprite: { Type: { FILLED: 1 }, FillType: { HORIZONTAL: 1 }, SizeMode: { RAW: 1 } } };
|
|
function method(file, name) {
|
|
const source = sources[file];
|
|
const match = new RegExp('^ (?:private )?' + name + '\\(', 'm').exec(source);
|
|
assert.ok(match, name);
|
|
const end = source.indexOf('\n }', match.index);
|
|
assert.ok(end > match.index, name);
|
|
const js = stripTypeScriptTypes('class Subject {\n' + source.slice(match.index, end + 6) + '\n}', { mode: 'strip' });
|
|
return Function('cc', 'CC_DEBUG', 'SpawnGateState', js + '; return Subject.prototype[' + JSON.stringify(name) + '];')(cc, true, states);
|
|
}
|
|
const passed = [];
|
|
function check(name, run) { run(); passed.push(name); }
|
|
const editor = new Editor(emptyLevel(8,8,990019));
|
|
editor.add(0,1,0,0);
|
|
const gateIndex = editor.addSpawn(2,2,3);
|
|
for (const count of [3,2]) {
|
|
editor.batch(gateIndex,'add');
|
|
const batch = editor.data.LEVEL_INFO[0].spawnGates[gateIndex].spawnQueue.length - 1;
|
|
for (let i=0;i<count;i++) editor.editBatch(gateIndex,batch,'add',0,i+2,2+i,2);
|
|
}
|
|
const gate = editor.data.LEVEL_INFO[0].spawnGates[0];
|
|
check('editor: two batches of 3 and 2 members validate and roundtrip without changing the initial block', () => {
|
|
assert.equal(validate(editor.data).errors.length,0);
|
|
assert.deepEqual(gate.spawnQueue.map(b=>b.members.length),[3,2]);
|
|
assert.deepEqual(new Editor(JSON.parse(editor.serialize())).data,editor.data);
|
|
assert.equal(editor.data.BLOCK_INFO[0].length,1);
|
|
});
|
|
const configCheck = method('Map','validateSpawnGateConfigs');
|
|
const configContext = { mapWidth:10,mapHeight:10,Block_Array:Array(23).fill({}) };
|
|
check('game: actual config validator accepts multiple members in each 3x3 batch', () => configCheck.call(configContext,[gate]));
|
|
check('current restriction: 4x4, 4x5 and 2x3 preserve JSON fields but game/editor validation reject them', () => {
|
|
for (const [width,height] of [[4,4],[4,5],[2,3]]) {
|
|
const modified = structuredClone(editor.data); Object.assign(modified.LEVEL_INFO[0].spawnGates[0],{width,height});
|
|
assert.deepEqual(new Editor(modified).data,modified);
|
|
assert.ok(validate(modified).errors.some(e=>e.code==='spawn-bounds'));
|
|
assert.throws(()=>configCheck.call(configContext,modified.LEVEL_INFO[0].spawnGates),/only supports 2x2 or 3x3/);
|
|
}
|
|
});
|
|
const area = { id:'audit-large',x:2,y:2,width:4,height:5,spawnQueue:[] };
|
|
const tiles = Array.from({length:10},(_,x)=>Array.from({length:10},(_,y)=>({ x:x*120,y:y*120,cell:{block_Id:''},getComponent(){return this.cell;} })));
|
|
const areaEmpty = method('Map','isSpawnGateAreaEmpty');
|
|
check('game rectangle loop: 4x5 checks every cell, including the far corner outside the old 3x3 extent', () => {
|
|
assert.equal(areaEmpty.call({mapBlocksWall:tiles},area),true);
|
|
tiles[5][6].cell.block_Id='occupied';assert.equal(areaEmpty.call({mapBlocksWall:tiles},area),false);
|
|
tiles[5][6].cell.block_Id='';assert.equal(areaEmpty.call({mapBlocksWall:tiles},area),true);
|
|
});
|
|
const memberCheck = method('Map','validateCreatedSpawnGateBatch');
|
|
const singleNode = (x,y) => ({getComponent:()=>({posX:x,posY:y,allBlocks:[{x:0,y:0}]})});
|
|
check('game geometry: 4x5 corner members accepted; overlap and outside members rejected', () => {
|
|
memberCheck.call({},[singleNode(2,2),singleNode(5,6)],area,0);
|
|
assert.throws(()=>memberCheck.call({},[singleNode(2,2),singleNode(2,2)],area,0),/overlap/);
|
|
assert.throws(()=>memberCheck.call({},[singleNode(6,6)],area,0),/exceeds gate area/);
|
|
});
|
|
const square = {...area,height:4};
|
|
const members = Array.from({length:16},(_,i)=>({id:100+i*10,block:0,type:0,color:i%10+1,position:position(editor.data,1+i%4,1+Math.floor(i/4))}));
|
|
const runtime = { node:{},blocks:[],blockNum:0,boom17Blocks:[],Block_Array:Array(23).fill({}),mapBlocksWall:tiles,gameStart:true,pause:false };
|
|
for(const name of ['sortBlock','createSpawnGateBatch','instantiateSpawnGateBlock','setupSpawnGateFloorTeams','setupSpawnGateColorPairs','validateCreatedSpawnGateBatch','unlockSpawnGateBatch','isSpawnGateAreaEmpty']) runtime[name]=method('Map',name);
|
|
cc.instantiate=()=>{
|
|
const block={spawnLocked:false,setSpawnLocked(value){this.spawnLocked=value;},init(info){this.block_Info=info;this.type=info.type;const a=anchor(editor.data,info);this.posX=a.x+1;this.posY=a.y+1;this.allBlocks=SHAPES[info.block].map(([x,y])=>({x,y}));}};
|
|
return {opacity:255,setPosition(value){this.position=value;},getComponent:()=>block,getChildByName:()=>null};
|
|
};
|
|
let batch;
|
|
check('game creation loop: creates all 16 ordinary members in 4x4, registers every node and locks interaction', () => {
|
|
// Directly exercise geometry/creation after the separate size guard; this does
|
|
// NOT claim that an unmodified debug build accepts the larger config.
|
|
const original=structuredClone(members);batch=runtime.createSpawnGateBatch(members,square,0);
|
|
assert.equal(batch.nodes.length,16);assert.equal(runtime.blocks.length,16);assert.equal(runtime.blockNum,16);
|
|
assert.ok(batch.nodes.every(n=>n.opacity===0&&n.getComponent().spawnLocked));assert.deepEqual(members,original);
|
|
});
|
|
check('game unlocking: every member becomes interactive after the reveal finishes', () => {
|
|
runtime.unlockSpawnGateBatch(batch.nodes);assert.ok(batch.nodes.every(n=>!n.getComponent().spawnLocked));
|
|
});
|
|
check('actual controller: area-empty starts an entire batch; 2 batches count down 2 to 1 to 0', () => {
|
|
let clear=false,finished=0;const lengths=[];
|
|
const controller={config:{...square,spawnQueue:[{members},{members:members.slice(0,2)}]},queueIndex:0,state:states.Waiting,elapsed:0,finished:false,batch:null,countLabel:{string:'',node:{active:true}},node:{destroy(){this.destroyed=true;}},revealSmokeData:null};
|
|
controller.map={pause:false,gameOver:false,gameWin:false,isSpawnGateAreaEmpty:()=>clear,createSpawnGateBatch(list){lengths.push(list.length);return {nodes:[],opacities:[]};},unlockSpawnGateBatch(){},onSpawnGateFinished(){finished++;}};
|
|
for(const name of ['update','beginBatch','completeBatch','finishGate','enterState','updateRemainingCount','playRevealSmoke','setClosedAmount','setBatchOpacity']) controller[name]=method('SpawnGate',name);
|
|
controller.updateRemainingCount();assert.equal(controller.countLabel.string,'2');controller.update(1);assert.deepEqual(lengths,[]);
|
|
clear=true;controller.update(.01);assert.deepEqual(lengths,[16]);controller.update(.31);assert.equal(controller.countLabel.string,'1');controller.update(.16);controller.update(.21);
|
|
clear=false;controller.update(1);assert.deepEqual(lengths,[16]);clear=true;controller.update(.01);controller.update(.31);assert.equal(controller.countLabel.string,'0');controller.update(.16);controller.update(.21);
|
|
assert.deepEqual(lengths,[16,2]);assert.equal(finished,1);assert.equal(controller.finished,true);
|
|
});
|
|
check('actual artwork: width4 still selects native size3 frames, so larger size is not currently adaptive', () => {
|
|
const left={width:0,setContentSize(s){Object.assign(this,s);}},right={width:0,setContentSize(s){Object.assign(this,s);}};
|
|
const frame3={getOriginalSize:()=>({width:180,height:360})};
|
|
const ctx={config:area,leftSprite:{},rightSprite:{},left2Frame:{},right2Frame:{},left3Frame:frame3,right3Frame:frame3,configureDoorPart:method('SpawnGate','configureDoorPart')};
|
|
method('SpawnGate','configureArtwork').call(ctx,left,right);
|
|
assert.equal(ctx.leftSprite.spriteFrame,frame3);assert.equal(left.width,180);assert.equal(left.height,360);assert.notEqual(left.height,area.height*120);
|
|
});
|
|
check('game victory: empty current board cannot win while a spawn controller has outstanding work', () => {
|
|
cc.fx={GameConfig:{LEVEL_INFO:[{spawnGates:[square]}]}};
|
|
const context={blocks:[],spawnGatesInitialized:true,spawnGateControllers:[{hasOutstandingWork:()=>true}],hasPendingSpawnGateWork:method('Map','hasPendingSpawnGateWork')};
|
|
const canWin=method('Map','canCompleteLevel');assert.equal(canWin.call(context),false);
|
|
context.spawnGateControllers=[];assert.equal(canWin.call(context),true);context.blocks=[{}];assert.equal(canWin.call(context),false);
|
|
});
|
|
check('three workbenches: generator/analysis reject unsupported spawn proof; checker reports initial-only scope', () => {
|
|
assert.throws(()=>fromCocos(editor.data),/spawnGates/);
|
|
assert.ok(compareParse(editor.data).warnings.some(w=>w.includes('后续出块序列')));
|
|
});
|
|
const corpus=[];
|
|
for(const name of readdirSync(path.join(game,'assets/custom/Json')).filter(n=>/^level.*\.json$/.test(n))){
|
|
const data=JSON.parse(readFileSync(path.join(game,'assets/custom/Json',name),'utf8'));
|
|
for(const g of data.LEVEL_INFO?.[0]?.spawnGates??[])corpus.push({file:name,width:g.width,height:g.height,batchMemberCounts:g.spawnQueue.map(b=>b.members.length)});
|
|
}
|
|
mkdirSync(output,{recursive:true});
|
|
writeFileSync(path.join(output,'multi-member-example.json'),editor.serialize());
|
|
const report={date:'2026-10-15',game,commit:execFileSync('git',['-C',game,'rev-parse','HEAD'],{encoding:'utf8'}).trim(),sourceHashes:Object.fromEntries(Object.entries(sources).map(([n,s])=>[n,createHash('sha256').update(s).digest('hex')])),passed:passed.length,checks:passed,corpus:{gateCount:corpus.length,multiMemberBatchCount:corpus.flatMap(g=>g.batchMemberCounts).filter(n=>n>1).length,gates:corpus},scope:'Actual extracted methods with mocked Cocos nodes; no engine, physics, device or visual replay. Game and editor runtime files unchanged. Larger sizes rejected by existing config guard; general loops tested independently.'};
|
|
writeFileSync(path.join(output,'report.json'),JSON.stringify(report,null,2)+'\n');
|
|
console.log(JSON.stringify({passed:report.passed,checks:passed,gateCount:corpus.length,multiMemberBatchCount:report.corpus.multiMemberBatchCount,output},null,2));
|