Skip to content

Commit

Permalink
[BE] 게임 시작시, 현재 단계의 진행을 결정한다. (#119)
Browse files Browse the repository at this point in the history
* feat: startGame 이벤트

turnChanged 이벤트 발행

* test: startGame 유닛 테스트

* docs: swagger 정리
  • Loading branch information
student079 authored Nov 19, 2024
1 parent 002f38f commit cc6b23a
Show file tree
Hide file tree
Showing 9 changed files with 399 additions and 16 deletions.
8 changes: 8 additions & 0 deletions be/gameServer/src/modules/games/dto/game-data.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export class GameDataDto {
gameId: string;
alivePlayers: string[];
currentTurn: number;
currentPlayer: string;
previousPitch: number;
previousPlayers: string[];
}
45 changes: 45 additions & 0 deletions be/gameServer/src/modules/games/dto/turn-data.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { ApiProperty } from '@nestjs/swagger';

export enum GameMode {
PRONUNCIATION = 'PRONUNCIATION',
CLEOPATRA = 'CLEOPATRA',
}

export class TurnDataDto {
@ApiProperty({
example: '6f42377f-42ea-42cc-ac1a-b5d2b99d4ced',
type: String,
description: '게임 방 ID',
})
roomId: string;

@ApiProperty({
example: 'player1',
type: String,
description: '해당 단계를 수행하는 플레이어 닉네임',
})
playerNickname: string;

@ApiProperty({
example: GameMode.PRONUNCIATION,
type: String,
description: '해당 단계 게임 모드',
})
gameMode: GameMode;

@ApiProperty({
example: 10,
type: Number,
description: '해당 단계 게임 모드를 수행할 때의 제한시간',
})
timeLimit: number;

@ApiProperty({
example:
'도토리가 문을 도로록, 드르륵, 두루룩 열었는가? 드로록, 두루륵, 두르룩 열었는가.',
type: String,
description: '게임모드가 PRONUNCIATION일 때 가사',
required: false,
})
lyrics?: string;
}
44 changes: 44 additions & 0 deletions be/gameServer/src/modules/games/games-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { GameMode, TurnDataDto } from './dto/turn-data.dto';
import { RoomDataDto } from './../rooms/dto/room-data.dto';
import { GameDataDto } from './dto/game-data.dto';

export function createTurnData(
roomData: RoomDataDto,
gameData: GameDataDto,
): TurnDataDto {
const gameModes = [GameMode.PRONUNCIATION, GameMode.CLEOPATRA];
const gameMode = gameModes[Math.floor(Math.random() * gameModes.length)];

let timeLimit: number;
if (gameMode === GameMode.CLEOPATRA) {
timeLimit = 10;
} else {
// 데이터에 따라 바뀜
timeLimit = 15;
}

let lyrics: string | undefined;
if (gameMode === GameMode.PRONUNCIATION) {
// 데이터에 따라 바뀜
lyrics = '테스트테스트테스트테스트테스트테스트테스트테스트테스트테스트';
}

return {
roomId: roomData.roomId,
playerNickname: gameData.currentPlayer,
gameMode: gameMode,
timeLimit: timeLimit,
lyrics: lyrics,
};
}

export function selectCurrentPlayer(alivePlayers: string[]): string {
const randomIndex = Math.floor(Math.random() * alivePlayers.length);
return alivePlayers[randomIndex];
}

export function checkPlayersReady(roomData: RoomDataDto): boolean {
return roomData.players
.filter((player) => player.playerNickname !== roomData.hostNickname)
.every((player) => player.isReady);
}
157 changes: 157 additions & 0 deletions be/gameServer/src/modules/games/games.gateway.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
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>(GamesGateway);
redisService = module.get<RedisService>(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 },
{ playerNickname: 'player1', isReady: true },
],
status: 'waiting',
};

jest
.spyOn(redisService, 'get')
.mockResolvedValueOnce(JSON.stringify(roomData));

await gateway.handleStartGame(mockHostClient);

expect(redisService.get).toHaveBeenCalledWith('room:test-room-id');
expect(redisService.set).toHaveBeenCalledWith(
'room:test-room-id',
JSON.stringify({ ...roomData, status: 'progress' }),
'roomUpdate',
);
});

