오늘은 마피아 게임의 주요 로직을 NestJS와 Redis를 활용하여 구현하였습니다. 게임의 진행을 관리하고, 플레이어들의 상태를 추적하며, 승리 조건을 판단하는 기능을 포함하여 여러 가지 기능을 개발하면서 많은 것을 배웠습니다.
endGame
메서드)현재 진행 중인 게임 ID를 Redis에서 조회하여 게임이 존재하는지 확인함.
플레이어들의 생존 여부를 기반으로 마피아 팀과 시민 팀의 수를 비교하여 승리 팀을 결정.
게임 종료 시 Redis에서 해당 게임 데이터를 삭제하여 정리.
최종 게임 결과를 반환하여 클라이언트에서 게임 결과를 확인할 수 있도록 함.
배운 점:
Redis를 활용하여 실시간으로 게임 상태를 관리하는 방법.
throw new BadRequestException()
을 활용한 에러 처리 방식.
마피아와 시민의 수를 기반으로 승리 조건을 판별하는 로직 설계.
getPlayerByRole
)특정 역할(예: 경찰, 마피아, 의사)을 가진 플레이어 중 살아있는 사람만 조회하는 기능 구현.
주어진 역할을 기준으로 find
메서드를 사용하여 해당하는 플레이어를 반환.
배운 점:
필터링을 활용한 특정 역할 플레이어 조회 방식.
역할 기반 게임 로직 설계.
startNightPhase
)게임의 상태를 "night"로 변경하고, 밤의 횟수(nightNumber)를 증가시킴.
현재 마피아 목록과 사망자 목록을 조회하여 클라이언트에게 알림을 전송.
이를 통해 마피아가 공격을 진행할 수 있도록 준비함.
배운 점:
Redis hset
을 사용하여 게임 상태를 업데이트하는 방법.
nightResultService.announceNightStart(roomId, mafias, dead);
을 통해 클라이언트에게 이벤트를 실시간으로 전송하는 방식.
상태 전환이 어떻게 게임 흐름을 제어하는지 이해.
selectMafiaTarget
: 마피아가 공격할 타겟을 Redis에 저장.
savePoliceTarget
: 경찰이 조사할 대상을 Redis에 저장.
saveDoctorTarget
: 의사가 보호할 대상을 Redis에 저장.
배운 점:
Redis를 이용해 각 역할별 타겟을 관리하는 방식.
hset
을 사용하여 키-값 구조로 데이터를 저장하고 관리하는 법.
BadRequestException
을 활용한 예외 처리.
getPoliceResult
)경찰이 조사한 플레이어의 역할을 확인하여 결과 반환.
조사 대상이 마피아인지 시민인지를 구별하여 경찰에게 제공.
배운 점:
특정 키(policeTarget
)를 기반으로 조사 결과를 조회하는 방식.
find
를 활용한 특정 플레이어 탐색 및 역할 확인.
processNightResult
)마피아의 공격 대상과 의사의 보호 대상을 비교하여 생존 여부 결정.
공격 대상이 의사의 보호를 받았다면 살해되지 않음.
마피아 공격이 성공하면 해당 플레이어를 사망 처리(markPlayerAsDead
).
게임이 종료 조건을 충족하면 자동으로 endGame
실행.
배운 점:
의사 역할과 마피아 공격 간의 상호작용 로직을 설계하는 방법.
await this.markPlayerAsDead(roomId, Number(mafiaTarget));
을 통해 플레이어 상태를 변경하는 방식.
게임이 끝났는지 여부를 checkEndGame()
으로 판단하고, 자동으로 endGame()
을 실행하여 흐름을 관리하는 법.
markPlayerAsDead
)특정 플레이어를 찾아 isAlive
속성을 false로 변경.
Redis에 반영하여 게임 상태를 최신화.
배운 점:
Redis에서 데이터를 가져온 후 객체 속성을 수정하고 다시 저장하는 방식.
JSON.stringify(players)
을 활용하여 변경된 데이터를 다시 Redis에 저장하는 법.
checkEndGame
)게임이 종료되었는지 확인하는 로직을 분리하여 유연한 구조로 만듦.
마피아 수가 시민보다 많거나 같다면 마피아 승리.
모든 마피아가 제거되었다면 시민 승리.
종료 조건을 충족하면 게임을 마무리하도록 설정.
배운 점:
승리 조건을 명확히 정의하여 게임 종료 여부를 판단하는 로직 설계.
게임이 끝났을 때 적절한 로그(console.log()
)를 출력하여 디버깅을 쉽게 하는 방법.
✔ Redis를 활용한 게임 상태 관리
✔ 비동기 프로그래밍을 활용한 실시간 데이터 처리
✔ 마피아, 경찰, 의사의 역할 간 상호작용 설계
✔ 게임 종료 및 승리 조건을 명확히 정의하고 자동화
✔ 클라이언트와의 실시간 이벤트 연계를 고려한 구조 설계
오늘 작성한 코드는 실시간 웹소켓 기반 게임의 핵심 로직을 구축하는 과정에서 많은 경험을 쌓을 수 있게 해주었습니다.
앞으로는 좀 더 최적화된 Redis 데이터 구조를 고민하고, 확장성을 고려한 코드 리팩토링을 진행할 계획입니다!
async endGame(roomId: string): Promise<any> {
// 현재 진행 중인 게임 ID 가져오기
const gameId = await this.getCurrentGameId(roomId);
if (!gameId) {
console.log(`room:${roomId}에 진행 중인 게임이 없음.`);
throw new BadRequestException('현재 진행 중인 게임이 존재하지 않습니다.');
}
// Redis에서 게임 데이터를 저장하는 키 생성
const gameKey = `room:${roomId}:game:${gameId}`;
const gameData = await this.getGameData(roomId, gameId);
// 게임에 참여한 플레이어 목록 가져오기
const players: Player[] = gameData.players;
// 생존한 마피아와 시민 수 카운트
const aliveMafias = players.filter(
(player) => player.role === 'mafia' && player.isAlive,
).length;
const aliveCitizens = players.filter(
(player) => player.role !== 'mafia' && player.isAlive,
).length;
let winningTeam = ''; // 최종 승리 팀 저장 변수
// 게임 종료 조건 판단
if (aliveMafias >= aliveCitizens) {
winningTeam = 'mafia'; // 마피아 수가 시민 이상이면 마피아 승리
} else if (aliveMafias === 0) {
winningTeam = 'citizens'; // 마피아가 모두 죽으면 시민 승리
} else {
return { message: '게임이 아직 끝나지 않았습니다.' }; // 아직 게임 종료 조건을 충족하지 않음
}
// 최종 게임 상태 데이터 구성 (각 플레이어의 역할 및 생존 여부 포함)
const finalState = {
players: players.map((player) => ({
userId: player.id,
role: player.role,
alive: player.isAlive,
})),
};
// Redis에서 게임 관련 데이터 삭제 (게임 종료 처리)
await this.redisClient.del(gameKey);
await this.redisClient.del(`room:${roomId}:currentGameId`);
// 최종 게임 결과 반환
const result = {
roomId,
winningTeam,
finalState,
message: `게임 종료: ${winningTeam === 'mafia' ? '마피아' : '시민'} 승리!`,
};
return result;
}
/// 1. 특정 역할(role)을 가진 살아있는 플레이어 찾기
async getPlayerByRole(roomId: string, role: string): Promise<number | null> {
const gameId = await this.getCurrentGameId(roomId);
if (!gameId) return null;
const gameData = await this.getGameData(roomId, gameId);
const players = gameData.players || [];
const player = players.find((p: any) => p.role === role && p.isAlive);
return player ? Number(player.id) : null;
}
// 2. NIGHT 시작 - 게임 상태 변경 및 클라이언트 알림
async startNightPhase(
roomId: string,
): Promise<{ nightNumber: number; mafias: Player[]; dead: Player[] }> {
const gameId = await this.getCurrentGameId(roomId);
if (!gameId)
throw new BadRequestException('현재 진행 중인 게임이 없습니다.');
const redisKey = `room:${roomId}:game:${gameId}`;
console.log(`🔹 방 ${roomId} - 밤으로 전환됨.`);
// 게임의 phase를 'night'로 설정
await this.redisClient.hset(redisKey, 'phase', 'night');
// 밤 횟수 관리 (nightNumber 증가)
const nightNumber = await this.getNightCount(roomId);
// 마피아 및 사망자 목록 조회
const mafias = await this.getMafias(roomId, gameId);
const dead = await this.getDead(roomId, gameId);
// 클라이언트에 밤 시작 이벤트 전송
this.nightResultService.announceNightStart(roomId, mafias, dead);
console.log(
`✅ 방 ${roomId} - NIGHT ${nightNumber} 시작됨. 마피아 수: ${mafias.length}, 사망자 수: ${dead.length}`,
);
return { nightNumber, mafias, dead };
}
// 3. 마피아 공격 대상 저장
async selectMafiaTarget(
roomId: string,
userId: number,
targetUserId: number,
): Promise<void> {
const gameId = await this.getCurrentGameId(roomId);
if (!gameId)
throw new BadRequestException('현재 진행 중인 게임이 없습니다.');
const redisKey = `room:${roomId}:game:${gameId}`;
await this.redisClient.hset(
redisKey,
'mafiaTarget',
targetUserId.toString(),
);
console.log(`🔫 마피아(${userId})가 ${targetUserId}를 대상으로 선택함.`);
}
// 4. 경찰 조사 대상 저장
async savePoliceTarget(roomId: string, targetUserId: number): Promise<void> {
const gameId = await this.getCurrentGameId(roomId);
if (!gameId)
throw new BadRequestException('현재 진행 중인 게임이 없습니다.');
const redisKey = `room:${roomId}:game:${gameId}`;
await this.redisClient.hset(
redisKey,
'policeTarget',
targetUserId.toString(),
);
}
// 5. 의사 보호 대상 저장
async saveDoctorTarget(roomId: string, targetUserId: number): Promise<void> {
const gameId = await this.getCurrentGameId(roomId);
if (!gameId)
throw new BadRequestException('현재 진행 중인 게임이 없습니다.');
const redisKey = `room:${roomId}:game:${gameId}`;
await this.redisClient.hset(
redisKey,
'doctorTarget',
targetUserId.toString(),
);
}
// 6. 경찰 조사 결과 조회
async getPoliceResult(roomId: string): Promise<{
policeId: number | null;
targetUserId: number | null;
role: string | null;
}> {
const gameId = await this.getCurrentGameId(roomId);
if (!gameId) return { policeId: null, targetUserId: null, role: null };
const redisKey = `room:${roomId}:game:${gameId}`;
const policeId = await this.getPlayerByRole(roomId, 'police');
if (!policeId) return { policeId: null, targetUserId: null, role: null };
const policeTarget = await this.redisClient.hget(redisKey, 'policeTarget');
if (!policeTarget) return { policeId, targetUserId: null, role: null };
const gameData = await this.getGameData(roomId, gameId);
const players = gameData.players || [];
const targetPlayer = players.find(
(p: any) => p.id === Number(policeTarget),
);
const role = targetPlayer?.role === 'mafia' ? 'mafia' : 'citizen';
return { policeId, targetUserId: Number(policeTarget), role };
}
// 7. 밤 결과 처리 (마피아 공격 결과 반영)
async processNightResult(
roomId: string,
): Promise<{ killedUserId: number | null; details: string }> {
const gameId = await this.getCurrentGameId(roomId);
if (!gameId)
throw new BadRequestException('현재 진행 중인 게임이 없습니다.');
const redisKey = `room:${roomId}:game:${gameId}`;
const mafiaTarget = await this.redisClient.hget(redisKey, 'mafiaTarget');
const doctorTarget = await this.redisClient.hget(redisKey, 'doctorTarget');
let killedUserId = mafiaTarget ? Number(mafiaTarget) : null;
let details = '마피아 공격 성공';
if (
mafiaTarget &&
doctorTarget &&
Number(mafiaTarget) === Number(doctorTarget)
) {
killedUserId = null;
details = '의사 보호로 인해 살해 취소됨';
} else if (mafiaTarget) {
await this.markPlayerAsDead(roomId, Number(mafiaTarget));
}
// 게임 종료 체크
const endCheck = await this.checkEndGame(roomId);
if (endCheck.isGameOver) {
await this.endGame(roomId);
}
return { killedUserId, details };
}
// 8. 플레이어 사망 처리
async markPlayerAsDead(roomId: string, playerId: number): Promise<void> {
const gameId = await this.getCurrentGameId(roomId);
if (!gameId)
throw new BadRequestException('현재 진행 중인 게임이 없습니다.');
const redisKey = `room:${roomId}:game:${gameId}`;
const gameData = await this.getGameData(roomId, gameId);
const players = gameData.players || [];
const player = players.find((p: any) => p.id === playerId);
if (player) player.isAlive = false;
await this.redisClient.hset(redisKey, 'players', JSON.stringify(players));
}
// 9. 밤 횟수 관리
async getNightCount(roomId: string): Promise<number> {
const gameId = await this.getCurrentGameId(roomId);
if (!gameId)
throw new BadRequestException('현재 진행 중인 게임이 없습니다.');
const redisKey = `room:${roomId}:game:${gameId}`;
const nightNumber = await this.redisClient.hget(redisKey, 'nightNumber');
const newNightCount = nightNumber ? parseInt(nightNumber) + 1 : 1;
await this.redisClient.hset(
redisKey,
'nightNumber',
newNightCount.toString(),
);
return newNightCount;
}
async checkEndGame(
roomId: string,
): Promise<{ isGameOver: boolean; winningTeam: string | null }> {
const gameId = await this.getCurrentGameId(roomId);
if (!gameId) {
console.log(`room:${roomId}에 진행 중인 게임이 없음.`);
return { isGameOver: false, winningTeam: null };
}
// 현재 게임 데이터를 조회
const gameData = await this.getGameData(roomId, gameId);
const players: Player[] = gameData.players;
// 생존한 마피아와 시민 수 카운트
const aliveMafias = players.filter(
(player) => player.role === 'mafia' && player.isAlive,
).length;
const aliveCitizens = players.filter(
(player) => player.role !== 'mafia' && player.isAlive,
).length;
// 게임 종료 조건 판단
if (aliveMafias >= aliveCitizens) {
console.log(`게임 종료 - 마피아 승리`);
return { isGameOver: true, winningTeam: 'mafia' };
} else if (aliveMafias === 0) {
console.log(`게임 종료 - 시민 승리`);
return { isGameOver: true, winningTeam: 'citizens' };
}
return { isGameOver: false, winningTeam: null };
}