Source

core/Board.ts

import Hole from "./Hole";
import Player from "./Player";
import PlayerSide from "./PlayerSide";
import Rules from "./Rules";
import MtajiModeRules from "./rules/MtajiModeRules";
import PlayerBoardHoles from "./PlayerBoardHoles";
import AppConstants from "./AppConstants";
import Move from "./Move";
import MoveDirection from "./MoveDirection";
import Me from "../me";
import { isEmpty } from "lodash";
import SeedUI from "../ui_entities/SeedUI";
import Utility from "../Utility";
import { Queue } from "queue-typescript";
import PlayerUI from "../ui_entities/PlayerUI";
import UiHelper from "../ui_entities/UiHelper";
import BoardUiState from "./BoardUiState";
import BoardUI from "../ui_entities/BoardUI";

import HUD from "../ui_entities/HUD";

/*
 * bawo.zone - <a href="https://bawo.zone">https://bawo.zone</a>
 * <a href="https://github.com/fumba/bawo.zone">https://github.com/fumba/bawo.zone</a>
 *
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

/**
 *  The <tt>GameBoard</tt> class represents the bawo/bao  game board.
 *  <p>
 *
 *  The board is represented using 2 circular doubly linked lists each assigned to a player. Each list represents 2 rows with
 *  hole Ids assigned as shown below;
 *
 *
 *    TOP PLAYER sitting position (facing down)
 *    00	01	02	03	04	05	06	07
 *    15	14	13	12	11	10	09	08
 *
 *    00	01	02	03	04	05	06	07
 *    15	14	13	12	11	10	09	08
 *    BOTTOM PLAYER sitting position (facing up)
 *
 *    32 (optional hole - depends of game version)
 *
 *  Rules for Placement of seeds are specified in classes implementing  the interface <tt>GameRules</tt>.
 *  <p>
 *
 *  @author  <a href="http://fumba.me">Fumba Chibaka</a>
 */
class Board {
  private currentPlayer: Player;
  public readonly bottomPlayer: Player;
  public readonly topPlayer: Player;
  public readonly rules: Rules;
  public readonly me: typeof Me;
  public uiTaskQueue: Queue<Record<string, unknown>> = new Queue();
  public readonly ui: BoardUI;
  public readonly HUD: HUD;

  /**
   * The game play is in a continuous loop if the player continues to play beyond a
   * threshold amount of moves.
   * The game will end and the player who got themselves into an infinite loop will lose.
   */
  private isInContinuousLoopStatus = false;

  /**
   * @param {any} me melonjs instance
   * @param {Rules} rules game rules - default is MtajiModeRules
   */
  constructor(me?: typeof Me, rules?: Rules) {
    this.me = me;
    if (this.isInGraphicsMode()) {
      this.ui = new BoardUI();
    }
    this.bottomPlayer = new Player(PlayerSide.Bottom, this);
    this.topPlayer = new Player(PlayerSide.Top, this);
    this.rules = rules ? rules : new MtajiModeRules();
    this.rules.validate();
    // initialize player board holes
    [this.topPlayer, this.bottomPlayer].forEach((player) => {
      const playerInitSeedConfig: Array<number> = this.rules
        .initialSeedForPlayerRows()
        .get(player.side);
      const playerBoardHoles: PlayerBoardHoles = new PlayerBoardHoles(
        player,
        this
      );
      for (let index = 0; index < AppConstants.NUM_PLAYER_HOLES; index++) {
        playerBoardHoles.insertAtEnd(playerInitSeedConfig[index]);
      }
      if (this.rules.hasHomeHole()) {
        player.boardHoles.nyumba.numSeeds = playerInitSeedConfig[16];
      }
    });

    //set current players
    this.currentPlayer = this.topPlayer;
    this.updateMovesStatus();

    if (this.isInGraphicsMode()) {
      //render holes on board
      [this.topPlayer, this.bottomPlayer].forEach((player) => {
        for (const hole of player.boardHoles) {
          hole.renderUI();
        }
        if (this.rules.hasHomeHole()) {
          player.boardHoles.nyumba.renderUI();
        }
      });

      this.HUD = new HUD(this);
      //add board ui container to game world
      this.me.game.world.addChild(this.ui);
      this.draw(BoardUiState.RESTING);
    }

    this.validateUiState();
  }

