Rédaction de matchmaking pour Dota 2014

Bonjour.



Ce printemps, je suis tombé sur un projet dans lequel les gars ont appris à exécuter le serveur Dota 2 de la version 2014 et, par conséquent, à jouer dessus. Je suis un grand fan de ce jeu, et je ne pouvais pas passer par l'opportunité unique de plonger dans mon enfance.



J'ai plongé très profondément, et il se trouve que j'ai écrit un bot Discord, qui est responsable de presque toutes les fonctionnalités qui ne sont pas supportées dans l'ancienne version du jeu, à savoir le matchmaking.

Avant toutes les innovations avec le bot, le lobby était créé manuellement. Nous avons recueilli 10 réponses à un message et assemblé manuellement un serveur ou hébergé un lobby local.







Ma nature de programmeur ne supportait pas autant de travail manuel, et du jour au lendemain, j'ai esquissé la version la plus simple du bot, qui faisait automatiquement apparaître le serveur lorsque 10 personnes étaient recrutées.



J'ai décidé d'écrire tout de suite dans nodejs, car je n'aime pas vraiment python, et je me sens plus à l'aise dans cet environnement.



C'est ma première expérience d'écriture d'un bot pour Discord, mais cela s'est avéré très simple. Le module officiel npm discord.js fournit une interface pratique pour travailler avec des messages, collecter des réactions, etc.



Avertissement: Tous les exemples de code sont "à jour", ce qui signifie qu'ils ont subi plusieurs itérations de réécriture pendant la nuit.



Le cœur du matchmaking est la «file d'attente» dans laquelle les joueurs qui veulent jouer sont placés et supprimés lorsqu'ils ne veulent pas ou ne trouvent pas de match.



Voilà à quoi ressemble l'essence du «joueur». Au départ, il s'agissait simplement d'un identifiant d'utilisateur dans Discord, mais les plans incluent un lanceur / recherche d'un jeu sur le site, mais tout d'abord.



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);
  }
}


Et voici l'interface de la file d'attente. Ici, au lieu de "joueurs", une abstraction sous la forme d'un "groupe" est utilisée. Pour un seul joueur, le groupe se compose de lui-même, et pour les joueurs d'un groupe, respectivement, de tous les joueurs du groupe.



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
}


J'ai décidé d'utiliser des événements pour échanger le contexte. Convient pour les cas - pour l'événement "trouvé un jeu pour 10 personnes", vous pouvez envoyer le message souhaité aux joueurs dans des messages privés et exécuter la logique commerciale principale - lancer une tâche pour vérifier l'état de préparation, préparer le lobby pour le lancement, etc.



Pour IOC, j'utilise InversifyJS. J'ai une expérience agréable avec cette bibliothèque. Rapide et facile!



Nous avons plusieurs files d'attente sur le serveur - nous avons ajouté des modes 1x1, normal / rating et quelques modes personnalisés. Par conséquent, il existe un RoomService unique qui se situe entre l'utilisateur et la recherche de jeu.



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)
          }
        });
      }
    );
  }


(Nouilles de code pour avoir une idée de ce à quoi ressemblent les processus)



Ici, j'initialise une file d'attente pour chacun des modes de jeu implémentés, et j'écoute également les changements dans les "groupes" afin de corriger les files d'attente et d'éviter certains conflits.



Donc, je suis super, j'ai inséré des morceaux de code qui n'ont rien à voir avec le sujet, et maintenant passons directement à la fabrication de mastics.



Prenons un cas:



1) L'utilisateur veut jouer.



2) Pour lancer la recherche, il utilise Gateway = Discord, c'est-à-dire met une réaction au message:







3) Cette passerelle va à RoomService, et dit "L'utilisateur de la discorde veut entrer dans la file d'attente, mode: jeu non évalué."



4) RoomService accepte la demande de la passerelle, et la pousse dans la file d'attente souhaitée de l'utilisateur (plus précisément, le groupe d'utilisateurs).



5) La file d'attente vérifie s'il y a suffisamment de joueurs pour jouer à chaque changement. Si possible, émettez un événement:



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


6) RoomService, évidemment, écoute volontiers chaque file d'attente dans l'anticipation anxieuse de cet événement. A l'entrée, nous recevons une liste de joueurs, formons une "salle" virtuelle de leur part, et, bien sûr, émettons un événement:



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) Nous sommes donc arrivés à l'instance "la plus élevée" - la classe Bot . En général, il traite du lien entre les passerelles (à quel point cela a l'air ridicule en russe, je ne peux pas) et la logique commerciale du matchmaking. Le bot écoute l'événement et ordonne à DiscordGateway d'envoyer un test de préparation à tous les utilisateurs.







8) Si quelqu'un rejette ou n'accepte pas le jeu en 3 minutes, nous NE le remettons PAS dans la file d'attente. Nous renvoyons tous les autres dans la file d'attente et attendons que 10 personnes soient à nouveau recrutées. Si tous les joueurs ont accepté le jeu, la partie amusante commence.



Configuration de serveur dédié



Nos jeux sont hébergés sur VDS avec le serveur Windows 2012. Plusieurs conclusions peuvent en être tirées:



  1. Il n'y a pas de docker dessus, qui a frappé mon cœur
  2. Nous économisons sur le loyer


La tâche consiste à démarrer le processus sur VDS avec VPS sur Linux. J'ai écrit un serveur simple dans Flask. Oui, je n'aime pas python, mais que puis-je faire - écrire sur ce serveur est plus rapide et plus facile.



Il a 3 fonctions:



  1. Lancement du serveur avec configuration - sélection de la carte, nombre de joueurs pour démarrer le jeu et ensemble de plugins. Je n'écrirai pas sur les plugins maintenant - c'est une histoire séparée avec des litres de café la nuit mélangés avec des larmes et des cheveux déchirés.
  2. Arrêt / redémarrage du serveur en cas d'échec des connexions, ce que nous ne pouvons gérer que manuellement.


Tout est simple ici, les exemples de code sont même inappropriés. Script pour 100 lignes



Ainsi, lorsque 10 personnes se sont réunies et ont accepté le jeu, le serveur est en marche et tout le monde a hâte de jouer, un lien pour se connecter au jeu arrive dans des messages privés.







En cliquant sur le lien, le joueur se connecte au serveur de jeu, et puis c'est tout. Après ~ 25 minutes, la "salle" virtuelle avec les joueurs est effacée.



Je m'excuse d'avance pour la maladresse de l'article, je n'ai pas écrit ici depuis longtemps, et il y a trop de code pour mettre en évidence des sections importantes. Nouilles, en bref.



Si je vois de l'intérêt pour le sujet, il y aura une deuxième partie - elle contiendra mes tourments avec des plugins pour srcds (serveur dédié Source), et, probablement, un système de notation et mini-dotabuff, un site avec des statistiques de jeu.



Quelques liens:



  1. Notre site (statistiques, classement, petits landos et téléchargement client)
  2. Serveur Discord



All Articles