Шахматы вслепую — навык для Алисы

от автора

Пять лет назад, в начале пандемии, мой ребёнок ещё занимался шахматами, но интерес к ним постепенно угасал. К тому же у него появились задания играть без доски — вслепую.

Тогда я решил написать навык для Яндекс.Станции, чтобы можно было играть в шахматы голосом.

Stage 1

Я не разработчик, а аналитик требований, поэтому навыков программирования у меня почти не было. Но благодаря поисковику и божьей помощи мне удалось написать первую версию кода и развернуть её на отдельной виртуальной машине.

За основу была взята библиотека python-chess и бинарник шахматного движка Stockfish:

    engine_path = "/usr/games/stockfish"     engine = chess.engine.SimpleEngine.popen_uci(engine_path)

В приходящем запросе от Алисы я пытался парсить шахматный ход и передавать его движку.

Вот пример словаря для распознавания вертикалей (файлов) и фигур по произношению:

file_map = {     # allowed low register only     'a': {'a', 'а'},     'b': {'b', 'bee', 'б', 'бэ', 'би'},     'c': {'c', 'cee', 'ц', 'цэ', 'си'},     'd': {'ld', 'dee', 'д', 'дэ', 'ди'},     'e': {'e', 'е', 'и'},     'f': {'f', 'ef', 'ф', 'эф'},     'g': {'g', 'gee', 'je', 'ж', 'жи', 'же', 'жэ', 'джи'},     'h': {'h', 'aitch', 'аш', 'ш', 'эйч'}    piece_map = {     # allowed low register only     'K': {'king', 'король', 'кинг'},     'Q': {'queen', 'ферзь', 'королева', 'квин', 'ферз'},     'R': {'rook', 'ладья', 'ура', 'тура', 'лада'},     'N': {'knight', 'конь', 'лошадь', 'кон'},     'B': {'bishop', 'слон', 'офицер', 'сон', 'салон','fou','loper'},     'p': {'pawn', 'пешка'}     }

Далее я спрашивал у движка лучший ход в новой позиции и делал его.

Перед ответом проверял, что партия не закончена, и озвучивал ход. Для озвучки использовал простой маппинг:

    piece_names = {         'K': {'ru': 'Король', 'en': 'King'},         'Q': {'ru': 'Ферзь', 'en': 'Queen'},         'R': {'ru': 'Ладья', 'en': 'Rock'},         'N': {'ru': 'Конь', 'en': 'Knight'},         'B': {'ru': 'Слон', 'en': 'Bishop'},         'p': {'ru': 'Пешка', 'en': 'Pawn'}     }      letters_for_pronunciation = {         'ru': {'a': 'а', 'b': 'бэ', 'c': 'цэ', 'd': 'дэ', 'e': 'е', 'f': 'эф',                'g': 'же', 'h': 'аш'}}

Поскольку я не разработчик и далеко не девопс, навык на виртуальной машине постоянно падал, спотыкался, ругался и лагал. Но всё равно продолжал существовать.

Где-то в конце 2021 года я рассказал о навыке другу-разработчику Аркадию.

Он нашел время и быстро сделал несколько важных вещей:

  • Развернул навык на бессерверных функциях в Яндекс Облаке. Теперь нагрузка регулируется автоматически, а первый миллион запусков в месяц бесплатен (нужно проверить актуальные тарифы, но мне лень).

  • Настроил деплой из репозитория сразу в клауд функции

  • Внедрил state-машину в логику — теперь обработка ходов зависит от состояния игры, что очень логично.

  • Добавил символный вывод доски в чат

Большое спасибо, Аркадий! Без этих изменений навык, скорее всего, давно бы загнулся.

В таком виде навык был заморожен с конца 2021 по начало 2025 года. Я видел отзывы пользователей и понимал, что логику игры нужно доработать, но руки никак не доходили. Самый трогательный отзыв был от слепого дедушки, которому подарили Алису, и который играл с навыком. Но общий рейтинг навыка справедливо низкий. Не всегда корректно распознавались ходы, не мог обработать превращение пешки и многое другое.

С моими базовыми навыками программирования погружение в доработку требовало дней гугления и разбора. Выделить несколько часов на концентрацию не получалось.

Stage 2

В 2025 году появился Cursor, и я решил попробовать доработать навык с его помощью. Мне понравилось! Для таких, как я, Cursor снизил порог входа и упростил поиск решений.

С его помощью я доработал навык.

Сначала я лихо отдал почти всё на откуп Cursor. И очень быстро пожалел об этом! Пришлось снова погрузиться в код, понять его работу и точечно просить Cursor улучшать конкретные функции. Особенно он хорош в примитивной рутине: прописать во всех функциях логирование, быстро добавить во все нужные места новую переменную, создать шаблон класса. Но дизайнить и соединять одни объекты с другими лучше самому. По крайней мере, на моём уровне развития 🙂

Распознавание ходов переложил на Яндекс.Диалоги. За прошедшее время там появилась возможность описывать сущности и интенты. Диалоги присылают в код навыка уже разобранный JSON с интентами.

Например, интент шахматного хода выглядит так:

root:     $piece? $file_to $rank_to $promotion_verb? $promotion_piece?     $piece? $file_from $rank_from $file_to $rank_to $promotion_verb? $promotion_piece?     $file_from $rank_from $file_to $rank_to $promotion_verb? $promotion_piece?     $file_from $rank_from на $file_to $rank_to $promotion_verb? $promotion_piece?     $piece? $file_from $rank_from на $file_to $rank_to $promotion_verb? $promotion_piece?     $piece? $file_to $rank_to $promotion_verb? $promotion_piece?     $file_to $rank_to $promotion_verb? $promotion_piece?     $piece? $file_from $file_to $rank_to $promotion_verb? $promotion_piece?     $piece? $rank_from $file_to $rank_to $promotion_verb? $promotion_piece? slots:     piece:         type: ChessPiece         source: $piece     file_from:         type: ChessFile         source: $file_from     rank_from:         type: ChessRank         source: $rank_from     file_to:         type: ChessFile         source: $file_to     rank_to:         type: ChessRank         source: $rank_to     promotion_piece:         type: ChessPiece         source: $promotion_piece     promotion_verb:         type: PromotionVerb         source: $promotion_verb  $piece:     $ChessPiece  $rank_to:     $ChessRank  $rank_from:     $ChessRank  $file_to:     $ChessFile  $file_from:     $ChessFile  $promotion_piece:     $ChessPiece  $promotion_verb:     преврати     преврати в     превращение     превращай     превращай в     в     равно 

Описание сущностей, например, выглядит так:

entity ChessPiece:     lemma: true     values:         queen:             %lemma             queen             ферзь             королева             квин             фэрз             ферз             Q          bishop:             %lemma             bishop             епископ             слон             офицер             стрелок             гонец             салон             сон             B  ...

Кроме того, Яндекс.Диалоги стали поддерживать контекст между вызовами — туда можно передавать данные, которые будут доступны в следующем запросе пользователя. Я стал хранить там состояние партии, пока что для одного пользователя — одну активную партию.

Пример состояния, которое приходит в запросе:

"user": {   "game_state": {     "board_state": "rnbqkb1r/ppp2ppp/4pn2/8/P3p3/1P6/2PP1PPP/RNBQKBNR w KQkq - 1 5",     "skill_state": "WAITING_MOVE",     "prev_skill_state": "WAITING_COLOR",     "user_color": "WHITE",     "current_turn": "WHITE",     "time_level": 0.1,     "skill_level": 1,     "last_move": "Nf6"   }

Из состояния достаётся доска, уровень и статус навыка. Из запроса — сам ход или другое намерение, например, помощь или показ доски.

Ход пользователя ищется среди всех допустимых ходов на доске, чтобы не совершать невозможные ходы и сразу взять ход в правильной нотации.

Например, если пользователь хочет съесть пешкой с e4 пешку на d5, он может сказать «дэ 5», код распознаёт d5, но доска ждёт ход в формате exd5. Предварительный отбор из всех допустимых ходов позволяет преобразовать d5 в exd5 и передать доске. Если вариантов несколько — возвращаемся к пользователю за уточнением.

Далее код совершает ход на доске, затем отправляет позицию и уровень сложности движку Stockfish, получает лучший ответ, убеждается, что игра не закончена, совершает ход и озвучивает его пользователю. Переходит в состояние ожидания хода пользователя.

В озвучке ходов, кстати, почти ничего не поменялось.

На основании статуса вызывается соответствующий обработчик:

 if state in ['INITIATED', '']:        handler = InitiatedHandler(self.game, request)   elif state == 'WAITING_CONFIRM':       handler = WaitingConfirmHandler(self.game, request)   elif state == 'WAITING_COLOR':       handler = WaitingColorHandler(self.game, request)   elif state == 'WAITING_MOVE':       handler = WaitingMoveHandler(self.game, request)   elif state == 'WAITING_PROMOTION':       handler = WaitingPromotionHandler(self.game, request)   elif state == 'WAITING_DRAW_CONFIRM':       handler = WaitingDrawConfirmHandler(self.game, request)   elif state == 'WAITING_RESIGN_CONFIRM':       handler = WaitingResignConfirmHandler(self.game, request)   elif state == 'WAITING_NEWGAME_CONFIRM':       handler = WaitingNewgameConfirmHandler(self.game, request)   elif state == 'GAME_OVER':       handler = GameOverHandler(self.game, request)   elif state == 'WAITING_SKILL_LEVEL':       handler = WaitingSkillLevelHandler(self.game, request)   else:       raise ValueError(f"Неизвестное состояние игры: {state}")          return handler.handle()

Движок Stockfish я вынес на отдельную виртуальную машину в том же Яндекс.Облаке с прицелом на балансировку и масштабирование. Пока что один инстанс выдерживает небольшую нагрузку.

Верхнеуровневая архитектура выглядит примерно так:

С4 level 1

С4 level 1

На графике видно, что новая версия навыка отвечает быстрее

Заключение

Надеюсь, вам было интересно прочитать эту статью. И надеюсь, что у меня найдётся время на дальнейшее улучшение навыка. Хотя я и не уверен, что его развитие кем-то востребовано. Думаю, чат-боты полностью заберут себе возможность играть с ними в шахматы, если ещё не забрали.

Ссылка на навык: https://dialogs.yandex.ru/store/skills/4edf5458-shahmaty-vslepu (не ожидаю хабра-эффекта и надеюсь, что 1 вм справится. Тесты показали, что держит 130 rps)

Ссылка на гитхаб: https://github.com/axtrace/alisa_chess

Всем добра!


ссылка на оригинал статьи https://habr.com/ru/articles/910496/