import { Game, available, random, trace, key, rotateBoard, solveRotations, validateLevel } from './core.js'; import { countBends, inspectExperience, pointsInward } from './experience-generator.js'; import { createRouteBoard } from './route-grammar.js'; import { createStageSeed } from './stage-seed.js'; import { EXPERIENCE_KINDS, PRODUCTION_PLAN_VERSION } from './production-plan.js'; const radiusAt = t => Math.max(Math.abs(t.q), Math.abs(t.r), Math.abs(t.q + t.r)); function rotateActions(from, to) { let delta = (to - from + 6) % 6; const direction = delta > 3 ? -1 : 1; if (delta > 3) delta = 6 - delta; return Array.from({ length: delta }, () => ({ type: 'rotate', direction })); } function validDeparture(board, tile, tiles) { return trace(board, tile, tiles).status === 'exit'; } function routePairs(route, orientation) { const pairs = []; for (const s of route.steps) if (s.enter !== null) { let q = s.q, r = s.r; for (let n = 0; n < (6 - orientation) % 6; n++) [q, r] = [-r || 0, q + r || 0]; const a = (s.enter - orientation + 6) % 6, b = (s.exit - orientation + 6) % 6; pairs.push(`${q},${r}:${Math.min(a, b)}-${Math.max(a, b)}`); } return [...new Set(pairs)]; } // Reverse construction stores a witness as it adds pieces. Shape construction // and placement are separate; each attempted insertion uses formal swept rules. export function constructProductionCandidate(slot, attempt = 0) { const seed = slot.seed + attempt * 104729; const rng = random(seed), base = createRouteBoard(seed, { family: slot.family, radius: slot.radius, variant: slot.variant + Math.floor(attempt / 4) }); const boards = Array.from({ length: 6 }, (_, i) => rotateBoard(base, i)); const featuredSet = new Set(base.routeDesign.featured.map(p => `${p.q},${p.r}:${Math.min(p.a, p.b)}-${Math.max(p.a, p.b)}`)); const pools = new Map(); const pool = turn => { if (!pools.has(turn)) { const candidates = []; for (const cell of base.cells) for (let dir = 0; dir < 6; dir++) { const tile = { q: cell.q, r: cell.r, dir }; const route = trace(boards[turn], tile, [], true), bends = countBends(route); if (route.status === 'exit' && bends <= slot.maxHeroBends) { const pairs = routePairs(route, turn), featured = pairs.filter(p => featuredSet.has(p)).length; candidates.push({ tile, bends, cells: route.steps.length, featured, featuredRatio: featured / Math.max(1, pairs.length) }); } } pools.set(turn, candidates); } return pools.get(turn); }; const contrast = slot.kind === 'contrast', turn = slot.kind === 'turn', home = contrast || turn ? 1 : 0; const stageSeed = slot.kind === 'stages' ? createStageSeed(base, { seed, minHeroBends: slot.minHeroBends, maxHeroBends: slot.maxHeroBends }) : null; if (slot.kind === 'stages' && !stageSeed) return { rejected: 'no-two-stage-lock' }; const heroChoices = pool(home).filter(c => c.bends >= slot.minHeroBends && c.featured >= Math.min(6, slot.minHeroBends) && c.featuredRatio >= .6 && (!contrast || pointsInward(c.tile))); if (!heroChoices.length) return { rejected: 'no-hero-route' }; const heroChoice = stageSeed ? { tile: stageSeed.hero, ...stageSeed.heroRoute } : heroChoices[Math.floor(rng() * heroChoices.length)]; const hero = { ...heroChoice.tile, id: 't0', color: 0 }; const tiles = stageSeed ? [...stageSeed.tiles] : [hero], placements = stageSeed ? [...stageSeed.placements] : [home]; for (let n = tiles.length; n < slot.target; n++) { let orientation = home; if (turn && n > 1) orientation = 0; const occupied = new Set(tiles.map(t => key(t.q, t.r))); let choices = pool(orientation).filter(c => !occupied.has(key(c.tile.q, c.tile.r))); const wantsGate = slot.kind === 'gate' && n === 1; // Sample a broad band rather than always maximizing length. Short departures // may be meaningful gates; spare pieces should not all repeat the hero path. choices = choices.map(c => ({ ...c, rank: rng() * 18 + (n % 3 === 0 ? c.bends * .3 : -c.bends * .06) })); choices.sort((a, b) => b.rank - a.rank); let chosen; for (const c of choices) { const tile = { ...c.tile, id: `t${n}`, color: n % 7 }, proposed = [...tiles, tile]; if (!validDeparture(boards[orientation], tile, proposed)) continue; if (slot.kind === 'tour' && tiles.some(t => !validDeparture(boards[home], t, proposed))) continue; if ((contrast || turn) && !validDeparture(boards[home], hero, proposed)) continue; if (wantsGate) { const before = trace(boards[home], hero, proposed); if (before.status !== 'blocked' || before.blocker !== tile.id || !trace(boards[home], hero, proposed, true).blockers.includes(tile.id)) continue; if (c.bends > Math.max(4, heroChoice.bends / 2)) continue; } if (slot.kind === 'gate' && n > 1) { const gate = proposed.find(t => t.id === 't1'); if (!validDeparture(boards[home], gate, proposed) || !validDeparture(boards[home], hero, proposed.filter(t => t.id !== 't1'))) continue; } if (contrast && n === 1) { const before = trace(boards[0], hero, proposed); if (before.status !== 'blocked' || !trace(boards[0], hero, proposed, true).blockers.includes(tile.id)) continue; } if ((turn || contrast) && n === 1) { // A mutual lock at 0 survives every peripheral removal. At +1 both // core pieces are free, so the intended hero cannot be greedily taken // down a short route before the meaningful turn. if (trace(boards[0], hero, proposed).blocker !== tile.id || trace(boards[0], tile, proposed).blocker !== hero.id) continue; if (!trace(boards[0], hero, proposed, true).blockers.includes(tile.id) || !trace(boards[0], tile, proposed, true).blockers.includes(hero.id)) continue; } chosen = tile; break; } if (!chosen) { if (n < Math.max(3, slot.target - 2) || (n === 1 && (wantsGate || contrast))) return { rejected: 'placement-capacity' }; break; } tiles.push(chosen); placements.push(orientation); } const level = { ...base, id: `trail-v05-${String(slot.number).padStart(4, '0')}`, seed, name: `${(base.name || slot.family).replace(' · 路线草图', '')} · ${EXPERIENCE_KINDS[slot.kind].name}`, chapter: slot.chapter, chapterName: slot.chapterName, beat: EXPERIENCE_KINDS[slot.kind].name, lesson: EXPERIENCE_KINDS[slot.kind].lesson, designIntent: EXPERIENCE_KINDS[slot.kind].intent, tiles, moveLimit: tiles.length, experience: { kind: slot.kind, feature: { id: hero.id, ...(contrast ? { direction: 1 } : {}) } }, production: { planVersion: PRODUCTION_PLAN_VERSION, rulesVersion: 'core-v1-immediate-departure', family: slot.family, variant: base.routeDesign?.variant ?? slot.variant, phase: slot.phase, intent: EXPERIENCE_KINDS[slot.kind].intent, source: { seed, attempt, slot, placements }, review: { automatic: 'pending', visual: 'pending', player: 'not-tested' } }, }; const witness = [], order = [...tiles.keys()].reverse().filter(n => !stageSeed || n >= 3); let orientation = 0; if (contrast) { witness.push({ type: 'rotate', direction: 1 }, { type: 'tap', id: hero.id }); orientation = 1; order.pop(); } if (slot.kind === 'gate') { witness.push({ type: 'tap', id: 't1' }, { type: 'tap', id: hero.id }); order.splice(order.indexOf(1), 1); order.splice(order.indexOf(0), 1); } if (slot.kind === 'tour') { witness.push({ type: 'tap', id: hero.id }); order.pop(); } for (const n of order) { witness.push(...rotateActions(orientation, placements[n]), { type: 'tap', id: tiles[n].id }); orientation = placements[n]; } if (stageSeed) witness.push(...stageSeed.actions); level.experience.actions = witness; const errors = validateLevel(level); if (errors.length) throw new Error(errors.join('; ')); return { level }; } export function inspectCandidate(level, slot) { const solution = solveRotations(level); if (!solution.solvable || solution.exhausted) return { rejected: solution.exhausted ? 'solver-exhausted' : 'unsolved' }; const reference = inspectExperience(level, solution.actions), authored = inspectExperience(level, level.experience.actions); if (!reference.won || !authored.won) throw new Error(`Broken construction witness ${level.id}`); if (reference.maxBends < slot.minHeroBends) return { rejected: 'reference-misses-long-route' }; if (reference.maxDryRotations > 2) return { rejected: 'dry-rotation-run' }; const hero = level.tiles[0], feature = level.experience.feature; if (slot.kind === 'tour' && solution.rotations !== 0) return { rejected: 'tour-needs-turn' }; if (slot.kind === 'gate') { const unlock = authored.unlocks.find(u => u.id === hero.id && u.removed === 't1' && u.onRoute); if (!unlock) return { rejected: 'gate-not-visible' }; Object.assign(feature, unlock); if (!validDeparture(level, level.tiles.find(t => t.id === 't1'), level.tiles) || !validDeparture(level, hero, level.tiles.filter(t => t.id !== 't1'))) return { rejected: 'gate-not-immediate' }; } if (slot.kind === 'contrast') { const before = trace(level, hero), after = trace(rotateBoard(level), hero); if (!pointsInward(hero) || before.status !== 'blocked' || after.status !== 'exit' || countBends(after) < slot.minHeroBends) return { rejected: 'contrast-not-immediate' }; Object.assign(feature, { before: before.status, bends: countBends(after), cells: after.steps.length }); if (solution.rotations < 1) return { rejected: 'contrast-bypassed-without-turn' }; } if (['turn', 'stages'].includes(slot.kind) && solution.rotations < 1) return { rejected: 'rotation-not-needed' }; if (slot.kind === 'stages' && reference.turns.length < 2) return { rejected: 'stages-collapse' }; if (reference.hidden.length > level.tiles.length * 2) return { rejected: 'too-many-hidden-blockers' }; // Keep geometric route usage as evidence rather than calling all drawn lines // gameplay. Rotation changes both cell coordinates and port directions. const game = new Game(level), used = new Set(); let heroPairs = [], heroFeatured = 0; const featured = level.routeDesign?.featured || []; const featuredSet = new Set(featured.map(p => `${p.q},${p.r}:${Math.min(p.a, p.b)}-${Math.max(p.a, p.b)}`)); for (const action of level.experience.actions) { if (action.type === 'rotate') { game.rotate(action.direction); continue; } const route = game.tap(action.id); const pairs = routePairs(route, game.orientation); for (const pair of pairs) used.add(pair); if (action.id === hero.id) { heroPairs = pairs; heroFeatured = pairs.filter(p => featuredSet.has(p)).length; } } const matches = featured.filter(p => used.has(`${p.q},${p.r}:${Math.min(p.a, p.b)}-${Math.max(p.a, p.b)}`)).length; if (featured.length && matches < 4) return { rejected: 'shape-not-traveled' }; if (heroFeatured < Math.min(6, slot.minHeroBends) || heroFeatured / Math.max(1, heroPairs.length) < .6) return { rejected: 'hero-misses-shape' }; return { solution, reference, authored, shapeUsage: { featuredPairs: featured.length, traveledPairs: matches, ratio: featured.length ? matches / featured.length : 0, heroFeaturedPairs: heroFeatured, heroRoutePairs: heroPairs.length, heroFeaturedRatio: heroFeatured / Math.max(1, heroPairs.length) }, opening: { free: available(level).length, inward: level.tiles.filter(pointsInward).length, outer: level.tiles.filter(t => radiusAt(t) === slot.radius).length } }; }