From dbc43d65c1b898df8aece5c86e5f8df8df37c629 Mon Sep 17 00:00:00 2001 From: Dmitry Date: Sat, 10 Jan 2026 19:44:06 +0300 Subject: [PATCH] stuff --- backend/prisma/schema.prisma | 4 +- backend/src/game/game.gateway.ts | 24 ++++++- backend/src/rooms/rooms.service.ts | 91 ++++++++++++++++++++++++ backend/src/utils/question-utils.ts | 29 ++++++-- src/components/GameManagementModal.jsx | 96 +++++++++++++++++++++++++- src/pages/GamePage.jsx | 16 ++++- src/services/socket.js | 10 +++ 7 files changed, 259 insertions(+), 11 deletions(-) diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 265f525..e6e4308 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -87,7 +87,7 @@ enum RoomStatus { model Participant { id String @id @default(uuid()) - userId String + userId String? roomId String name String role ParticipantRole @@ -95,7 +95,7 @@ model Participant { joinedAt DateTime @default(now()) isActive Boolean @default(true) - user User @relation(fields: [userId], references: [id]) + user User? @relation(fields: [userId], references: [id]) room Room @relation(fields: [roomId], references: [id], onDelete: Cascade) @@unique([userId, roomId]) diff --git a/backend/src/game/game.gateway.ts b/backend/src/game/game.gateway.ts index 9945d26..1d177cc 100644 --- a/backend/src/game/game.gateway.ts +++ b/backend/src/game/game.gateway.ts @@ -425,6 +425,7 @@ export class GameGateway implements OnGatewayConnection, OnGatewayDisconnect, On 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, @@ -583,6 +584,27 @@ export class GameGateway implements OnGatewayConnection, OnGatewayDisconnect, On 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); @@ -749,7 +771,7 @@ export class GameGateway implements OnGatewayConnection, OnGatewayDisconnect, On // Отправляем событие об удалении this.roomEventsService.emitPlayerKicked(payload.roomCode, { participantId: payload.participantId, - userId: participant.userId, + userId: participant.userId || null, participantName: participant.name, newCurrentPlayerId, }); diff --git a/backend/src/rooms/rooms.service.ts b/backend/src/rooms/rooms.service.ts index 10f4663..24cd8a2 100644 --- a/backend/src/rooms/rooms.service.ts +++ b/backend/src/rooms/rooms.service.ts @@ -150,6 +150,92 @@ export class RoomsService { return participant; } + async addPlayerManually( + roomId: string, + requestedByUserId: string, + name: string, + role: 'PLAYER' | 'SPECTATOR' = 'PLAYER', + ) { + // Проверяем, что запрашивающий - хост + const requester = await this.prisma.participant.findFirst({ + where: { + roomId, + userId: requestedByUserId, + role: 'HOST', + isActive: true, + }, + }); + + if (!requester) { + throw new UnauthorizedException('Only hosts can add players manually'); + } + + // Получаем комнату для проверки настроек и лимита + const room = await this.prisma.room.findUnique({ + where: { id: roomId }, + include: { + participants: { + where: { isActive: true }, + }, + }, + }); + + if (!room) { + throw new NotFoundException('Room not found'); + } + + // Проверяем лимит игроков + const activeParticipantsCount = room.participants.length; + if (activeParticipantsCount >= room.maxPlayers) { + throw new BadRequestException(`Room is full (max ${room.maxPlayers} players)`); + } + + // Проверяем, разрешены ли зрители + if (role === 'SPECTATOR' && !room.allowSpectators) { + throw new BadRequestException('Spectators are not allowed in this room'); + } + + // Валидация имени + const trimmedName = name.trim(); + if (!trimmedName || trimmedName.length === 0) { + throw new BadRequestException('Player name cannot be empty'); + } + if (trimmedName.length > 50) { + throw new BadRequestException('Player name cannot exceed 50 characters'); + } + + // Создаем участника без userId + const participant = await this.prisma.participant.create({ + data: { + userId: null, + roomId, + name: trimmedName, + role, + }, + }); + + // Получаем обновленную комнату со всеми участниками + const updatedRoom = await this.prisma.room.findUnique({ + where: { id: roomId }, + include: { + host: true, + participants: { + include: { user: true }, + }, + questionPack: true, + roomPack: true, + theme: true, + }, + }); + + // Отправляем событие roomUpdate всем клиентам в комнате + if (updatedRoom) { + this.roomEventsService.emitRoomUpdate(updatedRoom.code, updatedRoom); + } + + return participant; + } + async updateRoomStatus(roomId: string, status: 'WAITING' | 'PLAYING' | 'FINISHED') { return this.prisma.room.update({ where: { id: roomId }, @@ -391,6 +477,11 @@ export class RoomsService { throw new BadRequestException('Spectators are not allowed in this room'); } + // Игроки без userId не могут стать хостами (хосты должны быть аутентифицированными пользователями) + if (newRole === 'HOST' && !participant.userId) { + throw new BadRequestException('Players without user account cannot become hosts'); + } + // Проверяем, что не изменяем роль последнего хоста if (participant.role === 'HOST' && newRole !== 'HOST') { const hostCount = await this.prisma.participant.count({ diff --git a/backend/src/utils/question-utils.ts b/backend/src/utils/question-utils.ts index cbb5baf..c85d741 100644 --- a/backend/src/utils/question-utils.ts +++ b/backend/src/utils/question-utils.ts @@ -14,19 +14,36 @@ interface Question { } /** - * Добавляет UUID к вопросам и ответам, если их нет + * Проверяет, является ли строка валидным UUID + */ +function isValidUUID(id: string | number | undefined): boolean { + if (!id || typeof id !== 'string') { + return false; + } + // UUID v4 формат: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx + const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + return uuidRegex.test(id); +} + +/** + * Добавляет UUID к вопросам и ответам, если их нет или они невалидны * @param questions - Массив вопросов * @returns Массив вопросов с добавленными UUID */ export function ensureQuestionIds(questions: Question[]): Question[] { return questions.map((question) => { - const questionId = question.id || randomUUID(); + // Если ID нет или не является валидным UUID, создаем новый + const questionId = (question.id && isValidUUID(question.id)) ? question.id : randomUUID(); const questionText = question.text || question.question || ''; - const answersWithIds = question.answers.map((answer) => ({ - ...answer, - id: answer.id || randomUUID(), - })); + const answersWithIds = question.answers.map((answer) => { + // Если ID нет или не является валидным UUID, создаем новый + const answerId = (answer.id && isValidUUID(answer.id)) ? answer.id : randomUUID(); + return { + ...answer, + id: answerId, + }; + }); return { ...question, diff --git a/src/components/GameManagementModal.jsx b/src/components/GameManagementModal.jsx index bc40b63..9dac36d 100644 --- a/src/components/GameManagementModal.jsx +++ b/src/components/GameManagementModal.jsx @@ -33,6 +33,8 @@ const GameManagementModal = ({ particlesEnabled = null, onToggleParticles, initialTab = 'players', + onAddPlayer, + room, }) => { const { currentThemeData } = useTheme() const [activeTab, setActiveTab] = useState(initialTab) // players | game | scoring | questions @@ -57,6 +59,10 @@ const GameManagementModal = ({ const [editingPlayerScore, setEditingPlayerScore] = useState('') const [editMode, setEditMode] = useState(null) // 'name' | 'score' + // Add player state + const [newPlayerName, setNewPlayerName] = useState('') + const [newPlayerRole, setNewPlayerRole] = useState('PLAYER') + // Questions management state const [editingQuestion, setEditingQuestion] = useState(null) const [questionText, setQuestionText] = useState('') @@ -530,6 +536,89 @@ const GameManagementModal = ({ {activeTab === 'players' && (

Участники ({participants.length})

+ + {/* Форма добавления игрока - только для хоста */} + {onAddPlayer && ( +
+

➕ Добавить игрока

+
+ setNewPlayerName(e.target.value)} + placeholder="Имя игрока" + maxLength={50} + style={{ + flex: '1', + minWidth: '150px', + padding: '8px 12px', + background: 'rgba(255, 255, 255, 0.1)', + border: '1px solid rgba(255, 215, 0, 0.3)', + borderRadius: '6px', + color: 'var(--text-primary)', + fontSize: '0.9rem', + }} + onKeyDown={(e) => { + if (e.key === 'Enter' && newPlayerName.trim()) { + onAddPlayer(newPlayerName.trim(), newPlayerRole) + setNewPlayerName('') + } + }} + /> + + +
+ {room && room.maxPlayers && participants.length >= room.maxPlayers && ( +

+ Достигнут лимит игроков ({room.maxPlayers}) +

+ )} +
+ )} +
{participants.length === 0 ? (

Нет участников

@@ -579,6 +668,11 @@ const GameManagementModal = ({ return; } } + // Игроки без userId не могут стать хостами + if (newRole === 'HOST' && !participant.userId) { + alert('Игроки без аккаунта не могут стать хостами'); + return; + } onChangeParticipantRole(participant.id, newRole); }} style={{ @@ -592,7 +686,7 @@ const GameManagementModal = ({ }} title="Изменить роль участника" > - + diff --git a/src/pages/GamePage.jsx b/src/pages/GamePage.jsx index 9be724f..4ac78b2 100644 --- a/src/pages/GamePage.jsx +++ b/src/pages/GamePage.jsx @@ -32,6 +32,7 @@ const GamePage = () => { roomCode: null, themeId: null, particlesEnabled: null, // null = использовать настройку из темы, true/false = override + maxPlayers: 10, }); const [loading, setLoading] = useState(true); @@ -373,6 +374,17 @@ const GamePage = () => { ); }; + const handleAddPlayer = (playerName, role = 'PLAYER') => { + if (!gameState.roomId || !user) return; + socketService.addPlayer( + gameState.roomId, + gameState.roomCode, + user.id, + playerName, + role + ); + }; + const handleSelectPlayer = (participantId) => { if (!gameState.roomId || !user) return; if (!isHost) return; // Только хост может выбирать игрока @@ -547,7 +559,8 @@ const GamePage = () => { id: gameState.roomId, code: gameState.roomCode, status: gameState.status, - hostId: gameState.hostId + hostId: gameState.hostId, + maxPlayers: gameState.maxPlayers || 10 }} participants={gameState.participants} currentQuestion={currentQuestion} @@ -573,6 +586,7 @@ const GamePage = () => { onChangeParticipantRole={handleChangeParticipantRole} particlesEnabled={gameState.particlesEnabled} onToggleParticles={handleToggleParticles} + onAddPlayer={handleAddPlayer} /> )} diff --git a/src/services/socket.js b/src/services/socket.js index ad7127f..c24e630 100644 --- a/src/services/socket.js +++ b/src/services/socket.js @@ -165,6 +165,16 @@ class SocketService { particlesEnabled, }); } + + addPlayer(roomId, roomCode, userId, playerName, role = 'PLAYER') { + this.emit('addPlayer', { + roomId, + roomCode, + userId, + playerName, + role, + }); + } } export default new SocketService();