python

Пишем матчмейкинг для Доты 2014 года

  • пятница, 21 августа 2020 г. в 00:28:41
https://habr.com/ru/post/515934/
  • Python
  • Node.JS
  • Серверное администрирование
  • TypeScript
  • Игры и игровые приставки


Всем привет.

Этой весной я наткнулся на проект, в котором ребята научились запускать Dota 2 сервер версии 2014 года и, соответственно, играть на нем. Я большой фанат этой игры, и не смог пройти мимо уникальной возможности окунуться в свое детство.

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



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

Писать сходу решил на nodejs, потому что не очень люблю питон, ну и комфортнее себя чувствую в этой среде.

Это мой первый опыт написания бота для Discord, но оказалось все очень даже просто. Официальный npm модуль discord.js предоставляет удобный интерфейс для работы с сообщениями, сбором реакций и т.д.

Дисклеймер: все примеры кода являются «актуальными», то есть прошли несколько итераций переписывания по ночам.

Основа матчмейкинга — это «очередь», в которую помещаются игроки, которые хотят играть, и убираются, когда расхотели или нашли игру.

Так выглядит сущность «игрока». Изначально это был просто id пользователя в Discord, но в планах лаунчер/поиск игры с сайта, но обо всем по порядку.

export enum Realm {
  DISCORD,
  EXTERNAL,
}

export default class QueuePlayer {
  constructor(public readonly realm: Realm, public readonly id: string) {}

  public is(qp: QueuePlayer): boolean {
    return this.realm === qp.realm && this.id === qp.id;
  }

  static Discord(id: string) {
    return new QueuePlayer(Realm.DISCORD, id);
  }

  static External(id: string) {
    return new QueuePlayer(Realm.EXTERNAL, id);
  }
}

А вот интерфейс очереди. Тут вместо «игроков» используется абстракция в виде «группы». Для одиночного игрока группа состоит из него самого, а для игроков в группе, соответственно, из всех игроков группы.

export default interface IQueue extends EventEmitter {
  inQueue: QueuePlayer[]
  put(uid: Party): boolean;
  remove(uid: Party): boolean;
  removeAll(ids: Party[]): void;

  mode: MatchmakingMode
  roomSize: number;
  clear(): void
}

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

Для IOC я использую InversifyJS. Имею приятный опыт работы с этой библиотекой. Быстро и просто!

Очередей у нас на сервере несколько — добавились режими 1х1, обычный/рейтинговый, и пара кастомок. Поэтому есть singleton RoomService, который лежит между пользователем и поиском игры.

constructor(
    @inject(GameServers) private gameServers: GameServers,
    @inject(MatchStatsService) private stats: MatchStatsService,
    @inject(PartyService) private partyService: PartyService
  ) {
    super();
    this.initQueue(MatchmakingMode.RANKED);
    this.initQueue(MatchmakingMode.UNRANKED);
    this.initQueue(MatchmakingMode.SOLOMID);
    this.initQueue(MatchmakingMode.DIRETIDE);
    this.initQueue(MatchmakingMode.GREEVILING);
    this.partyService.addListener(
      "party-update",
      (event: PartyUpdatedEvent) => {
        this.queues.forEach((q) => {
          if (has(q.queue, (t) => t.is(event.party))) {
            // if queue has this party, we re-add party
            this.leaveQueue(event.qp, q.mode)
            this.enterQueue(event.qp, q.mode)
          }
        });
      }
    );

    this.partyService.addListener(
      "party-removed",
      (event: PartyUpdatedEvent) => {
        this.queues.forEach((q) => {
          if (has(q.queue, (t) => t.is(event.party))) {
            // if queue has this party, we re-add party
            q.remove(event.party)
          }
        });
      }
    );
  }

(Лапша кода для представления, как примерно выглядят процессы)

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

Так, я молодец, я вставил куски кода, которые никак не относятся к топику, а теперь перейдем уже непосредственно к мачтмейкингу.

Рассмотрим кейс:

1) Пользователь хочет поиграть.

2) Для того, чтобы начать поиск, он использует Gateway=Discord, то есть ставит реакцию на сообщение:



3) Этот гейтвей идет в RoomService, и говорит «Пользователь из дискорда хочет войти в очередь, режим: нерейтинговая игра».

4) RoomService принимает просьбу гейтвея, и пихает в нужную очередь пользователя(точнее, группу пользователя).

5) Очередь при каждом изменении проверяет, хватает ли игроков для игры. Если можно — эмиттим событие:

private onRoomFound(players: Party[]) {
    this.emit("room-found", {
      players,
    });
  }

6) RoomService, очевидно, с радостью слушает каждую очередь в трепетном ожидании этого события. На вход мы получаем список игроков, формируем из них виртуальную «комнату», и, конечно же, эмиттим событие:

queue.addListener("room-found", (event: RoomFoundEvent) => {
      console.log(
        `Room found mode: [${mode}]. Time to get free room for these guys`
      );
      const room = this.getFreeRoom(mode);
      room.fill(event.players);

      this.onRoomFormed(room);
    });

7) Вот мы и добрались до «высшей» инстанции — класса Bot. В целом он занимается связью между гейтвеями(как это смешно на русском выглядит я не могу) и бизнес логикой матчмейкинга. Бот подслушивает событие, и приказывает DiscordGateway отослать всем пользователям проверку на готовность.



8) Если кто-то отклонил или не принял игру за 3 минуты, то мы НЕ возвращаем их в очередь. Всех остальных возвращаем в очередь и ждем, когда снова наберется 10 человек. Если все игроки приняли игру, то начинается интересная часть.

Конфигурация выделенного сервера


У нас игры хостятся на VDS c Windows server 2012. Из этого можно сделать несколько выводов:

  1. На него нет докера, что ударило меня в самое сердце
  2. Мы экономим на аренде

Стоит задача: с VPS на линуксе запускать процесс на VDS. Написал простой сервер на Flask. Да, не люблю питон, но что поделать — на нем написать этот сервер быстрее и проще.

Он выполняет 3 функции:

  1. Запуск сервера с конфигурацией — выбор карты, количества игроков для старта игры, и набор плагинов. Про плагины сейчас не буду писать — это отдельная история с литрами кофе по ночам вперемешку со слезами и вырванными волосами.
  2. Остановка/перезапуск сервера в случае неудачных подключений, которые мы можем обработать только вручную.

Тут все просто, примеры кода даже неуместны. Скрипт на 100 строчек

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



По нажатию ссылки игрока коннектит к игровому серверу, и дальше уже само все. Через ~25 минут виртуальная «комната» с игроками очищается.

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

Если увижу интерес к теме, то будет вторая часть — в ней будут мои мучения с плагинами для srcds(Source dedicated server), и, наверное, система рейтинга и мини-dotabuff, сайт со статистикой игр.

Немного ссылок:

  1. Наш сайт(статистика, таблица лидеров, небольшой лендос и скачивание клиента)
  2. Discord сервер