Прототип многопользовательской игры за 3 вечера?

от автора

И снова здравствуйте! Часто ли вам в голову приходили идеи проектов, которые буквально мешали вам спать? То чувство, когда ты волнуешься, переживаешь и не можешь нормально работать над другими вещами. У меня такое бывает несколько раз в год. Какие-то идеи пропадают сами собой после углубления в тему и понимания, что извлечь пользу из такого начинания будет крайне сложно. Но есть такие идеи, развивая которые даже пару часов, захватывают меня настолько, что аж кушать не могу. Этот пост о том, как мне удалось воплотить одну из таких идей за пару вечеров после работы и не помереть с голоду. А ведь сама идея изначально звучало довольно амбициозно — PvP игра, в которой игроки соревнуются друг с другом, отвечая на вопросы.

Последнее время я был занят собственным проектом "Конструктор чат-ботов для бизнеса Botlify", первую часть истории создания которого вы можете прочитать по ссылке на Хабре. Но я решил отвлечься и попробовать использовать чат-ботов не для бизнеса, а для игр. Честно признаюсь, лет с 14-15 я почти не играю в компьютерные и мобильные игры, но каждый раз получаю огромное удовольствие от их программирования.

Идея

В очередной раз пролистывая ленту небезызвестной социальной сети я наткнулся на игру, над которой работал в одной компании в далеком 2013 году. Помимо того проекта, над которым работал я, у этой компании была еще одна игра, которая представляла собой адаптацию телевизионного шоу "100 к 1". К моему удивлению, я узнал, что телевизионная игра до сих пор существует, а вот в социальной сети она не работала. Я подумал, что было бы круто сделать адаптацию для мессенджеров, ведь сам формат игры по моим представлениям очень легко укладывался в концепцию чат-бота. Дополнительным преимуществом для меня в этом проекте был бесценный опыт работы с Telegram API, который мне очень пригодился при разработке моего конструктора чат-ботов.

Думаю, многим из вас знакомо: заводишь новый пет-проджект, начинаешь проектировать, продумывать архитектуру, пытаешься предусмотреть гипотетические ситуации(вероятность наступления которых, как правило не больше 1% — YAGNI), кучу разных фич без которых, как ошибочно кажется, нельзя выпускать релиз. Итогом, как правило становится то, что проект так и остается на задворках никогда не увидев свет. Я проходил это много раз и у меня без преувеличения есть добрая сотня незавершенных проектов. В этот раз я четко решил установить дедлайн и дал себе на разработку максимум 3 вечера. Разрабатывать прототип дольше — роскошь, ведь у меня все-таки есть и основной проект в котором есть прорва задач. Итак, я хочу сформулировать первое правило разработки прототипов: "Если вы нацелены на результат, а не планируете вечно наслаждаться процессом разработки, определите дедлайн". Даже если работаете в одиночку, как в этом случае было со мной. Вы всегда найдете что сделать лучше, надежнее, быстрее. Не ведитесь — это коварный мозг загоняет вас в ловушку. Лучше упасть под нагрузкой в 10 запросов в секунду, чем иметь приложение, способное обработать тысячи, но так и не увидившее свет.

Признаюсь честно, я не знаком с тонкостями оригинальной телевизионной игры, по сути я делал адаптацию адапции и хотел в первую очередь добиться того, чтобы было занимательно играть через Telegram.

Шаг 1. Правила

При разработке игры, первым делом бывает полезным формализовать правила и сыграть несколько тестовых партий с друзьями. Без Telegram или любого другого ПО. Описать правила для такой игры оказалось чертовски легко. Через пару часов игр в аналогичные игры я сформулировал текст правил:

Каждая партия состоит из 3 раундов. Во время раунда игроки по очереди отвечают на случайный вопрос из базы вопросов. Ответив на вопрос, пользователь получает баллы в соответствии со списком и и передает ход другому игроку. Каждый раунд состоит из 6 ходов, таким образом у каждого игрока есть по 3 попытки ответа на один вопрос.

  1. Наиболее популярный — 8 баллов
  2. Второй по популярности — 5 баллов
  3. Третий — 3 балла
  4. Четвертый — 2 балла
  5. Пятый — 1 балл
  6. Шестой — 1 балл
  7. Все менее популярные — 0 баллов

Как можно догадаться, суть состоит в том, чтобы набрать наибольшее количество очков. Чем более популярный ответ вы даете — тем больше очков. Если вы даете существующий ответ, добавляем за него 1 "голос". Если такого ответа еще никто не давал — добавляем его в список существующих ответов с одним "голосом". Чем больше у ответа голосов — тем более популярны считается ответ. Чтобы игра не затягивалась, а игроки чувствовали напряжение, существует ограничение на ответ в 20 секунд. Не успел? Ход переходит другому игроку, а тебе начисляется утешительные 0 баллов. По завершению 3 раунда подсчитываем очки и определяем победителя.

