From 11ad2be147ca91471873ce6c4aecd67792cde971 Mon Sep 17 00:00:00 2001 From: EJClaw Date: Mon, 5 Oct 2026 14:53:35 +0900 Subject: [PATCH] =?UTF-8?q?M7:=20=EB=B3=B4=EC=84=9D=20=EC=83=81=EC=9D=B8?= =?UTF-8?q?=20=EA=B7=9C=EC=B9=99=C2=B7=ED=99=94=EB=A9=B4=C2=B7=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 규칙 모듈 packages/games/src/splendor: 카드 90장·귀족 10장 데이터 파일 분리(cards.ts), 보석 가져오기(3색/같은 색 2개, allowFewerTokens), 예약(공개/더미, 황금), 구매(자동·수동 지불), 10개 한도 반납, 귀족 방문·선택, 라운드 끝까지 진행 후 종료, 동점은 카드 적은 쪽, 넘기기와 모두 넘기면 종료, 기권, 시간 초과 자동 행동(3번 연속이면 자리 비움), view는 더미·블라인드 예약·제외 귀족을 숨기고 살 수 있는 카드·지불 미리보기를 계산 - 테스트: 문서 10절 시나리오 42개 + 무작위 대국 1,000판(보존·정보 유출·onTimeout 합법·결정성) + 엔진 randomPlayout - 화면 apps/web/src/games/splendor: 자체 카드·귀족·보석 그림(색+모양+글자), 3단계 시장과 더미, 보석 은행, 내 보너스·보석·예약, 상대 요약, 살 수 있는 카드 강조, 반납·귀족 선택, PC/휴대폰·다크 모드 - 등록: GAMES, 카탈로그, 화면 레지스트리, 아이콘 - E2E: PC 방장 + 휴대폰 친구가 10점 판을 결과까지 진행 Co-Authored-By: Claude Opus 5.5 --- apps/web/src/games/registry.ts | 1 + apps/web/src/games/splendor/index.tsx | 1238 +++++++++++++++++ apps/web/src/lib/catalog-ui.ts | 1 + e2e/games.e2e.ts | 147 ++ packages/games/src/index.ts | 2 + packages/games/src/splendor/cards.ts | 76 + packages/games/src/splendor/index.ts | 968 +++++++++++++ .../src/splendor/splendor.playout.test.ts | 135 ++ packages/games/src/splendor/splendor.test.ts | 753 ++++++++++ packages/shared/src/catalog.ts | 1 + 10 files changed, 3322 insertions(+) create mode 100644 apps/web/src/games/splendor/index.tsx create mode 100644 packages/games/src/splendor/cards.ts create mode 100644 packages/games/src/splendor/index.ts create mode 100644 packages/games/src/splendor/splendor.playout.test.ts create mode 100644 packages/games/src/splendor/splendor.test.ts diff --git a/apps/web/src/games/registry.ts b/apps/web/src/games/registry.ts index 2a1cc79..d33cbd9 100644 --- a/apps/web/src/games/registry.ts +++ b/apps/web/src/games/registry.ts @@ -19,6 +19,7 @@ export const GAME_UI: Record Promise<{ default: GameUI }>> = { bang: () => import('./bang'), 'halli-galli': () => import('./halli-galli'), marble: () => import('./marble'), + splendor: () => import('./splendor'), }; const cache = new Map(); diff --git a/apps/web/src/games/splendor/index.tsx b/apps/web/src/games/splendor/index.tsx new file mode 100644 index 0000000..0543a49 --- /dev/null +++ b/apps/web/src/games/splendor/index.tsx @@ -0,0 +1,1238 @@ +/** 보석 상인 화면 (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, + Color, + DevCard, + LogEntry, + Noble, + ReservedView, + SplendorOptions, + SplendorPlayerView, + SplendorView, + Tier, + TokenColor, + Tokens, +} from '@bg/games/splendor'; +import type { PublicUser } from '@bg/shared/lite'; +import { Avatar, ConfirmSheet } from '../../components/ui'; +import { TurnTimer } from '../../components/TurnTimer'; +import { haptic, sounds } from '../../lib/feedback'; +import type { BoardProps, GameUI, OptionsProps } from '../types'; + +// Type-only imports above keep the rules module out of the bundle; the few constants live here. +const COLORS: Color[] = ['W', 'U', 'G', 'R', 'K']; +const TOKEN_COLORS: TokenColor[] = ['W', 'U', 'G', 'R', 'K', 'gold']; +const TIERS_DOWN: Tier[] = [3, 2, 1]; +const SHORT: Record = { W: '흰', U: '파', G: '초', R: '빨', K: '검', gold: '금' }; +const NAME: Record = { W: '흰 보석', U: '파란 보석', G: '초록 보석', R: '빨간 보석', K: '검은 보석', gold: '황금' }; +const TIER_NAME: Record = { 1: '광산', 2: '운송', 3: '상점' }; +/** Own names for the ten noble tiles (no original names or portraits, §3.2 / §12). */ +const NOBLE_NAME: Record = { + N01: '달빛 공작', + N02: '바람 백작', + N03: '은빛 남작', + N04: '호수 후작', + N05: '노을 자작', + N06: '별빛 공주', + N07: '숲의 영주', + N08: '안개 백작', + N09: '서리 공작', + N10: '등불 여왕', +}; + +/** 이/가 after a Korean word (by the final consonant). */ +const iGa = (w: string) => { + const code = w.charCodeAt(w.length - 1) - 0xac00; + return code >= 0 && code <= 11171 && code % 28 !== 0 ? '이' : '가'; +}; +const nick = (players: Record, id: string) => players[id]?.nickname ?? '알 수 없음'; +const userOf = (players: Record, id: string): PublicUser => players[id] ?? { id, nickname: '알 수 없음', avatar: null, kind: 'guest' }; +const reducedMotion = () => typeof matchMedia !== 'undefined' && matchMedia('(prefers-reduced-motion: reduce)').matches; +const emptyTokens = (): Tokens => ({ W: 0, U: 0, G: 0, R: 0, K: 0, gold: 0 }); +const sum = (t: Partial>) => TOKEN_COLORS.reduce((a, c) => a + (t[c] ?? 0), 0); +const costText = (cost: Partial>) => + TOKEN_COLORS.filter((c) => cost[c]) + .map((c) => `${SHORT[c]} ${cost[c]}`) + .join(' ') || '없음'; +const cardLabel = (c: DevCard) => `${c.tier}단계 ${TIER_NAME[c.tier]} 카드, ${NAME[c.bonus]} 보너스, ${c.points}점, 비용 ${costText(c.cost)}`; + +const CSS = ` +.sp-root { --cw: 100px; --ch: 136px; --dw: 72px; --tok: 58px; width: 100%; max-width: 1060px; align-self: center; display: flex; flex-direction: column; gap: 10px; } +.sp-table { display: grid; grid-template-columns: auto auto auto; justify-content: center; gap: 22px; align-items: start; } +.sp-nobles { display: flex; flex-direction: column; gap: 6px; } +.sp-market { display: flex; flex-direction: column; gap: 8px; align-items: center; } +.sp-row { display: grid; grid-template-columns: var(--dw) repeat(4, var(--cw)); gap: 8px; } +.sp-bank { display: flex; flex-direction: column; gap: 8px; align-items: center; } + +/* gems */ +.sp-gem { display: inline-block; vertical-align: middle; flex: none; } +.sp-pip { display: inline-flex; align-items: center; gap: 1px; font-weight: 800; font-variant-numeric: tabular-nums; line-height: 1; } + +/* development cards */ +.sp-card { position: relative; width: var(--cw); height: var(--ch); padding: 0; border-radius: 12px; border: 3px solid #cbbf9f; cursor: pointer; + background: linear-gradient(160deg, var(--tint) 0%, #fffaf0 58%); color: #1c1e21; font: inherit; text-align: left; overflow: hidden; + box-shadow: 0 2px 0 #b5a77f, 0 3px 8px rgba(0,0,0,.18); transition: transform .15s ease, box-shadow .15s ease; } +.sp-card:hover:not(:disabled) { transform: translateY(-2px); } +.sp-card:focus-visible, .sp-deck:focus-visible, .sp-token:focus-visible, .sp-noble:focus-visible, .sp-chip:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; } +.sp-card[data-b=W] { --tint: #e8edf3; } .sp-card[data-b=U] { --tint: #c9dcff; } .sp-card[data-b=G] { --tint: #c8efd3; } +.sp-card[data-b=R] { --tint: #ffd0cc; } .sp-card[data-b=K] { --tint: #c9ccd3; } +.sp-card-top { position: absolute; left: 0; right: 0; top: 0; height: 34%; display: flex; justify-content: space-between; align-items: flex-start; padding: 5px 6px; + background: rgba(255,255,255,.55); border-bottom: 1px solid rgba(0,0,0,.08); } +.sp-pts { font-size: calc(var(--ch) * .22); font-weight: 900; line-height: 1; color: #1c1e21; text-shadow: 0 1px 0 #fff; min-width: .6em; } +.sp-art { position: absolute; right: 4px; bottom: 4px; width: 46%; height: 40%; opacity: .22; } +.sp-cost { position: absolute; left: 5px; bottom: 5px; display: flex; flex-direction: column-reverse; gap: 2px; } +.sp-cost .sp-pip { background: rgba(255,255,255,.85); border-radius: 999px; padding: 1px 5px 1px 2px; font-size: calc(var(--ch) * .095); border: 1px solid rgba(0,0,0,.12); } +.sp-tierdots { position: absolute; right: 6px; bottom: 4px; font-size: .62rem; letter-spacing: 1px; color: #6b5d3a; font-weight: 900; } +.sp-card.can { border-color: var(--success); box-shadow: 0 0 0 3px var(--success), 0 3px 8px rgba(0,0,0,.18); } +.sp-card.gold { border-color: #ca8a04; box-shadow: 0 0 0 3px #eab308, 0 3px 8px rgba(0,0,0,.18); } +.sp-card.sel { transform: translateY(-4px); box-shadow: 0 0 0 4px var(--primary), 0 6px 14px rgba(0,0,0,.3); } +.sp-flag { position: absolute; top: 34%; right: 0; margin-top: 3px; font-size: .64rem; font-weight: 800; border-radius: 6px 0 0 6px; padding: 1px 4px 1px 5px; } +.sp-card.can .sp-flag { background: var(--success); color: #fff; } +.sp-card.gold .sp-flag { background: #a16207; color: #fff; } +.sp-card.empty { background: transparent; border: 2px dashed var(--border); box-shadow: none; cursor: default; display: flex; align-items: center; justify-content: center; color: var(--text-sub); font-size: .8rem; } +.sp-card.fresh { animation: sp-in .45s ease-out; } +@keyframes sp-in { from { transform: scale(.7) rotate(-4deg); opacity: 0; } to { transform: none; opacity: 1; } } +.sp-card.mini { --cw: 62px; --ch: 84px; border-width: 2px; } +.sp-back { background: repeating-linear-gradient(45deg, #3b3157, #3b3157 6px, #4a3f6b 6px, #4a3f6b 12px); color: #fff; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px; font-weight: 800; } +.sp-back.t1 { --bk: #2f5d46; background: repeating-linear-gradient(45deg, #2f5d46, #2f5d46 6px, #3a6d54 6px, #3a6d54 12px); } +.sp-back.t2 { background: repeating-linear-gradient(45deg, #7a5a14, #7a5a14 6px, #8a6a20 6px, #8a6a20 12px); } +.sp-back.t3 { background: repeating-linear-gradient(45deg, #34407a, #34407a 6px, #404d8c 6px, #404d8c 12px); } +.sp-deck { width: var(--dw); height: var(--ch); border-radius: 12px; border: 3px solid rgba(0,0,0,.25); cursor: pointer; padding: 0; font: inherit; box-shadow: 3px 3px 0 rgba(0,0,0,.25); } +.sp-deck:disabled { cursor: default; opacity: .55; } +.sp-deck .n { font-size: 1.25rem; } .sp-deck .t { font-size: .74rem; opacity: .95; } + +/* nobles */ +.sp-noble { position: relative; width: 92px; min-height: 74px; border-radius: 10px; border: 2px solid #b08d3c; padding: 4px 5px; font: inherit; color: #3b2a07; text-align: left; + background: linear-gradient(135deg, #fff4d1, #f2d79a); box-shadow: 0 2px 6px rgba(0,0,0,.15); display: flex; flex-direction: column; gap: 3px; } +button.sp-noble { cursor: pointer; } +.sp-noble .nm { font-size: .7rem; font-weight: 800; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.sp-noble .rq { display: flex; flex-wrap: wrap; gap: 2px 5px; font-size: .8rem; } +.sp-noble .np { position: absolute; right: 5px; top: 3px; font-weight: 900; font-size: .95rem; } +.sp-noble .need { font-size: .66rem; font-weight: 800; background: rgba(255,255,255,.7); border-radius: 6px; padding: 0 4px; align-self: flex-start; } +.sp-noble .need.zero { background: var(--success); color: #fff; } +.sp-noble .crown { display: none; } +.sp-noble.pick { outline: 3px solid var(--primary); outline-offset: 2px; } + +/* tokens */ +.sp-token { position: relative; width: var(--tok); height: var(--tok); border-radius: 50%; padding: 0; cursor: pointer; font: inherit; border: 3px solid rgba(0,0,0,.35); + background: var(--chip); color: var(--chip-text); display: inline-flex; flex-direction: column; align-items: center; justify-content: center; gap: 0; + box-shadow: inset 0 0 0 4px rgba(255,255,255,.25), 0 2px 4px rgba(0,0,0,.25); transition: transform .12s ease; } +.sp-token[data-c=W] { --chip: #f4f6f9; --chip-text: #1c1e21; border-color: #8a94a6; } +.sp-token[data-c=U] { --chip: #2563eb; --chip-text: #fff; } +.sp-token[data-c=G] { --chip: #15803d; --chip-text: #fff; } +.sp-token[data-c=R] { --chip: #c81e1e; --chip-text: #fff; } +.sp-token[data-c=K] { --chip: #23262d; --chip-text: #fff; border-color: #6b7280; } +.sp-token[data-c=gold] { --chip: #eab308; --chip-text: #2b1d00; border-color: #a16207; } +.sp-token .lt { font-size: .7rem; font-weight: 800; line-height: 1; margin-top: 1px; } +.sp-token .ct { position: absolute; right: -6px; top: -6px; min-width: 24px; height: 24px; border-radius: 12px; background: var(--surface); color: var(--text); + border: 2px solid var(--border); font-weight: 900; font-size: .85rem; display: flex; align-items: center; justify-content: center; padding: 0 3px; } +.sp-token:disabled { cursor: default; } +.sp-token.out { opacity: .35; } +.sp-token.picked { transform: translateY(-4px); box-shadow: 0 0 0 4px var(--primary), 0 4px 8px rgba(0,0,0,.3); } +.sp-token.picked2 { box-shadow: 0 0 0 4px var(--primary), 0 0 0 8px var(--primary-soft); } +.sp-token.shake { animation: sp-shake .38s ease; } +@keyframes sp-shake { 20%, 60% { transform: translateX(-5px); } 40%, 80% { transform: translateX(5px); } } +.sp-token.sm { --tok: 40px; border-width: 2px; } +.sp-token.sm .ct { width: 20px; min-width: 20px; height: 20px; font-size: .75rem; right: -5px; top: -5px; } + +/* my area / dock */ +.sp-dock { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 10px; align-items: stretch; } +.sp-mine, .sp-panel { background: var(--surface); border-radius: var(--radius-lg); box-shadow: var(--shadow); padding: 10px 12px; min-width: 0; } +.sp-mine-head { display: flex; justify-content: space-between; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 6px; } +.sp-cols { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 4px; } +.sp-col { display: flex; flex-direction: column; align-items: center; gap: 2px; border-radius: 10px; background: var(--surface-2); padding: 4px 2px; } +.sp-col .bn { font-size: .72rem; color: var(--text-sub); font-weight: 700; } +.sp-col .big { font-weight: 900; font-size: 1.05rem; font-variant-numeric: tabular-nums; } +.sp-reserved { display: flex; gap: 6px; align-items: flex-end; margin-top: 8px; flex-wrap: wrap; } +.sp-panel { display: flex; flex-direction: column; gap: 8px; } +.sp-panel h3 { margin: 0; font-size: 1rem; } +.sp-basket { display: flex; align-items: center; gap: 6px; min-height: 44px; flex-wrap: wrap; } +.sp-actions { display: flex; gap: 8px; flex-wrap: wrap; } +.sp-actions .btn { flex: 1 1 110px; min-height: 52px; white-space: nowrap; padding: 0 12px; } +.sp-actions .btn-ghost { flex: 0 0 auto; min-height: 44px; padding: 0 8px; } +.sp-msg { margin: 0; font-size: .92rem; color: var(--text-sub); } +.sp-msg.bad { color: var(--danger); font-weight: 700; } +.sp-pay { display: grid; grid-template-columns: repeat(auto-fill, minmax(118px, 1fr)); gap: 6px; } +.sp-pay-row { display: flex; align-items: center; gap: 4px; background: var(--surface-2); border-radius: 10px; padding: 4px 6px; font-size: .88rem; } +.sp-pay-row button { min-width: 36px; min-height: 36px; border-radius: 8px; border: 2px solid var(--border); background: var(--surface); color: var(--text); font-weight: 800; cursor: pointer; } +.sp-pay-row button:disabled { opacity: .35; cursor: default; } +.sp-detail { display: flex; gap: 10px; align-items: flex-start; } + +/* opponents (chips on narrow screens) */ +.sp-opps { display: none; gap: 6px; overflow-x: auto; padding-bottom: 2px; } +.sp-chip { display: flex; align-items: center; gap: 6px; border-radius: 12px; border: 3px solid transparent; background: var(--surface); box-shadow: var(--shadow); + padding: 3px 8px 3px 4px; min-height: 44px; font: inherit; color: var(--text); cursor: pointer; flex: none; font-size: .86rem; } +.sp-chip.turn { border-color: var(--focus); } +.sp-chip .pt { font-weight: 900; font-size: 1.05rem; } +.sp-opp-detail { display: none; } + +.sp-player { border: 3px solid transparent; border-radius: 12px; padding: 6px 8px; display: flex; flex-direction: column; gap: 4px; } +.sp-player.turn { border-color: var(--focus); } +.sp-player.gone { opacity: .55; } +.sp-mini-cols { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 3px; font-size: .8rem; } +.sp-mini-cols > span { display: flex; flex-direction: column; align-items: center; background: var(--surface-2); border-radius: 8px; padding: 2px 0; line-height: 1.25; } +.sp-badge { display: inline-block; border-radius: 999px; padding: 0 8px; font-weight: 800; font-size: .74rem; line-height: 1.6; } + +.sp-crown { position: fixed; left: 50%; top: 38%; transform: translate(-50%, -50%); z-index: 30; pointer-events: none; font-size: 2.4rem; font-weight: 900; + color: #fde047; text-shadow: 0 3px 10px rgba(0,0,0,.6); animation: sp-pop .6s ease-out both; white-space: nowrap; } +@keyframes sp-pop { 0% { transform: translate(-50%, -50%) scale(.4); opacity: 0; } 60% { transform: translate(-50%, -50%) scale(1.12); opacity: 1; } 100% { transform: translate(-50%, -50%) scale(1); } } +.sp-help { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; font-size: .9rem; } +.sp-help b { background: var(--primary-soft); border-radius: 8px; padding: 2px 8px; } + +@media (max-width: 1099px) { + .sp-root { max-width: 720px; } + .sp-table { grid-template-columns: minmax(0, 1fr); } + .sp-nobles { flex-direction: row; flex-wrap: wrap; justify-content: center; } + .sp-bank { flex-direction: row; justify-content: center; flex-wrap: wrap; } + .sp-opps { display: flex; } + .sp-opp-detail { display: block; } + .sp-dock { grid-template-columns: minmax(0, 1fr); } +} +@media (max-width: 719px) { + .sp-root { --dw: 44px; --cw: calc((100vw - 24px - var(--dw) - 4 * 5px) / 4); --ch: calc(var(--cw) * 1.34); --tok: 52px; gap: 8px; } + .sp-row { gap: 5px; } + .sp-market { gap: 6px; } + .sp-noble { width: 66px; min-height: 62px; padding: 3px 4px; } + .sp-noble .nm, .sp-noble .lg { display: none; } + .sp-noble .crown { display: block; font-size: .7rem; line-height: 1.1; } + .sp-noble .rq { font-size: .74rem; gap: 1px 3px; } + .sp-noble .np { font-size: .8rem; } + .sp-deck .n { font-size: 1rem; } .sp-deck .t { font-size: .64rem; } + .sp-card-top { padding: 3px 4px; } + .sp-cost .sp-pip { padding: 0 3px 0 1px; } + .sp-flag { font-size: .58rem; top: 40%; } + .sp-dock { display: contents; } + .sp-panel.turn { position: sticky; bottom: 0; z-index: 6; box-shadow: 0 -4px 14px rgba(0,0,0,.22); } + .sp-card.mini { --cw: 58px; --ch: 78px; } +} +@media (prefers-reduced-motion: reduce) { + .sp-card, .sp-token { transition: none; } + .sp-card.fresh, .sp-token.shake, .sp-crown { animation: none; } +} +`; + +// ---------------------------------------------------------------- art + +/** Colour + shape + letter, so colours are never the only cue (§9 색약 대비). */ +const GEM_FILL: Record = { W: '#f8fafc', U: '#2563eb', G: '#16a34a', R: '#dc2626', K: '#23262d', gold: '#eab308' }; +const GEM_LINE: Record = { W: '#475569', U: '#0b2f7a', G: '#0b4d23', R: '#7a0f0f', K: '#9ca3af', gold: '#7a4e00' }; + +function GemShape({ c }: { c: TokenColor }) { + const p = { fill: GEM_FILL[c], stroke: GEM_LINE[c], strokeWidth: 1.6, strokeLinejoin: 'round' as const }; + switch (c) { + case 'W': + return ; + case 'U': + return ; + case 'G': + return ; + case 'R': + return ; + case 'K': + return ; + case 'gold': + return ; + } +} + +function Gem({ c, size = 18 }: { c: TokenColor; size?: number }) { + return ( + + + {c !== 'gold' && } + + ); +} + +/** Gem + number, with the Korean colour letter for screen readers and colour-blind players. */ +function Pip({ c, n, size = 16, letter = true }: { c: TokenColor; n: number; size?: number; letter?: boolean }) { + return ( + + + {letter && {SHORT[c]}} + {n} + + ); +} + +/** Tier emblems: 1 광산(산과 곡괭이), 2 운송(수레), 3 상점(가게 지붕). */ +function TierArt({ tier }: { tier: Tier }) { + return ( + + {tier === 1 && ( + <> + + + + )} + {tier === 2 && ( + <> + + + + + )} + {tier === 3 && ( + <> + + + + + )} + + ); +} + +function CardFace({ card }: { card: DevCard }) { + return ( + <> + + {card.points > 0 ? card.points : ''} + + + {SHORT[card.bonus]} + + + + + {COLORS.filter((c) => card.cost[c] > 0).map((c) => ( + + ))} + + + {'●'.repeat(card.tier)} + + + ); +} + +function CardBack({ tier, n, label }: { tier: Tier; n?: number; label?: string }) { + return ( + <> + {n !== undefined && {n}} + {label ?? `${tier}단계`} + + {'●'.repeat(tier)} + + + ); +} + +function NobleTile({ noble, need, onClick, pick }: { noble: Noble; need?: number; onClick?: () => void; pick?: boolean }) { + const name = NOBLE_NAME[noble.id] ?? `귀족 ${Number(noble.id.slice(1))}`; + const req = COLORS.filter((c) => noble.req[c] > 0); + const label = `${name}, ${noble.points}점, 필요한 보너스 ${req.map((c) => `${SHORT[c]} ${noble.req[c]}`).join(' ')}${need !== undefined ? `, 남은 보너스 ${need}` : ''}`; + const body = ( + <> + + 👑 {name} + + + 👑 + + + {noble.points} + + + {req.map((c) => ( + + ))} + + {need !== undefined && ( + + {need === 0 ? ( + '충족' + ) : ( + <> + 남은 보너스 + {need} + + )} + + )} + + ); + if (onClick) + return ( + + ); + return ( +
+ {body} +
+ ); +} + +// ---------------------------------------------------------------- board + +type Sel = { kind: 'board'; tier: Tier; slot: number } | { kind: 'reserved'; index: number } | { kind: 'deck'; tier: Tier }; +const selKey = (s: Sel) => (s.kind === 'board' ? `b${s.tier}-${s.slot}` : s.kind === 'reserved' ? `r${s.index}` : `d${s.tier}`); + +function SplendorBoard({ view: v, me, players, act, pending, tapConfirm }: BoardProps) { + const my = v.me; + const mine = me ? v.players[me] : undefined; + const acting = !!my?.myTurn && v.phase === 'action'; + const hints = v.options.showHints; + const [picks, setPicks] = useState([]); + const [sel, setSel] = useState(null); + const [manual, setManual] = useState(null); + const [back, setBack] = useState(emptyTokens); + const [msg, setMsg] = useState<{ text: string; bad: boolean } | null>(null); + const [shake, setShake] = useState<{ c: TokenColor; n: number } | null>(null); + const [crown, setCrown] = useState(null); + const [announce, setAnnounce] = useState(''); + const [openOpp, setOpenOpp] = useState(null); + const [askPass, setAskPass] = useState(false); + const [help, setHelp] = useState(() => { + try { + return localStorage.getItem('sp-help-seen') !== '1'; + } catch { + return false; + } + }); + const prevTurn = useRef(v.turnSeq); + const prevLog = useRef(v.log.at(-1)?.n ?? 0); + const prevBoard = useRef>({}); + const [fresh, setFresh] = useState>(new Set()); + + // Reset local choices whenever the turn or phase moves on. + useEffect(() => { + setPicks([]); + setSel(null); + setManual(null); + setBack(emptyTokens()); + setMsg(null); + }, [v.turnSeq, v.phase]); + + // My-turn cue. + useEffect(() => { + if (v.turnSeq === prevTurn.current) return; + prevTurn.current = v.turnSeq; + if (my?.myTurn) { + haptic(40); + if (document.visibilityState === 'hidden') sounds.myTurn(); + } + }, [v.turnSeq]); // eslint-disable-line react-hooks/exhaustive-deps + + // New log lines → screen reader text, sounds, noble crown. + useEffect(() => { + const last = prevLog.current; + const news = v.log.filter((e) => e.n > last); + prevLog.current = v.log.at(-1)?.n ?? last; + if (!news.length || last === 0) return; + setAnnounce(news.map((e) => logText(e, players, me)).join(' ')); + if (news.some((e) => e.k === 'buy' || e.k === 'take')) sounds.stone(); + const noble = news.find((e) => e.k === 'noble'); + if (noble && noble.k === 'noble') { + setCrown(`👑 ${noble.p === me ? '나에게' : `${nick(players, noble.p)}님에게`} ${NOBLE_NAME[noble.nobleId] ?? '귀족'} 방문!`); + haptic([60, 40, 60]); + } + }, [v.log]); // eslint-disable-line react-hooks/exhaustive-deps + + useEffect(() => { + if (!crown) return; + const t = setTimeout(() => setCrown(null), reducedMotion() ? 2200 : 1500); + return () => clearTimeout(t); + }, [crown]); + + // Newly revealed board cards get a short entrance animation. + useEffect(() => { + const now: Record = {}; + const changed = new Set(); + for (const t of TIERS_DOWN) + v.board[t].forEach((c, i) => { + const k = `b${t}-${i}`; + now[k] = c?.id ?? null; + if (prevBoard.current[k] !== undefined && prevBoard.current[k] !== now[k] && c) changed.add(k); + }); + prevBoard.current = now; + if (changed.size) { + setFresh(changed); + const t = setTimeout(() => setFresh(new Set()), 600); + return () => clearTimeout(t); + } + }, [v.board]); + + useEffect(() => { + if (!shake) return; + const t = setTimeout(() => setShake(null), 400); + return () => clearTimeout(t); + }, [shake]); + + const refuse = (c: TokenColor, text: string) => { + setShake({ c, n: Date.now() }); + setMsg({ text, bad: true }); + sounds.warn(); + }; + + // ---- tokens + const same = picks.length === 2 && picks[0] === picks[1]; + const takeCount = my?.takeDifferent?.count ?? 0; + const pickValid = acting && (same ? !!my?.takeSame.includes(picks[0]!) : takeCount > 0 && picks.length === takeCount); + const tapToken = (c: Color) => { + if (!acting || pending) return; + setSel(null); + setManual(null); + if (v.supply[c] <= 0) return refuse(c, '남아 있지 않은 색이에요.'); + setMsg(null); + if (same) return setPicks([c]); + if (picks.length === 1 && picks[0] === c) { + if (my?.takeSame.includes(c)) { + setPicks([c, c]); + setMsg({ text: `${NAME[c]} 2개를 가져와요.`, bad: false }); + return; + } + return refuse(c, '같은 색 2개는 4개 이상 남은 색에서만 가져올 수 있어요.'); + } + if (picks.includes(c)) return setPicks(picks.filter((x) => x !== c)); + if (takeCount === 0) return refuse(c, '서로 다른 색 보석은 가져올 수 없어요.'); + if (picks.length >= takeCount) return refuse(c, `서로 다른 색은 ${takeCount}개까지 골라요.`); + setPicks([...picks, c]); + }; + const take = () => { + if (!pickValid || pending) return; + act(same ? { type: 'TAKE_SAME', color: picks[0] } : { type: 'TAKE_DIFFERENT', colors: picks }); + setPicks([]); + }; + + // ---- cards + const selCard: DevCard | null = !sel + ? null + : sel.kind === 'board' + ? v.board[sel.tier][sel.slot] ?? null + : sel.kind === 'reserved' + ? (() => { + const r = mine?.reserved[sel.index]; + return r && !r.hidden ? (r as DevCard) : null; + })() + : null; + const buyOpt: BuyOption | undefined = sel && sel.kind !== 'deck' ? my?.buy[selKey(sel)] : undefined; + const canReserveSel = !!my && acting && (sel?.kind === 'board' ? my.canReserve : sel?.kind === 'deck' ? my.reserveDecks.includes(sel.tier) : false); + const choose = (s: Sel) => { + setPicks([]); + setManual(null); + setMsg(null); + setSel(sel && selKey(sel) === selKey(s) ? null : s); + }; + const payment = manual ?? buyOpt?.payment ?? null; + const deckSel = sel?.kind === 'deck' ? sel : null; + const buy = () => { + if (!sel || sel.kind === 'deck' || !buyOpt || !acting || pending) return; + const from = sel.kind === 'board' ? { tier: sel.tier, slot: sel.slot } : { reservedIndex: sel.index }; + act(manual ? { type: 'BUY', from, payment: manual } : { type: 'BUY', from }); + setSel(null); + setManual(null); + }; + const reserve = () => { + if (!sel || sel.kind === 'reserved' || !canReserveSel || pending) return; + act({ type: 'RESERVE', from: sel.kind === 'board' ? { tier: sel.tier, slot: sel.slot } : { tier: sel.tier, deck: true } }); + setSel(null); + }; + const swapGold = (c: Color, toGold: boolean) => { + if (!payment || !mine) return; + const next = { ...payment }; + if (toGold) { + if (next[c] <= 0 || next.gold >= mine.tokens.gold) return; + next[c]--; + next.gold++; + } else { + if (next.gold <= 0 || next[c] >= mine.tokens[c] || !buyOpt || next[c] >= buyOpt.need[c]) return; + next[c]++; + next.gold--; + } + setManual(next); + }; + + // ---- discard + const discardCount = my?.discardCount ?? 0; + const tapBack = (c: TokenColor, add: boolean) => { + if (!mine) return; + const next = { ...back }; + if (add) { + if (sum(back) >= discardCount) return refuse(c, `${discardCount}개만 돌려놓으면 돼요.`); + if (next[c] >= mine.tokens[c]) return; + next[c]++; + } else if (next[c] > 0) next[c]--; + setMsg(null); + setBack(next); + }; + + const cardClass = (key: string, extra = '') => { + const opt = my?.buy[key]; + const hl = hints && opt && !mine?.conceded ? (opt.gold > 0 ? ' gold' : ' can') : ''; + return `sp-card${hl}${sel && selKey(sel) === key ? ' sel' : ''}${fresh.has(key) ? ' fresh' : ''}${extra}`; + }; + const cardAria = (card: DevCard, key: string) => { + const opt = my?.buy[key]; + return `${cardLabel(card)}${opt ? `. 살 수 있어요${opt.gold ? `(황금 ${opt.gold}개 사용)` : ''}` : ''}`; + }; + const flag = (key: string) => { + const opt = my?.buy[key]; + if (!hints || !opt) return null; + return ( + + {opt.gold ? `금${opt.gold}` : '✓ 가능'} + + ); + }; + + const others = v.order.filter((p) => p !== me); + + return ( +
+ + {others.length > 0 && ( +
+ {others.map((p) => { + const pv = v.players[p]!; + return ( + + ); + })} +
+ )} + {openOpp && v.players[openOpp] && ( +
+ +
+ )} + {help && v.phase !== 'finished' && ( +
+ + 💎 보석 모으기→🃏 카드 사기→할인(보너스)→더 비싼 카드→👑 귀족 + + {v.options.targetPoints}점이면 그 라운드까지 + +
+ )} +
+
+ {v.nobles.map((n) => ( + + ))} + {v.nobles.length === 0 && 남은 귀족이 없어요} +
+
+ {TIERS_DOWN.map((t) => ( +
+ + {v.board[t].map((card, i) => { + const key = `b${t}-${i}`; + if (!card) + return ( +
+ 빈 칸 +
+ ); + return ( + + ); + })} +
+ ))} +
+
+ {TOKEN_COLORS.map((c) => { + const n = v.supply[c]; + const count = picks.filter((x) => x === c).length; + const isGold = c === 'gold'; + return ( + + ); + })} +
+
+
+ {mine && choose({ kind: 'reserved', index: i })} cardClass={cardClass} cardAria={cardAria} flag={flag} />} + {my && !mine?.conceded && v.phase !== 'finished' && ( +
+ {!my.myTurn && ( + <> +

{v.current ? `${nick(players, v.current)}님 차례예요` : ''}

+

+ {selCard ? `${cardLabel(selCard)}${buyOpt ? ` · 지금이라면 ${buyOpt.total ? `보석 ${buyOpt.total}개로` : '공짜로'}살 수 있어요` : ''}` : '카드를 눌러 자세히 볼 수 있어요. 내 차례가 되면 보석을 가져오거나 카드를 사요.'} +

+ + )} + {my.myTurn && v.phase === 'discard' && ( + <> +

보석이 10개를 넘었어요. {discardCount}개를 돌려놓아 주세요

+
+ {TOKEN_COLORS.filter((c) => mine!.tokens[c] - back[c] > 0).map((c) => ( + + ))} +
+
+ 돌려놓을 것 {sum(back)}/{discardCount}: + {TOKEN_COLORS.filter((c) => back[c] > 0).map((c) => ( + + ))} +
+
+ +
+ + )} + {my.myTurn && v.phase === 'noble' && ( + <> +

귀족 두 명 이상이 찾아왔어요. 한 명을 골라 주세요

+
+ {(my.nobleChoices ?? []).map((id) => { + const n = v.nobles.find((x) => x.id === id); + return n ? !pending && act({ type: 'CHOOSE_NOBLE', nobleId: id })} /> : null; + })} +
+

고르지 않은 귀족은 다음 차례 이후에 다시 찾아올 수 있어요.

+ + )} + {acting && ( + <> + {selCard || deckSel ? ( + <> +
+ {selCard ? ( + + + + ) : ( + + + + )} +
+ {selCard ? `${selCard.tier}단계 ${TIER_NAME[selCard.tier]} · ${SHORT[selCard.bonus]} 보너스 · ${selCard.points}점` : `${deckSel!.tier}단계 더미 맨 위 카드 (안 보고 예약)`} + {selCard && ( + + {buyOpt && payment ? ( + <> + 내는 보석: {sum(payment) === 0 ? '없음(보너스로 공짜!)' : TOKEN_COLORS.filter((c) => payment[c]).map((c) => )} + {payment.gold > 0 && · 황금 {payment.gold}개 사용} + + ) : ( + 보석이 부족해서 아직 살 수 없어요. + )} + + )} + {!selCard && 예약하면 {my.goldOnReserve ? '황금 1개를 받아요' : '황금이 없어서 카드만 가져와요'}. 다른 사람은 이 카드를 볼 수 없어요.} + {selCard && sel?.kind === 'board' && my.canReserve && 예약하면 {my.goldOnReserve ? '황금 1개를 받아요' : '황금 없이 카드만 가져와요'} (예약 {mine!.reservedCount}/3)} +
+
+ {manual && buyOpt && ( +
+ {COLORS.filter((c) => buyOpt.need[c] > 0).map((c) => ( +
+ + +금{buyOpt.need[c] - manual[c]} + + + +
+ ))} +
+ )} +
+ {sel?.kind !== 'deck' && ( + + )} + {sel?.kind !== 'reserved' && ( + + )} + {buyOpt && mine!.tokens.gold > 0 && ( + + )} + +
+ {!canReserveSel && sel?.kind !== 'reserved' && mine!.reservedCount >= 3 &&

예약은 3장까지만 할 수 있어요.

} + + ) : ( + <> +
+ {picks.length ? ( + <> + 가져올 보석: + {picks.map((c, i) => ( + + ))} + {picks.map((c) => SHORT[c]).join(' · ')} + + ) : ( + + {my.canPass + ? '할 수 있는 행동이 없어요. 차례를 넘겨 주세요.' + : takeCount === 3 + ? '보석 은행에서 서로 다른 색 3개(또는 같은 색 두 번)를 누르거나, 카드를 눌러 사거나 예약해요.' + : takeCount > 0 + ? `남은 색이 적어서 서로 다른 색 ${takeCount}개를 가져올 수 있어요. 카드를 눌러 사거나 예약할 수도 있어요.` + : '카드를 눌러 사거나 예약해요.'} + + )} +
+
+ {my.canPass ? ( + + ) : ( + <> + + {picks.length > 0 && ( + + )} + + )} +
+ + )} +

+ {msg?.text ?? ''} +

+ + )} +
+ )} +
+

