이 글은 Claude Fable 5.1 을 이용해 초안이 작성되었으며, 이후 퇴고를 거쳤습니다.


다섯 편의 원칙 가운데 한 판에 나올 수 있는 것들을 모읍니다. 4인 게임이고 나는 좌석 1, 상대는 좌석 순서대로 B · C · D 입니다. 선은 B 입니다. 카드는 전부 손으로 설계한 것이고, 상대의 손패는 그 사람이 공개한 것만 적습니다.


1. 한 판 복기#

딜 — 받은 일곱 장을 분류한다#

J♦4♥5♥9♠9♦K♣8♥
내 손패. 카드합 11+4+5+9+9+13+8 = 59
무리카드
조합없음
조각4♥ 5♥ (3♥ 또는 6♥), 9♠ 9♦ (9 한 장)
반쯤 조각8♥ (7♥ 가 혼자 놓이면 붙는다)
홑 카드J♦ K♣

Part 1 의 분류입니다. 조각을 채우는 아웃은 넉 장(3♥ 6♥ 9♥ 9♣)이고, 7 을 뽑으면 단독 등록, 10♦ 를 뽑으면 9♦-10♦-J♦ 가 되니 어떤 형태로든 첫 등록을 여는 아웃은 아홉 장입니다. J♦ 는 완전한 홑 카드가 아니라 9♦ 와 10♦ 한 장을 사이에 둔 조각이기도 합니다. 확정 스톱 자격은 없습니다(합 59).

첫 공개 카드 — 6♥ 가 뒤집혔다#

공개 카드6♥내 손패J♦4♥5♥9♠9♦K♣8♥
6♥ 는 내 4♥ 5♥ 와 RUN 이 된다. 손패 두 장 + 공개 카드 한 장

첫 공개 카드는 선 B 부터 우선권이 돕니다. B · C · D 가 부르지 않았고 내가 불렀습니다. 독박이 없는 땡큐이므로 로우·하이 자격만 없으면 부릅니다(Part 4 §4).

내 첫 턴 — 등록하고 버린다#

6♥ 를 가져왔으니 RUN 4-5-6♥ 를 즉시 등록해야 합니다. 가져온 카드가 이 턴의 뽑기를 대신하니 덱에서는 뽑지 않습니다. 다른 등록 후보는 없습니다.

내 RUN4♥5♥6♥남은 손패J♦9♠9♦K♣8♥
RUN 등록. 남은 다섯 장 중 무엇을 버리는가

버릴 후보는 홑 카드 J♦ 와 K♣ 입니다(Part 3 원칙 1). 8♥ 는 7♥ 가 아직 안 보였으니 뒤로 미룹니다. 첫 턴이라 상대 셋이 모두 미등록이지만 아직 아무 카드도 공개되지 않아 누구에게 무엇이 땡큐가 될지 가릴 정보가 없으니 값으로 정합니다. 비싼 K♣ 를 버립니다. 손패는 J♦ 9♠ 9♦ 8♥ 네 장, 카드합 37 입니다.

한 가지 짚어 둘 것이 있습니다. 여기서 9♦ 를 버리면 안 됩니다. 9 가 비싸 보여도 9♠ 9♦ 는 조각입니다. 다음 절에서 이 조각이 판을 결정합니다.

B · C · D 의 턴#

좌석한 일그 뒤 손패
B뽑고, SET Q♠ Q♥ Q♣ 등록, 3♠ 버림4장, 등록함
C뽑고, 6♠ 버림7장, 미등록
D뽑고, 7♣ 등록, 9♣ 버림6장, 등록함

B 가 버린 3♠ 와 C 가 버린 6♠ 는 내 손패와 아무 조합도 안 됩니다. 그런데 D 가 9♣ 를 버렸습니다.

땡큐 — 좌석 순서로 내가 1순위#

버림패9♣내 손패J♦9♠9♦8♥
9♣ 는 9♠ 9♦ 와 SET 이 된다. D 의 다음 자리는 나이므로 내가 1순위

D 의 다음 자리는 좌석 1, 나입니다. 누가 같이 부르든 내 것입니다(Part 4 원칙 3). 부릅니다. SET 9 를 등록하고 손패는 J♦ 8♥ 두 장이 됩니다.