Показал правила своей жене и спросил, понятны ли они, а получив утвердительный ответ, предложил сыграть несколько партий "на словах", выбирая вопросы из открытой базы в интернете(конечно, я жульничал, ведь видел еще и ответы). Процесс показался жене достаточно увлекательным, а правила простыми и, получив одобрение, я сел кодить
Оригинальное ТВ-шоу

Шаг 2. Абстракции

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

  • Игрок(Player) — здесь будем хранить уникальный id игрока, количество его игр, побед, поражений, рейтинг и любую другую инфу, непосредственно связанную с каждым отдельновзятым игроком.
  • Игра(Game) — храним информацию о состоянии конкретной партии, статус игры, кто с кем играет, какой сейчас раунд, у кого сколько очков и т.п.
  • Раунд(Round) — в каждом раунде разные вопросы. Неплохо бы сохранить какой вопрос задавался игрокам в этом раунде, какие ответы мы получили, чей сейчас ход.
  • Вопрос(Question) — тут понятно, нужно как-то хранить вопросы. Просто текст
  • Ответ(Answer) — здесь тоже без неожиданностей, нужно как-то хранить ответы на вопросы и иметь возможность ранжировать их по популярности

Все самое необходимое для игры в первом приближении мы продумали. Для того, чтобы игра была больше похожа на игру, я решил также озадачится общим рейтингом игроков, добавляя тем самым мотивацию играть больше, ведь многие хотят быть где-то первым, любят разного рода баллы, очки, голоса, карму(да, дорогой Хабр?), рейтинг — соревновательный интерес.

Определив необходимый минимум для игрового процесса, я задал себе простой вопрос: "Что помимо самого игрового процесса мне нужно реализовать, чтобы это было похоже на законченный продукт?". Я решил, что было бы неплохо дать возможность:

  • Посмотреть правила
  • Посмотреть свой рейтинг
  • Посмотреть ТОП игроков
  • Присоединится к списку ожидания для начала новой игры со случайным соперником
  • Создать приватную игру для игры с друзьями

Здесь явно нарисовалась потребность в каком-то меню и тут же появились неигровые состояния, а именно: главное меню и разного рода подменю, ожидание своей очереди в играх со случайным соперником, ожидание подтверждения игры, ожидание начала игры. По моему замыслу все вместе это должно было работать так:
По команде /start бот показывает приветственное сообщение и главное меню. Далее, игрок выбрает игру со случайным соперником и ему показывается сообщение о том, что он добавлен в список ожидания и как только мы найдем оппонента — сообщим. Раз в N-секунд я планировал проверять список игроков в очереди, составлять пары и отправлять им сообщение с просьбой подтвердить матч. Когда матч подтвержден обоими игроками — уведомляем их о скором начале партии и даем немного времени на подготовку, то есть переходим в состояние "ожидание начала игры". Потом просто по таймеру стартуем игру и переходим к игровым состояниям.

Поехали!

Согласитесь, все что я описал до сих пор звучит чертовски просто. В своем предыдущем материале я уже писал, что самая быстрая для разработки технология — та, которую ты знаешь лучше всего. Потому для реализации бекенда я взял знакомый мне NodeJS c TypeScript и начал колбасить. Без чего невозможна ни одна игра? Конечно же без игрока. 10 минут в редакторе кода и я получаю простенький интерфейс:

interface Player {     id: string; // уникальный идентификатор игрока     rating: number;  // рейтинг игрока     username: string; // имя для отображения     games: number; // к-во игр всего     wins: number; // к-во побед     losses: number; // к-во поражений     createdAt: number; // timestamp "регистрации" игрока     lastGame: number|null; // timestamp окончания последней сыграной игры }

Ни необходимости в сложных связях между моделями, ни особых требований к сохранности данных, ни каких-то сложных структур я не обнаружил. При выборе СУБД я подумал, что большая часть того, что мне предлагают "тяжелые" решения вроде 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 легко сможет представить себе реализацию каждого из методов в пару-тройку строк.

interface IPlayerService {   createPlayer(id: string, name?: string): Promise<boolean>;   getPlayerName(id: string): Promise<string>;   countPlayers(): Promise<number>;   getRatingPosition(id: string): Promise<number>;   getTopPlayerIds(): Promise<string[]>;   getTopPlayerScores(): Promise<{id: string, value: number}[]>;   listPlayers(): Promise<Player[]>;   getPlayerRating(id: string): Promise<number>;   getPlayerScores(id: string): Promise<number>;   getPlayerScorePosition(id: string): Promise<number>;   getPlayerGames(id: string): Promise<number>;   getPlayerWins(id: string): Promise<number>;   winGame(id: string, newRating: number): Promise<boolean>;   looseGame(id: string, newRating: number): Promise<boolean>;   setSession(id: string, data: any): Promise<void>;   getSession(id: string): Promise<any>; }

После этого я посчитал, что непосредственной работы на этот день мне хватит, заварил чашечку "каркадэ" с сахарочком и начал представлять себе, как я скоро опубликую игру и буду играть в нее со своей женой. С этими мыслями я и отправился спать.

Второй вечер

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