+ {announce} +

+ {crown && ( +
+ {crown} +
+ )} + {askPass && act({ type: 'PASS' })} onClose={() => setAskPass(false)} />} +
+ ); +} + +function MyArea({ + v, + pv, + me, + sel, + onPick, + cardClass, + cardAria, + flag, +}: { + v: SplendorView; + pv: SplendorPlayerView; + me: string; + sel: Sel | null; + onPick: (i: number) => void; + cardClass: (key: string, extra?: string) => string; + cardAria: (card: DevCard, key: string) => string; + flag: (key: string) => React.ReactNode; +}) { + const turn = v.current === me; + return ( +
+
+ + 나 · {pv.points}점 + / {v.options.targetPoints}점 + + + 보석 10 ? 'var(--danger)' : undefined }}>{pv.tokenCount}/10 · 카드 {pv.cards.length}장{pv.nobles.length ? ` · 👑 ${pv.nobles.length}` : ''} + +
+
+ {TOKEN_COLORS.map((c) => ( +
+ + + {c === 'gold' ? '황금' : `할인 ${pv.bonuses[c as Color]}`} + + + {pv.tokens[c]} + +
+ ))} +
+
+ 예약 {pv.reservedCount}/3 + {pv.reserved.map((r, i) => { + if (r.hidden) return null; + const card = r as Extract; + const key = `r${i}`; + return ( + + ); + })} +
+
+ ); +} + +/** Bonuses and tokens per colour, reserved cards (blind ones as backs), nobles. */ +function PlayerDetail({ pv, name }: { pv: SplendorPlayerView; name: string }) { + return ( +
+
+ {TOKEN_COLORS.map((c) => ( + + + {c === 'gold' ? '-' : `+${pv.bonuses[c as Color]}`} + {pv.tokens[c]} + + ))} +
+ {pv.reservedCount > 0 && ( +
+ {pv.reserved.map((r, i) => + r.hidden ? ( + + {r.tier}단계 + + ) : ( + + + {(r as DevCard).points || ''} + + + + + + ), + )} +
+ )} + {pv.nobles.length > 0 && 👑 {pv.nobles.map((id) => NOBLE_NAME[id] ?? id).join(', ')}} +
+ ); +} + +// ---------------------------------------------------------------- side panel + +function logText(e: LogEntry, players: Record, me: string | null): string { + const who = e.p === me ? '내가' : `${nick(players, e.p)}님이`; + const auto = 'auto' in e && e.auto ? ' (시간 초과로 자동)' : ''; + switch (e.k) { + case 'take': + return `${who} 보석을 가져왔어요: ${costText(e.tokens)}${auto}`; + case 'reserve': + return `${who} ${e.tier}단계 카드를 예약했어요${e.cardId ? '' : '(더미에서)'}${e.gold ? ' · 황금 +1' : ''}${auto}`; + case 'buy': + return `${who} 카드를 샀어요: ${SHORT[e.cardId[1] as Color] ?? ''} 보너스 ${Number(e.cardId[0])}단계 (낸 보석: ${costText(e.paid)})${auto}`; + case 'pass': + return `${who} 할 수 있는 행동이 없어 넘겼어요${auto}`; + case 'discard': + return `${who} 보석을 돌려놓았어요: ${costText(e.tokens)}${auto}`; + case 'noble': + return `${NOBLE_NAME[e.nobleId] ?? '귀족'}${iGa(NOBLE_NAME[e.nobleId] ?? '귀족')} ${e.p === me ? '나를' : `${nick(players, e.p)}님을`} 찾아왔어요 (+3점)`; + case 'concede': + return `${who} 기권했어요`; + case 'away': + return `${e.p === me ? '나는' : `${nick(players, e.p)}님은`} 자리 비움이에요 (3번 연속 시간 초과)`; + case 'end': + return `${who} ${e.points}점에 도달했어요! 이번 라운드까지만 해요`; + } +} + +function SplendorSide({ view: v, me, players, act, deadline }: BoardProps) { + const [ask, setAsk] = useState(false); + const mine = me ? v.players[me] : undefined; + const log = useMemo(() => [...v.log].reverse(), [v.log]); + return ( + <> +
+
+ + {v.phase === 'finished' ? '게임 끝' : v.endTriggered ? '🏁 마지막 라운드예요' : `${v.options.targetPoints}점을 먼저 넘기면 그 라운드까지`} + +
+ {v.order.map((p, i) => { + const pv = v.players[p]!; + const turn = v.current === p; + return ( +
+
+ + {i === 0 ? '선' : i + 1} + + +
+
+ {nick(players, p)} + {p === me && (나)} +
+
+ {pv.conceded ? '기권' : pv.away ? '자리 비움' : `보석 ${pv.tokenCount} · 카드 ${pv.cards.length} · 예약 ${pv.reservedCount}`} +
+
+ + {pv.points} + + {turn && v.phase !== 'finished' && } +
+ {p !== me && } +
+ ); + })} +
+ {v.result && ( +
+ 최종 순위 + {v.result.ranking.map((r) => ( +
+ {r.rank}위 + {nick(players, r.player)} + + {r.points}점 · 카드 {r.cards}장{r.nobles ? ` · 귀족 ${r.nobles}` : ''} + {r.conceded ? ' · 기권' : ''} + +
+ ))} +
+ )} + {log.length > 0 && ( +
+ 기록 +
    + {log.map((e) => ( +
  1. + {logText(e, players, me)} +
  2. + ))} +
+
+ )} +
+ 규칙 요약 + +
+ {v.phase !== 'finished' && mine && !mine.conceded && ( + + )} + {ask && ( + act({ type: 'CONCEDE' })} + onClose={() => setAsk(false)} + /> + )} + + ); +} + +// ---------------------------------------------------------------- status + +function isMyTurn(v: SplendorView, me: string | null): boolean { + return !!me && !!v.me?.myTurn && v.phase !== 'finished'; +} + +function statusText(v: SplendorView, me: string | null, players: Record): string { + if (v.phase === 'finished') return v.result?.summary ?? '게임이 끝났어요'; + if (me && v.players[me]?.conceded) return '기권했어요. 구경하는 중이에요'; + const last = v.endTriggered ? ' · 마지막 라운드' : ''; + if (isMyTurn(v, me)) { + if (v.phase === 'discard') return `내 차례예요. 보석이 10개가 되도록 ${v.me!.discardCount}개를 돌려놓아 주세요`; + if (v.phase === 'noble') return '내 차례예요. 찾아온 귀족 중 한 명을 골라 주세요'; + if (v.me!.canPass) return '내 차례예요. 할 수 있는 행동이 없어 넘겨야 해요'; + return `내 차례예요. 보석을 가져오거나 카드를 사거나 예약해 주세요${last}`; + } + if (!v.current) return '잠시만 기다려 주세요'; + const doing = v.phase === 'discard' ? ' · 보석 돌려놓는 중' : v.phase === 'noble' ? ' · 귀족 고르는 중' : ''; + return `${nick(players, v.current)}님 차례예요${doing}${last}`; +} + +// ---------------------------------------------------------------- options + +function Seg({ value, options, onChange, label }: { value: T; options: [T, string][]; onChange: (v: T) => void; label: string }) { + return ( +
+ {options.map(([k, l]) => ( + + ))} +
+ ); +} + +function Field({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ {label} + {children} +
+ ); +} + +function Options({ value: o, onChange }: OptionsProps) { + const set = (patch: Partial) => onChange({ ...o, ...patch }); + return ( +
+ + set({ targetPoints })} /> + + + set({ turnSeconds: k === 'none' ? null : (Number(k) as 30 | 60 | 90) })} + /> + + + set({ allowFewerTokens: k === 'on' })} + /> + + + set({ showHints: k === 'on' })} /> + +
+ ); +} + +function optionsSummary(o: SplendorOptions): string { + return [`${o.targetPoints}점`, o.turnSeconds === null ? '시간 제한 없음' : `한 차례 ${o.turnSeconds}초`, ...(o.allowFewerTokens ? [] : ['3색 미만이면 가져오기 불가']), ...(o.showHints ? [] : ['힌트 끔'])].join(' · '); +} + +// ---------------------------------------------------------------- rules + +function Rules({ options }: { options?: SplendorOptions }) { + const target = options?.targetPoints ?? 15; + return ( +
+

