Presentazione di Battlescripts: bot, navi, caos!
Pubblicato: 2022-03-11La programmazione non deve necessariamente riguardare la creazione di applicazioni, il raggiungimento degli obiettivi e la soddisfazione delle specifiche del progetto. Può anche trattarsi di divertirsi, di godersi il processo di creazione di qualcosa. Molte persone considerano la programmazione e lo sviluppo di questa abilità come una forma di ricreazione. In Toptal, volevamo provare qualcosa di interessante all'interno della nostra comunità. Abbiamo deciso di costruire una piattaforma di gioco bot contro bot attorno a Battleship, che ora è open-source.
Sin dal suo lancio iniziale internamente, la piattaforma ha attirato l'attenzione di alcuni fantastici creatori di bot all'interno della nostra community. Siamo rimasti davvero colpiti nel vedere che uno dei membri della community, l'ingegnere di Toptal Quan Le, ha persino creato uno strumento per eseguire facilmente il debug dei bot di Battlescripts. L'annuncio ha anche suscitato l'interesse di alcuni a creare i propri motori bot-vs-bot, che supportano diversi tipi di giochi e regole diverse. Dal momento in cui Battlescripts è stato presentato, sono arrivate idee straordinarie. Oggi siamo felici di rendere Battlescripts open-source. Ciò offre alla nostra community e a tutti gli altri l'opportunità di esplorare il codice, fornire contributi e/o eseguirne il fork per ricavarne qualcos'altro.
Anatomia degli script di battaglia
Battlescripts è costruito utilizzando alcuni componenti molto semplici. Funziona su Node.js e utilizza alcuni dei pacchetti più popolari e ben implementati, come Express, Mongoose, ecc. Il back-end è in puro JavaScript, così come gli script front-end. Le uniche due dipendenze esterne di questa applicazione sono MongoDB e Redis. Il codice inviato dall'utente per i bot viene eseguito utilizzando il modulo "vm" fornito con Node.js. Nella produzione, Docker viene utilizzato per una maggiore sicurezza, ma non è una dipendenza rigida da Battlescripts.
Il codice per Battlescripts è disponibile su GitHub con la licenza BSD a 3 clausole. Il file README.md incluso contiene istruzioni dettagliate su come clonare il repository e avviare l'applicazione in locale.
Server web
Noterai che l'applicazione ha una struttura simile a quella delle semplici applicazioni web Express.js. Il file app.js esegue il bootstrap del server stabilendo una connessione al database, registrando alcuni middleware comuni e definendo alcune strategie di autenticazione sociale. Inoltre, tutti i modelli e le rotte sono definiti all'interno della directory “lib/”. L'intera applicazione richiede solo pochi modelli: Battle, Bot, Challenge, Contest, Party e User. Le battaglie tra bot vengono simulate al di fuori dei nodi del server Web e vengono eseguite utilizzando il pacchetto Kue di Node.js. Ciò ci consente di isolare il motore dal resto dell'applicazione Web, rendendo meno probabile che il motore di simulazione di battaglia interferisca con i server Web, mantenendo l'applicazione Web stessa più reattiva e stabile.
Bot e motore
Dal momento che i bot dovrebbero essere implementati in JavaScript, ed è esattamente quello che abbiamo sul nostro back-end con Node.js, è stato più facile costruire il motore. Quando si tratta di eseguire il codice inviato dall'utente, una delle maggiori sfide è assicurarsi che il codice non faccia qualcosa di dannoso sul server o che qualsiasi codice che presenta bug non interferisca con la stabilità del sistema generale. La libreria standard di Node.js viene fornita con questo fantastico modulo che rende molto semplice parte di questa attività. Il modulo "vm" è stato introdotto per facilitare agli sviluppatori Node.js l'esecuzione di codice non attendibile in un contesto separato. Sebbene, secondo la documentazione ufficiale, sia importante eseguire codice non attendibile in un processo separato, ma è qualcosa che facciamo sui server di produzione. Durante lo sviluppo locale, il modulo "vm" e le funzionalità che offre funzionano bene.
Esecuzione di JavaScript
Se desideri eseguire del codice JavaScript arbitrario in Node.js in un contesto separato, puoi utilizzare il modulo "vm" come segue:
var vm = require('vm') var ctxObj = { result: '' } vm.runInNewContext(' result = “0xBEEF” ', ctxObj ) console.log(ctxObj); // { result: “0xBEEF” }
All'interno di questo "nuovo contesto", il codice che esegui non ha nemmeno accesso a "console.log", perché in quel contesto tale funzione non esiste. Potresti, tuttavia, esporre la funzione "context.log" del contesto originale nel nuovo contesto passandolo come attributo di "ctxObj".
In Battlescripts, i nodi che simulano le battaglie eseguono ogni bot in contesti "vm" di Node.js separati. Il motore si assume la responsabilità di sincronizzare lo stato dei contesti per entrambi i bot secondo le regole del gioco.
L'esecuzione di codice JavaScript in un contesto isolato non è tutto ciò che fa questo modulo. La funzione "runInNewContext" accetta un oggetto come terzo parametro che può controllare tre aspetti aggiuntivi di questa esecuzione di codice:
- Nome file da utilizzare nelle tracce dello stack generate relative a questa esecuzione.
- Se stampare o meno gli errori su stderr.
- Numero di millisecondi per consentire il proseguimento dell'esecuzione prima del timeout.
Una delle insidie di questo modulo "vm" è che non fornisce alcun mezzo per limitare l'utilizzo della memoria. Questo, insieme ad alcune altre limitazioni del modulo, viene aggirato sul server attraverso l'uso di Docker e il modo in cui vengono eseguiti i nodi del motore. Il modulo “vm”, se usato molto frequentemente, inizia lentamente a perdere memoria che è difficile da rintracciare e liberare. Anche se gli oggetti contesto vengono riutilizzati, l'utilizzo della memoria continua a crescere. Abbiamo risolto questo problema seguendo una semplice strategia. Ogni volta che viene simulata una battaglia in un nodo di lavoro, il nodo esce. Il programma supervisore sul server di produzione riavvia quindi il nodo di lavoro che diventa pronto per gestire la successiva simulazione di battaglia in una frazione di secondo.
Estensibilità
Battlescripts è stato originariamente progettato attorno alle regole standard di Battleship. Il motore all'interno non era molto estensibile. Tuttavia, dopo il lancio di Battlescripts, una delle richieste più comuni è stata l'introduzione di nuovi tipi di giochi, poiché gli utenti dell'applicazione si sono subito resi conto che alcuni giochi sono più facili da conquistare con i bot rispetto ad altri. Ad esempio, se confronti TicTacToe con Chess, il primo ha uno spazio di stato molto più piccolo, rendendo molto facile per i bot trovare una soluzione che vincerà o finirà una partita in pareggio.
Il motore di Battlescripts è stato recentemente leggermente modificato per facilitare l'introduzione di nuovi tipi di gioco. Questo può essere fatto semplicemente seguendo un costrutto con una manciata di funzioni simili a un gancio. Un ulteriore tipo di gioco, TicTacToe, è stato aggiunto alla codebase poiché è più facile da seguire. Tutto ciò che riguarda questo tipo di gioco può essere trovato all'interno del file "lib/games/tictactoe.js".
Tuttavia, in questo articolo, daremo un'occhiata all'implementazione del tipo di gioco Battleship. L'esplorazione del codice di gioco di TicTacToe può essere lasciata come esercizio per dopo.
Corazzata
Prima di dare un'occhiata a come viene implementato il gioco, diamo un'occhiata all'aspetto di un bot standard per Battlescript:
function Bot() {} Bot.prototype.play = function(turn) { // ... }
Questo è praticamente tutto. Ogni bot è definito come una funzione di costruzione con un metodo “play”. Il metodo viene invocato per ogni turno con un argomento. Per qualsiasi gioco, l'argomento è un oggetto con un metodo che consente al bot di fare la sua mossa per il turno e può avere alcuni attributi aggiuntivi che rappresentano lo stato del gioco.
Come accennato in precedenza, il motore è stato leggermente modificato di recente. Tutta la logica specifica di Battleship è stata estratta dal codice motore effettivo. Poiché il motore fa ancora il lavoro pesante, il codice che definisce il gioco Battleship è molto semplice e leggero.
function Battleships(bot1, bot2) { return new Engine(bot1, bot2, { hooks: { init: function() { // ... }, play: function() { // ... }, turn: function() { // ... } } }) } module.exports = exports = Battleships
Nota come qui stiamo semplicemente definendo tre funzioni simili a un gancio: init, play e turn. Ogni funzione viene invocata con il motore come contesto. La funzione "init" proprio come viene istanziata l'oggetto motore, dall'interno della funzione di costruzione. In genere è qui che dovresti preparare tutti gli attributi di stato del motore. Uno di questi attributi che deve essere preparato per ogni gioco è "griglie" e (facoltativamente) "pezzi". Questo dovrebbe sempre essere un array con due elementi, uno per ogni giocatore, che rappresentano lo stato del tabellone di gioco.
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([]) }
Il secondo hook, "play", viene invocato subito prima dell'inizio del gioco. Questo è utile, in quanto ci dà l'opportunità di fare cose come posizionare pezzi di gioco sul tabellone per conto dei robot.
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 }) } } }
All'inizio può sembrare un po' opprimente, ma l'obiettivo che questo pezzo di codice raggiunge è semplice. Genera array di pezzi, uno per ogni bot, e li posiziona sulle griglie corrispondenti in modo uniforme. Per ogni pezzo, la griglia viene scansionata e ogni posizione valida viene memorizzata in un array temporaneo. Una posizione valida è quella in cui due pezzi non si sovrappongono o non condividono celle adiacenti.

