게임 4개 원래 이름으로, .5 + NPMplus 배포 구성

- 보석 상인 → 스플렌더, 벽 쌓기 길찾기 → 쿼리도, 숫자 타일 → 루미큐브, 주사위 땅따먹기 → 부루마불
- deploy/docker-compose.proxy.yml: Caddy 없이 앱만(.5:2970), HTTPS·헤더는 NPMplus
- deploy/npmplus-advanced.conf: Caddyfile과 같은 보안 헤더
- 문서: 2026-10-06 결정, 배포 7절 (e2e 구십구 0 카드 방향 버튼 반영 포함)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
EJClaw
2026-10-06 22:16:32 +09:00
parent 7da06d1b27
commit 6390e3e64c
45 changed files with 143 additions and 78 deletions

View File

@@ -29,6 +29,7 @@ bun scripts/loadtest.ts --help # 부하·장애 주입 (docs/reports 참고)
```bash ```bash
cd deploy && docker compose up -d --build cd deploy && docker compose up -d --build
``` ```
이미 HTTPS 리버스 프록시가 있는 서버(지금 운영 중인 .5 + NPMplus)에서는 `docker compose -f docker-compose.proxy.yml up -d --build`로 앱만 띄운다.
자세한 절차는 [docs/11-deployment-ops.md](docs/11-deployment-ops.md). 자세한 절차는 [docs/11-deployment-ops.md](docs/11-deployment-ops.md).
## 관리자 ## 관리자

View File

