Olá a todos.
Nesta primavera, encontrei um projeto em que pessoal aprendeu a rodar um servidor Dota 2 da versão de 2014, e assim, jogar nele. Sou um grande fã desse jogo e não pude resistir à oportunidade única de voltar à minha infância.
Mergulhei bem fundo e acabei escrevendo um bot para Discord, que responde por praticamente toda a funcionalidade que não é suportada na versão antiga do jogo, especificamente o matchmaking.
Antes de todas as novidades com o bot, os lobbies eram criados manualmente. Reuníamos 10 reações em uma mensagem e montávamos o servidor manualmente ou hospedávamos um lobby local.

A minha natureza de programador não suportou tanta mão de obra manual, e em uma noite escrevi a versão mais simples do bot, que levantava automaticamente o servidor quando 10 pessoas se juntavam.
Decidi escrever em nodejs de imediato, porque não gosto muito de Python, e me sinto mais confortável nesse ambiente.
Essa é a minha primeira experiência escrevendo um bot para Discord, mas na verdade tudo é bem simples. O módulo npm oficial discord.js oferece uma interface conveniente para trabalhar com mensagens, coleta de reações, etc.
Aviso: todos os exemplos de código são 'atuais', ou seja, passaram por várias iterações de reescrita durante as noites.
A base do matchmaking é uma 'fila', onde os jogadores que querem jogar são colocados e removidos quando desistem ou encontram um jogo.
Assim se parece a entidade 'jogador'. Inicialmente, era apenas um id de usuário no Discord, mas há planos para um launcher/busca de jogo no site, mas tudo a seu tempo.
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);
}
}
Aqui está a interface da fila. Em vez de 'jogadores', utiliza-se uma abstração chamada 'grupo'. Para um jogador solitário, o grupo é apenas ele mesmo, e para jogadores em grupo, é, assim, todos os jogadores do grupo.
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
}
Decidi usar eventos para a troca de contexto. Isso se aplica a casos como: ao evento "jogo encontrado para 10 pessoas", posso enviar mensagens diretas aos jogadores com a informação necessária e executar a lógica de negócios principal — iniciar uma tarefa para verificar a prontidão, preparar o lobby para o início e assim por diante.
Para IOC, utilizo o InversifyJS. Tenho uma experiência agradável com esta biblioteca. Rápido e simples!
Temos várias filas em nosso servidor — adicionamos modos 1x1, normal/classificado e algumas personalizadas. Portanto, existe um singleton RoomService, que fica entre o usuário e a busca de jogo.
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))) {
// se a fila tem essa party, re-adicionamos a 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))) {
// se a fila tem essa party, a removemos
q.remove(event.party)
}
});
}
);
}
(Trecho de código para ilustrar como os processos se parecem)
Aqui, inicializo a fila para cada um dos modos de jogo implementados, além de escutar as mudanças de "grupos" para ajustar as filas e evitar alguns conflitos.
Assim, fiz bem, inseri trechos de código que não estão relacionados ao tópico, e agora vamos passar diretamente para o matchmaking.
Vamos considerar o caso:
1) O usuário quer jogar.
2) Para iniciar a busca, ele usa o Gateway=Discord, ou seja, reage a uma mensagem:

3) Este gateway vai ao RoomService e diz "O usuário do Discord quer entrar na fila, modo: jogo não classificado."
4) O RoomService aceita o pedido do gateway e insere o usuário (ou melhor, o grupo do usuário) na fila apropriada.
5) A fila verifica a cada alteração se há jogadores suficientes para iniciar o jogo. Se sim — emitimos o evento:
private onRoomFound(players: Party[]) {
this.emit("room-found", {
players,
});
}
6) O RoomService, evidentemente, ouve com alegria cada fila na expectativa desse evento. Recebemos uma lista de jogadores, formamos uma "sala" virtual e, claro, emitimos o evento:
queue.addListener("room-found", (event: RoomFoundEvent) => {
console.log(
`Modo da sala encontrada: [${mode}]. Hora de conseguir uma sala gratuita para esses caras`
);
const room = this.getFreeRoom(mode);
room.fill(event.players);
this.onRoomFormed(room);
});
7) Aqui chegamos à "instância superior" — a classe Bot. Em geral, ela lida com a comunicação entre os gateways (como isso soa engraçado em russo, não posso) e a lógica de negócios do matchmaking. O bot escuta o evento e ordena ao DiscordGateway enviar a todos os usuários uma verificação de prontidão.

8) Se alguém rejeitou ou não aceitou o jogo em 3 minutos, nós NÃO os devolvemos para a fila. Todos os outros são retornados à fila e aguardamos até que 10 pessoas se reúnam novamente. Se todos os jogadores aceitaram o jogo, a parte interessante começa.
Configuração do servidor dedicado
Nossos jogos são hospedados em VDS com Windows Server 2012. A partir disso, podemos tirar algumas conclusões:
- Não temos Docker, o que me atingiu no coração
- Estamos economizando no aluguel
A tarefa é: rodar um processo no VDS a partir de VPS com Linux. Escrevi um servidor simples em Flask. Sim, não gosto de Python, mas o que posso fazer — escrever este servidor nele é mais rápido e simples.
Ele executa 3 funções:
- Iniciar o servidor com configuração — escolha do mapa, número de jogadores para o início do jogo e um conjunto de plugins. Não vou falar sobre os plugins agora — isso é uma história separada com litros de café à noite misturados com lágrimas e cabelos arrancados.
- Parar/reiniciar o servidor em caso de conexões malsucedidas, que podemos lidar apenas manualmente.
Aqui tudo é simples, exemplos de código seriam até inadequados. Um script de 100 linhas
Assim que 10 pessoas se reúnem e aceitam o jogo, o servidor é iniciado e todos estão ansiosos para jogar, uma mensagem privada com o link para conectar ao jogo é enviada.

Ao clicar no link, o jogador se conecta ao servidor de jogo, e daí para frente tudo se resolve por si. Após cerca de 25 minutos, a "sala" virtual com os jogadores é limpa.
Peço desculpas pela desorganização do artigo, não escrevo aqui há muito tempo, e há código demais para destacar as partes importantes. Enfim.
Se eu ver interesse no tema, haverá uma segunda parte — nela estarei compartilhando minhas dificuldades com plugins para srcds (Servidor dedicado Source), e provavelmente um sistema de classificação e mini-dotabuff, um site com estatísticas de jogos.
Alguns links:
Fonte: habr.com
