¡Presentamos Battlescripts: Bots, Ships, Mayhem!

Publicado: 2022-03-11

La programación no tiene por qué centrarse exclusivamente en la creación de aplicaciones, el cumplimiento de objetivos y el cumplimiento de las especificaciones del proyecto. También puede tratarse de divertirse, de disfrutar el proceso de creación de algo. Mucha gente trata la programación y el desarrollo de esta habilidad como una forma de recreación. En Toptal, queríamos probar algo interesante dentro de nuestra comunidad. Decidimos construir una plataforma de juego de bot contra bot en torno a Battleship, que ahora es de código abierto.

¡Presentamos Battlescripts: Bots, Ships, Mayhem!

Desde su lanzamiento inicial interno, la plataforma ha captado la atención de algunos increíbles creadores de bots dentro de nuestra comunidad. Nos impresionó mucho ver que uno de los miembros de la comunidad, el ingeniero de Toptal Quan Le, incluso creó una herramienta para depurar Battlescripts Bots fácilmente. El anuncio también despertó el interés de unos pocos por crear sus propios motores bot-vs-bot, compatibles con diferentes tipos de juegos y diferentes reglas. Ideas asombrosas comenzaron a fluir desde el momento en que se presentó Battlescripts. Hoy, estamos felices de hacer que Battlescripts sea de código abierto. Esto le da a nuestra comunidad y a todos los demás la oportunidad de explorar el código, hacer contribuciones y/o bifurcarlo para hacer algo completamente diferente.

Anatomía de los guiones de batalla

Battlescripts está construido usando algunos componentes muy simples. Se ejecuta en Node.js y utiliza algunos de los paquetes más populares y mejor implementados, como Express, Mongoose, etc. El back-end está en JavaScript puro, así como los scripts del front-end. Las únicas dos dependencias externas de esta aplicación son MongoDB y Redis. El código enviado por el usuario para los bots se ejecuta utilizando el módulo "vm" que viene con Node.js. En producción, Docker se usa para mayor seguridad, pero no es una dependencia estricta de Battlescripts.

guiones de batalla

El código de Battlescripts está disponible en GitHub bajo la licencia BSD de 3 cláusulas. El archivo README.md incluido tiene instrucciones detalladas sobre cómo clonar el repositorio e iniciar la aplicación localmente.

Servidor web

Notará que la aplicación tiene una estructura similar a la de las aplicaciones web Express.js simples. El archivo app.js arranca el servidor estableciendo una conexión con la base de datos, registrando algunos middleware comunes y definiendo algunas estrategias de autenticación social. Además, todos los modelos y rutas se definen dentro del directorio "lib/". La aplicación completa requiere solo unos pocos modelos: Batalla, Bot, Desafío, Concurso, Fiesta y Usuario. Las batallas entre bots se simulan fuera de los nodos del servidor web y se realizan utilizando el paquete Kue de Node.js. Esto nos permite aislar el motor del resto de la aplicación web, lo que hace que sea menos probable que el motor de simulación de batalla interfiera con los servidores web, manteniendo la propia aplicación web más receptiva y estable.

Robots y motor

Dado que se espera que los bots se implementen en JavaScript, y eso es exactamente lo que tenemos en nuestro back-end con Node.js, fue más fácil construir el motor. Cuando se trata de ejecutar el código enviado por el usuario, uno de los mayores desafíos es asegurarse de que el código no haga algo malicioso en el servidor, o que cualquier código que tenga errores no interfiera con la estabilidad del sistema en general. La biblioteca estándar de Node.js viene con este increíble módulo que hace que parte de esta tarea sea muy fácil. El módulo "vm" se introdujo para facilitar a los desarrolladores de Node.js la ejecución de código no confiable en un contexto separado. Aunque según la documentación oficial, es importante ejecutar el código que no es de confianza en un proceso separado, pero eso es algo que hacemos en los servidores de producción. Durante el desarrollo local, el módulo "vm" y las características que ofrece funcionan bien.

¡Haz que los bots peleen entre sí, mientras aún sea legal!
Pío

Ejecutando JavaScript

