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

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

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

Всем добра!

Комментарии (0)