diff --git a/SerpentRace_Backend/src/Api/index.ts b/SerpentRace_Backend/src/Api/index.ts index e6ac4ffe..b95bc626 100644 --- a/SerpentRace_Backend/src/Api/index.ts +++ b/SerpentRace_Backend/src/Api/index.ts @@ -14,6 +14,7 @@ import gameRouter from './routers/gameRouter'; import { LoggingService, logStartup, logConnection, logError, logRequest } from '../Application/Services/Logger'; import { WebSocketService } from '../Application/Services/WebSocketService'; import { GameWebSocketService } from '../Application/Services/GameWebSocketService'; +import { container } from '../Application/Services/DIContainer'; import { GameRepository } from '../Infrastructure/Repository/GameRepository'; import { UserRepository } from '../Infrastructure/Repository/UserRepository'; import { RedisService } from '../Application/Services/RedisService'; @@ -183,17 +184,9 @@ AppDataSource.initialize() chatInactivityTimeout: process.env.CHAT_INACTIVITY_TIMEOUT_MINUTES || '30' }); - // Initialize Game WebSocket service for /game namespace - const gameRepository = new GameRepository(); - const userRepository = new UserRepository(); - const redisService = RedisService.getInstance(); - - gameWebSocketService = new GameWebSocketService( - webSocketService['io'], // Access the io property directly - gameRepository, - userRepository, - redisService - ); + // Initialize Game WebSocket service for /game namespace via DIContainer + container.setSocketIO(webSocketService['io']); + gameWebSocketService = container.gameWebSocketService; logStartup('Game WebSocket service initialized for /game namespace'); }) .catch((error) => { diff --git a/SerpentRace_Backend/src/Application/Game/commands/StartGamePlayCommandHandler.ts b/SerpentRace_Backend/src/Application/Game/commands/StartGamePlayCommandHandler.ts index 153dfabb..a0cc02ea 100644 --- a/SerpentRace_Backend/src/Application/Game/commands/StartGamePlayCommandHandler.ts +++ b/SerpentRace_Backend/src/Application/Game/commands/StartGamePlayCommandHandler.ts @@ -226,21 +226,43 @@ export class StartGamePlayCommandHandler { private async notifyGameStart(game: GameAggregate): Promise { try { - // Note: WebSocket notifications will be handled when WebSocket service is available - // For now, just log the game start - logOther('Game start notifications prepared', { + // Get board data from Redis + const redisKey = `game_board_${game.id}`; + const boardDataStr = await this.redisService.get(redisKey); + + if (!boardDataStr) { + logError('Board data not found in Redis during game start notification', new Error('Missing board data')); + return; + } + + const boardData: BoardData = JSON.parse(boardDataStr); + + // Get turn sequence from Redis + const gamePlayData = await this.getGamePlayFromRedis(game.id); + if (!gamePlayData) { + logError('Game play data not found in Redis', new Error('Missing game play data')); + return; + } + + // Get WebSocket service from DIContainer and broadcast game start + const gameWebSocketService = DIContainer.getInstance().gameWebSocketService; + await gameWebSocketService.broadcastGameStart( + game.gamecode, + boardData, + gamePlayData.turnSequence, + game + ); + + logOther('Game start notifications sent via WebSocket', { gameId: game.id, gameCode: game.gamecode, playerCount: game.players.length, - websocketRoom: `game_${game.gamecode}` + websocketRoom: `game_${game.gamecode}`, + firstPlayer: gamePlayData.turnSequence[0] }); - // TODO: Implement WebSocket notifications when service is properly integrated - // wsService.notifyGameStart(game.gamecode, game.players); - // wsService.broadcastGameStateUpdate(game.gamecode, gameStateData); - } catch (error) { - logError('Failed to prepare game start notifications', error instanceof Error ? error : new Error(String(error))); + logError('Failed to send game start notifications', error instanceof Error ? error : new Error(String(error))); // Don't throw error here - notification failure shouldn't prevent game start } } diff --git a/SerpentRace_Backend/src/Application/Services/DIContainer.ts b/SerpentRace_Backend/src/Application/Services/DIContainer.ts index d4a5fd36..69661b05 100644 --- a/SerpentRace_Backend/src/Application/Services/DIContainer.ts +++ b/SerpentRace_Backend/src/Application/Services/DIContainer.ts @@ -68,6 +68,8 @@ import { RedisService } from './RedisService'; import { GameService } from '../Game/GameService'; import { BoardGenerationService } from '../Game/BoardGenerationService'; import { GenerateBoardCommandHandler } from '../Game/commands/GenerateBoardCommandHandler'; +import { GameWebSocketService } from './GameWebSocketService'; +import type { Server as SocketIOServer } from 'socket.io'; /** * Central Dependency Injection Container @@ -96,6 +98,8 @@ export class DIContainer { private _fieldEffectService: FieldEffectService | null = null; private _gameService: GameService | null = null; private _boardGenerationService: BoardGenerationService | null = null; + private _gameWebSocketService: GameWebSocketService | null = null; + private _socketIOInstance: SocketIOServer | null = null; // Command Handlers private _createUserCommandHandler: CreateUserCommandHandler | null = null; @@ -272,6 +276,30 @@ export class DIContainer { return this._boardGenerationService; } + /** + * Set the Socket.IO instance (must be called before accessing gameWebSocketService) + */ + public setSocketIO(io: SocketIOServer): void { + this._socketIOInstance = io; + // Reset gameWebSocketService so it gets recreated with new IO instance + this._gameWebSocketService = null; + } + + public get gameWebSocketService(): GameWebSocketService { + if (!this._gameWebSocketService) { + if (!this._socketIOInstance) { + throw new Error('Socket.IO instance must be set before accessing gameWebSocketService. Call setSocketIO() first.'); + } + this._gameWebSocketService = new GameWebSocketService( + this._socketIOInstance, + this.gameRepository as any, // Cast to concrete type + this.userRepository as any, // Cast to concrete type + RedisService.getInstance() + ); + } + return this._gameWebSocketService; + } + // Command Handler getters public get createUserCommandHandler(): CreateUserCommandHandler { if (!this._createUserCommandHandler) { diff --git a/SerpentRace_Frontend/src/App.jsx b/SerpentRace_Frontend/src/App.jsx index 57501676..9a0c72b9 100644 --- a/SerpentRace_Frontend/src/App.jsx +++ b/SerpentRace_Frontend/src/App.jsx @@ -24,6 +24,7 @@ import VerifyEmailPage from "./pages/Auth/VerifyEmailPage" import ChooseDeck from "./pages/Game/ChooseDeck" import PlayerSetup from "./pages/Game/PlayerSetup" import GameModalsDemo from "./pages/Game/GameModalsDemo" +import { GameWebSocketProvider } from "./contexts/GameWebSocketContext" function App() { const [isMobile, setIsMobile] = useState(false) @@ -53,31 +54,33 @@ function App() { return ( <> - - - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - {/* } /> */} - } /> - } /> - } /> - - + + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + {/* } /> */} + } /> + } /> + } /> + + + {/* ✅ Toastify Container */} diff --git a/SerpentRace_Frontend/src/contexts/GameWebSocketContext.jsx b/SerpentRace_Frontend/src/contexts/GameWebSocketContext.jsx new file mode 100644 index 00000000..22a2a8b8 --- /dev/null +++ b/SerpentRace_Frontend/src/contexts/GameWebSocketContext.jsx @@ -0,0 +1,371 @@ +import React, { createContext, useContext, useRef, useState, useCallback, useMemo, useEffect } from 'react'; +import { io } from 'socket.io-client'; +import { API_CONFIG } from '../api/userApi'; + +const GameWebSocketContext = createContext(null); + +const isDev = import.meta.env.DEV; +const log = (...args) => isDev && console.log(...args); +const warn = (...args) => isDev && console.warn(...args); +const logError = (...args) => console.error(...args); + +/** + * Provider that maintains WebSocket connection across page navigation + */ +export const GameWebSocketProvider = ({ children }) => { + const socketRef = useRef(null); + const gameTokenRef = useRef(null); + const [isConnected, setIsConnected] = useState(false); + const [gameState, setGameState] = useState(null); + const [boardData, setBoardData] = useState(null); + const [error, setError] = useState(null); + const [isGamemaster, setIsGamemaster] = useState(false); + const [gameStarted, setGameStarted] = useState(false); + const [pendingPlayers, setPendingPlayers] = useState([]); + const [approvalStatus, setApprovalStatus] = useState(null); + + // Memoized derived values + const players = useMemo(() => { + const connectedPlayers = gameState?.connectedPlayers || []; + const currentPlayers = gameState?.currentPlayers || []; + + if (currentPlayers.length > 0) { + return currentPlayers; + } + + if (connectedPlayers.length > 0) { + return connectedPlayers.map((nameOrObj, index) => { + const playerName = typeof nameOrObj === 'string' + ? nameOrObj + : (nameOrObj.playerName || nameOrObj.name || `Player ${index + 1}`); + + return { + id: `player-${index}`, + name: playerName, + isOnline: true, + isReady: gameState?.readyPlayers?.includes(playerName) || false, + }; + }); + } + + return []; + }, [gameState?.connectedPlayers, gameState?.currentPlayers, gameState?.readyPlayers]); + + const currentTurn = useMemo(() => gameState?.currentPlayer || null, [gameState?.currentPlayer]); + + /** + * Connect to game WebSocket with a game token + * This maintains the connection even when navigating between pages + */ + const connect = useCallback((gameToken) => { + if (!gameToken) { + warn('⚠️ Cannot connect without game token'); + return; + } + + // If already connected with same token, don't reconnect + if (socketRef.current?.connected && gameTokenRef.current === gameToken) { + log('✅ Already connected with same token'); + return; + } + + // Disconnect old socket if exists + if (socketRef.current) { + log('🔌 Disconnecting old socket'); + socketRef.current.removeAllListeners(); + socketRef.current.disconnect(); + } + + log('🔌 Connecting to game WebSocket...'); + gameTokenRef.current = gameToken; + + // Connect to /game namespace + socketRef.current = io(`${API_CONFIG.wsURL}/game`, { + transports: ['websocket'], + reconnection: true, + reconnectionAttempts: 5, + reconnectionDelay: 1000, + timeout: 5000, + }); + + const socket = socketRef.current; + + // Connection handlers + socket.on('connect', () => { + log('✅ Connected to game WebSocket'); + setIsConnected(true); + setError(null); + socket.emit('game:join', { gameToken }); + }); + + socket.on('connect_error', (err) => { + logError('❌ Connection error:', err); + setIsConnected(false); + setError(err.message); + }); + + socket.on('disconnect', (reason) => { + log('🔌 Disconnected:', reason); + setIsConnected(false); + }); + + // Game state handlers + socket.on('game:state', (state) => { + log('📊 Game state received:', state); + if (state?.isGamemaster !== undefined) { + setIsGamemaster(state.isGamemaster); + } + setGameState(state); + }); + + socket.on('game:state-update', (state) => { + log('📊 Game state update:', state); + if (state?.isGamemaster !== undefined) { + setIsGamemaster(state.isGamemaster); + } + setGameState(state); + }); + + socket.on('game:joined', (data) => { + log('✅ Joined game:', data); + if (data.isGamemaster !== undefined) { + setIsGamemaster(data.isGamemaster); + } + }); + + socket.on('game:player-joined', (data) => { + log('👤 Player joined:', data.playerName); + setGameState(prev => { + if (!prev) return prev; + const currentConnected = prev.connectedPlayers || []; + if (!currentConnected.includes(data.playerName)) { + return { + ...prev, + connectedPlayers: [...currentConnected, data.playerName] + }; + } + return prev; + }); + }); + + socket.on('game:start', (data) => { + log('🎮 Game started:', data); + setGameStarted(true); + + // Store board data if provided + if (data.boardData) { + setBoardData(data.boardData); + log('✅ Board data stored from game:start event'); + } + + // Update game state with turn info + if (data.playerOrder) { + setGameState(prev => ({ + ...prev, + playerOrder: data.playerOrder, + currentPlayer: data.currentPlayer, + turnSequence: data.playerOrder + })); + } + }); + + socket.on('game:started', (data) => { + log('🎮 Game started (legacy event):', data); + setGameStarted(true); + }); + + socket.on('game:player-moved', (moveData) => { + log('🏃 Player moved:', moveData.playerName); + setGameState(prev => { + if (!prev?.currentPlayers) return prev; + return { + ...prev, + currentPlayers: prev.currentPlayers.map(p => + p.playerId === moveData.playerId + ? { ...p, boardPosition: moveData.newPosition } + : p + ), + }; + }); + }); + + socket.on('game:turn-changed', (data) => { + log('🔄 Turn changed to:', data.currentPlayerName); + setGameState(prev => prev ? { ...prev, currentPlayer: data.currentPlayer } : prev); + }); + + socket.on('game:error', (err) => { + logError('❌ Game error:', err); + setError(err.message); + }); + + // Approval system handlers + socket.on('game:pending-approval', (data) => { + log('⏳ Pending gamemaster approval:', data); + setApprovalStatus('pending'); + setError('Waiting for gamemaster approval...'); + }); + + socket.on('game:player-requesting-join', (data) => { + log('🔔 Player requesting to join:', data.playerName); + setPendingPlayers(prev => { + if (prev.some(p => p.playerName === data.playerName)) return prev; + return [...prev, { + playerName: data.playerName, + isAuthenticated: data.isAuthenticated, + timestamp: data.timestamp + }]; + }); + }); + + socket.on('game:approval-granted', (data) => { + log('✅ Join request approved:', data); + setApprovalStatus('approved'); + setError(null); + socket.emit('game:join-approved', { gameToken }); + }); + + socket.on('game:approval-denied', (data) => { + logError('❌ Join request denied:', data.reason || data.message); + setApprovalStatus('denied'); + setError(data.reason || 'Your request to join was denied'); + }); + + socket.on('game:player-approved', (data) => { + log('✅ Player approved by gamemaster:', data.playerName); + setPendingPlayers(prev => prev.filter(p => p.playerName !== data.playerName)); + setGameState(prev => { + if (!prev) return prev; + const currentConnected = prev.connectedPlayers || []; + if (!currentConnected.includes(data.playerName)) { + return { + ...prev, + connectedPlayers: [...currentConnected, data.playerName] + }; + } + return prev; + }); + }); + }, []); + + /** + * Disconnect from WebSocket + */ + const disconnect = useCallback(() => { + if (socketRef.current) { + log('🔌 Disconnecting from game WebSocket'); + socketRef.current.removeAllListeners(); + socketRef.current.disconnect(); + socketRef.current = null; + gameTokenRef.current = null; + setIsConnected(false); + setGameState(null); + setBoardData(null); + setError(null); + setIsGamemaster(false); + setGameStarted(false); + setPendingPlayers([]); + setApprovalStatus(null); + } + }, []); + + // Action methods + const rollDice = useCallback((diceValue) => { + const socket = socketRef.current; + if (!socket || !isConnected) { + warn('⚠️ Cannot roll dice: not connected'); + return false; + } + log('🎲 Rolling dice:', diceValue); + socket.emit('game:dice-roll', { gameCode: gameState?.gameCode, diceValue }); + return true; + }, [isConnected, gameState?.gameCode]); + + const sendMessage = useCallback((message) => { + const socket = socketRef.current; + if (!socket || !isConnected) return false; + socket.emit('game:chat', { gameCode: gameState?.gameCode, message }); + return true; + }, [isConnected, gameState?.gameCode]); + + const setReady = useCallback((ready = true) => { + const socket = socketRef.current; + if (!socket || !isConnected) return false; + socket.emit('game:ready', { gameCode: gameState?.gameCode, ready }); + return true; + }, [isConnected, gameState?.gameCode]); + + const leaveGame = useCallback(() => { + const socket = socketRef.current; + if (!socket || !isConnected) return false; + socket.emit('game:leave', { gameCode: gameState?.gameCode }); + return true; + }, [isConnected, gameState?.gameCode]); + + const approvePlayer = useCallback((playerName) => { + const socket = socketRef.current; + if (!socket || !isConnected || !isGamemaster) return false; + socket.emit('game:approve-player', { gameCode: gameState?.gameCode, playerName }); + return true; + }, [isConnected, isGamemaster, gameState?.gameCode]); + + const rejectPlayer = useCallback((playerName, reason = 'Join request denied') => { + const socket = socketRef.current; + if (!socket || !isConnected || !isGamemaster) return false; + socket.emit('game:reject-player', { gameCode: gameState?.gameCode, playerName, reason }); + return true; + }, [isConnected, isGamemaster, gameState?.gameCode]); + + const addEventListener = useCallback((event, handler) => { + const socket = socketRef.current; + if (socket) socket.on(event, handler); + }, []); + + const removeEventListener = useCallback((event, handler) => { + const socket = socketRef.current; + if (socket) socket.off(event, handler); + }, []); + + const value = { + socket: socketRef.current, + isConnected, + gameState, + players, + boardData, + currentTurn, + error, + isGamemaster, + gameStarted, + pendingPlayers, + approvalStatus, + // Connection management + connect, + disconnect, + // Methods + rollDice, + sendMessage, + setReady, + leaveGame, + approvePlayer, + rejectPlayer, + addEventListener, + removeEventListener, + }; + + return ( + + {children} + + ); +}; + +/** + * Hook to access the shared WebSocket connection + */ +export const useGameWebSocketContext = () => { + const context = useContext(GameWebSocketContext); + if (!context) { + throw new Error('useGameWebSocketContext must be used within GameWebSocketProvider'); + } + return context; +}; diff --git a/SerpentRace_Frontend/src/pages/Game/GameScreen.jsx b/SerpentRace_Frontend/src/pages/Game/GameScreen.jsx index f9cf1592..ca7708ea 100644 --- a/SerpentRace_Frontend/src/pages/Game/GameScreen.jsx +++ b/SerpentRace_Frontend/src/pages/Game/GameScreen.jsx @@ -1,7 +1,7 @@ import React, { useState, useEffect, useMemo, useCallback } from "react" import { getVerticalOffset } from "../../utils/randomUtils" import Dice from "../../utils/dice/Dice" -import { useGameWebSocket } from "../../hooks/useGameWebSocket" +import { useGameWebSocketContext } from "../../contexts/GameWebSocketContext" import JokerApprovalModal from "./JokerApprovalModal" import CardDisplayModal from "./CardDisplayModal" import ConsequenceModal from "./ConsequenceModal" @@ -45,8 +45,7 @@ const getDefaultFieldType = (count) => { } const GameScreen = () => { - // WebSocket connection - const gameToken = localStorage.getItem('gameToken') + // WebSocket connection from context (maintains connection across navigation) const { isConnected, gameState, @@ -61,7 +60,7 @@ const GameScreen = () => { submitPositionGuess, addEventListener, removeEventListener - } = useGameWebSocket(gameToken) + } = useGameWebSocketContext() // Try to get boardData from WebSocket, fallback to localStorage const boardData = useMemo(() => { @@ -626,7 +625,7 @@ const GameScreen = () => {
👥 Players: {backendPlayers?.length || 0}
🎲 Board Fields: {boardData?.fields?.length || 0}
🏁 Current Turn: {currentTurn || 'N/A'}
-
🔑 Token: {gameToken ? '✅' : '❌'}
+ {/*
🔑 Token: {gameToken ? '✅' : '❌'}
*/} )} diff --git a/SerpentRace_Frontend/src/pages/Game/Lobby.jsx b/SerpentRace_Frontend/src/pages/Game/Lobby.jsx index 291a56f4..68f94c39 100644 --- a/SerpentRace_Frontend/src/pages/Game/Lobby.jsx +++ b/SerpentRace_Frontend/src/pages/Game/Lobby.jsx @@ -6,7 +6,7 @@ import HandleNavigate from "../../utils/HandleNavigate/HandleNavigate" import Navbar from "../../components/Navbar/Navbar.jsx" import Background from "../../assets/backgrounds/Background.jsx" import useRequireAuth from "../../hooks/useRequireAuth.jsx" -import { useGameWebSocket } from "../../hooks/useGameWebSocket.js" +import { useGameWebSocketContext } from "../../contexts/GameWebSocketContext" import { startGame } from "../../api/gameApi.js" const Lobby = () => { @@ -21,11 +21,13 @@ const Lobby = () => { // Get game code from location state or WebSocket const gameCodeFromState = location.state?.gameCode - const gameToken = localStorage.getItem("gameToken") - - const { - isConnected, - gameState, + const gameToken = localStorage.getItem('gameToken') + + // Use the shared WebSocket context + const { + connect, + isConnected, + gameState, players, isGamemaster, gameStarted, @@ -33,7 +35,14 @@ const Lobby = () => { approvalStatus, approvePlayer, rejectPlayer, - } = useGameWebSocket(gameToken) + } = useGameWebSocketContext() + + // Connect to WebSocket when component mounts + useEffect(() => { + if (gameToken) { + connect(gameToken) + } + }, [gameToken, connect]) const gameCode = gameCodeFromState || gameState?.gameCode || "Loading..." @@ -70,19 +79,18 @@ const Lobby = () => { console.log("🎮 Game started, navigating to /game") goGame() } - }, [gameStarted, navigate]) + }, [gameStarted, goGame]) // Handle approval status changes useEffect(() => { - if (approvalStatus === "denied") { - notifyError("A gamemaster elutasította a csatlakozási kérelmedet.") - localStorage.removeItem("gameToken") + if (approvalStatus === 'denied') { + alert('A gamemaster elutasította a csatlakozási kérelmedet.') + localStorage.removeItem('gameToken') navigate("/home") - } else if (approvalStatus === "approved") { - console.log("✅ Join approved, you can now see the lobby") - notifySuccess("Csatlakozás jóváhagyva!") + } else if (approvalStatus === 'approved') { + console.log('✅ Join approved, you can now see the lobby') } - }, [approvalStatus, navigate]) + }, [approvalStatus, goHome]) const handleExit = () => { if (window.confirm("Biztosan ki szeretnél lépni a lobbyból?")) { @@ -120,8 +128,9 @@ const Lobby = () => { } // Navigate immediately after successful start (don't wait for WebSocket) - console.log("🎮 Navigating to /game...") - navigate("/game") + console.log('🎮 Navigating to /game...') + navigate('/game') + } catch (error) { console.error("Failed to start game:", error) @@ -129,7 +138,7 @@ const Lobby = () => { if (error.response?.status === 409) { console.log("Game already started, navigating to /game...") // Navigate anyway - game is already running - navigate("/game") + navigate('/game') } else { notifyError(`Nem sikerült elindítani a játékot: ${error.response?.data?.error || error.message}`) }