import { actionCards } from '../namespaces/cardTypes.js';
import shuffle from '../utils/shuffle.js';
import Card from './Card.js';
/**
* The configuration object for a round in a game.
* @typedef {Object} RoundConfig
* @property {boolean} isFinished - Returns `true` if the round is finished.
* @property {boolean} isTurnClockwise - Returns `true` if the direction of play
* is in clockwise direction.
* @property {Object<string,boolean>} players - Array of players' name in the
* current round.
* @property {number} turn - Returns the index of player in the game in the
* current turn.
* @property {Card[]} drawPile - Array of Cards in the draw pile.
* @property {Card[]} discardPile - Array of Cards in the discard pile.
* @property {Array<Card[]>} playersCards - Array of player's cards.
* @property {number|null} mustCallsUno - The index of the player who must calls
* 'UNO' because they have only one card remaining on their hands.
* @property {string[]} winners - Indexes of players whose already wins the
* game.
*/
// TODO: Add custom rules configuration to the game.
// TODO: Add objectives options; Whether playing for points or race (winners
// ranking decided by the order of players going out the game).
/**
* The UNO Game class.
*/
export default class Game {
/**
* Creates a new UNO game.
* @param {string[]} players Array of players' name.
*/
constructor(players) {
/**
* Array of players' name.
* @type {string[]}
*/
this.players = [...new Set(players)];
/**
* The configuration of current round in the game.
* @type {RoundConfig}
*/
this.roundConfig = null;
if (this.players.length !== players.length) {
throw new Error('Cannot have duplicate players\' name.');
}
if (this.players?.length < 2) {
throw new RangeError('Too few players. Minimal 2 players');
} else if (this.players?.length > 10) {
throw new RangeError('Too much players. Maximal 10 players');
}
}
/**
* Array of cards in the draw pile of the current round.
* @type {Card[]}
*/
get drawPile() {
if (!this.roundConfig || this.roundConfig?.isFinished) {
throw new Error('No available round found in this game');
}
return this.roundConfig.drawPile;
}
/**
* Array of cards in the discard pile of the current round.
* @type {Card[]}
*/
get discardPile() {
if (!this.roundConfig || this.roundConfig?.isFinished) {
throw new Error('No available round found in this game');
}
return this.roundConfig.discardPile;
}
/**
* Returns an array of cards that belong to the specified player.
* @param {number} playerId - The index of the player in the current game.
* @returns {Card[]}
*/
getPlayerCards(playerId) {
if (!this.roundConfig || this.roundConfig?.isFinished) {
throw new Error('No available round found in this game');
}
if (playerId >= this.players.length || playerId < 0) {
throw new RangeError(
`No player found with the specified id: ${playerId}`,
);
}
return this.roundConfig.playersCards[playerId];
}
/**
* Returns an array of cards that belong to the specified player.
* @param {string} playerName - The name of the player.
* @returns {Card[]}
*/
getPlayerCardsByName(playerName) {
if (!this.roundConfig || this.roundConfig?.isFinished) {
throw new Error('No available round found in this game');
}
return this.getPlayerCards(this.players.indexOf(playerName));
}
/**
* Initialize a new UNO game round.
* @param {number} startingPlayer - The index of the first player to
* play in the round.
* @returns {RoundConfig}
*/
newRound(startingPlayer = 0) {
if (startingPlayer < 0 || startingPlayer >= this.players.length) {
throw new RangeError(
'The starting player index must be in the range of players\' '
+ 'indexes.',
);
}
const isTurnClockwise = true;
const turn = startingPlayer;
const players = Array(this.players.length).fill(true);
const drawPile = [];
// Add for each color of cards:
// 1 x 0 card, 2 x 1-9 cards, 2 x reverse, 2 x skip cards,
// 2 x draw two cards, 4 x wild cards, 4 x wild draw four cards
'red,yellow,green,blue'.split(',').forEach((color) => {
drawPile.push(new Card(color, '0'));
'123456789rs'.split('').concat('+2').forEach((symbol) => {
drawPile.push(new Card(color, symbol));
drawPile.push(new Card(color, symbol));
});
});
['w', '+4'].forEach((symbol) => {
for (let i = 0; i < 4; i += 1) drawPile.push(new Card('wild', symbol));
});
shuffle(drawPile);
const discardPile = [];
const playersCards = new Array(this.players.length)
.fill(null)
.map(() => []);
// Give 7 cards from draw pile to each this.players
this.players.forEach((_, playerId) => {
for (let i = 0; i < 7; i += 1) {
playersCards[playerId].push(drawPile.pop());
}
});
const mustCallsUno = null;
const winners = [];
this.roundConfig = {
isFinished: false,
isTurnClockwise,
players,
turn,
drawPile,
discardPile,
playersCards,
mustCallsUno,
winners,
};
// Take the first discard card from the drawing pile.
const firstDiscard = () => {
discardPile.push(drawPile.pop());
if (actionCards.includes(discardPile[0].symbol)) {
switch (discardPile[0].symbol) {
case 'r':
this.roundConfig.isTurnClockwise = false;
// falls through
case 's':
this.endTurn();
break;
case '+2':
this.draw(2);
break;
case '+4':
drawPile.push(discardPile.pop());
shuffle(drawPile);
firstDiscard();
break;
default:
break;
}
}
};
firstDiscard();
return this.roundConfig;
}
/**
* Draw a specified amount of cards from the draw pile to the specified
* player.\
* Returns an array of cards that drawed.
* @param {number} [amount] - The amount of the cards to be drawed.
* @param {number} playerId - The index or the name of the player.
*/
draw(amount = 1, playerId = this.roundConfig?.turn) {
if (!this.roundConfig || this.roundConfig?.isFinished) {
throw new Error('No available round found in this game');
}
// If the draw pile doesn't have enough card to be drawed,
// take all cards from the discard pile but the last card and reshuffle it
// along with the cards from the draw pile.
this.#checksUnoCall();
if (amount > this.drawPile.length) {
this.drawPile
.push(this.discardPile.splice(0, this.discardPile.length - 1)[0]);
shuffle(this.drawPile);
}
const drawedCards = this.drawPile.splice(-amount, amount);
this.getPlayerCards(playerId)
.push(...drawedCards);
// Handle if the player in turn should play the drawed card or just
// skip to next player turn.
if (
playerId === this.turn
&& !(amount === 1
&& this.isPlayable(drawedCards))
) this.endTurn();
return null;
}
/**
* Play a card; Put the played card into the discard pile.\
* Automatically trigger end current turn if playing action cards.
* Returns the played card.
* @param {number} cardId - The index of the card in the player's cards that
* will be played.
* @param {string} [color] - The color for the next turn if the player plays
* wild card. You can omit this parameter by providing `null` instead.
* @param {number} [playerId] - The index of the player, default to the index
* of the current player in turn.
* @returns {Card}
*/
play(cardId, color = null, playerId = this.roundConfig?.turn) {
if (!this.roundConfig || this.roundConfig?.isFinished) {
throw new Error('No available round found in this game');
}
if (playerId !== this.roundConfig.turn) {
throw new Error(
`Is not currently ${this.players[playerId]} turn.`
+ ' Cannot jump-in',
);
}
if (cardId < 0 || cardId >= this.getPlayerCards(playerId).length) {
throw new RangeError(`No card found with the specified id: ${cardId}`);
}
this.#checksUnoCall();
const willPlay = this.getPlayerCards(playerId)[cardId];
if (willPlay.color === 'wild') {
if (!color) {
throw new Error('Must specify color param if plays the wild card');
} else if (!'red,yellow,green,blue'.split(',').includes(color)) {
throw new RangeError(`Invalid color value: ${color}`);
}
}
const lastCard = this.discardPile.slice(-1);
// Play the cards if it match the conditions.
if (
this.isPlayable(willPlay)
// For if the first discard is a wild card because its color prop
// is still `wild` (not yet changed to the 4 valid colors props)
// A wild card will automatically change its color properties after
// the player decides what color to play next.
|| lastCard.color === 'wild'
) this.discardPile.push(this.getPlayerCards(playerId).splice(cardId, 1)[0]);
else throw new Error('The specified card is not currently playable');
// If the played card is a wild card,
// Change its color to the specified color by the player
willPlay.color = color ?? willPlay.color;
// Handle playing action cards.
if (actionCards.includes(willPlay.symbol)) {
this.#cardsActions[willPlay.symbol]();
} else this.endTurn();
return willPlay;
}
/**
* Checks if the specified cards is playable.\
* If the subject is a single card, returns the card if playable.\
* If the subejct is an array of cards, returns an array of playable cards.\
* Otherwise return `false`.
* @param {Card|Card[]} cards - The Card or array of cards to check.
* @returns {Card|Card[]|boolean}
*/
isPlayable(cards) {
if (!this.roundConfig || this.roundConfig?.isFinished) {
throw new Error('No available round found in this game');
}
const lastCard = this.roundConfig.discardPile.slice(-1)[0];
if (cards instanceof Array) {
return cards.map((card) => {
if (
lastCard.symbol === card.symbol
|| lastCard.color === card.color
|| card.color === 'wild'
) return card;
return false;
});
} if (cards instanceof Card) {
if (
lastCard.symbol === cards.symbol
|| lastCard.color === cards.color
|| cards.color === 'wild'
) return cards;
return false;
}
throw new TypeError(
'Parameter `cards` cannot accept the received object type. '
+ 'Accepted types: `Card` or `Array<Card>`',
);
}
/**
* Calls 'UNO' for the specified player after playing their penultimate card
* to avoids penalties and warns other players.
* @param {number} playerId - The index of the player who will calls 'UNO'.
*/
callUno(playerId) {
if (!this.roundConfig || this.roundConfig?.isFinished) {
throw new Error('No available round found in this game');
}
if (playerId === this.roundConfig.mustCallsUno) {
this.roundConfig.mustCallsUno = null;
} else this.#checksUnoCall();
}
/**
* Checks whether the previous player has to call 'UNO' or not and applies the
* penalties if the player with one remaining card didn't call 'UNO'.
*/
#checksUnoCall() {
if (!this.roundConfig || this.roundConfig?.isFinished) {
throw new Error('No available round found in this game');
}
// Checks if there is any player who must calls 'UNO' because they have only
// one card remaining.
if (this.roundConfig.mustCallsUno !== null) {
// The previous player get a penalty because didn't calls 'UNO' until the
// the next player plays.
this.getPlayerCards(this.roundConfig.mustCallsUno)
.push(...this.drawPile.splice(-2));
this.roundConfig.mustCallsUno = null;
}
}
/**
* End current player's turn.
*/
endTurn() {
if (!this.roundConfig || this.roundConfig?.isFinished) {
throw new Error('No available round found in this game');
}
const { roundConfig, players } = this;
const currPlayer = roundConfig.turn;
// Checks if the current player in turn has only one remaining card.
if (this.getPlayerCards(currPlayer).length === 1) {
roundConfig.mustCallsUno = currPlayer;
} else if (this.getPlayerCards(currPlayer).length === 0) {
roundConfig.winners.push(
players[roundConfig.turn],
);
roundConfig.players[roundConfig.turn] = false;
if (players.length < 2) {
roundConfig.winners.push(players[roundConfig.players.indexOf(true)]);
roundConfig.isFinished = true;
return null;
}
}
do {
// Switch to the next turn
roundConfig.turn += this.roundConfig.isTurnClockwise ? 1 : -1;
if (roundConfig.turn < 0) {
roundConfig.turn = players.length - 1;
} else if (roundConfig.turn >= players.length) {
roundConfig.turn = 0;
}
} while (!roundConfig.players[roundConfig.turn]);
return null;
}
/**
* Lists all actions cards and its corresponding side effects/actions methods.
*/
#cardsActions = {
r: this.#reverseEffect.bind(this),
s: this.#skipEffect.bind(this),
w: (() => { this.endTurn(); }),
'+2': this.#drawTwoEffect.bind(this),
'+4': this.#drawFourEffect.bind(this),
};
/**
* Triggers the side effect/action for reverse cards.
*/
#reverseEffect() {
this.roundConfig.isTurnClockwise = !this.roundConfig.isTurnClockwise;
if (this.players.length !== 2) this.endTurn();
}
/**
* Triggers the side effect/action for skip carda.
*/
#skipEffect() {
this.endTurn();
this.endTurn();
}
/**
* Triggers the side effect/action for draw two cards.
*/
#drawTwoEffect() {
this.endTurn();
this.draw(2);
this.endTurn();
}
/**
* Triggers the side effect/action for wild draw four cards.
*/
#drawFourEffect() {
this.endTurn();
this.draw(4);
this.endTurn();
}
}