Battlescripts를 소개합니다: 봇, 선박, 신체 상해!

게시 됨: 2022-03-11

프로그래밍은 애플리케이션 구축, 목표 달성 및 프로젝트 사양 충족에 관한 전부일 필요는 없습니다. 그것은 또한 재미를 느끼는 것, 무언가를 만드는 과정을 즐기는 것에 대한 것일 수도 있습니다. 많은 사람들이 프로그래밍과 이 기술의 개발을 레크리에이션의 한 형태로 취급합니다. Toptal에서 우리는 커뮤니티 내에서 흥미로운 것을 시도하고 싶었습니다. 우리는 이제 오픈 소스인 Battleship을 중심으로 봇 대 봇 게임 플랫폼을 구축하기로 결정했습니다.

Battlescripts를 소개합니다: 봇, 선박, 신체 상해!

내부적으로 처음 출시된 이후로 이 플랫폼은 커뮤니티 내에서 놀라운 봇 제작자들의 관심을 끌었습니다. 커뮤니티 회원 중 한 명인 Toptal 엔지니어 Quan Le가 Battlescripts 봇을 쉽게 디버깅할 수 있는 도구까지 구축한 것을 보고 정말 감명받았습니다. 이 발표는 또한 다양한 게임 유형과 다양한 규칙을 지원하는 자체 봇 대 봇 엔진을 만드는 데 관심을 촉발했습니다. Battlescript가 공개된 순간부터 놀라운 아이디어가 쏟아져 나오기 시작했습니다. 오늘 Battlescripts를 오픈 소스로 만들게 된 것을 기쁘게 생각합니다. 이것은 우리 커뮤니티와 다른 모든 사람들에게 코드를 탐색하고, 기여하고, 코드를 분기하여 완전히 다른 것을 만들 수 있는 기회를 제공합니다.

배틀스크립트의 해부

Battlescripts는 몇 가지 매우 간단한 구성 요소를 사용하여 구축됩니다. Node.js에서 실행되며 Express, Mongoose 등과 같이 가장 인기 있고 잘 구현된 패키지를 사용합니다. 백엔드는 프론트엔드 스크립트뿐만 아니라 순수 JavaScript로 되어 있습니다. 이 애플리케이션의 유일한 두 가지 외부 종속성은 MongoDB와 Redis입니다. 봇용 사용자 제출 코드는 Node.js와 함께 제공되는 "vm" 모듈을 사용하여 실행됩니다. 프로덕션에서 Docker는 추가 안전을 위해 사용되지만 Battlescripts의 하드 종속성은 아닙니다.

배틀스크립트

Battlescripts용 코드는 BSD 3절 라이선스에 따라 GitHub에서 사용할 수 있습니다. 포함된 README.md 파일에는 리포지토리를 복제하고 애플리케이션을 로컬로 실행하는 방법에 대한 자세한 지침이 있습니다.

웹 서버

응용 프로그램이 간단한 Express.js 웹 응용 프로그램과 유사한 구조를 가지고 있음을 알 수 있습니다. app.js 파일은 데이터베이스에 대한 연결을 설정하고, 몇 가지 일반적인 미들웨어를 등록하고, 몇 가지 소셜 인증 전략을 정의하여 서버를 부트스트랩합니다. 또한 모든 모델과 경로는 "lib/" 디렉토리에 정의되어 있습니다. 전체 응용 프로그램에는 Battle, Bot, Challenge, Contest, Party 및 User와 같은 몇 가지 모델만 필요합니다. 봇 간의 전투는 웹 서버 노드 외부에서 시뮬레이션되며 Node.js 패키지 Kue를 사용하여 수행됩니다. 이를 통해 엔진을 나머지 웹 응용 프로그램과 분리할 수 있으므로 전투 시뮬레이션 엔진이 웹 서버를 방해할 가능성이 줄어들어 웹 응용 프로그램 자체의 응답성과 안정성이 향상됩니다.

봇 및 엔진

봇은 JavaScript로 구현되어야 하고 이것이 바로 Node.js를 사용하여 백엔드에 있는 것과 동일하기 때문에 엔진을 빌드하는 것이 더 쉬웠습니다. 사용자가 제출한 코드를 실행할 때 가장 큰 문제 중 하나는 코드가 서버에서 악의적인 작업을 수행하지 않거나 버그가 있는 코드가 전체 시스템의 안정성을 방해하지 않는지 확인하는 것입니다. Node.js의 표준 라이브러리는 이 작업의 일부를 매우 쉽게 만들어주는 이 놀라운 모듈과 함께 제공됩니다. Node.js 개발자가 신뢰할 수 없는 코드를 별도의 컨텍스트에서 더 쉽게 실행할 수 있도록 "vm" 모듈이 도입되었습니다. 공식 문서에 따르면 신뢰할 수 없는 코드를 별도의 프로세스에서 실행하는 것이 중요하지만 이는 프로덕션 서버에서 수행하는 작업입니다. 로컬 개발 중에 "vm" 모듈과 이 모듈이 제공하는 기능은 제대로 작동합니다.

봇이 서로 싸우게 하는 것이 여전히 합법입니다!
트위터

자바스크립트 실행

별도의 컨텍스트에서 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" 함수는 이 코드 실행의 세 가지 추가 측면을 제어할 수 있는 세 번째 매개 변수로 개체를 허용합니다.

  • 이 실행과 관련된 생성된 스택 추적에 사용할 파일 이름입니다.
  • 오류를 stderr에 인쇄할지 여부입니다.
  • 시간이 초과되기 전에 실행을 계속할 수 있는 시간(밀리초)입니다.