Si desea ejecutar algún código JavaScript arbitrario en Node.js en un contexto separado, puede usar el módulo "vm" de la siguiente manera:

 var vm = require('vm') var ctxObj = { result: '' } vm.runInNewContext(' result = “0xBEEF” ', ctxObj ) console.log(ctxObj); // { result: “0xBEEF” }

Dentro de este "nuevo contexto", el código que ejecuta ni siquiera tiene acceso a "console.log", porque en ese contexto esa función no existe. Sin embargo, podría exponer la función "context.log" del contexto original en el nuevo contexto pasándola como un atributo de "ctxObj".

En Battlescripts, los nodos que simulan batallas ejecutan cada bot en contextos "vm" independientes de Node.js. El motor asume la responsabilidad de sincronizar el estado de los contextos de ambos bots según las reglas del juego.

Ejecutar código JavaScript en un contexto aislado no es todo lo que hace este módulo. La función “runInNewContext” acepta un objeto como tercer parámetro que puede controlar tres aspectos adicionales de la ejecución de este código:

  • Nombre de archivo que se usará en los seguimientos de pila generados relevantes para esta ejecución.
  • Si imprimir o no errores en stderr.
  • Número de milisegundos para permitir que la ejecución continúe antes de que se agote el tiempo.

Una de las trampas de este módulo "vm" es que no proporciona ningún medio para limitar el uso de la memoria. Esto, junto con algunas otras limitaciones del módulo, se soluciona en el servidor mediante el uso de Docker y la forma en que se ejecutan los nodos del motor. El módulo "vm", cuando se usa con mucha frecuencia, lentamente comienza a perder memoria que es difícil de rastrear y liberar. Incluso si los objetos de contexto se reutilizan, el uso de la memoria sigue creciendo. Resolvimos este problema siguiendo una estrategia simple. Cada vez que se simula una batalla en un nodo trabajador, el nodo sale. Luego, el programa supervisor en el servidor de producción reinicia el nodo trabajador, que luego queda listo para manejar la próxima simulación de batalla en una fracción de segundo.

Extensibilidad

Battlescripts se diseñó originalmente en torno a las reglas estándar de Battleship. El motor interior no era muy extensible. Sin embargo, después del lanzamiento de Battlescripts, una de las solicitudes más comunes fue la introducción de nuevos tipos de juegos, ya que los usuarios de la aplicación se dieron cuenta rápidamente de que algunos juegos son más fáciles de conquistar con bots que otros. Por ejemplo, si compara TicTacToe con Chess, el primero tiene un espacio de estado mucho más pequeño, lo que hace que sea muy fácil para los bots encontrar una solución que gane o termine un juego en tablas.

El motor de Battlescripts se ha modificado un poco recientemente para facilitar la introducción de nuevos tipos de juegos. Esto se puede hacer simplemente siguiendo una construcción con un puñado de funciones similares a ganchos. Se agregó un tipo de juego adicional, TicTacToe, a la base de código, ya que es más fácil de seguir. Todo lo relevante para este tipo de juego se puede encontrar dentro del archivo "lib/games/tictactoe.js".

Sin embargo, en este artículo, veremos la implementación del tipo de juego Battleship. La exploración del código del juego TicTacToe se puede dejar como ejercicio para más adelante.

Acorazado

