This commit is contained in:
Dmitry 2026-01-10 19:44:06 +03:00
parent 9acd6a6fa9
commit dbc43d65c1
7 changed files with 259 additions and 11 deletions

View file

@ -87,7 +87,7 @@ enum RoomStatus {
model Participant { model Participant {
id String @id @default(uuid()) id String @id @default(uuid())
userId String userId String?
roomId String roomId String
name String name String
role ParticipantRole role ParticipantRole
@ -95,7 +95,7 @@ model Participant {
joinedAt DateTime @default(now()) joinedAt DateTime @default(now())
isActive Boolean @default(true) 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) room Room @relation(fields: [roomId], references: [id], onDelete: Cascade)
@@unique([userId, roomId]) @@unique([userId, roomId])

View file

@ -425,6 +425,7 @@ export class GameGateway implements OnGatewayConnection, OnGatewayDisconnect, On
hostId: room.hostId, hostId: room.hostId,
themeId: (room as any).themeId || null, themeId: (room as any).themeId || null,
particlesEnabled: (room as any).particlesEnabled !== undefined ? (room as any).particlesEnabled : null, particlesEnabled: (room as any).particlesEnabled !== undefined ? (room as any).particlesEnabled : null,
maxPlayers: (room as any).maxPlayers || 10,
participants: room.participants.map((p) => ({ participants: room.participants.map((p) => ({
id: p.id, id: p.id,
userId: p.userId, userId: p.userId,
@ -583,6 +584,27 @@ export class GameGateway implements OnGatewayConnection, OnGatewayDisconnect, On
await this.broadcastFullState(payload.roomCode); 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') @SubscribeMessage('updateRoomPack')
async handleUpdateRoomPack(client: Socket, payload: { roomId: string; roomCode: string; userId: string; questions: any[] }) { async handleUpdateRoomPack(client: Socket, payload: { roomId: string; roomCode: string; userId: string; questions: any[] }) {
const isHost = await this.isHost(payload.roomId, payload.userId); 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, { this.roomEventsService.emitPlayerKicked(payload.roomCode, {
participantId: payload.participantId, participantId: payload.participantId,
userId: participant.userId, userId: participant.userId || null,
participantName: participant.name, participantName: participant.name,
newCurrentPlayerId, newCurrentPlayerId,
}); });

View file

@ -150,6 +150,92 @@ export class RoomsService {
return participant; 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') { async updateRoomStatus(roomId: string, status: 'WAITING' | 'PLAYING' | 'FINISHED') {
return this.prisma.room.update({ return this.prisma.room.update({
where: { id: roomId }, where: { id: roomId },
@ -391,6 +477,11 @@ export class RoomsService {
throw new BadRequestException('Spectators are not allowed in this room'); 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') { if (participant.role === 'HOST' && newRole !== 'HOST') {
const hostCount = await this.prisma.participant.count({ const hostCount = await this.prisma.participant.count({

View file

@ -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 - Массив вопросов * @param questions - Массив вопросов
* @returns Массив вопросов с добавленными UUID * @returns Массив вопросов с добавленными UUID
*/ */
export function ensureQuestionIds(questions: Question[]): Question[] { export function ensureQuestionIds(questions: Question[]): Question[] {
return questions.map((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 questionText = question.text || question.question || '';
const answersWithIds = question.answers.map((answer) => ({ const answersWithIds = question.answers.map((answer) => {
// Если ID нет или не является валидным UUID, создаем новый
const answerId = (answer.id && isValidUUID(answer.id)) ? answer.id : randomUUID();
return {
...answer, ...answer,
id: answer.id || randomUUID(), id: answerId,
})); };
});
return { return {
...question, ...question,

View file

@ -33,6 +33,8 @@ const GameManagementModal = ({
particlesEnabled = null, particlesEnabled = null,
onToggleParticles, onToggleParticles,
initialTab = 'players', initialTab = 'players',
onAddPlayer,
room,
}) => { }) => {
const { currentThemeData } = useTheme() const { currentThemeData } = useTheme()
const [activeTab, setActiveTab] = useState(initialTab) // players | game | scoring | questions const [activeTab, setActiveTab] = useState(initialTab) // players | game | scoring | questions
@ -57,6 +59,10 @@ const GameManagementModal = ({
const [editingPlayerScore, setEditingPlayerScore] = useState('') const [editingPlayerScore, setEditingPlayerScore] = useState('')
const [editMode, setEditMode] = useState(null) // 'name' | 'score' const [editMode, setEditMode] = useState(null) // 'name' | 'score'
// Add player state
const [newPlayerName, setNewPlayerName] = useState('')
const [newPlayerRole, setNewPlayerRole] = useState('PLAYER')
// Questions management state // Questions management state
const [editingQuestion, setEditingQuestion] = useState(null) const [editingQuestion, setEditingQuestion] = useState(null)
const [questionText, setQuestionText] = useState('') const [questionText, setQuestionText] = useState('')
@ -530,6 +536,89 @@ const GameManagementModal = ({
{activeTab === 'players' && ( {activeTab === 'players' && (
<div className="tab-content"> <div className="tab-content">
<h3>Участники ({participants.length})</h3> <h3>Участники ({participants.length})</h3>
{/* Форма добавления игрока - только для хоста */}
{onAddPlayer && (
<div className="add-player-form" style={{
marginBottom: '20px',
padding: '15px',
background: 'rgba(255, 255, 255, 0.05)',
borderRadius: '8px',
border: '1px solid rgba(255, 215, 0, 0.2)',
}}>
<h4 style={{ marginTop: 0, marginBottom: '10px', fontSize: '0.95rem' }}> Добавить игрока</h4>
<div style={{ display: 'flex', gap: '10px', alignItems: 'center', flexWrap: 'wrap' }}>
<input
type="text"
value={newPlayerName}
onChange={(e) => 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('')
}
}}
/>
<select
value={newPlayerRole}
onChange={(e) => setNewPlayerRole(e.target.value)}
style={{
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',
cursor: 'pointer',
}}
>
<option value="PLAYER">🎮 Игрок</option>
<option value="SPECTATOR">👀 Зритель</option>
</select>
<button
onClick={() => {
if (newPlayerName.trim()) {
onAddPlayer(newPlayerName.trim(), newPlayerRole)
setNewPlayerName('')
}
}}
disabled={!newPlayerName.trim()}
className="mgmt-button"
style={{
padding: '8px 16px',
fontSize: '0.9rem',
opacity: newPlayerName.trim() ? 1 : 0.5,
cursor: newPlayerName.trim() ? 'pointer' : 'not-allowed',
}}
>
Добавить
</button>
</div>
{room && room.maxPlayers && participants.length >= room.maxPlayers && (
<p style={{
marginTop: '8px',
marginBottom: 0,
fontSize: '0.85rem',
color: 'rgba(255, 100, 100, 0.8)',
}}>
Достигнут лимит игроков ({room.maxPlayers})
</p>
)}
</div>
)}
<div className="players-list"> <div className="players-list">
{participants.length === 0 ? ( {participants.length === 0 ? (
<p className="empty-message">Нет участников</p> <p className="empty-message">Нет участников</p>
@ -579,6 +668,11 @@ const GameManagementModal = ({
return; return;
} }
} }
// Игроки без userId не могут стать хостами
if (newRole === 'HOST' && !participant.userId) {
alert('Игроки без аккаунта не могут стать хостами');
return;
}
onChangeParticipantRole(participant.id, newRole); onChangeParticipantRole(participant.id, newRole);
}} }}
style={{ style={{
@ -592,7 +686,7 @@ const GameManagementModal = ({
}} }}
title="Изменить роль участника" title="Изменить роль участника"
> >
<option value="HOST">👑 Ведущий</option> <option value="HOST" disabled={!participant.userId}>👑 Ведущий</option>
<option value="PLAYER">🎮 Игрок</option> <option value="PLAYER">🎮 Игрок</option>
<option value="SPECTATOR">👀 Зритель</option> <option value="SPECTATOR">👀 Зритель</option>
</select> </select>

View file

@ -32,6 +32,7 @@ const GamePage = () => {
roomCode: null, roomCode: null,
themeId: null, themeId: null,
particlesEnabled: null, // null = использовать настройку из темы, true/false = override particlesEnabled: null, // null = использовать настройку из темы, true/false = override
maxPlayers: 10,
}); });
const [loading, setLoading] = useState(true); 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) => { const handleSelectPlayer = (participantId) => {
if (!gameState.roomId || !user) return; if (!gameState.roomId || !user) return;
if (!isHost) return; // Только хост может выбирать игрока if (!isHost) return; // Только хост может выбирать игрока
@ -547,7 +559,8 @@ const GamePage = () => {
id: gameState.roomId, id: gameState.roomId,
code: gameState.roomCode, code: gameState.roomCode,
status: gameState.status, status: gameState.status,
hostId: gameState.hostId hostId: gameState.hostId,
maxPlayers: gameState.maxPlayers || 10
}} }}
participants={gameState.participants} participants={gameState.participants}
currentQuestion={currentQuestion} currentQuestion={currentQuestion}
@ -573,6 +586,7 @@ const GamePage = () => {
onChangeParticipantRole={handleChangeParticipantRole} onChangeParticipantRole={handleChangeParticipantRole}
particlesEnabled={gameState.particlesEnabled} particlesEnabled={gameState.particlesEnabled}
onToggleParticles={handleToggleParticles} onToggleParticles={handleToggleParticles}
onAddPlayer={handleAddPlayer}
/> />
</> </>
)} )}

View file

@ -165,6 +165,16 @@ class SocketService {
particlesEnabled, particlesEnabled,
}); });
} }
addPlayer(roomId, roomCode, userId, playerName, role = 'PLAYER') {
this.emit('addPlayer', {
roomId,
roomCode,
userId,
playerName,
role,
});
}
} }
export default new SocketService(); export default new SocketService();