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

Всім привіт.

Цієї весни я натрапив на проект, в якому хлопці навчилися запускати Dota 2 сервер версії 2014 року і, відповідно, грати на ньому. Я великий фанат цієї гри, і не зміг пройти повз унікальну можливість поринути у своє дитинство.

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

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

Моя натура програміста не витримала такої кількості ручної роботи, і за ніч я накидав найпростішу версію бота, яка автоматично піднімала сервер, коли набиралося 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, тобто ставить реакцію на повідомлення:

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

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) Ось ми й дісталися «вищої» інстанції — класу Бот. В цілому він займається зв'язком між гейтвеями (як це смішно російською виглядає я не можу) і бізнес логікою матчмейкінгу. Робот підслуховує подію, і наказує DiscordGateway надіслати всім користувачам перевірку на готовність.

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

8) Якщо хтось відхилив або не прийняв гру за 3 хвилини, то ми не повертаємо їх у чергу. Всіх інших повертаємо в чергу та чекаємо, коли знову набереться 10 людей. Якщо всі гравці взяли гру, починається цікава частина.

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

У нас ігри хоститься на VDS з Windows Server 2012. З цього можна зробити кілька висновків:

  1. На нього немає докера, що вдарило мене в серце
  2. Ми заощаджуємо на оренді

Стоїть завдання: з VPS на лінукс запускати процес на VDS. Написав простий сервер Flask. Так, не люблю пітон, але що вдієш — на ньому написати цей сервер швидше і простіше.

Він виконує 3 функції:

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

Тут все просто, приклади коду навіть недоречні. Скрипт на 100 рядків

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

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

Натиснувши посилання гравця коннектит до ігрового серверу, і далі вже все. Через ~25 хвилин віртуальна «кімната» з гравцями очищується.

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

Якщо побачу інтерес до теми, то буде друга частина - в ній будуть мої муки з плагінами для srcds (Source dedicated server), і, напевно, система рейтингу та міні-dotabuff, сайт зі статистикою ігор.

Небагато посилань:

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

Джерело: habr.com

Додати коментар або відгук