ขอแนะนำ Battlescripts: Bots, Ships, Mayhem!
เผยแพร่แล้ว: 2022-03-11การเขียนโปรแกรมไม่จำเป็นต้องเกี่ยวกับการสร้างแอปพลิเคชัน การบรรลุเป้าหมาย และการปฏิบัติตามข้อกำหนดของโครงการเท่านั้น นอกจากนี้ยังสามารถเกี่ยวกับการสนุกสนานเกี่ยวกับการเพลิดเพลินกับกระบวนการสร้างบางสิ่งบางอย่าง หลายคนมองว่าการเขียนโปรแกรมและการพัฒนาทักษะนี้เป็นรูปแบบหนึ่งของการพักผ่อน ที่ Toptal เราต้องการลองสิ่งที่น่าสนใจภายในชุมชนของเรา เราตัดสินใจสร้างแพลตฟอร์มเกม bot-vs-bot รอบ Battleship ซึ่งตอนนี้เป็นโอเพ่นซอร์ส
นับตั้งแต่เปิดตัวภายในครั้งแรก แพลตฟอร์มนี้ได้รับความสนใจจากผู้สร้างบอทที่น่าทึ่งในชุมชนของเรา เราประทับใจมากที่เห็นว่าหนึ่งในสมาชิกของชุมชน วิศวกร Toptal Quan Le ได้สร้างเครื่องมือในการดีบัก Battlescripts Bots อย่างง่ายดาย การประกาศดังกล่าวยังจุดประกายความสนใจในหมู่คนสองสามคนในการสร้างเอ็นจิ้น bot-vs-bot ของตนเอง โดยรองรับเกมประเภทต่างๆ และกฎที่แตกต่างกัน ไอเดียอันน่าทึ่งเริ่มหลั่งไหลเข้ามาตั้งแต่เปิดตัว Battlescripts วันนี้ เรายินดีที่จะทำให้ Battlescripts เป็นโอเพ่นซอร์ส สิ่งนี้ทำให้ชุมชนของเราและคนอื่นๆ มีโอกาสสำรวจโค้ด มีส่วนร่วม และ/หรือแยกส่วนเพื่อสร้างอย่างอื่นทั้งหมด
กายวิภาคของ Battlescripts
Battlescripts สร้างขึ้นโดยใช้ส่วนประกอบที่เรียบง่ายมาก มันทำงานบน Node.js และใช้แพ็คเกจที่ได้รับความนิยมและใช้งานได้ดีที่สุด เช่น Express, Mongoose เป็นต้น ส่วนแบ็คเอนด์นั้นใช้ JavaScript ล้วนๆ เช่นเดียวกับสคริปต์ส่วนหน้า การพึ่งพาภายนอกเพียงสองรายการของแอปพลิเคชันนี้คือ MongoDB และ Redis ผู้ใช้ส่งรหัสสำหรับบอททำงานโดยใช้โมดูล “vm” ที่มาพร้อมกับ Node.js ในการผลิต Docker ถูกใช้เพื่อเพิ่มความปลอดภัย แต่ไม่ใช่การพึ่งพา Battlescripts อย่างจริงจัง
รหัสสำหรับ Battlescripts มีอยู่ใน GitHub ภายใต้ใบอนุญาต BSD 3 ข้อ ไฟล์ 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 โหนดที่จำลองการต่อสู้เรียกใช้แต่ละบอตภายใต้บริบท "vm" ของ Node.js ที่แยกจากกัน เอ็นจิ้นรับผิดชอบในการซิงค์สถานะของบริบทสำหรับบอททั้งสองตามกฎของเกม
การรันโค้ด JavaScript ในบริบทที่แยกออกมานั้นไม่ใช่สิ่งเดียวที่โมดูลนี้ทำ ฟังก์ชัน “runInNewContext” ยอมรับอ็อบเจกต์เป็นพารามิเตอร์ที่สาม ซึ่งสามารถควบคุมลักษณะเพิ่มเติมสามประการของการเรียกใช้โค้ดนี้:
- ชื่อไฟล์ที่จะใช้ในการติดตามสแต็กที่สร้างขึ้นที่เกี่ยวข้องกับการดำเนินการนี้
- จะพิมพ์ผิดพลาดไปที่ stderr หรือไม่
- จำนวนมิลลิวินาทีที่อนุญาตให้ดำเนินการต่อไปก่อนที่จะหมดเวลา
ข้อผิดพลาดประการหนึ่งของโมดูล "vm" นี้คือไม่มีวิธีการจำกัดการใช้หน่วยความจำ สิ่งนี้พร้อมกับข้อจำกัดอื่นๆ สองสามประการของโมดูลสามารถแก้ไขปัญหาบนเซิร์ฟเวอร์ผ่านการใช้ Docker และวิธีการทำงานของโหนดเครื่องยนต์ โมดูล “vm” เมื่อใช้บ่อยๆ อย่างช้าๆ จะเริ่มทำให้หน่วยความจำรั่ว ซึ่งยากต่อการติดตามและว่าง แม้ว่าวัตถุบริบทจะถูกนำกลับมาใช้ใหม่ การใช้หน่วยความจำก็ยังคงเพิ่มขึ้นเรื่อยๆ เราแก้ไขปัญหานี้โดยทำตามกลยุทธ์ง่ายๆ ทุกครั้งที่มีการจำลองการต่อสู้ในโหนดคนงาน โหนดจะออก โปรแกรมหัวหน้างานบนเซิร์ฟเวอร์ที่ใช้งานจริงจะรีสตาร์ทโหนดผู้ปฏิบัติงาน ซึ่งจากนั้นก็พร้อมที่จะจัดการกับการจำลองการรบครั้งต่อไปในเสี้ยววินาที
ความสามารถในการขยาย
เดิมที Battlescripts ได้รับการออกแบบตามกฎมาตรฐานของเรือประจัญบาน เครื่องยนต์ภายในไม่สามารถขยายได้มากนัก อย่างไรก็ตาม หลังจากเปิดตัว Battlescripts หนึ่งในคำขอที่พบบ่อยที่สุดคือการแนะนำประเภทเกมที่ใหม่กว่า เนื่องจากผู้ใช้แอปพลิเคชันตระหนักได้อย่างรวดเร็วว่าเกมบางเกมสามารถเอาชนะด้วยบอทได้ง่ายกว่าเกมอื่นๆ ตัวอย่างเช่น หากคุณเปรียบเทียบ TicTacToe กับ Chess แบบเดิมมีพื้นที่ของรัฐที่เล็กกว่ามาก ทำให้บ็อตสามารถคิดวิธีแก้ปัญหาได้ง่ายมากที่จะชนะหรือจบเกมด้วยผลเสมอกัน
เอ็นจิ้น Battlescripts เพิ่งได้รับการแก้ไขเล็กน้อยเพื่อให้แนะนำประเภทเกมใหม่ได้ง่ายขึ้น ซึ่งสามารถทำได้โดยเพียงแค่ทำตามโครงสร้างที่มีฟังก์ชันคล้ายเบ็ดจำนวนหนึ่ง เพิ่มประเภทเกมเพิ่มเติม TicTacToe ลงใน codebase เนื่องจากง่ายต่อการติดตาม ทุกอย่างที่เกี่ยวข้องกับเกมนี้สามารถพบได้ในไฟล์ “lib/games/tictactoe.js”
อย่างไรก็ตาม ในบทความนี้ เราจะมาดูการใช้งานประเภทเกมเรือประจัญบาน การสำรวจรหัสเกม TicTacToe สามารถทิ้งเป็นแบบฝึกหัดได้ในภายหลัง
เรือรบ
ก่อนที่เราจะดูวิธีการใช้งานเกม เรามาดูกันว่าบอทมาตรฐานสำหรับ Battlescript เป็นอย่างไร:
function Bot() {} Bot.prototype.play = function(turn) { // ... }
ที่มันสวยมาก บอททุกตัวถูกกำหนดให้เป็นฟังก์ชันคอนสตรัคเตอร์ด้วยวิธีการ "เล่น" เดียว เมธอดนี้เรียกใช้ทุกเทิร์นด้วยอาร์กิวเมนต์เดียว สำหรับเกมใดๆ อาร์กิวเมนต์เป็นวัตถุที่มีวิธีเดียวที่ช่วยให้บอทสามารถเคลื่อนที่ในเทิร์น และสามารถมาพร้อมกับคุณลักษณะเพิ่มเติมบางอย่างที่แสดงถึงสถานะของเกม
ดังที่ได้กล่าวไว้ก่อนหน้านี้ เครื่องยนต์ได้รับการปรับปรุงเล็กน้อยเมื่อเร็วๆ นี้ ตรรกะเฉพาะของเรือประจัญบานทั้งหมดถูกดึงออกจากรหัสเครื่องยนต์จริง เนื่องจากเครื่องยนต์ยังคงทำงานหนัก โค้ดที่กำหนดเกม Battleship นั้นเรียบง่ายและน้ำหนักเบามาก
function Battleships(bot1, bot2) { return new Engine(bot1, bot2, { hooks: { init: function() { // ... }, play: function() { // ... }, turn: function() { // ... } } }) } module.exports = exports = Battleships
สังเกตว่าเราแค่กำหนดฟังก์ชันแบบ hook-like สามแบบที่นี่: 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([]) }
เบ็ดที่สอง "เล่น" ถูกเรียกก่อนที่เกมจะเริ่ม สิ่งนี้มีประโยชน์ เนื่องจากทำให้เรามีโอกาสทำสิ่งต่างๆ เช่น วางชิ้นส่วนเกมบนกระดานในนามของบอท
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 }) } } }
นี้อาจดูล้นหลามเล็กน้อยในตอนแรก แต่เป้าหมายที่โค้ดชิ้นนี้บรรลุนั้นง่ายมาก มันสร้างอาร์เรย์ของชิ้นส่วน หนึ่งชิ้นสำหรับบอทแต่ละตัว และวางไว้บนกริดที่สอดคล้องกันในรูปแบบที่เหมือนกัน สำหรับทุกชิ้น ตารางจะถูกสแกนและทุกตำแหน่งที่ถูกต้องจะถูกเก็บไว้ในอาร์เรย์ชั่วคราว ตำแหน่งที่ถูกต้องคือส่วนที่สองส่วนไม่ทับซ้อนกันหรือแบ่งเซลล์ที่อยู่ติดกัน
ในที่สุดเบ็ดที่สามและครั้งสุดท้าย "เลี้ยว" ตะขอนี้ต่างจากตะขออีกสองอันอื่นเล็กน้อย จุดประสงค์ของ hook นี้คือส่งคืนวัตถุ ซึ่งเอ็นจิ้นใช้เป็นอาร์กิวเมนต์แรกในการเรียกใช้วิธีการเล่นของบอท
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)) }
ภายในวิธีนี้ เราเริ่มต้นด้วยการแจ้งเอ็นจิ้นว่าบอททำการย้ายสำเร็จแล้ว บอทที่ล้มเหลวในการโจมตีสำหรับเกมใด ๆ ในทุกเทิร์นจะเสียเกมโดยอัตโนมัติ ต่อไป ในกรณีที่เคลื่อนย้ายได้สำเร็จบนเรือรบ เราจะพิจารณาว่าเรือลำนั้นถูกทำลายโดยสมบูรณ์หรือไม่ ในกรณีดังกล่าว เราจะส่งคืนรายละเอียดของเรือรบที่ถูกทำลาย มิฉะนั้น เราจะคืนค่าเป็น "จริง" เพื่อระบุการจู่โจมที่สำเร็จโดยไม่มีข้อมูลเพิ่มเติม
ในโค้ดเหล่านี้ เราพบแอตทริบิวต์และชื่อเมธอดบางรายการที่มีให้ที่ "นี่" สิ่งเหล่านี้ถูกจัดเตรียมโดยอ็อบเจ็กต์ Engine และแต่ละอันมีลักษณะทางพฤติกรรมที่เรียบง่าย:
this.turn.call: สิ่งนี้เริ่มต้นเป็นเท็จก่อนทุกเทิร์น และต้องตั้งค่าเป็น true เพื่อแจ้งเครื่องยนต์ว่าบอทได้ดำเนินการในเทิร์นนั้น
this.turn.botNo: นี่อาจเป็น 0 หรือ 1 ขึ้นอยู่กับว่าบอทใดเล่นในเทิร์นนี้
this.end(botNo): การเรียกสิ่งนี้ด้วยหมายเลขบอทจะสิ้นสุดเกม และทำเครื่องหมายว่าบอทเป็นผู้ชนะ เรียกมันด้วย -1 จบเกมด้วยผลเสมอ
this.track(botNo, is Okay, data, failReason): นี่เป็นวิธีการอำนวยความสะดวกที่ให้คุณบันทึกรายละเอียดการย้ายสำหรับบอท หรือเหตุผลสำหรับการย้ายที่ล้มเหลว ในที่สุด ข้อมูลที่บันทึกไว้เหล่านี้จะถูกใช้เพื่อแสดงภาพการจำลองที่ส่วนหน้า
โดยพื้นฐานแล้ว นี่คือสิ่งที่ต้องทำในส่วนแบ็คเอนด์เพื่อใช้งานเกมบนแพลตฟอร์มนี้
เล่นเกมซ้ำ
ทันทีที่การจำลองการต่อสู้สิ้นสุดลง ส่วนหน้าจะเปลี่ยนเส้นทางไปยังหน้าเล่นซ้ำของเกม นี่คือที่ที่การจำลองและผลลัพธ์จะแสดงเป็นภาพ และข้อมูลอื่นๆ ที่เกี่ยวข้องกับเกมจะแสดงขึ้น
มุมมองนี้แสดงผลโดยแบ็คเอนด์โดยใช้ "battle-view-battleships.jade" ใน "views/" พร้อมรายละเอียดการต่อสู้ทั้งหมดในบริบท แอนิเมชั่นการเล่นซ้ำของเกมทำได้ผ่าน front-end 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 เป็นโอเพ่นซอร์สแล้ว ยินดีต้อนรับการร่วมให้ข้อมูล แพลตฟอร์มในขั้นปัจจุบันนั้นสมบูรณ์แล้ว แต่ยังมีพื้นที่สำหรับการปรับปรุงอีกมาก ไม่ว่าจะเป็นฟีเจอร์ใหม่ แพตช์ความปลอดภัย หรือแม้แต่การแก้ไขข้อผิดพลาด อย่าลังเลที่จะสร้างปัญหาในที่เก็บเพื่อขอให้แก้ไข หรือแยกที่เก็บแล้วส่งคำขอดึง และหากสิ่งนี้เป็นแรงบันดาลใจให้คุณสร้างสิ่งใหม่ทั้งหมด โปรดแจ้งให้เราทราบและทิ้งลิงก์ไว้ในส่วนความคิดเห็นด้านล่าง!