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/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/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..6a8beb6 --- /dev/null +++ b/src/game/game.gateway.ts @@ -0,0 +1,634 @@ +// game.gateway.ts + +import { + SubscribeMessage, + WebSocketGateway, + OnGatewayInit, + WebSocketServer, + OnGatewayConnection, + OnGatewayDisconnect, +} from '@nestjs/websockets'; +import { Server, Socket } from 'socket.io'; +import * as jwt from 'jsonwebtoken'; + +import { RedisService } from 'src/redis/redis.service'; +import { GameRoomService } from '../gameRoom/gameRoom.service'; +import { GameService, GameState } 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'); + } + + handleConnection(client: Socket) { + try { + 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'); + client.data.userId = userId; + this.userSockets.set(userId, client.id); + console.log(`User connected: ${userId}`); + } catch (err) { + console.error(err.message); + client.disconnect(); + } + } + + async handleDisconnect(client: Socket) { + console.log(`Client disconnected: ${client.id}`); + const userId = client.data.userId; + this.userSockets.delete(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', + message: `User ${userId} disconnected.`, + }); + } + } + + // ───────────────────────────────────────── + // 방 입/퇴장 + // ───────────────────────────────────────── + + @SubscribeMessage('joinRoom') + async handleJoinRoom(client: Socket, payload: { roomId: number }) { + const { roomId } = payload; + const userId = client.data.userId; + 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 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} left the room.`, + }); + } + } + + // ───────────────────────────────────────── + // 채팅 + // ───────────────────────────────────────── + @SubscribeMessage('message') + async handleMessage( + client: Socket, + payload: { roomId: number; message: string }, + ) { + const { roomId, message } = payload; + const userId = client.data.userId; + + 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 + // ───────────────────────────────────────── + @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 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 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.gameService.shuffle(blackDeck); + this.gameService.shuffle(whiteDeck); + + gameState.blackDeck = blackDeck; + gameState.whiteDeck = whiteDeck; + + 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) chooseInitialCards + // ───────────────────────────────────────── + @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: 'Must pick exactly 4 cards (black+white=4).', + }); + return; + } + + 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; + } + + st.players[userId].blackCount = blackCount; + st.players[userId].whiteCount = whiteCount; + + await this.saveGameState(roomId, st); + + this.server.to(roomId.toString()).emit('initialCardsChosen', { + userId, + blackCount, + whiteCount, + }); + + // 모두 골랐나? + const allChosen = Object.values(st.players).every( + (p) => p.blackCount + p.whiteCount === 4, + ); + 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); + } + + // 여기서 조커가 있어도 절대 맨 뒤로 안 보낼 수도 있음 + // 예: 간단히 compareCard로 sort하면 조커가 뒤로 감. + // => "사용자"가 이후 arrnageFinalHand로 옮길 수 있음 + arr.sort((a, b) => this.gameService.compareCard(a, b)); + + pState.finalHand = arr; + const hasJoker = arr.some((x) => x.num === -1); + if (!hasJoker) { + pState.arrangementDone = true; + } + } + + await this.saveGameState(roomId, st); + + // 본인에게 전송 + for (const pidStr of Object.keys(st.players)) { + const pid = Number(pidStr); + const sockId = this.userSockets.get(pid); + if (!sockId) continue; + + 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 + // ───────────────────────────────────────── + @SubscribeMessage('arrangeFinalHand') + async handleArrangeFinalHand( + 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: '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; + } + } + + // 검정<흰 + 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; + } + + 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; + } + + // 숫자 카드 => 조커 위치는 안 건드림 + // 그냥 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 ( + 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; + await this.saveGameState(roomId, st); + + 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 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.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..7c84460 --- /dev/null +++ b/src/game/game.service.ts @@ -0,0 +1,227 @@ +// game.service.ts + +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( + private readonly redisService: RedisService, + private readonly gameRoomService: GameRoomService, + ) {} + + /** + * 방의 모든 유저가 레디했는지 + */ + async checkAllPlayersReady(roomId: number): Promise { + const players = await this.gameRoomService.getPlayersInRoom(roomId); + for (const pid of players) { + const val = await this.redisService.get( + `room:${roomId}:user:${pid}:ready`, + ); + if (val !== 'true') return false; + } + return true; + } + + /** + * Redis에서 gameState 로딩 + */ + async getGameState(roomId: number): Promise { + const raw = await this.redisService.get(`room:${roomId}:gameState`); + return raw ? JSON.parse(raw) : null; + } + + /** + * Redis에 gameState 저장 + */ + async saveGameState(roomId: number, state: GameState): Promise { + const str = JSON.stringify(state); + await this.redisService.set(`room:${roomId}:gameState`, str); + } + + /** + * 덱 셔플 + */ + 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/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); + } + // ───────────────────────────────────────── // 방 생성 + 생성자 자동 참가 // ───────────────────────────────────────── 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); } }