Как устроен локальный шахматный помощник в браузере: Stockfish 18, Manifest V3 и веб-доски
- вторник, 25 августа 2026 г. в 00:00:09
Я хотел подсказку рядом с доской, а не ещё одну вкладку с движком. Stockfish 18 давно умеет искать сильный ход, упаковать WASM в Chromium-расширение тоже не подвиг. Первое демо на своей статичной странице собралось за вечер: фигуры сняли, FEN собрали, стрелка легла. Настоящая работа началась, когда ту же схему повесили на живой шахматный сайт.
Самый неприятный случай оказался не «движок тупит», а «движок прав, только не про эту доску». Реванш на Lichess часто стартует на том же URL и с той же начальной расстановкой. Для человека это новая партия. Для наивного кода это тот же FEN, и он спокойно дорисовывает стрелку от прошлого поиска. В другой раз на экране две доски сразу: большая игровая и маленькая «последняя позиция». Селектор радостно берёт обе. Так из эксперимента вырос ChessNavio — расширение, которое считает позицию локально, Stockfish и Maia лежат в сборке, текущий FEN на сервер не уходит. Дальше про то, как не показать красивый, но уже чужой ход.
Подсказку в партии с человеком я не собираюсь называть обучением. Это чит, площадки его обычно запрещают, обход античита мы не обещаем. Против компьютера, на тренировке или после партии тот же конвейер — обычный анализ. К этой границе вернусь, когда станет ясно, что именно расширение умеет технически.

На своей тестовой доске хватает короткой цепочки: найти 64 клетки, снять фигуры, собрать FEN, спросить Stockfish, нарисовать первый ход principal variation. На Lichess и Chess.com такого события, как positionReady, нет. Ход для игрока — одна анимация. Для MutationObserver это очередь противоречивых кадров: фигура уже на новой клетке, индикатор очереди ещё старый, список ходов подтянется чуть позже, а сам узел доски сайт может выкинуть и собрать заново. Часть этих кадров вообще не является легальной позицией, и запускать движок на них нельзя.
Дальше ломается уже не анимация, а контекст. На странице две доски. Идёт трансляция, а не своя партия. Фигуры стоят после мата. Вкладку свернули, service worker уснул, расчёт пришёл, когда на доске другой ход. Отсюда две проверки, а не одна. До движка нужно понять, что перед нами партия пользователя и позиция уже стабильна. После движка — что пока он думал, ничего не сменилось. Иначе Stockfish отрабатывает идеально и вешает стрелку не туда.
Один огромный content script кажется проще, пока не смотришь на срок жизни кусков. DOM пересоздают когда угодно. Движок думает секундами. Фоновый процесс в Manifest V3 Chrome имеет право просто исчезнуть. Запись партии при этом не должна сдохнуть вместе с воркером. Поэтому адаптер только читает известную вёрстку площадки, сессию проверяет отдельный слой, журнал ходов пишется независимо от анализа, а карточка показывает результат, только если он всё ещё про ту же партию. Упал Stockfish — история целая. Пришёл блестящий ответ на вчерашний FEN — до UI он не доезжает.
Шахматные сайты не подписывают DOM общим контрактом. 64 клетки и 32 фигуры ничего не доказывают: это может быть живая партия, превью, доска анализа, миниатюра последней игры или скрытый узел «для мобилки». Гостевой экран Chess.com ядовит тем, что фигуры стоят до нажатия Play. World Chess спокойно рисует вторую, уменьшенную доску рядом с основной. Общий querySelector вернёт оба узла и пойдёт считать. Математика будет верная. Стрелка окажется на той доске, на которую человек не смотрит.
Универсального адаптера у нас нет. Для каждой площадки свой короткий набор признаков, и они должны совпасть: этот маршрут, одна видимая доска нужного типа, партия реально началась, пользователь за этой стороной, известен локальный id игры, и если площадка умеет сказать, с кем ты играешь, мы это читаем, а не угадываем. Не хватает доказательств — молчим. Сообщение «доску не распознал» раздражает, стрелка на чужой трансляции раздражает сильнее. Отдельный класс багов ещё противнее: площадка чуть меняет вёрстку, селектор не падает и начинает показывать не то. Поэтому смотрим не на «селектор сработал», а на то, сходится ли картинка целиком — геометрия, число досок, состав фигур, сторона, игровые элементы, тот же ли id сессии.

