362 lines
12 KiB
TypeScript
362 lines
12 KiB
TypeScript
import { PRNG } from 'seedrandom';
|
|
import {EventEmitter} from 'events';
|
|
import { Board } from "./entities/Board";
|
|
import { PlayerMove } from "./entities/PlayerMove";
|
|
import { PlayerInterface } from "./entities/player/PlayerInterface";
|
|
import { Tile } from "./entities/Tile";
|
|
import { LoggingService } from "../common/LoggingService";
|
|
import { printBoard, printLine, uuid, whileNot } from '../common/utilities';
|
|
import { PlayerNotificationService } from '../server/services/PlayerNotificationService';
|
|
import { GameState } from './dto/GameState';
|
|
import { PlayerHuman } from './entities/player/PlayerHuman';
|
|
import { PlayerAI } from './entities/player/PlayerAI';
|
|
import { GameSummary } from './dto/GameSummary';
|
|
|
|
export class DominoesGame extends EventEmitter {
|
|
private id: string;
|
|
private seed: string | undefined;
|
|
autoDeal: boolean = true;
|
|
board: Board;
|
|
currentPlayerIndex: number = 0;
|
|
gameInProgress: boolean = false;
|
|
gameOver: boolean = false;
|
|
gameBlocked: boolean = false;
|
|
gameTied: boolean = false;
|
|
tileSelectionPhase: boolean = true;
|
|
logger: LoggingService = new LoggingService();
|
|
blockedCount: number = 0;
|
|
winner: PlayerInterface | null = null;
|
|
rng: PRNG;
|
|
handSize: number = 7;
|
|
notificationService: PlayerNotificationService = new PlayerNotificationService();
|
|
lastMove: PlayerMove | null = null;
|
|
forcedInitialPlayerIndex: number | null = null;
|
|
canAskNextPlayerMove: boolean = true;
|
|
clientsReady: string[] = [];
|
|
|
|
constructor(public players: PlayerInterface[], seed: PRNG) {
|
|
super();
|
|
this.id = uuid();
|
|
this.logger.info(`Game ID: ${this.id}`);
|
|
this.rng = seed
|
|
this.board = new Board(seed);
|
|
this.initializeGame();
|
|
}
|
|
|
|
get numHumanPlayers() {
|
|
return this.players.filter(player => player instanceof PlayerHuman).length;
|
|
}
|
|
|
|
async initializeGame() {
|
|
this.gameOver = false;
|
|
this.gameBlocked = false;
|
|
this.gameTied = false;
|
|
this.board.boneyard = this.generateTiles();
|
|
}
|
|
|
|
setForcedInitialPlayerIndex(index: number) {
|
|
this.forcedInitialPlayerIndex = index;
|
|
}
|
|
|
|
reset() {
|
|
this.board.reset();
|
|
this.initializeGame();
|
|
for (let player of this.players) {
|
|
player.hand = [];
|
|
}
|
|
}
|
|
|
|
generateTiles(): Tile[] {
|
|
const tiles: Tile[] = [];
|
|
for (let i = 6; i >= 0; i--) {
|
|
for (let j = i; j >= 0; j--) {
|
|
tiles.push(new Tile([i, j]));
|
|
}
|
|
}
|
|
this.logger.debug('tiles :>> ' + tiles);
|
|
return this.shuffle(tiles);
|
|
}
|
|
|
|
private shuffle(array: Tile[]): Tile[] {
|
|
for (let i = array.length - 1; i > 0; i--) {
|
|
const j = Math.floor(this.rng() * (i + 1));
|
|
[array[i], array[j]] = [array[j], array[i]];
|
|
}
|
|
return array;
|
|
}
|
|
|
|
nextPlayer() {
|
|
this.logger.debug('Turn ended');
|
|
this.currentPlayerIndex = (this.currentPlayerIndex + 1) % this.players.length;
|
|
}
|
|
|
|
isBlocked(): boolean {
|
|
const freeEnds = this.board.getFreeEnds();
|
|
const tiles = []
|
|
for (let player of this.players) {
|
|
tiles.push(...player.hand);
|
|
}
|
|
const canPlay = tiles.some(tile => tile.pips[0] === freeEnds[0] || tile.pips[1] === freeEnds[0] || tile.pips[0] === freeEnds[1] || tile.pips[1] === freeEnds[1]);
|
|
return this.blockedCount >= 4 && !canPlay;
|
|
}
|
|
|
|
isGameOver(): boolean {
|
|
const hasWinner: boolean = this.players.some(player => player.hand.length === 0);
|
|
return hasWinner || this.gameBlocked;
|
|
}
|
|
|
|
getWinner(): PlayerInterface {
|
|
const winnerNoTiles = this.players.find(player => player.hand.length === 0);
|
|
if (winnerNoTiles !== undefined) {
|
|
return winnerNoTiles;
|
|
}
|
|
const winnerMinPipsCount = this.players.reduce((acc, player) => {
|
|
return player.pipsCount() < acc.pipsCount() ? player : acc;
|
|
});
|
|
return winnerMinPipsCount;
|
|
}
|
|
|
|
getStartingPlayerIndex(): number {
|
|
// Determine starting player
|
|
let startingIndex = this.players.findIndex(player => player.hand.some(tile => tile.pips[0] === 6 && tile.pips[1] === 6));
|
|
if (startingIndex === -1) {
|
|
startingIndex = this.players.findIndex(player => player.hand.some(tile => tile.pips[0] === 5 && tile.pips[1] === 5));
|
|
if (startingIndex === -1) {
|
|
startingIndex = this.players.findIndex(player => player.hand.some(tile => tile.pips[0] === 4 && tile.pips[1] === 4));
|
|
if (startingIndex === -1) {
|
|
startingIndex = this.players.findIndex(player => player.hand.some(tile => tile.pips[0] === 3 && tile.pips[1] === 3));
|
|
if (startingIndex === -1) {
|
|
startingIndex = this.players.findIndex(player => player.hand.some(tile => tile.pips[0] === 2 && tile.pips[1] === 2));
|
|
if (startingIndex === -1) {
|
|
startingIndex = this.players.findIndex(player => player.hand.some(tile => tile.pips[0] === 1 && tile.pips[1] === 1));
|
|
if (startingIndex === -1) {
|
|
startingIndex = this.players.findIndex(player => player.hand.some(tile => tile.pips[0] === 0 && tile.pips[1] === 0));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
this.logger.debug('Starting player index: ' + startingIndex);
|
|
return startingIndex === -1 ? 0 : startingIndex;
|
|
}
|
|
|
|
playTurn() {
|
|
try {
|
|
const player = this.players[this.currentPlayerIndex];
|
|
this.notificationService.sendEventToPlayers('server:next-turn', this.players, this.getState());
|
|
this.logger.debug(`${player.name}'s turn (${player.hand.length} tiles)`);
|
|
this.printPlayerHand(player);
|
|
printBoard(this.board)
|
|
player.askForMove(this.board);
|
|
} catch (error) {
|
|
this.logger.error(error, 'Error playing turn');
|
|
}
|
|
}
|
|
|
|
async checkAllClientsReadyToContinue() {
|
|
try {
|
|
const conditionFn = () => {
|
|
this.logger.trace(`Clients ready: ${this.clientsReady.length}/${this.numHumanPlayers}`);
|
|
return this.clientsReady.length === this.numHumanPlayers
|
|
}
|
|
await whileNot(conditionFn, 100);
|
|
this.clientsReady = [];
|
|
} catch (error) {
|
|
this.logger.error(error, 'Error starting game');
|
|
throw new Error('Error starting game (checkAllClientsReadyToContinue)');
|
|
}
|
|
}
|
|
|
|
async finishTurn(playerMove: PlayerMove | null) {
|
|
try {
|
|
this.lastMove = playerMove;
|
|
if (playerMove === null) {
|
|
this.logger.info('Player cannot move');
|
|
this.blockedCount += 1;
|
|
this.logger.trace(`Blocked count: ${this.blockedCount}`);
|
|
this.gameBlocked = this.isBlocked();
|
|
this.notificationService.sendEventToPlayers('server:server-player-move', this.players, { move: playerMove });
|
|
if (this.gameBlocked) {
|
|
this.gameEnded();
|
|
} else {
|
|
this.nextPlayer();
|
|
await this.checkAllClientsReadyToContinue()
|
|
this.playTurn();
|
|
}
|
|
return;
|
|
}
|
|
const player = this.players[this.currentPlayerIndex];
|
|
const skipWaitForConfirmation = player instanceof PlayerAI && playerMove === null;
|
|
this.blockedCount = 0;
|
|
this.board.play(playerMove);
|
|
player.hand = player.hand.filter(tile => tile !== playerMove.tile);
|
|
this.notificationService.sendEventToPlayers('server:server-player-move', this.players, { move: playerMove });
|
|
this.canAskNextPlayerMove = false;
|
|
// whileNotUndefined(() => this.canAskNextPlayerMove === true ? {} : undefined);
|
|
this.gameOver = this.isGameOver();
|
|
if (!this.gameOver) {
|
|
this.nextPlayer();
|
|
if (!skipWaitForConfirmation) {
|
|
await this.checkAllClientsReadyToContinue()
|
|
}
|
|
this.playTurn();
|
|
} else {
|
|
this.gameEnded();
|
|
}
|
|
} catch (error) {
|
|
this.logger.error(error, 'Error finishing move');
|
|
}
|
|
}
|
|
|
|
gameEnded() {
|
|
this.gameInProgress = false;
|
|
this.winner = this.getWinner();
|
|
this.setScores();
|
|
const summary: GameSummary = {
|
|
gameId: this.id,
|
|
isBlocked: this.gameBlocked,
|
|
isTied: this.gameTied,
|
|
winner: this.winner?.getState(true),
|
|
score: this.players.map(player => ({id: player.id, name: player.name, score: player.score})),
|
|
players: this.players.map(player => player.getState(true)),
|
|
board: this.board.tiles.map(tile => tile.getState(true)),
|
|
boneyard: this.board.boneyard.map(tile => tile.getState(true)),
|
|
movements: this.board.movements
|
|
}
|
|
this.emit('game-over', summary);
|
|
}
|
|
|
|
resetPlayersScore() {
|
|
for (let player of this.players) {
|
|
player.score = 0;
|
|
}
|
|
}
|
|
|
|
setCanAskNextPlayerMove(value: boolean) {
|
|
this.canAskNextPlayerMove = value
|
|
}
|
|
|
|
private deal() {
|
|
if (this.autoDeal) {
|
|
this.autoDealTiles();
|
|
} else {
|
|
// await this.tilesSelection();
|
|
}
|
|
this.printPlayersHand();
|
|
}
|
|
|
|
printPlayersHand() {
|
|
for (let player of this.players) {
|
|
this.printPlayerHand(player);
|
|
}
|
|
}
|
|
|
|
printPlayerHand(player: PlayerInterface) {
|
|
this.logger.debug(`${player.name}'s hand (${player.hand.length}): ${player.hand.map(tile => tile.toString())}`);
|
|
}
|
|
|
|
async start(): Promise<void> {
|
|
try {
|
|
// Initalize game
|
|
this.gameInProgress = false;
|
|
this.resetPlayersScore();
|
|
this.tileSelectionPhase = true;
|
|
this.deal();
|
|
const extractStates = (p: PlayerInterface) => ({
|
|
player: p.getState(true),
|
|
gameState: this.getState()
|
|
});
|
|
await this.notificationService.sendEventToPlayers('server:hand-dealt', this.players, extractStates);
|
|
|
|
this.tileSelectionPhase = false;
|
|
|
|
// Start game
|
|
this.gameInProgress = true;
|
|
this.currentPlayerIndex = (this.forcedInitialPlayerIndex !== null) ? this.forcedInitialPlayerIndex : this.getStartingPlayerIndex();
|
|
|
|
printLine(`${this.players[this.currentPlayerIndex].name} is the starting player:`);
|
|
this.logger.debug("Before play turn")
|
|
this.playTurn();
|
|
} catch (error) {
|
|
this.logger.error(error, 'Error starting game');
|
|
throw new Error('Error starting game');
|
|
}
|
|
}
|
|
|
|
setScores() {
|
|
const totalPips = this.players.reduce((acc, player) => acc + player.pipsCount(), 0) || 0;
|
|
if (this.winner !== null) {
|
|
const winner = this.winner;
|
|
winner.score = totalPips;
|
|
if (winner.teamedWith !== undefined) {
|
|
const p = this.getPlayer(winner.teamedWith)
|
|
if (p !== undefined) {
|
|
p.score = totalPips;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private getPlayer(userId: string) {
|
|
return this.players.find(player => player.id === userId) || undefined;
|
|
}
|
|
|
|
private autoDealTiles() {
|
|
for (let i = 0; i < this.handSize; i++) {
|
|
for (let player of this.players) {
|
|
const tile: Tile | undefined = this.board.boneyard.pop();
|
|
if (tile !== undefined) {
|
|
tile.revealed = true;
|
|
tile.playerId = player.id;
|
|
player.hand.push(tile);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private async tilesSelection() {
|
|
while (this.board.boneyard.length > 0) {
|
|
for (let player of this.players) {
|
|
const choosen = await player.chooseTile(this.board);
|
|
if (this.board.boneyard.length === 0) {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
getState(): GameState {
|
|
const currentPlayer = this.players[this.currentPlayerIndex]
|
|
const lastMove = this.lastMove?.getState() || null;
|
|
const movements = this.board.movements.map(move => move.getState());
|
|
|
|
return {
|
|
id: uuid(),
|
|
lastMove,
|
|
gameInProgress: this.gameInProgress,
|
|
winner: this.winner,
|
|
tileSelectionPhase: this.tileSelectionPhase,
|
|
gameBlocked: this.gameBlocked,
|
|
gameTied: this.gameTied,
|
|
gameId: this.id,
|
|
boneyard: this.board.boneyard.map(tile => tile.getState(false)),
|
|
players: this.players.map(player => player.getState(false)),
|
|
currentPlayer: currentPlayer.getState(),
|
|
board: this.board.tiles.map(tile => tile.getState(true)),
|
|
boardFreeEnds: this.board.getFreeEnds(),
|
|
movements
|
|
}
|
|
|
|
}
|
|
|
|
setClientReady(userId: string) {
|
|
this.logger.trace(`${userId} - ${this.clientsReady}`);
|
|
if (!this.clientsReady.includes(userId)) {
|
|
this.logger.trace(`Client ${userId} is ready`)
|
|
this.clientsReady.push(userId);
|
|
}
|
|
}
|
|
|
|
|
|
} |