Pisząc matchmaking dla Dota 2014

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.

Pisząc matchmaking dla Dota 2014

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ść:

Pisząc matchmaking dla Dota 2014

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.

Pisząc matchmaking dla Dota 2014

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:

  1. Nie ma tu Dockera, co uderzyło mnie prosto w serce
  2. 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:

  1. 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.
  2. 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ą.

Pisząc matchmaking dla Dota 2014

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:

  1. Nasza strona (statystyki, tabela liderów, mała strona lądowania i pobieranie klienta)
  2. Serwer Discord

Źródło: habr.com

Kup solidny hosting stron z ochroną przed DDoS, serwery VPS VDS 🔥 Kup solidny hosting stron z ochroną przed DDoS, serwery VPS VDS | ProHoster