diff --git a/README.md b/README.md index d8dd870..87542eb 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ bun scripts/loadtest.ts --help # 부하·장애 주입 (docs/reports 참고) ```bash 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). ## 관리자 diff --git a/apps/web/src/games/help.test.ts b/apps/web/src/games/help.test.ts index 9991fa1..69959e8 100644 --- a/apps/web/src/games/help.test.ts +++ b/apps/web/src/games/help.test.ts @@ -35,7 +35,7 @@ describe.each(IDS)('%s', (id) => { const { helpNow } = await import(`./${id}/help`); const def = GAMES[id]!; 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 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; diff --git a/apps/web/src/games/marble/help.test.ts b/apps/web/src/games/marble/help.test.ts index ea79e83..15fe249 100644 --- a/apps/web/src/games/marble/help.test.ts +++ b/apps/web/src/games/marble/help.test.ts @@ -15,7 +15,7 @@ function fresh(opts: Record = {}) { return state; } -test('땅따먹기: 굴리기 차례 / 상대 차례 / 관전', () => { +test('부루마불: 굴리기 차례 / 상대 차례 / 관전', () => { const s = fresh(); const a = helpNow(view(s, 'A'), 'A', users); expect(a).toContain('[🎲 주사위 굴리기]'); @@ -25,7 +25,7 @@ test('땅따먹기: 굴리기 차례 / 상대 차례 / 관전', () => { expect(helpNow(view(s, null), null, users)).toContain('호랑이님 차례예요(1/20라운드).'); }); -test('땅따먹기: 빈 땅에 서면 사기 / 내 땅에 다시 오면 짓기', () => { +test('부루마불: 빈 땅에 서면 사기 / 내 땅에 다시 오면 짓기', () => { let s = fresh(); s = applyAction('marble', s, 'A', { type: 'ROLL' }, dice(2, 4)); // → 6 방콕 const t = helpNow(view(s, 'A'), 'A', users); @@ -40,7 +40,7 @@ test('땅따먹기: 빈 땅에 서면 사기 / 내 땅에 다시 오면 짓기', expect(u).toContain('[짓기]'); }); -test('땅따먹기: 무인도, 더블, 우주여행, 모자란 포인트', () => { +test('부루마불: 무인도, 더블, 우주여행, 모자란 포인트', () => { let s = fresh(); s.players.A.pos = 10; s.players.A.islandLeft = 2; diff --git a/apps/web/src/games/marble/help.ts b/apps/web/src/games/marble/help.ts index 3bc02a7..ebf3c5d 100644 --- a/apps/web/src/games/marble/help.ts +++ b/apps/web/src/games/marble/help.ts @@ -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 { SPACES } from '@bg/games/marble/board'; import type { PublicUser } from '@bg/shared/lite'; diff --git a/apps/web/src/games/marble/index.tsx b/apps/web/src/games/marble/index.tsx index f9c09ba..58530c4 100644 --- a/apps/web/src/games/marble/index.tsx +++ b/apps/web/src/games/marble/index.tsx @@ -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 type { MarbleOptions, MarbleOwnedView, MarblePlayerView, MarbleView } from '@bg/games/marble'; 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
- + {CELLS.map((c, pos) => { const sp = SPACES[pos]!; const o = ownedAt.get(pos); @@ -583,7 +583,7 @@ function Center({ return ( <>
- 주사위 땅따먹기 + 부루마불
{v.maxRounds > 0 ? `${v.round} / ${v.maxRounds} 라운드` : `${v.round} 라운드`} diff --git a/apps/web/src/games/marble/quick.tsx b/apps/web/src/games/marble/quick.tsx index c2dacfc..026043d 100644 --- a/apps/web/src/games/marble/quick.tsx +++ b/apps/web/src/games/marble/quick.tsx @@ -1,4 +1,4 @@ -/** 주사위 땅따먹기 30초 규칙 요약 (docs/07 §5). */ +/** 부루마불 30초 규칙 요약 (docs/07 §5). */ import type { ReactNode } from 'react'; import type { QuickCard } from '../types'; diff --git a/apps/web/src/games/quoridor/help.test.ts b/apps/web/src/games/quoridor/help.test.ts index 842348c..3ffe946 100644 --- a/apps/web/src/games/quoridor/help.test.ts +++ b/apps/web/src/games/quoridor/help.test.ts @@ -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 seatOf = (s: any, id: string) => s.seats.find((x: any) => x.playerId === id); -test('벽 쌓기 길찾기: 내 차례 / 상대 차례 / 관전', () => { +test('쿼리도: 내 차례 / 상대 차례 / 관전', () => { // joinOrder: A=남(빨강, 먼저), B=북(파랑). const { state } = setupGame('quoridor', 2, { seatOrder: 'joinOrder' }); const mine = helpNow(view(state, 'A'), 'A', users); @@ -23,7 +23,7 @@ test('벽 쌓기 길찾기: 내 차례 / 상대 차례 / 관전', () => { expect(spec).toContain('고양이님(파랑) 8칸'); }); -test('벽 쌓기 길찾기: 벽을 다 쓰면 말만, 뛰어넘기 안내', () => { +test('쿼리도: 벽을 다 쓰면 말만, 뛰어넘기 안내', () => { const { state } = setupGame('quoridor', 2, { seatOrder: 'joinOrder' }); seatOf(state, 'A').wallsLeft = 0; seatOf(state, 'A').pos = { c: 4, r: 3 }; @@ -33,7 +33,7 @@ test('벽 쌓기 길찾기: 벽을 다 쓰면 말만, 뛰어넘기 안내', () = expect(t).toContain('뛰어넘는 칸'); }); -test('벽 쌓기 길찾기: 무르기 요청과 무승부 제안', () => { +test('쿼리도: 무르기 요청과 무승부 제안', () => { const { state } = setupGame('quoridor', 2, { seatOrder: 'joinOrder' }); let s = applyAction('quoridor', state, 'A', { type: 'move', to: { c: 4, r: 1 } }); s = applyAction('quoridor', s, 'A', { type: 'requestUndo' }); @@ -46,7 +46,7 @@ test('벽 쌓기 길찾기: 무르기 요청과 무승부 제안', () => { expect(helpNow(view(s, 'A'), 'A', users)).toContain('무승부'); }); -test('벽 쌓기 길찾기: 4명, 도착하면 끝', () => { +test('쿼리도: 4명, 도착하면 끝', () => { const { state } = setupGame('quoridor', 4, { seatOrder: 'joinOrder' }); const t = helpNow(view(state, 'A'), 'A', users); expect(t).toContain('남은 벽 5개'); diff --git a/apps/web/src/games/quoridor/help.ts b/apps/web/src/games/quoridor/help.ts index f219aaa..5290411 100644 --- a/apps/web/src/games/quoridor/help.ts +++ b/apps/web/src/games/quoridor/help.ts @@ -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 { PublicUser } from '@bg/shared/lite'; diff --git a/apps/web/src/games/quoridor/index.tsx b/apps/web/src/games/quoridor/index.tsx index a7f44ea..965ecea 100644 --- a/apps/web/src/games/quoridor/index.tsx +++ b/apps/web/src/games/quoridor/index.tsx @@ -1,4 +1,4 @@ -/** 벽 쌓기 길찾기 화면 (docs/games/quoridor.md §9). 판은 내 출발 쪽이 아래로 오도록 돌려서 그린다. */ +/** 쿼리도 화면 (docs/games/quoridor.md §9). 판은 내 출발 쪽이 아래로 오도록 돌려서 그린다. */ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import type { Cell, QuoridorLegalWall, QuoridorMove, QuoridorOptions, QuoridorSeatView, QuoridorView, Seat, Wall } from '@bg/games/quoridor'; import type { PublicUser } from '@bg/shared/lite'; @@ -370,7 +370,7 @@ function QuoridorBoard({ view: v, players, act, pending, tapConfirm }: BoardProp }} tabIndex={0} 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} > {goalSides.map((g, k) => { diff --git a/apps/web/src/games/quoridor/quick.tsx b/apps/web/src/games/quoridor/quick.tsx index 3375ecb..8a4503a 100644 --- a/apps/web/src/games/quoridor/quick.tsx +++ b/apps/web/src/games/quoridor/quick.tsx @@ -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'; type Pawn = [number, number, string]; diff --git a/apps/web/src/games/rummikub/help.test.ts b/apps/web/src/games/rummikub/help.test.ts index b5a8f5f..9ac608c 100644 --- a/apps/web/src/games/rummikub/help.test.ts +++ b/apps/web/src/games/rummikub/help.test.ts @@ -16,7 +16,7 @@ function game(options: Record = {}, rack?: string[]) { return { state, cur, other }; } -test('숫자 타일: 내 차례(첫 등록 전) / 상대 차례 / 관전', () => { +test('루미큐브: 내 차례(첫 등록 전) / 상대 차례 / 관전', () => { const { state, cur, other } = game(); const mine = helpNow(view(state, cur), cur, users); expect(mine).toContain('내 차례예요'); @@ -34,7 +34,7 @@ test('숫자 타일: 내 차례(첫 등록 전) / 상대 차례 / 관전', () => expect(spec).not.toContain('내 받침대'); }); -test('숫자 타일: 방 옵션 반영(첫 등록 없음, 힌트 끔)', () => { +test('루미큐브: 방 옵션 반영(첫 등록 없음, 힌트 끔)', () => { const { state, cur } = game({ initialMeldPoints: 0, hints: false }); const t = helpNow(view(state, cur), cur, users); expect(t).not.toContain('첫 등록'); @@ -42,7 +42,7 @@ test('숫자 타일: 방 옵션 반영(첫 등록 없음, 힌트 끔)', () => { expect(t).toContain('바닥 묶음을 쪼개고 합쳐'); }); -test('숫자 타일: 틀린 배치 / 올바른 배치 / 조커', () => { +test('루미큐브: 틀린 배치 / 올바른 배치 / 조커', () => { const rack = ['R10a', 'R11a', 'R12a', 'K1a', 'JK1']; const { state, cur } = game({}, rack); const bad = applyAction('rummikub', state, cur, { type: 'setDraft', sets: [{ tileIds: ['K1a'] }] }); @@ -63,7 +63,7 @@ test('숫자 타일: 틀린 배치 / 올바른 배치 / 조커', () => { expect(t3).toContain('1장을 가져와요'); }); -test('숫자 타일: 판 끝(다음 판 시작) / 게임 끝', () => { +test('루미큐브: 판 끝(다음 판 시작) / 게임 끝', () => { const rack = ['R10a', 'R11a', 'R12a']; const m = game({ gamesPerMatch: '2' }, rack); let s = applyAction('rummikub', m.state, m.cur, { type: 'setDraft', sets: [{ tileIds: rack }] }); diff --git a/apps/web/src/games/rummikub/help.ts b/apps/web/src/games/rummikub/help.ts index c6f363c..4d49868 100644 --- a/apps/web/src/games/rummikub/help.ts +++ b/apps/web/src/games/rummikub/help.ts @@ -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 { PublicUser } from '@bg/shared/lite'; diff --git a/apps/web/src/games/rummikub/index.tsx b/apps/web/src/games/rummikub/index.tsx index 9f48fff..e84869d 100644 --- a/apps/web/src/games/rummikub/index.tsx +++ b/apps/web/src/games/rummikub/index.tsx @@ -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'; // 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'; @@ -605,7 +605,7 @@ function RummikubSide({ view: v, me, players }: BoardProps) {
- {v.phase === 'finished' ? '게임 끝' : v.match.total > 1 ? `${v.match.gameNo}판 / ${v.match.total}판` : '숫자 타일'} + {v.phase === 'finished' ? '게임 끝' : v.match.total > 1 ? `${v.match.gameNo}판 / ${v.match.total}판` : '루미큐브'} 남은 타일 {v.poolCount}장
    diff --git a/apps/web/src/games/rummikub/quick.tsx b/apps/web/src/games/rummikub/quick.tsx index b352643..96bb167 100644 --- a/apps/web/src/games/rummikub/quick.tsx +++ b/apps/web/src/games/rummikub/quick.tsx @@ -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 { QuickCard } from '../types'; diff --git a/apps/web/src/games/splendor/help.test.ts b/apps/web/src/games/splendor/help.test.ts index d333216..81b444c 100644 --- a/apps/web/src/games/splendor/help.test.ts +++ b/apps/web/src/games/splendor/help.test.ts @@ -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 tokens = (t: Partial>) => ({ W: 0, U: 0, G: 0, R: 0, K: 0, gold: 0, ...t }); -test('보석 상인: 내 차례 / 다른 사람 차례 / 관전', () => { +test('스플렌더: 내 차례 / 다른 사람 차례 / 관전', () => { const { state } = setupGame('splendor', 2); const [first, second] = state.order as [string, string]; const t = helpNow(view(state, first), first, users); @@ -26,7 +26,7 @@ test('보석 상인: 내 차례 / 다른 사람 차례 / 관전', () => { expect(spec).toContain('점수:'); }); -test('보석 상인: 살 수 있는 카드 안내(힌트 켬/끔)', () => { +test('스플렌더: 살 수 있는 카드 안내(힌트 켬/끔)', () => { const { state } = setupGame('splendor', 2); const first = state.order[0] as string; // 색마다 2개 + 황금 2개면 1단계 카드는 모두 살 수 있어요. @@ -39,7 +39,7 @@ test('보석 상인: 살 수 있는 카드 안내(힌트 켬/끔)', () => { expect(helpNow(view(off, first), first, users)).toContain('카드를 누르면 살 수 있는지 보여요'); }); -test('보석 상인: 보석 돌려놓기와 귀족 고르기', () => { +test('스플렌더: 보석 돌려놓기와 귀족 고르기', () => { const { state } = setupGame('splendor', 2); const [first, second] = state.order as [string, string]; state.players[first].tokens = tokens({ W: 3, U: 3, G: 3 }); @@ -56,7 +56,7 @@ test('보석 상인: 보석 돌려놓기와 귀족 고르기', () => { expect(helpNow(view(n, null), null, users)).toContain('귀족 중 한 명을 고르는 중'); }); -test('보석 상인: 기권으로 끝', () => { +test('스플렌더: 기권으로 끝', () => { const { state } = setupGame('splendor', 2); const [first, second] = state.order as [string, string]; const s = applyAction('splendor', state, second, { type: 'CONCEDE' }); diff --git a/apps/web/src/games/splendor/help.ts b/apps/web/src/games/splendor/help.ts index f8d87cf..2646ab2 100644 --- a/apps/web/src/games/splendor/help.ts +++ b/apps/web/src/games/splendor/help.ts @@ -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 { PublicUser } from '@bg/shared/lite'; diff --git a/apps/web/src/games/splendor/index.tsx b/apps/web/src/games/splendor/index.tsx index d70b714..7591ff2 100644 --- a/apps/web/src/games/splendor/index.tsx +++ b/apps/web/src/games/splendor/index.tsx @@ -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 type { BuyOption, diff --git a/apps/web/src/games/splendor/quick.tsx b/apps/web/src/games/splendor/quick.tsx index 1001078..d9bdb95 100644 --- a/apps/web/src/games/splendor/quick.tsx +++ b/apps/web/src/games/splendor/quick.tsx @@ -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'; type C = 'W' | 'U' | 'G' | 'R' | 'K'; diff --git a/deploy/docker-compose.proxy.yml b/deploy/docker-compose.proxy.yml new file mode 100644 index 0000000..4fccf56 --- /dev/null +++ b/deploy/docker-compose.proxy.yml @@ -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: diff --git a/deploy/npmplus-advanced.conf b/deploy/npmplus-advanced.conf new file mode 100644 index 0000000..c06ac7a --- /dev/null +++ b/deploy/npmplus-advanced.conf @@ -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; diff --git a/docs/11-deployment-ops.md b/docs/11-deployment-ops.md index d76a434..8f7c923 100644 --- a/docs/11-deployment-ops.md +++ b/docs/11-deployment-ops.md @@ -53,6 +53,23 @@ deploy/ ## 6. 정해야 할 것 (사용자 결정 필요) | 항목 | 선택지 | 기본 제안 | |---|---|---| -| 서버 위치 | 이 호스트 / .5 Docker 호스트 / 외부 VPS | 개발 중에는 이 호스트에서 테스트, 공개용은 사용자 결정 | +| 서버 위치 | 이 호스트 / .5 Docker 호스트 / 외부 VPS | **.5로 결정(2026-10-06)**, 7절 | | 도메인 | `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/` + diff --git a/docs/12-roadmap.md b/docs/12-roadmap.md index 33d0b01..6a3c52c 100644 --- a/docs/12-roadmap.md +++ b/docs/12-roadmap.md @@ -68,6 +68,7 @@ E2E: 달무티 4인 1라운드 → 세금 교환 → 2라운드, 보석 상인 10점 한 판, 벽 쌓기 길찾기 한 판(길 막는 벽 거절 확인), 라스베가스 3라운드 한 판, 바퀴벌레 포커 3인 한 판, 구십구 3인 한 판(PC 방장 + 휴대폰). 사용자 결정 대기: '바퀴벌레 포커'·'구십구' 이름 유지 여부, 보석 상인 원작 카드 수치 유지 여부(각 문서 §12). + → **2026-10-06 결정**: 원래 이름으로 표시(스플렌더·쿼리도·루미큐브·부루마불), 스플렌더 카드 수치는 원작 그대로. 구십구는 코리아보드게임즈 공식 상품 페이지의 규칙 설명대로 맞춤(`games/ninety-nine.md` §1). ## M8. 파티 게임 - 도블, 그림 맞히기(붓질 스트리밍), 마피아(채팅 채널). diff --git a/docs/13-decisions.md b/docs/13-decisions.md index 0fed6c7..71a412e 100644 --- a/docs/13-decisions.md +++ b/docs/13-decisions.md @@ -9,11 +9,18 @@ - 화면은 **PC 버전 중심**으로 만든다(휴대폰은 접어서 지원). - 게임 규칙 기본값은 관리자 사이트에서 모두 바꿀 수 있으므로, 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 | 서버 위치 | 도메인은 game.tkrmagid.kr 확정. 완성 후 연결 | 공개 서버 위치(이 호스트 / .5 Docker 호스트 등)와 DNS 연결 시점 | -| 2 | 디스코드 로그인 | 게스트만으로 먼저 개발. **관리자 사이트는 디스코드 로그인이 있어야 들어갈 수 있다** | 디스코드 개발자 포털에서 앱 생성 → Client ID, Client Secret, Redirect URI(`https://game.tkrmagid.kr/api/auth/discord/callback`) 등록 | +| 1 | 서버 위치 | **결정됨(2026-10-06)**: .5 Docker 호스트 + NPMplus | DNS에 `game` 레코드 추가(사용자) | +| 2 | 디스코드 로그인 | **결정됨(2026-10-06)**: 앱 생성, 서버 `.env`에 반영. **관리자 사이트는 디스코드 로그인이 있어야 들어갈 수 있다** | 개발자 포털 OAuth2 → Redirects에 `https://game.tkrmagid.kr/api/auth/discord/callback` 등록 확인 | ## 2. 정책 | # | 항목 | 기본안 | @@ -36,4 +43,4 @@ | 원카드 | 2→2장, A→3장, ♠A→5장, 조커 5/7장, 20장이면 파산 | 색깔 카드 모드 | | 할리갈리 | 마지막 1명 남을 때까지(10분 제한) | 원작 종료 규칙 | | 그림 맞히기 | 한 턴에 여러 명이 맞힐 수 있음(먼저 맞힐수록 높은 점수) | 한 명만 맞히기 | -| 구십구 | 공식 규칙서를 온라인에서 확인하지 못해 일부를 추정해 기본값으로 둠 | 클래식 99 규칙. 가지고 있는 실물 규칙서 사진이 있으면 정확히 맞출 수 있음 | +| 구십구 | 코리아보드게임즈 공식 규칙(5장씩, 인원별 시작 숫자, 0 카드는 방향 유지·반대 선택). 특수 카드 장수만 추정(각 4장) | 클래식 99 규칙, 목숨(하우스 룰) | diff --git a/docs/games/README.md b/docs/games/README.md index 5a52565..be77a9c 100644 --- a/docs/games/README.md +++ b/docs/games/README.md @@ -21,17 +21,17 @@ | `yut` | 윷놀이 | 개인 2~4, 팀전 2팀 최대 8 | 명절 국민 게임 | M4 | | `one-card` | 원카드 (+ 색깔 카드 모드) | 2~9 (색깔 카드 2~10) | 판매 2위(우노/원카드) | M4 | | `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 | | `yacht` | 요트 다이스 | 1~8 | 카페 7위, 쉬움 | M4 | | `halli-galli` | 할리갈리 | 2~6 | 판매 1위(실시간 반응) | M6 | -| `marble` | 주사위 땅따먹기(부루마블 방식) | 2~4 | 판매 9위, 국민 게임 | M6 | +| `marble` | 부루마불 | 2~4 | 판매 9위, 국민 게임 | M6 | ## 3. 3순위: 보드게임카페·파티 인기 | id | 이름 | 인원 | 마일스톤 | |---|---|---|---| | `dalmuti` | 달무티 | 4~8 | M7 | -| `splendor` | 보석 상인(스플렌더 방식) | 2~4 | M7 | +| `splendor` | 스플렌더 | 2~4 | M7 | | `quoridor` | 쿼리도 | 2, 4 | M7 | | `las-vegas` | 라스베가스 | 2~5 | M7 | | `cockroach-poker` | 바퀴벌레 포커 | 2~6 | M7 | @@ -53,4 +53,4 @@ | 5초 준다, 갈팡질팡 | 말로 하는 파티 게임(음성 필요) | ## 5. 상표·이름 표시 (확정) -친구끼리만 쓰는 사이트이므로 **원래 상표명을 그대로 쓴다**(루미큐브, 우노, 뱅!, 스플렌더, 할리갈리, 다빈치 코드, 부루마블 등). 표시 이름은 관리자 사이트에서 언제든 바꿀 수 있다(`14-admin.md` 4절). 카드·화투·타일 그림은 계속 직접 만든 SVG를 쓴다. +친구끼리만 쓰는 사이트이므로 **원래 상표명을 그대로 쓴다**(루미큐브, 우노, 뱅!, 스플렌더, 할리갈리, 다빈치 코드, 부루마불, 쿼리도 등). 표시 이름은 관리자 사이트에서 언제든 바꿀 수 있다(`14-admin.md` 4절). 카드·화투·타일 그림은 계속 직접 만든 SVG를 쓴다. diff --git a/docs/games/marble.md b/docs/games/marble.md index 591b79b..debf3a6 100644 --- a/docs/games/marble.md +++ b/docs/games/marble.md @@ -1,4 +1,4 @@ -# 세계 여행 땅따먹기 (`marble`) +# 부루마불 (`marble`) > 마일스톤: M6 · 인원: 최소 2 ~ 최대 4명 · 예상 시간: 약 30~50분(기본 20라운드 제한) · 난이도: 쉬움~보통 @@ -345,6 +345,7 @@ interface MarbleResult { - Wikipedia "Monopoly (game)"(원형 장르, 파산·매각 규칙 참고): https://en.wikipedia.org/wiki/Monopoly_(game) ## 12. 메모 (상표·법적 주의 등) +- **표시 이름(2026-10-06 확정)**: 친구끼리 쓰는 사이트라 원래 이름 "부루마불"으로 표시한다(`13-decisions.md` 0절). 아래 상표 메모는 공개 서비스로 바꿀 때를 위한 참고로 남긴다. - "부루마불"은 씨앗사, "모두의마블"은 넷마블, "Monopoly"는 Hasbro의 상표다. 화면·광고에 쓰지 말고 표시 이름은 자체 명칭(후보: "세계 여행 땅따먹기", "주사위 부자 여행", "지구 한 바퀴")으로 한다. "황금열쇠"라는 카드 이름도 부루마불을 강하게 연상시키므로 "행운카드"로 쓴다. - 판 배치·가격·카드는 모두 자체 설계이며 원작의 수치·카드 문구를 옮기지 않았다. 도시 이름은 실제 지명이라 자유롭게 쓸 수 있다. 원작의 우주선 이름(콜롬비아호 등) 대신 "우주센터"를 쓴다. - 사행성 주의: 돈은 방 안에서만 쓰는 가상 화폐로 저장·이월·환전이 없다. 실제 돈·상품 연동 금지. 규칙 보기에 명시한다. diff --git a/docs/games/quoridor.md b/docs/games/quoridor.md index bb48511..11504df 100644 --- a/docs/games/quoridor.md +++ b/docs/games/quoridor.md @@ -1,4 +1,4 @@ -# 벽 쌓기 길찾기 (쿼리도 방식) (`quoridor`) +# 쿼리도 (`quoridor`) > 마일스톤: M7 · 인원: 최소 2 ~ 최대 4명 (2인 또는 4인만, 3인 불가) · 예상 시간: 약 10~20분 · 난이도: 쉬움~보통 @@ -206,6 +206,7 @@ interface QuoridorResult { - BoardGameGeek "Quoridor": https://boardgamegeek.com/boardgame/624/quoridor (출판사·인원·플레이 시간) ## 12. 메모 (상표·법적 주의 등) +- **표시 이름(2026-10-06 확정)**: 친구끼리 쓰는 사이트라 원래 이름 "쿼리도"으로 표시한다(`13-decisions.md` 0절). 아래 상표 메모는 공개 서비스로 바꿀 때를 위한 참고로 남긴다. - "Quoridor"(쿼리도/쿼리더)는 Gigamic의 등록 상표다. 게임 규칙 자체는 저작권 보호 대상이 아니지만 이름·로고·판 디자인은 사용하지 말 것. 서비스 표시 이름 후보: "벽 쌓기 길찾기", "길막기", "미로 탈출 보드". 최종 이름은 사용자 결정 사항. 규칙 보기에서 "쿼리도와 비슷한 규칙" 같은 비교 언급 여부도 사용자와 확인. - 게임 id `quoridor`는 내부용이며 UI에 노출하지 않는다(필요하면 `wall-maze` 등으로 변경). - 금전·베팅 요소 없음. diff --git a/docs/games/rummikub.md b/docs/games/rummikub.md index dc175ed..6005fe6 100644 --- a/docs/games/rummikub.md +++ b/docs/games/rummikub.md @@ -1,4 +1,4 @@ -# 숫자 타일 게임 - 루미큐브 방식 (`rummikub`) +# 루미큐브 (`rummikub`) > 마일스톤: M4 · 인원: 최소 2 ~ 최대 4명 · 예상 시간: 약 30~60분 · 난이도: 보통 @@ -312,6 +312,7 @@ interface GameResult { - 나무위키 "루미큐브": https://namu.wiki/w/루미큐브 (조커 감점 30점 공식 / 50점 대회 룰, 2~4명) ## 12. 메모 (상표·법적 주의 등) +- **표시 이름(2026-10-06 확정)**: 친구끼리 쓰는 사이트라 원래 이름 "루미큐브"으로 표시한다(`13-decisions.md` 0절). 아래 상표 메모는 공개 서비스로 바꿀 때를 위한 참고로 남긴다. - "Rummikub"와 "루미큐브"는 M&M Ventures(Kodkod)의 등록 상표다. 국내 유통사도 있다. 화면, 방 목록, 도메인, 홍보에 상표명과 로고를 쓰지 않는다. - 표시 이름 후보(사용자가 선택): "숫자 타일", "숫자 맞추기 타일", "타일 놀이", "숫자 줄 세우기". - 문서와 코드 ID(`rummikub`)는 내부용이다. 외부 노출이 걱정되면 `number-tiles` 같은 중립 ID로 바꾸는 것을 검토한다. diff --git a/docs/games/splendor.md b/docs/games/splendor.md index 61c1440..b43f5b0 100644 --- a/docs/games/splendor.md +++ b/docs/games/splendor.md @@ -1,4 +1,4 @@ -# 보석 상인 (`splendor`) +# 스플렌더 (`splendor`) > 마일스톤: M7 · 인원: 최소 2 ~ 최대 4명 · 예상 시간: 약 30분 · 난이도: 보통 @@ -298,6 +298,7 @@ interface SplendorResult { - 나무위키 "스플렌더": https://namu.wiki/w/스플렌더 ## 12. 메모 (상표·법적 주의 등) +- **표시 이름(2026-10-06 확정)**: 친구끼리 쓰는 사이트라 원래 이름 "스플렌더"으로 표시한다(`13-decisions.md` 0절). 아래 상표 메모는 공개 서비스로 바꿀 때를 위한 참고로 남긴다. - "Splendor"와 "스플렌더"는 Space Cowboys(Asmodee)의 상표다. 화면·URL·광고 문구에 쓰지 말고 표시 이름은 "보석 상인"(후보: "보석 거상", "젬 마켓")으로 한다. 게임 규칙 자체는 저작권 보호 대상이 아니지만, 원작 카드 일러스트·귀족 초상·로고·규칙서 문장은 쓰지 않는다. - 카드 구성 수치(비용·점수 표)는 게임 규칙의 일부인 사실 데이터로 보는 것이 일반적이나, 원작과 완전히 같은 수치를 쓰는 것이 부담되면 같은 구조(단계별 장 수, 점수 분포, 비용 합 범위)를 유지한 자체 카드 목록으로 바꿀 수 있도록 카드 테이블을 데이터 파일로 분리해 둔다. 이 결정은 프로젝트 오너 확인 필요. - 돈이나 포인트를 걸지 않는다. diff --git a/e2e/games.e2e.ts b/e2e/games.e2e.ts index ae98f7e..82dfcde 100644 --- a/e2e/games.e2e.ts +++ b/e2e/games.e2e.ts @@ -58,7 +58,7 @@ async function finishHand(pages: Page[]) { 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) { const stats = { plays: 0, draws: 0, undo: false }; const myTurn = (p: Page) => p.evaluate(() => !!document.querySelector('.turn-status')?.textContent?.includes('내 차례예요')); @@ -220,7 +220,7 @@ async function halliTotals(p: Page): Promise> { } /** - * 주사위 땅따먹기: 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. */ const tag = (p: Page) => ((p.viewportSize()?.width ?? 0) < 600 ? 'phone' : 'pc'); @@ -329,7 +329,7 @@ async function dalmutiStep(p: Page, isHost: boolean, st: Record) } /** - * 보석 상인: 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. * 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, turn: number return null; } -/** 벽 쌓기 길찾기: 판의 data 속성(모델 좌표 a1~i9)을 읽어 PC는 클릭, 휴대폰은 탭으로 조작한다. */ +/** 쿼리도: 판의 data 속성(모델 좌표 a1~i9)을 읽어 PC는 클릭, 휴대폰은 탭으로 조작한다. */ const qdSvg = (p: Page) => p.locator('svg[data-my-seat]'); async function qdTap(p: Page, target: ReturnType) { if (tag(p) === 'phone') await target.tap(); @@ -623,9 +623,11 @@ async function ninetyNineStep(p: Page, stats: Record): Promise= 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)); - stats.reverse = (stats.reverse ?? 0) + 1; + await press(p.locator('.nn-pick button', { hasText: reverse ? /^방향 바꾸기/ : /^방향 그대로/ })); + stats[reverse ? 'reverse' : 'keep'] = (stats[reverse ? 'reverse' : 'keep'] ?? 0) + 1; } else { // 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]!; @@ -1793,15 +1795,16 @@ const PLANS: Record = { }, 'ninety-nine': { // 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, - // otherwise a number card (PC: keyboard Enter twice, phones: tap twice). Players run out of cards and are eliminated. + // game ends: joker via the number dial (quick 99, then −1), ± cards via their +/− buttons, 0 with its keep/reverse + // 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, lobby: async (host) => { await host.getByRole('group', { name: '한 턴 제한 시간' }).getByRole('button', { name: '30초' }).click(); await host.getByRole('button', { name: '▼ 자세한 설정' }).click(); await host.getByRole('group', { name: '특수 카드 장수' }).getByRole('button', { name: '6장' }).click(); await host.getByRole('button', { name: '설정 저장' }).click(); - await host.getByText(/^전용 카드 · 손패 3장 · 목숨 없음 · 30초/).first().waitFor(); + await host.getByText(/^전용 카드 · 손패 5장 · 목숨 없음 · 30초/).first().waitFor(); }, drive: async (pages) => { const [host, guest] = pages as [Page, Page, Page]; @@ -1841,7 +1844,7 @@ const PLANS: Record = { await host.waitForTimeout(600); await host.screenshot({ path: join(SHOTS, 'ninety-nine-result-pc.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}`); console.log(` ninety-nine: finished after ${turns} UI turns ${JSON.stringify(stats)}, ${outs} eliminations in the log`); }, diff --git a/packages/games/src/marble/board.ts b/packages/games/src/marble/board.ts index eed135a..98a76e2 100644 --- a/packages/games/src/marble/board.ts +++ b/packages/games/src/marble/board.ts @@ -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 * them with the §3.2 formulas). */ diff --git a/packages/games/src/marble/bot.ts b/packages/games/src/marble/bot.ts index 77f77f2..ff32ba6 100644 --- a/packages/games/src/marble/bot.ts +++ b/packages/games/src/marble/bot.ts @@ -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 우주여행, * never proposes trades and only accepts clearly good ones. */ diff --git a/packages/games/src/marble/index.ts b/packages/games/src/marble/index.ts index 01c9c8f..766a451 100644 --- a/packages/games/src/marble/index.ts +++ b/packages/games/src/marble/index.ts @@ -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 { fail, ok, type GameDefinition, type GameEvent, type GameResult, type PlayerId, type Validation } from '@bg/engine'; import { @@ -246,7 +246,7 @@ function spacePreview(s: MarbleState, id: PlayerId): MarbleSpacePreview[] { export const marble: GameDefinition = { id: 'marble', - nameKo: '주사위 땅따먹기', + nameKo: '부루마불', minPlayers: 2, maxPlayers: 4, stateVersion: 1, diff --git a/packages/games/src/marble/rules.ts b/packages/games/src/marble/rules.ts index bb9d576..a014818 100644 --- a/packages/games/src/marble/rules.ts +++ b/packages/games/src/marble/rules.ts @@ -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 * setup and never again (cards go back to the bottom). */ diff --git a/packages/games/src/quoridor/board.ts b/packages/games/src/quoridor/board.ts index 0e0ccb1..016fb55 100644 --- a/packages/games/src/quoridor/board.ts +++ b/packages/games/src/quoridor/board.ts @@ -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)의 가운데 교차점. */ export type Seat = 'S' | 'W' | 'N' | 'E'; diff --git a/packages/games/src/quoridor/bot.test.ts b/packages/games/src/quoridor/bot.test.ts index 2319b8c..fa5e084 100644 --- a/packages/games/src/quoridor/bot.test.ts +++ b/packages/games/src/quoridor/bot.test.ts @@ -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 { SeededRng } from '@bg/engine'; import { decideBotAction } from '../bots/index'; diff --git a/packages/games/src/quoridor/bot.ts b/packages/games/src/quoridor/bot.ts index 5a7c55d..7373390 100644 --- a/packages/games/src/quoridor/bot.ts +++ b/packages/games/src/quoridor/bot.ts @@ -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. */ import type { BotStrategy } from '../bots/types'; diff --git a/packages/games/src/quoridor/index.ts b/packages/games/src/quoridor/index.ts index 66644b6..192d1c4 100644 --- a/packages/games/src/quoridor/index.ts +++ b/packages/games/src/quoridor/index.ts @@ -1,4 +1,4 @@ -/** 벽 쌓기 길찾기 (docs/games/quoridor.md). 내부 id 'quoridor'는 화면에 보이지 않는다(§12). */ +/** 쿼리도 (docs/games/quoridor.md). 내부 id 'quoridor'는 화면에 보이지 않는다(§12). */ import { z } from 'zod'; import { fail, nextSeat, ok, type GameDefinition, type GameEvent, type GameResult, type PlayerId, type Rng } from '@bg/engine'; import { @@ -504,7 +504,7 @@ function chargeClock(s: QuoridorState, i: number, now: number): boolean { export const quoridor: GameDefinition = { id: 'quoridor', - nameKo: '벽 쌓기 길찾기', + nameKo: '쿼리도', minPlayers: 2, maxPlayers: 4, stateVersion: 1, diff --git a/packages/games/src/rummikub/bot.ts b/packages/games/src/rummikub/bot.ts index 089944f..e67c9a6 100644 --- a/packages/games/src/rummikub/bot.ts +++ b/packages/games/src/rummikub/bot.ts @@ -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 * 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. diff --git a/packages/games/src/rummikub/index.ts b/packages/games/src/rummikub/index.ts index 6e2f1b0..2ebcd3a 100644 --- a/packages/games/src/rummikub/index.ts +++ b/packages/games/src/rummikub/index.ts @@ -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 { fail, ok, type GameDefinition, type GameEvent, type GameResult, type PlayerId, type Rng, type Validation } from '@bg/engine'; import { @@ -525,7 +525,7 @@ function describeSet(d: DraftSet, n: NormalSet | null): DraftSetView { export const rummikub: GameDefinition = { id: 'rummikub', - nameKo: '숫자 타일', + nameKo: '루미큐브', minPlayers: 2, maxPlayers: 4, stateVersion: 1, diff --git a/packages/games/src/rummikub/rules.ts b/packages/games/src/rummikub/rules.ts index 67019f7..37b2e7a 100644 --- a/packages/games/src/rummikub/rules.ts +++ b/packages/games/src/rummikub/rules.ts @@ -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). * Pure functions without zod so the screen can import them cheaply. */ diff --git a/packages/games/src/splendor/bot.ts b/packages/games/src/splendor/bot.ts index f412b3d..a6876f1 100644 --- a/packages/games/src/splendor/bot.ts +++ b/packages/games/src/splendor/bot.ts @@ -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, * reserving it now and then. Uses only its own view (board, nobles, its tokens / bonuses / reservations). */ diff --git a/packages/games/src/splendor/cards.ts b/packages/games/src/splendor/cards.ts index 53317b3..6da1734 100644 --- a/packages/games/src/splendor/cards.ts +++ b/packages/games/src/splendor/cards.ts @@ -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: `::`. */ export const COLORS = ['W', 'U', 'G', 'R', 'K'] as const; diff --git a/packages/games/src/splendor/index.ts b/packages/games/src/splendor/index.ts index e73619d..a1c83bc 100644 --- a/packages/games/src/splendor/index.ts +++ b/packages/games/src/splendor/index.ts @@ -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. */ import { z } from 'zod'; @@ -656,7 +656,7 @@ function meView(s: SplendorState, me: PlayerId): SplendorMeView { export const splendor: GameDefinition = { id: 'splendor', - nameKo: '보석 상인', + nameKo: '스플렌더', minPlayers: 2, maxPlayers: 4, stateVersion: 1, diff --git a/packages/games/src/splendor/splendor.playout.test.ts b/packages/games/src/splendor/splendor.playout.test.ts index 92af740..f3342e3 100644 --- a/packages/games/src/splendor/splendor.playout.test.ts +++ b/packages/games/src/splendor/splendor.playout.test.ts @@ -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 { SeededRng, randomPlayout, seedN, type PlayerId } from '@bg/engine'; import { COLORS, NOBLES, TIERS, componentCounts, splendor, type SplendorAction, type SplendorOptions, type SplendorState, type Tokens } from './index'; diff --git a/packages/shared/src/catalog.ts b/packages/shared/src/catalog.ts index 40e02e9..b784e24 100644 --- a/packages/shared/src/catalog.ts +++ b/packages/shared/src/catalog.ts @@ -21,16 +21,16 @@ export const CATALOG: CatalogEntry[] = [ { 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: '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: '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: '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: '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: '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: '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: '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: 'ninety-nine', nameKo: '구십구', minPlayers: 2, maxPlayers: 10, minutes: '5~15', level: '쉬움', blurb: '카드를 내며 숫자를 더해요. 99를 넘기면 탈락!', available: true },