+ 보석 상인이 되어 보석을 모으고, 그 보석으로 개발 카드를 사요. 산 카드의 보석은 영구 할인(보너스)이 되어 다음 카드값을 색마다 1개씩 + 깎아 줘요. 카드의 큰 숫자가 점수예요. +

+

차례마다 아래 중 하나만 해요.

+
    +
  • + 서로 다른 색 보석 3개 가져오기{options?.allowFewerTokens === false ? '' : ' (남은 색이 3가지보다 적으면 남은 색만큼)'} +
  • +
  • + 같은 색 보석 2개 가져오기 (그 색이 4개 이상 남아 있을 때만) +
  • +
  • + 카드 1장 예약: 공개된 카드나 더미 맨 위 카드를 손에 두고 황금 1개를 받아요. 예약은 3장까지예요. +
  • +
  • + 카드 1장 사기: 공개 카드나 내 예약 카드. 황금은 아무 색 대신 쓸 수 있어요. +
  • +
+

+ 차례가 끝날 때 보석이 10개를 넘으면 10개가 되도록 돌려놓아요. 내 보너스가 귀족 타일의 조건을 채우면 귀족이 찾아와 3점을 줘요(한 차례에 한 명). +

+

+ 누군가 {target}점 이상이 되면 그 라운드를 끝까지 하고(모두 같은 횟수) 점수가 가장 높은 사람이 이겨요. 같으면 산 카드가 적은 사람이 이겨요. +

+
+ {TOKEN_COLORS.map((c) => ( + + {NAME[c]} + + ))} +
+

+ 시간 안에 하지 않으면 살 수 있는 카드 중 점수가 가장 높은 카드를 사고, 없으면 많이 남은 색 보석 3개를 자동으로 가져와요. 할 수 있는 행동이 없으면 차례를 넘겨요. +

