diff --git a/apps/web/src/games/dalmuti/index.tsx b/apps/web/src/games/dalmuti/index.tsx new file mode 100644 index 0000000..196d8b9 --- /dev/null +++ b/apps/web/src/games/dalmuti/index.tsx @@ -0,0 +1,1007 @@ +/** 달무티 화면 (docs/games/dalmuti.md §9). */ +import { useEffect, useRef, useState, type CSSProperties, type KeyboardEvent, type ReactNode } from 'react'; +import type { Choice, DCard, DalmutiOptions, DalmutiView, LogEntry, PlayView, SeatView, Title } from '@bg/games/dalmuti'; +import type { PublicUser } from '@bg/shared/lite'; +import { Avatar } from '../../components/ui'; +import { TurnTimer } from '../../components/TurnTimer'; +import { haptic, sounds } from '../../lib/feedback'; +import type { BoardProps, GameUI, OptionsProps } from '../types'; + +const JESTER = 13; +/** Same names as the rules module (kept here so the screen chunk does not pull in the rules code). */ +const CARD_NAME: Record = { + 1: '왕', + 2: '왕비', + 3: '재상', + 4: '공작', + 5: '백작', + 6: '기사', + 7: '학자', + 8: '상인', + 9: '요리사', + 10: '목동', + 11: '광부', + 12: '농부', + 13: '광대', +}; +const CARD_HUE: Record = { + 1: '#b7791f', + 2: '#7c3aed', + 3: '#4338ca', + 4: '#1d4ed8', + 5: '#0f766e', + 6: '#15803d', + 7: '#4d7c0f', + 8: '#a16207', + 9: '#c2410c', + 10: '#9a3412', + 11: '#475569', + 12: '#57534e', +}; +const TITLE_ICON: Record = { 대달무티: '👑', 소달무티: '🎩', 평민: '🙂', 소농노: '🌾', 대농노: '🧺' }; +const TIP_KEY = 'dm-tip-hidden'; + +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 cardName = (n: number) => (n === JESTER ? '광대' : `${n} ${CARD_NAME[n]}`); +const rankText = (rank: number) => (rank === JESTER ? '광대(13)' : String(rank)); +const setText = (p: { rank: number; count: number }) => `${rankText(p.rank)} × ${p.count}장`; +const facesText = (cards: number[]) => cards.map((n) => (n === JESTER ? '광대' : String(n))).join(', '); + +const CSS = ` +.dm { width: 100%; max-width: 1080px; display: flex; flex-direction: column; gap: 10px; align-self: center; } +.dm-tip { display: flex; align-items: center; gap: 8px; background: var(--primary-soft); color: var(--text); border-radius: 12px; padding: 6px 6px 6px 12px; font-weight: 700; font-size: .95rem; } +.dm-tip span { flex: 1; min-width: 0; } +.dm-tip button { min-width: 40px; min-height: 40px; border: 0; border-radius: 10px; background: transparent; color: var(--text); font-size: 1.1rem; cursor: pointer; } +.dm-seats { display: flex; flex-wrap: wrap; gap: 6px; margin: 0; padding: 0; list-style: none; justify-content: center; } +.dm-seat { position: relative; flex: 0 1 124px; min-width: 104px; background: var(--surface); color: var(--text); border-radius: 12px; padding: 6px 8px; + border: 3px solid transparent; box-shadow: var(--shadow); display: flex; flex-direction: column; gap: 2px; font-size: .88rem; animation: dm-in .35s ease both; } +.dm-seat.turn { border-color: var(--focus); } +.dm-seat.me { background: var(--primary-soft); } +.dm-seat.out { opacity: .7; } +.dm-seat .nm { font-weight: 800; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; min-width: 0; } +.dm-seat .ttl { font-weight: 800; font-size: .8rem; color: var(--text-sub); white-space: nowrap; } +.dm-seat .meta { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; min-height: 22px; } +.dm-count { display: inline-flex; align-items: center; gap: 4px; font-weight: 800; font-variant-numeric: tabular-nums; } +.dm-count i { display: inline-block; width: 11px; height: 15px; border-radius: 3px; background: #7c2d12; border: 1.5px solid #fdba74; } +.dm-badge { display: inline-block; border-radius: 999px; padding: 0 7px; font-weight: 800; font-size: .76rem; line-height: 1.6; white-space: nowrap; } +.dm-bubble { position: absolute; top: -10px; right: -4px; background: #fff; color: #1f2937; border: 2px solid #9ca3af; border-radius: 10px; padding: 0 6px; + font-weight: 800; font-size: .76rem; animation: dm-pop .25s ease; } +.dm-order { font-size: .78rem; color: var(--text-sub); text-align: center; margin: -4px 0 0; } +.dm-table { position: relative; border-radius: 22px; background: radial-gradient(ellipse at center, rgba(255,255,255,.12), rgba(255,255,255,0) 65%), var(--felt); + border: 8px solid var(--wood); box-shadow: var(--shadow); padding: 14px; min-height: 200px; color: #fff; display: flex; flex-direction: column; gap: 10px; align-items: center; justify-content: center; } +.dm-now { font-size: 1.6rem; font-weight: 900; text-shadow: 0 1px 3px rgba(0,0,0,.5); text-align: center; line-height: 1.25; } +.dm-now small { display: block; font-size: .95rem; font-weight: 700; opacity: .92; } +.dm-pile { display: flex; align-items: flex-end; gap: 14px; justify-content: center; flex-wrap: wrap; } +.dm-prev { display: flex; gap: 6px; opacity: .8; } +.dm-prev .dm-card .nm { display: none; } +.dm-set { display: flex; } +.dm-set > .dm-card + .dm-card { margin-left: calc(var(--w) * -0.55); } +.dm-set.top { animation: dm-fly .3s ease-out; } +.dm-card { --w: 60px; position: relative; flex: none; width: var(--w); height: calc(var(--w) * 1.4); border-radius: calc(var(--w) * .13); background: #fffdf7; color: #1f2937; + border: 2px solid var(--c, #444); box-shadow: 0 2px 4px rgba(0,0,0,.28); display: flex; flex-direction: column; align-items: center; overflow: hidden; font-family: inherit; } +.dm-card .band { width: 100%; background: var(--c, #444); color: #fff; font-weight: 900; font-size: calc(var(--w) * .2); line-height: 1.35; text-align: left; padding-left: 4px; } +.dm-card .big { flex: 1; display: flex; align-items: center; justify-content: center; font-weight: 900; font-size: calc(var(--w) * .5); color: var(--c, #444); line-height: 1; font-variant-numeric: tabular-nums; } +.dm-card .nm { font-size: calc(var(--w) * .19); font-weight: 800; padding-bottom: 3px; white-space: nowrap; } +.dm-card.jester { --c: #be185d; background: linear-gradient(160deg, #fff7ed, #fdf2f8); } +.dm-card.jester .band { background: linear-gradient(90deg, #dc2626, #f59e0b, #16a34a, #2563eb, #9333ea); } +.dm-card.jester .big { font-size: calc(var(--w) * .42); } +.dm-hand-wrap { background: var(--surface); border-radius: 16px; box-shadow: var(--shadow); padding: 10px 12px; display: flex; flex-direction: column; gap: 8px; } +.dm-hand { display: flex; flex-wrap: wrap; gap: 8px; align-items: flex-end; } +.dm-group { position: relative; min-width: 56px; min-height: 56px; padding: 4px 4px 0; border: 3px solid transparent; border-radius: 12px; background: none; cursor: pointer; + transition: transform .12s ease, opacity .12s ease; display: flex; flex-direction: column; align-items: center; gap: 2px; color: var(--text); font: inherit; } +.dm-group .stack { position: relative; } +.dm-group .stack[data-many='true'] .dm-card { box-shadow: 3px -3px 0 -1px #fffdf7, 3px -3px 0 1px var(--c, #444), 0 2px 4px rgba(0,0,0,.28); } +.dm-group .cnt { position: absolute; right: -7px; top: -7px; background: #111827; color: #fff; border-radius: 999px; padding: 0 7px; font-weight: 900; font-size: .85rem; line-height: 1.5; } +.dm-group .tag { font-size: .72rem; font-weight: 800; color: var(--warning); white-space: nowrap; min-height: 1.1em; } +.dm-group[data-legal='false'] { opacity: .42; filter: grayscale(.5); cursor: default; } +.dm-group[aria-pressed='true'] { border-color: var(--primary); transform: translateY(-6px); background: var(--primary-soft); } +.dm-group:focus-visible, .dm-one:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; } +.dm-one { padding: 2px; border: 3px solid transparent; border-radius: 10px; background: none; cursor: pointer; position: relative; } +.dm-one[aria-pressed='true'] { border-color: var(--primary); transform: translateY(-8px); } +.dm-one[data-received='true']::after { content: '받음'; position: absolute; left: 50%; top: -9px; transform: translateX(-50%); background: var(--warning); color: #fff; font-size: .7rem; font-weight: 800; border-radius: 999px; padding: 0 6px; animation: dm-pop .4s ease; } +.dm-pick { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; background: var(--surface-2); border-radius: 12px; padding: 8px 10px; } +.dm-pick .stepper { display: inline-flex; align-items: center; gap: 6px; } +.dm-pick .stepper button { width: 48px; height: 48px; border-radius: 12px; border: 2px solid var(--border); background: var(--surface); color: var(--text); font-size: 1.4rem; font-weight: 900; cursor: pointer; } +.dm-pick .stepper button:disabled { opacity: .4; cursor: not-allowed; } +.dm-pick .stepper b { min-width: 3.2em; text-align: center; font-size: 1.1rem; } +.dm-toggle { min-height: 48px; border-radius: 12px; border: 2px solid var(--border); background: var(--surface); color: var(--text); font-weight: 800; padding: 0 12px; cursor: pointer; } +.dm-toggle[aria-pressed='true'] { background: #fce7f3; border-color: #be185d; color: #831843; } +.dm-toggle:disabled { opacity: .55; cursor: not-allowed; } +.dm-actions { display: flex; gap: 8px; } +.dm-actions .btn { flex: 1 1 0; min-height: 56px; font-size: 1.1rem; } +.dm-panel { background: var(--surface); color: var(--text); border-radius: 16px; box-shadow: var(--shadow); padding: 12px; display: flex; flex-direction: column; gap: 8px; } +.dm-panel.hot { border: 3px solid var(--warning); } +.dm-panel h3 { margin: 0; font-size: 1.05rem; } +.dm-rev { background: linear-gradient(90deg, #7c2d12, #be185d); color: #fff; border-radius: 12px; padding: 8px 12px; font-weight: 800; text-align: center; animation: dm-pop .4s ease; } +.dm-draws { display: flex; flex-wrap: wrap; gap: 8px; } +.dm-draw { display: flex; align-items: center; gap: 6px; background: var(--surface-2); border-radius: 10px; padding: 4px 8px; animation: dm-in .4s ease both; } +.dm-mini { display: flex; flex-wrap: wrap; gap: 3px; } +.dm-mini .dm-card { --w: 30px; } +.dm-mini .dm-card .nm { display: none; } +.dm-log { list-style: none; margin: 8px 0 0; padding: 0; display: grid; gap: 3px; max-height: 220px; overflow: auto; } +.dm-rules td, .dm-rules th { padding: 3px 6px; text-align: left; border-bottom: 1px solid var(--border); vertical-align: middle; } +.dm-rules .dm-card { --w: 28px; } +.dm-rules .dm-card .nm { display: none; } +.dm-flip { animation: dm-flip .7s ease both; } +@keyframes dm-in { from { transform: translateY(-8px); opacity: 0; } to { transform: none; opacity: 1; } } +@keyframes dm-pop { 0% { transform: scale(.6); } 70% { transform: scale(1.12); } 100% { transform: scale(1); } } +@keyframes dm-fly { from { transform: translateY(40px) scale(.85); opacity: .2; } to { transform: none; opacity: 1; } } +@keyframes dm-flip { from { transform: rotateY(180deg); } to { transform: none; } } +@media (prefers-reduced-motion: reduce) { + .dm *, .dm-seat, .dm-set.top, .dm-rev, .dm-bubble, .dm-draw, .dm-flip { animation: none !important; transition: none !important; } +} +@media (max-width: 719px) { + .dm { gap: 8px; } + .dm-seat { flex: 1 1 30%; min-width: 0; padding: 4px 6px; font-size: .82rem; } + .dm-table { border-width: 6px; padding: 10px; min-height: 150px; } + .dm-now { font-size: 1.3rem; } + .dm-card { --w: 44px; } + .dm-hand { gap: 4px; } + .dm-group { min-width: 48px; padding: 3px 2px 0; } + .dm-hand-wrap { padding: 8px; position: relative; } + .dm-actions { position: sticky; bottom: 0; background: var(--surface); padding: 6px 0 2px; z-index: 2; } +} +`; + +// ---------------------------------------------------------------- cards + +function CardArt({ n, w }: { n: number; w?: number }) { + const style = { ...(w ? { '--w': `${w}px` } : {}), ...(n === JESTER ? {} : { '--c': CARD_HUE[n] }) } as CSSProperties; + return ( + + {n === JESTER ? '★' : n} + {n === JESTER ? '광대' : n} + {CARD_NAME[n]} + + ); +} + +function SetArt({ play, w, top }: { play: Pick; w?: number; top?: boolean }) { + return ( + + {play.cards.map((n, i) => ( + + ))} + + ); +} + +// ---------------------------------------------------------------- seats + +function seatLabel(s: SeatView, v: DalmutiView, players: Record, me: string | null): string { + const parts = [`${s.rank + 1}번째 신분 ${s.title}`, `${nick(players, s.id)}${s.id === me ? '(나)' : ''}`, `카드 ${s.cards}장`]; + if (s.place) parts.push(`${s.place}등으로 다 냄`); + if (s.passed) parts.push('패스'); + if (s.lockedOut && !s.passed) parts.push('이번 트릭 빠짐'); + if (v.phase === 'revolutionCheck') parts.push(s.confirmed ? '확인함' : '확인 전'); + if (s.left) parts.push('나감'); + else if (s.away) parts.push('자리 비움'); + if (s.isTurn && v.phase === 'playing') parts.push('차례'); + parts.push(`총점 ${s.total}점`); + return parts.join(', '); +} + +function Seats({ v, players, me, deadline }: { v: DalmutiView; players: Record; me: string | null; deadline: number | null }) { + const flip = v.revolution === 'great'; + return ( + <> +
    + {v.seats.map((s, i) => { + const u = userOf(players, s.id); + return ( +
  1. + {s.passed && ( + + 패스 + + )} + + {TITLE_ICON[s.title]} {s.title} + +
    + + {u.nickname} + {s.isTurn && v.phase === 'playing' && } +
    +
    + + + {s.cards} + + {s.place && {s.place}등} + {v.phase === 'revolutionCheck' && ( + + {s.confirmed ? '확인 ✓' : '확인 중'} + + )} + {s.lockedOut && !s.passed && 빠짐} + {s.left ? ( + 나감 + ) : ( + s.away && 자리 비움 + )} +
    +
  2. + ); + })} +
+

+ 차례: 왼쪽 위부터 신분 순서대로 돌아요 ↻ +

