介紹戰斗腳本:機器人、船舶、混亂!
已發表: 2022-03-11編程不必全是關於構建應用程序、滿足目標和滿足項目規範。 它也可以是關於享受樂趣,關於享受創造事物的過程。 許多人確實將這種技能的編程和開發視為一種娛樂形式。 在 Toptal,我們想在我們的社區中嘗試一些有趣的事情。 我們決定圍繞 Battleship 構建一個 bot-vs-bot 遊戲平台,該平台現已開源。
自內部首次推出以來,該平台已經吸引了我們社區中一些了不起的機器人製造商的注意。 看到社區成員之一 Toptal 工程師 Quan Le 甚至構建了一個工具來輕鬆調試 Battlescripts Bots,我們真的印象深刻。 該公告還引發了一些人創建自己的機器人對機器人引擎的興趣,以支持不同的遊戲類型和不同的規則。 從 Battlescripts 發布的那一刻起,驚人的想法就開始湧現。 今天,我們很高興將 Battlescripts 開源。 這為我們的社區和其他所有人提供了探索代碼、做出貢獻和/或分叉以完全利用它來製作其他東西的機會。
戰書剖析
Battlescripts 是使用一些非常簡單的組件構建的。 它在 Node.js 上運行,並使用一些最流行且實現良好的包,例如 Express、Mongoose 等。後端是純 JavaScript,以及前端腳本。 此應用程序僅有的兩個外部依賴項是 MongoDB 和 Redis。 用戶提交的機器人代碼使用 Node.js 附帶的“vm”模塊運行。 在生產中,Docker 用於增加安全性,但不是 Battlescripts 的硬依賴。
Battlescripts 的代碼在 BSD 3 條款許可下可在 GitHub 上獲得。 包含的 README.md 文件詳細說明瞭如何克隆存儲庫並在本地啟動應用程序。
網絡服務器
您會注意到該應用程序的結構類似於簡單的 Express.js Web 應用程序。 app.js 文件通過建立與數據庫的連接、註冊一些常見的中間件以及定義一些社交身份驗證策略來引導服務器。 此外,所有模型和路由都定義在“lib/”目錄中。 整個應用程序只需要幾個模型:Battle、Bot、Challenge、Contest、Party 和 User。 機器人之間的戰鬥是在 Web 服務器節點之外模擬的,並使用 Node.js 包 Kue 完成。 這使我們能夠將引擎與 Web 應用程序的其餘部分隔離開來,從而減少戰鬥模擬引擎干擾 Web 服務器的可能性,從而使 Web 應用程序本身更具響應性和穩定性。
機器人和引擎
由於預計這些機器人將在 JavaScript 中實現,而這正是我們使用 Node.js 在後端所擁有的,因此構建引擎更加容易。 在執行用戶提交的代碼時,最大的挑戰之一是確保代碼不會在服務器上做一些惡意的事情,或者任何有缺陷的代碼都不會干擾整個系統的穩定性。 Node.js 的標準庫附帶了這個令人驚嘆的模塊,它使這項任務的一部分變得非常容易。 引入“vm”模塊是為了讓 Node.js 開發人員更容易在單獨的上下文中運行不受信任的代碼。 儘管根據官方文檔,在單獨的進程中運行不受信任的代碼很重要——但這是我們在生產服務器上所做的事情。 在本地開發期間,“vm”模塊及其提供的功能可以正常工作。
執行 JavaScript
如果您想在單獨的上下文中在 Node.js 中運行一些任意 JavaScript 代碼,可以使用“vm”模塊,如下所示:
var vm = require('vm') var ctxObj = { result: '' } vm.runInNewContext(' result = “0xBEEF” ', ctxObj ) console.log(ctxObj); // { result: “0xBEEF” }
在這個“新上下文”中,您運行的代碼甚至無法訪問“console.log”,因為在那個上下文中不存在這樣的函數。 但是,您可以將原始上下文的“context.log”函數作為“ctxObj”的屬性傳遞給新上下文。
在 Battlescripts 中,模擬戰鬥的節點在單獨的 Node.js “vm”上下文中運行每個機器人。 引擎負責根據遊戲規則同步兩個機器人的上下文狀態。
在隔離的上下文中運行 JavaScript 代碼並不是這個模塊所做的全部。 “runInNewContext”函數接受一個對像作為第三個參數,它可以控制此代碼執行的三個附加方面:
- 要在與此執行相關的生成堆棧跟踪中使用的文件名。
- 是否將錯誤打印到標準錯誤。
- 在超時之前允許執行繼續的毫秒數。
這個“vm”模塊的缺陷之一是它沒有提供任何限制內存使用的方法。 通過使用 Docker 和引擎節點的運行方式,可以在服務器上解決該模塊的一些其他限制。 “vm”模塊在使用非常頻繁時會慢慢開始洩漏難以追踪和釋放的內存。 即使重複使用上下文對象,內存使用量也會不斷增長。 我們通過遵循一個簡單的策略解決了這個問題。 每次在工作節點中模擬戰鬥時,節點都會退出。 生產服務器上的主管程序然後重新啟動工作節點,然後在幾分之一秒內準備好處理下一場戰鬥模擬。
可擴展性
Battlescripts 最初是圍繞 Battleship 的標準規則設計的。 裡面的引擎不是很可擴展。 然而,在 Battlescripts 推出後,最常見的請求之一是引入更新的遊戲類型,因為該應用程序的用戶很快意識到有些遊戲比其他遊戲更容易用機器人征服。 例如,如果您將井字遊戲與國際象棋進行比較,前者的狀態空間要小得多,這使得機器人很容易提出一個解決方案,該解決方案可以贏得比賽,也可以平局結束比賽。
Battlescripts 引擎最近進行了一些修改,以便更容易引入新的遊戲類型。 這可以通過簡單地遵循帶有少量類似鉤子函數的構造來完成。 代碼庫中添加了一個額外的遊戲類型,井字遊戲,因為它更容易理解。 與此遊戲類型相關的所有內容都可以在“lib/games/tictactoe.js”文件中找到。
但是,在本文中,我們將看看 Battleship 遊戲類型的實現。 TicTacToe 遊戲代碼的探索可以留作以後的練習。
戰艦
在了解遊戲是如何實現的之前,讓我們先來看看 Battlescript 的標準機器人是什麼樣的:
function Bot() {} Bot.prototype.play = function(turn) { // ... }
差不多就是這樣。 每個機器人都被定義為一個帶有“play”方法的構造函數。 每一輪都使用一個參數調用該方法。 對於任何遊戲,參數都是具有一種方法的對象,該方法允許機器人在回合中移動,並且可以帶有一些表示遊戲狀態的附加屬性。
如前所述,該引擎最近進行了一些修改。 所有戰艦特定的邏輯都已從實際引擎代碼中提取出來。 由於引擎仍然負責繁重的工作,因此定義 Battleship 遊戲的代碼非常簡單且輕量級。
function Battleships(bot1, bot2) { return new Engine(bot1, bot2, { hooks: { init: function() { // ... }, play: function() { // ... }, turn: function() { // ... } } }) } module.exports = exports = Battleships
注意我們在這裡只定義了三個類似鉤子的函數:init、play 和 turn。 每個函數都以引擎作為其上下文來調用。 從構造函數中實例化引擎對象的“init”函數。 通常,您應該在此處準備引擎的所有狀態屬性。 必須為每場比賽準備的一個這樣的屬性是“網格”和(可選)“棋子”。 這應該始終是一個包含兩個元素的數組,每個玩家一個,代表遊戲板的狀態。
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([]) }
第二個鉤子“play”在遊戲開始前被調用。 這很有用,因為這讓我們有機會代表機器人在棋盤上放置遊戲棋子。
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 }) } } }
乍一看,這可能有點讓人不知所措,但這段代碼實現的目標很簡單。 它為每個機器人生成一個片段數組,並將它們以統一的方式放置在相應的網格上。 對於每一塊,都會掃描網格並將每個有效位置存儲在一個臨時數組中。 有效位置是兩個部分不重疊或不共享相鄰單元格的位置。

