From fbb9905fd3bbae3b9f4024985a5121a5f419fae5 Mon Sep 17 00:00:00 2001 From: Divyansh Seth Date: Thu, 20 Jun 2024 22:38:11 +0530 Subject: [PATCH] GameEvent: created Announce UNO event Details: - Created `announced_uno` event. - Added `runningEvents` with `vulnerableToUNO` and `hasAnnouncedUNO` subfields in engine.ts. - Created `getThrowableCards()` in eventHandlerUtils. - Handled corner cases related to the `announced_uno` event. - Created tests for the `announced_uno` event and its corner cases. Fixes: #132 --- backend/src/types.ts | 17 ++- backend/src/uno-game-engine/engine.ts | 17 ++- .../src/uno-game-engine/events/announceUno.ts | 39 ++++++ .../src/uno-game-engine/events/drawCard.ts | 5 + .../events/eventHandlerUtils.ts | 20 ++- .../src/uno-game-engine/events/joinGame.ts | 5 +- .../src/uno-game-engine/events/throwCard.ts | 28 ++-- backend/src/uno-game-engine/gameEvents.ts | 2 + backend/tests/engine.test.ts | 7 +- backend/tests/events.test.ts | 130 ++++++++++++++++++ frontend/src/channel.ts | 1 + 11 files changed, 248 insertions(+), 23 deletions(-) create mode 100644 backend/src/uno-game-engine/events/announceUno.ts create mode 100644 backend/tests/events.test.ts diff --git a/backend/src/types.ts b/backend/src/types.ts index 9b78f22..50c7a72 100644 --- a/backend/src/types.ts +++ b/backend/src/types.ts @@ -11,6 +11,7 @@ export type SpecialCardName = | 'draw2' | 'draw4' | 'colchange'; + export type CardNumber = | '0' | '1' @@ -37,6 +38,11 @@ export type Player = { cards: UNOCard[]; }; +export type RunningEvents = { + vulnerableToUNO: Player | null; + hasAnnouncedUNO: Player | null; +}; + export type EventResult = { type: 'SUCCESS' | 'ERROR'; message: string; @@ -47,16 +53,14 @@ export enum GameEventTypes { THROW_CARD = 'THROW_CARD', JOIN_GAME = 'JOIN_GAME', LEAVE_GAME = 'LEAVE_GAME', + ANNOUNCE_UNO = 'ANNOUNCE_UNO', STATE_SYNC = 'STATE_SYNC', } - export type GameEvent = | { type: GameEventTypes.DRAW_CARD; playerId: string; - data: { - cardId: string; - }; + data?: null; } | { type: GameEventTypes.THROW_CARD; @@ -75,6 +79,11 @@ export type GameEvent = playerId: string; data?: null; } + | { + type: GameEventTypes.ANNOUNCE_UNO; + playerId: string; + data?: null; + } | { type: GameEventTypes.STATE_SYNC; data: { diff --git a/backend/src/uno-game-engine/engine.ts b/backend/src/uno-game-engine/engine.ts index c8acc33..a0dcacf 100644 --- a/backend/src/uno-game-engine/engine.ts +++ b/backend/src/uno-game-engine/engine.ts @@ -1,4 +1,10 @@ -import type { EventResult, GameEvent, Player, UNOCard } from '../types'; +import type { + EventResult, + GameEvent, + Player, + RunningEvents, + UNOCard, +} from '../types'; import { getShuffledCardDeck, shuffle } from './deck'; import { handleEvent } from './gameEvents'; @@ -14,6 +20,7 @@ export class GameEngine { currentColor: number; direction: number; status: 'READY' | 'STARTED'; + runningEvents: RunningEvents; constructor(id: string) { this.id = id; @@ -24,6 +31,10 @@ export class GameEngine { this.lastThrownCard = null; this.currentColor = 0; this.direction = 1; + this.runningEvents = { + vulnerableToUNO: null, + hasAnnouncedUNO: null, + }; this.status = 'READY'; } @@ -51,7 +62,6 @@ export class GameEngine { (this.players.length + this.currentPlayerIndex + this.direction) % this.players.length; } - removePlayer(player: Player) { this.cardDeck.push(...player.cards); shuffle(this.cardDeck); @@ -73,6 +83,9 @@ export class GameEngine { const cards = this.cardDeck.splice(-numCards, numCards); if (cards.length > 0) { currentPlayer.cards.push(...cards); + + // make the last player no longer vulnerable to UNO + this.runningEvents.vulnerableToUNO = null; return { type: 'SUCCESS', message: `${numCards} card(s) drawn successfully`, diff --git a/backend/src/uno-game-engine/events/announceUno.ts b/backend/src/uno-game-engine/events/announceUno.ts new file mode 100644 index 0000000..8215626 --- /dev/null +++ b/backend/src/uno-game-engine/events/announceUno.ts @@ -0,0 +1,39 @@ +// The game continues until a player has one or two card(s) left. +// Once a player has two cards, they can announce "UNO!". +// The announcement of "UNO" needs to be repeated every time the player is left with one or two cards. +import assert from 'assert'; +import { EventResult, GameEvent, GameEventTypes, Player } from '../../types'; +import { GameEngine } from '../engine'; +import { getPlayer, getThrowableCards } from './eventHandlerUtils'; + +export function canAnnounceUNO(game: GameEngine, player: Player): EventResult { + const throwableCards = getThrowableCards(game, player); + if ( + player.cards.length > 2 || + (player.cards.length === 2 && !throwableCards) + ) { + return { type: 'ERROR', message: 'Cannot announce UNO' }; + } + + return { type: 'SUCCESS', message: 'Can announce UNO' }; +} + +export function announceUNO(game: GameEngine, event: GameEvent): EventResult { + assert(event.type === GameEventTypes.ANNOUNCE_UNO, 'Invalid event type'); + const player = getPlayer(game, event.playerId); + if (!player) { + return { type: 'ERROR', message: 'Player not found' }; + } + + const canAnnounce = canAnnounceUNO(game, player); + + if (canAnnounce.type === 'ERROR') { + return canAnnounce; + } + + // if the player has announced UNO, they are no longer vulnerable to UNO + game.runningEvents.hasAnnouncedUNO = player; + game.runningEvents.vulnerableToUNO = null; + + return { type: 'SUCCESS', message: 'UNO announced successfully' }; +} diff --git a/backend/src/uno-game-engine/events/drawCard.ts b/backend/src/uno-game-engine/events/drawCard.ts index d93aa05..ba60b52 100644 --- a/backend/src/uno-game-engine/events/drawCard.ts +++ b/backend/src/uno-game-engine/events/drawCard.ts @@ -22,6 +22,11 @@ export function drawCard(game: GameEngine, event: GameEvent): EventResult { return drawResult; } + if (player.cards.length === 2) { + game.runningEvents.hasAnnouncedUNO = null; + game.runningEvents.vulnerableToUNO = null; + } + const drawnCard = player.cards[player.cards.length - 1]; // Last drawn card // Check if the drawn card can be thrown diff --git a/backend/src/uno-game-engine/events/eventHandlerUtils.ts b/backend/src/uno-game-engine/events/eventHandlerUtils.ts index 4027075..6afb1ef 100644 --- a/backend/src/uno-game-engine/events/eventHandlerUtils.ts +++ b/backend/src/uno-game-engine/events/eventHandlerUtils.ts @@ -11,6 +11,21 @@ export function getPlayerCard(player: Player, cardId: string) { return player.cards.find((c) => c.id === cardId); } +export function getThrowableCards(game: GameEngine, player: Player) { + // get the cards that the player can throw + const { lastThrownCard } = game; + const throwableCards = player.cards.filter((card) => { + return ( + !lastThrownCard || + card.color === lastThrownCard.color || + card.value === lastThrownCard.value || + card.type === 'wild' + ); + }); + + return throwableCards; +} + export function checkCurrentPlayer( game: GameEngine, player: Player @@ -23,5 +38,8 @@ export function checkCurrentPlayer( return { type: 'ERROR', message: 'It is not your turn' }; } - return { type: 'SUCCESS', message: 'Can draw/throw card' }; + return { + type: 'SUCCESS', + message: 'Player is the current player', + }; } diff --git a/backend/src/uno-game-engine/events/joinGame.ts b/backend/src/uno-game-engine/events/joinGame.ts index 468c817..e936167 100644 --- a/backend/src/uno-game-engine/events/joinGame.ts +++ b/backend/src/uno-game-engine/events/joinGame.ts @@ -4,7 +4,10 @@ import { EventResult, GameEvent, Player } from '../../types'; export function joinGame(game: GameEngine, event: GameEvent): EventResult { assert(event.type === 'JOIN_GAME', 'Invalid event type'); - const player: Player = { id: event.playerId, cards: [] }; + const player: Player = { + id: event.playerId, + cards: [], + }; game.addPlayer(player); return { type: 'SUCCESS', message: 'player joined successfully' }; } diff --git a/backend/src/uno-game-engine/events/throwCard.ts b/backend/src/uno-game-engine/events/throwCard.ts index 7838ad9..c84a9f6 100644 --- a/backend/src/uno-game-engine/events/throwCard.ts +++ b/backend/src/uno-game-engine/events/throwCard.ts @@ -5,6 +5,7 @@ import { checkCurrentPlayer, getPlayer, getPlayerCard, + getThrowableCards, } from './eventHandlerUtils'; export function canThrowCard( @@ -17,20 +18,9 @@ export function canThrowCard( if (canThrow.type === 'ERROR') { return canThrow; } - // 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' - ) - ) { + const throwableCards = getThrowableCards(game, player); + if (!throwableCards.find((c) => c.id === card.id)) { return { type: 'ERROR', message: 'Cannot throw this card' }; } @@ -55,8 +45,20 @@ export function throwCard(game: GameEngine, event: GameEvent): EventResult { return canThrow; } + // make the last player no longer vulnerable to UNO + game.runningEvents.vulnerableToUNO = null; + player.cards = player.cards.filter((c) => c.id !== card.id); + // After throwing a card, check if the player has one card and forgot to announce UNO + if ( + player.cards.length === 1 && + game.runningEvents.hasAnnouncedUNO !== player + ) { + game.runningEvents.vulnerableToUNO = player; + } + + game.runningEvents.hasAnnouncedUNO = null; game.thrownCards.push(card); game.lastThrownCard = card; diff --git a/backend/src/uno-game-engine/gameEvents.ts b/backend/src/uno-game-engine/gameEvents.ts index b689a85..8654d3e 100644 --- a/backend/src/uno-game-engine/gameEvents.ts +++ b/backend/src/uno-game-engine/gameEvents.ts @@ -2,6 +2,7 @@ import { EventResult, GameEvent, GameEventTypes } from '../types'; import { type GameEngine } from './engine'; +import { announceUNO } from './events/announceUno'; import { drawCard } from './events/drawCard'; import { joinGame } from './events/joinGame'; import { leaveGame } from './events/leaveGame'; @@ -30,3 +31,4 @@ registerEventHandler(GameEventTypes.JOIN_GAME, joinGame); registerEventHandler(GameEventTypes.LEAVE_GAME, leaveGame); registerEventHandler(GameEventTypes.DRAW_CARD, drawCard); registerEventHandler(GameEventTypes.THROW_CARD, throwCard); +registerEventHandler(GameEventTypes.ANNOUNCE_UNO, announceUNO); diff --git a/backend/tests/engine.test.ts b/backend/tests/engine.test.ts index 536f9e2..a681c67 100644 --- a/backend/tests/engine.test.ts +++ b/backend/tests/engine.test.ts @@ -4,7 +4,7 @@ import { NUM_CARDS_PER_PLAYER, } from '../src/uno-game-engine/engine'; -function generateMockPlayers(numPlayers: number) { +export function generateMockPlayers(numPlayers: number) { const players: Player[] = []; for (let i = 0; i < numPlayers; i++) { players.push({ id: i.toString(), cards: [] }); @@ -86,7 +86,10 @@ describe('GameEngine', () => { }); test('drawCardFromDeck() returns an error when the player is not found', () => { - const status = game.drawCardFromDeck({ id: '1', cards: [] }); + const status = game.drawCardFromDeck({ + id: '1', + cards: [], + }); expect(status.type).toBe('ERROR'); expect(status.message).toBe('Player not found or cardDeck is empty'); }); diff --git a/backend/tests/events.test.ts b/backend/tests/events.test.ts new file mode 100644 index 0000000..6e48761 --- /dev/null +++ b/backend/tests/events.test.ts @@ -0,0 +1,130 @@ +import { CardNumber, GameEventTypes } from '../src/types'; +import { GameEngine } from '../src/uno-game-engine/engine'; +import { announceUNO } from '../src/uno-game-engine/events/announceUno'; +import { drawCard } from '../src/uno-game-engine/events/drawCard'; +import { getPlayer } from '../src/uno-game-engine/events/eventHandlerUtils'; +import { throwCard } from '../src/uno-game-engine/events/throwCard'; +import { generateMockPlayers } from './engine.test'; + +export const initializeMockGame = ( + game: GameEngine, + numPlayers: number, + numCards: number, + currentPlayerIndex: number +) => { + generateMockPlayers(numPlayers).forEach((player, index) => { + game.addPlayer(player); + for (let i = 1; i <= numCards; i++) { + player.cards.push({ + type: 'number', + color: 'yellow', + value: `${index}` as CardNumber, + id: `card-number-yellow-${index}-${i}`, + }); + } + }); + game.currentPlayerIndex = currentPlayerIndex; +}; + +describe('Events', () => { + let game: GameEngine; + + beforeEach(() => { + game = new GameEngine('dummyGame'); + }); + + test('Announce UNO when player has only two cards and at least one of them is throwable', () => { + // when player announces UNO first, then throws a card + initializeMockGame(game, 3, 2, 1); + const AnnounceStatus = announceUNO(game, { + type: GameEventTypes.ANNOUNCE_UNO, + playerId: '1', + }); + + // after announcing UNO the player is no more vulnerable + expect(AnnounceStatus.type).toBe('SUCCESS'); + expect(game.runningEvents.hasAnnouncedUNO).toBe(getPlayer(game, '1')); + expect(game.runningEvents.vulnerableToUNO).toBe(null); + + const throwStatus = throwCard(game, { + type: GameEventTypes.THROW_CARD, + playerId: '1', + data: { cardId: 'card-number-yellow-1-1' }, + }); + + expect(throwStatus.type).toBe('SUCCESS'); + expect(game.runningEvents.hasAnnouncedUNO).toBe(null); + expect(game.runningEvents.vulnerableToUNO).toBe(null); + }); + + test('Announce UNO when player has only two cards and at least one of them is throwable', () => { + // when player throws a card first, then announces UNO + initializeMockGame(game, 3, 2, 1); + const throwStatus = throwCard(game, { + type: GameEventTypes.THROW_CARD, + playerId: '1', + data: { cardId: 'card-number-yellow-1-1' }, + }); + + expect(throwStatus.type).toBe('SUCCESS'); + expect(game.runningEvents.hasAnnouncedUNO).toBe(null); + expect(game.runningEvents.vulnerableToUNO).toBe(getPlayer(game, '1')); + + const AnnounceStatus = announceUNO(game, { + type: GameEventTypes.ANNOUNCE_UNO, + playerId: '1', + }); + + // after announcing UNO the player is no more vulnerable + expect(AnnounceStatus.type).toBe('SUCCESS'); + expect(game.runningEvents.hasAnnouncedUNO).toBe(getPlayer(game, '1')); + expect(game.runningEvents.vulnerableToUNO).toBe(null); + }); + + test('Player did not announce UNO, and the next player threw a card without catching him', () => { + initializeMockGame(game, 3, 2, 1); + const throwStatus1 = throwCard(game, { + type: GameEventTypes.THROW_CARD, + playerId: '1', + data: { cardId: 'card-number-yellow-1-1' }, + }); + + expect(throwStatus1.type).toBe('SUCCESS'); + expect(game.runningEvents.hasAnnouncedUNO).toBe(null); + expect(game.runningEvents.vulnerableToUNO).toBe(getPlayer(game, '1')); + + const throwStatus2 = throwCard(game, { + type: GameEventTypes.THROW_CARD, + playerId: '2', + data: { cardId: 'card-number-yellow-2-1' }, + }); + expect(throwStatus2.type).toBe('SUCCESS'); + expect(game.runningEvents.hasAnnouncedUNO).toBe(null); + expect(game.runningEvents.vulnerableToUNO).not.toBe( + getPlayer(game, '1') + ); + }); + + test('Player did not announce UNO, and the next player drew a card without catching him', () => { + initializeMockGame(game, 3, 2, 1); + const throwStatus = throwCard(game, { + type: GameEventTypes.THROW_CARD, + playerId: '1', + data: { cardId: 'card-number-yellow-1-1' }, + }); + + expect(throwStatus.type).toBe('SUCCESS'); + expect(game.runningEvents.hasAnnouncedUNO).toBe(null); + expect(game.runningEvents.vulnerableToUNO).toBe(getPlayer(game, '1')); + + const drawStatus = drawCard(game, { + type: GameEventTypes.DRAW_CARD, + playerId: '2', + }); + expect(drawStatus.type).toBe('SUCCESS'); + expect(game.runningEvents.hasAnnouncedUNO).toBe(null); + expect(game.runningEvents.vulnerableToUNO).not.toBe( + getPlayer(game, '1') + ); + }); +}); diff --git a/frontend/src/channel.ts b/frontend/src/channel.ts index 191c0ab..47ed760 100644 --- a/frontend/src/channel.ts +++ b/frontend/src/channel.ts @@ -10,6 +10,7 @@ const GAME_EVENTS = { JOIN_GAME: 'JOIN_GAME', LEAVE_GAME: 'LEAVE_GAME', STATE_SYNC: 'STATE_SYNC', + ANNOUNCE_UNO: 'ANNOUNCE_UNO', }; let authCreds: { jwt: string; playerId: string } | null = null;