MutationObserver сообщает, что что-то изменилось, и не умеет сказать, что позиция снова целостная. Найденную расстановку держим как кандидата и перечитываем доску; совпало — можно считать подтверждённой. Секундный debounce здесь слабое утешение: в интерфейсе это уже тормоза, а после медленной анимации или восстановления вкладки секунды всё равно мало. Если корневой узел на мгновение пропал, это ещё не конец партии, SPA так часто перестраивает страницу. Стрелку всё равно прячем сразу. Старый ход над пустым местом выглядит как баг, даже когда через 80 мс доска вернётся.
нет сессии → кандидат → позиция нестабильна → подтверждена → анализ → показ
Позицию подтвердили, Stockfish пошёл считать, а пользователь в это время ходит, жмёт реванш или возвращается во вкладку, которую Chrome уже выгружал. Движок присылает хороший ход. К доске он уже не относится. Одного FEN мало: запрос таскает снимок сессии, внутри монотонный номер эпохи.
type AnalysisIdentity = { epoch: number; gameId: string; fen: string; playerColor: "white" | "black"; };
Сменилась доска — эпоха увеличивается, стрелка гаснет. Когда анализ возвращается, сравниваем его с тем, что на экране сейчас.
const request = { epoch: currentEpoch, gameId: currentGameId, fen: currentFen, playerColor: currentPlayerColor, }; const result = await analyze(request.fen); if ( request.epoch !== currentEpoch || request.gameId !== currentGameId || request.fen !== currentFen || request.playerColor !== currentPlayerColor ) { return; // красиво, но уже не про эту позицию } showHint(result);
Полей в настоящем коде больше, идея та же. UCI-команда stop асинхронная, предыдущий поиск мог уже положить строку в очередь. Мы не делаем вид, что умеем доказать «старый расчёт умер». Мы не пускаем его в UI. Отдельный gameId нужен как раз из-за реванша: URL тот же, стартовый FEN тот же, партия уже другая. Без id расширение вешает на новую игру хвост от старой.
Снаружи после этой проверки человек видит уже не сырой PV, а одну карточку и стрелку. Это не «нашли доску», это конец цепочки идентичности.

Stockfish 18 упакован в расширение и крутится на машине пользователя, позиция и варианты в наше API не едут. Положить его в service worker нельзя: в MV3 это не вечный демон, побездействовал — Chrome прибил, состояние из RAM беречь не обязан. После пробуждения нужно заново пожать руку процессу, понять, что это всё ещё та же сессия, не вспыхнуть старой стрелкой, поднять зависший расчёт и не потерять уже записанные ходы.
Движок живёт в offscreen document, ходить к нему можно только через один планировщик. Иначе текущая подсказка дерётся за CPU с фоновым разбором вчерашней партии. Очередь жёсткая: сначала ход, который сейчас на доске, потом ответ на вероятный ход соперника, оценка только что сделанного хода, дописывание истории и разбор законченной партии сзади. Верхнее вытесняет нижнее. Скрыли вкладку или машина хрипит — отваливаются предпросмотр и постигровой анализ. Живая подсказка должна остаться.
Аккаунт нужен для доступа и для тех данных, которые человек сам разрешил синхронизировать. Считать текущую позицию сервер не имеет права. Если поставить HTTP между доской и Stockfish, подсказка начнёт зависеть от сети и нашего аптайма. Мы платим весом сборки и нагрузкой на ноутбук, зато критический путь не ходит в интернет.
Локально: позиция, Stockfish, стрелка, журнал, текст хода Сервер: аккаунт, доступ, завершённые партии только с согласием
Как эта же граница сформулирована без внутренних имён модулей, лежит в описании локального расчёта. Это не лендинг установки, а тот же контракт: что остаётся в браузере и что уезжает в аккаунт, только если пользователь это включил.
Есть ещё один вход в тот же движок, без content script и без чтения чужого DOM. Туда можно скормить PGN или FEN и гонять инварианты журнала, когда адаптера площадки в кадре нет. Это разбор позиции в браузере. Для статьи он важен тем, что история партии проверяется отдельно от селекторов Lichess и Chess.com.
Лучший ход у Stockfish просится одной командой. Ход «как человек около 1500» — нет. Урезать глубину или movetime кажется логичным и даёт слабость, но не человечность: в одной позиции движок зевает пешку, в соседней выдаёт компьютерный ресурс, который клубный игрок не найдёт и не поймёт. Мы развели две роли. Один слой говорит, насколько позиция объективно плохая или хорошая. Второй выбирает рекомендацию нужного уровня.
На человеческих уровнях кандидатов сначала предлагает Maia. Stockfish сидит сбоку как аудитор: если потеря слишком большая, ход выкидываем. Оценку Maia на экран как «правду позиции» не пишем, правду по-прежнему говорит Stockfish. Поэтому в карточке могут соседствовать лучший ход и ход выбранного уровня, и это не один полуфабрикат.
Строка e4 e5 Nf3 — это PV, не мысль. Гнать каждую позицию в LLM ради одной фразы не захотелось: снова сеть в критическом пути и планы, которых движок не считал. Текст собираем локально из позиции, выбранного хода, варианта и скачка оценки. Ищем то, что можно ткнуть пальцем по доске: шах, взятие, нападение на более дорогую фигуру, развитие, дырка вокруг короля, открытая линия, тактика в первых ходах варианта, просадка оценки после хода игрока. Нет явного мотива — лучше скучная нейтральная подпись, чем «давление на королевском фланге», которого в расчёте не было.
Сначала анализ и история шли одним пайплайном. Экономия вышла дурацкая: движок завис, и ход в журнал опаздывал. Теперь ход сначала пишется в IndexedDB, оценка догоняет потом. Пауза подсказок гасит стрелку и расчёт, уже записанную партию не трогает. Журнал можно выключить и стереть. После партии остаются PGN, график оценки и прокрутка критических ходов.
Тест «открыл сайт — увидел стрелку» проходит даже у кривого прототипа. Ломается на последовательностях: два хода, пока движок ещё думает; вкладку спрятали; reload посреди анализа; Chrome прибил worker; на экране две доски; реванш; ответ от поиска, который уже отменили. Это гоняется на собранном расширении, не на юнитах в вакууме. Для журнала есть сверка с эталонным PGN. Инвариант, из-за которого больше всего шума в тестах, простой: ни один результат предыдущей эпохи не имеет права изменить текущую стрелку, текст или выбранный вариант. В очереди старые и новые сообщения намешаны специально. Аккуратная отмена одного запроса эту гонку почти не воспроизводит.
Спорить, помощник это или чит, бессмысленно. Движок шепчет ход в живой партии с человеком — чит. Тот же движок после партии или против бота — анализатор. Стрелка одного цвета, правила разные. Мы не обещаем стелс и не называем рабочий селектор партнёрством с Lichess. Перед подключением площадки есть предупреждение про её правила. Режимы, которые реально проверили, не равны фразе «вообще открывается доска». Автохода за человека в игре с людьми нет. Не поняли, что за страница — лучше потухнуть, чем угадать.
Предупреждение ничего не разрешает. Если Chess.com или Lichess запрещают подсказки в рейтинге, адаптер этот запрет не отменяет. Спрятать локальный инструмент полностью нельзя. Можно не строить вокруг этого маркетинг и не делать вид, что «прочитали DOM» и «здесь можно играть» — одно и то же.
Ограничения, которые никуда не делись: площадка завтра поменяет класс у доски, и адаптер поедет; тип соперника мы определяем не везде; локальный движок заметно грузит машину. Если рисовать схему ещё раз, Stockfish я бы не ставил в центр. В центре вопрос: это всё ещё та же партия, тот же ход, тот же цвет. Движок отвечает быстро и самоуверенно. Ошибки почти всегда в вопросе. Пока сессия не сложилась, анализ не стартует. Если контекст не доказали — ничего не рисуем. Это, пожалуй, единственное место, где молчание лучше красивого хода.