From 8ad0eca8dbbcfdcddcc5f89731658a130e650983 Mon Sep 17 00:00:00 2001 From: Sagnik Mandal Date: Tue, 11 Jun 2024 20:52:31 +0530 Subject: [PATCH] gameEvent: Implement Throw Card Event Adds the throw card event handler. Fixes #44 Signed-off-by: Sagnik Mandal --- ARCHITECTURE.md | 9 +- backend/src/types.d.ts | 2 +- backend/src/uno-game-engine/deck.ts | 2 +- backend/src/uno-game-engine/engine.ts | 16 +-- .../src/uno-game-engine/events/throwCard.ts | 97 +++++++++++++++++++ backend/tests/deck.test.ts | 4 +- backend/tests/engine.test.ts | 72 +++++++++++++- 7 files changed, 181 insertions(+), 21 deletions(-) create mode 100644 backend/src/uno-game-engine/events/throwCard.ts diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index f96f292..1afa91e 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -31,13 +31,12 @@ format of the message is: ```json { - "type": "DRAW_CARD", - "player": "player1", - "data": { - "cardID": "red-5" - } + "type": "THROW_CARD", + "playerId": "1", + "cardId": "card-number-red-5", } ``` +Other possible values for `type` are `DRAW_CARD`, `ANNOUNCE_UNO`, etc. When such a request reaches the server, the server updates the game state and sends the message to all clients (through the polling mechanism). The clients update their game state accordingly, and make the necessary changes to their UI and game state. diff --git a/backend/src/types.d.ts b/backend/src/types.d.ts index a75b4e8..e5c320b 100644 --- a/backend/src/types.d.ts +++ b/backend/src/types.d.ts @@ -1,7 +1,7 @@ // We declare those types which are used throughout the application here. // For types that are used only in one file, we can declare them in that file itself. -type CardType = 'number' | 'special' | 'wild'; +type CardType = 'number' | 'special'; type CardColor = 'red' | 'blue' | 'green' | 'yellow' | 'wild'; diff --git a/backend/src/uno-game-engine/deck.ts b/backend/src/uno-game-engine/deck.ts index 5571f8a..7e01626 100644 --- a/backend/src/uno-game-engine/deck.ts +++ b/backend/src/uno-game-engine/deck.ts @@ -45,7 +45,7 @@ export function getShuffledCardDeck(): Array { if (color === 'wild') { wildCardValues.forEach((value) => { for (let i = 0; i < 4; i++) { - deck.push(makeCard('wild', color, value)); + deck.push(makeCard('special', color, value)); } }); } else { diff --git a/backend/src/uno-game-engine/engine.ts b/backend/src/uno-game-engine/engine.ts index 0c97d04..096dc65 100644 --- a/backend/src/uno-game-engine/engine.ts +++ b/backend/src/uno-game-engine/engine.ts @@ -1,7 +1,7 @@ import { getShuffledCardDeck, shuffle } from './deck'; import { handleEvent } from './gameEvents'; -const NUM_CARDS_PER_PLAYER = 7; +export const NUM_CARDS_PER_PLAYER = 7; export class GameEngine { cardDeck: UNOCard[]; @@ -47,9 +47,9 @@ export class GameEngine { this.currentPlayerIndex = (this.currentPlayerIndex + this.direction) % this.players.length; } - drawCardFromDeck(player: Player): EventResult { + drawCardFromDeck(player: Player, numCards = 1): EventResult { try { - if (this.cardDeck.length === 0) { + if (this.cardDeck.length < numCards) { this.cardDeck = [...this.thrownCards]; this.thrownCards = []; shuffle(this.cardDeck); @@ -58,15 +58,15 @@ export class GameEngine { (p: Player) => p.id === player.id ); if (currentPlayer && this.cardDeck) { - const card = this.cardDeck.pop(); - if (card) { - currentPlayer.cards.push(card); + const cards = this.cardDeck.splice(-numCards, numCards); + if (cards.length > 0) { + currentPlayer.cards.push(...cards); return { type: 'SUCCESS', - message: 'Card drawn successfully', + message: `${numCards} card(s) drawn successfully`, }; } else - return { type: 'ERROR', message: 'Unable to draw a card' }; + return { type: 'ERROR', message: `Unable to draw ${numCards} card(s)` }; } else { return { type: 'ERROR', diff --git a/backend/src/uno-game-engine/events/throwCard.ts b/backend/src/uno-game-engine/events/throwCard.ts new file mode 100644 index 0000000..8c61a96 --- /dev/null +++ b/backend/src/uno-game-engine/events/throwCard.ts @@ -0,0 +1,97 @@ +import { registerEventHandler } from '../gameEvents'; +import { GameEngine } from '../engine'; + +function findPlayer(game: GameEngine, playerId: string) { + return game.players.find((p) => p.id === playerId); +} + +function findCard(player, cardId: string) { + return player.cards.find((c) => c.id === cardId); +} + +function canThrowCard(game: GameEngine, player, card): EventResult { + const { currentPlayerIndex, players } = game; + const currentPlayer = players[currentPlayerIndex]; + + // check if the player is the current player + if (currentPlayer.id !== player.id) { + return { type: 'ERROR', message: 'It is not your turn' }; + } + + // check if the player actually possesses the card + if (!player.cards.some((c) => c.id === card.id)) { + return { type: 'ERROR', message: 'Player does not possess the card' }; + } + + // check if the card can be thrown + if ( + game.lastThrownCard && + !( + game.lastThrownCard.color === card.color || + game.lastThrownCard.value === card.value || + card.color === 'wild' + ) + ) { + return { type: 'ERROR', message: 'Cannot throw this card' }; + } + + return { type: 'SUCCESS', message: 'Can throw card' }; +} + +export function throwCard(game: GameEngine, event: GameEvent): EventResult { + const player = findPlayer(game, event.playerId); + if (!player) { + return { type: 'ERROR', message: 'Player not found' }; + } + + const card = findCard(player, event.data.card.id); + if (!card) { + return { type: 'ERROR', message: 'Card not found' }; + } + + const canThrow = canThrowCard(game, player, card); + if (canThrow.type === 'ERROR') { + return canThrow; + } + + player.cards = player.cards.filter((c) => c.id !== card.id); + + game.thrownCards.push(card); + game.lastThrownCard = card; + + game.nextPlayer(); + + // handle special cards + if (card.type === 'special') { + handleSpecialCard(game, card); + } + + return { type: 'SUCCESS', message: 'Card thrown successfully' }; +} + +function handleSpecialCard(game: GameEngine, card: UNOCard) { + switch (card.value) { + case 'skip': + game.nextPlayer(); + break; + case 'reverse': + game.direction *= -1; + break; + case 'draw2': + { + const nextPlayer = game.players[game.currentPlayerIndex]; + game.drawCardFromDeck(nextPlayer, 2); + } + break; + case 'draw4': + { + const currentPlayer = game.players[game.currentPlayerIndex]; + game.drawCardFromDeck(currentPlayer, 4); + } + break; + default: + break; + } +} + +registerEventHandler('THROW_CARD', throwCard); diff --git a/backend/tests/deck.test.ts b/backend/tests/deck.test.ts index 686b7c4..baa7b0a 100644 --- a/backend/tests/deck.test.ts +++ b/backend/tests/deck.test.ts @@ -14,9 +14,9 @@ describe('getShuffledCardDeck', () => { expect(numberCards.length).toBe(76); const specialCards = deck.filter((card) => card.type === 'special'); - expect(specialCards.length).toBe(24); + expect(specialCards.length).toBe(32); - const wildCards = deck.filter((card) => card.type === 'wild'); + const wildCards = deck.filter((card) => card.color === 'wild'); expect(wildCards.length).toBe(8); } { diff --git a/backend/tests/engine.test.ts b/backend/tests/engine.test.ts index 2d6225f..2f61425 100644 --- a/backend/tests/engine.test.ts +++ b/backend/tests/engine.test.ts @@ -1,8 +1,21 @@ -import { GameEngine } from '../src/uno-game-engine/engine'; +import { GameEngine, NUM_CARDS_PER_PLAYER } from '../src/uno-game-engine/engine'; + +function generateMockPlayers(numPlayers: number) { + const players: Player[] = []; + for (let i = 0; i < numPlayers; i++) { + players.push({ id: i.toString(), cards: [] }); + } + return players; +} + +describe('GameEngine', () => { + let game: GameEngine; + + beforeEach(() => { + game = new GameEngine(); + }); -describe('testing drawCardFromDeck()', () => { test('draws a card when deck is empty but thrownCards is not', () => { - const game = new GameEngine(); game.addPlayer({ id: '1', cards: [] }); game.cardDeck = []; game.thrownCards = [ @@ -18,4 +31,55 @@ describe('testing drawCardFromDeck()', () => { expect(player.cards.length).toBe(1); expect(status.type).toBe('SUCCESS'); }); -}); + + test('allotCards() throws an error when there are not enough cards to distribute', () => { + const MAX_CARDS = 108; + const MAXIMUM_PLAYERS = Math.ceil(MAX_CARDS / NUM_CARDS_PER_PLAYER) + 1; + game.players = generateMockPlayers(MAXIMUM_PLAYERS); + expect(() => game.allotCards()).toThrow('Not enough cards to distribute'); + }); + + test('addPlayer() adds a player to the game', () => { + game.addPlayer({ id: '1', cards: [] }); + expect(game.players.length).toBe(1); + }); + + test('startGame() throws an error when there are not enough players', () => { + expect(() => game.startGame()).toThrow('Not enough players to start the game'); + }); + + test('startGame() changes the status to STARTED when there are enough players', () => { + game.players = generateMockPlayers(2); + game.startGame(); + expect(game.status).toBe('STARTED'); + }); + + test('nextPlayer() changes the current player index', () => { + game.players = generateMockPlayers(2); + + // Initial player index is 0, so next player should be 1 + game.nextPlayer(); + expect(game.currentPlayerIndex).toBe(1); + + // As only 2 players are there, next player should loop back to 0 + game.nextPlayer(); + expect(game.currentPlayerIndex).toBe(0); + }); + + test('drawCardFromDeck() draws a card from the deck', () => { + game.players = generateMockPlayers(2); + const player = game.players[0]; + const status = game.drawCardFromDeck(player); + expect(game.cardDeck.length).toBe(107); + expect(player.cards.length).toBe(1); + expect(status.type).toBe('SUCCESS'); + + expect(game.cardDeck).not.toContain(player.cards[0]); + }); + + test('drawCardFromDeck() returns an error when the player is not found', () => { + const status = game.drawCardFromDeck({ id: '1', cards: [] }); + expect(status.type).toBe('ERROR'); + expect(status.message).toBe('Player not found or cardDeck is empty'); + }); +}); \ No newline at end of file