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
+295
View File
@@ -0,0 +1,295 @@
import { Game, canRotate, random, rotateBoard, solveRotations, trace, validateLevel } from './core.js';
import { countBends, pointsInward } from './experience-generator.js';
// These are repeatable structural observations, not player difficulty or enjoyment
// measurements. Six policies sample sensible choices; they do not enumerate play.
export const CAMPAIGN_EVIDENCE_VERSION = 2;
const LONG_BENDS = 6;
const POLICY_NAMES = ['outer-first', 'short-first', 'long-first', 'blocker-first', 'rotate-early', 'seeded-random'];
const radius = tile => Math.max(Math.abs(tile.q), Math.abs(tile.r), Math.abs(tile.q + tile.r));
const rounded = value => Math.round(value * 1000) / 1000;
const fraction = (n, total) => total ? rounded(n / total) : null;
function hashSeed(text) {
let hash = 2166136261;
for (const character of text) hash = Math.imul(hash ^ character.charCodeAt(0), 16777619);
return hash >>> 0;
}
// Keep the pivot fixed: translating only the artwork can change rotation legality.
// A reflection maps d to -d; a clockwise whole-level turn then adds one to d.
export function canonicalLevelSignature(level) {
const variants = [];
for (const reflected of [false, true]) for (let turns = 0; turns < 6; turns++) {
const direction = d => ((reflected ? -d : d) + turns + 6) % 6;
const coordinate = source => {
let q = reflected ? -source.q : source.q;
let r = reflected ? source.q + source.r : source.r;
for (let i = 0; i < turns; i++) [q, r] = [-r, q + r];
return [q || 0, r || 0];
};
const cells = level.cells.map(cell => {
const tracks = [];
cell.tracks.forEach((partner, port) => { tracks[direction(port)] = direction(partner); });
return [...coordinate(cell), ...tracks];
}).sort((a, b) => a[0] - b[0] || a[1] - b[1]);
const tiles = level.tiles.map(tile => [...coordinate(tile), direction(tile.dir)])
.sort((a, b) => a[0] - b[0] || a[1] - b[1] || a[2] - b[2]);
// Spare moves have no gameplay effect; insufficient removal allowance does.
variants.push(JSON.stringify([level.version, level.rotationEnabled !== false,
Math.min(level.moveLimit, level.tiles.length), cells, tiles]));
}
return variants.sort()[0];
}
function createContext(level) {
const boards = Array.from({ length: 6 }, (_, turn) => rotateBoard(level, turn));
const indices = new Map(level.tiles.map((tile, index) => [tile.id, index]));
const cache = new Map();
const mask = tiles => tiles.reduce((value, tile) => value | (1n << BigInt(indices.get(tile.id))), 0n).toString(36);
function routes(orientation, tiles) {
const state = `${orientation}:${mask(tiles)}`;
if (!cache.has(state)) {
cache.set(state, tiles.map(tile => ({ tile, route: trace(boards[orientation], tile, tiles) })));
}
return cache.get(state);
}
return { boards, mask, routes, rotating: canRotate(level) && level.rotationEnabled !== false };
}
function createReplay(level, context) {
const game = new Game(level);
const paths = [], reveals = [], unlocks = [], turns = [], hidden = [], actions = [];
const depth = new Map(), initial = context.routes(0, game.tiles);
for (const { tile, route } of initial) if (route.status === 'exit') depth.set(tile.id, 0);
let dry = 0, maxDryRotations = 0, stage = 0, maxUnlockDepth = 0, error = null;
function execute(action) {
const index = actions.length;
const before = context.routes(game.orientation, game.tiles);
const previousTiles = game.tiles;
for (const { tile, route } of before) {
if (route.status === 'blocked' && !trace(game.level, tile, game.tiles, true).blockers.includes(route.blocker)) {
hidden.push({ action: index, id: tile.id, blocker: route.blocker });
}
}
if (!action || (action.type !== 'tap' && action.type !== 'rotate')) {
error = { action: index, status: 'invalid-action' };
return false;
}
const result = action.type === 'rotate' ? game.rotate(action.direction) : game.tap(action.id);
if (result.status !== (action.type === 'rotate' ? 'rotated' : 'exit')) {
error = { action: index, status: result.status };
return false;
}
actions.push(action.type === 'rotate' ? { type: 'rotate', direction: action.direction } : { type: 'tap', id: action.id });
const after = context.routes(game.orientation, game.tiles);
const afterById = new Map(after.map(entry => [entry.tile.id, entry.route]));
if (action.type === 'rotate') {
dry++;
maxDryRotations = Math.max(maxDryRotations, dry);
stage++;
const opened = [];
for (const { tile, route } of before) {
const next = afterById.get(tile.id);
if (route.status !== 'exit' && next?.status === 'exit') {
opened.push(tile.id);
depth.set(tile.id, 0);
if (pointsInward(tile)) reveals.push({ action: index, id: tile.id, direction: action.direction, orientation: game.orientation,
before: route.status, bends: countBends(next), cells: next.steps.length });
}
}
turns.push({ action: index, direction: action.direction, remaining: game.tiles.length,
opened, free: after.filter(entry => entry.route.status === 'exit').map(entry => entry.tile.id) });
} else {
dry = 0;
paths.push({ id: action.id, action: index, stage, orientation: game.orientation,
bends: countBends(result), cells: result.steps.length });
const nextDepth = (depth.get(action.id) || 0) + 1;
for (const { tile, route } of before) {
const next = afterById.get(tile.id);
if (route.status === 'blocked' && next?.status === 'exit') {
depth.set(tile.id, nextDepth);
maxUnlockDepth = Math.max(maxUnlockDepth, nextDepth);
unlocks.push({ action: index, removed: action.id, id: tile.id, orientation: game.orientation, bends: countBends(next), cells: next.steps.length,
onRoute: trace(game.level, tile, previousTiles, true).blockers.includes(action.id) });
}
}
}
return true;
}
function summary(name, stopped = null) {
const fulfilled = events => events.filter(event => paths.some(path => path.id === event.id &&
path.action > event.action && path.orientation === event.orientation && path.cells === event.cells && path.bends === event.bends));
return { ...(name ? { name } : {}), won: game.won && !error, valid: !error, error,
stopped: error ? 'invalid-action' : game.won ? null : stopped || 'incomplete', actions,
paths, reveals, unlocks, fulfilledReveals: fulfilled(reveals), fulfilledUnlocks: fulfilled(unlocks),
turns, maxDryRotations, hidden, maxUnlockDepth,
maxBends: Math.max(0, ...paths.map(path => path.bends)),
longPaths: paths.filter(path => path.bends >= LONG_BENDS).length,
routeCells: paths.reduce((total, path) => total + path.cells, 0),
totalBends: paths.reduce((total, path) => total + path.bends, 0),
initialFree: initial.filter(entry => entry.route.status === 'exit').length,
remaining: game.tiles.map(tile => tile.id) };
}
return { game, execute, summary, actionCount: () => actions.length };
}
function policyReplay(level, context, name, seed) {
const replay = createReplay(level, context), { game } = replay;
const rng = random(seed), earlyConsidered = new Set();
const limit = level.tiles.length * 8 + 12;
let stopped = 'step-limit';
while (!game.won && replay.actionCount() < limit) {
const entries = context.routes(game.orientation, game.tiles);
const free = entries.filter(entry => entry.route.status === 'exit');
const occupancy = context.mask(game.tiles);
const randomPolicy = name.startsWith('seeded-random');
if (context.rotating && !earlyConsidered.has(occupancy) && (name === 'rotate-early' || (randomPolicy && rng() < .25))) {
earlyConsidered.add(occupancy);
const currentIds = new Set(free.map(entry => entry.tile.id));
const adjacent = [1, -1].map(direction => {
const candidates = context.routes((game.orientation + direction + 6) % 6, game.tiles)
.filter(entry => entry.route.status === 'exit');
return { direction, candidates, opened: candidates.filter(entry => !currentIds.has(entry.tile.id)).length };
}).filter(option => option.candidates.length && (randomPolicy || option.opened));
adjacent.sort((a, b) => b.opened - a.opened);
if (adjacent.length) {
if (!replay.execute({ type: 'rotate', direction: adjacent[randomPolicy ? Math.floor(rng() * adjacent.length) : 0].direction })) break;
continue;
}
}
if (!free.length) {
if (!context.rotating) { stopped = 'no-available-route'; break; }
let turn = null;
const preferred = randomPolicy && rng() < .5 ? -1 : 1;
for (let distance = 1; distance <= 3 && !turn; distance++) for (const direction of [preferred, -preferred]) {
const orientation = (game.orientation + distance * direction + 6) % 6;
if (context.routes(orientation, game.tiles).some(entry => entry.route.status === 'exit')) {
turn = { direction, distance };
break;
}
}
if (!turn) { stopped = 'no-available-orientation'; break; }
let valid = true;
for (let i = 0; i < turn.distance; i++) if (!replay.execute({ type: 'rotate', direction: turn.direction })) { valid = false; break; }
if (!valid) break;
// The required turn has already offered this occupancy its exploration.
earlyConsidered.add(occupancy);
continue;
}
const blockers = new Map();
for (const { route } of entries) if (route.status === 'blocked') blockers.set(route.blocker, (blockers.get(route.blocker) || 0) + 1);
const score = entry => {
if (name === 'outer-first') return radius(entry.tile);
if (name === 'short-first') return -entry.route.steps.length;
if (name === 'long-first') return countBends(entry.route) * 1000 + entry.route.steps.length;
if (name === 'blocker-first') return (blockers.get(entry.tile.id) || 0) * 1000 - entry.route.steps.length;
return radius(entry.tile);
};
free.sort((a, b) => score(b) - score(a) || a.tile.q - b.tile.q || a.tile.r - b.tile.r || a.tile.dir - b.tile.dir);
const chosen = free[randomPolicy ? Math.floor(rng() * free.length) : 0];
if (!replay.execute({ type: 'tap', id: chosen.tile.id })) break;
}
return replay.summary(name, stopped);
}
// Optional authored actions are audited as supplied. They never replace the core
// solver's independent solvability finding; inspect reference.won before publish.
export function assessLevel(level, { actions, policyRuns = 6 } = {}) {
const errors = validateLevel(level);
if (errors.length) return { schemaVersion: CAMPAIGN_EVIDENCE_VERSION, valid: false, errors,
solvable: false, exhausted: false, referenceActions: [], reference: null, policies: [],
difficulty: null, reward: null, workload: null };
if (!Number.isInteger(policyRuns) || policyRuns < 0 || policyRuns > 64) throw new RangeError('policyRuns must be an integer from 0 to 64');
const solution = solveRotations(level);
const context = createContext(level), replay = createReplay(level, context);
const referenceActions = actions ?? solution.actions;
for (const action of referenceActions) if (!replay.execute(action)) break;
const reference = replay.summary();
let solverReference = reference;
if (JSON.stringify(referenceActions) !== JSON.stringify(solution.actions)) {
const solverReplay = createReplay(level, context);
for (const action of solution.actions) if (!solverReplay.execute(action)) break;
solverReference = solverReplay.summary();
}
const signature = canonicalLevelSignature(level), seed = hashSeed(signature);
const policies = Array.from({ length: policyRuns }, (_, index) => policyReplay(level, context,
POLICY_NAMES[index] || `seeded-random-${index - 4}`, seed + index * 104729));
const initialFreeRatio = level.tiles.length ? reference.initialFree / level.tiles.length : 0;
const hiddenIds = new Set(solverReference.hidden.map(event => event.id));
const directionChanges = reference.turns.slice(1).filter((turn, i) => turn.direction !== reference.turns[i].direction).length;
const parts = {
requiredRotations: Math.min(4, solution.rotations) * 15,
releaseDepth: Math.min(4, solverReference.maxUnlockDepth) * 6,
openingConstraint: (1 - initialFreeRatio) * 12,
sweptBlockers: hiddenIds.size ? 8 + 10 * hiddenIds.size / Math.max(1, level.tiles.length) : 0,
};
const score = Math.round(Object.values(parts).reduce((sum, value) => sum + value, 0));
const tier = score < 12 ? 1 : score < 30 ? 2 : score < 48 ? 3 : score < 66 ? 4 : 5;
const heroId = level.experience?.feature?.id || level.experience?.primaryPiece || level.production?.primaryPiece ||
[...reference.paths].sort((a, b) => b.bends - a.bends || b.cells - a.cells)[0]?.id || null;
const hero = reference.paths.find(path => path.id === heroId) || null;
const referenceContrasts = new Set(reference.fulfilledReveals.map(event => event.id));
const longSeen = policies.filter(policy => policy.longPaths > 0).length;
const heroSeen = policies.filter(policy => policy.paths.some(path => path.id === heroId && path.bends >= LONG_BENDS)).length;
const contrastSeen = policies.filter(policy => policy.fulfilledReveals.some(event => referenceContrasts.has(event.id))).length;
const heroContrast = event => event.id === heroId && event.bends >= LONG_BENDS;
const heroGate = event => event.id === heroId && event.bends >= LONG_BENDS && event.onRoute;
const heroContrastSeen = policies.filter(policy => policy.fulfilledReveals.some(heroContrast)).length;
const heroGateSeen = policies.filter(policy => policy.fulfilledUnlocks.some(heroGate)).length;
return {
schemaVersion: CAMPAIGN_EVIDENCE_VERSION, valid: true, errors: [], solvable: solution.solvable,
exhausted: solution.exhausted, solverStates: solution.states, referenceActions, reference,
solverActions: solution.actions, solverReference, policies,
difficulty: { tier, label: ['轻松', '入门组合', '进阶', '挑战', '高挑战'][tier - 1],
basis: 'structural-estimate', playerCalibrated: false, score,
parts: Object.fromEntries(Object.entries(parts).map(([name, value]) => [name, rounded(value)])),
minimumRotations: solution.solvable ? solution.rotations : null,
releaseDepth: solverReference.maxUnlockDepth, initialFreeRatio: rounded(initialFreeRatio),
sweptBlockerPieces: hiddenIds.size, referenceDirectionChanges: directionChanges },
reward: { longThresholdBends: LONG_BENDS, thresholdBasis: 'diagnostic-band-not-quality-guarantee', heroId,
referenceHero: hero, referenceLongPaths: reference.longPaths, referenceMaxBends: reference.maxBends,
policyRuns: policies.length, wonRuns: policies.filter(policy => policy.won).length,
longExposure: { seen: longSeen, total: policies.length, rate: fraction(longSeen, policies.length) },
heroLongExposure: { seen: heroSeen, total: policies.length, rate: fraction(heroSeen, policies.length) },
referenceContrastIds: [...referenceContrasts],
contrastExposure: { seen: contrastSeen, total: policies.length,
rate: referenceContrasts.size ? fraction(contrastSeen, policies.length) : null, scope: 'any-reference-contrast-piece' },
// General contrast above can be another inward piece or a short route.
// These two fields require the designated hero to actually finish a long
// route after its own opening event. Gate exposure also requires the
// removed blocker to occupy a cell on that route, rather than a side graze.
heroContrastExposure: { seen: heroContrastSeen, total: policies.length, rate: fraction(heroContrastSeen, policies.length),
scope: 'hero-fulfilled-long-rotation', thresholdBends: LONG_BENDS,
referenceEligible: reference.fulfilledReveals.some(heroContrast) },
heroGateExposure: { seen: heroGateSeen, total: policies.length, rate: fraction(heroGateSeen, policies.length),
scope: 'hero-fulfilled-on-route-long-release', thresholdBends: LONG_BENDS,
referenceEligible: reference.fulfilledUnlocks.some(heroGate) },
contrastBypasses: referenceContrasts.size ? policies.filter(policy => policy.won && !policy.fulfilledReveals.some(event => referenceContrasts.has(event.id))).map(policy => policy.name) : [],
firstLongAction: reference.paths.find(path => path.bends >= LONG_BENDS)?.action ?? null,
policyMaxBendsRange: policies.length ? [Math.min(...policies.map(policy => policy.maxBends)), Math.max(...policies.map(policy => policy.maxBends))] : null,
coverage: 'bounded-policies-not-exhaustive-player-behavior' },
workload: { removals: level.tiles.length, minimumActions: solution.minimumActions,
referenceActions: reference.actions.length, referenceRotations: reference.turns.length,
routeCells: reference.routeCells, totalBends: reference.totalBends,
basis: 'counts-not-measured-duration', animationSeconds: null, playerSeconds: null },
};
}
// A deliberately coarse pacing key. Equal keys are a reason to inspect adjacent
// choices, never proof that geometrically different levels should be discarded.
export function experienceSignature(assessment) {
if (!assessment.valid || !assessment.reference) return 'invalid';
const { reference, difficulty, reward, workload } = assessment;
const mode = reference.turns.length ? reference.unlocks.length ? 'combined' : 'rotation' : reference.unlocks.length ? 'release' : 'direct';
const band = (value, cuts) => cuts.filter(cut => value >= cut).length;
const stages = [...new Set(reference.paths.map(path => path.stage))].map(stage => {
const paths = reference.paths.filter(path => path.stage === stage);
return [band(paths.length, [3, 7, 12]), band(Math.max(0, ...paths.map(path => path.bends)), [1, 6, 12, 20])];
});
return JSON.stringify([mode, difficulty.tier, band(difficulty.releaseDepth, [1, 3, 5]),
band(workload.removals, [5, 10, 16, 24]), band(reward.referenceMaxBends, [1, 6, 12, 20]),
Boolean(reference.reveals.length), stages]);
}