при помощи Crystal, Lucky, Tourmaline и Telegram Bot Gaming Platform

Физикам можно сразу в репу.

Как известно(не многим), программист, хотя бы раз в жизни должен – поломать прод и выхватить за это, починить его, а на досуге построить баню и написать игру.
Успешно выполнив первые пункты пришла пора перейти к последнему из них, чтобы пить заслуженное пиво в бане, ни на что уже более не отвлекаясь.
Просто писать игру достаточно скучно и как и миллиард авторов до меня, я решил сделать такую игру – в которую интересно будет сыграть, хотя бы мне самому и не меньше двух, а то и трёх раз.

К моменту принятия решения, закончились новогодние выходные, друзья мои разъехались и я подумал:

  • для усложнения задачи неплохо было бы воплотить эту самую игру в телеге. 

  • игра должна многопользовательской - как минимум для пары человек.

  • и почему бы не нарды - мы с удовольствием в них рубимся время от времени?

Время на проект (для борьбы с прокрастинацией) выделил себе ровно месяц. Так появилась задача и сроки.

И если, для ее решения, мне нужны инструменты, то почему бы не взять какой нибудь новенький, сверкающий молоток моей мечты, который я давно хотел применить но не было повода взять его в руки?

Итак – Crystal поскольку, я решил, что он прекрасен, удобен и быстр (спойлер: да, так и есть).

Копание в Telegram Bot Gaming Platform несколько разочаровало. Довольно унылые примеры с HTML+JS на тему: нажми кнопку быстро/вовремя или считай в уме быстрее всех, не вдохновляли. Копание в исходниках тележки дало некоторое понимание процесса взаимодействия ее с игрой. Документация на эту тему – довольно мутная.

Сценарий взаимодействия получился такой – телеграмм кидает уведомление, о том что пользователь нажал  кнопку «Играть в ...» под сообщением с игрой. В ответ нужно отправить уникальную ссылку которую телеграмм откроет для пользователя – и да начнется битва)
Конечно я рассчитывал на что нибудь большее, вроде отправки сообщений через сервер tg для какой то коммуникации между участниками игры, но не случилось. Печально, зато можно поковырять websockets.

Что понадобится?

  • endpoint который будет обрабатывать сообщения телеги

  • еще (как минимум один) который собственно и будет отдавать игровую доску

  • нечто, имеющее роутер для обработки запросов, средства собрать html страницу и возможность создавать/хранить/доставать активную игру в базе. Словом небольшой framework который избавит меня от рутины и освободит для высокого)

Выбор мой пал на Lucky (на тот момент версии 0.24) по следующим причинам: 

  • подход создателей и философия проекта

  • хорошая документация

  • механизм передачи параметров от контроллера к визуализации

  • наличие готовых json api контроллеров нужных для api телеги

  • хороший роутер и хелперы к нему

  • более менее приемлемый интерфейс для работы с БД

  • наличие готового деплоя на Heroku  

  • всё лишнее можно отключить

  • всё отсутствующее дописать

Создание игровой доски в примитивном виде.

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

Доска была нарисована в Inkscape и содержит несколько слоев. Сама доска, фишки, кости, слой сообщений. В процессе конечно были сделаны всевозможные ошибки относительно размещения начала системы координат, что важно для использования transform rotate преобразований в SVG.

В приложении, доска представляет из себя SVG, – генерируемый кодом на Crystal при помощи Lucky конечно. Большую часть нудной работы сделал converting HTML to Lucky methods, (в него я загнал svg из Inkscape) остается только убрать повторения и разбить структуру svg на логические элементы с которыми будет удобно работать.

src/pages/active_games/game_page.cr
src/pages/active_games/game_page.cr

В результате из 50kb SVG получилось 2-3 сотни строк на Crystal лежащие в соответствующей page. Время на вывод такой страницы без полезной нагрузки на моем буке измеряется µs. Я не стал разносить всё по компонентам исключительно экономя время. Многие возможности Crystal были просто не использованы. На тот момент степень понимания языка ещё не давала мне использовать все его преимущества. К концу проекта глядя на этот код я понимал, что многое можно сделать на много, на много красивее и удобнее.

