bend_puzzle/test/special-cell-smoke-test.js

95 lines
11 KiB
JavaScript
Raw Normal View History

2026-07-30 15:13:53 +09:00
'use strict';
const {vm,assert,functionSource,loadBendPuzzle,loadAppLogic}=require('./helpers/app-source');
const BendPuzzle=loadBendPuzzle(),AppLogic=loadAppLogic();
const context={
AppLogic,
2026-07-31 12:54:46 +09:00
SPECIAL_CELL_MIN_LEVEL:5,data:{metas:{},specialMechanicsSeen:[]},
2026-07-30 15:13:53 +09:00
hash32:BendPuzzle.hash32,rngFrom:BendPuzzle.rngFrom,
deepClone:value=>JSON.parse(JSON.stringify(value)),key2:(x,y)=>`${x},${y}`,ckey:(r,c)=>`${r},${c}`,
2026-07-31 12:54:46 +09:00
SIDE_D:{N:[-1,0],S:[1,0],W:[0,-1],E:[0,1]},OPP:{N:'S',S:'N',W:'E',E:'W'},sameCell:(a,b)=>a[0]===b[0]&&a[1]===b[1],manhattan:(a,b)=>Math.abs(a[0]-b[0])+Math.abs(a[1]-b[1])
2026-07-30 15:13:53 +09:00
};
vm.createContext(context);
vm.runInContext([
2026-07-31 12:54:46 +09:00
'specialCellSet','internalGateIndexes','internalGateIndexSet','isInternalGateIndex','invalidateSpecialCellCaches','reservedSpecialKeys','cellSet','warpMap','warpPairForCell','isWarpTransition','pathCellsAdjacent','lockForDoor','pathHasLockKey','crossingKeys','pathIndexesAtCell','crossingStateAtCell','crossingsSatisfied','gateObj','outsidePoint','analyzeTurns','turnAnalysis','partialTurnCount','numbersForPath','specialPathValid','pathAxisAtCell','pathValid','usedGateSet','occupiedMap','isSolved','rebuildSolutionClues','addWarpSpecial','addLockSpecial','obstacleCellLimit','buildCrossingTemplate','addCrossingSpecial','addInternalGateSpecial','recentSpecialMechanicTypes','addScheduledSpecial','specialCellUsage','addSpecialCellPattern'
].map(name=>functionSource(name)).join('\n')+'\nthis.logic={specialCellSet,turnAnalysis,pathValid,isSolved,addWarpSpecial,addLockSpecial,addCrossingSpecial,addInternalGateSpecial,addSpecialCellPattern,isWarpTransition,crossingStateAtCell,crossingsSatisfied};',context);
2026-07-30 15:13:53 +09:00
const logic=context.logic;
for(let level=1;level<=4;level++)assert(AppLogic.specialSchedule(level,100,level).types.length===0,`Level ${level} scheduled a production special`);
2026-07-31 12:54:46 +09:00
for(const level of[5,6]){const schedule=AppLogic.specialSchedule(level,100,level,['warp','lock','crossing','internalGate'],[]);assert(schedule.types.length===1&&schedule.setCount===1,`Level ${level} did not schedule exactly one minimum set`)}
const level7=AppLogic.specialSchedule(7,200,7,['warp','lock','crossing','internalGate'],[]),level8=AppLogic.specialSchedule(8,200,8,['warp','lock','crossing','internalGate'],[]);
2026-07-30 15:13:53 +09:00
assert(level7.setCount===2&&level8.setCount===3,'Level 7-8 special density does not increase deterministically');
{
const puzzle={valid:[[2,0],[2,1],[0,3],[0,4],[1,4],[2,4]],g:[[2,0,'W'],[2,4,'S']],n:[[0,4,1]],specialCells:{crossings:[],warps:[{a:[2,1],b:[0,3]}],locks:[]},solution:[]},path={startGate:0,endGate:1,openGate:null,cells:[[2,0],[2,1],[0,3],[0,4],[1,4],[2,4]]};
assert(logic.turnAnalysis(path,puzzle).count===1,'Warp transition itself was counted as a turn');
}
let base=null;
const shapes=[[[0,0],[1,0],[0,1],[1,1]],[[0,0],[1,0],[2,0],[0,1],[1,1]],[[0,0],[1,0],[2,0],[0,1],[1,1],[2,1]]];
for(const shape of shapes)for(let seed=1;seed<200&&!base;seed++){
let puzzle;try{puzzle=BendPuzzle.generatePuzzle(shape,0x470000+seed,8,0,0)}catch(_){continue}
const reserved=new Set(),warp=context.deepClone(puzzle),lock=context.deepClone(puzzle);
if(logic.addWarpSpecial(warp,context.rngFrom(seed),reserved)&&logic.addLockSpecial(lock,context.rngFrom(seed+1),new Set()))base=puzzle;
}
assert(base,'Could not find a puzzle supporting warp and key/door special cells');
{
const single=BendPuzzle.generatePuzzle([[0,0]],0x47c205ed,5,0,0),cross=context.deepClone(single);
assert(logic.addCrossingSpecial(cross,context.rngFrom(0x47c205ed),new Set()),'A one-section crossing template could not be generated within the obstacle cap');
assert((cross.obstacles||[]).length<=Math.floor((cross.valid.length+(cross.obstacles||[]).length)*.2),'Crossing template exceeded the 20% obstacle cap');
}
{
const cached=context.deepClone(base);cached.specialCells={crossings:[],warps:[],locks:[]};cached._warpMap=new Map();
const added=logic.addWarpSpecial(cached,context.rngFrom(0x9e3779b9),new Set());assert(added,'Could not add a warp for stale-cache regression');
assert(!Object.prototype.hasOwnProperty.call(cached,'_warpMap'),'Warp mutation did not invalidate the stale warp map');const addedPair=cached.specialCells.warps[0];assert(logic.isWarpTransition(cached,addedPair.a,addedPair.b),'Warp lookup did not rebuild after cache invalidation');
const after=cached.solution.map(path=>logic.turnAnalysis(path,cached).count);assert(cached._warpMap&&typeof cached._warpMap.get==='function'&&cached._warpMap.size===2,'Warp map was not rebuilt after adding a warp');
const warpedIndex=cached.solution.findIndex(path=>cached.specialCells.warps.some(pair=>path.cells.some(cell=>context.sameCell(cell,pair.a))));assert(warpedIndex>=0&&Number.isFinite(after[warpedIndex]),'Warp turn analysis did not run');
}
2026-07-31 12:54:46 +09:00
for(let level=1;level<=4;level++){const low=logic.addSpecialCellPattern(base,0x700000+level,level),special=logic.specialCellSet(low);assert(low===base&&!special.internalGates.length&&!special.warps.length&&!special.locks.length&&!special.crossings.length,`Level ${level} received a special mechanic before the production threshold`)}
2026-07-30 15:13:53 +09:00
const counts={warp:0,lock:0,cross:0,boards:0};let examples={};
for(let seed=0;seed<900;seed++){
2026-07-31 12:54:46 +09:00
context.data.specialMechanicsSeen=['internalGate'];
2026-07-30 15:13:53 +09:00
const puzzle=logic.addSpecialCellPattern(base,0x710000+seed,5);if(!puzzle)continue;const special=logic.specialCellSet(puzzle),
present=[special.warps.length&&'warp',special.locks.length&&'lock',special.crossings.length&&'cross'].filter(Boolean);
2026-07-31 12:54:46 +09:00
assert(special.internalGates.length===0&&present.length===1&&puzzle.specialSchedule?.introduction===true&&puzzle.specialSchedule?.setCount===1,'Level-5 first encounter did not schedule exactly one production special');
2026-07-30 15:13:53 +09:00
if(special.warps.length){counts.warp++;examples.warp||=puzzle}
if(special.locks.length){counts.lock++;examples.lock||=puzzle}
if(special.crossings.length){counts.cross++;examples.cross||=puzzle}
if(special.warps.length||special.locks.length||special.crossings.length)counts.boards++;
}
for(const [type,count] of Object.entries({warp:counts.warp,lock:counts.lock}))assert(count>0,`${type} was never selected by deterministic first-encounter scheduling`);
assert(counts.boards>0,'Level-5 scheduling generated no viable special boards');
2026-07-31 12:54:46 +09:00
for(let seed=0;seed<900&&!examples.internalGate;seed++){
context.data.specialMechanicsSeen=['warp','lock','crossing'];
const puzzle=logic.addSpecialCellPattern(base,0x720000+seed,5);
if(puzzle&&logic.specialCellSet(puzzle).internalGates.length===1)examples.internalGate=puzzle;
}
assert(examples.internalGate,'Internal gates were not introduced by the normal level-5 production schedule');
2026-07-30 15:13:53 +09:00
function verifyPuzzle(puzzle){
const special=logic.specialCellSet(puzzle),valid=new Set(puzzle.valid.map(cell=>context.ckey(...cell))),crossingKeys=new Set(special.crossings.map(cell=>context.ckey(...cell))),covered=new Map();
assert((puzzle.obstacles||[]).length<=Math.floor((puzzle.valid.length+(puzzle.obstacles||[]).length)*.2),'Special-cell conversion exceeded the 20% obstacle cap');
for(const path of puzzle.solution){
for(let i=0;i<path.cells.length;i++){
const cell=path.cells[i],key=context.ckey(...cell),visits=covered.get(key)||0;assert(valid.has(key),'Special solution leaves the valid area');assert(visits===0||visits===1&&crossingKeys.has(key),'Special solution overlaps a non-crossing cell or crosses more than twice');covered.set(key,visits+1);
if(i){const prev=path.cells[i-1];assert(context.manhattan(prev,cell)===1||logic.isWarpTransition(puzzle,prev,cell),'Special solution has an invalid transition')}
}
assert(logic.pathValid(path,puzzle),'Special solution no longer satisfies its clue');
}
assert(covered.size===valid.size,'Special solution does not cover every valid cell');
for(const pair of special.warps){let found=false;for(const path of puzzle.solution){const ai=path.cells.findIndex(cell=>context.sameCell(cell,pair.a)),bi=path.cells.findIndex(cell=>context.sameCell(cell,pair.b));if(ai>=0||bi>=0){assert(Math.abs(ai-bi)===1,'Warp endpoints are not consecutive');found=true}}assert(found,'Warp pair is absent from the solution')}
for(const lock of special.locks){let found=false;for(const path of puzzle.solution){const ki=path.cells.findIndex(cell=>context.sameCell(cell,lock.key)),di=path.cells.findIndex(cell=>context.sameCell(cell,lock.door));if(ki>=0||di>=0){assert(ki>=0&&di>ki,'Key is not before its door on the solution line');found=true}}assert(found,'Key/door pair is absent from the solution')}
2026-07-31 12:54:46 +09:00
for(const pair of special.internalGates||[]){const a=puzzle.g[pair.a],b=puzzle.g[pair.b],da=context.SIDE_D[a[2]],db=context.SIDE_D[b[2]];assert(context.manhattan(a,b)===1&&a[0]+da[0]===b[0]&&a[1]+da[1]===b[1]&&b[0]+db[0]===a[0]&&b[1]+db[1]===a[1],'Internal gate pair is not adjacent and mutually facing');assert(puzzle.solution.some(path=>path.startGate===pair.a||path.endGate===pair.a)&&puzzle.solution.some(path=>path.startGate===pair.b||path.endGate===pair.b),'Internal gate pair is not used as solution endpoints')}
2026-07-30 15:13:53 +09:00
const state={paths:context.deepClone(puzzle.solution),specialProgress:{crossings:special.crossings.map(cell=>context.ckey(...cell))}};assert(logic.isSolved(state,puzzle),'Generated special-cell solution does not solve its board');
for(const cell of special.crossings){const key=context.ckey(...cell),validSet=new Set(puzzle.valid.map(candidate=>context.ckey(...candidate))),gateSet=new Set(puzzle.g.map(g=>context.ckey(g[0],g[1])));assert(covered.get(key)===2,'Crossing solution does not use the crossing cell exactly twice');assert(!gateSet.has(key),'Crossing cell was placed on a gate cell');for(const neighbor of[[cell[0]-1,cell[1]],[cell[0]+1,cell[1]],[cell[0],cell[1]-1],[cell[0],cell[1]+1]])assert(validSet.has(context.ckey(...neighbor)),'Crossing cell was placed on the board edge')}
}
for(const puzzle of Object.values(examples))verifyPuzzle(puzzle);
{
const puzzle={valid:[[1,2],[2,1],[2,2],[2,3],[3,2]],g:[],n:[],specialCells:{crossings:[[2,2]],warps:[],locks:[]}},state={paths:[{cells:[[2,1],[2,2],[2,3]]},{cells:[[1,2],[2,2],[3,2]]}],specialProgress:{crossings:[]}};
assert(logic.crossingStateAtCell(state,puzzle,[2,2]),'Perpendicular live crossing was not detected');
state.paths[1].cells=[[1,2],[2,2],[2,3]];assert(!logic.crossingsSatisfied(state,puzzle),'Two lines with the same axis were accepted as a crossing');
const solvedPuzzle={valid:[[2,0],[2,1],[2,2],[2,3],[1,3],[0,2],[1,2],[3,2],[3,1]],g:[[2,0,'W'],[1,3,'N'],[0,2,'N'],[3,1,'W']],n:[[2,3,1],[3,2,1]],specialCells:{crossings:[[2,2]],warps:[],locks:[]}},solvedState={paths:[{startGate:0,endGate:1,cells:[[2,0],[2,1],[2,2],[2,3],[1,3]]},{startGate:2,endGate:3,cells:[[0,2],[1,2],[2,2],[3,2],[3,1]]}],specialProgress:{crossings:[]}};
assert(logic.isSolved(solvedState,solvedPuzzle),'A live perpendicular crossing did not permit normal board clear');
solvedState.paths[1].cells=[[0,2],[1,2],[1,1],[2,1],[3,1]];solvedState.specialProgress.crossings=['2,2'];assert(!logic.isSolved(solvedState,solvedPuzzle),'Crossing history permitted clear after the crossing state was removed');
}
2026-07-31 12:54:46 +09:00
console.log(`Special-cell generation passed: ${counts.warp} warp, ${counts.lock} key/door, ${counts.cross} crossing first-encounter boards, plus production-scheduled internal gates`);