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 {
|
||||
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])
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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' && (
|
||||
<div className="tab-content">
|
||||
<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">
|
||||
{participants.length === 0 ? (
|
||||
<p className="empty-message">Нет участников</p>
|
||||
|
|
@ -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="Изменить роль участника"
|
||||
>
|
||||
<option value="HOST">👑 Ведущий</option>
|
||||
<option value="HOST" disabled={!participant.userId}>👑 Ведущий</option>
|
||||
<option value="PLAYER">🎮 Игрок</option>
|
||||
<option value="SPECTATOR">👀 Зритель</option>
|
||||
</select>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in a new issue