  /**
   * Extract the opposing enemy hole from the players front row.
   * This is hole from which the current player will be capturing seeds from.
   *
   * @param {Hole} hole Hole from which the opposing enemy hole is to be retrieved from.
   * @returns {Hole} Opposing enemy hole.
   */
  public adjacentOpponentHole(hole: Hole): Hole {
    if (hole.isInFrontRow()) {
      const opposingId: number = 15 - hole.id;
      const opponentPlayer: Player = this.getOpponentPlayer(hole.player);
      return opponentPlayer.boardHoles.getHoleWithID(opposingId);
    }
    throw new Error(
      `Attempted to retrieve adjacent opponent hole from a hole that is not in the front row | input: ${hole.id}`
    );
  }

  /**
   *  Retrieves the current player.
   *
   * @returns {Player} current player
   */
  public getCurrentPlayer(): Player {
    return this.currentPlayer;
  }

  /**
   * Update the move status for all the holes on the board.
   *
   * Move Status can be one of the following:
   *
   * CLOCKWISE
   *
   * ANTI_CLOCKWISE
   *
   * BOTH - Both clockwise and anti-clockwise moves are allowed.
   *
   * LOCKED - A valid move cannot be made from the hole.
   *
   * UNAUTHORIZED - The hole does not belong to the current player.
   */
  private updateMovesStatus(): void {
    for (const hole of this.topPlayer.boardHoles) {
      this.updateMoveStatusForHole(hole);
    }
    for (const hole of this.bottomPlayer.boardHoles) {
      this.updateMoveStatusForHole(hole);
    }
  }

  /**
   * Updates the move status for the specified hole.
   *
   * @param {Hole} hole The Hole on will be performed on.
   */
  private updateMoveStatusForHole(hole: Hole): void {
    // Update status only if the current player is authorized to make a move
    // on the hole.
    if (
      hole.player == this.currentPlayer &&
      this.currentPlayer.numSeedsInHand == 0
    ) {
      const clockwiseMove: Move = new Move(hole, MoveDirection.Clockwise);
      const antiClockwiseMove: Move = new Move(
        hole,
        MoveDirection.AntiClockwise
      );
      const isValidClockwiseMove: boolean = this.isValidMove(clockwiseMove);
      const isValidAntiClockwiseMove: boolean =
        this.isValidMove(antiClockwiseMove);
      if (isValidClockwiseMove && isValidAntiClockwiseMove) {
        hole.moveStatus = MoveDirection.Both;
      } else if (isValidClockwiseMove) {
        hole.moveStatus = MoveDirection.Clockwise;
      } else if (isValidAntiClockwiseMove) {
        hole.moveStatus = MoveDirection.AntiClockwise;
      } else {
        hole.moveStatus = MoveDirection.Locked;
      }
    } else {
      // holes that do not belong to the current player are set as
      // unauthorized.
      hole.moveStatus = MoveDirection.UnAuthorized;
    }
  }

  /**
   * Check if a move valid. GameRules implementation is used to make this
   * decision.
   *
   * @param {Move} move The move whose validity is to be checked.
   * @returns {boolean} {@code true} if the move is valid.
   */
  public isValidMove(move: Move): boolean {
    //check move integrity
    if (move == null) {
      throw new Error("Move is null.");
    }
    if (move.hole == null) {
      throw new Error("Move is required to have a starting hole specified.");
    }
    if (move.direction == null) {
      throw new Error("Move is required to have its direction specified.");
    }
    if (this.currentPlayer.side != move.hole.player.side) {
      throw new Error(
        "Player " +
          this.currentPlayer.toString() +
          " is not allowed to make a move on hole " +
          move.hole.toString()
      );
    }
    return this.rules.isValidMove(move);
  }

