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.
This commit is contained in:
toshiocn
2026-09-21 17:55:06 +08:00
parent 0e67c4c188
commit c99716b1d5
8 changed files with 1157 additions and 1128 deletions
+34 -4
View File
@@ -2,7 +2,7 @@ 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 { 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';
@@ -28,7 +28,7 @@ test('frozen levels have unique routes at every side/orientation and exact budge
}
});
test('waiting aprons sit on the board outline flat sides, outside every cell',()=>{
assert.deepEqual(SIDES,['上方','右上方','右下方','下方','左下方','左上方']);
assert.deepEqual(SIDES,['右上方','右方','右下方','左下方','左方','左上方']);
assert.equal(boardRadius(hexagon(2)),2);
assert.equal(boardRadius(hexagon(3)),3);
for(let side=0;side<6;side++){
@@ -66,6 +66,36 @@ test('same-side aircraft stack outward along the flat-side normal, never tangent
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);
@@ -179,8 +209,8 @@ 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');
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'));