it('모든 플레이어가 준비되지 않았을 경우 오류를 반환해야 한다.', async () => {
const roomData: RoomDataDto = {
roomId: 'test-room-id',
roomName: 'testRoomName',
hostNickname: 'hostPlayer',
players: [
{ playerNickname: 'hostPlayer', isReady: true },
{ playerNickname: 'player1', isReady: 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 },
{ playerNickname: 'player1', isReady: true },
],
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',
});
});
});
});
102 changes: 98 additions & 4 deletions be/gameServer/src/modules/games/games.gateway.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,26 @@
import { WebSocketGateway, WebSocketServer } from '@nestjs/websockets';
import { Server } from 'socket.io';
import {
WebSocketGateway,
WebSocketServer,
SubscribeMessage,
ConnectedSocket,
} from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';
import { RedisService } from '../../redis/redis.service';
import { Logger, UseFilters } from '@nestjs/common';
import { WsExceptionsFilter } from '../../common/filters/ws-exceptions.filter';
import { RoomDataDto } from '../rooms/dto/room-data.dto';
import { GameDataDto } from './dto/game-data.dto';
import { TurnDataDto } from './dto/turn-data.dto';
import { ErrorResponse } from '../rooms/dto/error-response.dto';
import { io, Socket as ClientSocket } from 'socket.io-client';
import {
createTurnData,
selectCurrentPlayer,
checkPlayersReady,
} from './games-utils';

@WebSocketGateway({
namespace: '/games',
namespace: '/rooms',
cors: {
origin: '*',
methods: ['GET', 'POST'],
Expand All @@ -19,5 +34,84 @@ export class GamesGateway {
@WebSocketServer()
server: Server;

constructor(private readonly redisService: RedisService) {}
private voiceProcessingSocket: ClientSocket;

constructor(private readonly redisService: RedisService) {
this.voiceProcessingSocket = io(`wss://voice-processing.clovapatra.com`);
this.voiceProcessingSocket.on('connect', () => {
this.logger.log('Successfully connected to the voice processing server');
});
}

@SubscribeMessage('startGame')
async handleStartGame(@ConnectedSocket() client: Socket) {
const { roomId, playerNickname } = client.data;
this.logger.log(`Game start requested for room: ${roomId}`);

try {
const roomDataString = await this.redisService.get<string>(
`room:${roomId}`,
);

if (!roomDataString) {
this.logger.warn(`Room not found: ${roomId}`);
client.emit('error', 'Room not found');
return;
}

const roomData: RoomDataDto = JSON.parse(roomDataString);

if (roomData.hostNickname !== playerNickname) {
this.logger.warn(
`User ${client.data.playerNickname} is not the host of room ${roomId}`,
);
client.emit('error', 'Only the host can start the game');
return;
}

const allReady = checkPlayersReady(roomData);
if (!allReady) {
this.logger.warn(`Not all players are ready in room: ${roomId}`);
client.emit('error', 'All players must be ready to start the game');
return;
}

roomData.status = 'progress';

await this.redisService.set(
`room:${roomId}`,
JSON.stringify(roomData),
'roomUpdate',
);

const alivePlayers = roomData.players.map(
(player) => player.playerNickname,
);

const gameData: GameDataDto = {
gameId: roomId,
alivePlayers,
currentTurn: 1,
currentPlayer: selectCurrentPlayer(alivePlayers),
previousPitch: 0,
previousPlayers: [],
};
await this.redisService.set(`game:${roomId}`, JSON.stringify(gameData));

const turnData: TurnDataDto = createTurnData(roomData, gameData);

this.server.to(roomId).emit('turnChanged', turnData);
this.voiceProcessingSocket.emit('turnChanged', turnData);

this.logger.log(`Game started successfully in room: ${roomId}`);
} catch (error) {
this.logger.error(
`Error starting game in room ${roomId}: ${error.message}`,
);
const errorResponse: ErrorResponse = {
message: 'Failed to start the game',
};
client.emit('error', errorResponse);
}
}
}
4 changes: 3 additions & 1 deletion be/gameServer/src/modules/games/games.module.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { Module } from '@nestjs/common';
import { RedisModule } from '../../redis/redis.module';
import { GamesWebSocketEmitController } from './games.websocket.emit.controller';
import { GamesWebSocketOnController } from './games.websocket.on.controller';
import { GamesGateway } from './games.gateway';

@Module({
imports: [RedisModule],
providers: [GamesGateway],
controllers: [],
controllers: [GamesWebSocketEmitController, GamesWebSocketOnController],
})
export class GamesModule {}
Loading

0 comments on commit cc6b23a

Please sign in to comment.