diff --git a/src/module/errors.js b/src/module/errors.js new file mode 100644 index 0000000..3db0685 --- /dev/null +++ b/src/module/errors.js @@ -0,0 +1,19 @@ +/** + * Placeholder class for an error that is thrown when a ship is placed + * in an invalid position. + * @extends Error + */ +export class InvalidShipPlacementError extends Error {} + +/** + * Error thrown when the program tries to advance the state, but it is + * invalid. + * @extends Error + */ +export class InvalidAdvanceStateError extends Error {} + +/** + * Error thrown when a missile is fired at an invalid cell. + * @extends Error + */ +export class InvalidMissileFireAttemptError extends Error {} diff --git a/src/module/util.js b/src/module/util.js index 487e788..cf2d52e 100644 --- a/src/module/util.js +++ b/src/module/util.js @@ -22,6 +22,31 @@ export const GridCellState = { Missed: 'missed', } +/** + * Returns true if the given grid cell state represents a ship in some way. + * @param {GridCellState} grid_cell_state + * @return {boolean} + */ +export function isShipCell(grid_cell_state) { + return [ + GridCellState.Ship, + GridCellState.Damaged, + GridCellState.Sunk, + ].includes(grid_cell_state) +} + +/** + * Returns true if the given grid cell state can be fired upon. + * @param {GridCellState} grid_cell_state + * @return {boolean} + */ +export function isValidTargetCell(grid_cell_state) { + return [ + GridCellState.Ship, + GridCellState.Available, + ].includes(grid_cell_state) +} + /** * Enum of all possible players. * @type {object} @@ -52,6 +77,22 @@ export const GameState = { PlayerVictory: 'player_victory', } +/** + * The various supported ship types, by dimension. + * @type {object} + */ +export const ShipType = { + x1: '1x1', + x2: '1x2', + x3: '1x3', + x4: '1x4', + x5: '1x5', +} + +export function isShipType(type) { + return ['1x1', '1x2', '1x3', '1x4', '1x5'].includes(type) +} + /** * Makes a deep copy of the value passed in. * @param {*} obj diff --git a/src/services/GameState.service.js b/src/services/GameState.service.js index 5195d67..572a635 100644 --- a/src/services/GameState.service.js +++ b/src/services/GameState.service.js @@ -1,4 +1,5 @@ -import { Player, GridCellState, GameState, clone } from '../module/util.js' +import { Player, GridCellState, GameState, clone, ShipType, isShipType, isShipCell, isValidTargetCell } from '../module/util.js' +import { InvalidShipPlacementError, InvalidAdvanceStateError, InvalidMissileFireAttemptError } from '../module/errors.js' /** * Singleton service for managing the state of the game. @@ -11,6 +12,13 @@ export class GameStateService { */ player_x_game_board = {} + /** + * A mapping of player => ship definitions. + * @private + * @type {object} + */ + player_x_ships = {} + /** * An array of all players. This is mostly for internal purposes. * @private @@ -32,6 +40,34 @@ export class GameStateService { */ n_cols = 9 + /** + * Number boats placed on the board. + * @private + * @type {number} + */ + n_boats = 1 + + /** + * gets the number of boats placed on the board + * @private + * @return {number} + */ + get_n_boats(){ + return (this.n_boats); + } + + /** + * sets the number of boats to a valid number + * @private + * @return none + */ + set_n_boats(number){ + if(number >= 1 && number <= 5 ) + { + this.n_boats = number; + } + } + /** * The current state of the game. * @private @@ -60,6 +96,7 @@ export class GameStateService { // Generate empty boards for each player for ( const player of this.players ) { this.player_x_game_board[player] = this._build_empty_board() + this.player_x_ships[player] = [] } } @@ -124,6 +161,326 @@ export class GameStateService { }) } + /** + * get the "score" (the number of hits) that the + * current player has (counting sunk ships) + * @return {number} + * @private + */ + get_player_score () { + let i = 1; + let j = 1; + let score = 0; + for(i; i<=9; i++) + { + for(j; j<=9; j++) + { + let cell = this.player_x_game_board[this.current_opponent][i][j]; + if(cell.render === GridCellState.Damaged || cell.render === GridCellState.Sunk ) + { + score++; + } + } + } + return(score); + } + + /** + * gets the number of the boats (sunken, damaged or not) that the opponent has + * used to help keep get progress method looking clean + * @return {number} + * @private + */ + get_boat_count(){ + let i = 1; + let j = 1; + let boat_count = 0; + for(i; i<=9; i++) + { + for(j; j<=9; j++) + { + let cell = this.player_x_game_board[this.current_opponent][i][j]; + if(cell.render === GridCellState.Damaged || cell.render === GridCellState.Sunk || cell.render === GridCellState.Ship ) + { + boat_count++; + } + } + } + return(boat_count); + } + + /** + * gets the progress (hits/total boats) that the player has + * @return {number} + * @private + */ + get_progress(){ + return(this.get_player_score() / this.get_progress() ) + } + + + /** + * responsible for advancing the game state + * will be consisting of + * @return + * @private + */ + advance_game_state() { + /** functions to be made that validate: + * 1) number of ships + * 2) player one placement + * 3) player two placement + * 4) player one turn + * 5) advance to player 2 + * 6) player 2 turn + * 7) advance to player one + * 8) player win + * + */ + //1 + if (this.current_state === GameState.ChoosingNumberOfShips) { + if (this.n_boats >= 1 && this.n_boats <= 5) { + this.current_state = GameState.PlayerSetup; + this.current_player = Player.One; + this.current_opponent = Player.Two; + } else { + throw new InvalidAdvanceStateError("Invalid Number of Boats"); + } + + } + if (this.current_state === GameState.PlayerSetup) { + if (this.current_player === Player.One) { + //wait + // because the place_ship handles all the validation + // all you need to do is make sure they have placed all the appropriate ships + // e.g. if ( this.get_ship_entities(this.current_player).length === this.n_boats ) { ... } + } + if (this.current_player === Player.Two) { + //wait for now + } + } + } + + /** + * Attempt to fire a missile at the current opponent at the given coordinates. + * The coordinates should be an array of [row_index, column_index] where the missile should fire. + * Returns true if the missile hit an undamaged cell of a ship. + * + * @example + * If I want to fire a missile at row 5 column 7, then: + * game_service.attempt_missile_fire([5, 7]) + * + * @param {[number, number]} coords + * @return {boolean} + */ + attempt_missile_fire([target_row_i, target_col_i]) { + const target_cell = this._get_cell_state(this.current_opponent, target_row_i, target_col_i) + if ( !isValidTargetCell(target_cell.render) ) + throw new InvalidMissileFireAttemptError('Cannot fire on cell with state: ' + target_cell.render) + + if ( target_cell.render === GridCellState.Ship ) { + // We hit an un-hit ship cell! + this._set_cell_state(this.current_opponent, target_row_i, target_col_i, GridCellState.Damaged) + + // set ships to sunk where appropriate + this._sink_damaged_ships(this.current_opponent) + return true + } else if ( target_cell.render === GridCellState.Available ) { + // We missed... + this._set_cell_state(this.current_opponent, target_row_i, target_col_i, GridCellState.Missed) + } + + return false + } + + /** + * Checks the player's ships. If any are fully damaged, it flags that ship's cells + * as "sunk" rather than damaged. + * @param {Player} player + * @private + */ + _sink_damaged_ships(player) { + this.get_ship_entities(player).some(ship => { + const covered_cells = this.get_covered_cells(ship.coords_one, ship.coords_two) + const all_damaged = covered_cells.every(([cell_row, cell_col]) => { + return this._get_cell_state(player, cell_row, cell_col).render === GridCellState.Damaged + }) + + if ( all_damaged ) { + // The entire boat was damaged, so sink it + covered_cells.some(([cell_row, cell_col]) => { + this._set_cell_state(player, cell_row, cell_col, GridCellState.Sunk) + }) + } + }) + } + + /** + * Attempt to place a ship of the given type at the given coordinates. + * Throws an InvalidShipPlacementError if the coordinates are invalid. + * Coordinates should be [row_index, column_index] of either end of the ship. + * + * @example + * If I am placing a 1x3 ship and I want it to be in row 3 column 2 horizontal + * to row 3 column 4, then I would call: + * game_service.place_ship(ShipType.x3, [3,2], [3,4]) + * + * @param {ShipType} ship_type + * @param {[number, number]} coords_one + * @param {[number, number]} coords_two + */ + place_ship(ship_type, coords_one, coords_two) { + // make sure the coordinates are valid for the given ship type + this.validate_coordinates(ship_type, coords_one, coords_two) + + // get the ships for the current player + const player_ships = this.get_ship_entities(this.current_player) + + // make sure they don't already have this ship type + const have_ship_type = player_ships.some(ship => ship.ship_type === ship_type) + if ( have_ship_type ) + throw new InvalidShipPlacementError('A ship with this type has already been placed.') + + // make sure they don't already have too many + if ( player_ships.length >= this.n_boats ) + throw new InvalidShipPlacementError('This player cannot place any more ships.') + + // place the ship + this.player_x_ships[this.current_player].push({ ship_type, coords_one, coords_two }) + + // mark the cells as having a ship in them + this.get_covered_cells(coords_one, coords_two).some(([row_i, col_i]) => { + this._set_cell_state(this.current_player, row_i, col_i, GridCellState.Ship) + }) + } + + /** + * Get an array of cell coordinates that are covered by a ship that spans + * the given coordinates. + * + * @example + * If a ship goes from row 1 column 1 to row 4 column 1, then I can get + * the coordinates of all cells covered by that ship using: + * game_service.get_covered_cells([1,1], [4,1]) + * Which would return [[1,1], [2,1], [3,1], [4,1]]. + * + * @param {[number, number]} coords_one + * @param {[number, number]} coords_two + * @return {[number, number][]} + */ + get_covered_cells(coords_one, coords_two) { + const [row_one, col_one] = coords_one + const [row_two, col_two] = coords_two + const [left_col, right_col] = [Math.min(col_one, col_two), Math.max(col_one, col_two)] + const [top_row, bottom_row] = [Math.min(row_one, row_two), Math.max(row_one, row_two)] + const is_horizontal = top_row === bottom_row + + if ( is_horizontal ) { + return Array((right_col - left_col) + 1).fill('').map((_, i) => { + return [top_row, i + left_col] + }) + } else { + return Array((bottom_row - top_row) + 1).fill('').map((_, i) => { + return [i + top_row, left_col] + }) + } + } + + /** + * Validate the given coordinates for the given ship type. + * Throws an InvalidShipPlacementError if the coordinates are invalid. + * Coordinates should be [row_index, column_index] of either end of the ship. + * + * @example + * If I am placing a 1x3 ship and I want it to be in row 3 column 2 horizontal + * to row 3 column 4, then I would call: + * game_service.validate_coordinates(ShipType.x3, [3,2], [3,4]) + * + * @param {ShipType} ship_type + * @param {[number, number]} coords_one + * @param {[number, number]} coords_two + */ + validate_coordinates(ship_type, coords_one, coords_two) { + if ( !isShipType(ship_type) ) throw new InvalidShipPlacementError('Invalid ship type: '+ship_type) + + const ship_length = this.get_ship_length(ship_type) + const [row_one, col_one] = coords_one + const [row_two, col_two] = coords_two + const [left_col, right_col] = [Math.min(col_one, col_two), Math.max(col_one, col_two)] + const [top_row, bottom_row] = [Math.min(row_one, row_two), Math.max(row_one, row_two)] + + const is_horizontal = top_row === bottom_row + const ship_cells = this.get_ship_cells(this.current_player) + const placement_cells = [] + + if ( is_horizontal ) { + // Make sure the input length matches the given ship type + if ( (right_col - left_col) !== (ship_length - 1) ) + throw new InvalidShipPlacementError('Invalid coordinates: ship length is invalid') + + Array(ship_length).fill('').map((_, i) => { + placement_cells.push([top_row, i + left_col]) + }) + } else { + // Make sure the input length matches the given ship type + if ( (bottom_row - top_row) !== (ship_length - 1) ) + throw new InvalidShipPlacementError('Invalid coordinates: ship length is invalid') + + Array(ship_length).fill('').map((_, i) => { + placement_cells.push([i + top_row, left_col]) + }) + } + + // Make sure none of the placement cells overlap with existing ships + const has_overlap = ship_cells.some(([ship_row, ship_col]) => { + return placement_cells.some(([placement_row, placement_col]) => { + return ship_row === placement_row && ship_col === placement_col + }) + }) + + if ( has_overlap ) + throw new InvalidShipPlacementError('Invalid coordinates: ship overlaps with others') + } + + /** + * Get the number of cells the given ship type should occupy. + * @param {ShipType} ship_type + * @return {number} + */ + get_ship_length(ship_type) { + if ( ship_type === ShipType.x1 ) return 1 + if ( ship_type === ShipType.x2 ) return 2 + if ( ship_type === ShipType.x3 ) return 3 + if ( ship_type === ShipType.x4 ) return 4 + if ( ship_type === ShipType.x5 ) return 5 + } + + /** + * Get the coordinates of all cells that have ships in them, for the given player. + * @param {Player} player + * @return {[number, number]} + */ + get_ship_cells(player) { + const cells = [] + this.player_x_game_board[player].some((row, row_i) => { + row.some((col, col_i) => { + if ( isShipCell(col.render) ) { + cells.push([row_i, col_i]) + } + }) + }) + return cells + } + + /** + * Get an array of ship entities for the given player. + * @param {Player} player + * @return {object[]} + */ + get_ship_entities(player) { + return clone(this.player_x_ships[player]) + } + /** * Build an empty structure of grid cells. * @return {object[][]} @@ -138,6 +495,31 @@ export class GameStateService { }) }) } + + /** + * Set the state of the cell at the given coordinates on the player's board + * to the specified state. + * @param {Player} player + * @param {number} row_i + * @param {number} col_i + * @param {GridCellState} state + * @private + */ + _set_cell_state(player, row_i, col_i, state) { + this.player_x_game_board[player][row_i][col_i].render = state + } + + /** + * Get the state of the cell at the given coordinates on the player's board. + * @param {Player} player + * @param {number} row_i + * @param {number} col_i + * @return {object} + * @private + */ + _get_cell_state(player, row_i, col_i) { + return this.player_x_game_board[player][row_i][col_i] + } } // Export a single instance, so it can be shared by all files