Files
joke-app/docs/games/rummikub.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

26 KiB
Raw Blame History

숫자 타일 게임 - 루미큐브 방식 (rummikub)

마일스톤: M4 · 인원: 최소 2 ~ 최대 4명 · 예상 시간: 약 30~60분 · 난이도: 보통

1. 개요

  • 1~13 숫자가 적힌 4색 타일로 "그룹"(같은 숫자, 다른 색)과 "런"(같은 색, 연속 숫자)을 만들어 내려놓는다. 자기 받침대(랙)의 타일을 가장 먼저 다 쓰면 이긴다.
  • 핵심 재미는 테이블 타일 재배치(manipulation)다. 이미 놓인 세트를 쪼개고 합치고 바꿔서 내 타일을 끼워 넣을 수 있다. 한국에서는 가족 보드게임 판매량 상위권이고, 노년층 두뇌 게임으로도 인기가 많다.
  • 인원 근거: 공식 규칙서 기본판 106타일은 24명이다. 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. 준비(셋업)

  1. 106개를 시드 RNG로 섞는다(뒷면 상태의 "풀").
  2. 선 플레이어 정하기(공식): 각자 1개씩 뽑아 숫자가 가장 큰 사람이 선이다. 조커를 뽑거나 최고 숫자가 같으면 그 사람들만 다시 뽑는다. 뽑은 타일은 풀에 돌려놓고 다시 섞는다. 화면에서는 뽑기 애니메이션으로 보여 주고, 엔진에서는 RNG로 같은 절차를 재현한다.
  3. 각자 14개를 받는다.
  4. 남은 타일이 풀이 된다(2인 78개, 3인 64개, 4인 50개).
  5. 진행은 선부터 시계 방향(왼쪽)으로 한다.

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개 이상 테이블에 내고, 턴이 끝날 때 테이블 전체가 유효한 세트로만 이루어져 있어야 한다.
  2. 뽑기: 풀에서 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개를 뽑는다.
  • 되돌리기: setDraft 5회 후 undoDraft 2회 → 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. 참고 자료

12. 메모 (상표·법적 주의 등)

  • "Rummikub"와 "루미큐브"는 M&M Ventures(Kodkod)의 등록 상표다. 국내 유통사도 있다. 화면, 방 목록, 도메인, 홍보에 상표명과 로고를 쓰지 않는다.
    • 표시 이름 후보(사용자가 선택): "숫자 타일", "숫자 맞추기 타일", "타일 놀이", "숫자 줄 세우기".
    • 문서와 코드 ID(rummikub)는 내부용이다. 외부 노출이 걱정되면 number-tiles 같은 중립 ID로 바꾸는 것을 검토한다.
  • 승리 시 "루미큐브!" 외침 대신 "다 냈다!" 같은 일반 문구를 쓴다.
  • 타일 디자인(둥근 숫자, 웃는 얼굴 조커 등 원작 고유 그래픽)을 따라 하지 않는다. 게임 규칙 자체는 저작권 보호 대상이 아니지만 규칙서 문장과 그림은 보호 대상이므로 직접 작성한다.
  • 돈, 칩을 쓰지 않는다. 점수는 방 안 결과 표시용이다.