Пять лет назад, в начале пандемии, мой ребёнок ещё занимался шахматами, но интерес к ним постепенно угасал. К тому же у него появились задания играть без доски - вслепую.
Тогда я решил написать навык для Яндекс.Станции, чтобы можно было играть в шахматы голосом.
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 я вынес на отдельную виртуальную машину в том же Яндекс.Облаке с прицелом на балансировку и масштабирование. Пока что один инстанс выдерживает небольшую нагрузку.
Верхнеуровневая архитектура выглядит примерно так:

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

Заключение
Надеюсь, вам было интересно прочитать эту статью. И надеюсь, что у меня найдётся время на дальнейшее улучшение навыка. Хотя я и не уверен, что его развитие кем-то востребовано. Думаю, чат-боты полностью заберут себе возможность играть с ними в шахматы, если ещё не забрали.
Ссылка на навык: https://dialogs.yandex.ru/store/skills/4edf5458-shahmaty-vslepu (не ожидаю хабра-эффекта и надеюсь, что 1 вм справится. Тесты показали, что держит 130 rps)
Ссылка на гитхаб: https://github.com/axtrace/alisa_chess
Всем добра!