Представляем боевые сценарии: боты, корабли, беспредел!

Опубликовано: 2022-03-11

Программирование не обязательно должно сводиться к созданию приложений, достижению целей и удовлетворению спецификаций проекта. Это также может быть развлечение, удовольствие от процесса создания чего-либо. Многие люди относятся к программированию и развитию этого навыка как к форме отдыха. В Toptal мы хотели попробовать что-то интересное в нашем сообществе. Мы решили создать игровую платформу бот против бота на основе Battleship, которая теперь имеет открытый исходный код.

Представляем боевые сценарии: боты, корабли, беспредел!

С момента своего первого внутреннего запуска платформа привлекла внимание некоторых замечательных создателей ботов в нашем сообществе. Мы были очень впечатлены, увидев, что один из членов сообщества, инженер Toptal Куан Ле, даже создал инструмент для легкой отладки ботов Battlescripts. Объявление также вызвало у некоторых интерес к созданию собственных движков бот против бота, поддерживающих разные типы игр и разные правила. Удивительные идеи начали поступать с самого момента появления Battlescripts. Сегодня мы рады сделать Battlescripts открытым исходным кодом. Это дает нашему сообществу и всем остальным возможность изучить код, внести свой вклад и/или разветвить его, чтобы сделать из него что-то еще.

Анатомия боевых сценариев

Battlescripts построен с использованием нескольких очень простых компонентов. Он работает на Node.js и использует некоторые из самых популярных и хорошо реализованных пакетов, таких как Express, Mongoose и т. д. Серверная часть выполнена на чистом JavaScript, а также внешние сценарии. Единственными двумя внешними зависимостями этого приложения являются MongoDB и Redis. Пользовательский код для ботов запускается с помощью модуля «vm», который поставляется с Node.js. В рабочей среде Docker используется для дополнительной безопасности, но не является жесткой зависимостью от Battlescripts.

боевые сценарии

Код для Battlescripts доступен на GitHub под лицензией BSD с тремя пунктами. Включенный файл README.md содержит подробные инструкции о том, как клонировать репозиторий и запускать приложение локально.

Веб сервер

Вы заметите, что структура приложения аналогична структуре простых веб-приложений Express.js. Файл app.js загружает сервер, устанавливая соединение с базой данных, регистрируя некоторые распространенные промежуточные программы и определяя некоторые стратегии социальной аутентификации. Кроме того, все модели и маршруты определены в каталоге «lib/». Для всего приложения требуется всего несколько моделей: Battle, Bot, Challenge, Contest, Party и User. Сражения между ботами моделируются за пределами узлов веб-сервера и выполняются с помощью пакета Node.js Kue. Это позволяет нам изолировать движок от остальной части веб-приложения, снижая вероятность того, что движок моделирования боя будет мешать веб-серверам, сохраняя само веб-приложение более отзывчивым и стабильным.

Боты и движок

Поскольку ожидается, что боты будут реализованы на JavaScript, а это именно то, что у нас есть на нашем сервере с Node.js, было проще создать движок. Когда дело доходит до выполнения пользовательского кода, одна из самых больших проблем состоит в том, чтобы убедиться, что код не делает что-то злонамеренное на сервере или что какой-либо код с ошибками не влияет на стабильность всей системы. Стандартная библиотека Node.js поставляется с этим замечательным модулем, который делает часть этой задачи очень легкой. Модуль «vm» был введен для того, чтобы разработчикам Node.js было проще запускать ненадежный код в отдельном контексте. Хотя, согласно официальной документации, важно запускать ненадежный код в отдельном процессе, но это то, что мы делаем на рабочих серверах. Во время локальной разработки модуль «vm» и функции, которые он предлагает, работают нормально.

Заставьте ботов драться друг с другом, пока это еще законно!
Твитнуть

Выполнение JavaScript

Если вы хотите запустить произвольный код JavaScript в Node.js в отдельном контексте, вы можете использовать модуль «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 принимает объект в качестве третьего параметра, который может управлять тремя дополнительными аспектами выполнения этого кода:

  • Имя файла, которое будет использоваться в сгенерированных трассировках стека, относящихся к этому выполнению.
  • Печатать ли ошибки в stderr.
  • Количество миллисекунд, в течение которого выполнение может продолжаться до истечения времени его ожидания.

