import { clone, trace, rotateBoard, rotateTracks, key } from '../../src/core.js'; import { pathPoints, position, PORTS, sweepPath } from '../../src/path-math.js'; export function createState(level) { return { ...clone(level), orientation: 0, fired: [] }; } export function routeFor(state, tile) { let route = trace(state, tile, state.tiles, true); let points = pathPoints(route); if (state.mode === 'relay') { const index = route.steps.findIndex((s, i) => i > 0 && state.stops.some(p => p.q === s.q && p.r === s.r)); if (index >= 0) { const stop = route.steps[index]; const center = position(stop.q, stop.r), port = PORTS[stop.enter]; points = [...pathPoints({ steps: route.steps.slice(0, index) }), { x: center.x + port.x, y: center.y + port.y }, center]; route = { ...route, status: 'stop', steps: route.steps.slice(0, index + 1), end: { q: stop.q, r: stop.r }, dir: stop.exit }; } } if (route.status === 'exit') { const last = points.at(-1), port = PORTS[route.steps.at(-1).exit]; points.push({ x: last.x + port.x * 4, y: last.y + port.y * 4 }); } const collision = sweepPath(points, state.tiles.filter(t => t.id !== tile.id).map(t => ({ ...position(t.q, t.r), id: t.id }))); if (collision) return { ...route, status: 'blocked', blocker: collision.blocker, points: collision.points }; if (route.status === 'exit' && state.mode === 'color') { const last = route.steps.at(-1); const gate = state.gates.find(g => g.q === last.q && g.r === last.r && g.dir === last.exit); if (!gate || gate.color !== tile.color) return { ...route, status: 'wrong', points }; } return { ...route, points }; } export function act(state, action) { if (action.type === 'rotate') { const next = rotateBoard(state, action.direction); next.orientation = (state.orientation + action.direction + 6) % 6; const count = (action.direction + 6) % 6; next.cells = next.cells.map(c => { if (!c.alternate) return c; let alternate = c.alternate; for (let i = 0; i < count; i++) alternate = rotateTracks(alternate); return { ...c, alternate }; }); return { state: next, status: 'rotate' }; } const tile = state.tiles.find(t => t.id === action.id); if (!tile) return { state, status: 'invalid' }; const route = routeFor(state, tile); if (!['exit', 'stop'].includes(route.status)) return { state, ...route }; const next = clone(state); if (route.status === 'stop') { Object.assign(next.tiles.find(t => t.id === tile.id), route.end, { dir: route.dir }); } else { next.tiles = next.tiles.filter(t => t.id !== tile.id); if (state.mode === 'switch' && tile.trigger) { next.cells = next.cells.map(c => c.switchId === tile.trigger ? { ...c, tracks: c.alternate, alternate: c.tracks } : c); next.fired.push(tile.trigger); } } return { state: next, ...route }; } export function signature(state) { return `${state.orientation}|${state.fired.join(',')}|${state.tiles.map(t => `${t.id}:${key(t.q, t.r)}:${t.dir}`).join(';')}`; } // Unlike the main campaign, departures can change tracks and stops change occupancy. // Search every action; never greedily drain all currently available pieces. export function solve(state, limit = 12000) { const queue = [{ state, actions: [] }], seen = new Set([signature(state)]); for (let head = 0; head < queue.length && head < limit; head++) { const node = queue[head]; if (!node.state.tiles.length) return { solved: true, actions: node.actions, states: head + 1 }; const actions = [...node.state.tiles.map(t => ({ type: 'tap', id: t.id })), { type: 'rotate', direction: 1 }, { type: 'rotate', direction: -1 }]; for (const action of actions) { const result = act(node.state, action); if (result.state === node.state) continue; const id = signature(result.state); if (seen.has(id)) continue; seen.add(id); queue.push({ state: result.state, actions: [...node.actions, action] }); } } return { solved: false, exhausted: queue.length > limit, actions: [] }; }