- 개요, 조사(BGA·한국 인기 보드게임·UI/UX), 아키텍처, 실시간 프로토콜, 안정성·보안, 계정, 로비·방, UI/UX, 데이터 모델, 게임 엔진, 테스트, 배포·운영, 로드맵(M0~M9), 사용자 결정 항목 - 게임별 규칙·엔진 설계·UI·테스트 체크리스트 26종 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
26 KiB
26 KiB
숫자 타일 게임 - 루미큐브 방식 (rummikub)
마일스톤: M4 · 인원: 최소 2 ~ 최대 4명 · 예상 시간: 약 30~60분 · 난이도: 보통
1. 개요
- 1~13 숫자가 적힌 4색 타일로 "그룹"(같은 숫자, 다른 색)과 "런"(같은 색, 연속 숫자)을 만들어 내려놓는다. 자기 받침대(랙)의 타일을 가장 먼저 다 쓰면 이긴다.
- 핵심 재미는 테이블 타일 재배치(manipulation)다. 이미 놓인 세트를 쪼개고 합치고 바꿔서 내 타일을 끼워 넣을 수 있다. 한국에서는 가족 보드게임 판매량 상위권이고, 노년층 두뇌 게임으로도 인기가 많다.
- 인원 근거: 공식 규칙서 기본판 106타일은 2
4명이다. 56명은 160타일 확장판(XP)용이라 범위에서 뺀다.
2. 모드와 옵션
| 옵션 키 | 설명 | 선택지 | 기본값 |
|---|---|---|---|
initialMeldPoints |
첫 등록에 필요한 최소 점수 | 0(없음) / 20 / 30 / 50 | 30 |
turnSeconds |
턴 제한 시간(공식 1분) | 60 / 90 / 120 / 180 | 60 |
timeoutValidDraft |
시간 초과 때 초안이 유효한 경우 처리 | commit(자동 확정) / revert(공식대로 되돌리고 1장 뽑기) |
commit |
invalidTimeoutPenalty |
시간 초과 때 초안이 무효한 경우 뽑는 장수(공식 3) | 1 / 3 | 3 |
invalidCommitPenalty |
"완료"를 눌렀는데 무효일 때 처리 | reject(거부만, 계속 수정) / penalty3(되돌리고 3장 뽑고 턴 종료) |
reject |
jokerPenalty |
랙에 남은 조커 감점 | 30(공식) / 50(대회) | 30 |
initialMeldAllowExtend |
첫 등록 턴에 기존 테이블 세트에도 붙이기 허용 | on / off(공식) | off |
showDraftToOthers |
내가 고민 중인 초안을 다른 사람에게 실시간으로 보이기 | on / off | off |
gamesPerMatch |
한 매치의 판 수(공식은 인원수만큼 판 = 1라운드) | 1 / 인원수 / 2 / 3 / 4 | 1 |
hints |
"낼 수 있는 조합 찾기" 힌트 버튼 | on / off | on |
3. 구성물
- 숫자 타일 104개: 4색(검정 K, 빨강 R, 파랑 B, 주황 O) × 숫자 1~13 × 각 2개.
- 조커 2개. 조커는 어떤 색, 어떤 숫자든 대신할 수 있다.
- 합계 106개. 타일 ID 규칙 예:
R7a,R7b,JK1,JK2. - 타일 점수: 숫자 타일은 적힌 숫자, 조커는 테이블에서 대신하는 타일의 숫자. 게임 끝에 랙에 남은 조커는
jokerPenalty(30점)다.
4. 준비(셋업)
- 106개를 시드 RNG로 섞는다(뒷면 상태의 "풀").
- 선 플레이어 정하기(공식): 각자 1개씩 뽑아 숫자가 가장 큰 사람이 선이다. 조커를 뽑거나 최고 숫자가 같으면 그 사람들만 다시 뽑는다. 뽑은 타일은 풀에 돌려놓고 다시 섞는다. 화면에서는 뽑기 애니메이션으로 보여 주고, 엔진에서는 RNG로 같은 절차를 재현한다.
- 각자 14개를 받는다.
- 남은 타일이 풀이 된다(2인 78개, 3인 64개, 4인 50개).
- 진행은 선부터 시계 방향(왼쪽)으로 한다.
5. 진행 규칙
5.1 세트(조합) 정의
- 그룹: 같은 숫자, 서로 다른 색 3~4개. 예: 빨강7, 파랑7, 검정7. 같은 색이 두 개 들어가면 무효다.
- 런: 같은 색, 연속 숫자 3개 이상(최대 13개). 예: 파랑3, 파랑4, 파랑5, 파랑6.
- 1은 항상 가장 작은 수다. 13 다음에 1이 올 수 없다(12-13-1 불가).
- 조커는 세트에 필요한 아무 타일이나 대신한다. 한 세트에 조커 2개도 쓸 수 있다. 단 세트에 진짜 타일이 최소 1개 있어야 한다(조커 2개만으로는 3개 세트가 안 되므로 자연히 성립).
5.2 턴에 할 수 있는 일
내 차례에는 둘 중 하나를 한다.
- 내려놓기(완료): 랙에서 타일을 1개 이상 테이블에 내고, 턴이 끝날 때 테이블 전체가 유효한 세트로만 이루어져 있어야 한다.
- 뽑기: 풀에서 1개를 가져오고 턴이 끝난다. 낼 수 있어도 일부러 뽑을 수 있다. 방금 뽑은 타일은 이번 턴에 낼 수 없다(뽑으면 턴이 끝나므로 자연히 성립).
- 풀이 비어 있으면 "뽑기" 대신 "넘기기"가 된다.
5.3 첫 등록(initial meld)
- 아직 첫 등록을 하지 않은 플레이어는 자기 랙의 타일만으로 하나 이상의 새 세트를 만들어 내야 한다. 그 합이
initialMeldPoints(30) 이상이어야 한다. - 조커는 첫 등록에 쓸 수 있고, 대신하는 타일의 숫자로 계산한다.
- 첫 등록 턴에는 테이블의 기존 세트를 건드리거나 거기에 붙일 수 없다(공식).
initialMeldAllowExtend = on이면 30점을 랙 타일만의 새 세트로 채운 뒤라면 같은 턴에 기존 세트에 붙이기와 재배치도 허용한다. - 예: 랙 [빨강10, 빨강11, 빨강12] = 33점이면 가능하다. [파랑1, 파랑2, 파랑3] + [검정5, 빨강5, 주황5] = 6 + 15 = 21점이면 불가능하다. [조커, 검정9, 검정10]을 8-9-10으로 쓰면 27점이라 불가능하고, 9-10-11로 쓰면 30점이라 가능하다(5.5 조커 위치 해석 참고).
5.4 테이블 재배치(첫 등록을 한 뒤부터)
- 테이블의 어떤 세트든 쪼개고, 합치고, 타일을 옮기고, 끝에서 떼어 내고, 그룹의 4번째 타일을 빼 오는 등 마음대로 바꿀 수 있다.
- 조건
- 턴이 끝날 때 테이블의 모든 타일이 유효한 세트에 속해 있어야 한다(남는 낱개 타일 없음).
- 테이블에 있던 타일은 랙으로 가져갈 수 없다.
- 이번 턴에 랙에서 최소 1개를 내야 한다(재배치만 하고 끝낼 수 없음).
- 공식 규칙서 예시
- 파랑4-5-6에 파랑3 붙이기.
- 8 그룹(3색)에 남은 색 8 붙이기.
- 4가 4색인 그룹에서 파랑4를 빼서 랙의 파랑3, 파랑5, 파랑6과 런 만들기.
- 빨강4-5-6-7-8 런을 랙의 빨강6으로 4-5-6 / 6-7-8로 쪼개기.
5.5 조커 규칙
- 조커는 테이블에 놓인 뒤, 같은 역할의 타일로 바꿔 넣으면 회수할 수 있다.
- 런 안의 조커: 그 자리의 색·숫자 타일로 바꾼다.
- 3개짜리 그룹의 조커: 빠진 두 색 중 아무 색으로 바꿔도 된다.
- 회수한 조커는 같은 턴에 테이블에 다시 써야 하고, 랙으로 가져갈 수 없다. 이번 턴에 랙에서 최소 1개를 내야 하는 조건도 그대로다.
- 첫 등록 전에는 조커를 회수할 수 없다(첫 등록 전에는 재배치 자체가 불가).
- 엔진 구현 원칙: 조커 회수를 따로 액션으로 두지 않는다. "턴 종료 시점의 테이블 전체가 유효하고, 테이블 타일 집합 ⊇ 턴 시작 시점 테이블 타일 집합"만 검사하면 위 조건이 모두 자동으로 지켜진다. 조커가 다른 세트로 옮겨 가도 되고, 런 끝 조커를 떼어 새 세트에 써도 된다(공식 예시 3·4번과 같음).
- 단순화 메모: 공식 문구는 "회수한 조커는 새 세트를 만드는 데 써야 한다"이지만, 조커를 기존 세트로 옮기는 것도 허용한다. 엄격히 하려면 "턴 시작 때 테이블에 있던 조커는 턴 시작 때와 같은 세트 구성원 집합에 머물 수 없다" 같은 검사가 필요하지만, 실제 대국에서 차이가 거의 없어 기본은 생략한다.
- 조커 값 해석(점수·표시용): 서버가 각 세트를 정규화할 때 조커의 색·숫자를 정한다.
- 런: 클라이언트가 보낸 순서 그대로 유효하면 그 순서를 따른다. 아니면 정렬한 뒤 빈칸을 조커로 채우고, 남는 조커는 위쪽 끝(13 방향)에 먼저 붙이고 불가능하면 아래쪽 끝에 붙인다.
- 그룹: 숫자는 그룹의 숫자이고 색은 "빠진 색 중 하나"로 표시한다(정해지지 않음).
- 런과 그룹 둘 다 될 수 있는 세트(예: [빨강5, 조커, 조커])는 클라이언트의
as힌트를 따르고, 힌트가 없으면 런으로 해석한다.
5.6 초안(draft)과 되돌리기
- 내 턴 동안 테이블을 바꾸는 것은 서버에 있는 "초안"을 바꾸는 것이다. 초안은 턴을 확정하기 전까지 실제 테이블이 아니다.
- "되돌리기"는 초안을 바로 이전 단계로, "처음으로"는 턴 시작 상태로 돌린다. 서버가 초안 기록(최대 100단계)을 보관하므로 재접속해도 유지된다.
- 초안 중간 상태는 무효해도 된다(낱개 타일, 2개짜리 세트 등). 검증은 "완료"를 누를 때와 시간 초과 때만 한다.
5.7 시간 제한과 벌칙(공식 + 옵션)
- 턴 제한은
turnSeconds(공식 1분)다. - 시간 초과 때
- 초안이 턴 시작과 같으면 1개를 뽑고 턴이 끝난다(풀이 비었으면 넘기기).
- 초안이 유효하고 랙 타일이 1개 이상 들어 있으면
timeoutValidDraft = commit일 때 자동으로 확정한다.revert면 되돌리고 1개를 뽑는다. - 초안이 무효하면 테이블을 턴 시작 상태로 되돌리고, 낸 타일은 랙으로 돌아오며,
invalidTimeoutPenalty(공식 3)개를 뽑고 턴이 끝난다.
- "완료"를 눌렀는데 무효하면
invalidCommitPenalty = reject(기본)일 때 거부만 하고, 무효한 세트를 빨간색으로 표시한 뒤 계속 고칠 수 있게 한다. 타이머는 계속 간다.
5.8 풀이 다 떨어졌을 때(공식)
- 풀이 비어도 누군가 랙을 다 비우거나 더 이상 아무도 낼 수 없을 때까지 계속한다.
- 엔진 판정: 풀이 빈 상태에서 남아 있는 모든 플레이어가 연속으로 한 번씩 "넘기기"(또는 시간 초과로 내려놓지 못함)를 하면 게임이 끝난다.
6. 승패와 점수 계산
- 랙을 비운 경우(공식)
- 나머지 플레이어는 각자 랙 타일 숫자 합을 음수로 받는다(조커는
jokerPenalty). - 승자는 그 합의 절대값을 양수로 받는다. 한 판의 점수 합은 항상 0이다.
- 예: 4인에서 A가 끝냄. B 랙 [3, 12] = -15, C [조커] = -30, D [1, 1, 13] = -15. A = +60.
- 나머지 플레이어는 각자 랙 타일 숫자 합을 음수로 받는다(조커는
- 풀이 비고 아무도 못 내서 끝난 경우(공식)
- 랙 합이 가장 작은 사람이 승자다.
- 나머지는 (자기 랙 합 - 승자 랙 합)을 음수로 받고, 승자는 그 합의 절대값을 받는다.
- 예: 랙 합 A=5, B=12, C=30. A 승리. B = -(12-5) = -7, C = -(30-5) = -25, A = +32.
- 랙 합이 같은 최저자가 여럿이면 랙 타일 수가 적은 사람이 승자다. 그래도 같으면 공동 승자이고, 다른 사람의 음수 합을 공동 승자 수로 나누어 내림한 값을 나눠 갖는다(이 경우 판 합이 0이 아닐 수 있음, 결과 표시용).
- 여러 판(
gamesPerMatch> 1): 판별 점수를 더한다. 공식 최종 승자 결정은 "판을 가장 많이 이긴 사람"이고, 동률이면 총점이 높은 사람이다. - 순위: 1위는 승자, 나머지는 그 판 점수가 높은(감점이 적은) 순이다. 같으면 공동 순위다.
- 이 점수는 방 안 결과 표시용이고, 돈이나 칩이 아니다.
7. 지역 룰 / 하우스 룰 (옵션으로 켜고 끄기)
- 첫 등록 점수 변경(
initialMeldPoints). 어린이용 20점이나 0점(없음), 고수용 50점. - 조커 감점 50점(대회 룰,
jokerPenalty). - 첫 등록 턴 추가 재배치 허용(
initialMeldAllowExtend). - 시간 초과 때 유효 초안 자동 확정(
timeoutValidDraft). - 넉넉한 턴 시간(90~180초). 노년층과 어린이 방에 권장한다.
- 지원하지 않는 변형(문서화만): 13-1 이어 붙이기, 조커 회수를 첫 등록 전에도 허용, 5~6인 확장(160타일).
8. 엔진 설계
8.1 상태(State)
type TileColor = 'K' | 'R' | 'B' | 'O';
type Tile =
| { id: string; kind: 'num'; color: TileColor; n: number } // n: 1..13
| { id: string; kind: 'joker' };
interface TableSet {
id: string; // 세트 ID(애니메이션 연속성용, 서버가 발급)
tileIds: string[]; // 정규화된 순서(런은 오름차순)
kind: 'run' | 'group';
jokerAs: Record<string, { color: TileColor | null; n: number }>; // 조커 해석(표시·점수용)
}
interface DraftSet { tileIds: string[]; as?: 'run' | 'group' }
interface RummiState {
options: RummiOptions;
rng: RngState;
seats: PlayerId[];
tiles: Record<string, Tile>; // 전체 타일 정의(불변)
pool: string[]; // 비공개, 섞인 순서
racks: Record<PlayerId, string[]>; // 비공개
table: TableSet[]; // 공개, 확정된 테이블
hasMelded: Record<PlayerId, boolean>; // 첫 등록 여부(공개)
current: PlayerId;
turnStartedAt: number;
draft: { // 현재 턴 초안
sets: DraftSet[];
history: DraftSet[][]; // 되돌리기 스택(최대 100)
} | null;
consecutivePassesWithEmptyPool: number;
phase: 'playing' | 'finished';
endReason: 'rackEmpty' | 'stalemate' | null;
winnerIds: PlayerId[];
gameScores: Record<PlayerId, number>; // 이번 판 점수
match: { gameNo: number; wins: Record<PlayerId, number>; totals: Record<PlayerId, number> };
seq: number;
}
8.2 액션
| type | payload | 누가 / 언제 | 검증 조건 |
|---|---|---|---|
setDraft |
{ sets: DraftSet[] } |
current, playing |
초안 보존 검사만 한다(아래 C1~C3). 세트 유효성은 보지 않는다. 초당 10회 제한(서버 레벨). 현재 초안을 history에 넣는다 |
undoDraft |
{} |
current |
history가 비어 있지 않음 |
resetDraft |
{} |
current |
언제나 가능(초안을 턴 시작 테이블로) |
commit |
{} |
current |
아래 "턴 확정 검증" 통과. 실패하면 invalidCommitPenalty에 따름 |
draw |
{} |
current |
초안이 턴 시작과 같거나, 다르면 초안을 버리고 뽑는다(UI에서 확인 대화상자). 풀이 비었으면 pass와 같다 |
pass |
{} |
current |
풀이 비었을 때만 |
초안 보존 검사(setDraft)
- C1: 초안의 모든 타일 ID는 (턴 시작 테이블 타일 ∪ 내 랙 타일)에 속하고, 중복이 없다.
- C2: 턴 시작 테이블의 모든 타일이 초안에 들어 있다(테이블 → 랙 이동 금지).
- C3: 첫 등록 전(
hasMelded=false)이고initialMeldAllowExtend=off이면, 턴 시작 테이블의 세트들이 구성 그대로(같은 타일 집합) 초안에 있다.
턴 확정 검증(commit, 시간 초과 판정 공용)
function validateTurn(s: RummiState, p: PlayerId, draft: DraftSet[]): Result {
// 1) 보존: C1, C2 (위와 동일)
// 2) 랙에서 1개 이상 냄
const startTable = new Set(s.table.flatMap(t => t.tileIds));
const fromRack = draft.flatMap(d => d.tileIds).filter(id => !startTable.has(id));
if (fromRack.length === 0) return fail('랙에서 타일을 1개 이상 내야 해요');
// 3) 모든 세트가 유효(normalizeSet이 null이면 무효)
const normalized = draft.map(d => normalizeSet(d, s.tiles));
if (normalized.some(n => n === null)) return fail('올바르지 않은 조합이 있어요', { invalidSetIdx });
// 4) 첫 등록 조건
if (!s.hasMelded[p]) {
// 기존 세트는 그대로여야 하고(C3), 새 세트는 랙 타일로만 구성
const newSets = normalized.filter(n => n.tileIds.every(id => !startTable.has(id)));
const pts = sum(newSets.map(setPoints)); // 조커는 jokerAs.n
if (pts < s.options.initialMeldPoints) return fail(`첫 등록은 ${s.options.initialMeldPoints}점 이상이어야 해요 (지금 ${pts}점)`);
// initialMeldAllowExtend=off 이면: 기존 세트에 랙 타일이 섞인 세트가 없어야 함
}
return ok();
}
function normalizeSet(d: DraftSet, tiles): TableSet | null {
const ts = d.tileIds.map(id => tiles[id]);
if (ts.length < 3) return null;
const nums = ts.filter(t => t.kind === 'num');
const jokers = ts.length - nums.length;
const asGroup = () => {
if (ts.length > 4) return null;
if (new Set(nums.map(t => t.n)).size !== 1) return null;
if (new Set(nums.map(t => t.color)).size !== nums.length) return null; // 색 중복 금지
return /* kind:'group', jokerAs: {n, color:null} */;
};
const asRun = () => {
if (ts.length > 13) return null;
if (new Set(nums.map(t => t.color)).size !== 1) return null;
// (a) 클라이언트 순서 그대로 검사: 첫 숫자 타일의 index i, 값 v → start = v - i
// 모든 숫자 타일 k에 대해 n == start + k, 1 <= start, start+len-1 <= 13 이면 그 순서 채택
// (b) 아니면 정렬: 숫자 중복이면 null. span = max-min+1. gaps = span - nums.length
// gaps > jokers 면 null. extra = jokers - gaps.
// 위쪽으로 min(extra, 13-max)개, 나머지는 아래쪽(min-1 이상 남아야 함). 불가능하면 null
return /* kind:'run', 오름차순 tileIds, jokerAs 각 위치 값 */;
};
const g = asGroup(), r = asRun();
if (g && r) return d.as === 'group' ? g : r;
return g ?? r;
}
- 계산량: 테이블은 최대 106타일이고 세트 하나마다 O(k log k)이므로 매 검증이 1ms 미만이다.
commit 성공 시 적용
table = normalized(세트 ID는 기존 세트와 타일이 가장 많이 겹치는 것을 이어받아 애니메이션을 자연스럽게 한다).- 랙에서
fromRack을 뺀다. 첫 등록이면hasMelded = true로 바꾼다.consecutivePassesWithEmptyPool = 0. - 랙이 비면 게임 종료(
rackEmpty). 아니면 다음 플레이어로 넘긴다.
reason 예: "첫 등록은 30점 이상이어야 해요 (지금 21점)", "첫 등록 전에는 테이블의 타일을 옮길 수 없어요", "테이블의 타일은 내 받침대로 가져올 수 없어요", "빨간 표시된 조합을 고쳐 주세요".
8.3 공개/비공개 정보 (view)
- 모두 공개(관전자 포함): 확정 테이블(
table), 각 플레이어 랙 타일 수, 첫 등록 여부, 풀 남은 수, 현재 턴, 마감 시각, 매치 점수. - 본인만: 자기 랙 타일 전체.
- 초안
- 현재 턴 플레이어는 자기 초안 전체와 각 세트의 유효 여부(실시간 검증 결과: 초록/빨강), 현재 첫 등록 점수 합계를 본다.
- 다른 사람과 관전자는
showDraftToOthers = off(기본)이면 "생각 중" 표시와 초안 변경 횟수만 받는다.on이면 초안 테이블 전체를 받는다. 이 경우 랙에서 꺼낸 타일이 보이는데, 실제 게임에서도 테이블에 올린 타일은 모두가 보므로 허용한다. 되돌려도 이미 본 정보는 남는다는 점을 옵션 설명에 적는다.
- 절대 보내지 않는 것: 풀 순서, 남의 랙, RNG 상태. 뽑기 이벤트는 본인에게만 타일 내용을 주고, 다른 사람에게는 "1개 뽑음"만 준다. 시간 초과 벌칙으로 되돌아간 타일 목록도 본인에게만 준다.
8.4 랜덤 요소 (시드 RNG 사용 지점)
setup: 선 정하기 시뮬레이션(뽑기와 재뽑기), 106타일 섞기, 분배.- 매치의 다음 판
setup: 새로 섞고 선을 다시 정한다(공식). - 그 외 진행 중에는 RNG를 쓰지 않는다(뽑기는 섞인 풀의 앞에서부터).
8.5 타이머·시간 초과·연결 끊김 시 자동 행동
deadline = turnStartedAt + turnSeconds*1000.onTimeout(state, p)는 내부 액션timeout을 돌려준다.apply에서 5.7의 규칙대로 처리한다.- 초안 변경 없음 → 1개 뽑기 또는 넘기기.
- 유효 초안 →
commit또는 되돌리고 1개 뽑기. - 무효 초안 → 되돌리고
invalidTimeoutPenalty개 뽑기(풀에 남은 만큼만).
- 연결이 끊겨도 초안은 서버에 남는다. 다시 들어오면 그대로 이어서 한다. 타이머는 멈추지 않는다.
- 끊긴 플레이어가 계속 돌아오지 않으면(서버 정책: 연속 3턴 타임아웃) 이후 그 플레이어의 턴은 즉시
draw로 처리한다. 엔진은deadline을 짧게 돌려주기만 하면 된다.
8.6 종료 조건과 결과(GameResult)
- 누군가
commit으로 랙을 비움 →rackEmpty. - 풀이 빈 상태에서 연속 넘기기 수가 진행 인원 수에 도달 →
stalemate.
interface GameResult {
ranking: { player: PlayerId; rank: number; score: number; rackValue: number; tilesLeft: number }[];
winner: PlayerId[];
endReason: 'rackEmpty' | 'stalemate';
match?: { gameNo: number; wins: Record<PlayerId, number>; totals: Record<PlayerId, number>; finished: boolean };
summary: string; // "영희 님이 타일을 모두 내려놓았어요! (+60점)"
}
9. UI/UX
- 모바일 세로 화면
- 위: 상대 정보(이름, 남은 타일 수, 첫 등록 표시, 타이머 링).
- 가운데: 테이블. 세트를 줄 단위로 놓고 핀치 줌과 스크롤을 지원한다. 세트 사이에 "새 세트" 빈 칸이 있다.
- 아래: 내 랙 2줄. 타일 최소 44×60px이고 탭 영역은 48px 이상.
- 하단 고정 버튼: [정렬: 색별 | 숫자별] [되돌리기] [처음으로] [뽑기 / 완료]
- PC: 테이블이 넓고 랙은 아래 1~2줄이다.
- 조작
- 드래그 앤 드롭: 랙 ↔ 테이블, 테이블 세트 ↔ 세트, 세트 끝·중간에 끼우기, 세트 바깥에 놓으면 새 세트.
- 탭 두 번 방식: 타일을 탭해서 선택하고 놓을 곳을 탭한다. 드래그가 어려운 노년층을 위한 방식이고, 둘 다 지원한다.
- 여러 개 선택: 길게 눌러 여러 타일을 고르고 한꺼번에 옮긴다.
- 자동 정렬
- "색별": 색(검정·빨강·파랑·주황) 다음 숫자 순.
- "숫자별": 숫자 다음 색 순. 조커는 맨 끝.
- 랙 배치 순서는 클라이언트 로컬 상태이고, 서버는 순서를 저장하지 않는다.
- 실시간 검증 표시: 초안의 각 세트에 초록 테두리(유효) 또는 빨강 테두리(무효)와 짧은 이유("같은 색이 두 개예요")를 보여 준다. 첫 등록 전에는 "첫 등록: 21 / 30점" 진행 막대를 보여 준다.
- "완료" 버튼은 초안이 유효할 때만 강조색으로 바뀐다(무효일 때도 누를 수는 있고, 누르면 이유를 보여 준다).
- 힌트(
hints): "도와줘" 버튼을 누르면 랙에서 바로 낼 수 있는 새 세트 1개를 하이라이트한다. 재배치 탐색은 하지 않는다(계산량 제한). 클라이언트에서 자기 랙 정보만으로 계산하므로 서버 비공개 정보가 필요 없다. - 애니메이션: 타일 이동 200ms, 세트 확정 때 반짝임, 다른 사람의 확정 때 바뀐 세트를 1초 동안 강조해서 무엇이 바뀌었는지 보이게 한다.
- 색 구분: 숫자 아래에 색별 모양(검정 ●, 빨강 ♥, 파랑 ◆, 주황 ★)을 넣어 색약 사용자도 구별할 수 있게 한다.
- 초보 도움말: 그룹과 런 그림 예시, 재배치 예시 애니메이션 4종(공식 규칙서의 예와 같은 상황).
10. 테스트 체크리스트
- 셋업: 106타일(숫자 104 + 조커 2), 각자 14개, 풀 크기(2인 78 / 3인 64 / 4인 50). 같은 시드면 같은 결과다.
- 세트 판정: [빨강12, 빨강13, 빨강1]은 무효. [파랑5, 빨강5, 파랑5]는 무효(색 중복). 5개짜리 그룹은 무효. [빨강1, 빨강2]는 무효(2개).
- 조커 해석: [조커, 검정9, 검정10]을 이 순서로 보내면 8-9-10(27점)이라 첫 등록이 거부된다. [검정9, 검정10, 조커]로 보내면 9-10-11(30점)이라 허용된다.
- [빨강12, 빨강13, 조커]는 위쪽으로 붙일 수 없으므로 11-12-13으로 해석된다(유효).
- 첫 등록 전 플레이어가 테이블 세트에 랙 타일을 붙이면 거부된다. 테이블 세트를 쪼개는 초안은
setDraft(C3)에서 거부된다. - 재배치: 테이블 [빨강4..8] + 랙 빨강6 → [빨강4,5,6] [빨강6,7,8] 확정이 성공한다.
- 조커 회수: 테이블 [파랑5, 조커, 파랑7] + 랙 [파랑6, 검정3, 빨강3] → [파랑5,6,7] [검정3, 빨강3, 조커] 성공. 조커가 테이블에서 사라지는(랙으로 가는) 초안은 C2로 거부된다.
- 랙 타일 없이 재배치만 하고
commit하면 "랙에서 타일을 1개 이상 내야 해요"로 거부된다. - 시간 초과: 무효 초안 상태에서 마감이 지나면 테이블이 턴 시작 상태로 복구되고 랙도 원래대로 돌아온 뒤 3개를 뽑는다. 풀에 2개만 있으면 2개만 뽑는다.
- 시간 초과: 유효 초안이면(
commit옵션) 자동 확정된다. 변경이 없으면 1개를 뽑는다. - 되돌리기:
setDraft5회 후undoDraft2회 → 3번째 초안 상태.resetDraft→ 턴 시작 상태. 재접속해도 초안이 유지된다. - 점수: 4인 예시(B -15, C 조커 -30, D -15, A +60). 교착 종료 예시(A +32, B -7, C -25).
- 교착 종료: 풀이 빈 3인 게임에서 3명이 연속 넘기면 종료된다. 중간에 누가
commit하면 카운트가 0으로 초기화된다. - 정보 유출: 관전자와 상대 view에 남의 랙 타일 ID·숫자, 풀 내용이 없다.
showDraftToOthers=off이면 초안 타일도 없다. 뽑기 이벤트에 타일 내용이 본인에게만 있다. - 동시성: 같은
commit패킷이 두 번 오면 두 번째는 "차례가 아니에요"로 거부된다. 턴이 넘어간 뒤 도착한 이전 턴setDraft도 거부된다.
11. 참고 자료
- Rummikub 공식 영문 규칙서(2600-English): https://rummikub.com/wp-content/uploads/2019/12/2600-English-1.pdf (106타일, 14개 분배, 첫 등록 30점, 조커 회수, 1분 제한, 미완성 시 3개 벌칙, 풀 소진 처리, 점수 계산, 조커 30점)
- Wikipedia "Rummikub": https://en.wikipedia.org/wiki/Rummikub
- 나무위키 "루미큐브": https://namu.wiki/w/루미큐브 (조커 감점 30점 공식 / 50점 대회 룰, 2~4명)
12. 메모 (상표·법적 주의 등)
- "Rummikub"와 "루미큐브"는 M&M Ventures(Kodkod)의 등록 상표다. 국내 유통사도 있다. 화면, 방 목록, 도메인, 홍보에 상표명과 로고를 쓰지 않는다.
- 표시 이름 후보(사용자가 선택): "숫자 타일", "숫자 맞추기 타일", "타일 놀이", "숫자 줄 세우기".
- 문서와 코드 ID(
rummikub)는 내부용이다. 외부 노출이 걱정되면number-tiles같은 중립 ID로 바꾸는 것을 검토한다.
- 승리 시 "루미큐브!" 외침 대신 "다 냈다!" 같은 일반 문구를 쓴다.
- 타일 디자인(둥근 숫자, 웃는 얼굴 조커 등 원작 고유 그래픽)을 따라 하지 않는다. 게임 규칙 자체는 저작권 보호 대상이 아니지만 규칙서 문장과 그림은 보호 대상이므로 직접 작성한다.
- 돈, 칩을 쓰지 않는다. 점수는 방 안 결과 표시용이다.