Одна из ловушек этого модуля «vm» заключается в том, что он не предоставляет никаких средств ограничения использования памяти. Это, наряду с некоторыми другими ограничениями модуля, обходится на сервере за счет использования Docker и способа запуска узлов движка. Модуль «vm» при очень частом использовании медленно начинает утекать память, которую трудно отследить и освободить. Даже если объекты контекста используются повторно, использование памяти продолжает расти. Мы решили эту проблему, следуя простой стратегии. Каждый раз, когда на рабочем узле моделируется битва, узел выходит. Затем программа супервизора на производственном сервере перезапускает рабочий узел, который за доли секунды становится готовым к следующей симуляции боя.

Расширяемость

Изначально Battlescripts разрабатывался по стандартным правилам Battleship. Двигатель внутри был не очень растяжимым. Однако после запуска Battlescripts одним из самых частых запросов было введение новых типов игр, так как пользователи приложения быстро поняли, что одни игры легче победить с помощью ботов, чем другие. Например, если вы сравните TicTacToe с Chess, первый имеет гораздо меньшее пространство состояний, что позволяет ботам очень легко найти решение, которое либо выиграет, либо завершит игру вничью.

Движок Battlescripts был недавно немного изменен, чтобы облегчить введение новых типов игр. Это можно сделать, просто следуя конструкции с несколькими функциями, подобными крючкам. В кодовую базу был добавлен дополнительный тип игры, TicTacToe, поскольку за ним легче следить. Все, что относится к этому типу игр, можно найти в файле «lib/games/tictactoe.js».

Однако в этой статье мы рассмотрим реализацию типа игры Морской бой. Изучение кода игры TicTacToe можно оставить в качестве упражнения на потом.

Линкор

Перед тем, как посмотреть, как реализована игра, давайте взглянем на то, как выглядит стандартный бот для Battlescript:

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

Вот и все. Каждый бот определяется как функция-конструктор с одним методом «play». Метод вызывается для каждого хода с одним аргументом. В любой игре аргумент — это объект с одним методом, который позволяет боту делать ход за ход, и может иметь некоторые дополнительные атрибуты, представляющие состояние игры.

Как уже упоминалось ранее, двигатель был немного изменен в последнее время. Вся специфичная для линкора логика была извлечена из фактического кода движка. Поскольку движок по-прежнему выполняет тяжелую работу, код, определяющий игру Морской бой, очень прост и легок.

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

Поначалу это может показаться немного ошеломляющим, но цель, которой достигает этот фрагмент кода, проста. Он генерирует массивы частей, по одной для каждого бота, и размещает их в соответствующих сетках единым образом. Для каждой части сетка сканируется, и каждая допустимая позиция сохраняется во временном массиве. Допустимое положение — это когда две части не перекрываются и не делят соседние ячейки.

Наконец, третий и последний крючок «поворачивают». В отличие от двух других крючков, этот немного отличается. Цель этого хука — вернуть объект, который движок использует в качестве первого аргумента при вызове метода воспроизведения бота.

 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.call: Перед каждым ходом это значение начинается со значения false, и для него необходимо установить значение true, чтобы движок информировался о том, что бот действовал в течение хода.

  • this.turn.botNo: это будет либо 0, либо 1, в зависимости от того, какой бот сыграл этот ход.

  • this.end(botNo): вызов this с номером бота завершает игру и отмечает бота как победителя. Вызов с -1 заканчивает игру вничью.

  • this.track(botNo, isOkay, data, failReason): это удобный метод, который позволяет записывать сведения о перемещении бота или причину неудачного перемещения. В конце концов, эти записанные данные используются для визуализации симуляции во внешнем интерфейсе.

По сути, это все, что нужно сделать на бэкенде, чтобы реализовать игру на этой платформе.

Воспроизведение игр

Как только симуляция боя заканчивается, интерфейс перенаправляется на страницу повтора игры. Здесь визуализируются симуляция и результаты, а также отображаются другие данные, связанные с игрой.

Это представление отображается серверной частью с использованием «battle-view-battleships.jade» в «views/» со всеми деталями битвы в контексте. Анимация воспроизведения игры выполняется с помощью внешнего интерфейса 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 открыт, мы приветствуем участие. Платформа на нынешнем этапе является зрелой, но имеет много возможностей для улучшений. Будь то новая функция, исправление безопасности или даже исправление ошибок, не стесняйтесь создавать проблему в репозитории с запросом на ее решение, или разветвите репозиторий и отправьте запрос на вытягивание. И если это вдохновит вас на создание чего-то совершенно нового, обязательно сообщите нам об этом и оставьте ссылку на него в разделе комментариев ниже!