게임 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:
@@ -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).
|
||||
|
||||
## 관리자
|
||||
|
||||
@@ -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';
|
||||
|
||||
25
deploy/docker-compose.proxy.yml
Normal file
25
deploy/docker-compose.proxy.yml
Normal 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:
|
||||
7
deploy/npmplus-advanced.conf
Normal file
7
deploy/npmplus-advanced.conf
Normal 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;
|
||||
@@ -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/`
|
||||
|
||||
|
||||
@@ -68,6 +68,7 @@
|
||||
E2E: 달무티 4인 1라운드 → 세금 교환 → 2라운드, 보석 상인 10점 한 판, 벽 쌓기 길찾기 한 판(길 막는 벽 거절 확인),
|
||||
라스베가스 3라운드 한 판, 바퀴벌레 포커 3인 한 판, 구십구 3인 한 판(PC 방장 + 휴대폰).
|
||||
사용자 결정 대기: '바퀴벌레 포커'·'구십구' 이름 유지 여부, 보석 상인 원작 카드 수치 유지 여부(각 문서 §12).
|
||||
→ **2026-10-06 결정**: 원래 이름으로 표시(스플렌더·쿼리도·루미큐브·부루마불), 스플렌더 카드 수치는 원작 그대로. 구십구는 코리아보드게임즈 공식 상품 페이지의 규칙 설명대로 맞춤(`games/ninety-nine.md` §1).
|
||||
|
||||
## M8. 파티 게임
|
||||
- 도블, 그림 맞히기(붓질 스트리밍), 마피아(채팅 채널).
|
||||
|
||||
@@ -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 규칙, 목숨(하우스 룰) |
|
||||
|
||||
@@ -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를 쓴다.
|
||||
|
||||
@@ -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의 상표다. 화면·광고에 쓰지 말고 표시 이름은 자체 명칭(후보: "세계 여행 땅따먹기", "주사위 부자 여행", "지구 한 바퀴")으로 한다. "황금열쇠"라는 카드 이름도 부루마불을 강하게 연상시키므로 "행운카드"로 쓴다.
|
||||
- 판 배치·가격·카드는 모두 자체 설계이며 원작의 수치·카드 문구를 옮기지 않았다. 도시 이름은 실제 지명이라 자유롭게 쓸 수 있다. 원작의 우주선 이름(콜롬비아호 등) 대신 "우주센터"를 쓴다.
|
||||
- 사행성 주의: 돈은 방 안에서만 쓰는 가상 화폐로 저장·이월·환전이 없다. 실제 돈·상품 연동 금지. 규칙 보기에 명시한다.
|
||||
|
||||
@@ -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` 등으로 변경).
|
||||
- 금전·베팅 요소 없음.
|
||||
|
||||
@@ -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로 바꾸는 것을 검토한다.
|
||||
|
||||
@@ -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·광고 문구에 쓰지 말고 표시 이름은 "보석 상인"(후보: "보석 거상", "젬 마켓")으로 한다. 게임 규칙 자체는 저작권 보호 대상이 아니지만, 원작 카드 일러스트·귀족 초상·로고·규칙서 문장은 쓰지 않는다.
|
||||
- 카드 구성 수치(비용·점수 표)는 게임 규칙의 일부인 사실 데이터로 보는 것이 일반적이나, 원작과 완전히 같은 수치를 쓰는 것이 부담되면 같은 구조(단계별 장 수, 점수 분포, 비용 합 범위)를 유지한 자체 카드 목록으로 바꿀 수 있도록 카드 테이블을 데이터 파일로 분리해 둔다. 이 결정은 프로젝트 오너 확인 필요.
|
||||
- 돈이나 포인트를 걸지 않는다.
|
||||
|
||||
@@ -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<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.
|
||||
*/
|
||||
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.
|
||||
* 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;
|
||||
}
|
||||
|
||||
/** 벽 쌓기 길찾기: 판의 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<Page['locator']>) {
|
||||
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);
|
||||
stats[usePlus ? 'plus' : 'minus'] = (stats[usePlus ? 'plus' : 'minus'] ?? 0) + 1;
|
||||
} 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));
|
||||
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<string, Plan> = {
|
||||
},
|
||||
'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<string, Plan> = {
|
||||
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`);
|
||||
},
|
||||
|
||||
@@ -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).
|
||||
*/
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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<MarbleState, MarbleAction, MarbleView, MarbleOptions> = {
|
||||
id: 'marble',
|
||||
nameKo: '주사위 땅따먹기',
|
||||
nameKo: '부루마불',
|
||||
minPlayers: 2,
|
||||
maxPlayers: 4,
|
||||
stateVersion: 1,
|
||||
|
||||
@@ -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).
|
||||
*/
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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<QuoridorState, QuoridorAction, QuoridorView, QuoridorOptions> = {
|
||||
id: 'quoridor',
|
||||
nameKo: '벽 쌓기 길찾기',
|
||||
nameKo: '쿼리도',
|
||||
minPlayers: 2,
|
||||
maxPlayers: 4,
|
||||
stateVersion: 1,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<RummikubState, RummikubAction, RummikubView, RummikubOptions> = {
|
||||
id: 'rummikub',
|
||||
nameKo: '숫자 타일',
|
||||
nameKo: '루미큐브',
|
||||
minPlayers: 2,
|
||||
maxPlayers: 4,
|
||||
stateVersion: 1,
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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).
|
||||
*/
|
||||
|
||||
@@ -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>`.
|
||||
*/
|
||||
export const COLORS = ['W', 'U', 'G', 'R', 'K'] as const;
|
||||
|
||||
@@ -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<SplendorState, SplendorAction, SplendorView, SplendorOptions> = {
|
||||
id: 'splendor',
|
||||
nameKo: '보석 상인',
|
||||
nameKo: '스플렌더',
|
||||
minPlayers: 2,
|
||||
maxPlayers: 4,
|
||||
stateVersion: 1,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user