Witajcie wszyscy.
Wiosną natknąłem się na projekt, w którym chłopaki nauczyli się uruchamiać serwer Dota 2 w wersji z 2014 roku i, odpowiednio, grać na nim. Jestem dużym fanem tej gry i nie mogłem przejść obok tej unikalnej możliwości, by zanurzyć się w swoje dzieciństwo.
Zanurzyłem się bardzo głęboko i tak się stało, że napisałem bota na Discorda, który odpowiada praktycznie za całą funkcjonalność, która nie jest wspierana w starej wersji gry, a mianowicie matchmaking.
Przed wszystkimi nowościami z botem, lobby tworzyło się ręcznie. Zbierano 10 reakcji na wiadomość i ręcznie zakładało serwer lub hostowano lokalne lobby.

Moja natura programisty nie wytrzymała takiej ilości ręcznej pracy, więc w nocy naszkicowałem najprostsza wersję bota, który automatycznie uruchamiał serwer, gdy zbierało się 10 osób.
Postanowiłem pisać od razu w nodejs, ponieważ nie przepadam za Pythonem, a w tej przestrzeni czuję się komfortowo.
To moje pierwsze doświadczenie w pisaniu bota dla Discorda, ale okazało się, że wszystko jest bardzo proste. Oficjalny moduł npm discord.js zapewnia wygodny interfejs do pracy z wiadomościami, zbieraniem reakcji itd.
Zastrzeżenie: wszystkie przykłady kodu są «aktualne», to znaczy przeszły kilka iteracji przepisywania w nocy.
Podstawą matchmakingu jest «kolejka», do której trafiają gracze chcący grać, a usuwają się, gdy się rozmyślą lub znajdą grę.
Tak wygląda encja «gracza». Początkowo był to po prostu id użytkownika na Discordzie, ale w planach jest launcher / wyszukiwanie gry z strony, ale o wszystkim w swoim czasie.
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);
}
}
A oto interfejs kolejki. Zamiast «graczy» używa się tu abstrakcji w postaci «grupy». Dla pojedynczego gracza grupa składa się z niego samego, a dla graczy w grupie, odpowiednio, z wszystkich graczy grupy.
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
}
Postanowiłem użyć zdarzeń do wymiany kontekstu. Pasuje to do przypadków — po zdarzeniu „znaleziona gra dla 10 osób” można wysłać odpowiednią wiadomość do graczy w wiadomościach prywatnych oraz wykonać główną logikę biznesową — uruchomić zadanie do sprawdzenia gotowości, przygotować lobby do uruchomienia i tak dalej.
Dla IOC używam InversifyJS. Mam przyjemne doświadczenia z tą biblioteką. Szybko i łatwo!
Mamy kilka kolejek na serwerze — dodano tryby 1x1, normalny/rankingowy, oraz kilka customowych. Dlatego istnieje singleton RoomService, który znajduje się pomiędzy użytkownikiem a wyszukiwaniem gry.
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)
}
});
}
);
}
(Fragment kodu ilustrujący, jak mniej więcej wyglądają procesy)
Tutaj inicjalizuję kolejkę dla każdego z wdrożonych trybów gry i nasłuchuję zmian w „grupach”, aby skorygować kolejki i uniknąć pewnych konfliktów.
Tak, jestem świetny, wstawiłem kawałki kodu, które nie mają związku z tematem, a teraz przejdźmy już do matchmakingu.
Rozważmy przypadek:
1) Użytkownik chce zagrać.
2) Aby rozpocząć wyszukiwanie, używa Gateway=Discord, czyli reaguje na wiadomość:

3) Ten gateway idzie do RoomService i mówi „Użytkownik z Discorda chce wejść do kolejki, tryb: gra niena rankingowa”.
4) RoomService przyjmuje prośbę gatewaya i dodaje odpowiedniego użytkownika (dokładniej, grupę użytkownika) do właściwej kolejki.
5) Kolejka przy każdej zmianie sprawdza, czy jest wystarczająco graczy do gry. Jeśli tak — emitujemy zdarzenie:
private onRoomFound(players: Party[]) {
this.emit("room-found", {
players,
});
}
6) RoomService z radością nasłuchuje każdą kolejkę w napięciu oczekiwania na to wydarzenie. Otrzymujemy listę graczy, tworzymy z nich wirtualną "pokój", a następnie emitujemy zdarzenie:
queue.addListener("room-found", (event: RoomFoundEvent) => {
console.log(
`Tryb wykrywania pokoju: [${mode}]. Czas na znalezienie wolnego pokoju dla tych facetów`
);
const room = this.getFreeRoom(mode);
room.fill(event.players);
this.onRoomFormed(room);
});
7) Dotarliśmy do "najwyższej" instancji – klasy Bot. Ogólnie rzecz biorąc, zajmuje się on komunikacją między bramkami (jak to zabawnie wygląda po rosyjsku, nie mogę tego znieść) a logiką biznesową matchmakingu. Bot nasłuchuje zdarzenia i nakazuje DiscordGateway wysłać wszystkim użytkownikom sprawdzenie gotowości.

8) Jeśli ktoś odrzucił lub nie zaakceptował gry w ciągu 3 minut, nie przywracamy ich do kolejki. Wszystkich pozostałych przywracamy do kolejki i czekamy, aż znowu zbierze się 10 osób. Gdy wszyscy gracze zaakceptują grę, rozpoczyna się ciekawa część.
Konfiguracja dedykowanego serwera
Nasze gry są hostowane na VDS z Windows Server 2012. Można z tego wyciągnąć kilka wniosków:
- Nie ma tu Dockera, co uderzyło mnie prosto w serce
- Oszczędzamy na wynajmie
Zadanie jest takie: uruchomić proces na VDS z VPS na Linuksa. Napisałem prosty serwer na Flask. Tak, nie lubię Pythona, ale co zrobić — napisanie tego serwera jest szybsze i prostsze na nim.
Pełni 3 funkcje:
- Uruchamianie serwera z konfiguracją – wybór mapy, liczba graczy do rozpoczęcia gry oraz zestaw wtyczek. O wtyczkach nie będę teraz pisał – to osobna historia z litrami kawy w nocy, wymieszaną z łzami i wyrywanymi włosami.
- Zatrzymywanie/ponowne uruchamianie serwera w przypadku nieudanych połączeń, które możemy obsługiwać tylko ręcznie.
Tutaj wszystko jest proste, przykłady kodu są zbyteczne. Skrypt liczy 100 linii
I tak, gdy 10 osób zebrało się razem i zaakceptowało grę, serwer jest uruchomiony, a wszyscy pragną grać, w wiadomości prywatnej pojawia się link do połączenia z grą.

Po kliknięciu w link gracz łączy się z serwerem gry, a dalsze kroki przebiegają już automatycznie. Po około 25 minutach wirtualny "pokój" z graczami zostaje opróżniony.
Z góry przepraszam za nieporadność artykułu, dawno tutaj nie pisałem, a kodu jest zbyt dużo, by wyróżnić ważne fragmenty. Krótko mówiąc, to chaos.
Jeśli zobaczę zainteresowanie tematem, to będzie druga część — w niej będą moje zmagania z wtyczkami do srcds (Source dedicated server), a także prawdopodobnie system rankingowy i mini-dotabuff, stroną ze statystykami gier.
Kilka linków:
Źródło: habr.com
