import test from 'node:test'; import assert from 'node:assert/strict'; import { hexagon, trace, opposite, DIRS } from '../src/core.js'; import { pathPoints, sweepPath, position, PORTS, hexPoints } from '../src/path-math.js'; import { AirportGame, catalogs, checkLanding, createState, transition, solveAirport, replay, validateAirportLevel, uniquenessIssues, SIDES, boardRadius, apronDistance, waitingPose, boardAt, trayHull, rimCrossing, bayNormal, TRAY_PAD } from '../labs/airport/core.js'; import { LEVELS } from '../labs/airport/levels.js'; import { requestReward } from '../labs/airport/rewards.js'; const clone=x=>structuredClone(x); const fixture=()=>({id:'fixture',name:'直线机场',cells:hexagon(2),stands:[{id:'P1',q:0,r:0,color:8}], arrivals:[{id:'in1',color:8,side:0}],originals:[],moveLimit:10,reward:{steps:5,maxClaims:2}}); const tap=id=>({type:'tap',id}); const execute=(game,action)=>{const result=game.begin(action);game.finish(action.type==='rotate'?'rotation':action.id);return result;}; test('frozen levels have unique routes at every side/orientation and exact budgeted proofs',()=>{ for(const level of LEVELS){ assert.deepEqual(validateAirportLevel(level),[],level.id); assert.deepEqual(uniquenessIssues(level),[]); const answer=solveAirport(level); assert.equal(answer.status,'solved');assert.equal(answer.minimumActions,level.evidence.minimumActions); const result=replay(level,level.solution);assert.ok(result.won);assert.ok(result.moves>=0); assert.equal(result.state.planes.filter(p=>p.kind==='original'&&p.state==='ground').length,level.evidence.remainingOriginals); assert.ok(level.evidence.remainingOriginals>0); assert.ok(replay(level,level.experience).won); const all=solveAirport(level,createState(level),{clearFirst:true}); assert.equal(all.status,'solved');assert.equal(all.minimumActions,level.evidence.clearFirst.minimumActions); assert.ok(all.minimumActions>level.moveLimit,'clearing all must cost more than free budget'); } }); test('waiting aprons sit on the board outline flat sides, outside every cell',()=>{ assert.deepEqual(SIDES,['上方','右上方','右下方','下方','左下方','左上方']); assert.equal(boardRadius(hexagon(2)),2); assert.equal(boardRadius(hexagon(3)),3); for(let side=0;side<6;side++){ const plane={id:'in1',kind:'arrival',state:'waiting',side}; const pose=waitingPose(plane,[plane],2),n=bayNormal(side); assert.ok(Math.abs(pose.x-n.x*apronDistance(2))<1e-9&&Math.abs(pose.y-n.y*apronDistance(2))<1e-9,`side ${side} bay on its flat-side normal`); for(const cell of hexagon(2)){const p=position(cell.q,cell.r);assert.ok(pose.x*n.x+pose.y*n.y>p.x*n.x+p.y*n.y+0.5,`side ${side} bay must stand off the board`);} } }); test('each waiting bay sits on a flat side of the pointy-top outline, nose pointing at the board',()=>{ // The cell cluster's outline is pointy-top: its wide flat sides are half a step // (30°) from the port normals at screen angles -60/0/60/120/180/240. for(let side=0;side<6;side++){ const plane={id:'in1',kind:'arrival',state:'waiting',side}; const pose=waitingPose(plane,[plane],2),n=bayNormal(side); const dir={x:pose.x/Math.hypot(pose.x,pose.y),y:pose.y/Math.hypot(pose.x,pose.y)}; assert.ok(Math.abs(dir.x-n.x)<1e-9&&Math.abs(dir.y-n.y)<1e-9,`side ${side} must lie on its flat-side normal`); assert.ok(Math.abs(Math.hypot(pose.x,pose.y)-apronDistance(2))<1e-9); // Nose vector in screen space is (sin(angle), -cos(angle)); it must equal the // inward normal (opposite of the outward normal). const inward={x:-n.x,y:-n.y}; assert.ok(Math.abs(Math.sin(pose.angle)-inward.x)<1e-9&&Math.abs(-Math.cos(pose.angle)-inward.y)<1e-9,`side ${side} nose must point at the board`); } }); test('same-side aircraft stack outward along the flat-side normal, never tangent',()=>{ const group=[0,1,2].map(i=>({id:`in${i}`,kind:'arrival',state:'waiting',side:0})); const n=bayNormal(0),tangent={x:-n.y,y:n.x}; let previous=null; for(const plane of group){ const pose=waitingPose(plane,group,2); // Zero tangential component: position is a scalar multiple of the normal. assert.ok(Math.abs(pose.x*tangent.x+pose.y*tangent.y)<1e-9,`${plane.id} drifts along the tangent`); const dist=Math.hypot(pose.x,pose.y); if(previous!==null) assert.ok(dist>previous,`${plane.id} must be farther out than the previous`); previous=dist; } }); test('landing is the exact reverse of centre-out geometry and retains inward heading',()=>{ const level=fixture(),state=createState(level),route=checkLanding(level,state,'in1'); assert.equal(route.status,'land');assert.equal(route.dir,3); const reversed=pathPoints(trace(level,{id:'virtual',q:0,r:0,dir:0},[],true)).reverse(); assert.deepEqual(route.points,reversed);assert.deepEqual(route.points.at(-1),position(0,0)); assert.ok(catalogs(level)[0].arrivals.every(r=>r.dir===opposite(Number(r.signature.split(':')[1])))); }); test('landing selects across a side, not an exact entrance or another side',()=>{ const level=LEVELS[2]; let checkedNoRoute=false,checkedLand=false; for(let o=0;o<6;o++)for(let side=0;side<6;side++){ const state=createState(level);state.orientation=o;state.planes=state.planes.filter(p=>p.kind==='arrival');state.planes[0].side=side; const route=checkLanding(level,state,state.planes[0].id); const matches=catalogs(level)[o].arrivals.filter(r=>r.color===state.planes[0].color&&r.side===side); if(!matches.length){assert.equal(route.status,'no-route');checkedNoRoute=true;} else{assert.equal(route.status,'land');assert.equal(route.side,side);checkedLand=true;} } assert.ok(checkedNoRoute&&checkedLand); }); test('clearing a colored original does not create a stand or count toward the goal',()=>{ const level=fixture();level.originals=[{id:'red',q:2,r:0,dir:2,color:8}]; const game=new AirportGame(level);assert.equal(execute(game,tap('red')).status,'exit'); assert.equal(game.won,false);assert.equal(game.level.stands.length,1); assert.equal(game.state.planes.find(p=>p.id==='red').state,'gone'); }); test('incoming collision reports only the first obstacle, then the next',()=>{ const level=fixture();level.originals=[{id:'near',q:0,r:-1,dir:0,color:0},{id:'outer',q:0,r:-2,dir:0,color:2}]; const state=createState(level); assert.equal(checkLanding(level,state,'in1').blocker,'outer'); state.planes.find(p=>p.id==='outer').state='gone'; assert.equal(checkLanding(level,state,'in1').blocker,'near'); }); test('curved/grazing collision uses the shared continuous hex sweep, including terminal leg',()=>{ let grazing=0,terminal=0; for(const level of LEVELS)for(let o=0;o<6;o++)for(const route of catalogs(level)[o].arrivals){ const visited=new Set(route.steps.map(s=>`${s.q},${s.r}`)); for(const cell of level.cells){ if(cell.q===route.q&&cell.r===route.r)continue; const obstacle={...position(cell.q,cell.r),id:'blocker'},hit=sweepPath(route.points,[obstacle]); if(!hit)continue; const state={orientation:o,planes:[{id:'in',kind:'arrival',state:'waiting',color:route.color,side:route.side},{id:'blocker',kind:'original',state:'ground',color:0,dir:0,q:cell.q,r:cell.r}]}; const actual=checkLanding(level,state,'in');assert.equal(actual.status,'blocked');assert.equal(actual.blocker,'blocker'); if(!visited.has(`${cell.q},${cell.r}`))grazing++; if(sweepPath(route.points.slice(-2),[obstacle]))terminal++; } } assert.ok(grazing>0,'fixtures must exercise neighboring-cell grazing');assert.ok(terminal>0,'terminal segment is swept'); }); test('atomic reservation prevents two fast landings into the same free stand',()=>{ const level=fixture();level.arrivals.push({id:'in2',color:8,side:0}); const orientation=catalogs(level).findIndex(c=>c.arrivals.length); const route=catalogs(level)[orientation].arrivals[0];level.arrivals.forEach(p=>p.side=route.side); const game=new AirportGame(level);game.state.orientation=orientation; const first=game.begin(tap('in1')); assert.equal(first.status,'land');assert.ok(game.busy.has('in1')); assert.equal(game.begin(tap('in1')).status,'busy'); assert.equal(game.begin(tap('in2')).status,'no-stand'); assert.equal(game.moves,level.moveLimit);assert.equal(game.state.planes.filter(p=>p.state==='parked').length,1); }); test('off-board taps are free, while ground departures and rotations cost one',()=>{ const level=fixture();level.originals=[{id:'out',q:0,r:1,dir:0,color:0}]; const game=new AirportGame(level); assert.equal(execute(game,tap('out')).status,'exit');assert.equal(game.moves,level.moveLimit-1); assert.equal(execute(game,{type:'rotate',direction:1}).status,'rotated');assert.equal(game.moves,level.moveLimit-2); assert.equal(execute(game,tap('in1')).status,'land');assert.equal(game.moves,level.moveLimit-2); }); test('returning arrival waits at actual exit side; landing count is not cumulative',()=>{ const level=fixture();level.arrivals.push({id:'in2',color:0,side:1});level.stands.push({id:'P2',q:2,r:-1,color:0}); const game=new AirportGame(level);assert.equal(execute(game,tap('in1')).status,'land'); const departure=execute(game,tap('in1'));assert.equal(departure.status,'exit');assert.equal(departure.side,3); const plane=game.state.planes.find(p=>p.id==='in1');assert.equal(plane.state,'waiting');assert.equal(plane.side,3);assert.equal(plane.q,undefined); assert.equal(game.won,false);assert.equal(execute(game,tap('in1')).status,'land');assert.equal(game.moves,level.moveLimit-1);assert.equal(game.won,false); }); test('rotation keeps aircraft and stands fixed; only rotations cost here',()=>{ const level=fixture();level.originals=[{id:'block',q:0,r:-1,dir:3,color:2}]; const game=new AirportGame(level),planes=clone(game.state.planes),stands=clone(level.stands); assert.equal(execute(game,tap('in1')).status,'blocked');assert.equal(game.moves,level.moveLimit);assert.deepEqual(game.state.planes,planes); for(let i=0;i<6;i++)assert.equal(execute(game,{type:'rotate',direction:1}).status,'rotated'); assert.equal(game.state.orientation,0);assert.deepEqual(game.state.planes,planes);assert.deepEqual(level.stands,stands);assert.equal(game.moves,level.moveLimit-6); assert.equal(execute(game,tap('missing')).status,'invalid');assert.equal(game.moves,level.moveLimit-6); }); test('a free landing still wins after the budget is spent',()=>{ const level=fixture();level.moveLimit=1;level.originals=[{id:'out',q:0,r:1,dir:0,color:0}]; const game=new AirportGame(level); assert.equal(game.begin(tap('out')).status,'exit');assert.equal(game.moves,0); assert.equal(game.begin(tap('in1')).status,'land');assert.equal(game.moves,0);assert.ok(game.won); assert.equal(game.begin({type:'rotate',direction:1}).status,'finished'); }); test('validator rejects ambiguous targets, equal-target/different-heading routes and bad counts',()=>{ const level=fixture();assert.deepEqual(validateAirportLevel(level),[]); level.stands.push({id:'P2',q:1,r:0,color:8});level.arrivals.push({id:'in2',color:8,side:1}); assert.ok(validateAirportLevel(level).includes('存在多个合法停靠结果')); const game=new AirportGame(level);assert.equal(game.begin(tap('in1')).status,'ambiguous');assert.equal(game.moves,10); const bent=fixture();bent.cells.forEach(c=>c.tracks=[1,0,3,2,5,4]);bent.stands[0].q=2;bent.stands[0].r=-2; assert.ok(uniquenessIssues(bent).some(i=>new Set(i.results.map(r=>r.split(':')[0])).size===1)); const malformed=fixture();malformed.arrivals=[];assert.ok(validateAirportLevel(malformed).length>0); }); test('empty stands do not obstruct original takeoff regardless of color',()=>{ const level=fixture();level.originals=[{id:'out',q:0,r:1,dir:0,color:0}]; const game=new AirportGame(level);assert.equal(execute(game,tap('out')).status,'exit');assert.equal(game.level.stands[0].color,8); }); test('solver distinguishes a rotation rescue, proven deadlock and search limits',()=>{ const level=LEVELS[1],state=createState(level); assert.equal(solveAirport(level,state,{maxStates:1}).status,'unknown'); const rotating=LEVELS.find(l=>l.solution.some(a=>a.type==='rotate'));assert.equal(solveAirport(rotating).status,'solved'); const dead=fixture();dead.cells.forEach(c=>c.tracks=[1,0,3,2,5,4]); assert.equal(solveAirport(dead).status,'deadlock'); assert.equal(solveAirport(fixture(),createState(fixture()),{maxDepth:0}).status,'unknown'); }); test('teaching arc: rotation connects the runway and blockers must be cleared',()=>{ // L3: the off-board plane is not connected at start; one rotation opens the runway. const l3=LEVELS[2];let s3=createState(l3); assert.equal(checkLanding(l3,s3,'in1').status,'no-route'); s3=transition(l3,s3,{type:'rotate',direction:-1}).state; assert.equal(checkLanding(l3,s3,'in1').status,'land'); // L4: an original blocks a runway at the start; the proven replay rotates and clears a blocker. const l4=LEVELS[3];const s4=createState(l4); assert.ok(l4.arrivals.some(a=>checkLanding(l4,s4,a.id).status==='blocked')); assert.ok(l4.solution.some(a=>a.type==='rotate')); assert.ok(l4.solution.some(a=>a.type==='tap'&&a.id.startsWith('out'))); assert.ok(replay(l4,l4.solution).won); }); test('reward receipts are idempotent, limited and do not mutate the board',async()=>{ const game=new AirportGame(fixture()),state=clone(game.state);game.moves=0; assert.equal((await requestReward(game,async()=>({completed:false,id:'a'}))).status,'cancelled');assert.equal(game.moves,0); assert.equal((await requestReward(game,async()=>{throw Error('network');})).status,'failed'); assert.equal((await requestReward(game,async()=>({completed:true,id:'a'}))).status,'granted');assert.equal(game.moves,5); assert.equal((await requestReward(game,async()=>({completed:true,id:'a'}))).status,'cancelled');assert.equal(game.moves,5); assert.deepEqual(game.state,state); assert.equal(game.grantReward({completed:'false',id:'invalid'}),false); assert.equal(game.grantReward({completed:true,id:'second'}),true);assert.equal(game.moves,10); assert.equal(game.grantReward({completed:true,id:'over-limit'}),false); game.deadlocked=true;assert.equal(game.grantReward({completed:true,id:'b'}),false); }); test('reward completion cannot revive a restarted session or accept concurrent claims',async()=>{ const game=new AirportGame(fixture());let current=true,resolve; const pending=requestReward(game,()=>new Promise(r=>resolve=r),()=>current); assert.equal((await requestReward(game,async()=>({completed:true,id:'double'}))).status,'unavailable'); current=false;resolve({completed:true,id:'old'});assert.equal((await pending).status,'stale');assert.equal(game.moves,10); }); const FACE_NORMALS=[0,1,2].map(k=>({x:Math.cos(Math.PI/6+k*Math.PI/3),y:Math.sin(Math.PI/6+k*Math.PI/3)})); const insideFace=(x,y,cell,radius=.9)=>{const p=position(cell.q,cell.r),dx=x-p.x,dy=y-p.y;return FACE_NORMALS.every(n=>Math.abs(dx*n.x+dy*n.y)<=radius*Math.cos(Math.PI/6)+1e-9);}; const EDGE_ENDPOINT_RADIUS=.90*Math.cos(Math.PI/6)-.11/2-.01; const insideHull=(hull,x,y)=>{for(let i=0;i{ for(const level of LEVELS){ const hull=trayHull(level.cells); assert.ok(hull.length>=6,`${level.id} hull`); const cross=(o,a,b)=>(a.x-o.x)*(b.y-o.y)-(a.y-o.y)*(b.x-o.x); for(let i=0;i0,`${level.id} hull must stay convex`); for(const cell of level.cells){ const p=position(cell.q,cell.r); for(const v of hexPoints(p.x,p.y,.90))assert.ok(insideHull(hull,v.x,v.y),`${level.id} hull must enclose brick ${cell.q},${cell.r}`); assert.ok(TRAY_PAD>.90,`${level.id} tray pad must clear the brick face`); } } }); test('every board-edge runway reaches the tray outline without crossing another brick',()=>{ for(const level of LEVELS)for(let o=0;o<6;o++){ const board=boardAt(level,o),hull=trayHull(board.cells),keys=new Set(board.cells.map(c=>`${c.q},${c.r}`)); for(const cell of board.cells){ const p=position(cell.q,cell.r); for(let d=0;d<6;d++){ if(d>=cell.tracks[d])continue; const b=cell.tracks[d]; for(const port of [d,b]){ if(keys.has(`${cell.q+DIRS[port][0]},${cell.r+DIRS[port][1]}`))continue; // shares an edge with a brick const n=PORTS[port],l=Math.hypot(n.x,n.y),ux=n.x/l,uy=n.y/l; const end={x:p.x+ux*EDGE_ENDPOINT_RADIUS,y:p.y+uy*EDGE_ENDPOINT_RADIUS}; const t=rimCrossing(hull,end,{x:ux,y:uy}); assert.ok(t!==null&&t>0,`${level.id} o${o} ${cell.q},${cell.r} port ${port} must reach the tray outline`); for(let s=1;s<24;s++){ const at={x:end.x+ux*t*s/24,y:end.y+uy*t*s/24}; for(const c of board.cells){ if(c.q===cell.q&&c.r===cell.r)continue; assert.ok(!insideFace(at.x,at.y,c),`${level.id} o${o} runway ${cell.q},${cell.r} port ${port} must not graze brick ${c.q},${c.r}`); } } } } } } }); test('apron slabs start outside the tray and stack along each side normal',()=>{ for(const level of LEVELS){ const radius=boardRadius(level.cells),hull=trayHull(level.cells); for(let side=0;side<6;side++){ const n=bayNormal(side),rim=rimCrossing(hull,{x:0,y:0},n); assert.ok(rim!==null&&rim>0,`${level.id} side ${side} slab needs a rim`); for(const arrival of level.arrivals.filter(a=>a.side===side)){ const pose=waitingPose({...arrival,kind:'arrival',state:'waiting'},level.arrivals.map(a=>({...a,kind:'arrival',state:'waiting'})),radius); // The plane sits on the slab axis, beyond the tray: the lane reaches its bay. assert.ok(pose.x*n.x+pose.y*n.y>rim,`${level.id} side ${side} bay must sit outside the tray`); assert.ok(Math.abs(pose.x*n.y-pose.y*n.x)<1e-9,`${level.id} side ${side} bay must stay on the slab axis`); } } } });