Files
joke-app/docs/games/halli-galli.md
EJClaw f4d63e1544 docs: 온라인 보드게임 사이트 계획서 (공통 14편 + 게임 26종)
- 개요, 조사(BGA·한국 인기 보드게임·UI/UX), 아키텍처, 실시간 프로토콜,
  안정성·보안, 계정, 로비·방, UI/UX, 데이터 모델, 게임 엔진, 테스트,
  배포·운영, 로드맵(M0~M9), 사용자 결정 항목
- 게임별 규칙·엔진 설계·UI·테스트 체크리스트 26종

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 04:18:29 +09:00

204 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 할리갈리 (`halli-galli`)
> 마일스톤: M6 · 인원: 최소 2 ~ 최대 6명 · 예상 시간: 약 5~15분 · 난이도: 쉬움
## 1. 개요
- 차례대로 과일 카드를 한 장씩 뒤집다가, 펼쳐진 카드에서 **한 종류 과일이 정확히 5개**가 되면 가운데 종을 가장 먼저 친 사람이 펼쳐진 카드를 모두 가져가는 순발력 게임.
- 한국에서 가장 유명한 보드게임 중 하나로(코리아보드게임즈 유통), 어린이부터 어르신까지 규칙을 1분이면 이해한다.
- 인원 근거: 원작(Amigo, 1990, 하임 샤피르) 박스 표기 2~6인.
## 2. 모드와 옵션
| 옵션 키 | 설명 | 선택지 | 기본값 |
|---|---|---|---|
| `endMode` | 종료 방식 | `lastStanding`(한 명이 모든 카드를 가질 때까지, 국내 통상 규칙) / `official`(2명 남으면 다음 종 판정으로 종료, 원작 규칙) | `lastStanding` |
| `timeLimitMin` | 전체 제한 시간(분). 만료 시 카드 많은 사람 승 | 5 / 10 / 15 / 없음 | 10 |
| `flipSeconds` | 자기 차례에 뒤집기 제한 시간(초과 시 자동 뒤집기) | 2 / 3 / 5 / 8 | 3 |
| `autoFlip` | 모든 뒤집기를 서버가 일정 간격으로 자동 진행 | on / off | off |
| `flipGuardMs` | 뒤집기 직후 다음 뒤집기를 받지 않는 시간(지연 공정성) | 400 / 600 / 1000 | 600 |
| `wrongRingLockoutMs` | 틀린 종 후 그 사람의 종 잠금 시간 | 0 / 1000 / 2000 | 1000 |
| `leftoverCards` | 나누고 남은 카드 처리 | `pot`(종 밑, 다음 정답자가 가져감) / `remove`(게임에서 제외) | `pot` |
| `latencyComp` | 지연 보정 | `off` / `light` | `off` |
| `showRtt` | 각자 핑 표시 | on / off | on |
| `kidMode` | 어린이 모드: `flipSeconds` 8, 과일 개수 합계 보조 표시 | on / off | off |
## 3. 구성물
- 카드 56장: 과일 4종(딸기, 바나나, 라임, 자두) × 14장.
- 과일 1종당 구성:
| 그려진 과일 수 | 장수 |
|---|---|
| 1개 | 5장 |
| 2개 | 3장 |
| 3개 | 3장 |
| 4개 | 2장 |
| 5개 | 1장 |
과일 1종 합계 14장, 과일 개수 총합 5×1 + 3×2 + 3×3 + 2×4 + 1×5 = 33개.
- 종 1개(가상, 화면 가운데).
## 4. 준비(셋업)
1. 56장을 RNG로 섞는다. 좌석과 첫 플레이어도 RNG.
2. 같은 장수로 나눈다: `floor(56 / N)`장씩. 남은 카드는 `leftoverCards`에 따라 종 밑(공개되지 않은 더미, 장수만 공개) 또는 제외.
| 인원 | 1인당 | 남는 카드 |
|---|---|---|
| 2 | 28 | 0 |
| 3 | 18 | 2 |
| 4 | 14 | 0 |
| 5 | 11 | 1 |
| 6 | 9 | 2 |
3. 각자 받은 카드는 뒷면 더미(덱). 그 앞에 공개 더미(펼친 카드) 자리가 빈 채로 시작.
4. 3초 카운트다운 후 첫 플레이어 차례.
## 5. 진행 규칙
### 5.1 뒤집기
- 차례인 플레이어는 자기 덱 맨 위 카드를 뒤집어 자기 공개 더미 위에 놓는다. 그 다음 시계 방향으로 다음 "참가 중" 플레이어 차례.
- 각 플레이어의 공개 더미 **맨 위 카드만** 셈에 들어간다.
- `flipSeconds` 안에 뒤집지 않으면 서버가 자동으로 뒤집는다.
- 뒤집은 직후 `flipGuardMs` 동안은 다음 사람의 뒤집기를 받지 않는다(예약해 두었다가 시간이 지나면 처리). 지연이 큰 사람도 같은 화면을 보고 종을 칠 시간을 보장하기 위함.
### 5.2 종 치기
- 누구든(차례와 무관하게, 참가 중이면) 언제든 종을 칠 수 있다.
- 판정 기준: 종을 친 순간의 테이블 상태. 테이블의 모든 공개 더미 맨 위 카드(탈락자의 공개 더미 포함)에서 과일별 개수를 합산해 **어떤 과일이든 정확히 5**면 정답. 6 이상, 4 이하는 정답이 아니다.
- 예: 딸기 2 + 딸기 3 + 바나나 4 → 딸기 5 → 정답. 딸기 2 + 딸기 3 + 딸기 1 → 딸기 6 → 오답.
- **정답**: 가장 먼저 도착한 사람이 모든 공개 더미(+ 종 밑 카드)를 가져와 자기 덱 맨 아래에 넣는다(순서: 좌석 순으로 각 공개 더미를 아래부터, 마지막에 종 밑 카드). 그 사람이 다음 뒤집기 차례.
- **오답**: 종을 친 사람은 자기 덱에서 다른 참가 중인 모든 플레이어에게 1장씩 준다(받는 사람 덱 맨 아래, 지급 순서는 시계 방향). 덱이 모자라 다 주지 못하면 시계 방향 순으로 줄 수 있는 만큼만 주고 즉시 탈락. 정확히 다 줘서 0장이 된 경우는 5.3과 같이 자기 차례가 올 때까지 참가 상태. 진행 차례는 바뀌지 않는다.
- **늦은 종**: 종에는 클라이언트가 보고 있던 테이블 버전(`tableVersion`, 뒤집기·수거마다 +1)이 담긴다. 서버 버전과 다르면 "늦었어요"로 무시(벌칙 없음). 같은 버전에서 이미 누가 정답을 가져갔다면 버전이 바뀌었으므로 자연히 무시된다.
- 2명이 거의 동시에 친 경우: 서버 도착 순서가 우선(5.4).
### 5.3 탈락
- 자기 차례가 왔는데 덱이 0장이면 탈락(공개 더미는 테이블에 남아 계속 셈에 들어가며, 다음 정답자가 가져간다).
- 덱이 0장이어도 자기 차례가 오기 전에는 종을 칠 수 있다(정답이면 카드를 얻어 복귀). 단 오답이면 줄 카드가 없으므로 즉시 탈락.
- 탈락자는 관전 화면으로 전환.
### 5.4 실시간 판정과 공정성
- 방 단위 단일 큐에서 서버 수신 순서대로 `serverTs`를 붙여 처리한다. 같은 ms라도 큐 순서가 판정 순서이고 로그로 재생 가능.
- 클라이언트 시각은 신뢰하지 않는다.
- RTT: 2초마다 ping, 지수이동평균(α=0.2). `showRtt`면 이름 옆에 표시(초록 < 80ms, 노랑 < 200ms, 빨강 ≥ 200ms). 200ms 이상인 사람에게 "연결이 느려요" 안내.
- `flipGuardMs`: 테이블이 바뀐 뒤 최소 이 시간 동안은 다시 바뀌지 않으므로, 지연 RTT/2가 이 값보다 작은 사람은 정답 상태를 놓치지 않는다.
- `latencyComp=light`(선택): 첫 정답 종이 도착하면 50ms 창을 열고, 창 안의 같은 버전 정답 종들을 `serverTs - min(RTT/2, 80)` 기준으로 비교해 가장 이른 사람 승리. 창 마감 시 서버 내부 `resolve` 액션으로 확정(재생 가능). 오답 종은 창과 무관하게 즉시 벌칙.
- `wrongRingLockoutMs`: 오답 후 잠금(연타 방지). 잠금 중 종은 거부(벌칙 없음).
### 5.5 예시(3인 A·B·C)
1. A 뒤집기: 바나나 3. B 뒤집기: 딸기 2. C 뒤집기: 바나나 2 → 바나나 5.
2. B가 먼저 종(버전 3) → 정답. B가 세 공개 더미 + 종 밑 2장을 덱 아래로. B 차례로 다음 뒤집기.
3. 0.1초 뒤 A의 종(버전 3) 도착 → 버전이 4로 바뀌었으므로 "늦었어요", 벌칙 없음.
4. 이후 A가 바나나 4 상태에서 착각해 종 → 오답 → A가 B와 C에게 1장씩.
## 6. 승패와 점수 계산
- `lastStanding`: 마지막까지 남은 1명(모든 카드 보유) 승리. 순위는 탈락 역순.
- `official`: 참가자가 2명 남으면, 그다음 정답 종이 나올 때까지 진행하고 종료. 그 2명 상태에서 오답 종이 나오면 상대가 펼쳐진 카드 전부를 가져가고 즉시 종료. 종료 시 보유 카드가 많은 순. 처음부터 2인 게임이면 `official`도 `lastStanding`으로 진행한다(원작 문구대로면 첫 종에서 끝나 버리므로).
- `timeLimitMin` 만료: 즉시 종료, 보유 카드 수 많은 순.
- 보유 카드 수 = 덱 장수 + 자기 공개 더미 장수(아직 아무도 가져가지 않은 자기 카드). 종 밑 카드는 누구 것도 아님.
- 동점: 정답 종 횟수 많은 순 → 그래도 같으면 공동 순위.
- 방 기록용 점수: 1등 3점, 2등 2점, 3등 1점.
## 7. 지역 룰 / 하우스 룰 (옵션으로 켜고 끄기)
| 옵션 | 내용 | 기본값 |
|---|---|---|
| `duelWrongLoses` | 1대1 상황에서 오답 종을 친 사람이 즉시 패배(국내 하우스 룰) | off |
| `strikeOut` | 오답 3회 누적 시 몰수패 | off |
| `penaltyEscalation` | 오답마다 줄 장수가 1, 2, 3장...으로 증가 | off |
| `wrongToPot` | 오답 벌칙 카드를 다른 사람에게 주지 않고 종 밑으로 | off |
## 8. 엔진 설계
### 8.1 상태(State)
```ts
type Fruit = 'strawberry' | 'banana' | 'lime' | 'plum';
interface Card { id: number; fruit: Fruit; count: 1 | 2 | 3 | 4 | 5 } // id 0..55
interface HalliGalliState {
options: HalliGalliOptions;
rng: RngState;
order: PlayerId[];
decks: Record<PlayerId, number[]>; // 뒷면 덱, 맨 위 = 끝
faceUp: Record<PlayerId, number[]>; // 공개 더미, 맨 위 = 끝
pot: number[]; // 종 밑
active: PlayerId[]; // 참가 중(탈락 안 함)
eliminated: PlayerId[]; // 탈락 순서
turn: PlayerId;
turnDeadline: number;
flipAllowedAt: number; // flipGuard
pendingFlip: PlayerId | null; // 가드 중 들어온 뒤집기 예약
tableVersion: number;
lockedUntil: Record<PlayerId, number>;
wrongCount: Record<PlayerId, number>;
correctCount: Record<PlayerId, number>;
ringWindow: null | { version: number; openedAt: number; rings: { player: PlayerId; adjTs: number }[] };
phase: 'countdown' | 'playing' | 'ended';
startedAt: number;
endsAt: number | null; // timeLimit
lastBell?: { player: PlayerId; correct: boolean; fruit?: Fruit; at: number };
}
```
### 8.2 액션
액션에는 서버가 `serverTs`를 붙인다.
| type | payload | 누가 / 언제 | 검증 조건 |
|---|---|---|---|
| `flip` | `{}` | 차례인 플레이어, playing | `turn == actor`, 덱 1장 이상. `serverTs < flipAllowedAt`이면 거부하지 않고 `pendingFlip`에 예약 |
| `ring` | `{ version: number }` | 참가 중 플레이어(덱 0장 포함), playing | `serverTs >= lockedUntil[actor]`, `version == tableVersion`(다르면 "늦었어요"). 정답/오답 판정은 apply에서 |
| `tick` | `{}` | 서버 타이머 | 자동 뒤집기, 예약 뒤집기 실행, 시간 제한, 카운트다운 |
| `resolve` | `{}` | 서버 내부, `latencyComp=light` | 창 열림 |
### 8.3 공개/비공개 정보 (view)
- 모두(관전자 포함): 각 공개 더미 맨 위 카드(과일·개수), 공개 더미 장수, 각자 덱 장수, 종 밑 장수, 차례, 테이블 버전, 잠금 상태, RTT, 남은 시간, 탈락자.
- 비공개: 모든 덱의 카드 내용과 순서(본인 덱 포함 — 실제 게임에서도 자기 덱을 보지 않음), 종 밑 카드 내용, 공개 더미의 맨 위 아래 카드(가려져 있으므로 보낼 필요 없음).
- 자기 덱 내용을 본인에게도 보내지 않는 이유: 다음 카드를 미리 알면 뒤집기 타이밍을 조작할 수 있다.
- "지금 5개다"라는 판정 결과는 view에 넣지 않는다(`kidMode`의 과일 합계 보조 표시는 클라이언트가 공개 정보로 계산).
### 8.4 랜덤 요소 (시드 RNG 사용 지점)
덱 셔플, 좌석, 첫 플레이어. 이후 카드 이동은 모두 결정적(수거 순서 고정).
### 8.5 타이머·시간 초과·연결 끊김 시 자동 행동
- `activePlayers`: 참가 중 전원(종은 누구나), 뒤집기는 `turn`.
- `deadline`: `min(turnDeadline, flipAllowedAt(예약 있을 때), ringWindow 마감, endsAt)`.
- `onTimeout(player)`: 차례 플레이어의 `flip`. 종은 자동으로 치지 않는다.
- 연결 끊김: 그 사람 차례는 `flipSeconds` 후 자동 뒤집기로 계속 진행(카드를 모두 잃을 때까지 남아 있음). 60초 이상 끊기면 `flipSeconds`를 1초로 줄여 진행 속도 유지.
- `autoFlip` on이면 모든 뒤집기를 서버가 `flipSeconds` 간격으로 실행(`flip` 액션 거부).
### 8.6 종료 조건과 결과(GameResult)
```ts
interface HalliGalliResult {
endReason: 'lastStanding' | 'official' | 'timeLimit';
ranking: { rank: number; id: PlayerId; cards: number; correct: number; wrong: number }[];
summary: string; // 예: "도윤님이 56장을 모두 모아 우승!"
}
```
## 9. UI/UX
- 모바일 세로: 화면 가운데 큰 종 버튼(지름 화면 폭 40% 이상, 최소 120px) — 화면 어디를 쳐도 되게 하지 않고 종만 반응(오조작 방지). 종 주위에 플레이어별 공개 카드(최대 6장)를 원형 배치. 하단 내 덱(장수 표시)과 "뒤집기" 버튼(내 차례에만 활성, 큰 버튼). 상단 남은 시간, 순위.
- 차례 표시: 차례인 사람 카드 자리 테두리 깜빡임 + 남은 뒤집기 시간 원형 게이지.
- 종 피드백: 정답 시 "땡!" 효과음, 카드가 정답자에게 날아가는 애니메이션. 오답 시 "삐!" + 벌칙 카드 이동 애니메이션 + 잠금 원형 타이머. 늦음 시 작은 "늦었어요".
- 키보드(PC): 스페이스 = 종, 엔터 = 뒤집기.
- 어린이 모드: 느린 뒤집기, 과일별 합계 막대(공개 정보로 계산), "5개가 되면 종을 쳐요" 상시 안내.
- 효과음은 끄기 가능, 진동(지원 기기) 옵션.
## 10. 테스트 체크리스트
- [ ] 덱: 56장, 과일별 14장, 개수별 5/3/3/2/1장.
- [ ] 분배: 인원별 장수와 남는 카드가 4장 표와 일치, `pot`/`remove` 처리.
- [ ] 판정: 딸기 2+3 → 정답, 딸기 2+3+1 → 오답, 다른 과일 섞여도 한 과일이 정확히 5면 정답, 탈락자 공개 더미 맨 위도 합산.
- [ ] 정답 수거 순서 결정적(좌석 순, 종 밑 마지막), 정답자가 다음 차례.
- [ ] 동시 종: 같은 버전 정답 종 2개 → 먼저 도착한 사람만 수거, 두 번째는 버전 불일치로 "늦었어요" 무벌칙.
- [ ] 오답 벌칙: 4인에서 덱 2장인 사람 오답 → 시계 방향 2명에게만 1장씩 주고 0장 → 즉시 탈락.
- [ ] 덱 0장인 사람이 차례 전에 정답 종 → 카드 획득 후 계속 참가. 차례가 왔을 때 0장 → 탈락.
- [ ] `flipGuardMs`: 가드 중 들어온 flip은 예약되었다가 가드 종료 시 실행, 테이블 버전 1회만 증가.
- [ ] 뒤집기 시간 초과 → 자동 뒤집기. 연결 끊긴 사람도 계속 자동 진행.
- [ ] `official` 종료: 3인 → 2인 남은 뒤 첫 정답 종에서 종료, 2인 상태 오답 → 상대 수거 후 종료. 처음부터 2인이면 lastStanding처럼 진행.
- [ ] 시간 제한 만료: 보유 카드(덱 + 자기 공개 더미) 기준 순위, 동점은 정답 횟수.
- [ ] 비공개 누출 검사: 모든 view/이벤트에 덱 카드 내용(본인 포함), 종 밑 카드 내용, 다음 카드가 없는지 자동 검사.
- [ ] 같은 시드 + 액션 로그(serverTs 포함) 재생 시 동일 결과.
## 11. 참고 자료
- UltraBoardGames "How to play Halli Galli"(원작 규칙: 오답 벌칙, 탈락, 2인 종료 규칙) — https://www.ultraboardgames.com/halli-galli/game-rules.php
- 나무위키 "할리갈리"(인원별 분배와 남는 카드, 국내 하우스 룰) — https://namu.wiki/w/할리갈리
- BoardGameGeek "Halli Galli" — https://boardgamegeek.com/boardgame/2944/halli-galli
- Board Game Capital "How To Play Halli Galli"(과일별 카드 구성) — https://www.boardgamecapital.com/halli-galli-rules.htm
## 12. 메모 (상표·법적 주의 등)
- "Halli Galli / 할리갈리"는 Amigo Spiele의 상표이며 국내 유통은 코리아보드게임즈. 카드 그림도 저작물이므로 자체 제작. 표시 이름 후보: "과일 종치기", "다섯 개 땡!", "후르츠 벨".
- 도박 요소 없음.
- 종 버튼 연타는 터치스크린 기기 성능 차이(입력 지연)가 있으므로 경쟁 기록(랭킹)을 만들 경우 공정성 한계를 안내한다.