Apresentando Battlescripts: Bots, Ships, Mayhem!
Publicados: 2022-03-11A programação não precisa se concentrar apenas na criação de aplicativos, no cumprimento de metas e na satisfação das especificações do projeto. Também pode ser sobre se divertir, sobre aproveitar o processo de criação de algo. Muitas pessoas tratam a programação e o desenvolvimento dessa habilidade como uma forma de recreação. Na Toptal, queríamos experimentar algo interessante em nossa comunidade. Decidimos construir uma plataforma de jogo bot-vs-bot em torno do Battleship, que agora é de código aberto.
Desde seu lançamento inicial internamente, a plataforma chamou a atenção de alguns criadores de bots incríveis em nossa comunidade. Ficamos realmente impressionados ao ver que um dos membros da comunidade, o engenheiro da Toptal Quan Le, até construiu uma ferramenta para depurar facilmente os Bots de Battlescripts. O anúncio também despertou o interesse de alguns em criar seus próprios mecanismos bot-vs-bot, suportando diferentes tipos de jogos e regras diferentes. Ideias incríveis começaram a surgir a partir do momento em que Battlescripts foi revelado. Hoje, estamos felizes em tornar o Battlescripts de código aberto. Isso dá à nossa comunidade e a todos a oportunidade de explorar o código, fazer contribuições e/ou bifurcá-lo para fazer algo totalmente diferente.
Anatomia dos scripts de batalha
Battlescripts é construído usando alguns componentes muito simples. Ele roda em Node.js e usa alguns dos pacotes mais populares e bem implementados, como Express, Mongoose, etc. O back-end é em JavaScript puro, assim como os scripts de front-end. As duas únicas dependências externas deste aplicativo são MongoDB e Redis. O código enviado pelo usuário para bots é executado usando o módulo “vm” que vem com o Node.js. Na produção, o Docker é usado para maior segurança, mas não é uma dependência rígida de Battlescripts.
O código para Battlescripts está disponível no GitHub sob a licença BSD 3-clause. O arquivo README.md incluído contém instruções detalhadas sobre como clonar o repositório e iniciar o aplicativo localmente.
Servidor web
Você notará que o aplicativo tem uma estrutura semelhante à dos aplicativos da Web Express.js simples. O arquivo app.js inicializa o servidor estabelecendo uma conexão com o banco de dados, registrando alguns middlewares comuns e definindo algumas estratégias de autenticação social. Além disso, todos os modelos e rotas são definidos dentro do diretório “lib/”. A aplicação completa requer apenas alguns modelos: Battle, Bot, Challenge, Contest, Party e User. As batalhas entre bots são simuladas fora dos nós do servidor web e são feitas usando o pacote Kue do Node.js. Isso nos permite isolar o mecanismo do resto do aplicativo da web, tornando menos provável que o mecanismo de simulação de batalha interfira nos servidores da web, mantendo o próprio aplicativo da web mais responsivo e estável.
Bots e mecanismo
Como os bots devem ser implementados em JavaScript, e é exatamente isso que temos em nosso back-end com Node.js, foi mais fácil construir o mecanismo. Quando se trata de executar o código enviado pelo usuário, um dos maiores desafios é garantir que o código não faça algo malicioso no servidor ou que qualquer código com bugs não interfira na estabilidade geral do sistema. A biblioteca padrão do Node.js vem com este módulo incrível que torna parte desta tarefa muito fácil. O módulo “vm” foi introduzido para tornar mais fácil para os desenvolvedores do Node.js executar código não confiável em um contexto separado. Embora de acordo com a documentação oficial, é importante executar código não confiável em um processo separado - mas isso é algo que fazemos nos servidores de produção. Durante o desenvolvimento local, o módulo “vm” e os recursos que ele oferece funcionam bem.
Executando JavaScript
Se você deseja executar algum código JavaScript arbitrário no Node.js em um contexto separado, você pode usar o módulo “vm” da seguinte forma:
var vm = require('vm') var ctxObj = { result: '' } vm.runInNewContext(' result = “0xBEEF” ', ctxObj ) console.log(ctxObj); // { result: “0xBEEF” }
Dentro desse “novo contexto”, o código que você executa nem sequer tem acesso ao “console.log”, pois nesse contexto tal função não existe. Você pode, no entanto, expor a função “context.log” do contexto original no novo contexto passando-a como um atributo de “ctxObj”.
Em Battlescripts, nós que simulam batalhas executam cada bot em contextos “vm” separados do Node.js. O motor assume a responsabilidade de sincronizar o estado dos contextos para ambos os bots de acordo com as regras do jogo.
Executar código JavaScript em contexto isolado não é tudo o que este módulo faz. A função “runInNewContext” aceita um objeto como terceiro parâmetro que pode controlar três aspectos adicionais da execução deste código:
- Nome do arquivo a ser usado nos rastreamentos de pilha gerados relevantes para esta execução.
- Se deve ou não imprimir erros para stderr.
- Número de milissegundos para permitir que a execução continue antes do tempo limite.
Uma das armadilhas deste módulo “vm” é que ele não fornece nenhum meio de limitar o uso de memória. Isso, juntamente com algumas outras limitações do módulo, é contornado no servidor por meio do uso do Docker e da maneira como os nós do mecanismo são executados. O módulo “vm”, quando usado com muita frequência, lentamente começa a vazar memória difícil de rastrear e liberar. Mesmo que os objetos de contexto sejam reutilizados, o uso de memória continua crescendo. Resolvemos esse problema seguindo uma estratégia simples. Toda vez que uma batalha é simulada em um nó do trabalhador, o nó sai. O programa supervisor no servidor de produção reinicia o nó do trabalhador, que fica pronto para lidar com a próxima simulação de batalha em uma fração de segundo.
Extensibilidade
Battlescripts foi originalmente projetado em torno das regras padrão do Battleship. O motor dentro não era muito extensível. No entanto, após o lançamento do Battlescripts, um dos pedidos mais comuns foi a introdução de novos tipos de jogos, pois os usuários do aplicativo rapidamente perceberam que alguns jogos são mais fáceis de conquistar com bots do que outros. Por exemplo, se você comparar o TicTacToe com o Xadrez, o primeiro tem um espaço de estado muito menor, tornando muito fácil para os bots encontrarem uma solução que vença ou termine um jogo empatado.
O mecanismo de Battlescripts foi recentemente modificado um pouco para facilitar a introdução de novos tipos de jogos. Isso pode ser feito simplesmente seguindo uma construção com um punhado de funções semelhantes a ganchos. Um tipo de jogo adicional, TicTacToe, foi adicionado à base de código, pois é mais fácil de seguir. Tudo relevante para este tipo de jogo pode ser encontrado dentro do arquivo “lib/games/tictactoe.js”.
No entanto, neste artigo, veremos a implementação do tipo de jogo Battleship. A exploração do código do jogo TicTacToe pode ser deixada como exercício para mais tarde.
Navio de guerra
Antes de dar uma olhada em como o jogo é implementado, vamos dar uma olhada em como é um bot padrão para Battlescript:
function Bot() {} Bot.prototype.play = function(turn) { // ... }
É bem isso. Cada bot é definido como uma função construtora com um método “play”. O método é invocado para cada turno com um argumento. Para qualquer jogo, o argumento é um objeto com um método que permite ao bot fazer sua jogada no turno e pode vir com alguns atributos adicionais que representam o estado do jogo.
Como mencionado anteriormente, o motor foi modificado um pouco recentemente. Toda a lógica específica do Battleship foi retirada do código do motor real. Como o motor ainda faz o trabalho pesado, o código que define o jogo Battleship é muito simples e leve.
function Battleships(bot1, bot2) { return new Engine(bot1, bot2, { hooks: { init: function() { // ... }, play: function() { // ... }, turn: function() { // ... } } }) } module.exports = exports = Battleships
Observe como estamos apenas definindo três funções semelhantes a ganchos aqui: init, play e turn. Cada função é invocada com o mecanismo como seu contexto. A função “init” assim que o objeto engine é instanciado, de dentro da função construtora. Normalmente, é aqui que você deve preparar todos os atributos de estado do mecanismo. Um desses atributos que deve ser preparado para cada jogo são “grades” e (opcionalmente) “peças”. Deve ser sempre um array com dois elementos, um para cada jogador, representando o estado do tabuleiro.

for(var i = 0; i < this.bots.length; ++i) { var grid = [] for(var y = 0; y < consts.gridSize.height; ++y) { var row = [] for(var x = 0; x < consts.gridSize.width; ++x) { row.push({ attacked: false }) } grid.push(row) } this.grids.push(grid) this.pieces.push([]) }
O segundo gancho, “play”, é invocado logo antes do jogo começar. Isso é útil, pois nos dá a oportunidade de fazer coisas como colocar peças do jogo no tabuleiro em nome dos bots.
for(var botNo = 0; botNo < this.bots.length; ++botNo) { for(var i = 0; i < consts.pieces.length; ++i) { var piece = consts.pieces[i] for(var j = 0; j < piece.many; ++j) { var pieceNo = this.pieces[botNo].length var squares = [] for(var y = 0; y < consts.gridSize.height; ++y) { for(var x = 0; x < consts.gridSize.width; ++x) { squares.push({ x: x, y: y, direction: 'h' }) squares.push({ x: x, y: y, direction: 'v' }) } } var square = _.sample(squares.filter(function(square) { var f = { 'h': [1, 0], 'v': [0, 1] } for(var xn = square.x, yn = square.y, i = 0; i < piece.size; xn += f[square.direction][0], yn += f[square.direction][1], ++i) { var d = [[0, -1], [0, 1], [-1, 0], [1, 0], [-1, -1], [-1, 1], [1, -1], [1, 1]] for(var j = 0; j < d.length; ++j) { var xp = xn+d[j][0] var yp = yn+d[j][1] if(xp >= 0 && xp < 10 && yp >= 0 && yp < 10 && this.grids[botNo][yp][xp].pieceNo >= 0) { return false } } if(xn >= consts.gridSize.width || yn >= consts.gridSize.height || this.grids[botNo][yn][xn].pieceNo >= 0) { return false } } return true; }.bind(this))) switch(true) { case square.direction === 'h': for(var k = square.x; k < square.x+piece.size; ++k) { this.grids[botNo][square.y][k].pieceNo = pieceNo } break case square.direction === 'v': for(var k = square.y; k < square.y+piece.size; ++k) { this.grids[botNo][k][square.x].pieceNo = pieceNo } break } this.pieces[botNo].push({ kind: piece.kind, size: piece.size, x: square.x, y: square.y, direction: square.direction, hits: 0, dead: false }) } } }
Isso pode parecer um pouco esmagador no começo, mas o objetivo que esse pedaço de código atinge é simples. Ele gera matrizes de peças, uma para cada bot, e as coloca nas grades correspondentes de maneira uniforme. Para cada peça, a grade é escaneada e cada posição válida é armazenada em uma matriz temporária. Uma posição válida é onde duas peças não se sobrepõem ou compartilham células adjacentes.
Por fim, o terceiro e último gancho “vira”. Ao contrário dos outros dois ganchos, este é um pouco diferente. O objetivo desse gancho é retornar um objeto, que o mecanismo usa como o primeiro argumento ao invocar o método play do bot.
return { attack: _.once(function(x, y) { this.turn.called = true var botNo = this.turn.botNo var otherNo = (botNo+1)%2 var baam = false var square = this.grids[otherNo][y][x] square.attacked = true if(square.pieceNo >= 0) { baam = true this.turn.nextNo = botNo var pieceNo = square.pieceNo var pieces = this.pieces[otherNo] var piece = pieces[pieceNo] piece.hits += 1 if(piece.hits === piece.size) { piece.dead = true baam = { no: pieceNo, kind: piece.kind, size: piece.size, x: piece.x, y: piece.y, direction: piece.direction } } var undead = false for(var i = 0; i < pieces.length; ++i) { if(!pieces[i].dead) { undead = true } } if(!undead) { this.end(botNo) } } this.track(botNo, true, { x: x, y: y, baam: !!baam }) return baam }.bind(this)) }
Dentro desse método, começamos informando ao mecanismo que o bot fez uma jogada com sucesso. Um bot que não fizer um movimento de ataque para qualquer jogo em qualquer turno automaticamente perde o jogo. Em seguida, caso o movimento tenha atingido o navio com sucesso, determinamos se o navio foi destruído completamente. Caso seja, retornamos detalhes do navio que foi destruído, caso contrário, retornamos “true” para indicar um acerto bem-sucedido sem nenhuma informação adicional.
Ao longo desses códigos, encontramos alguns atributos e nomes de métodos que estão disponíveis em “this”. Eles são fornecidos pelo objeto Engine e cada um possui algumas características comportamentais simples:
this.turn.called: Isso começa como false antes de cada turno e deve ser definido como true para informar ao mecanismo que o bot agiu no turno.
this.turn.botNo: Será 0 ou 1, dependendo de qual bot jogou neste turno.
this.end(botNo): Chamar isso com um número de bot encerra o jogo e marca o bot como vitorioso. Pagar com -1 termina o jogo empatado.
this.track(botNo, isOkay, data, failReason): Este é um método de conveniência que permite registrar os detalhes da movimentação do bot ou o motivo de uma movimentação com falha. Eventualmente, esses dados registrados são usados para visualizar a simulação no front-end.
Essencialmente, isso é tudo o que precisa ser feito no back-end para implementar um jogo nessa plataforma.
Repetindo jogos
Assim que uma simulação de batalha termina, o front-end se redireciona para a página de replay do jogo. É aqui que a simulação e os resultados são visualizados e outros dados relacionados ao jogo são exibidos.
Essa visão é renderizada pelo back-end usando o “battle-view-battleships.jade” em “views/” com todos os detalhes da batalha no contexto. A animação de repetição do jogo é feita através de JavaScript front-end. Todos os dados registrados através do método “trace()” do mecanismo estão disponíveis no contexto deste modelo.
function play() { $('.btn-play').hide() $('.btn-stop').show() if(i === moves.length) { i = 0 stop() $('.ul-moves h4').fadeIn() return } if(i === 0) { $('.ul-moves h4').hide() $('table td').removeClass('warning danger') $('.count span').text(0) } $('.ul-moves li').slice(0, $('.ul-moves li').length-i).hide() $('.ul-moves li').slice($('.ul-moves li').length-i-1).show() var move = moves[i] var $td = $('table').eq((move.botNo+1)%2).find('tr').eq(move.data.y+1).find('td').eq(move.data.x+1) if(parseInt($td.text()) >= 0) { $td.addClass('danger') } else { $td.addClass('warning') } ++i $('.count span').eq(move.botNo).text(parseInt($('.count span').eq(move.botNo).text())+1) var delay = 0 switch(true) { case $('.btn-fast').hasClass('active'): delay = 10 break case $('.btn-slow').hasClass('active'): delay = 100 break case $('.btn-slower').hasClass('active'): delay = 500 break case $('.btn-step').hasClass('active'): stop() return } playTimer = setTimeout(function() { play() }, delay) } function stop() { $('.btn-stop').hide() $('.btn-play').text(i === 0 ? 'Re-play' : ($('.btn-step').hasClass('active') ? 'Next' : 'Resume')).show() clearTimeout(playTimer) } $('.btn-play').click(function() { play() }) $('.btn-stop').click(function() { stop() })
Qual o proximo?
Agora que o Battlescripts é de código aberto, contribuições são bem-vindas. A plataforma em seu estágio atual está madura, mas tem muito espaço para melhorias. Seja um novo recurso, um patch de segurança ou até mesmo correções de bugs, sinta-se à vontade para criar um problema no repositório solicitando que ele seja resolvido ou faça um fork do repositório e envie um pull request. E se isso te inspirar a construir algo totalmente novo, não deixe de nos informar e deixar um link na seção de comentários abaixo!