  • Создать сервис для управления вопросами\ответами
  • Запилить игровой процесс
  • Организоваать матчмейкинг

Вопросы и ответы в игре имеют ключевое значение. Все-таки, механика игры завязана именно на них. Вопрос в нашем случае это просто какой-то вопросительный текст, идентификатор и дата добавления(на всякий случай).

interface Question {   id: string;   message: string;   createdAt: number; }

Сохранять вопросы я решил в обычный key-value, в качестве ключа — ID, в качестве значения JSON-строка объекта вопроса. Дополнительно я завел себе неупорядоченный список(set) в котором хранятся ID всех доступных вопросов, чтобы было удобно их выбирать. Какие операции над вопросами нам необходимы? Я определил следующие:

  • Добавить вопрос
  • Получить вопрос по ID
  • Получить случайный вопрос

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

Код

class QuestionService {    /**    * Get redis key of question by id    * @param id    */   public static getQuestionKey(id: string) {     return `questions:${id}`;   }    /**    * Get redis key of questions list    */   public static getQuestionsListKey() {     return 'questions';   }    /**    * Return question object    * @param id    */   async getQuestion(id: string) {     return JSON.parse(       await this.redisService.getClient().get(QuestionService.getQuestionKey(id))     );   }    /**    * Add new question to the store    * @param question    */   async addQuestion(message: string): Promise<string> {     const data =  {       id: randomStringGenerator(),       createdAt: Date.now(),       message,     };     await this.redisService.getClient()       .set(QuestionService.getQuestionKey(data.id), JSON.stringify(data));     await this.redisService.getClient()       .sadd(QuestionService.getQuestionsListKey(), data.id);     return data.id;   }    /**    * Return ID of the random question    */   async getRandomQuestionId(): Promise<string> {     return this.redisService.getClient()       .srandmember(QuestionService.getQuestionsListKey());   }    /**    * Return random question    */   async getRandomQuestion() {     const id = await this.getRandomQuestionId(gameId);     return this.getQuestion(id);   }  }

Обратите внимание на то, что выбор случайного вопроса я просто отдал на откуп Redis. Уверен, каждый из Вас найдет множество способов улучшить этот код, но давайте простим мне его качество и двинемся дальше. Следующее, чем нужно было озадачиться — ответы на вопросы.

Во-первых, ответы на вопросы добавляют сами игроки. Во-вторых, у ответов есть свой рейтинг, который влияет на то, сколько очков получит игрок. И, наконец, каждый ответ жестко привязан к определнномму вопросу. Также, мне хотелось, чтобы ответы не только не зависили от регистра, ведь было бы обидно, если ты ответил правильно, а из-за регистра ты не получил положенные по праву очки, но и допускали бы небольшие опечатки и разнообразие форм. Тут же в памяти всплыл какой-то коэффициент Жаккара, пошел гуглить как бы опредилить сходство строк и, немного почитав одернул себя. Все ведь уже сделали до меня, а значит нужно сначала попробовать загуглить готовый пакет для определения сходства строк по какому-то коэффициенту. Буквально через 5 минут у меня уже был выбор из нескольких готовых решений и я решил остановиться на https://github.com/aceakash/string-similarity основанном на коэффициенте Сёренсена. Кому интересно, как это работает — велкам в википедию. Поигравшись с библиотекой в песочнице, методом научного тыка я выяснил, что коэффициент сходства 0.75 мне вполне подходит, хоть иногда и не пропускает неккоторые опечатки и формы слов. Но все же — лучше, чем ничего.

Для каждого существующего вопроса я решил завести отдельный упорядоченный набор(sorted set) ответов в котором порядок ответов определен непосредственно популярностью. Поскольку ответы настолько тесно связаны с вопросами я решил делать все в том же самом QuestionService, добавив туда методы добавления ответа, получения списка всех существующих ответов, получение списка ТОП-ответов(за которые начисляются баллы), определения существует ли уже такой ответ на этот вопрос, определения очков, положенных за этот ответ и т.п.

Конечно, нужно было исключить возможность дважды получить баллы за один и тот же ответ на один и тот же вопрос в рамках одной игры. А потому, ответы в какой-то степени сопряжены с конкретной игрой. В итоге, мой QuestionService пополнился следующим кодом:

Еще код

private turnScores: number[] = [8, 5, 3, 2, 1, 1];  /**  * Get key of list of the answers  * @param questionId  */ public static getAnswersKey(questionId: string): string {   return `answers:${questionId}`; }  async addAnswer(questionId: string, answer: string): Promise<string> {   return this.redisService.getClient()     .zincrby(QuestionService.getAnswersKey(questionId), 1, answer); }  /**  * Get all answers for the given question  * @param questionId  */ async getQuestionAnswers(questionId: string): Promise<string[]> {   return this.redisService.getClient()     .zrevrange(QuestionService.getAnswersKey(questionId), 0, -1); }  /**  * Top 6 answers  * @param questionId  */ async getTopAnswers(gameId: string, questionId: string): Promise<string[]> {   const copiedAnswers = await this.redisService.getClient()     .get(`answers:game:${gameId}:q:${questionId}`);   if (!copiedAnswers) {     const ans = await this.redisService.getClient()       .zrevrange(QuestionService.getAnswersKey(questionId), 0, 5);     await this.redisService.getClient()       .set(`answers:game:${gameId}:q:${questionId}`, JSON.stringify(ans));     return ans;   }   return JSON.parse(copiedAnswers); }  /**  * Find if answer already exists and return it if found  * null if answer doesnt exist  * @param questionId  * @param answer  */ async existingAnswer(questionId: string, answer: string): Promise<string | null> {   const answers = await this.getQuestionAnswers(questionId);   const matches = stringSimilarity.findBestMatch(answer, answers);   return matches.bestMatch.rating >= 0.75     ?      matches.bestMatch.target     :     null; }  /**  * Existing answer scores  * @param questionId  * @param answer  */ async getExistingAnswerScore(   gameId: string, questionId: string, answer: string ): Promise<number> {     const topAnswers = await this.getTopAnswers(gameId, questionId);     const matches = stringSimilarity.findBestMatch(answer, topAnswers);     return matches.bestMatch.rating >= 0.75       ?       this.turnScores[matches.bestMatchIndex]       :       0; }  /**  * Submit the new answer. Updates answer counter, save if doesn't exist, return answer score  * @param questionId  * @param answer  */ async submitAnswer(gameId: string, questionId: string, answer: string): Promise<number> {   answer = answer.toLowerCase();   const existingAnswer = await this.existingAnswer(questionId, answer);   if (!existingAnswer) {     await this.addAnswer(questionId, answer);     return 0;   } else {     await this.addAnswer(questionId, existingAnswer);     return this.getExistingAnswerScore(gameId, questionId, existingAnswer);   } }

Как можно понять из этого куска, логика добавления ответа примерно следующая: приводим ответ к нижнему регистру. Проверяем, существует ли уже такой ответ и, если нет — добавляем его в базу и возвращаем 0 очков за него. Если ответ уже есть, то добавляем "голос" за ответ, делая его более популярным в нашей системе, продвигая в общем рейтинге ответов на этот вопрос. Дальше, мы пытаемся узнать, положены ли игроку очки за этот ответ. Для этого мы получаем топовые ответы на этот вопрос и смотрим, есть ли среди них те, коэффициент Сёренсена которых ≥ 0.75 для ответа игрока. Чтобы "баллы" за ответы на вопрос не менялись прямо во время игры, я решил копировать ТОП-ответы на вопросы из общего списка ответов и использовать копию для каждой конкретной игры. Почему я решил засунуть это в getTopAnswers? Возможно, я был пьян — не делайте так 😉 Со всеми этими и несколькими другими функциями, весь сервис занял у меня < 300 строк и при этом не только давал возможность управлять ответами и вопросами, но и определять сколько баллов должен получить игрок за ответ в рамках конкретной игры(точнее в рамках какого-то gameId, ведь игры еще и в помине нет).

Уже чувствуете, как на ваших глазах игра приобретает очертания? У нас уже есть игроки, вопросы к которым эти самые игроки могут добавлять ответы и даже возможность определить сколько баллов положено конкрентному игроку за ответ. Пора бы приступить непосредственно к игровому циклу, не так ли?

Игра

Как я уже писал выше, каждая партия(игра) состоит из раундов и ходов. В каждой игре будет участвовать 2 игрока — player1 и player2 соответственно. У игры есть несколько состояний: ожидание игроков, в процессе, завершена. Саму игру я решил создавать только в тот момент, когда оба игрока уже известны, а статус "ожидание игроков" нужен для того, чтобы после создания игры игроки успели подключится и подтвердили свое участие. Когда оба игрока подтвердили — можно переходить к самой игре.

enum GameStatus {   WaitingForPlayers,   Active,   Finished, }

Игры я решил хранить как key-value, где ключ как всегда = ID, а value = JSON.stringify(data). В итоге, получился такой вот незатейливый интерфейс игры

interface Game {     id: string;     player1: string;     player2: string;     joined: string[];     status: GameStatus;     round?: number;     winner?: string;     createdAt: number;     updatedAt?: number; }

Игровой раунд же должен иметь порядковый номер, информацию о текущем ходе, содержать вопрос(помните? 1 раунд — 1 вопрос), а также ответы игроков.

interface GameRound {     index: number;     question: string;     currentPlayer: string;     turn: number;     answers: UserAnswer[];     updatedAt?: number; }

Теперь мы можем перейти к game.service.ts в котором и будем описывать логику, а именно: инициализация игры, старт игры, смена раундов, смена ходов, начисление баллов за ответы и подведение итогов. В игре есть ряд событий, при наступлении которых нам стоит совершать каакие-то действия, например, уведомлять игроков о смене хода или конце игры. Для работы с этими событиями я использовал Redis pub/sub. Внимательный читатль возможно помнит, что время хода игрока ограничено. Для этого я решил использовать максимально простой, но довольно опасный подход — стандартные таймеры, но мы тут не строим какую-то масштабируемую систему, а получаем удовольствие от процесса, так что и так сойдет.

Кусок GameService

class GameService {    // Перечислим наиболее важные для нас игровые события   public static CHANNEL_G_CREATED = 'game-created';   public static CHANNEL_G_STARTED = 'game-started';   public static CHANNEL_G_ENDED = 'game-ended';   public static CHANNEL_G_NEXT_ROUND = 'game-next-round';   public static CHANNEL_G_NEXT_TURN = 'game-next-turn';   public static CHANNEL_G_ANSWER_SUBMIT = 'game-next-turn';    private defaultRounds: number; // количество раундов   private defaultTurns: number; // количество ходов в одном раунде    private timers: any = {}; // таймеры. Да-да, any - зло      // Внедрим сервисы, от которых зависит наша игра и загрузим     // настройки раундов и ходов из конфига   constructor(     private readonly redisService: RedisService,     private readonly playerService: PlayerService,     private readonly questionService: QuestionService,     @Inject('EventPublisher') private readonly eventPublisher,   ) {     this.defaultRounds = config.game.defaultRounds;     this.defaultTurns = config.game.defaultTurns;   }    /**    * Get redis key of game    * @param id string identifier of game    */   public static getGameKey(id: string) {     return `game:${id}`;   }    /**    * Get game object by id    * @param gameId    */   async getGame(gameId: string): Promise<Game> {     const game = await this.redisService.getClient()       .get(GameService.getGameKey(gameId));     if (!game) {       throw new Error(`Game ${gameId} not found`);     }     return JSON.parse(game);   }    /**    * Save the game state to database    * @param game    */   async saveGame(game: Game) {     return this.redisService.getClient().set(       GameService.getGameKey(game.id),       JSON.stringify(game)     );   }    /**    * Save round    * @param gameId    * @param round    */   async saveRound(gameId: string, round: GameRound) {     return this.redisService.getClient().set(       GameService.getRoundKey(gameId, round.index),       JSON.stringify(round)     );   }    /**    * Initialize default game structure, generate id    * and save new game to the storage    *    * @param player1    * @param player2    */   async initGame(player1: string, player2: string): Promise<Game> {     const game: Game = {       id: randomStringGenerator(),       player1,       player2,       joined: [],       status: GameStatus.WaitingForPlayers,       createdAt: Date.now(),     };     await this.saveGame(game);     this.eventPublisher.emit(       GameService.CHANNEL_G_CREATED, JSON.stringify(game)     );     return game;   }    /**    * When the game is created and is in the  "Waiting for players" state    * users can approve participation    * @param playerId    * @param gameId    */   async joinGame(playerId: string, gameId: string) {     const game: Game = await this.getGame(gameId);     if (!game) throw new Error('Game not found err')     if (isUndefined(game.joined.find(element => element === playerId))) {       game.joined.push(playerId);       game.updatedAt = Date.now();       await this.saveGame(game);     }     if (game.joined.length === 2) return this.startGame(game);   }    /**    * Start the game    * @param game    */   async startGame(game: Game) {     game.round = 0;     game.updatedAt = Date.now();     game.status = GameStatus.Active;     this.eventPublisher.emit(       GameService.CHANNEL_G_STARTED, JSON.stringify(game)     );     await this.questionService.pickQuestionsForGame(game.id);     await this.nextRound(game);   }    /**    * Start the next round    * @param game    */   async nextRound(game: Game) {     clearTimeout(this.timers[game.id]);     if (game.round >= this.defaultRounds) {       return this.endGame(game);     }     game.round++;     const round: GameRound = {       index: game.round,       question: await this.questionService.getRandomQuestionId(game.id),       currentPlayer: game.player1,       turn: 1,       answers: [],     };     await this.saveGame(game);     await this.saveRound(game.id, round);      // Начиная новый раунд мы также начинаем новый ход     // и устанавливваем таймер в 20 секунд по истичению котрого     // игроку засчитается пустой ответ за который положено 0 баллов     this.timers[game.id] = setTimeout(async () => {       await this.submitAnswer(round.currentPlayer, game.id, '');     }, 20000);     await this.eventPublisher.emit(       GameService.CHANNEL_G_NEXT_ROUND,       JSON.stringify({game, round})     );   }    /**    * Switch round to next turn    * @param game    * @param round    */   async nextTurn(game: Game, round: GameRound) {     clearTimeout(this.timers[game.id]);     if (round.turn >= this.defaultTurns) {       return this.nextRound(game);     }     round.turn++;     round.currentPlayer = this.anotherPlayer(round.currentPlayer, game);     round.updatedAt = Date.now();     await this.eventPublisher.emit(       GameService.CHANNEL_G_NEXT_TURN,       JSON.stringify({game, round})     );     this.timers[game.id] = setTimeout(async () => {       await this.submitAnswer(round.currentPlayer, game.id, '');     }, 20000);     return this.saveRound(game.id, round);   }    async answerExistInRound(round: GameRound, answer: string) {     const existingKey = round.answers.find(       rAnswer => stringSimilarity.compareTwoStrings(answer, rAnswer.value) >= 0.85     );     return !isUndefined(existingKey);   }    async submitAnswer(playerId: string, gameId: string, answer: string) {     const game = await this.getGame(gameId);     const round = await this.getCurrentRound(gameId);     if (playerId !== round.currentPlayer) {       throw new Error('Its not your turn');     }     if (answer.length === 0) {       round.updatedAt = Date.now();       this.eventPublisher.emit(         GameService.CHANNEL_G_ANSWER_SUBMIT,         JSON.stringify({game, answer, score: 0, playerId})       );       return this.nextTurn(game, round);     }     if (await this.answerExistInRound(round, answer)) {       throw new Error('Такой ответ уже был в этом  раунде');     }     round.answers.push({ value: answer, playerId, turn: round.turn });     const score = await this.questionService.submitAnswer(       gameId, round.question, answer     );     if (score > 0) {       await this.addGameScore(gameId, playerId, score);     }     round.updatedAt = Date.now();     this.eventPublisher.emit(       GameService.CHANNEL_G_ANSWER_SUBMIT,       JSON.stringify({game, answer, score, playerId})     );     return this.nextTurn(game, round);   }    /**    * Adds game score in specified game for specified player    * @param gameId    * @param playerId    * @param score    */   async addGameScore(gameId: string, playerId: string, score: number) {     await this.redisService.getClient()       .zincrby(`game:${gameId}:player:scores`, score, playerId);   }    async endGame(game: Game) {     clearTimeout(this.timers[game.id]);     game.updatedAt = Date.now();     game.status = GameStatus.Finished;     game.updatedAt = Date.now();     // опредилимм победителя     const places = await this.redisService.getClient()       .zrevrange(`game:${game.id}:player:scores`, 0, -1);     game.winner = places[0];     // Рейтинги ДО игры     const winnerRating: number = await this.playerService.getPlayerRating(game.winner);     const looserRating: number = await this.playerService.getPlayerRating(places[1]);     // считаем нновые рейтинги     const newRatings = rating.calculate(winnerRating, looserRating);     await this.playerService.winGame(game.winner, newRatings.playerRating);      await this.playerService.looseGame(places[1], newRatings.opponentRating);     await this.redisService.getClient().expire(GameService.getGameKey(game.id), 600)     this.eventPublisher.emit(GameService.CHANNEL_G_ENDED, JSON.stringify(game));     return game;   } }

Отлично, уже что-то. Теперь оставалось решить вопрос с тем, чтобы игроки могли подключаться к игре. Поскольку проблему с рейтингом игроков мы уже решили, а матчмейкинг я хотел организовать именно на его основе — половина проблемы уже решена. Предполагается, что когда игрок хочет поучаствовать в игре, он добавляется в некий список ожидания, из которого мы создаем пары игроков с наиболее близким рейтингом. В этом нам снова поможет Redis с его упорядоченными наборами(sorted sets). Таким образом мы можем организовать добавление игрока в список ожидания буквально в пару строку(можно и в одну в ущерб читабельности и константа тут лишняя, но простите мне это). При подключении к игре мы должы удалять игрока из списка ожидания. Сформированные пару должны инициализировать новую игру. Поскольку матчмейкинг в моем случае настолько простой я засунул его прямо в game.service.ts, добавив туда нечто такое

async addPlayerToMatchmakingList(id: string) {   const playerRating = await this.playerService.getPlayerRating(id);   return this.redisService.getClient().zadd('matchmaking-1-1', playerRating, id); }  async removePlayerFromMatchmakingList(id: string) {   return this.redisService.getClient().zrem('matchmaking-1-1', id); }  async getMatchmakingListData() {   return this.redisService.getClient().zrange('matchmaking-1-1', 0, -1); }  async matchPlayers(): Promise<Game> {   const players = await this.redisService.getClient()         .zrevrange('matchmaking-1-1', 0, -1);   while (players.length > 0) {      const pair = players.splice(0, 2);      if (pair.length === 2) {        return this.coinflip()           ?          this.initGame(pair[0], pair[1])          :          this.initGame(pair[1], pair[0]);      }   } }

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

Немножко потестировав, довольный собой я отправился спать. Вечер следующего дня обещал быть не менее интересным, ведь я планировал сделать интерфейс самой игры.

Пользовательский интерфейс

Чат-бот — всего лишь интерфейс. Я безумно рад тому, что в отличии от веб-сайтов тут не пришлось ничего верстать, писать css и клиентский JS-код — за нас это все уже сделали разработчики Telegram. А я просто могу воспользоваться результатами их труда. Как мне кажется, в процессе чтения API документации я немного неверно уловил несколько концепций. Тем не менее, на работоспособности игры это особо не сказалось, а скорее касается удобства.

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

Добро пожаловать в игру "100 к 1", AndreyDegtyaruk!
В этой игре тебе нужно сражаться против других игроков, выясняя, чья же интуиция развита лучше! Играй против своих друзей, или случайных игроков из интернета. Введи команду /help чтобы узнать правила игры и получить более подрообную информацию о доступных командах

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

В игре "100 к 1" Вам предстоит сразиться с другими игроками в умении угадывать наиболее популярные ответы на вопросы. Неправильных ответов нет! Важно выбирать те ответы, которые наиболее часто выбирают другие игроки
Каждая партия состоит из 3 раундов. Во время раунда игроки по очереди отвечают на случайный вопрос из нашей базы вопросов. Отправив ответ на вопрос, пользователь получает баллы в соответствии с таблицей ниже и передает ход другому игроку. Каждый раунд состоит из 6 ходов, таким образом у каждого игрока есть по 3 попытки ответа на один вопрос
Таблица наград за ответы

