Six-side aprons, frozen levels, solver, rewards and verify scripts; the edge-runway readability work continues from here.
210 lines
14 KiB
JavaScript
210 lines
14 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { hexagon, trace, opposite } from '../src/core.js';
|
|
import { pathPoints, sweepPath, position, PORTS } from '../src/path-math.js';
|
|
import { AirportGame, catalogs, checkLanding, createState, transition, solveAirport, replay, validateAirportLevel, uniquenessIssues, SIDES, boardRadius, apronDistance, waitingPose } 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');
|
|
}
|
|
});
|
|
const bayNormal=side=>{const n=PORTS[side],l=Math.hypot(n.x,n.y),x=n.x/l,y=n.y/l;const c=Math.cos(Math.PI/6),s=Math.sin(Math.PI/6);return{x:x*c-y*s,y:x*s+y*c};};
|
|
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);
|
|
});
|