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]); }