@@ -35,7 +35,7 @@ describe.each(IDS)('%s', (id) => {
const { helpNow } = await import(`./${id}/help`); const { helpNow } = await import(`./${id}/help`);
const def = GAMES[id]!; const def = GAMES[id]!;
const range = def.playersFor?.(def.defaultOptions) ?? { min: def.minPlayers, max: def.maxPlayers }; const range = def.playersFor?.(def.defaultOptions) ?? { min: def.minPlayers, max: def.maxPlayers };
// 3 players where allowed; otherwise the nearest count the game accepts (e.g. 벽 쌓기 길찾기: 2 or 4). // 3 players where allowed; otherwise the nearest count the game accepts (e.g. 쿼리도: 2 or 4).
const want = Math.min(range.max, Math.max(range.min, 3)); const want = Math.min(range.max, Math.max(range.min, 3));
const counts = Array.from({ length: range.max - range.min + 1 }, (_, i) => range.min + i).sort((a, b) => Math.abs(a - want) - Math.abs(b - want) || a - b); const counts = Array.from({ length: range.max - range.min + 1 }, (_, i) => range.min + i).sort((a, b) => Math.abs(a - want) - Math.abs(b - want) || a - b);
const n = counts.find((c) => !def.playerCountError?.(def.defaultOptions, c)) ?? want; const n = counts.find((c) => !def.playerCountError?.(def.defaultOptions, c)) ?? want;

View File

@@ -15,7 +15,7 @@ function fresh(opts: Record<string, unknown> = {}) {
return state; return state;
} }
test('땅따먹기: 굴리기 차례 / 상대 차례 / 관전', () => { test('부루마불: 굴리기 차례 / 상대 차례 / 관전', () => {
const s = fresh(); const s = fresh();
const a = helpNow(view(s, 'A'), 'A', users); const a = helpNow(view(s, 'A'), 'A', users);
expect(a).toContain('[🎲 주사위 굴리기]'); expect(a).toContain('[🎲 주사위 굴리기]');
@@ -25,7 +25,7 @@ test('땅따먹기: 굴리기 차례 / 상대 차례 / 관전', () => {
expect(helpNow(view(s, null), null, users)).toContain('호랑이님 차례예요(1/20라운드).'); expect(helpNow(view(s, null), null, users)).toContain('호랑이님 차례예요(1/20라운드).');
}); });
test('땅따먹기: 빈 땅에 서면 사기 / 내 땅에 다시 오면 짓기', () => { test('부루마불: 빈 땅에 서면 사기 / 내 땅에 다시 오면 짓기', () => {
let s = fresh(); let s = fresh();
s = applyAction('marble', s, 'A', { type: 'ROLL' }, dice(2, 4)); // → 6 방콕 s = applyAction('marble', s, 'A', { type: 'ROLL' }, dice(2, 4)); // → 6 방콕
const t = helpNow(view(s, 'A'), 'A', users); const t = helpNow(view(s, 'A'), 'A', users);
@@ -40,7 +40,7 @@ test('땅따먹기: 빈 땅에 서면 사기 / 내 땅에 다시 오면 짓기',
expect(u).toContain('[짓기]'); expect(u).toContain('[짓기]');
}); });
test('땅따먹기: 무인도, 더블, 우주여행, 모자란 포인트', () => { test('부루마불: 무인도, 더블, 우주여행, 모자란 포인트', () => {
let s = fresh(); let s = fresh();
s.players.A.pos = 10; s.players.A.pos = 10;
s.players.A.islandLeft = 2; s.players.A.islandLeft = 2;

View File

@@ -1,4 +1,4 @@
/** 주사위 땅따먹기 [?] "지금 할 수 있는 것" (docs/07 §5). Pure: only reads the client view (+ the pure board data). */ /** 부루마불 [?] "지금 할 수 있는 것" (docs/07 §5). Pure: only reads the client view (+ the pure board data). */
import type { MarbleView } from '@bg/games/marble'; import type { MarbleView } from '@bg/games/marble';
import { SPACES } from '@bg/games/marble/board'; import { SPACES } from '@bg/games/marble/board';
import type { PublicUser } from '@bg/shared/lite'; import type { PublicUser } from '@bg/shared/lite';

View File

@@ -1,4 +1,4 @@
/** 주사위 땅따먹기 화면 (docs/games/marble.md §9). Board, dice, tokens and buildings are drawn here (SVG/CSS only). */ /** 부루마불 화면 (docs/games/marble.md §9). Board, dice, tokens and buildings are drawn here (SVG/CSS only). */
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import type { MarbleOptions, MarbleOwnedView, MarblePlayerView, MarbleView } from '@bg/games/marble'; import type { MarbleOptions, MarbleOwnedView, MarblePlayerView, MarbleView } from '@bg/games/marble';
import { CARDS, SPACES, type CardId, type Level } from '@bg/games/marble/board'; import { CARDS, SPACES, type CardId, type Level } from '@bg/games/marble/board';
@@ -428,7 +428,7 @@ function MarbleBoard({ view: v, me, players, act, pending }: BoardProps<MarbleVi
<div className="mb-wrap"> <div className="mb-wrap">
<style>{CSS}</style> <style>{CSS}</style>
<div className="mb-box"> <div className="mb-box">
<svg className="mb-svg" viewBox="0 0 1000 1000" role="group" aria-label={`땅따먹기 판. 출발은 오른쪽 아래이고, 말은 시계 방향으로 돌아요. 방향키로 칸을 옮기고 Enter로 칸 정보를 봐요.${spaceMode ? ' 우주여행으로 갈 칸을 골라 주세요.' : ''}`}> <svg className="mb-svg" viewBox="0 0 1000 1000" role="group" aria-label={`부루마불 판. 출발은 오른쪽 아래이고, 말은 시계 방향으로 돌아요. 방향키로 칸을 옮기고 Enter로 칸 정보를 봐요.${spaceMode ? ' 우주여행으로 갈 칸을 골라 주세요.' : ''}`}>
{CELLS.map((c, pos) => { {CELLS.map((c, pos) => {
const sp = SPACES[pos]!; const sp = SPACES[pos]!;
const o = ownedAt.get(pos); const o = ownedAt.get(pos);
@@ -583,7 +583,7 @@ function Center({
return ( return (
<> <>
<div className="mb-title" aria-hidden> <div className="mb-title" aria-hidden>
주사위 땅따먹기 부루마불
</div> </div>
<div className="mb-meta"> <div className="mb-meta">
<span className="mb-pill">{v.maxRounds > 0 ? `${v.round} / ${v.maxRounds} 라운드` : `${v.round} 라운드`}</span> <span className="mb-pill">{v.maxRounds > 0 ? `${v.round} / ${v.maxRounds} 라운드` : `${v.round} 라운드`}</span>

View File

@@ -1,4 +1,4 @@
/** 주사위 땅따먹기 30초 규칙 요약 (docs/07 §5). */ /** 부루마불 30초 규칙 요약 (docs/07 §5). */
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import type { QuickCard } from '../types'; import type { QuickCard } from '../types';

View File

@@ -7,7 +7,7 @@ const users = usersOf(['A', 'B', 'C', 'D']);
const view = (s: unknown, who: string | null) => GAMES.quoridor!.view(s, who) as any; const view = (s: unknown, who: string | null) => GAMES.quoridor!.view(s, who) as any;
const seatOf = (s: any, id: string) => s.seats.find((x: any) => x.playerId === id); const seatOf = (s: any, id: string) => s.seats.find((x: any) => x.playerId === id);
test('벽 쌓기 길찾기: 내 차례 / 상대 차례 / 관전', () => { test('쿼리도: 내 차례 / 상대 차례 / 관전', () => {
// joinOrder: A=남(빨강, 먼저), B=북(파랑). // joinOrder: A=남(빨강, 먼저), B=북(파랑).
const { state } = setupGame('quoridor', 2, { seatOrder: 'joinOrder' }); const { state } = setupGame('quoridor', 2, { seatOrder: 'joinOrder' });
const mine = helpNow(view(state, 'A'), 'A', users); const mine = helpNow(view(state, 'A'), 'A', users);
@@ -23,7 +23,7 @@ test('벽 쌓기 길찾기: 내 차례 / 상대 차례 / 관전', () => {
expect(spec).toContain('고양이님(파랑) 8칸'); expect(spec).toContain('고양이님(파랑) 8칸');
}); });
test('벽 쌓기 길찾기: 벽을 다 쓰면 말만, 뛰어넘기 안내', () => { test('쿼리도: 벽을 다 쓰면 말만, 뛰어넘기 안내', () => {
const { state } = setupGame('quoridor', 2, { seatOrder: 'joinOrder' }); const { state } = setupGame('quoridor', 2, { seatOrder: 'joinOrder' });
seatOf(state, 'A').wallsLeft = 0; seatOf(state, 'A').wallsLeft = 0;
seatOf(state, 'A').pos = { c: 4, r: 3 }; seatOf(state, 'A').pos = { c: 4, r: 3 };
@@ -33,7 +33,7 @@ test('벽 쌓기 길찾기: 벽을 다 쓰면 말만, 뛰어넘기 안내', () =
expect(t).toContain('뛰어넘는 칸'); expect(t).toContain('뛰어넘는 칸');
}); });
test('벽 쌓기 길찾기: 무르기 요청과 무승부 제안', () => { test('쿼리도: 무르기 요청과 무승부 제안', () => {
const { state } = setupGame('quoridor', 2, { seatOrder: 'joinOrder' }); const { state } = setupGame('quoridor', 2, { seatOrder: 'joinOrder' });
let s = applyAction('quoridor', state, 'A', { type: 'move', to: { c: 4, r: 1 } }); let s = applyAction('quoridor', state, 'A', { type: 'move', to: { c: 4, r: 1 } });
s = applyAction('quoridor', s, 'A', { type: 'requestUndo' }); s = applyAction('quoridor', s, 'A', { type: 'requestUndo' });
@@ -46,7 +46,7 @@ test('벽 쌓기 길찾기: 무르기 요청과 무승부 제안', () => {
expect(helpNow(view(s, 'A'), 'A', users)).toContain('무승부'); expect(helpNow(view(s, 'A'), 'A', users)).toContain('무승부');
}); });
test('벽 쌓기 길찾기: 4명, 도착하면 끝', () => { test('쿼리도: 4명, 도착하면 끝', () => {
const { state } = setupGame('quoridor', 4, { seatOrder: 'joinOrder' }); const { state } = setupGame('quoridor', 4, { seatOrder: 'joinOrder' });
const t = helpNow(view(state, 'A'), 'A', users); const t = helpNow(view(state, 'A'), 'A', users);
expect(t).toContain('남은 벽 5개'); expect(t).toContain('남은 벽 5개');

View File

@@ -1,4 +1,4 @@
/** 벽 쌓기 길찾기 [?] "지금 할 수 있는 것" (docs/07 §5). Pure: only reads the client view. */ /** 쿼리도 [?] "지금 할 수 있는 것" (docs/07 §5). Pure: only reads the client view. */
import type { QuoridorSeatView, QuoridorView, Seat } from '@bg/games/quoridor'; import type { QuoridorSeatView, QuoridorView, Seat } from '@bg/games/quoridor';
import type { PublicUser } from '@bg/shared/lite'; import type { PublicUser } from '@bg/shared/lite';

View File

@@ -1,4 +1,4 @@
/** 벽 쌓기 길찾기 화면 (docs/games/quoridor.md §9). 판은 내 출발 쪽이 아래로 오도록 돌려서 그린다. */ /** 쿼리도 화면 (docs/games/quoridor.md §9). 판은 내 출발 쪽이 아래로 오도록 돌려서 그린다. */
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import type { Cell, QuoridorLegalWall, QuoridorMove, QuoridorOptions, QuoridorSeatView, QuoridorView, Seat, Wall } from '@bg/games/quoridor'; import type { Cell, QuoridorLegalWall, QuoridorMove, QuoridorOptions, QuoridorSeatView, QuoridorView, Seat, Wall } from '@bg/games/quoridor';
import type { PublicUser } from '@bg/shared/lite'; import type { PublicUser } from '@bg/shared/lite';
@@ -370,7 +370,7 @@ function QuoridorBoard({ view: v, players, act, pending, tapConfirm }: BoardProp
}} }}
tabIndex={0} tabIndex={0}
role="grid" role="grid"
aria-label={`벽 쌓기 길찾기 판 9×9, 지금은 ${mode === 'wall' ? '벽 세우기' : '말 움직이기'}. 방향키로 고르고 Enter로 정해요. W는 모드 바꾸기, R은 벽 방향 바꾸기예요.${myGoal ? ` 내 목표는 ${myGoal} 줄이에요.` : ''}`} aria-label={`쿼리도 판 9×9, 지금은 ${mode === 'wall' ? '벽 세우기' : '말 움직이기'}. 방향키로 고르고 Enter로 정해요. W는 모드 바꾸기, R은 벽 방향 바꾸기예요.${myGoal ? ` 내 목표는 ${myGoal} 줄이에요.` : ''}`}
onKeyDown={onKey} onKeyDown={onKey}
> >
{goalSides.map((g, k) => { {goalSides.map((g, k) => {

View File

@@ -1,4 +1,4 @@
/** 벽 쌓기 길찾기 30초 규칙 요약 (docs/07 §5). A small corner of the board, drawn inline. */ /** 쿼리도 30초 규칙 요약 (docs/07 §5). A small corner of the board, drawn inline. */
import type { QuickCard } from '../types'; import type { QuickCard } from '../types';
type Pawn = [number, number, string]; type Pawn = [number, number, string];

View File

@@ -16,7 +16,7 @@ function game(options: Record<string, unknown> = {}, rack?: string[]) {
return { state, cur, other }; return { state, cur, other };
} }
test('숫자 타일: 내 차례(첫 등록 전) / 상대 차례 / 관전', () => { test('루미큐브: 내 차례(첫 등록 전) / 상대 차례 / 관전', () => {
const { state, cur, other } = game(); const { state, cur, other } = game();
const mine = helpNow(view(state, cur), cur, users); const mine = helpNow(view(state, cur), cur, users);
expect(mine).toContain('내 차례예요'); expect(mine).toContain('내 차례예요');
@@ -34,7 +34,7 @@ test('숫자 타일: 내 차례(첫 등록 전) / 상대 차례 / 관전', () =>
expect(spec).not.toContain('내 받침대'); expect(spec).not.toContain('내 받침대');
}); });
test('숫자 타일: 방 옵션 반영(첫 등록 없음, 힌트 끔)', () => { test('루미큐브: 방 옵션 반영(첫 등록 없음, 힌트 끔)', () => {
const { state, cur } = game({ initialMeldPoints: 0, hints: false }); const { state, cur } = game({ initialMeldPoints: 0, hints: false });
const t = helpNow(view(state, cur), cur, users); const t = helpNow(view(state, cur), cur, users);
expect(t).not.toContain('첫 등록'); expect(t).not.toContain('첫 등록');
@@ -42,7 +42,7 @@ test('숫자 타일: 방 옵션 반영(첫 등록 없음, 힌트 끔)', () => {
expect(t).toContain('바닥 묶음을 쪼개고 합쳐'); expect(t).toContain('바닥 묶음을 쪼개고 합쳐');
}); });
test('숫자 타일: 틀린 배치 / 올바른 배치 / 조커', () => { test('루미큐브: 틀린 배치 / 올바른 배치 / 조커', () => {
const rack = ['R10a', 'R11a', 'R12a', 'K1a', 'JK1']; const rack = ['R10a', 'R11a', 'R12a', 'K1a', 'JK1'];
const { state, cur } = game({}, rack); const { state, cur } = game({}, rack);
const bad = applyAction('rummikub', state, cur, { type: 'setDraft', sets: [{ tileIds: ['K1a'] }] }); const bad = applyAction('rummikub', state, cur, { type: 'setDraft', sets: [{ tileIds: ['K1a'] }] });
@@ -63,7 +63,7 @@ test('숫자 타일: 틀린 배치 / 올바른 배치 / 조커', () => {
expect(t3).toContain('1장을 가져와요'); expect(t3).toContain('1장을 가져와요');
}); });
test('숫자 타일: 판 끝(다음 판 시작) / 게임 끝', () => { test('루미큐브: 판 끝(다음 판 시작) / 게임 끝', () => {
const rack = ['R10a', 'R11a', 'R12a']; const rack = ['R10a', 'R11a', 'R12a'];
const m = game({ gamesPerMatch: '2' }, rack); const m = game({ gamesPerMatch: '2' }, rack);
let s = applyAction('rummikub', m.state, m.cur, { type: 'setDraft', sets: [{ tileIds: rack }] }); let s = applyAction('rummikub', m.state, m.cur, { type: 'setDraft', sets: [{ tileIds: rack }] });

View File

@@ -1,4 +1,4 @@
/** 숫자 타일 [?] "지금 할 수 있는 것" (docs/07 §5). Pure: only reads the client view. */ /** 루미큐브 [?] "지금 할 수 있는 것" (docs/07 §5). Pure: only reads the client view. */
import type { RummikubView } from '@bg/games/rummikub'; import type { RummikubView } from '@bg/games/rummikub';
import type { PublicUser } from '@bg/shared/lite'; import type { PublicUser } from '@bg/shared/lite';

View File

@@ -1,4 +1,4 @@
/** 숫자 타일 화면 (docs/games/rummikub.md §9). Tap a tile to pick it up, then tap where it goes. */ /** 루미큐브 화면 (docs/games/rummikub.md §9). Tap a tile to pick it up, then tap where it goes. */
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
// Pure tile/set helpers only (no zod): keeps this lazy chunk small. // Pure tile/set helpers only (no zod): keeps this lazy chunk small.
import { findMeld, normalizeSet, pickDisjoint, setProblem, sortTiles, tileLabel, tileOf, type DraftSet, type JokerAs, type TileColor } from '@bg/games/rummikub/rules'; import { findMeld, normalizeSet, pickDisjoint, setProblem, sortTiles, tileLabel, tileOf, type DraftSet, type JokerAs, type TileColor } from '@bg/games/rummikub/rules';
@@ -605,7 +605,7 @@ function RummikubSide({ view: v, me, players }: BoardProps<RummikubView>) {
<style>{CSS}</style> <style>{CSS}</style>
<div className="card stack" style={{ gap: 6, padding: 12 }}> <div className="card stack" style={{ gap: 6, padding: 12 }}>
<div className="row" style={{ justifyContent: 'space-between' }}> <div className="row" style={{ justifyContent: 'space-between' }}>
<strong>{v.phase === 'finished' ? '게임 끝' : v.match.total > 1 ? `${v.match.gameNo}판 / ${v.match.total}판` : '숫자 타일'}</strong> <strong>{v.phase === 'finished' ? '게임 끝' : v.match.total > 1 ? `${v.match.gameNo}판 / ${v.match.total}판` : '루미큐브'}</strong>
<span className="small muted">남은 타일 {v.poolCount}장</span> <span className="small muted">남은 타일 {v.poolCount}장</span>
</div> </div>
<ol style={{ listStyle: 'none', margin: 0, padding: 0, display: 'grid', gap: 4 }}> <ol style={{ listStyle: 'none', margin: 0, padding: 0, display: 'grid', gap: 4 }}>

View File

@@ -1,4 +1,4 @@
/** 숫자 타일 30초 규칙 요약 (docs/07 §5). Tiles are redrawn here as simple SVG (the screen's Tile lives in index.tsx). */ /** 루미큐브 30초 규칙 요약 (docs/07 §5). Tiles are redrawn here as simple SVG (the screen's Tile lives in index.tsx). */
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import type { QuickCard } from '../types'; import type { QuickCard } from '../types';

View File

@@ -8,7 +8,7 @@ const view = (s: unknown, who: string | null) => GAMES.splendor!.view(s, who) as
const nickOf = (id: string) => users[id]!.nickname; const nickOf = (id: string) => users[id]!.nickname;
const tokens = (t: Partial<Record<'W' | 'U' | 'G' | 'R' | 'K' | 'gold', number>>) => ({ W: 0, U: 0, G: 0, R: 0, K: 0, gold: 0, ...t }); const tokens = (t: Partial<Record<'W' | 'U' | 'G' | 'R' | 'K' | 'gold', number>>) => ({ W: 0, U: 0, G: 0, R: 0, K: 0, gold: 0, ...t });
test('보석 상인: 내 차례 / 다른 사람 차례 / 관전', () => { test('스플렌더: 내 차례 / 다른 사람 차례 / 관전', () => {
const { state } = setupGame('splendor', 2); const { state } = setupGame('splendor', 2);
const [first, second] = state.order as [string, string]; const [first, second] = state.order as [string, string];
const t = helpNow(view(state, first), first, users); const t = helpNow(view(state, first), first, users);
@@ -26,7 +26,7 @@ test('보석 상인: 내 차례 / 다른 사람 차례 / 관전', () => {
expect(spec).toContain('점수:'); expect(spec).toContain('점수:');
}); });
test('보석 상인: 살 수 있는 카드 안내(힌트 켬/끔)', () => { test('스플렌더: 살 수 있는 카드 안내(힌트 켬/끔)', () => {
const { state } = setupGame('splendor', 2); const { state } = setupGame('splendor', 2);
const first = state.order[0] as string; const first = state.order[0] as string;
// 색마다 2개 + 황금 2개면 1단계 카드는 모두 살 수 있어요. // 색마다 2개 + 황금 2개면 1단계 카드는 모두 살 수 있어요.
@@ -39,7 +39,7 @@ test('보석 상인: 살 수 있는 카드 안내(힌트 켬/끔)', () => {
expect(helpNow(view(off, first), first, users)).toContain('카드를 누르면 살 수 있는지 보여요'); expect(helpNow(view(off, first), first, users)).toContain('카드를 누르면 살 수 있는지 보여요');
}); });
test('보석 상인: 보석 돌려놓기와 귀족 고르기', () => { test('스플렌더: 보석 돌려놓기와 귀족 고르기', () => {
const { state } = setupGame('splendor', 2); const { state } = setupGame('splendor', 2);
const [first, second] = state.order as [string, string]; const [first, second] = state.order as [string, string];
state.players[first].tokens = tokens({ W: 3, U: 3, G: 3 }); state.players[first].tokens = tokens({ W: 3, U: 3, G: 3 });
@@ -56,7 +56,7 @@ test('보석 상인: 보석 돌려놓기와 귀족 고르기', () => {
expect(helpNow(view(n, null), null, users)).toContain('귀족 중 한 명을 고르는 중'); expect(helpNow(view(n, null), null, users)).toContain('귀족 중 한 명을 고르는 중');
}); });
test('보석 상인: 기권으로 끝', () => { test('스플렌더: 기권으로 끝', () => {
const { state } = setupGame('splendor', 2); const { state } = setupGame('splendor', 2);
const [first, second] = state.order as [string, string]; const [first, second] = state.order as [string, string];
const s = applyAction('splendor', state, second, { type: 'CONCEDE' }); const s = applyAction('splendor', state, second, { type: 'CONCEDE' });

View File

@@ -1,4 +1,4 @@
/** 보석 상인 [?] "지금 할 수 있는 것" (docs/07 §5). Pure: only reads the client view. */ /** 스플렌더 [?] "지금 할 수 있는 것" (docs/07 §5). Pure: only reads the client view. */
import type { Color, SplendorView } from '@bg/games/splendor'; import type { Color, SplendorView } from '@bg/games/splendor';
import type { PublicUser } from '@bg/shared/lite'; import type { PublicUser } from '@bg/shared/lite';

View File

@@ -1,4 +1,4 @@
/** 보석 상인 화면 (docs/games/splendor.md §9). Everything is drawn from the view; art is own CSS/SVG. */ /** 스플렌더 화면 (docs/games/splendor.md §9). Everything is drawn from the view; art is own CSS/SVG. */
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import type { import type {
BuyOption, BuyOption,

View File

@@ -1,4 +1,4 @@
/** 보석 상인 30초 규칙 요약 (docs/07 §5). Gems, cards and a noble drawn as small inline SVG. */ /** 스플렌더 30초 규칙 요약 (docs/07 §5). Gems, cards and a noble drawn as small inline SVG. */
import type { QuickCard } from '../types'; import type { QuickCard } from '../types';
type C = 'W' | 'U' | 'G' | 'R' | 'K'; type C = 'W' | 'U' | 'G' | 'R' | 'K';

View File

@@ -0,0 +1,25 @@
# App only, for a host that already has a reverse proxy in front (e.g. NPMplus on 192.168.10.3 → .5:2970).
# The proxy terminates HTTPS and adds the security headers in deploy/npmplus-advanced.conf (same as the Caddyfile).
# cd deploy && docker compose -f docker-compose.proxy.yml up -d --build
name: joke-app
services:
app:
build:
context: ..
dockerfile: deploy/Dockerfile
image: boardgame-site:${APP_VERSION:-latest}
restart: unless-stopped
env_file: .env
environment:
TRUST_PROXY: "1"
ports:
- "${APP_PORT:-2970}:3000"
volumes:
- app-data:/data
stop_grace_period: 15s
logging:
driver: json-file
options: { max-size: "50m", max-file: "5" }
volumes:
app-data:

View File

@@ -0,0 +1,7 @@
# NPMplus proxy host "game.tkrmagid.kr" → Advanced. Same headers as deploy/Caddyfile.
# HSTS is set by NPMplus itself (HSTS option on). /internal/* already returns 404 from the app.
location /internal/ { return 404; }
more_set_headers "X-Content-Type-Options: nosniff";
more_set_headers "Referrer-Policy: strict-origin-when-cross-origin";
more_set_headers "Content-Security-Policy: default-src 'self'; img-src 'self' https://cdn.discordapp.com data:; connect-src 'self' wss://game.tkrmagid.kr; style-src 'self' 'unsafe-inline'; font-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'";
more_clear_headers Server;

View File

@@ -53,6 +53,23 @@ deploy/
## 6. 정해야 할 것 (사용자 결정 필요) ## 6. 정해야 할 것 (사용자 결정 필요)
| 항목 | 선택지 | 기본 제안 | | 항목 | 선택지 | 기본 제안 |
|---|---|---| |---|---|---|
| 서버 위치 | 이 호스트 / .5 Docker 호스트 / 외부 VPS | 개발 중에는 이 호스트에서 테스트, 공개용은 사용자 결정 | | 서버 위치 | 이 호스트 / .5 Docker 호스트 / 외부 VPS | **.5로 결정(2026-10-06)**, 7절 |
| 도메인 | `game.tkrmagid.kr` (확정) | 모든 기능 완성 후 DNS 연결 | | 도메인 | `game.tkrmagid.kr` (확정) | 모든 기능 완성 후 DNS 연결 |
| 디스코드 앱 | 개발자 포털에서 생성 → client ID/secret, Redirect URI 등록 | 도메인 정해진 뒤 | | 디스코드 앱 | 개발자 포털에서 생성 → client ID/secret, Redirect URI 등록 | **생성됨(2026-10-06)**, 값은 .5의 `deploy/.env` |
## 7. 실제 배포: .5 Docker 호스트 + NPMplus (2026-10-06)
.5(192.168.10.5)는 80/443을 쓰지 않고, 집 공인 IP의 80/443은 192.168.10.3의 NPMplus가 받는다(다른 tkrmagid.kr 사이트와 같은 방식).
그래서 Caddy 없이 앱만 띄우고 HTTPS·보안 헤더는 NPMplus가 맡는다.
```
인터넷 → game.tkrmagid.kr(211.213.11.141) → NPMplus 192.168.10.3:443 → 192.168.10.5:2970 → app:3000
```
- 코드: `root@192.168.10.5:/root/other/joke-app` (Gitea `tkrmagid/joke-app` clone)
- 설정: 같은 곳 `deploy/.env` (권한 600, `APP_PORT=2970`, `SESSION_SECRET`은 서버에서 생성)
- 실행: `cd /root/other/joke-app/deploy && docker compose -f docker-compose.proxy.yml up -d --build` (프로젝트 이름 `joke-app`, 볼륨 `joke-app_app-data`)
- NPMplus 프록시 호스트 `game.tkrmagid.kr` → `http://192.168.10.5:2970`, WebSocket 허용. Advanced 칸에는 `deploy/npmplus-advanced.conf`(CSP 등 Caddyfile과 같은 헤더)를 넣는다.
- 인증서: DNS에 `game` 레코드(`CNAME tkrmagid.kr` 또는 `A 211.213.11.141`)가 생긴 뒤 NPMplus에서 Let's Encrypt 인증서를 받고 Force SSL·HSTS·HTTP/2를 켠다.
- 업데이트: `git pull && docker compose -f docker-compose.proxy.yml up -d --build`
- 확인: `curl http://192.168.10.5:2970/healthz`, `curl -I https://game.tkrmagid.kr/`

View File

@@ -68,6 +68,7 @@
E2E: 달무티 4인 1라운드 → 세금 교환 → 2라운드, 보석 상인 10점 한 판, 벽 쌓기 길찾기 한 판(길 막는 벽 거절 확인), E2E: 달무티 4인 1라운드 → 세금 교환 → 2라운드, 보석 상인 10점 한 판, 벽 쌓기 길찾기 한 판(길 막는 벽 거절 확인),
라스베가스 3라운드 한 판, 바퀴벌레 포커 3인 한 판, 구십구 3인 한 판(PC 방장 + 휴대폰). 라스베가스 3라운드 한 판, 바퀴벌레 포커 3인 한 판, 구십구 3인 한 판(PC 방장 + 휴대폰).
사용자 결정 대기: '바퀴벌레 포커'·'구십구' 이름 유지 여부, 보석 상인 원작 카드 수치 유지 여부(각 문서 §12). 사용자 결정 대기: '바퀴벌레 포커'·'구십구' 이름 유지 여부, 보석 상인 원작 카드 수치 유지 여부(각 문서 §12).
→ **2026-10-06 결정**: 원래 이름으로 표시(스플렌더·쿼리도·루미큐브·부루마불), 스플렌더 카드 수치는 원작 그대로. 구십구는 코리아보드게임즈 공식 상품 페이지의 규칙 설명대로 맞춤(`games/ninety-nine.md` §1).
## M8. 파티 게임 ## M8. 파티 게임
- 도블, 그림 맞히기(붓질 스트리밍), 마피아(채팅 채널). - 도블, 그림 맞히기(붓질 스트리밍), 마피아(채팅 채널).

View File

@@ -9,11 +9,18 @@
- 화면은 **PC 버전 중심**으로 만든다(휴대폰은 접어서 지원). - 화면은 **PC 버전 중심**으로 만든다(휴대폰은 접어서 지원).
- 게임 규칙 기본값은 관리자 사이트에서 모두 바꿀 수 있으므로, 3절의 기본안은 "처음 값"이다. - 게임 규칙 기본값은 관리자 사이트에서 모두 바꿀 수 있으므로, 3절의 기본안은 "처음 값"이다.
## 0-1. 확정된 결정 (2026-10-06)
- 일반 이름으로 만들었던 게임 4개를 원래 이름으로: 보석 상인 → **스플렌더**, 벽 쌓기 길찾기 → **쿼리도**, 숫자 타일 → **루미큐브**, 주사위 땅따먹기 → **부루마불**.
- 스플렌더 카드 수치: 원작 그대로.
- 구십구: 공식 규칙이 있으면 그것으로, 없으면 알아서. → 코리아보드게임즈 공식 상품 페이지의 규칙 설명 이미지로 맞춤. 공개되지 않은 특수 카드 장수는 각 4장으로 정함(`games/ninety-nine.md` §1).
- 서버: **.5 Docker 호스트**(192.168.10.5:2970), 앞단은 기존 NPMplus(192.168.10.3)가 HTTPS를 맡는다(`11-deployment-ops.md` 7절).
- 디스코드 앱 생성 완료(Client ID `1557009917641105499`). Secret은 서버 `.env`와 호스트 비밀 저장소에만 둔다.
## 1. 운영 (진행에 꼭 필요) ## 1. 운영 (진행에 꼭 필요)
| # | 항목 | 기본안 | 필요한 것 | | # | 항목 | 기본안 | 필요한 것 |
|---|---|---|---| |---|---|---|---|
| 1 | 서버 위치 | 도메인은 game.tkrmagid.kr 확정. 완성 후 연결 | 공개 서버 위치(이 호스트 / .5 Docker 호스트 등)와 DNS 연결 시점 | | 1 | 서버 위치 | **결정됨(2026-10-06)**: .5 Docker 호스트 + NPMplus | DNS에 `game` 레코드 추가(사용자) |
| 2 | 디스코드 로그인 | 게스트만으로 먼저 개발. **관리자 사이트는 디스코드 로그인이 있어야 들어갈 수 있다** | 디스코드 개발자 포털에서 앱 생성 → Client ID, Client Secret, Redirect URI(`https://game.tkrmagid.kr/api/auth/discord/callback`) 등록 | | 2 | 디스코드 로그인 | **결정됨(2026-10-06)**: 앱 생성, 서버 `.env`에 반영. **관리자 사이트는 디스코드 로그인이 있어야 들어갈 수 있다** | 개발자 포털 OAuth2 → Redirects에 `https://game.tkrmagid.kr/api/auth/discord/callback` 등록 확인 |
## 2. 정책 ## 2. 정책
| # | 항목 | 기본안 | | # | 항목 | 기본안 |
@@ -36,4 +43,4 @@
| 원카드 | 2→2장, A→3장, ♠A→5장, 조커 5/7장, 20장이면 파산 | 색깔 카드 모드 | | 원카드 | 2→2장, A→3장, ♠A→5장, 조커 5/7장, 20장이면 파산 | 색깔 카드 모드 |
| 할리갈리 | 마지막 1명 남을 때까지(10분 제한) | 원작 종료 규칙 | | 할리갈리 | 마지막 1명 남을 때까지(10분 제한) | 원작 종료 규칙 |
| 그림 맞히기 | 한 턴에 여러 명이 맞힐 수 있음(먼저 맞힐수록 높은 점수) | 한 명만 맞히기 | | 그림 맞히기 | 한 턴에 여러 명이 맞힐 수 있음(먼저 맞힐수록 높은 점수) | 한 명만 맞히기 |
| 구십구 | 공식 규칙서를 온라인에서 확인하지 못해 일부를 추정해 기본값으로 둠 | 클래식 99 규칙. 가지고 있는 실물 규칙서 사진이 있으면 정확히 맞출 수 있음 | | 구십구 | 코리아보드게임즈 공식 규칙(5장씩, 인원별 시작 숫자, 0 카드는 방향 유지·반대 선택). 특수 카드 장수만 추정(각 4장) | 클래식 99 규칙, 목숨(하우스 룰) |

View File

@@ -21,17 +21,17 @@
| `yut` | 윷놀이 | 개인 2~4, 팀전 2팀 최대 8 | 명절 국민 게임 | M4 | | `yut` | 윷놀이 | 개인 2~4, 팀전 2팀 최대 8 | 명절 국민 게임 | M4 |
| `one-card` | 원카드 (+ 색깔 카드 모드) | 2~9 (색깔 카드 2~10) | 판매 2위(우노/원카드) | M4 | | `one-card` | 원카드 (+ 색깔 카드 모드) | 2~9 (색깔 카드 2~10) | 판매 2위(우노/원카드) | M4 |
| `gostop` | 고스톱 / 맞고 | 2~5 (맞고 2, 고스톱 3, 광팔기 4~5) | 한국 대표 화투 게임 | M5 | | `gostop` | 고스톱 / 맞고 | 2~5 (맞고 2, 고스톱 3, 광팔기 4~5) | 한국 대표 화투 게임 | M5 |
| `rummikub` | 숫자 타일(루미큐브 방식) | 2~4 | 판매 3위, 카페 2위 | M4 | | `rummikub` | 루미큐브 | 2~4 | 판매 3위, 카페 2위 | M4 |
| `davinci-code` | 다빈치 코드 | 2~4 | 카페 1위, 판매 6위 | M4 | | `davinci-code` | 다빈치 코드 | 2~4 | 카페 1위, 판매 6위 | M4 |
| `yacht` | 요트 다이스 | 1~8 | 카페 7위, 쉬움 | M4 | | `yacht` | 요트 다이스 | 1~8 | 카페 7위, 쉬움 | M4 |
| `halli-galli` | 할리갈리 | 2~6 | 판매 1위(실시간 반응) | M6 | | `halli-galli` | 할리갈리 | 2~6 | 판매 1위(실시간 반응) | M6 |
| `marble` | 주사위 땅따먹기(부루마블 방식) | 2~4 | 판매 9위, 국민 게임 | M6 | | `marble` | 부루마불 | 2~4 | 판매 9위, 국민 게임 | M6 |
## 3. 3순위: 보드게임카페·파티 인기 ## 3. 3순위: 보드게임카페·파티 인기
| id | 이름 | 인원 | 마일스톤 | | id | 이름 | 인원 | 마일스톤 |
|---|---|---|---| |---|---|---|---|
| `dalmuti` | 달무티 | 4~8 | M7 | | `dalmuti` | 달무티 | 4~8 | M7 |
| `splendor` | 보석 상인(스플렌더 방식) | 2~4 | M7 | | `splendor` | 스플렌더 | 2~4 | M7 |
| `quoridor` | 쿼리도 | 2, 4 | M7 | | `quoridor` | 쿼리도 | 2, 4 | M7 |
| `las-vegas` | 라스베가스 | 2~5 | M7 | | `las-vegas` | 라스베가스 | 2~5 | M7 |
| `cockroach-poker` | 바퀴벌레 포커 | 2~6 | M7 | | `cockroach-poker` | 바퀴벌레 포커 | 2~6 | M7 |
@@ -53,4 +53,4 @@
| 5초 준다, 갈팡질팡 | 말로 하는 파티 게임(음성 필요) | | 5초 준다, 갈팡질팡 | 말로 하는 파티 게임(음성 필요) |
## 5. 상표·이름 표시 (확정) ## 5. 상표·이름 표시 (확정)
친구끼리만 쓰는 사이트이므로 **원래 상표명을 그대로 쓴다**(루미큐브, 우노, 뱅!, 스플렌더, 할리갈리, 다빈치 코드, 부루마블 등). 표시 이름은 관리자 사이트에서 언제든 바꿀 수 있다(`14-admin.md` 4절). 카드·화투·타일 그림은 계속 직접 만든 SVG를 쓴다. 친구끼리만 쓰는 사이트이므로 **원래 상표명을 그대로 쓴다**(루미큐브, 우노, 뱅!, 스플렌더, 할리갈리, 다빈치 코드, 부루마불, 쿼리도 등). 표시 이름은 관리자 사이트에서 언제든 바꿀 수 있다(`14-admin.md` 4절). 카드·화투·타일 그림은 계속 직접 만든 SVG를 쓴다.

View File

@@ -1,4 +1,4 @@
# 세계 여행 땅따먹기 (`marble`) # 부루마불 (`marble`)
> 마일스톤: M6 · 인원: 최소 2 ~ 최대 4명 · 예상 시간: 약 30~50분(기본 20라운드 제한) · 난이도: 쉬움~보통 > 마일스톤: M6 · 인원: 최소 2 ~ 최대 4명 · 예상 시간: 약 30~50분(기본 20라운드 제한) · 난이도: 쉬움~보통
@@ -345,6 +345,7 @@ interface MarbleResult {
- Wikipedia "Monopoly (game)"(원형 장르, 파산·매각 규칙 참고): https://en.wikipedia.org/wiki/Monopoly_(game) - Wikipedia "Monopoly (game)"(원형 장르, 파산·매각 규칙 참고): https://en.wikipedia.org/wiki/Monopoly_(game)
## 12. 메모 (상표·법적 주의 등) ## 12. 메모 (상표·법적 주의 등)
- **표시 이름(2026-10-06 확정)**: 친구끼리 쓰는 사이트라 원래 이름 "부루마불"으로 표시한다(`13-decisions.md` 0절). 아래 상표 메모는 공개 서비스로 바꿀 때를 위한 참고로 남긴다.
- "부루마불"은 씨앗사, "모두의마블"은 넷마블, "Monopoly"는 Hasbro의 상표다. 화면·광고에 쓰지 말고 표시 이름은 자체 명칭(후보: "세계 여행 땅따먹기", "주사위 부자 여행", "지구 한 바퀴")으로 한다. "황금열쇠"라는 카드 이름도 부루마불을 강하게 연상시키므로 "행운카드"로 쓴다. - "부루마불"은 씨앗사, "모두의마블"은 넷마블, "Monopoly"는 Hasbro의 상표다. 화면·광고에 쓰지 말고 표시 이름은 자체 명칭(후보: "세계 여행 땅따먹기", "주사위 부자 여행", "지구 한 바퀴")으로 한다. "황금열쇠"라는 카드 이름도 부루마불을 강하게 연상시키므로 "행운카드"로 쓴다.
- 판 배치·가격·카드는 모두 자체 설계이며 원작의 수치·카드 문구를 옮기지 않았다. 도시 이름은 실제 지명이라 자유롭게 쓸 수 있다. 원작의 우주선 이름(콜롬비아호 등) 대신 "우주센터"를 쓴다. - 판 배치·가격·카드는 모두 자체 설계이며 원작의 수치·카드 문구를 옮기지 않았다. 도시 이름은 실제 지명이라 자유롭게 쓸 수 있다. 원작의 우주선 이름(콜롬비아호 등) 대신 "우주센터"를 쓴다.
- 사행성 주의: 돈은 방 안에서만 쓰는 가상 화폐로 저장·이월·환전이 없다. 실제 돈·상품 연동 금지. 규칙 보기에 명시한다. - 사행성 주의: 돈은 방 안에서만 쓰는 가상 화폐로 저장·이월·환전이 없다. 실제 돈·상품 연동 금지. 규칙 보기에 명시한다.

View File

@@ -1,4 +1,4 @@
# 벽 쌓기 길찾기 (쿼리도 방식) (`quoridor`) # 쿼리도 (`quoridor`)
> 마일스톤: M7 · 인원: 최소 2 ~ 최대 4명 (2인 또는 4인만, 3인 불가) · 예상 시간: 약 10~20분 · 난이도: 쉬움~보통 > 마일스톤: M7 · 인원: 최소 2 ~ 최대 4명 (2인 또는 4인만, 3인 불가) · 예상 시간: 약 10~20분 · 난이도: 쉬움~보통
@@ -206,6 +206,7 @@ interface QuoridorResult {
- BoardGameGeek "Quoridor": https://boardgamegeek.com/boardgame/624/quoridor (출판사·인원·플레이 시간) - BoardGameGeek "Quoridor": https://boardgamegeek.com/boardgame/624/quoridor (출판사·인원·플레이 시간)
## 12. 메모 (상표·법적 주의 등) ## 12. 메모 (상표·법적 주의 등)
- **표시 이름(2026-10-06 확정)**: 친구끼리 쓰는 사이트라 원래 이름 "쿼리도"으로 표시한다(`13-decisions.md` 0절). 아래 상표 메모는 공개 서비스로 바꿀 때를 위한 참고로 남긴다.
- "Quoridor"(쿼리도/쿼리더)는 Gigamic의 등록 상표다. 게임 규칙 자체는 저작권 보호 대상이 아니지만 이름·로고·판 디자인은 사용하지 말 것. 서비스 표시 이름 후보: "벽 쌓기 길찾기", "길막기", "미로 탈출 보드". 최종 이름은 사용자 결정 사항. 규칙 보기에서 "쿼리도와 비슷한 규칙" 같은 비교 언급 여부도 사용자와 확인. - "Quoridor"(쿼리도/쿼리더)는 Gigamic의 등록 상표다. 게임 규칙 자체는 저작권 보호 대상이 아니지만 이름·로고·판 디자인은 사용하지 말 것. 서비스 표시 이름 후보: "벽 쌓기 길찾기", "길막기", "미로 탈출 보드". 최종 이름은 사용자 결정 사항. 규칙 보기에서 "쿼리도와 비슷한 규칙" 같은 비교 언급 여부도 사용자와 확인.
- 게임 id `quoridor`는 내부용이며 UI에 노출하지 않는다(필요하면 `wall-maze` 등으로 변경). - 게임 id `quoridor`는 내부용이며 UI에 노출하지 않는다(필요하면 `wall-maze` 등으로 변경).
- 금전·베팅 요소 없음. - 금전·베팅 요소 없음.

View File

@@ -1,4 +1,4 @@
# 숫자 타일 게임 - 루미큐브 방식 (`rummikub`) # 루미큐브 (`rummikub`)
> 마일스톤: M4 · 인원: 최소 2 ~ 최대 4명 · 예상 시간: 약 30~60분 · 난이도: 보통 > 마일스톤: M4 · 인원: 최소 2 ~ 최대 4명 · 예상 시간: 약 30~60분 · 난이도: 보통
@@ -312,6 +312,7 @@ interface GameResult {
- 나무위키 "루미큐브": https://namu.wiki/w/루미큐브 (조커 감점 30점 공식 / 50점 대회 룰, 2~4명) - 나무위키 "루미큐브": https://namu.wiki/w/루미큐브 (조커 감점 30점 공식 / 50점 대회 룰, 2~4명)
## 12. 메모 (상표·법적 주의 등) ## 12. 메모 (상표·법적 주의 등)
- **표시 이름(2026-10-06 확정)**: 친구끼리 쓰는 사이트라 원래 이름 "루미큐브"으로 표시한다(`13-decisions.md` 0절). 아래 상표 메모는 공개 서비스로 바꿀 때를 위한 참고로 남긴다.
- "Rummikub"와 "루미큐브"는 M&M Ventures(Kodkod)의 등록 상표다. 국내 유통사도 있다. 화면, 방 목록, 도메인, 홍보에 상표명과 로고를 쓰지 않는다. - "Rummikub"와 "루미큐브"는 M&M Ventures(Kodkod)의 등록 상표다. 국내 유통사도 있다. 화면, 방 목록, 도메인, 홍보에 상표명과 로고를 쓰지 않는다.
- 표시 이름 후보(사용자가 선택): "숫자 타일", "숫자 맞추기 타일", "타일 놀이", "숫자 줄 세우기". - 표시 이름 후보(사용자가 선택): "숫자 타일", "숫자 맞추기 타일", "타일 놀이", "숫자 줄 세우기".
- 문서와 코드 ID(`rummikub`)는 내부용이다. 외부 노출이 걱정되면 `number-tiles` 같은 중립 ID로 바꾸는 것을 검토한다. - 문서와 코드 ID(`rummikub`)는 내부용이다. 외부 노출이 걱정되면 `number-tiles` 같은 중립 ID로 바꾸는 것을 검토한다.

View File

@@ -1,4 +1,4 @@
# 보석 상인 (`splendor`) # 스플렌더 (`splendor`)
> 마일스톤: M7 · 인원: 최소 2 ~ 최대 4명 · 예상 시간: 약 30분 · 난이도: 보통 > 마일스톤: M7 · 인원: 최소 2 ~ 최대 4명 · 예상 시간: 약 30분 · 난이도: 보통
@@ -298,6 +298,7 @@ interface SplendorResult {
- 나무위키 "스플렌더": https://namu.wiki/w/스플렌더 - 나무위키 "스플렌더": https://namu.wiki/w/스플렌더
## 12. 메모 (상표·법적 주의 등) ## 12. 메모 (상표·법적 주의 등)
- **표시 이름(2026-10-06 확정)**: 친구끼리 쓰는 사이트라 원래 이름 "스플렌더"으로 표시한다(`13-decisions.md` 0절). 아래 상표 메모는 공개 서비스로 바꿀 때를 위한 참고로 남긴다.
- "Splendor"와 "스플렌더"는 Space Cowboys(Asmodee)의 상표다. 화면·URL·광고 문구에 쓰지 말고 표시 이름은 "보석 상인"(후보: "보석 거상", "젬 마켓")으로 한다. 게임 규칙 자체는 저작권 보호 대상이 아니지만, 원작 카드 일러스트·귀족 초상·로고·규칙서 문장은 쓰지 않는다. - "Splendor"와 "스플렌더"는 Space Cowboys(Asmodee)의 상표다. 화면·URL·광고 문구에 쓰지 말고 표시 이름은 "보석 상인"(후보: "보석 거상", "젬 마켓")으로 한다. 게임 규칙 자체는 저작권 보호 대상이 아니지만, 원작 카드 일러스트·귀족 초상·로고·규칙서 문장은 쓰지 않는다.
- 카드 구성 수치(비용·점수 표)는 게임 규칙의 일부인 사실 데이터로 보는 것이 일반적이나, 원작과 완전히 같은 수치를 쓰는 것이 부담되면 같은 구조(단계별 장 수, 점수 분포, 비용 합 범위)를 유지한 자체 카드 목록으로 바꿀 수 있도록 카드 테이블을 데이터 파일로 분리해 둔다. 이 결정은 프로젝트 오너 확인 필요. - 카드 구성 수치(비용·점수 표)는 게임 규칙의 일부인 사실 데이터로 보는 것이 일반적이나, 원작과 완전히 같은 수치를 쓰는 것이 부담되면 같은 구조(단계별 장 수, 점수 분포, 비용 합 범위)를 유지한 자체 카드 목록으로 바꿀 수 있도록 카드 테이블을 데이터 파일로 분리해 둔다. 이 결정은 프로젝트 오너 확인 필요.
- 돈이나 포인트를 걸지 않는다. - 돈이나 포인트를 걸지 않는다.

View File

@@ -58,7 +58,7 @@ async function finishHand(pages: Page[]) {
for (const p of pages) await p.getByText('이번 판 결과').first().waitFor({ timeout: 10_000 }); for (const p of pages) await p.getByText('이번 판 결과').first().waitFor({ timeout: 10_000 });
} }
/** 숫자 타일: whoever has the turn tries "도와줘" → "새 묶음" → "확정"; otherwise takes one tile. */ /** 루미큐브: whoever has the turn tries "도와줘" → "새 묶음" → "확정"; otherwise takes one tile. */
async function rummikubTurns(pages: Page[], turns: number) { async function rummikubTurns(pages: Page[], turns: number) {
const stats = { plays: 0, draws: 0, undo: false }; const stats = { plays: 0, draws: 0, undo: false };
const myTurn = (p: Page) => p.evaluate(() => !!document.querySelector('.turn-status')?.textContent?.includes('내 차례예요')); const myTurn = (p: Page) => p.evaluate(() => !!document.querySelector('.turn-status')?.textContent?.includes('내 차례예요'));
@@ -220,7 +220,7 @@ async function halliTotals(p: Page): Promise<Record<string, number>> {
} }
/** /**
* 주사위 땅따먹기: one UI step for whoever can act on `p` — answer a trade, sell, buy/build (or skip), * 부루마불: one UI step for whoever can act on `p` — answer a trade, sell, buy/build (or skip),
* pick a space-travel square on the board, escape the island or roll. Returns the label pressed. * pick a space-travel square on the board, escape the island or roll. Returns the label pressed.
*/ */
const tag = (p: Page) => ((p.viewportSize()?.width ?? 0) < 600 ? 'phone' : 'pc'); const tag = (p: Page) => ((p.viewportSize()?.width ?? 0) < 600 ? 'phone' : 'pc');
@@ -329,7 +329,7 @@ async function dalmutiStep(p: Page, isHost: boolean, st: Record<string, number>)
} }
/** /**
* 보석 상인: one UI action for whoever has the turn on `p` — return tokens / choose a noble / pass when * 스플렌더: one UI action for whoever has the turn on `p` — return tokens / choose a noble / pass when
* asked, otherwise buy a highlighted card, reserve now and then, take 2 of a colour, or 3 different gems. * asked, otherwise buy a highlighted card, reserve now and then, take 2 of a colour, or 3 different gems.
* Returns the kind of action or null when it isn't this page's turn. * Returns the kind of action or null when it isn't this page's turn.
*/ */
@@ -419,7 +419,7 @@ async function splendorStep(p: Page, stats: Record<string, number>, turn: number
return null; return null;
} }
/** 벽 쌓기 길찾기: 판의 data 속성(모델 좌표 a1~i9)을 읽어 PC는 클릭, 휴대폰은 탭으로 조작한다. */ /** 쿼리도: 판의 data 속성(모델 좌표 a1~i9)을 읽어 PC는 클릭, 휴대폰은 탭으로 조작한다. */
const qdSvg = (p: Page) => p.locator('svg[data-my-seat]'); const qdSvg = (p: Page) => p.locator('svg[data-my-seat]');
async function qdTap(p: Page, target: ReturnType<Page['locator']>) { async function qdTap(p: Page, target: ReturnType<Page['locator']>) {
if (tag(p) === 'phone') await target.tap(); if (tag(p) === 'phone') await target.tap();
@@ -623,9 +623,11 @@ async function ninetyNineStep(p: Page, stats: Record<string, number>): Promise<b
await press(usePlus ? plus : minus); await press(usePlus ? plus : minus);
stats[usePlus ? 'plus' : 'minus'] = (stats[usePlus ? 'plus' : 'minus'] ?? 0) + 1; stats[usePlus ? 'plus' : 'minus'] = (stats[usePlus ? 'plus' : 'minus'] ?? 0) + 1;
} else if (zero && (total >= 60 || !nums.length)) { } else if (zero && (total >= 60 || !nums.length)) {
// The 0 card asks whether to keep or reverse the direction: alternate between the two.
const reverse = (stats.reverse ?? 0) <= (stats.keep ?? 0);
await press(cards.nth(zero.i)); await press(cards.nth(zero.i));
await press(cards.nth(zero.i)); await press(p.locator('.nn-pick button', { hasText: reverse ? /^방향 바꾸기/ : /^방향 그대로/ }));
stats.reverse = (stats.reverse ?? 0) + 1; stats[reverse ? 'reverse' : 'keep'] = (stats[reverse ? 'reverse' : 'keep'] ?? 0) + 1;
} else { } else {
// Small cards while the total is low (longer game, more cards seen), then the biggest one. // Small cards while the total is low (longer game, more cards seen), then the biggest one.
const pick = total < 55 ? nums[0]! : nums[nums.length - 1]!; const pick = total < 55 ? nums[0]! : nums[nums.length - 1]!;
@@ -1793,15 +1795,16 @@ const PLANS: Record<string, Plan> = {
}, },
'ninety-nine': { 'ninety-nine': {
// PC host + 2 phones, 전용 카드 with 6 of each special card and 30-second turns. Real turns through the UI until the // PC host + 2 phones, 전용 카드 with 6 of each special card and 30-second turns. Real turns through the UI until the
// game ends: joker via the number dial (quick 99, then −1), ± cards via their +/− buttons, 0 to turn the direction, // game ends: joker via the number dial (quick 99, then −1), ± cards via their +/− buttons, 0 with its keep/reverse
// otherwise a number card (PC: keyboard Enter twice, phones: tap twice). Players run out of cards and are eliminated. // direction buttons, otherwise a number card (PC: keyboard Enter twice, phones: tap twice). 3 players start at 20
// with 5 cards each (official rules). Players run out of cards and are eliminated.
players: 3, players: 3,
lobby: async (host) => { lobby: async (host) => {
await host.getByRole('group', { name: '한 턴 제한 시간' }).getByRole('button', { name: '30초' }).click(); await host.getByRole('group', { name: '한 턴 제한 시간' }).getByRole('button', { name: '30초' }).click();
await host.getByRole('button', { name: '▼ 자세한 설정' }).click(); await host.getByRole('button', { name: '▼ 자세한 설정' }).click();
await host.getByRole('group', { name: '특수 카드 장수' }).getByRole('button', { name: '6장' }).click(); await host.getByRole('group', { name: '특수 카드 장수' }).getByRole('button', { name: '6장' }).click();
await host.getByRole('button', { name: '설정 저장' }).click(); await host.getByRole('button', { name: '설정 저장' }).click();
await host.getByText(/^전용 카드 · 손패 3장 · 목숨 없음 · 30초/).first().waitFor(); await host.getByText(/^전용 카드 · 손패 5장 · 목숨 없음 · 30초/).first().waitFor();
}, },
drive: async (pages) => { drive: async (pages) => {
const [host, guest] = pages as [Page, Page, Page]; const [host, guest] = pages as [Page, Page, Page];
@@ -1841,7 +1844,7 @@ const PLANS: Record<string, Plan> = {
await host.waitForTimeout(600); await host.waitForTimeout(600);
await host.screenshot({ path: join(SHOTS, 'ninety-nine-result-pc.png') }); await host.screenshot({ path: join(SHOTS, 'ninety-nine-result-pc.png') });
await guest.screenshot({ path: join(SHOTS, 'ninety-nine-result-phone.png') }); await guest.screenshot({ path: join(SHOTS, 'ninety-nine-result-phone.png') });
const specials = (stats.joker ?? 0) + (stats.plus ?? 0) + (stats.minus ?? 0) + (stats.reverse ?? 0); const specials = (stats.joker ?? 0) + (stats.plus ?? 0) + (stats.minus ?? 0) + (stats.reverse ?? 0) + (stats.keep ?? 0);
if (turns < 6 || specials < 1 || outs < 1) throw new Error(`too few real turns / special cards / eliminations: ${turns} ${JSON.stringify(stats)} outs=${outs}`); if (turns < 6 || specials < 1 || outs < 1) throw new Error(`too few real turns / special cards / eliminations: ${turns} ${JSON.stringify(stats)} outs=${outs}`);
console.log(` ninety-nine: finished after ${turns} UI turns ${JSON.stringify(stats)}, ${outs} eliminations in the log`); console.log(` ninety-nine: finished after ${turns} UI turns ${JSON.stringify(stats)}, ${outs} eliminations in the log`);
}, },

View File

@@ -1,5 +1,5 @@
/** /**
* 주사위 땅따먹기 판 데이터 (docs/games/marble.md §3). Pure data + price helpers, no zod, * 부루마불 판 데이터 (docs/games/marble.md §3). Pure data + price helpers, no zod,
* so it stays cheap to import. Every number here is copied from the doc tables (tests compare * so it stays cheap to import. Every number here is copied from the doc tables (tests compare
* them with the §3.2 formulas). * them with the §3.2 formulas).
*/ */

View File

@@ -1,5 +1,5 @@
/** /**
* 주사위 땅따먹기 computer player: buys and builds while it keeps a cash reserve against the tolls ahead, sells the * 부루마불 computer player: buys and builds while it keeps a cash reserve against the tolls ahead, sells the
* cheapest thing that covers a debt, escapes the island early in the game, flies to the best square on 우주여행, * cheapest thing that covers a debt, escapes the island early in the game, flies to the best square on 우주여행,
* never proposes trades and only accepts clearly good ones. * never proposes trades and only accepts clearly good ones.
*/ */

View File

@@ -1,4 +1,4 @@
/** 주사위 땅따먹기 (docs/games/marble.md). Hidden info: the 행운카드 deck order (only its size is public). */ /** 부루마불 (docs/games/marble.md). Hidden info: the 행운카드 deck order (only its size is public). */
import type { ZodType } from 'zod'; import type { ZodType } from 'zod';
import { fail, ok, type GameDefinition, type GameEvent, type GameResult, type PlayerId, type Validation } from '@bg/engine'; import { fail, ok, type GameDefinition, type GameEvent, type GameResult, type PlayerId, type Validation } from '@bg/engine';
import { import {
@@ -246,7 +246,7 @@ function spacePreview(s: MarbleState, id: PlayerId): MarbleSpacePreview[] {
export const marble: GameDefinition<MarbleState, MarbleAction, MarbleView, MarbleOptions> = { export const marble: GameDefinition<MarbleState, MarbleAction, MarbleView, MarbleOptions> = {
id: 'marble', id: 'marble',
nameKo: '주사위 땅따먹기', nameKo: '부루마불',
minPlayers: 2, minPlayers: 2,
maxPlayers: 4, maxPlayers: 4,
stateVersion: 1, stateVersion: 1,

View File

@@ -1,5 +1,5 @@
/** /**
* 주사위 땅따먹기 상태 기계 (docs/games/marble.md §5, §8). Functions here mutate a state that * 부루마불 상태 기계 (docs/games/marble.md §5, §8). Functions here mutate a state that
* `apply` has already cloned. The RNG is only used for dice (ROLL) — the deck is shuffled once in * `apply` has already cloned. The RNG is only used for dice (ROLL) — the deck is shuffled once in
* setup and never again (cards go back to the bottom). * setup and never again (cards go back to the bottom).
*/ */

View File

@@ -1,5 +1,5 @@
/** /**
* 벽 쌓기 길찾기 판 계산 (docs/games/quoridor.md §3, §5): 좌표, 벽 충돌, 말 이동, BFS 경로. * 쿼리도 판 계산 (docs/games/quoridor.md §3, §5): 좌표, 벽 충돌, 말 이동, BFS 경로.
* 칸 (c, r) 0~8, r=0이 1행(아래). 벽 앵커 (c, r) 0~7은 칸 (c,r)(c+1,r)(c,r+1)(c+1,r+1)의 가운데 교차점. * 칸 (c, r) 0~8, r=0이 1행(아래). 벽 앵커 (c, r) 0~7은 칸 (c,r)(c+1,r)(c,r+1)(c+1,r+1)의 가운데 교차점.
*/ */
export type Seat = 'S' | 'W' | 'N' | 'E'; export type Seat = 'S' | 'W' | 'N' | 'E';

View File

@@ -1,4 +1,4 @@
/** 벽 쌓기 길찾기 computer player: shortest path, winning step, walls a rival who is about to win. */ /** 쿼리도 computer player: shortest path, winning step, walls a rival who is about to win. */
import { describe, expect, test } from 'bun:test'; import { describe, expect, test } from 'bun:test';
import { SeededRng } from '@bg/engine'; import { SeededRng } from '@bg/engine';
import { decideBotAction } from '../bots/index'; import { decideBotAction } from '../bots/index';

View File

@@ -1,5 +1,5 @@
/** /**
* 벽 쌓기 길찾기 computer player: step onto the goal when possible, otherwise walk the shortest path; when the * 쿼리도 computer player: step onto the goal when possible, otherwise walk the shortest path; when the
* leading rival would get home first, put up the wall that delays that rival most compared to itself. * leading rival would get home first, put up the wall that delays that rival most compared to itself.
*/ */
import type { BotStrategy } from '../bots/types'; import type { BotStrategy } from '../bots/types';

View File

@@ -1,4 +1,4 @@
/** 벽 쌓기 길찾기 (docs/games/quoridor.md). 내부 id 'quoridor'는 화면에 보이지 않는다(§12). */ /** 쿼리도 (docs/games/quoridor.md). 내부 id 'quoridor'는 화면에 보이지 않는다(§12). */
import { z } from 'zod'; import { z } from 'zod';
import { fail, nextSeat, ok, type GameDefinition, type GameEvent, type GameResult, type PlayerId, type Rng } from '@bg/engine'; import { fail, nextSeat, ok, type GameDefinition, type GameEvent, type GameResult, type PlayerId, type Rng } from '@bg/engine';
import { import {
@@ -504,7 +504,7 @@ function chargeClock(s: QuoridorState, i: number, now: number): boolean {
export const quoridor: GameDefinition<QuoridorState, QuoridorAction, QuoridorView, QuoridorOptions> = { export const quoridor: GameDefinition<QuoridorState, QuoridorAction, QuoridorView, QuoridorOptions> = {
id: 'quoridor', id: 'quoridor',
nameKo: '벽 쌓기 길찾기', nameKo: '쿼리도',
minPlayers: 2, minPlayers: 2,
maxPlayers: 4, maxPlayers: 4,
stateVersion: 1, stateVersion: 1,

View File

@@ -1,5 +1,5 @@
/** /**
* 숫자 타일 computer player. A turn is two decisions: first one `setDraft` with the whole plan (rack-only sets, then * 루미큐브 computer player. A turn is two decisions: first one `setDraft` with the whole plan (rack-only sets, then
* single tiles laid off on table / new sets), then `commit` once the view says the draft is valid. If the draft is * single tiles laid off on table / new sets), then `commit` once the view says the draft is valid. If the draft is
* not valid (should not happen) it draws, which also throws the draft away — it never leaves a half-built draft. * not valid (should not happen) it draws, which also throws the draft away — it never leaves a half-built draft.
* Table sets are never rearranged beyond adding tiles to their ends. * Table sets are never rearranged beyond adding tiles to their ends.

View File

@@ -1,4 +1,4 @@
/** 숫자 타일 (루미큐브 방식, docs/games/rummikub.md). Scores are room-only result points (§6, §12). */ /** 루미큐브 (docs/games/rummikub.md). Scores are room-only result points (§6, §12). */
import { z } from 'zod'; import { z } from 'zod';
import { fail, ok, type GameDefinition, type GameEvent, type GameResult, type PlayerId, type Rng, type Validation } from '@bg/engine'; import { fail, ok, type GameDefinition, type GameEvent, type GameResult, type PlayerId, type Rng, type Validation } from '@bg/engine';
import { import {
@@ -525,7 +525,7 @@ function describeSet(d: DraftSet, n: NormalSet | null): DraftSetView {
export const rummikub: GameDefinition<RummikubState, RummikubAction, RummikubView, RummikubOptions> = { export const rummikub: GameDefinition<RummikubState, RummikubAction, RummikubView, RummikubOptions> = {
id: 'rummikub', id: 'rummikub',
nameKo: '숫자 타일', nameKo: '루미큐브',
minPlayers: 2, minPlayers: 2,
maxPlayers: 4, maxPlayers: 4,
stateVersion: 1, stateVersion: 1,

View File

@@ -1,5 +1,5 @@
/** /**
* 숫자 타일 (docs/games/rummikub.md §3, §5.1, §5.5): tile ids, set validation / joker interpretation, * 루미큐브 (docs/games/rummikub.md §3, §5.1, §5.5): tile ids, set validation / joker interpretation,
* scoring helpers and simple "what can I play from my rack" search (hint button + random playouts). * scoring helpers and simple "what can I play from my rack" search (hint button + random playouts).
* Pure functions without zod so the screen can import them cheaply. * Pure functions without zod so the screen can import them cheaply.
*/ */

View File

@@ -1,5 +1,5 @@
/** /**
* 보석 상인 computer player: buys the best affordable card (points first, then bonuses that nobles and * 스플렌더 computer player: buys the best affordable card (points first, then bonuses that nobles and
* wanted cards need); otherwise picks a target card (value per missing gem) and takes gems toward it, * wanted cards need); otherwise picks a target card (value per missing gem) and takes gems toward it,
* reserving it now and then. Uses only its own view (board, nobles, its tokens / bonuses / reservations). * reserving it now and then. Uses only its own view (board, nobles, its tokens / bonuses / reservations).
*/ */

View File

@@ -1,5 +1,5 @@
/** /**
* 보석 상인 component data (docs/games/splendor.md §3.1, §3.2). Kept in a separate data file so the card * 스플렌더 component data (docs/games/splendor.md §3.1, §3.2). Kept in a separate data file so the card
* list can be swapped for an own list with the same structure (§12). Encoding: `<id>:<points>:<W U G R K cost digits>`. * list can be swapped for an own list with the same structure (§12). Encoding: `<id>:<points>:<W U G R K cost digits>`.
*/ */
export const COLORS = ['W', 'U', 'G', 'R', 'K'] as const; export const COLORS = ['W', 'U', 'G', 'R', 'K'] as const;

View File

@@ -1,5 +1,5 @@
/** /**
* 보석 상인 (스플렌더 방식, docs/games/splendor.md). Pure rules module: all randomness is in setup * 스플렌더 (docs/games/splendor.md). Pure rules module: all randomness is in setup
* (turn order, nobles, decks), state is plain JSON, the view hides deck contents and blind reservations. * (turn order, nobles, decks), state is plain JSON, the view hides deck contents and blind reservations.
*/ */
import { z } from 'zod'; import { z } from 'zod';
@@ -656,7 +656,7 @@ function meView(s: SplendorState, me: PlayerId): SplendorMeView {
export const splendor: GameDefinition<SplendorState, SplendorAction, SplendorView, SplendorOptions> = { export const splendor: GameDefinition<SplendorState, SplendorAction, SplendorView, SplendorOptions> = {
id: 'splendor', id: 'splendor',
nameKo: '보석 상인', nameKo: '스플렌더',
minPlayers: 2, minPlayers: 2,
maxPlayers: 4, maxPlayers: 4,
stateVersion: 1, stateVersion: 1,

View File

@@ -1,4 +1,4 @@
/** Random playouts for 보석 상인 (docs/09 §7): 1,000 seeds plus a few full engine randomPlayout runs. */ /** Random playouts for 스플렌더 (docs/09 §7): 1,000 seeds plus a few full engine randomPlayout runs. */
import { describe, expect, test } from 'bun:test'; import { describe, expect, test } from 'bun:test';
import { SeededRng, randomPlayout, seedN, type PlayerId } from '@bg/engine'; import { SeededRng, randomPlayout, seedN, type PlayerId } from '@bg/engine';
import { COLORS, NOBLES, TIERS, componentCounts, splendor, type SplendorAction, type SplendorOptions, type SplendorState, type Tokens } from './index'; import { COLORS, NOBLES, TIERS, componentCounts, splendor, type SplendorAction, type SplendorOptions, type SplendorState, type Tokens } from './index';

View File

@@ -21,16 +21,16 @@ export const CATALOG: CatalogEntry[] = [
{ id: 'yacht', nameKo: '요트 다이스', minPlayers: 1, maxPlayers: 8, minutes: '10~30', level: '쉬움', blurb: '주사위 다섯 개로 족보 만들기 · 혼자서도 OK', available: true }, { id: 'yacht', nameKo: '요트 다이스', minPlayers: 1, maxPlayers: 8, minutes: '10~30', level: '쉬움', blurb: '주사위 다섯 개로 족보 만들기 · 혼자서도 OK', available: true },
{ id: 'yut', nameKo: '윷놀이', minPlayers: 2, maxPlayers: 8, minutes: '15~25', level: '쉬움', blurb: '도·개·걸·윷·모! 개인전 또는 팀전', available: true }, { id: 'yut', nameKo: '윷놀이', minPlayers: 2, maxPlayers: 8, minutes: '15~25', level: '쉬움', blurb: '도·개·걸·윷·모! 개인전 또는 팀전', available: true },
{ id: 'one-card', nameKo: '원카드', minPlayers: 2, maxPlayers: 10, minutes: '10~15', level: '쉬움', blurb: '같은 무늬·숫자를 내고 공격 카드로 먹이기! · 색깔 카드 모드', available: true }, { id: 'one-card', nameKo: '원카드', minPlayers: 2, maxPlayers: 10, minutes: '10~15', level: '쉬움', blurb: '같은 무늬·숫자를 내고 공격 카드로 먹이기! · 색깔 카드 모드', available: true },
{ id: 'rummikub', nameKo: '숫자 타일', minPlayers: 2, maxPlayers: 4, minutes: '30~60', level: '보통', blurb: '같은 숫자, 이어지는 숫자로 묶어 먼저 다 내려놓기!', available: true }, { id: 'rummikub', nameKo: '루미큐브', minPlayers: 2, maxPlayers: 4, minutes: '30~60', level: '보통', blurb: '같은 숫자, 이어지는 숫자로 묶어 먼저 다 내려놓기!', available: true },
{ id: 'gostop', nameKo: '고스톱', minPlayers: 2, maxPlayers: 5, minutes: '20~40', level: '보통', blurb: '같은 월을 맞춰 먹고 고·스톱! 2인 맞고·3인·광팔기 · 놀이용 칩', available: true }, { id: 'gostop', nameKo: '고스톱', minPlayers: 2, maxPlayers: 5, minutes: '20~40', level: '보통', blurb: '같은 월을 맞춰 먹고 고·스톱! 2인 맞고·3인·광팔기 · 놀이용 칩', available: true },
{ id: 'bang', nameKo: '뱅!', minPlayers: 4, maxPlayers: 7, minutes: '20~40', level: '어려움', blurb: '보안관과 무법자, 숨은 정체를 추리하며 쏘고 피하기!', available: true }, { id: 'bang', nameKo: '뱅!', minPlayers: 4, maxPlayers: 7, minutes: '20~40', level: '어려움', blurb: '보안관과 무법자, 숨은 정체를 추리하며 쏘고 피하기!', available: true },
{ id: 'davinci-code', nameKo: '다빈치 코드', minPlayers: 2, maxPlayers: 4, minutes: '10~15', level: '쉬움', blurb: '검은·흰 숫자 타일의 숫자를 추리해 맞히기', available: true }, { id: 'davinci-code', nameKo: '다빈치 코드', minPlayers: 2, maxPlayers: 4, minutes: '10~15', level: '쉬움', blurb: '검은·흰 숫자 타일의 숫자를 추리해 맞히기', available: true },
{ id: 'mahjong', nameKo: '마작', minPlayers: 3, maxPlayers: 4, minutes: '25~50', level: '어려움', blurb: '리치 마작 4인 · 3인 산마 · 초보 모드로 대기패 힌트', available: true }, { id: 'mahjong', nameKo: '마작', minPlayers: 3, maxPlayers: 4, minutes: '25~50', level: '어려움', blurb: '리치 마작 4인 · 3인 산마 · 초보 모드로 대기패 힌트', available: true },
{ id: 'halli-galli', nameKo: '할리갈리', minPlayers: 2, maxPlayers: 6, minutes: '5~15', level: '쉬움', blurb: '한 과일이 딱 5개가 되면 누구보다 먼저 종을 땡!', available: true }, { id: 'halli-galli', nameKo: '할리갈리', minPlayers: 2, maxPlayers: 6, minutes: '5~15', level: '쉬움', blurb: '한 과일이 딱 5개가 되면 누구보다 먼저 종을 땡!', available: true },
{ id: 'marble', nameKo: '주사위 땅따먹기', minPlayers: 2, maxPlayers: 4, minutes: '30~50', level: '쉬움', blurb: '주사위로 세계 도시를 돌며 땅 사고 건물 짓기 · 놀이용 포인트', available: true }, { id: 'marble', nameKo: '부루마불', minPlayers: 2, maxPlayers: 4, minutes: '30~50', level: '쉬움', blurb: '주사위로 세계 도시를 돌며 땅 사고 건물 짓기 · 놀이용 포인트', available: true },
{ id: 'dalmuti', nameKo: '달무티', minPlayers: 4, maxPlayers: 8, minutes: '20~40', level: '쉬움', blurb: '낮은 숫자일수록 강해요! 먼저 다 털고 신분을 올리기', available: true }, { id: 'dalmuti', nameKo: '달무티', minPlayers: 4, maxPlayers: 8, minutes: '20~40', level: '쉬움', blurb: '낮은 숫자일수록 강해요! 먼저 다 털고 신분을 올리기', available: true },
{ id: 'splendor', nameKo: '보석 상인', minPlayers: 2, maxPlayers: 4, minutes: '20~40', level: '보통', blurb: '보석을 모아 카드를 사고, 할인으로 더 비싼 카드까지! 귀족 방문은 덤', available: true }, { id: 'splendor', nameKo: '스플렌더', minPlayers: 2, maxPlayers: 4, minutes: '20~40', level: '보통', blurb: '보석을 모아 카드를 사고, 할인으로 더 비싼 카드까지! 귀족 방문은 덤', available: true },
{ id: 'quoridor', nameKo: '벽 쌓기 길찾기', minPlayers: 2, maxPlayers: 4, minutes: '10~20', level: '쉬움', blurb: '말을 움직이거나 벽을 세워 먼저 반대편 끝줄로! · 2명 또는 4명', available: true }, { id: 'quoridor', nameKo: '쿼리도', minPlayers: 2, maxPlayers: 4, minutes: '10~20', level: '쉬움', blurb: '말을 움직이거나 벽을 세워 먼저 반대편 끝줄로! · 2명 또는 4명', available: true },
{ id: 'las-vegas', nameKo: '라스베가스', minPlayers: 2, maxPlayers: 5, minutes: '20~30', level: '쉬움', blurb: '주사위를 같은 숫자 카지노에 몽땅! 개수가 같으면 둘 다 꽝 · 놀이용 지폐', available: true }, { id: 'las-vegas', nameKo: '라스베가스', minPlayers: 2, maxPlayers: 5, minutes: '20~30', level: '쉬움', blurb: '주사위를 같은 숫자 카지노에 몽땅! 개수가 같으면 둘 다 꽝 · 놀이용 지폐', available: true },
{ id: 'cockroach-poker', nameKo: '바퀴벌레 포커', minPlayers: 2, maxPlayers: 6, minutes: '15~20', level: '쉬움', blurb: '벌레 카드를 건네며 "이건 쥐야!" 진짜일까 거짓말일까?', available: true }, { id: 'cockroach-poker', nameKo: '바퀴벌레 포커', minPlayers: 2, maxPlayers: 6, minutes: '15~20', level: '쉬움', blurb: '벌레 카드를 건네며 "이건 쥐야!" 진짜일까 거짓말일까?', available: true },
{ id: 'ninety-nine', nameKo: '구십구', minPlayers: 2, maxPlayers: 10, minutes: '5~15', level: '쉬움', blurb: '카드를 내며 숫자를 더해요. 99를 넘기면 탈락!', available: true }, { id: 'ninety-nine', nameKo: '구십구', minPlayers: 2, maxPlayers: 10, minutes: '5~15', level: '쉬움', blurb: '카드를 내며 숫자를 더해요. 99를 넘기면 탈락!', available: true },