  1. Наиболее популярный — 8 баллов
  2. Второй по популярности — 5 баллов
  3. Третий по популярности — 3 балла
  4. Четвертый по популярности — 2 балла.
  5. Пятый — 1 балл.
  6. Шестой — 1 балл.
  7. Все менее популярные — 0 баллов

Торопись! Время на раздумья ограничено! У игроков есть всего 20 секунд на ответ. Не успели отправить? Вам засчитывается 0 баллов и ход переходит другому игрооку. По окончании 3 раунда производится определение победителя и начисление очков, а значит и Ваше продвижение в рейтинговой таблице

Поработав над текстами, я задумался о том, как в принципе работают чат-боты в телеграм, а точнее о том, как получать обновления от игроков(команды боту, ответы на вопросы). Существует 2 подхода: pull и push. Pull подход подразумевает, что мы "опрашиваем" Telegram на предмет изменений, для этого в API Telegram существует метод getUpdates. Push это когда телеграм сам присылает нам обновления, если они есть, иными словами — дергает webhook. Вебхуки показались мне более хорошей идей, поскольку избавляют от необходимости часто "опрашивать" телеграм на предмет обновлений даже если их нет, а так же от необходимости реализации механизма самого получения этих данных. Значит, вебхукам быть!

Осмотрев доступные библиотеки для создания ботов и официальный SDK я выбрал Telegraf, ибо мне показалось, что его уровень абстракции как раз подходит для моих нужд — удобные, простые и понятные интерфейсы. Да еще и довольно большое коммунити, активная поддержка и разработка.

Останавливаться на том, как "зарегистрировать" бота в телеграм, получить API key я не буду, это все подробно описано в документации, так что предлагаю сразу перейти к делу. Для работы с ботами я создал очередной сервис — bot.service.ts, который и должен взять на себя все взаимодействие с Telegram. Для начала, я решил определиться с тем, какие команды будет понимать мой бот. Почитав документацию и порисовав на листочке схему работы я получил такой список:

  • /start — показывает приветственнное сообщение и главное меню(зачем-то я сделал его inline, возможно, custom keyboard был бы более удачным выбором)
  • /help — показывает правила игры и главное меню
  • /player_info — информация об игроке(победы, поражения, рейтинг) и главное меню
  • /global_rating — выводит глобальный рейтинг игроков и главное меню
  • /go — добавляет игрока в лист ожидания игр

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

    getMainMenuKeyboard() {         return {           inline_keyboard: [             [             {                     text: ' Игра со случайным соперником',                 callback_data: JSON.stringify({type: 'go'})             }              ],              [             {                 text: ' Рейтинг игроков',                 callback_data: JSON.stringify({type: 'player-rating'})             }              ],              [             {                 text: ' Правила',                 callback_data: JSON.stringify({type: 'rules'})             }               ],              [             {                 text: ' Моя статистика',                 callback_data: JSON.stringify({type: 'stats'})             }              ],           ],         };       }

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

Для обработки таких вот коллбеков я сделал отдельную функцию, в которой повесил switch/case statement по полю type, внутри которого просто вызываю нужный метод. Далее я попробовал реаализовать информационные команды start и help, чтобы попробовать протестировать бота и узнать дойдет ли до меня вебхук, залогировать его и попробовать ответить. Тут же я узнал, что Telegram шлет запросы только по https, да еще и какой-то домен мне бы не повредил. Для того, чтобы все это получить локально и желательно не тратить на это пол дня я взял небезызвестный Ngrok с которым справится даже ребенок.

