diff --git a/BE/.jest-test-results.json b/BE/.jest-test-results.json index b49c7ac..5dda7c4 100644 --- a/BE/.jest-test-results.json +++ b/BE/.jest-test-results.json @@ -1 +1 @@ -{"numFailedTestSuites":0,"numFailedTests":0,"numPassedTestSuites":2,"numPassedTests":10,"numPendingTestSuites":0,"numPendingTests":0,"numRuntimeErrorTestSuites":0,"numTodoTests":0,"numTotalTestSuites":2,"numTotalTests":10,"openHandles":[],"snapshot":{"added":0,"didUpdate":false,"failure":false,"filesAdded":0,"filesRemoved":0,"filesRemovedList":[],"filesUnmatched":0,"filesUpdated":0,"matched":0,"total":0,"unchecked":0,"uncheckedKeysByFile":[],"unmatched":0,"updated":0},"startTime":1730881811395,"success":true,"testResults":[{"assertionResults":[{"ancestorTitles":["GameGateway (e2e)","createRoom 이벤트 테스트"],"duration":203,"failureDetails":[],"failureMessages":[],"fullName":"GameGateway (e2e) createRoom 이벤트 테스트 유효한 설정으로 게임방 생성 성공","invocations":1,"location":null,"numPassingAsserts":2,"retryReasons":[],"status":"passed","title":"유효한 설정으로 게임방 생성 성공"},{"ancestorTitles":["GameGateway (e2e)","createRoom 이벤트 테스트"],"duration":105,"failureDetails":[],"failureMessages":[],"fullName":"GameGateway (e2e) createRoom 이벤트 테스트 빈 title","invocations":1,"location":null,"numPassingAsserts":3,"retryReasons":[],"status":"passed","title":"빈 title"},{"ancestorTitles":["GameGateway (e2e)","createRoom 이벤트 테스트"],"duration":68,"failureDetails":[],"failureMessages":[],"fullName":"GameGateway (e2e) createRoom 이벤트 테스트 빈 gameMode","invocations":1,"location":null,"numPassingAsserts":3,"retryReasons":[],"status":"passed","title":"빈 gameMode"},{"ancestorTitles":["GameGateway (e2e)","createRoom 이벤트 테스트"],"duration":85,"failureDetails":[],"failureMessages":[],"fullName":"GameGateway (e2e) createRoom 이벤트 테스트 잘못된 gameMode","invocations":1,"location":null,"numPassingAsserts":3,"retryReasons":[],"status":"passed","title":"잘못된 gameMode"},{"ancestorTitles":["GameGateway (e2e)","createRoom 이벤트 테스트"],"duration":83,"failureDetails":[],"failureMessages":[],"fullName":"GameGateway (e2e) createRoom 이벤트 테스트 최소 인원 미달","invocations":1,"location":null,"numPassingAsserts":3,"retryReasons":[],"status":"passed","title":"최소 인원 미달"},{"ancestorTitles":["GameGateway (e2e)","createRoom 이벤트 테스트"],"duration":79,"failureDetails":[],"failureMessages":[],"fullName":"GameGateway (e2e) createRoom 이벤트 테스트 최대 인원 초과","invocations":1,"location":null,"numPassingAsserts":3,"retryReasons":[],"status":"passed","title":"최대 인원 초과"},{"ancestorTitles":["GameGateway (e2e)","createRoom 이벤트 테스트"],"duration":91,"failureDetails":[],"failureMessages":[],"fullName":"GameGateway (e2e) createRoom 이벤트 테스트 잘못된 boolean 타입","invocations":1,"location":null,"numPassingAsserts":3,"retryReasons":[],"status":"passed","title":"잘못된 boolean 타입"},{"ancestorTitles":["GameGateway (e2e)","createRoom 이벤트 테스트"],"duration":69,"failureDetails":[],"failureMessages":[],"fullName":"GameGateway (e2e) createRoom 이벤트 테스트 방생성시 서버는 올바른 6자리 숫자(PIN)을 응답해야한다.","invocations":1,"location":null,"numPassingAsserts":4,"retryReasons":[],"status":"passed","title":"방생성시 서버는 올바른 6자리 숫자(PIN)을 응답해야한다."},{"ancestorTitles":["GameGateway (e2e)","chatMessage 이벤트 테스트"],"duration":126,"failureDetails":[],"failureMessages":[],"fullName":"GameGateway (e2e) chatMessage 이벤트 테스트 같은 Room의 플레이어들에게 브로드캐스팅 성공","invocations":1,"location":null,"numPassingAsserts":1,"retryReasons":[],"status":"passed","title":"같은 Room의 플레이어들에게 브로드캐스팅 성공"}],"endTime":1730881821139,"message":"","name":"D:\\nest_project\\QuizGround\\web10-QuizGround\\BE\\test\\game.e2e-spec.ts","startTime":1730881811553,"status":"passed","summary":""},{"assertionResults":[{"ancestorTitles":["AppController (e2e)"],"duration":158,"failureDetails":[],"failureMessages":[],"fullName":"AppController (e2e) / (GET)","invocations":1,"location":null,"numPassingAsserts":0,"retryReasons":[],"status":"passed","title":"/ (GET)"}],"endTime":1730881821996,"message":"","name":"D:\\nest_project\\QuizGround\\web10-QuizGround\\BE\\test\\app.e2e-spec.ts","startTime":1730881821176,"status":"passed","summary":""}],"wasInterrupted":false} +{"numFailedTestSuites":1,"numFailedTests":0,"numPassedTestSuites":1,"numPassedTests":10,"numPendingTestSuites":0,"numPendingTests":0,"numRuntimeErrorTestSuites":1,"numTodoTests":0,"numTotalTestSuites":2,"numTotalTests":10,"openHandles":[{}],"snapshot":{"added":0,"didUpdate":false,"failure":false,"filesAdded":0,"filesRemoved":0,"filesRemovedList":[],"filesUnmatched":0,"filesUpdated":0,"matched":0,"total":0,"unchecked":0,"uncheckedKeysByFile":[],"unmatched":0,"updated":0},"startTime":1731590376451,"success":false,"testResults":[{"assertionResults":[{"ancestorTitles":["GameGateway (e2e)","createRoom 이벤트 테스트"],"duration":242,"failureDetails":[],"failureMessages":[],"fullName":"GameGateway (e2e) createRoom 이벤트 테스트 유효한 설정으로 게임방 생성 성공","invocations":1,"location":null,"numPassingAsserts":6,"retryReasons":[],"status":"passed","title":"유효한 설정으로 게임방 생성 성공"},{"ancestorTitles":["GameGateway (e2e)","createRoom 이벤트 테스트"],"duration":93,"failureDetails":[],"failureMessages":[],"fullName":"GameGateway (e2e) createRoom 이벤트 테스트 빈 title인 경우 에러 발생","invocations":1,"location":null,"numPassingAsserts":2,"retryReasons":[],"status":"passed","title":"빈 title인 경우 에러 발생"},{"ancestorTitles":["GameGateway (e2e)","createRoom 이벤트 테스트"],"duration":80,"failureDetails":[],"failureMessages":[],"fullName":"GameGateway (e2e) createRoom 이벤트 테스트 빈 gameMode인 경우 에러 발생","invocations":1,"location":null,"numPassingAsserts":2,"retryReasons":[],"status":"passed","title":"빈 gameMode인 경우 에러 발생"},{"ancestorTitles":["GameGateway (e2e)","createRoom 이벤트 테스트"],"duration":87,"failureDetails":[],"failureMessages":[],"fullName":"GameGateway (e2e) createRoom 이벤트 테스트 잘못된 gameMode인 경우 에러 발생","invocations":1,"location":null,"numPassingAsserts":2,"retryReasons":[],"status":"passed","title":"잘못된 gameMode인 경우 에러 발생"},{"ancestorTitles":["GameGateway (e2e)","createRoom 이벤트 테스트"],"duration":78,"failureDetails":[],"failureMessages":[],"fullName":"GameGateway (e2e) createRoom 이벤트 테스트 최소 인원 미달인 경우 에러 발생","invocations":1,"location":null,"numPassingAsserts":2,"retryReasons":[],"status":"passed","title":"최소 인원 미달인 경우 에러 발생"},{"ancestorTitles":["GameGateway (e2e)","joinRoom 이벤트 테스트"],"duration":87,"failureDetails":[],"failureMessages":[],"fullName":"GameGateway (e2e) joinRoom 이벤트 테스트 존재하는 방 참여 성공","invocations":1,"location":null,"numPassingAsserts":3,"retryReasons":[],"status":"passed","title":"존재하는 방 참여 성공"},{"ancestorTitles":["GameGateway (e2e)","joinRoom 이벤트 테스트"],"duration":73,"failureDetails":[],"failureMessages":[],"fullName":"GameGateway (e2e) joinRoom 이벤트 테스트 존재하지 않는 방 참여 실패","invocations":1,"location":null,"numPassingAsserts":2,"retryReasons":[],"status":"passed","title":"존재하지 않는 방 참여 실패"},{"ancestorTitles":["GameGateway (e2e)","chatMessage 이벤트 테스트"],"duration":182,"failureDetails":[],"failureMessages":[],"fullName":"GameGateway (e2e) chatMessage 이벤트 테스트 같은 방의 모든 플레이어에게 메시지 전송","invocations":1,"location":null,"numPassingAsserts":4,"retryReasons":[],"status":"passed","title":"같은 방의 모든 플레이어에게 메시지 전송"},{"ancestorTitles":["GameGateway (e2e)","updatePosition 이벤트 테스트"],"duration":133,"failureDetails":[],"failureMessages":[],"fullName":"GameGateway (e2e) updatePosition 이벤트 테스트 위치 업데이트 성공","invocations":1,"location":null,"numPassingAsserts":3,"retryReasons":[],"status":"passed","title":"위치 업데이트 성공"},{"ancestorTitles":["GameGateway (e2e)","startGame 이벤트 테스트"],"duration":1613,"failureDetails":[],"failureMessages":[],"fullName":"GameGateway (e2e) startGame 이벤트 테스트 게임 시작 성공","invocations":1,"location":null,"numPassingAsserts":3,"retryReasons":[],"status":"passed","title":"게임 시작 성공"}],"endTime":1731590391795,"message":"","name":"/Users/song-build/IdeaProjects/web10-QuizGround/BE/test/game.e2e-spec.ts","startTime":1731590376502,"status":"passed","summary":""},{"assertionResults":[],"coverage":{},"endTime":1731590392117,"message":" \u001b[1m● \u001b[22mTest suite failed to run\n\n Your test suite must contain at least one test.\n\n \u001b[2mat onResult (\u001b[22m../node_modules/@jest/core/build/TestScheduler.js\u001b[2m:133:18)\u001b[22m\n \u001b[2mat \u001b[22m../node_modules/@jest/core/build/TestScheduler.js\u001b[2m:254:19\u001b[22m\n \u001b[2mat \u001b[22m../node_modules/emittery/index.js\u001b[2m:363:13\u001b[22m\n at Array.map ()\n \u001b[2mat Emittery.emit (\u001b[22m../node_modules/emittery/index.js\u001b[2m:361:23)\u001b[22m\n","name":"/Users/song-build/IdeaProjects/web10-QuizGround/BE/test/app.e2e-spec.ts","startTime":1731590392117,"status":"failed","summary":""}],"wasInterrupted":false} diff --git a/BE/package-lock.json b/BE/package-lock.json index b9b5999..2f67d30 100644 --- a/BE/package-lock.json +++ b/BE/package-lock.json @@ -10,6 +10,7 @@ "license": "UNLICENSED", "dependencies": { "@nestjs-modules/ioredis": "^2.0.2", + "@nestjs/axios": "^3.1.2", "@nestjs/common": "^10.4.7", "@nestjs/config": "^3.3.0", "@nestjs/core": "^10.4.7", @@ -19,6 +20,7 @@ "@nestjs/swagger": "^8.0.5", "@nestjs/typeorm": "^10.0.2", "@nestjs/websockets": "^10.4.7", + "axios": "^1.7.7", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", "dotenv": "^16.4.5", @@ -1506,6 +1508,17 @@ "ioredis": ">=5.0.0" } }, + "node_modules/@nestjs/axios": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@nestjs/axios/-/axios-3.1.2.tgz", + "integrity": "sha512-pFlfi4ZQsZtTNNhvgssbxjCHUd1nMpV3sXy/xOOB2uEJhw3M8j8SFR08gjFNil2we2Har7VCsXLfCkwbMHECFQ==", + "license": "MIT", + "peerDependencies": { + "@nestjs/common": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0", + "axios": "^1.3.1", + "rxjs": "^6.0.0 || ^7.0.0" + } + }, "node_modules/@nestjs/cli": { "version": "10.4.5", "resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-10.4.5.tgz", @@ -3127,8 +3140,7 @@ "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" }, "node_modules/aws-ssl-profiles": { "version": "1.1.2", @@ -3138,6 +3150,17 @@ "node": ">= 6.0.0" } }, + "node_modules/axios": { + "version": "1.7.7", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.7.tgz", + "integrity": "sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, "node_modules/babel-jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", @@ -3951,7 +3974,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, "dependencies": { "delayed-stream": "~1.0.0" }, @@ -4227,7 +4249,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, "engines": { "node": ">=0.4.0" } @@ -5161,6 +5182,26 @@ "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==", "dev": true }, + "node_modules/follow-redirects": { + "version": "1.15.9", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", + "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/foreground-child": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz", @@ -5230,7 +5271,6 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.1.tgz", "integrity": "sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==", - "dev": true, "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", @@ -7959,6 +7999,12 @@ "node": ">= 0.10" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", diff --git a/BE/package.json b/BE/package.json index 5dd7a06..8463a55 100644 --- a/BE/package.json +++ b/BE/package.json @@ -25,6 +25,7 @@ }, "dependencies": { "@nestjs-modules/ioredis": "^2.0.2", + "@nestjs/axios": "^3.1.2", "@nestjs/common": "^10.4.7", "@nestjs/config": "^3.3.0", "@nestjs/core": "^10.4.7", @@ -34,6 +35,7 @@ "@nestjs/swagger": "^8.0.5", "@nestjs/typeorm": "^10.0.2", "@nestjs/websockets": "^10.4.7", + "axios": "^1.7.7", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", "dotenv": "^16.4.5", diff --git a/BE/src/app.module.ts b/BE/src/app.module.ts index f03861c..d9d71f9 100644 --- a/BE/src/app.module.ts +++ b/BE/src/app.module.ts @@ -1,51 +1,51 @@ -import { Module } from '@nestjs/common'; -import { AppController } from './app.controller'; -import { AppService } from './app.service'; -import { GameModule } from './game/game.module'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import { RedisModule } from '@nestjs-modules/ioredis'; -import { ConfigModule } from '@nestjs/config'; -import { QuizSetModel } from './quiz/entities/quiz-set.entity'; -import { QuizModel } from './quiz/entities/quiz.entity'; -import { QuizChoiceModel } from './quiz/entities/quiz-choice.entity'; -import { UserModel } from './user/entities/user.entity'; -import { UserQuizArchiveModel } from './user/entities/user-quiz-archive.entity'; -import { InitDBModule } from './InitDB/InitDB.module'; -import { UserModule } from './user/user.module'; -import { QuizModule } from './quiz/quiz.module'; - -@Module({ - imports: [ - ConfigModule.forRoot({ - envFilePath: '../.env', - isGlobal: true - }), - GameModule, - TypeOrmModule.forRoot({ - type: 'mysql', - host: process.env.DB_HOST || 'localhost', - port: +process.env.DB_PORT || 3306, - username: process.env.DB_USER || 'root', - password: process.env.DB_PASSWD || 'test', - database: process.env.DB_NAME || 'test_db', - entities: [QuizSetModel, QuizModel, QuizChoiceModel, UserModel, UserQuizArchiveModel], - synchronize: process.env.DEV ? true : false, // 개발 모드에서만 활성화 - logging: true, // 모든 쿼리 로깅 - logger: 'advanced-console' - // extra: { - // // 글로벌 batch size 설정 - // maxBatchSize: 100 - // } - }), - RedisModule.forRoot({ - type: 'single', - url: process.env.REDIS_URL || 'redis://localhost:6379' - }), - QuizModule, - UserModule, - InitDBModule - ], - controllers: [AppController], - providers: [AppService] -}) -export class AppModule {} +import { Module } from '@nestjs/common'; +import { AppController } from './app.controller'; +import { AppService } from './app.service'; +import { GameModule } from './game/game.module'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { RedisModule } from '@nestjs-modules/ioredis'; +import { ConfigModule } from '@nestjs/config'; +import { QuizSetModel } from './quiz/entities/quiz-set.entity'; +import { QuizModel } from './quiz/entities/quiz.entity'; +import { QuizChoiceModel } from './quiz/entities/quiz-choice.entity'; +import { UserModel } from './user/entities/user.entity'; +import { UserQuizArchiveModel } from './user/entities/user-quiz-archive.entity'; +import { InitDBModule } from './InitDB/InitDB.module'; +import { UserModule } from './user/user.module'; +import { QuizModule } from './quiz/quiz.module'; + +@Module({ + imports: [ + ConfigModule.forRoot({ + envFilePath: '../.env', + isGlobal: true + }), + GameModule, + TypeOrmModule.forRoot({ + type: 'mysql', + host: process.env.DB_HOST || 'localhost', + port: +process.env.DB_PORT || 3306, + username: process.env.DB_USER || 'root', + password: process.env.DB_PASSWD || 'test', + database: process.env.DB_NAME || 'test_db', + entities: [QuizSetModel, QuizModel, QuizChoiceModel, UserModel, UserQuizArchiveModel], + synchronize: process.env.DEV ? true : false, // 개발 모드에서만 활성화 + logging: true, // 모든 쿼리 로깅 + logger: 'advanced-console' + // extra: { + // // 글로벌 batch size 설정 + // maxBatchSize: 100 + // } + }), + RedisModule.forRoot({ + type: 'single', + url: process.env.REDIS_URL || 'redis://localhost:6379' + }), + QuizModule, + UserModule, + InitDBModule + ], + controllers: [AppController], + providers: [AppService] +}) +export class AppModule {} diff --git a/BE/src/common/constants/exception-message.ts b/BE/src/common/constants/exception-message.ts index 9bc8ee8..ecec959 100644 --- a/BE/src/common/constants/exception-message.ts +++ b/BE/src/common/constants/exception-message.ts @@ -2,6 +2,7 @@ export const ExceptionMessage = { ROOM_NOT_FOUND: '존재하지 않는 게임 방입니다.', ROOM_FULL: '게임 방 최대 인원이 모두 찼습니다.', NOT_A_PLAYER: '해당 게임 방의 플레이어가 아닙니다.', - ONLY_HOST_CAN_START: '방장만 게임을 시작할 수 있습니다.', + ONLY_HOST: '방장이 아닙니다.', GAME_NOT_STARTED: '게임이 시작되지 않았습니다.', -}; \ No newline at end of file + EXCEEDS_QUIZ_SET_LIMIT: '선택된 퀴즈 수가 퀴즈셋에 있는 퀴즈 수를 초과했습니다.' +}; diff --git a/BE/src/common/constants/redis-key.constant.ts b/BE/src/common/constants/redis-key.constant.ts index e113ec2..9866610 100644 --- a/BE/src/common/constants/redis-key.constant.ts +++ b/BE/src/common/constants/redis-key.constant.ts @@ -1,11 +1,13 @@ export const REDIS_KEY = { ROOM: (gameId: string) => `Room:${gameId}`, - ROOM_PLAYER: (gameId: string, playerId: string) => `Room:${gameId}:Player:${playerId}`, + ROOM_PLAYERS: (gameId: string) => `Room:${gameId}:Players`, ROOM_QUIZ: (gameId: string, quizId: string) => `Room:${gameId}:Quiz:${quizId}`, ROOM_QUIZ_CHOICES: (gameId: string, quizId: string) => `Room:${gameId}:Quiz:${quizId}:Choices`, ROOM_LEADERBOARD: (gameId: string) => `Room:${gameId}:Leaderboard`, ROOM_CURRENT_QUIZ: (gameId: string) => `Room:${gameId}:CurrentQuiz`, + ROOM_TIMER: (gameId: string) => `Room:${gameId}:Timer`, ROOM_QUIZ_SET: (gameId: string) => `Room:${gameId}:QuizSet`, + PLAYER: (playerId: string) => `Player:${playerId}`, - ACTIVE_ROOMS: 'ActiveRooms', // 활성화된 방 목록을 저장하는 Set (핀번호 중복 체크하기 위함) -}; \ No newline at end of file + ACTIVE_ROOMS: 'ActiveRooms' // 활성화된 방 목록을 저장하는 Set (핀번호 중복 체크하기 위함) +}; diff --git a/BE/src/common/constants/socket-events.ts b/BE/src/common/constants/socket-events.ts index 9d64e70..d9dd1f7 100644 --- a/BE/src/common/constants/socket-events.ts +++ b/BE/src/common/constants/socket-events.ts @@ -6,7 +6,7 @@ const SocketEvents = { UPDATE_ROOM_QUIZSET: 'updateRoomQuizset', JOIN_ROOM: 'joinRoom', START_GAME: 'startGame', - STOP_GAME: 'stopGame', + END_GAME: 'endGame', END_QUIZ_TIME: 'endQuizTime', START_QUIZ_TIME: 'startQuizTime', UPDATE_SCORE: 'updateScore', diff --git a/BE/src/game/game.gateway.ts b/BE/src/game/game.gateway.ts index 72ed734..fa57183 100644 --- a/BE/src/game/game.gateway.ts +++ b/BE/src/game/game.gateway.ts @@ -8,18 +8,18 @@ import { import { Server, Socket } from 'socket.io'; import { Logger, UseFilters, UsePipes } from '@nestjs/common'; import { WsExceptionFilter } from '../common/filters/ws-exception.filter'; -import socketEvents from '../common/constants/socket-events'; +import SocketEvents from '../common/constants/socket-events'; import { CreateGameDto } from './dto/create-game.dto'; import { JoinRoomDto } from './dto/join-room.dto'; import { ChatMessageDto } from './dto/chat-message.dto'; -import { GameService } from './game.service'; +import { GameService } from './service/game.service'; import { UpdatePositionDto } from './dto/update-position.dto'; import { GameValidationPipe } from './validations/game-validation.pipe'; import { StartGameDto } from './dto/start-game.dto'; import { UpdateRoomOptionDto } from './dto/update-room-option.dto'; -import { InjectRedis } from '@nestjs-modules/ioredis'; -import Redis from 'ioredis'; import { UpdateRoomQuizsetDto } from './dto/update-room-quizset.dto'; +import { GameChatService } from './service/game.chat.service'; +import { GameRoomService } from './service/game.room.service'; @UseFilters(new WsExceptionFilter()) @WebSocketGateway({ @@ -34,184 +34,95 @@ export class GameGateway { private logger = new Logger('GameGateway'); constructor( - @InjectRedis() private readonly redis: Redis, - private readonly gameService: GameService + private readonly gameService: GameService, + private readonly gameChatService: GameChatService, + private readonly gameRoomService: GameRoomService ) {} - @SubscribeMessage(socketEvents.CREATE_ROOM) - @UsePipes(new GameValidationPipe(socketEvents.CREATE_ROOM)) + @SubscribeMessage(SocketEvents.CREATE_ROOM) + @UsePipes(new GameValidationPipe(SocketEvents.CREATE_ROOM)) async handleCreateRoom( @MessageBody() gameConfig: CreateGameDto, @ConnectedSocket() client: Socket ): Promise { - const roomId = await this.gameService.createRoom(gameConfig, client.id); - client.emit(socketEvents.CREATE_ROOM, { gameId: roomId }); + const roomId = await this.gameRoomService.createRoom(gameConfig, client.id); + client.emit(SocketEvents.CREATE_ROOM, { gameId: roomId }); } - @SubscribeMessage(socketEvents.JOIN_ROOM) - @UsePipes(new GameValidationPipe(socketEvents.JOIN_ROOM)) + @SubscribeMessage(SocketEvents.JOIN_ROOM) + @UsePipes(new GameValidationPipe(SocketEvents.JOIN_ROOM)) async handleJoinRoom( @MessageBody() dto: JoinRoomDto, @ConnectedSocket() client: Socket ): Promise { - const { players, newPlayer } = await this.gameService.joinRoom(dto, client.id); - client.join(dto.gameId); - client.emit(socketEvents.JOIN_ROOM, { players }); - this.server.to(dto.gameId).emit(socketEvents.JOIN_ROOM, { - players: [newPlayer] - }); + const players = await this.gameRoomService.joinRoom(dto, client.id); + client.emit(SocketEvents.JOIN_ROOM, { players }); } - @SubscribeMessage(socketEvents.UPDATE_POSITION) - @UsePipes(new GameValidationPipe(socketEvents.UPDATE_POSITION)) + @SubscribeMessage(SocketEvents.UPDATE_POSITION) + @UsePipes(new GameValidationPipe(SocketEvents.UPDATE_POSITION)) async handleUpdatePosition( @MessageBody() updatePosition: UpdatePositionDto, @ConnectedSocket() client: Socket ): Promise { - const result = await this.gameService.updatePosition(updatePosition, client.id); - this.server.to(updatePosition.gameId).emit(socketEvents.UPDATE_POSITION, result); + await this.gameService.updatePosition(updatePosition, client.id); } - @SubscribeMessage(socketEvents.CHAT_MESSAGE) - @UsePipes(new GameValidationPipe(socketEvents.CHAT_MESSAGE)) + @SubscribeMessage(SocketEvents.CHAT_MESSAGE) + @UsePipes(new GameValidationPipe(SocketEvents.CHAT_MESSAGE)) async handleChatMessage( @MessageBody() chatMessage: ChatMessageDto, @ConnectedSocket() client: Socket ): Promise { - const result = await this.gameService.handleChatMessage(chatMessage, client.id); - this.server.to(chatMessage.gameId).emit(socketEvents.CHAT_MESSAGE, result); + await this.gameChatService.chatMessage(chatMessage, client.id); } - // TODO: Redis에 맞게 구현해야 함. (아직 초안이라 해서, redis로 바로 수정하지 않았음) - // @SubscribeMessage(socketEvents.START_GAME) - // handleStartGame( - // @MessageBody() startGameDto: StartGameDto, - // @ConnectedSocket() client: Socket - // ): void { - // const { gameId } = startGameDto; - // const room = this.rooms.get(gameId); - // if (room.host !== client.id) { - // client.emit('error', '[ERROR] 방장만 게임을 시작할 수 있습니다.'); - // return; - // } - // room.status = 'playing'; - // this.server.to(gameId).emit(socketEvents.START_GAME, 'gameStarted'); - // this.logger.verbose(`게임 시작: ${gameId}`); - // } - // - // @SubscribeMessage(socketEvents.START_QUIZ_TIME) - // handleStartQuizTime( - // @MessageBody() startGameDto: StartGameDto, - // @ConnectedSocket() client: Socket - // ): void { - // const { gameId } = startGameDto; - // const room = this.rooms.get(gameId); - // if (room.host !== client.id) { - // client.emit('error', '[ERROR] 방장만 퀴즈를 시작할 수 있습니다.'); - // return; - // } - // if (room.status !== 'playing') { - // client.emit('error', '[ERROR] 게임이 시작되지 않았습니다.'); - // return; - // } - // this.server.to(gameId).emit(socketEvents.START_QUIZ_TIME, 'gameStarted'); - // this.logger.verbose(`퀴즈 시간 시작: ${gameId}`); - // } - - // TODO: Redis에 맞게 구현해야 함. (아직 완전히 작성된 함수가 아니라, redis로 바로 수정하지 않았음) - // async handleDisconnect(client: Socket) { - // this.logger.verbose(`클라이언트가 연결 해제되었어요!: ${client.id}`); - // this.rooms.forEach((room, roomId) => { - // room.players.delete(client.id); - // if (room.players.has(client.id)) { - // const player = room.players.get(client.id); - // room.players.delete(client.id); - // if (player.isHost && room.players.size > 0) { - // const newHostId = room.players.keys()[0]; - // const newHost = room.players.get(newHostId); - // newHost.isHost = true; - // room.host = newHostId; - // // TODO: 호스트 변경 클라이언트에게 보내주기 - // } - // this.server.to(room.id).emit(socketEvents.EXIT_ROOM, { playerId: client.id }); - // } - // if (room.players.size === 0) { - // this.rooms.delete(roomId); // TODO: delete는 성능이 좋지 않음. 우선 임시로 사용하고 향후 Redis로 개선 - // } - // }); - // - // // TODO: 세션만 삭제하는 게 아니라 소켓도 삭제하기 - // } + @SubscribeMessage(SocketEvents.UPDATE_ROOM_OPTION) + @UsePipes(new GameValidationPipe(SocketEvents.UPDATE_ROOM_OPTION)) + async handleUpdateRoomOption( + @MessageBody() updateRoomOptionDto: UpdateRoomOptionDto, + @ConnectedSocket() client: Socket + ) { + await this.gameRoomService.updateRoomOption(updateRoomOptionDto, client.id); + } - @SubscribeMessage(socketEvents.UPDATE_ROOM_OPTION) - async handleUpdateRoomOption(@MessageBody() updateRoomOptionDto: UpdateRoomOptionDto) { - const { gameId, gameMode, title, maxPlayerCount, isPublicGame } = updateRoomOptionDto; - const roomKey = `Room:${gameId}`; - // TODO: 호스트인지 확인 - await this.redis.set(`${roomKey}:Changes`, 'Option'); - await this.redis.hmset(roomKey, { - title: title, - gameMode: gameMode, - maxPlayerCount: maxPlayerCount, - isPublic: isPublicGame - }); + @SubscribeMessage(SocketEvents.UPDATE_ROOM_QUIZSET) + @UsePipes(new GameValidationPipe(SocketEvents.UPDATE_ROOM_QUIZSET)) + async handleUpdateRoomQuizset( + @MessageBody() updateRoomQuizsetDto: UpdateRoomQuizsetDto, + @ConnectedSocket() client: Socket + ) { + await this.gameRoomService.updateRoomQuizset(updateRoomQuizsetDto, client.id); } - @SubscribeMessage(socketEvents.UPDATE_ROOM_QUIZSET) - async handleUpdateRoomQuizset(@MessageBody() updateRoomQuizsetDto: UpdateRoomQuizsetDto) { - const { gameId, quizSetId, quizCount } = updateRoomQuizsetDto; - const roomKey = `Room:${gameId}`; - // TODO: 호스트인지 확인 - await this.redis.set(`${roomKey}:Changes`, 'Quizset'); - await this.redis.hmset(roomKey, { - quizSetId: quizSetId, - quizCount: quizCount - }); + @SubscribeMessage(SocketEvents.START_GAME) + @UsePipes(new GameValidationPipe(SocketEvents.START_GAME)) + async handleStartGame( + @MessageBody() startGameDto: StartGameDto, + @ConnectedSocket() client: Socket + ) { + await this.gameService.startGame(startGameDto, client.id); } afterInit() { this.logger.verbose('WebSocket 서버 초기화 완료했어요!'); - this.subscribeRedisEvent().then(() => { + this.gameService.subscribeRedisEvent(this.server).then(() => { this.logger.verbose('Redis 이벤트 등록 완료했어요!'); }); - } - - async subscribeRedisEvent() { - const subscriber = this.redis.duplicate(); - await subscriber.subscribe('__keyspace@0__:Room:*'); - - subscriber.on('message', async (channel, message) => { - const key = channel.replace('__keyspace@0__:', ''); - const splitKey = key.split(':'); - if (splitKey.length !== 2) { - return; - } - const gameId = splitKey[1]; - - if (message === 'hset') { - const changes = await this.redis.get(`${key}:Changes`); - const roomData = await this.redis.hgetall(key); - - if (changes === 'Option') { - this.server.to(gameId).emit(socketEvents.UPDATE_ROOM_OPTION, { - title: roomData.title, - gameMode: roomData.gameMode, - maxPlayerCount: roomData.maxPlayerCount, - isPublic: roomData.isPublic - }); - } else if (changes === 'Quizset') { - this.server.to(gameId).emit(socketEvents.UPDATE_ROOM_QUIZSET, { - quizSetId: roomData.quizSetId, - quizCount: roomData.quizCount - }); - } - } + this.gameChatService.subscribeChatEvent(this.server).then(() => { + this.logger.verbose('Redis Chat 이벤트 등록 완료했어요!'); }); } handleConnection(client: Socket) { this.logger.verbose(`클라이언트가 연결되었어요!: ${client.id}`); } + + handleDisconnect(client: Socket) { + this.logger.verbose(`클라이언트가 연결 해제되었어요!: ${client.id}`); + + this.gameService.disconnect(client.id); + } } diff --git a/BE/src/game/game.module.ts b/BE/src/game/game.module.ts index 5aa1da8..07c46ab 100644 --- a/BE/src/game/game.module.ts +++ b/BE/src/game/game.module.ts @@ -1,11 +1,14 @@ import { Module } from '@nestjs/common'; import { GameGateway } from './game.gateway'; -import { GameService } from './game.service'; +import { GameService } from './service/game.service'; import { RedisModule } from '@nestjs-modules/ioredis'; import { GameValidator } from './validations/game.validator'; +import { HttpModule } from '@nestjs/axios'; +import { GameChatService } from './service/game.chat.service'; +import { GameRoomService } from './service/game.room.service'; @Module({ - imports: [RedisModule], - providers: [GameGateway, GameService, GameValidator] + imports: [RedisModule, HttpModule], + providers: [GameGateway, GameService, GameChatService, GameRoomService, GameValidator] }) export class GameModule {} diff --git a/BE/src/game/game.service.ts b/BE/src/game/game.service.ts deleted file mode 100644 index 143b59d..0000000 --- a/BE/src/game/game.service.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { InjectRedis } from '@nestjs-modules/ioredis'; -import Redis from 'ioredis'; -import { REDIS_KEY } from '../common/constants/redis-key.constant'; -import { CreateGameDto } from './dto/create-game.dto'; -import { JoinRoomDto } from './dto/join-room.dto'; -import { UpdatePositionDto } from './dto/update-position.dto'; -import { ChatMessageDto } from './dto/chat-message.dto'; -import { generateUniquePin } from '../common/utils/utils'; -import { GameValidator } from './validations/game.validator'; -import SocketEvents from '../common/constants/socket-events'; - -@Injectable() -export class GameService { - private readonly logger = new Logger(GameService.name); - - constructor( - @InjectRedis() private readonly redis: Redis, - private readonly gameValidator: GameValidator - ) {} - async createRoom(gameConfig: CreateGameDto, clientId: string): Promise { - const currentRoomPins = await this.redis.smembers(REDIS_KEY.ACTIVE_ROOMS); - const roomId = generateUniquePin(currentRoomPins); - - await this.redis.hmset(REDIS_KEY.ROOM(roomId), { - host: clientId, - status: 'waiting', - title: gameConfig.title, - gameMode: gameConfig.gameMode, - maxPlayerCount: gameConfig.maxPlayerCount.toString(), - isPublicGame: gameConfig.isPublicGame ? '1' : '0', - isWaiting: '1', - lastActivityAt: new Date().getTime().toString() - }); - - await this.redis.sadd(REDIS_KEY.ACTIVE_ROOMS, roomId); - this.logger.verbose(`게임 방 생성 완료: ${roomId}`); - - return roomId; - } - - async joinRoom(dto: JoinRoomDto, clientId: string) { - const roomKey = REDIS_KEY.ROOM(dto.gameId); - const room = await this.redis.hgetall(roomKey); - this.gameValidator.validateRoomExists(SocketEvents.JOIN_ROOM, room); - - const currentPlayers = await this.redis.keys(`Room:${dto.gameId}:Player:*`); - this.gameValidator.validateRoomCapacity(SocketEvents.JOIN_ROOM, currentPlayers.length, parseInt(room.maxPlayerCount)); - - const playerKey = REDIS_KEY.ROOM_PLAYER(dto.gameId, clientId); - const positionX = Math.random(); - const positionY = Math.random(); - - await this.redis.hmset(playerKey, { - playerName: dto.playerName, - positionX: positionX.toString(), - positionY: positionY.toString(), - disconnected: '0' - }); - - await this.redis.zadd(REDIS_KEY.ROOM_LEADERBOARD(dto.gameId), 0, clientId); - - const players = []; - for (const playerKey of currentPlayers) { - const playerId = playerKey.split(':').pop(); - const player = await this.redis.hgetall(playerKey); - players.push({ - playerId, - playerName: player.playerName, - playerPosition: [parseFloat(player.positionX), parseFloat(player.positionY)] - }); - } - - const newPlayer = { - playerId: clientId, - playerName: dto.playerName, - playerPosition: [positionX, positionY] - }; - - this.logger.verbose(`게임 방 입장 완료: ${dto.gameId} - ${clientId} (${dto.playerName})`); - - return { players, newPlayer }; - } - - async updatePosition(updatePosition: UpdatePositionDto, clientId: string) { - const { gameId, newPosition } = updatePosition; - const playerKey = REDIS_KEY.ROOM_PLAYER(gameId, clientId); - - const player = await this.redis.hgetall(playerKey); - this.gameValidator.validatePlayerInRoom(SocketEvents.UPDATE_POSITION, player); - - await this.redis.hmset(playerKey, { - positionX: newPosition[0].toString(), - positionY: newPosition[1].toString() - }); - - this.logger.verbose( - `플레이어 위치 업데이트: ${gameId} - ${clientId} (${player.playerName}) = ${newPosition}` - ); - - return { - playerId: clientId, - playerPosition: newPosition - }; - } - - async handleChatMessage(chatMessage: ChatMessageDto, clientId: string) { - const { gameId, message } = chatMessage; - const roomKey = REDIS_KEY.ROOM(gameId); - - const room = await this.redis.hgetall(roomKey); - this.gameValidator.validateRoomExists(SocketEvents.CHAT_MESSAGE, room); - - const playerKey = REDIS_KEY.ROOM_PLAYER(gameId, clientId); - const player = await this.redis.hgetall(playerKey); - - this.gameValidator.validatePlayerInRoom(SocketEvents.CHAT_MESSAGE, player); - - this.logger.verbose( - `채팅 전송: ${gameId} - ${clientId} (${player.playerName}) = ${message}` - ); - - return { - playerId: clientId, - playerName: player.playerName, - message, - timestamp: new Date() - }; - } -} \ No newline at end of file diff --git a/BE/src/game/service/game.chat.service.ts b/BE/src/game/service/game.chat.service.ts new file mode 100644 index 0000000..4a25904 --- /dev/null +++ b/BE/src/game/service/game.chat.service.ts @@ -0,0 +1,55 @@ +import { Injectable, Logger } from '@nestjs/common'; +import Redis from 'ioredis'; +import { InjectRedis } from '@nestjs-modules/ioredis'; +import { GameValidator } from '../validations/game.validator'; +import { ChatMessageDto } from '../dto/chat-message.dto'; +import { REDIS_KEY } from '../../common/constants/redis-key.constant'; +import SocketEvents from '../../common/constants/socket-events'; +import { Server } from 'socket.io'; + +@Injectable() +export class GameChatService { + private readonly logger = new Logger(GameChatService.name); + + constructor( + @InjectRedis() private readonly redis: Redis, + private readonly gameValidator: GameValidator + ) {} + + async chatMessage(chatMessage: ChatMessageDto, clientId: string) { + const { gameId, message } = chatMessage; + const roomKey = REDIS_KEY.ROOM(gameId); + + const room = await this.redis.hgetall(roomKey); + this.gameValidator.validateRoomExists(SocketEvents.CHAT_MESSAGE, room); + + const playerKey = REDIS_KEY.PLAYER(clientId); + const player = await this.redis.hgetall(playerKey); + + this.gameValidator.validatePlayerInRoom(SocketEvents.CHAT_MESSAGE, gameId, player); + + await this.redis.publish( + `chat:${gameId}`, + JSON.stringify({ + playerId: clientId, + playerName: player.playerName, + message, + timestamp: new Date() + }) + ); + this.logger.verbose(`채팅 전송: ${gameId} - ${clientId} (${player.playerName}) = ${message}`); + } + + async subscribeChatEvent(server: Server) { + const chatSubscriber = this.redis.duplicate(); + chatSubscriber.psubscribe('chat:*'); + + chatSubscriber.on('pmessage', async (_pattern, channel, message) => { + console.log(`channel: ${channel}`); // channel: chat:317172 + console.log(`message: ${message}`); // message: {"playerId":"8CT28Iw5FgjgPHNyAAAs","playerName":"Player1","message":"Hello, everyone!","timestamp":"2024-11-14T08:32:38.617Z"} + const gameId = channel.split(':')[1]; + const chatMessage = JSON.parse(message); + server.to(gameId).emit(SocketEvents.CHAT_MESSAGE, chatMessage); + }); + } +} diff --git a/BE/src/game/service/game.room.service.ts b/BE/src/game/service/game.room.service.ts new file mode 100644 index 0000000..f38d3d2 --- /dev/null +++ b/BE/src/game/service/game.room.service.ts @@ -0,0 +1,123 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRedis } from '@nestjs-modules/ioredis'; +import Redis from 'ioredis'; +import { GameValidator } from '../validations/game.validator'; +import { CreateGameDto } from '../dto/create-game.dto'; +import { REDIS_KEY } from '../../common/constants/redis-key.constant'; +import { generateUniquePin } from '../../common/utils/utils'; +import { JoinRoomDto } from '../dto/join-room.dto'; +import SocketEvents from '../../common/constants/socket-events'; +import { UpdateRoomOptionDto } from '../dto/update-room-option.dto'; +import { UpdateRoomQuizsetDto } from '../dto/update-room-quizset.dto'; + +@Injectable() +export class GameRoomService { + private readonly logger = new Logger(GameRoomService.name); + + constructor( + @InjectRedis() private readonly redis: Redis, + private readonly gameValidator: GameValidator + ) {} + + async createRoom(gameConfig: CreateGameDto, clientId: string): Promise { + const currentRoomPins = await this.redis.smembers(REDIS_KEY.ACTIVE_ROOMS); + const roomId = generateUniquePin(currentRoomPins); + + await this.redis.hmset(REDIS_KEY.ROOM(roomId), { + host: clientId, + status: 'waiting', + title: gameConfig.title, + gameMode: gameConfig.gameMode, + maxPlayerCount: gameConfig.maxPlayerCount.toString(), + isPublicGame: gameConfig.isPublicGame ? '1' : '0', + isWaiting: '1', + lastActivityAt: new Date().getTime().toString(), + quizSetId: '0', + quizCount: '2' + }); + + await this.redis.sadd(REDIS_KEY.ACTIVE_ROOMS, roomId); + this.logger.verbose(`게임 방 생성 완료: ${roomId}`); + + return roomId; + } + + async joinRoom(dto: JoinRoomDto, clientId: string) { + const roomKey = REDIS_KEY.ROOM(dto.gameId); + const room = await this.redis.hgetall(roomKey); + this.gameValidator.validateRoomExists(SocketEvents.JOIN_ROOM, room); + + const currentPlayers = await this.redis.smembers(REDIS_KEY.ROOM_PLAYERS(dto.gameId)); + this.gameValidator.validateRoomCapacity( + SocketEvents.JOIN_ROOM, + currentPlayers.length, + parseInt(room.maxPlayerCount) + ); + + const playerKey = REDIS_KEY.PLAYER(clientId); + const positionX = Math.random(); + const positionY = Math.random(); + + await this.redis.set(`${playerKey}:Changes`, 'Join'); + await this.redis.hmset(playerKey, { + playerName: dto.playerName, + positionX: positionX.toString(), + positionY: positionY.toString(), + disconnected: '0', + gameId: dto.gameId + }); + + await this.redis.zadd(REDIS_KEY.ROOM_LEADERBOARD(dto.gameId), 0, clientId); + await this.redis.sadd(REDIS_KEY.ROOM_PLAYERS(dto.gameId), clientId); + + const players = []; + for (const playerId of currentPlayers) { + const player = await this.redis.hgetall(REDIS_KEY.PLAYER(playerId)); + players.push({ + playerId, + playerName: player.playerName, + playerPosition: [parseFloat(player.positionX), parseFloat(player.positionY)] + }); + } + + this.logger.verbose(`게임 방 입장 완료: ${dto.gameId} - ${clientId} (${dto.playerName})`); + + return players; + } + + async updateRoomOption(updateRoomOptionDto: UpdateRoomOptionDto, clientId: string) { + const { gameId, gameMode, title, maxPlayerCount, isPublicGame } = updateRoomOptionDto; + const roomKey = `Room:${gameId}`; + + const room = await this.redis.hgetall(roomKey); + this.gameValidator.validateRoomExists(SocketEvents.UPDATE_ROOM_OPTION, room); + + this.gameValidator.validatePlayerIsHost(SocketEvents.UPDATE_ROOM_OPTION, room, clientId); + + await this.redis.set(`${roomKey}:Changes`, 'Option'); + await this.redis.hmset(roomKey, { + title: title, + gameMode: gameMode, + maxPlayerCount: maxPlayerCount.toString(), + isPublicGame: isPublicGame ? '1' : '0' + }); + this.logger.verbose(`게임방 옵션 변경: ${gameId}`); + } + + async updateRoomQuizset(updateRoomQuizsetDto: UpdateRoomQuizsetDto, clientId: string) { + const { gameId, quizSetId, quizCount } = updateRoomQuizsetDto; + const roomKey = `Room:${gameId}`; + + const room = await this.redis.hgetall(roomKey); + this.gameValidator.validateRoomExists(SocketEvents.UPDATE_ROOM_QUIZSET, room); + + this.gameValidator.validatePlayerIsHost(SocketEvents.UPDATE_ROOM_QUIZSET, room, clientId); + + await this.redis.set(`${roomKey}:Changes`, 'Quizset'); + await this.redis.hmset(roomKey, { + quizSetId: quizSetId.toString(), + quizCount: quizCount.toString() + }); + this.logger.verbose(`게임방 퀴즈셋 변경: ${gameId}`); + } +} diff --git a/BE/src/game/service/game.service.ts b/BE/src/game/service/game.service.ts new file mode 100644 index 0000000..d53c980 --- /dev/null +++ b/BE/src/game/service/game.service.ts @@ -0,0 +1,379 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRedis } from '@nestjs-modules/ioredis'; +import Redis from 'ioredis'; +import { REDIS_KEY } from '../../common/constants/redis-key.constant'; +import { UpdatePositionDto } from '../dto/update-position.dto'; +import { GameValidator } from '../validations/game.validator'; +import SocketEvents from '../../common/constants/socket-events'; +import { StartGameDto } from '../dto/start-game.dto'; +import { Server } from 'socket.io'; +import { HttpService } from '@nestjs/axios'; +import { mockQuizData } from '../../../test/mocks/quiz-data.mock'; + +@Injectable() +export class GameService { + private readonly logger = new Logger(GameService.name); + private scoringMap = new Map(); + + constructor( + @InjectRedis() private readonly redis: Redis, + private readonly httpService: HttpService, + private readonly gameValidator: GameValidator + ) {} + + async updatePosition(updatePosition: UpdatePositionDto, clientId: string) { + const { gameId, newPosition } = updatePosition; + + const playerKey = REDIS_KEY.PLAYER(clientId); + + const player = await this.redis.hgetall(playerKey); + this.gameValidator.validatePlayerInRoom(SocketEvents.UPDATE_POSITION, gameId, player); + + await this.redis.set(`${playerKey}:Changes`, 'Position'); + await this.redis.hmset(playerKey, { + positionX: newPosition[0].toString(), + positionY: newPosition[1].toString() + }); + + this.logger.verbose( + `플레이어 위치 업데이트: ${gameId} - ${clientId} (${player.playerName}) = ${newPosition}` + ); + } + + async startGame(startGameDto: StartGameDto, clientId: string) { + const { gameId } = startGameDto; + const roomKey = `Room:${gameId}`; + + const room = await this.redis.hgetall(roomKey); + this.gameValidator.validateRoomExists(SocketEvents.START_GAME, room); + + this.gameValidator.validatePlayerIsHost(SocketEvents.START_GAME, room, clientId); + + // const getQuizsetURL = `http://localhost:3000/api/quizset/${room.quizSetId}`; + // + // // REFACTOR: get 대신 Promise를 반환하는 axiosRef를 사용했으나 더 나은 방식이 있는지 확인 + // const quizset = await this.httpService.axiosRef({ + // url: getQuizsetURL, + // method: 'GET' + // }); + const quizset = mockQuizData; + this.gameValidator.validateQuizsetCount( + SocketEvents.START_GAME, + parseInt(room.quizCount), + quizset.quizList.length + ); + + // Room Quiz 초기화 + const prevQuizList = await this.redis.smembers(REDIS_KEY.ROOM_QUIZ_SET(gameId)); + for (const prevQuiz of prevQuizList) { + await this.redis.del(REDIS_KEY.ROOM_QUIZ(gameId, prevQuiz)); + await this.redis.del(REDIS_KEY.ROOM_QUIZ_CHOICES(gameId, prevQuiz)); + } + await this.redis.del(REDIS_KEY.ROOM_QUIZ_SET(gameId)); + + // 퀴즈셋 랜덤 선택 + const shuffledQuizList = quizset.quizList.sort(() => 0.5 - Math.random()); + const selectedQuizList = shuffledQuizList.slice(0, parseInt(room.quizCount)); + + // 퀴즈들 id 레디스에 등록 + await this.redis.sadd( + REDIS_KEY.ROOM_QUIZ_SET(gameId), + ...selectedQuizList.map((quiz) => quiz.id) + ); + for (const quiz of selectedQuizList) { + await this.redis.hmset(REDIS_KEY.ROOM_QUIZ(gameId, quiz.id), { + quiz: quiz.quiz, + answer: quiz.choiceList.find((choice) => choice.isAnswer).order, + limitTime: quiz.limitTime.toString(), + choiceCount: quiz.choiceList.length.toString() + }); + await this.redis.hmset( + REDIS_KEY.ROOM_QUIZ_CHOICES(gameId, quiz.id), + quiz.choiceList.reduce( + (acc, choice) => { + acc[choice.order] = choice.content; + return acc; + }, + {} as Record + ) + ); + } + + // 리더보드 초기화 + const leaderboard = await this.redis.zrange(REDIS_KEY.ROOM_LEADERBOARD(gameId), 0, -1); + for (const playerId of leaderboard) { + await this.redis.zadd(REDIS_KEY.ROOM_LEADERBOARD(gameId), 0, playerId); + } + + // 게임이 시작되었음을 알림 + await this.redis.set(`${roomKey}:Changes`, 'Start'); + await this.redis.hmset(roomKey, { + status: 'playing' + }); + + // 첫 퀴즈 걸어주기 + await this.redis.set(REDIS_KEY.ROOM_CURRENT_QUIZ(gameId), '-1:end'); // 0:start, 0:end, 1:start, 1:end + await this.redis.set(REDIS_KEY.ROOM_TIMER(gameId), 'timer', 'EX', 3); + + this.logger.verbose(`게임 시작: ${gameId}`); + } + + async subscribeRedisEvent(server: Server) { + this.redis.config('SET', 'notify-keyspace-events', 'KEhx'); + + // TODO: 분리 필요 + + const scoringSubscriber = this.redis.duplicate(); + await scoringSubscriber.psubscribe('scoring:*'); + scoringSubscriber.on('pmessage', async (_pattern, channel, message) => { + const gameId = channel.split(':')[1]; + + const completeClientsCount = parseInt(message); + if (!this.scoringMap.has(gameId)) { + this.scoringMap[gameId] = 0; + } + this.scoringMap[gameId] += completeClientsCount; + + const playersCount = await this.redis.scard(REDIS_KEY.ROOM_PLAYERS(gameId)); + if (this.scoringMap[gameId] >= playersCount) { + // 채점 완료! + const currentQuiz = await this.redis.get(REDIS_KEY.ROOM_CURRENT_QUIZ(gameId)); + const splitCurrentQuiz = currentQuiz.split(':'); + + const quizNum = parseInt(splitCurrentQuiz[0]); + const quizList = await this.redis.smembers(REDIS_KEY.ROOM_QUIZ_SET(gameId)); + const quiz = await this.redis.hgetall(REDIS_KEY.ROOM_QUIZ(gameId, quizList[quizNum])); + + const leaderboard = await this.redis.zrange( + REDIS_KEY.ROOM_LEADERBOARD(gameId), + 0, + -1, + 'WITHSCORES' + ); + const players = []; + for (let i = 0; i < leaderboard.length; i += 2) { + players.push({ + playerId: leaderboard[i], + score: parseInt(leaderboard[i + 1]), + isAnswer: + (await this.redis.hget(REDIS_KEY.PLAYER(leaderboard[i]), 'isAnswerCorrect')) === '1' + }); + } + server.to(gameId).emit(SocketEvents.END_QUIZ_TIME, { + answer: quiz.answer, + players: players + }); + + await this.redis.set(REDIS_KEY.ROOM_CURRENT_QUIZ(gameId), `${quizNum}:end`); // 현재 퀴즈 상태를 종료 상태로 변경 + await this.redis.set(REDIS_KEY.ROOM_TIMER(gameId), 'timer', 'EX', '10', 'NX'); // 타이머 설정 + this.logger.verbose(`endQuizTime: ${gameId} - ${quizNum}`); + } + }); + + const timerSubscriber = this.redis.duplicate(); + await timerSubscriber.psubscribe(`__keyspace@0__:${REDIS_KEY.ROOM_TIMER('*')}`); + timerSubscriber.on('pmessage', async (_pattern, channel, message) => { + const key = channel.replace('__keyspace@0__:', ''); + const splitKey = key.split(':'); + if (splitKey.length !== 3) { + return; + } + const gameId = splitKey[1]; + + if (message === 'expired') { + const currentQuiz = await this.redis.get(REDIS_KEY.ROOM_CURRENT_QUIZ(gameId)); + const splitCurrentQuiz = currentQuiz.split(':'); + const quizNum = parseInt(splitCurrentQuiz[0]); + if (splitCurrentQuiz[1] === 'start') { + // 채점 + const quizList = await this.redis.smembers(REDIS_KEY.ROOM_QUIZ_SET(gameId)); + const quiz = await this.redis.hgetall(REDIS_KEY.ROOM_QUIZ(gameId, quizList[quizNum])); + // gameId를 통해 해당 room에 있는 client id들을 받기 + const sockets = await server.in(gameId).fetchSockets(); + const clients = sockets.map((socket) => socket.id); + const correctPlayers = []; + for (const clientId of clients) { + const player = await this.redis.hgetall(REDIS_KEY.PLAYER(clientId)); + // 임시로 4개 선택지의 경우만 선정 (1 2 / 3 4) + let selectAnswer = 0; + if (parseFloat(player.positionY) < 0.5) { + if (parseFloat(player.positionX) < 0.5) { + selectAnswer = 1; + } else { + selectAnswer = 2; + } + } else { + if (parseFloat(player.positionX) < 0.5) { + selectAnswer = 3; + } else { + selectAnswer = 4; + } + } + await this.redis.set(`${REDIS_KEY.PLAYER(clientId)}:Changes`, 'AnswerCorrect'); + if (selectAnswer.toString() === quiz.answer) { + correctPlayers.push(clientId); + await this.redis.hmset(REDIS_KEY.PLAYER(clientId), { isAnswerCorrect: '1' }); + } else { + await this.redis.hmset(REDIS_KEY.PLAYER(clientId), { isAnswerCorrect: '0' }); + } + } + for (const clientId of correctPlayers) { + await this.redis.zincrby( + REDIS_KEY.ROOM_LEADERBOARD(gameId), + 1000 / correctPlayers.length, + clientId + ); + } + await this.redis.publish(`scoring:${gameId}`, clients.length.toString()); + this.logger.verbose(`채점: ${gameId} - ${clients.length}`); + } else { + // startQuizTime 하는 부분 + const newQuizNum = quizNum + 1; + const quizList = await this.redis.smembers(REDIS_KEY.ROOM_QUIZ_SET(gameId)); + if (quizList.length <= newQuizNum) { + // 마지막 퀴즈이면, 게임 종료! + // 1등을 새로운 호스트로 설정 + const leaderboard = await this.redis.zrange( + REDIS_KEY.ROOM_LEADERBOARD(gameId), + 0, + -1, + 'WITHSCORES' + ); + // const players = []; + // for (let i = 0; i < leaderboard.length; i += 2) { + // players.push({ + // playerId: leaderboard[i], + // score: parseInt(leaderboard[i + 1]) + // }); + // } + server.to(gameId).emit(SocketEvents.END_GAME, { + host: leaderboard[0] // 아마 첫 번째가 1등..? + }); + this.logger.verbose(`endGame: ${leaderboard[0]}`); + return; + } + const quiz = await this.redis.hgetall(REDIS_KEY.ROOM_QUIZ(gameId, quizList[newQuizNum])); + const quizChoices = await this.redis.hgetall( + REDIS_KEY.ROOM_QUIZ_CHOICES(gameId, quizList[newQuizNum]) + ); + server.to(gameId).emit(SocketEvents.START_QUIZ_TIME, { + quiz: quiz.quiz, + choiceList: Object.entries(quizChoices).map(([key, value]) => ({ + order: key, + content: value + })), + startTime: Date.now() + 3000, + endTime: Date.now() + (parseInt(quiz.limitTime) + 3) * 1000 + }); + await this.redis.set(REDIS_KEY.ROOM_CURRENT_QUIZ(gameId), `${newQuizNum}:start`); // 현재 퀴즈 상태를 시작 상태로 변경 + await this.redis.set( + REDIS_KEY.ROOM_TIMER(gameId), + 'timer', + 'EX', + (parseInt(quiz.limitTime) + 3).toString(), + 'NX' + ); // 타이머 설정 + this.logger.verbose(`startQuizTime: ${gameId} - ${newQuizNum}`); + } + } + }); + + const roomSubscriber = this.redis.duplicate(); + await roomSubscriber.psubscribe('__keyspace@0__:Room:*'); + roomSubscriber.on('pmessage', async (_pattern, channel, message) => { + const key = channel.replace('__keyspace@0__:', ''); + const splitKey = key.split(':'); + if (splitKey.length !== 2) { + return; + } + const gameId = splitKey[1]; + + if (message === 'hset') { + const changes = await this.redis.get(`${key}:Changes`); + const roomData = await this.redis.hgetall(key); + + if (changes === 'Option') { + server.to(gameId).emit(SocketEvents.UPDATE_ROOM_OPTION, { + title: roomData.title, + gameMode: roomData.gameMode, + maxPlayerCount: roomData.maxPlayerCount, + isPublic: roomData.isPublic + }); + } else if (changes === 'Quizset') { + server.to(gameId).emit(SocketEvents.UPDATE_ROOM_QUIZSET, { + quizSetId: roomData.quizSetId, + quizCount: roomData.quizCount + }); + } else if (changes === 'Start') { + server.to(gameId).emit(SocketEvents.START_GAME, ''); + } + } + }); + + const playerSubscriber = this.redis.duplicate(); + playerSubscriber.psubscribe('__keyspace@0__:Player:*'); + playerSubscriber.on('pmessage', async (_pattern, channel, message) => { + const key = channel.replace('__keyspace@0__:', ''); + const splitKey = key.split(':'); + if (splitKey.length !== 2) { + return; + } + const playerId = splitKey[1]; + + if (message === 'hset') { + const changes = await this.redis.get(`${key}:Changes`); + const playerData = await this.redis.hgetall(key); + if (changes === 'Join') { + const newPlayer = { + playerId: playerId, + playerName: playerData.playerName, + playerPosition: [parseFloat(playerData.positionX), parseFloat(playerData.positionY)] + }; + server.to(playerData.gameId).emit(SocketEvents.JOIN_ROOM, { + players: [newPlayer] + }); + } else if (changes === 'Position') { + server.to(playerData.gameId).emit(SocketEvents.UPDATE_POSITION, { + playerId: playerId, + playerPosition: [parseFloat(playerData.positionX), parseFloat(playerData.positionY)] + }); + } else if (changes === 'Disconnect') { + server.to(playerData.gameId).emit(SocketEvents.EXIT_ROOM, { + playerId: playerId + }); + } + } + }); + } + + async disconnect(clientId: string) { + const playerKey = REDIS_KEY.PLAYER(clientId); + const playerData = await this.redis.hgetall(playerKey); + + const roomPlayersKey = REDIS_KEY.ROOM_PLAYERS(playerData.gameId); + await this.redis.srem(roomPlayersKey, clientId); + + const roomLeaderboardKey = REDIS_KEY.ROOM_LEADERBOARD(playerData.gameId); + await this.redis.zrem(roomLeaderboardKey, clientId); + + const roomKey = REDIS_KEY.ROOM(playerData.gameId); + const host = await this.redis.hget(roomKey, 'host'); + const players = await this.redis.smembers(roomPlayersKey); + if (host === clientId && players.length > 0) { + const newHost = await this.redis.srandmember(REDIS_KEY.ROOM_PLAYERS(playerData.gameId)); + await this.redis.hmset(roomKey, { + host: newHost + }); + } + await this.redis.set(`${playerKey}:Changes`, 'Disconnect'); + await this.redis.hmset(playerKey, { + disconnected: '1' + }); + + if (players.length === 0) { + await this.redis.del(roomKey); + await this.redis.del(roomPlayersKey); + await this.redis.del(roomLeaderboardKey); + } + } +} diff --git a/BE/src/game/validations/game.validator.ts b/BE/src/game/validations/game.validator.ts index ac54166..ff1e1ee 100644 --- a/BE/src/game/validations/game.validator.ts +++ b/BE/src/game/validations/game.validator.ts @@ -6,28 +6,31 @@ import { ExceptionMessage } from '../../common/constants/exception-message'; export class GameValidator { validateRoomExists(eventName: string, room: any) { if (!room?.title) { - throw new GameWsException( - eventName, - ExceptionMessage.ROOM_NOT_FOUND - ); + throw new GameWsException(eventName, ExceptionMessage.ROOM_NOT_FOUND); } } validateRoomCapacity(eventName: string, currentPlayerCount: number, maxPlayerCount: number) { if (currentPlayerCount >= maxPlayerCount) { - throw new GameWsException( - eventName, - ExceptionMessage.ROOM_FULL - ); + throw new GameWsException(eventName, ExceptionMessage.ROOM_FULL); } } - validatePlayerInRoom(eventName: string, player: any) { - if (!player.playerName) { - throw new GameWsException( - eventName, - ExceptionMessage.NOT_A_PLAYER - ); + validatePlayerInRoom(eventName: string, gameId: string, player: any) { + if (gameId !== player?.gameId) { + throw new GameWsException(eventName, ExceptionMessage.NOT_A_PLAYER); } } -} \ No newline at end of file + + validatePlayerIsHost(eventName: string, room: any, clientId: string) { + if (room?.host !== clientId) { + throw new GameWsException(eventName, ExceptionMessage.ONLY_HOST); + } + } + + validateQuizsetCount(eventName: string, selectedQuizsetCount: number, quizsetCount: number) { + if (selectedQuizsetCount > quizsetCount) { + throw new GameWsException(eventName, ExceptionMessage.EXCEEDS_QUIZ_SET_LIMIT); + } + } +} diff --git a/BE/test/game.e2e-spec.ts b/BE/test/game.e2e-spec.ts index 246a0b5..bf68e1a 100644 --- a/BE/test/game.e2e-spec.ts +++ b/BE/test/game.e2e-spec.ts @@ -3,12 +3,26 @@ import { INestApplication } from '@nestjs/common'; import { IoAdapter } from '@nestjs/platform-socket.io'; import { io, Socket } from 'socket.io-client'; import { GameGateway } from '../src/game/game.gateway'; -import { GameService } from '../src/game/game.service'; +import { GameService } from '../src/game/service/game.service'; import socketEvents from '../src/common/constants/socket-events'; import { RedisModule } from '@nestjs-modules/ioredis'; import { Redis } from 'ioredis'; -import RedisMock from 'ioredis-mock'; import { GameValidator } from '../src/game/validations/game.validator'; +import { GameChatService } from '../src/game/service/game.chat.service'; +import { GameRoomService } from '../src/game/service/game.room.service'; +import { HttpService } from '@nestjs/axios'; +import { mockQuizData } from './mocks/quiz-data.mock'; +import RedisMock from 'ioredis-mock'; +import { REDIS_KEY } from '../src/common/constants/redis-key.constant'; + +const mockHttpService = { + axiosRef: jest.fn().mockImplementation(() => { + return Promise.resolve({ + data: mockQuizData, + status: 200 + }); + }) +}; describe('GameGateway (e2e)', () => { let app: INestApplication; @@ -22,21 +36,38 @@ describe('GameGateway (e2e)', () => { beforeAll(async () => { /* ioredis-mock을 사용하여 테스트용 인메모리 Redis 생성 */ redisMock = new RedisMock(); + jest.spyOn(redisMock, 'config').mockImplementation(() => Promise.resolve('OK')); + + // hset 메소드 오버라이드 + const originalHset = redisMock.hmset.bind(redisMock); + redisMock.hmset = async function (key: string, ...args: any[]) { + const result = await originalHset(key, ...args); + + await this.publish(`__keyspace@0__:${key}`, 'hset'); + + return result; + }; const moduleRef = await Test.createTestingModule({ imports: [ RedisModule.forRoot({ type: 'single', - url: 'redis://localhost:6379', - }), + url: 'redis://localhost:6379' + }) ], providers: [ GameGateway, GameService, + GameChatService, + GameRoomService, GameValidator, { provide: 'default_IORedisModuleConnectionToken', useValue: redisMock + }, + { + provide: HttpService, + useValue: mockHttpService } ] }).compile(); @@ -78,14 +109,22 @@ describe('GameGateway (e2e)', () => { }); afterEach(async () => { - if (client1 && client1.connected) client1.disconnect(); - if (client2 && client2.connected) client2.disconnect(); - if (client3 && client3.connected) client3.disconnect(); + if (client1 && client1.connected) { + client1.disconnect(); + } + if (client2 && client2.connected) { + client2.disconnect(); + } + if (client3 && client3.connected) { + client3.disconnect(); + } await redisMock.flushall(); }); afterAll(async () => { - if (app) await app.close(); + if (app) { + await app.close(); + } }); describe('createRoom 이벤트 테스트', () => { @@ -170,7 +209,7 @@ describe('GameGateway (e2e)', () => { expect(joinResponse.players).toBeDefined(); // Redis에서 플레이어 정보 확인 - const playerData = await redisMock.hgetall(`Room:${createResponse.gameId}:Player:${client2.id}`); + const playerData = await redisMock.hgetall(`Player:${client2.id}`); expect(playerData).toBeDefined(); expect(playerData.playerName).toBe('TestPlayer'); }); @@ -233,7 +272,7 @@ describe('GameGateway (e2e)', () => { }); const receivedMessages = await Promise.all(messagePromises); - receivedMessages.forEach(msg => { + receivedMessages.forEach((msg) => { expect(msg.message).toBe(testMessage); expect(msg.playerName).toBe('Player1'); }); @@ -241,37 +280,41 @@ describe('GameGateway (e2e)', () => { }); describe('updatePosition 이벤트 테스트', () => { - let gameId: string; - - beforeEach(async () => { + it('위치 업데이트 성공', async () => { // 방 생성 및 참여 설정 const createResponse = await new Promise<{ gameId: string }>((resolve) => { client1.once(socketEvents.CREATE_ROOM, resolve); client1.emit(socketEvents.CREATE_ROOM, { - title: 'Position Test Room', + title: 'Chat Test Room', gameMode: 'RANKING', maxPlayerCount: 5, isPublicGame: true }); }); - gameId = createResponse.gameId; - await new Promise((resolve) => { - client1.once(socketEvents.JOIN_ROOM, () => resolve()); - client1.emit(socketEvents.JOIN_ROOM, { - gameId, - playerName: 'Player1' - }); - }); - }); - - it('위치 업데이트 성공', async () => { - const newPosition = [0.5, 0.5]; + // 플레이어들 입장 + await Promise.all([ + new Promise((resolve) => { + client1.once(socketEvents.JOIN_ROOM, () => resolve()); + client1.emit(socketEvents.JOIN_ROOM, { + gameId: createResponse.gameId, + playerName: 'Player1' + }); + }), + new Promise((resolve) => { + client2.once(socketEvents.JOIN_ROOM, () => resolve()); + client2.emit(socketEvents.JOIN_ROOM, { + gameId: createResponse.gameId, + playerName: 'Player2' + }); + }) + ]); + const newPosition = [0.2, 0.5]; const updateResponse = await new Promise((resolve) => { client1.once(socketEvents.UPDATE_POSITION, resolve); client1.emit(socketEvents.UPDATE_POSITION, { - gameId, + gameId: createResponse.gameId, newPosition }); }); @@ -279,9 +322,58 @@ describe('GameGateway (e2e)', () => { expect(updateResponse.playerPosition).toEqual(newPosition); // Redis에서 위치 정보 확인 - const playerData = await redisMock.hgetall(`Room:${gameId}:Player:${client1.id}`); + const playerData = await redisMock.hgetall(`Player:${client1.id}`); expect(parseFloat(playerData.positionX)).toBe(newPosition[0]); expect(parseFloat(playerData.positionY)).toBe(newPosition[1]); }); }); -}); \ No newline at end of file + + describe('startGame 이벤트 테스트', () => { + it('게임 시작할 때 초기 설정 성공', async () => { + // 방 생성 및 참여 설정 + const createResponse = await new Promise<{ gameId: string }>((resolve) => { + client1.once(socketEvents.CREATE_ROOM, resolve); + client1.emit(socketEvents.CREATE_ROOM, { + title: 'Chat Test Room', + gameMode: 'RANKING', + maxPlayerCount: 5, + isPublicGame: true + }); + }); + + // 플레이어들 입장 + await Promise.all([ + new Promise((resolve) => { + client1.once(socketEvents.JOIN_ROOM, () => resolve()); + client1.emit(socketEvents.JOIN_ROOM, { + gameId: createResponse.gameId, + playerName: 'Player1' + }); + }), + new Promise((resolve) => { + client2.once(socketEvents.JOIN_ROOM, () => resolve()); + client2.emit(socketEvents.JOIN_ROOM, { + gameId: createResponse.gameId, + playerName: 'Player2' + }); + }) + ]); + const gameId = createResponse.gameId; + + client1.emit(socketEvents.START_GAME, { + gameId + }); + + await new Promise((resolve) => setTimeout(resolve, 1500)); // 1.5초 대기 + + const quizSetIds = await redisMock.smembers(REDIS_KEY.ROOM_QUIZ_SET(gameId)); + + // 내림차순 조회 (높은 점수부터) + const leaderboard = await redisMock.zrevrange(REDIS_KEY.ROOM_LEADERBOARD(gameId), 0, -1); + + expect(gameId).toBe(createResponse.gameId); + expect(quizSetIds.length).toBeGreaterThan(0); // FIX: 추후 더 fit하게 바꾸기 + expect(leaderboard).toBeDefined(); + }); + }); +}); diff --git a/BE/test/mocks/custom-redis.mock.ts b/BE/test/mocks/custom-redis.mock.ts new file mode 100644 index 0000000..7b37e71 --- /dev/null +++ b/BE/test/mocks/custom-redis.mock.ts @@ -0,0 +1,14 @@ +// import RedisMock from 'ioredis-mock'; +// +// export class CustomRedisMock extends RedisMock { +// constructor() { +// super(); +// } +// +// // @ts-ignore +// async hset(key: string, value: string): Promise { +// await super.hset(key, value); +// console.log(key); +// await super.publish(`__keyspace@0__:${key}`, 'hset'); +// } +// } diff --git a/BE/test/mocks/quiz-data.mock.ts b/BE/test/mocks/quiz-data.mock.ts new file mode 100644 index 0000000..f7d1cc7 --- /dev/null +++ b/BE/test/mocks/quiz-data.mock.ts @@ -0,0 +1,61 @@ +export const mockQuizData = { + id: '1', + title: '재미있는 상식 퀴즈', + category: 'common', + quizList: [ + { + id: '1', + quiz: '다음 중 대한민국의 수도는?', + limitTime: 30, + choiceList: [ + { + content: '서울', + order: 1, + isAnswer: true + }, + { + content: '부산', + order: 2, + isAnswer: false + }, + { + content: '인천', + order: 3, + isAnswer: false + }, + { + content: '대구', + order: 4, + isAnswer: false + } + ] + }, + { + id: '2', + quiz: '1 + 1 = ?', + limitTime: 20, + choiceList: [ + { + content: '1', + order: 1, + isAnswer: false + }, + { + content: '2', + order: 2, + isAnswer: true + }, + { + content: '3', + order: 3, + isAnswer: false + }, + { + content: '4', + order: 4, + isAnswer: false + } + ] + } + ] +};