+
+ ); +} + +const ui: GameUI = { + Board: SplendorBoard, + Side: SplendorSide, + statusText, + isMyTurn, + Options, + optionsSummary, + Rules, +}; + +export default ui; diff --git a/apps/web/src/lib/catalog-ui.ts b/apps/web/src/lib/catalog-ui.ts index a88513f..e9af776 100644 --- a/apps/web/src/lib/catalog-ui.ts +++ b/apps/web/src/lib/catalog-ui.ts @@ -16,6 +16,7 @@ export const GAME_ICON: Record = { marble: '🏙️', bang: '🤠', 'halli-galli': '🔔', + splendor: '💎', }; /** Shown on the home screen so users know what's coming (docs/12-roadmap.md). */ diff --git a/e2e/games.e2e.ts b/e2e/games.e2e.ts index d1d9937..4a2fb74 100644 --- a/e2e/games.e2e.ts +++ b/e2e/games.e2e.ts @@ -260,6 +260,97 @@ async function marbleStep(p: Page, stats: Record): Promise, turn: number): Promise { + const status = (await p.locator('.turn-status').innerText().catch(() => '')).trim(); + if (!status.startsWith('내 차례예요')) return null; + const phone = (p.viewportSize()?.width ?? 0) < 600; + const press = async (l: ReturnType) => { + if (phone) await l.tap({ timeout: 2000 }); + else if (turn % 2 === 0) { + // Keyboard path on PC every other turn: focus + Enter. + await l.focus({ timeout: 2000 }); + await p.keyboard.press('Enter'); + } else await l.click({ timeout: 2000 }); + }; + const panel = p.locator('.sp-panel'); + if (status.includes('돌려놓아')) { + const need = Number(status.match(/(\d+)개를 돌려놓아/)?.[1] ?? 0); + for (let i = 0; i < need; i++) await press(panel.locator('.sp-basket').first().locator('button.sp-token').first()); + await press(panel.getByRole('button', { name: `돌려놓기 (${need}/${need})` })); + return 'discard'; + } + if (status.includes('귀족 중 한 명')) { + await press(panel.getByRole('button', { name: /이 귀족 고르기$/ }).first()); + return 'noble'; + } + if (status.includes('넘겨야')) { + await press(panel.getByRole('button', { name: '차례 넘기기' })); + const ok = p.getByRole('button', { name: '넘기기', exact: true }); + if (await ok.isVisible({ timeout: 500 }).catch(() => false)) await ok.click(); + return 'pass'; + } + const affordable = p.locator('.sp-market button.sp-card.can, .sp-market button.sp-card.gold, .sp-mine button.sp-card.can, .sp-mine button.sp-card.gold'); + const reserveTurn = (stats.reserve ?? 0) < 2 && turn >= 2 && turn % 5 === 2; + // The phone player hoards gems early on (no buying) until it has had to give some back once. + const hoard = phone && !stats.discard && turn < 30; + if ((await affordable.count()) && !reserveTurn && !hoard) { + const card = affordable.first(); + await press(card); + if ((await p.locator('.sp-card.gold.sel').count()) && !stats.shotPay) { + stats.shotPay = 1; + await p.waitForTimeout(250); + await p.screenshot({ path: join(SHOTS, `splendor-pay-${phone ? 'phone' : 'pc'}.png`), fullPage: phone }); + } + await press(panel.getByRole('button', { name: '🛒 사기' })); + return 'buy'; + } + const reserveBtn = panel.getByRole('button', { name: /^📌 예약/ }); + if (reserveTurn) { + // Tier 1 open card on even turns, the tier 2 deck (blind) otherwise. + const target = (stats.reserve ?? 0) === 0 ? p.locator('.sp-market .sp-row').nth(2).locator('button.sp-card').first() : p.locator('.sp-market .sp-row').nth(1).locator('button.sp-deck'); + await press(target); + if (await reserveBtn.isEnabled({ timeout: 800 }).catch(() => false)) { + await press(reserveBtn); + return 'reserve'; + } + await press(panel.getByRole('button', { name: '닫기' })); + } + const tokens = p.locator('.sp-bank button.sp-token:not([disabled])'); + const counts = await tokens.evaluateAll((els) => els.map((e) => Number(e.querySelector('.ct')?.textContent ?? 0))); + const four = counts.findIndex((n) => n >= 4); + if ((stats.same ?? 0) < 2 && four >= 0 && turn % 3 === 1) { + await press(tokens.nth(four)); + await press(tokens.nth(four)); + await press(panel.getByRole('button', { name: '💎 가져오기 (2개)' })); + return 'same'; + } + const want = Number((await panel.locator('.sp-basket').innerText()).match(/서로 다른 색 (\d)개/)?.[1] ?? 3); + let picked = 0; + for (let i = 0; i < counts.length && picked < want; i++) { + if (counts[i]! > 0) { + await press(tokens.nth(i)); + picked++; + } + } + const takeBtn = panel.getByRole('button', { name: /^💎 가져오기/ }); + if (picked > 0 && (await takeBtn.isEnabled().catch(() => false))) { + await press(takeBtn); + return 'different'; + } + // Nothing to take: reserve whatever is first, or buy anything. + await press(p.locator('.sp-market button.sp-card').first()); + if (await reserveBtn.isEnabled({ timeout: 500 }).catch(() => false)) { + await press(reserveBtn); + return 'reserve'; + } + return null; +} + const PLANS: Record = { rummikub: { // PC host + phone guest, first meld "없음" so hint plays happen early; 10 real turns through the UI. @@ -879,6 +970,62 @@ const PLANS: Record = { console.log(` marble: finished after ${actions} UI actions ${JSON.stringify(stats)}`); }, }, + splendor: { + // PC host + phone guest, short game (10 points) through the UI to the result: 3 different gems, 2 of a + // colour, reserve an open card and a blind deck card (gold), buy highlighted cards (with gold too), + // return tokens over 10 and choose nobles when it happens. Mid-game shots in light and dark. + lobby: async (host) => { + await host.getByRole('group', { name: '끝나는 점수' }).getByRole('button', { name: '10점(짧은 판)' }).click(); + await host.getByRole('button', { name: '설정 저장' }).click(); + await host.getByText(/^10점 · 한 차례 60초/).first().waitFor(); + }, + drive: async (pages) => { + const [host, guest] = pages as [Page, Page]; + const stats: Record = {}; + let turns = 0; + let shot = false; + const until = Date.now() + 480_000; + const over = () => host.getByRole('dialog', { name: '게임 결과' }).count(); + while (Date.now() < until && !(await over())) { + let acted: string | null = null; + for (const p of pages) { + const before = (await p.locator('.turn-status').innerText().catch(() => '')).trim(); + acted = await splendorStep(p, stats, turns); + if (acted) { + stats[acted] = (stats[acted] ?? 0) + 1; + turns++; + // Wait until the server answered (status or log changed). + await p + .waitForFunction((b) => (document.querySelector('.turn-status')?.textContent ?? '').trim() !== b || !!document.querySelector('[role="dialog"]'), before, { timeout: 5000 }) + .catch(() => {}); + break; + } + } + if (turns >= 14 && !shot) { + shot = true; + await host.waitForTimeout(700); + await host.screenshot({ path: join(SHOTS, 'splendor-mid-pc.png') }); + await guest.screenshot({ path: join(SHOTS, 'splendor-mid-phone.png') }); + await guest.screenshot({ path: join(SHOTS, 'splendor-mid-phone-full.png'), fullPage: true }); + await host.emulateMedia({ colorScheme: 'dark' }); + await guest.emulateMedia({ colorScheme: 'dark' }); + await host.waitForTimeout(400); + await host.screenshot({ path: join(SHOTS, 'splendor-mid-pc-dark.png') }); + await guest.screenshot({ path: join(SHOTS, 'splendor-mid-phone-dark.png'), fullPage: true }); + await host.emulateMedia({ colorScheme: 'light' }); + await guest.emulateMedia({ colorScheme: 'light' }); + } + if (!acted) await host.waitForTimeout(150); + } + if (!(await over())) throw new Error(`game did not finish (${turns} UI turns: ${JSON.stringify(stats)})`); + await guest.getByRole('dialog', { name: '게임 결과' }).waitFor(); + await host.waitForTimeout(600); + await host.screenshot({ path: join(SHOTS, 'splendor-result-pc.png') }); + await guest.screenshot({ path: join(SHOTS, 'splendor-result-phone.png') }); + if (turns < 15 || !stats.buy || !stats.reserve || !stats.different || !stats.same || !stats.discard) throw new Error(`too few kinds of turns: ${JSON.stringify(stats)}`); + console.log(` splendor: finished after ${turns} UI turns ${JSON.stringify(stats)}`); + }, + }, }; const games = process.argv.slice(2).length ? process.argv.slice(2) : Object.keys(PLANS); diff --git a/packages/games/src/index.ts b/packages/games/src/index.ts index 180c602..777ea83 100644 --- a/packages/games/src/index.ts +++ b/packages/games/src/index.ts @@ -16,6 +16,7 @@ import { gostop } from './gostop'; import { bang } from './bang'; import { halliGalli } from './halli-galli'; import { marble } from './marble'; +import { splendor } from './splendor'; export const GAMES: Record = { [omok.id]: omok, @@ -35,4 +36,5 @@ export const GAMES: Record = { [bang.id]: bang, [halliGalli.id]: halliGalli, [marble.id]: marble, + [splendor.id]: splendor, }; diff --git a/packages/games/src/splendor/cards.ts b/packages/games/src/splendor/cards.ts new file mode 100644 index 0000000..53317b3 --- /dev/null +++ b/packages/games/src/splendor/cards.ts @@ -0,0 +1,76 @@ +/** + * 보석 상인 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; +export type Color = (typeof COLORS)[number]; +export type TokenColor = Color | 'gold'; +export const TOKEN_COLORS: readonly TokenColor[] = [...COLORS, 'gold']; +export type Gems = Record; +export type Tokens = Record; +export type Tier = 1 | 2 | 3; +export const TIERS: readonly Tier[] = [1, 2, 3]; + +export interface DevCard { + id: string; + tier: Tier; + bonus: Color; + points: number; + cost: Gems; +} +export interface Noble { + id: string; + points: 3; + req: Gems; +} + +const RAW_CARDS = [ + // tier 1 + '1W01:0:01111', '1W02:0:01211', '1W03:0:02201', '1W04:0:31001', '1W05:0:00021', '1W06:0:02002', '1W07:0:03000', '1W08:1:00400', + '1U01:0:10111', '1U02:0:10121', '1U03:0:10220', '1U04:0:01310', '1U05:0:10002', '1U06:0:00202', '1U07:0:00003', '1U08:1:00040', + '1G01:0:11011', '1G02:0:11012', '1G03:0:01022', '1G04:0:13100', '1G05:0:21000', '1G06:0:02020', '1G07:0:00030', '1G08:1:00004', + '1R01:0:11101', '1R02:0:21101', '1R03:0:20102', '1R04:0:10013', '1R05:0:02100', '1R06:0:20020', '1R07:0:30000', '1R08:1:40000', + '1K01:0:11110', '1K02:0:12110', '1K03:0:22010', '1K04:0:00131', '1K05:0:00210', '1K06:0:20200', '1K07:0:00300', '1K08:1:04000', + // tier 2 + '2W01:1:00322', '2W02:1:23030', '2W03:2:00142', '2W04:2:00053', '2W05:2:00050', '2W06:3:60000', '2U01:1:02230', '2U02:1:02303', + '2U03:2:53000', '2U04:2:20014', '2U05:2:05000', '2U06:3:06000', '2G01:1:30230', '2G02:1:23002', '2G03:2:42001', '2G04:2:05300', + '2G05:2:00500', '2G06:3:00600', '2R01:1:20023', '2R02:1:03023', '2R03:2:14200', '2R04:2:30005', '2R05:2:00005', '2R06:3:00060', + '2K01:1:32200', '2K02:1:30302', '2K03:2:01420', '2K04:2:00530', '2K05:2:50000', '2K06:3:00006', + // tier 3 + '3W01:3:03353', '3W02:4:00007', '3W03:4:30036', '3W04:5:30007', '3U01:3:30335', '3U02:4:70000', '3U03:4:63003', '3U04:5:73000', + '3G01:3:53033', '3G02:4:07000', '3G03:4:36300', '3G04:5:07300', '3R01:3:35303', '3R02:4:00700', '3R03:4:03630', '3R04:5:00730', + '3K01:3:33530', '3K02:4:00070', '3K03:4:00363', '3K04:5:00073', +]; + +const RAW_NOBLES = [ + 'N01:00440', 'N02:04400', 'N03:44000', 'N04:40004', 'N05:00044', 'N06:03330', 'N07:33300', 'N08:33003', 'N09:30033', 'N10:00333', +]; + +const gems = (digits: string): Gems => ({ W: +digits[0]!, U: +digits[1]!, G: +digits[2]!, R: +digits[3]!, K: +digits[4]! }); + +export const CARDS: readonly DevCard[] = RAW_CARDS.map((raw) => { + const [id, points, cost] = raw.split(':') as [string, string, string]; + return { id, tier: Number(id[0]) as Tier, bonus: id[1] as Color, points: Number(points), cost: gems(cost) }; +}); +export const NOBLES: readonly Noble[] = RAW_NOBLES.map((raw) => { + const [id, req] = raw.split(':') as [string, string]; + return { id, points: 3, req: gems(req) }; +}); + +const CARD_BY_ID = new Map(CARDS.map((c) => [c.id, c])); +const NOBLE_BY_ID = new Map(NOBLES.map((n) => [n.id, n])); +export function cardOf(id: string): DevCard { + const c = CARD_BY_ID.get(id); + if (!c) throw new Error(`unknown card ${id}`); + return c; +} +export function nobleOf(id: string): Noble { + const n = NOBLE_BY_ID.get(id); + if (!n) throw new Error(`unknown noble ${id}`); + return n; +} +export const isCardId = (id: string): boolean => CARD_BY_ID.has(id); + +export const emptyGems = (): Gems => ({ W: 0, U: 0, G: 0, R: 0, K: 0 }); +export const emptyTokens = (): Tokens => ({ W: 0, U: 0, G: 0, R: 0, K: 0, gold: 0 }); +export const sumTokens = (t: Partial>): number => TOKEN_COLORS.reduce((a, c) => a + (t[c] ?? 0), 0); diff --git a/packages/games/src/splendor/index.ts b/packages/games/src/splendor/index.ts new file mode 100644 index 0000000..e73619d --- /dev/null +++ b/packages/games/src/splendor/index.ts @@ -0,0 +1,968 @@ +/** + * 보석 상인 (스플렌더 방식, 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'; +import { fail, nextSeat, ok, type GameDefinition, type GameEvent, type GameResult, type PlayerId, type Validation } from '@bg/engine'; +import { + CARDS, + COLORS, + NOBLES, + TIERS, + TOKEN_COLORS, + cardOf, + emptyGems, + emptyTokens, + nobleOf, + sumTokens, + type Color, + type DevCard, + type Gems, + type Noble, + type Tier, + type TokenColor, + type Tokens, +} from './cards'; + +export * from './cards'; + +const lit = (...vals: T[]) => + z.union(vals.map((v) => z.literal(v)) as unknown as [z.ZodLiteral, z.ZodLiteral, ...z.ZodLiteral[]]); + +export const splendorOptionsSchema = z.object({ + targetPoints: lit(15, 10) + .default(15) + .meta({ title: '끝나는 점수', labels: { 15: '15점(원작)', 10: '10점(짧은 판)' } }), + allowFewerTokens: z.boolean().default(true).meta({ title: '남은 색이 3가지보다 적으면 서로 다른 색 2개나 1개만 가져오기 허용' }), + turnSeconds: lit(30, 60, 90, null) + .default(60) + .meta({ title: '한 차례 제한 시간', labels: { 30: '30초', 60: '60초', 90: '90초', null: '제한 없음' } }), + showHints: z.boolean().default(true).meta({ title: '살 수 있는 카드와 귀족까지 남은 보너스 보여 주기' }), +}); +export type SplendorOptions = z.infer; + +const colorSchema = z.enum(COLORS); +const tierSchema = lit(1, 2, 3); +const count = z.number().int().min(0).max(10); +const tokensSchema = z.object({ W: count, U: count, G: count, R: count, K: count, gold: count }); + +export const splendorActionSchema = z.discriminatedUnion('type', [ + z.object({ type: z.literal('TAKE_DIFFERENT'), colors: z.array(colorSchema).min(1).max(3) }), + z.object({ type: z.literal('TAKE_SAME'), color: colorSchema }), + z.object({ + type: z.literal('RESERVE'), + from: z.union([z.object({ tier: tierSchema, slot: z.number().int().min(0).max(3) }), z.object({ tier: tierSchema, deck: z.literal(true) })]), + }), + z.object({ + type: z.literal('BUY'), + from: z.union([z.object({ tier: tierSchema, slot: z.number().int().min(0).max(3) }), z.object({ reservedIndex: z.number().int().min(0).max(2) })]), + payment: tokensSchema.optional(), + }), + z.object({ type: z.literal('PASS') }), + z.object({ type: z.literal('DISCARD'), tokens: tokensSchema }), + z.object({ type: z.literal('CHOOSE_NOBLE'), nobleId: z.string().max(8) }), + z.object({ type: z.literal('CONCEDE') }), +]); +/** Client actions plus the system-only timeout action (not in the schema, docs/09 §2). */ +export type SplendorAction = z.infer | { type: 'TIMEOUT' }; +type PlayAction = Extract; +export type BuyFrom = Extract['from']; +export type ReserveFrom = Extract['from']; + +export const TOKEN_LIMIT = 10; +export const MAX_RESERVED = 3; +export const BOARD_SLOTS = 4; +/** Consecutive automatic turns before a player is shown as "자리 비움" (§8.5). */ +export const AWAY_AFTER = 3; +/** Turn length for away players. */ +export const AWAY_MS = 5000; +/** Extra time when the turn goes on to returning tokens or choosing a noble (§8.5). */ +export const EXTRA_PHASE_MS = 15_000; +const LOG_MAX = 20; + +export interface PlayerState { + tokens: Tokens; + /** Bought development cards. */ + cards: string[]; + /** `blind` = taken face down from a deck (only the owner sees it). */ + reserved: { cardId: string; blind: boolean }[]; + nobles: string[]; +} + +export type LogEntry = + | { n: number; k: 'take'; p: PlayerId; tokens: Partial; auto: boolean } + | { n: number; k: 'reserve'; p: PlayerId; tier: Tier; cardId: string | null; gold: boolean; auto: boolean } + | { n: number; k: 'buy'; p: PlayerId; cardId: string; paid: Partial; fromReserve: boolean; auto: boolean } + | { n: number; k: 'pass'; p: PlayerId; auto: boolean } + | { n: number; k: 'discard'; p: PlayerId; tokens: Partial; auto: boolean } + | { n: number; k: 'noble'; p: PlayerId; nobleId: string } + | { n: number; k: 'concede'; p: PlayerId } + | { n: number; k: 'away'; p: PlayerId } + | { n: number; k: 'end'; p: PlayerId; points: number }; + +export interface SplendorState { + options: SplendorOptions; + /** Turn order, order[0] = first player (shuffled at setup). */ + order: PlayerId[]; + current: number; + phase: 'action' | 'discard' | 'noble' | 'finished'; + /** Somebody reached the target: the round is played to its end (§6). */ + endTriggered: boolean; + endReason: 'target' | 'stalemate' | 'resign' | null; + supply: Tokens; + /** Hidden, index 0 = top. */ + decks: Record; + /** Four open slots per tier, null = empty (deck ran out). */ + board: Record; + /** Open nobles still waiting for a visit. */ + nobles: string[]; + players: Record; + pendingNobleChoices: string[] | null; + turnsTaken: Record; + turnSeq: number; + deadlineAt: number | null; + conceded: PlayerId[]; + /** Reserved cards of players who conceded (out of the game). */ + removed: { cardId: string; blind: boolean }[]; + timeoutsInRow: Record; + /** Consecutive PASS turns; a full round of passes ends the game (nothing can change any more). */ + passStreak: number; + logSeq: number; + log: LogEntry[]; +} + +export type ReservedView = { hidden: true; tier: Tier } | (DevCard & { hidden: false; blind: boolean }); + +export interface BuyOption { + /** Automatic payment (colour tokens first, gold only for the rest). */ + payment: Tokens; + /** Tokens per colour still needed after bonuses. */ + need: Gems; + gold: number; + total: number; +} + +export interface SplendorPlayerView { + id: PlayerId; + tokens: Tokens; + tokenCount: number; + bonuses: Gems; + cards: string[]; + points: number; + nobles: string[]; + reservedCount: number; + reserved: ReservedView[]; + conceded: boolean; + away: boolean; + turnsTaken: number; +} + +export interface SplendorMeView { + id: PlayerId; + myTurn: boolean; + /** Action phase only: the colours that can be picked for "different colours" and how many to pick. */ + takeDifferent: { available: Color[]; count: number } | null; + takeSame: Color[]; + canReserve: boolean; + reserveDecks: Tier[]; + goldOnReserve: boolean; + /** Affordable cards for me, keyed `b-` (board) or `r` (my reservations). */ + buy: Record; + canPass: boolean; + /** Discard phase: how many tokens must go back. */ + discardCount: number; + nobleChoices: string[] | null; + /** Hints: bonuses still missing per open noble (empty when hints are off). */ + nobleNeed: Record; +} + +export interface SplendorRankRow { + rank: number; + player: PlayerId; + points: number; + cards: number; + nobles: number; + bonuses: Gems; + conceded: boolean; +} + +export interface SplendorResult { + ranking: SplendorRankRow[]; + winners: PlayerId[]; + revealedReserved: Record; + summary: string; + reason: GameResult['reason']; + endReason: SplendorState['endReason']; +} + +export interface SplendorView { + options: SplendorOptions; + phase: SplendorState['phase']; + order: PlayerId[]; + current: PlayerId | null; + turnSeq: number; + deadlineAt: number | null; + /** Full length of the running timer (for the ring). */ + turnMs: number; + endTriggered: boolean; + supply: Tokens; + board: Record; + deckCounts: Record; + nobles: Noble[]; + players: Record; + me: SplendorMeView | null; + log: LogEntry[]; + result: SplendorResult | null; +} + +// ---------------------------------------------------------------- helpers + +export const tokensPerColor = (players: number): number => (players <= 2 ? 4 : players === 3 ? 5 : 7); + +export function bonusesOf(ps: PlayerState): Gems { + const b = emptyGems(); + for (const id of ps.cards) b[cardOf(id).bonus]++; + return b; +} + +export function pointsOf(ps: PlayerState): number { + return ps.cards.reduce((a, id) => a + cardOf(id).points, 0) + ps.nobles.length * 3; +} + +/** Tokens per colour still needed for `card` after my bonuses. */ +export function needFor(card: DevCard, bonuses: Gems): Gems { + const n = emptyGems(); + for (const c of COLORS) n[c] = Math.max(0, card.cost[c] - bonuses[c]); + return n; +} + +/** §5 "색 토큰 먼저, 부족분만 황금"; null when I can't afford the card. */ +export function autoPayment(card: DevCard, ps: PlayerState, bonuses = bonusesOf(ps)): Tokens | null { + const need = needFor(card, bonuses); + const pay = emptyTokens(); + let short = 0; + for (const c of COLORS) { + pay[c] = Math.min(need[c], ps.tokens[c]); + short += need[c] - pay[c]; + } + if (short > ps.tokens.gold) return null; + pay.gold = short; + return pay; +} + +/** A manual payment: never more than I hold, no colour overpaid, gold covers exactly the rest. */ +export function paymentProblem(card: DevCard, ps: PlayerState, payment: Tokens, bonuses = bonusesOf(ps)): string | null { + const need = needFor(card, bonuses); + let short = 0; + for (const c of TOKEN_COLORS) { + const v = payment[c]; + if (!Number.isInteger(v) || v < 0) return '지불 내역이 올바르지 않아요.'; + if (v > ps.tokens[c]) return '보석이 부족해요.'; + } + for (const c of COLORS) { + if (payment[c] > need[c]) return '필요한 것보다 많이 낼 수는 없어요.'; + short += need[c] - payment[c]; + } + if (payment.gold > short) return '필요한 것보다 많이 낼 수는 없어요.'; + if (payment.gold < short) return '보석이 부족해요.'; + return null; +} + +const satisfies = (bonuses: Gems, req: Gems) => COLORS.every((c) => bonuses[c] >= req[c]); +const tokenCount = (ps: PlayerState) => sumTokens(ps.tokens); +const isActive = (s: SplendorState, p: PlayerId) => !s.conceded.includes(p); +const activePlayersOf = (s: SplendorState) => s.order.filter((p) => isActive(s, p)); +const currentPlayer = (s: SplendorState): PlayerId | null => (s.phase === 'finished' ? null : s.order[s.current]!); +const compact = (t: Tokens): Partial => { + const out: Partial = {}; + for (const c of TOKEN_COLORS) if (t[c]) out[c] = t[c]; + return out; +}; +const buyKey = (from: BuyFrom) => ('reservedIndex' in from ? `r${from.reservedIndex}` : `b${from.tier}-${from.slot}`); + +/** Colours that still have tokens, and how many "different colours" must be taken (0 = not possible). */ +function differentRule(s: SplendorState): { available: Color[]; count: number } { + const available = COLORS.filter((c) => s.supply[c] > 0); + if (available.length >= 3) return { available, count: 3 }; + return { available, count: s.options.allowFewerTokens ? available.length : 0 }; +} + +function cardAt(s: SplendorState, p: PlayerId, from: BuyFrom): { id: string; reserved: boolean } | null { + if ('reservedIndex' in from) { + const r = s.players[p]!.reserved[from.reservedIndex]; + return r ? { id: r.cardId, reserved: true } : null; + } + const id = s.board[from.tier]?.[from.slot]; + return id ? { id, reserved: false } : null; +} + +/** Every card I could buy right now (board tier 1→3, left to right, then my reservations). */ +function buyables(s: SplendorState, p: PlayerId): { from: BuyFrom; card: DevCard; payment: Tokens }[] { + const ps = s.players[p]!; + const bonuses = bonusesOf(ps); + const out: { from: BuyFrom; card: DevCard; payment: Tokens }[] = []; + for (const tier of TIERS) { + s.board[tier].forEach((id, slot) => { + if (!id) return; + const card = cardOf(id); + const payment = autoPayment(card, ps, bonuses); + if (payment) out.push({ from: { tier, slot }, card, payment }); + }); + } + ps.reserved.forEach((r, i) => { + const card = cardOf(r.cardId); + const payment = autoPayment(card, ps, bonuses); + if (payment) out.push({ from: { reservedIndex: i }, card, payment }); + }); + return out; +} + +function reserveTargets(s: SplendorState, p: PlayerId): ReserveFrom[] { + if (s.players[p]!.reserved.length >= MAX_RESERVED) return []; + const out: ReserveFrom[] = []; + for (const tier of TIERS) s.board[tier].forEach((id, slot) => id && out.push({ tier, slot })); + for (const tier of TIERS) if (s.decks[tier].length) out.push({ tier, deck: true }); + return out; +} + +/** Can the player do any of the four actions (§5 "행동 불가")? */ +function canAct(s: SplendorState, p: PlayerId): boolean { + if (differentRule(s).count > 0) return true; + if (COLORS.some((c) => s.supply[c] >= 4)) return true; + if (reserveTargets(s, p).length) return true; + return buyables(s, p).length > 0; +} + +function deadlineFor(s: SplendorState, p: PlayerId, now: number): number | null { + if (s.options.turnSeconds === null) return null; + if ((s.timeoutsInRow[p] ?? 0) >= AWAY_AFTER) return now + AWAY_MS; + return now + s.options.turnSeconds * 1000; +} + +type DistOmit = T extends unknown ? Omit : never; +function pushLog(s: SplendorState, e: DistOmit): void { + s.log.push({ ...e, n: ++s.logSeq } as LogEntry); + if (s.log.length > LOG_MAX) s.log.splice(0, s.log.length - LOG_MAX); +} + +function refill(s: SplendorState, tier: Tier, slot: number, events: GameEvent[]): void { + const next = s.decks[tier].shift() ?? null; + s.board[tier][slot] = next; + events.push({ type: 'refilled', tier, slot, cardId: next }); +} + +// ---------------------------------------------------------------- turn steps (mutate a cloned state) + +function doPlay(s: SplendorState, p: PlayerId, a: PlayAction, auto: boolean, events: GameEvent[]): void { + const ps = s.players[p]!; + s.passStreak = a.type === 'PASS' ? s.passStreak + 1 : 0; + switch (a.type) { + case 'TAKE_DIFFERENT': + case 'TAKE_SAME': { + const got = emptyTokens(); + const colors = a.type === 'TAKE_SAME' ? [a.color, a.color] : a.colors; + for (const c of colors) { + if (s.supply[c] <= 0) throw new Error('supply empty'); + s.supply[c]--; + ps.tokens[c]++; + got[c]++; + } + events.push({ type: 'tookTokens', player: p, tokens: compact(got) }); + pushLog(s, { k: 'take', p, tokens: compact(got), auto }); + break; + } + case 'RESERVE': { + if (ps.reserved.length >= MAX_RESERVED) throw new Error('reserve limit'); + let cardId: string; + const blind = 'deck' in a.from; + if ('deck' in a.from) { + const top = s.decks[a.from.tier].shift(); + if (!top) throw new Error('deck empty'); + cardId = top; + } else { + const id = s.board[a.from.tier][a.from.slot]; + if (!id) throw new Error('empty slot'); + cardId = id; + } + ps.reserved.push({ cardId, blind }); + const gold = s.supply.gold > 0; + if (gold) { + s.supply.gold--; + ps.tokens.gold++; + } + events.push({ type: 'reserved', player: p, tier: a.from.tier, cardId: blind ? null : cardId, gold }); + pushLog(s, { k: 'reserve', p, tier: a.from.tier, cardId: blind ? null : cardId, gold, auto }); + if (!blind && 'slot' in a.from) refill(s, a.from.tier, a.from.slot, events); + break; + } + case 'BUY': { + const at = cardAt(s, p, a.from); + if (!at) throw new Error('no card'); + const card = cardOf(at.id); + const payment = a.payment ?? autoPayment(card, ps); + if (!payment || paymentProblem(card, ps, payment)) throw new Error('cannot pay'); + for (const c of TOKEN_COLORS) { + ps.tokens[c] -= payment[c]; + s.supply[c] += payment[c]; + } + ps.cards.push(at.id); + if ('reservedIndex' in a.from) ps.reserved.splice(a.from.reservedIndex, 1); + events.push({ type: 'bought', player: p, cardId: at.id, paid: compact(payment), fromReserve: at.reserved }); + pushLog(s, { k: 'buy', p, cardId: at.id, paid: compact(payment), fromReserve: at.reserved, auto }); + if (!('reservedIndex' in a.from)) refill(s, a.from.tier, a.from.slot, events); + break; + } + case 'PASS': + events.push({ type: 'passed', player: p }); + pushLog(s, { k: 'pass', p, auto }); + break; + } +} + +function doDiscard(s: SplendorState, p: PlayerId, tokens: Tokens, auto: boolean, events: GameEvent[]): void { + const ps = s.players[p]!; + for (const c of TOKEN_COLORS) { + if (tokens[c] > ps.tokens[c]) throw new Error('discard more than held'); + ps.tokens[c] -= tokens[c]; + s.supply[c] += tokens[c]; + } + if (tokenCount(ps) !== TOKEN_LIMIT) throw new Error('discard must leave exactly 10'); + events.push({ type: 'discarded', player: p, tokens: compact(tokens) }); + pushLog(s, { k: 'discard', p, tokens: compact(tokens), auto }); +} + +function visitNoble(s: SplendorState, p: PlayerId, nobleId: string, events: GameEvent[]): void { + const i = s.nobles.indexOf(nobleId); + if (i < 0) throw new Error('noble not open'); + s.nobles.splice(i, 1); + s.players[p]!.nobles.push(nobleId); + events.push({ type: 'nobleVisited', player: p, nobleId }); + pushLog(s, { k: 'noble', p, nobleId }); +} + +/** After an action (or a discard): token limit → noble → end of turn (§8.2 "차례 마무리 순서"). */ +function continueTurn(s: SplendorState, p: PlayerId, now: number, events: GameEvent[], afterDiscard = false): void { + const ps = s.players[p]!; + if (!afterDiscard && tokenCount(ps) > TOKEN_LIMIT) { + s.phase = 'discard'; + if (s.deadlineAt !== null) s.deadlineAt = Math.max(s.deadlineAt, now) + EXTRA_PHASE_MS; + return; + } + const bonuses = bonusesOf(ps); + const eligible = s.nobles.filter((id) => satisfies(bonuses, nobleOf(id).req)).sort(); + if (eligible.length === 1) visitNoble(s, p, eligible[0]!, events); + else if (eligible.length > 1) { + s.phase = 'noble'; + s.pendingNobleChoices = eligible; + if (s.deadlineAt !== null) s.deadlineAt = Math.max(s.deadlineAt, now) + EXTRA_PHASE_MS; + return; + } + endTurn(s, p, now, events); +} + +function endTurn(s: SplendorState, p: PlayerId, now: number, events: GameEvent[]): void { + s.turnsTaken[p] = (s.turnsTaken[p] ?? 0) + 1; + s.pendingNobleChoices = null; + const pts = pointsOf(s.players[p]!); + if (!s.endTriggered && pts >= s.options.targetPoints) { + s.endTriggered = true; + events.push({ type: 'endTriggered', player: p, points: pts }); + pushLog(s, { k: 'end', p, points: pts }); + } + advance(s, now, events); +} + +function finish(s: SplendorState, reason: NonNullable, events: GameEvent[]): void { + s.phase = 'finished'; + s.endReason = reason; + s.deadlineAt = null; + s.pendingNobleChoices = null; + events.push({ type: 'gameOver', reason }); +} + +/** Next active seat; the game ends after the last seat of a triggered round, on a full round of passes, or with one player left. */ +function advance(s: SplendorState, now: number, events: GameEvent[]): void { + const active = activePlayersOf(s); + if (s.order.length > 1 ? active.length <= 1 : active.length === 0) return finish(s, 'resign', events); + const n = s.order.length; + const next = nextSeat(s.current, n, (i) => !isActive(s, s.order[i]!)); + if (s.endTriggered && next <= s.current) return finish(s, 'target', events); + if (s.passStreak >= active.length) return finish(s, 'stalemate', events); + s.current = next; + s.phase = 'action'; + s.pendingNobleChoices = null; + s.turnSeq++; + s.deadlineAt = deadlineFor(s, s.order[next]!, now); + events.push({ type: 'turnChanged', player: s.order[next]! }); +} + +function concede(s: SplendorState, p: PlayerId, now: number, events: GameEvent[]): void { + const ps = s.players[p]!; + for (const c of TOKEN_COLORS) { + s.supply[c] += ps.tokens[c]; + ps.tokens[c] = 0; + } + s.removed.push(...ps.reserved); + ps.reserved = []; + s.conceded.push(p); + events.push({ type: 'conceded', player: p }); + pushLog(s, { k: 'concede', p }); + const wasCurrent = s.order[s.current] === p; + const active = activePlayersOf(s); + if (s.order.length > 1 ? active.length <= 1 : active.length === 0) return finish(s, 'resign', events); + if (wasCurrent) { + // Their turn ends without an action; passes so far still count only for players who are in. + s.passStreak = 0; + advance(s, now, events); + } +} + +// ---------------------------------------------------------------- automatic play (§8.5) + +/** Deterministic default move: best buy → 3 different (most supply) → 2 same → reserve → pass. */ +export function autoPlay(s: SplendorState, p: PlayerId): PlayAction { + const buys = buyables(s, p); + if (buys.length) { + let best = buys[0]!; + for (const b of buys) { + const better = b.card.points > best.card.points || (b.card.points === best.card.points && sumTokens(b.payment) < sumTokens(best.payment)); + if (better) best = b; + } + return { type: 'BUY', from: best.from }; + } + const rule = differentRule(s); + if (rule.count > 0) { + // Stable sort keeps W,U,G,R,K order among equal supplies. + const colors = [...rule.available].sort((a, b) => s.supply[b] - s.supply[a]).slice(0, rule.count); + return { type: 'TAKE_DIFFERENT', colors }; + } + const same = [...COLORS].sort((a, b) => s.supply[b] - s.supply[a]).find((c) => s.supply[c] >= 4); + if (same) return { type: 'TAKE_SAME', color: same }; + const res = reserveTargets(s, p)[0]; + if (res) return { type: 'RESERVE', from: res }; + return { type: 'PASS' }; +} + +const DISCARD_ORDER: Color[] = ['K', 'R', 'G', 'U', 'W']; +/** One token at a time from the colour I hold most of (ties K,R,G,U,W), gold last. */ +export function autoDiscard(ps: PlayerState): Tokens { + const left = { ...ps.tokens }; + const out = emptyTokens(); + let extra = sumTokens(left) - TOKEN_LIMIT; + while (extra > 0) { + let pick: TokenColor = 'gold'; + let most = 0; + for (const c of DISCARD_ORDER) { + if (left[c] > most) { + most = left[c]; + pick = c; + } + } + left[pick]--; + out[pick]++; + extra--; + } + return out; +} + +function autoTurn(s: SplendorState, p: PlayerId, now: number, events: GameEvent[]): void { + if (s.phase === 'action') { + doPlay(s, p, autoPlay(s, p), true, events); + continueTurn(s, p, now, events); + } + // Discard and noble choices of an automatic (or abandoned) turn are resolved right away. + for (let guard = 0; guard < 3 && s.phase !== 'finished' && s.order[s.current] === p; guard++) { + if (s.phase === 'discard') { + doDiscard(s, p, autoDiscard(s.players[p]!), true, events); + continueTurn(s, p, now, events, true); + } else if (s.phase === 'noble') { + visitNoble(s, p, [...s.pendingNobleChoices!].sort()[0]!, events); + endTurn(s, p, now, events); + } else break; + } +} + +// ---------------------------------------------------------------- result + +function rankRows(s: SplendorState): SplendorRankRow[] { + const rows = s.order.map((p) => { + const ps = s.players[p]!; + return { player: p, points: pointsOf(ps), cards: ps.cards.length, nobles: ps.nobles.length, bonuses: bonusesOf(ps), conceded: s.conceded.includes(p) }; + }); + // Conceded players rank last; then points high → fewer cards (§6). + rows.sort((a, b) => Number(a.conceded) - Number(b.conceded) || b.points - a.points || a.cards - b.cards); + const out: SplendorRankRow[] = []; + rows.forEach((r, i) => { + const prev = out[i - 1]; + const tied = prev && prev.conceded === r.conceded && prev.points === r.points && prev.cards === r.cards; + out.push({ ...r, rank: tied ? prev.rank : i + 1 }); + }); + return out; +} + +function buildResult(s: SplendorState): SplendorResult { + const ranking = rankRows(s); + const winners = ranking.filter((r) => r.rank === 1).map((r) => r.player); + const top = ranking[0]!; + const detail = (r: SplendorRankRow) => `${r.points}점 (카드 ${r.cards}장${r.nobles ? `, 귀족 ${r.nobles}` : ''})`; + let summary: string; + if (winners.length > 1) summary = `공동 1위 ${winners.length}명, ${detail(top)}`; + else if (ranking.length > 1) summary = `1위 ${detail(top)} · 2위 ${ranking[1]!.points}점`; + else summary = `${detail(top)}`; + if (s.endReason === 'resign') summary = `다른 사람이 모두 기권했어요. ${summary}`; + if (s.endReason === 'stalemate') summary = `더 할 수 있는 행동이 없어 끝났어요. ${summary}`; + const revealedReserved: Record = {}; + for (const p of s.order) revealedReserved[p] = s.players[p]!.reserved.map((r) => r.cardId); + const reason: GameResult['reason'] = s.endReason === 'resign' ? (activePlayersOf(s).length === 0 ? 'abandoned' : 'resign') : 'normal'; + return { ranking, winners, revealedReserved, summary, reason, endReason: s.endReason }; +} + +// ---------------------------------------------------------------- view helpers + +function meView(s: SplendorState, me: PlayerId): SplendorMeView { + const ps = s.players[me]!; + const myTurn = s.phase !== 'finished' && s.order[s.current] === me && isActive(s, me); + const acting = myTurn && s.phase === 'action'; + const buy: Record = {}; + if (isActive(s, me) && s.phase !== 'finished') { + const bonuses = bonusesOf(ps); + for (const b of buyables(s, me)) { + buy[buyKey(b.from)] = { payment: b.payment, need: needFor(b.card, bonuses), gold: b.payment.gold, total: sumTokens(b.payment) }; + } + } + const rule = differentRule(s); + const nobleNeed: Record = {}; + if (s.options.showHints) { + const bonuses = bonusesOf(ps); + for (const id of s.nobles) nobleNeed[id] = COLORS.reduce((a, c) => a + Math.max(0, nobleOf(id).req[c] - bonuses[c]), 0); + } + return { + id: me, + myTurn, + takeDifferent: acting && rule.count > 0 ? rule : null, + takeSame: acting ? COLORS.filter((c) => s.supply[c] >= 4) : [], + canReserve: acting && ps.reserved.length < MAX_RESERVED && reserveTargets(s, me).length > 0, + reserveDecks: acting && ps.reserved.length < MAX_RESERVED ? TIERS.filter((t) => s.decks[t].length > 0) : [], + goldOnReserve: s.supply.gold > 0, + buy, + canPass: acting && !canAct(s, me), + discardCount: myTurn && s.phase === 'discard' ? tokenCount(ps) - TOKEN_LIMIT : 0, + nobleChoices: myTurn && s.phase === 'noble' ? s.pendingNobleChoices : null, + nobleNeed, + }; +} + +// ---------------------------------------------------------------- definition + +export const splendor: GameDefinition = { + id: 'splendor', + nameKo: '보석 상인', + minPlayers: 2, + maxPlayers: 4, + stateVersion: 1, + defaultOptions: splendorOptionsSchema.parse({}), + optionsSchema: splendorOptionsSchema, + actionSchema: splendorActionSchema as z.ZodType, + + setup({ players, options, rng, now }) { + if (players.length < 2 || players.length > 4) throw new Error('splendor: 2~4 players'); + const order = rng.shuffle(players); + const nobles = rng.shuffle(NOBLES.map((n) => n.id)).slice(0, players.length + 1); + const decks = {} as Record; + const board = {} as Record; + for (const tier of TIERS) { + const deck = rng.shuffle(CARDS.filter((c) => c.tier === tier).map((c) => c.id)); + board[tier] = deck.splice(0, BOARD_SLOTS); + decks[tier] = deck; + } + const per = tokensPerColor(players.length); + const supply: Tokens = { W: per, U: per, G: per, R: per, K: per, gold: 5 }; + const ps: Record = {}; + for (const p of players) ps[p] = { tokens: emptyTokens(), cards: [], reserved: [], nobles: [] }; + const s: SplendorState = { + options, + order, + current: 0, + phase: 'action', + endTriggered: false, + endReason: null, + supply, + decks, + board, + nobles, + players: ps, + pendingNobleChoices: null, + turnsTaken: Object.fromEntries(players.map((p) => [p, 0])), + turnSeq: 1, + deadlineAt: null, + conceded: [], + removed: [], + timeoutsInRow: Object.fromEntries(players.map((p) => [p, 0])), + passStreak: 0, + logSeq: 0, + log: [], + }; + s.deadlineAt = deadlineFor(s, order[0]!, now); + return s; + }, + + validate(s, actor, a): Validation { + const ps = s.players[actor]; + if (!ps) return fail('이 게임의 플레이어가 아니에요.'); + if (s.phase === 'finished') return fail('게임이 이미 끝났어요.'); + if (s.conceded.includes(actor)) return fail('이미 기권했어요.'); + if (a.type === 'CONCEDE') return ok; + if (s.order[s.current] !== actor) return fail('지금은 내 차례가 아니에요.'); + if (a.type === 'TIMEOUT') return ok; + if (a.type === 'DISCARD') { + if (s.phase !== 'discard') return fail('지금은 보석을 돌려놓을 때가 아니에요.'); + for (const c of TOKEN_COLORS) { + const v = a.tokens[c]; + if (!Number.isInteger(v) || v < 0) return fail('잘못된 요청이에요.'); + if (v > ps.tokens[c]) return fail('가진 것보다 많이 돌려놓을 수 없어요.'); + } + const extra = tokenCount(ps) - TOKEN_LIMIT; + if (sumTokens(a.tokens) !== extra) return fail(`보석이 10개가 되도록 ${extra}개를 골라 주세요.`); + return ok; + } + if (a.type === 'CHOOSE_NOBLE') { + if (s.phase !== 'noble') return fail('지금은 귀족을 고를 때가 아니에요.'); + return s.pendingNobleChoices?.includes(a.nobleId) ? ok : fail('고를 수 없는 귀족이에요.'); + } + if (s.phase === 'discard') return fail('먼저 보석을 10개가 되도록 돌려놓아 주세요.'); + if (s.phase === 'noble') return fail('먼저 방문할 귀족을 골라 주세요.'); + switch (a.type) { + case 'TAKE_DIFFERENT': { + const colors = a.colors; + const rule = differentRule(s); + if (!Array.isArray(colors) || new Set(colors).size !== colors.length) return fail('서로 다른 색 3개를 골라 주세요.'); + if (colors.some((c) => !COLORS.includes(c))) return fail('서로 다른 색 3개를 골라 주세요.'); + if (colors.some((c) => s.supply[c] < 1)) return fail('남아 있지 않은 색이 있어요.'); + if (rule.count === 0) return fail('서로 다른 색 3개를 가져올 수 없어요.'); + if (colors.length !== rule.count) return fail(rule.count === 3 ? '서로 다른 색 3개를 골라 주세요.' : `남은 색이 ${rule.count}가지라 ${rule.count}개를 골라 주세요.`); + return ok; + } + case 'TAKE_SAME': + if (!COLORS.includes(a.color)) return fail('잘못된 요청이에요.'); + return s.supply[a.color] >= 4 ? ok : fail('같은 색 2개는 4개 이상 남은 색에서만 가져올 수 있어요.'); + case 'RESERVE': { + if (ps.reserved.length >= MAX_RESERVED) return fail('예약은 3장까지만 할 수 있어요.'); + if ('deck' in a.from) return s.decks[a.from.tier]?.length ? ok : fail('그 단계 더미에 카드가 없어요.'); + return s.board[a.from.tier]?.[a.from.slot] ? ok : fail('그 자리에 카드가 없어요.'); + } + case 'BUY': { + const at = cardAt(s, actor, a.from); + if (!at) return fail('그 자리에 카드가 없어요.'); + const card = cardOf(at.id); + if (a.payment) { + const err = paymentProblem(card, ps, a.payment); + return err ? fail(err) : ok; + } + return autoPayment(card, ps) ? ok : fail('보석이 부족해요.'); + } + case 'PASS': + return canAct(s, actor) ? fail('할 수 있는 행동이 있어요.') : ok; + } + }, + + apply(prev, actor, a, { now }) { + // The state is plain JSON by contract; this is much faster than structuredClone. + const s = JSON.parse(JSON.stringify(prev)) as SplendorState; + const events: GameEvent[] = []; + if (a.type !== 'TIMEOUT' && a.type !== 'CONCEDE') s.timeoutsInRow[actor] = 0; + switch (a.type) { + case 'CONCEDE': + concede(s, actor, now, events); + break; + case 'TIMEOUT': { + s.timeoutsInRow[actor] = (s.timeoutsInRow[actor] ?? 0) + 1; + events.push({ type: 'timeoutAuto', player: actor }); + if (s.timeoutsInRow[actor] === AWAY_AFTER) pushLog(s, { k: 'away', p: actor }); + autoTurn(s, actor, now, events); + break; + } + case 'DISCARD': + if (s.phase !== 'discard') throw new Error('not in discard phase'); + doDiscard(s, actor, a.tokens, false, events); + continueTurn(s, actor, now, events, true); + break; + case 'CHOOSE_NOBLE': + if (s.phase !== 'noble' || !s.pendingNobleChoices?.includes(a.nobleId)) throw new Error('illegal noble'); + visitNoble(s, actor, a.nobleId, events); + endTurn(s, actor, now, events); + break; + default: + if (s.phase !== 'action') throw new Error('not in action phase'); + doPlay(s, actor, a, false, events); + continueTurn(s, actor, now, events); + } + return { state: s, events }; + }, + + view(s, viewer) { + const finished = s.phase === 'finished'; + const me = viewer && s.players[viewer] ? viewer : null; + const board = {} as Record; + const deckCounts = {} as Record; + for (const t of TIERS) { + board[t] = s.board[t].map((id) => (id ? cardOf(id) : null)); + deckCounts[t] = s.decks[t].length; + } + const players: Record = {}; + for (const p of s.order) { + const ps = s.players[p]!; + players[p] = { + id: p, + tokens: ps.tokens, + tokenCount: tokenCount(ps), + bonuses: bonusesOf(ps), + cards: ps.cards, + points: pointsOf(ps), + nobles: ps.nobles, + reservedCount: ps.reserved.length, + reserved: ps.reserved.map((r): ReservedView => { + const card = cardOf(r.cardId); + return r.blind && p !== me && !finished ? { hidden: true, tier: card.tier } : { ...card, hidden: false, blind: r.blind }; + }), + conceded: s.conceded.includes(p), + away: (s.timeoutsInRow[p] ?? 0) >= AWAY_AFTER, + turnsTaken: s.turnsTaken[p] ?? 0, + }; + } + const cur = currentPlayer(s); + const away = cur ? (s.timeoutsInRow[cur] ?? 0) >= AWAY_AFTER : false; + const base = s.options.turnSeconds === null ? 0 : away ? AWAY_MS : s.options.turnSeconds * 1000; + return { + options: s.options, + phase: s.phase, + order: s.order, + current: cur, + turnSeq: s.turnSeq, + deadlineAt: s.deadlineAt, + turnMs: base + (s.phase === 'discard' || s.phase === 'noble' ? EXTRA_PHASE_MS : 0), + endTriggered: s.endTriggered, + supply: s.supply, + board, + deckCounts, + nobles: s.nobles.map(nobleOf), + players, + me: me ? meView(s, me) : null, + log: s.log, + result: finished ? buildResult(s) : null, + }; + }, + + activePlayers(s) { + const cur = currentPlayer(s); + return cur ? [cur] : []; + }, + + deadline(s) { + return s.phase === 'finished' ? null : s.deadlineAt; + }, + + onTimeout() { + return { type: 'TIMEOUT' }; + }, + + onLeave(s, player) { + if (s.phase === 'finished' || !s.players[player] || s.conceded.includes(player)) return null; + return { type: 'CONCEDE' }; + }, + + result(s): GameResult | null { + if (s.phase !== 'finished') return null; + const r = buildResult(s); + const groups = new Map(); + for (const row of r.ranking) groups.set(row.rank, [...(groups.get(row.rank) ?? []), row.player]); + const scores: Record = {}; + for (const row of r.ranking) scores[row.player] = row.points; + return { ranking: [...groups.values()], scores, summary: r.summary, reason: r.reason }; + }, + + /** + * Test helper. Buying is preferred whenever possible (with a manual all-gold variant), so random games + * make progress; otherwise every take / reserve, or PASS when nothing is possible. Concede is left out. + */ + legalActions(s, p) { + if (s.phase === 'finished' || s.order[s.current] !== p || s.conceded.includes(p)) return []; + const ps = s.players[p]!; + if (s.phase === 'noble') return (s.pendingNobleChoices ?? []).map((nobleId) => ({ type: 'CHOOSE_NOBLE', nobleId })); + if (s.phase === 'discard') return discardOptions(ps.tokens, tokenCount(ps) - TOKEN_LIMIT).map((tokens) => ({ type: 'DISCARD', tokens })); + const out: SplendorAction[] = []; + const buys = buyables(s, p); + const bonuses = bonusesOf(ps); + for (const b of buys) { + out.push({ type: 'BUY', from: b.from }); + // Same card paid with as much gold as possible (manual payment path). + const need = needFor(b.card, bonuses); + const manual = { ...b.payment }; + for (const c of COLORS) { + const swap = Math.min(manual[c], ps.tokens.gold - manual.gold); + manual[c] -= swap; + manual.gold += swap; + } + if (manual.gold !== b.payment.gold && COLORS.every((c) => manual[c] <= need[c])) out.push({ type: 'BUY', from: b.from, payment: manual }); + } + if (out.length && s.turnSeq % 6 !== 0) return out; + const rule = differentRule(s); + if (rule.count > 0) for (const colors of combos(rule.available, rule.count)) out.push({ type: 'TAKE_DIFFERENT', colors }); + for (const c of COLORS) if (s.supply[c] >= 4) out.push({ type: 'TAKE_SAME', color: c }); + for (const from of reserveTargets(s, p)) out.push({ type: 'RESERVE', from }); + if (!out.length) out.push({ type: 'PASS' }); + return out; + }, + + /** Hidden from others: my blind reservations; hidden from everyone: deck contents, unused nobles. */ + secretsOf(s, player) { + const out: string[] = []; + if (s.phase !== 'finished') for (const r of s.players[player]?.reserved ?? []) if (r.blind) out.push(r.cardId); + for (const r of s.removed) if (r.blind) out.push(r.cardId); + for (const t of TIERS) out.push(...s.decks[t]); + const seen = new Set([...s.nobles, ...Object.values(s.players).flatMap((x) => x.nobles)]); + for (const n of NOBLES) if (!seen.has(n.id)) out.push(n.id); + return out; + }, +}; + +function combos(arr: readonly T[], k: number): T[][] { + if (k === 0) return [[]]; + const out: T[][] = []; + arr.forEach((x, i) => { + for (const rest of combos(arr.slice(i + 1), k - 1)) out.push([x, ...rest]); + }); + return out; +} + +/** Every way to give back `k` tokens from `held`. */ +function discardOptions(held: Tokens, k: number): Tokens[] { + const out: Tokens[] = []; + const cur = emptyTokens(); + const go = (i: number, left: number) => { + if (left === 0) return void out.push({ ...cur }); + if (i >= TOKEN_COLORS.length) return; + const c = TOKEN_COLORS[i]!; + for (let v = Math.min(left, held[c]); v >= 0; v--) { + cur[c] = v; + go(i + 1, left - v); + } + cur[c] = 0; + }; + go(0, k); + return out; +} + +/** Component totals for invariants: tokens per colour and cards / nobles accounted for. */ +export function componentCounts(s: SplendorState): { tokens: Tokens; cards: number; nobles: number } { + const tokens = { ...s.supply }; + let cards = s.removed.length; + for (const t of TIERS) cards += s.decks[t].length + s.board[t].filter(Boolean).length; + let nobles = s.nobles.length; + for (const p of s.order) { + const ps = s.players[p]!; + for (const c of TOKEN_COLORS) tokens[c] += ps.tokens[c]; + cards += ps.cards.length + ps.reserved.length; + nobles += ps.nobles.length; + } + return { tokens, cards, nobles }; +} diff --git a/packages/games/src/splendor/splendor.playout.test.ts b/packages/games/src/splendor/splendor.playout.test.ts new file mode 100644 index 0000000..92af740 --- /dev/null +++ b/packages/games/src/splendor/splendor.playout.test.ts @@ -0,0 +1,135 @@ +/** 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'; + +const CONFIGS: Partial[] = [ + {}, + { targetPoints: 10 }, + { allowFewerTokens: false }, + { turnSeconds: null, showHints: false }, + { turnSeconds: 30, targetPoints: 10, allowFewerTokens: false }, +]; + +function conserved(state: unknown): void { + const s = state as SplendorState; + const n = s.order.length <= 2 ? 4 : s.order.length === 3 ? 5 : 7; + const c = componentCounts(s); + const want: Tokens = { W: n, U: n, G: n, R: n, K: n, gold: 5 }; + for (const k of [...COLORS, 'gold'] as const) if (c.tokens[k] !== want[k]) throw new Error(`token ${k}: ${c.tokens[k]} != ${want[k]}`); + if (c.cards !== 90) throw new Error(`cards: ${c.cards}`); + if (c.nobles !== s.order.length + 1) throw new Error(`nobles: ${c.nobles}`); + for (const p of s.order) { + const ps = s.players[p]!; + if (ps.reserved.length > 3) throw new Error('more than 3 reservations'); + for (const k of [...COLORS, 'gold'] as const) if (ps.tokens[k] < 0) throw new Error('negative tokens'); + const total = Object.values(ps.tokens).reduce((a, b) => a + b, 0); + if (total > 10 && !(s.phase === 'discard' && s.order[s.current] === p)) throw new Error(`${p} holds ${total} tokens outside the discard phase`); + } + for (const t of TIERS) if (s.board[t].length !== 4) throw new Error('board must keep 4 slots'); +} + +const ID_RE = /"(?:[123][WUGRK]\d\d|N\d\d)"/g; + +/** Who may see each hidden id: '*' = nobody, otherwise the owner of a blind reservation. */ +function hiddenOwners(s: SplendorState): Map { + const m = new Map(); + for (const t of TIERS) for (const id of s.decks[t]) m.set(id, '*'); + for (const r of s.removed) if (r.blind) m.set(r.cardId, '*'); + const seen = new Set([...s.nobles, ...s.order.flatMap((p) => s.players[p]!.nobles)]); + for (const n of NOBLES) if (!seen.has(n.id)) m.set(n.id, '*'); + if (s.phase !== 'finished') for (const p of s.order) for (const r of s.players[p]!.reserved) if (r.blind) m.set(r.cardId, p); + return m; +} + +function run(seed: number): { state: SplendorState; steps: number } { + const options = splendor.optionsSchema.parse(CONFIGS[seed % CONFIGS.length]); + const players = ['P0', 'P1', 'P2', 'P3'].slice(0, 2 + (seed % 3)); + const rng = SeededRng.fromSeed(seedN(seed)); + const chooser = SeededRng.fromSeed(seedN(seed + 700_000)); + let now = 1_700_000_000_000; + let s = splendor.setup({ players, options, rng, now }); + const concedeAt = seed % 13 === 0 ? 30 + (seed % 40) : -1; + for (let step = 0; step < 2000; step++) { + if (step % 10 === 0) { + const json = JSON.stringify(s); + if (JSON.stringify(JSON.parse(json)) !== json) throw new Error('not JSON round-trippable'); + } + conserved(s); + const owners = hiddenOwners(s); + for (const viewer of [...players, null]) { + const ids = JSON.stringify(splendor.view(s, viewer)).match(ID_RE) ?? []; + for (const q of ids) { + const o = owners.get(q.slice(1, -1)); + if (o && o !== viewer) throw new Error(`seed ${seed} step ${step}: ${viewer} sees ${q} (${o})`); + } + } + if (splendor.result(s)) return { state: s, steps: step }; + const waiting = splendor.activePlayers(s); + if (waiting.length !== 1) throw new Error('exactly one player should be waited for'); + for (const p of waiting) { + const v = splendor.validate(s, p, splendor.onTimeout(s, p)); + if (!v.ok) throw new Error(`seed ${seed}: onTimeout illegal: ${v.reason}`); + } + let actor = waiting[0]!; + let a: SplendorAction; + if (step === concedeAt) { + const inGame = players.filter((p) => !s.conceded.includes(p)); + actor = inGame[chooser.int(inGame.length)]!; + a = splendor.onLeave!(s, actor)!; + } else { + const legal = splendor.legalActions!(s, actor); + a = legal.length && chooser.int(8) !== 0 ? legal[chooser.int(legal.length)]! : splendor.onTimeout(s, actor); + } + const v = splendor.validate(s, actor, a); + if (!v.ok) throw new Error(`seed ${seed}: generated action rejected: ${v.reason} ${JSON.stringify(a)}`); + now += 1000; + const t0 = performance.now(); + const out = splendor.apply(s, actor, a, { rng, now }); + const ms = performance.now() - t0; + if (ms > 20) throw new Error(`apply took ${ms.toFixed(1)}ms`); + const after = hiddenOwners(out.state); + for (const q of JSON.stringify(out.events).match(ID_RE) ?? []) if (after.has(q.slice(1, -1))) throw new Error(`seed ${seed}: event leaks ${q}`); + s = out.state; + } + throw new Error(`seed ${seed}: not finished within 2000 steps`); +} + +describe('random playouts (docs/09 §7)', () => { + test('engine randomPlayout (round trip, checkNoLeak via secretsOf, invariants, determinism)', () => { + for (let i = 0; i < 8; i++) { + const options = splendor.optionsSchema.parse(CONFIGS[i % CONFIGS.length]); + const players = ['P0', 'P1', 'P2', 'P3'].slice(0, 2 + (i % 3)); + const a = randomPlayout(splendor, { players, options, seed: seedN(5000 + i), maxSteps: 2000, invariant: conserved, maxApplyMs: 20 }); + expect(a.finished).toBe(true); + if (i < 2) { + const b = randomPlayout(splendor, { players, options, seed: seedN(5000 + i), maxSteps: 2000, maxApplyMs: 20 }); + expect(JSON.stringify(b.finalState)).toBe(JSON.stringify(a.finalState)); + } + } + }, 60_000); + + test('1,000 seeded playouts: conservation, no leaks in views or events, onTimeout always legal, every game ends', () => { + const ends: Record = {}; + let maxSteps = 0; + for (let seed = 1; seed <= 1000; seed++) { + const { state, steps } = run(seed); + maxSteps = Math.max(maxSteps, steps); + const res = splendor.result(state)!; + expect(res.ranking.flat().sort()).toEqual([...state.order].sort()); + expect(res.summary.length).toBeGreaterThan(0); + const k = state.endReason ?? 'none'; + ends[k] = (ends[k] ?? 0) + 1; + if (seed % 100 === 0) expect(JSON.stringify(run(seed).state)).toBe(JSON.stringify(state)); + if (state.endReason === 'target') { + // Everyone in the game had the same number of turns (the round was played to its end). + const inGame = state.order.filter((p: PlayerId) => !state.conceded.includes(p)); + const turns = new Set(inGame.map((p) => state.turnsTaken[p])); + if (!state.conceded.length) expect(turns.size).toBe(1); + } + } + expect(ends.target ?? 0).toBeGreaterThan(900); + expect(ends.resign ?? 0).toBeGreaterThan(0); + expect(maxSteps).toBeLessThan(2000); + }, 60_000); +}); diff --git a/packages/games/src/splendor/splendor.test.ts b/packages/games/src/splendor/splendor.test.ts new file mode 100644 index 0000000..dcae7c5 --- /dev/null +++ b/packages/games/src/splendor/splendor.test.ts @@ -0,0 +1,753 @@ +import { describe, expect, test } from 'bun:test'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { SeededRng, seedN, type GameEvent, type PlayerId } from '@bg/engine'; +import { + CARDS, + COLORS, + NOBLES, + TIERS, + cardOf, + componentCounts, + emptyTokens, + pointsOf, + splendor, + splendorActionSchema, + type Color, + type SplendorAction, + type SplendorOptions, + type SplendorState, + type Tier, + type Tokens, +} from './index'; + +// ---------------------------------------------------------------- helpers + +let clock = 1_000_000; +const RNG = () => SeededRng.fromSeed([7, 7, 7, 7]); + +function mk(players = ['A', 'B'], opts: Partial = {}, seed = 1): SplendorState { + const options = splendor.optionsSchema.parse(opts); + return splendor.setup({ players, options, rng: SeededRng.fromSeed(seedN(seed)), now: clock }); +} + +/** Fixes the turn order (setup shuffles it) and gives the turn to the first player. */ +function inOrder(s: SplendorState, order: PlayerId[]): SplendorState { + s.order = [...order]; + s.current = 0; + return s; +} + +function act(s: SplendorState, who: PlayerId, a: SplendorAction): SplendorState { + return actE(s, who, a).state; +} +function actE(s: SplendorState, who: PlayerId, a: SplendorAction): { state: SplendorState; events: GameEvent[] } { + const v = splendor.validate(s, who, a); + if (!v.ok) throw new Error(`${who} ${JSON.stringify(a)} rejected: ${v.reason}`); + clock += 1000; + const out = splendor.apply(s, who, a, { rng: RNG(), now: clock }); + conserved(out.state); + return out; +} +function reason(s: SplendorState, who: PlayerId, a: SplendorAction): string { + const v = splendor.validate(s, who, a); + if (v.ok) throw new Error(`expected rejection of ${JSON.stringify(a)}`); + return v.reason; +} +const cur = (s: SplendorState) => s.order[s.current]!; +const tk = (t: Partial): Tokens => ({ ...emptyTokens(), ...t }); + +/** Removes a card from wherever it is (deck or board, refilling the board slot from the deck). */ +function pull(s: SplendorState, id: string): void { + const tier = cardOf(id).tier; + const di = s.decks[tier].indexOf(id); + if (di >= 0) return void s.decks[tier].splice(di, 1); + const bi = s.board[tier].indexOf(id); + if (bi < 0) throw new Error(`card ${id} not available`); + s.board[tier][bi] = s.decks[tier].shift() ?? null; +} +function giveCards(s: SplendorState, p: PlayerId, ids: string[]): void { + for (const id of ids) { + pull(s, id); + s.players[p]!.cards.push(id); + } +} +/** Moves tokens supply → player (keeps the totals). */ +function giveTokens(s: SplendorState, p: PlayerId, t: Partial): void { + for (const [c, n] of Object.entries(t) as [keyof Tokens, number][]) { + if (s.supply[c] < n) throw new Error(`supply short of ${c}`); + s.supply[c] -= n; + s.players[p]!.tokens[c] += n; + } +} +/** Puts card `id` face up at board slot (tier, slot); the card that was there goes back to the deck top. */ +function place(s: SplendorState, id: string, slot = 0): void { + const tier = cardOf(id).tier; + pull(s, id); + const old = s.board[tier][slot]; + if (old) s.decks[tier].unshift(old); + s.board[tier][slot] = id; +} +/** Moves supply tokens of one colour away to a bystander so the supply shows `n`. */ +function setSupply(s: SplendorState, c: Color | 'gold', n: number, holder: PlayerId): void { + const d = s.supply[c] - n; + if (d < 0) throw new Error('can only lower the supply'); + s.supply[c] = n; + s.players[holder]!.tokens[c] += d; +} + +function initialTokens(s: SplendorState): Tokens { + const n = s.order.length <= 2 ? 4 : s.order.length === 3 ? 5 : 7; + return { W: n, U: n, G: n, R: n, K: n, gold: 5 }; +} +function conserved(s: SplendorState): void { + const c = componentCounts(s); + expect(c.tokens).toEqual(initialTokens(s)); + expect(c.cards).toBe(90); + expect(c.nobles).toBe(s.order.length + 1); + for (const p of s.order) for (const k of [...COLORS, 'gold'] as const) expect(s.players[p]!.tokens[k]).toBeGreaterThanOrEqual(0); +} + +// ---------------------------------------------------------------- setup & data + +describe('셋업 (§4)', () => { + test.each([ + [2, 4, 3, 36, 26, 16], + [3, 5, 4, 36, 26, 16], + [4, 7, 5, 36, 26, 16], + ])('%i players: %i tokens per colour, gold 5, %i nobles, 12 open cards, decks %i/%i/%i', (n, per, nobles, d1, d2, d3) => { + const s = mk(['A', 'B', 'C', 'D'].slice(0, n)); + expect(s.supply).toEqual({ W: per, U: per, G: per, R: per, K: per, gold: 5 }); + expect(s.nobles).toHaveLength(nobles); + expect(TIERS.map((t) => s.board[t].filter(Boolean).length)).toEqual([4, 4, 4]); + expect([s.decks[1].length, s.decks[2].length, s.decks[3].length]).toEqual([d1, d2, d3]); + expect([...s.order].sort()).toEqual(['A', 'B', 'C', 'D'].slice(0, n)); + expect(s.phase).toBe('action'); + expect(s.deadlineAt).toBe(clock + 60_000); + conserved(s); + }); + + test('turn order, nobles and decks come from the seeded RNG', () => { + const a = mk(['A', 'B', 'C', 'D'], {}, 3); + const b = mk(['A', 'B', 'C', 'D'], {}, 3); + expect(JSON.stringify(a)).toBe(JSON.stringify(b)); + const differs = [4, 5, 6, 7].some((seed) => JSON.stringify(mk(['A', 'B', 'C', 'D'], {}, seed)) !== JSON.stringify(a)); + expect(differs).toBe(true); + }); + + test('player count is limited to 2~4', () => { + expect(() => mk(['A'])).toThrow(); + expect(() => mk(['A', 'B', 'C', 'D', 'E'])).toThrow(); + expect([splendor.minPlayers, splendor.maxPlayers]).toEqual([2, 4]); + }); +}); + +describe('카드 데이터 (§3.1, §3.2)', () => { + test('90 cards: 40/30/20, 8/6/4 per colour per tier, points as in §3.1', () => { + expect(CARDS).toHaveLength(90); + expect(new Set(CARDS.map((c) => c.id)).size).toBe(90); + const per = (t: Tier) => CARDS.filter((c) => c.tier === t); + expect(TIERS.map((t) => per(t).length)).toEqual([40, 30, 20]); + for (const t of TIERS) for (const c of COLORS) expect(per(t).filter((x) => x.bonus === c)).toHaveLength([8, 6, 4][t - 1]!); + const dist = (t: Tier) => { + const d: Record = {}; + for (const c of per(t)) d[c.points] = (d[c.points] ?? 0) + 1; + return d; + }; + expect(dist(1)).toEqual({ 0: 35, 1: 5 }); + expect(dist(2)).toEqual({ 1: 10, 2: 15, 3: 5 }); + expect(dist(3)).toEqual({ 3: 5, 4: 10, 5: 5 }); + }); + + test('the card and noble tables match the spec document row by row (snapshot of §3.1 / §3.2)', () => { + const doc = readFileSync(join(import.meta.dir, '../../../../docs/games/splendor.md'), 'utf8'); + const num = (x: string) => (x === '·' ? 0 : Number(x)); + const rows = [...doc.matchAll(/^\| ([123][WUGRK]\d\d) \| . \| (\d) \| (.) \| (.) \| (.) \| (.) \| (.) \| (\d+) \|$/gm)]; + expect(rows).toHaveLength(90); + for (const m of rows) { + const card = cardOf(m[1]!); + expect(card.points).toBe(Number(m[2])); + expect(card.cost).toEqual({ W: num(m[3]!), U: num(m[4]!), G: num(m[5]!), R: num(m[6]!), K: num(m[7]!) }); + expect(COLORS.reduce((a, c) => a + card.cost[c], 0)).toBe(Number(m[8])); + } + const nobles = [...doc.matchAll(/^\| (N\d\d) \| (.) \| (.) \| (.) \| (.) \| (.) \|$/gm)]; + expect(nobles).toHaveLength(10); + expect(NOBLES).toHaveLength(10); + for (const m of nobles) { + const n = NOBLES.find((x) => x.id === m[1])!; + expect(n.points).toBe(3); + expect(n.req).toEqual({ W: num(m[2]!), U: num(m[3]!), G: num(m[4]!), R: num(m[5]!), K: num(m[6]!) }); + } + }); +}); + +// ---------------------------------------------------------------- taking tokens + +describe('보석 가져오기 (§5)', () => { + test('TAKE_SAME: allowed with 4 in the supply, refused with 3', () => { + const s = inOrder(mk(), ['A', 'B']); + const s2 = act(s, 'A', { type: 'TAKE_SAME', color: 'R' }); + expect(s2.players.A!.tokens.R).toBe(2); + expect(s2.supply.R).toBe(2); + const t = inOrder(mk(), ['A', 'B']); + setSupply(t, 'R', 3, 'B'); + expect(reason(t, 'A', { type: 'TAKE_SAME', color: 'R' })).toBe('같은 색 2개는 4개 이상 남은 색에서만 가져올 수 있어요.'); + }); + + test('TAKE_DIFFERENT: duplicates and gold are refused', () => { + const s = inOrder(mk(), ['A', 'B']); + expect(reason(s, 'A', { type: 'TAKE_DIFFERENT', colors: ['W', 'W', 'U'] })).toBe('서로 다른 색 3개를 골라 주세요.'); + expect(splendorActionSchema.safeParse({ type: 'TAKE_DIFFERENT', colors: ['W', 'U', 'gold'] }).success).toBe(false); + expect(reason(s, 'A', { type: 'TAKE_DIFFERENT', colors: ['W', 'U', 'gold' as Color] })).toBe('서로 다른 색 3개를 골라 주세요.'); + expect(reason(s, 'A', { type: 'TAKE_DIFFERENT', colors: ['W', 'U'] })).toBe('서로 다른 색 3개를 골라 주세요.'); + const s2 = act(s, 'A', { type: 'TAKE_DIFFERENT', colors: ['W', 'U', 'K'] }); + expect(s2.players.A!.tokens).toEqual(tk({ W: 1, U: 1, K: 1 })); + }); + + test('only 2 colours left: 2 allowed with allowFewerTokens, refused without; 3 left but only 2 taken → refused', () => { + const s = inOrder(mk(), ['A', 'B']); + setSupply(s, 'W', 0, 'B'); + setSupply(s, 'U', 0, 'B'); + setSupply(s, 'G', 0, 'B'); + expect(act(s, 'A', { type: 'TAKE_DIFFERENT', colors: ['R', 'K'] }).players.A!.tokens).toEqual(tk({ R: 1, K: 1 })); + expect(reason(s, 'A', { type: 'TAKE_DIFFERENT', colors: ['R'] })).toBe('남은 색이 2가지라 2개를 골라 주세요.'); + expect(splendor.view(s, 'A').me!.takeDifferent).toEqual({ available: ['R', 'K'], count: 2 }); + const strict = inOrder(mk(['A', 'B'], { allowFewerTokens: false }), ['A', 'B']); + for (const c of ['W', 'U', 'G'] as const) setSupply(strict, c, 0, 'B'); + expect(reason(strict, 'A', { type: 'TAKE_DIFFERENT', colors: ['R', 'K'] })).toBe('서로 다른 색 3개를 가져올 수 없어요.'); + expect(splendor.view(strict, 'A').me!.takeDifferent).toBeNull(); + const three = inOrder(mk(), ['A', 'B']); + setSupply(three, 'W', 0, 'B'); + setSupply(three, 'U', 0, 'B'); + expect(reason(three, 'A', { type: 'TAKE_DIFFERENT', colors: ['R', 'K'] })).toBe('서로 다른 색 3개를 골라 주세요.'); + expect(reason(three, 'A', { type: 'TAKE_DIFFERENT', colors: ['W', 'R', 'K'] })).toBe('남아 있지 않은 색이 있어요.'); + }); + + test('only the current player may act', () => { + const s = inOrder(mk(), ['A', 'B']); + expect(reason(s, 'B', { type: 'TAKE_SAME', color: 'W' })).toBe('지금은 내 차례가 아니에요.'); + expect(reason(s, 'Z', { type: 'TAKE_SAME', color: 'W' })).toBe('이 게임의 플레이어가 아니에요.'); + }); +}); + +// ---------------------------------------------------------------- reserving + +describe('예약 (§5)', () => { + test('reserving an open card gives a gold token and refills the slot from the deck', () => { + const s = inOrder(mk(), ['A', 'B']); + const id = s.board[2][1]!; + const top = s.decks[2][0]!; + const { state, events } = actE(s, 'A', { type: 'RESERVE', from: { tier: 2, slot: 1 } }); + expect(state.players.A!.reserved).toEqual([{ cardId: id, blind: false }]); + expect(state.players.A!.tokens.gold).toBe(1); + expect(state.board[2][1]).toBe(top); + expect(events.find((e) => e.type === 'reserved')).toMatchObject({ cardId: id, gold: true }); + expect(events.find((e) => e.type === 'refilled')).toMatchObject({ tier: 2, slot: 1, cardId: top }); + }); + + test('a fourth reservation is refused; with no gold left reserving still works (without gold)', () => { + let s = inOrder(mk(), ['A', 'B']); + for (let i = 0; i < 3; i++) { + s = act(s, 'A', { type: 'RESERVE', from: { tier: 1, slot: 0 } }); + s = act(s, 'B', { type: 'TAKE_DIFFERENT', colors: ['W', 'U', 'G'] }); + if (s.phase === 'discard') s = act(s, 'B', { type: 'TIMEOUT' }); + } + expect(s.players.A!.reserved).toHaveLength(3); + expect(reason(s, 'A', { type: 'RESERVE', from: { tier: 1, slot: 0 } })).toBe('예약은 3장까지만 할 수 있어요.'); + expect(reason(s, 'A', { type: 'RESERVE', from: { tier: 3, deck: true } })).toBe('예약은 3장까지만 할 수 있어요.'); + const t = inOrder(mk(), ['A', 'B']); + setSupply(t, 'gold', 0, 'B'); + const t2 = act(t, 'A', { type: 'RESERVE', from: { tier: 3, slot: 0 } }); + expect(t2.players.A!.reserved).toHaveLength(1); + expect(t2.players.A!.tokens.gold).toBe(0); + expect(splendor.view(t, 'A').me!.goldOnReserve).toBe(false); + }); + + test('a blind deck reservation: only the owner sees the card; others and spectators get the tier only; events carry no id', () => { + const s = inOrder(mk(['A', 'B', 'C']), ['A', 'B', 'C']); + const id = s.decks[3][0]!; + const { state, events } = actE(s, 'A', { type: 'RESERVE', from: { tier: 3, deck: true } }); + expect(state.players.A!.reserved).toEqual([{ cardId: id, blind: true }]); + expect(state.board[3]).toEqual(s.board[3]); + expect(splendor.view(state, 'A').players.A!.reserved[0]).toMatchObject({ id, hidden: false, blind: true }); + for (const viewer of ['B', 'C', null]) { + const v = splendor.view(state, viewer); + expect(v.players.A!.reserved).toEqual([{ hidden: true, tier: 3 }]); + expect(v.players.A!.reservedCount).toBe(1); + expect(JSON.stringify(v)).not.toContain(`"${id}"`); + } + expect(JSON.stringify(events)).not.toContain(`"${id}"`); + expect(events.find((e) => e.type === 'reserved')).toMatchObject({ tier: 3, cardId: null }); + }); +}); + +// ---------------------------------------------------------------- buying + +/** §5.1 position: bonuses W2 U0 G1 R0 K1, tokens W0 U2 G1 R0 K0 gold 1. */ +function example(): SplendorState { + const s = inOrder(mk(['A', 'B']), ['A', 'B']); + giveCards(s, 'A', ['1W07', '1W06', '1G07', '1K07']); // bonuses W2 G1 K1, 0 points + giveTokens(s, 'A', { U: 2, G: 1, gold: 1 }); + return s; +} + +describe('구매 (§5, §5.1)', () => { + test('example 1: 2W04 (R5 K3) needs 7 tokens → cannot buy', () => { + const s = example(); + place(s, '2W04', 0); + expect(reason(s, 'A', { type: 'BUY', from: { tier: 2, slot: 0 } })).toBe('보석이 부족해요.'); + expect(splendor.view(s, 'A').me!.buy['b2-0']).toBeUndefined(); + }); + + test('example 2: 1U05 (W1 K2) → W covered by bonus, K 1 paid with gold; gold 0 afterwards', () => { + const s = example(); + place(s, '1U05', 2); + const v = splendor.view(s, 'A').me!; + expect(v.buy['b1-2']).toMatchObject({ gold: 1, total: 1, need: { W: 0, U: 0, G: 0, R: 0, K: 1 } }); + const { state, events } = actE(s, 'A', { type: 'BUY', from: { tier: 1, slot: 2 } }); + expect(state.players.A!.tokens.gold).toBe(0); + expect(state.players.A!.cards).toContain('1U05'); + expect(state.supply.gold).toBe(5); + expect(events.find((e) => e.type === 'bought')).toMatchObject({ cardId: '1U05', paid: { gold: 1 } }); + }); + + test('bonuses that cover the whole cost: the card is free', () => { + const s = inOrder(mk(['A', 'B']), ['A', 'B']); + giveCards(s, 'A', ['1U07', '1U06', '1U01']); // three blue bonuses + place(s, '1W07', 0); // cost U3 + const s2 = act(s, 'A', { type: 'BUY', from: { tier: 1, slot: 0 } }); + expect(s2.players.A!.tokens).toEqual(emptyTokens()); + expect(s2.players.A!.cards).toContain('1W07'); + }); + + test('manual payment: gold instead of colour tokens is allowed; paying more than needed is refused', () => { + const s = inOrder(mk(['A', 'B']), ['A', 'B']); + giveTokens(s, 'A', { R: 3, gold: 2 }); + place(s, '1G07', 1); // cost R3 + const slot = { tier: 1 as const, slot: 1 }; + const s2 = act(s, 'A', { type: 'BUY', from: slot, payment: tk({ R: 1, gold: 2 }) }); + expect(s2.players.A!.tokens).toEqual(tk({ R: 2 })); + expect(reason(s, 'A', { type: 'BUY', from: slot, payment: tk({ R: 3, gold: 1 }) })).toBe('필요한 것보다 많이 낼 수는 없어요.'); + expect(reason(s, 'A', { type: 'BUY', from: slot, payment: tk({ R: 2 }) })).toBe('보석이 부족해요.'); + expect(reason(s, 'A', { type: 'BUY', from: slot, payment: tk({ R: 1, U: 1, gold: 1 }) })).toBe('보석이 부족해요.'); + }); + + test('buying a reserved card reveals it; the board is not refilled', () => { + let s = inOrder(mk(['A', 'B']), ['A', 'B']); + pull(s, '1R07'); // cost W3 + s.decks[1].unshift('1R07'); + const id = '1R07'; + s = act(s, 'A', { type: 'RESERVE', from: { tier: 1, deck: true } }); + s = act(s, 'B', { type: 'TAKE_SAME', color: 'G' }); + giveTokens(s, 'A', { W: 3 }); + const board = JSON.stringify(s.board); + const { state, events } = actE(s, 'A', { type: 'BUY', from: { reservedIndex: 0 } }); + expect(state.players.A!.cards).toEqual([id]); + expect(state.players.A!.reserved).toEqual([]); + expect(JSON.stringify(state.board)).toBe(board); + expect(events.find((e) => e.type === 'bought')).toMatchObject({ cardId: id, fromReserve: true }); + }); +}); + +// ---------------------------------------------------------------- token limit + +describe('토큰 한도 (§5)', () => { + test('9 tokens + 3 → discard phase; anything but exactly 10 left is refused', () => { + const s = inOrder(mk(['A', 'B']), ['A', 'B']); + giveTokens(s, 'A', { W: 3, U: 3, G: 3 }); + const s2 = act(s, 'A', { type: 'TAKE_DIFFERENT', colors: ['R', 'K', 'W'] }); + expect(s2.phase).toBe('discard'); + expect(cur(s2)).toBe('A'); + expect(splendor.view(s2, 'A').me!.discardCount).toBe(2); + expect(reason(s2, 'A', { type: 'TAKE_SAME', color: 'U' })).toBe('먼저 보석을 10개가 되도록 돌려놓아 주세요.'); + expect(reason(s2, 'A', { type: 'DISCARD', tokens: tk({ W: 1 }) })).toBe('보석이 10개가 되도록 2개를 골라 주세요.'); + expect(reason(s2, 'A', { type: 'DISCARD', tokens: tk({ W: 3 }) })).toBe('보석이 10개가 되도록 2개를 골라 주세요.'); + expect(reason(s2, 'A', { type: 'DISCARD', tokens: tk({ gold: 2 }) })).toBe('가진 것보다 많이 돌려놓을 수 없어요.'); + // A token taken just now may go back. + const s3 = act(s2, 'A', { type: 'DISCARD', tokens: tk({ R: 1, K: 1 }) }); + expect(s3.players.A!.tokens).toEqual(tk({ W: 4, U: 3, G: 3 })); + expect(cur(s3)).toBe('B'); + expect(s3.phase).toBe('action'); + }); +}); + +// ---------------------------------------------------------------- nobles + +describe('귀족 (§5)', () => { + test('two nobles satisfied at once → choose one; the other comes automatically at the end of the next turn', () => { + let s = inOrder(mk(['A', 'B']), ['A', 'B']); + s.nobles = ['N06', 'N07', 'N10']; // U3 G3 R3 / W3 U3 G3 / G3 R3 K3 + // Keep the noble total (3 open for 2 players): swap in from whatever setup chose. + giveCards(s, 'A', ['1W07', '1W06', '1W05', '1U07', '1U06', '1U05', '1G07', '1G06']); // W3 U3 G2 + place(s, '1G05', 0); // cost W2 U1 → free with bonuses; makes G3 → N07 (W3 U3 G3) + giveCards(s, 'A', ['1R07', '1R06', '1R05']); // R3 → with U3 G3 also N06 + const { state, events } = actE(s, 'A', { type: 'BUY', from: { tier: 1, slot: 0 } }); + expect(events.some((e) => e.type === 'nobleVisited')).toBe(false); + expect(state.phase).toBe('noble'); + expect(state.pendingNobleChoices).toEqual(['N06', 'N07']); + expect(splendor.view(state, 'A').me!.nobleChoices).toEqual(['N06', 'N07']); + expect(reason(state, 'A', { type: 'CHOOSE_NOBLE', nobleId: 'N10' })).toBe('고를 수 없는 귀족이에요.'); + s = act(state, 'A', { type: 'CHOOSE_NOBLE', nobleId: 'N07' }); + expect(s.players.A!.nobles).toEqual(['N07']); + expect(pointsOf(s.players.A!)).toBe(3); + expect(cur(s)).toBe('B'); + s = act(s, 'B', { type: 'TAKE_DIFFERENT', colors: ['W', 'U', 'G'] }); + const out = actE(s, 'A', { type: 'TAKE_DIFFERENT', colors: ['W', 'U', 'K'] }); + expect(out.state.players.A!.nobles).toEqual(['N07', 'N06']); + expect(out.events.find((e) => e.type === 'nobleVisited')).toMatchObject({ player: 'A', nobleId: 'N06' }); + expect(out.state.nobles).toEqual(['N10']); + }); + + test('hints: bonuses still missing per noble (only with showHints)', () => { + const s = inOrder(mk(['A', 'B']), ['A', 'B']); + s.nobles = ['N01', 'N06', 'N10']; + giveCards(s, 'A', ['1G07', '1R07']); + expect(splendor.view(s, 'A').me!.nobleNeed).toEqual({ N01: 6, N06: 7, N10: 7 }); + const off = inOrder(mk(['A', 'B'], { showHints: false }), ['A', 'B']); + expect(splendor.view(off, 'A').me!.nobleNeed).toEqual({}); + }); +}); + +// ---------------------------------------------------------------- end & ranking + +describe('종료와 순위 (§6)', () => { + test('the 2nd player reaches 15 → the 3rd and 4th still play, then the game ends', () => { + let s = inOrder(mk(['A', 'B', 'C', 'D']), ['A', 'B', 'C', 'D']); + giveCards(s, 'B', ['3W04', '3U04', '3G04']); // 15 points, bonuses W1 U1 G1 + s = act(s, 'A', { type: 'TAKE_DIFFERENT', colors: ['W', 'U', 'G'] }); + const out = actE(s, 'B', { type: 'TAKE_DIFFERENT', colors: ['W', 'U', 'G'] }); + expect(out.events.find((e) => e.type === 'endTriggered')).toMatchObject({ player: 'B', points: 15 }); + s = out.state; + expect(s.endTriggered).toBe(true); + s = act(s, 'C', { type: 'TAKE_DIFFERENT', colors: ['W', 'U', 'G'] }); + expect(s.phase).toBe('action'); + expect(cur(s)).toBe('D'); + s = act(s, 'D', { type: 'TAKE_DIFFERENT', colors: ['R', 'U', 'K'] }); + expect(s.phase).toBe('finished'); + const r = splendor.result(s)!; + expect(r.ranking[0]).toEqual(['B']); + expect(r.reason).toBe('normal'); + expect(r.summary).toBe('1위 15점 (카드 3장) · 2위 0점'); + }); + + test('the player just before the first player reaches 15 → the game ends at once', () => { + let s = inOrder(mk(['A', 'B', 'C']), ['A', 'B', 'C']); + giveCards(s, 'C', ['3W04', '3U04', '3G04']); + s = act(s, 'A', { type: 'TAKE_DIFFERENT', colors: ['W', 'U', 'G'] }); + s = act(s, 'B', { type: 'TAKE_DIFFERENT', colors: ['W', 'U', 'G'] }); + s = act(s, 'C', { type: 'TAKE_DIFFERENT', colors: ['R', 'U', 'K'] }); + expect(s.phase).toBe('finished'); + expect(s.turnsTaken).toEqual({ A: 1, B: 1, C: 1 }); + }); + + test('targetPoints=10 ends at 10 points', () => { + let s = inOrder(mk(['A', 'B'], { targetPoints: 10 }), ['A', 'B']); + giveCards(s, 'B', ['3W04', '3U04']); + s = act(s, 'A', { type: 'TAKE_DIFFERENT', colors: ['W', 'U', 'G'] }); + s = act(s, 'B', { type: 'TAKE_DIFFERENT', colors: ['W', 'U', 'G'] }); + expect(s.phase).toBe('finished'); + }); + + test('ties: same points → fewer cards wins; same points and cards → shared rank (A 16/14, B 16/12, C 15)', () => { + const s = inOrder(mk(['A', 'B', 'C']), ['A', 'B', 'C']); + const zeros = CARDS.filter((c) => c.tier === 1 && c.points === 0).map((c) => c.id); + giveCards(s, 'A', ['3W04', '3U04', '3G04', '1W08', ...zeros.slice(0, 10)]); + giveCards(s, 'B', ['3R04', '3K04', '3W03', '1U08', '1G08', ...zeros.slice(10, 17)]); + giveCards(s, 'C', ['3U03', '3G03', '3R03', '2W06']); + expect(['A', 'B', 'C'].map((p) => [pointsOf(s.players[p]!), s.players[p]!.cards.length])).toEqual([ + [16, 14], + [16, 12], + [15, 4], + ]); + s.phase = 'finished'; + s.endReason = 'target'; + expect(splendor.result(s)!.ranking).toEqual([['B'], ['A'], ['C']]); + expect(splendor.view(s, null).result!.ranking.map((x) => [x.player, x.rank])).toEqual([ + ['B', 1], + ['A', 2], + ['C', 3], + ]); + giveCards(s, 'B', zeros.slice(17, 19)); // B: 16 points, 14 cards → same as A + const r = splendor.result(s)!; + expect(r.ranking).toEqual([['A', 'B'], ['C']]); + expect(r.summary).toBe('공동 1위 2명, 16점 (카드 14장)'); + expect(splendor.view(s, 'A').result!.winners).toEqual(['A', 'B']); + }); +}); + +// ---------------------------------------------------------------- pass, empty decks + +describe('넘기기와 더미 소진 (§5)', () => { + /** + * Nobody can do anything: all colour tokens are held (10 each), both have 3 reservations they can't + * afford and the board is empty (its cards went back under the decks). + */ + function stuck(): SplendorState { + const s = inOrder(mk(['A', 'B']), ['A', 'B']); + const reserve = (p: PlayerId, ids: string[]) => { + for (const id of ids) { + pull(s, id); + s.players[p]!.reserved.push({ cardId: id, blind: false }); + } + }; + reserve('A', ['3W02', '3U02', '3G02']); // K7, W7, U7 + reserve('B', ['3R02', '3K02', '3U04']); // G7, R7, W7 U3 + giveTokens(s, 'A', { W: 4, U: 4, G: 2 }); + giveTokens(s, 'B', { G: 2, R: 4, K: 4 }); + for (const t of TIERS) { + for (const id of s.board[t]) if (id) s.decks[t].push(id); + s.board[t] = [null, null, null, null]; + } + return s; + } + + test('PASS is refused while any action is possible and allowed only when none is', () => { + const s = inOrder(mk(['A', 'B']), ['A', 'B']); + expect(reason(s, 'A', { type: 'PASS' })).toBe('할 수 있는 행동이 있어요.'); + expect(splendor.view(s, 'A').me!.canPass).toBe(false); + const t = stuck(); + expect(splendor.view(t, 'A').me!.canPass).toBe(true); + expect(splendor.legalActions!(t, 'A')).toEqual([{ type: 'PASS' }]); + const t2 = act(t, 'A', { type: 'PASS' }); + expect(cur(t2)).toBe('B'); + expect(t2.passStreak).toBe(1); + expect(t2.phase).toBe('action'); + // With one token back in the supply A could take it, so passing is refused again. + const u = stuck(); + u.players.B!.tokens.K--; + u.supply.K++; + expect(reason(u, 'A', { type: 'PASS' })).toBe('할 수 있는 행동이 있어요.'); + }); + + test('a full round of passes ends the game (nothing can change any more)', () => { + let s = act(stuck(), 'A', { type: 'PASS' }); + expect(splendor.onTimeout(s, 'B')).toEqual({ type: 'TIMEOUT' }); + s = act(s, 'B', { type: 'TIMEOUT' }); // automatic move is a pass as well + expect(s.phase).toBe('finished'); + expect(s.endReason).toBe('stalemate'); + expect(splendor.result(s)!.summary).toMatch(/^더 할 수 있는 행동이 없어 끝났어요\./); + }); + + test('empty deck: the board slot stays empty after a buy, and reserving from the empty deck is refused', () => { + let s = inOrder(mk(['A', 'B']), ['A', 'B']); + // Move the rest of the tier-3 deck out of reach: B "owns" them as bought cards (keeps the 90 total). + s.players.B!.cards.push(...s.decks[3].splice(0)); + const id = s.board[3][2]!; + giveTokens(s, 'A', { gold: 5 }); + const need = COLORS.reduce((a, c) => a + cardOf(id).cost[c], 0); + giveTokens(s, 'A', Object.fromEntries(COLORS.map((c) => [c, Math.min(cardOf(id).cost[c], s.supply[c])])) as Partial); + expect(need).toBeGreaterThan(0); + expect(reason(s, 'A', { type: 'RESERVE', from: { tier: 3, deck: true } })).toBe('그 단계 더미에 카드가 없어요.'); + expect(splendor.view(s, 'A').me!.reserveDecks).toEqual([1, 2]); + s = act(s, 'A', { type: 'BUY', from: { tier: 3, slot: 2 } }); + if (s.phase === 'discard') s = act(s, 'A', { type: 'TIMEOUT' }); + expect(s.board[3][2]).toBeNull(); + expect(splendor.view(s, null).board[3][2]).toBeNull(); + expect(reason(s, 'B', { type: 'BUY', from: { tier: 3, slot: 2 } })).toBe('그 자리에 카드가 없어요.'); + expect(reason(s, 'B', { type: 'RESERVE', from: { tier: 3, slot: 2 } })).toBe('그 자리에 카드가 없어요.'); + }); +}); + +// ---------------------------------------------------------------- timeouts + +describe('시간 초과와 자리 비움 (§8.5)', () => { + test('TIMEOUT is system-only (not in the action schema) and legal for the current player', () => { + const s = mk(); + expect(splendorActionSchema.safeParse({ type: 'TIMEOUT' }).success).toBe(false); + expect(splendor.onTimeout(s, cur(s))).toEqual({ type: 'TIMEOUT' }); + expect(splendor.validate(s, cur(s), { type: 'TIMEOUT' }).ok).toBe(true); + }); + + test('affordable card → automatic buy (most points, then fewest tokens)', () => { + const s = inOrder(mk(['A', 'B']), ['A', 'B']); + place(s, '1W08', 0); // G4, 1 point + place(s, '1U07', 1); // K3, 0 points + giveTokens(s, 'A', { G: 4, K: 3 }); + const { state, events } = actE(s, 'A', { type: 'TIMEOUT' }); + expect(state.players.A!.cards).toEqual(['1W08']); + expect(events.some((e) => e.type === 'timeoutAuto')).toBe(true); + expect(state.log.at(-1)).toMatchObject({ k: 'buy', cardId: '1W08', auto: true }); + }); + + test('nothing affordable → 3 different colours with the most supply (ties W,U,G,R,K)', () => { + const s = inOrder(mk(['A', 'B']), ['A', 'B']); + setSupply(s, 'W', 2, 'B'); + const s2 = act(s, 'A', { type: 'TIMEOUT' }); + expect(s2.players.A!.tokens).toEqual(tk({ U: 1, G: 1, R: 1 })); + }); + + test('timeouts in discard and noble phases finish the turn so the game never stalls', () => { + let s = inOrder(mk(['A', 'B']), ['A', 'B']); + giveTokens(s, 'A', { W: 3, U: 3, G: 3 }); + s = act(s, 'A', { type: 'TAKE_DIFFERENT', colors: ['R', 'K', 'W'] }); + expect(s.phase).toBe('discard'); + s = act(s, 'A', { type: 'TIMEOUT' }); + // Most held first (W 4), then ties K,R,G,U,W: G (3) before U (3). + expect(s.players.A!.tokens).toEqual(tk({ W: 3, U: 3, G: 2, R: 1, K: 1 })); + expect(cur(s)).toBe('B'); + + let t = inOrder(mk(['A', 'B']), ['A', 'B']); + t.nobles = ['N06', 'N07', 'N10']; + giveCards(t, 'A', ['1W07', '1W06', '1W05', '1U07', '1U06', '1U05', '1G07', '1G06', '1G05', '1R07', '1R06', '1R05']); + t = act(t, 'A', { type: 'TAKE_DIFFERENT', colors: ['W', 'U', 'G'] }); + expect(t.phase).toBe('noble'); + t = act(t, 'A', { type: 'TIMEOUT' }); + expect(t.players.A!.nobles).toEqual(['N06']); // smallest id + expect(cur(t)).toBe('B'); + }); + + test('an automatic action that goes over 10 tokens also returns them in the same timeout', () => { + const s = inOrder(mk(['A', 'B']), ['A', 'B']); + giveTokens(s, 'A', { W: 2, U: 2, G: 2, R: 2, K: 2 }); + for (const t of [1, 2] as const) { + for (const id of s.board[t]) if (id) s.decks[t].push(id); + s.board[t] = [null, null, null, null]; // only tier-3 cards left: none affordable with 2 of each + } + const s2 = act(s, 'A', { type: 'TIMEOUT' }); + expect(s2.log.find((e) => e.k === 'take')).toMatchObject({ auto: true }); + expect(s2.log.find((e) => e.k === 'discard')).toMatchObject({ auto: true }); + expect(cur(s2)).toBe('B'); + expect(componentCounts(s2).tokens).toEqual(initialTokens(s2)); + expect(Object.values(s2.players.A!.tokens).reduce((a, b) => a + b, 0)).toBe(10); + }); + + test('3 timeouts in a row → away (short deadline); acting again clears it', () => { + let s = inOrder(mk(['A', 'B']), ['A', 'B']); + for (let i = 0; i < 3; i++) { + s = act(s, 'A', { type: 'TIMEOUT' }); + s = act(s, 'B', { type: 'RESERVE', from: { tier: 1, slot: 0 } }); + } + expect(splendor.view(s, 'B').players.A!.away).toBe(true); + expect(s.deadlineAt).toBe(clock + 5000); + expect(s.log.some((e) => e.k === 'away' && e.p === 'A')).toBe(true); + s = act(s, 'A', { type: 'RESERVE', from: { tier: 2, slot: 0 } }); + if (s.phase === 'discard') s = act(s, 'A', { type: 'DISCARD', tokens: tk({ gold: 1 }) }); + expect(splendor.view(s, 'B').players.A!.away).toBe(false); + expect(s.deadlineAt).toBe(clock + 60_000); + }); + + test('discard / noble phases get 15 extra seconds; no time limit → no deadline', () => { + const s = inOrder(mk(['A', 'B']), ['A', 'B']); + giveTokens(s, 'A', { W: 3, U: 3, G: 3 }); + const before = s.deadlineAt!; + const s2 = act(s, 'A', { type: 'TAKE_DIFFERENT', colors: ['R', 'K', 'W'] }); + expect(s2.deadlineAt).toBe(before + 15_000); + const free = mk(['A', 'B'], { turnSeconds: null }); + expect(free.deadlineAt).toBeNull(); + expect(splendor.deadline!(free)).toBeNull(); + }); +}); + +// ---------------------------------------------------------------- concede / leave + +describe('기권 (§8.2)', () => { + test('3 players: tokens go back, reservations leave the game, cards stay for the result (ranked last)', () => { + let s = inOrder(mk(['A', 'B', 'C']), ['A', 'B', 'C']); + giveCards(s, 'B', ['3W04']); + s = act(s, 'A', { type: 'RESERVE', from: { tier: 1, deck: true } }); + giveTokens(s, 'B', { R: 2 }); + s = act(s, 'B', { type: 'RESERVE', from: { tier: 2, slot: 0 } }); + expect(splendor.onLeave!(s, 'B')).toEqual({ type: 'CONCEDE' }); + s = act(s, 'B', { type: 'CONCEDE' }); + expect(s.players.B!.tokens).toEqual(emptyTokens()); + expect(s.players.B!.reserved).toEqual([]); + expect(s.players.B!.cards).toEqual(['3W04']); + expect(s.removed).toHaveLength(1); + expect(cur(s)).toBe('C'); + s = act(s, 'C', { type: 'TAKE_DIFFERENT', colors: ['W', 'U', 'G'] }); + expect(cur(s)).toBe('A'); // B is skipped + expect(reason(s, 'B', { type: 'CONCEDE' })).toBe('이미 기권했어요.'); + expect(splendor.onLeave!(s, 'B')).toBeNull(); + }); + + test('the current player concedes → next player; 2 players → the game ends, the other one wins', () => { + let s = inOrder(mk(['A', 'B', 'C']), ['A', 'B', 'C']); + s = act(s, 'A', { type: 'CONCEDE' }); + expect(cur(s)).toBe('B'); + let t = inOrder(mk(['A', 'B']), ['A', 'B']); + giveCards(t, 'A', ['3W04']); + t = act(t, 'A', { type: 'CONCEDE' }); + expect(t.phase).toBe('finished'); + const r = splendor.result(t)!; + expect(r.ranking).toEqual([['B'], ['A']]); + expect(r.reason).toBe('resign'); + expect(r.summary).toMatch(/^다른 사람이 모두 기권했어요\./); + }); +}); + +// ---------------------------------------------------------------- info hiding, determinism, options + +describe('정보 공개 (§8.3)', () => { + test('views and events never contain deck contents, unused nobles, blind reservations of others or an rng', () => { + let s = inOrder(mk(['A', 'B', 'C']), ['A', 'B', 'C']); + const allEvents: GameEvent[] = []; + const step = (who: PlayerId, a: SplendorAction) => { + const out = actE(s, who, a); + allEvents.push(...out.events); + s = out.state; + }; + step('A', { type: 'RESERVE', from: { tier: 1, deck: true } }); + step('B', { type: 'RESERVE', from: { tier: 2, deck: true } }); + step('C', { type: 'RESERVE', from: { tier: 3, deck: true } }); + const blind = { A: s.players.A!.reserved[0]!.cardId, B: s.players.B!.reserved[0]!.cardId, C: s.players.C!.reserved[0]!.cardId }; + for (const viewer of ['A', 'B', 'C', null] as const) { + const json = JSON.stringify(splendor.view(s, viewer)); + for (const [owner, id] of Object.entries(blind)) { + if (owner === viewer) expect(json).toContain(`"${id}"`); + else expect(json).not.toContain(`"${id}"`); + } + for (const t of TIERS) for (const id of s.decks[t]) expect(json).not.toContain(`"${id}"`); + const used = new Set(s.nobles); + for (const n of NOBLES) if (!used.has(n.id)) expect(json).not.toContain(`"${n.id}"`); + expect(json).not.toMatch(/"rng"|"decks"/); + } + const ev = JSON.stringify(allEvents); + for (const id of Object.values(blind)) expect(ev).not.toContain(`"${id}"`); + // After the game all reservations are shown. + s.phase = 'finished'; + s.endReason = 'target'; + const res = splendor.view(s, null); + expect(res.result!.revealedReserved).toEqual({ A: [blind.A], B: [blind.B], C: [blind.C] }); + expect(res.players.A!.reserved[0]).toMatchObject({ id: blind.A, hidden: false }); + }); + + test('secretsOf: own blind reservations + every deck card + the unused nobles', () => { + let s = inOrder(mk(['A', 'B']), ['A', 'B']); + s = act(s, 'A', { type: 'RESERVE', from: { tier: 2, deck: true } }); + const sec = splendor.secretsOf!(s, 'A'); + expect(sec).toContain(s.players.A!.reserved[0]!.cardId); + expect(sec.filter((x) => x.startsWith('N'))).toHaveLength(7); + expect(sec.length).toBe(1 + s.decks[1].length + s.decks[2].length + s.decks[3].length + 7); + }); +}); + +describe('결정성과 옵션', () => { + test('replay: same seed + same actions → same states and events', () => { + const play = () => { + let s = mk(['A', 'B', 'C'], {}, 42); + const log: string[] = []; + for (let i = 0; i < 60 && s.phase !== 'finished'; i++) { + const p = cur(s); + const legal = splendor.legalActions!(s, p); + const a = legal[(i * 7) % legal.length]!; + const out = splendor.apply(s, p, a, { rng: RNG(), now: 5000 + i }); + log.push(JSON.stringify(out.events)); + s = out.state; + } + return JSON.stringify(s) + log.join('|'); + }; + expect(play()).toBe(play()); + }); + + test('every option has a Korean title (and labels for choices); defaults follow §2', () => { + expect(splendor.defaultOptions).toEqual({ targetPoints: 15, allowFewerTokens: true, turnSeconds: 60, showHints: true }); + const shape = splendor.optionsSchema as unknown as { shape: Record } }> }; + for (const [key, field] of Object.entries(shape.shape)) { + const m = field.meta(); + expect(m?.title, key).toMatch(/[가-힣]/); + if (key === 'targetPoints' || key === 'turnSeconds') expect(Object.keys(m.labels ?? {}).length).toBeGreaterThan(1); + } + expect(splendor.optionsSchema.safeParse({ targetPoints: 12 }).success).toBe(false); + expect(splendor.optionsSchema.parse({ turnSeconds: null }).turnSeconds).toBeNull(); + }); +}); diff --git a/packages/shared/src/catalog.ts b/packages/shared/src/catalog.ts index dded0fa..1757f01 100644 --- a/packages/shared/src/catalog.ts +++ b/packages/shared/src/catalog.ts @@ -28,6 +28,7 @@ export const CATALOG: CatalogEntry[] = [ { 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: 'splendor', nameKo: '보석 상인', minPlayers: 2, maxPlayers: 4, minutes: '20~40', level: '보통', blurb: '보석을 모아 카드를 사고, 할인으로 더 비싼 카드까지! 귀족 방문은 덤', available: true }, ]; export const catalogById = (id: string): CatalogEntry | undefined => CATALOG.find((g) => g.id === id);