    async commandStart(ctx) {       let name = `${escapeHtml(ctx.from.first_name)}`;       name += ctx.from.last_name ? escapeHtml(ctx.from.last_name) : '';       await this.playerService.createPlayer(ctx.from.id.toString(10), name);       await this.bot.telegram.sendMessage(ctx.from.id, messages.start(name), {         parse_mode: 'HTML',         reply_markup: this.getMainMenuKeyboard(),       });     }

Как видно, в команде старт я просто создаю игрока и отправляю ему приветственное сообщение с клавиатурой. Как ни странно, все заработало и у меня отлегло от сердца. Весь предыдущий труд был не напрасен и, судя по всему, у меня получится сделать все что я хотел. Дальше, как говорится, дело техники. Я думаю, что большого смысла писать тут реализацию всех команд нет.

Когда я добрался до матчмейкинга, мне пришлось вспомнить тот самый Redis pub/sub. Раньше мы уже сделали публикацию игровых событий. Теперь нужно было организовать слушателя, который отправлял бы соответствующие сообщения при нахождении пары, начале матча, принятии ответа оппонента(чтобы показать что он ответил и сколько очков заработал). Временно засунул этого слушателя прямо в свой main.ts, но все мы знаем, что нет ничего более постоянного, чем временное. Переделать руки так и не дошли. Типичный "слушатель" в итоге выглядит так

