Браузерный решатель и визуализатор головоломки с дисковыми замками — механика взлома из Gothic и подобных RPG.
Запуск: открыть index.html в браузере. Зависимостей нет, сборки нет.
demo.mp4
Скриншоты: Настройки · Решение · Поиск
Головоломка — N плашек на линейной шкале позиций. Каждая плашка может двигаться влево или вправо. Плашки связаны зависимостями: движение одной тянет связанные плашки в том же или обратном направлении. Зависимости не транзитивны — если A→B и B→C, то движение A затрагивает только B. C реагирует лишь когда B двигают напрямую. Цель — привести все плашки в центральную позицию одновременно.
Приложение:
- визуализирует конфигурацию в 3D-изометрической сцене
- решает головоломку через BFS (поиск в ширину) с гарантией минимального количества ходов — а среди всех минимальных решений выбирает то, где меньше всего переключений между плашками (короче список шагов)
- пошагово воспроизводит решение
- генерирует случайные конфигурации заданной сложности
В правом верхнем углу — три кнопки-флага: 🇷🇺 Русский / 🇺🇦 Українська / 🇬🇧 English. Выбор сохраняется в localStorage.
| Элемент | Действие |
|---|---|
| Элементы −/+ | Количество плашек (2–8) |
| Позиции −/+ | Нечётное количество позиций (3, 5, 7, …) |
| 🟢 🟡 🔴 | Случайная конфигурация: лёгкая / средняя / сложная |
| 📤 | Копировать конфигурацию в буфер (JSON) |
| 📥 | Импортировать конфигурацию из буфера |
| Ctrl+C | Копировать текущую конфигурацию |
| Ctrl+V | Вставить конфигурацию из буфера |
🔍 / Ctrl+K (на Mac — ⌘K) |
Открыть поиск по базе реальных замков из игры |
| РЕШЕНИЕ | Запустить BFS и перейти к решению |
Барабан позиций (колонка + / цифра / − на каждую плашку, над матрицей) и клик по дырке в 3D-виде:
- выставляют позицию напрямую, без проверки зависимостей — инструмент конфигурации: любое положение задаётся независимо от других плашек
- цифру можно ввести с клавиатуры;
+ / −, стрелки↑ / ↓и колесо мыши над цифрой меняют позицию на ±1 - вставка нескольких цифр заменяет весь набор, причём база определяется по граничным цифрам:
0бывает только при отсчёте с нуля (0–6),7— только при отсчёте с единицы (1–7); если цифры лишь 1–6, вставка читается как 1-based (барабан задаёт то, что показывает). Одиночная цифра — всегда 1-based - в 3D-виде при наведении на дырку — подсветка; клик (или тап) выставляет позицию той плашки, по дырке которой кликнули
- все правки позиции проходят через единый обработчик (
posSetPlateValue), поэтому 3D, кэш решения и подсказки обновляются согласованно
Матрица зависимостей:
- ЛКМ — переключить связь: нет → прямо → обратно
- ПКМ — переключить в обратную сторону
- Диагональ (самозависимость) заблокирована и обозначена полосками
- Когда ячейкам становится тесно для слова, вместо него показывается иконка направления (стрелки в одну сторону = «прямо», врозь = «обратно»); цвет наследует цвет ячейки. Порог — по ширине самой матрицы (
@container, 670px), а не по ширине экрана: ширина ячейки равнаматрица / (N+1), поэтому восемь плашек переходят на иконки и на десктопе
Управление плашками клавиатурой:
W / Sили↑ / ↓— выбор активной плашкиA / Dили← / →— сдвинуть активную плашку на ±1 (одну плашку, без зависимостей — как барабан; зависимости применяются только на этапе решения)
Решение отображается строкой нотации сверху и списком шагов-карточек. Каждый шаг — карточка: элемент, цветной значок направления (зелёный = вправо, красный = влево) с кратностью ×N, и код в нотации справа. Текущий шаг подсвечен; активная плашка одновременно подсвечивается в 3D-сцене.
| Кнопка | Действие |
|---|---|
| ‹ Шаг | Шаг назад по решению |
| ▶ Авто | Автовоспроизведение (500 мс/шаг) |
| Шаг › | Шаг вперёд по решению |
| Вернуться | Вернуться в настройки |
Нажать на шаг в списке — перейти к нему напрямую.
Если все плашки уже стоят в центре, решать нечего: вместо пустого списка выводится пояснение «замок уже решён, возможно вы забыли выставить начальные позиции», а кнопки шагов недоступны.
Нажатие A / D или ← / → в режиме решения автоматически входит в режим исследования:
- Авто-воспроизведение останавливается.
- Плашки можно двигать свободно, независимо от BFS-решения.
- Последовательные ходы одной плашки в одном направлении схлопываются в одну запись (
1A3вместо1A 1A 1A); обратные ходы схлопываются в обратную сторону. - Если ход заблокирован, мешающая плашка подсвечивается красным и дёргается на 250 мс. На мобильных устройствах (Android) блок отзывается коротким вибро, а поворот диска / ручной шаг решения — лёгким тиком (тихий no-op на iOS/десктопе, без запроса разрешений).
- В списке шагов появляется разделитель ↩ вернуться к шагу N — клик по нему или по любому пройденному BFS-шагу возвращает к решению.
Конфигурация живёт в адресной строке — ?lock=<dotted>, при необходимости плюс &solve. Достаточно скопировать URL, чтобы поделиться замком.
- Любая правка конфигурации сразу обновляет ссылку (
replaceState— история не засоряется). - Переход по ссылке применяет замок молча, без диалога подтверждения; с
&solveрешение запускается сразу. - Переход между настройками и решением —
pushState, поэтому браузерные «назад/вперёд» переключают вкладку. - Ходы в режиме исследования и позиция воспроизведения в ссылку не попадают: делится всегда исходная конфигурация, решение пересчитывается на той стороне.
- Если
?lockесть, но не разбирается, появляется уведомление о повреждённой ссылке (отсутствие?lock— норма и молчит).
🔍 (или Ctrl+K) открывает поиск по базе реальных сундуков/дверей из Gothic 1 Remake. Кнопка недоступна (с подсказкой почему), если не загрузились база или библиотека поиска.
Одно поле, режим определяется автоматически:
- Текст — нечёткий поиск (опечатки не страшны) по названию и тегам сразу на всех 4 языках базы (ru/en/de/uk), независимо от текущего языка интерфейса
- Позиции — точный поиск по расположению дисков. Поддерживается:
- через запятую:
4,0,6,3,2,1 - компактно, по одной цифре на позицию:
406326 - и так и так — учитывается сдвиг на +1 (если считать позиции с 1, а не с 0)
- через запятую:
↑/↓ — выбор результата без потери фокуса в поле, Enter или клик — применить найденную конфигурацию (переход на вкладку настроек).
По мере ввода начальных позиций между барабаном и матрицей появляется ряд карточек реальных замков из базы, чьи диски совпадают с текущими по префиксу слева. Это способ найти замок по видимым дискам и получить его скрытые правила зависимостей.
- Совпадение считается детерминированно (позиции пользователя 1-based → 0-based базы, без «пробуем оба варианта», как в текстовом поиске).
- Балл совпадения:
score = prefix · (0.7 + 0.3 · count), гдеprefix = L / N(L— длина совпавшего префикса,N— число плашек),count = 1 / (1 + |дисков − N|). Умножение делает префикс обязательным: замок без позиционного совпадения (prefix = 0) не всплывёт. - В список попадают записи со
score > 0.25(по сутиL ≥ 2), максимум 3, отсортированные по баллу. - Зависимости из матрицы тоже влияют — но только если что-то введено (пустая матрица ничего не меняет). Оцениваются лишь введённые связи: совпадение направления даёт бонус (
+0.5), отсутствие связи у замка — мягкий штраф (−0.2), противоположное направление — жёсткий (−1.5, одно противоречие обнуляет замок, вытянуть могут только сильно перевешивающие совпадения). Позиции остаются обязательными. - Яркость карточки растёт с баллом; полное совпадение (
score ≈ 1) заметно ярче частичных. Совпавшие диски в превью подсвечены зелёным. - Третья строка карточки — цепочка связей замка в gothic-формате (
A:B-,C+;D:E-, без позиций). Токены зелёные, если совпадают с введёнными в матрицу, красные — если противоречат. - Карточка кликабельна целиком — применяет весь конфиг замка (позиции + правила). При наведении полное имя показывается в
title(в UI оно может быть обрезано). - Раскладка: имя + теги слева, превью дисков справа. Количество карточек зависит от ширины панели (не вьюпорта, через CSS container queries): широкая — 3, уже — 2, узкая — 1.
- Если база не загрузилась (оффлайн/сбой) — ряд просто не показывается, без ошибок.
<id плашки><направление><кол-во шагов>
A= влево,D= вправо- Количество шагов опускается если равно
1
Примеры: 1A, 2D3, 7A2
Последовательные ходы одной плашки в одном направлении сжимаются: 1A 1A 1A → 1A3.
Импорт распознаёт четыре формата автоматически (вставка / Ctrl+V → диалог подтверждения; каждый кодек — самодостаточный юнит в namespace Codecs, блок codecs). Разбор gothic-правил щадящ к пробелам и регистру, но строг к содержимому: связь без указания направления (+/-) — это битые данные, и такой конфиг не импортируется. Экспорт/копирование выдают gothic или JSON (JSON — по Shift).
Конфиг никогда не перетирается молча:
Ctrl+Vоткрывает диалог подтверждения с превью замка (позиции + матрица связей) и, если такой замок есть в базе, карточкой найденного. Текст, не похожий на конфиг, просто игнорируется — про «неверный формат» больше не сообщается на каждую случайную вставку.- Вставка в барабан — это про цифры. Если в буфере полный конфиг, он применяется целиком только когда матрица пуста (ни одной связи); иначе берутся только цифры, а о том, что конфиг не применён, сообщает уведомление.
- Позиции вне диапазона не «подгоняются» по одной: набор либо сдвигается целиком, либо отвергается — иначе получился бы конфиг, которого пользователь не вводил.
[
{
"id": 1,
"positions": 7,
"currentPos": 3,
"deps": [
{ "targetId": 2, "direction": "same", "steps": 1 }
]
},
{
"id": 2,
"positions": 7,
"currentPos": 5,
"deps": []
}
]Ограничения:
positions— нечётное, минимум 3, одинаковое для всех плашекcurrentPos— от 1 доpositions; 1 = крайняя левая дырка в плашке (плашка при этом стоит крайне вправо), N = крайняя правая дырка (плашка стоит крайне влево)id— строго последовательность1..Ndeps.direction—"same"или"opposite"deps.steps— целое положительное число- Самозависимости (
targetId === id) не допускаются
Компактная строка вида 040615 A:B-,C+;D:E-: цифры позиций (0-based) и связи в любом порядке. Требует и позиции, и связи. + = same, − = opposite; буквы A..H → плашки 1..8.
«gothic» — принятое в проекте условное название этой нотации, не формат самой игры и не её экспорт; из Gothic 1 Remake пришли только данные базы замков.
Например 3.531.saaoaa: N плашек, по цифре позиции на плашку (0-based), затем по символу на упорядоченную пару (from-мажорно, self пропущен) — s = same, o = opposite, a = нет связи.
Компактный base64url-код (как в ?lock= на unlockmyloot) — их ссылки импортируются напрямую. Каноничный (фиксированные длины, нулевые pad-биты).
| Кнопка | Минимум плашек | Длина решения |
|---|---|---|
| 🟢 Лёгкая | 2 | 7–13 ходов |
| 🟡 Средняя | 5 | 14–20 ходов |
| 🔴 Сложная | 7 | ≥ 21 хода |
Длина считается по сжатой нотации (1A3 = 1 ход).
Один файл — index.html, без зависимостей и шага сборки.
BFS и генерация выполняются в Web Workers через Blob URL — UI не блокируется.
Solve-воркер (один) — запускается при нажатии РЕШЕНИЕ, возвращает минимальный путь с групповой оптимизацией (см. ниже).
Пул random-воркеров (до 8, по числу CPU) — параллельно перебирают случайные конфигурации. Первый, нашедший подходящую, останавливает остальных через worker.terminate().
Прогресс вычисления отображается в оверлее:
Состояний проверено: 5 222 тыс. ← для BFS
Попытки: 47 / 400 · 5 222 тыс. ← для рандомизации
├─ #1 5 попыток · 412 тыс.
├─ #2 8 попыток · 630 тыс.
...
└─ #8 5 попыток · 395 тыс.
Единственный солвер в проекте — им пользуются и кнопка РЕШЕНИЕ, и рандомизатор. Кратчайших по нажатиям решений обычно много, и наивный BFS вернул бы первое попавшееся, которое «зигзагом» скачет между плашками. Поэтому алгоритм трёхфазный, точный:
- BFS от старта →
distSдо обнаружения цели на глубинеL(минимум нажатий — гарантия BFS) - Обратный BFS от цели по «коридору» кратчайших путей (
distS + distG == L; ходы обратимы, граф неориентированный) - Точный DP по коридору с узлом
(состояние, последняя плашка)— минимум переключений. Направление в узле не нужно: на кратчайшем пути плашка не может пойти дважды подряд в разные стороны (это возврат в предыдущее состояние)
Итог: минимальное число нажатий и минимально возможный список шагов (напр. 41 нажатие: 30 групп у наивного BFS → 11 у нашего). Для патологически широких коридоров (>200 тыс. состояний) — жадный fallback «предпочитай ту же плашку», по-прежнему минимальный по нажатиям.
Прогресс стримится через onProgress каждые 2000 открытых состояний; максимальное пространство — positions^N (7 позиций × 8 плашек ≈ 5.7 млн). Сложность рандома фильтруется по длине группового решения — то есть ровно по тому списку шагов, который увидит пользователь.
Решение, найденное при генерации (cachedSolution), сохраняется и переиспользуется при нажатии РЕШЕНИЕ без изменения конфигурации. Любое изменение (ход клавишей, правка зависимости, смена количества плашек/позиций) инвалидирует кеш. BFS-решение тоже кешируется при первом вычислении.
CSS разбит на блоки <style> с семантическими id — так же, как скрипты:
style-base reset, body, .stage, .hidden
style-layout панели, стадии, обёртка 3D
style-preview read-only виз: мини-матрица связей, карточка превью импорта
style-controls заголовки, кнопки, инпуты, пилюля поиска
style-config барабан позиций + матрица зависимостей
style-notation диалог справки по нотации
style-search диалог поиска, карточки результатов, подсказки-совпадения
style-solve список решения, карточки шагов, разделитель explore, «уже решён»
style-overlay вспышка блокировки, оверлей расчёта, диалог импорта, тосты, легенда
style-tokens палитра (:root custom properties)
style-scene 3D-сцена: плашки, грани, дырки, пины, переключатель языка
style-responsive медиа-запросы под вьюпорт
Несколько блоков применяются в порядке документа — ровно как один склеенный лист. Поэтому перенос правила между блоками меняет каскад: новые правила добавляются в блок своей зоны, а перемещение между блоками считается изменением поведения, а не рефакторингом. !important в проекте нет — так и держим.
Блоки <script> имеют семантические id — вытащить нужный: grep 'id="<имя>"' index.html.
solver-src Ядро решателя: center(), computeMove(), нотация,
bfsSolveGrouped(), compressPath(). Исполняется страницей
И инъектится в Blob воркера — один исходник, два потребителя
state Константы, state, makePlate(), TRANSLATIONS, t(), setLanguage()
ui-utils showToast(), copyToClipboard() — общие UI-утилиты
game-logic applyMove(), getBlockingPlateId(), cycleActivePlate(), flashBlockedPlate()
render-scene buildScene(), updateScene(), posToOffsetX() (3D-сцена)
render-preview posStripHTML(), depMatrixHTML(+depCellColor/Icon), depDirIcon — read-only виз
codecs Codecs = {json,gothic,dotted,bytearray}; глобали-фасад:
validatePlates(), PARSERS, parseConfig(), looksLikeImportConfig()
url Шаримый ?lock=<dotted>[&solve]: urlQueryFor/urlReadConfig (чистые),
urlReplace/urlPush, urlApplyOnLoad, urlReconcile (popstate)
config Барабан позиций (posSetPlateValue — единая точка смены позиции:
ввод/±/стрелки/колесо/клик по дырке), матрица (renderMatrix,
depCellHTML), импорт/экспорт, рандомизация
solve-ui Список шагов-карточек, навигация, авто-воспроизведение,
addExploreMove(), returnToSolution()
keyboard Обработчик клавиатуры (config + solve + explore)
init init()
worker-src Точка входа воркера: только onmessage (solve + рандомизатор);
(text/x-worker) логика — из solver-src
worker-host createWorker(), пул, прогресс, отмена
db-search loadChestDb(), Fuse.js поиск, поиск по позициям, рендер карточек;
computeChestHints() + renderChestHints() — живые подсказки
chests.json — то, что грузит в браузере поиск (fetch('chests.json')). Собирается из трёх слоёв:
chests.ini сырец: community-набранная база (опечатки,
│ разнобой языков; НЕ лежит в репозитории)
▼ parse + normalize
tools/db-decisions.json слой решений («git rerere»): каждое ручное
│ решение записано один раз и переигрывается
▼ apply при каждой пересборке
tools/gaps/ → translations переводы Google-Translate-раундтрипом
│ (заполняют только недостающие языки)
▼
chests.json итог, коммитится, грузится приложением
npm run build:db # полная пересборка: ini + decisions → chests.json
npm run review:db # веб-ревью дублей/обогащений → http://localhost:3210Идентичность замка = pos.join(',') + '|' + отсортированное множество связей. Не зависит от порядка записи правил (B:D-;C:B- ≡ C:B-;B:D-) — старый дедуп сравнивал правила строкой и пропускал переупорядоченные дубли. Все слои (overrides, translations, очередь ревью) адресуются этим ключом, поэтому решения переживают любые изменения сырца.
{ "v": 1,
"drops": [ "<канонический-ключ>" ],
"overrides": [ { "key": "…", "note": "review: dedup", "entries": [ {…} ] } ],
"additions": [ { …запись без аналога в ini… } ],
"translations": { "<key>": { "byId": { "<id>": { "desc": { "de": "…" } } } } } }- drops — канонические ключи, которые исключаются из вывода (битые/неисправимые данные ini — например, зависимость без указания направления). Так эти записи не вернутся при повторном импорте ini, без правки самого
chests.ini(он может приходить извне). - overrides — каноническая группа из ini заменяется записанными
entries. Так хранятся: bootstrap-заморозка (см. историю ниже), слитые дубли, обогащения. - additions — записи, которых нет в ini (например, будущие импорты).
- translations — переводы по ключу; применяются только в недостающие языки (fill-only: машинный перевод никогда не затирает явное имя из ini или отредактированный тобой мёрж).
byIdадресует конкретную запись внутри неслитой группы дублей.
Группы-дубли без решения не сливаются молча — попадают в отчёт REVIEW-NEEDED и в очередь ревью. Записи без внятного имени не выбрасываются: идут с name: {} и стабильным id lock-<позиции>, а UI показывает локализованную заглушку «Неизвестный замок».
{
"id": "swamp-camp-cor-kalom-hut",
"name": { "ru": "…", "en": "…", "de": "…", "uk": "…" },
"desc": { "ru": "…", "en": "…", "de": "…", "uk": "…" },
"rules": "A:B+,C-;B:A-",
"pos": [4, 0, 6, 3, 2, 1],
"tags": ["болотный лагерь", "кор галом"],
"img": []
}desc — опциональное описание (локация + лут); пока только хранится, показ в UI — отдельная задача. cells не хранится — считается как pos.length.
Веб-страница без зависимостей: один пункт очереди за раз, все записи-кандидаты бок о бок + редактируемый итоговый вариант (id, имена/описания по языкам, теги; позиции/правила read-only). «Принять» немедленно пишет решение в db-decisions.json, «Пропустить» просто листает. Очередь: канонические группы-дубли + внешние предложения из tools/review-queue.json (обогащения, конфликты, новые замки). Пункт гаснет только когда решён его собственный тип — мёрж дублей не прячет висящее обогащение того же замка.
Эвристики предложенного мёржа: id — от «лучшего» кандидата (длина имени + теги), имена/описания — самый длинный текст на язык, теги — объединение без регистро-дублей.
unlockmyloot.com — открытый (AGPL) решатель с курируемым каталогом замков. Тулза качает их ru/en-страницы (или --cache), достаёт короткие имена из хлебных крошек и описания из meta, декодирует конфиг из ?lock=-кода (битовый поток: 3 бита — число плиток−3, 1 бит ориентации, по 3 бита на штифт, по 2 бита на связь; base64url) и классифицирует против нашей базы:
- enrich — канонический ключ совпал: предложение заполнить наши пустые
descих описаниями (наши имена по умолчанию побеждают); - conflict — правила совпали, позиции разошлись: у кого-то опечатка, в note обе версии — вердикт за человеком (обе стороны обычно решаемы, солвер не арбитр);
- add — неизвестный замок: готовая запись в
additions.
Всё идёт через очередь ревью — вслепую не импортируется ничего. Из их репозитория берутся только числовые коды и тексты страниц, код не заимствуется.
Раундтрип через Google Translate + обязательная AI-сверка:
node tools/translate-gaps.cjs --export # tools/gaps/gaps-<lang>.txt + .map.json
# → вставить txt в Google Translate, результат сохранить построчно
# как tools/gaps/gaps-<lang>.translated.txt (порядок и число строк 1:1!)
node tools/translate-gaps.cjs --import de # → staging (translationsPending)
node tools/translate-gaps.cjs --import uk
# → AI-сверка: ассистент сверяет staging с tools/gothic-glossary.json и
# каноном Gothic (имена, локации, термины), выдаёт нумерованный список
# подозрений с фиксами; после вердиктов владельца патчит staging
node tools/translate-gaps.cjs --finalize # staging → translations
npm run build:dbЭкспорт кладёт по одному тексту на строку (.map.json — параллельный манифест key/id/field), поэтому Google Translate можно скармливать файл целиком. Staging (translationsPending) существует ровно затем, чтобы машинный перевод не попал в базу до сверки: канон ловит то, что GT не знает — Ravens Turm (имя, не «Rabenturm»), Cor Kalom, Sektierer (не «Kultisten»), Feuerschutzring («кольцо от огня», а не огненное), Burg (не «Schloss»), укр. «шкода» (урон, не «збитки»), «столові прибори» и т.п.
translate.sh (npm run translate:db) — старый батч-перевод имён через claude --print; оставлен как альтернатива для массовых имён.
- Bootstrap (
tools/bootstrap-decisions.cjs, одноразовый):chests.jsonуспел разойтись сchests.ini— переводы и ручные правки жили только в json, пересборка была разрушительной. Инструмент отдиффил чистую регенерацию против текущего json по каноническим ключам и заморозил все 393 отличающиеся группы как overrides с текущим содержимым. Проверено: пересборка воспроизводит базу 1:1. - Дедуп-ревью: 83 канонические группы дублей (пережившие старый строковый дедуп), 78 слито владельцем через ревью-тулзу — база ужалась 508 → 394 записи с объединением имён/тегов/языков.
- Обогащение: sync с unlockmyloot — 38 замков, из них 36 точных совпадений (их база оказалась подмножеством нашей), 0 новых; 34 обогащения принято, 32 замка получили описания (локация + лут) ru/en.
- Переводы: 30 описаний прогнаны через Google Translate в de/uk, AI-сверка поймала 16 расхождений с каноном до финализации. Итог: 32 замка с описаниями на всех четырёх языках.
# Открыть в браузере
open index.html # macOS
xdg-open index.html # Linux
# Запустить тесты
npx playwright test
# Прогон всей базы через решатель (~3 мин, отдельно — за флагом RUN_CORPUS)
npm run test:corpus
CHEST=<часть-id> npm run test:corpus # один-два сундука с полной диагностикой
# Одноразовая настройка pre-push хука (запускает тесты перед каждым push)
git config core.hooksPath .githooksТесты — Playwright, элементы ищутся только по data-test-id. Инварианты, которые раньше держались на внимательности, закреплены отдельными наборами: tests/i18n.spec.js (все три локали имеют один и тот же набор непустых ключей, каждый data-i18n существует) и tests/review-findings.spec.js (по тесту на каждый дефект, найденный полным ревью).
Без сервера, без транспиляции. Все изменения видны после обновления страницы.
- Количество плашек: 2–8
- Количество позиций: нечётное, минимум 3
- Зависимости:
steps = 1(редактирование через UI), больше — только через импорт - Циклические зависимости допустимы (первый вызов в цепочке побеждает)
Chrome/Edge 108+, Firefox 110+, Safari 16.4+. Полифилов нет и не будет — порог задают три конкретные вещи:
| Что используется | Chrome/Edge | Firefox | Safari |
|---|---|---|---|
dvh (высота вьюпорта без скачков от адресной строки) |
108 | 101 | 15.4 |
контейнерные запросы (container-type + @container) |
105 | 110 | 16.0 |
lookbehind в регулярке ((?<![,\d]), разбор вставки) |
62 | 78 | 16.4 |
Первые две деградируют мягко — поедет вёрстка. Lookbehind нет: регулярка проверяется при разборе скрипта, поэтому на Safari старше 16.4 весь блок падает целиком и приложение не запускается.