이 "vm" 모듈의 함정 중 하나는 메모리 사용을 제한하는 수단을 제공하지 않는다는 것입니다. 이것은 모듈의 몇 가지 다른 제한 사항과 함께 Docker를 사용하고 엔진 노드가 실행되는 방식을 통해 서버에서 해결됩니다. "vm" 모듈은 매우 자주 사용하면 추적하기 어렵고 해제하기 어려운 메모리 누수가 천천히 시작됩니다. 컨텍스트 객체를 재사용하더라도 메모리 사용량은 계속 증가합니다. 우리는 간단한 전략에 따라 이 문제를 해결했습니다. 작업자 노드에서 전투가 시뮬레이션될 때마다 노드가 종료됩니다. 그런 다음 프로덕션 서버의 수퍼바이저 프로그램은 작업자 노드를 다시 시작하고 1초 미만의 찰나의 순간에 다음 전투 시뮬레이션을 처리할 준비가 됩니다.

확장성

Battlescripts는 원래 Battleship의 표준 규칙을 중심으로 설계되었습니다. 내부의 엔진은 그다지 확장 가능하지 않았습니다. 그러나 Battlescripts가 출시된 후 가장 일반적인 요청 중 하나는 새로운 게임 유형을 도입하는 것이었습니다. 애플리케이션 사용자는 일부 게임이 다른 게임보다 봇으로 정복하기 더 쉽다는 것을 빠르게 깨달았습니다. 예를 들어 TicTacToe를 Chess와 비교하면 전자의 상태 공간이 훨씬 작아 봇이 게임을 무승부로 이기거나 끝내는 솔루션을 매우 쉽게 찾을 수 있습니다.

Battlescripts 엔진은 새로운 게임 유형을 더 쉽게 도입할 수 있도록 최근에 약간 수정되었습니다. 이것은 몇 가지 후크와 유사한 기능을 가진 구조를 따라가기만 하면 됩니다. 추가 게임 유형인 TicTacToe가 따라하기 쉽기 때문에 코드베이스에 추가되었습니다. 이 게임 유형과 관련된 모든 것은 "lib/games/tictactoe.js" 파일에서 찾을 수 있습니다.

그러나 이 기사에서는 Battleship 게임 유형의 구현을 살펴보겠습니다. TicTacToe 게임 코드 탐색은 나중에 연습으로 남길 수 있습니다.

전함

게임이 어떻게 구현되는지 살펴보기 전에 Battlescript의 표준 봇이 어떻게 생겼는지 살펴보겠습니다.

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

그 정도입니다. 모든 봇은 하나의 메소드 "play"를 사용하는 생성자 함수로 정의됩니다. 메서드는 하나의 인수로 매 턴마다 호출됩니다. 모든 게임에서 인수는 봇이 차례를 위해 이동할 수 있도록 하는 하나의 메서드가 있는 개체이며 게임 상태를 나타내는 몇 가지 추가 속성과 함께 제공될 수 있습니다.

앞서 언급했듯이 엔진은 최근에 약간 수정되었습니다. 모든 Battleship 관련 로직은 실제 엔진 코드에서 제외되었습니다. 엔진이 여전히 무거운 작업을 수행하기 때문에 Battleship 게임을 정의하는 코드는 매우 간단하고 가볍습니다.

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

여기에서 init, play, turn이라는 세 가지 후크와 같은 함수를 정의하는 방법에 주목하십시오. 각 함수는 엔진을 컨텍스트로 사용하여 호출됩니다. 생성자 함수 내에서 엔진 개체가 인스턴스화되는 것처럼 "초기화" 함수입니다. 일반적으로 여기에서 엔진의 모든 상태 속성을 준비해야 합니다. 모든 게임에 대해 준비해야 하는 이러한 속성 중 하나는 "그리드" 및 (선택 사항) "조각"입니다. 이것은 항상 게임 보드의 상태를 나타내는 각 플레이어에 대해 하나씩 두 개의 요소가 있는 배열이어야 합니다.

 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): 봇 번호로 이것을 호출하면 게임이 종료되고 봇이 승리한 것으로 표시됩니다. -1로 호출하면 게임이 무승부로 끝납니다.

  • this.track(botNo, isOkay, data, failReason): 봇의 이동 세부 정보 또는 이동 실패 이유를 기록할 수 있는 편리한 메서드입니다. 결국 이 기록된 데이터는 프런트 엔드에서 시뮬레이션을 시각화하는 데 사용됩니다.

기본적으로 이것이 이 플랫폼에서 게임을 구현하기 위해 백엔드에서 수행해야 하는 모든 것입니다.

게임 다시하기

전투 시뮬레이션이 종료되자마자 프론트엔드는 게임 리플레이 페이지로 리디렉션됩니다. 시뮬레이션 및 결과를 시각화하고 기타 게임 관련 데이터를 표시하는 곳입니다.

이 보기는 컨텍스트의 모든 전투 세부 정보와 함께 "views/"의 "battle-view-battleships.jade"를 사용하여 백엔드에서 렌더링됩니다. 게임의 리플레이 애니메이션은 프론트엔드 자바스크립트를 통해 이루어집니다. 엔진의 "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가 오픈 소스이므로 기여를 환영합니다. 현재 단계의 플랫폼은 성숙하지만 개선의 여지가 많습니다. 새로운 기능, 보안 패치 또는 버그 수정이든 상관없이 저장소에서 해결을 요청하는 문제를 자유롭게 생성하거나 저장소를 분기하고 풀 요청을 제출하십시오. 그리고 이것이 완전히 새로운 것을 구축하도록 영감을 준다면 저희에게 알려주고 아래의 댓글 섹션에 링크를 남겨주세요!