D 의 입장에서 보면 9♣ 는 위험한 카드였습니다. 9 가 한 장도 공개되지 않아 SET 방향이 열려 있었고, 바로 다음 자리인 내가 9 를 두 장 들고 있었습니다. 두 방향이 모두 막힌 카드가 손에 있었다면 그쪽이 먼저고, 한쪽만 막힌 카드는 그다음이었습니다(Part 3 §4).

가로챈 턴 — 뽑지 않고 버린다#

땡큐로 가져온 9♣ 가 이 턴의 뽑기입니다. 덱에서는 뽑지 않습니다. 등록할 것도 붙일 것도 더 없으니 J♦ 와 8♥ 중 한 장을 버립니다.

내 RUN4♥5♥6♥B 의 SETQ♠Q♥Q♣D 의 77♣내 SET9♠9♦9♣내 손패J♦8♥
테이블에 조합 넷. J♦ 와 8♥ 중 무엇을 버리는가

다음 자리 B 는 등록했고 네 장이니 독박은 없습니다. C 는 미등록으로 일곱 장을 들고 있는데 아직 첫 바퀴라 훌라를 완성해 두었을 가능성은 낮습니다. 값으로는 J♦ 가 먼저이고, 8♥ 는 7♥ 의 이웃 입니다. 테이블에 7♣ 은 나왔어도 7♥ 는 아직입니다. 누가 7♥ 를 혼자 놓는 순간 8♥ 는 한 장으로 붙습니다(Part 3 의 “6 과 8 은 반쯤 조각”). 그래서 J♦ 를 버립니다. 손패는 8♥ 한 장, 카드합 8 입니다.

만약 C 가 등록할 수 있었을 텐데도 여러 바퀴를 미등록으로 버티고 있다면 순서가 바뀝니다. 먼저 C 에게 땡큐가 되는지인데, J 도 8 도 한 장도 공개되지 않아 SET 방향이 둘 다 열려 있습니다. RUN 방향은 J♦ 가 9-10-J (9♦ 가 내 SET 에 있어 불가) · 10-J-Q · J-Q-K, 8♥ 가 6-7-8 (6♥ 가 내 RUN 에 있어 불가) · 7-8-9 · 8-9-10 이라 각각 두 경로가 열려 있습니다. 둘 다 안전하지 않고 더 안전한 쪽도 없으니 6·8 규칙보다 값이 앞섭니다. J♦ 를 버리는 결론은 같지만 이유가 다릅니다. 독박 위험, 그다음 6·8 규칙, 그다음 값 의 순서입니다.

마지막 바퀴 — B · C · D#

좌석한 일그 뒤 손패
B뽑고, K♠ 버림4장, 등록함
C뽑고, Q♦ 버림7장, 미등록
D뽑은 8♣ 을 자기 7♣ 에 붙이고, 2♠ 버림5장, 등록함

세 장 모두 내 8♥ 와 조합이 안 됩니다. D 가 8♣ 을 7♣ 에 붙인 것이 Part 3 의 “6 과 8 은 반쯤 조각” 그 장면입니다. 7♥ 는 아직 나오지 않았습니다.

내 다음 턴 — 뽑지 않고 스톱#

내 차례입니다. 손패 한 장, 카드합 8, 등록한 상태. 일반 스톱 자격입니다. 먼저 Part 5 의 확정 구간을 봅니다.

상대남은 장수최소 가능 합판정
B4장4확정 아님. 표: 내 합 8 대 4장은 >99%
C7장10확정. 8 보다 낮거나 같을 수 없음
D5장6확정 아님. 표: 내 합 8 대 5장은 >99%

C 는 확정이고, B 와 D 는 표에서 99% 를 넘습니다. 둘을 곱해도 99% 언저리입니다. 기다리는 쪽의 길은 생각보다 많습니다. 뽑은 카드를 처리하고 8♥ 를 마지막으로 버려도 끝나기 때문입니다. 7 을 뽑으면 단독 등록(세 장), 3♥ 는 내 RUN 앞에, 6♣ 는 D 의 7-8♣ 앞에, 9♥ 는 내 SET 9 에 붙이고 8♥ 를 버리면 됩니다. 여섯 장이고, 내가 아는 카드 19장을 뺀 33장 중 여섯이라 다음 뽑기로 끝날 확률은 18% 안팎입니다. 그래도 지금 걸면 99% 를 넘으니 스톱이 낫습니다. 기다림의 값을 낮게 세어서 스톱을 고르는 것이 아니라, 제대로 세어도 스톱이 앞섭니다. 지금 스톱 입니다(Part 5 §2.6). 뽑기 전에 선언합니다.

