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