最後,第三個也是最後一個鉤子“轉”。 與其他兩個鉤子不同,這個鉤子有點不同。 這個鉤子的目的是返回一個對象,引擎在調用機器人的 play 方法時將其用作第一個參數。
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)) }
在此方法中,我們首先通知引擎機器人已成功移動。 在任何回合中未能為任何遊戲做出攻擊動作的機器人將自動放棄該遊戲。 接下來,如果移動成功擊中船,我們確定船是否已被完全摧毀。 如果是這樣,我們返回被摧毀的船的詳細信息,否則我們返回“true”以表示成功命中而沒有任何附加信息。
在這些代碼中,我們遇到了一些在“this”中可用的屬性和方法名稱。 這些由 Engine 對象提供,每個都有一些簡單的行為特徵:
this.turn.called:在每個回合之前都以 false 開始,並且必須設置為 true 以通知引擎機器人已針對回合採取行動。
this.turn.botNo:這將是 0 或 1,這取決於本回合中哪個機器人玩過。
this.end(botNo):使用機器人編號調用 this 結束遊戲,並將機器人標記為勝利。 用 -1 調用它以平局結束遊戲。
this.track(botNo, isOkay, data, failReason):這是一種方便的方法,可讓您記錄機器人的移動詳細信息,或移動失敗的原因。 最終,這些記錄的數據用於在前端可視化模擬。
從本質上講,這就是在該平台上實現遊戲所需在後端完成的所有工作。
重玩遊戲
一旦戰鬥模擬結束,前端就會將自身重定向到遊戲重播頁面。 這是可視化模擬和結果的地方,並顯示其他遊戲相關數據。
該視圖由後端使用“views/”中的“battle-view-battleships.jade”渲染,所有戰鬥細節都在上下文中。 遊戲的重播動畫是通過前端 JavaScript 完成的。 通過引擎的“trace()”方法記錄的所有數據都在該模板的上下文中可用。
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() })
接下來是什麼?
現在 Battlescripts 是開源的,歡迎貢獻。 現階段的平台已經成熟,但還有很大的改進空間。 無論是新功能、安全補丁,甚至是錯誤修復,您都可以隨意在存儲庫中創建問題以請求解決,或者分叉存儲庫並提交拉取請求。 如果這激勵您構建全新的東西,請務必讓我們知道並在下面的評論部分留下指向它的鏈接!