정산 — 손패를 공개해 검산한다#

스톱을 걸면 전원이 손패를 공개합니다.

B (등록함)10♣10♠5♣4♣D (등록함)A♥2♥10♦5♠K♦C (미등록)8♠8♦J♥K♥4♦3♣9♥
B 29, D 31, C 56. 모두 내 8 보다 높다. 스톱 성공
좌석카드합등수계산점수
나81등아래 셋의 합+11
B292등기본 1-1
D313등기본 2-2
C564등 (미등록이라 맨 아래)기본 4 × 미등록박 2-8

계산 순서는 등수별 기본 부담 → 판 배율 → 개인 박 입니다. 이 판은 일반 승리라 배율 1 이고, C 에게만 미등록박이 붙었습니다. C 손에 7 이 없으니 세븐박은 없습니다.

1등의 몫 +7 이 미등록박 덕에 +11 이 됐습니다. C 가 손에 7 을 한 장 들고 있었다면 -8 에 세븐박 2배가 곱해져 -16 이고 내 몫은 +19 입니다. 이 판에서 내가 한 것은 첫 공개 카드의 땡큐, 조각을 지킨 버리기, 1순위 땡큐, 7 의 이웃을 남긴 버리기, 한 장 남았을 때의 스톱 다섯 가지입니다. 어느 것도 어렵지 않고, 어느 것도 “가장 긴 조합을 등록하고 가장 비싼 카드를 버린다” 만으로는 나오지 않습니다.


2. 턴마다 보는 체크리스트#

[뽑기 전]
  미등록이고 합 22 이하 / 76 이상인가 → 뽑은 뒤 손패를 다 비울 수 있으면 훌라, 아니면 등록하지 않는다
    (아래 [뽑은 뒤]의 등록 단계를 건너뛴다). 두 번째 턴부터 로우·하이
  확정 스톱 자격이 있는가 (총통 / 두 번째 턴 이후 미등록 로우·하이)
  등록했고 합 15 이하인가 → 내 합 < 상대 전원 최소 가능 합이면 확정 스톱. 아니면 Part 5 표
[뽑은 뒤]
  7 이 있는가 → 내려놓는다 (세 장이면 같은 턴에 붙일 6·8 이 있을 때 따로, 없으면 SET. RUN 안에 있으면 RUN)
  등록 후보가 여럿인가 → 남는 카드를 보고 이 턴에 가장 많이 터는 순서로
  등록했는가 → 붙일 수 있는 카드는 지금 붙인다
[버리기 전]
  미등록 상대가 있는가 (다음 자리부터 전원) → 그 상대에게 땡큐가 될 카드는 가능한 한 피한다
  죽은 조각이 있는가 → 홑 카드로 재분류하고 값·위험으로 비교한다
  홑 카드 중 위험하지 않은 것 중 가장 비싼 것
[남의 차례]
  버림패 + 내 손패 2장이 조합인가 → 부른다
  단, 로우·하이 자격을 지키는 중이거나 한 장이면 훌라인 손패는 참는다
  가져가면 손패가 비는가 → 무조건 부른다

처음 몇 판은 위 줄을 다 볼 수 없습니다. 한 판에는 7 과 버리기 만, 다음 판에는 땡큐 만, 그다음에는 스톱 만 보는 식으로 하나씩 늘리면 됩니다.


3. 흔한 실수 열 가지#

#실수어느 편
17 두 장을 SET 이 될 때까지 쥔다1, 2
2넉 장짜리 조합을 통째로 내려놓아 다른 조합의 다리를 없앤다2
3등록하기 전에 남는 카드를 보지 않고 눈에 먼저 띈 조합을 내려놓는다2
4붙일 수 있는 카드를 손에 쥐고 있는다3
5가장 비싼 카드라는 이유로 조각을 버린다3
6짝이 전부 공개된 죽은 조각을 살아 있다고 믿고 지키다 다른 조각을 깬다3
7더 안전한 카드가 있는데 위험을 확인하지 않고 미등록 상대에게 땡큐가 될 카드를 버린다 (독박)3
8땡큐 자격이 생겼는데 “조합이 작다” 며 참는다4
9카드합 15 이하라는 이유만으로, 상대 장수와 최소 가능 합을 보지 않고 스톱을 건다 (스톱박)5
10한 장만 오면 훌라라며 7 을 든 채 몇 턴을 기다린다5