  /**
   * Switch Players.
   *
   * @throws Error when an attempt is made to switch while one of the players has seeds in hands.
   */
  public switchPlayers(): void {
    if (
      this.topPlayer.numSeedsInHand == 0 &&
      this.bottomPlayer.numSeedsInHand == 0
    ) {
      this.currentPlayer = this.getOpponentPlayer(this.currentPlayer);
    } else {
      throw new Error(
        "Cannot assign players while player(s) has(ve) seed(s) in hand: \n" +
          this.topPlayer +
          "\n" +
          this.bottomPlayer
      );
    }
    this.updateMovesStatus();
    console.info(
      "Players Switched. Current player info: \n" +
        this.currentPlayer.toString()
    );
  }

  /**
   * Clones a board
   *
   * @returns {Board} a clone of the board
   */
  public clone(): Board {
    const topPlayerSeedArrangement: Array<number> = [];
    const btmPlayerSeedArrangement: Array<number> = [];
    for (const hole of this.topPlayer.boardHoles) {
      topPlayerSeedArrangement.push(hole.numSeeds);
    }
    for (const hole of this.bottomPlayer.boardHoles) {
      btmPlayerSeedArrangement.push(hole.numSeeds);
    }
    return Board.loadState(
      topPlayerSeedArrangement,
      btmPlayerSeedArrangement,
      this.currentPlayer
    );
  }

  //TODO
  public static loadState(
    topPlayerSeedArrangement: Array<number>,
    btmPlayerSeedArrangement: Array<number>,
    currentPlayer: Player
  ): Board {
    const board: Board = new Board(null);
    if (
      currentPlayer != null &&
      board.currentPlayer.side != currentPlayer.side
    ) {
      board.switchPlayers();
    }
    //update hole seed counts for top player
    if (topPlayerSeedArrangement) {
      //TODO validate length - 16
      console.info(
        `Cloning seed configuration for TOP player to ${topPlayerSeedArrangement}`
      );
      for (let id = 0; id < AppConstants.NUM_PLAYER_HOLES; id++) {
        board.topPlayer.boardHoles.getHoleWithID(id).numSeeds =
          topPlayerSeedArrangement[id];
      }
    }
    //update hole seed counts for bottom player
    if (btmPlayerSeedArrangement) {
      //TODO validate length - 16
      console.info(
        `Cloning seed configuration for BOTTOM player to ${btmPlayerSeedArrangement}`
      );
      for (let id = 0; id < AppConstants.NUM_PLAYER_HOLES; id++) {
        board.bottomPlayer.boardHoles.getHoleWithID(id).numSeeds =
          btmPlayerSeedArrangement[id];
      }
    }
    board.updateMovesStatus();
    return board;
  }

  /**
   * Retrieves all the valid moves for a player.
   *
   * @param {Player} player The Player who is requesting the list of valid moves.
   * @returns {Array<Move>} A list of moves that are valid. These moves can be executed on the board.
   */
  public getAllAvailableValidMoves(player: Player): Array<Move> {
    this.updateMovesStatus();
    const moves: Array<Move> = [];
    for (const hole of player.boardHoles) {
      hole.availableMovesForCurrentPlayer().forEach((move) => moves.push(move));
    }
    return moves;
  }

