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

Последнее время я был занят собственным проектом "Конструктор чат-ботов для бизнеса Botlify", первую часть истории создания которого вы можете прочитать по ссылке на Хабре. Но я решил отвлечься и попробовать использовать чат-ботов не для бизнеса, а для игр. Честно признаюсь, лет с 14-15 я почти не играю в компьютерные и мобильные игры, но каждый раз получаю огромное удовольствие от их программирования.
Идея
В очередной раз пролистывая ленту небезызвестной социальной сети я наткнулся на игру, над которой работал в одной компании в далеком 2013 году. Помимо того проекта, над которым работал я, у этой компании была еще одна игра, которая представляла собой адаптацию телевизионного шоу "100 к 1". К моему удивлению, я узнал, что телевизионная игра до сих пор существует, а вот в социальной сети она не работала. Я подумал, что было бы круто сделать адаптацию для мессенджеров, ведь сам формат игры по моим представлениям очень легко укладывался в концепцию чат-бота. Дополнительным преимуществом для меня в этом проекте был бесценный опыт работы с Telegram API, который мне очень пригодился при разработке моего конструктора чат-ботов.
Думаю, многим из вас знакомо: заводишь новый пет-проджект, начинаешь проектировать, продумывать архитектуру, пытаешься предусмотреть гипотетические ситуации(вероятность наступления которых, как правило не больше 1% — YAGNI), кучу разных фич без которых, как ошибочно кажется, нельзя выпускать релиз. Итогом, как правило становится то, что проект так и остается на задворках никогда не увидев свет. Я проходил это много раз и у меня без преувеличения есть добрая сотня незавершенных проектов. В этот раз я четко решил установить дедлайн и дал себе на разработку максимум 3 вечера. Разрабатывать прототип дольше — роскошь, ведь у меня все-таки есть и основной проект в котором есть прорва задач. Итак, я хочу сформулировать первое правило разработки прототипов: "Если вы нацелены на результат, а не планируете вечно наслаждаться процессом разработки, определите дедлайн". Даже если работаете в одиночку, как в этом случае было со мной. Вы всегда найдете что сделать лучше, надежнее, быстрее. Не ведитесь — это коварный мозг загоняет вас в ловушку. Лучше упасть под нагрузкой в 10 запросов в секунду, чем иметь приложение, способное обработать тысячи, но так и не увидившее свет.
Признаюсь честно, я не знаком с тонкостями оригинальной телевизионной игры, по сути я делал адаптацию адапции и хотел в первую очередь добиться того, чтобы было занимательно играть через Telegram.
Шаг 1. Правила
При разработке игры, первым делом бывает полезным формализовать правила и сыграть несколько тестовых партий с друзьями. Без Telegram или любого другого ПО. Описать правила для такой игры оказалось чертовски легко. Через пару часов игр в аналогичные игры я сформулировал текст правил:
Каждая партия состоит из 3 раундов. Во время раунда игроки по очереди отвечают на случайный вопрос из базы вопросов. Ответив на вопрос, пользователь получает баллы в соответствии со списком и и передает ход другому игроку. Каждый раунд состоит из 6 ходов, таким образом у каждого игрока есть по 3 попытки ответа на один вопрос.
- Наиболее популярный — 8 баллов
- Второй по популярности — 5 баллов
- Третий — 3 балла
- Четвертый — 2 балла
- Пятый — 1 балл
- Шестой — 1 балл
- Все менее популярные — 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. Внимательный читатль возможно помнит, что время хода игрока ограничено. Для этого я решил использовать максимально простой, но довольно опасный подход — стандартные таймеры, но мы тут не строим какую-то масштабируемую систему, а получаем удовольствие от процесса, так что и так сойдет.
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 попытки ответа на один вопрос
Таблица наград за ответы
- Наиболее популярный — 8 баллов
- Второй по популярности — 5 баллов
- Третий по популярности — 3 балла
- Четвертый по популярности — 2 балла.
- Пятый — 1 балл.
- Шестой — 1 балл.
- Все менее популярные — 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/
Добавить комментарий