Логика игры, правила и ограничения

Теперь когда есть на чем играть, реализую логику – все правила и состояния игры, в одном классе Game, лежит в src/models/game.cr. Примерно 600 строк. Проследить боль и страдания можно в тестах к этому классу до теста 399 строки когда оно смогло играть с собой до победы. Правила брал здесь

Совмещение логики и визуализации игры

Дальше я начал натягивать сову на глобус. Совмещать логику и визуализацию, цель: отображать любое сохраненное состояние игры на доске.

После победы(сова сопротивлялась как могла) я двинул дальше, Надо добавить управление. Детектировать, что ход сделан и отправлять его на сервер в соответствующий action.
Это удобнее было сделать на JS. Я не фанат JS, но когда надо – тогда надо.
Lucky уже настроен для использования всей этой лабуды с Webpack и.т.д. По умолчанию подключены небольшие и довольно полезные Turbolinks и rails-ujs. Выпиливать их не стал. JS в проекте Lucky лежит в src/js/app.js   

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

Доверять полученному со стороны клиента (без должной проверки) нехорошо. Поэтому, работает только логика на сервере в классе Game. Состояние игры всегда корректно, можно спокойно выйти в другой чат и ответить на сообщение, и без проблем снова открыть игру на том же самом месте. Смычка города с селом, происходит в этом action. По сути это один большой case связывающий действия пользователя с состоянием игры. Если изменение было игрок увидит его после выполнения перенаправления на action отображения игровой доски.
На этом же этапе я подключил Turbolinks и rails-ujs для плавного апдейта страницы.

Настало время подключать Telegram

Чтобы тестировать игру в связке с телеграмм нужен сервер с IP или доменом и соответствующим SSL сертификатом для подключения Webhooks через который телеграмм будет слать updates приложению.

Сервис размещаю на Heroku. Процесс настройки деплоя сводится к нескольким тривиальным командам, после прочтения главы документации Lucky на эту тему. Heroku работает с телеграмм без возни с сертификатами сразу из коробки. 

Режим бота для доставки игры выбрал inline mode как наиболее прогрессивный и безопасный.

Быстро набросал клиент для взаимодействия с api телеграмм, пара часов отладки и всё заработало. После чего, естественно, обнаружил прекрасный шард реализующий работу с api телеграмм для Crystal и имя ему Tourmaline. У него был всего один недостаток он не умел игры, я это поправил и автор оперативно принял изменения. Немного перетряхнул код и встроил бот уже через Tourmaline.

