diff --git a/packages/server/src/module/game/game.module.ts b/packages/server/src/module/game/game.module.ts index cdb5f537..5f6d072e 100644 --- a/packages/server/src/module/game/game.module.ts +++ b/packages/server/src/module/game/game.module.ts @@ -2,13 +2,14 @@ import { Module } from '@nestjs/common'; import { GameController } from './games/game.controller'; import { GameService } from './games/game.service'; import { GameGateway } from './games/game.gateway'; +import { GameGatewayService } from './games/game.gateway.service'; import { QuizModule } from '../quiz/quiz.module'; import { RedisModule } from '../../config/database/redis/redis.module'; @Module({ imports: [QuizModule, RedisModule], controllers: [GameController], - providers: [GameService, GameGateway], - exports: [GameService, GameGateway], + providers: [GameService, GameGatewayService, GameGateway], + exports: [GameService, GameGatewayService, GameGateway], }) export class GameModule {} diff --git a/packages/server/src/module/game/games/dto/response/emoji.response.dto.ts b/packages/server/src/module/game/games/dto/response/emoji.response.dto.ts new file mode 100644 index 00000000..7734497b --- /dev/null +++ b/packages/server/src/module/game/games/dto/response/emoji.response.dto.ts @@ -0,0 +1,11 @@ +import { Expose } from 'class-transformer'; +import { EmojiType } from '@shared/types/emoji.types'; + +export class EmojiResponseDto { + @Expose() + emojiStatus: EmojiType; + + constructor(emojiStatus: EmojiType) { + this.emojiStatus = emojiStatus; + } +} diff --git a/packages/server/src/module/game/games/dto/response/end-quiz.response.dto.ts b/packages/server/src/module/game/games/dto/response/end-quiz.response.dto.ts new file mode 100644 index 00000000..fc0800dc --- /dev/null +++ b/packages/server/src/module/game/games/dto/response/end-quiz.response.dto.ts @@ -0,0 +1,10 @@ +import { Expose } from 'class-transformer'; + +export class EndQuizResponseDto { + @Expose() + isEnded: Boolean; + + constructor(isEnd: Boolean) { + this.isEnded = isEnd; + } +} diff --git a/packages/server/src/module/game/games/dto/response/masterStatistics.response.dto.ts b/packages/server/src/module/game/games/dto/response/masterStatistics.response.dto.ts new file mode 100644 index 00000000..ee8b379b --- /dev/null +++ b/packages/server/src/module/game/games/dto/response/masterStatistics.response.dto.ts @@ -0,0 +1,42 @@ +import { Expose } from 'class-transformer'; + +export class MasterStatisticsResponseDto { + @Expose() + totalSubmit: number; + + @Expose() + solveRate: number; + + @Expose() + averageTime: number; + + @Expose() + participantRate: number; + + @Expose() + choiceStatus: Record; + + @Expose() + submitHistory: [string, number][]; + + @Expose() + participantLength: number; + + constructor( + total: number, + solveRate: number, + averageTime: number, + participantRate: number, + choiceStatus: Record, + submitHistory: [string, number][], + participantLength: number, + ) { + this.totalSubmit = total; + this.solveRate = solveRate; + this.averageTime = averageTime; + this.participantRate = participantRate; + this.choiceStatus = choiceStatus; + this.submitHistory = submitHistory; + this.participantLength = participantLength; + } +} diff --git a/packages/server/src/module/game/games/dto/response/message.response.dto.ts b/packages/server/src/module/game/games/dto/response/message.response.dto.ts new file mode 100644 index 00000000..8de9c29b --- /dev/null +++ b/packages/server/src/module/game/games/dto/response/message.response.dto.ts @@ -0,0 +1,14 @@ +import { Expose } from 'class-transformer'; + +export class MessageResponseDto { + @Expose() + message: string; + + @Expose() + position: number; + + constructor(message: string, position: number) { + this.message = message; + this.position = position; + } +} diff --git a/packages/server/src/module/game/games/dto/response/myPositionData.response.dto.ts b/packages/server/src/module/game/games/dto/response/myPositionData.response.dto.ts new file mode 100644 index 00000000..a19f3533 --- /dev/null +++ b/packages/server/src/module/game/games/dto/response/myPositionData.response.dto.ts @@ -0,0 +1,15 @@ +import { Expose } from 'class-transformer'; +import { ParticipantInfo } from '../../interfaces/participantInfo.interface'; + +export class MyPositionDataResponseDto { + @Expose() + participantList: ParticipantInfo[]; + + @Expose() + myPosition: number; + + constructor(participantList: ParticipantInfo[], myPosition: number) { + this.participantList = participantList; + this.myPosition = myPosition; + } +} diff --git a/packages/server/src/module/game/games/dto/response/nicknameEventData.response.dto.ts b/packages/server/src/module/game/games/dto/response/nicknameEventData.response.dto.ts new file mode 100644 index 00000000..c07d0f90 --- /dev/null +++ b/packages/server/src/module/game/games/dto/response/nicknameEventData.response.dto.ts @@ -0,0 +1,11 @@ +import { Expose } from 'class-transformer'; +import { ParticipantInfo } from '../../interfaces/participantInfo.interface'; + +export class NicknameEventDataResponseDto { + @Expose() + participantList: ParticipantInfo[]; + + constructor(participantList: ParticipantInfo[]) { + this.participantList = participantList; + } +} diff --git a/packages/server/src/module/game/games/dto/response/participant-entry.response.dto.ts b/packages/server/src/module/game/games/dto/response/participant-entry.response.dto.ts new file mode 100644 index 00000000..76b4734e --- /dev/null +++ b/packages/server/src/module/game/games/dto/response/participant-entry.response.dto.ts @@ -0,0 +1,19 @@ +import { Expose } from 'class-transformer'; +import { NicknameEventDataResponseDto } from './nicknameEventData.response.dto'; +import { MyPositionDataResponseDto } from './myPositionData.response.dto'; + +export class ParticipantEntryResponseDto { + @Expose() + nicknameEventData: NicknameEventDataResponseDto; + + @Expose() + myPositionData: MyPositionDataResponseDto; + + constructor( + nicknameEventData: NicknameEventDataResponseDto, + myPositionData: MyPositionDataResponseDto, + ) { + this.nicknameEventData = nicknameEventData; + this.myPositionData = myPositionData; + } +} diff --git a/packages/server/src/module/game/games/dto/response/participantStatistics.response.dto.ts b/packages/server/src/module/game/games/dto/response/participantStatistics.response.dto.ts new file mode 100644 index 00000000..cd6d0bf4 --- /dev/null +++ b/packages/server/src/module/game/games/dto/response/participantStatistics.response.dto.ts @@ -0,0 +1,22 @@ +import { Expose } from 'class-transformer'; + +export class ParticipantStatisticsResponseDto { + @Expose() + totalSubmit: number; + + @Expose() + solveRate: number; + + @Expose() + averageTime: number; + + @Expose() + participantRate: number; + + constructor(total: number, solveRate: number, averageTime: number, participantRate: number) { + this.totalSubmit = total; + this.solveRate = solveRate; + this.averageTime = averageTime; + this.participantRate = participantRate; + } +} diff --git a/packages/server/src/module/game/games/dto/response/show-ranking.response.dto.ts b/packages/server/src/module/game/games/dto/response/show-ranking.response.dto.ts new file mode 100644 index 00000000..6eab9e7e --- /dev/null +++ b/packages/server/src/module/game/games/dto/response/show-ranking.response.dto.ts @@ -0,0 +1,27 @@ +import { Expose } from 'class-transformer'; + +export class ShowRankingResponseDto { + @Expose() + rankerData: { nickname: any; score: string }[]; + + @Expose() + myRank: number; + + @Expose() + myScore: string; + + @Expose() + myNickname: string; + + constructor( + rankerData: { nickname: string; score: string }[], + myRank: number, + myScore: string, + myNickname: string, + ) { + this.rankerData = rankerData; + this.myRank = myRank; + this.myScore = myScore; + this.myNickname = myNickname; + } +} diff --git a/packages/server/src/module/game/games/dto/response/start-quiz.response.dto.ts b/packages/server/src/module/game/games/dto/response/start-quiz.response.dto.ts new file mode 100644 index 00000000..fb566bd9 --- /dev/null +++ b/packages/server/src/module/game/games/dto/response/start-quiz.response.dto.ts @@ -0,0 +1,10 @@ +import { Expose } from 'class-transformer'; + +export class StartQuizResponseDto { + @Expose() + isStarted: Boolean; + + constructor(isStarted: Boolean) { + this.isStarted = isStarted; + } +} diff --git a/packages/server/src/module/game/games/dto/response/submit-answer.response.dto.ts b/packages/server/src/module/game/games/dto/response/submit-answer.response.dto.ts new file mode 100644 index 00000000..ed5191b0 --- /dev/null +++ b/packages/server/src/module/game/games/dto/response/submit-answer.response.dto.ts @@ -0,0 +1,24 @@ +import { Expose } from 'class-transformer'; +import { ParticipantStatisticsResponseDto } from './participantStatistics.response.dto'; +import { MasterStatisticsResponseDto } from './masterStatistics.response.dto'; + +export class SubmitAnswerResponseDto { + @Expose() + participantStatistics: ParticipantStatisticsResponseDto; + + @Expose() + masterStatistics: MasterStatisticsResponseDto; + + @Expose() + submitOrder: number; + + constructor( + participantStatistics: ParticipantStatisticsResponseDto, + masterStatistics: MasterStatisticsResponseDto, + totalSubmit: number, + ) { + this.participantStatistics = participantStatistics; + this.masterStatistics = masterStatistics; + this.submitOrder = totalSubmit; + } +} diff --git a/packages/server/src/module/game/games/game.gateway.service.ts b/packages/server/src/module/game/games/game.gateway.service.ts new file mode 100644 index 00000000..17ba37b2 --- /dev/null +++ b/packages/server/src/module/game/games/game.gateway.service.ts @@ -0,0 +1,408 @@ +import { Injectable } from '@nestjs/common'; +import { + MASTER_POSITION, + QUIZ_WAITING_TIME, + INTERVAL_TIME, +} from '@shared/constants/game.constants'; +import { CONVERT_TO_MS } from '@shared/constants/utils.constants'; +import { CONNECTION_TYPES } from '@shared/types/connection.types'; +import { GAMESTATUS_TYPES } from '@shared/types/gameStatus.types'; +import { v4 as uuidv4 } from 'uuid'; +import { RedisService } from 'src/config/database/redis/redis.service'; +import { MasterEntryRequestDto } from './dto/request/master-entry.request.dto'; +import { Socket } from 'socket.io'; +import { Quiz } from 'src/module/quiz/quizzes/entities/quiz.entity'; +import { ClassRepository } from 'src/module/quiz/quizzes/repositories/class.repository'; +import { MessageRequestDto } from './dto/request/message.request.dto'; +import { MessageResponseDto } from './dto/response/message.response.dto'; +import { EndQuizRequestDto } from './dto/request/end-quiz.request.dto'; +import { EndQuizResponseDto } from './dto/response/end-quiz.response.dto'; +import { EmojiRequestDto } from './dto/request/emoji.request.dto'; +import { EmojiResponseDto } from './dto/response/emoji.response.dto'; +import { SubmitAnswerRequestDto } from './dto/request/submit-answer.request.dto'; +import { SubmitAnswerResponseDto } from './dto/response/submit-answer.response.dto'; +import { MasterStatisticsResponseDto } from './dto/response/masterStatistics.response.dto'; +import { ParticipantStatisticsResponseDto } from './dto/response/participantStatistics.response.dto'; +import { ShowQuizRequestDto } from './dto/request/show-quiz.request.dto'; +import { ShowRankingRequestDto } from './dto/request/show-ranking.request.dto'; +import { ShowRankingResponseDto } from './dto/response/show-ranking.response.dto'; +import { RankerData } from './interfaces/rankerData.interface'; +import { ClientInfo } from './interfaces/clientInfo.interface'; +import { RankerInfo } from './interfaces/rankerInfo.interface'; +import { StartQuizRequestDto } from './dto/request/start-quiz.request.dto'; +import { StartQuizResponseDto } from './dto/response/start-quiz.response.dto'; +import { ParticipantEntryRequestDto } from './dto/request/participant-entry.request.dto'; +import { NicknameEventDataResponseDto } from './dto/response/nicknameEventData.response.dto'; +import { MyPositionDataResponseDto } from './dto/response/myPositionData.response.dto'; +import { ParticipantEntryResponseDto } from './dto/response/participant-entry.response.dto'; + +@Injectable() +export class GameGatewayService { + constructor( + private readonly redisService: RedisService, + private readonly classRepository: ClassRepository, + ) {} + + async handleParticipantEntry( + client: Socket, + dto: ParticipantEntryRequestDto, + ): Promise { + const { pinCode, nickname } = dto; + const socketId = client.id; + + const gameInfo = JSON.parse(await this.redisService.get(`gameId=${pinCode}`)); + + // TODO: 만약 participant.length가 32로 제한이면 더이상 못들어오도록 막아야함 -> gameState를 업데이트? + const character = Math.floor(Math.random() * 6); + const position = gameInfo.participantList.length; + const connection = CONNECTION_TYPES.ON; + + const clientInfo = { pinCode, nickname, socketId, character, position, connection }; + + client.join(pinCode); + + const participantSid = uuidv4(); + await this.redisService.set(`participant_sid=${participantSid}`, JSON.stringify(clientInfo)); + client.emit('session', participantSid); + + await this.redisService.zincrby(`gameId=${pinCode}:ranking`, 0, participantSid); + + const pariticipantInfo = { nickname, character, position, connection }; + gameInfo.participantList.push(pariticipantInfo); + await this.redisService.set(`gameId=${pinCode}`, JSON.stringify(gameInfo)); + + const nicknameEventData = new NicknameEventDataResponseDto(gameInfo.participantList); + const myPositionData = new MyPositionDataResponseDto(gameInfo.participantList, position); + + const response = new ParticipantEntryResponseDto(nicknameEventData, myPositionData); + return response; + } + + async handleMasterEntry(client: Socket, dto: MasterEntryRequestDto) { + const { classId } = dto; + + const masterSid = uuidv4(); + const pinCode = uuidv4().slice(0, 6); //TODO: 메소드 분리해서 중복 확인하고 없을 때까지 반복 + const socketId = client.id; + + const position = MASTER_POSITION; + const connection = CONNECTION_TYPES.ON; + + const masterinfo = { pinCode, socketId, position, connection }; + + client.join(pinCode); + + await this.redisService.set(`master_sid=${masterSid}`, JSON.stringify(masterinfo)); + + const quizData = await this.storeQuizToRedis(classId); + const quizMaxNum = quizData.length - 1; + const gameStatus = GAMESTATUS_TYPES.WAITING; + + const gameInfo = { classId, gameStatus, currentOrder: 0, quizMaxNum, participantList: [] }; + + await this.redisService.set(`gameId=${pinCode}`, JSON.stringify(gameInfo)); + + client.emit('session', masterSid); + client.emit('pincode', pinCode); + } + + async handleSubmitAnswer(dto: SubmitAnswerRequestDto): Promise { + const { pinCode, sid, selectedAnswer, submitTime } = dto; + + const gameInfo = JSON.parse(await this.redisService.get(`gameId=${pinCode}`)); + const pariticipantInfo = JSON.parse(await this.redisService.get(`participant_sid=${sid}`)); + + const { classId, currentOrder, participantList } = gameInfo; + const quizData = JSON.parse(await this.redisService.get(`classId=${classId}`)); + const currentQuizData = quizData[currentOrder]; + + const participantLength = participantList.length; + + const currentChoicesData = currentQuizData['choices']; + const { point, timeLimit } = currentQuizData; + + const gameStatus = JSON.parse( + await this.redisService.get(`gameId=${pinCode}:quizId=${currentOrder}`), + ); + + gameStatus.submitHistory.push([pariticipantInfo.nickname, submitTime]); + const submitHistory = gameStatus.submitHistory; + + gameStatus.totalSubmit += 1; + const totalSubmit = gameStatus.totalSubmit; + + const isFlag = this.matchingAnswer(selectedAnswer, currentChoicesData); + if (isFlag) { + gameStatus.totalCorrect += 1; + } + + const processedPoint = this.calculatePoints(isFlag, submitTime, timeLimit, point); + await this.redisService.zincrby(`gameId=${pinCode}:ranking`, processedPoint, sid); + + const totalCorrect = gameStatus.totalCorrect; + + gameStatus.totalTime += submitTime; + const totalTime = gameStatus.totalTime; + + for (const answer of selectedAnswer) { + gameStatus.choiceStatus[answer] += 1; + } + const choiceStatus = gameStatus.choiceStatus; + + const participantNum = participantList.length; + + await this.redisService.set( + `gameId=${pinCode}:quizId=${currentOrder}`, + JSON.stringify(gameStatus), + ); + + await this.redisService.set(`gameId=${pinCode}`, JSON.stringify(gameInfo)); + + const solveRate = (totalCorrect / totalSubmit) * 100; + const averageTime = (totalTime / totalSubmit) * 100; + const participantRate = (totalSubmit / participantNum) * 100; + + const participantStatistics = new ParticipantStatisticsResponseDto( + totalSubmit, + solveRate, + averageTime, + participantRate, + ); + const masterStatistics = new MasterStatisticsResponseDto( + totalSubmit, + solveRate, + averageTime, + participantRate, + choiceStatus, + submitHistory, + participantLength, + ); + + const response = new SubmitAnswerResponseDto( + participantStatistics, + masterStatistics, + totalSubmit, + ); + return response; + } + + private async storeQuizToRedis(classId: number) { + const cachedQuizData = await this.redisService.get(`classId=${classId}`); + + if (cachedQuizData) { + const quizData = JSON.parse(cachedQuizData); + return quizData; + } + + const quizData = await this.cachingQuizData(classId); + + await this.redisService.set(`classId=${classId}`, JSON.stringify(quizData), 'EX', 604800); + + return quizData; + } + + async cachingQuizData(classId: number) { + const classWithRelations = await this.findClassWithRelations(classId); + const transformedData = this.transformQuizData(classWithRelations); + + return transformedData; + } + + async findClassWithRelations(id: number) { + const classEntity = await this.classRepository.getOnlyQuiz(id); + + return classEntity?.quizzes || []; + } + + private transformQuizData(quizlists: Quiz[]) { + const result = []; + + quizlists.forEach((quiz) => { + const choiceList = []; + quiz.choices.forEach((choice) => { + const { id, quizId, content, isCorrect, position } = choice; + const oneChoice = { id, quizId, content, isCorrect, position }; // 인터페이스로 refactor + choiceList.push(oneChoice); + }); + + const { id, content, quizType, timeLimit, point, position } = quiz; + const oneQuiz = { id, content, quizType, timeLimit, point, position, choices: choiceList }; // 인터페이스로 refactor + result.push(oneQuiz); + }); + return result; + } + + async getLeaderBoadrd(dto) { + const { pinCode } = dto; + + const participantNumber = await this.redisService.zcard(`gameId=${pinCode}:ranking`); + const allRankers = await this.getRank(`gameId=${pinCode}:ranking`, participantNumber); + + const rankerData = await Promise.all( + allRankers.map(async ([sid, score]) => { + const { nickname, character } = JSON.parse( + await this.redisService.get(`participant_sid=${sid}`), + ); + return { nickname, score, character }; + }), + ); + + const allParticipantsScore = rankerData.reduce((acc, { score }) => acc + Number(score), 0); + const averageScore = allParticipantsScore / participantNumber; + + const leaderboardData = { rankerData, participantNumber, averageScore }; + return leaderboardData; + } + + async handleStartQuiz(dto: StartQuizRequestDto): Promise { + const { sid, pinCode } = dto; + + const { pinCode: storedPinCode } = JSON.parse(await this.redisService.get(`master_sid=${sid}`)); + + if (storedPinCode !== pinCode) { + console.log('Invalid pinCode'); + } + const gameInfo = JSON.parse(await this.redisService.get(`gameId=${pinCode}`)); + gameInfo.gameStatus = GAMESTATUS_TYPES.IN_PROGRESS; + await this.redisService.set(`gameId=${pinCode}`, JSON.stringify(gameInfo)); + + const response = new StartQuizResponseDto(true); + return response; + } + + async getQuizData(pinCode: string) { + const gameInfo = JSON.parse(await this.redisService.get(`gameId=${pinCode}`)); + const { classId, currentOrder, quizMaxNum } = gameInfo; + + // 캐싱된 퀴즈 데이터를 가져옵니다. + const quizData = JSON.parse(await this.redisService.get(`classId=${classId}`)); + const currentQuizData = quizData[currentOrder]; + const currentTimeLimit = currentQuizData['timeLimit']; + const isLast = currentOrder === quizMaxNum; + + return { gameInfo, currentQuizData, currentTimeLimit, isLast }; + } + + async initializeGameStatus(pinCode: string, currentOrder: number, choicesLength: number) { + const choiceStatus = Object.fromEntries( + Array.from({ length: choicesLength }, (_, i) => [i, 0]), + ); + + const gameStatus = { + totalSubmit: 0, + totalCorrect: 0, + totalTime: 0, + choiceStatus, + submitHistory: [], + emojiStatus: { easy: 0, hard: 0 }, + }; + + await this.redisService.set( + `gameId=${pinCode}:quizId=${currentOrder}`, + JSON.stringify(gameStatus), + ); + } + + async updateGameOrder(pinCode: string) { + const gameInfo = JSON.parse(await this.redisService.get(`gameId=${pinCode}`)); + gameInfo.currentOrder += 1; + await this.redisService.set(`gameId=${pinCode}`, JSON.stringify(gameInfo)); + } + + async handleShowRanking(dto: ShowRankingRequestDto): Promise { + const { pinCode, sid } = dto; + + const participantNumber = await this.redisService.zcard(`gameId=${pinCode}:ranking`); + const allRankers = await this.getRank(`gameId=${pinCode}:ranking`, participantNumber); + + const rankerData = await Promise.all( + allRankers.map(async ([sid, score]) => { + const { nickname } = JSON.parse(await this.redisService.get(`participant_sid=${sid}`)); + return { nickname, score }; + }), + ); + + const myRank = await this.redisService.zrevrank(`gameId=${pinCode}:ranking`, sid); + const myScore = await this.redisService.zscore(`gameId=${pinCode}:ranking`, sid); + const { nickname: myNickname } = JSON.parse( + await this.redisService.get(`participant_sid=${sid}`), + ); + const response = new ShowRankingResponseDto(rankerData, myRank, myScore, myNickname); + return response; + } + + async handleEndQuiz(dto: EndQuizRequestDto): Promise { + const { sid, pinCode } = dto; + + const { pinCode: storedPinCode } = JSON.parse(await this.redisService.get(`master_sid=${sid}`)); + + if (storedPinCode !== pinCode) { + console.log('Invalid pinCode'); + } + + const gameInfo = JSON.parse(await this.redisService.get(`gameId=${pinCode}`)); + gameInfo.gameStatus = GAMESTATUS_TYPES.END; + await this.redisService.set(`gameId=${pinCode}`, JSON.stringify(gameInfo)); + + const response = new EndQuizResponseDto(true); + return response; + } + + async handleEmoji(dto: EmojiRequestDto): Promise { + const { pinCode, currentOrder, emoji } = dto; + const gameStatus = JSON.parse( + await this.redisService.get(`gameId=${pinCode}:quizId=${currentOrder}`), + ); + gameStatus.emojiStatus[emoji] += 1; + await this.redisService.set( + `gameId=${pinCode}:quizId=${currentOrder}`, + JSON.stringify(gameStatus), + ); + + const response = new EmojiResponseDto(gameStatus.emojiStatus); + return response; + } + + async handleMessage(dto: MessageRequestDto): Promise { + const { message, position } = dto; + const reponse = new MessageResponseDto(message, position); + return reponse; + } + + async getRank(key: string, participantNum: number) { + const result = await this.redisService.zrevrange(key, 0, participantNum); + + const formattedRank = result + .map((item, index) => { + if (index % 2 === 0) { + return [item, result[index + 1]]; + } + }) + .filter((item) => item !== undefined); + + return formattedRank; + } + + private matchingAnswer(selectedAnswer: Number[], currentChoicesData) { + const correctAnswers = currentChoicesData + .map((choice, index) => (choice.isCorrect ? index : null)) + .filter((index) => index !== null); + + const equals = (a: Number[], b: Number[]) => + a.length === b.length && a.every((v, i) => v === b[i]); + + correctAnswers.sort(); + selectedAnswer.sort(); + + return equals(selectedAnswer, correctAnswers); + } + + private calculatePoints(isFlag: boolean, submitTime: number, timeLimit: number, point: number) { + const timeLimitToMs = (timeLimit + QUIZ_WAITING_TIME) * CONVERT_TO_MS; + if (isFlag) { + const ratio = (timeLimitToMs - submitTime) / timeLimitToMs; + return Math.floor(ratio * point); + } + return 0; + } +} diff --git a/packages/server/src/module/game/games/game.gateway.ts b/packages/server/src/module/game/games/game.gateway.ts index 495a4a49..d6e157ec 100644 --- a/packages/server/src/module/game/games/game.gateway.ts +++ b/packages/server/src/module/game/games/game.gateway.ts @@ -4,12 +4,14 @@ import { SubscribeMessage, OnGatewayConnection, OnGatewayDisconnect, + MessageBody, + ConnectedSocket, } from '@nestjs/websockets'; import { Server, Socket } from 'socket.io'; import { RedisService } from '../../../config/database/redis/redis.service'; import { v4 as uuidv4 } from 'uuid'; import { GameService } from './game.service'; -import { Injectable } from '@nestjs/common'; +import { Injectable, UsePipes, ValidationPipe } from '@nestjs/common'; import { MasterEntryRequestDto } from './dto/request/master-entry.request.dto'; import { ParticipantEntryRequestDto } from './dto/request/participant-entry.request.dto'; import { ShowQuizRequestDto } from './dto/request/show-quiz.request.dto'; @@ -28,8 +30,10 @@ import { import { CONVERT_TO_MS } from '@shared/constants/utils.constants'; import { CONNECTION_TYPES } from '@shared/types/connection.types'; import { GAMESTATUS_TYPES } from '@shared/types/gameStatus.types'; +import { GameGatewayService } from './game.gateway.service'; @Injectable() +@UsePipes(ValidationPipe) @WebSocketGateway({ cors: { origin: '*', @@ -43,6 +47,7 @@ export class GameGateway implements OnGatewayConnection, OnGatewayDisconnect { constructor( private readonly redisService: RedisService, private readonly gameService: GameService, + private readonly gameGatewayService: GameGatewayService, ) {} async handleConnection(client: Socket) { @@ -81,342 +86,118 @@ export class GameGateway implements OnGatewayConnection, OnGatewayDisconnect { } @SubscribeMessage('master entry') - async handleMasterEntry(client: Socket, payload: MasterEntryRequestDto) { - const { classId } = payload; - - const masterSid = uuidv4(); - const pinCode = uuidv4().slice(0, 6); //TODO: 메소드 분리해서 중복 확인하고 없을 때까지 반복 - const socketId = client.id; - - const position = MASTER_POSITION; - const connection = CONNECTION_TYPES.ON; - - const masterinfo = { pinCode, socketId, position, connection }; - - client.join(pinCode); - - await this.redisService.set(`master_sid=${masterSid}`, JSON.stringify(masterinfo)); - - const quizData = await this.storeQuizToRedis(classId); - const quizMaxNum = quizData.length - 1; - const gameStatus = GAMESTATUS_TYPES.WAITING; - - const gameInfo = { classId, gameStatus, currentOrder: 0, quizMaxNum, participantList: [] }; - - await this.redisService.set(`gameId=${pinCode}`, JSON.stringify(gameInfo)); - - client.emit('session', masterSid); - client.emit('pincode', pinCode); + async handleMasterEntry( + @ConnectedSocket() client: Socket, + @MessageBody() dto: MasterEntryRequestDto, + ) { + this.gameGatewayService.handleMasterEntry(client, dto); } @SubscribeMessage('participant entry') - async handleParticipantEntry(client: Socket, payload: ParticipantEntryRequestDto) { - const { pinCode, nickname } = payload; - const socketId = client.id; - - const gameInfo = JSON.parse(await this.redisService.get(`gameId=${pinCode}`)); - - // TODO: 만약 participant.length가 32로 제한이면 더이상 못들어오도록 막아야함 -> gameState를 업데이트? - const character = Math.floor(Math.random() * 6); - const position = gameInfo.participantList.length; - const connection = CONNECTION_TYPES.ON; - - const clientInfo = { pinCode, nickname, socketId, character, position, connection }; - - client.join(pinCode); - - const participantSid = uuidv4(); - await this.redisService.set(`participant_sid=${participantSid}`, JSON.stringify(clientInfo)); - client.emit('session', participantSid); - - await this.redisService.zincrby(`gameId=${pinCode}:ranking`, 0, participantSid); - - const pariticipantInfo = { nickname, character, position, connection }; - gameInfo.participantList.push(pariticipantInfo); - await this.redisService.set(`gameId=${pinCode}`, JSON.stringify(gameInfo)); - - const nicknameEventData = { participantList: gameInfo.participantList }; - const myPositionData = { participantList: gameInfo.participantList, myPosition: position }; - - client.emit('my position', myPositionData); - client.to(pinCode).emit('nickname', nicknameEventData); + async handleParticipantEntry( + @ConnectedSocket() client: Socket, + @MessageBody() dto: ParticipantEntryRequestDto, + ) { + const response = await this.gameGatewayService.handleParticipantEntry(client, dto); + client.emit('my position', response.myPositionData); + client.to(dto.pinCode).emit('nickname', response.nicknameEventData); } - @SubscribeMessage('show quiz') - async handleShowQuiz(client: Socket, payload: ShowQuizRequestDto) { - const { pinCode } = payload; - const gameInfo = JSON.parse(await this.redisService.get(`gameId=${pinCode}`)); - - const { classId, currentOrder, quizMaxNum } = gameInfo; - // TODO:캐싱된 퀴즈를 가져온다. 퀴즈를 생성할 경우, 만들어졌을거라 예상 - // 만일 레디스에 퀴즈가 저장되어있지않다면, 퀴즈를 다시 캐싱해오는 로직이 필요할지도. - - const quizData = JSON.parse(await this.redisService.get(`classId=${classId}`)); + async handleShowQuiz(@ConnectedSocket() client: Socket, @MessageBody() dto: ShowQuizRequestDto) { + const { pinCode } = dto; - const currentQuizData = quizData[currentOrder]; - const currentTimeLimit = currentQuizData['timeLimit']; + const { gameInfo, currentQuizData, currentTimeLimit, isLast } = + await this.gameGatewayService.getQuizData(pinCode); const choicesLength = currentQuizData['choices'].length; - const choiceStatus = Object.fromEntries( - Array.from({ length: choicesLength }, (_, i) => [i, 0]), - ); - - const gameStatus = { - totalSubmit: 0, - totalCorrect: 0, - totalTime: 0, - choiceStatus, - submitHistory: [], - emojiStatus: { easy: 0, hard: 0 }, - }; - await this.redisService.set( - `gameId=${pinCode}:quizId=${currentOrder}`, - JSON.stringify(gameStatus), + await this.gameGatewayService.initializeGameStatus( + pinCode, + gameInfo.currentOrder, + choicesLength, ); - const isLast = gameInfo.currentOrder === quizMaxNum ? true : false; - this.server.to(pinCode).emit('show quiz', { quizMaxNum, currentQuizData, isLast }); + this.server.to(pinCode).emit('show quiz', { + quizMaxNum: gameInfo.quizMaxNum, + currentQuizData, + isLast, + }); const startTime = Date.now(); - await this.intervalTimeSender(pinCode, startTime, currentTimeLimit); + this.intervalTimeSender(pinCode, startTime, currentTimeLimit); } - async intervalTimeSender(pinCode: string, startTime: number, timeLimit: number) { + private async intervalTimeSender(pinCode: string, startTime: number, timeLimit: number) { const intervalId = setInterval(async () => { const currentTime = Date.now(); const elapsedTime = currentTime - startTime; const remainingTime = (timeLimit + QUIZ_WAITING_TIME) * 1000 - elapsedTime; - if (remainingTime <= 0) { - const gameInfo = JSON.parse(await this.redisService.get(`gameId=${pinCode}`)); - gameInfo.currentOrder += 1; - await this.redisService.set(`gameId=${pinCode}`, JSON.stringify(gameInfo)); + if (remainingTime <= 0) { + await this.gameGatewayService.updateGameOrder(pinCode); this.server.to(pinCode).emit('time end', { isEnd: true }); clearInterval(intervalId); return; } + this.server.to(pinCode).emit('timer tick', { currentTime, elapsedTime, remainingTime }); }, INTERVAL_TIME); } @SubscribeMessage('start quiz') - async handleStartQuiz(client: Socket, payload: StartQuizRequestDto) { - const { sid, pinCode } = payload; - - const { pinCode: storedPinCode } = JSON.parse(await this.redisService.get(`master_sid=${sid}`)); - - if (storedPinCode !== pinCode) { - console.log('Invalid pinCode'); - } - - client.to(pinCode).emit('start quiz', { isStarted: true }); - - const gameInfo = JSON.parse(await this.redisService.get(`gameId=${pinCode}`)); - gameInfo.gameStatus = GAMESTATUS_TYPES.IN_PROGRESS; - await this.redisService.set(`gameId=${pinCode}`, JSON.stringify(gameInfo)); - } - - private async storeQuizToRedis(classId: number) { - const cachedQuizData = await this.redisService.get(`classId=${classId}`); - - if (cachedQuizData) { - const quizData = JSON.parse(cachedQuizData); - return quizData; - } - - const quizData = await this.gameService.cachingQuizData(classId); - - await this.redisService.set(`classId=${classId}`, JSON.stringify(quizData), 'EX', 604800); - - return quizData; + async handleStartQuiz( + @ConnectedSocket() client: Socket, + @MessageBody() dto: StartQuizRequestDto, + ) { + const response = await this.gameGatewayService.handleStartQuiz(dto); + client.to(dto.pinCode).emit('start quiz', response); } @SubscribeMessage('submit answer') - async handleSubmitAnswer(client: Socket, payload: SubmitAnswerRequestDto) { - const { pinCode, sid, selectedAnswer, submitTime } = payload; - - const gameInfo = JSON.parse(await this.redisService.get(`gameId=${pinCode}`)); - const pariticipantInfo = JSON.parse(await this.redisService.get(`participant_sid=${sid}`)); - - const { classId, currentOrder, participantList } = gameInfo; - const quizData = JSON.parse(await this.redisService.get(`classId=${classId}`)); - const currentQuizData = quizData[currentOrder]; - - const participantLength = participantList.length; - - const currentChoicesData = currentQuizData['choices']; - const { point, timeLimit } = currentQuizData; - - const gameStatus = JSON.parse( - await this.redisService.get(`gameId=${pinCode}:quizId=${currentOrder}`), - ); - - gameStatus.submitHistory.push([pariticipantInfo.nickname, submitTime]); - const submitHistory = gameStatus.submitHistory; - - gameStatus.totalSubmit += 1; - const totalSubmit = gameStatus.totalSubmit; - - const isFlag = this.matchingAnswer(selectedAnswer, currentChoicesData); - if (isFlag) { - gameStatus.totalCorrect += 1; - } - - const processedPoint = this.calculatePoints(isFlag, submitTime, timeLimit, point); - await this.redisService.zincrby(`gameId=${pinCode}:ranking`, processedPoint, sid); - - const totalCorrect = gameStatus.totalCorrect; - - gameStatus.totalTime += submitTime; - const totalTime = gameStatus.totalTime; - - for (const answer of selectedAnswer) { - gameStatus.choiceStatus[answer] += 1; - } - const choiceStatus = gameStatus.choiceStatus; - - const participantNum = participantList.length; - - await this.redisService.set( - `gameId=${pinCode}:quizId=${currentOrder}`, - JSON.stringify(gameStatus), - ); - - await this.redisService.set(`gameId=${pinCode}`, JSON.stringify(gameInfo)); - - const solveRate = (totalCorrect / totalSubmit) * 100; - const averageTime = (totalTime / totalSubmit) * 100; - const participantRate = (totalSubmit / participantNum) * 100; - - const participantStatistics = { totalSubmit, solveRate, averageTime, participantRate }; - const masterStatistics = { - totalSubmit, - solveRate, - averageTime, - participantRate, - choiceStatus, - submitHistory, - participantLength, - }; - - this.server.to(pinCode).emit('participant statistics', participantStatistics); - this.server.to(pinCode).emit('master statistics', masterStatistics); - return { submitOrder: totalSubmit }; + async handleSubmitAnswer( + @ConnectedSocket() client: Socket, + @MessageBody() dto: SubmitAnswerRequestDto, + ) { + const response = await this.gameGatewayService.handleSubmitAnswer(dto); + this.server.to(dto.pinCode).emit('participant statistics', response.participantStatistics); + this.server.to(dto.pinCode).emit('master statistics', response.masterStatistics); + return response.submitOrder; } @SubscribeMessage('emoji') - async handleEmoji(client: Socket, payload: EmojiRequestDto) { - const { pinCode, currentOrder, emoji } = payload; - const gameStatus = JSON.parse( - await this.redisService.get(`gameId=${pinCode}:quizId=${currentOrder}`), - ); - gameStatus.emojiStatus[emoji] += 1; - await this.redisService.set( - `gameId=${pinCode}:quizId=${currentOrder}`, - JSON.stringify(gameStatus), - ); - this.server.to(pinCode).emit('emoji', gameStatus.emojiStatus); - } - - calculatePoints(isFlag: boolean, submitTime: number, timeLimit: number, point: number) { - const timeLimitToMs = (timeLimit + QUIZ_WAITING_TIME) * CONVERT_TO_MS; - if (isFlag) { - const ratio = (timeLimitToMs - submitTime) / timeLimitToMs; - return Math.floor(ratio * point); - } - return 0; + async handleEmoji(@ConnectedSocket() client: Socket, @MessageBody() dto: EmojiRequestDto) { + const response = await this.gameGatewayService.handleEmoji(dto); + this.server.to(dto.pinCode).emit('emoji', response); } @SubscribeMessage('show ranking') - async handleShowRanking(client: Socket, payload: ShowRankingRequestDto) { - const { pinCode, sid } = payload; - - const participantNumber = await this.redisService.zcard(`gameId=${pinCode}:ranking`); - const allRankers = await this.gameService.getRank( - `gameId=${pinCode}:ranking`, - participantNumber, - ); - - const rankerData = await Promise.all( - allRankers.map(async ([sid, score]) => { - const { nickname } = JSON.parse(await this.redisService.get(`participant_sid=${sid}`)); - return { nickname, score }; - }), - ); - - const myRank = await this.redisService.zrevrank(`gameId=${pinCode}:ranking`, sid); - const myScore = await this.redisService.zscore(`gameId=${pinCode}:ranking`, sid); - const { nickname: myNickname } = JSON.parse( - await this.redisService.get(`participant_sid=${sid}`), - ); - const showRankingData = { rankerData, myRank, myScore, myNickname }; - - return showRankingData; + async handleShowRanking( + @ConnectedSocket() client: Socket, + @MessageBody() dto: ShowRankingRequestDto, + ) { + const response = await this.gameGatewayService.handleShowRanking(dto); + return response; } @SubscribeMessage('end quiz') - async handleEndQuiz(client: Socket, payload: EndQuizRequestDto) { - const { sid, pinCode } = payload; - - const { pinCode: storedPinCode } = JSON.parse(await this.redisService.get(`master_sid=${sid}`)); - - if (storedPinCode !== pinCode) { - console.log('Invalid pinCode'); - } - - client.to(pinCode).emit('end quiz', { isEnded: true }); - - const gameInfo = JSON.parse(await this.redisService.get(`gameId=${pinCode}`)); - gameInfo.gameStatus = GAMESTATUS_TYPES.END; - await this.redisService.set(`gameId=${pinCode}`, JSON.stringify(gameInfo)); - } - - matchingAnswer(selectedAnswer: Number[], currentChoicesData) { - const correctAnswers = currentChoicesData - .map((choice, index) => (choice.isCorrect ? index : null)) - .filter((index) => index !== null); - - const equals = (a: Number[], b: Number[]) => - a.length === b.length && a.every((v, i) => v === b[i]); - - correctAnswers.sort(); - selectedAnswer.sort(); - - return equals(selectedAnswer, correctAnswers); + async handleEndQuiz(@ConnectedSocket() client: Socket, @MessageBody() dto: EndQuizRequestDto) { + const response = await this.gameGatewayService.handleEndQuiz(dto); + client.to(dto.pinCode).emit('end quiz', response); } @SubscribeMessage('leaderboard') - async handleLeaderboard(client: Socket, payload: LeaderboardRequestDto) { - const { pinCode } = payload; - - const participantNumber = await this.redisService.zcard(`gameId=${pinCode}:ranking`); - const allRankers = await this.gameService.getRank( - `gameId=${pinCode}:ranking`, - participantNumber, - ); - - const rankerData = await Promise.all( - allRankers.map(async ([sid, score]) => { - const { nickname, character } = JSON.parse( - await this.redisService.get(`participant_sid=${sid}`), - ); - return { nickname, score, character }; - }), - ); - - const allParticipantsScore = rankerData.reduce((acc, { score }) => acc + Number(score), 0); - const averageScore = allParticipantsScore / participantNumber; - - const leaderboardData = { rankerData, participantNumber, averageScore }; - + async handleLeaderboard( + @ConnectedSocket() client: Socket, + @MessageBody() dto: LeaderboardRequestDto, + ) { + const leaderboardData = await this.gameGatewayService.getLeaderBoadrd(dto); // TODO: 이벤트 어떤 형식으로 전달할 지 정해야 함 return leaderboardData; } @SubscribeMessage('message') - async handleMessage(client: Socket, payload: MessageRequestDto) { - const { pinCode, message, position } = payload; - this.server.to(pinCode).emit('message', { message, position }); + async handleMessage(@ConnectedSocket() client: Socket, @MessageBody() dto: MessageRequestDto) { + const response = await this.gameGatewayService.handleMessage(dto); + this.server.to(dto.pinCode).emit('message', response); } } diff --git a/packages/server/src/module/game/games/game.service.ts b/packages/server/src/module/game/games/game.service.ts index a211e249..184a75b9 100644 --- a/packages/server/src/module/game/games/game.service.ts +++ b/packages/server/src/module/game/games/game.service.ts @@ -15,37 +15,6 @@ export class GameService { private readonly redisService: RedisService, ) {} - async cachingQuizData(classId: number) { - const classWithRelations = await this.findClassWithRelations(classId); - const transformedData = this.transformQuizData(classWithRelations); - - return transformedData; - } - - async findClassWithRelations(id: number) { - const classEntity = await this.classRepository.getOnlyQuiz(id); - - return classEntity?.quizzes || []; - } - - private transformQuizData(quizlists: Quiz[]) { - const result = []; - - quizlists.forEach((quiz) => { - const choiceList = []; - quiz.choices.forEach((choice) => { - const { id, quizId, content, isCorrect, position } = choice; - const oneChoice = { id, quizId, content, isCorrect, position }; // 인터페이스로 refactor - choiceList.push(oneChoice); - }); - - const { id, content, quizType, timeLimit, point, position } = quiz; - const oneQuiz = { id, content, quizType, timeLimit, point, position, choices: choiceList }; // 인터페이스로 refactor - result.push(oneQuiz); - }); - return result; - } - async checkPinCode(pinCode: string) { try { const result = await this.redisService.get(`gameId=${pinCode}`); @@ -72,18 +41,4 @@ export class GameService { console.error('error: ', error); } } - - async getRank(key: string, participantNum: number) { - const result = await this.redisService.zrevrange(key, 0, participantNum); - - const formattedRank = result - .map((item, index) => { - if (index % 2 === 0) { - return [item, result[index + 1]]; - } - }) - .filter((item) => item !== undefined); - - return formattedRank; - } } diff --git a/packages/server/src/module/game/games/interfaces/clientInfo.interface.ts b/packages/server/src/module/game/games/interfaces/clientInfo.interface.ts new file mode 100644 index 00000000..b6fdd25f --- /dev/null +++ b/packages/server/src/module/game/games/interfaces/clientInfo.interface.ts @@ -0,0 +1,10 @@ +import { ConnectionType } from '@shared/types/connection.types'; + +export interface ClientInfo { + pinCode: string; + nickname: string; + socketId: string; + character: number; + position: number; + connection: ConnectionType; +} diff --git a/packages/server/src/module/game/games/interfaces/participantInfo.interface.ts b/packages/server/src/module/game/games/interfaces/participantInfo.interface.ts new file mode 100644 index 00000000..4e944eb5 --- /dev/null +++ b/packages/server/src/module/game/games/interfaces/participantInfo.interface.ts @@ -0,0 +1,6 @@ +export interface ParticipantInfo { + nickname: string; + character: number; + position: number; + connection: number; +} diff --git a/packages/server/src/module/game/games/interfaces/rankerData.interface.ts b/packages/server/src/module/game/games/interfaces/rankerData.interface.ts new file mode 100644 index 00000000..a995fc2e --- /dev/null +++ b/packages/server/src/module/game/games/interfaces/rankerData.interface.ts @@ -0,0 +1,4 @@ +export interface RankerData { + nickname: string; + score: number; +} diff --git a/packages/server/src/module/game/games/interfaces/rankerInfo.interface.ts b/packages/server/src/module/game/games/interfaces/rankerInfo.interface.ts new file mode 100644 index 00000000..ab4bb4b5 --- /dev/null +++ b/packages/server/src/module/game/games/interfaces/rankerInfo.interface.ts @@ -0,0 +1,5 @@ +export interface RankerInfo { + pinCode: string; + nickname: string; + socketId: string; +}