バトルスクリプトの紹介:ボット、船、騒乱!
公開: 2022-03-11プログラミングは、アプリケーションの構築、目標の達成、およびプロジェクトの仕様を満たすことだけである必要はありません。 それはまた、楽しむこと、何かを作成するプロセスを楽しむことでもあります。 多くの人々は、このスキルのプログラミングと開発をレクリエーションの一形態として扱っています。 Toptalでは、コミュニティ内で何か面白いことを試してみたかったのです。 戦艦を中心にボット対ボットのゲームプラットフォームを構築することを決定しました。これは現在オープンソースです。
社内での最初の立ち上げ以来、このプラットフォームは、コミュニティ内のいくつかの素晴らしいボットメーカーの注目を集めています。 コミュニティメンバーの1人であるToptalエンジニアのQuanLeが、Battlescriptsボットを簡単にデバッグするためのツールを作成したことに本当に感銘を受けました。 この発表はまた、さまざまなゲームタイプとさまざまなルールをサポートする独自のボット対ボットエンジンを作成することへの関心をいくつかの人々の間で引き起こしました。 Battlescriptsが発表された瞬間から素晴らしいアイデアが流れ始めました。 本日、Battlescriptsをオープンソースにすることができました。 これにより、私たちのコミュニティや他のすべての人が、コードを探索したり、貢献したり、フォークしてコードを完全に別のものにする機会が得られます。
バトルスクリプトの構造
Battlescriptsは、いくつかの非常に単純なコンポーネントを使用して構築されています。 Node.jsで実行され、Express、Mongooseなどの最も一般的で適切に実装されたパッケージのいくつかを使用します。バックエンドは純粋なJavaScriptと、フロントエンドスクリプトです。 このアプリケーションの外部依存関係は、MongoDBとRedisの2つだけです。 ユーザーが送信したボットのコードは、Node.jsに付属の「vm」モジュールを使用して実行されます。 本番環境では、安全性を高めるためにDockerが使用されますが、Battlescriptsに大きく依存することはありません。
Battlescriptsのコードは、BSD3条項ライセンスの下でGitHubから入手できます。 含まれているREADME.mdファイルには、リポジトリのクローンを作成してアプリケーションをローカルで起動する方法の詳細な手順が含まれています。
Webサーバー
このアプリケーションは、単純なExpress.jsWebアプリケーションと同様の構造になっていることがわかります。 app.jsファイルは、データベースへの接続を確立し、いくつかの一般的なミドルウェアを登録し、いくつかのソーシャル認証戦略を定義することにより、サーバーをブートストラップします。 さらに、すべてのモデルとルートは「lib/」ディレクトリ内で定義されています。 完全にアプリケーションに必要なモデルは、バトル、ボット、チャレンジ、コンテスト、パーティー、ユーザーです。 ボット間の戦闘は、Webサーバーノードの外部でシミュレートされ、Node.jsパッケージKueを使用して行われます。 これにより、エンジンを他のWebアプリケーションから分離できるため、戦闘シミュレーションエンジンがWebサーバーに干渉する可能性が低くなり、Webアプリケーション自体の応答性と安定性が向上します。
ボットとエンジン
ボットはJavaScriptで実装されることが期待されており、それがNode.jsのバックエンドにあるものとまったく同じであるため、エンジンの構築が簡単でした。 ユーザーが送信したコードの実行に関して、最大の課題の1つは、コードがサーバー上で悪意のあることを行わないようにすること、またはバグのあるコードがシステム全体の安定性を妨げないようにすることです。 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」にアクセスすることさえできません。これは、そのコンテキストではそのような関数が存在しないためです。 ただし、「ctxObj」の属性として渡すことにより、元のコンテキストの「context.log」関数を新しいコンテキストに公開することができます。
Battlescriptsでは、戦闘をシミュレートするノードは、個別のNode.js「vm」コンテキストで各ボットを実行します。 エンジンは、ゲームのルールに従って、両方のボットのコンテキストの状態を同期する責任を負います。
分離されたコンテキストでJavaScriptコードを実行することは、このモジュールが行うことのすべてではありません。 「runInNewContext」関数は、このコード実行の3つの追加の側面を制御できる3番目のパラメーターとしてオブジェクトを受け入れます。
- この実行に関連する生成されたスタックトレースで使用されるファイル名。
- エラーをstderrに出力するかどうか。
- タイムアウトする前に実行を続行できるようにするミリ秒数。
この「vm」モジュールの落とし穴の1つは、メモリ使用量を制限する手段を提供しないことです。 これは、モジュールの他のいくつかの制限とともに、Dockerの使用、およびエンジンノードの実行方法を通じてサーバー上で回避されます。 「vm」モジュールは、非常に頻繁に使用されると、追跡や解放が困難なメモリのリークをゆっくりと開始します。 コンテキストオブジェクトが再利用されても、メモリ使用量は増え続けます。 この問題は、簡単な戦略に従って解決しました。 ワーカーノードで戦闘がシミュレートされるたびに、ノードは終了します。 次に、実動サーバー上のスーパーバイザー・プログラムがワーカー・ノードを再始動します。ワーカー・ノードは、ほんの一瞬で次の戦闘シミュレーションを処理できるようになります。
拡張性
Battlescriptsは、もともと戦艦の標準ルールに基づいて設計されました。 内部のエンジンはあまり拡張可能ではありませんでした。 ただし、Battlescriptsがリリースされた後、最も一般的なリクエストの1つは、新しいゲームタイプを導入することでした。アプリケーションのユーザーは、一部のゲームが他のゲームよりもボットで征服しやすいことにすぐに気づきました。 たとえば、TicTacToeとChessを比較すると、前者の状態空間ははるかに小さいため、ボットはゲームに勝つか、引き分けでゲームを終了するソリューションを簡単に思い付くことができます。
Battlescriptsエンジンは最近少し変更され、新しいタイプのゲームを簡単に導入できるようになりました。 これは、いくつかのフックのような関数を備えた構成に従うだけで実行できます。 追加のゲームタイプであるTicTacToeが、フォローしやすいため、コードベースに追加されました。 このゲームタイプに関連するものはすべて、「lib / games/tictactoe.js」ファイル内にあります。
ただし、この記事では、戦艦ゲームタイプの実装について見ていきます。 TicTacToeゲームコードの探索は、後の演習として残すことができます。
戦艦
ゲームがどのように実装されているかを見る前に、Battlescriptの標準ボットがどのように見えるかを見てみましょう。
function Bot() {} Bot.prototype.play = function(turn) { // ... }
それはほとんどそれです。 すべてのボットは、1つのメソッド「play」を持つコンストラクター関数として定義されています。 このメソッドは、1つの引数で毎ターン呼び出されます。 どのゲームでも、引数はボットがそのターンに移動できるようにする1つのメソッドを持つオブジェクトであり、ゲームの状態を表すいくつかの追加の属性を付けることができます。
先に述べたように、エンジンは少し最近変更されました。 戦艦固有のロジックはすべて、実際のエンジンコードから抽出されています。 エンジンは依然として重労働を行うため、戦艦ゲームを定義するコードは非常にシンプルで軽量です。
function Battleships(bot1, bot2) { return new Engine(bot1, bot2, { hooks: { init: function() { // ... }, play: function() { // ... }, turn: function() { // ... } } }) } module.exports = exports = Battleships
ここでは、init、play、turnの3つのフックのような関数を定義しているだけであることに注意してください。 各関数は、エンジンをコンテキストとして使用して呼び出されます。 コンストラクター関数内から、エンジンオブジェクトがインスタンス化されるときの「init」関数。 通常、これはエンジンのすべての状態属性を準備する必要がある場所です。 すべてのゲームに備えなければならないそのような属性の1つは、「グリッド」と(オプションで)「ピース」です。 これは常に、ゲームボードの状態を表す2つの要素(各プレーヤーに1つ)を持つ配列である必要があります。
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([]) }
2番目のフック「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 }) } } }
これは最初は少し圧倒されるように見えるかもしれませんが、このコードが達成する目標は単純です。 ボットごとに1つずつ、ピースの配列を生成し、それらを対応するグリッドに均一に配置します。 すべてのピースについて、グリッドがスキャンされ、すべての有効な位置が一時的な配列に格納されます。 有効な位置は、2つのピースがオーバーラップしたり、隣接するセルを共有したりしない場所です。

最後に、3番目と最後のフックが「回転」します。 他の2つのフックとは異なり、これは少し異なります。 このフックの目的は、ボットの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):ボット番号を使用してこれを呼び出すと、ゲームが終了し、ボットに勝利のマークが付けられます。 -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がオープンソースになったので、貢献を歓迎します。 現在の段階のプラットフォームは成熟していますが、改善の余地がたくさんあります。 新機能、セキュリティパッチ、バグ修正など、リポジトリに問題を作成して対処をリクエストするか、リポジトリをフォークしてプルリクエストを送信してください。 そして、これがまったく新しいものを構築するようにあなたを刺激するなら、私たちに知らせて、下のコメントセクションにそれへのリンクを残してください!