Схема работы приложения с api телеграмм подробно:

  1. создание игрового бота, всё стандартно через @BotFather

  2. action в Lucky который будет принимать updates от телеграмм. 

  3. прописать url этого action с помощью setwebhook в телеграмм api. (Tourmaline позволяет динамически устанавливать webhook, но я предпочел самостоятельно контролировать этот процесс)

  4. при регистрации игры выдается ссылка вида game link (e.g., t.me/bot?game=game) в моем случае  http://t.me/tavla_best_bot?game=tavla. Для начала, достаточно этой ссылки чтобы поделится игрой. Клик на нее предложит выбрать чат, куда будет отправлено сообщение с игрой.

  5. Под сообщением, по умолчанию будет одна обязательная кнопка «Играть в…». Клик пользователем по этой кнопке, отправляет на action (привязанный в пункте 3 к Webhook) структуру Update c вложенной в поле callback_query еще одной структурой с оригинальным названием CallbackQuery. Из нее берется поле game_short_name – имя вызываемой игры (если у нас ссылка вида http://t.me/tavla_best_bot?game=tavla  значение должно быть «tavla») и второе поле callback_query.id нужно передать обратно в answerCallbackQuery, чтобы телега понимала на что отвечаем.
    Tourmaline активно  использует аннотации, что очень удобно в написании бота, но не всегда удобно искать где отработает соответствующая функция. Из action апдейт прилетит сюда.

Структура Update
Структура Update

В соответствии с 5 пунктом соглашения при создании игрового бота не получится хранить куки и создавать сессии – поэтому все нужные параметры должны быть в url который генерирую в ответе. Фактически это идентификация игры и пользователя по динамическому url. Проверять такие url надо самостоятельно, поэтому использую helper и mixin.

Если всё прошло успешно, телеграмм клиент открывает в браузере встроенном или внешнем переданный ему в answerCallbackQuery url. 

Параметры user сохраняются в базу при необходимости. Я использую в проекте Postgres скорее по привычке и для потестить ORM Avram, чем по необходимости, а вообще, вполне хватило бы Redis. 

Выглядит url так (разделение по двойному тире): /active_games/1eae03de19d1e909207c6192baff500771e0cbeb--AgzzzzzzzzzzzzzzUWfH7r74--321232123--5345353343 

Первая часть подпись, генерируется из остальных параметров и некоего salt. Вторая часть inline_mesage_id. Третья и четвертая id пользователя телеграмм. Четвертая часть не обязательна, в этом случае за второго игрока станет действовать бот.

Идентификаторinline_mesage_id однозначно определяет сообщение с игрой по которому сделан клик, user_idтого кто кликал.

На этом работа телеги пока заканчивается и я возвращаюсь к игровой странице.

Получив запрос get c url и проверив его валидность смотрю есть ли в базе такая игра. Если есть отображаю ее, если нет - сначала создаю.

Общая логика такая (здесь) –  если два человека нажали в чате на одном сообщение кнопку играть, до того как игра начата одним из них, игра будет человек – человек, в противном случае человек – бот. Естественно схему можно менять как угодно, вплоть до рейтинга игроков и поиска активных в данный момент соперников не имеющих общих чатов.

Последние штрихи

Добавил в middleware Lucky websocket обработчик для доставки обновлений.  Когда противник сделал ход, игра обновит и доску второго игрока. На тот момент ws actions в Lucky был только в виде пары коммитов в экспериментальной ветке, но это всё равно было немного не то, что мне нужно. 

Отладка websocket
Отладка websocket

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

Вызов бота через inline запрос
Вызов бота через inline запрос

Добавил боту возможность отправлять игру через inline запрос. Для этого в строке чата набираем имя бота @tavla_best_bot в ответ появится подсказка с игрой на которую нужно кликнуть.
Добавил меню c кнопками запуска игры, для комплекта если кто то запустит бот напрямую. 
Добавил отправку набранных игровых очков в телеграмм.   

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

Проблема оставшаяся нерешенной из скупердяйства принципиальных соображений – вертикальный режим в каких то (не всех) Iphone/Ipad, – ломает верстку. Я не пользуюсь этими девайсами, а реально бесплатных тестовых сред для разработчика я не нашел. Если кто то пофиксит буду рад.

Ps

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

Мест приложения рук, еще довольно много, например добавить боту игры ума. Возможно со временем я сделаю порт TDBackGammon и бот будет красавчик, но пока времени на это нет. 

Всего на проект я потратил почти 75 часов за отведенные 30 дней, в среднем примерно по 2.5 часа в день, самый длительный непрерывный интервал 9.5 часов.

Pps

Еще момент который касается бесплатных инстансов Heroku, они засыпают при неактивности, поэтому, отклик которого телеграмм ждет от приложения очень быстро, может не успеть с первого раза. Чтобы избежать подобной ситуации я дергаю инстанс снаружи HEAD запросом 1 раз в минуту, при таком подходе времени бесплатной активности вполне хватает на месяц для всех, кто сейчас играет в tavla. Надеюсь хабраэффекта не случится, и я и дальше смогу спокойно рубиться в нее за завтраком:)

Моя искренняя благодарность всем, кто делает и помогает делать Crystal и его экосистему. Мне очень понравился Crystal. Я сделал для себя определённые выводы относительно будущего этого языка и постараюсь сделать на нём еще несколько проектов, не важно по работе или ради собственного удовольствия. Спасибо всем, кто это осилил, успехов и удачных проектов!