Files
HexTrails/tests/airport.test.js
T
toshiocn c99716b1d5 feat(airport): bind landings to the six visual flats
Entry side is the 60-degree wedge of the port, not the cell-edge direction. Levels regenerated; hint line joins the waiting plane to the on-board path.
2026-09-21 17:55:06 +08:00

297 lines
20 KiB
JavaScript

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, visualSide, visualSideFromPoint } 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('each boundary port belongs to exactly one visual flat, matching the waiting bay',()=>{
const cells=hexagon(2),keys=new Set(cells.map(c=>`${c.q},${c.r}`));
const seen=new Map();
for(const cell of cells)for(let d=0;d<6;d++){
if(keys.has(`${cell.q+DIRS[d][0]},${cell.r+DIRS[d][1]}`))continue;
const p=position(cell.q,cell.r),n=PORTS[d],side=visualSide(cell.q,cell.r,d);
assert.ok(side>=0&&side<=5,`port ${cell.q},${cell.r} dir ${d}`);
assert.equal(side,visualSideFromPoint(p.x+n.x,p.y+n.y));
const bay=bayNormal(side),port={x:p.x+n.x,y:p.y+n.y};
const ang=Math.atan2(port.y,port.x),bang=Math.atan2(bay.y,bay.x);
let delta=ang-bang;while(delta>Math.PI)delta-=Math.PI*2;while(delta<-Math.PI)delta+=Math.PI*2;
assert.ok(Math.abs(delta)<=Math.PI/6+1e-9,`port ${cell.q},${cell.r} dir ${d} must sit in side ${side}'s 60° wedge`);
if(!seen.has(side))seen.set(side,0);seen.set(side,seen.get(side)+1);
}
assert.equal(seen.size,6,'all six flats receive at least one port');
});
test('a waiting plane cannot enter from a neighbouring visual edge',()=>{
const cells=hexagon(2).map(c=>({...c,tracks:[3,4,5,0,1,2]}));
const level={id:'edge',name:'edge',cells,stands:[{id:'P1',q:0,r:-2,color:8}],arrivals:[{id:'in1',color:8,side:0}],originals:[],moveLimit:10,reward:{steps:5,maxClaims:2}};
const routes=catalogs(level)[0].arrivals.filter(r=>r.color===8);
const occupied=new Set(routes.map(r=>r.side));
const home=routes.map(r=>r.side).find(s=>routes.filter(r=>r.side===s).length===1);
const other=[0,1,2,3,4,5].find(s=>!occupied.has(s));
assert.equal(typeof other,'number','need a visual edge this stand does not open onto');
const state=createState(level);state.planes[0].side=other;
assert.equal(checkLanding(level,state,'in1').status,'no-route');
state.planes[0].side=home;
assert.equal(checkLanding(level,state,'in1').status,'land');
assert.equal(checkLanding(level,state,'in1').side,home);
});
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');
const left=transition(l3,s3,{type:'rotate',direction:-1}).state,right=transition(l3,s3,{type:'rotate',direction:1}).state;
assert.ok(checkLanding(l3,left,'in1').status==='land'||checkLanding(l3,right,'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<hull.length;i++){const a=hull[i],b=hull[(i+1)%hull.length];if((b.x-a.x)*(y-a.y)-(b.y-a.y)*(x-a.x)<-1e-9)return false;}return true;};
test('the tray outline is convex and contains every brick',()=>{
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;i<hull.length;i++)assert.ok(cross(hull[i],hull[(i+1)%hull.length],hull[(i+2)%hull.length])>0,`${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`);
}
}
}
});