介绍战斗脚本:机器人、船舶、混乱!
已发表: 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 是开源的,欢迎贡献。 现阶段的平台已经成熟,但还有很大的改进空间。 无论是新功能、安全补丁,甚至是错误修复,您都可以随意在存储库中创建问题以请求解决,或者分叉存储库并提交拉取请求。 如果这激励您构建全新的东西,请务必让我们知道并在下面的评论部分留下指向它的链接!