게임 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

@@ -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;

View File

@@ -15,7 +15,7 @@ function fresh(opts: Record<string, unknown> = {}) {
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;

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 { SPACES } from '@bg/games/marble/board';
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 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<MarbleVi
<div className="mb-wrap">
<style>{CSS}</style>
<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) => {
const sp = SPACES[pos]!;
const o = ownedAt.get(pos);
@@ -583,7 +583,7 @@ function Center({
return (
<>
<div className="mb-title" aria-hidden>
주사위 땅따먹기
부루마불
</div>
<div className="mb-meta">
<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 { 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 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개');

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 { 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 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) => {

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';
type Pawn = [number, number, string];

View File

@@ -16,7 +16,7 @@ function game(options: Record<string, unknown> = {}, 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 }] });

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 { 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';
// 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<RummikubView>) {
<style>{CSS}</style>
<div className="card stack" style={{ gap: 6, padding: 12 }}>
<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>
</div>
<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 { 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 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 [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' });

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 { 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 type {
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';
type C = 'W' | 'U' | 'G' | 'R' | 'K';