Прототип многопользовательской игры за 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. Всем мир!

Источник: habr.ru