4. 인원이 다르면#

이 시리즈는 4인 기준입니다. 3인과 5인에서 달라지는 것은 셋입니다.

3인5인
1등의 몫 (박·동점 없을 때)+3+15
일반 스톱 성공 조건상대 둘 다 나보다 높아야 함. 성공률이 4인보다 높음상대 넷 모두. 한 명만 낮아도 실패라 성공률이 뚝 떨어짐
스톱박 (박·동점 없을 때)-3-15. 실패의 값이 큼
땡큐한 바퀴에 남의 버림패가 두 번뿐이지만 나 외의 경쟁자가 하나뿐한 바퀴에 남의 버림패가 네 번이고, 나 외의 경쟁자가 셋이라 우선권이 뒤로 밀리기 쉬움. 내가 꼴찌인 버림패는 인원과 무관하게 바로 다음 사람의 것

5인에서는 일반 스톱을 4인보다 훨씬 보수적으로 걸어야 합니다. 확정 구간이 아니면 상대 넷 전원이 4장 이상일 때만 겁니다. 3인에서는 상대가 둘뿐이라 같은 표로도 성공률이 높게 나옵니다.

봇 실험에서는. hard 봇이 easy 봇보다 얼마나 더 버는지를 인원별로 재면 3인 판당 약 +3.6, 4인 약 +8.2, 5인 약 +18.1 이었습니다. 인원이 늘수록 판단의 값이 커집니다. 1등의 몫과 스톱박이 함께 커지기 때문입니다.


5. 다음 단계#

이 여섯 편은 큰 손실을 피하는 법입니다. 그다음은 상대를 읽는 것입니다. 상대가 땡큐로 무엇을 가져갔는지, 어느 무늬를 계속 버리는지, 미등록으로 몇 장을 버티는지를 세면 버리기와 스톱의 정확도가 한 단계 오릅니다. 그리고 그 셈을 컴퓨터가 하면 어떻게 되는지가 궁금하시면 훌라 CPU 플레이어 만들기 시리즈로 이어집니다. 이 가이드의 “봇 실험에서는” 상자가 전부 거기서 왔습니다.


부록: 확률표 스크립트#

Part 5 의 표를 만든 스크립트입니다. 표준 라이브러리만 씁니다. 상대 한 명의 카드합 분포는 경우의 수를 전수로 세고(균등), 치우친 가정은 낮은 카드에 무게를 준 가중 추출을 6만 회 표본으로 구합니다. 실행에 9초쯤 걸립니다.

#!/usr/bin/env python3
"""훌라 초보자 전략 가이드의 암기용 표를 계산한다. 표준 라이브러리만 쓴다.

1. 아웃 확률: 안 보이는 카드 U 장 중 아웃 k 장. 한 번 뽑을 때 / 두 번 뽑을 때 적어도 한 장.
2. 일반 스톱 성공률: 상대 한 명이 n 장을 들고 있을 때 카드합이 내 합 S 보다 클 확률.
   (a) 균등 가정 — 안 보이는 카드가 표준 덱 분포(각 값 4장)에서 무작위로 뽑힌다. 전수 계산(DP).
   (b) 치우친 가정 — 상대는 높은 카드를 먼저 버리므로 낮은 카드일수록 손에 남을 무게가 크다.
       무게 w(v) = 1 + (13 - v) / 12  (2 는 K 의 약 2배). 비복원 가중 추출을 Monte Carlo 로 근사.
3. 4인 스톱 기대값: 성공 시 +7, 실패 시 -7 (스톱박 = 패자 전원 몫). 박(미등록·세븐)과 패자 간 동점이 없다고 가정.
"""
import random
from fractions import Fraction
from itertools import product

VALUES = list(range(1, 14))  # A=1 … K=13
DECK = {v: 4 for v in VALUES}


def outs_table():
    print('## 아웃 확률 (안 보이는 카드 U 장, 아웃 k 장)')
    print('| U | k=1 한 번 | k=2 한 번 | k=3 한 번 | k=4 한 번 | k=2 두 번 | k=4 두 번 |')
    print('|---|---|---|---|---|---|---|')
    for U in (40, 35, 30, 25, 20, 15, 10):
        row = [str(U)]
        for k in (1, 2, 3, 4):
            row.append(f'{k / U * 100:.0f}%')
        for k in (2, 4):
            p2 = 1 - (1 - k / U) * (1 - k / (U - 1))
            row.append(f'{p2 * 100:.0f}%')
        print('| ' + ' | '.join(row) + ' |')
    print()


