// Inbound dial. A turn moves a notch on a 6-mark ring. It does not slide tracks // under aircraft, and it does not call rotateBoard. Mouths are fixed world // objects: open iff mouth.tick === dial. import { opposite, trace } from '../../src/core.js'; import { pathPoints, position, sweepPath, PORTS } from '../../src/path-math.js'; import { visualSide, SIDES } from '../airport/core.js'; export { SIDES }; export const COLORS = { 0: { name: '草莓', color: '#EE5398' }, 8: { name: '可可', color: '#B5806A' }, }; const boardOf = level => ({ cells: level.cells }); const clone = value => structuredClone(value); function departure(level, q, r, dir) { const route = trace(boardOf(level), { id: 'probe', q, r, dir }, [], true); if (route.status !== 'exit') return { status: route.status, route, points: pathPoints(route) }; const last = route.steps.at(-1); return { status: 'exit', route, points: pathPoints(route), side: visualSide(last.q, last.r, last.exit), exitDir: last.exit }; } export function catalog(level) { return level.mouths.map(mouth => { const stand = level.stands.find(item => item.id === mouth.standId); const inbound = departure(level, stand.q, stand.r, mouth.departDir); const parkedDir = opposite(mouth.departDir); const outbound = departure(level, stand.q, stand.r, parkedDir); return { ...mouth, stand, side: inbound.side, sideName: SIDES[inbound.side], parkedDir, exitSide: outbound.side, exitSideName: SIDES[outbound.side], arrivalPoints: [...inbound.points].reverse(), departPoints: outbound.points, cells: inbound.route.steps.length, }; }); } export function createState(level) { return { dial: level.dial, stars: 0, planes: level.arrivals.map(plane => ({ id: plane.id, color: plane.color, side: plane.side, state: 'waiting', q: null, r: null, dir: null, standId: null, mouthId: null, })), }; } const parked = state => state.planes.filter(plane => plane.state === 'parked'); const obstacles = planes => planes.map(plane => ({ ...position(plane.q, plane.r), id: plane.id })); export function checkLanding(level, state, planeId) { const plane = state.planes.find(item => item.id === planeId); if (!plane || plane.state !== 'waiting') return { status: 'invalid' }; const mouths = catalog(level).filter(mouth => mouth.tick === state.dial && mouth.side === plane.side && mouth.stand.color === plane.color); if (!mouths.length) return { status: 'no-route', plane }; const taken = new Set(parked(state).map(item => `${item.q},${item.r}`)); const free = mouths.filter(mouth => !taken.has(`${mouth.stand.q},${mouth.stand.r}`)); if (!free.length) return { status: 'no-stand', plane }; const blocks = obstacles(parked(state)); const legal = [], blocked = []; for (const mouth of free) { const hit = sweepPath(mouth.arrivalPoints, blocks); if (!hit) legal.push(mouth); else blocked.push({ mouth, blocker: hit.blocker, point: hit.point, travel: hit.points }); } if (legal.length > 1) return { status: 'ambiguous', plane, candidates: legal }; if (legal.length === 1) return { status: 'land', plane, mouth: legal[0] }; if (blocked.length === 1) return { status: 'blocked', plane, ...blocked[0] }; return { status: 'ambiguous', plane, candidates: blocked }; } export function checkRelocate(level, state, planeId) { const plane = state.planes.find(item => item.id === planeId); if (!plane || plane.state !== 'parked') return { status: 'invalid' }; const outbound = departure(level, plane.q, plane.r, plane.dir); if (outbound.status !== 'exit') return { status: 'no-route', plane }; const end = outbound.points.at(-1), port = PORTS[outbound.exitDir]; const swept = [...outbound.points, { x: end.x + port.x * 4, y: end.y + port.y * 4 }]; const hit = sweepPath(swept, obstacles(parked(state).filter(item => item.id !== plane.id))); if (hit) return { status: 'blocked', plane, blocker: hit.blocker, point: hit.point, travel: hit.points, side: outbound.side }; return { status: 'relocate', plane, side: outbound.side, sideName: SIDES[outbound.side], points: outbound.points }; } function applyLanding(state, check) { const next = clone(state); const plane = next.planes.find(item => item.id === check.plane.id); plane.state = 'parked'; plane.q = check.mouth.stand.q; plane.r = check.mouth.stand.r; plane.dir = check.mouth.parkedDir; plane.standId = check.mouth.standId; plane.mouthId = check.mouth.id; plane.side = null; next.stars += 1; return next; } function applyRelocate(state, check) { const next = clone(state); const plane = next.planes.find(item => item.id === check.plane.id); plane.state = 'waiting'; plane.side = check.side; plane.q = plane.r = plane.dir = plane.standId = plane.mouthId = null; next.stars += 1; return next; } export function transition(level, state, action) { if (action.type === 'confirm') { const dial = action.dial, steps = action.steps; if (!Number.isInteger(dial) || dial < 0 || dial > 5 || !Number.isInteger(steps) || steps < 1) return { commit: false, result: { status: 'invalid' } }; const next = clone(state); next.dial = dial; next.stars += steps; return { commit: true, state: next, result: { status: 'confirmed', dial, steps } }; } if (action.type === 'land') { const check = checkLanding(level, state, action.id); if (check.status !== 'land') return { commit: false, result: check }; return { commit: true, state: applyLanding(state, check), result: check }; } if (action.type === 'relocate') { const check = checkRelocate(level, state, action.id); if (check.status !== 'relocate') return { commit: false, result: check }; return { commit: true, state: applyRelocate(state, check), result: check }; } return { commit: false, result: { status: 'invalid' } }; } export function isWon(state) { return state.planes.length > 0 && state.planes.every(plane => plane.state === 'parked'); } export class Inbound { constructor(level) { this.level = level; this.state = createState(level); this.history = []; } get won() { return isWon(this.state); } act(action) { if (action.type === 'undo') { if (!this.history.length) return { status: 'invalid' }; this.state = this.history.pop(); return { status: 'undone' }; } const outcome = transition(this.level, this.state, action); if (!outcome.commit) return outcome.result; this.history.push(clone(this.state)); this.state = outcome.state; return { ...outcome.result, won: this.won, stars: this.state.stars }; } } export function validateInbound(level) { const issues = []; const mouths = catalog(level); for (const mouth of mouths) { if (mouth.side === undefined) issues.push(`${mouth.id} 没有出口`); if (mouth.cells < 1 || mouth.cells > 5) issues.push(`${mouth.id} 路径 ${mouth.cells} 格,超出 1–5`); } for (let dial = 0; dial < 6; dial++) { const groups = new Map(); for (const mouth of mouths.filter(item => item.tick === dial)) { const group = `${mouth.side}:${mouth.stand.color}`; groups.set(group, (groups.get(group) || 0) + 1); } for (const [group, count] of groups) if (count > 1) issues.push(`朝向 ${dial} ${group} 有 ${count} 个口`); } const seen = new Set(); for (const mouth of mouths) { const signature = `${mouth.side}:${mouth.tick}:${mouth.standId}`; if (seen.has(signature)) issues.push(`重复口 ${signature}`); seen.add(signature); } return issues; } // What a reader can count before touching preview. Independent of dial mutation. export function reading(level, dial) { return catalog(level).map(mouth => ({ id: mouth.id, side: mouth.sideName, tick: mouth.tick, open: mouth.tick === dial, right: (mouth.tick - dial + 6) % 6, left: (dial - mouth.tick + 6) % 6, exitSide: mouth.exitSideName, cells: mouth.cells, })); }