  /**
   * Execute a move:
   *
   * The rules on how the move is to be performed are specified in the
   * implementation of the GameRules interface.
   *
   * @param {Move} move The move to be executed.
   * @returns {boolean} A boolean value representing whether or not the move was successful.
   */
  public executeMove(move: Move): boolean {
    console.info("Board#executeMove- Received move", move);
    this.validateUiState();
    if (move.hole.player.side != this.currentPlayer.side) {
      throw new Error(
        `${this.currentPlayer} is unauthorized to perform ${move.toString()}`
      );
    }
    if (move.prevContinuedMovesCount > AppConstants.INFINITE_LOOP_THRESHOLD) {
      // The player has made more than the allowed amount of moves. They
      // are now regarded to be in infinite move status. The game will end and the player who got themselves into an infinite loop will loose.

      // place all the seeds back into the start hole for the move.
      move.hole.transferSeedsFromCurrPlayer(this.currentPlayer.numSeedsInHand);

      this.isInContinuousLoopStatus = true;
      // TODO update GUI state
      return true;
    }
    if (!this.isValidMove(move)) {
      console.info(
        "Requested move is not valid.",
        move.toString(),
        move.hole.board.toString()
      );
      return false;
    }

    const numSeedsToSowPerStep = 1;
    let currentHole: Hole = move.hole; //starting hole
    let destinationHole: Hole;
    // Continuing moves already have seeds in the player hands
    // Initial moves need to get the seeds from the hole into the players hand
    if (!move.isContinuing()) {
      console.info("detected new move - transferring seeds to players hand");
      currentHole.transferAllSeedsToCurrPlayer();
    }

    console.info(`Board before seeds are moved: ${this.toString()}`);
    console.info("Current player before seeds are moved", this.currentPlayer);
    while (this.currentPlayer.numSeedsInHand > 0) {
      // Find out which hole the player should move to next
      if (this.currentPlayer.capturedOnPrevMove) {
        // if a player captured on the previous move, plant a seed in
        // the after-capture start hole before moving to other holes.
        destinationHole = currentHole;
        // reset previous capture flag
        this.currentPlayer.capturedOnPrevMove = false;
      } else {
        destinationHole = this.computeDestinationHole(
          move.direction,
          currentHole,
          numSeedsToSowPerStep
        );
      }
      // Place 1 seed in the destination hole and move on to the next
      // hole.
      destinationHole.transferSeedsFromCurrPlayer(numSeedsToSowPerStep); //TODO-GUI: remove seed from hand
      currentHole = destinationHole;

      //TODO update GUI state
      console.info("Board Status after sowing one seed: \n" + this.toString());
      console.info(
        "Current Player status  after sowing one seed",
        this.currentPlayer
      );
    }

    console.info("Current player no longer has any  left seeds in hand");
    // Get the enemy's hole id at the end of the move if destination has
    // move than 1 seed in total
    if (currentHole.numSeeds > 1) {
      let continuingMove: Move;
      // Capture enemies seeds and continue playing (re-start from side)
      // Only capture seeds if the initial move was mtaji (a capture move)
      if (move.isMtaji && this.captureAllSeedsFromEnemy(currentHole)) {
        const captureMove: Move = new Move(
          currentHole,
          move.direction,
          move.prevContinuedMovesCount
        );
        continuingMove = this.rules.nextContinuingMoveAfterCapture(captureMove);
        // All moves at this point will be able to capture seeds...
        continuingMove.isMtaji = move.isMtaji;
        return this.executeMove(continuingMove);
      } else {
        // Take all seeds in hole and continue with current player without capturing any seeds
        currentHole.transferAllSeedsToCurrPlayer();
        continuingMove = new Move(
          currentHole,
          move.direction,
          move.prevContinuedMovesCount + 1
        );
        if (move.isContinuing()) {
          // get mtaji status from parent
          continuingMove.isMtaji = move.isMtaji;
        } else {
          // this is an initial move that didn't capture...
          // Do not allow any other moves to capture at this point...
          continuingMove.isMtaji = false;
        }
        return this.executeMove(continuingMove);
      }
    }

    // Switch player sides once a move is executed successfully.
    this.switchPlayers();
    console.info("Board Status After Move : \n" + this.toString());
    console.info("Move is Completed!");
    //TODO Update GUI state

    this.validateUiState();
    return true;
  }

  /**
   * Validates the state of UI entities:
   *
   * 1. There should always be 64 seeds in play (before move and after move)
   */
  public validateUiState(): void {
    if (this.isInGraphicsMode()) {
      const seedUiCount = this.me.game.world.getChildByType(SeedUI).length;
      if (seedUiCount != AppConstants.MAX_SEED_COUNT) {
        throw new Error(
          `UI State has ${seedUiCount} seeds present. There should always be 64 seeds in play`
        );
      }
      const playerUiCount = this.me.game.world.getChildByType(PlayerUI).length;
      if (playerUiCount != 2) {
        throw new Error(
          `UI State has ${playerUiCount} players on board. There should always be 2.`
        );
      }
    }
  }

