From 6b1cfbc13bcec68f609d45d5dc97504dd84f1760 Mon Sep 17 00:00:00 2001 From: Ilya Konstantinov Date: Mon, 7 Sep 2026 11:01:43 +0400 Subject: [PATCH 1/7] feat: add exact matrix action search and reproducible catalog benchmarks --- AGENTS.md | 28 +- README.md | 126 +- benchmarks/README.md | 59 + benchmarks/baseline/LICENSE | 201 + benchmarks/baseline/README.md | 12 + benchmarks/baseline/manifest.json | 15 + benchmarks/baseline/src/index.mjs | 10 + benchmarks/baseline/src/lock-definition.mjs | 57 + benchmarks/baseline/src/result.mjs | 59 + benchmarks/baseline/src/solver.mjs | 80 + benchmarks/baseline/src/state-codec.mjs | 25 + benchmarks/baseline/src/transition.mjs | 46 + benchmarks/catalog.mjs | 26 + benchmarks/fixtures/catalog.json | 9390 +++++++++++++++++ benchmarks/fixtures/manifest.json | 115 + benchmarks/harness.mjs | 403 + benchmarks/validation.mjs | 54 + dist/gothic-lock-solver.js | 912 ++ docs/algorithm.md | 161 + .../plans/2026-09-07-matrix-search.md | 62 + .../specs/2026-08-31-gothic-lock-solver.md | 2 + .../specs/2026-09-07-matrix-search.md | 38 + docs/upstream-contribution.md | 25 + package.json | 6 +- scripts/benchmark.mjs | 46 +- scripts/browser/worker.mjs | 9 + scripts/build-browser.mjs | 116 + scripts/verify-web-core.mjs | 51 + src/index.mjs | 15 +- src/lock-definition.mjs | 25 +- src/matrix-analysis.mjs | 64 + src/matrix-search.mjs | 167 + src/search-bfs.mjs | 86 + src/search-limits.mjs | 116 + src/solver.mjs | 71 +- test/benchmark.test.mjs | 193 + test/browser-bundle.test.mjs | 121 + test/browser-smoke.html | 40 + test/catalog.test.mjs | 45 + test/lock-definition.test.mjs | 11 +- test/matrix-search.test.mjs | 197 + test/web-core.test.mjs | 12 + 42 files changed, 13184 insertions(+), 113 deletions(-) create mode 100644 benchmarks/README.md create mode 100644 benchmarks/baseline/LICENSE create mode 100644 benchmarks/baseline/README.md create mode 100644 benchmarks/baseline/manifest.json create mode 100644 benchmarks/baseline/src/index.mjs create mode 100644 benchmarks/baseline/src/lock-definition.mjs create mode 100644 benchmarks/baseline/src/result.mjs create mode 100644 benchmarks/baseline/src/solver.mjs create mode 100644 benchmarks/baseline/src/state-codec.mjs create mode 100644 benchmarks/baseline/src/transition.mjs create mode 100644 benchmarks/catalog.mjs create mode 100644 benchmarks/fixtures/catalog.json create mode 100644 benchmarks/fixtures/manifest.json create mode 100644 benchmarks/harness.mjs create mode 100644 benchmarks/validation.mjs create mode 100644 dist/gothic-lock-solver.js create mode 100644 docs/algorithm.md create mode 100644 docs/superpowers/plans/2026-09-07-matrix-search.md create mode 100644 docs/superpowers/specs/2026-09-07-matrix-search.md create mode 100644 docs/upstream-contribution.md create mode 100644 scripts/browser/worker.mjs create mode 100644 scripts/build-browser.mjs create mode 100644 scripts/verify-web-core.mjs create mode 100644 src/matrix-analysis.mjs create mode 100644 src/matrix-search.mjs create mode 100644 src/search-bfs.mjs create mode 100644 src/search-limits.mjs create mode 100644 test/benchmark.test.mjs create mode 100644 test/browser-bundle.test.mjs create mode 100644 test/browser-smoke.html create mode 100644 test/catalog.test.mjs create mode 100644 test/matrix-search.test.mjs create mode 100644 test/web-core.test.mjs diff --git a/AGENTS.md b/AGENTS.md index 75ce2b4..94c21e9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,8 +10,9 @@ 1. [README.md](README.md) — пользовательский интерфейс и примеры. 2. [Спецификацию](docs/superpowers/specs/2026-08-31-gothic-lock-solver.md) — источник требований к механике. -3. [Выполненный план](docs/superpowers/plans/2026-08-31-gothic-lock-solver.md) — архитектурные решения и история реализации. -4. Тесты изменяемого модуля. +3. [Спецификацию матричного поиска](docs/superpowers/specs/2026-09-07-matrix-search.md) — обновлённый контракт алгоритма и ресурсов. +4. [Выполненный план](docs/superpowers/plans/2026-08-31-gothic-lock-solver.md) — архитектурные решения и история реализации. +5. Тесты изменяемого модуля. Если README, план или код расходятся со спецификацией, приоритет имеет спецификация и подтверждённое тестами поведение. @@ -22,7 +23,10 @@ - `src/lock-definition.mjs` — разбор и валидация модели замка. - `src/state-codec.mjs` — base-7 кодирование состояний. - `src/transition.mjs` — применение команд и генерация переходов. -- `src/solver.mjs` — BFS и восстановление кратчайшего пути. +- `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/` — модульные, дифференциальные и интеграционные тесты. @@ -30,7 +34,7 @@ ## Неизменяемые правила модели -- От 2 до 7 пластин; позиции каждой — целые числа от `1` до `7`. +- Модель: не менее 2 пластин; позиции каждой — целые числа от `1` до `7`. Ресурсные пределы решателя не являются ограничением игровой механики. - Цель — позиция `4` у каждой пластины. - Влево означает `+1`, вправо — `-1`. - Матрица связей направленная; значения только `-1`, `0`, `1`, диагональ равна `0`. @@ -38,7 +42,10 @@ - Связи применяются один раз и не каскадируют. - Если хотя бы одна затронутая пластина выходит за границы, вся команда блокируется без изменения состояния. - Одна команда может сдвигать пластину на `1..6` делений и имеет стоимость `1` для поиска. -- Среди равных решений сохраняется порядок: меньшая пластина, `left` перед `right`, большее `steps` перед меньшим. +- Главная гарантия обоих режимов — минимум действий. Матричный A* используется по умолчанию; среди равных решений он детерминирован, но не обязан повторять путь BFS. +- В режиме `bfs` сохраняется прежний порядок: меньшая пластина, `left` перед `right`, большее `steps` перед меньшим. +- Минимум разных выбранных пластин и единичных сдвигов не является общей гарантией. +- Исчерпание ресурсов обозначается `SearchLimitError`; оно не означает нерешаемость. Не изменяй эти правила без явного изменения спецификации и новых тестов. @@ -57,7 +64,7 @@ ```bash # Полная проверка -npm test +pnpm test # Пример запуска node solve-lock.mjs examples/lock.example.json @@ -65,13 +72,18 @@ node solve-lock.mjs examples/lock.example.json # JSON-результат node solve-lock.mjs examples/lock.example.json --output solution.json -# Ручной benchmark -node scripts/benchmark.mjs +# Бенчмарк 45 замков и отчёт +pnpm benchmark --output-dir docs/benchmarks + +# Граница браузерного ESM-ядра +pnpm verify:web-core ``` ## Критерии готовности - Все тесты проходят без пропусков. +- Все 45 конфигураций проверены независимой симуляцией и точным минимумом действий. +- Отчёт измеряет скорость в миллисекундах; unit tests не имеют нестабильных временных порогов. - Новое поведение покрыто тестами. - Пример из README соответствует фактическому выводу. - Спецификация и публичная документация обновлены при изменении интерфейса. diff --git a/README.md b/README.md index 3330164..eeb7afb 100644 --- a/README.md +++ b/README.md @@ -4,10 +4,12 @@ ## Возможности -- от 2 до 7 пластин, по 7 позиций у каждой; +- от 2 пластин, по 7 позиций у каждой; вычислительные ресурсы ограничены отдельно; - направленные синхронные и реверсивные зависимости; - атомарная блокировка недопустимого хода; -- поиск кратчайшего решения по количеству команд; +- точный матричный анализ и A* с гарантией минимального количества команд; +- точный BFS для вырожденных матриц и сравнений; +- 45 каталожных конфигураций, независимые проверки и воспроизводимый бенчмарк; - вывод в консоль или JSON-файл; - отсутствие сторонних зависимостей. @@ -103,10 +105,126 @@ node solve-lock.mjs examples/lock.example.json --output solution.json ## Проверка ```bash -npm test +pnpm test ``` -Подробная [спецификация](docs/superpowers/specs/2026-08-31-gothic-lock-solver.md) и [выполненный план реализации](docs/superpowers/plans/2026-08-31-gothic-lock-solver.md) находятся в `docs/superpowers`. +Подробная [спецификация механики](docs/superpowers/specs/2026-08-31-gothic-lock-solver.md) +и [обновлённый контракт матричного поиска](docs/superpowers/specs/2026-09-07-matrix-search.md) +находятся в `docs/superpowers`. + +## Вычислительное ядро + +```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' }); +``` + +Одна команда сдвигает выбранную пластину на `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). + +## Использование в браузере + +Ядро — обычные ES-модули без Node.js API, сети, DOM и сторонних runtime-зависимостей. +Файлы `src/` можно хранить внутри приложения и импортировать с того же origin: + +```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 +``` + +Первая команда загружает настоящий граф ESM-зависимостей в изолированный JS-контекст +без `process`, `Buffer` и `require`. Это проверка границы зависимостей, не эмуляция +браузера. Для проверки в настоящем браузере откройте +`http://localhost:8000/test/browser-smoke.html`: страница проверяет импорт и решение +восьмипластинного замка на главном потоке и в модульном Worker. +HTTP-сервер нужен для ESM; открытие этой страницы через `file://` не поддерживается. + +## Бенчмарк и доказательный отчёт + +```bash +pnpm benchmark --output-dir docs/benchmarks +``` + +Запуск проверяет все 45 входов, прогревает реализации, делает повторные измерения +и создаёт подкаталог `run-*` с JSON и Markdown. Без внешнего checkout сравниваются +матричный режим, текущий BFS и закреплённый исходный BFS нашего проекта. +Исторические показатели UnlockMyLoot помечаются отдельно и не выдают себя +за свежие измерения скорости. + +Чтобы сравнить сторонний решатель в том же JS-runtime: + +```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 +``` + +Проверяются закреплённый commit и хэши исходников. Чужая функция исполняется без +изменения алгоритма; её код не включён в этот репозиторий. Происхождение данных +каталога сохраняется в `benchmarks/fixtures`. + +Каждый найденный путь воспроизводится независимой симуляцией по одному делению. +Минимальность количества действий проверяется точным BFS. Отчёт показывает +действия, разные выбранные пластины, единичные сдвиги, время в **миллисекундах**, +среду и хэши. Обычные тесты не требуют «быстрее 1 мс»: скорость зависит от машины. +Для быстрой проверки самого сценария есть `--smoke`; её неполный охват явно указан. + +Пример сохранённого полного запуска: [docs/benchmarks](docs/benchmarks). +Порядок предложения необязательного режима стороннему проекту описан в +[плане вклада в UnlockMyLoot](docs/upstream-contribution.md). ## Лицензия diff --git a/benchmarks/README.md b/benchmarks/README.md new file mode 100644 index 0000000..52945e2 --- /dev/null +++ b/benchmarks/README.md @@ -0,0 +1,59 @@ +# 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 new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/benchmarks/baseline/LICENSE @@ -0,0 +1,201 @@ + 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 new file mode 100644 index 0000000..3d852d7 --- /dev/null +++ b/benchmarks/baseline/README.md @@ -0,0 +1,12 @@ +# 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 new file mode 100644 index 0000000..489be60 --- /dev/null +++ b/benchmarks/baseline/manifest.json @@ -0,0 +1,15 @@ +{ + "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 new file mode 100644 index 0000000..5cc1f05 --- /dev/null +++ b/benchmarks/baseline/src/index.mjs @@ -0,0 +1,10 @@ +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 new file mode 100644 index 0000000..63a5719 --- /dev/null +++ b/benchmarks/baseline/src/lock-definition.mjs @@ -0,0 +1,57 @@ +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 new file mode 100644 index 0000000..03fd343 --- /dev/null +++ b/benchmarks/baseline/src/result.mjs @@ -0,0 +1,59 @@ +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 new file mode 100644 index 0000000..50d30b5 --- /dev/null +++ b/benchmarks/baseline/src/solver.mjs @@ -0,0 +1,80 @@ +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 new file mode 100644 index 0000000..ea0431c --- /dev/null +++ b/benchmarks/baseline/src/state-codec.mjs @@ -0,0 +1,25 @@ +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 new file mode 100644 index 0000000..6886872 --- /dev/null +++ b/benchmarks/baseline/src/transition.mjs @@ -0,0 +1,46 @@ +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 new file mode 100644 index 0000000..ca64991 --- /dev/null +++ b/benchmarks/catalog.mjs @@ -0,0 +1,26 @@ +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 new file mode 100644 index 0000000..532d932 --- /dev/null +++ b/benchmarks/fixtures/catalog.json @@ -0,0 +1,9390 @@ +[ + { + "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 new file mode 100644 index 0000000..2b4ce93 --- /dev/null +++ b/benchmarks/fixtures/manifest.json @@ -0,0 +1,115 @@ +{ + "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 new file mode 100644 index 0000000..e7e8577 --- /dev/null +++ b/benchmarks/harness.mjs @@ -0,0 +1,403 @@ +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 new file mode 100644 index 0000000..27f658d --- /dev/null +++ b/benchmarks/validation.mjs @@ -0,0 +1,54 @@ +// 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 new file mode 100644 index 0000000..b520b1e --- /dev/null +++ b/dist/gothic-lock-solver.js @@ -0,0 +1,912 @@ +/*! + * 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 new file mode 100644 index 0000000..5bf9705 --- /dev/null +++ b/docs/algorithm.md @@ -0,0 +1,161 @@ +# Модель, оптимальность и пределы вычисления + +## Что минимизируется + +Состояние замка — вектор `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/superpowers/plans/2026-09-07-matrix-search.md b/docs/superpowers/plans/2026-09-07-matrix-search.md new file mode 100644 index 0000000..30fcc68 --- /dev/null +++ b/docs/superpowers/plans/2026-09-07-matrix-search.md @@ -0,0 +1,62 @@ +# 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. +- [ ] 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. +- [ ] 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. diff --git a/docs/superpowers/specs/2026-08-31-gothic-lock-solver.md b/docs/superpowers/specs/2026-08-31-gothic-lock-solver.md index a0862d4..77fb13a 100644 --- a/docs/superpowers/specs/2026-08-31-gothic-lock-solver.md +++ b/docs/superpowers/specs/2026-08-31-gothic-lock-solver.md @@ -5,6 +5,8 @@ Дата: 2026-08-31 Статус: утверждена пользователем и реализована. +Историческая спецификация версии 1.0. Изменения алгоритма, размера модели, равных решений и доказательного набора описаны в [спецификации от 2026-09-07](2026-09-07-matrix-search.md), которая имеет приоритет для новой версии. + ## 1. Цель Создать детерминированный решатель замков Gothic 1 Remake без использования искусственного интеллекта. diff --git a/docs/superpowers/specs/2026-09-07-matrix-search.md b/docs/superpowers/specs/2026-09-07-matrix-search.md new file mode 100644 index 0000000..5442d8f --- /dev/null +++ b/docs/superpowers/specs/2026-09-07-matrix-search.md @@ -0,0 +1,38 @@ +# Матричный поиск и воспроизводимое сравнение + +Дата: 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 new file mode 100644 index 0000000..8a24ff1 --- /dev/null +++ b/test/browser-bundle.test.mjs @@ -0,0 +1,121 @@ +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 new file mode 100644 index 0000000..e0bcc88 --- /dev/null +++ b/test/browser-smoke.html @@ -0,0 +1,40 @@ + + + +Gothic solver browser verification +

Browser ESM and Worker verification

