stuff
This commit is contained in:
parent
9acd6a6fa9
commit
dbc43d65c1
7 changed files with 259 additions and 11 deletions
|
|
@ -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])
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
||||||
...answer,
|
// Если ID нет или не является валидным UUID, создаем новый
|
||||||
id: answer.id || randomUUID(),
|
const answerId = (answer.id && isValidUUID(answer.id)) ? answer.id : randomUUID();
|
||||||
}));
|
return {
|
||||||
|
...answer,
|
||||||
|
id: answerId,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...question,
|
...question,
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue