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; } }> & { 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 { const room = await this.prisma.room.findUnique({ where: { id: roomId }, select: { hostId: true }, }); return room?.hostId === userId; } 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); } @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 isHost = room.hostId === 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 } } } 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, 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('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; } // Обновляем вопросы через 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; } 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; } await this.prisma.participant.update({ where: { id: payload.participantId }, data: { isActive: false }, }); await this.broadcastFullState(payload.roomCode); } }