Présentation de Battlescripts : Bots, Navires, Mayhem !
Publié: 2022-03-11La programmation n'a pas besoin de se limiter à la création d'applications, à la réalisation d'objectifs et à la satisfaction des spécifications du projet. Il peut aussi s'agir de s'amuser, d'apprécier le processus de création de quelque chose. Beaucoup de gens considèrent la programmation et le développement de cette compétence comme une forme de loisir. Chez Toptal, nous voulions essayer quelque chose d'intéressant au sein de notre communauté. Nous avons décidé de construire une plateforme de jeu bot-vs-bot autour de Battleship, qui est désormais open-source.
Depuis son lancement initial en interne, la plateforme a attiré l'attention de certains incroyables fabricants de robots au sein de notre communauté. Nous avons été vraiment impressionnés de voir que l'un des membres de la communauté, l'ingénieur Toptal Quan Le, a même créé un outil pour déboguer facilement les Battlescripts Bots. L'annonce a également suscité l'intérêt de quelques-uns pour créer leurs propres moteurs bot-v-bot, prenant en charge différents types de jeux et différentes règles. Des idées incroyables ont commencé à affluer à partir du moment où Battlescripts a été dévoilé. Aujourd'hui, nous sommes heureux de rendre Battlescripts open-source. Cela donne à notre communauté et à tous les autres l'occasion d'explorer le code, d'apporter des contributions et/ou de le bifurquer pour en faire quelque chose d'entièrement différent.
Anatomie des Battlescripts
Battlescripts est construit à l'aide de composants très simples. Il fonctionne sur Node.js et utilise certains des packages les plus populaires et les mieux implémentés, tels que Express, Mongoose, etc. Le back-end est en pur JavaScript, ainsi que les scripts front-end. Les deux seules dépendances externes de cette application sont MongoDB et Redis. Le code soumis par l'utilisateur pour les bots est exécuté à l'aide du module "vm" fourni avec Node.js. En production, Docker est utilisé pour plus de sécurité, mais n'est pas une dépendance matérielle de Battlescripts.
Le code pour Battlescripts est disponible sur GitHub sous la licence BSD à 3 clauses. Le fichier README.md inclus contient des instructions détaillées sur la façon de cloner le référentiel et de lancer l'application localement.
Serveur Web
Vous remarquerez que l'application a une structure similaire à celle des applications Web Express.js simples. Le fichier app.js démarre le serveur en établissant une connexion à la base de données, en enregistrant certains middlewares courants et en définissant certaines stratégies d'authentification sociale. De plus, tous les modèles et routes sont définis dans le répertoire « lib/ ». L'application complète ne nécessite que quelques modèles : Battle, Bot, Challenge, Contest, Party et User. Les batailles entre les bots sont simulées en dehors des nœuds du serveur Web et se font à l'aide du package Node.js Kue. Cela nous permet d'isoler le moteur du reste de l'application Web, ce qui réduit la probabilité que le moteur de simulation de combat interfère avec les serveurs Web, ce qui rend l'application Web elle-même plus réactive et stable.
Robots et moteur
Étant donné que les bots devraient être implémentés en JavaScript, et c'est exactement ce que nous avons sur notre back-end avec Node.js, il était plus facile de construire le moteur. Lorsqu'il s'agit d'exécuter du code soumis par l'utilisateur, l'un des plus grands défis est de s'assurer que le code ne fait pas quelque chose de malveillant sur le serveur, ou qu'un code bogué n'interfère pas avec la stabilité du système global. La bibliothèque standard de Node.js est livrée avec ce module étonnant qui rend une partie de cette tâche très facile. Le module "vm" a été introduit afin de permettre aux développeurs Node.js d'exécuter plus facilement du code non approuvé dans un contexte séparé. Bien que selon la documentation officielle, il est important d'exécuter du code non fiable dans un processus séparé - mais c'est quelque chose que nous faisons sur les serveurs de production. Lors du développement local, le module "vm" et les fonctionnalités qu'il propose fonctionnent bien.
Exécuter JavaScript
Si vous souhaitez exécuter du code JavaScript arbitraire dans Node.js dans un contexte distinct, vous pouvez utiliser le module "vm" comme suit :
var vm = require('vm') var ctxObj = { result: '' } vm.runInNewContext(' result = “0xBEEF” ', ctxObj ) console.log(ctxObj); // { result: “0xBEEF” }Dans ce "nouveau contexte", le code que vous exécutez n'a même pas accès à "console.log", car dans ce contexte, une telle fonction n'existe pas. Vous pouvez cependant exposer la fonction "context.log" du contexte d'origine dans le nouveau contexte en la passant comme attribut de "ctxObj".
Dans Battlescripts, les nœuds qui simulent des batailles exécutent chaque bot sous des contextes « vm » Node.js distincts. Le moteur se charge de synchroniser l'état des contextes pour les deux bots selon les règles du jeu.
L'exécution de code JavaScript dans un contexte isolé n'est pas tout ce que fait ce module. La fonction "runInNewContext" accepte un objet comme troisième paramètre qui peut contrôler trois aspects supplémentaires de l'exécution de ce code :
- Nom de fichier à utiliser dans les traces de pile générées pertinentes pour cette exécution.
- Indique s'il faut ou non imprimer les erreurs sur stderr.
- Nombre de millisecondes pour permettre à l'exécution de se poursuivre avant son expiration.
L'un des écueils de ce module « vm » est qu'il ne fournit aucun moyen de limiter l'utilisation de la mémoire. Ceci, ainsi que quelques autres limitations du module, est contourné sur le serveur grâce à l'utilisation de Docker et à la manière dont les nœuds du moteur sont exécutés. Le module "vm", lorsqu'il est utilisé très fréquemment, commence lentement à fuir de la mémoire difficile à localiser et à libérer. Même si les objets de contexte sont réutilisés, l'utilisation de la mémoire ne cesse de croître. Nous avons résolu ce problème en suivant une stratégie simple. Chaque fois qu'une bataille est simulée dans un nœud de travail, le nœud se ferme. Le programme de supervision sur le serveur de production redémarre alors le nœud de travail qui devient alors prêt à gérer la prochaine simulation de bataille en une fraction de seconde.
Extensibilité
Battlescripts a été conçu à l'origine autour des règles standard de Battleship. Le moteur à l'intérieur n'était pas très extensible. Cependant, après le lancement de Battlescripts, l'une des demandes les plus courantes était d'introduire de nouveaux types de jeux, car les utilisateurs de l'application ont rapidement réalisé que certains jeux sont plus faciles à conquérir avec des bots que d'autres. Par exemple, si vous comparez TicTacToe avec Chess, le premier a un espace d'état beaucoup plus petit, ce qui permet aux bots de trouver très facilement une solution qui gagnera ou terminera une partie en match nul.
Le moteur Battlescripts a récemment été légèrement modifié pour faciliter l'introduction de nouveaux types de jeux. Cela peut être fait en suivant simplement une construction avec une poignée de fonctions de type crochet. Un type de jeu supplémentaire, TicTacToe, a été ajouté à la base de code car il est plus facile à suivre. Tout ce qui concerne ce type de jeu se trouve dans le fichier "lib/games/tictactoe.js".
Cependant, dans cet article, nous examinerons la mise en œuvre du type de jeu Battleship. L'exploration du code du jeu TicTacToe peut être laissée comme exercice pour plus tard.
Bataille navale
Avant de jeter un coup d'œil à la façon dont le jeu est implémenté, jetons un coup d'œil à ce à quoi ressemble un bot standard pour Battlescript :
function Bot() {} Bot.prototype.play = function(turn) { // ... }C'est à peu près tout. Chaque bot est défini comme une fonction constructeur avec une méthode "play". La méthode est invoquée à chaque tour avec un argument. Pour n'importe quel jeu, l'argument est un objet avec une méthode qui permet au bot d'effectuer son mouvement pour le tour, et peut être accompagné de quelques attributs supplémentaires représentant l'état du jeu.
Comme mentionné précédemment, le moteur a été modifié un peu récemment. Toute la logique spécifique à Battleship a été extraite du code moteur réel. Comme le moteur fait toujours le gros du travail, le code qui définit le jeu Battleship est très simple et léger.

function Battleships(bot1, bot2) { return new Engine(bot1, bot2, { hooks: { init: function() { // ... }, play: function() { // ... }, turn: function() { // ... } } }) } module.exports = exports = BattleshipsRemarquez comment nous définissons simplement trois fonctions de type crochet ici : init, play et turn. Chaque fonction est invoquée avec le moteur comme contexte. La fonction "init" juste au moment où l'objet moteur est instancié, à partir de la fonction constructeur. C'est généralement là que vous devez préparer tous les attributs d'état du moteur. Un de ces attributs qui doit être préparé pour chaque jeu est les "grilles" et (éventuellement) les "pièces". Cela devrait toujours être un tableau avec deux éléments, un pour chaque joueur, représentant l'état du plateau de jeu.
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([]) }Le deuxième crochet, "jouer", est invoqué juste avant le début du jeu. C'est utile, car cela nous donne la possibilité de faire des choses comme placer des pièces de jeu sur le plateau au nom des 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 }) } } }Cela peut sembler un peu écrasant au début, mais l'objectif que ce morceau de code atteint est simple. Il génère des tableaux de pièces, une pour chaque bot, et les place sur les grilles correspondantes de manière uniforme. Pour chaque pièce, la grille est scannée et chaque position valide est stockée dans un tableau temporaire. Une position valide est celle où deux pièces ne se chevauchent pas ou ne partagent pas de cellules adjacentes.
Enfin, le troisième et le dernier crochet « tournent ». Contrairement aux deux autres crochets, celui-ci est un peu différent. Le but de ce crochet est de renvoyer un objet, que le moteur utilise comme premier argument pour invoquer la méthode play du 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)) }Dans cette méthode, nous commençons par informer le moteur que le bot a effectué un mouvement avec succès. Un bot qui ne parvient pas à effectuer un mouvement d'attaque pour n'importe quel jeu à n'importe quel tour perd automatiquement la partie. Ensuite, si le mouvement a réussi à toucher le navire, nous déterminons si le navire a été complètement détruit. Dans le cas où c'était le cas, nous renvoyons les détails du navire qui a été détruit, sinon nous renvoyons "true" pour indiquer un coup réussi sans aucune information supplémentaire.
Tout au long de ces codes, nous avons rencontré des attributs et des noms de méthodes qui sont disponibles à « this ». Ceux-ci sont fournis par l'objet Engine et ont chacun des caractéristiques comportementales simples :
this.turn.called : Cela commence par false avant chaque tour, et doit être défini sur true pour informer le moteur que le bot a agi pour le tour.
this.turn.botNo : Ce sera soit 0 soit 1, selon le bot qui a joué ce tour.
this.end(botNo) : appeler ceci avec un numéro de bot met fin au jeu et marque le bot comme victorieux. L'appeler avec -1 termine le jeu par un match nul.
this.track(botNo, isOkay, data, failReason) : il s'agit d'une méthode pratique qui vous permet d'enregistrer les détails du déplacement du bot ou la raison d'un échec de déplacement. Finalement, ces données enregistrées sont utilisées pour visualiser la simulation sur le front-end.
Essentiellement, c'est tout ce qui doit être fait sur le back-end pour implémenter un jeu sur cette plate-forme.
Rejouer des jeux
Dès qu'une simulation de combat se termine, le front-end se redirige vers la page de replay du jeu. C'est là que la simulation et les résultats sont visualisés, et d'autres données liées au jeu sont affichées.
Cette vue est rendue par le back-end à l'aide de "battle-view-battleships.jade" dans "views/" avec tous les détails de la bataille en contexte. L'animation de relecture du jeu se fait via JavaScript frontal. Toutes les données enregistrées via la méthode « trace() » du moteur sont disponibles dans le cadre de ce modèle.
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() })Et ensuite ?
Maintenant que Battlescripts est open source, les contributions sont les bienvenues. La plate-forme à son stade actuel est mature, mais a beaucoup de place pour des améliorations. Qu'il s'agisse d'une nouvelle fonctionnalité, d'un correctif de sécurité ou même de corrections de bogues, n'hésitez pas à créer un problème dans le référentiel en demandant qu'il soit résolu, ou bifurquez le référentiel et soumettez une demande d'extraction. Et si cela vous inspire à construire quelque chose d'entièrement nouveau, assurez-vous de nous le faire savoir et de laisser un lien vers celui-ci dans la section des commentaires ci-dessous !
