296 lines
18 KiB
JavaScript
296 lines
18 KiB
JavaScript
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]);
|
|
}
|