Source: services/GameState.service.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.
 */
export class GameStateService {
    /**
     * A mapping of player => game board state.
     * @private
     * @type {object}
     */
    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
     * @type {string[]}
     */
    players = [Player.One, Player.Two]

    /**
     * Number of rows in the grid.
     * @private
     * @type {number}
     */
    n_rows = 9

    /**
     * Number of cols in the grid.
     * @private
     * @type {number}
     */
    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;
        }
    }

    /**
     * Given the number of boats set by the player (n_boats), return an array
     * of possible ShipTypes.
     * @return {string[]}
     */
    get_possible_boats(){
        if (this.get_n_boats() === 1) {
            return [ShipType.x1]
        }
        else if (this.get_n_boats() === 2) {
            return [ShipType.x1, ShipType.x2]
        }
        else if (this.get_n_boats() === 3) {
            return [ShipType.x1, ShipType.x2, ShipType.x3]
        }
        else if (this.get_n_boats() === 4) {
            return [ShipType.x1, ShipType.x2, ShipType.x3, ShipType.x4]
        }
        else if (this.get_n_boats() === 5) {
            return [ShipType.x1, ShipType.x2, ShipType.x3, ShipType.x4, ShipType.x5]
        }
    }

    /**
     * The current state of the game.
     * @private
     * @type {string}
     */
    current_state = GameState.ChoosingNumberOfShips

    /**
     * The current player.
     * @private
     * @type {string}
     */
    current_player = Player.One

    /**
     * The current opponent.
     * @private
     * @type {string}
     */
    current_opponent = Player.Two

    /**
     * If the current state is the PromptPlayerChange, then this is
     * the state that we should move to next.
     * @type {undefined|string}
     */
    post_player_change_state = undefined

    /**
     * True if, during the current turn, the user has tried to fire a missile.
     * @type {boolean}
     */
    current_turn_had_missile_attempt = false

    /**
     * Array of functions that are called when the game state changes.
     * @type {function[]}
     */
    game_state_change_listeners = []

    /**
     * Construct a new game service. Initialize any internal states.
     */
    constructor() {
        // 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] = []
        }
    }

    /**
     * Get the dimensions of the board as [rows, cols].
     * @example const [n_rows, n_cols] = game_service.get_dimensions()
     * @return {number[]}
     */
    get_dimensions() {
        return [this.n_rows, this.n_cols]
    }

    /**
     * Get the player who is the focus of the current game state.
     * @return {string}
     */
    get_current_player() {
        return this.current_player
    }

    /**
     * Get the player who is NOT the focus of the current game state.
     * @return {string}
     */
    get_current_opponent() {
        return this.current_opponent
    }

    /**
     * Get the state of the current player's board, as it should appear to them.
     * @return {object[][]}
     */
    get_current_player_state() {
        // The player can see everything about their own board, so just return it.
        // Return a deep-copy, so internal state can't be modified.
        return clone(this.player_x_game_board[this.current_player])
    }

    /**
     * Get the state of the current opponent's board, as it should appear to the
     * current player. Note that the current player cannot see "ship" spaces, only
     * available, damaged, missed, or sunk.
     * @return {object[][]}
     */
    get_current_opponent_state() {
        // Return a deep-copy, so internal state can't be modified.
        const state = clone(this.player_x_game_board[this.current_opponent])
        const hidden_states = [
            GridCellState.Disabled,
            GridCellState.Ship,
        ]

        return state.map(row => {
            return row.map(cell => {
                if ( hidden_states.includes(cell.render) ) {
                    // This is a hidden state, so hide it
                    cell.render = GridCellState.Available
                }

                return cell
            })
        })
    }

    /**
     * Get the states that should be shown on the victory screen.
     * First element is the winner's state, second element is the loser's state.
     * @return {object[][][]}
     */
    get_player_victory_state(){
      return [clone(this.player_x_game_board[this.current_player]), clone(this.player_x_game_board[this.current_opponent])]
    }

    /**
     * get the "score" (the number of hits) that the
     * current player has (counting sunk ships)
     * @return {number}
     * @private
     */
    get_player_score(player) {
        let score = 0
        this.player_x_game_board[player].some(row => {
            row.some(cell => {
                if ( cell.render === GridCellState.Damaged || cell.render === GridCellState.Sunk ) {
                    score += 1
                }
            })
        })

        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(player){
        let boat_count = 0
        this.player_x_game_board[player].some(row => {
            row.some(cell => {
                if ( isShipCell(cell.render) ) {
                    boat_count += 1
                }
            })
        })

        return boat_count
    }

    /**
     * gets the progress (hits/total boats) that the player has
     * @return {number}
     * @private
     */
    get_progress(player){
        const boat_count = this.get_boat_count(player)
        if ( boat_count !== 0 ) {
            return (this.get_player_score(player) / boat_count).toFixed(2)
        } else {
            return 0
        }
    }

    /**
     * Get the current game state.
     * @return {string}
     */
    get_game_state() {
        return clone(this.current_state)
    }

    /**
     * responsible for advancing the game state
     * will be consisting of
     */
    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
         */
        if (this.current_state === GameState.ChoosingNumberOfShips) {
            if (this.n_boats >= 1 && this.n_boats <= 5) {
                this.current_state = GameState.PromptPlayerChange;
                this.post_player_change_state = GameState.PlayerSetup;
                this.current_player = Player.One;
                this.current_opponent = Player.Two;
            } else {
                throw new InvalidAdvanceStateError("Invalid Number of Boats");
            }
        }
        else if (this.current_state === GameState.PlayerSetup) {
            if (this.current_player === Player.One) {
                // because the place_ship handles all the validation
                // all you need to do is make sure they have placed all the appropriate ships
                if ( this.get_ship_entities(this.current_player).length === this.n_boats ) {
                    this.current_state = GameState.PromptPlayerChange;
                    this.post_player_change_state = GameState.PlayerSetup;
                    this.current_player = Player.Two;
                    this.current_opponent = Player.One;
                }
                else{
                    throw new InvalidAdvanceStateError("Player One has a problem with the number of boats selected");
                }
            }
            else if (this.current_player === Player.Two) {
                if ( this.get_ship_entities(this.current_player).length === this.n_boats ) {
                    this.current_state = GameState.PromptPlayerChange;
                    this.post_player_change_state = GameState.PlayerTurn;
                    this.current_player = Player.One;
                    this.current_opponent = Player.Two;
                }
                else{
                    throw new InvalidAdvanceStateError("Player Two has a problem with the number of boats selected");
                }
            }
        }
        else if (this.current_state === GameState.PlayerTurn && this.current_player === Player.One) {
            if (this.current_turn_had_missile_attempt === true) {
                this.current_state = GameState.PromptPlayerChange;
                this.post_player_change_state = GameState.PlayerTurn;
                this.current_player = Player.Two;
                this.current_opponent = Player.One;
            }
            else {
                throw new InvalidAdvanceStateError("the player has not fired a missle");
            }
        }
        else if (this.current_state === GameState.PlayerTurn && this.current_player === Player.Two) {
            if (this.current_turn_had_missile_attempt === true) {
                this.current_state = GameState.PromptPlayerChange;
                this.post_player_change_state = GameState.PlayerTurn;
                this.current_player = Player.One;
                this.current_opponent = Player.Two;
            }
            else {
                throw new InvalidAdvanceStateError("the player has not fired a missle");
            }
        }
        else if ( this.current_state === GameState.PromptPlayerChange ) {
            if ( !this.post_player_change_state ) {
                throw new InvalidAdvanceStateError('No state to advance to after player change!')
            }

            this.current_state = this.post_player_change_state
            this.post_player_change_state = undefined
        }

        let winner = this.get_winner();
        if(winner) {
            this.current_state = GameState.PlayerVictory;
            this.current_player = winner;
            this.current_opponent = this.get_other_player(winner);
        }

        this.current_turn_had_missile_attempt = false
        this.game_state_change_listeners.forEach(fn => fn(this.current_state, false))
    }

    /**
     * Register a handler to be called when the game state changes.
     * @param {function} handler
     */
    on_state_change(handler) {
        this.game_state_change_listeners.push(handler)
    }

    /**
     * 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[]} coords
     * @return {boolean}
     */
    attempt_missile_fire([target_row_i, target_col_i]) {
        if ( this.current_turn_had_missile_attempt ) {
            throw new InvalidMissileFireAttemptError('Cannot fire more than once per turn.')
        } else {
            this.current_turn_had_missile_attempt = true
        }

        const target_cell = this._get_cell_state(this.current_opponent, target_row_i, target_col_i)
        if ( !isValidTargetCell(target_cell.render) ) {
            this.current_turn_had_missile_attempt = false
            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)
            this._trigger_view_update()
            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)
        }

        this._trigger_view_update()
        return false
    }

    /**
     * Checks the player's ships. If any are fully damaged, it flags that ship's cells
     * as "sunk" rather than damaged.
     * @param {string} 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 {string} ship_type
     * @param {number[]} coords_one
     * @param {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)
        })

        // refresh the view
        this._trigger_view_update()
    }

    /**
     * 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[]} coords_one
     * @param {number[]} coords_two
     * @return {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 {string} ship_type
     * @param {number[]} coords_one
     * @param {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 {string} 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 {string} player
     * @return {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
    }

    /**
     * If there is a winner, this will return the Player that won.
     * If no winner has been decided yet, will return undefined.
     * @return {string|undefined}
     */
    get_winner() {
        const [player_1, player_2] = this.players

        // Make sure to sink any fully-damaged ships
        this._sink_damaged_ships(player_1)
        const player_1_ship_cells = this.get_ship_cells(player_1)
        const player_1_loses = (
            (player_1_ship_cells.length > 0)
            && player_1_ship_cells.every(cell => this._get_cell_state(player_1, cell[0], cell[1]).render === GridCellState.Sunk)
        )
        if ( player_1_loses ) return player_2

        // Make sure to sink any fully-damaged ships
        this._sink_damaged_ships(player_2)
        const player_2_ship_cells = this.get_ship_cells(player_2)
        const player_2_loses = (
            (player_2_ship_cells.length > 0)
            && player_2_ship_cells.every(cell => this._get_cell_state(player_2, cell[0], cell[1]).render === GridCellState.Sunk)
        )
        if ( player_2_loses ) return player_2
    }

    /**
     * Returns the other player.
     * @param {string} player
     * @return {string}
     */
    get_other_player(player) {
        if ( player === Player.One ) return Player.Two
        else if ( player === Player.Two ) return Player.One
    }

    /**
     * Given a Player type, return the display value of that player.
     * @param {string} player
     * @return {string}
     */
    get_player_display(player) {
        if ( player === Player.One ) return 'Player 1'
        else if ( player === Player.Two ) return 'Player 2'
    }

    /**
     * Get an array of ship entities for the given player.
     * @param {string} player
     * @return {object[]}
     */
    get_ship_entities(player) {
        return clone(this.player_x_ships[player])
    }

    /**
     * Build an empty structure of grid cells.
     * @return {object[][]}
     * @private
     */
    _build_empty_board() {
        return Array(this.n_rows).fill('').map(_ => {
            return Array(this.n_cols).fill('').map(_ => {
                return {
                    render: GridCellState.Available,
                }
            })
        })
    }

    /**
     * Set the state of the cell at the given coordinates on the player's board
     * to the specified state.
     * @param {string} player
     * @param {number} row_i
     * @param {number} col_i
     * @param {string} 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 {string} 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]
    }

    /**
     * Force a view update without changing the current state.
     * @private
     */
    _trigger_view_update() {
        this.game_state_change_listeners.forEach(fn => fn(this.current_state, true))
    }
}

// Export a single instance, so it can be shared by all files
// To use the game state service, you should do:
// import game_service from './services/GameState.service.js'
const game_service = new GameStateService()
export default game_service