feat: add production campaigns, gameplay labs, and review documentation
This commit is contained in:
@@ -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 } };
|
||||
}
|
||||
Reference in New Issue
Block a user