    const redisClient = app.get('EventSubscriber');     redisClient.on(GameService.CHANNEL_G_CREATED, async (data) => {       try {         await botService.sendGameCreated(JSON.parse(data));       } catch (error) {         console.log('Error in CHANNEL_G_CREATED');         console.log(error);       }     });

В BotService.sendGameCreated, как не трудно догадаться мы просто отправляем обоим игрокам соответсвующие сообщения о том, что мы нашли пару и просим подтвердить участие в игре.

Когда дело дошло до работы внутри самой игры(и еще нескольких других функций, добавленных позже), возникла необходимость где-то хранить состояние игроков, чтобы бот понимал, находится ли игрок внутри игры, или в меню. А если в меню, то в каком? Я немного поковырял механизм Stages и Scenes, предоставленный Telegraf, поскольку по документации мне показалось что это именно то, что мне нужно. Но, к сожалению, за 30 минут мне так и не удалось заставить его работать и я воспользовался старыми добрыми сессиями, которые просто сохранил в Redis.

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

this.bot.on('text', async (ctx: any) => {   ctx.message.text = escapeHtml(ctx.message.text);   ctx.message.text = ctx.message.text.toLowerCase();   try {     const state = await this.playerService.getSession(ctx.from.id.toString());     if (!state.currentGame) {       return ctx.reply(         'Используйте команды, чтобы начать игру.          Воспользуйтесь командой /help, чтобы увидеть список доступных команд'         );     }     await this.gameIncomingMessage(ctx, state);   } catch (error) {     console.log('Error during processing TEXT message');     console.log(error);   } }); this.bot.on('message', async ctx => {     ctx.reply('Поддерживаются только текстовые сообщения'); });

На все сообщения, кроме текстовых бот отвечает, что поддерживает только текст.

Доделав Telegram-обертку над игрой, я отправил ссылку на бота жене и парочке друзей. Конечно, в процессе находились какие-то баги. Особенно позабавило то, что у одного из знакомых в имени было нечто вро </миша>, а поскольку я использую parseMode HTML, телеграм начал мне выдавать ошибку: "Не могу спарсить html" и при этом сообщения ВСЕМ перестали доходить. Тем не менее от игр со случайными соперниками получил удовольствие не только я, но и мои знакомые. Некоторые из них даже делились им со своими друзьями и за несколько часов тестирования на localhost количество игроков достигло 50 человек. Я посчитал этот эксперимент удачным, убил NodeJS процесс и успешно вернулся к своим повседневным делам. Идея написать об этом пост зрела очень долго и к моменту его написания я даже не удосужился выгрузить бота на какой-нибудь сервер, однако, к моменту публикации все-таки заставил себя это сделать.

Я очень надеюсь, что мой пост вдохновит кого-то на реализацию своих идей, заставит делать пет-проджекты быстрей и доводить их до релиза, поможет целиком представить процесс создания проектов от идеи до реалиазации, или просто позволит получить хоть немного удовольствия от чтения. Я почти уверен в том, что бот не выдержит большое количество игроков. Все-таки таймеры, subscriber прямо в main, один процесс и т.д и т.п. Тем не менее, если кому-то интересно посмотреть на результат — ищите бота @QuizMatchGameBot. Всем мир!

ссылка на оригинал статьи https://habr.com/ru/post/488152/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *