И снова здравствуйте! Часто ли вам в голову приходили идеи проектов, которые буквально мешали вам спать? То чувство, когда ты волнуешься, переживаешь и не можешь нормально работать над другими вещами. У меня такое бывает несколько раз в год. Какие-то идеи пропадают сами собой после углубления в тему и понимания, что извлечь пользу из такого начинания будет крайне сложно. Но есть такие идеи, развивая которые даже пару часов, захватывают меня настолько, что аж кушать не могу. Этот пост о том, как мне удалось воплотить одну из таких идей за пару вечеров после работы и не помереть с голоду. А ведь сама идея изначально звучало довольно амбициозно — PvP игра, в которой игроки соревнуются друг с другом, отвечая на вопросы.  Последнее время я был занят собственным проектом "Конструктор чат-ботов для бизнеса Botlify", первую часть истории создания которого вы можете прочитать по ссылке на Хабре. Но я решил отвлечься и попробовать использовать чат-ботов не для бизнеса, а для игр. Честно признаюсь, лет с 14-15 я почти не играю в компьютерные и мобильные игры, но каждый раз получаю огромное удовольствие от их программирования. В очередной раз пролистывая ленту небезызвестной социальной сети я наткнулся на игру, над которой работал в одной компании в далеком 2013 году. Помимо того проекта, над которым работал я, у этой компании была еще одна игра, которая представляла собой адаптацию телевизионного шоу "100 к 1". К моему удивлению, я узнал, что телевизионная игра до сих пор существует, а вот в социальной сети она не работала. Я подумал, что было бы круто сделать адаптацию для мессенджеров, ведь сам формат игры по моим представлениям очень легко укладывался в концепцию чат-бота. Дополнительным преимуществом для меня в этом проекте был бесценный опыт работы с Telegram API, который мне очень пригодился при разработке моего конструктора чат-ботов. Думаю, многим из вас знакомо: заводишь новый пет-проджект, начинаешь проектировать, продумывать архитектуру, пытаешься предусмотреть гипотетические ситуации(вероятность наступления которых, как правило не больше 1% — YAGNI), кучу разных фич без которых, как ошибочно кажется, нельзя выпускать релиз. Итогом, как правило становится то, что проект так и остается на задворках никогда не увидев свет. Я проходил это много раз и у меня без преувеличения есть добрая сотня незавершенных проектов. В этот раз я четко решил установить дедлайн и дал себе на разработку максимум 3 вечера. Разрабатывать прототип дольше — роскошь, ведь у меня все-таки есть и основной проект в котором есть прорва задач. Итак, я хочу сформулировать первое правило разработки прототипов: "Если вы нацелены на результат, а не планируете вечно наслаждаться процессом разработки, определите дедлайн". Даже если работаете в одиночку, как в этом случае было со мной. Вы всегда найдете что сделать лучше, надежнее, быстрее. Не ведитесь — это коварный мозг загоняет вас в ловушку. Лучше упасть под нагрузкой в 10 запросов в секунду, чем иметь приложение, способное обработать тысячи, но так и не увидившее свет. Признаюсь честно, я не знаком с тонкостями оригинальной телевизионной игры, по сути я делал адаптацию адапции и хотел в первую очередь добиться того, чтобы было занимательно играть через Telegram. При разработке игры, первым делом бывает полезным формализовать правила и сыграть несколько тестовых партий с друзьями. Без Telegram или любого другого ПО. Описать правила для такой игры оказалось чертовски легко. Через пару часов игр в аналогичные игры я сформулировал текст правил: Каждая партия состоит из 3 раундов. Во время раунда игроки по очереди отвечают на случайный вопрос из базы вопросов. Ответив на вопрос, пользователь получает баллы в соответствии со списком и и передает ход другому игроку. Каждый раунд состоит из 6 ходов, таким образом у каждого игрока есть по 3 попытки ответа на один вопрос. Как можно догадаться, суть состоит в том, чтобы набрать наибольшее количество очков. Чем более популярный ответ вы даете — тем больше очков. Если вы даете существующий ответ, добавляем за него 1 "голос". Если такого ответа еще никто не давал — добавляем его в список существующих ответов с одним "голосом". Чем больше у ответа голосов — тем более популярны считается ответ. Чтобы игра не затягивалась, а игроки чувствовали напряжение, существует ограничение на ответ в 20 секунд. Не успел? Ход переходит другому игроку, а тебе начисляется утешительные 0 баллов. По завершению 3 раунда подсчитываем очки и определяем победителя. Показал правила своей жене и спросил, понятны ли они, а получив утвердительный ответ, предложил сыграть несколько партий "на словах", выбирая вопросы из открытой базы в интернете(конечно, я жульничал, ведь видел еще и ответы). Процесс показался жене достаточно увлекательным, а правила простыми и, получив одобрение, я сел кодить
 Перед началом написания кода я люблю заранее попробовать определить с какими абстракциями мне вообще предстоит иметь дело, а заодно попытаться описать за что каждая из них будет отвечать. Из одного только текста правил можно определить, что нам, скорее всего, придется иметь дело со следующими "сущностями": Все самое необходимое для игры в первом приближении мы продумали. Для того, чтобы игра была больше похожа на игру, я решил также озадачится общим рейтингом игроков, добавляя тем самым мотивацию играть больше, ведь многие хотят быть где-то первым, любят разного рода баллы, очки, голоса, карму(да, дорогой Хабр?), рейтинг — соревновательный интерес. Определив необходимый минимум для игрового процесса, я задал себе простой вопрос: "Что помимо самого игрового процесса мне нужно реализовать, чтобы это было похоже на законченный продукт?". Я решил, что было бы неплохо дать возможность: Здесь явно нарисовалась потребность в каком-то меню и тут же появились неигровые состояния, а именно: главное меню и разного рода подменю, ожидание своей очереди в играх со случайным соперником, ожидание подтверждения игры, ожидание начала игры. По моему замыслу все вместе это должно было работать так:
По команде /start бот показывает приветственное сообщение и главное меню. Далее, игрок выбрает игру со случайным соперником и ему показывается сообщение о том, что он добавлен в список ожидания и как только мы найдем оппонента — сообщим. Раз в N-секунд я планировал проверять список игроков в очереди, составлять пары и отправлять им сообщение с просьбой подтвердить матч. Когда матч подтвержден обоими игроками — уведомляем их о скором начале партии и даем немного времени на подготовку, то есть переходим в состояние "ожидание начала игры". Потом просто по таймеру стартуем игру и переходим к игровым состояниям. Согласитесь, все что я описал до сих пор звучит чертовски просто. В своем предыдущем материале я уже писал, что самая быстрая для разработки технология — та, которую ты знаешь лучше всего. Потому для реализации бекенда я взял знакомый мне NodeJS c TypeScript и начал колбасить. Без чего невозможна ни одна игра? Конечно же без игрока. 10 минут в редакторе кода и я получаю простенький интерфейс: Ни необходимости в сложных связях между моделями, ни особых требований к сохранности данных, ни каких-то сложных структур я не обнаружил. При выборе СУБД я подумал, что большая часть того, что мне предлагают "тяжелые" решения вроде MySQL, Postgres или MongoDB мне попросту ненужны. Я решил тряхнуть стариной и вспомнить Redis. Я его использовал до этого в основном как кэш, или для организации очередей и думал, что буду использовать его просто как key-value хранилище и сохранять туда JSON. На всякий случай открыл документацию и тут меня озарило, что использование именно Redis позволит мне сохранить немало времени. Мне понравилось, что используя Redis я не только могу автоматически "подчищать" данные, которые мне больше не нужны, но и использовать встроенную систему "очков"(scores), pub/sub, а про скорость работы Redis я думаю, вы все и так знаете. Выбрав СУБД, я быстренько накидал простейший сервис с функциями сохранения игрока в БД, получения его оттуда, а так же функциями winGame и looseGame, которые обновляют соответствующие данные у пользователя и рейтинг игроков. Рейтинг я сделал сразу же, еще до игры, ведь Redis предоставляет замечательную структуру данных sorted set, которая позволяет по ключу хранить набор значений, каждому из которых можно задать вес(score). В соответствии с весом список будет отсортирован. То есть глобальный рейтинг игроков в моей игре это просто sorted set в котором хранятся id всех игроков, а вес(score) соответствует рейтингу игрока. Конечно, рейтинг нужен не только для того, чтобы мериться с другими игроками. Он также нужен был для того, чтобы более опытные игроки по возможности играли с такими же опытными игроками, а менее опытные с менее опытными соответственно. Писать самому алгоритм рассчета рейтинга? Мой проект совсем не об этом и я пошел в гугл с запросом в духе "Rating system". Тут же мне выпала какая-то статья про рейтинговую систему, применяемую в шахматах под названием ELO Rating. Если честно, я не вникал в детали особо глубоко, но быстро понял, что это мне вполне подойдет. Идем на npmjs.com и вводим запрос elo-rating и тут же БИНГО! https://www.npmjs.com/package/elo-rating. Пусть либа и не особо популярная, но зато работает. Взял, пару раз вызвал функцию рассчета нового рейтинга по результатам игры — сработало, внедрил. Теперь у меня есть игрок, который умеет проигрывать и побеждать и рейтинговая система, отлично! При этом весь "сервис" player.service.ts с учетом админской функции вывода списка, возможностью удаления, создания игрока, а также методами "выиграть" и "проиграть" уместился в 130 строк кода. На всякий случай, для понимания того, чем он занят — вот его интерфейс, весь код я выкладывать не буду, но внимательный читатель, глядя на интерфейс сущности Player легко сможет представить себе реализацию каждого из методов в пару-тройку строк. После этого я посчитал, что непосредственной работы на этот день мне хватит, заварил чашечку "каркадэ" с сахарочком и начал представлять себе, как я скоро опубликую игру и буду играть в нее со своей женой. С этими мыслями я и отправился спать. Следующий день в моем основном проекте выдался очень бурным, а я никак не мог сконцентрироваться — мысли были об игре. Мне хотелось как можно быстрее сесть программировать. И вот, дождавшись окончания рабочего дня, я наконец открыл редактор кода, пробежался по вчерашним наработкам и наметил такой план: Вопросы и ответы в игре имеют ключевое значение. Все-таки, механика игры завязана именно на них. Вопрос в нашем случае это просто какой-то вопросительный текст, идентификатор и дата добавления(на всякий случай). Сохранять вопросы я решил в обычный key-value, в качестве ключа — ID, в качестве значения JSON-строка объекта вопроса. Дополнительно я завел себе неупорядоченный список(set) в котором хранятся ID всех доступных вопросов, чтобы было удобно их выбирать. Какие операции над вопросами нам необходимы? Я определил следующие: Не обошлось и без небольших вспомогательных функций, но даже с ними сервис чертовски прост и выглядит примерно так: Обратите внимание на то, что выбор случайного вопроса я просто отдал на откуп Redis. Уверен, каждый из Вас найдет множество способов улучшить этот код, но давайте простим мне его качество и двинемся дальше. Следующее, чем нужно было озадачиться — ответы на вопросы. Во-первых, ответы на вопросы добавляют сами игроки. Во-вторых, у ответов есть свой рейтинг, который влияет на то, сколько очков получит игрок. И, наконец, каждый ответ жестко привязан к определнномму вопросу. Также, мне хотелось, чтобы ответы не только не зависили от регистра, ведь было бы обидно, если ты ответил правильно, а из-за регистра ты не получил положенные по праву очки, но и допускали бы небольшие опечатки и разнообразие форм. Тут же в памяти всплыл какой-то коэффициент Жаккара, пошел гуглить как бы опредилить сходство строк и, немного почитав одернул себя. Все ведь уже сделали до меня, а значит нужно сначала попробовать загуглить готовый пакет для определения сходства строк по какому-то коэффициенту. Буквально через 5 минут у меня уже был выбор из нескольких готовых решений и я решил остановиться на https://github.com/aceakash/string-similarity основанном на коэффициенте Сёренсена. Кому интересно, как это работает — велкам в википедию. Поигравшись с библиотекой в песочнице, методом научного тыка я выяснил, что коэффициент сходства 0.75 мне вполне подходит, хоть иногда и не пропускает неккоторые опечатки и формы слов. Но все же — лучше, чем ничего. Для каждого существующего вопроса я решил завести отдельный упорядоченный набор(sorted set) ответов в котором порядок ответов определен непосредственно популярностью. Поскольку ответы настолько тесно связаны с вопросами я решил делать все в том же самом QuestionService, добавив туда методы добавления ответа, получения списка всех существующих ответов, получение списка ТОП-ответов(за которые начисляются баллы), определения существует ли уже такой ответ на этот вопрос, определения очков, положенных за этот ответ и т.п. Конечно, нужно было исключить возможность дважды получить баллы за один и тот же ответ на один и тот же вопрос в рамках одной игры. А потому, ответы в какой-то степени сопряжены с конкретной игрой. В итоге, мой QuestionService пополнился следующим кодом: Как можно понять из этого куска, логика добавления ответа примерно следующая: приводим ответ к нижнему регистру. Проверяем, существует ли уже такой ответ и, если нет — добавляем его в базу и возвращаем 0 очков за него. Если ответ уже есть, то добавляем "голос" за ответ, делая его более популярным в нашей системе, продвигая в общем рейтинге ответов на этот вопрос. Дальше, мы пытаемся узнать, положены ли игроку очки за этот ответ. Для этого мы получаем топовые ответы на этот вопрос и смотрим, есть ли среди них те, коэффициент Сёренсена которых ≥ 0.75 для ответа игрока. Чтобы "баллы" за ответы на вопрос не менялись прямо во время игры, я решил копировать ТОП-ответы на вопросы из общего списка ответов и использовать копию для каждой конкретной игры. Почему я решил засунуть это в getTopAnswers? Возможно, я был пьян — не делайте так ;) Со всеми этими и несколькими другими функциями, весь сервис занял у меня < 300 строк и при этом не только давал возможность управлять ответами и вопросами, но и определять сколько баллов должен получить игрок за ответ в рамках конкретной игры(точнее в рамках какого-то gameId, ведь игры еще и в помине нет). Уже чувствуете, как на ваших глазах игра приобретает очертания? У нас уже есть игроки, вопросы к которым эти самые игроки могут добавлять ответы и даже возможность определить сколько баллов положено конкрентному игроку за ответ. Пора бы приступить непосредственно к игровому циклу, не так ли? Как я уже писал выше, каждая партия(игра) состоит из раундов и ходов. В каждой игре будет участвовать 2 игрока — player1 и player2 соответственно. У игры есть несколько состояний: ожидание игроков, в процессе, завершена. Саму игру я решил создавать только в тот момент, когда оба игрока уже известны, а статус "ожидание игроков" нужен для того, чтобы после создания игры игроки успели подключится и подтвердили свое участие. Когда оба игрока подтвердили — можно переходить к самой игре. Игры я решил хранить как key-value, где ключ как всегда = ID, а value = JSON.stringify(data). В итоге, получился такой вот незатейливый интерфейс игры Игровой раунд же должен иметь порядковый номер, информацию о текущем ходе, содержать вопрос(помните? 1 раунд — 1 вопрос), а также ответы игроков. Теперь мы можем перейти к game.service.ts в котором и будем описывать логику, а именно: инициализация игры, старт игры, смена раундов, смена ходов, начисление баллов за ответы и подведение итогов. В игре есть ряд событий, при наступлении которых нам стоит совершать каакие-то действия, например, уведомлять игроков о смене хода или конце игры. Для работы с этими событиями я использовал Redis pub/sub. Внимательный читатль возможно помнит, что время хода игрока ограничено. Для этого я решил использовать максимально простой, но довольно опасный подход — стандартные таймеры, но мы тут не строим какую-то масштабируемую систему, а получаем удовольствие от процесса, так что и так сойдет. Отлично, уже что-то. Теперь оставалось решить вопрос с тем, чтобы игроки могли подключаться к игре. Поскольку проблему с рейтингом игроков мы уже решили, а матчмейкинг я хотел организовать именно на его основе — половина проблемы уже решена. Предполагается, что когда игрок хочет поучаствовать в игре, он добавляется в некий список ожидания, из которого мы создаем пары игроков с наиболее близким рейтингом. В этом нам снова поможет Redis с его упорядоченными наборами(sorted sets). Таким образом мы можем организовать добавление игрока в список ожидания буквально в пару строку(можно и в одну в ущерб читабельности и константа тут лишняя, но простите мне это). При подключении к игре мы должы удалять игрока из списка ожидания. Сформированные пару должны инициализировать новую игру. Поскольку матчмейкинг в моем случае настолько простой я засунул его прямо в game.service.ts, добавив туда нечто такое Круто, игровой процесс есть, игроки могут добавиться в список ожидания и подключиться к игре. Замечу, что опубликован не весь код и в планах публиковать его весь у меня нет. Мне приходится восстанавливать события по кусочкам и это немного затруднительно, что-то я мог упустить. Немножко потестировав, довольный собой я отправился спать. Вечер следующего дня обещал быть не менее интересным, ведь я планировал сделать интерфейс самой игры. Чат-бот — всего лишь интерфейс. Я безумно рад тому, что в отличии от веб-сайтов тут не пришлось ничего верстать, писать css и клиентский JS-код — за нас это все уже сделали разработчики Telegram. А я просто могу воспользоваться результатами их труда. Как мне кажется, в процессе чтения API документации я немного неверно уловил несколько концепций. Тем не менее, на работоспособности игры это особо не сказалось, а скорее касается удобства. Первым делом, я озадачился текстами, ведь наш будующий бот должен как-то приветствовать игрока, рассказать куда он попал и что нужно делать. Немного переписал правила, чтобы они лучше отражали суть происходящего и были более понятны игрокам, получился такой текст: Поработав над текстами, я задумался о том, как в принципе работают чат-боты в телеграм, а точнее о том, как получать обновления от игроков(команды боту, ответы на вопросы). Существует 2 подхода: pull и push. Pull подход подразумевает, что мы "опрашиваем" Telegram на предмет изменений, для этого в API Telegram существует метод getUpdates. Push это когда телеграм сам присылает нам обновления, если они есть, иными словами — дергает webhook. Вебхуки показались мне более хорошей идей, поскольку избавляют от необходимости часто "опрашивать" телеграм на предмет обновлений даже если их нет, а так же от необходимости реализации механизма самого получения этих данных. Значит, вебхукам быть! Осмотрев доступные библиотеки для создания ботов и официальный SDK я выбрал Telegraf, ибо мне показалось, что его уровень абстракции как раз подходит для моих нужд — удобные, простые и понятные интерфейсы. Да еще и довольно большое коммунити, активная поддержка и разработка. Останавливаться на том, как "зарегистрировать" бота в телеграм, получить API key я не буду, это все подробно описано в документации, так что предлагаю сразу перейти к делу. Для работы с ботами я создал очередной сервис — bot.service.ts, который и должен взять на себя все взаимодействие с Telegram. Для начала, я решил определиться с тем, какие команды будет понимать мой бот. Почитав документацию и порисовав на листочке схему работы я получил такой список: Можно заметить, что главное меню выводится почти на любую команду. Потому, его описание я вынес в отдельную функцию и получил что-то вроде этого Можно обратить внимание на свойство callback_data. Telegram пришлет эти данные при нажатии на кнопку, а я, в свою очередь смогу определить что же за кнопку нажали и отреагировать верным образом. Для обработки таких вот коллбеков я сделал отдельную функцию, в которой повесил switch/case statement по полю type, внутри которого просто вызываю нужный метод. Далее я попробовал реаализовать информационные команды start и help, чтобы попробовать протестировать бота и узнать дойдет ли до меня вебхук, залогировать его и попробовать ответить. Тут же я узнал, что Telegram шлет запросы только по https, да еще и какой-то домен мне бы не повредил. Для того, чтобы все это получить локально и желательно не тратить на это пол дня я взял небезызвестный Ngrok с которым справится даже ребенок. Как видно, в команде старт я просто создаю игрока и отправляю ему приветственное сообщение с клавиатурой. Как ни странно, все заработало и у меня отлегло от сердца. Весь предыдущий труд был не напрасен и, судя по всему, у меня получится сделать все что я хотел. Дальше, как говорится, дело техники. Я думаю, что большого смысла писать тут реализацию всех команд нет. Когда я добрался до матчмейкинга, мне пришлось вспомнить тот самый Redis pub/sub. Раньше мы уже сделали публикацию игровых событий. Теперь нужно было организовать слушателя, который отправлял бы соответствующие сообщения при нахождении пары, начале матча, принятии ответа оппонента(чтобы показать что он ответил и сколько очков заработал). Временно засунул этого слушателя прямо в свой main.ts, но все мы знаем, что нет ничего более постоянного, чем временное. Переделать руки так и не дошли. Типичный "слушатель" в итоге выглядит так В BotService.sendGameCreated, как не трудно догадаться мы просто отправляем обоим игрокам соответсвующие сообщения о том, что мы нашли пару и просим подтвердить участие в игре. Когда дело дошло до работы внутри самой игры(и еще нескольких других функций, добавленных позже), возникла необходимость где-то хранить состояние игроков, чтобы бот понимал, находится ли игрок внутри игры, или в меню. А если в меню, то в каком? Я немного поковырял механизм Stages и Scenes, предоставленный Telegraf, поскольку по документации мне показалось что это именно то, что мне нужно. Но, к сожалению, за 30 минут мне так и не удалось заставить его работать и я воспользовался старыми добрыми сессиями, которые просто сохранил в Redis. Когда боту приходит текстовое сообщение от игрока, бот проверяет, находится ли приславший сообщение в актвной игре. Если да, то мы смотрим чей сейчас ход и, если ход того, кто прислал сообщение — засчитываем ответ(если его еще не было). Если же ход другого игрока — присылаем сообщение о том, что сейчас ход оппонента, подождите его ответа. На все сообщения, кроме текстовых бот отвечает, что поддерживает только текст. Доделав Telegram-обертку над игрой, я отправил ссылку на бота жене и парочке друзей. Конечно, в процессе находились какие-то баги. Особенно позабавило то, что у одного из знакомых в имени было нечто вро </миша>, а поскольку я использую parseMode HTML, телеграм начал мне выдавать ошибку: "Не могу спарсить html" и при этом сообщения ВСЕМ перестали доходить. Тем не менее от игр со случайными соперниками получил удовольствие не только я, но и мои знакомые. Некоторые из них даже делились им со своими друзьями и за несколько часов тестирования на localhost количество игроков достигло 50 человек. Я посчитал этот эксперимент удачным, убил NodeJS процесс и успешно вернулся к своим повседневным делам. Идея написать об этом пост зрела очень долго и к моменту его написания я даже не удосужился выгрузить бота на какой-нибудь сервер, однако, к моменту публикации все-таки заставил себя это сделать. Я очень надеюсь, что мой пост вдохновит кого-то на реализацию своих идей, заставит делать пет-проджекты быстрей и доводить их до релиза, поможет целиком представить процесс создания проектов от идеи до реалиазации, или просто позволит получить хоть немного удовольствия от чтения. Я почти уверен в том, что бот не выдержит большое количество игроков. Все-таки таймеры, subscriber прямо в main, один процесс и т.д и т.п. Тем не менее, если кому-то интересно посмотреть на результат — ищите бота @QuizMatchGameBot. Всем мир! CTO