  /**
   * Checks to see if graphics should be rendered
   *
   * @returns {boolean} if graphics need to be rendered
   */
  public isInGraphicsMode(): boolean {
    return !isEmpty(this.me);
  }

  /**
   * Finds the hole on which planting the given number of seed tokens one by one-
   * will stop when executed.
   *
   * @param {MoveDirection} direction Direction of the move
   * @param {Hole} hole The hole on which the move starts on
   * @param {number} numberOfSteps Number of seeds to be planted in the move
   * @returns {Hole} Hole on which the move ends on
   */
  private computeDestinationHole(
    direction: MoveDirection,
    hole: Hole,
    numberOfSteps: number
  ): Hole {
    switch (direction) {
      case MoveDirection.AntiClockwise:
        return hole.player.boardHoles.stepAntiClockwise(hole, numberOfSteps);
      case MoveDirection.Clockwise:
        return hole.player.boardHoles.stepClockwise(hole, numberOfSteps);
      default:
        throw new Error(
          `Only Clockwise and Anticlockwise moves are allowed. Received : ${direction}`
        );
    }
  }

  /**
   * Capture all seeds from enemy if current player stopped on front row.
   *
   * @param {Hole} startHole The hole which will be capturing the enemy seeds.
   * @returns {boolean} true if the capture was successful
   */
  private captureAllSeedsFromEnemy(startHole: Hole): boolean {
    if (startHole.isInFrontRow()) {
      const opponentHole = this.getOpposingEnemyHole(startHole);
      const stolenSeedCount = opponentHole.transferAllSeedsToCurrPlayer();
      if (stolenSeedCount > 0) {
        console.info(
          `******* HOLE (${startHole.id}) CAPTURED ${stolenSeedCount} SEEDS FROM (${opponentHole.id}) ********`
        );
        this.currentPlayer.capturedOnPrevMove = true;
        return true;
      }
    }
    return false;
  }

  /**
   * Extract the opposing enemy hole from the players front row.
   *
   * @param {Hole} hole Hole from which the opposing enemy hole is to be retrieved.
   * @returns {Hole} Opposing enemy hole.
   */
  public getOpposingEnemyHole(hole: Hole): Hole {
    if (hole.isInFrontRow()) {
      const opposingId = 15 - hole.id;
      const opponentPlayer = this.getOpponentPlayer(hole.player);
      return opponentPlayer.boardHoles.getHoleWithID(opposingId);
    }
    throw new Error("Could not retrieve opposing enemy hole");
  }

  public toString(): string {
    this.updateMovesStatus();
    let buffer = "\n".concat(this.topPlayer.boardHoles.toString());
    buffer = buffer.concat(this.bottomPlayer.boardHoles.toString());
    return buffer.replace(/\n$/, ""); //remove newline at the end
  }

  public runSimulation(randomize: boolean): void {
    const moves: Array<Move> = this.getAllAvailableValidMoves(
      this.currentPlayer
    );
    // either always pick first move or randomize
    const move = moves[randomize ? Utility.getRandomInt(moves.length) : 0];
    this.executeMove(move);
    if (!this.isGameOver()) {
      // recursively run the simulation
      this.runSimulation(randomize);
    } else {
      console.info(
        `GAME OVER ::: Winner is : \n ${this.getWinningPlayer().toString()}`
      );
      console.info(
        "GAME SCORE: \nTOP Player: " +
          this.getScore().get(PlayerSide.Top) +
          "\nBOTTOM Player: " +
          this.getScore().get(PlayerSide.Bottom)
      );
    }
  }

  /**
   * Checks if the game is over. The game is over if the current player is left
   * with no valid moves (ie. all the moves are locked).
   *
   * When the game play gets in an infinite loop, the game is also considered be
   * over. The player who gets themselves into the loop is considered to have lost
   * the game in this case.
   *
   * @returns {boolean} true if the game is over.
   */
  public isGameOver(): boolean {
    if (this.isInContinuousLoopStatus) {
      return true;
    }
    for (const hole of this.currentPlayer.boardHoles) {
      if (hole.moveStatus != MoveDirection.Locked) {
        // found a non-locked hole which means that the game is not over yet!
        return false;
      }
    }
    return true;
  }

