import { WebSocketGateway, WebSocketServer, SubscribeMessage, OnGatewayConnection, OnGatewayDisconnect, OnGatewayInit, } from '@nestjs/websockets'; import { Server, Socket } from 'socket.io'; import { RoomsService } from '../rooms/rooms.service'; import { RoomEventsService } from './room-events.service'; import { PrismaService } from '../prisma/prisma.service'; import { RoomPackService } from '../room-pack/room-pack.service'; import { Prisma } from '@prisma/client'; interface PlayerAction { action: 'revealAnswer' | 'nextQuestion' | 'prevQuestion'; roomId: string; roomCode: string; userId: string; participantId: string; // Для revealAnswer: questionId?: string; // UUID вопроса answerId?: string; // UUID ответа } interface Question { id: string; text?: string; question?: string; answers: Array<{ id: string; text: string; points: number; }>; } type RoomWithPack = Prisma.RoomGetPayload<{ include: { roomPack: true; participants: true; theme: true; } }> & { currentQuestionId?: string | null; }; interface RevealedAnswers { [questionId: string]: string[]; } @WebSocketGateway({ cors: { origin: process.env.CORS_ORIGIN || 'http://localhost:5173', credentials: true, }, }) export class GameGateway implements OnGatewayConnection, OnGatewayDisconnect, OnGatewayInit { @WebSocketServer() server: Server; constructor( private roomsService: RoomsService, private roomEventsService: RoomEventsService, private prisma: PrismaService, private roomPackService: RoomPackService, ) {} afterInit(server: Server) { this.roomEventsService.setServer(server); } handleConnection(client: Socket) { console.log(`Client connected: ${client.id}`); } handleDisconnect(client: Socket) { console.log(`Client disconnected: ${client.id}`); } private async isHost(roomId: string, userId: string): Promise { // Проверяем роль участника (role === 'HOST') для поддержки нескольких хостов const participant = await this.prisma.participant.findFirst({ where: { roomId, userId, role: 'HOST', isActive: true, }, }); return !!participant; } private async isCurrentPlayer(roomId: string, participantId: string): Promise { const room = await this.prisma.room.findUnique({ where: { id: roomId }, select: { currentPlayerId: true }, }); return room?.currentPlayerId === participantId; } @SubscribeMessage('joinRoom') async handleJoinRoom(client: Socket, payload: { roomCode: string; userId: string }) { client.join(payload.roomCode); await this.broadcastFullState(payload.roomCode); } @SubscribeMessage('startGame') async handleStartGame(client: Socket, payload: { roomId: string; roomCode: string; userId: string }) { const isHost = await this.isHost(payload.roomId, payload.userId); if (!isHost) { client.emit('error', { message: 'Only the host can start the game' }); return; } await this.roomsService.updateRoomStatus(payload.roomId, 'PLAYING'); // Инициализировать первый вопрос и игрока const room = (await this.prisma.room.findUnique({ where: { id: payload.roomId }, include: { roomPack: true, participants: { where: { isActive: true }, orderBy: { joinedAt: 'asc' } } } as Prisma.RoomInclude, })) as unknown as RoomWithPack | null; if (room) { const questions = ((room.roomPack as unknown as { questions?: Question[] } | null)?.questions || []) as Question[]; const firstQuestion = questions[0]; // Админ (хост) должен быть первым игроком const hostParticipant = room.participants.find(p => p.userId === room.hostId); const firstParticipant = hostParticipant || room.participants[0]; // Убеждаемся что firstQuestion.id - строка (UUID) const firstQuestionId = firstQuestion?.id && typeof firstQuestion.id === 'string' ? firstQuestion.id : null; if (firstQuestionId && firstParticipant) { await this.prisma.room.update({ where: { id: payload.roomId }, data: { currentQuestionId: firstQuestionId, currentQuestionIndex: 0, currentPlayerId: firstParticipant.id, } }); } } await this.broadcastFullState(payload.roomCode); // Явно отправить событие начала игры для перенаправления всех игроков this.server.to(payload.roomCode).emit('gameStarted', { roomId: payload.roomId, roomCode: payload.roomCode, status: 'PLAYING' }); } @SubscribeMessage('playerAction') async handlePlayerAction(client: Socket, payload: PlayerAction) { // Получаем комнату с данными const room = (await this.prisma.room.findUnique({ where: { id: payload.roomId }, include: { roomPack: true, participants: { where: { isActive: true }, orderBy: { joinedAt: 'asc' } } } as unknown as Prisma.RoomInclude, })) as unknown as RoomWithPack | null; if (!room) { client.emit('error', { message: 'Room not found' }); return; } // Получаем участника для проверки роли const participant = room.participants.find(p => p.id === payload.participantId); if (!participant) { client.emit('error', { message: 'Participant not found' }); return; } // Проверяем роль участника - зрители не могут выполнять действия игрока if (participant.role === 'SPECTATOR') { client.emit('error', { message: 'Spectators cannot perform player actions' }); return; } // Проверяем права const isHost = await this.isHost(payload.roomId, payload.userId); const isCurrentPlayer = room.currentPlayerId === payload.participantId; if (!isHost && !isCurrentPlayer) { client.emit('error', { message: 'Not your turn!' }); return; } // Выполняем действие try { switch (payload.action) { case 'revealAnswer': await this.handleRevealAnswerAction(payload, room); break; case 'nextQuestion': await this.handleNextQuestionAction(payload, room); break; case 'prevQuestion': await this.handlePrevQuestionAction(payload, room); break; } // Отправляем полное состояние всем await this.broadcastFullState(payload.roomCode); } catch (error) { console.error('Error handling player action:', error); client.emit('error', { message: 'Failed to process action' }); } } private async handleRevealAnswerAction(payload: PlayerAction, room: any) { if (!payload.questionId || !payload.answerId) { console.error('Missing questionId or answerId in payload'); return; } const questions = ((room.roomPack as { questions?: Question[] } | null)?.questions || []) as Question[]; const question = questions.find(q => q.id === payload.questionId); if (!question) { console.error('Question not found:', payload.questionId); return; } const answer = question.answers?.find((a: any) => a.id === payload.answerId); if (!answer) { console.error('Answer not found:', payload.answerId); return; } // Обновляем revealedAnswers const revealed = (room.revealedAnswers as RevealedAnswers) || {}; const currentRevealed: string[] = revealed[payload.questionId || ''] || []; if (!currentRevealed.includes(payload.answerId)) { currentRevealed.push(payload.answerId); revealed[payload.questionId] = currentRevealed; // Начисляем очки await this.prisma.participant.update({ where: { id: payload.participantId }, data: { score: { increment: answer.points } } }); // Сохраняем revealedAnswers await this.prisma.room.update({ where: { id: payload.roomId }, data: { revealedAnswers: revealed as Prisma.InputJsonValue } }); // Определяем следующего игрока const participants = room.participants; const currentIdx = participants.findIndex((p) => p.id === payload.participantId); const nextIdx = (currentIdx + 1) % participants.length; const nextPlayerId = participants[nextIdx]?.id; // Проверяем, это последний ответ? const isLastAnswer = currentRevealed.length >= question.answers.length; if (!isLastAnswer) { // Меняем игрока await this.prisma.room.update({ where: { id: payload.roomId }, data: { currentPlayerId: nextPlayerId } }); } else { // Последний ответ - проверяем, последний ли вопрос const currentQuestionIndex = questions.findIndex((q: any) => q.id === payload.questionId); const isLastQuestion = currentQuestionIndex >= questions.length - 1; if (isLastQuestion) { // Конец игры await this.prisma.room.update({ where: { id: payload.roomId }, data: { status: 'FINISHED', isGameOver: true, finishedAt: new Date() } }); } else { // Меняем игрока для следующего вопроса await this.prisma.room.update({ where: { id: payload.roomId }, data: { currentPlayerId: nextPlayerId } }); } } } } private async handleNextQuestionAction(payload: PlayerAction, room: RoomWithPack) { const questions = ((room.roomPack as { questions?: Question[] } | null)?.questions || []) as Question[]; const currentIdx = questions.findIndex((q) => q.id === (room.currentQuestionId as string | null)); if (currentIdx < questions.length - 1) { const nextQuestion = questions[currentIdx + 1]; await this.prisma.room.update({ where: { id: payload.roomId }, data: { currentQuestionId: nextQuestion.id, currentQuestionIndex: currentIdx + 1 } }); } } private async handlePrevQuestionAction(payload: PlayerAction, room: RoomWithPack) { const questions = ((room.roomPack as { questions?: Question[] } | null)?.questions || []) as Question[]; const currentIdx = questions.findIndex((q) => q.id === (room.currentQuestionId as string | null)); if (currentIdx > 0) { const prevQuestion = questions[currentIdx - 1]; await this.prisma.room.update({ where: { id: payload.roomId }, data: { currentQuestionId: prevQuestion.id, currentQuestionIndex: currentIdx - 1 } }); } } // КЛЮЧЕВОЙ МЕТОД: Отправка полного состояния private async broadcastFullState(roomCode: string) { const room = (await this.prisma.room.findUnique({ where: { code: roomCode }, include: { participants: { where: { isActive: true }, orderBy: { joinedAt: 'asc' } }, roomPack: true, host: { select: { id: true, name: true } }, theme: true } as Prisma.RoomInclude, })) as unknown as RoomWithPack | null; if (!room) return; // Извлекаем вопросы из roomPack.questions (JSON поле) const roomPackQuestions = (room.roomPack as unknown as { questions?: any } | null)?.questions; let questions: Question[] = []; if (roomPackQuestions) { // Если это уже массив, используем как есть if (Array.isArray(roomPackQuestions)) { questions = roomPackQuestions as Question[]; } else if (typeof roomPackQuestions === 'string') { // Если это строка, парсим JSON try { questions = JSON.parse(roomPackQuestions) as Question[]; } catch (e) { console.error('Error parsing roomPack.questions:', e); questions = []; } } } console.log(`📋 Room ${roomCode}: Found ${questions.length} questions`); // Инициализация currentQuestionId если не установлен или невалиден let currentQuestionId = (room.currentQuestionId as string | null) || null; // Проверяем, что currentQuestionId валиден (существует в вопросах) if (currentQuestionId) { const questionExists = questions.some((q: any) => q.id === currentQuestionId); if (!questionExists) { currentQuestionId = null; // Сбрасываем если вопрос удален } } // Устанавливаем первый вопрос если нет текущего if (!currentQuestionId && questions.length > 0) { const firstQuestion = questions[0]; // Убеждаемся что id - строка (UUID) if (firstQuestion.id && typeof firstQuestion.id === 'string') { currentQuestionId = firstQuestion.id; await this.prisma.room.update({ where: { id: room.id }, data: { currentQuestionId: currentQuestionId, currentQuestionIndex: 0 } }); } } // Инициализация currentPlayerId если не установлен let currentPlayerId = room.currentPlayerId; if (!currentPlayerId && room.participants.length > 0) { // Админ (хост) должен быть первым игроком const hostParticipant = room.participants.find(p => p.userId === room.hostId); const firstParticipant = hostParticipant || room.participants[0]; if (firstParticipant) { currentPlayerId = firstParticipant.id; await this.prisma.room.update({ where: { id: room.id }, data: { currentPlayerId: currentPlayerId } }); } } const fullState = { roomId: room.id, roomCode: room.code, status: room.status, currentQuestionId: currentQuestionId, currentPlayerId: currentPlayerId, revealedAnswers: room.revealedAnswers as RevealedAnswers, isGameOver: room.isGameOver, hostId: room.hostId, themeId: (room as any).themeId || null, particlesEnabled: (room as any).particlesEnabled !== undefined ? (room as any).particlesEnabled : null, maxPlayers: (room as any).maxPlayers || 10, participants: room.participants.map((p) => ({ id: p.id, userId: p.userId, name: p.name, role: p.role, score: p.score })), questions: questions.map((q: any) => { // Убеждаемся, что у вопроса есть id const questionId = q.id || (typeof q === 'object' && 'id' in q ? q.id : null); if (!questionId) { console.warn('⚠️ Question without ID:', q); } return { id: questionId || `temp-${Math.random()}`, text: q.text || q.question || '', answers: (q.answers || []).map((a: any) => ({ id: a.id || `answer-${Math.random()}`, text: a.text || '', points: a.points || 0 })) }; }) }; this.server.to(roomCode).emit('gameStateUpdated', fullState); } @SubscribeMessage('requestFullState') async handleRequestFullState(client: Socket, payload: { roomCode: string }) { await this.broadcastFullState(payload.roomCode); } @SubscribeMessage('endGame') async handleEndGame(client: Socket, payload: { roomId: string; roomCode: string; userId: string }) { const isHost = await this.isHost(payload.roomId, payload.userId); if (!isHost) { client.emit('error', { message: 'Only the host can end the game' }); return; } await this.roomsService.updateRoomStatus(payload.roomId, 'FINISHED'); await this.broadcastFullState(payload.roomCode); } @SubscribeMessage('restartGame') async handleRestartGame(client: Socket, payload: { roomId: string; roomCode: string; userId: string }) { const isHost = await this.isHost(payload.roomId, payload.userId); if (!isHost) { client.emit('error', { message: 'Only the host can restart the game' }); return; } const room = (await this.prisma.room.findUnique({ where: { id: payload.roomId }, include: { roomPack: true, participants: { where: { isActive: true }, orderBy: { joinedAt: 'asc' } } } as Prisma.RoomInclude, })) as unknown as RoomWithPack | null; if (room) { const questions = ((room.roomPack as unknown as { questions?: Question[] } | null)?.questions || []) as Question[]; const firstQuestion = questions[0]; // Админ (хост) должен быть первым игроком const hostParticipant = room.participants.find(p => p.userId === room.hostId); const firstParticipant = hostParticipant || room.participants[0]; // Убеждаемся что firstQuestion.id - строка (UUID) const firstQuestionId = firstQuestion?.id && typeof firstQuestion.id === 'string' ? firstQuestion.id : null; await this.prisma.room.update({ where: { id: payload.roomId }, data: { status: 'WAITING', currentQuestionId: firstQuestionId, currentQuestionIndex: 0, revealedAnswers: {} as Prisma.InputJsonValue, currentPlayerId: firstParticipant?.id || null, isGameOver: false, answeredQuestions: 0, } }); } await this.prisma.participant.updateMany({ where: { roomId: payload.roomId }, data: { score: 0 }, }); await this.broadcastFullState(payload.roomCode); } @SubscribeMessage('setCurrentPlayer') async handleSetCurrentPlayer(client: Socket, payload: { roomId: string; roomCode: string; userId: string; participantId: string }) { const isHost = await this.isHost(payload.roomId, payload.userId); if (!isHost) { client.emit('error', { message: 'Only the host can set current player' }); return; } // Проверяем, что участник существует и активен const participant = await this.prisma.participant.findFirst({ where: { id: payload.participantId, roomId: payload.roomId, isActive: true } }); if (!participant) { client.emit('error', { message: 'Participant not found' }); return; } await this.prisma.room.update({ where: { id: payload.roomId }, data: { currentPlayerId: payload.participantId } }); await this.broadcastFullState(payload.roomCode); } @SubscribeMessage('changeRoomTheme') async handleChangeRoomTheme(client: Socket, payload: { roomId: string; roomCode: string; userId: string; themeId: string | null }) { const isHost = await this.isHost(payload.roomId, payload.userId); if (!isHost) { client.emit('error', { message: 'Only the host can change room theme' }); return; } await this.prisma.room.update({ where: { id: payload.roomId }, data: { themeId: payload.themeId } as Prisma.RoomUpdateInput }); await this.broadcastFullState(payload.roomCode); } @SubscribeMessage('toggleParticles') async handleToggleParticles(client: Socket, payload: { roomId: string; roomCode: string; userId: string; particlesEnabled: boolean }) { const isHost = await this.isHost(payload.roomId, payload.userId); if (!isHost) { client.emit('error', { message: 'Only the host can toggle particles' }); return; } await this.prisma.room.update({ where: { id: payload.roomId }, data: { particlesEnabled: payload.particlesEnabled } as any }); await this.broadcastFullState(payload.roomCode); } @SubscribeMessage('addPlayer') async handleAddPlayer(client: Socket, payload: { roomId: string; roomCode: string; userId: string; playerName: string; role?: 'PLAYER' | 'SPECTATOR' }) { const isHost = await this.isHost(payload.roomId, payload.userId); if (!isHost) { client.emit('error', { message: 'Only the host can add players' }); return; } try { await this.roomsService.addPlayerManually( payload.roomId, payload.userId, payload.playerName, payload.role || 'PLAYER', ); await this.broadcastFullState(payload.roomCode); } catch (error: any) { client.emit('error', { message: error.message || 'Failed to add player' }); } } @SubscribeMessage('updateRoomPack') async handleUpdateRoomPack(client: Socket, payload: { roomId: string; roomCode: string; userId: string; questions: any[] }) { const isHost = await this.isHost(payload.roomId, payload.userId); if (!isHost) { client.emit('error', { message: 'Only the host can update questions' }); return; } // Проверить uiControls const roomForControls = await this.prisma.room.findUnique({ where: { id: payload.roomId }, }) as any; const uiControls = roomForControls?.uiControls as { allowPackChange?: boolean } | null; if (uiControls && uiControls.allowPackChange === false) { client.emit('error', { message: 'Pack editing is disabled for this room' }); return; } // Обновляем вопросы через service (который добавит UUID если нужно) await this.roomsService.updateRoomPack(payload.roomId, payload.questions); // После обновления вопросов проверяем и обновляем currentQuestionId const room = (await this.prisma.room.findUnique({ where: { id: payload.roomId }, include: { roomPack: true } as unknown as Prisma.RoomInclude, })) as unknown as RoomWithPack | null; if (room) { const questions = ((room.roomPack as unknown as { questions?: Question[] } | null)?.questions || []) as Question[]; const currentQuestionId = (room.currentQuestionId as string | null) || null; // Проверяем, что currentQuestionId все еще валиден let validQuestionId = currentQuestionId; if (currentQuestionId) { const questionExists = questions.some((q: any) => q.id === currentQuestionId); if (!questionExists) { // Текущий вопрос был удален, устанавливаем первый validQuestionId = questions[0]?.id && typeof questions[0].id === 'string' ? questions[0].id : null; } } else if (questions.length > 0) { // Если нет currentQuestionId, устанавливаем первый validQuestionId = questions[0].id && typeof questions[0].id === 'string' ? questions[0].id : null; } // Обновляем currentQuestionId если изменился if (validQuestionId !== currentQuestionId) { const questionIndex = questions.findIndex((q: any) => q.id === validQuestionId); await this.prisma.room.update({ where: { id: payload.roomId }, data: { currentQuestionId: validQuestionId, currentQuestionIndex: questionIndex >= 0 ? questionIndex : 0 } }); } } await this.broadcastFullState(payload.roomCode); } @SubscribeMessage('importQuestions') async handleImportQuestions(client: Socket, payload: { roomId: string; roomCode: string; userId: string; sourcePackId: string; questionIndices: number[]; }) { const isHost = await this.isHost(payload.roomId, payload.userId); if (!isHost) { client.emit('error', { message: 'Only the host can import questions' }); return; } // Проверить uiControls const roomForControls = await this.prisma.room.findUnique({ where: { id: payload.roomId }, }) as any; const uiControls = roomForControls?.uiControls as { allowPackChange?: boolean } | null; if (uiControls && uiControls.allowPackChange === false) { client.emit('error', { message: 'Pack editing is disabled for this room' }); return; } await this.roomPackService.importQuestions(payload.roomId, payload.sourcePackId, payload.questionIndices); await this.broadcastFullState(payload.roomCode); } @SubscribeMessage('kickPlayer') async handleKickPlayer(client: Socket, payload: { roomId: string; roomCode: string; userId: string; participantId: string }) { const isHost = await this.isHost(payload.roomId, payload.userId); if (!isHost) { client.emit('error', { message: 'Only the host can kick players' }); return; } // Получаем комнату с участниками const room = (await this.prisma.room.findUnique({ where: { id: payload.roomId }, include: { participants: { where: { isActive: true }, orderBy: { joinedAt: 'asc' } } } as Prisma.RoomInclude, })) as unknown as RoomWithPack | null; if (!room) { client.emit('error', { message: 'Room not found' }); return; } // Проверяем существование и активность участника const participant = await this.prisma.participant.findUnique({ where: { id: payload.participantId }, include: { user: true }, }); if (!participant || participant.roomId !== payload.roomId) { client.emit('error', { message: 'Participant not found' }); return; } if (!participant.isActive) { client.emit('error', { message: 'Participant is already inactive' }); return; } // Запрещаем удаление хоста if (participant.role === 'HOST') { client.emit('error', { message: 'Cannot kick the host' }); return; } // Если удаляемый участник - текущий игрок, выбираем следующего let newCurrentPlayerId = room.currentPlayerId; if (room.currentPlayerId === payload.participantId) { const activeParticipants = room.participants.filter(p => p.id !== payload.participantId); if (activeParticipants.length > 0) { // Выбираем первого активного участника после удаляемого newCurrentPlayerId = activeParticipants[0].id; } else { newCurrentPlayerId = null; } } // Деактивируем участника await this.prisma.participant.update({ where: { id: payload.participantId }, data: { isActive: false }, }); // Обновляем currentPlayerId если нужно if (newCurrentPlayerId !== room.currentPlayerId) { await this.prisma.room.update({ where: { id: payload.roomId }, data: { currentPlayerId: newCurrentPlayerId }, }); } // Отправляем событие об удалении this.roomEventsService.emitPlayerKicked(payload.roomCode, { participantId: payload.participantId, userId: participant.userId || null, participantName: participant.name, newCurrentPlayerId, }); // Отправляем обновленное состояние await this.broadcastFullState(payload.roomCode); } @SubscribeMessage('updatePlayerName') async handleUpdatePlayerName(client: Socket, payload: { roomId: string; roomCode: string; userId: string; participantId: string; newName: string; }) { const isHost = await this.isHost(payload.roomId, payload.userId); if (!isHost) { client.emit('error', { message: 'Only the host can update player names' }); return; } // Проверить uiControls const roomForControls = await this.prisma.room.findUnique({ where: { id: payload.roomId }, }) as any; const uiControls = roomForControls?.uiControls as { allowNameChange?: boolean } | null; if (uiControls && uiControls.allowNameChange === false) { client.emit('error', { message: 'Name editing is disabled for this room' }); return; } if (!payload.newName || payload.newName.trim().length === 0) { client.emit('error', { message: 'Name cannot be empty' }); return; } if (payload.newName.trim().length > 50) { client.emit('error', { message: 'Name is too long (max 50 characters)' }); return; } await this.prisma.participant.update({ where: { id: payload.participantId }, data: { name: payload.newName.trim() } }); await this.broadcastFullState(payload.roomCode); } @SubscribeMessage('updatePlayerScore') async handleUpdatePlayerScore(client: Socket, payload: { roomId: string; roomCode: string; userId: string; participantId: string; newScore: number; }) { const isHost = await this.isHost(payload.roomId, payload.userId); if (!isHost) { client.emit('error', { message: 'Only the host can update scores' }); return; } // Проверить uiControls const roomForControls = await this.prisma.room.findUnique({ where: { id: payload.roomId }, }) as any; const uiControls = roomForControls?.uiControls as { allowScoreEdit?: boolean } | null; if (uiControls && uiControls.allowScoreEdit === false) { client.emit('error', { message: 'Score editing is disabled for this room' }); return; } if (typeof payload.newScore !== 'number' || isNaN(payload.newScore)) { client.emit('error', { message: 'Invalid score value' }); return; } await this.prisma.participant.update({ where: { id: payload.participantId }, data: { score: Math.round(payload.newScore) } }); await this.broadcastFullState(payload.roomCode); } @SubscribeMessage('changeParticipantRole') async handleChangeParticipantRole(client: Socket, payload: { roomId: string; roomCode: string; userId: string; participantId: string; newRole: 'HOST' | 'PLAYER' | 'SPECTATOR'; }) { const isHost = await this.isHost(payload.roomId, payload.userId); if (!isHost) { client.emit('error', { message: 'Only hosts can change participant roles' }); return; } try { const room = await this.roomsService.updateParticipantRole( payload.roomId, payload.participantId, payload.newRole, payload.userId, ); await this.broadcastFullState(payload.roomCode); } catch (error) { console.error('Error changing participant role:', error); client.emit('error', { message: error.message || 'Failed to change participant role' }); } } }