+ + ); +} + +// ---------------------------------------------------------------- table + +function TableArea({ v, players, me }: { v: DalmutiView; players: Record; me: string | null }) { + const t = v.trick; + if (!t) return null; + const top = t.top; + const prev = t.pile.slice(0, -1).slice(-4); + const leaderName = t.leader === me ? '내가' : `${nick(players, t.leader)}님이`; + const curName = v.current === me ? '내' : `${nick(players, v.current ?? '')}님`; + return ( +
+ {top ? ( + <> +
+ 지금 기준: {setText(top)} + + {top.by === me ? '내가' : `${nick(players, top.by)}님이`} 냈어요 · 같은 장수로 더 낮은 숫자만 낼 수 있어요 + +
+
+ {prev.length > 0 && ( +
setText(p)).join(', ')}`}> + {prev.map((p, i) => ( + + ))} +
+ )} + + + +
+ + ) : ( +
+ {leaderName} 새로 시작해요 + {v.current === me ? '아무 숫자나 같은 숫자로 원하는 장수만큼 내요' : `${curName} 차례예요`} +
+ )} +
+ ); +} + +// ---------------------------------------------------------------- hand (playing) + +interface Group { + n: number; + cards: DCard[]; +} + +function groupsOf(hand: DCard[]): Group[] { + const map = new Map(); + for (const c of hand) map.set(c.n, [...(map.get(c.n) ?? []), c]); + return [...map.entries()].sort((a, b) => a[0] - b[0]).map(([n, cards]) => ({ n, cards })); +} + +/** Card ids for `count` cards of `rank` with at least `minJ` jesters (more if needed); null if impossible. */ +function pickIds(hand: DCard[], rank: number, count: number, minJ: number): string[] | null { + const js = hand.filter((c) => c.n === JESTER); + if (rank === JESTER) return count <= js.length ? js.slice(0, count).map((c) => c.id) : null; + const ns = hand.filter((c) => c.n === rank); + const j = Math.max(minJ, count - ns.length); + if (j > js.length || count - j < 1 || count - j > ns.length) return null; + return [...ns.slice(0, count - j), ...js.slice(0, j)].map((c) => c.id); +} + +function onArrowKeys(e: KeyboardEvent, selector: string) { + if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return; + const btns = Array.from(e.currentTarget.querySelectorAll(selector)); + const i = btns.indexOf(document.activeElement as HTMLButtonElement); + if (i < 0) return; + e.preventDefault(); + btns[(i + (e.key === 'ArrowRight' ? 1 : btns.length - 1)) % btns.length]?.focus(); +} + +interface Sel { + rank: number; + count: number; + mix: boolean; +} + +function PlayHand({ v, act, pending }: { v: DalmutiView; act: (a: unknown) => void; pending: boolean }) { + const my = v.my!; + const [sel, setSel] = useState(null); + const turnKey = `${v.turnNo}`; + useEffect(() => setSel(null), [turnKey]); + const groups = groupsOf(my.hand); + const choice = (n: number): Choice | undefined => my.choices.find((c) => c.rank === n); + const top = v.trick?.top ?? null; + const J = my.jesters; + const mine = my.canPlay; + + const tap = (g: Group) => { + const ch = choice(g.n); + if (!mine || !ch) return; + if (sel?.rank === g.n) { + setSel(null); + return; + } + if (top) setSel({ rank: g.n, count: top.count, mix: ch.needsJester }); + else setSel({ rank: g.n, count: ch.have, mix: false }); + }; + const ch = sel ? choice(sel.rank) : undefined; + const have = ch?.have ?? 0; + const maxCount = !sel || !ch ? 0 : sel.rank === JESTER ? J : sel.mix ? have + J : have; + const ids = sel && ch ? pickIds(my.hand, sel.rank, sel.count, sel.mix && sel.rank !== JESTER ? 1 : 0) : null; + const legal = !!ids && !!ch && ch.counts.includes(sel!.count); + const jUsed = ids ? ids.filter((id) => id.startsWith('j')).length : 0; + const setCount = (count: number) => sel && setSel({ ...sel, count }); + const toggleMix = () => { + if (!sel || !ch) return; + if (top) { + setSel({ ...sel, mix: !sel.mix }); + return; + } + if (!sel.mix) setSel({ ...sel, mix: true, count: Math.min(have + J, Math.max(sel.count, have) + 1) }); + else setSel({ ...sel, mix: false, count: Math.min(sel.count, have) }); + }; + const send = () => { + if (!ids || !legal) return; + act({ type: 'play', cardIds: ids }); + setSel(null); + }; + const mixLocked = !!top && !!ch?.needsJester; + const mixAllowed = !!sel && sel.rank !== JESTER && J > 0 && !!ch && (!top || ch.needsJester || have >= 2); + + return ( +
+
+ + 내 카드 {my.hand.length}장{J > 0 ? ` · 광대 ${J}장` : ''} + + {mine && {top ? `${setText(top)}보다 낮은 숫자를 골라요` : '낼 숫자를 골라요'}} +
+
onArrowKeys(e, 'button.dm-group')}> + {groups.map((g) => { + const c = choice(g.n); + const picked = sel?.rank === g.n; + return ( + + ); + })} + {my.hand.length === 0 && 손패를 다 냈어요! 다른 사람을 지켜봐요.} +
+ {mine && sel && ch && ( +
+ {sel.rank === JESTER ? '광대' : cardName(sel.rank)} + {!top && ( + + + {sel.count}장 + + + )} + {mixAllowed && ( + + )} + + {legal ? `${rankText(sel.rank)} × ${sel.count}장${jUsed > 0 && sel.rank !== JESTER ? ` (광대 ${jUsed}장 포함)` : ''}` : '이렇게는 낼 수 없어요'} + +
+ )} + {mine && ( +
+ + +
+ )} + {!mine && my.lockedOut && 이번 트릭은 패스해서 빠졌어요. 다음 트릭을 기다려요.} +
+ ); +} + +// ---------------------------------------------------------------- revolution check / tax + +function RoundRecap({ v, players, me }: { v: DalmutiView; players: Record; me: string | null }) { + const last = v.roundResults[v.roundResults.length - 1]; + if (last) { + return ( +
+ {last.round}라운드 결과 → 새 신분 +
    + {last.order.map((p, i) => ( +
  1. + {nick(players, p)} + {p === me ? '(나)' : ''} · +{last.points[p]}점 → {TITLE_ICON[v.seats[i]?.title ?? '평민']} {v.seats.find((s) => s.id === p)?.title} +
  2. + ))} +
+
+ ); + } + if (!v.firstDraw) return null; + return ( +
+ 첫 신분 뽑기 · 낮은 숫자일수록 높은 신분이에요 +
+ {v.seats.map((s, i) => ( + (n === JESTER ? '광대' : n)).join(', 다시 뽑아 ')} → ${s.title}`}> + + {v.firstDraw![s.id]!.map((n, k) => ( + + ))} + + + {nick(players, s.id)} +
+ {TITLE_ICON[s.title]} {s.title} +
+
+ ))} +
+ {Object.values(v.firstDraw).some((d) => d.length > 1) && 같은 숫자가 나온 사람끼리는 다시 뽑았어요.} +
+ ); +} + +function HandPreview({ hand }: { hand: DCard[] }) { + return ( +
`${cardName(g.n)} ${g.cards.length}장`).join(', ')}`} role="img"> + {groupsOf(hand).map((g) => ( + + 1}> + + {g.cards.length > 1 && ×{g.cards.length}} + + + ))} +
+ ); +} + +function RevolutionPanel({ v, players, me, act, pending }: { v: DalmutiView; players: Record; me: string | null; act: (a: unknown) => void; pending: boolean }) { + const my = v.my; + const done = v.seats.filter((s) => s.confirmed).length; + return ( +
+

+ {v.round}라운드 준비 · 손패 확인 ({done}/{v.seats.length}) +

+ + {my && ( + <> + + {my.canConfirm ? ( +
+ + {my.canDeclareRevolution && ( + + )} +
+ ) : ( + 다른 사람이 확인하기를 기다리는 중이에요… + )} + {my.canDeclareRevolution && ( + + 광대 2장이 다 있어요! 혁명을 선언하면 광대 2장을 보여 주고 이번 라운드 세금이 없어요. + {v.seats[v.seats.length - 1]?.id === me ? ' 대농노가 선언하면 대혁명으로 신분이 완전히 뒤집혀요.' : ''} + + )} + + )} +
+ ); +} + +function TaxPanel({ v, players, me, act, pending }: { v: DalmutiView; players: Record; me: string | null; act: (a: unknown) => void; pending: boolean }) { + const my = v.my; + const owe = my?.owe ?? null; + const [picked, setPicked] = useState([]); + const oweKey = owe ? `${v.round}-${owe.count}` : ''; + useEffect(() => setPicked(my?.suggestedReturn ?? []), [oweKey]); // eslint-disable-line react-hooks/exhaustive-deps + const titleOf = (id: string) => v.seats.find((s) => s.id === id)?.title ?? ''; + const who = (id: string) => (id === me ? '나' : `${nick(players, id)}님`); + const subject = (id: string) => (id === me ? '내가' : `${nick(players, id)}님이`); + const toggle = (id: string) => { + if (!owe) return; + if (picked.includes(id)) setPicked(picked.filter((x) => x !== id)); + else setPicked(picked.length >= owe.count ? [...picked.slice(1), id] : [...picked, id]); + }; + return ( +
+

세금 카드 교환

+ +
    + {v.tax.map((t, i) => ( +
  • + {titleOf(t.from)} {who(t.from)} → {titleOf(t.to)} {who(t.to)} {t.count}장 {t.kind === 'tax' ? '세금' : '돌려줌'} + {t.cards && ({facesText(t.cards)})} +
  • + ))} + {v.taxPending + .filter((p) => !p.done) + .map((p) => ( +
  • + {titleOf(p.from)} {subject(p.from)} {p.count}장을 돌려줄 차례예요 +
  • + ))} +
+ {owe && my ? ( + <> + 돌려줄 카드 {owe.count}장을 고르세요 · 받은 카드도 돌려줄 수 있어요 +
onArrowKeys(e, 'button.dm-one')}> + {my.hand.map((c) => { + const on = picked.includes(c.id); + const got = my.receivedIds.includes(c.id); + return ( + + ); + })} +
+
+ +
+ + ) : ( + my && + )} +
+ ); +} + +// ---------------------------------------------------------------- log + +function logText(e: LogEntry, players: Record): string { + const n = (id: string) => `${nick(players, id)}님`; + switch (e.t) { + case 'play': + return `${n(e.p)}: ${setText(e)}${e.cards.includes(JESTER) && e.rank !== JESTER ? ' (광대 포함)' : ''}`; + case 'pass': + return `${n(e.p)}: 패스`; + case 'trick': + return `모두 패스해서 ${n(e.p)}이 새로 시작해요`; + case 'out': + return `${n(e.p)}이 손패를 다 냈어요 (${e.place}등)`; + case 'revolution': + return e.kind === 'great' ? `${n(e.p)}의 대혁명! 신분이 뒤집혔어요` : `${n(e.p)}의 혁명! 이번 라운드는 세금이 없어요`; + case 'tax': + return `세금: ${n(e.from)} → ${n(e.to)} ${e.n}장`; + case 'return': + return `돌려줌: ${n(e.from)} → ${n(e.to)} ${e.n}장`; + case 'round': + return `${e.round}라운드가 끝났어요`; + case 'timeout': + return `${n(e.p)}의 시간이 다 되어 자동으로 진행했어요`; + case 'left': + return `${n(e.p)}이 나갔어요. 남은 차례는 자동으로 진행해요`; + } +} + +// ---------------------------------------------------------------- board + +function Tip() { + const [hidden, setHidden] = useState(() => { + try { + return localStorage.getItem(TIP_KEY) === '1'; + } catch { + return false; + } + }); + if (hidden) return null; + return ( +
+ 💡 숫자가 낮을수록 강해요 · 앞사람과 같은 장수로만 낼 수 있어요 + +
+ ); +} + +function RevolutionBanner({ v, players, me }: { v: DalmutiView; players: Record; me: string | null }) { + if (v.revolution === 'none' || !v.revolutionBy || v.phase !== 'playing') return null; + const who = v.revolutionBy === me ? '내가' : `${nick(players, v.revolutionBy)}님이`; + return ( +
+ {v.revolution === 'great' + ? `🃏🃏 대혁명! ${who} 광대 2장으로 신분을 완전히 뒤집었어요. 세금은 없어요` + : `🃏🃏 혁명! ${who} 광대 2장을 보여 줬어요. 이번 라운드는 세금이 없어요`} +
+ ); +} + +function DalmutiBoard({ view: v, me, players, act, pending, deadline }: BoardProps) { + const [announce, setAnnounce] = useState(''); + const mine = isMyTurn(v, me); + const prevMine = useRef(false); + const lastLog = v.log[v.log.length - 1]; + useEffect(() => { + if (mine && !prevMine.current) { + haptic(40); + sounds.myTurn(); + setAnnounce(statusText(v, me, players)); + } + prevMine.current = mine; + }, [mine]); // eslint-disable-line react-hooks/exhaustive-deps + useEffect(() => { + if (lastLog && !mine) setAnnounce(logText(lastLog, players)); + }, [lastLog?.seq, lastLog?.t]); // eslint-disable-line react-hooks/exhaustive-deps + return ( +
+ + + + + {v.phase === 'revolutionCheck' && } + {v.phase === 'taxReturn' && } + {v.phase === 'playing' && } + {v.phase === 'playing' && v.my && } + {v.phase === 'finished' && v.result && ( +
+

게임이 끝났어요

+ {v.result.summary} +
+ )} +

+ {announce} +

+
+ ); +} + +// ---------------------------------------------------------------- side + +function rulesLine(o: DalmutiOptions): string { + return [ + `${o.rounds}라운드`, + o.scoring === 'rank-points' ? '순위 점수 합' : '마지막 라운드 순위', + o.revolution ? '혁명 있음' : '혁명 없음', + o.jesterTaxable ? '세금에 광대 포함' : '세금에 광대 빼기', + o.passLocksOut ? '패스하면 트릭에서 빠짐' : '패스해도 다시 낼 수 있음', + o.removeHighCards ? '9~12 빼기' : '', + `${o.turnSeconds}초`, + ] + .filter(Boolean) + .join(' · '); +} + +function DalmutiSide({ view: v, me, players, deadline }: BoardProps) { + const phaseKo = v.phase === 'revolutionCheck' ? '손패 확인' : v.phase === 'taxReturn' ? '세금 교환' : v.phase === 'playing' ? '진행 중' : '게임 끝'; + const rounds = v.roundResults; + return ( + <> + +
+
+ + 달무티 · {Math.min(v.round, v.options.rounds)}/{v.options.rounds}라운드 · {phaseKo} + + {v.phase !== 'finished' && } +
+ {rulesLine(v.options)} +
    + {v.seats.map((s) => ( +
  1. + + {TITLE_ICON[s.title]} + + + + {s.isTurn && v.phase === 'playing' ? '▶ ' : ''} + {nick(players, s.id)} + {s.id === me ? ' (나)' : ''} {s.title} + + + {s.total}점 + + + + {s.cards} + +
  2. + ))} +
+
+ {rounds.length > 0 && ( +
+ 라운드 순위 + + + + + {rounds.map((r) => ( + + ))} + + + + + {(v.result ? v.result.players.map((p) => p.player) : v.seats.map((s) => s.id)).map((p) => ( + + + {rounds.map((r) => ( + + ))} + + + ))} + +
이름 + {r.round}R + 합계
+ {nick(players, p)} + {r.order.indexOf(p) + 1}등 + {v.seats.find((s) => s.id === p)?.total ?? 0}점 +
+ {v.result &&

{v.result.summary}

} +
+ )} + {v.log.length > 0 && ( +
+ 기록 +
    + {[...v.log].reverse().map((e, i) => ( +
  1. + {logText(e, players)} +
  2. + ))} +
+
+ )} +
+ 카드 장수표 + +
+ + ); +} + +// ---------------------------------------------------------------- status + +function isMyTurn(v: DalmutiView, me: string | null): boolean { + if (!me || !v.my) return false; + if (v.phase === 'revolutionCheck') return v.my.canConfirm; + if (v.phase === 'taxReturn') return !!v.my.owe; + return v.phase === 'playing' && v.current === me; +} + +function statusText(v: DalmutiView, me: string | null, players: Record): string { + if (v.phase === 'finished') return v.result?.summary ?? '게임이 끝났어요'; + if (v.phase === 'revolutionCheck') { + if (v.my?.canDeclareRevolution) return '손패를 확인해 주세요 · 광대 2장으로 혁명할 수 있어요'; + if (v.my?.canConfirm) return `${v.round}라운드 손패를 확인해 주세요`; + return '다른 사람이 손패를 확인하는 중이에요'; + } + if (v.phase === 'taxReturn') { + if (v.my?.owe) return `내 차례예요 · 돌려줄 카드 ${v.my.owe.count}장을 골라 주세요`; + return '세금 카드를 주고받는 중이에요'; + } + if (me && v.current === me) return v.my?.leading ? '내 차례예요 · 새로 시작해요' : '내 차례예요'; + if (!v.current) return '잠시만 기다려 주세요'; + return `${nick(players, v.current)}님 차례예요`; +} + +// ---------------------------------------------------------------- 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, help }: { label: string; children: ReactNode; help?: string }) { + return ( +
+ {label} + {children} + {help && {help}} +
+ ); +} + +const onOff: [boolean, string][] = [ + [true, '켜기'], + [false, '끄기'], +]; + +function Options({ value: o, onChange }: OptionsProps) { + const [more, setMore] = useState(false); + const set = (patch: Partial) => onChange({ ...o, ...patch }); + return ( +
+ + set({ rounds })} /> + + + set({ scoring })} /> + + + set({ revolution })} /> + + + set({ turnSeconds })} /> + + + {more && ( + <> + + set({ firstRanking })} /> + + + set({ jesterTaxable })} /> + + + set({ passLocksOut })} /> + + + set({ removeHighCards })} /> + + + )} +
+ ); +} + +function optionsSummary(o: DalmutiOptions): string { + const parts = [`${o.rounds}라운드`, o.scoring === 'rank-points' ? '순위 점수 합' : '마지막 라운드 순위']; + if (!o.revolution) parts.push('혁명 없음'); + if (o.jesterTaxable) parts.push('세금에 광대'); + if (o.passLocksOut) parts.push('패스하면 빠짐'); + if (o.removeHighCards) parts.push('9~12 빼기'); + parts.push(`${o.turnSeconds}초`); + return parts.join(' · '); +} + +// ---------------------------------------------------------------- rules + +function CardTable({ removeHigh }: { removeHigh?: boolean }) { + const rows = Array.from({ length: 13 }, (_, i) => i + 1).filter((n) => !removeHigh || n <= 8 || n === JESTER); + return ( + + + + + + + + + + + {rows.map((n) => ( + + + + + + + ))} + +
카드숫자이름장수
+ + {n === JESTER ? '13 / 와일드' : n}{CARD_NAME[n]}{n === JESTER ? 2 : n}장
+ ); +} + +function Rules({ options }: { options?: DalmutiOptions }) { + const o = options; + const titles: Title[] = ['대달무티', '소달무티', '평민', '소농노', '대농노']; + return ( +
+

+ 숫자가 낮을수록 강해요. 손패를 먼저 다 내는 사람이 다음 라운드에서 높은 신분이 돼요. 4~8명이 해요. +

+

+ 새로 시작하는 사람은 같은 숫자 카드를 원하는 장수만큼 내요. 다음 사람부터는 같은 장수로 더 낮은 숫자를 내거나 패스해요. 낸 사람 + 말고 모두 패스하면 바닥을 치우고 마지막에 낸 사람이 새로 시작해요. + {o?.passLocksOut ? ' 이 방에서는 한 번 패스하면 그 트릭이 끝날 때까지 빠져요.' : ' 패스해도 차례가 다시 오면 낼 수 있어요.'} +

+

+ 광대는 혼자 내면 13이에요. 숫자 카드와 함께 내면 그 숫자로 바뀌어요(예: 4 + 광대 = 4 두 장). +

+

+ 신분: {titles.map((t) => `${TITLE_ICON[t]} ${t}`).join(' → ')} (4명이면 평민이 없어요). 라운드를 시작할 때 대농노는 가장 좋은 카드 2장을 대달무티에게, 소농노는 + 1장을 소달무티에게 세금으로 줘요. 받은 사람은 아무 카드나 같은 장수만큼 돌려줘요. +

+ {(o?.revolution ?? true) && ( +

+ 혁명: 손패를 확인할 때 광대 2장을 다 가진 사람은 혁명을 선언할 수 있어요. 그 라운드는 세금이 없어요. 대농노가 선언하면 대혁명으로 신분이 완전히 + 뒤집혀요. +

+ )} +

+ 라운드에서 1등은 (사람 수 − 1)점, 마지막 순위는 0점을 받아요. {o?.scoring === 'last-round' ? '이 방은 마지막 라운드 순위가 최종 순위예요.' : '총점이 높은 사람이 이기고, 같으면 마지막 라운드 순위로 정해요.'}{' '} + 점수는 이 방 안에서만 쓰는 표시용이에요. +

+

시간 안에 고르지 않으면 새로 시작할 때는 가장 큰 숫자 카드를 모두 내고, 따라 낼 때는 패스해요.

+ +
+ ); +} + +const ui: GameUI = { + Board: DalmutiBoard, + Side: DalmutiSide, + statusText, + isMyTurn, + Options, + optionsSummary, + Rules, +}; + +export default ui; diff --git a/apps/web/src/games/registry.ts b/apps/web/src/games/registry.ts index 2a1cc79..2948957 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'), + dalmuti: () => import('./dalmuti'), }; const cache = new Map(); diff --git a/apps/web/src/lib/catalog-ui.ts b/apps/web/src/lib/catalog-ui.ts index a88513f..dec4c4f 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': '🔔', + dalmuti: '👑', }; /** 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..6b06192 100644 --- a/e2e/games.e2e.ts +++ b/e2e/games.e2e.ts @@ -260,6 +260,71 @@ async function marbleStep(p: Page, stats: Record): Promise): Promise { + const enabled = async (l: ReturnType) => (await l.count()) > 0 && (await l.first().isEnabled().catch(() => false)); + const confirm = p.getByRole('button', { name: '손패 확인', exact: true }); + if (await enabled(confirm)) { + await confirm.click({ timeout: 1500 }); + return 'confirm'; + } + const giveBack = p.getByRole('button', { name: /^돌려주기/ }); + if (await enabled(giveBack)) { + if (!st[`taxshot-${tag(p)}`] && (await p.getByText('1라운드 결과 → 새 신분').count())) { + st[`taxshot-${tag(p)}`] = 1; + await p.screenshot({ path: join(SHOTS, `dalmuti-tax-${tag(p)}.png`) }); + await p.emulateMedia({ colorScheme: 'dark' }); + await p.waitForTimeout(400); + await p.screenshot({ path: join(SHOTS, `dalmuti-tax-${tag(p)}-dark.png`) }); + await p.emulateMedia({ colorScheme: 'light' }); + } + await giveBack.click({ timeout: 1500 }); + return 'return'; + } + const status = (await p.locator('.turn-status').innerText().catch(() => '')).trim(); + if (!status.includes('내 차례예요')) return null; + const pass = p.getByRole('button', { name: '패스', exact: true }); + const canPass = await enabled(pass); + const legal = p.locator('button.dm-group[data-legal="true"]'); + const n = await legal.count(); + if (n === 0 || (canPass && st.turns! % 4 === 3)) { + if (!canPass) return null; + await pass.click({ timeout: 1500 }); + st.turns!++; + st.passes = (st.passes ?? 0) + 1; + return 'pass'; + } + const needs = p.locator('button.dm-group[data-legal="true"]', { hasText: '광대 필요' }); + const lead = status.includes('새로 시작'); + const jesterGroup = p.locator('button.dm-group[data-legal="true"][data-n="13"]'); + let target = legal.last(); + if (await needs.count()) target = needs.last(); + else if (lead && (await jesterGroup.count()) && n > 1) target = legal.nth(n - 2); + if (isHost) { + await target.focus(); + await p.keyboard.press('Enter'); + st.hostKeys = (st.hostKeys ?? 0) + 1; + } else await target.click({ timeout: 1500 }); + const toggle = p.locator('.dm-toggle'); + if (lead && (await enabled(toggle)) && (await toggle.getAttribute('aria-pressed')) === 'false') await toggle.click({ timeout: 1500 }); + const play = p.getByRole('button', { name: /^내기 \(/ }); + if (!(await enabled(play))) return null; + const summary = await p.locator('.dm-pick .small').innerText().catch(() => ''); + if (!st[`pickshot-${tag(p)}`]) { + st[`pickshot-${tag(p)}`] = 1; + await p.screenshot({ path: join(SHOTS, `dalmuti-pick-${tag(p)}.png`) }); + } + if (summary.includes('광대')) st.jesterPlays = (st.jesterPlays ?? 0) + 1; + await play.click({ timeout: 1500 }); + st.turns!++; + st.plays = (st.plays ?? 0) + 1; + return 'play'; +} + const PLANS: Record = { rummikub: { // PC host + phone guest, first meld "없음" so hint plays happen early; 10 real turns through the UI. @@ -879,6 +944,70 @@ const PLANS: Record = { console.log(` marble: finished after ${actions} UI actions ${JSON.stringify(stats)}`); }, }, + dalmuti: { + // PC host + 3 phone guests, 9~12 removed (38 cards) for a shorter round: real turns through the UI (sets with + // jesters, passes) until round 1 ends, then the round-2 hand check and the tax exchange (세금 카드 교환) screen. + players: 4, + lobby: async (host) => { + await host.getByRole('button', { name: '▼ 자세한 설정' }).click(); + await host.getByRole('group', { name: '9~12 카드 빼기' }).getByRole('button', { name: '켜기' }).click(); + await host.getByRole('group', { name: '한 턴 제한 시간' }).getByRole('button', { name: '45초' }).click(); + await host.getByRole('button', { name: '설정 저장' }).click(); + await host.getByText(/9~12 빼기/).first().waitFor(); + }, + drive: async (pages) => { + const [host, guest] = pages as [Page, Page]; + const st: Record = { turns: 0 }; + let actions = 0; + let midShot = false; + let round2 = false; + const until = Date.now() + 300_000; + const playingAgain = () => host.locator('.dm-table').count(); + while (Date.now() < until) { + let acted = false; + for (const [i, p] of pages.entries()) { + if (await dalmutiStep(p, i === 0, st)) { + acted = true; + actions++; + await p.waitForTimeout(120); + break; + } + } + if (!midShot && (st.plays ?? 0) >= 6 && (await host.locator('.dm-set.top').count())) { + midShot = true; + await host.waitForTimeout(400); + await host.screenshot({ path: join(SHOTS, 'dalmuti-mid-pc.png') }); + await guest.screenshot({ path: join(SHOTS, 'dalmuti-mid-phone.png') }); + await guest.screenshot({ path: join(SHOTS, 'dalmuti-mid-phone-full.png'), fullPage: true }); + for (const p of [host, guest]) await p.emulateMedia({ colorScheme: 'dark' }); + await host.waitForTimeout(400); + await host.screenshot({ path: join(SHOTS, 'dalmuti-mid-pc-dark.png') }); + await guest.screenshot({ path: join(SHOTS, 'dalmuti-mid-phone-dark.png') }); + for (const p of [host, guest]) await p.emulateMedia({ colorScheme: 'light' }); + } + if (!round2 && (await host.getByText('1라운드 결과 → 새 신분').count())) { + round2 = true; + await host.waitForTimeout(300); + await host.screenshot({ path: join(SHOTS, 'dalmuti-round2-check-pc.png') }); + await guest.screenshot({ path: join(SHOTS, 'dalmuti-round2-check-phone.png'), fullPage: true }); + } + if (round2 && st.taxDone === undefined && (await host.getByRole('heading', { name: '세금 카드 교환' }).count())) { + st.taxDone = 0; + await host.waitForTimeout(300); + await host.screenshot({ path: join(SHOTS, 'dalmuti-round2-tax-pc.png') }); + await guest.screenshot({ path: join(SHOTS, 'dalmuti-round2-tax-phone.png'), fullPage: true }); + } + // Done: round 2's tax exchange happened (or a revolution skipped it) and the table is back, with a jester set seen. + if (round2 && (await playingAgain()) && (st.jesterPlays ?? 0) > 0) break; + if (!acted) await host.waitForTimeout(120); + } + if (!round2) throw new Error(`round 1 did not finish (${actions} UI actions: ${JSON.stringify(st)})`); + if (!(await playingAgain())) throw new Error(`round 2 did not start playing (${JSON.stringify(st)})`); + if (!st.passes || (st.plays ?? 0) < 8 || !st.jesterPlays) throw new Error(`too few real turns / no jester set: ${JSON.stringify(st)}`); + await host.screenshot({ path: join(SHOTS, 'dalmuti-round2-pc.png') }); + console.log(` dalmuti: ${actions} UI actions ${JSON.stringify(st)}${st.taxDone === 0 ? ', tax screen shown' : ', revolution (no tax)'}`); + }, + }, }; const games = process.argv.slice(2).length ? process.argv.slice(2) : Object.keys(PLANS); diff --git a/packages/games/src/dalmuti/dalmuti.test.ts b/packages/games/src/dalmuti/dalmuti.test.ts new file mode 100644 index 0000000..0d64294 --- /dev/null +++ b/packages/games/src/dalmuti/dalmuti.test.ts @@ -0,0 +1,678 @@ +import { describe, expect, test } from 'bun:test'; +import { SeededRng, randomPlayout, seedN, type PlayerId, type RngState } from '@bg/engine'; +import { + FULL_DECK, + allCardIds, + dalmuti, + deckFor, + drawFirstRanks, + finalOf, + type DCard, + type DalmutiAction, + type DalmutiOptions, + type DalmutiState, + type DalmutiView, +} from './index'; + +// ---------------------------------------------------------------- helpers + +const BY_ID = new Map(FULL_DECK.map((c) => [c.id, c])); +const card = (id: string): DCard => { + const c = BY_ID.get(id); + if (!c) throw new Error(`no card ${id}`); + return c; +}; +/** k-th copy of number n ("c7-2"). */ +const C = (n: number, k = 1) => `c${n}-${k}`; +const J1 = 'j1'; +const J2 = 'j2'; +const P5 = ['A', 'B', 'C', 'D', 'E']; +const P4 = ['A', 'B', 'C', 'D']; +let clock = 1_000_000; + +function make(opts: Partial = {}, players = P5, seed = 1): DalmutiState { + const options = dalmuti.optionsSchema.parse(opts); + return dalmuti.setup({ players, options, rng: SeededRng.fromSeed(seedN(seed)), now: 0 }); +} + +/** Seats `order`, gives hands (rest of the deck → discard) and starts a trick (optionally with a top set). */ +function rig( + s: DalmutiState, + r: { order?: string[]; hands: Record; current?: string; top?: { by: string; ids: string[] } }, +): DalmutiState { + if (r.order) s.order = [...r.order]; + for (const p of s.players) s.hands[p] = (r.hands[p] ?? []).map(card); + const used = new Set([...Object.values(r.hands).flat(), ...(r.top?.ids ?? [])]); + s.discard = deckFor(s.options.removeHighCards) + .filter((c) => !used.has(c.id)) + .map((c) => c.id); + s.phase = { kind: 'playing' }; + s.finishOrder = []; + s.taxLog = []; + const lead = r.current ?? s.order[0]!; + s.trick = { leader: r.top?.by ?? lead, top: null, passesSinceTop: [], lockedOut: [], pile: [] }; + if (r.top) { + const cards = r.top.ids.map(card); + const nums = cards.filter((c) => c.n !== 13); + const top = { cardIds: r.top.ids, count: cards.length, rank: nums.length ? nums[0]!.n : 13, by: r.top.by }; + s.trick.top = top; + s.trick.pile = [top]; + } + s.current = lead; + return s; +} + +const act = (s: DalmutiState, p: string, a: DalmutiAction): DalmutiState => { + const v = dalmuti.validate(s, p, a); + if (!v.ok) throw new Error(`${p} ${JSON.stringify(a)}: ${v.reason}`); + return dalmuti.apply(s, p, a, { rng: SeededRng.fromSeed([3, 3, 3, 3]), now: (clock += 1000) }).state; +}; +const play = (s: DalmutiState, p: string, ids: string[]) => act(s, p, { type: 'play', cardIds: ids }); +const pass = (s: DalmutiState, p: string) => act(s, p, { type: 'pass' }); +const reject = (s: DalmutiState, p: string, a: DalmutiAction): string | null => { + const v = dalmuti.validate(s, p, a); + return v.ok ? null : v.reason; +}; +const ids = (s: DalmutiState, p: string) => s.hands[p]!.map((c) => c.id); +const nums = (s: DalmutiState, p: string) => s.hands[p]!.map((c) => c.n); +const confirmAll = (s: DalmutiState, except: Record = {}) => { + for (const p of s.players) s = act(s, p, except[p] ?? { type: 'confirmHand' }); + return s; +}; + +/** A revolutionCheck state with given hands (5 players, order A..E unless given). */ +function atRevolutionCheck(hands: Record, opts: Partial = {}, order = P5): DalmutiState { + const s = rig(make(opts), { order, hands }); + s.phase = { kind: 'revolutionCheck', confirmed: Object.fromEntries(s.players.map((p) => [p, false])) }; + return s; +} + +/** Splits the full deck so that the given cards land where wanted and the rest fill hands round-robin. */ +function handsWith(order: string[], fixed: Record): Record { + const taken = new Set(Object.values(fixed).flat()); + const rest = FULL_DECK.map((c) => c.id).filter((id) => !taken.has(id)); + const hands: Record = Object.fromEntries(order.map((p) => [p, [...(fixed[p] ?? [])]])); + const per = Math.floor(80 / order.length); + for (const p of order) while (hands[p]!.length < per) hands[p]!.push(rest.shift()!); + for (let i = 0; rest.length; i++) hands[order[i % order.length]!]!.push(rest.shift()!); + return hands; +} + +// ---------------------------------------------------------------- deck / setup + +describe('덱과 셋업 (§3, §4)', () => { + test('80 cards: n copies of n, 2 jesters; removeHighCards → 38', () => { + expect(FULL_DECK).toHaveLength(80); + for (let n = 1; n <= 12; n++) expect(FULL_DECK.filter((c) => c.n === n)).toHaveLength(n); + expect(FULL_DECK.filter((c) => c.n === 13)).toHaveLength(2); + expect(new Set(FULL_DECK.map((c) => c.id)).size).toBe(80); + expect(deckFor(true)).toHaveLength(38); + expect(deckFor(true).some((c) => c.n >= 9 && c.n <= 12)).toBe(false); + }); + + test('6 players: 14, 14, 13, 13, 13, 13 in rank order; all deals add up', () => { + const s = make({}, ['A', 'B', 'C', 'D', 'E', 'F'], 3); + expect(s.order.map((p) => s.hands[p]!.length)).toEqual([14, 14, 13, 13, 13, 13]); + const sizes: Record = { 4: [20, 20, 20, 20], 5: [16, 16, 16, 16, 16], 7: [12, 12, 12, 11, 11, 11, 11], 8: [10, 10, 10, 10, 10, 10, 10, 10] }; + for (const [n, want] of Object.entries(sizes)) { + const t = make({}, Array.from({ length: Number(n) }, (_, i) => `P${i}`), 4); + expect(t.order.map((p) => t.hands[p]!.length)).toEqual(want); + expect(allCardIds(t).sort()).toEqual(FULL_DECK.map((c) => c.id).sort()); + } + const r = make({ removeHighCards: true }, Array.from({ length: 8 }, (_, i) => `P${i}`), 4); + expect(r.order.map((p) => r.hands[p]!.length)).toEqual([5, 5, 5, 5, 5, 5, 4, 4]); + }); + + test('player range 4..8', () => { + expect(dalmuti.minPlayers).toBe(4); + expect(dalmuti.maxPlayers).toBe(8); + expect(() => make({}, ['A', 'B', 'C'])).toThrow(); + expect(() => make({}, 'ABCDEFGHI'.split(''))).toThrow(); + }); + + test('first rank draw: same seed → same result; ties re-draw until the order is fixed (lower first)', () => { + expect(JSON.stringify(make({}, P5, 9))).toBe(JSON.stringify(make({}, P5, 9))); + let sawTie = 0; + for (let seed = 1; seed <= 400; seed++) { + const players = Array.from({ length: 8 }, (_, i) => `P${i}`); + const { order, draws } = drawFirstRanks(players, FULL_DECK as DCard[], SeededRng.fromSeed(seedN(seed))); + expect([...order].sort()).toEqual([...players].sort()); + if (Object.values(draws).some((d) => d.length > 1)) sawTie++; + // Lexicographic on the draw sequence: tied players share a prefix and re-drew together. + for (let i = 0; i + 1 < order.length; i++) { + const a = draws[order[i]!]!; + const b = draws[order[i + 1]!]!; + const k = a.findIndex((x, j) => x !== b[j]); + expect(k).toBeGreaterThanOrEqual(0); + expect(a[k]!).toBeLessThan(b[k]!); + } + } + expect(sawTie).toBeGreaterThan(10); + const s = make({}, P5, 9); + expect(s.firstDraw).not.toBeNull(); + expect(make({ firstRanking: 'random' }, P5, 9).firstDraw).toBeNull(); + }); + + test('revolution on → everyone confirms first; off → tax right away', () => { + expect(make().phase.kind).toBe('revolutionCheck'); + const s = make({ revolution: false }); + expect(s.phase.kind).toBe('taxReturn'); + const o = s.order as string[]; + expect(s.taxLog.map((t) => [t.from, t.to, t.cardIds.length])).toEqual([ + [o[4]!, o[0]!, 2], + [o[3]!, o[1]!, 1], + ]); + }); +}); + +// ---------------------------------------------------------------- tax + +describe('세금 (§4-5)', () => { + const bottomHand = [J1, J2, C(3, 1), C(3, 2), C(7, 1), C(9, 1), C(12, 1)]; + const hands = () => handsWith(P5, { E: bottomHand, D: [C(2, 1), C(5, 1), C(11, 1)] }); + + test('jesterTaxable off: 대농노 [광대, 광대, 3, 3, 7…] gives 3, 3; 소농노 gives its lowest number', () => { + let s = atRevolutionCheck(hands(), { jesterTaxable: false }); + const dBefore = s.hands.D!.map((c) => c.n).filter((n) => n !== 13); + s = confirmAll(s); + expect(s.phase.kind).toBe('taxReturn'); + expect(s.taxLog[0]).toEqual({ kind: 'tax', from: 'E', to: 'A', cardIds: [C(3, 1), C(3, 2)] }); + expect(s.taxLog[1]!.from).toBe('D'); + expect(s.taxLog[1]!.to).toBe('B'); + expect(card(s.taxLog[1]!.cardIds[0]!).n).toBe(Math.min(...dBefore)); + expect(ids(s, 'E')).toContain(J1); + expect(ids(s, 'A')).toEqual(expect.arrayContaining([C(3, 1), C(3, 2)])); + }); + + test('jesterTaxable on: the two jesters go', () => { + const s = confirmAll(atRevolutionCheck(hands(), { jesterTaxable: true })); + expect(s.taxLog[0]!.cardIds).toEqual([J1, J2]); + }); + + test('returning: 대달무티 may give back what it just got; wrong count refused; both done → A leads', () => { + let s = confirmAll(atRevolutionCheck(hands())); + const got = s.taxLog[0]!.cardIds; + expect(reject(s, 'A', { type: 'returnTax', cardIds: [got[0]!] })).toBe('돌려줄 카드 2장을 골라 주세요.'); + expect(reject(s, 'B', { type: 'returnTax', cardIds: [ids(s, 'B')[0]!, ids(s, 'B')[1]!] })).toBe('돌려줄 카드 1장을 골라 주세요.'); + expect(reject(s, 'C', { type: 'returnTax', cardIds: [ids(s, 'C')[0]!] })).toBe('돌려줄 카드가 없어요.'); + expect(reject(s, 'A', { type: 'returnTax', cardIds: [got[0]!, ids(s, 'E')[0]!] })).toBe('손에 없는 카드예요.'); + s = act(s, 'A', { type: 'returnTax', cardIds: [...got] }); + expect(ids(s, 'E')).toEqual(expect.arrayContaining([C(3, 1), C(3, 2)])); + expect(s.phase.kind).toBe('taxReturn'); + expect(reject(s, 'A', { type: 'returnTax', cardIds: [...got] })).toBe('이미 카드를 돌려줬어요.'); + s = act(s, 'B', { type: 'returnTax', cardIds: [ids(s, 'B')[0]!] }); + expect(s.phase.kind).toBe('playing'); + expect(s.current).toBe('A'); + expect(s.hands.A!.length).toBe(16); + expect(allCardIds(s).sort()).toEqual(FULL_DECK.map((c) => c.id).sort()); + }); + + test('4 players: 소달무티 ↔ 소농노 and 대달무티 ↔ 대농노 only', () => { + const s = confirmAll(atRevolutionCheck(handsWith(P4, {}), {}, P4)); + expect(s.taxLog.map((t) => [t.from, t.to, t.cardIds.length])).toEqual([ + ['D', 'A', 2], + ['C', 'B', 1], + ]); + }); +}); + +// ---------------------------------------------------------------- revolution + +describe('혁명 (§4-4)', () => { + test('a commoner with both jesters declares → no tax, same ranks, 대달무티 leads', () => { + const s0 = atRevolutionCheck(handsWith(P5, { C: [J1, J2] })); + expect(reject(s0, 'A', { type: 'confirmHand', declareRevolution: true })).toBe('광대 2장이 있어야 혁명할 수 있어요.'); + const s = confirmAll(s0, { C: { type: 'confirmHand', declareRevolution: true } }); + expect(s.revolution).toBe('revolution'); + expect(s.revolutionBy).toBe('C'); + expect(s.taxLog).toEqual([]); + expect(s.order).toEqual(P5); + expect(s.phase.kind).toBe('playing'); + expect(s.current).toBe('A'); + }); + + test('대농노 declares → great revolution: order reversed, the new 대달무티 leads, no tax', () => { + const s = confirmAll(atRevolutionCheck(handsWith(P5, { E: [J1, J2] })), { E: { type: 'confirmHand', declareRevolution: true } }); + expect(s.revolution).toBe('great'); + expect(s.order).toEqual(['E', 'D', 'C', 'B', 'A']); + expect(s.current).toBe('E'); + expect(s.taxLog).toEqual([]); + const v = dalmuti.view(s, 'A'); + expect(v.seats[0]!).toMatchObject({ id: 'E', title: '대달무티' }); + expect(v.seats[4]!).toMatchObject({ id: 'A', title: '대농노' }); + }); + + test('not declaring is allowed: holder confirms → normal tax', () => { + const s = confirmAll(atRevolutionCheck(handsWith(P5, { B: [J1, J2] }))); + expect(s.revolution).toBe('none'); + expect(s.phase.kind).toBe('taxReturn'); + }); + + test('no info leak: the check phase looks the same with or without a holder; canDeclareRevolution only in the holder view', () => { + const withHolder = atRevolutionCheck(handsWith(P5, { C: [J1, J2] })); + const without = atRevolutionCheck(handsWith(P5, { C: [J1], D: [J2] })); + for (const s of [withHolder, without]) { + expect(s.phase.kind).toBe('revolutionCheck'); + for (const p of ['A', 'B', 'D', 'E']) { + const v = JSON.stringify(dalmuti.view(s, p)); + expect(v).not.toContain('canDeclareRevolution'); + } + expect(JSON.stringify(dalmuti.view(s, null))).not.toContain('canDeclareRevolution'); + expect(dalmuti.deadline!(s)).toBe(s.turnStartedAt + 20_000); + } + expect(dalmuti.view(withHolder, 'C').my!.canDeclareRevolution).toBe(true); + expect(JSON.stringify(dalmuti.view(without, 'C'))).not.toContain('canDeclareRevolution'); + // Other views of both states differ only in the hidden hand contents (same shape, same keys). + const keys = (v: DalmutiView) => JSON.stringify({ ...v, my: v.my ? Object.keys(v.my) : null }); + expect(keys(dalmuti.view(withHolder, 'A')).replace(/"hand":\[.*?\]/, '')).toBe(keys(dalmuti.view(without, 'A')).replace(/"hand":\[.*?\]/, '')); + }); +}); + +// ---------------------------------------------------------------- playing + +describe('트릭 진행 (§5.1)', () => { + test('following 9×3: 8×2 refused (count), 9×3 refused (same number), 4+4+광대 ok, 광대×2+1 ok (1×3)', () => { + const s = rig(make(), { + order: P5, + hands: { A: [C(8, 1), C(8, 2), C(9, 4), C(9, 5), C(9, 6), C(4, 1), C(4, 2), J1, C(1, 1), J2], B: [C(5, 1)], C: [C(6, 1)], D: [C(7, 1)], E: [C(10, 1)] }, + top: { by: 'E', ids: [C(9, 1), C(9, 2), C(9, 3)] }, + current: 'A', + }); + expect(reject(s, 'A', { type: 'play', cardIds: [C(8, 1), C(8, 2)] })).toBe('앞사람과 같은 장수(3장)로 내야 해요.'); + expect(reject(s, 'A', { type: 'play', cardIds: [C(9, 4), C(9, 5), C(9, 6)] })).toBe('더 낮은 숫자만 낼 수 있어요.'); + expect(reject(s, 'A', { type: 'play', cardIds: [C(4, 1), C(8, 1), J1] })).toBe('같은 숫자끼리만 낼 수 있어요.'); + expect(reject(s, 'A', { type: 'play', cardIds: [C(4, 1), C(4, 2), J1] })).toBeNull(); + expect(reject(s, 'A', { type: 'play', cardIds: [J1, J2, C(1, 1)] })).toBeNull(); + const after = play(s, 'A', [J1, J2, C(1, 1)]); + expect(after.trick.top).toMatchObject({ count: 3, rank: 1, by: 'A' }); + // The view offers the same choices. + const ch = dalmuti.view(s, 'A').my!.choices; + expect(ch.find((c) => c.rank === 4)).toEqual({ rank: 4, have: 2, counts: [3], needsJester: true }); + expect(ch.find((c) => c.rank === 1)).toEqual({ rank: 1, have: 1, counts: [3], needsJester: true }); + expect(ch.find((c) => c.rank === 8)).toEqual({ rank: 8, have: 2, counts: [3], needsJester: true }); + expect(ch.some((c) => c.rank === 9 || c.rank === 13)).toBe(false); + }); + + test('jester alone: on 12×1 refused (13 is worse); as a lead allowed (13×1), then any single number beats it', () => { + let s = rig(make(), { order: P5, hands: { A: [J1, C(12, 2)], B: [C(12, 3), C(5, 1)], C: [C(6, 1)], D: [C(7, 1)], E: [C(10, 1)] }, top: { by: 'E', ids: [C(12, 1)] }, current: 'A' }); + expect(reject(s, 'A', { type: 'play', cardIds: [J1] })).toBe('더 낮은 숫자만 낼 수 있어요.'); + s = rig(make(), { order: P5, hands: { A: [J1, C(12, 2)], B: [C(12, 3), C(5, 1)], C: [C(6, 1)], D: [C(7, 1)], E: [C(10, 1)] }, current: 'A' }); + s = play(s, 'A', [J1]); + expect(s.trick.top).toMatchObject({ count: 1, rank: 13 }); + expect(reject(s, 'B', { type: 'play', cardIds: [C(12, 3)] })).toBeNull(); + }); + + test('a lead cannot pass; leads may be any size of one number', () => { + const s = rig(make(), { order: P5, hands: { A: [C(7, 1), C(7, 2), C(7, 3), J1], B: [C(5, 1)], C: [C(6, 1)], D: [C(8, 1)], E: [C(10, 1)] }, current: 'A' }); + expect(reject(s, 'A', { type: 'pass' })).toBe('새로 시작할 때는 패스할 수 없어요.'); + expect(reject(s, 'A', { type: 'play', cardIds: [C(7, 1), C(7, 2), C(7, 3), J1] })).toBeNull(); + expect(dalmuti.view(s, 'A').my!.choices.find((c) => c.rank === 7)!.counts).toEqual([1, 2, 3, 4]); + }); + + test('§5.3 example: everyone but D passes → D leads the next trick', () => { + let s = rig(make(), { + order: P5, + hands: { A: [C(7, 1), C(7, 2), C(3, 1)], B: [C(5, 1), C(5, 2), C(9, 1)], C: [C(10, 1), C(11, 1)], D: [C(2, 1), J1, C(8, 1)], E: [C(12, 1), C(6, 1)] }, + }); + s = play(s, 'A', [C(7, 1), C(7, 2)]); + s = play(s, 'B', [C(5, 1), C(5, 2)]); + s = pass(s, 'C'); + s = play(s, 'D', [C(2, 1), J1]); + for (const p of ['E', 'A', 'B']) s = pass(s, p); + expect(s.current).toBe('C'); + expect(s.trick.top!.by).toBe('D'); + s = pass(s, 'C'); + expect(s.trick.top).toBeNull(); + expect(s.trick.leader).toBe('D'); + expect(s.current).toBe('D'); + expect(s.discard).toEqual(expect.arrayContaining([C(7, 1), C(5, 1), C(2, 1), J1])); + }); + + test('going out with the top set → the next player with cards leads', () => { + let s = rig(make(), { order: P5, hands: { A: [C(3, 1)], B: [C(5, 1), C(5, 2)], C: [C(10, 1)], D: [C(8, 1)], E: [C(12, 1), C(12, 2)] }, current: 'E' }); + s = play(s, 'E', [C(12, 1)]); + s = play(s, 'A', [C(3, 1)]); // A is out on the top set + expect(s.finishOrder).toEqual(['A']); + for (const p of ['B', 'C', 'D']) s = pass(s, p); + expect(s.current).toBe('E'); + s = pass(s, 'E'); + expect(s.trick.top).toBeNull(); + expect(s.current).toBe('B'); + }); + + test('passLocksOut off: a passer may play when the turn comes back; on: skipped for the rest of the trick', () => { + const hands = { A: [C(10, 1), C(1, 1)], B: [C(9, 1), C(2, 1)], C: [C(8, 1), C(12, 1)], D: [C(12, 2), C(12, 3)], E: [C(12, 4), C(12, 5)] }; + const run = (passLocksOut: boolean) => { + let s = rig(make({ passLocksOut }), { order: P5, hands }); + s = play(s, 'A', [C(10, 1)]); + s = pass(s, 'B'); + s = play(s, 'C', [C(8, 1)]); + for (const p of ['D', 'E', 'A']) s = pass(s, p); + return s; + }; + const off = run(false); + expect(off.current).toBe('B'); + expect(reject(off, 'B', { type: 'play', cardIds: [C(2, 1)] })).toBeNull(); + const on = run(true); + expect(on.trick.top).toBeNull(); + expect(on.current).toBe('C'); + // Mid-trick, a locked-out player is skipped in the turn order. + let m = rig(make({ passLocksOut: true }), { order: P5, hands }); + m = play(m, 'A', [C(10, 1)]); + m = pass(m, 'B'); + m = play(m, 'C', [C(8, 1)]); + m = pass(m, 'D'); + m = pass(m, 'E'); + m = play(m, 'A', [C(1, 1)]); + expect(m.current).toBe('C'); + expect(dalmuti.view(m, null).seats.find((x) => x.id === 'B')!.lockedOut).toBe(true); + }); +}); + +// ---------------------------------------------------------------- rounds / scoring + +describe('라운드 종료와 점수 (§5.2, §6)', () => { + test('one player left with cards → round over; that player is the next 대농노; next order = finish order', () => { + let s = rig(make({ rounds: 3 }), { order: P5, hands: { A: [C(3, 1)], B: [C(4, 1)], C: [C(5, 1)], D: [C(6, 1)], E: [C(12, 1), C(12, 2)] }, current: 'D' }); + s = play(s, 'D', [C(6, 1)]); + s = pass(s, 'E'); + s = play(s, 'A', [C(3, 1)]); // A out (1st) + s = pass(s, 'B'); // B: 4 > 3 + s = pass(s, 'C'); + // D is out already; E passes → trick over, A is out → next with cards after A = B leads. + s = pass(s, 'E'); + expect(s.current).toBe('B'); + s = play(s, 'B', [C(4, 1)]); // B out (3rd? no: D 1st, A 2nd, B 3rd) + expect(s.finishOrder).toEqual(['D', 'A', 'B']); + s = pass(s, 'C'); + s = pass(s, 'E'); + expect(s.current).toBe('C'); + s = play(s, 'C', [C(5, 1)]); // C out → only E left → round over + expect(s.round).toBe(2); + expect(s.roundResults[0]!.order).toEqual(['D', 'A', 'B', 'C', 'E']); + expect(s.roundResults[0]!.points).toEqual({ D: 4, A: 3, B: 2, C: 1, E: 0 }); + expect(s.order).toEqual(['D', 'A', 'B', 'C', 'E']); + expect(s.phase.kind).toBe('revolutionCheck'); + expect(s.order.map((p) => s.hands[p]!.length)).toEqual([16, 16, 16, 16, 16]); + expect(dalmuti.view(s, 'E').seats.find((x) => x.id === 'E')!.title).toBe('대농노'); + expect(allCardIds(s).sort()).toEqual(FULL_DECK.map((c) => c.id).sort()); + }); + + test('5 players, 3 rounds: rank points add up; ties go to the better last-round rank; last-round mode', () => { + const rr = (round: number, order: string[]) => ({ round, order, revolution: 'none' as const, points: Object.fromEntries(order.map((p, i) => [p, 4 - i])) }); + const roundResults = [rr(1, ['A', 'B', 'C', 'D', 'E']), rr(2, ['C', 'A', 'B', 'E', 'D']), rr(3, ['B', 'C', 'E', 'A', 'D'])]; + const totals: Record = {}; + for (const r of roundResults) for (const [p, pt] of Object.entries(r.points)) totals[p] = (totals[p] ?? 0) + pt; + expect(totals).toEqual({ A: 8, B: 9, C: 9, D: 1, E: 3 }); + const base = { roundResults, totals, players: P5, options: dalmuti.optionsSchema.parse({}) }; + const f = finalOf(base); + // B and C tie on 9 → B was 1st in the last round, C 2nd. + expect(f.ranking).toEqual([['B'], ['C'], ['A'], ['E'], ['D']]); + expect(f.players.find((p) => p.player === 'A')!.roundRanks).toEqual([1, 2, 4]); + expect(f.summary).toContain('같은 점수는 마지막 라운드 순위'); + expect(f.summary).toContain('3라운드'); + const last = finalOf({ ...base, options: dalmuti.optionsSchema.parse({ scoring: 'last-round' }) }); + expect(last.ranking).toEqual([['B'], ['C'], ['E'], ['A'], ['D']]); + }); + + test('the game ends after `rounds` rounds with a result', () => { + let s = rig(make({ rounds: 1 }), { order: P4.concat('E'), hands: { A: [C(3, 1)], B: [C(4, 1)], C: [C(5, 1)], D: [C(6, 1)], E: [C(7, 1)] }, current: 'E' }); + s = play(s, 'E', [C(7, 1)]); + s = play(s, 'A', [C(3, 1)]); + for (const p of ['B', 'C', 'D']) s = pass(s, p); + s = play(s, 'B', [C(4, 1)]); + s = pass(s, 'C'); + s = pass(s, 'D'); + s = play(s, 'C', [C(5, 1)]); + expect(s.phase.kind).toBe('finished'); + const res = dalmuti.result(s)!; + expect(res.ranking).toEqual([['E'], ['A'], ['B'], ['C'], ['D']]); + expect(res.scores).toEqual({ E: 4, A: 3, B: 2, C: 1, D: 0 }); + expect(reject(s, 'D', { type: 'pass' })).toBe('게임이 이미 끝났어요.'); + }); +}); + +// ---------------------------------------------------------------- timeouts / leaving + +describe('시간 초과와 나가기 (§8.5)', () => { + test('lead timeout plays every card of the highest number; follow timeout passes; jesters only → all jesters', () => { + let s = rig(make(), { order: P5, hands: { A: [C(2, 1), C(11, 1), C(11, 2), J1], B: [C(5, 1)], C: [C(6, 1)], D: [C(7, 1)], E: [C(10, 1)] } }); + expect(dalmuti.onTimeout(s, 'A')).toEqual({ type: 'timeout' }); + s = act(s, 'A', { type: 'timeout' }); + expect(s.trick.top).toMatchObject({ by: 'A', count: 2, rank: 11 }); + expect(ids(s, 'A')).toEqual([C(2, 1), J1]); + s = act(s, 'B', { type: 'timeout' }); + expect(s.trick.passesSinceTop).toEqual(['B']); + const j = act(rig(make(), { order: P5, hands: { A: [J1, J2], B: [C(5, 1)], C: [C(6, 1)], D: [C(7, 1)], E: [C(10, 1)] } }), 'A', { type: 'timeout' }); + expect(j.trick.top).toMatchObject({ count: 2, rank: 13 }); + }); + + test('tax return timeout gives back the highest number cards; revolutionCheck timeout never declares', () => { + let s = atRevolutionCheck(handsWith(P5, { A: [C(12, 1), C(12, 2), J1, J2] })); + for (const p of P5) s = act(s, p, { type: 'timeout' }); + expect(s.revolution).toBe('none'); + expect(s.phase.kind).toBe('taxReturn'); + const hiA = Math.max(...nums(s, 'A').filter((n) => n !== 13)); + s = act(s, 'A', { type: 'timeout' }); + const back = s.taxLog.find((t) => t.kind === 'return' && t.from === 'A')!; + expect(back.cardIds.map((id) => card(id).n)).toEqual([hiA, hiA]); + expect(back.cardIds).not.toContain(J1); + }); + + test('3 timeouts in a row → away, deadline 5 s; acting again clears it', () => { + let s = rig(make(), { order: P5, hands: { A: [C(1, 1), C(9, 1), C(9, 2), C(9, 3)], B: [C(12, 1), C(12, 2)], C: [C(12, 3)], D: [C(12, 4)], E: [C(12, 5)] } }); + s.timeoutsInRow.B = 2; + s = play(s, 'A', [C(9, 1)]); + expect(dalmuti.deadline!(s)).toBe(s.turnStartedAt + 20_000); + s = act(s, 'B', { type: 'timeout' }); + expect(s.away.B).toBe(true); + for (const p of ['C', 'D', 'E']) s = pass(s, p); + s = play(s, 'A', [C(9, 2)]); + expect(s.current).toBe('B'); + expect(dalmuti.deadline!(s)).toBe(s.turnStartedAt + 5000); + expect(dalmuti.timeoutNotBefore!(s)).toBe(s.turnStartedAt + 1200); + s = pass(s, 'B'); + expect(s.away.B).toBe(false); + }); + + test('leaving does not eliminate: the seat stays, marked away, and keeps being auto-played', () => { + let s = rig(make(), { order: P5, hands: { A: [C(9, 1)], B: [C(12, 1), C(5, 1)], C: [C(12, 3)], D: [C(12, 4)], E: [C(12, 5)] } }); + expect(dalmuti.onLeave!(s, 'B')).toEqual({ type: 'leave' }); + s = act(s, 'B', { type: 'leave' }); + expect(s.left.B).toBe(true); + expect(s.away.B).toBe(true); + expect(s.order).toContain('B'); + expect(dalmuti.onLeave!(s, 'B')).toBeNull(); + expect(dalmuti.view(s, null).seats.find((x) => x.id === 'B')!.left).toBe(true); + }); +}); + +// ---------------------------------------------------------------- validation / leaks / options + +describe('검증과 정보 유출 (§8.3, §10)', () => { + test('not my turn / card not in hand / resent packet are refused and change nothing', () => { + let s = rig(make(), { order: P5, hands: { A: [C(9, 1), C(9, 2)], B: [C(5, 1)], C: [C(6, 1)], D: [C(7, 1)], E: [C(10, 1)] } }); + const snap = JSON.stringify(s); + expect(reject(s, 'B', { type: 'play', cardIds: [C(5, 1)] })).toBe('지금은 내 차례가 아니에요.'); + expect(reject(s, 'A', { type: 'play', cardIds: [C(5, 1)] })).toBe('손에 없는 카드예요.'); + expect(reject(s, 'A', { type: 'play', cardIds: [C(9, 1), C(9, 1)] })).toBe('같은 카드를 두 번 고를 수 없어요.'); + expect(reject(s, 'Z', { type: 'pass' })).toBe('이 게임의 플레이어가 아니에요.'); + expect(reject(s, 'B', { type: 'timeout' })).toBe('시간 초과 대상이 아니에요.'); + expect(JSON.stringify(s)).toBe(snap); + s = play(s, 'A', [C(9, 1)]); + const snap2 = JSON.stringify(s); + expect(reject(s, 'A', { type: 'play', cardIds: [C(9, 1)] })).toBe('지금은 내 차례가 아니에요.'); + expect(JSON.stringify(s)).toBe(snap2); + }); + + test('spectators and other players see counts only; tax card contents only for the two involved', () => { + const s = confirmAll(atRevolutionCheck(handsWith(P5, { E: [J1, J2, C(3, 1), C(3, 2)] }))); + const taxIds = s.taxLog.flatMap((t) => t.cardIds); + for (const viewer of [null, 'C']) { + const v = dalmuti.view(s, viewer); + const json = JSON.stringify(v); + for (const p of P5) if (p !== viewer) for (const id of ids(s, p)) expect(json).not.toContain(`"${id}"`); + for (const id of taxIds) expect(json).not.toContain(`"${id}"`); + expect(v.tax.every((t) => t.cards === null)).toBe(true); + expect(v.tax.map((t) => t.count)).toEqual([2, 1]); + } + const ve = dalmuti.view(s, 'E'); + expect(ve.tax[0]!.cards).toEqual([3, 3]); + expect(ve.tax[1]!.cards).toBeNull(); + const va = dalmuti.view(s, 'A'); + expect(va.tax[0]!.cards).toEqual([3, 3]); + expect(va.my!.receivedIds).toEqual([C(3, 1), C(3, 2)]); + expect(va.my!.owe).toEqual({ to: 'E', count: 2 }); + expect(va.my!.suggestedReturn.map((id) => card(id).n)).toEqual(worst2(s, 'A')); + // After A gives the 3s back, A's view must not keep their ids. + const t = act(s, 'A', { type: 'returnTax', cardIds: [C(3, 1), C(3, 2)] }); + expect(JSON.stringify(dalmuti.view(t, 'A'))).not.toContain(`"${C(3, 1)}"`); + expect(dalmuti.view(t, 'E').tax[2]!.cards).toEqual([3, 3]); + }); + + test('options: defaults, Korean metadata on every option', () => { + const o = dalmuti.defaultOptions; + expect(o).toEqual({ rounds: 3, scoring: 'rank-points', firstRanking: 'draw', revolution: true, jesterTaxable: false, passLocksOut: false, removeHighCards: false, turnSeconds: 20 }); + const shape = (dalmuti.optionsSchema as unknown as { shape: Record }).shape; + for (const [k, f] of Object.entries(shape)) { + expect(f.meta()?.title, k).toMatch(/[가-힣]/); + } + }); + + test('client actions only (system actions are not in the schema)', () => { + expect(dalmuti.actionSchema.safeParse({ type: 'timeout' }).success).toBe(false); + expect(dalmuti.actionSchema.safeParse({ type: 'leave' }).success).toBe(false); + expect(dalmuti.actionSchema.safeParse({ type: 'pass' }).success).toBe(true); + expect(dalmuti.actionSchema.safeParse({ type: 'play', cardIds: [] }).success).toBe(false); + }); +}); + +function worst2(s: DalmutiState, p: string): number[] { + const n = nums(s, p).filter((x) => x !== 13).sort((a, b) => b - a); + return n.slice(0, 2); +} + +// ---------------------------------------------------------------- random playouts + +function invariants(s: DalmutiState): void { + const all = allCardIds(s); + const deck = deckFor(s.options.removeHighCards); + if (all.length !== deck.length || new Set(all).size !== deck.length) throw new Error(`card count ${all.length} (unique ${new Set(all).size}) ≠ ${deck.length}`); + if (s.phase.kind === 'playing' && s.hands[s.current]!.length === 0) throw new Error('current player has no cards'); + if (s.phase.kind !== 'finished' && s.order.length !== s.players.length) throw new Error('order lost a player'); +} + +function checkLeaks(s: DalmutiState, players: PlayerId[], viewers: (PlayerId | null)[] = [null, ...players]): void { + for (const viewer of viewers) { + const v = dalmuti.view(s, viewer); + const json = JSON.stringify(v); + for (const p of players) { + if (p === viewer) continue; + for (const c of s.hands[p]!) if (json.includes(`"${c.id}"`)) throw new Error(`view for ${viewer} leaks ${c.id} of ${p}`); + } + for (const t of v.tax) if (t.cards && viewer !== t.from && viewer !== t.to) throw new Error(`tax contents leak to ${viewer}`); + const holder = viewer !== null && s.phase.kind === 'revolutionCheck' && s.hands[viewer]!.filter((c) => c.n === 13).length === 2; + if (!holder && json.includes('canDeclareRevolution')) throw new Error(`canDeclareRevolution in the view for ${viewer}`); + } +} + +interface FastReport { + steps: number; + finished: boolean; + trace: string; + final: DalmutiState; +} + +/** Same chooser and selection as `randomPlayout`, with lighter per-step checks so 1,000 games stay fast. */ +function fastPlayout(players: PlayerId[], options: DalmutiOptions, seed: RngState, maxSteps: number): FastReport { + const r = SeededRng.fromSeed(seed); + const chooser = SeededRng.fromSeed([seed[3], seed[2], seed[1], seed[0] ^ 0x9e3779b9]); + let now = 1_700_000_000_000; + let s = dalmuti.setup({ players, options, rng: r, now }); + const trace: string[] = []; + for (let step = 0; step < maxSteps; step++) { + invariants(s); + if (step % 15 === 0) checkLeaks(s, players, [null, players[(step / 15) % players.length]!]); + if (step % 60 === 0 && JSON.stringify(JSON.parse(JSON.stringify(s))) !== JSON.stringify(s)) throw new Error('not JSON round-trippable'); + for (const p of dalmuti.timeoutPlayers!(s)) { + const v = dalmuti.validate(s, p, dalmuti.onTimeout(s, p)); + if (!v.ok) throw new Error(`onTimeout rejected for ${p}: ${v.reason}`); + } + if (dalmuti.result(s)) { + checkLeaks(s, players); + return { steps: step, finished: true, trace: trace.join(';'), final: s }; + } + const actives = dalmuti.activePlayers(s); + if (!actives.length) throw new Error('no active players'); + const actor = actives[chooser.int(actives.length)]!; + // Some turns go through the timer path, like real away players. + const legal = chooser.int(12) === 0 ? [] : dalmuti.legalActions!(s, actor); + const action = legal.length > 0 ? legal[chooser.int(legal.length)]! : dalmuti.onTimeout(s, actor); + const v = dalmuti.validate(s, actor, action); + if (!v.ok) throw new Error(`generated action rejected: ${v.reason} ${JSON.stringify(action)}`); + now += 1000; + const before = step % 25 === 0 ? JSON.stringify(s) : null; + const out = dalmuti.apply(s, actor, action, { rng: r, now }); + if (before !== null && JSON.stringify(s) !== before) throw new Error('apply mutated the previous state'); + if (JSON.stringify(out.events).match(/"c\d+-\d+"|"j[12]"/)) throw new Error('event carries a card id'); + s = out.state; + trace.push(`${actor}:${JSON.stringify(action)}`); + } + return { steps: maxSteps, finished: false, trace: trace.join(';'), final: s }; +} + +const PRESETS: Partial[] = [ + { rounds: 1 }, + { rounds: 3 }, + { rounds: 1, passLocksOut: true, jesterTaxable: true }, + { rounds: 3, revolution: false, firstRanking: 'random', scoring: 'last-round' }, + { rounds: 1, removeHighCards: true }, + { rounds: 5, removeHighCards: true, passLocksOut: true }, +]; + +describe('random playouts (1,000 seeds)', () => { + test('1,000 seeded games finish, cards conserved, no leaks, onTimeout always legal, deterministic', () => { + let finished = 0; + let revolutions = 0; + let great = 0; + for (let i = 1; i <= 1000; i++) { + const n = 4 + (i % 5); + const players = Array.from({ length: n }, (_, k) => `P${k}`); + const options = dalmuti.optionsSchema.parse(PRESETS[i % PRESETS.length]!); + const a = fastPlayout(players, options, seedN(i), 20_000); + if (!a.finished) throw new Error(`seed ${i} (${n} players) did not finish in 20,000 steps`); + finished++; + const res = dalmuti.result(a.final)!; + expect(res.ranking.flat().sort()).toEqual([...players].sort()); + expect(a.final.roundResults).toHaveLength(options.rounds); + revolutions += a.final.roundResults.filter((x) => x.revolution === 'revolution').length; + great += a.final.roundResults.filter((x) => x.revolution === 'great').length; + if (i % 50 === 0) { + const b = fastPlayout(players, options, seedN(i), 20_000); + expect(b.trace).toBe(a.trace); + expect(JSON.stringify(b.final)).toBe(JSON.stringify(a.final)); + } + } + expect(finished).toBe(1000); + expect(revolutions).toBeGreaterThan(0); + expect(great).toBeGreaterThan(0); + }, 60_000); + + test.each([ + [4, {}], + [5, { rounds: 1, passLocksOut: true }], + [8, { rounds: 1, revolution: false, removeHighCards: true }], + ] as const)('randomPlayout with the engine’s per-step leak / round-trip checks: %i players', (n, opts) => { + const players = Array.from({ length: n }, (_, k) => `P${k}`); + const options = dalmuti.optionsSchema.parse(opts); + const conserve = (st: unknown) => invariants(st as DalmutiState); + for (let i = 1; i <= 2; i++) { + const a = randomPlayout(dalmuti, { players, options, seed: seedN(2000 + i), maxSteps: 20_000, invariant: conserve, maxApplyMs: 30 }); + expect(a.finished).toBe(true); + if (i === 1) { + const b = randomPlayout(dalmuti, { players, options, seed: seedN(2000 + i), maxSteps: 20_000, maxApplyMs: 30 }); + expect(JSON.stringify(b.trace)).toBe(JSON.stringify(a.trace)); + } + } + }, 60_000); +}); diff --git a/packages/games/src/dalmuti/index.ts b/packages/games/src/dalmuti/index.ts new file mode 100644 index 0000000..55af68c --- /dev/null +++ b/packages/games/src/dalmuti/index.ts @@ -0,0 +1,749 @@ +/** 달무티 (docs/games/dalmuti.md). Pure and deterministic; state is JSON. */ +import { fail, ok, type GameDefinition, type GameEvent, type GameResult, type PlayerId, type Rng, type Validation } from '@bg/engine'; +import { + JESTER, + cardOrder, + dalmutiActionSchema, + dalmutiOptionsSchema, + deckFor, + type Choice, + type DCard, + type DalmutiAction, + type DalmutiFinal, + type DalmutiOptions, + type DalmutiState, + type DalmutiView, + type LogEntry, + type Play, + type PlayAction, + type PlayView, + type Title, +} from './types'; + +export * from './types'; + +const REVOLUTION_MS = 20_000; +const TAX_MS = 30_000; +const AWAY_MS = 5000; +const AWAY_AFTER = 3; +/** Auto moves for away / departed players wait this long so others can follow the table. */ +const MIN_TURN_MS = 1200; +const LOG_MAX = 40; +const LOG_VIEW = 20; +const MAX_REDRAWS = 50; + +type Def = GameDefinition; + +interface Ctx { + rng: Rng; + now: number; + events: GameEvent[]; +} + +// ---------------------------------------------------------------- helpers + +/** Copy for `apply`: containers are copied; cards, plays, log entries and round results are never mutated. */ +function cloneState(s: DalmutiState): DalmutiState { + const ph = s.phase; + const phase: DalmutiState['phase'] = + ph.kind === 'revolutionCheck' + ? { kind: ph.kind, confirmed: { ...ph.confirmed } } + : ph.kind === 'taxReturn' + ? { kind: ph.kind, owed: Object.fromEntries(Object.entries(ph.owed).map(([p, o]) => [p, { ...o }])) } + : { ...ph }; + return { + ...s, + order: [...s.order], + hands: Object.fromEntries(Object.entries(s.hands).map(([p, h]) => [p, [...h]])), + discard: [...s.discard], + phase, + taxLog: [...s.taxLog], + trick: { ...s.trick, passesSinceTop: [...s.trick.passesSinceTop], lockedOut: [...s.trick.lockedOut], pile: [...s.trick.pile] }, + finishOrder: [...s.finishOrder], + roundResults: [...s.roundResults], + totals: { ...s.totals }, + timeoutsInRow: { ...s.timeoutsInRow }, + away: { ...s.away }, + left: { ...s.left }, + log: [...s.log], + }; +} + +export const titleOf = (rank: number, n: number): Title => + rank === 0 ? '대달무티' : rank === 1 ? '소달무티' : rank === n - 1 ? '대농노' : rank === n - 2 ? '소농노' : '평민'; + +const byCard = (a: DCard, b: DCard) => cardOrder(a.id) - cardOrder(b.id); +export const sortHand = (h: DCard[]): DCard[] => [...h].sort(byCard); +const isJ = (c: DCard) => c.n === JESTER; +const withCards = (s: DalmutiState): PlayerId[] => s.order.filter((p) => s.hands[p]!.length > 0); +export const deckSize = (o: Pick): number => deckFor(o.removeHighCards).length; +/** Every card id of the deck, exactly once, across hands, the current trick and the discard (test invariant). */ +export const allCardIds = (s: DalmutiState): string[] => [ + ...s.players.flatMap((p) => s.hands[p]!.map((c) => c.id)), + ...s.trick.pile.flatMap((pl) => pl.cardIds), + ...s.discard, +]; + +function log(s: DalmutiState, e: Record & { t: LogEntry['t'] }): void { + s.log.push({ seq: s.seq, ...e } as LogEntry); + if (s.log.length > LOG_MAX) s.log.splice(0, s.log.length - LOG_MAX); +} + +/** Rank of a set: the number of its number cards, 13 for jesters alone; null if numbers differ. */ +export function setRank(cards: DCard[]): number | null { + const nums = cards.filter((c) => !isJ(c)); + if (nums.length === 0) return JESTER; + return nums.every((c) => c.n === nums[0]!.n) ? nums[0]!.n : null; +} + +/** Next player after `from` in turn order who still has cards and is not locked out. */ +function nextActor(s: DalmutiState, from: PlayerId): PlayerId { + const n = s.order.length; + const i0 = s.order.indexOf(from); + for (let k = 1; k <= n; k++) { + const p = s.order[(i0 + k) % n]!; + if (s.hands[p]!.length > 0 && !s.trick.lockedOut.includes(p)) return p; + } + return from; +} + +/** Next player after `from` (exclusive) who still has cards. */ +function nextWithCards(s: DalmutiState, from: PlayerId): PlayerId { + const n = s.order.length; + const i0 = s.order.indexOf(from); + for (let k = 1; k <= n; k++) { + const p = s.order[(i0 + k) % n]!; + if (s.hands[p]!.length > 0) return p; + } + return from; +} + +function newTurn(s: DalmutiState, p: PlayerId): void { + s.current = p; + s.turnNo += 1; +} + +function startTrick(s: DalmutiState, leader: PlayerId, ctx: Ctx): void { + for (const pl of s.trick.pile) s.discard.push(...pl.cardIds); + s.trick = { leader, top: null, passesSinceTop: [], lockedOut: [], pile: [] }; + ctx.events.push({ type: 'trickStart', leader }); + newTurn(s, leader); +} + +// ---------------------------------------------------------------- setup / rounds + +/** §4-1: everyone draws one card; ties re-draw among themselves (fresh shuffle each pass). */ +export function drawFirstRanks(players: PlayerId[], deck: DCard[], rng: Rng): { order: PlayerId[]; draws: Record } { + const draws: Record = Object.fromEntries(players.map((p) => [p, [] as number[]])); + let passes = 0; + const resolve = (group: PlayerId[]): PlayerId[] => { + if (group.length <= 1) return group; + if (passes++ >= MAX_REDRAWS) return group; + const shuffled = rng.shuffle(deck); + group.forEach((p, i) => draws[p]!.push(shuffled[i]!.n)); + const drawn = new Map(group.map((p, i) => [p, shuffled[i]!.n])); + const nums = [...new Set(drawn.values())].sort((a, b) => a - b); + // Buckets first: re-draws inside one bucket must not change another bucket's membership. + const buckets = nums.map((n) => group.filter((p) => drawn.get(p) === n)); + return buckets.flatMap((b) => resolve(b)); + }; + return { order: resolve([...players]), draws }; +} + +/** §4-3: shuffle and deal one at a time from the top rank; earlier ranks get the extra cards. */ +function deal(s: DalmutiState, ctx: Ctx): void { + const deck = ctx.rng.shuffle(deckFor(s.options.removeHighCards)); + s.hands = Object.fromEntries(s.players.map((p) => [p, [] as DCard[]])); + deck.forEach((c, i) => s.hands[s.order[i % s.order.length]!]!.push(c)); + for (const p of s.players) s.hands[p] = sortHand(s.hands[p]!); + s.discard = []; + s.taxLog = []; + s.finishOrder = []; + s.revolution = 'none'; + s.revolutionBy = null; + s.trick = { leader: s.order[0]!, top: null, passesSinceTop: [], lockedOut: [], pile: [] }; + s.current = s.order[0]!; + ctx.events.push({ type: 'dealt', round: s.round, order: [...s.order], counts: s.order.map((p) => s.hands[p]!.length) }); + if (s.options.revolution) { + s.phase = { kind: 'revolutionCheck', confirmed: Object.fromEntries(s.players.map((p) => [p, false])) }; + s.turnNo += 1; + } else collectTax(s, ctx); +} + +/** Best cards for tax (§4-5): lowest numbers by card order; jesters first only with `jesterTaxable`. */ +export function taxCards(hand: DCard[], count: number, jesterTaxable: boolean): DCard[] { + const sorted = sortHand(hand); + const pool = jesterTaxable ? [...sorted.filter(isJ), ...sorted.filter((c) => !isJ(c))] : [...sorted.filter((c) => !isJ(c)), ...sorted.filter(isJ)]; + return pool.slice(0, count); +} + +/** Default return / timeout lead: highest number cards first, jesters last. */ +export function worstCards(hand: DCard[], count: number): DCard[] { + const sorted = sortHand(hand); + return [...sorted.filter((c) => !isJ(c)).reverse(), ...sorted.filter(isJ)].slice(0, count); +} + +function moveCards(s: DalmutiState, from: PlayerId, to: PlayerId, ids: string[]): void { + const moving = s.hands[from]!.filter((c) => ids.includes(c.id)); + s.hands[from] = s.hands[from]!.filter((c) => !ids.includes(c.id)); + s.hands[to] = sortHand([...s.hands[to]!, ...moving]); +} + +function collectTax(s: DalmutiState, ctx: Ctx): void { + const o = s.order; + const n = o.length; + const pairs: [PlayerId, PlayerId, number][] = [ + [o[n - 1]!, o[0]!, 2], + [o[n - 2]!, o[1]!, 1], + ]; + const owed: Extract['owed'] = {}; + for (const [from, to, count] of pairs) { + const ids = taxCards(s.hands[from]!, count, s.options.jesterTaxable).map((c) => c.id); + moveCards(s, from, to, ids); + s.taxLog.push({ kind: 'tax', from, to, cardIds: ids }); + owed[to] = { to: from, count, done: false }; + ctx.events.push({ type: 'tax', from, to, count }); + log(s, { t: 'tax', from, to, n: count }); + } + s.phase = { kind: 'taxReturn', owed }; + s.turnNo += 1; +} + +function startPlaying(s: DalmutiState, ctx: Ctx): void { + s.phase = { kind: 'playing' }; + s.trick = { leader: s.order[0]!, top: null, passesSinceTop: [], lockedOut: [], pile: [] }; + ctx.events.push({ type: 'trickStart', leader: s.order[0]! }); + newTurn(s, s.order[0]!); +} + +function endRevolutionCheck(s: DalmutiState, ctx: Ctx): void { + const by = s.revolutionBy; + if (by) { + const great = by === s.order[s.order.length - 1]; + s.revolution = great ? 'great' : 'revolution'; + if (great) s.order = [...s.order].reverse(); + ctx.events.push({ type: 'revolution', player: by, kind: s.revolution, order: [...s.order] }); + log(s, { t: 'revolution', p: by, kind: s.revolution }); + startPlaying(s, ctx); + return; + } + collectTax(s, ctx); +} + +function endRound(s: DalmutiState, ctx: Ctx): void { + const last = withCards(s); + const order = [...s.finishOrder, ...last]; + for (const p of last) { + s.discard.push(...s.hands[p]!.map((c) => c.id)); + s.hands[p] = []; + } + for (const pl of s.trick.pile) s.discard.push(...pl.cardIds); + s.trick = { ...s.trick, top: null, passesSinceTop: [], lockedOut: [], pile: [] }; + const n = order.length; + const points = Object.fromEntries(order.map((p, i) => [p, n - 1 - i])); + for (const p of order) s.totals[p] = (s.totals[p] ?? 0) + points[p]!; + s.roundResults.push({ round: s.round, order, revolution: s.revolution, points }); + ctx.events.push({ type: 'roundEnd', round: s.round, order }); + log(s, { t: 'round', round: s.round }); + if (s.round >= s.options.rounds) { + finish(s, ctx); + return; + } + s.round += 1; + s.order = order; + deal(s, ctx); +} + +/** Final ranking (§6): total points (ties → better last-round rank) or the last round's order. */ +export function finalOf(s: Pick): DalmutiFinal { + const last = s.roundResults[s.roundResults.length - 1]!; + const lastRank = (p: PlayerId) => last.order.indexOf(p); + const sorted = + s.options.scoring === 'last-round' ? [...last.order] : [...s.players].sort((a, b) => s.totals[b]! - s.totals[a]! || lastRank(a) - lastRank(b)); + const players = sorted.map((p, i) => ({ + player: p, + rank: i + 1, + total: s.totals[p] ?? 0, + roundRanks: s.roundResults.map((r) => r.order.indexOf(p) + 1), + })); + const rounds = s.roundResults.length; + let summary: string; + if (s.options.scoring === 'last-round') { + summary = `${rounds}라운드를 마쳤어요. 마지막 라운드에서 가장 먼저 손패를 다 낸 사람이 1등이에요!`; + } else { + const top = players[0]!; + const tied = players.filter((x) => x.total === top.total).length > 1; + summary = + rounds === 1 + ? '가장 먼저 손패를 다 낸 사람이 1등이에요!' + : `${rounds}라운드 동안 순위 점수 ${top.total}점을 모아 가장 높은 신분을 지켰어요!${tied ? ' (같은 점수는 마지막 라운드 순위로 정했어요)' : ''}`; + } + return { ranking: sorted.map((p) => [p]), players, summary }; +} + +function finish(s: DalmutiState, ctx: Ctx): void { + s.final = finalOf(s); + s.phase = { kind: 'finished' }; + ctx.events.push({ type: 'gameEnd', ranking: s.final.ranking }); +} + +// ---------------------------------------------------------------- playing + +function playCards(s: DalmutiState, p: PlayerId, ids: string[], ctx: Ctx): void { + const hand = s.hands[p]!; + const cards = ids.map((id) => hand.find((c) => c.id === id)!); + const rank = setRank(cards)!; + const play: Play & { by: PlayerId } = { cardIds: [...ids], count: ids.length, rank, by: p }; + s.hands[p] = hand.filter((c) => !ids.includes(c.id)); + s.trick.top = play; + s.trick.pile.push(play); + s.trick.passesSinceTop = []; + const faces = sortHand(cards).map((c) => c.n); + ctx.events.push({ type: 'played', player: p, cards: faces, count: play.count, rank }); + log(s, { t: 'play', p, cards: faces, count: play.count, rank }); + if (s.hands[p]!.length === 0) { + s.finishOrder.push(p); + ctx.events.push({ type: 'playerOut', player: p, place: s.finishOrder.length }); + log(s, { t: 'out', p, place: s.finishOrder.length }); + } + if (withCards(s).length <= 1) { + endRound(s, ctx); + return; + } + afterMove(s, p, ctx); +} + +function pass(s: DalmutiState, p: PlayerId, ctx: Ctx): void { + s.trick.passesSinceTop.push(p); + if (s.options.passLocksOut && !s.trick.lockedOut.includes(p)) s.trick.lockedOut.push(p); + ctx.events.push({ type: 'passed', player: p }); + log(s, { t: 'pass', p }); + afterMove(s, p, ctx); +} + +/** §8.2: trick ends when everyone with cards except the top player has passed (or is locked out). */ +function afterMove(s: DalmutiState, p: PlayerId, ctx: Ctx): void { + const top = s.trick.top!; + const waitingOn = withCards(s).filter((x) => x !== top.by && !s.trick.passesSinceTop.includes(x) && !s.trick.lockedOut.includes(x)); + if (waitingOn.length === 0) { + const leader = s.hands[top.by]!.length > 0 ? top.by : nextWithCards(s, top.by); + log(s, { t: 'trick', p: leader }); + startTrick(s, leader, ctx); + return; + } + newTurn(s, nextActor(s, p)); +} + +/** What `p` may play now (rank, set sizes). */ +export function choicesFor(s: DalmutiState, p: PlayerId): Choice[] { + if (s.phase.kind !== 'playing' || s.current !== p) return []; + const hand = s.hands[p]!; + const J = hand.filter(isJ).length; + const top = s.trick.top; + const out: Choice[] = []; + for (let r = 1; r <= 12; r++) { + const have = hand.filter((c) => c.n === r).length; + if (have === 0) continue; + if (!top) out.push({ rank: r, have, counts: Array.from({ length: have + J }, (_, i) => i + 1), needsJester: false }); + else if (r < top.rank && have + J >= top.count) out.push({ rank: r, have, counts: [top.count], needsJester: have < top.count }); + } + if (J > 0 && !top) out.push({ rank: JESTER, have: J, counts: Array.from({ length: J }, (_, i) => i + 1), needsJester: false }); + return out; +} + +/** Card ids for a set: `count` cards of `rank`, using at least `minJesters` jesters (more if needed). */ +export function pickSet(hand: DCard[], rank: number, count: number, minJesters = 0): string[] | null { + const jesters = sortHand(hand.filter(isJ)); + if (rank === JESTER) return count <= jesters.length ? jesters.slice(0, count).map((c) => c.id) : null; + const nums = sortHand(hand.filter((c) => c.n === rank)); + const j = Math.max(minJesters, count - nums.length); + if (j > jesters.length || count - j < 1 || count - j > nums.length) return null; + return [...nums.slice(0, count - j), ...jesters.slice(0, j)].map((c) => c.id); +} + +/** §8.5 lead timeout: all cards of the highest number; only jesters left → all jesters. */ +export function timeoutLead(hand: DCard[]): string[] { + const nums = hand.filter((c) => !isJ(c)); + if (nums.length === 0) return hand.map((c) => c.id); + const hi = Math.max(...nums.map((c) => c.n)); + return sortHand(nums.filter((c) => c.n === hi)).map((c) => c.id); +} + +function validatePlay(s: DalmutiState, actor: PlayerId, a: PlayAction): Validation { + if (s.phase.kind !== 'playing') return fail('지금은 카드를 낼 수 없어요.'); + if (s.current !== actor) return fail('지금은 내 차례가 아니에요.'); + if (s.trick.lockedOut.includes(actor)) return fail('이번 트릭에서는 이미 패스했어요.'); + if (new Set(a.cardIds).size !== a.cardIds.length) return fail('같은 카드를 두 번 고를 수 없어요.'); + const hand = s.hands[actor]!; + const cards = a.cardIds.map((id) => hand.find((c) => c.id === id)); + if (cards.some((c) => !c)) return fail('손에 없는 카드예요.'); + const rank = setRank(cards as DCard[]); + if (rank === null) return fail('같은 숫자끼리만 낼 수 있어요.'); + const top = s.trick.top; + if (top) { + if (a.cardIds.length !== top.count) return fail(`앞사람과 같은 장수(${top.count}장)로 내야 해요.`); + if (rank >= top.rank) return fail('더 낮은 숫자만 낼 수 있어요.'); + } + return ok; +} + +function validateReturn(s: DalmutiState, actor: PlayerId, ids: string[]): Validation { + if (s.phase.kind !== 'taxReturn') return fail('지금은 카드를 돌려줄 때가 아니에요.'); + const owe = s.phase.owed[actor]; + if (!owe) return fail('돌려줄 카드가 없어요.'); + if (owe.done) return fail('이미 카드를 돌려줬어요.'); + if (ids.length !== owe.count) return fail(`돌려줄 카드 ${owe.count}장을 골라 주세요.`); + if (new Set(ids).size !== ids.length) return fail('같은 카드를 두 번 고를 수 없어요.'); + if (ids.some((id) => !s.hands[actor]!.some((c) => c.id === id))) return fail('손에 없는 카드예요.'); + return ok; +} + +function returnTax(s: DalmutiState, p: PlayerId, ids: string[], ctx: Ctx): void { + if (s.phase.kind !== 'taxReturn') throw new Error('not in taxReturn'); + const owe = s.phase.owed[p]!; + moveCards(s, p, owe.to, ids); + owe.done = true; + s.taxLog.push({ kind: 'return', from: p, to: owe.to, cardIds: [...ids] }); + ctx.events.push({ type: 'taxReturned', from: p, to: owe.to, count: ids.length }); + log(s, { t: 'return', from: p, to: owe.to, n: ids.length }); + if (Object.values(s.phase.owed).every((x) => x.done)) startPlaying(s, ctx); +} + +function confirmHand(s: DalmutiState, p: PlayerId, declare: boolean, ctx: Ctx): void { + if (s.phase.kind !== 'revolutionCheck') throw new Error('not in revolutionCheck'); + s.phase.confirmed[p] = true; + if (declare) { + s.revolutionBy = p; + ctx.events.push({ type: 'revolutionDeclared', player: p }); + } + if (s.players.every((x) => (s.phase as { confirmed: Record }).confirmed[x])) endRevolutionCheck(s, ctx); +} + +const hasBothJesters = (s: DalmutiState, p: PlayerId) => s.hands[p]!.filter(isJ).length === 2; +const canDeclare = (s: DalmutiState, p: PlayerId) => + s.phase.kind === 'revolutionCheck' && !s.phase.confirmed[p] && s.revolutionBy === null && hasBothJesters(s, p); + +function waiting(s: DalmutiState): PlayerId[] { + const ph = s.phase; + switch (ph.kind) { + case 'revolutionCheck': + return s.players.filter((p) => !ph.confirmed[p]); + case 'taxReturn': + return s.players.filter((p) => ph.owed[p] && !ph.owed[p]!.done); + case 'playing': + return [s.current]; + case 'finished': + return []; + } +} + +function deadlineMs(s: DalmutiState): number | null { + const w = waiting(s); + const allAway = w.length > 0 && w.every((p) => s.away[p]); + switch (s.phase.kind) { + case 'revolutionCheck': + return allAway ? AWAY_MS : REVOLUTION_MS; + case 'taxReturn': + return allAway ? AWAY_MS : TAX_MS; + case 'playing': + return allAway ? AWAY_MS : s.options.turnSeconds * 1000; + case 'finished': + return null; + } +} + +function timeoutMove(s: DalmutiState, p: PlayerId, ctx: Ctx): void { + const ph = s.phase; + switch (ph.kind) { + case 'revolutionCheck': + confirmHand(s, p, false, ctx); + break; + case 'taxReturn': + returnTax(s, p, worstCards(s.hands[p]!, ph.owed[p]!.count).map((c) => c.id), ctx); + break; + case 'playing': + if (s.trick.top) pass(s, p, ctx); + else playCards(s, p, timeoutLead(s.hands[p]!), ctx); + break; + } +} + +// ---------------------------------------------------------------- view + +const faceOf = (id: string): number => (id.startsWith('j') ? JESTER : Number(id.slice(1, id.indexOf('-')))); + +function playView(pl: Play & { by: PlayerId }): PlayView { + return { by: pl.by, count: pl.count, rank: pl.rank, cards: pl.cardIds.map(faceOf).sort((a, b) => a - b) }; +} + +// ---------------------------------------------------------------- definition + +export const dalmuti: Def = { + id: 'dalmuti', + nameKo: '달무티', + minPlayers: 4, + maxPlayers: 8, + stateVersion: 1, + defaultOptions: dalmutiOptionsSchema.parse({}), + optionsSchema: dalmutiOptionsSchema as unknown as Def['optionsSchema'], + actionSchema: dalmutiActionSchema as unknown as Def['actionSchema'], + + setup({ players, options, rng, now }) { + if (players.length < 4 || players.length > 8) throw new Error(`dalmuti: ${players.length} players not allowed`); + const deck = deckFor(options.removeHighCards); + let order: PlayerId[]; + let firstDraw: Record | null = null; + if (options.firstRanking === 'draw') { + const d = drawFirstRanks(players, deck, rng); + order = d.order; + firstDraw = d.draws; + } else order = rng.shuffle(players); + const s: DalmutiState = { + options, + players: [...players], + order, + round: 1, + hands: {}, + discard: [], + phase: { kind: 'playing' }, + revolution: 'none', + revolutionBy: null, + firstDraw, + taxLog: [], + trick: { leader: order[0]!, top: null, passesSinceTop: [], lockedOut: [], pile: [] }, + current: order[0]!, + finishOrder: [], + roundResults: [], + totals: Object.fromEntries(players.map((p) => [p, 0])), + turnStartedAt: now, + turnNo: 0, + seq: 0, + timeoutsInRow: Object.fromEntries(players.map((p) => [p, 0])), + away: Object.fromEntries(players.map((p) => [p, false])), + left: Object.fromEntries(players.map((p) => [p, false])), + log: [], + final: null, + }; + deal(s, { rng, now, events: [] }); + return s; + }, + + validate(s, actor, a) { + if (!s.players.includes(actor)) return fail('이 게임의 플레이어가 아니에요.'); + if (s.phase.kind === 'finished') return fail('게임이 이미 끝났어요.'); + switch (a.type) { + case 'leave': + return s.left[actor] ? fail('이미 나간 플레이어예요.') : ok; + case 'timeout': + return waiting(s).includes(actor) ? ok : fail('시간 초과 대상이 아니에요.'); + case 'confirmHand': { + const ph = s.phase; + if (ph.kind !== 'revolutionCheck') return fail('지금은 손패를 확인할 때가 아니에요.'); + if (ph.confirmed[actor]) return fail('이미 확인했어요.'); + if (a.declareRevolution && !hasBothJesters(s, actor)) return fail('광대 2장이 있어야 혁명할 수 있어요.'); + return ok; + } + case 'returnTax': + return validateReturn(s, actor, a.cardIds); + case 'play': + return validatePlay(s, actor, a); + case 'pass': + if (s.phase.kind !== 'playing') return fail('지금은 패스할 수 없어요.'); + if (s.current !== actor) return fail('지금은 내 차례가 아니에요.'); + if (!s.trick.top) return fail('새로 시작할 때는 패스할 수 없어요.'); + return ok; + } + }, + + apply(prev, actor, a, { rng, now }) { + const s = cloneState(prev); + const ctx: Ctx = { rng, now, events: [] }; + const before = `${s.turnNo}|${s.phase.kind}`; + s.seq += 1; + if (a.type !== 'timeout' && a.type !== 'leave') { + s.timeoutsInRow[actor] = 0; + s.away[actor] = !!s.left[actor]; + } + switch (a.type) { + case 'confirmHand': + confirmHand(s, actor, !!a.declareRevolution, ctx); + break; + case 'returnTax': + returnTax(s, actor, a.cardIds, ctx); + break; + case 'play': + playCards(s, actor, a.cardIds, ctx); + break; + case 'pass': + pass(s, actor, ctx); + break; + case 'leave': + // Not eliminated (§8.5): the seat keeps playing on automatic moves. + s.left[actor] = true; + s.away[actor] = true; + ctx.events.push({ type: 'left', player: actor }); + log(s, { t: 'left', p: actor }); + break; + case 'timeout': { + s.timeoutsInRow[actor] = (s.timeoutsInRow[actor] ?? 0) + 1; + if (s.timeoutsInRow[actor]! >= AWAY_AFTER && !s.away[actor]) { + s.away[actor] = true; + ctx.events.push({ type: 'away', player: actor }); + } + ctx.events.push({ type: 'timeout', player: actor }); + log(s, { t: 'timeout', p: actor }); + timeoutMove(s, actor, ctx); + break; + } + } + if (`${s.turnNo}|${s.phase.kind}` !== before) s.turnStartedAt = now; + return { state: s, events: ctx.events }; + }, + + view(s, viewer) { + const me = viewer && s.players.includes(viewer) ? viewer : null; + const ph = s.phase; + const n = s.order.length; + const top = s.trick.top; + const w = waiting(s); + let my: DalmutiView['my'] = null; + if (me) { + const hand = s.hands[me]!; + const mineNow = ph.kind === 'playing' && s.current === me; + const owe = ph.kind === 'taxReturn' && ph.owed[me] && !ph.owed[me]!.done ? { to: ph.owed[me]!.to, count: ph.owed[me]!.count } : null; + const handIds = new Set(hand.map((c) => c.id)); + const received = s.taxLog.filter((t) => t.to === me).flatMap((t) => t.cardIds).filter((id) => handIds.has(id)); + my = { + hand, + choices: choicesFor(s, me), + jesters: hand.filter(isJ).length, + canPlay: mineNow, + canPass: mineNow && !!top, + leading: mineNow && !top, + lockedOut: s.trick.lockedOut.includes(me), + canConfirm: ph.kind === 'revolutionCheck' && !ph.confirmed[me], + ...(canDeclare(s, me) ? { canDeclareRevolution: true } : {}), + owe, + receivedIds: received, + suggestedReturn: owe ? worstCards(hand, owe.count).map((c) => c.id) : [], + }; + } + const playing = ph.kind === 'playing'; + return { + options: s.options, + me, + round: s.round, + phase: ph.kind, + seats: s.order.map((p, i) => ({ + id: p, + rank: i, + title: titleOf(i, n), + cards: s.hands[p]!.length, + isTurn: w.includes(p), + passed: playing && s.trick.passesSinceTop.includes(p), + lockedOut: playing && s.trick.lockedOut.includes(p), + place: s.finishOrder.includes(p) ? s.finishOrder.indexOf(p) + 1 : null, + away: !!s.away[p], + left: !!s.left[p], + confirmed: ph.kind === 'revolutionCheck' ? !!ph.confirmed[p] : false, + total: s.totals[p] ?? 0, + })), + current: playing ? s.current : null, + trick: playing ? { leader: s.trick.leader, top: top ? playView(top) : null, pile: s.trick.pile.map((pl) => playView(pl)) } : null, + finishOrder: [...s.finishOrder], + revolution: s.revolution, + revolutionBy: s.revolutionBy, + firstDraw: s.round === 1 ? s.firstDraw : null, + tax: s.taxLog.map((t) => ({ + kind: t.kind, + from: t.from, + to: t.to, + count: t.cardIds.length, + cards: me && (me === t.from || me === t.to) ? t.cardIds.map(faceOf).sort((a, b) => a - b) : null, + })), + taxPending: ph.kind === 'taxReturn' ? Object.entries(ph.owed).map(([from, o]) => ({ from, to: o.to, count: o.count, done: o.done })) : [], + roundResults: s.roundResults, + discardCount: s.discard.length, + deckSize: deckSize(s.options), + my, + turnStartedAt: s.turnStartedAt, + deadlineTotalMs: deadlineMs(s) ?? 0, + turnNo: s.turnNo, + log: s.log.slice(-LOG_VIEW), + result: s.final, + }; + }, + + activePlayers: waiting, + + deadline(s) { + const ms = deadlineMs(s); + return ms === null ? null : s.turnStartedAt + ms; + }, + + timeoutPlayers: waiting, + + timeoutNotBefore(s) { + return s.phase.kind === 'playing' ? s.turnStartedAt + MIN_TURN_MS : null; + }, + + onTimeout() { + return { type: 'timeout' }; + }, + + onLeave(s, player) { + if (!s.players.includes(player) || s.left[player] || s.phase.kind === 'finished') return null; + return { type: 'leave' }; + }, + + result(s): GameResult | null { + if (!s.final) return null; + return { ranking: s.final.ranking, scores: Object.fromEntries(s.final.players.map((p) => [p.player, p.total])), summary: s.final.summary, reason: 'normal' }; + }, + + legalActions(s, p) { + if (!s.players.includes(p) || s.phase.kind === 'finished') return []; + const ph = s.phase; + const out: DalmutiAction[] = []; + if (ph.kind === 'revolutionCheck') { + if (ph.confirmed[p]) return []; + out.push({ type: 'confirmHand' }); + if (canDeclare(s, p)) out.push({ type: 'confirmHand', declareRevolution: true }); + return out; + } + if (ph.kind === 'taxReturn') { + const owe = ph.owed[p]; + if (!owe || owe.done) return []; + // One representative card per number keeps the list small; pairs of the same number included. + const hand = sortHand(s.hands[p]!); + const reps: DCard[] = []; + for (const c of hand) if (reps.filter((r) => r.n === c.n).length < owe.count) reps.push(c); + if (owe.count === 1) return reps.map((c) => ({ type: 'returnTax', cardIds: [c.id] })); + for (let i = 0; i < reps.length; i++) for (let j = i + 1; j < reps.length; j++) out.push({ type: 'returnTax', cardIds: [reps[i]!.id, reps[j]!.id] }); + return out; + } + if (s.current !== p) return []; + const hand = s.hands[p]!; + const J = hand.filter(isJ).length; + for (const ch of choicesFor(s, p)) { + for (const count of ch.counts) { + if (ch.rank === JESTER) { + out.push({ type: 'play', cardIds: pickSet(hand, JESTER, count)! }); + continue; + } + const minJ = Math.max(0, count - ch.have); + for (let j = minJ; j <= Math.min(J, count - 1); j++) { + const ids = pickSet(hand, ch.rank, count, j); + if (ids) out.push({ type: 'play', cardIds: ids }); + } + } + } + if (s.trick.top) out.push({ type: 'pass' }); + return out; + }, + + /** Own hand (card ids exactly as they would appear in a view). */ + secretsOf(s, player) { + return (s.hands[player] ?? []).map((c) => c.id); + }, +}; diff --git a/packages/games/src/dalmuti/types.ts b/packages/games/src/dalmuti/types.ts new file mode 100644 index 0000000..6a98487 --- /dev/null +++ b/packages/games/src/dalmuti/types.ts @@ -0,0 +1,287 @@ +/** 달무티 options, cards, actions, state and view types (docs/games/dalmuti.md §2, §3, §8). */ +import { z } from 'zod'; +import type { PlayerId } from '@bg/engine'; + +// ---------------------------------------------------------------- cards + +/** A card: n = 1..12, jester (광대) = 13. */ +export interface DCard { + id: string; + n: number; +} + +export const JESTER = 13; + +/** + * Card names shown on screen. The original rank names are the publisher's wording (§12), so the site + * uses its own generic names with the same numbers. + */ +export const CARD_NAME: Record = { + 1: '왕', + 2: '왕비', + 3: '재상', + 4: '공작', + 5: '백작', + 6: '기사', + 7: '학자', + 8: '상인', + 9: '요리사', + 10: '목동', + 11: '광부', + 12: '농부', + 13: '광대', +}; + +/** 80 cards: n copies of n (1..12) + 2 jesters, in a fixed order (n, copy). */ +export const FULL_DECK: readonly DCard[] = [ + ...Array.from({ length: 12 }, (_, i) => i + 1).flatMap((n) => Array.from({ length: n }, (_, k) => ({ id: `c${n}-${k + 1}`, n }))), + { id: 'j1', n: JESTER }, + { id: 'j2', n: JESTER }, +]; + +const ORDER = new Map(FULL_DECK.map((c, i) => [c.id, i])); +/** Fixed card order (number, then copy) used for tie-breaks and sorting. */ +export const cardOrder = (id: string): number => ORDER.get(id) ?? 999; + +export function deckFor(removeHighCards: boolean): DCard[] { + return FULL_DECK.filter((c) => !removeHighCards || c.n <= 8 || c.n === JESTER); +} + +// ---------------------------------------------------------------- options / actions + +const lit = (...vals: T[]) => + z.union(vals.map((v) => z.literal(v)) as unknown as [z.ZodLiteral, z.ZodLiteral, ...z.ZodLiteral[]]); + +export const dalmutiOptionsSchema = z.object({ + rounds: lit<1 | 3 | 5 | 7>(1, 3, 5, 7).default(3).meta({ title: '라운드 수', labels: { 1: '1라운드', 3: '3라운드', 5: '5라운드', 7: '7라운드' } }), + scoring: z + .enum(['rank-points', 'last-round']) + .default('rank-points') + .meta({ title: '최종 순위 방식', labels: { 'rank-points': '라운드별 순위 점수 합', 'last-round': '마지막 라운드 순위' } }), + firstRanking: z + .enum(['draw', 'random']) + .default('draw') + .meta({ title: '첫 라운드 신분 정하기', labels: { draw: '카드 한 장씩 뽑기', random: '무작위' } }), + revolution: z.boolean().default(true).meta({ title: '혁명 규칙 사용(광대 2장으로 선언)' }), + jesterTaxable: z.boolean().default(false).meta({ title: '세금 낼 때 광대도 좋은 카드로 내기' }), + passLocksOut: z.boolean().default(false).meta({ title: '한 번 패스하면 그 트릭에서 다시 못 내기' }), + removeHighCards: z.boolean().default(false).meta({ title: '9~12 카드를 빼고 하기(38장)' }), + turnSeconds: lit<15 | 20 | 30 | 45>(15, 20, 30, 45) + .default(20) + .meta({ title: '한 턴 제한 시간', labels: { 15: '15초', 20: '20초', 30: '30초', 45: '45초' } }), +}); +export type DalmutiOptions = z.infer; + +export const dalmutiActionSchema = z.discriminatedUnion('type', [ + z.object({ type: z.literal('confirmHand'), declareRevolution: z.boolean().optional() }), + z.object({ type: z.literal('returnTax'), cardIds: z.array(z.string().max(8)).min(1).max(2) }), + z.object({ type: z.literal('play'), cardIds: z.array(z.string().max(8)).min(1).max(14) }), + z.object({ type: z.literal('pass') }), +]); +/** Client actions plus system-only ones (server timer, leaving) that are not in the schema. */ +export type DalmutiAction = z.infer | { type: 'timeout' } | { type: 'leave' }; +export type PlayAction = Extract; + +// ---------------------------------------------------------------- state + +/** A set on the table. rank: the number of the number cards, or 13 for jesters alone. */ +export interface Play { + cardIds: string[]; + count: number; + rank: number; +} + +export type RevolutionKind = 'none' | 'revolution' | 'great'; + +/** + * The draw (§4-1) and the end of a round (§8.2 `roundOver`) are resolved at once; their results stay in + * `firstDraw` / `roundResults` for the screen. So the state only rests in these phases. + */ +export type Phase = + | { kind: 'revolutionCheck'; confirmed: Record } + | { kind: 'taxReturn'; owed: Record } + | { kind: 'playing' } + | { kind: 'finished' }; + +export interface TaxMove { + kind: 'tax' | 'return'; + from: PlayerId; + to: PlayerId; + cardIds: string[]; +} + +export interface Trick { + leader: PlayerId; + top: (Play & { by: PlayerId }) | null; + passesSinceTop: PlayerId[]; + lockedOut: PlayerId[]; + pile: (Play & { by: PlayerId })[]; +} + +export interface RoundResult { + round: number; + /** Finish order = next round's seating; [0] got the most points. */ + order: PlayerId[]; + revolution: RevolutionKind; + points: Record; +} + +/** Public log line (structured; the screen turns it into Korean with nicknames). */ +export type LogEntry = + | { seq: number; t: 'play'; p: PlayerId; cards: number[]; count: number; rank: number } + | { seq: number; t: 'pass'; p: PlayerId } + | { seq: number; t: 'trick'; p: PlayerId } + | { seq: number; t: 'out'; p: PlayerId; place: number } + | { seq: number; t: 'revolution'; p: PlayerId; kind: 'revolution' | 'great' } + | { seq: number; t: 'tax'; from: PlayerId; to: PlayerId; n: number } + | { seq: number; t: 'return'; from: PlayerId; to: PlayerId; n: number } + | { seq: number; t: 'round'; round: number } + | { seq: number; t: 'timeout'; p: PlayerId } + | { seq: number; t: 'left'; p: PlayerId }; + +export interface PlayerFinal { + player: PlayerId; + rank: number; + total: number; + roundRanks: number[]; +} + +export interface DalmutiFinal { + ranking: PlayerId[][]; + players: PlayerFinal[]; + summary: string; +} + +export interface DalmutiState { + options: DalmutiOptions; + /** Participants (fixed). */ + players: PlayerId[]; + /** This round's ranks = turn order; [0] = 대달무티. */ + order: PlayerId[]; + round: number; + /** Secret. */ + hands: Record; + /** Cards from finished tricks and the last player's leftovers (not sent to clients). */ + discard: string[]; + phase: Phase; + revolution: RevolutionKind; + revolutionBy: PlayerId | null; + /** First-round rank draw (§4-1): numbers each player drew, re-draws included. null for `random`. */ + firstDraw: Record | null; + /** This round's tax moves; card contents only for the two people involved. */ + taxLog: TaxMove[]; + trick: Trick; + current: PlayerId; + finishOrder: PlayerId[]; + roundResults: RoundResult[]; + totals: Record; + turnStartedAt: number; + /** Changes whenever a new turn or phase starts (resets the timer). */ + turnNo: number; + seq: number; + timeoutsInRow: Record; + away: Record; + left: Record; + log: LogEntry[]; + final: DalmutiFinal | null; +} + +// ---------------------------------------------------------------- view + +export type Title = '대달무티' | '소달무티' | '평민' | '소농노' | '대농노'; + +export interface SeatView { + id: PlayerId; + /** 0-based rank this round (= turn order). */ + rank: number; + title: Title; + cards: number; + isTurn: boolean; + /** Passed since the current top set. */ + passed: boolean; + lockedOut: boolean; + /** Finish place this round (1-based) once out of cards. */ + place: number | null; + away: boolean; + left: boolean; + /** revolutionCheck: has pressed "확인". */ + confirmed: boolean; + total: number; +} + +export interface PlayView { + by: PlayerId; + count: number; + rank: number; + /** Faces (13 = jester), no ids. */ + cards: number[]; +} + +/** One number the player can play now. */ +export interface Choice { + /** 1..12, or 13 for jesters alone. */ + rank: number; + /** Number cards of this rank in hand (jesters for rank 13). */ + have: number; + /** Legal set sizes. */ + counts: number[]; + /** Only possible by mixing in jesters (following). */ + needsJester: boolean; +} + +export interface TaxMoveView { + kind: 'tax' | 'return'; + from: PlayerId; + to: PlayerId; + count: number; + /** Faces, only for the giver and the receiver. */ + cards: number[] | null; +} + +export interface DalmutiView { + options: DalmutiOptions; + me: PlayerId | null; + round: number; + phase: Phase['kind']; + seats: SeatView[]; + current: PlayerId | null; + trick: { + leader: PlayerId; + top: PlayView | null; + pile: PlayView[]; + } | null; + finishOrder: PlayerId[]; + revolution: RevolutionKind; + revolutionBy: PlayerId | null; + firstDraw: Record | null; + tax: TaxMoveView[]; + /** taxReturn: who still has to give cards back. */ + taxPending: { from: PlayerId; to: PlayerId; count: number; done: boolean }[]; + roundResults: RoundResult[]; + discardCount: number; + deckSize: number; + my: { + hand: DCard[]; + choices: Choice[]; + jesters: number; + canPlay: boolean; + canPass: boolean; + /** Leading a new trick (must play). */ + leading: boolean; + lockedOut: boolean; + canConfirm: boolean; + /** Only in the viewer's own view (§8.3). */ + canDeclareRevolution?: boolean; + /** Cards to give back now (taxReturn). */ + owe: { to: PlayerId; count: number } | null; + /** Received tax cards still in hand (highlight). */ + receivedIds: string[]; + /** Default suggestion for the return: the highest number cards. */ + suggestedReturn: string[]; + } | null; + turnStartedAt: number; + deadlineTotalMs: number; + turnNo: number; + log: LogEntry[]; + result: DalmutiFinal | null; +} diff --git a/packages/games/src/index.ts b/packages/games/src/index.ts index 180c602..ee309d9 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 { dalmuti } from './dalmuti'; export const GAMES: Record = { [omok.id]: omok, @@ -35,4 +36,5 @@ export const GAMES: Record = { [bang.id]: bang, [halliGalli.id]: halliGalli, [marble.id]: marble, + [dalmuti.id]: dalmuti, }; diff --git a/packages/shared/src/catalog.ts b/packages/shared/src/catalog.ts index dded0fa..2d2001f 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: 'dalmuti', nameKo: '달무티', minPlayers: 4, maxPlayers: 8, minutes: '20~40', level: '쉬움', blurb: '낮은 숫자일수록 강해요! 먼저 다 털고 신분을 올리기', available: true }, ]; export const catalogById = (id: string): CatalogEntry | undefined => CATALOG.find((g) => g.id === id);