Antes de echar un vistazo a cómo se implementa el juego, echemos un vistazo a cómo se ve un bot estándar para Battlescript:

 function Bot() {} Bot.prototype.play = function(turn) { // ... }

Eso es básicamente todo. Cada bot se define como una función constructora con un método "reproducir". El método se invoca para cada turno con un argumento. Para cualquier juego, el argumento es un objeto con un método que le permite al bot hacer su movimiento para el turno y puede venir con algunos atributos adicionales que representan el estado del juego.

Como se mencionó anteriormente, el motor se ha modificado un poco recientemente. Toda la lógica específica de Battleship se ha extraído del código del motor real. Como el motor todavía hace el trabajo pesado, el código que define el juego Battleship es muy simple y liviano.

 function Battleships(bot1, bot2) { return new Engine(bot1, bot2, { hooks: { init: function() { // ... }, play: function() { // ... }, turn: function() { // ... } } }) } module.exports = exports = Battleships

Observe cómo aquí simplemente estamos definiendo tres funciones similares a ganchos: init, play y turn. Cada función se invoca con el motor como su contexto. Se crea una instancia de la función "init" justo como el objeto del motor, desde dentro de la función constructora. Por lo general, aquí es donde debe preparar todos los atributos de estado del motor. Uno de esos atributos que debe prepararse para cada juego son las "cuadrículas" y (opcionalmente) las "piezas". Siempre debe ser una matriz con dos elementos, uno para cada jugador, que represente el estado del tablero de juego.

 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([]) }

El segundo gancho, "jugar", se invoca justo antes de que comience el juego. Esto es útil, ya que nos da la oportunidad de hacer cosas como colocar piezas de juego en el tablero en nombre de los 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 }) } } }

Esto puede parecer un poco abrumador al principio, pero el objetivo que logra este código es simple. Genera matrices de piezas, una para cada bot, y las coloca en las cuadrículas correspondientes de manera uniforme. Para cada pieza, se escanea la cuadrícula y cada posición válida se almacena en una matriz temporal. Una posición válida es donde dos piezas no se superponen ni comparten celdas adyacentes.

Finalmente, el tercer y último gancho “gira”. A diferencia de los otros dos anzuelos, este es un poco diferente. El propósito de este enlace es devolver un objeto, que el motor utiliza como primer argumento al invocar el método de reproducción del 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 de este método, comenzamos informando al motor que el bot ha realizado un movimiento con éxito. Un bot que no logra hacer un movimiento de ataque para cualquier juego en cualquier turno automáticamente pierde el juego. A continuación, en caso de que el movimiento haya alcanzado con éxito el barco, determinamos si el barco ha sido destruido por completo. En caso de que lo fuera, devolvemos los detalles del barco que fue destruido, de lo contrario, devolvemos "verdadero" para indicar un golpe exitoso sin ninguna información adicional.

A lo largo de estos códigos, hemos encontrado algunos atributos y nombres de métodos que están disponibles en "esto". Estos son proporcionados por el objeto Engine y cada uno tiene algunas características de comportamiento simples:

  • this.turn.called: Esto comienza como falso antes de cada turno, y debe establecerse en verdadero para informar al motor que el bot ha actuado en el turno.

  • this.turn.botNo: será 0 o 1, según el bot que haya jugado este turno.

  • this.end(botNo): llamar a esto con un número de bot finaliza el juego y marca al bot como victorioso. Llamarlo con -1 termina el juego en tablas.

  • this.track(botNo, isOkay, data, failReason): este es un método práctico que le permite registrar los detalles del movimiento del bot o el motivo de un movimiento fallido. Eventualmente, estos datos registrados se utilizan para visualizar la simulación en el front-end.

Esencialmente, esto es todo lo que se necesita hacer en el back-end para implementar un juego en esta plataforma.

Juegos de repetición

Tan pronto como finaliza una simulación de batalla, el front-end se redirige a la página de repetición del juego. Aquí es donde se visualizan la simulación y los resultados, y se muestran otros datos relacionados con el juego.

El back-end representa esta vista utilizando "battle-view-battleships.jade" en "views/" con todos los detalles de la batalla en contexto. La animación de reproducción del juego se realiza a través de JavaScript front-end. Todos los datos registrados a través del método “trace()” del motor están disponibles en el contexto de esta plantilla.

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

¿Qué sigue?

Ahora que Battlescripts es de código abierto, las contribuciones son bienvenidas. La plataforma en su etapa actual está madura, pero tiene mucho espacio para mejoras. Ya sea una nueva característica, un parche de seguridad o incluso correcciones de errores, siéntase libre de crear un problema en el repositorio solicitando que se solucione, o bifurque el repositorio y envíe una solicitud de extracción. Y si esto lo inspira a construir algo completamente nuevo, ¡asegúrese de hacérnoslo saber y deje un enlace en la sección de comentarios a continuación!