Przedstawiamy skrypty bitewne: boty, statki, chaos!

Opublikowany: 2022-03-11

Programowanie nie musi polegać wyłącznie na budowaniu aplikacji, osiąganiu celów i spełnianiu specyfikacji projektu. Może też oznaczać dobrą zabawę, czerpanie radości z procesu tworzenia czegoś. Wiele osób traktuje programowanie i rozwijanie tej umiejętności jako formę rekreacji. W Toptal chcieliśmy wypróbować coś interesującego w naszej społeczności. Zdecydowaliśmy się zbudować platformę gier typu bot-vs-bot wokół Battleship, która jest teraz open-source.

Przedstawiamy skrypty bitewne: boty, statki, chaos!

Od czasu pierwszego wewnętrznego uruchomienia platforma przyciągnęła uwagę niektórych niesamowitych twórców botów w naszej społeczności. Byliśmy pod wrażeniem, że jeden z członków społeczności, inżynier z Toptal, Quan Le, zbudował nawet narzędzie do łatwego debugowania botów Battlescripts. Ogłoszenie wywołało również zainteresowanie wśród kilku osób stworzeniem własnych silników bot-vs-bot, obsługujących różne typy gier i różne zasady. Od momentu premiery Battlescripts zaczęły napływać niesamowite pomysły. Dzisiaj z przyjemnością udostępniamy Battlescripts jako open source. Daje to naszej społeczności i wszystkim innym możliwość poznania kodu, wniesienia wkładu i/lub rozwidlenia go, aby zrobić z niego coś zupełnie innego.

Anatomia skryptów bitewnych

Battlescripty są zbudowane z kilku bardzo prostych komponentów. Działa na Node.js i używa niektórych z najpopularniejszych i najlepiej zaimplementowanych pakietów, takich jak Express, Mongoose itp. Back-end jest w czystym JavaScript, a także skrypty front-endowe. Jedyne dwie zewnętrzne zależności tej aplikacji to MongoDB i Redis. Przesłany przez użytkownika kod dla botów jest uruchamiany za pomocą modułu „vm”, który jest dostarczany z Node.js. W produkcji Docker służy do zwiększenia bezpieczeństwa, ale nie jest twardą zależnością od Battlescriptów.

skrypty bitewne

Kod Battlescripts jest dostępny na GitHub na licencji BSD z trzema klauzulami. Dołączony plik README.md zawiera szczegółowe instrukcje, jak sklonować repozytorium i uruchomić aplikację lokalnie.

Serwer internetowy

Zauważysz, że aplikacja ma strukturę podobną do prostych aplikacji internetowych Express.js. Plik app.js uruchamia serwer, nawiązując połączenie z bazą danych, rejestrując niektóre typowe oprogramowanie pośredniczące i definiując niektóre strategie uwierzytelniania społecznościowego. Ponadto wszystkie modele i trasy są zdefiniowane w katalogu „lib/”. Cała aplikacja wymaga tylko kilku modeli: Battle, Bot, Challenge, Contest, Party i User. Bitwy między botami są symulowane poza węzłami serwerów WWW i odbywają się za pomocą pakietu Node.js Kue. Pozwala nam to odizolować silnik od reszty aplikacji internetowej, zmniejszając prawdopodobieństwo, że silnik symulacji bitwy będzie ingerował w serwery sieciowe, dzięki czemu sama aplikacja internetowa będzie bardziej responsywna i stabilna.

Boty i silnik

Ponieważ oczekuje się, że boty będą zaimplementowane w JavaScript, a dokładnie to mamy na naszym zapleczu z Node.js, łatwiej było zbudować silnik. Jeśli chodzi o wykonanie kodu przesłanego przez użytkownika, jednym z największych wyzwań jest upewnienie się, że kod nie robi niczego złośliwego na serwerze lub że jakikolwiek kod, który zawiera błędy, nie zakłóca stabilności całego systemu. Standardowa biblioteka Node.js zawiera ten niesamowity moduł, który bardzo ułatwia część tego zadania. Moduł „vm” został wprowadzony w celu ułatwienia programistom Node.js uruchamiania niezaufanego kodu w oddzielnym kontekście. Co prawda zgodnie z oficjalną dokumentacją ważne jest, aby niezaufany kod uruchamiać w osobnym procesie - ale to jest coś, co robimy na serwerach produkcyjnych. Podczas rozwoju lokalnego moduł „vm” i oferowane przez niego funkcje działają dobrze.

Spraw, by boty walczyły ze sobą, póki jest to legalne!
Ćwierkać