+
Running…
+ + diff --git a/test/catalog.test.mjs b/test/catalog.test.mjs new file mode 100644 index 0000000..9544f81 --- /dev/null +++ b/test/catalog.test.mjs @@ -0,0 +1,45 @@ +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/lock-definition.test.mjs b/test/lock-definition.test.mjs index ecfe78c..c364797 100644 --- a/test/lock-definition.test.mjs +++ b/test/lock-definition.test.mjs @@ -34,7 +34,7 @@ test('validateLockDefinition ignores additional input properties', () => { }); for (const [name, value, message] of [ - ['requires 2 to 7 plates', { state: [4], links: [[0]] }, /от 2 до 7/], + ['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/], @@ -45,6 +45,15 @@ for (const [name, value, message] of [ }); } +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('{'), diff --git a/test/matrix-search.test.mjs b/test/matrix-search.test.mjs new file mode 100644 index 0000000..1d10a47 --- /dev/null +++ b/test/matrix-search.test.mjs @@ -0,0 +1,197 @@ +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/web-core.test.mjs b/test/web-core.test.mjs new file mode 100644 index 0000000..1dcca2a --- /dev/null +++ b/test/web-core.test.mjs @@ -0,0 +1,12 @@ +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'); +}); From 3cbbeedbb409a2d8ffa5b00c778cfcb0b07c0ec6 Mon Sep 17 00:00:00 2001 From: Ilya Konstantinov Date: Mon, 7 Sep 2026 11:04:30 +0400 Subject: [PATCH 2/7] docs: record verified benchmark on published core revision --- docs/benchmarks/README.md | 76 + .../benchmark.json | 17137 ++++++++++++++++ .../benchmark.md | 239 + .../plans/2026-09-07-matrix-search.md | 6 +- 4 files changed, 17456 insertions(+), 2 deletions(-) create mode 100644 docs/benchmarks/README.md create mode 100644 docs/benchmarks/run-20260907T070258001Z-f78dff13/benchmark.json create mode 100644 docs/benchmarks/run-20260907T070258001Z-f78dff13/benchmark.md diff --git a/docs/benchmarks/README.md b/docs/benchmarks/README.md new file mode 100644 index 0000000..9b92765 --- /dev/null +++ b/docs/benchmarks/README.md @@ -0,0 +1,76 @@ +# Проверенный запуск на 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 new file mode 100644 index 0000000..4093e15 --- /dev/null +++ b/docs/benchmarks/run-20260907T070258001Z-f78dff13/benchmark.json @@ -0,0 +1,17137 @@ +{ + "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 new file mode 100644 index 0000000..939792d --- /dev/null +++ b/docs/benchmarks/run-20260907T070258001Z-f78dff13/benchmark.md @@ -0,0 +1,239 @@ +# 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-09-07-matrix-search.md b/docs/superpowers/plans/2026-09-07-matrix-search.md index 30fcc68..4f385f7 100644 --- a/docs/superpowers/plans/2026-09-07-matrix-search.md +++ b/docs/superpowers/plans/2026-09-07-matrix-search.md @@ -51,12 +51,14 @@ - [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. -- [ ] 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] 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. -- [ ] 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. +- [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`. From 8422abc8b0686050995e8282566fd2161f24d8dd Mon Sep 17 00:00:00 2001 From: Ilya Konstantinov Date: Mon, 7 Sep 2026 21:17:15 +0400 Subject: [PATCH 3/7] feat: package exact solver with strict TypeScript, configuration and CLI (#2) The minimum-action solver becomes a reusable TypeScript package with a small public contract, shared library/CLI core and verified browser/Node distribution. This revision addresses readability across every source file and the GitHub delivery tooling. Search orchestration, matrix row operations, predecessor storage, input validation and publication steps now have named responsibilities. Domain bounds, storage units and sentinels are explicit; deeply nested operations are split into short functions without changing search order or adding dependencies. Oxlint limits nesting to three levels in `src/` and `.github/scripts/`. - Keep ten flat `src/*.ts` files and two build entries: `index.ts` and `cli.ts`. - Export `solveLock(state, links, config?)` and `createSolverConfig(overrides?)`; preserve readonly tuple typing, immutable defaults and CLI `--config`. - Build four self-contained browser/library files and one standalone CLI with Vite; keep strict TypeScript 7 and the coverage gate. - Keep tests under `tests/` and maintained documentation under `wiki/`. Generate reports, snapshots, coverage and traces only as artifacts. - Use one `release.yml` workflow with three jobs: readonly verification, npm delivery and GitHub release. Main verification/build runs once. The same tested archive passes between jobs; publication uses trusted main tooling and separate permissions. - Preserve actual PR-base / preceding-release comparisons, source and file identity checks, explicit performance acceptance, canaries and recovery from partial publication. Local tests and the full benchmark completed before workflow YAML changes. An independent reviewer examined all ten source files, all seven delivery scripts and the workflow data flow; findings were resolved without adding reviewer reports to the repository. | Local verification | Result | | --- | --- | | Node source tests | 277 passed on Node 24 and Node 26 | | Distribution / real-browser tests | 13 / 528 passed | | Static / installed browser consumers | 24 / 24 passed | | Same installed archive | Node 22.23.2, 24.19.0 and 26.8.1; CLI exits 0/1/2, exports and types | | Coverage: statements / branches / functions / lines | 96.44% / 92.72% / 100% / 96.26% | | All 45 exact command paths | Unchanged from the pre-refactor implementation | | 45-input quality vs PR target | 483 actions, 245 distinct-plate totals, 1,723 unit shifts, 438 switches; unchanged | | Local timing / memory | Inconclusive / inconclusive; raw calibration and bounded repeats retained | Reproduce from the repository root: ```sh pnpm install --frozen-lockfile pnpm exec playwright install --with-deps chromium firefox webkit pnpm test pnpm verify:package --node /path/to/node22 --node /path/to/node26 git worktree add --detach ../gothic-lock-solver-base 3cbbeedbb409a2d8ffa5b00c778cfcb0b07c0ec6 pnpm benchmark --baseline-module ../gothic-lock-solver-base/src/index.mjs --baseline-format legacy --output-dir artifacts/benchmark ``` The benchmark compares the separately checked-out target source, not a candidate-owned expected snapshot. Inconclusive performance does not certify the absence of a regression and is not silently accepted for publication. [Hosted CI on b66d3d8](https://github.com/Marcus-Rise/gothic-lock-solver/actions/runs/34130127903) passed on its first attempt. Verification completed; the npm/GitHub publication jobs were skipped for this PR. [Verification artifacts](https://github.com/Marcus-Rise/gothic-lock-solver/actions/runs/34130127903/artifacts/10021794594) contain the five builds, one archive tested in Node 22/24/26, coverage, real-browser reports, the baseline snapshot and the complete 45-input benchmark with calibration and bounded repeats. The downloaded artifact, report hashes, module hashes, source SHA and archive SHA were checked. Hosted quality remains A/U/C/switches = **483/245/1723/438**. Timing and memory both remain **inconclusive**. Sums of per-lock medians were **60.685 ms** for the candidate and **55.259 ms** for the target; these descriptive sums are not whole-workload latency or proof of a speedup. The report identifies candidate `b66d3d8d371b2d7f17d85bf6bed9186006eab87c` and separately checked-out target `3cbbeedbb409a2d8ffa5b00c778cfcb0b07c0ec6`. This PR remains a draft. The reference branch and history are preserved. No package publication, GitHub release or merge is part of this revision. --- .codex/skills/gothic-lock-solver/SKILL.md | 45 - .../gothic-lock-solver/agents/openai.yaml | 4 - .github/dependabot.yml | 13 + .github/scripts/build.ts | 88 + .github/scripts/ci-baseline.ts | 129 + .github/scripts/release-browser.ts | 120 + .github/scripts/release-files.ts | 254 + .github/scripts/release-lib.ts | 228 + .github/scripts/release-network.ts | 268 + .github/scripts/release.ts | 223 + .github/workflows/release.yml | 287 + .gitignore | 5 + .oxlintrc.json | 43 + AGENTS.md | 160 +- README.md | 255 +- benchmarks/README.md | 59 - benchmarks/baseline/LICENSE | 201 - benchmarks/baseline/README.md | 12 - benchmarks/baseline/manifest.json | 15 - benchmarks/baseline/src/index.mjs | 10 - benchmarks/baseline/src/lock-definition.mjs | 57 - benchmarks/baseline/src/result.mjs | 59 - benchmarks/baseline/src/solver.mjs | 80 - benchmarks/baseline/src/state-codec.mjs | 25 - benchmarks/baseline/src/transition.mjs | 46 - benchmarks/catalog.mjs | 26 - benchmarks/fixtures/catalog.json | 9390 --------- benchmarks/fixtures/manifest.json | 115 - benchmarks/harness.mjs | 403 - benchmarks/validation.mjs | 54 - dist/gothic-lock-solver.js | 912 - docs/algorithm.md | 161 - docs/benchmarks/README.md | 76 - .../benchmark.json | 17137 ---------------- .../benchmark.md | 239 - .../plans/2026-08-31-gothic-lock-solver.md | 1430 -- .../plans/2026-09-07-matrix-search.md | 64 - .../plans/2026-09-07-simplify-library.md | 58 + .../specs/2026-08-31-gothic-lock-solver.md | 360 - .../specs/2026-09-07-matrix-search.md | 38 - docs/upstream-contribution.md | 25 - package.json | 73 +- playwright.config.ts | 30 + pnpm-lock.yaml | 1479 ++ scripts/benchmark.mjs | 27 - scripts/browser/worker.mjs | 9 - scripts/build-browser.mjs | 116 - scripts/verify-web-core.mjs | 51 - solve-lock.mjs | 4 - src/astar.ts | 232 + src/bfs.ts | 183 + src/cli.mjs | 36 - src/cli.ts | 188 + src/config.ts | 86 + src/errors.ts | 35 + src/index.mjs | 19 - src/index.ts | 34 + src/lock-definition.mjs | 60 - src/lock.ts | 206 + src/matrix-analysis.mjs | 64 - src/matrix-search.mjs | 167 - src/matrix.ts | 161 + src/priority-queue.ts | 103 + src/result.mjs | 59 - src/search-bfs.mjs | 86 - src/search-limits.mjs | 116 - src/solver.mjs | 25 - src/state-codec.mjs | 25 - src/transition.mjs | 46 - src/types.ts | 20 + test/benchmark.test.mjs | 193 - test/browser-bundle.test.mjs | 121 - test/browser-smoke.html | 40 - test/catalog.test.mjs | 45 - test/cli.test.mjs | 160 - test/differential.test.mjs | 50 - test/e2e.test.mjs | 24 - test/lock-definition.test.mjs | 62 - test/matrix-search.test.mjs | 197 - test/result.test.mjs | 54 - test/solver.test.mjs | 85 - test/state-codec.test.mjs | 23 - test/transition.test.mjs | 64 - test/web-core.test.mjs | 12 - tests/benchmarks/benchmark.ts | 2 + tests/benchmarks/catalog.ts | 43 + tests/benchmarks/cli.ts | 93 + tests/benchmarks/compare.ts | 2 + tests/benchmarks/comparison.ts | 53 + tests/benchmarks/fixtures.json | 2580 +++ tests/benchmarks/harness.ts | 206 + tests/benchmarks/memory-worker.ts | 25 + tests/benchmarks/memory.ts | 74 + tests/benchmarks/rss.ts | 14 + tests/benchmarks/snapshot.ts | 46 + tests/benchmarks/source.ts | 105 + tests/benchmarks/validation.ts | 64 + tests/e2e/assets.d.ts | 4 + tests/e2e/browser/catalog.test.ts | 33 + tests/e2e/build-fixtures.ts | 40 + tests/e2e/catalog.ts | 52 + tests/e2e/consumers.spec.ts | 207 + tests/e2e/distribution/artifacts.test.ts | 58 + tests/e2e/distribution/catalog.test.ts | 20 + tests/e2e/distribution/cli.test.ts | 45 + .../distribution/fixtures/global-consumer.ts | 8 + .../distribution/fixtures/runtime-consumer.ts | 62 + .../distribution/fixtures/types-consumer.ts | 46 + tests/e2e/fixtures/classic.html | 9 + tests/e2e/fixtures/classic.min.html | 9 + tests/e2e/fixtures/classic.ts | 12 + .../e2e/fixtures}/lock.example.json | 0 tests/e2e/fixtures/module.html | 8 + tests/e2e/fixtures/module.min.html | 8 + tests/e2e/fixtures/module.ts | 13 + tests/e2e/fixtures/worker.html | 7 + tests/e2e/fixtures/worker.ts | 9 + tests/e2e/paths.ts | 10 + tests/e2e/server.ts | 79 + tests/e2e/verify-package.ts | 88 + tests/unit/benchmark.test.ts | 338 + tests/unit/cli.test.ts | 170 + tests/unit/config.test.ts | 133 + tests/unit/core-contract.test.ts | 101 + tests/unit/core-contract.types.ts | 37 + tests/unit/core-limits.test.ts | 47 + tests/unit/core-oracle.test.ts | 78 + tests/unit/oracle.ts | 55 + tests/unit/release-files.test.ts | 103 + tests/unit/release-network.test.ts | 172 + tests/unit/release.test.ts | 66 + tsconfig.build.json | 21 + tsconfig.json | 45 + vite.config.ts | 31 + vitest.config.ts | 27 + wiki/algorithm.md | 155 + wiki/api.md | 154 + wiki/benchmarks.md | 84 + wiki/configuration.md | 55 + wiki/development.md | 63 + wiki/releasing.md | 154 + 141 files changed, 10882 insertions(+), 33385 deletions(-) delete mode 100644 .codex/skills/gothic-lock-solver/SKILL.md delete mode 100644 .codex/skills/gothic-lock-solver/agents/openai.yaml create mode 100644 .github/dependabot.yml create mode 100644 .github/scripts/build.ts create mode 100644 .github/scripts/ci-baseline.ts create mode 100644 .github/scripts/release-browser.ts create mode 100644 .github/scripts/release-files.ts create mode 100644 .github/scripts/release-lib.ts create mode 100644 .github/scripts/release-network.ts create mode 100644 .github/scripts/release.ts create mode 100644 .github/workflows/release.yml create mode 100644 .oxlintrc.json delete mode 100644 benchmarks/README.md delete mode 100644 benchmarks/baseline/LICENSE delete mode 100644 benchmarks/baseline/README.md delete mode 100644 benchmarks/baseline/manifest.json delete mode 100644 benchmarks/baseline/src/index.mjs delete mode 100644 benchmarks/baseline/src/lock-definition.mjs delete mode 100644 benchmarks/baseline/src/result.mjs delete mode 100644 benchmarks/baseline/src/solver.mjs delete mode 100644 benchmarks/baseline/src/state-codec.mjs delete mode 100644 benchmarks/baseline/src/transition.mjs delete mode 100644 benchmarks/catalog.mjs delete mode 100644 benchmarks/fixtures/catalog.json delete mode 100644 benchmarks/fixtures/manifest.json delete mode 100644 benchmarks/harness.mjs delete mode 100644 benchmarks/validation.mjs delete mode 100644 dist/gothic-lock-solver.js delete mode 100644 docs/algorithm.md delete mode 100644 docs/benchmarks/README.md delete mode 100644 docs/benchmarks/run-20260907T070258001Z-f78dff13/benchmark.json delete mode 100644 docs/benchmarks/run-20260907T070258001Z-f78dff13/benchmark.md delete mode 100644 docs/superpowers/plans/2026-08-31-gothic-lock-solver.md delete mode 100644 docs/superpowers/plans/2026-09-07-matrix-search.md create mode 100644 docs/superpowers/plans/2026-09-07-simplify-library.md delete mode 100644 docs/superpowers/specs/2026-08-31-gothic-lock-solver.md delete mode 100644 docs/superpowers/specs/2026-09-07-matrix-search.md delete mode 100644 docs/upstream-contribution.md create mode 100644 playwright.config.ts create mode 100644 pnpm-lock.yaml delete mode 100644 scripts/benchmark.mjs delete mode 100644 scripts/browser/worker.mjs delete mode 100644 scripts/build-browser.mjs delete mode 100644 scripts/verify-web-core.mjs delete mode 100755 solve-lock.mjs create mode 100644 src/astar.ts create mode 100644 src/bfs.ts delete mode 100644 src/cli.mjs create mode 100644 src/cli.ts create mode 100644 src/config.ts create mode 100644 src/errors.ts delete mode 100644 src/index.mjs create mode 100644 src/index.ts delete mode 100644 src/lock-definition.mjs create mode 100644 src/lock.ts delete mode 100644 src/matrix-analysis.mjs delete mode 100644 src/matrix-search.mjs create mode 100644 src/matrix.ts create mode 100644 src/priority-queue.ts delete mode 100644 src/result.mjs delete mode 100644 src/search-bfs.mjs delete mode 100644 src/search-limits.mjs delete mode 100644 src/solver.mjs delete mode 100644 src/state-codec.mjs delete mode 100644 src/transition.mjs create mode 100644 src/types.ts delete mode 100644 test/benchmark.test.mjs delete mode 100644 test/browser-bundle.test.mjs delete mode 100644 test/browser-smoke.html delete mode 100644 test/catalog.test.mjs delete mode 100644 test/cli.test.mjs delete mode 100644 test/differential.test.mjs delete mode 100644 test/e2e.test.mjs delete mode 100644 test/lock-definition.test.mjs delete mode 100644 test/matrix-search.test.mjs delete mode 100644 test/result.test.mjs delete mode 100644 test/solver.test.mjs delete mode 100644 test/state-codec.test.mjs delete mode 100644 test/transition.test.mjs delete mode 100644 test/web-core.test.mjs create mode 100644 tests/benchmarks/benchmark.ts create mode 100644 tests/benchmarks/catalog.ts create mode 100644 tests/benchmarks/cli.ts create mode 100644 tests/benchmarks/compare.ts create mode 100644 tests/benchmarks/comparison.ts create mode 100644 tests/benchmarks/fixtures.json create mode 100644 tests/benchmarks/harness.ts create mode 100644 tests/benchmarks/memory-worker.ts create mode 100644 tests/benchmarks/memory.ts create mode 100644 tests/benchmarks/rss.ts create mode 100644 tests/benchmarks/snapshot.ts create mode 100644 tests/benchmarks/source.ts create mode 100644 tests/benchmarks/validation.ts create mode 100644 tests/e2e/assets.d.ts create mode 100644 tests/e2e/browser/catalog.test.ts create mode 100644 tests/e2e/build-fixtures.ts create mode 100644 tests/e2e/catalog.ts create mode 100644 tests/e2e/consumers.spec.ts create mode 100644 tests/e2e/distribution/artifacts.test.ts create mode 100644 tests/e2e/distribution/catalog.test.ts create mode 100644 tests/e2e/distribution/cli.test.ts create mode 100644 tests/e2e/distribution/fixtures/global-consumer.ts create mode 100644 tests/e2e/distribution/fixtures/runtime-consumer.ts create mode 100644 tests/e2e/distribution/fixtures/types-consumer.ts create mode 100644 tests/e2e/fixtures/classic.html create mode 100644 tests/e2e/fixtures/classic.min.html create mode 100644 tests/e2e/fixtures/classic.ts rename {examples => tests/e2e/fixtures}/lock.example.json (100%) create mode 100644 tests/e2e/fixtures/module.html create mode 100644 tests/e2e/fixtures/module.min.html create mode 100644 tests/e2e/fixtures/module.ts create mode 100644 tests/e2e/fixtures/worker.html create mode 100644 tests/e2e/fixtures/worker.ts create mode 100644 tests/e2e/paths.ts create mode 100644 tests/e2e/server.ts create mode 100644 tests/e2e/verify-package.ts create mode 100644 tests/unit/benchmark.test.ts create mode 100644 tests/unit/cli.test.ts create mode 100644 tests/unit/config.test.ts create mode 100644 tests/unit/core-contract.test.ts create mode 100644 tests/unit/core-contract.types.ts create mode 100644 tests/unit/core-limits.test.ts create mode 100644 tests/unit/core-oracle.test.ts create mode 100644 tests/unit/oracle.ts create mode 100644 tests/unit/release-files.test.ts create mode 100644 tests/unit/release-network.test.ts create mode 100644 tests/unit/release.test.ts create mode 100644 tsconfig.build.json create mode 100644 tsconfig.json create mode 100644 vite.config.ts create mode 100644 vitest.config.ts create mode 100644 wiki/algorithm.md create mode 100644 wiki/api.md create mode 100644 wiki/benchmarks.md create mode 100644 wiki/configuration.md create mode 100644 wiki/development.md create mode 100644 wiki/releasing.md 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) From cdd920d6dec396b5adf59a4caf88cdf0e44eb8c2 Mon Sep 17 00:00:00 2001 From: Ilya Konstantinov Date: Mon, 7 Sep 2026 22:32:22 +0400 Subject: [PATCH 4/7] refactor: simplify CI and minimize shifts among shortest solutions --- .github/scripts/artifact.ts | 152 +++++ .github/scripts/ci-baseline.ts | 129 ---- .github/scripts/release-browser.ts | 120 ---- .github/scripts/release-files.ts | 254 ------- .github/scripts/release-lib.ts | 228 ------- .github/scripts/release-network.ts | 268 -------- .github/scripts/release.ts | 570 ++++++++++------ .github/workflows/ci.yml | 116 ++++ .github/workflows/release.yml | 297 ++------ AGENTS.md | 18 +- README.md | 7 +- .../plans/2026-09-07-simplify-library.md | 106 ++- package.json | 1 - src/astar.ts | 28 +- src/bfs.ts | 105 ++- src/index.ts | 5 +- src/lock.ts | 8 + src/priority-queue.ts | 7 + tests/benchmarks/benchmark.ts | 8 +- tests/benchmarks/cli.ts | 233 +++++-- tests/benchmarks/compare.ts | 2 - tests/benchmarks/comparison.ts | 81 ++- tests/benchmarks/harness.ts | 641 +++++++++++++----- tests/benchmarks/memory-worker.ts | 41 +- tests/benchmarks/memory.ts | 188 +++-- tests/benchmarks/snapshot.ts | 46 -- tests/benchmarks/source.ts | 105 --- tests/benchmarks/validation.ts | 10 - tests/unit/artifact.test.ts | 114 ++++ tests/unit/benchmark.test.ts | 423 ++++-------- tests/unit/core-limits.test.ts | 4 +- tests/unit/core-oracle.test.ts | 32 +- tests/unit/core-priority-queue.test.ts | 37 + tests/unit/oracle.ts | 44 +- tests/unit/release-files.test.ts | 215 +++--- tests/unit/release-network.test.ts | 271 ++++---- tests/unit/release.test.ts | 74 +- wiki/algorithm.md | 61 +- wiki/api.md | 5 +- wiki/benchmarks.md | 116 ++-- wiki/configuration.md | 2 +- wiki/development.md | 5 +- wiki/releasing.md | 215 ++---- 43 files changed, 2488 insertions(+), 2904 deletions(-) create mode 100644 .github/scripts/artifact.ts delete mode 100644 .github/scripts/ci-baseline.ts delete mode 100644 .github/scripts/release-browser.ts delete mode 100644 .github/scripts/release-files.ts delete mode 100644 .github/scripts/release-lib.ts delete mode 100644 .github/scripts/release-network.ts create mode 100644 .github/workflows/ci.yml delete mode 100644 tests/benchmarks/compare.ts delete mode 100644 tests/benchmarks/snapshot.ts delete mode 100644 tests/benchmarks/source.ts create mode 100644 tests/unit/artifact.test.ts create mode 100644 tests/unit/core-priority-queue.test.ts diff --git a/.github/scripts/artifact.ts b/.github/scripts/artifact.ts new file mode 100644 index 0000000..4407080 --- /dev/null +++ b/.github/scripts/artifact.ts @@ -0,0 +1,152 @@ +import { appendFile } from 'node:fs/promises'; +import { pathToFileURL } from 'node:url'; + +const CI_WORKFLOW = '.github/workflows/ci.yml'; +const PAGE_SIZE = 100; +const REQUEST_TIMEOUT_MS = 30_000; +const COMMIT_SHA = /^[a-f0-9]{40}$/; + +interface CiArtifact { + readonly artifactId: number; + readonly runId: number; + readonly runAttempt: number; + readonly sourceSha: string; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function record(value: unknown): Record { + if (!isRecord(value)) throw new Error('Invalid GitHub API object'); + return value; +} + +function items(value: unknown, field: string): readonly Record[] { + const list = record(value)[field]; + if (!Array.isArray(list)) throw new Error(`Invalid GitHub API ${field}`); + return list.map(record); +} + +function positiveInteger(value: unknown): number { + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 1) { + throw new Error('Invalid GitHub run/artifact identity'); + } + return value; +} + +async function request(repository: string, path: string): Promise { + if (!/^[\w.-]+\/[\w.-]+$/.test(repository)) throw new Error('Invalid repository'); + const headers: Record = { Accept: 'application/vnd.github+json' }; + const token = process.env['GH_TOKEN']; + if (token) headers['Authorization'] = `Bearer ${token}`; + const response = await fetch(`https://api.github.com/repos/${repository}/${path}`, { + headers, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + if (response.status === 404) return null; + if (!response.ok) throw new Error(`GitHub API HTTP ${response.status}`); + const value: unknown = await response.json(); + return value; +} + +function isSuccessfulCi(run: Record, repository: string): boolean { + const path = run['path']; + const matchesWorkflow = typeof path === 'string' + && (path === CI_WORKFLOW || path.startsWith(`${CI_WORKFLOW}@`)); + return matchesWorkflow + && run['status'] === 'completed' + && run['conclusion'] === 'success' + && record(run['repository'])['full_name'] === repository + && record(run['head_repository'])['full_name'] === repository; +} + +async function artifactFromRun( + repository: string, + run: Record, + kind: 'benchmark' | 'build', +): Promise { + const sourceSha = run['head_sha']; + if (typeof sourceSha !== 'string' || !COMMIT_SHA.test(sourceSha)) throw new Error('Invalid CI source SHA'); + const runId = positiveInteger(run['id']); + const attempt = positiveInteger(run['run_attempt']); + const name = `${kind}-${sourceSha}-${attempt}`; + for (let page = 1; ; page += 1) { + const result = await request(repository, `actions/runs/${runId}/artifacts?per_page=${PAGE_SIZE}&page=${page}`); + if (result === null) return null; + const artifacts = items(result, 'artifacts'); + const artifact = artifacts.find(item => item['name'] === name && item['expired'] === false); + if (artifact) { + return { + artifactId: positiveInteger(artifact['id']), + runId, + runAttempt: attempt, + sourceSha, + }; + } + if (artifacts.length < PAGE_SIZE) return null; + } +} + +/** A missing report is a normal bootstrap/retention case; no source fallback. */ +export async function findBenchmarkArtifact(repository: string, sha: string): Promise { + if (!COMMIT_SHA.test(sha)) throw new Error('Invalid target SHA'); + for (let page = 1; ; page += 1) { + const result = await request(repository, `actions/workflows/ci.yml/runs?head_sha=${sha}&status=success&per_page=${PAGE_SIZE}&page=${page}`); + if (result === null) return null; + const runs = items(result, 'workflow_runs'); + for (const run of runs) { + if (run['head_sha'] !== sha || !isSuccessfulCi(run, repository)) continue; + const artifact = await artifactFromRun(repository, run, 'benchmark'); + if (artifact) return artifact; + } + if (runs.length < PAGE_SIZE) return null; + } +} + +/** Release artifacts must come from an explicitly identified successful main CI. */ +export async function findReleaseArtifact(repository: string, runId: string): Promise { + if (!/^[1-9]\d*$/.test(runId)) throw new Error('Invalid CI run ID'); + const result = await request(repository, `actions/runs/${runId}`); + const run = record(result); + if (!isSuccessfulCi(run, repository) || run['event'] !== 'push' || run['head_branch'] !== 'main') { + throw new Error('Release requires a successful main CI push in this repository'); + } + const artifact = await artifactFromRun(repository, run, 'build'); + if (artifact === null) throw new Error('The tested build artifact is unavailable; run CI again'); + return artifact; +} + +async function main(): Promise { + const [mode, identity, ...extra] = process.argv.slice(2); + const repository = process.env['GITHUB_REPOSITORY'] ?? ''; + if ((mode !== 'baseline' && mode !== 'release') || identity === undefined || extra.length > 0) { + throw new Error('Usage: node .github/scripts/artifact.ts baseline TARGET_SHA | release CI_RUN_ID'); + } + // A manual CI run or a first branch push may have no previous target commit. + const absentTarget = identity === '' || /^0{40}$/.test(identity); + let artifact: CiArtifact | null = null; + if (mode === 'release') { + artifact = await findReleaseArtifact(repository, identity); + } else if (!absentTarget) { + artifact = await findBenchmarkArtifact(repository, identity); + } + const outputs = artifact === null + ? { available: 'false' } + : { + available: 'true', + 'artifact-id': artifact.artifactId, + 'run-id': artifact.runId, + 'run-attempt': artifact.runAttempt, + 'source-sha': artifact.sourceSha, + }; + if (process.env['GITHUB_OUTPUT']) { + const text = Object.entries(outputs).map(([key, value]) => `${key}=${value}\n`).join(''); + await appendFile(process.env['GITHUB_OUTPUT'], text); + } + console.log(artifact ?? 'No report for the exact target SHA; comparison skipped.'); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + await main(); +} diff --git a/.github/scripts/ci-baseline.ts b/.github/scripts/ci-baseline.ts deleted file mode 100644 index a4b8fe8..0000000 --- a/.github/scripts/ci-baseline.ts +++ /dev/null @@ -1,129 +0,0 @@ -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 deleted file mode 100644 index 9de9417..0000000 --- a/.github/scripts/release-browser.ts +++ /dev/null @@ -1,120 +0,0 @@ -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 deleted file mode 100644 index 94b9771..0000000 --- a/.github/scripts/release-files.ts +++ /dev/null @@ -1,254 +0,0 @@ -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 deleted file mode 100644 index b4ed8bf..0000000 --- a/.github/scripts/release-lib.ts +++ /dev/null @@ -1,228 +0,0 @@ -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 deleted file mode 100644 index b64ded0..0000000 --- a/.github/scripts/release-network.ts +++ /dev/null @@ -1,268 +0,0 @@ -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 index 24d0838..9918a30 100644 --- a/.github/scripts/release.ts +++ b/.github/scripts/release.ts @@ -1,223 +1,415 @@ +import assert from 'node:assert/strict'; import { execFileSync } from 'node:child_process'; -import { appendFile, writeFile } from 'node:fs/promises'; -import { resolve } from 'node:path'; +import { createHash } from 'node:crypto'; +import { copyFile, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; 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'] ?? '', - }; +import { readReport, type BenchmarkReport } from '../../tests/benchmarks/harness.ts'; + +const STABLE_VERSION = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/; +const SOURCE_SHA = /^[a-f0-9]{40}$/; +const REQUEST_TIMEOUT_MS = 30_000; +const MAX_ARCHIVE_FILE_BYTES = 16 * 1024 * 1024; +const CANARY_VERSION = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)-canary\.[1-9]\d*\.[1-9]\d*\.[a-f0-9]{12}$/; +export 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', +] as const; + +export function sha256(bytes: string | Uint8Array): string { + return createHash('sha256').update(bytes).digest('hex'); } -function releaseKind(): ReleaseKind { - const value = process.env['RELEASE_KIND'] ?? 'stable'; - if (value !== 'stable' && value !== 'canary') { - throw new Error('Invalid RELEASE_KIND'); - } +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function record(value: unknown): Record { + assert.ok(isRecord(value), 'Expected an object'); 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'); +function archiveFiles(path: string): Map { + const names = execFileSync('tar', ['-tzf', path], { encoding: 'utf8' }).trim().split('\n'); + const entries = execFileSync('tar', ['-tvzf', path], { encoding: 'utf8' }).trim().split('\n'); + assert.ok(entries.every(entry => entry.startsWith('-') || entry.startsWith('d')), 'Archive contains a link or special file'); + assert.equal(new Set(names).size, names.length, 'Archive repeats a file'); + const result = new Map(); + for (const name of names.filter(name => !name.endsWith('/'))) { + assert.ok(/^package\/(?:[A-Za-z0-9_.-]+\/)*[A-Za-z0-9_.-]+$/.test(name) && !name.split('/').includes('..'), 'Unsafe archive path'); + result.set(name.slice('package/'.length), execFileSync('tar', ['-xOzf', path, name], { maxBuffer: MAX_ARCHIVE_FILE_BYTES })); } + return result; +} - 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 requiredFile(files: ReadonlyMap, name: string): Buffer { + const bytes = files.get(name); + assert.ok(bytes, `Missing packaged ${name}`); + return bytes; } -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); - } +function archiveMetadata(files: ReadonlyMap, name: string): Record { + const value: unknown = JSON.parse(requiredFile(files, name).toString('utf8')); + return record(value); +} - const suppliedOptions = new Set(); - for (const token of parsed.tokens) { - if (token.kind !== 'option') { - continue; +function inspectArchive(files: ReadonlyMap, sourceSha: string): string { + const metadata = archiveMetadata(files, 'package.json'); + const build = archiveMetadata(files, 'dist/manifest.json'); + const version = metadata['version']; + assert.equal(metadata['name'], 'gothic-lock-solver', 'Wrong package identity'); + assert.ok(typeof version === 'string' && (STABLE_VERSION.test(version) || CANARY_VERSION.test(version)), 'Invalid package version'); + assert.ok(SOURCE_SHA.test(sourceSha) && build['sourceSha'] === sourceSha, 'Build source identity differs from successful CI'); + assert.equal(build['version'], version, 'Build version differs from package'); + assert.equal(build['schemaVersion'], 1, 'Unknown build manifest'); + assert.equal(build['package'], 'gothic-lock-solver', 'Wrong build identity'); + const hashes = record(build['files']); + const distribution = [...files.keys()].filter(name => name.startsWith('dist/') && name !== 'dist/manifest.json'); + assert.deepEqual(Object.keys(hashes).sort(), distribution.map(name => name.slice(5)).sort(), 'Build manifest file list differs'); + assert.deepEqual(distribution.filter(name => /\.(?:mjs|js)$/.test(name)).map(name => name.slice(5)).sort(), [...runtimeFiles].sort(), 'Expected exactly five runtime files'); + for (const name of distribution) assert.equal(sha256(requiredFile(files, name)), hashes[name.slice(5)], `Packaged file hash differs: ${name}`); + return version; +} + +export function canaryVersion(base: string, source: string, run: string, attempt: string): string { + assert.ok(STABLE_VERSION.test(base), 'Expected stable package base version'); + assert.ok(SOURCE_SHA.test(source) && /^[1-9]\d*$/.test(run) && /^[1-9]\d*$/.test(attempt), 'Invalid CI source identity'); + return `${base}-canary.${run}.${attempt}.${source.slice(0, 12)}`; +} + +type Preparation = { + input: string; + output: string; + sourceSha: string; + version?: string; + runId?: string; + runAttempt?: string; +}; + +/** Both release kinds change only version metadata in the tested package. */ +export async function prepareArchive(options: Preparation): Promise<{ version: string }> { + const files = archiveFiles(options.input); + const base = inspectArchive(files, options.sourceSha); + assert.ok(STABLE_VERSION.test(base), 'Expected a tested stable package base version'); + const version = options.version ?? canaryVersion(base, options.sourceSha, options.runId ?? '', options.runAttempt ?? ''); + assert.ok(options.version === undefined || STABLE_VERSION.test(version), 'Expected stable promotion version'); + await mkdir(dirname(options.output), { recursive: true }); + const temporary = await mkdtemp(join(tmpdir(), 'gothic-stable-')); + try { + for (const [name, bytes] of files) { + const path = join(temporary, name); + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, bytes); } - if (!token.value) { - throw new Error(`Empty release option: --${token.name}`); + for (const name of ['package.json', 'dist/manifest.json']) { + const metadata = archiveMetadata(files, name); + await writeJson(join(temporary, name), { ...metadata, version }); } - if (suppliedOptions.has(token.name)) { - throw new Error(`Repeated release option: --${token.name}`); + const packed: unknown = JSON.parse(execFileSync('npm', ['pack', '--json', '--ignore-scripts'], { cwd: temporary, encoding: 'utf8' })); + assert.ok(Array.isArray(packed) && packed.length === 1, 'Expected one stable archive'); + const filename = record(packed[0])['filename']; + assert.ok(typeof filename === 'string' && /^[A-Za-z0-9_.-]+\.tgz$/.test(filename), 'Invalid packed filename'); + const archive = join(temporary, filename); + const promoted = archiveFiles(archive); + assert.deepEqual([...promoted.keys()].sort(), [...files.keys()].sort(), 'Repack changed packaged file list'); + for (const [name, bytes] of files) { + const expected = name === 'package.json' || name === 'dist/manifest.json' ? await readFile(join(temporary, name)) : bytes; + assert.deepEqual(requiredFile(promoted, name), expected, `Repack changed tested bytes: ${name}`); } - suppliedOptions.add(token.name); + inspectArchive(promoted, options.sourceSha); + await copyFile(archive, options.output); + } finally { + await rm(temporary, { recursive: true, force: true }); } - return { - command: parsed.positionals[0], - directory: resolve(parsed.values.directory ?? 'artifacts/release'), - benchmarkDirectory: parsed.values['benchmark-dir'], - sourceSha: parsed.values['source-sha'], + return { version }; +} + +async function readJson(path: string): Promise> { + const value: unknown = JSON.parse(await readFile(path, 'utf8')); + return record(value); +} + +async function writeJson(path: string, value: unknown): Promise { + await writeFile(path, `${JSON.stringify(value, null, 2)}\n`); +} + +function environment(name: string): string { + const value = process.env[name]; + assert.ok(value, `Missing ${name}`); + return value; +} + +export const releaseAssets = [...runtimeFiles, 'package.tgz', 'benchmark.json', 'benchmark.md'] as const; +export type ReleaseManifest = { + schemaVersion: 1; + version: string; + sourceSha: string; + kind: 'canary' | 'stable'; + assets: Record; +}; + +function checkReport(directory: string, sourceSha: string, moduleSha256: string): BenchmarkReport { + const report = readReport(join(directory, 'benchmark.json'), sourceSha); + assert.equal(report.quality, 'passed', 'Benchmark quality regression prevents publication'); + assert.equal(report.source.moduleSha256, moduleSha256, 'Benchmark measured a different module'); + return report; +} + +export async function prepareRelease(options: Preparation): Promise { + const original = archiveFiles(join(options.input, 'package.tgz')); + inspectArchive(original, options.sourceSha); + const report = checkReport(options.input, options.sourceSha, sha256(requiredFile(original, 'dist/gothic-lock-solver.mjs'))); + await mkdir(options.output, { recursive: true }); + const { version } = await prepareArchive({ ...options, input: join(options.input, 'package.tgz'), output: join(options.output, 'package.tgz') }); + for (const filename of runtimeFiles) await writeFile(join(options.output, filename), requiredFile(original, `dist/${filename}`)); + for (const filename of ['benchmark.json', 'benchmark.md']) await copyFile(join(options.input, filename), join(options.output, filename)); + const assets: Record = {}; + for (const filename of releaseAssets) assets[filename] = sha256(await readFile(join(options.output, filename))); + const manifest: ReleaseManifest = { + schemaVersion: 1, + version, + sourceSha: options.sourceSha, + kind: options.version === undefined ? 'canary' : 'stable', + assets, }; + await writeJson(join(options.output, 'release-manifest.json'), manifest); + const cdn = `https://cdn.jsdelivr.net/npm/gothic-lock-solver@${version}/dist`; + const { totals, comparison } = report; + const benchmarkSummary = [ + `Benchmark quality: **${report.quality}**; ${report.fixtures.count} locks.`, '', + `Totals: A=${totals.A}, U=${totals.U}, C=${totals.C}, switches=${totals.plateSwitches}.`, + 'A counts actions, U distinct plates per lock, and C unit shifts; totals sum all locks.', '', + ]; + if (comparison.status === 'compared') { + const drift = comparison.results.reduce((sum, row) => ({ + A: sum.A + row.deltas.A, + U: sum.U + row.deltas.U, + C: sum.C + row.deltas.C, + plateSwitches: sum.plateSwitches + row.deltas.plateSwitches, + }), { A: 0, U: 0, C: 0, plateSwitches: 0 }); + benchmarkSummary.push( + `Comparison: compared with target SHA \`${comparison.baselineSha}\`.`, + `Deterministic drift: ΔA=${drift.A}, ΔU=${drift.U}, ΔC=${drift.C}, Δswitches=${drift.plateSwitches}.`, '', + ); + } else { + benchmarkSummary.push(`Comparison: skipped. ${comparison.reason}`, ''); + } + await writeFile(join(options.output, 'release-notes.md'), [ + `# Gothic Lock Solver ${version}`, '', `Tested source: ${options.sourceSha}.`, '', + ...benchmarkSummary, + `Install: \`npm install gothic-lock-solver@${version}\`.`, '', + `Browser module: ${cdn}/gothic-lock-solver.min.mjs`, '', + `Classic script: ${cdn}/gothic-lock-solver.min.js`, '', + 'All five standalone runtime files, npm archive, checksums and full 45-lock benchmark reports are attached.', + 'Release packaging changes version metadata only; runtime and declaration bytes match successful CI.', '', + ].join('\n')); + await checkRelease(options.output, options.sourceSha); + return manifest; } -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']); +export async function checkRelease(directory: string, sourceSha: string): Promise { + const value = await readJson(join(directory, 'release-manifest.json')); + const version = value['version']; + const kind = value['kind']; + assert.ok(kind === 'stable' || kind === 'canary', 'Invalid release kind'); + assert.ok(typeof version === 'string' && (kind === 'stable' ? STABLE_VERSION.test(version) : kind === 'canary' && CANARY_VERSION.test(version)), 'Invalid release version/kind'); + assert.ok(value['schemaVersion'] === 1 && value['sourceSha'] === sourceSha && SOURCE_SHA.test(sourceSha), 'Release source identity differs'); + const hashes = record(value['assets']); + assert.deepEqual(Object.keys(hashes).sort(), [...releaseAssets].sort(), 'Incomplete release assets'); + const assets: Record = {}; + for (const filename of releaseAssets) { + const hash = hashes[filename]; + assert.ok(typeof hash === 'string' && /^[a-f0-9]{64}$/.test(hash), 'Invalid asset hash'); + assert.equal(sha256(await readFile(join(directory, filename))), hash, `Release asset identity differs: ${filename}`); + assets[filename] = hash; + } + const files = archiveFiles(join(directory, 'package.tgz')); + assert.equal(inspectArchive(files, sourceSha), version, 'Prepared package version differs'); + for (const filename of runtimeFiles) assert.equal(sha256(requiredFile(files, `dist/${filename}`)), assets[filename], 'Attached and packaged runtime identity differs'); + checkReport(directory, sourceSha, sha256(requiredFile(files, 'dist/gothic-lock-solver.mjs'))); + return { schemaVersion: 1, version, sourceSha, kind, assets }; } -async function publishPreparedRelease(directory: string): Promise { - requirePublicationContext(); - const manifest = await verifyPreparedRelease(directory, workflowAcceptance()); - requirePreparedSource(manifest); +const NPM_REGISTRY = 'https://registry.npmjs.org'; +const PACKAGE = 'gothic-lock-solver'; + +export async function registryMetadata(version: string): Promise | null> { + const response = await fetch(`${NPM_REGISTRY}/${PACKAGE}/${encodeURIComponent(version)}`, { signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) }); + if (response.status === 404) return null; + assert.ok(response.ok, `npm registry HTTP ${response.status}`); + const value: unknown = await response.json(); + return record(value); +} - const npmVersion = execFileSync('npm', ['--version'], { encoding: 'utf8' }).trim(); - if (npmVersion !== NPM_VERSION) { - throw new Error(`Expected pinned npm CLI ${NPM_VERSION}`); +export async function publishNpm(directory: string, manifest: ReleaseManifest): Promise { + let metadata = await registryMetadata(manifest.version); + if (metadata === null) { + execFileSync('npm', [ + 'publish', join(directory, 'package.tgz'), + '--access', 'public', + '--tag', manifest.kind === 'stable' ? 'latest' : 'canary', + '--ignore-scripts', '--registry', `${NPM_REGISTRY}/`, + ], { stdio: 'inherit' }); + metadata = await registryMetadata(manifest.version); } - await publishOrResumeNpm(directory, manifest); + assert.ok(metadata && metadata['name'] === PACKAGE && metadata['version'] === manifest.version, 'Exact npm version unavailable'); + const url = record(metadata['dist'])['tarball']; + assert.ok(typeof url === 'string' && url.startsWith(`${NPM_REGISTRY}/${PACKAGE}/-/`), 'Unexpected registry archive URL'); + const response = await fetch(url, { signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) }); + assert.ok(response.ok, `npm archive HTTP ${response.status}`); + assert.equal(sha256(new Uint8Array(await response.arrayBuffer())), manifest.assets['package.tgz'], 'Published npm archive identity differs'); } -async function verifyDelivery(directory: string): Promise { - const manifest = await verifyPreparedRelease(directory, workflowAcceptance()); - await retryDelivery(async () => { - await verifyRegistry(manifest); - await verifyCdn(manifest); +export async function verifyCdn(manifest: ReleaseManifest): Promise { + for (const filename of runtimeFiles) { + const response = await fetch(`https://cdn.jsdelivr.net/npm/${PACKAGE}@${manifest.version}/dist/${filename}`, { + headers: { Origin: 'https://example.com' }, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + const scriptType = /(?:java|ecma)script/i.test(response.headers.get('content-type') ?? ''); + const crossOrigin = response.headers.get('access-control-allow-origin') === '*'; + assert.ok(response.ok && scriptType && crossOrigin, `CDN headers rejected: ${filename}`); + assert.equal(sha256(new Uint8Array(await response.arrayBuffer())), manifest.assets[filename], `CDN identity differs: ${filename}`); + } +} + +function publicationContext(manifest: ReleaseManifest): void { + const repository = environment('GITHUB_REPOSITORY'); + assert.ok(process.env['GITHUB_ACTIONS'] === 'true' && process.env['NPM_PUBLICATION_READY'] === 'true', 'Publication readiness is not configured'); + assert.equal(process.env['GITHUB_REF'], 'refs/heads/main', 'Publication requires main'); + assert.equal(process.env['GITHUB_WORKFLOW_REF'], `${repository}/.github/workflows/release.yml@refs/heads/main`, 'Publication requires the main release workflow'); + assert.equal(process.env['GITHUB_EVENT_NAME'], manifest.kind === 'stable' ? 'workflow_dispatch' : 'workflow_run', 'Release kind and trigger differ'); + assert.equal(execFileSync('npm', ['--version'], { encoding: 'utf8' }).trim(), '12.0.2', 'Unexpected npm version'); +} + +async function githubRecord(path: string): Promise | null> { + const response = await fetch(`https://api.github.com/${path}`, { + headers: { Authorization: `Bearer ${environment('GH_TOKEN')}`, Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28' }, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), }); - 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}`); + if (response.status === 404) return null; + assert.ok(response.ok, `GitHub HTTP ${response.status}`); + const value: unknown = await response.json(); + return record(value); +} + +function missingGithubAssets(value: unknown, expected: Readonly>): string[] { + assert.ok(Array.isArray(value), 'Missing GitHub assets'); + const remaining = new Set(Object.keys(expected)); + for (const raw of value) { + const asset = record(raw); + const name = asset['name']; + assert.ok(typeof name === 'string' && remaining.delete(name), 'Unexpected or duplicate GitHub asset'); + assert.equal(asset['digest'], `sha256:${expected[name]}`, 'GitHub asset identity differs'); } - 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'); + return [...remaining]; +} + +async function githubRelease(repository: string, tag: string): Promise | null> { + const [owner, name] = repository.split('/'); + const query = 'query($owner: String!, $name: String!, $tag: String!) { repository(owner: $owner, name: $name) { release(tagName: $tag) { databaseId } } }'; + // Exact GraphQL lookup includes drafts; the REST tag endpoint only serves published releases. + const id: unknown = JSON.parse(execFileSync('gh', [ + 'api', 'graphql', '-f', `query=${query}`, + '-f', `owner=${owner}`, '-f', `name=${name}`, '-f', `tag=${tag}`, + '--jq', '.data.repository.release.databaseId', + ], { encoding: 'utf8' })); + if (id === null) return null; + assert.ok(typeof id === 'number' && Number.isSafeInteger(id) && id > 0, 'Invalid GitHub release ID'); + return githubRecord(`repos/${repository}/releases/${id}`); +} + +export async function publishGithub(directory: string, manifest: ReleaseManifest, repository: string): Promise { + assert.ok(/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository), 'Invalid repository'); + const tag = `v${manifest.version}`; + const commitPath = `repos/${repository}/commits/refs/tags/${tag}`; + let commit = await githubRecord(commitPath); + if (commit === null) { + execFileSync('gh', [ + 'api', '--method', 'POST', `repos/${repository}/git/refs`, + '-f', `ref=refs/tags/${tag}`, '-f', `sha=${manifest.sourceSha}`, + ], { stdio: 'inherit' }); + commit = await githubRecord(commitPath); + } + assert.equal(commit?.['sha'], manifest.sourceSha, 'GitHub tag identifies different source'); + + const expected: Record = { + ...manifest.assets, + 'release-manifest.json': sha256(await readFile(join(directory, 'release-manifest.json'))), + }; + let release = await githubRelease(repository, tag); + if (release === null) { + execFileSync('gh', [ + 'release', 'create', tag, ...Object.keys(expected).map(name => join(directory, name)), + '--repo', repository, '--verify-tag', '--title', tag, + '--notes-file', join(directory, 'release-notes.md'), + ...(manifest.kind === 'canary' ? ['--prerelease'] : []), + `--latest=${manifest.kind === 'stable'}`, + ], { stdio: 'inherit' }); + release = await githubRelease(repository, tag); } - 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; + assert.ok(release && typeof release['draft'] === 'boolean', 'GitHub release unavailable'); + assert.equal(release['prerelease'], manifest.kind === 'canary', 'GitHub release kind differs'); + const missing = missingGithubAssets(release['assets'], expected); + if (release['draft']) { + if (missing.length > 0) { + execFileSync('gh', [ + 'release', 'upload', tag, ...missing.map(name => join(directory, name)), '--repo', repository, + ], { stdio: 'inherit' }); } - 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); + const updated = await githubRelease(repository, tag); + assert.ok(updated && updated['draft'] === true, 'GitHub draft changed during completion'); + assert.equal(updated['prerelease'], manifest.kind === 'canary', 'GitHub release kind differs'); + assert.equal(missingGithubAssets(updated['assets'], expected).length, 0, 'GitHub draft is incomplete'); + execFileSync('gh', [ + 'release', 'edit', tag, '--repo', repository, '--draft=false', `--latest=${manifest.kind === 'stable'}`, + ], { stdio: 'inherit' }); + return; } + assert.equal(missing.length, 0, 'Published GitHub release is incomplete'); } -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`); - } +async function main(): Promise { + const args = parseArgs({ + allowPositionals: true, + options: { + input: { type: 'string' }, + output: { type: 'string' }, + directory: { type: 'string' }, + version: { type: 'string' }, + }, + }); + assert.equal(args.positionals.length, 1, 'Expected prepare or publish'); + const sourceSha = environment('RELEASE_SOURCE_SHA'); + if (args.positionals[0] === 'prepare') { + await prepareRelease({ + input: resolve(args.values.input ?? 'artifacts/build'), + output: resolve(args.values.output ?? 'artifacts/release'), + sourceSha, + ...(args.values.version === undefined + ? { runId: environment('CI_RUN_ID'), runAttempt: environment('CI_RUN_ATTEMPT') } + : { version: args.values.version }), + }); + return; + } + if (args.positionals[0] === 'publish') { + const directory = resolve(args.values.directory ?? 'artifacts/release'); + const manifest = await checkRelease(directory, sourceSha); + publicationContext(manifest); + await publishNpm(directory, manifest); + await verifyCdn(manifest); + await publishGithub(directory, manifest, environment('GITHUB_REPOSITORY')); + return; } - process.exitCode = 1; + throw new Error('Expected prepare or publish'); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + await main().catch((error: unknown) => { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; + }); } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a3cfe26 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,116 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + actions: read + +concurrency: + group: ci-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +env: + SOURCE_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + TARGET_SHA: ${{ github.event.pull_request.base.sha || github.event.before }} + +jobs: + verify: + runs-on: ubuntu-24.04 + timeout-minutes: 30 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ env.SOURCE_SHA }} + persist-credentials: false + - 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 + run_install: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24.19.0 + cache: pnpm + - 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: Check source, bundles and installed package + 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: Find the exact target SHA report + id: baseline + env: + GH_TOKEN: ${{ github.token }} + run: node .github/scripts/artifact.ts baseline "$TARGET_SHA" + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + if: steps.baseline.outputs.available == 'true' + with: + artifact-ids: ${{ steps.baseline.outputs.artifact-id }} + run-id: ${{ steps.baseline.outputs.run-id }} + github-token: ${{ github.token }} + path: ${{ runner.temp }}/baseline + - name: Measure all 45 locks + env: + BASELINE_AVAILABLE: ${{ steps.baseline.outputs.available }} + run: | + if [ "$BASELINE_AVAILABLE" = true ]; then + pnpm benchmark --baseline-report "$RUNNER_TEMP/baseline/benchmark.json" --baseline-sha "$TARGET_SHA" + else + pnpm benchmark --baseline-sha "$TARGET_SHA" + fi + - name: Show the benchmark report + if: always() + run: test ! -f artifacts/benchmark/benchmark.md || cat artifacts/benchmark/benchmark.md >> "$GITHUB_STEP_SUMMARY" + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + if: always() + with: + name: benchmark-${{ env.SOURCE_SHA }}-${{ github.run_attempt }} + path: artifacts/benchmark/ + retention-days: 90 + - name: Collect the tested distribution + run: | + mkdir -p artifacts/build + cp dist/gothic-lock-solver.{mjs,min.mjs,js,min.js,cli.mjs} artifacts/build/ + cp artifacts/package/package.tgz artifacts/benchmark/benchmark.{json,md} artifacts/build/ + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: build-${{ env.SOURCE_SHA }}-${{ github.run_attempt }} + path: artifacts/build/ + if-no-files-found: error + retention-days: 90 + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + if: always() + with: + name: diagnostics-${{ github.run_id }}-${{ github.run_attempt }} + path: | + artifacts/coverage/ + artifacts/playwright-report/ + artifacts/test-results/ + retention-days: 14 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c678ca5..745fdac 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,259 +1,46 @@ -name: CI +name: Release on: - pull_request: - push: + workflow_run: + workflows: [CI] + types: [completed] branches: [main] workflow_dispatch: inputs: - kind: - description: Stable main release or an owner-branch canary - type: choice - options: [canary, stable] - default: canary + ci_run_id: + description: Successful main-push CI run containing the tested package + type: string required: true - source_branch: - description: Owner branch for a canary; stable always uses main + version: + description: Stable SemVer to publish, for example 1.0.0 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 }} + group: release + cancel-in-progress: false jobs: - verification: - name: Tests, package and 45-lock benchmark - if: github.event_name != 'workflow_dispatch' || github.ref == 'refs/heads/main' + publish: + if: >- + github.ref == 'refs/heads/main' && vars.NPM_PUBLICATION_READY == 'true' && + (github.event_name == 'workflow_dispatch' || + (github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push')) 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 + timeout-minutes: 15 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: + actions: read contents: write + id-token: write env: + GH_TOKEN: ${{ github.token }} 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 + - name: Check out trusted main release tools uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.sha }} @@ -261,27 +48,45 @@ jobs: - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 24.19.0 - - name: Install the exact official GitHub CLI release + registry-url: https://registry.npmjs.org + - name: Install official publication CLIs env: GH_ARCHIVE_SHA256: e4d4bb4498e8d007abe545b6568926793ace1b6447da598294a610018cb164be run: | + npm install --global --ignore-scripts npm@12.0.2 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" + - name: Select the successful main CI build + id: build + env: + REQUESTED_RUN: ${{ inputs.ci_run_id || github.event.workflow_run.id }} + run: node .github/scripts/artifact.ts release "$REQUESTED_RUN" - 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 + artifact-ids: ${{ steps.build.outputs.artifact-id }} + run-id: ${{ steps.build.outputs.run-id }} + github-token: ${{ github.token }} + path: ${{ runner.temp }}/build + - name: Prepare release metadata without rebuilding runtime files + env: + RELEASE_SOURCE_SHA: ${{ steps.build.outputs.source-sha }} + CI_RUN_ID: ${{ steps.build.outputs.run-id }} + CI_RUN_ATTEMPT: ${{ steps.build.outputs.run-attempt }} + STABLE_VERSION: ${{ inputs.version }} + run: | + args=(--input "$RUNNER_TEMP/build" --output "$RUNNER_TEMP/release") + if [ -n "$STABLE_VERSION" ]; then args+=(--version "$STABLE_VERSION"); fi + node .github/scripts/release.ts prepare "${args[@]}" + cat "$RUNNER_TEMP/release/release-notes.md" >> "$GITHUB_STEP_SUMMARY" + - name: Publish npm, verify CDN and create GitHub Release env: - GH_TOKEN: ${{ github.token }} - run: node .github/scripts/release.ts github - - name: Preserve the final delivery status + RELEASE_SOURCE_SHA: ${{ steps.build.outputs.source-sha }} + run: node .github/scripts/release.ts publish --directory "$RUNNER_TEMP/release" + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 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 + name: release-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/release/ + retention-days: 90 diff --git a/AGENTS.md b/AGENTS.md index 8730d6f..780b9ef 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,8 +12,8 @@ work is in the [current plan](docs/superpowers/plans/2026-09-07-simplify-library 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 +- Successful paths minimize grouped actions, then unit shifts among equal-action + paths. Do not promise minimum 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. @@ -50,13 +50,17 @@ source and requires 80% per metric. Verify Node.js 22/24/26, real Chromium/Firef 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. +Compare only a saved CI report for the exact target SHA. If it is absent or +expired, record that comparison was skipped. Never rebuild a target checkout or +fall back to a registry/release baseline. Validate report source and catalog identity. +Historical timing and memory ratios are observations across runs, not paired +experiments. Preserve deterministic quality checks; do not change expected minima +or comparison 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 +check hosted CI on the exact head. CI builds and checks; a separate release +workflow publishes main canaries after successful CI and stable versions only +through a manual dispatch selecting a tested main CI run. 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. diff --git a/README.md b/README.md index db437c0..14df3ca 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # Gothic Lock Solver -An exact minimum-action solver for Gothic 1 Remake coupled plate locks. A +An exact solver for Gothic 1 Remake coupled plate locks: minimum grouped actions, +then minimum unit shifts among equally short action sequences. A TypeScript library and standalone Node.js CLI share one mathematical core with no runtime dependencies. One action can move a plate several positions. @@ -75,8 +76,8 @@ 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](wiki/releasing.md) and [AGENTS](AGENTS.md). Generated builds, +reports and coverage are CI artifacts, with the benchmark report attached to GitHub Releases. They are not committed to the source repository. Apache-2.0 — see [LICENSE](LICENSE). diff --git a/docs/superpowers/plans/2026-09-07-simplify-library.md b/docs/superpowers/plans/2026-09-07-simplify-library.md index 3679c30..e0a9c47 100644 --- a/docs/superpowers/plans/2026-09-07-simplify-library.md +++ b/docs/superpowers/plans/2026-09-07-simplify-library.md @@ -1,58 +1,48 @@ -# 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. +# Simple CI and canary releases + +The owner replaced the previous delivery design with two simple workflows. +Keep the solver, public API, five runtime outputs and strict local checks. +This plan supersedes checkout-based benchmarks and the previous release flow. + +## CI + +- One job runs the existing checks, coverage, Vite builds, Node and browser consumers. +- Measure the current built solver on all 45 fixed inputs. +- For a PR, use its target SHA; for main pushes, use the previous main SHA. +- Look up a successful CI report artifact for exactly that commit. +- If no unexpired artifact exists, explicitly skip comparison. No source checkout, + rebuild, npm lookup, previous-release fallback or synthetic baseline. +- Compare deterministic quality metrics; report historical timing/memory ratios + with their cross-run limitations. Keep generated output out of Git. +- Save the benchmark report separately from the tested package artifact; name + artifacts with commit SHA and run attempt to avoid overwriting earlier attempts. + +## Release + +- A separate workflow runs after successful main-push CI to publish a canary. +- CI leaves tracked files unchanged. Release preparation assigns the canary or + stable version by changing package/build version metadata only. +- Manual stable dispatch takes a successful main CI run ID and stable SemVer. +- Download only that run's tested build artifact. No algorithm rebuild. +- Stable packaging changes only package/build version metadata; verify all other + archive bytes remain identical before publishing the new npm version. +- Publish to npm through OIDC, check registry/CDN delivery, attach five runtime + files, package archive and the benchmark report to the GitHub Release. +- Keep the two workflows readable, one job each, using official pinned Actions. +- Retain direct-publish setup and the readiness switch for first-package bootstrap. + +## Execution + +1. Replace module-vs-module benchmark machinery with current measurement and + optional saved-report comparison; test valid/missing/wrong-SHA reports. +2. Replace release machinery with one release script; test canary identity, + stable metadata-only repack and publication boundaries without publishing. +3. Test exact-SHA artifact selection and successful-main-only release selection. +4. Complete all local gates before changing workflow YAML. +5. Add the two workflows and update README, Wiki and agent instructions. +6. After CI simplification, fix secondary solution ordering: minimize unit shifts + among paths already optimal in grouped actions. Verify lock-018 and all 45 + inputs with an independent lexicographic oracle; preserve the public contract. +7. Review independently, update draft PR #3 and verify CI on the exact new head. + +No actual merge or package publication is authorized by this implementation task. diff --git a/package.json b/package.json index 5ce3971..6fe0155 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,6 @@ "build": "node .github/scripts/build.ts", "verify:package": "node tests/e2e/verify-package.ts", "benchmark": "node tests/benchmarks/benchmark.ts", - "benchmark:compare": "node tests/benchmarks/compare.ts", "solve": "node src/cli.ts" }, "description": "Exact shortest-action solver for Gothic 1 Remake plate locks.", diff --git a/src/astar.ts b/src/astar.ts index 9a81646..51ca76c 100644 --- a/src/astar.ts +++ b/src/astar.ts @@ -39,7 +39,7 @@ function findExecutablePlate( /** * A sufficient certificate: one legal action per nonzero net shift attains the - * lower bound. Failure proves nothing; A* then starts from the initial state. + * action and unit-shift lower bounds. Failure proves nothing; A* starts anew. */ function greedyCertificate( prepared: PreparedSearch, @@ -85,7 +85,8 @@ function reconstruct(node: SearchNode): readonly Command[] { /** * An invertible matrix gives unique remaining shifts for each physical state. - * Their action lower bound is consistent, so the first popped goal is optimal. + * Both cost lower bounds are consistent, so the first popped goal minimizes + * actions first, then unit shifts among all minimum-action paths. */ export class MatrixSearch { private readonly model: LockModel; @@ -135,10 +136,16 @@ class AStarSearch { solve(requiredShifts: readonly number[], lowerBound: number): readonly Command[] | null { this.budget.visit(); + const shiftEstimate = requiredShifts.reduce((sum, shift) => sum + Math.abs(shift), 0); + if (!Number.isSafeInteger(shiftEstimate)) { + throw new SearchLimitError('matrixArithmetic', Number.MAX_SAFE_INTEGER, shiftEstimate); + } const root: SearchNode = { state: this.prepared.initialCode, actionCount: 0, estimate: lowerBound, + unitShifts: 0, + shiftEstimate, remainingShifts: requiredShifts, previous: null, // The root has no incoming command; reconstruction stops at previous=null. @@ -185,14 +192,21 @@ class AStarSearch { private visitNeighbor(parent: SearchNode, plate: number, delta: number, effect: Effect): void { const state = parent.state + delta * effect.stateCodeOffset; const actionCount = parent.actionCount + 1; + const unitShifts = parent.unitShifts + Math.abs(delta); const existing = this.records.get(state); if (existing !== undefined) { - if (existing.closed || existing.actionCount <= actionCount) { + const noImprovement = existing.actionCount < actionCount + || (existing.actionCount === actionCount && existing.unitShifts <= unitShifts); + if (existing.closed || noImprovement) { return; } + if (!Number.isSafeInteger(unitShifts + existing.shiftEstimate)) { + throw new SearchLimitError('matrixArithmetic', Number.MAX_SAFE_INTEGER, unitShifts); + } // The physical state fixes remaining shifts and their lower bound. - // A shorter path only changes the predecessor and queue priority. + // A better cost only changes the predecessor and queue priority. existing.actionCount = actionCount; + existing.unitShifts = unitShifts; existing.previous = parent; existing.plate = plate; existing.delta = delta; @@ -209,7 +223,9 @@ class AStarSearch { const estimate = parent.estimate - minimumActionsForShift(previousShift) + minimumActionsForShift(remainingShift); - if (!Number.isSafeInteger(remainingShift) || !Number.isSafeInteger(actionCount + estimate)) { + const shiftEstimate = parent.shiftEstimate - Math.abs(previousShift) + Math.abs(remainingShift); + if (!Number.isSafeInteger(remainingShift) || !Number.isSafeInteger(actionCount + estimate) + || !Number.isSafeInteger(unitShifts + shiftEstimate)) { throw new SearchLimitError('matrixArithmetic', Number.MAX_SAFE_INTEGER, String(remainingShift)); } @@ -217,6 +233,8 @@ class AStarSearch { state, actionCount, estimate, + unitShifts, + shiftEstimate, remainingShifts, previous: parent, plate, diff --git a/src/bfs.ts b/src/bfs.ts index b116d63..b5aed85 100644 --- a/src/bfs.ts +++ b/src/bfs.ts @@ -7,6 +7,7 @@ import { movementLimits, readAt, readByte, + readFloat64, readInt32, } from './lock.ts'; import type { PreparedSearch } from './lock.ts'; @@ -21,10 +22,18 @@ const DIRECTION_COUNT = DIRECTIONS.length; interface Predecessor { readonly parent: number; readonly action: number; + readonly actionCount: number; + readonly unitShifts: number; } type Records = - | { readonly dense: true; readonly parents: Int32Array; readonly actions: Uint8Array } + | { + readonly dense: true; + readonly parents: Int32Array; + readonly actions: Uint8Array; + readonly actionCounts: Int32Array; + readonly unitShifts: Float64Array; + } | { readonly dense: false; readonly parents: Map }; function encodeCommand(plate: number, direction: number, steps: number): number { @@ -46,12 +55,15 @@ function hasVisited(records: Records, state: number): boolean { : records.parents.has(state); } -function remember(records: Records, state: number, parent: number, action: number): void { +function remember(records: Records, state: number, parent: number, action: number, + actionCount: number, unitShifts: number): void { if (records.dense) { records.parents[state] = parent; records.actions[state] = action; + records.actionCounts[state] = actionCount; + records.unitShifts[state] = unitShifts; } else { - records.parents.set(state, { parent, action }); + records.parents.set(state, { parent, action, actionCount, unitShifts }); } } @@ -60,6 +72,8 @@ function readPredecessor(records: Records, state: number): Predecessor { return { parent: readInt32(records.parents, state), action: readByte(records.actions, state), + actionCount: readInt32(records.actionCounts, state), + unitShifts: readFloat64(records.unitShifts, state), }; } const predecessor = records.parents.get(state); @@ -79,7 +93,7 @@ function reconstruct(records: Records, initialCode: number, state: number): read return commands.reverse(); } -/** Exact grouped-action BFS; plate order, positive direction, then larger shifts. */ +/** Exact action layers; each next-layer state retains its least unit-shift cost. */ export class BfsSearch { private readonly prepared: PreparedSearch; private readonly budget: SearchBudget; @@ -90,6 +104,7 @@ export class BfsSearch { private readIndex = 0; private writeIndex = 0; private queuedStates = 0; + private goalFound = false; constructor(prepared: PreparedSearch, budget: SearchBudget) { this.prepared = prepared; @@ -103,17 +118,18 @@ export class BfsSearch { return []; } this.initializeStorage(); - remember(this.records, this.prepared.initialCode, ROOT_PARENT, 0); + remember(this.records, this.prepared.initialCode, ROOT_PARENT, 0, 0, 0); this.enqueue(this.prepared.initialCode); while (this.queuedStates > 0) { - this.budget.expand(); - const state = this.dequeue(); - decodeDigits(state, this.positionDigits); - for (let plate = 0; plate < this.prepared.plateCount; plate += 1) { - if (this.expandPlate(state, plate)) { - return reconstruct(this.records, this.prepared.initialCode, this.prepared.goalCode); - } + const layerSize = this.queuedStates; + for (let index = 0; index < layerSize; index += 1) { + const state = this.dequeue(); + this.expandState(state); + } + // Every predecessor at this depth has now offered its cheapest goal path. + if (this.goalFound) { + return reconstruct(this.records, this.prepared.initialCode, this.prepared.goalCode); } } return null; @@ -123,13 +139,20 @@ export class BfsSearch { const { stateCount } = this.prepared; const { maxVisited, maxFrontier, maxDenseBytes } = this.budget.options; this.queueCapacity = Math.min(stateCount, maxVisited, maxFrontier); - const bytesPerRecord = Int32Array.BYTES_PER_ELEMENT + Uint8Array.BYTES_PER_ELEMENT; + const bytesPerRecord = 2 * Int32Array.BYTES_PER_ELEMENT + + Uint8Array.BYTES_PER_ELEMENT + Float64Array.BYTES_PER_ELEMENT; const denseBytes = stateCount * bytesPerRecord + this.queueCapacity * Int32Array.BYTES_PER_ELEMENT; // Dense parents are signed state codes; negative values are sentinels. const useDenseStorage = stateCount <= MAX_INT32 && denseBytes <= maxDenseBytes; try { this.records = useDenseStorage - ? { dense: true, parents: new Int32Array(stateCount).fill(UNVISITED), actions: new Uint8Array(stateCount) } + ? { + dense: true, + parents: new Int32Array(stateCount).fill(UNVISITED), + actions: new Uint8Array(stateCount), + actionCounts: new Int32Array(stateCount), + unitShifts: new Float64Array(stateCount), + } : { dense: false, parents: new Map() }; this.queue = useDenseStorage ? new Int32Array(this.queueCapacity) : []; } catch (error) { @@ -141,6 +164,7 @@ export class BfsSearch { this.readIndex = 0; this.writeIndex = 0; this.queuedStates = 0; + this.goalFound = false; } /** Ring-buffer storage retains only the live frontier, including sparse mode. */ @@ -160,24 +184,61 @@ export class BfsSearch { return state; } - private expandPlate(state: number, plate: number): boolean { + private expandState(state: number): void { + this.budget.expand(); + decodeDigits(state, this.positionDigits); + const distance = readPredecessor(this.records, state); + for (let plate = 0; plate < this.prepared.plateCount; plate += 1) { + if (this.expandPlate(state, plate, distance)) { + // Every one-command route from this state to the goal costs the same. + return; + } + } + } + + private expandPlate(state: number, plate: number, distance: Predecessor): boolean { const effect = readAt(this.prepared.effects, plate); const [positiveLimit, negativeLimit] = movementLimits(this.positionDigits, effect); for (const direction of DIRECTIONS) { const limit = direction > 0 ? positiveLimit : negativeLimit; for (let steps = limit; steps >= 1; steps -= 1) { const nextState = state + direction * steps * effect.stateCodeOffset; - if (hasVisited(this.records, nextState)) { - continue; - } - this.budget.visit(); - remember(this.records, nextState, state, encodeCommand(plate, direction, steps)); - if (nextState === this.prepared.goalCode) { + if (this.visitNeighbor(state, nextState, encodeCommand(plate, direction, steps), distance, steps)) { return true; } - this.enqueue(nextState); } } return false; } + + private visitNeighbor(state: number, nextState: number, action: number, + distance: Predecessor, steps: number): boolean { + const isGoal = nextState === this.prepared.goalCode; + if (this.goalFound && !isGoal) { + return false; + } + const actionCount = distance.actionCount + 1; + const unitShifts = distance.unitShifts + steps; + if (!Number.isSafeInteger(unitShifts)) { + throw new SearchLimitError('matrixArithmetic', Number.MAX_SAFE_INTEGER, unitShifts); + } + const visited = hasVisited(this.records, nextState); + if (visited) { + const previous = readPredecessor(this.records, nextState); + if (previous.actionCount !== actionCount || previous.unitShifts <= unitShifts) { + return false; + } + } else { + this.budget.visit(); + } + remember(this.records, nextState, state, action, actionCount, unitShifts); + if (isGoal) { + this.goalFound = true; + return true; + } + if (!visited) { + this.enqueue(nextState); + } + return false; + } } diff --git a/src/index.ts b/src/index.ts index 7c23b87..01e6803 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,8 +5,9 @@ import type { SolverConfig } from './config.ts'; import type { Command, Links, Position, StateConstraint } from './types.ts'; /** - * Find a legal sequence with the minimum number of actions, or null when the - * goal is proved unreachable. Each [index, delta] directly changes the selected + * Minimize actions, then total absolute pin displacement among equal-action + * solutions; return null when the goal is proved unreachable. + * Each [index, delta] directly changes the selected * pin's numeric position and applies links[index][target] once without cascades. * Inputs are copied and never modified; all goal positions are 4. * diff --git a/src/lock.ts b/src/lock.ts index ccb6f96..9ac270f 100644 --- a/src/lock.ts +++ b/src/lock.ts @@ -204,3 +204,11 @@ export function readInt32(values: Int32Array, index: number): number { } return value; } + +export function readFloat64(values: Float64Array, index: number): number { + const value = values[index]; + if (value === undefined) { + throw new Error(`Internal number-access invariant failed at ${index}.`); + } + return value; +} diff --git a/src/priority-queue.ts b/src/priority-queue.ts index dd539b6..c973711 100644 --- a/src/priority-queue.ts +++ b/src/priority-queue.ts @@ -4,6 +4,8 @@ export interface SearchNode { readonly state: number; actionCount: number; readonly estimate: number; + unitShifts: number; + readonly shiftEstimate: number; readonly remainingShifts: readonly number[]; previous: SearchNode | null; plate: number; @@ -27,6 +29,11 @@ export class IndexedHeap { if (leftTotal !== rightTotal) { return leftTotal < rightTotal; } + const leftShifts = left.unitShifts + left.shiftEstimate; + const rightShifts = right.unitShifts + right.shiftEstimate; + if (leftShifts !== rightShifts) { + return leftShifts < rightShifts; + } if (left.estimate !== right.estimate) { return left.estimate < right.estimate; } diff --git a/tests/benchmarks/benchmark.ts b/tests/benchmarks/benchmark.ts index 43f2748..e81d8cb 100644 --- a/tests/benchmarks/benchmark.ts +++ b/tests/benchmarks/benchmark.ts @@ -1,2 +1,8 @@ import { main } from './cli.ts'; -try { await main(); } catch (error) { console.error(error); process.exitCode = 1; } + +try { + await main(); +} catch (error) { + console.error(error); + process.exitCode = 1; +} diff --git a/tests/benchmarks/cli.ts b/tests/benchmarks/cli.ts index 435b1bb..f2a9e8e 100644 --- a/tests/benchmarks/cli.ts +++ b/tests/benchmarks/cli.ts @@ -1,93 +1,186 @@ -import { readFileSync, statSync } from 'node:fs'; +import { readFileSync } from 'node:fs'; import os from 'node:os'; -import { dirname, join, resolve } from 'node:path'; +import { 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'; +import { assertArtifactOutput, git, jsonFile, sha256 } from './catalog.ts'; +import { + createReport, + loadImplementation, + publishReport, + readReport, + runBenchmark, + type Environment, + type Settings, + type Source, +} from './harness.ts'; +import { collectMemory } from './memory.ts'; +import { record } from './validation.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 }; +const modulePath = join(root, 'dist/gothic-lock-solver.mjs'); +export type BenchmarkOptions = Settings & { + baselineReport?: string; + baselineSha?: string; + outputDir: string; + 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 options: BenchmarkOptions = { + outputDir: join(root, 'artifacts/benchmark'), + warmups: 2, + repetitions: 7, + memoryRepetitions: 3, + help: false, + }; 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}`); + 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 === '--help') { + options.help = true; + continue; + } + if (!['--baseline-report', '--baseline-sha', '--output-dir', '--warmups', '--repetitions', '--memory-repetitions'].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; + case '--baseline-report': + options.baselineReport = resolve(value); + break; + case '--baseline-sha': + if (!/^[a-f0-9]{40}$/.test(value)) { + throw new Error('Baseline SHA must be an exact 40-character source SHA.'); + } + options.baselineSha = 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.'); + if (options.baselineReport !== undefined && options.baselineSha === undefined) { + throw new Error('A provided baseline report requires --baseline-sha for the exact target revision.'); + } 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 function collectSource(): Source { + const revision = git(root, ['rev-parse', 'HEAD']); + const moduleSha256 = sha256(readFileSync(modulePath)); + const manifest = record(jsonFile(join(root, 'dist/manifest.json')), 'build manifest'); + if (manifest['sourceSha'] !== revision || record(manifest['files'], 'build files')['gothic-lock-solver.mjs'] !== moduleSha256) { + throw new Error('Built solver manifest does not match the current source SHA and module checksum; run pnpm build.'); + } + return { + revision, + moduleSha256, + dirty: git(root, ['status', '--porcelain', '--untracked-files=normal']).length > 0, + }; } + +function collectEnvironment(): Environment { + return { + 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(), + }; +} + 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.'); + console.log([ + 'Usage: pnpm benchmark [--baseline-report PATH --baseline-sha SHA]', + '[--output-dir artifacts/benchmark] [--warmups 2]', + '[--repetitions 7] [--memory-repetitions 3]', + ].join(' ')); + console.log([ + 'Measures the current built ESM on all 45 fixed locks.', + 'Only a saved report for the exact target SHA is compared;', + 'without one the comparison is explicitly skipped.', + ].join(' ')); 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; + const baseline = options.baselineReport === undefined ? undefined : readReport(options.baselineReport, options.baselineSha); + if (baseline !== undefined && baseline.quality !== 'passed') { + throw new Error('Saved baseline report quality must pass.'); + } + const source = collectSource(); + const environment = collectEnvironment(); + const solve = await loadImplementation(modulePath); + const results = runBenchmark({ + solve, + warmups: options.warmups, + repetitions: options.repetitions, + onProgress: (completed, total, id) => { + process.stderr.write(`[${completed}/${total}] ${id}: replay and optimum checked\n`); + }, + }); + const memory = collectMemory(modulePath, options.memoryRepetitions, (line) => process.stderr.write(`${line}\n`)); + if (sha256(readFileSync(modulePath)) !== source.moduleSha256 || git(root, ['rev-parse', 'HEAD']) !== source.revision) { + throw new Error('Current solver source or module changed during measurement.'); + } + const settings = { + warmups: options.warmups, + repetitions: options.repetitions, + memoryRepetitions: options.memoryRepetitions, + }; + const report = createReport({ + source, + environment, + settings, + results, + memory, + ...(baseline === undefined ? {} : { baseline }), + }); + if (report.comparison.status === 'skipped' && options.baselineSha !== undefined) { + report.comparison.reason = `No saved benchmark report is available for exact target SHA ${options.baselineSha}.`; + } + const paths = await publishReport(report, options.outputDir); + console.log(JSON.stringify({ + ...paths, + lockCount: report.fixtures.count, + quality: report.quality, + comparison: report.comparison.status, + }, null, 2)); + if (report.quality === 'regression') { + process.exitCode = 1; + } } diff --git a/tests/benchmarks/compare.ts b/tests/benchmarks/compare.ts deleted file mode 100644 index 43f2748..0000000 --- a/tests/benchmarks/compare.ts +++ /dev/null @@ -1,2 +0,0 @@ -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 index 352c8e6..ef1a658 100644 --- a/tests/benchmarks/comparison.ts +++ b/tests/benchmarks/comparison.ts @@ -1,53 +1,50 @@ import type { Metrics } from './validation.ts'; -export type Verdict = 'passed' | 'regression' | 'inconclusive'; + +export type Quality = 'passed' | 'regression'; export const metricNames = ['A', 'U', 'C', 'plateSwitches'] as const; +export type MetricComparison = { + quality: Quality; + deltas: Metrics; + regressions: string[]; +}; -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 }; +export function compareMetrics(candidate: Metrics, baseline: Metrics): MetricComparison { + 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 }; + 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.'); + if (samples.length === 0 || samples.some((sample) => !Number.isFinite(sample) || sample < 0)) { + throw new Error('Invalid measurement samples.'); + } const sorted = [...samples].sort((a, b) => a - b); - const first = sorted[0]; const last = sorted.at(-1); + 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 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 }; + 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, + }; } -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'; + +export function historicalRatio(current: number, previous: number): number | null { + return previous === 0 ? null : current / previous; } diff --git a/tests/benchmarks/harness.ts b/tests/benchmarks/harness.ts index eb15ffa..bbae41a 100644 --- a/tests/benchmarks/harness.ts +++ b/tests/benchmarks/harness.ts @@ -1,206 +1,487 @@ import assert from 'node:assert/strict'; -import { randomUUID } from 'node:crypto'; -import { mkdir, mkdtemp, rename, rm, writeFile } from 'node:fs/promises'; +import { mkdir, 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 }; +import { + array, + commandMetrics, + integer, + record, + replayCommands, + string, + tupleCommands, + type Definition, + type Metrics, + type TupleCommand, +} from './validation.ts'; +import { catalogSha256, jsonFile, loadCatalog, readMetrics, type Fixture } from './catalog.ts'; +import { + compareMetrics, + historicalRatio, + statistics, + type MetricComparison, + type Quality, +} from './comparison.ts'; +import { parseMemory, type MemoryMeasurement } from './memory.ts'; + +export type Solver = (definition: Definition) => 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 type Measurement = Solution & { + id: string; + optimumA: number; + samplesMs: number[]; + timingMs: ReturnType; +}; +export type Source = { + revision: string; + dirty: boolean; + moduleSha256: string; +}; +export type Environment = { + node: string; + v8: string; + platform: string; + arch: string; + cpu: string; + logicalCpus: number; + osRelease: string; + totalMemoryBytes: number; +}; +export type Settings = { + warmups: number; + repetitions: number; + memoryRepetitions: number; +}; +export type ComparedLock = MetricComparison & { + id: string; + baseline: Metrics; + timingMedianRatio: number | null; + timingP95Ratio: number | null; +}; +export type Comparison = + | { status: 'skipped'; reason: string } + | { + status: 'compared'; + baselineSha: string; + quality: Quality; + results: ComparedLock[]; + memoryPeakMedianRatio: number | null; + limitations: string; + }; +export type BenchmarkReport = { + schemaVersion: 2; + createdAt: string; + source: Source; + environment: Environment; + settings: Settings; + fixtures: { count: 45; catalogSha256: string }; + quality: Quality; + results: Measurement[]; + totals: Metrics; + memory: MemoryMeasurement; + comparison: Comparison; +}; +const historicalLimitations = [ + 'Historical timing and RSS ratios are informational observations.', + 'Hosts, runtimes, load, sample counts and measurement noise can differ;', + 'these are not paired performance gates.', +].join(' '); -export async function loadImplementation(modulePath: string, format: ModuleFormat, label: string): Promise { +export async function loadImplementation(modulePath: 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); + const solveLock = record(module, 'solver module')['solveLock']; + if (typeof solveLock !== 'function') { + throw new Error(`Missing named solveLock export: ${modulePath}`); } - 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' }); + return (definition) => { + const result: unknown = solveLock(definition.state, definition.links); + return result; + }; } + export function validateSolution(lock: Fixture, raw: unknown): Solution { - if (raw === null) throw new Error(`${lock.id}: lost solvability.`); + 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; + +export function runBenchmark(options: { + solve: Solver; + locks?: readonly Fixture[]; + 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; +}): Measurement[] { + const locks = options.locks ?? loadCatalog(); + const repetitions = count(options.repetitions ?? 7, 1, 'repetitions'); + const warmups = count(options.warmups ?? 2, 0, 'warmups'); + const clock = options.clock ?? (() => performance.now()); + if (locks.length === 0 || new Set(locks.map((lock) => lock.id)).size !== locks.length) { + throw new Error('Expected unique nonempty fixtures.'); + } + return locks.map((lock, index) => { + const samplesMs: number[] = []; + let solution: 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 definition = structuredClone({ state: lock.state, links: lock.links }); + const before = JSON.stringify(definition); + const start = clock(); + const raw = options.solve(definition); + const elapsed = clock() - start; + assert.equal(JSON.stringify(definition), before, `${lock.id}: solver mutated its input.`); + const measured = validateSolution(lock, raw); + if (solution !== undefined) { + assert.deepEqual(measured, solution, `${lock.id}: nondeterministic solution.`); } - } - 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); + solution = measured; + if (round >= 0) { + samplesMs.push(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.`); + if (solution === undefined) { + throw new Error('Missing measured solution.'); + } + options.onProgress?.(index + 1, locks.length, lock.id); + return { id: lock.id, optimumA: lock.expectedActions, ...solution, samplesMs, timingMs: statistics(samplesMs) }; + }); +} + +function count(value: unknown, minimum: number, label: string): number { + const parsed = integer(value, label); + if (parsed < minimum) { + throw new Error(`Invalid ${label} count.`); + } + return parsed; +} + +function digest(value: unknown, length: number, label: string): string { + const parsed = string(value, label); + if (!new RegExp(`^[a-f0-9]{${length}}$`).test(parsed)) { + throw new Error(`Invalid ${label}.`); + } + return parsed; +} + +function totalMetrics(results: readonly Metrics[]): Metrics { + const totals = { A: 0, U: 0, C: 0, plateSwitches: 0 }; + for (const row of results) { + totals.A += row.A; + totals.U += row.U; + totals.C += row.C; + totals.plateSwitches += row.plateSwitches; + } + return totals; +} + +function compareReport(results: readonly Measurement[], memory: MemoryMeasurement, baseline?: BenchmarkReport): Comparison { + if (baseline === undefined) { + return { status: 'skipped', reason: 'No saved benchmark report was supplied for the exact target SHA.' }; + } + const compared = results.map((row): ComparedLock => { + const previous = baseline.results.find((item) => item.id === row.id); + if (previous === undefined) { + throw new Error(`Baseline fixture missing: ${row.id}`); + } + return { + id: row.id, + baseline: readMetrics(previous), + ...compareMetrics(row, previous), + timingMedianRatio: historicalRatio(row.timingMs.median, previous.timingMs.median), + timingP95Ratio: historicalRatio(row.timingMs.p95, previous.timingMs.p95), + }; + }); + return { + status: 'compared', + baselineSha: baseline.source.revision, + quality: compared.some((row) => row.quality === 'regression') ? 'regression' : 'passed', + results: compared, + memoryPeakMedianRatio: memory.metric === baseline.memory.metric + ? historicalRatio(memory.peakRssBytes.median, baseline.memory.peakRssBytes.median) + : null, + limitations: historicalLimitations, + }; +} + +export function createReport(options: { + source: Source; + environment: Environment; + settings: Settings; + results: Measurement[]; + memory: MemoryMeasurement; + baseline?: BenchmarkReport; +}): BenchmarkReport { + const comparison = compareReport(options.results, options.memory, options.baseline); + return { + schemaVersion: 2, + createdAt: new Date().toISOString(), + source: options.source, + environment: options.environment, + settings: options.settings, + fixtures: { count: 45, catalogSha256 }, + quality: comparison.status === 'compared' ? comparison.quality : 'passed', + results: options.results, + totals: totalMetrics(options.results), + memory: options.memory, + comparison, + }; +} + +function parseMeasurements(raw: unknown, repetitions: number): Measurement[] { + const rows = array(raw, 'benchmark results'); + const catalog = loadCatalog(); + if (rows.length !== catalog.length) { + throw new Error('Benchmark report requires all 45 locks.'); + } + return catalog.map((lock, index) => { + const row = record(rows[index], 'benchmark lock'); + if (row['id'] !== lock.id || row['optimumA'] !== lock.expectedActions) { + throw new Error('Benchmark fixture identity or optimum mismatch.'); } - 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 solution = validateSolution(lock, row['commands']); + assert.deepEqual(readMetrics(row), commandMetrics(solution.commands), 'Report metric/path mismatch.'); + const samplesMs = array(row['samplesMs'], 'timing samples').map((value) => { + if (typeof value !== 'number') { + throw new Error('Invalid timing sample.'); } - 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 }); + return value; + }); + if (samplesMs.length !== repetitions) { + throw new Error('Timing sample count mismatch.'); } - 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 timingMs = statistics(samplesMs); + assert.deepEqual(row['timingMs'], timingMs, 'Timing statistics mismatch.'); + return { id: lock.id, optimumA: lock.expectedActions, ...solution, samplesMs, timingMs }; }); - 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') }; +} + +function ratio(value: unknown): number | null { + if (value === null) { + return null; + } + if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) { + throw new Error('Invalid historical ratio.'); + } + return value; +} + +function parseComparison(raw: unknown, results: readonly Measurement[]): Comparison { + const value = record(raw, 'comparison'); + if (value['status'] === 'skipped') { + const reason = string(value['reason'], 'comparison skip reason'); + if (reason.trim() === '') { + throw new Error('Comparison skip reason missing.'); + } + return { status: 'skipped', reason }; + } + if (value['status'] !== 'compared') { + throw new Error('Invalid comparison status.'); + } + const rows = array(value['results'], 'comparison results'); + if (rows.length !== results.length) { + throw new Error('Comparison requires every lock.'); + } + const compared = results.map((current, index): ComparedLock => { + const row = record(rows[index], 'comparison lock'); + if (row['id'] !== current.id) { + throw new Error('Comparison fixture mismatch.'); + } + const baseline = readMetrics(row['baseline']); + if (Object.values(baseline).some((metric) => metric < 0) || baseline.A !== current.optimumA) { + throw new Error('Invalid baseline metrics.'); + } + const comparison = compareMetrics(current, baseline); + assert.deepEqual(row['deltas'], comparison.deltas, 'Comparison delta mismatch.'); + assert.deepEqual(row['regressions'], comparison.regressions, 'Comparison regressions mismatch.'); + if (row['quality'] !== comparison.quality) { + throw new Error('Comparison lock quality mismatch.'); + } + return { + id: current.id, + baseline, + ...comparison, + timingMedianRatio: ratio(row['timingMedianRatio']), + timingP95Ratio: ratio(row['timingP95Ratio']), + }; + }); + const quality = compared.some((row) => row.quality === 'regression') ? 'regression' : 'passed'; + if (value['quality'] !== quality) { + throw new Error('Comparison quality mismatch.'); + } + return { + status: 'compared', + baselineSha: digest(value['baselineSha'], 40, 'baseline SHA'), + quality, + results: compared, + memoryPeakMedianRatio: ratio(value['memoryPeakMedianRatio']), + limitations: string(value['limitations'], 'historical limitations'), + }; +} + +export function parseReport(raw: unknown, expectedSha?: string): BenchmarkReport { + const value = record(raw, 'benchmark report'); + const fixtures = record(value['fixtures'], 'report fixtures'); + if (value['schemaVersion'] !== 2 || fixtures['count'] !== 45 || fixtures['catalogSha256'] !== catalogSha256) { + throw new Error('Report schema or fixture catalog mismatch.'); + } + const rawSource = record(value['source'], 'report source'); + const revision = digest(rawSource['revision'], 40, 'source revision'); + const moduleSha256 = digest(rawSource['moduleSha256'], 64, 'source module checksum'); + if (typeof rawSource['dirty'] !== 'boolean') { + throw new Error('Missing source dirty status.'); + } + if (expectedSha !== undefined && revision !== digest(expectedSha, 40, 'expected source SHA')) { + throw new Error('Report source revision differs from exact target SHA.'); + } + if (expectedSha !== undefined && rawSource['dirty']) { + throw new Error('An exact source SHA report requires a clean checkout; report is dirty.'); + } + const source = { revision, moduleSha256, dirty: rawSource['dirty'] }; + const rawSettings = record(value['settings'], 'benchmark settings'); + const settings = { + warmups: count(rawSettings['warmups'], 0, 'warmups'), + repetitions: count(rawSettings['repetitions'], 1, 'repetitions'), + memoryRepetitions: count(rawSettings['memoryRepetitions'], 1, 'memory repetitions'), + }; + const rawEnvironment = record(value['environment'], 'benchmark environment'); + const environment = { + node: string(rawEnvironment['node']), + v8: string(rawEnvironment['v8']), + platform: string(rawEnvironment['platform']), + arch: string(rawEnvironment['arch']), + cpu: string(rawEnvironment['cpu']), + logicalCpus: count(rawEnvironment['logicalCpus'], 1, 'logical CPUs'), + osRelease: string(rawEnvironment['osRelease']), + totalMemoryBytes: count(rawEnvironment['totalMemoryBytes'], 1, 'total memory'), + }; + const createdAt = string(value['createdAt'], 'report timestamp'); + if (!Number.isFinite(Date.parse(createdAt))) { + throw new Error('Invalid report timestamp.'); + } + const results = parseMeasurements(value['results'], settings.repetitions); + const totals = totalMetrics(results); + assert.deepEqual(value['totals'], totals, 'Report totals mismatch.'); + const memory = parseMemory(value['memory'], settings.memoryRepetitions); + const comparison = parseComparison(value['comparison'], results); + const quality = comparison.status === 'compared' ? comparison.quality : 'passed'; + if (value['quality'] !== quality) { + throw new Error('Report quality mismatch.'); + } + return { + schemaVersion: 2, + createdAt, + source, + environment, + settings, + fixtures: { count: 45, catalogSha256 }, + quality, + results, + totals, + memory, + comparison, + }; +} + +export function readReport(path: string, expectedSha?: string): BenchmarkReport { + return parseReport(jsonFile(path), expectedSha); +} + +function renderComparison(comparison: Comparison): string { + if (comparison.status === 'skipped') { + return `Comparison: **skipped**. ${comparison.reason}`; + } + const rows = comparison.results.map((row) => [ + '', + row.id, + row.deltas.A, + row.deltas.U, + row.deltas.C, + row.deltas.plateSwitches, + row.timingMedianRatio?.toFixed(3) ?? 'unavailable', + row.timingP95Ratio?.toFixed(3) ?? 'unavailable', + '', + ].join(' | ')); + const memoryRatio = comparison.memoryPeakMedianRatio?.toFixed(3) + ?? 'unavailable (different metrics or zero baseline)'; + return [ + `Comparison: **${comparison.quality}**, saved report for ${comparison.baselineSha}.`, + '', + comparison.limitations, + '', + '| Lock | Δ actions | Δ distinct plates | Δ unit shifts | Δ switches | Median ratio | p95 ratio |', + '| --- | ---: | ---: | ---: | ---: | ---: | ---: |', + ...rows, + '', + `Whole-catalog RSS median ratio: ${memoryRatio}.`, + ].join('\n'); +} + +function renderMarkdown(report: BenchmarkReport): string { + const rows = report.results.map((row) => [ + '', + row.id, + row.A, + row.U, + row.C, + row.plateSwitches, + row.timingMs.median.toFixed(3), + row.timingMs.p95.toFixed(3), + '', + ].join(' | ')); + const summary = [ + `Quality: **${report.quality}**. All 45 fixed locks replayed and exact action minima checked.`, + `Totals: ${report.totals.A} actions, ${report.totals.U} distinct plates summed per lock,`, + `${report.totals.C} unit shifts, ${report.totals.plateSwitches} switches.`, + ].join(' '); + const environment = [ + `Node ${report.environment.node}; ${report.environment.platform}/${report.environment.arch};`, + `${report.environment.cpu}. ${report.settings.warmups} warmups and`, + `${report.settings.repetitions} measured solves per lock.`, + 'Timings cover the solver call, excluding input cloning and independent validation.', + ].join(' '); + const memory = [ + `Whole-catalog Node peak RSS median: ${report.memory.peakRssBytes.median} bytes;`, + `p95: ${report.memory.peakRssBytes.p95} bytes`, + `(${report.memory.samples.length} fresh-process observations, ${report.memory.metric}).`, + ].join(' '); + return [ + '# Gothic Lock Solver benchmark', + '', + `Source: ${report.source.revision}; dirty: ${report.source.dirty}. ${report.createdAt}`, + '', + summary, + '', + environment, + '', + '| Lock | Actions | Distinct plates | Unit shifts | Switches | Median ms | p95 ms |', + '| --- | ---: | ---: | ---: | ---: | ---: | ---: |', + ...rows, + '', + renderComparison(report.comparison), + '', + memory, + '', + report.memory.limitations, + '', + report.memory.browser.reason, + '', + 'Commands, raw timings, RSS samples, and environment details are retained in benchmark.json.', + '', + ].join('\n'); +} + +export async function publishReport(report: BenchmarkReport, outputDir: string): Promise<{ jsonPath: string; markdownPath: string }> { + parseReport(report); + await mkdir(outputDir, { recursive: true }); + const jsonPath = join(outputDir, 'benchmark.json'); + const markdownPath = join(outputDir, 'benchmark.md'); + await writeFile(jsonPath, `${JSON.stringify(report, null, 2)}\n`); + await writeFile(markdownPath, renderMarkdown(report)); + return { jsonPath, markdownPath }; } diff --git a/tests/benchmarks/memory-worker.ts b/tests/benchmarks/memory-worker.ts index 16add05..a8a01b0 100644 --- a/tests/benchmarks/memory-worker.ts +++ b/tests/benchmarks/memory-worker.ts @@ -1,25 +1,34 @@ import { readPeakRss } from './rss.ts'; import { loadCatalog } from './catalog.ts'; -import { loadImplementation } from './harness.ts'; -import { commandMetrics, replayCommands } from './validation.ts'; +import { loadImplementation, runBenchmark } from './harness.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 [modulePath, fixtureIds] = process.argv.slice(2); +if (modulePath === undefined) { + throw new Error('Missing RSS worker module.'); +} +const solve = await loadImplementation(modulePath); 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 catalog = loadCatalog(); +const selected = fixtureIds === undefined ? null : new Set(fixtureIds.split(',')); +const locks = selected === null ? catalog : catalog.filter((lock) => selected.has(lock.id)); +if (locks.length === 0 || (selected !== null && locks.length !== selected.size)) { + throw new Error('RSS fixture not found.'); } +runBenchmark({ locks, solve, warmups: 0, repetitions: 1 }); 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 })); +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 index 21b07d5..0312052 100644 --- a/tests/benchmarks/memory.ts +++ b/tests/benchmarks/memory.ts @@ -1,74 +1,134 @@ +import assert from 'node:assert/strict'; 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 }; +import { array, integer, record, string } from './validation.ts'; +import { statistics } from './comparison.ts'; + +export type RssMetric = 'linux-proc-VmHWM' | 'node-resourceUsage-maxRSS'; +export type RssSample = { + peakRssBytes: number; + startupRssBytes: number; + loadedRssBytes: number; + lockCount: number; + peakRssMetric: RssMetric; + startupPeakRssBytes: number; + loadedPeakRssBytes: number; + resourceUsageMaxRssBytes: number; + startupResourceUsageMaxRssBytes: number; + resourceUsageLaunchFloorDetected: boolean; +}; +export type MemoryMeasurement = { + metric: RssMetric; + limitations: string; + browser: { available: false; reason: string }; + samples: RssSample[]; + peakRssBytes: ReturnType; +}; + function positive(value: unknown): number { - if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) throw new Error('Invalid worker RSS measurement.'); return value; + const result = integer(value, 'RSS measurement'); + if (result <= 0) { + throw new Error('Invalid positive RSS measurement.'); + } + return result; } -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 }; + +function metric(value: unknown): RssMetric { + if (value !== 'linux-proc-VmHWM' && value !== 'node-resourceUsage-maxRSS') { + throw new Error('Unknown RSS metric.'); + } + return value; +} + +function parseSample(raw: unknown): RssSample { + const value = record(raw, 'RSS sample'); + if (typeof value['resourceUsageLaunchFloorDetected'] !== 'boolean') { + throw new Error('Missing RSS launch-floor diagnostic.'); + } + return { + peakRssBytes: positive(value['peakRssBytes']), + startupRssBytes: positive(value['startupRssBytes']), + loadedRssBytes: positive(value['loadedRssBytes']), + lockCount: positive(value['lockCount']), + peakRssMetric: metric(value['peakRssMetric']), + startupPeakRssBytes: positive(value['startupPeakRssBytes']), + loadedPeakRssBytes: positive(value['loadedPeakRssBytes']), + resourceUsageMaxRssBytes: positive(value['resourceUsageMaxRssBytes']), + startupResourceUsageMaxRssBytes: positive(value['startupResourceUsageMaxRssBytes']), + resourceUsageLaunchFloorDetected: value['resourceUsageLaunchFloorDetected'], + }; +} + +export function parseMemory(raw: unknown, repetitions: number): MemoryMeasurement { + const value = record(raw, 'memory measurement'); + const samples = array(value['samples'], 'RSS samples').map(parseSample); + const rssMetric = metric(value['metric']); + if (samples.length !== repetitions || samples.some((sample) => sample.lockCount !== 45 || sample.peakRssMetric !== rssMetric)) { + throw new Error('RSS sample count, workload or metric mismatch.'); + } + const peakRssBytes = statistics(samples.map((sample) => sample.peakRssBytes)); + assert.deepEqual(value['peakRssBytes'], peakRssBytes, 'RSS statistics mismatch.'); + const browser = record(value['browser'], 'browser memory limitation'); + if (browser['available'] !== false) { + throw new Error('Node RSS cannot establish browser memory.'); + } + return { + metric: rssMetric, + limitations: string(value['limitations']), + browser: { available: false, reason: string(browser['reason']) }, + samples, + peakRssBytes, + }; } -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.' }; + +export function measureRss(modulePath: string, fixtureIds?: readonly string[]): RssSample { + const worker = fileURLToPath(new URL('./memory-worker.ts', import.meta.url)); + const args = [worker, modulePath]; + if (fixtureIds !== undefined) { + args.push(fixtureIds.join(',')); + } + const stdout = execFileSync(process.execPath, args, { + encoding: 'utf8', + timeout: 120_000, + maxBuffer: 1_048_576, + stdio: ['ignore', 'pipe', 'pipe'], }); - 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 parsed: unknown = JSON.parse(stdout); + const value = record(parsed, 'RSS worker result'); + if (value['status'] !== 'passed') { + throw new Error('RSS worker failed.'); } - 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 sample = parseSample(value); + if (sample.lockCount !== (fixtureIds?.length ?? 45)) { + throw new Error('RSS worker measured a different workload.'); } - 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 sample; +} + +export function collectMemory(modulePath: string, repetitions = 3, onProgress?: (text: string) => void): MemoryMeasurement { + if (!Number.isSafeInteger(repetitions) || repetitions < 1) { + throw new Error('Invalid memory repetition count.'); + } + const samples: RssSample[] = []; + for (let round = 0; round < repetitions; round += 1) { + samples.push(measureRss(modulePath)); + onProgress?.(`RSS whole-catalog observation ${round + 1}/${repetitions}`); } - 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 }; + return { + metric: process.platform === 'linux' ? 'linux-proc-VmHWM' : 'node-resourceUsage-maxRSS', + limitations: [ + 'Approximate whole-process peak RSS in fresh Node processes running all 45 locks.', + 'Includes Node, the TypeScript loader, module and fixture loading, independent replay, and solver allocations.', + 'Linux uses /proc/self/status VmHWM; other platforms use process.resourceUsage().maxRSS × 1024', + 'with platform-dependent launch high-water behavior.', + 'Startup diagnostics are observations, not exact allocation subtraction.', + 'Historical hosts and runtimes can differ.', + ].join(' '), + browser: { + available: false, + reason: 'Node RSS is not browser memory; no portable browser peak-memory API is measured.', + }, + samples, + peakRssBytes: statistics(samples.map((sample) => sample.peakRssBytes)), + }; } diff --git a/tests/benchmarks/snapshot.ts b/tests/benchmarks/snapshot.ts deleted file mode 100644 index e288f56..0000000 --- a/tests/benchmarks/snapshot.ts +++ /dev/null @@ -1,46 +0,0 @@ -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 deleted file mode 100644 index f4796f8..0000000 --- a/tests/benchmarks/source.ts +++ /dev/null @@ -1,105 +0,0 @@ -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 index 9c2d0fd..62c2d8c 100644 --- a/tests/benchmarks/validation.ts +++ b/tests/benchmarks/validation.ts @@ -29,16 +29,6 @@ export function tupleCommands(value: unknown): TupleCommand[] { 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)) { diff --git a/tests/unit/artifact.test.ts b/tests/unit/artifact.test.ts new file mode 100644 index 0000000..9c661cb --- /dev/null +++ b/tests/unit/artifact.test.ts @@ -0,0 +1,114 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { findBenchmarkArtifact, findReleaseArtifact } from '../../.github/scripts/artifact.ts'; + +const repository = 'owner/repo'; +const sha = 'a'.repeat(40); +const run = { + id: 12, run_attempt: 2, path: '.github/workflows/ci.yml', head_sha: sha, + status: 'completed', conclusion: 'success', event: 'push', head_branch: 'main', + repository: { full_name: repository }, head_repository: { full_name: repository }, +}; + +function mockApi(responses: readonly { status?: number; body: unknown }[]) { + const pending = [...responses]; + return vi.spyOn(globalThis, 'fetch').mockImplementation(async () => { + const next = pending.shift(); + if (!next) throw new Error('Unexpected GitHub request'); + return new Response(JSON.stringify(next.body), { status: next.status ?? 200 }); + }); +} + +afterEach(() => vi.restoreAllMocks()); + +describe('CI report lookup for the exact target commit', () => { + it('skips comparison when the target has no CI workflow or report', async () => { + mockApi([{ status: 404, body: { message: 'Not Found' } }]); + await expect(findBenchmarkArtifact(repository, sha)).resolves.toBeNull(); + }); + + it('selects the report from the matching successful run and attempt', async () => { + const api = mockApi([ + { body: { workflow_runs: [run] } }, + { body: { artifacts: [ + { id: 1, name: `benchmark-${sha}-1`, expired: false }, + { id: 2, name: `benchmark-${sha}-2`, expired: false }, + ] } }, + ]); + await expect(findBenchmarkArtifact(repository, sha)).resolves.toEqual({ artifactId: 2, runId: 12, runAttempt: 2, sourceSha: sha }); + expect(String(api.mock.calls[0]?.[0])).toContain(`head_sha=${sha}`); + }); + + it('skips absent or expired reports without substituting another commit', async () => { + mockApi([ + { body: { workflow_runs: [run, { ...run, head_sha: 'b'.repeat(40) }] } }, + { body: { artifacts: [{ id: 2, name: `benchmark-${sha}-2`, expired: true }] } }, + ]); + await expect(findBenchmarkArtifact(repository, sha)).resolves.toBeNull(); + }); + + it('continues through full run and artifact pages to find the exact target report', async () => { + const pageSize = 100; + const runsWithoutReports = Array.from({ length: pageSize }, (_, index) => ({ ...run, id: 1000 + index })); + const otherArtifacts = Array.from({ length: pageSize }, (_, index) => ({ id: 2000 + index, name: `other-${index}`, expired: false })); + const api = mockApi([ + { body: { workflow_runs: runsWithoutReports } }, + ...runsWithoutReports.map(() => ({ body: { artifacts: [] } })), + { body: { workflow_runs: [run] } }, + { body: { artifacts: otherArtifacts } }, + { body: { artifacts: [{ id: 9, name: `benchmark-${sha}-2`, expired: false }] } }, + ]); + + await expect(findBenchmarkArtifact(repository, sha)).resolves.toEqual({ artifactId: 9, runId: 12, runAttempt: 2, sourceSha: sha }); + const requestedUrls = api.mock.calls.map(([url]) => String(url)); + expect(requestedUrls).toContain(`https://api.github.com/repos/${repository}/actions/workflows/ci.yml/runs?head_sha=${sha}&status=success&per_page=100&page=2`); + expect(requestedUrls).toContain(`https://api.github.com/repos/${repository}/actions/runs/12/artifacts?per_page=100&page=2`); + }); + + it('uses an older successful run of the same SHA when the newest report expired', async () => { + const olderRun = { ...run, id: 11, run_attempt: 1 }; + const api = mockApi([ + { body: { workflow_runs: [run, olderRun] } }, + { body: { artifacts: [{ id: 9, name: `benchmark-${sha}-2`, expired: true }] } }, + { body: { artifacts: [{ id: 8, name: `benchmark-${sha}-1`, expired: false }] } }, + ]); + + await expect(findBenchmarkArtifact(repository, sha)).resolves.toEqual({ artifactId: 8, runId: 11, runAttempt: 1, sourceSha: sha }); + expect(String(api.mock.calls.at(-1)?.[0])).toContain('/actions/runs/11/artifacts?'); + }); + + it('does not hide access errors as a missing baseline', async () => { + mockApi([{ status: 403, body: { message: 'Forbidden' } }]); + await expect(findBenchmarkArtifact(repository, sha)).rejects.toThrow('403'); + }); +}); + +describe('release selects only a tested main build', () => { + it('selects an immutable build artifact by run ID', async () => { + mockApi([ + { body: run }, + { body: { artifacts: [{ id: 9, name: `build-${sha}-2`, expired: false }] } }, + ]); + await expect(findReleaseArtifact(repository, '12')).resolves.toEqual({ artifactId: 9, runId: 12, runAttempt: 2, sourceSha: sha }); + }); + + it('accepts a workflow path qualified with its Git ref', async () => { + mockApi([ + { body: { ...run, path: '.github/workflows/ci.yml@main' } }, + { body: { artifacts: [{ id: 9, name: `build-${sha}-2`, expired: false }] } }, + ]); + await expect(findReleaseArtifact(repository, '12')).resolves.toEqual({ artifactId: 9, runId: 12, runAttempt: 2, sourceSha: sha }); + }); + + it.each([ + { event: 'pull_request' }, { head_branch: 'feature' }, { conclusion: 'failure' }, + { path: '.github/workflows/another.yml' }, { head_repository: { full_name: 'fork/repo' } }, + ])('rejects an untrusted or unsuccessful source: %j', async (change) => { + mockApi([{ body: { ...run, ...change } }]); + await expect(findReleaseArtifact(repository, '12')).rejects.toThrow('successful main CI'); + }); + + it('fails when the tested build expired instead of rebuilding it', async () => { + mockApi([{ body: run }, { body: { artifacts: [] } }]); + await expect(findReleaseArtifact(repository, '12')).rejects.toThrow('artifact is unavailable'); + }); +}); diff --git a/tests/unit/benchmark.test.ts b/tests/unit/benchmark.test.ts index 9279184..f7db364 100644 --- a/tests/unit/benchmark.test.ts +++ b/tests/unit/benchmark.test.ts @@ -1,65 +1,81 @@ -import { execFileSync } from 'node:child_process'; -import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'; +import { mkdtemp, readFile, readdir, rm, 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'; +import { beforeAll, describe, expect, it } from 'vitest'; +import { commandMetrics, replayCommands, type Definition } from '../benchmarks/validation.ts'; +import { compareMetrics, statistics } from '../benchmarks/comparison.ts'; +import { catalogSha256, loadCatalog } from '../benchmarks/catalog.ts'; +import { createReport, loadImplementation, parseReport, publishReport, readReport, runBenchmark, type BenchmarkReport } from '../benchmarks/harness.ts'; +import { assertArtifactOutput, parseBenchmarkArgs } from '../benchmarks/cli.ts'; +import { measureRss, type MemoryMeasurement } from '../benchmarks/memory.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', () => { +const source = { revision: '1'.repeat(40), dirty: false, moduleSha256: '2'.repeat(64) }; +const environment = { node: process.version, v8: process.versions.v8, platform: process.platform, arch: process.arch, + cpu: 'unit test', logicalCpus: 1, osRelease: 'test', totalMemoryBytes: 1024 }; +const settings = { warmups: 0, repetitions: 1, memoryRepetitions: 1 }; +const memory: MemoryMeasurement = { metric: 'linux-proc-VmHWM', limitations: 'Whole-process approximate RSS, including Node; not browser memory.', + browser: { available: false, reason: 'No portable browser peak-memory API.' }, + samples: [{ peakRssBytes: 20, startupRssBytes: 10, loadedRssBytes: 12, lockCount: 45, peakRssMetric: 'linux-proc-VmHWM', startupPeakRssBytes: 10, + loadedPeakRssBytes: 12, resourceUsageMaxRssBytes: 30, startupResourceUsageMaxRssBytes: 30, resourceUsageLaunchFloorDetected: true }], + peakRssBytes: statistics([20]) }; +let fullReport: BenchmarkReport; +beforeAll(async () => { + const solver = await loadImplementation(new URL('../../src/index.ts', import.meta.url).pathname); + const results = runBenchmark({ solve: solver, warmups: 0, repetitions: 1 }); + fullReport = createReport({ source, environment, settings, results, memory }); +}); + +describe('independent benchmark replay', () => { + it('uses outgoing links and checks every intermediate unit shift', () => { 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', () => { + it('rejects invalid commands', () => { 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', () => { + it('reports actions, distinct plates, unit shifts and plate switches', () => { 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'); +describe('current solver measurement', () => { + it('runs all 45 fixed inputs with their unchanged exact minima', () => { + expect(loadCatalog()).toHaveLength(45); + expect(loadCatalog().reduce((sum, lock) => sum + lock.expectedActions, 0)).toBe(483); + expect(fullReport.results).toHaveLength(45); + expect(fullReport.totals.A).toBe(483); + expect(fullReport.quality).toBe('passed'); + expect(fullReport.comparison).toMatchObject({ status: 'skipped', reason: expect.stringMatching(/saved.*report/i) }); }); - 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 measurements while excluding warmups', () => { + let calls = 0; + let clock = 0; + const results = runBenchmark({ locks: [fixture], solve: () => { calls += 1; clock += calls; return commands; }, + warmups: 2, repetitions: 3, clock: () => clock }); + expect(calls).toBe(5); + expect(results[0]?.samplesMs).toEqual([3, 4, 5]); + expect(results[0]?.timingMs).toEqual({ min: 3, median: 4, p95: 5, max: 5 }); + }); + it('checks input immutability, completion and minimum actions during warmups too', () => { + const run = (solve: (input: Definition) => unknown) => runBenchmark({ locks: [fixture], solve, warmups: 1, repetitions: 1 }); + expect(() => run(() => null)).toThrow(/solvability/i); + expect(() => run(() => [])).toThrow(/target/i); + expect(() => run(() => [[0, 2], [0, -1]])).toThrow(/minimum/i); + expect(() => run((input) => { input.state[0] = 4; return commands; })).toThrow(/mutat/i); + }); + it('rejects nondeterministic equal-optimum paths', () => { + const lock = { id: 'choice', state: [3, 3], links: [[0, 1], [1, 0]], expectedActions: 1 }; + let calls = 0; + expect(() => runBenchmark({ locks: [lock], solve: () => [[calls++ % 2, 1]], warmups: 1, repetitions: 1 })).toThrow(/determin/i); }); - it('retains raw sample order and computes nearest-rank p95', () => { + it('computes median and nearest-rank p95 without reordering samples', () => { 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]); @@ -67,272 +83,95 @@ describe('deterministic and environmental verdicts stay separate', () => { }); }); -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'); +describe('saved report comparison', () => { + it('compares stored metrics and historical ratios without executing a baseline', () => { + const baseline = structuredClone(fullReport); + for (const row of baseline.results) { row.samplesMs = row.samplesMs.map((value) => value / 2); row.timingMs = statistics(row.samplesMs); } + const report = createReport({ source, environment, settings, results: fullReport.results, memory, baseline }); + expect(report.quality).toBe('passed'); + expect(report.comparison).toMatchObject({ status: 'compared', baselineSha: source.revision, quality: 'passed', + memoryPeakMedianRatio: 1, limitations: expect.stringMatching(/historical.*informational/i) }); + if (report.comparison.status !== 'compared') throw new Error('Missing comparison'); + expect(report.comparison.results[0]?.timingMedianRatio).toBe(2); + expect(report.comparison.results[0]?.deltas).toEqual({ A: 0, U: 0, C: 0, plateSwitches: 0 }); + expect(parseReport(report)).toEqual(report); + }); + it('fails an increase in every deterministic metric independently', () => { + const baseline = { A: 3, U: 2, C: 8, plateSwitches: 1 }; + for (const name of ['A', 'U', 'C', 'plateSwitches'] as const) { + expect(compareMetrics({ ...baseline, [name]: baseline[name] + 1 }, baseline)).toMatchObject({ quality: 'regression', regressions: [name] }); + } + expect(compareMetrics({ A: 3, U: 1, C: 9, plateSwitches: 0 }, baseline).quality).toBe('regression'); + }); + it('accepts only exact source SHA reports and requires clean source identity', () => { + expect(parseReport(fullReport, source.revision)).toEqual(fullReport); + expect(() => parseReport(fullReport, '3'.repeat(40))).toThrow(/source|revision|SHA/i); + expect(() => parseReport({ ...fullReport, source: { ...source, dirty: true } }, source.revision)).toThrow(/dirty|clean/i); + expect(() => parseReport({ ...fullReport, source: {} })).toThrow(/source|revision/i); + }); + it('rejects corrupt schema, catalog, incomplete rows and fabricated measurements', () => { + const first = fullReport.results[0]; + if (first === undefined) throw new Error('Fixture missing'); + for (const change of [ + { schemaVersion: 1 }, { fixtures: { count: 45, catalogSha256: '0'.repeat(64) } }, + { results: fullReport.results.slice(1) }, { results: [first, ...fullReport.results.slice(0, 44)] }, + { results: [{ ...first, C: first.C + 1 }, ...fullReport.results.slice(1)] }, + { results: [{ ...first, commands: [] }, ...fullReport.results.slice(1)] }, + { results: [{ ...first, timingMs: { ...first.timingMs, median: first.timingMs.median + 1 } }, ...fullReport.results.slice(1)] }, + { totals: { ...fullReport.totals, A: 0 } }, { memory: { ...memory, peakRssBytes: statistics([1]) } }, + { quality: 'regression' }, { comparison: { status: 'skipped' } }, + ]) expect(() => parseReport({ ...fullReport, ...change })).toThrow(); + }); + it('rejects missing and malformed provided report files', async () => { + const root = await mkdtemp(join(tmpdir(), 'benchmark-corrupt-')); 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); + const path = join(root, 'benchmark.json'); + expect(() => readReport(path, source.revision)).toThrow(); + await writeFile(path, '{broken'); + expect(() => readReport(path, source.revision)).toThrow(); } 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('accepts a standalone CLI and requires a target SHA for a provided report', () => { + expect(parseBenchmarkArgs([])).toMatchObject({ warmups: 2, repetitions: 7, memoryRepetitions: 3 }); + expect(parseBenchmarkArgs([]).outputDir).toMatch(/artifacts\/benchmark$/); + expect(parseBenchmarkArgs(['--baseline-sha', source.revision]).baselineReport).toBeUndefined(); + expect(parseBenchmarkArgs(['--baseline-report', '/saved.json', '--baseline-sha', source.revision]).baselineReport).toBe('/saved.json'); + for (const args of [['--baseline-report', '/saved.json'], ['--baseline-sha', 'main'], ['--baseline-module', '/base.mjs'], + ['--baseline-snapshot', '/snapshot.json'], ['--smoke'], ['--skip-memory'], ['--warmups', '-1'], ['--warmups', '01'], + ['--repetitions', '0'], ['--warmups', '1', '--warmups', '2'], ['--unknown']]) { + expect(() => parseBenchmarkArgs(args)).toThrow(); } }); -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-')); +it('publishes exactly JSON and Markdown with full rows and explicit skipped comparison', async () => { + const root = await mkdtemp(join(tmpdir(), 'benchmark-output-')); 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/); + const paths = await publishReport(fullReport, root); + expect((await readdir(root)).sort()).toEqual(['benchmark.json', 'benchmark.md']); + expect(readReport(paths.jsonPath, source.revision)).toEqual(fullReport); + const markdown = await readFile(paths.markdownPath, 'utf8'); + expect(markdown).toMatch(/comparison.*skipped/i); + expect(markdown).toContain('lock-045'); + expect(markdown).toMatch(/actions.*distinct plates.*unit shifts.*switches/i); + expect(markdown).toMatch(/Node.*browser/i); } 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('measures only the requested current solver in an isolated RSS worker', () => { + const ids = loadCatalog().slice(0, 2).map((lock) => lock.id); + const sample = measureRss(new URL('../../src/index.ts', import.meta.url).pathname, ids); + expect(sample.lockCount).toBe(2); + expect(sample.peakRssBytes).toBeGreaterThan(0); + expect(sample.startupRssBytes).toBeGreaterThan(0); + expect(sample.peakRssMetric).toBe(process.platform === 'linux' ? 'linux-proc-VmHWM' : 'node-resourceUsage-maxRSS'); + expect(sample.resourceUsageMaxRssBytes).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 () => { +it('keeps report output in ignored artifacts and retains the fixed catalog hash', () => { 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 }); } + expect(catalogSha256).toBe('902a57e0c867b7ed7513af09257fde765396593ab45738bb60967b412b4bcd69'); }); diff --git a/tests/unit/core-limits.test.ts b/tests/unit/core-limits.test.ts index f949a00..b57d17c 100644 --- a/tests/unit/core-limits.test.ts +++ b/tests/unit/core-limits.test.ts @@ -23,10 +23,10 @@ it('reports exhaustion separately on BFS, A*, singular fallback and greedy certi 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', () => { +it('dense and sparse BFS agree on minimum-cost commands, 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]]; + const expected = [[0, -1], [1, -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)]; diff --git a/tests/unit/core-oracle.test.ts b/tests/unit/core-oracle.test.ts index 4fb7aa1..5737924 100644 --- a/tests/unit/core-oracle.test.ts +++ b/tests/unit/core-oracle.test.ts @@ -6,7 +6,7 @@ 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 { commandCost, referenceCost, replay } from './oracle.ts'; import fixtures from '../benchmarks/fixtures.json' with { type: 'json' }; function position(value: number): Position { @@ -18,15 +18,20 @@ function link(value: number): Link { throw new Error('Invalid fixture link'); } -describe('independent minimum-action oracle', () => { +describe('independent minimum-action then minimum-unit-shift 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]); + const prepared = new PreparedSearch(new LockModel(lock.state, lock.links)); + const expected = referenceCost(lock); + const results = [solveLock(lock.state, lock.links), new BfsSearch(prepared, new SearchBudget()).solve(), + new BfsSearch(prepared, new SearchBudget(createSolverConfig({ maxDenseBytes: 0 }))).solve()]; + for (const result of results) { + expect(commandCost(result), JSON.stringify(lock)).toEqual(expected); + if (result !== null) expect(replay(lock, result)).toEqual([4, 4]); + } checked += 1; } } @@ -42,12 +47,12 @@ describe('independent minimum-action oracle', () => { 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 expected = referenceCost(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); + expect(commandCost(result), JSON.stringify(lock)).toEqual(expected); if (result !== null) expect(replay(lock, result)).toEqual([4, 4, 4]); } } @@ -55,6 +60,19 @@ describe('independent minimum-action oracle', () => { }); describe('fixed mathematical expectations', () => { + it.each([ + { id: 'lock-011', actions: 9, unitShifts: 37 }, + { id: 'lock-018', actions: 14, unitShifts: 45 }, + { id: 'lock-019', actions: 14, unitShifts: 41 }, + ])('$id: minimizes unit shifts among minimum-action solutions', ({ id, actions, unitShifts }) => { + const entry = fixtures.find((fixture) => fixture.id === id); + if (entry === undefined) throw new Error('Missing regression fixture'); + const lock = { state: entry.state.map(position), links: entry.links.map((row) => row.map(link)) }; + const result = solveLock(lock.state, lock.links); + expect(commandCost(result)).toEqual({ actions, unitShifts }); + if (result === null) throw new Error('Expected catalog solution'); + expect(replay(lock, result)).toEqual(lock.state.map(() => 4)); + }); 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))); diff --git a/tests/unit/core-priority-queue.test.ts b/tests/unit/core-priority-queue.test.ts new file mode 100644 index 0000000..e5dcf41 --- /dev/null +++ b/tests/unit/core-priority-queue.test.ts @@ -0,0 +1,37 @@ +import { expect, it } from 'vitest'; +import { IndexedHeap, NOT_QUEUED } from '../../src/priority-queue.ts'; +import type { SearchNode } from '../../src/priority-queue.ts'; + +function node(state: number, cost: readonly [actions: number, units: number], + estimate: readonly [actions: number, units: number]): SearchNode { + return { + state, actionCount: cost[0], unitShifts: cost[1], estimate: estimate[0], shiftEstimate: estimate[1], + remainingShifts: [], previous: null, plate: -1, delta: 0, sequence: state, + heapIndex: NOT_QUEUED, closed: false, + }; +} + +it('orders total actions before total units, with remaining actions only breaking cost ties', () => { + const frontier = new IndexedHeap(); + const fewerActions = node(0, [1, 100], [0, 0]); + const fewerUnits = node(1, [1, 3], [1, 1]); + const moreUnits = node(2, [2, 5], [0, 0]); + const equalCostCloser = node(3, [2, 4], [0, 0]); + for (const entry of [moreUnits, fewerUnits, equalCostCloser, fewerActions]) frontier.push(entry); + expect([frontier.pop(), frontier.pop(), frontier.pop(), frontier.pop()]) + .toEqual([fewerActions, equalCostCloser, fewerUnits, moreUnits]); +}); + +it('decreases equal-action unit cost in place without stale queue entries', () => { + const frontier = new IndexedHeap(); + const first = node(0, [2, 7], [0, 0]); + const improved = node(1, [1, 8], [1, 2]); + frontier.push(first); + frontier.push(improved); + improved.unitShifts = 3; + frontier.decreasePriority(improved); + expect(frontier.size).toBe(2); + expect(frontier.pop()).toBe(improved); + expect(frontier.pop()).toBe(first); + expect(frontier.size).toBe(0); +}); diff --git a/tests/unit/oracle.ts b/tests/unit/oracle.ts index 88c215c..3c65e81 100644 --- a/tests/unit/oracle.ts +++ b/tests/unit/oracle.ts @@ -31,11 +31,33 @@ export function replay(lock: OracleLock, commands: readonly (readonly [number, n 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; +export interface OracleCost { + readonly actions: number; + readonly unitShifts: number; +} + +export function commandCost(commands: readonly (readonly [number, number])[] | null): OracleCost | null { + return commands === null ? null : { + actions: commands.length, + unitShifts: commands.reduce((sum, [, delta]) => sum + Math.abs(delta), 0), + }; +} + +function compareCost(left: OracleCost, right: OracleCost): number { + return left.actions - right.actions || left.unitShifts - right.unitShifts; +} + +/** Deliberately simple Dijkstra: string keys, sorted queue, unit-by-unit edges. */ +export function referenceCost(lock: OracleLock): OracleCost | null { + const root = { actions: 0, unitShifts: 0 }; + const queue = [{ state: [...lock.state], cost: root }]; + const best = new Map([[lock.state.join(','), root]]); + while (queue.length > 0) { + queue.sort((left, right) => compareCost(left.cost, right.cost)); + const current = queue.shift(); + if (current === undefined) throw new Error('Missing oracle frontier state'); + if (best.get(current.state.join(',')) !== current.cost) continue; + if (current.state.every((position) => position === 4)) return current.cost; for (let source = 0; source < current.state.length; source += 1) { for (const direction of [1, -1]) { let next = current.state; @@ -44,12 +66,18 @@ export function referenceActions(lock: OracleLock): number | null { 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 }); + const cost = { actions: current.cost.actions + 1, unitShifts: current.cost.unitShifts + steps }; + const previous = best.get(key); + if (previous !== undefined && compareCost(previous, cost) <= 0) continue; + best.set(key, cost); + queue.push({ state: next, cost }); } } } } return null; } + +export function referenceActions(lock: OracleLock): number | null { + return referenceCost(lock)?.actions ?? null; +} diff --git a/tests/unit/release-files.test.ts b/tests/unit/release-files.test.ts index 71f584d..0343ba2 100644 --- a/tests/unit/release-files.test.ts +++ b/tests/unit/release-files.test.ts @@ -1,103 +1,152 @@ import { execFileSync } from 'node:child_process'; -import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { createHash } from 'node:crypto'; +import { copyFile, mkdir, mkdtemp, 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'; +import { beforeAll, describe, expect, it } from 'vitest'; +import { prepareArchive, prepareRelease, checkRelease } from '../../.github/scripts/release.ts'; +import { createReport, loadImplementation, runBenchmark, type BenchmarkReport } from '../benchmarks/harness.ts'; +import { statistics } from '../benchmarks/comparison.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 }; -} +const sourceSha = 'a'.repeat(40); +const canary = '1.2.3-canary.123.2.aaaaaaaaaaaa'; +const names = ['gothic-lock-solver.mjs', 'gothic-lock-solver.min.mjs', 'gothic-lock-solver.js', 'gothic-lock-solver.min.js', 'gothic-lock-solver.cli.mjs', 'index.d.ts']; +const bytes = 'export const tested = true;\n'; +const digest = createHash('sha256').update(bytes).digest('hex'); +let report: BenchmarkReport; +beforeAll(async () => { + const solve = await loadImplementation(new URL('../../src/index.ts', import.meta.url).pathname); + report = createReport({ source: { revision: sourceSha, dirty: false, moduleSha256: digest }, + environment: { node: process.version, v8: process.versions.v8, platform: process.platform, arch: process.arch, cpu: 'test', logicalCpus: 1, osRelease: 'test', totalMemoryBytes: 1024 }, + settings: { warmups: 0, repetitions: 1, memoryRepetitions: 1 }, results: runBenchmark({ solve, warmups: 0, repetitions: 1 }), + memory: { metric: 'linux-proc-VmHWM', limitations: 'Whole process RSS', browser: { available: false, reason: 'Unavailable' }, + samples: [{ peakRssBytes: 20, startupRssBytes: 10, loadedRssBytes: 12, lockCount: 45, peakRssMetric: 'linux-proc-VmHWM', startupPeakRssBytes: 10, + loadedPeakRssBytes: 12, resourceUsageMaxRssBytes: 30, startupResourceUsageMaxRssBytes: 30, resourceUsageLaunchFloorDetected: true }], peakRssBytes: statistics([20]) } }); +}); -type PreparedFixture = Awaited>; -type EvidenceData = PreparedFixture['evidenceData']; +async function fixture(options: { version?: string; brokenHash?: boolean } = {}) { + const root = await mkdtemp(join(tmpdir(), 'gothic-release-')); + const packed = join(root, 'package'); + await mkdir(join(packed, 'dist'), { recursive: true }); + await writeFile(join(packed, 'package.json'), JSON.stringify({ name: 'gothic-lock-solver', version: options.version ?? '1.2.3', files: ['dist'], + scripts: { prepack: 'exit 91', prepare: 'exit 92', postpack: 'exit 93' } })); + await writeFile(join(packed, 'README.md'), 'Tested package documentation\n'); + await writeFile(join(packed, 'LICENSE'), 'Tested license\n'); + for (const name of names) await writeFile(join(packed, 'dist', name), bytes); + await writeFile(join(packed, 'dist/manifest.json'), JSON.stringify({ schemaVersion: 1, package: 'gothic-lock-solver', version: options.version ?? '1.2.3', sourceSha, + files: Object.fromEntries(names.map(name => [name, options.brokenHash ? '0'.repeat(64) : digest])) })); + const input = join(root, 'tested.tgz'); + execFileSync('tar', ['-czf', input, '-C', root, 'package']); + await copyFile(input, join(root, 'package.tgz')); + await writeFile(join(root, 'benchmark.json'), JSON.stringify(report)); + await writeFile(join(root, 'benchmark.md'), '# All 45 measured locks\n'); + return { root, input, output: join(root, 'prepared.tgz'), runId: '123', runAttempt: '2' }; +} -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; +function archiveFile(path: string, name: string): Buffer { + return execFileSync('tar', ['-xOzf', path, `package/${name}`]); } -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; +describe('publish the tested archive', () => { + it('assigns a canary while preserving the tested runtime bytes', async () => { + const files = await fixture(); + try { + const result = await prepareArchive({ ...files, sourceSha }); + expect(result.version).toBe(canary); + for (const name of names) expect(archiveFile(files.output, `dist/${name}`)).toEqual(archiveFile(files.input, `dist/${name}`)); + } finally { await rm(files.root, { recursive: true, force: true }); } + }); + + it('promotes by repacking only version metadata, keeping every runtime and declaration byte', async () => { + const files = await fixture(); + try { + const result = await prepareArchive({ ...files, sourceSha, version: '2.0.0' }); + expect(result.version).toBe('2.0.0'); + for (const name of [...names.map(name => `dist/${name}`), 'README.md', 'LICENSE']) { + expect(archiveFile(files.output, name)).toEqual(archiveFile(files.input, name)); + } + for (const name of ['package.json', 'dist/manifest.json']) { + const before: unknown = JSON.parse(archiveFile(files.input, name).toString('utf8')); + expect(JSON.parse(archiveFile(files.output, name).toString('utf8'))).toEqual({ ...Object(before), version: '2.0.0' }); + } + } finally { await rm(files.root, { recursive: true, force: true }); } + }); + + it('refuses a valid archive belonging to another source SHA', async () => { + const files = await fixture(); + try { await expect(prepareArchive({ ...files, sourceSha: 'b'.repeat(40) })).rejects.toThrow(/source/i); } + finally { await rm(files.root, { recursive: true, force: true }); } + }); + + it('refuses altered runtime bytes even with unchanged metadata', async () => { + const files = await fixture({ brokenHash: true }); + try { await expect(prepareArchive({ ...files, sourceSha })).rejects.toThrow(/hash|identity/i); } + finally { await rm(files.root, { recursive: true, force: true }); } + }); + + it.each(['1.02.3', '2.0.0-beta', 'latest'])('rejects nonstable promotion version %s', async version => { + const files = await fixture(); + try { await expect(prepareArchive({ ...files, sourceSha, version })).rejects.toThrow(/version/i); } + finally { await rm(files.root, { recursive: true, force: true }); } + }); +}); + +describe('release evidence gate', () => { + it('accepts a validated skipped comparison and preserves both original reports during stable promotion', async () => { + const files = await fixture(); + const output = join(files.root, 'release'); 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 }); } + await prepareRelease({ input: files.root, output, sourceSha, version: '2.0.0' }); + expect(await readFile(join(output, 'benchmark.json'))).toEqual(await readFile(join(files.root, 'benchmark.json'))); + expect(await readFile(join(output, 'benchmark.md'))).toEqual(await readFile(join(files.root, 'benchmark.md'))); + expect(await checkRelease(output, sourceSha)).toMatchObject({ kind: 'stable', version: '2.0.0', sourceSha }); + const notes = await readFile(join(output, 'release-notes.md'), 'utf8'); + expect(notes).toContain('Benchmark quality: **passed**; 45 locks.'); + expect(notes).toContain(`A=${report.totals.A}`); + expect(notes).toContain(`C=${report.totals.C}`); + expect(notes).toMatch(/Comparison: skipped.*exact target SHA/); + await writeFile(join(output, 'gothic-lock-solver.js'), 'tampered'); + await expect(checkRelease(output, sourceSha)).rejects.toThrow(/identity|hash/i); + } finally { await rm(files.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(); + it('states the compared target SHA and measured deterministic drift in the release body', async () => { + const files = await fixture(); + const output = join(files.root, 'release'); try { - await replaceEvidence(fixture, change(fixture.evidenceData)); - await expect(verifyPreparedRelease(fixture.output)).rejects.toThrow(error); - } finally { await rm(fixture.root, { recursive: true, force: true }); } + const baseline = structuredClone(report); + baseline.source.revision = 'b'.repeat(40); + const row = baseline.results[0]; + if (row === undefined) throw new Error('Missing first fixture'); + row.C += 2; + await writeFile(join(files.root, 'benchmark.json'), JSON.stringify(createReport({ ...report, baseline }))); + await prepareRelease({ input: files.root, output, sourceSha, version: '2.0.0' }); + const notes = await readFile(join(output, 'release-notes.md'), 'utf8'); + expect(notes).toContain(`Comparison: compared with target SHA \`${'b'.repeat(40)}\`.`); + expect(notes).toContain('Benchmark quality: **passed**; 45 locks.'); + expect(notes).toContain('ΔA=0'); + expect(notes).toContain('ΔC=-2'); + } finally { await rm(files.root, { recursive: true, force: true }); } }); - it('requires acceptance supplied by the verifier, ignoring an artifact acceptance claim', async () => { - const fixture = await createPreparedFixture(); + it('rejects a report for another measured module', async () => { + const files = await fixture(); 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 }); } + await writeFile(join(files.root, 'benchmark.json'), JSON.stringify({ ...report, source: { ...report.source, moduleSha256: '0'.repeat(64) } })); + await expect(prepareRelease({ input: files.root, output: join(files.root, 'release'), sourceSha, runId: '123', runAttempt: '2' })).rejects.toThrow(/module/i); + } finally { await rm(files.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(); + it('rejects deterministic quality regressions without an acceptance bypass', async () => { + const files = await fixture(); 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 }); } + const baseline = structuredClone(report); + const row = baseline.results[0]; + if (row === undefined) throw new Error('Missing first fixture'); + row.C -= 1; + const regression = createReport({ ...report, baseline }); + await writeFile(join(files.root, 'benchmark.json'), JSON.stringify(regression)); + await expect(prepareRelease({ input: files.root, output: join(files.root, 'release'), sourceSha, runId: '123', runAttempt: '2' })).rejects.toThrow(/quality/i); + } finally { await rm(files.root, { recursive: true, force: true }); } }); }); diff --git a/tests/unit/release-network.test.ts b/tests/unit/release-network.test.ts index e923d66..b0d2248 100644 --- a/tests/unit/release-network.test.ts +++ b/tests/unit/release-network.test.ts @@ -1,172 +1,165 @@ -import { readFileSync, writeFileSync } from 'node:fs'; +import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'; 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'; +import { publishNpm, publishGithub, verifyCdn, registryMetadata, sha256, runtimeFiles, type ReleaseManifest } from '../../.github/scripts/release.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' } }); - +const archive = 'exact prepared archive'; +const manifest: ReleaseManifest = { schemaVersion: 1, version: '1.2.3', sourceSha: 'a'.repeat(40), kind: 'stable', + assets: { 'package.tgz': sha256(archive), ...Object.fromEntries(runtimeFiles.map(name => [name, sha256(name)])) } }; +const metadata = { name: 'gothic-lock-solver', version: manifest.version, dist: { tarball: 'https://registry.npmjs.org/gothic-lock-solver/-/gothic-lock-solver-1.2.3.tgz' } }; +const response = (value: unknown) => new Response(JSON.stringify(value), { 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; -} +afterEach(() => { vi.unstubAllGlobals(); vi.unstubAllEnvs(); }); -describe('npm and CDN delivery boundaries', () => { - it('distinguishes an absent version from a registry outage', async () => { +describe('immutable npm and public CDN delivery', () => { + it('distinguishes a missing npm version from a registry outage', async () => { fetchMock.mockResolvedValueOnce(new Response('', { status: 404 })); - expect(await registryMetadata('1.0.0')).toBeNull(); + expect(await registryMetadata('1.2.3')).toBeNull(); fetchMock.mockResolvedValueOnce(new Response('', { status: 503 })); - await expect(registryMetadata('1.0.0')).rejects.toThrow(/503/); + await expect(registryMetadata('1.2.3')).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 prepared archive with the chosen dist-tag and verifies registry bytes', async () => { + fetchMock.mockResolvedValueOnce(new Response('', { status: 404 })).mockResolvedValueOnce(response(metadata)).mockResolvedValueOnce(new Response(archive)); + await publishNpm('/prepared', manifest); + expect(command).toHaveBeenCalledWith('npm', ['publish', '/prepared/package.tgz', '--access', 'public', '--tag', 'latest', '--ignore-scripts', '--registry', 'https://registry.npmjs.org/'], expect.objectContaining({ stdio: 'inherit' })); }); - - 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 an identical npm version without overwriting it', async () => { + fetchMock.mockResolvedValueOnce(response(metadata)).mockResolvedValueOnce(new Response(archive)); + await publishNpm('/prepared', manifest); + expect(command).not.toHaveBeenCalled(); }); - - 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('refuses different bytes under an existing npm version', async () => { + fetchMock.mockResolvedValueOnce(response(metadata)).mockResolvedValueOnce(new Response('changed')); + await expect(publishNpm('/prepared', manifest)).rejects.toThrow(/identity/i); + expect(command).not.toHaveBeenCalled(); + }); + it('checks all five public CDN files against the tested bytes and browser delivery headers', async () => { + fetchMock.mockImplementation(async input => { + const filename = String(input).split('/').at(-1) ?? ''; + return new Response(filename, { headers: { 'content-type': 'application/javascript', 'access-control-allow-origin': '*' } }); + }); + await verifyCdn(manifest); + expect(fetchMock).toHaveBeenCalledTimes(5); }); - 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); + { type: 'text/plain', cors: '*', bytes: 'gothic-lock-solver.mjs', error: /headers/ }, + { type: 'application/javascript', cors: 'https://elsewhere.test', bytes: 'gothic-lock-solver.mjs', error: /headers/ }, + { type: 'application/javascript', cors: '*', bytes: 'changed', error: /identity/ }, + ])('refuses bad CDN headers or bytes: $type/$cors/$bytes', async value => { + fetchMock.mockResolvedValue(new Response(value.bytes, { headers: { 'content-type': value.type, 'access-control-allow-origin': value.cors } })); + await expect(verifyCdn(manifest)).rejects.toThrow(value.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(); +describe('GitHub release identity', () => { + it.each(['canary', 'stable'] as const)('attaches the verified assets and changes latest only for %s', async kind => { + const directory = await mkdtemp(join(tmpdir(), 'gothic-github-')); 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); + vi.stubEnv('GH_TOKEN', 'unit-test-token'); + const chosen = { ...manifest, kind }; + const bytes = JSON.stringify(chosen); + await writeFile(join(directory, 'release-manifest.json'), bytes); + const assets = { ...chosen.assets, 'release-manifest.json': sha256(bytes) }; + const release = { draft: false, prerelease: kind === 'canary', assets: Object.entries(assets).map(([name, hash]) => ({ name, digest: `sha256:${hash}` })) }; + let created = false; + fetchMock.mockImplementation(async input => { + if (String(input).includes('/commits/')) return response({ sha: chosen.sourceSha }); + return created ? response(release) : new Response('', { status: 404 }); + }); + command.mockImplementation((file, args) => { + expect(file).toBe('gh'); + if (args[0] === 'api' && args[1] === 'graphql') { + expect(args).toContain('owner=owner'); + expect(args).toContain('name=repo'); + expect(args).toContain('tag=v1.2.3'); + return JSON.stringify(created ? 42 : null); + } + expect(args).toContain('--verify-tag'); + expect(args).toContain(`--latest=${kind === 'stable'}`); + expect(args.includes('--prerelease')).toBe(kind === 'canary'); + for (const name of Object.keys(assets)) expect(args).toContain(join(directory, name)); + created = true; + return ''; + }); + await publishGithub(directory, chosen, 'owner/repo'); + expect(created).toBe(true); + command.mockClear(); + await publishGithub(directory, chosen, 'owner/repo'); + expect(command.mock.calls.every(([, args]) => args[0] === 'api' && args[1] === 'graphql')).toBe(true); } 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('refuses an existing tag for another source before creating a release', async () => { + vi.stubEnv('GH_TOKEN', 'unit-test-token'); + fetchMock.mockImplementation(async input => String(input).includes('/commits/') ? response({ sha: 'b'.repeat(40) }) : new Response('', { status: 404 })); + await expect(publishGithub('/prepared', manifest, 'owner/repo')).rejects.toThrow(/source/i); + expect(command).not.toHaveBeenCalled(); }); - it('finishes an older draft while preserving the newer stable latest release', async () => { - const directory = await releaseDirectory(); + it.each(['canary', 'stable'] as const)('finishes a matching partial %s draft without overwriting uploaded assets', async kind => { + const directory = await mkdtemp(join(tmpdir(), 'gothic-draft-')); try { - mockGithub(directory, { draft: true, newer: true }); - await completeGithubRelease(directory, manifest, 'owner/repo'); - expect(command.mock.calls.at(-1)?.[1]).toContain('--latest=false'); + vi.stubEnv('GH_TOKEN', 'unit-test-token'); + const chosen = { ...manifest, kind }; + const bytes = JSON.stringify(chosen); + await writeFile(join(directory, 'release-manifest.json'), bytes); + const hashes = { ...chosen.assets, 'release-manifest.json': sha256(bytes) }; + const complete = Object.entries(hashes).map(([name, hash]) => ({ name, digest: `sha256:${hash}` })); + const missing = 'gothic-lock-solver.js'; + const release = { draft: true, prerelease: kind === 'canary', assets: complete.filter(asset => asset.name !== missing) }; + fetchMock.mockImplementation(async input => { + const url = String(input); + if (url.includes('/commits/')) return response({ sha: chosen.sourceSha }); + // The tag endpoint exposes published releases; drafts are read by ID. + if (url.includes('/releases/tags/')) return new Response('', { status: 404 }); + expect(url).toBe('https://api.github.com/repos/owner/repo/releases/42'); + return response(release); + }); + command.mockImplementation((file, args) => { + expect(file).toBe('gh'); + if (args[0] === 'api' && args[1] === 'graphql') return '42'; + if (args[1] === 'upload') { + expect(args).toEqual(['release', 'upload', 'v1.2.3', join(directory, missing), '--repo', 'owner/repo']); + release.assets = complete; + return ''; + } + expect(args).toEqual(['release', 'edit', 'v1.2.3', '--repo', 'owner/repo', '--draft=false', `--latest=${kind === 'stable'}`]); + expect(release.assets).toEqual(complete); + release.draft = false; + return ''; + }); + await publishGithub(directory, chosen, 'owner/repo'); + expect(release.draft).toBe(false); + expect(release.assets).toEqual(complete); } 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(); + it.each(['mismatched digest', 'unexpected name'])('refuses a draft with an %s before any upload or publication', async defect => { + const directory = await mkdtemp(join(tmpdir(), 'gothic-draft-')); 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'); + vi.stubEnv('GH_TOKEN', 'unit-test-token'); + await writeFile(join(directory, 'release-manifest.json'), JSON.stringify(manifest)); + const asset = defect === 'mismatched digest' + ? { name: 'package.tgz', digest: `sha256:${'0'.repeat(64)}` } + : { name: 'unexpected.js', digest: `sha256:${sha256('unexpected')}` }; + const release = { draft: true, prerelease: false, assets: [asset] }; + fetchMock.mockImplementation(async input => { + const url = String(input); + if (url.includes('/commits/')) return response({ sha: manifest.sourceSha }); + if (url.includes('/releases/tags/')) return new Response('', { status: 404 }); + return response(release); + }); + command.mockImplementation((file, args) => { + expect(file).toBe('gh'); + expect(args.slice(0, 2)).toEqual(['api', 'graphql']); + return '42'; + }); + await expect(publishGithub(directory, manifest, 'owner/repo')).rejects.toThrow(/identity|Unexpected/); + expect(command.mock.calls.every(([, args]) => args[0] === 'api' && args[1] === 'graphql')).toBe(true); } finally { await rm(directory, { recursive: true, force: true }); } }); }); diff --git a/tests/unit/release.test.ts b/tests/unit/release.test.ts index 96016c4..bc0a4fd 100644 --- a/tests/unit/release.test.ts +++ b/tests/unit/release.test.ts @@ -1,66 +1,16 @@ 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(); +import { canaryVersion } from '../../.github/scripts/release.ts'; + +describe('release version identity', () => { + it('derives unique canaries from the successful CI run without modifying source metadata', () => { + expect(canaryVersion('1.2.3', 'a'.repeat(40), '123', '2')).toBe('1.2.3-canary.123.2.aaaaaaaaaaaa'); + expect(canaryVersion('1.2.3', 'a'.repeat(40), '123', '3')).toBe('1.2.3-canary.123.3.aaaaaaaaaaaa'); + }); + it('rejects malformed source, run and base version identities', () => { + for (const [version, sha, run, attempt] of [['1.2.3', 'wrong', '123', '2'], ['1.2.3', 'a'.repeat(40), '0', '2'], + ['1.2.3', 'a'.repeat(40), '123', '01'], ['1.2.3-beta', 'a'.repeat(40), '123', '2']]) { + if (!version || !sha || !run || !attempt) throw new Error('Missing test input'); + expect(() => canaryVersion(version, sha, run, attempt)).toThrow(/identity|version/i); } }); - - 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/wiki/algorithm.md b/wiki/algorithm.md index 4418f70..f34aa46 100644 --- a/wiki/algorithm.md +++ b/wiki/algorithm.md @@ -18,8 +18,9 @@ in its entirety. | 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 +A successfully returned solution minimizes the pair (A, C) lexicographically: +first grouped actions, then unit shifts among paths with that minimum action count. +Minimum U 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 @@ -67,31 +68,42 @@ Three nonzero balance components do not guarantee a three-action solution. consistency without floating-point tolerances. An inconsistent system proves unreachability. For a nonsingular matrix, a unique noninteger balance also proves that no path exists. -2. A consistent singular system uses exact BFS over legal grouped actions. +2. A consistent singular system uses layered BFS over legal grouped actions. A particular rational solution cannot exclude other integer solutions. 3. For a unique integer balance, a sufficient certificate is attempted first. If every `abs(z_i) <= 6` and each nonzero component can be executed as a single - legal action, the result reaches the lower bound and is globally optimal. + legal action, the result reaches both lower bounds: one action per nonzero + component and exactly `sum(abs(z_i))` unit shifts. It is globally optimal. The first currently legal plate by index is selected. Failure of this greedy attempt proves nothing: the complete A* search then starts from the initial state. -4. A* maintains the residual balance `r = M⁻¹(g−x)` and uses +4. A* maintains the residual balance `r = M⁻¹(g−x)` and uses two lower bounds: ```math -h(x) = \sum_i \left\lceil |r_i|/6 \right\rceil. +h_A(x) = \sum_i \left\lceil |r_i|/6 \right\rceil, +\qquad h_C(x) = \sum_i |r_i|. ``` -One action changes only one residual component, by at most six. That component -requires at least `ceil(abs(r_i)/6)` actions, so h is admissible. It is also -consistent: `h(x) <= 1 + h(x')` on every edge. Consequently the first goal removed -from the priority queue has minimum action count, and closed states need not be -reopened. An indexed heap decreases priorities in place, without retaining stale -copies of open nodes. +One action changes only one residual component by its signed displacement k. +That component needs at least `ceil(abs(r_i)/6)` actions and `abs(r_i)` unit +shifts. Both bounds are admissible and consistent on every legal edge: +`h_A(x) <= 1 + h_A(x')` and `h_C(x) <= abs(k) + h_C(x')`. -BFS also has unit-cost edges and therefore returns the same minimum A. Its -neighbor order is ascending plate index, positive then negative displacement, -and larger displacement first. A* has deterministic ordering but may choose a -different equal-A path. Only one `solveLock` facade is public; callers do not -select a search implementation. +A* orders its frontier by `(g_A + h_A, g_C + h_C)`. The first popped goal therefore +minimizes (A, C), and closed states need not reopen. Revisiting an open state can +improve either its action count or its unit-shift count at equal action depth; +changing only the heap tie-breaker would miss the latter improvement. An indexed +heap decreases priorities in place without retaining stale copies. + +BFS processes complete action-depth layers. At equal depth, a state retains the +predecessor with fewer unit shifts. It completes the whole predecessor layer +before returning a reached goal, so all minimum-action alternatives contribute. +Every state is queued and expanded at most once. A one-command goal can return +immediately: every such path has the same shift magnitude, equal to the largest +absolute difference between the initial and target positions. + +Ordering is deterministic after these costs: ascending plate index and larger +legal displacement first; A* prefers the remaining displacement's direction. +Only one `solveLock` facade is public; callers do not select a search algorithm. ## Complexity @@ -112,6 +124,8 @@ O(N³) rational operations is not O(N³) bit operations: exact arithmetic costs increase with numerator/denominator bit length. Search bounds use the usual unit cost model for safe JS numbers and expected O(1) Map operations. +The secondary objective can require more exploration than stopping at the first +minimum-action path. A custom resource budget may therefore need adjustment. The matrix heuristic reduces search on the benchmark corpus; the exponential worst case remains. There is no universal tens-of-milliseconds guarantee. @@ -142,14 +156,15 @@ WebKit tests exercise the unchanged distributed files and Worker imports. ## Evidence and scope Catalog tests independently replay all 45 paths and verify minimum action counts. -A separate exhaustive oracle checks all 441 two-plate configurations and 192 -seeded three-plate configurations. Counterexamples cover fractional balances, +Regression cases also check secondary unit-shift optima. A separate exhaustive +oracle checks both costs on all 441 two-plate configurations and 192 seeded +three-plate configurations. Counterexamples cover fractional balances, inconsistency, singularity, blocked ordering, boundaries and repeated plate use. These tests establish implementation behavior on their inputs. The general -minimum-A guarantee follows from the search proof, not the number of tests. -The [benchmark harness](benchmarks.md) compares the candidate with its own -actual target revision or previous release in one runtime, with separate timing -and process-memory measurements. CI artifacts identify +minimum-(A, C) guarantee follows from the search proof, not the number of tests. +The [benchmark harness](benchmarks.md) records current timing and process-memory +measurements. When available, it compares a saved CI report for the exact target +SHA; historical timing ratios are observations across runs. CI artifacts identify the exact sources, machine and runtime; Node measurements are not measurements of a phone or browser. diff --git a/wiki/api.md b/wiki/api.md index 55775be..0329bfb 100644 --- a/wiki/api.md +++ b/wiki/api.md @@ -56,7 +56,8 @@ Zero displacement is not a command. A command costs one action irrespective of 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. +across versions. The guarantees are legal completion, minimum grouped actions, +and then minimum unit shifts among solutions with that action count. `LockInputError` signals malformed lock input or configuration. `SearchLimitError` exposes a documented `limit` discriminator and `maximum`; it signals unavailable computation, never @@ -150,5 +151,5 @@ 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 [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 index ebf4d9b..dc12977 100644 --- a/wiki/benchmarks.md +++ b/wiki/benchmarks.md @@ -1,84 +1,56 @@ -# Reproducible benchmarks +# 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: +The catalog contains 45 fixed numerical locks. Every run independently replays +solutions, verifies the known minimum action counts, and measures the built module. ```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 +pnpm 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. +Output is `artifacts/benchmark/benchmark.json` and `benchmark.md`. These files are +CI artifacts, never committed. JSON contains each solution, raw timings, memory +samples, environment information, source SHA and module/catalog hashes. -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. +## Optional comparison with a saved report -- `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. +```sh +pnpm benchmark --baseline-report /path/to/benchmark.json --baseline-sha TARGET_SHA +``` -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). +CI obtains `TARGET_SHA` from the PR target commit or the previous main commit. +It looks for that exact commit's successful CI report artifact. If the artifact +is absent or expired, the current benchmark still runs and explicitly records +`comparison: skipped`. CI does not check out the target, rebuild it, execute its +code or substitute an npm/GitHub release. API access failures and malformed reports +are errors, not missing baselines. -## Storage +A supplied report must match its claimed SHA, current schema and fixed catalog. +Its commands are replayed and its recorded deterministic metrics checked before +comparison. Artifacts include the CI attempt in their names so reruns do not +silently replace an earlier report. -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. +## Metrics and limits -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. +| Metric | Meaning | +| --- | --- | +| Actions | Number of grouped commands; one command can shift several positions | +| Distinct plates | Number of different directly selected plates per lock | +| Unit shifts | Sum of absolute command displacements | +| Switches | Changes between consecutive selected plates | +| Median / p95 | Solver-call latency, excluding input cloning and replay | +| Peak RSS | Whole-catalog Node process memory, including runtime and imported module | + +When a baseline exists, deterministic quality is compared per lock. The current +policy reports a regression if any action, distinct-plate, unit-shift or switch +count increases. Aggregate improvements do not hide a per-lock increase. + +Saved timings and RSS come from different runs. Ratios are informative observations; +different hosts, runtimes, loads and measurement noise prevent treating them as a +paired performance experiment. They do not produce a statistical pass/fail gate. +RSS is not solver-only allocated memory and does not measure browser heap usage. + +Defaults are two warmups, seven measured solves per lock and three fresh Node +process memory samples. `--warmups`, `--repetitions`, `--memory-repetitions` and +`--output-dir` allow local investigation. Run measurements after other heavy tasks +finish. Keep raw observations and environment details with any performance claim. diff --git a/wiki/configuration.md b/wiki/configuration.md index 2304567..aadfc87 100644 --- a/wiki/configuration.md +++ b/wiki/configuration.md @@ -31,7 +31,7 @@ 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. +minimum action count and, among equal-action paths, minimum unit shifts. Positions 1–7, goal 4 and direct links are fixed domain rules. ## CLI diff --git a/wiki/development.md b/wiki/development.md index 87d5cdf..4c9c8b4 100644 --- a/wiki/development.md +++ b/wiki/development.md @@ -52,11 +52,12 @@ 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 +Builds, archives, 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. +and check hosted CI. Saved benchmark timing and memory ratios are historical +observations, not paired performance guarantees. Official references: [TypeScript](https://www.typescriptlang.org/docs/handbook/intro.html), [Vite configuration](https://vite.dev/config/), [Vitest configuration](https://vitest.dev/config/), diff --git a/wiki/releasing.md b/wiki/releasing.md index d52a8a2..d407481 100644 --- a/wiki/releasing.md +++ b/wiki/releasing.md @@ -1,154 +1,83 @@ -# Packaging and releases +# CI 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. +Two workflows, one job each: -## Before the first publication +| Workflow | Trigger | Result | +| --- | --- | --- | +| `ci.yml` | Pull request or push to main | Checks, five builds, tested package, benchmark report | +| `release.yml` | Successful main-push CI | Automatic npm canary and GitHub prerelease | +| `release.yml` | Manual dispatch | Stable version from a selected successful main CI run | -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. +CI runs the local commands. It downloads a benchmark baseline only when a report +exists for the exact target SHA; otherwise comparison is skipped. See +[benchmarks](benchmarks.md). All build output, reports, coverage and browser +diagnostics remain CI artifacts. -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. +## First publication setup -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. +Establish the public `gothic-lock-solver` npm package and configure a GitHub trusted +publisher for this repository, `release.yml` and the `npm-production` environment. +Enable direct `npm publish` permission in the publisher settings. Follow the +[official npm instructions](https://docs.npmjs.com/trusted-publishers/). +Then set the repository variable `NPM_PUBLICATION_READY=true`. -## One workflow, three jobs +The workflow uses OIDC rather than a stored npm token. GitHub Actions and the npm +and GitHub CLIs are official, pinned tools. Public npm, standard public GitHub +runners and jsDelivr require no paid subscription for this setup. -`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. +## Automatic canaries -| 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 -``` +A successful CI run for a push to main triggers the release workflow. The release +job validates the originating repository, workflow, branch, event and run result, +then downloads that run's build artifact by ID. PR artifacts cannot be published. + +Preparation assigns a unique version such as +`1.0.0-canary.RUN_ID.ATTEMPT.SOURCE_SHA_PREFIX`. It changes only the package version +and the build manifest's version, then packs with lifecycle scripts disabled. +Every other archive entry must remain byte-identical to the tested package. +The five runtime files are not rebuilt. + +The package is published under npm's `canary` tag and attached to a GitHub +prerelease. Stable `latest` is unchanged. The full JSON/Markdown benchmark report, +source identity and file hashes accompany the release. + +## Manual stable release + +In GitHub Actions, choose **Release → Run workflow** on **main** and provide: -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 +- `ci_run_id`: the successful main CI run whose tested artifact you reviewed. +- `version`: the stable SemVer to publish, for example `1.0.0`. + +The workflow validates that run and prepares a new package version from its tested +archive. Only the same two version metadata fields may change. npm versions are +immutable: assigning the `latest` tag to a canary would not turn its prerelease +version into a stable SemVer, so stable delivery needs this metadata-only repack. +The resulting npm version uses `latest` and a regular GitHub Release. + +If the artifact expired, run CI again before releasing. No source rebuild or +fallback artifact is hidden inside the release workflow. Existing npm versions +must match the prepared bytes; a retry never overwrites a published version. +A matching GitHub draft resumes by uploading missing assets, checking their hashes +and publishing the completed draft. Existing complete releases remain unchanged. + +## Browser delivery + +jsDelivr serves the files already published to npm. No CDN account, SDK, Action or +upload is needed. Pin the complete version in integrations: + +```html + + ``` -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) +The release verifies npm archive identity and CDN bytes/headers. CI has already +checked the identical runtime files in Chromium, Firefox and WebKit. The fifth +file is the standalone Node CLI. Release assets include all five runtime files, +the npm archive and the two benchmark reports. + +Official references: [workflow run triggers](https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows#workflow_run), +[artifact downloads](https://github.com/actions/download-artifact), +[npm publishing](https://docs.npmjs.com/cli/commands/npm-publish/), +[jsDelivr npm delivery](https://github.com/jsdelivr/jsdelivr#npm). From 24aa787e348e3f7588eadd15627e87d7c1789acd Mon Sep 17 00:00:00 2001 From: Ilya Konstantinov Date: Mon, 7 Sep 2026 22:41:55 +0400 Subject: [PATCH 5/7] fix: discover packed archives independently of npm JSON output --- .github/scripts/release.ts | 9 +++++---- wiki/development.md | 3 +++ 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/.github/scripts/release.ts b/.github/scripts/release.ts index 9918a30..08b5a4d 100644 --- a/.github/scripts/release.ts +++ b/.github/scripts/release.ts @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import { execFileSync } from 'node:child_process'; import { createHash } from 'node:crypto'; -import { copyFile, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { copyFile, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { pathToFileURL } from 'node:url'; @@ -110,9 +110,10 @@ export async function prepareArchive(options: Preparation): Promise<{ version: s const metadata = archiveMetadata(files, name); await writeJson(join(temporary, name), { ...metadata, version }); } - const packed: unknown = JSON.parse(execFileSync('npm', ['pack', '--json', '--ignore-scripts'], { cwd: temporary, encoding: 'utf8' })); - assert.ok(Array.isArray(packed) && packed.length === 1, 'Expected one stable archive'); - const filename = record(packed[0])['filename']; + execFileSync('npm', ['pack', '--ignore-scripts', '--silent'], { cwd: temporary }); + const archives = (await readdir(temporary)).filter(name => name.endsWith('.tgz')); + assert.equal(archives.length, 1, 'Expected one release archive'); + const filename = archives[0]; assert.ok(typeof filename === 'string' && /^[A-Za-z0-9_.-]+\.tgz$/.test(filename), 'Invalid packed filename'); const archive = join(temporary, filename); const promoted = archiveFiles(archive); diff --git a/wiki/development.md b/wiki/development.md index 4c9c8b4..1a12a34 100644 --- a/wiki/development.md +++ b/wiki/development.md @@ -5,11 +5,14 @@ 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 +npm install --global --ignore-scripts npm@12.0.2 pnpm install --frozen-lockfile pnpm exec playwright install --with-deps chromium firefox webkit pnpm test ``` +Use npm 12.0.2 for package verification and release preparation, matching CI. + `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 From 42f4722f15b75b7c9887d131d4c7592baa204372 Mon Sep 17 00:00:00 2001 From: Ilya Konstantinov Date: Tue, 8 Sep 2026 08:57:23 +0400 Subject: [PATCH 6/7] refactor: remove CI wrappers and test one build across Node and browsers --- .github/scripts/artifact.ts | 152 ------- .github/scripts/build.ts | 88 ---- .github/scripts/release.ts | 416 ------------------ .github/workflows/ci.yml | 148 +++++-- .github/workflows/release.yml | 123 +++++- .oxlintrc.json | 2 +- AGENTS.md | 12 +- .../plans/2026-09-07-simplify-library.md | 49 ++- package.json | 4 +- tests/e2e/verify-package.ts | 65 +-- tests/unit/artifact.test.ts | 114 ----- tests/unit/release-files.test.ts | 152 ------- tests/unit/release-network.test.ts | 165 ------- tests/unit/release.test.ts | 16 - tsconfig.json | 1 - vite.config.ts | 44 +- wiki/benchmarks.md | 5 +- wiki/development.md | 23 +- wiki/releasing.md | 33 +- 19 files changed, 390 insertions(+), 1222 deletions(-) delete mode 100644 .github/scripts/artifact.ts delete mode 100644 .github/scripts/build.ts delete mode 100644 .github/scripts/release.ts delete mode 100644 tests/unit/artifact.test.ts delete mode 100644 tests/unit/release-files.test.ts delete mode 100644 tests/unit/release-network.test.ts delete mode 100644 tests/unit/release.test.ts diff --git a/.github/scripts/artifact.ts b/.github/scripts/artifact.ts deleted file mode 100644 index 4407080..0000000 --- a/.github/scripts/artifact.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { appendFile } from 'node:fs/promises'; -import { pathToFileURL } from 'node:url'; - -const CI_WORKFLOW = '.github/workflows/ci.yml'; -const PAGE_SIZE = 100; -const REQUEST_TIMEOUT_MS = 30_000; -const COMMIT_SHA = /^[a-f0-9]{40}$/; - -interface CiArtifact { - readonly artifactId: number; - readonly runId: number; - readonly runAttempt: number; - readonly sourceSha: string; -} - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - -function record(value: unknown): Record { - if (!isRecord(value)) throw new Error('Invalid GitHub API object'); - return value; -} - -function items(value: unknown, field: string): readonly Record[] { - const list = record(value)[field]; - if (!Array.isArray(list)) throw new Error(`Invalid GitHub API ${field}`); - return list.map(record); -} - -function positiveInteger(value: unknown): number { - if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 1) { - throw new Error('Invalid GitHub run/artifact identity'); - } - return value; -} - -async function request(repository: string, path: string): Promise { - if (!/^[\w.-]+\/[\w.-]+$/.test(repository)) throw new Error('Invalid repository'); - const headers: Record = { Accept: 'application/vnd.github+json' }; - const token = process.env['GH_TOKEN']; - if (token) headers['Authorization'] = `Bearer ${token}`; - const response = await fetch(`https://api.github.com/repos/${repository}/${path}`, { - headers, - signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), - }); - if (response.status === 404) return null; - if (!response.ok) throw new Error(`GitHub API HTTP ${response.status}`); - const value: unknown = await response.json(); - return value; -} - -function isSuccessfulCi(run: Record, repository: string): boolean { - const path = run['path']; - const matchesWorkflow = typeof path === 'string' - && (path === CI_WORKFLOW || path.startsWith(`${CI_WORKFLOW}@`)); - return matchesWorkflow - && run['status'] === 'completed' - && run['conclusion'] === 'success' - && record(run['repository'])['full_name'] === repository - && record(run['head_repository'])['full_name'] === repository; -} - -async function artifactFromRun( - repository: string, - run: Record, - kind: 'benchmark' | 'build', -): Promise { - const sourceSha = run['head_sha']; - if (typeof sourceSha !== 'string' || !COMMIT_SHA.test(sourceSha)) throw new Error('Invalid CI source SHA'); - const runId = positiveInteger(run['id']); - const attempt = positiveInteger(run['run_attempt']); - const name = `${kind}-${sourceSha}-${attempt}`; - for (let page = 1; ; page += 1) { - const result = await request(repository, `actions/runs/${runId}/artifacts?per_page=${PAGE_SIZE}&page=${page}`); - if (result === null) return null; - const artifacts = items(result, 'artifacts'); - const artifact = artifacts.find(item => item['name'] === name && item['expired'] === false); - if (artifact) { - return { - artifactId: positiveInteger(artifact['id']), - runId, - runAttempt: attempt, - sourceSha, - }; - } - if (artifacts.length < PAGE_SIZE) return null; - } -} - -/** A missing report is a normal bootstrap/retention case; no source fallback. */ -export async function findBenchmarkArtifact(repository: string, sha: string): Promise { - if (!COMMIT_SHA.test(sha)) throw new Error('Invalid target SHA'); - for (let page = 1; ; page += 1) { - const result = await request(repository, `actions/workflows/ci.yml/runs?head_sha=${sha}&status=success&per_page=${PAGE_SIZE}&page=${page}`); - if (result === null) return null; - const runs = items(result, 'workflow_runs'); - for (const run of runs) { - if (run['head_sha'] !== sha || !isSuccessfulCi(run, repository)) continue; - const artifact = await artifactFromRun(repository, run, 'benchmark'); - if (artifact) return artifact; - } - if (runs.length < PAGE_SIZE) return null; - } -} - -/** Release artifacts must come from an explicitly identified successful main CI. */ -export async function findReleaseArtifact(repository: string, runId: string): Promise { - if (!/^[1-9]\d*$/.test(runId)) throw new Error('Invalid CI run ID'); - const result = await request(repository, `actions/runs/${runId}`); - const run = record(result); - if (!isSuccessfulCi(run, repository) || run['event'] !== 'push' || run['head_branch'] !== 'main') { - throw new Error('Release requires a successful main CI push in this repository'); - } - const artifact = await artifactFromRun(repository, run, 'build'); - if (artifact === null) throw new Error('The tested build artifact is unavailable; run CI again'); - return artifact; -} - -async function main(): Promise { - const [mode, identity, ...extra] = process.argv.slice(2); - const repository = process.env['GITHUB_REPOSITORY'] ?? ''; - if ((mode !== 'baseline' && mode !== 'release') || identity === undefined || extra.length > 0) { - throw new Error('Usage: node .github/scripts/artifact.ts baseline TARGET_SHA | release CI_RUN_ID'); - } - // A manual CI run or a first branch push may have no previous target commit. - const absentTarget = identity === '' || /^0{40}$/.test(identity); - let artifact: CiArtifact | null = null; - if (mode === 'release') { - artifact = await findReleaseArtifact(repository, identity); - } else if (!absentTarget) { - artifact = await findBenchmarkArtifact(repository, identity); - } - const outputs = artifact === null - ? { available: 'false' } - : { - available: 'true', - 'artifact-id': artifact.artifactId, - 'run-id': artifact.runId, - 'run-attempt': artifact.runAttempt, - 'source-sha': artifact.sourceSha, - }; - if (process.env['GITHUB_OUTPUT']) { - const text = Object.entries(outputs).map(([key, value]) => `${key}=${value}\n`).join(''); - await appendFile(process.env['GITHUB_OUTPUT'], text); - } - console.log(artifact ?? 'No report for the exact target SHA; comparison skipped.'); -} - -if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { - await main(); -} diff --git a/.github/scripts/build.ts b/.github/scripts/build.ts deleted file mode 100644 index 6f5973e..0000000 --- a/.github/scripts/build.ts +++ /dev/null @@ -1,88 +0,0 @@ -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/release.ts b/.github/scripts/release.ts deleted file mode 100644 index 08b5a4d..0000000 --- a/.github/scripts/release.ts +++ /dev/null @@ -1,416 +0,0 @@ -import assert from 'node:assert/strict'; -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 { dirname, join, resolve } from 'node:path'; -import { pathToFileURL } from 'node:url'; -import { parseArgs } from 'node:util'; -import { readReport, type BenchmarkReport } from '../../tests/benchmarks/harness.ts'; - -const STABLE_VERSION = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/; -const SOURCE_SHA = /^[a-f0-9]{40}$/; -const REQUEST_TIMEOUT_MS = 30_000; -const MAX_ARCHIVE_FILE_BYTES = 16 * 1024 * 1024; -const CANARY_VERSION = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)-canary\.[1-9]\d*\.[1-9]\d*\.[a-f0-9]{12}$/; -export 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', -] as const; - -export function sha256(bytes: string | Uint8Array): string { - return createHash('sha256').update(bytes).digest('hex'); -} - -function isRecord(value: unknown): value is Record { - return value !== null && typeof value === 'object' && !Array.isArray(value); -} - -function record(value: unknown): Record { - assert.ok(isRecord(value), 'Expected an object'); - return value; -} - -function archiveFiles(path: string): Map { - const names = execFileSync('tar', ['-tzf', path], { encoding: 'utf8' }).trim().split('\n'); - const entries = execFileSync('tar', ['-tvzf', path], { encoding: 'utf8' }).trim().split('\n'); - assert.ok(entries.every(entry => entry.startsWith('-') || entry.startsWith('d')), 'Archive contains a link or special file'); - assert.equal(new Set(names).size, names.length, 'Archive repeats a file'); - const result = new Map(); - for (const name of names.filter(name => !name.endsWith('/'))) { - assert.ok(/^package\/(?:[A-Za-z0-9_.-]+\/)*[A-Za-z0-9_.-]+$/.test(name) && !name.split('/').includes('..'), 'Unsafe archive path'); - result.set(name.slice('package/'.length), execFileSync('tar', ['-xOzf', path, name], { maxBuffer: MAX_ARCHIVE_FILE_BYTES })); - } - return result; -} - -function requiredFile(files: ReadonlyMap, name: string): Buffer { - const bytes = files.get(name); - assert.ok(bytes, `Missing packaged ${name}`); - return bytes; -} - -function archiveMetadata(files: ReadonlyMap, name: string): Record { - const value: unknown = JSON.parse(requiredFile(files, name).toString('utf8')); - return record(value); -} - -function inspectArchive(files: ReadonlyMap, sourceSha: string): string { - const metadata = archiveMetadata(files, 'package.json'); - const build = archiveMetadata(files, 'dist/manifest.json'); - const version = metadata['version']; - assert.equal(metadata['name'], 'gothic-lock-solver', 'Wrong package identity'); - assert.ok(typeof version === 'string' && (STABLE_VERSION.test(version) || CANARY_VERSION.test(version)), 'Invalid package version'); - assert.ok(SOURCE_SHA.test(sourceSha) && build['sourceSha'] === sourceSha, 'Build source identity differs from successful CI'); - assert.equal(build['version'], version, 'Build version differs from package'); - assert.equal(build['schemaVersion'], 1, 'Unknown build manifest'); - assert.equal(build['package'], 'gothic-lock-solver', 'Wrong build identity'); - const hashes = record(build['files']); - const distribution = [...files.keys()].filter(name => name.startsWith('dist/') && name !== 'dist/manifest.json'); - assert.deepEqual(Object.keys(hashes).sort(), distribution.map(name => name.slice(5)).sort(), 'Build manifest file list differs'); - assert.deepEqual(distribution.filter(name => /\.(?:mjs|js)$/.test(name)).map(name => name.slice(5)).sort(), [...runtimeFiles].sort(), 'Expected exactly five runtime files'); - for (const name of distribution) assert.equal(sha256(requiredFile(files, name)), hashes[name.slice(5)], `Packaged file hash differs: ${name}`); - return version; -} - -export function canaryVersion(base: string, source: string, run: string, attempt: string): string { - assert.ok(STABLE_VERSION.test(base), 'Expected stable package base version'); - assert.ok(SOURCE_SHA.test(source) && /^[1-9]\d*$/.test(run) && /^[1-9]\d*$/.test(attempt), 'Invalid CI source identity'); - return `${base}-canary.${run}.${attempt}.${source.slice(0, 12)}`; -} - -type Preparation = { - input: string; - output: string; - sourceSha: string; - version?: string; - runId?: string; - runAttempt?: string; -}; - -/** Both release kinds change only version metadata in the tested package. */ -export async function prepareArchive(options: Preparation): Promise<{ version: string }> { - const files = archiveFiles(options.input); - const base = inspectArchive(files, options.sourceSha); - assert.ok(STABLE_VERSION.test(base), 'Expected a tested stable package base version'); - const version = options.version ?? canaryVersion(base, options.sourceSha, options.runId ?? '', options.runAttempt ?? ''); - assert.ok(options.version === undefined || STABLE_VERSION.test(version), 'Expected stable promotion version'); - await mkdir(dirname(options.output), { recursive: true }); - const temporary = await mkdtemp(join(tmpdir(), 'gothic-stable-')); - try { - for (const [name, bytes] of files) { - const path = join(temporary, name); - await mkdir(dirname(path), { recursive: true }); - await writeFile(path, bytes); - } - for (const name of ['package.json', 'dist/manifest.json']) { - const metadata = archiveMetadata(files, name); - await writeJson(join(temporary, name), { ...metadata, version }); - } - execFileSync('npm', ['pack', '--ignore-scripts', '--silent'], { cwd: temporary }); - const archives = (await readdir(temporary)).filter(name => name.endsWith('.tgz')); - assert.equal(archives.length, 1, 'Expected one release archive'); - const filename = archives[0]; - assert.ok(typeof filename === 'string' && /^[A-Za-z0-9_.-]+\.tgz$/.test(filename), 'Invalid packed filename'); - const archive = join(temporary, filename); - const promoted = archiveFiles(archive); - assert.deepEqual([...promoted.keys()].sort(), [...files.keys()].sort(), 'Repack changed packaged file list'); - for (const [name, bytes] of files) { - const expected = name === 'package.json' || name === 'dist/manifest.json' ? await readFile(join(temporary, name)) : bytes; - assert.deepEqual(requiredFile(promoted, name), expected, `Repack changed tested bytes: ${name}`); - } - inspectArchive(promoted, options.sourceSha); - await copyFile(archive, options.output); - } finally { - await rm(temporary, { recursive: true, force: true }); - } - return { version }; -} - -async function readJson(path: string): Promise> { - const value: unknown = JSON.parse(await readFile(path, 'utf8')); - return record(value); -} - -async function writeJson(path: string, value: unknown): Promise { - await writeFile(path, `${JSON.stringify(value, null, 2)}\n`); -} - -function environment(name: string): string { - const value = process.env[name]; - assert.ok(value, `Missing ${name}`); - return value; -} - -export const releaseAssets = [...runtimeFiles, 'package.tgz', 'benchmark.json', 'benchmark.md'] as const; -export type ReleaseManifest = { - schemaVersion: 1; - version: string; - sourceSha: string; - kind: 'canary' | 'stable'; - assets: Record; -}; - -function checkReport(directory: string, sourceSha: string, moduleSha256: string): BenchmarkReport { - const report = readReport(join(directory, 'benchmark.json'), sourceSha); - assert.equal(report.quality, 'passed', 'Benchmark quality regression prevents publication'); - assert.equal(report.source.moduleSha256, moduleSha256, 'Benchmark measured a different module'); - return report; -} - -export async function prepareRelease(options: Preparation): Promise { - const original = archiveFiles(join(options.input, 'package.tgz')); - inspectArchive(original, options.sourceSha); - const report = checkReport(options.input, options.sourceSha, sha256(requiredFile(original, 'dist/gothic-lock-solver.mjs'))); - await mkdir(options.output, { recursive: true }); - const { version } = await prepareArchive({ ...options, input: join(options.input, 'package.tgz'), output: join(options.output, 'package.tgz') }); - for (const filename of runtimeFiles) await writeFile(join(options.output, filename), requiredFile(original, `dist/${filename}`)); - for (const filename of ['benchmark.json', 'benchmark.md']) await copyFile(join(options.input, filename), join(options.output, filename)); - const assets: Record = {}; - for (const filename of releaseAssets) assets[filename] = sha256(await readFile(join(options.output, filename))); - const manifest: ReleaseManifest = { - schemaVersion: 1, - version, - sourceSha: options.sourceSha, - kind: options.version === undefined ? 'canary' : 'stable', - assets, - }; - await writeJson(join(options.output, 'release-manifest.json'), manifest); - const cdn = `https://cdn.jsdelivr.net/npm/gothic-lock-solver@${version}/dist`; - const { totals, comparison } = report; - const benchmarkSummary = [ - `Benchmark quality: **${report.quality}**; ${report.fixtures.count} locks.`, '', - `Totals: A=${totals.A}, U=${totals.U}, C=${totals.C}, switches=${totals.plateSwitches}.`, - 'A counts actions, U distinct plates per lock, and C unit shifts; totals sum all locks.', '', - ]; - if (comparison.status === 'compared') { - const drift = comparison.results.reduce((sum, row) => ({ - A: sum.A + row.deltas.A, - U: sum.U + row.deltas.U, - C: sum.C + row.deltas.C, - plateSwitches: sum.plateSwitches + row.deltas.plateSwitches, - }), { A: 0, U: 0, C: 0, plateSwitches: 0 }); - benchmarkSummary.push( - `Comparison: compared with target SHA \`${comparison.baselineSha}\`.`, - `Deterministic drift: ΔA=${drift.A}, ΔU=${drift.U}, ΔC=${drift.C}, Δswitches=${drift.plateSwitches}.`, '', - ); - } else { - benchmarkSummary.push(`Comparison: skipped. ${comparison.reason}`, ''); - } - await writeFile(join(options.output, 'release-notes.md'), [ - `# Gothic Lock Solver ${version}`, '', `Tested source: ${options.sourceSha}.`, '', - ...benchmarkSummary, - `Install: \`npm install gothic-lock-solver@${version}\`.`, '', - `Browser module: ${cdn}/gothic-lock-solver.min.mjs`, '', - `Classic script: ${cdn}/gothic-lock-solver.min.js`, '', - 'All five standalone runtime files, npm archive, checksums and full 45-lock benchmark reports are attached.', - 'Release packaging changes version metadata only; runtime and declaration bytes match successful CI.', '', - ].join('\n')); - await checkRelease(options.output, options.sourceSha); - return manifest; -} - -export async function checkRelease(directory: string, sourceSha: string): Promise { - const value = await readJson(join(directory, 'release-manifest.json')); - const version = value['version']; - const kind = value['kind']; - assert.ok(kind === 'stable' || kind === 'canary', 'Invalid release kind'); - assert.ok(typeof version === 'string' && (kind === 'stable' ? STABLE_VERSION.test(version) : kind === 'canary' && CANARY_VERSION.test(version)), 'Invalid release version/kind'); - assert.ok(value['schemaVersion'] === 1 && value['sourceSha'] === sourceSha && SOURCE_SHA.test(sourceSha), 'Release source identity differs'); - const hashes = record(value['assets']); - assert.deepEqual(Object.keys(hashes).sort(), [...releaseAssets].sort(), 'Incomplete release assets'); - const assets: Record = {}; - for (const filename of releaseAssets) { - const hash = hashes[filename]; - assert.ok(typeof hash === 'string' && /^[a-f0-9]{64}$/.test(hash), 'Invalid asset hash'); - assert.equal(sha256(await readFile(join(directory, filename))), hash, `Release asset identity differs: ${filename}`); - assets[filename] = hash; - } - const files = archiveFiles(join(directory, 'package.tgz')); - assert.equal(inspectArchive(files, sourceSha), version, 'Prepared package version differs'); - for (const filename of runtimeFiles) assert.equal(sha256(requiredFile(files, `dist/${filename}`)), assets[filename], 'Attached and packaged runtime identity differs'); - checkReport(directory, sourceSha, sha256(requiredFile(files, 'dist/gothic-lock-solver.mjs'))); - return { schemaVersion: 1, version, sourceSha, kind, assets }; -} - -const NPM_REGISTRY = 'https://registry.npmjs.org'; -const PACKAGE = 'gothic-lock-solver'; - -export async function registryMetadata(version: string): Promise | null> { - const response = await fetch(`${NPM_REGISTRY}/${PACKAGE}/${encodeURIComponent(version)}`, { signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) }); - if (response.status === 404) return null; - assert.ok(response.ok, `npm registry HTTP ${response.status}`); - const value: unknown = await response.json(); - return record(value); -} - -export async function publishNpm(directory: string, manifest: ReleaseManifest): Promise { - let metadata = await registryMetadata(manifest.version); - if (metadata === null) { - execFileSync('npm', [ - 'publish', join(directory, 'package.tgz'), - '--access', 'public', - '--tag', manifest.kind === 'stable' ? 'latest' : 'canary', - '--ignore-scripts', '--registry', `${NPM_REGISTRY}/`, - ], { stdio: 'inherit' }); - metadata = await registryMetadata(manifest.version); - } - assert.ok(metadata && metadata['name'] === PACKAGE && metadata['version'] === manifest.version, 'Exact npm version unavailable'); - const url = record(metadata['dist'])['tarball']; - assert.ok(typeof url === 'string' && url.startsWith(`${NPM_REGISTRY}/${PACKAGE}/-/`), 'Unexpected registry archive URL'); - const response = await fetch(url, { signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) }); - assert.ok(response.ok, `npm archive HTTP ${response.status}`); - assert.equal(sha256(new Uint8Array(await response.arrayBuffer())), manifest.assets['package.tgz'], 'Published npm archive identity differs'); -} - -export async function verifyCdn(manifest: ReleaseManifest): Promise { - for (const filename of runtimeFiles) { - const response = await fetch(`https://cdn.jsdelivr.net/npm/${PACKAGE}@${manifest.version}/dist/${filename}`, { - headers: { Origin: 'https://example.com' }, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), - }); - const scriptType = /(?:java|ecma)script/i.test(response.headers.get('content-type') ?? ''); - const crossOrigin = response.headers.get('access-control-allow-origin') === '*'; - assert.ok(response.ok && scriptType && crossOrigin, `CDN headers rejected: ${filename}`); - assert.equal(sha256(new Uint8Array(await response.arrayBuffer())), manifest.assets[filename], `CDN identity differs: ${filename}`); - } -} - -function publicationContext(manifest: ReleaseManifest): void { - const repository = environment('GITHUB_REPOSITORY'); - assert.ok(process.env['GITHUB_ACTIONS'] === 'true' && process.env['NPM_PUBLICATION_READY'] === 'true', 'Publication readiness is not configured'); - assert.equal(process.env['GITHUB_REF'], 'refs/heads/main', 'Publication requires main'); - assert.equal(process.env['GITHUB_WORKFLOW_REF'], `${repository}/.github/workflows/release.yml@refs/heads/main`, 'Publication requires the main release workflow'); - assert.equal(process.env['GITHUB_EVENT_NAME'], manifest.kind === 'stable' ? 'workflow_dispatch' : 'workflow_run', 'Release kind and trigger differ'); - assert.equal(execFileSync('npm', ['--version'], { encoding: 'utf8' }).trim(), '12.0.2', 'Unexpected npm version'); -} - -async function githubRecord(path: string): Promise | null> { - const response = await fetch(`https://api.github.com/${path}`, { - headers: { Authorization: `Bearer ${environment('GH_TOKEN')}`, Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28' }, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), - }); - if (response.status === 404) return null; - assert.ok(response.ok, `GitHub HTTP ${response.status}`); - const value: unknown = await response.json(); - return record(value); -} - -function missingGithubAssets(value: unknown, expected: Readonly>): string[] { - assert.ok(Array.isArray(value), 'Missing GitHub assets'); - const remaining = new Set(Object.keys(expected)); - for (const raw of value) { - const asset = record(raw); - const name = asset['name']; - assert.ok(typeof name === 'string' && remaining.delete(name), 'Unexpected or duplicate GitHub asset'); - assert.equal(asset['digest'], `sha256:${expected[name]}`, 'GitHub asset identity differs'); - } - return [...remaining]; -} - -async function githubRelease(repository: string, tag: string): Promise | null> { - const [owner, name] = repository.split('/'); - const query = 'query($owner: String!, $name: String!, $tag: String!) { repository(owner: $owner, name: $name) { release(tagName: $tag) { databaseId } } }'; - // Exact GraphQL lookup includes drafts; the REST tag endpoint only serves published releases. - const id: unknown = JSON.parse(execFileSync('gh', [ - 'api', 'graphql', '-f', `query=${query}`, - '-f', `owner=${owner}`, '-f', `name=${name}`, '-f', `tag=${tag}`, - '--jq', '.data.repository.release.databaseId', - ], { encoding: 'utf8' })); - if (id === null) return null; - assert.ok(typeof id === 'number' && Number.isSafeInteger(id) && id > 0, 'Invalid GitHub release ID'); - return githubRecord(`repos/${repository}/releases/${id}`); -} - -export async function publishGithub(directory: string, manifest: ReleaseManifest, repository: string): Promise { - assert.ok(/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository), 'Invalid repository'); - const tag = `v${manifest.version}`; - const commitPath = `repos/${repository}/commits/refs/tags/${tag}`; - let commit = await githubRecord(commitPath); - if (commit === null) { - execFileSync('gh', [ - 'api', '--method', 'POST', `repos/${repository}/git/refs`, - '-f', `ref=refs/tags/${tag}`, '-f', `sha=${manifest.sourceSha}`, - ], { stdio: 'inherit' }); - commit = await githubRecord(commitPath); - } - assert.equal(commit?.['sha'], manifest.sourceSha, 'GitHub tag identifies different source'); - - const expected: Record = { - ...manifest.assets, - 'release-manifest.json': sha256(await readFile(join(directory, 'release-manifest.json'))), - }; - let release = await githubRelease(repository, tag); - if (release === null) { - execFileSync('gh', [ - 'release', 'create', tag, ...Object.keys(expected).map(name => join(directory, name)), - '--repo', repository, '--verify-tag', '--title', tag, - '--notes-file', join(directory, 'release-notes.md'), - ...(manifest.kind === 'canary' ? ['--prerelease'] : []), - `--latest=${manifest.kind === 'stable'}`, - ], { stdio: 'inherit' }); - release = await githubRelease(repository, tag); - } - assert.ok(release && typeof release['draft'] === 'boolean', 'GitHub release unavailable'); - assert.equal(release['prerelease'], manifest.kind === 'canary', 'GitHub release kind differs'); - const missing = missingGithubAssets(release['assets'], expected); - if (release['draft']) { - if (missing.length > 0) { - execFileSync('gh', [ - 'release', 'upload', tag, ...missing.map(name => join(directory, name)), '--repo', repository, - ], { stdio: 'inherit' }); - } - const updated = await githubRelease(repository, tag); - assert.ok(updated && updated['draft'] === true, 'GitHub draft changed during completion'); - assert.equal(updated['prerelease'], manifest.kind === 'canary', 'GitHub release kind differs'); - assert.equal(missingGithubAssets(updated['assets'], expected).length, 0, 'GitHub draft is incomplete'); - execFileSync('gh', [ - 'release', 'edit', tag, '--repo', repository, '--draft=false', `--latest=${manifest.kind === 'stable'}`, - ], { stdio: 'inherit' }); - return; - } - assert.equal(missing.length, 0, 'Published GitHub release is incomplete'); -} - -async function main(): Promise { - const args = parseArgs({ - allowPositionals: true, - options: { - input: { type: 'string' }, - output: { type: 'string' }, - directory: { type: 'string' }, - version: { type: 'string' }, - }, - }); - assert.equal(args.positionals.length, 1, 'Expected prepare or publish'); - const sourceSha = environment('RELEASE_SOURCE_SHA'); - if (args.positionals[0] === 'prepare') { - await prepareRelease({ - input: resolve(args.values.input ?? 'artifacts/build'), - output: resolve(args.values.output ?? 'artifacts/release'), - sourceSha, - ...(args.values.version === undefined - ? { runId: environment('CI_RUN_ID'), runAttempt: environment('CI_RUN_ATTEMPT') } - : { version: args.values.version }), - }); - return; - } - if (args.positionals[0] === 'publish') { - const directory = resolve(args.values.directory ?? 'artifacts/release'); - const manifest = await checkRelease(directory, sourceSha); - publicationContext(manifest); - await publishNpm(directory, manifest); - await verifyCdn(manifest); - await publishGithub(directory, manifest, environment('GITHUB_REPOSITORY')); - return; - } - throw new Error('Expected prepare or publish'); -} - -if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { - await main().catch((error: unknown) => { - console.error(error instanceof Error ? error.message : error); - process.exitCode = 1; - }); -} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a3cfe26..551e82d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,26 +18,18 @@ env: TARGET_SHA: ${{ github.event.pull_request.base.sha || github.event.before }} jobs: - verify: + build: + name: Build and benchmark runs-on: ubuntu-24.04 - timeout-minutes: 30 + timeout-minutes: 20 + defaults: + run: + shell: bash steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ env.SOURCE_SHA }} persist-credentials: false - - 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 @@ -46,29 +38,53 @@ jobs: with: node-version: 24.19.0 cache: pnpm - - name: Install tools and browsers + - name: Install tools and dependencies run: | npm install --global --ignore-scripts npm@12.0.2 pnpm install --frozen-lockfile - pnpm exec playwright install --with-deps chromium firefox webkit - - name: Check source, bundles and installed package - env: - NODE22: ${{ steps.node22.outputs.path }} - NODE26: ${{ steps.node26.outputs.path }} + - name: Check and build the distribution 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: Install official GitHub CLI + 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 --no-same-owner -xzf "$RUNNER_TEMP/gh.tar.gz" -C "$RUNNER_TEMP" gh_2.100.0_linux_amd64/bin/gh + echo "$RUNNER_TEMP/gh_2.100.0_linux_amd64/bin" >> "$GITHUB_PATH" - name: Find the exact target SHA report id: baseline env: GH_TOKEN: ${{ github.token }} - run: node .github/scripts/artifact.ts baseline "$TARGET_SHA" + run: | + if [[ -z "$TARGET_SHA" || "$TARGET_SHA" == 0000000000000000000000000000000000000000 ]]; then + echo "available=false" >> "$GITHUB_OUTPUT" + echo "No target commit; benchmark comparison skipped." + exit 0 + fi + runs=$(gh api --paginate "repos/$GITHUB_REPOSITORY/actions/runs?head_sha=$TARGET_SHA&status=success&per_page=100" --jq ' + .workflow_runs[] + | select(.head_sha == env.TARGET_SHA + and .repository.full_name == env.GITHUB_REPOSITORY + and .head_repository.full_name == env.GITHUB_REPOSITORY + and .status == "completed" and .conclusion == "success" + and (.path | split("@")[0]) == ".github/workflows/ci.yml") + | .id') + while read -r run_id; do + [[ -n "$run_id" ]] || continue + artifact_id=$(gh api --paginate "repos/$GITHUB_REPOSITORY/actions/runs/$run_id/artifacts?per_page=100" --jq ".artifacts[] | select(.name == \"benchmark-$TARGET_SHA\" and .expired == false) | .id") + if [[ -n "$artifact_id" ]]; then + echo "available=true" >> "$GITHUB_OUTPUT" + echo "artifact-id=$artifact_id" >> "$GITHUB_OUTPUT" + echo "run-id=$run_id" >> "$GITHUB_OUTPUT" + exit 0 + fi + done <<< "$runs" + echo "available=false" >> "$GITHUB_OUTPUT" + echo "No report for the exact target SHA; benchmark comparison skipped." - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 if: steps.baseline.outputs.available == 'true' with: @@ -82,8 +98,10 @@ jobs: run: | if [ "$BASELINE_AVAILABLE" = true ]; then pnpm benchmark --baseline-report "$RUNNER_TEMP/baseline/benchmark.json" --baseline-sha "$TARGET_SHA" - else + elif [[ -n "$TARGET_SHA" && "$TARGET_SHA" != 0000000000000000000000000000000000000000 ]]; then pnpm benchmark --baseline-sha "$TARGET_SHA" + else + pnpm benchmark fi - name: Show the benchmark report if: always() @@ -91,24 +109,92 @@ jobs: - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 if: always() with: - name: benchmark-${{ env.SOURCE_SHA }}-${{ github.run_attempt }} + name: benchmark-${{ env.SOURCE_SHA }} + overwrite: true path: artifacts/benchmark/ retention-days: 90 - name: Collect the tested distribution run: | + mkdir "$RUNNER_TEMP/package" mkdir -p artifacts/build + npm pack --ignore-scripts --pack-destination "$RUNNER_TEMP/package" + mv "$RUNNER_TEMP/package/"*.tgz artifacts/build/package.tgz cp dist/gothic-lock-solver.{mjs,min.mjs,js,min.js,cli.mjs} artifacts/build/ - cp artifacts/package/package.tgz artifacts/benchmark/benchmark.{json,md} artifacts/build/ + cp artifacts/benchmark/benchmark.{json,md} artifacts/build/ - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: build-${{ env.SOURCE_SHA }}-${{ github.run_attempt }} + name: build-${{ env.SOURCE_SHA }} + overwrite: true path: artifacts/build/ if-no-files-found: error retention-days: 90 + + test: + name: Node ${{ matrix.node }} / ${{ matrix.project }} + needs: build + runs-on: ubuntu-24.04 + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + include: + - { node: '22.23.2', project: node } + - { node: '24.19.0', project: node } + - { node: '26.8.1', project: node } + - { node: '24.19.0', project: chromium } + - { node: '24.19.0', project: firefox } + - { node: '24.19.0', project: webkit } + defaults: + run: + shell: bash + env: + NODE_VERSION: ${{ matrix.node }} + PROJECT: ${{ matrix.project }} + BROWSER: ${{ matrix.project == 'node' && 'none' || matrix.project }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ env.SOURCE_SHA }} + persist-credentials: false + - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 + with: + version: 12.3.4 + run_install: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: ${{ matrix.node }} + cache: pnpm + - name: Install tools and dependencies + run: | + npm install --global --ignore-scripts npm@12.0.2 + pnpm install --frozen-lockfile + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: build-${{ env.SOURCE_SHA }} + path: ${{ runner.temp }}/build + - name: Extract the same built distribution + run: tar -xzf "$RUNNER_TEMP/build/package.tgz" --strip-components=1 -C . package/dist + - name: Install the selected browser + if: matrix.project != 'node' + run: pnpm exec playwright install --with-deps "$PROJECT" + - name: Test the selected environment + run: | + if [[ "$PROJECT" == node && "$NODE_VERSION" == 24.19.0 ]]; then + pnpm test:coverage + else + pnpm exec vitest run --project "$PROJECT" + fi + - name: Verify browser pages and Workers + if: matrix.project != 'node' + run: | + node tests/e2e/build-fixtures.ts + pnpm exec playwright test --project "$PROJECT" + - name: Verify the installed build archive + run: pnpm verify:package --archive "$RUNNER_TEMP/build/package.tgz" --browser "$BROWSER" - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 if: always() with: - name: diagnostics-${{ github.run_id }}-${{ github.run_attempt }} + name: diagnostics-${{ github.run_id }}-${{ github.run_attempt }}-node-${{ matrix.node }}-${{ matrix.project }} path: | artifacts/coverage/ artifacts/playwright-report/ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 745fdac..58abb64 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -38,13 +38,10 @@ jobs: id-token: write env: GH_TOKEN: ${{ github.token }} - NPM_PUBLICATION_READY: ${{ vars.NPM_PUBLICATION_READY }} + defaults: + run: + shell: bash steps: - - name: Check out trusted main release tools - 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 @@ -62,28 +59,122 @@ jobs: id: build env: REQUESTED_RUN: ${{ inputs.ci_run_id || github.event.workflow_run.id }} - run: node .github/scripts/artifact.ts release "$REQUESTED_RUN" + run: | + [[ "$REQUESTED_RUN" =~ ^[1-9][0-9]*$ ]] + gh api "repos/$GITHUB_REPOSITORY/actions/runs/$REQUESTED_RUN" > "$RUNNER_TEMP/run.json" + jq -e --arg repo "$GITHUB_REPOSITORY" ' + .repository.full_name == $repo and .head_repository.full_name == $repo and + .event == "push" and .head_branch == "main" and .status == "completed" and .conclusion == "success" and + (.path | split("@")[0]) == ".github/workflows/ci.yml" and + (.head_sha | test("^[a-f0-9]{40}$")) and .run_attempt >= 1 + ' "$RUNNER_TEMP/run.json" + SOURCE_SHA=$(jq -r .head_sha "$RUNNER_TEMP/run.json") + RUN_ATTEMPT=$(jq -r .run_attempt "$RUNNER_TEMP/run.json") + ARTIFACT_NAME="build-$SOURCE_SHA" + gh api "repos/$GITHUB_REPOSITORY/actions/runs/$REQUESTED_RUN/artifacts?per_page=100" --paginate | + jq -res --arg name "$ARTIFACT_NAME" ' + [.[].artifacts[] | select(.name == $name and .expired == false)] | + if length == 1 then .[0].id else error("Expected one unexpired CI build") end + ' > "$RUNNER_TEMP/artifact-id" + echo "artifact-id=$(cat "$RUNNER_TEMP/artifact-id")" >> "$GITHUB_OUTPUT" + echo "run-id=$REQUESTED_RUN" >> "$GITHUB_OUTPUT" + echo "run-attempt=$RUN_ATTEMPT" >> "$GITHUB_OUTPUT" + echo "source-sha=$SOURCE_SHA" >> "$GITHUB_OUTPUT" - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: artifact-ids: ${{ steps.build.outputs.artifact-id }} run-id: ${{ steps.build.outputs.run-id }} github-token: ${{ github.token }} path: ${{ runner.temp }}/build - - name: Prepare release metadata without rebuilding runtime files + - name: Assign version without rebuilding tested files + id: release env: - RELEASE_SOURCE_SHA: ${{ steps.build.outputs.source-sha }} + SOURCE_SHA: ${{ steps.build.outputs.source-sha }} CI_RUN_ID: ${{ steps.build.outputs.run-id }} CI_RUN_ATTEMPT: ${{ steps.build.outputs.run-attempt }} STABLE_VERSION: ${{ inputs.version }} run: | - args=(--input "$RUNNER_TEMP/build" --output "$RUNNER_TEMP/release") - if [ -n "$STABLE_VERSION" ]; then args+=(--version "$STABLE_VERSION"); fi - node .github/scripts/release.ts prepare "${args[@]}" - cat "$RUNNER_TEMP/release/release-notes.md" >> "$GITHUB_STEP_SUMMARY" - - name: Publish npm, verify CDN and create GitHub Release + mkdir -p "$RUNNER_TEMP/original" "$RUNNER_TEMP/repacked" "$RUNNER_TEMP/release" + tar -xzf "$RUNNER_TEMP/build/package.tgz" -C "$RUNNER_TEMP/original" --no-same-owner + cp -a "$RUNNER_TEMP/original/package" "$RUNNER_TEMP/package" + cd "$RUNNER_TEMP/package" + BASE_VERSION=$(jq -er 'select(.name == "gothic-lock-solver") | .version' package.json) + [[ "$BASE_VERSION" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] + jq -e --arg sha "$SOURCE_SHA" --arg version "$BASE_VERSION" ' + .schemaVersion == 1 and .package == "gothic-lock-solver" and + .sourceSha == $sha and .version == $version + ' dist/manifest.json + jq -r '.files | to_entries[] | "\(.value) dist/\(.key)"' dist/manifest.json | sha256sum --check --strict + MODULE_SHA=$(sha256sum dist/gothic-lock-solver.mjs | cut -d ' ' -f 1) + jq -e --arg sha "$SOURCE_SHA" --arg module "$MODULE_SHA" ' + .schemaVersion == 2 and .source.revision == $sha and .source.dirty == false and + .source.moduleSha256 == $module and .quality == "passed" and .fixtures.count == 45 + ' "$RUNNER_TEMP/build/benchmark.json" + VERSION="${BASE_VERSION}-canary.${CI_RUN_ID}.${CI_RUN_ATTEMPT}.${SOURCE_SHA:0:12}" + TAG=canary + if [[ "$GITHUB_EVENT_NAME" == workflow_dispatch ]]; then + [[ "$STABLE_VERSION" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] + VERSION="$STABLE_VERSION" + TAG=latest + fi + for file in package.json dist/manifest.json; do + jq --arg version "$VERSION" '.version = $version' "$file" > "$file.tmp" + mv "$file.tmp" "$file" + done + npm pack --ignore-scripts --silent --pack-destination "$RUNNER_TEMP/release" + archives=("$RUNNER_TEMP/release/"*.tgz) + [[ ${#archives[@]} == 1 && -f "${archives[0]}" ]] + mv "${archives[0]}" "$RUNNER_TEMP/release/package.tgz" + tar -xzf "$RUNNER_TEMP/release/package.tgz" -C "$RUNNER_TEMP/repacked" --no-same-owner + diff -r "$RUNNER_TEMP/package" "$RUNNER_TEMP/repacked/package" + runtime=(dist/*.js dist/*.mjs) + [[ ${#runtime[@]} == 5 ]] + cp "${runtime[@]}" "$RUNNER_TEMP/release/" + cp "$RUNNER_TEMP/build/benchmark.json" "$RUNNER_TEMP/build/benchmark.md" "$RUNNER_TEMP/release/" + cd "$RUNNER_TEMP/release" + sha256sum package.tgz *.js *.mjs benchmark.json benchmark.md > SHA256SUMS + { + printf '# Gothic Lock Solver %s\n\nTested source: `%s`.\n\n' "$VERSION" "$SOURCE_SHA" + printf 'Install: `npm install gothic-lock-solver@%s`.\n\n' "$VERSION" + printf 'Browser module: https://cdn.jsdelivr.net/npm/gothic-lock-solver@%s/dist/gothic-lock-solver.min.mjs\n\n' "$VERSION" + printf 'Classic script: https://cdn.jsdelivr.net/npm/gothic-lock-solver@%s/dist/gothic-lock-solver.min.js\n\n' "$VERSION" + cat benchmark.md + } > release-notes.md + cat release-notes.md >> "$GITHUB_STEP_SUMMARY" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + - name: Publish the tested archive to npm + env: + VERSION: ${{ steps.release.outputs.version }} + TAG: ${{ steps.release.outputs.tag }} + run: | + gh api "repos/$GITHUB_REPOSITORY/git/matching-refs/tags/v$VERSION" | + jq -e --arg ref "refs/tags/v$VERSION" 'all(.[]; .ref != $ref)' + npm publish "$RUNNER_TEMP/release/package.tgz" --access public --tag "$TAG" --ignore-scripts --registry https://registry.npmjs.org + - name: Verify public CDN files env: - RELEASE_SOURCE_SHA: ${{ steps.build.outputs.source-sha }} - run: node .github/scripts/release.ts publish --directory "$RUNNER_TEMP/release" + VERSION: ${{ steps.release.outputs.version }} + run: | + cd "$RUNNER_TEMP/release" + for file in *.js *.mjs; do + curl --fail --location --retry 5 --retry-all-errors --retry-delay 5 --max-time 30 \ + --dump-header "$RUNNER_TEMP/cdn-headers" \ + "https://cdn.jsdelivr.net/npm/gothic-lock-solver@$VERSION/dist/$file" -o "$RUNNER_TEMP/$file" + grep -Eiq '^content-type: (application|text)/javascript' "$RUNNER_TEMP/cdn-headers" + grep -Eiq '^access-control-allow-origin: \*\s*$' "$RUNNER_TEMP/cdn-headers" + cmp "$file" "$RUNNER_TEMP/$file" + done + - name: Create GitHub Release with benchmark evidence + env: + SOURCE_SHA: ${{ steps.build.outputs.source-sha }} + VERSION: ${{ steps.release.outputs.version }} + TAG: ${{ steps.release.outputs.tag }} + run: | + flags=(--latest) + if [[ "$TAG" == canary ]]; then flags=(--prerelease --latest=false); fi + gh release create "v$VERSION" "$RUNNER_TEMP/release/"* --repo "$GITHUB_REPOSITORY" \ + --target "$SOURCE_SHA" --title "Gothic Lock Solver $VERSION" \ + --notes-file "$RUNNER_TEMP/release/release-notes.md" "${flags[@]}" - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 if: always() with: diff --git a/.oxlintrc.json b/.oxlintrc.json index ce2bd85..946ea01 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -30,7 +30,7 @@ { "files": [ "src/**/*.ts", - ".github/scripts/**/*.ts" + "vite.config.ts" ], "rules": { "max-depth": [ diff --git a/AGENTS.md b/AGENTS.md index 780b9ef..6d7dc0f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,8 +18,10 @@ work is in the [current plan](docs/superpowers/plans/2026-09-07-simplify-library - `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/`. + `tests/benchmarks`; maintained documentation in `wiki/`. Build configuration + belongs in `vite.config.ts`; CI and publication steps stay directly in workflow + YAML. Do not introduce orchestration wrappers or a scripts directory. + Keep only the current plan in `docs/`. ## Engineering @@ -33,7 +35,7 @@ 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/`. +Oxlint limits block nesting to three levels in `src/` and `vite.config.ts`. Review responsibilities and data flow as well as tests; passing gates alone does not establish maintainability. @@ -58,7 +60,9 @@ experiments. Preserve deterministic quality checks; do not change expected minim or comparison 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. CI builds and checks; a separate release +check hosted CI on the exact head. CI builds and benchmarks once on Node 24, then +tests that same archive in six matrix rows: Node 22/24/26 and Chromium/Firefox/ +WebKit. Browser rows use Node 24 to drive tests. A separate release workflow publishes main canaries after successful CI and stable versions only through a manual dispatch selecting a tested main CI run. Working logs/review outputs belong in ignored `artifacts/` and CI artifacts. Use official SHA-pinned Actions, npm/gh CLIs and free diff --git a/docs/superpowers/plans/2026-09-07-simplify-library.md b/docs/superpowers/plans/2026-09-07-simplify-library.md index e0a9c47..5de9f42 100644 --- a/docs/superpowers/plans/2026-09-07-simplify-library.md +++ b/docs/superpowers/plans/2026-09-07-simplify-library.md @@ -1,12 +1,21 @@ -# Simple CI and canary releases +# Flat CI and canary releases The owner replaced the previous delivery design with two simple workflows. Keep the solver, public API, five runtime outputs and strict local checks. This plan supersedes checkout-based benchmarks and the previous release flow. +The September 8 refinement removes `.github/scripts` entirely: configuration +belongs in Vite, ordinary build commands in package.json, and CI/release +coordination directly in workflow YAML. Do not relocate wrappers or implement +a custom publication recovery engine. ## CI -- One job runs the existing checks, coverage, Vite builds, Node and browser consumers. +- One Node 24 build job checks types/lint, builds, checks the distribution, measures + benchmarks and uploads one package archive. +- A dependent test job uses six Ubuntu matrix rows: Node 22/24/26 and Chromium/ + Firefox/WebKit. Each row tests the same archive without rebuilding; browser rows + use Node 24. The Node 24/node row collects coverage. Diagnostic names include + Node version and environment. All rows must succeed for release. - Measure the current built solver on all 45 fixed inputs. - For a PR, use its target SHA; for main pushes, use the previous main SHA. - Look up a successful CI report artifact for exactly that commit. @@ -14,8 +23,9 @@ This plan supersedes checkout-based benchmarks and the previous release flow. rebuild, npm lookup, previous-release fallback or synthetic baseline. - Compare deterministic quality metrics; report historical timing/memory ratios with their cross-run limitations. Keep generated output out of Git. -- Save the benchmark report separately from the tested package artifact; name - artifacts with commit SHA and run attempt to avoid overwriting earlier attempts. +- Save the benchmark report separately from the tested package artifact, named + by commit SHA within the selected CI run. Full reruns overwrite these artifacts; + failed-test reruns reuse the successful build. Diagnostics retain attempt names. ## Release @@ -26,23 +36,28 @@ This plan supersedes checkout-based benchmarks and the previous release flow. - Download only that run's tested build artifact. No algorithm rebuild. - Stable packaging changes only package/build version metadata; verify all other archive bytes remain identical before publishing the new npm version. -- Publish to npm through OIDC, check registry/CDN delivery, attach five runtime +- Publish to npm through OIDC, check CDN delivery, attach five runtime files, package archive and the benchmark report to the GitHub Release. -- Keep the two workflows readable, one job each, using official pinned Actions. +- Keep the two workflows readable: CI build and matrix test jobs, and one + publication job, using official pinned Actions. - Retain direct-publish setup and the readiness switch for first-package bootstrap. ## Execution -1. Replace module-vs-module benchmark machinery with current measurement and - optional saved-report comparison; test valid/missing/wrong-SHA reports. -2. Replace release machinery with one release script; test canary identity, - stable metadata-only repack and publication boundaries without publishing. -3. Test exact-SHA artifact selection and successful-main-only release selection. -4. Complete all local gates before changing workflow YAML. -5. Add the two workflows and update README, Wiki and agent instructions. -6. After CI simplification, fix secondary solution ordering: minimize unit shifts - among paths already optimal in grouped actions. Verify lock-018 and all 45 - inputs with an independent lexicographic oracle; preserve the public contract. -7. Review independently, update draft PR #3 and verify CI on the exact new head. +1. Replace the programmatic build wrapper with direct Vite/tsc commands and a + shared Vite configuration for readable, minified and CLI builds. Preserve + global declarations and the source/checksum manifest; verify distribution tests. +2. Replace the artifact wrapper with official gh/jq commands in a candidate CI + workflow. Exercise exact-SHA, missing/expired, foreign-source and API-error cases. +3. Replace the release wrapper with explicit commands in a candidate release + workflow. Verify trusted main-run selection, metadata-only repack and file + identity using the existing tested archive, without publishing. +4. Delete tests belonging only to removed wrappers; retain mathematical, consumer, + configuration, benchmark-report and package checks. Run all local gates and + all 45 locks before installing the candidate workflow YAML. +5. Update Wiki, agent instructions and lint/type configuration. Independently + review both workflow commands and the complete diff, then run actionlint. +6. Update draft PR #3 and verify CI plus artifacts on the exact new head. Keep + minimum-(actions, shifts) behavior: lock-018 remains 14/45 and all 45 total 483/1713. No actual merge or package publication is authorized by this implementation task. diff --git a/package.json b/package.json index 6fe0155..a07f646 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "node": "^22.0.0 || ^24.0.0 || ^26.0.0" }, "scripts": { - "lint": "oxlint --deny-warnings src tests .github/scripts *.ts", + "lint": "oxlint --deny-warnings src tests *.ts", "typecheck": "tsc --noEmit", "check": "pnpm lint && pnpm typecheck", "test": "pnpm check && pnpm test:coverage && pnpm build && pnpm test:distribution && pnpm test:browser && pnpm test:e2e && pnpm verify:package", @@ -16,7 +16,7 @@ "test:browser": "vitest run --project chromium --project firefox --project webkit", "test:e2e": "node tests/e2e/build-fixtures.ts && playwright test", "test:coverage": "vitest run --project node --coverage", - "build": "node .github/scripts/build.ts", + "build": "vite build --mode readable && tsc -p tsconfig.build.json && vite build --mode minified && vite build --mode cli", "verify:package": "node tests/e2e/verify-package.ts", "benchmark": "node tests/benchmarks/benchmark.ts", "solve": "node src/cli.ts" diff --git a/tests/e2e/verify-package.ts b/tests/e2e/verify-package.ts index aa35c8b..23dcd34 100644 --- a/tests/e2e/verify-package.ts +++ b/tests/e2e/verify-package.ts @@ -3,18 +3,24 @@ 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 { join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { parseArgs } from 'node:util'; -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 { values } = parseArgs({ + options: { + archive: { type: 'string' }, + browser: { type: 'string' }, + node: { type: 'string', multiple: true }, + }, +}); +const browsers = ['none', 'chromium', 'firefox', 'webkit']; +assert.ok(values.browser === undefined || browsers.includes(values.browser), 'Browser must be none, chromium, firefox or webkit'); +assert.ok(values.archive !== '', 'Archive path must not be empty'); +assert.ok(values.node?.every(path => path.length > 0) ?? true, 'Node executable path must not be empty'); +const browser = values.browser ?? 'all'; +const suppliedArchive = values.archive === undefined ? undefined : resolve(values.archive); +const consumerNodes = new Set([process.execPath, ...(values.node ?? [])]); const root = fileURLToPath(new URL('../../', import.meta.url)); const temporary = await mkdtemp(join(tmpdir(), 'gothic-package-consumer-')); @@ -28,15 +34,18 @@ function run(command: string, args: readonly string[], cwd = temporary): string } try { await mkdir(artifacts, { recursive: true }); - // A failed fresh check must not leave a stale archive labeled verified. - await rm(destination, { force: true }); + // Preserve a supplied archive even when its verification fails. + if (suppliedArchive !== destination) 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); + let archivePath = suppliedArchive; + if (archivePath === undefined) { + run(npm, ['pack', '--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); + 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'); @@ -71,15 +80,17 @@ try { 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); + if (browser !== 'none') { + run(process.execPath, [join(root, 'tests', 'e2e', 'build-fixtures.ts')], root); + // Isolated HTTP/file consumers load installed tarball bytes without Vite. + const browserArgs = browser === 'all' ? [] : ['--project', browser]; + execFileSync(process.execPath, [join(root, 'node_modules', 'playwright', 'cli.js'), 'test', ...browserArgs], { + cwd: root, + env: { ...process.env, GOTHIC_DIST_DIRECTORY: packageDist }, + stdio: 'inherit', + }); + } + if (archivePath !== destination) 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 })); diff --git a/tests/unit/artifact.test.ts b/tests/unit/artifact.test.ts deleted file mode 100644 index 9c661cb..0000000 --- a/tests/unit/artifact.test.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { findBenchmarkArtifact, findReleaseArtifact } from '../../.github/scripts/artifact.ts'; - -const repository = 'owner/repo'; -const sha = 'a'.repeat(40); -const run = { - id: 12, run_attempt: 2, path: '.github/workflows/ci.yml', head_sha: sha, - status: 'completed', conclusion: 'success', event: 'push', head_branch: 'main', - repository: { full_name: repository }, head_repository: { full_name: repository }, -}; - -function mockApi(responses: readonly { status?: number; body: unknown }[]) { - const pending = [...responses]; - return vi.spyOn(globalThis, 'fetch').mockImplementation(async () => { - const next = pending.shift(); - if (!next) throw new Error('Unexpected GitHub request'); - return new Response(JSON.stringify(next.body), { status: next.status ?? 200 }); - }); -} - -afterEach(() => vi.restoreAllMocks()); - -describe('CI report lookup for the exact target commit', () => { - it('skips comparison when the target has no CI workflow or report', async () => { - mockApi([{ status: 404, body: { message: 'Not Found' } }]); - await expect(findBenchmarkArtifact(repository, sha)).resolves.toBeNull(); - }); - - it('selects the report from the matching successful run and attempt', async () => { - const api = mockApi([ - { body: { workflow_runs: [run] } }, - { body: { artifacts: [ - { id: 1, name: `benchmark-${sha}-1`, expired: false }, - { id: 2, name: `benchmark-${sha}-2`, expired: false }, - ] } }, - ]); - await expect(findBenchmarkArtifact(repository, sha)).resolves.toEqual({ artifactId: 2, runId: 12, runAttempt: 2, sourceSha: sha }); - expect(String(api.mock.calls[0]?.[0])).toContain(`head_sha=${sha}`); - }); - - it('skips absent or expired reports without substituting another commit', async () => { - mockApi([ - { body: { workflow_runs: [run, { ...run, head_sha: 'b'.repeat(40) }] } }, - { body: { artifacts: [{ id: 2, name: `benchmark-${sha}-2`, expired: true }] } }, - ]); - await expect(findBenchmarkArtifact(repository, sha)).resolves.toBeNull(); - }); - - it('continues through full run and artifact pages to find the exact target report', async () => { - const pageSize = 100; - const runsWithoutReports = Array.from({ length: pageSize }, (_, index) => ({ ...run, id: 1000 + index })); - const otherArtifacts = Array.from({ length: pageSize }, (_, index) => ({ id: 2000 + index, name: `other-${index}`, expired: false })); - const api = mockApi([ - { body: { workflow_runs: runsWithoutReports } }, - ...runsWithoutReports.map(() => ({ body: { artifacts: [] } })), - { body: { workflow_runs: [run] } }, - { body: { artifacts: otherArtifacts } }, - { body: { artifacts: [{ id: 9, name: `benchmark-${sha}-2`, expired: false }] } }, - ]); - - await expect(findBenchmarkArtifact(repository, sha)).resolves.toEqual({ artifactId: 9, runId: 12, runAttempt: 2, sourceSha: sha }); - const requestedUrls = api.mock.calls.map(([url]) => String(url)); - expect(requestedUrls).toContain(`https://api.github.com/repos/${repository}/actions/workflows/ci.yml/runs?head_sha=${sha}&status=success&per_page=100&page=2`); - expect(requestedUrls).toContain(`https://api.github.com/repos/${repository}/actions/runs/12/artifacts?per_page=100&page=2`); - }); - - it('uses an older successful run of the same SHA when the newest report expired', async () => { - const olderRun = { ...run, id: 11, run_attempt: 1 }; - const api = mockApi([ - { body: { workflow_runs: [run, olderRun] } }, - { body: { artifacts: [{ id: 9, name: `benchmark-${sha}-2`, expired: true }] } }, - { body: { artifacts: [{ id: 8, name: `benchmark-${sha}-1`, expired: false }] } }, - ]); - - await expect(findBenchmarkArtifact(repository, sha)).resolves.toEqual({ artifactId: 8, runId: 11, runAttempt: 1, sourceSha: sha }); - expect(String(api.mock.calls.at(-1)?.[0])).toContain('/actions/runs/11/artifacts?'); - }); - - it('does not hide access errors as a missing baseline', async () => { - mockApi([{ status: 403, body: { message: 'Forbidden' } }]); - await expect(findBenchmarkArtifact(repository, sha)).rejects.toThrow('403'); - }); -}); - -describe('release selects only a tested main build', () => { - it('selects an immutable build artifact by run ID', async () => { - mockApi([ - { body: run }, - { body: { artifacts: [{ id: 9, name: `build-${sha}-2`, expired: false }] } }, - ]); - await expect(findReleaseArtifact(repository, '12')).resolves.toEqual({ artifactId: 9, runId: 12, runAttempt: 2, sourceSha: sha }); - }); - - it('accepts a workflow path qualified with its Git ref', async () => { - mockApi([ - { body: { ...run, path: '.github/workflows/ci.yml@main' } }, - { body: { artifacts: [{ id: 9, name: `build-${sha}-2`, expired: false }] } }, - ]); - await expect(findReleaseArtifact(repository, '12')).resolves.toEqual({ artifactId: 9, runId: 12, runAttempt: 2, sourceSha: sha }); - }); - - it.each([ - { event: 'pull_request' }, { head_branch: 'feature' }, { conclusion: 'failure' }, - { path: '.github/workflows/another.yml' }, { head_repository: { full_name: 'fork/repo' } }, - ])('rejects an untrusted or unsuccessful source: %j', async (change) => { - mockApi([{ body: { ...run, ...change } }]); - await expect(findReleaseArtifact(repository, '12')).rejects.toThrow('successful main CI'); - }); - - it('fails when the tested build expired instead of rebuilding it', async () => { - mockApi([{ body: run }, { body: { artifacts: [] } }]); - await expect(findReleaseArtifact(repository, '12')).rejects.toThrow('artifact is unavailable'); - }); -}); diff --git a/tests/unit/release-files.test.ts b/tests/unit/release-files.test.ts deleted file mode 100644 index 0343ba2..0000000 --- a/tests/unit/release-files.test.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { execFileSync } from 'node:child_process'; -import { createHash } from 'node:crypto'; -import { copyFile, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { beforeAll, describe, expect, it } from 'vitest'; -import { prepareArchive, prepareRelease, checkRelease } from '../../.github/scripts/release.ts'; -import { createReport, loadImplementation, runBenchmark, type BenchmarkReport } from '../benchmarks/harness.ts'; -import { statistics } from '../benchmarks/comparison.ts'; - -const sourceSha = 'a'.repeat(40); -const canary = '1.2.3-canary.123.2.aaaaaaaaaaaa'; -const names = ['gothic-lock-solver.mjs', 'gothic-lock-solver.min.mjs', 'gothic-lock-solver.js', 'gothic-lock-solver.min.js', 'gothic-lock-solver.cli.mjs', 'index.d.ts']; -const bytes = 'export const tested = true;\n'; -const digest = createHash('sha256').update(bytes).digest('hex'); -let report: BenchmarkReport; -beforeAll(async () => { - const solve = await loadImplementation(new URL('../../src/index.ts', import.meta.url).pathname); - report = createReport({ source: { revision: sourceSha, dirty: false, moduleSha256: digest }, - environment: { node: process.version, v8: process.versions.v8, platform: process.platform, arch: process.arch, cpu: 'test', logicalCpus: 1, osRelease: 'test', totalMemoryBytes: 1024 }, - settings: { warmups: 0, repetitions: 1, memoryRepetitions: 1 }, results: runBenchmark({ solve, warmups: 0, repetitions: 1 }), - memory: { metric: 'linux-proc-VmHWM', limitations: 'Whole process RSS', browser: { available: false, reason: 'Unavailable' }, - samples: [{ peakRssBytes: 20, startupRssBytes: 10, loadedRssBytes: 12, lockCount: 45, peakRssMetric: 'linux-proc-VmHWM', startupPeakRssBytes: 10, - loadedPeakRssBytes: 12, resourceUsageMaxRssBytes: 30, startupResourceUsageMaxRssBytes: 30, resourceUsageLaunchFloorDetected: true }], peakRssBytes: statistics([20]) } }); -}); - -async function fixture(options: { version?: string; brokenHash?: boolean } = {}) { - const root = await mkdtemp(join(tmpdir(), 'gothic-release-')); - const packed = join(root, 'package'); - await mkdir(join(packed, 'dist'), { recursive: true }); - await writeFile(join(packed, 'package.json'), JSON.stringify({ name: 'gothic-lock-solver', version: options.version ?? '1.2.3', files: ['dist'], - scripts: { prepack: 'exit 91', prepare: 'exit 92', postpack: 'exit 93' } })); - await writeFile(join(packed, 'README.md'), 'Tested package documentation\n'); - await writeFile(join(packed, 'LICENSE'), 'Tested license\n'); - for (const name of names) await writeFile(join(packed, 'dist', name), bytes); - await writeFile(join(packed, 'dist/manifest.json'), JSON.stringify({ schemaVersion: 1, package: 'gothic-lock-solver', version: options.version ?? '1.2.3', sourceSha, - files: Object.fromEntries(names.map(name => [name, options.brokenHash ? '0'.repeat(64) : digest])) })); - const input = join(root, 'tested.tgz'); - execFileSync('tar', ['-czf', input, '-C', root, 'package']); - await copyFile(input, join(root, 'package.tgz')); - await writeFile(join(root, 'benchmark.json'), JSON.stringify(report)); - await writeFile(join(root, 'benchmark.md'), '# All 45 measured locks\n'); - return { root, input, output: join(root, 'prepared.tgz'), runId: '123', runAttempt: '2' }; -} - -function archiveFile(path: string, name: string): Buffer { - return execFileSync('tar', ['-xOzf', path, `package/${name}`]); -} - -describe('publish the tested archive', () => { - it('assigns a canary while preserving the tested runtime bytes', async () => { - const files = await fixture(); - try { - const result = await prepareArchive({ ...files, sourceSha }); - expect(result.version).toBe(canary); - for (const name of names) expect(archiveFile(files.output, `dist/${name}`)).toEqual(archiveFile(files.input, `dist/${name}`)); - } finally { await rm(files.root, { recursive: true, force: true }); } - }); - - it('promotes by repacking only version metadata, keeping every runtime and declaration byte', async () => { - const files = await fixture(); - try { - const result = await prepareArchive({ ...files, sourceSha, version: '2.0.0' }); - expect(result.version).toBe('2.0.0'); - for (const name of [...names.map(name => `dist/${name}`), 'README.md', 'LICENSE']) { - expect(archiveFile(files.output, name)).toEqual(archiveFile(files.input, name)); - } - for (const name of ['package.json', 'dist/manifest.json']) { - const before: unknown = JSON.parse(archiveFile(files.input, name).toString('utf8')); - expect(JSON.parse(archiveFile(files.output, name).toString('utf8'))).toEqual({ ...Object(before), version: '2.0.0' }); - } - } finally { await rm(files.root, { recursive: true, force: true }); } - }); - - it('refuses a valid archive belonging to another source SHA', async () => { - const files = await fixture(); - try { await expect(prepareArchive({ ...files, sourceSha: 'b'.repeat(40) })).rejects.toThrow(/source/i); } - finally { await rm(files.root, { recursive: true, force: true }); } - }); - - it('refuses altered runtime bytes even with unchanged metadata', async () => { - const files = await fixture({ brokenHash: true }); - try { await expect(prepareArchive({ ...files, sourceSha })).rejects.toThrow(/hash|identity/i); } - finally { await rm(files.root, { recursive: true, force: true }); } - }); - - it.each(['1.02.3', '2.0.0-beta', 'latest'])('rejects nonstable promotion version %s', async version => { - const files = await fixture(); - try { await expect(prepareArchive({ ...files, sourceSha, version })).rejects.toThrow(/version/i); } - finally { await rm(files.root, { recursive: true, force: true }); } - }); -}); - -describe('release evidence gate', () => { - it('accepts a validated skipped comparison and preserves both original reports during stable promotion', async () => { - const files = await fixture(); - const output = join(files.root, 'release'); - try { - await prepareRelease({ input: files.root, output, sourceSha, version: '2.0.0' }); - expect(await readFile(join(output, 'benchmark.json'))).toEqual(await readFile(join(files.root, 'benchmark.json'))); - expect(await readFile(join(output, 'benchmark.md'))).toEqual(await readFile(join(files.root, 'benchmark.md'))); - expect(await checkRelease(output, sourceSha)).toMatchObject({ kind: 'stable', version: '2.0.0', sourceSha }); - const notes = await readFile(join(output, 'release-notes.md'), 'utf8'); - expect(notes).toContain('Benchmark quality: **passed**; 45 locks.'); - expect(notes).toContain(`A=${report.totals.A}`); - expect(notes).toContain(`C=${report.totals.C}`); - expect(notes).toMatch(/Comparison: skipped.*exact target SHA/); - await writeFile(join(output, 'gothic-lock-solver.js'), 'tampered'); - await expect(checkRelease(output, sourceSha)).rejects.toThrow(/identity|hash/i); - } finally { await rm(files.root, { recursive: true, force: true }); } - }); - - it('states the compared target SHA and measured deterministic drift in the release body', async () => { - const files = await fixture(); - const output = join(files.root, 'release'); - try { - const baseline = structuredClone(report); - baseline.source.revision = 'b'.repeat(40); - const row = baseline.results[0]; - if (row === undefined) throw new Error('Missing first fixture'); - row.C += 2; - await writeFile(join(files.root, 'benchmark.json'), JSON.stringify(createReport({ ...report, baseline }))); - await prepareRelease({ input: files.root, output, sourceSha, version: '2.0.0' }); - const notes = await readFile(join(output, 'release-notes.md'), 'utf8'); - expect(notes).toContain(`Comparison: compared with target SHA \`${'b'.repeat(40)}\`.`); - expect(notes).toContain('Benchmark quality: **passed**; 45 locks.'); - expect(notes).toContain('ΔA=0'); - expect(notes).toContain('ΔC=-2'); - } finally { await rm(files.root, { recursive: true, force: true }); } - }); - - it('rejects a report for another measured module', async () => { - const files = await fixture(); - try { - await writeFile(join(files.root, 'benchmark.json'), JSON.stringify({ ...report, source: { ...report.source, moduleSha256: '0'.repeat(64) } })); - await expect(prepareRelease({ input: files.root, output: join(files.root, 'release'), sourceSha, runId: '123', runAttempt: '2' })).rejects.toThrow(/module/i); - } finally { await rm(files.root, { recursive: true, force: true }); } - }); - - it('rejects deterministic quality regressions without an acceptance bypass', async () => { - const files = await fixture(); - try { - const baseline = structuredClone(report); - const row = baseline.results[0]; - if (row === undefined) throw new Error('Missing first fixture'); - row.C -= 1; - const regression = createReport({ ...report, baseline }); - await writeFile(join(files.root, 'benchmark.json'), JSON.stringify(regression)); - await expect(prepareRelease({ input: files.root, output: join(files.root, 'release'), sourceSha, runId: '123', runAttempt: '2' })).rejects.toThrow(/quality/i); - } finally { await rm(files.root, { recursive: true, force: true }); } - }); -}); diff --git a/tests/unit/release-network.test.ts b/tests/unit/release-network.test.ts deleted file mode 100644 index b0d2248..0000000 --- a/tests/unit/release-network.test.ts +++ /dev/null @@ -1,165 +0,0 @@ -import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'; -import { mkdtemp, rm, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { publishNpm, publishGithub, verifyCdn, registryMetadata, sha256, runtimeFiles, type ReleaseManifest } from '../../.github/scripts/release.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 = 'exact prepared archive'; -const manifest: ReleaseManifest = { schemaVersion: 1, version: '1.2.3', sourceSha: 'a'.repeat(40), kind: 'stable', - assets: { 'package.tgz': sha256(archive), ...Object.fromEntries(runtimeFiles.map(name => [name, sha256(name)])) } }; -const metadata = { name: 'gothic-lock-solver', version: manifest.version, dist: { tarball: 'https://registry.npmjs.org/gothic-lock-solver/-/gothic-lock-solver-1.2.3.tgz' } }; -const response = (value: unknown) => new Response(JSON.stringify(value), { headers: { 'content-type': 'application/json' } }); -beforeEach(() => { command.mockReset(); fetchMock.mockReset(); vi.stubGlobal('fetch', fetchMock); }); -afterEach(() => { vi.unstubAllGlobals(); vi.unstubAllEnvs(); }); - -describe('immutable npm and public CDN delivery', () => { - it('distinguishes a missing npm version from a registry outage', async () => { - fetchMock.mockResolvedValueOnce(new Response('', { status: 404 })); - expect(await registryMetadata('1.2.3')).toBeNull(); - fetchMock.mockResolvedValueOnce(new Response('', { status: 503 })); - await expect(registryMetadata('1.2.3')).rejects.toThrow(/503/); - }); - it('publishes the prepared archive with the chosen dist-tag and verifies registry bytes', async () => { - fetchMock.mockResolvedValueOnce(new Response('', { status: 404 })).mockResolvedValueOnce(response(metadata)).mockResolvedValueOnce(new Response(archive)); - await publishNpm('/prepared', manifest); - expect(command).toHaveBeenCalledWith('npm', ['publish', '/prepared/package.tgz', '--access', 'public', '--tag', 'latest', '--ignore-scripts', '--registry', 'https://registry.npmjs.org/'], expect.objectContaining({ stdio: 'inherit' })); - }); - it('resumes an identical npm version without overwriting it', async () => { - fetchMock.mockResolvedValueOnce(response(metadata)).mockResolvedValueOnce(new Response(archive)); - await publishNpm('/prepared', manifest); - expect(command).not.toHaveBeenCalled(); - }); - it('refuses different bytes under an existing npm version', async () => { - fetchMock.mockResolvedValueOnce(response(metadata)).mockResolvedValueOnce(new Response('changed')); - await expect(publishNpm('/prepared', manifest)).rejects.toThrow(/identity/i); - expect(command).not.toHaveBeenCalled(); - }); - it('checks all five public CDN files against the tested bytes and browser delivery headers', async () => { - fetchMock.mockImplementation(async input => { - const filename = String(input).split('/').at(-1) ?? ''; - return new Response(filename, { headers: { 'content-type': 'application/javascript', 'access-control-allow-origin': '*' } }); - }); - await verifyCdn(manifest); - expect(fetchMock).toHaveBeenCalledTimes(5); - }); - it.each([ - { type: 'text/plain', cors: '*', bytes: 'gothic-lock-solver.mjs', error: /headers/ }, - { type: 'application/javascript', cors: 'https://elsewhere.test', bytes: 'gothic-lock-solver.mjs', error: /headers/ }, - { type: 'application/javascript', cors: '*', bytes: 'changed', error: /identity/ }, - ])('refuses bad CDN headers or bytes: $type/$cors/$bytes', async value => { - fetchMock.mockResolvedValue(new Response(value.bytes, { headers: { 'content-type': value.type, 'access-control-allow-origin': value.cors } })); - await expect(verifyCdn(manifest)).rejects.toThrow(value.error); - }); -}); - -describe('GitHub release identity', () => { - it.each(['canary', 'stable'] as const)('attaches the verified assets and changes latest only for %s', async kind => { - const directory = await mkdtemp(join(tmpdir(), 'gothic-github-')); - try { - vi.stubEnv('GH_TOKEN', 'unit-test-token'); - const chosen = { ...manifest, kind }; - const bytes = JSON.stringify(chosen); - await writeFile(join(directory, 'release-manifest.json'), bytes); - const assets = { ...chosen.assets, 'release-manifest.json': sha256(bytes) }; - const release = { draft: false, prerelease: kind === 'canary', assets: Object.entries(assets).map(([name, hash]) => ({ name, digest: `sha256:${hash}` })) }; - let created = false; - fetchMock.mockImplementation(async input => { - if (String(input).includes('/commits/')) return response({ sha: chosen.sourceSha }); - return created ? response(release) : new Response('', { status: 404 }); - }); - command.mockImplementation((file, args) => { - expect(file).toBe('gh'); - if (args[0] === 'api' && args[1] === 'graphql') { - expect(args).toContain('owner=owner'); - expect(args).toContain('name=repo'); - expect(args).toContain('tag=v1.2.3'); - return JSON.stringify(created ? 42 : null); - } - expect(args).toContain('--verify-tag'); - expect(args).toContain(`--latest=${kind === 'stable'}`); - expect(args.includes('--prerelease')).toBe(kind === 'canary'); - for (const name of Object.keys(assets)) expect(args).toContain(join(directory, name)); - created = true; - return ''; - }); - await publishGithub(directory, chosen, 'owner/repo'); - expect(created).toBe(true); - command.mockClear(); - await publishGithub(directory, chosen, 'owner/repo'); - expect(command.mock.calls.every(([, args]) => args[0] === 'api' && args[1] === 'graphql')).toBe(true); - } finally { await rm(directory, { recursive: true, force: true }); } - }); - - it('refuses an existing tag for another source before creating a release', async () => { - vi.stubEnv('GH_TOKEN', 'unit-test-token'); - fetchMock.mockImplementation(async input => String(input).includes('/commits/') ? response({ sha: 'b'.repeat(40) }) : new Response('', { status: 404 })); - await expect(publishGithub('/prepared', manifest, 'owner/repo')).rejects.toThrow(/source/i); - expect(command).not.toHaveBeenCalled(); - }); - - it.each(['canary', 'stable'] as const)('finishes a matching partial %s draft without overwriting uploaded assets', async kind => { - const directory = await mkdtemp(join(tmpdir(), 'gothic-draft-')); - try { - vi.stubEnv('GH_TOKEN', 'unit-test-token'); - const chosen = { ...manifest, kind }; - const bytes = JSON.stringify(chosen); - await writeFile(join(directory, 'release-manifest.json'), bytes); - const hashes = { ...chosen.assets, 'release-manifest.json': sha256(bytes) }; - const complete = Object.entries(hashes).map(([name, hash]) => ({ name, digest: `sha256:${hash}` })); - const missing = 'gothic-lock-solver.js'; - const release = { draft: true, prerelease: kind === 'canary', assets: complete.filter(asset => asset.name !== missing) }; - fetchMock.mockImplementation(async input => { - const url = String(input); - if (url.includes('/commits/')) return response({ sha: chosen.sourceSha }); - // The tag endpoint exposes published releases; drafts are read by ID. - if (url.includes('/releases/tags/')) return new Response('', { status: 404 }); - expect(url).toBe('https://api.github.com/repos/owner/repo/releases/42'); - return response(release); - }); - command.mockImplementation((file, args) => { - expect(file).toBe('gh'); - if (args[0] === 'api' && args[1] === 'graphql') return '42'; - if (args[1] === 'upload') { - expect(args).toEqual(['release', 'upload', 'v1.2.3', join(directory, missing), '--repo', 'owner/repo']); - release.assets = complete; - return ''; - } - expect(args).toEqual(['release', 'edit', 'v1.2.3', '--repo', 'owner/repo', '--draft=false', `--latest=${kind === 'stable'}`]); - expect(release.assets).toEqual(complete); - release.draft = false; - return ''; - }); - await publishGithub(directory, chosen, 'owner/repo'); - expect(release.draft).toBe(false); - expect(release.assets).toEqual(complete); - } finally { await rm(directory, { recursive: true, force: true }); } - }); - - it.each(['mismatched digest', 'unexpected name'])('refuses a draft with an %s before any upload or publication', async defect => { - const directory = await mkdtemp(join(tmpdir(), 'gothic-draft-')); - try { - vi.stubEnv('GH_TOKEN', 'unit-test-token'); - await writeFile(join(directory, 'release-manifest.json'), JSON.stringify(manifest)); - const asset = defect === 'mismatched digest' - ? { name: 'package.tgz', digest: `sha256:${'0'.repeat(64)}` } - : { name: 'unexpected.js', digest: `sha256:${sha256('unexpected')}` }; - const release = { draft: true, prerelease: false, assets: [asset] }; - fetchMock.mockImplementation(async input => { - const url = String(input); - if (url.includes('/commits/')) return response({ sha: manifest.sourceSha }); - if (url.includes('/releases/tags/')) return new Response('', { status: 404 }); - return response(release); - }); - command.mockImplementation((file, args) => { - expect(file).toBe('gh'); - expect(args.slice(0, 2)).toEqual(['api', 'graphql']); - return '42'; - }); - await expect(publishGithub(directory, manifest, 'owner/repo')).rejects.toThrow(/identity|Unexpected/); - expect(command.mock.calls.every(([, args]) => args[0] === 'api' && args[1] === 'graphql')).toBe(true); - } finally { await rm(directory, { recursive: true, force: true }); } - }); -}); diff --git a/tests/unit/release.test.ts b/tests/unit/release.test.ts deleted file mode 100644 index bc0a4fd..0000000 --- a/tests/unit/release.test.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { canaryVersion } from '../../.github/scripts/release.ts'; - -describe('release version identity', () => { - it('derives unique canaries from the successful CI run without modifying source metadata', () => { - expect(canaryVersion('1.2.3', 'a'.repeat(40), '123', '2')).toBe('1.2.3-canary.123.2.aaaaaaaaaaaa'); - expect(canaryVersion('1.2.3', 'a'.repeat(40), '123', '3')).toBe('1.2.3-canary.123.3.aaaaaaaaaaaa'); - }); - it('rejects malformed source, run and base version identities', () => { - for (const [version, sha, run, attempt] of [['1.2.3', 'wrong', '123', '2'], ['1.2.3', 'a'.repeat(40), '0', '2'], - ['1.2.3', 'a'.repeat(40), '123', '01'], ['1.2.3-beta', 'a'.repeat(40), '123', '2']]) { - if (!version || !sha || !run || !attempt) throw new Error('Missing test input'); - expect(() => canaryVersion(version, sha, run, attempt)).toThrow(/identity|version/i); - } - }); -}); diff --git a/tsconfig.json b/tsconfig.json index d42c2ed..450f8c7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -33,7 +33,6 @@ "include": [ "src/**/*.ts", "tests/**/*.ts", - ".github/scripts/**/*.ts", "*.config.ts" ], "exclude": [ diff --git a/vite.config.ts b/vite.config.ts index f5a4d98..10a141c 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,22 +1,56 @@ +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { readFile, readdir, writeFile } from 'node:fs/promises'; +import { isBuiltin } from 'node:module'; import { fileURLToPath } from 'node:url'; import { defineConfig } from 'vite'; +import metadata from './package.json' with { type: 'json' }; + +async function writeDistributionManifest(): Promise { + const dist = new URL('./dist/', import.meta.url); + await writeFile(new URL('global.d.ts', dist), [ + '/** Types for the classic GothicLockSolver global. */', + 'export as namespace GothicLockSolver;', + "export * from './index.js';", + '', + ].join('\n')); + + const files: Record = {}; + for (const filename of (await readdir(dist)).sort()) { + if (filename === 'manifest.json') continue; + const bytes = await readFile(new URL(filename, dist)); + files[filename] = createHash('sha256').update(bytes).digest('hex'); + } + const manifest = { + schemaVersion: 1, + package: metadata.name, + version: metadata.version, + sourceSha: execFileSync('git', ['rev-parse', 'HEAD'], { cwd: import.meta.dirname, encoding: 'utf8' }).trim(), + files, + }; + await writeFile(new URL('manifest.json', dist), `${JSON.stringify(manifest, null, 2)}\n`); +} export default defineConfig(({ mode }) => { const minified = mode === 'minified'; + const cli = mode === 'cli'; return { + plugins: cli ? [{ name: 'distribution-manifest', closeBundle: writeDistributionManifest }] : [], build: { - target: 'es2022', + target: cli ? 'node22' : 'es2022', outDir: 'dist', - emptyOutDir: false, + emptyOutDir: !minified && !cli, sourcemap: false, minify: false, lib: { - entry: fileURLToPath(new URL('./src/index.ts', import.meta.url)), + entry: fileURLToPath(new URL(cli ? './src/cli.ts' : './src/index.ts', import.meta.url)), name: 'GothicLockSolver', - formats: ['es', 'iife'], - fileName: (format) => `gothic-lock-solver${minified ? '.min' : ''}.${format === 'es' ? 'mjs' : 'js'}`, + formats: cli ? ['es'] : ['es', 'iife'], + fileName: (format) => cli ? 'gothic-lock-solver.cli.mjs' + : `gothic-lock-solver${minified ? '.min' : ''}.${format === 'es' ? 'mjs' : 'js'}`, }, rolldownOptions: { + external: cli ? isBuiltin : [], output: { // Explicit full Rolldown minification includes ESM whitespace, unlike // the Vite library-mode build.minify shortcut. diff --git a/wiki/benchmarks.md b/wiki/benchmarks.md index dc12977..af07f52 100644 --- a/wiki/benchmarks.md +++ b/wiki/benchmarks.md @@ -27,8 +27,9 @@ are errors, not missing baselines. A supplied report must match its claimed SHA, current schema and fixed catalog. Its commands are replayed and its recorded deterministic metrics checked before -comparison. Artifacts include the CI attempt in their names so reruns do not -silently replace an earlier report. +comparison. The canonical `benchmark-SHA` artifact belongs to the selected CI +run. Rerunning the build replaces its report; rerunning only failed test rows +reuses the successful build and its report. ## Metrics and limits diff --git a/wiki/development.md b/wiki/development.md index 1a12a34..f688a0d 100644 --- a/wiki/development.md +++ b/wiki/development.md @@ -1,6 +1,6 @@ # Development -Use pnpm and maintained Node.js 24 or 26 for source development. Exact versions +Use pnpm and maintained Node.js 22, 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. @@ -24,6 +24,17 @@ Narrower commands: `pnpm check`, `pnpm test:node`, `pnpm test:coverage`, versions: `pnpm verify:package --node /path/to/node22 --node /path/to/node26`. Run [benchmarks](benchmarks.md) separately, after competing heavy tasks stop. +CI builds and benchmarks once on Node 24, then distributes the same archive to +six Ubuntu matrix rows: Node 22/24/26 and Chromium/Firefox/WebKit. Each row runs +its selected Vitest project and verifies the installed archive. Browser rows use +Node 24 to drive their static-page and installed-package E2E tests; the Node 24 +row collects coverage. All rows must pass before release. + +To verify an existing build without repacking, use +`pnpm verify:package --archive /path/to/package.tgz --browser chromium`. +The browser selector accepts `none`, `chromium`, `firefox` or `webkit`; omitting +it runs all three browsers. The archive must match the checkout and its `dist/`. + ## Structure `src/index.ts` and `src/cli.ts` are build entries for integrations and Node tooling. @@ -34,12 +45,15 @@ priority queue each own one responsibility. Config/default validation lives in 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. +steps. Oxlint limits block nesting to three levels in source and Vite configuration. 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. +production files. `pnpm build` invokes Vite and TypeScript directly; all three +Vite modes live in `vite.config.ts`. Its final build hook emits only the global +declaration and source/checksum manifest. CI and publication use explicit commands +in workflow YAML, without a scripts directory or repository orchestration wrappers. The 45 inputs use independent replay and mathematical expected minima. A separate exhaustive small-state oracle helps expose shared defects. Tests assert behavior, @@ -64,4 +78,5 @@ observations, not paired performance guarantees. 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). +[coverage](https://vitest.dev/guide/coverage.html), [Playwright browsers](https://playwright.dev/docs/browsers), +[GitHub matrices](https://docs.github.com/en/actions/how-tos/write-workflows/choose-what-workflows-do/run-job-variations). diff --git a/wiki/releasing.md b/wiki/releasing.md index d407481..9a98aae 100644 --- a/wiki/releasing.md +++ b/wiki/releasing.md @@ -1,14 +1,27 @@ # CI and releases -Two workflows, one job each: +Two workflows. CI has a build job and a test matrix: | Workflow | Trigger | Result | | --- | --- | --- | -| `ci.yml` | Pull request or push to main | Checks, five builds, tested package, benchmark report | +| `ci.yml` | Pull request or push to main | One build and benchmark; six Node/browser test environments | | `release.yml` | Successful main-push CI | Automatic npm canary and GitHub prerelease | | `release.yml` | Manual dispatch | Stable version from a selected successful main CI run | -CI runs the local commands. It downloads a benchmark baseline only when a report +Build commands invoke Vite and TypeScript directly. CI and release coordination +lives in the two workflow files; there are no repository script wrappers. + +The Node 24 build job runs lint, type checking, the build, distribution checks +and benchmarks. Six Ubuntu matrix rows then test the same package archive: +Node 22/24/26 and Chromium/Firefox/WebKit. Browser rows use Node 24 as their test +driver and install only the selected browser. The matrix does not rebuild the +distribution. Diagnostics identify the Node version and test environment. +The build and every matrix row must succeed before automatic canary publication. +Within a CI run, `build-SHA` and `benchmark-SHA` identify its canonical artifacts. +Rerunning failed tests reuses the successful build; rerunning the build replaces +its artifacts through GitHub's `overwrite` option. + +CI downloads a benchmark baseline only when a report exists for the exact target SHA; otherwise comparison is skipped. See [benchmarks](benchmarks.md). All build output, reports, coverage and browser diagnostics remain CI artifacts. @@ -39,7 +52,7 @@ The five runtime files are not rebuilt. The package is published under npm's `canary` tag and attached to a GitHub prerelease. Stable `latest` is unchanged. The full JSON/Markdown benchmark report, -source identity and file hashes accompany the release. +source identity and SHA256SUMS accompany the release. ## Manual stable release @@ -55,10 +68,11 @@ version into a stable SemVer, so stable delivery needs this metadata-only repack The resulting npm version uses `latest` and a regular GitHub Release. If the artifact expired, run CI again before releasing. No source rebuild or -fallback artifact is hidden inside the release workflow. Existing npm versions -must match the prepared bytes; a retry never overwrites a published version. -A matching GitHub draft resumes by uploading missing assets, checking their hashes -and publishing the completed draft. Existing complete releases remain unchanged. +fallback artifact is hidden inside the release workflow. Publication uses ordinary `npm publish` and `gh release create` commands. +A duplicate npm version or existing GitHub tag stops publication. If delivery +fails after npm succeeds, inspect the saved release artifact and complete the +remaining delivery manually; rerunning the entire job is not an automatic recovery +mechanism. Published npm versions are never overwritten. ## Browser delivery @@ -72,7 +86,8 @@ upload is needed. Pin the complete version in integrations: ``` -The release verifies npm archive identity and CDN bytes/headers. CI has already +The release verifies the tested archive before repacking, compares the repacked +files, and checks published CDN bytes/headers. CI has already checked the identical runtime files in Chromium, Firefox and WebKit. The fifth file is the standalone Node CLI. Release assets include all five runtime files, the npm archive and the two benchmark reports. From c6662611494f2b2181329b16dc212f5e9b84be7f Mon Sep 17 00:00:00 2001 From: Ilya Konstantinov Date: Tue, 8 Sep 2026 09:09:08 +0400 Subject: [PATCH 7/7] ci: simplify build and release to direct GitHub and npm commands --- .github/workflows/ci.yml | 68 ++------ .github/workflows/release.yml | 158 +++++------------- .../plans/2026-09-07-simplify-library.md | 12 +- wiki/benchmarks.md | 3 +- wiki/releasing.md | 25 +-- 5 files changed, 77 insertions(+), 189 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 551e82d..206508e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,62 +47,23 @@ jobs: pnpm check pnpm build pnpm test:distribution - - name: Install official GitHub CLI - 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 --no-same-owner -xzf "$RUNNER_TEMP/gh.tar.gz" -C "$RUNNER_TEMP" gh_2.100.0_linux_amd64/bin/gh - echo "$RUNNER_TEMP/gh_2.100.0_linux_amd64/bin" >> "$GITHUB_PATH" - - name: Find the exact target SHA report - id: baseline + - name: Benchmark against the exact target SHA when available env: GH_TOKEN: ${{ github.token }} run: | - if [[ -z "$TARGET_SHA" || "$TARGET_SHA" == 0000000000000000000000000000000000000000 ]]; then - echo "available=false" >> "$GITHUB_OUTPUT" - echo "No target commit; benchmark comparison skipped." - exit 0 - fi - runs=$(gh api --paginate "repos/$GITHUB_REPOSITORY/actions/runs?head_sha=$TARGET_SHA&status=success&per_page=100" --jq ' - .workflow_runs[] - | select(.head_sha == env.TARGET_SHA - and .repository.full_name == env.GITHUB_REPOSITORY - and .head_repository.full_name == env.GITHUB_REPOSITORY - and .status == "completed" and .conclusion == "success" - and (.path | split("@")[0]) == ".github/workflows/ci.yml") - | .id') - while read -r run_id; do - [[ -n "$run_id" ]] || continue - artifact_id=$(gh api --paginate "repos/$GITHUB_REPOSITORY/actions/runs/$run_id/artifacts?per_page=100" --jq ".artifacts[] | select(.name == \"benchmark-$TARGET_SHA\" and .expired == false) | .id") - if [[ -n "$artifact_id" ]]; then - echo "available=true" >> "$GITHUB_OUTPUT" - echo "artifact-id=$artifact_id" >> "$GITHUB_OUTPUT" - echo "run-id=$run_id" >> "$GITHUB_OUTPUT" - exit 0 + args=() + if [[ -n "$TARGET_SHA" && "$TARGET_SHA" != 0000000000000000000000000000000000000000 ]]; then + args+=(--baseline-sha "$TARGET_SHA") + run_id=$(gh run list --repo "$GITHUB_REPOSITORY" --workflow ci.yml --commit "$TARGET_SHA" --status success --limit 1 --json databaseId --jq '.[0].databaseId // empty') + if [[ -n "$run_id" ]]; then + artifact_id=$(gh api --paginate "repos/$GITHUB_REPOSITORY/actions/runs/$run_id/artifacts" --jq ".artifacts[] | select(.name == \"benchmark-$TARGET_SHA\" and .expired == false) | .id") + if [[ -n "$artifact_id" ]]; then + gh run download "$run_id" --repo "$GITHUB_REPOSITORY" --name "benchmark-$TARGET_SHA" --dir "$RUNNER_TEMP/baseline" + args+=(--baseline-report "$RUNNER_TEMP/baseline/benchmark.json") + fi fi - done <<< "$runs" - echo "available=false" >> "$GITHUB_OUTPUT" - echo "No report for the exact target SHA; benchmark comparison skipped." - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - if: steps.baseline.outputs.available == 'true' - with: - artifact-ids: ${{ steps.baseline.outputs.artifact-id }} - run-id: ${{ steps.baseline.outputs.run-id }} - github-token: ${{ github.token }} - path: ${{ runner.temp }}/baseline - - name: Measure all 45 locks - env: - BASELINE_AVAILABLE: ${{ steps.baseline.outputs.available }} - run: | - if [ "$BASELINE_AVAILABLE" = true ]; then - pnpm benchmark --baseline-report "$RUNNER_TEMP/baseline/benchmark.json" --baseline-sha "$TARGET_SHA" - elif [[ -n "$TARGET_SHA" && "$TARGET_SHA" != 0000000000000000000000000000000000000000 ]]; then - pnpm benchmark --baseline-sha "$TARGET_SHA" - else - pnpm benchmark fi + pnpm benchmark "${args[@]}" - name: Show the benchmark report if: always() run: test ! -f artifacts/benchmark/benchmark.md || cat artifacts/benchmark/benchmark.md >> "$GITHUB_STEP_SUMMARY" @@ -113,13 +74,12 @@ jobs: overwrite: true path: artifacts/benchmark/ retention-days: 90 - - name: Collect the tested distribution + - name: Pack the built distribution run: | mkdir "$RUNNER_TEMP/package" - mkdir -p artifacts/build npm pack --ignore-scripts --pack-destination "$RUNNER_TEMP/package" + mkdir -p artifacts/build mv "$RUNNER_TEMP/package/"*.tgz artifacts/build/package.tgz - cp dist/gothic-lock-solver.{mjs,min.mjs,js,min.js,cli.mjs} artifacts/build/ cp artifacts/benchmark/benchmark.{json,md} artifacts/build/ - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 58abb64..e0e6522 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,11 +8,11 @@ on: workflow_dispatch: inputs: ci_run_id: - description: Successful main-push CI run containing the tested package + description: Successful main-push CI run type: string required: true version: - description: Stable SemVer to publish, for example 1.0.0 + description: Stable version, for example 1.0.0 type: string required: true @@ -38,6 +38,9 @@ jobs: id-token: write env: GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + CI_RUN_ID: ${{ inputs.ci_run_id || github.event.workflow_run.id }} + STABLE_VERSION: ${{ inputs.version }} defaults: run: shell: bash @@ -46,138 +49,55 @@ jobs: with: node-version: 24.19.0 registry-url: https://registry.npmjs.org - - name: Install official publication CLIs - env: - GH_ARCHIVE_SHA256: e4d4bb4498e8d007abe545b6568926793ace1b6447da598294a610018cb164be + - run: npm install --global --ignore-scripts npm@12.0.2 + - name: Download the successful main CI package run: | - npm install --global --ignore-scripts npm@12.0.2 - 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" - - name: Select the successful main CI build - id: build - env: - REQUESTED_RUN: ${{ inputs.ci_run_id || github.event.workflow_run.id }} + [[ "$CI_RUN_ID" =~ ^[1-9][0-9]*$ ]] + gh api "repos/$GH_REPO/actions/runs/$CI_RUN_ID" > "$RUNNER_TEMP/run.json" + SOURCE_SHA=$(jq -er --arg repo "$GH_REPO" ' + select(.repository.full_name == $repo and .head_repository.full_name == $repo and + .event == "push" and .head_branch == "main" and .status == "completed" and .conclusion == "success" and + (.path | split("@")[0]) == ".github/workflows/ci.yml" and (.head_sha | test("^[a-f0-9]{40}$"))) | .head_sha + ' "$RUNNER_TEMP/run.json") + gh run download "$CI_RUN_ID" --repo "$GH_REPO" --name "build-$SOURCE_SHA" --dir "$RUNNER_TEMP/build" + echo "SOURCE_SHA=$SOURCE_SHA" >> "$GITHUB_ENV" + echo "CI_RUN_ATTEMPT=$(jq -r .run_attempt "$RUNNER_TEMP/run.json")" >> "$GITHUB_ENV" + - name: Prepare version metadata and release files run: | - [[ "$REQUESTED_RUN" =~ ^[1-9][0-9]*$ ]] - gh api "repos/$GITHUB_REPOSITORY/actions/runs/$REQUESTED_RUN" > "$RUNNER_TEMP/run.json" - jq -e --arg repo "$GITHUB_REPOSITORY" ' - .repository.full_name == $repo and .head_repository.full_name == $repo and - .event == "push" and .head_branch == "main" and .status == "completed" and .conclusion == "success" and - (.path | split("@")[0]) == ".github/workflows/ci.yml" and - (.head_sha | test("^[a-f0-9]{40}$")) and .run_attempt >= 1 - ' "$RUNNER_TEMP/run.json" - SOURCE_SHA=$(jq -r .head_sha "$RUNNER_TEMP/run.json") - RUN_ATTEMPT=$(jq -r .run_attempt "$RUNNER_TEMP/run.json") - ARTIFACT_NAME="build-$SOURCE_SHA" - gh api "repos/$GITHUB_REPOSITORY/actions/runs/$REQUESTED_RUN/artifacts?per_page=100" --paginate | - jq -res --arg name "$ARTIFACT_NAME" ' - [.[].artifacts[] | select(.name == $name and .expired == false)] | - if length == 1 then .[0].id else error("Expected one unexpired CI build") end - ' > "$RUNNER_TEMP/artifact-id" - echo "artifact-id=$(cat "$RUNNER_TEMP/artifact-id")" >> "$GITHUB_OUTPUT" - echo "run-id=$REQUESTED_RUN" >> "$GITHUB_OUTPUT" - echo "run-attempt=$RUN_ATTEMPT" >> "$GITHUB_OUTPUT" - echo "source-sha=$SOURCE_SHA" >> "$GITHUB_OUTPUT" - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - artifact-ids: ${{ steps.build.outputs.artifact-id }} - run-id: ${{ steps.build.outputs.run-id }} - github-token: ${{ github.token }} - path: ${{ runner.temp }}/build - - name: Assign version without rebuilding tested files - id: release - env: - SOURCE_SHA: ${{ steps.build.outputs.source-sha }} - CI_RUN_ID: ${{ steps.build.outputs.run-id }} - CI_RUN_ATTEMPT: ${{ steps.build.outputs.run-attempt }} - STABLE_VERSION: ${{ inputs.version }} - run: | - mkdir -p "$RUNNER_TEMP/original" "$RUNNER_TEMP/repacked" "$RUNNER_TEMP/release" - tar -xzf "$RUNNER_TEMP/build/package.tgz" -C "$RUNNER_TEMP/original" --no-same-owner - cp -a "$RUNNER_TEMP/original/package" "$RUNNER_TEMP/package" + tar -xzf "$RUNNER_TEMP/build/package.tgz" -C "$RUNNER_TEMP" cd "$RUNNER_TEMP/package" - BASE_VERSION=$(jq -er 'select(.name == "gothic-lock-solver") | .version' package.json) - [[ "$BASE_VERSION" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] - jq -e --arg sha "$SOURCE_SHA" --arg version "$BASE_VERSION" ' - .schemaVersion == 1 and .package == "gothic-lock-solver" and - .sourceSha == $sha and .version == $version - ' dist/manifest.json - jq -r '.files | to_entries[] | "\(.value) dist/\(.key)"' dist/manifest.json | sha256sum --check --strict - MODULE_SHA=$(sha256sum dist/gothic-lock-solver.mjs | cut -d ' ' -f 1) - jq -e --arg sha "$SOURCE_SHA" --arg module "$MODULE_SHA" ' - .schemaVersion == 2 and .source.revision == $sha and .source.dirty == false and - .source.moduleSha256 == $module and .quality == "passed" and .fixtures.count == 45 - ' "$RUNNER_TEMP/build/benchmark.json" + BASE_VERSION=$(jq -r .version package.json) VERSION="${BASE_VERSION}-canary.${CI_RUN_ID}.${CI_RUN_ATTEMPT}.${SOURCE_SHA:0:12}" - TAG=canary + NPM_TAG=canary if [[ "$GITHUB_EVENT_NAME" == workflow_dispatch ]]; then [[ "$STABLE_VERSION" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] VERSION="$STABLE_VERSION" - TAG=latest + NPM_TAG=latest fi for file in package.json dist/manifest.json; do jq --arg version "$VERSION" '.version = $version' "$file" > "$file.tmp" mv "$file.tmp" "$file" done + mkdir "$RUNNER_TEMP/release" npm pack --ignore-scripts --silent --pack-destination "$RUNNER_TEMP/release" - archives=("$RUNNER_TEMP/release/"*.tgz) - [[ ${#archives[@]} == 1 && -f "${archives[0]}" ]] - mv "${archives[0]}" "$RUNNER_TEMP/release/package.tgz" - tar -xzf "$RUNNER_TEMP/release/package.tgz" -C "$RUNNER_TEMP/repacked" --no-same-owner - diff -r "$RUNNER_TEMP/package" "$RUNNER_TEMP/repacked/package" - runtime=(dist/*.js dist/*.mjs) - [[ ${#runtime[@]} == 5 ]] - cp "${runtime[@]}" "$RUNNER_TEMP/release/" + mv "$RUNNER_TEMP/release/gothic-lock-solver-$VERSION.tgz" "$RUNNER_TEMP/release/package.tgz" + cp dist/*.js dist/*.mjs "$RUNNER_TEMP/release/" cp "$RUNNER_TEMP/build/benchmark.json" "$RUNNER_TEMP/build/benchmark.md" "$RUNNER_TEMP/release/" - cd "$RUNNER_TEMP/release" - sha256sum package.tgz *.js *.mjs benchmark.json benchmark.md > SHA256SUMS { - printf '# Gothic Lock Solver %s\n\nTested source: `%s`.\n\n' "$VERSION" "$SOURCE_SHA" - printf 'Install: `npm install gothic-lock-solver@%s`.\n\n' "$VERSION" - printf 'Browser module: https://cdn.jsdelivr.net/npm/gothic-lock-solver@%s/dist/gothic-lock-solver.min.mjs\n\n' "$VERSION" - printf 'Classic script: https://cdn.jsdelivr.net/npm/gothic-lock-solver@%s/dist/gothic-lock-solver.min.js\n\n' "$VERSION" - cat benchmark.md - } > release-notes.md - cat release-notes.md >> "$GITHUB_STEP_SUMMARY" - echo "version=$VERSION" >> "$GITHUB_OUTPUT" - echo "tag=$TAG" >> "$GITHUB_OUTPUT" - - name: Publish the tested archive to npm - env: - VERSION: ${{ steps.release.outputs.version }} - TAG: ${{ steps.release.outputs.tag }} - run: | - gh api "repos/$GITHUB_REPOSITORY/git/matching-refs/tags/v$VERSION" | - jq -e --arg ref "refs/tags/v$VERSION" 'all(.[]; .ref != $ref)' - npm publish "$RUNNER_TEMP/release/package.tgz" --access public --tag "$TAG" --ignore-scripts --registry https://registry.npmjs.org - - name: Verify public CDN files - env: - VERSION: ${{ steps.release.outputs.version }} - run: | - cd "$RUNNER_TEMP/release" - for file in *.js *.mjs; do - curl --fail --location --retry 5 --retry-all-errors --retry-delay 5 --max-time 30 \ - --dump-header "$RUNNER_TEMP/cdn-headers" \ - "https://cdn.jsdelivr.net/npm/gothic-lock-solver@$VERSION/dist/$file" -o "$RUNNER_TEMP/$file" - grep -Eiq '^content-type: (application|text)/javascript' "$RUNNER_TEMP/cdn-headers" - grep -Eiq '^access-control-allow-origin: \*\s*$' "$RUNNER_TEMP/cdn-headers" - cmp "$file" "$RUNNER_TEMP/$file" - done - - name: Create GitHub Release with benchmark evidence - env: - SOURCE_SHA: ${{ steps.build.outputs.source-sha }} - VERSION: ${{ steps.release.outputs.version }} - TAG: ${{ steps.release.outputs.tag }} + printf '# Gothic Lock Solver %s\n\nSource: `%s`.\n\n' "$VERSION" "$SOURCE_SHA" + printf 'npm: https://www.npmjs.com/package/gothic-lock-solver/v/%s\n\n' "$VERSION" + printf 'CDN: https://cdn.jsdelivr.net/npm/gothic-lock-solver@%s/dist/\n\n' "$VERSION" + cat "$RUNNER_TEMP/build/benchmark.md" + } > "$RUNNER_TEMP/release-notes.md" + cat "$RUNNER_TEMP/release-notes.md" >> "$GITHUB_STEP_SUMMARY" + echo "VERSION=$VERSION" >> "$GITHUB_ENV" + echo "NPM_TAG=$NPM_TAG" >> "$GITHUB_ENV" + - name: Publish npm package + run: npm publish "$RUNNER_TEMP/release/package.tgz" --access public --tag "$NPM_TAG" --ignore-scripts + - name: Create GitHub Release run: | flags=(--latest) - if [[ "$TAG" == canary ]]; then flags=(--prerelease --latest=false); fi - gh release create "v$VERSION" "$RUNNER_TEMP/release/"* --repo "$GITHUB_REPOSITORY" \ + if [[ "$NPM_TAG" == canary ]]; then flags=(--prerelease --latest=false); fi + gh release create "v$VERSION" "$RUNNER_TEMP/release/"* --repo "$GH_REPO" \ --target "$SOURCE_SHA" --title "Gothic Lock Solver $VERSION" \ - --notes-file "$RUNNER_TEMP/release/release-notes.md" "${flags[@]}" - - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - if: always() - with: - name: release-${{ github.run_id }}-${{ github.run_attempt }} - path: ${{ runner.temp }}/release/ - retention-days: 90 + --notes-file "$RUNNER_TEMP/release-notes.md" "${flags[@]}" diff --git a/docs/superpowers/plans/2026-09-07-simplify-library.md b/docs/superpowers/plans/2026-09-07-simplify-library.md index 5de9f42..2b023a6 100644 --- a/docs/superpowers/plans/2026-09-07-simplify-library.md +++ b/docs/superpowers/plans/2026-09-07-simplify-library.md @@ -7,6 +7,10 @@ The September 8 refinement removes `.github/scripts` entirely: configuration belongs in Vite, ordinary build commands in package.json, and CI/release coordination directly in workflow YAML. Do not relocate wrappers or implement a custom publication recovery engine. +The further simplification uses the runner's GitHub CLI directly, a single latest +successful-run lookup for the benchmark, and standard download/pack/publish +commands for releases. Do not repeat CI's file and benchmark validation in the +publication job or build a CDN verification subsystem. ## CI @@ -18,7 +22,7 @@ a custom publication recovery engine. Node version and environment. All rows must succeed for release. - Measure the current built solver on all 45 fixed inputs. - For a PR, use its target SHA; for main pushes, use the previous main SHA. -- Look up a successful CI report artifact for exactly that commit. +- Select the latest successful CI run for exactly that commit and its named report. - If no unexpired artifact exists, explicitly skip comparison. No source checkout, rebuild, npm lookup, previous-release fallback or synthetic baseline. - Compare deterministic quality metrics; report historical timing/memory ratios @@ -34,9 +38,9 @@ a custom publication recovery engine. stable version by changing package/build version metadata only. - Manual stable dispatch takes a successful main CI run ID and stable SemVer. - Download only that run's tested build artifact. No algorithm rebuild. -- Stable packaging changes only package/build version metadata; verify all other - archive bytes remain identical before publishing the new npm version. -- Publish to npm through OIDC, check CDN delivery, attach five runtime +- Stable packaging changes only package/build version metadata. Locally verify + that preparation preserves all other archive files; CI tests the built archive. +- Publish to npm through OIDC, include CDN links, attach five runtime files, package archive and the benchmark report to the GitHub Release. - Keep the two workflows readable: CI build and matrix test jobs, and one publication job, using official pinned Actions. diff --git a/wiki/benchmarks.md b/wiki/benchmarks.md index af07f52..23daf9d 100644 --- a/wiki/benchmarks.md +++ b/wiki/benchmarks.md @@ -19,7 +19,8 @@ pnpm benchmark --baseline-report /path/to/benchmark.json --baseline-sha TARGET_S ``` CI obtains `TARGET_SHA` from the PR target commit or the previous main commit. -It looks for that exact commit's successful CI report artifact. If the artifact +It selects the latest successful CI run for that exact commit and its named +benchmark report artifact. If the artifact is absent or expired, the current benchmark still runs and explicitly records `comparison: skipped`. CI does not check out the target, rebuild it, execute its code or substitute an npm/GitHub release. API access failures and malformed reports diff --git a/wiki/releasing.md b/wiki/releasing.md index 9a98aae..859ef8e 100644 --- a/wiki/releasing.md +++ b/wiki/releasing.md @@ -34,15 +34,17 @@ Enable direct `npm publish` permission in the publisher settings. Follow the [official npm instructions](https://docs.npmjs.com/trusted-publishers/). Then set the repository variable `NPM_PUBLICATION_READY=true`. -The workflow uses OIDC rather than a stored npm token. GitHub Actions and the npm -and GitHub CLIs are official, pinned tools. Public npm, standard public GitHub +The workflow uses OIDC rather than a stored npm token. Actions are pinned to +official releases; GitHub CLI and jq are provided by GitHub's Ubuntu runner. +Public npm, standard public GitHub runners and jsDelivr require no paid subscription for this setup. ## Automatic canaries A successful CI run for a push to main triggers the release workflow. The release -job validates the originating repository, workflow, branch, event and run result, -then downloads that run's build artifact by ID. PR artifacts cannot be published. +job checks the selected CI run's workflow, branch, event and success, +then downloads its named `build-SHA` artifact with `gh run download`. +PR artifacts cannot be published. Preparation assigns a unique version such as `1.0.0-canary.RUN_ID.ATTEMPT.SOURCE_SHA_PREFIX`. It changes only the package version @@ -52,7 +54,7 @@ The five runtime files are not rebuilt. The package is published under npm's `canary` tag and attached to a GitHub prerelease. Stable `latest` is unchanged. The full JSON/Markdown benchmark report, -source identity and SHA256SUMS accompany the release. +source identity and package/CDN links accompany the release. ## Manual stable release @@ -69,8 +71,8 @@ The resulting npm version uses `latest` and a regular GitHub Release. If the artifact expired, run CI again before releasing. No source rebuild or fallback artifact is hidden inside the release workflow. Publication uses ordinary `npm publish` and `gh release create` commands. -A duplicate npm version or existing GitHub tag stops publication. If delivery -fails after npm succeeds, inspect the saved release artifact and complete the +Standard npm and GitHub CLI errors stop publication. If delivery +fails after npm succeeds, inspect the job log and published version, then complete the remaining delivery manually; rerunning the entire job is not an automatic recovery mechanism. Published npm versions are never overwritten. @@ -86,13 +88,14 @@ upload is needed. Pin the complete version in integrations: ``` -The release verifies the tested archive before repacking, compares the repacked -files, and checks published CDN bytes/headers. CI has already -checked the identical runtime files in Chromium, Firefox and WebKit. The fifth +CI checks the runtime files in Chromium, Firefox and WebKit and verifies the +installed archive in every matrix row. Release downloads that successful build, +updates version metadata, packs and publishes it through the standard CLIs. The fifth file is the standalone Node CLI. Release assets include all five runtime files, the npm archive and the two benchmark reports. Official references: [workflow run triggers](https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows#workflow_run), -[artifact downloads](https://github.com/actions/download-artifact), +[artifact downloads](https://cli.github.com/manual/gh_run_download), +[runner tools](https://github.com/actions/runner-images/blob/main/images/ubuntu/Ubuntu2404-Readme.md), [npm publishing](https://docs.npmjs.com/cli/commands/npm-publish/), [jsDelivr npm delivery](https://github.com/jsdelivr/jsdelivr#npm).