diff --git a/.codex/skills/gothic-lock-solver/SKILL.md b/.codex/skills/gothic-lock-solver/SKILL.md deleted file mode 100644 index a3d5792..0000000 --- a/.codex/skills/gothic-lock-solver/SKILL.md +++ /dev/null @@ -1,45 +0,0 @@ ---- -name: gothic-lock-solver -description: Use when a user provides Gothic 1 Remake lock-plate positions or links, or requests a shortest combination; not for ordinary code locks or cases requiring guessed unconfirmed links. ---- - -# Gothic Lock Solver - -Используй вычислительное ядро проекта, а не языковую модель, для поиска комбинации. - -## Правила модели - -- Пластин от 2 до 7, позиции каждой от 1 до 7, цель всегда 4. -- Влево означает `+1`, вправо означает `-1`. -- Каждая строка матрицы направленная и независимая; обратную связь не подразумевай. -- Связи не каскадируют. Диагональ матрицы равна 0. -- Если зависимая пластина не может выполнить весь сдвиг, команда заблокирована полностью. -- Не используй приложенный PDF в этом workflow. - -## Сбор входа - -1. Зафиксируй число пластин и текущий вектор слева направо. -2. Для каждой исходной пластины отдельно зафиксируй, какие другие пластины движутся вместе с ней. -3. Запиши `1` для того же направления, `-1` для противоположного, `0` только для подтвержденного отсутствия связи. -4. Не запускай решатель, пока в каждой строке не устранены неизвестные связи. -5. Создай JSON с полями `state` и `links` по схеме `gothic-lock-solver-spec.md`. - -## Запуск - -Разреши корень проекта как три уровня вверх от каталога этого skill. Запусти: - -```bash -node /solve-lock.mjs -``` - -Добавляй `--output ` только когда пользователь просит файл результата. - -Передай пользователю команды без перегруппировки и с исходной нумерацией пластин: - -```text -Пластина K - влево|вправо xS -``` - -## Проверка в игре - -После каждой выполненной команды сверяй наблюдаемое состояние с рассчитанным, если пользователь сообщает его. При первом расхождении останови текущую комбинацию, зафиксируй фактический вектор и уточни только затронутую строку матрицы. Не продолжай по заведомо неверной модели. diff --git a/.codex/skills/gothic-lock-solver/agents/openai.yaml b/.codex/skills/gothic-lock-solver/agents/openai.yaml deleted file mode 100644 index 64d5729..0000000 --- a/.codex/skills/gothic-lock-solver/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "Gothic Lock Solver" - short_description: "Собирает модель замка и находит кратчайшую комбинацию" - default_prompt: "Помоги описать и решить замок Gothic 1 Remake." diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..cecdeb8 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,13 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 5 + - package-ecosystem: npm + directory: / + schedule: + interval: weekly + versioning-strategy: increase + open-pull-requests-limit: 5 diff --git a/.github/scripts/build.ts b/.github/scripts/build.ts new file mode 100644 index 0000000..6f5973e --- /dev/null +++ b/.github/scripts/build.ts @@ -0,0 +1,88 @@ +import { createHash } from 'node:crypto'; +import { builtinModules } from 'node:module'; +import { execFileSync } from 'node:child_process'; +import { mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import { build } from 'vite'; + +const root = fileURLToPath(new URL('../../', import.meta.url)); +const dist = new URL('../../dist/', import.meta.url); + +async function buildCli(): Promise { + await build({ + root, + configFile: false, + build: { + target: 'node22', + outDir: 'dist', + emptyOutDir: false, + minify: false, + sourcemap: false, + lib: { + entry: fileURLToPath(new URL('../../src/cli.ts', import.meta.url)), + formats: ['es'], + fileName: () => 'gothic-lock-solver.cli.mjs', + }, + rolldownOptions: { + external: [...builtinModules, ...builtinModules.map((name) => `node:${name}`)], + output: { + codeSplitting: false, + comments: { legal: false }, + }, + }, + }, + }); +} + +async function buildDeclarations(): Promise { + const compiler = fileURLToPath(new URL('../../node_modules/typescript/bin/tsc', import.meta.url)); + execFileSync(process.execPath, [compiler, '-p', 'tsconfig.build.json'], { + cwd: root, + stdio: 'inherit', + }); + + const globalDeclaration = [ + '/** Types for the classic GothicLockSolver global. */', + 'export as namespace GothicLockSolver;', + "export * from './index.js';", + '', + ].join('\n'); + await writeFile(new URL('global.d.ts', dist), globalDeclaration); +} + +async function writeBuildManifest(): Promise { + const files: Record = {}; + for (const filename of (await readdir(dist)).sort()) { + const bytes = await readFile(new URL(filename, dist)); + files[filename] = createHash('sha256').update(bytes).digest('hex'); + } + + const metadata: unknown = JSON.parse(await readFile(new URL('../../package.json', import.meta.url), 'utf8')); + if (typeof metadata !== 'object' || metadata === null || !('version' in metadata) || typeof metadata.version !== 'string') { + throw new Error('Package version is missing.'); + } + const sourceSha = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: root, encoding: 'utf8' }).trim(); + const manifest = { + schemaVersion: 1, + package: 'gothic-lock-solver', + version: metadata.version, + sourceSha, + files, + }; + await writeFile(new URL('manifest.json', dist), `${JSON.stringify(manifest, null, 2)}\n`); +} + +await rm(dist, { recursive: true, force: true }); +await mkdir(dist, { recursive: true }); + +// Each library pass produces ESM and classic-script files; the CLI is a separate entry. +for (const mode of ['readable', 'minified']) { + await build({ + root, + configFile: fileURLToPath(new URL('../../vite.config.ts', import.meta.url)), + mode, + }); +} +await buildCli(); +await buildDeclarations(); +await writeBuildManifest(); diff --git a/.github/scripts/ci-baseline.ts b/.github/scripts/ci-baseline.ts new file mode 100644 index 0000000..a4b8fe8 --- /dev/null +++ b/.github/scripts/ci-baseline.ts @@ -0,0 +1,129 @@ +import { execFileSync } from 'node:child_process'; +import { appendFile, mkdir, readFile, stat, writeFile } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; +import { + NPM_REGISTRY, + PACKAGE_NAME, + assertDigest, + previousStableVersion, + record, + sha256, + string, + validateManifest, +} from './release-lib.ts'; +import { writeJson } from './release-files.ts'; +import { fetchBytes, verifyRegistry } from './release-network.ts'; + +const REGISTRY_TIMEOUT_MS = 30_000; + +async function emit(fields: Readonly>): Promise { + const text = Object.entries(fields) + .map(([key, value]) => `${key}=${value}\n`) + .join(''); + const githubOutput = process.env['GITHUB_OUTPUT']; + if (githubOutput) { + await appendFile(githubOutput, text); + } + console.log(JSON.stringify(fields)); +} + +async function checkoutBaseline(checkoutDirectory: string, outputDirectory: string): Promise { + const base = resolve(checkoutDirectory); + const legacyModule = join(base, 'src/index.mjs'); + const legacy = await stat(legacyModule).then( + (value) => value.isFile(), + () => false, + ); + const module = legacy ? legacyModule : join(base, 'dist/gothic-lock-solver.mjs'); + const format = legacy ? 'legacy' : 'tuple'; + const snapshot = join(outputDirectory, 'baseline-snapshot.json'); + + // The supplied base checkout, never a candidate-owned expected snapshot, supplies the live baseline. + execFileSync(process.execPath, [ + 'tests/benchmarks/snapshot.ts', + '--source-module', module, + '--source-format', format, + '--reason', 'Fresh snapshot from the separately checked-out PR target/reference commit; candidate snapshot is not read.', + '--output', snapshot, + ], { stdio: 'inherit' }); + await emit({ available: 'true', module, snapshot, format }); +} + +async function releaseBaseline(candidateVersion: string, outputDirectory: string): Promise { + const response = await fetch(`${NPM_REGISTRY}/${PACKAGE_NAME}`, { + signal: AbortSignal.timeout(REGISTRY_TIMEOUT_MS), + }); + if (response.status === 404) { + await emit({ available: 'false', reason: 'No npm package yet; use the explicitly pinned reference checkout.' }); + return; + } + if (!response.ok) { + throw new Error(`npm baseline metadata HTTP ${response.status}`); + } + + const metadata: unknown = await response.json(); + const versions = record(record(metadata, 'package metadata')['versions'], 'versions'); + const previous = previousStableVersion(Object.keys(versions), candidateVersion); + if (previous === null) { + await emit({ available: 'false', reason: 'No preceding stable version; use the explicitly pinned reference checkout.' }); + return; + } + + const prefix = `https://github.com/Marcus-Rise/gothic-lock-solver/releases/download/v${previous}/`; + const manifestBytes = await fetchBytes(`${prefix}release-manifest.json`); + const manifestJson: unknown = JSON.parse(manifestBytes.toString('utf8')); + const manifest = validateManifest(manifestJson); + if (manifest.version !== previous || manifest.kind !== 'stable') { + throw new Error('Previous stable release identity mismatch'); + } + await verifyRegistry(manifest); + + const moduleBytes = await fetchBytes(`${prefix}gothic-lock-solver.mjs`); + const moduleHash = string(manifest.assets['gothic-lock-solver.mjs'], 'previous ESM hash'); + assertDigest(moduleBytes, moduleHash, 'previous release module'); + + const evidenceBytes = await fetchBytes(`${prefix}benchmark-evidence.json`); + const evidenceHash = string(manifest.assets['benchmark-evidence.json'], 'previous evidence hash'); + assertDigest(evidenceBytes, evidenceHash, 'previous release evidence'); + + const evidenceJson: unknown = JSON.parse(evidenceBytes.toString('utf8')); + const evidence = record(evidenceJson, 'previous evidence'); + const snapshot = record(evidence['qualitySnapshot'], 'previous quality snapshot'); + const source = record(snapshot['source'], 'previous snapshot source'); + if (source['revision'] !== manifest.sourceSha || source['moduleSha256'] !== sha256(moduleBytes)) { + throw new Error('Previous release snapshot source differs from published module'); + } + + const module = join(outputDirectory, 'baseline.mjs'); + const snapshotPath = join(outputDirectory, 'baseline-snapshot.json'); + await writeFile(module, moduleBytes, { flag: 'wx' }); + await writeFile(join(outputDirectory, 'previous-release-manifest.json'), manifestBytes, { flag: 'wx' }); + await writeFile(join(outputDirectory, 'previous-benchmark-evidence.json'), evidenceBytes, { flag: 'wx' }); + await writeJson(snapshotPath, { + ...snapshot, + source: { + ...source, + releaseVersion: previous, + kind: 'previous-stable-release', + releaseManifestSha256: sha256(manifestBytes), + releaseEvidenceSha256: sha256(evidenceBytes), + }, + }); + + // Re-read the bytes that will actually be imported, after writing the evidence chain. + assertDigest(await readFile(module), string(source['moduleSha256'], 'snapshot module hash'), 'saved baseline module'); + await emit({ available: 'true', module, snapshot: snapshotPath, format: 'tuple', version: previous }); +} + +const [mode, sourceArgument, outputArgument] = process.argv.slice(2); +if (!['checkout', 'release'].includes(mode ?? '') || !sourceArgument || !outputArgument) { + throw new Error('Usage: node .github/scripts/ci-baseline.ts checkout BASE_CHECKOUT OUTPUT_DIR | release CANDIDATE_VERSION OUTPUT_DIR'); +} + +const outputDirectory = resolve(outputArgument); +await mkdir(outputDirectory, { recursive: true }); +if (mode === 'checkout') { + await checkoutBaseline(sourceArgument, outputDirectory); +} else { + await releaseBaseline(sourceArgument, outputDirectory); +} diff --git a/.github/scripts/release-browser.ts b/.github/scripts/release-browser.ts new file mode 100644 index 0000000..9de9417 --- /dev/null +++ b/.github/scripts/release-browser.ts @@ -0,0 +1,120 @@ +import { createServer } from 'node:http'; +import { chromium, firefox, webkit, type Browser, type BrowserType } from 'playwright'; +import { loadCatalog, type Fixture } from '../../tests/benchmarks/catalog.ts'; +import { replayCommands, tupleCommands } from '../../tests/benchmarks/validation.ts'; +import { CDN_ORIGIN, REQUIRED_LOCK_COUNT, runtimeFiles, type ReleaseManifest } from './release-lib.ts'; + +const TARGET_POSITION = 4; + +function verifySolutions(results: unknown, locks: readonly Fixture[], context: string): void { + if (!Array.isArray(results) || results.length !== REQUIRED_LOCK_COUNT) { + throw new Error('CDN fixture results are incomplete'); + } + + for (const [index, result] of results.entries()) { + const lock = locks[index]; + if (!lock) { + throw new Error('Unexpected CDN fixture'); + } + const commands = tupleCommands(result); + const hasMinimumActions = commands.length === lock.expectedActions; + const solved = hasMinimumActions && replayCommands(lock, commands) + .every((position) => position === TARGET_POSITION); + if (!solved) { + throw new Error(`CDN fixture mismatch: ${context}/${lock.id}`); + } + } +} + +async function verifyBundle( + browser: Browser, + filename: string, + manifest: ReleaseManifest, + locks: readonly Fixture[], + origin: string, +): Promise { + const engineName = browser.browserType().name(); + const page = await browser.newPage(); + try { + await page.goto(origin); + const url = `${CDN_ORIGIN}/npm/${manifest.name}@${manifest.version}/dist/${filename}`; + const runtimeRequests: string[] = []; + page.on('request', (request) => { + if (request.url().startsWith('https://')) { + runtimeRequests.push(request.url()); + } + }); + + if (filename.endsWith('.js')) { + await page.addScriptTag({ url }); + } + const results: unknown = await page.evaluate(async ({ moduleUrl, moduleFormat, definitions }) => { + const namespace: unknown = moduleFormat + ? await import(moduleUrl) + : Reflect.get(globalThis, 'GothicLockSolver'); + if (typeof namespace !== 'object' || namespace === null || !('solveLock' in namespace) || typeof namespace.solveLock !== 'function') { + throw new Error('Missing CDN solveLock export'); + } + const solve = namespace.solveLock; + return definitions.map((definition): unknown => Reflect.apply(solve, undefined, [definition.state, definition.links])); + }, { + moduleUrl: url, + moduleFormat: filename.endsWith('.mjs'), + definitions: locks.map(({ state, links }) => ({ state, links })), + }); + + verifySolutions(results, locks, `${engineName}/${filename}`); + // Each bundle must work with exactly its own request and no runtime imports. + if (runtimeRequests.length !== 1 || runtimeRequests[0] !== url) { + throw new Error(`Additional CDN runtime dependency: ${runtimeRequests.join(', ')}`); + } + console.log(`CDN browser passed: ${engineName}, ${filename}, ${REQUIRED_LOCK_COUNT} fixtures`); + } finally { + await page.close(); + } +} + +async function verifyEngine( + engine: BrowserType, + manifest: ReleaseManifest, + locks: readonly Fixture[], + origin: string, +): Promise { + const browser = await engine.launch(); + try { + for (const filename of runtimeFiles) { + await verifyBundle(browser, filename, manifest, locks, origin); + } + } finally { + await browser.close(); + } +} + +/** Real CDN imports, in each supported engine, after byte/header verification. */ +export async function verifyCdnBrowsers(manifest: ReleaseManifest): Promise { + const locks = loadCatalog(); + const server = createServer((_request, response) => { + response.writeHead(200, { 'Content-Type': 'text/html' }); + response.end('Gothic Lock Solver CDN verification'); + }); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', resolve); + }); + + try { + const address = server.address(); + if (address === null || typeof address === 'string') { + throw new Error('Local CDN test origin unavailable'); + } + const origin = `http://127.0.0.1:${address.port}/`; + for (const engine of [chromium, firefox, webkit]) { + await verifyEngine(engine, manifest, locks, origin); + } + } finally { + await new Promise((resolve, reject) => server.close((error) => { + if (error) reject(error); + else resolve(); + })); + } +} diff --git a/.github/scripts/release-files.ts b/.github/scripts/release-files.ts new file mode 100644 index 0000000..94b9771 --- /dev/null +++ b/.github/scripts/release-files.ts @@ -0,0 +1,254 @@ +import { execFileSync } from 'node:child_process'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; +import { isDeepStrictEqual } from 'node:util'; +import { + CDN_ORIGIN, + PACKAGE_NAME, + REQUIRED_LOCK_COUNT, + assertDigest, + executableFiles, + parseReleaseVersion, + record, + releaseAssets, + sha256, + string, + validateEvidenceGate, + validateManifest, + type ReleaseKind, + type ReleaseManifest, +} from './release-lib.ts'; + +const MAX_ARCHIVE_FILE_BYTES = 16 * 1024 * 1024; + +export async function readJson(path: string): Promise { + const value: unknown = JSON.parse(await readFile(path, 'utf8')); + return value; +} + +export async function writeJson(path: string, value: unknown): Promise { + await writeFile(path, `${JSON.stringify(value, null, 2)}\n`, { flag: 'wx' }); +} + +function archiveFile(tarball: string, filename: string): Buffer { + return execFileSync('tar', ['-xOf', tarball, `package/${filename}`], { + maxBuffer: MAX_ARCHIVE_FILE_BYTES, + }); +} + +export interface EvidenceAcceptance { + readonly acceptedEvidenceSha256?: string; + readonly acceptanceReason?: string; +} + +export async function verifyPreparedRelease( + directory: string, + acceptance: EvidenceAcceptance = {}, +): Promise { + const manifest = validateManifest(await readJson(join(directory, 'release-manifest.json'))); + for (const filename of releaseAssets) { + const bytes = await readFile(join(directory, filename)); + const expectedHash = string(manifest.assets[filename], filename); + assertDigest(bytes, expectedHash, filename); + } + + const tarball = resolve(directory, 'package.tgz'); + const packed: unknown = JSON.parse(archiveFile(tarball, 'package.json').toString('utf8')); + const metadata = record(packed, 'packed metadata'); + if ( + metadata['name'] !== manifest.name || + metadata['version'] !== manifest.version || + metadata['private'] === true + ) { + throw new Error('Packed package identity differs from release'); + } + + const build: unknown = JSON.parse(archiveFile(tarball, 'dist/manifest.json').toString('utf8')); + const buildManifest = record(build, 'packed build manifest'); + if (buildManifest['sourceSha'] !== manifest.sourceSha || buildManifest['version'] !== manifest.version) { + throw new Error('Packed build source/version differs from release'); + } + + for (const filename of executableFiles) { + const bytes = archiveFile(tarball, `dist/${filename}`); + const expectedHash = string(manifest.assets[filename], filename); + assertDigest(bytes, expectedHash, `packed ${filename}`); + } + + // The publishing-side verifier receives acceptance from its trusted caller, never from an artifact. + const moduleSha256 = string(manifest.assets['gothic-lock-solver.mjs'], 'prepared ESM hash'); + await verifyBenchmarkEvidence( + directory, + manifest.sourceSha, + moduleSha256, + acceptance.acceptedEvidenceSha256, + acceptance.acceptanceReason, + ); + return manifest; +} + +export interface PrepareOptions { + readonly root: string; + readonly outputDir: string; + readonly benchmarkDir: string; + readonly sourceSha: string; + readonly kind: ReleaseKind; + readonly acceptedEvidenceSha256?: string; + readonly acceptanceReason?: string; +} + +export async function verifyBenchmarkEvidence( + directory: string, + sourceSha: string, + moduleSha256: string, + acceptedSha256 = '', + acceptanceReason = '', +): Promise { + const bytes = await readFile(join(directory, 'benchmark-evidence.json')); + const parsed: unknown = JSON.parse(bytes.toString('utf8')); + const evidence = record(parsed, 'benchmark evidence'); + const acceptance = validateEvidenceGate(evidence, sha256(bytes), acceptedSha256, acceptanceReason); + const metadata = record(evidence['metadata'], 'benchmark metadata'); + const candidate = record(metadata['candidate'], 'benchmark candidate'); + if ( + candidate['revision'] !== sourceSha || + candidate['moduleSha256'] !== moduleSha256 || + candidate['format'] !== 'tuple' + ) { + throw new Error('Benchmark candidate differs from prepared source/artifact'); + } + + const reports = record(evidence['reports'], 'benchmark report hashes'); + const reportFiles = [ + ['json', 'benchmark.json'], + ['markdown', 'benchmark.md'], + ] as const; + for (const [field, filename] of reportFiles) { + const report = record(reports[field], field); + if (report['path'] !== filename) { + throw new Error('Unexpected benchmark report path'); + } + const reportBytes = await readFile(join(directory, filename)); + const expectedHash = string(report['sha256'], 'report hash'); + assertDigest(reportBytes, expectedHash, filename); + } + + const report = record(await readJson(join(directory, 'benchmark.json')), 'detailed benchmark report'); + const correctness = record(report['correctness'], 'report correctness'); + if (correctness['passed'] !== true) { + throw new Error('Detailed report correctness must pass'); + } + for (const field of ['schemaVersion', 'quality', 'performance', 'config', 'metadata', 'memory']) { + if (!isDeepStrictEqual(report[field], evidence[field])) { + throw new Error(`Detailed report ${field} differs from benchmark evidence`); + } + } + return acceptance; +} + +function releaseAssetPath(filename: string, options: PrepareOptions): string { + if (filename === 'package.tgz') { + return join(options.root, 'artifacts/package/package.tgz'); + } + if (filename.startsWith('benchmark')) { + return join(options.benchmarkDir, filename); + } + return join(options.root, 'dist', filename); +} + +/** Copies the already tested archive; this function never invokes a build or npm pack. */ +export async function prepareRelease(options: PrepareOptions): Promise { + const build = record(await readJson(join(options.root, 'dist/manifest.json')), 'build manifest'); + const metadata = record(await readJson(join(options.root, 'package.json')), 'package metadata'); + const version = string(metadata['version'], 'package version'); + if (parseReleaseVersion(version).prerelease !== (options.kind === 'canary')) { + throw new Error('Stable/canary version mismatch'); + } + if (build['sourceSha'] !== options.sourceSha || build['version'] !== version) { + throw new Error('Build source/version differs from requested release'); + } + + const moduleSha256 = sha256(await readFile(join(options.root, 'dist/gothic-lock-solver.mjs'))); + const acceptance = await verifyBenchmarkEvidence( + options.benchmarkDir, + options.sourceSha, + moduleSha256, + options.acceptedEvidenceSha256, + options.acceptanceReason, + ); + + const assets: Record = {}; + await mkdir(options.outputDir, { recursive: true }); + for (const filename of releaseAssets) { + const bytes = await readFile(releaseAssetPath(filename, options)); + assets[filename] = sha256(bytes); + await writeFile(join(options.outputDir, filename), bytes, { flag: 'wx' }); + } + + const manifest = validateManifest({ + schemaVersion: 1, + name: PACKAGE_NAME, + version, + sourceSha: options.sourceSha, + kind: options.kind, + tarballSha256: assets['package.tgz'], + assets, + }); + await writeJson(join(options.outputDir, 'release-manifest.json'), manifest); + await verifyPreparedRelease(options.outputDir, options); + + const evidence = record(await readJson(join(options.benchmarkDir, 'benchmark-evidence.json')), 'release evidence'); + const notes = releaseNotes(manifest, evidence, acceptance); + await writeFile(join(options.outputDir, 'release-notes.md'), notes, { flag: 'wx' }); + return manifest; +} + +function releaseNotes( + manifest: ReleaseManifest, + evidence: Record, + acceptance: string | null, +): string { + const evidenceMetadata = record(evidence['metadata'], 'evidence metadata'); + const baseline = record(evidenceMetadata['baseline'] ?? {}, 'evidence baseline'); + const baselineKind = typeof baseline['kind'] === 'string' ? baseline['kind'] : 'reference/previous release'; + const baselineRevision = typeof baseline['revision'] === 'string' + ? baseline['revision'] + : 'see benchmark-evidence.json'; + const memory = record(evidence['memory'], 'memory evidence'); + const memoryPerformance = record(memory['performance'], 'memory verdict'); + const timingVerdict = string(evidence['performance'], 'performance'); + const memoryVerdict = string(memoryPerformance['verdict'], 'memory verdict'); + const acceptanceNote = acceptance === null + ? 'The calibrated timing and memory gates passed.' + : `Accepted performance evidence: ${acceptance}`; + const cdn = `${CDN_ORIGIN}/npm/${manifest.name}@${manifest.version}/dist`; + + return `# Gothic Lock Solver ${manifest.version} + +Source: ${manifest.sourceSha}. + +[npm ${manifest.version}](https://www.npmjs.com/package/${manifest.name}/v/${manifest.version}) + +Install: \`npm install ${manifest.name}@${manifest.version}\`. + +This release provides solveLock(state, links, config?) and createSolverConfig(overrides?). Commands are [zero-based index, signed numeric pin delta]. See the Wiki API contract for integration details. + +Benchmark quality: **passed**, ${REQUIRED_LOCK_COUNT} fixtures. Performance: **${timingVerdict}**; memory: **${memoryVerdict}**. +Baseline: ${baselineKind}, source ${baselineRevision}. Detailed report hashes and the comparison source are preserved in benchmark-evidence.json. +${acceptanceNote} + +\`\`\`html + + +\`\`\` + +\`\`\`html + +\`\`\` + +All five executable files, the exact tested npm archive, checksum manifest and detailed benchmark reports are attached. +`; +} diff --git a/.github/scripts/release-lib.ts b/.github/scripts/release-lib.ts new file mode 100644 index 0000000..b4ed8bf --- /dev/null +++ b/.github/scripts/release-lib.ts @@ -0,0 +1,228 @@ +import { createHash } from 'node:crypto'; + +export const PACKAGE_NAME = 'gothic-lock-solver'; +export const REQUIRED_LOCK_COUNT = 45; +export const NPM_REGISTRY = 'https://registry.npmjs.org'; +export const CDN_ORIGIN = 'https://cdn.jsdelivr.net'; + +const COMMIT_SHA = /^[a-f0-9]{40}$/; +const SHA256 = /^[a-f0-9]{64}$/; +const POSITIVE_INTEGER = /^[1-9]\d*$/; +const VERSION = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/; +const CANARY_SHA_LENGTH = 12; + +export const runtimeFiles = [ + 'gothic-lock-solver.mjs', + 'gothic-lock-solver.min.mjs', + 'gothic-lock-solver.js', + 'gothic-lock-solver.min.js', +] as const; + +export const executableFiles = [...runtimeFiles, 'gothic-lock-solver.cli.mjs'] as const; +export const releaseAssets = [ + ...executableFiles, + 'benchmark-evidence.json', + 'benchmark.json', + 'benchmark.md', + 'package.tgz', +] as const; + +export type ReleaseKind = 'stable' | 'canary'; + +export interface ReleaseManifest { + readonly schemaVersion: 1; + readonly name: typeof PACKAGE_NAME; + readonly version: string; + readonly sourceSha: string; + readonly kind: ReleaseKind; + readonly tarballSha256: string; + readonly assets: Readonly>; +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +export function record(value: unknown, context: string): Record { + if (!isRecord(value)) { + throw new Error(`Invalid ${context}`); + } + return value; +} + +export function string(value: unknown, context: string): string { + if (typeof value !== 'string' || value.length === 0) { + throw new Error(`Invalid ${context}`); + } + return value; +} + +export function sha256(value: Uint8Array | string): string { + return createHash('sha256').update(value).digest('hex'); +} + +export function assertDigest(bytes: Uint8Array, expected: string, context: string): void { + if (!SHA256.test(expected) || sha256(bytes) !== expected) { + throw new Error(`${context} identity mismatch; existing versions/assets must never be overwritten`); + } +} + +export function parseReleaseVersion(version: string): { version: string; prerelease: boolean } { + const match = VERSION.exec(version); + const prerelease = match?.[4]; + const hasLeadingZero = prerelease?.split('.').some(part => /^0\d+$/.test(part)); + if (match === null || hasLeadingZero) { + throw new Error(`Invalid release SemVer: ${version}`); + } + return { version, prerelease: prerelease !== undefined }; +} + +export function canaryVersion(base: string, sourceSha: string, runId: string, attempt: string): string { + const validRun = POSITIVE_INTEGER.test(runId) && POSITIVE_INTEGER.test(attempt); + if (parseReleaseVersion(base).prerelease || !COMMIT_SHA.test(sourceSha) || !validRun) { + throw new Error('Canary requires stable base version, source SHA and positive run identifiers'); + } + return `${base}-canary.${runId}.${attempt}.${sourceSha.slice(0, CANARY_SHA_LENGTH)}`; +} + +function compareStableVersions(left: string, right: string): number { + const [leftMajor, leftMinor, leftPatch] = left.split('.').map(BigInt); + const [rightMajor, rightMinor, rightPatch] = right.split('.').map(BigInt); + const components = [ + [leftMajor, rightMajor], + [leftMinor, rightMinor], + [leftPatch, rightPatch], + ] as const; + + for (const [first, second] of components) { + if (first === undefined || second === undefined) { + throw new Error('Missing version component'); + } + if (first !== second) { + return first < second ? -1 : 1; + } + } + return 0; +} + +export function previousStableVersion(versions: readonly string[], current: string): string | null { + const currentIsCanary = parseReleaseVersion(current).prerelease; + const currentBase = string(current.split('-')[0], 'current version base'); + let previous: string | null = null; + + for (const version of versions) { + if (parseReleaseVersion(version).prerelease) { + continue; + } + const comparedWithCurrent = compareStableVersions(version, currentBase); + const isEligible = comparedWithCurrent < 0 || (currentIsCanary && comparedWithCurrent === 0); + if (isEligible && (previous === null || compareStableVersions(version, previous) > 0)) { + previous = version; + } + } + return previous; +} + +export function shouldPromoteGithubLatest( + kind: ReleaseKind, + version: string, + npmLatest: string | null, + publishedStableVersions: readonly string[], +): boolean { + if (kind !== 'stable' || version !== npmLatest) { + return false; + } + if (parseReleaseVersion(version).prerelease) { + throw new Error('Stable latest must be a stable version'); + } + return !publishedStableVersions.some(published => compareStableVersions(published, version) > 0); +} + +export function assertReleaseSource( + kind: ReleaseKind, + ref: string, + event: string, + repository: string, + sourceRepository: string, +): void { + if (!repository || repository !== sourceRepository || !ref.startsWith('refs/heads/')) { + throw new Error('Release must use a branch in the owner repository'); + } + const stableEvent = event === 'push' || event === 'workflow_dispatch'; + if (kind === 'stable' && (ref !== 'refs/heads/main' || !stableEvent)) { + throw new Error('Stable releases require main push or explicit main resume'); + } + if (kind === 'canary' && event !== 'workflow_dispatch') { + throw new Error('Canaries require workflow_dispatch'); + } +} + +export function validateManifest(value: unknown): ReleaseManifest { + const input = record(value, 'release manifest'); + if (input['schemaVersion'] !== 1 || input['name'] !== PACKAGE_NAME) { + throw new Error('Unsupported release manifest'); + } + + const version = string(input['version'], 'version'); + const sourceSha = string(input['sourceSha'], 'source SHA'); + const kind = input['kind']; + const tarballSha256 = string(input['tarballSha256'], 'tarball hash'); + if (!COMMIT_SHA.test(sourceSha) || !SHA256.test(tarballSha256) || (kind !== 'stable' && kind !== 'canary')) { + throw new Error('Invalid release identity'); + } + if (parseReleaseVersion(version).prerelease !== (kind === 'canary')) { + throw new Error('Release kind and version disagree'); + } + + const rawAssets = record(input['assets'], 'asset hashes'); + if (Object.keys(rawAssets).length !== releaseAssets.length) { + throw new Error('Incomplete or unexpected release assets'); + } + const assets: Record = {}; + for (const filename of releaseAssets) { + const hash = string(rawAssets[filename], `hash for ${filename}`); + if (!SHA256.test(hash)) { + throw new Error(`Invalid asset hash: ${filename}`); + } + assets[filename] = hash; + } + if (assets['package.tgz'] !== tarballSha256) { + throw new Error('Tarball hash disagrees with asset manifest'); + } + + return { schemaVersion: 1, name: PACKAGE_NAME, version, sourceSha, kind, tarballSha256, assets }; +} + +export function validateEvidenceGate( + value: unknown, + evidenceSha256: string, + acceptedSha256 = '', + acceptanceReason = '', +): string | null { + const evidence = record(value, 'benchmark evidence'); + if (evidence['schemaVersion'] !== 1 || evidence['quality'] !== 'passed') { + throw new Error('Release requires passed benchmark quality'); + } + const config = record(evidence['config'], 'benchmark config'); + if (config['lockCount'] !== REQUIRED_LOCK_COUNT) { + throw new Error(`Release requires all ${REQUIRED_LOCK_COUNT} fixtures`); + } + + const memory = record(evidence['memory'], 'memory evidence'); + const memoryPerformance = record(memory['performance'], 'memory performance'); + const memoryVerdict = string(memoryPerformance['verdict'], 'memory performance verdict'); + const timingVerdict = string(evidence['performance'], 'timing performance verdict'); + const allowedVerdicts = ['passed', 'inconclusive', 'regression']; + if (!allowedVerdicts.includes(timingVerdict) || !allowedVerdicts.includes(memoryVerdict)) { + throw new Error('Unknown performance verdict'); + } + if (timingVerdict === 'passed' && memoryVerdict === 'passed') { + return null; + } + + const exactEvidenceAccepted = SHA256.test(evidenceSha256) && acceptedSha256 === evidenceSha256; + if (!exactEvidenceAccepted || acceptanceReason.trim().length === 0) { + throw new Error('Release performance needs a passed result or explicit acceptance of this exact evidence SHA with a reason'); + } + return acceptanceReason; +} diff --git a/.github/scripts/release-network.ts b/.github/scripts/release-network.ts new file mode 100644 index 0000000..b64ded0 --- /dev/null +++ b/.github/scripts/release-network.ts @@ -0,0 +1,268 @@ +import { execFileSync } from 'node:child_process'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { setTimeout } from 'node:timers/promises'; +import { + assertDigest, + CDN_ORIGIN, + NPM_REGISTRY, + PACKAGE_NAME, + record, + releaseAssets, + runtimeFiles, + sha256, + shouldPromoteGithubLatest, + string, + type ReleaseManifest, +} from './release-lib.ts'; + +const REQUEST_TIMEOUT_MS = 30_000; +const DELIVERY_ATTEMPTS = 4; +const RETRY_DELAY_MS = 5_000; +const GITHUB_RESPONSE_LIMIT_BYTES = 16 * 1024 * 1024; +const RELEASES_PER_PAGE = 100; +const STABLE_RELEASE_TAG = /^v(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)$/; + +export async function fetchBytes(url: string): Promise { + const response = await fetch(url, { signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) }); + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${url}`); + } + return Buffer.from(await response.arrayBuffer()); +} + +export async function registryMetadata(version: string): Promise | null> { + const url = `${NPM_REGISTRY}/${PACKAGE_NAME}/${encodeURIComponent(version)}`; + const response = await fetch(url, { signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) }); + if (response.status === 404) { + return null; + } + if (!response.ok) { + throw new Error(`npm registry HTTP ${response.status}`); + } + const metadata: unknown = await response.json(); + return record(metadata, 'npm metadata'); +} + +export async function verifyRegistry(manifest: ReleaseManifest): Promise { + const metadata = await registryMetadata(manifest.version); + if (!metadata || metadata['name'] !== manifest.name || metadata['version'] !== manifest.version) { + throw new Error('Exact npm version is not available'); + } + const distribution = record(metadata['dist'], 'npm distribution'); + const tarballUrl = new URL(string(distribution['tarball'], 'npm tarball URL')); + if (tarballUrl.origin !== NPM_REGISTRY || !tarballUrl.pathname.startsWith(`/${PACKAGE_NAME}/-/`)) { + throw new Error('Unexpected npm tarball origin/path'); + } + assertDigest(await fetchBytes(tarballUrl.href), manifest.tarballSha256, 'published npm tarball'); +} + +export async function publishOrResumeNpm(directory: string, manifest: ReleaseManifest): Promise { + const existing = await registryMetadata(manifest.version); + if (existing) { + await verifyRegistry(manifest); + console.log(`Resuming identical npm ${manifest.version}; no publication was needed.`); + return; + } + + execFileSync('npm', [ + 'publish', resolve(directory, 'package.tgz'), + '--access', 'public', + '--tag', manifest.kind === 'stable' ? 'latest' : 'canary', + '--provenance', + '--ignore-scripts', + '--registry', `${NPM_REGISTRY}/`, + ], { stdio: 'inherit' }); +} + +/** Registry/CDN propagation may lag publication. Never retry publication here. */ +export async function retryDelivery(action: () => Promise): Promise { + let lastError: unknown; + for (let attempt = 1; attempt <= DELIVERY_ATTEMPTS; attempt += 1) { + try { + await action(); + return; + } catch (error) { + lastError = error; + } + if (attempt < DELIVERY_ATTEMPTS) { + await setTimeout(RETRY_DELAY_MS * attempt); + } + } + throw lastError; +} + +export async function verifyCdn(manifest: ReleaseManifest): Promise { + for (const filename of runtimeFiles) { + const url = `${CDN_ORIGIN}/npm/${manifest.name}@${manifest.version}/dist/${filename}`; + const response = await fetch(url, { + headers: { Origin: 'https://example.com' }, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + const contentType = response.headers.get('content-type') ?? ''; + const allowsCrossOrigin = response.headers.get('access-control-allow-origin') === '*'; + if (!response.ok || !/(?:java|ecma)script/i.test(contentType) || !allowsCrossOrigin) { + throw new Error(`CDN delivery headers/status rejected: ${filename}`); + } + const expectedHash = string(manifest.assets[filename], filename); + assertDigest(Buffer.from(await response.arrayBuffer()), expectedHash, `CDN ${filename}`); + } +} + +function github(args: readonly string[]): string { + return execFileSync('gh', [...args], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + maxBuffer: GITHUB_RESPONSE_LIMIT_BYTES, + }); +} + +function githubJson(args: readonly string[]): unknown { + const value: unknown = JSON.parse(github(args)); + return value; +} + +function ensureSourceTag(repository: string, tag: string, sourceSha: string): void { + const matchingRefs = githubJson(['api', `repos/${repository}/git/matching-refs/tags/${tag}`]); + if (!Array.isArray(matchingRefs)) { + throw new Error('Invalid tag refs response'); + } + const tagExists = matchingRefs.some(value => record(value, 'tag ref')['ref'] === `refs/tags/${tag}`); + if (!tagExists) { + github([ + 'api', '--method', 'POST', `repos/${repository}/git/refs`, + '-f', `ref=refs/tags/${tag}`, + '-f', `sha=${sourceSha}`, + ]); + } + + // The commit endpoint resolves lightweight and annotated tags for us. + const commit = record(githubJson(['api', `repos/${repository}/commits/refs/tags/${tag}`]), 'tag commit'); + if (commit['sha'] !== sourceSha) { + throw new Error('Existing release tag does not identify the prepared source'); + } +} + +function publishedReleases(repository: string): readonly Record[] { + const pages = githubJson([ + 'api', `repos/${repository}/releases?per_page=${RELEASES_PER_PAGE}`, + '--paginate', '--slurp', + ]); + if (!Array.isArray(pages)) { + throw new Error('Invalid release pages response'); + } + return pages.flatMap((page: unknown) => { + if (!Array.isArray(page)) { + throw new Error('Invalid releases page'); + } + return page.map(value => record(value, 'GitHub release')); + }); +} + +function uploadMissingAssets( + directory: string, + repository: string, + tag: string, + release: Record, +): void { + const assets = release['assets']; + if (!Array.isArray(assets)) { + throw new Error('Invalid GitHub assets'); + } + const existingNames = new Set(assets.map(value => record(value, 'release asset')['name'])); + const expectedFiles = [...releaseAssets, 'release-manifest.json']; + const missingFiles = expectedFiles.filter(filename => !existingNames.has(filename)); + if (missingFiles.length === 0) { + return; + } + if (release['draft'] !== true) { + throw new Error('Published release has a missing asset; refusing to mutate it'); + } + github([ + 'release', 'upload', tag, + ...missingFiles.map(filename => join(directory, filename)), + '--repo', repository, + ]); +} + +async function verifyGithubAssets(directory: string, repository: string, tag: string): Promise { + const temporary = await mkdtemp(join(tmpdir(), 'gothic-release-assets-')); + const expectedFiles = [...releaseAssets, 'release-manifest.json']; + try { + github([ + 'release', 'download', tag, + '--repo', repository, + ...expectedFiles.flatMap(filename => ['--pattern', filename]), + '--dir', temporary, + ]); + for (const filename of expectedFiles) { + const expectedBytes = await readFile(join(directory, filename)); + const downloadedBytes = await readFile(join(temporary, filename)); + assertDigest(downloadedBytes, sha256(expectedBytes), `GitHub ${filename}`); + } + } finally { + await rm(temporary, { recursive: true, force: true }); + } +} + +function stableReleaseVersions(releases: readonly Record[]): readonly string[] { + return releases + .filter(release => release['draft'] === false && release['prerelease'] === false) + .map(release => string(release['tag_name'], 'release tag')) + .filter(tag => STABLE_RELEASE_TAG.test(tag)) + .map(tag => tag.slice(1)); +} + +/** Resume partial drafts, but never overwrite existing npm versions or assets. */ +export async function completeGithubRelease( + directory: string, + manifest: ReleaseManifest, + repository: string, +): Promise { + if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository)) { + throw new Error('Invalid repository'); + } + const tag = `v${manifest.version}`; + ensureSourceTag(repository, tag, manifest.sourceSha); + + const releases = publishedReleases(repository); + const existing = releases.find(release => release['tag_name'] === tag); + if (!existing) { + github([ + 'release', 'create', tag, + '--repo', repository, + '--verify-tag', + '--target', manifest.sourceSha, + '--draft', + '--title', tag, + '--notes-file', join(directory, 'release-notes.md'), + ...(manifest.kind === 'canary' ? ['--prerelease', '--latest=false'] : []), + ]); + } + + const release = record(githubJson(['api', `repos/${repository}/releases/tags/${tag}`]), 'GitHub release'); + if (release['prerelease'] !== (manifest.kind === 'canary')) { + throw new Error('Existing release kind differs'); + } + uploadMissingAssets(directory, repository, tag, release); + await verifyGithubAssets(directory, repository, tag); + if (release['draft'] !== true) { + return; + } + + const npmMetadata = await registryMetadata('latest'); + const npmLatest = npmMetadata ? string(npmMetadata['version'], 'npm latest version') : null; + const promoteLatest = shouldPromoteGithubLatest(manifest.kind, manifest.version, npmLatest, stableReleaseVersions(releases)); + github([ + 'release', 'edit', tag, + '--repo', repository, + '--draft=false', + `--latest=${promoteLatest}`, + ]); +} + +export async function recordDeliveryStatus(directory: string, stage: string, detail: string): Promise { + const status = { stage, detail, recordedAt: new Date().toISOString() }; + await writeFile(join(directory, 'delivery-status.json'), `${JSON.stringify(status, null, 2)}\n`); +} diff --git a/.github/scripts/release.ts b/.github/scripts/release.ts new file mode 100644 index 0000000..24d0838 --- /dev/null +++ b/.github/scripts/release.ts @@ -0,0 +1,223 @@ +import { execFileSync } from 'node:child_process'; +import { appendFile, writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { parseArgs } from 'node:util'; +import { + assertReleaseSource, + canaryVersion, + record, + string, + type ReleaseKind, + type ReleaseManifest, +} from './release-lib.ts'; +import { + prepareRelease, + readJson, + verifyPreparedRelease, + type EvidenceAcceptance, +} from './release-files.ts'; +import { + completeGithubRelease, + publishOrResumeNpm, + recordDeliveryStatus, + retryDelivery, + verifyCdn, + verifyRegistry, +} from './release-network.ts'; + +const NPM_VERSION = '12.0.2'; +const GITHUB_CLI_VERSION = '2.100.0'; +const MAIN_REF = 'refs/heads/main'; +const USAGE = 'Usage: node .github/scripts/release.ts canary-version|prepare|check|publish|verify|github [--directory PATH] [--benchmark-dir PATH] [--source-sha SHA]'; + +function environmentValue(name: string): string { + return string(process.env[name], name); +} + +function workflowAcceptance(): EvidenceAcceptance { + return { + acceptedEvidenceSha256: process.env['PERFORMANCE_ACCEPTED_EVIDENCE_SHA256'] ?? '', + acceptanceReason: process.env['PERFORMANCE_ACCEPTANCE_REASON'] ?? '', + }; +} + +function releaseKind(): ReleaseKind { + const value = process.env['RELEASE_KIND'] ?? 'stable'; + if (value !== 'stable' && value !== 'canary') { + throw new Error('Invalid RELEASE_KIND'); + } + return value; +} + +function requirePublicationContext(): void { + if (process.env['NPM_PUBLICATION_READY'] !== 'true' || process.env['GITHUB_ACTIONS'] !== 'true') { + throw new Error('Publication readiness is not configured; prepare/verify are available locally'); + } + + const repository = environmentValue('GITHUB_REPOSITORY'); + const expectedWorkflow = `${repository}/.github/workflows/release.yml@${MAIN_REF}`; + if (process.env['GITHUB_REF'] !== MAIN_REF || process.env['GITHUB_WORKFLOW_REF'] !== expectedWorkflow) { + throw new Error('Privileged release code must come from the main workflow'); + } + assertReleaseSource( + releaseKind(), + environmentValue('RELEASE_SOURCE_REF'), + environmentValue('GITHUB_EVENT_NAME'), + repository, + environmentValue('RELEASE_SOURCE_REPOSITORY'), + ); +} + +function requirePreparedSource(manifest: ReleaseManifest): void { + if (manifest.kind !== releaseKind() || manifest.sourceSha !== environmentValue('RELEASE_SOURCE_SHA')) { + throw new Error('Prepared release does not match the verified workflow source'); + } +} + +function parseReleaseArguments() { + const parsed = parseArgs({ + allowPositionals: true, + tokens: true, + options: { + directory: { type: 'string' }, + 'benchmark-dir': { type: 'string' }, + 'source-sha': { type: 'string' }, + }, + }); + if (parsed.positionals.length !== 1) { + throw new Error(USAGE); + } + + const suppliedOptions = new Set(); + for (const token of parsed.tokens) { + if (token.kind !== 'option') { + continue; + } + if (!token.value) { + throw new Error(`Empty release option: --${token.name}`); + } + if (suppliedOptions.has(token.name)) { + throw new Error(`Repeated release option: --${token.name}`); + } + suppliedOptions.add(token.name); + } + return { + command: parsed.positionals[0], + directory: resolve(parsed.values.directory ?? 'artifacts/release'), + benchmarkDirectory: parsed.values['benchmark-dir'], + sourceSha: parsed.values['source-sha'], + }; +} + +async function assignCanaryVersion(): Promise { + const path = resolve('package.json'); + const metadata = record(await readJson(path), 'package metadata'); + metadata['version'] = canaryVersion( + string(metadata['version'], 'package version'), + environmentValue('RELEASE_SOURCE_SHA'), + environmentValue('GITHUB_RUN_ID'), + environmentValue('GITHUB_RUN_ATTEMPT'), + ); + await writeFile(path, `${JSON.stringify(metadata, null, 2)}\n`); + console.log(metadata['version']); +} + +async function publishPreparedRelease(directory: string): Promise { + requirePublicationContext(); + const manifest = await verifyPreparedRelease(directory, workflowAcceptance()); + requirePreparedSource(manifest); + + const npmVersion = execFileSync('npm', ['--version'], { encoding: 'utf8' }).trim(); + if (npmVersion !== NPM_VERSION) { + throw new Error(`Expected pinned npm CLI ${NPM_VERSION}`); + } + await publishOrResumeNpm(directory, manifest); +} + +async function verifyDelivery(directory: string): Promise { + const manifest = await verifyPreparedRelease(directory, workflowAcceptance()); + await retryDelivery(async () => { + await verifyRegistry(manifest); + await verifyCdn(manifest); + }); + const { verifyCdnBrowsers } = await import('./release-browser.ts'); + await verifyCdnBrowsers(manifest); + await recordDeliveryStatus( + directory, + 'registry-cdn-verified', + 'Exact npm tarball and four CDN files verified; Chromium, Firefox and WebKit passed all 45 fixtures.', + ); +} + +async function finalizeGithubRelease(directory: string): Promise { + requirePublicationContext(); + const manifest = await verifyPreparedRelease(directory, workflowAcceptance()); + requirePreparedSource(manifest); + + const githubVersion = execFileSync('gh', ['--version'], { encoding: 'utf8' }); + if (!githubVersion.startsWith(`gh version ${GITHUB_CLI_VERSION} `)) { + throw new Error(`Expected pinned GitHub CLI ${GITHUB_CLI_VERSION}`); + } + const status = record(await readJson(resolve(directory, 'delivery-status.json')), 'delivery status'); + if (status['stage'] !== 'registry-cdn-verified') { + throw new Error('GitHub publication requires completed registry/CDN/browser verification'); + } + await completeGithubRelease(directory, manifest, environmentValue('GITHUB_REPOSITORY')); + await recordDeliveryStatus(directory, 'complete', 'npm, CDN browser delivery and GitHub Release verified.'); +} + +async function runReleaseCommand(): Promise { + const { command, directory, benchmarkDirectory, sourceSha } = parseReleaseArguments(); + switch (command) { + case 'canary-version': + await assignCanaryVersion(); + return; + case 'prepare': { + const revision = sourceSha ?? execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(); + const manifest = await prepareRelease({ + root: process.cwd(), + outputDir: directory, + benchmarkDir: resolve(string(benchmarkDirectory, '--benchmark-dir')), + sourceSha: revision, + kind: releaseKind(), + ...workflowAcceptance(), + }); + console.log(JSON.stringify(manifest)); + const outputPath = process.env['GITHUB_OUTPUT']; + if (outputPath) { + await appendFile(outputPath, `version=${manifest.version}\nsource-sha=${manifest.sourceSha}\n`); + } + return; + } + case 'check': + console.log(JSON.stringify(await verifyPreparedRelease(directory, workflowAcceptance()))); + return; + case 'publish': + await publishPreparedRelease(directory); + return; + case 'verify': + await verifyDelivery(directory); + return; + case 'github': + await finalizeGithubRelease(directory); + return; + default: + throw new Error(USAGE); + } +} + +try { + await runReleaseCommand(); +} catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + const command = process.argv[2]; + if (command === 'publish' || command === 'verify' || command === 'github') { + const recovery = 'Release incomplete. Preserve the prepared artifacts and rerun failed jobs. Existing npm versions and assets are verified, never overwritten.'; + console.error(recovery); + const summaryPath = process.env['GITHUB_STEP_SUMMARY']; + if (summaryPath) { + await appendFile(summaryPath, `\n${recovery}\n`); + } + } + process.exitCode = 1; +} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..c678ca5 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,287 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + workflow_dispatch: + inputs: + kind: + description: Stable main release or an owner-branch canary + type: choice + options: [canary, stable] + default: canary + required: true + source_branch: + description: Owner branch for a canary; stable always uses main + type: string + default: main + required: true + +permissions: + contents: read + +concurrency: + group: ci-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +env: + RELEASE_KIND: ${{ github.event_name == 'workflow_dispatch' && inputs.kind || 'stable' }} + PERFORMANCE_ACCEPTED_EVIDENCE_SHA256: ${{ vars.PERFORMANCE_ACCEPTED_EVIDENCE_SHA256 }} + PERFORMANCE_ACCEPTANCE_REASON: ${{ vars.PERFORMANCE_ACCEPTANCE_REASON }} + +jobs: + verification: + name: Tests, package and 45-lock benchmark + if: github.event_name != 'workflow_dispatch' || github.ref == 'refs/heads/main' + runs-on: ubuntu-24.04 + timeout-minutes: 45 + defaults: + run: + working-directory: candidate + outputs: + source-sha: ${{ steps.source.outputs.sha }} + source-ref: ${{ steps.source.outputs.ref }} + artifact: verification-${{ github.run_id }}-${{ github.run_attempt }} + benchmark-directory: ${{ steps.report.outputs.directory }} + steps: + - name: Check out the candidate + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.head.sha || (github.event_name == 'workflow_dispatch' && inputs.kind == 'canary' && format('refs/heads/{0}', inputs.source_branch)) || github.sha }} + path: candidate + persist-credentials: false + - name: Check out main helpers for release baselines and canary versioning + if: github.event_name != 'pull_request' + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + path: workflow + persist-credentials: false + - name: Record the source before running candidate code + id: source + env: + SOURCE_REF: ${{ github.event_name == 'workflow_dispatch' && inputs.kind == 'canary' && format('refs/heads/{0}', inputs.source_branch) || 'refs/heads/main' }} + run: | + git check-ref-format "$SOURCE_REF" + echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + echo "ref=$SOURCE_REF" >> "$GITHUB_OUTPUT" + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22.23.2 + - name: Record Node 22 + id: node22 + run: echo "path=$(command -v node)" >> "$GITHUB_OUTPUT" + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 26.8.1 + - name: Record Node 26 + id: node26 + run: echo "path=$(command -v node)" >> "$GITHUB_OUTPUT" + - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 + with: + version: 12.3.4 + package_json_file: candidate/package.json + run_install: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24.19.0 + cache: pnpm + cache-dependency-path: candidate/pnpm-lock.yaml + - name: Install tools and browsers + run: | + npm install --global --ignore-scripts npm@12.0.2 + pnpm install --frozen-lockfile + pnpm exec playwright install --with-deps chromium firefox webkit + - name: Assign a canary version before building + if: env.RELEASE_KIND == 'canary' + env: + RELEASE_SOURCE_SHA: ${{ steps.source.outputs.sha }} + run: node ../workflow/.github/scripts/release.ts canary-version + - name: Check source, five bundles and one installed archive + env: + NODE22: ${{ steps.node22.outputs.path }} + NODE26: ${{ steps.node26.outputs.path }} + run: | + pnpm check + pnpm test:coverage + "$NODE26" node_modules/vitest/vitest.mjs run --project node + pnpm build + pnpm test:distribution + pnpm test:browser + pnpm test:e2e + pnpm verify:package --node "$NODE22" --node "$NODE26" + - name: Resolve the preceding stable release + if: github.event_name != 'pull_request' + id: previous + run: | + version=$(node -p "JSON.parse(require('node:fs').readFileSync('package.json','utf8')).version") + node ../workflow/.github/scripts/ci-baseline.ts release "$version" artifacts/baseline + - name: Check out the PR target or first-release reference + if: github.event_name == 'pull_request' || steps.previous.outputs.available != 'true' + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.base.sha || '3cbbeedbb409a2d8ffa5b00c778cfcb0b07c0ec6' }} + path: baseline + persist-credentials: false + - name: Build and measure the separately checked-out baseline + if: github.event_name == 'pull_request' || steps.previous.outputs.available != 'true' + id: baseline + run: | + if [ ! -f ../baseline/src/index.mjs ]; then + pnpm --dir ../baseline install --frozen-lockfile + pnpm --dir ../baseline build + fi + node .github/scripts/ci-baseline.ts checkout ../baseline artifacts/baseline + - name: Compare all 45 locks after other checks have finished + id: comparison + # Releases retain failed measurements for explicit evidence acceptance. + # The trusted publication job always enforces quality and performance gates. + continue-on-error: ${{ github.event_name != 'pull_request' }} + env: + BASELINE_MODULE: ${{ steps.previous.outputs.module || steps.baseline.outputs.module }} + BASELINE_SNAPSHOT: ${{ steps.previous.outputs.snapshot || steps.baseline.outputs.snapshot }} + BASELINE_FORMAT: ${{ steps.previous.outputs.format || steps.baseline.outputs.format }} + run: >- + pnpm benchmark:compare + --candidate-module dist/gothic-lock-solver.mjs + --baseline-module "$BASELINE_MODULE" + --baseline-snapshot "$BASELINE_SNAPSHOT" + --baseline-format "$BASELINE_FORMAT" + --output-dir artifacts/benchmark + - name: Record the complete benchmark and show its verdict + if: always() && steps.comparison.outcome != 'skipped' + id: report + run: | + reports=(artifacts/benchmark/run-*) + test "${#reports[@]}" -eq 1 + test -f "${reports[0]}/benchmark-evidence.json" + echo "directory=${reports[0]}" >> "$GITHUB_OUTPUT" + cat "${reports[0]}/benchmark.md" >> "$GITHUB_STEP_SUMMARY" + sha256sum "${reports[0]}/benchmark-evidence.json" >> "$GITHUB_STEP_SUMMARY" + - name: Preserve the tested archive, baseline and reports + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: verification-${{ github.run_id }}-${{ github.run_attempt }} + path: | + candidate/package.json + candidate/README.md + candidate/LICENSE + candidate/dist/ + candidate/artifacts/ + if-no-files-found: warn + retention-days: 30 + + npm-delivery: + name: Publish npm and verify browser delivery + needs: verification + if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request' && vars.NPM_PUBLICATION_READY == 'true' + runs-on: ubuntu-24.04 + timeout-minutes: 30 + environment: npm-production + permissions: + contents: read + id-token: write + defaults: + run: + working-directory: candidate + outputs: + artifact: delivery-${{ github.run_id }}-${{ github.run_attempt }} + env: + NPM_PUBLICATION_READY: ${{ vars.NPM_PUBLICATION_READY }} + RELEASE_SOURCE_SHA: ${{ needs.verification.outputs.source-sha }} + RELEASE_SOURCE_REF: ${{ needs.verification.outputs.source-ref }} + RELEASE_SOURCE_REPOSITORY: ${{ github.repository }} + steps: + - name: Check out trusted main publication code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + path: workflow + persist-credentials: false + - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 + with: + version: 12.3.4 + package_json_file: workflow/package.json + run_install: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24.19.0 + cache: pnpm + cache-dependency-path: workflow/pnpm-lock.yaml + - name: Install only trusted tooling + working-directory: workflow + run: | + npm install --global --ignore-scripts npm@12.0.2 + pnpm install --frozen-lockfile + pnpm exec playwright install --with-deps chromium firefox webkit + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: ${{ needs.verification.outputs.artifact }} + path: candidate + - name: Validate saved inputs using trusted main code + env: + BENCHMARK_DIRECTORY: ${{ needs.verification.outputs.benchmark-directory }} + run: >- + node ../workflow/.github/scripts/release.ts prepare + --benchmark-dir "$BENCHMARK_DIRECTORY" + --source-sha "$RELEASE_SOURCE_SHA" + - name: Publish or resume the exact tested npm archive + run: node ../workflow/.github/scripts/release.ts publish + - name: Verify npm bytes, CDN headers and actual browser imports + run: node ../workflow/.github/scripts/release.ts verify + - name: Preserve prepared assets for completion or recovery + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: delivery-${{ github.run_id }}-${{ github.run_attempt }} + path: candidate/artifacts/release/ + if-no-files-found: warn + retention-days: 30 + + github-release: + name: Complete GitHub release with verified assets + needs: [verification, npm-delivery] + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: write + env: + NPM_PUBLICATION_READY: ${{ vars.NPM_PUBLICATION_READY }} + RELEASE_SOURCE_SHA: ${{ needs.verification.outputs.source-sha }} + RELEASE_SOURCE_REF: ${{ needs.verification.outputs.source-ref }} + RELEASE_SOURCE_REPOSITORY: ${{ github.repository }} + steps: + - name: Check out trusted main release code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24.19.0 + - name: Install the exact official GitHub CLI release + env: + GH_ARCHIVE_SHA256: e4d4bb4498e8d007abe545b6568926793ace1b6447da598294a610018cb164be + run: | + curl --fail --location --proto '=https' --tlsv1.2 https://github.com/cli/cli/releases/download/v2.100.0/gh_2.100.0_linux_amd64.tar.gz -o "$RUNNER_TEMP/gh.tar.gz" + echo "$GH_ARCHIVE_SHA256 $RUNNER_TEMP/gh.tar.gz" | sha256sum --check --strict + tar -xzf "$RUNNER_TEMP/gh.tar.gz" -C "$RUNNER_TEMP" + echo "$RUNNER_TEMP/gh_2.100.0_linux_amd64/bin" >> "$GITHUB_PATH" + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: ${{ needs.npm-delivery.outputs.artifact }} + path: artifacts/release + - name: Resume missing draft assets or finalize the verified release + env: + GH_TOKEN: ${{ github.token }} + run: node .github/scripts/release.ts github + - name: Preserve the final delivery status + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: github-status-${{ github.run_id }}-${{ github.run_attempt }} + path: artifacts/release/delivery-status.json + if-no-files-found: warn + retention-days: 30 diff --git a/.gitignore b/.gitignore index 4d5b0c2..328bcac 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,10 @@ node_modules/ coverage/ +dist/ +artifacts/ +playwright-report/ +test-results/ +*.cpuprofile *.tmp project_sources/ upload/ diff --git a/.oxlintrc.json b/.oxlintrc.json new file mode 100644 index 0000000..ce2bd85 --- /dev/null +++ b/.oxlintrc.json @@ -0,0 +1,43 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": [ + "typescript", + "unicorn", + "oxc" + ], + "categories": { + "correctness": "error" + }, + "rules": { + "typescript/no-explicit-any": "error", + "typescript/no-non-null-assertion": "error", + "typescript/ban-ts-comment": [ + "error", + { + "ts-ignore": true, + "ts-nocheck": true, + "ts-expect-error": "allow-with-description" + } + ] + }, + "ignorePatterns": [ + "dist/**", + "node_modules/**", + "artifacts/**", + "tests/e2e/distribution/fixtures/**" + ], + "overrides": [ + { + "files": [ + "src/**/*.ts", + ".github/scripts/**/*.ts" + ], + "rules": { + "max-depth": [ + "error", + 3 + ] + } + } + ] +} diff --git a/AGENTS.md b/AGENTS.md index 94c21e9..8730d6f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,90 +1,70 @@ -# AGENTS.md - -Этот файл — основная точка входа для coding-agent, работающего с репозиторием. - -## Назначение проекта - -`gothic-lock-solver` вычисляет кратчайшую последовательность команд для пластинчатых замков **Gothic 1 Remake**. Текущее приложение — CLI на Node.js; вычислительное ядро подготовлено для повторного использования в будущем сайте. - -## Что прочитать перед изменениями - -1. [README.md](README.md) — пользовательский интерфейс и примеры. -2. [Спецификацию](docs/superpowers/specs/2026-08-31-gothic-lock-solver.md) — источник требований к механике. -3. [Спецификацию матричного поиска](docs/superpowers/specs/2026-09-07-matrix-search.md) — обновлённый контракт алгоритма и ресурсов. -4. [Выполненный план](docs/superpowers/plans/2026-08-31-gothic-lock-solver.md) — архитектурные решения и история реализации. -5. Тесты изменяемого модуля. - -Если README, план или код расходятся со спецификацией, приоритет имеет спецификация и подтверждённое тестами поведение. - -## Архитектура - -- `solve-lock.mjs` — исполняемая точка входа CLI. -- `src/index.mjs` — стабильный facade для CLI и будущего сайта. -- `src/lock-definition.mjs` — разбор и валидация модели замка. -- `src/state-codec.mjs` — base-7 кодирование состояний. -- `src/transition.mjs` — применение команд и генерация переходов. -- `src/solver.mjs`, `src/search-bfs.mjs` — точный BFS по действиям. -- `src/matrix-analysis.mjs`, `src/matrix-search.mjs` — точная матричная подготовка и A*. -- `src/search-limits.mjs` — отдельные ограничения вычисления. -- `benchmarks/` — 45 конфигураций, независимая симуляция и закреплённый исходный BFS. -- `src/result.mjs` — результат, метрики и консольное представление. -- `src/cli.mjs` — аргументы, файлы и коды завершения. -- `test/` — модульные, дифференциальные и интеграционные тесты. -- `.codex/skills/gothic-lock-solver/` — workflow интерактивного решения замка. - -## Неизменяемые правила модели - -- Модель: не менее 2 пластин; позиции каждой — целые числа от `1` до `7`. Ресурсные пределы решателя не являются ограничением игровой механики. -- Цель — позиция `4` у каждой пластины. -- Влево означает `+1`, вправо — `-1`. -- Матрица связей направленная; значения только `-1`, `0`, `1`, диагональ равна `0`. -- Движение выбранной пластины учитывается отдельно от матрицы. -- Связи применяются один раз и не каскадируют. -- Если хотя бы одна затронутая пластина выходит за границы, вся команда блокируется без изменения состояния. -- Одна команда может сдвигать пластину на `1..6` делений и имеет стоимость `1` для поиска. -- Главная гарантия обоих режимов — минимум действий. Матричный A* используется по умолчанию; среди равных решений он детерминирован, но не обязан повторять путь BFS. -- В режиме `bfs` сохраняется прежний порядок: меньшая пластина, `left` перед `right`, большее `steps` перед меньшим. -- Минимум разных выбранных пластин и единичных сдвигов не является общей гарантией. -- Исчерпание ресурсов обозначается `SearchLimitError`; оно не означает нерешаемость. - -Не изменяй эти правила без явного изменения спецификации и новых тестов. - -## Правила разработки - -- Node.js 20+, JavaScript ESM (`.mjs`), без сторонних runtime-зависимостей. -- Делай минимальные изменения и сохраняй существующие публичные интерфейсы. -- Не связывай вычислительное ядро с файловой системой, CLI или UI. -- Для сайта используй `solveLock` из `src/index.mjs`, а не копию алгоритма. -- Изменение поведения начинай с теста, воспроизводящего требуемый результат или дефект. -- Не добавляй `project_sources/`, `upload/`, PDF, временные файлы и секреты в Git. -- Не редактируй `.env` и не добавляй новые зависимости без явной необходимости. -- Пользовательские сообщения CLI остаются на русском; JSON-поля и значения направления остаются совместимыми с текущим API. - -## Команды - -```bash -# Полная проверка -pnpm test - -# Пример запуска -node solve-lock.mjs examples/lock.example.json - -# JSON-результат -node solve-lock.mjs examples/lock.example.json --output solution.json - -# Бенчмарк 45 замков и отчёт -pnpm benchmark --output-dir docs/benchmarks - -# Граница браузерного ESM-ядра -pnpm verify:web-core -``` - -## Критерии готовности - -- Все тесты проходят без пропусков. -- Все 45 конфигураций проверены независимой симуляцией и точным минимумом действий. -- Отчёт измеряет скорость в миллисекундах; unit tests не имеют нестабильных временных порогов. -- Новое поведение покрыто тестами. -- Пример из README соответствует фактическому выводу. -- Спецификация и публичная документация обновлены при изменении интерфейса. -- В diff нет PDF, секретов, временных файлов и несвязанных изменений. +# Agent instructions + +Read [README](README.md), [API](wiki/api.md), [configuration](wiki/configuration.md), +[mathematics](wiki/algorithm.md) and relevant tests before editing. The approved +work is in the [current plan](docs/superpowers/plans/2026-09-07-simplify-library.md). + +## Contract and ownership + +- `solveLock(state, links, config?)` returns readonly `[index, delta]` commands. +- `createSolverConfig(overrides?)` validates settings and fills frozen defaults. +- Positions 1–7, target 4, at least two plates; source-first matrix, zero diagonal, + entries −1/0/+1, direct effects without cascading. Every affected pin stays in range. +- Commands have zero-based indices and nonzero signed displacement −6…+6. + Inputs and caller configuration remain unchanged. +- Successful paths minimize grouped actions. Do not promise minimum unit shifts, + distinct plates or universal timing. `null` is proved unreachable; resource + exhaustion throws `SearchLimitError`; invalid lock/config throws `LockInputError`. +- `src/index.ts` and `src/cli.ts` are the two build entries. CLI invokes the same + public solver/factory. Shared mathematics has one implementation. +- Keep source flat under `src/`; tests in `tests/unit`, `tests/e2e` and + `tests/benchmarks`; maintained documentation in `wiki/`. `.github/scripts` + contains necessary build/release coordination. Keep only the current plan in `docs/`. + +## Engineering + +Use TDD with observed failing behavior before changes. Tests use independent +replay and an exhaustive oracle; avoid assertions that mirror private structure. +Use domain terms and clear invariant ownership. Prefer composition, small stateful +classes and plain functions over registries, extra layers or helper-file scaffolding. +Apply SOLID, DRY, KISS and YAGNI proportionately. + +Use descriptive domain names and one operation per statement. Give domain bounds, +byte sizes and sentinel values names; ordinary loop indices need no constant. +Keep the top-level search/release flow readable in order. Necessary matrix loops +remain explicit, with short bodies; do not hide them in allocation-heavy pipelines. +Oxlint limits block nesting to three levels in `src/` and `.github/scripts/`. +Review responsibilities and data flow as well as tests; passing gates alone does +not establish maintainability. + +Use pnpm and exact current stable dependencies with a frozen lockfile. Preserve +strict TypeScript, checked indexed access, exact optional properties and +`skipLibCheck: false`. No `any`, non-null assertions, `ts-ignore` or unchecked casts. +Explained `ts-expect-error` belongs only in negative type tests. Do not patch +libraries or weaken checks to conceal incompatibilities; verify official APIs. + +## Verification + +Run focused checks and `pnpm check`, then `pnpm test`. Coverage includes all shipped +source and requires 80% per metric. Verify Node.js 22/24/26, real Chromium/Firefox/ +WebKit, all five generated files, Workers and an installed archive. + +Run the [45-input benchmark](wiki/benchmarks.md) without competing heavy jobs. +Use an independently checked-out actual PR target or verified own previous release. +Candidate results cannot supply their own baseline. Preserve raw calibration and +bounded repeats; uncertainty remains inconclusive. Never change expected minima +or thresholds merely to turn a regression green. + +All local gates precede workflow edits. Independently review the final diff and +check hosted CI on the exact head. Working logs/review outputs belong in ignored +`artifacts/` and CI artifacts. Use official SHA-pinned Actions, npm/gh CLIs and free +standard runners. Keep the PR draft; merging and publication are separate actions. + +## Hygiene + +Track TypeScript source, fixed test inputs, Wiki and configuration. Do not track +JavaScript/MJS/MTS sources, copied implementations, builds, result snapshots, +benchmark reports, coverage, traces, reviewer reports, dependencies, uploaded PDFs +or credentials. Do not edit `.env` files. Do not reference other solver projects +in current code, data, docs or the PR description. Official tool documentation +and this project's own package/repository/CDN URLs remain valid. diff --git a/README.md b/README.md index eeb7afb..db437c0 100644 --- a/README.md +++ b/README.md @@ -1,231 +1,82 @@ # Gothic Lock Solver -Решатель пластинчатых замков из **Gothic 1 Remake**. По начальному состоянию и направленной матрице зависимостей программа находит кратчайшую последовательность команд для установки всех пластин в позицию `4`. - -## Возможности - -- от 2 пластин, по 7 позиций у каждой; вычислительные ресурсы ограничены отдельно; -- направленные синхронные и реверсивные зависимости; -- атомарная блокировка недопустимого хода; -- точный матричный анализ и A* с гарантией минимального количества команд; -- точный BFS для вырожденных матриц и сравнений; -- 45 каталожных конфигураций, независимые проверки и воспроизводимый бенчмарк; -- вывод в консоль или JSON-файл; -- отсутствие сторонних зависимостей. - -## Требования - -- Node.js 20 или новее. - -Устанавливать пакеты не требуется. - -## Формат входного файла - -```json -{ - "state": [1, 7, 4, 2, 6], - "links": [ - [0, 0, 0, 0, 0], - [0, 0, 0, 0, 0], - [0, 0, 0, 0, 0], - [0, 0, 0, 0, 0], - [0, 0, 0, 0, 0] - ] -} -``` - -- `state` — текущие позиции пластин от `1` до `7`; -- `links[i][j] = 1` — пластина `j` движется вместе с выбранной пластиной `i`; -- `links[i][j] = -1` — пластина `j` движется в противоположном направлении; -- `links[i][j] = 0` — зависимости нет; -- диагональ матрицы всегда равна `0`. - -Влево увеличивает позицию на `1`, вправо уменьшает её на `1`. Цель — состояние `[4, 4, ...]`. - -## Пример использования +An exact minimum-action solver for Gothic 1 Remake coupled plate locks. A +TypeScript library and standalone Node.js CLI share one mathematical core with +no runtime dependencies. One action can move a plate several positions. -```bash -node solve-lock.mjs examples/lock.example.json -``` - -Ответ в консоли: - -```text -Найдено команд: 4 -1. Пластина 1 - влево x3 -2. Пластина 2 - вправо x3 -3. Пластина 4 - влево x2 -4. Пластина 5 - вправо x2 -``` +> The first public package is being prepared. npm/CDN examples become available +> after publication; replace `VERSION` with an exact published version. -Для сохранения результата в JSON: +## Library -```bash -node solve-lock.mjs examples/lock.example.json --output solution.json +```sh +npm install gothic-lock-solver ``` -Пример `solution.json`: - -```json -{ - "status": "solved", - "initialState": [1, 7, 4, 2, 6], - "targetState": [4, 4, 4, 4, 4], - "commands": [ - { - "plate": 1, - "direction": "left", - "steps": 3 - }, - { - "plate": 2, - "direction": "right", - "steps": 3 - }, - { - "plate": 4, - "direction": "left", - "steps": 2 - }, - { - "plate": 5, - "direction": "right", - "steps": 2 - } - ], - "finalState": [4, 4, 4, 4, 4], - "metrics": { - "commands": 4, - "divisions": 10, - "plateSwitches": 3 - } -} -``` - -## Проверка - -```bash -pnpm test -``` - -Подробная [спецификация механики](docs/superpowers/specs/2026-08-31-gothic-lock-solver.md) -и [обновлённый контракт матричного поиска](docs/superpowers/specs/2026-09-07-matrix-search.md) -находятся в `docs/superpowers`. - -## Вычислительное ядро +```ts +import { solveLock, createSolverConfig } from "gothic-lock-solver"; -```js -import { solveLock, SearchLimitError } from './src/index.mjs'; - -const definition = { state: [1, 7], links: [[0, 0], [0, 0]] }; -const result = solveLock(definition); // matrix-astar по умолчанию -const comparison = solveLock(definition, { algorithm: 'bfs' }); +const state = [6, 2] as const; +const links = [[0, -1], [0, 0]] as const; +solveLock(state, links); // [[0, -2]] +solveLock(state, links, createSolverConfig({ maxVisited: 500_000 })); ``` -Одна команда сдвигает выбранную пластину на `steps` делений и стоит одно действие. -Оба алгоритма гарантируют минимум действий. Их конкретные последовательности могут -отличаться. В режиме BFS сохранён прежний порядок выбора: меньший номер пластины, -`left` перед `right`, большее `steps` перед меньшим. Матричный режим детерминирован, -но не гарантирует глобальный минимум разных пластин или единичных сдвигов среди -решений с равным количеством действий. - -Начальная матрица задаёт необходимый суммарный баланс сдвигов. A* использует его -как нижнюю оценку оставшихся действий и проверяет границы каждого перехода. -Наличие целочисленного решения уравнения само по себе не доказывает возможность -открыть замок. [Модель и доказательство эвристики](docs/algorithm.md). - -`SearchLimitError` означает, что вычисление не укладывается в заданный бюджет или -представление состояния. Это не результат `unsolvable`. CLI печатает такую ошибку -на русском и завершается с кодом `1`. Ограничения механики не следует путать -с возможностями конкретного запуска; стартовая граница будущего интерфейса 8 -не требует жёсткого ограничения модели числом 8. - -Бюджеты задаются в том же объекте `options`: `maxVisited` (2 000 000), -`maxExpanded` (1 000 000), `maxFrontier` (1 000 000), `maxDenseBytes` (64 MiB). -Последний параметр выбирает плотное или разреженное хранение BFS и не ограничивает -всю память процесса. [Подробности и сложность](docs/algorithm.md). +Namespace imports (`import * as GothicLockSolver`) are also supported. Positions +are 1–7, the goal is 4, and `links[source][target]` is −1/0/+1 with zero diagonal. +Links apply directly without cascading. Commands are `[zeroBasedIndex, signedPinDelta]`. +Inputs remain unchanged; successful results minimize grouped actions. -## Использование в браузере +`[]` means already open; `null` means proved unreachable. Invalid input/config +throws `LockInputError`; exhausted computation throws `SearchLimitError`. +Read [API](wiki/api.md), [configuration](wiki/configuration.md) and +[mathematics and complexity](wiki/algorithm.md) for precise guarantees and limits. -Ядро — обычные ES-модули без Node.js API, сети, DOM и сторонних runtime-зависимостей. -Файлы `src/` можно хранить внутри приложения и импортировать с того же origin: +## Browser and CLI ```html - -``` - -Публикация в npm, внешнее CDN и наш сервер не нужны. Для потенциально тяжёлых -входов вызывайте синхронный решатель в модульном Web Worker; рабочий пример — -[`scripts/browser/worker.mjs`](scripts/browser/worker.mjs). Передавайте ему -`{state, links}` через `postMessage`; он возвращает результат или имя и текст ошибки. - -Для страницы без ESM есть готовый файл [`dist/gothic-lock-solver.js`](dist/gothic-lock-solver.js): - -```html - + ``` -Скопируйте этот файл рядом с HTML. У принимающего проекта не появятся зависимости -или этап сборки. Файл не загружает код по сети и не требует ESM-сервера; ограничения -запуска конкретного браузера через `file://` следует проверять отдельно. -В нашем репозитории `pnpm build:browser` воспроизводит файл из текущих исходников, -а `pnpm verify:browser-bundle` проверяет, что он не устарел. Скрипт сборки поддерживает -только явно разрешённую структуру модулей этого ядра и отвергает неподдерживаемый -синтаксис импортов и экспортов. - -```bash -pnpm verify:web-core -python3 -m http.server 8000 +Browser modules import named exports from the corresponding `.mjs` URL. + +```sh +gothic-lock-solver lock.json --config solver.config.json --output solution.json +node gothic-lock-solver.cli.mjs lock.json --config solver.config.json ``` -Первая команда загружает настоящий граф ESM-зависимостей в изолированный JS-контекст -без `process`, `Buffer` и `require`. Это проверка границы зависимостей, не эмуляция -браузера. Для проверки в настоящем браузере откройте -`http://localhost:8000/test/browser-smoke.html`: страница проверяет импорт и решение -восьмипластинного замка на главном потоке и в модульном Worker. -HTTP-сервер нужен для ESM; открытие этой страницы через `file://` не поддерживается. +`lock.json` contains `state` and `links`; config JSON contains partial overrides. +Both flags are optional. CLI messages are Russian; exits are 0 solved, 2 unreachable +and 1 error. `--help` lists options. Use maintained Node.js 22, 24 or 26. -## Бенчмарк и доказательный отчёт +Vite builds `src/index.ts` and `src/cli.ts` into five self-contained choices: -```bash -pnpm benchmark --output-dir docs/benchmarks -``` +| Generated file | Consumer | +| --- | --- | +| `gothic-lock-solver.mjs` / `gothic-lock-solver.min.mjs` | Readable / minified ESM | +| `gothic-lock-solver.js` / `gothic-lock-solver.min.js` | Readable / minified classic script | +| `gothic-lock-solver.cli.mjs` | Standalone Node.js CLI | -Запуск проверяет все 45 входов, прогревает реализации, делает повторные измерения -и создаёт подкаталог `run-*` с JSON и Markdown. Без внешнего checkout сравниваются -матричный режим, текущий BFS и закреплённый исходный BFS нашего проекта. -Исторические показатели UnlockMyLoot помечаются отдельно и не выдают себя -за свежие измерения скорости. +Core browser builds use ES2022. A Worker can isolate synchronous search from the UI. -Чтобы сравнить сторонний решатель в том же JS-runtime: +## Development -```bash -git clone https://github.com/1h8s/unlockmyloot.git ../unlockmyloot -git -C ../unlockmyloot checkout eee0bf50ebcb7fffd2b47954fd76bb015854e365 -pnpm benchmark --reference ../unlockmyloot --repetitions 5 --warmups 1 --output-dir docs/benchmarks +```sh +pnpm install --frozen-lockfile +pnpm exec playwright install --with-deps chromium firefox webkit +pnpm test ``` -Проверяются закреплённый commit и хэши исходников. Чужая функция исполняется без -изменения алгоритма; её код не включён в этот репозиторий. Происхождение данных -каталога сохраняется в `benchmarks/fixtures`. - -Каждый найденный путь воспроизводится независимой симуляцией по одному делению. -Минимальность количества действий проверяется точным BFS. Отчёт показывает -действия, разные выбранные пластины, единичные сдвиги, время в **миллисекундах**, -среду и хэши. Обычные тесты не требуют «быстрее 1 мс»: скорость зависит от машины. -Для быстрой проверки самого сценария есть `--smoke`; её неполный охват явно указан. - -Пример сохранённого полного запуска: [docs/benchmarks](docs/benchmarks). -Порядок предложения необязательного режима стороннему проекту описан в -[плане вклада в UnlockMyLoot](docs/upstream-contribution.md). +Tests cover 45 fixed inputs, independent small-state oracles, configuration, +Node/browser consumers and the installed package. Coverage requires 80% each for +statements, branches, functions and lines; strict types and lint warnings are enforced. -## Лицензия +Read [development](wiki/development.md), [benchmarks](wiki/benchmarks.md), +[releases](wiki/releasing.md) and [AGENTS](AGENTS.md). Generated builds, snapshots, +reports and coverage are CI artifacts, with release evidence attached to GitHub +Releases. They are not committed to the source repository. -[Apache License 2.0](LICENSE) +Apache-2.0 — see [LICENSE](LICENSE). diff --git a/benchmarks/README.md b/benchmarks/README.md deleted file mode 100644 index 52945e2..0000000 --- a/benchmarks/README.md +++ /dev/null @@ -1,59 +0,0 @@ -# Reproducible catalog evidence - -The 45 fixtures in `fixtures/catalog.json` are fixed catalog configurations from -[UnlockMyLoot](https://github.com/1h8s/unlockmyloot/tree/eee0bf50ebcb7fffd2b47954fd76bb015854e365), -commit `eee0bf50ebcb7fffd2b47954fd76bb015854e365`. Each entry retains its name, -encoded lock code, source URL, initial state, directed links, and original -catalog/research provenance. These are factual configurations and measured paths; -no UnlockMyLoot solver source is included. `fixtures/manifest.json` records source -file hashes, the imported analysis checksums, and the fixture checksum. - -`expectedActions` is the exact grouped-command BFS minimum previously established -by the original Apache BFS and cross-checked with optimized BFS and the matrix -prototype. `baselineCommands` preserves that BFS's deterministic path. Both -public matrix search and current exact BFS must reproduce the minimum; BFS must -also preserve the path. The independent unit-click verifier in `validation.mjs` -does not use the production transitions or state encoder. - -`historicalReference` preserves the original reference path, A/U/C, switches, -and prior timing observations. These timings are marked historical and excluded -from live comparisons. A = grouped actions, U = distinct selected plates, -C = unit divisions of selected plates. A is the solver's optimization objective; -U and C are descriptive and need not be minimal. - -```bash -node scripts/benchmark.mjs --repetitions 5 --warmups 1 --output-dir docs/benchmarks -node scripts/benchmark.mjs --repetitions 5 --warmups 1 --output-dir docs/benchmarks --reference /path/to/unlockmyloot -node scripts/benchmark.mjs --smoke --repetitions 1 --warmups 0 --output-dir /tmp/lock-smoke -``` - -The default benchmarks all 45 inputs. `--smoke` selects the first two and labels -the scope `subset`; it is not evidence for the full catalog. Positive integer -repetitions and nonnegative integer warmups are required. Runtime dependencies, -network access, and a reference checkout are not required for ordinary tests. - -The optional reference requires the pinned Git HEAD, exact `index.html` hash, -and exact marked solver-block hash. The unchanged block is compiled using -`Function` in the same Node.js realm as the other solvers. Reference click -grouping is outside timing; current and original Apache BFS are timed through -their public facades, including input validation and result construction. - -Each measured and warmup solution passes independent replay, action-count, -determinism, input-mutation and metric checks. Current BFS and original BFS must -match the pinned path. Live reference output must match its pinned observed -path and A/U/C. The reference has a different optimization objective, so it need -not attain the exact minimum grouped-action count. - -Every successful invocation creates a new `run-*` directory containing -`benchmark.json` and `benchmark.md`. Both artifacts are rendered after all -correctness checks; a single directory rename publishes them together. Existing -reports are never overwritten. Failed correctness checks publish no report. -Reports contain raw timing samples, per-lock min/median/max milliseconds, paths, -aggregates, source/core/fixture hashes, revisions, runtime and CPU metadata, and -measured matrix wins/ties/losses. No unit test imposes a wall-clock threshold. -Measurements run in Node.js and do not measure actual browser or Web Worker -performance. The harness uses natural garbage collection and never forces GC. - -`runBenchmark` and `publishReport` are separate exported helpers so focused tests -can inject tiny deterministic solvers and clocks to exercise evidence failures -without timing 45 slow baseline calls. diff --git a/benchmarks/baseline/LICENSE b/benchmarks/baseline/LICENSE deleted file mode 100644 index 261eeb9..0000000 --- a/benchmarks/baseline/LICENSE +++ /dev/null @@ -1,201 +0,0 @@ - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright [yyyy] [name of copyright owner] - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. diff --git a/benchmarks/baseline/README.md b/benchmarks/baseline/README.md deleted file mode 100644 index 3d852d7..0000000 --- a/benchmarks/baseline/README.md +++ /dev/null @@ -1,12 +0,0 @@ -# Pinned Apache BFS baseline - -`src/` and `LICENSE` are byte-for-byte copies from -[Marcus-Rise/gothic-lock-solver](https://github.com/Marcus-Rise/gothic-lock-solver/tree/a87e739a22ccc4215d6d9b14fa06b0738171cb60), -commit `a87e739a22ccc4215d6d9b14fa06b0738171cb60`, under Apache License 2.0. -`manifest.json` records each original file's SHA-256. The benchmark checks these -hashes before measuring the baseline's public `solveLock` facade. The original -algorithm, validation, transition ordering, result construction, and relative -imports are unchanged. This snapshot intentionally preserves the old N <= 7 API. - -UnlockMyLoot solver code is not distributed here. Its optional comparison loads -the unchanged, hash-verified function from a separately supplied checkout. diff --git a/benchmarks/baseline/manifest.json b/benchmarks/baseline/manifest.json deleted file mode 100644 index 489be60..0000000 --- a/benchmarks/baseline/manifest.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "repository": "https://github.com/Marcus-Rise/gothic-lock-solver", - "revision": "a87e739a22ccc4215d6d9b14fa06b0738171cb60", - "license": "Apache-2.0", - "files": { - "src/index.mjs": "927fc5391c8c5f673f52c35d1fd2bd14afa5d47554a7a410dae0d8c03788c90f", - "src/lock-definition.mjs": "e87e8eae439601cb93d85e8c5d5314976574ad6c6e595fff98f419ef80623d74", - "src/result.mjs": "1a38110c34b696f62b9632cb3de27bb0e07c938cdd11b3ffcb89de8532bf08b0", - "src/solver.mjs": "ab7335383670c40ccbe2a53c6591dc67a04b7abda49d44978c9a790938f92dec", - "src/state-codec.mjs": "ce37a9ede6b26196fef7b89aa9501fe94c6a18fc4110c1cea394995b053759e4", - "src/transition.mjs": "28cf5007a8fd1db9d179bf233d995a5e88f5a6c486bca22395e2ed144cdbd4fd", - "LICENSE": "c71d239df91726fc519c6eb72d318ec65820627232b2f796219e87dcf35d0ab4" - }, - "notes": "Unmodified source snapshots; imports remain relative within this directory. No upstream UnlockMyLoot solver code is included." -} diff --git a/benchmarks/baseline/src/index.mjs b/benchmarks/baseline/src/index.mjs deleted file mode 100644 index 5cc1f05..0000000 --- a/benchmarks/baseline/src/index.mjs +++ /dev/null @@ -1,10 +0,0 @@ -import { validateLockDefinition } from './lock-definition.mjs'; -import { createSolveResult } from './result.mjs'; -import { findShortestCommands } from './solver.mjs'; - -export function solveLock(value) { - const definition = validateLockDefinition(value); - return createSolveResult(definition, findShortestCommands(definition)); -} - -export { applyCommand, generateTransitions } from './transition.mjs'; diff --git a/benchmarks/baseline/src/lock-definition.mjs b/benchmarks/baseline/src/lock-definition.mjs deleted file mode 100644 index 63a5719..0000000 --- a/benchmarks/baseline/src/lock-definition.mjs +++ /dev/null @@ -1,57 +0,0 @@ -export class LockInputError extends Error { - constructor(message, options) { - super(message, options); - this.name = 'LockInputError'; - } -} - -export function validateLockDefinition(value) { - if (value === null || typeof value !== 'object' || Array.isArray(value)) { - throw new LockInputError('Определение замка должно быть JSON-объектом.'); - } - - const { state, links } = value; - if (!Array.isArray(state) || state.length < 2 || state.length > 7) { - throw new LockInputError('state должен содержать от 2 до 7 пластин.'); - } - - state.forEach((position, index) => { - if (!Number.isInteger(position) || position < 1 || position > 7) { - throw new LockInputError(`state[${index}] должен быть целым числом от 1 до 7.`); - } - }); - - if (!Array.isArray(links) || links.length !== state.length) { - throw new LockInputError(`links должен быть матрицей ${state.length}x${state.length}.`); - } - - links.forEach((row, rowIndex) => { - if (!Array.isArray(row) || row.length !== state.length) { - throw new LockInputError(`links[${rowIndex}] должен содержать ${state.length} элементов.`); - } - row.forEach((coefficient, columnIndex) => { - if (![-1, 0, 1].includes(coefficient)) { - throw new LockInputError(`links[${rowIndex}][${columnIndex}] должен быть -1, 0 или 1.`); - } - if (rowIndex === columnIndex && coefficient !== 0) { - throw new LockInputError(`links[${rowIndex}][${columnIndex}] на диагонали должен быть 0.`); - } - }); - }); - - return { - state: [...state], - links: links.map((row) => [...row]), - }; -} - -export function parseLockDefinition(text) { - try { - return validateLockDefinition(JSON.parse(text)); - } catch (error) { - if (error instanceof LockInputError) { - throw error; - } - throw new LockInputError(`Некорректный JSON: ${error.message}`, { cause: error }); - } -} diff --git a/benchmarks/baseline/src/result.mjs b/benchmarks/baseline/src/result.mjs deleted file mode 100644 index 03fd343..0000000 --- a/benchmarks/baseline/src/result.mjs +++ /dev/null @@ -1,59 +0,0 @@ -import { createTargetState } from './state-codec.mjs'; -import { applyCommand } from './transition.mjs'; - -export function createSolveResult(definition, commands) { - const initialState = [...definition.state]; - const targetState = createTargetState(initialState.length); - - if (commands === null) { - return { status: 'unsolvable', initialState, targetState, commands: [] }; - } - - let finalState = [...initialState]; - for (const command of commands) { - const nextState = applyCommand(finalState, definition.links, command); - if (nextState === null) { - throw new Error('Решатель вернул заблокированную команду.'); - } - finalState = nextState; - } - - if (!finalState.every((position) => position === 4)) { - throw new Error('Решатель не привел замок к целевому состоянию.'); - } - - const plateSwitches = commands.reduce((count, command, index) => { - if (index === 0 || command.plate === commands[index - 1].plate) { - return count; - } - return count + 1; - }, 0); - - return { - status: 'solved', - initialState, - targetState, - commands: commands.map((command) => ({ ...command })), - finalState, - metrics: { - commands: commands.length, - divisions: commands.reduce((sum, command) => sum + command.steps, 0), - plateSwitches, - }, - }; -} - -export function formatConsoleResult(result) { - if (result.status === 'unsolvable') { - return 'Решение не найдено.'; - } - if (result.commands.length === 0) { - return 'Замок уже открыт.'; - } - - const directionNames = { left: 'влево', right: 'вправо' }; - const lines = result.commands.map((command, index) => ( - `${index + 1}. Пластина ${command.plate} - ${directionNames[command.direction]} x${command.steps}` - )); - return [`Найдено команд: ${result.commands.length}`, ...lines].join('\n'); -} diff --git a/benchmarks/baseline/src/solver.mjs b/benchmarks/baseline/src/solver.mjs deleted file mode 100644 index 50d30b5..0000000 --- a/benchmarks/baseline/src/solver.mjs +++ /dev/null @@ -1,80 +0,0 @@ -import { - createTargetState, - decodeState, - encodeState, - stateSpaceSize, -} from './state-codec.mjs'; -import { generateTransitions } from './transition.mjs'; - -const UNSEEN = -2; -const ROOT = -1; - -export function packCommand(command) { - const directionBit = command.direction === 'left' ? 0 : 1; - return (((command.plate - 1) * 2 + directionBit) * 6) + command.steps - 1; -} - -export function unpackCommand(packed) { - const steps = (packed % 6) + 1; - const plateDirection = Math.floor(packed / 6); - return { - plate: Math.floor(plateDirection / 2) + 1, - direction: plateDirection % 2 === 0 ? 'left' : 'right', - steps, - }; -} - -function reconstructCommands(startCode, targetCode, parents, actions) { - const commands = []; - let code = targetCode; - while (code !== startCode) { - commands.push(unpackCommand(actions[code])); - code = parents[code]; - } - commands.reverse(); - return commands; -} - -export function findShortestCommands({ state, links }) { - const plateCount = state.length; - const startCode = encodeState(state); - const targetCode = encodeState(createTargetState(plateCount)); - if (startCode === targetCode) { - return []; - } - - const size = stateSpaceSize(plateCount); - const parents = new Int32Array(size); - parents.fill(UNSEEN); - const actions = new Uint8Array(size); - const queue = new Int32Array(size); - - parents[startCode] = ROOT; - let head = 0; - let tail = 0; - queue[tail] = startCode; - tail += 1; - - while (head < tail) { - const currentCode = queue[head]; - head += 1; - const currentState = decodeState(currentCode, plateCount); - - for (const transition of generateTransitions(currentState, links)) { - const nextCode = encodeState(transition.state); - if (parents[nextCode] !== UNSEEN) { - continue; - } - - parents[nextCode] = currentCode; - actions[nextCode] = packCommand(transition.command); - if (nextCode === targetCode) { - return reconstructCommands(startCode, targetCode, parents, actions); - } - queue[tail] = nextCode; - tail += 1; - } - } - - return null; -} diff --git a/benchmarks/baseline/src/state-codec.mjs b/benchmarks/baseline/src/state-codec.mjs deleted file mode 100644 index ea0431c..0000000 --- a/benchmarks/baseline/src/state-codec.mjs +++ /dev/null @@ -1,25 +0,0 @@ -export function stateSpaceSize(plateCount) { - return 7 ** plateCount; -} - -export function encodeState(state) { - let code = 0; - for (const position of state) { - code = code * 7 + (position - 1); - } - return code; -} - -export function decodeState(code, plateCount) { - const state = new Array(plateCount); - let remainder = code; - for (let index = plateCount - 1; index >= 0; index -= 1) { - state[index] = (remainder % 7) + 1; - remainder = Math.floor(remainder / 7); - } - return state; -} - -export function createTargetState(plateCount) { - return Array.from({ length: plateCount }, () => 4); -} diff --git a/benchmarks/baseline/src/transition.mjs b/benchmarks/baseline/src/transition.mjs deleted file mode 100644 index 6886872..0000000 --- a/benchmarks/baseline/src/transition.mjs +++ /dev/null @@ -1,46 +0,0 @@ -const DIRECTIONS = ['left', 'right']; - -export function applyCommand(state, links, command) { - const sourceIndex = command.plate - 1; - const directionDelta = command.direction === 'left' ? 1 : -1; - const nextState = [...state]; - - for (let targetIndex = 0; targetIndex < state.length; targetIndex += 1) { - const coefficient = targetIndex === sourceIndex - ? 1 - : links[sourceIndex][targetIndex]; - - if (coefficient === 0) { - continue; - } - - const nextPosition = state[targetIndex] - + directionDelta * coefficient * command.steps; - if (nextPosition < 1 || nextPosition > 7) { - return null; - } - nextState[targetIndex] = nextPosition; - } - - return nextState; -} - -export function generateTransitions(state, links) { - const transitions = []; - for (let plate = 1; plate <= state.length; plate += 1) { - const sourcePosition = state[plate - 1]; - for (const direction of DIRECTIONS) { - const sourceLimit = direction === 'left' - ? 7 - sourcePosition - : sourcePosition - 1; - for (let steps = sourceLimit; steps >= 1; steps -= 1) { - const command = { plate, direction, steps }; - const nextState = applyCommand(state, links, command); - if (nextState !== null) { - transitions.push({ command, state: nextState }); - } - } - } - } - return transitions; -} diff --git a/benchmarks/catalog.mjs b/benchmarks/catalog.mjs deleted file mode 100644 index ca64991..0000000 --- a/benchmarks/catalog.mjs +++ /dev/null @@ -1,26 +0,0 @@ -import { createHash } from 'node:crypto'; -import { readFileSync } from 'node:fs'; - -export const catalogUrl = new URL('./fixtures/catalog.json', import.meta.url); - -export function sha256(value) { - return createHash('sha256').update(value).digest('hex'); -} - -export function loadManifest() { - return JSON.parse(readFileSync(new URL('./fixtures/manifest.json', import.meta.url), 'utf8')); -} - -export function loadCatalog() { - const bytes = readFileSync(catalogUrl); - const manifest = loadManifest(); - if (sha256(bytes) !== manifest.catalogSha256) { - throw new Error('Catalog data hash differs from its pinned manifest.'); - } - const catalog = JSON.parse(bytes.toString('utf8')); - if (!Array.isArray(catalog) || catalog.length !== manifest.count - || new Set(catalog.map((lock) => lock.id)).size !== manifest.count) { - throw new Error('Catalog must contain the 45 distinct pinned inputs.'); - } - return catalog; -} diff --git a/benchmarks/fixtures/catalog.json b/benchmarks/fixtures/catalog.json deleted file mode 100644 index 532d932..0000000 --- a/benchmarks/fixtures/catalog.json +++ /dev/null @@ -1,9390 +0,0 @@ -[ - { - "id": "ruined-tower-door", - "title": "Дверь Разрушенной башни", - "titleEn": "Ruined Tower door", - "url": "https://unlockmyloot.com/locks/ruined-tower-door/", - "code": "aUkAACUBAAJikA", - "flip": false, - "description": "Дверь у основания Разрушенной башни рядом со Старым лагерем", - "catalogMetadata": "6 плиток · 57 ходов · 10 связей", - "provenance": { - "commit": "eee0bf50ebcb7fffd2b47954fd76bb015854e365", - "catalog": "locks/index.html", - "catalog_line": 452, - "english_catalog": "en/locks/index.html", - "landing_pages": [ - "locks/ruined-tower-door/index.html", - "en/locks/ruined-tower-door/index.html" - ], - "source_notes": "locks-research.md", - "supplied_by": null, - "research_line": 11, - "research_section": "### 1. Дверь Разрушенной башни (Старый Лагерь)", - "research_provenance": [ - "- Источник: корректная комбинация от Лёши (июнь 2026), заменила прежний код", - "- СТАТУС: в каталоге. Это и есть тот замок, на котором ловили баг BFS-помощника." - ] - }, - "definition": { - "state": [ - 5, - 6, - 2, - 2, - 1, - 1 - ], - "links": [ - [ - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - -1, - 1, - 1, - 0 - ], - [ - 0, - 0, - 0, - 1, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - -1, - 1, - -1, - 0, - 0, - -1 - ], - [ - -1, - 1, - 0, - 0, - 0, - 0 - ] - ] - }, - "expectedActions": 17, - "baselineCommands": [ - { - "plate": 1, - "direction": "right", - "steps": 4 - }, - { - "plate": 3, - "direction": "left", - "steps": 5 - }, - { - "plate": 4, - "direction": "right", - "steps": 6 - }, - { - "plate": 2, - "direction": "left", - "steps": 1 - }, - { - "plate": 5, - "direction": "right", - "steps": 1 - }, - { - "plate": 2, - "direction": "left", - "steps": 1 - }, - { - "plate": 5, - "direction": "right", - "steps": 1 - }, - { - "plate": 6, - "direction": "right", - "steps": 2 - }, - { - "plate": 1, - "direction": "right", - "steps": 4 - }, - { - "plate": 2, - "direction": "left", - "steps": 3 - }, - { - "plate": 4, - "direction": "right", - "steps": 5 - }, - { - "plate": 5, - "direction": "right", - "steps": 3 - }, - { - "plate": 6, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "right", - "steps": 6 - }, - { - "plate": 2, - "direction": "left", - "steps": 6 - }, - { - "plate": 4, - "direction": "right", - "steps": 3 - }, - { - "plate": 5, - "direction": "right", - "steps": 3 - } - ], - "historicalReference": { - "kind": "cached-observation", - "capturedAt": "2026-09-07T06:02:47.149Z", - "A": 17, - "U": 6, - "C": 57, - "plateSwitches": 16, - "commands": [ - { - "plate": 3, - "direction": "left", - "steps": 5 - }, - { - "plate": 4, - "direction": "right", - "steps": 5 - }, - { - "plate": 2, - "direction": "left", - "steps": 1 - }, - { - "plate": 5, - "direction": "right", - "steps": 1 - }, - { - "plate": 2, - "direction": "left", - "steps": 1 - }, - { - "plate": 5, - "direction": "right", - "steps": 1 - }, - { - "plate": 1, - "direction": "right", - "steps": 5 - }, - { - "plate": 6, - "direction": "right", - "steps": 2 - }, - { - "plate": 2, - "direction": "left", - "steps": 3 - }, - { - "plate": 5, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "right", - "steps": 6 - }, - { - "plate": 6, - "direction": "right", - "steps": 3 - }, - { - "plate": 4, - "direction": "right", - "steps": 6 - }, - { - "plate": 2, - "direction": "left", - "steps": 6 - }, - { - "plate": 5, - "direction": "right", - "steps": 3 - }, - { - "plate": 4, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - } - ], - "historicalTimingMs": { - "min": 91.817025, - "median": 96.49636299999997, - "max": 126.86948000000007 - } - } - }, - { - "id": "scatty-chest", - "title": "Сундук Скатти", - "titleEn": "Scatty's chest", - "url": "https://unlockmyloot.com/locks/scatty-chest/", - "code": "SojAgCBDQA", - "flip": false, - "description": "Сундук Скатти с кольцом для Фингерса - квест «Кольцо для Фингерса»", - "catalogMetadata": "5 плиток · 31 ход · 6 связей", - "provenance": { - "commit": "eee0bf50ebcb7fffd2b47954fd76bb015854e365", - "catalog": "locks/index.html", - "catalog_line": 458, - "english_catalog": "en/locks/index.html", - "landing_pages": [ - "locks/scatty-chest/index.html", - "en/locks/scatty-chest/index.html" - ], - "source_notes": "locks-research.md", - "supplied_by": null, - "research_line": 25, - "research_section": "### 2. Сундук Скатти (квест Фингерса «Кольцо для Фингерса»)", - "research_provenance": [ - "- СТАТУС: в каталоге (2-я карточка)." - ] - }, - "definition": { - "state": [ - 6, - 3, - 2, - 1, - 7 - ], - "links": [ - [ - 0, - 0, - 0, - 1, - 0 - ], - [ - 0, - 0, - 0, - 0, - 1 - ], - [ - 0, - 0, - 0, - 0, - -1 - ], - [ - 0, - 1, - -1, - 0, - -1 - ], - [ - 0, - 0, - 0, - 0, - 0 - ] - ] - }, - "expectedActions": 9, - "baselineCommands": [ - { - "plate": 2, - "direction": "right", - "steps": 2 - }, - { - "plate": 3, - "direction": "left", - "steps": 4 - }, - { - "plate": 5, - "direction": "left", - "steps": 6 - }, - { - "plate": 4, - "direction": "left", - "steps": 5 - }, - { - "plate": 1, - "direction": "right", - "steps": 2 - }, - { - "plate": 5, - "direction": "left", - "steps": 5 - }, - { - "plate": 2, - "direction": "right", - "steps": 2 - }, - { - "plate": 3, - "direction": "left", - "steps": 3 - }, - { - "plate": 5, - "direction": "left", - "steps": 2 - } - ], - "historicalReference": { - "kind": "cached-observation", - "capturedAt": "2026-09-07T06:02:47.149Z", - "A": 9, - "U": 5, - "C": 31, - "plateSwitches": 8, - "commands": [ - { - "plate": 3, - "direction": "left", - "steps": 5 - }, - { - "plate": 2, - "direction": "right", - "steps": 1 - }, - { - "plate": 5, - "direction": "left", - "steps": 6 - }, - { - "plate": 4, - "direction": "left", - "steps": 5 - }, - { - "plate": 5, - "direction": "left", - "steps": 5 - }, - { - "plate": 3, - "direction": "left", - "steps": 2 - }, - { - "plate": 5, - "direction": "left", - "steps": 2 - }, - { - "plate": 2, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "right", - "steps": 2 - } - ], - "historicalTimingMs": { - "min": 9.513588000000027, - "median": 9.766299000000117, - "max": 12.794235999999955 - } - } - }, - { - "id": "cor-galom-chest", - "title": "Сундук Кор Галома", - "titleEn": "Cor Galom's chest", - "url": "https://unlockmyloot.com/locks/cor-galom-chest/", - "code": "LagFFgA", - "flip": false, - "description": "Рецепт для квеста Декстера - между лабораторными столами на первом этаже", - "catalogMetadata": "4 плитки · 15 ходов · 5 связей", - "provenance": { - "commit": "eee0bf50ebcb7fffd2b47954fd76bb015854e365", - "catalog": "locks/index.html", - "catalog_line": 464, - "english_catalog": "en/locks/index.html", - "landing_pages": [ - "locks/cor-galom-chest/index.html", - "en/locks/cor-galom-chest/index.html" - ], - "source_notes": "locks-research.md", - "supplied_by": null, - "research_line": 37, - "research_section": "### 3. Сундук Кор Галома (рецепт для квеста Декстера)", - "research_provenance": [ - "- Источник: сверено в игре (июнь 2026).", - "- СТАТУС: в каталоге (3-я карточка)." - ] - }, - "definition": { - "state": [ - 7, - 7, - 6, - 1 - ], - "links": [ - [ - 0, - 0, - 0, - 1 - ], - [ - 1, - 0, - 0, - 1 - ], - [ - 1, - -1, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0 - ] - ] - }, - "expectedActions": 5, - "baselineCommands": [ - { - "plate": 4, - "direction": "left", - "steps": 6 - }, - { - "plate": 2, - "direction": "right", - "steps": 5 - }, - { - "plate": 1, - "direction": "left", - "steps": 4 - }, - { - "plate": 3, - "direction": "right", - "steps": 2 - }, - { - "plate": 4, - "direction": "right", - "steps": 2 - } - ], - "historicalReference": { - "kind": "cached-observation", - "capturedAt": "2026-09-07T06:02:47.149Z", - "A": 5, - "U": 4, - "C": 15, - "plateSwitches": 4, - "commands": [ - { - "plate": 4, - "direction": "left", - "steps": 4 - }, - { - "plate": 2, - "direction": "right", - "steps": 4 - }, - { - "plate": 3, - "direction": "right", - "steps": 2 - }, - { - "plate": 1, - "direction": "left", - "steps": 4 - }, - { - "plate": 2, - "direction": "right", - "steps": 1 - } - ], - "historicalTimingMs": { - "min": 0.3375069999999596, - "median": 0.507082999999966, - "max": 0.6312800000000607 - } - } - }, - { - "id": "cor-galom-bedroom-chest", - "title": "Сундук в спальне Кор Галома", - "titleEn": "Cor Galom's bedroom chest", - "url": "https://unlockmyloot.com/locks/cor-galom-bedroom-chest/", - "code": "a2YMQAAIiECEAA", - "flip": false, - "description": "Спальня на втором этаже - кольцо очищения, свиток пиромантии и алхимический лут", - "catalogMetadata": "6 плиток · 60 ходов · 7 связей", - "provenance": { - "commit": "eee0bf50ebcb7fffd2b47954fd76bb015854e365", - "catalog": "locks/index.html", - "catalog_line": 470, - "english_catalog": "en/locks/index.html", - "landing_pages": [ - "locks/cor-galom-bedroom-chest/index.html", - "en/locks/cor-galom-bedroom-chest/index.html" - ], - "source_notes": "locks-research.md", - "supplied_by": null, - "research_line": 49, - "research_section": "### 3a. Сундук в спальне Кор Галома", - "research_provenance": [ - "- Источник: сверено в игре (июнь 2026).", - "- СТАТУС: в каталоге (4-я карточка)." - ] - }, - "definition": { - "state": [ - 6, - 6, - 5, - 7, - 1, - 4 - ], - "links": [ - [ - 0, - 0, - 1, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - -1, - 0, - 0, - -1, - 0 - ], - [ - -1, - 0, - 1, - 0, - 0, - 0 - ], - [ - 0, - -1, - 0, - 1, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 0 - ] - ] - }, - "expectedActions": 14, - "baselineCommands": [ - { - "plate": 1, - "direction": "left", - "steps": 1 - }, - { - "plate": 2, - "direction": "right", - "steps": 5 - }, - { - "plate": 3, - "direction": "right", - "steps": 5 - }, - { - "plate": 2, - "direction": "right", - "steps": 5 - }, - { - "plate": 5, - "direction": "right", - "steps": 5 - }, - { - "plate": 2, - "direction": "right", - "steps": 5 - }, - { - "plate": 4, - "direction": "left", - "steps": 5 - }, - { - "plate": 3, - "direction": "right", - "steps": 5 - }, - { - "plate": 1, - "direction": "left", - "steps": 4 - }, - { - "plate": 2, - "direction": "right", - "steps": 5 - }, - { - "plate": 5, - "direction": "right", - "steps": 5 - }, - { - "plate": 2, - "direction": "right", - "steps": 5 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 4, - "direction": "left", - "steps": 2 - } - ], - "historicalReference": { - "kind": "cached-observation", - "capturedAt": "2026-09-07T06:02:47.149Z", - "A": 14, - "U": 5, - "C": 60, - "plateSwitches": 13, - "commands": [ - { - "plate": 2, - "direction": "right", - "steps": 3 - }, - { - "plate": 3, - "direction": "right", - "steps": 4 - }, - { - "plate": 2, - "direction": "right", - "steps": 4 - }, - { - "plate": 5, - "direction": "right", - "steps": 4 - }, - { - "plate": 4, - "direction": "left", - "steps": 4 - }, - { - "plate": 2, - "direction": "right", - "steps": 6 - }, - { - "plate": 1, - "direction": "left", - "steps": 2 - }, - { - "plate": 3, - "direction": "right", - "steps": 6 - }, - { - "plate": 2, - "direction": "right", - "steps": 6 - }, - { - "plate": 5, - "direction": "right", - "steps": 6 - }, - { - "plate": 4, - "direction": "left", - "steps": 3 - }, - { - "plate": 2, - "direction": "right", - "steps": 6 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "left", - "steps": 3 - } - ], - "historicalTimingMs": { - "min": 30.516717999999855, - "median": 31.42274099999986, - "max": 34.788095999999996 - } - } - }, - { - "id": "swamp-camp-forge-chest", - "title": "Сундук в кузнице Болотного лагеря", - "titleEn": "Swamp Camp forge chest", - "url": "https://unlockmyloot.com/locks/swamp-camp-forge-chest/", - "code": "YQY0gBgAAGACIE", - "flip": false, - "description": "Кузница Болотного лагеря - одноручный топор на 29 урона и материалы", - "catalogMetadata": "6 плиток · 41 ход · 8 связей", - "provenance": { - "commit": "eee0bf50ebcb7fffd2b47954fd76bb015854e365", - "catalog": "locks/index.html", - "catalog_line": 476, - "english_catalog": "en/locks/index.html", - "landing_pages": [ - "locks/swamp-camp-forge-chest/index.html", - "en/locks/swamp-camp-forge-chest/index.html" - ], - "source_notes": "locks-research.md", - "supplied_by": null, - "research_line": 62, - "research_section": "### 3b. Сундук в кузнице Болотного лагеря", - "research_provenance": [ - "- Источник: сверено в игре (июнь 2026).", - "- СТАТУС: в каталоге." - ] - }, - "definition": { - "state": [ - 1, - 5, - 1, - 7, - 2, - 6 - ], - "links": [ - [ - 0, - 0, - -1, - 0, - 0, - 0 - ], - [ - 0, - 0, - 1, - -1, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 1, - 0, - -1, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - -1 - ], - [ - 0, - -1, - 0, - 0, - 1, - 0 - ] - ] - }, - "expectedActions": 11, - "baselineCommands": [ - { - "plate": 3, - "direction": "left", - "steps": 6 - }, - { - "plate": 1, - "direction": "left", - "steps": 3 - }, - { - "plate": 3, - "direction": "left", - "steps": 3 - }, - { - "plate": 4, - "direction": "right", - "steps": 5 - }, - { - "plate": 3, - "direction": "left", - "steps": 5 - }, - { - "plate": 2, - "direction": "right", - "steps": 4 - }, - { - "plate": 6, - "direction": "right", - "steps": 4 - }, - { - "plate": 2, - "direction": "right", - "steps": 1 - }, - { - "plate": 3, - "direction": "left", - "steps": 5 - }, - { - "plate": 4, - "direction": "right", - "steps": 3 - }, - { - "plate": 5, - "direction": "right", - "steps": 2 - } - ], - "historicalReference": { - "kind": "cached-observation", - "capturedAt": "2026-09-07T06:02:47.149Z", - "A": 11, - "U": 6, - "C": 41, - "plateSwitches": 10, - "commands": [ - { - "plate": 3, - "direction": "left", - "steps": 5 - }, - { - "plate": 4, - "direction": "right", - "steps": 5 - }, - { - "plate": 3, - "direction": "left", - "steps": 6 - }, - { - "plate": 2, - "direction": "right", - "steps": 3 - }, - { - "plate": 6, - "direction": "right", - "steps": 4 - }, - { - "plate": 5, - "direction": "right", - "steps": 2 - }, - { - "plate": 4, - "direction": "right", - "steps": 3 - }, - { - "plate": 3, - "direction": "left", - "steps": 6 - }, - { - "plate": 2, - "direction": "right", - "steps": 2 - }, - { - "plate": 3, - "direction": "left", - "steps": 2 - }, - { - "plate": 1, - "direction": "left", - "steps": 3 - } - ], - "historicalTimingMs": { - "min": 36.47057800000039, - "median": 37.77093500000001, - "max": 38.26945599999999 - } - } - }, - { - "id": "rice-lord-storeroom", - "title": "Склад Рисового лорда (3-й этаж)", - "titleEn": "Rice Lord's storeroom (3rd floor)", - "url": "https://unlockmyloot.com/locks/rice-lord-storeroom/", - "code": "RY2VUBQAiA", - "flip": false, - "description": "Дверь на 3-м этаже над рисовыми полями - на 2-м другой замок", - "catalogMetadata": "5 плиток · 30 ходов · 9 связей", - "provenance": { - "commit": "eee0bf50ebcb7fffd2b47954fd76bb015854e365", - "catalog": "locks/index.html", - "catalog_line": 482, - "english_catalog": "en/locks/index.html", - "landing_pages": [ - "locks/rice-lord-storeroom/index.html", - "en/locks/rice-lord-storeroom/index.html" - ], - "source_notes": "locks-research.md", - "supplied_by": null, - "research_line": 76, - "research_section": "### 4. Склад Рисового лорда (Новый лагерь, рисовые поля, 3-й этаж)", - "research_provenance": [ - "- Источник: конфигурация от Лёши (июнь 2026).", - "- СТАТУС: в каталоге (4-я карточка)." - ] - }, - "definition": { - "state": [ - 3, - 7, - 2, - 6, - 5 - ], - "links": [ - [ - 0, - -1, - -1, - -1, - -1 - ], - [ - -1, - 0, - 0, - 0, - 0 - ], - [ - -1, - -1, - 0, - 0, - 0 - ], - [ - 0, - 0, - 1, - 0, - 0 - ], - [ - 1, - 0, - 0, - 0, - 0 - ] - ] - }, - "expectedActions": 9, - "baselineCommands": [ - { - "plate": 1, - "direction": "left", - "steps": 1 - }, - { - "plate": 5, - "direction": "left", - "steps": 3 - }, - { - "plate": 3, - "direction": "left", - "steps": 4 - }, - { - "plate": 2, - "direction": "left", - "steps": 2 - }, - { - "plate": 1, - "direction": "left", - "steps": 3 - }, - { - "plate": 4, - "direction": "left", - "steps": 5 - }, - { - "plate": 5, - "direction": "left", - "steps": 3 - }, - { - "plate": 2, - "direction": "left", - "steps": 6 - }, - { - "plate": 1, - "direction": "left", - "steps": 3 - } - ], - "historicalReference": { - "kind": "cached-observation", - "capturedAt": "2026-09-07T06:02:47.149Z", - "A": 9, - "U": 5, - "C": 30, - "plateSwitches": 8, - "commands": [ - { - "plate": 1, - "direction": "left", - "steps": 1 - }, - { - "plate": 5, - "direction": "left", - "steps": 3 - }, - { - "plate": 3, - "direction": "left", - "steps": 4 - }, - { - "plate": 2, - "direction": "left", - "steps": 2 - }, - { - "plate": 1, - "direction": "left", - "steps": 3 - }, - { - "plate": 5, - "direction": "left", - "steps": 3 - }, - { - "plate": 4, - "direction": "left", - "steps": 5 - }, - { - "plate": 2, - "direction": "left", - "steps": 6 - }, - { - "plate": 1, - "direction": "left", - "steps": 3 - } - ], - "historicalTimingMs": { - "min": 4.488335000000006, - "median": 4.637408999999934, - "max": 4.859664000000066 - } - } - }, - { - "id": "gomez-chambers-door", - "title": "Дверь в покои Гомеза", - "titleEn": "Door to Gomez's chambers", - "url": "https://unlockmyloot.com/locks/gomez-chambers-door/", - "code": "YiNAIJpEEAiAAA", - "flip": false, - "description": "Дверь в покои Гомеза в замке Старого лагеря", - "catalogMetadata": "6 плиток · 28 ходов · 10 связей", - "provenance": { - "commit": "eee0bf50ebcb7fffd2b47954fd76bb015854e365", - "catalog": "locks/index.html", - "catalog_line": 488, - "english_catalog": "en/locks/index.html", - "landing_pages": [ - "locks/gomez-chambers-door/index.html", - "en/locks/gomez-chambers-door/index.html" - ], - "source_notes": "locks-research.md", - "supplied_by": null, - "research_line": 88, - "research_section": "### 5. Дверь в покои Гомеза (Старый Лагерь)", - "research_provenance": [ - "- Источник: сверено в игре (июнь 2026).", - "- СТАТУС: в каталоге." - ] - }, - "definition": { - "state": [ - 2, - 1, - 5, - 4, - 3, - 1 - ], - "links": [ - [ - 0, - 0, - 0, - -1, - 0, - 0 - ], - [ - -1, - 0, - 1, - -1, - -1, - 1 - ], - [ - 0, - 1, - 0, - 0, - 0, - 1 - ], - [ - 0, - 0, - 0, - 0, - 0, - -1 - ], - [ - 0, - -1, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 0 - ] - ] - }, - "expectedActions": 9, - "baselineCommands": [ - { - "plate": 3, - "direction": "left", - "steps": 2 - }, - { - "plate": 2, - "direction": "right", - "steps": 2 - }, - { - "plate": 3, - "direction": "left", - "steps": 2 - }, - { - "plate": 4, - "direction": "right", - "steps": 4 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - }, - { - "plate": 5, - "direction": "right", - "steps": 4 - }, - { - "plate": 6, - "direction": "right", - "steps": 4 - }, - { - "plate": 4, - "direction": "right", - "steps": 4 - }, - { - "plate": 2, - "direction": "right", - "steps": 3 - } - ], - "historicalReference": { - "kind": "cached-observation", - "capturedAt": "2026-09-07T06:02:47.149Z", - "A": 9, - "U": 6, - "C": 28, - "plateSwitches": 8, - "commands": [ - { - "plate": 3, - "direction": "left", - "steps": 2 - }, - { - "plate": 2, - "direction": "right", - "steps": 2 - }, - { - "plate": 5, - "direction": "right", - "steps": 4 - }, - { - "plate": 4, - "direction": "right", - "steps": 5 - }, - { - "plate": 2, - "direction": "right", - "steps": 3 - }, - { - "plate": 4, - "direction": "right", - "steps": 3 - }, - { - "plate": 6, - "direction": "right", - "steps": 4 - }, - { - "plate": 3, - "direction": "left", - "steps": 2 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - } - ], - "historicalTimingMs": { - "min": 29.93992900000012, - "median": 30.68829599999981, - "max": 31.623243999999886 - } - } - }, - { - "id": "gomez-throne-hall-chest", - "title": "Сундук в тронном зале у Гомеза", - "titleEn": "Chest in Gomez's throne hall", - "url": "https://unlockmyloot.com/locks/gomez-throne-hall-chest/", - "code": "IIAKWgA", - "flip": false, - "description": "Справа от Гомеза - чертёж королевского оружия, кража не считается", - "catalogMetadata": "4 плитки · 16 ходов · 6 связей", - "provenance": { - "commit": "eee0bf50ebcb7fffd2b47954fd76bb015854e365", - "catalog": "locks/index.html", - "catalog_line": 494, - "english_catalog": "en/locks/index.html", - "landing_pages": [ - "locks/gomez-throne-hall-chest/index.html", - "en/locks/gomez-throne-hall-chest/index.html" - ], - "source_notes": "locks-research.md", - "supplied_by": null, - "research_line": 229, - "research_section": "### 5s. Сундук в тронном зале у Гомеза", - "research_provenance": [ - "- Источник: сверено в игре (июнь 2026).", - "- СТАТУС: в каталоге." - ] - }, - "definition": { - "state": [ - 1, - 3, - 1, - 1 - ], - "links": [ - [ - 0, - 0, - 0, - -1 - ], - [ - -1, - 0, - 1, - 1 - ], - [ - -1, - -1, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0 - ] - ] - }, - "expectedActions": 5, - "baselineCommands": [ - { - "plate": 4, - "direction": "left", - "steps": 6 - }, - { - "plate": 1, - "direction": "left", - "steps": 6 - }, - { - "plate": 2, - "direction": "left", - "steps": 2 - }, - { - "plate": 3, - "direction": "left", - "steps": 1 - }, - { - "plate": 4, - "direction": "left", - "steps": 1 - } - ], - "historicalReference": { - "kind": "cached-observation", - "capturedAt": "2026-09-07T06:02:47.149Z", - "A": 5, - "U": 4, - "C": 16, - "plateSwitches": 4, - "commands": [ - { - "plate": 4, - "direction": "left", - "steps": 6 - }, - { - "plate": 1, - "direction": "left", - "steps": 6 - }, - { - "plate": 4, - "direction": "left", - "steps": 1 - }, - { - "plate": 3, - "direction": "left", - "steps": 1 - }, - { - "plate": 2, - "direction": "left", - "steps": 2 - } - ], - "historicalTimingMs": { - "min": 0.25226000000020576, - "median": 0.3677139999999781, - "max": 0.39675699999952485 - } - } - }, - { - "id": "old-camp-dungeon-door", - "title": "Дверь в темнице с расстрелянным призраком", - "titleEn": "Dungeon door by the executed ghost", - "url": "https://unlockmyloot.com/locks/old-camp-dungeon-door/", - "code": "YMTCAKIgIEBiAA", - "flip": false, - "description": "Вход в темницу под замком - расстрелянный призрак, квест Арлин", - "catalogMetadata": "6 плиток · 68 ходов · 10 связей", - "provenance": { - "commit": "eee0bf50ebcb7fffd2b47954fd76bb015854e365", - "catalog": "locks/index.html", - "catalog_line": 500, - "english_catalog": "en/locks/index.html", - "landing_pages": [ - "locks/old-camp-dungeon-door/index.html", - "en/locks/old-camp-dungeon-door/index.html" - ], - "source_notes": "locks-research.md", - "supplied_by": null, - "research_line": 240, - "research_section": "### 5t. Дверь в темнице с расстрелянным призраком", - "research_provenance": [ - "- Источник: сверено в игре (июнь 2026).", - "- СТАТУС: в каталоге." - ] - }, - "definition": { - "state": [ - 1, - 4, - 1, - 5, - 7, - 1 - ], - "links": [ - [ - 0, - -1, - 0, - 0, - 0, - 0 - ], - [ - -1, - 0, - -1, - 0, - -1, - 0 - ], - [ - -1, - 0, - 0, - 0, - 0, - -1 - ], - [ - 0, - 0, - 1, - 0, - 0, - 0 - ], - [ - 0, - 1, - -1, - 0, - 0, - -1 - ], - [ - 0, - 0, - 0, - 0, - 0, - 0 - ] - ] - }, - "expectedActions": 17, - "baselineCommands": [ - { - "plate": 4, - "direction": "left", - "steps": 2 - }, - { - "plate": 5, - "direction": "right", - "steps": 2 - }, - { - "plate": 2, - "direction": "right", - "steps": 1 - }, - { - "plate": 3, - "direction": "right", - "steps": 4 - }, - { - "plate": 1, - "direction": "right", - "steps": 5 - }, - { - "plate": 6, - "direction": "right", - "steps": 6 - }, - { - "plate": 5, - "direction": "right", - "steps": 5 - }, - { - "plate": 6, - "direction": "right", - "steps": 5 - }, - { - "plate": 3, - "direction": "right", - "steps": 6 - }, - { - "plate": 1, - "direction": "right", - "steps": 6 - }, - { - "plate": 2, - "direction": "right", - "steps": 6 - }, - { - "plate": 1, - "direction": "right", - "steps": 6 - }, - { - "plate": 4, - "direction": "right", - "steps": 3 - }, - { - "plate": 6, - "direction": "right", - "steps": 6 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 5, - "direction": "right", - "steps": 3 - }, - { - "plate": 6, - "direction": "right", - "steps": 3 - } - ], - "historicalReference": { - "kind": "cached-observation", - "capturedAt": "2026-09-07T06:02:47.149Z", - "A": 18, - "U": 6, - "C": 68, - "plateSwitches": 17, - "commands": [ - { - "plate": 5, - "direction": "right", - "steps": 3 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 6, - "direction": "right", - "steps": 6 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - }, - { - "plate": 2, - "direction": "right", - "steps": 3 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 6, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "right", - "steps": 6 - }, - { - "plate": 5, - "direction": "right", - "steps": 6 - }, - { - "plate": 6, - "direction": "right", - "steps": 6 - }, - { - "plate": 3, - "direction": "right", - "steps": 5 - }, - { - "plate": 6, - "direction": "right", - "steps": 5 - }, - { - "plate": 1, - "direction": "right", - "steps": 5 - }, - { - "plate": 2, - "direction": "right", - "steps": 4 - }, - { - "plate": 5, - "direction": "right", - "steps": 1 - }, - { - "plate": 4, - "direction": "right", - "steps": 1 - }, - { - "plate": 3, - "direction": "right", - "steps": 2 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - } - ], - "historicalTimingMs": { - "min": 41.57797500000015, - "median": 42.28448800000024, - "max": 46.73487599999953 - } - } - }, - { - "id": "arlin-dungeon-key", - "title": "Ключ от темницы Арлина", - "titleEn": "Arlin's dungeon key", - "url": "https://unlockmyloot.com/locks/arlin-dungeon-key/", - "code": "gBDXAECQhAAQAQAIRAA", - "flip": false, - "description": "Камера Арлина в темнице - ключ у стражника или взлом", - "catalogMetadata": "7 плиток · 25 ходов · 10 связей", - "provenance": { - "commit": "eee0bf50ebcb7fffd2b47954fd76bb015854e365", - "catalog": "locks/index.html", - "catalog_line": 506, - "english_catalog": "en/locks/index.html", - "landing_pages": [ - "locks/arlin-dungeon-key/index.html", - "en/locks/arlin-dungeon-key/index.html" - ], - "source_notes": "locks-research.md", - "supplied_by": null, - "research_line": 250, - "research_section": "### 5u. Ключ от темницы Арлина", - "research_provenance": [ - "- Источник: сверено в игре (июнь 2026).", - "- СТАТУС: в каталоге." - ] - }, - "definition": { - "state": [ - 1, - 1, - 3, - 1, - 7, - 6, - 7 - ], - "links": [ - [ - 0, - 0, - 0, - 0, - 0, - -1, - 0 - ], - [ - 0, - 0, - 1, - 0, - -1, - 0, - 1 - ], - [ - 0, - 0, - 0, - -1, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - -1, - 0, - 0 - ], - [ - 0, - 0, - 0, - -1, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 1, - 0, - 0, - 0 - ], - [ - -1, - 0, - -1, - 0, - 0, - 0, - 0 - ] - ] - }, - "expectedActions": 7, - "baselineCommands": [ - { - "plate": 3, - "direction": "right", - "steps": 2 - }, - { - "plate": 6, - "direction": "right", - "steps": 2 - }, - { - "plate": 7, - "direction": "right", - "steps": 6 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - }, - { - "plate": 3, - "direction": "right", - "steps": 6 - }, - { - "plate": 2, - "direction": "left", - "steps": 3 - }, - { - "plate": 6, - "direction": "right", - "steps": 3 - } - ], - "historicalReference": { - "kind": "cached-observation", - "capturedAt": "2026-09-07T06:02:47.149Z", - "A": 7, - "U": 5, - "C": 25, - "plateSwitches": 6, - "commands": [ - { - "plate": 3, - "direction": "right", - "steps": 2 - }, - { - "plate": 7, - "direction": "right", - "steps": 6 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 6, - "direction": "right", - "steps": 5 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 2, - "direction": "left", - "steps": 3 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - } - ], - "historicalTimingMs": { - "min": 22.995531999999912, - "median": 23.810668999999507, - "max": 25.53016800000023 - } - } - }, - { - "id": "gomez-wine-cellar", - "title": "Винный погреб Гомеза", - "titleEn": "Gomez's wine cellar", - "url": "https://unlockmyloot.com/locks/gomez-wine-cellar/", - "code": "bI02qgAAIBgEAg", - "flip": false, - "description": "Склад под Гомезом - дверь внизу вертикальной лестницы", - "catalogMetadata": "6 плиток · 31 ход · 10 связей", - "provenance": { - "commit": "eee0bf50ebcb7fffd2b47954fd76bb015854e365", - "catalog": "locks/index.html", - "catalog_line": 512, - "english_catalog": "en/locks/index.html", - "landing_pages": [ - "locks/gomez-wine-cellar/index.html", - "en/locks/gomez-wine-cellar/index.html" - ], - "source_notes": "locks-research.md", - "supplied_by": null, - "research_line": 260, - "research_section": "### 5v. Винный погреб Гомеза", - "research_provenance": [ - "- Источник: сверено в игре (июнь 2026).", - "- СТАТУС: в каталоге." - ] - }, - "definition": { - "state": [ - 7, - 3, - 2, - 6, - 2, - 6 - ], - "links": [ - [ - 0, - -1, - -1, - -1, - -1, - -1 - ], - [ - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - -1 - ], - [ - 0, - 0, - 0, - 0, - 1, - -1 - ], - [ - 0, - 0, - 0, - 1, - 0, - 0 - ], - [ - 0, - 0, - 0, - -1, - 0, - 0 - ] - ] - }, - "expectedActions": 9, - "baselineCommands": [ - { - "plate": 2, - "direction": "right", - "steps": 2 - }, - { - "plate": 3, - "direction": "left", - "steps": 5 - }, - { - "plate": 6, - "direction": "left", - "steps": 4 - }, - { - "plate": 4, - "direction": "left", - "steps": 4 - }, - { - "plate": 3, - "direction": "right", - "steps": 6 - }, - { - "plate": 5, - "direction": "right", - "steps": 5 - }, - { - "plate": 4, - "direction": "left", - "steps": 6 - }, - { - "plate": 5, - "direction": "right", - "steps": 6 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - } - ], - "historicalReference": { - "kind": "cached-observation", - "capturedAt": "2026-09-07T06:02:47.149Z", - "A": 11, - "U": 6, - "C": 31, - "plateSwitches": 10, - "commands": [ - { - "plate": 4, - "direction": "left", - "steps": 1 - }, - { - "plate": 5, - "direction": "right", - "steps": 2 - }, - { - "plate": 4, - "direction": "left", - "steps": 2 - }, - { - "plate": 6, - "direction": "left", - "steps": 4 - }, - { - "plate": 4, - "direction": "left", - "steps": 4 - }, - { - "plate": 5, - "direction": "right", - "steps": 6 - }, - { - "plate": 3, - "direction": "right", - "steps": 1 - }, - { - "plate": 4, - "direction": "left", - "steps": 3 - }, - { - "plate": 5, - "direction": "right", - "steps": 3 - }, - { - "plate": 2, - "direction": "right", - "steps": 2 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - } - ], - "historicalTimingMs": { - "min": 27.862570000000233, - "median": 28.006227000000763, - "max": 28.23831599999994 - } - } - }, - { - "id": "gomez-bedside-chest", - "title": "Сундук в ногах Гомеза", - "titleEn": "Gomez bedside chest", - "url": "https://unlockmyloot.com/locks/gomez-bedside-chest/", - "code": "SgjQQDUgCI", - "flip": false, - "description": "Сундук у кровати в ногах Гомеза, Старый Лагерь", - "catalogMetadata": "5 плиток · 68 ходов · 9 связей", - "provenance": { - "commit": "eee0bf50ebcb7fffd2b47954fd76bb015854e365", - "catalog": "locks/index.html", - "catalog_line": 518, - "english_catalog": "en/locks/index.html", - "landing_pages": [ - "locks/gomez-bedside-chest/index.html", - "en/locks/gomez-bedside-chest/index.html" - ], - "source_notes": "locks-research.md", - "supplied_by": "Влад · DTF", - "research_line": 95, - "research_section": "### 5a. Сундук в ногах Гомеза (Старый Лагерь)", - "research_provenance": [ - "- Нашёл и прислал: Влад, DTF (https://dtf.ru/id644393). На карточке кредит «нашёл: Влад · DTF».", - "- СТАТУС: в каталоге." - ] - }, - "definition": { - "state": [ - 6, - 1, - 2, - 1, - 7 - ], - "links": [ - [ - 0, - -1, - 0, - 0, - -1 - ], - [ - 0, - 0, - 0, - 0, - 1 - ], - [ - -1, - -1, - 0, - -1, - 1 - ], - [ - 0, - 0, - 0, - 0, - 0 - ], - [ - 1, - 0, - 1, - 0, - 0 - ] - ] - }, - "expectedActions": 23, - "baselineCommands": [ - { - "plate": 3, - "direction": "right", - "steps": 1 - }, - { - "plate": 1, - "direction": "right", - "steps": 1 - }, - { - "plate": 2, - "direction": "right", - "steps": 2 - }, - { - "plate": 1, - "direction": "right", - "steps": 2 - }, - { - "plate": 2, - "direction": "right", - "steps": 2 - }, - { - "plate": 5, - "direction": "left", - "steps": 1 - }, - { - "plate": 3, - "direction": "right", - "steps": 1 - }, - { - "plate": 1, - "direction": "right", - "steps": 2 - }, - { - "plate": 2, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - }, - { - "plate": 2, - "direction": "right", - "steps": 3 - }, - { - "plate": 5, - "direction": "left", - "steps": 3 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 2, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "right", - "steps": 6 - }, - { - "plate": 2, - "direction": "right", - "steps": 6 - }, - { - "plate": 4, - "direction": "right", - "steps": 5 - }, - { - "plate": 5, - "direction": "left", - "steps": 3 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 5, - "direction": "left", - "steps": 3 - }, - { - "plate": 2, - "direction": "right", - "steps": 6 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - } - ], - "historicalReference": { - "kind": "cached-observation", - "capturedAt": "2026-09-07T06:02:47.149Z", - "A": 23, - "U": 5, - "C": 68, - "plateSwitches": 22, - "commands": [ - { - "plate": 3, - "direction": "right", - "steps": 1 - }, - { - "plate": 2, - "direction": "right", - "steps": 1 - }, - { - "plate": 1, - "direction": "right", - "steps": 2 - }, - { - "plate": 2, - "direction": "right", - "steps": 2 - }, - { - "plate": 1, - "direction": "right", - "steps": 2 - }, - { - "plate": 2, - "direction": "right", - "steps": 2 - }, - { - "plate": 5, - "direction": "left", - "steps": 2 - }, - { - "plate": 3, - "direction": "right", - "steps": 2 - }, - { - "plate": 1, - "direction": "right", - "steps": 2 - }, - { - "plate": 2, - "direction": "right", - "steps": 4 - }, - { - "plate": 5, - "direction": "left", - "steps": 2 - }, - { - "plate": 1, - "direction": "right", - "steps": 2 - }, - { - "plate": 3, - "direction": "right", - "steps": 2 - }, - { - "plate": 2, - "direction": "right", - "steps": 4 - }, - { - "plate": 1, - "direction": "right", - "steps": 6 - }, - { - "plate": 2, - "direction": "right", - "steps": 6 - }, - { - "plate": 5, - "direction": "left", - "steps": 3 - }, - { - "plate": 4, - "direction": "right", - "steps": 5 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - }, - { - "plate": 5, - "direction": "left", - "steps": 3 - }, - { - "plate": 2, - "direction": "right", - "steps": 6 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - } - ], - "historicalTimingMs": { - "min": 7.661264000000301, - "median": 7.950699999999415, - "max": 8.508680999999342 - } - } - }, - { - "id": "gomez-wall-chest", - "title": "Сундук у стены в покоях Гомеза", - "titleEn": "Wall chest in Gomez's chambers", - "url": "https://unlockmyloot.com/locks/gomez-wall-chest/", - "code": "ZDAkKqACQECQAA", - "flip": false, - "description": "Сундук у стены - со стороны комнаты Ворона, рекорд каталога", - "catalogMetadata": "6 плиток · 75 ходов · 10 связей", - "provenance": { - "commit": "eee0bf50ebcb7fffd2b47954fd76bb015854e365", - "catalog": "locks/index.html", - "catalog_line": 525, - "english_catalog": "en/locks/index.html", - "landing_pages": [ - "locks/gomez-wall-chest/index.html", - "en/locks/gomez-wall-chest/index.html" - ], - "source_notes": "locks-research.md", - "supplied_by": null, - "research_line": 104, - "research_section": "### 5b. Сундук у стены в покоях Гомеза (со стороны Ворона)", - "research_provenance": [ - "- Источник: сверено в игре (июнь 2026).", - "- СТАТУС: в каталоге." - ] - }, - "definition": { - "state": [ - 3, - 1, - 7, - 1, - 2, - 2 - ], - "links": [ - [ - 0, - 0, - 0, - -1, - -1, - -1 - ], - [ - -1, - 0, - -1, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - -1, - 1, - 0 - ], - [ - 0, - 0, - 1, - 0, - 0, - 0 - ], - [ - 0, - -1, - 1, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 0 - ] - ] - }, - "expectedActions": 20, - "baselineCommands": [ - { - "plate": 1, - "direction": "right", - "steps": 2 - }, - { - "plate": 4, - "direction": "right", - "steps": 2 - }, - { - "plate": 5, - "direction": "right", - "steps": 3 - }, - { - "plate": 2, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - }, - { - "plate": 3, - "direction": "left", - "steps": 2 - }, - { - "plate": 5, - "direction": "right", - "steps": 5 - }, - { - "plate": 2, - "direction": "right", - "steps": 5 - }, - { - "plate": 6, - "direction": "right", - "steps": 6 - }, - { - "plate": 1, - "direction": "right", - "steps": 5 - }, - { - "plate": 5, - "direction": "right", - "steps": 5 - }, - { - "plate": 2, - "direction": "right", - "steps": 5 - }, - { - "plate": 4, - "direction": "right", - "steps": 4 - }, - { - "plate": 6, - "direction": "right", - "steps": 5 - }, - { - "plate": 1, - "direction": "right", - "steps": 2 - }, - { - "plate": 3, - "direction": "left", - "steps": 4 - }, - { - "plate": 5, - "direction": "right", - "steps": 6 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - }, - { - "plate": 2, - "direction": "right", - "steps": 3 - }, - { - "plate": 6, - "direction": "right", - "steps": 2 - } - ], - "historicalReference": { - "kind": "cached-observation", - "capturedAt": "2026-09-07T06:02:47.149Z", - "A": 20, - "U": 6, - "C": 75, - "plateSwitches": 19, - "commands": [ - { - "plate": 1, - "direction": "right", - "steps": 2 - }, - { - "plate": 5, - "direction": "right", - "steps": 3 - }, - { - "plate": 2, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - }, - { - "plate": 6, - "direction": "right", - "steps": 6 - }, - { - "plate": 4, - "direction": "right", - "steps": 3 - }, - { - "plate": 3, - "direction": "left", - "steps": 2 - }, - { - "plate": 5, - "direction": "right", - "steps": 5 - }, - { - "plate": 2, - "direction": "right", - "steps": 5 - }, - { - "plate": 1, - "direction": "right", - "steps": 5 - }, - { - "plate": 6, - "direction": "right", - "steps": 5 - }, - { - "plate": 5, - "direction": "right", - "steps": 5 - }, - { - "plate": 2, - "direction": "right", - "steps": 5 - }, - { - "plate": 4, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "right", - "steps": 2 - }, - { - "plate": 6, - "direction": "right", - "steps": 2 - }, - { - "plate": 3, - "direction": "left", - "steps": 4 - }, - { - "plate": 5, - "direction": "right", - "steps": 6 - }, - { - "plate": 2, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - } - ], - "historicalTimingMs": { - "min": 83.76281300000028, - "median": 85.88850400000047, - "max": 89.70766300000014 - } - } - }, - { - "id": "voran-bedside-chest", - "title": "Сундук у кровати Ворона", - "titleEn": "Voran's bedside chest", - "url": "https://unlockmyloot.com/locks/voran-bedside-chest/", - "code": "jV46BEAAIAAAJLASAAA", - "flip": false, - "description": "Первый замок на 7 плиток - ключ от башни Ворона", - "catalogMetadata": "7 плиток · 38 ходов · 10 связей", - "provenance": { - "commit": "eee0bf50ebcb7fffd2b47954fd76bb015854e365", - "catalog": "locks/index.html", - "catalog_line": 531, - "english_catalog": "en/locks/index.html", - "landing_pages": [ - "locks/voran-bedside-chest/index.html", - "en/locks/voran-bedside-chest/index.html" - ], - "source_notes": "locks-research.md", - "supplied_by": null, - "research_line": 112, - "research_section": "### 5c. Сундук у кровати Ворона", - "research_provenance": [ - "- Источник: сверено в игре (июнь 2026).", - "- СТАТУС: в каталоге." - ] - }, - "definition": { - "state": [ - 7, - 6, - 4, - 7, - 2, - 7, - 5 - ], - "links": [ - [ - 0, - 0, - 0, - -1, - 0, - -1, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 1, - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 1, - 0, - -1, - 1, - 0, - 1, - -1 - ], - [ - 0, - 0, - 0, - -1, - 1, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ] - ] - }, - "expectedActions": 9, - "baselineCommands": [ - { - "plate": 2, - "direction": "right", - "steps": 2 - }, - { - "plate": 5, - "direction": "right", - "steps": 1 - }, - { - "plate": 3, - "direction": "right", - "steps": 4 - }, - { - "plate": 1, - "direction": "left", - "steps": 5 - }, - { - "plate": 4, - "direction": "left", - "steps": 6 - }, - { - "plate": 6, - "direction": "left", - "steps": 6 - }, - { - "plate": 4, - "direction": "left", - "steps": 6 - }, - { - "plate": 7, - "direction": "right", - "steps": 5 - }, - { - "plate": 5, - "direction": "right", - "steps": 3 - } - ], - "historicalReference": { - "kind": "cached-observation", - "capturedAt": "2026-09-07T06:02:47.149Z", - "A": 9, - "U": 7, - "C": 38, - "plateSwitches": 8, - "commands": [ - { - "plate": 5, - "direction": "right", - "steps": 1 - }, - { - "plate": 7, - "direction": "right", - "steps": 5 - }, - { - "plate": 3, - "direction": "right", - "steps": 4 - }, - { - "plate": 1, - "direction": "left", - "steps": 5 - }, - { - "plate": 4, - "direction": "left", - "steps": 6 - }, - { - "plate": 6, - "direction": "left", - "steps": 6 - }, - { - "plate": 4, - "direction": "left", - "steps": 6 - }, - { - "plate": 5, - "direction": "right", - "steps": 3 - }, - { - "plate": 2, - "direction": "right", - "steps": 2 - } - ], - "historicalTimingMs": { - "min": 172.0179500000013, - "median": 178.24784999999974, - "max": 182.09039500000017 - } - } - }, - { - "id": "arto-room-door", - "title": "Дверь в комнату Арто", - "titleEn": "Door to Arto's room", - "url": "https://unlockmyloot.com/locks/arto-room-door/", - "code": "TbYwABSyBC", - "flip": false, - "description": "Комната Арто слева в покоях Гомеза - напротив Ворона", - "catalogMetadata": "5 плиток · 31 ход · 9 связей", - "provenance": { - "commit": "eee0bf50ebcb7fffd2b47954fd76bb015854e365", - "catalog": "locks/index.html", - "catalog_line": 537, - "english_catalog": "en/locks/index.html", - "landing_pages": [ - "locks/arto-room-door/index.html", - "en/locks/arto-room-door/index.html" - ], - "source_notes": "locks-research.md", - "supplied_by": null, - "research_line": 180, - "research_section": "### 5m. Дверь в комнату Арто", - "research_provenance": [ - "- Источник: сверено в игре (июнь 2026).", - "- СТАТУС: в каталоге." - ] - }, - "definition": { - "state": [ - 7, - 7, - 7, - 7, - 2 - ], - "links": [ - [ - 0, - -1, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0 - ], - [ - -1, - -1, - 0, - 1, - 1 - ], - [ - -1, - 1, - 0, - 0, - 0 - ], - [ - 0, - -1, - 0, - 1, - 0 - ] - ] - }, - "expectedActions": 7, - "baselineCommands": [ - { - "plate": 2, - "direction": "right", - "steps": 6 - }, - { - "plate": 1, - "direction": "right", - "steps": 6 - }, - { - "plate": 2, - "direction": "right", - "steps": 1 - }, - { - "plate": 4, - "direction": "right", - "steps": 5 - }, - { - "plate": 1, - "direction": "right", - "steps": 5 - }, - { - "plate": 5, - "direction": "left", - "steps": 5 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - } - ], - "historicalReference": { - "kind": "cached-observation", - "capturedAt": "2026-09-07T06:02:47.149Z", - "A": 7, - "U": 5, - "C": 31, - "plateSwitches": 6, - "commands": [ - { - "plate": 2, - "direction": "right", - "steps": 6 - }, - { - "plate": 1, - "direction": "right", - "steps": 5 - }, - { - "plate": 4, - "direction": "right", - "steps": 5 - }, - { - "plate": 1, - "direction": "right", - "steps": 6 - }, - { - "plate": 5, - "direction": "left", - "steps": 5 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 2, - "direction": "right", - "steps": 1 - } - ], - "historicalTimingMs": { - "min": 4.352972000000591, - "median": 4.386361999999281, - "max": 4.6916220000002795 - } - } - }, - { - "id": "arto-far-bed-chest", - "title": "Сундук у дальней кровати Арто", - "titleEn": "Arto's far bed chest", - "url": "https://unlockmyloot.com/locks/arto-far-bed-chest/", - "code": "ZpEUEAAAgASIAk", - "flip": false, - "description": "Дальняя кровать - кольцо защиты от оружия, экстракты", - "catalogMetadata": "6 плиток · 40 ходов · 7 связей", - "provenance": { - "commit": "eee0bf50ebcb7fffd2b47954fd76bb015854e365", - "catalog": "locks/index.html", - "catalog_line": 543, - "english_catalog": "en/locks/index.html", - "landing_pages": [ - "locks/arto-far-bed-chest/index.html", - "en/locks/arto-far-bed-chest/index.html" - ], - "source_notes": "locks-research.md", - "supplied_by": null, - "research_line": 188, - "research_section": "### 5n. Сундук у дальней кровати Арто", - "research_provenance": [ - "- Источник: сверено в игре (июнь 2026).", - "- СТАТУС: в каталоге." - ] - }, - "definition": { - "state": [ - 4, - 3, - 3, - 2, - 1, - 6 - ], - "links": [ - [ - 0, - 0, - 0, - 1, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - -1, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 1 - ], - [ - 0, - -1, - 0, - -1, - 0, - 0 - ], - [ - 0, - 0, - 0, - -1, - 1, - 0 - ] - ] - }, - "expectedActions": 11, - "baselineCommands": [ - { - "plate": 2, - "direction": "left", - "steps": 4 - }, - { - "plate": 3, - "direction": "right", - "steps": 2 - }, - { - "plate": 6, - "direction": "right", - "steps": 2 - }, - { - "plate": 4, - "direction": "left", - "steps": 3 - }, - { - "plate": 5, - "direction": "left", - "steps": 6 - }, - { - "plate": 2, - "direction": "left", - "steps": 6 - }, - { - "plate": 6, - "direction": "right", - "steps": 6 - }, - { - "plate": 5, - "direction": "left", - "steps": 6 - }, - { - "plate": 2, - "direction": "left", - "steps": 3 - }, - { - "plate": 3, - "direction": "left", - "steps": 3 - }, - { - "plate": 4, - "direction": "left", - "steps": 3 - } - ], - "historicalReference": { - "kind": "cached-observation", - "capturedAt": "2026-09-07T06:02:47.149Z", - "A": 13, - "U": 5, - "C": 40, - "plateSwitches": 12, - "commands": [ - { - "plate": 4, - "direction": "left", - "steps": 1 - }, - { - "plate": 5, - "direction": "left", - "steps": 2 - }, - { - "plate": 6, - "direction": "right", - "steps": 2 - }, - { - "plate": 4, - "direction": "left", - "steps": 2 - }, - { - "plate": 2, - "direction": "left", - "steps": 4 - }, - { - "plate": 5, - "direction": "left", - "steps": 4 - }, - { - "plate": 6, - "direction": "right", - "steps": 3 - }, - { - "plate": 4, - "direction": "left", - "steps": 3 - }, - { - "plate": 3, - "direction": "left", - "steps": 1 - }, - { - "plate": 2, - "direction": "left", - "steps": 6 - }, - { - "plate": 5, - "direction": "left", - "steps": 6 - }, - { - "plate": 6, - "direction": "right", - "steps": 3 - }, - { - "plate": 2, - "direction": "left", - "steps": 3 - } - ], - "historicalTimingMs": { - "min": 25.627033000000665, - "median": 26.17454699999871, - "max": 26.764604999998483 - } - } - }, - { - "id": "arto-near-bed-chest", - "title": "Сундук у ближней кровати Арто", - "titleEn": "Arto's near bed chest", - "url": "https://unlockmyloot.com/locks/arto-near-bed-chest/", - "code": "Y2WYAEGAAIGEAY", - "flip": false, - "description": "Ближняя кровать - кольцо мощи, руда, 52 хода", - "catalogMetadata": "6 плиток · 52 хода · 9 связей", - "provenance": { - "commit": "eee0bf50ebcb7fffd2b47954fd76bb015854e365", - "catalog": "locks/index.html", - "catalog_line": 549, - "english_catalog": "en/locks/index.html", - "landing_pages": [ - "locks/arto-near-bed-chest/index.html", - "en/locks/arto-near-bed-chest/index.html" - ], - "source_notes": "locks-research.md", - "supplied_by": null, - "research_line": 196, - "research_section": "### 5o. Сундук у ближней кровати Арто", - "research_provenance": [ - "- Источник: сверено в игре (июнь 2026).", - "- СТАТУС: в каталоге." - ] - }, - "definition": { - "state": [ - 2, - 6, - 5, - 6, - 5, - 7 - ], - "links": [ - [ - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 1, - 0, - 0, - 0, - 1, - -1 - ], - [ - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - -1, - 0, - 0, - 0 - ], - [ - 1, - -1, - 0, - 1, - 0, - 0 - ], - [ - 0, - 0, - 0, - 1, - -1, - 0 - ] - ] - }, - "expectedActions": 13, - "baselineCommands": [ - { - "plate": 5, - "direction": "right", - "steps": 1 - }, - { - "plate": 1, - "direction": "left", - "steps": 6 - }, - { - "plate": 4, - "direction": "left", - "steps": 2 - }, - { - "plate": 3, - "direction": "left", - "steps": 4 - }, - { - "plate": 6, - "direction": "right", - "steps": 3 - }, - { - "plate": 2, - "direction": "right", - "steps": 3 - }, - { - "plate": 5, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "left", - "steps": 6 - }, - { - "plate": 4, - "direction": "left", - "steps": 6 - }, - { - "plate": 3, - "direction": "left", - "steps": 6 - }, - { - "plate": 6, - "direction": "right", - "steps": 6 - }, - { - "plate": 2, - "direction": "right", - "steps": 3 - }, - { - "plate": 4, - "direction": "left", - "steps": 3 - } - ], - "historicalReference": { - "kind": "cached-observation", - "capturedAt": "2026-09-07T06:02:47.149Z", - "A": 13, - "U": 6, - "C": 52, - "plateSwitches": 12, - "commands": [ - { - "plate": 5, - "direction": "right", - "steps": 1 - }, - { - "plate": 6, - "direction": "right", - "steps": 3 - }, - { - "plate": 4, - "direction": "left", - "steps": 4 - }, - { - "plate": 1, - "direction": "left", - "steps": 6 - }, - { - "plate": 2, - "direction": "right", - "steps": 3 - }, - { - "plate": 5, - "direction": "right", - "steps": 3 - }, - { - "plate": 3, - "direction": "left", - "steps": 4 - }, - { - "plate": 4, - "direction": "left", - "steps": 4 - }, - { - "plate": 6, - "direction": "right", - "steps": 6 - }, - { - "plate": 3, - "direction": "left", - "steps": 6 - }, - { - "plate": 4, - "direction": "left", - "steps": 3 - }, - { - "plate": 1, - "direction": "left", - "steps": 6 - }, - { - "plate": 2, - "direction": "right", - "steps": 3 - } - ], - "historicalTimingMs": { - "min": 71.42761900000005, - "median": 72.47732999999971, - "max": 73.87815799999953 - } - } - }, - { - "id": "gomez-bedrooms-door", - "title": "Дверь на этаже спален покоев", - "titleEn": "Door on Gomez's bedrooms floor", - "url": "https://unlockmyloot.com/locks/gomez-bedrooms-door/", - "code": "YHayiAREIAIABE", - "flip": false, - "description": "Первая дверь на этаже спален - комната с двумя сундуками", - "catalogMetadata": "6 плиток · 45 ходов · 10 связей", - "provenance": { - "commit": "eee0bf50ebcb7fffd2b47954fd76bb015854e365", - "catalog": "locks/index.html", - "catalog_line": 555, - "english_catalog": "en/locks/index.html", - "landing_pages": [ - "locks/gomez-bedrooms-door/index.html", - "en/locks/gomez-bedrooms-door/index.html" - ], - "source_notes": "locks-research.md", - "supplied_by": null, - "research_line": 17, - "research_section": "### 1. Дверь Разрушенной башни (Старый Лагерь)", - "research_provenance": [ - "- Источник: корректная комбинация от Лёши (июнь 2026), заменила прежний код", - "- СТАТУС: в каталоге. Это и есть тот замок, на котором ловили баг BFS-помощника." - ] - }, - "definition": { - "state": [ - 1, - 2, - 7, - 7, - 6, - 5 - ], - "links": [ - [ - 0, - -1, - -1, - 0, - -1, - 0 - ], - [ - 0, - 0, - 0, - 1, - 0, - 1 - ], - [ - 0, - 1, - 0, - 0, - 0, - -1 - ], - [ - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - -1, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 1, - 0, - 1, - 0 - ] - ] - }, - "expectedActions": 14, - "baselineCommands": [ - { - "plate": 1, - "direction": "left", - "steps": 1 - }, - { - "plate": 3, - "direction": "left", - "steps": 1 - }, - { - "plate": 4, - "direction": "right", - "steps": 6 - }, - { - "plate": 2, - "direction": "left", - "steps": 3 - }, - { - "plate": 1, - "direction": "left", - "steps": 4 - }, - { - "plate": 3, - "direction": "left", - "steps": 4 - }, - { - "plate": 4, - "direction": "right", - "steps": 3 - }, - { - "plate": 5, - "direction": "left", - "steps": 5 - }, - { - "plate": 1, - "direction": "left", - "steps": 4 - }, - { - "plate": 2, - "direction": "left", - "steps": 3 - }, - { - "plate": 3, - "direction": "left", - "steps": 3 - }, - { - "plate": 5, - "direction": "left", - "steps": 4 - }, - { - "plate": 1, - "direction": "left", - "steps": 3 - }, - { - "plate": 6, - "direction": "left", - "steps": 1 - } - ], - "historicalReference": { - "kind": "cached-observation", - "capturedAt": "2026-09-07T06:02:47.149Z", - "A": 14, - "U": 6, - "C": 45, - "plateSwitches": 13, - "commands": [ - { - "plate": 4, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "left", - "steps": 1 - }, - { - "plate": 3, - "direction": "left", - "steps": 1 - }, - { - "plate": 2, - "direction": "left", - "steps": 3 - }, - { - "plate": 1, - "direction": "left", - "steps": 4 - }, - { - "plate": 5, - "direction": "left", - "steps": 4 - }, - { - "plate": 3, - "direction": "left", - "steps": 4 - }, - { - "plate": 1, - "direction": "left", - "steps": 4 - }, - { - "plate": 6, - "direction": "left", - "steps": 1 - }, - { - "plate": 5, - "direction": "left", - "steps": 5 - }, - { - "plate": 4, - "direction": "right", - "steps": 6 - }, - { - "plate": 3, - "direction": "left", - "steps": 3 - }, - { - "plate": 2, - "direction": "left", - "steps": 3 - }, - { - "plate": 1, - "direction": "left", - "steps": 3 - } - ], - "historicalTimingMs": { - "min": 36.83029600000009, - "median": 37.16563900000074, - "max": 42.04815899999994 - } - } - }, - { - "id": "gomez-bedrooms-far-chest", - "title": "Сундук у дальней кровати (этаж спален)", - "titleEn": "Far bed chest (bedrooms floor)", - "url": "https://unlockmyloot.com/locks/gomez-bedrooms-far-chest/", - "code": "RzbBFITEQQ", - "flip": false, - "description": "Дальняя кровать - кольцо рудной кожи, экстракты, алкоголь", - "catalogMetadata": "5 плиток · 39 ходов · 10 связей", - "provenance": { - "commit": "eee0bf50ebcb7fffd2b47954fd76bb015854e365", - "catalog": "locks/index.html", - "catalog_line": 561, - "english_catalog": "en/locks/index.html", - "landing_pages": [ - "locks/gomez-bedrooms-far-chest/index.html", - "en/locks/gomez-bedrooms-far-chest/index.html" - ], - "source_notes": "locks-research.md", - "supplied_by": null, - "research_line": 213, - "research_section": "### 5q. Сундук у дальней кровати (этаж спален)", - "research_provenance": [ - "- Источник: сверено в игре (июнь 2026).", - "- СТАТУС: в каталоге." - ] - }, - "definition": { - "state": [ - 4, - 5, - 7, - 7, - 7 - ], - "links": [ - [ - 0, - 0, - 0, - -1, - 0 - ], - [ - -1, - 0, - -1, - 1, - 0 - ], - [ - 0, - -1, - 0, - 1, - -1 - ], - [ - 0, - -1, - 0, - 0, - -1 - ], - [ - 0, - 0, - -1, - 0, - 0 - ] - ] - }, - "expectedActions": 14, - "baselineCommands": [ - { - "plate": 1, - "direction": "left", - "steps": 3 - }, - { - "plate": 4, - "direction": "left", - "steps": 3 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - }, - { - "plate": 5, - "direction": "right", - "steps": 3 - }, - { - "plate": 3, - "direction": "right", - "steps": 2 - }, - { - "plate": 2, - "direction": "right", - "steps": 2 - }, - { - "plate": 4, - "direction": "left", - "steps": 4 - }, - { - "plate": 3, - "direction": "right", - "steps": 5 - }, - { - "plate": 1, - "direction": "right", - "steps": 4 - }, - { - "plate": 2, - "direction": "right", - "steps": 2 - }, - { - "plate": 4, - "direction": "left", - "steps": 3 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 5, - "direction": "right", - "steps": 3 - } - ], - "historicalReference": { - "kind": "cached-observation", - "capturedAt": "2026-09-07T06:02:47.149Z", - "A": 16, - "U": 5, - "C": 39, - "plateSwitches": 15, - "commands": [ - { - "plate": 1, - "direction": "left", - "steps": 1 - }, - { - "plate": 4, - "direction": "left", - "steps": 1 - }, - { - "plate": 3, - "direction": "right", - "steps": 1 - }, - { - "plate": 4, - "direction": "left", - "steps": 1 - }, - { - "plate": 3, - "direction": "right", - "steps": 1 - }, - { - "plate": 5, - "direction": "right", - "steps": 2 - }, - { - "plate": 3, - "direction": "right", - "steps": 2 - }, - { - "plate": 2, - "direction": "right", - "steps": 2 - }, - { - "plate": 4, - "direction": "left", - "steps": 4 - }, - { - "plate": 3, - "direction": "right", - "steps": 4 - }, - { - "plate": 4, - "direction": "left", - "steps": 4 - }, - { - "plate": 3, - "direction": "right", - "steps": 2 - }, - { - "plate": 5, - "direction": "right", - "steps": 4 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "right", - "steps": 5 - }, - { - "plate": 2, - "direction": "right", - "steps": 2 - } - ], - "historicalTimingMs": { - "min": 5.34081900000092, - "median": 5.4789160000000265, - "max": 5.777416000000812 - } - } - }, - { - "id": "gomez-bedrooms-near-chest", - "title": "Сундук у ближней кровати (этаж спален)", - "titleEn": "Near bed chest (bedrooms floor)", - "url": "https://unlockmyloot.com/locks/gomez-bedrooms-near-chest/", - "code": "ZVbYAEKEBgECAA", - "flip": false, - "description": "Ближняя кровать - кольцо великой жизненной силы, 56 ходов", - "catalogMetadata": "6 плиток · 56 ходов · 8 связей", - "provenance": { - "commit": "eee0bf50ebcb7fffd2b47954fd76bb015854e365", - "catalog": "locks/index.html", - "catalog_line": 567, - "english_catalog": "en/locks/index.html", - "landing_pages": [ - "locks/gomez-bedrooms-near-chest/index.html", - "en/locks/gomez-bedrooms-near-chest/index.html" - ], - "source_notes": "locks-research.md", - "supplied_by": null, - "research_line": 221, - "research_section": "### 5r. Сундук у ближней кровати (этаж спален)", - "research_provenance": [ - "- Источник: сверено в игре (июнь 2026).", - "- СТАТУС: в каталоге." - ] - }, - "definition": { - "state": [ - 3, - 6, - 3, - 7, - 7, - 7 - ], - "links": [ - [ - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 1, - 0, - 0, - 0, - -1, - -1 - ], - [ - 0, - 1, - 0, - 0, - 0, - 0 - ], - [ - 1, - -1, - 0, - 0, - 0, - 0 - ], - [ - 1, - 0, - 0, - 0, - 0, - -1 - ], - [ - 0, - 0, - 0, - 0, - 0, - 0 - ] - ] - }, - "expectedActions": 12, - "baselineCommands": [ - { - "plate": 1, - "direction": "left", - "steps": 4 - }, - { - "plate": 3, - "direction": "left", - "steps": 1 - }, - { - "plate": 6, - "direction": "right", - "steps": 6 - }, - { - "plate": 5, - "direction": "right", - "steps": 6 - }, - { - "plate": 1, - "direction": "left", - "steps": 6 - }, - { - "plate": 6, - "direction": "right", - "steps": 6 - }, - { - "plate": 2, - "direction": "right", - "steps": 6 - }, - { - "plate": 1, - "direction": "left", - "steps": 6 - }, - { - "plate": 4, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "left", - "steps": 3 - }, - { - "plate": 6, - "direction": "right", - "steps": 6 - }, - { - "plate": 5, - "direction": "right", - "steps": 3 - } - ], - "historicalReference": { - "kind": "cached-observation", - "capturedAt": "2026-09-07T06:02:47.149Z", - "A": 12, - "U": 6, - "C": 56, - "plateSwitches": 11, - "commands": [ - { - "plate": 6, - "direction": "right", - "steps": 6 - }, - { - "plate": 1, - "direction": "left", - "steps": 4 - }, - { - "plate": 5, - "direction": "right", - "steps": 6 - }, - { - "plate": 6, - "direction": "right", - "steps": 6 - }, - { - "plate": 3, - "direction": "left", - "steps": 1 - }, - { - "plate": 1, - "direction": "left", - "steps": 6 - }, - { - "plate": 2, - "direction": "right", - "steps": 6 - }, - { - "plate": 6, - "direction": "right", - "steps": 6 - }, - { - "plate": 1, - "direction": "left", - "steps": 6 - }, - { - "plate": 5, - "direction": "right", - "steps": 3 - }, - { - "plate": 4, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "left", - "steps": 3 - } - ], - "historicalTimingMs": { - "min": 40.47176899999977, - "median": 40.801266000000396, - "max": 43.21825400000125 - } - } - }, - { - "id": "raven-tower-left-chest", - "title": "Левый сундук в башне Ворона", - "titleEn": "Raven Tower left chest", - "url": "https://unlockmyloot.com/locks/raven-tower-left-chest/", - "code": "bVyMKQAAGQAAWA", - "flip": false, - "description": "1-й этаж башни, слева у входа - 2 из 9 сундуков в каталоге", - "catalogMetadata": "6 плиток · 35 ходов · 9 связей", - "provenance": { - "commit": "eee0bf50ebcb7fffd2b47954fd76bb015854e365", - "catalog": "locks/index.html", - "catalog_line": 573, - "english_catalog": "en/locks/index.html", - "landing_pages": [ - "locks/raven-tower-left-chest/index.html", - "en/locks/raven-tower-left-chest/index.html" - ], - "source_notes": "locks-research.md", - "supplied_by": null, - "research_line": 121, - "research_section": "### 5d. Левый сундук в башне Ворона (1-й этаж)", - "research_provenance": [ - "- Источник: сверено в игре (июнь 2026).", - "- СТАТУС: в каталоге." - ] - }, - "definition": { - "state": [ - 7, - 6, - 4, - 5, - 5, - 4 - ], - "links": [ - [ - 0, - 0, - 0, - -1, - -1, - 1 - ], - [ - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 1 - ], - [ - -1, - 1, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 1, - 1, - -1, - 0, - 0, - 0 - ] - ] - }, - "expectedActions": 11, - "baselineCommands": [ - { - "plate": 2, - "direction": "right", - "steps": 5 - }, - { - "plate": 3, - "direction": "right", - "steps": 1 - }, - { - "plate": 4, - "direction": "left", - "steps": 2 - }, - { - "plate": 1, - "direction": "left", - "steps": 2 - }, - { - "plate": 4, - "direction": "left", - "steps": 2 - }, - { - "plate": 5, - "direction": "left", - "steps": 4 - }, - { - "plate": 6, - "direction": "right", - "steps": 4 - }, - { - "plate": 1, - "direction": "left", - "steps": 6 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 4, - "direction": "left", - "steps": 3 - }, - { - "plate": 5, - "direction": "left", - "steps": 3 - } - ], - "historicalReference": { - "kind": "cached-observation", - "capturedAt": "2026-09-07T06:02:47.149Z", - "A": 11, - "U": 6, - "C": 35, - "plateSwitches": 10, - "commands": [ - { - "plate": 6, - "direction": "right", - "steps": 3 - }, - { - "plate": 5, - "direction": "left", - "steps": 1 - }, - { - "plate": 4, - "direction": "left", - "steps": 2 - }, - { - "plate": 1, - "direction": "left", - "steps": 5 - }, - { - "plate": 3, - "direction": "right", - "steps": 4 - }, - { - "plate": 6, - "direction": "right", - "steps": 1 - }, - { - "plate": 5, - "direction": "left", - "steps": 6 - }, - { - "plate": 2, - "direction": "right", - "steps": 2 - }, - { - "plate": 4, - "direction": "left", - "steps": 5 - }, - { - "plate": 2, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "left", - "steps": 3 - } - ], - "historicalTimingMs": { - "min": 45.528119999999035, - "median": 46.7260040000001, - "max": 47.06144800000038 - } - } - }, - { - "id": "raven-tower-right-chest", - "title": "Правый сундук в башне Ворона", - "titleEn": "Raven Tower right chest", - "url": "https://unlockmyloot.com/locks/raven-tower-right-chest/", - "code": "bEhYigAEVCQEAA", - "flip": false, - "description": "1-й этаж башни, справа у входа - 2 из 9 сундуков в каталоге", - "catalogMetadata": "6 плиток · 23 хода · 10 связей", - "provenance": { - "commit": "eee0bf50ebcb7fffd2b47954fd76bb015854e365", - "catalog": "locks/index.html", - "catalog_line": 579, - "english_catalog": "en/locks/index.html", - "landing_pages": [ - "locks/raven-tower-right-chest/index.html", - "en/locks/raven-tower-right-chest/index.html" - ], - "source_notes": "locks-research.md", - "supplied_by": null, - "research_line": 130, - "research_section": "### 5e. Правый сундук в башне Ворона (1-й этаж)", - "research_provenance": [ - "- Источник: сверено в игре (июнь 2026).", - "- СТАТУС: в каталоге." - ] - }, - "definition": { - "state": [ - 7, - 2, - 2, - 1, - 3, - 7 - ], - "links": [ - [ - 0, - 0, - -1, - 0, - -1, - -1 - ], - [ - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 1, - 0, - 0, - 1, - 1 - ], - [ - 1, - 0, - 0, - 0, - -1, - 1 - ], - [ - 0, - 0, - 0, - 1, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 0 - ] - ] - }, - "expectedActions": 8, - "baselineCommands": [ - { - "plate": 2, - "direction": "left", - "steps": 5 - }, - { - "plate": 3, - "direction": "right", - "steps": 1 - }, - { - "plate": 6, - "direction": "right", - "steps": 5 - }, - { - "plate": 1, - "direction": "right", - "steps": 5 - }, - { - "plate": 3, - "direction": "right", - "steps": 2 - }, - { - "plate": 4, - "direction": "left", - "steps": 2 - }, - { - "plate": 5, - "direction": "left", - "steps": 1 - }, - { - "plate": 6, - "direction": "right", - "steps": 2 - } - ], - "historicalReference": { - "kind": "cached-observation", - "capturedAt": "2026-09-07T06:02:47.149Z", - "A": 8, - "U": 6, - "C": 23, - "plateSwitches": 7, - "commands": [ - { - "plate": 6, - "direction": "right", - "steps": 6 - }, - { - "plate": 1, - "direction": "right", - "steps": 2 - }, - { - "plate": 6, - "direction": "right", - "steps": 1 - }, - { - "plate": 5, - "direction": "left", - "steps": 1 - }, - { - "plate": 4, - "direction": "left", - "steps": 2 - }, - { - "plate": 2, - "direction": "left", - "steps": 5 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - } - ], - "historicalTimingMs": { - "min": 15.836498000000574, - "median": 16.735591999999087, - "max": 18.030751000000237 - } - } - }, - { - "id": "raven-tower-floor2-stairs-chest", - "title": "Сундук у лестницы (2-й этаж башни)", - "titleEn": "Chest by stairs (Raven Tower floor 2)", - "url": "https://unlockmyloot.com/locks/raven-tower-floor2-stairs-chest/", - "code": "YqahUKAAACIIAA", - "flip": false, - "description": "2-й этаж - сразу после лестницы, вино и пиво", - "catalogMetadata": "6 плиток · 48 ходов · 8 связей", - "provenance": { - "commit": "eee0bf50ebcb7fffd2b47954fd76bb015854e365", - "catalog": "locks/index.html", - "catalog_line": 585, - "english_catalog": "en/locks/index.html", - "landing_pages": [ - "locks/raven-tower-floor2-stairs-chest/index.html", - "en/locks/raven-tower-floor2-stairs-chest/index.html" - ], - "source_notes": "locks-research.md", - "supplied_by": null, - "research_line": 137, - "research_section": "### 5f. Сундук у лестницы на 2-м этаже башни Ворона", - "research_provenance": [ - "- СТАТУС: в каталоге." - ] - }, - "definition": { - "state": [ - 2, - 3, - 5, - 7, - 6, - 1 - ], - "links": [ - [ - 0, - 1, - 1, - 1, - 0, - 0 - ], - [ - -1, - 0, - -1, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - -1, - 0 - ], - [ - -1, - 0, - 0, - -1, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 0 - ] - ] - }, - "expectedActions": 16, - "baselineCommands": [ - { - "plate": 1, - "direction": "right", - "steps": 1 - }, - { - "plate": 5, - "direction": "right", - "steps": 1 - }, - { - "plate": 1, - "direction": "right", - "steps": 1 - }, - { - "plate": 3, - "direction": "left", - "steps": 4 - }, - { - "plate": 4, - "direction": "right", - "steps": 2 - }, - { - "plate": 5, - "direction": "right", - "steps": 3 - }, - { - "plate": 2, - "direction": "left", - "steps": 3 - }, - { - "plate": 4, - "direction": "right", - "steps": 3 - }, - { - "plate": 5, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - }, - { - "plate": 3, - "direction": "left", - "steps": 6 - }, - { - "plate": 4, - "direction": "right", - "steps": 3 - }, - { - "plate": 5, - "direction": "right", - "steps": 6 - }, - { - "plate": 2, - "direction": "left", - "steps": 3 - }, - { - "plate": 4, - "direction": "right", - "steps": 3 - }, - { - "plate": 6, - "direction": "left", - "steps": 3 - } - ], - "historicalReference": { - "kind": "cached-observation", - "capturedAt": "2026-09-07T06:02:47.149Z", - "A": 16, - "U": 6, - "C": 48, - "plateSwitches": 15, - "commands": [ - { - "plate": 6, - "direction": "left", - "steps": 3 - }, - { - "plate": 4, - "direction": "right", - "steps": 1 - }, - { - "plate": 5, - "direction": "right", - "steps": 1 - }, - { - "plate": 4, - "direction": "right", - "steps": 1 - }, - { - "plate": 1, - "direction": "right", - "steps": 2 - }, - { - "plate": 5, - "direction": "right", - "steps": 3 - }, - { - "plate": 4, - "direction": "right", - "steps": 3 - }, - { - "plate": 5, - "direction": "right", - "steps": 3 - }, - { - "plate": 4, - "direction": "right", - "steps": 3 - }, - { - "plate": 3, - "direction": "left", - "steps": 4 - }, - { - "plate": 2, - "direction": "left", - "steps": 6 - }, - { - "plate": 5, - "direction": "right", - "steps": 3 - }, - { - "plate": 4, - "direction": "right", - "steps": 3 - }, - { - "plate": 5, - "direction": "right", - "steps": 3 - }, - { - "plate": 3, - "direction": "left", - "steps": 6 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - } - ], - "historicalTimingMs": { - "min": 53.395453000001, - "median": 54.389939999999115, - "max": 56.43529800000033 - } - } - }, - { - "id": "raven-tower-floor2-chest-2", - "title": "Второй сундук на 2-м этаже башни", - "titleEn": "Second chest on Raven Tower floor 2", - "url": "https://unlockmyloot.com/locks/raven-tower-floor2-chest-2/", - "code": "Q6XCAhAglI", - "flip": false, - "description": "2-й этаж - 12 приборов и 8897 монет", - "catalogMetadata": "5 плиток · 27 ходов · 8 связей", - "provenance": { - "commit": "eee0bf50ebcb7fffd2b47954fd76bb015854e365", - "catalog": "locks/index.html", - "catalog_line": 591, - "english_catalog": "en/locks/index.html", - "landing_pages": [ - "locks/raven-tower-floor2-chest-2/index.html", - "en/locks/raven-tower-floor2-chest-2/index.html" - ], - "source_notes": "locks-research.md", - "supplied_by": null, - "research_line": 143, - "research_section": "### 5g. Второй сундук на 2-м этаже башни Ворона", - "research_provenance": [ - "- СТАТУС: в каталоге." - ] - }, - "definition": { - "state": [ - 2, - 7, - 5, - 6, - 7 - ], - "links": [ - [ - 0, - 0, - 1, - 0, - 0 - ], - [ - 0, - 0, - 1, - 0, - 0 - ], - [ - -1, - 0, - 0, - 0, - 1 - ], - [ - 0, - 0, - 1, - 0, - 0 - ], - [ - -1, - -1, - 1, - 0, - 0 - ] - ] - }, - "expectedActions": 8, - "baselineCommands": [ - { - "plate": 4, - "direction": "left", - "steps": 1 - }, - { - "plate": 3, - "direction": "right", - "steps": 5 - }, - { - "plate": 5, - "direction": "left", - "steps": 5 - }, - { - "plate": 1, - "direction": "right", - "steps": 1 - }, - { - "plate": 4, - "direction": "right", - "steps": 3 - }, - { - "plate": 2, - "direction": "left", - "steps": 5 - }, - { - "plate": 3, - "direction": "right", - "steps": 6 - }, - { - "plate": 5, - "direction": "left", - "steps": 3 - } - ], - "historicalReference": { - "kind": "cached-observation", - "capturedAt": "2026-09-07T06:02:47.149Z", - "A": 9, - "U": 5, - "C": 27, - "plateSwitches": 8, - "commands": [ - { - "plate": 3, - "direction": "right", - "steps": 4 - }, - { - "plate": 5, - "direction": "left", - "steps": 4 - }, - { - "plate": 3, - "direction": "right", - "steps": 4 - }, - { - "plate": 2, - "direction": "left", - "steps": 2 - }, - { - "plate": 5, - "direction": "left", - "steps": 4 - }, - { - "plate": 4, - "direction": "right", - "steps": 2 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 2, - "direction": "left", - "steps": 3 - }, - { - "plate": 1, - "direction": "right", - "steps": 1 - } - ], - "historicalTimingMs": { - "min": 3.0696099999986473, - "median": 3.0996259999992617, - "max": 3.4110639999998966 - } - } - }, - { - "id": "raven-tower-floor3-entrance-chest", - "title": "Сундук у входа (3-й этаж башни)", - "titleEn": "Chest at entrance (Raven Tower floor 3)", - "url": "https://unlockmyloot.com/locks/raven-tower-floor3-entrance-chest/", - "code": "QmzSSoBAAg", - "flip": false, - "description": "3-й этаж - волчьи когти, зубы, приборы", - "catalogMetadata": "5 плиток · 44 хода · 8 связей", - "provenance": { - "commit": "eee0bf50ebcb7fffd2b47954fd76bb015854e365", - "catalog": "locks/index.html", - "catalog_line": 597, - "english_catalog": "en/locks/index.html", - "landing_pages": [ - "locks/raven-tower-floor3-entrance-chest/index.html", - "en/locks/raven-tower-floor3-entrance-chest/index.html" - ], - "source_notes": "locks-research.md", - "supplied_by": null, - "research_line": 148, - "research_section": "### 5h. Сундук у входа на 3-м этаже башни Ворона", - "research_provenance": [ - "- СТАТУС: в каталоге." - ] - }, - "definition": { - "state": [ - 2, - 2, - 6, - 5, - 7 - ], - "links": [ - [ - 0, - -1, - 1, - 0, - -1 - ], - [ - 1, - 0, - 1, - 1, - 0 - ], - [ - 0, - 0, - 0, - 0, - -1 - ], - [ - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 1, - 0, - 0, - 0 - ] - ] - }, - "expectedActions": 14, - "baselineCommands": [ - { - "plate": 2, - "direction": "left", - "steps": 1 - }, - { - "plate": 4, - "direction": "right", - "steps": 5 - }, - { - "plate": 5, - "direction": "right", - "steps": 2 - }, - { - "plate": 1, - "direction": "right", - "steps": 2 - }, - { - "plate": 2, - "direction": "left", - "steps": 2 - }, - { - "plate": 4, - "direction": "right", - "steps": 2 - }, - { - "plate": 5, - "direction": "right", - "steps": 4 - }, - { - "plate": 1, - "direction": "right", - "steps": 2 - }, - { - "plate": 5, - "direction": "right", - "steps": 2 - }, - { - "plate": 3, - "direction": "right", - "steps": 4 - }, - { - "plate": 2, - "direction": "left", - "steps": 6 - }, - { - "plate": 4, - "direction": "right", - "steps": 3 - }, - { - "plate": 5, - "direction": "right", - "steps": 6 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - } - ], - "historicalReference": { - "kind": "cached-observation", - "capturedAt": "2026-09-07T06:02:47.149Z", - "A": 14, - "U": 5, - "C": 44, - "plateSwitches": 13, - "commands": [ - { - "plate": 2, - "direction": "left", - "steps": 1 - }, - { - "plate": 5, - "direction": "right", - "steps": 2 - }, - { - "plate": 4, - "direction": "right", - "steps": 1 - }, - { - "plate": 1, - "direction": "right", - "steps": 2 - }, - { - "plate": 2, - "direction": "left", - "steps": 2 - }, - { - "plate": 5, - "direction": "right", - "steps": 4 - }, - { - "plate": 1, - "direction": "right", - "steps": 2 - }, - { - "plate": 5, - "direction": "right", - "steps": 2 - }, - { - "plate": 4, - "direction": "right", - "steps": 6 - }, - { - "plate": 3, - "direction": "right", - "steps": 4 - }, - { - "plate": 2, - "direction": "left", - "steps": 6 - }, - { - "plate": 5, - "direction": "right", - "steps": 6 - }, - { - "plate": 4, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - } - ], - "historicalTimingMs": { - "min": 6.549548999999388, - "median": 6.864462999998068, - "max": 7.441822000000684 - } - } - }, - { - "id": "raven-tower-floor3-empty-chest", - "title": "Пустой сундук (3-й этаж башни)", - "titleEn": "Empty chest (Raven Tower floor 3)", - "url": "https://unlockmyloot.com/locks/raven-tower-floor3-empty-chest/", - "code": "RYIBEylAIA", - "flip": false, - "description": "3-й этаж - сундук пустой", - "catalogMetadata": "5 плиток · 37 ходов · 9 связей", - "provenance": { - "commit": "eee0bf50ebcb7fffd2b47954fd76bb015854e365", - "catalog": "locks/index.html", - "catalog_line": 603, - "english_catalog": "en/locks/index.html", - "landing_pages": [ - "locks/raven-tower-floor3-empty-chest/index.html", - "en/locks/raven-tower-floor3-empty-chest/index.html" - ], - "source_notes": "locks-research.md", - "supplied_by": null, - "research_line": 154, - "research_section": "### 5i. Пустой сундук на 3-м этаже башни Ворона", - "research_provenance": [ - "- СТАТУС: в каталоге." - ] - }, - "definition": { - "state": [ - 3, - 7, - 1, - 3, - 1 - ], - "links": [ - [ - 0, - 0, - 0, - -1, - 0 - ], - [ - -1, - 0, - 1, - -1, - 1 - ], - [ - 1, - 0, - 0, - -1, - -1 - ], - [ - 0, - 0, - 0, - 0, - 1 - ], - [ - 0, - 0, - 0, - 0, - 0 - ] - ] - }, - "expectedActions": 9, - "baselineCommands": [ - { - "plate": 1, - "direction": "right", - "steps": 2 - }, - { - "plate": 4, - "direction": "left", - "steps": 2 - }, - { - "plate": 5, - "direction": "left", - "steps": 4 - }, - { - "plate": 3, - "direction": "left", - "steps": 6 - }, - { - "plate": 1, - "direction": "right", - "steps": 6 - }, - { - "plate": 5, - "direction": "left", - "steps": 6 - }, - { - "plate": 4, - "direction": "right", - "steps": 6 - }, - { - "plate": 5, - "direction": "left", - "steps": 6 - }, - { - "plate": 2, - "direction": "right", - "steps": 3 - } - ], - "historicalReference": { - "kind": "cached-observation", - "capturedAt": "2026-09-07T06:02:47.149Z", - "A": 9, - "U": 5, - "C": 37, - "plateSwitches": 8, - "commands": [ - { - "plate": 5, - "direction": "left", - "steps": 6 - }, - { - "plate": 3, - "direction": "left", - "steps": 2 - }, - { - "plate": 1, - "direction": "right", - "steps": 4 - }, - { - "plate": 3, - "direction": "left", - "steps": 4 - }, - { - "plate": 5, - "direction": "left", - "steps": 6 - }, - { - "plate": 1, - "direction": "right", - "steps": 4 - }, - { - "plate": 4, - "direction": "right", - "steps": 4 - }, - { - "plate": 5, - "direction": "left", - "steps": 4 - }, - { - "plate": 2, - "direction": "right", - "steps": 3 - } - ], - "historicalTimingMs": { - "min": 5.393829000000551, - "median": 5.431555999999546, - "max": 5.844538000001194 - } - } - }, - { - "id": "raven-tower-floor4-elixirs-chest", - "title": "Сундук с эликсирами (4-й этаж башни)", - "titleEn": "Elixir chest (Raven Tower floor 4)", - "url": "https://unlockmyloot.com/locks/raven-tower-floor4-elixirs-chest/", - "code": "h24ZgUAAEAQgAwAKQAA", - "flip": false, - "description": "4-й этаж за лестницей - зелья и экстракты", - "catalogMetadata": "7 плиток · 36 ходов · 10 связей", - "provenance": { - "commit": "eee0bf50ebcb7fffd2b47954fd76bb015854e365", - "catalog": "locks/index.html", - "catalog_line": 609, - "english_catalog": "en/locks/index.html", - "landing_pages": [ - "locks/raven-tower-floor4-elixirs-chest/index.html", - "en/locks/raven-tower-floor4-elixirs-chest/index.html" - ], - "source_notes": "locks-research.md", - "supplied_by": null, - "research_line": 159, - "research_section": "### 5j. Сундук с эликсирами на 4-м этаже башни Ворона", - "research_provenance": [ - "- СТАТУС: в каталоге." - ] - }, - "definition": { - "state": [ - 4, - 6, - 6, - 7, - 1, - 7, - 4 - ], - "links": [ - [ - 0, - 0, - 0, - 0, - -1, - -1, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - -1, - 0, - 0, - 0, - 0, - 0 - ], - [ - -1, - 0, - 1, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 1, - -1, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 1, - 1, - 0, - 0 - ], - [ - -1, - 0, - 0, - 0, - 0, - 0, - 0 - ] - ] - }, - "expectedActions": 8, - "baselineCommands": [ - { - "plate": 2, - "direction": "right", - "steps": 5 - }, - { - "plate": 3, - "direction": "right", - "steps": 5 - }, - { - "plate": 2, - "direction": "right", - "steps": 5 - }, - { - "plate": 5, - "direction": "left", - "steps": 6 - }, - { - "plate": 1, - "direction": "left", - "steps": 3 - }, - { - "plate": 3, - "direction": "right", - "steps": 6 - }, - { - "plate": 2, - "direction": "right", - "steps": 3 - }, - { - "plate": 4, - "direction": "left", - "steps": 3 - } - ], - "historicalReference": { - "kind": "cached-observation", - "capturedAt": "2026-09-07T06:02:47.149Z", - "A": 8, - "U": 5, - "C": 36, - "plateSwitches": 7, - "commands": [ - { - "plate": 2, - "direction": "right", - "steps": 4 - }, - { - "plate": 3, - "direction": "right", - "steps": 5 - }, - { - "plate": 5, - "direction": "left", - "steps": 6 - }, - { - "plate": 2, - "direction": "right", - "steps": 6 - }, - { - "plate": 3, - "direction": "right", - "steps": 6 - }, - { - "plate": 4, - "direction": "left", - "steps": 3 - }, - { - "plate": 2, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "left", - "steps": 3 - } - ], - "historicalTimingMs": { - "min": 108.15463799999998, - "median": 113.79403699999966, - "max": 127.23316599999816 - } - } - }, - { - "id": "raven-tower-floor4-food-chest", - "title": "Сундук с едой (4-й этаж башни)", - "titleEn": "Food chest (Raven Tower floor 4)", - "url": "https://unlockmyloot.com/locks/raven-tower-floor4-food-chest/", - "code": "aYqYiAQgCACABA", - "flip": false, - "description": "4-й этаж слева от эликсиров - вино, пиво, мясо", - "catalogMetadata": "6 плиток · 43 хода · 7 связей", - "provenance": { - "commit": "eee0bf50ebcb7fffd2b47954fd76bb015854e365", - "catalog": "locks/index.html", - "catalog_line": 615, - "english_catalog": "en/locks/index.html", - "landing_pages": [ - "locks/raven-tower-floor4-food-chest/index.html", - "en/locks/raven-tower-floor4-food-chest/index.html" - ], - "source_notes": "locks-research.md", - "supplied_by": null, - "research_line": 165, - "research_section": "### 5k. Сундук с едой на 4-м этаже башни Ворона", - "research_provenance": [ - "- СТАТУС: в каталоге." - ] - }, - "definition": { - "state": [ - 5, - 7, - 2, - 3, - 5, - 7 - ], - "links": [ - [ - 0, - 0, - -1, - 0, - -1, - 0 - ], - [ - 0, - 0, - 0, - 1, - 0, - 0 - ], - [ - -1, - 0, - 0, - 0, - 0, - 0 - ], - [ - -1, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - -1, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 1, - 0, - 0, - 0 - ] - ] - }, - "expectedActions": 11, - "baselineCommands": [ - { - "plate": 3, - "direction": "left", - "steps": 4 - }, - { - "plate": 1, - "direction": "left", - "steps": 4 - }, - { - "plate": 3, - "direction": "left", - "steps": 4 - }, - { - "plate": 4, - "direction": "right", - "steps": 1 - }, - { - "plate": 5, - "direction": "left", - "steps": 6 - }, - { - "plate": 1, - "direction": "left", - "steps": 5 - }, - { - "plate": 2, - "direction": "left", - "steps": 5 - }, - { - "plate": 3, - "direction": "left", - "steps": 6 - }, - { - "plate": 4, - "direction": "right", - "steps": 3 - }, - { - "plate": 5, - "direction": "left", - "steps": 2 - }, - { - "plate": 6, - "direction": "right", - "steps": 3 - } - ], - "historicalReference": { - "kind": "cached-observation", - "capturedAt": "2026-09-07T06:02:47.149Z", - "A": 11, - "U": 6, - "C": 43, - "plateSwitches": 10, - "commands": [ - { - "plate": 3, - "direction": "left", - "steps": 2 - }, - { - "plate": 6, - "direction": "right", - "steps": 3 - }, - { - "plate": 5, - "direction": "left", - "steps": 2 - }, - { - "plate": 2, - "direction": "left", - "steps": 2 - }, - { - "plate": 4, - "direction": "right", - "steps": 4 - }, - { - "plate": 3, - "direction": "left", - "steps": 6 - }, - { - "plate": 1, - "direction": "left", - "steps": 6 - }, - { - "plate": 5, - "direction": "left", - "steps": 6 - }, - { - "plate": 3, - "direction": "left", - "steps": 6 - }, - { - "plate": 2, - "direction": "left", - "steps": 3 - }, - { - "plate": 1, - "direction": "left", - "steps": 3 - } - ], - "historicalTimingMs": { - "min": 51.97651599999881, - "median": 53.12884100000156, - "max": 56.046502999997756 - } - } - }, - { - "id": "raven-tower-top-chest", - "title": "Сундук на вершине башни Ворона", - "titleEn": "Chest at the top of Raven Tower", - "url": "https://unlockmyloot.com/locks/raven-tower-top-chest/", - "code": "aMCmkAAGBBYAAA", - "flip": false, - "description": "Верх башни - кольцо ловкости, зов сна, велайис", - "catalogMetadata": "6 плиток · 48 ходов · 9 связей", - "provenance": { - "commit": "eee0bf50ebcb7fffd2b47954fd76bb015854e365", - "catalog": "locks/index.html", - "catalog_line": 621, - "english_catalog": "en/locks/index.html", - "landing_pages": [ - "locks/raven-tower-top-chest/index.html", - "en/locks/raven-tower-top-chest/index.html" - ], - "source_notes": "locks-research.md", - "supplied_by": null, - "research_line": 171, - "research_section": "### 5l. Сундук на вершине башни Ворона", - "research_provenance": [ - "- СТАТУС: в каталоге." - ] - }, - "definition": { - "state": [ - 5, - 4, - 1, - 1, - 6, - 2 - ], - "links": [ - [ - 0, - -1, - -1, - 1, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 1, - 0, - -1, - 0, - 0 - ], - [ - 1, - 0, - 0, - 0, - 1, - 1 - ], - [ - -1, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 0 - ] - ] - }, - "expectedActions": 20, - "baselineCommands": [ - { - "plate": 2, - "direction": "right", - "steps": 3 - }, - { - "plate": 4, - "direction": "left", - "steps": 1 - }, - { - "plate": 1, - "direction": "right", - "steps": 1 - }, - { - "plate": 3, - "direction": "right", - "steps": 1 - }, - { - "plate": 1, - "direction": "right", - "steps": 1 - }, - { - "plate": 3, - "direction": "right", - "steps": 1 - }, - { - "plate": 1, - "direction": "right", - "steps": 1 - }, - { - "plate": 3, - "direction": "right", - "steps": 1 - }, - { - "plate": 5, - "direction": "right", - "steps": 2 - }, - { - "plate": 4, - "direction": "left", - "steps": 2 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 5, - "direction": "right", - "steps": 6 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - }, - { - "plate": 6, - "direction": "right", - "steps": 4 - }, - { - "plate": 4, - "direction": "left", - "steps": 3 - } - ], - "historicalReference": { - "kind": "cached-observation", - "capturedAt": "2026-09-07T06:02:47.149Z", - "A": 20, - "U": 6, - "C": 48, - "plateSwitches": 19, - "commands": [ - { - "plate": 4, - "direction": "left", - "steps": 1 - }, - { - "plate": 1, - "direction": "right", - "steps": 1 - }, - { - "plate": 5, - "direction": "right", - "steps": 2 - }, - { - "plate": 3, - "direction": "right", - "steps": 1 - }, - { - "plate": 1, - "direction": "right", - "steps": 1 - }, - { - "plate": 4, - "direction": "left", - "steps": 1 - }, - { - "plate": 1, - "direction": "right", - "steps": 1 - }, - { - "plate": 4, - "direction": "left", - "steps": 1 - }, - { - "plate": 6, - "direction": "right", - "steps": 4 - }, - { - "plate": 3, - "direction": "right", - "steps": 2 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - }, - { - "plate": 5, - "direction": "right", - "steps": 6 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - }, - { - "plate": 4, - "direction": "left", - "steps": 3 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 2, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - } - ], - "historicalTimingMs": { - "min": 42.69402500000069, - "median": 45.8797799999993, - "max": 46.05100699999821 - } - } - }, - { - "id": "fire-lizard-cave-chest", - "title": "Ящик у огненного ящера", - "titleEn": "Fire lizard cave box", - "url": "https://unlockmyloot.com/?lock=aDasBgBgCAACCA", - "code": "aDasBgBgCAACCA", - "flip": false, - "description": "Первый ящик в пещерке у круга с огненным ящером на востоке", - "catalogMetadata": "6 плиток · 41 ход · 7 связей", - "provenance": { - "commit": "eee0bf50ebcb7fffd2b47954fd76bb015854e365", - "catalog": "locks/index.html", - "catalog_line": 628, - "english_catalog": "en/locks/index.html", - "landing_pages": [], - "source_notes": "locks-research.md", - "supplied_by": "Влад · DTF", - "research_line": 311, - "research_section": "### 6. Ящик у огненного ящера (восток)", - "research_provenance": [ - "- Нашёл и прислал: Влад, DTF (https://dtf.ru/id644393). СТАТУС: в каталоге (6-я карточка)." - ] - }, - "definition": { - "state": [ - 5, - 1, - 7, - 7, - 6, - 4 - ], - "links": [ - [ - 0, - 0, - 0, - 0, - 1, - -1 - ], - [ - 0, - 0, - 0, - 0, - 0, - 1 - ], - [ - -1, - 0, - 0, - 0, - 0, - 0 - ], - [ - -1, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - -1 - ], - [ - 0, - 0, - -1, - 0, - 0, - 0 - ] - ] - }, - "expectedActions": 10, - "baselineCommands": [ - { - "plate": 1, - "direction": "right", - "steps": 3 - }, - { - "plate": 3, - "direction": "right", - "steps": 5 - }, - { - "plate": 5, - "direction": "left", - "steps": 4 - }, - { - "plate": 2, - "direction": "left", - "steps": 3 - }, - { - "plate": 6, - "direction": "right", - "steps": 5 - }, - { - "plate": 1, - "direction": "right", - "steps": 6 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 4, - "direction": "right", - "steps": 3 - }, - { - "plate": 5, - "direction": "left", - "steps": 6 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - } - ], - "historicalReference": { - "kind": "cached-observation", - "capturedAt": "2026-09-07T06:02:47.149Z", - "A": 10, - "U": 6, - "C": 41, - "plateSwitches": 9, - "commands": [ - { - "plate": 1, - "direction": "right", - "steps": 3 - }, - { - "plate": 3, - "direction": "right", - "steps": 5 - }, - { - "plate": 6, - "direction": "right", - "steps": 5 - }, - { - "plate": 2, - "direction": "left", - "steps": 3 - }, - { - "plate": 5, - "direction": "left", - "steps": 4 - }, - { - "plate": 1, - "direction": "right", - "steps": 6 - }, - { - "plate": 5, - "direction": "left", - "steps": 6 - }, - { - "plate": 4, - "direction": "right", - "steps": 3 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - } - ], - "historicalTimingMs": { - "min": 52.59948500000246, - "median": 53.04343399999925, - "max": 66.56672799999797 - } - } - }, - { - "id": "silas-tavern-basement-chest", - "title": "Сундук в подвале таверны", - "titleEn": "Silas tavern basement chest", - "url": "https://unlockmyloot.com/locks/silas-tavern-basement-chest/", - "code": "RrGUCRKQIQ", - "flip": false, - "description": "Подводный сундук в подвале под таверной Силаса - топор Брана, квест «По шею»", - "catalogMetadata": "5 плиток · 30 ходов · 10 связей", - "provenance": { - "commit": "eee0bf50ebcb7fffd2b47954fd76bb015854e365", - "catalog": "locks/index.html", - "catalog_line": 635, - "english_catalog": "en/locks/index.html", - "landing_pages": [ - "locks/silas-tavern-basement-chest/index.html", - "en/locks/silas-tavern-basement-chest/index.html" - ], - "source_notes": "locks-research.md", - "supplied_by": null, - "research_line": 319, - "research_section": "### 7. Сундук в подвале таверны (Новый лагерь, таверна Силаса)", - "research_provenance": [ - "- СТАТУС: в каталоге (7-я карточка)." - ] - }, - "definition": { - "state": [ - 4, - 3, - 7, - 2, - 5 - ], - "links": [ - [ - 0, - -1, - -1, - 0, - 0 - ], - [ - 1, - 0, - 0, - -1, - 0 - ], - [ - -1, - 1, - 0, - 1, - 0 - ], - [ - -1, - 0, - 0, - 0, - 1 - ], - [ - 0, - 0, - -1, - 0, - 0 - ] - ] - }, - "expectedActions": 9, - "baselineCommands": [ - { - "plate": 1, - "direction": "left", - "steps": 2 - }, - { - "plate": 3, - "direction": "left", - "steps": 2 - }, - { - "plate": 2, - "direction": "right", - "steps": 2 - }, - { - "plate": 4, - "direction": "right", - "steps": 4 - }, - { - "plate": 5, - "direction": "left", - "steps": 6 - }, - { - "plate": 3, - "direction": "left", - "steps": 5 - }, - { - "plate": 4, - "direction": "right", - "steps": 5 - }, - { - "plate": 2, - "direction": "right", - "steps": 2 - }, - { - "plate": 5, - "direction": "left", - "steps": 2 - } - ], - "historicalReference": { - "kind": "cached-observation", - "capturedAt": "2026-09-07T06:02:47.149Z", - "A": 9, - "U": 5, - "C": 30, - "plateSwitches": 8, - "commands": [ - { - "plate": 5, - "direction": "left", - "steps": 2 - }, - { - "plate": 1, - "direction": "left", - "steps": 2 - }, - { - "plate": 3, - "direction": "left", - "steps": 4 - }, - { - "plate": 2, - "direction": "right", - "steps": 1 - }, - { - "plate": 4, - "direction": "right", - "steps": 6 - }, - { - "plate": 5, - "direction": "left", - "steps": 6 - }, - { - "plate": 3, - "direction": "left", - "steps": 3 - }, - { - "plate": 4, - "direction": "right", - "steps": 3 - }, - { - "plate": 2, - "direction": "right", - "steps": 3 - } - ], - "historicalTimingMs": { - "min": 3.8142619999998715, - "median": 3.8800910000027216, - "max": 3.9581380000017816 - } - } - }, - { - "id": "silas-tavern-storeroom-chest", - "title": "Сундук склада таверны", - "titleEn": "Tavern storeroom chest", - "url": "https://unlockmyloot.com/?lock=Yt05ARCBAAARCA", - "code": "Yt05ARCBAAARCA", - "flip": false, - "description": "Сундук справа в складе таверны за дверью за Силасом (бармен) - это не подвал", - "catalogMetadata": "6 плиток · 31 ход · 8 связей", - "provenance": { - "commit": "eee0bf50ebcb7fffd2b47954fd76bb015854e365", - "catalog": "locks/index.html", - "catalog_line": 641, - "english_catalog": "en/locks/index.html", - "landing_pages": [], - "source_notes": "locks-research.md", - "supplied_by": null, - "research_line": 329, - "research_section": "### 8. Сундук склада таверны (Новый лагерь, за дверью Силаса)", - "research_provenance": [ - "- СТАТУС: в каталоге (8-я карточка)." - ] - }, - "definition": { - "state": [ - 2, - 4, - 4, - 6, - 2, - 7 - ], - "links": [ - [ - 0, - 1, - 0, - 0, - 0, - 1 - ], - [ - 0, - 0, - 1, - 0, - 0, - -1 - ], - [ - 0, - 0, - 0, - 1, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 1, - 0, - 0, - 1 - ], - [ - 0, - 0, - -1, - 0, - 0, - 0 - ] - ] - }, - "expectedActions": 9, - "baselineCommands": [ - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 4, - "direction": "left", - "steps": 4 - }, - { - "plate": 6, - "direction": "right", - "steps": 6 - }, - { - "plate": 1, - "direction": "left", - "steps": 2 - }, - { - "plate": 2, - "direction": "right", - "steps": 2 - }, - { - "plate": 5, - "direction": "left", - "steps": 2 - }, - { - "plate": 3, - "direction": "right", - "steps": 6 - }, - { - "plate": 4, - "direction": "left", - "steps": 3 - }, - { - "plate": 6, - "direction": "right", - "steps": 3 - } - ], - "historicalReference": { - "kind": "cached-observation", - "capturedAt": "2026-09-07T06:02:47.149Z", - "A": 9, - "U": 6, - "C": 31, - "plateSwitches": 8, - "commands": [ - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 6, - "direction": "right", - "steps": 6 - }, - { - "plate": 4, - "direction": "left", - "steps": 4 - }, - { - "plate": 3, - "direction": "right", - "steps": 6 - }, - { - "plate": 5, - "direction": "left", - "steps": 2 - }, - { - "plate": 2, - "direction": "right", - "steps": 2 - }, - { - "plate": 6, - "direction": "right", - "steps": 3 - }, - { - "plate": 4, - "direction": "left", - "steps": 3 - }, - { - "plate": 1, - "direction": "left", - "steps": 2 - } - ], - "historicalTimingMs": { - "min": 30.20962199999849, - "median": 31.75662200000079, - "max": 36.70291599999837 - } - } - }, - { - "id": "silas-tavern-storeroom-door", - "title": "Склад таверны - дверь", - "titleEn": "Tavern storeroom door", - "url": "https://unlockmyloot.com/?lock=aSnYABCIZIICCA", - "code": "aSnYABCIZIICCA", - "flip": false, - "description": "Запертая дверь в склад таверны Нового лагеря за барменом Силасом", - "catalogMetadata": "6 плиток · 31 ход · 10 связей", - "provenance": { - "commit": "eee0bf50ebcb7fffd2b47954fd76bb015854e365", - "catalog": "locks/index.html", - "catalog_line": 647, - "english_catalog": "en/locks/index.html", - "landing_pages": [], - "source_notes": "locks-research.md", - "supplied_by": null, - "research_line": 338, - "research_section": "### 9. Дверь в склад таверны (Новый лагерь, за Силасом)", - "research_provenance": [ - "- СТАТУС: в каталоге (9-я карточка)." - ] - }, - "definition": { - "state": [ - 5, - 5, - 6, - 2, - 7, - 7 - ], - "links": [ - [ - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 1, - 0, - 0, - -1 - ], - [ - 0, - -1, - 0, - 0, - 1, - -1 - ], - [ - 1, - 0, - -1, - 0, - 0, - 0 - ], - [ - -1, - 0, - 0, - 0, - 0, - -1 - ], - [ - 0, - 0, - -1, - 0, - 0, - 0 - ] - ] - }, - "expectedActions": 10, - "baselineCommands": [ - { - "plate": 4, - "direction": "left", - "steps": 2 - }, - { - "plate": 6, - "direction": "right", - "steps": 3 - }, - { - "plate": 2, - "direction": "right", - "steps": 3 - }, - { - "plate": 6, - "direction": "right", - "steps": 3 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 5, - "direction": "left", - "steps": 3 - }, - { - "plate": 6, - "direction": "right", - "steps": 3 - }, - { - "plate": 2, - "direction": "right", - "steps": 4 - }, - { - "plate": 6, - "direction": "right", - "steps": 4 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - } - ], - "historicalReference": { - "kind": "cached-observation", - "capturedAt": "2026-09-07T06:02:47.149Z", - "A": 10, - "U": 5, - "C": 31, - "plateSwitches": 9, - "commands": [ - { - "plate": 4, - "direction": "left", - "steps": 2 - }, - { - "plate": 6, - "direction": "right", - "steps": 3 - }, - { - "plate": 2, - "direction": "right", - "steps": 3 - }, - { - "plate": 6, - "direction": "right", - "steps": 3 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 6, - "direction": "right", - "steps": 3 - }, - { - "plate": 5, - "direction": "left", - "steps": 3 - }, - { - "plate": 2, - "direction": "right", - "steps": 4 - }, - { - "plate": 6, - "direction": "right", - "steps": 4 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - } - ], - "historicalTimingMs": { - "min": 22.321948999997403, - "median": 23.63271099999838, - "max": 27.04742599999736 - } - } - }, - { - "id": "jan-pond-house-chest", - "title": "Сундук в домике напротив Джана", - "titleEn": "Hut chest opposite Jan (New Camp)", - "url": "https://unlockmyloot.com/?lock=aDAIAEhAAAQaAA", - "code": "aDAIAEhAAAQaAA", - "flip": false, - "description": "Домик напротив Джана у пруда рядом с Новым лагерем - нужно переплыть; внутри чертёж «Дробителя камней» (требует кузнечное дело)", - "catalogMetadata": "6 плиток · 33 хода · 7 связей", - "provenance": { - "commit": "eee0bf50ebcb7fffd2b47954fd76bb015854e365", - "catalog": "locks/index.html", - "catalog_line": 653, - "english_catalog": "en/locks/index.html", - "landing_pages": [], - "source_notes": "locks-research.md", - "supplied_by": null, - "research_line": 372, - "research_section": "### Сундук в домике напротив Джана (Новый лагерь)", - "research_provenance": [ - "- Источник: Лёша (прошёл). Джан — тренер кулачного боя у Нового лагеря" - ] - }, - "definition": { - "state": [ - 5, - 1, - 7, - 1, - 1, - 3 - ], - "links": [ - [ - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 1, - 0, - 0, - -1, - 0, - 1 - ], - [ - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 1 - ], - [ - 0, - 0, - 1, - -1, - 0, - -1 - ], - [ - 0, - 0, - 0, - 0, - 0, - 0 - ] - ] - }, - "expectedActions": 8, - "baselineCommands": [ - { - "plate": 1, - "direction": "right", - "steps": 4 - }, - { - "plate": 3, - "direction": "right", - "steps": 6 - }, - { - "plate": 4, - "direction": "left", - "steps": 4 - }, - { - "plate": 5, - "direction": "left", - "steps": 3 - }, - { - "plate": 6, - "direction": "right", - "steps": 3 - }, - { - "plate": 4, - "direction": "left", - "steps": 5 - }, - { - "plate": 6, - "direction": "right", - "steps": 5 - }, - { - "plate": 2, - "direction": "left", - "steps": 3 - } - ], - "historicalReference": { - "kind": "cached-observation", - "capturedAt": "2026-09-07T06:02:47.149Z", - "A": 8, - "U": 6, - "C": 33, - "plateSwitches": 7, - "commands": [ - { - "plate": 4, - "direction": "left", - "steps": 4 - }, - { - "plate": 6, - "direction": "right", - "steps": 3 - }, - { - "plate": 3, - "direction": "right", - "steps": 6 - }, - { - "plate": 5, - "direction": "left", - "steps": 3 - }, - { - "plate": 4, - "direction": "left", - "steps": 5 - }, - { - "plate": 6, - "direction": "right", - "steps": 5 - }, - { - "plate": 1, - "direction": "right", - "steps": 4 - }, - { - "plate": 2, - "direction": "left", - "steps": 3 - } - ], - "historicalTimingMs": { - "min": 48.03793500000029, - "median": 48.69068799999877, - "max": 50.139368000000104 - } - } - }, - { - "id": "graham-chest", - "title": "Сундук Грэхэма", - "titleEn": "Graham's chest", - "url": "https://unlockmyloot.com/locks/graham-chest/", - "code": "bE0IBAAQkBCAEg", - "flip": false, - "description": "Сундук картографа Грэхэма в Старом лагере - у хижины слева от Северных ворот, где покупают карту колонии за руду", - "catalogMetadata": "6 плиток · 40 ходов · 8 связей", - "provenance": { - "commit": "eee0bf50ebcb7fffd2b47954fd76bb015854e365", - "catalog": "locks/index.html", - "catalog_line": 659, - "english_catalog": "en/locks/index.html", - "landing_pages": [ - "locks/graham-chest/index.html", - "en/locks/graham-chest/index.html" - ], - "source_notes": "locks-research.md", - "supplied_by": null, - "research_line": 384, - "research_section": "### Сундук Грэхэма (Старый лагерь)", - "research_provenance": [ - "- Источник: Грэхэм — старатель/картограф Старого лагеря, живёт в хижине слева" - ] - }, - "definition": { - "state": [ - 7, - 2, - 2, - 6, - 1, - 3 - ], - "links": [ - [ - 0, - 0, - 0, - 0, - 1, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 1, - 0, - 0, - 0, - -1, - 1 - ], - [ - 0, - 0, - 0, - 0, - 1, - 0 - ], - [ - 0, - -1, - 0, - 0, - 0, - 0 - ], - [ - 0, - 1, - 0, - -1, - 0, - 0 - ] - ] - }, - "expectedActions": 10, - "baselineCommands": [ - { - "plate": 2, - "direction": "left", - "steps": 5 - }, - { - "plate": 5, - "direction": "left", - "steps": 6 - }, - { - "plate": 1, - "direction": "right", - "steps": 5 - }, - { - "plate": 2, - "direction": "left", - "steps": 6 - }, - { - "plate": 5, - "direction": "left", - "steps": 5 - }, - { - "plate": 2, - "direction": "left", - "steps": 5 - }, - { - "plate": 3, - "direction": "left", - "steps": 2 - }, - { - "plate": 4, - "direction": "right", - "steps": 3 - }, - { - "plate": 5, - "direction": "left", - "steps": 2 - }, - { - "plate": 6, - "direction": "right", - "steps": 1 - } - ], - "historicalReference": { - "kind": "cached-observation", - "capturedAt": "2026-09-07T06:02:47.149Z", - "A": 10, - "U": 6, - "C": 40, - "plateSwitches": 9, - "commands": [ - { - "plate": 6, - "direction": "right", - "steps": 1 - }, - { - "plate": 2, - "direction": "left", - "steps": 6 - }, - { - "plate": 5, - "direction": "left", - "steps": 6 - }, - { - "plate": 2, - "direction": "left", - "steps": 5 - }, - { - "plate": 1, - "direction": "right", - "steps": 5 - }, - { - "plate": 5, - "direction": "left", - "steps": 5 - }, - { - "plate": 4, - "direction": "right", - "steps": 3 - }, - { - "plate": 2, - "direction": "left", - "steps": 5 - }, - { - "plate": 5, - "direction": "left", - "steps": 2 - }, - { - "plate": 3, - "direction": "left", - "steps": 2 - } - ], - "historicalTimingMs": { - "min": 32.42070899999817, - "median": 33.52989900000102, - "max": 36.52016999999978 - } - } - }, - { - "id": "gravo-chest", - "title": "Сундук Граво", - "titleEn": "Gravo's chest", - "url": "https://unlockmyloot.com/?lock=SkCAAjAhAg", - "code": "SkCAAjAhAg", - "flip": false, - "description": "Сундук старателя Граво в Старом лагере - его хижина у входа в замок (Северные ворота), напротив дома Диего; Граво правит репутацию", - "catalogMetadata": "5 плиток · 27 ходов · 6 связей", - "provenance": { - "commit": "eee0bf50ebcb7fffd2b47954fd76bb015854e365", - "catalog": "locks/index.html", - "catalog_line": 665, - "english_catalog": "en/locks/index.html", - "landing_pages": [], - "source_notes": "locks-research.md", - "supplied_by": null, - "research_line": 396, - "research_section": "### Сундук Граво (Старый лагерь)", - "research_provenance": [ - "- Источник: Граво — старатель Старого лагеря, хижина у входа в замок / у Северных" - ] - }, - "definition": { - "state": [ - 6, - 2, - 1, - 1, - 5 - ], - "links": [ - [ - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 1, - 0, - 1 - ], - [ - -1, - 0, - 0, - 0, - 1 - ], - [ - 0, - 0, - -1, - 0, - 0 - ], - [ - 0, - 1, - 0, - 0, - 0 - ] - ] - }, - "expectedActions": 8, - "baselineCommands": [ - { - "plate": 1, - "direction": "right", - "steps": 5 - }, - { - "plate": 5, - "direction": "right", - "steps": 1 - }, - { - "plate": 2, - "direction": "left", - "steps": 3 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 2, - "direction": "left", - "steps": 3 - }, - { - "plate": 4, - "direction": "left", - "steps": 3 - }, - { - "plate": 5, - "direction": "right", - "steps": 6 - }, - { - "plate": 2, - "direction": "left", - "steps": 3 - } - ], - "historicalReference": { - "kind": "cached-observation", - "capturedAt": "2026-09-07T06:02:47.149Z", - "A": 8, - "U": 5, - "C": 27, - "plateSwitches": 7, - "commands": [ - { - "plate": 5, - "direction": "right", - "steps": 1 - }, - { - "plate": 2, - "direction": "left", - "steps": 3 - }, - { - "plate": 1, - "direction": "right", - "steps": 5 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 2, - "direction": "left", - "steps": 3 - }, - { - "plate": 5, - "direction": "right", - "steps": 6 - }, - { - "plate": 4, - "direction": "left", - "steps": 3 - }, - { - "plate": 2, - "direction": "left", - "steps": 3 - } - ], - "historicalTimingMs": { - "min": 5.467348999998649, - "median": 8.080862999999226, - "max": 8.312551999999414 - } - } - }, - { - "id": "diego-chest", - "title": "Сундук Диего", - "titleEn": "Diego's chest", - "url": "https://unlockmyloot.com/locks/diego-chest/", - "code": "Yix4ABIAASCSAE", - "flip": false, - "description": "Сундук Диего в Старом лагере - его хижина юго-восточнее ворот замка, с красной тканью и шкурами у входа; внутри отмычки, эссенция исцеления и руда", - "catalogMetadata": "6 плиток · 40 ходов · 8 связей", - "provenance": { - "commit": "eee0bf50ebcb7fffd2b47954fd76bb015854e365", - "catalog": "locks/index.html", - "catalog_line": 671, - "english_catalog": "en/locks/index.html", - "landing_pages": [ - "locks/diego-chest/index.html", - "en/locks/diego-chest/index.html" - ], - "source_notes": "locks-research.md", - "supplied_by": null, - "research_line": 407, - "research_section": "### Сундук Диего (Старый лагерь)", - "research_provenance": [ - "- Источник: Диего — ключевой NPC (Тень), помогает герою в начале. Хижина юго-" - ] - }, - "definition": { - "state": [ - 2, - 1, - 6, - 5, - 4, - 7 - ], - "links": [ - [ - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 1, - 0, - -1, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 1, - 0, - 0, - -1, - 0 - ], - [ - 0, - -1, - 1, - 0, - 0, - -1 - ], - [ - 0, - 0, - 0, - 0, - 1, - 0 - ] - ] - }, - "expectedActions": 11, - "baselineCommands": [ - { - "plate": 1, - "direction": "left", - "steps": 2 - }, - { - "plate": 2, - "direction": "left", - "steps": 1 - }, - { - "plate": 3, - "direction": "right", - "steps": 6 - }, - { - "plate": 4, - "direction": "right", - "steps": 1 - }, - { - "plate": 2, - "direction": "left", - "steps": 3 - }, - { - "plate": 5, - "direction": "left", - "steps": 3 - }, - { - "plate": 3, - "direction": "right", - "steps": 6 - }, - { - "plate": 6, - "direction": "left", - "steps": 3 - }, - { - "plate": 2, - "direction": "left", - "steps": 6 - }, - { - "plate": 3, - "direction": "right", - "steps": 6 - }, - { - "plate": 5, - "direction": "left", - "steps": 3 - } - ], - "historicalReference": { - "kind": "cached-observation", - "capturedAt": "2026-09-07T06:02:47.149Z", - "A": 11, - "U": 6, - "C": 40, - "plateSwitches": 10, - "commands": [ - { - "plate": 2, - "direction": "left", - "steps": 1 - }, - { - "plate": 4, - "direction": "right", - "steps": 1 - }, - { - "plate": 3, - "direction": "right", - "steps": 6 - }, - { - "plate": 2, - "direction": "left", - "steps": 3 - }, - { - "plate": 5, - "direction": "left", - "steps": 3 - }, - { - "plate": 6, - "direction": "left", - "steps": 3 - }, - { - "plate": 3, - "direction": "right", - "steps": 6 - }, - { - "plate": 2, - "direction": "left", - "steps": 6 - }, - { - "plate": 3, - "direction": "right", - "steps": 6 - }, - { - "plate": 5, - "direction": "left", - "steps": 3 - }, - { - "plate": 1, - "direction": "left", - "steps": 2 - } - ], - "historicalTimingMs": { - "min": 30.864245999997365, - "median": 31.402186000002985, - "max": 48.39241899999979 - } - } - }, - { - "id": "grim-chest", - "title": "Сундук Грима", - "titleEn": "Grim's chest", - "url": "https://unlockmyloot.com/?lock=aFhwAAABhgAAKY", - "code": "aFhwAAABhgAAKY", - "flip": false, - "description": "Сундук старателя Грима в Старом лагере - рядом с Диего у ворот замка (слева от него); завязан на квест «Амулет»", - "catalogMetadata": "6 плиток · 21 ход · 8 связей", - "provenance": { - "commit": "eee0bf50ebcb7fffd2b47954fd76bb015854e365", - "catalog": "locks/index.html", - "catalog_line": 677, - "english_catalog": "en/locks/index.html", - "landing_pages": [], - "source_notes": "locks-research.md", - "supplied_by": null, - "research_line": 419, - "research_section": "### Сундук Грима (Старый лагерь)", - "research_provenance": [ - "- Источник: Грим — старатель Старого лагеря, живёт рядом с Диего у ворот замка" - ] - }, - "definition": { - "state": [ - 5, - 2, - 4, - 1, - 4, - 5 - ], - "links": [ - [ - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 1, - -1, - 0 - ], - [ - 1, - -1, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - -1, - -1, - 1, - -1, - 0 - ] - ] - }, - "expectedActions": 8, - "baselineCommands": [ - { - "plate": 1, - "direction": "right", - "steps": 4 - }, - { - "plate": 2, - "direction": "left", - "steps": 5 - }, - { - "plate": 4, - "direction": "left", - "steps": 5 - }, - { - "plate": 1, - "direction": "right", - "steps": 2 - }, - { - "plate": 2, - "direction": "left", - "steps": 1 - }, - { - "plate": 3, - "direction": "right", - "steps": 1 - }, - { - "plate": 5, - "direction": "right", - "steps": 2 - }, - { - "plate": 6, - "direction": "right", - "steps": 1 - } - ], - "historicalReference": { - "kind": "cached-observation", - "capturedAt": "2026-09-07T06:02:47.149Z", - "A": 8, - "U": 6, - "C": 21, - "plateSwitches": 7, - "commands": [ - { - "plate": 2, - "direction": "left", - "steps": 4 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - }, - { - "plate": 4, - "direction": "left", - "steps": 5 - }, - { - "plate": 6, - "direction": "right", - "steps": 1 - }, - { - "plate": 5, - "direction": "right", - "steps": 2 - }, - { - "plate": 3, - "direction": "right", - "steps": 1 - }, - { - "plate": 2, - "direction": "left", - "steps": 2 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - } - ], - "historicalTimingMs": { - "min": 20.744904999999562, - "median": 21.38251400000081, - "max": 25.32780400000047 - } - } - }, - { - "id": "mercenary-near-grim-chest", - "title": "Сундук наёмника (у Грима)", - "titleEn": "Mercenary's chest (by Grim)", - "url": "https://unlockmyloot.com/?lock=ZA4gEWAAAAASAQ", - "code": "ZA4gEWAAAAASAQ", - "flip": false, - "description": "Сундук наёмника слева от Грима в Старом лагере, рядом с Диего у ворот замка", - "catalogMetadata": "6 плиток · 25 ходов · 7 связей", - "provenance": { - "commit": "eee0bf50ebcb7fffd2b47954fd76bb015854e365", - "catalog": "locks/index.html", - "catalog_line": 683, - "english_catalog": "en/locks/index.html", - "landing_pages": [], - "source_notes": "locks-research.md", - "supplied_by": null, - "research_line": 430, - "research_section": "### Сундук наёмника у Грима (Старый лагерь)", - "research_provenance": [ - "- Источник: Лёша. Безымянный наёмник слева от Грима. Имени в гайдах нет —" - ] - }, - "definition": { - "state": [ - 3, - 1, - 2, - 7, - 2, - 1 - ], - "links": [ - [ - 0, - 0, - 0, - 1, - 0, - 1 - ], - [ - 1, - 0, - -1, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 1, - 0, - 0, - -1 - ], - [ - 0, - 0, - 0, - 1, - 0, - 0 - ] - ] - }, - "expectedActions": 8, - "baselineCommands": [ - { - "plate": 3, - "direction": "left", - "steps": 3 - }, - { - "plate": 2, - "direction": "left", - "steps": 3 - }, - { - "plate": 4, - "direction": "right", - "steps": 6 - }, - { - "plate": 6, - "direction": "left", - "steps": 6 - }, - { - "plate": 1, - "direction": "right", - "steps": 2 - }, - { - "plate": 4, - "direction": "right", - "steps": 2 - }, - { - "plate": 5, - "direction": "left", - "steps": 2 - }, - { - "plate": 6, - "direction": "left", - "steps": 1 - } - ], - "historicalReference": { - "kind": "cached-observation", - "capturedAt": "2026-09-07T06:02:47.149Z", - "A": 8, - "U": 6, - "C": 25, - "plateSwitches": 7, - "commands": [ - { - "plate": 4, - "direction": "right", - "steps": 6 - }, - { - "plate": 6, - "direction": "left", - "steps": 6 - }, - { - "plate": 5, - "direction": "left", - "steps": 2 - }, - { - "plate": 4, - "direction": "right", - "steps": 2 - }, - { - "plate": 6, - "direction": "left", - "steps": 1 - }, - { - "plate": 3, - "direction": "left", - "steps": 3 - }, - { - "plate": 2, - "direction": "left", - "steps": 3 - }, - { - "plate": 1, - "direction": "right", - "steps": 2 - } - ], - "historicalTimingMs": { - "min": 30.25687200000175, - "median": 31.47373399999924, - "max": 35.279522999997425 - } - } - }, - { - "id": "popular-lock", - "title": "Популярный замок", - "titleEn": "Popular lock", - "url": "https://unlockmyloot.com/locks/popular-lock/", - "code": "TAmBLEwMAE", - "flip": false, - "description": "Частый замок из копилки - полный набор связей в коде", - "catalogMetadata": "5 плиток · 46 ходов · 10 связей", - "provenance": { - "commit": "eee0bf50ebcb7fffd2b47954fd76bb015854e365", - "catalog": "locks/index.html", - "catalog_line": 689, - "english_catalog": "en/locks/index.html", - "landing_pages": [ - "locks/popular-lock/index.html", - "en/locks/popular-lock/index.html" - ], - "source_notes": "locks-research.md", - "supplied_by": null, - "research_line": 269, - "research_section": "### 5w. Популярный замок (5 плиток)", - "research_provenance": [ - "- СТАТУС: в каталоге." - ] - }, - "definition": { - "state": [ - 7, - 1, - 2, - 2, - 5 - ], - "links": [ - [ - 0, - 0, - 0, - -1, - 1 - ], - [ - 1, - 0, - -1, - 0, - -1 - ], - [ - 1, - -1, - 0, - 0, - 0 - ], - [ - 1, - -1, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - -1, - 0 - ] - ] - }, - "expectedActions": 13, - "baselineCommands": [ - { - "plate": 3, - "direction": "right", - "steps": 1 - }, - { - "plate": 4, - "direction": "right", - "steps": 1 - }, - { - "plate": 2, - "direction": "right", - "steps": 2 - }, - { - "plate": 5, - "direction": "right", - "steps": 6 - }, - { - "plate": 1, - "direction": "left", - "steps": 4 - }, - { - "plate": 5, - "direction": "right", - "steps": 4 - }, - { - "plate": 4, - "direction": "right", - "steps": 4 - }, - { - "plate": 1, - "direction": "left", - "steps": 2 - }, - { - "plate": 2, - "direction": "right", - "steps": 4 - }, - { - "plate": 5, - "direction": "right", - "steps": 6 - }, - { - "plate": 1, - "direction": "left", - "steps": 6 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 5, - "direction": "right", - "steps": 3 - } - ], - "historicalReference": { - "kind": "cached-observation", - "capturedAt": "2026-09-07T06:02:47.149Z", - "A": 13, - "U": 5, - "C": 46, - "plateSwitches": 12, - "commands": [ - { - "plate": 5, - "direction": "right", - "steps": 4 - }, - { - "plate": 4, - "direction": "right", - "steps": 2 - }, - { - "plate": 3, - "direction": "right", - "steps": 1 - }, - { - "plate": 2, - "direction": "right", - "steps": 3 - }, - { - "plate": 5, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "left", - "steps": 6 - }, - { - "plate": 5, - "direction": "right", - "steps": 6 - }, - { - "plate": 4, - "direction": "right", - "steps": 3 - }, - { - "plate": 2, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "left", - "steps": 3 - }, - { - "plate": 5, - "direction": "right", - "steps": 6 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "left", - "steps": 3 - } - ], - "historicalTimingMs": { - "min": 6.609628999998677, - "median": 8.722789999999804, - "max": 12.860713000001851 - } - } - }, - { - "id": "alberto-mine-hut-chest", - "title": "Сундук в хижине шахты Альберто", - "titleEn": "Chest in Alberto's mine hut", - "url": "https://unlockmyloot.com/locks/alberto-mine-hut-chest/", - "code": "ULWBAQhAKg", - "flip": true, - "description": "1-й этаж хижины у шахты Альберто", - "catalogMetadata": "5 плиток · 36 ходов · 7 связей", - "provenance": { - "commit": "eee0bf50ebcb7fffd2b47954fd76bb015854e365", - "catalog": "locks/index.html", - "catalog_line": 695, - "english_catalog": "en/locks/index.html", - "landing_pages": [ - "locks/alberto-mine-hut-chest/index.html", - "en/locks/alberto-mine-hut-chest/index.html" - ], - "source_notes": "locks-research.md", - "supplied_by": "nepetacataria · DTF", - "research_line": 277, - "research_section": "### 5x. Сундук в хижине шахты Альберто", - "research_provenance": [ - "- Источник: nepetacataria, DTF (https://dtf.ru/id3493234), [комментарий](https://dtf.ru/games/5121406-kak-vskryt-zamki-v-gothic-1-remake?comment=65516715).", - "- СТАТУС: в каталоге." - ] - }, - "definition": { - "state": [ - 1, - 3, - 7, - 6, - 5 - ], - "links": [ - [ - 0, - 0, - 0, - -1, - 0 - ], - [ - 0, - 0, - 0, - -1, - 0 - ], - [ - 1, - 0, - 0, - 0, - -1 - ], - [ - 0, - 0, - 0, - 0, - 1 - ], - [ - 1, - 1, - 0, - 0, - 0 - ] - ] - }, - "expectedActions": 9, - "baselineCommands": [ - { - "plate": 4, - "direction": "right", - "steps": 4 - }, - { - "plate": 2, - "direction": "right", - "steps": 2 - }, - { - "plate": 5, - "direction": "left", - "steps": 6 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - }, - { - "plate": 4, - "direction": "right", - "steps": 6 - }, - { - "plate": 2, - "direction": "right", - "steps": 6 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 4, - "direction": "right", - "steps": 3 - }, - { - "plate": 5, - "direction": "left", - "steps": 3 - } - ], - "historicalReference": { - "kind": "cached-observation", - "capturedAt": "2026-09-07T06:02:47.149Z", - "A": 9, - "U": 5, - "C": 36, - "plateSwitches": 8, - "commands": [ - { - "plate": 4, - "direction": "right", - "steps": 4 - }, - { - "plate": 5, - "direction": "left", - "steps": 3 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 2, - "direction": "right", - "steps": 5 - }, - { - "plate": 4, - "direction": "right", - "steps": 6 - }, - { - "plate": 5, - "direction": "left", - "steps": 6 - }, - { - "plate": 2, - "direction": "right", - "steps": 3 - }, - { - "plate": 4, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - } - ], - "historicalTimingMs": { - "min": 5.769542999998521, - "median": 6.056866999999329, - "max": 6.331880999998248 - } - } - }, - { - "id": "alberto-mine-upstairs-left-chest", - "title": "Левый сундук наверху в шахте Альберто", - "titleEn": "Left chest upstairs in Alberto's mine", - "url": "https://unlockmyloot.com/locks/alberto-mine-upstairs-left-chest/", - "code": "MEAIAho", - "flip": true, - "description": "Наверху по лестнице - левый сундук", - "catalogMetadata": "4 плитки · 20 ходов · 5 связей", - "provenance": { - "commit": "eee0bf50ebcb7fffd2b47954fd76bb015854e365", - "catalog": "locks/index.html", - "catalog_line": 702, - "english_catalog": "en/locks/index.html", - "landing_pages": [ - "locks/alberto-mine-upstairs-left-chest/index.html", - "en/locks/alberto-mine-upstairs-left-chest/index.html" - ], - "source_notes": "locks-research.md", - "supplied_by": "nepetacataria · DTF", - "research_line": 284, - "research_section": "### 5y. Левый сундук наверху в шахте Альберто", - "research_provenance": [ - "- Источник: nepetacataria · DTF (тот же комментарий).", - "- СТАТУС: в каталоге." - ] - }, - "definition": { - "state": [ - 1, - 2, - 1, - 1 - ], - "links": [ - [ - 0, - 0, - 0, - -1 - ], - [ - 0, - 0, - 0, - 0 - ], - [ - 0, - -1, - 0, - 0 - ], - [ - 1, - -1, - -1, - 0 - ] - ] - }, - "expectedActions": 4, - "baselineCommands": [ - { - "plate": 2, - "direction": "left", - "steps": 5 - }, - { - "plate": 3, - "direction": "left", - "steps": 6 - }, - { - "plate": 2, - "direction": "left", - "steps": 6 - }, - { - "plate": 4, - "direction": "left", - "steps": 3 - } - ], - "historicalReference": { - "kind": "cached-observation", - "capturedAt": "2026-09-07T06:02:47.149Z", - "A": 4, - "U": 3, - "C": 20, - "plateSwitches": 3, - "commands": [ - { - "plate": 2, - "direction": "left", - "steps": 5 - }, - { - "plate": 3, - "direction": "left", - "steps": 6 - }, - { - "plate": 2, - "direction": "left", - "steps": 6 - }, - { - "plate": 4, - "direction": "left", - "steps": 3 - } - ], - "historicalTimingMs": { - "min": 0.34879500000170083, - "median": 0.37524499999926775, - "max": 0.3974180000004708 - } - } - }, - { - "id": "alberto-mine-upstairs-right-chest", - "title": "Правый сундук наверху в шахте Альберто", - "titleEn": "Right chest upstairs in Alberto's mine", - "url": "https://unlockmyloot.com/locks/alberto-mine-upstairs-right-chest/", - "code": "UZ1wAFAKxU", - "flip": true, - "description": "Наверху по лестнице - правый, короткий замок", - "catalogMetadata": "5 плиток · 13 ходов · 10 связей", - "provenance": { - "commit": "eee0bf50ebcb7fffd2b47954fd76bb015854e365", - "catalog": "locks/index.html", - "catalog_line": 709, - "english_catalog": "en/locks/index.html", - "landing_pages": [ - "locks/alberto-mine-upstairs-right-chest/index.html", - "en/locks/alberto-mine-upstairs-right-chest/index.html" - ], - "source_notes": "locks-research.md", - "supplied_by": "nepetacataria · DTF", - "research_line": 291, - "research_section": "### 5z. Правый сундук наверху в шахте Альберто", - "research_provenance": [ - "- Источник: nepetacataria · DTF.", - "- СТАТУС: в каталоге." - ] - }, - "definition": { - "state": [ - 1, - 7, - 4, - 6, - 4 - ], - "links": [ - [ - 0, - -1, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - -1 - ], - [ - -1, - 0, - 0, - 0, - 0 - ], - [ - 1, - 1, - 1, - 0, - -1 - ], - [ - 0, - -1, - -1, - -1, - 0 - ] - ] - }, - "expectedActions": 5, - "baselineCommands": [ - { - "plate": 1, - "direction": "left", - "steps": 3 - }, - { - "plate": 2, - "direction": "left", - "steps": 2 - }, - { - "plate": 3, - "direction": "left", - "steps": 2 - }, - { - "plate": 5, - "direction": "left", - "steps": 4 - }, - { - "plate": 4, - "direction": "left", - "steps": 2 - } - ], - "historicalReference": { - "kind": "cached-observation", - "capturedAt": "2026-09-07T06:02:47.149Z", - "A": 5, - "U": 5, - "C": 13, - "plateSwitches": 4, - "commands": [ - { - "plate": 1, - "direction": "left", - "steps": 3 - }, - { - "plate": 3, - "direction": "left", - "steps": 2 - }, - { - "plate": 2, - "direction": "left", - "steps": 2 - }, - { - "plate": 5, - "direction": "left", - "steps": 4 - }, - { - "plate": 4, - "direction": "left", - "steps": 2 - } - ], - "historicalTimingMs": { - "min": 1.5827119999994466, - "median": 1.7594979999994393, - "max": 1.899319000000105 - } - } - }, - { - "id": "cavalorn-storeroom-door", - "title": "Дверь на склад охотника Кавалорна", - "titleEn": "Hunter Cavalorn's storeroom door", - "url": "https://unlockmyloot.com/locks/cavalorn-storeroom-door/", - "code": "cuaSQAAQgoQJQA", - "flip": true, - "description": "Склад охотника Кавалорна", - "catalogMetadata": "6 плиток · 41 ход · 10 связей", - "provenance": { - "commit": "eee0bf50ebcb7fffd2b47954fd76bb015854e365", - "catalog": "locks/index.html", - "catalog_line": 716, - "english_catalog": "en/locks/index.html", - "landing_pages": [ - "locks/cavalorn-storeroom-door/index.html", - "en/locks/cavalorn-storeroom-door/index.html" - ], - "source_notes": "locks-research.md", - "supplied_by": "nepetacataria · DTF", - "research_line": 298, - "research_section": "### 5aa. Дверь на склад охотника Кавалорна", - "research_provenance": [ - "- Источник: nepetacataria · DTF.", - "- СТАТУС: в каталоге." - ] - }, - "definition": { - "state": [ - 2, - 4, - 5, - 7, - 5, - 5 - ], - "links": [ - [ - 0, - -1, - 1, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 1, - 0, - 0, - 0, - -1, - 0 - ], - [ - 0, - -1, - -1, - 0, - 0, - 1 - ], - [ - 0, - 0, - 0, - -1, - 0, - 1 - ], - [ - 1, - 0, - 0, - 0, - 0, - 0 - ] - ] - }, - "expectedActions": 11, - "baselineCommands": [ - { - "plate": 1, - "direction": "right", - "steps": 1 - }, - { - "plate": 4, - "direction": "right", - "steps": 2 - }, - { - "plate": 2, - "direction": "right", - "steps": 6 - }, - { - "plate": 6, - "direction": "left", - "steps": 4 - }, - { - "plate": 1, - "direction": "right", - "steps": 4 - }, - { - "plate": 4, - "direction": "right", - "steps": 2 - }, - { - "plate": 2, - "direction": "right", - "steps": 6 - }, - { - "plate": 5, - "direction": "right", - "steps": 4 - }, - { - "plate": 6, - "direction": "left", - "steps": 6 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 4, - "direction": "right", - "steps": 3 - } - ], - "historicalReference": { - "kind": "cached-observation", - "capturedAt": "2026-09-07T06:02:47.149Z", - "A": 11, - "U": 6, - "C": 41, - "plateSwitches": 10, - "commands": [ - { - "plate": 4, - "direction": "right", - "steps": 1 - }, - { - "plate": 5, - "direction": "right", - "steps": 1 - }, - { - "plate": 6, - "direction": "left", - "steps": 4 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "right", - "steps": 2 - }, - { - "plate": 2, - "direction": "right", - "steps": 6 - }, - { - "plate": 4, - "direction": "right", - "steps": 6 - }, - { - "plate": 6, - "direction": "left", - "steps": 6 - }, - { - "plate": 5, - "direction": "right", - "steps": 3 - }, - { - "plate": 2, - "direction": "right", - "steps": 6 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - } - ], - "historicalTimingMs": { - "min": 40.57944999999745, - "median": 42.616525000001275, - "max": 43.05461400000058 - } - } - }, - { - "id": "old-to-new-camp-bandits-chest", - "title": "Сундук у разбойников на пути в Новый лагерь", - "titleEn": "Chest by bandits on the path to the New Camp", - "url": "https://unlockmyloot.com/locks/old-to-new-camp-bandits-chest/", - "code": "cOEMSCgBGIQAAA", - "flip": true, - "description": "Двое разбойников на тропе Старый → Новый лагерь", - "catalogMetadata": "6 плиток · 33 хода · 9 связей", - "provenance": { - "commit": "eee0bf50ebcb7fffd2b47954fd76bb015854e365", - "catalog": "locks/index.html", - "catalog_line": 723, - "english_catalog": "en/locks/index.html", - "landing_pages": [ - "locks/old-to-new-camp-bandits-chest/index.html", - "en/locks/old-to-new-camp-bandits-chest/index.html" - ], - "source_notes": "locks-research.md", - "supplied_by": "nepetacataria · DTF", - "research_line": 305, - "research_section": "### 5ab. Сундук у разбойников на пути в Новый лагерь", - "research_provenance": [ - "- Источник: nepetacataria · DTF (автор уточнила направление: не в Старый, а в Новый лагерь).", - "- СТАТУС: в каталоге." - ] - }, - "definition": { - "state": [ - 1, - 4, - 5, - 2, - 1, - 4 - ], - "links": [ - [ - 0, - 0, - 1, - 0, - -1, - 0 - ], - [ - 0, - 0, - -1, - -1, - 0, - 0 - ], - [ - 0, - 0, - 0, - 1, - 0, - 1 - ], - [ - -1, - 0, - -1, - 0, - 0, - 1 - ], - [ - 0, - 0, - 0, - 0, - 0, - 0 - ], - [ - 0, - 0, - 0, - 0, - 0, - 0 - ] - ] - }, - "expectedActions": 10, - "baselineCommands": [ - { - "plate": 3, - "direction": "right", - "steps": 1 - }, - { - "plate": 5, - "direction": "left", - "steps": 6 - }, - { - "plate": 1, - "direction": "left", - "steps": 3 - }, - { - "plate": 4, - "direction": "left", - "steps": 3 - }, - { - "plate": 1, - "direction": "left", - "steps": 3 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 4, - "direction": "left", - "steps": 3 - }, - { - "plate": 5, - "direction": "left", - "steps": 6 - }, - { - "plate": 1, - "direction": "left", - "steps": 3 - }, - { - "plate": 6, - "direction": "right", - "steps": 2 - } - ], - "historicalReference": { - "kind": "cached-observation", - "capturedAt": "2026-09-07T06:02:47.149Z", - "A": 10, - "U": 5, - "C": 33, - "plateSwitches": 9, - "commands": [ - { - "plate": 6, - "direction": "right", - "steps": 2 - }, - { - "plate": 5, - "direction": "left", - "steps": 6 - }, - { - "plate": 3, - "direction": "right", - "steps": 1 - }, - { - "plate": 1, - "direction": "left", - "steps": 3 - }, - { - "plate": 4, - "direction": "left", - "steps": 3 - }, - { - "plate": 1, - "direction": "left", - "steps": 3 - }, - { - "plate": 5, - "direction": "left", - "steps": 6 - }, - { - "plate": 4, - "direction": "left", - "steps": 3 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "left", - "steps": 3 - } - ], - "historicalTimingMs": { - "min": 24.668550999998843, - "median": 24.905797999999777, - "max": 25.209614999999758 - } - } - } -] diff --git a/benchmarks/fixtures/manifest.json b/benchmarks/fixtures/manifest.json deleted file mode 100644 index 2b4ce93..0000000 --- a/benchmarks/fixtures/manifest.json +++ /dev/null @@ -1,115 +0,0 @@ -{ - "schemaVersion": 1, - "count": 45, - "catalogSha256": "0eb5b641eac4bda105bde24b60399220f1767dd13db6a312b78638b2750ba675", - "source": { - "repository": "https://github.com/1h8s/unlockmyloot", - "revision": "eee0bf50ebcb7fffd2b47954fd76bb015854e365", - "files": { - "en/locks/alberto-mine-hut-chest/index.html": "b80235a81fa161a90268a69daf2a3e53e4a3d82c70e5ef88b5e46fb2aff3c2eb", - "en/locks/alberto-mine-upstairs-left-chest/index.html": "2e6b78e62b599c297d7d6c7c9b615dec997bb4a6131705e6cc800c9b95dcc9d1", - "en/locks/alberto-mine-upstairs-right-chest/index.html": "4181816e8bb3ac662cf2aa5851bc5bfb83ecca101871b747a484e7fa7a4f2bc6", - "en/locks/arlin-dungeon-key/index.html": "d8a1b2371575e7401ec878f77484570a433aa4c36e5fd45ad204209f0afd0040", - "en/locks/arto-far-bed-chest/index.html": "07aaefc05cb0fdf0c7ee52e9c4fa480b77432fb88451ca205a73f3380c657bd0", - "en/locks/arto-near-bed-chest/index.html": "1e2aeb0a1eb27aefbe2561f7f10b42e7de314d9e6c968293ccf112be0f25f621", - "en/locks/arto-room-door/index.html": "a05c7f12071be89f0066f98e2659f40a540e2938d70972c12cd2da654e0b643f", - "en/locks/cavalorn-storeroom-door/index.html": "2f0d4ad2b762ee13abdba52cd2d95f0092737918e32028912137dc13b58b1a78", - "en/locks/cor-galom-bedroom-chest/index.html": "35b34337121619859be49315134ad706ccf9ffa83088fac03e62bbd92a94142c", - "en/locks/cor-galom-chest/index.html": "01c18921c3123fba5335ead0914c7c0b8ed1d427b42e1b0439e8c330ae6ac64b", - "en/locks/diego-chest/index.html": "c9374172928844e753687d63396e11b6eb2d699a90e171f1182e6590a465126b", - "en/locks/gomez-bedrooms-door/index.html": "2b98750357a041e4af80630b1993f95088a471afbd5e336e24bb0ac7be3e40fd", - "en/locks/gomez-bedrooms-far-chest/index.html": "3ca12f2c54056c391c8d95b987ae77536faed0cbfeb08c976bb73c706f328928", - "en/locks/gomez-bedrooms-near-chest/index.html": "d87b2e949d34e50f9cadf71d111c840b32e9e7a594060c9be19039785cc637f9", - "en/locks/gomez-bedside-chest/index.html": "e1cd6ae0053d044e513a3b45eb11f8f13b0c3302b62f3a31e77eed1f8e83fa92", - "en/locks/gomez-chambers-door/index.html": "e00302b7b8084911def5691d1e7aff17cfb24cfdc3dbaa65e1a0c7e7d4e43c50", - "en/locks/gomez-throne-hall-chest/index.html": "9c921b14877b3c7f5900045c3d4112bcff57c5949a63b2036e85616f1d4e6fb9", - "en/locks/gomez-wall-chest/index.html": "48bff3709e568b823b5b4f40b846de0ae3ebb8112ed1e59899a64b8975f1f05c", - "en/locks/gomez-wine-cellar/index.html": "e91ab32910e8f6a9478b4d4b0cc38ab6fc5223a8ae0722656f3f59e06c47bd12", - "en/locks/graham-chest/index.html": "921b8a106183a1ae260c7dd69971c78c995f9debd64a516a370bcd93cbad5d73", - "en/locks/index.html": "a1c838d45f57fa9b9ffa8f8e69017c17b2fe8da5431b00f1c37df0e0a688c9d0", - "en/locks/old-camp-dungeon-door/index.html": "54e4e0e60a41e9ee9a5412625ce4d9f4ccc186645c4bf352faaa9fa2f0df3c2e", - "en/locks/old-to-new-camp-bandits-chest/index.html": "820d8e8a93fb33135ad95897b6431b664e43c88ae22dd9f4b67be85d79761b12", - "en/locks/popular-lock/index.html": "2c3011e509fe81819262a0a26accc5bd5d049f231dd19a3f38899b435a794a35", - "en/locks/raven-tower-floor2-chest-2/index.html": "b8e7fa53c36e9066f0752420c904f00fd0be8c3f6e5040db0204a138495613a8", - "en/locks/raven-tower-floor2-stairs-chest/index.html": "3a75e3a01b3817ec4cec0f2f994d6ddae7506220a8f63d509e4ad58e328fcb17", - "en/locks/raven-tower-floor3-empty-chest/index.html": "79298f343f8b35201cf848b56ee6caa89f6053fa8f059745305a52598ed6a325", - "en/locks/raven-tower-floor3-entrance-chest/index.html": "295d2c791cebc9762b50b2d3d0171fe5e17ffb0b1dffb85dfd2eb74584f96de9", - "en/locks/raven-tower-floor4-elixirs-chest/index.html": "30a8049b0e03084984f28b6009d498958deebc20fc62c0b5a10f75602986018c", - "en/locks/raven-tower-floor4-food-chest/index.html": "614cc96c33fa62595f6d80cc6ddcb13a32234c1741f2b4517fb4b652929dd4e4", - "en/locks/raven-tower-left-chest/index.html": "fdadb796e0359dd662f5cac35e1fd7a5c6b8a2d97b47ea4705e16ffab3c4947f", - "en/locks/raven-tower-right-chest/index.html": "dc0784a332bbcd7da39306fcf154ccdf906251179dc73201af0aa385907707ca", - "en/locks/raven-tower-top-chest/index.html": "e29553bcf88d46241dc0d5e90504d8b3a760c970c585a233b8d8a83ea2d23473", - "en/locks/rice-lord-storeroom/index.html": "4c618c74718b466872be4ba378967334c16b6fdd1b9470c0bcc64bc7b2c440ad", - "en/locks/ruined-tower-door/index.html": "8b74fbcf81234090bfada0cdd7fd33cbed7f1221cd8371ecbb43e582301de0e7", - "en/locks/scatty-chest/index.html": "7775f24351a5d05255e6440851e5522ca696f71c8c8d2e19a0d48735ba5e5ed9", - "en/locks/silas-tavern-basement-chest/index.html": "78e6e411c056df5828e52e4fc488323d60a9ae0b7eeb8e764189074162cb0798", - "en/locks/swamp-camp-forge-chest/index.html": "f5124edab177b2052d16463965e1c9428e6b24ce98d0da72653f40106c63baf2", - "en/locks/voran-bedside-chest/index.html": "caa5bbe7c3da95a8e4ddffd8f646c9d11b35e41d37a0939746547ea1ddd26376", - "index.html": "691f744099a9169f30e9883fddc5fdbb4949d306f3011e601678419d7b93821b", - "locks-research.md": "b1d4ca78220e225fabb32f6e26d10d1b66dc38fed4eb8747bebd0998d9222eaa", - "locks/alberto-mine-hut-chest/index.html": "1903860a6608eec79df1bbbf8c6710a9f857f5153a4f591532ae9f08138942c5", - "locks/alberto-mine-upstairs-left-chest/index.html": "05ca7aabafa7dba63365d93bdd5fb186d46700fa1a1a9a2260117c175dc17f2b", - "locks/alberto-mine-upstairs-right-chest/index.html": "0c15a419ebf82a572eb5119dbd213bf6df4be1731fc4d5479aa29ecde4058c94", - "locks/arlin-dungeon-key/index.html": "f0e15522b7a6ed7faf2c9d77f5e59fee1a1ea07061ecff8f50f28aef27f8295b", - "locks/arto-far-bed-chest/index.html": "22febd161e0dd560e95c62b3583a3beb8d6558d5023f5e20ebc86cbd302b4ff5", - "locks/arto-near-bed-chest/index.html": "224b8cee55849b7dabd527f0b34240a521e217e3158d69c11e14e06933ede94a", - "locks/arto-room-door/index.html": "d7530ade288e42a64ebea72e141d4814727152f83b691857b3a20bab5182adb8", - "locks/cavalorn-storeroom-door/index.html": "2423004cf2a361a68619246d60fd1303c12f61400443c674d300866c7c1850b2", - "locks/cor-galom-bedroom-chest/index.html": "92d4dc4ad5ede6aa39675230223d1cc4370adf5b88cc29f91d22493acd93e394", - "locks/cor-galom-chest/index.html": "63e943853e7b2e2c419ace53cd0b38062b16e04d2dd5c523266d69fc4c30c4c1", - "locks/diego-chest/index.html": "09244d9c13a3093202dd3e294e620a78e77845ffe9360bbe13313da6ef0fea0c", - "locks/gomez-bedrooms-door/index.html": "22c1c125225095d1d9e6042543dd051b3951ba070fedee8b0e12a0da59243615", - "locks/gomez-bedrooms-far-chest/index.html": "df38114cec6f8b9b8fd4742a85e0dc1846adc6d303cd2740bff4491ead62a635", - "locks/gomez-bedrooms-near-chest/index.html": "95023d568f4f0b19f7dff3d0cf6c203f513f12154cf1ba259f257acce0883297", - "locks/gomez-bedside-chest/index.html": "eed3a69f01a57ec2899c7d75c61b442963140633501e21dbaa9ef983808d9c6d", - "locks/gomez-chambers-door/index.html": "b99eb923fc6b59b2d946e0a7fc06875bee1742e73ded25d2c8d38e56c6f5a583", - "locks/gomez-throne-hall-chest/index.html": "c526b2dd13572ab050f3df72626b356d87c4ddf5587dbfda90e592f8a935bde3", - "locks/gomez-wall-chest/index.html": "37435fe39e8564fe41eecc23a844ff30973959cea117bc54db0f3282064ea5a6", - "locks/gomez-wine-cellar/index.html": "2dd8197e796ed1ade7c15f56ea6ade1db696dc819f2d009a69dbf5f25aab6bee", - "locks/graham-chest/index.html": "413dc9ffd2a8dc90899d7b67e543c9688353ff73b5d8595c9415cdf462565f99", - "locks/index.html": "418950accf552db95cb037466215372bac10c2477aaddd90947342b6b87c0178", - "locks/old-camp-dungeon-door/index.html": "e8a4da8b4f9d0339576ec234d258e4e3af70f2c4bf083b5049482a4007ec448c", - "locks/old-to-new-camp-bandits-chest/index.html": "6dcbfa42ffcf8e6e9a7f32ad8a55ee5a16ec0b7c807a687b39e2aa51a8c21a1a", - "locks/popular-lock/index.html": "5433134fe4308f88fc652f0fcb6dcb8d61bd09a94520398d03d960a0cb788d71", - "locks/raven-tower-floor2-chest-2/index.html": "2ace199375b0865dc182fc23c612130837bd146e2992eb4b42296d142dd68afc", - "locks/raven-tower-floor2-stairs-chest/index.html": "6a0bdf73eead24e3458fbcbc87e48c42f13a18435010e2f1a31cd2b4671019d2", - "locks/raven-tower-floor3-empty-chest/index.html": "5dd67965ab6de1f441ba9d338d25220b00a68fedde9b2beb00decb752911cb91", - "locks/raven-tower-floor3-entrance-chest/index.html": "49e87c9e1e34652da272a0efcd628b42378f3f0cea8581838f59035707607598", - "locks/raven-tower-floor4-elixirs-chest/index.html": "cba32e3137d7c7becd2400bf15b75e6e4ef73c87fea5db7a1e89595cc19d3b9d", - "locks/raven-tower-floor4-food-chest/index.html": "713f3b65b250865775590854c4a52609ae80c6491c5bd1fc4c78d6b335235e5b", - "locks/raven-tower-left-chest/index.html": "44784aba6a21aa5883cb8d304b360264cc04cab1f371b624dd53c478b3bcba79", - "locks/raven-tower-right-chest/index.html": "8ab61196263291560d5f59a6fcdb8cf1a842711cdd6c1d70adc32d6ff9fbf44c", - "locks/raven-tower-top-chest/index.html": "eaeb59365f3a3a3a11dae19770697aec05c88e94643cc985a44f89de2c7e83db", - "locks/rice-lord-storeroom/index.html": "eff6425d39e668a60849ba863011517d6640e5a8ebdfabc7b81c8df0aed3186e", - "locks/ruined-tower-door/index.html": "2fc1bab43602d91fb30d10eea967258dcbdc051659db15f8736563fb0d5d1a8d", - "locks/scatty-chest/index.html": "18a6fa9f77b21ef668363288691fed5831c07ecda5ffd0a95d133b3929af59a9", - "locks/silas-tavern-basement-chest/index.html": "0e825fadee620075fdab0ea0f5d2ed754d94028b0f2890bdfe422dbe363187c8", - "locks/swamp-camp-forge-chest/index.html": "95bd46afd84e6dc241929060fccbf2e09f14e83f401352c07107861e12f318b6", - "locks/voran-bedside-chest/index.html": "c527200baac6eedadea1b6f4b3110326fe66fbc5183789f5b3d579b9513e9b0f" - }, - "solver": { - "path": "index.html", - "sha256": "691f744099a9169f30e9883fddc5fdbb4949d306f3011e601678419d7b93821b", - "blockSha256": "d5bc85f40fd7c5b61097403df399ecb7dfd2b58924dc7a670aa0d05306017380", - "startMarker": "/* solver-start", - "endMarker": "/* solver-end */" - } - }, - "importEvidence": { - "files": { - "catalog.json": "7e5ac1075ef6bc749e52a1393c3b1c49e53b14d106516f1bb67b2c3e4a410b7d", - "benchmark.json": "2e96ed1c560d051c1b5de2a13be74bee747795a796ebe2c44a6de5812d8b82d7", - "alternatives.json": "72f9c28f1c612ca752e6f5c0b2f9964dd38c8d267c36af6fc334d5f2f9f0f6bf" - }, - "expectedActions": "Exact grouped-command BFS from Apache baseline, cross-checked with optimized BFS and matrix prototype. Rechecked against current exact BFS in tests and every benchmark run.", - "historicalEnvironment": { - "date": "2026-09-07T06:02:47.149Z", - "node": "v24.19.0", - "cpu": "AMD EPYC 9V74 80-Core Processor", - "platform": "linux", - "arch": "x64", - "repetitions": 5 - }, - "historicalReference": "Historical timings are provenance only; current timing comparisons require an explicitly supplied verified reference checkout." - } -} diff --git a/benchmarks/harness.mjs b/benchmarks/harness.mjs deleted file mode 100644 index e7e8577..0000000 --- a/benchmarks/harness.mjs +++ /dev/null @@ -1,403 +0,0 @@ -import assert from 'node:assert/strict'; -import { execFileSync } from 'node:child_process'; -import { randomUUID } from 'node:crypto'; -import { readFileSync, readdirSync } from 'node:fs'; -import { mkdir, mkdtemp, rename, rm, writeFile } from 'node:fs/promises'; -import os from 'node:os'; -import { join, relative, resolve } from 'node:path'; -import { performance } from 'node:perf_hooks'; -import { fileURLToPath } from 'node:url'; -import { solveLock } from '../src/index.mjs'; -import { solveLock as baselineSolveLock } from './baseline/src/index.mjs'; -import { loadCatalog, loadManifest, sha256 } from './catalog.mjs'; -import { commandMetrics, groupReferenceClicks, replayCommands } from './validation.mjs'; - -const root = fileURLToPath(new URL('../', import.meta.url)); -const baselineRoot = fileURLToPath(new URL('./baseline/', import.meta.url)); - -export function statistics(samples) { - if (!Array.isArray(samples) || samples.length === 0 - || samples.some((sample) => !Number.isFinite(sample) || sample < 0)) { - throw new Error('Timing samples must be a nonempty array of finite nonnegative numbers.'); - } - const sorted = [...samples].sort((a, b) => a - b); - const middle = Math.floor(sorted.length / 2); - return { - min: sorted[0], - median: sorted.length % 2 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2, - max: sorted.at(-1), - }; -} - -function validateCount(value, name, minimum) { - if (!Number.isSafeInteger(value) || value < minimum) { - throw new Error(`${name} must be a safe integer >= ${minimum}.`); - } -} - -export function parseBenchmarkArgs(args) { - const options = { repetitions: 5, warmups: 1, outputDir: 'docs/benchmarks', reference: null, smoke: false, help: false }; - const seen = new Set(); - const fields = { '--repetitions': 'repetitions', '--warmups': 'warmups', - '--output-dir': 'outputDir', '--reference': 'reference' }; - for (let index = 0; index < args.length; index += 1) { - const argument = args[index]; - if (seen.has(argument)) throw new Error(`Duplicate argument: ${argument}`); - seen.add(argument); - if (argument === '--smoke' || argument === '--help') { - options[argument.slice(2)] = true; - continue; - } - const field = fields[argument]; - if (!field) throw new Error(`Unknown argument: ${argument}`); - const value = args[++index]; - if (value === undefined || value.length === 0 || value.startsWith('--')) { - throw new Error(`Missing argument value: ${argument}`); - } - if (field === 'repetitions' || field === 'warmups') { - if (!/^(0|[1-9][0-9]*)$/.test(value)) throw new Error(`${argument} requires an integer.`); - options[field] = Number(value); - } else options[field] = value; - } - validateCount(options.repetitions, 'repetitions', 1); - validateCount(options.warmups, 'warmups', 0); - return options; -} - -function git(directory, args) { - return execFileSync('git', ['-C', directory, ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim(); -} - -export function verifyBaseline() { - const manifest = JSON.parse(readFileSync(join(baselineRoot, 'manifest.json'), 'utf8')); - for (const [path, expected] of Object.entries(manifest.files)) { - if (sha256(readFileSync(join(baselineRoot, path))) !== expected) { - throw new Error(`Apache baseline source hash mismatch: ${path}`); - } - } - return manifest; -} - -export function verifyReferenceSource({ revision, html }, source = loadManifest().source) { - if (revision !== source.revision) { - throw new Error(`Reference revision mismatch: expected ${source.revision}, received ${revision}.`); - } - if (sha256(html) !== source.solver.sha256) throw new Error('Reference index.html source hash mismatch.'); - const text = Buffer.isBuffer(html) ? html.toString('utf8') : html; - const start = text.indexOf(source.solver.startMarker); - const end = text.indexOf(source.solver.endMarker, start); - if (start < 0 || end < start) throw new Error('Reference solver markers are missing.'); - const block = text.slice(start, end + source.solver.endMarker.length); - if (sha256(block) !== source.solver.blockSha256) throw new Error('Reference solver block hash mismatch.'); - return block; -} - -export function loadReference(directory) { - const source = loadManifest().source; - const checkout = resolve(directory); - let revision; - try { revision = git(checkout, ['rev-parse', 'HEAD']); } catch { - throw new Error(`Cannot read reference checkout revision: ${checkout}`); - } - const html = readFileSync(join(checkout, source.solver.path)); - const block = verifyReferenceSource({ revision, html }, source); - // The hash-verified, unchanged upstream block is compiled in this Node realm. - // This avoids cross-realm Array/typed-array overhead and does not vendor AGPL code. - const reference = Function(`${block}\n; return solveLock;`)(); - if (typeof reference !== 'function') throw new Error('Reference solveLock function is missing.'); - return { - implementation: { - label: 'UnlockMyLoot (live pinned reference)', requiresOptimality: false, - solve: (definition) => reference(definition.state, definition.links), - normalize: (sequence) => { - const commands = groupReferenceClicks(sequence); - return { status: commands === null ? 'unsolvable' : 'solved', commands }; - }, - }, - metadata: { measured: true, repository: source.repository, revision, path: source.solver.path, - fileSha256: source.solver.sha256, solverBlockSha256: source.solver.blockSha256, - execution: 'Unchanged source compiled with Function in the same Node.js realm; no browser or vm context.' }, - }; -} - -export function defaultImplementations(reference = null) { - const normalize = (result) => result; - const implementations = { - 'matrix-astar': { label: 'Matrix A*', solve: (definition) => solveLock(definition, { algorithm: 'matrix-astar' }), normalize, requiresOptimality: true }, - bfs: { label: 'Current exact BFS', solve: (definition) => solveLock(definition, { algorithm: 'bfs' }), normalize, requiresOptimality: true }, - baseline: { label: 'Original Apache BFS', solve: baselineSolveLock, normalize, requiresOptimality: true }, - }; - if (reference) implementations.reference = reference; - return implementations; -} - -function hashTree(directory, predicate = () => true) { - const files = {}; - function visit(path) { - for (const entry of readdirSync(path, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) { - const full = join(path, entry.name); - if (entry.isDirectory()) visit(full); - else if (entry.isFile() && predicate(full)) files[relative(directory, full)] = sha256(readFileSync(full)); - } - } - visit(directory); - return { sha256: sha256(JSON.stringify(files)), files }; -} - -export function collectMetadata(referenceMetadata = null) { - let revision = null; let dirty = null; - try { - revision = git(root, ['rev-parse', 'HEAD']); - dirty = git(root, ['status', '--porcelain', '--untracked-files=normal']).length > 0; - } catch { /* Source hashes remain available outside a Git checkout. */ } - const manifest = loadManifest(); - return { - environment: { node: process.version, v8: process.versions.v8, platform: process.platform, - arch: process.arch, osRelease: os.release(), cpu: os.cpus()[0]?.model ?? null, - logicalCpus: os.cpus().length, totalMemoryBytes: os.totalmem(), execArgv: process.execArgv }, - source: { repository: 'https://github.com/Marcus-Rise/gothic-lock-solver', revision, - dirty, core: hashTree(join(root, 'src'), (path) => path.endsWith('.mjs')), - harness: { ...hashTree(join(root, 'benchmarks'), (path) => path.endsWith('.mjs')), - cliSha256: sha256(readFileSync(join(root, 'scripts/benchmark.mjs'))) } }, - fixtures: { count: manifest.count, catalogSha256: manifest.catalogSha256, - manifestSha256: sha256(readFileSync(new URL('./fixtures/manifest.json', import.meta.url))), - repository: manifest.source.repository, revision: manifest.source.revision }, - baseline: verifyBaseline(), - reference: referenceMetadata ?? { measured: false, reason: 'No --reference checkout supplied; cached metrics are not live timings.', - repository: manifest.source.repository, revision: manifest.source.revision }, - }; -} - -function assertMetricsEqual(actual, expected, message) { - for (const field of ['A', 'U', 'C', 'plateSwitches']) { - assert.equal(actual[field], expected[field], `${message}: ${field}`); - } -} - -function validateResult(lock, name, implementation, result) { - assert.equal(result?.status, 'solved', `${lock.id}/${name}: expected a solved result.`); - const finalState = replayCommands(lock.definition, result.commands); - assert.deepEqual(finalState, lock.definition.state.map(() => 4), `${lock.id}/${name}: target state not reached.`); - const metrics = commandMetrics(result.commands); - if (implementation.requiresOptimality) { - assert.equal(metrics.A, lock.expectedActions, `${lock.id}/${name}: wrong minimum action count.`); - } else { - assert.ok(metrics.A >= lock.expectedActions, `${lock.id}/${name}: reference beats the claimed exact minimum.`); - } - if (name === 'bfs' || name === 'baseline') { - assert.deepEqual(result.commands, lock.baselineCommands, `${lock.id}/${name}: original BFS order changed.`); - } - if (name === 'reference') { - assertMetricsEqual(metrics, lock.historicalReference, `${lock.id}: historical reference metric drift`); - assert.deepEqual(result.commands, lock.historicalReference.commands, `${lock.id}: reference path drift.`); - } - if (result.metrics) { - assert.equal(result.metrics.commands, metrics.A, `${lock.id}/${name}: result action metric mismatch.`); - assert.equal(result.metrics.divisions, metrics.C, `${lock.id}/${name}: result division metric mismatch.`); - assert.equal(result.metrics.plateSwitches, metrics.plateSwitches, `${lock.id}/${name}: result switch metric mismatch.`); - } - for (const [field, expected] of [['initialState', lock.definition.state], ['targetState', finalState], ['finalState', finalState]]) { - if (result[field]) assert.deepEqual(result[field], expected, `${lock.id}/${name}: invalid ${field}.`); - } - return { ...metrics, commands: structuredClone(result.commands) }; -} - -function metricComparison(rows, getComparator, field) { - const result = { wins: 0, ties: 0, losses: 0 }; - for (const row of rows) { - const matrix = row.solvers['matrix-astar'][field]; - const other = getComparator(row)[field]; - result[matrix < other ? 'wins' : matrix > other ? 'losses' : 'ties'] += 1; - } - return result; -} - -function summarize(rows, names) { - const aggregates = {}; - for (const name of names) { - aggregates[name] = rows.reduce((total, row) => { - const result = row.solvers[name]; - for (const field of ['A', 'U', 'C']) total[field] += result[field]; - total.sumOfMediansMs += result.timingMs.median; - return total; - }, { A: 0, U: 0, C: 0, sumOfMediansMs: 0 }); - } - const comparisons = {}; - for (const name of [...names.filter((name) => name !== 'matrix-astar'), 'historicalReference']) { - const historic = name === 'historicalReference'; - const get = (row) => historic ? row.historicalReference : row.solvers[name]; - comparisons[name] = { actions: metricComparison(rows, get, 'A'), - uniquePlates: metricComparison(rows, get, 'U'), clicks: metricComparison(rows, get, 'C'), - timingCompared: !historic }; - if (!historic) { - const values = rows.map((row) => ({ A: row.solvers['matrix-astar'].timingMs.median, B: get(row).timingMs.median })); - const ratios = values.map(({ A, B }) => A > 0 && B > 0 ? B / A : null); - comparisons[name].timing = { - wins: values.filter(({ A, B }) => A < B).length, - ties: values.filter(({ A, B }) => A === B).length, - losses: values.filter(({ A, B }) => A > B).length, - geometricMeanSpeedup: ratios.every((value) => value !== null) - ? Math.exp(ratios.reduce((sum, value) => sum + Math.log(value), 0) / ratios.length) : null, - ratioOfSummedMedians: aggregates['matrix-astar'].sumOfMediansMs > 0 - ? aggregates[name].sumOfMediansMs / aggregates['matrix-astar'].sumOfMediansMs : null, - }; - } - } - return { aggregates, comparisons }; -} - -export function runBenchmark({ locks = loadCatalog(), implementations = defaultImplementations(), - repetitions = 5, warmups = 1, metadata = {}, clock = () => performance.now(), - onProgress = null } = {}) { - validateCount(repetitions, 'repetitions', 1); - validateCount(warmups, 'warmups', 0); - if (!Array.isArray(locks) || locks.length === 0) throw new Error('At least one benchmark lock is required.'); - if (new Set(locks.map((lock) => lock.id)).size !== locks.length) throw new Error('Duplicate benchmark lock identifiers.'); - const names = Object.keys(implementations); - for (const name of ['matrix-astar', 'bfs', 'baseline']) { - if (!implementations[name]) throw new Error(`Missing benchmark implementation: ${name}`); - } - const results = []; - for (const [lockIndex, lock] of locks.entries()) { - validateCount(lock.expectedActions, 'expectedActions', 0); - const historicalMetrics = commandMetrics(lock.historicalReference.commands); - assertMetricsEqual(historicalMetrics, lock.historicalReference, `${lock.id}: cached historical reference metrics`); - assert.deepEqual(replayCommands(lock.definition, lock.historicalReference.commands), - lock.definition.state.map(() => 4), `${lock.id}: cached reference path must reach target.`); - const baselineMetrics = commandMetrics(lock.baselineCommands); - assert.equal(baselineMetrics.A, lock.expectedActions, `${lock.id}: cached BFS minimum mismatch.`); - assert.deepEqual(replayCommands(lock.definition, lock.baselineCommands), - lock.definition.state.map(() => 4), `${lock.id}: cached baseline path must reach target.`); - const measured = Object.fromEntries(names.map((name) => [name, { samplesMs: [], solution: null }])); - for (let round = -warmups; round < repetitions; round += 1) { - const offset = (lockIndex + round + warmups) % names.length; - for (let index = 0; index < names.length; index += 1) { - const name = names[(offset + index) % names.length]; - const implementation = implementations[name]; - const definition = structuredClone(lock.definition); - const before = JSON.stringify(definition); - const start = clock(); - const raw = implementation.solve(definition); - const elapsed = clock() - start; - assert.equal(JSON.stringify(definition), before, `${lock.id}/${name}: solver mutated its input.`); - const solution = validateResult(lock, name, implementation, implementation.normalize(raw)); - const entry = measured[name]; - if (entry.solution) assert.deepEqual(solution, entry.solution, `${lock.id}/${name}: nondeterministic solution.`); - entry.solution = solution; - if (round >= 0) entry.samplesMs.push(elapsed); - } - } - const solvers = Object.fromEntries(names.map((name) => [name, { ...measured[name].solution, - timingMs: statistics(measured[name].samplesMs), samplesMs: measured[name].samplesMs }])); - results.push({ id: lock.id, title: lock.title, plateCount: lock.definition.state.length, - url: lock.url ?? null, code: lock.code ?? null, expectedActions: lock.expectedActions, - solvers, historicalReference: structuredClone(lock.historicalReference) }); - if (onProgress) onProgress({ completed: lockIndex + 1, total: locks.length, id: lock.id }); - } - const inputEvidence = (entries) => JSON.stringify(entries.map(({ id, definition, expectedActions }) => ({ id, definition, expectedActions }))); - const inputs = inputEvidence(locks); - const completeCatalog = inputs === inputEvidence(loadCatalog()); - return { - schemaVersion: 1, createdAt: new Date().toISOString(), - config: { repetitions, warmups, lockCount: locks.length, scope: completeCatalog ? 'full-45-catalog' : 'subset', - inputSha256: sha256(inputs), - units: 'milliseconds', executionOrder: 'Per lock, warmup and measured rounds rotate solver order deterministically.', - timingBoundary: 'Only the synchronous solver call is timed. Input cloning, reference click grouping, replay, metrics, and reporting are outside timing. Current and baseline calls include their public facade validation/result construction.', - gc: 'Natural Node.js garbage collection; no forced GC.' }, - metadata, implementations: Object.fromEntries(names.map((name) => [name, { label: implementations[name].label, - requiresOptimality: implementations[name].requiresOptimality }])), - correctness: { passed: true, lockCount: locks.length, checks: [ - 'Every warmup and measured solution independently replayed one division at a time.', - 'Matrix A*, exact BFS, and original Apache BFS equal the stored exact action minimum.', - 'Current BFS preserves the pinned original BFS command sequence.', - 'Inputs are unchanged and every implementation returns a deterministic path.', - names.includes('reference') ? 'Live reference path and A/U/C equal the cached pinned observation.' - : 'Cached reference paths and metrics are validated; reference was not timed.', - ] }, - ...summarize(results, names), results, - limitations: [ - 'These are 45 fixed catalog configurations, not a random sample or a claim about every game state.', - 'Only actions (A) are minimized by the production solver; distinct plates (U) and unit divisions (C) are descriptive metrics.', - 'The reference minimizes unit divisions before plate switches; its objective differs from minimum grouped actions.', - 'Timing reflects this runtime, machine, sample count, JIT and garbage collection; no timing threshold is a correctness gate.', - 'These are Node.js timings, not measurements in an actual browser or Web Worker.', - 'Cached historical timings are provenance only and are excluded from current speed comparisons.', - 'The sum of per-lock medians is an aggregate of medians, not the median runtime of a whole-catalog pass.', - 'Speedup ratios above 1 favor matrix A*; ratios below 1 favor the comparator. Timing ties require equal measured medians.', - ], - }; -} - -const cell = (value) => String(value ?? '—').replaceAll('|', '\\|').replaceAll('\n', ' '); -const number = (value) => value === null || value === undefined ? '—' : value.toFixed(6); -const counts = (value) => `${value.wins}/${value.ties}/${value.losses}`; - -export function renderMarkdown(report) { - const names = Object.keys(report.implementations); - const lines = ['# Matrix search benchmark', '', - `Generated: ${report.createdAt}. Scope: **${report.config.scope}** (${report.config.lockCount} locks).`, '', - `Correctness gates: **${report.correctness.passed ? 'PASS' : 'FAIL'}**. Warmups per solver/lock: ${report.config.warmups}; measured repetitions: ${report.config.repetitions}.`, '', - 'A = actions (grouped commands); U = unique selected plates; C = unit divisions of the selected plate. All timings are milliseconds.', '', - report.config.timingBoundary, '', report.config.executionOrder, '', - `GC: ${report.config.gc}`, '', '## Runtime and source evidence', '', - '```json', JSON.stringify(report.metadata, null, 2), '```', '', - '## Totals across measured locks', '', - '| Solver | A | U | C | Sum of per-lock median ms |', - '| --- | ---: | ---: | ---: | ---: |']; - for (const name of names) { - const value = report.aggregates[name]; - lines.push(`| ${cell(report.implementations[name].label)} | ${value.A} | ${value.U} | ${value.C} | ${number(value.sumOfMediansMs)} |`); - } - lines.push('', '## Matrix A* comparisons', '', - 'Wins/ties/losses count locks where matrix A* is lower/equal/higher. A ratio above 1 favors matrix A*.', '', - '| Comparator | A wins/ties/losses | U wins/ties/losses | C wins/ties/losses | Time wins/ties/losses | Geometric mean speedup | Ratio of summed medians |', - '| --- | --- | --- | --- | --- | ---: | ---: |'); - for (const [name, value] of Object.entries(report.comparisons)) { - lines.push(`| ${cell(report.implementations[name]?.label ?? 'Reference (cached metrics only)')} | ${counts(value.actions)} | ${counts(value.uniquePlates)} | ${counts(value.clicks)} | ${value.timingCompared ? counts(value.timing) : 'not timed'} | ${number(value.timing?.geometricMeanSpeedup)} | ${number(value.timing?.ratioOfSummedMedians)} |`); - } - lines.push('', '## Per-lock action metrics', '', - `| Lock | N | Exact minimum A | ${names.map((name) => `${cell(report.implementations[name].label)} A/U/C`).join(' | ')} | Cached reference A/U/C |`, - `| --- | ---: | ---: | ${names.map(() => '---').join(' | ')} | --- |`); - for (const row of report.results) { - const metrics = (result) => `${result.A}/${result.U}/${result.C}`; - lines.push(`| ${cell(row.title)} (${row.id}) | ${row.plateCount} | ${row.expectedActions} | ${names.map((name) => metrics(row.solvers[name])).join(' | ')} | ${metrics(row.historicalReference)} |`); - } - lines.push('', '## Per-lock live timings (min / median / max ms)', '', - `| Lock | ${names.map((name) => cell(report.implementations[name].label)).join(' | ')} |`, - `| --- | ${names.map(() => '---').join(' | ')} |`); - for (const row of report.results) { - lines.push(`| ${cell(row.title)} (${row.id}) | ${names.map((name) => { - const timing = row.solvers[name].timingMs; - return `${number(timing.min)} / ${number(timing.median)} / ${number(timing.max)}`; - }).join(' | ')} |`); - } - lines.push('', '## Correctness checks', '', ...report.correctness.checks.map((value) => `- ${value}`), '', - '## Limits of the conclusions', '', ...report.limitations.map((value) => `- ${value}`), '', - 'The JSON report includes individual samples and independently verified command sequences. Cached observations never supply values to the live timing table.', ''); - return lines.join('\n'); -} - -export async function publishReport(report, outputDir) { - if (report?.correctness?.passed !== true) throw new Error('A report cannot be published before correctness gates pass.'); - // Render both artifacts before touching disk; publish the directory with one same-filesystem rename. - const json = `${JSON.stringify(report, null, 2)}\n`; - const markdown = renderMarkdown(report); - const outputRoot = resolve(outputDir); - await mkdir(outputRoot, { recursive: true }); - const staged = await mkdtemp(join(outputRoot, '.benchmark-')); - const directory = join(outputRoot, `run-${report.createdAt.replaceAll(/[^0-9TZ]/g, '')}-${randomUUID().slice(0, 8)}`); - try { - await writeFile(join(staged, 'benchmark.json'), json, { flag: 'wx' }); - await writeFile(join(staged, 'benchmark.md'), markdown, { flag: 'wx' }); - await rename(staged, directory); - } catch (error) { - await rm(staged, { recursive: true, force: true }); - throw error; - } - return { directory, jsonPath: join(directory, 'benchmark.json'), markdownPath: join(directory, 'benchmark.md') }; -} - -export async function runAndPublish({ outputDir, ...options }) { - const report = runBenchmark(options); - return { report, ...await publishReport(report, outputDir) }; -} diff --git a/benchmarks/validation.mjs b/benchmarks/validation.mjs deleted file mode 100644 index 27f658d..0000000 --- a/benchmarks/validation.mjs +++ /dev/null @@ -1,54 +0,0 @@ -// Independent verification: deliberately imports none of the production transition/search code. -export function replayCommands({ state, links }, commands) { - if (!Array.isArray(commands)) throw new Error('Commands must be an array.'); - let current = [...state]; - for (const [index, command] of commands.entries()) { - if (!command || !Number.isInteger(command.plate) || command.plate < 1 - || command.plate > current.length || !['left', 'right'].includes(command.direction) - || !Number.isInteger(command.steps) || command.steps < 1 || command.steps > 6) { - throw new Error(`Invalid command at index ${index}.`); - } - const source = command.plate - 1; - const sign = command.direction === 'left' ? 1 : -1; - // Every unit click is simultaneous. Only the selected plate's outgoing row is read. - for (let click = 0; click < command.steps; click += 1) { - const next = current.map((position, target) => position - + sign * (target === source ? 1 : links[source][target])); - if (next.some((position) => !Number.isInteger(position) || position < 1 || position > 7)) { - throw new Error(`Blocked intermediate click ${click + 1} of command ${index + 1}.`); - } - current = next; - } - } - return current; -} - -export function commandMetrics(commands) { - return { - A: commands.length, - U: new Set(commands.map((command) => command.plate)).size, - C: commands.reduce((sum, command) => sum + command.steps, 0), - plateSwitches: commands.reduce((sum, command, index) => sum - + Number(index > 0 && commands[index - 1].plate !== command.plate), 0), - }; -} - -export function groupReferenceClicks(sequence) { - if (sequence === null) return null; - if (!Array.isArray(sequence)) throw new Error('Invalid reference result.'); - const commands = []; - for (const click of sequence) { - if (!click || !Number.isInteger(click.plate) || click.plate < 0 - || (click.d !== 1 && click.d !== -1)) { - throw new Error('Invalid reference unit click.'); - } - const direction = click.d === 1 ? 'left' : 'right'; - const last = commands.at(-1); - if (last?.plate === click.plate + 1 && last.direction === direction) { - last.steps += 1; - } else { - commands.push({ plate: click.plate + 1, direction, steps: 1 }); - } - } - return commands; -} diff --git a/dist/gothic-lock-solver.js b/dist/gothic-lock-solver.js deleted file mode 100644 index b520b1e..0000000 --- a/dist/gothic-lock-solver.js +++ /dev/null @@ -1,912 +0,0 @@ -/*! - * gothic-lock-solver — standalone classic browser script - * Project attribution: https://github.com/Marcus-Rise/gothic-lock-solver - * SPDX-License-Identifier: Apache-2.0 - * Generated by scripts/build-browser.mjs. Do not edit this artifact directly. - * Source SHA-256 (ordered UTF-8/LF modules and LICENSE): be3b68485e67d47a855cf6f3103917676234a5f53ca6227e7e7fd1d3a656bcdf - * No runtime dependencies, module imports, network access, or generated code. - * - * Apache License - * Version 2.0, January 2004 - * http://www.apache.org/licenses/ - * - * TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - * - * 1. Definitions. - * - * "License" shall mean the terms and conditions for use, reproduction, - * and distribution as defined by Sections 1 through 9 of this document. - * - * "Licensor" shall mean the copyright owner or entity authorized by - * the copyright owner that is granting the License. - * - * "Legal Entity" shall mean the union of the acting entity and all - * other entities that control, are controlled by, or are under common - * control with that entity. For the purposes of this definition, - * "control" means (i) the power, direct or indirect, to cause the - * direction or management of such entity, whether by contract or - * otherwise, or (ii) ownership of fifty percent (50%) or more of the - * outstanding shares, or (iii) beneficial ownership of such entity. - * - * "You" (or "Your") shall mean an individual or Legal Entity - * exercising permissions granted by this License. - * - * "Source" form shall mean the preferred form for making modifications, - * including but not limited to software source code, documentation - * source, and configuration files. - * - * "Object" form shall mean any form resulting from mechanical - * transformation or translation of a Source form, including but - * not limited to compiled object code, generated documentation, - * and conversions to other media types. - * - * "Work" shall mean the work of authorship, whether in Source or - * Object form, made available under the License, as indicated by a - * copyright notice that is included in or attached to the work - * (an example is provided in the Appendix below). - * - * "Derivative Works" shall mean any work, whether in Source or Object - * form, that is based on (or derived from) the Work and for which the - * editorial revisions, annotations, elaborations, or other modifications - * represent, as a whole, an original work of authorship. For the purposes - * of this License, Derivative Works shall not include works that remain - * separable from, or merely link (or bind by name) to the interfaces of, - * the Work and Derivative Works thereof. - * - * "Contribution" shall mean any work of authorship, including - * the original version of the Work and any modifications or additions - * to that Work or Derivative Works thereof, that is intentionally - * submitted to Licensor for inclusion in the Work by the copyright owner - * or by an individual or Legal Entity authorized to submit on behalf of - * the copyright owner. For the purposes of this definition, "submitted" - * means any form of electronic, verbal, or written communication sent - * to the Licensor or its representatives, including but not limited to - * communication on electronic mailing lists, source code control systems, - * and issue tracking systems that are managed by, or on behalf of, the - * Licensor for the purpose of discussing and improving the Work, but - * excluding communication that is conspicuously marked or otherwise - * designated in writing by the copyright owner as "Not a Contribution." - * - * "Contributor" shall mean Licensor and any individual or Legal Entity - * on behalf of whom a Contribution has been received by Licensor and - * subsequently incorporated within the Work. - * - * 2. Grant of Copyright License. Subject to the terms and conditions of - * this License, each Contributor hereby grants to You a perpetual, - * worldwide, non-exclusive, no-charge, royalty-free, irrevocable - * copyright license to reproduce, prepare Derivative Works of, - * publicly display, publicly perform, sublicense, and distribute the - * Work and such Derivative Works in Source or Object form. - * - * 3. Grant of Patent License. Subject to the terms and conditions of - * this License, each Contributor hereby grants to You a perpetual, - * worldwide, non-exclusive, no-charge, royalty-free, irrevocable - * (except as stated in this section) patent license to make, have made, - * use, offer to sell, sell, import, and otherwise transfer the Work, - * where such license applies only to those patent claims licensable - * by such Contributor that are necessarily infringed by their - * Contribution(s) alone or by combination of their Contribution(s) - * with the Work to which such Contribution(s) was submitted. If You - * institute patent litigation against any entity (including a - * cross-claim or counterclaim in a lawsuit) alleging that the Work - * or a Contribution incorporated within the Work constitutes direct - * or contributory patent infringement, then any patent licenses - * granted to You under this License for that Work shall terminate - * as of the date such litigation is filed. - * - * 4. Redistribution. You may reproduce and distribute copies of the - * Work or Derivative Works thereof in any medium, with or without - * modifications, and in Source or Object form, provided that You - * meet the following conditions: - * - * (a) You must give any other recipients of the Work or - * Derivative Works a copy of this License; and - * - * (b) You must cause any modified files to carry prominent notices - * stating that You changed the files; and - * - * (c) You must retain, in the Source form of any Derivative Works - * that You distribute, all copyright, patent, trademark, and - * attribution notices from the Source form of the Work, - * excluding those notices that do not pertain to any part of - * the Derivative Works; and - * - * (d) If the Work includes a "NOTICE" text file as part of its - * distribution, then any Derivative Works that You distribute must - * include a readable copy of the attribution notices contained - * within such NOTICE file, excluding those notices that do not - * pertain to any part of the Derivative Works, in at least one - * of the following places: within a NOTICE text file distributed - * as part of the Derivative Works; within the Source form or - * documentation, if provided along with the Derivative Works; or, - * within a display generated by the Derivative Works, if and - * wherever such third-party notices normally appear. The contents - * of the NOTICE file are for informational purposes only and - * do not modify the License. You may add Your own attribution - * notices within Derivative Works that You distribute, alongside - * or as an addendum to the NOTICE text from the Work, provided - * that such additional attribution notices cannot be construed - * as modifying the License. - * - * You may add Your own copyright statement to Your modifications and - * may provide additional or different license terms and conditions - * for use, reproduction, or distribution of Your modifications, or - * for any such Derivative Works as a whole, provided Your use, - * reproduction, and distribution of the Work otherwise complies with - * the conditions stated in this License. - * - * 5. Submission of Contributions. Unless You explicitly state otherwise, - * any Contribution intentionally submitted for inclusion in the Work - * by You to the Licensor shall be under the terms and conditions of - * this License, without any additional terms or conditions. - * Notwithstanding the above, nothing herein shall supersede or modify - * the terms of any separate license agreement you may have executed - * with Licensor regarding such Contributions. - * - * 6. Trademarks. This License does not grant permission to use the trade - * names, trademarks, service marks, or product names of the Licensor, - * except as required for reasonable and customary use in describing the - * origin of the Work and reproducing the content of the NOTICE file. - * - * 7. Disclaimer of Warranty. Unless required by applicable law or - * agreed to in writing, Licensor provides the Work (and each - * Contributor provides its Contributions) on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - * implied, including, without limitation, any warranties or conditions - * of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - * PARTICULAR PURPOSE. You are solely responsible for determining the - * appropriateness of using or redistributing the Work and assume any - * risks associated with Your exercise of permissions under this License. - * - * 8. Limitation of Liability. In no event and under no legal theory, - * whether in tort (including negligence), contract, or otherwise, - * unless required by applicable law (such as deliberate and grossly - * negligent acts) or agreed to in writing, shall any Contributor be - * liable to You for damages, including any direct, indirect, special, - * incidental, or consequential damages of any character arising as a - * result of this License or out of the use or inability to use the - * Work (including but not limited to damages for loss of goodwill, - * work stoppage, computer failure or malfunction, or any and all - * other commercial damages or losses), even if such Contributor - * has been advised of the possibility of such damages. - * - * 9. Accepting Warranty or Additional Liability. While redistributing - * the Work or Derivative Works thereof, You may choose to offer, - * and charge a fee for, acceptance of support, warranty, indemnity, - * or other liability obligations and/or rights consistent with this - * License. However, in accepting such obligations, You may act only - * on Your own behalf and on Your sole responsibility, not on behalf - * of any other Contributor, and only if You agree to indemnify, - * defend, and hold each Contributor harmless for any liability - * incurred by, or claims asserted against, such Contributor by reason - * of your accepting any such warranty or additional liability. - * - * END OF TERMS AND CONDITIONS - * - * APPENDIX: How to apply the Apache License to your work. - * - * To apply the Apache License to your work, attach the following - * boilerplate notice, with the fields enclosed by brackets "[]" - * replaced with your own identifying information. (Don't include - * the brackets!) The text should be enclosed in the appropriate - * comment syntax for the file format. We also recommend that a - * file or class name and description of purpose be included on the - * same "printed page" as the copyright notice for easier - * identification within third-party archives. - * - * Copyright [yyyy] [name of copyright owner] - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -(function () { -'use strict'; -const modules = Object.create(null); - -// src/lock-definition.mjs -modules["src/lock-definition.mjs"] = (() => { -class LockInputError extends Error { - constructor(message, options) { - super(message, options); - this.name = 'LockInputError'; - } -} - -function validateLockDefinition(value) { - if (value === null || typeof value !== 'object' || Array.isArray(value)) { - throw new LockInputError('Определение замка должно быть JSON-объектом.'); - } - - const { state, links } = value; - if (!Array.isArray(state) || state.length < 2) { - throw new LockInputError('state должен содержать не менее 2 пластин.'); - } - - for (let index = 0; index < state.length; index += 1) { - const position = state[index]; - if (!Object.hasOwn(state, index) || !Number.isInteger(position) || position < 1 || position > 7) { - throw new LockInputError(`state[${index}] должен быть целым числом от 1 до 7.`); - } - } - - if (!Array.isArray(links) || links.length !== state.length) { - throw new LockInputError(`links должен быть матрицей ${state.length}x${state.length}.`); - } - - for (let rowIndex = 0; rowIndex < links.length; rowIndex += 1) { - const row = links[rowIndex]; - if (!Object.hasOwn(links, rowIndex) || !Array.isArray(row) || row.length !== state.length) { - throw new LockInputError(`links[${rowIndex}] должен содержать ${state.length} элементов.`); - } - for (let columnIndex = 0; columnIndex < row.length; columnIndex += 1) { - const coefficient = row[columnIndex]; - if (!Object.hasOwn(row, columnIndex) || ![-1, 0, 1].includes(coefficient)) { - throw new LockInputError(`links[${rowIndex}][${columnIndex}] должен быть -1, 0 или 1.`); - } - if (rowIndex === columnIndex && coefficient !== 0) { - throw new LockInputError(`links[${rowIndex}][${columnIndex}] на диагонали должен быть 0.`); - } - } - } - - return { - state: [...state], - links: links.map((row) => [...row]), - }; -} - -function parseLockDefinition(text) { - try { - return validateLockDefinition(JSON.parse(text)); - } catch (error) { - if (error instanceof LockInputError) { - throw error; - } - throw new LockInputError(`Некорректный JSON: ${error.message}`, { cause: error }); - } -} - -return Object.freeze({ LockInputError, validateLockDefinition, parseLockDefinition }); -})(); - -// src/state-codec.mjs -modules["src/state-codec.mjs"] = (() => { -function stateSpaceSize(plateCount) { - return 7 ** plateCount; -} - -function encodeState(state) { - let code = 0; - for (const position of state) { - code = code * 7 + (position - 1); - } - return code; -} - -function decodeState(code, plateCount) { - const state = new Array(plateCount); - let remainder = code; - for (let index = plateCount - 1; index >= 0; index -= 1) { - state[index] = (remainder % 7) + 1; - remainder = Math.floor(remainder / 7); - } - return state; -} - -function createTargetState(plateCount) { - return Array.from({ length: plateCount }, () => 4); -} - -return Object.freeze({ stateSpaceSize, encodeState, decodeState, createTargetState }); -})(); - -// src/transition.mjs -modules["src/transition.mjs"] = (() => { -const DIRECTIONS = ['left', 'right']; - -function applyCommand(state, links, command) { - const sourceIndex = command.plate - 1; - const directionDelta = command.direction === 'left' ? 1 : -1; - const nextState = [...state]; - - for (let targetIndex = 0; targetIndex < state.length; targetIndex += 1) { - const coefficient = targetIndex === sourceIndex - ? 1 - : links[sourceIndex][targetIndex]; - - if (coefficient === 0) { - continue; - } - - const nextPosition = state[targetIndex] - + directionDelta * coefficient * command.steps; - if (nextPosition < 1 || nextPosition > 7) { - return null; - } - nextState[targetIndex] = nextPosition; - } - - return nextState; -} - -function generateTransitions(state, links) { - const transitions = []; - for (let plate = 1; plate <= state.length; plate += 1) { - const sourcePosition = state[plate - 1]; - for (const direction of DIRECTIONS) { - const sourceLimit = direction === 'left' - ? 7 - sourcePosition - : sourcePosition - 1; - for (let steps = sourceLimit; steps >= 1; steps -= 1) { - const command = { plate, direction, steps }; - const nextState = applyCommand(state, links, command); - if (nextState !== null) { - transitions.push({ command, state: nextState }); - } - } - } - } - return transitions; -} - -return Object.freeze({ applyCommand, generateTransitions }); -})(); - -// src/result.mjs -modules["src/result.mjs"] = (() => { -const { createTargetState } = modules["src/state-codec.mjs"]; -const { applyCommand } = modules["src/transition.mjs"]; - -function createSolveResult(definition, commands) { - const initialState = [...definition.state]; - const targetState = createTargetState(initialState.length); - - if (commands === null) { - return { status: 'unsolvable', initialState, targetState, commands: [] }; - } - - let finalState = [...initialState]; - for (const command of commands) { - const nextState = applyCommand(finalState, definition.links, command); - if (nextState === null) { - throw new Error('Решатель вернул заблокированную команду.'); - } - finalState = nextState; - } - - if (!finalState.every((position) => position === 4)) { - throw new Error('Решатель не привел замок к целевому состоянию.'); - } - - const plateSwitches = commands.reduce((count, command, index) => { - if (index === 0 || command.plate === commands[index - 1].plate) { - return count; - } - return count + 1; - }, 0); - - return { - status: 'solved', - initialState, - targetState, - commands: commands.map((command) => ({ ...command })), - finalState, - metrics: { - commands: commands.length, - divisions: commands.reduce((sum, command) => sum + command.steps, 0), - plateSwitches, - }, - }; -} - -function formatConsoleResult(result) { - if (result.status === 'unsolvable') { - return 'Решение не найдено.'; - } - if (result.commands.length === 0) { - return 'Замок уже открыт.'; - } - - const directionNames = { left: 'влево', right: 'вправо' }; - const lines = result.commands.map((command, index) => ( - `${index + 1}. Пластина ${command.plate} - ${directionNames[command.direction]} x${command.steps}` - )); - return [`Найдено команд: ${result.commands.length}`, ...lines].join('\n'); -} - -return Object.freeze({ createSolveResult, formatConsoleResult }); -})(); - -// src/search-limits.mjs -modules["src/search-limits.mjs"] = (() => { -/** Resource failures are deliberately distinct from a proof of unreachability. */ -class SearchLimitError extends Error { - constructor(limit, maximum, used, options) { - super(`Превышен ресурсный предел поиска: ${limit} (предел ${maximum}, требуется ${used}).`, options); - this.name = 'SearchLimitError'; - this.limit = limit; - this.maximum = maximum; - this.used = used; - } -} - -const DEFAULTS = Object.freeze({ - maxVisited: 2_000_000, - maxExpanded: 1_000_000, - maxFrontier: 1_000_000, - maxDenseBytes: 64 * 1024 * 1024, -}); - -function normalizeSearchOptions(options = {}) { - if (options === null || typeof options !== 'object' || Array.isArray(options)) { - throw new TypeError('Параметры поиска должны быть объектом.'); - } - const algorithm = options.algorithm === undefined ? 'matrix-astar' : options.algorithm; - if (algorithm !== 'matrix-astar' && algorithm !== 'bfs') { - throw new TypeError('algorithm должен быть matrix-astar или bfs.'); - } - const result = { algorithm }; - for (const [name, fallback] of Object.entries(DEFAULTS)) { - const value = options[name] === undefined ? fallback : options[name]; - const minimum = name === 'maxVisited' || name === 'maxFrontier' ? 1 : 0; - if (!Number.isSafeInteger(value) || value < minimum) { - throw new TypeError(`${name} должен быть безопасным целым числом не меньше ${minimum}.`); - } - result[name] = value; - } - return result; -} - -class SearchBudget { - constructor(options) { - this.options = options; - this.visited = 0; - this.expanded = 0; - } - - check(name, used) { - if (used > this.options[name]) throw new SearchLimitError(name, this.options[name], used); - } - - visit() { - this.check('maxVisited', this.visited + 1); - this.visited += 1; - } - - expand() { - this.check('maxExpanded', this.expanded + 1); - this.expanded += 1; - } -} - -/** Validate encoding before matrix work, early returns, or dense allocation. */ -function prepareSearch({ state, links }) { - const n = state.length; - const size = 7 ** n; - if (!Number.isSafeInteger(size)) { - throw new SearchLimitError('stateEncoding', Number.MAX_SAFE_INTEGER, size); - } - const powers = Array.from({ length: n }, (_, index) => 7 ** (n - index - 1)); - let source = 0; - let goal = 0; - for (let index = 0; index < n; index += 1) { - source += (state[index] - 1) * powers[index]; - goal += 3 * powers[index]; - } - const effects = links.map((row, plate) => { - const targets = []; - const signs = []; - let offset = 0; - for (let target = 0; target < n; target += 1) { - const sign = plate === target ? 1 : row[target]; - if (sign === 0) continue; - targets.push(target); - signs.push(sign); - offset += sign * powers[target]; - } - return { targets, signs, offset }; - }); - return { n, size, source, goal, effects }; -} - -function decodeDigits(code, digits) { - for (let index = digits.length - 1; index >= 0; index -= 1) { - digits[index] = code % 7; - code = Math.floor(code / 7); - } -} - -function movementLimits(digits, effect) { - let plus = 6; - let minus = 6; - for (let index = 0; index < effect.targets.length; index += 1) { - const position = digits[effect.targets[index]]; - if (effect.signs[index] === 1) { - if (6 - position < plus) plus = 6 - position; - if (position < minus) minus = position; - } else { - if (position < plus) plus = position; - if (6 - position < minus) minus = 6 - position; - } - } - return [plus, minus]; -} - -function commandFor(plate, delta) { - return { plate: plate + 1, direction: delta > 0 ? 'left' : 'right', steps: Math.abs(delta) }; -} - -return Object.freeze({ SearchLimitError, normalizeSearchOptions, SearchBudget, prepareSearch, decodeDigits, movementLimits, commandFor }); -})(); - -// src/search-bfs.mjs -modules["src/search-bfs.mjs"] = (() => { -const { decodeDigits, movementLimits, SearchLimitError } = modules["src/search-limits.mjs"]; - -function unpack(packed) { - const plateDirection = Math.floor(packed / 6); - return { - plate: Math.floor(plateDirection / 2) + 1, - direction: plateDirection % 2 === 0 ? 'left' : 'right', - steps: packed % 6 + 1, - }; -} - -/** Exact macro BFS. Loop order is the original public tie-breaking contract. */ -function searchBfs(prepared, budget) { - const { n, size, source, goal, effects } = prepared; - budget.visit(); - if (source === goal) return []; - - // Signed parents need two sentinel values. Queue stores only the live frontier. - const capacity = Math.min(size, budget.options.maxVisited, budget.options.maxFrontier); - const denseBytes = size * 5 + capacity * 4; - const dense = size <= 0x7fffffff && denseBytes <= budget.options.maxDenseBytes; - let parents; - let actions; - let queue; - try { - parents = dense ? new Int32Array(size).fill(-2) : new Map(); - actions = dense ? new Uint8Array(size) : null; - queue = dense ? new Int32Array(capacity) : []; - } catch (error) { - if (!(error instanceof RangeError)) throw error; - throw new SearchLimitError('maxDenseBytes', budget.options.maxDenseBytes, denseBytes, { cause: error }); - } - if (dense) parents[source] = -1; - else parents.set(source, { parent: -1, action: -1 }); - queue[0] = source; - let head = 0; - let tail = 1 % capacity; - let frontier = 1; - const digits = new Uint8Array(n); - - function reconstruct(code) { - const commands = []; - while (code !== source) { - if (dense) { - commands.push(unpack(actions[code])); - code = parents[code]; - } else { - const node = parents.get(code); - commands.push(unpack(node.action)); - code = node.parent; - } - } - return commands.reverse(); - } - - while (frontier > 0) { - budget.expand(); - const code = queue[head]; - if (!dense) queue[head] = undefined; - head = (head + 1) % capacity; - frontier -= 1; - decodeDigits(code, digits); - for (let plate = 0; plate < n; plate += 1) { - const limits = movementLimits(digits, effects[plate]); - for (let direction = 0; direction < 2; direction += 1) { - const sign = direction === 0 ? 1 : -1; - for (let steps = limits[direction]; steps >= 1; steps -= 1) { - const next = code + sign * steps * effects[plate].offset; - if (dense ? parents[next] !== -2 : parents.has(next)) continue; - budget.visit(); - const action = (plate * 2 + direction) * 6 + steps - 1; - if (dense) { - parents[next] = code; - actions[next] = action; - } else parents.set(next, { parent: code, action }); - if (next === goal) return reconstruct(next); - budget.check('maxFrontier', frontier + 1); - queue[tail] = next; - tail = (tail + 1) % capacity; - frontier += 1; - } - } - } - } - return null; -} - -return Object.freeze({ searchBfs }); -})(); - -// src/matrix-analysis.mjs -modules["src/matrix-analysis.mjs"] = (() => { -const { SearchLimitError } = modules["src/search-limits.mjs"]; - -const abs = (value) => value < 0n ? -value : value; - -function fraction(n, d = 1n) { - if (n === 0n) return { n: 0n, d: 1n }; - if (d < 0n) { n = -n; d = -d; } - let a = abs(n); - let b = d; - while (b !== 0n) [a, b] = [b, a % b]; - return { n: n / a, d: d / a }; -} - -const divide = (a, b) => fraction(a.n * b.d, a.d * b.n); -const subtractProduct = (a, b, c) => fraction(a.n * b.d * c.d - b.n * c.n * a.d, a.d * b.d * c.d); - -/** - * Exact necessary equation A z = goal - state. links[source][target] is - * transposed into A[target][source]; selected-plate movement is the diagonal 1. - * Rational inconsistency or a fractional unique z is a proof of no integer path. - * Rank deficiency alone says nothing about whether a path exists. - */ -function analyzeMatrix({ state, links }) { - const n = state.length; - const rows = Array.from({ length: n }, (_, target) => [ - ...Array.from({ length: n }, (_, source) => fraction(BigInt(source === target ? 1 : links[source][target]))), - fraction(BigInt(4 - state[target])), - ]); - const columns = []; - let rank = 0; - for (let column = 0; column < n; column += 1) { - let pivot = rank; - while (pivot < n && rows[pivot][column].n === 0n) pivot += 1; - if (pivot === n) continue; - [rows[rank], rows[pivot]] = [rows[pivot], rows[rank]]; - const divisor = rows[rank][column]; - for (let j = column; j <= n; j += 1) rows[rank][j] = divide(rows[rank][j], divisor); - for (let row = 0; row < n; row += 1) { - if (row === rank || rows[row][column].n === 0n) continue; - const factor = rows[row][column]; - for (let j = column; j <= n; j += 1) rows[row][j] = subtractProduct(rows[row][j], factor, rows[rank][j]); - } - columns.push(column); - rank += 1; - } - for (let row = rank; row < n; row += 1) { - if (rows[row][n].n !== 0n) return { kind: 'inconsistent', rank }; - } - if (rank < n) return { kind: 'singular', rank }; - const exact = Array(n); - for (let row = 0; row < n; row += 1) exact[columns[row]] = rows[row][n]; - if (exact.some((value) => value.d !== 1n)) return { kind: 'noninteger', rank }; - const net = exact.map(({ n: numerator }) => { - if (abs(numerator) > BigInt(Number.MAX_SAFE_INTEGER)) { - throw new SearchLimitError('matrixArithmetic', Number.MAX_SAFE_INTEGER, String(numerator)); - } - return Number(numerator); - }); - const lowerBound = net.reduce((sum, value) => sum + Math.ceil(Math.abs(value) / 6), 0); - if (!Number.isSafeInteger(lowerBound)) { - throw new SearchLimitError('matrixArithmetic', Number.MAX_SAFE_INTEGER, lowerBound); - } - return { kind: 'unique-integer', rank, net, lowerBound }; -} - -return Object.freeze({ analyzeMatrix }); -})(); - -// src/matrix-search.mjs -modules["src/matrix-search.mjs"] = (() => { -const { analyzeMatrix } = modules["src/matrix-analysis.mjs"]; -const { searchBfs } = modules["src/search-bfs.mjs"]; -const { commandFor, decodeDigits, movementLimits, SearchLimitError } = modules["src/search-limits.mjs"]; - -/** One entry per open state; decrease-key never leaves obsolete heap nodes. */ -class IndexedHeap { - nodes = []; - - before(a, b) { - return a.g + a.h < b.g + b.h || - (a.g + a.h === b.g + b.h && (a.h < b.h || (a.h === b.h && a.sequence < b.sequence))); - } - - up(node) { - let index = node.heapIndex; - while (index > 0) { - const parent = Math.floor((index - 1) / 2); - if (!this.before(node, this.nodes[parent])) break; - this.nodes[index] = this.nodes[parent]; - this.nodes[index].heapIndex = index; - index = parent; - } - this.nodes[index] = node; - node.heapIndex = index; - } - - push(node) { - node.heapIndex = this.nodes.length; - this.nodes.push(node); - this.up(node); - } - - pop() { - const first = this.nodes[0]; - const last = this.nodes.pop(); - first.heapIndex = -1; - if (this.nodes.length === 0) return first; - let index = 0; - while (true) { - let child = index * 2 + 1; - if (child >= this.nodes.length) break; - if (child + 1 < this.nodes.length && this.before(this.nodes[child + 1], this.nodes[child])) child += 1; - if (!this.before(this.nodes[child], last)) break; - this.nodes[index] = this.nodes[child]; - this.nodes[index].heapIndex = index; - index = child; - } - this.nodes[index] = last; - last.heapIndex = index; - return first; - } -} - -/** - * A sufficient certificate, never an unreachability test. A legal single action - * for each nonzero net entry attains sum ceil(abs(net[i])/6), a global bound. - * An unsuccessful greedy attempt is discarded and full A* still runs. - */ -function greedyCertificate(prepared, net, budget) { - if (net.some((value) => Math.abs(value) > 6)) return null; - const remaining = [...net]; - const digits = new Uint8Array(prepared.n); - decodeDigits(prepared.source, digits); - const commands = []; - let pending = remaining.filter(Boolean).length; - while (pending > 0) { - let selected = -1; - for (let plate = 0; plate < prepared.n; plate += 1) { - const delta = remaining[plate]; - if (delta === 0) continue; - const limits = movementLimits(digits, prepared.effects[plate]); - if (Math.abs(delta) <= limits[delta > 0 ? 0 : 1]) { selected = plate; break; } - } - if (selected === -1) return null; - const delta = remaining[selected]; - const effect = prepared.effects[selected]; - for (let index = 0; index < effect.targets.length; index += 1) { - digits[effect.targets[index]] += delta * effect.signs[index]; - } - commands.push(commandFor(selected, delta)); - remaining[selected] = 0; - pending -= 1; - } - // Successful scheduling accounts for its path states and expansions too. - budget.check('maxVisited', commands.length + 1); - budget.check('maxExpanded', commands.length); - return commands; -} - -function reconstruct(node) { - const commands = []; - while (node.previous !== null) { - commands.push(commandFor(node.plate, node.delta)); - node = node.previous; - } - return commands.reverse(); -} - -/** - * Invertible A makes remaining net r unique for every physical state. An action - * changes one r[i] by at most six, hence h=sum ceil(abs(r[i])/6) is consistent. - * Closed nodes are final; the first popped goal has the minimum action count. - */ -function searchMatrix(definition, prepared, budget) { - const { n, source, goal, effects } = prepared; - if (source === goal) { budget.visit(); return []; } - const analysis = analyzeMatrix(definition); - if (analysis.kind === 'inconsistent' || analysis.kind === 'noninteger') return null; - if (analysis.kind === 'singular') return searchBfs(prepared, budget); - const certificate = greedyCertificate(prepared, analysis.net, budget); - if (certificate !== null) return certificate; - - budget.visit(); - const root = { - key: source, g: 0, h: analysis.lowerBound, r: analysis.net, - previous: null, sequence: 0, heapIndex: -1, closed: false, - }; - const heap = new IndexedHeap(); - heap.push(root); - const records = new Map([[source, root]]); - const digits = new Uint8Array(n); - let sequence = 1; - while (heap.nodes.length > 0) { - const node = heap.pop(); - if (node.key === goal) return reconstruct(node); - budget.expand(); - node.closed = true; - decodeDigits(node.key, digits); - for (let plate = 0; plate < n; plate += 1) { - const limits = movementLimits(digits, effects[plate]); - const preferred = node.r[plate] < 0 ? -1 : 1; - for (let side = 0; side < 2; side += 1) { - const sign = side === 0 ? preferred : -preferred; - const limit = limits[sign > 0 ? 0 : 1]; - for (let steps = limit; steps >= 1; steps -= 1) { - const delta = sign * steps; - const key = node.key + delta * effects[plate].offset; - const g = node.g + 1; - const existing = records.get(key); - if (existing && (existing.closed || existing.g <= g)) continue; - if (existing) { - // The physical state uniquely determines r and h, so only g and - // the predecessor change. Its predecessor is already finalized. - existing.g = g; - existing.previous = node; - existing.plate = plate; - existing.delta = delta; - heap.up(existing); - continue; - } - budget.visit(); - budget.check('maxFrontier', heap.nodes.length + 1); - const r = [...node.r]; - r[plate] -= delta; - const h = node.h - Math.ceil(Math.abs(node.r[plate]) / 6) + Math.ceil(Math.abs(r[plate]) / 6); - if (!Number.isSafeInteger(r[plate]) || !Number.isSafeInteger(g + h)) { - throw new SearchLimitError('matrixArithmetic', Number.MAX_SAFE_INTEGER, String(r[plate])); - } - const next = { key, g, h, r, previous: node, plate, delta, sequence: sequence++, heapIndex: -1, closed: false }; - records.set(key, next); - heap.push(next); - } - } - } - } - return null; -} - -return Object.freeze({ searchMatrix }); -})(); - -// src/index.mjs -modules["src/index.mjs"] = (() => { -const { validateLockDefinition } = modules["src/lock-definition.mjs"]; -const { createSolveResult } = modules["src/result.mjs"]; -const { searchBfs } = modules["src/search-bfs.mjs"]; -const { searchMatrix } = modules["src/matrix-search.mjs"]; -const { normalizeSearchOptions, prepareSearch, SearchBudget } = modules["src/search-limits.mjs"]; - -function solveLock(value, options) { - const limits = normalizeSearchOptions(options); - const definition = validateLockDefinition(value); - const prepared = prepareSearch(definition); - const budget = new SearchBudget(limits); - const commands = limits.algorithm === 'bfs' - ? searchBfs(prepared, budget) - : searchMatrix(definition, prepared, budget); - return createSolveResult(definition, commands); -} - -const { applyCommand, generateTransitions } = modules["src/transition.mjs"]; -const { SearchLimitError } = modules["src/search-limits.mjs"]; - -return Object.freeze({ solveLock, applyCommand, generateTransitions, SearchLimitError }); -})(); - -globalThis.GothicLockSolver = modules["src/index.mjs"]; -})(); diff --git a/docs/algorithm.md b/docs/algorithm.md deleted file mode 100644 index 5bf9705..0000000 --- a/docs/algorithm.md +++ /dev/null @@ -1,161 +0,0 @@ -# Модель, оптимальность и пределы вычисления - -## Что минимизируется - -Состояние замка — вектор `x` длины N, где каждое значение находится в `1..7`. -Матрица интерфейса визуализирует этот вектор; матрица зависимостей `L` имеет размер -N×N. Строка `L[i]` описывает влияние выбранной пластины i на остальные пластины. -Связи направленные, не распространяются каскадом, диагональ `L` равна нулю. - -Команда `(i, k)`, где `k ∈ {-6,…,-1,1,…,6}`, стоит одно **действие**. -Она изменяет положение выбранной пластины на k и применяет её прямые связи. -Каждое промежуточное положение обязано оставаться в `1..7`. -Недопустимое перемещение блокируется целиком. - -В отчётах используются отдельные показатели: - -| Обозначение | Значение | -|---|---| -| A | Количество команд: серий сдвигов выбранной пластины | -| U | Количество разных выбранных пластин; косвенно затронутые связи не считаются | -| C | Количество единичных сдвигов: сумма `steps` | -| plateSwitches | Число смен выбранной пластины между соседними командами | - -Оба публичных режима гарантируют минимум A при успешном завершении. Минимум U -или C среди равных по A решений не является общей гарантией. В кратчайшем пути -не бывает двух соседних команд одной пластины: однонаправленные можно объединить, -противоположные сократить. Поэтому для непустого кратчайшего решения число -переключений равно A−1. U при этом может быть меньше A: к пластине иногда нужно -возвращаться. - -UnlockMyLoot на закреплённой версии сначала минимизирует C и затем A среди путей -с минимальным C. Это другая цель. Меньшее A нашего решения может сопровождаться -большим C; отчёт показывает обе величины. Число действий также не является прямым -измерением секунд работы игрока: на него влияют устройство ввода и анимации. - -## Матричное уравнение - -Используем векторы-столбцы и матрицу эффектов `M = I + Lᵀ`. -Тогда допустимая команда имеет вид - -```text -x' = x + k M e_i, 1 ≤ x'_j ≤ 7. -``` - -Все компоненты движутся монотонно внутри одной команды, поэтому для известного -допустимого начала проверка конечных границ эквивалентна проверке каждого деления. -Независимый проверяющий симулятор всё равно воспроизводит команды по одному делению. - -Если `z_i` — суммарное знаковое перемещение при выборе пластины i за весь путь, -то любой успешный путь удовлетворяет - -```text -M z = g − x, g = (4,…,4), z ∈ ℤᴺ. -``` - -Это необходимое условие. Оно не определяет допустимый порядок команд. -Например, у трёх пластин с взаимными реверсивными связями матрица M имеет единицы -на диагонали и −1 вне диагонали. Из `[1,1,1]` уравнение имеет единственное -целочисленное решение `z=[−3,−3,−3]`, но ни одна первая команда невозможна. -Из `[3,1,1]` баланс равен `[−3,−2,−2]`, однако нужны четыре действия: - -```text -[3,1,1] → (1,−2) → [1,3,3] → (3,−2) → [3,5,1] - → (2,−2) → [5,3,3] → (1,−1) → [4,4,4]. -``` - -Три ненулевых компоненты баланса не гарантируют путь из трёх действий. - -## Точный анализ и A* - -1. Гаусс–Жордан над рациональными числами с `BigInt` проверяет совместность - уравнения без допусков floating point. Несовместность доказывает отсутствие - пути. У невырожденной матрицы единственное дробное решение также исключает путь. -2. При вырожденной совместной матрице используется точный BFS по действиям. - Отдельное рациональное решение в этом случае не даёт основания отвергать - все целочисленные решения. -3. При единственном целочисленном балансе сначала пробуется достаточный сертификат: - если все `|z_i| ≤ 6` и удаётся исполнить каждый ненулевой компонент одной - допустимой командой, найден глобальный минимум A. Алгоритм выбирает первую - доступную пластину по номеру. Неудача этой жадной попытки ничего не доказывает: - после неё запускается полный A*. -4. A* хранит остаточный баланс `r=M⁻¹(g−x)` и использует оценку - -```text -h(x) = Σ_i ceil(|r_i| / 6). -``` - -За одно действие меняется только один компонент r, максимум на шесть. Для каждого -компонента потребуется не меньше `ceil(|r_i|/6)` действий, поэтому h допустима. -Кроме того, `h(x) ≤ 1+h(x')` на любом ребре: оценка согласована. Когда цель впервые -извлечена из приоритетной очереди, длина пути минимальна. Закрытые состояния -не требуется открывать повторно. Уменьшение цены открытого состояния выполняется -в индексированной куче без накопления устаревших копий узлов. - -У BFS каждое ребро тоже стоит одно действие, поэтому он даёт тот же минимум A. -Для режима `bfs` сохранён порядок перебора исходной версии: номер пластины по -возрастанию, `left` перед `right`, длина по убыванию. A* использует детерминированный -порядок, который может дать другой равный по A путь. - -## Сложность - -Пусть S=7ᴺ, V≤S — число обнаруженных состояний, Q≤V — максимальный размер -очереди поиска. У состояния не больше 6N соседей: для каждой пластины суммарно -не больше шести допустимых ненулевых сдвигов в обе стороны. - -| Этап | Время | Память | -|---|---|---| -| Рациональный Гаусс–Жордан | O(N³) рациональных операций | O(N²) рациональных чисел | -| Достаточный сертификат | O(N³) в худшем случае | O(N²) вместе с эффектами | -| BFS, разреженное хранение | O(N²V) | O(V+Q+N²) | -| BFS, плотное хранение | O(S+N²V), включая инициализацию | O(S+Qcap+N²) | -| A* после матричного анализа | O(N²V+NV log V) | O(NV+N²) | - -`Qcap` — заранее выделенная ёмкость плотной очереди, ограниченная настройками. -O(N³) для матрицы не означает O(N³) битовых операций: стоимость точной арифметики -растёт с разрядностью числителей и знаменателей. Оценки поиска используют обычную -модель стоимости операций с безопасными JS-числами и ожидаемую O(1) стоимость Map. - -Матричная эвристика сокращает перебор на проверяемом наборе, но экспоненциальный -худший случай остаётся. Она не гарантирует десятки миллисекунд на любом входе. - -## Бюджеты и браузер - -Модель допускает N≥2. Текущая реализация поиска кодирует состояние одним безопасным -целым JS-числом и требует безопасного `7**N`; это допускает N≤18. Для N≥19 -возникает `SearchLimitError`, включая уже открытый замок. Это ограничение -представления текущего алгоритма, а не заявленный предел игрового замка. - -| Параметр `solveLock` | По умолчанию | Назначение | -|---|---:|---| -| `maxVisited` | 2 000 000 | Обнаруженные состояния | -| `maxExpanded` | 1 000 000 | Раскрытые состояния | -| `maxFrontier` | 1 000 000 | Одновременно ожидающие состояния | -| `maxDenseBytes` | 67 108 864 | Порог выделения плотных буферов BFS | - -`maxDenseBytes` не ограничивает всю память процесса: при превышении этого порога -BFS выбирает разреженное хранение. Объекты Map, векторы A* и накладные расходы -движка учитываются косвенно через число состояний, а не точным лимитом байтов. -Исчерпание бюджета вызывает `SearchLimitError`, никогда не ответ `unsolvable`. -Успешный сертификат также проверяет бюджет длины своего пути. - -Вычислительные модули синхронны и не зависят от Node.js. Для приложения с отзывчивым -интерфейсом используйте модульный Worker. `pnpm verify:web-core` проверяет настоящий -граф импортов в изолированном ESM-контексте без Node globals. Это не заменяет -проверку HTML-примера и Worker в целевых браузерах. - -## Что доказывают проверки - -- Каталожные тесты сравнивают все 45 конфигураций с точным BFS и воспроизводят - команды независимым симулятором. -- Малые модели сравниваются с независимым полным поиском: все 441 двухпластинных - входа и 192 воспроизводимых трёхпластинных входа. -- Контрпримеры проверяют границы, дробный баланс, несовместность, вырожденность - и необходимость повторного выбора пластины. -- Бенчмарк формирует измерения текущего кода, исходного BFS и, при предоставленном - checkout, неизменённого стороннего решателя в одном JS-runtime. - -Проверки подтверждают реализацию на выбранных входах; общая гарантия минимума A -следует из доказательства поиска. Измеренное время относится к указанной машине -и runtime. Каталог не представляет все возможные конфигурации, а Node.js-замеры -не являются измерениями Chrome, Firefox, Safari или телефона. diff --git a/docs/benchmarks/README.md b/docs/benchmarks/README.md deleted file mode 100644 index 9b92765..0000000 --- a/docs/benchmarks/README.md +++ /dev/null @@ -1,76 +0,0 @@ -# Проверенный запуск на 45 замках - -Полный отчёт: [Markdown](run-20260907T070258001Z-f78dff13/benchmark.md) -и [JSON с отдельными замерами и путями](run-20260907T070258001Z-f78dff13/benchmark.json). - -Проверен commit `6b1cfbc13bcec68f609d45d5dc97504dd84f1760` при чистом рабочем дереве. -Сторонний решатель: UnlockMyLoot `eee0bf50ebcb7fffd2b47954fd76bb015854e365`. -Все 45 конфигураций прошли проверки корректности на каждом прогреве и повторе. -Общая тестовая проверка нашего проекта: **113/113**, без пропусков. - -## Результат - -| Реализация | Действия A, сумма | Разные пластины U, сумма | Сдвиги C, сумма | Медиана времени по замкам, мс | Сумма медиан по замкам, мс | -|---|---:|---:|---:|---:|---:| -| Новый матричный A* | 483 | 245 | 1723 | 0,196 | 49,631 | -| Текущий точный BFS | 483 | 245 | 1727 | 11,221 | 423,231 | -| Исходный BFS нашего проекта | 483 | 245 | 1727 | 67,716 | 2537,917 | -| UnlockMyLoot | 491 | 245 | 1695 | 26,897 | 1465,337 | - -В столбце «Медиана времени по замкам» взята медиана 45 индивидуальных медиан, -каждая из которых получена из пяти повторов после одного прогрева. -Сумма медиан не является медианой времени полного прохода каталога. - -По числу действий относительно UnlockMyLoot: **5 выигрышей, 40 равенств, -0 проигрышей**. Разных выбранных пластин столько же на каждом из 45 входов. -Сдвигов больше на 6 входах, столько же на 39. Новый поиск быстрее стороннего -по индивидуальной медиане на всех 45 входах в этом запуске; максимум индивидуальных -медиан нового поиска — 7,302 мс. Это наблюдение, не универсальная гарантия скорости. - -| Замок | UnlockMyLoot A → новый A | UnlockMyLoot C → новый C | -|---|---:|---:| -| old-camp-dungeon-door | 18 → 17 | 68 → 72 | -| gomez-wine-cellar | 11 → 9 | 31 → 41 | -| arto-far-bed-chest | 13 → 11 | 40 → 44 | -| gomez-bedrooms-far-chest | 16 → 14 | 39 → 43 | -| raven-tower-floor2-chest-2 | 9 → 8 | 27 → 29 | - -Исходный BFS нашего проекта уже находил минимум действий на этих входах. -Матричный поиск ускоряет получение этого минимума. Разница с UnlockMyLoot связана -с другим приоритетом: его текущий режим сначала минимизирует сдвиги, наш — действия. -Глобальный минимум U и C среди равных по A решений не заявляется. - -## Воспроизведение - -Требуется Node.js 20+. Сторонний checkout хранится отдельно; его solver не включён -в наш репозиторий. - -```bash -git clone https://github.com/Marcus-Rise/gothic-lock-solver.git -git -C gothic-lock-solver checkout 6b1cfbc13bcec68f609d45d5dc97504dd84f1760 -git clone https://github.com/1h8s/unlockmyloot.git -git -C unlockmyloot checkout eee0bf50ebcb7fffd2b47954fd76bb015854e365 -cd gothic-lock-solver -node --test -node scripts/build-browser.mjs --check -node scripts/benchmark.mjs --reference ../unlockmyloot --repetitions 5 --warmups 1 --output-dir docs/benchmarks -``` - -Отчёт содержит версии Node/V8, CPU/ОС, хэши входов и исходников, порядок измерений, -границы таймера и все отдельные замеры. Все реализации исполнялись в одном -Node.js runtime; проверенная функция UnlockMyLoot компилировалась без изменений -в том же JS-контексте. Проверка путей, копирование входов и нормализация ответов -исключены из измерения. GC естественный, без принудительного запуска. - -## Границы проверки - -Проверки подтвердили независимое воспроизведение путей, минимум действий через -точный BFS, детерминированность, отсутствие изменения входов и сохранение исходного -порядка BFS. Математическое ядро и сценарий измерений прошли независимый review -без существенных замечаний. - -Времена относятся к Node.js на указанной машине. Они не измеряют браузер, Worker, -телефон или время действий игрока. Изолированные проверки ESM и готового classic -script прошли без Node/DOM-зависимостей. Попытка проверки HTML в настоящем браузере -не смогла загрузить локальный сервер среды (`net::ERR_BLOCKED_BY_CLIENT`), поэтому -реальный браузерный запуск и Worker остаются отдельной ручной проверкой. diff --git a/docs/benchmarks/run-20260907T070258001Z-f78dff13/benchmark.json b/docs/benchmarks/run-20260907T070258001Z-f78dff13/benchmark.json deleted file mode 100644 index 4093e15..0000000 --- a/docs/benchmarks/run-20260907T070258001Z-f78dff13/benchmark.json +++ /dev/null @@ -1,17137 +0,0 @@ -{ - "schemaVersion": 1, - "createdAt": "2026-09-07T07:02:58.001Z", - "config": { - "repetitions": 5, - "warmups": 1, - "lockCount": 45, - "scope": "full-45-catalog", - "inputSha256": "c75f6378543138d974b11e9af9d5959f21ed6dbe58d71ecb12a11b197fe5bb09", - "units": "milliseconds", - "executionOrder": "Per lock, warmup and measured rounds rotate solver order deterministically.", - "timingBoundary": "Only the synchronous solver call is timed. Input cloning, reference click grouping, replay, metrics, and reporting are outside timing. Current and baseline calls include their public facade validation/result construction.", - "gc": "Natural Node.js garbage collection; no forced GC." - }, - "metadata": { - "environment": { - "node": "v24.19.0", - "v8": "13.6.233.17-node.51", - "platform": "linux", - "arch": "x64", - "osRelease": "6.18.35", - "cpu": "AMD EPYC 9V74 80-Core Processor", - "logicalCpus": 9, - "totalMemoryBytes": 23109910528, - "execArgv": [] - }, - "source": { - "repository": "https://github.com/Marcus-Rise/gothic-lock-solver", - "revision": "6b1cfbc13bcec68f609d45d5dc97504dd84f1760", - "dirty": false, - "core": { - "sha256": "064dd862f17ea1a8fdd00abd4e06dfb024ffc4dcf98cb648c881a163c4599079", - "files": { - "cli.mjs": "a8ca1332163fa5df64387b1fccc985244f07e99664eba463153700ecd4d672b5", - "index.mjs": "5e3d680cbb1ca7bf9437a3800654bc26b1445ec71994bfc1fc70a7d5381aa6ed", - "lock-definition.mjs": "cecc13bdb3c760400dda8421e5bdb58e9fdc5bbd46e1782dbd11f94a6de9a8b3", - "matrix-analysis.mjs": "7262f85ba882cd8a6ea9cb294be2d2f7a22acd16bc3a963744b9edbdce397986", - "matrix-search.mjs": "b018cfdad21aaf52f4350d5d8f0001afed1b41c44643e7fc8c330e6aca40b1d4", - "result.mjs": "1a38110c34b696f62b9632cb3de27bb0e07c938cdd11b3ffcb89de8532bf08b0", - "search-bfs.mjs": "69f253801d4bb36a9db6887a82a668b0a2765fe55c33cdf7c712fe82df128a75", - "search-limits.mjs": "4a302b500fa41019b1ec3d8f3b86ad28de1e2046a8f9ac7a707608170ce7b569", - "solver.mjs": "31e3ebfb7035bc57d4c435351642eac0b49820d9ed4682d7eac38b0529dddd29", - "state-codec.mjs": "ce37a9ede6b26196fef7b89aa9501fe94c6a18fc4110c1cea394995b053759e4", - "transition.mjs": "28cf5007a8fd1db9d179bf233d995a5e88f5a6c486bca22395e2ed144cdbd4fd" - } - }, - "harness": { - "sha256": "becf8b6ca5b15dd64d91e52939bb1bf8b46816f4f62e70b27629e17c26f9bde4", - "files": { - "baseline/src/index.mjs": "927fc5391c8c5f673f52c35d1fd2bd14afa5d47554a7a410dae0d8c03788c90f", - "baseline/src/lock-definition.mjs": "e87e8eae439601cb93d85e8c5d5314976574ad6c6e595fff98f419ef80623d74", - "baseline/src/result.mjs": "1a38110c34b696f62b9632cb3de27bb0e07c938cdd11b3ffcb89de8532bf08b0", - "baseline/src/solver.mjs": "ab7335383670c40ccbe2a53c6591dc67a04b7abda49d44978c9a790938f92dec", - "baseline/src/state-codec.mjs": "ce37a9ede6b26196fef7b89aa9501fe94c6a18fc4110c1cea394995b053759e4", - "baseline/src/transition.mjs": "28cf5007a8fd1db9d179bf233d995a5e88f5a6c486bca22395e2ed144cdbd4fd", - "catalog.mjs": "1aeead30de3b4041309df18edae2daa206212cf6bb5e8b7031e77743d450a497", - "harness.mjs": "49c73bd6cd5b096f079446ca89a272b73f63e333ac187f814e126d66198ed2a0", - "validation.mjs": "3162b5374be266e5963e63a494006666c2b29ff2be10f2d2b58c8c57e036759e" - }, - "cliSha256": "57276d8b7dfbc48c8348d021b443f64d14c3bb3dbce753db8d54d51ee2fbf76e" - } - }, - "fixtures": { - "count": 45, - "catalogSha256": "0eb5b641eac4bda105bde24b60399220f1767dd13db6a312b78638b2750ba675", - "manifestSha256": "a634d5328fd8d41470397b622ae0bd32b8aac0355dfc31975e023a63a62e65c9", - "repository": "https://github.com/1h8s/unlockmyloot", - "revision": "eee0bf50ebcb7fffd2b47954fd76bb015854e365" - }, - "baseline": { - "repository": "https://github.com/Marcus-Rise/gothic-lock-solver", - "revision": "a87e739a22ccc4215d6d9b14fa06b0738171cb60", - "license": "Apache-2.0", - "files": { - "src/index.mjs": "927fc5391c8c5f673f52c35d1fd2bd14afa5d47554a7a410dae0d8c03788c90f", - "src/lock-definition.mjs": "e87e8eae439601cb93d85e8c5d5314976574ad6c6e595fff98f419ef80623d74", - "src/result.mjs": "1a38110c34b696f62b9632cb3de27bb0e07c938cdd11b3ffcb89de8532bf08b0", - "src/solver.mjs": "ab7335383670c40ccbe2a53c6591dc67a04b7abda49d44978c9a790938f92dec", - "src/state-codec.mjs": "ce37a9ede6b26196fef7b89aa9501fe94c6a18fc4110c1cea394995b053759e4", - "src/transition.mjs": "28cf5007a8fd1db9d179bf233d995a5e88f5a6c486bca22395e2ed144cdbd4fd", - "LICENSE": "c71d239df91726fc519c6eb72d318ec65820627232b2f796219e87dcf35d0ab4" - }, - "notes": "Unmodified source snapshots; imports remain relative within this directory. No upstream UnlockMyLoot solver code is included." - }, - "reference": { - "measured": true, - "repository": "https://github.com/1h8s/unlockmyloot", - "revision": "eee0bf50ebcb7fffd2b47954fd76bb015854e365", - "path": "index.html", - "fileSha256": "691f744099a9169f30e9883fddc5fdbb4949d306f3011e601678419d7b93821b", - "solverBlockSha256": "d5bc85f40fd7c5b61097403df399ecb7dfd2b58924dc7a670aa0d05306017380", - "execution": "Unchanged source compiled with Function in the same Node.js realm; no browser or vm context." - } - }, - "implementations": { - "matrix-astar": { - "label": "Matrix A*", - "requiresOptimality": true - }, - "bfs": { - "label": "Current exact BFS", - "requiresOptimality": true - }, - "baseline": { - "label": "Original Apache BFS", - "requiresOptimality": true - }, - "reference": { - "label": "UnlockMyLoot (live pinned reference)", - "requiresOptimality": false - } - }, - "correctness": { - "passed": true, - "lockCount": 45, - "checks": [ - "Every warmup and measured solution independently replayed one division at a time.", - "Matrix A*, exact BFS, and original Apache BFS equal the stored exact action minimum.", - "Current BFS preserves the pinned original BFS command sequence.", - "Inputs are unchanged and every implementation returns a deterministic path.", - "Live reference path and A/U/C equal the cached pinned observation." - ] - }, - "aggregates": { - "matrix-astar": { - "A": 483, - "U": 245, - "C": 1723, - "sumOfMediansMs": 49.63095100000089 - }, - "bfs": { - "A": 483, - "U": 245, - "C": 1727, - "sumOfMediansMs": 423.23053500001197 - }, - "baseline": { - "A": 483, - "U": 245, - "C": 1727, - "sumOfMediansMs": 2537.9171760000077 - }, - "reference": { - "A": 491, - "U": 245, - "C": 1695, - "sumOfMediansMs": 1465.3368119999984 - } - }, - "comparisons": { - "bfs": { - "actions": { - "wins": 0, - "ties": 45, - "losses": 0 - }, - "uniquePlates": { - "wins": 0, - "ties": 45, - "losses": 0 - }, - "clicks": { - "wins": 2, - "ties": 42, - "losses": 1 - }, - "timingCompared": true, - "timing": { - "wins": 43, - "ties": 0, - "losses": 2, - "geometricMeanSpeedup": 16.23201179805268, - "ratioOfSummedMedians": 8.527552393666694 - } - }, - "baseline": { - "actions": { - "wins": 0, - "ties": 45, - "losses": 0 - }, - "uniquePlates": { - "wins": 0, - "ties": 45, - "losses": 0 - }, - "clicks": { - "wins": 2, - "ties": 42, - "losses": 1 - }, - "timingCompared": true, - "timing": { - "wins": 45, - "ties": 0, - "losses": 0, - "geometricMeanSpeedup": 88.39310296954534, - "ratioOfSummedMedians": 51.13577565741108 - } - }, - "reference": { - "actions": { - "wins": 5, - "ties": 40, - "losses": 0 - }, - "uniquePlates": { - "wins": 0, - "ties": 45, - "losses": 0 - }, - "clicks": { - "wins": 0, - "ties": 39, - "losses": 6 - }, - "timingCompared": true, - "timing": { - "wins": 45, - "ties": 0, - "losses": 0, - "geometricMeanSpeedup": 52.86312829307665, - "ratioOfSummedMedians": 29.524657143885317 - } - }, - "historicalReference": { - "actions": { - "wins": 5, - "ties": 40, - "losses": 0 - }, - "uniquePlates": { - "wins": 0, - "ties": 45, - "losses": 0 - }, - "clicks": { - "wins": 0, - "ties": 39, - "losses": 6 - }, - "timingCompared": false - } - }, - "results": [ - { - "id": "ruined-tower-door", - "title": "Дверь Разрушенной башни", - "plateCount": 6, - "url": "https://unlockmyloot.com/locks/ruined-tower-door/", - "code": "aUkAACUBAAJikA", - "expectedActions": 17, - "solvers": { - "matrix-astar": { - "A": 17, - "U": 6, - "C": 57, - "plateSwitches": 16, - "commands": [ - { - "plate": 1, - "direction": "right", - "steps": 4 - }, - { - "plate": 3, - "direction": "left", - "steps": 5 - }, - { - "plate": 4, - "direction": "right", - "steps": 6 - }, - { - "plate": 2, - "direction": "left", - "steps": 1 - }, - { - "plate": 5, - "direction": "right", - "steps": 1 - }, - { - "plate": 2, - "direction": "left", - "steps": 1 - }, - { - "plate": 5, - "direction": "right", - "steps": 1 - }, - { - "plate": 6, - "direction": "right", - "steps": 2 - }, - { - "plate": 1, - "direction": "right", - "steps": 4 - }, - { - "plate": 2, - "direction": "left", - "steps": 3 - }, - { - "plate": 4, - "direction": "right", - "steps": 5 - }, - { - "plate": 5, - "direction": "right", - "steps": 3 - }, - { - "plate": 6, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "right", - "steps": 6 - }, - { - "plate": 2, - "direction": "left", - "steps": 6 - }, - { - "plate": 4, - "direction": "right", - "steps": 3 - }, - { - "plate": 5, - "direction": "right", - "steps": 3 - } - ], - "timingMs": { - "min": 3.45307600000001, - "median": 4.894904999999994, - "max": 9.461225000000013 - }, - "samplesMs": [ - 5.811674000000039, - 4.894904999999994, - 9.461225000000013, - 3.527486999999951, - 3.45307600000001 - ] - }, - "bfs": { - "A": 17, - "U": 6, - "C": 57, - "plateSwitches": 16, - "commands": [ - { - "plate": 1, - "direction": "right", - "steps": 4 - }, - { - "plate": 3, - "direction": "left", - "steps": 5 - }, - { - "plate": 4, - "direction": "right", - "steps": 6 - }, - { - "plate": 2, - "direction": "left", - "steps": 1 - }, - { - "plate": 5, - "direction": "right", - "steps": 1 - }, - { - "plate": 2, - "direction": "left", - "steps": 1 - }, - { - "plate": 5, - "direction": "right", - "steps": 1 - }, - { - "plate": 6, - "direction": "right", - "steps": 2 - }, - { - "plate": 1, - "direction": "right", - "steps": 4 - }, - { - "plate": 2, - "direction": "left", - "steps": 3 - }, - { - "plate": 4, - "direction": "right", - "steps": 5 - }, - { - "plate": 5, - "direction": "right", - "steps": 3 - }, - { - "plate": 6, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "right", - "steps": 6 - }, - { - "plate": 2, - "direction": "left", - "steps": 6 - }, - { - "plate": 4, - "direction": "right", - "steps": 3 - }, - { - "plate": 5, - "direction": "right", - "steps": 3 - } - ], - "timingMs": { - "min": 13.383325999999897, - "median": 15.879230000000007, - "max": 25.095195999999987 - }, - "samplesMs": [ - 25.095195999999987, - 15.879230000000007, - 17.286757999999963, - 15.852350000000001, - 13.383325999999897 - ] - }, - "baseline": { - "A": 17, - "U": 6, - "C": 57, - "plateSwitches": 16, - "commands": [ - { - "plate": 1, - "direction": "right", - "steps": 4 - }, - { - "plate": 3, - "direction": "left", - "steps": 5 - }, - { - "plate": 4, - "direction": "right", - "steps": 6 - }, - { - "plate": 2, - "direction": "left", - "steps": 1 - }, - { - "plate": 5, - "direction": "right", - "steps": 1 - }, - { - "plate": 2, - "direction": "left", - "steps": 1 - }, - { - "plate": 5, - "direction": "right", - "steps": 1 - }, - { - "plate": 6, - "direction": "right", - "steps": 2 - }, - { - "plate": 1, - "direction": "right", - "steps": 4 - }, - { - "plate": 2, - "direction": "left", - "steps": 3 - }, - { - "plate": 4, - "direction": "right", - "steps": 5 - }, - { - "plate": 5, - "direction": "right", - "steps": 3 - }, - { - "plate": 6, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "right", - "steps": 6 - }, - { - "plate": 2, - "direction": "left", - "steps": 6 - }, - { - "plate": 4, - "direction": "right", - "steps": 3 - }, - { - "plate": 5, - "direction": "right", - "steps": 3 - } - ], - "timingMs": { - "min": 77.48588100000006, - "median": 89.62647500000003, - "max": 108.14718999999991 - }, - "samplesMs": [ - 98.286856, - 108.14718999999991, - 89.62647500000003, - 87.04519200000004, - 77.48588100000006 - ] - }, - "reference": { - "A": 17, - "U": 6, - "C": 57, - "plateSwitches": 16, - "commands": [ - { - "plate": 3, - "direction": "left", - "steps": 5 - }, - { - "plate": 4, - "direction": "right", - "steps": 5 - }, - { - "plate": 2, - "direction": "left", - "steps": 1 - }, - { - "plate": 5, - "direction": "right", - "steps": 1 - }, - { - "plate": 2, - "direction": "left", - "steps": 1 - }, - { - "plate": 5, - "direction": "right", - "steps": 1 - }, - { - "plate": 1, - "direction": "right", - "steps": 5 - }, - { - "plate": 6, - "direction": "right", - "steps": 2 - }, - { - "plate": 2, - "direction": "left", - "steps": 3 - }, - { - "plate": 5, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "right", - "steps": 6 - }, - { - "plate": 6, - "direction": "right", - "steps": 3 - }, - { - "plate": 4, - "direction": "right", - "steps": 6 - }, - { - "plate": 2, - "direction": "left", - "steps": 6 - }, - { - "plate": 5, - "direction": "right", - "steps": 3 - }, - { - "plate": 4, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - } - ], - "timingMs": { - "min": 79.48287400000004, - "median": 85.13851399999999, - "max": 124.03954099999999 - }, - "samplesMs": [ - 94.57184599999994, - 79.48287400000004, - 124.03954099999999, - 83.54486599999996, - 85.13851399999999 - ] - } - }, - "historicalReference": { - "kind": "cached-observation", - "capturedAt": "2026-09-07T06:02:47.149Z", - "A": 17, - "U": 6, - "C": 57, - "plateSwitches": 16, - "commands": [ - { - "plate": 3, - "direction": "left", - "steps": 5 - }, - { - "plate": 4, - "direction": "right", - "steps": 5 - }, - { - "plate": 2, - "direction": "left", - "steps": 1 - }, - { - "plate": 5, - "direction": "right", - "steps": 1 - }, - { - "plate": 2, - "direction": "left", - "steps": 1 - }, - { - "plate": 5, - "direction": "right", - "steps": 1 - }, - { - "plate": 1, - "direction": "right", - "steps": 5 - }, - { - "plate": 6, - "direction": "right", - "steps": 2 - }, - { - "plate": 2, - "direction": "left", - "steps": 3 - }, - { - "plate": 5, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "right", - "steps": 6 - }, - { - "plate": 6, - "direction": "right", - "steps": 3 - }, - { - "plate": 4, - "direction": "right", - "steps": 6 - }, - { - "plate": 2, - "direction": "left", - "steps": 6 - }, - { - "plate": 5, - "direction": "right", - "steps": 3 - }, - { - "plate": 4, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - } - ], - "historicalTimingMs": { - "min": 91.817025, - "median": 96.49636299999997, - "max": 126.86948000000007 - } - } - }, - { - "id": "scatty-chest", - "title": "Сундук Скатти", - "plateCount": 5, - "url": "https://unlockmyloot.com/locks/scatty-chest/", - "code": "SojAgCBDQA", - "expectedActions": 9, - "solvers": { - "matrix-astar": { - "A": 9, - "U": 5, - "C": 31, - "plateSwitches": 8, - "commands": [ - { - "plate": 3, - "direction": "left", - "steps": 5 - }, - { - "plate": 5, - "direction": "left", - "steps": 5 - }, - { - "plate": 2, - "direction": "right", - "steps": 1 - }, - { - "plate": 4, - "direction": "left", - "steps": 5 - }, - { - "plate": 5, - "direction": "left", - "steps": 2 - }, - { - "plate": 1, - "direction": "right", - "steps": 2 - }, - { - "plate": 3, - "direction": "left", - "steps": 2 - }, - { - "plate": 5, - "direction": "left", - "steps": 6 - }, - { - "plate": 2, - "direction": "right", - "steps": 3 - } - ], - "timingMs": { - "min": 0.1425249999999778, - "median": 0.19603500000016538, - "max": 0.24954500000012558 - }, - "samplesMs": [ - 0.24954500000012558, - 0.22157300000003488, - 0.19603500000016538, - 0.1425249999999778, - 0.15903899999989335 - ] - }, - "bfs": { - "A": 9, - "U": 5, - "C": 31, - "plateSwitches": 8, - "commands": [ - { - "plate": 2, - "direction": "right", - "steps": 2 - }, - { - "plate": 3, - "direction": "left", - "steps": 4 - }, - { - "plate": 5, - "direction": "left", - "steps": 6 - }, - { - "plate": 4, - "direction": "left", - "steps": 5 - }, - { - "plate": 1, - "direction": "right", - "steps": 2 - }, - { - "plate": 5, - "direction": "left", - "steps": 5 - }, - { - "plate": 2, - "direction": "right", - "steps": 2 - }, - { - "plate": 3, - "direction": "left", - "steps": 3 - }, - { - "plate": 5, - "direction": "left", - "steps": 2 - } - ], - "timingMs": { - "min": 2.27847799999995, - "median": 2.334081999999853, - "max": 3.2826689999999417 - }, - "samplesMs": [ - 3.193435000000136, - 3.2826689999999417, - 2.27847799999995, - 2.334081999999853, - 2.299539999999979 - ] - }, - "baseline": { - "A": 9, - "U": 5, - "C": 31, - "plateSwitches": 8, - "commands": [ - { - "plate": 2, - "direction": "right", - "steps": 2 - }, - { - "plate": 3, - "direction": "left", - "steps": 4 - }, - { - "plate": 5, - "direction": "left", - "steps": 6 - }, - { - "plate": 4, - "direction": "left", - "steps": 5 - }, - { - "plate": 1, - "direction": "right", - "steps": 2 - }, - { - "plate": 5, - "direction": "left", - "steps": 5 - }, - { - "plate": 2, - "direction": "right", - "steps": 2 - }, - { - "plate": 3, - "direction": "left", - "steps": 3 - }, - { - "plate": 5, - "direction": "left", - "steps": 2 - } - ], - "timingMs": { - "min": 11.29907900000012, - "median": 11.719831999999997, - "max": 14.03815099999997 - }, - "samplesMs": [ - 14.03815099999997, - 12.689040999999861, - 11.719831999999997, - 11.29907900000012, - 11.465169999999944 - ] - }, - "reference": { - "A": 9, - "U": 5, - "C": 31, - "plateSwitches": 8, - "commands": [ - { - "plate": 3, - "direction": "left", - "steps": 5 - }, - { - "plate": 2, - "direction": "right", - "steps": 1 - }, - { - "plate": 5, - "direction": "left", - "steps": 6 - }, - { - "plate": 4, - "direction": "left", - "steps": 5 - }, - { - "plate": 5, - "direction": "left", - "steps": 5 - }, - { - "plate": 3, - "direction": "left", - "steps": 2 - }, - { - "plate": 5, - "direction": "left", - "steps": 2 - }, - { - "plate": 2, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "right", - "steps": 2 - } - ], - "timingMs": { - "min": 5.317440000000033, - "median": 8.441120000000183, - "max": 15.322402999999895 - }, - "samplesMs": [ - 8.441120000000183, - 8.47560199999998, - 5.317440000000033, - 5.322917999999845, - 15.322402999999895 - ] - } - }, - "historicalReference": { - "kind": "cached-observation", - "capturedAt": "2026-09-07T06:02:47.149Z", - "A": 9, - "U": 5, - "C": 31, - "plateSwitches": 8, - "commands": [ - { - "plate": 3, - "direction": "left", - "steps": 5 - }, - { - "plate": 2, - "direction": "right", - "steps": 1 - }, - { - "plate": 5, - "direction": "left", - "steps": 6 - }, - { - "plate": 4, - "direction": "left", - "steps": 5 - }, - { - "plate": 5, - "direction": "left", - "steps": 5 - }, - { - "plate": 3, - "direction": "left", - "steps": 2 - }, - { - "plate": 5, - "direction": "left", - "steps": 2 - }, - { - "plate": 2, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "right", - "steps": 2 - } - ], - "historicalTimingMs": { - "min": 9.513588000000027, - "median": 9.766299000000117, - "max": 12.794235999999955 - } - } - }, - { - "id": "cor-galom-chest", - "title": "Сундук Кор Галома", - "plateCount": 4, - "url": "https://unlockmyloot.com/locks/cor-galom-chest/", - "code": "LagFFgA", - "expectedActions": 5, - "solvers": { - "matrix-astar": { - "A": 5, - "U": 4, - "C": 15, - "plateSwitches": 4, - "commands": [ - { - "plate": 4, - "direction": "left", - "steps": 4 - }, - { - "plate": 2, - "direction": "right", - "steps": 4 - }, - { - "plate": 1, - "direction": "left", - "steps": 4 - }, - { - "plate": 2, - "direction": "right", - "steps": 1 - }, - { - "plate": 3, - "direction": "right", - "steps": 2 - } - ], - "timingMs": { - "min": 0.04282399999988229, - "median": 0.05560399999990295, - "max": 0.08443699999997989 - }, - "samplesMs": [ - 0.05560399999990295, - 0.08443699999997989, - 0.058818999999857624, - 0.04584899999986192, - 0.04282399999988229 - ] - }, - "bfs": { - "A": 5, - "U": 4, - "C": 19, - "plateSwitches": 4, - "commands": [ - { - "plate": 4, - "direction": "left", - "steps": 6 - }, - { - "plate": 2, - "direction": "right", - "steps": 5 - }, - { - "plate": 1, - "direction": "left", - "steps": 4 - }, - { - "plate": 3, - "direction": "right", - "steps": 2 - }, - { - "plate": 4, - "direction": "right", - "steps": 2 - } - ], - "timingMs": { - "min": 0.12644000000000233, - "median": 0.12936400000012327, - "max": 0.1602109999998902 - }, - "samplesMs": [ - 0.1288839999999709, - 0.12968500000010863, - 0.1602109999998902, - 0.12936400000012327, - 0.12644000000000233 - ] - }, - "baseline": { - "A": 5, - "U": 4, - "C": 19, - "plateSwitches": 4, - "commands": [ - { - "plate": 4, - "direction": "left", - "steps": 6 - }, - { - "plate": 2, - "direction": "right", - "steps": 5 - }, - { - "plate": 1, - "direction": "left", - "steps": 4 - }, - { - "plate": 3, - "direction": "right", - "steps": 2 - }, - { - "plate": 4, - "direction": "right", - "steps": 2 - } - ], - "timingMs": { - "min": 0.45256099999983235, - "median": 0.4729010000000926, - "max": 0.49103900000000067 - }, - "samplesMs": [ - 0.49103900000000067, - 0.47248100000001614, - 0.4788700000001427, - 0.4729010000000926, - 0.45256099999983235 - ] - }, - "reference": { - "A": 5, - "U": 4, - "C": 15, - "plateSwitches": 4, - "commands": [ - { - "plate": 4, - "direction": "left", - "steps": 4 - }, - { - "plate": 2, - "direction": "right", - "steps": 4 - }, - { - "plate": 3, - "direction": "right", - "steps": 2 - }, - { - "plate": 1, - "direction": "left", - "steps": 4 - }, - { - "plate": 2, - "direction": "right", - "steps": 1 - } - ], - "timingMs": { - "min": 0.30269499999985783, - "median": 0.32210399999985384, - "max": 0.38023199999997814 - }, - "samplesMs": [ - 0.38023199999997814, - 0.32210399999985384, - 0.31444299999998293, - 0.30269499999985783, - 0.33010699999999815 - ] - } - }, - "historicalReference": { - "kind": "cached-observation", - "capturedAt": "2026-09-07T06:02:47.149Z", - "A": 5, - "U": 4, - "C": 15, - "plateSwitches": 4, - "commands": [ - { - "plate": 4, - "direction": "left", - "steps": 4 - }, - { - "plate": 2, - "direction": "right", - "steps": 4 - }, - { - "plate": 3, - "direction": "right", - "steps": 2 - }, - { - "plate": 1, - "direction": "left", - "steps": 4 - }, - { - "plate": 2, - "direction": "right", - "steps": 1 - } - ], - "historicalTimingMs": { - "min": 0.3375069999999596, - "median": 0.507082999999966, - "max": 0.6312800000000607 - } - } - }, - { - "id": "cor-galom-bedroom-chest", - "title": "Сундук в спальне Кор Галома", - "plateCount": 6, - "url": "https://unlockmyloot.com/locks/cor-galom-bedroom-chest/", - "code": "a2YMQAAIiECEAA", - "expectedActions": 14, - "solvers": { - "matrix-astar": { - "A": 14, - "U": 5, - "C": 60, - "plateSwitches": 13, - "commands": [ - { - "plate": 2, - "direction": "right", - "steps": 5 - }, - { - "plate": 3, - "direction": "right", - "steps": 4 - }, - { - "plate": 2, - "direction": "right", - "steps": 4 - }, - { - "plate": 5, - "direction": "right", - "steps": 4 - }, - { - "plate": 2, - "direction": "right", - "steps": 4 - }, - { - "plate": 4, - "direction": "left", - "steps": 4 - }, - { - "plate": 3, - "direction": "right", - "steps": 4 - }, - { - "plate": 1, - "direction": "left", - "steps": 5 - }, - { - "plate": 3, - "direction": "right", - "steps": 2 - }, - { - "plate": 2, - "direction": "right", - "steps": 6 - }, - { - "plate": 5, - "direction": "right", - "steps": 6 - }, - { - "plate": 2, - "direction": "right", - "steps": 6 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 4, - "direction": "left", - "steps": 3 - } - ], - "timingMs": { - "min": 0.246550999999954, - "median": 0.32631099999980506, - "max": 0.40945699999997487 - }, - "samplesMs": [ - 0.3410030000000006, - 0.246550999999954, - 0.40945699999997487, - 0.2560849999999846, - 0.32631099999980506 - ] - }, - "bfs": { - "A": 14, - "U": 5, - "C": 60, - "plateSwitches": 13, - "commands": [ - { - "plate": 1, - "direction": "left", - "steps": 1 - }, - { - "plate": 2, - "direction": "right", - "steps": 5 - }, - { - "plate": 3, - "direction": "right", - "steps": 5 - }, - { - "plate": 2, - "direction": "right", - "steps": 5 - }, - { - "plate": 5, - "direction": "right", - "steps": 5 - }, - { - "plate": 2, - "direction": "right", - "steps": 5 - }, - { - "plate": 4, - "direction": "left", - "steps": 5 - }, - { - "plate": 3, - "direction": "right", - "steps": 5 - }, - { - "plate": 1, - "direction": "left", - "steps": 4 - }, - { - "plate": 2, - "direction": "right", - "steps": 5 - }, - { - "plate": 5, - "direction": "right", - "steps": 5 - }, - { - "plate": 2, - "direction": "right", - "steps": 5 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 4, - "direction": "left", - "steps": 2 - } - ], - "timingMs": { - "min": 12.28265999999985, - "median": 12.730002999999897, - "max": 13.442364999999882 - }, - "samplesMs": [ - 12.721741000000065, - 12.730002999999897, - 13.442364999999882, - 13.175422000000026, - 12.28265999999985 - ] - }, - "baseline": { - "A": 14, - "U": 5, - "C": 60, - "plateSwitches": 13, - "commands": [ - { - "plate": 1, - "direction": "left", - "steps": 1 - }, - { - "plate": 2, - "direction": "right", - "steps": 5 - }, - { - "plate": 3, - "direction": "right", - "steps": 5 - }, - { - "plate": 2, - "direction": "right", - "steps": 5 - }, - { - "plate": 5, - "direction": "right", - "steps": 5 - }, - { - "plate": 2, - "direction": "right", - "steps": 5 - }, - { - "plate": 4, - "direction": "left", - "steps": 5 - }, - { - "plate": 3, - "direction": "right", - "steps": 5 - }, - { - "plate": 1, - "direction": "left", - "steps": 4 - }, - { - "plate": 2, - "direction": "right", - "steps": 5 - }, - { - "plate": 5, - "direction": "right", - "steps": 5 - }, - { - "plate": 2, - "direction": "right", - "steps": 5 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 4, - "direction": "left", - "steps": 2 - } - ], - "timingMs": { - "min": 70.64990499999976, - "median": 72.50764000000004, - "max": 74.4288610000001 - }, - "samplesMs": [ - 74.4288610000001, - 73.53681900000015, - 71.80593399999998, - 72.50764000000004, - 70.64990499999976 - ] - }, - "reference": { - "A": 14, - "U": 5, - "C": 60, - "plateSwitches": 13, - "commands": [ - { - "plate": 2, - "direction": "right", - "steps": 3 - }, - { - "plate": 3, - "direction": "right", - "steps": 4 - }, - { - "plate": 2, - "direction": "right", - "steps": 4 - }, - { - "plate": 5, - "direction": "right", - "steps": 4 - }, - { - "plate": 4, - "direction": "left", - "steps": 4 - }, - { - "plate": 2, - "direction": "right", - "steps": 6 - }, - { - "plate": 1, - "direction": "left", - "steps": 2 - }, - { - "plate": 3, - "direction": "right", - "steps": 6 - }, - { - "plate": 2, - "direction": "right", - "steps": 6 - }, - { - "plate": 5, - "direction": "right", - "steps": 6 - }, - { - "plate": 4, - "direction": "left", - "steps": 3 - }, - { - "plate": 2, - "direction": "right", - "steps": 6 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "left", - "steps": 3 - } - ], - "timingMs": { - "min": 29.965103999999883, - "median": 30.333096999999952, - "max": 38.59786200000008 - }, - "samplesMs": [ - 33.34818300000006, - 29.965103999999883, - 30.333096999999952, - 38.59786200000008, - 30.23817500000041 - ] - } - }, - "historicalReference": { - "kind": "cached-observation", - "capturedAt": "2026-09-07T06:02:47.149Z", - "A": 14, - "U": 5, - "C": 60, - "plateSwitches": 13, - "commands": [ - { - "plate": 2, - "direction": "right", - "steps": 3 - }, - { - "plate": 3, - "direction": "right", - "steps": 4 - }, - { - "plate": 2, - "direction": "right", - "steps": 4 - }, - { - "plate": 5, - "direction": "right", - "steps": 4 - }, - { - "plate": 4, - "direction": "left", - "steps": 4 - }, - { - "plate": 2, - "direction": "right", - "steps": 6 - }, - { - "plate": 1, - "direction": "left", - "steps": 2 - }, - { - "plate": 3, - "direction": "right", - "steps": 6 - }, - { - "plate": 2, - "direction": "right", - "steps": 6 - }, - { - "plate": 5, - "direction": "right", - "steps": 6 - }, - { - "plate": 4, - "direction": "left", - "steps": 3 - }, - { - "plate": 2, - "direction": "right", - "steps": 6 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "left", - "steps": 3 - } - ], - "historicalTimingMs": { - "min": 30.516717999999855, - "median": 31.42274099999986, - "max": 34.788095999999996 - } - } - }, - { - "id": "swamp-camp-forge-chest", - "title": "Сундук в кузнице Болотного лагеря", - "plateCount": 6, - "url": "https://unlockmyloot.com/locks/swamp-camp-forge-chest/", - "code": "YQY0gBgAAGACIE", - "expectedActions": 11, - "solvers": { - "matrix-astar": { - "A": 11, - "U": 6, - "C": 41, - "plateSwitches": 10, - "commands": [ - { - "plate": 3, - "direction": "left", - "steps": 6 - }, - { - "plate": 1, - "direction": "left", - "steps": 3 - }, - { - "plate": 3, - "direction": "left", - "steps": 3 - }, - { - "plate": 4, - "direction": "right", - "steps": 5 - }, - { - "plate": 3, - "direction": "left", - "steps": 5 - }, - { - "plate": 2, - "direction": "right", - "steps": 4 - }, - { - "plate": 6, - "direction": "right", - "steps": 4 - }, - { - "plate": 2, - "direction": "right", - "steps": 1 - }, - { - "plate": 3, - "direction": "left", - "steps": 5 - }, - { - "plate": 4, - "direction": "right", - "steps": 3 - }, - { - "plate": 5, - "direction": "right", - "steps": 2 - } - ], - "timingMs": { - "min": 0.2597609999997985, - "median": 0.2701670000001286, - "max": 0.3253899999999703 - }, - "samplesMs": [ - 0.2597609999997985, - 0.2701670000001286, - 0.3253899999999703, - 0.26081299999987095, - 0.30051200000025347 - ] - }, - "bfs": { - "A": 11, - "U": 6, - "C": 41, - "plateSwitches": 10, - "commands": [ - { - "plate": 3, - "direction": "left", - "steps": 6 - }, - { - "plate": 1, - "direction": "left", - "steps": 3 - }, - { - "plate": 3, - "direction": "left", - "steps": 3 - }, - { - "plate": 4, - "direction": "right", - "steps": 5 - }, - { - "plate": 3, - "direction": "left", - "steps": 5 - }, - { - "plate": 2, - "direction": "right", - "steps": 4 - }, - { - "plate": 6, - "direction": "right", - "steps": 4 - }, - { - "plate": 2, - "direction": "right", - "steps": 1 - }, - { - "plate": 3, - "direction": "left", - "steps": 5 - }, - { - "plate": 4, - "direction": "right", - "steps": 3 - }, - { - "plate": 5, - "direction": "right", - "steps": 2 - } - ], - "timingMs": { - "min": 17.623634999999922, - "median": 17.761071999999785, - "max": 18.015034000000014 - }, - "samplesMs": [ - 17.761071999999785, - 18.015034000000014, - 17.84554900000012, - 17.751026999999794, - 17.623634999999922 - ] - }, - "baseline": { - "A": 11, - "U": 6, - "C": 41, - "plateSwitches": 10, - "commands": [ - { - "plate": 3, - "direction": "left", - "steps": 6 - }, - { - "plate": 1, - "direction": "left", - "steps": 3 - }, - { - "plate": 3, - "direction": "left", - "steps": 3 - }, - { - "plate": 4, - "direction": "right", - "steps": 5 - }, - { - "plate": 3, - "direction": "left", - "steps": 5 - }, - { - "plate": 2, - "direction": "right", - "steps": 4 - }, - { - "plate": 6, - "direction": "right", - "steps": 4 - }, - { - "plate": 2, - "direction": "right", - "steps": 1 - }, - { - "plate": 3, - "direction": "left", - "steps": 5 - }, - { - "plate": 4, - "direction": "right", - "steps": 3 - }, - { - "plate": 5, - "direction": "right", - "steps": 2 - } - ], - "timingMs": { - "min": 105.26430400000027, - "median": 107.644315, - "max": 113.35371499999974 - }, - "samplesMs": [ - 105.26430400000027, - 107.644315, - 113.35371499999974, - 107.33653100000038, - 108.5920799999999 - ] - }, - "reference": { - "A": 11, - "U": 6, - "C": 41, - "plateSwitches": 10, - "commands": [ - { - "plate": 3, - "direction": "left", - "steps": 5 - }, - { - "plate": 4, - "direction": "right", - "steps": 5 - }, - { - "plate": 3, - "direction": "left", - "steps": 6 - }, - { - "plate": 2, - "direction": "right", - "steps": 3 - }, - { - "plate": 6, - "direction": "right", - "steps": 4 - }, - { - "plate": 5, - "direction": "right", - "steps": 2 - }, - { - "plate": 4, - "direction": "right", - "steps": 3 - }, - { - "plate": 3, - "direction": "left", - "steps": 6 - }, - { - "plate": 2, - "direction": "right", - "steps": 2 - }, - { - "plate": 3, - "direction": "left", - "steps": 2 - }, - { - "plate": 1, - "direction": "left", - "steps": 3 - } - ], - "timingMs": { - "min": 35.30947400000014, - "median": 36.690352000000075, - "max": 40.80572399999983 - }, - "samplesMs": [ - 40.80572399999983, - 36.690352000000075, - 36.14560299999994, - 39.86313500000006, - 35.30947400000014 - ] - } - }, - "historicalReference": { - "kind": "cached-observation", - "capturedAt": "2026-09-07T06:02:47.149Z", - "A": 11, - "U": 6, - "C": 41, - "plateSwitches": 10, - "commands": [ - { - "plate": 3, - "direction": "left", - "steps": 5 - }, - { - "plate": 4, - "direction": "right", - "steps": 5 - }, - { - "plate": 3, - "direction": "left", - "steps": 6 - }, - { - "plate": 2, - "direction": "right", - "steps": 3 - }, - { - "plate": 6, - "direction": "right", - "steps": 4 - }, - { - "plate": 5, - "direction": "right", - "steps": 2 - }, - { - "plate": 4, - "direction": "right", - "steps": 3 - }, - { - "plate": 3, - "direction": "left", - "steps": 6 - }, - { - "plate": 2, - "direction": "right", - "steps": 2 - }, - { - "plate": 3, - "direction": "left", - "steps": 2 - }, - { - "plate": 1, - "direction": "left", - "steps": 3 - } - ], - "historicalTimingMs": { - "min": 36.47057800000039, - "median": 37.77093500000001, - "max": 38.26945599999999 - } - } - }, - { - "id": "rice-lord-storeroom", - "title": "Склад Рисового лорда (3-й этаж)", - "plateCount": 5, - "url": "https://unlockmyloot.com/locks/rice-lord-storeroom/", - "code": "RY2VUBQAiA", - "expectedActions": 9, - "solvers": { - "matrix-astar": { - "A": 9, - "U": 5, - "C": 30, - "plateSwitches": 8, - "commands": [ - { - "plate": 1, - "direction": "left", - "steps": 1 - }, - { - "plate": 5, - "direction": "left", - "steps": 3 - }, - { - "plate": 3, - "direction": "left", - "steps": 4 - }, - { - "plate": 2, - "direction": "left", - "steps": 2 - }, - { - "plate": 1, - "direction": "left", - "steps": 3 - }, - { - "plate": 4, - "direction": "left", - "steps": 5 - }, - { - "plate": 5, - "direction": "left", - "steps": 3 - }, - { - "plate": 2, - "direction": "left", - "steps": 6 - }, - { - "plate": 1, - "direction": "left", - "steps": 3 - } - ], - "timingMs": { - "min": 0.13477199999988443, - "median": 0.16157299999986208, - "max": 0.17313999999987573 - }, - "samplesMs": [ - 0.16157299999986208, - 0.13477199999988443, - 0.17313999999987573, - 0.1627150000003894, - 0.15835799999968003 - ] - }, - "bfs": { - "A": 9, - "U": 5, - "C": 30, - "plateSwitches": 8, - "commands": [ - { - "plate": 1, - "direction": "left", - "steps": 1 - }, - { - "plate": 5, - "direction": "left", - "steps": 3 - }, - { - "plate": 3, - "direction": "left", - "steps": 4 - }, - { - "plate": 2, - "direction": "left", - "steps": 2 - }, - { - "plate": 1, - "direction": "left", - "steps": 3 - }, - { - "plate": 4, - "direction": "left", - "steps": 5 - }, - { - "plate": 5, - "direction": "left", - "steps": 3 - }, - { - "plate": 2, - "direction": "left", - "steps": 6 - }, - { - "plate": 1, - "direction": "left", - "steps": 3 - } - ], - "timingMs": { - "min": 1.684364000000187, - "median": 1.6987959999996747, - "max": 1.9495330000004287 - }, - "samplesMs": [ - 1.684364000000187, - 1.7642550000000483, - 1.6882299999997485, - 1.9495330000004287, - 1.6987959999996747 - ] - }, - "baseline": { - "A": 9, - "U": 5, - "C": 30, - "plateSwitches": 8, - "commands": [ - { - "plate": 1, - "direction": "left", - "steps": 1 - }, - { - "plate": 5, - "direction": "left", - "steps": 3 - }, - { - "plate": 3, - "direction": "left", - "steps": 4 - }, - { - "plate": 2, - "direction": "left", - "steps": 2 - }, - { - "plate": 1, - "direction": "left", - "steps": 3 - }, - { - "plate": 4, - "direction": "left", - "steps": 5 - }, - { - "plate": 5, - "direction": "left", - "steps": 3 - }, - { - "plate": 2, - "direction": "left", - "steps": 6 - }, - { - "plate": 1, - "direction": "left", - "steps": 3 - } - ], - "timingMs": { - "min": 8.109771999999793, - "median": 8.252085999999963, - "max": 8.363103000000137 - }, - "samplesMs": [ - 8.363103000000137, - 8.211424000000079, - 8.109771999999793, - 8.279035999999905, - 8.252085999999963 - ] - }, - "reference": { - "A": 9, - "U": 5, - "C": 30, - "plateSwitches": 8, - "commands": [ - { - "plate": 1, - "direction": "left", - "steps": 1 - }, - { - "plate": 5, - "direction": "left", - "steps": 3 - }, - { - "plate": 3, - "direction": "left", - "steps": 4 - }, - { - "plate": 2, - "direction": "left", - "steps": 2 - }, - { - "plate": 1, - "direction": "left", - "steps": 3 - }, - { - "plate": 5, - "direction": "left", - "steps": 3 - }, - { - "plate": 4, - "direction": "left", - "steps": 5 - }, - { - "plate": 2, - "direction": "left", - "steps": 6 - }, - { - "plate": 1, - "direction": "left", - "steps": 3 - } - ], - "timingMs": { - "min": 4.524296999999933, - "median": 4.608883999999762, - "max": 4.867021999999906 - }, - "samplesMs": [ - 4.68925500000023, - 4.524296999999933, - 4.867021999999906, - 4.535954999999831, - 4.608883999999762 - ] - } - }, - "historicalReference": { - "kind": "cached-observation", - "capturedAt": "2026-09-07T06:02:47.149Z", - "A": 9, - "U": 5, - "C": 30, - "plateSwitches": 8, - "commands": [ - { - "plate": 1, - "direction": "left", - "steps": 1 - }, - { - "plate": 5, - "direction": "left", - "steps": 3 - }, - { - "plate": 3, - "direction": "left", - "steps": 4 - }, - { - "plate": 2, - "direction": "left", - "steps": 2 - }, - { - "plate": 1, - "direction": "left", - "steps": 3 - }, - { - "plate": 5, - "direction": "left", - "steps": 3 - }, - { - "plate": 4, - "direction": "left", - "steps": 5 - }, - { - "plate": 2, - "direction": "left", - "steps": 6 - }, - { - "plate": 1, - "direction": "left", - "steps": 3 - } - ], - "historicalTimingMs": { - "min": 4.488335000000006, - "median": 4.637408999999934, - "max": 4.859664000000066 - } - } - }, - { - "id": "gomez-chambers-door", - "title": "Дверь в покои Гомеза", - "plateCount": 6, - "url": "https://unlockmyloot.com/locks/gomez-chambers-door/", - "code": "YiNAIJpEEAiAAA", - "expectedActions": 9, - "solvers": { - "matrix-astar": { - "A": 9, - "U": 6, - "C": 28, - "plateSwitches": 8, - "commands": [ - { - "plate": 4, - "direction": "right", - "steps": 3 - }, - { - "plate": 3, - "direction": "left", - "steps": 2 - }, - { - "plate": 2, - "direction": "right", - "steps": 2 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - }, - { - "plate": 3, - "direction": "left", - "steps": 2 - }, - { - "plate": 5, - "direction": "right", - "steps": 4 - }, - { - "plate": 6, - "direction": "right", - "steps": 4 - }, - { - "plate": 4, - "direction": "right", - "steps": 5 - }, - { - "plate": 2, - "direction": "right", - "steps": 3 - } - ], - "timingMs": { - "min": 0.21509299999979703, - "median": 0.2234960000000683, - "max": 0.40059300000029907 - }, - "samplesMs": [ - 0.2234960000000683, - 0.40059300000029907, - 0.28184399999963716, - 0.21509299999979703, - 0.22211400000014692 - ] - }, - "bfs": { - "A": 9, - "U": 6, - "C": 28, - "plateSwitches": 8, - "commands": [ - { - "plate": 3, - "direction": "left", - "steps": 2 - }, - { - "plate": 2, - "direction": "right", - "steps": 2 - }, - { - "plate": 3, - "direction": "left", - "steps": 2 - }, - { - "plate": 4, - "direction": "right", - "steps": 4 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - }, - { - "plate": 5, - "direction": "right", - "steps": 4 - }, - { - "plate": 6, - "direction": "right", - "steps": 4 - }, - { - "plate": 4, - "direction": "right", - "steps": 4 - }, - { - "plate": 2, - "direction": "right", - "steps": 3 - } - ], - "timingMs": { - "min": 13.604608000000098, - "median": 15.005065000000286, - "max": 17.20152999999982 - }, - "samplesMs": [ - 14.522829999999885, - 17.20152999999982, - 15.005065000000286, - 13.604608000000098, - 15.536575000000084 - ] - }, - "baseline": { - "A": 9, - "U": 6, - "C": 28, - "plateSwitches": 8, - "commands": [ - { - "plate": 3, - "direction": "left", - "steps": 2 - }, - { - "plate": 2, - "direction": "right", - "steps": 2 - }, - { - "plate": 3, - "direction": "left", - "steps": 2 - }, - { - "plate": 4, - "direction": "right", - "steps": 4 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - }, - { - "plate": 5, - "direction": "right", - "steps": 4 - }, - { - "plate": 6, - "direction": "right", - "steps": 4 - }, - { - "plate": 4, - "direction": "right", - "steps": 4 - }, - { - "plate": 2, - "direction": "right", - "steps": 3 - } - ], - "timingMs": { - "min": 75.37116799999967, - "median": 80.54447200000004, - "max": 83.78035 - }, - "samplesMs": [ - 79.58682099999987, - 82.10630200000014, - 83.78035, - 80.54447200000004, - 75.37116799999967 - ] - }, - "reference": { - "A": 9, - "U": 6, - "C": 28, - "plateSwitches": 8, - "commands": [ - { - "plate": 3, - "direction": "left", - "steps": 2 - }, - { - "plate": 2, - "direction": "right", - "steps": 2 - }, - { - "plate": 5, - "direction": "right", - "steps": 4 - }, - { - "plate": 4, - "direction": "right", - "steps": 5 - }, - { - "plate": 2, - "direction": "right", - "steps": 3 - }, - { - "plate": 4, - "direction": "right", - "steps": 3 - }, - { - "plate": 6, - "direction": "right", - "steps": 4 - }, - { - "plate": 3, - "direction": "left", - "steps": 2 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - } - ], - "timingMs": { - "min": 28.923345999999583, - "median": 30.335059999999885, - "max": 35.09097500000007 - }, - "samplesMs": [ - 29.580093999999917, - 30.335059999999885, - 35.09097500000007, - 31.36678299999994, - 28.923345999999583 - ] - } - }, - "historicalReference": { - "kind": "cached-observation", - "capturedAt": "2026-09-07T06:02:47.149Z", - "A": 9, - "U": 6, - "C": 28, - "plateSwitches": 8, - "commands": [ - { - "plate": 3, - "direction": "left", - "steps": 2 - }, - { - "plate": 2, - "direction": "right", - "steps": 2 - }, - { - "plate": 5, - "direction": "right", - "steps": 4 - }, - { - "plate": 4, - "direction": "right", - "steps": 5 - }, - { - "plate": 2, - "direction": "right", - "steps": 3 - }, - { - "plate": 4, - "direction": "right", - "steps": 3 - }, - { - "plate": 6, - "direction": "right", - "steps": 4 - }, - { - "plate": 3, - "direction": "left", - "steps": 2 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - } - ], - "historicalTimingMs": { - "min": 29.93992900000012, - "median": 30.68829599999981, - "max": 31.623243999999886 - } - } - }, - { - "id": "gomez-throne-hall-chest", - "title": "Сундук в тронном зале у Гомеза", - "plateCount": 4, - "url": "https://unlockmyloot.com/locks/gomez-throne-hall-chest/", - "code": "IIAKWgA", - "expectedActions": 5, - "solvers": { - "matrix-astar": { - "A": 5, - "U": 4, - "C": 16, - "plateSwitches": 4, - "commands": [ - { - "plate": 4, - "direction": "left", - "steps": 6 - }, - { - "plate": 1, - "direction": "left", - "steps": 6 - }, - { - "plate": 2, - "direction": "left", - "steps": 2 - }, - { - "plate": 3, - "direction": "left", - "steps": 1 - }, - { - "plate": 4, - "direction": "left", - "steps": 1 - } - ], - "timingMs": { - "min": 0.0261000000000422, - "median": 0.03207799999972849, - "max": 0.03897800000004281 - }, - "samplesMs": [ - 0.03560400000014852, - 0.03897800000004281, - 0.03207799999972849, - 0.0261000000000422, - 0.028272000000015396 - ] - }, - "bfs": { - "A": 5, - "U": 4, - "C": 16, - "plateSwitches": 4, - "commands": [ - { - "plate": 4, - "direction": "left", - "steps": 6 - }, - { - "plate": 1, - "direction": "left", - "steps": 6 - }, - { - "plate": 2, - "direction": "left", - "steps": 2 - }, - { - "plate": 3, - "direction": "left", - "steps": 1 - }, - { - "plate": 4, - "direction": "left", - "steps": 1 - } - ], - "timingMs": { - "min": 0.048131999999895925, - "median": 0.05973999999969237, - "max": 0.06918399999995017 - }, - "samplesMs": [ - 0.061361999999917316, - 0.05973999999969237, - 0.05593399999997928, - 0.048131999999895925, - 0.06918399999995017 - ] - }, - "baseline": { - "A": 5, - "U": 4, - "C": 16, - "plateSwitches": 4, - "commands": [ - { - "plate": 4, - "direction": "left", - "steps": 6 - }, - { - "plate": 1, - "direction": "left", - "steps": 6 - }, - { - "plate": 2, - "direction": "left", - "steps": 2 - }, - { - "plate": 3, - "direction": "left", - "steps": 1 - }, - { - "plate": 4, - "direction": "left", - "steps": 1 - } - ], - "timingMs": { - "min": 0.1722789999998895, - "median": 0.1746619999998984, - "max": 0.18212399999993067 - }, - "samplesMs": [ - 0.18212399999993067, - 0.1810219999997571, - 0.1746619999998984, - 0.17381199999999808, - 0.1722789999998895 - ] - }, - "reference": { - "A": 5, - "U": 4, - "C": 16, - "plateSwitches": 4, - "commands": [ - { - "plate": 4, - "direction": "left", - "steps": 6 - }, - { - "plate": 1, - "direction": "left", - "steps": 6 - }, - { - "plate": 4, - "direction": "left", - "steps": 1 - }, - { - "plate": 3, - "direction": "left", - "steps": 1 - }, - { - "plate": 2, - "direction": "left", - "steps": 2 - } - ], - "timingMs": { - "min": 0.21751700000004348, - "median": 0.23293100000000777, - "max": 0.25035699999989447 - }, - "samplesMs": [ - 0.2355040000002191, - 0.23293100000000777, - 0.25035699999989447, - 0.21751700000004348, - 0.22672099999999773 - ] - } - }, - "historicalReference": { - "kind": "cached-observation", - "capturedAt": "2026-09-07T06:02:47.149Z", - "A": 5, - "U": 4, - "C": 16, - "plateSwitches": 4, - "commands": [ - { - "plate": 4, - "direction": "left", - "steps": 6 - }, - { - "plate": 1, - "direction": "left", - "steps": 6 - }, - { - "plate": 4, - "direction": "left", - "steps": 1 - }, - { - "plate": 3, - "direction": "left", - "steps": 1 - }, - { - "plate": 2, - "direction": "left", - "steps": 2 - } - ], - "historicalTimingMs": { - "min": 0.25226000000020576, - "median": 0.3677139999999781, - "max": 0.39675699999952485 - } - } - }, - { - "id": "old-camp-dungeon-door", - "title": "Дверь в темнице с расстрелянным призраком", - "plateCount": 6, - "url": "https://unlockmyloot.com/locks/old-camp-dungeon-door/", - "code": "YMTCAKIgIEBiAA", - "expectedActions": 17, - "solvers": { - "matrix-astar": { - "A": 17, - "U": 6, - "C": 72, - "plateSwitches": 16, - "commands": [ - { - "plate": 5, - "direction": "right", - "steps": 2 - }, - { - "plate": 2, - "direction": "right", - "steps": 1 - }, - { - "plate": 4, - "direction": "left", - "steps": 2 - }, - { - "plate": 3, - "direction": "right", - "steps": 4 - }, - { - "plate": 6, - "direction": "right", - "steps": 5 - }, - { - "plate": 1, - "direction": "right", - "steps": 5 - }, - { - "plate": 5, - "direction": "right", - "steps": 5 - }, - { - "plate": 6, - "direction": "right", - "steps": 6 - }, - { - "plate": 3, - "direction": "right", - "steps": 6 - }, - { - "plate": 6, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "right", - "steps": 6 - }, - { - "plate": 2, - "direction": "right", - "steps": 6 - }, - { - "plate": 1, - "direction": "right", - "steps": 6 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 4, - "direction": "right", - "steps": 3 - }, - { - "plate": 6, - "direction": "right", - "steps": 6 - }, - { - "plate": 5, - "direction": "right", - "steps": 3 - } - ], - "timingMs": { - "min": 2.305848999999398, - "median": 2.3744320000005246, - "max": 2.994915999999648 - }, - "samplesMs": [ - 2.305848999999398, - 2.994915999999648, - 2.3744320000005246, - 2.3572570000005726, - 2.465860000000248 - ] - }, - "bfs": { - "A": 17, - "U": 6, - "C": 72, - "plateSwitches": 16, - "commands": [ - { - "plate": 4, - "direction": "left", - "steps": 2 - }, - { - "plate": 5, - "direction": "right", - "steps": 2 - }, - { - "plate": 2, - "direction": "right", - "steps": 1 - }, - { - "plate": 3, - "direction": "right", - "steps": 4 - }, - { - "plate": 1, - "direction": "right", - "steps": 5 - }, - { - "plate": 6, - "direction": "right", - "steps": 6 - }, - { - "plate": 5, - "direction": "right", - "steps": 5 - }, - { - "plate": 6, - "direction": "right", - "steps": 5 - }, - { - "plate": 3, - "direction": "right", - "steps": 6 - }, - { - "plate": 1, - "direction": "right", - "steps": 6 - }, - { - "plate": 2, - "direction": "right", - "steps": 6 - }, - { - "plate": 1, - "direction": "right", - "steps": 6 - }, - { - "plate": 4, - "direction": "right", - "steps": 3 - }, - { - "plate": 6, - "direction": "right", - "steps": 6 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 5, - "direction": "right", - "steps": 3 - }, - { - "plate": 6, - "direction": "right", - "steps": 3 - } - ], - "timingMs": { - "min": 14.948962000000392, - "median": 15.437987000000248, - "max": 19.826901000000362 - }, - "samplesMs": [ - 15.437987000000248, - 14.948962000000392, - 15.152798000000075, - 19.826901000000362, - 15.456804999999804 - ] - }, - "baseline": { - "A": 17, - "U": 6, - "C": 72, - "plateSwitches": 16, - "commands": [ - { - "plate": 4, - "direction": "left", - "steps": 2 - }, - { - "plate": 5, - "direction": "right", - "steps": 2 - }, - { - "plate": 2, - "direction": "right", - "steps": 1 - }, - { - "plate": 3, - "direction": "right", - "steps": 4 - }, - { - "plate": 1, - "direction": "right", - "steps": 5 - }, - { - "plate": 6, - "direction": "right", - "steps": 6 - }, - { - "plate": 5, - "direction": "right", - "steps": 5 - }, - { - "plate": 6, - "direction": "right", - "steps": 5 - }, - { - "plate": 3, - "direction": "right", - "steps": 6 - }, - { - "plate": 1, - "direction": "right", - "steps": 6 - }, - { - "plate": 2, - "direction": "right", - "steps": 6 - }, - { - "plate": 1, - "direction": "right", - "steps": 6 - }, - { - "plate": 4, - "direction": "right", - "steps": 3 - }, - { - "plate": 6, - "direction": "right", - "steps": 6 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 5, - "direction": "right", - "steps": 3 - }, - { - "plate": 6, - "direction": "right", - "steps": 3 - } - ], - "timingMs": { - "min": 82.73822199999995, - "median": 85.22048600000016, - "max": 92.24529600000005 - }, - "samplesMs": [ - 82.73822199999995, - 92.24529600000005, - 85.08997, - 87.70465400000012, - 85.22048600000016 - ] - }, - "reference": { - "A": 18, - "U": 6, - "C": 68, - "plateSwitches": 17, - "commands": [ - { - "plate": 5, - "direction": "right", - "steps": 3 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 6, - "direction": "right", - "steps": 6 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - }, - { - "plate": 2, - "direction": "right", - "steps": 3 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 6, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "right", - "steps": 6 - }, - { - "plate": 5, - "direction": "right", - "steps": 6 - }, - { - "plate": 6, - "direction": "right", - "steps": 6 - }, - { - "plate": 3, - "direction": "right", - "steps": 5 - }, - { - "plate": 6, - "direction": "right", - "steps": 5 - }, - { - "plate": 1, - "direction": "right", - "steps": 5 - }, - { - "plate": 2, - "direction": "right", - "steps": 4 - }, - { - "plate": 5, - "direction": "right", - "steps": 1 - }, - { - "plate": 4, - "direction": "right", - "steps": 1 - }, - { - "plate": 3, - "direction": "right", - "steps": 2 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - } - ], - "timingMs": { - "min": 39.82016099999964, - "median": 40.37351299999955, - "max": 44.69285200000013 - }, - "samplesMs": [ - 40.37351299999955, - 44.69285200000013, - 39.82016099999964, - 40.002465000000484, - 40.941437000000406 - ] - } - }, - "historicalReference": { - "kind": "cached-observation", - "capturedAt": "2026-09-07T06:02:47.149Z", - "A": 18, - "U": 6, - "C": 68, - "plateSwitches": 17, - "commands": [ - { - "plate": 5, - "direction": "right", - "steps": 3 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 6, - "direction": "right", - "steps": 6 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - }, - { - "plate": 2, - "direction": "right", - "steps": 3 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 6, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "right", - "steps": 6 - }, - { - "plate": 5, - "direction": "right", - "steps": 6 - }, - { - "plate": 6, - "direction": "right", - "steps": 6 - }, - { - "plate": 3, - "direction": "right", - "steps": 5 - }, - { - "plate": 6, - "direction": "right", - "steps": 5 - }, - { - "plate": 1, - "direction": "right", - "steps": 5 - }, - { - "plate": 2, - "direction": "right", - "steps": 4 - }, - { - "plate": 5, - "direction": "right", - "steps": 1 - }, - { - "plate": 4, - "direction": "right", - "steps": 1 - }, - { - "plate": 3, - "direction": "right", - "steps": 2 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - } - ], - "historicalTimingMs": { - "min": 41.57797500000015, - "median": 42.28448800000024, - "max": 46.73487599999953 - } - } - }, - { - "id": "arlin-dungeon-key", - "title": "Ключ от темницы Арлина", - "plateCount": 7, - "url": "https://unlockmyloot.com/locks/arlin-dungeon-key/", - "code": "gBDXAECQhAAQAQAIRAA", - "expectedActions": 7, - "solvers": { - "matrix-astar": { - "A": 7, - "U": 5, - "C": 25, - "plateSwitches": 6, - "commands": [ - { - "plate": 3, - "direction": "right", - "steps": 2 - }, - { - "plate": 6, - "direction": "right", - "steps": 2 - }, - { - "plate": 7, - "direction": "right", - "steps": 6 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - }, - { - "plate": 3, - "direction": "right", - "steps": 6 - }, - { - "plate": 2, - "direction": "left", - "steps": 3 - }, - { - "plate": 6, - "direction": "right", - "steps": 3 - } - ], - "timingMs": { - "min": 7.17545599999994, - "median": 7.3016549999993, - "max": 8.008499000000484 - }, - "samplesMs": [ - 8.008499000000484, - 7.395747000000483, - 7.279462999999851, - 7.17545599999994, - 7.3016549999993 - ] - }, - "bfs": { - "A": 7, - "U": 5, - "C": 25, - "plateSwitches": 6, - "commands": [ - { - "plate": 3, - "direction": "right", - "steps": 2 - }, - { - "plate": 6, - "direction": "right", - "steps": 2 - }, - { - "plate": 7, - "direction": "right", - "steps": 6 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - }, - { - "plate": 3, - "direction": "right", - "steps": 6 - }, - { - "plate": 2, - "direction": "left", - "steps": 3 - }, - { - "plate": 6, - "direction": "right", - "steps": 3 - } - ], - "timingMs": { - "min": 7.162446000000273, - "median": 7.25756999999976, - "max": 7.964862999999241 - }, - "samplesMs": [ - 7.964862999999241, - 7.25756999999976, - 7.162446000000273, - 7.221013999999741, - 7.285120000000461 - ] - }, - "baseline": { - "A": 7, - "U": 5, - "C": 25, - "plateSwitches": 6, - "commands": [ - { - "plate": 3, - "direction": "right", - "steps": 2 - }, - { - "plate": 6, - "direction": "right", - "steps": 2 - }, - { - "plate": 7, - "direction": "right", - "steps": 6 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - }, - { - "plate": 3, - "direction": "right", - "steps": 6 - }, - { - "plate": 2, - "direction": "left", - "steps": 3 - }, - { - "plate": 6, - "direction": "right", - "steps": 3 - } - ], - "timingMs": { - "min": 43.22346199999993, - "median": 44.592169999999896, - "max": 48.96067200000016 - }, - "samplesMs": [ - 48.96067200000016, - 44.592169999999896, - 43.40488499999992, - 43.22346199999993, - 47.93774300000041 - ] - }, - "reference": { - "A": 7, - "U": 5, - "C": 25, - "plateSwitches": 6, - "commands": [ - { - "plate": 3, - "direction": "right", - "steps": 2 - }, - { - "plate": 7, - "direction": "right", - "steps": 6 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 6, - "direction": "right", - "steps": 5 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 2, - "direction": "left", - "steps": 3 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - } - ], - "timingMs": { - "min": 20.249174999999923, - "median": 20.278919999999744, - "max": 24.130545999999413 - }, - "samplesMs": [ - 21.914961000000403, - 24.130545999999413, - 20.249174999999923, - 20.278919999999744, - 20.275265000000218 - ] - } - }, - "historicalReference": { - "kind": "cached-observation", - "capturedAt": "2026-09-07T06:02:47.149Z", - "A": 7, - "U": 5, - "C": 25, - "plateSwitches": 6, - "commands": [ - { - "plate": 3, - "direction": "right", - "steps": 2 - }, - { - "plate": 7, - "direction": "right", - "steps": 6 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 6, - "direction": "right", - "steps": 5 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 2, - "direction": "left", - "steps": 3 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - } - ], - "historicalTimingMs": { - "min": 22.995531999999912, - "median": 23.810668999999507, - "max": 25.53016800000023 - } - } - }, - { - "id": "gomez-wine-cellar", - "title": "Винный погреб Гомеза", - "plateCount": 6, - "url": "https://unlockmyloot.com/locks/gomez-wine-cellar/", - "code": "bI02qgAAIBgEAg", - "expectedActions": 9, - "solvers": { - "matrix-astar": { - "A": 9, - "U": 6, - "C": 41, - "plateSwitches": 8, - "commands": [ - { - "plate": 2, - "direction": "right", - "steps": 2 - }, - { - "plate": 3, - "direction": "left", - "steps": 5 - }, - { - "plate": 6, - "direction": "left", - "steps": 4 - }, - { - "plate": 4, - "direction": "left", - "steps": 4 - }, - { - "plate": 3, - "direction": "right", - "steps": 6 - }, - { - "plate": 5, - "direction": "right", - "steps": 5 - }, - { - "plate": 4, - "direction": "left", - "steps": 6 - }, - { - "plate": 5, - "direction": "right", - "steps": 6 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - } - ], - "timingMs": { - "min": 0.12915400000019872, - "median": 0.1397610000003624, - "max": 0.2738119999994524 - }, - "samplesMs": [ - 0.2738119999994524, - 0.1988389999996798, - 0.13804799999979878, - 0.1397610000003624, - 0.12915400000019872 - ] - }, - "bfs": { - "A": 9, - "U": 6, - "C": 41, - "plateSwitches": 8, - "commands": [ - { - "plate": 2, - "direction": "right", - "steps": 2 - }, - { - "plate": 3, - "direction": "left", - "steps": 5 - }, - { - "plate": 6, - "direction": "left", - "steps": 4 - }, - { - "plate": 4, - "direction": "left", - "steps": 4 - }, - { - "plate": 3, - "direction": "right", - "steps": 6 - }, - { - "plate": 5, - "direction": "right", - "steps": 5 - }, - { - "plate": 4, - "direction": "left", - "steps": 6 - }, - { - "plate": 5, - "direction": "right", - "steps": 6 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - } - ], - "timingMs": { - "min": 8.284353999999439, - "median": 8.696474000000308, - "max": 9.232170999999653 - }, - "samplesMs": [ - 8.284353999999439, - 8.696474000000308, - 9.145520000000033, - 8.663674999999785, - 9.232170999999653 - ] - }, - "baseline": { - "A": 9, - "U": 6, - "C": 41, - "plateSwitches": 8, - "commands": [ - { - "plate": 2, - "direction": "right", - "steps": 2 - }, - { - "plate": 3, - "direction": "left", - "steps": 5 - }, - { - "plate": 6, - "direction": "left", - "steps": 4 - }, - { - "plate": 4, - "direction": "left", - "steps": 4 - }, - { - "plate": 3, - "direction": "right", - "steps": 6 - }, - { - "plate": 5, - "direction": "right", - "steps": 5 - }, - { - "plate": 4, - "direction": "left", - "steps": 6 - }, - { - "plate": 5, - "direction": "right", - "steps": 6 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - } - ], - "timingMs": { - "min": 53.11341999999968, - "median": 53.189674999999625, - "max": 56.717633000000205 - }, - "samplesMs": [ - 53.11341999999968, - 53.458159000000705, - 53.189674999999625, - 53.183266, - 56.717633000000205 - ] - }, - "reference": { - "A": 11, - "U": 6, - "C": 31, - "plateSwitches": 10, - "commands": [ - { - "plate": 4, - "direction": "left", - "steps": 1 - }, - { - "plate": 5, - "direction": "right", - "steps": 2 - }, - { - "plate": 4, - "direction": "left", - "steps": 2 - }, - { - "plate": 6, - "direction": "left", - "steps": 4 - }, - { - "plate": 4, - "direction": "left", - "steps": 4 - }, - { - "plate": 5, - "direction": "right", - "steps": 6 - }, - { - "plate": 3, - "direction": "right", - "steps": 1 - }, - { - "plate": 4, - "direction": "left", - "steps": 3 - }, - { - "plate": 5, - "direction": "right", - "steps": 3 - }, - { - "plate": 2, - "direction": "right", - "steps": 2 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - } - ], - "timingMs": { - "min": 26.219829000000573, - "median": 26.63177799999994, - "max": 34.84491499999967 - }, - "samplesMs": [ - 26.486160000000382, - 34.84491499999967, - 26.219829000000573, - 26.783986999999797, - 26.63177799999994 - ] - } - }, - "historicalReference": { - "kind": "cached-observation", - "capturedAt": "2026-09-07T06:02:47.149Z", - "A": 11, - "U": 6, - "C": 31, - "plateSwitches": 10, - "commands": [ - { - "plate": 4, - "direction": "left", - "steps": 1 - }, - { - "plate": 5, - "direction": "right", - "steps": 2 - }, - { - "plate": 4, - "direction": "left", - "steps": 2 - }, - { - "plate": 6, - "direction": "left", - "steps": 4 - }, - { - "plate": 4, - "direction": "left", - "steps": 4 - }, - { - "plate": 5, - "direction": "right", - "steps": 6 - }, - { - "plate": 3, - "direction": "right", - "steps": 1 - }, - { - "plate": 4, - "direction": "left", - "steps": 3 - }, - { - "plate": 5, - "direction": "right", - "steps": 3 - }, - { - "plate": 2, - "direction": "right", - "steps": 2 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - } - ], - "historicalTimingMs": { - "min": 27.862570000000233, - "median": 28.006227000000763, - "max": 28.23831599999994 - } - } - }, - { - "id": "gomez-bedside-chest", - "title": "Сундук в ногах Гомеза", - "plateCount": 5, - "url": "https://unlockmyloot.com/locks/gomez-bedside-chest/", - "code": "SgjQQDUgCI", - "expectedActions": 23, - "solvers": { - "matrix-astar": { - "A": 23, - "U": 5, - "C": 68, - "plateSwitches": 22, - "commands": [ - { - "plate": 3, - "direction": "right", - "steps": 1 - }, - { - "plate": 2, - "direction": "right", - "steps": 1 - }, - { - "plate": 1, - "direction": "right", - "steps": 2 - }, - { - "plate": 2, - "direction": "right", - "steps": 2 - }, - { - "plate": 1, - "direction": "right", - "steps": 2 - }, - { - "plate": 2, - "direction": "right", - "steps": 2 - }, - { - "plate": 5, - "direction": "left", - "steps": 2 - }, - { - "plate": 3, - "direction": "right", - "steps": 2 - }, - { - "plate": 1, - "direction": "right", - "steps": 2 - }, - { - "plate": 2, - "direction": "right", - "steps": 4 - }, - { - "plate": 1, - "direction": "right", - "steps": 4 - }, - { - "plate": 2, - "direction": "right", - "steps": 4 - }, - { - "plate": 5, - "direction": "left", - "steps": 2 - }, - { - "plate": 3, - "direction": "right", - "steps": 2 - }, - { - "plate": 4, - "direction": "right", - "steps": 5 - }, - { - "plate": 1, - "direction": "right", - "steps": 4 - }, - { - "plate": 2, - "direction": "right", - "steps": 6 - }, - { - "plate": 5, - "direction": "left", - "steps": 3 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - }, - { - "plate": 5, - "direction": "left", - "steps": 3 - }, - { - "plate": 2, - "direction": "right", - "steps": 6 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - } - ], - "timingMs": { - "min": 1.5267970000004425, - "median": 1.6018199999998615, - "max": 1.966659000000618 - }, - "samplesMs": [ - 1.966659000000618, - 1.713477999999668, - 1.5267970000004425, - 1.5952500000003056, - 1.6018199999998615 - ] - }, - "bfs": { - "A": 23, - "U": 5, - "C": 68, - "plateSwitches": 22, - "commands": [ - { - "plate": 3, - "direction": "right", - "steps": 1 - }, - { - "plate": 1, - "direction": "right", - "steps": 1 - }, - { - "plate": 2, - "direction": "right", - "steps": 2 - }, - { - "plate": 1, - "direction": "right", - "steps": 2 - }, - { - "plate": 2, - "direction": "right", - "steps": 2 - }, - { - "plate": 5, - "direction": "left", - "steps": 1 - }, - { - "plate": 3, - "direction": "right", - "steps": 1 - }, - { - "plate": 1, - "direction": "right", - "steps": 2 - }, - { - "plate": 2, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - }, - { - "plate": 2, - "direction": "right", - "steps": 3 - }, - { - "plate": 5, - "direction": "left", - "steps": 3 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 2, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "right", - "steps": 6 - }, - { - "plate": 2, - "direction": "right", - "steps": 6 - }, - { - "plate": 4, - "direction": "right", - "steps": 5 - }, - { - "plate": 5, - "direction": "left", - "steps": 3 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 5, - "direction": "left", - "steps": 3 - }, - { - "plate": 2, - "direction": "right", - "steps": 6 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - } - ], - "timingMs": { - "min": 1.9026420000000144, - "median": 1.977524999999332, - "max": 2.3347429999994347 - }, - "samplesMs": [ - 2.3347429999994347, - 1.9453270000003613, - 1.977524999999332, - 1.9026420000000144, - 2.076614999999947 - ] - }, - "baseline": { - "A": 23, - "U": 5, - "C": 68, - "plateSwitches": 22, - "commands": [ - { - "plate": 3, - "direction": "right", - "steps": 1 - }, - { - "plate": 1, - "direction": "right", - "steps": 1 - }, - { - "plate": 2, - "direction": "right", - "steps": 2 - }, - { - "plate": 1, - "direction": "right", - "steps": 2 - }, - { - "plate": 2, - "direction": "right", - "steps": 2 - }, - { - "plate": 5, - "direction": "left", - "steps": 1 - }, - { - "plate": 3, - "direction": "right", - "steps": 1 - }, - { - "plate": 1, - "direction": "right", - "steps": 2 - }, - { - "plate": 2, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - }, - { - "plate": 2, - "direction": "right", - "steps": 3 - }, - { - "plate": 5, - "direction": "left", - "steps": 3 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 2, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "right", - "steps": 6 - }, - { - "plate": 2, - "direction": "right", - "steps": 6 - }, - { - "plate": 4, - "direction": "right", - "steps": 5 - }, - { - "plate": 5, - "direction": "left", - "steps": 3 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 5, - "direction": "left", - "steps": 3 - }, - { - "plate": 2, - "direction": "right", - "steps": 6 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - } - ], - "timingMs": { - "min": 9.919945000000553, - "median": 10.484193000000232, - "max": 13.091537000000244 - }, - "samplesMs": [ - 13.091537000000244, - 10.404543999999987, - 10.943454000000202, - 10.484193000000232, - 9.919945000000553 - ] - }, - "reference": { - "A": 23, - "U": 5, - "C": 68, - "plateSwitches": 22, - "commands": [ - { - "plate": 3, - "direction": "right", - "steps": 1 - }, - { - "plate": 2, - "direction": "right", - "steps": 1 - }, - { - "plate": 1, - "direction": "right", - "steps": 2 - }, - { - "plate": 2, - "direction": "right", - "steps": 2 - }, - { - "plate": 1, - "direction": "right", - "steps": 2 - }, - { - "plate": 2, - "direction": "right", - "steps": 2 - }, - { - "plate": 5, - "direction": "left", - "steps": 2 - }, - { - "plate": 3, - "direction": "right", - "steps": 2 - }, - { - "plate": 1, - "direction": "right", - "steps": 2 - }, - { - "plate": 2, - "direction": "right", - "steps": 4 - }, - { - "plate": 5, - "direction": "left", - "steps": 2 - }, - { - "plate": 1, - "direction": "right", - "steps": 2 - }, - { - "plate": 3, - "direction": "right", - "steps": 2 - }, - { - "plate": 2, - "direction": "right", - "steps": 4 - }, - { - "plate": 1, - "direction": "right", - "steps": 6 - }, - { - "plate": 2, - "direction": "right", - "steps": 6 - }, - { - "plate": 5, - "direction": "left", - "steps": 3 - }, - { - "plate": 4, - "direction": "right", - "steps": 5 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - }, - { - "plate": 5, - "direction": "left", - "steps": 3 - }, - { - "plate": 2, - "direction": "right", - "steps": 6 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - } - ], - "timingMs": { - "min": 8.14983200000006, - "median": 9.029956000000311, - "max": 9.092720000000554 - }, - "samplesMs": [ - 9.029956000000311, - 9.072660000000724, - 9.092720000000554, - 8.2767130000002, - 8.14983200000006 - ] - } - }, - "historicalReference": { - "kind": "cached-observation", - "capturedAt": "2026-09-07T06:02:47.149Z", - "A": 23, - "U": 5, - "C": 68, - "plateSwitches": 22, - "commands": [ - { - "plate": 3, - "direction": "right", - "steps": 1 - }, - { - "plate": 2, - "direction": "right", - "steps": 1 - }, - { - "plate": 1, - "direction": "right", - "steps": 2 - }, - { - "plate": 2, - "direction": "right", - "steps": 2 - }, - { - "plate": 1, - "direction": "right", - "steps": 2 - }, - { - "plate": 2, - "direction": "right", - "steps": 2 - }, - { - "plate": 5, - "direction": "left", - "steps": 2 - }, - { - "plate": 3, - "direction": "right", - "steps": 2 - }, - { - "plate": 1, - "direction": "right", - "steps": 2 - }, - { - "plate": 2, - "direction": "right", - "steps": 4 - }, - { - "plate": 5, - "direction": "left", - "steps": 2 - }, - { - "plate": 1, - "direction": "right", - "steps": 2 - }, - { - "plate": 3, - "direction": "right", - "steps": 2 - }, - { - "plate": 2, - "direction": "right", - "steps": 4 - }, - { - "plate": 1, - "direction": "right", - "steps": 6 - }, - { - "plate": 2, - "direction": "right", - "steps": 6 - }, - { - "plate": 5, - "direction": "left", - "steps": 3 - }, - { - "plate": 4, - "direction": "right", - "steps": 5 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - }, - { - "plate": 5, - "direction": "left", - "steps": 3 - }, - { - "plate": 2, - "direction": "right", - "steps": 6 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - } - ], - "historicalTimingMs": { - "min": 7.661264000000301, - "median": 7.950699999999415, - "max": 8.508680999999342 - } - } - }, - { - "id": "gomez-wall-chest", - "title": "Сундук у стены в покоях Гомеза", - "plateCount": 6, - "url": "https://unlockmyloot.com/locks/gomez-wall-chest/", - "code": "ZDAkKqACQECQAA", - "expectedActions": 20, - "solvers": { - "matrix-astar": { - "A": 20, - "U": 6, - "C": 75, - "plateSwitches": 19, - "commands": [ - { - "plate": 1, - "direction": "right", - "steps": 2 - }, - { - "plate": 5, - "direction": "right", - "steps": 3 - }, - { - "plate": 2, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - }, - { - "plate": 6, - "direction": "right", - "steps": 6 - }, - { - "plate": 4, - "direction": "right", - "steps": 3 - }, - { - "plate": 3, - "direction": "left", - "steps": 2 - }, - { - "plate": 5, - "direction": "right", - "steps": 5 - }, - { - "plate": 2, - "direction": "right", - "steps": 5 - }, - { - "plate": 1, - "direction": "right", - "steps": 5 - }, - { - "plate": 5, - "direction": "right", - "steps": 5 - }, - { - "plate": 2, - "direction": "right", - "steps": 5 - }, - { - "plate": 4, - "direction": "right", - "steps": 3 - }, - { - "plate": 6, - "direction": "right", - "steps": 5 - }, - { - "plate": 1, - "direction": "right", - "steps": 2 - }, - { - "plate": 6, - "direction": "right", - "steps": 2 - }, - { - "plate": 3, - "direction": "left", - "steps": 4 - }, - { - "plate": 5, - "direction": "right", - "steps": 6 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - }, - { - "plate": 2, - "direction": "right", - "steps": 3 - } - ], - "timingMs": { - "min": 5.406092999999601, - "median": 6.001978999999665, - "max": 7.071057999999539 - }, - "samplesMs": [ - 5.406092999999601, - 7.071057999999539, - 5.818043000000216, - 6.001978999999665, - 6.5075010000000475 - ] - }, - "bfs": { - "A": 20, - "U": 6, - "C": 75, - "plateSwitches": 19, - "commands": [ - { - "plate": 1, - "direction": "right", - "steps": 2 - }, - { - "plate": 4, - "direction": "right", - "steps": 2 - }, - { - "plate": 5, - "direction": "right", - "steps": 3 - }, - { - "plate": 2, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - }, - { - "plate": 3, - "direction": "left", - "steps": 2 - }, - { - "plate": 5, - "direction": "right", - "steps": 5 - }, - { - "plate": 2, - "direction": "right", - "steps": 5 - }, - { - "plate": 6, - "direction": "right", - "steps": 6 - }, - { - "plate": 1, - "direction": "right", - "steps": 5 - }, - { - "plate": 5, - "direction": "right", - "steps": 5 - }, - { - "plate": 2, - "direction": "right", - "steps": 5 - }, - { - "plate": 4, - "direction": "right", - "steps": 4 - }, - { - "plate": 6, - "direction": "right", - "steps": 5 - }, - { - "plate": 1, - "direction": "right", - "steps": 2 - }, - { - "plate": 3, - "direction": "left", - "steps": 4 - }, - { - "plate": 5, - "direction": "right", - "steps": 6 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - }, - { - "plate": 2, - "direction": "right", - "steps": 3 - }, - { - "plate": 6, - "direction": "right", - "steps": 2 - } - ], - "timingMs": { - "min": 15.09058399999958, - "median": 15.563595999999961, - "max": 16.102007000000413 - }, - "samplesMs": [ - 15.09058399999958, - 15.663696999999956, - 15.291948000000048, - 15.563595999999961, - 16.102007000000413 - ] - }, - "baseline": { - "A": 20, - "U": 6, - "C": 75, - "plateSwitches": 19, - "commands": [ - { - "plate": 1, - "direction": "right", - "steps": 2 - }, - { - "plate": 4, - "direction": "right", - "steps": 2 - }, - { - "plate": 5, - "direction": "right", - "steps": 3 - }, - { - "plate": 2, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - }, - { - "plate": 3, - "direction": "left", - "steps": 2 - }, - { - "plate": 5, - "direction": "right", - "steps": 5 - }, - { - "plate": 2, - "direction": "right", - "steps": 5 - }, - { - "plate": 6, - "direction": "right", - "steps": 6 - }, - { - "plate": 1, - "direction": "right", - "steps": 5 - }, - { - "plate": 5, - "direction": "right", - "steps": 5 - }, - { - "plate": 2, - "direction": "right", - "steps": 5 - }, - { - "plate": 4, - "direction": "right", - "steps": 4 - }, - { - "plate": 6, - "direction": "right", - "steps": 5 - }, - { - "plate": 1, - "direction": "right", - "steps": 2 - }, - { - "plate": 3, - "direction": "left", - "steps": 4 - }, - { - "plate": 5, - "direction": "right", - "steps": 6 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - }, - { - "plate": 2, - "direction": "right", - "steps": 3 - }, - { - "plate": 6, - "direction": "right", - "steps": 2 - } - ], - "timingMs": { - "min": 91.64663500000006, - "median": 93.95285500000045, - "max": 113.02099300000009 - }, - "samplesMs": [ - 113.02099300000009, - 93.95285500000045, - 91.64663500000006, - 92.53698499999973, - 104.01599600000009 - ] - }, - "reference": { - "A": 20, - "U": 6, - "C": 75, - "plateSwitches": 19, - "commands": [ - { - "plate": 1, - "direction": "right", - "steps": 2 - }, - { - "plate": 5, - "direction": "right", - "steps": 3 - }, - { - "plate": 2, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - }, - { - "plate": 6, - "direction": "right", - "steps": 6 - }, - { - "plate": 4, - "direction": "right", - "steps": 3 - }, - { - "plate": 3, - "direction": "left", - "steps": 2 - }, - { - "plate": 5, - "direction": "right", - "steps": 5 - }, - { - "plate": 2, - "direction": "right", - "steps": 5 - }, - { - "plate": 1, - "direction": "right", - "steps": 5 - }, - { - "plate": 6, - "direction": "right", - "steps": 5 - }, - { - "plate": 5, - "direction": "right", - "steps": 5 - }, - { - "plate": 2, - "direction": "right", - "steps": 5 - }, - { - "plate": 4, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "right", - "steps": 2 - }, - { - "plate": 6, - "direction": "right", - "steps": 2 - }, - { - "plate": 3, - "direction": "left", - "steps": 4 - }, - { - "plate": 5, - "direction": "right", - "steps": 6 - }, - { - "plate": 2, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - } - ], - "timingMs": { - "min": 80.892425, - "median": 84.37700799999948, - "max": 86.19715600000018 - }, - "samplesMs": [ - 80.892425, - 83.31325800000013, - 85.81988900000033, - 84.37700799999948, - 86.19715600000018 - ] - } - }, - "historicalReference": { - "kind": "cached-observation", - "capturedAt": "2026-09-07T06:02:47.149Z", - "A": 20, - "U": 6, - "C": 75, - "plateSwitches": 19, - "commands": [ - { - "plate": 1, - "direction": "right", - "steps": 2 - }, - { - "plate": 5, - "direction": "right", - "steps": 3 - }, - { - "plate": 2, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - }, - { - "plate": 6, - "direction": "right", - "steps": 6 - }, - { - "plate": 4, - "direction": "right", - "steps": 3 - }, - { - "plate": 3, - "direction": "left", - "steps": 2 - }, - { - "plate": 5, - "direction": "right", - "steps": 5 - }, - { - "plate": 2, - "direction": "right", - "steps": 5 - }, - { - "plate": 1, - "direction": "right", - "steps": 5 - }, - { - "plate": 6, - "direction": "right", - "steps": 5 - }, - { - "plate": 5, - "direction": "right", - "steps": 5 - }, - { - "plate": 2, - "direction": "right", - "steps": 5 - }, - { - "plate": 4, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "right", - "steps": 2 - }, - { - "plate": 6, - "direction": "right", - "steps": 2 - }, - { - "plate": 3, - "direction": "left", - "steps": 4 - }, - { - "plate": 5, - "direction": "right", - "steps": 6 - }, - { - "plate": 2, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - } - ], - "historicalTimingMs": { - "min": 83.76281300000028, - "median": 85.88850400000047, - "max": 89.70766300000014 - } - } - }, - { - "id": "voran-bedside-chest", - "title": "Сундук у кровати Ворона", - "plateCount": 7, - "url": "https://unlockmyloot.com/locks/voran-bedside-chest/", - "code": "jV46BEAAIAAAJLASAAA", - "expectedActions": 9, - "solvers": { - "matrix-astar": { - "A": 9, - "U": 7, - "C": 38, - "plateSwitches": 8, - "commands": [ - { - "plate": 2, - "direction": "right", - "steps": 2 - }, - { - "plate": 5, - "direction": "right", - "steps": 1 - }, - { - "plate": 3, - "direction": "right", - "steps": 4 - }, - { - "plate": 1, - "direction": "left", - "steps": 5 - }, - { - "plate": 4, - "direction": "left", - "steps": 6 - }, - { - "plate": 6, - "direction": "left", - "steps": 6 - }, - { - "plate": 4, - "direction": "left", - "steps": 6 - }, - { - "plate": 7, - "direction": "right", - "steps": 5 - }, - { - "plate": 5, - "direction": "right", - "steps": 3 - } - ], - "timingMs": { - "min": 0.14533899999878486, - "median": 0.17423199999939243, - "max": 0.4060009999993781 - }, - "samplesMs": [ - 0.14533899999878486, - 0.4060009999993781, - 0.17423199999939243, - 0.20772200000101293, - 0.16797200000110024 - ] - }, - "bfs": { - "A": 9, - "U": 7, - "C": 38, - "plateSwitches": 8, - "commands": [ - { - "plate": 2, - "direction": "right", - "steps": 2 - }, - { - "plate": 5, - "direction": "right", - "steps": 1 - }, - { - "plate": 3, - "direction": "right", - "steps": 4 - }, - { - "plate": 1, - "direction": "left", - "steps": 5 - }, - { - "plate": 4, - "direction": "left", - "steps": 6 - }, - { - "plate": 6, - "direction": "left", - "steps": 6 - }, - { - "plate": 4, - "direction": "left", - "steps": 6 - }, - { - "plate": 7, - "direction": "right", - "steps": 5 - }, - { - "plate": 5, - "direction": "right", - "steps": 3 - } - ], - "timingMs": { - "min": 27.983533000000534, - "median": 30.266827999999805, - "max": 41.61805600000116 - }, - "samplesMs": [ - 30.266827999999805, - 29.730952000001707, - 36.83347800000047, - 41.61805600000116, - 27.983533000000534 - ] - }, - "baseline": { - "A": 9, - "U": 7, - "C": 38, - "plateSwitches": 8, - "commands": [ - { - "plate": 2, - "direction": "right", - "steps": 2 - }, - { - "plate": 5, - "direction": "right", - "steps": 1 - }, - { - "plate": 3, - "direction": "right", - "steps": 4 - }, - { - "plate": 1, - "direction": "left", - "steps": 5 - }, - { - "plate": 4, - "direction": "left", - "steps": 6 - }, - { - "plate": 6, - "direction": "left", - "steps": 6 - }, - { - "plate": 4, - "direction": "left", - "steps": 6 - }, - { - "plate": 7, - "direction": "right", - "steps": 5 - }, - { - "plate": 5, - "direction": "right", - "steps": 3 - } - ], - "timingMs": { - "min": 186.19204700000046, - "median": 190.95703200000025, - "max": 209.45192899999893 - }, - "samplesMs": [ - 196.2128190000003, - 190.95703200000025, - 209.45192899999893, - 188.73478799999975, - 186.19204700000046 - ] - }, - "reference": { - "A": 9, - "U": 7, - "C": 38, - "plateSwitches": 8, - "commands": [ - { - "plate": 5, - "direction": "right", - "steps": 1 - }, - { - "plate": 7, - "direction": "right", - "steps": 5 - }, - { - "plate": 3, - "direction": "right", - "steps": 4 - }, - { - "plate": 1, - "direction": "left", - "steps": 5 - }, - { - "plate": 4, - "direction": "left", - "steps": 6 - }, - { - "plate": 6, - "direction": "left", - "steps": 6 - }, - { - "plate": 4, - "direction": "left", - "steps": 6 - }, - { - "plate": 5, - "direction": "right", - "steps": 3 - }, - { - "plate": 2, - "direction": "right", - "steps": 2 - } - ], - "timingMs": { - "min": 188.30050400000073, - "median": 203.81756099999984, - "max": 265.4217410000001 - }, - "samplesMs": [ - 207.7155449999991, - 265.4217410000001, - 203.81756099999984, - 190.18953699999838, - 188.30050400000073 - ] - } - }, - "historicalReference": { - "kind": "cached-observation", - "capturedAt": "2026-09-07T06:02:47.149Z", - "A": 9, - "U": 7, - "C": 38, - "plateSwitches": 8, - "commands": [ - { - "plate": 5, - "direction": "right", - "steps": 1 - }, - { - "plate": 7, - "direction": "right", - "steps": 5 - }, - { - "plate": 3, - "direction": "right", - "steps": 4 - }, - { - "plate": 1, - "direction": "left", - "steps": 5 - }, - { - "plate": 4, - "direction": "left", - "steps": 6 - }, - { - "plate": 6, - "direction": "left", - "steps": 6 - }, - { - "plate": 4, - "direction": "left", - "steps": 6 - }, - { - "plate": 5, - "direction": "right", - "steps": 3 - }, - { - "plate": 2, - "direction": "right", - "steps": 2 - } - ], - "historicalTimingMs": { - "min": 172.0179500000013, - "median": 178.24784999999974, - "max": 182.09039500000017 - } - } - }, - { - "id": "arto-room-door", - "title": "Дверь в комнату Арто", - "plateCount": 5, - "url": "https://unlockmyloot.com/locks/arto-room-door/", - "code": "TbYwABSyBC", - "expectedActions": 7, - "solvers": { - "matrix-astar": { - "A": 7, - "U": 5, - "C": 31, - "plateSwitches": 6, - "commands": [ - { - "plate": 2, - "direction": "right", - "steps": 6 - }, - { - "plate": 1, - "direction": "right", - "steps": 6 - }, - { - "plate": 2, - "direction": "right", - "steps": 1 - }, - { - "plate": 4, - "direction": "right", - "steps": 5 - }, - { - "plate": 1, - "direction": "right", - "steps": 5 - }, - { - "plate": 5, - "direction": "left", - "steps": 5 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - } - ], - "timingMs": { - "min": 0.06326499999886437, - "median": 0.07394100000055914, - "max": 0.14646999999968102 - }, - "samplesMs": [ - 0.06326499999886437, - 0.06469800000013493, - 0.14646999999968102, - 0.08190300000023853, - 0.07394100000055914 - ] - }, - "bfs": { - "A": 7, - "U": 5, - "C": 31, - "plateSwitches": 6, - "commands": [ - { - "plate": 2, - "direction": "right", - "steps": 6 - }, - { - "plate": 1, - "direction": "right", - "steps": 6 - }, - { - "plate": 2, - "direction": "right", - "steps": 1 - }, - { - "plate": 4, - "direction": "right", - "steps": 5 - }, - { - "plate": 1, - "direction": "right", - "steps": 5 - }, - { - "plate": 5, - "direction": "left", - "steps": 5 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - } - ], - "timingMs": { - "min": 0.4833470000012312, - "median": 0.5335629999990488, - "max": 0.5610440000000381 - }, - "samplesMs": [ - 0.48387800000091374, - 0.4833470000012312, - 0.5335629999990488, - 0.5337839999992866, - 0.5610440000000381 - ] - }, - "baseline": { - "A": 7, - "U": 5, - "C": 31, - "plateSwitches": 6, - "commands": [ - { - "plate": 2, - "direction": "right", - "steps": 6 - }, - { - "plate": 1, - "direction": "right", - "steps": 6 - }, - { - "plate": 2, - "direction": "right", - "steps": 1 - }, - { - "plate": 4, - "direction": "right", - "steps": 5 - }, - { - "plate": 1, - "direction": "right", - "steps": 5 - }, - { - "plate": 5, - "direction": "left", - "steps": 5 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - } - ], - "timingMs": { - "min": 2.122744000000239, - "median": 2.2399900000000343, - "max": 2.4100859999998647 - }, - "samplesMs": [ - 2.4100859999998647, - 2.2399900000000343, - 2.122744000000239, - 2.232788999999684, - 2.2581979999995383 - ] - }, - "reference": { - "A": 7, - "U": 5, - "C": 31, - "plateSwitches": 6, - "commands": [ - { - "plate": 2, - "direction": "right", - "steps": 6 - }, - { - "plate": 1, - "direction": "right", - "steps": 5 - }, - { - "plate": 4, - "direction": "right", - "steps": 5 - }, - { - "plate": 1, - "direction": "right", - "steps": 6 - }, - { - "plate": 5, - "direction": "left", - "steps": 5 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 2, - "direction": "right", - "steps": 1 - } - ], - "timingMs": { - "min": 4.410926000000472, - "median": 4.537776999999551, - "max": 6.422823999999309 - }, - "samplesMs": [ - 4.410926000000472, - 4.483815999999933, - 6.422823999999309, - 5.09459600000082, - 4.537776999999551 - ] - } - }, - "historicalReference": { - "kind": "cached-observation", - "capturedAt": "2026-09-07T06:02:47.149Z", - "A": 7, - "U": 5, - "C": 31, - "plateSwitches": 6, - "commands": [ - { - "plate": 2, - "direction": "right", - "steps": 6 - }, - { - "plate": 1, - "direction": "right", - "steps": 5 - }, - { - "plate": 4, - "direction": "right", - "steps": 5 - }, - { - "plate": 1, - "direction": "right", - "steps": 6 - }, - { - "plate": 5, - "direction": "left", - "steps": 5 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 2, - "direction": "right", - "steps": 1 - } - ], - "historicalTimingMs": { - "min": 4.352972000000591, - "median": 4.386361999999281, - "max": 4.6916220000002795 - } - } - }, - { - "id": "arto-far-bed-chest", - "title": "Сундук у дальней кровати Арто", - "plateCount": 6, - "url": "https://unlockmyloot.com/locks/arto-far-bed-chest/", - "code": "ZpEUEAAAgASIAk", - "expectedActions": 11, - "solvers": { - "matrix-astar": { - "A": 11, - "U": 5, - "C": 44, - "plateSwitches": 10, - "commands": [ - { - "plate": 2, - "direction": "left", - "steps": 4 - }, - { - "plate": 3, - "direction": "right", - "steps": 2 - }, - { - "plate": 6, - "direction": "right", - "steps": 2 - }, - { - "plate": 4, - "direction": "left", - "steps": 3 - }, - { - "plate": 5, - "direction": "left", - "steps": 6 - }, - { - "plate": 2, - "direction": "left", - "steps": 6 - }, - { - "plate": 6, - "direction": "right", - "steps": 6 - }, - { - "plate": 5, - "direction": "left", - "steps": 6 - }, - { - "plate": 2, - "direction": "left", - "steps": 3 - }, - { - "plate": 3, - "direction": "left", - "steps": 3 - }, - { - "plate": 4, - "direction": "left", - "steps": 3 - } - ], - "timingMs": { - "min": 0.19944000000032247, - "median": 0.21220999999968626, - "max": 0.3316790000008041 - }, - "samplesMs": [ - 0.3316790000008041, - 0.20198400000117545, - 0.27441299999918556, - 0.21220999999968626, - 0.19944000000032247 - ] - }, - "bfs": { - "A": 11, - "U": 5, - "C": 44, - "plateSwitches": 10, - "commands": [ - { - "plate": 2, - "direction": "left", - "steps": 4 - }, - { - "plate": 3, - "direction": "right", - "steps": 2 - }, - { - "plate": 6, - "direction": "right", - "steps": 2 - }, - { - "plate": 4, - "direction": "left", - "steps": 3 - }, - { - "plate": 5, - "direction": "left", - "steps": 6 - }, - { - "plate": 2, - "direction": "left", - "steps": 6 - }, - { - "plate": 6, - "direction": "right", - "steps": 6 - }, - { - "plate": 5, - "direction": "left", - "steps": 6 - }, - { - "plate": 2, - "direction": "left", - "steps": 3 - }, - { - "plate": 3, - "direction": "left", - "steps": 3 - }, - { - "plate": 4, - "direction": "left", - "steps": 3 - } - ], - "timingMs": { - "min": 13.547542000000249, - "median": 13.620272000000114, - "max": 17.535142000000633 - }, - "samplesMs": [ - 13.57144899999912, - 13.547542000000249, - 17.535142000000633, - 13.620272000000114, - 13.810718999999153 - ] - }, - "baseline": { - "A": 11, - "U": 5, - "C": 44, - "plateSwitches": 10, - "commands": [ - { - "plate": 2, - "direction": "left", - "steps": 4 - }, - { - "plate": 3, - "direction": "right", - "steps": 2 - }, - { - "plate": 6, - "direction": "right", - "steps": 2 - }, - { - "plate": 4, - "direction": "left", - "steps": 3 - }, - { - "plate": 5, - "direction": "left", - "steps": 6 - }, - { - "plate": 2, - "direction": "left", - "steps": 6 - }, - { - "plate": 6, - "direction": "right", - "steps": 6 - }, - { - "plate": 5, - "direction": "left", - "steps": 6 - }, - { - "plate": 2, - "direction": "left", - "steps": 3 - }, - { - "plate": 3, - "direction": "left", - "steps": 3 - }, - { - "plate": 4, - "direction": "left", - "steps": 3 - } - ], - "timingMs": { - "min": 78.97781500000019, - "median": 79.81662699999833, - "max": 82.36540199999945 - }, - "samplesMs": [ - 79.81662699999833, - 79.46290500000032, - 81.65442199999961, - 78.97781500000019, - 82.36540199999945 - ] - }, - "reference": { - "A": 13, - "U": 5, - "C": 40, - "plateSwitches": 12, - "commands": [ - { - "plate": 4, - "direction": "left", - "steps": 1 - }, - { - "plate": 5, - "direction": "left", - "steps": 2 - }, - { - "plate": 6, - "direction": "right", - "steps": 2 - }, - { - "plate": 4, - "direction": "left", - "steps": 2 - }, - { - "plate": 2, - "direction": "left", - "steps": 4 - }, - { - "plate": 5, - "direction": "left", - "steps": 4 - }, - { - "plate": 6, - "direction": "right", - "steps": 3 - }, - { - "plate": 4, - "direction": "left", - "steps": 3 - }, - { - "plate": 3, - "direction": "left", - "steps": 1 - }, - { - "plate": 2, - "direction": "left", - "steps": 6 - }, - { - "plate": 5, - "direction": "left", - "steps": 6 - }, - { - "plate": 6, - "direction": "right", - "steps": 3 - }, - { - "plate": 2, - "direction": "left", - "steps": 3 - } - ], - "timingMs": { - "min": 26.54638999999952, - "median": 26.896836999998413, - "max": 28.641020999999455 - }, - "samplesMs": [ - 26.651318000000174, - 28.156301999999414, - 26.896836999998413, - 28.641020999999455, - 26.54638999999952 - ] - } - }, - "historicalReference": { - "kind": "cached-observation", - "capturedAt": "2026-09-07T06:02:47.149Z", - "A": 13, - "U": 5, - "C": 40, - "plateSwitches": 12, - "commands": [ - { - "plate": 4, - "direction": "left", - "steps": 1 - }, - { - "plate": 5, - "direction": "left", - "steps": 2 - }, - { - "plate": 6, - "direction": "right", - "steps": 2 - }, - { - "plate": 4, - "direction": "left", - "steps": 2 - }, - { - "plate": 2, - "direction": "left", - "steps": 4 - }, - { - "plate": 5, - "direction": "left", - "steps": 4 - }, - { - "plate": 6, - "direction": "right", - "steps": 3 - }, - { - "plate": 4, - "direction": "left", - "steps": 3 - }, - { - "plate": 3, - "direction": "left", - "steps": 1 - }, - { - "plate": 2, - "direction": "left", - "steps": 6 - }, - { - "plate": 5, - "direction": "left", - "steps": 6 - }, - { - "plate": 6, - "direction": "right", - "steps": 3 - }, - { - "plate": 2, - "direction": "left", - "steps": 3 - } - ], - "historicalTimingMs": { - "min": 25.627033000000665, - "median": 26.17454699999871, - "max": 26.764604999998483 - } - } - }, - { - "id": "arto-near-bed-chest", - "title": "Сундук у ближней кровати Арто", - "plateCount": 6, - "url": "https://unlockmyloot.com/locks/arto-near-bed-chest/", - "code": "Y2WYAEGAAIGEAY", - "expectedActions": 13, - "solvers": { - "matrix-astar": { - "A": 13, - "U": 6, - "C": 52, - "plateSwitches": 12, - "commands": [ - { - "plate": 5, - "direction": "right", - "steps": 1 - }, - { - "plate": 1, - "direction": "left", - "steps": 6 - }, - { - "plate": 6, - "direction": "right", - "steps": 3 - }, - { - "plate": 4, - "direction": "left", - "steps": 4 - }, - { - "plate": 3, - "direction": "left", - "steps": 6 - }, - { - "plate": 2, - "direction": "right", - "steps": 3 - }, - { - "plate": 5, - "direction": "right", - "steps": 3 - }, - { - "plate": 4, - "direction": "left", - "steps": 4 - }, - { - "plate": 1, - "direction": "left", - "steps": 6 - }, - { - "plate": 3, - "direction": "left", - "steps": 4 - }, - { - "plate": 6, - "direction": "right", - "steps": 6 - }, - { - "plate": 2, - "direction": "right", - "steps": 3 - }, - { - "plate": 4, - "direction": "left", - "steps": 3 - } - ], - "timingMs": { - "min": 0.5017150000003312, - "median": 0.5202929999995831, - "max": 0.6356059999998251 - }, - "samplesMs": [ - 0.5202929999995831, - 0.6356059999998251, - 0.5104079999982787, - 0.5017150000003312, - 0.560762999999497 - ] - }, - "bfs": { - "A": 13, - "U": 6, - "C": 52, - "plateSwitches": 12, - "commands": [ - { - "plate": 5, - "direction": "right", - "steps": 1 - }, - { - "plate": 1, - "direction": "left", - "steps": 6 - }, - { - "plate": 4, - "direction": "left", - "steps": 2 - }, - { - "plate": 3, - "direction": "left", - "steps": 4 - }, - { - "plate": 6, - "direction": "right", - "steps": 3 - }, - { - "plate": 2, - "direction": "right", - "steps": 3 - }, - { - "plate": 5, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "left", - "steps": 6 - }, - { - "plate": 4, - "direction": "left", - "steps": 6 - }, - { - "plate": 3, - "direction": "left", - "steps": 6 - }, - { - "plate": 6, - "direction": "right", - "steps": 6 - }, - { - "plate": 2, - "direction": "right", - "steps": 3 - }, - { - "plate": 4, - "direction": "left", - "steps": 3 - } - ], - "timingMs": { - "min": 14.866877000000386, - "median": 15.097874999999476, - "max": 16.56613499999912 - }, - "samplesMs": [ - 15.017594000000827, - 15.502164000001358, - 16.56613499999912, - 14.866877000000386, - 15.097874999999476 - ] - }, - "baseline": { - "A": 13, - "U": 6, - "C": 52, - "plateSwitches": 12, - "commands": [ - { - "plate": 5, - "direction": "right", - "steps": 1 - }, - { - "plate": 1, - "direction": "left", - "steps": 6 - }, - { - "plate": 4, - "direction": "left", - "steps": 2 - }, - { - "plate": 3, - "direction": "left", - "steps": 4 - }, - { - "plate": 6, - "direction": "right", - "steps": 3 - }, - { - "plate": 2, - "direction": "right", - "steps": 3 - }, - { - "plate": 5, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "left", - "steps": 6 - }, - { - "plate": 4, - "direction": "left", - "steps": 6 - }, - { - "plate": 3, - "direction": "left", - "steps": 6 - }, - { - "plate": 6, - "direction": "right", - "steps": 6 - }, - { - "plate": 2, - "direction": "right", - "steps": 3 - }, - { - "plate": 4, - "direction": "left", - "steps": 3 - } - ], - "timingMs": { - "min": 87.3285990000004, - "median": 87.60512500000004, - "max": 92.91102800000044 - }, - "samplesMs": [ - 92.91102800000044, - 87.3285990000004, - 88.5518890000003, - 87.60512500000004, - 87.53706200000124 - ] - }, - "reference": { - "A": 13, - "U": 6, - "C": 52, - "plateSwitches": 12, - "commands": [ - { - "plate": 5, - "direction": "right", - "steps": 1 - }, - { - "plate": 6, - "direction": "right", - "steps": 3 - }, - { - "plate": 4, - "direction": "left", - "steps": 4 - }, - { - "plate": 1, - "direction": "left", - "steps": 6 - }, - { - "plate": 2, - "direction": "right", - "steps": 3 - }, - { - "plate": 5, - "direction": "right", - "steps": 3 - }, - { - "plate": 3, - "direction": "left", - "steps": 4 - }, - { - "plate": 4, - "direction": "left", - "steps": 4 - }, - { - "plate": 6, - "direction": "right", - "steps": 6 - }, - { - "plate": 3, - "direction": "left", - "steps": 6 - }, - { - "plate": 4, - "direction": "left", - "steps": 3 - }, - { - "plate": 1, - "direction": "left", - "steps": 6 - }, - { - "plate": 2, - "direction": "right", - "steps": 3 - } - ], - "timingMs": { - "min": 69.04895699999906, - "median": 72.45650399999977, - "max": 82.42188700000042 - }, - "samplesMs": [ - 72.70658999999978, - 71.73398599999928, - 82.42188700000042, - 72.45650399999977, - 69.04895699999906 - ] - } - }, - "historicalReference": { - "kind": "cached-observation", - "capturedAt": "2026-09-07T06:02:47.149Z", - "A": 13, - "U": 6, - "C": 52, - "plateSwitches": 12, - "commands": [ - { - "plate": 5, - "direction": "right", - "steps": 1 - }, - { - "plate": 6, - "direction": "right", - "steps": 3 - }, - { - "plate": 4, - "direction": "left", - "steps": 4 - }, - { - "plate": 1, - "direction": "left", - "steps": 6 - }, - { - "plate": 2, - "direction": "right", - "steps": 3 - }, - { - "plate": 5, - "direction": "right", - "steps": 3 - }, - { - "plate": 3, - "direction": "left", - "steps": 4 - }, - { - "plate": 4, - "direction": "left", - "steps": 4 - }, - { - "plate": 6, - "direction": "right", - "steps": 6 - }, - { - "plate": 3, - "direction": "left", - "steps": 6 - }, - { - "plate": 4, - "direction": "left", - "steps": 3 - }, - { - "plate": 1, - "direction": "left", - "steps": 6 - }, - { - "plate": 2, - "direction": "right", - "steps": 3 - } - ], - "historicalTimingMs": { - "min": 71.42761900000005, - "median": 72.47732999999971, - "max": 73.87815799999953 - } - } - }, - { - "id": "gomez-bedrooms-door", - "title": "Дверь на этаже спален покоев", - "plateCount": 6, - "url": "https://unlockmyloot.com/locks/gomez-bedrooms-door/", - "code": "YHayiAREIAIABE", - "expectedActions": 14, - "solvers": { - "matrix-astar": { - "A": 14, - "U": 6, - "C": 49, - "plateSwitches": 13, - "commands": [ - { - "plate": 4, - "direction": "right", - "steps": 6 - }, - { - "plate": 6, - "direction": "right", - "steps": 2 - }, - { - "plate": 3, - "direction": "left", - "steps": 2 - }, - { - "plate": 1, - "direction": "left", - "steps": 3 - }, - { - "plate": 2, - "direction": "left", - "steps": 6 - }, - { - "plate": 4, - "direction": "right", - "steps": 3 - }, - { - "plate": 5, - "direction": "left", - "steps": 3 - }, - { - "plate": 1, - "direction": "left", - "steps": 3 - }, - { - "plate": 3, - "direction": "left", - "steps": 3 - }, - { - "plate": 6, - "direction": "left", - "steps": 3 - }, - { - "plate": 1, - "direction": "left", - "steps": 3 - }, - { - "plate": 3, - "direction": "left", - "steps": 3 - }, - { - "plate": 5, - "direction": "left", - "steps": 6 - }, - { - "plate": 1, - "direction": "left", - "steps": 3 - } - ], - "timingMs": { - "min": 5.449187999998685, - "median": 5.570851000000403, - "max": 6.123373000000356 - }, - "samplesMs": [ - 5.570851000000403, - 5.449187999998685, - 5.473485000000437, - 5.594065999999657, - 6.123373000000356 - ] - }, - "bfs": { - "A": 14, - "U": 6, - "C": 45, - "plateSwitches": 13, - "commands": [ - { - "plate": 1, - "direction": "left", - "steps": 1 - }, - { - "plate": 3, - "direction": "left", - "steps": 1 - }, - { - "plate": 4, - "direction": "right", - "steps": 6 - }, - { - "plate": 2, - "direction": "left", - "steps": 3 - }, - { - "plate": 1, - "direction": "left", - "steps": 4 - }, - { - "plate": 3, - "direction": "left", - "steps": 4 - }, - { - "plate": 4, - "direction": "right", - "steps": 3 - }, - { - "plate": 5, - "direction": "left", - "steps": 5 - }, - { - "plate": 1, - "direction": "left", - "steps": 4 - }, - { - "plate": 2, - "direction": "left", - "steps": 3 - }, - { - "plate": 3, - "direction": "left", - "steps": 3 - }, - { - "plate": 5, - "direction": "left", - "steps": 4 - }, - { - "plate": 1, - "direction": "left", - "steps": 3 - }, - { - "plate": 6, - "direction": "left", - "steps": 1 - } - ], - "timingMs": { - "min": 16.233053999998447, - "median": 16.523340000001554, - "max": 16.799676000000545 - }, - "samplesMs": [ - 16.57423700000072, - 16.233053999998447, - 16.523340000001554, - 16.331560999999056, - 16.799676000000545 - ] - }, - "baseline": { - "A": 14, - "U": 6, - "C": 45, - "plateSwitches": 13, - "commands": [ - { - "plate": 1, - "direction": "left", - "steps": 1 - }, - { - "plate": 3, - "direction": "left", - "steps": 1 - }, - { - "plate": 4, - "direction": "right", - "steps": 6 - }, - { - "plate": 2, - "direction": "left", - "steps": 3 - }, - { - "plate": 1, - "direction": "left", - "steps": 4 - }, - { - "plate": 3, - "direction": "left", - "steps": 4 - }, - { - "plate": 4, - "direction": "right", - "steps": 3 - }, - { - "plate": 5, - "direction": "left", - "steps": 5 - }, - { - "plate": 1, - "direction": "left", - "steps": 4 - }, - { - "plate": 2, - "direction": "left", - "steps": 3 - }, - { - "plate": 3, - "direction": "left", - "steps": 3 - }, - { - "plate": 5, - "direction": "left", - "steps": 4 - }, - { - "plate": 1, - "direction": "left", - "steps": 3 - }, - { - "plate": 6, - "direction": "left", - "steps": 1 - } - ], - "timingMs": { - "min": 92.03947700000026, - "median": 92.59356099999968, - "max": 92.85346099999879 - }, - "samplesMs": [ - 92.03947700000026, - 92.85346099999879, - 92.23526099999981, - 92.67548399999941, - 92.59356099999968 - ] - }, - "reference": { - "A": 14, - "U": 6, - "C": 45, - "plateSwitches": 13, - "commands": [ - { - "plate": 4, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "left", - "steps": 1 - }, - { - "plate": 3, - "direction": "left", - "steps": 1 - }, - { - "plate": 2, - "direction": "left", - "steps": 3 - }, - { - "plate": 1, - "direction": "left", - "steps": 4 - }, - { - "plate": 5, - "direction": "left", - "steps": 4 - }, - { - "plate": 3, - "direction": "left", - "steps": 4 - }, - { - "plate": 1, - "direction": "left", - "steps": 4 - }, - { - "plate": 6, - "direction": "left", - "steps": 1 - }, - { - "plate": 5, - "direction": "left", - "steps": 5 - }, - { - "plate": 4, - "direction": "right", - "steps": 6 - }, - { - "plate": 3, - "direction": "left", - "steps": 3 - }, - { - "plate": 2, - "direction": "left", - "steps": 3 - }, - { - "plate": 1, - "direction": "left", - "steps": 3 - } - ], - "timingMs": { - "min": 35.48603000000003, - "median": 36.05874100000074, - "max": 38.373734999999215 - }, - "samplesMs": [ - 35.48603000000003, - 35.75500500000089, - 36.05874100000074, - 38.27632799999992, - 38.373734999999215 - ] - } - }, - "historicalReference": { - "kind": "cached-observation", - "capturedAt": "2026-09-07T06:02:47.149Z", - "A": 14, - "U": 6, - "C": 45, - "plateSwitches": 13, - "commands": [ - { - "plate": 4, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "left", - "steps": 1 - }, - { - "plate": 3, - "direction": "left", - "steps": 1 - }, - { - "plate": 2, - "direction": "left", - "steps": 3 - }, - { - "plate": 1, - "direction": "left", - "steps": 4 - }, - { - "plate": 5, - "direction": "left", - "steps": 4 - }, - { - "plate": 3, - "direction": "left", - "steps": 4 - }, - { - "plate": 1, - "direction": "left", - "steps": 4 - }, - { - "plate": 6, - "direction": "left", - "steps": 1 - }, - { - "plate": 5, - "direction": "left", - "steps": 5 - }, - { - "plate": 4, - "direction": "right", - "steps": 6 - }, - { - "plate": 3, - "direction": "left", - "steps": 3 - }, - { - "plate": 2, - "direction": "left", - "steps": 3 - }, - { - "plate": 1, - "direction": "left", - "steps": 3 - } - ], - "historicalTimingMs": { - "min": 36.83029600000009, - "median": 37.16563900000074, - "max": 42.04815899999994 - } - } - }, - { - "id": "gomez-bedrooms-far-chest", - "title": "Сундук у дальней кровати (этаж спален)", - "plateCount": 5, - "url": "https://unlockmyloot.com/locks/gomez-bedrooms-far-chest/", - "code": "RzbBFITEQQ", - "expectedActions": 14, - "solvers": { - "matrix-astar": { - "A": 14, - "U": 5, - "C": 43, - "plateSwitches": 13, - "commands": [ - { - "plate": 1, - "direction": "left", - "steps": 3 - }, - { - "plate": 4, - "direction": "left", - "steps": 3 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 4, - "direction": "left", - "steps": 2 - }, - { - "plate": 5, - "direction": "right", - "steps": 3 - }, - { - "plate": 3, - "direction": "right", - "steps": 4 - }, - { - "plate": 1, - "direction": "right", - "steps": 5 - }, - { - "plate": 2, - "direction": "right", - "steps": 4 - }, - { - "plate": 1, - "direction": "right", - "steps": 2 - }, - { - "plate": 4, - "direction": "left", - "steps": 2 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 4, - "direction": "left", - "steps": 3 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 5, - "direction": "right", - "steps": 3 - } - ], - "timingMs": { - "min": 1.4950800000005984, - "median": 1.962974000000031, - "max": 3.205933000001096 - }, - "samplesMs": [ - 1.962974000000031, - 2.7143340000002354, - 1.4950800000005984, - 1.5721560000001773, - 3.205933000001096 - ] - }, - "bfs": { - "A": 14, - "U": 5, - "C": 43, - "plateSwitches": 13, - "commands": [ - { - "plate": 1, - "direction": "left", - "steps": 3 - }, - { - "plate": 4, - "direction": "left", - "steps": 3 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - }, - { - "plate": 5, - "direction": "right", - "steps": 3 - }, - { - "plate": 3, - "direction": "right", - "steps": 2 - }, - { - "plate": 2, - "direction": "right", - "steps": 2 - }, - { - "plate": 4, - "direction": "left", - "steps": 4 - }, - { - "plate": 3, - "direction": "right", - "steps": 5 - }, - { - "plate": 1, - "direction": "right", - "steps": 4 - }, - { - "plate": 2, - "direction": "right", - "steps": 2 - }, - { - "plate": 4, - "direction": "left", - "steps": 3 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 5, - "direction": "right", - "steps": 3 - } - ], - "timingMs": { - "min": 2.0729879999998957, - "median": 2.326961000000665, - "max": 3.976502999999866 - }, - "samplesMs": [ - 2.227611000000252, - 2.552369999999428, - 2.0729879999998957, - 2.326961000000665, - 3.976502999999866 - ] - }, - "baseline": { - "A": 14, - "U": 5, - "C": 43, - "plateSwitches": 13, - "commands": [ - { - "plate": 1, - "direction": "left", - "steps": 3 - }, - { - "plate": 4, - "direction": "left", - "steps": 3 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - }, - { - "plate": 5, - "direction": "right", - "steps": 3 - }, - { - "plate": 3, - "direction": "right", - "steps": 2 - }, - { - "plate": 2, - "direction": "right", - "steps": 2 - }, - { - "plate": 4, - "direction": "left", - "steps": 4 - }, - { - "plate": 3, - "direction": "right", - "steps": 5 - }, - { - "plate": 1, - "direction": "right", - "steps": 4 - }, - { - "plate": 2, - "direction": "right", - "steps": 2 - }, - { - "plate": 4, - "direction": "left", - "steps": 3 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 5, - "direction": "right", - "steps": 3 - } - ], - "timingMs": { - "min": 10.662161000000197, - "median": 11.987535000000207, - "max": 16.185300999999527 - }, - "samplesMs": [ - 12.506196000000273, - 11.987535000000207, - 11.98554299999887, - 10.662161000000197, - 16.185300999999527 - ] - }, - "reference": { - "A": 16, - "U": 5, - "C": 39, - "plateSwitches": 15, - "commands": [ - { - "plate": 1, - "direction": "left", - "steps": 1 - }, - { - "plate": 4, - "direction": "left", - "steps": 1 - }, - { - "plate": 3, - "direction": "right", - "steps": 1 - }, - { - "plate": 4, - "direction": "left", - "steps": 1 - }, - { - "plate": 3, - "direction": "right", - "steps": 1 - }, - { - "plate": 5, - "direction": "right", - "steps": 2 - }, - { - "plate": 3, - "direction": "right", - "steps": 2 - }, - { - "plate": 2, - "direction": "right", - "steps": 2 - }, - { - "plate": 4, - "direction": "left", - "steps": 4 - }, - { - "plate": 3, - "direction": "right", - "steps": 4 - }, - { - "plate": 4, - "direction": "left", - "steps": 4 - }, - { - "plate": 3, - "direction": "right", - "steps": 2 - }, - { - "plate": 5, - "direction": "right", - "steps": 4 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "right", - "steps": 5 - }, - { - "plate": 2, - "direction": "right", - "steps": 2 - } - ], - "timingMs": { - "min": 5.977913999999146, - "median": 6.535843999999997, - "max": 8.36480499999925 - }, - "samplesMs": [ - 7.7652130000005855, - 5.977913999999146, - 6.278376000000208, - 6.535843999999997, - 8.36480499999925 - ] - } - }, - "historicalReference": { - "kind": "cached-observation", - "capturedAt": "2026-09-07T06:02:47.149Z", - "A": 16, - "U": 5, - "C": 39, - "plateSwitches": 15, - "commands": [ - { - "plate": 1, - "direction": "left", - "steps": 1 - }, - { - "plate": 4, - "direction": "left", - "steps": 1 - }, - { - "plate": 3, - "direction": "right", - "steps": 1 - }, - { - "plate": 4, - "direction": "left", - "steps": 1 - }, - { - "plate": 3, - "direction": "right", - "steps": 1 - }, - { - "plate": 5, - "direction": "right", - "steps": 2 - }, - { - "plate": 3, - "direction": "right", - "steps": 2 - }, - { - "plate": 2, - "direction": "right", - "steps": 2 - }, - { - "plate": 4, - "direction": "left", - "steps": 4 - }, - { - "plate": 3, - "direction": "right", - "steps": 4 - }, - { - "plate": 4, - "direction": "left", - "steps": 4 - }, - { - "plate": 3, - "direction": "right", - "steps": 2 - }, - { - "plate": 5, - "direction": "right", - "steps": 4 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "right", - "steps": 5 - }, - { - "plate": 2, - "direction": "right", - "steps": 2 - } - ], - "historicalTimingMs": { - "min": 5.34081900000092, - "median": 5.4789160000000265, - "max": 5.777416000000812 - } - } - }, - { - "id": "gomez-bedrooms-near-chest", - "title": "Сундук у ближней кровати (этаж спален)", - "plateCount": 6, - "url": "https://unlockmyloot.com/locks/gomez-bedrooms-near-chest/", - "code": "ZVbYAEKEBgECAA", - "expectedActions": 12, - "solvers": { - "matrix-astar": { - "A": 12, - "U": 6, - "C": 56, - "plateSwitches": 11, - "commands": [ - { - "plate": 1, - "direction": "left", - "steps": 4 - }, - { - "plate": 3, - "direction": "left", - "steps": 1 - }, - { - "plate": 6, - "direction": "right", - "steps": 6 - }, - { - "plate": 5, - "direction": "right", - "steps": 6 - }, - { - "plate": 1, - "direction": "left", - "steps": 6 - }, - { - "plate": 6, - "direction": "right", - "steps": 6 - }, - { - "plate": 2, - "direction": "right", - "steps": 6 - }, - { - "plate": 1, - "direction": "left", - "steps": 6 - }, - { - "plate": 4, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "left", - "steps": 3 - }, - { - "plate": 6, - "direction": "right", - "steps": 6 - }, - { - "plate": 5, - "direction": "right", - "steps": 3 - } - ], - "timingMs": { - "min": 0.1174670000000333, - "median": 0.14508899999964342, - "max": 0.1590890000006766 - }, - "samplesMs": [ - 0.1590890000006766, - 0.14545899999939138, - 0.1174670000000333, - 0.14508899999964342, - 0.12090200000056939 - ] - }, - "bfs": { - "A": 12, - "U": 6, - "C": 56, - "plateSwitches": 11, - "commands": [ - { - "plate": 1, - "direction": "left", - "steps": 4 - }, - { - "plate": 3, - "direction": "left", - "steps": 1 - }, - { - "plate": 6, - "direction": "right", - "steps": 6 - }, - { - "plate": 5, - "direction": "right", - "steps": 6 - }, - { - "plate": 1, - "direction": "left", - "steps": 6 - }, - { - "plate": 6, - "direction": "right", - "steps": 6 - }, - { - "plate": 2, - "direction": "right", - "steps": 6 - }, - { - "plate": 1, - "direction": "left", - "steps": 6 - }, - { - "plate": 4, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "left", - "steps": 3 - }, - { - "plate": 6, - "direction": "right", - "steps": 6 - }, - { - "plate": 5, - "direction": "right", - "steps": 3 - } - ], - "timingMs": { - "min": 11.567102999999406, - "median": 11.670178000000305, - "max": 13.297065999999177 - }, - "samplesMs": [ - 13.297065999999177, - 11.567102999999406, - 11.613171999999395, - 12.4329859999998, - 11.670178000000305 - ] - }, - "baseline": { - "A": 12, - "U": 6, - "C": 56, - "plateSwitches": 11, - "commands": [ - { - "plate": 1, - "direction": "left", - "steps": 4 - }, - { - "plate": 3, - "direction": "left", - "steps": 1 - }, - { - "plate": 6, - "direction": "right", - "steps": 6 - }, - { - "plate": 5, - "direction": "right", - "steps": 6 - }, - { - "plate": 1, - "direction": "left", - "steps": 6 - }, - { - "plate": 6, - "direction": "right", - "steps": 6 - }, - { - "plate": 2, - "direction": "right", - "steps": 6 - }, - { - "plate": 1, - "direction": "left", - "steps": 6 - }, - { - "plate": 4, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "left", - "steps": 3 - }, - { - "plate": 6, - "direction": "right", - "steps": 6 - }, - { - "plate": 5, - "direction": "right", - "steps": 3 - } - ], - "timingMs": { - "min": 65.11633000000074, - "median": 67.71605199999976, - "max": 95.26326700000027 - }, - "samplesMs": [ - 95.26326700000027, - 65.11633000000074, - 68.37845799999923, - 67.71605199999976, - 66.34749200000078 - ] - }, - "reference": { - "A": 12, - "U": 6, - "C": 56, - "plateSwitches": 11, - "commands": [ - { - "plate": 6, - "direction": "right", - "steps": 6 - }, - { - "plate": 1, - "direction": "left", - "steps": 4 - }, - { - "plate": 5, - "direction": "right", - "steps": 6 - }, - { - "plate": 6, - "direction": "right", - "steps": 6 - }, - { - "plate": 3, - "direction": "left", - "steps": 1 - }, - { - "plate": 1, - "direction": "left", - "steps": 6 - }, - { - "plate": 2, - "direction": "right", - "steps": 6 - }, - { - "plate": 6, - "direction": "right", - "steps": 6 - }, - { - "plate": 1, - "direction": "left", - "steps": 6 - }, - { - "plate": 5, - "direction": "right", - "steps": 3 - }, - { - "plate": 4, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "left", - "steps": 3 - } - ], - "timingMs": { - "min": 38.93337699999938, - "median": 41.40710900000158, - "max": 46.79834899999878 - }, - "samplesMs": [ - 46.79834899999878, - 38.93337699999938, - 41.40710900000158, - 41.60829600000034, - 41.130763000001025 - ] - } - }, - "historicalReference": { - "kind": "cached-observation", - "capturedAt": "2026-09-07T06:02:47.149Z", - "A": 12, - "U": 6, - "C": 56, - "plateSwitches": 11, - "commands": [ - { - "plate": 6, - "direction": "right", - "steps": 6 - }, - { - "plate": 1, - "direction": "left", - "steps": 4 - }, - { - "plate": 5, - "direction": "right", - "steps": 6 - }, - { - "plate": 6, - "direction": "right", - "steps": 6 - }, - { - "plate": 3, - "direction": "left", - "steps": 1 - }, - { - "plate": 1, - "direction": "left", - "steps": 6 - }, - { - "plate": 2, - "direction": "right", - "steps": 6 - }, - { - "plate": 6, - "direction": "right", - "steps": 6 - }, - { - "plate": 1, - "direction": "left", - "steps": 6 - }, - { - "plate": 5, - "direction": "right", - "steps": 3 - }, - { - "plate": 4, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "left", - "steps": 3 - } - ], - "historicalTimingMs": { - "min": 40.47176899999977, - "median": 40.801266000000396, - "max": 43.21825400000125 - } - } - }, - { - "id": "raven-tower-left-chest", - "title": "Левый сундук в башне Ворона", - "plateCount": 6, - "url": "https://unlockmyloot.com/locks/raven-tower-left-chest/", - "code": "bVyMKQAAGQAAWA", - "expectedActions": 11, - "solvers": { - "matrix-astar": { - "A": 11, - "U": 6, - "C": 35, - "plateSwitches": 10, - "commands": [ - { - "plate": 5, - "direction": "left", - "steps": 2 - }, - { - "plate": 6, - "direction": "right", - "steps": 3 - }, - { - "plate": 4, - "direction": "left", - "steps": 2 - }, - { - "plate": 1, - "direction": "left", - "steps": 5 - }, - { - "plate": 3, - "direction": "right", - "steps": 4 - }, - { - "plate": 5, - "direction": "left", - "steps": 5 - }, - { - "plate": 6, - "direction": "right", - "steps": 1 - }, - { - "plate": 2, - "direction": "right", - "steps": 3 - }, - { - "plate": 4, - "direction": "left", - "steps": 5 - }, - { - "plate": 1, - "direction": "left", - "steps": 3 - }, - { - "plate": 2, - "direction": "right", - "steps": 2 - } - ], - "timingMs": { - "min": 1.3328849999998056, - "median": 1.4743689999995695, - "max": 2.742175999999745 - }, - "samplesMs": [ - 1.3328849999998056, - 1.3447130000004108, - 2.742175999999745, - 1.4991160000008676, - 1.4743689999995695 - ] - }, - "bfs": { - "A": 11, - "U": 6, - "C": 35, - "plateSwitches": 10, - "commands": [ - { - "plate": 2, - "direction": "right", - "steps": 5 - }, - { - "plate": 3, - "direction": "right", - "steps": 1 - }, - { - "plate": 4, - "direction": "left", - "steps": 2 - }, - { - "plate": 1, - "direction": "left", - "steps": 2 - }, - { - "plate": 4, - "direction": "left", - "steps": 2 - }, - { - "plate": 5, - "direction": "left", - "steps": 4 - }, - { - "plate": 6, - "direction": "right", - "steps": 4 - }, - { - "plate": 1, - "direction": "left", - "steps": 6 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 4, - "direction": "left", - "steps": 3 - }, - { - "plate": 5, - "direction": "left", - "steps": 3 - } - ], - "timingMs": { - "min": 15.65353099999993, - "median": 16.923682000000554, - "max": 19.095038999999815 - }, - "samplesMs": [ - 15.65353099999993, - 17.06224100000145, - 19.095038999999815, - 15.767813999998907, - 16.923682000000554 - ] - }, - "baseline": { - "A": 11, - "U": 6, - "C": 35, - "plateSwitches": 10, - "commands": [ - { - "plate": 2, - "direction": "right", - "steps": 5 - }, - { - "plate": 3, - "direction": "right", - "steps": 1 - }, - { - "plate": 4, - "direction": "left", - "steps": 2 - }, - { - "plate": 1, - "direction": "left", - "steps": 2 - }, - { - "plate": 4, - "direction": "left", - "steps": 2 - }, - { - "plate": 5, - "direction": "left", - "steps": 4 - }, - { - "plate": 6, - "direction": "right", - "steps": 4 - }, - { - "plate": 1, - "direction": "left", - "steps": 6 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 4, - "direction": "left", - "steps": 3 - }, - { - "plate": 5, - "direction": "left", - "steps": 3 - } - ], - "timingMs": { - "min": 94.0908330000002, - "median": 94.9195090000012, - "max": 95.29427399999986 - }, - "samplesMs": [ - 95.29427399999986, - 94.9195090000012, - 95.09720700000071, - 94.74589899999955, - 94.0908330000002 - ] - }, - "reference": { - "A": 11, - "U": 6, - "C": 35, - "plateSwitches": 10, - "commands": [ - { - "plate": 6, - "direction": "right", - "steps": 3 - }, - { - "plate": 5, - "direction": "left", - "steps": 1 - }, - { - "plate": 4, - "direction": "left", - "steps": 2 - }, - { - "plate": 1, - "direction": "left", - "steps": 5 - }, - { - "plate": 3, - "direction": "right", - "steps": 4 - }, - { - "plate": 6, - "direction": "right", - "steps": 1 - }, - { - "plate": 5, - "direction": "left", - "steps": 6 - }, - { - "plate": 2, - "direction": "right", - "steps": 2 - }, - { - "plate": 4, - "direction": "left", - "steps": 5 - }, - { - "plate": 2, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "left", - "steps": 3 - } - ], - "timingMs": { - "min": 45.36870900000031, - "median": 47.14976799999931, - "max": 51.96910900000148 - }, - "samplesMs": [ - 47.14976799999931, - 46.880622999999105, - 49.69751200000064, - 45.36870900000031, - 51.96910900000148 - ] - } - }, - "historicalReference": { - "kind": "cached-observation", - "capturedAt": "2026-09-07T06:02:47.149Z", - "A": 11, - "U": 6, - "C": 35, - "plateSwitches": 10, - "commands": [ - { - "plate": 6, - "direction": "right", - "steps": 3 - }, - { - "plate": 5, - "direction": "left", - "steps": 1 - }, - { - "plate": 4, - "direction": "left", - "steps": 2 - }, - { - "plate": 1, - "direction": "left", - "steps": 5 - }, - { - "plate": 3, - "direction": "right", - "steps": 4 - }, - { - "plate": 6, - "direction": "right", - "steps": 1 - }, - { - "plate": 5, - "direction": "left", - "steps": 6 - }, - { - "plate": 2, - "direction": "right", - "steps": 2 - }, - { - "plate": 4, - "direction": "left", - "steps": 5 - }, - { - "plate": 2, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "left", - "steps": 3 - } - ], - "historicalTimingMs": { - "min": 45.528119999999035, - "median": 46.7260040000001, - "max": 47.06144800000038 - } - } - }, - { - "id": "raven-tower-right-chest", - "title": "Правый сундук в башне Ворона", - "plateCount": 6, - "url": "https://unlockmyloot.com/locks/raven-tower-right-chest/", - "code": "bEhYigAEVCQEAA", - "expectedActions": 8, - "solvers": { - "matrix-astar": { - "A": 8, - "U": 6, - "C": 23, - "plateSwitches": 7, - "commands": [ - { - "plate": 2, - "direction": "left", - "steps": 5 - }, - { - "plate": 5, - "direction": "left", - "steps": 1 - }, - { - "plate": 6, - "direction": "right", - "steps": 6 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "right", - "steps": 2 - }, - { - "plate": 4, - "direction": "left", - "steps": 2 - }, - { - "plate": 6, - "direction": "right", - "steps": 1 - } - ], - "timingMs": { - "min": 0.14451799999915238, - "median": 0.1515479999998206, - "max": 0.1800309999998717 - }, - "samplesMs": [ - 0.1493850000006205, - 0.1515479999998206, - 0.15155799999956798, - 0.1800309999998717, - 0.14451799999915238 - ] - }, - "bfs": { - "A": 8, - "U": 6, - "C": 23, - "plateSwitches": 7, - "commands": [ - { - "plate": 2, - "direction": "left", - "steps": 5 - }, - { - "plate": 3, - "direction": "right", - "steps": 1 - }, - { - "plate": 6, - "direction": "right", - "steps": 5 - }, - { - "plate": 1, - "direction": "right", - "steps": 5 - }, - { - "plate": 3, - "direction": "right", - "steps": 2 - }, - { - "plate": 4, - "direction": "left", - "steps": 2 - }, - { - "plate": 5, - "direction": "left", - "steps": 1 - }, - { - "plate": 6, - "direction": "right", - "steps": 2 - } - ], - "timingMs": { - "min": 5.5585230000015144, - "median": 6.44756100000086, - "max": 7.085629999999583 - }, - "samplesMs": [ - 7.085629999999583, - 5.919084999999541, - 6.44756100000086, - 6.700902000000497, - 5.5585230000015144 - ] - }, - "baseline": { - "A": 8, - "U": 6, - "C": 23, - "plateSwitches": 7, - "commands": [ - { - "plate": 2, - "direction": "left", - "steps": 5 - }, - { - "plate": 3, - "direction": "right", - "steps": 1 - }, - { - "plate": 6, - "direction": "right", - "steps": 5 - }, - { - "plate": 1, - "direction": "right", - "steps": 5 - }, - { - "plate": 3, - "direction": "right", - "steps": 2 - }, - { - "plate": 4, - "direction": "left", - "steps": 2 - }, - { - "plate": 5, - "direction": "left", - "steps": 1 - }, - { - "plate": 6, - "direction": "right", - "steps": 2 - } - ], - "timingMs": { - "min": 31.400143000000753, - "median": 35.7508629999993, - "max": 38.89449799999966 - }, - "samplesMs": [ - 35.7508629999993, - 37.677066000000195, - 38.89449799999966, - 32.10103700000036, - 31.400143000000753 - ] - }, - "reference": { - "A": 8, - "U": 6, - "C": 23, - "plateSwitches": 7, - "commands": [ - { - "plate": 6, - "direction": "right", - "steps": 6 - }, - { - "plate": 1, - "direction": "right", - "steps": 2 - }, - { - "plate": 6, - "direction": "right", - "steps": 1 - }, - { - "plate": 5, - "direction": "left", - "steps": 1 - }, - { - "plate": 4, - "direction": "left", - "steps": 2 - }, - { - "plate": 2, - "direction": "left", - "steps": 5 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - } - ], - "timingMs": { - "min": 15.234651000000667, - "median": 15.675302999999985, - "max": 17.53932900000109 - }, - "samplesMs": [ - 16.070357999999032, - 17.53932900000109, - 15.675302999999985, - 15.242341999999553, - 15.234651000000667 - ] - } - }, - "historicalReference": { - "kind": "cached-observation", - "capturedAt": "2026-09-07T06:02:47.149Z", - "A": 8, - "U": 6, - "C": 23, - "plateSwitches": 7, - "commands": [ - { - "plate": 6, - "direction": "right", - "steps": 6 - }, - { - "plate": 1, - "direction": "right", - "steps": 2 - }, - { - "plate": 6, - "direction": "right", - "steps": 1 - }, - { - "plate": 5, - "direction": "left", - "steps": 1 - }, - { - "plate": 4, - "direction": "left", - "steps": 2 - }, - { - "plate": 2, - "direction": "left", - "steps": 5 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - } - ], - "historicalTimingMs": { - "min": 15.836498000000574, - "median": 16.735591999999087, - "max": 18.030751000000237 - } - } - }, - { - "id": "raven-tower-floor2-stairs-chest", - "title": "Сундук у лестницы (2-й этаж башни)", - "plateCount": 6, - "url": "https://unlockmyloot.com/locks/raven-tower-floor2-stairs-chest/", - "code": "YqahUKAAACIIAA", - "expectedActions": 16, - "solvers": { - "matrix-astar": { - "A": 16, - "U": 6, - "C": 48, - "plateSwitches": 15, - "commands": [ - { - "plate": 6, - "direction": "left", - "steps": 3 - }, - { - "plate": 1, - "direction": "right", - "steps": 1 - }, - { - "plate": 5, - "direction": "right", - "steps": 1 - }, - { - "plate": 1, - "direction": "right", - "steps": 1 - }, - { - "plate": 3, - "direction": "left", - "steps": 4 - }, - { - "plate": 4, - "direction": "right", - "steps": 2 - }, - { - "plate": 5, - "direction": "right", - "steps": 3 - }, - { - "plate": 4, - "direction": "right", - "steps": 3 - }, - { - "plate": 5, - "direction": "right", - "steps": 3 - }, - { - "plate": 2, - "direction": "left", - "steps": 6 - }, - { - "plate": 3, - "direction": "left", - "steps": 6 - }, - { - "plate": 4, - "direction": "right", - "steps": 3 - }, - { - "plate": 5, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - }, - { - "plate": 4, - "direction": "right", - "steps": 3 - }, - { - "plate": 5, - "direction": "right", - "steps": 3 - } - ], - "timingMs": { - "min": 4.273090000000593, - "median": 4.441642999998294, - "max": 7.479363000000376 - }, - "samplesMs": [ - 4.441642999998294, - 7.479363000000376, - 4.273090000000593, - 4.88193499999943, - 4.438348000001497 - ] - }, - "bfs": { - "A": 16, - "U": 6, - "C": 48, - "plateSwitches": 15, - "commands": [ - { - "plate": 1, - "direction": "right", - "steps": 1 - }, - { - "plate": 5, - "direction": "right", - "steps": 1 - }, - { - "plate": 1, - "direction": "right", - "steps": 1 - }, - { - "plate": 3, - "direction": "left", - "steps": 4 - }, - { - "plate": 4, - "direction": "right", - "steps": 2 - }, - { - "plate": 5, - "direction": "right", - "steps": 3 - }, - { - "plate": 2, - "direction": "left", - "steps": 3 - }, - { - "plate": 4, - "direction": "right", - "steps": 3 - }, - { - "plate": 5, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - }, - { - "plate": 3, - "direction": "left", - "steps": 6 - }, - { - "plate": 4, - "direction": "right", - "steps": 3 - }, - { - "plate": 5, - "direction": "right", - "steps": 6 - }, - { - "plate": 2, - "direction": "left", - "steps": 3 - }, - { - "plate": 4, - "direction": "right", - "steps": 3 - }, - { - "plate": 6, - "direction": "left", - "steps": 3 - } - ], - "timingMs": { - "min": 13.366459999999279, - "median": 13.868455999998332, - "max": 14.56905899999947 - }, - "samplesMs": [ - 13.366459999999279, - 14.56905899999947, - 14.253684999999678, - 13.479621000000407, - 13.868455999998332 - ] - }, - "baseline": { - "A": 16, - "U": 6, - "C": 48, - "plateSwitches": 15, - "commands": [ - { - "plate": 1, - "direction": "right", - "steps": 1 - }, - { - "plate": 5, - "direction": "right", - "steps": 1 - }, - { - "plate": 1, - "direction": "right", - "steps": 1 - }, - { - "plate": 3, - "direction": "left", - "steps": 4 - }, - { - "plate": 4, - "direction": "right", - "steps": 2 - }, - { - "plate": 5, - "direction": "right", - "steps": 3 - }, - { - "plate": 2, - "direction": "left", - "steps": 3 - }, - { - "plate": 4, - "direction": "right", - "steps": 3 - }, - { - "plate": 5, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - }, - { - "plate": 3, - "direction": "left", - "steps": 6 - }, - { - "plate": 4, - "direction": "right", - "steps": 3 - }, - { - "plate": 5, - "direction": "right", - "steps": 6 - }, - { - "plate": 2, - "direction": "left", - "steps": 3 - }, - { - "plate": 4, - "direction": "right", - "steps": 3 - }, - { - "plate": 6, - "direction": "left", - "steps": 3 - } - ], - "timingMs": { - "min": 80.16143600000032, - "median": 84.43987300000117, - "max": 86.14672700000119 - }, - "samplesMs": [ - 80.16143600000032, - 84.91440700000021, - 84.43987300000117, - 81.68729200000053, - 86.14672700000119 - ] - }, - "reference": { - "A": 16, - "U": 6, - "C": 48, - "plateSwitches": 15, - "commands": [ - { - "plate": 6, - "direction": "left", - "steps": 3 - }, - { - "plate": 4, - "direction": "right", - "steps": 1 - }, - { - "plate": 5, - "direction": "right", - "steps": 1 - }, - { - "plate": 4, - "direction": "right", - "steps": 1 - }, - { - "plate": 1, - "direction": "right", - "steps": 2 - }, - { - "plate": 5, - "direction": "right", - "steps": 3 - }, - { - "plate": 4, - "direction": "right", - "steps": 3 - }, - { - "plate": 5, - "direction": "right", - "steps": 3 - }, - { - "plate": 4, - "direction": "right", - "steps": 3 - }, - { - "plate": 3, - "direction": "left", - "steps": 4 - }, - { - "plate": 2, - "direction": "left", - "steps": 6 - }, - { - "plate": 5, - "direction": "right", - "steps": 3 - }, - { - "plate": 4, - "direction": "right", - "steps": 3 - }, - { - "plate": 5, - "direction": "right", - "steps": 3 - }, - { - "plate": 3, - "direction": "left", - "steps": 6 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - } - ], - "timingMs": { - "min": 51.24391699999978, - "median": 52.77958199999921, - "max": 64.88564299999962 - }, - "samplesMs": [ - 51.81840200000079, - 55.08690099999876, - 51.24391699999978, - 64.88564299999962, - 52.77958199999921 - ] - } - }, - "historicalReference": { - "kind": "cached-observation", - "capturedAt": "2026-09-07T06:02:47.149Z", - "A": 16, - "U": 6, - "C": 48, - "plateSwitches": 15, - "commands": [ - { - "plate": 6, - "direction": "left", - "steps": 3 - }, - { - "plate": 4, - "direction": "right", - "steps": 1 - }, - { - "plate": 5, - "direction": "right", - "steps": 1 - }, - { - "plate": 4, - "direction": "right", - "steps": 1 - }, - { - "plate": 1, - "direction": "right", - "steps": 2 - }, - { - "plate": 5, - "direction": "right", - "steps": 3 - }, - { - "plate": 4, - "direction": "right", - "steps": 3 - }, - { - "plate": 5, - "direction": "right", - "steps": 3 - }, - { - "plate": 4, - "direction": "right", - "steps": 3 - }, - { - "plate": 3, - "direction": "left", - "steps": 4 - }, - { - "plate": 2, - "direction": "left", - "steps": 6 - }, - { - "plate": 5, - "direction": "right", - "steps": 3 - }, - { - "plate": 4, - "direction": "right", - "steps": 3 - }, - { - "plate": 5, - "direction": "right", - "steps": 3 - }, - { - "plate": 3, - "direction": "left", - "steps": 6 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - } - ], - "historicalTimingMs": { - "min": 53.395453000001, - "median": 54.389939999999115, - "max": 56.43529800000033 - } - } - }, - { - "id": "raven-tower-floor2-chest-2", - "title": "Второй сундук на 2-м этаже башни", - "plateCount": 5, - "url": "https://unlockmyloot.com/locks/raven-tower-floor2-chest-2/", - "code": "Q6XCAhAglI", - "expectedActions": 8, - "solvers": { - "matrix-astar": { - "A": 8, - "U": 5, - "C": 29, - "plateSwitches": 7, - "commands": [ - { - "plate": 4, - "direction": "left", - "steps": 1 - }, - { - "plate": 3, - "direction": "right", - "steps": 5 - }, - { - "plate": 5, - "direction": "left", - "steps": 5 - }, - { - "plate": 4, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "right", - "steps": 1 - }, - { - "plate": 2, - "direction": "left", - "steps": 5 - }, - { - "plate": 3, - "direction": "right", - "steps": 6 - }, - { - "plate": 5, - "direction": "left", - "steps": 3 - } - ], - "timingMs": { - "min": 0.17590500000005704, - "median": 0.199640000000727, - "max": 0.2346529999995255 - }, - "samplesMs": [ - 0.199640000000727, - 0.2346529999995255, - 0.20039100000030885, - 0.17590500000005704, - 0.18819300000177464 - ] - }, - "bfs": { - "A": 8, - "U": 5, - "C": 29, - "plateSwitches": 7, - "commands": [ - { - "plate": 4, - "direction": "left", - "steps": 1 - }, - { - "plate": 3, - "direction": "right", - "steps": 5 - }, - { - "plate": 5, - "direction": "left", - "steps": 5 - }, - { - "plate": 1, - "direction": "right", - "steps": 1 - }, - { - "plate": 4, - "direction": "right", - "steps": 3 - }, - { - "plate": 2, - "direction": "left", - "steps": 5 - }, - { - "plate": 3, - "direction": "right", - "steps": 6 - }, - { - "plate": 5, - "direction": "left", - "steps": 3 - } - ], - "timingMs": { - "min": 2.012869000000137, - "median": 2.0886019999998098, - "max": 2.34657100000004 - }, - "samplesMs": [ - 2.012869000000137, - 2.34657100000004, - 2.0473299999994197, - 2.0886019999998098, - 2.0896539999994275 - ] - }, - "baseline": { - "A": 8, - "U": 5, - "C": 29, - "plateSwitches": 7, - "commands": [ - { - "plate": 4, - "direction": "left", - "steps": 1 - }, - { - "plate": 3, - "direction": "right", - "steps": 5 - }, - { - "plate": 5, - "direction": "left", - "steps": 5 - }, - { - "plate": 1, - "direction": "right", - "steps": 1 - }, - { - "plate": 4, - "direction": "right", - "steps": 3 - }, - { - "plate": 2, - "direction": "left", - "steps": 5 - }, - { - "plate": 3, - "direction": "right", - "steps": 6 - }, - { - "plate": 5, - "direction": "left", - "steps": 3 - } - ], - "timingMs": { - "min": 9.323398000000452, - "median": 9.79350599999998, - "max": 10.253747999999177 - }, - "samplesMs": [ - 9.79350599999998, - 10.253747999999177, - 9.459863000000041, - 10.128740000000107, - 9.323398000000452 - ] - }, - "reference": { - "A": 9, - "U": 5, - "C": 27, - "plateSwitches": 8, - "commands": [ - { - "plate": 3, - "direction": "right", - "steps": 4 - }, - { - "plate": 5, - "direction": "left", - "steps": 4 - }, - { - "plate": 3, - "direction": "right", - "steps": 4 - }, - { - "plate": 2, - "direction": "left", - "steps": 2 - }, - { - "plate": 5, - "direction": "left", - "steps": 4 - }, - { - "plate": 4, - "direction": "right", - "steps": 2 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 2, - "direction": "left", - "steps": 3 - }, - { - "plate": 1, - "direction": "right", - "steps": 1 - } - ], - "timingMs": { - "min": 2.983339000000342, - "median": 3.1965799999998126, - "max": 3.440536999998585 - }, - "samplesMs": [ - 3.1965799999998126, - 3.440536999998585, - 3.3594339999999647, - 3.0248410000003787, - 2.983339000000342 - ] - } - }, - "historicalReference": { - "kind": "cached-observation", - "capturedAt": "2026-09-07T06:02:47.149Z", - "A": 9, - "U": 5, - "C": 27, - "plateSwitches": 8, - "commands": [ - { - "plate": 3, - "direction": "right", - "steps": 4 - }, - { - "plate": 5, - "direction": "left", - "steps": 4 - }, - { - "plate": 3, - "direction": "right", - "steps": 4 - }, - { - "plate": 2, - "direction": "left", - "steps": 2 - }, - { - "plate": 5, - "direction": "left", - "steps": 4 - }, - { - "plate": 4, - "direction": "right", - "steps": 2 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 2, - "direction": "left", - "steps": 3 - }, - { - "plate": 1, - "direction": "right", - "steps": 1 - } - ], - "historicalTimingMs": { - "min": 3.0696099999986473, - "median": 3.0996259999992617, - "max": 3.4110639999998966 - } - } - }, - { - "id": "raven-tower-floor3-entrance-chest", - "title": "Сундук у входа (3-й этаж башни)", - "plateCount": 5, - "url": "https://unlockmyloot.com/locks/raven-tower-floor3-entrance-chest/", - "code": "QmzSSoBAAg", - "expectedActions": 14, - "solvers": { - "matrix-astar": { - "A": 14, - "U": 5, - "C": 44, - "plateSwitches": 13, - "commands": [ - { - "plate": 4, - "direction": "right", - "steps": 4 - }, - { - "plate": 2, - "direction": "left", - "steps": 1 - }, - { - "plate": 5, - "direction": "right", - "steps": 2 - }, - { - "plate": 1, - "direction": "right", - "steps": 2 - }, - { - "plate": 2, - "direction": "left", - "steps": 2 - }, - { - "plate": 5, - "direction": "right", - "steps": 4 - }, - { - "plate": 3, - "direction": "right", - "steps": 4 - }, - { - "plate": 2, - "direction": "left", - "steps": 3 - }, - { - "plate": 4, - "direction": "right", - "steps": 6 - }, - { - "plate": 5, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - }, - { - "plate": 2, - "direction": "left", - "steps": 3 - }, - { - "plate": 5, - "direction": "right", - "steps": 5 - }, - { - "plate": 1, - "direction": "right", - "steps": 2 - } - ], - "timingMs": { - "min": 0.6376500000005763, - "median": 0.7073239999990619, - "max": 0.7690469999997731 - }, - "samplesMs": [ - 0.7073239999990619, - 0.6376500000005763, - 0.7659220000005007, - 0.6442090000000462, - 0.7690469999997731 - ] - }, - "bfs": { - "A": 14, - "U": 5, - "C": 44, - "plateSwitches": 13, - "commands": [ - { - "plate": 2, - "direction": "left", - "steps": 1 - }, - { - "plate": 4, - "direction": "right", - "steps": 5 - }, - { - "plate": 5, - "direction": "right", - "steps": 2 - }, - { - "plate": 1, - "direction": "right", - "steps": 2 - }, - { - "plate": 2, - "direction": "left", - "steps": 2 - }, - { - "plate": 4, - "direction": "right", - "steps": 2 - }, - { - "plate": 5, - "direction": "right", - "steps": 4 - }, - { - "plate": 1, - "direction": "right", - "steps": 2 - }, - { - "plate": 5, - "direction": "right", - "steps": 2 - }, - { - "plate": 3, - "direction": "right", - "steps": 4 - }, - { - "plate": 2, - "direction": "left", - "steps": 6 - }, - { - "plate": 4, - "direction": "right", - "steps": 3 - }, - { - "plate": 5, - "direction": "right", - "steps": 6 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - } - ], - "timingMs": { - "min": 1.5406480000001466, - "median": 1.6406690000003437, - "max": 3.2485080000005837 - }, - "samplesMs": [ - 1.6563120000009803, - 1.5406480000001466, - 1.6406690000003437, - 1.5575529999987339, - 3.2485080000005837 - ] - }, - "baseline": { - "A": 14, - "U": 5, - "C": 44, - "plateSwitches": 13, - "commands": [ - { - "plate": 2, - "direction": "left", - "steps": 1 - }, - { - "plate": 4, - "direction": "right", - "steps": 5 - }, - { - "plate": 5, - "direction": "right", - "steps": 2 - }, - { - "plate": 1, - "direction": "right", - "steps": 2 - }, - { - "plate": 2, - "direction": "left", - "steps": 2 - }, - { - "plate": 4, - "direction": "right", - "steps": 2 - }, - { - "plate": 5, - "direction": "right", - "steps": 4 - }, - { - "plate": 1, - "direction": "right", - "steps": 2 - }, - { - "plate": 5, - "direction": "right", - "steps": 2 - }, - { - "plate": 3, - "direction": "right", - "steps": 4 - }, - { - "plate": 2, - "direction": "left", - "steps": 6 - }, - { - "plate": 4, - "direction": "right", - "steps": 3 - }, - { - "plate": 5, - "direction": "right", - "steps": 6 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - } - ], - "timingMs": { - "min": 7.454707000000781, - "median": 8.776225000001432, - "max": 12.391845000000103 - }, - "samplesMs": [ - 7.454707000000781, - 8.776225000001432, - 8.644166000000041, - 9.519312999998874, - 12.391845000000103 - ] - }, - "reference": { - "A": 14, - "U": 5, - "C": 44, - "plateSwitches": 13, - "commands": [ - { - "plate": 2, - "direction": "left", - "steps": 1 - }, - { - "plate": 5, - "direction": "right", - "steps": 2 - }, - { - "plate": 4, - "direction": "right", - "steps": 1 - }, - { - "plate": 1, - "direction": "right", - "steps": 2 - }, - { - "plate": 2, - "direction": "left", - "steps": 2 - }, - { - "plate": 5, - "direction": "right", - "steps": 4 - }, - { - "plate": 1, - "direction": "right", - "steps": 2 - }, - { - "plate": 5, - "direction": "right", - "steps": 2 - }, - { - "plate": 4, - "direction": "right", - "steps": 6 - }, - { - "plate": 3, - "direction": "right", - "steps": 4 - }, - { - "plate": 2, - "direction": "left", - "steps": 6 - }, - { - "plate": 5, - "direction": "right", - "steps": 6 - }, - { - "plate": 4, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - } - ], - "timingMs": { - "min": 6.350915999999415, - "median": 6.698889000001145, - "max": 9.02689200000168 - }, - "samplesMs": [ - 6.396123999998963, - 9.02689200000168, - 6.350915999999415, - 6.74929499999962, - 6.698889000001145 - ] - } - }, - "historicalReference": { - "kind": "cached-observation", - "capturedAt": "2026-09-07T06:02:47.149Z", - "A": 14, - "U": 5, - "C": 44, - "plateSwitches": 13, - "commands": [ - { - "plate": 2, - "direction": "left", - "steps": 1 - }, - { - "plate": 5, - "direction": "right", - "steps": 2 - }, - { - "plate": 4, - "direction": "right", - "steps": 1 - }, - { - "plate": 1, - "direction": "right", - "steps": 2 - }, - { - "plate": 2, - "direction": "left", - "steps": 2 - }, - { - "plate": 5, - "direction": "right", - "steps": 4 - }, - { - "plate": 1, - "direction": "right", - "steps": 2 - }, - { - "plate": 5, - "direction": "right", - "steps": 2 - }, - { - "plate": 4, - "direction": "right", - "steps": 6 - }, - { - "plate": 3, - "direction": "right", - "steps": 4 - }, - { - "plate": 2, - "direction": "left", - "steps": 6 - }, - { - "plate": 5, - "direction": "right", - "steps": 6 - }, - { - "plate": 4, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - } - ], - "historicalTimingMs": { - "min": 6.549548999999388, - "median": 6.864462999998068, - "max": 7.441822000000684 - } - } - }, - { - "id": "raven-tower-floor3-empty-chest", - "title": "Пустой сундук (3-й этаж башни)", - "plateCount": 5, - "url": "https://unlockmyloot.com/locks/raven-tower-floor3-empty-chest/", - "code": "RYIBEylAIA", - "expectedActions": 9, - "solvers": { - "matrix-astar": { - "A": 9, - "U": 5, - "C": 37, - "plateSwitches": 8, - "commands": [ - { - "plate": 1, - "direction": "right", - "steps": 2 - }, - { - "plate": 5, - "direction": "left", - "steps": 6 - }, - { - "plate": 3, - "direction": "left", - "steps": 3 - }, - { - "plate": 2, - "direction": "right", - "steps": 3 - }, - { - "plate": 5, - "direction": "left", - "steps": 6 - }, - { - "plate": 4, - "direction": "right", - "steps": 4 - }, - { - "plate": 1, - "direction": "right", - "steps": 6 - }, - { - "plate": 5, - "direction": "left", - "steps": 4 - }, - { - "plate": 3, - "direction": "left", - "steps": 3 - } - ], - "timingMs": { - "min": 0.07011600000078033, - "median": 0.08950499999991735, - "max": 0.09559399999852758 - }, - "samplesMs": [ - 0.08950499999991735, - 0.07011600000078033, - 0.09443199999986973, - 0.09559399999852758, - 0.08529899999848567 - ] - }, - "bfs": { - "A": 9, - "U": 5, - "C": 41, - "plateSwitches": 8, - "commands": [ - { - "plate": 1, - "direction": "right", - "steps": 2 - }, - { - "plate": 4, - "direction": "left", - "steps": 2 - }, - { - "plate": 5, - "direction": "left", - "steps": 4 - }, - { - "plate": 3, - "direction": "left", - "steps": 6 - }, - { - "plate": 1, - "direction": "right", - "steps": 6 - }, - { - "plate": 5, - "direction": "left", - "steps": 6 - }, - { - "plate": 4, - "direction": "right", - "steps": 6 - }, - { - "plate": 5, - "direction": "left", - "steps": 6 - }, - { - "plate": 2, - "direction": "right", - "steps": 3 - } - ], - "timingMs": { - "min": 1.4483090000012453, - "median": 1.4614280000005238, - "max": 1.8376450000014302 - }, - "samplesMs": [ - 1.4483090000012453, - 1.459606000000349, - 1.8376450000014302, - 1.4614280000005238, - 1.692786999999953 - ] - }, - "baseline": { - "A": 9, - "U": 5, - "C": 41, - "plateSwitches": 8, - "commands": [ - { - "plate": 1, - "direction": "right", - "steps": 2 - }, - { - "plate": 4, - "direction": "left", - "steps": 2 - }, - { - "plate": 5, - "direction": "left", - "steps": 4 - }, - { - "plate": 3, - "direction": "left", - "steps": 6 - }, - { - "plate": 1, - "direction": "right", - "steps": 6 - }, - { - "plate": 5, - "direction": "left", - "steps": 6 - }, - { - "plate": 4, - "direction": "right", - "steps": 6 - }, - { - "plate": 5, - "direction": "left", - "steps": 6 - }, - { - "plate": 2, - "direction": "right", - "steps": 3 - } - ], - "timingMs": { - "min": 8.154039000000921, - "median": 8.421802000000753, - "max": 8.643384999999398 - }, - "samplesMs": [ - 8.154039000000921, - 8.421802000000753, - 8.604736999999659, - 8.160427000000709, - 8.643384999999398 - ] - }, - "reference": { - "A": 9, - "U": 5, - "C": 37, - "plateSwitches": 8, - "commands": [ - { - "plate": 5, - "direction": "left", - "steps": 6 - }, - { - "plate": 3, - "direction": "left", - "steps": 2 - }, - { - "plate": 1, - "direction": "right", - "steps": 4 - }, - { - "plate": 3, - "direction": "left", - "steps": 4 - }, - { - "plate": 5, - "direction": "left", - "steps": 6 - }, - { - "plate": 1, - "direction": "right", - "steps": 4 - }, - { - "plate": 4, - "direction": "right", - "steps": 4 - }, - { - "plate": 5, - "direction": "left", - "steps": 4 - }, - { - "plate": 2, - "direction": "right", - "steps": 3 - } - ], - "timingMs": { - "min": 5.290108999999575, - "median": 5.552193999999872, - "max": 6.112656999999672 - }, - "samplesMs": [ - 5.4817370000000665, - 5.56495299999915, - 5.290108999999575, - 6.112656999999672, - 5.552193999999872 - ] - } - }, - "historicalReference": { - "kind": "cached-observation", - "capturedAt": "2026-09-07T06:02:47.149Z", - "A": 9, - "U": 5, - "C": 37, - "plateSwitches": 8, - "commands": [ - { - "plate": 5, - "direction": "left", - "steps": 6 - }, - { - "plate": 3, - "direction": "left", - "steps": 2 - }, - { - "plate": 1, - "direction": "right", - "steps": 4 - }, - { - "plate": 3, - "direction": "left", - "steps": 4 - }, - { - "plate": 5, - "direction": "left", - "steps": 6 - }, - { - "plate": 1, - "direction": "right", - "steps": 4 - }, - { - "plate": 4, - "direction": "right", - "steps": 4 - }, - { - "plate": 5, - "direction": "left", - "steps": 4 - }, - { - "plate": 2, - "direction": "right", - "steps": 3 - } - ], - "historicalTimingMs": { - "min": 5.393829000000551, - "median": 5.431555999999546, - "max": 5.844538000001194 - } - } - }, - { - "id": "raven-tower-floor4-elixirs-chest", - "title": "Сундук с эликсирами (4-й этаж башни)", - "plateCount": 7, - "url": "https://unlockmyloot.com/locks/raven-tower-floor4-elixirs-chest/", - "code": "h24ZgUAAEAQgAwAKQAA", - "expectedActions": 8, - "solvers": { - "matrix-astar": { - "A": 8, - "U": 5, - "C": 36, - "plateSwitches": 7, - "commands": [ - { - "plate": 2, - "direction": "right", - "steps": 5 - }, - { - "plate": 3, - "direction": "right", - "steps": 5 - }, - { - "plate": 2, - "direction": "right", - "steps": 5 - }, - { - "plate": 5, - "direction": "left", - "steps": 6 - }, - { - "plate": 1, - "direction": "left", - "steps": 3 - }, - { - "plate": 3, - "direction": "right", - "steps": 6 - }, - { - "plate": 2, - "direction": "right", - "steps": 3 - }, - { - "plate": 4, - "direction": "left", - "steps": 3 - } - ], - "timingMs": { - "min": 0.10925399999905494, - "median": 0.1123090000000957, - "max": 0.13619499999913387 - }, - "samplesMs": [ - 0.11310999999841442, - 0.10964500000045518, - 0.13619499999913387, - 0.10925399999905494, - 0.1123090000000957 - ] - }, - "bfs": { - "A": 8, - "U": 5, - "C": 36, - "plateSwitches": 7, - "commands": [ - { - "plate": 2, - "direction": "right", - "steps": 5 - }, - { - "plate": 3, - "direction": "right", - "steps": 5 - }, - { - "plate": 2, - "direction": "right", - "steps": 5 - }, - { - "plate": 5, - "direction": "left", - "steps": 6 - }, - { - "plate": 1, - "direction": "left", - "steps": 3 - }, - { - "plate": 3, - "direction": "right", - "steps": 6 - }, - { - "plate": 2, - "direction": "right", - "steps": 3 - }, - { - "plate": 4, - "direction": "left", - "steps": 3 - } - ], - "timingMs": { - "min": 11.04016099999717, - "median": 11.22109300000011, - "max": 12.678585999998177 - }, - "samplesMs": [ - 11.987196000001859, - 11.22109300000011, - 11.050477000000683, - 11.04016099999717, - 12.678585999998177 - ] - }, - "baseline": { - "A": 8, - "U": 5, - "C": 36, - "plateSwitches": 7, - "commands": [ - { - "plate": 2, - "direction": "right", - "steps": 5 - }, - { - "plate": 3, - "direction": "right", - "steps": 5 - }, - { - "plate": 2, - "direction": "right", - "steps": 5 - }, - { - "plate": 5, - "direction": "left", - "steps": 6 - }, - { - "plate": 1, - "direction": "left", - "steps": 3 - }, - { - "plate": 3, - "direction": "right", - "steps": 6 - }, - { - "plate": 2, - "direction": "right", - "steps": 3 - }, - { - "plate": 4, - "direction": "left", - "steps": 3 - } - ], - "timingMs": { - "min": 73.12932, - "median": 75.16747799999939, - "max": 82.03915599999891 - }, - "samplesMs": [ - 75.00344000000041, - 73.12932, - 75.16747799999939, - 75.42378299999837, - 82.03915599999891 - ] - }, - "reference": { - "A": 8, - "U": 5, - "C": 36, - "plateSwitches": 7, - "commands": [ - { - "plate": 2, - "direction": "right", - "steps": 4 - }, - { - "plate": 3, - "direction": "right", - "steps": 5 - }, - { - "plate": 5, - "direction": "left", - "steps": 6 - }, - { - "plate": 2, - "direction": "right", - "steps": 6 - }, - { - "plate": 3, - "direction": "right", - "steps": 6 - }, - { - "plate": 4, - "direction": "left", - "steps": 3 - }, - { - "plate": 2, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "left", - "steps": 3 - } - ], - "timingMs": { - "min": 107.46184699999867, - "median": 112.56941299999744, - "max": 114.66700800000035 - }, - "samplesMs": [ - 112.56941299999744, - 113.98022499999934, - 112.39501000000018, - 107.46184699999867, - 114.66700800000035 - ] - } - }, - "historicalReference": { - "kind": "cached-observation", - "capturedAt": "2026-09-07T06:02:47.149Z", - "A": 8, - "U": 5, - "C": 36, - "plateSwitches": 7, - "commands": [ - { - "plate": 2, - "direction": "right", - "steps": 4 - }, - { - "plate": 3, - "direction": "right", - "steps": 5 - }, - { - "plate": 5, - "direction": "left", - "steps": 6 - }, - { - "plate": 2, - "direction": "right", - "steps": 6 - }, - { - "plate": 3, - "direction": "right", - "steps": 6 - }, - { - "plate": 4, - "direction": "left", - "steps": 3 - }, - { - "plate": 2, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "left", - "steps": 3 - } - ], - "historicalTimingMs": { - "min": 108.15463799999998, - "median": 113.79403699999966, - "max": 127.23316599999816 - } - } - }, - { - "id": "raven-tower-floor4-food-chest", - "title": "Сундук с едой (4-й этаж башни)", - "plateCount": 6, - "url": "https://unlockmyloot.com/locks/raven-tower-floor4-food-chest/", - "code": "aYqYiAQgCACABA", - "expectedActions": 11, - "solvers": { - "matrix-astar": { - "A": 11, - "U": 6, - "C": 43, - "plateSwitches": 10, - "commands": [ - { - "plate": 3, - "direction": "left", - "steps": 4 - }, - { - "plate": 1, - "direction": "left", - "steps": 4 - }, - { - "plate": 3, - "direction": "left", - "steps": 4 - }, - { - "plate": 5, - "direction": "left", - "steps": 6 - }, - { - "plate": 1, - "direction": "left", - "steps": 5 - }, - { - "plate": 4, - "direction": "right", - "steps": 1 - }, - { - "plate": 2, - "direction": "left", - "steps": 5 - }, - { - "plate": 3, - "direction": "left", - "steps": 6 - }, - { - "plate": 4, - "direction": "right", - "steps": 3 - }, - { - "plate": 5, - "direction": "left", - "steps": 2 - }, - { - "plate": 6, - "direction": "right", - "steps": 3 - } - ], - "timingMs": { - "min": 0.267201999999088, - "median": 0.27586499999961234, - "max": 0.2955149999979767 - }, - "samplesMs": [ - 0.2955149999979767, - 0.27586499999961234, - 0.27411299999948824, - 0.28803399999742396, - 0.267201999999088 - ] - }, - "bfs": { - "A": 11, - "U": 6, - "C": 43, - "plateSwitches": 10, - "commands": [ - { - "plate": 3, - "direction": "left", - "steps": 4 - }, - { - "plate": 1, - "direction": "left", - "steps": 4 - }, - { - "plate": 3, - "direction": "left", - "steps": 4 - }, - { - "plate": 4, - "direction": "right", - "steps": 1 - }, - { - "plate": 5, - "direction": "left", - "steps": 6 - }, - { - "plate": 1, - "direction": "left", - "steps": 5 - }, - { - "plate": 2, - "direction": "left", - "steps": 5 - }, - { - "plate": 3, - "direction": "left", - "steps": 6 - }, - { - "plate": 4, - "direction": "right", - "steps": 3 - }, - { - "plate": 5, - "direction": "left", - "steps": 2 - }, - { - "plate": 6, - "direction": "right", - "steps": 3 - } - ], - "timingMs": { - "min": 15.084485999999742, - "median": 15.373972000001231, - "max": 15.722725999999966 - }, - "samplesMs": [ - 15.084485999999742, - 15.722725999999966, - 15.57101799999873, - 15.373972000001231, - 15.149865000003047 - ] - }, - "baseline": { - "A": 11, - "U": 6, - "C": 43, - "plateSwitches": 10, - "commands": [ - { - "plate": 3, - "direction": "left", - "steps": 4 - }, - { - "plate": 1, - "direction": "left", - "steps": 4 - }, - { - "plate": 3, - "direction": "left", - "steps": 4 - }, - { - "plate": 4, - "direction": "right", - "steps": 1 - }, - { - "plate": 5, - "direction": "left", - "steps": 6 - }, - { - "plate": 1, - "direction": "left", - "steps": 5 - }, - { - "plate": 2, - "direction": "left", - "steps": 5 - }, - { - "plate": 3, - "direction": "left", - "steps": 6 - }, - { - "plate": 4, - "direction": "right", - "steps": 3 - }, - { - "plate": 5, - "direction": "left", - "steps": 2 - }, - { - "plate": 6, - "direction": "right", - "steps": 3 - } - ], - "timingMs": { - "min": 90.67828500000178, - "median": 96.31410499999765, - "max": 109.6251830000001 - }, - "samplesMs": [ - 95.69089699999677, - 96.31410499999765, - 98.28082399999766, - 90.67828500000178, - 109.6251830000001 - ] - }, - "reference": { - "A": 11, - "U": 6, - "C": 43, - "plateSwitches": 10, - "commands": [ - { - "plate": 3, - "direction": "left", - "steps": 2 - }, - { - "plate": 6, - "direction": "right", - "steps": 3 - }, - { - "plate": 5, - "direction": "left", - "steps": 2 - }, - { - "plate": 2, - "direction": "left", - "steps": 2 - }, - { - "plate": 4, - "direction": "right", - "steps": 4 - }, - { - "plate": 3, - "direction": "left", - "steps": 6 - }, - { - "plate": 1, - "direction": "left", - "steps": 6 - }, - { - "plate": 5, - "direction": "left", - "steps": 6 - }, - { - "plate": 3, - "direction": "left", - "steps": 6 - }, - { - "plate": 2, - "direction": "left", - "steps": 3 - }, - { - "plate": 1, - "direction": "left", - "steps": 3 - } - ], - "timingMs": { - "min": 51.32584499999939, - "median": 52.31401100000221, - "max": 71.14698800000042 - }, - "samplesMs": [ - 53.33788300000015, - 52.0905359999997, - 52.31401100000221, - 51.32584499999939, - 71.14698800000042 - ] - } - }, - "historicalReference": { - "kind": "cached-observation", - "capturedAt": "2026-09-07T06:02:47.149Z", - "A": 11, - "U": 6, - "C": 43, - "plateSwitches": 10, - "commands": [ - { - "plate": 3, - "direction": "left", - "steps": 2 - }, - { - "plate": 6, - "direction": "right", - "steps": 3 - }, - { - "plate": 5, - "direction": "left", - "steps": 2 - }, - { - "plate": 2, - "direction": "left", - "steps": 2 - }, - { - "plate": 4, - "direction": "right", - "steps": 4 - }, - { - "plate": 3, - "direction": "left", - "steps": 6 - }, - { - "plate": 1, - "direction": "left", - "steps": 6 - }, - { - "plate": 5, - "direction": "left", - "steps": 6 - }, - { - "plate": 3, - "direction": "left", - "steps": 6 - }, - { - "plate": 2, - "direction": "left", - "steps": 3 - }, - { - "plate": 1, - "direction": "left", - "steps": 3 - } - ], - "historicalTimingMs": { - "min": 51.97651599999881, - "median": 53.12884100000156, - "max": 56.046502999997756 - } - } - }, - { - "id": "raven-tower-top-chest", - "title": "Сундук на вершине башни Ворона", - "plateCount": 6, - "url": "https://unlockmyloot.com/locks/raven-tower-top-chest/", - "code": "aMCmkAAGBBYAAA", - "expectedActions": 20, - "solvers": { - "matrix-astar": { - "A": 20, - "U": 6, - "C": 48, - "plateSwitches": 19, - "commands": [ - { - "plate": 2, - "direction": "right", - "steps": 3 - }, - { - "plate": 4, - "direction": "left", - "steps": 1 - }, - { - "plate": 1, - "direction": "right", - "steps": 1 - }, - { - "plate": 5, - "direction": "right", - "steps": 2 - }, - { - "plate": 3, - "direction": "right", - "steps": 1 - }, - { - "plate": 1, - "direction": "right", - "steps": 1 - }, - { - "plate": 3, - "direction": "right", - "steps": 1 - }, - { - "plate": 1, - "direction": "right", - "steps": 1 - }, - { - "plate": 4, - "direction": "left", - "steps": 2 - }, - { - "plate": 6, - "direction": "right", - "steps": 4 - }, - { - "plate": 3, - "direction": "right", - "steps": 1 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - }, - { - "plate": 5, - "direction": "right", - "steps": 6 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - }, - { - "plate": 4, - "direction": "left", - "steps": 3 - } - ], - "timingMs": { - "min": 7.054034000000684, - "median": 7.251360999998724, - "max": 7.770962999999028 - }, - "samplesMs": [ - 7.054034000000684, - 7.143949000001157, - 7.691812000000937, - 7.251360999998724, - 7.770962999999028 - ] - }, - "bfs": { - "A": 20, - "U": 6, - "C": 48, - "plateSwitches": 19, - "commands": [ - { - "plate": 2, - "direction": "right", - "steps": 3 - }, - { - "plate": 4, - "direction": "left", - "steps": 1 - }, - { - "plate": 1, - "direction": "right", - "steps": 1 - }, - { - "plate": 3, - "direction": "right", - "steps": 1 - }, - { - "plate": 1, - "direction": "right", - "steps": 1 - }, - { - "plate": 3, - "direction": "right", - "steps": 1 - }, - { - "plate": 1, - "direction": "right", - "steps": 1 - }, - { - "plate": 3, - "direction": "right", - "steps": 1 - }, - { - "plate": 5, - "direction": "right", - "steps": 2 - }, - { - "plate": 4, - "direction": "left", - "steps": 2 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 5, - "direction": "right", - "steps": 6 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - }, - { - "plate": 6, - "direction": "right", - "steps": 4 - }, - { - "plate": 4, - "direction": "left", - "steps": 3 - } - ], - "timingMs": { - "min": 11.989058999999543, - "median": 12.636073000001488, - "max": 14.032353999999032 - }, - "samplesMs": [ - 12.1789749999989, - 12.636073000001488, - 11.989058999999543, - 12.681780999999319, - 14.032353999999032 - ] - }, - "baseline": { - "A": 20, - "U": 6, - "C": 48, - "plateSwitches": 19, - "commands": [ - { - "plate": 2, - "direction": "right", - "steps": 3 - }, - { - "plate": 4, - "direction": "left", - "steps": 1 - }, - { - "plate": 1, - "direction": "right", - "steps": 1 - }, - { - "plate": 3, - "direction": "right", - "steps": 1 - }, - { - "plate": 1, - "direction": "right", - "steps": 1 - }, - { - "plate": 3, - "direction": "right", - "steps": 1 - }, - { - "plate": 1, - "direction": "right", - "steps": 1 - }, - { - "plate": 3, - "direction": "right", - "steps": 1 - }, - { - "plate": 5, - "direction": "right", - "steps": 2 - }, - { - "plate": 4, - "direction": "left", - "steps": 2 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 5, - "direction": "right", - "steps": 6 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - }, - { - "plate": 6, - "direction": "right", - "steps": 4 - }, - { - "plate": 4, - "direction": "left", - "steps": 3 - } - ], - "timingMs": { - "min": 78.8732029999992, - "median": 82.08234200000152, - "max": 83.19212100000004 - }, - "samplesMs": [ - 78.8732029999992, - 82.08234200000152, - 78.88792499999909, - 82.49721599999975, - 83.19212100000004 - ] - }, - "reference": { - "A": 20, - "U": 6, - "C": 48, - "plateSwitches": 19, - "commands": [ - { - "plate": 4, - "direction": "left", - "steps": 1 - }, - { - "plate": 1, - "direction": "right", - "steps": 1 - }, - { - "plate": 5, - "direction": "right", - "steps": 2 - }, - { - "plate": 3, - "direction": "right", - "steps": 1 - }, - { - "plate": 1, - "direction": "right", - "steps": 1 - }, - { - "plate": 4, - "direction": "left", - "steps": 1 - }, - { - "plate": 1, - "direction": "right", - "steps": 1 - }, - { - "plate": 4, - "direction": "left", - "steps": 1 - }, - { - "plate": 6, - "direction": "right", - "steps": 4 - }, - { - "plate": 3, - "direction": "right", - "steps": 2 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - }, - { - "plate": 5, - "direction": "right", - "steps": 6 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - }, - { - "plate": 4, - "direction": "left", - "steps": 3 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 2, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - } - ], - "timingMs": { - "min": 43.02287200000137, - "median": 43.43741500000033, - "max": 48.328645999998116 - }, - "samplesMs": [ - 48.328645999998116, - 43.43741500000033, - 43.38970399999744, - 45.51470199999676, - 43.02287200000137 - ] - } - }, - "historicalReference": { - "kind": "cached-observation", - "capturedAt": "2026-09-07T06:02:47.149Z", - "A": 20, - "U": 6, - "C": 48, - "plateSwitches": 19, - "commands": [ - { - "plate": 4, - "direction": "left", - "steps": 1 - }, - { - "plate": 1, - "direction": "right", - "steps": 1 - }, - { - "plate": 5, - "direction": "right", - "steps": 2 - }, - { - "plate": 3, - "direction": "right", - "steps": 1 - }, - { - "plate": 1, - "direction": "right", - "steps": 1 - }, - { - "plate": 4, - "direction": "left", - "steps": 1 - }, - { - "plate": 1, - "direction": "right", - "steps": 1 - }, - { - "plate": 4, - "direction": "left", - "steps": 1 - }, - { - "plate": 6, - "direction": "right", - "steps": 4 - }, - { - "plate": 3, - "direction": "right", - "steps": 2 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - }, - { - "plate": 5, - "direction": "right", - "steps": 6 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - }, - { - "plate": 4, - "direction": "left", - "steps": 3 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 2, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - } - ], - "historicalTimingMs": { - "min": 42.69402500000069, - "median": 45.8797799999993, - "max": 46.05100699999821 - } - } - }, - { - "id": "fire-lizard-cave-chest", - "title": "Ящик у огненного ящера", - "plateCount": 6, - "url": "https://unlockmyloot.com/?lock=aDasBgBgCAACCA", - "code": "aDasBgBgCAACCA", - "expectedActions": 10, - "solvers": { - "matrix-astar": { - "A": 10, - "U": 6, - "C": 41, - "plateSwitches": 9, - "commands": [ - { - "plate": 1, - "direction": "right", - "steps": 3 - }, - { - "plate": 3, - "direction": "right", - "steps": 5 - }, - { - "plate": 5, - "direction": "left", - "steps": 4 - }, - { - "plate": 2, - "direction": "left", - "steps": 3 - }, - { - "plate": 6, - "direction": "right", - "steps": 5 - }, - { - "plate": 1, - "direction": "right", - "steps": 6 - }, - { - "plate": 4, - "direction": "right", - "steps": 3 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 5, - "direction": "left", - "steps": 6 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - } - ], - "timingMs": { - "min": 0.17170899999837275, - "median": 0.17570400000113295, - "max": 0.2585590000016964 - }, - "samplesMs": [ - 0.2585590000016964, - 0.1746930000008433, - 0.17170899999837275, - 0.18511800000123912, - 0.17570400000113295 - ] - }, - "bfs": { - "A": 10, - "U": 6, - "C": 41, - "plateSwitches": 9, - "commands": [ - { - "plate": 1, - "direction": "right", - "steps": 3 - }, - { - "plate": 3, - "direction": "right", - "steps": 5 - }, - { - "plate": 5, - "direction": "left", - "steps": 4 - }, - { - "plate": 2, - "direction": "left", - "steps": 3 - }, - { - "plate": 6, - "direction": "right", - "steps": 5 - }, - { - "plate": 1, - "direction": "right", - "steps": 6 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 4, - "direction": "right", - "steps": 3 - }, - { - "plate": 5, - "direction": "left", - "steps": 6 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - } - ], - "timingMs": { - "min": 10.301790000001347, - "median": 10.657054999999673, - "max": 11.986535000000003 - }, - "samplesMs": [ - 10.657054999999673, - 10.631106000000727, - 10.69336999999723, - 11.986535000000003, - 10.301790000001347 - ] - }, - "baseline": { - "A": 10, - "U": 6, - "C": 41, - "plateSwitches": 9, - "commands": [ - { - "plate": 1, - "direction": "right", - "steps": 3 - }, - { - "plate": 3, - "direction": "right", - "steps": 5 - }, - { - "plate": 5, - "direction": "left", - "steps": 4 - }, - { - "plate": 2, - "direction": "left", - "steps": 3 - }, - { - "plate": 6, - "direction": "right", - "steps": 5 - }, - { - "plate": 1, - "direction": "right", - "steps": 6 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 4, - "direction": "right", - "steps": 3 - }, - { - "plate": 5, - "direction": "left", - "steps": 6 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - } - ], - "timingMs": { - "min": 63.81029900000067, - "median": 64.97021400000085, - "max": 67.82240499999898 - }, - "samplesMs": [ - 64.27876499999911, - 64.97021400000085, - 63.81029900000067, - 67.82240499999898, - 65.38025099999868 - ] - }, - "reference": { - "A": 10, - "U": 6, - "C": 41, - "plateSwitches": 9, - "commands": [ - { - "plate": 1, - "direction": "right", - "steps": 3 - }, - { - "plate": 3, - "direction": "right", - "steps": 5 - }, - { - "plate": 6, - "direction": "right", - "steps": 5 - }, - { - "plate": 2, - "direction": "left", - "steps": 3 - }, - { - "plate": 5, - "direction": "left", - "steps": 4 - }, - { - "plate": 1, - "direction": "right", - "steps": 6 - }, - { - "plate": 5, - "direction": "left", - "steps": 6 - }, - { - "plate": 4, - "direction": "right", - "steps": 3 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - } - ], - "timingMs": { - "min": 53.29116200000135, - "median": 55.654889000001276, - "max": 69.18781099999978 - }, - "samplesMs": [ - 53.29116200000135, - 69.18781099999978, - 55.41265399999975, - 55.654889000001276, - 56.53110200000083 - ] - } - }, - "historicalReference": { - "kind": "cached-observation", - "capturedAt": "2026-09-07T06:02:47.149Z", - "A": 10, - "U": 6, - "C": 41, - "plateSwitches": 9, - "commands": [ - { - "plate": 1, - "direction": "right", - "steps": 3 - }, - { - "plate": 3, - "direction": "right", - "steps": 5 - }, - { - "plate": 6, - "direction": "right", - "steps": 5 - }, - { - "plate": 2, - "direction": "left", - "steps": 3 - }, - { - "plate": 5, - "direction": "left", - "steps": 4 - }, - { - "plate": 1, - "direction": "right", - "steps": 6 - }, - { - "plate": 5, - "direction": "left", - "steps": 6 - }, - { - "plate": 4, - "direction": "right", - "steps": 3 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - } - ], - "historicalTimingMs": { - "min": 52.59948500000246, - "median": 53.04343399999925, - "max": 66.56672799999797 - } - } - }, - { - "id": "silas-tavern-basement-chest", - "title": "Сундук в подвале таверны", - "plateCount": 5, - "url": "https://unlockmyloot.com/locks/silas-tavern-basement-chest/", - "code": "RrGUCRKQIQ", - "expectedActions": 9, - "solvers": { - "matrix-astar": { - "A": 9, - "U": 5, - "C": 30, - "plateSwitches": 8, - "commands": [ - { - "plate": 1, - "direction": "left", - "steps": 2 - }, - { - "plate": 5, - "direction": "left", - "steps": 2 - }, - { - "plate": 3, - "direction": "left", - "steps": 4 - }, - { - "plate": 4, - "direction": "right", - "steps": 3 - }, - { - "plate": 2, - "direction": "right", - "steps": 4 - }, - { - "plate": 4, - "direction": "right", - "steps": 3 - }, - { - "plate": 5, - "direction": "left", - "steps": 6 - }, - { - "plate": 3, - "direction": "left", - "steps": 3 - }, - { - "plate": 4, - "direction": "right", - "steps": 3 - } - ], - "timingMs": { - "min": 0.08665099999780068, - "median": 0.11405100000047241, - "max": 0.12520800000129384 - }, - "samplesMs": [ - 0.08671099999992293, - 0.11868799999865587, - 0.12520800000129384, - 0.11405100000047241, - 0.08665099999780068 - ] - }, - "bfs": { - "A": 9, - "U": 5, - "C": 30, - "plateSwitches": 8, - "commands": [ - { - "plate": 1, - "direction": "left", - "steps": 2 - }, - { - "plate": 3, - "direction": "left", - "steps": 2 - }, - { - "plate": 2, - "direction": "right", - "steps": 2 - }, - { - "plate": 4, - "direction": "right", - "steps": 4 - }, - { - "plate": 5, - "direction": "left", - "steps": 6 - }, - { - "plate": 3, - "direction": "left", - "steps": 5 - }, - { - "plate": 4, - "direction": "right", - "steps": 5 - }, - { - "plate": 2, - "direction": "right", - "steps": 2 - }, - { - "plate": 5, - "direction": "left", - "steps": 2 - } - ], - "timingMs": { - "min": 1.36113800000021, - "median": 1.583772999998473, - "max": 2.4945330000009562 - }, - "samplesMs": [ - 1.3633210000007239, - 2.4945330000009562, - 1.583772999998473, - 1.36113800000021, - 1.6200070000013511 - ] - }, - "baseline": { - "A": 9, - "U": 5, - "C": 30, - "plateSwitches": 8, - "commands": [ - { - "plate": 1, - "direction": "left", - "steps": 2 - }, - { - "plate": 3, - "direction": "left", - "steps": 2 - }, - { - "plate": 2, - "direction": "right", - "steps": 2 - }, - { - "plate": 4, - "direction": "right", - "steps": 4 - }, - { - "plate": 5, - "direction": "left", - "steps": 6 - }, - { - "plate": 3, - "direction": "left", - "steps": 5 - }, - { - "plate": 4, - "direction": "right", - "steps": 5 - }, - { - "plate": 2, - "direction": "right", - "steps": 2 - }, - { - "plate": 5, - "direction": "left", - "steps": 2 - } - ], - "timingMs": { - "min": 6.591978000000381, - "median": 7.1919010000019625, - "max": 9.724731999998767 - }, - "samplesMs": [ - 9.724731999998767, - 6.591978000000381, - 8.430585000001884, - 7.1919010000019625, - 6.710055999999895 - ] - }, - "reference": { - "A": 9, - "U": 5, - "C": 30, - "plateSwitches": 8, - "commands": [ - { - "plate": 5, - "direction": "left", - "steps": 2 - }, - { - "plate": 1, - "direction": "left", - "steps": 2 - }, - { - "plate": 3, - "direction": "left", - "steps": 4 - }, - { - "plate": 2, - "direction": "right", - "steps": 1 - }, - { - "plate": 4, - "direction": "right", - "steps": 6 - }, - { - "plate": 5, - "direction": "left", - "steps": 6 - }, - { - "plate": 3, - "direction": "left", - "steps": 3 - }, - { - "plate": 4, - "direction": "right", - "steps": 3 - }, - { - "plate": 2, - "direction": "right", - "steps": 3 - } - ], - "timingMs": { - "min": 3.8559820000009495, - "median": 3.980659000000742, - "max": 4.069674000002124 - }, - "samplesMs": [ - 3.8559820000009495, - 4.069674000002124, - 3.980659000000742, - 3.98505600000135, - 3.9780460000001767 - ] - } - }, - "historicalReference": { - "kind": "cached-observation", - "capturedAt": "2026-09-07T06:02:47.149Z", - "A": 9, - "U": 5, - "C": 30, - "plateSwitches": 8, - "commands": [ - { - "plate": 5, - "direction": "left", - "steps": 2 - }, - { - "plate": 1, - "direction": "left", - "steps": 2 - }, - { - "plate": 3, - "direction": "left", - "steps": 4 - }, - { - "plate": 2, - "direction": "right", - "steps": 1 - }, - { - "plate": 4, - "direction": "right", - "steps": 6 - }, - { - "plate": 5, - "direction": "left", - "steps": 6 - }, - { - "plate": 3, - "direction": "left", - "steps": 3 - }, - { - "plate": 4, - "direction": "right", - "steps": 3 - }, - { - "plate": 2, - "direction": "right", - "steps": 3 - } - ], - "historicalTimingMs": { - "min": 3.8142619999998715, - "median": 3.8800910000027216, - "max": 3.9581380000017816 - } - } - }, - { - "id": "silas-tavern-storeroom-chest", - "title": "Сундук склада таверны", - "plateCount": 6, - "url": "https://unlockmyloot.com/?lock=Yt05ARCBAAARCA", - "code": "Yt05ARCBAAARCA", - "expectedActions": 9, - "solvers": { - "matrix-astar": { - "A": 9, - "U": 6, - "C": 31, - "plateSwitches": 8, - "commands": [ - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 4, - "direction": "left", - "steps": 4 - }, - { - "plate": 6, - "direction": "right", - "steps": 6 - }, - { - "plate": 1, - "direction": "left", - "steps": 2 - }, - { - "plate": 2, - "direction": "right", - "steps": 2 - }, - { - "plate": 5, - "direction": "left", - "steps": 2 - }, - { - "plate": 3, - "direction": "right", - "steps": 6 - }, - { - "plate": 4, - "direction": "left", - "steps": 3 - }, - { - "plate": 6, - "direction": "right", - "steps": 3 - } - ], - "timingMs": { - "min": 0.10359600000083447, - "median": 0.10550899999725516, - "max": 0.13074699999924633 - }, - "samplesMs": [ - 0.13074699999924633, - 0.10687099999995553, - 0.1044770000007702, - 0.10359600000083447, - 0.10550899999725516 - ] - }, - "bfs": { - "A": 9, - "U": 6, - "C": 31, - "plateSwitches": 8, - "commands": [ - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 4, - "direction": "left", - "steps": 4 - }, - { - "plate": 6, - "direction": "right", - "steps": 6 - }, - { - "plate": 1, - "direction": "left", - "steps": 2 - }, - { - "plate": 2, - "direction": "right", - "steps": 2 - }, - { - "plate": 5, - "direction": "left", - "steps": 2 - }, - { - "plate": 3, - "direction": "right", - "steps": 6 - }, - { - "plate": 4, - "direction": "left", - "steps": 3 - }, - { - "plate": 6, - "direction": "right", - "steps": 3 - } - ], - "timingMs": { - "min": 13.265008999998827, - "median": 13.68164600000091, - "max": 15.403705999997328 - }, - "samplesMs": [ - 13.265008999998827, - 15.403705999997328, - 13.898020000000542, - 13.65417399999933, - 13.68164600000091 - ] - }, - "baseline": { - "A": 9, - "U": 6, - "C": 31, - "plateSwitches": 8, - "commands": [ - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 4, - "direction": "left", - "steps": 4 - }, - { - "plate": 6, - "direction": "right", - "steps": 6 - }, - { - "plate": 1, - "direction": "left", - "steps": 2 - }, - { - "plate": 2, - "direction": "right", - "steps": 2 - }, - { - "plate": 5, - "direction": "left", - "steps": 2 - }, - { - "plate": 3, - "direction": "right", - "steps": 6 - }, - { - "plate": 4, - "direction": "left", - "steps": 3 - }, - { - "plate": 6, - "direction": "right", - "steps": 3 - } - ], - "timingMs": { - "min": 86.41672299999846, - "median": 90.60363300000245, - "max": 94.45698000000266 - }, - "samplesMs": [ - 87.26756300000125, - 93.11861699999645, - 90.60363300000245, - 94.45698000000266, - 86.41672299999846 - ] - }, - "reference": { - "A": 9, - "U": 6, - "C": 31, - "plateSwitches": 8, - "commands": [ - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 6, - "direction": "right", - "steps": 6 - }, - { - "plate": 4, - "direction": "left", - "steps": 4 - }, - { - "plate": 3, - "direction": "right", - "steps": 6 - }, - { - "plate": 5, - "direction": "left", - "steps": 2 - }, - { - "plate": 2, - "direction": "right", - "steps": 2 - }, - { - "plate": 6, - "direction": "right", - "steps": 3 - }, - { - "plate": 4, - "direction": "left", - "steps": 3 - }, - { - "plate": 1, - "direction": "left", - "steps": 2 - } - ], - "timingMs": { - "min": 30.445969000000332, - "median": 30.779860999999073, - "max": 31.124510999998165 - }, - "samplesMs": [ - 30.70966600000247, - 30.779860999999073, - 31.124510999998165, - 30.876908000002004, - 30.445969000000332 - ] - } - }, - "historicalReference": { - "kind": "cached-observation", - "capturedAt": "2026-09-07T06:02:47.149Z", - "A": 9, - "U": 6, - "C": 31, - "plateSwitches": 8, - "commands": [ - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 6, - "direction": "right", - "steps": 6 - }, - { - "plate": 4, - "direction": "left", - "steps": 4 - }, - { - "plate": 3, - "direction": "right", - "steps": 6 - }, - { - "plate": 5, - "direction": "left", - "steps": 2 - }, - { - "plate": 2, - "direction": "right", - "steps": 2 - }, - { - "plate": 6, - "direction": "right", - "steps": 3 - }, - { - "plate": 4, - "direction": "left", - "steps": 3 - }, - { - "plate": 1, - "direction": "left", - "steps": 2 - } - ], - "historicalTimingMs": { - "min": 30.20962199999849, - "median": 31.75662200000079, - "max": 36.70291599999837 - } - } - }, - { - "id": "silas-tavern-storeroom-door", - "title": "Склад таверны - дверь", - "plateCount": 6, - "url": "https://unlockmyloot.com/?lock=aSnYABCIZIICCA", - "code": "aSnYABCIZIICCA", - "expectedActions": 10, - "solvers": { - "matrix-astar": { - "A": 10, - "U": 5, - "C": 31, - "plateSwitches": 9, - "commands": [ - { - "plate": 4, - "direction": "left", - "steps": 2 - }, - { - "plate": 6, - "direction": "right", - "steps": 3 - }, - { - "plate": 2, - "direction": "right", - "steps": 3 - }, - { - "plate": 6, - "direction": "right", - "steps": 3 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 5, - "direction": "left", - "steps": 3 - }, - { - "plate": 6, - "direction": "right", - "steps": 3 - }, - { - "plate": 2, - "direction": "right", - "steps": 4 - }, - { - "plate": 6, - "direction": "right", - "steps": 4 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - } - ], - "timingMs": { - "min": 0.17005600000265986, - "median": 0.17566400000214344, - "max": 0.19170800000210875 - }, - "samplesMs": [ - 0.19170800000210875, - 0.17694699999992736, - 0.17005600000265986, - 0.17566400000214344, - 0.17566400000214344 - ] - }, - "bfs": { - "A": 10, - "U": 5, - "C": 31, - "plateSwitches": 9, - "commands": [ - { - "plate": 4, - "direction": "left", - "steps": 2 - }, - { - "plate": 6, - "direction": "right", - "steps": 3 - }, - { - "plate": 2, - "direction": "right", - "steps": 3 - }, - { - "plate": 6, - "direction": "right", - "steps": 3 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 5, - "direction": "left", - "steps": 3 - }, - { - "plate": 6, - "direction": "right", - "steps": 3 - }, - { - "plate": 2, - "direction": "right", - "steps": 4 - }, - { - "plate": 6, - "direction": "right", - "steps": 4 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - } - ], - "timingMs": { - "min": 13.175473999999667, - "median": 13.43728800000099, - "max": 14.941330999998172 - }, - "samplesMs": [ - 13.467813000002934, - 13.43728800000099, - 14.941330999998172, - 13.175473999999667, - 13.220671999999468 - ] - }, - "baseline": { - "A": 10, - "U": 5, - "C": 31, - "plateSwitches": 9, - "commands": [ - { - "plate": 4, - "direction": "left", - "steps": 2 - }, - { - "plate": 6, - "direction": "right", - "steps": 3 - }, - { - "plate": 2, - "direction": "right", - "steps": 3 - }, - { - "plate": 6, - "direction": "right", - "steps": 3 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 5, - "direction": "left", - "steps": 3 - }, - { - "plate": 6, - "direction": "right", - "steps": 3 - }, - { - "plate": 2, - "direction": "right", - "steps": 4 - }, - { - "plate": 6, - "direction": "right", - "steps": 4 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - } - ], - "timingMs": { - "min": 75.4616700000006, - "median": 81.03830999999991, - "max": 83.86942999999883 - }, - "samplesMs": [ - 75.4616700000006, - 78.55857900000046, - 81.0876350000035, - 81.03830999999991, - 83.86942999999883 - ] - }, - "reference": { - "A": 10, - "U": 5, - "C": 31, - "plateSwitches": 9, - "commands": [ - { - "plate": 4, - "direction": "left", - "steps": 2 - }, - { - "plate": 6, - "direction": "right", - "steps": 3 - }, - { - "plate": 2, - "direction": "right", - "steps": 3 - }, - { - "plate": 6, - "direction": "right", - "steps": 3 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 6, - "direction": "right", - "steps": 3 - }, - { - "plate": 5, - "direction": "left", - "steps": 3 - }, - { - "plate": 2, - "direction": "right", - "steps": 4 - }, - { - "plate": 6, - "direction": "right", - "steps": 4 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - } - ], - "timingMs": { - "min": 22.19480399999884, - "median": 22.875538000000233, - "max": 24.6016950000012 - }, - "samplesMs": [ - 22.19480399999884, - 22.24881599999935, - 24.6016950000012, - 22.875538000000233, - 23.891546999999264 - ] - } - }, - "historicalReference": { - "kind": "cached-observation", - "capturedAt": "2026-09-07T06:02:47.149Z", - "A": 10, - "U": 5, - "C": 31, - "plateSwitches": 9, - "commands": [ - { - "plate": 4, - "direction": "left", - "steps": 2 - }, - { - "plate": 6, - "direction": "right", - "steps": 3 - }, - { - "plate": 2, - "direction": "right", - "steps": 3 - }, - { - "plate": 6, - "direction": "right", - "steps": 3 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 6, - "direction": "right", - "steps": 3 - }, - { - "plate": 5, - "direction": "left", - "steps": 3 - }, - { - "plate": 2, - "direction": "right", - "steps": 4 - }, - { - "plate": 6, - "direction": "right", - "steps": 4 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - } - ], - "historicalTimingMs": { - "min": 22.321948999997403, - "median": 23.63271099999838, - "max": 27.04742599999736 - } - } - }, - { - "id": "jan-pond-house-chest", - "title": "Сундук в домике напротив Джана", - "plateCount": 6, - "url": "https://unlockmyloot.com/?lock=aDAIAEhAAAQaAA", - "code": "aDAIAEhAAAQaAA", - "expectedActions": 8, - "solvers": { - "matrix-astar": { - "A": 8, - "U": 6, - "C": 33, - "plateSwitches": 7, - "commands": [ - { - "plate": 1, - "direction": "right", - "steps": 4 - }, - { - "plate": 3, - "direction": "right", - "steps": 6 - }, - { - "plate": 4, - "direction": "left", - "steps": 4 - }, - { - "plate": 5, - "direction": "left", - "steps": 3 - }, - { - "plate": 6, - "direction": "right", - "steps": 3 - }, - { - "plate": 4, - "direction": "left", - "steps": 5 - }, - { - "plate": 6, - "direction": "right", - "steps": 5 - }, - { - "plate": 2, - "direction": "left", - "steps": 3 - } - ], - "timingMs": { - "min": 0.0985690000015893, - "median": 0.10396600000240142, - "max": 0.10593000000153552 - }, - "samplesMs": [ - 0.10396600000240142, - 0.10593000000153552, - 0.0985690000015893, - 0.10307499999908032, - 0.10507800000050338 - ] - }, - "bfs": { - "A": 8, - "U": 6, - "C": 33, - "plateSwitches": 7, - "commands": [ - { - "plate": 1, - "direction": "right", - "steps": 4 - }, - { - "plate": 3, - "direction": "right", - "steps": 6 - }, - { - "plate": 4, - "direction": "left", - "steps": 4 - }, - { - "plate": 5, - "direction": "left", - "steps": 3 - }, - { - "plate": 6, - "direction": "right", - "steps": 3 - }, - { - "plate": 4, - "direction": "left", - "steps": 5 - }, - { - "plate": 6, - "direction": "right", - "steps": 5 - }, - { - "plate": 2, - "direction": "left", - "steps": 3 - } - ], - "timingMs": { - "min": 6.9588110000004235, - "median": 7.15661800000089, - "max": 8.019225999996706 - }, - "samplesMs": [ - 6.9588110000004235, - 7.351751000001968, - 7.15661800000089, - 8.019225999996706, - 7.065991999999824 - ] - }, - "baseline": { - "A": 8, - "U": 6, - "C": 33, - "plateSwitches": 7, - "commands": [ - { - "plate": 1, - "direction": "right", - "steps": 4 - }, - { - "plate": 3, - "direction": "right", - "steps": 6 - }, - { - "plate": 4, - "direction": "left", - "steps": 4 - }, - { - "plate": 5, - "direction": "left", - "steps": 3 - }, - { - "plate": 6, - "direction": "right", - "steps": 3 - }, - { - "plate": 4, - "direction": "left", - "steps": 5 - }, - { - "plate": 6, - "direction": "right", - "steps": 5 - }, - { - "plate": 2, - "direction": "left", - "steps": 3 - } - ], - "timingMs": { - "min": 44.52101699999912, - "median": 45.8115779999971, - "max": 47.468220999999176 - }, - "samplesMs": [ - 45.8115779999971, - 45.762574999997014, - 44.52101699999912, - 47.468220999999176, - 46.772163999998156 - ] - }, - "reference": { - "A": 8, - "U": 6, - "C": 33, - "plateSwitches": 7, - "commands": [ - { - "plate": 4, - "direction": "left", - "steps": 4 - }, - { - "plate": 6, - "direction": "right", - "steps": 3 - }, - { - "plate": 3, - "direction": "right", - "steps": 6 - }, - { - "plate": 5, - "direction": "left", - "steps": 3 - }, - { - "plate": 4, - "direction": "left", - "steps": 5 - }, - { - "plate": 6, - "direction": "right", - "steps": 5 - }, - { - "plate": 1, - "direction": "right", - "steps": 4 - }, - { - "plate": 2, - "direction": "left", - "steps": 3 - } - ], - "timingMs": { - "min": 47.22482500000115, - "median": 47.61862800000017, - "max": 48.63067900000169 - }, - "samplesMs": [ - 47.38332300000184, - 47.22482500000115, - 47.61862800000017, - 47.89738700000089, - 48.63067900000169 - ] - } - }, - "historicalReference": { - "kind": "cached-observation", - "capturedAt": "2026-09-07T06:02:47.149Z", - "A": 8, - "U": 6, - "C": 33, - "plateSwitches": 7, - "commands": [ - { - "plate": 4, - "direction": "left", - "steps": 4 - }, - { - "plate": 6, - "direction": "right", - "steps": 3 - }, - { - "plate": 3, - "direction": "right", - "steps": 6 - }, - { - "plate": 5, - "direction": "left", - "steps": 3 - }, - { - "plate": 4, - "direction": "left", - "steps": 5 - }, - { - "plate": 6, - "direction": "right", - "steps": 5 - }, - { - "plate": 1, - "direction": "right", - "steps": 4 - }, - { - "plate": 2, - "direction": "left", - "steps": 3 - } - ], - "historicalTimingMs": { - "min": 48.03793500000029, - "median": 48.69068799999877, - "max": 50.139368000000104 - } - } - }, - { - "id": "graham-chest", - "title": "Сундук Грэхэма", - "plateCount": 6, - "url": "https://unlockmyloot.com/locks/graham-chest/", - "code": "bE0IBAAQkBCAEg", - "expectedActions": 10, - "solvers": { - "matrix-astar": { - "A": 10, - "U": 6, - "C": 40, - "plateSwitches": 9, - "commands": [ - { - "plate": 2, - "direction": "left", - "steps": 5 - }, - { - "plate": 5, - "direction": "left", - "steps": 6 - }, - { - "plate": 1, - "direction": "right", - "steps": 5 - }, - { - "plate": 2, - "direction": "left", - "steps": 6 - }, - { - "plate": 5, - "direction": "left", - "steps": 5 - }, - { - "plate": 2, - "direction": "left", - "steps": 5 - }, - { - "plate": 3, - "direction": "left", - "steps": 2 - }, - { - "plate": 4, - "direction": "right", - "steps": 3 - }, - { - "plate": 5, - "direction": "left", - "steps": 2 - }, - { - "plate": 6, - "direction": "right", - "steps": 1 - } - ], - "timingMs": { - "min": 0.10560800000166637, - "median": 0.10942399999839836, - "max": 0.13842899999872316 - }, - "samplesMs": [ - 0.13842899999872316, - 0.10942399999839836, - 0.11032600000180537, - 0.10560800000166637, - 0.10712099999727798 - ] - }, - "bfs": { - "A": 10, - "U": 6, - "C": 40, - "plateSwitches": 9, - "commands": [ - { - "plate": 2, - "direction": "left", - "steps": 5 - }, - { - "plate": 5, - "direction": "left", - "steps": 6 - }, - { - "plate": 1, - "direction": "right", - "steps": 5 - }, - { - "plate": 2, - "direction": "left", - "steps": 6 - }, - { - "plate": 5, - "direction": "left", - "steps": 5 - }, - { - "plate": 2, - "direction": "left", - "steps": 5 - }, - { - "plate": 3, - "direction": "left", - "steps": 2 - }, - { - "plate": 4, - "direction": "right", - "steps": 3 - }, - { - "plate": 5, - "direction": "left", - "steps": 2 - }, - { - "plate": 6, - "direction": "right", - "steps": 1 - } - ], - "timingMs": { - "min": 16.359714000001986, - "median": 17.14464399999997, - "max": 18.1319599999988 - }, - "samplesMs": [ - 16.359714000001986, - 18.1319599999988, - 17.14464399999997, - 17.511607000000367, - 16.962910999998712 - ] - }, - "baseline": { - "A": 10, - "U": 6, - "C": 40, - "plateSwitches": 9, - "commands": [ - { - "plate": 2, - "direction": "left", - "steps": 5 - }, - { - "plate": 5, - "direction": "left", - "steps": 6 - }, - { - "plate": 1, - "direction": "right", - "steps": 5 - }, - { - "plate": 2, - "direction": "left", - "steps": 6 - }, - { - "plate": 5, - "direction": "left", - "steps": 5 - }, - { - "plate": 2, - "direction": "left", - "steps": 5 - }, - { - "plate": 3, - "direction": "left", - "steps": 2 - }, - { - "plate": 4, - "direction": "right", - "steps": 3 - }, - { - "plate": 5, - "direction": "left", - "steps": 2 - }, - { - "plate": 6, - "direction": "right", - "steps": 1 - } - ], - "timingMs": { - "min": 99.46993599999769, - "median": 101.19952900000135, - "max": 103.5627940000013 - }, - "samplesMs": [ - 101.70862500000294, - 101.19952900000135, - 100.83769400000165, - 103.5627940000013, - 99.46993599999769 - ] - }, - "reference": { - "A": 10, - "U": 6, - "C": 40, - "plateSwitches": 9, - "commands": [ - { - "plate": 6, - "direction": "right", - "steps": 1 - }, - { - "plate": 2, - "direction": "left", - "steps": 6 - }, - { - "plate": 5, - "direction": "left", - "steps": 6 - }, - { - "plate": 2, - "direction": "left", - "steps": 5 - }, - { - "plate": 1, - "direction": "right", - "steps": 5 - }, - { - "plate": 5, - "direction": "left", - "steps": 5 - }, - { - "plate": 4, - "direction": "right", - "steps": 3 - }, - { - "plate": 2, - "direction": "left", - "steps": 5 - }, - { - "plate": 5, - "direction": "left", - "steps": 2 - }, - { - "plate": 3, - "direction": "left", - "steps": 2 - } - ], - "timingMs": { - "min": 30.6395279999997, - "median": 33.14164299999902, - "max": 36.96395399999892 - }, - "samplesMs": [ - 33.14164299999902, - 33.58286699999735, - 31.785358000000997, - 30.6395279999997, - 36.96395399999892 - ] - } - }, - "historicalReference": { - "kind": "cached-observation", - "capturedAt": "2026-09-07T06:02:47.149Z", - "A": 10, - "U": 6, - "C": 40, - "plateSwitches": 9, - "commands": [ - { - "plate": 6, - "direction": "right", - "steps": 1 - }, - { - "plate": 2, - "direction": "left", - "steps": 6 - }, - { - "plate": 5, - "direction": "left", - "steps": 6 - }, - { - "plate": 2, - "direction": "left", - "steps": 5 - }, - { - "plate": 1, - "direction": "right", - "steps": 5 - }, - { - "plate": 5, - "direction": "left", - "steps": 5 - }, - { - "plate": 4, - "direction": "right", - "steps": 3 - }, - { - "plate": 2, - "direction": "left", - "steps": 5 - }, - { - "plate": 5, - "direction": "left", - "steps": 2 - }, - { - "plate": 3, - "direction": "left", - "steps": 2 - } - ], - "historicalTimingMs": { - "min": 32.42070899999817, - "median": 33.52989900000102, - "max": 36.52016999999978 - } - } - }, - { - "id": "gravo-chest", - "title": "Сундук Граво", - "plateCount": 5, - "url": "https://unlockmyloot.com/?lock=SkCAAjAhAg", - "code": "SkCAAjAhAg", - "expectedActions": 8, - "solvers": { - "matrix-astar": { - "A": 8, - "U": 5, - "C": 27, - "plateSwitches": 7, - "commands": [ - { - "plate": 1, - "direction": "right", - "steps": 5 - }, - { - "plate": 5, - "direction": "right", - "steps": 1 - }, - { - "plate": 2, - "direction": "left", - "steps": 3 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 2, - "direction": "left", - "steps": 3 - }, - { - "plate": 4, - "direction": "left", - "steps": 3 - }, - { - "plate": 5, - "direction": "right", - "steps": 6 - }, - { - "plate": 2, - "direction": "left", - "steps": 3 - } - ], - "timingMs": { - "min": 0.06116199999814853, - "median": 0.07165800000075251, - "max": 0.11318000000028405 - }, - "samplesMs": [ - 0.06975500000044121, - 0.11318000000028405, - 0.07165800000075251, - 0.08374600000024657, - 0.06116199999814853 - ] - }, - "bfs": { - "A": 8, - "U": 5, - "C": 27, - "plateSwitches": 7, - "commands": [ - { - "plate": 1, - "direction": "right", - "steps": 5 - }, - { - "plate": 5, - "direction": "right", - "steps": 1 - }, - { - "plate": 2, - "direction": "left", - "steps": 3 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 2, - "direction": "left", - "steps": 3 - }, - { - "plate": 4, - "direction": "left", - "steps": 3 - }, - { - "plate": 5, - "direction": "right", - "steps": 6 - }, - { - "plate": 2, - "direction": "left", - "steps": 3 - } - ], - "timingMs": { - "min": 1.0047220000014931, - "median": 1.1070859999999811, - "max": 1.1677870000021358 - }, - "samplesMs": [ - 1.1070859999999811, - 1.1483979999975418, - 1.1677870000021358, - 1.0047220000014931, - 1.046704000000318 - ] - }, - "baseline": { - "A": 8, - "U": 5, - "C": 27, - "plateSwitches": 7, - "commands": [ - { - "plate": 1, - "direction": "right", - "steps": 5 - }, - { - "plate": 5, - "direction": "right", - "steps": 1 - }, - { - "plate": 2, - "direction": "left", - "steps": 3 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 2, - "direction": "left", - "steps": 3 - }, - { - "plate": 4, - "direction": "left", - "steps": 3 - }, - { - "plate": 5, - "direction": "right", - "steps": 6 - }, - { - "plate": 2, - "direction": "left", - "steps": 3 - } - ], - "timingMs": { - "min": 5.29304399999819, - "median": 5.463670000001002, - "max": 5.936682000003202 - }, - "samplesMs": [ - 5.463260000000446, - 5.463670000001002, - 5.936682000003202, - 5.29304399999819, - 5.673746000000392 - ] - }, - "reference": { - "A": 8, - "U": 5, - "C": 27, - "plateSwitches": 7, - "commands": [ - { - "plate": 5, - "direction": "right", - "steps": 1 - }, - { - "plate": 2, - "direction": "left", - "steps": 3 - }, - { - "plate": 1, - "direction": "right", - "steps": 5 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 2, - "direction": "left", - "steps": 3 - }, - { - "plate": 5, - "direction": "right", - "steps": 6 - }, - { - "plate": 4, - "direction": "left", - "steps": 3 - }, - { - "plate": 2, - "direction": "left", - "steps": 3 - } - ], - "timingMs": { - "min": 5.050747999997839, - "median": 5.359533000002557, - "max": 5.448867999999493 - }, - "samplesMs": [ - 5.359533000002557, - 5.111549999997806, - 5.448867999999493, - 5.422327999996924, - 5.050747999997839 - ] - } - }, - "historicalReference": { - "kind": "cached-observation", - "capturedAt": "2026-09-07T06:02:47.149Z", - "A": 8, - "U": 5, - "C": 27, - "plateSwitches": 7, - "commands": [ - { - "plate": 5, - "direction": "right", - "steps": 1 - }, - { - "plate": 2, - "direction": "left", - "steps": 3 - }, - { - "plate": 1, - "direction": "right", - "steps": 5 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 2, - "direction": "left", - "steps": 3 - }, - { - "plate": 5, - "direction": "right", - "steps": 6 - }, - { - "plate": 4, - "direction": "left", - "steps": 3 - }, - { - "plate": 2, - "direction": "left", - "steps": 3 - } - ], - "historicalTimingMs": { - "min": 5.467348999998649, - "median": 8.080862999999226, - "max": 8.312551999999414 - } - } - }, - { - "id": "diego-chest", - "title": "Сундук Диего", - "plateCount": 6, - "url": "https://unlockmyloot.com/locks/diego-chest/", - "code": "Yix4ABIAASCSAE", - "expectedActions": 11, - "solvers": { - "matrix-astar": { - "A": 11, - "U": 6, - "C": 40, - "plateSwitches": 10, - "commands": [ - { - "plate": 1, - "direction": "left", - "steps": 2 - }, - { - "plate": 2, - "direction": "left", - "steps": 1 - }, - { - "plate": 3, - "direction": "right", - "steps": 6 - }, - { - "plate": 4, - "direction": "right", - "steps": 1 - }, - { - "plate": 2, - "direction": "left", - "steps": 3 - }, - { - "plate": 5, - "direction": "left", - "steps": 3 - }, - { - "plate": 3, - "direction": "right", - "steps": 6 - }, - { - "plate": 6, - "direction": "left", - "steps": 3 - }, - { - "plate": 2, - "direction": "left", - "steps": 6 - }, - { - "plate": 3, - "direction": "right", - "steps": 6 - }, - { - "plate": 5, - "direction": "left", - "steps": 3 - } - ], - "timingMs": { - "min": 0.1599099999984901, - "median": 0.16224399999919115, - "max": 0.21563400000013644 - }, - "samplesMs": [ - 0.16224399999919115, - 0.16279500000018743, - 0.21563400000013644, - 0.16081199999825913, - 0.1599099999984901 - ] - }, - "bfs": { - "A": 11, - "U": 6, - "C": 40, - "plateSwitches": 10, - "commands": [ - { - "plate": 1, - "direction": "left", - "steps": 2 - }, - { - "plate": 2, - "direction": "left", - "steps": 1 - }, - { - "plate": 3, - "direction": "right", - "steps": 6 - }, - { - "plate": 4, - "direction": "right", - "steps": 1 - }, - { - "plate": 2, - "direction": "left", - "steps": 3 - }, - { - "plate": 5, - "direction": "left", - "steps": 3 - }, - { - "plate": 3, - "direction": "right", - "steps": 6 - }, - { - "plate": 6, - "direction": "left", - "steps": 3 - }, - { - "plate": 2, - "direction": "left", - "steps": 6 - }, - { - "plate": 3, - "direction": "right", - "steps": 6 - }, - { - "plate": 5, - "direction": "left", - "steps": 3 - } - ], - "timingMs": { - "min": 12.245143000000098, - "median": 12.377211999999417, - "max": 13.043719000001147 - }, - "samplesMs": [ - 12.968141000001197, - 12.377211999999417, - 12.245143000000098, - 13.043719000001147, - 12.376984999998967 - ] - }, - "baseline": { - "A": 11, - "U": 6, - "C": 40, - "plateSwitches": 10, - "commands": [ - { - "plate": 1, - "direction": "left", - "steps": 2 - }, - { - "plate": 2, - "direction": "left", - "steps": 1 - }, - { - "plate": 3, - "direction": "right", - "steps": 6 - }, - { - "plate": 4, - "direction": "right", - "steps": 1 - }, - { - "plate": 2, - "direction": "left", - "steps": 3 - }, - { - "plate": 5, - "direction": "left", - "steps": 3 - }, - { - "plate": 3, - "direction": "right", - "steps": 6 - }, - { - "plate": 6, - "direction": "left", - "steps": 3 - }, - { - "plate": 2, - "direction": "left", - "steps": 6 - }, - { - "plate": 3, - "direction": "right", - "steps": 6 - }, - { - "plate": 5, - "direction": "left", - "steps": 3 - } - ], - "timingMs": { - "min": 79.94461199999932, - "median": 82.86884199999986, - "max": 86.73401899999953 - }, - "samplesMs": [ - 80.7599369999989, - 85.23674200000096, - 79.94461199999932, - 86.73401899999953, - 82.86884199999986 - ] - }, - "reference": { - "A": 11, - "U": 6, - "C": 40, - "plateSwitches": 10, - "commands": [ - { - "plate": 2, - "direction": "left", - "steps": 1 - }, - { - "plate": 4, - "direction": "right", - "steps": 1 - }, - { - "plate": 3, - "direction": "right", - "steps": 6 - }, - { - "plate": 2, - "direction": "left", - "steps": 3 - }, - { - "plate": 5, - "direction": "left", - "steps": 3 - }, - { - "plate": 6, - "direction": "left", - "steps": 3 - }, - { - "plate": 3, - "direction": "right", - "steps": 6 - }, - { - "plate": 2, - "direction": "left", - "steps": 6 - }, - { - "plate": 3, - "direction": "right", - "steps": 6 - }, - { - "plate": 5, - "direction": "left", - "steps": 3 - }, - { - "plate": 1, - "direction": "left", - "steps": 2 - } - ], - "timingMs": { - "min": 28.88734299999851, - "median": 29.897691999998642, - "max": 32.130709999997634 - }, - "samplesMs": [ - 30.00152800000069, - 29.897691999998642, - 28.88734299999851, - 32.130709999997634, - 29.73279299999922 - ] - } - }, - "historicalReference": { - "kind": "cached-observation", - "capturedAt": "2026-09-07T06:02:47.149Z", - "A": 11, - "U": 6, - "C": 40, - "plateSwitches": 10, - "commands": [ - { - "plate": 2, - "direction": "left", - "steps": 1 - }, - { - "plate": 4, - "direction": "right", - "steps": 1 - }, - { - "plate": 3, - "direction": "right", - "steps": 6 - }, - { - "plate": 2, - "direction": "left", - "steps": 3 - }, - { - "plate": 5, - "direction": "left", - "steps": 3 - }, - { - "plate": 6, - "direction": "left", - "steps": 3 - }, - { - "plate": 3, - "direction": "right", - "steps": 6 - }, - { - "plate": 2, - "direction": "left", - "steps": 6 - }, - { - "plate": 3, - "direction": "right", - "steps": 6 - }, - { - "plate": 5, - "direction": "left", - "steps": 3 - }, - { - "plate": 1, - "direction": "left", - "steps": 2 - } - ], - "historicalTimingMs": { - "min": 30.864245999997365, - "median": 31.402186000002985, - "max": 48.39241899999979 - } - } - }, - { - "id": "grim-chest", - "title": "Сундук Грима", - "plateCount": 6, - "url": "https://unlockmyloot.com/?lock=aFhwAAABhgAAKY", - "code": "aFhwAAABhgAAKY", - "expectedActions": 8, - "solvers": { - "matrix-astar": { - "A": 8, - "U": 6, - "C": 21, - "plateSwitches": 7, - "commands": [ - { - "plate": 5, - "direction": "right", - "steps": 2 - }, - { - "plate": 4, - "direction": "left", - "steps": 1 - }, - { - "plate": 2, - "direction": "left", - "steps": 6 - }, - { - "plate": 3, - "direction": "right", - "steps": 1 - }, - { - "plate": 1, - "direction": "right", - "steps": 5 - }, - { - "plate": 4, - "direction": "left", - "steps": 4 - }, - { - "plate": 1, - "direction": "right", - "steps": 1 - }, - { - "plate": 6, - "direction": "right", - "steps": 1 - } - ], - "timingMs": { - "min": 0.19919999999910942, - "median": 0.21358099999997648, - "max": 0.5288360000013199 - }, - "samplesMs": [ - 0.5288360000013199, - 0.19919999999910942, - 0.21358099999997648, - 0.21277999999801978, - 0.25489399999787565 - ] - }, - "bfs": { - "A": 8, - "U": 6, - "C": 21, - "plateSwitches": 7, - "commands": [ - { - "plate": 1, - "direction": "right", - "steps": 4 - }, - { - "plate": 2, - "direction": "left", - "steps": 5 - }, - { - "plate": 4, - "direction": "left", - "steps": 5 - }, - { - "plate": 1, - "direction": "right", - "steps": 2 - }, - { - "plate": 2, - "direction": "left", - "steps": 1 - }, - { - "plate": 3, - "direction": "right", - "steps": 1 - }, - { - "plate": 5, - "direction": "right", - "steps": 2 - }, - { - "plate": 6, - "direction": "right", - "steps": 1 - } - ], - "timingMs": { - "min": 16.70064099999945, - "median": 16.907052000002295, - "max": 18.926580999999715 - }, - "samplesMs": [ - 16.907052000002295, - 17.332852999999886, - 18.926580999999715, - 16.881413999999495, - 16.70064099999945 - ] - }, - "baseline": { - "A": 8, - "U": 6, - "C": 21, - "plateSwitches": 7, - "commands": [ - { - "plate": 1, - "direction": "right", - "steps": 4 - }, - { - "plate": 2, - "direction": "left", - "steps": 5 - }, - { - "plate": 4, - "direction": "left", - "steps": 5 - }, - { - "plate": 1, - "direction": "right", - "steps": 2 - }, - { - "plate": 2, - "direction": "left", - "steps": 1 - }, - { - "plate": 3, - "direction": "right", - "steps": 1 - }, - { - "plate": 5, - "direction": "right", - "steps": 2 - }, - { - "plate": 6, - "direction": "right", - "steps": 1 - } - ], - "timingMs": { - "min": 100.21867100000236, - "median": 106.55781000000206, - "max": 116.94362900000124 - }, - "samplesMs": [ - 109.42307100000107, - 116.94362900000124, - 100.68139800000063, - 100.21867100000236, - 106.55781000000206 - ] - }, - "reference": { - "A": 8, - "U": 6, - "C": 21, - "plateSwitches": 7, - "commands": [ - { - "plate": 2, - "direction": "left", - "steps": 4 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - }, - { - "plate": 4, - "direction": "left", - "steps": 5 - }, - { - "plate": 6, - "direction": "right", - "steps": 1 - }, - { - "plate": 5, - "direction": "right", - "steps": 2 - }, - { - "plate": 3, - "direction": "right", - "steps": 1 - }, - { - "plate": 2, - "direction": "left", - "steps": 2 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - } - ], - "timingMs": { - "min": 19.493484999999055, - "median": 20.138954999998532, - "max": 20.343853999998828 - }, - "samplesMs": [ - 20.343853999998828, - 20.32147000000259, - 20.138954999998532, - 20.00796899999841, - 19.493484999999055 - ] - } - }, - "historicalReference": { - "kind": "cached-observation", - "capturedAt": "2026-09-07T06:02:47.149Z", - "A": 8, - "U": 6, - "C": 21, - "plateSwitches": 7, - "commands": [ - { - "plate": 2, - "direction": "left", - "steps": 4 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - }, - { - "plate": 4, - "direction": "left", - "steps": 5 - }, - { - "plate": 6, - "direction": "right", - "steps": 1 - }, - { - "plate": 5, - "direction": "right", - "steps": 2 - }, - { - "plate": 3, - "direction": "right", - "steps": 1 - }, - { - "plate": 2, - "direction": "left", - "steps": 2 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - } - ], - "historicalTimingMs": { - "min": 20.744904999999562, - "median": 21.38251400000081, - "max": 25.32780400000047 - } - } - }, - { - "id": "mercenary-near-grim-chest", - "title": "Сундук наёмника (у Грима)", - "plateCount": 6, - "url": "https://unlockmyloot.com/?lock=ZA4gEWAAAAASAQ", - "code": "ZA4gEWAAAAASAQ", - "expectedActions": 8, - "solvers": { - "matrix-astar": { - "A": 8, - "U": 6, - "C": 25, - "plateSwitches": 7, - "commands": [ - { - "plate": 3, - "direction": "left", - "steps": 3 - }, - { - "plate": 2, - "direction": "left", - "steps": 3 - }, - { - "plate": 4, - "direction": "right", - "steps": 6 - }, - { - "plate": 6, - "direction": "left", - "steps": 6 - }, - { - "plate": 1, - "direction": "right", - "steps": 2 - }, - { - "plate": 4, - "direction": "right", - "steps": 2 - }, - { - "plate": 5, - "direction": "left", - "steps": 2 - }, - { - "plate": 6, - "direction": "left", - "steps": 1 - } - ], - "timingMs": { - "min": 0.09175800000230083, - "median": 0.09823799999867333, - "max": 0.1189090000007127 - }, - "samplesMs": [ - 0.09823799999867333, - 0.1189090000007127, - 0.10301600000093458, - 0.09668500000043423, - 0.09175800000230083 - ] - }, - "bfs": { - "A": 8, - "U": 6, - "C": 25, - "plateSwitches": 7, - "commands": [ - { - "plate": 3, - "direction": "left", - "steps": 3 - }, - { - "plate": 2, - "direction": "left", - "steps": 3 - }, - { - "plate": 4, - "direction": "right", - "steps": 6 - }, - { - "plate": 6, - "direction": "left", - "steps": 6 - }, - { - "plate": 1, - "direction": "right", - "steps": 2 - }, - { - "plate": 4, - "direction": "right", - "steps": 2 - }, - { - "plate": 5, - "direction": "left", - "steps": 2 - }, - { - "plate": 6, - "direction": "left", - "steps": 1 - } - ], - "timingMs": { - "min": 15.047442999999475, - "median": 15.120403000000806, - "max": 15.504441000000952 - }, - "samplesMs": [ - 15.047442999999475, - 15.120403000000806, - 15.259673000000475, - 15.504441000000952, - 15.074404999999388 - ] - }, - "baseline": { - "A": 8, - "U": 6, - "C": 25, - "plateSwitches": 7, - "commands": [ - { - "plate": 3, - "direction": "left", - "steps": 3 - }, - { - "plate": 2, - "direction": "left", - "steps": 3 - }, - { - "plate": 4, - "direction": "right", - "steps": 6 - }, - { - "plate": 6, - "direction": "left", - "steps": 6 - }, - { - "plate": 1, - "direction": "right", - "steps": 2 - }, - { - "plate": 4, - "direction": "right", - "steps": 2 - }, - { - "plate": 5, - "direction": "left", - "steps": 2 - }, - { - "plate": 6, - "direction": "left", - "steps": 1 - } - ], - "timingMs": { - "min": 93.2197699999997, - "median": 96.11985300000015, - "max": 100.79693100000077 - }, - "samplesMs": [ - 94.04025300000285, - 93.2197699999997, - 96.11985300000015, - 97.28438499999902, - 100.79693100000077 - ] - }, - "reference": { - "A": 8, - "U": 6, - "C": 25, - "plateSwitches": 7, - "commands": [ - { - "plate": 4, - "direction": "right", - "steps": 6 - }, - { - "plate": 6, - "direction": "left", - "steps": 6 - }, - { - "plate": 5, - "direction": "left", - "steps": 2 - }, - { - "plate": 4, - "direction": "right", - "steps": 2 - }, - { - "plate": 6, - "direction": "left", - "steps": 1 - }, - { - "plate": 3, - "direction": "left", - "steps": 3 - }, - { - "plate": 2, - "direction": "left", - "steps": 3 - }, - { - "plate": 1, - "direction": "right", - "steps": 2 - } - ], - "timingMs": { - "min": 28.609511999999086, - "median": 29.191116999998485, - "max": 34.457431999999244 - }, - "samplesMs": [ - 29.191116999998485, - 29.199329999999463, - 34.457431999999244, - 28.609511999999086, - 28.962954000002355 - ] - } - }, - "historicalReference": { - "kind": "cached-observation", - "capturedAt": "2026-09-07T06:02:47.149Z", - "A": 8, - "U": 6, - "C": 25, - "plateSwitches": 7, - "commands": [ - { - "plate": 4, - "direction": "right", - "steps": 6 - }, - { - "plate": 6, - "direction": "left", - "steps": 6 - }, - { - "plate": 5, - "direction": "left", - "steps": 2 - }, - { - "plate": 4, - "direction": "right", - "steps": 2 - }, - { - "plate": 6, - "direction": "left", - "steps": 1 - }, - { - "plate": 3, - "direction": "left", - "steps": 3 - }, - { - "plate": 2, - "direction": "left", - "steps": 3 - }, - { - "plate": 1, - "direction": "right", - "steps": 2 - } - ], - "historicalTimingMs": { - "min": 30.25687200000175, - "median": 31.47373399999924, - "max": 35.279522999997425 - } - } - }, - { - "id": "popular-lock", - "title": "Популярный замок", - "plateCount": 5, - "url": "https://unlockmyloot.com/locks/popular-lock/", - "code": "TAmBLEwMAE", - "expectedActions": 13, - "solvers": { - "matrix-astar": { - "A": 13, - "U": 5, - "C": 46, - "plateSwitches": 12, - "commands": [ - { - "plate": 5, - "direction": "right", - "steps": 4 - }, - { - "plate": 3, - "direction": "right", - "steps": 1 - }, - { - "plate": 4, - "direction": "right", - "steps": 2 - }, - { - "plate": 2, - "direction": "right", - "steps": 3 - }, - { - "plate": 5, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "left", - "steps": 6 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 5, - "direction": "right", - "steps": 6 - }, - { - "plate": 2, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "left", - "steps": 3 - }, - { - "plate": 4, - "direction": "right", - "steps": 3 - }, - { - "plate": 5, - "direction": "right", - "steps": 6 - }, - { - "plate": 1, - "direction": "left", - "steps": 3 - } - ], - "timingMs": { - "min": 0.5506289999975706, - "median": 0.5712390000007872, - "max": 0.592822000002343 - }, - "samplesMs": [ - 0.5820249999997031, - 0.592822000002343, - 0.5712390000007872, - 0.5506289999975706, - 0.5536530000026687 - ] - }, - "bfs": { - "A": 13, - "U": 5, - "C": 46, - "plateSwitches": 12, - "commands": [ - { - "plate": 3, - "direction": "right", - "steps": 1 - }, - { - "plate": 4, - "direction": "right", - "steps": 1 - }, - { - "plate": 2, - "direction": "right", - "steps": 2 - }, - { - "plate": 5, - "direction": "right", - "steps": 6 - }, - { - "plate": 1, - "direction": "left", - "steps": 4 - }, - { - "plate": 5, - "direction": "right", - "steps": 4 - }, - { - "plate": 4, - "direction": "right", - "steps": 4 - }, - { - "plate": 1, - "direction": "left", - "steps": 2 - }, - { - "plate": 2, - "direction": "right", - "steps": 4 - }, - { - "plate": 5, - "direction": "right", - "steps": 6 - }, - { - "plate": 1, - "direction": "left", - "steps": 6 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 5, - "direction": "right", - "steps": 3 - } - ], - "timingMs": { - "min": 1.9107960000001185, - "median": 1.9221920000018144, - "max": 1.9523069999995641 - }, - "samplesMs": [ - 1.9221920000018144, - 1.9523069999995641, - 1.932487999998557, - 1.9211009999999078, - 1.9107960000001185 - ] - }, - "baseline": { - "A": 13, - "U": 5, - "C": 46, - "plateSwitches": 12, - "commands": [ - { - "plate": 3, - "direction": "right", - "steps": 1 - }, - { - "plate": 4, - "direction": "right", - "steps": 1 - }, - { - "plate": 2, - "direction": "right", - "steps": 2 - }, - { - "plate": 5, - "direction": "right", - "steps": 6 - }, - { - "plate": 1, - "direction": "left", - "steps": 4 - }, - { - "plate": 5, - "direction": "right", - "steps": 4 - }, - { - "plate": 4, - "direction": "right", - "steps": 4 - }, - { - "plate": 1, - "direction": "left", - "steps": 2 - }, - { - "plate": 2, - "direction": "right", - "steps": 4 - }, - { - "plate": 5, - "direction": "right", - "steps": 6 - }, - { - "plate": 1, - "direction": "left", - "steps": 6 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 5, - "direction": "right", - "steps": 3 - } - ], - "timingMs": { - "min": 9.808650999999372, - "median": 9.924764999999752, - "max": 10.116053000001557 - }, - "samplesMs": [ - 9.808650999999372, - 9.924764999999752, - 10.116053000001557, - 9.954040000000532, - 9.852795999999216 - ] - }, - "reference": { - "A": 13, - "U": 5, - "C": 46, - "plateSwitches": 12, - "commands": [ - { - "plate": 5, - "direction": "right", - "steps": 4 - }, - { - "plate": 4, - "direction": "right", - "steps": 2 - }, - { - "plate": 3, - "direction": "right", - "steps": 1 - }, - { - "plate": 2, - "direction": "right", - "steps": 3 - }, - { - "plate": 5, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "left", - "steps": 6 - }, - { - "plate": 5, - "direction": "right", - "steps": 6 - }, - { - "plate": 4, - "direction": "right", - "steps": 3 - }, - { - "plate": 2, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "left", - "steps": 3 - }, - { - "plate": 5, - "direction": "right", - "steps": 6 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "left", - "steps": 3 - } - ], - "timingMs": { - "min": 5.52619599999889, - "median": 5.561628999999812, - "max": 5.680867999999464 - }, - "samplesMs": [ - 5.567137000001821, - 5.52619599999889, - 5.680867999999464, - 5.561628999999812, - 5.544122999999672 - ] - } - }, - "historicalReference": { - "kind": "cached-observation", - "capturedAt": "2026-09-07T06:02:47.149Z", - "A": 13, - "U": 5, - "C": 46, - "plateSwitches": 12, - "commands": [ - { - "plate": 5, - "direction": "right", - "steps": 4 - }, - { - "plate": 4, - "direction": "right", - "steps": 2 - }, - { - "plate": 3, - "direction": "right", - "steps": 1 - }, - { - "plate": 2, - "direction": "right", - "steps": 3 - }, - { - "plate": 5, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "left", - "steps": 6 - }, - { - "plate": 5, - "direction": "right", - "steps": 6 - }, - { - "plate": 4, - "direction": "right", - "steps": 3 - }, - { - "plate": 2, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "left", - "steps": 3 - }, - { - "plate": 5, - "direction": "right", - "steps": 6 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "left", - "steps": 3 - } - ], - "historicalTimingMs": { - "min": 6.609628999998677, - "median": 8.722789999999804, - "max": 12.860713000001851 - } - } - }, - { - "id": "alberto-mine-hut-chest", - "title": "Сундук в хижине шахты Альберто", - "plateCount": 5, - "url": "https://unlockmyloot.com/locks/alberto-mine-hut-chest/", - "code": "ULWBAQhAKg", - "expectedActions": 9, - "solvers": { - "matrix-astar": { - "A": 9, - "U": 5, - "C": 36, - "plateSwitches": 8, - "commands": [ - { - "plate": 4, - "direction": "right", - "steps": 4 - }, - { - "plate": 2, - "direction": "right", - "steps": 2 - }, - { - "plate": 5, - "direction": "left", - "steps": 6 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - }, - { - "plate": 4, - "direction": "right", - "steps": 6 - }, - { - "plate": 2, - "direction": "right", - "steps": 6 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 4, - "direction": "right", - "steps": 3 - }, - { - "plate": 5, - "direction": "left", - "steps": 3 - } - ], - "timingMs": { - "min": 0.0598100000024715, - "median": 0.065547999998671, - "max": 0.09760699999969802 - }, - "samplesMs": [ - 0.07305999999880441, - 0.065547999998671, - 0.0598100000024715, - 0.06335500000204775, - 0.09760699999969802 - ] - }, - "bfs": { - "A": 9, - "U": 5, - "C": 36, - "plateSwitches": 8, - "commands": [ - { - "plate": 4, - "direction": "right", - "steps": 4 - }, - { - "plate": 2, - "direction": "right", - "steps": 2 - }, - { - "plate": 5, - "direction": "left", - "steps": 6 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - }, - { - "plate": 4, - "direction": "right", - "steps": 6 - }, - { - "plate": 2, - "direction": "right", - "steps": 6 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 4, - "direction": "right", - "steps": 3 - }, - { - "plate": 5, - "direction": "left", - "steps": 3 - } - ], - "timingMs": { - "min": 1.7664679999979853, - "median": 1.77728500000012, - "max": 1.8063279999987572 - }, - "samplesMs": [ - 1.7774649999992107, - 1.7723970000006375, - 1.7664679999979853, - 1.77728500000012, - 1.8063279999987572 - ] - }, - "baseline": { - "A": 9, - "U": 5, - "C": 36, - "plateSwitches": 8, - "commands": [ - { - "plate": 4, - "direction": "right", - "steps": 4 - }, - { - "plate": 2, - "direction": "right", - "steps": 2 - }, - { - "plate": 5, - "direction": "left", - "steps": 6 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - }, - { - "plate": 4, - "direction": "right", - "steps": 6 - }, - { - "plate": 2, - "direction": "right", - "steps": 6 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 4, - "direction": "right", - "steps": 3 - }, - { - "plate": 5, - "direction": "left", - "steps": 3 - } - ], - "timingMs": { - "min": 8.646631999999954, - "median": 9.241656999998668, - "max": 10.841904999997496 - }, - "samplesMs": [ - 10.841904999997496, - 8.646631999999954, - 9.251651999999012, - 8.938310000001366, - 9.241656999998668 - ] - }, - "reference": { - "A": 9, - "U": 5, - "C": 36, - "plateSwitches": 8, - "commands": [ - { - "plate": 4, - "direction": "right", - "steps": 4 - }, - { - "plate": 5, - "direction": "left", - "steps": 3 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 2, - "direction": "right", - "steps": 5 - }, - { - "plate": 4, - "direction": "right", - "steps": 6 - }, - { - "plate": 5, - "direction": "left", - "steps": 6 - }, - { - "plate": 2, - "direction": "right", - "steps": 3 - }, - { - "plate": 4, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - } - ], - "timingMs": { - "min": 5.200595999998768, - "median": 5.284802000001946, - "max": 5.329430000001594 - }, - "samplesMs": [ - 5.200595999998768, - 5.329430000001594, - 5.2918529999988095, - 5.284802000001946, - 5.225623999998788 - ] - } - }, - "historicalReference": { - "kind": "cached-observation", - "capturedAt": "2026-09-07T06:02:47.149Z", - "A": 9, - "U": 5, - "C": 36, - "plateSwitches": 8, - "commands": [ - { - "plate": 4, - "direction": "right", - "steps": 4 - }, - { - "plate": 5, - "direction": "left", - "steps": 3 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 2, - "direction": "right", - "steps": 5 - }, - { - "plate": 4, - "direction": "right", - "steps": 6 - }, - { - "plate": 5, - "direction": "left", - "steps": 6 - }, - { - "plate": 2, - "direction": "right", - "steps": 3 - }, - { - "plate": 4, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - } - ], - "historicalTimingMs": { - "min": 5.769542999998521, - "median": 6.056866999999329, - "max": 6.331880999998248 - } - } - }, - { - "id": "alberto-mine-upstairs-left-chest", - "title": "Левый сундук наверху в шахте Альберто", - "plateCount": 4, - "url": "https://unlockmyloot.com/locks/alberto-mine-upstairs-left-chest/", - "code": "MEAIAho", - "expectedActions": 4, - "solvers": { - "matrix-astar": { - "A": 4, - "U": 3, - "C": 20, - "plateSwitches": 3, - "commands": [ - { - "plate": 2, - "direction": "left", - "steps": 5 - }, - { - "plate": 3, - "direction": "left", - "steps": 6 - }, - { - "plate": 2, - "direction": "left", - "steps": 6 - }, - { - "plate": 4, - "direction": "left", - "steps": 3 - } - ], - "timingMs": { - "min": 0.015703999997640494, - "median": 0.018407999999908498, - "max": 0.030525999998644693 - }, - "samplesMs": [ - 0.018407999999908498, - 0.019089000001258682, - 0.015703999997640494, - 0.030525999998644693, - 0.01837800000066636 - ] - }, - "bfs": { - "A": 4, - "U": 3, - "C": 20, - "plateSwitches": 3, - "commands": [ - { - "plate": 2, - "direction": "left", - "steps": 5 - }, - { - "plate": 3, - "direction": "left", - "steps": 6 - }, - { - "plate": 2, - "direction": "left", - "steps": 6 - }, - { - "plate": 4, - "direction": "left", - "steps": 3 - } - ], - "timingMs": { - "min": 0.013030000001890585, - "median": 0.014802000001509441, - "max": 0.015342999999120366 - }, - "samplesMs": [ - 0.015342999999120366, - 0.01387099999919883, - 0.013030000001890585, - 0.014802000001509441, - 0.014901999998983229 - ] - }, - "baseline": { - "A": 4, - "U": 3, - "C": 20, - "plateSwitches": 3, - "commands": [ - { - "plate": 2, - "direction": "left", - "steps": 5 - }, - { - "plate": 3, - "direction": "left", - "steps": 6 - }, - { - "plate": 2, - "direction": "left", - "steps": 6 - }, - { - "plate": 4, - "direction": "left", - "steps": 3 - } - ], - "timingMs": { - "min": 0.034911999999167165, - "median": 0.038548000000446336, - "max": 0.04640999999901396 - }, - "samplesMs": [ - 0.039508999998361105, - 0.038548000000446336, - 0.034911999999167165, - 0.04640999999901396, - 0.037846999999601394 - ] - }, - "reference": { - "A": 4, - "U": 3, - "C": 20, - "plateSwitches": 3, - "commands": [ - { - "plate": 2, - "direction": "left", - "steps": 5 - }, - { - "plate": 3, - "direction": "left", - "steps": 6 - }, - { - "plate": 2, - "direction": "left", - "steps": 6 - }, - { - "plate": 4, - "direction": "left", - "steps": 3 - } - ], - "timingMs": { - "min": 0.1981679999989865, - "median": 0.2100959999988845, - "max": 0.23570499999914318 - }, - "samplesMs": [ - 0.2100959999988845, - 0.2263409999977739, - 0.1981679999989865, - 0.23570499999914318, - 0.20336600000155158 - ] - } - }, - "historicalReference": { - "kind": "cached-observation", - "capturedAt": "2026-09-07T06:02:47.149Z", - "A": 4, - "U": 3, - "C": 20, - "plateSwitches": 3, - "commands": [ - { - "plate": 2, - "direction": "left", - "steps": 5 - }, - { - "plate": 3, - "direction": "left", - "steps": 6 - }, - { - "plate": 2, - "direction": "left", - "steps": 6 - }, - { - "plate": 4, - "direction": "left", - "steps": 3 - } - ], - "historicalTimingMs": { - "min": 0.34879500000170083, - "median": 0.37524499999926775, - "max": 0.3974180000004708 - } - } - }, - { - "id": "alberto-mine-upstairs-right-chest", - "title": "Правый сундук наверху в шахте Альберто", - "plateCount": 5, - "url": "https://unlockmyloot.com/locks/alberto-mine-upstairs-right-chest/", - "code": "UZ1wAFAKxU", - "expectedActions": 5, - "solvers": { - "matrix-astar": { - "A": 5, - "U": 5, - "C": 13, - "plateSwitches": 4, - "commands": [ - { - "plate": 1, - "direction": "left", - "steps": 3 - }, - { - "plate": 2, - "direction": "left", - "steps": 2 - }, - { - "plate": 3, - "direction": "left", - "steps": 2 - }, - { - "plate": 5, - "direction": "left", - "steps": 4 - }, - { - "plate": 4, - "direction": "left", - "steps": 2 - } - ], - "timingMs": { - "min": 0.026289000001270324, - "median": 0.032729000002291286, - "max": 0.05053599999882863 - }, - "samplesMs": [ - 0.05053599999882863, - 0.029293999999936204, - 0.03702599999814993, - 0.032729000002291286, - 0.026289000001270324 - ] - }, - "bfs": { - "A": 5, - "U": 5, - "C": 13, - "plateSwitches": 4, - "commands": [ - { - "plate": 1, - "direction": "left", - "steps": 3 - }, - { - "plate": 2, - "direction": "left", - "steps": 2 - }, - { - "plate": 3, - "direction": "left", - "steps": 2 - }, - { - "plate": 5, - "direction": "left", - "steps": 4 - }, - { - "plate": 4, - "direction": "left", - "steps": 2 - } - ], - "timingMs": { - "min": 0.4969170000003942, - "median": 0.5107790000001842, - "max": 0.644560000000638 - }, - "samplesMs": [ - 0.5082340000008116, - 0.4969170000003942, - 0.644560000000638, - 0.561706000000413, - 0.5107790000001842 - ] - }, - "baseline": { - "A": 5, - "U": 5, - "C": 13, - "plateSwitches": 4, - "commands": [ - { - "plate": 1, - "direction": "left", - "steps": 3 - }, - { - "plate": 2, - "direction": "left", - "steps": 2 - }, - { - "plate": 3, - "direction": "left", - "steps": 2 - }, - { - "plate": 5, - "direction": "left", - "steps": 4 - }, - { - "plate": 4, - "direction": "left", - "steps": 2 - } - ], - "timingMs": { - "min": 2.1250879999970493, - "median": 2.144357000001037, - "max": 2.69321299999865 - }, - "samplesMs": [ - 2.144357000001037, - 2.69321299999865, - 2.125657999997202, - 2.1250879999970493, - 2.1700160000000324 - ] - }, - "reference": { - "A": 5, - "U": 5, - "C": 13, - "plateSwitches": 4, - "commands": [ - { - "plate": 1, - "direction": "left", - "steps": 3 - }, - { - "plate": 3, - "direction": "left", - "steps": 2 - }, - { - "plate": 2, - "direction": "left", - "steps": 2 - }, - { - "plate": 5, - "direction": "left", - "steps": 4 - }, - { - "plate": 4, - "direction": "left", - "steps": 2 - } - ], - "timingMs": { - "min": 1.5175940000008268, - "median": 1.549391000000469, - "max": 1.5679799999998068 - }, - "samplesMs": [ - 1.549391000000469, - 1.561029000000417, - 1.5679799999998068, - 1.5377640000006068, - 1.5175940000008268 - ] - } - }, - "historicalReference": { - "kind": "cached-observation", - "capturedAt": "2026-09-07T06:02:47.149Z", - "A": 5, - "U": 5, - "C": 13, - "plateSwitches": 4, - "commands": [ - { - "plate": 1, - "direction": "left", - "steps": 3 - }, - { - "plate": 3, - "direction": "left", - "steps": 2 - }, - { - "plate": 2, - "direction": "left", - "steps": 2 - }, - { - "plate": 5, - "direction": "left", - "steps": 4 - }, - { - "plate": 4, - "direction": "left", - "steps": 2 - } - ], - "historicalTimingMs": { - "min": 1.5827119999994466, - "median": 1.7594979999994393, - "max": 1.899319000000105 - } - } - }, - { - "id": "cavalorn-storeroom-door", - "title": "Дверь на склад охотника Кавалорна", - "plateCount": 6, - "url": "https://unlockmyloot.com/locks/cavalorn-storeroom-door/", - "code": "cuaSQAAQgoQJQA", - "expectedActions": 11, - "solvers": { - "matrix-astar": { - "A": 11, - "U": 6, - "C": 41, - "plateSwitches": 10, - "commands": [ - { - "plate": 4, - "direction": "right", - "steps": 2 - }, - { - "plate": 6, - "direction": "left", - "steps": 4 - }, - { - "plate": 1, - "direction": "right", - "steps": 1 - }, - { - "plate": 2, - "direction": "right", - "steps": 6 - }, - { - "plate": 1, - "direction": "right", - "steps": 4 - }, - { - "plate": 4, - "direction": "right", - "steps": 2 - }, - { - "plate": 2, - "direction": "right", - "steps": 6 - }, - { - "plate": 5, - "direction": "right", - "steps": 4 - }, - { - "plate": 6, - "direction": "left", - "steps": 6 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 4, - "direction": "right", - "steps": 3 - } - ], - "timingMs": { - "min": 0.23629499999879044, - "median": 0.2387090000011085, - "max": 0.24234400000204914 - }, - "samplesMs": [ - 0.23906999999962864, - 0.24234400000204914, - 0.23629499999879044, - 0.2387090000011085, - 0.2364159999997355 - ] - }, - "bfs": { - "A": 11, - "U": 6, - "C": 41, - "plateSwitches": 10, - "commands": [ - { - "plate": 1, - "direction": "right", - "steps": 1 - }, - { - "plate": 4, - "direction": "right", - "steps": 2 - }, - { - "plate": 2, - "direction": "right", - "steps": 6 - }, - { - "plate": 6, - "direction": "left", - "steps": 4 - }, - { - "plate": 1, - "direction": "right", - "steps": 4 - }, - { - "plate": 4, - "direction": "right", - "steps": 2 - }, - { - "plate": 2, - "direction": "right", - "steps": 6 - }, - { - "plate": 5, - "direction": "right", - "steps": 4 - }, - { - "plate": 6, - "direction": "left", - "steps": 6 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 4, - "direction": "right", - "steps": 3 - } - ], - "timingMs": { - "min": 13.715700000000652, - "median": 13.902150000001711, - "max": 14.243180000001303 - }, - "samplesMs": [ - 13.715700000000652, - 13.902150000001711, - 14.200926000001346, - 14.243180000001303, - 13.861504999997123 - ] - }, - "baseline": { - "A": 11, - "U": 6, - "C": 41, - "plateSwitches": 10, - "commands": [ - { - "plate": 1, - "direction": "right", - "steps": 1 - }, - { - "plate": 4, - "direction": "right", - "steps": 2 - }, - { - "plate": 2, - "direction": "right", - "steps": 6 - }, - { - "plate": 6, - "direction": "left", - "steps": 4 - }, - { - "plate": 1, - "direction": "right", - "steps": 4 - }, - { - "plate": 4, - "direction": "right", - "steps": 2 - }, - { - "plate": 2, - "direction": "right", - "steps": 6 - }, - { - "plate": 5, - "direction": "right", - "steps": 4 - }, - { - "plate": 6, - "direction": "left", - "steps": 6 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 4, - "direction": "right", - "steps": 3 - } - ], - "timingMs": { - "min": 79.4916000000012, - "median": 81.64077299999917, - "max": 90.80175600000075 - }, - "samplesMs": [ - 79.4916000000012, - 82.5692220000019, - 81.08390599999984, - 90.80175600000075, - 81.64077299999917 - ] - }, - "reference": { - "A": 11, - "U": 6, - "C": 41, - "plateSwitches": 10, - "commands": [ - { - "plate": 4, - "direction": "right", - "steps": 1 - }, - { - "plate": 5, - "direction": "right", - "steps": 1 - }, - { - "plate": 6, - "direction": "left", - "steps": 4 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "right", - "steps": 2 - }, - { - "plate": 2, - "direction": "right", - "steps": 6 - }, - { - "plate": 4, - "direction": "right", - "steps": 6 - }, - { - "plate": 6, - "direction": "left", - "steps": 6 - }, - { - "plate": 5, - "direction": "right", - "steps": 3 - }, - { - "plate": 2, - "direction": "right", - "steps": 6 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - } - ], - "timingMs": { - "min": 40.194054999999935, - "median": 40.40897699999914, - "max": 42.25605799999903 - }, - "samplesMs": [ - 42.25605799999903, - 40.194054999999935, - 41.53761099999974, - 40.40897699999914, - 40.382708000001 - ] - } - }, - "historicalReference": { - "kind": "cached-observation", - "capturedAt": "2026-09-07T06:02:47.149Z", - "A": 11, - "U": 6, - "C": 41, - "plateSwitches": 10, - "commands": [ - { - "plate": 4, - "direction": "right", - "steps": 1 - }, - { - "plate": 5, - "direction": "right", - "steps": 1 - }, - { - "plate": 6, - "direction": "left", - "steps": 4 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "right", - "steps": 2 - }, - { - "plate": 2, - "direction": "right", - "steps": 6 - }, - { - "plate": 4, - "direction": "right", - "steps": 6 - }, - { - "plate": 6, - "direction": "left", - "steps": 6 - }, - { - "plate": 5, - "direction": "right", - "steps": 3 - }, - { - "plate": 2, - "direction": "right", - "steps": 6 - }, - { - "plate": 1, - "direction": "right", - "steps": 3 - } - ], - "historicalTimingMs": { - "min": 40.57944999999745, - "median": 42.616525000001275, - "max": 43.05461400000058 - } - } - }, - { - "id": "old-to-new-camp-bandits-chest", - "title": "Сундук у разбойников на пути в Новый лагерь", - "plateCount": 6, - "url": "https://unlockmyloot.com/locks/old-to-new-camp-bandits-chest/", - "code": "cOEMSCgBGIQAAA", - "expectedActions": 10, - "solvers": { - "matrix-astar": { - "A": 10, - "U": 5, - "C": 33, - "plateSwitches": 9, - "commands": [ - { - "plate": 5, - "direction": "left", - "steps": 6 - }, - { - "plate": 6, - "direction": "right", - "steps": 2 - }, - { - "plate": 3, - "direction": "right", - "steps": 1 - }, - { - "plate": 1, - "direction": "left", - "steps": 3 - }, - { - "plate": 4, - "direction": "left", - "steps": 3 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "left", - "steps": 3 - }, - { - "plate": 4, - "direction": "left", - "steps": 3 - }, - { - "plate": 5, - "direction": "left", - "steps": 6 - }, - { - "plate": 1, - "direction": "left", - "steps": 3 - } - ], - "timingMs": { - "min": 0.4119699999973818, - "median": 0.4313090000032389, - "max": 0.43824899999890476 - }, - "samplesMs": [ - 0.4313090000032389, - 0.43735799999922165, - 0.4185300000026473, - 0.43824899999890476, - 0.4119699999973818 - ] - }, - "bfs": { - "A": 10, - "U": 5, - "C": 33, - "plateSwitches": 9, - "commands": [ - { - "plate": 3, - "direction": "right", - "steps": 1 - }, - { - "plate": 5, - "direction": "left", - "steps": 6 - }, - { - "plate": 1, - "direction": "left", - "steps": 3 - }, - { - "plate": 4, - "direction": "left", - "steps": 3 - }, - { - "plate": 1, - "direction": "left", - "steps": 3 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 4, - "direction": "left", - "steps": 3 - }, - { - "plate": 5, - "direction": "left", - "steps": 6 - }, - { - "plate": 1, - "direction": "left", - "steps": 3 - }, - { - "plate": 6, - "direction": "right", - "steps": 2 - } - ], - "timingMs": { - "min": 9.617881000001944, - "median": 9.699493000000075, - "max": 11.34095299999899 - }, - "samplesMs": [ - 9.699493000000075, - 9.617881000001944, - 10.828151000001526, - 9.671301000002131, - 11.34095299999899 - ] - }, - "baseline": { - "A": 10, - "U": 5, - "C": 33, - "plateSwitches": 9, - "commands": [ - { - "plate": 3, - "direction": "right", - "steps": 1 - }, - { - "plate": 5, - "direction": "left", - "steps": 6 - }, - { - "plate": 1, - "direction": "left", - "steps": 3 - }, - { - "plate": 4, - "direction": "left", - "steps": 3 - }, - { - "plate": 1, - "direction": "left", - "steps": 3 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 4, - "direction": "left", - "steps": 3 - }, - { - "plate": 5, - "direction": "left", - "steps": 6 - }, - { - "plate": 1, - "direction": "left", - "steps": 3 - }, - { - "plate": 6, - "direction": "right", - "steps": 2 - } - ], - "timingMs": { - "min": 61.399960000002466, - "median": 66.13834900000074, - "max": 66.89568900000086 - }, - "samplesMs": [ - 66.13834900000074, - 61.399960000002466, - 66.1904570000006, - 62.48275899999862, - 66.89568900000086 - ] - }, - "reference": { - "A": 10, - "U": 5, - "C": 33, - "plateSwitches": 9, - "commands": [ - { - "plate": 6, - "direction": "right", - "steps": 2 - }, - { - "plate": 5, - "direction": "left", - "steps": 6 - }, - { - "plate": 3, - "direction": "right", - "steps": 1 - }, - { - "plate": 1, - "direction": "left", - "steps": 3 - }, - { - "plate": 4, - "direction": "left", - "steps": 3 - }, - { - "plate": 1, - "direction": "left", - "steps": 3 - }, - { - "plate": 5, - "direction": "left", - "steps": 6 - }, - { - "plate": 4, - "direction": "left", - "steps": 3 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "left", - "steps": 3 - } - ], - "timingMs": { - "min": 24.716668000000936, - "median": 25.8066370000015, - "max": 27.208236999998917 - }, - "samplesMs": [ - 25.281278000002203, - 24.716668000000936, - 26.408062000002246, - 27.208236999998917, - 25.8066370000015 - ] - } - }, - "historicalReference": { - "kind": "cached-observation", - "capturedAt": "2026-09-07T06:02:47.149Z", - "A": 10, - "U": 5, - "C": 33, - "plateSwitches": 9, - "commands": [ - { - "plate": 6, - "direction": "right", - "steps": 2 - }, - { - "plate": 5, - "direction": "left", - "steps": 6 - }, - { - "plate": 3, - "direction": "right", - "steps": 1 - }, - { - "plate": 1, - "direction": "left", - "steps": 3 - }, - { - "plate": 4, - "direction": "left", - "steps": 3 - }, - { - "plate": 1, - "direction": "left", - "steps": 3 - }, - { - "plate": 5, - "direction": "left", - "steps": 6 - }, - { - "plate": 4, - "direction": "left", - "steps": 3 - }, - { - "plate": 3, - "direction": "right", - "steps": 3 - }, - { - "plate": 1, - "direction": "left", - "steps": 3 - } - ], - "historicalTimingMs": { - "min": 24.668550999998843, - "median": 24.905797999999777, - "max": 25.209614999999758 - } - } - } - ], - "limitations": [ - "These are 45 fixed catalog configurations, not a random sample or a claim about every game state.", - "Only actions (A) are minimized by the production solver; distinct plates (U) and unit divisions (C) are descriptive metrics.", - "The reference minimizes unit divisions before plate switches; its objective differs from minimum grouped actions.", - "Timing reflects this runtime, machine, sample count, JIT and garbage collection; no timing threshold is a correctness gate.", - "These are Node.js timings, not measurements in an actual browser or Web Worker.", - "Cached historical timings are provenance only and are excluded from current speed comparisons.", - "The sum of per-lock medians is an aggregate of medians, not the median runtime of a whole-catalog pass.", - "Speedup ratios above 1 favor matrix A*; ratios below 1 favor the comparator. Timing ties require equal measured medians." - ] -} diff --git a/docs/benchmarks/run-20260907T070258001Z-f78dff13/benchmark.md b/docs/benchmarks/run-20260907T070258001Z-f78dff13/benchmark.md deleted file mode 100644 index 939792d..0000000 --- a/docs/benchmarks/run-20260907T070258001Z-f78dff13/benchmark.md +++ /dev/null @@ -1,239 +0,0 @@ -# Matrix search benchmark - -Generated: 2026-09-07T07:02:58.001Z. Scope: **full-45-catalog** (45 locks). - -Correctness gates: **PASS**. Warmups per solver/lock: 1; measured repetitions: 5. - -A = actions (grouped commands); U = unique selected plates; C = unit divisions of the selected plate. All timings are milliseconds. - -Only the synchronous solver call is timed. Input cloning, reference click grouping, replay, metrics, and reporting are outside timing. Current and baseline calls include their public facade validation/result construction. - -Per lock, warmup and measured rounds rotate solver order deterministically. - -GC: Natural Node.js garbage collection; no forced GC. - -## Runtime and source evidence - -```json -{ - "environment": { - "node": "v24.19.0", - "v8": "13.6.233.17-node.51", - "platform": "linux", - "arch": "x64", - "osRelease": "6.18.35", - "cpu": "AMD EPYC 9V74 80-Core Processor", - "logicalCpus": 9, - "totalMemoryBytes": 23109910528, - "execArgv": [] - }, - "source": { - "repository": "https://github.com/Marcus-Rise/gothic-lock-solver", - "revision": "6b1cfbc13bcec68f609d45d5dc97504dd84f1760", - "dirty": false, - "core": { - "sha256": "064dd862f17ea1a8fdd00abd4e06dfb024ffc4dcf98cb648c881a163c4599079", - "files": { - "cli.mjs": "a8ca1332163fa5df64387b1fccc985244f07e99664eba463153700ecd4d672b5", - "index.mjs": "5e3d680cbb1ca7bf9437a3800654bc26b1445ec71994bfc1fc70a7d5381aa6ed", - "lock-definition.mjs": "cecc13bdb3c760400dda8421e5bdb58e9fdc5bbd46e1782dbd11f94a6de9a8b3", - "matrix-analysis.mjs": "7262f85ba882cd8a6ea9cb294be2d2f7a22acd16bc3a963744b9edbdce397986", - "matrix-search.mjs": "b018cfdad21aaf52f4350d5d8f0001afed1b41c44643e7fc8c330e6aca40b1d4", - "result.mjs": "1a38110c34b696f62b9632cb3de27bb0e07c938cdd11b3ffcb89de8532bf08b0", - "search-bfs.mjs": "69f253801d4bb36a9db6887a82a668b0a2765fe55c33cdf7c712fe82df128a75", - "search-limits.mjs": "4a302b500fa41019b1ec3d8f3b86ad28de1e2046a8f9ac7a707608170ce7b569", - "solver.mjs": "31e3ebfb7035bc57d4c435351642eac0b49820d9ed4682d7eac38b0529dddd29", - "state-codec.mjs": "ce37a9ede6b26196fef7b89aa9501fe94c6a18fc4110c1cea394995b053759e4", - "transition.mjs": "28cf5007a8fd1db9d179bf233d995a5e88f5a6c486bca22395e2ed144cdbd4fd" - } - }, - "harness": { - "sha256": "becf8b6ca5b15dd64d91e52939bb1bf8b46816f4f62e70b27629e17c26f9bde4", - "files": { - "baseline/src/index.mjs": "927fc5391c8c5f673f52c35d1fd2bd14afa5d47554a7a410dae0d8c03788c90f", - "baseline/src/lock-definition.mjs": "e87e8eae439601cb93d85e8c5d5314976574ad6c6e595fff98f419ef80623d74", - "baseline/src/result.mjs": "1a38110c34b696f62b9632cb3de27bb0e07c938cdd11b3ffcb89de8532bf08b0", - "baseline/src/solver.mjs": "ab7335383670c40ccbe2a53c6591dc67a04b7abda49d44978c9a790938f92dec", - "baseline/src/state-codec.mjs": "ce37a9ede6b26196fef7b89aa9501fe94c6a18fc4110c1cea394995b053759e4", - "baseline/src/transition.mjs": "28cf5007a8fd1db9d179bf233d995a5e88f5a6c486bca22395e2ed144cdbd4fd", - "catalog.mjs": "1aeead30de3b4041309df18edae2daa206212cf6bb5e8b7031e77743d450a497", - "harness.mjs": "49c73bd6cd5b096f079446ca89a272b73f63e333ac187f814e126d66198ed2a0", - "validation.mjs": "3162b5374be266e5963e63a494006666c2b29ff2be10f2d2b58c8c57e036759e" - }, - "cliSha256": "57276d8b7dfbc48c8348d021b443f64d14c3bb3dbce753db8d54d51ee2fbf76e" - } - }, - "fixtures": { - "count": 45, - "catalogSha256": "0eb5b641eac4bda105bde24b60399220f1767dd13db6a312b78638b2750ba675", - "manifestSha256": "a634d5328fd8d41470397b622ae0bd32b8aac0355dfc31975e023a63a62e65c9", - "repository": "https://github.com/1h8s/unlockmyloot", - "revision": "eee0bf50ebcb7fffd2b47954fd76bb015854e365" - }, - "baseline": { - "repository": "https://github.com/Marcus-Rise/gothic-lock-solver", - "revision": "a87e739a22ccc4215d6d9b14fa06b0738171cb60", - "license": "Apache-2.0", - "files": { - "src/index.mjs": "927fc5391c8c5f673f52c35d1fd2bd14afa5d47554a7a410dae0d8c03788c90f", - "src/lock-definition.mjs": "e87e8eae439601cb93d85e8c5d5314976574ad6c6e595fff98f419ef80623d74", - "src/result.mjs": "1a38110c34b696f62b9632cb3de27bb0e07c938cdd11b3ffcb89de8532bf08b0", - "src/solver.mjs": "ab7335383670c40ccbe2a53c6591dc67a04b7abda49d44978c9a790938f92dec", - "src/state-codec.mjs": "ce37a9ede6b26196fef7b89aa9501fe94c6a18fc4110c1cea394995b053759e4", - "src/transition.mjs": "28cf5007a8fd1db9d179bf233d995a5e88f5a6c486bca22395e2ed144cdbd4fd", - "LICENSE": "c71d239df91726fc519c6eb72d318ec65820627232b2f796219e87dcf35d0ab4" - }, - "notes": "Unmodified source snapshots; imports remain relative within this directory. No upstream UnlockMyLoot solver code is included." - }, - "reference": { - "measured": true, - "repository": "https://github.com/1h8s/unlockmyloot", - "revision": "eee0bf50ebcb7fffd2b47954fd76bb015854e365", - "path": "index.html", - "fileSha256": "691f744099a9169f30e9883fddc5fdbb4949d306f3011e601678419d7b93821b", - "solverBlockSha256": "d5bc85f40fd7c5b61097403df399ecb7dfd2b58924dc7a670aa0d05306017380", - "execution": "Unchanged source compiled with Function in the same Node.js realm; no browser or vm context." - } -} -``` - -## Totals across measured locks - -| Solver | A | U | C | Sum of per-lock median ms | -| --- | ---: | ---: | ---: | ---: | -| Matrix A* | 483 | 245 | 1723 | 49.630951 | -| Current exact BFS | 483 | 245 | 1727 | 423.230535 | -| Original Apache BFS | 483 | 245 | 1727 | 2537.917176 | -| UnlockMyLoot (live pinned reference) | 491 | 245 | 1695 | 1465.336812 | - -## Matrix A* comparisons - -Wins/ties/losses count locks where matrix A* is lower/equal/higher. A ratio above 1 favors matrix A*. - -| Comparator | A wins/ties/losses | U wins/ties/losses | C wins/ties/losses | Time wins/ties/losses | Geometric mean speedup | Ratio of summed medians | -| --- | --- | --- | --- | --- | ---: | ---: | -| Current exact BFS | 0/45/0 | 0/45/0 | 2/42/1 | 43/0/2 | 16.232012 | 8.527552 | -| Original Apache BFS | 0/45/0 | 0/45/0 | 2/42/1 | 45/0/0 | 88.393103 | 51.135776 | -| UnlockMyLoot (live pinned reference) | 5/40/0 | 0/45/0 | 0/39/6 | 45/0/0 | 52.863128 | 29.524657 | -| Reference (cached metrics only) | 5/40/0 | 0/45/0 | 0/39/6 | not timed | — | — | - -## Per-lock action metrics - -| Lock | N | Exact minimum A | Matrix A* A/U/C | Current exact BFS A/U/C | Original Apache BFS A/U/C | UnlockMyLoot (live pinned reference) A/U/C | Cached reference A/U/C | -| --- | ---: | ---: | --- | --- | --- | --- | --- | -| Дверь Разрушенной башни (ruined-tower-door) | 6 | 17 | 17/6/57 | 17/6/57 | 17/6/57 | 17/6/57 | 17/6/57 | -| Сундук Скатти (scatty-chest) | 5 | 9 | 9/5/31 | 9/5/31 | 9/5/31 | 9/5/31 | 9/5/31 | -| Сундук Кор Галома (cor-galom-chest) | 4 | 5 | 5/4/15 | 5/4/19 | 5/4/19 | 5/4/15 | 5/4/15 | -| Сундук в спальне Кор Галома (cor-galom-bedroom-chest) | 6 | 14 | 14/5/60 | 14/5/60 | 14/5/60 | 14/5/60 | 14/5/60 | -| Сундук в кузнице Болотного лагеря (swamp-camp-forge-chest) | 6 | 11 | 11/6/41 | 11/6/41 | 11/6/41 | 11/6/41 | 11/6/41 | -| Склад Рисового лорда (3-й этаж) (rice-lord-storeroom) | 5 | 9 | 9/5/30 | 9/5/30 | 9/5/30 | 9/5/30 | 9/5/30 | -| Дверь в покои Гомеза (gomez-chambers-door) | 6 | 9 | 9/6/28 | 9/6/28 | 9/6/28 | 9/6/28 | 9/6/28 | -| Сундук в тронном зале у Гомеза (gomez-throne-hall-chest) | 4 | 5 | 5/4/16 | 5/4/16 | 5/4/16 | 5/4/16 | 5/4/16 | -| Дверь в темнице с расстрелянным призраком (old-camp-dungeon-door) | 6 | 17 | 17/6/72 | 17/6/72 | 17/6/72 | 18/6/68 | 18/6/68 | -| Ключ от темницы Арлина (arlin-dungeon-key) | 7 | 7 | 7/5/25 | 7/5/25 | 7/5/25 | 7/5/25 | 7/5/25 | -| Винный погреб Гомеза (gomez-wine-cellar) | 6 | 9 | 9/6/41 | 9/6/41 | 9/6/41 | 11/6/31 | 11/6/31 | -| Сундук в ногах Гомеза (gomez-bedside-chest) | 5 | 23 | 23/5/68 | 23/5/68 | 23/5/68 | 23/5/68 | 23/5/68 | -| Сундук у стены в покоях Гомеза (gomez-wall-chest) | 6 | 20 | 20/6/75 | 20/6/75 | 20/6/75 | 20/6/75 | 20/6/75 | -| Сундук у кровати Ворона (voran-bedside-chest) | 7 | 9 | 9/7/38 | 9/7/38 | 9/7/38 | 9/7/38 | 9/7/38 | -| Дверь в комнату Арто (arto-room-door) | 5 | 7 | 7/5/31 | 7/5/31 | 7/5/31 | 7/5/31 | 7/5/31 | -| Сундук у дальней кровати Арто (arto-far-bed-chest) | 6 | 11 | 11/5/44 | 11/5/44 | 11/5/44 | 13/5/40 | 13/5/40 | -| Сундук у ближней кровати Арто (arto-near-bed-chest) | 6 | 13 | 13/6/52 | 13/6/52 | 13/6/52 | 13/6/52 | 13/6/52 | -| Дверь на этаже спален покоев (gomez-bedrooms-door) | 6 | 14 | 14/6/49 | 14/6/45 | 14/6/45 | 14/6/45 | 14/6/45 | -| Сундук у дальней кровати (этаж спален) (gomez-bedrooms-far-chest) | 5 | 14 | 14/5/43 | 14/5/43 | 14/5/43 | 16/5/39 | 16/5/39 | -| Сундук у ближней кровати (этаж спален) (gomez-bedrooms-near-chest) | 6 | 12 | 12/6/56 | 12/6/56 | 12/6/56 | 12/6/56 | 12/6/56 | -| Левый сундук в башне Ворона (raven-tower-left-chest) | 6 | 11 | 11/6/35 | 11/6/35 | 11/6/35 | 11/6/35 | 11/6/35 | -| Правый сундук в башне Ворона (raven-tower-right-chest) | 6 | 8 | 8/6/23 | 8/6/23 | 8/6/23 | 8/6/23 | 8/6/23 | -| Сундук у лестницы (2-й этаж башни) (raven-tower-floor2-stairs-chest) | 6 | 16 | 16/6/48 | 16/6/48 | 16/6/48 | 16/6/48 | 16/6/48 | -| Второй сундук на 2-м этаже башни (raven-tower-floor2-chest-2) | 5 | 8 | 8/5/29 | 8/5/29 | 8/5/29 | 9/5/27 | 9/5/27 | -| Сундук у входа (3-й этаж башни) (raven-tower-floor3-entrance-chest) | 5 | 14 | 14/5/44 | 14/5/44 | 14/5/44 | 14/5/44 | 14/5/44 | -| Пустой сундук (3-й этаж башни) (raven-tower-floor3-empty-chest) | 5 | 9 | 9/5/37 | 9/5/41 | 9/5/41 | 9/5/37 | 9/5/37 | -| Сундук с эликсирами (4-й этаж башни) (raven-tower-floor4-elixirs-chest) | 7 | 8 | 8/5/36 | 8/5/36 | 8/5/36 | 8/5/36 | 8/5/36 | -| Сундук с едой (4-й этаж башни) (raven-tower-floor4-food-chest) | 6 | 11 | 11/6/43 | 11/6/43 | 11/6/43 | 11/6/43 | 11/6/43 | -| Сундук на вершине башни Ворона (raven-tower-top-chest) | 6 | 20 | 20/6/48 | 20/6/48 | 20/6/48 | 20/6/48 | 20/6/48 | -| Ящик у огненного ящера (fire-lizard-cave-chest) | 6 | 10 | 10/6/41 | 10/6/41 | 10/6/41 | 10/6/41 | 10/6/41 | -| Сундук в подвале таверны (silas-tavern-basement-chest) | 5 | 9 | 9/5/30 | 9/5/30 | 9/5/30 | 9/5/30 | 9/5/30 | -| Сундук склада таверны (silas-tavern-storeroom-chest) | 6 | 9 | 9/6/31 | 9/6/31 | 9/6/31 | 9/6/31 | 9/6/31 | -| Склад таверны - дверь (silas-tavern-storeroom-door) | 6 | 10 | 10/5/31 | 10/5/31 | 10/5/31 | 10/5/31 | 10/5/31 | -| Сундук в домике напротив Джана (jan-pond-house-chest) | 6 | 8 | 8/6/33 | 8/6/33 | 8/6/33 | 8/6/33 | 8/6/33 | -| Сундук Грэхэма (graham-chest) | 6 | 10 | 10/6/40 | 10/6/40 | 10/6/40 | 10/6/40 | 10/6/40 | -| Сундук Граво (gravo-chest) | 5 | 8 | 8/5/27 | 8/5/27 | 8/5/27 | 8/5/27 | 8/5/27 | -| Сундук Диего (diego-chest) | 6 | 11 | 11/6/40 | 11/6/40 | 11/6/40 | 11/6/40 | 11/6/40 | -| Сундук Грима (grim-chest) | 6 | 8 | 8/6/21 | 8/6/21 | 8/6/21 | 8/6/21 | 8/6/21 | -| Сундук наёмника (у Грима) (mercenary-near-grim-chest) | 6 | 8 | 8/6/25 | 8/6/25 | 8/6/25 | 8/6/25 | 8/6/25 | -| Популярный замок (popular-lock) | 5 | 13 | 13/5/46 | 13/5/46 | 13/5/46 | 13/5/46 | 13/5/46 | -| Сундук в хижине шахты Альберто (alberto-mine-hut-chest) | 5 | 9 | 9/5/36 | 9/5/36 | 9/5/36 | 9/5/36 | 9/5/36 | -| Левый сундук наверху в шахте Альберто (alberto-mine-upstairs-left-chest) | 4 | 4 | 4/3/20 | 4/3/20 | 4/3/20 | 4/3/20 | 4/3/20 | -| Правый сундук наверху в шахте Альберто (alberto-mine-upstairs-right-chest) | 5 | 5 | 5/5/13 | 5/5/13 | 5/5/13 | 5/5/13 | 5/5/13 | -| Дверь на склад охотника Кавалорна (cavalorn-storeroom-door) | 6 | 11 | 11/6/41 | 11/6/41 | 11/6/41 | 11/6/41 | 11/6/41 | -| Сундук у разбойников на пути в Новый лагерь (old-to-new-camp-bandits-chest) | 6 | 10 | 10/5/33 | 10/5/33 | 10/5/33 | 10/5/33 | 10/5/33 | - -## Per-lock live timings (min / median / max ms) - -| Lock | Matrix A* | Current exact BFS | Original Apache BFS | UnlockMyLoot (live pinned reference) | -| --- | --- | --- | --- | --- | -| Дверь Разрушенной башни (ruined-tower-door) | 3.453076 / 4.894905 / 9.461225 | 13.383326 / 15.879230 / 25.095196 | 77.485881 / 89.626475 / 108.147190 | 79.482874 / 85.138514 / 124.039541 | -| Сундук Скатти (scatty-chest) | 0.142525 / 0.196035 / 0.249545 | 2.278478 / 2.334082 / 3.282669 | 11.299079 / 11.719832 / 14.038151 | 5.317440 / 8.441120 / 15.322403 | -| Сундук Кор Галома (cor-galom-chest) | 0.042824 / 0.055604 / 0.084437 | 0.126440 / 0.129364 / 0.160211 | 0.452561 / 0.472901 / 0.491039 | 0.302695 / 0.322104 / 0.380232 | -| Сундук в спальне Кор Галома (cor-galom-bedroom-chest) | 0.246551 / 0.326311 / 0.409457 | 12.282660 / 12.730003 / 13.442365 | 70.649905 / 72.507640 / 74.428861 | 29.965104 / 30.333097 / 38.597862 | -| Сундук в кузнице Болотного лагеря (swamp-camp-forge-chest) | 0.259761 / 0.270167 / 0.325390 | 17.623635 / 17.761072 / 18.015034 | 105.264304 / 107.644315 / 113.353715 | 35.309474 / 36.690352 / 40.805724 | -| Склад Рисового лорда (3-й этаж) (rice-lord-storeroom) | 0.134772 / 0.161573 / 0.173140 | 1.684364 / 1.698796 / 1.949533 | 8.109772 / 8.252086 / 8.363103 | 4.524297 / 4.608884 / 4.867022 | -| Дверь в покои Гомеза (gomez-chambers-door) | 0.215093 / 0.223496 / 0.400593 | 13.604608 / 15.005065 / 17.201530 | 75.371168 / 80.544472 / 83.780350 | 28.923346 / 30.335060 / 35.090975 | -| Сундук в тронном зале у Гомеза (gomez-throne-hall-chest) | 0.026100 / 0.032078 / 0.038978 | 0.048132 / 0.059740 / 0.069184 | 0.172279 / 0.174662 / 0.182124 | 0.217517 / 0.232931 / 0.250357 | -| Дверь в темнице с расстрелянным призраком (old-camp-dungeon-door) | 2.305849 / 2.374432 / 2.994916 | 14.948962 / 15.437987 / 19.826901 | 82.738222 / 85.220486 / 92.245296 | 39.820161 / 40.373513 / 44.692852 | -| Ключ от темницы Арлина (arlin-dungeon-key) | 7.175456 / 7.301655 / 8.008499 | 7.162446 / 7.257570 / 7.964863 | 43.223462 / 44.592170 / 48.960672 | 20.249175 / 20.278920 / 24.130546 | -| Винный погреб Гомеза (gomez-wine-cellar) | 0.129154 / 0.139761 / 0.273812 | 8.284354 / 8.696474 / 9.232171 | 53.113420 / 53.189675 / 56.717633 | 26.219829 / 26.631778 / 34.844915 | -| Сундук в ногах Гомеза (gomez-bedside-chest) | 1.526797 / 1.601820 / 1.966659 | 1.902642 / 1.977525 / 2.334743 | 9.919945 / 10.484193 / 13.091537 | 8.149832 / 9.029956 / 9.092720 | -| Сундук у стены в покоях Гомеза (gomez-wall-chest) | 5.406093 / 6.001979 / 7.071058 | 15.090584 / 15.563596 / 16.102007 | 91.646635 / 93.952855 / 113.020993 | 80.892425 / 84.377008 / 86.197156 | -| Сундук у кровати Ворона (voran-bedside-chest) | 0.145339 / 0.174232 / 0.406001 | 27.983533 / 30.266828 / 41.618056 | 186.192047 / 190.957032 / 209.451929 | 188.300504 / 203.817561 / 265.421741 | -| Дверь в комнату Арто (arto-room-door) | 0.063265 / 0.073941 / 0.146470 | 0.483347 / 0.533563 / 0.561044 | 2.122744 / 2.239990 / 2.410086 | 4.410926 / 4.537777 / 6.422824 | -| Сундук у дальней кровати Арто (arto-far-bed-chest) | 0.199440 / 0.212210 / 0.331679 | 13.547542 / 13.620272 / 17.535142 | 78.977815 / 79.816627 / 82.365402 | 26.546390 / 26.896837 / 28.641021 | -| Сундук у ближней кровати Арто (arto-near-bed-chest) | 0.501715 / 0.520293 / 0.635606 | 14.866877 / 15.097875 / 16.566135 | 87.328599 / 87.605125 / 92.911028 | 69.048957 / 72.456504 / 82.421887 | -| Дверь на этаже спален покоев (gomez-bedrooms-door) | 5.449188 / 5.570851 / 6.123373 | 16.233054 / 16.523340 / 16.799676 | 92.039477 / 92.593561 / 92.853461 | 35.486030 / 36.058741 / 38.373735 | -| Сундук у дальней кровати (этаж спален) (gomez-bedrooms-far-chest) | 1.495080 / 1.962974 / 3.205933 | 2.072988 / 2.326961 / 3.976503 | 10.662161 / 11.987535 / 16.185301 | 5.977914 / 6.535844 / 8.364805 | -| Сундук у ближней кровати (этаж спален) (gomez-bedrooms-near-chest) | 0.117467 / 0.145089 / 0.159089 | 11.567103 / 11.670178 / 13.297066 | 65.116330 / 67.716052 / 95.263267 | 38.933377 / 41.407109 / 46.798349 | -| Левый сундук в башне Ворона (raven-tower-left-chest) | 1.332885 / 1.474369 / 2.742176 | 15.653531 / 16.923682 / 19.095039 | 94.090833 / 94.919509 / 95.294274 | 45.368709 / 47.149768 / 51.969109 | -| Правый сундук в башне Ворона (raven-tower-right-chest) | 0.144518 / 0.151548 / 0.180031 | 5.558523 / 6.447561 / 7.085630 | 31.400143 / 35.750863 / 38.894498 | 15.234651 / 15.675303 / 17.539329 | -| Сундук у лестницы (2-й этаж башни) (raven-tower-floor2-stairs-chest) | 4.273090 / 4.441643 / 7.479363 | 13.366460 / 13.868456 / 14.569059 | 80.161436 / 84.439873 / 86.146727 | 51.243917 / 52.779582 / 64.885643 | -| Второй сундук на 2-м этаже башни (raven-tower-floor2-chest-2) | 0.175905 / 0.199640 / 0.234653 | 2.012869 / 2.088602 / 2.346571 | 9.323398 / 9.793506 / 10.253748 | 2.983339 / 3.196580 / 3.440537 | -| Сундук у входа (3-й этаж башни) (raven-tower-floor3-entrance-chest) | 0.637650 / 0.707324 / 0.769047 | 1.540648 / 1.640669 / 3.248508 | 7.454707 / 8.776225 / 12.391845 | 6.350916 / 6.698889 / 9.026892 | -| Пустой сундук (3-й этаж башни) (raven-tower-floor3-empty-chest) | 0.070116 / 0.089505 / 0.095594 | 1.448309 / 1.461428 / 1.837645 | 8.154039 / 8.421802 / 8.643385 | 5.290109 / 5.552194 / 6.112657 | -| Сундук с эликсирами (4-й этаж башни) (raven-tower-floor4-elixirs-chest) | 0.109254 / 0.112309 / 0.136195 | 11.040161 / 11.221093 / 12.678586 | 73.129320 / 75.167478 / 82.039156 | 107.461847 / 112.569413 / 114.667008 | -| Сундук с едой (4-й этаж башни) (raven-tower-floor4-food-chest) | 0.267202 / 0.275865 / 0.295515 | 15.084486 / 15.373972 / 15.722726 | 90.678285 / 96.314105 / 109.625183 | 51.325845 / 52.314011 / 71.146988 | -| Сундук на вершине башни Ворона (raven-tower-top-chest) | 7.054034 / 7.251361 / 7.770963 | 11.989059 / 12.636073 / 14.032354 | 78.873203 / 82.082342 / 83.192121 | 43.022872 / 43.437415 / 48.328646 | -| Ящик у огненного ящера (fire-lizard-cave-chest) | 0.171709 / 0.175704 / 0.258559 | 10.301790 / 10.657055 / 11.986535 | 63.810299 / 64.970214 / 67.822405 | 53.291162 / 55.654889 / 69.187811 | -| Сундук в подвале таверны (silas-tavern-basement-chest) | 0.086651 / 0.114051 / 0.125208 | 1.361138 / 1.583773 / 2.494533 | 6.591978 / 7.191901 / 9.724732 | 3.855982 / 3.980659 / 4.069674 | -| Сундук склада таверны (silas-tavern-storeroom-chest) | 0.103596 / 0.105509 / 0.130747 | 13.265009 / 13.681646 / 15.403706 | 86.416723 / 90.603633 / 94.456980 | 30.445969 / 30.779861 / 31.124511 | -| Склад таверны - дверь (silas-tavern-storeroom-door) | 0.170056 / 0.175664 / 0.191708 | 13.175474 / 13.437288 / 14.941331 | 75.461670 / 81.038310 / 83.869430 | 22.194804 / 22.875538 / 24.601695 | -| Сундук в домике напротив Джана (jan-pond-house-chest) | 0.098569 / 0.103966 / 0.105930 | 6.958811 / 7.156618 / 8.019226 | 44.521017 / 45.811578 / 47.468221 | 47.224825 / 47.618628 / 48.630679 | -| Сундук Грэхэма (graham-chest) | 0.105608 / 0.109424 / 0.138429 | 16.359714 / 17.144644 / 18.131960 | 99.469936 / 101.199529 / 103.562794 | 30.639528 / 33.141643 / 36.963954 | -| Сундук Граво (gravo-chest) | 0.061162 / 0.071658 / 0.113180 | 1.004722 / 1.107086 / 1.167787 | 5.293044 / 5.463670 / 5.936682 | 5.050748 / 5.359533 / 5.448868 | -| Сундук Диего (diego-chest) | 0.159910 / 0.162244 / 0.215634 | 12.245143 / 12.377212 / 13.043719 | 79.944612 / 82.868842 / 86.734019 | 28.887343 / 29.897692 / 32.130710 | -| Сундук Грима (grim-chest) | 0.199200 / 0.213581 / 0.528836 | 16.700641 / 16.907052 / 18.926581 | 100.218671 / 106.557810 / 116.943629 | 19.493485 / 20.138955 / 20.343854 | -| Сундук наёмника (у Грима) (mercenary-near-grim-chest) | 0.091758 / 0.098238 / 0.118909 | 15.047443 / 15.120403 / 15.504441 | 93.219770 / 96.119853 / 100.796931 | 28.609512 / 29.191117 / 34.457432 | -| Популярный замок (popular-lock) | 0.550629 / 0.571239 / 0.592822 | 1.910796 / 1.922192 / 1.952307 | 9.808651 / 9.924765 / 10.116053 | 5.526196 / 5.561629 / 5.680868 | -| Сундук в хижине шахты Альберто (alberto-mine-hut-chest) | 0.059810 / 0.065548 / 0.097607 | 1.766468 / 1.777285 / 1.806328 | 8.646632 / 9.241657 / 10.841905 | 5.200596 / 5.284802 / 5.329430 | -| Левый сундук наверху в шахте Альберто (alberto-mine-upstairs-left-chest) | 0.015704 / 0.018408 / 0.030526 | 0.013030 / 0.014802 / 0.015343 | 0.034912 / 0.038548 / 0.046410 | 0.198168 / 0.210096 / 0.235705 | -| Правый сундук наверху в шахте Альберто (alberto-mine-upstairs-right-chest) | 0.026289 / 0.032729 / 0.050536 | 0.496917 / 0.510779 / 0.644560 | 2.125088 / 2.144357 / 2.693213 | 1.517594 / 1.549391 / 1.567980 | -| Дверь на склад охотника Кавалорна (cavalorn-storeroom-door) | 0.236295 / 0.238709 / 0.242344 | 13.715700 / 13.902150 / 14.243180 | 79.491600 / 81.640773 / 90.801756 | 40.194055 / 40.408977 / 42.256058 | -| Сундук у разбойников на пути в Новый лагерь (old-to-new-camp-bandits-chest) | 0.411970 / 0.431309 / 0.438249 | 9.617881 / 9.699493 / 11.340953 | 61.399960 / 66.138349 / 66.895689 | 24.716668 / 25.806637 / 27.208237 | - -## Correctness checks - -- Every warmup and measured solution independently replayed one division at a time. -- Matrix A*, exact BFS, and original Apache BFS equal the stored exact action minimum. -- Current BFS preserves the pinned original BFS command sequence. -- Inputs are unchanged and every implementation returns a deterministic path. -- Live reference path and A/U/C equal the cached pinned observation. - -## Limits of the conclusions - -- These are 45 fixed catalog configurations, not a random sample or a claim about every game state. -- Only actions (A) are minimized by the production solver; distinct plates (U) and unit divisions (C) are descriptive metrics. -- The reference minimizes unit divisions before plate switches; its objective differs from minimum grouped actions. -- Timing reflects this runtime, machine, sample count, JIT and garbage collection; no timing threshold is a correctness gate. -- These are Node.js timings, not measurements in an actual browser or Web Worker. -- Cached historical timings are provenance only and are excluded from current speed comparisons. -- The sum of per-lock medians is an aggregate of medians, not the median runtime of a whole-catalog pass. -- Speedup ratios above 1 favor matrix A*; ratios below 1 favor the comparator. Timing ties require equal measured medians. - -The JSON report includes individual samples and independently verified command sequences. Cached observations never supply values to the live timing table. diff --git a/docs/superpowers/plans/2026-08-31-gothic-lock-solver.md b/docs/superpowers/plans/2026-08-31-gothic-lock-solver.md deleted file mode 100644 index a477f22..0000000 --- a/docs/superpowers/plans/2026-08-31-gothic-lock-solver.md +++ /dev/null @@ -1,1430 +0,0 @@ -# План реализации решателя замков Gothic 1 Remake - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Создать проверяемую Node.js CLI-утилиту, которая по состоянию замка и направленной матрице связей находит минимальную последовательность сгруппированных команд `пластина + направление + xN`. - -**Status:** Выполнен 2026-08-31. Все задачи реализованы и проверены. - -**Architecture:** Вычислительное ядро состоит из чистых ESM-модулей: валидация модели, кодирование состояния, переходы и поиск в ширину. CLI отвечает только за файлы, аргументы, форматирование и коды выхода; благодаря этому ядро позднее можно напрямую использовать на сайте. - -**Tech Stack:** Node.js 20+, pnpm, JavaScript ESM (`.mjs`), встроенные `node:test`, `node:assert`, `node:fs`, `node:child_process`; сторонних runtime- и test-зависимостей нет. - -**Spec:** `../specs/2026-08-31-gothic-lock-solver.md` - -## Global Constraints - -- Число пластин: от 2 до 7; у каждой ровно 7 позиций. -- Цель: все пластины в позиции `4`. -- Влево означает `+1`, вправо означает `-1`. -- Матрица направленная, коэффициенты только `-1`, `0`, `1`, диагональ только `0`. -- Связи применяются один раз от выбранной строки, без обратного вывода связей и без каскада. -- Если хотя бы одна затронутая пластина не может пройти все `steps`, команда полностью заблокирована. -- Стоимость каждого допустимого макрохода `x1..x6` равна одной команде. -- Среди кратчайших решений порядок действий: пластина по возрастанию, `left` перед `right`, большее `steps` перед меньшим. -- Состояния кодируются числами в системе счисления по основанию 7; BFS использует типизированные массивы. -- Ядро не читает и не пишет файлы и не зависит от CLI. -- Пользовательские сообщения CLI выводятся по-русски; значения `direction` в JSON: только `left` и `right`. -- Грамматика CLI строгая: `` либо ` --output `; неизвестные флаги и лишние аргументы дают код `1`. -- Дополнительные свойства входного JSON игнорируются; нормализованная модель содержит только `state` и `links`. -- Обычный результат идет в stdout, ошибки - в stderr; `unsolvable` с `--output` сначала записывает JSON, затем завершает процесс кодом `2`. -- PDF не используется. Сайт, распознавание изображений, база известных замков и преждевременная оптимизация не входят в план. -- Разработка идет по TDD: сначала наблюдаемый правильный провал теста, затем минимальная реализация, затем полный зеленый прогон. - ---- - -## Карта файлов - -| Файл | Ответственность | -|---|---| -| `package.json` | Метаданные Node.js, команды `test` и `solve`, ограничение Node.js 20+. | -| `.gitignore` | Исключение временных данных, зависимостей и приложенных исходных материалов. | -| `solve-lock.mjs` | Минимальная исполняемая точка входа. | -| `src/lock-definition.mjs` | Разбор JSON и полная валидация определения замка. | -| `src/state-codec.mjs` | Кодирование и декодирование состояний в base-7. | -| `src/transition.mjs` | Атомарное применение команды и генерация переходов в стабильном порядке. | -| `src/solver.mjs` | BFS, массивы родителей и восстановление кратчайшего пути. | -| `src/result.mjs` | Формирование JSON-результата, метрик и русского консольного текста. | -| `src/index.mjs` | Стабильный website-safe facade `solveLock` вычислительного ядра. | -| `src/cli.mjs` | Разбор аргументов, чтение/запись файлов и коды завершения. | -| `test/*.test.mjs` | Модульные, дифференциальные и интеграционные тесты. | -| `examples/lock.example.json` | Проверяемый пример входного замка. | -| `scripts/benchmark.mjs` | Ручное измерение предельного семипластинчатого случая. | -| `.codex/skills/gothic-lock-solver/` | Проектный skill интерактивного сбора модели и запуска решателя. | - ---- - -### Task 1: Каркас проекта и валидация определения замка - -**Files:** -- Create: `package.json` -- Create: `.gitignore` -- Create: `src/lock-definition.mjs` -- Create: `test/lock-definition.test.mjs` -- Include in first commit: `docs/superpowers/specs/2026-08-31-gothic-lock-solver.md` -- Include in first commit: `docs/superpowers/plans/2026-08-31-gothic-lock-solver.md` - -**Interfaces:** -- Consumes: строку JSON или неизвестное JavaScript-значение. -- Produces: `LockInputError`, `parseLockDefinition(text)`, `validateLockDefinition(value)`. -- Return shape: `{ state: number[], links: number[][] }`; функции возвращают защитные копии массивов. - -- [x] **Step 1: Подготовить Git без затрагивания приложенного PDF** - -Run: - -```bash -git rev-parse --is-inside-work-tree -``` - -Expected: если команда завершается ошибкой, выполнить `git init`. Не добавлять `project_sources/` в индекс. - -Run: - -```bash -mkdir -p src test -``` - -Expected: каталоги для первых production- и test-файлов существуют. - -- [x] **Step 2: Создать падающие тесты валидации** - -```js -// test/lock-definition.test.mjs -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { - LockInputError, - parseLockDefinition, - validateLockDefinition, -} from '../src/lock-definition.mjs'; - -const validLock = { - state: [7, 4, 1], - links: [ - [0, 1, -1], - [0, 0, 0], - [1, 0, 0], - ], -}; - -test('validateLockDefinition accepts and copies a valid directed lock', () => { - const result = validateLockDefinition(validLock); - assert.deepEqual(result, validLock); - assert.notEqual(result.state, validLock.state); - assert.notEqual(result.links[0], validLock.links[0]); -}); - -for (const [name, value, message] of [ - ['requires 2 to 7 plates', { state: [4], links: [[0]] }, /от 2 до 7/], - ['rejects a position outside 1..7', { ...validLock, state: [7, 8, 1] }, /state\[1\]/], - ['requires a square matrix', { ...validLock, links: [[0], [0], [0]] }, /links\[0\]/], - ['rejects an unknown coefficient', { ...validLock, links: [[0, 2, 0], [0, 0, 0], [0, 0, 0]] }, /-1, 0 или 1/], - ['requires a zero diagonal', { ...validLock, links: [[1, 0, 0], [0, 0, 0], [0, 0, 0]] }, /диагонали/], -]) { - test(name, () => { - assert.throws(() => validateLockDefinition(value), message); - }); -} - -test('parseLockDefinition reports malformed JSON as LockInputError', () => { - assert.throws( - () => parseLockDefinition('{'), - (error) => error instanceof LockInputError && /Некорректный JSON/.test(error.message), - ); -}); -``` - -- [x] **Step 3: Запустить тест и увидеть правильный RED** - -Run: - -```bash -node --test test/lock-definition.test.mjs -``` - -Expected: FAIL с `ERR_MODULE_NOT_FOUND` для `src/lock-definition.mjs`. - -- [x] **Step 4: Создать минимальный проект и реализацию валидации** - -```json -{ - "name": "gothic-lock-solver", - "version": "0.1.0", - "private": true, - "type": "module", - "engines": { - "node": ">=20" - }, - "scripts": { - "test": "node --test" - } -} -``` - -```gitignore -# .gitignore -node_modules/ -coverage/ -*.tmp -project_sources/ -``` - -```js -// src/lock-definition.mjs -export class LockInputError extends Error { - constructor(message, options) { - super(message, options); - this.name = 'LockInputError'; - } -} - -export function validateLockDefinition(value) { - if (value === null || typeof value !== 'object' || Array.isArray(value)) { - throw new LockInputError('Определение замка должно быть JSON-объектом.'); - } - - const { state, links } = value; - if (!Array.isArray(state) || state.length < 2 || state.length > 7) { - throw new LockInputError('state должен содержать от 2 до 7 пластин.'); - } - - state.forEach((position, index) => { - if (!Number.isInteger(position) || position < 1 || position > 7) { - throw new LockInputError(`state[${index}] должен быть целым числом от 1 до 7.`); - } - }); - - if (!Array.isArray(links) || links.length !== state.length) { - throw new LockInputError(`links должен быть матрицей ${state.length}x${state.length}.`); - } - - links.forEach((row, rowIndex) => { - if (!Array.isArray(row) || row.length !== state.length) { - throw new LockInputError(`links[${rowIndex}] должен содержать ${state.length} элементов.`); - } - row.forEach((coefficient, columnIndex) => { - if (![ -1, 0, 1 ].includes(coefficient)) { - throw new LockInputError(`links[${rowIndex}][${columnIndex}] должен быть -1, 0 или 1.`); - } - if (rowIndex === columnIndex && coefficient !== 0) { - throw new LockInputError(`links[${rowIndex}][${columnIndex}] на диагонали должен быть 0.`); - } - }); - }); - - return { - state: [...state], - links: links.map((row) => [...row]), - }; -} - -export function parseLockDefinition(text) { - try { - return validateLockDefinition(JSON.parse(text)); - } catch (error) { - if (error instanceof LockInputError) { - throw error; - } - throw new LockInputError(`Некорректный JSON: ${error.message}`, { cause: error }); - } -} -``` - -- [x] **Step 5: Запустить тесты и увидеть GREEN** - -Run: - -```bash -pnpm test -``` - -Expected: все тесты `lock-definition` PASS. - -- [x] **Step 6: Зафиксировать первый самостоятельно проверяемый результат** - -```bash -git add .gitignore package.json docs/superpowers/specs/2026-08-31-gothic-lock-solver.md docs/superpowers/plans/2026-08-31-gothic-lock-solver.md src/lock-definition.mjs test/lock-definition.test.mjs -git commit -m "feat: validate Gothic lock definitions" -``` - ---- - -### Task 2: Числовой кодек состояний - -**Files:** -- Create: `src/state-codec.mjs` -- Create: `test/state-codec.test.mjs` - -**Interfaces:** -- Consumes: `state: number[]`, `code: number`, `plateCount: number`. -- Produces: `stateSpaceSize(plateCount)`, `encodeState(state)`, `decodeState(code, plateCount)`, `createTargetState(plateCount)`. -- Encoding invariant: `[1, 1] -> 0`, `[7, 7] -> 48`, любой валидный вектор проходит round-trip без изменений. -- Порядок разрядов: `state[0]` - старший base-7 разряд; обязательный `plateCount` при декодировании сохраняет ведущие позиции `1`. - -- [x] **Step 1: Написать падающий тест кодека** - -```js -// test/state-codec.test.mjs -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { - createTargetState, - decodeState, - encodeState, - stateSpaceSize, -} from '../src/state-codec.mjs'; - -test('base-7 encoding has stable boundary values', () => { - assert.equal(encodeState([1, 1]), 0); - assert.equal(encodeState([7, 7]), 48); - assert.equal(stateSpaceSize(7), 823_543); -}); - -test('encodeState and decodeState round-trip a seven-plate state', () => { - const state = [7, 4, 1, 3, 6, 2, 5]; - assert.deepEqual(decodeState(encodeState(state), state.length), state); -}); - -test('createTargetState creates only center positions', () => { - assert.deepEqual(createTargetState(5), [4, 4, 4, 4, 4]); -}); -``` - -- [x] **Step 2: Подтвердить RED** - -Run: `node --test test/state-codec.test.mjs` -Expected: FAIL с `ERR_MODULE_NOT_FOUND`. - -- [x] **Step 3: Реализовать кодек без файловых и CLI-зависимостей** - -```js -// src/state-codec.mjs -export function stateSpaceSize(plateCount) { - return 7 ** plateCount; -} - -export function encodeState(state) { - let code = 0; - for (const position of state) { - code = code * 7 + (position - 1); - } - return code; -} - -export function decodeState(code, plateCount) { - const state = new Array(plateCount); - let remainder = code; - for (let index = plateCount - 1; index >= 0; index -= 1) { - state[index] = (remainder % 7) + 1; - remainder = Math.floor(remainder / 7); - } - return state; -} - -export function createTargetState(plateCount) { - return Array.from({ length: plateCount }, () => 4); -} -``` - -- [x] **Step 4: Подтвердить GREEN и отсутствие регрессии** - -Run: `pnpm test` -Expected: тесты `lock-definition` и `state-codec` PASS. - -- [x] **Step 5: Commit** - -```bash -git add src/state-codec.mjs test/state-codec.test.mjs -git commit -m "feat: encode lock states as base-7 integers" -``` - ---- - -### Task 3: Атомарные макрокоманды и стабильный генератор переходов - -**Files:** -- Create: `src/transition.mjs` -- Create: `test/transition.test.mjs` - -**Interfaces:** -- Consumes: `state`, `links`, `command`. -- Produces: `applyCommand(state, links, command): number[] | null`. -- Produces: `generateTransitions(state, links): Array<{ command, state }>`. -- Public command shape: `{ plate: number, direction: 'left' | 'right', steps: number }`; `plate` is one-based, `steps` is 1..6. -- `null` means the whole command is blocked; input arrays remain unchanged. - -- [x] **Step 1: Написать падающие тесты направления, инверсии, отсутствия каскада и блокировки** - -```js -// test/transition.test.mjs -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { applyCommand, generateTransitions } from '../src/transition.mjs'; - -test('applyCommand moves direct and inverse links by the full step count', () => { - const state = [3, 5, 4]; - const links = [ - [0, 1, -1], - [0, 0, 0], - [0, 0, 0], - ]; - const result = applyCommand(state, links, { plate: 1, direction: 'left', steps: 2 }); - assert.deepEqual(result, [5, 7, 2]); - assert.deepEqual(state, [3, 5, 4]); -}); - -test('applyCommand does not cascade through a moved target row', () => { - const links = [ - [0, 1, 0], - [0, 0, 1], - [0, 0, 0], - ]; - assert.deepEqual( - applyCommand([3, 5, 4], links, { plate: 1, direction: 'left', steps: 2 }), - [5, 7, 4], - ); -}); - -test('applyCommand atomically blocks when one linked plate cannot finish', () => { - const state = [3, 6]; - const links = [[0, 1], [0, 0]]; - assert.deepEqual( - applyCommand(state, links, { plate: 1, direction: 'left', steps: 1 }), - [4, 7], - ); - assert.equal( - applyCommand(state, links, { plate: 1, direction: 'left', steps: 2 }), - null, - ); - assert.deepEqual(state, [3, 6]); -}); - -test('a directed source row does not imply a reverse link', () => { - const links = [[0, 1], [0, 0]]; - assert.deepEqual( - applyCommand([3, 5], links, { plate: 2, direction: 'right', steps: 1 }), - [3, 4], - ); -}); - -test('generateTransitions uses plate, direction, descending steps order', () => { - const transitions = generateTransitions([4, 4], [[0, 0], [0, 0]]); - assert.deepEqual( - transitions.slice(0, 6).map(({ command }) => command), - [ - { plate: 1, direction: 'left', steps: 3 }, - { plate: 1, direction: 'left', steps: 2 }, - { plate: 1, direction: 'left', steps: 1 }, - { plate: 1, direction: 'right', steps: 3 }, - { plate: 1, direction: 'right', steps: 2 }, - { plate: 1, direction: 'right', steps: 1 }, - ], - ); -}); -``` - -- [x] **Step 2: Подтвердить RED** - -Run: `node --test test/transition.test.mjs` -Expected: FAIL с `ERR_MODULE_NOT_FOUND`. - -- [x] **Step 3: Реализовать один атомарный переход и генератор** - -```js -// src/transition.mjs -const DIRECTIONS = ['left', 'right']; - -export function applyCommand(state, links, command) { - const sourceIndex = command.plate - 1; - const directionDelta = command.direction === 'left' ? 1 : -1; - const nextState = [...state]; - - for (let targetIndex = 0; targetIndex < state.length; targetIndex += 1) { - const coefficient = targetIndex === sourceIndex - ? 1 - : links[sourceIndex][targetIndex]; - - if (coefficient === 0) { - continue; - } - - const nextPosition = state[targetIndex] - + directionDelta * coefficient * command.steps; - if (nextPosition < 1 || nextPosition > 7) { - return null; - } - nextState[targetIndex] = nextPosition; - } - - return nextState; -} - -export function generateTransitions(state, links) { - const transitions = []; - for (let plate = 1; plate <= state.length; plate += 1) { - const sourcePosition = state[plate - 1]; - for (const direction of DIRECTIONS) { - const sourceLimit = direction === 'left' - ? 7 - sourcePosition - : sourcePosition - 1; - for (let steps = sourceLimit; steps >= 1; steps -= 1) { - const command = { plate, direction, steps }; - const nextState = applyCommand(state, links, command); - if (nextState !== null) { - transitions.push({ command, state: nextState }); - } - } - } - } - return transitions; -} -``` - -- [x] **Step 4: Подтвердить GREEN полным прогоном** - -Run: `pnpm test` -Expected: все тесты PASS. - -- [x] **Step 5: Commit** - -```bash -git add src/transition.mjs test/transition.test.mjs -git commit -m "feat: model atomic linked plate commands" -``` - ---- - -### Task 4: BFS и восстановление минимальной комбинации - -**Files:** -- Create: `src/solver.mjs` -- Create: `test/solver.test.mjs` - -**Interfaces:** -- Consumes: проверенный объект `{ state, links }`. -- Produces: `findShortestCommands(definition): Command[] | null`. -- `[]` означает уже открытый замок; `null` означает недостижимую цель. -- Внутренние `packCommand`/`unpackCommand` кодируют одну команду в `Uint8Array`. - -- [x] **Step 1: Написать падающие тесты готовой цели, макрооптимальности, tie-break и недостижимости** - -```js -// test/solver.test.mjs -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { - findShortestCommands, - packCommand, - unpackCommand, -} from '../src/solver.mjs'; - -const noLinks = [[0, 0], [0, 0]]; - -test('packs every possible command into one byte and restores it', () => { - for (let plate = 1; plate <= 7; plate += 1) { - for (const direction of ['left', 'right']) { - for (let steps = 1; steps <= 6; steps += 1) { - const command = { plate, direction, steps }; - const packed = packCommand(command); - assert.ok(packed >= 0 && packed <= 83); - assert.deepEqual(unpackCommand(packed), command); - } - } - } -}); - -test('returns no commands for an already open lock', () => { - assert.deepEqual( - findShortestCommands({ state: [4, 4], links: noLinks }), - [], - ); -}); - -test('uses one macro command per independent plate', () => { - assert.deepEqual( - findShortestCommands({ state: [1, 7], links: noLinks }), - [ - { plate: 1, direction: 'left', steps: 3 }, - { plate: 2, direction: 'right', steps: 3 }, - ], - ); -}); - -test('prefers the lower plate when two one-command solutions exist', () => { - const links = [[0, 1], [1, 0]]; - assert.deepEqual( - findShortestCommands({ state: [1, 1], links }), - [{ plate: 1, direction: 'left', steps: 3 }], - ); -}); - -test('returns null when every possible move is blocked', () => { - const links = [[0, 1], [1, 0]]; - assert.equal(findShortestCommands({ state: [1, 7], links }), null); -}); - -test('prefers left over right when both start equally short solutions', () => { - const definition = { - state: [2, 7, 1], - links: [ - [0, 0, 0], - [-1, 0, -1], - [-1, -1, 0], - ], - }; - assert.deepEqual(findShortestCommands(definition), [ - { plate: 1, direction: 'left', steps: 5 }, - { plate: 3, direction: 'left', steps: 3 }, - ]); -}); - -test('prefers larger steps when earlier action fields are equal', () => { - const definition = { - state: [3, 2, 1], - links: [ - [0, 0, -1], - [-1, 0, -1], - [-1, -1, 0], - ], - }; - assert.deepEqual(findShortestCommands(definition), [ - { plate: 1, direction: 'right', steps: 2 }, - { plate: 3, direction: 'right', steps: 2 }, - { plate: 2, direction: 'right', steps: 2 }, - { plate: 1, direction: 'right', steps: 3 }, - { plate: 3, direction: 'right', steps: 2 }, - ]); -}); -``` - -- [x] **Step 2: Подтвердить RED** - -Run: `node --test test/solver.test.mjs` -Expected: FAIL с `ERR_MODULE_NOT_FOUND`. - -- [x] **Step 3: Реализовать упаковку команды и восстановление пути** - -```js -// начало src/solver.mjs -import { - createTargetState, - decodeState, - encodeState, - stateSpaceSize, -} from './state-codec.mjs'; -import { generateTransitions } from './transition.mjs'; - -const UNSEEN = -2; -const ROOT = -1; - -export function packCommand(command) { - const directionBit = command.direction === 'left' ? 0 : 1; - return (((command.plate - 1) * 2 + directionBit) * 6) + command.steps - 1; -} - -export function unpackCommand(packed) { - const steps = (packed % 6) + 1; - const plateDirection = Math.floor(packed / 6); - return { - plate: Math.floor(plateDirection / 2) + 1, - direction: plateDirection % 2 === 0 ? 'left' : 'right', - steps, - }; -} - -function reconstructCommands(startCode, targetCode, parents, actions) { - const commands = []; - let code = targetCode; - while (code !== startCode) { - commands.push(unpackCommand(actions[code])); - code = parents[code]; - } - commands.reverse(); - return commands; -} -``` - -- [x] **Step 4: Реализовать минимальный BFS на типизированных массивах** - -```js -// продолжение src/solver.mjs -export function findShortestCommands({ state, links }) { - const plateCount = state.length; - const startCode = encodeState(state); - const targetCode = encodeState(createTargetState(plateCount)); - if (startCode === targetCode) { - return []; - } - - const size = stateSpaceSize(plateCount); - const parents = new Int32Array(size); - parents.fill(UNSEEN); - const actions = new Uint8Array(size); - const queue = new Int32Array(size); - - parents[startCode] = ROOT; - let head = 0; - let tail = 0; - queue[tail] = startCode; - tail += 1; - - while (head < tail) { - const currentCode = queue[head]; - head += 1; - const currentState = decodeState(currentCode, plateCount); - - for (const transition of generateTransitions(currentState, links)) { - const nextCode = encodeState(transition.state); - if (parents[nextCode] !== UNSEEN) { - continue; - } - - parents[nextCode] = currentCode; - actions[nextCode] = packCommand(transition.command); - if (nextCode === targetCode) { - return reconstructCommands(startCode, targetCode, parents, actions); - } - queue[tail] = nextCode; - tail += 1; - } - } - - return null; -} -``` - -- [x] **Step 5: Подтвердить кратчайший и стабильный GREEN** - -Run: `pnpm test` -Expected: все тесты PASS; независимый замок решается двумя командами, а равный однокомандный путь выбирает пластину 1. - -- [x] **Step 6: Commit** - -```bash -git add src/solver.mjs test/solver.test.mjs -git commit -m "feat: find shortest lock command sequence" -``` - ---- - -### Task 5: Результат, метрики и консольное представление - -**Files:** -- Create: `src/result.mjs` -- Create: `src/index.mjs` -- Create: `test/result.test.mjs` - -**Interfaces:** -- Consumes: проверенное определение замка и `Command[] | null`. -- Produces: `createSolveResult(definition, commands)` строго по JSON-структуре спеки. -- Produces: `formatConsoleResult(result): string` без завершающего перевода строки. -- Produces: `solveLock(value)` как единственную стабильную высокоуровневую функцию для будущего сайта. - -- [x] **Step 1: Написать падающие тесты solved, already-open и unsolvable** - -```js -// test/result.test.mjs -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { solveLock } from '../src/index.mjs'; -import { createSolveResult, formatConsoleResult } from '../src/result.mjs'; - -const definition = { - state: [1, 7], - links: [[0, 0], [0, 0]], -}; -const commands = [ - { plate: 1, direction: 'left', steps: 3 }, - { plate: 2, direction: 'right', steps: 3 }, -]; - -test('createSolveResult replays commands and calculates metrics', () => { - const result = createSolveResult(definition, commands); - assert.deepEqual(result.finalState, [4, 4]); - assert.deepEqual(result.metrics, { - commands: 2, - divisions: 6, - plateSwitches: 1, - }); -}); - -test('formatConsoleResult renders grouped Russian commands', () => { - const result = createSolveResult(definition, commands); - assert.equal( - formatConsoleResult(result), - 'Найдено команд: 2\n' - + '1. Пластина 1 - влево x3\n' - + '2. Пластина 2 - вправо x3', - ); -}); - -test('already-open and unsolvable results have distinct text', () => { - const open = createSolveResult({ state: [4, 4], links: definition.links }, []); - const unsolvable = createSolveResult(definition, null); - assert.equal(formatConsoleResult(open), 'Замок уже открыт.'); - assert.equal(formatConsoleResult(unsolvable), 'Решение не найдено.'); - assert.deepEqual(unsolvable.commands, []); -}); - -test('solveLock validates and solves through the website-safe facade', () => { - const result = solveLock(definition); - assert.equal(result.status, 'solved'); - assert.deepEqual(result.finalState, [4, 4]); -}); -``` - -- [x] **Step 2: Подтвердить RED** - -Run: `node --test test/result.test.mjs` -Expected: FAIL с `ERR_MODULE_NOT_FOUND`. - -- [x] **Step 3: Реализовать построение результата через повторное применение команд** - -```js -// src/result.mjs -import { createTargetState } from './state-codec.mjs'; -import { applyCommand } from './transition.mjs'; - -export function createSolveResult(definition, commands) { - const initialState = [...definition.state]; - const targetState = createTargetState(initialState.length); - - if (commands === null) { - return { status: 'unsolvable', initialState, targetState, commands: [] }; - } - - let finalState = initialState; - for (const command of commands) { - const nextState = applyCommand(finalState, definition.links, command); - if (nextState === null) { - throw new Error('Решатель вернул заблокированную команду.'); - } - finalState = nextState; - } - - if (!finalState.every((position) => position === 4)) { - throw new Error('Решатель не привел замок к целевому состоянию.'); - } - - const plateSwitches = commands.reduce((count, command, index) => { - if (index === 0 || command.plate === commands[index - 1].plate) { - return count; - } - return count + 1; - }, 0); - - return { - status: 'solved', - initialState, - targetState, - commands: commands.map((command) => ({ ...command })), - finalState, - metrics: { - commands: commands.length, - divisions: commands.reduce((sum, command) => sum + command.steps, 0), - plateSwitches, - }, - }; -} - -export function formatConsoleResult(result) { - if (result.status === 'unsolvable') { - return 'Решение не найдено.'; - } - if (result.commands.length === 0) { - return 'Замок уже открыт.'; - } - - const directionNames = { left: 'влево', right: 'вправо' }; - const lines = result.commands.map((command, index) => ( - `${index + 1}. Пластина ${command.plate} - ${directionNames[command.direction]} x${command.steps}` - )); - return [`Найдено команд: ${result.commands.length}`, ...lines].join('\n'); -} -``` - -```js -// src/index.mjs -import { validateLockDefinition } from './lock-definition.mjs'; -import { createSolveResult } from './result.mjs'; -import { findShortestCommands } from './solver.mjs'; - -export function solveLock(value) { - const definition = validateLockDefinition(value); - return createSolveResult(definition, findShortestCommands(definition)); -} - -export { applyCommand, generateTransitions } from './transition.mjs'; -``` - -- [x] **Step 4: Подтвердить GREEN** - -Run: `pnpm test` -Expected: все тесты PASS. - -- [x] **Step 5: Commit** - -```bash -git add src/index.mjs src/result.mjs test/result.test.mjs -git commit -m "feat: format lock solving results" -``` - ---- - -### Task 6: CLI, консоль, создание и перезапись JSON-файла - -**Files:** -- Create: `src/cli.mjs` -- Create: `solve-lock.mjs` -- Create: `test/cli.test.mjs` -- Modify: `package.json` - -**Interfaces:** -- Produces: `parseArgs(argv): { inputPath: string, outputPath: string | null }`. -- Produces: `runCli(argv, streams?): Promise<0 | 1 | 2>`. -- `solve-lock.mjs` передает `process.argv.slice(2)` и устанавливает `process.exitCode`. -- При `--output` файл записывается до печати успешного результата; `writeFile` полностью перезаписывает существующий файл. - -- [x] **Step 1: Написать интеграционные тесты реального процесса Node.js** - -```js -// test/cli.test.mjs -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { spawnSync } from 'node:child_process'; -import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const projectRoot = dirname(dirname(fileURLToPath(import.meta.url))); -const entry = join(projectRoot, 'solve-lock.mjs'); - -async function createCase(t, definition) { - const directory = await mkdtemp(join(tmpdir(), 'gothic-lock-solver-')); - t.after(() => rm(directory, { recursive: true, force: true })); - const inputPath = join(directory, 'lock.json'); - await writeFile(inputPath, JSON.stringify(definition), 'utf8'); - return { directory, inputPath }; -} - -function run(args) { - return spawnSync(process.execPath, [entry, ...args], { encoding: 'utf8' }); -} - -test('prints a solution without creating an output file by default', async (t) => { - const { directory, inputPath } = await createCase(t, { - state: [1, 7], - links: [[0, 0], [0, 0]], - }); - const outputPath = join(directory, 'solution.json'); - const result = run([inputPath]); - assert.equal(result.status, 0); - assert.match(result.stdout, /Найдено команд: 2/); - assert.equal(result.stderr, ''); - await assert.rejects(readFile(outputPath, 'utf8'), { code: 'ENOENT' }); -}); - -test('--output creates and then overwrites a JSON result', async (t) => { - const { directory, inputPath } = await createCase(t, { - state: [1, 7], - links: [[0, 0], [0, 0]], - }); - const outputPath = join(directory, 'solution.json'); - const created = run([inputPath, '--output', outputPath]); - assert.equal(created.status, 0); - assert.equal(JSON.parse(await readFile(outputPath, 'utf8')).status, 'solved'); - - await writeFile(outputPath, 'stale'.repeat(1_000), 'utf8'); - const overwritten = run([inputPath, '--output', outputPath]); - assert.equal(overwritten.status, 0); - assert.match(overwritten.stdout, /Пластина 1 - влево x3/); - assert.equal(JSON.parse(await readFile(outputPath, 'utf8')).status, 'solved'); -}); - -test('invalid JSON exits with code 1 and a Russian error', async (t) => { - const { inputPath } = await createCase(t, { - state: [4, 4], - links: [[0, 0], [0, 0]], - }); - await writeFile(inputPath, '{', 'utf8'); - const result = run([inputPath]); - assert.equal(result.status, 1); - assert.match(result.stderr, /Ошибка: Некорректный JSON/); -}); - -test('an unsolvable lock writes JSON and exits with code 2', async (t) => { - const { directory, inputPath } = await createCase(t, { - state: [1, 7], - links: [[0, 1], [1, 0]], - }); - const outputPath = join(directory, 'solution.json'); - const result = run([inputPath, '--output', outputPath]); - assert.equal(result.status, 2); - assert.match(result.stdout, /Решение не найдено/); - assert.equal(JSON.parse(await readFile(outputPath, 'utf8')).status, 'unsolvable'); -}); - -test('read, write, and CLI grammar errors exit with code 1', async (t) => { - const { directory, inputPath } = await createCase(t, { - state: [4, 4], - links: [[0, 0], [0, 0]], - }); - - const missingInput = run([join(directory, 'missing.json')]); - assert.equal(missingInput.status, 1); - assert.match(missingInput.stderr, /Ошибка:/); - - for (const args of [ - [inputPath, '--unknown'], - [inputPath, '--output'], - [inputPath, 'extra'], - ]) { - const invalidArgs = run(args); - assert.equal(invalidArgs.status, 1); - assert.match(invalidArgs.stderr, /Ошибка: Использование:/); - } - - const writeError = run([inputPath, '--output', directory]); - assert.equal(writeError.status, 1); - assert.match(writeError.stderr, /Ошибка:/); -}); -``` - -- [x] **Step 2: Подтвердить RED** - -Run: `node --test test/cli.test.mjs` -Expected: FAIL, потому что `solve-lock.mjs` отсутствует. - -- [x] **Step 3: Реализовать CLI-оркестрацию и точные коды выхода** - -```js -// src/cli.mjs -import { readFile, writeFile } from 'node:fs/promises'; -import { solveLock } from './index.mjs'; -import { parseLockDefinition } from './lock-definition.mjs'; -import { formatConsoleResult } from './result.mjs'; - -export function parseArgs(argv) { - if (argv.length === 1) { - return { inputPath: argv[0], outputPath: null }; - } - if (argv.length === 3 && argv[1] === '--output') { - return { inputPath: argv[0], outputPath: argv[2] }; - } - throw new Error('Использование: node solve-lock.mjs [--output ]'); -} - -export async function runCli(argv, streams = {}) { - const stdout = streams.stdout ?? process.stdout; - const stderr = streams.stderr ?? process.stderr; - - try { - const { inputPath, outputPath } = parseArgs(argv); - const definition = parseLockDefinition(await readFile(inputPath, 'utf8')); - const result = solveLock(definition); - - if (outputPath !== null) { - await writeFile(outputPath, `${JSON.stringify(result, null, 2)}\n`, 'utf8'); - } - stdout.write(`${formatConsoleResult(result)}\n`); - return result.status === 'unsolvable' ? 2 : 0; - } catch (error) { - stderr.write(`Ошибка: ${error.message}\n`); - return 1; - } -} -``` - -```js -#!/usr/bin/env node -// solve-lock.mjs -import { runCli } from './src/cli.mjs'; - -process.exitCode = await runCli(process.argv.slice(2)); -``` - -Replace `package.json` with the complete final content: - -```json -{ - "name": "gothic-lock-solver", - "version": "0.1.0", - "private": true, - "type": "module", - "engines": { - "node": ">=20" - }, - "scripts": { - "test": "node --test", - "solve": "node solve-lock.mjs" - }, - "bin": { - "gothic-lock-solver": "./solve-lock.mjs" - } -} -``` - -- [x] **Step 4: Сделать точку входа исполняемой и подтвердить GREEN** - -Run: - -```bash -chmod +x solve-lock.mjs -pnpm test -``` - -Expected: все модульные и CLI-тесты PASS. - -- [x] **Step 5: Отдельно проверить ошибки аргументов** - -Run: - -```bash -node solve-lock.mjs -``` - -Expected: stderr начинается с `Ошибка: Использование:`, код выхода `1`. - -- [x] **Step 6: Commit** - -```bash -git add package.json solve-lock.mjs src/cli.mjs test/cli.test.mjs -git commit -m "feat: add Gothic lock solver CLI" -``` - ---- - -### Task 7: Сквозной пример и измерение предельного случая - -**Files:** -- Create: `examples/lock.example.json` -- Create: `test/e2e.test.mjs` -- Create: `test/differential.test.mjs` -- Create: `scripts/benchmark.mjs` - -**Interfaces:** -- Consumes: готовый CLI и внутреннюю тестируемую `findShortestCommands`; стабильным внешним facade остается `solveLock` из `src/index.mjs`. -- Produces: воспроизводимый пяти-пластинчатый пример и ручной benchmark без жесткого временного порога. - -- [x] **Step 1: Сначала создать только падающий сквозной тест** - -Run: - -```bash -mkdir -p examples scripts -``` - -Expected: каталоги существуют, но `examples/lock.example.json` еще отсутствует, поэтому следующий тест действительно будет RED. - -```js -// test/e2e.test.mjs -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { spawnSync } from 'node:child_process'; -import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const projectRoot = dirname(dirname(fileURLToPath(import.meta.url))); - -test('example lock produces the documented four-command solution', () => { - const result = spawnSync( - process.execPath, - [join(projectRoot, 'solve-lock.mjs'), join(projectRoot, 'examples/lock.example.json')], - { encoding: 'utf8' }, - ); - assert.equal(result.status, 0); - assert.equal( - result.stdout, - 'Найдено команд: 4\n' - + '1. Пластина 1 - влево x3\n' - + '2. Пластина 2 - вправо x3\n' - + '3. Пластина 4 - влево x2\n' - + '4. Пластина 5 - вправо x2\n', - ); -}); -``` - -- [x] **Step 2: Подтвердить RED до создания example-файла** - -Run: `node --test test/e2e.test.mjs` -Expected: FAIL из-за отсутствующего `examples/lock.example.json`. - -- [x] **Step 3: Добавить example-файл и получить GREEN** - -```json -{ - "state": [1, 7, 4, 2, 6], - "links": [ - [0, 0, 0, 0, 0], - [0, 0, 0, 0, 0], - [0, 0, 0, 0, 0], - [0, 0, 0, 0, 0], - [0, 0, 0, 0, 0] - ] -} -``` - -Run: `pnpm test` -Expected: все тесты PASS, включая точный четырехкомандный вывод. - -- [x] **Step 4: Добавить дифференциальную проверку BFS для всех двухпластинчатых состояний и матриц** - -```js -// test/differential.test.mjs -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { createTargetState } from '../src/state-codec.mjs'; -import { findShortestCommands } from '../src/solver.mjs'; -import { generateTransitions } from '../src/transition.mjs'; - -function referenceBfs(definition) { - const targetKey = createTargetState(definition.state.length).join(','); - const startKey = definition.state.join(','); - if (startKey === targetKey) { - return []; - } - - const queue = [{ state: definition.state, commands: [] }]; - const visited = new Set([startKey]); - for (let head = 0; head < queue.length; head += 1) { - const current = queue[head]; - for (const transition of generateTransitions(current.state, definition.links)) { - const key = transition.state.join(','); - if (visited.has(key)) { - continue; - } - const commands = [...current.commands, transition.command]; - if (key === targetKey) { - return commands; - } - visited.add(key); - queue.push({ state: transition.state, commands }); - } - } - return null; -} - -test('typed-array BFS matches reference BFS for every N=2 lock', () => { - for (const link12 of [-1, 0, 1]) { - for (const link21 of [-1, 0, 1]) { - const links = [[0, link12], [link21, 0]]; - for (let first = 1; first <= 7; first += 1) { - for (let second = 1; second <= 7; second += 1) { - const definition = { state: [first, second], links }; - assert.deepEqual( - findShortestCommands(definition), - referenceBfs(definition), - `state=${first},${second}; links=${link12},${link21}`, - ); - } - } - } - } -}); -``` - -Run: `node --test test/differential.test.mjs` -Expected: PASS для всех `9 * 49 = 441` конфигураций. - -- [x] **Step 5: Создать benchmark максимального пространства без performance-assertion** - -```js -// scripts/benchmark.mjs -import { performance } from 'node:perf_hooks'; -import { findShortestCommands } from '../src/solver.mjs'; - -const plateCount = 7; -const definition = { - state: Array.from({ length: plateCount }, () => 1), - links: Array.from( - { length: plateCount }, - () => Array.from({ length: plateCount }, () => 0), - ), -}; - -const startedAt = performance.now(); -const commands = findShortestCommands(definition); -const elapsedMs = performance.now() - startedAt; - -console.log(JSON.stringify({ - elapsedMs: Math.round(elapsedMs), - commands: commands?.length ?? null, - rssMiB: Math.round(process.memoryUsage().rss / 1024 / 1024), -}, null, 2)); - -if (commands?.length !== 7) { - process.exitCode = 1; -} -``` - -- [x] **Step 6: Выполнить функциональную и предельную проверку** - -Run: - -```bash -pnpm test -node scripts/benchmark.mjs -``` - -Expected: тесты PASS; benchmark завершается с кодом `0`, сообщает `commands: 7` и фактические `elapsedMs`/`rssMiB`. Не менять алгоритм только из-за субъективной оценки времени; сначала зафиксировать измерение. - -- [x] **Step 7: Commit** - -```bash -git add examples/lock.example.json test/e2e.test.mjs test/differential.test.mjs scripts/benchmark.mjs -git commit -m "test: verify solver end to end" -``` - ---- - -### Task 8: Проектный skill для интерактивного решения замков - -**Files:** -- Create: `.codex/skills/gothic-lock-solver/SKILL.md` -- Create: `.codex/skills/gothic-lock-solver/agents/openai.yaml` - -**Interfaces:** -- Consumes: наблюдаемое состояние и подтвержденные пользователем направленные связи. -- Produces: полный `lock.json`, запуск `solve-lock.mjs` и сгруппированную комбинацию. -- Depends on: стабильные интерфейсы CLI из Task 6 и проверенный пример из Task 7. - -- [x] **Step 1: Перед редактированием прочитать обязательные skill-инструкции** - -Read and follow полностью: - -```text -superpowers:writing-skills -skill-creator -``` - -Expected: дальнейшее создание следует их требованиям, а изменения skill будут включены в Git-коммит. - -- [x] **Step 2: Инициализировать проектный skill штатным генератором** - -Run: - -```bash -python3 /root/.codex/skills/.system/skill-creator/scripts/init_skill.py gothic-lock-solver --path .codex/skills -``` - -Expected: созданы `SKILL.md` и `agents/openai.yaml`; незаполненные scaffold-подсказки не остаются в финальном файле. - -- [x] **Step 3: Заменить `SKILL.md` точными рабочими инструкциями** - -````markdown ---- -name: gothic-lock-solver -description: Собирает модель пластинчатого замка Gothic 1 Remake и запускает проектный Node.js-решатель. Используй, когда пользователь сообщает позиции или связи пластин либо просит кратчайшую комбинацию; не применяй к обычным кодовым замкам и не угадывай неподтвержденные связи. ---- - -# Gothic Lock Solver - -Используй вычислительное ядро проекта, а не языковую модель, для поиска комбинации. - -## Правила модели - -- Пластин от 2 до 7, позиции каждой от 1 до 7, цель всегда 4. -- Влево означает `+1`, вправо означает `-1`. -- Каждая строка матрицы направленная и независимая; обратную связь не подразумевай. -- Связи не каскадируют. Диагональ матрицы равна 0. -- Если зависимая пластина не может выполнить весь сдвиг, команда заблокирована полностью. -- Не используй приложенный PDF в этом workflow. - -## Сбор входа - -1. Зафиксируй число пластин и текущий вектор слева направо. -2. Для каждой исходной пластины отдельно зафиксируй, какие другие пластины движутся вместе с ней. -3. Запиши `1` для того же направления, `-1` для противоположного, `0` только для подтвержденного отсутствия связи. -4. Не запускай решатель, пока в каждой строке не устранены неизвестные связи. -5. Создай JSON с полями `state` и `links` по схеме `docs/superpowers/specs/2026-08-31-gothic-lock-solver.md`. - -## Запуск - -Разреши корень проекта как три уровня вверх от каталога этого skill. Запусти: - -```bash -node /solve-lock.mjs -``` - -Добавляй `--output ` только когда пользователь просит файл результата. - -Передай пользователю команды без перегруппировки и с исходной нумерацией пластин: - -```text -Пластина K - влево|вправо xS -``` - -## Проверка в игре - -После каждой выполненной команды сверяй наблюдаемое состояние с рассчитанным, если пользователь сообщает его. При первом расхождении останови текущую комбинацию, зафиксируй фактический вектор и уточни только затронутую строку матрицы. Не продолжай по заведомо неверной модели. -```` - -- [x] **Step 4: Сгенерировать интерфейсные метаданные** - -Run: - -```bash -python3 /root/.codex/skills/.system/skill-creator/scripts/generate_openai_yaml.py .codex/skills/gothic-lock-solver --interface "display_name=Gothic Lock Solver" --interface "short_description=Собирает модель замка и находит кратчайшую комбинацию" --interface "default_prompt=Помоги описать и решить замок Gothic 1 Remake." -``` - -Expected: `agents/openai.yaml` описывает только этот skill и сохраняет автоматическое обнаружение. - -- [x] **Step 5: Валидировать структуру skill** - -Run: - -```bash -python3 /root/.codex/skills/.system/skill-creator/scripts/quick_validate.py .codex/skills/gothic-lock-solver -``` - -Expected: validation PASS, нет scaffold-placeholder и ошибок frontmatter. - -- [x] **Step 6: Выполнить независимый forward-test** - -Передать свежему subagent только skill и запрос: - -```text -Используй skill gothic-lock-solver из .codex/skills/gothic-lock-solver. Замок: две пластины, состояние [1, 7], связей нет. Получи комбинацию через проектный решатель. PDF не используй. -``` - -Expected: агент формирует нулевую матрицу `2x2`, запускает CLI и возвращает ровно две команды: пластина 1 влево `x3`, затем пластина 2 вправо `x3`. - -- [x] **Step 7: Зафиксировать skill в Git** - -```bash -git add .codex/skills/gothic-lock-solver -git commit -m "feat: add Gothic lock solving skill" -``` - ---- - -## Покрытие утвержденной спеки - -| Раздел спеки | Реализующая задача | -|---|---| -| §1 Цель, Node.js 20+, ESM, отсутствие зависимостей | Tasks 1 и 6 | -| §2 Состояние, направления и направленная матрица | Tasks 1 и 3 | -| §3 Атомарная команда `x1..x6`, блокировка, отсутствие каскада | Task 3 | -| §4 JSON и валидация | Tasks 1 и 6 | -| §5 Base-7 и BFS на типизированных массивах | Tasks 2, 4 и 7 | -| §5.1 Независимое ядро и CLI-адаптер | Tasks 1-6 | -| §6 Минимум макрокоманд и стабильный tie-break | Tasks 3, 4 и 7 | -| §7 CLI, overwrite и коды `0/1/2` | Task 6 | -| §8 JSON-результат, метрики и русский вывод | Tasks 5 и 6 | -| §9 Все обязательные сценарии | Tasks 1-7 | -| §10 Не-цели | Global Constraints | -| §11 Проектный skill | Task 8 | - ---- - -## Финальная проверка перед завершением реализации - -- [x] Прочитать и применить `superpowers:verification-before-completion`. -- [x] Run: `pnpm test` — Expected: весь набор PASS без пропусков. -- [x] Run: `node solve-lock.mjs examples/lock.example.json` — Expected: точные четыре сгруппированные команды из Task 7. -- [x] Run: `node scripts/benchmark.mjs` — Expected: код `0`, `commands: 7`, измерения напечатаны. -- [x] Run: `python3 /root/.codex/skills/.system/skill-creator/scripts/quick_validate.py .codex/skills/gothic-lock-solver` — Expected: PASS. -- [x] Run: `git status --short` — Expected: пустой вывод; все изменения сохранены коммитами. -- [x] Не заявлять о завершении, пока каждая команда выше не выполнена заново после последнего изменения. diff --git a/docs/superpowers/plans/2026-09-07-matrix-search.md b/docs/superpowers/plans/2026-09-07-matrix-search.md deleted file mode 100644 index 4f385f7..0000000 --- a/docs/superpowers/plans/2026-09-07-matrix-search.md +++ /dev/null @@ -1,64 +0,0 @@ -# Matrix search and benchmarks implementation plan - -> **For agentic workers:** use the task scope and interfaces below; verification evidence is mandatory. - -**Goal:** закрепить быстрый точный поиск минимума действий и воспроизводимое сравнение 45 замков в gothic-lock-solver. - -**Architecture:** браузерное ESM-ядро с точной матричной подготовкой, A* и BFS fallback. Node.js используется только для CLI, независимых проверок и создания отчёта. Матричный прототип из предыдущего исследования адаптируется к стабильному API и явным бюджетам ресурсов. - -**Tech Stack:** JavaScript ESM, BigInt, node:test; без runtime-зависимостей. - -**Spec:** ../specs/2026-09-07-matrix-search.md - -## Global Constraints - -- ES modules, Node.js 20+, без сторонних runtime-зависимостей. -- `solveLock(definition, {algorithm:'matrix-astar'|'bfs'}?)`; matrix-astar по умолчанию, прежний solved/unsolvable result. -- positions1..7, target4, directed non-cascading links, atomic blocking, each grouped command costs1. -- Вычислительное ядро не зависит от Node.js/DOM/сети. -- Исчерпание ресурсов даёт SearchLimitError, не unsolvable. -- Не менять .env, проектные skills, PDF или сторонний репозиторий. - -## Task 1: production search - -**Files:** src/matrix*.mjs, src/search*.mjs, src/solver.mjs, src/index.mjs, src/lock-definition.mjs; covering test files. Worker owns these files; result/CLI shape stays stable. - -**Interfaces:** public solveLock as above. Existing findShortestCommands remains exact BFS with previous stable command ordering; matrix path may choose another deterministic optimal sequence. Export SearchLimitError from facade. - -- [x] Add failing assertions for solveLock on independent N8; explicit BFS selection; invalid algorithm; resource exhaustion; no solution versus matrix false positive. Run red stage and record failures. -- [x] Implement exact matrix analysis, cheap legal scheduling certificate and matrix A*. Use a cheap greedy legal certificate first; bounded subset scheduling may follow, with safe mask representation. For singular matrices use bounded optimized macro BFS. -- [x] Preserve old BFS command order; precompute signed effects and base-7 offsets, calculate valid movement intervals, avoid per-edge state arrays. Validate budgets and safe encodings before allocation; bound frontier storage, not only expanded nodes. -- [x] Test the counterexample start[1,1,1] with all off-diagonal links−1 (unsolvable) and start[3,1,1] with the same links (4 actions). Test fractional unique solution and singular fallback. -- [x] Validate all 441 N2 cases and a deterministic bounded N3 sample against independent complete macro BFS; assert unit-click replay and no mutation of inputs. Existing tests remain green, except the deliberately updated model maximum and new documented mode differences. -- [x] Report files, tests, result, actual options/budget defaults, and concerns. No Git commit until root integrates independent files. - -## Task 2: catalog and evidence harness - -**Files:** benchmarks/**, scripts/benchmark.mjs, test/catalog.test.mjs, test/benchmark.test.mjs. Worker owns only these. Parent owns package.json and docs. - -**Interfaces:** call public solveLock(definition,{algorithm}); definition is {state,links}. Keep source fixtures under benchmarks/fixtures. Export a reusable independent unit-click replay helper for tests/harness. - -- [x] Import 45 entries from prior analysis catalog with provenance; preserve expected optimal counts and original competitor solutions/metrics. Record upstream SHA and data checksum. Add catalog tests against explicit BFS and default solver, including independent replay. -- [x] Preserve original Apache BFS baseline from commit a87e739a22ccc4215d6d9b14fa06b0738171cb60 with attribution and unchanged algorithm. Do not vendor AGPL solver code. -- [x] Implement deterministic benchmark CLI with repetitions, warmups, output directory and optional reference-repository path. Wrong comparator revision/hash must fail; no network required by ordinary tests. -- [x] Generate JSON and Markdown with per-lock A/U/C, min/median/max milliseconds, aggregates, runtime/source metadata and truthful comparisons. CLI writes report only after all correctness checks succeed. -- [x] Add focused tests for statistics/report generation/input validation and evidence failure handling; avoid wall-clock thresholds. Make smoke run reproducible without 45×slow timing in routine tests. -- [x] Report command and output format so parent can document/run final comparison. No Git commit. - -## Task 3: browser verification, docs and integration - -**Files:** README.md, AGENTS.md, package.json, docs/benchmarks/**, browser verification scripts/tests; parent owns these. - -- [x] Document algorithm choice, exact objective, changed deterministic tie policy, resource errors, N>=2 versus implementation capacity, browser ESM and Worker usage. -- [x] Add commands for unit tests, benchmark and browser verification. Verify actual ESM dependency graph in a browser-like restricted environment; perform a real browser import when available. -- [x] Run full tests and one repeated comparison against pinned UnlockMyLoot checkout, preserving report in docs/benchmarks. Inspect every action metric and aggregate; no claims derived from stale historical timing. -- [x] Obtain independent code review with spec and full diff. Address material findings and rerun only covering checks before final full gate. -- [x] Commit and push the feature to user's GitHub repository; return reviewable branch/PR and measured outcomes. Keep upstream submission as a separate step after our project records the result. - -## Verification notes - -- Independent reviews of the production search and benchmark harness found no material defects. -- Added a reproducible classic-script distribution for the requested no-build upstream variant; source synchronization and isolated execution are tested. -- A real browser attempt could not load the workspace HTTP server (`net::ERR_BLOCKED_BY_CLIENT`). ESM and classic-script VM checks passed, but actual browser/Worker verification is not claimed. - -- Final own-project test gate: 113/113 passed. Published core tree matches the tested local tree exactly. Full45 benchmark at clean published commit `6b1cfbc13bcec68f609d45d5dc97504dd84f1760` passed; report stored in `docs/benchmarks/run-20260907T070258001Z-f78dff13`. diff --git a/docs/superpowers/plans/2026-09-07-simplify-library.md b/docs/superpowers/plans/2026-09-07-simplify-library.md new file mode 100644 index 0000000..3679c30 --- /dev/null +++ b/docs/superpowers/plans/2026-09-07-simplify-library.md @@ -0,0 +1,58 @@ +# Readability and delivery simplification + +The owner requested a complete readability correction across `src/` and `.github/`. +The prior directory cleanup did not make the implementation sufficiently clear. +This is the one active plan; generated measurements and review notes stay outside Git. + +## Boundaries + +Keep the flat ten-file source layout, two build entries, public tuple contract, +optional validated configuration, CLI behavior and all five distribution files. +Keep exact mathematics, deterministic command ordering, search budget semantics, +all 45 fixed inputs, strict TypeScript and every required consumer environment. +No source copies, tracked reports, extra service or new abstraction framework. + +Domain constants describe the fixed lock mechanics; they are not new config fields. +Ordinary counters and binary arithmetic do not need decorative constants. +Necessary matrix/search loops remain explicit, with short, cohesive bodies. + +## Source + +- [x] Review every source file, including small API, type and error modules. +- [x] Replace opaque internal identifiers with domain names. +- [x] Name position bounds, target, encoding units, storage byte sizes and sentinels. +- [x] Separate matrix construction, pivot operations and result classification. +- [x] Make search strategy selection, state expansion and path relaxation readable. +- [x] Keep dense/sparse predecessor storage details out of BFS traversal. +- [x] Express CLI IO and configuration validation as clear ordered operations. +- [x] Preserve all 45 exact command paths and resource-limit behavior. + +## Delivery + +- [x] Review and simplify every script under `.github/scripts/`. +- [x] Give preparation, publication and verification explicit responsibilities. +- [x] Remove compressed multi-statement lines and deeply nested delivery branches. +- [x] Preserve one verified archive, source identity, preceding-release baseline, + performance acceptance and idempotent recovery from partial publication. +- [x] Verify helpers locally before editing workflow YAML. +- [x] Use one `release.yml` workflow for PR checks, main publication and manual canary. +- [x] Consolidate verification into one readonly job, including Node 26 source tests. +- [x] Use a separate npm delivery job with fresh trusted-main preparation and OIDC; + verify npm/CDN bytes and real browser imports before the GitHub release job. +- [x] Keep GitHub write permissions separate; candidate/fork code never becomes + privileged publication tooling. +- [x] Preserve existing checks and canary behavior; do not remove features silently. + +## Verification and review + +- [x] Enforce supported Oxlint block-depth limits for source and delivery scripts. +- [x] Run strict types/lint, existing behavior/oracle/config/CLI tests and coverage. +- [x] Build and verify the same archive on Node 22/24/26 and real browser engines. +- [x] Run the 45-input benchmark after competing heavy processes stop. +- [x] Adapt and validate workflow YAML only after those local gates finish. +- [x] Independently review every source/delivery file for maintainability, not only + correctness; resolve concrete findings before updating the draft PR. +- [ ] Update the existing PR and inspect hosted CI on its exact commit. + +No merge or package publication is part of this correction. Hosted evidence and +final execution status are linked from the draft PR, rather than copied into Git. diff --git a/docs/superpowers/specs/2026-08-31-gothic-lock-solver.md b/docs/superpowers/specs/2026-08-31-gothic-lock-solver.md deleted file mode 100644 index 77fb13a..0000000 --- a/docs/superpowers/specs/2026-08-31-gothic-lock-solver.md +++ /dev/null @@ -1,360 +0,0 @@ -# Решатель замков Gothic 1 Remake - -## Спецификация 1.0 - -Дата: 2026-08-31 -Статус: утверждена пользователем и реализована. - -Историческая спецификация версии 1.0. Изменения алгоритма, размера модели, равных решений и доказательного набора описаны в [спецификации от 2026-09-07](2026-09-07-matrix-search.md), которая имеет приоритет для новой версии. - -## 1. Цель - -Создать детерминированный решатель замков Gothic 1 Remake без использования искусственного интеллекта. - -Первая версия - CLI-утилита на Node.js. Она получает описание замка из JSON-файла, ищет выигрышную комбинацию и по умолчанию выводит ее в консоль. По флагу результат записывается в JSON-файл. - -Технические ограничения первой версии: - -- обычный JavaScript в формате ES modules (`.mjs`); -- Node.js 20 или новее; -- отсутствие сторонних runtime-зависимостей; -- тесты на встроенном `node:test`. - -В дальнейшем вычислительное ядро должно быть пригодно для использования на сайте. - -## 2. Модель замка - -### 2.1. Пластины и позиции - -- Количество пластин `N`: от 2 до 7. -- Каждая пластина имеет ровно 7 положений: от `1` до `7`. -- `1` - крайнее левое положение. -- `7` - крайнее правое положение. -- Целевое положение каждой пластины - `4`. -- Состояние замка - упорядоченный вектор длины `N`. - -Пример состояния пяти пластин: - -```json -[7, 4, 1, 3, 6] -``` - -Целевое состояние: - -```json -[4, 4, 4, 4, 4] -``` - -### 2.2. Направления - -- Движение влево увеличивает номер позиции: `+1`. -- Движение вправо уменьшает номер позиции: `-1`. - -Например, пластина в позиции `3` после одного деления влево окажется в позиции `4`. - -### 2.3. Матрица связей - -Связи задаются направленной квадратной матрицей `N x N`. - -Допустимые значения: - -- `0` - связи нет; -- `1` - зависимая пластина движется в том же направлении; -- `-1` - зависимая пластина движется в противоположном направлении. - -Строка `i` описывает только эффект ручного движения пластины `i` на остальные пластины. - -Матрица не обязана быть симметричной. Связь `i -> j` не означает наличие связи `j -> i`. - -Диагональ всегда равна нулю: - -```text -links[i][i] = 0 -``` - -Движение выбранной пластины учитывается отдельно и не кодируется связью с самой собой. - -Пример: - -```json -[ - [0, 1, -1], - [0, 0, 0], - [1, 0, 0] -] -``` - -При ручном движении первой пластины: - -- первая движется в выбранном направлении; -- вторая движется в том же направлении; -- третья движется в противоположном направлении. - -Движение второй пластины не затрагивает остальные. Движение третьей затрагивает первую, но не обязано повторять связи первой строки. - -## 3. Правило перехода - -Одна пользовательская команда имеет вид: - -```text -Пластина K - направление xS -``` - -где `K` - номер выбранной пластины, а `S` - количество делений от 1 до 6. - -Команда рассчитывается атомарно: - -1. Выбираются исходная пластина, направление и количество делений. -2. Исходная пластина сдвигается на `S` делений. -3. Все ненулевые связи из строки исходной пластины применяются один раз с тем же множителем `S`. -4. Связанные пластины не запускают собственные строки связей. Каскада и рекурсии нет. -5. Проверяются границы всех затронутых пластин. - -Если хотя бы одна затронутая пластина должна выйти за диапазон `1..7`: - -- весь переход считается заблокированным; -- ни одна пластина не перемещается; -- исходное состояние остается неизменным. - -Заблокированный переход не включается в найденную комбинацию. Решатель генерирует только полностью допустимые команды; частичное выполнение до первой блокировки не считается отдельным переходом. - -## 4. Формат входного файла - -Предварительно согласованный формат: - -```json -{ - "state": [7, 4, 1, 3, 6], - "links": [ - [0, 1, 0, 0, -1], - [0, 0, 0, 0, 0], - [1, 0, 0, 1, 0], - [0, 0, 0, 0, 1], - [0, -1, 0, 0, 0] - ] -} -``` - -Обязательная валидация: - -- `state` - массив длины от 2 до 7; -- каждое состояние - целое число от 1 до 7; -- `links` - квадратная матрица того же размера; -- каждый коэффициент - только `-1`, `0` или `1`; -- каждый диагональный коэффициент - `0`. - -Некорректный вход должен завершать программу с понятной ошибкой и ненулевым кодом выхода. - -## 5. Алгоритм - -Выбран поиск в ширину по конечному графу состояний. - -Это именно граф, а не обычное дерево: одно состояние может быть получено разными последовательностями, а каждый успешный переход обратим противоположным движением той же исходной пластины. Повторно посещать уже обработанное состояние нельзя. - -Поиск в ширину выбран потому, что он: - -- гарантирует кратчайшее решение согласно окончательно выбранной стоимости перехода; -- определяет, что цель недостижима; -- имеет предсказуемые верхние границы; -- не требует эвристики или очереди с приоритетом. - -Количество возможных состояний: - -```text -S = 7^N -``` - -При `N = 7`: - -```text -S = 823 543 -``` - -Для одной пластины из любого состояния существует не более шести возможных ненулевых сдвигов суммарно по обоим направлениям. Поэтому: - -- не более `6N = 42` допустимых команд-кандидатов из состояния; -- не более `34 588 806` сгенерированных переходов по всему пространству при `N = 7`; -- не более примерно `242,2 млн` простых проверок координат при наивной проверке каждой команды; -- ориентировочно `7-8 МБ` служебной памяти при числовом кодировании состояний и использовании типизированных массивов. - -Временная сложность в худшем случае: - -```text -O(N^2 * 7^N) -``` - -Память: - -```text -O(7^N) -``` - -Рекомендуемое внутреннее представление состояния - одно целое число, кодирующее позиции пластин в системе счисления по основанию 7. - -Двунаправленный поиск, A*, Dijkstra и IDDFS не входят в первую версию. Их можно пересмотреть только после измерения обычного поиска в ширину на предельных случаях. - -### 5.1. Компоненты вычислительного ядра - -Решение разделяется на независимые модули: - -1. Загрузка и валидация входного JSON. -2. Кодирование и декодирование состояния числом в системе счисления по основанию 7. -3. Чистая функция применения одной команды к состоянию. -4. Генератор всех допустимых команд из состояния. -5. Поиск в ширину с таблицами посещенных состояний, родителей и команд. -6. Восстановление последовательности команд. -7. Формирование JSON-результата и человекочитаемого консольного вывода. -8. CLI-оболочка для аргументов, кодов выхода и записи файла. - -Вычислительное ядро не должно зависеть от CLI или файловой системы, чтобы позднее его можно было перенести на сайт. - -## 6. Выбор среди нескольких решений - -Стоимость одного перехода равна одной пользовательской команде независимо от количества делений `S`. - -Главный критерий оптимальности: - -```text -минимальное количество команд -``` - -Это напрямую минимизирует количество переключений между пластинами. Две соседние команды для одной пластины всегда можно объединить или сократить, поэтому они не могут входить в минимальное решение как отдельные команды. - -Одна команда сразу отображается в сгруппированном виде: - -```text -Пластина 3 - влево x5 -``` - -Если существует несколько решений с одинаковым минимальным количеством команд, поиск использует стабильный порядок: - -1. меньший номер пластины; -2. движение влево перед движением вправо; -3. большее количество делений перед меньшим. - -FIFO-очередь поиска в ширину и такой порядок генерации переходов дают детерминированно первое решение среди равных. - -## 7. Интерфейс командной строки - -Входной файл обязателен: - -```bash -node solve-lock.mjs lock.json -``` - -По умолчанию комбинация выводится в консоль. - -Для записи результата в файл: - -```bash -node solve-lock.mjs lock.json --output solution.json -``` - -Правила `--output`: - -- отсутствующий файл создается; -- существующий файл полностью перезаписывается; -- без флага никакой выходной файл не создается. -- с флагом JSON-файл записывается дополнительно, а человекочитаемый результат по-прежнему выводится в консоль. - -Коды завершения: - -- `0` - решение найдено или замок уже находится в целевом состоянии; -- `1` - ошибка входных данных, аргументов CLI, чтения или записи; -- `2` - корректный замок не имеет решения из указанного состояния. - -## 8. Структура результата - -JSON-результат должен позволять проверить решение программно и показать его человеку: - -- `metrics.commands` - количество пользовательских команд; -- `metrics.divisions` - сумма `steps` выбранных пластин без дополнительного подсчета движений связанных пластин; -- `metrics.plateSwitches` - количество смен выбранной пластины между соседними командами. - -```json -{ - "status": "solved", - "initialState": [7, 4, 1], - "targetState": [4, 4, 4], - "commands": [ - { - "plate": 1, - "direction": "right", - "steps": 3 - } - ], - "finalState": [4, 4, 4], - "metrics": { - "commands": 1, - "divisions": 3, - "plateSwitches": 0 - } -} -``` - -Если решения нет: - -```json -{ - "status": "unsolvable", - "initialState": [1, 1, 1], - "targetState": [4, 4, 4], - "commands": [] -} -``` - -Консольное представление найденного решения: - -```text -Найдено команд: 2 -1. Пластина 1 - влево x3 -2. Пластина 3 - вправо x2 -``` - -Для уже открытого замка выводится `Замок уже открыт.`. Для недостижимой цели выводится `Решение не найдено.`. - -## 9. Требования к тестированию - -Минимальный набор сценариев: - -1. Начальное состояние уже является целевым. -2. Независимые пластины без связей. -3. Прямая связь `1`. -4. Инвертированная связь `-1`. -5. Несимметричные направленные связи. -6. Отсутствие каскадного применения связей. -7. Полная блокировка атомарного перехода на границе. -8. Обнаружение недостижимой цели. -9. Гарантия минимального количества сгруппированных команд. -10. Команда со сдвигом сразу на несколько делений. -11. Блокировка всей команды, если связанная пластина не может пройти все запрошенные деления. -12. Правило выбора среди нескольких равных решений. -13. Вывод в консоль. -14. Создание нового выходного файла. -15. Перезапись существующего выходного файла. -16. Ошибки валидации входного JSON. - -## 10. Границы первой версии - -В первую версию не входят: - -- сайт или графический интерфейс; -- автоматическое распознавание состояния по изображению; -- автоматическое определение связей пластин; -- искусственный интеллект; -- база известных игровых замков; -- анализ приложенного PDF; -- оптимизация алгоритма без подтвержденной проблемы производительности. - -## 11. Проектный skill - -После реализации и проверки решателя должен быть создан проектный skill, который зафиксирует: - -- как интерактивно собирать исходное состояние замка; -- как проверять направленные связи каждой пластины; -- как переводить наблюдения в `lock.json`; -- как запускать решатель; -- как проверять найденную комбинацию в игре; -- как уточнять модель, если фактический переход отличается от ожидаемого. - -Skill создается после реализации и проверки интерфейсов, чтобы его инструкции соответствовали фактическому поведению утилиты. diff --git a/docs/superpowers/specs/2026-09-07-matrix-search.md b/docs/superpowers/specs/2026-09-07-matrix-search.md deleted file mode 100644 index 5442d8f..0000000 --- a/docs/superpowers/specs/2026-09-07-matrix-search.md +++ /dev/null @@ -1,38 +0,0 @@ -# Матричный поиск и воспроизводимое сравнение - -Дата: 2026-09-07. Основание: пользователь попросил закрепить проверенный матричный алгоритм в собственном проекте, добавить 45 замков в проверки и бенчмарки, формировать доказательный отчёт, после этого готовить перенос в UnlockMyLoot. Отдельный сайт в эту работу не входит. - -Эта спецификация обновляет пункты о размере, алгоритме, критерии равенства и наборе данных спецификации 1.0. Остальные правила механики и формат команд сохраняются. - -## Контракт - -- ES modules, Node.js 20+ для CLI и проверок, без сторонних runtime-зависимостей. -- Вычислительные модули не используют Node.js API, DOM, файловую систему или сеть. Они пригодны для импорта в браузер и Worker. -- По уточнению пользователя добавляется готовый classic-script файл для подключения через обычный ``; - const syntheticSource = { revision: 'synthetic', solver: { - sha256: sha256(html), blockSha256: sha256(block), - startMarker: '/* solver-start', endMarker: '/* solver-end */', - } }; - assert.equal(verifyReferenceSource({ revision: 'synthetic', html }, syntheticSource), block); - assert.throws(() => verifyReferenceSource({ revision: 'synthetic', html }, { - ...syntheticSource, solver: { ...syntheticSource.solver, blockSha256: 'wrong' }, - }), /block hash/i); -}); - -test('benchmark records samples, counts, scope, and separates cached reference observations', () => { - const report = runBenchmark(options()); - assert.equal(report.correctness.passed, true); - assert.equal(report.config.lockCount, 1); - assert.equal(report.config.scope, 'subset'); - assert.match(report.config.inputSha256, /^[0-9a-f]{64}$/); - assert.equal(report.results[0].solvers['matrix-astar'].A, 1); - assert.deepEqual(report.results[0].solvers.bfs.timingMs, { min: 1, median: 1, max: 1 }); - assert.deepEqual(report.results[0].solvers.bfs.samplesMs, [1, 1]); - assert.equal(report.results[0].historicalReference.kind, 'cached-observation'); - assert.equal(report.comparisons.historicalReference.timingCompared, false); - assert.equal(report.comparisons.bfs.actions.ties, 1); - const markdown = renderMarkdown(report); - assert.match(markdown, /A = actions/); - assert.match(markdown, /cached/i); - assert.match(markdown, /Test lock/); - assert.match(markdown, /subset/i); -}); - -test('timing comparisons and speedup ratios follow measured evidence in both directions', () => { - for (const matrixElapsed of [1, 2, 4]) { - let time = 0; - const setup = options(); setup.warmups = 0; setup.clock = () => time; - for (const [name, implementation] of Object.entries(setup.implementations)) { - implementation.solve = () => { - time += name === 'matrix-astar' ? matrixElapsed : 2; - return { status: 'solved', commands }; - }; - } - const result = runBenchmark(setup).comparisons.bfs.timing; - assert.equal(result[matrixElapsed < 2 ? 'wins' : matrixElapsed > 2 ? 'losses' : 'ties'], 1); - assert.equal(result.geometricMeanSpeedup, 2 / matrixElapsed); - assert.equal(result.ratioOfSummedMedians, 2 / matrixElapsed); - } -}); - -test('benchmark validates warmups and every measured solution outside timing', () => { - for (const failAt of [1, 2, 3]) { - const setup = options(); let calls = 0; - setup.implementations['matrix-astar'] = makeSolver(() => { - calls += 1; - return calls === failAt ? { status: 'solved', commands: [] } : { status: 'solved', commands }; - }); - assert.throws(() => runBenchmark(setup), /target|minimum/i); - } -}); - -test('benchmark rejects wrong optimum, mutation, nondeterminism, and reference metric drift', () => { - const wrongMinimum = options(); wrongMinimum.locks[0].expectedActions = 2; - assert.throws(() => runBenchmark(wrongMinimum), /minimum/i); - const mutation = options(); - mutation.implementations.bfs = makeSolver((value) => { - value.links[1][0] = 1; return { status: 'solved', commands }; - }); - assert.throws(() => runBenchmark(mutation), /mutat/i); - const drift = options(); - drift.implementations.reference = { ...makeSolver(), requiresOptimality: false }; - drift.locks[0].historicalReference.C = 2; - assert.throws(() => runBenchmark(drift), /reference|historical/i); - const changedPath = options(); let calls = 0; - changedPath.implementations.reference = { ...makeSolver(() => { - calls += 1; - return { status: 'solved', commands: calls === 1 ? commands : [ - { plate: 1, direction: 'left', steps: 2 }, - { plate: 1, direction: 'right', steps: 1 }, - ] }; - }), requiresOptimality: false }; - assert.throws(() => runBenchmark(changedPath), /determin|reference/i); -}); - -test('report pair is published together; correctness failure leaves existing evidence intact', async () => { - const root = await mkdtemp(join(tmpdir(), 'lock-benchmark-')); - try { - await writeFile(join(root, 'existing.txt'), 'keep'); - const paths = await publishReport(runBenchmark(options()), root); - assert.equal(JSON.parse(await readFile(paths.jsonPath, 'utf8')).correctness.passed, true); - assert.match(await readFile(paths.markdownPath, 'utf8'), /Test lock/); - assert.deepEqual((await readdir(paths.directory)).sort(), ['benchmark.json', 'benchmark.md']); - const before = (await readdir(root)).sort(); - const bad = options(); bad.locks[0].expectedActions = 99; - await assert.rejects(runAndPublish({ ...bad, outputDir: root }), /minimum/i); - assert.deepEqual((await readdir(root)).sort(), before); - assert.equal(await readFile(join(root, 'existing.txt'), 'utf8'), 'keep'); - await assert.rejects(publishReport({ correctness: { passed: false } }, root), /correctness/i); - } finally { - await rm(root, { recursive: true, force: true }); - } -}); diff --git a/test/browser-bundle.test.mjs b/test/browser-bundle.test.mjs deleted file mode 100644 index 8a24ff1..0000000 --- a/test/browser-bundle.test.mjs +++ /dev/null @@ -1,121 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { existsSync } from 'node:fs'; -import { readFile, mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { spawnSync } from 'node:child_process'; -import vm from 'node:vm'; -import { solveLock } from '../src/index.mjs'; - -const root = fileURLToPath(new URL('../', import.meta.url)); -const builderPath = join(root, 'scripts/build-browser.mjs'); -const artifactPath = join(root, 'dist/gothic-lock-solver.js'); -const independent = (state) => ({ state, links: state.map(() => state.map(() => 0)) }); -const normalize = (value) => JSON.parse(JSON.stringify(value)); - -async function builder() { - assert.ok(existsSync(builderPath), 'the reproducible classic-script builder must exist'); - return import('../scripts/build-browser.mjs'); -} - -test('ready browser artifact is reproducible and the CLI verifies source synchronization', async () => { - assert.ok(existsSync(builderPath), 'the reproducible classic-script builder must exist'); - const result = spawnSync(process.execPath, [builderPath, '--check'], { encoding: 'utf8', cwd: tmpdir() }); - assert.equal(result.status, 0, result.stderr); - const { buildBrowserBundle } = await builder(); - const first = await readFile(artifactPath, 'utf8'); - assert.equal(await buildBrowserBundle({ check: true }), first); - assert.equal(await buildBrowserBundle({ check: true }), first); - assert.match(first, /SPDX-License-Identifier: Apache-2\.0/); - assert.match(first, /Apache License[\s\S]+Version 2\.0, January 2004/); - assert.match(first, /gothic-lock-solver/); -}); - -test('classic script loads without module, DOM, Node, network, or dynamic-code facilities', async () => { - assert.ok(existsSync(artifactPath), 'a ready classic-script artifact must be checked in'); - const source = await readFile(artifactPath, 'utf8'); - const context = vm.createContext({ untouched: 123 }, { codeGeneration: { strings: false, wasm: false } }); - new vm.Script(source, { filename: 'gothic-lock-solver.js' }).runInContext(context, { timeout: 5000 }); - assert.deepEqual(Object.keys(context).sort(), ['GothicLockSolver', 'untouched']); - assert.equal(context.untouched, 123); - assert.equal(vm.runInContext('typeof process + ":" + typeof require + ":" + typeof document + ":" + typeof fetch', context), 'undefined:undefined:undefined:undefined'); - assert.deepEqual(Object.keys(context.GothicLockSolver).sort(), ['SearchLimitError', 'applyCommand', 'generateTransitions', 'solveLock']); - assert.equal(Object.isFrozen(context.GothicLockSolver), true); - const definitions = [ - independent([1, 7, 4, 2, 6]), - independent([1, 7, 2, 6, 3, 5, 4, 1]), - { state: [3, 1, 1], links: [[0, -1, -1], [-1, 0, -1], [-1, -1, 0]] }, - { state: [1, 1, 1], links: [[0, -1, -1], [-1, 0, -1], [-1, -1, 0]] }, - { state: [1, 1], links: [[0, 1], [1, 0]] }, - ]; - for (const definition of definitions) { - const before = structuredClone(definition); - assert.deepEqual(normalize(context.GothicLockSolver.solveLock(definition)), solveLock(definition)); - assert.deepEqual(definition, before); - } - const definition = independent([1, 7]); - assert.deepEqual(normalize(context.GothicLockSolver.solveLock(definition, { algorithm: 'bfs' })), solveLock(definition, { algorithm: 'bfs' })); - assert.throws(() => context.GothicLockSolver.solveLock(definition, { algorithm: 'bfs', maxExpanded: 0 }), - (error) => error instanceof context.GothicLockSolver.SearchLimitError && error.limit === 'maxExpanded'); - const otherContext = vm.createContext({}, { codeGeneration: { strings: false, wasm: false } }); - new vm.Script(source).runInContext(otherContext, { timeout: 5000 }); - assert.notEqual(otherContext.GothicLockSolver, context.GothicLockSolver); - assert.notEqual(otherContext.GothicLockSolver.SearchLimitError, context.GothicLockSolver.SearchLimitError); - assert.deepEqual(normalize(otherContext.GothicLockSolver.solveLock(definition)), solveLock(definition)); -}); - -test('builder rejects unsupported imports and exports before replacing an artifact', async () => { - const { buildBrowserBundle, BROWSER_MODULES } = await builder(); - const temporary = await mkdtemp(join(tmpdir(), 'gothic-browser-builder-')); - try { - await mkdir(join(temporary, 'src')); - for (const name of BROWSER_MODULES) { - await writeFile(join(temporary, name), await readFile(join(root, name))); - } - await writeFile(join(temporary, 'LICENSE'), await readFile(join(root, 'LICENSE'))); - const original = await buildBrowserBundle({ root: temporary }); - assert.equal(original, await readFile(artifactPath, 'utf8'), 'generation must not depend on the checkout path'); - const target = join(temporary, 'src/index.mjs'); - const source = await readFile(target, 'utf8'); - for (const unsupported of [ - "import ignored from './transition.mjs';", - "import { readFile } from 'node:fs';", - "import { x } from './not-allowlisted.mjs';", - "export * from './transition.mjs';", - 'export default solveLock;', - "const lazy = () => import('./transition.mjs');", - 'const address = import.meta.url;', - ]) { - await writeFile(target, `${source}\n${unsupported}\n`); - await assert.rejects(() => buildBrowserBundle({ root: temporary }), /Unsupported|unsupported|allowlist/); - assert.equal(await readFile(join(temporary, 'dist/gothic-lock-solver.js'), 'utf8'), original); - } - } finally { - await rm(temporary, { recursive: true, force: true }); - } -}); - -test('check mode fails on stale source or changed artifact without writing', async () => { - const { buildBrowserBundle, BROWSER_MODULES } = await builder(); - const temporary = await mkdtemp(join(tmpdir(), 'gothic-browser-sync-')); - try { - await mkdir(join(temporary, 'src')); - for (const name of BROWSER_MODULES) await writeFile(join(temporary, name), await readFile(join(root, name))); - await writeFile(join(temporary, 'LICENSE'), await readFile(join(root, 'LICENSE'))); - await buildBrowserBundle({ root: temporary }); - const artifact = join(temporary, 'dist/gothic-lock-solver.js'); - const original = await readFile(artifact, 'utf8'); - const source = join(temporary, 'src/index.mjs'); - await writeFile(source, `${await readFile(source, 'utf8')}\n// source synchronization fixture\n`); - await assert.rejects(() => buildBrowserBundle({ root: temporary, check: true }), /out of date/); - assert.equal(await readFile(artifact, 'utf8'), original); - await buildBrowserBundle({ root: temporary }); - await writeFile(artifact, 'stale artifact'); - await assert.rejects(() => buildBrowserBundle({ root: temporary, check: true }), /out of date/); - assert.equal(await readFile(artifact, 'utf8'), 'stale artifact'); - } finally { - await rm(temporary, { recursive: true, force: true }); - } -}); diff --git a/test/browser-smoke.html b/test/browser-smoke.html deleted file mode 100644 index e0bcc88..0000000 --- a/test/browser-smoke.html +++ /dev/null @@ -1,40 +0,0 @@ - - - -Gothic solver browser verification -

Browser ESM and Worker verification

-
Running…
- - diff --git a/test/catalog.test.mjs b/test/catalog.test.mjs deleted file mode 100644 index 9544f81..0000000 --- a/test/catalog.test.mjs +++ /dev/null @@ -1,45 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { solveLock } from '../src/index.mjs'; -import { loadCatalog, loadManifest } from '../benchmarks/catalog.mjs'; -import { commandMetrics, replayCommands } from '../benchmarks/validation.mjs'; - -const catalog = loadCatalog(); - -test('catalog preserves 45 named inputs and pinned source provenance', () => { - assert.equal(catalog.length, 45); - assert.equal(new Set(catalog.map((lock) => lock.id)).size, 45); - assert.equal(loadManifest().source.revision, 'eee0bf50ebcb7fffd2b47954fd76bb015854e365'); - for (const lock of catalog) { - assert.ok(lock.code && lock.title && lock.url && lock.provenance.catalog); - assert.equal(lock.provenance.commit, loadManifest().source.revision); - assert.ok(Number.isInteger(lock.expectedActions)); - assert.equal(lock.historicalReference.kind, 'cached-observation'); - const historic = commandMetrics(lock.historicalReference.commands); - assert.equal(historic.A, lock.historicalReference.A, lock.id); - assert.equal(historic.U, lock.historicalReference.U, lock.id); - assert.equal(historic.C, lock.historicalReference.C, lock.id); - assert.deepEqual(replayCommands(lock.definition, lock.historicalReference.commands), - lock.definition.state.map(() => 4), lock.id); - } -}); - -for (const lock of catalog) { - test(`catalog ${lock.id}: default and exact BFS agree on minimum actions`, () => { - const before = structuredClone(lock.definition); - const matrix = solveLock(lock.definition); - const bfs = solveLock(lock.definition, { algorithm: 'bfs' }); - assert.equal(matrix.status, 'solved', lock.id); - assert.equal(bfs.status, 'solved', lock.id); - assert.equal(matrix.commands.length, lock.expectedActions, lock.id); - assert.equal(bfs.commands.length, lock.expectedActions, lock.id); - assert.deepEqual(bfs.commands, lock.baselineCommands, `BFS ordering: ${lock.id}`); - for (const result of [matrix, bfs]) { - assert.deepEqual(replayCommands(lock.definition, result.commands), - lock.definition.state.map(() => 4), lock.id); - assert.equal(result.metrics.commands, commandMetrics(result.commands).A); - assert.equal(result.metrics.divisions, commandMetrics(result.commands).C); - } - assert.deepEqual(lock.definition, before, `input mutation: ${lock.id}`); - }); -} diff --git a/test/cli.test.mjs b/test/cli.test.mjs deleted file mode 100644 index e9f14a4..0000000 --- a/test/cli.test.mjs +++ /dev/null @@ -1,160 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { spawnSync } from 'node:child_process'; -import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const projectRoot = dirname(dirname(fileURLToPath(import.meta.url))); -const entry = join(projectRoot, 'solve-lock.mjs'); - -async function createCase(t, definition) { - const directory = await mkdtemp(join(tmpdir(), 'gothic-lock-solver-')); - t.after(() => rm(directory, { recursive: true, force: true })); - const inputPath = join(directory, 'lock.json'); - await writeFile(inputPath, JSON.stringify(definition), 'utf8'); - return { directory, inputPath }; -} - -function run(args) { - return spawnSync(process.execPath, [entry, ...args], { encoding: 'utf8' }); -} - -const expectedSolutionJson = `{ - "status": "solved", - "initialState": [ - 1, - 7 - ], - "targetState": [ - 4, - 4 - ], - "commands": [ - { - "plate": 1, - "direction": "left", - "steps": 3 - }, - { - "plate": 2, - "direction": "right", - "steps": 3 - } - ], - "finalState": [ - 4, - 4 - ], - "metrics": { - "commands": 2, - "divisions": 6, - "plateSwitches": 1 - } -} -`; - -test('prints a solution without creating an output file by default', async (t) => { - const { directory, inputPath } = await createCase(t, { - state: [1, 7], - links: [[0, 0], [0, 0]], - }); - const outputPath = join(directory, 'solution.json'); - const result = run([inputPath]); - assert.equal(result.status, 0); - assert.match(result.stdout, /Найдено команд: 2/); - assert.equal(result.stderr, ''); - await assert.rejects(readFile(outputPath, 'utf8'), { code: 'ENOENT' }); -}); - -test('--output creates and then overwrites exact pretty JSON bytes', async (t) => { - const { directory, inputPath } = await createCase(t, { - state: [1, 7], - links: [[0, 0], [0, 0]], - }); - const outputPath = join(directory, 'solution.json'); - const created = run([inputPath, '--output', outputPath]); - assert.equal(created.status, 0); - assert.equal(created.stderr, ''); - assert.equal(await readFile(outputPath, 'utf8'), expectedSolutionJson); - - await writeFile(outputPath, 'stale'.repeat(1_000), 'utf8'); - const overwritten = run([inputPath, '--output', outputPath]); - assert.equal(overwritten.status, 0); - assert.match(overwritten.stdout, /Пластина 1 - влево x3/); - assert.equal(overwritten.stderr, ''); - assert.equal(await readFile(outputPath, 'utf8'), expectedSolutionJson); -}); - -test('invalid JSON exits with code 1 and a Russian error', async (t) => { - const { inputPath } = await createCase(t, { - state: [4, 4], - links: [[0, 0], [0, 0]], - }); - await writeFile(inputPath, '{', 'utf8'); - const result = run([inputPath]); - assert.equal(result.status, 1); - assert.equal(result.stdout, ''); - assert.match(result.stderr, /Ошибка: Некорректный JSON/); -}); - -test('an unsolvable lock writes JSON and exits with code 2', async (t) => { - const { directory, inputPath } = await createCase(t, { - state: [1, 7], - links: [[0, 1], [1, 0]], - }); - const outputPath = join(directory, 'solution.json'); - const result = run([inputPath, '--output', outputPath]); - assert.equal(result.status, 2); - assert.match(result.stdout, /Решение не найдено/); - assert.equal(result.stderr, ''); - assert.equal(JSON.parse(await readFile(outputPath, 'utf8')).status, 'unsolvable'); -}); - -test('read errors exit with code 1 and keep stdout empty', async (t) => { - const { directory, inputPath } = await createCase(t, { - state: [4, 4], - links: [[0, 0], [0, 0]], - }); - - const missingInput = run([join(directory, 'missing.json')]); - assert.equal(missingInput.status, 1); - assert.equal(missingInput.stdout, ''); - assert.match(missingInput.stderr, /Ошибка:/); -}); - -test('strict CLI grammar errors exit with code 1 and keep stdout empty', async (t) => { - const { inputPath, directory } = await createCase(t, { - state: [4, 4], - links: [[0, 0], [0, 0]], - }); - - for (const args of [ - [], - ['--unknown'], - ['--output'], - [''], - [inputPath, '--unknown'], - [inputPath, '--output'], - [inputPath, '--output', ''], - [inputPath, 'extra'], - [inputPath, '--output', join(directory, 'solution.json'), 'extra'], - ]) { - const invalidArgs = run(args); - assert.equal(invalidArgs.status, 1); - assert.equal(invalidArgs.stdout, ''); - assert.match(invalidArgs.stderr, /Ошибка: Использование:/); - } -}); - -test('write errors exit with code 1 and keep stdout empty', async (t) => { - const { directory, inputPath } = await createCase(t, { - state: [4, 4], - links: [[0, 0], [0, 0]], - }); - const writeError = run([inputPath, '--output', directory]); - assert.equal(writeError.status, 1); - assert.equal(writeError.stdout, ''); - assert.match(writeError.stderr, /Ошибка:/); -}); diff --git a/test/differential.test.mjs b/test/differential.test.mjs deleted file mode 100644 index 60ace08..0000000 --- a/test/differential.test.mjs +++ /dev/null @@ -1,50 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { createTargetState } from '../src/state-codec.mjs'; -import { findShortestCommands } from '../src/solver.mjs'; -import { generateTransitions } from '../src/transition.mjs'; - -function referenceBfs(definition) { - const targetKey = createTargetState(definition.state.length).join(','); - const startKey = definition.state.join(','); - if (startKey === targetKey) { - return []; - } - - const queue = [{ state: definition.state, commands: [] }]; - const visited = new Set([startKey]); - for (let head = 0; head < queue.length; head += 1) { - const current = queue[head]; - for (const transition of generateTransitions(current.state, definition.links)) { - const key = transition.state.join(','); - if (visited.has(key)) { - continue; - } - const commands = [...current.commands, transition.command]; - if (key === targetKey) { - return commands; - } - visited.add(key); - queue.push({ state: transition.state, commands }); - } - } - return null; -} - -test('typed-array BFS matches reference BFS for every N=2 lock', () => { - for (const link12 of [-1, 0, 1]) { - for (const link21 of [-1, 0, 1]) { - const links = [[0, link12], [link21, 0]]; - for (let first = 1; first <= 7; first += 1) { - for (let second = 1; second <= 7; second += 1) { - const definition = { state: [first, second], links }; - assert.deepEqual( - findShortestCommands(definition), - referenceBfs(definition), - `state=${first},${second}; links=${link12},${link21}`, - ); - } - } - } - } -}); diff --git a/test/e2e.test.mjs b/test/e2e.test.mjs deleted file mode 100644 index e0af50d..0000000 --- a/test/e2e.test.mjs +++ /dev/null @@ -1,24 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { spawnSync } from 'node:child_process'; -import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const projectRoot = dirname(dirname(fileURLToPath(import.meta.url))); - -test('example lock produces the documented four-command solution', () => { - const result = spawnSync( - process.execPath, - [join(projectRoot, 'solve-lock.mjs'), join(projectRoot, 'examples/lock.example.json')], - { encoding: 'utf8' }, - ); - assert.equal(result.status, 0); - assert.equal( - result.stdout, - 'Найдено команд: 4\n' - + '1. Пластина 1 - влево x3\n' - + '2. Пластина 2 - вправо x3\n' - + '3. Пластина 4 - влево x2\n' - + '4. Пластина 5 - вправо x2\n', - ); -}); diff --git a/test/lock-definition.test.mjs b/test/lock-definition.test.mjs deleted file mode 100644 index c364797..0000000 --- a/test/lock-definition.test.mjs +++ /dev/null @@ -1,62 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { - LockInputError, - parseLockDefinition, - validateLockDefinition, -} from '../src/lock-definition.mjs'; - -const validLock = { - state: [7, 4, 1], - links: [ - [0, 1, -1], - [0, 0, 0], - [1, 0, 0], - ], -}; - -test('validateLockDefinition accepts and copies a valid directed lock', () => { - const result = validateLockDefinition(validLock); - assert.deepEqual(result, validLock); - assert.notEqual(result.state, validLock.state); - assert.notEqual(result.links, validLock.links); - for (const index of validLock.links.keys()) { - assert.notEqual(result.links[index], validLock.links[index]); - } - assert.notEqual(result.links[0], validLock.links[0]); -}); - -test('validateLockDefinition ignores additional input properties', () => { - const result = validateLockDefinition({ ...validLock, name: 'ignored', metadata: { source: 'test' } }); - assert.deepEqual(result, validLock); - assert.equal('name' in result, false); - assert.equal('metadata' in result, false); -}); - -for (const [name, value, message] of [ - ['requires at least two plates', { state: [4], links: [[0]] }, /не менее 2/], - ['rejects a position outside 1..7', { ...validLock, state: [7, 8, 1] }, /state\[1\]/], - ['requires a square matrix', { ...validLock, links: [[0], [0], [0]] }, /links\[0\]/], - ['rejects an unknown coefficient', { ...validLock, links: [[0, 2, 0], [0, 0, 0], [0, 0, 0]] }, /-1, 0 или 1/], - ['requires a zero diagonal', { ...validLock, links: [[1, 0, 0], [0, 0, 0], [0, 0, 0]] }, /диагонали/], -]) { - test(name, () => { - assert.throws(() => validateLockDefinition(value), message); - }); -} - -test('rejects sparse state arrays, matrix rows, and matrix entries', () => { - const cases = [ - { state: [4, , 4], links: validLock.links }, - { state: validLock.state, links: [validLock.links[0], , validLock.links[2]] }, - { state: validLock.state, links: [[0, , -1], validLock.links[1], validLock.links[2]] }, - ]; - for (const definition of cases) assert.throws(() => validateLockDefinition(definition), LockInputError); -}); - -test('parseLockDefinition reports malformed JSON as LockInputError', () => { - assert.throws( - () => parseLockDefinition('{'), - (error) => error instanceof LockInputError && /Некорректный JSON/.test(error.message), - ); -}); diff --git a/test/matrix-search.test.mjs b/test/matrix-search.test.mjs deleted file mode 100644 index 1d10a47..0000000 --- a/test/matrix-search.test.mjs +++ /dev/null @@ -1,197 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import * as api from '../src/index.mjs'; -import { findShortestCommands } from '../src/solver.mjs'; -import { validateLockDefinition } from '../src/lock-definition.mjs'; - -const independent = (state) => ({ state, links: state.map(() => state.map(() => 0)) }); -const inverseLinks = [[0, -1, -1], [-1, 0, -1], [-1, -1, 0]]; - -// This oracle intentionally imports no production transition/encoding helpers. -function unitMove(state, links, plate, direction) { - const next = [...state]; - next[plate] += direction; - for (let target = 0; target < state.length; target += 1) { - if (target !== plate) next[target] += direction * links[plate][target]; - } - return next.some((position) => position < 1 || position > 7) ? null : next; -} - -function replay(definition, commands) { - let state = [...definition.state]; - for (const command of commands) { - assert.ok(Number.isInteger(command.plate) && command.plate >= 1 && command.plate <= state.length); - assert.ok(command.direction === 'left' || command.direction === 'right'); - assert.ok(Number.isInteger(command.steps) && command.steps >= 1 && command.steps <= 6); - for (let click = 0; click < command.steps; click += 1) { - state = unitMove(state, definition.links, command.plate - 1, command.direction === 'left' ? 1 : -1); - assert.notEqual(state, null, 'each individual division must be legal'); - } - } - assert.deepEqual(state, state.map(() => 4)); -} - -function referenceActions(definition) { - const queue = [{ state: [...definition.state], depth: 0 }]; - const visited = new Set([definition.state.join(',')]); - for (let head = 0; head < queue.length; head += 1) { - const { state, depth } = queue[head]; - if (state.every((position) => position === 4)) return depth; - for (let plate = 0; plate < state.length; plate += 1) { - for (const direction of [1, -1]) { - let next = state; - for (let steps = 1; steps <= 6; steps += 1) { - next = unitMove(next, definition.links, plate, direction); - if (next === null) break; - const key = next.join(','); - if (visited.has(key)) continue; - visited.add(key); - queue.push({ state: next, depth: depth + 1 }); - } - } - } - } - return null; -} - -test('default matrix solver accepts eight independent plates and keeps result shape', () => { - const definition = independent([1, 7, 2, 6, 3, 5, 4, 1]); - const before = structuredClone(definition); - const result = api.solveLock(definition); - assert.equal(result.status, 'solved'); - assert.equal(result.metrics.commands, 7); - assert.deepEqual(Object.keys(result), ['status', 'initialState', 'targetState', 'commands', 'finalState', 'metrics']); - replay(definition, result.commands); - assert.deepEqual(definition, before); -}); - -test('explicit BFS keeps previous deterministic command ordering', () => { - const definition = { state: [2, 7, 1], links: [[0, 0, 0], [-1, 0, -1], [-1, -1, 0]] }; - assert.deepEqual(api.solveLock(definition, { algorithm: 'bfs' }).commands, [ - { plate: 1, direction: 'left', steps: 5 }, - { plate: 3, direction: 'left', steps: 3 }, - ]); - assert.deepEqual(api.solveLock(definition, { algorithm: 'bfs' }).commands, findShortestCommands(definition)); -}); - -test('rejects unknown algorithms and invalid resource budgets even on an open lock', () => { - const definition = independent([4, 4]); - assert.throws(() => api.solveLock(definition, { algorithm: 'greedy' }), /algorithm/); - for (const value of [null, [], 'bfs']) assert.throws(() => api.solveLock(definition, value), TypeError); - for (const name of ['maxExpanded', 'maxVisited', 'maxFrontier', 'maxDenseBytes']) { - for (const value of [-1, NaN, Infinity, 0.5, Number.MAX_SAFE_INTEGER + 1, '20']) { - assert.throws(() => api.solveLock(definition, { [name]: value }), TypeError, `${name}=${value}`); - } - } -}); - -test('a safe model size is separate from safe computational representation', () => { - const definition = independent(Array(19).fill(4)); - assert.deepEqual(validateLockDefinition(definition), definition); - for (const algorithm of ['matrix-astar', 'bfs']) { - assert.throws(() => api.solveLock(definition, { algorithm }), (error) => error.name === 'SearchLimitError' && error.limit === 'stateEncoding'); - } -}); - -test('matrix equation is necessary but a legal schedule remains required', () => { - assert.equal(api.solveLock({ state: [1, 1, 1], links: inverseLinks }).status, 'unsolvable'); - const definition = { state: [3, 1, 1], links: inverseLinks }; - const result = api.solveLock(definition); - assert.equal(result.metrics.commands, 4, 'three nonzero net movements require a repeated plate here'); - assert.ok(new Set(result.commands.map((command) => command.plate)).size < result.commands.length); - replay(definition, result.commands); - assert.deepEqual(api.solveLock(definition), result, 'matrix tie selection is deterministic'); -}); - -test('fractional and inconsistent equations prove unreachable targets', () => { - for (const links of [[[0, 1], [-1, 0]], [[0, 1], [1, 0]]]) { - assert.equal(api.solveLock({ state: [3, 4], links }).status, 'unsolvable'); - } -}); - -test('singular matrices fall back to exact action BFS', () => { - const definition = { state: [1, 1], links: [[0, 1], [1, 0]] }; - assert.deepEqual(api.solveLock(definition).commands, [{ plate: 1, direction: 'left', steps: 3 }]); -}); - -test('every graph-search route throws SearchLimitError rather than unsolvable on exhaustion', () => { - assert.equal(typeof api.SearchLimitError, 'function'); - for (const [definition, algorithm] of [ - [independent([1, 7]), 'bfs'], - [{ state: [3, 1, 1], links: inverseLinks }, 'matrix-astar'], - [{ state: [1, 1], links: [[0, 1], [1, 0]] }, 'matrix-astar'], - ]) { - for (const [limit, value] of [['maxExpanded', 0], ['maxVisited', 1], ['maxFrontier', 1]]) { - assert.throws(() => api.solveLock(definition, { algorithm, [limit]: value }), - (error) => error instanceof api.SearchLimitError && error.limit === limit, - `${algorithm} ${limit}`); - } - } - assert.throws(() => findShortestCommands(independent([1, 7]), { maxExpanded: 0 }), (error) => error.name === 'SearchLimitError'); -}); - -test('zero dense-memory budget selects sparse BFS without changing the answer', () => { - const definition = { state: [3, 2, 1], links: [[0, 0, -1], [-1, 0, -1], [-1, -1, 0]] }; - assert.deepEqual(api.solveLock(definition, { algorithm: 'bfs', maxDenseBytes: 0 }), api.solveLock(definition, { algorithm: 'bfs' })); -}); - -test('greedy certificates respect visited and expanded budgets', () => { - const definition = independent([1, 7]); - for (const [limit, value] of [['maxExpanded', 1], ['maxVisited', 2]]) { - assert.throws(() => api.solveLock(definition, { [limit]: value }), (error) => error instanceof api.SearchLimitError && error.limit === limit); - } - assert.equal(api.solveLock(definition, { maxExpanded: 2, maxVisited: 3, maxFrontier: 1 }).metrics.commands, 2); - assert.equal(api.solveLock(independent([4, 4]), { maxExpanded: 0, maxVisited: 1 }).metrics.commands, 0); -}); - -test('safe state codes beyond 32 bits survive sparse BFS and matrix search', () => { - const definition = independent([1, ...Array(17).fill(4)]); - for (const algorithm of ['bfs', 'matrix-astar']) { - const result = api.solveLock(definition, { algorithm, maxDenseBytes: 0, maxVisited: 5, maxExpanded: 1, maxFrontier: 4 }); - assert.deepEqual(result.commands, [{ plate: 1, direction: 'left', steps: 3 }]); - replay(definition, result.commands); - } -}); - -test('A* handles net movement larger than one command without overestimating actions', () => { - // A z = [-3,-3,3] has unique integer z=[-3,-6,12], so at least four - // commands are necessary even though only three plates must be selected. - const definition = { state: [7, 7, 1], links: [[0, -1, 1], [0, 0, 1], [0, 0, 0]] }; - const result = api.solveLock(definition); - assert.equal(referenceActions(definition), 4); - assert.equal(result.metrics.commands, 4); - replay(definition, result.commands); -}); - -test('new matrix solver matches independent complete search for all 441 two-plate cases', () => { - for (const firstLink of [-1, 0, 1]) { - for (const secondLink of [-1, 0, 1]) { - for (let first = 1; first <= 7; first += 1) { - for (let second = 1; second <= 7; second += 1) { - const definition = { state: [first, second], links: [[0, firstLink], [secondLink, 0]] }; - const expected = referenceActions(definition); - const actual = api.solveLock(definition); - assert.equal(actual.status === 'solved' ? actual.commands.length : null, expected, JSON.stringify(definition)); - if (expected !== null) replay(definition, actual.commands); - } - } - } - } -}); - -test('matrix and both BFS storage modes match independent complete search on 192 seeded N3 inputs', () => { - let seed = 0x51405; - const random = (max) => { seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0; return seed % max; }; - for (let sample = 0; sample < 192; sample += 1) { - const definition = { - state: Array.from({ length: 3 }, () => 1 + random(7)), - links: Array.from({ length: 3 }, (_, row) => Array.from({ length: 3 }, (_, column) => row === column ? 0 : random(3) - 1)), - }; - const expected = referenceActions(definition); - for (const options of [{}, { algorithm: 'bfs' }, { algorithm: 'bfs', maxDenseBytes: 0 }]) { - const actual = api.solveLock(definition, options); - assert.equal(actual.status === 'solved' ? actual.commands.length : null, expected, JSON.stringify({ definition, options })); - if (expected !== null) replay(definition, actual.commands); - } - } -}); diff --git a/test/result.test.mjs b/test/result.test.mjs deleted file mode 100644 index fe4e4e4..0000000 --- a/test/result.test.mjs +++ /dev/null @@ -1,54 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { solveLock } from '../src/index.mjs'; -import { createSolveResult, formatConsoleResult } from '../src/result.mjs'; - -const definition = { - state: [1, 7], - links: [[0, 0], [0, 0]], -}; -const commands = [ - { plate: 1, direction: 'left', steps: 3 }, - { plate: 2, direction: 'right', steps: 3 }, -]; - -test('createSolveResult replays commands and calculates metrics', () => { - const result = createSolveResult(definition, commands); - assert.deepEqual(result.finalState, [4, 4]); - assert.deepEqual(result.metrics, { - commands: 2, - divisions: 6, - plateSwitches: 1, - }); -}); - -test('formatConsoleResult renders grouped Russian commands', () => { - const result = createSolveResult(definition, commands); - assert.equal( - formatConsoleResult(result), - 'Найдено команд: 2\n' - + '1. Пластина 1 - влево x3\n' - + '2. Пластина 2 - вправо x3', - ); -}); - -test('already-open and unsolvable results have distinct text', () => { - const open = createSolveResult({ state: [4, 4], links: definition.links }, []); - const unsolvable = createSolveResult(definition, null); - assert.equal(formatConsoleResult(open), 'Замок уже открыт.'); - assert.equal(formatConsoleResult(unsolvable), 'Решение не найдено.'); - assert.deepEqual(unsolvable.commands, []); -}); - -test('already-open result exposes independent initial and final states', () => { - const result = createSolveResult({ state: [4, 4], links: definition.links }, []); - assert.notStrictEqual(result.initialState, result.finalState); - result.finalState[0] = 1; - assert.deepEqual(result.initialState, [4, 4]); -}); - -test('solveLock validates and solves through the website-safe facade', () => { - const result = solveLock(definition); - assert.equal(result.status, 'solved'); - assert.deepEqual(result.finalState, [4, 4]); -}); diff --git a/test/solver.test.mjs b/test/solver.test.mjs deleted file mode 100644 index 810968d..0000000 --- a/test/solver.test.mjs +++ /dev/null @@ -1,85 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { - findShortestCommands, - packCommand, - unpackCommand, -} from '../src/solver.mjs'; - -const noLinks = [[0, 0], [0, 0]]; - -test('packs every possible command into one byte and restores it', () => { - for (let plate = 1; plate <= 7; plate += 1) { - for (const direction of ['left', 'right']) { - for (let steps = 1; steps <= 6; steps += 1) { - const command = { plate, direction, steps }; - const packed = packCommand(command); - assert.ok(packed >= 0 && packed <= 83); - assert.deepEqual(unpackCommand(packed), command); - } - } - } -}); - -test('returns no commands for an already open lock', () => { - assert.deepEqual( - findShortestCommands({ state: [4, 4], links: noLinks }), - [], - ); -}); - -test('uses one macro command per independent plate', () => { - assert.deepEqual( - findShortestCommands({ state: [1, 7], links: noLinks }), - [ - { plate: 1, direction: 'left', steps: 3 }, - { plate: 2, direction: 'right', steps: 3 }, - ], - ); -}); - -test('prefers the lower plate when two one-command solutions exist', () => { - const links = [[0, 1], [1, 0]]; - assert.deepEqual( - findShortestCommands({ state: [1, 1], links }), - [{ plate: 1, direction: 'left', steps: 3 }], - ); -}); - -test('returns null when every possible move is blocked', () => { - const links = [[0, 1], [1, 0]]; - assert.equal(findShortestCommands({ state: [1, 7], links }), null); -}); - -test('prefers left over right when both start equally short solutions', () => { - const definition = { - state: [2, 7, 1], - links: [ - [0, 0, 0], - [-1, 0, -1], - [-1, -1, 0], - ], - }; - assert.deepEqual(findShortestCommands(definition), [ - { plate: 1, direction: 'left', steps: 5 }, - { plate: 3, direction: 'left', steps: 3 }, - ]); -}); - -test('prefers larger steps when earlier action fields are equal', () => { - const definition = { - state: [3, 2, 1], - links: [ - [0, 0, -1], - [-1, 0, -1], - [-1, -1, 0], - ], - }; - assert.deepEqual(findShortestCommands(definition), [ - { plate: 1, direction: 'right', steps: 2 }, - { plate: 3, direction: 'right', steps: 2 }, - { plate: 2, direction: 'right', steps: 2 }, - { plate: 1, direction: 'right', steps: 3 }, - { plate: 3, direction: 'right', steps: 2 }, - ]); -}); diff --git a/test/state-codec.test.mjs b/test/state-codec.test.mjs deleted file mode 100644 index f898793..0000000 --- a/test/state-codec.test.mjs +++ /dev/null @@ -1,23 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { - createTargetState, - decodeState, - encodeState, - stateSpaceSize, -} from '../src/state-codec.mjs'; - -test('base-7 encoding has stable boundary values', () => { - assert.equal(encodeState([1, 1]), 0); - assert.equal(encodeState([7, 7]), 48); - assert.equal(stateSpaceSize(7), 823_543); -}); - -test('encodeState and decodeState round-trip a seven-plate state', () => { - const state = [7, 4, 1, 3, 6, 2, 5]; - assert.deepEqual(decodeState(encodeState(state), state.length), state); -}); - -test('createTargetState creates only center positions', () => { - assert.deepEqual(createTargetState(5), [4, 4, 4, 4, 4]); -}); diff --git a/test/transition.test.mjs b/test/transition.test.mjs deleted file mode 100644 index 2e73728..0000000 --- a/test/transition.test.mjs +++ /dev/null @@ -1,64 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { applyCommand, generateTransitions } from '../src/transition.mjs'; - -test('applyCommand moves direct and inverse links by the full step count', () => { - const state = [3, 5, 4]; - const links = [ - [0, 1, -1], - [0, 0, 0], - [0, 0, 0], - ]; - const result = applyCommand(state, links, { plate: 1, direction: 'left', steps: 2 }); - assert.deepEqual(result, [5, 7, 2]); - assert.deepEqual(state, [3, 5, 4]); -}); - -test('applyCommand does not cascade through a moved target row', () => { - const links = [ - [0, 1, 0], - [0, 0, 1], - [0, 0, 0], - ]; - assert.deepEqual( - applyCommand([3, 5, 4], links, { plate: 1, direction: 'left', steps: 2 }), - [5, 7, 4], - ); -}); - -test('applyCommand atomically blocks when one linked plate cannot finish', () => { - const state = [3, 6]; - const links = [[0, 1], [0, 0]]; - assert.deepEqual( - applyCommand(state, links, { plate: 1, direction: 'left', steps: 1 }), - [4, 7], - ); - assert.equal( - applyCommand(state, links, { plate: 1, direction: 'left', steps: 2 }), - null, - ); - assert.deepEqual(state, [3, 6]); -}); - -test('a directed source row does not imply a reverse link', () => { - const links = [[0, 1], [0, 0]]; - assert.deepEqual( - applyCommand([3, 5], links, { plate: 2, direction: 'right', steps: 1 }), - [3, 4], - ); -}); - -test('generateTransitions uses plate, direction, descending steps order', () => { - const transitions = generateTransitions([4, 4], [[0, 0], [0, 0]]); - assert.deepEqual( - transitions.slice(0, 6).map(({ command }) => command), - [ - { plate: 1, direction: 'left', steps: 3 }, - { plate: 1, direction: 'left', steps: 2 }, - { plate: 1, direction: 'left', steps: 1 }, - { plate: 1, direction: 'right', steps: 3 }, - { plate: 1, direction: 'right', steps: 2 }, - { plate: 1, direction: 'right', steps: 1 }, - ], - ); -}); diff --git a/test/web-core.test.mjs b/test/web-core.test.mjs deleted file mode 100644 index 1dcca2a..0000000 --- a/test/web-core.test.mjs +++ /dev/null @@ -1,12 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { execFileSync } from 'node:child_process'; -import { fileURLToPath } from 'node:url'; - -test('public core imports and solves in an ESM context without Node globals', () => { - const output = execFileSync(process.execPath, [ - '--experimental-vm-modules', - fileURLToPath(new URL('../scripts/verify-web-core.mjs', import.meta.url)), - ], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }); - assert.equal(JSON.parse(output).status, 'passed'); -}); diff --git a/tests/benchmarks/benchmark.ts b/tests/benchmarks/benchmark.ts new file mode 100644 index 0000000..43f2748 --- /dev/null +++ b/tests/benchmarks/benchmark.ts @@ -0,0 +1,2 @@ +import { main } from './cli.ts'; +try { await main(); } catch (error) { console.error(error); process.exitCode = 1; } diff --git a/tests/benchmarks/catalog.ts b/tests/benchmarks/catalog.ts new file mode 100644 index 0000000..851391c --- /dev/null +++ b/tests/benchmarks/catalog.ts @@ -0,0 +1,43 @@ +import { createHash } from 'node:crypto'; +import { execFileSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { array, integer, record, string, type Definition, type Metrics } from './validation.ts'; + +export const catalogSha256 = '902a57e0c867b7ed7513af09257fde765396593ab45738bb60967b412b4bcd69'; +export type Fixture = Definition & { id: string; expectedActions: number }; +export function sha256(value: string | Uint8Array): string { return createHash('sha256').update(value).digest('hex'); } +export function jsonFile(path: string | URL): unknown { const parsed: unknown = JSON.parse(readFileSync(path, 'utf8')); return parsed; } +export function readMetrics(value: unknown): Metrics { + const item = record(value, 'metrics'); + return { A: integer(item['A']), U: integer(item['U']), C: integer(item['C']), plateSwitches: integer(item['plateSwitches']) }; +} +export function loadCatalog(): Fixture[] { + const bytes = readFileSync(new URL('./fixtures.json', import.meta.url)); + if (sha256(bytes) !== catalogSha256) throw new Error('Fixture hash differs from the fixed 45-input pin.'); + const parsed: unknown = JSON.parse(bytes.toString('utf8')); + const locks = array(parsed, 'fixtures').map((raw, index): Fixture => { + const lock = record(raw, 'fixture'); + if (Object.keys(lock).sort().join(',') !== 'expectedActions,id,links,state') throw new Error('Unexpected fixture fields.'); + const id = string(lock['id']); + const state = array(lock['state']).map((value) => integer(value)); + const links = array(lock['links']).map((row) => array(row).map((value) => integer(value))); + const expectedActions = integer(lock['expectedActions']); + if (id !== `lock-${String(index + 1).padStart(3, '0')}` || expectedActions < 0 || state.length === 0 + || state.some((value) => value < 1 || value > 7) || links.length !== state.length + || links.some((row) => row.length !== state.length || row.some((value) => value < -1 || value > 1))) throw new Error('Invalid fixed fixture.'); + return { id, state, links, expectedActions }; + }); + if (locks.length !== 45) throw new Error('Expected 45 unique fixed inputs.'); + return locks; +} +export function git(directory: string, args: readonly string[]): string { + return execFileSync('git', ['-C', directory, ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim(); +} + +export function assertArtifactOutput(path: string): void { + const root = fileURLToPath(new URL('../../', import.meta.url)); + const local = relative(root, resolve(path)); + if (local !== 'artifacts' && !local.startsWith('artifacts/')) throw new Error('Benchmark output must be under the ignored artifacts directory.'); +} diff --git a/tests/benchmarks/cli.ts b/tests/benchmarks/cli.ts new file mode 100644 index 0000000..435b1bb --- /dev/null +++ b/tests/benchmarks/cli.ts @@ -0,0 +1,93 @@ +import { readFileSync, statSync } from 'node:fs'; +import os from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { assertArtifactOutput, catalogSha256, git, loadCatalog, sha256 } from './catalog.ts'; +import { loadImplementation, publishReport, readSnapshot, runComparison, type ModuleFormat, type Snapshot } from './harness.ts'; +import { collectMemory, type MemorySpec } from './memory.ts'; +import { combineVerdicts } from './comparison.ts'; +import { generateSnapshot } from './snapshot.ts'; +import { hashFiles, inspectComparisonBaseline, inspectSource, revision } from './source.ts'; + +const root = fileURLToPath(new URL('../../', import.meta.url)); +export type BenchmarkOptions = { candidateModule: string; baselineModule: string; baselineSnapshot?: string; + baselineFormat: ModuleFormat; outputDir: string; warmups: number; repetitions: number; + memoryRepetitions: number; smoke: boolean; skipMemory: boolean; help: boolean }; +export function parseBenchmarkArgs(args: readonly string[]): BenchmarkOptions { + const options: BenchmarkOptions = { candidateModule: join(root, 'dist/gothic-lock-solver.mjs'), baselineModule: '', baselineFormat: 'tuple', + outputDir: join(root, 'artifacts/benchmark'), warmups: 2, repetitions: 7, memoryRepetitions: 5, smoke: false, skipMemory: false, help: false }; + const valued = ['--candidate-module', '--baseline-module', '--baseline-snapshot', '--baseline-format', '--output-dir', '--warmups', '--repetitions', '--memory-repetitions']; + const seen = new Set(); + for (let index = 0; index < args.length; index += 1) { + const argument = args[index]; if (argument === undefined) throw new Error('Missing argument.'); + if (seen.has(argument)) throw new Error(`Duplicate argument: ${argument}`); seen.add(argument); + if (argument === '--smoke') { options.smoke = true; continue; } + if (argument === '--skip-memory') { options.skipMemory = true; continue; } + if (argument === '--help') { options.help = true; continue; } + if (!valued.includes(argument)) throw new Error(`Unknown argument: ${argument}`); + const value = args[++index]; if (value === undefined || value === '' || value.startsWith('--')) throw new Error(`Missing argument value: ${argument}`); + switch (argument) { + case '--candidate-module': options.candidateModule = resolve(value); break; + case '--baseline-module': options.baselineModule = resolve(value); break; + case '--baseline-snapshot': options.baselineSnapshot = resolve(value); break; + case '--baseline-format': if (value !== 'tuple' && value !== 'legacy') throw new Error('Baseline format must be tuple or legacy.'); options.baselineFormat = value; break; + case '--output-dir': options.outputDir = resolve(value); break; + case '--warmups': case '--repetitions': case '--memory-repetitions': { + if (!/^(0|[1-9][0-9]*)$/.test(value)) throw new Error('Expected a nonnegative integer.'); + const count = Number(value); const minimum = argument === '--warmups' ? 0 : 1; + if (!Number.isSafeInteger(count) || count < minimum) throw new Error('Invalid repetition/warmup integer.'); + if (argument === '--warmups') options.warmups = count; + else if (argument === '--repetitions') options.repetitions = count; + else options.memoryRepetitions = count; + break; + } + } + } + if (!options.help && !seen.has('--baseline-module')) throw new Error('Benchmark requires explicit --baseline-module from an own target checkout or verified previous release.'); + return options; +} +export { assertArtifactOutput } from './catalog.ts'; +export function collectMetadata(options: BenchmarkOptions, baselineSnapshot: Snapshot) { + const baselineSource = inspectComparisonBaseline(options.candidateModule, options.baselineModule, options.baselineFormat, baselineSnapshot.source); + const runtimeFiles = ['gothic-lock-solver.mjs', 'gothic-lock-solver.min.mjs', 'gothic-lock-solver.js', 'gothic-lock-solver.min.js', 'gothic-lock-solver.cli.mjs']; + const artifacts = Object.fromEntries(runtimeFiles.map((name) => { + const path = join(root, 'dist', name); return [name, { bytes: statSync(path).size, sha256: sha256(readFileSync(path)) }]; + })); + return { environment: { node: process.version, v8: process.versions.v8, platform: process.platform, arch: process.arch, + cpu: os.cpus()[0]?.model ?? 'unknown', logicalCpus: os.cpus().length, osRelease: os.release(), totalMemoryBytes: os.totalmem(), execArgv: process.execArgv }, + source: { revision: revision(root), dirty: git(root, ['status', '--porcelain', '--untracked-files=normal']).length > 0, + files: hashFiles(join(root, 'src'), (path) => path.endsWith('.ts')), harness: hashFiles(join(root, 'tests/benchmarks'), (path) => path.endsWith('.ts')) }, + candidate: { revision: revision(dirname(options.candidateModule)), moduleSha256: sha256(readFileSync(options.candidateModule)), format: 'tuple', module: options.candidateModule }, + baseline: { ...baselineSource, module: options.baselineModule, + snapshotSha256: sha256(`${JSON.stringify(baselineSnapshot, null, 2)}\n`), snapshot: options.baselineSnapshot ?? 'baseline-snapshot.json', kind: baselineSource['kind'] ?? 'own-target-checkout' }, + fixtures: { count: 45, catalogSha256 }, artifacts }; +} +export async function main(): Promise { + const options = parseBenchmarkArgs(process.argv.slice(2)); + if (options.help) { + console.log('Usage: pnpm benchmark --baseline-module PATH [--baseline-format tuple|legacy] [--baseline-snapshot PATH] [--output-dir artifacts/benchmark] [--warmups 2] [--repetitions 7] [--memory-repetitions 5] [--smoke] [--skip-memory]'); + console.log('The baseline snapshot is generated from the supplied own checkout when omitted. A verified previous release supplies its module and snapshot. Candidate defaults to the built ESM; --candidate-module PATH overrides it.'); + return; + } + assertArtifactOutput(options.outputDir); + const stored = options.baselineSnapshot === undefined ? undefined : readSnapshot(options.baselineSnapshot); + inspectComparisonBaseline(options.candidateModule, options.baselineModule, options.baselineFormat, stored?.source); + const snapshot = stored ?? await generateSnapshot(options.baselineModule, options.baselineFormat, 'Fresh execution of the supplied own baseline on the fixed inputs.'); + const metadata = collectMetadata(options, snapshot); + const candidate = await loadImplementation(options.candidateModule, 'tuple', 'Candidate TypeScript library'); + const baseline = await loadImplementation(options.baselineModule, options.baselineFormat, 'Own target or preceding-release baseline'); + const memorySpecs: MemorySpec[] = [ { name: 'candidate', module: options.candidateModule, format: 'tuple' }, + { name: 'baseline', module: options.baselineModule, format: options.baselineFormat } ]; + const catalog = loadCatalog(); const locks = options.smoke ? catalog.slice(0, 2) : catalog; + const report = runComparison({ locks, candidate, baseline, baselineSnapshot: snapshot, repetitions: options.repetitions, warmups: options.warmups, + onProgress: (completed, total, id) => process.stderr.write(`[${completed}/${total}] ${id}: replay and optimum checked\n`) }); + const memory = options.skipMemory ? { performance: { verdict: 'inconclusive' as const }, available: false, reason: 'Memory explicitly skipped; no memory pass is established.', browser: { available: false } } + : collectMemory(locks, memorySpecs, options.memoryRepetitions, (line) => process.stderr.write(`${line}\n`)); + inspectSource(options.baselineModule, options.baselineFormat, snapshot.source); + if (sha256(readFileSync(options.candidateModule)) !== metadata.candidate.moduleSha256) throw new Error('Candidate source changed during measurement.'); + report.performance = combineVerdicts([report.performance, memory.performance.verdict]); + const paths = await publishReport(report, options.outputDir, metadata, memory, snapshot); + console.log(JSON.stringify({ ...paths, lockCount: locks.length, quality: report.quality, performance: report.performance }, null, 2)); + // Uncertain observations remain evidence for review; deterministic failures and confirmed regressions fail CI. + if (report.quality === 'regression' || report.performance === 'regression') process.exitCode = 1; +} diff --git a/tests/benchmarks/compare.ts b/tests/benchmarks/compare.ts new file mode 100644 index 0000000..43f2748 --- /dev/null +++ b/tests/benchmarks/compare.ts @@ -0,0 +1,2 @@ +import { main } from './cli.ts'; +try { await main(); } catch (error) { console.error(error); process.exitCode = 1; } diff --git a/tests/benchmarks/comparison.ts b/tests/benchmarks/comparison.ts new file mode 100644 index 0000000..352c8e6 --- /dev/null +++ b/tests/benchmarks/comparison.ts @@ -0,0 +1,53 @@ +import type { Metrics } from './validation.ts'; +export type Verdict = 'passed' | 'regression' | 'inconclusive'; +export const metricNames = ['A', 'U', 'C', 'plateSwitches'] as const; + +export function compareMetrics(candidate: Metrics, baseline: Metrics): { quality: 'passed' | 'regression'; deltas: Metrics; regressions: string[] } { + const deltas = { A: candidate.A - baseline.A, U: candidate.U - baseline.U, + C: candidate.C - baseline.C, plateSwitches: candidate.plateSwitches - baseline.plateSwitches }; + const regressions = metricNames.filter((name) => deltas[name] > 0); + return { quality: regressions.length === 0 ? 'passed' : 'regression', deltas, regressions }; +} +export function statistics(samples: readonly number[]): { min: number; median: number; p95: number; max: number } { + if (samples.length === 0 || samples.some((sample) => !Number.isFinite(sample) || sample < 0)) throw new Error('Invalid timing samples.'); + const sorted = [...samples].sort((a, b) => a - b); + const first = sorted[0]; const last = sorted.at(-1); + const middle = Math.floor(sorted.length / 2); + const high = sorted[middle]; const low = sorted[Math.max(0, middle - 1)]; + const p95 = sorted[Math.ceil(sorted.length * 0.95) - 1]; + if (first === undefined || last === undefined || high === undefined || low === undefined || p95 === undefined) throw new Error('Missing sample.'); + return { min: first, median: sorted.length % 2 === 0 ? (low + high) / 2 : high, p95, max: last }; +} +function interval(samples: readonly number[]): { mean: number; lower: number; upper: number } { + const mean = samples.reduce((sum, sample) => sum + sample, 0) / samples.length; + const variance = samples.reduce((sum, sample) => sum + (sample - mean) ** 2, 0) / Math.max(1, samples.length - 1); + // Conservative t bound >= two-sided 95% t critical for n >= 5. No claimed exact CI. + const radius = 2.776 * Math.sqrt(variance / samples.length); + return { mean, lower: mean - radius, upper: mean + radius }; +} +export function performanceVerdict(candidate: readonly number[], baseline: readonly number[], selfA: readonly number[], selfB: readonly number[]) { + const missing = { verdict: 'inconclusive' as Verdict, reason: 'At least five matched positive samples and unchanged-vs-itself calibration are required.', + ratio: null, noiseRatio: null, lowerRatio: null, upperRatio: null }; + if (candidate.length < 5 || candidate.length !== baseline.length || selfA.length < 5 || selfA.length !== selfB.length + || [...candidate, ...baseline, ...selfA, ...selfB].some((sample) => !Number.isFinite(sample) || sample <= 0)) return missing; + const logs = (a: readonly number[], b: readonly number[]): number[] => a.map((value, index) => { + const other = b[index]; if (other === undefined) throw new Error('Unpaired sample.'); return Math.log(value / other); + }); + const self = interval(logs(selfA, selfB)); + const measured = interval(logs(candidate, baseline)); + const noise = Math.max(Math.abs(self.lower), Math.abs(self.upper)); + // A noisy control cannot certify equivalence. A clear slowdown still remains a regression. + const verdict: Verdict = measured.lower > noise ? 'regression' + : noise <= Math.log(1.25) && measured.upper <= noise ? 'passed' : 'inconclusive'; + return { verdict, reason: verdict === 'regression' ? 'Matched slowdown exceeds the unchanged-control uncertainty envelope.' + : verdict === 'passed' ? 'Measured upper slowdown bound lies within the calibrated control envelope.' + : 'Measurement overlaps the control envelope or unchanged-control noise exceeds 25%; no performance pass is established.', + ratio: Math.exp(measured.mean), noiseRatio: Math.exp(noise), lowerRatio: Math.exp(measured.lower), upperRatio: Math.exp(measured.upper) }; +} +export function combineVerdicts(values: readonly Verdict[]): Verdict { + return values.includes('regression') ? 'regression' : values.includes('inconclusive') ? 'inconclusive' : 'passed'; +} +export function confirmPerformance(first: Verdict, second: Verdict): Verdict { + // A bounded repeat never erases the first signal. Conflicting observations are uncertainty. + return first === second ? first : 'inconclusive'; +} diff --git a/tests/benchmarks/fixtures.json b/tests/benchmarks/fixtures.json new file mode 100644 index 0000000..830d6fc --- /dev/null +++ b/tests/benchmarks/fixtures.json @@ -0,0 +1,2580 @@ +[ + { + "id": "lock-001", + "state": [ + 5, + 6, + 2, + 2, + 1, + 1 + ], + "links": [ + [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + -1, + 1, + 1, + 0 + ], + [ + 0, + 0, + 0, + 1, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + -1, + 1, + -1, + 0, + 0, + -1 + ], + [ + -1, + 1, + 0, + 0, + 0, + 0 + ] + ], + "expectedActions": 17 + }, + { + "id": "lock-002", + "state": [ + 6, + 3, + 2, + 1, + 7 + ], + "links": [ + [ + 0, + 0, + 0, + 1, + 0 + ], + [ + 0, + 0, + 0, + 0, + 1 + ], + [ + 0, + 0, + 0, + 0, + -1 + ], + [ + 0, + 1, + -1, + 0, + -1 + ], + [ + 0, + 0, + 0, + 0, + 0 + ] + ], + "expectedActions": 9 + }, + { + "id": "lock-003", + "state": [ + 7, + 7, + 6, + 1 + ], + "links": [ + [ + 0, + 0, + 0, + 1 + ], + [ + 1, + 0, + 0, + 1 + ], + [ + 1, + -1, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0 + ] + ], + "expectedActions": 5 + }, + { + "id": "lock-004", + "state": [ + 6, + 6, + 5, + 7, + 1, + 4 + ], + "links": [ + [ + 0, + 0, + 1, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + -1, + 0, + 0, + -1, + 0 + ], + [ + -1, + 0, + 1, + 0, + 0, + 0 + ], + [ + 0, + -1, + 0, + 1, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0 + ] + ], + "expectedActions": 14 + }, + { + "id": "lock-005", + "state": [ + 1, + 5, + 1, + 7, + 2, + 6 + ], + "links": [ + [ + 0, + 0, + -1, + 0, + 0, + 0 + ], + [ + 0, + 0, + 1, + -1, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 1, + 0, + -1, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + -1 + ], + [ + 0, + -1, + 0, + 0, + 1, + 0 + ] + ], + "expectedActions": 11 + }, + { + "id": "lock-006", + "state": [ + 3, + 7, + 2, + 6, + 5 + ], + "links": [ + [ + 0, + -1, + -1, + -1, + -1 + ], + [ + -1, + 0, + 0, + 0, + 0 + ], + [ + -1, + -1, + 0, + 0, + 0 + ], + [ + 0, + 0, + 1, + 0, + 0 + ], + [ + 1, + 0, + 0, + 0, + 0 + ] + ], + "expectedActions": 9 + }, + { + "id": "lock-007", + "state": [ + 2, + 1, + 5, + 4, + 3, + 1 + ], + "links": [ + [ + 0, + 0, + 0, + -1, + 0, + 0 + ], + [ + -1, + 0, + 1, + -1, + -1, + 1 + ], + [ + 0, + 1, + 0, + 0, + 0, + 1 + ], + [ + 0, + 0, + 0, + 0, + 0, + -1 + ], + [ + 0, + -1, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0 + ] + ], + "expectedActions": 9 + }, + { + "id": "lock-008", + "state": [ + 1, + 3, + 1, + 1 + ], + "links": [ + [ + 0, + 0, + 0, + -1 + ], + [ + -1, + 0, + 1, + 1 + ], + [ + -1, + -1, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0 + ] + ], + "expectedActions": 5 + }, + { + "id": "lock-009", + "state": [ + 1, + 4, + 1, + 5, + 7, + 1 + ], + "links": [ + [ + 0, + -1, + 0, + 0, + 0, + 0 + ], + [ + -1, + 0, + -1, + 0, + -1, + 0 + ], + [ + -1, + 0, + 0, + 0, + 0, + -1 + ], + [ + 0, + 0, + 1, + 0, + 0, + 0 + ], + [ + 0, + 1, + -1, + 0, + 0, + -1 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0 + ] + ], + "expectedActions": 17 + }, + { + "id": "lock-010", + "state": [ + 1, + 1, + 3, + 1, + 7, + 6, + 7 + ], + "links": [ + [ + 0, + 0, + 0, + 0, + 0, + -1, + 0 + ], + [ + 0, + 0, + 1, + 0, + -1, + 0, + 1 + ], + [ + 0, + 0, + 0, + -1, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + -1, + 0, + 0 + ], + [ + 0, + 0, + 0, + -1, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 1, + 0, + 0, + 0 + ], + [ + -1, + 0, + -1, + 0, + 0, + 0, + 0 + ] + ], + "expectedActions": 7 + }, + { + "id": "lock-011", + "state": [ + 7, + 3, + 2, + 6, + 2, + 6 + ], + "links": [ + [ + 0, + -1, + -1, + -1, + -1, + -1 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + -1 + ], + [ + 0, + 0, + 0, + 0, + 1, + -1 + ], + [ + 0, + 0, + 0, + 1, + 0, + 0 + ], + [ + 0, + 0, + 0, + -1, + 0, + 0 + ] + ], + "expectedActions": 9 + }, + { + "id": "lock-012", + "state": [ + 6, + 1, + 2, + 1, + 7 + ], + "links": [ + [ + 0, + -1, + 0, + 0, + -1 + ], + [ + 0, + 0, + 0, + 0, + 1 + ], + [ + -1, + -1, + 0, + -1, + 1 + ], + [ + 0, + 0, + 0, + 0, + 0 + ], + [ + 1, + 0, + 1, + 0, + 0 + ] + ], + "expectedActions": 23 + }, + { + "id": "lock-013", + "state": [ + 3, + 1, + 7, + 1, + 2, + 2 + ], + "links": [ + [ + 0, + 0, + 0, + -1, + -1, + -1 + ], + [ + -1, + 0, + -1, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + -1, + 1, + 0 + ], + [ + 0, + 0, + 1, + 0, + 0, + 0 + ], + [ + 0, + -1, + 1, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0 + ] + ], + "expectedActions": 20 + }, + { + "id": "lock-014", + "state": [ + 7, + 6, + 4, + 7, + 2, + 7, + 5 + ], + "links": [ + [ + 0, + 0, + 0, + -1, + 0, + -1, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 1, + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 1, + 0, + -1, + 1, + 0, + 1, + -1 + ], + [ + 0, + 0, + 0, + -1, + 1, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] + ], + "expectedActions": 9 + }, + { + "id": "lock-015", + "state": [ + 7, + 7, + 7, + 7, + 2 + ], + "links": [ + [ + 0, + -1, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0 + ], + [ + -1, + -1, + 0, + 1, + 1 + ], + [ + -1, + 1, + 0, + 0, + 0 + ], + [ + 0, + -1, + 0, + 1, + 0 + ] + ], + "expectedActions": 7 + }, + { + "id": "lock-016", + "state": [ + 4, + 3, + 3, + 2, + 1, + 6 + ], + "links": [ + [ + 0, + 0, + 0, + 1, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + -1, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 1 + ], + [ + 0, + -1, + 0, + -1, + 0, + 0 + ], + [ + 0, + 0, + 0, + -1, + 1, + 0 + ] + ], + "expectedActions": 11 + }, + { + "id": "lock-017", + "state": [ + 2, + 6, + 5, + 6, + 5, + 7 + ], + "links": [ + [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 1, + 0, + 0, + 0, + 1, + -1 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + -1, + 0, + 0, + 0 + ], + [ + 1, + -1, + 0, + 1, + 0, + 0 + ], + [ + 0, + 0, + 0, + 1, + -1, + 0 + ] + ], + "expectedActions": 13 + }, + { + "id": "lock-018", + "state": [ + 1, + 2, + 7, + 7, + 6, + 5 + ], + "links": [ + [ + 0, + -1, + -1, + 0, + -1, + 0 + ], + [ + 0, + 0, + 0, + 1, + 0, + 1 + ], + [ + 0, + 1, + 0, + 0, + 0, + -1 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + -1, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 1, + 0, + 1, + 0 + ] + ], + "expectedActions": 14 + }, + { + "id": "lock-019", + "state": [ + 4, + 5, + 7, + 7, + 7 + ], + "links": [ + [ + 0, + 0, + 0, + -1, + 0 + ], + [ + -1, + 0, + -1, + 1, + 0 + ], + [ + 0, + -1, + 0, + 1, + -1 + ], + [ + 0, + -1, + 0, + 0, + -1 + ], + [ + 0, + 0, + -1, + 0, + 0 + ] + ], + "expectedActions": 14 + }, + { + "id": "lock-020", + "state": [ + 3, + 6, + 3, + 7, + 7, + 7 + ], + "links": [ + [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 1, + 0, + 0, + 0, + -1, + -1 + ], + [ + 0, + 1, + 0, + 0, + 0, + 0 + ], + [ + 1, + -1, + 0, + 0, + 0, + 0 + ], + [ + 1, + 0, + 0, + 0, + 0, + -1 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0 + ] + ], + "expectedActions": 12 + }, + { + "id": "lock-021", + "state": [ + 7, + 6, + 4, + 5, + 5, + 4 + ], + "links": [ + [ + 0, + 0, + 0, + -1, + -1, + 1 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 1 + ], + [ + -1, + 1, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 1, + 1, + -1, + 0, + 0, + 0 + ] + ], + "expectedActions": 11 + }, + { + "id": "lock-022", + "state": [ + 7, + 2, + 2, + 1, + 3, + 7 + ], + "links": [ + [ + 0, + 0, + -1, + 0, + -1, + -1 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 1, + 0, + 0, + 1, + 1 + ], + [ + 1, + 0, + 0, + 0, + -1, + 1 + ], + [ + 0, + 0, + 0, + 1, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0 + ] + ], + "expectedActions": 8 + }, + { + "id": "lock-023", + "state": [ + 2, + 3, + 5, + 7, + 6, + 1 + ], + "links": [ + [ + 0, + 1, + 1, + 1, + 0, + 0 + ], + [ + -1, + 0, + -1, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + -1, + 0 + ], + [ + -1, + 0, + 0, + -1, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0 + ] + ], + "expectedActions": 16 + }, + { + "id": "lock-024", + "state": [ + 2, + 7, + 5, + 6, + 7 + ], + "links": [ + [ + 0, + 0, + 1, + 0, + 0 + ], + [ + 0, + 0, + 1, + 0, + 0 + ], + [ + -1, + 0, + 0, + 0, + 1 + ], + [ + 0, + 0, + 1, + 0, + 0 + ], + [ + -1, + -1, + 1, + 0, + 0 + ] + ], + "expectedActions": 8 + }, + { + "id": "lock-025", + "state": [ + 2, + 2, + 6, + 5, + 7 + ], + "links": [ + [ + 0, + -1, + 1, + 0, + -1 + ], + [ + 1, + 0, + 1, + 1, + 0 + ], + [ + 0, + 0, + 0, + 0, + -1 + ], + [ + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 1, + 0, + 0, + 0 + ] + ], + "expectedActions": 14 + }, + { + "id": "lock-026", + "state": [ + 3, + 7, + 1, + 3, + 1 + ], + "links": [ + [ + 0, + 0, + 0, + -1, + 0 + ], + [ + -1, + 0, + 1, + -1, + 1 + ], + [ + 1, + 0, + 0, + -1, + -1 + ], + [ + 0, + 0, + 0, + 0, + 1 + ], + [ + 0, + 0, + 0, + 0, + 0 + ] + ], + "expectedActions": 9 + }, + { + "id": "lock-027", + "state": [ + 4, + 6, + 6, + 7, + 1, + 7, + 4 + ], + "links": [ + [ + 0, + 0, + 0, + 0, + -1, + -1, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + -1, + 0, + 0, + 0, + 0, + 0 + ], + [ + -1, + 0, + 1, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 1, + -1, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 1, + 1, + 0, + 0 + ], + [ + -1, + 0, + 0, + 0, + 0, + 0, + 0 + ] + ], + "expectedActions": 8 + }, + { + "id": "lock-028", + "state": [ + 5, + 7, + 2, + 3, + 5, + 7 + ], + "links": [ + [ + 0, + 0, + -1, + 0, + -1, + 0 + ], + [ + 0, + 0, + 0, + 1, + 0, + 0 + ], + [ + -1, + 0, + 0, + 0, + 0, + 0 + ], + [ + -1, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + -1, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 1, + 0, + 0, + 0 + ] + ], + "expectedActions": 11 + }, + { + "id": "lock-029", + "state": [ + 5, + 4, + 1, + 1, + 6, + 2 + ], + "links": [ + [ + 0, + -1, + -1, + 1, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 1, + 0, + -1, + 0, + 0 + ], + [ + 1, + 0, + 0, + 0, + 1, + 1 + ], + [ + -1, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0 + ] + ], + "expectedActions": 20 + }, + { + "id": "lock-030", + "state": [ + 5, + 1, + 7, + 7, + 6, + 4 + ], + "links": [ + [ + 0, + 0, + 0, + 0, + 1, + -1 + ], + [ + 0, + 0, + 0, + 0, + 0, + 1 + ], + [ + -1, + 0, + 0, + 0, + 0, + 0 + ], + [ + -1, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + -1 + ], + [ + 0, + 0, + -1, + 0, + 0, + 0 + ] + ], + "expectedActions": 10 + }, + { + "id": "lock-031", + "state": [ + 4, + 3, + 7, + 2, + 5 + ], + "links": [ + [ + 0, + -1, + -1, + 0, + 0 + ], + [ + 1, + 0, + 0, + -1, + 0 + ], + [ + -1, + 1, + 0, + 1, + 0 + ], + [ + -1, + 0, + 0, + 0, + 1 + ], + [ + 0, + 0, + -1, + 0, + 0 + ] + ], + "expectedActions": 9 + }, + { + "id": "lock-032", + "state": [ + 2, + 4, + 4, + 6, + 2, + 7 + ], + "links": [ + [ + 0, + 1, + 0, + 0, + 0, + 1 + ], + [ + 0, + 0, + 1, + 0, + 0, + -1 + ], + [ + 0, + 0, + 0, + 1, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 1, + 0, + 0, + 1 + ], + [ + 0, + 0, + -1, + 0, + 0, + 0 + ] + ], + "expectedActions": 9 + }, + { + "id": "lock-033", + "state": [ + 5, + 5, + 6, + 2, + 7, + 7 + ], + "links": [ + [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 1, + 0, + 0, + -1 + ], + [ + 0, + -1, + 0, + 0, + 1, + -1 + ], + [ + 1, + 0, + -1, + 0, + 0, + 0 + ], + [ + -1, + 0, + 0, + 0, + 0, + -1 + ], + [ + 0, + 0, + -1, + 0, + 0, + 0 + ] + ], + "expectedActions": 10 + }, + { + "id": "lock-034", + "state": [ + 5, + 1, + 7, + 1, + 1, + 3 + ], + "links": [ + [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 1, + 0, + 0, + -1, + 0, + 1 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 1 + ], + [ + 0, + 0, + 1, + -1, + 0, + -1 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0 + ] + ], + "expectedActions": 8 + }, + { + "id": "lock-035", + "state": [ + 7, + 2, + 2, + 6, + 1, + 3 + ], + "links": [ + [ + 0, + 0, + 0, + 0, + 1, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 1, + 0, + 0, + 0, + -1, + 1 + ], + [ + 0, + 0, + 0, + 0, + 1, + 0 + ], + [ + 0, + -1, + 0, + 0, + 0, + 0 + ], + [ + 0, + 1, + 0, + -1, + 0, + 0 + ] + ], + "expectedActions": 10 + }, + { + "id": "lock-036", + "state": [ + 6, + 2, + 1, + 1, + 5 + ], + "links": [ + [ + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 1, + 0, + 1 + ], + [ + -1, + 0, + 0, + 0, + 1 + ], + [ + 0, + 0, + -1, + 0, + 0 + ], + [ + 0, + 1, + 0, + 0, + 0 + ] + ], + "expectedActions": 8 + }, + { + "id": "lock-037", + "state": [ + 2, + 1, + 6, + 5, + 4, + 7 + ], + "links": [ + [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 1, + 0, + -1, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 1, + 0, + 0, + -1, + 0 + ], + [ + 0, + -1, + 1, + 0, + 0, + -1 + ], + [ + 0, + 0, + 0, + 0, + 1, + 0 + ] + ], + "expectedActions": 11 + }, + { + "id": "lock-038", + "state": [ + 5, + 2, + 4, + 1, + 4, + 5 + ], + "links": [ + [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 1, + -1, + 0 + ], + [ + 1, + -1, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + -1, + -1, + 1, + -1, + 0 + ] + ], + "expectedActions": 8 + }, + { + "id": "lock-039", + "state": [ + 3, + 1, + 2, + 7, + 2, + 1 + ], + "links": [ + [ + 0, + 0, + 0, + 1, + 0, + 1 + ], + [ + 1, + 0, + -1, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 1, + 0, + 0, + -1 + ], + [ + 0, + 0, + 0, + 1, + 0, + 0 + ] + ], + "expectedActions": 8 + }, + { + "id": "lock-040", + "state": [ + 7, + 1, + 2, + 2, + 5 + ], + "links": [ + [ + 0, + 0, + 0, + -1, + 1 + ], + [ + 1, + 0, + -1, + 0, + -1 + ], + [ + 1, + -1, + 0, + 0, + 0 + ], + [ + 1, + -1, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + -1, + 0 + ] + ], + "expectedActions": 13 + }, + { + "id": "lock-041", + "state": [ + 1, + 3, + 7, + 6, + 5 + ], + "links": [ + [ + 0, + 0, + 0, + -1, + 0 + ], + [ + 0, + 0, + 0, + -1, + 0 + ], + [ + 1, + 0, + 0, + 0, + -1 + ], + [ + 0, + 0, + 0, + 0, + 1 + ], + [ + 1, + 1, + 0, + 0, + 0 + ] + ], + "expectedActions": 9 + }, + { + "id": "lock-042", + "state": [ + 1, + 2, + 1, + 1 + ], + "links": [ + [ + 0, + 0, + 0, + -1 + ], + [ + 0, + 0, + 0, + 0 + ], + [ + 0, + -1, + 0, + 0 + ], + [ + 1, + -1, + -1, + 0 + ] + ], + "expectedActions": 4 + }, + { + "id": "lock-043", + "state": [ + 1, + 7, + 4, + 6, + 4 + ], + "links": [ + [ + 0, + -1, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + -1 + ], + [ + -1, + 0, + 0, + 0, + 0 + ], + [ + 1, + 1, + 1, + 0, + -1 + ], + [ + 0, + -1, + -1, + -1, + 0 + ] + ], + "expectedActions": 5 + }, + { + "id": "lock-044", + "state": [ + 2, + 4, + 5, + 7, + 5, + 5 + ], + "links": [ + [ + 0, + -1, + 1, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 1, + 0, + 0, + 0, + -1, + 0 + ], + [ + 0, + -1, + -1, + 0, + 0, + 1 + ], + [ + 0, + 0, + 0, + -1, + 0, + 1 + ], + [ + 1, + 0, + 0, + 0, + 0, + 0 + ] + ], + "expectedActions": 11 + }, + { + "id": "lock-045", + "state": [ + 1, + 4, + 5, + 2, + 1, + 4 + ], + "links": [ + [ + 0, + 0, + 1, + 0, + -1, + 0 + ], + [ + 0, + 0, + -1, + -1, + 0, + 0 + ], + [ + 0, + 0, + 0, + 1, + 0, + 1 + ], + [ + -1, + 0, + -1, + 0, + 0, + 1 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0 + ] + ], + "expectedActions": 10 + } +] diff --git a/tests/benchmarks/harness.ts b/tests/benchmarks/harness.ts new file mode 100644 index 0000000..eb15ffa --- /dev/null +++ b/tests/benchmarks/harness.ts @@ -0,0 +1,206 @@ +import assert from 'node:assert/strict'; +import { randomUUID } from 'node:crypto'; +import { mkdir, mkdtemp, rename, rm, writeFile } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; +import { performance } from 'node:perf_hooks'; +import { pathToFileURL } from 'node:url'; +import { array, commandMetrics, legacyCommands, record, replayCommands, string, tupleCommands, type Definition, type Metrics, type TupleCommand } from './validation.ts'; +import { catalogSha256, jsonFile, loadCatalog, readMetrics, sha256, type Fixture } from './catalog.ts'; +import { validateSource } from './source.ts'; +import { combineVerdicts, compareMetrics, confirmPerformance, performanceVerdict, statistics } from './comparison.ts'; + +export type ModuleFormat = 'tuple' | 'legacy'; +export type Implementation = { label: string; solve: (definition: Definition) => unknown; + normalize: (raw: unknown) => unknown }; +export type Solution = Metrics & { commands: readonly TupleCommand[] }; +export type Snapshot = { schemaVersion: number; fixtureSha256: string; source: Record; reason: string; + locks: (Solution & { id: string; optimumA: number })[] }; +export type Measurement = Solution & { samplesMs: number[]; timingMs: ReturnType }; + +export async function loadImplementation(modulePath: string, format: ModuleFormat, label: string): Promise { + const module: unknown = await import(pathToFileURL(resolve(modulePath)).href); + const exported = record(module, 'solver module')['solveLock']; + if (typeof exported !== 'function') throw new Error(`Missing named solveLock export: ${modulePath}`); + return { label, + solve: (definition) => { const raw: unknown = format === 'tuple' ? exported(definition.state, definition.links) : exported(definition); return raw; }, + normalize: (raw) => { + if (format === 'tuple') return raw; + const result = record(raw, 'legacy result'); + if (result['status'] === 'unsolvable') return null; + if (result['status'] !== 'solved') throw new Error('Unexpected legacy solver status.'); + return legacyCommands(result['commands']); + } }; +} +export function readSnapshot(path: string): Snapshot { + const value = record(jsonFile(path), 'snapshot'); + const source = validateSource(value['source']); + if (value['schemaVersion'] !== 1 || value['fixtureSha256'] !== catalogSha256) throw new Error('Snapshot schema or fixture hash mismatch.'); + const locks = array(value['locks'], 'snapshot locks').map((raw) => { + const row = record(raw); const metrics = readMetrics(row); const commands = tupleCommands(row['commands']); + assert.deepEqual(commandMetrics(commands), metrics, 'Snapshot metric/path mismatch.'); + if (typeof row['optimumA'] !== 'number' || !Number.isInteger(row['optimumA'])) throw new Error('Snapshot optimum missing.'); + return { id: string(row['id']), optimumA: row['optimumA'], ...metrics, commands }; + }); + if (locks.length !== 45 || new Set(locks.map((lock) => lock.id)).size !== 45) throw new Error('Snapshot requires 45 unique locks.'); + for (const fixture of loadCatalog()) { + const row = locks.find((lock) => lock.id === fixture.id); + if (row === undefined || row.optimumA !== fixture.expectedActions) throw new Error('Snapshot fixture identity or optimum mismatch.'); + validateSolution(fixture, row.commands); + } + return { schemaVersion: 1, fixtureSha256: catalogSha256, source, reason: string(value['reason']), locks }; +} +export async function writeSnapshot(path: string, snapshot: unknown): Promise { + // Deliberately exclusive. Refreshes require a new reviewed destination, never an implicit replacement. + await writeFile(path, `${JSON.stringify(snapshot, null, 2)}\n`, { flag: 'wx' }); +} +export function validateSolution(lock: Fixture, raw: unknown): Solution { + if (raw === null) throw new Error(`${lock.id}: lost solvability.`); + const commands = tupleCommands(raw); + assert.deepEqual(replayCommands(lock, commands), lock.state.map(() => 4), `${lock.id}: target not reached.`); + const metrics = commandMetrics(commands); + assert.equal(metrics.A, lock.expectedActions, `${lock.id}: wrong exact action minimum.`); + return { ...metrics, commands }; +} +function measure(lock: Fixture, implementation: Implementation, clock: () => number): { solution: Solution; elapsed: number } { + const definition = structuredClone({ state: lock.state, links: lock.links }); const before = JSON.stringify(definition); + const start = clock(); const raw = implementation.solve(definition); const elapsed = clock() - start; + assert.equal(JSON.stringify(definition), before, `${lock.id}: solver mutated its input.`); + return { solution: validateSolution(lock, implementation.normalize(raw)), elapsed }; +} +export function runComparison(options: { + locks?: Fixture[]; candidate: Implementation; baseline: Implementation; baselineSnapshot?: Snapshot; + repetitions?: number; warmups?: number; clock?: () => number; + onProgress?: (completed: number, total: number, id: string) => void; +}) { + const locks = options.locks ?? loadCatalog(); const repetitions = options.repetitions ?? 7; + const warmups = options.warmups ?? 2; const clock = options.clock ?? (() => performance.now()); + if (!Number.isSafeInteger(repetitions) || repetitions < 1 || !Number.isSafeInteger(warmups) || warmups < 0) throw new Error('Invalid repetition/warmup count.'); + if (locks.length === 0 || new Set(locks.map((lock) => lock.id)).size !== locks.length) throw new Error('Expected unique nonempty fixture set.'); + const implementations = { candidate: options.candidate, baseline: options.baseline }; + const entries = Object.entries(implementations); + const results = locks.map((lock, lockIndex) => { + const selfA: number[] = []; const selfB: number[] = []; + let baselineControl: Solution | undefined; + for (let round = -warmups; round < repetitions; round += 1) { + for (const slot of (round + warmups) % 2 === 0 ? [selfA, selfB] : [selfB, selfA]) { + const measured = measure(lock, options.baseline, clock); + if (baselineControl !== undefined) assert.deepEqual(measured.solution, baselineControl, `${lock.id}: nondeterministic baseline control.`); + baselineControl = measured.solution; + if (round >= 0) slot.push(measured.elapsed); + } + } + const measured = new Map(); + for (const [name] of entries) measured.set(name, { samplesMs: [] }); + for (let round = -warmups; round < repetitions; round += 1) { + const offset = (round + warmups + lockIndex) % entries.length; + for (let index = 0; index < entries.length; index += 1) { + const entry = entries[(offset + index) % entries.length]; if (entry === undefined) throw new Error('Missing implementation.'); + const [name, implementation] = entry; const prior = measured.get(name); if (prior === undefined) throw new Error('Missing measurement.'); + const call = measure(lock, implementation, clock); + if (prior.solution !== undefined) assert.deepEqual(call.solution, prior.solution, `${lock.id}/${name}: nondeterministic path.`); + prior.solution = call.solution; + if (round >= 0) prior.samplesMs.push(call.elapsed); + } + } + const solvers: Record = Object.fromEntries([...measured].map(([name, value]) => { + if (value.solution === undefined) throw new Error('Missing measured solution.'); + return [name, { ...value.solution, samplesMs: value.samplesMs, timingMs: statistics(value.samplesMs) }]; + })); + const candidate = solvers['candidate']; const baseline = solvers['baseline']; + if (candidate === undefined || baseline === undefined) throw new Error('Missing matched implementations.'); + assert.deepEqual({ ...baseline, samplesMs: undefined, timingMs: undefined }, { ...baselineControl, samplesMs: undefined, timingMs: undefined }, 'Baseline changed between calibration and measurement.'); + if (options.baselineSnapshot !== undefined) { + const stored = options.baselineSnapshot.locks.find((row) => row.id === lock.id); + if (stored === undefined || stored.optimumA !== lock.expectedActions) throw new Error(`${lock.id}: baseline snapshot optimum/identity mismatch.`); + assert.deepEqual(baseline.commands, stored.commands, `${lock.id}: target-branch baseline snapshot path drift.`); + assert.deepEqual(commandMetrics(baseline.commands), readMetrics(stored), `${lock.id}: target-branch baseline snapshot metrics drift.`); + } + const quality = compareMetrics(candidate, baseline); + const firstTiming = performanceVerdict(candidate.samplesMs, baseline.samplesMs, selfA, selfB); + const timingAttempts = [{ ...firstTiming, candidate: candidate.samplesMs, baseline: baseline.samplesMs, selfA, selfB }]; + if (firstTiming.verdict !== 'passed' && repetitions >= 5) { + const repeatSamples = new Map(['candidate', 'baseline', 'selfA', 'selfB'].map((name) => [name, []])); + const repeated = [['selfA', options.baseline], ['selfB', options.baseline], ['candidate', options.candidate], ['baseline', options.baseline]] as const; + for (let round = -warmups; round < repetitions; round += 1) { + for (let index = 0; index < repeated.length; index += 1) { + const entry = repeated[(index + round + warmups) % repeated.length]; + if (entry === undefined) throw new Error('Missing repeat entry.'); + const [name, implementation] = entry; + const call = measure(lock, implementation, clock); + const expected: Measurement = name === 'candidate' ? candidate : baseline; + assert.deepEqual(call.solution.commands, expected.commands, `${lock.id}: nondeterministic repeat.`); + if (round >= 0) repeatSamples.get(name)?.push(call.elapsed); + } + } + const candidateRepeat = repeatSamples.get('candidate') ?? []; const baselineRepeat = repeatSamples.get('baseline') ?? []; + const selfARepeat = repeatSamples.get('selfA') ?? []; const selfBRepeat = repeatSamples.get('selfB') ?? []; + timingAttempts.push({ ...performanceVerdict(candidateRepeat, baselineRepeat, selfARepeat, selfBRepeat), + candidate: candidateRepeat, baseline: baselineRepeat, selfA: selfARepeat, selfB: selfBRepeat }); + } + const repeatTiming = timingAttempts[1]; + const timing = { ...firstTiming, verdict: repeatTiming === undefined ? firstTiming.verdict : confirmPerformance(firstTiming.verdict, repeatTiming.verdict), + reason: repeatTiming === undefined ? firstTiming.reason : `First: ${firstTiming.verdict}; bounded repeat: ${repeatTiming.verdict}. Conflicting verdicts remain inconclusive.` }; + options.onProgress?.(lockIndex + 1, locks.length, lock.id); + return { id: lock.id, optimumA: lock.expectedActions, solvers, quality, timing, + timingAttempts, calibration: { baselineSelfA: selfA, baselineSelfB: selfB }, + differences: Object.fromEntries(Object.entries(solvers).filter(([name]) => name !== 'candidate').map(([name, other]) => [name, compareMetrics(candidate, other)])) }; + }); + const aggregates = Object.fromEntries(entries.map(([name]) => [name, results.reduce((total, row) => { + const result = row.solvers[name]; if (result === undefined) throw new Error('Missing solver aggregate.'); + return { A: total.A + result.A, U: total.U + result.U, C: total.C + result.C, plateSwitches: total.plateSwitches + result.plateSwitches, + sumOfMediansMs: total.sumOfMediansMs + result.timingMs.median }; + }, { A: 0, U: 0, C: 0, plateSwitches: 0, sumOfMediansMs: 0 })])); + return { schemaVersion: 1, createdAt: new Date().toISOString(), + config: { repetitions, warmups, lockCount: locks.length, scope: locks.length === 45 ? 'full-45-catalog' : 'subset', + timingBoundary: 'Only synchronous public solver call; cloning, legacy normalization, replay and metrics excluded.', + order: 'Per-lock baseline-vs-itself calibration first; matched rounds rotate by lock and round. A non-pass receives one bounded repeat with all raw attempts retained.', + uncertainty: 'Paired log-ratio mean with conservative 2.776 standard-error envelope; control sets tolerance. >=5 samples; >25% control envelope cannot certify a pass.', + gc: 'Natural GC; no forced collection.', fixtureSha256: catalogSha256 }, + correctness: { passed: true }, quality: results.some((row) => row.quality.quality === 'regression') ? 'regression' : 'passed', + performance: combineVerdicts(results.map((row) => row.timing.verdict)), + implementations: Object.fromEntries(entries.map(([name, implementation]) => [name, implementation.label])), aggregates, results }; +} +export type ComparisonReport = ReturnType; +export function renderMarkdown(report: ComparisonReport, metadata: unknown, memory: unknown): string { + const lines = ['# Gothic Lock Solver benchmark evidence', '', `Scope: **${report.config.scope}**; quality: **${report.quality}**; performance: **${report.performance}**.`, '', + `Warmups: ${report.config.warmups}; repetitions: ${report.config.repetitions}. A = grouped actions, U = distinct plates, C = unit divisions, S = plate switches.`, '', + report.config.timingBoundary, report.config.order, report.config.uncertainty, '', + '| Implementation | A | U | C | S | Sum of per-lock medians (ms) |', '| --- | ---: | ---: | ---: | ---: | ---: |']; + for (const [name, metrics] of Object.entries(report.aggregates)) lines.push(`| ${name} | ${metrics.A} | ${metrics.U} | ${metrics.C} | ${metrics.plateSwitches} | ${metrics.sumOfMediansMs.toFixed(3)} |`); + lines.push('', '| Lock | Optimum A | Implementation | A/U/C/S | Median / p95 ms | Quality vs base | Timing vs base |', '| --- | ---: | --- | --- | --- | --- | --- |'); + for (const row of report.results) for (const [name, value] of Object.entries(row.solvers)) lines.push(`| ${row.id} | ${row.optimumA} | ${name} | ${value.A}/${value.U}/${value.C}/${value.plateSwitches} | ${value.timingMs.median.toFixed(3)} / ${value.timingMs.p95.toFixed(3)} | ${name === 'candidate' ? row.quality.quality : ''} | ${name === 'candidate' ? row.timing.verdict : ''} |`); + lines.push('', '## Per-lock candidate differences', '', '| Lock | Comparator | ΔA / ΔU / ΔC / ΔS |', '| --- | --- | --- |'); + for (const row of report.results) for (const [name, difference] of Object.entries(row.differences)) lines.push(`| ${row.id} | ${name} | ${difference.deltas.A}/${difference.deltas.U}/${difference.deltas.C}/${difference.deltas.plateSwitches} |`); + lines.push('', '## Isolated process peak RSS', '', 'RSS includes Node, module loading and runtime allocation; it is not exact algorithm heap allocation. Browser peak memory is unavailable. Memory runs are separate from timed calls.', '', '```json', JSON.stringify(memory, null, 2), '```', '', + '## Environment and source provenance', '', '```json', JSON.stringify(metadata, null, 2), '```', '', + '## Interpretation', '', 'Every warmup and measured solution is replayed independently one unit division at a time, checked against the stored exact action minimum, input immutability and deterministic paths. Target snapshot changes cannot mask candidate drift: the supplied baseline module must reproduce the supplied baseline snapshot.', '', + 'U/C/switches are descriptive and are still strict migration regression gates. Timings describe this Node runtime and machine only. A performance inconclusive verdict does not establish absence of regression. Sum of per-lock medians is not a whole-catalog wall-clock median. Raw samples, paths and calibration envelopes are in benchmark.json.', ''); + return lines.join('\n'); +} +export async function publishReport(report: ComparisonReport, outputDir: string, metadata: unknown = {}, memory: unknown = {}, baselineSnapshot?: Snapshot) { + if (!report.correctness.passed) throw new Error('Correctness gates must pass before publishing.'); + const root = resolve(outputDir); await mkdir(root, { recursive: true }); + const staged = await mkdtemp(join(root, '.benchmark-')); + const directory = join(root, `run-${report.createdAt.replaceAll(/[^0-9TZ]/g, '')}-${randomUUID().slice(0, 8)}`); + const full = { ...report, metadata, memory }; + const json = `${JSON.stringify(full, null, 2)}\n`; const markdown = renderMarkdown(report, metadata, memory); + const metadataRecord = record(metadata); + const candidateSource = metadataRecord['candidate'] ?? {}; + const qualitySnapshot = { schemaVersion: 1, fixtureSha256: catalogSha256, source: candidateSource, + reason: 'Measured candidate paths and quality in this immutable benchmark evidence.', + locks: report.results.map((row) => { + const value = row.solvers['candidate']; if (value === undefined) throw new Error('Missing candidate quality snapshot.'); + return { id: row.id, optimumA: row.optimumA, A: value.A, U: value.U, C: value.C, plateSwitches: value.plateSwitches, commands: value.commands }; + }) }; + const evidence = { schemaVersion: 1, createdAt: report.createdAt, quality: report.quality, performance: report.performance, qualitySnapshot, + metadata, memory, config: report.config, aggregates: report.aggregates, + reports: { json: { path: 'benchmark.json', sha256: sha256(json) }, markdown: { path: 'benchmark.md', sha256: sha256(markdown) } } }; + try { + if (baselineSnapshot !== undefined) await writeSnapshot(join(staged, 'baseline-snapshot.json'), baselineSnapshot); + await writeFile(join(staged, 'benchmark.json'), json, { flag: 'wx' }); + await writeFile(join(staged, 'benchmark.md'), markdown, { flag: 'wx' }); + await writeFile(join(staged, 'benchmark-evidence.json'), `${JSON.stringify(evidence, null, 2)}\n`, { flag: 'wx' }); + await rename(staged, directory); + } catch (error) { await rm(staged, { recursive: true, force: true }); throw error; } + return { directory, ...(baselineSnapshot === undefined ? {} : { baselineSnapshotPath: join(directory, 'baseline-snapshot.json') }), jsonPath: join(directory, 'benchmark.json'), markdownPath: join(directory, 'benchmark.md'), evidencePath: join(directory, 'benchmark-evidence.json') }; +} diff --git a/tests/benchmarks/memory-worker.ts b/tests/benchmarks/memory-worker.ts new file mode 100644 index 0000000..16add05 --- /dev/null +++ b/tests/benchmarks/memory-worker.ts @@ -0,0 +1,25 @@ +import { readPeakRss } from './rss.ts'; +import { loadCatalog } from './catalog.ts'; +import { loadImplementation } from './harness.ts'; +import { commandMetrics, replayCommands } from './validation.ts'; + +const startupRssBytes = process.memoryUsage().rss; +const startupPeak = readPeakRss(); +const [modulePath, format, fixtureId] = process.argv.slice(2); +if (modulePath === undefined || (format !== 'tuple' && format !== 'legacy')) throw new Error('Invalid RSS worker arguments.'); +const solver = await loadImplementation(modulePath, format, 'RSS worker'); +const loadedRssBytes = process.memoryUsage().rss; +const loadedPeak = readPeakRss(); +const catalog = loadCatalog(); const selectedIds = fixtureId === undefined ? null : new Set(fixtureId.split(',')); +const locks = selectedIds === null ? catalog : catalog.filter((lock) => selectedIds.has(lock.id)); +if (locks.length === 0) throw new Error('RSS fixture not found.'); +for (const lock of locks) { + const commands = solver.normalize(solver.solve(structuredClone({ state: lock.state, links: lock.links }))); + if (commands === null || replayCommands(lock, commands).some((position) => position !== 4)) throw new Error('RSS worker solution did not reach target.'); + if (commandMetrics(commands).A !== lock.expectedActions) throw new Error('RSS worker action regression.'); +} +const finalPeak = readPeakRss(); +process.stdout.write(JSON.stringify({ status: 'passed', startupRssBytes, loadedRssBytes, + peakRssBytes: finalPeak.peakRssBytes, peakRssMetric: finalPeak.metric, startupPeakRssBytes: startupPeak.peakRssBytes, loadedPeakRssBytes: loadedPeak.peakRssBytes, + resourceUsageMaxRssBytes: finalPeak.resourceUsageMaxRssBytes, startupResourceUsageMaxRssBytes: startupPeak.resourceUsageMaxRssBytes, + resourceUsageLaunchFloorDetected: startupPeak.resourceUsageMaxRssBytes > startupPeak.peakRssBytes, lockCount: locks.length })); diff --git a/tests/benchmarks/memory.ts b/tests/benchmarks/memory.ts new file mode 100644 index 0000000..21b07d5 --- /dev/null +++ b/tests/benchmarks/memory.ts @@ -0,0 +1,74 @@ +import { execFileSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { record, string } from './validation.ts'; +import type { Fixture } from './catalog.ts'; +import type { ModuleFormat } from './harness.ts'; +import { confirmPerformance, performanceVerdict, statistics } from './comparison.ts'; +export type MemorySpec = { name: string; module: string; format: ModuleFormat }; +export type RssSample = { peakRssBytes: number; startupRssBytes: number; loadedRssBytes: number; lockCount: number; peakRssMetric: string; startupPeakRssBytes: number; loadedPeakRssBytes: number; + resourceUsageMaxRssBytes: number; startupResourceUsageMaxRssBytes: number; resourceUsageLaunchFloorDetected: boolean }; +function positive(value: unknown): number { + if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) throw new Error('Invalid worker RSS measurement.'); return value; +} +export function measureRss(spec: MemorySpec, fixtureId?: string): RssSample { + const worker = fileURLToPath(new URL('./memory-worker.ts', import.meta.url)); + const args = [worker, spec.module, spec.format]; if (fixtureId !== undefined) args.push(fixtureId); + const stdout = execFileSync(process.execPath, args, { encoding: 'utf8', timeout: 120_000, maxBuffer: 1_048_576, stdio: ['ignore', 'pipe', 'pipe'] }); + const parsed: unknown = JSON.parse(stdout); const value = record(parsed, 'RSS worker result'); + if (value['status'] !== 'passed') throw new Error(string(value['error'], 'RSS worker error')); + return { peakRssBytes: positive(value['peakRssBytes']), startupRssBytes: positive(value['startupRssBytes']), + loadedRssBytes: positive(value['loadedRssBytes']), lockCount: positive(value['lockCount']), peakRssMetric: string(value['peakRssMetric']), + startupPeakRssBytes: positive(value['startupPeakRssBytes']), loadedPeakRssBytes: positive(value['loadedPeakRssBytes']), + resourceUsageMaxRssBytes: positive(value['resourceUsageMaxRssBytes']), startupResourceUsageMaxRssBytes: positive(value['startupResourceUsageMaxRssBytes']), + resourceUsageLaunchFloorDetected: value['resourceUsageLaunchFloorDetected'] === true }; +} +export function collectMemory(locks: readonly Fixture[], specs: readonly MemorySpec[], repetitions = 5, onProgress?: (text: string) => void) { + const baseline = specs.find((spec) => spec.name === 'baseline'); + if (baseline === undefined) throw new Error('Memory baseline missing.'); + const perLock = locks.map((lock, index) => { + const solvers = Object.fromEntries(specs.map((spec) => [spec.name, measureRss(spec, lock.id)])); + onProgress?.(`RSS ${index + 1}/${locks.length}: ${lock.id}`); + return { id: lock.id, solvers, repetitions: 1, verdict: 'inconclusive', reason: 'Single isolated per-lock observation; environmental variation is not estimated per lock.' }; + }); + const selectedIds = locks.map((lock) => lock.id).join(','); + const groups = [...specs, { ...baseline, name: 'baselineSelfA' }, { ...baseline, name: 'baselineSelfB' }]; + const samples = new Map(groups.map((spec) => [spec.name, []])); + for (let round = 0; round < repetitions; round += 1) { + for (let index = 0; index < groups.length; index += 1) { + const spec = groups[(index + round) % groups.length]; if (spec === undefined) throw new Error('Missing memory implementation.'); + const values = samples.get(spec.name); if (values === undefined) throw new Error('Missing memory series.'); + values.push(measureRss(spec, selectedIds)); + } + onProgress?.(`RSS whole-catalog matched round ${round + 1}/${repetitions}`); + } + const peaks = (name: string): number[] => (samples.get(name) ?? []).map((sample) => sample.peakRssBytes); + const firstVerdict = performanceVerdict(peaks('candidate'), peaks('baseline'), peaks('baselineSelfA'), peaks('baselineSelfB')); + const attempts = [{ ...firstVerdict, samples: Object.fromEntries(samples) }]; + if (firstVerdict.verdict !== 'passed' && repetitions >= 5) { + const retryGroups = groups.filter((spec) => ['candidate', 'baseline', 'baselineSelfA', 'baselineSelfB'].includes(spec.name)); + const retrySamples = new Map(retryGroups.map((spec) => [spec.name, []])); + for (let round = 0; round < repetitions; round += 1) { + for (let index = 0; index < retryGroups.length; index += 1) { + const spec = retryGroups[(index + round) % retryGroups.length]; if (spec === undefined) throw new Error('Missing memory retry implementation.'); + retrySamples.get(spec.name)?.push(measureRss(spec, selectedIds)); + } + onProgress?.(`RSS bounded repeat ${round + 1}/${repetitions}`); + } + const retryPeaks = (name: string): number[] => (retrySamples.get(name) ?? []).map((sample) => sample.peakRssBytes); + attempts.push({ ...performanceVerdict(retryPeaks('candidate'), retryPeaks('baseline'), retryPeaks('baselineSelfA'), retryPeaks('baselineSelfB')), + samples: Object.fromEntries(retrySamples) }); + } + const retry = attempts[1]; + const verdict = { ...firstVerdict, verdict: retry === undefined ? firstVerdict.verdict : confirmPerformance(firstVerdict.verdict, retry.verdict), + reason: retry === undefined ? firstVerdict.reason : `First: ${firstVerdict.verdict}; bounded repeat: ${retry.verdict}. Conflicting observations remain inconclusive.` }; + if ([...samples.values()].flat().some((sample) => sample.peakRssMetric !== 'linux-proc-VmHWM')) { + verdict.verdict = 'inconclusive'; verdict.reason = 'Non-Linux getrusage fallback is recorded but process-image high-water semantics are not verified for this platform.'; + } + return { metric: process.platform === 'linux' ? 'Linux /proc/self/status VmHWM × 1024 bytes (current process-image RSS high-water)' : 'Node process.resourceUsage().maxRSS × 1024 bytes (platform fallback; no certified memory verdict)', repetitions, + sources: ['https://docs.kernel.org/filesystems/proc.html', 'https://nodejs.org/api/process.html#processresourceusage'], + accuracy: 'Kernel RSS accounting is asynchronous and approximate; this is not an exact allocation count. Raw getrusage maxRSS and startup/current-image peaks are preserved to expose pre-exec launch floors.', + scope: `Each per-lock observation is a fresh process. Repeated comparison is peak RSS of the selected ${locks.length}-lock workload per fresh process.`, + includes: 'Node runtime, TypeScript benchmark loader, module loading, fixture loading, normalization, independent replay and solver allocations. Startup and loaded current RSS are recorded separately; subtraction is not exact algorithm allocation.', + browser: { available: false, reason: 'No portable exact browser peak-memory API; Node RSS is not browser memory.' }, + performance: verdict, attempts, wholeCatalog: Object.fromEntries([...samples].map(([name, values]) => [name, { samples: values, peakRssBytes: statistics(values.map((sample) => sample.peakRssBytes)) }])), perLock }; +} diff --git a/tests/benchmarks/rss.ts b/tests/benchmarks/rss.ts new file mode 100644 index 0000000..a1355f9 --- /dev/null +++ b/tests/benchmarks/rss.ts @@ -0,0 +1,14 @@ +import { readFileSync } from 'node:fs'; +export type PeakRss = { peakRssBytes: number; metric: 'linux-proc-VmHWM' | 'node-resourceUsage-maxRSS'; resourceUsageMaxRssBytes: number }; +export function readPeakRss(): PeakRss { + const resourceUsageMaxRssBytes = process.resourceUsage().maxRSS * 1024; + if (process.platform === 'linux') { + // VmHWM belongs to the current process image; getrusage can retain the heavy launcher high-water across exec. + const match = /^VmHWM:\s+(\d+)\s+kB$/m.exec(readFileSync('/proc/self/status', 'utf8')); + const digits = match?.[1]; + const peakRssBytes = digits === undefined ? NaN : Number(digits) * 1024; + if (!Number.isSafeInteger(peakRssBytes) || peakRssBytes <= 0) throw new Error('Linux /proc/self/status has no valid VmHWM RSS observation.'); + return { peakRssBytes, metric: 'linux-proc-VmHWM', resourceUsageMaxRssBytes }; + } + return { peakRssBytes: resourceUsageMaxRssBytes, metric: 'node-resourceUsage-maxRSS', resourceUsageMaxRssBytes }; +} diff --git a/tests/benchmarks/snapshot.ts b/tests/benchmarks/snapshot.ts new file mode 100644 index 0000000..e288f56 --- /dev/null +++ b/tests/benchmarks/snapshot.ts @@ -0,0 +1,46 @@ +import assert from 'node:assert/strict'; +import { mkdir } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { assertArtifactOutput, catalogSha256, loadCatalog, type Fixture } from './catalog.ts'; +import { loadImplementation, validateSolution, writeSnapshot, type Implementation, type ModuleFormat, type Snapshot } from './harness.ts'; +import { inspectSource, validateSource } from './source.ts'; + +export function snapshotFromImplementation(solver: Implementation, source: Record, reason: string, locks: readonly Fixture[] = loadCatalog()): Snapshot { + validateSource(source); + if (reason.trim() === '') throw new Error('Snapshot reason is required.'); + return { schemaVersion: 1, fixtureSha256: catalogSha256, source, reason, + locks: locks.map((lock) => { + const input = structuredClone({ state: lock.state, links: lock.links }); + const raw = solver.solve(input); + assert.deepEqual(input, { state: lock.state, links: lock.links }, `${lock.id}: snapshot solver mutated its input.`); + return { id: lock.id, optimumA: lock.expectedActions, ...validateSolution(lock, solver.normalize(raw)) }; + }) }; +} +export async function generateSnapshot(modulePath: string, format: ModuleFormat, reason: string): Promise { + const source = inspectSource(modulePath, format); + const solver = await loadImplementation(modulePath, format, 'Own baseline snapshot source'); + const snapshot = snapshotFromImplementation(solver, source, reason); + inspectSource(modulePath, format, source); + return snapshot; +} +async function main(): Promise { + const fields = new Map(); + const args = process.argv.slice(2); + for (let index = 0; index < args.length; index += 2) { + const key = args[index]; const value = args[index + 1]; + if (key === undefined || value === undefined || !['--source-module', '--source-format', '--reason', '--output'].includes(key) || fields.has(key)) throw new Error('Invalid snapshot arguments.'); + fields.set(key, value); + } + const modulePath = fields.get('--source-module'); const format = fields.get('--source-format'); + const reason = fields.get('--reason'); const output = fields.get('--output'); + if (modulePath === undefined || reason === undefined || output === undefined || (format !== 'tuple' && format !== 'legacy')) throw new Error('Usage: node tests/benchmarks/snapshot.ts --source-module PATH --source-format tuple|legacy --reason TEXT --output NEW_PATH'); + assertArtifactOutput(output); + const snapshot = await generateSnapshot(modulePath, format, reason); + await mkdir(dirname(resolve(output)), { recursive: true }); + await writeSnapshot(output, snapshot); + console.log(JSON.stringify({ snapshotPath: resolve(output), lockCount: snapshot.locks.length, source: snapshot.source })); +} +if (process.argv[1] !== undefined && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + try { await main(); } catch (error) { console.error(error); process.exitCode = 1; } +} diff --git a/tests/benchmarks/source.ts b/tests/benchmarks/source.ts new file mode 100644 index 0000000..f4796f8 --- /dev/null +++ b/tests/benchmarks/source.ts @@ -0,0 +1,105 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { readFileSync, readdirSync, realpathSync, statSync } from 'node:fs'; +import { basename, dirname, join, relative } from 'node:path'; +import { validateManifest } from '../../.github/scripts/release-lib.ts'; +import { git, jsonFile, sha256 } from './catalog.ts'; +import { record, string } from './validation.ts'; +import type { ModuleFormat } from './harness.ts'; + +export function hashFiles(directory: string, matches: (path: string) => boolean): Record { + const result: Record = {}; + const visit = (path: string): void => { + for (const entry of readdirSync(path, { withFileTypes: true })) { + const full = join(path, entry.name); + if (entry.isDirectory()) visit(full); + else if (entry.isFile() && matches(full)) result[relative(directory, full)] = sha256(readFileSync(full)); + else if (entry.isSymbolicLink()) throw new Error(`Source symlink cannot establish immutable provenance: ${full}`); + } + }; + visit(directory); + return Object.fromEntries(Object.entries(result).sort(([a], [b]) => a.localeCompare(b))); +} +export function revision(directory: string): string | null { try { return git(directory, ['rev-parse', 'HEAD']); } catch { return null; } } +function checkout(module: string): { root: string; revision: string; built: boolean } | null { + const sourceRevision = revision(dirname(module)); + if (sourceRevision === null) return null; + const root = realpathSync(git(dirname(module), ['rev-parse', '--show-toplevel'])); + const tracked = git(root, ['ls-files', '--', relative(root, module)]) !== ''; + const built = !tracked && relative(root, dirname(module)) === 'dist'; + return tracked || built ? { root, revision: sourceRevision, built } : null; +} +export function validateSource(value: unknown): Record { + const source = record(value, 'baseline source identity'); + if (!/^[0-9a-f]{40}$/.test(string(source['revision'], 'baseline source revision')) + || !/^[0-9a-f]{64}$/.test(string(source['moduleSha256'], 'baseline source module hash')) + || (source['format'] !== 'tuple' && source['format'] !== 'legacy')) throw new Error('Baseline source identity is missing or invalid.'); + return source; +} +function verifyReleaseSource(module: string, source: Record): void { + if (source['kind'] !== 'previous-stable-release') throw new Error('A module outside its own checkout requires verified release provenance.'); + const manifestPath = join(dirname(module), 'previous-release-manifest.json'); + const evidencePath = join(dirname(module), 'previous-benchmark-evidence.json'); + const manifestHash = sha256(readFileSync(manifestPath)); const evidenceHash = sha256(readFileSync(evidencePath)); + if (source['releaseManifestSha256'] !== manifestHash || source['releaseEvidenceSha256'] !== evidenceHash) throw new Error('Verified release manifest/evidence hash mismatch.'); + const manifest = validateManifest(jsonFile(manifestPath)); + const snapshot = record(record(jsonFile(evidencePath), 'previous release evidence')['qualitySnapshot'], 'previous release snapshot'); + const original = validateSource(snapshot['source']); + if (manifest.kind !== 'stable' || manifest.version !== source['releaseVersion'] || manifest.sourceSha !== source['revision'] + || manifest.assets['gothic-lock-solver.mjs'] !== source['moduleSha256'] || manifest.assets['benchmark-evidence.json'] !== evidenceHash + || original['revision'] !== source['revision'] || original['moduleSha256'] !== source['moduleSha256'] || original['format'] !== source['format']) { + throw new Error('Verified release module, manifest and snapshot identities disagree.'); + } +} +export function inspectSource(modulePath: string, format: ModuleFormat, pinned?: Record): Record { + const module = realpathSync(modulePath); + if (!statSync(module).isFile()) throw new Error('Baseline module must be a regular file.'); + const moduleSha256 = sha256(readFileSync(module)); + const moduleTree = hashFiles(dirname(module), (path) => /\.(?:mjs|js|ts)$/.test(path)); + if (pinned !== undefined) { + validateSource(pinned); + if (pinned['moduleSha256'] !== moduleSha256 || pinned['format'] !== format) throw new Error('Baseline module hash or format differs from supplied source identity.'); + if (pinned['moduleTree'] !== undefined) assert.deepEqual(moduleTree, pinned['moduleTree'], 'Baseline source tree hash mismatch.'); + } + const location = checkout(module); + let buildManifestSha256: string | undefined; + if (location === null) { + if (pinned === undefined) throw new Error('Baseline requires an own Git checkout or a verified release snapshot with source identity.'); + verifyReleaseSource(module, pinned); + } else { + const { root, revision: sourceRevision } = location; + if (pinned !== undefined && pinned['revision'] !== sourceRevision) throw new Error('Baseline revision differs from supplied source identity.'); + if (location.built) { + const path = join(dirname(module), 'manifest.json'); + const manifest = record(jsonFile(path), 'baseline build manifest'); + if (manifest['schemaVersion'] !== 1 || manifest['package'] !== 'gothic-lock-solver') throw new Error('Unsupported baseline build manifest.'); + if (manifest['sourceSha'] !== sourceRevision) throw new Error('Baseline build manifest revision differs from checkout HEAD.'); + if (record(manifest['files'], 'baseline build checksums')[basename(module)] !== moduleSha256) throw new Error('Baseline build manifest module checksum mismatch.'); + buildManifestSha256 = sha256(readFileSync(path)); + if (pinned?.['buildManifestSha256'] !== undefined && pinned['buildManifestSha256'] !== buildManifestSha256) throw new Error('Baseline build manifest changed after snapshot generation.'); + } + const paths = git(root, ['ls-files', '--', 'src', relative(root, module)]).split('\n').filter((path) => /\.(?:mjs|js|ts)$/.test(path)); + if (paths.length === 0) throw new Error('Baseline checkout has no tracked solver source.'); + const untracked = git(root, ['ls-files', '--others', '--', 'src']).split('\n').filter((path) => /\.(?:mjs|js|ts)$/.test(path)); + if (untracked.length > 0) throw new Error(`Baseline contains untracked source: ${untracked.join(', ')}`); + // The supplied own checkout is the pin: no duplicated implementation or source manifest is needed. + for (const path of paths) { + if (sha256(readFileSync(join(root, path))) !== sha256(execFileSync('git', ['-C', root, 'show', `HEAD:${path}`]))) throw new Error(`Baseline source hash differs from pinned revision: ${path}`); + } + } + return { ...pinned, revision: location?.revision ?? pinned?.['revision'], moduleSha256, format, moduleTree, + ...(buildManifestSha256 === undefined ? {} : { buildManifestSha256 }) }; +} +export function inspectComparisonBaseline(candidatePath: string, baselinePath: string, format: ModuleFormat, pinned?: Record): Record { + const candidate = realpathSync(candidatePath); const baseline = realpathSync(baselinePath); + if (candidate === baseline) throw new Error('Candidate and baseline must not be the same module.'); + const baselineCheckout = checkout(baseline); + const candidateRevision = revision(dirname(candidate)); + if (baselineCheckout !== null && candidateRevision !== null) { + const candidateRoot = realpathSync(git(dirname(candidate), ['rev-parse', '--show-toplevel'])); + if (baselineCheckout.root === candidateRoot) throw new Error('Baseline must not use the candidate checkout.'); + if (baselineCheckout.revision === candidateRevision) throw new Error('Baseline checkout must not use the candidate revision.'); + } + // A release saved inside candidate/artifacts is independent only when its persisted release chain verifies. + return inspectSource(baseline, format, pinned); +} diff --git a/tests/benchmarks/validation.ts b/tests/benchmarks/validation.ts new file mode 100644 index 0000000..9c2d0fd --- /dev/null +++ b/tests/benchmarks/validation.ts @@ -0,0 +1,64 @@ +// Deliberately independent of production types, transitions, encoders and search. +export type TupleCommand = readonly [index: number, delta: number]; +export type Definition = { state: number[]; links: number[][] }; +export type Metrics = { A: number; U: number; C: number; plateSwitches: number }; + +export function record(value: unknown, label = 'value'): Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) throw new Error(`Invalid ${label}: expected object.`); + // Object.entries exposes own properties only; no unchecked structural assertion. + return Object.fromEntries(Object.entries(value)); +} +export function array(value: unknown, label = 'value'): unknown[] { + if (!Array.isArray(value)) throw new Error(`Invalid ${label}: expected array.`); + return Array.from(value, (item: unknown) => item); +} +export function integer(value: unknown, label = 'value'): number { + if (typeof value !== 'number' || !Number.isSafeInteger(value)) throw new Error(`Invalid ${label}: expected integer.`); + return value; +} +export function string(value: unknown, label = 'value'): string { + if (typeof value !== 'string') throw new Error(`Invalid ${label}: expected string.`); + return value; +} +export function tupleCommands(value: unknown): TupleCommand[] { + return array(value, 'commands').map((command) => { + const tuple = array(command, 'command'); + const index = integer(tuple[0], 'command index'); + const delta = integer(tuple[1], 'command delta'); + if (tuple.length !== 2 || index < 0 || delta === 0 || Math.abs(delta) > 6) throw new Error('Invalid command coordinates.'); + return [index, delta]; + }); +} +export function legacyCommands(value: unknown): TupleCommand[] { + return array(value, 'legacy commands').map((command) => { + const item = record(command, 'legacy command'); + const plate = integer(item['plate'], 'legacy command plate'); + const steps = integer(item['steps'], 'legacy command steps'); + const direction = item['direction']; + if (plate < 1 || steps < 1 || steps > 6 || (direction !== 'left' && direction !== 'right')) throw new Error('Invalid legacy command.'); + return [plate - 1, direction === 'left' ? steps : -steps]; + }); +} +export function replayCommands(definition: Definition, value: unknown): number[] { + let state = [...definition.state]; + for (const [index, delta] of tupleCommands(value)) { + const row = definition.links[index]; + if (index >= state.length || row === undefined) throw new Error('Invalid command plate index.'); + for (let click = 0; click < Math.abs(delta); click += 1) { + const next = state.map((position, target) => { + const link = row[target]; + if (link === undefined) throw new Error('Invalid link matrix.'); + return position + Math.sign(delta) * (target === index ? 1 : link); + }); + if (next.some((position) => position < 1 || position > 7 || !Number.isInteger(position))) throw new Error('Blocked intermediate command click.'); + state = next; + } + } + return state; +} +export function commandMetrics(value: unknown): Metrics { + const commands = tupleCommands(value); + return { A: commands.length, U: new Set(commands.map(([index]) => index)).size, + C: commands.reduce((sum, [, delta]) => sum + Math.abs(delta), 0), + plateSwitches: commands.reduce((sum, [index], position) => sum + Number(position > 0 && commands[position - 1]?.[0] !== index), 0) }; +} diff --git a/tests/e2e/assets.d.ts b/tests/e2e/assets.d.ts new file mode 100644 index 0000000..76af98e --- /dev/null +++ b/tests/e2e/assets.d.ts @@ -0,0 +1,4 @@ +declare module '*.json?raw' { + const content: string; + export default content; +} diff --git a/tests/e2e/browser/catalog.test.ts b/tests/e2e/browser/catalog.test.ts new file mode 100644 index 0000000..763c615 --- /dev/null +++ b/tests/e2e/browser/catalog.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, test } from 'vitest'; +import rawCatalog from '../../benchmarks/fixtures.json' with { type: 'json' }; +import { replayCommands } from '../../benchmarks/validation.ts'; +import { solveLock, type Link, type Position } from '../../../src/index.ts'; + +function position(value: number): Position { + if (value === 1 || value === 2 || value === 3 || value === 4 || value === 5 || value === 6 || value === 7) return value; + throw new Error('Invalid pinned position'); +} +function link(value: number): Link { + if (value === -1 || value === 0 || value === 1) return value; + throw new Error('Invalid pinned link'); +} + +describe('shared immutable 45-lock catalog', () => { + test('contains exactly 45 unique fixed fixtures', () => { + expect(rawCatalog).toHaveLength(45); + expect(new Set(rawCatalog.map(({ id }) => id)).size).toBe(45); + }); + for (const fixture of rawCatalog) { + test(`${fixture.id}: independently replays the exact action minimum`, () => { + const state = fixture.state.map(position); + const links = fixture.links.map((row) => row.map(link)); + const before = JSON.stringify([state, links]); + const commands = solveLock(state, links); + expect(commands).not.toBeNull(); + expect(commands).toHaveLength(fixture.expectedActions); + expect(replayCommands(fixture, commands)).toEqual(state.map(() => 4)); + expect(JSON.stringify([state, links])).toBe(before); + expect(solveLock(state, links)).toEqual(commands); + }); + } +}); diff --git a/tests/e2e/build-fixtures.ts b/tests/e2e/build-fixtures.ts new file mode 100644 index 0000000..2be61b6 --- /dev/null +++ b/tests/e2e/build-fixtures.ts @@ -0,0 +1,40 @@ +import { rm } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import { build } from 'vite'; + +const root = fileURLToPath(new URL('../../', import.meta.url)); +const output = fileURLToPath(new URL('../../artifacts/e2e/fixtures/', import.meta.url)); +await rm(output, { recursive: true, force: true }); +for (const minified of [false, true]) { + const suffix = minified ? '.min' : ''; + for (const entry of ['module', 'worker', 'classic']) { + await build({ + root, + configFile: false, + logLevel: 'warn', + plugins: [{ + name: 'built-library-consumer', + enforce: 'pre', + resolveId(source) { + if (source === '../../../src/index.ts') { + return { id: `../../../dist/gothic-lock-solver${suffix}.mjs`, external: true }; + } + return null; + }, + }], + build: { + target: 'es2022', + outDir: output, + emptyOutDir: false, + minify: false, + lib: { + name: 'GothicConsumer', + entry: fileURLToPath(new URL(`./fixtures/${entry}.ts`, import.meta.url)), + formats: [entry === 'classic' ? 'iife' : 'es'], + fileName: () => `${entry}${suffix}.${entry === 'classic' ? 'js' : 'mjs'}`, + }, + rolldownOptions: { output: { codeSplitting: false } }, + }, + }); + } +} diff --git a/tests/e2e/catalog.ts b/tests/e2e/catalog.ts new file mode 100644 index 0000000..212f68f --- /dev/null +++ b/tests/e2e/catalog.ts @@ -0,0 +1,52 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { loadCatalog as loadPinnedCatalog } from '../benchmarks/catalog.ts'; +import { array, replayCommands, tupleCommands } from '../benchmarks/validation.ts'; +import { distDirectory } from './paths.ts'; + +export interface CatalogFixture { + readonly id: string; + readonly state: readonly number[]; + readonly links: readonly (readonly number[])[]; + readonly expectedActions: number; +} + +export function loadCatalog(): readonly CatalogFixture[] { + return loadPinnedCatalog().map(({ id, state, links, expectedActions }) => ({ + id, + state, + links, + expectedActions, + })); +} + +export function verifySolution(fixture: CatalogFixture, commands: unknown): void { + const path = tupleCommands(commands); + assert.equal(path.length, fixture.expectedActions, `${fixture.id}: minimum actions`); + const definition = { + state: [...fixture.state], + links: fixture.links.map((row) => [...row]), + }; + // The benchmark oracle checks every intermediate division without production imports. + assert.deepEqual(replayCommands(definition, path), fixture.state.map(() => 4), `${fixture.id}: goal state`); +} + +export function loadExpectedSolutions(fixtures: readonly CatalogFixture[]): readonly unknown[] { + const moduleUrl = pathToFileURL(join(distDirectory, 'gothic-lock-solver.mjs')).href; + // A subprocess guarantees the native Node loader, outside Playwright's loader. + const code = `import { solveLock } from ${JSON.stringify(moduleUrl)}; + let input = ''; + for await (const chunk of process.stdin) input += chunk; + console.log(JSON.stringify(JSON.parse(input).map(({ state, links }) => solveLock(state, links))));`; + const output = execFileSync(process.execPath, ['--input-type=module', '-e', code], { + encoding: 'utf8', + input: JSON.stringify(fixtures), + }); + const raw: unknown = JSON.parse(output); + const results = array(raw, 'native Node solutions'); + assert.equal(results.length, fixtures.length); + for (const [index, fixture] of fixtures.entries()) verifySolution(fixture, results[index]); + return results; +} diff --git a/tests/e2e/consumers.spec.ts b/tests/e2e/consumers.spec.ts new file mode 100644 index 0000000..39b958c --- /dev/null +++ b/tests/e2e/consumers.spec.ts @@ -0,0 +1,207 @@ +import { copyFile, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { expect, test } from '@playwright/test'; +import type { BrowserContext, Page } from '@playwright/test'; +import { loadCatalog, loadExpectedSolutions, verifySolution } from './catalog.ts'; +import type { CatalogFixture } from './catalog.ts'; +import { distDirectory } from './paths.ts'; + +const catalog = loadCatalog(); +let nativeNodeSolutions: readonly unknown[] | undefined; +const reportedBrowserVersions = new Set(); +test.beforeEach(({ browser, browserName }, testInfo) => { + const version = browser.version(); + testInfo.annotations.push({ type: 'browser-version', description: `${browserName} ${version}` }); + if (!reportedBrowserVersions.has(browserName)) { + console.log(JSON.stringify({ browser: browserName, version })); + reportedBrowserVersions.add(browserName); + } +}); +const formats = [ + { fixture: 'module.html', bundle: 'gothic-lock-solver.mjs', classic: false }, + { fixture: 'module.min.html', bundle: 'gothic-lock-solver.min.mjs', classic: false }, + { fixture: 'classic.html', bundle: 'gothic-lock-solver.js', classic: true }, + { fixture: 'classic.min.html', bundle: 'gothic-lock-solver.min.js', classic: true }, +]; + +function observeRequests(context: BrowserContext, page: Page) { + const requests: string[] = []; + const failures: string[] = []; + const pageErrors: string[] = []; + context.on('request', (request) => requests.push(request.url())); + context.on('requestfailed', (request) => failures.push(request.url())); + page.on('pageerror', (error) => pageErrors.push(error.message)); + return { requests, failures, pageErrors }; +} + +// Playwright serializes this function into the consumer page. All invocation logic +// stays here; the only solver available to it came from the HTML's dist import. +function exerciseConsumer(fixtures: readonly CatalogFixture[]) { + const consumer: unknown = Reflect.get(globalThis, 'consumer'); + if (typeof consumer !== 'object' || consumer === null) { + throw new Error('The plain HTML consumer did not load'); + } + const solveLock: unknown = Reflect.get(consumer, 'solveLock'); + if (typeof solveLock !== 'function') { + throw new Error('Missing public solveLock function'); + } + const factory: unknown = Reflect.get(consumer, 'createSolverConfig'); + if (typeof factory !== 'function') throw new Error('Missing configuration factory'); + const config: unknown = Reflect.apply(factory, undefined, [{ maxDenseBytes: 0 }]); + const invoke = (state: unknown, links: unknown): unknown => Reflect.apply( + solveLock, undefined, [state, links, config], + ); + const publicExports: unknown = Reflect.get(consumer, 'exports'); + const namedMatchesNamespace: unknown = Reflect.get(consumer, 'namedMatchesNamespace'); + const hasClassicGlobal: unknown = Reflect.get(consumer, 'hasClassicGlobal'); + let invalidError: string | null = null; + try { + invoke([0, 4], [[0, 0], [0, 0]]); + } catch (error) { + invalidError = error instanceof Error ? error.name : 'non-Error thrown'; + } + return { + publicExports, + config, + configFrozen: Object.isFrozen(config), + namedMatchesNamespace, + hasClassicGlobal, + signed: invoke([6, 2], [[0, -1], [0, 0]]), + opened: invoke([4, 4], [[0, 0], [0, 0]]), + impossible: invoke([1, 1, 1], [[0, -1, -1], [-1, 0, -1], [-1, -1, 0]]), + invalidError, + catalog: fixtures.map((fixture) => { + const state = Object.freeze([...fixture.state]); + const links = Object.freeze(fixture.links.map((row) => Object.freeze([...row]))); + const commands = invoke(state, links); + return { id: fixture.id, commands, repeat: invoke(state, links), state, links }; + }), + }; +} + +for (const format of formats) { + test(`${format.bundle}: static HTML, public contract, and all 45 locks`, async ({ context, page }) => { + nativeNodeSolutions ??= loadExpectedSolutions(catalog); + const observed = observeRequests(context, page); + const bundlePath = `/dist/${format.bundle}`; + const fixturePath = `/tests/e2e/fixtures/${format.fixture}`; + const responsePromise = page.waitForResponse((response) => new URL(response.url()).pathname === bundlePath); + await page.goto(fixturePath); + await expect(page.locator('#status')).toHaveText('ready'); + const response = await responsePromise; + expect(response.status()).toBe(200); + expect(response.headers()['content-type']).toBe('application/javascript; charset=utf-8'); + expect(await response.body()).toEqual(await readFile(join(distDirectory, format.bundle))); + + const result = await page.evaluate(exerciseConsumer, catalog); + expect(result.publicExports).toContain('solveLock'); + expect(result.publicExports).toContain('createSolverConfig'); + expect(result.config).toEqual({ maxVisited: 2_000_000, maxExpanded: 1_000_000, maxFrontier: 1_000_000, maxDenseBytes: 0 }); + expect(result.configFrozen).toBe(true); + expect(result.publicExports).not.toContain('default'); + expect(result.namedMatchesNamespace).toBe(true); + expect(result.hasClassicGlobal).toBe(format.classic); + expect(result.signed).toEqual([[0, -2]]); + expect(result.opened).toEqual([]); + expect(result.impossible).toBeNull(); + expect(result.invalidError).toBe('LockInputError'); + expect(result.catalog).toHaveLength(45); + for (const [index, entry] of result.catalog.entries()) { + const fixture = catalog[index]; + if (fixture === undefined) throw new Error(`Unexpected result ${entry.id}`); + expect(entry.id).toBe(fixture.id); + verifySolution(fixture, entry.commands); + expect(entry.commands, `${fixture.id}: exact format and environment equivalence`).toEqual(nativeNodeSolutions[index]); + expect(entry.repeat, `${fixture.id}: deterministic repeat`).toEqual(entry.commands); + expect(entry.state, `${fixture.id}: state mutation`).toEqual(fixture.state); + expect(entry.links, `${fixture.id}: links mutation`).toEqual(fixture.links); + } + expect(observed.pageErrors).toEqual([]); + expect(observed.failures).toEqual([]); + expect(observed.requests.sort()).toEqual([ + `http://127.0.0.1:4177${fixturePath}`, + `http://127.0.0.1:4177${bundlePath}`, + `http://127.0.0.1:4177/tests/e2e/fixtures/${format.fixture.replace('.html', format.classic ? '.js' : '.mjs')}`, + ].sort()); + }); +} + +for (const minified of [false, true]) { + const suffix = minified ? '.min' : ''; + test(`module Worker imports gothic-lock-solver${suffix}.mjs`, async ({ context, page }) => { + const observed = observeRequests(context, page); + await page.goto('/tests/e2e/fixtures/worker.html'); + const results = await page.evaluate(async (workerFile) => { + const worker = new Worker(`./${workerFile}`, { type: 'module' }); + const cases = [ + { state: [6, 2], links: [[0, -1], [0, 0]] }, + { state: [4, 4], links: [[0, 0], [0, 0]] }, + ]; + return await new Promise((resolve, reject) => { + const received: unknown[] = []; + worker.onerror = (event) => { + worker.terminate(); + reject(new Error(event.message)); + }; + worker.onmessage = (event: MessageEvent) => { + received.push(event.data); + if (received.length === cases.length) { + worker.terminate(); + resolve(received); + } else { + worker.postMessage(cases[received.length]); + } + }; + worker.postMessage(cases[0]); + }); + }, `worker${suffix}.mjs`); + expect(results).toEqual([[[0, -2]], []]); + expect(observed.pageErrors).toEqual([]); + expect(observed.failures).toEqual([]); + expect(observed.requests.sort()).toEqual([ + 'http://127.0.0.1:4177/tests/e2e/fixtures/worker.html', + `http://127.0.0.1:4177/tests/e2e/fixtures/worker${suffix}.mjs`, + `http://127.0.0.1:4177/dist/gothic-lock-solver${suffix}.mjs`, + ].sort()); + }); + + test(`classic gothic-lock-solver${suffix}.js loads through file://`, async ({ context, page }) => { + const observed = observeRequests(context, page); + const isolatedDirectory = await mkdtemp(join(tmpdir(), 'gothic-classic-')); + try { + const fixtureName = `classic${suffix}.html`; + const bundleName = `gothic-lock-solver${suffix}.js`; + const html = await readFile(new URL(`./fixtures/${fixtureName}`, import.meta.url), 'utf8'); + await writeFile(join(isolatedDirectory, fixtureName), html.replace('src="../../../dist/', 'src="./')); + await copyFile(join(distDirectory, bundleName), join(isolatedDirectory, bundleName)); + const consumerName = `classic${suffix}.js`; + await copyFile(new URL(`../../artifacts/e2e/fixtures/${consumerName}`, import.meta.url), join(isolatedDirectory, consumerName)); + const consumerUrl = pathToFileURL(join(isolatedDirectory, consumerName)).href; + const fixtureUrl = pathToFileURL(join(isolatedDirectory, fixtureName)).href; + const bundleUrl = pathToFileURL(join(isolatedDirectory, bundleName)).href; + await page.goto(fixtureUrl); + await expect(page.locator('#status')).toHaveText('ready'); + expect(page.url()).toBe(fixtureUrl); + const externalScript = page.locator('script[src]').first(); + await expect(externalScript).toHaveCount(1); + await expect(externalScript).toHaveJSProperty('src', bundleUrl); + await expect(externalScript).toHaveJSProperty('type', ''); + const result = await page.evaluate(exerciseConsumer, []); + expect(result.signed).toEqual([[0, -2]]); + expect(result.opened).toEqual([]); + expect(result.impossible).toBeNull(); + expect(result.invalidError).toBe('LockInputError'); + expect(result.hasClassicGlobal).toBe(true); + expect(observed.pageErrors).toEqual([]); + expect(observed.failures).toEqual([]); + // Firefox does not emit request events for file:// loads. Verify the actual + // local document/script above, and reject unexpected URLs when events exist. + const allowedRequests = new Set([fixtureUrl, bundleUrl, consumerUrl]); + expect(observed.requests.filter((url) => !allowedRequests.has(url))).toEqual([]); + } finally { + await rm(isolatedDirectory, { recursive: true, force: true }); + } + }); +} diff --git a/tests/e2e/distribution/artifacts.test.ts b/tests/e2e/distribution/artifacts.test.ts new file mode 100644 index 0000000..3c2b437 --- /dev/null +++ b/tests/e2e/distribution/artifacts.test.ts @@ -0,0 +1,58 @@ +import { createHash } from 'node:crypto'; +import { execFileSync } from 'node:child_process'; +import { readFile, readdir } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, test } from 'vitest'; + +const dist = new URL('../../../dist/', import.meta.url); +const runtime = ['gothic-lock-solver.js', 'gothic-lock-solver.min.js', 'gothic-lock-solver.min.mjs', 'gothic-lock-solver.mjs']; + +describe('published artifacts', () => { + test('ships exactly four core runtime files and one Node CLI', async () => { + expect((await readdir(dist)).filter((name) => /\.(?:m?js)$/.test(name)).sort()).toEqual(['gothic-lock-solver.cli.mjs', ...runtime].sort()); + }); + + for (const extension of ['js', 'mjs']) { + test(`minifies ${extension} bytes and whitespace`, async () => { + const readable = await readFile(new URL(`gothic-lock-solver.${extension}`, dist), 'utf8'); + const minified = await readFile(new URL(`gothic-lock-solver.min.${extension}`, dist), 'utf8'); + expect(minified.length).toBeLessThan(readable.length * 0.8); + expect(minified.split('\n').length).toBeLessThan(readable.split('\n').length * 0.1); + expect(minified).not.toBe(readable); + }); + } + + for (const filename of runtime) { + test(`${filename} has no external loading or Node runtime requirements`, async () => { + const code = await readFile(new URL(filename, dist), 'utf8'); + expect(code).not.toMatch(/\b(?:require|fetch|importScripts)\s*\(|\bimport\s*(?:\(|["'{*])|\bfrom\s*["']|\b(?:process|Buffer|__dirname|__filename)\b|\bnode:/); + expect(code).not.toMatch(/sourceMappingURL/); + expect(code).not.toContain('export default'); + }); + } + + for (const filename of runtime.filter((name) => name.endsWith('.mjs'))) { + test(`${filename} exposes the actual ESM namespace`, async () => { + // A variable file URL deliberately bypasses Vite source transformation. + const url = new URL(filename, dist).href; + const api: unknown = await import(/* @vite-ignore */ url); + expect(api).not.toHaveProperty('default'); + expect(api).toHaveProperty('solveLock'); + const node = await import('node:child_process'); + const output = node.execFileSync(process.execPath, ['--input-type=module', '-e', `import { solveLock, createSolverConfig } from ${JSON.stringify(url)}; import * as api from ${JSON.stringify(url)}; console.log(JSON.stringify([solveLock([6,2],[[0,-1],[0,0]],createSolverConfig({maxDenseBytes:0})),api.solveLock([4,4],[[0,0],[0,0]]),Object.keys(api).sort()]));`], { encoding: 'utf8', cwd: fileURLToPath(dist) }); + expect(JSON.parse(output)).toEqual([[[0, -2]], [], ['LockInputError', 'SearchLimitError', 'createSolverConfig', 'solveLock']]); + }); + } + + test('manifest binds every runtime file and declaration to its bytes', async () => { + const raw: unknown = JSON.parse(await readFile(new URL('manifest.json', dist), 'utf8')); + const packageMetadata: unknown = JSON.parse(await readFile(new URL('../../../package.json', import.meta.url), 'utf8')); + if (typeof packageMetadata !== 'object' || packageMetadata === null || !('version' in packageMetadata)) throw new Error('Malformed package metadata'); + expect(raw).toMatchObject({ schemaVersion: 1, package: 'gothic-lock-solver', version: packageMetadata.version, sourceSha: execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim() }); + if (typeof raw !== 'object' || raw === null || !('files' in raw) || typeof raw.files !== 'object' || raw.files === null) throw new Error('Malformed distribution manifest'); + expect(Object.keys(raw.files).sort()).toEqual((await readdir(dist)).filter((name) => name !== 'manifest.json').sort()); + for (const [name, digest] of Object.entries(raw.files)) { + expect(createHash('sha256').update(await readFile(new URL(name, dist))).digest('hex')).toBe(digest); + } + }); +}); diff --git a/tests/e2e/distribution/catalog.test.ts b/tests/e2e/distribution/catalog.test.ts new file mode 100644 index 0000000..396ce20 --- /dev/null +++ b/tests/e2e/distribution/catalog.test.ts @@ -0,0 +1,20 @@ +import { execFileSync } from 'node:child_process'; +import { test, expect } from 'vitest'; +import { loadCatalog } from '../../benchmarks/catalog.ts'; +import { replayCommands } from '../../benchmarks/validation.ts'; + +for (const filename of ['gothic-lock-solver.mjs', 'gothic-lock-solver.min.mjs']) { + test(`${filename}: real Node loader solves all 45 fixtures at the exact minimum`, () => { + const catalog = loadCatalog(); + const code = `import { solveLock } from ${JSON.stringify(new URL(`../../../dist/${filename}`, import.meta.url).href)};let input='';for await (const chunk of process.stdin) input+=chunk;console.log(JSON.stringify(JSON.parse(input).map(({state,links})=>solveLock(state,links))));`; + const output = execFileSync(process.execPath, ['--input-type=module', '-e', code], { input: JSON.stringify(catalog), encoding: 'utf8' }); + const results: unknown = JSON.parse(output); + if (!Array.isArray(results)) throw new Error('Node consumer returned a malformed result'); + expect(results).toHaveLength(45); + for (const [index, fixture] of catalog.entries()) { + const commands: unknown = results[index]; + expect(commands).toHaveLength(fixture.expectedActions); + expect(replayCommands(fixture, commands)).toEqual(fixture.state.map(() => 4)); + } + }); +} diff --git a/tests/e2e/distribution/cli.test.ts b/tests/e2e/distribution/cli.test.ts new file mode 100644 index 0000000..d9d8d05 --- /dev/null +++ b/tests/e2e/distribution/cli.test.ts @@ -0,0 +1,45 @@ +import { spawnSync } from 'node:child_process'; +import { copyFile, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, test } from 'vitest'; + +describe('standalone Node CLI artifact', () => { + test('runs copied bytes outside the repository with exit codes 0, 1 and 2 and --output', async () => { + const directory = await mkdtemp(join(tmpdir(), 'gothic-standalone-cli-')); + try { + const executable = join(directory, 'gothic-lock-solver.cli.mjs'); + const input = join(directory, 'lock.json'); + const output = join(directory, 'solution.json'); + const config = join(directory, 'config.json'); + await writeFile(config, JSON.stringify({ maxDenseBytes: 0 })); + await copyFile(new URL('../../../dist/gothic-lock-solver.cli.mjs', import.meta.url), executable); + const contents = await readFile(executable, 'utf8'); + expect(contents.startsWith('#!/usr/bin/env node\n')).toBe(true); + // The sole Node file can import builtins; no sibling core modules or npm + // runtime package is present in this temporary consumer directory. + expect(contents).not.toMatch(/\bfrom\s*["'](?:\.|\/|gothic-lock-solver)/); + await writeFile(input, JSON.stringify({ state: [6, 2], links: [[0, -1], [0, 0]] })); + const solved = spawnSync(process.execPath, [executable, input, '--config', config, '--output', output], { cwd: directory, encoding: 'utf8' }); + expect(solved.status).toBe(0); + expect(solved.stderr).toBe(''); + expect(solved.stdout).toContain('Найдено команд: 1'); + expect(JSON.parse(await readFile(output, 'utf8'))).toMatchObject({ status: 'solved', commands: [{ plate: 1, direction: 'right', steps: 2 }] }); + await writeFile(input, JSON.stringify({ state: [1, 7], links: [[0, 1], [1, 0]] })); + const unreachable = spawnSync(process.execPath, [executable, input], { cwd: directory, encoding: 'utf8' }); + expect(unreachable.status).toBe(2); + expect(unreachable.stdout).toContain('Решение не найдено.'); + await writeFile(config, JSON.stringify({ unknown: 1 })); + const invalidConfig = spawnSync(process.execPath, [executable, input, '--config', config], { cwd: directory, encoding: 'utf8' }); + expect(invalidConfig.status).toBe(1); + expect(invalidConfig.stderr).not.toBe(''); + await writeFile(input, '{'); + const invalid = spawnSync(process.execPath, [executable, input], { cwd: directory, encoding: 'utf8' }); + expect(invalid.status).toBe(1); + expect(invalid.stdout).toBe(''); + expect(invalid.stderr).toContain('Некорректный JSON'); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/e2e/distribution/fixtures/global-consumer.ts b/tests/e2e/distribution/fixtures/global-consumer.ts new file mode 100644 index 0000000..27d919a --- /dev/null +++ b/tests/e2e/distribution/fixtures/global-consumer.ts @@ -0,0 +1,8 @@ +/// +const globalConfig: GothicLockSolver.SolverConfig = GothicLockSolver.createSolverConfig({ maxDenseBytes: 0 }); +const globalCommands: readonly GothicLockSolver.Command[] | null = GothicLockSolver.solveLock([6, 2], [[0, -1], [0, 0]], globalConfig); +void globalCommands; +// @ts-expect-error The classic namespace exposes no default property. +GothicLockSolver.default; +// @ts-expect-error The global solver requires a complete configuration. +GothicLockSolver.solveLock([4, 4], [[0, 0], [0, 0]], { maxExpanded: 0 }); diff --git a/tests/e2e/distribution/fixtures/runtime-consumer.ts b/tests/e2e/distribution/fixtures/runtime-consumer.ts new file mode 100644 index 0000000..0c3dc0d --- /dev/null +++ b/tests/e2e/distribution/fixtures/runtime-consumer.ts @@ -0,0 +1,62 @@ +import assert from 'node:assert/strict'; +import { readFileSync, writeFileSync, existsSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; +import { delimiter, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { solveLock, createSolverConfig } from 'gothic-lock-solver'; +import * as GothicLockSolver from 'gothic-lock-solver'; + +const config = createSolverConfig({ maxDenseBytes: 0 }); +assert.ok(Object.isFrozen(config)); +assert.deepEqual(solveLock([6, 2], [[0, -1], [0, 0]], config), [[0, -2]]); +assert.deepEqual(GothicLockSolver.solveLock([4, 4], [[0, 0], [0, 0]]), []); +assert.deepEqual(Object.keys(GothicLockSolver).sort(), ['LockInputError', 'SearchLimitError', 'createSolverConfig', 'solveLock']); +assert.ok(import.meta.resolve('gothic-lock-solver').includes('/node_modules/gothic-lock-solver/dist/')); +const manifest: unknown = JSON.parse(readFileSync(new URL('./node_modules/gothic-lock-solver/package.json', import.meta.url), 'utf8')); +assert.ok(typeof manifest === 'object' && manifest !== null); +for (const field of ['dependencies', 'optionalDependencies', 'peerDependencies']) assert.deepEqual(Reflect.get(manifest, field) ?? {}, {}); +assert.deepEqual(Reflect.get(manifest, 'bundleDependencies') ?? [], []); +assert.equal(Reflect.get(manifest, 'private'), false); +const scripts: unknown = Reflect.get(manifest, 'scripts'); +assert.ok(typeof scripts === 'object' && scripts !== null); +for (const lifecycle of ['preinstall', 'install', 'postinstall', 'prepare']) assert.equal(Reflect.get(scripts, lifecycle), undefined); +assert.deepEqual(Reflect.get(manifest, 'bin'), { 'gothic-lock-solver': './dist/gothic-lock-solver.cli.mjs' }); +for (const path of ['gothic-lock-solver/dist/gothic-lock-solver.mjs', 'gothic-lock-solver/dist/gothic-lock-solver.min.mjs']) { + const api: unknown = await import(path); + assert.ok(typeof api === 'object' && api !== null); + assert.deepEqual(Object.keys(api).sort(), Object.keys(GothicLockSolver).sort()); + const factory: unknown = Reflect.get(api, 'createSolverConfig'); + const solve: unknown = Reflect.get(api, 'solveLock'); + assert.equal(typeof factory, 'function'); + assert.equal(typeof solve, 'function'); + if (typeof factory !== 'function' || typeof solve !== 'function') throw new Error('Malformed ESM exports'); + const settings: unknown = Reflect.apply(factory, undefined, [{ maxDenseBytes: 0 }]); + assert.deepEqual(settings, config); + assert.deepEqual(Reflect.apply(solve, undefined, [[6, 2], [[0, -1], [0, 0]], settings]), [[0, -2]]); +} +console.log(JSON.stringify({ node: process.version, installed: true, namespace: Object.keys(GothicLockSolver).sort() })); + +const input = new URL('./lock.json', import.meta.url); +const configFile = new URL('./config.json', import.meta.url); +const output = new URL('./solution.json', import.meta.url); +const executable = new URL('./node_modules/gothic-lock-solver/dist/gothic-lock-solver.cli.mjs', import.meta.url); +const bin = fileURLToPath(new URL('./node_modules/.bin/gothic-lock-solver', import.meta.url)); +assert.ok(existsSync(bin)); +writeFileSync(input, JSON.stringify({ state: [6, 2], links: [[0, -1], [0, 0]] })); +writeFileSync(configFile, JSON.stringify({ maxDenseBytes: 0 })); +let cli = spawnSync(bin, [fileURLToPath(input), '--config', fileURLToPath(configFile), '--output', fileURLToPath(output)], { encoding: 'utf8', env: { ...process.env, PATH: `${dirname(process.execPath)}${delimiter}${process.env['PATH'] ?? ''}` } }); +assert.equal(cli.status, 0); +assert.equal(cli.stderr, ''); +const solution: unknown = JSON.parse(readFileSync(output, 'utf8')); +assert.ok(typeof solution === 'object' && solution !== null); +assert.deepEqual(Reflect.get(solution, 'commands'), [{ plate: 1, direction: 'right', steps: 2 }]); +writeFileSync(input, JSON.stringify({ state: [1, 7], links: [[0, 1], [1, 0]] })); +cli = spawnSync(process.execPath, [fileURLToPath(executable), fileURLToPath(input)], { encoding: 'utf8' }); +assert.equal(cli.status, 2); +writeFileSync(configFile, JSON.stringify({ unknown: 1 })); +cli = spawnSync(process.execPath, [fileURLToPath(executable), fileURLToPath(input), '--config', fileURLToPath(configFile)], { encoding: 'utf8' }); +assert.equal(cli.status, 1); +writeFileSync(input, '{'); +cli = spawnSync(process.execPath, [fileURLToPath(executable), fileURLToPath(input)], { encoding: 'utf8' }); +assert.equal(cli.status, 1); +console.log(JSON.stringify({ installedCli: true, node: process.version, exitCodes: [0, 1, 2] })); diff --git a/tests/e2e/distribution/fixtures/types-consumer.ts b/tests/e2e/distribution/fixtures/types-consumer.ts new file mode 100644 index 0000000..3a28242 --- /dev/null +++ b/tests/e2e/distribution/fixtures/types-consumer.ts @@ -0,0 +1,46 @@ +import { solveLock, createSolverConfig, LockInputError, SearchLimitError } from 'gothic-lock-solver'; +import * as GothicLockSolver from 'gothic-lock-solver'; +import { solveLock as minified } from 'gothic-lock-solver/dist/gothic-lock-solver.min.mjs'; +import { solveLock as readable } from 'gothic-lock-solver/dist/gothic-lock-solver.mjs'; +import type { Command, Delta, Link, Links, Position, State, SearchLimit, SolverConfig } from 'gothic-lock-solver'; + +const state: State = [6, 2]; +const position: Position = 4; +const delta: Delta = -2; +const link: Link = -1; +const links: Links = [[0, link], [0, 0]]; +const config: SolverConfig = createSolverConfig({ maxExpanded: 100, maxDenseBytes: 0 }); +const commands: readonly Command[] | null = solveLock(state, links, config); +solveLock(state, links, createSolverConfig()); +const dynamic: Position[] = [position, position]; +const dynamicLinks: Link[][] = [[0, 0], [0, 0]]; +const limit: SearchLimit | undefined = undefined; +void [commands, delta, limit, new LockInputError('input'), SearchLimitError]; +GothicLockSolver.solveLock(dynamic, dynamicLinks); +minified([6, 2], [[0, -1], [0, 0]]); +readable([4, 4], [[0, 0], [0, 0]]); +// @ts-expect-error The public ESM entry has no default export. +import defaultSolver from 'gothic-lock-solver'; +void defaultSolver; +// @ts-expect-error Internal search modules are not public package exports. +import internalSearch from 'gothic-lock-solver/dist/astar.js'; +void internalSearch; +// @ts-expect-error Position zero is outside the lock model. +solveLock([0, 4], [[0, 0], [0, 0]]); +// @ts-expect-error Fixed input tuples require at least two positions. +solveLock([4], [[0]]); +// @ts-expect-error Fixed input tuples require a matching matrix. +solveLock([4, 4], [[0, 0]]); +// @ts-expect-error The solver requires a complete configuration object. +solveLock([4, 4], [[0, 0], [0, 0]], {}); +// @ts-expect-error A returned zero delta would be invalid. +const zero: Command = [0, 0]; +void zero; +// @ts-expect-error Solver configuration is readonly. +config.maxExpanded = 1; +// @ts-expect-error Configuration fields require numbers. +createSolverConfig({ maxVisited: '100' }); +// @ts-expect-error Unknown factory options are invalid. +createSolverConfig({ timeout: 100 }); +// @ts-expect-error Configured fixed tuples still require matching matrix dimensions. +solveLock([4, 4], [[0]], config); diff --git a/tests/e2e/fixtures/classic.html b/tests/e2e/fixtures/classic.html new file mode 100644 index 0000000..f3ee919 --- /dev/null +++ b/tests/e2e/fixtures/classic.html @@ -0,0 +1,9 @@ + + + + + Plain HTML classic consumer + loading + + + diff --git a/tests/e2e/fixtures/classic.min.html b/tests/e2e/fixtures/classic.min.html new file mode 100644 index 0000000..d741389 --- /dev/null +++ b/tests/e2e/fixtures/classic.min.html @@ -0,0 +1,9 @@ + + + + + Plain HTML minified classic consumer + loading + + + diff --git a/tests/e2e/fixtures/classic.ts b/tests/e2e/fixtures/classic.ts new file mode 100644 index 0000000..7b739e3 --- /dev/null +++ b/tests/e2e/fixtures/classic.ts @@ -0,0 +1,12 @@ +const namespace: unknown = Reflect.get(globalThis, 'GothicLockSolver'); +if (typeof namespace !== 'object' || namespace === null) throw new Error('Missing classic global'); +Reflect.set(globalThis, 'consumer', { + solveLock: Reflect.get(namespace, 'solveLock'), + createSolverConfig: Reflect.get(namespace, 'createSolverConfig'), + exports: Object.keys(namespace), + namedMatchesNamespace: true, + hasClassicGlobal: Object.hasOwn(globalThis, 'GothicLockSolver'), +}); +const status = document.querySelector('#status'); +if (status === null) throw new Error('Missing consumer status'); +status.textContent = 'ready'; diff --git a/examples/lock.example.json b/tests/e2e/fixtures/lock.example.json similarity index 100% rename from examples/lock.example.json rename to tests/e2e/fixtures/lock.example.json diff --git a/tests/e2e/fixtures/module.html b/tests/e2e/fixtures/module.html new file mode 100644 index 0000000..f32b198 --- /dev/null +++ b/tests/e2e/fixtures/module.html @@ -0,0 +1,8 @@ + + + + + Plain HTML ESM consumer + loading + + diff --git a/tests/e2e/fixtures/module.min.html b/tests/e2e/fixtures/module.min.html new file mode 100644 index 0000000..5c640df --- /dev/null +++ b/tests/e2e/fixtures/module.min.html @@ -0,0 +1,8 @@ + + + + + Plain HTML minified ESM consumer + loading + + diff --git a/tests/e2e/fixtures/module.ts b/tests/e2e/fixtures/module.ts new file mode 100644 index 0000000..e92359b --- /dev/null +++ b/tests/e2e/fixtures/module.ts @@ -0,0 +1,13 @@ +import { createSolverConfig, solveLock } from '../../../src/index.ts'; +import * as namespace from '../../../src/index.ts'; + +Reflect.set(globalThis, 'consumer', { + solveLock, + createSolverConfig, + exports: Object.keys(namespace), + namedMatchesNamespace: solveLock === namespace.solveLock && createSolverConfig === namespace.createSolverConfig, + hasClassicGlobal: Object.hasOwn(globalThis, 'GothicLockSolver'), +}); +const status = document.querySelector('#status'); +if (status === null) throw new Error('Missing consumer status'); +status.textContent = 'ready'; diff --git a/tests/e2e/fixtures/worker.html b/tests/e2e/fixtures/worker.html new file mode 100644 index 0000000..2b660d8 --- /dev/null +++ b/tests/e2e/fixtures/worker.html @@ -0,0 +1,7 @@ + + + + + Plain HTML module Worker consumer + ready + diff --git a/tests/e2e/fixtures/worker.ts b/tests/e2e/fixtures/worker.ts new file mode 100644 index 0000000..69d0045 --- /dev/null +++ b/tests/e2e/fixtures/worker.ts @@ -0,0 +1,9 @@ +import { createSolverConfig, solveLock } from '../../../src/index.ts'; + +self.addEventListener('message', (event: MessageEvent) => { + const data = event.data; + if (typeof data !== 'object' || data === null || !('state' in data) || !('links' in data)) { + throw new Error('Malformed Worker input'); + } + self.postMessage(Reflect.apply(solveLock, undefined, [data.state, data.links, createSolverConfig()])); +}); diff --git a/tests/e2e/paths.ts b/tests/e2e/paths.ts new file mode 100644 index 0000000..fb498e1 --- /dev/null +++ b/tests/e2e/paths.ts @@ -0,0 +1,10 @@ +import { isAbsolute } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const override = process.env['GOTHIC_DIST_DIRECTORY']; +if (override !== undefined && !isAbsolute(override)) { + throw new Error('GOTHIC_DIST_DIRECTORY must be an absolute directory path.'); +} + +// Package verification points this at the installed tarball's dist directory. +export const distDirectory = override ?? fileURLToPath(new URL('../../dist/', import.meta.url)); diff --git a/tests/e2e/server.ts b/tests/e2e/server.ts new file mode 100644 index 0000000..1dc688a --- /dev/null +++ b/tests/e2e/server.ts @@ -0,0 +1,79 @@ +import { access, readFile } from 'node:fs/promises'; +import { createServer } from 'node:http'; +import { join } from 'node:path'; +import { distDirectory } from './paths.ts'; + +const root = new URL('../../', import.meta.url); +const runtimeFiles = [ + 'gothic-lock-solver.mjs', + 'gothic-lock-solver.min.mjs', + 'gothic-lock-solver.js', + 'gothic-lock-solver.min.js', +]; +const fixtureFiles = [ + 'module.html', + 'module.min.html', + 'classic.html', + 'classic.min.html', + 'worker.html', + +]; +const generatedFiles = ['module.mjs', 'module.min.mjs', 'worker.mjs', 'worker.min.mjs', 'classic.js', 'classic.min.js']; +const generated = new URL('../../artifacts/e2e/fixtures/', import.meta.url); +const paths = new Set([ + ...runtimeFiles.map((file) => `/dist/${file}`), + ...fixtureFiles.map((file) => `/tests/e2e/fixtures/${file}`), + ...generatedFiles.map((file) => `/tests/e2e/fixtures/${file}`), +]); + +// Starting this server never builds or transforms the artifacts being tested. +await Promise.all(runtimeFiles.map(async (file) => { + try { + await access(join(distDirectory, file)); + } catch (cause) { + throw new Error(`Missing ${join(distDirectory, file)}; run pnpm build before pnpm test:e2e.`, { cause }); + } +})); + +const server = createServer((request, response) => { + const pathname = new URL(request.url ?? '/', 'http://127.0.0.1:4177').pathname; + if (request.method !== 'GET' && request.method !== 'HEAD') { + response.writeHead(405, { Allow: 'GET, HEAD' }); + response.end(); + return; + } + if (pathname === '/health') { + response.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' }); + response.end(request.method === 'HEAD' ? undefined : 'ready'); + return; + } + if (!paths.has(pathname)) { + response.writeHead(404); + response.end(); + return; + } + const file = pathname.startsWith('/dist/') + ? join(distDirectory, pathname.slice('/dist/'.length)) + : generatedFiles.includes(pathname.split('/').at(-1) ?? '') + ? new URL(pathname.split('/').at(-1) ?? '', generated) + : new URL(pathname.slice(1), root); + void readFile(file).then((bytes) => { + response.writeHead(200, { + 'Content-Type': pathname.endsWith('.html') + ? 'text/html; charset=utf-8' + : 'application/javascript; charset=utf-8', + 'Content-Length': bytes.byteLength, + 'Cache-Control': 'no-store', + 'X-Content-Type-Options': 'nosniff', + }); + response.end(request.method === 'HEAD' ? undefined : bytes); + }, () => { + response.writeHead(500); + response.end(); + }); +}); + +server.listen(4177, '127.0.0.1'); +process.once('SIGTERM', () => { + server.close(); +}); diff --git a/tests/e2e/verify-package.ts b/tests/e2e/verify-package.ts new file mode 100644 index 0000000..aa35c8b --- /dev/null +++ b/tests/e2e/verify-package.ts @@ -0,0 +1,88 @@ +import { strict as assert } from 'node:assert'; +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { copyFile, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const consumerNodes = new Set([process.execPath]); +const args = process.argv.slice(2); +for (let index = 0; index < args.length; index += 2) { + const executable = args[index + 1]; + if (args[index] !== '--node' || executable === undefined || executable.length === 0) { + throw new Error('Usage: pnpm verify:package [--node PATH]...'); + } + consumerNodes.add(executable); +} + +const root = fileURLToPath(new URL('../../', import.meta.url)); +const temporary = await mkdtemp(join(tmpdir(), 'gothic-package-consumer-')); +const artifacts = join(root, 'artifacts', 'package'); +const destination = join(artifacts, 'package.tgz'); +const checksumPath = join(artifacts, 'package.sha256'); +const compiler = fileURLToPath(new URL('../../node_modules/typescript/bin/tsc', import.meta.url)); +const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm'; +function run(command: string, args: readonly string[], cwd = temporary): string { + return execFileSync(command, args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'inherit'] }); +} +try { + await mkdir(artifacts, { recursive: true }); + // A failed fresh check must not leave a stale archive labeled verified. + await rm(destination, { force: true }); + await rm(checksumPath, { force: true }); + run(npm, ['pack', '--json', '--ignore-scripts', '--pack-destination', temporary], root); + const archives = (await readdir(temporary)).filter((name) => name.endsWith('.tgz')); + const archive = archives[0]; + assert.equal(archives.length, 1, 'Pack must create exactly one tarball'); + assert.ok(archive); + const archivePath = join(temporary, archive); + await writeFile(join(temporary, 'package.json'), JSON.stringify({ name: 'gothic-clean-consumer', version: '1.0.0', private: true, type: 'module' })); + run(npm, ['install', '--offline', '--ignore-scripts', '--no-audit', '--no-fund', archivePath]); + const packageRoot = join(temporary, 'node_modules', 'gothic-lock-solver'); + const packageDist = join(packageRoot, 'dist'); + assert.deepEqual((await readdir(packageRoot)).sort(), ['LICENSE', 'README.md', 'dist', 'package.json']); + const installedMetadata: unknown = JSON.parse(await readFile(join(packageRoot, 'package.json'), 'utf8')); + const distributionManifest: unknown = JSON.parse(await readFile(join(packageDist, 'manifest.json'), 'utf8')); + assert.ok(typeof installedMetadata === 'object' && installedMetadata !== null && 'version' in installedMetadata); + assert.ok(typeof distributionManifest === 'object' && distributionManifest !== null && 'version' in distributionManifest && 'sourceSha' in distributionManifest); + assert.equal(distributionManifest.version, installedMetadata.version, 'Build version is stale'); + assert.equal(distributionManifest.sourceSha, run('git', ['rev-parse', 'HEAD'], root).trim(), 'Build source SHA is stale'); + const repoDist = join(root, 'dist'); + const sourceFiles = (await readdir(repoDist)).sort(); + assert.deepEqual((await readdir(packageDist)).sort(), sourceFiles); + for (const file of sourceFiles) assert.deepEqual(await readFile(join(packageDist, file)), await readFile(join(repoDist, file)), `Pack changed ${file}`); + for (const file of ['types-consumer.ts', 'runtime-consumer.ts', 'global-consumer.ts']) { + await copyFile(join(root, 'tests', 'e2e', 'distribution', 'fixtures', file), join(temporary, file)); + } + const rawConfig: unknown = JSON.parse(await readFile(join(root, 'tsconfig.json'), 'utf8')); + assert.ok(typeof rawConfig === 'object' && rawConfig !== null && 'compilerOptions' in rawConfig); + assert.ok(typeof rawConfig.compilerOptions === 'object' && rawConfig.compilerOptions !== null); + await writeFile(join(temporary, 'tsconfig.json'), JSON.stringify({ + compilerOptions: { ...rawConfig.compilerOptions, types: [], lib: ['ES2022'], moduleDetection: 'legacy' }, + include: ['types-consumer.ts', 'global-consumer.ts'], + })); + run(process.execPath, [compiler, '--project', join(temporary, 'tsconfig.json')]); + await writeFile(join(temporary, 'tsconfig.runtime.json'), JSON.stringify({ + compilerOptions: { ...rawConfig.compilerOptions, types: ['node'], typeRoots: [join(root, 'node_modules', '@types')], noEmit: false, allowImportingTsExtensions: false }, + include: ['runtime-consumer.ts'], + })); + run(process.execPath, [compiler, '--project', join(temporary, 'tsconfig.runtime.json')]); + for (const executable of consumerNodes) { + process.stdout.write(run(executable, [join(temporary, 'runtime-consumer.js')])); + } + run(process.execPath, [join(root, 'tests', 'e2e', 'build-fixtures.ts')], root); + // Reuse byte-preserving HTTP and isolated file consumers against installed + // tarball files. Vite and source resolution are absent from these pages. + execFileSync(process.execPath, [join(root, 'node_modules', 'playwright', 'cli.js'), 'test'], { + cwd: root, + env: { ...process.env, GOTHIC_DIST_DIRECTORY: packageDist }, + stdio: 'inherit', + }); + await copyFile(archivePath, destination); + const sha256 = createHash('sha256').update(await readFile(destination)).digest('hex'); + await writeFile(checksumPath, `${sha256} package.tgz\n`); + console.log(JSON.stringify({ verified: true, node: process.version, tarball: destination, sha256 })); +} finally { + await rm(temporary, { recursive: true, force: true }); +} diff --git a/tests/unit/benchmark.test.ts b/tests/unit/benchmark.test.ts new file mode 100644 index 0000000..9279184 --- /dev/null +++ b/tests/unit/benchmark.test.ts @@ -0,0 +1,338 @@ +import { execFileSync } from 'node:child_process'; +import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { releaseAssets } from '../../.github/scripts/release-lib.ts'; +import { array, commandMetrics, replayCommands, legacyCommands, record } from '../benchmarks/validation.ts'; +import { compareMetrics, confirmPerformance, performanceVerdict, statistics } from '../benchmarks/comparison.ts'; +import { catalogSha256, loadCatalog, sha256 } from '../benchmarks/catalog.ts'; +import { publishReport, readSnapshot, runComparison, writeSnapshot, type Implementation } from '../benchmarks/harness.ts'; + +import { inspectComparisonBaseline, inspectSource, validateSource } from '../benchmarks/source.ts'; +import { snapshotFromImplementation } from '../benchmarks/snapshot.ts'; +import { assertArtifactOutput, collectMetadata, parseBenchmarkArgs } from '../benchmarks/cli.ts'; + +const definition = { state: [3, 4], links: [[0, 0], [0, 0]] }; +const commands: readonly (readonly [number, number])[] = [[0, 1]]; +const fixture = { id: 'unit', ...definition, expectedActions: 1 }; +const source = { revision: '1'.repeat(40), moduleSha256: '2'.repeat(64), format: 'tuple' }; +const implementation = (solve: Implementation['solve'] = () => commands): Implementation => ({ + label: 'unit', solve, normalize: (value) => value, +}); + +describe('benchmark replay independent of production transitions', () => { + it('uses zero-based numeric deltas, simultaneous outgoing links and every intermediate click', () => { + expect(replayCommands(definition, commands)).toEqual([4, 4]); + expect(replayCommands({ state: [2, 5, 6], links: [[0, -1, 0], [0, 0, 1], [0, 0, 0]] }, [[0, 2]])).toEqual([4, 3, 6]); + expect(() => replayCommands({ state: [6, 6], links: [[0, 1], [0, 0]] }, [[0, 2]])).toThrow(/blocked/i); + expect(definition.state).toEqual([3, 4]); + }); + it('rejects invalid tuple and command coordinates', () => { + for (const invalid of [[[0, 0]], [[-1, 1]], [[2, 1]], [[0, 7]], [[0, 1.5]], [[0, 1, 2]], [{ plate: 1 }]]) { + expect(() => replayCommands(definition, invalid)).toThrow(/command/i); + } + }); + it('measures A/U/C/switches and converts legacy left to positive numeric delta', () => { + expect(commandMetrics([[0, 2], [1, -3], [0, 1]])).toEqual({ A: 3, U: 2, C: 6, plateSwitches: 2 }); + expect(legacyCommands([{ plate: 1, direction: 'left', steps: 2 }, { plate: 2, direction: 'right', steps: 3 }])).toEqual([[0, 2], [1, -3]]); + }); +}); + +describe('deterministic and environmental verdicts stay separate', () => { + it('fails every metric regression even if another metric improves', () => { + expect(compareMetrics({ A: 4, U: 1, C: 6, plateSwitches: 0 }, { A: 3, U: 2, C: 8, plateSwitches: 1 }).quality).toBe('regression'); + expect(compareMetrics({ A: 3, U: 3, C: 7, plateSwitches: 1 }, { A: 3, U: 2, C: 8, plateSwitches: 1 }).quality).toBe('regression'); + expect(compareMetrics({ A: 3, U: 2, C: 9, plateSwitches: 1 }, { A: 3, U: 2, C: 8, plateSwitches: 1 }).quality).toBe('regression'); + expect(compareMetrics({ A: 3, U: 2, C: 8, plateSwitches: 2 }, { A: 3, U: 2, C: 8, plateSwitches: 1 }).quality).toBe('regression'); + }); + it('calibrates uncertainty from self-comparison and never uses historical times as proof', () => { + expect(performanceVerdict([10, 10, 10, 10, 10], [10, 10, 10, 10, 10], [10, 10, 10, 10, 10], [10, 10, 10, 10, 10]).verdict).toBe('passed'); + expect(performanceVerdict([20, 20, 20, 20, 20], [10, 10, 10, 10, 10], [10, 10, 10, 10, 10], [10, 10, 10, 10, 10]).verdict).toBe('regression'); + expect(performanceVerdict([20], [10], [10], [10]).verdict).toBe('inconclusive'); + expect(performanceVerdict([10, 20, 10, 20, 10], [10, 10, 10, 10, 10], [5, 20, 5, 20, 5], [10, 10, 10, 10, 10]).verdict).toBe('inconclusive'); + expect(performanceVerdict([10, 10, 10, 10, 10], [10, 10, 10, 10, 10], [], []).verdict).toBe('inconclusive'); + }); + it('requires repeat confirmation of an environmental regression and preserves conflicting signals', () => { + expect(confirmPerformance('regression', 'regression')).toBe('regression'); + expect(confirmPerformance('regression', 'passed')).toBe('inconclusive'); + expect(confirmPerformance('inconclusive', 'passed')).toBe('inconclusive'); + expect(confirmPerformance('passed', 'passed')).toBe('passed'); + }); + it('retains raw sample order and computes nearest-rank p95', () => { + const samples = [9, 1, 5, 3]; + expect(statistics(samples)).toEqual({ min: 1, median: 4, p95: 9, max: 9 }); + expect(samples).toEqual([9, 1, 5, 3]); + for (const bad of [[], [-1], [NaN]]) expect(() => statistics(bad)).toThrow(/sample/i); + }); +}); + +describe('evidence trust boundaries', () => { + it('retains only all 45 fixed numerical fixtures and mathematical expectations', () => { + const locks = loadCatalog(); + expect(locks).toHaveLength(45); + expect(Object.keys(locks[0] ?? {}).sort()).toEqual(['expectedActions', 'id', 'links', 'state']); + expect(locks.reduce((sum, lock) => sum + lock.expectedActions, 0)).toBe(483); + }); + it('rejects missing source identity and mismatched module, format or revision', async () => { + expect(() => validateSource({})).toThrow(/source|baseline/i); + expect(() => inspectSource('/missing/own-baseline.ts', 'tuple')).toThrow(); + const root = await mkdtemp(join(tmpdir(), 'benchmark-source-')); + const module = join(root, 'solver.ts'); + try { + await writeFile(module, 'export function solveLock() { return [[0, 1]]; }\n'); + expect(() => inspectSource(module, 'tuple')).toThrow(/own Git checkout|identity/); + expect(() => inspectSource(module, 'tuple', source)).toThrow(/hash/); + const pinned = { ...source, moduleSha256: sha256(await readFile(module)) }; + expect(() => inspectSource(module, 'tuple', pinned)).toThrow(/verified release/); + expect(() => inspectSource(module, 'legacy', pinned)).toThrow(/format/); + execFileSync('git', ['init', '--quiet', root]); + execFileSync('git', ['-C', root, 'add', '.']); + execFileSync('git', ['-C', root, '-c', 'user.name=Benchmark Test', '-c', 'user.email=benchmark@example.invalid', 'commit', '--quiet', '-m', 'Own baseline']); + expect(() => inspectSource(module, 'tuple', pinned)).toThrow(/revision/); + } finally { await rm(root, { recursive: true, force: true }); } + }); + it('pins every own checkout source file directly to Git without copying it', async () => { + const root = await mkdtemp(join(tmpdir(), 'benchmark-source-')); + try { + await mkdir(join(root, 'src')); + const module = join(root, 'src', 'solver.ts'); + const dependency = join(root, 'src', 'dependency.ts'); + await writeFile(module, 'export function solveLock() { return [[0, 1]]; }\n'); + await writeFile(dependency, 'export const value = 1;\n'); + execFileSync('git', ['init', '--quiet', root]); + execFileSync('git', ['-C', root, 'add', '.']); + execFileSync('git', ['-C', root, '-c', 'user.name=Benchmark Test', '-c', 'user.email=benchmark@example.invalid', 'commit', '--quiet', '-m', 'Own baseline']); + const pinned = inspectSource(module, 'tuple'); + expect(inspectSource(module, 'tuple', pinned)).toEqual(pinned); + await writeFile(dependency, 'export const value = 1;\n\n'); + expect(() => inspectSource(module, 'tuple')).toThrow(/source hash/); + expect(() => inspectSource(module, 'tuple', pinned)).toThrow(/tree hash/); + await writeFile(dependency, 'export const value = 1;\n'); + await writeFile(join(root, 'src', 'untracked.ts'), 'export const value = 2;\n'); + expect(() => inspectSource(module, 'tuple')).toThrow(/untracked source/); + } finally { await rm(root, { recursive: true, force: true }); } + }); + it('generates snapshot quality by executing only the explicitly supplied baseline', () => { + let calls = 0; + const snapshot = snapshotFromImplementation(implementation(() => { calls += 1; return commands; }), source, 'own baseline', [fixture]); + expect(calls).toBe(1); + expect(snapshot.source).toEqual(source); + expect(snapshot.locks).toEqual([{ id: 'unit', optimumA: 1, ...commandMetrics(commands), commands }]); + expect(() => snapshotFromImplementation(implementation(() => []), source, 'own baseline', [fixture])).toThrow(/target/); + expect(() => snapshotFromImplementation(implementation(), {}, 'own baseline', [fixture])).toThrow(/source|baseline/); + }); + it('never overwrites a quality snapshot', async () => { + const root = await mkdtemp(join(tmpdir(), 'benchmark-snapshot-')); + const path = join(root, 'quality.json'); + try { + await writeSnapshot(path, { reason: 'initial', locks: [] }); + const initial = await readFile(path, 'utf8'); + await expect(writeSnapshot(path, { reason: 'replacement', locks: [] })).rejects.toThrow(); + expect(await readFile(path, 'utf8')).toBe(initial); + } finally { await rm(root, { recursive: true, force: true }); } + }); + it('validates warmups, measured paths, determinism, input immutability and exact action optimum', () => { + let calls = 0; + const setup = { locks: [fixture], candidate: implementation(), baseline: implementation(), repetitions: 5, warmups: 1 }; + expect(runComparison(setup).quality).toBe('passed'); + expect(() => runComparison({ ...setup, candidate: implementation(() => []) })).toThrow(/target|minimum/i); + expect(() => runComparison({ ...setup, candidate: implementation((input) => { input.state[0] = 4; return commands; }) })).toThrow(/mutat/i); + expect(() => runComparison({ ...setup, candidate: implementation(() => ++calls === 1 ? commands : [[0, 2], [0, -1]]) })).toThrow(/minimum|determin/i); + }); +}); + +it('requires explicit baseline inputs and rejects unsafe or ambiguous CLI settings', () => { + expect(() => parseBenchmarkArgs([])).toThrow(/explicit/); + expect(parseBenchmarkArgs(['--baseline-module', '/base.mjs', '--baseline-format', 'legacy']).outputDir).toMatch(/artifacts\/benchmark$/); + expect(parseBenchmarkArgs(['--baseline-module', '/base.mjs', '--baseline-format', 'legacy']).baselineSnapshot).toBeUndefined(); + expect(parseBenchmarkArgs(['--baseline-module', '/base.mjs', '--baseline-snapshot', '/quality.json', '--baseline-format', 'legacy']).baselineFormat).toBe('legacy'); + for (const args of [['--warmups', '-1'], ['--warmups', '01'], ['--repetitions', '0'], ['--warmups', '1', '--warmups', '2'], ['--reference'], ['--unknown', '1']]) { + expect(() => parseBenchmarkArgs(args)).toThrow(/argument|integer/); + } +}); + +it('rejects unsupported comparison slots', () => { + expect(() => parseBenchmarkArgs(['--reference', '/separate-checkout'])).toThrow(/Unknown argument/); +}); + +it('reports only measurements of the supplied project implementations', () => { + const report = runComparison({ locks: [fixture], candidate: implementation(), baseline: implementation(), repetitions: 1, warmups: 0 }); + expect(Object.keys(report.implementations)).toEqual(['candidate', 'baseline']); + expect(report.results[0]).not.toHaveProperty('historicalReference'); +}); + +it('checks target-branch snapshot against the live base independently of candidate expectations', () => { + const baselineSnapshot = { schemaVersion: 1, fixtureSha256: catalogSha256, source: {}, reason: 'test baseline', + locks: [{ id: 'unit', optimumA: 1, ...commandMetrics(commands), commands: [[0, 2], [0, -1]] as const }] }; + expect(() => runComparison({ locks: [fixture], candidate: implementation(), baseline: implementation(), baselineSnapshot, repetitions: 1, warmups: 0 })).toThrow(/snapshot path drift/); + +}); + +it('publishes report, Markdown and hash-linked release evidence without changing earlier reports', async () => { + const root = await mkdtemp(join(tmpdir(), 'benchmark-report-')); + try { + const report = runComparison({ locks: [fixture], candidate: implementation(), baseline: implementation(), repetitions: 1, warmups: 0 }); + const first = await publishReport(report, root); const previous = await readFile(first.jsonPath, 'utf8'); + const second = await publishReport(report, root); + expect(second.directory).not.toBe(first.directory); + expect(await readFile(first.jsonPath, 'utf8')).toBe(previous); + const parsed: unknown = JSON.parse(await readFile(first.evidencePath, 'utf8')); + const evidence = record(parsed); + expect(record(record(evidence['reports'])['json'])['sha256']).toBe(sha256(previous)); + expect(record(array(record(evidence['qualitySnapshot'])['locks'])[0])['commands']).toEqual(commands); + expect(await readFile(first.markdownPath, 'utf8')).toMatch(/inconclusive/); + } finally { await rm(root, { recursive: true, force: true }); } +}); + +it('isolated RSS worker restricts its workload to the selected smoke fixtures', async () => { + const { measureRss } = await import('../benchmarks/memory.ts'); + const result = measureRss({ name: 'baseline', module: new URL('../../src/index.ts', import.meta.url).pathname, format: 'tuple' }, loadCatalog().slice(0, 2).map((lock) => lock.id).join(',')); + expect(result.lockCount).toBe(2); + expect(result.peakRssBytes).toBeGreaterThan(0); + expect(result.startupRssBytes).toBeGreaterThan(0); +}); + +it('names the process-image RSS metric and preserves launch-floor diagnostics', async () => { + const { measureRss } = await import('../benchmarks/memory.ts'); + const result = measureRss({ name: 'baseline', module: new URL('../../src/index.ts', import.meta.url).pathname, format: 'tuple' }, loadCatalog()[0]?.id); + expect(result.peakRssMetric).toBe(process.platform === 'linux' ? 'linux-proc-VmHWM' : 'node-resourceUsage-maxRSS'); + expect(result.startupPeakRssBytes).toBeGreaterThan(0); + expect(result.resourceUsageMaxRssBytes).toBeGreaterThan(0); +}); + + +it('requires ignored artifact output paths and rejects snapshots with missing identity', async () => { + expect(() => assertArtifactOutput('docs/benchmarks')).toThrow(/ignored artifacts/); + expect(() => assertArtifactOutput('artifacts/../tests')).toThrow(/ignored artifacts/); + expect(() => assertArtifactOutput('artifacts/benchmark')).not.toThrow(); + const root = await mkdtemp(join(tmpdir(), 'benchmark-snapshot-')); + try { + const path = join(root, 'snapshot.json'); + await writeSnapshot(path, { schemaVersion: 1, fixtureSha256: catalogSha256, source: {}, reason: 'missing identity', locks: [] }); + expect(() => readSnapshot(path)).toThrow(/source|identity|baseline/); + } finally { await rm(root, { recursive: true, force: true }); } +}); + + +it('retains both complete raw timing attempts when a matched slowdown repeats', () => { + let elapsed = 0; + const order: string[] = []; + const measured = (name: string, duration: number) => implementation(() => { order.push(name); elapsed += duration; return commands; }); + const report = runComparison({ locks: [fixture], candidate: measured('candidate', 2), baseline: measured('baseline', 1), repetitions: 5, warmups: 0, clock: () => elapsed }); + const row = report.results[0]; + expect(row?.timing.verdict).toBe('regression'); + expect(row?.timingAttempts).toHaveLength(2); + for (const attempt of row?.timingAttempts ?? []) { + expect(attempt.candidate).toEqual([2, 2, 2, 2, 2]); + expect(attempt.baseline).toEqual([1, 1, 1, 1, 1]); + expect(attempt.selfA).toEqual([1, 1, 1, 1, 1]); + expect(attempt.selfB).toEqual([1, 1, 1, 1, 1]); + } + expect(order.slice(10, 14)).toEqual(['candidate', 'baseline', 'baseline', 'candidate']); +}); + +async function makeTypedCheckout() { + const directory = await mkdtemp(join(tmpdir(), 'benchmark-typed-base-')); + await mkdir(join(directory, 'src')); + await mkdir(join(directory, 'dist')); + await writeFile(join(directory, '.gitignore'), 'dist/\n'); + const trackedModule = join(directory, 'src', 'index.ts'); + await writeFile(trackedModule, 'export function solveLock() { return [[0, 1]]; }\n'); + execFileSync('git', ['init', '--quiet', directory]); + execFileSync('git', ['-C', directory, 'add', '.']); + execFileSync('git', ['-C', directory, '-c', 'user.name=Benchmark Test', '-c', 'user.email=benchmark@example.invalid', 'commit', '--quiet', '-m', 'Own typed source']); + const revision = execFileSync('git', ['-C', directory, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(); + const module = join(directory, 'dist', 'gothic-lock-solver.mjs'); + await writeFile(module, 'export function solveLock() { return [[0, 1]]; }\n'); + const manifest = { schemaVersion: 1, package: 'gothic-lock-solver', version: '1.0.0', sourceSha: revision, + files: { 'gothic-lock-solver.mjs': sha256(await readFile(module)) } }; + return { directory, trackedModule, module, manifest, manifestPath: join(directory, 'dist', 'manifest.json') }; +} + +describe('typed baseline build provenance', () => { + it.each(['missing manifest', 'stale revision', 'wrong module checksum'] as const)('rejects %s in an otherwise clean checkout', async (problem) => { + const checkout = await makeTypedCheckout(); + try { + if (problem !== 'missing manifest') await writeFile(checkout.manifestPath, JSON.stringify({ ...checkout.manifest, + ...(problem === 'stale revision' ? { sourceSha: '0'.repeat(40) } : { files: { 'gothic-lock-solver.mjs': '0'.repeat(64) } }) })); + expect(() => inspectSource(checkout.module, 'tuple')).toThrow(/manifest|revision|checksum|hash/); + } finally { await rm(checkout.directory, { recursive: true, force: true }); } + }); + it('accepts the module whose manifest pins its checkout revision and bytes', async () => { + const checkout = await makeTypedCheckout(); + try { + await writeFile(checkout.manifestPath, JSON.stringify(checkout.manifest)); + expect(inspectSource(checkout.module, 'tuple')).toMatchObject({ revision: checkout.manifest.sourceSha, moduleSha256: checkout.manifest.files['gothic-lock-solver.mjs'] }); + } finally { await rm(checkout.directory, { recursive: true, force: true }); } + }); + it('rejects a baseline module from the candidate checkout before measuring', async () => { + const checkout = await makeTypedCheckout(); + try { + await writeFile(checkout.manifestPath, JSON.stringify(checkout.manifest)); + const baseline = inspectSource(checkout.trackedModule, 'tuple'); + const snapshot = snapshotFromImplementation(implementation(), baseline, 'test', [fixture]); + const options = parseBenchmarkArgs(['--candidate-module', checkout.module, '--baseline-module', checkout.trackedModule]); + expect(() => collectMetadata(options, snapshot)).toThrow(/same.*checkout|candidate.*checkout/); + } finally { await rm(checkout.directory, { recursive: true, force: true }); } + }); + it('rejects a separate checkout at the candidate revision', async () => { + const checkout = await makeTypedCheckout(); + const copy = await mkdtemp(join(tmpdir(), 'benchmark-same-revision-')); + try { + execFileSync('git', ['clone', '--quiet', checkout.directory, copy]); + const baselineModule = join(copy, 'src', 'index.ts'); + const baseline = inspectSource(baselineModule, 'tuple'); + const snapshot = snapshotFromImplementation(implementation(), baseline, 'test', [fixture]); + const options = parseBenchmarkArgs(['--candidate-module', checkout.trackedModule, '--baseline-module', baselineModule]); + expect(() => collectMetadata(options, snapshot)).toThrow(/same.*revision|candidate.*revision/); + } finally { await rm(checkout.directory, { recursive: true, force: true }); await rm(copy, { recursive: true, force: true }); } + }); +}); + + +it('rejects a symlink alias of the candidate module', async () => { + const checkout = await makeTypedCheckout(); + const aliasRoot = await mkdtemp(join(tmpdir(), 'benchmark-module-alias-')); + try { + const alias = join(aliasRoot, 'baseline.ts'); + await symlink(checkout.trackedModule, alias); + expect(() => inspectComparisonBaseline(checkout.trackedModule, alias, 'tuple')).toThrow(/same module/); + } finally { await rm(checkout.directory, { recursive: true, force: true }); await rm(aliasRoot, { recursive: true, force: true }); } +}); + +it('accepts identical module bytes from a separate checkout at a different target revision', async () => { + const checkout = await makeTypedCheckout(); + const copy = await mkdtemp(join(tmpdir(), 'benchmark-other-revision-')); + try { + execFileSync('git', ['clone', '--quiet', checkout.directory, copy]); + execFileSync('git', ['-C', copy, '-c', 'user.name=Benchmark Test', '-c', 'user.email=benchmark@example.invalid', 'commit', '--quiet', '--allow-empty', '-m', 'Another target revision']); + const baseline = join(copy, 'src', 'index.ts'); + expect(inspectComparisonBaseline(checkout.trackedModule, baseline, 'tuple')).toHaveProperty('moduleSha256', sha256(await readFile(checkout.trackedModule))); + } finally { await rm(checkout.directory, { recursive: true, force: true }); await rm(copy, { recursive: true, force: true }); } +}); + +it('allows a same-revision preceding release only with its verified manifest and evidence chain', async () => { + const checkout = await makeTypedCheckout(); + try { + const release = join(checkout.directory, 'artifacts', 'baseline'); + await mkdir(release, { recursive: true }); + const module = join(release, 'baseline.mjs'); + await writeFile(module, await readFile(checkout.module)); + const originalSource = { revision: checkout.manifest.sourceSha, moduleSha256: sha256(await readFile(module)), format: 'tuple' }; + const evidenceBytes = JSON.stringify({ qualitySnapshot: { source: originalSource } }); + const evidenceHash = sha256(evidenceBytes); + const assets = { ...Object.fromEntries(releaseAssets.map((name) => [name, 'a'.repeat(64)])), + 'gothic-lock-solver.mjs': originalSource.moduleSha256, 'benchmark-evidence.json': evidenceHash }; + const manifestBytes = JSON.stringify({ schemaVersion: 1, name: 'gothic-lock-solver', version: '1.0.0', sourceSha: originalSource.revision, + kind: 'stable', tarballSha256: 'a'.repeat(64), assets }); + const pinned = { ...originalSource, kind: 'previous-stable-release', releaseVersion: '1.0.0', + releaseManifestSha256: sha256(manifestBytes), releaseEvidenceSha256: evidenceHash }; + expect(() => inspectComparisonBaseline(checkout.trackedModule, module, 'tuple', pinned)).toThrow(/manifest/); + await writeFile(join(release, 'previous-release-manifest.json'), manifestBytes); + await writeFile(join(release, 'previous-benchmark-evidence.json'), evidenceBytes); + expect(inspectComparisonBaseline(checkout.trackedModule, module, 'tuple', pinned)).toMatchObject(pinned); + await writeFile(join(release, 'previous-benchmark-evidence.json'), `${evidenceBytes} `); + expect(() => inspectComparisonBaseline(checkout.trackedModule, module, 'tuple', pinned)).toThrow(/evidence hash/); + } finally { await rm(checkout.directory, { recursive: true, force: true }); } +}); diff --git a/tests/unit/cli.test.ts b/tests/unit/cli.test.ts new file mode 100644 index 0000000..3998162 --- /dev/null +++ b/tests/unit/cli.test.ts @@ -0,0 +1,170 @@ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { runCli } from '../../src/cli.ts'; + +const directories: string[] = []; +afterEach(async () => { + await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))); +}); + +async function fixture(contents: unknown) { + const directory = await mkdtemp(join(tmpdir(), 'gothic-cli-')); + directories.push(directory); + const input = join(directory, 'lock.json'); + await writeFile(input, JSON.stringify(contents)); + return { directory, input, output: join(directory, 'solution.json') }; +} + +async function run(args: readonly string[]) { + let stdout = ''; + let stderr = ''; + const code = await runCli(args, { + stdout: { write: (text) => { stdout += text; } }, + stderr: { write: (text) => { stderr += text; } }, + }); + return { code, stdout, stderr }; +} + +describe('CLI file adapter', () => { + it('prints grouped actions while leaving files untouched unless --output is requested', async () => { + const { input, output } = await fixture({ state: [1, 7], links: [[0, 0], [0, 0]] }); + expect(await run([input])).toEqual({ code: 0, stdout: 'Найдено команд: 2\n1. Пластина 1 - влево x3\n2. Пластина 2 - вправо x3\n', stderr: '' }); + await expect(readFile(output)).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('creates and overwrites the existing structured JSON contract', async () => { + const { input, output } = await fixture({ state: [1, 7], links: [[0, 0], [0, 0]] }); + const expected = { + status: 'solved', initialState: [1, 7], targetState: [4, 4], + commands: [{ plate: 1, direction: 'left', steps: 3 }, { plate: 2, direction: 'right', steps: 3 }], + finalState: [4, 4], metrics: { commands: 2, divisions: 6, plateSwitches: 1 }, + }; + for (const stale of ['', 'stale'.repeat(1000)]) { + await writeFile(output, stale); + expect((await run([input, '--output', output])).code).toBe(0); + expect(await readFile(output, 'utf8')).toBe(`${JSON.stringify(expected, null, 2)}\n`); + } + }); + + it('distinguishes an already open lock from a proven unreachable goal', async () => { + const open = await fixture({ state: [4, 4], links: [[0, 0], [0, 0]] }); + expect(await run([open.input])).toEqual({ code: 0, stdout: 'Замок уже открыт.\n', stderr: '' }); + const blocked = await fixture({ state: [1, 7], links: [[0, 1], [1, 0]] }); + expect(await run([blocked.input, '--output', blocked.output])).toEqual({ code: 2, stdout: 'Решение не найдено.\n', stderr: '' }); + const result: unknown = JSON.parse(await readFile(blocked.output, 'utf8')); + expect(result).toEqual({ status: 'unsolvable', initialState: [1, 7], targetState: [4, 4], commands: [] }); + }); + + it.each([ + {}, + { maxVisited: 2_000_000, maxExpanded: 1_000_000, maxFrontier: 1_000_000, maxDenseBytes: 67_108_864 }, + { maxDenseBytes: 0 }, + ])('uses defaults for omitted settings in configuration %j', async (config) => { + const files = await fixture({ state: [1, 7], links: [[0, 0], [0, 0]] }); + const configPath = join(files.directory, 'solver.config.json'); + const contents = JSON.stringify(config); + await writeFile(configPath, contents); + expect(await run([files.input, '--config', configPath])).toEqual({ + code: 0, + stdout: 'Найдено команд: 2\n1. Пластина 1 - влево x3\n2. Пластина 2 - вправо x3\n', + stderr: '', + }); + expect(await readFile(configPath, 'utf8')).toBe(contents); + }); + + it('applies a partial configuration and reports exhaustion as an error', async () => { + const files = await fixture({ state: [3, 1, 1], links: [[0, -1, -1], [-1, 0, -1], [-1, -1, 0]] }); + const configPath = join(files.directory, 'solver.config.json'); + await writeFile(configPath, JSON.stringify({ maxExpanded: 0 })); + expect(await run([files.input, '--config', configPath, '--output', files.output])).toMatchObject({ + code: 1, stdout: '', stderr: expect.stringContaining('maxExpanded'), + }); + await expect(readFile(files.output)).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('accepts output before configuration and preserves the JSON result', async () => { + const files = await fixture({ state: [4, 4], links: [[0, 0], [0, 0]] }); + const configPath = join(files.directory, 'solver.config.json'); + await writeFile(configPath, JSON.stringify({ maxExpanded: 0 })); + expect(await run([files.input, '--output', files.output, '--config', configPath])).toEqual({ + code: 0, stdout: 'Замок уже открыт.\n', stderr: '', + }); + const output: unknown = JSON.parse(await readFile(files.output, 'utf8')); + expect(output).toEqual({ + status: 'solved', initialState: [4, 4], targetState: [4, 4], commands: [], finalState: [4, 4], + metrics: { commands: 0, divisions: 0, plateSwitches: 0 }, + }); + }); + + it.each([null, [], 1, 'config', { unknown: 1 }, { maxVisited: '10' }, { maxExpanded: null }, { maxFrontier: 0 }].map((config) => ({ config })))( + 'rejects an invalid configuration $config without running the solver', async ({ config }) => { + const files = await fixture({ state: [4, 4], links: [[0, 0], [0, 0]] }); + const configPath = join(files.directory, 'solver.config.json'); + await writeFile(configPath, JSON.stringify(config)); + const result = await run([files.input, '--config', configPath]); + expect(result).toMatchObject({ code: 1, stdout: '' }); + expect(result.stderr).toMatch(/^Ошибка:/u); + expect(result.stderr).not.toContain('Использование'); + }, + ); + + it('reports a missing configuration file', async () => { + const files = await fixture({ state: [4, 4], links: [[0, 0], [0, 0]] }); + const configPath = join(files.directory, 'solver.config.json'); + expect(await run([files.input, '--config', configPath])).toMatchObject({ + code: 1, stdout: '', stderr: expect.stringContaining('ENOENT'), + }); + }); + + it('reports malformed configuration JSON', async () => { + const files = await fixture({ state: [4, 4], links: [[0, 0], [0, 0]] }); + const configPath = join(files.directory, 'solver.config.json'); + await writeFile(configPath, '{'); + expect(await run([files.input, '--config', configPath])).toMatchObject({ + code: 1, stdout: '', stderr: expect.stringContaining('Некорректный JSON'), + }); + }); + + it('prints help successfully without requiring an input file', async () => { + const result = await run(['--help']); + expect(result.code).toBe(0); + expect(result.stderr).toBe(''); + expect(result.stdout).toMatch(/^Использование:/u); + expect(result.stdout).toContain('--config'); + expect(result.stdout).toContain('--output'); + }); + + it.each([ + [], [''], ['--unknown'], ['x', '--output'], ['x', 'extra'], ['x', '--output', ''], + ['x', '--output', 'y', 'extra'], ['x', '--config'], ['x', '--config', ''], + ['x', '--config', '--output', 'y'], ['x', '--unknown', 'y'], + ['x', '--config', 'a', '--config', 'b'], ['x', '--output', 'a', '--output', 'b'], + ['--help', '--unknown'], ['x', '--help'], + ])('rejects malformed command line %j', async (...args) => { + const result = await run(args); + expect(result.code).toBe(1); + expect(result.stdout).toBe(''); + expect(result.stderr).toMatch(/^Ошибка: Использование:/u); + }); + + it.each([null, [], {}, { state: [0, 4], links: [[0, 0], [0, 0]] }].map((input) => ({ input })))('rejects malformed input $input without reporting unsolvable', async ({ input }) => { + const files = await fixture(input); + const result = await run([files.input]); + expect(result.code).toBe(1); + expect(result.stdout).toBe(''); + expect(result.stderr).toMatch(/^Ошибка:/u); + }); + + it('reports JSON, input IO and output IO failures without a success message', async () => { + const files = await fixture({ state: [4, 4], links: [[0, 0], [0, 0]] }); + for (const args of [[join(files.directory, 'missing.json')], [files.input, '--output', files.directory]]) { + const result = await run(args); + expect(result).toMatchObject({ code: 1, stdout: '' }); + expect(result.stderr).toMatch(/^Ошибка:/u); + } + await writeFile(files.input, '{'); + expect(await run([files.input])).toMatchObject({ code: 1, stdout: '', stderr: expect.stringContaining('Некорректный JSON') }); + }); +}); diff --git a/tests/unit/config.test.ts b/tests/unit/config.test.ts new file mode 100644 index 0000000..cc25e7e --- /dev/null +++ b/tests/unit/config.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it } from 'vitest'; +import * as api from '../../src/index.ts'; +import type { SolverConfig } from '../../src/index.ts'; + +const defaults = { + maxVisited: 2_000_000, + maxExpanded: 1_000_000, + maxFrontier: 1_000_000, + maxDenseBytes: 67_108_864, +}; +const state = [1, 7] as const; +const links = [[0, 0], [0, 0]] as const; + +function configFromJavaScript(value: unknown): unknown { + return Reflect.apply(api.createSolverConfig, undefined, [value]); +} + +function solveFromJavaScript(value: unknown): unknown { + return Reflect.apply(api.solveLock, undefined, [state, links, value]); +} + +describe('public solver configuration', () => { + it('exports a factory that returns complete frozen defaults', () => { + expect(api).toHaveProperty('createSolverConfig'); + const config = api.createSolverConfig(); + expect(config).toEqual(defaults); + expect(Object.isFrozen(config)).toBe(true); + expect(api.createSolverConfig(undefined)).toEqual(defaults); + expect(api.solveLock(state, links, config)).toEqual([[0, 3], [1, -3]]); + expect(api.solveLock(state, links)).toEqual([[0, 3], [1, -3]]); + }); + + it('copies partial overrides without freezing or mutating the caller', () => { + const overrides = { maxVisited: 3, maxDenseBytes: 0 }; + const config = api.createSolverConfig(overrides); + expect(config).toEqual({ ...defaults, maxVisited: 3, maxDenseBytes: 0 }); + expect(overrides).toEqual({ maxVisited: 3, maxDenseBytes: 0 }); + expect(Object.isFrozen(overrides)).toBe(false); + overrides.maxVisited = 1; + expect(config.maxVisited).toBe(3); + expect(Reflect.set(config, 'maxVisited', 1)).toBe(false); + expect(api.solveLock(state, links, config)).toEqual([[0, 3], [1, -3]]); + }); + + it('accepts inclusive integer bounds and zero for expansion and dense storage', () => { + expect(api.createSolverConfig({ maxVisited: 1, maxFrontier: 1, maxExpanded: 0, maxDenseBytes: 0 })) + .toEqual({ maxVisited: 1, maxFrontier: 1, maxExpanded: 0, maxDenseBytes: 0 }); + const maximum = Object.fromEntries(Object.keys(defaults).map((key) => [key, Number.MAX_SAFE_INTEGER])); + expect(configFromJavaScript(maximum)).toEqual(maximum); + }); + + it.each([null, [], 1, 'config', true, { unknown: 1 }, { [Symbol('unknown')]: 1 }].map((value) => ({ value }))) + ('rejects invalid override objects and unknown fields: $value', ({ value }) => { + expect(() => configFromJavaScript(value)).toThrow(api.LockInputError); + }); + + for (const field of Object.keys(defaults)) { + it.each([undefined, null, true, '1', 1n, -1, 0.5, Number.NaN, Infinity, Number.MAX_SAFE_INTEGER + 1]) + (`rejects invalid ${field} values: %s`, (value) => { + expect(() => configFromJavaScript({ [field]: value })).toThrow(api.LockInputError); + expect(() => solveFromJavaScript({ ...defaults, [field]: value })).toThrow(api.LockInputError); + }); + } + + it('rejects zero visited/frontier bounds and nonenumerable unknown fields', () => { + for (const field of ['maxVisited', 'maxFrontier']) { + expect(() => configFromJavaScript({ [field]: 0 })).toThrow(api.LockInputError); + } + expect(() => configFromJavaScript(Object.defineProperty({}, 'unknown', { value: 1 }))).toThrow(api.LockInputError); + }); + + it.each([null, [], false, {}, { maxVisited: 3 }, { ...defaults, unknown: 1 }].map((value) => ({ value }))) + ('revalidates complete configs from JavaScript: $value', ({ value }) => { + expect(() => solveFromJavaScript(value)).toThrow(api.LockInputError); + }); + + it('requires own complete fields and validates ordinary caller mutations', () => { + const inherited: unknown = Object.create(defaults); + expect(() => solveFromJavaScript(inherited)).toThrow(api.LockInputError); + const ordinary = { ...defaults }; + expect(solveFromJavaScript(ordinary)).toEqual([[0, 3], [1, -3]]); + expect(ordinary).toEqual(defaults); + expect(Object.isFrozen(ordinary)).toBe(false); + ordinary.maxExpanded = -1; + expect(() => solveFromJavaScript(ordinary)).toThrow(api.LockInputError); + }); +}); + +describe('public search budgets', () => { + const cases = [ + { name: 'certificate', state, links }, + { name: 'A*', state: [3, 1, 1] as const, links: [[0, -1, -1], [-1, 0, -1], [-1, -1, 0]] as const }, + { name: 'singular BFS', state: [1, 1] as const, links: [[0, 1], [1, 0]] as const }, + ]; + for (const lock of cases) { + for (const overrides of [{ maxExpanded: 0 }, { maxVisited: 1 }]) { + it(`${lock.name} reports ${Object.keys(overrides)[0]} exhaustion instead of null`, () => { + const config = api.createSolverConfig(overrides); + expect(() => api.solveLock(lock.state, lock.links, config)).toThrow(api.SearchLimitError); + }); + } + } + + it('enforces frontier limits in both exact search strategies', () => { + for (const lock of cases.slice(1)) { + expect(() => api.solveLock(lock.state, lock.links, api.createSolverConfig({ maxFrontier: 1 }))) + .toThrow(api.SearchLimitError); + } + }); + + it('counts the certificate path and permits exactly sufficient budgets', () => { + const overrides: Partial = { maxVisited: 3, maxExpanded: 2, maxFrontier: 1 }; + expect(api.solveLock(state, links, api.createSolverConfig(overrides))).toEqual([[0, 3], [1, -3]]); + expect(() => api.solveLock(state, links, api.createSolverConfig({ ...overrides, maxVisited: 2 }))) + .toThrow(api.SearchLimitError); + expect(() => api.solveLock(state, links, api.createSolverConfig({ ...overrides, maxExpanded: 1 }))) + .toThrow(api.SearchLimitError); + }); + + it('preserves singular BFS action order in dense and forced sparse storage', () => { + const singular = [[0, 1, 0], [1, 0, 0], [0, 0, 0]] as const; + for (const maxDenseBytes of [0, defaults.maxDenseBytes]) { + expect(api.solveLock([1, 1, 7], singular, api.createSolverConfig({ maxDenseBytes }))) + .toEqual([[0, 3], [2, -3]]); + } + }); + + it('allows open and algebraically unreachable locks without state expansion', () => { + const config = api.createSolverConfig({ maxExpanded: 0 }); + expect(api.solveLock([4, 4], links, config)).toEqual([]); + expect(api.solveLock([3, 4], [[0, 1], [-1, 0]], config)).toBeNull(); + }); +}); diff --git a/tests/unit/core-contract.test.ts b/tests/unit/core-contract.test.ts new file mode 100644 index 0000000..8ea9d31 --- /dev/null +++ b/tests/unit/core-contract.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from 'vitest'; +import * as api from '../../src/index.ts'; +import type { Link, Position } from '../../src/index.ts'; +import { referenceActions, replay, unitMove } from './oracle.ts'; + +const inverse = [[0, -1, -1], [-1, 0, -1], [-1, -1, 0]] as const; +const independent = (state: readonly Position[]) => state.map(() => state.map((): Link => 0)); + +function solveUnknown(state: unknown, links: unknown): unknown { + return Reflect.apply(api.solveLock, undefined, [state, links]); +} + +describe('public tuple contract', () => { + it('exports the named solver, config factory and documented error classes', () => { + expect(Object.keys(api).sort()).toEqual(['LockInputError', 'SearchLimitError', 'createSolverConfig', 'solveLock']); + }); + it('uses numeric pin delta and links[source][target] without reverse influence', () => { + expect(api.solveLock([6, 2], [[0, -1], [0, 0]])).toEqual([[0, -2]]); + expect(api.solveLock([4, 2], [[0, -1], [0, 0]])).toEqual([[1, 2]]); + }); + it('applies direct effects once without cascades', () => { + const state = [3, 3, 4] as const; + const links = [[0, 1, 0], [0, 0, 1], [0, 0, 0]] as const; + expect(api.solveLock(state, links)).toEqual([[0, 1]]); + }); + it('does not mutate frozen input and returns independent repeatable results', () => { + const state: readonly Position[] = Object.freeze([1, 7, 2, 6, 3, 5, 4, 1]); + const links = Object.freeze(independent(state).map((row) => Object.freeze(row))); + const result = api.solveLock(state, links); + expect(result).toHaveLength(7); + if (result === null) throw new Error('Expected solution'); + expect(replay({ state, links }, result)).toEqual(state.map(() => 4)); + expect(api.solveLock(state, links)).toEqual(result); + expect(api.solveLock(state, links)).not.toBe(result); + }); + it('returns [] for open and null only for proved unreachable locks', () => { + expect(api.solveLock([4, 4], [[0, 0], [0, 0]])).toEqual([]); + expect(api.solveLock([1, 1, 1], inverse)).toBeNull(); + expect(api.solveLock([3, 4], [[0, 1], [-1, 0]])).toBeNull(); + expect(api.solveLock([3, 4], [[0, 1], [1, 0]])).toBeNull(); + }); + it('handles singular matrices and repeated-plate schedules exactly', () => { + expect(api.solveLock([1, 1], [[0, 1], [1, 0]])).toEqual([[0, 3]]); + const lock = { state: [3, 1, 1] as const, links: inverse }; + const result = api.solveLock(lock.state, lock.links); + expect(result).toHaveLength(4); + if (result === null) throw new Error('Expected solution'); + expect(replay(lock, result)).toEqual([4, 4, 4]); + expect(new Set(result.map(([index]) => index)).size).toBeLessThan(result.length); + }); + it('allows net movements above six while minimizing actions', () => { + const lock = { state: [7, 7, 1] as const, links: [[0, -1, 1], [0, 0, 1], [0, 0, 0]] as const }; + const result = api.solveLock(lock.state, lock.links); + expect(referenceActions(lock)).toBe(4); + expect(result).toHaveLength(4); + if (result === null) throw new Error('Expected solution'); + expect(replay(lock, result)).toEqual([4, 4, 4]); + }); + it('rejects invalid dimensions, positions, links, diagonals and sparse arrays', () => { + const sparseState = [4, 4, 4]; + delete sparseState[1]; + const sparseMatrix = [[0, 0], [0, 0]]; + delete sparseMatrix[0]; + const sparseRow = [0, 0]; + delete sparseRow[1]; + const bad: readonly (readonly [unknown, unknown])[] = [ + [null, null], [[], []], [[4], [[0]]], [[0, 4], [[0, 0], [0, 0]]], + [[4, 8], [[0, 0], [0, 0]]], [[4, 2.5], [[0, 0], [0, 0]]], + [[4, NaN], [[0, 0], [0, 0]]], [[4, '4'], [[0, 0], [0, 0]]], + [[4, 4], [[0, 0]]], [[4, 4], [[0, 0], [0]]], + [[4, 4], [[1, 0], [0, 0]]], [[4, 4], [[0, 2], [0, 0]]], + [sparseState, [[0, 0, 0], [0, 0, 0], [0, 0, 0]]], + [[4, 4], sparseMatrix], [[4, 4], [sparseRow, [0, 0]]], + ]; + for (const [state, links] of bad) expect(() => solveUnknown(state, links)).toThrow(api.LockInputError); + const inherited = [4, 4]; + delete inherited[0]; + Object.setPrototypeOf(inherited, Object.assign(Object.create(Array.prototype), { 0: 4 })); + expect(() => solveUnknown(inherited, [[0, 0], [0, 0]])).toThrow(api.LockInputError); + }); + it('blocks the complete direct movement when a linked pin hits a boundary', () => { + expect(unitMove([4, 7], [[0, 1], [0, 0]], 0, 1)).toBeNull(); + const lock = { state: [1, 7] as const, links: [[0, 1], [0, 0]] as const }; + const result = api.solveLock(lock.state, lock.links); + const expected = referenceActions(lock); + if (expected === null) throw new Error('Expected reachable oracle lock'); + expect(result).toHaveLength(expected); + if (result === null) throw new Error('Expected solution'); + expect(replay(lock, result)).toEqual([4, 4]); + }); + it('supports 18-plate safe codes and reports encoding exhaustion separately from null', () => { + const state: Position[] = [1, ...Array.from({ length: 17 }, (): Position => 4)]; + expect(api.solveLock(state, independent(state))).toEqual([[0, 3]]); + const tooWide = Array.from({ length: 19 }, (): Position => 4); + expect(() => api.solveLock(tooWide, independent(tooWide))).toThrow(api.SearchLimitError); + try { api.solveLock(tooWide, independent(tooWide)); } catch (error) { + expect(error).toBeInstanceOf(api.SearchLimitError); + if (error instanceof api.SearchLimitError) expect(error.limit).toBe('stateEncoding'); + } + }); +}); diff --git a/tests/unit/core-contract.types.ts b/tests/unit/core-contract.types.ts new file mode 100644 index 0000000..6656b04 --- /dev/null +++ b/tests/unit/core-contract.types.ts @@ -0,0 +1,37 @@ +import { solveLock } from '../../src/index.ts'; +import type { Command, Delta, Link, Links, Position, State } from '../../src/index.ts'; +const state = [6, 2] as const satisfies State; +const links = [[0, -1], [0, 0]] as const satisfies Links; +const result: readonly Command[] | null = solveLock(state, links); +void result; +const dynamicState: readonly Position[] = [1, 7, 4]; +const dynamicLinks: readonly (readonly Link[])[] = [[0, 0, 0], [0, 0, 0], [0, 0, 0]]; +solveLock(dynamicState, dynamicLinks); +// @ts-expect-error one plate is not a fixed valid state +solveLock([4], [[0]]); +// @ts-expect-error no state positions outside 1..7 +solveLock([0, 4], [[0, 0], [0, 0]]); +// @ts-expect-error N is inferred only from state; row count must be N +solveLock([4, 4], [[0, 0], [0, 0], [0, 0]]); +// @ts-expect-error fixed tuples require square rows +solveLock([4, 4], [[0, 0, 0], [0, 0, 0]]); +// @ts-expect-error short rows must be rejected +solveLock(state, [[0], [0]]); +// @ts-expect-error links are -1, 0 or 1 +solveLock(state, [[0, 2], [0, 0]]); +// @ts-expect-error old object wrapper is not public +solveLock({ state, links }); +// @ts-expect-error unknown algorithm selectors are not solver configuration +solveLock(state, links, { algorithm: 'bfs' }); +// @ts-expect-error zero delta is not a command +const invalidDelta: Delta = 0; +void invalidDelta; +if (result !== null) { + // @ts-expect-error returned list is readonly + result.push([0, 1]); + const first = result[0]; + if (first !== undefined) { + // @ts-expect-error returned command is readonly + first[1] = 1; + } +} diff --git a/tests/unit/core-limits.test.ts b/tests/unit/core-limits.test.ts new file mode 100644 index 0000000..f949a00 --- /dev/null +++ b/tests/unit/core-limits.test.ts @@ -0,0 +1,47 @@ +import { expect, it } from 'vitest'; +import { SearchLimitError } from '../../src/index.ts'; +import { LockModel } from '../../src/lock.ts'; +import { PreparedSearch, movementLimits } from '../../src/lock.ts'; +import { SearchBudget } from '../../src/config.ts'; +import { createSolverConfig } from '../../src/index.ts'; +import { BfsSearch } from '../../src/bfs.ts'; +import { MatrixSearch } from '../../src/astar.ts'; +import { analyzeMatrix } from '../../src/matrix.ts'; + +it('reports exhaustion separately on BFS, A*, singular fallback and greedy certificate', () => { + const inputs = [ + new LockModel([1, 7], [[0, 0], [0, 0]]), + new LockModel([3, 1, 1], [[0, -1, -1], [-1, 0, -1], [-1, -1, 0]]), + new LockModel([1, 1], [[0, 1], [1, 0]]), + ]; + for (const model of inputs) { + const prepared = new PreparedSearch(model); + for (const options of [{ maxExpanded: 0 }, { maxVisited: 1 }]) { + expect(() => new MatrixSearch(model, prepared, new SearchBudget(createSolverConfig(options))).solve()).toThrow(SearchLimitError); + expect(() => new BfsSearch(prepared, new SearchBudget(createSolverConfig(options))).solve()).toThrow(SearchLimitError); + } + expect(() => new BfsSearch(prepared, new SearchBudget(createSolverConfig({ maxFrontier: 1 }))).solve()).toThrow(SearchLimitError); + } +}); +it('dense and sparse BFS preserve exact action order, including wider safe codes', () => { + const model = new LockModel([2, 7, 1], [[0, 0, 0], [-1, 0, -1], [-1, -1, 0]]); + const prepared = new PreparedSearch(model); + const expected = [[0, 5], [2, 3]]; + expect(new BfsSearch(prepared, new SearchBudget()).solve()).toEqual(expected); + expect(new BfsSearch(prepared, new SearchBudget(createSolverConfig({ maxDenseBytes: 0 }))).solve()).toEqual(expected); + const state = [1, ...Array.from({ length: 17 }, () => 4)]; + const wide = new PreparedSearch(new LockModel(state, state.map(() => state.map(() => 0)))); + expect(new BfsSearch(wide, new SearchBudget(createSolverConfig({ maxDenseBytes: 0, maxVisited: 5, maxExpanded: 1, maxFrontier: 4 }))).solve()).toEqual([[0, 3]]); +}); +it('distinguishes exact rational proofs from rank deficiency', () => { + expect(analyzeMatrix(new LockModel([3, 4], [[0, 1], [-1, 0]])).kind).toBe('noninteger'); + expect(analyzeMatrix(new LockModel([3, 4], [[0, 1], [1, 0]])).kind).toBe('inconsistent'); + expect(analyzeMatrix(new LockModel([1, 1], [[0, 1], [1, 0]])).kind).toBe('singular'); + expect(analyzeMatrix(new LockModel([7, 7, 1], [[0, -1, 1], [0, 0, 1], [0, 0, 0]]))).toEqual({ kind: 'unique-integer', rank: 3, net: [-3, -6, 12], lowerBound: 4 }); +}); +it('computes the legal range from all direct affected pins atomically', () => { + const prepared = new PreparedSearch(new LockModel([4, 7], [[0, 1], [0, 0]])); + const effect = prepared.effects[0]; + if (effect === undefined) throw new Error('Missing prepared effect'); + expect(movementLimits(new Uint8Array([3, 6]), effect)).toEqual([0, 3]); +}); diff --git a/tests/unit/core-oracle.test.ts b/tests/unit/core-oracle.test.ts new file mode 100644 index 0000000..4fb7aa1 --- /dev/null +++ b/tests/unit/core-oracle.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from 'vitest'; +import { solveLock } from '../../src/index.ts'; +import type { Link, Position } from '../../src/index.ts'; +import { LockModel } from '../../src/lock.ts'; +import { PreparedSearch } from '../../src/lock.ts'; +import { SearchBudget } from '../../src/config.ts'; +import { createSolverConfig } from '../../src/index.ts'; +import { BfsSearch } from '../../src/bfs.ts'; +import { referenceActions, replay } from './oracle.ts'; +import fixtures from '../benchmarks/fixtures.json' with { type: 'json' }; + +function position(value: number): Position { + if (value === 1 || value === 2 || value === 3 || value === 4 || value === 5 || value === 6 || value === 7) return value; + throw new Error('Invalid fixture position'); +} +function link(value: number): Link { + if (value === -1 || value === 0 || value === 1) return value; + throw new Error('Invalid fixture link'); +} + +describe('independent minimum-action oracle', () => { + it('checks all 441 two-plate cases and replays every intermediate unit move', () => { + let checked = 0; + for (const a of [-1, 0, 1] as const) for (const b of [-1, 0, 1] as const) { + for (let first = 1; first <= 7; first += 1) for (let second = 1; second <= 7; second += 1) { + const lock = { state: [position(first), position(second)] as const, links: [[0, a], [b, 0]] as const }; + const result = solveLock(lock.state, lock.links); + expect(result?.length ?? null, JSON.stringify(lock)).toBe(referenceActions(lock)); + if (result !== null) expect(replay(lock, result)).toEqual([4, 4]); + checked += 1; + } + } + expect(checked).toBe(441); + }); + it('checks 192 seeded three-plate inputs against A*, dense BFS and sparse BFS', () => { + let seed = 0x51405; + const random = (max: number): number => { + seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0; + return seed % max; + }; + for (let sample = 0; sample < 192; sample += 1) { + const state = Array.from({ length: 3 }, () => position(1 + random(7))); + const links = Array.from({ length: 3 }, (_, row) => Array.from({ length: 3 }, (_, column) => row === column ? 0 : link(random(3) - 1))); + const lock = { state, links }; + const expected = referenceActions(lock); + const prepared = new PreparedSearch(new LockModel(state, links)); + const results = [solveLock(state, links), new BfsSearch(prepared, new SearchBudget()).solve(), + new BfsSearch(prepared, new SearchBudget(createSolverConfig({ maxDenseBytes: 0 }))).solve()]; + for (const result of results) { + expect(result?.length ?? null, JSON.stringify(lock)).toBe(expected); + if (result !== null) expect(replay(lock, result)).toEqual([4, 4, 4]); + } + } + }); +}); + +describe('fixed mathematical expectations', () => { + it('keeps all 45 numerical configurations and action minima', async () => { + const semantics = fixtures.map(({ id, state, links, expectedActions }) => ({ id, state, links, expectedActions })); + const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(JSON.stringify(semantics))); + const hash = [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, '0')).join(''); + expect(hash).toBe('22cfb8a9610dd81a487dcecf40420474f9a634916af054622cbfe0ef9fafb4a4'); + expect(fixtures).toHaveLength(45); + expect(new Set(fixtures.map((entry) => entry.id)).size).toBe(45); + }); + for (const entry of fixtures) { + it(`${entry.id}: solves in ${entry.expectedActions} actions with legal intermediate moves`, () => { + const state = entry.state.map(position); + const links = entry.links.map((row) => row.map(link)); + const before = { state: [...state], links: links.map((row) => [...row]) }; + const result = solveLock(state, links); + expect(result).toHaveLength(entry.expectedActions); + if (result === null) throw new Error('Expected catalog solution'); + expect(replay({ state, links }, result)).toEqual(state.map(() => 4)); + expect({ state, links }).toEqual(before); + }); + } +}); diff --git a/tests/unit/oracle.ts b/tests/unit/oracle.ts new file mode 100644 index 0000000..88c215c --- /dev/null +++ b/tests/unit/oracle.ts @@ -0,0 +1,55 @@ +/** Independent oracle: imports no production transitions, encodings or search. */ +export interface OracleLock { + readonly state: readonly number[]; + readonly links: readonly (readonly number[])[]; +} + +function element(values: readonly T[], index: number): T { + const value = values[index]; + if (value === undefined) throw new Error(`Missing oracle element ${index}`); + return value; +} + +export function unitMove(state: readonly number[], links: OracleLock['links'], source: number, direction: number): number[] | null { + const row = element(links, source); + const next = state.map((position, target) => position + direction * (source === target ? 1 : element(row, target))); + return next.some((position) => position < 1 || position > 7) ? null : next; +} + +export function replay(lock: OracleLock, commands: readonly (readonly [number, number])[]): number[] { + let state = [...lock.state]; + for (const [index, delta] of commands) { + if (!Number.isInteger(index) || index < 0 || index >= state.length || !Number.isInteger(delta) || delta === 0 || Math.abs(delta) > 6) { + throw new Error('Invalid command'); + } + for (let step = 0; step < Math.abs(delta); step += 1) { + const next = unitMove(state, lock.links, index, Math.sign(delta)); + if (next === null) throw new Error('Blocked intermediate unit movement'); + state = next; + } + } + return state; +} + +export function referenceActions(lock: OracleLock): number | null { + const queue = [{ state: [...lock.state], depth: 0 }]; + const visited = new Set([lock.state.join(',')]); + for (const current of queue) { + if (current.state.every((position) => position === 4)) return current.depth; + for (let source = 0; source < current.state.length; source += 1) { + for (const direction of [1, -1]) { + let next = current.state; + for (let steps = 1; steps <= 6; steps += 1) { + const moved = unitMove(next, lock.links, source, direction); + if (moved === null) break; + next = moved; + const key = next.join(','); + if (visited.has(key)) continue; + visited.add(key); + queue.push({ state: next, depth: current.depth + 1 }); + } + } + } + } + return null; +} diff --git a/tests/unit/release-files.test.ts b/tests/unit/release-files.test.ts new file mode 100644 index 0000000..71f584d --- /dev/null +++ b/tests/unit/release-files.test.ts @@ -0,0 +1,103 @@ +import { execFileSync } from 'node:child_process'; +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { prepareRelease, verifyBenchmarkEvidence, verifyPreparedRelease } from '../../.github/scripts/release-files.ts'; +import { releaseAssets, sha256 } from '../../.github/scripts/release-lib.ts'; + +async function createPreparedFixture() { + const root = await mkdtemp(join(tmpdir(), 'gothic-release-test-')); + const sourceSha = 'a'.repeat(40); + const packaged = join(root, 'packed/package'); const evidence = join(root, 'evidence'); const output = join(root, 'prepared'); + for (const directory of [join(root, 'dist'), join(root, 'artifacts/package'), join(packaged, 'dist'), evidence]) await mkdir(directory, { recursive: true }); + const metadata = JSON.stringify({ name: 'gothic-lock-solver', version: '1.0.0' }); + const build = JSON.stringify({ schemaVersion: 1, sourceSha, version: '1.0.0' }); + await writeFile(join(root, 'package.json'), metadata); await writeFile(join(packaged, 'package.json'), metadata); + await writeFile(join(root, 'dist/manifest.json'), build); await writeFile(join(packaged, 'dist/manifest.json'), build); + for (const filename of releaseAssets.filter(name => /\.(?:mjs|js)$/.test(name))) { + await writeFile(join(root, 'dist', filename), 'export const marker = 1;\n'); + await writeFile(join(packaged, 'dist', filename), 'export const marker = 1;\n'); + } + const tarball = join(root, 'artifacts/package/package.tgz'); + execFileSync('tar', ['-czf', tarball, '-C', join(root, 'packed'), 'package']); + const markdown = '# Measured evidence\n'; + const envelope = { schemaVersion: 1, quality: 'passed', performance: 'passed', config: { lockCount: 45 }, + memory: { performance: { verdict: 'passed' } }, metadata: { candidate: { revision: sourceSha, moduleSha256: sha256('export const marker = 1;\n'), format: 'tuple' } }, + }; + const reportData = { ...envelope, correctness: { passed: true } }; + const json = JSON.stringify(reportData); + await writeFile(join(evidence, 'benchmark.json'), json); await writeFile(join(evidence, 'benchmark.md'), markdown); + const evidenceData = { ...envelope, reports: { json: { path: 'benchmark.json', sha256: sha256(json) }, markdown: { path: 'benchmark.md', sha256: sha256(markdown) } } }; + await writeFile(join(evidence, 'benchmark-evidence.json'), JSON.stringify(evidenceData)); + const manifest = await prepareRelease({ root, outputDir: output, benchmarkDir: evidence, sourceSha, kind: 'stable' }); + return { root, sourceSha, evidence, output, tarball, manifest, evidenceData, reportData }; +} + +type PreparedFixture = Awaited>; +type EvidenceData = PreparedFixture['evidenceData']; + +async function replaceEvidence(fixture: PreparedFixture, evidenceData: unknown): Promise { + const bytes = JSON.stringify(evidenceData); const hash = sha256(bytes); + await writeFile(join(fixture.output, 'benchmark-evidence.json'), bytes); + await writeFile(join(fixture.output, 'release-manifest.json'), JSON.stringify({ ...fixture.manifest, + assets: { ...fixture.manifest.assets, 'benchmark-evidence.json': hash } })); + return hash; +} + +describe('prepared archive identity', () => { + it('copies one verified archive, verifies its packed files, and detects later asset tampering', async () => { + const fixture = await createPreparedFixture(); + const { root, output, tarball, manifest, evidence, sourceSha } = fixture; + try { + expect(await readFile(join(output, 'package.tgz'))).toEqual(await readFile(tarball)); + expect((await verifyPreparedRelease(output)).tarballSha256).toBe(manifest.tarballSha256); + await expect(verifyBenchmarkEvidence(evidence, 'b'.repeat(40), sha256('export const marker = 1;\n'))).rejects.toThrow(/candidate/); + await writeFile(join(output, 'gothic-lock-solver.js'), 'changed'); + await expect(verifyPreparedRelease(output)).rejects.toThrow(/identity/); + await writeFile(join(evidence, 'benchmark.md'), 'edited report'); + await expect(verifyBenchmarkEvidence(evidence, sourceSha, sha256('export const marker = 1;\n'))).rejects.toThrow(/identity/); + } finally { await rm(root, { recursive: true, force: true }); } + }); + + const changes: readonly { name: string; change: (value: EvidenceData) => unknown; error: RegExp }[] = [ + { name: 'quality regression', change: value => ({ ...value, quality: 'regression' }), error: /quality/ }, + { name: 'uncertain performance', change: value => ({ ...value, performance: 'inconclusive' }), error: /performance/ }, + { name: 'wrong source revision', change: value => ({ ...value, metadata: { candidate: { ...value.metadata.candidate, revision: 'b'.repeat(40) } } }), error: /candidate/ }, + { name: 'wrong measured module', change: value => ({ ...value, metadata: { candidate: { ...value.metadata.candidate, moduleSha256: 'b'.repeat(64) } } }), error: /candidate/ }, + { name: 'wrong report link', change: value => ({ ...value, reports: { ...value.reports, json: { ...value.reports.json, path: 'different.json' } } }), error: /report path/ }, + { name: 'wrong report hash', change: value => ({ ...value, reports: { ...value.reports, json: { ...value.reports.json, sha256: 'b'.repeat(64) } } }), error: /identity/ }, + ]; + it.each(changes)('rejects $name even after the matching manifest digest is recomputed', async ({ change, error }) => { + const fixture = await createPreparedFixture(); + try { + await replaceEvidence(fixture, change(fixture.evidenceData)); + await expect(verifyPreparedRelease(fixture.output)).rejects.toThrow(error); + } finally { await rm(fixture.root, { recursive: true, force: true }); } + }); + + it('requires acceptance supplied by the verifier, ignoring an artifact acceptance claim', async () => { + const fixture = await createPreparedFixture(); + try { + const report = JSON.stringify({ ...fixture.reportData, performance: 'inconclusive' }); + await writeFile(join(fixture.output, 'benchmark.json'), report); + fixture.manifest = { ...fixture.manifest, assets: { ...fixture.manifest.assets, 'benchmark.json': sha256(report) } }; + const hash = await replaceEvidence(fixture, { ...fixture.evidenceData, performance: 'inconclusive', acceptanceReason: 'Self-approved artifact', + reports: { ...fixture.evidenceData.reports, json: { path: 'benchmark.json', sha256: sha256(report) } } }); + await expect(verifyPreparedRelease(fixture.output)).rejects.toThrow(/performance/); + await expect(verifyPreparedRelease(fixture.output, { acceptedEvidenceSha256: hash, acceptanceReason: 'Maintainer reviewed this exact evidence.' })).resolves.toMatchObject({ version: '1.0.0' }); + await expect(verifyPreparedRelease(fixture.output, { acceptedEvidenceSha256: 'b'.repeat(64), acceptanceReason: 'Wrong evidence.' })).rejects.toThrow(/performance/); + } finally { await rm(fixture.root, { recursive: true, force: true }); } + }); + + it('rejects an evidence pass that disagrees with the detailed report after all hashes are updated', async () => { + const fixture = await createPreparedFixture(); + try { + const report = JSON.stringify({ ...fixture.reportData, quality: 'regression' }); + await writeFile(join(fixture.output, 'benchmark.json'), report); + fixture.manifest = { ...fixture.manifest, assets: { ...fixture.manifest.assets, 'benchmark.json': sha256(report) } }; + await replaceEvidence(fixture, { ...fixture.evidenceData, reports: { ...fixture.evidenceData.reports, json: { path: 'benchmark.json', sha256: sha256(report) } } }); + await expect(verifyPreparedRelease(fixture.output)).rejects.toThrow(/report.*quality/); + } finally { await rm(fixture.root, { recursive: true, force: true }); } + }); +}); diff --git a/tests/unit/release-network.test.ts b/tests/unit/release-network.test.ts new file mode 100644 index 0000000..e923d66 --- /dev/null +++ b/tests/unit/release-network.test.ts @@ -0,0 +1,172 @@ +import { readFileSync, writeFileSync } from 'node:fs'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { releaseAssets, sha256, validateManifest } from '../../.github/scripts/release-lib.ts'; +import { completeGithubRelease, publishOrResumeNpm, registryMetadata, verifyCdn, verifyRegistry } from '../../.github/scripts/release-network.ts'; + +const command = vi.hoisted(() => vi.fn<(file: string, args: readonly string[]) => string>()); +vi.mock('node:child_process', () => ({ execFileSync: command })); +const fetchMock = vi.fn(); +const archive = Buffer.from('exact prepared archive'); +const assetBytes = (filename: string): Buffer => filename === 'package.tgz' ? archive : Buffer.from(`prepared ${filename}`); +const manifest = validateManifest({ schemaVersion: 1, name: 'gothic-lock-solver', version: '1.0.0', sourceSha: 'a'.repeat(40), kind: 'stable', + tarballSha256: sha256(archive), assets: Object.fromEntries(releaseAssets.map(filename => [filename, sha256(assetBytes(filename))])) }); +const metadata = { name: manifest.name, version: manifest.version, dist: { tarball: 'https://registry.npmjs.org/gothic-lock-solver/-/gothic-lock-solver-1.0.0.tgz' } }; +const jsonResponse = (body: unknown): Response => new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json' } }); + +beforeEach(() => { command.mockReset(); fetchMock.mockReset(); vi.stubGlobal('fetch', fetchMock); }); +afterEach(() => { vi.unstubAllGlobals(); }); + +async function releaseDirectory(): Promise { + const directory = await mkdtemp(join(tmpdir(), 'gothic-network-test-')); + for (const filename of releaseAssets) await writeFile(join(directory, filename), assetBytes(filename)); + await writeFile(join(directory, 'release-manifest.json'), JSON.stringify(manifest)); + await writeFile(join(directory, 'release-notes.md'), 'Prepared release notes'); + return directory; +} + +describe('npm and CDN delivery boundaries', () => { + it('distinguishes an absent version from a registry outage', async () => { + fetchMock.mockResolvedValueOnce(new Response('', { status: 404 })); + expect(await registryMetadata('1.0.0')).toBeNull(); + fetchMock.mockResolvedValueOnce(new Response('', { status: 503 })); + await expect(registryMetadata('1.0.0')).rejects.toThrow(/503/); + }); + + it('verifies an existing archive and rejects different bytes at the same version', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse(metadata)).mockResolvedValueOnce(new Response(archive)); + await expect(verifyRegistry(manifest)).resolves.toBeUndefined(); + fetchMock.mockResolvedValueOnce(jsonResponse(metadata)).mockResolvedValueOnce(new Response('different archive')); + await expect(verifyRegistry(manifest)).rejects.toThrow(/identity/); + }); + + it('publishes the one prepared archive only when the npm version is absent', async () => { + const directory = await releaseDirectory(); + try { + fetchMock.mockResolvedValue(new Response('', { status: 404 })); + await publishOrResumeNpm(directory, manifest); + expect(command).toHaveBeenCalledWith('npm', ['publish', join(directory, 'package.tgz'), '--access', 'public', '--tag', 'latest', '--provenance', '--ignore-scripts', '--registry', 'https://registry.npmjs.org/'], expect.objectContaining({ stdio: 'inherit' })); + } finally { await rm(directory, { recursive: true, force: true }); } + }); + + it('resumes identical npm bytes without republishing and refuses different existing bytes', async () => { + const directory = await releaseDirectory(); + try { + fetchMock.mockResolvedValueOnce(jsonResponse(metadata)).mockResolvedValueOnce(jsonResponse(metadata)).mockResolvedValueOnce(new Response(archive)); + await publishOrResumeNpm(directory, manifest); + expect(command).not.toHaveBeenCalled(); + fetchMock.mockResolvedValueOnce(jsonResponse(metadata)).mockResolvedValueOnce(jsonResponse(metadata)).mockResolvedValueOnce(new Response('changed')); + await expect(publishOrResumeNpm(directory, manifest)).rejects.toThrow(/identity/); + expect(command).not.toHaveBeenCalled(); + } finally { await rm(directory, { recursive: true, force: true }); } + }); + + it.each([ + { name: 'MIME type', type: 'text/plain', cors: '*', bytes: assetBytes('gothic-lock-solver.mjs'), error: /headers/ }, + { name: 'CORS', type: 'application/javascript', cors: 'https://elsewhere.test', bytes: assetBytes('gothic-lock-solver.mjs'), error: /headers/ }, + { name: 'runtime hash', type: 'application/javascript', cors: '*', bytes: Buffer.from('changed'), error: /identity/ }, + ])('rejects an invalid CDN $name', async ({ type, cors, bytes, error }) => { + fetchMock.mockResolvedValue(new Response(bytes.toString('utf8'), { headers: { 'Content-Type': type, 'Access-Control-Allow-Origin': cors } })); + await expect(verifyCdn(manifest)).rejects.toThrow(error); + }); +}); + +function mockGithub(directory: string, options: { draft: boolean; missing?: string; tagSha?: string; newer?: boolean; onSecondPage?: boolean; corruptAsset?: string }): void { + const tag = `v${manifest.version}`; + const filenames = [...releaseAssets, 'release-manifest.json']; + const release = { tag_name: tag, draft: options.draft, prerelease: false, assets: filenames.filter(name => name !== options.missing).map(name => ({ name })) }; + command.mockImplementation((file, args) => { + if (file !== 'gh') throw new Error('Unexpected executable'); + if (args[0] === 'api') { + const endpoint = args[1] ?? ''; + if (endpoint.includes('matching-refs')) return JSON.stringify([{ ref: `refs/tags/${tag}` }]); + if (endpoint.includes('/commits/refs/tags/')) return JSON.stringify({ sha: options.tagSha ?? manifest.sourceSha }); + if (endpoint.includes('releases?')) { + const firstPage = options.onSecondPage ? Array.from({ length: 100 }, (_, index) => ({ tag_name: `v2.0.0-canary.${index}`, draft: false, prerelease: true })) : [release]; + const pages = [firstPage, ...(options.onSecondPage ? [[release]] : []), ...(options.newer ? [[{ tag_name: 'v1.1.0', draft: false, prerelease: false }]] : [])]; + return JSON.stringify(args.includes('--paginate') && args.includes('--slurp') ? pages : firstPage); + } + if (endpoint.includes('/releases/tags/')) return JSON.stringify(release); + } + if (args[0] === 'release' && args[1] === 'download') { + const destination = args[args.indexOf('--dir') + 1]; + if (!destination) throw new Error('Missing download directory'); + for (const [index, argument] of args.entries()) { + if (argument !== '--pattern') continue; + const filename = args[index + 1]; + if (!filename) throw new Error('Missing asset pattern'); + const bytes = options.corruptAsset === filename ? Buffer.from('changed asset') : readFileSync(join(directory, filename)); + writeFileSync(join(destination, filename), bytes); + } + return ''; + } + if (args[0] === 'release' && ['upload', 'edit'].includes(args[1] ?? '')) return ''; + throw new Error(`Unexpected gh call: ${args.join(' ')}`); + }); + fetchMock.mockResolvedValue(jsonResponse({ ...metadata, version: options.newer ? '1.1.0' : '1.0.0' })); +} + +describe('GitHub partial-release recovery', () => { + it('rejects changed downloaded assets before publishing the draft', async () => { + const directory = await releaseDirectory(); + try { + mockGithub(directory, { draft: true, corruptAsset: 'benchmark-evidence.json' }); + await expect(completeGithubRelease(directory, manifest, 'owner/repo')).rejects.toThrow(/identity/); + expect(command.mock.calls.some(([, args]) => args[1] === 'edit')).toBe(false); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + + it('rejects an existing tag at a different commit before uploading or finalizing', async () => { + const directory = await releaseDirectory(); + try { + mockGithub(directory, { draft: true, tagSha: 'b'.repeat(40) }); + await expect(completeGithubRelease(directory, manifest, 'owner/repo')).rejects.toThrow(/tag/); + expect(command.mock.calls.some(([, args]) => args[0] === 'release')).toBe(false); + } finally { await rm(directory, { recursive: true, force: true }); } + }); + + it('uploads only missing draft assets and verifies downloads before finalizing', async () => { + const directory = await releaseDirectory(); + try { + mockGithub(directory, { draft: true, missing: 'benchmark-evidence.json' }); + await completeGithubRelease(directory, manifest, 'owner/repo'); + const uploads = command.mock.calls.filter(([, args]) => args[1] === 'upload'); + expect(uploads).toHaveLength(1); + expect(uploads[0]?.[1]).toContain(join(directory, 'benchmark-evidence.json')); + expect(command.mock.calls.some(([, args]) => args.includes('--clobber'))).toBe(false); + expect(command.mock.calls.at(-1)?.[1]).toEqual(['release', 'edit', 'v1.0.0', '--repo', 'owner/repo', '--draft=false', '--latest=true']); + } finally { await rm(directory, { recursive: true, force: true }); } + }); + + it('refuses to add missing assets to an already published release', async () => { + const directory = await releaseDirectory(); + try { + mockGithub(directory, { draft: false, missing: 'benchmark-evidence.json' }); + await expect(completeGithubRelease(directory, manifest, 'owner/repo')).rejects.toThrow(/Published release/); + expect(command.mock.calls.some(([, args]) => args[1] === 'upload' || args[1] === 'edit')).toBe(false); + } finally { await rm(directory, { recursive: true, force: true }); } + }); + + it('finishes an older draft while preserving the newer stable latest release', async () => { + const directory = await releaseDirectory(); + try { + mockGithub(directory, { draft: true, newer: true }); + await completeGithubRelease(directory, manifest, 'owner/repo'); + expect(command.mock.calls.at(-1)?.[1]).toContain('--latest=false'); + } finally { await rm(directory, { recursive: true, force: true }); } + }); + + it('resumes an existing release beyond the first 100 entries without duplicate creation', async () => { + const directory = await releaseDirectory(); + try { + mockGithub(directory, { draft: true, onSecondPage: true, newer: true }); + await completeGithubRelease(directory, manifest, 'owner/repo'); + expect(command.mock.calls.some(([, args]) => args[0] === 'release' && args[1] === 'create')).toBe(false); + expect(command.mock.calls.at(-1)?.[1]).toContain('--latest=false'); + } finally { await rm(directory, { recursive: true, force: true }); } + }); +}); diff --git a/tests/unit/release.test.ts b/tests/unit/release.test.ts new file mode 100644 index 0000000..96016c4 --- /dev/null +++ b/tests/unit/release.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest'; +import { assertDigest, assertReleaseSource, canaryVersion, parseReleaseVersion, previousStableVersion, shouldPromoteGithubLatest, validateManifest, validateEvidenceGate } from '../../.github/scripts/release-lib.ts'; + +describe('release identity and publication guards', () => { + it('accepts stable SemVer and rejects aliases, leading zeros, and shell text', () => { + expect(parseReleaseVersion('1.2.3')).toEqual({ version: '1.2.3', prerelease: false }); + for (const value of ['latest', 'v1.2.3', '01.2.3', '1.2', '1.2.3;true', '1.2.3-01']) { + expect(() => parseReleaseVersion(value)).toThrow(); + } + }); + + it('makes canaries unique by immutable source and run attempt without a stable tag', () => { + expect(canaryVersion('1.2.3', 'a'.repeat(40), '12', '2')).toBe('1.2.3-canary.12.2.aaaaaaaaaaaa'); + expect(parseReleaseVersion('1.2.3-canary.12.2.aaaaaaaaaaaa').prerelease).toBe(true); + expect(() => canaryVersion('1.2.3-beta', 'a'.repeat(40), '12', '2')).toThrow(); + expect(() => canaryVersion('1.2.3', 'bad', '12', '2')).toThrow(); + }); + + it('only permits stable source on a main push or explicit main resume', () => { + expect(() => assertReleaseSource('stable', 'refs/heads/main', 'push', 'owner/repo', 'owner/repo')).not.toThrow(); + expect(() => assertReleaseSource('stable', 'refs/heads/main', 'workflow_dispatch', 'owner/repo', 'owner/repo')).not.toThrow(); + for (const [ref, event, source] of [ + ['refs/heads/feature', 'push', 'owner/repo'], + ['refs/heads/main', 'pull_request', 'owner/repo'], + ['refs/heads/main', 'push', 'fork/repo'], + ]) expect(() => assertReleaseSource('stable', ref ?? '', event ?? '', 'owner/repo', source ?? '')).toThrow(); + }); + + it('only permits manually requested canaries from the owner repository', () => { + expect(() => assertReleaseSource('canary', 'refs/heads/feature', 'workflow_dispatch', 'owner/repo', 'owner/repo')).not.toThrow(); + expect(() => assertReleaseSource('canary', 'refs/heads/feature', 'push', 'owner/repo', 'owner/repo')).toThrow(); + expect(() => assertReleaseSource('canary', 'refs/pull/1/head', 'workflow_dispatch', 'owner/repo', 'owner/repo')).toThrow(); + }); + + it('refuses to resume an existing npm version with different bytes', () => { + expect(() => assertDigest(Buffer.from('abc'), 'ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad', 'package')).not.toThrow(); + expect(() => assertDigest(Buffer.from('changed'), 'ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad', 'package')).toThrow(/identity/); + }); + + it('rejects unsafe manifest paths and incomplete runtime assets', () => { + expect(() => validateManifest({ schemaVersion: 1, name: 'gothic-lock-solver', version: '1.0.0', sourceSha: 'a'.repeat(40), kind: 'stable', tarballSha256: 'a'.repeat(64), assets: { '../secret': 'a'.repeat(64) } })).toThrow(); + expect(() => validateManifest(null)).toThrow(); + }); + + it('never silently accepts uncertain performance and never allows a quality regression', () => { + const evidence = { schemaVersion: 1, quality: 'passed', performance: 'inconclusive', config: { lockCount: 45 }, memory: { performance: { verdict: 'passed' } } }; + expect(() => validateEvidenceGate(evidence, 'a'.repeat(64))).toThrow(/performance/); + expect(() => validateEvidenceGate(evidence, 'a'.repeat(64), 'b'.repeat(64), 'reviewed')).toThrow(/performance/); + expect(validateEvidenceGate(evidence, 'a'.repeat(64), 'a'.repeat(64), 'Reviewed shared-runner uncertainty.')).toBe('Reviewed shared-runner uncertainty.'); + expect(() => validateEvidenceGate({ ...evidence, quality: 'regression' }, 'a'.repeat(64), 'a'.repeat(64), 'reviewed')).toThrow(/quality/); + expect(() => validateEvidenceGate({ ...evidence, performance: 'passed', config: { lockCount: 44 } }, 'a'.repeat(64))).toThrow(/45/); + }); + + it('selects the preceding stable release, excluding canaries and the version being resumed', () => { + expect(previousStableVersion(['1.2.0', '1.10.0', '1.11.0-canary.1', '1.11.0', '2.0.0'], '1.11.0')).toBe('1.10.0'); + expect(previousStableVersion(['1.0.0-canary.1'], '1.0.0')).toBeNull(); + expect(previousStableVersion(['1.0.0'], '1.0.0-canary.5')).toBe('1.0.0'); + }); + + it('resumes an older stable release without downgrading GitHub latest', () => { + expect(shouldPromoteGithubLatest('stable', '1.0.0', '1.1.0', ['1.1.0'])).toBe(false); + expect(shouldPromoteGithubLatest('stable', '1.0.0', '1.0.0', ['1.1.0'])).toBe(false); + expect(shouldPromoteGithubLatest('stable', '1.1.0', '1.1.0', ['1.0.0'])).toBe(true); + expect(shouldPromoteGithubLatest('canary', '1.2.0-canary.4', '1.1.0', ['1.1.0'])).toBe(false); + }); +}); diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 0000000..7acca1d --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,21 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "emitDeclarationOnly": true, + "declaration": true, + "rootDir": "src", + "outDir": "dist", + "types": [], + "lib": [ + "ES2022" + ] + }, + "include": [ + "src/index.ts" + ], + "exclude": [ + "node_modules", + "dist" + ] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..d42c2ed --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,45 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": [ + "ES2022", + "DOM", + "DOM.Iterable" + ], + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noPropertyAccessFromIndexSignature": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noUncheckedSideEffectImports": true, + "verbatimModuleSyntax": true, + "isolatedModules": true, + "allowUnreachableCode": false, + "allowUnusedLabels": false, + "skipLibCheck": false, + "noEmit": true, + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "types": [ + "node" + ] + }, + "include": [ + "src/**/*.ts", + "tests/**/*.ts", + ".github/scripts/**/*.ts", + "*.config.ts" + ], + "exclude": [ + "node_modules", + "dist", + "artifacts", + "tests/e2e/distribution/fixtures" + ] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..f5a4d98 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,31 @@ +import { fileURLToPath } from 'node:url'; +import { defineConfig } from 'vite'; + +export default defineConfig(({ mode }) => { + const minified = mode === 'minified'; + return { + build: { + target: 'es2022', + outDir: 'dist', + emptyOutDir: false, + sourcemap: false, + minify: false, + lib: { + entry: fileURLToPath(new URL('./src/index.ts', import.meta.url)), + name: 'GothicLockSolver', + formats: ['es', 'iife'], + fileName: (format) => `gothic-lock-solver${minified ? '.min' : ''}.${format === 'es' ? 'mjs' : 'js'}`, + }, + rolldownOptions: { + output: { + // Explicit full Rolldown minification includes ESM whitespace, unlike + // the Vite library-mode build.minify shortcut. + minify: minified, + exports: 'named', + codeSplitting: false, + comments: { legal: false }, + }, + }, + }, + }; +}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..16addf8 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,27 @@ +import { playwright } from '@vitest/browser-playwright'; +import type { TestUserConfig } from 'vitest/node'; + +const shared = ['tests/unit/core*.test.ts', 'tests/unit/config.test.ts', 'tests/e2e/browser/*.test.ts']; + +// Plain Vite config keeps test options checked without globally augmenting Vite. +export default { + test: { + coverage: { provider: 'v8', reportsDirectory: 'artifacts/coverage', include: ['src/**/*.ts'], thresholds: { statements: 80, branches: 80, functions: 80, lines: 80 }, reporter: ['text', 'json-summary', 'html'] }, + projects: [ + { test: { name: 'node', environment: 'node', include: [...shared, 'tests/unit/*.test.ts'], testTimeout: 120_000 } }, + { test: { name: 'distribution', environment: 'node', include: ['tests/e2e/distribution/*.test.ts'], testTimeout: 120_000 } }, + { + test: { + include: shared, + testTimeout: 120_000, + browser: { + enabled: true, + headless: true, + provider: playwright(), + instances: [{ browser: 'chromium', name: 'chromium' }, { browser: 'firefox', name: 'firefox' }, { browser: 'webkit', name: 'webkit' }], + }, + }, + }, + ], + } satisfies TestUserConfig, +}; diff --git a/wiki/algorithm.md b/wiki/algorithm.md new file mode 100644 index 0000000..4418f70 --- /dev/null +++ b/wiki/algorithm.md @@ -0,0 +1,155 @@ +# Model, optimality and computational complexity + +## Objective and coordinates + +The state is a vector `x` of N integers in 1–7. The UI's grid visualizes this +vector; the dependency matrix `L` is N×N, with `L[source][target]` describing a +direct influence. Its diagonal is zero. Effects do not cascade. + +A command `[i, k]`, where `i` is zero-based and `k` is one of −6…−1 or +1…+6, +costs one **action**. It changes the selected pin and its directly linked pins. +Every intermediate pin position must remain in 1–7. An illegal action is blocked +in its entirety. + +| Metric | Definition | +| --- | --- | +| A | Number of commands, or grouped actions | +| U | Number of distinct selected plates, excluding indirectly affected plates | +| C | Number of unit shifts: sum of absolute command deltas | +| Switches | Changes of selected plate between adjacent commands | + +A successfully returned solution minimizes A. Minimum U or C among equal-A +solutions is not a general guarantee. An optimal action sequence never has two +adjacent commands selecting the same plate: movements in the same direction can +be combined, and opposite movements can be cancelled. The combined displacement +still lies within the plate's six-position travel range. Thus a nonempty shortest +solution has A−1 switches, although it may revisit a plate later and have U` preserves the dimensions of a fixed-length state tuple. + +```ts +const state = [6, 2] as const satisfies State; +const links = [[0, -1], [0, 0]] as const satisfies Links; +const result = solveLock(state, links); +``` + +The state determines the matrix size. Literal tuples with too few plates, +wrong dimensions or out-of-range values fail type checking. Dynamically sized +`readonly Position[]` arrays are supported and checked at runtime. JSON remains +untrusted input regardless of a caller's TypeScript annotations. + +## Inputs and configuration + +`solveLock(state, links, config?)` accepts an optional complete `SolverConfig`. +Use `createSolverConfig(overrides?)` to fill defaults and validate partial settings; +see [configuration](configuration.md). Omitting config selects the same defaults. +`state[i]` is the numeric +pin position on plate `i`. Both plate indices and matrix indices start at zero; +pin positions retain their game labels 1 through 7. All target positions are 4. + +The square matrix is **source first**: `links[i][j]` specifies the direct effect +on plate `j` when selecting plate `i`. A diagonal value other than zero is invalid. +The selected plate moves independently of the links. A reverse link has value −1, +a synchronous link +1, and no link 0. Effects apply once and never cascade. + +The solver validates dense arrays, dimensions, integers and ranges. It makes +immutable internal copies; caller-owned arrays remain unchanged, including on +failure. `readonly` expresses the type contract but does not replace validation. + +## Commands and outcomes + +`[i, k]` moves pin `i` by signed numeric displacement `k` and applies its links. +Zero displacement is not a command. A command costs one action irrespective of +`abs(k)`. Each affected pin must remain within 1–7 during every unit shift. + +The result is a sequence of these commands, `[]` if already open, or `null` if +unreachability has been proved. It is deterministic for identical valid inputs +within a version. Consumers must not depend on a particular tie-breaking path +across versions; minimum actions and legal completion are the guarantees. + +`LockInputError` signals malformed lock input or configuration. `SearchLimitError` exposes a documented +`limit` discriminator and `maximum`; it signals unavailable computation, never +unreachability. The current resource limits are documented with +[the algorithm](algorithm.md#computation-limits). + +```ts +try { + const result = solveLock([6, 2], [[0, -1], [0, 0]]); + if (result === null) { + console.log("No legal solution exists."); + } else { + for (const [index, delta] of result) console.log(index, delta); + } +} catch (error) { + if (error instanceof LockInputError) console.error("Invalid lock", error.message); + else if (error instanceof SearchLimitError) console.error("Search limit", error.limit); + else throw error; +} +``` + +Positive delta increases the numbered pin position. Physical left/right may be +opposite depending on whether an interface describes plate movement or pin +movement. That conversion belongs in the adapter, not in the matrix solver. + +## Web Workers + +For a local installed or copied ESM bundle, a module Worker can isolate synchronous +search from the page's main thread: + +```js +// solver-worker.mjs, hosted by the integrator +import { solveLock } from "https://cdn.jsdelivr.net/npm/gothic-lock-solver@VERSION/dist/gothic-lock-solver.min.mjs"; + +self.onmessage = ({ data: { state, links } }) => { + try { + self.postMessage({ commands: solveLock(state, links) }); + } catch (error) { + self.postMessage({ error: String(error) }); + } +}; +``` + +```js +// page.js +const worker = new Worker(new URL("./solver-worker.mjs", import.meta.url), { type: "module" }); +worker.onmessage = ({ data }) => console.log(data); +worker.postMessage({ state: [6, 2], links: [[0, -1], [0, 0]] }); +``` + +Host the Worker entry on your own origin and pin the library's exact CDN version. +Browser CSP must permit the chosen script source. No Worker wrapper is required +by the core contract; message shape and cancellation belong to the integrator. + +## CLI + +The fifth distribution file bundles the same solver and a Node.js file adapter. +It requires no sibling files or installed dependencies. + +```sh +node dist/gothic-lock-solver.cli.mjs lock.json +node dist/gothic-lock-solver.cli.mjs lock.json --config solver.config.json --output solution.json +# Equivalent installed npm bin: +gothic-lock-solver lock.json --output solution.json +# Source entry for development: +pnpm solve lock.json --config solver.config.json +``` + +The input file is a JSON object with `state` and `links`. `--config` reads a +partial config JSON object through the same factory; `--help` lists CLI options. The console is Russian +for compatibility. The optional output preserves the original structured format: + +```json +{ + "status": "solved", + "initialState": [6, 2], + "targetState": [4, 4], + "commands": [{ "plate": 1, "direction": "right", "steps": 2 }], + "finalState": [4, 4], + "metrics": { "commands": 1, "divisions": 2, "plateSwitches": 0 } +} +``` + +Here plate numbers are one-based, `left` means positive numeric displacement, +and `right` means negative. A proved unreachable result has `status: "unsolvable"`, +`initialState`, `targetState`, and `commands: []`, without success metrics. +An open lock has `status: "solved"` and an empty command list. + +Exit codes: 0 solved, 2 proved unreachable, 1 validation, IO or resource error. +With `--output`, the destination is created or overwritten before a success +message is printed. Without it, no result file is created. + +A CLI process measures startup, parsing and filesystem work as well as the solver. +Use the paired [benchmark harness](benchmarks.md) for algorithm timings; +use the CLI to measure your actual end-to-end command-line workload. diff --git a/wiki/benchmarks.md b/wiki/benchmarks.md new file mode 100644 index 0000000..ebf4d9b --- /dev/null +++ b/wiki/benchmarks.md @@ -0,0 +1,84 @@ +# Reproducible benchmarks + +`tests/benchmarks/fixtures.json` contains 45 fixed numerical inputs and expected +mathematical minima. These are regression cases, not a random sample of all locks. +Independent unit-step replay checks legal completion, nonmutation and deterministic +commands. Only the candidate and our own actual baseline are compared. + +## Run + +Prepare a separate checkout of this repository's reference branch: + +```sh +git worktree add --detach ../gothic-lock-solver-base feat/matrix-astar-benchmarks +pnpm build +pnpm benchmark \ + --baseline-module ../gothic-lock-solver-base/src/index.mjs \ + --baseline-format legacy \ + --output-dir artifacts/benchmark +``` + +That legacy module belongs to the separate checkout. For a typed target, build it +and use `dist/gothic-lock-solver.mjs` with `--baseline-format tuple`. The command +generates a snapshot by executing the exact baseline, then compares both modules. +`pnpm benchmark:compare` also accepts `--baseline-snapshot PATH` for a verified +release snapshot. Fixture and baseline identities must match. + +Defaults: two warmups, seven timing repetitions and five whole-workload memory +repetitions. Set `--warmups`, `--repetitions`, `--memory-repetitions` explicitly +when needed. `--smoke` labels a subset; `--skip-memory` records unavailable memory. +Neither establishes full release readiness. Unknown/duplicate flags fail. + +## Metrics and gates + +| Metric | Meaning | +| --- | --- | +| A | Grouped commands; must equal the mathematical minimum | +| U | Distinct selected plates | +| C | Sum of absolute command displacements | +| Switches | Changes of selected plate between adjacent commands | + +Only A is the optimization objective. This change also preserves U/C/switches +against the actual baseline. Improvement in one metric cannot conceal loss in +another. Candidate results cannot supply their own expected baseline. + +Only the synchronous public call is timed. Cloning, normalization, replay and +reporting are outside the timer; public validation/config handling are inside. +Matched rounds alternate implementation order; baseline-versus-itself samples +calibrate noise. The screen uses paired log ratios and a conservative standard-error +envelope with at least five observations, not an exact distribution-free confidence +interval. A control envelope above 25% cannot certify a pass. + +- `passed`: the upper slowdown envelope lies within calibrated control noise. +- `regression`: a slowdown outside the envelope is confirmed in one bounded repeat. +- `inconclusive`: insufficient precision, noisy controls or conflicting attempts. + +A non-pass gets one bounded repeat; both attempts remain visible. Never rerun +until a preferred verdict appears. Confirmed performance or quality regressions +fail the command; uncertainty is retained for explicit release review. A sum of +per-lock medians is not a measured whole-workload latency or universal promise. + +## Memory + +Fresh Linux workers measure `/proc/self/status` VmHWM in bytes. Raw Node maxRSS, +startup and loaded RSS remain diagnostics: maxRSS can retain a launcher's pre-exec +peak. Per-lock observations are descriptive; repeated whole-workload workers +supply the matched comparison. Values include Node, loading, replay and harness +costs; they are not exact solver allocation. Kernel RSS accounting is approximate. + +Other-platform fallbacks cannot establish a pass without equivalent semantics +being verified. Portable exact browser peak memory is unavailable; Node RSS is +not browser heap. See [Linux proc](https://docs.kernel.org/filesystems/proc.html) +and [Node resource usage](https://nodejs.org/api/process.html#processresourceusage). + +## Storage + +Each run creates fresh ignored output: baseline snapshot, full JSON/Markdown, +raw samples and versioned `benchmark-evidence.json` with a generated quality +snapshot. Reports include environment, source/module/fixture hashes, bundle sizes +and paths. Existing evidence is never silently overwritten. + +GitHub Actions stores these as artifacts. PR CI uses the actual target SHA in a +separate checkout; releases verify preceding-release bytes/snapshot. The first +release explicitly uses our preserved reference. GitHub Releases retain evidence +with the checked package. No snapshots or run reports are committed to Git. diff --git a/wiki/configuration.md b/wiki/configuration.md new file mode 100644 index 0000000..2304567 --- /dev/null +++ b/wiki/configuration.md @@ -0,0 +1,55 @@ +# Solver configuration + +`solveLock(state, links, config?)` accepts a complete readonly `SolverConfig`. +`createSolverConfig(overrides?)` accepts partial overrides, fills defaults, validates +values and returns a frozen object. Both APIs are available through ESM and the +`GothicLockSolver` browser global. + +```ts +import { createSolverConfig, solveLock } from "gothic-lock-solver"; +import type { SolverConfig } from "gothic-lock-solver"; + +const config: SolverConfig = createSolverConfig({ maxVisited: 500_000 }); +solveLock([6, 2], [[0, -1], [0, 0]], config); +solveLock([6, 2], [[0, -1], [0, 0]]); // Defaults +``` + +| Field | Default | Unit / permitted range | Effect | +| --- | ---: | --- | --- | +| `maxVisited` | 2,000,000 | Discovered states; positive safe integer | Bounds retained search history | +| `maxExpanded` | 1,000,000 | Expanded states; nonnegative safe integer | Bounds processing; zero permits no expansion | +| `maxFrontier` | 1,000,000 | Pending states; positive safe integer | Bounds the queue | +| `maxDenseBytes` | 67,108,864 | Bytes; nonnegative safe integer | Dense-storage budget; zero selects sparse storage | + +Unknown fields, null, arrays, strings, fractional/unsafe numbers and invalid ranges +are rejected with `LockInputError`. The solver revalidates complete configurations +supplied from ordinary JavaScript. Neither API mutates the caller's object. + +Larger budgets permit more resource consumption without guaranteeing success. +Safe-number state representation limits still apply. `maxDenseBytes` controls +only dense storage, not total heap or process RSS; sparse structures and runtime +costs remain relevant. Exceeding a search budget throws `SearchLimitError` with +`limit` and `maximum`, never `null` for interrupted work. An open state or algebraic +impossibility can be decided without expansion. Every returned path still has the +minimum action count. Positions 1–7, goal 4 and direct links are fixed domain rules. + +## CLI + +Save partial overrides in `solver.config.json`: + +```json +{ + "maxVisited": 500000, + "maxFrontier": 100000 +} +``` + +```sh +gothic-lock-solver lock.json --config solver.config.json +node gothic-lock-solver.cli.mjs lock.json --config solver.config.json --output solution.json +``` + +CLI uses the same factory. Without `--config`, defaults apply. Unreadable files, +invalid JSON and invalid settings produce an error and exit 1; exit 2 is reserved +for proved unreachability. `--help` lists flags; `--output` optionally creates or +overwrites the result JSON. See [API](api.md#cli). diff --git a/wiki/development.md b/wiki/development.md new file mode 100644 index 0000000..87d5cdf --- /dev/null +++ b/wiki/development.md @@ -0,0 +1,63 @@ +# Development + +Use pnpm and maintained Node.js 24 or 26 for source development. Exact versions +and integrity hashes live in `package.json` and `pnpm-lock.yaml`. Generated +JavaScript supports Node.js 22/24/26 and ES2022-capable core consumers. + +```sh +pnpm install --frozen-lockfile +pnpm exec playwright install --with-deps chromium firefox webkit +pnpm test +``` + +`pnpm test` runs lint, strict types, Node/coverage tests, Vite builds, distribution +checks, real browsers, static-file E2E and installed-package verification. Coverage +requires 80% each for statements, branches, functions and lines over all shipped +`src/` code, including unimported entries. Subprocess tests also exercise the CLI. + +Narrower commands: `pnpm check`, `pnpm test:node`, `pnpm test:coverage`, +`pnpm test:distribution`, `pnpm test:browser`, `pnpm test:e2e`, `pnpm build`, +`pnpm verify:package` and `pnpm solve lock.json`. To check one archive across Node +versions: `pnpm verify:package --node /path/to/node22 --node /path/to/node26`. +Run [benchmarks](benchmarks.md) separately, after competing heavy tasks stop. + +## Structure + +`src/index.ts` and `src/cli.ts` are build entries for integrations and Node tooling. +CLI invokes the exported solver/factory. The model, matrix analysis, searches and +priority queue each own one responsibility. Config/default validation lives in +`config.ts`. Classes encapsulate useful state; arithmetic uses plain functions. + +Use domain names, named mechanical bounds and explicit units for packed state or +byte arithmetic. Keep one operation per statement and short loop bodies. Matrix +iteration remains explicit; search and release orchestration should read as ordered +steps. Oxlint limits block nesting to three levels in source and delivery scripts. +Review these properties independently of test success. + +Tests live in `tests/unit`, `tests/e2e` and `tests/benchmarks`. TS consumer fixtures +are compiled into ignored output when JavaScript is needed. Vite bundles the +production files; `.github/scripts` coordinates existing build/release tools. + +The 45 inputs use independent replay and mathematical expected minima. A separate +exhaustive small-state oracle helps expose shared defects. Tests assert behavior, +not private structures. Add a failing test before a behavioral change, then the +smallest implementation preserving invariants. Apply DDD, BDD, TDD, SOLID, DRY, +KISS and YAGNI through these boundaries rather than extra frameworks or layers. + +## Strictness and evidence + +Strict flags include checked indexed access, exact optional properties and +library declaration checking. No `any`, non-null assertions or suppressed errors +are allowed; explained `ts-expect-error` proves negative type-consumer cases. +Vitest exports a plain configuration checked against public `TestUserConfig`; +dependencies remain unmodified, without coupling Vite's resolved config types. + +Builds, archives, snapshots, raw benchmark reports, coverage, traces and review +logs stay outside Git. Local output is ignored; CI uploads the same evidence and +releases retain distribution evidence. Maintained explanations live in Wiki. +Complete local gates before workflow edits, independently review the final diff, +and check hosted CI. A green correctness gate does not approve noisy performance. + +Official references: [TypeScript](https://www.typescriptlang.org/docs/handbook/intro.html), +[Vite configuration](https://vite.dev/config/), [Vitest configuration](https://vitest.dev/config/), +[coverage](https://vitest.dev/guide/coverage.html), [Playwright browsers](https://playwright.dev/docs/browsers). diff --git a/wiki/releasing.md b/wiki/releasing.md new file mode 100644 index 0000000..d52a8a2 --- /dev/null +++ b/wiki/releasing.md @@ -0,0 +1,154 @@ +# Packaging and releases + +The distribution is one public npm package, automatically served file-by-file by +jsDelivr, followed by a GitHub Release with the exact verified bytes and evidence. +No CDN account, SDK, Action, upload or separate deployment is involved. + +## Before the first publication + +This branch does not publish anything. The owner must establish the npm package +and configure its GitHub trusted publisher before the first automatic release. +GitHub and npm account names need not match. + +Configure npm trusted publishing for the owner repository, `release.yml` workflow +and the `npm-production` release environment used by that workflow. Permit direct publication +in the publisher settings where npm distinguishes staged from direct publishing. +Use the [official npm instructions](https://docs.npmjs.com/trusted-publishers/). +Do not put a token in chat or repository files. Once setup is complete, set the +repository readiness variable `NPM_PUBLICATION_READY=true`. No npm token is +required by the OIDC workflow; GitHub supplies its own narrowly scoped +`GITHUB_TOKEN` for release operations. + +The automated release uses official npm CLI 12.0.2 with OIDC and provenance on a +GitHub-hosted runner. GitHub assets are managed by official GitHub CLI 2.100.0. +All referenced Actions are from their tool/service authors and pinned to verified +full commit SHAs. pnpm owns dependency installation, Vite owns bundling, and +Vitest/Playwright own the checks. Workflow YAML only connects working local steps. + +## One workflow, three jobs + +`release.yml` runs PR verification, stable publication on main and optional manual +canaries. Its check name is `CI`. A main push runs the verification sequence once. + +| Job | Responsibility | Publication permission | +| --- | --- | --- | +| Verification | Local checks, one archive across Node 22/24/26, browser consumers and the paired benchmark | None; repository read only | +| npm delivery | Prepare the verified files using trusted main tooling, publish or resume the identical archive, verify npm/CDN delivery | npm OIDC | +| GitHub release | Verify the delivered assets, resume or create the release and finalize it | Repository contents write | + +PRs finish after verification. Publication jobs require main workflow context and +the readiness setting. Candidate code runs in the readonly job; publication tools +come from a separate trusted main checkout. The same archive moves between jobs +without rebuilding. Failed delivery retains its evidence for recovery. + +## Local acceptance precedes workflow integration + +Run the full local test command, coverage, clean installed package verification +and the all-45 paired benchmark before creating or updating the workflow. +Keep exact failure evidence and fix the cause locally. Required browser projects +cannot be replaced by Node VM tests or skipped because a runner is inconvenient. + +`pnpm build` produces five self-contained runtime files and declarations under +`dist/`, plus a build manifest binding version, source SHA and file hashes. +`pnpm verify:package` packs those existing bytes, installs the archive into an +isolated consumer, checks imports/types/CLI and reruns browser consumers against +the installed files. Only after all required checks pass does it write +`artifacts/package/package.tgz` and its checksum. It does not publish. + +A completed benchmark run supplies `benchmark.json`, `benchmark.md` and +`benchmark-evidence.json`, including the generated quality snapshot. These are +CI artifacts, never tracked repository files. Prepare a release locally from the exact verified files: + +```sh +node .github/scripts/release.ts prepare --benchmark-dir artifacts/benchmark/run-EXACT +node .github/scripts/release.ts check +``` + +The default output is `artifacts/release`. Preparation never rebuilds or repacks. +A version/source mismatch, changed tarball file, incorrect report hash or failed +quality gate stops it. Benchmark evidence must identify the exact candidate +module and source commit. Prepare output is created exclusively to prevent +accidental replacement of an earlier release candidate. + +Both calibrated timing and whole-catalog peak-RSS comparisons must pass. An +inconclusive or regressed performance result requires explicit review of that +exact evidence, recorded by its SHA256 and a reason. This exception does not +waive malformed paths, lost solvability, worse deterministic quality or mismatched +source bytes. The exceptional acceptance settings are repository variables +`PERFORMANCE_ACCEPTED_EVIDENCE_SHA256` and `PERFORMANCE_ACCEPTANCE_REASON`; +privileged steps read them independently of the uploaded artifact. Ordinary code changes cannot silently accept their own regression. + +## Stable publication sequence + +Each release-producing PR increments the SemVer in `package.json`; versions +are never inferred from arbitrary commit text or reused for changed files. +After a future approved merge to main, the workflow validates the exact source +commit and prepares one immutable candidate: + +1. Run the existing local checks and matched baseline comparison; preserve reports. +2. Verify and retain one tarball containing the five runtime files, declarations, + package metadata, README and license. No consumer installation script builds it. +3. Publish that exact tarball to public npm with the agreed SemVer and `latest` tag. +4. Download the exact published version without consumer credentials. Verify + its tarball is byte-identical to the archive already installed and tested. +5. Verify all four core file URLs on jsDelivr for status, JavaScript MIME, CORS + and SHA256. Load them in real Chromium, Firefox and WebKit from another origin + and solve all 45 fixtures. The fifth file is a Node tool, not a browser script. +6. Create a GitHub draft release, attach the same runtime files, package archive, + checksum manifest and full evidence, verify assets, then finalize the release. + +Release notes contain the npm version, source SHA, browser snippets and the actual +quality/timing/memory verdicts compared with the previous stable release. The +first release explicitly uses the preserved reference rather than inventing a +previous stable release. PR comparisons obtain their baseline from the actual +PR target checkout; candidate snapshots are not accepted as their own baseline. + +## CDN contract + +After npm publication, the browser endpoint is: + +```text +https://cdn.jsdelivr.net/npm/gothic-lock-solver@VERSION/dist/gothic-lock-solver.min.js +``` + +Use the full version and one of the four already-built core filenames. Do not +use `latest`, version ranges, `+esm`, combine or generated minification endpoints +for reproducible integrations. Updating or rolling back means explicitly changing +the version. The npm archive already contains the exact served files. + +npm's documented delivery contract provides registry metadata and a package +tarball, not a supported browser URL for each unpacked JavaScript file. +GitHub Packages' npm registry requires authentication even for public package +installation. jsDelivr documents automatic delivery of public npm files without +an account and supplies the required anonymous browser endpoint. + +The budget is zero: public npm, jsDelivr and standard free public GitHub runners. +No paid larger runners, increased quotas, subscriptions or automatic paid overflow +are configured. Temporary CI artifacts have bounded retention; releases retain +the durable distribution and evidence. + +## Partial publication and canaries + +npm publication and GitHub Releases are not one atomic transaction. If npm +succeeds and later delivery verification fails, preserve the prepared artifact +and rerun the failed steps. An existing version is downloaded and checked for +identity; it is never overwritten or automatically unpublished. Changed bytes +require a new version. A temporary delivery failure allows a bounded verification +retry, not rebuilding under the same version. + +Regular PRs only produce validation artifacts. An optional manual canary uses a +unique prerelease version containing the source SHA and run identifiers, and the +separate npm `canary` tag. It does not move `latest`, replace the stable benchmark +baseline or become GitHub's latest stable release. Privileged publication logic +must always come from main, even when the candidate is built from an owner branch. +No fork PR receives publication credentials or a privileged workflow context. + +## Official references + +- [npm trusted publishing](https://docs.npmjs.com/trusted-publishers/) +- [npm publish](https://docs.npmjs.com/cli/commands/npm-publish/) +- [GitHub Packages npm authentication](https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-npm-registry#authenticating-to-github-packages) +- [jsDelivr npm delivery](https://github.com/jsdelivr/jsdelivr#npm) +- [GitHub CLI release creation](https://cli.github.com/manual/gh_release_create) +- [GitHub Actions security](https://docs.github.com/en/actions/reference/security/secure-use) +- [GitHub Actions billing and free public runners](https://docs.github.com/en/billing/concepts/product-billing/github-actions)