Infine, il terzo e l'ultimo gancio “girano”. A differenza degli altri due ganci, questo è leggermente diverso. Lo scopo di questo hook è restituire un oggetto, che il motore utilizza come primo argomento per invocare il metodo di riproduzione 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)) }
All'interno di questo metodo, iniziamo informando il motore che il bot ha eseguito con successo una mossa. Un bot che non riesce a effettuare una mossa di attacco per qualsiasi partita in qualsiasi turno, perde automaticamente la partita. Successivamente, nel caso in cui la mossa abbia colpito con successo la nave, determiniamo se la nave è stata completamente distrutta. Nel caso lo fosse, restituiamo i dettagli della nave che è stata distrutta, altrimenti restituiamo "true" per indicare un colpo riuscito senza alcuna informazione aggiuntiva.
In tutti questi codici, abbiamo riscontrato alcuni attributi e nomi di metodi disponibili in "questo". Questi sono forniti dall'oggetto Engine e ognuno ha alcune semplici caratteristiche comportamentali:
this.turn.call: inizia come false prima di ogni turno e deve essere impostato su true per informare il motore che il bot ha agito per il turno.
this.turn.botNo: Questo sarà 0 o 1, a seconda del bot che ha giocato questo turno.
this.end(botNo): chiamarlo con un numero di bot termina il gioco e contrassegna il bot come vittorioso. Chiamarlo con -1 pone fine al gioco in parità.
this.track(botNo, isOkay, data, failReason): questo è un metodo pratico che ti consente di registrare i dettagli della mossa per il bot o il motivo di una mossa non riuscita. Alla fine, questi dati registrati vengono utilizzati per visualizzare la simulazione sul front-end.
In sostanza, questo è tutto ciò che è necessario fare sul back-end per implementare un gioco su questa piattaforma.
Riproduzione di giochi
Non appena una simulazione di battaglia termina, il front-end si reindirizza alla pagina di riproduzione del gioco. Qui è dove vengono visualizzati la simulazione e i risultati e vengono visualizzati altri dati relativi al gioco.
Questa vista è resa dal back-end usando "battle-view-battleships.jade" in "views/" con tutti i dettagli della battaglia nel contesto. L'animazione di re-play del gioco viene eseguita tramite JavaScript front-end. Tutti i dati registrati attraverso il metodo “trace()” del motore sono disponibili nel contesto di questo template.
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() })
fermare()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() })
fermare()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() })
fermare()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() })
Cosa succede dopo?
Ora che Battlescripts è open source, i contributi sono i benvenuti. La piattaforma nella sua fase attuale è matura, ma ha molto spazio per miglioramenti. Che si tratti di una nuova funzionalità, di una patch di sicurezza o anche di correzioni di bug, sentiti libero di creare un problema nel repository richiedendone la risoluzione, oppure effettua il fork del repository e invia una richiesta pull. E se questo ti ispira a costruire qualcosa di completamente nuovo, assicurati di farcelo sapere e di lasciare un link ad esso nella sezione commenti qui sotto!