Wykonywanie JavaScript

Jeśli chcesz uruchomić dowolny kod JavaScript w Node.js w osobnym kontekście, możesz użyć modułu „vm” w następujący sposób:

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

W tym „nowym kontekście” uruchamiany kod nie uzyskuje nawet dostępu do „console.log”, ponieważ w tym kontekście taka funkcja nie istnieje. Można jednak udostępnić funkcję „context.log” oryginalnego kontekstu w nowym kontekście, przekazując ją jako atrybut „ctxObj”.

W Battlescripts węzły symulujące bitwy uruchamiają każdego bota w osobnych kontekstach Node.js „vm”. Silnik bierze na siebie odpowiedzialność za synchronizację stanu kontekstów dla obu botów zgodnie z regułami gry.

Uruchamianie kodu JavaScript w odizolowanym kontekście to nie wszystko, co robi ten moduł. Funkcja „runInNewContext” akceptuje obiekt jako trzeci parametr, który może kontrolować trzy dodatkowe aspekty wykonania tego kodu:

  • Nazwa pliku do użycia w wygenerowanych śladach stosu związanych z tym wykonaniem.
  • Czy drukować błędy na stderr, czy nie.
  • Liczba milisekund umożliwiających kontynuowanie wykonywania przed przekroczeniem limitu czasu.

Jedną z pułapek tego modułu „VM” jest to, że nie zapewnia on żadnych środków ograniczania zużycia pamięci. To, wraz z kilkoma innymi ograniczeniami modułu, można obejść na serwerze za pomocą Dockera i sposobu, w jaki działają węzły silnika. Moduł „vm”, gdy jest używany bardzo często, powoli zaczyna przeciekać pamięć, która jest trudna do wyśledzenia i uwolnienia. Nawet jeśli obiekty kontekstu są ponownie używane, zużycie pamięci wciąż rośnie. Rozwiązaliśmy ten problem, stosując prostą strategię. Za każdym razem, gdy symulowana jest bitwa w węźle roboczym, węzeł ten jest zamykany. Następnie program nadzorczy na serwerze produkcyjnym ponownie uruchamia węzeł roboczy, który jest gotowy do obsługi następnej symulacji bitwy w ułamku sekundy.

Rozciągliwość

Battlescripty zostały pierwotnie zaprojektowane zgodnie ze standardowymi zasadami pancernika. Silnik w środku nie był bardzo rozciągliwy. Jednak po uruchomieniu Battlescripts jednym z najczęstszych próśb było wprowadzenie nowszych typów gier, ponieważ użytkownicy aplikacji szybko zdali sobie sprawę, że niektóre gry są łatwiejsze do pokonania za pomocą botów niż inne. Na przykład, jeśli porównasz Kółko i Krzyżyk z szachami, ten pierwszy ma znacznie mniejszą przestrzeń stanu, co bardzo ułatwia botom wymyślenie rozwiązania, które albo wygra, albo zakończy partię remisem.

Silnik Battlescripts został ostatnio nieco zmodyfikowany, aby ułatwić wprowadzanie nowszych typów gier. Można to zrobić, po prostu podążając za konstrukcją z kilkoma funkcjami podobnymi do haka. Dodatkowy typ gry, TicTacToe, został dodany do bazy kodu, ponieważ jest łatwiejszy do śledzenia. Wszystko, co dotyczy tego typu gier, można znaleźć w pliku „lib/games/tictactoe.js”.

Jednak w tym artykule przyjrzymy się implementacji gry typu Battleship. Eksplorację kodu gry TicTacToe można pozostawić jako ćwiczenie na później.

Okręt wojenny

Zanim przyjrzymy się, jak zaimplementowano grę, przyjrzyjmy się, jak wygląda standardowy bot dla Battlescriptu:

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

To prawie wszystko. Każdy bot jest zdefiniowany jako funkcja konstruktora z jedną metodą „play”. Metoda jest wywoływana dla każdej tury z jednym argumentem. W każdej grze argumentem jest obiekt z jedną metodą, która pozwala botowi na wykonanie ruchu w turze i może mieć dodatkowe atrybuty reprezentujące stan gry.

Jak wspomniano wcześniej, silnik został niedawno nieco zmodyfikowany. Cała logika charakterystyczna dla Battleship została usunięta z rzeczywistego kodu silnika. Ponieważ silnik nadal wykonuje ciężkie zadania, kod, który definiuje grę Battleship, jest bardzo prosty i lekki.

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

