게임 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:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/** 주사위 땅따먹기 30초 규칙 요약 (docs/07 §5). */
|
||||
/** 부루마불 30초 규칙 요약 (docs/07 §5). */
|
||||
import type { ReactNode } from 'react';
|
||||
import type { QuickCard } from '../types';
|
||||
|
||||
|
||||
@@ -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개');
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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 }] });
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user