diff --git a/be/gameServer/src/common/constant.ts b/be/gameServer/src/common/constant.ts index 869d658..1252192 100644 --- a/be/gameServer/src/common/constant.ts +++ b/be/gameServer/src/common/constant.ts @@ -9,6 +9,7 @@ export enum ErrorMessages { ALL_PLAYERS_MUST_BE_READY = 'AllPlayersMustBeReady', NOT_ENOUGH_PLAYERS = 'NotEnoughPlayers', VALIDATION_FAILED = 'ValidationFailed', + GAME_ALREADY_IN_PROGRESS = 'GameAlreadyInProgress', } export enum RedisKeys { diff --git a/be/gameServer/src/modules/games/dto/game-data.dto.ts b/be/gameServer/src/modules/games/dto/game-data.dto.ts index adfaf2a..b776e8a 100644 --- a/be/gameServer/src/modules/games/dto/game-data.dto.ts +++ b/be/gameServer/src/modules/games/dto/game-data.dto.ts @@ -1,5 +1,8 @@ +import { PlayerDataDto } from '../../players/dto/player-data.dto'; + export class GameDataDto { gameId: string; + players: PlayerDataDto[]; alivePlayers: string[]; currentTurn: number; currentPlayer: string; diff --git a/be/gameServer/src/modules/games/dto/voice-processing-result.dto.ts b/be/gameServer/src/modules/games/dto/voice-processing-result.dto.ts index eda90ef..4027079 100644 --- a/be/gameServer/src/modules/games/dto/voice-processing-result.dto.ts +++ b/be/gameServer/src/modules/games/dto/voice-processing-result.dto.ts @@ -14,4 +14,19 @@ export class VoiceProcessingResultDto { description: '결과', }) result: string; + + @ApiProperty({ + example: '3옥도#', + type: String, + description: '음계', + required: false, + }) + note?: string; + + @ApiProperty({ + example: 99, + type: Number, + description: '발음 게임 점수', + }) + procounceScore?: number; } diff --git a/be/gameServer/src/modules/games/games-utils.ts b/be/gameServer/src/modules/games/games-utils.ts index 9e52da2..381975f 100644 --- a/be/gameServer/src/modules/games/games-utils.ts +++ b/be/gameServer/src/modules/games/games-utils.ts @@ -25,10 +25,6 @@ const SAMPLE_DATA = [ timeLimit: 6, lyrics: '중앙청 창살은 쌍창살이고 시청의 창살은 외창살이다.', }, - { - timeLimit: 4, - lyrics: '페페페페페페페페페페', - }, ]; export function createTurnData( @@ -38,8 +34,9 @@ export function createTurnData( const gameModes = [ GameMode.PRONUNCIATION, GameMode.CLEOPATRA, - // GameMode.CLEOPATRA, - // GameMode.CLEOPATRA, + GameMode.CLEOPATRA, + GameMode.CLEOPATRA, + GameMode.CLEOPATRA, ]; const gameMode = gameModes[Math.floor(Math.random() * gameModes.length)]; @@ -94,11 +91,15 @@ export function removePlayerFromGame( gameData: GameDataDto, playerNickname: string, ): void { - gameData.alivePlayers = gameData.alivePlayers.filter( - (player: string) => player !== playerNickname, - ); - - gameData.rank.unshift(playerNickname); + if (gameData.alivePlayers.includes(playerNickname)) { + gameData.alivePlayers = gameData.alivePlayers.filter( + (player: string) => player !== playerNickname, + ); + + if (!gameData.rank.includes(playerNickname)) { + gameData.rank.unshift(playerNickname); + } + } } export function noteToNumber(note: string): number { @@ -125,23 +126,29 @@ export function noteToNumber(note: string): number { } export function numberToNote(number: number): string { - const notes = [ - 'C', - 'C#', - 'D', - 'D#', - 'E', - 'F', - 'F#', - 'G', - 'G#', - 'A', - 'A#', - 'B', - ]; + const koreanNoteNames = { + C: '도', + 'C#': '도#', + D: '레', + 'D#': '레#', + E: '미', + F: '파', + 'F#': '파#', + G: '솔', + 'G#': '솔#', + A: '라', + 'A#': '라#', + B: '시', + }; + + const noteNames = Object.keys(koreanNoteNames); + const noteBase = number % 12; const octave = Math.floor(number / 12) - 1; - const noteIndex = Math.round(number) % 12; - return `${notes[noteIndex]}${octave}`; + + const noteName = noteNames[noteBase]; + const koreanNote = koreanNoteNames[noteName]; + + return `${octave}옥${koreanNote}`; } export function updatePreviousPlayers( @@ -154,22 +161,6 @@ export function updatePreviousPlayers( gameData.previousPlayers.push(playerNickname); } -const PRONOUNCE_SCORE_ORIGINAL_THRESOLHD = 50; -const PRONOUNCE_SCORE_THRESOLHD = 90; -const INCREMENT = 2 / 7; -const DECREMENT = 3; - -export function transformScore(originalScore) { - let transformed; - if (originalScore >= PRONOUNCE_SCORE_ORIGINAL_THRESOLHD) { - transformed = - PRONOUNCE_SCORE_THRESOLHD + - (originalScore - PRONOUNCE_SCORE_ORIGINAL_THRESOLHD) * INCREMENT; - } else { - transformed = - PRONOUNCE_SCORE_THRESOLHD - - (PRONOUNCE_SCORE_ORIGINAL_THRESOLHD - originalScore) * DECREMENT; - } - - return Math.max(Math.min(Math.round(transformed), 100), 0); +export function transformScore(originalScore: number) { + return Math.min(originalScore + 50, 100); } diff --git a/be/gameServer/src/modules/games/games.gateway.spec.ts b/be/gameServer/src/modules/games/games.gateway.spec.ts deleted file mode 100644 index dfea66f..0000000 --- a/be/gameServer/src/modules/games/games.gateway.spec.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { GamesGateway } from './games.gateway'; -import { RedisService } from '../../redis/redis.service'; -import { Logger } from '@nestjs/common'; -import { Server, Socket } from 'socket.io'; -import { RoomDataDto } from '../rooms/dto/room-data.dto'; - -describe('GamesGateway', () => { - let gateway: GamesGateway; - let redisService: RedisService; - let mockServer: Server; - let mockHostClient: Socket; - let mockLogger: Logger; - - beforeEach(async () => { - const redisServiceMock = { - set: jest.fn(), - get: jest.fn(), - delete: jest.fn(), - }; - - const module: TestingModule = await Test.createTestingModule({ - providers: [ - GamesGateway, - { provide: RedisService, useValue: redisServiceMock }, - { provide: Logger, useValue: mockLogger }, - ], - }).compile(); - - gateway = module.get(GamesGateway); - redisService = module.get(RedisService); - - mockServer = { - to: jest.fn().mockReturnValue({ - emit: jest.fn(), - }), - } as unknown as Server; - - mockHostClient = { - emit: jest.fn(), - data: { - roomId: 'test-room-id', - playerNickname: 'hostPlayer', - }, - } as unknown as Socket; - - gateway.server = mockServer; - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - describe('handleStartGame', () => { - it('모든 플레이어가 준비되지 않았을 경우 오류를 반환해야 한다.', async () => { - const roomData: RoomDataDto = { - roomId: 'test-room-id', - roomName: 'testRoomName', - hostNickname: 'hostPlayer', - players: [ - { playerNickname: 'hostPlayer', isReady: true, isMuted: false }, - { playerNickname: 'player1', isReady: false, isMuted: false }, - ], - status: 'waiting', - }; - - jest - .spyOn(redisService, 'get') - .mockResolvedValueOnce(JSON.stringify(roomData)); - - await gateway.handleStartGame(mockHostClient); - - expect(redisService.get).toHaveBeenCalledWith('room:test-room-id'); - expect(mockHostClient.emit).toHaveBeenCalledWith( - 'error', - 'All players must be ready to start the game', - ); - }); - - it('호스트가 아닌 사용자가 게임 시작을 요청할 경우 오류를 반환해야 한다.', async () => { - mockHostClient.data.playerNickname = 'notHostPlayer'; - - const roomData: RoomDataDto = { - roomId: 'test-room-id', - roomName: 'testRoomName', - hostNickname: 'hostPlayer', - players: [ - { playerNickname: 'hostPlayer', isReady: true, isMuted: false }, - { playerNickname: 'player1', isReady: true, isMuted: false }, - ], - status: 'waiting', - }; - - jest - .spyOn(redisService, 'get') - .mockResolvedValueOnce(JSON.stringify(roomData)); - - await gateway.handleStartGame(mockHostClient); - - expect(redisService.get).toHaveBeenCalledWith('room:test-room-id'); - expect(mockHostClient.emit).toHaveBeenCalledWith( - 'error', - 'Only the host can start the game', - ); - }); - - it('Redis에서 방 정보를 찾을 수 없을 경우 오류를 반환해야 한다.', async () => { - jest.spyOn(redisService, 'get').mockResolvedValueOnce(null); - - await gateway.handleStartGame(mockHostClient); - - expect(redisService.get).toHaveBeenCalledWith('room:test-room-id'); - expect(mockHostClient.emit).toHaveBeenCalledWith( - 'error', - 'Room not found', - ); - }); - - it('Redis에서 예외가 발생할 경우 오류를 반환해야 한다.', async () => { - jest - .spyOn(redisService, 'get') - .mockRejectedValueOnce(new Error('Redis error')); - - await gateway.handleStartGame(mockHostClient); - - expect(mockHostClient.emit).toHaveBeenCalledWith('error', { - message: 'Failed to start the game', - }); - }); - }); -}); diff --git a/be/gameServer/src/modules/games/games.gateway.ts b/be/gameServer/src/modules/games/games.gateway.ts index f3767ee..95c9b3a 100644 --- a/be/gameServer/src/modules/games/games.gateway.ts +++ b/be/gameServer/src/modules/games/games.gateway.ts @@ -27,7 +27,7 @@ import { import { ErrorMessages } from '../../common/constant'; const VOICE_SERVERS = 'voice-servers'; -const PRONOUNCE_SCORE_THRESOLHD = 50; +const PRONOUNCE_SCORE_THRESOLHD = 40; @WebSocketGateway({ namespace: '/rooms', @@ -104,6 +104,7 @@ export class GamesGateway implements OnGatewayDisconnect { const gameData: GameDataDto = { gameId: roomId, + players: roomData.players, alivePlayers, rank: [], currentTurn: 1, @@ -208,23 +209,22 @@ export class GamesGateway implements OnGatewayDisconnect { @MessageBody() voiceResultFromServerDto: VoiceResultFromServerDto, @ConnectedSocket() client: Socket, ) { - try { - const { roomId, playerNickname, averageNote, pronounceScore } = - voiceResultFromServerDto; - - this.logger.log( - `Received voice result for roomId: ${roomId}, player: ${playerNickname}, averageNote: ${averageNote}, pronounceScore: ${pronounceScore}`, - ); - - const gameDataString = await this.redisService.get( - `game:${roomId}`, - ); - if (!gameDataString) { - return client.emit('error', ErrorMessages.GAME_NOT_FOUND); - } + const { roomId, playerNickname, averageNote, pronounceScore } = + voiceResultFromServerDto; + this.logger.log( + `Received voice result for roomId: ${roomId}, player: ${playerNickname}, averageNote: ${averageNote}, pronounceScore: ${pronounceScore}`, + ); + + const gameDataString = await this.redisService.get( + `game:${roomId}`, + ); + if (!gameDataString) { + return client.emit('error', ErrorMessages.GAME_NOT_FOUND); + } - const gameData: GameDataDto = JSON.parse(gameDataString); + const gameData: GameDataDto = JSON.parse(gameDataString); + try { if (averageNote !== undefined) { const note = noteToNumber(averageNote); this.logger.log( @@ -237,13 +237,7 @@ export class GamesGateway implements OnGatewayDisconnect { this.server.to(roomId).emit('voiceProcessingResult', { result: 'PASS', playerNickname, - note: averageNote, - preNote: numberToNote(gameData.previousPitch), - prelayerNickname: - gameData.previousPlayers.length === 0 - ? null - : gameData.previousPlayers[gameData.previousPlayers.length - 1], - previousPlayerNote: numberToNote(gameData.previousPitch), + note: numberToNote(note), }); gameData.previousPitch = note; } else { @@ -253,14 +247,18 @@ export class GamesGateway implements OnGatewayDisconnect { this.server.to(roomId).emit('voiceProcessingResult', { result: 'FAIL', playerNickname, - playerNote: averageNote, - previousPlayerNickname: - gameData.previousPlayers.length === 0 - ? null - : gameData.previousPlayers[gameData.previousPlayers.length - 1], - previousPlayerNote: numberToNote(gameData.previousPitch), + note: numberToNote(note), }); removePlayerFromGame(gameData, playerNickname); + + const player = gameData.players.find( + (p) => p.playerNickname === playerNickname, + ); + + if (player) { + player.isDead = true; + this.server.to(roomId).emit('updateUsers', gameData.players); + } } } else if (pronounceScore !== undefined) { this.logger.log( @@ -279,6 +277,14 @@ export class GamesGateway implements OnGatewayDisconnect { pronounceScore: transformScore(pronounceScore), }); removePlayerFromGame(gameData, playerNickname); + const player = gameData.players.find( + (p) => p.playerNickname === playerNickname, + ); + + if (player) { + player.isDead = true; + this.server.to(roomId).emit('updateUsers', gameData.players); + } } } else { this.logger.log('pronounceScore nor averageNote'); @@ -297,9 +303,36 @@ export class GamesGateway implements OnGatewayDisconnect { await this.redisService.set(`game:${roomId}`, JSON.stringify(gameData)); } catch (error) { this.logger.error('Error handling voiceResult:', error); - client.emit('error', ErrorMessages.INTERNAL_ERROR); + this.server.to(roomId).emit('voiceProcessingResult', { + result: 'FAIL', + playerNickname, + pronounceScore: 0, + note: '0옥도', + }); + + removePlayerFromGame(gameData, playerNickname); + + const player = gameData.players.find( + (p) => p.playerNickname === playerNickname, + ); + + if (player) { + player.isDead = true; + this.server.to(roomId).emit('updateUsers', gameData.players); + } + + updatePreviousPlayers(gameData, playerNickname); + gameData.currentTurn++; + this.logger.log(`Turn updated: ${gameData.currentTurn}`); + gameData.currentPlayer = selectCurrentPlayer( + gameData.alivePlayers, + gameData.previousPlayers, + ); - // 오류 일때 + this.logger.log( + `Saving updated game data to Redis for roomId: ${roomId}`, + ); + await this.redisService.set(`game:${roomId}`, JSON.stringify(gameData)); } } @@ -320,6 +353,43 @@ export class GamesGateway implements OnGatewayDisconnect { removePlayerFromGame(gameData, playerNickname); + const player = gameData.players.find( + (p) => p.playerNickname === playerNickname, + ); + + if (player) { + player.isLeft = true; + await this.redisService.set(`game:${roomId}`, JSON.stringify(gameData)); + this.server.to(roomId).emit('updateUsers', gameData.players); + + if (playerNickname === gameData.currentPlayer) { + updatePreviousPlayers(gameData, playerNickname); + gameData.currentTurn++; + this.logger.log(`Turn updated: ${gameData.currentTurn}`); + gameData.currentPlayer = selectCurrentPlayer( + gameData.alivePlayers, + gameData.previousPlayers, + ); + await this.redisService.set( + `game:${roomId}`, + JSON.stringify(gameData), + ); + this.logger.log(`leaved player === currentPlayer: ${playerNickname}`); + setTimeout(() => { + this.server.to(roomId).emit('voiceProcessingResult', { + result: 'FAIL', + playerNickname, + pronounceScore: 0, + note: '탈주', + }); + + this.logger.log( + `Voice processing result sent for player: ${playerNickname}`, + ); + }, 7000); + } + } + if (gameData.alivePlayers.length <= 0) { this.logger.log(`${roomId} deleting game`); await this.redisService.delete(`game:${roomId}`); diff --git a/be/gameServer/src/modules/games/games.websocket.emit.controller.ts b/be/gameServer/src/modules/games/games.websocket.emit.controller.ts index 952f6dc..9ce648b 100644 --- a/be/gameServer/src/modules/games/games.websocket.emit.controller.ts +++ b/be/gameServer/src/modules/games/games.websocket.emit.controller.ts @@ -28,6 +28,24 @@ export class GamesWebSocketEmitController { @ApiResponse({ description: '채점 결과', type: VoiceProcessingResultDto, + examples: { + example1: { + summary: '클레오파트라게임 채점 결과', + value: { + result: 'PASS', + playerNickname: '호스트', + note: '3옥도#', + }, + }, + example2: { + summary: '발음게임 채점 결과', + value: { + result: 'PASS', + playerNickname: '플레이어', + pronounceScore: 99, + }, + }, + }, }) voiceProcessingResult() { return; diff --git a/be/gameServer/src/modules/players/dto/player-data.dto.ts b/be/gameServer/src/modules/players/dto/player-data.dto.ts index ca29676..7636d8d 100644 --- a/be/gameServer/src/modules/players/dto/player-data.dto.ts +++ b/be/gameServer/src/modules/players/dto/player-data.dto.ts @@ -18,4 +18,16 @@ export class PlayerDataDto { description: '플레이어의 음소거 상태 (true: 음소거, false: 정상)', }) isMuted: boolean; + + @ApiProperty({ + example: true, + description: '플레이어의 게임 진행 상태 (true: 탈락, false: 생존)', + }) + isDead: boolean; + + @ApiProperty({ + example: true, + description: '플레이어의 탈주 상태 (true: 탈주)', + }) + isLeft: boolean; } diff --git a/be/gameServer/src/modules/rooms/dto/create-room.dto.ts b/be/gameServer/src/modules/rooms/dto/create-room.dto.ts index 15205c4..a05d15b 100644 --- a/be/gameServer/src/modules/rooms/dto/create-room.dto.ts +++ b/be/gameServer/src/modules/rooms/dto/create-room.dto.ts @@ -11,7 +11,7 @@ export class CreateRoomDto { @IsString({ message: 'hostNickname은 문자열이어야 합니다.' }) @IsNotEmpty({ message: 'hostNickname은 필수 입력 항목입니다.' }) @Length(2, 8, { message: 'hostNickname은 2자에서 8자 사이여야 합니다.' }) - @Matches(/^[a-zA-Z0-9가-힣 ]+$/, { + @Matches(/^[a-zA-Z0-9가-힣ㄱ-ㅎㅏ-ㅣ ]+$/, { message: 'hostNickname은 한글, 알파벳, 숫자, 공백만 허용됩니다.', }) @ApiProperty({ diff --git a/be/gameServer/src/modules/rooms/dto/join-data.dto.ts b/be/gameServer/src/modules/rooms/dto/join-data.dto.ts index 4b4c650..e574223 100644 --- a/be/gameServer/src/modules/rooms/dto/join-data.dto.ts +++ b/be/gameServer/src/modules/rooms/dto/join-data.dto.ts @@ -13,7 +13,7 @@ export class JoinRoomDto { @IsString({ message: 'playerNickname은 문자열이어야 합니다.' }) @IsNotEmpty({ message: 'playerNickname은 필수 입력 항목입니다.' }) @Length(2, 8, { message: 'playerNickname은 2자에서 8자 사이여야 합니다.' }) - @Matches(/^[a-zA-Z0-9가-힣 ]+$/, { + @Matches(/^[a-zA-Z0-9가-힣ㄱ-ㅎㅏ-ㅣ ]+$/, { message: 'playerNickname은 한글, 알파벳, 숫자, 공백만 허용됩니다.', }) @ApiProperty({ diff --git a/be/gameServer/src/modules/rooms/rooms.gateway.spec.ts b/be/gameServer/src/modules/rooms/rooms.gateway.spec.ts deleted file mode 100644 index 1999594..0000000 --- a/be/gameServer/src/modules/rooms/rooms.gateway.spec.ts +++ /dev/null @@ -1,484 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { RoomsGateway } from './rooms.gateway'; -import { RedisService } from '../../redis/redis.service'; -import { Logger } from '@nestjs/common'; -import { Server, Socket } from 'socket.io'; -import { CreateRoomDto } from './dto/create-room.dto'; -import { JoinRoomDto } from './dto/join-data.dto'; -import { RoomDataDto } from './dto/room-data.dto'; - -describe('RoomsGateway', () => { - let gateway: RoomsGateway; - let redisService: RedisService; - let mockServer: Server; - let mockClient: Socket; - let mockLogger: Logger; - - beforeEach(async () => { - const redisServiceMock = { - set: jest.fn(), - get: jest.fn(), - delete: jest.fn(), - }; - - const module: TestingModule = await Test.createTestingModule({ - providers: [ - RoomsGateway, - { provide: RedisService, useValue: redisServiceMock }, - { provide: Logger, useValue: mockLogger }, - ], - }).compile(); - - gateway = module.get(RoomsGateway); - redisService = module.get(RedisService); - - mockServer = { - to: jest.fn().mockReturnValue({ - emit: jest.fn(), - fetchSockets: jest.fn(), - }), - } as unknown as Server; - - mockClient = { - emit: jest.fn(), - join: jest.fn(), - to: jest.fn().mockReturnValue({ - emit: jest.fn(), - }), - data: {}, - } as unknown as Socket; - - mockLogger = { - log: jest.fn(), - error: jest.fn(), - warn: jest.fn(), - debug: jest.fn(), - } as unknown as Logger; - - gateway.server = mockServer; - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - describe('handleCreateRoom', () => { - it('Redis 오류로 인해 방 생성이 실패하면 오류를 반환해야 한다.', async () => { - jest - .spyOn(redisService, 'set') - .mockRejectedValue(new Error('Redis error')); - - await gateway.handleCreateRoom( - { roomName: 'Fail Room', hostNickname: 'Host' }, - mockClient, - ); - - expect(mockClient.emit).toHaveBeenCalledWith('error', { - message: 'Failed to create the room', - }); - }); - - it('hostNickname이 유효하지 않으면 오류를 반환해야 한다.', async () => { - const createRoomDto: CreateRoomDto = { - roomName: 'Room Name', - hostNickname: 'HostHostHostHost', - }; - - try { - await gateway.handleCreateRoom(createRoomDto, mockClient); - } catch (error) { - console.error('Validation failed error:', error); - - expect(mockClient.emit).toHaveBeenCalledWith('error', { - message: 'Validation failed.', - details: expect.any(Array), - }); - - expect(redisService.set).not.toHaveBeenCalled(); - expect(mockClient.join).not.toHaveBeenCalled(); - } - }); - }); - - describe('handleJoinRoom', () => { - it('방이 꽉 차지 않고 닉네임이 유효하면 사용자가 방에 입장할 수 있어야 한다.', async () => { - const roomId = 'testRoomId'; - const playerNickname = 'Player1'; - const roomData = { - roomId, - players: [{ playerNickname: 'host', isReady: false, isMuted: false }], - }; - - jest - .spyOn(redisService, 'get') - .mockResolvedValueOnce(JSON.stringify(roomData)); - - const joinRoomDto: JoinRoomDto = { roomId, playerNickname }; - - await gateway.handleJoinRoom(joinRoomDto, mockClient); - - expect(mockClient.join).toHaveBeenCalledWith(roomId); - expect(mockClient.data.roomId).toBe(roomId); - expect(mockClient.data.playerNickname).toBe(playerNickname); - expect(mockServer.to(roomId).emit).toHaveBeenCalledWith('updateUsers', [ - { playerNickname: 'host', isReady: false, isMuted: false }, - { playerNickname, isReady: false, isMuted: false }, - ]); - expect(redisService.set).toHaveBeenCalledWith( - `room:${roomId}`, - JSON.stringify({ - roomId, - players: [ - { playerNickname: 'host', isReady: false, isMuted: false }, - { playerNickname, isReady: false, isMuted: false }, - ], - }), - 'roomUpdate', - ); - }); - - it('닉네임이 유효하지 않으면 오류를 반환해야 한다.', async () => { - const roomId = 'testRoomId'; - const playerNickname = 'testPlayertestPlayertestPlayer'; - - const joinRoomDto: JoinRoomDto = { roomId, playerNickname }; - - try { - await gateway.handleJoinRoom(joinRoomDto, mockClient); - } catch (error) { - console.error('Validation failed error:', error); - - expect(mockClient.emit).toHaveBeenCalledWith('error', { - message: 'Validation failed.', - details: expect.any(Array), - }); - - expect(redisService.get).not.toHaveBeenCalled(); - expect(mockClient.join).not.toHaveBeenCalled(); - } - }); - - it('닉네임이 이미 사용 중이면 오류를 반환해야 한다.', async () => { - const roomId = 'testRoomId'; - const playerNickname = 'host'; - const roomData = { - roomId, - players: [{ playerNickname: 'host', isReady: false, isMuted: false }], - }; - - jest - .spyOn(redisService, 'get') - .mockResolvedValueOnce(JSON.stringify(roomData)); - - const joinRoomDto: JoinRoomDto = { roomId, playerNickname }; - - await gateway.handleJoinRoom(joinRoomDto, mockClient); - - expect(mockClient.emit).toHaveBeenCalledWith( - 'error', - 'Nickname already taken in this room', - ); - }); - - it('방이 꽉 차 있으면 오류를 반환해야 한다.', async () => { - const roomId = 'testRoomId'; - const playerNickname = 'Player1'; - const roomData = { - roomId, - players: ['host', 'Player2', 'Player3', 'Player4'], - }; - - jest - .spyOn(redisService, 'get') - .mockResolvedValueOnce(JSON.stringify(roomData)); - - const joinRoomDto: JoinRoomDto = { roomId, playerNickname }; - - await gateway.handleJoinRoom(joinRoomDto, mockClient); - - expect(mockClient.emit).toHaveBeenCalledWith('error', 'Room is full'); - }); - - it('Redis에서 방 데이터를 가져오는 데 실패하면 오류를 반환해야 한다.', async () => { - const roomId = 'testRoomId'; - const playerNickname = 'Player1'; - - jest - .spyOn(redisService, 'get') - .mockRejectedValueOnce(new Error('Redis error')); - - const joinRoomDto: JoinRoomDto = { roomId, playerNickname }; - - await gateway.handleJoinRoom(joinRoomDto, mockClient); - - expect(mockClient.emit).toHaveBeenCalledWith('error', { - message: 'Failed to join the room', - }); - }); - }); - - describe('handleDisconnect', () => { - it('사용자가 방을 떠나면 방의 사용자 목록이 업데이트되어야 한다.', async () => { - const roomId = 'testRoomId'; - const nickname = 'Player1'; - const roomData: RoomDataDto = { - roomId, - roomName: 'testRoom', - players: [ - { playerNickname: 'host', isReady: false, isMuted: false }, - { playerNickname: 'Player1', isReady: false, isMuted: false }, - ], - hostNickname: 'host', - status: 'wating', - }; - - jest - .spyOn(redisService, 'get') - .mockResolvedValueOnce(JSON.stringify(roomData)); - - mockClient.data = { roomId, playerNickname: nickname }; - - await gateway.handleDisconnect(mockClient); - - expect(redisService.set).toHaveBeenCalledWith( - `room:${roomId}`, - JSON.stringify({ - roomId, - roomName: 'testRoom', - players: [{ playerNickname: 'host', isReady: false, isMuted: false }], - hostNickname: 'host', - status: 'wating', - }), - 'roomUpdate', - ); - - expect(mockServer.to(roomId).emit).toHaveBeenCalledWith('updateUsers', [ - { playerNickname: 'host', isReady: false, isMuted: false }, - ]); - }); - - it('호스트가 나가면 새로운 호스트가 지정되어야 한다.', async () => { - const roomId = 'testRoomId'; - const nickname = 'host'; - const roomData: RoomDataDto = { - roomId, - roomName: 'testRoom', - players: [ - { playerNickname: 'host', isReady: false, isMuted: false }, - { playerNickname: 'Player1', isReady: false, isMuted: false }, - ], - hostNickname: 'host', - status: 'wating', - }; - - jest - .spyOn(redisService, 'get') - .mockResolvedValueOnce(JSON.stringify(roomData)); - - mockClient.data = { roomId, playerNickname: nickname }; - - await gateway.handleDisconnect(mockClient); - - expect(redisService.set).toHaveBeenCalledWith( - `room:${roomId}`, - JSON.stringify({ - roomId, - roomName: 'testRoom', - players: [ - { playerNickname: 'Player1', isReady: false, isMuted: false }, - ], - hostNickname: 'Player1', - status: 'wating', - }), - 'roomUpdate', - ); - - expect(mockServer.to(roomId).emit).toHaveBeenCalledWith('updateUsers', [ - { playerNickname: 'Player1', isReady: false, isMuted: false }, - ]); - }); - - it('방에 사용자가 하나만 남았을 때 방을 삭제해야 한다.', async () => { - const roomId = 'testRoomId'; - const nickname = 'host'; - const roomData = { - roomId, - roomName: 'testRoom', - players: [{ playerNickname: 'host', isReady: false, isMuted: false }], - hostNickname: 'host', - status: 'wating', - }; - - jest - .spyOn(redisService, 'get') - .mockResolvedValueOnce(JSON.stringify(roomData)); - - mockClient.data = { roomId, playerNickname: nickname }; - - await gateway.handleDisconnect(mockClient); - - expect(redisService.delete).toHaveBeenCalledWith( - `room:${roomId}`, - 'roomUpdate', - ); - - expect(mockServer.to(roomId).emit).not.toHaveBeenCalled(); - }); - - it('Redis에서 방 데이터를 가져오는 데 실패하면 오류를 반환해야 한다.', async () => { - const roomId = 'testRoomId'; - const nickname = 'Player1'; - - jest - .spyOn(redisService, 'get') - .mockRejectedValueOnce(new Error('Redis error')); - - mockClient.data = { roomId, nickname }; - - await gateway.handleDisconnect(mockClient); - - expect(mockClient.emit).toHaveBeenCalledWith('error', { - message: 'Failed to handle disconnect', - }); - }); - - it('방 데이터가 없으면 로그만 기록 후 오류를 반환하지 않아야 한다.', async () => { - const roomId = 'testRoomId'; - const nickname = 'Player1'; - - jest.spyOn(redisService, 'get').mockResolvedValueOnce(null); - - mockClient.data = { roomId, nickname }; - - await gateway.handleDisconnect(mockClient); - - expect(mockClient.emit).not.toHaveBeenCalled(); - }); - }); - - describe('handleSetReady', () => { - it('플레이어가 준비 상태로 설정되어야 한다.', async () => { - const roomId = 'testRoomId'; - const playerNickname = 'Player1'; - const roomData: RoomDataDto = { - roomId, - roomName: 'testRoom', - hostNickname: 'host', - players: [ - { playerNickname: 'host', isReady: false, isMuted: false }, - { playerNickname: 'Player1', isReady: false, isMuted: false }, - ], - status: 'waiting', - }; - - jest - .spyOn(redisService, 'get') - .mockResolvedValueOnce(JSON.stringify(roomData)); - - mockClient.data = { roomId, playerNickname }; - - await gateway.handleSetReady(mockClient); - - expect(redisService.set).toHaveBeenCalledWith( - `room:${roomId}`, - JSON.stringify({ - ...roomData, - players: [ - { playerNickname: 'host', isReady: false, isMuted: false }, - { playerNickname: 'Player1', isReady: true, isMuted: false }, - ], - }), - ); - expect(mockServer.to(roomId).emit).toHaveBeenCalledWith('updateUsers', [ - { playerNickname: 'host', isReady: false, isMuted: false }, - { playerNickname: 'Player1', isReady: true, isMuted: false }, - ]); - }); - - it('플레이어가 방에 없으면 에러를 전송해야 한다.', async () => { - const roomId = 'testRoomId'; - const playerNickname = 'Player2'; - const roomData: RoomDataDto = { - roomId, - roomName: 'testRoom', - hostNickname: 'host', - players: [ - { playerNickname: 'host', isReady: false, isMuted: false }, - { playerNickname: 'Player1', isReady: false, isMuted: false }, - ], - status: 'waiting', - }; - - jest - .spyOn(redisService, 'get') - .mockResolvedValueOnce(JSON.stringify(roomData)); - - mockClient.data = { roomId, playerNickname }; - - await gateway.handleSetReady(mockClient); - - expect(mockClient.emit).toHaveBeenCalledWith( - 'error', - 'Player not found in room', - ); - }); - }); - - describe('handleKickPlayer', () => { - it('호스트만 플레이어를 강퇴할 수 있어야 한다.', async () => { - const roomId = 'testRoomId'; - const playerNickname = 'Player1'; - const roomData: RoomDataDto = { - roomId, - roomName: 'testRoom', - hostNickname: 'host', - players: [ - { playerNickname: 'host', isReady: false, isMuted: false }, - { playerNickname: 'Player1', isReady: false, isMuted: false }, - ], - status: 'waiting', - }; - - jest - .spyOn(redisService, 'get') - .mockResolvedValueOnce(JSON.stringify(roomData)); - - mockClient.data = { roomId, playerNickname }; - - await gateway.handleKickPlayer('Player1', mockClient); - - expect(mockClient.emit).toHaveBeenCalledWith( - 'error', - 'Only host can kick players', - ); - }); - - it('플레이어가 방에 없으면 에러를 전송해야 한다.', async () => { - const roomId = 'testRoomId'; - const playerNickname = 'host'; - const roomData: RoomDataDto = { - roomId, - roomName: 'testRoom', - hostNickname: 'host', - players: [ - { playerNickname: 'host', isReady: false, isMuted: false }, - { playerNickname: 'Player1', isReady: false, isMuted: false }, - ], - status: 'waiting', - }; - - jest - .spyOn(redisService, 'get') - .mockResolvedValueOnce(JSON.stringify(roomData)); - - mockClient.data = { roomId, playerNickname }; - - await gateway.handleKickPlayer('Player2', mockClient); - - expect(mockClient.emit).toHaveBeenCalledWith( - 'error', - 'Player not found in room', - ); - }); - }); -}); diff --git a/be/gameServer/src/modules/rooms/rooms.gateway.ts b/be/gameServer/src/modules/rooms/rooms.gateway.ts index 521c826..654083c 100644 --- a/be/gameServer/src/modules/rooms/rooms.gateway.ts +++ b/be/gameServer/src/modules/rooms/rooms.gateway.ts @@ -58,7 +58,13 @@ export class RoomsGateway implements OnGatewayDisconnect { roomName, hostNickname, players: [ - { playerNickname: hostNickname, isReady: false, isMuted: false }, + { + playerNickname: hostNickname, + isReady: false, + isMuted: false, + isDead: false, + isLeft: false, + }, ], status: 'waiting', }; @@ -124,7 +130,19 @@ export class RoomsGateway implements OnGatewayDisconnect { return; } - roomData.players.push({ playerNickname, isReady: false, isMuted: false }); + if (roomData.status === 'progress') { + this.logger.warn(`GAME_ALREADY_IN_PROGRESS`); + client.emit('error', ErrorMessages.GAME_ALREADY_IN_PROGRESS); + return; + } + + roomData.players.push({ + playerNickname, + isReady: false, + isMuted: false, + isDead: false, + isLeft: false, + }); const totalRoomIdList = await this.redisService.lrange( `${RedisKeys.ROOMS_LIST}`, @@ -170,9 +188,6 @@ export class RoomsGateway implements OnGatewayDisconnect { removePlayerFromRoom(roomData, playerNickname); - // todo - // 이 상태에서 다른 사용자가 방에 들어온다면? - if (roomData.hostNickname === playerNickname) { const totalRoomIdList = await this.redisService.lrange( `${RedisKeys.ROOMS_LIST}`, @@ -182,6 +197,7 @@ export class RoomsGateway implements OnGatewayDisconnect { const index = totalRoomIdList.indexOf(roomId); if (roomData.players.length > 0) { changeRoomHost(roomData); + await this.redisService.hmset( `room:${roomId}`, { @@ -198,7 +214,9 @@ export class RoomsGateway implements OnGatewayDisconnect { this.logger.log( `host changed to ${roomData.players[0].playerNickname}`, ); - this.server.to(roomId).emit('updateUsers', roomData.players); + if (roomData.status === 'waiting') { + this.server.to(roomId).emit('updateUsers', roomData.players); + } } else { this.logger.log(`${roomId} deleting room`); await this.redisService.lrem( @@ -237,7 +255,9 @@ export class RoomsGateway implements OnGatewayDisconnect { : [Math.floor(index / RoomsConstant.ROOMS_LIMIT)]), ); this.logger.log(`host ${playerNickname} leave room`); - this.server.to(roomId).emit('updateUsers', roomData.players); + if (roomData.status === 'waiting') { + this.server.to(roomId).emit('updateUsers', roomData.players); + } } } catch (error) { this.logger.error('Error handling disconnect: ', error.message); diff --git a/be/gameServer/src/modules/rooms/rooms.websocket.on.controller.ts b/be/gameServer/src/modules/rooms/rooms.websocket.on.controller.ts index 84defac..a303a59 100644 --- a/be/gameServer/src/modules/rooms/rooms.websocket.on.controller.ts +++ b/be/gameServer/src/modules/rooms/rooms.websocket.on.controller.ts @@ -22,7 +22,7 @@ export class RoomsWebSocketOnController { @ApiOperation({ summary: '게임 방 입장', description: - 'wss://clovapatra.com/rooms 에서 "joinRoom" 이벤트를 emit해 사용합니다. 성공적으로 입장하면 입장한 방의 사용자들에게 "updateUsers" 이벤트를 통해 갱신된 사용자 목록을 제공합니다.', + 'wss://clovapatra.com/rooms 에서 "joinRoom" 이벤트를 emit해 사용합니다. 성공적으로 입장하면 입장한 방의 사용자들에게 "updateUsers" 이벤트를 통해 갱신된 사용자 목록을 제공합니다. 이미 게임중인 경우 들어가지 못하며 GameAlreadyInProgress 에러 메시지를 가진 에러를 전송합니다.', }) @ApiBody({ type: JoinRoomDto }) joinRoom(@Body() joinRoomDto: JoinRoomDto) {