src/battleship/game.js
/**
* @type {function}
*/
const chalk = require('chalk');
/**
* @type {parameters}
*/
const parameters = require('./parameters');
/**
* @type {Board}
*/
const Board = require('./board');
/**
*
* @type {Tile}
*/
const Tile = require('./tile');
/**
* @type {Utils}
*/
const Utils = require('./utils');
/**
* @type {parameters}
*/
const { numRows, numColumns, shipTypes, totalCount } = parameters;
/**
* @type {Utils}
*/
const { isRowValid, isColumnValid, getRandomShipPosition } = Utils;
/*
* constants for GameBanner
*/
/**
* @type {string}
*/
const fontColor = 'white';
/**
* @type {string}
*/
const bgColor = 'bgBlack';
/**
* @type {string}
*/
const bannerFontColor = 'black';
/**
* @type {string}
*/
const bannerBgColor = 'bgWhite';
/**
* @type {string}
*/
const dash = '-';
/**
* @type {string}
*/
const pipe = '|';
/**
* @type {string}
*/
const space = ' ';
/**
* @type {number}
*/
const width = numColumns * 3;
/**
* @type {string}
*/
const square = chalk`{${fontColor}.${bgColor} ${dash}}`;
/**
* @type {string}
*/
const edge = chalk`{${fontColor}.${bgColor} ${pipe}}`;
/**
* @type {string}
*/
const blank = chalk`{${bannerFontColor}.${bannerBgColor} ${space}}`;
/**
* @type {string}
*/
const border = square.repeat(width);
/**
* Controller class for the board to auto-play a game.
*/
class Game {
/**
* If no array of ships is provide, all necessary ships will be added to
* the board at random positions that do not overlap.
*
* @param {Object} obj
* @param {?[Ship]} obj.ships optional array of ships
* @param {?boolean} obj.toConsole print game to console
*/
constructor({ships = [], toConsole = false} = {}) {
/**
* @type {Board}
*/
this.board = new Board(toConsole);
this.board.setUp(ships);
}
/**
* Return random position on the board.
* @returns {{column: number, row: number}}
*/
static randomPosition() {
const row = Math.floor(numRows * Math.random())
const column = Math.floor(numColumns * Math.random())
return { row, column };
}
/**
* Return random position that has not been attacked.
*
* @returns {{column: number, row: number}}
*/
randomAvailablePosition() {
/**
* @type {number}
*/
let row;
/**
* @type {number}
*/
let column;
const { board } = this;
do {
({ row, column } = Game.randomPosition());
} while (board.isAttacked({ row, column }))
return { row, column };
}
/**
* Return array of positions that are neighbors to the given position and
* that have not been attacked.
*
* @param {Object} obj
* @param {number} obj.row
* @param {number} obj.column
* @returns {Array<{column: number, row: number}>}
*/
availableNeighbors({row, column}) {
const neighbors = [
{row: row - 1, column},
{row: row + 1, column},
{row, column: column - 1},
{row, column: column + 1}
];
const { board } = this;
const available = neighbors.reduce((acc, next) => {
const { row, column } = next;
if (isRowValid(row) && isColumnValid(column) && !board.isAttacked({ row, column })) {
acc.push({ row, column })
}
return acc;
}, []);
if (Game.logStack) {
const s = available.map(a => `(${a.row}, ${a.column})`).join(', ');
console.log(`availableNeighbors(${row}, ${column}): ${s}`);
}
return available;
}
/**
* Simulate game play using this strategy:
* * store positions to try next on a stack
* * if the stack is empty, push a random available position onto the stack
* * pop the next position from the stack
* * if the attack was a hit, push the next available neighbors onto the stack
* * store the last hit position
* * if there was a last hit, then filter the stack to keep positions in line with
* the last hit
*
* @returns {Moves}
*/
play() {
let stack = [];
let lastStack = [];
let current;
let numMoves = 0;
let lastHit;
while (!this.board.isWon()) {
if (!stack.length) {
if (lastStack.length) {
stack = lastStack;
} else {
/*
* if there is no stack, then get a random available position
*/
this.board.moves.setIsRandom(true);
stack.push(this.randomAvailablePosition())
}
}
if (Game.logStack) {
const s = stack.map(a => `(${a.row}, ${a.column})`).join(', ');
this.log(`stack: ${s}`);
}
current = stack.pop();
const {row, column} = current;
if (!this.board.isAttacked({row, column})) {
numMoves++;
const attackResult = this.board.attack(current);
if (attackResult === 'Hit') {
stack.push(...this.availableNeighbors(current));
if (lastHit) {
/*
* remove items from the stack that are not in a line with the last hit
* save the stack in case the filter does not end up in a ship being sunk
*/
lastStack = stack;
if (lastHit.row === row) {
stack = stack.filter(item => item.row === row);
} else {
stack = stack.filter(item => item.column === column);
}
}
if (Game.logStack) {
const s = stack.map(a => `(${a.row}, ${a.column})`).join(', ');
this.log(`filtered stack: ${s}`);
}
lastHit = current;
} else if (attackResult === 'Sunk') {
/*
* empty the stack if we just sunk a ship
*/
stack = [];
lastHit = null;
}
}
}
return this.board.moves;
}
/**
* Forwards call to Tile class to get the HTML style tag with class rules
* to display the board.
*
* @returns {string}
*/
static getHtmlStyleType() {
return Tile.getHtmlStyleTag();
}
/**
* Return string with banner for a given game number.
*
* @param {number} gameNumber
*/
static gameBanner(gameNumber) {
const heading = `Game #${gameNumber}`;
const banner = chalk`{${bannerFontColor}.${bannerBgColor} ${heading}}`;
const leadingSpace = Math.floor((width - heading.length - 2) / 2);
const trailingSpace = width - heading.length - leadingSpace - 2;
return '\n' + border + '\n' +
edge +
blank.repeat(leadingSpace) +
banner +
blank.repeat(trailingSpace) +
edge + '\n' +
border;
}
}
Game.logStack = false;
module.exports = Game;
/*
strategy:
stack of tiles to pick next
list of tiles with hits
n
nxn
n
if x is a hit, then put neighbors on stack
pop until stack is empty
attack if not was attacked
when stack is empty, push random pick on stack
*/