Этой весной я наткнулся на проект, в котором ребята научились запускать 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. Из этого можно сделать несколько выводов:
- На него нет докера, что ударило меня в самое сердце
- Мы экономим на аренде
Стоит задача: с VPS на линуксе запускать процесс на VDS. Написал простой сервер на Flask. Да, не люблю питон, но что поделать — на нем написать этот сервер быстрее и проще.
Он выполняет 3 функции:
- Запуск сервера с конфигурацией — выбор карты, количества игроков для старта игры, и набор плагинов. Про плагины сейчас не буду писать — это отдельная история с литрами кофе по ночам вперемешку со слезами и вырванными волосами.
- Остановка/перезапуск сервера в случае неудачных подключений, которые мы можем обработать только вручную.
Тут все просто, примеры кода даже неуместны. Скрипт на 100 строчек
Итак, когда 10 человек собрались вместе и приняли игру, запущен сервер и все жаждут играть, в личные сообщения приходит ссылка на подключение к игре.

По нажатию ссылки игрока коннектит к игровому серверу, и дальше уже само все. Через ~25 минут виртуальная «комната» с игроками очищается.
Заранее извиняюсь за нескладность статьи, давно не писал сюда, да и кода слишком много, чтобы выделить важные участки. Лапша, короче.
Если увижу интерес к теме, то будет вторая часть — в ней будут мои мучения с плагинами для srcds(Source dedicated server), и, наверное, система рейтинга и мини-dotabuff, сайт со статистикой игр.
Немного ссылок:
ссылка на оригинал статьи https://habr.com/ru/post/515934/
Добавить комментарий