  /**
   * Gets the player who won the game. This only happens if the game is over.
   * opposing player is determined as the winning player since the current player
   * cannot make any moves in this case.
   *
   * @returns {Player} The winning Player.
   */
  public getWinningPlayer(): Player {
    if (this.isGameOver()) {
      return this.getOpponentPlayer(this.currentPlayer);
    } else {
      throw new Error(
        "The game is still in play. There is no winning player yet."
      );
    }
  }

  /**
   * Re-renders the state of the hole, HUD, and other UI components on the board
   *
   * @param state
   */
  public draw(state: BoardUiState): void {
    switch (state) {
      case BoardUiState.RESTING: {
        UiHelper.forEachBoardHole(this, (hole: Hole) => {
          hole.ui.label.setText(hole.ui.seedCount());
          hole.ui.sleepStateUI();
          hole.seedGroupUI.updateContainerStatus();
        });
        break;
      }
      case BoardUiState.PLAY_IN_PROGRESS: {
        UiHelper.forEachBoardHole(this, (hole: Hole) => {
          hole.ui.label.setText(hole.ui.seedCount());
          hole.ui.renderable = hole.ui.sleepingHoleSprite;
          hole.seedGroupUI.renderable.tint.setColor(105, 105, 105); //gray out all holes
        });
        break;
      }
    }

    //validate that ui and non-ui board are in sync
    UiHelper.forEachBoardHole(this, (hole: Hole) => {
      if (
        state != BoardUiState.PLAY_IN_PROGRESS &&
        hole.numSeeds != hole.ui.seedCount()
      ) {
        throw new Error(
          `UI (${hole.ui.seedCount()}) and non-UI (${
            hole.numSeeds
          }) board not in sync : ${hole.toString()}`
        );
      }
    });
  }

  /**
   * Calculates the current game score. The score is calculated as the number
   * seeds on the player side. If a player gets themselves into an infinite loop
   * move, they have lost the game.
   *
   * @returns {number} A Hash with the PlayerSide as the key and the number of seeds on that
   *         side as the value.
   */
  public getScore(): Map<PlayerSide, number> {
    if (
      this.bottomPlayer.numSeedsInHand != 0 ||
      this.topPlayer.numSeedsInHand != 0
    ) {
      throw new Error(
        "Score cannot be determined while one of the players is holding seeds in hand."
      );
    }
    const scoreMap = new Map<PlayerSide, number>();
    let btmPlayerScore = 0;
    let topPlayerScore = 0;
    for (const hole of this.bottomPlayer.boardHoles) {
      btmPlayerScore += hole.numSeeds;
    }
    scoreMap.set(PlayerSide.Bottom, btmPlayerScore);
    for (const hole of this.topPlayer.boardHoles) {
      topPlayerScore += hole.numSeeds;
    }
    scoreMap.set(PlayerSide.Top, topPlayerScore);

    // if one of the players got into an infinite loop, they are punished by
    // loosing all their seed scores to the opponent player.
    if (this.isInContinuousLoopStatus) {
      const loosingPlayerSeedCount = scoreMap.get(this.currentPlayer.side);
      const winningPlayerSeedCount = scoreMap.get(
        this.getOpponentPlayer(this.currentPlayer).side
      );
      scoreMap.set(this.currentPlayer.side, 0);
      scoreMap.set(
        this.getOpponentPlayer(this.currentPlayer).side,
        loosingPlayerSeedCount + winningPlayerSeedCount
      );
    }
    if (topPlayerScore + btmPlayerScore != AppConstants.MAX_SEED_COUNT) {
      console.info(this.toString());
      throw new Error(
        `Total score is not 64. Got : ${topPlayerScore + btmPlayerScore}`
      );
    }
    return scoreMap;
  }

  /**
   * Retrieves the opponent player for the current player.
   *
   * @param {Player} player Player whose opponent is to be determined.
   * @returns {Player} opponent player
   */
  private getOpponentPlayer(player: Player): Player {
    return player.isOnTopSide() ? this.bottomPlayer : this.topPlayer;
  }
}
export default Board;