From cf28e63cffbf6d46efd8518864bca35360bad313 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=EC=A4=80=EC=88=98?= <99115509+hoheesu@users.noreply.github.com> Date: Sat, 11 Jan 2025 02:00:11 +0900 Subject: [PATCH 1/2] =?UTF-8?q?Feat:=20=EA=B2=8C=EC=9E=84=EB=B0=A9=20?= =?UTF-8?q?=EC=9E=85=EC=9E=A5=20=ED=9B=84=20=EB=A0=88=EB=94=94=20/=20?= =?UTF-8?q?=EC=B9=B4=EB=93=9C=EB=8D=B1=20=EA=B3=A0=EB=A5=B4=EA=B8=B0=20?= =?UTF-8?q?=EA=B9=8C=EC=A7=80=20=EC=84=B1=EA=B3=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app.module.ts | 4 +- src/chat/chat.gateway.ts | 138 ----------- src/chat/chat.module.ts | 9 - src/chat/chat.service.ts | 84 ------- src/game/game.gateway.ts | 387 +++++++++++++++++++++++++++++++ src/game/game.module.ts | 12 + src/game/game.service.ts | 89 +++++++ src/gameRoom/gameRoom.service.ts | 10 + 8 files changed, 500 insertions(+), 233 deletions(-) delete mode 100644 src/chat/chat.gateway.ts delete mode 100644 src/chat/chat.module.ts delete mode 100644 src/chat/chat.service.ts create mode 100644 src/game/game.gateway.ts create mode 100644 src/game/game.module.ts create mode 100644 src/game/game.service.ts diff --git a/src/app.module.ts b/src/app.module.ts index 37b5e64..3decbf2 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -7,7 +7,7 @@ import { UserModule } from 'src/user/user.module'; import { AuthModule } from './auth/auth.module'; import { RedisModule } from './redis/redis.module'; import { GameRoomModule } from './gameRoom/gameRoom.module'; -import { ChatModule } from './chat/chat.module'; +import { GameModule } from './game/game.module'; @Module({ imports: [ @@ -15,7 +15,7 @@ import { ChatModule } from './chat/chat.module'; AuthModule, RedisModule, GameRoomModule, - ChatModule, + GameModule, TypeOrmModule.forRoot({ type: 'mysql', host: process.env.MYSQL_HOST || 'mysql', diff --git a/src/chat/chat.gateway.ts b/src/chat/chat.gateway.ts deleted file mode 100644 index ac388db..0000000 --- a/src/chat/chat.gateway.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { - SubscribeMessage, - WebSocketGateway, - OnGatewayInit, - WebSocketServer, - OnGatewayConnection, - OnGatewayDisconnect, -} from '@nestjs/websockets'; -import { Server, Socket } from 'socket.io'; -import { GameRoomService } from '../gameRoom/gameRoom.service'; -import * as jwt from 'jsonwebtoken'; - -@WebSocketGateway({ namespace: '/chat', cors: { origin: '*' } }) -export class ChatGateway - implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect -{ - @WebSocketServer() - server: Server; - - constructor(private readonly gameRoomService: GameRoomService) {} - - afterInit(server: Server) { - console.log('WebSocket initialized', server); - } - - handleConnection(client: Socket) { - try { - const token = client.handshake.auth.token.replace('Bearer ', ''); - const decoded: any = jwt.verify(token, process.env.JWT_SECRET); - const userId = Number(decoded?.userId); - - if (!Number.isFinite(userId)) { - throw new Error('Invalid userId in token'); - } - - client.data.userId = userId; // 유효한 userId만 저장 - console.log('User connected:', userId); - if (!client.handshake.auth.token) { - console.error('No token provided. Disconnecting client.'); - client.disconnect(); - return; - } - - console.log('Client connected successfully:', userId); - } catch (error) { - console.error('Invalid token or userId:', error.message); - client.disconnect(); - } - } - - async handleDisconnect(client: Socket) { - console.log(`Client disconnected: ${client.id}`); - - // 1) userId를 client.data.userId 로 가져옴 - const userId = client.data.userId; - - // 2) DB에서 userId를 이용해 어느 방에 있었는지 찾기 - const roomId = await this.gameRoomService.getRoomIdByClient( - userId.toString(), - ); - - if (roomId) { - await this.gameRoomService.leaveRoom(roomId, userId); - this.server.to(roomId.toString()).emit('message', { - sender: 'System', - // 소켓 식별자는 client.id, 그러나 실제 "유저명"을 보여주려면 userId를 써도 됨 - message: `User ${userId} has disconnected.`, - }); - } - } - - @SubscribeMessage('joinRoom') - async handleJoinRoom(client: Socket, payload: { roomId: number }) { - const { roomId } = payload; - const userId = client.data.userId; // handleConnection에서 이미 검증된 값 - - // (1) 이미 DB상으로 방에 있는지 확인 - const alreadyInRoom = await this.gameRoomService.isUserInRoom( - userId, - roomId, - ); - - // (2) DB에 참여 기록이 없을 때만 실제 joinRoom 호출 - if (!alreadyInRoom) { - await this.gameRoomService.joinRoom(roomId, userId); - } else { - console.log(`User ${userId} already in room ${roomId}, skipping DB join`); - } - - // (3) 소켓 레벨에서 방 join (항상 수행) - client.join(roomId.toString()); - - // (4) 메시지 브로드캐스트 - this.server.to(roomId.toString()).emit('message', { - sender: 'System', - message: `User ${userId} joined or re-joined the room.`, - }); - } - - @SubscribeMessage('message') - async handleMessage( - client: Socket, - payload: { roomId: number; message: string }, - ) { - const { roomId, message } = payload; - const isInRoom = await this.gameRoomService.isUserInRoom( - client.data.userId, - roomId, - ); - if (isInRoom) { - this.server - .to(roomId.toString()) - .emit('message', { sender: client.data.userId, message }); - } else { - client.emit('error', { message: 'You are not in this room.' }); - } - } - - @SubscribeMessage('leaveRoom') - async handleLeaveRoom(client: Socket, payload: { roomId: number }) { - const { roomId } = payload; - - // const token = client.handshake.auth.token.replace('Bearer ', ''); - // const decoded: any = jwt.verify(token, process.env.JWT_SECRET); - // const userId = Number(decoded?.userId); - const userId = client.data.userId; - - console.log(userId, ' want to leave', roomId, 'room'); - - if (roomId) { - await this.gameRoomService.leaveRoom(roomId, userId); - this.server.to(roomId.toString()).emit('message', { - sender: 'System', - message: `User ${userId} has disconnected.`, - }); - } - } -} diff --git a/src/chat/chat.module.ts b/src/chat/chat.module.ts deleted file mode 100644 index 8e7bced..0000000 --- a/src/chat/chat.module.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Module } from '@nestjs/common'; -import { ChatGateway } from './chat.gateway'; -import { GameRoomModule } from '../gameRoom/gameRoom.module'; - -@Module({ - imports: [GameRoomModule], - providers: [ChatGateway], -}) -export class ChatModule {} diff --git a/src/chat/chat.service.ts b/src/chat/chat.service.ts deleted file mode 100644 index 23e59c5..0000000 --- a/src/chat/chat.service.ts +++ /dev/null @@ -1,84 +0,0 @@ -// import { -// Injectable, -// NotFoundException, -// BadRequestException, -// } from '@nestjs/common'; -// import { GameRoom } from './entities/gameRoom.entity'; -// import { GameRoomUser } from './entities/gameRoomUser.entity'; -// import { InjectRepository } from '@nestjs/typeorm'; -// import { Repository } from 'typeorm'; - -// @Injectable() -// export class GameRoomService { -// constructor( -// @InjectRepository(GameRoom) -// private readonly gameRoomRepository: Repository, -// @InjectRepository(GameRoomUser) -// private readonly gameRoomUserRepository: Repository, -// ) {} - -// async joinRoom(roomId: number, userId: number) { -// const room = await this.gameRoomRepository.findOne({ -// where: { id: roomId }, -// }); -// if (!room) { -// throw new NotFoundException('Room not found'); -// } - -// const existingMembership = await this.gameRoomUserRepository.findOne({ -// where: { userId }, -// }); -// if (existingMembership) { -// throw new BadRequestException('User already in a room'); -// } - -// if (room.currentCount >= room.maxPlayers) { -// throw new BadRequestException('Room is full'); -// } - -// const newUser = this.gameRoomUserRepository.create({ roomId, userId }); -// room.currentCount += 1; -// await this.gameRoomRepository.save(room); -// await this.gameRoomUserRepository.save(newUser); -// } - -// async leaveRoom(roomId: number, userId: number) { -// const room = await this.gameRoomRepository.findOne({ -// where: { id: roomId }, -// }); -// if (!room) { -// throw new NotFoundException('Room not found'); -// } - -// const user = await this.gameRoomUserRepository.findOne({ -// where: { roomId, userId }, -// }); -// if (!user) { -// throw new BadRequestException('User not in the room'); -// } - -// await this.gameRoomUserRepository.remove(user); -// room.currentCount -= 1; -// if (room.currentCount === 0) { -// await this.gameRoomRepository.remove(room); -// } else { -// await this.gameRoomRepository.save(room); -// } -// } - -// async isUserInRoom(clientId: string, roomId: number): Promise { -// const user = await this.gameRoomUserRepository.findOne({ -// where: { roomId, userId: +clientId }, -// }); -// return !!user; -// } - -// getRoomByClient(clientId: string): string | null { -// // Placeholder for a real implementation -// return null; -// } - -// leaveRoomByClient(clientId: string): void { -// // Placeholder for a real implementation -// } -// } diff --git a/src/game/game.gateway.ts b/src/game/game.gateway.ts new file mode 100644 index 0000000..f2561a5 --- /dev/null +++ b/src/game/game.gateway.ts @@ -0,0 +1,387 @@ +// game.gateway.ts + +import { + SubscribeMessage, + WebSocketGateway, + OnGatewayInit, + WebSocketServer, + OnGatewayConnection, + OnGatewayDisconnect, +} from '@nestjs/websockets'; +import { Server, Socket } from 'socket.io'; +import { GameRoomService } from '../gameRoom/gameRoom.service'; +import * as jwt from 'jsonwebtoken'; +import { RedisService } from 'src/redis/redis.service'; +import { GameService } from './game.service'; + +@WebSocketGateway({ namespace: '/game', cors: { origin: '*' } }) +export class GameGateway + implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect +{ + @WebSocketServer() + server: Server; + + // userSockets: userId -> socketId + private userSockets: Map = new Map(); + + constructor( + private readonly gameRoomService: GameRoomService, + private readonly redisService: RedisService, + private readonly gameService: GameService, + ) {} + + afterInit(server: Server) { + console.log('WebSocket initialized', server); + } + + handleConnection(client: Socket) { + console.log('try to connect '); + try { + // 1) 토큰 검증 + const token = client.handshake.auth.token.replace('Bearer ', ''); + const decoded: any = jwt.verify(token, process.env.JWT_SECRET); + const userId = Number(decoded?.userId); + + if (!Number.isFinite(userId)) { + throw new Error('Invalid userId in token'); + } + + // 2) userId를 소켓에 저장 + userSockets 맵 갱신 + client.data.userId = userId; + this.userSockets.set(userId, client.id); + + console.log('User connected:', userId); + + if (!client.handshake.auth.token) { + console.error('No token provided. Disconnecting client.'); + client.disconnect(); + return; + } + + console.log('Client connected successfully:', userId); + } catch (error) { + console.error('Invalid token or userId:', error.message); + client.disconnect(); + } + } + + async handleDisconnect(client: Socket) { + console.log(`Client disconnected: ${client.id}`); + const userId = client.data.userId; + + // 소켓 연결 해제 시 userSockets에서 제거 + this.userSockets.delete(userId); + + // 사용자가 속해있던 방 확인 후 DB에서 제거 + const roomId = await this.gameRoomService.getRoomIdByClient( + userId.toString(), + ); + if (roomId) { + await this.gameRoomService.leaveRoom(roomId, userId); + this.server.to(roomId.toString()).emit('message', { + sender: 'System', + message: `User ${userId} has disconnected.`, + }); + } + } + + // ───────────────────────────────────────── + // 방 입장 / 퇴장 + // ───────────────────────────────────────── + + @SubscribeMessage('joinRoom') + async handleJoinRoom(client: Socket, payload: { roomId: number }) { + const { roomId } = payload; + const userId = client.data.userId; + + const alreadyInRoom = await this.gameRoomService.isUserInRoom( + userId, + roomId, + ); + if (!alreadyInRoom) { + await this.gameRoomService.joinRoom(roomId, userId); + } + + client.join(roomId.toString()); + this.server.to(roomId.toString()).emit('message', { + sender: 'System', + message: `User ${userId} joined or re-joined the room.`, + }); + } + + @SubscribeMessage('leaveRoom') + async handleLeaveRoom(client: Socket, payload: { roomId: number }) { + const { roomId } = payload; + const userId = client.data.userId; + + if (roomId) { + await this.gameRoomService.leaveRoom(roomId, userId); + this.server.to(roomId.toString()).emit('message', { + sender: 'System', + message: `User ${userId} has disconnected.`, + }); + } + } + + // ───────────────────────────────────────── + // 채팅 + // ───────────────────────────────────────── + @SubscribeMessage('message') + async handleMessage( + client: Socket, + payload: { roomId: number; message: string }, + ) { + const { roomId, message } = payload; + const userId = client.data.userId; + const isInRoom = await this.gameRoomService.isUserInRoom(userId, roomId); + + if (isInRoom) { + // 브로드캐스트 + this.server + .to(roomId.toString()) + .emit('message', { sender: userId, message }); + } else { + client.emit('error', { message: 'You are not in this room.' }); + } + } + + // ───────────────────────────────────────── + // 게임 로직 + // ───────────────────────────────────────── + + /** + * (1) 사용자가 "레디"를 누름 → 모두 레디 시 게임 시작 + */ + @SubscribeMessage('setReady') + async handleSetReady(client: Socket, payload: { roomId: number }) { + const { roomId } = payload; + const userId = client.data.userId; + + // 레디 상태 기록 + await this.redisService.set(`room:${roomId}:user:${userId}:ready`, 'true'); + + // 전체에게 브로드캐스트 (사용자가 레디했다는 알림) + this.server.to(roomId.toString()).emit('readyStatusChanged', { + userId, + ready: true, + }); + + // 모두 레디인지 확인 + const isAllReady = await this.gameService.checkAllPlayersReady(roomId); + if (isAllReady) { + // 게임 시작 + const players = await this.gameRoomService.getPlayersInRoom(roomId); + const firstPlayerId = players[Math.floor(Math.random() * players.length)]; + + // 초기 상태 + const initialGameState = { + status: 'ongoing', + turn: firstPlayerId, + players: {}, + }; + + // 플레이어 데이터 + players.forEach((pid) => { + initialGameState.players[pid] = { + blackCount: 0, + whiteCount: 0, + chosenInitialCards: false, + finalHand: [], + }; + }); + + // Redis에 저장 + await this.redisService.set( + `room:${roomId}:gameState`, + JSON.stringify(initialGameState), + ); + + // "게임 시작" 자체는 모두에게 알림 가능 (누가 선공인지 정도는 알려줄 수 있음) + this.server.to(roomId.toString()).emit('gameStarted', { + starterUserId: firstPlayerId, + }); + } + } + + /** + * (2) 흑/백 카드 개수 선택 + */ + @SubscribeMessage('chooseInitialCards') + async handleChooseInitialCards( + client: Socket, + payload: { roomId: number; blackCount: number; whiteCount: number }, + ) { + const { roomId, blackCount, whiteCount } = payload; + const userId = client.data.userId; + + if (blackCount + whiteCount !== 4) { + client.emit('error', { + message: 'You must choose a total of 4 cards (black + white).', + }); + return; + } + + const gameStateStr = await this.redisService.get( + `room:${roomId}:gameState`, + ); + if (!gameStateStr) { + client.emit('error', { message: 'Game not started or no state found.' }); + return; + } + + const gameState = JSON.parse(gameStateStr); + if (!gameState.players[userId]) { + client.emit('error', { message: 'User not found in this room.' }); + return; + } + + gameState.players[userId].blackCount = blackCount; + gameState.players[userId].whiteCount = whiteCount; + gameState.players[userId].chosenInitialCards = true; + + await this.redisService.set( + `room:${roomId}:gameState`, + JSON.stringify(gameState), + ); + + // 알려줄 필요가 있다면 (ex. "누가 몇 장 골랐다" 정도) + this.server.to(roomId.toString()).emit('initialCardsChosen', { + userId, + blackCount, + whiteCount, + }); + + // 모두 선택했는지 체크 + const allChosen = Object.values(gameState.players).every( + (p: any) => p.chosenInitialCards, + ); + if (allChosen) { + // (3) 카드 랜덤 부여 (조커 포함), 정렬 + await this.gameService.assignRandomCards(roomId, gameState); + + // 새 상태 읽어오기 + const updatedGameStateStr = await this.redisService.get( + `room:${roomId}:gameState`, + ); + const updatedGameState = JSON.parse(updatedGameStateStr); + + // **개인에게만 최종 패 전달** (상대 카드 정보는 숨김) + for (const pid of Object.keys(updatedGameState.players)) { + // userSockets에서 해당 pid의 socketId 찾기 + const socketId = this.userSockets.get(Number(pid)); + if (!socketId) continue; + + const finalHand = updatedGameState.players[pid].finalHand; + + // 조커가 없으면, 이미 오름차순(검정이 하얀색보다 우선)으로 정렬됨 + // 조커가 있으면, 임시로 (맨끝 등) 놓여 있을 수 있음 -> 재배치 가능 + this.server.to(socketId).emit('yourFinalHand', { + message: 'Your final hand is assigned.', + finalHand, + }); + } + + // "모두가 조합 선택 완료" 정도는 전체에 알릴 수 있음 + this.server.to(roomId.toString()).emit('bothInitialCardsChosen', { + message: + 'Both players have chosen their initial combos (hidden from each other).', + }); + } + } + + /** + * (3) 조커가 있는 유저만 "arrangeFinalHand" 가능 + * - 같은 숫자(0~11)에서는 검정(black)이 항상 더 작게 취급됨 + */ + @SubscribeMessage('arrangeFinalHand') + async handleArrangeFinalHand( + client: Socket, + payload: { roomId: number; newOrder: { color: string; num: number }[] }, + ) { + const { roomId, newOrder } = payload; + const userId = client.data.userId; + + const gameStateStr = await this.redisService.get( + `room:${roomId}:gameState`, + ); + if (!gameStateStr) { + client.emit('error', { message: 'Game state not found or not started.' }); + return; + } + + const gameState = JSON.parse(gameStateStr); + const player = gameState.players[userId]; + if (!player) { + client.emit('error', { message: 'User not found in this room.' }); + return; + } + + // 조커 포함 여부 확인 + const hasJoker = player.finalHand.some((c: any) => c.num === -1); + if (!hasJoker) { + // 조커 없으면 재배치 불가 + client.emit('error', { + message: 'You have no joker. Cannot rearrange hand.', + }); + return; + } + + // newOrder가 기존 카드와 동일한 카드들인지 유효성 검사 + const original = player.finalHand; + if ( + newOrder.length !== original.length || + !newOrder.every((card) => + original.some((o: any) => o.color === card.color && o.num === card.num), + ) + ) { + client.emit('error', { message: 'Invalid card order.' }); + return; + } + + // 재배치 후, 다시 오름차순 정렬은 하지 않음 (조커 위치를 사용자 맘대로) + // 단, "같은 숫자"인 경우 black이 white보다 항상 앞서야 한다는 조건 반영 + // → 조커(-1)는 유저가 직접 지정하므로 그대로 둔다고 가정 + const rearranged = [...newOrder]; + + // color 우선순위: black < white (단, num이 같을 때만) + // 조커는 그대로 user가 넣은 위치를 존중 + // 구현 아이디어: + // 1) 조커가 아닌 것들만 이 순서대로 한 번 나열한 뒤, + // 2) 동일 숫자가 있으면 black->white가 되도록 swap + // 하지만 사용자가 고른 순서를 100% 신뢰한다면, "유저가 규칙에 맞춰 놓는 것"을 전제로 해도 됨. + // 여기서는 "최소한 black이 white보다 앞에 오도록" 간단 검증만 하겠습니다. + for (let i = 0; i < rearranged.length - 1; i++) { + const curr = rearranged[i]; + const next = rearranged[i + 1]; + if ( + curr.num === next.num && + curr.num !== -1 && // 둘 다 조커가 아니고, + curr.color === 'white' && + next.color === 'black' + ) { + // 색상 순서가 어긋났음 + client.emit('error', { + message: `Invalid arrangement: black must come before white for the same number.`, + }); + return; + } + } + + // 최종 반영 + player.finalHand = rearranged; + + await this.redisService.set( + `room:${roomId}:gameState`, + JSON.stringify(gameState), + ); + + // 본인에게만 업데이트 알림 + const socketId = this.userSockets.get(userId); + if (socketId) { + this.server.to(socketId).emit('finalHandArranged', { + message: 'Your final hand arrangement is updated.', + newOrder: rearranged, + }); + } + } +} diff --git a/src/game/game.module.ts b/src/game/game.module.ts new file mode 100644 index 0000000..cf62362 --- /dev/null +++ b/src/game/game.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { GameGateway } from './game.gateway'; +import { GameRoomModule } from '../gameRoom/gameRoom.module'; +import { GameService } from './game.service'; +import { RedisModule } from 'src/redis/redis.module'; +// import { RedisService } from 'src/redis/redis.service'; + +@Module({ + imports: [GameRoomModule, RedisModule], + providers: [GameGateway, GameService], +}) +export class GameModule {} diff --git a/src/game/game.service.ts b/src/game/game.service.ts new file mode 100644 index 0000000..c7ef853 --- /dev/null +++ b/src/game/game.service.ts @@ -0,0 +1,89 @@ +// game.service.ts + +import { Injectable } from '@nestjs/common'; +import { RedisService } from 'src/redis/redis.service'; +import { GameRoomService } from 'src/gameRoom/gameRoom.service'; + +@Injectable() +export class GameService { + constructor( + private readonly redisService: RedisService, + private readonly gameRoomService: GameRoomService, + ) {} + + // 모든 사용자가 레디했는지 확인 + async checkAllPlayersReady(roomId: number): Promise { + const players = await this.gameRoomService.getPlayersInRoom(roomId); + for (const playerId of players) { + const readyKey = `room:${roomId}:user:${playerId}:ready`; + const isReady = await this.redisService.get(readyKey); + if (isReady !== 'true') { + return false; + } + } + return true; + } + + // (1) 흑/백 각각 0~11 + 조커(-1) → 13장씩 + // (2) blackCount, whiteCount만큼 무작위 발급 + // (3) 기본 정렬 (숫자 ascending, 같은 숫자면 black < white) + async assignRandomCards(roomId: number, gameState: any) { + // 흑 카드 풀 + const blackDeck = Array.from({ length: 12 }, (_, i) => ({ + color: 'black', + num: i, + })); + blackDeck.push({ color: 'black', num: -1 }); // 흑조커 + + // 백 카드 풀 + const whiteDeck = Array.from({ length: 12 }, (_, i) => ({ + color: 'white', + num: i, + })); + whiteDeck.push({ color: 'white', num: -1 }); // 백조커 + + // 셔플 + this.shuffle(blackDeck); + this.shuffle(whiteDeck); + + // color 우선순위 map + const colorPriority = { black: 0, white: 1 }; + + for (const pid of Object.keys(gameState.players)) { + const p = gameState.players[pid]; + const { blackCount, whiteCount } = p; + + const selectedBlack = blackDeck.splice(0, blackCount); + const selectedWhite = whiteDeck.splice(0, whiteCount); + + // 합쳐서 기본 정렬 + p.finalHand = [...selectedBlack, ...selectedWhite].sort((a, b) => { + // -1(조커) vs 일반카드 + // → 우선 여기서는 "조커는 뒤로" 등 임시 처리를 할 수도 있지만, + // 사용자 재배치가 필요 없으면 바로 자리를 잡아도 됨. + // 예시는 “숫자 < 조커” 로 놓아도 괜찮습니다. + if (a.num === -1 && b.num !== -1) return 1; // 조커 뒤 + if (b.num === -1 && a.num !== -1) return -1; // 조커 뒤 + if (a.num === b.num && a.num !== -1) { + // 숫자 동일 & 둘 다 조커가 아닐 때 → black < white + return colorPriority[a.color] - colorPriority[b.color]; + } + return a.num - b.num; + }); + } + + // Redis 갱신 + await this.redisService.set( + `room:${roomId}:gameState`, + JSON.stringify(gameState), + ); + } + + // Fisher-Yates + private shuffle(deck: any[]) { + for (let i = deck.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [deck[i], deck[j]] = [deck[j], deck[i]]; + } + } +} diff --git a/src/gameRoom/gameRoom.service.ts b/src/gameRoom/gameRoom.service.ts index 7269f79..271bf33 100644 --- a/src/gameRoom/gameRoom.service.ts +++ b/src/gameRoom/gameRoom.service.ts @@ -24,6 +24,16 @@ export class GameRoomService { return this.gameRoomRepository.find(); } + // ───────────────────────────────────────── + // 게임방 유저 조회 + // ───────────────────────────────────────── + async getPlayersInRoom(roomId: number): Promise { + const roomMemberships = await this.gameRoomUserRepository.find({ + where: { roomId }, + }); + return roomMemberships.map((membership) => membership.userId); + } + // ───────────────────────────────────────── // 방 생성 + 생성자 자동 참가 // ───────────────────────────────────────── From 7f4755d36fa5b3f8299c4a05521900dd6698de63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=EC=A4=80=EC=88=98?= <99115509+hoheesu@users.noreply.github.com> Date: Sun, 12 Jan 2025 17:14:25 +0900 Subject: [PATCH 2/2] =?UTF-8?q?Feat:=20=EC=A1=B0=EC=BB=A4=20=EC=9C=84?= =?UTF-8?q?=EC=B9=98=20=EC=A0=95=ED=95=98=EA=B8=B0=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/auth/auth.controller.ts | 72 +++- src/game/game.gateway.ts | 647 +++++++++++++++++++++++++----------- src/game/game.service.ts | 260 +++++++++++---- src/redis/redis.service.ts | 2 +- 4 files changed, 706 insertions(+), 275 deletions(-) diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index d72667f..2cfe882 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -76,6 +76,7 @@ export class AuthController { const accessToken = this.jwtService.sign(payload, { expiresIn: '2h' }); const refreshToken = this.jwtService.sign(payload, { expiresIn: '7d' }); + // Redis 저장 await this.redisService.set(`access:${user.userEmail}`, accessToken, 3600); await this.redisService.set( `refresh:${user.userEmail}`, @@ -83,15 +84,17 @@ export class AuthController { 7 * 24 * 60 * 60, ); + // Refresh Token 쿠키 설정 res.cookie('refreshToken', refreshToken, { - httpOnly: true, // JavaScript로 접근 불가 - secure: true, // HTTPS에서만 동작 (개발시엔 false로 설정) + httpOnly: true, + secure: true, sameSite: 'none', - maxAge: 7 * 24 * 60 * 60 * 1000, // 7일 + maxAge: 7 * 24 * 60 * 60 * 1000, }); + // 클라이언트로 토큰 반환 (새로 sign하지 말고 기존 accessToken 그대로) return { - accessToken: `Bearer ${this.jwtService.sign(payload)}`, + accessToken: `Bearer ${accessToken}`, }; } @@ -132,7 +135,7 @@ export class AuthController { }) @ApiResponse({ status: 200, - description: '새로운 Access Token을 발급합니다.', + description: '새로운 Access Token과 Refresh Token을 발급합니다.', schema: { example: { accessToken: 'Bearer ', @@ -153,8 +156,8 @@ export class AuthController { @Req() req: Request, @Res({ passthrough: true }) res: Response, ) { - const refreshToken = req.cookies['refreshToken']; // 쿠키에서 RefreshToken 읽기 - if (!refreshToken) { + const oldRefreshToken = req.cookies['refreshToken']; // 쿠키에서 RefreshToken 읽기 + if (!oldRefreshToken) { throw new HttpException( 'Refresh token not found', HttpStatus.UNAUTHORIZED, @@ -163,26 +166,69 @@ export class AuthController { try { // RefreshToken 검증 - const payload = this.jwtService.verify(refreshToken); + const payload = this.jwtService.verify(oldRefreshToken); // Redis에서 RefreshToken 확인 const storedRefreshToken = await this.redisService.get( `refresh:${payload.userEmail}`, ); - if (!storedRefreshToken || storedRefreshToken !== refreshToken) { + if ( + !storedRefreshToken || + storedRefreshToken.trim() !== oldRefreshToken.trim() + ) { throw new HttpException( 'Invalid refresh token', HttpStatus.UNAUTHORIZED, ); } - - // 새로운 AccessToken 생성 + console.log(payload); + // 새로운 AccessToken 생성 (만료 시간 1시간) const newAccessToken = this.jwtService.sign( - { userEmail: payload.userEmail, sub: payload.sub }, - { expiresIn: '15m' }, + { + userEmail: payload.userEmail, + userId: payload.userId, + sub: payload.sub, + }, + { expiresIn: '1h' }, // 1시간 + ); + + // 새로운 RefreshToken 생성 + const newRefreshToken = this.jwtService.sign( + { + userEmail: payload.userEmail, + userId: payload.userId, + sub: payload.sub, + }, + + { expiresIn: '7d' }, ); + // Redis에 새로운 토큰 저장 + // 기존 Redis 키 삭제 + await this.redisService.del(`access:${payload.userEmail}`); + await this.redisService.del(`refresh:${payload.userEmail}`); + + // 새로운 토큰 저장 + await this.redisService.set( + `access:${payload.userEmail}`, + newAccessToken, + 60 * 60, + ); // 1시간 + await this.redisService.set( + `refresh:${payload.userEmail}`, + newRefreshToken, + 7 * 24 * 60 * 60, + ); // 7일 + + // 새 RefreshToken을 쿠키에 저장 + res.cookie('refreshToken', newRefreshToken, { + httpOnly: true, + secure: true, // 개발 시 false + sameSite: 'none', + maxAge: 7 * 24 * 60 * 60 * 1000, // 7일 + }); + return { accessToken: `Bearer ${newAccessToken}`, }; diff --git a/src/game/game.gateway.ts b/src/game/game.gateway.ts index f2561a5..6a8beb6 100644 --- a/src/game/game.gateway.ts +++ b/src/game/game.gateway.ts @@ -9,10 +9,11 @@ import { OnGatewayDisconnect, } from '@nestjs/websockets'; import { Server, Socket } from 'socket.io'; -import { GameRoomService } from '../gameRoom/gameRoom.service'; import * as jwt from 'jsonwebtoken'; + import { RedisService } from 'src/redis/redis.service'; -import { GameService } from './game.service'; +import { GameRoomService } from '../gameRoom/gameRoom.service'; +import { GameService, GameState } from './game.service'; @WebSocketGateway({ namespace: '/game', cors: { origin: '*' } }) export class GameGateway @@ -31,36 +32,21 @@ export class GameGateway ) {} afterInit(server: Server) { - console.log('WebSocket initialized', server); + console.log('WebSocket initialized'); } handleConnection(client: Socket) { - console.log('try to connect '); try { - // 1) 토큰 검증 - const token = client.handshake.auth.token.replace('Bearer ', ''); + const token = client.handshake.auth.token?.replace('Bearer ', ''); + if (!token) throw new Error('No token provided'); const decoded: any = jwt.verify(token, process.env.JWT_SECRET); const userId = Number(decoded?.userId); - - if (!Number.isFinite(userId)) { - throw new Error('Invalid userId in token'); - } - - // 2) userId를 소켓에 저장 + userSockets 맵 갱신 + if (!Number.isFinite(userId)) throw new Error('Invalid userId'); client.data.userId = userId; this.userSockets.set(userId, client.id); - - console.log('User connected:', userId); - - if (!client.handshake.auth.token) { - console.error('No token provided. Disconnecting client.'); - client.disconnect(); - return; - } - - console.log('Client connected successfully:', userId); - } catch (error) { - console.error('Invalid token or userId:', error.message); + console.log(`User connected: ${userId}`); + } catch (err) { + console.error(err.message); client.disconnect(); } } @@ -68,11 +54,8 @@ export class GameGateway async handleDisconnect(client: Socket) { console.log(`Client disconnected: ${client.id}`); const userId = client.data.userId; - - // 소켓 연결 해제 시 userSockets에서 제거 this.userSockets.delete(userId); - // 사용자가 속해있던 방 확인 후 DB에서 제거 const roomId = await this.gameRoomService.getRoomIdByClient( userId.toString(), ); @@ -80,32 +63,27 @@ export class GameGateway await this.gameRoomService.leaveRoom(roomId, userId); this.server.to(roomId.toString()).emit('message', { sender: 'System', - message: `User ${userId} has disconnected.`, + message: `User ${userId} disconnected.`, }); } } // ───────────────────────────────────────── - // 방 입장 / 퇴장 + // 방 입/퇴장 // ───────────────────────────────────────── @SubscribeMessage('joinRoom') async handleJoinRoom(client: Socket, payload: { roomId: number }) { const { roomId } = payload; const userId = client.data.userId; - - const alreadyInRoom = await this.gameRoomService.isUserInRoom( - userId, - roomId, - ); - if (!alreadyInRoom) { + const inRoom = await this.gameRoomService.isUserInRoom(userId, roomId); + if (!inRoom) { await this.gameRoomService.joinRoom(roomId, userId); } - client.join(roomId.toString()); this.server.to(roomId.toString()).emit('message', { sender: 'System', - message: `User ${userId} joined or re-joined the room.`, + message: `User ${userId} joined the room.`, }); } @@ -118,7 +96,7 @@ export class GameGateway await this.gameRoomService.leaveRoom(roomId, userId); this.server.to(roomId.toString()).emit('message', { sender: 'System', - message: `User ${userId} has disconnected.`, + message: `User ${userId} left the room.`, }); } } @@ -133,79 +111,88 @@ export class GameGateway ) { const { roomId, message } = payload; const userId = client.data.userId; - const isInRoom = await this.gameRoomService.isUserInRoom(userId, roomId); - if (isInRoom) { - // 브로드캐스트 - this.server - .to(roomId.toString()) - .emit('message', { sender: userId, message }); - } else { + const inRoom = await this.gameRoomService.isUserInRoom(userId, roomId); + if (!inRoom) { client.emit('error', { message: 'You are not in this room.' }); + return; } + this.server + .to(roomId.toString()) + .emit('message', { sender: userId, message }); } // ───────────────────────────────────────── - // 게임 로직 + // (1) setReady // ───────────────────────────────────────── - - /** - * (1) 사용자가 "레디"를 누름 → 모두 레디 시 게임 시작 - */ @SubscribeMessage('setReady') async handleSetReady(client: Socket, payload: { roomId: number }) { const { roomId } = payload; const userId = client.data.userId; - // 레디 상태 기록 await this.redisService.set(`room:${roomId}:user:${userId}:ready`, 'true'); - - // 전체에게 브로드캐스트 (사용자가 레디했다는 알림) - this.server.to(roomId.toString()).emit('readyStatusChanged', { - userId, - ready: true, + this.server + .to(roomId.toString()) + .emit('readyStatusChanged', { userId, ready: true }); + + const allReady = await this.gameService.checkAllPlayersReady(roomId); + if (!allReady) return; + + const players = await this.gameRoomService.getPlayersInRoom(roomId); + if (players.length !== 2) return; + + const firstPlayerId = players[Math.floor(Math.random() * players.length)]; + + const gameState: GameState = { + status: 'ongoing', + turn: firstPlayerId, + alreadyRevealed: false, + players: {}, + blackDeck: [], + whiteDeck: [], + }; + + players.forEach((pid) => { + gameState.players[pid] = { + finalHand: [], + arrangementDone: false, + blackCount: 0, + whiteCount: 0, + }; }); - // 모두 레디인지 확인 - const isAllReady = await this.gameService.checkAllPlayersReady(roomId); - if (isAllReady) { - // 게임 시작 - const players = await this.gameRoomService.getPlayersInRoom(roomId); - const firstPlayerId = players[Math.floor(Math.random() * players.length)]; - - // 초기 상태 - const initialGameState = { - status: 'ongoing', - turn: firstPlayerId, - players: {}, - }; + const blackDeck = Array.from({ length: 12 }, (_, i) => ({ + color: 'black', + num: i, + })); + blackDeck.push({ color: 'black', num: -1 }); + const whiteDeck = Array.from({ length: 12 }, (_, i) => ({ + color: 'white', + num: i, + })); + whiteDeck.push({ color: 'white', num: -1 }); - // 플레이어 데이터 - players.forEach((pid) => { - initialGameState.players[pid] = { - blackCount: 0, - whiteCount: 0, - chosenInitialCards: false, - finalHand: [], - }; - }); + this.gameService.shuffle(blackDeck); + this.gameService.shuffle(whiteDeck); - // Redis에 저장 - await this.redisService.set( - `room:${roomId}:gameState`, - JSON.stringify(initialGameState), - ); + gameState.blackDeck = blackDeck; + gameState.whiteDeck = whiteDeck; - // "게임 시작" 자체는 모두에게 알림 가능 (누가 선공인지 정도는 알려줄 수 있음) - this.server.to(roomId.toString()).emit('gameStarted', { - starterUserId: firstPlayerId, - }); - } + await this.saveGameState(roomId, gameState); + + this.server.to(roomId.toString()).emit('gameStarted', { + starterUserId: firstPlayerId, + message: `Game started! First: ${firstPlayerId}`, + }); + this.server.to(roomId.toString()).emit('turnStarted', { + turnUserId: firstPlayerId, + message: `It's user ${firstPlayerId}'s turn.`, + }); } - /** - * (2) 흑/백 카드 개수 선택 - */ + // ───────────────────────────────────────── + // (2) chooseInitialCards + // ───────────────────────────────────────── @SubscribeMessage('chooseInitialCards') async handleChooseInitialCards( client: Socket, @@ -216,83 +203,97 @@ export class GameGateway if (blackCount + whiteCount !== 4) { client.emit('error', { - message: 'You must choose a total of 4 cards (black + white).', + message: 'Must pick exactly 4 cards (black+white=4).', }); return; } - const gameStateStr = await this.redisService.get( - `room:${roomId}:gameState`, - ); - if (!gameStateStr) { - client.emit('error', { message: 'Game not started or no state found.' }); + const st = await this.getGameState(roomId); + if (!st) { + client.emit('error', { message: 'No game state found.' }); return; } - - const gameState = JSON.parse(gameStateStr); - if (!gameState.players[userId]) { - client.emit('error', { message: 'User not found in this room.' }); + if (!st.players[userId]) { + client.emit('error', { message: 'Invalid user or room.' }); return; } - gameState.players[userId].blackCount = blackCount; - gameState.players[userId].whiteCount = whiteCount; - gameState.players[userId].chosenInitialCards = true; + st.players[userId].blackCount = blackCount; + st.players[userId].whiteCount = whiteCount; - await this.redisService.set( - `room:${roomId}:gameState`, - JSON.stringify(gameState), - ); + await this.saveGameState(roomId, st); - // 알려줄 필요가 있다면 (ex. "누가 몇 장 골랐다" 정도) this.server.to(roomId.toString()).emit('initialCardsChosen', { userId, blackCount, whiteCount, }); - // 모두 선택했는지 체크 - const allChosen = Object.values(gameState.players).every( - (p: any) => p.chosenInitialCards, + // 모두 골랐나? + const allChosen = Object.values(st.players).every( + (p) => p.blackCount + p.whiteCount === 4, ); - if (allChosen) { - // (3) 카드 랜덤 부여 (조커 포함), 정렬 - await this.gameService.assignRandomCards(roomId, gameState); + if (!allChosen) return; + + // 실제 4장씩 뽑기 + for (const pidStr of Object.keys(st.players)) { + const pid = Number(pidStr); + const pState = st.players[pid]; + const arr: { color: string; num: number }[] = []; + for (let i = 0; i < pState.blackCount; i++) { + const c = st.blackDeck.pop(); + if (!c) { + client.emit('error', { message: 'No more black cards left.' }); + return; + } + arr.push(c); + } + for (let i = 0; i < pState.whiteCount; i++) { + const c = st.whiteDeck.pop(); + if (!c) { + client.emit('error', { message: 'No more white cards left.' }); + return; + } + arr.push(c); + } - // 새 상태 읽어오기 - const updatedGameStateStr = await this.redisService.get( - `room:${roomId}:gameState`, - ); - const updatedGameState = JSON.parse(updatedGameStateStr); + // 여기서 조커가 있어도 절대 맨 뒤로 안 보낼 수도 있음 + // 예: 간단히 compareCard로 sort하면 조커가 뒤로 감. + // => "사용자"가 이후 arrnageFinalHand로 옮길 수 있음 + arr.sort((a, b) => this.gameService.compareCard(a, b)); - // **개인에게만 최종 패 전달** (상대 카드 정보는 숨김) - for (const pid of Object.keys(updatedGameState.players)) { - // userSockets에서 해당 pid의 socketId 찾기 - const socketId = this.userSockets.get(Number(pid)); - if (!socketId) continue; + pState.finalHand = arr; + const hasJoker = arr.some((x) => x.num === -1); + if (!hasJoker) { + pState.arrangementDone = true; + } + } - const finalHand = updatedGameState.players[pid].finalHand; + await this.saveGameState(roomId, st); - // 조커가 없으면, 이미 오름차순(검정이 하얀색보다 우선)으로 정렬됨 - // 조커가 있으면, 임시로 (맨끝 등) 놓여 있을 수 있음 -> 재배치 가능 - this.server.to(socketId).emit('yourFinalHand', { - message: 'Your final hand is assigned.', - finalHand, - }); - } + // 본인에게 전송 + for (const pidStr of Object.keys(st.players)) { + const pid = Number(pidStr); + const sockId = this.userSockets.get(pid); + if (!sockId) continue; - // "모두가 조합 선택 완료" 정도는 전체에 알릴 수 있음 - this.server.to(roomId.toString()).emit('bothInitialCardsChosen', { - message: - 'Both players have chosen their initial combos (hidden from each other).', + const arr = st.players[pid].finalHand; + this.server.to(sockId).emit('yourFinalHand', { + message: 'Your initial 4 cards assigned.', + finalHand: arr, }); } + + this.server.to(roomId.toString()).emit('bothInitialCardsChosen', { + message: 'Both players have 4 cards now.', + }); + + this.checkAndRevealColorArrays(roomId); } - /** - * (3) 조커가 있는 유저만 "arrangeFinalHand" 가능 - * - 같은 숫자(0~11)에서는 검정(black)이 항상 더 작게 취급됨 - */ + // ───────────────────────────────────────── + // (3) arrangeFinalHand + // ───────────────────────────────────────── @SubscribeMessage('arrangeFinalHand') async handleArrangeFinalHand( client: Socket, @@ -301,87 +302,333 @@ export class GameGateway const { roomId, newOrder } = payload; const userId = client.data.userId; - const gameStateStr = await this.redisService.get( - `room:${roomId}:gameState`, - ); - if (!gameStateStr) { - client.emit('error', { message: 'Game state not found or not started.' }); + const st = await this.getGameState(roomId); + if (!st) { + client.emit('error', { message: 'No game state found.' }); return; } + if (!st.players[userId]) { + client.emit('error', { message: 'Invalid user or room.' }); + return; + } + const pState = st.players[userId]; + const oldArr = [...pState.finalHand]; - const gameState = JSON.parse(gameStateStr); - const player = gameState.players[userId]; - if (!player) { - client.emit('error', { message: 'User not found in this room.' }); + if (newOrder.length !== oldArr.length) { + client.emit('error', { message: 'Invalid newOrder length.' }); return; } + for (const c of newOrder) { + if (!oldArr.some((x) => x.color === c.color && x.num === c.num)) { + client.emit('error', { message: 'newOrder has unknown card.' }); + return; + } + } - // 조커 포함 여부 확인 - const hasJoker = player.finalHand.some((c: any) => c.num === -1); - if (!hasJoker) { - // 조커 없으면 재배치 불가 - client.emit('error', { - message: 'You have no joker. Cannot rearrange hand.', + // 검정<흰 + for (let i = 0; i < newOrder.length - 1; i++) { + if ( + newOrder[i].num !== -1 && + newOrder[i + 1].num !== -1 && + newOrder[i].num === newOrder[i + 1].num && + newOrder[i].color === 'white' && + newOrder[i + 1].color === 'black' + ) { + client.emit('error', { message: '동일 숫자는 black < white.' }); + return; + } + } + + pState.finalHand = newOrder; + pState.arrangementDone = true; + + await this.saveGameState(roomId, st); + + const sockId = this.userSockets.get(userId); + if (sockId) { + this.server.to(sockId).emit('finalHandArranged', { + message: 'Your final hand arrangement updated.', + newOrder, }); + } + + this.checkAndRevealColorArrays(roomId); + } + + // ───────────────────────────────────────── + // (4) drawCard + // ───────────────────────────────────────── + @SubscribeMessage('drawCard') + async handleDrawCard( + client: Socket, + payload: { roomId: number; color: string }, + ) { + const { roomId, color } = payload; + const userId = client.data.userId; + + const st = await this.getGameState(roomId); + if (!st) { + client.emit('error', { message: 'No game state found.' }); + return; + } + if (st.turn !== userId) { + client.emit('error', { message: 'Not your turn.' }); return; } - // newOrder가 기존 카드와 동일한 카드들인지 유효성 검사 - const original = player.finalHand; - if ( - newOrder.length !== original.length || - !newOrder.every((card) => - original.some((o: any) => o.color === card.color && o.num === card.num), - ) - ) { - client.emit('error', { message: 'Invalid card order.' }); + let card = null; + if (color === 'black') { + card = st.blackDeck.pop(); + } else { + card = st.whiteDeck.pop(); + } + if (!card) { + client.emit('error', { message: `No more ${color} cards left.` }); + return; + } + + const pState = st.players[userId]; + const oldArr = [...pState.finalHand]; + + // 조커 뽑음? + if (card.num === -1) { + // 유저가 직접 위치를 선택 + pState.finalHand.push(card); + await this.saveGameState(roomId, st); + + const sockId = this.userSockets.get(userId); + if (sockId) { + const idx = pState.finalHand.length - 1; + this.server.to(sockId).emit('cardDrawn', { + newCard: card, + finalHand: pState.finalHand, + drawnIndex: idx, + message: 'You drew a Joker. Place it anywhere you want.', + }); + this.server.to(sockId).emit('arrangeNewlyDrawnRequested', { + message: 'Joker drawn. Please rearrange if needed.', + newlyDrawn: card, + currentHand: pState.finalHand, + }); + } return; } - // 재배치 후, 다시 오름차순 정렬은 하지 않음 (조커 위치를 사용자 맘대로) - // 단, "같은 숫자"인 경우 black이 white보다 항상 앞서야 한다는 조건 반영 - // → 조커(-1)는 유저가 직접 지정하므로 그대로 둔다고 가정 - const rearranged = [...newOrder]; - - // color 우선순위: black < white (단, num이 같을 때만) - // 조커는 그대로 user가 넣은 위치를 존중 - // 구현 아이디어: - // 1) 조커가 아닌 것들만 이 순서대로 한 번 나열한 뒤, - // 2) 동일 숫자가 있으면 black->white가 되도록 swap - // 하지만 사용자가 고른 순서를 100% 신뢰한다면, "유저가 규칙에 맞춰 놓는 것"을 전제로 해도 됨. - // 여기서는 "최소한 black이 white보다 앞에 오도록" 간단 검증만 하겠습니다. - for (let i = 0; i < rearranged.length - 1; i++) { - const curr = rearranged[i]; - const next = rearranged[i + 1]; + // 숫자 카드 => 조커 위치는 안 건드림 + // 그냥 finalHand 내에서 "오름차순 인덱스" 찾되, 조커 skip? + // 여기서는 간단히 "이미 정렬돼있다고 가정" -> 직접 삽입 위치 계산 + const newHand = [...pState.finalHand]; + // 한 줄 로직: find an index i such that newCard if(newHand[i].num===-1) { continue; } + if (newHand[i].num === -1) { + // 건너뛰고 insertIndex 계속 증가 + insertIndex = i + 1; + continue; + } + // compare + if (this.gameService.compareCard(card, newHand[i]) < 0) { + insertIndex = i; + break; + } else { + insertIndex = i + 1; + } + } + newHand.splice(insertIndex, 0, card); + + pState.finalHand = newHand; + await this.saveGameState(roomId, st); + + const sockId = this.userSockets.get(userId); + if (sockId) { + this.server.to(sockId).emit('cardDrawn', { + newCard: card, + finalHand: pState.finalHand, + drawnIndex: insertIndex, + message: `You drew ${card.color}${card.num} at index=${insertIndex}`, + }); + } + this.broadcastNewCardPosition(roomId, userId, card, insertIndex); + + // "조커 양옆" 범위인지? + // => gameService.isNearJokerRange(newHand, card) + const isNear = this.gameService.isNearJokerRange(newHand, card); + if (isNear) { + // "You drew a card near Joker range. You can rearrange if you want." + this.server.to(sockId).emit('arrangeNewlyDrawnRequested', { + message: + 'You drew a card near Joker range. You can rearrange if you want.', + newlyDrawn: card, + currentHand: pState.finalHand, + }); + } + } + + // ───────────────────────────────────────── + // (5) 새 카드 수동 배치 + // ───────────────────────────────────────── + @SubscribeMessage('arrangeNewlyDrawn') + async handleArrangeNewlyDrawn( + client: Socket, + payload: { roomId: number; newOrder: { color: string; num: number }[] }, + ) { + const { roomId, newOrder } = payload; + const userId = client.data.userId; + + const st = await this.getGameState(roomId); + if (!st) { + client.emit('error', { message: 'No game state found.' }); + return; + } + if (!st.players[userId]) { + client.emit('error', { message: 'Invalid user or room.' }); + return; + } + const pState = st.players[userId]; + const oldArr = [...pState.finalHand]; + + // 검증 + if (newOrder.length !== oldArr.length) { + client.emit('error', { message: 'newOrder length mismatch.' }); + return; + } + for (const c of newOrder) { + if (!oldArr.some((o) => o.color === c.color && o.num === c.num)) { + client.emit('error', { message: 'newOrder has invalid card.' }); + return; + } + } + // 검정<흰 + for (let i = 0; i < newOrder.length - 1; i++) { if ( - curr.num === next.num && - curr.num !== -1 && // 둘 다 조커가 아니고, - curr.color === 'white' && - next.color === 'black' + newOrder[i].num !== -1 && + newOrder[i + 1].num !== -1 && + newOrder[i].num === newOrder[i + 1].num && + newOrder[i].color === 'white' && + newOrder[i + 1].color === 'black' ) { - // 색상 순서가 어긋났음 - client.emit('error', { - message: `Invalid arrangement: black must come before white for the same number.`, - }); + client.emit('error', { message: '동일 숫자는 black < white.' }); return; } } - // 최종 반영 - player.finalHand = rearranged; + pState.finalHand = newOrder; + await this.saveGameState(roomId, st); - await this.redisService.set( - `room:${roomId}:gameState`, - JSON.stringify(gameState), - ); + const sockId = this.userSockets.get(userId); + if (sockId) { + this.server.to(sockId).emit('newlyDrawnArrangementDone', { + message: '새로 뽑은 카드 수동 배치 완료.', + finalHand: newOrder, + }); + } + + // 상대방 알림 + const newly = this.gameService.findNewlyAdded(oldArr, newOrder); + if (newly) { + const idx = newOrder.findIndex( + (x) => x.color === newly.color && x.num === newly.num, + ); + this.broadcastNewCardPosition(roomId, userId, newly, idx); + } + } + + // ───────────────────────────────────────── + // (6) endTurn + // ───────────────────────────────────────── + @SubscribeMessage('endTurn') + async handleEndTurn(client: Socket, payload: { roomId: number }) { + const { roomId } = payload; + const userId = client.data.userId; + + const st = await this.getGameState(roomId); + if (!st) return; + + if (st.turn !== userId) { + client.emit('error', { message: 'Not your turn to end.' }); + return; + } + + const players = Object.keys(st.players).map(Number); + const next = players.find((p) => p !== userId) || userId; + st.turn = next; + + await this.saveGameState(roomId, st); + this.server.to(roomId.toString()).emit('turnStarted', { + turnUserId: next, + message: `Now it's user ${next}'s turn.`, + }); + } + + // ───────────────────────────────────────── + // 내부 메서드 + // ───────────────────────────────────────── + private async getGameState(roomId: number) { + return await this.gameService.getGameState(roomId); + } + private async saveGameState(roomId: number, state: GameState) { + await this.gameService.saveGameState(roomId, state); + } + + private async checkAndRevealColorArrays(roomId: number) { + const st = await this.getGameState(roomId); + if (!st) return; + if (st.alreadyRevealed) return; - // 본인에게만 업데이트 알림 - const socketId = this.userSockets.get(userId); - if (socketId) { - this.server.to(socketId).emit('finalHandArranged', { - message: 'Your final hand arrangement is updated.', - newOrder: rearranged, + const players = Object.keys(st.players); + if (players.length !== 2) return; + const [p1, p2] = players; + + const p1Done = st.players[p1].arrangementDone; + const p2Done = st.players[p2].arrangementDone; + if (!p1Done || !p2Done) return; + + st.alreadyRevealed = true; + await this.saveGameState(roomId, st); + + const arr1 = st.players[p1].finalHand.map((c) => c.color); + const arr2 = st.players[p2].finalHand.map((c) => c.color); + + const s1 = this.userSockets.get(Number(p1)); + if (s1) { + this.server.to(s1).emit('opponentColorArrayRevealed', { + message: '상대방 색상 배열 공개 (numbers hidden).', + opponentColorArray: arr2, }); } + const s2 = this.userSockets.get(Number(p2)); + if (s2) { + this.server.to(s2).emit('opponentColorArrayRevealed', { + message: '상대방 색상 배열 공개 (numbers hidden).', + opponentColorArray: arr1, + }); + } + } + + private broadcastNewCardPosition( + roomId: number, + drawerId: number, + card: { color: string; num: number }, + index: number, + ) { + const drawerSocket = this.userSockets.get(drawerId); + + (async () => { + const st = await this.getGameState(roomId); + if (!st) return; + + const arr = st.players[drawerId].finalHand.map((x) => x.color); + this.server + .to(roomId.toString()) + .except(drawerSocket) + .emit('opponentNewCardRevealed', { + userId: drawerId, + color: card.color, + index, + message: `User ${drawerId} placed ${card.color} at index=${index}`, + drawerColorArray: arr, + }); + })(); } } diff --git a/src/game/game.service.ts b/src/game/game.service.ts index c7ef853..7c84460 100644 --- a/src/game/game.service.ts +++ b/src/game/game.service.ts @@ -4,6 +4,37 @@ import { Injectable } from '@nestjs/common'; import { RedisService } from 'src/redis/redis.service'; import { GameRoomService } from 'src/gameRoom/gameRoom.service'; +/** + * 플레이어 상태 + * - finalHand: 유저가 가진 카드 배열 + * - arrangementDone: 조커 재배치 완료 여부 + * - blackCount, whiteCount: 처음에 뽑을 흑/백 카드 수 + */ +export interface PlayerState { + finalHand: { color: string; num: number }[]; + arrangementDone: boolean; + blackCount: number; + whiteCount: number; +} + +/** + * 전체 게임 상태 + * - turn: 현재 턴 유저 ID + * - alreadyRevealed: 색상 배열 공개 여부 + * - players: userId -> PlayerState + * - blackDeck, whiteDeck: 남은 흑/백 덱 + */ +export interface GameState { + status: string; + turn: number; + alreadyRevealed: boolean; + players: { + [userId: number]: PlayerState; + }; + blackDeck: { color: string; num: number }[]; + whiteDeck: { color: string; num: number }[]; +} + @Injectable() export class GameService { constructor( @@ -11,79 +42,186 @@ export class GameService { private readonly gameRoomService: GameRoomService, ) {} - // 모든 사용자가 레디했는지 확인 + /** + * 방의 모든 유저가 레디했는지 + */ async checkAllPlayersReady(roomId: number): Promise { const players = await this.gameRoomService.getPlayersInRoom(roomId); - for (const playerId of players) { - const readyKey = `room:${roomId}:user:${playerId}:ready`; - const isReady = await this.redisService.get(readyKey); - if (isReady !== 'true') { - return false; - } + for (const pid of players) { + const val = await this.redisService.get( + `room:${roomId}:user:${pid}:ready`, + ); + if (val !== 'true') return false; } return true; } - // (1) 흑/백 각각 0~11 + 조커(-1) → 13장씩 - // (2) blackCount, whiteCount만큼 무작위 발급 - // (3) 기본 정렬 (숫자 ascending, 같은 숫자면 black < white) - async assignRandomCards(roomId: number, gameState: any) { - // 흑 카드 풀 - const blackDeck = Array.from({ length: 12 }, (_, i) => ({ - color: 'black', - num: i, - })); - blackDeck.push({ color: 'black', num: -1 }); // 흑조커 - - // 백 카드 풀 - const whiteDeck = Array.from({ length: 12 }, (_, i) => ({ - color: 'white', - num: i, - })); - whiteDeck.push({ color: 'white', num: -1 }); // 백조커 - - // 셔플 - this.shuffle(blackDeck); - this.shuffle(whiteDeck); - - // color 우선순위 map - const colorPriority = { black: 0, white: 1 }; - - for (const pid of Object.keys(gameState.players)) { - const p = gameState.players[pid]; - const { blackCount, whiteCount } = p; - - const selectedBlack = blackDeck.splice(0, blackCount); - const selectedWhite = whiteDeck.splice(0, whiteCount); - - // 합쳐서 기본 정렬 - p.finalHand = [...selectedBlack, ...selectedWhite].sort((a, b) => { - // -1(조커) vs 일반카드 - // → 우선 여기서는 "조커는 뒤로" 등 임시 처리를 할 수도 있지만, - // 사용자 재배치가 필요 없으면 바로 자리를 잡아도 됨. - // 예시는 “숫자 < 조커” 로 놓아도 괜찮습니다. - if (a.num === -1 && b.num !== -1) return 1; // 조커 뒤 - if (b.num === -1 && a.num !== -1) return -1; // 조커 뒤 - if (a.num === b.num && a.num !== -1) { - // 숫자 동일 & 둘 다 조커가 아닐 때 → black < white - return colorPriority[a.color] - colorPriority[b.color]; - } - return a.num - b.num; - }); - } + /** + * Redis에서 gameState 로딩 + */ + async getGameState(roomId: number): Promise { + const raw = await this.redisService.get(`room:${roomId}:gameState`); + return raw ? JSON.parse(raw) : null; + } - // Redis 갱신 - await this.redisService.set( - `room:${roomId}:gameState`, - JSON.stringify(gameState), - ); + /** + * Redis에 gameState 저장 + */ + async saveGameState(roomId: number, state: GameState): Promise { + const str = JSON.stringify(state); + await this.redisService.set(`room:${roomId}:gameState`, str); } - // Fisher-Yates - private shuffle(deck: any[]) { + /** + * 덱 셔플 + */ + shuffle(deck: { color: string; num: number }[]) { for (let i = deck.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [deck[i], deck[j]] = [deck[j], deck[i]]; } } + + /** + * 카드 비교 (조커(-1)는 뒤, 숫자 같으면 black < white) + */ + compareCard( + a: { color: string; num: number }, + b: { color: string; num: number }, + ): number { + if (a.num === -1 && b.num !== -1) return 1; // a 조커 -> 뒤 + if (b.num === -1 && a.num !== -1) return -1; // b 조커 -> 뒤 + if (a.num === b.num && a.num !== -1) { + // 동숫자 + if (a.color === 'black' && b.color === 'white') return -1; + if (a.color === 'white' && b.color === 'black') return 1; + return 0; + } + return a.num - b.num; + } + + /** + * 해당 finalHand에 조커가 있는지 여부 + */ + hasJoker(finalHand: { color: string; num: number }[]): boolean { + return finalHand.some((c) => c.num === -1); + } + + /** + * 특정 finalHand에서 조커의 index를 찾고, + * 조커의 양옆 카드 숫자를 기준으로 "근접 범위" 계산 + * 예: [백1, 백조커, 검4] + * => 조커인덱스=1, left.num=1, right.num=4 + * => nearRange = {2,3} (또는 조커(-1)) + */ + private computeJokerRange( + finalHand: { color: string; num: number }[], + ): Set { + // 현재 예시: 조커가 한 장 있다고 가정 (여러 장이면 더 복잡해짐) + const s = new Set(); + const idx = finalHand.findIndex((c) => c.num === -1); + if (idx < 0) return s; // 조커 없음 => 빈 set + + const leftCard = finalHand[idx - 1]; + const rightCard = finalHand[idx + 1]; + if (!leftCard || !rightCard) { + // 조커가 맨앞 혹은 맨뒤인 경우, + // 여기선 예시로 leftCard 없으면 => nearRange = 0..(rightNum-1) + // etc. 편의상 예시: + // 만약 left없고 rightCard.num=4 => nearRange = { -1, 0,1,2,3 } + // (원하는대로 정교화) + if (!leftCard && rightCard) { + for (let x = -1; x < rightCard.num; x++) { + s.add(x); + } + } else if (!rightCard && leftCard) { + for (let x = leftCard.num + 1; x <= 11; x++) { + s.add(x); + } + s.add(-1); // 조커 + } + return s; + } + + // 일반 케이스: left.num = L, right.num= R + // nearRange = (L+1 .. R-1) ∪ {-1} + const L = leftCard.num; + const R = rightCard.num; + + // 조커도 near + s.add(-1); + + if (L < R) { + // 범위 (L+1) ~ (R-1) + for (let v = L + 1; v < R; v++) { + s.add(v); + } + } + return s; + } + + /** + * "조커 양옆 범위" 판별: + * - computeJokerRange()로 구한 집합에 newCard.num이 있으면 => true + */ + isNearJokerRange( + finalHand: { color: string; num: number }[], + newCard: { color: string; num: number }, + ): boolean { + if (!this.hasJoker(finalHand)) return false; + const nearSet = this.computeJokerRange(finalHand); + return nearSet.has(newCard.num); + } + + /** + * oldArr vs newArr => 새로 들어온 카드 찾기 + */ + findNewlyAdded( + oldArr: { color: string; num: number }[], + newArr: { color: string; num: number }[], + ): { color: string; num: number } | null { + for (const c of newArr) { + if (!oldArr.some((x) => x.color === c.color && x.num === c.num)) { + return c; + } + } + return null; + } + + insertCardInOrder( + finalHand: { color: string; num: number }[], + card: { color: string; num: number }, + ): { color: string; num: number }[] { + const newHand = [...finalHand]; + let insertIndex = 0; + + for (let i = 0; i < newHand.length; i++) { + if (newHand[i].num === -1) { + // 조커는 건너뜀 + insertIndex = i + 1; + continue; + } + if (this.compareCard(card, newHand[i]) < 0) { + insertIndex = i; + break; + } else { + insertIndex = i + 1; + } + } + + newHand.splice(insertIndex, 0, card); + return newHand; + } + + validateNewOrder( + oldArr: { color: string; num: number }[], + newOrder: { color: string; num: number }[], + ): boolean { + if (oldArr.length !== newOrder.length) return false; + + const oldSet = new Set(oldArr.map((c) => `${c.color}-${c.num}`)); + const newSet = new Set(newOrder.map((c) => `${c.color}-${c.num}`)); + + return [...oldSet].every((key) => newSet.has(key)); + } } diff --git a/src/redis/redis.service.ts b/src/redis/redis.service.ts index f6b9953..77f9d62 100644 --- a/src/redis/redis.service.ts +++ b/src/redis/redis.service.ts @@ -25,7 +25,7 @@ export class RedisService { return await this.redis.get(key); } - async delete(key: string): Promise { + async del(key: string): Promise { await this.redis.del(key); } }