diff --git a/backend/nodemon.json b/backend/nodemon.json new file mode 100644 index 0000000..655cab7 --- /dev/null +++ b/backend/nodemon.json @@ -0,0 +1,6 @@ +{ + "watch": ["src/**/*.ts", "src/**/*.js"], + "ext": "js,ts", + "ignore": ["**/*.test.ts", "**/*.spec.ts", "node_modules"], + "exec": "ts-node ./src/index.ts" +} diff --git a/backend/src/controllers/gameControllers.ts b/backend/src/controllers/gameControllers.ts index 8c737ca..8f585ef 100644 --- a/backend/src/controllers/gameControllers.ts +++ b/backend/src/controllers/gameControllers.ts @@ -1,7 +1,7 @@ import { Response } from 'express'; import { enqueueForSend } from '../eventRecipients'; import { AuthRequest } from '../middlewares/authMiddleware'; -import { retrieveGame } from '../gameStore'; +import { createGame, retrieveGame } from '../gameStore'; export async function handleGameEvent(req: AuthRequest, res: Response) { const event = req.body; @@ -35,3 +35,46 @@ export async function handleGameEvent(req: AuthRequest, res: Response) { res.status(200).send({ message: 'Event propagated to clients.' }); } } + +export async function handleGameJoin(req: AuthRequest, res: Response) { + const gameCode = req.body.code; + const activeGameId = req.user.activeGameId; + if (activeGameId) { + res.status(400).send({ + message: 'User is already playing a game', + }); + return; + } + const game = retrieveGame(gameCode); + if (!game) { + res.status(404).send({ message: 'Game not found' }); + return; + } + //note: when retrieving game from database, it is not an instance of GameEngine + // we'd need to add these functions to the mongodb game schema + game.dispatchEvent({ type: 'JOIN_GAME', playerId: req.user.id }); + req.user.activeGameId = gameCode; + await req.user.save(); + res.status(200).send({ + message: 'Game joined successfully', + gameState: game, + }); +} + +export async function handleGameCreate(req: AuthRequest, res: Response) { + const game = createGame(); + const eventResult = game.dispatchEvent({ + type: 'JOIN_GAME', + playerId: req.user.id, + }); + if (eventResult.type === 'ERROR') { + res.status(500).send({ message: 'Failed to create game' }); + return; + } + req.user.activeGameId = game.id; + await req.user.save(); + res.status(200).send({ + message: 'Game created successfully', + gameState: game, + }); +} diff --git a/backend/src/eventRecipients.ts b/backend/src/eventRecipients.ts index 4d43435..0539844 100644 --- a/backend/src/eventRecipients.ts +++ b/backend/src/eventRecipients.ts @@ -7,6 +7,7 @@ // from the queue to the client. import { Response } from 'express'; +import { AppEvent } from './types'; type ClientId = string; diff --git a/backend/src/gameStore.ts b/backend/src/gameStore.ts index a40035e..9f67c5d 100644 --- a/backend/src/gameStore.ts +++ b/backend/src/gameStore.ts @@ -4,22 +4,13 @@ import { v4 as uuid } from 'uuid'; import { GameEngine } from './uno-game-engine/engine'; const games: Map = new Map(); -/** - * Create a new game and store it in the games map - * @returns {string} gameId - */ export function createGame() { const gameId = uuid(); - const game = new GameEngine(); + const game = new GameEngine(gameId); games.set(gameId, game); - return gameId; + return game; } -/** - * Retrieve a game from the games map - * @param {string} id gameId - * @returns {GameEngine|null} GameEngine instance - */ -export function retrieveGame(id: string) { +export function retrieveGame(id: string): GameEngine | null { return games.get(id) || null; } diff --git a/backend/src/index.ts b/backend/src/index.ts index 79bf414..112d664 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -3,6 +3,8 @@ import express, { json } from 'express'; import { connect } from 'mongoose'; import cors from 'cors'; import { config } from 'dotenv'; +import userRoutes from './routes/userRoutes'; +import gameRoutes from './routes/gameRoutes'; config(); @@ -21,9 +23,9 @@ app.get('/', (req, res) => { }); //Routes -import userRoutes from './routes/userRoutes.js'; app.use('/api/v1/auth', userRoutes); +app.use('/api/v1/game', gameRoutes); app.listen(port, () => { console.log(`Server running on port ${port}`); diff --git a/backend/src/middlewares/authMiddleware.ts b/backend/src/middlewares/authMiddleware.ts index 5db773e..6cb1bbc 100644 --- a/backend/src/middlewares/authMiddleware.ts +++ b/backend/src/middlewares/authMiddleware.ts @@ -13,7 +13,6 @@ export const verifyToken = async ( ) => { try { const accessToken: string = req.body.token; - if (!accessToken) { return res.status(401).json({ error: 'Access token is required' }); } diff --git a/backend/src/routes/gameRoutes.ts b/backend/src/routes/gameRoutes.ts index f272825..57a95d4 100644 --- a/backend/src/routes/gameRoutes.ts +++ b/backend/src/routes/gameRoutes.ts @@ -1,6 +1,10 @@ import express from 'express'; import { addClient } from '../eventRecipients'; -import { handleGameEvent } from '../controllers/gameControllers'; +import { + handleGameCreate, + handleGameEvent, + handleGameJoin, +} from '../controllers/gameControllers'; import { AuthRequest, verifyToken } from '../middlewares/authMiddleware'; const router = express.Router(); @@ -15,4 +19,7 @@ router.get('/events', function (req: AuthRequest, res) { router.post('/events', handleGameEvent); +router.post('/join', handleGameJoin); +router.post('/create', handleGameCreate); + export default router; diff --git a/backend/src/routes/userRoutes.js b/backend/src/routes/userRoutes.ts similarity index 100% rename from backend/src/routes/userRoutes.js rename to backend/src/routes/userRoutes.ts diff --git a/backend/src/types.d.ts b/backend/src/types.ts similarity index 56% rename from backend/src/types.d.ts rename to backend/src/types.ts index 4e82613..b56f571 100644 --- a/backend/src/types.d.ts +++ b/backend/src/types.ts @@ -1,35 +1,54 @@ // 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'; +export type CardType = 'number' | 'special' | 'wild'; -type CardColor = 'red' | 'blue' | 'green' | 'yellow' | 'wild'; +export type CardColor = 'red' | 'blue' | 'green' | 'yellow' | 'wild'; -type SpecialCardName = 'skip' | 'reverse' | 'draw2' | 'draw4' | 'colchange'; -type CardNumber = '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9'; +export type SpecialCardName = + | 'skip' + | 'reverse' + | 'draw2' + | 'draw4' + | 'colchange'; +export type CardNumber = + | '0' + | '1' + | '2' + | '3' + | '4' + | '5' + | '6' + | '7' + | '8' + | '9'; -type CardValue = SpecialCardName | CardNumber; +export type CardValue = SpecialCardName | CardNumber; -type UNOCard = { +export type UNOCard = { type: CardType; color: CardColor; value: CardValue; id: string; }; -type Player = { +export type Player = { id: string; cards: UNOCard[]; }; -type EventResult = { +export type EventResult = { type: 'SUCCESS' | 'ERROR'; message: string; }; -type GameEventType = 'DRAW_CARD' | 'THROW_CARD' | 'JOIN_GAME' | 'LEAVE_GAME'; +export type GameEventType = + | 'DRAW_CARD' + | 'THROW_CARD' + | 'JOIN_GAME' + | 'LEAVE_GAME'; -type GameEvent = +export type GameEvent = | { type: 'DRAW_CARD'; playerId: string; @@ -47,14 +66,12 @@ type GameEvent = | { type: 'JOIN_GAME'; playerId: string; - data: null; } | { type: 'LEAVE_GAME'; playerId: string; - data: null; }; // Represent all the events that can be sent to the client -type AppEvent = GameEvent; +export type AppEvent = GameEvent; //todo: Add more events diff --git a/backend/src/uno-game-engine/deck.ts b/backend/src/uno-game-engine/deck.ts index 5571f8a..2a7cec5 100644 --- a/backend/src/uno-game-engine/deck.ts +++ b/backend/src/uno-game-engine/deck.ts @@ -1,3 +1,12 @@ +import type { + CardColor, + CardNumber, + CardType, + CardValue, + SpecialCardName, + UNOCard, +} from '../types'; + const colors: Array = ['red', 'yellow', 'green', 'blue', 'wild']; const numValues: Array = [ '0', diff --git a/backend/src/uno-game-engine/engine.ts b/backend/src/uno-game-engine/engine.ts index 80daf30..c8acc33 100644 --- a/backend/src/uno-game-engine/engine.ts +++ b/backend/src/uno-game-engine/engine.ts @@ -1,9 +1,11 @@ +import type { EventResult, GameEvent, Player, UNOCard } from '../types'; import { getShuffledCardDeck, shuffle } from './deck'; import { handleEvent } from './gameEvents'; export const NUM_CARDS_PER_PLAYER = 7; export class GameEngine { + id: string; cardDeck: UNOCard[]; thrownCards: UNOCard[]; players: Player[]; @@ -13,7 +15,8 @@ export class GameEngine { direction: number; status: 'READY' | 'STARTED'; - constructor() { + constructor(id: string) { + this.id = id; this.cardDeck = getShuffledCardDeck(); this.thrownCards = []; this.players = []; diff --git a/backend/src/uno-game-engine/events/drawCard.ts b/backend/src/uno-game-engine/events/drawCard.ts index 72685a9..2b1258b 100644 --- a/backend/src/uno-game-engine/events/drawCard.ts +++ b/backend/src/uno-game-engine/events/drawCard.ts @@ -1,11 +1,8 @@ -import { - checkCurrentPlayer, - getPlayer, - registerEventHandler, -} from '../gameEvents'; import { GameEngine } from '../engine'; import assert from 'assert'; import { canThrowCard, throwCard } from './throwCard'; +import { EventResult, GameEvent } from '../../types'; +import { checkCurrentPlayer, getPlayer } from './eventHandlerUtils'; export function drawCard(game: GameEngine, event: GameEvent): EventResult { // validate the event so that typescript knows that event is of type 'DRAW_CARD' @@ -50,5 +47,3 @@ export function drawCard(game: GameEngine, event: GameEvent): EventResult { }; } } - -registerEventHandler('DRAW_CARD', drawCard); diff --git a/backend/src/uno-game-engine/events/eventHandlerUtils.ts b/backend/src/uno-game-engine/events/eventHandlerUtils.ts new file mode 100644 index 0000000..4027075 --- /dev/null +++ b/backend/src/uno-game-engine/events/eventHandlerUtils.ts @@ -0,0 +1,27 @@ +// some utility functions shared by event handlers + +import { Player, EventResult } from '../../types'; +import { GameEngine } from '../engine'; + +export function getPlayer(game: GameEngine, playerId: string) { + return game.players.find((p) => p.id === playerId); +} + +export function getPlayerCard(player: Player, cardId: string) { + return player.cards.find((c) => c.id === cardId); +} + +export function checkCurrentPlayer( + game: GameEngine, + player: Player +): 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' }; + } + + return { type: 'SUCCESS', message: 'Can draw/throw card' }; +} diff --git a/backend/src/uno-game-engine/events/joinGame.ts b/backend/src/uno-game-engine/events/joinGame.ts index 1697a57..33fe97d 100644 --- a/backend/src/uno-game-engine/events/joinGame.ts +++ b/backend/src/uno-game-engine/events/joinGame.ts @@ -1,6 +1,6 @@ import { assert } from 'console'; import { GameEngine } from '../engine'; -import { registerEventHandler } from '../gameEvents'; +import { EventResult, GameEvent, Player } from '../../types'; export function joinGame(game: GameEngine, event: GameEvent): EventResult { assert(event.type === 'JOIN_GAME', 'Invalid event type'); @@ -8,5 +8,3 @@ export function joinGame(game: GameEngine, event: GameEvent): EventResult { game.addPlayer(player); return { type: 'SUCCESS', message: 'player joined successfully' }; } - -registerEventHandler('JOIN_GAME', joinGame); diff --git a/backend/src/uno-game-engine/events/leaveGame.ts b/backend/src/uno-game-engine/events/leaveGame.ts index 1cd1c19..c575102 100644 --- a/backend/src/uno-game-engine/events/leaveGame.ts +++ b/backend/src/uno-game-engine/events/leaveGame.ts @@ -1,6 +1,7 @@ import { assert } from 'console'; import { GameEngine } from '../engine'; -import { getPlayer, registerEventHandler } from '../gameEvents'; +import { EventResult, GameEvent } from '../../types'; +import { getPlayer } from './eventHandlerUtils'; export function leaveGame(game: GameEngine, event: GameEvent): EventResult { assert(event.type === 'LEAVE_GAME', 'Invalid event type'); @@ -11,5 +12,3 @@ export function leaveGame(game: GameEngine, event: GameEvent): EventResult { game.removePlayer(player); return { type: 'SUCCESS', message: 'player left successfully' }; } - -registerEventHandler('LEAVE_GAME', leaveGame); diff --git a/backend/src/uno-game-engine/events/throwCard.ts b/backend/src/uno-game-engine/events/throwCard.ts index c901988..1b3137d 100644 --- a/backend/src/uno-game-engine/events/throwCard.ts +++ b/backend/src/uno-game-engine/events/throwCard.ts @@ -1,11 +1,11 @@ +import { GameEngine } from '../engine'; +import assert from 'assert'; +import { EventResult, GameEvent, Player, UNOCard } from '../../types'; import { checkCurrentPlayer, getPlayer, getPlayerCard, - registerEventHandler, -} from '../gameEvents'; -import { GameEngine } from '../engine'; -import assert from 'assert'; +} from './eventHandlerUtils'; export function canThrowCard( game: GameEngine, @@ -94,5 +94,3 @@ function handleSpecialCard(game: GameEngine, card: UNOCard) { break; } } - -registerEventHandler('THROW_CARD', throwCard); diff --git a/backend/src/uno-game-engine/gameEvents.ts b/backend/src/uno-game-engine/gameEvents.ts index e2ddad2..e4175e5 100644 --- a/backend/src/uno-game-engine/gameEvents.ts +++ b/backend/src/uno-game-engine/gameEvents.ts @@ -1,6 +1,11 @@ // this module houses the handlers for various game events. +import { EventResult, GameEvent, GameEventType } from '../types'; import { type GameEngine } from './engine'; +import { drawCard } from './events/drawCard'; +import { joinGame } from './events/joinGame'; +import { leaveGame } from './events/leaveGame'; +import { throwCard } from './events/throwCard'; type GameEventHandler = (game: GameEngine, event: GameEvent) => EventResult; @@ -21,27 +26,7 @@ export function handleEvent(game: GameEngine, event: GameEvent): EventResult { return handler(game, event); } -// some utility functions shared by event handlers - -export function getPlayer(game: GameEngine, playerId: string) { - return game.players.find((p) => p.id === playerId); -} - -export function getPlayerCard(player: Player, cardId: string) { - return player.cards.find((c) => c.id === cardId); -} - -export function checkCurrentPlayer( - game: GameEngine, - player: Player -): 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' }; - } - - return { type: 'SUCCESS', message: 'Can draw/throw card' }; -} +registerEventHandler('JOIN_GAME', joinGame); +registerEventHandler('LEAVE_GAME', leaveGame); +registerEventHandler('DRAW_CARD', drawCard); +registerEventHandler('THROW_CARD', throwCard); diff --git a/backend/tests/engine.test.ts b/backend/tests/engine.test.ts index a44b92e..d2fe5a4 100644 --- a/backend/tests/engine.test.ts +++ b/backend/tests/engine.test.ts @@ -15,7 +15,7 @@ describe('GameEngine', () => { let game: GameEngine; beforeEach(() => { - game = new GameEngine(); + game = new GameEngine('dummygame'); }); test('draws a card when deck is empty but thrownCards is not', () => { diff --git a/backend/tsconfig.json b/backend/tsconfig.json index bc6b9d8..cbaaac0 100644 --- a/backend/tsconfig.json +++ b/backend/tsconfig.json @@ -11,7 +11,7 @@ // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ /* Language and Environment */ - "target": "es2016" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, + "target": "ESNext" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ // "jsx": "preserve", /* Specify what JSX code is generated. */ // "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */ diff --git a/frontend/src/contexts/AuthContext.tsx b/frontend/src/contexts/AuthContext.tsx index 67ea979..e590deb 100644 --- a/frontend/src/contexts/AuthContext.tsx +++ b/frontend/src/contexts/AuthContext.tsx @@ -21,12 +21,14 @@ export type AuthContextProps = { ) => Promise; logout: () => void; isLoggedIn: () => boolean; + jwt: string; }; export const AuthContext = createContext({ getUser: () => null, authenticate: () => new Promise(() => {}), logout: () => {}, isLoggedIn: () => false, + jwt: '', }); // eslint-disable-next-line react-refresh/only-export-components @@ -36,13 +38,13 @@ export function useAuth() { export function AuthProvider({ children }: { children: ReactElement }) { const [user, setUser] = useState(null); - const [jwt, setJwt] = useState(null); + const [jwt, setJwt] = useState(''); const toast = useToast(); useEffect(() => { + const localToken = localStorage.getItem('jwt'); async function checkExistingJWT() { try { - const localToken = localStorage.getItem('jwt'); const res = await fetch( process.env.REACT_APP_BACKEND_URL + '/auth/verify', { @@ -59,17 +61,18 @@ export function AuthProvider({ children }: { children: ReactElement }) { throw new Error('Invalid token'); } else { const data = await res.json(); + // so the jwt was valid setUser({ name: data.user.username, }); - setJwt(localToken); + setJwt(localToken!); } } catch (e) { console.info('deleting existing jwt'); localStorage.removeItem('jwt'); } } - checkExistingJWT(); + if (localToken) checkExistingJWT(); }, []); const authenticate = useCallback( @@ -106,7 +109,7 @@ export function AuthProvider({ children }: { children: ReactElement }) { const logout = useCallback(() => { setUser(null); - setJwt(null); + setJwt(''); localStorage.removeItem('jwt'); }, []); @@ -115,12 +118,18 @@ export function AuthProvider({ children }: { children: ReactElement }) { }, [user]); const isLoggedIn = useCallback(() => { - return jwt !== null; + return !!jwt; }, [jwt]); return ( {children} diff --git a/frontend/src/contexts/GameContext.tsx b/frontend/src/contexts/GameContext.tsx index ed2cdfd..aeb5adf 100644 --- a/frontend/src/contexts/GameContext.tsx +++ b/frontend/src/contexts/GameContext.tsx @@ -1,7 +1,8 @@ /* eslint-disable */ import React, { createContext, useContext, useState, useEffect } from 'react'; import Game from '../pages/Game'; -import { useLocation } from 'react-router-dom'; +import { useLocation, useNavigate } from 'react-router-dom'; +import { useAuth } from './AuthContext'; interface GameState { players: { id: number; name: string; cards: string[] }[]; @@ -33,28 +34,43 @@ export const GameProvider = () => { ); const location = useLocation(); + const navigate = useNavigate(); + const auth = useAuth(); const backendUrl = process.env.REACT_APP_BACKEND_URL; useEffect(() => { const queryParams = new URLSearchParams(location.search); const gameType = queryParams.get('type'); - - fetch(`${backendUrl}/api/game?type=${gameType}`) - .then((response) => response.json()) - .then((data) => { - setGameState(data); - }); - - // polling - const interval = setInterval(() => { - fetch(`${backendUrl}/api/game/state`) - .then((response) => response.json()) - .then((data) => { - setGameState(data); - }); - }, 5000); - - return () => clearInterval(interval); + try { + if (gameType === 'join') { + const gameCode = queryParams.get('code'); + fetch(`${backendUrl}/game/join`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ code: gameCode, token: auth.jwt }), + }) + .then((response) => response.json()) + .then((data) => { + setGameState(data.gameState); + }); + } else if (gameType === 'create') { + fetch(`${backendUrl}/game/create`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ token: auth.jwt }), + }) + .then((response) => response.json()) + .then((data) => { + setGameState(data.gameState); + }); + } + } catch (e) { + navigate('/'); + } }, [location.search]); return ( diff --git a/frontend/src/library/modal.tsx b/frontend/src/library/modal.tsx index 632d02e..dda5f4e 100644 --- a/frontend/src/library/modal.tsx +++ b/frontend/src/library/modal.tsx @@ -21,7 +21,7 @@ const Modal: React.FC = ({ onClose }) => { const handleButtonClick = () => { if (gameCode.trim()) { - navigate('/game'); + navigate('/game?type=join&code=' + gameCode); } else { open({ message: 'Please Enter The Game Code', @@ -33,7 +33,7 @@ const Modal: React.FC = ({ onClose }) => { }; const handleInputChange = (e: React.ChangeEvent) => { - setGameCode(e.target.value); + setGameCode(e.target.value.trim()); }; return ( diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index a3323d8..81d95a3 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,13 +1,12 @@ -import React from 'react'; import ReactDOM from 'react-dom/client'; import App from './App.tsx'; import './index.css'; import { ToastProvider } from './library/toast/Toast.tsx'; ReactDOM.createRoot(document.getElementById('root')!).render( - - - - - + + + + // + // ); diff --git a/frontend/src/pages/Home.tsx b/frontend/src/pages/Home.tsx index ba2da7b..d7b87bf 100644 --- a/frontend/src/pages/Home.tsx +++ b/frontend/src/pages/Home.tsx @@ -11,7 +11,7 @@ const Home: React.FC = () => { const CreateGame = () => { // Logic to create a game console.log('Create Game'); - navigate('/game'); + navigate('/game?type=create'); }; const JoinGame = () => {