This commit is contained in:
Dmitry 2026-01-09 01:44:20 +03:00
parent 4dee8677df
commit 0fb6e53d7a
4 changed files with 10041 additions and 57 deletions

View file

@ -4,4 +4,5 @@ alwaysApply: true
App runs on a dedicated server with coolify and docker. App runs on a dedicated server with coolify and docker.
Migrations are run via docker on the server. Migrations are run via docker on the server.
Keep code clean, never add todos and stubs outside tests. Keep code clean, never add todos and stubs outside tests.
Use strict static types, never add 'any' type
Keep the final review short. Keep the final review short.

9886
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -34,12 +34,12 @@
"@prisma/adapter-pg": "^7.2.0", "@prisma/adapter-pg": "^7.2.0",
"@prisma/client": "^7.2.0", "@prisma/client": "^7.2.0",
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"pg": "^8.13.1",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.3", "class-validator": "^0.14.3",
"nanoid": "^5.1.6", "nanoid": "^5.1.6",
"passport": "^0.7.0", "passport": "^0.7.0",
"passport-jwt": "^4.0.1", "passport-jwt": "^4.0.1",
"pg": "^8.13.1",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"socket.io": "^4.8.3" "socket.io": "^4.8.3"

View file

@ -11,6 +11,7 @@ import { RoomsService } from '../rooms/rooms.service';
import { RoomEventsService } from './room-events.service'; import { RoomEventsService } from './room-events.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { RoomPackService } from '../room-pack/room-pack.service'; import { RoomPackService } from '../room-pack/room-pack.service';
import { Prisma } from '@prisma/client';
interface PlayerAction { interface PlayerAction {
action: 'revealAnswer' | 'nextQuestion' | 'prevQuestion'; action: 'revealAnswer' | 'nextQuestion' | 'prevQuestion';
@ -23,6 +24,30 @@ interface PlayerAction {
answerId?: 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({ @WebSocketGateway({
cors: { cors: {
origin: process.env.CORS_ORIGIN || 'http://localhost:5173', origin: process.env.CORS_ORIGIN || 'http://localhost:5173',
@ -85,24 +110,30 @@ export class GameGateway implements OnGatewayConnection, OnGatewayDisconnect, On
await this.roomsService.updateRoomStatus(payload.roomId, 'PLAYING'); await this.roomsService.updateRoomStatus(payload.roomId, 'PLAYING');
// Инициализировать первый вопрос и игрока // Инициализировать первый вопрос и игрока
const room = await this.prisma.room.findUnique({ const room = (await this.prisma.room.findUnique({
where: { id: payload.roomId }, where: { id: payload.roomId },
include: { include: {
roomPack: true, roomPack: true,
participants: { where: { isActive: true }, orderBy: { joinedAt: 'asc' } } participants: { where: { isActive: true }, orderBy: { joinedAt: 'asc' } }
} } as Prisma.RoomInclude,
}); })) as unknown as RoomWithPack | null;
if (room) { if (room) {
const questions = room.roomPack?.questions as any[] || []; const questions = ((room.roomPack as unknown as { questions?: Question[] } | null)?.questions || []) as Question[];
const firstQuestion = questions[0]; const firstQuestion = questions[0];
const firstParticipant = room.participants[0]; const firstParticipant = room.participants[0];
if (firstQuestion && firstParticipant) { // Убеждаемся что firstQuestion.id - строка (UUID)
const firstQuestionId = firstQuestion?.id && typeof firstQuestion.id === 'string'
? firstQuestion.id
: null;
if (firstQuestionId && firstParticipant) {
await this.prisma.room.update({ await this.prisma.room.update({
where: { id: payload.roomId }, where: { id: payload.roomId },
data: { data: {
currentQuestionId: firstQuestion.id, currentQuestionId: firstQuestionId,
currentQuestionIndex: 0,
currentPlayerId: firstParticipant.id, currentPlayerId: firstParticipant.id,
} }
}); });
@ -115,13 +146,13 @@ export class GameGateway implements OnGatewayConnection, OnGatewayDisconnect, On
@SubscribeMessage('playerAction') @SubscribeMessage('playerAction')
async handlePlayerAction(client: Socket, payload: PlayerAction) { async handlePlayerAction(client: Socket, payload: PlayerAction) {
// Получаем комнату с данными // Получаем комнату с данными
const room = await this.prisma.room.findUnique({ const room = (await this.prisma.room.findUnique({
where: { id: payload.roomId }, where: { id: payload.roomId },
include: { include: {
roomPack: true, roomPack: true,
participants: { where: { isActive: true }, orderBy: { joinedAt: 'asc' } } participants: { where: { isActive: true }, orderBy: { joinedAt: 'asc' } }
}, } as unknown as Prisma.RoomInclude,
}); })) as unknown as RoomWithPack | null;
if (!room) { if (!room) {
client.emit('error', { message: 'Room not found' }); client.emit('error', { message: 'Room not found' });
@ -165,7 +196,7 @@ export class GameGateway implements OnGatewayConnection, OnGatewayDisconnect, On
return; return;
} }
const questions = room.roomPack?.questions as any[] || []; const questions = ((room.roomPack as { questions?: Question[] } | null)?.questions || []) as Question[];
const question = questions.find(q => q.id === payload.questionId); const question = questions.find(q => q.id === payload.questionId);
if (!question) { if (!question) {
@ -180,8 +211,8 @@ export class GameGateway implements OnGatewayConnection, OnGatewayDisconnect, On
} }
// Обновляем revealedAnswers // Обновляем revealedAnswers
const revealed = (room.revealedAnswers as any) || {}; const revealed = (room.revealedAnswers as RevealedAnswers) || {};
const currentRevealed: string[] = revealed[payload.questionId] || []; const currentRevealed: string[] = revealed[payload.questionId || ''] || [];
if (!currentRevealed.includes(payload.answerId)) { if (!currentRevealed.includes(payload.answerId)) {
currentRevealed.push(payload.answerId); currentRevealed.push(payload.answerId);
@ -196,12 +227,12 @@ export class GameGateway implements OnGatewayConnection, OnGatewayDisconnect, On
// Сохраняем revealedAnswers // Сохраняем revealedAnswers
await this.prisma.room.update({ await this.prisma.room.update({
where: { id: payload.roomId }, where: { id: payload.roomId },
data: { revealedAnswers: revealed } data: { revealedAnswers: revealed as Prisma.InputJsonValue }
}); });
// Определяем следующего игрока // Определяем следующего игрока
const participants = room.participants; const participants = room.participants;
const currentIdx = participants.findIndex((p: any) => p.id === payload.participantId); const currentIdx = participants.findIndex((p) => p.id === payload.participantId);
const nextIdx = (currentIdx + 1) % participants.length; const nextIdx = (currentIdx + 1) % participants.length;
const nextPlayerId = participants[nextIdx]?.id; const nextPlayerId = participants[nextIdx]?.id;
@ -240,9 +271,9 @@ export class GameGateway implements OnGatewayConnection, OnGatewayDisconnect, On
} }
} }
private async handleNextQuestionAction(payload: PlayerAction, room: any) { private async handleNextQuestionAction(payload: PlayerAction, room: RoomWithPack) {
const questions = room.roomPack?.questions as any[] || []; const questions = ((room.roomPack as { questions?: Question[] } | null)?.questions || []) as Question[];
const currentIdx = questions.findIndex((q: any) => q.id === room.currentQuestionId); const currentIdx = questions.findIndex((q) => q.id === (room.currentQuestionId as string | null));
if (currentIdx < questions.length - 1) { if (currentIdx < questions.length - 1) {
const nextQuestion = questions[currentIdx + 1]; const nextQuestion = questions[currentIdx + 1];
@ -250,15 +281,15 @@ export class GameGateway implements OnGatewayConnection, OnGatewayDisconnect, On
where: { id: payload.roomId }, where: { id: payload.roomId },
data: { data: {
currentQuestionId: nextQuestion.id, currentQuestionId: nextQuestion.id,
currentQuestionIndex: currentIdx + 1 // Для совместимости currentQuestionIndex: currentIdx + 1
} }
}); });
} }
} }
private async handlePrevQuestionAction(payload: PlayerAction, room: any) { private async handlePrevQuestionAction(payload: PlayerAction, room: RoomWithPack) {
const questions = room.roomPack?.questions as any[] || []; const questions = ((room.roomPack as { questions?: Question[] } | null)?.questions || []) as Question[];
const currentIdx = questions.findIndex((q: any) => q.id === room.currentQuestionId); const currentIdx = questions.findIndex((q) => q.id === (room.currentQuestionId as string | null));
if (currentIdx > 0) { if (currentIdx > 0) {
const prevQuestion = questions[currentIdx - 1]; const prevQuestion = questions[currentIdx - 1];
@ -266,7 +297,7 @@ export class GameGateway implements OnGatewayConnection, OnGatewayDisconnect, On
where: { id: payload.roomId }, where: { id: payload.roomId },
data: { data: {
currentQuestionId: prevQuestion.id, currentQuestionId: prevQuestion.id,
currentQuestionIndex: currentIdx - 1 // Для совместимости currentQuestionIndex: currentIdx - 1
} }
}); });
} }
@ -274,7 +305,7 @@ export class GameGateway implements OnGatewayConnection, OnGatewayDisconnect, On
// КЛЮЧЕВОЙ МЕТОД: Отправка полного состояния // КЛЮЧЕВОЙ МЕТОД: Отправка полного состояния
private async broadcastFullState(roomCode: string) { private async broadcastFullState(roomCode: string) {
const room = await this.prisma.room.findUnique({ const room = (await this.prisma.room.findUnique({
where: { code: roomCode }, where: { code: roomCode },
include: { include: {
participants: { participants: {
@ -283,22 +314,39 @@ export class GameGateway implements OnGatewayConnection, OnGatewayDisconnect, On
}, },
roomPack: true, roomPack: true,
host: { select: { id: true, name: true } } host: { select: { id: true, name: true } }
} } as Prisma.RoomInclude,
}); })) as unknown as RoomWithPack | null;
if (!room) return; if (!room) return;
const questions = room.roomPack?.questions as any[] || []; const questions = ((room.roomPack as unknown as { questions?: Question[] } | null)?.questions || []) as Question[];
// Инициализация currentQuestionId если не установлен // Инициализация currentQuestionId если не установлен или невалиден
let currentQuestionId = room.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) { if (!currentQuestionId && questions.length > 0) {
currentQuestionId = questions[0].id; const firstQuestion = questions[0];
// Убеждаемся что id - строка (UUID)
if (firstQuestion.id && typeof firstQuestion.id === 'string') {
currentQuestionId = firstQuestion.id;
await this.prisma.room.update({ await this.prisma.room.update({
where: { id: room.id }, where: { id: room.id },
data: { currentQuestionId } data: {
currentQuestionId: currentQuestionId,
currentQuestionIndex: 0
}
}); });
} }
}
const fullState = { const fullState = {
roomId: room.id, roomId: room.id,
@ -306,20 +354,20 @@ export class GameGateway implements OnGatewayConnection, OnGatewayDisconnect, On
status: room.status, status: room.status,
currentQuestionId: currentQuestionId, currentQuestionId: currentQuestionId,
currentPlayerId: room.currentPlayerId, currentPlayerId: room.currentPlayerId,
revealedAnswers: room.revealedAnswers, revealedAnswers: room.revealedAnswers as RevealedAnswers,
isGameOver: room.isGameOver, isGameOver: room.isGameOver,
hostId: room.hostId, hostId: room.hostId,
participants: room.participants.map(p => ({ participants: room.participants.map((p) => ({
id: p.id, id: p.id,
userId: p.userId, userId: p.userId,
name: p.name, name: p.name,
role: p.role, role: p.role,
score: p.score score: p.score
})), })),
questions: questions.map((q: any) => ({ questions: questions.map((q) => ({
id: q.id, id: q.id,
text: q.text || q.question, text: q.text || q.question || '',
answers: (q.answers || []).map((a: any) => ({ answers: (q.answers || []).map((a) => ({
id: a.id, id: a.id,
text: a.text, text: a.text,
points: a.points points: a.points
@ -355,30 +403,37 @@ export class GameGateway implements OnGatewayConnection, OnGatewayDisconnect, On
return; return;
} }
const room = await this.prisma.room.findUnique({ const room = (await this.prisma.room.findUnique({
where: { id: payload.roomId }, where: { id: payload.roomId },
include: { include: {
roomPack: true, roomPack: true,
participants: { where: { isActive: true }, orderBy: { joinedAt: 'asc' } } participants: { where: { isActive: true }, orderBy: { joinedAt: 'asc' } }
} } as Prisma.RoomInclude,
}); })) as unknown as RoomWithPack | null;
const questions = room?.roomPack?.questions as any[] || []; if (room) {
const questions = ((room.roomPack as unknown as { questions?: Question[] } | null)?.questions || []) as Question[];
const firstQuestion = questions[0]; const firstQuestion = questions[0];
const firstParticipant = room?.participants[0]; const firstParticipant = room.participants[0];
// Убеждаемся что firstQuestion.id - строка (UUID)
const firstQuestionId = firstQuestion?.id && typeof firstQuestion.id === 'string'
? firstQuestion.id
: null;
await this.prisma.room.update({ await this.prisma.room.update({
where: { id: payload.roomId }, where: { id: payload.roomId },
data: { data: {
status: 'WAITING', status: 'WAITING',
currentQuestionId: firstQuestionId,
currentQuestionIndex: 0, currentQuestionIndex: 0,
currentQuestionId: firstQuestion?.id || null, revealedAnswers: {} as Prisma.InputJsonValue,
revealedAnswers: {},
currentPlayerId: firstParticipant?.id || null, currentPlayerId: firstParticipant?.id || null,
isGameOver: false, isGameOver: false,
answeredQuestions: 0, answeredQuestions: 0,
}, }
}); });
}
await this.prisma.participant.updateMany({ await this.prisma.participant.updateMany({
where: { roomId: payload.roomId }, where: { roomId: payload.roomId },
@ -396,7 +451,49 @@ export class GameGateway implements OnGatewayConnection, OnGatewayDisconnect, On
return; return;
} }
// Обновляем вопросы через service (который добавит UUID если нужно)
await this.roomsService.updateRoomPack(payload.roomId, payload.questions); 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); await this.broadcastFullState(payload.roomCode);
} }