Zauważ, że definiujemy tutaj tylko trzy funkcje przypominające hak: init, play i turn. Każda funkcja jest wywoływana z silnikiem jako kontekstem. Funkcja „init” zaraz po utworzeniu instancji obiektu silnika z poziomu funkcji konstruktora. Zazwyczaj w tym miejscu powinieneś przygotować wszystkie atrybuty stanu silnika. Jednym z takich atrybutów, które należy przygotować na każdą grę, są „siatki” i (opcjonalnie) „pionki”. Powinna to być zawsze tablica z dwoma elementami, po jednym dla każdego gracza, reprezentująca stan planszy.

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

Drugi hak, „play”, jest wywoływany tuż przed rozpoczęciem gry. Jest to przydatne, ponieważ daje nam to możliwość robienia takich rzeczy, jak umieszczanie elementów gry na planszy w imieniu botów.

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

Na początku może to wyglądać trochę przytłaczająco, ale cel, który osiąga ten fragment kodu, jest prosty. Generuje tablice elementów, po jednym dla każdego bota, i umieszcza je na odpowiednich siatkach w jednolity sposób. Dla każdego elementu siatka jest skanowana, a każda ważna pozycja jest przechowywana w tymczasowej tablicy. Prawidłowa pozycja to sytuacja, w której dwie części nie nakładają się ani nie dzielą sąsiadujących komórek.

Wreszcie trzeci i ostatni hak „obrót”. W przeciwieństwie do pozostałych dwóch haczyków, ten jest nieco inny. Celem tego haka jest zwrócenie obiektu, którego silnik używa jako pierwszego argumentu przy wywołaniu metody play bota.

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

W ramach tej metody zaczynamy od poinformowania silnika, że ​​bot z powodzeniem wykonał ruch. Bot, który nie wykona ataku w jakiejkolwiek grze w dowolnej turze, automatycznie przegrywa. Następnie, w przypadku pomyślnego trafienia ruchu w statek, ustalamy, czy statek został całkowicie zniszczony. W przypadku, gdy tak było, zwracamy szczegóły statku, który został zniszczony, w przeciwnym razie zwracamy „prawda”, aby wskazać udane trafienie bez żadnych dodatkowych informacji.

W tych kodach napotkaliśmy pewne atrybuty i nazwy metod, które są dostępne w „this”. Są one dostarczane przez obiekt Engine i każdy ma kilka prostych cech behawioralnych:

  • this.turn.lated: To zaczyna się jako false przed każdą turą i musi być ustawione na true, aby poinformować silnik, że bot zareagował na turę.

  • this.turn.botNo: Będzie to 0 lub 1, w zależności od tego, który bot grał w tej turze.

  • this.end(botNo): Wywołanie tego z numerem bota kończy grę i oznacza bota jako zwycięskiego. Sprawdzenie go z -1 kończy grę remisem.

  • this.track(botNo, isOkay, data, failReason): Jest to wygodna metoda, która pozwala rejestrować szczegóły ruchu dla bota lub powód nieudanego ruchu. Ostatecznie te zarejestrowane dane są wykorzystywane do wizualizacji symulacji w interfejsie użytkownika.

Zasadniczo to wszystko, co należy zrobić na zapleczu, aby zaimplementować grę na tej platformie.

Odtwarzanie gier

Gdy tylko symulacja bitwy się kończy, front-end przekierowuje się na stronę z powtórkami gry. W tym miejscu wizualizowana jest symulacja i wyniki oraz wyświetlane są inne dane związane z grą.

Ten widok jest renderowany przez zaplecze za pomocą pliku „battle-view-battleships.jade” w „widokach/” ze wszystkimi szczegółami bitwy w kontekście. Animacja ponownego odtwarzania gry odbywa się za pomocą front-endowego JavaScript. Wszystkie dane zarejestrowane za pomocą metody „trace()” silnika są dostępne w kontekście tego szablonu.

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

Co następne?

Teraz, gdy Battlescripts jest oprogramowaniem typu open source, wkład jest mile widziany. Platforma na obecnym etapie jest dojrzała, ale ma dużo miejsca na ulepszenia. Niezależnie od tego, czy jest to nowa funkcja, łatka bezpieczeństwa, czy nawet poprawki błędów, możesz swobodnie utworzyć problem w repozytorium, prosząc o jego rozwiązanie, lub rozwidlić repozytorium i przesłać żądanie ściągnięcia. A jeśli to zainspiruje Cię do zbudowania czegoś zupełnie nowego, daj nam znać i zostaw link do tego w sekcji komentarzy poniżej!