From 8fb5c5f682d26f8e33a18d152550c2b4ca0d0055 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EB=AF=B8=EB=9D=BC?= Date: Thu, 28 Nov 2024 15:58:52 +0900 Subject: [PATCH 1/4] feat: Implement room and player validation with Redis --- server/src/chat/chat.gateway.ts | 7 ++++++- server/src/chat/chat.repository.ts | 10 ++++++++++ server/src/chat/chat.service.ts | 8 ++++++++ server/src/drawing/drawing.gateway.ts | 10 +++++++++- server/src/drawing/drawing.repository.ts | 17 +++++++++++++++++ server/src/drawing/drawing.service.ts | 15 +++++++++++++++ server/src/redis/redis.service.ts | 4 ++++ 7 files changed, 69 insertions(+), 2 deletions(-) create mode 100644 server/src/drawing/drawing.repository.ts create mode 100644 server/src/drawing/drawing.service.ts diff --git a/server/src/chat/chat.gateway.ts b/server/src/chat/chat.gateway.ts index 89d98b1c..08c7f10a 100644 --- a/server/src/chat/chat.gateway.ts +++ b/server/src/chat/chat.gateway.ts @@ -3,7 +3,7 @@ import { Server, Socket } from 'socket.io'; import { ChatService } from './chat.service'; import { UseFilters } from '@nestjs/common'; import { WsExceptionFilter } from 'src/filters/ws-exception.filter'; -import { BadRequestException } from 'src/exceptions/game.exception'; +import { BadRequestException, PlayerNotFoundException, RoomNotFoundException } from 'src/exceptions/game.exception'; @WebSocketGateway({ cors: '*', @@ -22,6 +22,11 @@ export class ChatGateway { if (!roomId || !playerId) throw new BadRequestException('Room ID and Player ID are required'); + const roomExists = this.chatService.existsRoom(roomId); + if (!roomExists) throw new RoomNotFoundException('Room not found'); + const playerExists = this.chatService.existsPlayer(roomId, playerId); + if (!playerExists) throw new PlayerNotFoundException('Player not found in room'); + client.data.roomId = roomId; client.data.playerId = playerId; diff --git a/server/src/chat/chat.repository.ts b/server/src/chat/chat.repository.ts index 8f23ef44..3b3a4a8e 100644 --- a/server/src/chat/chat.repository.ts +++ b/server/src/chat/chat.repository.ts @@ -17,4 +17,14 @@ export class ChatRepository { score: parseInt(player.score, 10) || 0, } as Player; } + + async existsRoom(roomId: string) { + const exists = await this.redisService.exists(`room:${roomId}`); + return exists === 1; + } + + async existsPlayer(roomId: string, playerId: string) { + const exists = await this.redisService.exists(`room:${roomId}:player:${playerId}`); + return exists === 1; + } } diff --git a/server/src/chat/chat.service.ts b/server/src/chat/chat.service.ts index 817adae8..8afaf8fb 100644 --- a/server/src/chat/chat.service.ts +++ b/server/src/chat/chat.service.ts @@ -21,4 +21,12 @@ export class ChatService { createdAt: new Date(), }; } + + async existsRoom(roomId: string) { + return await this.chatRepository.existsRoom(roomId); + } + + async existsPlayer(roomId: string, playerId: string) { + return await this.chatRepository.existsPlayer(roomId, playerId); + } } diff --git a/server/src/drawing/drawing.gateway.ts b/server/src/drawing/drawing.gateway.ts index 61fe64bd..c369c519 100644 --- a/server/src/drawing/drawing.gateway.ts +++ b/server/src/drawing/drawing.gateway.ts @@ -8,8 +8,9 @@ import { WebSocketServer, } from '@nestjs/websockets'; import { Server, Socket } from 'socket.io'; -import { BadRequestException } from 'src/exceptions/game.exception'; +import { BadRequestException, PlayerNotFoundException, RoomNotFoundException } from 'src/exceptions/game.exception'; import { WsExceptionFilter } from 'src/filters/ws-exception.filter'; +import { DrawingService } from './drawing.service'; @WebSocketGateway({ cors: '*', @@ -20,12 +21,19 @@ export class DrawingGateway implements OnGatewayConnection { @WebSocketServer() server: Server; + constructor(private readonly drawingService: DrawingService) {} + handleConnection(client: Socket) { const roomId = client.handshake.auth.roomId; const playerId = client.handshake.auth.playerId; if (!roomId || !playerId) throw new BadRequestException('Room ID and Player ID are required'); + const roomExists = this.drawingService.existsRoom(roomId); + if (!roomExists) throw new RoomNotFoundException('Room not found'); + const playerExists = this.drawingService.existsPlayer(roomId, playerId); + if (!playerExists) throw new PlayerNotFoundException('Player not found in room'); + client.data.roomId = roomId; client.data.playerId = playerId; diff --git a/server/src/drawing/drawing.repository.ts b/server/src/drawing/drawing.repository.ts new file mode 100644 index 00000000..817cf9be --- /dev/null +++ b/server/src/drawing/drawing.repository.ts @@ -0,0 +1,17 @@ +import { Injectable } from '@nestjs/common'; +import { RedisService } from 'src/redis/redis.service'; + +@Injectable() +export class DrawingRepository { + constructor(private readonly redisService: RedisService) {} + + async existsRoom(roomId: string) { + const exists = await this.redisService.exists(`room:${roomId}`); + return exists === 1; + } + + async existsPlayer(roomId: string, playerId: string) { + const exists = await this.redisService.exists(`room:${roomId}:player:${playerId}`); + return exists === 1; + } +} diff --git a/server/src/drawing/drawing.service.ts b/server/src/drawing/drawing.service.ts new file mode 100644 index 00000000..89896333 --- /dev/null +++ b/server/src/drawing/drawing.service.ts @@ -0,0 +1,15 @@ +import { Injectable } from '@nestjs/common'; +import { DrawingRepository } from './drawing.repository'; + +@Injectable() +export class DrawingService { + constructor(private readonly drawingRepository: DrawingRepository) {} + + async existsRoom(roomId: string) { + return await this.drawingRepository.existsRoom(roomId); + } + + async existsPlayer(roomId: string, playerId: string) { + return await this.drawingRepository.existsPlayer(roomId, playerId); + } +} diff --git a/server/src/redis/redis.service.ts b/server/src/redis/redis.service.ts index ae3e2880..6059fb53 100644 --- a/server/src/redis/redis.service.ts +++ b/server/src/redis/redis.service.ts @@ -48,6 +48,10 @@ export class RedisService { await this.redis.lrem(key, count, value); } + async exists(key: string): Promise { + return await this.redis.exists(key); + } + multi() { return this.redis.multi(); } From c1c5924b51341f85ae55667f021cd234f3cf73dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EB=AF=B8=EB=9D=BC?= Date: Thu, 28 Nov 2024 16:33:31 +0900 Subject: [PATCH 2/4] feat: Implement winners array including painters --- server/src/game/game.service.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/server/src/game/game.service.ts b/server/src/game/game.service.ts index 166ef9d6..211ff720 100644 --- a/server/src/game/game.service.ts +++ b/server/src/game/game.service.ts @@ -266,14 +266,14 @@ export class GameService { await Promise.all( updatedPlayers.map((p) => this.gameRepository.updatePlayer(roomId, p.playerId, { score: p.score })), ); - - const winner = currentPlayer; + const painters = updatedPlayers.filter((p) => p.role === PlayerRole.PAINTER); + const winner = updatedPlayers.find((p) => p.playerId === playerId); return { isCorrect, roundNumber: room.currentRound, word: room.currentWord, - winner, + winners: [winner, ...painters], players: updatedPlayers, }; } @@ -324,7 +324,7 @@ export class GameService { return { roundNumber: room.currentRound, word: room.currentWord, - winner, + winners: [winner], players: updatedPlayers, }; } From d58f29eba0eeb99ce9af7ec6273eca9a1a453acf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EB=AF=B8=EB=9D=BC?= Date: Thu, 28 Nov 2024 16:43:08 +0900 Subject: [PATCH 3/4] feat: Prevent joining room during active game --- server/src/game/game.gateway.ts | 10 ++++++++-- server/src/game/game.repository.ts | 4 ++++ server/src/game/game.service.ts | 4 ++++ 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/server/src/game/game.gateway.ts b/server/src/game/game.gateway.ts index 6d2c9e26..bc261906 100644 --- a/server/src/game/game.gateway.ts +++ b/server/src/game/game.gateway.ts @@ -11,8 +11,8 @@ import { GameService } from './game.service'; import { UseFilters } from '@nestjs/common'; import { WsExceptionFilter } from 'src/filters/ws-exception.filter'; import { Player, Room, RoomSettings } from 'src/common/types/game.types'; -import { BadRequestException } from 'src/exceptions/game.exception'; -import { PlayerRole } from 'src/common/enums/game.status.enum'; +import { BadRequestException, RoomNotFoundException } from 'src/exceptions/game.exception'; +import { PlayerRole, RoomStatus } from 'src/common/enums/game.status.enum'; import { TimerService } from 'src/common/services/timer.service'; import { TimerType } from 'src/common/enums/game.timer.enum'; @@ -36,6 +36,12 @@ export class GameGateway implements OnGatewayDisconnect { @SubscribeMessage('joinRoom') async handleJoinRoom(@ConnectedSocket() client: Socket, @MessageBody() data: { roomId: string }) { + const roomStatus = await this.gameService.getRoomStatus(data.roomId); + if (!roomStatus) throw new RoomNotFoundException('Room not found'); + if (roomStatus === RoomStatus.GUESSING || roomStatus === RoomStatus.DRAWING) { + throw new BadRequestException('Cannot join room while game is in progress'); + } + const { room, roomSettings, player, players } = await this.gameService.joinRoom(data.roomId); client.data.playerId = player.playerId; diff --git a/server/src/game/game.repository.ts b/server/src/game/game.repository.ts index 3eee8af7..28aa5ed9 100644 --- a/server/src/game/game.repository.ts +++ b/server/src/game/game.repository.ts @@ -39,6 +39,10 @@ export class GameRepository { await multi.exec(); } + async getRoomStatus(roomId: string) { + return this.redisService.hget(`room:${roomId}`, 'status') as Promise; + } + async getRoomSettings(roomId: string) { const settings = await this.redisService.hgetall(`room:${roomId}:settings`); diff --git a/server/src/game/game.service.ts b/server/src/game/game.service.ts index 211ff720..10afd5f0 100644 --- a/server/src/game/game.service.ts +++ b/server/src/game/game.service.ts @@ -78,6 +78,10 @@ export class GameService { return { room, roomSettings, player, players: updatedPlayers }; } + getRoomStatus(roomId: string) { + return this.gameRepository.getRoomStatus(roomId); + } + async reconnect(roomId: string, playerId: string) { const [room, roomSettings, players] = await Promise.all([ this.gameRepository.getRoom(roomId), From 8ba5ed5bb45764995a49e12dbe43380f880140af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EB=AF=B8=EB=9D=BC?= Date: Thu, 28 Nov 2024 17:07:39 +0900 Subject: [PATCH 4/4] fix: Add GAME_ALREADY_STARTED exception for game state validation --- server/src/drawing/drawing.module.ts | 6 +++++- server/src/exceptions/game.exception.ts | 5 +++++ server/src/game/game.gateway.ts | 4 ++-- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/server/src/drawing/drawing.module.ts b/server/src/drawing/drawing.module.ts index 13c0645b..541f54a4 100644 --- a/server/src/drawing/drawing.module.ts +++ b/server/src/drawing/drawing.module.ts @@ -1,7 +1,11 @@ import { Module } from '@nestjs/common'; import { DrawingGateway } from './drawing.gateway'; +import { RedisModule } from 'src/redis/redis.module'; +import { DrawingService } from './drawing.service'; +import { DrawingRepository } from './drawing.repository'; @Module({ - providers: [DrawingGateway], + imports: [RedisModule], + providers: [DrawingGateway, DrawingService, DrawingRepository], }) export class DrawingModule {} diff --git a/server/src/exceptions/game.exception.ts b/server/src/exceptions/game.exception.ts index 920d12f0..a05d277b 100644 --- a/server/src/exceptions/game.exception.ts +++ b/server/src/exceptions/game.exception.ts @@ -44,3 +44,8 @@ export class ForbiddenException extends GameException { super(SocketErrorCode.FORBIDDEN, message); } } +export class GameAlreadyStartedException extends GameException { + constructor(message: string = 'Game already started') { + super(SocketErrorCode.GAME_ALREADY_STARTED, message); + } +} diff --git a/server/src/game/game.gateway.ts b/server/src/game/game.gateway.ts index bc261906..4f5c62b6 100644 --- a/server/src/game/game.gateway.ts +++ b/server/src/game/game.gateway.ts @@ -11,7 +11,7 @@ import { GameService } from './game.service'; import { UseFilters } from '@nestjs/common'; import { WsExceptionFilter } from 'src/filters/ws-exception.filter'; import { Player, Room, RoomSettings } from 'src/common/types/game.types'; -import { BadRequestException, RoomNotFoundException } from 'src/exceptions/game.exception'; +import { BadRequestException, GameAlreadyStartedException, RoomNotFoundException } from 'src/exceptions/game.exception'; import { PlayerRole, RoomStatus } from 'src/common/enums/game.status.enum'; import { TimerService } from 'src/common/services/timer.service'; import { TimerType } from 'src/common/enums/game.timer.enum'; @@ -39,7 +39,7 @@ export class GameGateway implements OnGatewayDisconnect { const roomStatus = await this.gameService.getRoomStatus(data.roomId); if (!roomStatus) throw new RoomNotFoundException('Room not found'); if (roomStatus === RoomStatus.GUESSING || roomStatus === RoomStatus.DRAWING) { - throw new BadRequestException('Cannot join room while game is in progress'); + throw new GameAlreadyStartedException('Cannot join room while game is in progress'); } const { room, roomSettings, player, players } = await this.gameService.joinRoom(data.roomId);