Vă prezentăm Battlescripts: Bots, Ships, Mayhem!

Publicat: 2022-03-11

Programarea nu trebuie să fie doar despre construirea de aplicații, îndeplinirea obiectivelor și satisfacerea specificațiilor proiectului. Poate fi vorba și despre a te distra, despre a te bucura de procesul de a crea ceva. Mulți oameni tratează programarea și dezvoltarea acestei abilități ca pe o formă de recreere. La Toptal, am vrut să încercăm ceva interesant în comunitatea noastră. Am decis să construim o platformă de joc bot-vs-bot în jurul Battleship, care este acum open-source.

Vă prezentăm Battlescripts: Bots, Ships, Mayhem!

De la lansarea sa inițială pe plan intern, platforma a atras atenția unor creatori de bot uimitori din comunitatea noastră. Am fost foarte impresionați să vedem că unul dintre membrii comunității, inginerul Toptal Quan Le, a construit chiar și un instrument pentru a depana cu ușurință Battlescripts Bots. Anunțul a stârnit, de asemenea, un interes printre câțiva pentru a-și crea propriile motoare bot-vs-bot, care acceptă diferite tipuri de jocuri și reguli diferite. Ideile uimitoare au început să apară din momentul în care Battlescripts a fost dezvăluit. Astăzi, suntem bucuroși să facem Battlescripts open-source. Acest lucru oferă comunității noastre și tuturor celorlalți ocazia de a explora codul, de a contribui și/sau de a-l bifurca pentru a face cu totul altceva din el.

Anatomia scripturilor de luptă

Battlescripts este construit folosind câteva componente foarte simple. Se rulează pe Node.js și folosește unele dintre cele mai populare și bine implementate pachete, cum ar fi Express, Mongoose, etc. Back-end-ul este în pur JavaScript, precum și script-urile front-end. Singurele două dependențe externe ale acestei aplicații sunt MongoDB și Redis. Codul trimis de utilizator pentru boți este rulat folosind modulul „vm” care vine cu Node.js. În producție, Docker este folosit pentru un plus de siguranță, dar nu este o dependență grea de Battlescripts.

scripturi de luptă

Codul pentru Battlescripts este disponibil pe GitHub sub licența BSD cu 3 clauze. Fișierul README.md inclus conține instrucțiuni detaliate despre cum să clonați depozitul și să lansați aplicația local.

Server Web

Veți observa că aplicația are o structură similară cu cea a aplicațiilor web simple Express.js. Fișierul app.js pornește serverul prin stabilirea unei conexiuni la baza de date, înregistrarea unor middleware obișnuiți și definirea unor strategii de autentificare socială. În plus, toate modelele și rutele sunt definite în directorul „lib/”. Aplicația completă necesită doar câteva modele: Battle, Bot, Challenge, Contest, Party și User. Bătăliile dintre roboți sunt simulate în afara nodurilor serverului web și se desfășoară folosind pachetul Node.js Kue. Acest lucru ne permite să izolăm motorul de restul aplicației web, făcând mai puțin probabil ca motorul de simulare de luptă să interfereze cu serverele web, păstrând aplicația web în sine mai receptivă și mai stabilă.

Boți și motor

Deoarece se așteaptă ca boții să fie implementați în JavaScript și exact asta avem în back-end-ul nostru cu Node.js, a fost mai ușor să construim motorul. Când vine vorba de executarea codului trimis de utilizator, una dintre cele mai mari provocări este să vă asigurați că codul nu face ceva rău intenționat pe server sau că orice cod care are erori nu interferează cu stabilitatea întregului sistem. Biblioteca standard a lui Node.js vine cu acest modul uimitor care face ca o parte din această sarcină să fie foarte ușoară. Modulul „vm” a fost introdus pentru a facilita dezvoltatorilor Node.js să ruleze cod neîncrezător într-un context separat. Deși, conform documentației oficiale, este important să rulăm cod neîncrezător într-un proces separat - dar asta este ceva ce facem pe serverele de producție. În timpul dezvoltării locale, modulul „vm” și caracteristicile pe care le oferă funcționează bine.

Faceți roboții să se lupte între ei, cât timp este încă legal!
Tweet

Se execută JavaScript

Dacă doriți să rulați un cod JavaScript arbitrar în Node.js într-un context separat, puteți utiliza modulul „vm” după cum urmează:

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

În acest „nou context”, codul pe care îl rulați nici măcar nu are acces la „console.log”, deoarece în acel context o astfel de funcție nu există. Ați putea, totuși, să expuneți funcția „context.log” a contextului original în noul context, pasând-o ca atribut al „ctxObj”.

În Battlescripts, nodurile care simulează bătălii rulează fiecare bot în contexte separate „vm” Node.js. Motorul își asumă responsabilitatea sincronizării stării contextelor pentru ambii roboți conform regulilor jocului.

Rularea codului JavaScript în context izolat nu este tot ceea ce face acest modul. Funcția „runInNewContext” acceptă un obiect ca al treilea parametru care poate controla trei aspecte suplimentare ale execuției acestui cod:

  • Nume de fișier care va fi utilizat în urmele stivei generate relevante pentru această execuție.
  • Indiferent dacă se imprimă sau nu erori în stderr.
  • Număr de milisecunde pentru a permite execuției să continue înainte de expirarea timpului.

Unul dintre capcanele acestui modul „vm” este că nu oferă niciun mijloc de limitare a utilizării memoriei. Acest lucru, împreună cu alte câteva limitări ale modulului, este rezolvat pe server prin utilizarea Docker și prin modul în care sunt rulate nodurile motorului. Modulul „vm”, atunci când este folosit foarte frecvent, începe încet să scurgă memorie care este greu de găsit și de eliberat. Chiar dacă obiectele context sunt reutilizate, utilizarea memoriei continuă să crească. Am rezolvat această problemă urmând o strategie simplă. De fiecare dată când o luptă este simulată într-un nod lucrător, nodul iese. Programul supervizor de pe serverul de producție repornește apoi nodul de lucru care devine apoi gata să se ocupe de următoarea simulare de luptă într-o fracțiune de secundă.

Extensibilitate

Battlescripts a fost conceput inițial în jurul regulilor standard ale Battleship. Motorul din interior nu era foarte extensibil. Cu toate acestea, după lansarea Battlescripts, una dintre cele mai frecvente solicitări a fost introducerea unor tipuri de jocuri mai noi, deoarece utilizatorii aplicației și-au dat seama rapid că unele jocuri sunt mai ușor de cucerit cu roboți decât altele. De exemplu, dacă compari TicTacToe cu Șah, primul are un spațiu de stat mult mai mic, ceea ce face foarte ușor pentru roboți să vină cu o soluție care fie va câștiga, fie va termina un joc la egalitate.

Motorul Battlescripts a fost recent modificat puțin pentru a facilita introducerea unor tipuri de jocuri mai noi. Acest lucru se poate face pur și simplu urmând un construct cu o mână de funcții asemănătoare cârligului. Un tip de joc suplimentar, TicTacToe, a fost adăugat la baza de cod, deoarece este mai ușor de urmărit. Tot ce este relevant pentru acest tip de joc poate fi găsit în fișierul „lib/games/tictactoe.js”.

Cu toate acestea, în acest articol, vom arunca o privire asupra implementării tipului de joc Battleship. Explorarea codului jocului TicTacToe poate fi lăsată ca exercițiu pentru mai târziu.

Vas de război

Înainte de a arunca o privire asupra modului în care este implementat jocul, să aruncăm o privire la cum arată un bot standard pentru Battlescript:

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

Cam asta este. Fiecare bot este definit ca o funcție de constructor cu o singură metodă „play”. Metoda este invocată pentru fiecare tură cu un singur argument. Pentru orice joc, argumentul este un obiect cu o singură metodă care îi permite botului să facă mișcarea pentru turn și poate veni cu câteva atribute suplimentare reprezentând starea jocului.

După cum am menționat mai devreme, motorul a fost puțin modificat recent. Toată logica specifică Battleship a fost scoasă din codul motorului real. Deoarece motorul încă face greutăți, codul care definește jocul Battleship este foarte simplu și ușor.

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