def sum_dist_uniform(n, deck=DECK):
    """deck(값→장수)에서 n 장을 비복원 균등 추출했을 때 합의 분포. 경우의 수 DP."""
    # dp[j][s] = j 장 골라 합 s 인 조합 수
    from math import comb
    dp = [dict() for _ in range(n + 1)]
    dp[0][0] = 1
    for v, cnt in deck.items():
        for j in range(n, 0, -1):
            for take in range(1, min(cnt, j) + 1):
                ways = comb(cnt, take)
                for s, w in list(dp[j - take].items()):
                    dp[j][s + v * take] = dp[j][s + v * take] + w * ways if (s + v * take) in dp[j] else w * ways
    total = sum(dp[n].values())
    return {s: Fraction(w, total) for s, w in dp[n].items()}


def p_opp_above_uniform(S, n):
    d = sum_dist_uniform(n)
    return float(sum(p for s, p in d.items() if s > S))


def p_opp_above_biased(S, n, rng, trials=60000):
    cards = [v for v in VALUES for _ in range(4)]
    weights = [1 + (13 - v) / 12 for v in cards]
    hit = 0
    for _ in range(trials):
        pool = cards[:]
        wts = weights[:]
        total = 0
        for _ in range(n):
            i = rng.choices(range(len(pool)), weights=wts, k=1)[0]
            total += pool[i]
            pool.pop(i)
            wts.pop(i)
        if total > S:
            hit += 1
    return hit / trials


def stop_tables():
    rng = random.Random(7)
    print('## 상대 한 명이 내 카드합보다 높을 확률 — 균등 가정')
    print('| 내 합 \\ 상대 장수 | 2장 | 3장 | 4장 | 5장 | 6장 | 7장 |')
    print('|---|---|---|---|---|---|---|')
    uni = {}
    for S in (5, 8, 10, 12, 15):
        row = [str(S)]
        for n in range(2, 8):
            p = p_opp_above_uniform(S, n)
            uni[(S, n)] = p
            row.append(f'{p * 100:.0f}%')
        print('| ' + ' | '.join(row) + ' |')
    print()
    print('## 상대 한 명이 내 카드합보다 높을 확률 — 치우친 가정 (낮은 카드가 손에 남는다)')
    print('| 내 합 \\ 상대 장수 | 2장 | 3장 | 4장 | 5장 | 6장 | 7장 |')
    print('|---|---|---|---|---|---|---|')
    bia = {}
    for S in (5, 8, 10, 12, 15):
        row = [str(S)]
        for n in range(2, 8):
            p = p_opp_above_biased(S, n, rng)
            bia[(S, n)] = p
            row.append(f'{p * 100:.0f}%')
        print('| ' + ' | '.join(row) + ' |')
    print()
    print('## 4인: 상대 셋이 (n1,n2,n3) 장일 때 스톱 성공률 (상대별 확률의 곱 — 근사) 과 기대값 (성공 +7 / 실패 -7)')
    print('| 내 합 | 상대 장수 | 균등 성공률 | 균등 기대값 | 치우침 성공률 | 치우침 기대값 |')
    print('|---|---|---|---|---|---|')
    for S in (8, 10, 12, 15):
        for sizes in ((3, 3, 3), (2, 4, 5), (4, 5, 6), (5, 6, 7)):
            pu = 1
            pb = 1
            for n in sizes:
                pu *= uni[(S, n)]
                pb *= bia[(S, n)]
            print(f'| {S} | {sizes} | {pu * 100:.0f}% | {7 * (2 * pu - 1):+.1f} | {pb * 100:.0f}% | {7 * (2 * pb - 1):+.1f} |')
    print()


def seven_table():
    print('## 7 을 들고 판이 끝났을 때 배수 (4인 4등 -4점 기준)')
    print('| 손에 남은 7 | 등록함 | 미등록 |')
    print('|---|---|---|')
    for k in range(0, 4):
        reg = -4 * (2 ** k)
        unreg = -4 * 2 * (2 ** k)
        print(f'| {k}장 | {reg} | {unreg} |')
    print()


if __name__ == '__main__':
    outs_table()
    seven_table()
    stop_tables()

시리즈 목록#

References#