Mahjong Joy 개발기 (3) — 게임 엔진과 AI, 그리고 100판 자동 대국 테스트
Mahjong Joy 시리즈
- 기획 분석과 작업 계획
- 핵심 로직 — 승리 판정 알고리즘
- 게임 엔진과 AI 만들기 ← 이번 글
- 파스텔 UI와 타일 애니메이션
- 점수 시스템과 메인화면
턴제 게임의 뼈대는 상태 머신
2편에서 만든 판정 로직은 "한 손패"에 대한 함수였다. 이제 4명이 돌아가며 뽑고 버리고, 남이 버린 패를 가로채는 게임 흐름을 만들 차례다.
턴제 게임 엔진은 결국 상태 머신이다. Mahjong Joy의 상태는 딱 3개로 정리됐다.
enum GamePhase {
awaitingDiscard, // 현재 플레이어가 버릴 패를 고르는 중
awaitingClaims, // 방금 버려진 패에 대한 완성/뺏어오기 응답 대기
finished, // 승리 또는 유국
}
흐름은 이렇게 순환한다.
드로우 → [awaitingDiscard] → 버리기 → [awaitingClaims]
↑ │
└── 아무도 안 가져감: 다음 사람 드로우 ──┘
누가 가져감: 그 사람이 [awaitingDiscard]로
누가 완성: [finished]
Step 1: 엔진은 심판, 컨트롤러는 진행자
설계에서 가장 신경 쓴 부분은 역할 분리다.
- 엔진(
Game): 규칙 검증만 한다. "이 뺏어오기가 유효한가", "지금 츠모 가능한가". 잘못된 조작은StateError를 던진다. - 컨트롤러: 의사결정 순서를 정한다. 누가 먼저 가져갈지, AI는 언제 행동할지.
이렇게 나눈 이유는 우선순위 규칙 때문이다. 한 명이 패를 버리면 여러 명이 동시에 반응할 수 있다. 완성(론)이 뺏어오기보다 항상 우선이고, 같은 급이면 턴 순서가 빠른 쪽이 이긴다. 이 규칙을 엔진에 박아버리면 나중에 사람 플레이어의 "고민할 시간"을 넣기 어려워진다. 그래서 엔진은 기회 목록만 제공한다.
class ClaimOpportunity {
final int seat;
final bool canWin; // 이 패로 즉시 완성 가능?
final List<ClaimOption> options; // 뺏어와서 만들 수 있는 몸통들
}
버리기가 일어나면 엔진이 나머지 3명의 기회를 계산해두고, 컨트롤러가 declareRon / applyClaim / passClaims 중 하나를 호출하는 구조다. 이 분리 덕분에 테스트에서는 즉시 결정하고, UI에서는 사람의 입력을 기다리는 두 가지 사용 방식이 같은 엔진 위에서 돌아간다.
Step 2: AI — 셴텐 계산 대신 잠재력 점수
마작 AI를 제대로 만들려면 셴텐(향청수) 계산이 필요하지만, 캐주얼 게임의 첫 AI에게는 과하다. 대신 손패 잠재력 점수라는 단순한 지표를 만들었다.
- 완성된 몸통(트리플/스트레이트): 100점
- 부분 몸통(쌍, 연속 2장, 한 칸 띈 2장): 20점
- 부분 몸통은 "남은 몸통 수 + 머리 1개"까지만 가치 인정
점수 계산은 2편의 재귀 분해를 확장해서, 가능한 조합 중 최고 점수를 찾는 방식이다. 이 지표 하나로 AI의 두 가지 판단이 해결된다.
버리기: 한 장씩 빼보고, 남은 손패 점수가 가장 높아지는 패를 버린다.
Tile chooseDiscard(List<Tile> hand, int meldCount) {
Tile? best;
var bestScore = -1;
for (final tile in hand) {
final rest = List.of(hand)..remove(tile);
final score = _handPotential(rest, meldCount);
if (score > bestScore) { bestScore = score; best = tile; }
}
return best!;
}
뺏어오기: 가져온 뒤(몸통 +1, 최적 버리기까지 반영한) 점수가 지금보다 오를 때만 실행한다. "뺏어올 수 있다고 무조건 뺏으면" 오히려 손이 망가지는 경우가 많은데, 점수 비교 한 줄이 그걸 걸러준다.
고립된 자패부터 버리고, 몸통을 완성해주는 패는 가져오는, 제법 마작 두는 것처럼 보이는 AI가 이 점수 하나로 만들어졌다.
Step 3: 100판 자동 대국 — 시뮬레이션 테스트
이번 편의 하이라이트. 엔진과 AI가 제대로 맞물리는지 어떻게 검증할까? AI 4명끼리 100판을 자동으로 두게 하면 된다.
test('100판이 모두 정상 종료된다 (승리 또는 유국)', () {
for (var seed = 0; seed < 100; seed++) {
final game = playFullGame(seed); // 시드 고정 → 재현 가능
expect(game.phase, GamePhase.finished);
if (game.winner != null) {
// 승자의 손패는 실제로 승리 조건을 만족해야 한다
expect(isWinningHand(game.winningHand!,
meldCount: winner.meldCount), isTrue);
}
}
expect(wins, greaterThan(50)); // 대부분은 승부가 나야 정상
});
단순히 "끝났는지"만 보는 게 아니라, 매 스텝마다 **불변 조건(invariant)**을 검사한다.
- 타일 보존: 손패 + 공개 몸통 + 바닥패 + 덱 = 항상 136장
- 손패 크기: 대기 중엔
13 - 3 × 공개몸통수, 버릴 차례인 사람만 +1
이 시뮬레이션이 무한 루프, 타일 증발, 손패 개수 꼬임 같은 버그를 전부 잡아준다. 시드를 고정했으니 실패하면 seed=17처럼 재현 번호까지 알려준다.
트러블슈팅: 츠모 승자는 14장을 들고 끝난다
시뮬레이션을 처음 돌리자 바로 실패가 났다.
Expected: <4>
Actual: <5>
seed=0 seat=1
원인을 추적해보니 엔진 버그가 아니라 테스트의 불변 조건이 틀렸다. 츠모(스스로 뽑아 완성)로 이기면 승자는 14번째 패를 손에 든 채 게임이 끝난다. 그런데 불변 조건은 "게임이 끝나면 모두 13장"을 가정하고 있었다.
// 츠모 승자는 14번째 패를 든 채 끝나므로 종료 후에는 승자만 +1 허용
final mayHoldExtra = game.phase == GamePhase.awaitingDiscard
? p.seat == game.current
: game.phase == GamePhase.finished && p.seat == game.winner;
테스트가 실패했을 때 "코드가 틀렸나?"만큼 "테스트의 전제가 틀렸나?"도 의심해야 한다는 걸 다시 배웠다. 도메인 규칙(마작에서 츠모 승자의 손패는 14장)이 코드보다 우선이다.
정리
- 게임 흐름은 3-상태 머신(
awaitingDiscard/awaitingClaims/finished)으로 정리 - 엔진은 규칙 검증만, 우선순위 결정은 컨트롤러에게 — 테스트와 UI가 같은 엔진을 공유
- AI는 셴텐 대신 "몸통 100점 / 부분 20점" 잠재력 점수 하나로 버리기·뺏어오기 판단
- 시드 고정 100판 자동 대국 + 불변 조건 검사로 엔진 전체를 검증
- 실패한 테스트의 원인이 코드가 아니라 테스트 전제일 수도 있다
다음 편은 드디어 화면이다. 파스텔톤 UI, 이모지 타일, 그리고 RotatedBox로 만드는 진짜 마작 테이블 배치와 타일이 날아다니는 애니메이션을 다룬다.
backtodev
40대 PM, 다시 개발자로 돌아갑니다. 실패하고 배우며 성장하는 기록.