при помощи Crystal, Lucky, Tourmaline и Telegram Bot Gaming Platform
Физикам можно сразу в репу.
![](https://habrastorage.org/getpro/habr/upload_files/ae7/361/1cf/ae73611cf38481dc0924c29fe3871685.png)
Как известно(не многим), программист, хотя бы раз в жизни должен – поломать прод и выхватить за это, починить его, а на досуге построить баню и написать игру.
Успешно выполнив первые пункты пришла пора перейти к последнему из них, чтобы пить заслуженное пиво в бане, ни на что уже более не отвлекаясь.
Просто писать игру достаточно скучно и как и миллиард авторов до меня, я решил сделать такую игру – в которую интересно будет сыграть, хотя бы мне самому и не меньше двух, а то и трёх раз.
К моменту принятия решения, закончились новогодние выходные, друзья мои разъехались и я подумал:
для усложнения задачи неплохо было бы воплотить эту самую игру в телеге.
игра должна многопользовательской - как минимум для пары человек.
и почему бы не нарды - мы с удовольствием в них рубимся время от времени?
Время на проект (для борьбы с прокрастинацией) выделил себе ровно месяц. Так появилась задача и сроки.
И если, для ее решения, мне нужны инструменты, то почему бы не взять какой нибудь новенький, сверкающий молоток моей мечты, который я давно хотел применить но не было повода взять его в руки?
Итак – Crystal поскольку, я решил, что он прекрасен, удобен и быстр (спойлер: да, так и есть).
Копание в Telegram Bot Gaming Platform несколько разочаровало. Довольно унылые примеры с HTML+JS на тему: нажми кнопку быстро/вовремя или считай в уме быстрее всех, не вдохновляли. Копание в исходниках тележки дало некоторое понимание процесса взаимодействия ее с игрой. Документация на эту тему – довольно мутная.
Сценарий взаимодействия получился такой – телеграмм кидает уведомление, о том что пользователь нажал кнопку «Играть в ...» под сообщением с игрой. В ответ нужно отправить уникальную ссылку которую телеграмм откроет для пользователя – и да начнется битва)
Конечно я рассчитывал на что нибудь большее, вроде отправки сообщений через сервер tg для какой то коммуникации между участниками игры, но не случилось. Печально, зато можно поковырять websockets.
Что понадобится?
endpoint который будет обрабатывать сообщения телеги
еще (как минимум один) который собственно и будет отдавать игровую доску
нечто, имеющее роутер для обработки запросов, средства собрать html страницу и возможность создавать/хранить/доставать активную игру в базе. Словом небольшой framework который избавит меня от рутины и освободит для высокого)
Выбор мой пал на Lucky (на тот момент версии 0.24) по следующим причинам:
подход создателей и философия проекта
хорошая документация
механизм передачи параметров от контроллера к визуализации
наличие готовых json api контроллеров нужных для api телеги
хороший роутер и хелперы к нему
более менее приемлемый интерфейс для работы с БД
наличие готового деплоя на Heroku
всё лишнее можно отключить
всё отсутствующее дописать
Создание игровой доски в примитивном виде.
![Выглядело примерно так Выглядело примерно так](https://habrastorage.org/getpro/habr/upload_files/841/0ea/6a6/8410ea6a6632752731d6475912fa89c8.png)
Доска была нарисована в 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](https://habrastorage.org/getpro/habr/upload_files/1b9/f1d/7d6/1b9f1d7d6c86c555b30e0ecf0902e000.png)
В результате из 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 телеграмм подробно:
создание игрового бота, всё стандартно через @BotFather
action в Lucky который будет принимать updates от телеграмм.
прописать url этого action с помощью setwebhook в телеграмм api. (Tourmaline позволяет динамически устанавливать webhook, но я предпочел самостоятельно контролировать этот процесс)
при регистрации игры выдается ссылка вида game link (e.g., t.me/bot?game=game) в моем случае http://t.me/tavla_best_bot?game=tavla. Для начала, достаточно этой ссылки чтобы поделится игрой. Клик на нее предложит выбрать чат, куда будет отправлено сообщение с игрой.
Под сообщением, по умолчанию будет одна обязательная кнопка «Играть в…». Клик пользователем по этой кнопке, отправляет на 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](https://habrastorage.org/getpro/habr/upload_files/aa8/2ce/758/aa82ce758e6ae35cfbd1d31ed99693db.png)
В соответствии с 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](https://habrastorage.org/getpro/habr/upload_files/93f/e0f/a32/93fe0fa32d45b994cf973ee2d0cdca02.png)
Тут пришлось повозится из за чудесатой поддержки websocket в Heroku, а точнее на бесплатном инстансе.
Он тупо рвет соединение при неактивности сокета. Пришлось добавить пинг от клиента через подобранный эмпирическим путем интервал и отправку мусорного сообщения в ответ.
![Вызов бота через inline запрос Вызов бота через inline запрос](https://habrastorage.org/getpro/habr/upload_files/8df/d1d/40d/8dfd1d40d4e8bf5f0f9c2f11ef5132aa.jpeg)
Добавил боту возможность отправлять игру через inline запрос. Для этого в строке чата набираем имя бота @tavla_best_bot
в ответ появится подсказка с игрой на которую нужно кликнуть.
Добавил меню c кнопками запуска игры, для комплекта если кто то запустит бот напрямую.
Добавил отправку набранных игровых очков в телеграмм.
На этом процесс можно было считать законченным. Оставшееся время я потратил на визуальщину и вкусовщину. Frontend не является моей специализацией, поэтому прошу отнестись снисходительно. Многое наверняка можно было сделать красивее и проще.
Проблема оставшаяся нерешенной из скупердяйства принципиальных соображений – вертикальный режим в каких то (не всех) Iphone/Ipad, – ломает верстку. Я не пользуюсь этими девайсами, а реально бесплатных тестовых сред для разработчика я не нашел. Если кто то пофиксит буду рад.
Ps
Код выложен в том виде к котором был на момент окончания времени проекта, он полностью рабочий, следуя инструкции в Readme можно поднять свой инстанс игры, естественно бот тоже нужен свой.
Мест приложения рук, еще довольно много, например добавить боту игры ума. Возможно со временем я сделаю порт TDBackGammon и бот будет красавчик, но пока времени на это нет.
Всего на проект я потратил почти 75 часов за отведенные 30 дней, в среднем примерно по 2.5 часа в день, самый длительный непрерывный интервал 9.5 часов.
Pps
Еще момент который касается бесплатных инстансов Heroku, они засыпают при неактивности, поэтому, отклик которого телеграмм ждет от приложения очень быстро, может не успеть с первого раза. Чтобы избежать подобной ситуации я дергаю инстанс снаружи HEAD запросом 1 раз в минуту, при таком подходе времени бесплатной активности вполне хватает на месяц для всех, кто сейчас играет в tavla. Надеюсь хабраэффекта не случится, и я и дальше смогу спокойно рубиться в нее за завтраком:)
Моя искренняя благодарность всем, кто делает и помогает делать Crystal и его экосистему. Мне очень понравился Crystal. Я сделал для себя определённые выводы относительно будущего этого языка и постараюсь сделать на нём еще несколько проектов, не важно по работе или ради собственного удовольствия. Спасибо всем, кто это осилил, успехов и удачных проектов!
Throwable
Круто! Года 3 года назад я тоже сделал бота для онлайн игры в шахматы: https://t.me/GameFactoryBot Многие решения реализованы похожим образом (даже Postgres), за исключением того, что бэкенд на джаве. Не понял только как реализована нотификация хода с сервера -- Heroku вроде бы не дает вебсокеты.
Не лучшие впечатления от Telegram Bot Gaming Platform: она не эволюционирует и выглядит заброшенной, функционал заточен под простые казуальные игры и не приспособлен для мультиплеера, в десктопной версии из чата не работают линки на t.me/bot, невозможность нормально "зацепить" игру в чате с партией на сервере, кроме как по inline_mesage_id, ограничение, что игру вызывает только первая кнопка.
dammer Автор
Спасибо, приятно знать, что я страдал не один:)
Видимо за три года эта возможность появилась, она немного кривая (рвётся коннект при неактивности как я и писал) но победить можно. Открыв ws я запускаю таймер на отсылку чего нибудь на сервер, желательно не одинакового, в свою очередь сервер отвечает тоже специальным мусором который клиент игнорирует. Полезное сообщение только одно, и заставляет клиент сопреника обновить страницу.
Думаю на самом деле можно, если правильно помню, можно передавать дополнительные параметры к ссылке дополнительной кнопки. Либо, имея список живых(по ws) клиентов выводить, кто находится в ожидании партнёра если сделать такой режим. Придётся повозится конечно, но теоретически ничего не мешает. Список хранящий живые ws сессии я сделал, но до баттлмода руки не дошли к сожалению.