Observați cum definim doar trei funcții de tip cârlig aici: init, play și turn. Fiecare funcție este invocată cu motorul ca context. Funcția „init” chiar pe măsură ce obiectul motor este instanțiat, din interiorul funcției de constructor. De obicei, aici ar trebui să pregătiți toate atributele de stare ale motorului. Un astfel de atribut care trebuie pregătit pentru fiecare joc este „grile” și (opțional) „piese”. Acesta ar trebui să fie întotdeauna o matrice cu două elemente, câte unul pentru fiecare jucător, reprezentând starea tablei de joc.

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

Al doilea cârlig, „play”, este invocat chiar înainte de începerea jocului. Acest lucru este util, deoarece ne oferă posibilitatea de a face lucruri precum plasarea pieselor de joc pe tablă în numele roboților.

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

Acest lucru poate părea puțin copleșitor la început, dar scopul pe care îl atinge această bucată de cod este simplu. Acesta generează matrice de piese, câte una pentru fiecare bot și le plasează pe grilele corespunzătoare într-un mod uniform. Pentru fiecare piesă, grila este scanată și fiecare poziție validă este stocată într-o matrice temporară. O poziție validă este acolo unde două piese nu se suprapun sau nu împart celule adiacente.

În cele din urmă, al treilea și ultimul cârlig „se întoarce”. Spre deosebire de celelalte două cârlige, acesta este puțin diferit. Scopul acestui cârlig este de a returna un obiect, pe care motorul îl folosește ca prim argument în invocarea metodei de redare a botului.

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

În cadrul acestei metode, începem prin a informa motorul că botul a făcut o mișcare cu succes. Un bot care nu reușește să efectueze o mișcare de atac pentru niciun joc la orice rând pierde automat jocul. Apoi, în cazul în care mutarea a lovit cu succes nava, determinăm dacă nava a fost distrusă complet. În cazul în care a fost, returnăm detaliile navei care a fost distrusă, altfel revenim „adevărat” pentru a indica o lovitură reușită fără nicio informație suplimentară.

De-a lungul acestor coduri, am întâlnit câteva atribute și nume de metode care sunt disponibile la „acest”. Acestea sunt furnizate de obiectul Engine și fiecare are câteva caracteristici comportamentale simple:

  • this.turn.called: acesta începe ca fals înainte de fiecare viraj și trebuie setat la adevărat pentru a informa motorul că botul a acționat pentru viraj.

  • this.turn.botNo: Acesta va fi fie 0, fie 1, în funcție de botul care a jucat acest turn.

  • this.end(botNo): Apelarea acestuia cu un număr de bot încheie jocul și marchează botul ca învingător. Apelarea cu -1 încheie jocul la egalitate.

  • this.track(botNo, isOkay, data, failReason): Aceasta este o metodă convenabilă care vă permite să înregistrați detaliile mișcării pentru bot sau motivul unei mișcări eșuate. În cele din urmă, aceste date înregistrate sunt folosite pentru a vizualiza simularea pe front-end.

În esență, acesta este tot ceea ce trebuie făcut pe back-end pentru a implementa un joc pe această platformă.

Jocuri de reluare

De îndată ce o simulare de luptă se termină, front-end-ul se redirecționează către pagina de reluare a jocului. Aici sunt vizualizate simularea și rezultatele și sunt afișate alte date legate de joc.

Această vedere este redată de back-end folosind „battle-view-battleships.jade” în „views/” cu toate detaliile bătăliei în context. Animația de re-play a jocului se realizează prin JavaScript front-end. Toate datele înregistrate prin metoda „trace()” a motorului sunt disponibile în contextul acestui șablon.

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

Ce urmează?

Acum că Battlescripts este open source, contribuțiile sunt binevenite. Platforma în stadiul actual este matură, dar are mult loc de îmbunătățire. Fie că este o caracteristică nouă, un patch de securitate sau chiar remedieri de erori, nu ezitați să creați o problemă în depozit prin care să solicitați soluționarea acesteia sau să transferați depozitul și să trimiteți o cerere de extragere. Și dacă acest lucru vă inspiră să construiți ceva complet nou, asigurați-vă că ne anunțați și lăsați un link către acesta în secțiunea de comentarii de mai jos!