feat: add production campaigns, gameplay labs, and review documentation

This commit is contained in:
cabinw
2026-09-13 12:27:10 +08:00
parent d1dffa9338
commit 4644e975b0
214 changed files with 98994 additions and 134 deletions
+173
View File
@@ -0,0 +1,173 @@
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 } };
}