diff --git a/apps/web/src/components/cards/HwatuCard.tsx b/apps/web/src/components/cards/HwatuCard.tsx index 947efa6..662da45 100644 --- a/apps/web/src/components/cards/HwatuCard.tsx +++ b/apps/web/src/components/cards/HwatuCard.tsx @@ -145,7 +145,13 @@ export function HwatuCard({ - + {card.kind === 'bonus' ? ( + + {card.piValue ?? 2}피 + + ) : ( + + )} {card.kind === 'tti' && ( @@ -170,7 +176,7 @@ export function HwatuCard({ {card.month || '+'} - {MONTH_NAME[card.month]} · {card.kind === 'tti' ? (TTI_TEXT[card.tti ?? 'plain'] || '띠') : KIND_BADGE[card.kind] === '光' ? '광' : KIND_BADGE[card.kind] === '열' ? '열끗' : KIND_BADGE[card.kind]} + {MONTH_NAME[card.month]} · {card.kind === 'bonus' ? `${card.piValue ?? 2}피` : card.kind === 'tti' ? (TTI_TEXT[card.tti ?? 'plain'] || '띠') : KIND_BADGE[card.kind] === '光' ? '광' : KIND_BADGE[card.kind] === '열' ? '열끗' : KIND_BADGE[card.kind]} diff --git a/apps/web/src/games/gostop/index.tsx b/apps/web/src/games/gostop/index.tsx new file mode 100644 index 0000000..4c17a66 --- /dev/null +++ b/apps/web/src/games/gostop/index.tsx @@ -0,0 +1,1174 @@ +/** 고스톱 / 맞고 화면 (docs/games/gostop.md §9). Points are room-only play points. */ +import { useEffect, useRef, useState, type ReactNode } from 'react'; +import type { BakKind, CardView, GostopOptions, GostopView, HandCardView, OutcomeView, SeatView, TurnTag } from '@bg/games/gostop'; +import type { PublicUser } from '@bg/shared/lite'; +import { Avatar, Sheet } from '../../components/ui'; +import { TurnTimer } from '../../components/TurnTimer'; +import { HwatuCard, type HwatuInfo } from '../../components/cards/HwatuCard'; +import { ChipIcon } from '../../components/cards/Chips'; +import { haptic, sounds } from '../../lib/feedback'; +import type { BoardProps, GameUI, OptionsProps } from '../types'; + +const nick = (players: Record, id: string | null | undefined) => (id ? (players[id]?.nickname ?? '알 수 없음') : ''); +const userOf = (players: Record, id: string): PublicUser => players[id] ?? { id, nickname: '알 수 없음', avatar: null, kind: 'guest' }; +const signed = (n: number) => `${n > 0 ? '+' : ''}${n.toLocaleString('ko-KR')}점`; +const pts = (n: number) => `${n.toLocaleString('ko-KR')}점`; + +const NOTICE = '점수는 이 방에서만 쓰는 놀이용 점수예요. 실제 가치가 없고 바꾸거나 주고받을 수 없으며, 방이 끝나면 사라져요.'; +const BAK_KO: Record = { pi: '피박 x2', gwang: '광박 x2', meong: '멍박 x2', go: '고박' }; +const TAG_KO: Partial> = { + jjok: '쪽!', + ppeok: '뻑!', + ttadak: '따닥!', + sseul: '싹쓸이!', + bomb: '폭탄!', + shake: '흔들기!', + ppeokEat: '뻑 먹기!', + jaPpeok: '자뻑!', + firstPpeok: '첫뻑! +3점', + yeonPpeok: '연뻑! +6점', + samPpeok: '삼뻑!', + bonus: '보너스패', + bonusFlip: '보너스패', +}; +const KIND_LABEL = { gwang: '광', yeol: '열끗', tti: '띠', pi: '피' } as const; + +function useNarrow(): boolean { + const q = '(max-width: 719px)'; + const [narrow, setNarrow] = useState(() => typeof matchMedia !== 'undefined' && matchMedia(q).matches); + useEffect(() => { + const m = matchMedia(q); + const on = () => setNarrow(m.matches); + m.addEventListener('change', on); + return () => m.removeEventListener('change', on); + }, []); + return narrow; +} + +function totalMsOf(v: GostopView): number { + if (v.phase === 'roundResult') return 10_000; + const who = v.step === 'sell' ? v.sell?.current : v.turn; + if (who && v.seats.find((s) => s.id === who)?.autoPilot) return 2_000; + return (v.step === 'play' ? v.rules.turnSeconds : v.rules.decisionSeconds) * 1000; +} + +/** Whoever the table is waiting for (null on result screens). */ +function actorOf(v: GostopView): string | null { + if (v.phase !== 'playing') return null; + return v.step === 'sell' ? (v.sell?.current ?? null) : v.turn; +} + +function isMyTurn(v: GostopView, me: string | null): boolean { + if (!me || v.phase === 'finished') return false; + if (v.phase === 'roundResult') return !v.ready.includes(me) && !!v.seats.find((s) => s.id === me && !s.left); + return actorOf(v) === me; +} + +function statusText(v: GostopView, me: string | null, players: Record): string { + if (v.phase === 'finished') return v.result?.summary ?? '게임이 끝났어요'; + if (v.phase === 'roundResult') return isMyTurn(v, me) ? `${v.roundNo}판이 끝났어요. 다음 판 준비를 눌러 주세요` : '다른 사람이 다음 판을 준비하는 중이에요'; + const who = actorOf(v); + const mine = who === me && !!me; + const name = nick(players, who); + switch (v.step) { + case 'sell': + return mine ? '칠지, 죽고 광을 팔지 골라 주세요' : `${name}님이 칠지 고르는 중이에요`; + case 'chongtong': + return mine ? '총통이에요! 선언할지 골라 주세요' : `${name}님이 총통을 확인하는 중이에요`; + case 'play': + return mine ? '내 차례예요. 낼 패를 골라 주세요' : `${name}님 차례예요`; + case 'chooseFlip': + return mine ? '내 차례예요. 가져올 바닥 패를 골라 주세요' : `${name}님이 가져올 패를 고르는 중이에요`; + case 'goStop': + return mine ? `내 차례예요. ${v.goStop?.score ?? ''}점! 고 또는 스톱을 골라 주세요` : `${name}님이 고·스톱을 고르는 중이에요`; + case 'gukjin': + return mine ? '9월 열끗(국진)을 어떻게 쓸지 골라 주세요' : `${name}님이 국진을 정하는 중이에요`; + default: + return '잠시만 기다려 주세요'; + } +} + +const CSS = ` +.gs { width: 100%; max-width: 1100px; display: flex; flex-direction: column; gap: 8px; align-self: center; margin: auto 0; } +.gs-opps { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 8px; list-style: none; margin: 0; padding: 0; } +.gs-seat { background: var(--surface); color: var(--text); border-radius: 12px; padding: 6px 10px; border: 3px solid transparent; + box-shadow: var(--shadow); display: flex; flex-direction: column; gap: 4px; min-width: 0; } +.gs-seat.turn { border-color: var(--focus); } +.gs-seat.out { opacity: .65; } +.gs-head { display: flex; align-items: center; gap: 6px; min-width: 0; flex-wrap: wrap; } +.gs-head .nm { font-weight: 800; min-width: 0; max-width: 12em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.gs-head .sp { flex: 1; } +.gs-badges { display: flex; flex-wrap: wrap; gap: 4px; } +.gs-badge { display: inline-block; border-radius: 999px; padding: 0 8px; font-weight: 800; font-size: .8rem; line-height: 1.7; white-space: nowrap; + background: var(--surface-2); color: var(--text); } +.gs-badge.score { background: var(--primary); color: var(--primary-text); } +.gs-badge.go { background: #dc2626; color: #fff; } +.gs-badge.dealer { background: #facc15; color: #422006; } +.gs-badge.warn { background: var(--warning); color: #fff; } +.gs-backs { display: inline-flex; align-items: center; gap: 4px; font-weight: 800; font-variant-numeric: tabular-nums; } +.gs-backs i { display: inline-block; width: 12px; height: 18px; border-radius: 3px; background: #b91c1c; border: 1.5px solid #450a0a; } +.gs-cap { display: flex; flex-wrap: wrap; gap: 6px 12px; align-items: flex-end; padding: 0; border: 0; background: none; color: inherit; text-align: left; cursor: pointer; width: 100%; } +.gs-cap:disabled { cursor: default; } +.gs-pile { display: flex; flex-direction: column; gap: 1px; min-width: 40px; } +.gs-pile b { font-size: .78rem; font-weight: 800; color: var(--text-sub); white-space: nowrap; } +.gs-pile-cards { display: flex; } +.gs-pile-cards > * + * { margin-left: var(--ov, -30px); } +.gs-cap[aria-expanded='true'] .gs-pile-cards > * + * { --ov: -14px; } +.gs-table { border-radius: 20px; border: 8px solid var(--wood); box-shadow: var(--shadow); color: #fff; padding: 10px; + background: radial-gradient(ellipse at center, rgba(255,255,255,.12), rgba(255,255,255,0) 65%), var(--felt); + display: grid; grid-template-columns: minmax(0, 1fr) 210px; gap: 10px; align-items: stretch; } +.gs-floor { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 6px; list-style: none; margin: 0; padding: 0; } +.gs-slot { min-height: 104px; border-radius: 10px; border: 2px dashed rgba(255,255,255,.16); display: flex; align-items: center; justify-content: center; position: relative; } +.gs-slot .m { position: absolute; top: 1px; left: 6px; font-size: .72rem; font-weight: 700; color: rgba(255,255,255,.55); } +.gs-slot.has { border-style: solid; border-color: rgba(255,255,255,.28); } +.gs-slot.ppeok { border: 3px solid #f87171; background: rgba(239,68,68,.18); } +.gs-slot.ppeok::after { content: '뻑'; position: absolute; right: 4px; top: 2px; font-size: .75rem; font-weight: 900; color: #fecaca; } +.gs-slot.natural::after { content: '3장'; } +.gs-slot-cards { display: flex; } +.gs-slot-cards > * + * { margin-left: var(--sov, -40px); } +.gs-glow { animation: gs-blink .9s ease-in-out infinite; border-radius: 8px; } +@keyframes gs-blink { 0%, 100% { filter: drop-shadow(0 0 2px #fde047); } 50% { filter: drop-shadow(0 0 10px #fde047) brightness(1.08); } } +.gs-side { display: flex; flex-direction: column; gap: 6px; min-width: 0; } +.gs-deck { display: flex; align-items: center; gap: 10px; justify-content: center; text-align: center; } +.gs-deck strong { font-size: 1.1rem; } +.gs-info { font-size: .85rem; opacity: .92; } +.gs-last { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 6px; font-size: .85rem; min-height: 40px; flex: 1; + background: rgba(0,0,0,.18); border-radius: 10px; padding: 4px 8px; align-content: center; } +.gs-last .cards { display: inline-flex; gap: 2px; } +.gs-me { background: var(--surface); border-radius: 12px; padding: 6px 10px; box-shadow: var(--shadow); display: flex; flex-direction: column; gap: 6px; border: 3px solid transparent; } +.gs-me.turn { border-color: var(--focus); } +.gs-prog { display: flex; flex-wrap: wrap; gap: 4px; } +.gs-prog span { border-radius: 8px; padding: 1px 7px; background: var(--surface-2); font-size: .82rem; font-weight: 700; white-space: nowrap; } +.gs-prog span.done { background: var(--success-soft); color: var(--success); } +.gs-hand { display: flex; flex-wrap: wrap; gap: 6px; justify-content: center; } +.gs-hcard { position: relative; padding: 0; border: 3px solid transparent; border-radius: 11px; background: none; cursor: pointer; line-height: 0; transition: transform .12s ease; } +.gs-hcard[data-match='true'] { border-color: #16a34a; box-shadow: 0 0 0 2px rgba(22,163,74,.35); } +.gs-hcard[data-lifted='true'] { transform: translateY(-10px); border-color: var(--primary); } +.gs-hcard:disabled { cursor: default; } +.gs-hcard:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; } +.gs-hcard .tag { position: absolute; top: -9px; right: -7px; z-index: 1; line-height: 1.5; font-size: .72rem; font-weight: 900; border-radius: 999px; padding: 0 6px; + background: #dc2626; color: #fff; pointer-events: none; } +.gs-hcard .tag.shake { background: #7c3aed; } +.gs-hint { margin: 0; text-align: center; font-size: .9rem; color: var(--text-sub); } +.gs-flash { position: fixed; left: 50%; top: 36%; transform: translate(-50%, -50%); z-index: 30; pointer-events: none; text-align: center; + font-size: 3rem; font-weight: 900; color: #fde047; text-shadow: 0 3px 10px rgba(0,0,0,.7); animation: gs-pop .35s ease-out; white-space: nowrap; } +.gs-flash small { display: block; font-size: 1.1rem; color: #fff; } +@keyframes gs-pop { from { transform: translate(-50%, -50%) scale(.5); opacity: 0 } to { transform: translate(-50%, -50%) scale(1); opacity: 1 } } +.gs-big { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; } +.gs-big .btn { min-height: 76px; font-size: 1.3rem; flex-direction: column; gap: 2px; line-height: 1.2; } +.gs-big .btn small { font-size: .85rem; font-weight: 600; opacity: .95; } +.gs-pick { display: flex; gap: 16px; justify-content: center; flex-wrap: wrap; } +.gs-pick button { display: flex; flex-direction: column; align-items: center; gap: 6px; padding: 8px; border-radius: 12px; border: 3px solid var(--border); + background: var(--surface); color: var(--text); cursor: pointer; font-weight: 700; } +.gs-pick button:hover, .gs-pick button:focus-visible { border-color: var(--primary); } +.gs-panel { background: var(--surface); border-radius: 12px; padding: 12px; box-shadow: var(--shadow); display: flex; flex-direction: column; gap: 8px; } +.gs-res table { width: 100%; border-collapse: collapse; font-size: .95rem; } +.gs-res td { padding: 3px 4px; border-bottom: 1px solid var(--border); } +.gs-res td.n { text-align: right; font-variant-numeric: tabular-nums; font-weight: 700; white-space: nowrap; } +.gs-notice { background: var(--surface-2); border-radius: 10px; padding: 8px 12px; margin: 0; } +@media (max-width: 719px) { + .gs-opps { grid-template-columns: 1fr; } + .gs-table { grid-template-columns: 1fr; border-width: 5px; padding: 8px; } + .gs-floor { grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 4px; } + .gs-slot { min-height: 72px; } + .gs-slot-cards > * + * { --sov: -26px; } + .gs-me { padding: 6px 4px; } + .gs-hand { gap: 4px; } + .gs-hcard { border-width: 2px; } + .gs-flash { font-size: 2.4rem; } +} +`; + +// ---------------------------------------------------------------- small pieces + +function Card({ c, size = 'sm', className, style }: { c: CardView | null; size?: 'sm' | 'md' | 'lg'; className?: string; style?: React.CSSProperties }) { + const card = c as HwatuInfo | null; + return className ? ( + + + + ) : ( + + ); +} + +function CapturedRow({ seat, label }: { seat: SeatView; label: string }) { + const [open, setOpen] = useState(false); + const groups = (['gwang', 'yeol', 'tti', 'pi'] as const).filter((k) => seat.captured[k].length > 0); + if (groups.length === 0) return 먹은 패 없음; + return ( + + ); +} + +function Progress({ seat, winScore }: { seat: SeatView; winScore: number }) { + const s = seat.score; + const items: [string, boolean][] = [ + [`광 ${s.gwang}장`, s.gwang >= 3], + [`열끗 ${s.yeol}/5`, s.yeol >= 5], + [`띠 ${s.tti}/5`, s.tti >= 5], + [`피 ${s.pi}/10`, s.pi >= 10], + ]; + if (s.godori) items.push([`고도리 ${s.godori}/3`, s.godori === 3]); + if (s.hong) items.push([`홍단 ${s.hong}/3`, s.hong === 3]); + if (s.cheong) items.push([`청단 ${s.cheong}/3`, s.cheong === 3]); + if (s.cho) items.push([`초단 ${s.cho}/3`, s.cho === 3]); + return ( +
x[0]).join(', ')}. 지금 ${s.total}점, 기준 ${winScore}점`}> + {items.map(([t, done]) => ( + + {t} + + ))} +
+ ); +} + +function SeatBadges({ seat, v }: { seat: SeatView; v: GostopView }) { + return ( +
+ {seat.participant && 지금 {seat.score.total}점} + {seat.goCount > 0 && {seat.goCount}고} + {seat.shakes > 0 && 흔듦 {seat.shakes}} + {seat.bombs > 0 && 폭탄 {seat.bombs}} + {seat.ppeok > 0 && 뻑 {seat.ppeok}} + {seat.dummies > 0 && 빈패 {seat.dummies}} + {seat.seller && 광 팔기 {signed(seat.seller.paid)}} + {!seat.participant && !seat.seller && !seat.isDealer && v.step === 'sell' && 고르는 중} + {seat.left ? 나감 : seat.autoPilot ? 자동 : null} + {v.phase === 'roundResult' && seat.ready && 준비} +
+ ); +} + +function SeatBox({ seat, v, players, me, deadline }: { seat: SeatView; v: GostopView; players: Record; me: string | null; deadline: number | null }) { + const u = userOf(players, seat.id); + const active = actorOf(v) === seat.id; + return ( +
  • +
    + + + {u.nickname} + {seat.id === me && (나)} + + {seat.isDealer && 선} + + 합계 {signed(seat.total)} + + {(seat.participant || v.step === 'sell') && ( + + {seat.handCount} + + )} + + + {active && } +
    + {seat.revealed.length > 0 && ( +
    + 공개 + {seat.revealed.map((c) => ( + + ))} +
    + )} + {seat.seller && seat.seller.shown.length > 0 && ( +
    + 판 패 + {seat.seller.shown.map((c) => ( + + ))} +
    + )} + {seat.participant && } +
  • + ); +} + +// ---------------------------------------------------------------- table + +function Floor({ v, glow, narrow, players }: { v: GostopView; glow: Set; narrow: boolean; players: Record }) { + const months = Array.from({ length: 12 }, (_, i) => i + 1); + const pf = v.pendingFlip; + return ( +
    +
      + {months.map((m) => { + const g = v.floor.find((x) => x.month === m); + const cls = `gs-slot${g ? ' has' : ''}${g && g.cards.length === 3 ? (g.natural ? ' ppeok natural' : ' ppeok') : ''}`; + const label = g + ? `${m}월 ${g.cards.length}장${g.cards.length === 3 ? (g.natural ? ', 처음 깔린 3장' : `, ${nick(players, g.ppeokBy)}님이 싼 뻑`) : ''}: ${g.cards.map((c) => c.name).join(', ')}` + : `${m}월 없음`; + return ( +
    • + + {m}월 + + {g && ( + + {g.cards.map((c) => ( + + ))} + + )} +
    • + ); + })} +
    +
    +
    +
    + + {v.deckCount}장 +
    +
    + {v.roundNo}/{v.rules.rounds}판 +
    + {v.mode === 'matgo' ? '맞고' : '고스톱'} · {v.rules.winScore}점 나기 + {v.nagariMult > 1 && ( + <> +
    + 나가리 x{v.nagariMult} + + )} +
    + {pf && ( +
    +
    뒤집은 패
    + +
    + )} +
    + +
    +
    + ); +} + +function LastTurn({ v, players }: { v: GostopView; players: Record }) { + const t = v.lastTurn; + if (!t) return
    ; + const tags = t.tags.map((x) => TAG_KO[x]).filter(Boolean); + const stolen = t.stolen.reduce((a, s) => a + s.cards.length, 0); + return ( +
    + {nick(players, t.player)} + {t.played.length > 0 && ( + <> + 냄 + + {t.played.map((c) => ( + + ))} + + + )} + {t.flipped.length > 0 && ( + <> + 뒤집음 + + {t.flipped.map((c) => ( + + ))} + + + )} + {t.captured.length > 0 ? `· ${t.captured.length}장 먹음` : '· 못 먹음'} + {tags.length > 0 && {tags.join(' ')}} + {stolen > 0 && 피 {stolen}장 뺏음} +
    + ); +} + +function flashText(v: GostopView, players: Record): { big: string; small: string } | null { + const t = v.lastTurn; + if (!t) return null; + const order: TurnTag[] = ['samPpeok', 'yeonPpeok', 'firstPpeok', 'bomb', 'ttadak', 'jaPpeok', 'ppeokEat', 'ppeok', 'jjok', 'sseul', 'shake', 'bonus', 'bonusFlip']; + // 첫뻑·연뻑·삼뻑 already say 뻑. + const special = t.tags.some((x) => x === 'firstPpeok' || x === 'yeonPpeok' || x === 'samPpeok'); + const tags = order.filter((x) => t.tags.includes(x) && !(special && x === 'ppeok')); + if (!tags.length && !t.stolen.length) return null; + const big = tags.length ? tags.map((x) => TAG_KO[x]).slice(0, 2).join(' ') : '피 뺏기'; + const stolen = t.stolen.reduce((a, s) => a + s.cards.length, 0); + return { big, small: `${nick(players, t.player)}님${stolen ? ` · 피 ${stolen}장 뺏음` : ''}` }; +} + +// ---------------------------------------------------------------- my area + +type Ask = { kind: 'target' | 'shake' | 'bomb'; h: HandCardView } | null; + +function MyHand({ v, act, pending, tapConfirm, onFocusCard }: { v: GostopView; act: (a: unknown) => void; pending: boolean; tapConfirm: boolean; onFocusCard: (h: HandCardView | null) => void }) { + const [lifted, setLifted] = useState(null); + const [ask, setAsk] = useState(null); + const canPlay = v.step === 'play' && v.turn === v.me && !pending; + const hints = v.rules.hints; + useEffect(() => { + setLifted(null); + setAsk(null); + }, [v.turn, v.step, v.roundNo, v.lastTurn?.seq]); + const hand = v.myHand ?? []; + const send = (a: object) => { + sounds.stone(); + act({ type: 'play', ...a }); + setLifted(null); + setAsk(null); + }; + const tap = (h: HandCardView) => { + if (!canPlay) return; + if (tapConfirm && lifted !== h.card.id) { + setLifted(h.card.id); + onFocusCard(h); + return; + } + if (h.card.kind === 'bonus') send({ card: h.card.id }); + else if (h.canBomb) setAsk({ kind: 'bomb', h }); + else if (h.canShake) setAsk({ kind: 'shake', h }); + else if (h.needTarget) setAsk({ kind: 'target', h }); + else send({ card: h.card.id }); + }; + const liftedCard = hand.find((h) => h.card.id === lifted); + const floorCards = (ids: number[]) => v.floor.flatMap((g) => g.cards).filter((c) => ids.includes(c.id)); + return ( + <> +
    + {hand.map((h) => { + const match = hints && h.matches.length > 0; + const tag = h.canBomb ? '폭탄' : h.canShake ? '흔들기' : null; + const label = `${h.card.name}${h.matches.length ? `, 바닥 ${h.matches.length}장과 맞음` : ''}${tag ? `, ${tag} 가능` : ''}${h.hint ? `, ${h.hint}` : ''}`; + return ( + + ); + })} + {v.myDummies > 0 && ( + + )} +
    + {canPlay && liftedCard && ( +

    + {`${liftedCard.card.name}${liftedCard.hint ? ` · ${liftedCard.hint}` : ''} — 한 번 더 누르면 내요`} +

    + )} + {ask && ( + setAsk(null)} label={ask.kind === 'target' ? '가져올 패 고르기' : ask.kind === 'shake' ? '흔들기' : '폭탄'}> + {ask.kind === 'target' && ( + <> +

    어느 패를 가져올까요?

    +

    {ask.h.card.name}을(를) 내고 바닥의 같은 월 두 장 중 하나를 가져와요.

    +
    + {floorCards(ask.h.matches).map((c) => ( + + ))} +
    + + )} + {ask.kind === 'shake' && ( + <> +

    흔들까요?

    +

    같은 월 3장을 모두에게 보여 주고 내요. 이번 판을 이기면 점수가 2배가 돼요.

    +
    + + +
    + + )} + {ask.kind === 'bomb' && ( + <> +

    폭탄을 할까요?

    +

    같은 월 3장을 한꺼번에 내서 바닥 1장까지 4장을 가져오고, 상대마다 피 1장을 받아요. 이기면 x2, 빈패 2장을 받아요.

    +
    + + +
    + + )} +
    + )} + + ); +} + +function MyArea(props: BoardProps & { onFocusCard: (h: HandCardView | null) => void }) { + const { view: v, me, players, act, pending, deadline, tapConfirm, onFocusCard } = props; + const seat = v.seats.find((s) => s.id === me); + if (!seat || !me) return null; + const active = actorOf(v) === me; + if (v.phase !== 'playing') return null; + return ( +
    +
    + + 나 · 합계 {signed(seat.total)} + {seat.isDealer && 선} + + {seat.participant && } + + {active && } +
    + {seat.participant && } + {seat.revealed.length > 0 && 흔들어서 공개한 패는 다른 사람도 볼 수 있어요.} + {v.step === 'sell' && } + {seat.participant && v.myHand && v.step !== 'sell' && } + {!seat.participant && v.step !== 'sell' &&

    이번 판은 광을 팔고 쉬어요. 다른 사람의 판을 지켜봐요.

    } +
    + ); +} + +function SellPanel({ v, me, players, act, pending }: { v: GostopView; me: string; players: Record; act: (a: unknown) => void; pending: boolean }) { + const s = v.sell!; + const mine = s.current === me; + const payers = v.rules.gwangPayer === 'nonDealerPlayers' ? 2 : 3; + const n = s.mySellable?.length ?? 0; + const gain = n * v.rules.gwangPrice * payers; + return ( +
    + 광팔기: 3명만 치고 나머지는 죽어요(선은 반드시 쳐요). + {s.decisions.length > 0 && ( + + {s.decisions.map((d) => `${nick(players, d.player)} ${d.sell ? '죽기' : '치기'}${d.auto ? '(자동)' : ''}`).join(' · ')} + + )} + {v.myHand && ( +
    + {v.myHand.map((h) => ( + c.id === h.card.id)} /> + ))} +
    + )} + {mine ? ( +
    + + +
    + ) : ( + {s.current ? `${nick(players, s.current)}님이 고르는 중이에요.` : ''} + )} +
    + ); +} + +// ---------------------------------------------------------------- decisions (sheets) + +function Decisions({ view: v, me, players, act, pending }: BoardProps) { + if (!me || v.phase !== 'playing') return null; + const nop = () => {}; + if (v.step === 'goStop' && v.goStop && v.turn === me) { + const g = v.goStop; + const baks = (b: BakKind[]) => (b.length ? ` (${b.map((x) => BAK_KO[x]).join(', ')})` : ''); + return ( + +

    {g.score}점 났어요! 고 할까요, 스톱 할까요?

    +

    + 지금 스톱하면 {pts(g.stopNow.total)}을 받아요{g.stopNow.notes.length ? ` · ${g.stopNow.notes.join(', ')}` : ''} +

    +
      + {g.stopNow.perLoser.map((x) => ( +
    • + {nick(players, x.player)}님: {pts(x.amount)} + {baks(x.baks)} +
    • + ))} +
    +

    + 고 하면 {g.goNext.goCount}고: +{g.goNext.plus}점{g.goNext.factor > 1 ? `, 점수 x${g.goNext.factor}` : ''}. 다른 사람이 먼저 나면 고박이 될 수 있어요. +

    +
    + + +
    +
    + ); + } + if (v.step === 'chooseFlip' && v.pendingFlip && v.turn === me) { + const pf = v.pendingFlip; + return ( + +

    뒤집은 {pf.flipped.name}과(와) 짝지을 패를 골라 주세요

    +
    + {pf.options.map((c) => ( + + ))} +
    +
    + ); + } + if (v.step === 'chongtong' && v.chongtongAsk?.player === me) { + const months = v.chongtongAsk.months ?? []; + const others = v.seats.filter((s) => s.participant && s.id !== me).length; + return ( + +

    총통이에요! ({months.map((m) => `${m}월`).join(', ')} 4장)

    +

    선언하면 이번 판을 바로 이기고 상대마다 {pts(v.rules.chongtongPoints * v.nagariMult)}을 받아요. 그냥 치면 그 월로 흔들기를 할 수 있어요.

    +
    + + +
    +
    + ); + } + if (v.step === 'gukjin' && v.gukjinAsk?.player === me) { + const g = v.gukjinAsk; + return ( + +

    9월 열끗(국진)을 어떻게 쓸까요?

    +

    열끗 1장 또는 쌍피(피 2장 값)로 셀 수 있어요. 시간이 지나면 나에게 유리한 쪽으로 정해져요.

    +
    + + +
    +
    + ); + } + return null; +} + +// ---------------------------------------------------------------- round result + +function outcomeTitle(o: OutcomeView, players: Record, me: string | null): string { + if (o.type === 'nagari') return '나가리예요'; + const who = o.winner === me ? '내가' : `${nick(players, o.winner)}님이`; + if (o.type === 'chongtong') return `총통! ${who} 이겼어요`; + if (o.type === 'samppeok') return `삼뻑! ${who} 이겼어요`; + return `${who} 스톱해서 이겼어요`; +} + +function RoundResult({ view: v, me, players, act, pending }: BoardProps) { + const o = v.outcome; + if (!o || v.phase === 'playing') return null; + const seat = v.seats.find((s) => s.id === me); + const nextMult = v.rules.nagariMultiplier === 'x2' ? Math.min(2 ** v.nagariStreak, v.rules.nagariMaxMultiplier) : 1; + return ( +
    +

    + {v.roundNo}판 결과 · {outcomeTitle(o, players, me)} +

    + {o.type === 'nagari' &&

    아무도 나지 못했어요. 선은 그대로이고 다음 판은 점수 x{nextMult}이에요.

    } + {o.shown.length > 0 && ( +
    + {o.shown.map((c) => ( + + ))} +
    + )} + {o.type !== 'nagari' && ( + + + {o.breakdown.map((b) => ( + + + + + ))} + + + + + {o.multipliers.map((m) => ( + + + + + ))} + + + + + {o.payments.map((p) => ( + + + + + ))} + +
    {b.name}+{b.points}점
    + 합계 + {o.baseScore + (o.type === 'stop' ? o.goCount : 0)}점
    {m.name}x{m.factor}
    + 패자 1명당 + {pts(o.perLoser)}
    + {nick(players, p.from)}님이 냄{p.baks.length ? ` · ${p.baks.map((b) => (b === 'go' ? (v.mode === 'matgo' ? '고박 x2' : '독박') : BAK_KO[b])).join(', ')}` : ''} + {p.coveredBy ? ` · ${nick(players, p.coveredBy)}님이 대신 냄` : ''} + {pts(p.amount)}
    + )} + + + {v.seats.map((s) => ( + + + + + + ))} + +
    + {nick(players, s.id)} + {s.id === me ? ' (나)' : ''} + {signed(o.delta[s.id] ?? 0)}합계 {signed(s.total)}
    + {v.phase === 'roundResult' && seat && !seat.left && ( + + )} +
    + ); +} + +// ---------------------------------------------------------------- board + +function GostopBoard(props: BoardProps) { + const { view: v, me, players } = props; + const narrow = useNarrow(); + const [focus, setFocus] = useState(null); + const [flash, setFlash] = useState<{ big: string; small: string } | null>(null); + const [announce, setAnnounce] = useState(''); + const mine = isMyTurn(v, me); + const prevMine = useRef(false); + const seq = `${v.roundNo}-${v.lastTurn?.seq ?? 0}`; + useEffect(() => { + const f = flashText(v, players); + if (v.lastTurn) { + const t = v.lastTurn; + setAnnounce( + `${nick(players, t.player)}님: ${t.played.map((c) => c.name).join(', ')}${t.played.length ? ' 냄, ' : ''}${t.flipped.map((c) => c.name).join(', ')} 뒤집음, ${t.captured.length}장 먹음${f ? `. ${f.big}` : ''}`, + ); + } + setFlash(f); + if (!f) return undefined; + const tm = setTimeout(() => setFlash(null), 1300); + return () => clearTimeout(tm); + }, [seq]); // eslint-disable-line react-hooks/exhaustive-deps + useEffect(() => { + if (mine && !prevMine.current && v.phase === 'playing') { + haptic(40); + sounds.myTurn(); + setAnnounce('내 차례예요.'); + } + prevMine.current = mine; + }, [mine]); // eslint-disable-line react-hooks/exhaustive-deps + useEffect(() => { + if (v.outcome && v.phase !== 'playing') { + if (v.outcome.winner === me) sounds.win(); + setAnnounce(`${v.roundNo}판 결과: ${outcomeTitle(v.outcome, players, me)}`); + } + }, [v.roundNo, v.phase]); // eslint-disable-line react-hooks/exhaustive-deps + const myIdx = v.seats.findIndex((s) => s.id === me); + const others = myIdx < 0 ? v.seats : [...v.seats.slice(myIdx + 1), ...v.seats.slice(0, myIdx)]; + const glow = new Set(v.rules.hints && focus ? focus.matches : []); + if (v.pendingFlip) for (const c of v.pendingFlip.options) glow.add(c.id); + return ( +
    + +
      + {others.map((s) => ( + + ))} +
    + + + + + {flash && v.phase === 'playing' && ( +
    + {flash.big} + {flash.small} +
    + )} +

    + {announce} +

    +
    + ); +} + +// ---------------------------------------------------------------- side + +function GostopSide({ view: v, me, players, deadline }: BoardProps) { + const ranked = [...v.seats].sort((a, b) => b.total - a.total); + const mySeat = v.seats.find((s) => s.id === me); + return ( + <> + +

    + {NOTICE} +

    +
    +
    + + {v.phase === 'finished' ? '게임 끝' : `${v.roundNo}판 / ${v.rules.rounds}판`} · {v.mode === 'matgo' ? '맞고' : v.seats.length > 3 ? '고스톱(광팔기)' : '고스톱'} + + {v.phase !== 'finished' && } +
    + + {v.rules.winScore}점부터 고·스톱 · 피박 {v.rules.piBakLimit} 이하{v.nagariMult > 1 ? ` · 이번 판 나가리 x${v.nagariMult}` : ''} + +
      + {ranked.map((s) => ( +
    1. + + + {nick(players, s.id)} + {s.isDealer ? ' · 선' : ''} + {s.left ? ' · 나감' : s.autoPilot ? ' · 자동' : ''} + + {signed(s.total)} +
    2. + ))} +
    +
    + {mySeat?.participant && v.phase === 'playing' && ( +
    + + 내 점수 {mySeat.score.total}점 / 기준 {v.rules.winScore}점 + + {mySeat.score.items.length > 0 ? ( + {mySeat.score.items.map((x) => `${x.name} ${x.points}점`).join(' · ')} + ) : ( + 아직 점수 족보가 없어요. + )} +
    + )} + {v.log.length > 0 && ( +
    + 판 기록 +
      + {[...v.log].reverse().map((l) => ( +
    1. + {l.no}판{' '} + {l.type === 'nagari' ? '나가리' : `${nick(players, l.winner)} ${pts(l.amount)}${l.type === 'stop' ? '' : l.type === 'chongtong' ? ' · 총통' : ' · 삼뻑'}`} + {l.breakdown.length > 0 && ({l.breakdown.join(', ')})} +
    2. + ))} +
    +
    + )} + {v.result && ( +
    + 최종 순위 + {v.result.ranking.map((g, i) => ( + + {v.result!.ranking.slice(0, i).reduce((a, x) => a + x.length, 1)}위 {g.map((id) => nick(players, id)).join(', ')} ({signed(v.result!.scores[g[0]!] ?? 0)}) + + ))} + {v.stats.bestRound && ( + + 가장 큰 판: {v.stats.bestRound.roundNo}판 {nick(players, v.stats.bestRound.player)} {pts(v.stats.bestRound.amount)} + + )} +
    + )} +
    + 족보표 + +
    + + ); +} + +// ---------------------------------------------------------------- rules & options + +/** 월별 카드 구성 (§3.1): G 광, Y 열끗, H 홍단, C 청단, O 초단, T 띠(비), P 피, S 쌍피. */ +const MONTH_CODES = ['GHPP', 'YHPP', 'GHPP', 'YOPP', 'YOPP', 'YCPP', 'YOPP', 'GYPP', 'YCPP', 'YCPP', 'GSPP', 'GYTS']; +const MONTH_NAMES = ['송학', '매조', '벚꽃', '흑싸리', '난초', '모란', '홍싸리', '공산', '국화', '단풍', '오동', '비']; + +function illus(m: number, slot: number): HwatuInfo { + const code = MONTH_CODES[m - 1]![slot]!; + const id = (m - 1) * 4 + slot; + const base = { id, month: m }; + switch (code) { + case 'G': + return { ...base, kind: 'gwang', name: `${m}월 광(${MONTH_NAMES[m - 1]})` }; + case 'Y': + return { ...base, kind: 'yeol', name: `${m}월 열끗` }; + case 'H': + return { ...base, kind: 'tti', tti: 'hong', name: `${m}월 홍단` }; + case 'C': + return { ...base, kind: 'tti', tti: 'cheong', name: `${m}월 청단` }; + case 'O': + return { ...base, kind: 'tti', tti: 'cho', name: `${m}월 초단` }; + case 'T': + return { ...base, kind: 'tti', tti: 'plain', name: `${m}월 띠` }; + case 'S': + return { ...base, kind: 'ssangpi', name: `${m}월 쌍피` }; + default: + return { ...base, kind: 'pi', name: `${m}월 피` }; + } +} + +function JokboTable({ rules }: { rules: Pick }) { + const row = (name: string, cards: [number, number][], note: string): ReactNode => ( + + + {cards.map(([m, s]) => ( + + ))} + + + {name} +
    {note}
    + + + ); + return ( + + + {row('오광', [[1, 0], [3, 0], [8, 0], [11, 0], [12, 0]], `광 5장 ${rules.ogwangPoints}점`)} + {row('사광', [[1, 0], [3, 0], [8, 0], [11, 0]], '광 4장 4점(비광 포함 가능)')} + {row('삼광', [[1, 0], [3, 0], [8, 0]], `비광 없이 3장 3점 · 비광 끼면 ${rules.biGwangThree ? `${rules.biGwangThree}점` : '점수 없음'}`)} + {row('고도리', [[2, 0], [4, 0], [8, 1]], '새 3마리 5점(열끗 장수에도 셈)')} + {row('홍단', [[1, 1], [2, 1], [3, 1]], '3점')} + {row('청단', [[6, 1], [9, 1], [10, 1]], '3점')} + {row('초단', [[4, 1], [5, 1], [7, 1]], '3점')} + {row('열끗', [[5, 0], [6, 0], [7, 0], [10, 0], [12, 1]], '5장 1점, 1장마다 +1점 · 7장이면 멍따 x2')} + {row('띠', [[1, 1], [4, 1], [6, 1], [7, 1], [12, 2]], '5장 1점, 1장마다 +1점')} + {row('피', [[1, 2], [11, 1], [12, 3]], '피 값 10에 1점, 1마다 +1점 · 쌍피는 2')} + +
    + ); +} + +function Rules({ options }: { options?: GostopOptions }) { + const o = options; + return ( +
    +

    + 화투 48장으로 바닥 패와 같은 월을 맞춰 가져오고, 가져온 패로 족보를 만들어 점수를 내요. 2명이면 맞고(7점부터), 3명이면 고스톱(3점부터), 4~5명이면 3명만 치고 나머지는 광을 팔아요. 점수는 이 방에서만 쓰는 놀이용 점수예요. +

    +

    + 한 턴: 손패 1장을 내고 더미에서 1장을 뒤집어요. 낸 패·뒤집은 패가 바닥의 같은 월 패와 맞으면 함께 가져와요. 바닥에 같은 월이 2장이면 하나를 골라요. +

    +

    + 특별한 일: 쪽(낸 패를 바로 뒤집어 먹음), 따닥(바닥 2장 + 손 + 뒤집은 패 4장), 싹쓸이(바닥이 빔)는 상대마다 피 1장을 뺏어요. 뻑은 3장이 바닥에 쌓이고, 4번째 패로 먹으면 피를 뺏어요(내가 싼 뻑을 내가 먹으면 자뻑으로 2장). 첫 턴 뻑은 첫뻑 3점, 한 판에 뻑 3번이면 삼뻑으로 바로 이겨요. +

    +

    + 흔들기·폭탄: 같은 월 3장을 가졌을 때 바닥에 그 월이 없으면 흔들고 낼 수 있고(이기면 x2), 바닥에 1장 있으면 3장을 한꺼번에 내는 폭탄(x2, 피 1장씩, 빈패 2장)을 할 수 있어요. +

    +

    + 고·스톱: 내 점수가 기준 점수 이상이 되면 스톱(끝내고 이김) 또는 고(계속, 1고·2고는 +1점씩, 3고부터 x2, x4…)를 골라요. 고를 한 뒤에는 그때보다 점수가 커져야 다시 고를 수 있어요. 마지막 턴에는 자동으로 스톱해요. +

    +

    + 박: 이긴 사람이 피 점수가 있고 진 사람 피 값이 {!o || o.piBakLimit === 'auto' ? '맞고 7·고스톱 5' : o.piBakLimit} 이하면 피박 x2, 이긴 사람이 광 점수가 있고 진 사람이 광이 없으면 광박 x2, 고를 했다가 지면 고박(맞고 x2, 3명이면 혼자 다 내는 독박)이에요. +

    +

    + 나가리: 아무도 나지 못하면 점수 이동 없이 다음 판 점수가 2배가 돼요(연속이면 누적). 총통(같은 월 4장)은 선언하면 바로 이겨요. +

    +

    + 국진: 9월 열끗은 열끗 또는 쌍피로 쓸 수 있어요. {o?.gukjinMode === 'ask' ? '판이 끝날 때 직접 골라요.' : o?.gukjinMode === 'yeolOnly' ? '이 방은 열끗으로만 써요.' : '점수에 유리한 쪽으로 자동으로 정해요.'} +

    + 월별 패 +
    + {MONTH_NAMES.map((name, i) => ( +
    + + {i + 1}월 {name} + + {[0, 1, 2, 3].map((s) => ( + + ))} +
    + ))} +
    + 족보 + +

    시간 안에 내지 않으면 바닥과 맞는 패 중 가장 좋은 패를 대신 내고, 고·스톱은 스톱으로 정해요. 두 번 연속 시간을 넘기면 2초 만에 자동으로 진행돼요.

    +
    + ); +} + +function Seg({ value, options, onChange, label }: { value: T; options: [T, string][]; onChange: (v: T) => void; label: string }) { + return ( +
    + {options.map(([k, l]) => ( + + ))} +
    + ); +} + +function Field({ label, children }: { label: string; children: ReactNode }) { + return ( +
    + {label} + {children} +
    + ); +} + +const onOff = (label: string, value: boolean, set: (v: boolean) => void, on = '켜기', off = '끄기') => ( + + + +); + +function Options({ value: o, onChange }: OptionsProps) { + const [more, setMore] = useState(false); + const set = (patch: Partial) => onChange({ ...o, ...patch }); + return ( +
    +

    + 2명이면 맞고, 3명이면 고스톱, 4~5명이면 3명만 치고 나머지는 광을 팔아요. +

    + + set({ rounds })} /> + + + set({ winScore })} /> + + + set({ turnSeconds })} /> + + + {more && ( + <> + + set({ bonusCards })} /> + + + set({ decisionSeconds })} /> + + + set({ piBakLimit })} /> + + {onOff('피가 없으면 피박 면제', o.piBakZeroExempt, (piBakZeroExempt) => set({ piBakZeroExempt }), '면제', '피박')} + {onOff('광박', o.gwangBak, (gwangBak) => set({ gwangBak }))} + {onOff('멍따(열끗 7장 x2)', o.meongTta, (meongTta) => set({ meongTta }))} + {onOff('멍박', o.meongBak, (meongBak) => set({ meongBak }))} + {onOff('고박(맞고 x2, 3명 독박)', o.goBak, (goBak) => set({ goBak }))} + + set({ biGwangThree })} /> + + + set({ ogwangPoints })} /> + + + set({ gukjinMode })} /> + + {onOff('흔들기', o.shake, (shake) => set({ shake }))} + {onOff('폭탄', o.bomb, (bomb) => set({ bomb }))} + + set({ chongtong })} /> + + + set({ chongtongPoints })} /> + + {onOff('첫뻑·연뻑·삼뻑', o.ppeokBonus, (ppeokBonus) => set({ ppeokBonus }))} + + set({ jaPpeokSteal })} /> + + {onOff('마지막 턴 쓸·쪽 피 뺏기', o.lastTurnSweepBonus, (lastTurnSweepBonus) => set({ lastTurnSweepBonus }), '인정', '안 함')} + {onOff('처음 깔린 3장 먹으면 피 뺏기', o.naturalThreeSteal, (naturalThreeSteal) => set({ naturalThreeSteal }), '뺏기', '안 함')} + + set({ nagariMultiplier })} /> + {o.nagariMultiplier === 'x2' && ( + set({ nagariMaxMultiplier })} /> + )} + + {onOff('4~5명이면 광팔기', o.gwangPalgi, (gwangPalgi) => set({ gwangPalgi }), '광팔기', '3명까지만')} + {o.gwangPalgi && ( + <> + + set({ gwangPrice })} /> + + + set({ gwangPayer })} /> + + + set({ gwangSellCards })} /> + + + )} + {onOff('먹을 수 있는 패·족보 힌트', o.hints, (hints) => set({ hints }), '보여 주기', '숨기기')} + + )} +
    + ); +} + +function optionsSummary(o: GostopOptions): string { + return [ + `${o.rounds}판`, + o.winScore === 'auto' ? '맞고 7점·고스톱 3점' : `${o.winScore}점 나기`, + `${o.turnSeconds}초`, + o.bonusCards === 'none' ? '보너스패 없음' : o.bonusCards === 'auto' ? '' : `보너스 ${o.bonusCards}`, + o.gwangPalgi ? '' : '광팔기 없음', + ] + .filter(Boolean) + .join(' · '); +} + +const ui: GameUI = { + Board: GostopBoard, + Side: GostopSide, + 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 570d217..115c0b1 100644 --- a/apps/web/src/games/registry.ts +++ b/apps/web/src/games/registry.ts @@ -14,6 +14,7 @@ export const GAME_UI: Record Promise<{ default: GameUI }>> = { 'one-card': () => import('./one-card'), rummikub: () => import('./rummikub'), 'davinci-code': () => import('./davinci-code'), + gostop: () => import('./gostop'), }; const cache = new Map(); diff --git a/apps/web/src/lib/catalog-ui.ts b/apps/web/src/lib/catalog-ui.ts index 0124b59..8dce15c 100644 --- a/apps/web/src/lib/catalog-ui.ts +++ b/apps/web/src/lib/catalog-ui.ts @@ -12,7 +12,8 @@ export const GAME_ICON: Record = { 'one-card': '♣', rummikub: '🔢', 'davinci-code': '🔐', + gostop: '🌸', }; /** Shown on the home screen so users know what's coming (docs/12-roadmap.md). */ -export const UPCOMING = ['마작', '고스톱', '뱅', '할리갈리']; +export const UPCOMING = ['마작', '뱅', '할리갈리']; diff --git a/e2e/games.e2e.ts b/e2e/games.e2e.ts index 8a6fd27..ece8fa8 100644 --- a/e2e/games.e2e.ts +++ b/e2e/games.e2e.ts @@ -150,7 +150,112 @@ async function yutStep(p: Page): Promise { } } + +/** + * 고스톱: one UI step for `p` if it has something to do — answer an open decision sheet (고/스톱: 고 once, then 스톱; + * choose a floor card; 흔들기/폭탄; 총통 → 그냥 치기; 국진) or play a hand card (green-bordered match first; phones + * use tap-to-lift, so a lifted card is tapped again). Returns what it did, or null. + */ +async function gostopStep(p: Page, state: { go: number; stop: number; picks: number; plays: number; sheets: number }): Promise { + const dialog = p.getByRole('dialog'); + if (await dialog.count()) { + const name = (await dialog.first().getAttribute('aria-label')) ?? ''; + if (name === '게임 결과') return null; + if (name === '고 또는 스톱') { + const goFirst = state.go === 0; + if (goFirst && state.stop === 0) await p.screenshot({ path: join(SHOTS, 'gostop-gostop-dialog.png') }); + await dialog.getByRole('button', { name: goFirst ? /^고/ : /^스톱/ }).click({ timeout: 1500 }); + if (goFirst) state.go++; + else state.stop++; + return goFirst ? 'go' : 'stop'; + } + if (name === '총통') { + await dialog.getByRole('button', { name: /^그냥 치기/ }).click({ timeout: 1500 }); + return 'chongtong'; + } + const pick = dialog.locator('.gs-pick button, .gs-big .btn').first(); + if (await pick.count()) { + if (state.sheets === 0) await p.screenshot({ path: join(SHOTS, 'gostop-choice-dialog.png') }); + await pick.click({ timeout: 1500 }); + state.sheets++; + if (name.includes('고르기')) state.picks++; + return name; + } + return null; + } + const status = (await p.locator('.turn-status').innerText().catch(() => '')).trim(); + if (!status.includes('내 차례예요')) return null; + const card = p.locator('.gs-hcard[data-match="true"]:not([disabled])').first(); + const any = p.locator('.gs-hcard:not([disabled])').first(); + const target = (await card.count()) ? card : any; + if (!(await target.count())) { + const dummy = p.getByRole('button', { name: /^빈패 내기/ }); + if (await dummy.isEnabled().catch(() => false)) { + await dummy.click({ timeout: 1500 }); + state.plays++; + return 'dummy'; + } + return null; + } + const label = await target.getAttribute('aria-label'); + await target.click({ timeout: 1500 }); + const lifted = p.locator(`.gs-hcard[data-lifted="true"][aria-label="${label}"]`); + if (await lifted.count()) await lifted.click({ timeout: 1500 }); + state.plays++; + return 'play'; +} + const PLANS: Record = { + gostop: { + // 맞고: PC host + phone guest, 1판. Real turns through the UI — hand cards (tap twice on the phone), floor-card choices, + // 흔들기/폭탄 sheets, one 고 and then 스톱 — until the hand ends (스톱 or 나가리) and the game result shows. + lobby: async (host) => { + await host.getByRole('group', { name: '판 수' }).getByRole('button', { name: '1판', exact: true }).click(); + await host.getByRole('button', { name: '설정 저장' }).click(); + await host.getByText(/^1판 · /).first().waitFor(); + }, + drive: async (pages) => { + const [host, guest] = pages as [Page, Page]; + const state = { go: 0, stop: 0, picks: 0, plays: 0, sheets: 0 }; + let shot = false; + const until = Date.now() + 240_000; + const over = async () => (await host.getByRole('dialog', { name: '게임 결과' }).count()) > 0; + while (Date.now() < until && !(await over())) { + let acted = false; + for (const p of pages) { + const did = await gostopStep(p, state).catch(() => null); + if (did) { + acted = true; + await p.waitForTimeout(250); + break; + } + } + if (state.plays >= 8 && !shot) { + shot = true; + await host.waitForTimeout(1500); + await host.screenshot({ path: join(SHOTS, 'gostop-mid-pc.png') }); + await guest.screenshot({ path: join(SHOTS, 'gostop-mid-phone.png'), fullPage: true }); + await guest.emulateMedia({ colorScheme: 'dark' }); + await host.emulateMedia({ colorScheme: 'dark' }); + await guest.screenshot({ path: join(SHOTS, 'gostop-mid-phone-dark.png'), fullPage: true }); + await host.screenshot({ path: join(SHOTS, 'gostop-mid-pc-dark.png') }); + await guest.emulateMedia({ colorScheme: 'light' }); + await host.emulateMedia({ colorScheme: 'light' }); + } + if (!acted) await host.waitForTimeout(150); + } + if (!(await over())) throw new Error(`hand did not end (${JSON.stringify(state)})`); + await guest.getByRole('dialog', { name: '게임 결과' }).waitFor(); + await host.screenshot({ path: join(SHOTS, 'gostop-result-pc.png') }); + await guest.screenshot({ path: join(SHOTS, 'gostop-result-phone.png') }); + // Close the result sheet to see the round breakdown underneath. + await host.keyboard.press('Escape'); + await host.getByText(/1판 결과/).first().waitFor(); + await host.screenshot({ path: join(SHOTS, 'gostop-breakdown-pc.png') }); + if (state.plays < 10) throw new Error(`only ${state.plays} cards were played`); + console.log(` gostop: ${state.plays} plays, ${state.sheets} choice sheets (${state.picks} card picks), 고 ${state.go}, 스톱 ${state.stop}`); + }, + }, rummikub: { // PC host + phone guest, first meld "없음" so hint plays happen early; 10 real turns through the UI. lobby: async (host) => { diff --git a/packages/games/src/gostop/cards.ts b/packages/games/src/gostop/cards.ts new file mode 100644 index 0000000..64dcd27 --- /dev/null +++ b/packages/games/src/gostop/cards.ts @@ -0,0 +1,108 @@ +/** 고스톱 패 정보 (docs/games/gostop.md §3.1): 화투 48장 + 보너스패. */ +import { HWATU, hwatuBonus } from '@bg/engine'; + +export type CardId = number; +export type CardKind = 'gwang' | 'yeol' | 'tti' | 'pi'; +export type DanKind = 'hong' | 'cheong' | 'cho'; + +export interface CardInfo { + id: CardId; + /** 1..12, 0 for bonus cards. */ + month: number; + kind: CardKind; + /** 피 값: 일반 피 1, 쌍피 2, 2피 보너스 2, 3피 보너스 3, 그 밖 0. */ + piValue: 0 | 1 | 2 | 3; + bi?: true; + godori?: true; + dan?: DanKind; + gukjin?: true; + bonus?: true; + name: string; +} + +export type BonusSetting = 'none' | '2+1' | '3+1'; +export const BONUS_COUNT: Record = { none: 0, '2+1': 3, '3+1': 4 }; + +export const GUKJIN: CardId = 32; +export const BI_GWANG: CardId = 44; + +function base(id: CardId): CardInfo { + const c = HWATU[id]!; + const kind: CardKind = c.kind === 'ssangpi' ? 'pi' : (c.kind as CardKind); + const info: CardInfo = { id, month: c.month, kind, piValue: c.piValue as 0 | 1 | 2, name: c.name }; + if (c.tags.includes('rain')) info.bi = true; + if (c.tags.includes('godori')) info.godori = true; + if (c.tags.includes('gukjin')) { + info.gukjin = true; + info.name = '9월 열끗·국진(국화)'; + } + if (c.tti && c.tti !== 'plain') info.dan = c.tti; + return info; +} + +const REGULAR: CardInfo[] = HWATU.map((c) => base(c.id)); +const BONUS_TABLES: CardInfo[][] = [0, 1, 2, 3, 4].map((n) => + hwatuBonus(n).map((b) => ({ id: b.id, month: 0, kind: 'pi' as const, piValue: b.piValue as 2 | 3, bonus: true as const, name: b.name })), +); + +/** Card info; `nb` = number of bonus cards in play (the last bonus id is the 3피). */ +export function cardInfo(id: CardId, nb: number): CardInfo { + if (id < 48) { + const c = REGULAR[id]; + if (!c) throw new Error(`gostop: unknown card ${id}`); + return c; + } + const c = BONUS_TABLES[nb]?.[id - 48]; + if (!c) throw new Error(`gostop: unknown bonus card ${id} (bonus ${nb})`); + return c; +} + +export const isBonus = (id: CardId): boolean => id >= 48; +export const monthOf = (id: CardId): number => (id >= 48 ? 0 : Math.floor(id / 4) + 1); + +export function fullDeck(nb: number): CardId[] { + return Array.from({ length: 48 + nb }, (_, i) => i); +} + +/** Card value for automatic choices (§8.5): 광 > 열끗 > 띠 > 쌍피(피 값 2 이상) > 피. */ +export function cardValue(id: CardId, nb: number): number { + const c = cardInfo(id, nb); + if (c.kind === 'gwang') return 5; + if (c.kind === 'yeol') return 4; + if (c.kind === 'tti') return 3; + return c.piValue >= 2 ? 2 : 1; +} + +/** Two floor cards of a month are interchangeable when kind and 피 값 are the same (§5.2). */ +export function sameKind(a: CardId, b: CardId, nb: number): boolean { + const x = cardInfo(a, nb); + const y = cardInfo(b, nb); + return x.kind === y.kind && x.piValue === y.piValue; +} + +/** Best of two (or more) cards by value, ties → lower id. */ +export function bestCard(ids: CardId[], nb: number): CardId { + return [...ids].sort((a, b) => cardValue(b, nb) - cardValue(a, nb) || a - b)[0]!; +} + +/** Display kind for the client (matches the shared HwatuCard component). */ +export type ViewKind = 'gwang' | 'yeol' | 'tti' | 'pi' | 'ssangpi' | 'bonus'; + +export interface CardView { + /** Unique per round so leak tests can spot hidden cards. */ + key: string; + id: CardId; + month: number; + kind: ViewKind; + tti?: 'hong' | 'cheong' | 'cho' | 'plain'; + name: string; + piValue: number; +} + +export function cardView(id: CardId, nb: number, keyPrefix: string): CardView { + const c = cardInfo(id, nb); + const kind: ViewKind = c.bonus ? 'bonus' : c.kind === 'pi' && c.piValue >= 2 ? 'ssangpi' : c.kind; + const out: CardView = { key: `${keyPrefix}c${id}`, id, month: c.month, kind, name: c.name, piValue: c.piValue }; + if (c.kind === 'tti') out.tti = c.dan ?? 'plain'; + return out; +} diff --git a/packages/games/src/gostop/gostop.test.ts b/packages/games/src/gostop/gostop.test.ts new file mode 100644 index 0000000..3d79f45 --- /dev/null +++ b/packages/games/src/gostop/gostop.test.ts @@ -0,0 +1,956 @@ +import { describe, expect, test } from 'bun:test'; +import { SeededRng, randomPlayout, seedN, type GameEvent, type Rng } from '@bg/engine'; +import { + autoPlay, + bestScore, + cardInfo, + cardsInRound, + gostop, + isBonus, + scoreOf, + settle, + type FloorGroup, + type GostopAction, + type GostopOptions, + type GostopState, + type SettleInput, +} from './index'; +import { resolveRules } from './round'; + +// ---------------------------------------------------------------- helpers +// Card ids (§3.1): month m → (m-1)*4 + slot. 1월 0광 1홍단 2·3피, 2월 4열(고도리) 5홍단, 3월 8광 9홍단, +// 4월 12열(고도리) 13초단, 5월 16열 17초단, 6월 20열 21청단, 7월 24열 25초단, 8월 28광 29열(고도리), +// 9월 32국진 33청단, 10월 36열 37청단, 11월 40광 41쌍피, 12월 44비광 45열 46띠 47쌍피. + +const NAMES = ['A', 'B', 'C', 'D', 'E']; +const players = (n: number) => NAMES.slice(0, n); +let clock = 1000; +const rng = () => SeededRng.fromSeed([9, 9, 9, 9]); +const parse = (o: Partial = {}) => gostop.optionsSchema.parse(o); + +/** Rng whose first shuffles return the given prefixes (rest appended in id order), then falls back to a seeded rng. */ +function scripted(orders: number[][], dealer = 0): Rng { + const fallback = SeededRng.fromSeed([5, 6, 7, 8]); + let si = 0; + let first = true; + return { + next: () => fallback.next(), + int: (n) => { + if (first) { + first = false; + return dealer % n; + } + return fallback.int(n); + }, + shuffle: (arr: readonly T[]): T[] => { + const o = orders[si++] as unknown as T[] | undefined; + if (!o) return fallback.shuffle(arr); + return [...o, ...arr.filter((x) => !o.includes(x))]; + }, + }; +} + +interface Spec { + hands: number[][]; + floor?: number[]; + piles?: Record; + deck: number[]; + captured?: number[][]; + turn?: number; +} + +/** A play-phase state with exactly these cards (no conservation; dealer = A). */ +function rigged(n: number, spec: Spec, opts: Partial = {}): GostopState { + const s = gostop.setup({ players: players(n), options: parse({ chongtong: 'off', ...opts }), rng: SeededRng.fromSeed(seedN(1)), now: 0 }); + s.dealer = 0; + const r = s.round!; + const zeros = () => Array.from({ length: n }, () => 0); + const lists = () => Array.from({ length: n }, (): number[] => []); + Object.assign(r, { + participants: Array.from({ length: Math.min(n, 3) }, (_, i) => i), + sellOrder: [], + sellCursor: 0, + sellDecisions: [], + sellers: [], + step: 'play', + turn: spec.turn ?? 0, + pending: null, + stopBy: null, + chongtongBy: null, + bombDummies: zeros(), + revealed: lists(), + shakenMonths: lists(), + goCount: zeros(), + lastGoScore: zeros(), + goOrder: [], + shakes: zeros(), + bombs: zeros(), + ppeokCount: zeros(), + ppeokTurns: lists(), + turnsTaken: zeros(), + instant: zeros(), + }); + r.hands = Array.from({ length: n }, (_, i) => [...(spec.hands[i] ?? [])]); + r.deck = [...spec.deck]; + r.captured = Array.from({ length: n }, (_, i) => [...(spec.captured?.[i] ?? [])]); + const groups: FloorGroup[] = []; + for (const c of spec.floor ?? []) { + const m = Math.floor(c / 4) + 1; + const g = groups.find((x) => x.month === m); + if (g) g.cards.push(c); + else groups.push({ month: m, cards: [c], ppeokBy: null, natural: false }); + } + for (const g of groups) Object.assign(g, spec.piles?.[g.month] ?? {}); + r.floor = groups.sort((a, b) => a.month - b.month); + s.waitKey = ''; + return s; +} + +function actE(s: GostopState, p: string, a: GostopAction, now = (clock += 1000)): { state: GostopState; events: GameEvent[] } { + const v = gostop.validate(s, p, a); + if (!v.ok) throw new Error(`${p} ${JSON.stringify(a)}: ${v.reason}`); + return gostop.apply(s, p, a, { rng: rng(), now }); +} +const act = (s: GostopState, p: string, a: GostopAction, now?: number) => actE(s, p, a, now).state; +const reject = (s: GostopState, p: string, a: GostopAction) => { + const v = gostop.validate(s, p, a); + return v.ok ? null : v.reason; +}; +const play = (card: number | 'dummy', extra: Partial> = {}): GostopAction => ({ type: 'play', card, ...extra }); +const sorted = (xs: number[]) => [...xs].sort((a, b) => a - b); +const cap = (s: GostopState, i: number) => sorted(s.round!.captured[i]!); +const floorIds = (s: GostopState) => sorted(s.round!.floor.flatMap((g) => g.cards)); + +function settleOf(over: Partial & { captured: number[][] }, opts: Partial = {}, n = over.captured.length): ReturnType { + const zeros = () => Array.from({ length: n }, () => 0); + return settle({ + rules: resolveRules(parse(opts), n), + mode: n === 2 ? 'matgo' : 'gostop', + winner: 0, + participants: Array.from({ length: n }, (_, i) => i), + goCount: zeros(), + goOrder: [], + shakes: zeros(), + bombs: zeros(), + nagariMult: 1, + gukjinChoice: Array.from({ length: n }, () => null), + ...over, + }); +} +const pay = (st: ReturnType, from: number) => st.payments.find((x) => x.from === from)!; + +// pi helpers +const PIS = [2, 3, 6, 7, 10, 11, 14, 15, 18, 19, 22, 23, 26, 27, 30, 31, 34, 35, 38, 39, 42, 43]; +const pis = (n: number, from = 0) => PIS.slice(from, from + n); + +// ---------------------------------------------------------------- cards & scoring + +describe('패 구성 (§3.1)', () => { + test('48장 + 보너스: 광 5, 열끗 9, 띠 10, 피 24(쌍피 41·47), 보너스 2피·3피', () => { + const all = Array.from({ length: 48 }, (_, i) => cardInfo(i, 0)); + const count = (k: string) => all.filter((c) => c.kind === k).length; + expect([count('gwang'), count('yeol'), count('tti'), count('pi')]).toEqual([5, 9, 10, 24]); + expect(all.filter((c) => c.piValue === 2).map((c) => c.id)).toEqual([41, 47]); + expect(all.filter((c) => c.godori).map((c) => c.id)).toEqual([4, 12, 29]); + expect(cardInfo(32, 0).gukjin).toBe(true); + expect(cardInfo(44, 0).bi).toBe(true); + expect(cardInfo(46, 0).dan).toBeUndefined(); + expect([48, 49, 50].map((id) => cardInfo(id, 3).piValue)).toEqual([2, 2, 3]); + expect([48, 49, 50, 51].map((id) => cardInfo(id, 4).piValue)).toEqual([2, 2, 2, 3]); + }); +}); + +describe('점수 (§6.1)', () => { + const R = resolveRules(parse(), 2); + test('비삼광 2점, 삼광 3점, 사광 4점(비광 포함), 오광 15점(옵션 30)', () => { + expect(scoreOf([0, 8, 44], R).total).toBe(2); + expect(scoreOf([0, 8, 28], R).total).toBe(3); + expect(scoreOf([0, 8, 28, 44], R).total).toBe(4); + expect(scoreOf([0, 8, 28, 40, 44], R).total).toBe(15); + expect(scoreOf([0, 8, 28, 40, 44], resolveRules(parse({ ogwangPoints: 30 }), 2)).total).toBe(30); + expect(scoreOf([0, 8, 44], resolveRules(parse({ biGwangThree: 0 }), 2)).total).toBe(0); + expect(scoreOf([0, 8], R).total).toBe(0); + }); + test('고도리 5 + 열끗 장수 점수 중복', () => { + expect(scoreOf([4, 12, 29], R).total).toBe(5); + expect(scoreOf([4, 12, 29, 16, 20], R).items).toEqual([ + { name: '열끗 5장', points: 1 }, + { name: '고도리', points: 5 }, + ]); + }); + test('띠 5장 1점 + 홍단 3점 중복, 청단·초단, 비띠는 장수에만', () => { + expect(scoreOf([1, 5, 9, 13, 46], R).total).toBe(4); + expect(scoreOf([21, 33, 37], R).total).toBe(3); + expect(scoreOf([13, 17, 25], R).total).toBe(3); + expect(scoreOf([1, 5, 46], R).total).toBe(0); + }); + test('피 값 10에 1점, 쌍피·보너스 반영', () => { + expect(scoreOf(pis(9), R).total).toBe(0); + expect(scoreOf(pis(10), R).total).toBe(1); + expect(scoreOf([...pis(6), 41, 47], R).total).toBe(1); + const R3 = resolveRules(parse({ bonusCards: '2+1' }), 2); + expect(scoreOf([...pis(5), 48, 50], R3).total).toBe(1); + expect(scoreOf([...pis(5), 48, 50], R3).pi).toBe(10); + }); + test('국진 auto (예시 6): 열끗 5장(국진 포함) + 피 값 9 → 쌍피로 계산해 2점', () => { + const cards = [4, 12, 16, 20, 32, ...pis(9)]; + expect(scoreOf(cards, R, 'yeol').total).toBe(1); + expect(scoreOf(cards, R, 'pi').total).toBe(2); + expect(bestScore(cards, R)).toMatchObject({ as: 'pi', calc: { total: 2 } }); + expect(bestScore(cards, resolveRules(parse({ gukjinMode: 'yeolOnly' }), 2))).toMatchObject({ as: 'yeol', calc: { total: 1 } }); + }); +}); + +describe('최종 공식 (§6.2, §6.3)', () => { + // 예시 1: 삼광 3 + 피 12(3) + 띠 5장(1) = 7. 상대 광 0, 피 6 → 피박·광박 28. + const ex1 = [0, 8, 28, ...pis(12), 1, 5, 13, 21, 46]; + test('예시 1: 맞고 7점 스톱, 피박·광박 → 28점', () => { + const st = settleOf({ captured: [ex1, pis(6, 12)] }); + expect(st.baseScore).toBe(7); + expect(pay(st, 1)).toEqual({ from: 1, amount: 28, baks: ['pi', 'gwang'] }); + }); + test('예시 3: 9점·3고·흔들기 1번 → 48점, 상대 피박이면 96점', () => { + const w = [0, 8, 28, ...pis(13), 41]; + expect(scoreOf(w, resolveRules(parse(), 2)).total).toBe(9); + const base = { goCount: [3, 0], goOrder: [0, 0, 0], shakes: [1, 0] }; + expect(pay(settleOf({ ...base, captured: [w, [40, ...pis(8, 13)]] }), 1).amount).toBe(48); + expect(pay(settleOf({ ...base, captured: [w, [40, ...pis(7, 13)]] }), 1)).toMatchObject({ amount: 96, baks: ['pi'] }); + }); + test('예시 5: 3인 독박 — A가 18점, C 0점, B +18', () => { + const B = [1, 5, 9, 13, 21, ...pis(11)]; + const st = settleOf({ winner: 1, captured: [pis(8, 11), B, pis(4, 19).concat([43])], goCount: [1, 0, 0], goOrder: [0] }, {}, 3); + expect(st.baseScore).toBe(6); + expect(pay(st, 0)).toMatchObject({ amount: 18, baks: ['go'] }); + expect(pay(st, 2)).toMatchObject({ amount: 0, coveredBy: 0 }); + // Without 고박 each loser pays their own share. + const off = settleOf({ winner: 1, captured: [pis(8, 11), B, pis(4, 19).concat([43])], goCount: [1, 0, 0], goOrder: [0] }, { goBak: false }, 3); + expect([pay(off, 0).amount, pay(off, 2).amount]).toEqual([6, 12]); + }); + test('3인 독박: 고를 부른 패자가 2명이면 마지막에 부른 사람', () => { + const B = [1, 5, 9, 13, 21, ...pis(11)]; + const st = settleOf({ winner: 1, captured: [pis(8, 11), B, [40, ...pis(8)]], goCount: [1, 0, 1], goOrder: [2, 0] }, {}, 3); + expect(pay(st, 0).amount).toBe(12); + expect(pay(st, 2).amount).toBe(0); + }); + test('예시 7: 직전 판 나가리 → 7 x 2 = 14점', () => { + const w = [0, 8, 28, ...pis(12), 1, 5, 13, 21, 46]; + expect(pay(settleOf({ nagariMult: 2, captured: [w, [40, ...pis(8, 12)]] }), 1).amount).toBe(14); + }); + test('예시 8: 멍따 — 열끗 7장(고도리 포함) 8점 x2 = 16점; 멍박 옵션이면 패자 열끗 0장 x2', () => { + const w = [4, 12, 29, 16, 20, 24, 36]; + expect(pay(settleOf({ captured: [w, [45, ...pis(3)]] }), 1).amount).toBe(16); + expect(pay(settleOf({ captured: [w, [45, ...pis(3)]] }, { meongTta: false }), 1).amount).toBe(8); + expect(pay(settleOf({ captured: [w, pis(3)] }, { meongBak: true }), 1)).toMatchObject({ amount: 32, baks: ['meong'] }); + }); + test('고 배수: 1·2고는 가산만, 3고 x2, 4고 x4, 5고 x8', () => { + const w = [0, 8, 28, ...pis(12), 1, 5, 13, 21, 46]; + const amt = (go: number) => pay(settleOf({ goCount: [go, 0], goOrder: Array(go).fill(0), captured: [w, [40, ...pis(8, 12)]] }), 1).amount; + expect([0, 1, 2, 3, 4, 5].map(amt)).toEqual([7, 8, 9, 20, 44, 96]); + }); + test('흔들기 2번 x4, 폭탄 x2, 패자의 흔들기는 배수 없음', () => { + const w = [0, 8, 28, ...pis(12), 1, 5, 13, 21, 46]; + const L = [40, ...pis(8, 12)]; + expect(pay(settleOf({ shakes: [2, 0], captured: [w, L] }), 1).amount).toBe(28); + expect(pay(settleOf({ shakes: [1, 0], bombs: [1, 0], captured: [w, L] }), 1).amount).toBe(28); + expect(pay(settleOf({ shakes: [0, 2], bombs: [0, 1], captured: [w, L] }), 1).amount).toBe(7); + }); + test('피박·광박·고박이 모두 겹치면 모두 곱함(맞고)', () => { + const st = settleOf({ captured: [ex1, pis(6, 12)], goCount: [0, 1], goOrder: [1] }); + expect(pay(st, 1)).toEqual({ from: 1, amount: 56, baks: ['pi', 'gwang', 'go'] }); + }); +}); + +describe('박 경계 (§6.2)', () => { + // Winner: 피 값 12 (3점) + 열끗 5장 (1점) + 고도리 (5) = 9, no 광. + const W = [4, 12, 29, 16, 20, ...pis(12)]; + test('맞고 피박: 7 피박, 8 아님; 피 0장은 면제(옵션으로 끄면 피박)', () => { + expect(pay(settleOf({ captured: [W, pis(7, 12)] }), 1).baks).toEqual(['pi']); + expect(pay(settleOf({ captured: [W, pis(8, 12)] }), 1).baks).toEqual([]); + expect(pay(settleOf({ captured: [W, [36]] }), 1).baks).toEqual([]); + expect(pay(settleOf({ captured: [W, [36]] }, { piBakZeroExempt: false }), 1).baks).toEqual(['pi']); + }); + test('3인 피박: 5 피박, 6 아님', () => { + const st = settleOf({ captured: [W, pis(5, 12), pis(6, 12).map((c) => c)] }, {}, 3); + expect(pay(st, 1).baks).toEqual(['pi']); + expect(pay(st, 2).baks).toEqual([]); + }); + test('승자 피 점수가 없으면 피박 없음', () => { + expect(pay(settleOf({ captured: [[4, 12, 29, 16, 20, 24, 36, 45], pis(2, 12)] }, { meongTta: false }), 1).baks).toEqual([]); + }); + test('광박은 승자가 광 점수를 받았을 때만', () => { + expect(pay(settleOf({ captured: [[0, 8, ...W], pis(8, 12)] }), 1).baks).toEqual([]); + expect(pay(settleOf({ captured: [[0, 8, 28, ...W], pis(8, 12)] }), 1).baks).toEqual(['gwang']); + expect(pay(settleOf({ captured: [[0, 8, 28, ...W], pis(8, 12)] }, { gwangBak: false }), 1).baks).toEqual([]); + }); + test('국진: 패자는 피박을 피하는 쪽으로(3인 5→7 면제, 맞고 기준 7이면 여전히 피박)', () => { + const loser = [32, ...pis(5, 12)]; + const g3 = settleOf({ captured: [W, loser, pis(8, 12)] }, {}, 3); + expect(g3.gukjinAs).toBe('pi'); + expect(pay(g3, 1).baks).toEqual([]); + const g2 = settleOf({ captured: [W, loser] }); + expect(pay(g2, 1).baks).toEqual(['pi']); + // ask mode with an explicit choice is honoured. + expect(settleOf({ captured: [W, loser, pis(8, 12)], gukjinChoice: [null, 'yeol', null] }, { gukjinMode: 'ask' }, 3).gukjinAs).toBe('yeol'); + }); +}); + +// ---------------------------------------------------------------- setup + +describe('셋업 (§4)', () => { + test('같은 시드면 같은 배분; 맞고 10/8/20(+보너스), 3인 7/6/21', () => { + for (let seed = 1; seed <= 40; seed++) { + const a = gostop.setup({ players: players(2), options: parse(), rng: SeededRng.fromSeed(seedN(seed)), now: 0 }); + const b = gostop.setup({ players: players(2), options: parse(), rng: SeededRng.fromSeed(seedN(seed)), now: 0 }); + expect(JSON.stringify(a)).toBe(JSON.stringify(b)); + const r = a.round!; + expect(r.hands.map((h) => h.length)).toEqual([10, 10]); + expect(r.floor.reduce((x, g) => x + g.cards.length, 0)).toBe(8); + const floorBonus = r.captured[a.dealer]!.length; + expect(r.deck.length + floorBonus).toBe(23); + expect(cardsInRound(a)).toBe(51); + const s3 = gostop.setup({ players: players(3), options: parse({ chongtong: 'off' }), rng: SeededRng.fromSeed(seedN(seed)), now: 0 }); + expect(s3.round!.hands.map((h) => h.length)).toEqual([7, 7, 7]); + expect(s3.round!.deck).toHaveLength(21); + expect(s3.rules).toMatchObject({ nb: 0, winScore: 3, piBakLimit: 5, chongtongPoints: 5 }); + expect(a.rules).toMatchObject({ nb: 3, winScore: 7, piBakLimit: 7, chongtongPoints: 10 }); + } + }); + + test('바닥에 같은 월 4장 → 다시 섞기(선·판 번호 유지)', () => { + const hands = [[4, 5, 6, 8, 9, 10, 12, 13, 14, 16], [17, 18, 20, 21, 22, 24, 25, 26, 28, 29]]; + const rngS = scripted([[...hands.flat(), 0, 1, 2, 3, 32, 33, 36, 37]], 1); + const s = gostop.setup({ players: players(2), options: parse({ bonusCards: 'none' }), rng: rngS, now: 0 }); + expect(s.round!.redeals).toBe(1); + expect(s.roundNo).toBe(1); + expect(s.dealer).toBe(1); + const counts = new Map(); + for (const g of s.round!.floor) counts.set(g.month, g.cards.length); + expect([...counts.values()].every((v) => v < 4)).toBe(true); + expect(cardsInRound(s)).toBe(48); + }); + + test('바닥 3장 같은 월 → 한 무더기(자연 뻑); 4번째로 먹으면 피 1장씩', () => { + const hands = [[3, 5, 6, 8, 9, 10, 12, 13, 14, 16], [17, 18, 20, 21, 22, 24, 25, 26, 28, 29]]; + const s = gostop.setup({ players: players(2), options: parse({ bonusCards: 'none' }), rng: scripted([[...hands.flat(), 0, 1, 2, 32, 33, 36, 37, 40]]), now: 0 }); + const pile = s.round!.floor.find((g) => g.month === 1)!; + expect(pile).toMatchObject({ cards: [0, 1, 2], natural: true, ppeokBy: null }); + s.round!.captured[1] = [41, 7]; + const t = act(s, 'A', play(3)); + expect(cap(t, 0)).toEqual(expect.arrayContaining([0, 1, 2, 3, 7])); + expect(t.round!.lastTurn!.tags).toContain('ppeokEat'); + // naturalThreeSteal off → no 피 + const s2 = gostop.setup({ players: players(2), options: parse({ bonusCards: 'none', naturalThreeSteal: false }), rng: scripted([[...hands.flat(), 0, 1, 2, 32, 33, 36, 37, 40]]), now: 0 }); + s2.round!.captured[1] = [41, 7]; + expect(cap(act(s2, 'A', play(3)), 1)).toEqual([7, 41]); + }); + + test('바닥 보너스패 → 선이 가져가고 더미에서 보충, 보충패도 보너스면 반복', () => { + const hands = [[4, 5, 6, 8, 9, 10, 12, 13, 14, 16], [17, 18, 20, 21, 22, 24, 25, 26, 28, 29]]; + const s = gostop.setup({ players: players(2), options: parse(), rng: scripted([[...hands.flat(), 48, 0, 1, 32, 33, 36, 37, 40, 49, 44, 45]]), now: 0 }); + const r = s.round!; + expect(sorted(r.captured[0]!)).toEqual([48, 49]); + expect(floorIds(s)).toEqual([0, 1, 32, 33, 36, 37, 40, 44]); + expect(r.deck).toHaveLength(51 - 20 - 8 - 2); + expect(r.deck[0]).toBe(45); + }); +}); + +// ---------------------------------------------------------------- turn mechanics + +describe('먹기 판정 (§5.2)', () => { + test('쪽: 바닥에 없는 월을 냈는데 같은 월이 뒤집힘 → 2장 먹고 상대마다 피 1장', () => { + const s = rigged(2, { hands: [[2, 40], [10, 11]], floor: [4, 12], deck: [3, 20, 21], captured: [[], [6]] }); + const { state: t, events } = actE(s, 'A', play(2)); + expect(cap(t, 0)).toEqual([2, 3, 6]); + expect(cap(t, 1)).toEqual([]); + expect(t.round!.lastTurn!.tags).toEqual(['jjok']); + expect(events.some((e) => e.type === 'jjok')).toBe(true); + expect(t.round!.turn).toBe(1); + }); + + test('뻑: 바닥 1장과 짝 + 같은 월 뒤집힘 → 3장 무더기, ppeokBy 기록; 남이 먹으면 1장, 자뻑은 2장씩', () => { + const base = () => rigged(2, { hands: [[2, 40], [1, 11]], floor: [0, 4], deck: [3, 20, 21, 22], captured: [[6, 7, 41], [10, 47, 14]] }, { ppeokBonus: false }); + let s = act(base(), 'A', play(2)); + const pile = s.round!.floor.find((g) => g.month === 1)!; + expect(pile).toMatchObject({ cards: [0, 2, 3], ppeokBy: 0, natural: false }); + expect(cap(s, 0)).toEqual([6, 7, 41]); + expect(s.round!.ppeokCount[0]).toBe(1); + // B eats the pile with the 4th card → 1 피 from A (lowest value first: 6). + const t = act(s, 'B', play(1)); + expect(cap(t, 1)).toEqual(expect.arrayContaining([0, 1, 2, 3, 6])); + expect(cap(t, 0)).toEqual([7, 41]); + expect(t.round!.lastTurn!.tags).toContain('ppeokEat'); + // 자뻑: A eats its own pile → 2 피 from B. + s = rigged(2, { hands: [[1, 40], [11, 15]], floor: [0, 2, 3, 4], piles: { 1: { ppeokBy: 0 } }, deck: [20, 21, 22], captured: [[], [10, 47, 14]] }); + const u = act(s, 'A', play(1)); + expect(u.round!.lastTurn!.tags).toContain('jaPpeok'); + expect(cap(u, 1)).toEqual([47]); + // jaPpeokSteal = 1 + s = rigged(2, { hands: [[1, 40], [11, 15]], floor: [0, 2, 3, 4], piles: { 1: { ppeokBy: 0 } }, deck: [20, 21, 22], captured: [[], [10, 47, 14]] }, { jaPpeokSteal: 1 }); + expect(cap(act(s, 'A', play(1)), 1)).toEqual([14, 47]); + }); + + test('따닥: 바닥 2장 + 손 1장 + 같은 월 뒤집힘 → 4장 먹고 피 1장씩', () => { + const s = rigged(2, { hands: [[1, 40], [11]], floor: [0, 2, 4], deck: [3, 20], captured: [[], [6, 7]] }); + const t = act(s, 'A', play(1, { target: 0 })); + expect(cap(t, 0)).toEqual([0, 1, 2, 3, 6]); + expect(t.round!.lastTurn!.tags).toEqual(['ttadak']); + }); + + test('쓸: 턴 끝에 바닥이 비면 피 1장씩; 따닥 + 쓸이면 2장씩', () => { + let s = rigged(2, { hands: [[2, 40], [11]], floor: [0, 20], deck: [21, 5], captured: [[], [6, 7, 10]] }); + let t = act(s, 'A', play(2)); + expect(t.round!.lastTurn!.tags).toEqual(['sseul']); + expect(cap(t, 1)).toEqual([7, 10]); + s = rigged(2, { hands: [[1, 40], [11]], floor: [0, 2], deck: [3, 5], captured: [[], [6, 7, 10]] }); + t = act(s, 'A', play(1, { target: 2 })); + expect(t.round!.lastTurn!.tags).toEqual(['ttadak', 'sseul']); + expect(cap(t, 1)).toEqual([10]); + }); + + test('마지막 턴 쓸·쪽은 lastTurnSweepBonus=false면 피 뺏기 없음(따닥은 인정)', () => { + // 쪽 on the last flip, nobody has cards left → 나가리 afterwards. + let t = act(rigged(2, { hands: [[2], []], floor: [4], deck: [3], captured: [[], [6]] }), 'A', play(2)); + expect(t.round!.lastTurn).toMatchObject({ tags: ['jjok'], stolen: [] }); + expect(t.round!.outcome!.type).toBe('nagari'); + t = act(rigged(2, { hands: [[2], []], floor: [4], deck: [3], captured: [[], [6]] }, { lastTurnSweepBonus: true }), 'A', play(2)); + expect(t.round!.lastTurn!.stolen).toEqual([[1, [6]]]); + // 쓸 on the last turn + t = act(rigged(2, { hands: [[2], []], floor: [0, 20], deck: [21], captured: [[], [6]] }), 'A', play(2)); + expect(t.round!.lastTurn).toMatchObject({ tags: ['sseul'], stolen: [] }); + // 따닥 on the last turn still steals + t = act(rigged(2, { hands: [[1], []], floor: [0, 2, 4], deck: [3], captured: [[], [6]] }), 'A', play(1, { target: 0 })); + expect(t.round!.lastTurn!.stolen).toEqual([[1, [6]]]); + }); + + test('두 장 중 고르기: 광/피면 target 없이 내면 거부; 같은 종류면 자동', () => { + const s = rigged(2, { hands: [[1, 40], [11]], floor: [0, 2, 20], deck: [21, 22] }); + expect(reject(s, 'A', play(1))).toBe('가져올 패를 골라 주세요.'); + expect(reject(s, 'A', play(1, { target: 3 }))).toBe('바닥에 있는 같은 월 패 중에서 골라 주세요.'); + expect(reject(s, 'A', play(40, { target: 0 }))).toBe('고를 패가 없어요.'); + const t = act(s, 'A', play(1, { target: 0 })); + expect(cap(t, 0)).toEqual([0, 1, 20, 21]); + expect(floorIds(t)).toEqual([2]); + const same = rigged(2, { hands: [[0, 40], [11]], floor: [2, 3, 20], deck: [21, 22] }); + expect(reject(same, 'A', play(0))).toBeNull(); + expect(cap(act(same, 'A', play(0)), 0)).toEqual([0, 2, 20, 21]); + }); + + test('뒤집은 패가 바닥 2장과 맞고 종류가 다르면 chooseFlip 단계; 같으면 자동', () => { + const s = rigged(2, { hands: [[4, 40], [11]], floor: [28, 30, 12], deck: [31, 22] }); + const t = act(s, 'A', play(4)); + expect(t.round!.step).toBe('chooseFlip'); + expect(gostop.activePlayers(t)).toEqual(['A']); + expect(reject(t, 'A', play(40))).toBe('먼저 뒤집은 패로 가져올 패를 골라 주세요.'); + expect(reject(t, 'A', { type: 'chooseFlip', target: 12 })).toBeTruthy(); + expect(cardsInRound(t)).toBe(cardsInRound(s)); + const u = act(t, 'A', { type: 'chooseFlip', target: 28 }); + expect(cap(u, 0)).toEqual([28, 31]); + expect(floorIds(u)).toEqual([4, 12, 30]); + expect(u.round!.turn).toBe(1); + const same = act(rigged(2, { hands: [[4, 40], [11]], floor: [30, 31, 12], deck: [28, 22] }), 'A', play(4)); + expect(same.round!.step).toBe('play'); + expect(cap(same, 0)).toEqual([28, 30]); + }); +}); + +describe('피 뺏기 순서 (§5.3)', () => { + test('일반 피 → 쌍피·2피 → 3피, 같은 값이면 낮은 ID; 피가 없으면 안 줌; 국진은 안 뺏김', () => { + const go = (b: number[], opts: Partial = {}) => cap(act(rigged(2, { hands: [[2, 40], [11]], floor: [4], deck: [3, 20], captured: [[], b] }, opts), 'A', play(2)), 1); + expect(go([41])).toEqual([]); + expect(go([41, 43])).toEqual([41]); + expect(go([32, 36])).toEqual([32, 36]); + expect(go([50, 48], { bonusCards: '2+1' })).toEqual([50]); + expect(go([])).toEqual([]); + }); +}); + +describe('폭탄·흔들기·보너스패 (§5.4–5.6)', () => { + test('폭탄: 3장 + 바닥 1장 → 4장 먹고 피 1장씩, 빈패 2장, 빈패 내면 더미만 뒤집음', () => { + const s = rigged(2, { hands: [[0, 1, 2, 40], [11, 15]], floor: [3, 20, 12], deck: [21, 5, 16, 17], captured: [[], [6, 7]] }); + expect(reject(s, 'A', play(40, { bomb: true }))).toBe('폭탄은 같은 월 3장과 바닥 1장이 있어야 해요.'); + let t = act(s, 'A', play(1, { bomb: true })); + const r = t.round!; + expect(cap(t, 0)).toEqual([0, 1, 2, 3, 6, 20, 21]); + expect(r.hands[0]).toEqual([40]); + expect(r.bombDummies[0]).toBe(2); + expect(r.bombs[0]).toBe(1); + expect(r.lastTurn!.tags).toEqual(['bomb']); + t = act(t, 'B', play(11)); + expect(gostop.view(t, 'A').myDummies).toBe(2); + expect(reject(t, 'A', play('dummy', { shake: true }))).toBeTruthy(); + const deckBefore = t.round!.deck.length; + t = act(t, 'A', play('dummy')); + expect(t.round!.hands[0]).toEqual([40]); + expect(t.round!.bombDummies[0]).toBe(1); + expect(t.round!.deck.length).toBe(deckBefore - 1); + expect(reject(rigged(2, { hands: [[0, 1, 2], []], floor: [3], deck: [5] }, { bomb: false }), 'A', play(0, { bomb: true }))).toBe('이 방은 폭탄을 쓰지 않아요.'); + }); + + test('흔들기: 공개된 패는 상대 view에 보이고 나머지 손패는 안 보임; 같은 월 두 번은 안 됨', () => { + const s = rigged(2, { hands: [[0, 1, 2, 40, 44], [11, 15]], floor: [20], deck: [21, 5, 16, 17] }); + expect(reject(s, 'A', play(40, { shake: true }))).toContain('흔들기는'); + const t = act(s, 'A', play(0, { shake: true })); + expect(t.round!.shakes[0]).toBe(1); + expect(sorted(t.round!.revealed[0]!)).toEqual([1, 2]); + const bView = JSON.stringify(gostop.view(t, 'B')); + expect(bView).toContain('"r1c1"'); + expect(bView).toContain('"r1c2"'); + expect(bView).not.toContain('"r1c40"'); + expect(bView).not.toContain('"r1c44"'); + expect(gostop.secretsOf!(t, 'A')).toEqual(['r1c40', 'r1c44', ...t.round!.deck.map((c) => `r1c${c}`)]); + expect(reject(rigged(2, { hands: [[0, 1, 2], []], floor: [], deck: [5] }, { shake: false }), 'A', play(0, { shake: true }))).toBe('이 방은 흔들기를 쓰지 않아요.'); + const again = rigged(2, { hands: [[0, 1, 2], []], floor: [], deck: [5] }); + again.round!.shakenMonths[0] = [1]; + expect(reject(again, 'A', play(0, { shake: true }))).toBe('이 월은 이미 흔들었어요.'); + }); + + test('보너스패: 손에서 내면 먹고 1장 보충 후 같은 턴 계속; 뒤집힌 보너스패는 먹고 1장 더', () => { + const opts = { bonusCards: '2+1' as const }; + let s = rigged(2, { hands: [[48, 2], [11]], floor: [0], deck: [20, 49, 21, 22, 23], captured: [[], []] }, opts); + expect(reject(s, 'A', play(48, { target: 0 }))).toBe('보너스패는 그냥 내기만 할 수 있어요.'); + s = act(s, 'A', play(48)); + expect(s.round!.step).toBe('play'); + expect(s.round!.turn).toBe(0); + expect(sorted(s.round!.hands[0]!)).toEqual([2, 20]); + expect(cap(s, 0)).toEqual([48]); + expect(s.round!.lastTurn!.tags).toEqual(['bonus']); + // Flip: 49 is a bonus → taken, then 21 flips. + s = act(s, 'A', play(2)); + expect(cap(s, 0)).toEqual([0, 2, 48, 49]); + expect(s.round!.lastTurn!.flipped).toEqual([49, 21]); + expect(floorIds(s)).toEqual([21]); + expect(s.round!.turn).toBe(1); + }); +}); + +describe('총통 (§5.7)', () => { + const hands = [[0, 1, 2, 3, 4, 5, 6, 8, 9, 10], [12, 13, 14, 16, 17, 18, 20, 21, 22, 24]]; + const order = [...hands.flat(), 25, 26, 28, 29, 32, 33, 36, 37]; + test('손에 같은 월 4장 → 선언 시 맞고 10점(나가리 배수만 적용); 미선언 시 진행·흔들기 가능', () => { + const s = gostop.setup({ players: players(2), options: parse({ bonusCards: 'none' }), rng: scripted([order]), now: 0 }); + expect(s.round!.step).toBe('chongtong'); + expect(gostop.activePlayers(s)).toEqual(['A']); + expect(gostop.view(s, 'A').chongtongAsk).toEqual({ player: 'A', months: [1] }); + expect(gostop.view(s, 'B').chongtongAsk).toEqual({ player: 'A', months: null }); + const t = act(s, 'A', { type: 'chongtong', declare: true }); + expect(t.round!.outcome).toMatchObject({ type: 'chongtong', winner: 0, shown: [0, 1, 2, 3] }); + expect(t.totals).toEqual([10, -10]); + const doubled = structuredClone(s); + doubled.round!.nagariMult = 2; + expect(act(doubled, 'A', { type: 'chongtong', declare: true }).totals).toEqual([20, -20]); + const u = act(s, 'A', { type: 'chongtong', declare: false }); + expect(u.round!.step).toBe('play'); + expect(reject(u, 'A', play(0, { shake: true }))).toBeNull(); + // timeout → declare + expect(act(s, 'A', { type: 'timeout' }).round!.outcome!.type).toBe('chongtong'); + }); + test('redeal: 다시 돌림(선 유지); off: 그냥 진행', () => { + const s = gostop.setup({ players: players(2), options: parse({ bonusCards: 'none', chongtong: 'redeal' }), rng: scripted([order]), now: 0 }); + expect(s.round!.redeals).toBeGreaterThanOrEqual(1); + expect(s.round!.step).toBe('play'); + expect(s.dealer).toBe(0); + const off = gostop.setup({ players: players(2), options: parse({ bonusCards: 'none', chongtong: 'off' }), rng: scripted([order]), now: 0 }); + expect(off.round!.step).toBe('play'); + expect(sorted(off.round!.hands[0]!)).toEqual(sorted(hands[0]!)); + }); +}); + +describe('고 / 스톱 (§5.9)', () => { + // 삼광 3 + 홍단 3 + 띠 5장 1 = 7점, B has a 광 (no 광박). + const seven = [0, 8, 28, 1, 5, 9, 13, 21]; + test('마지막 고 점수와 같으면 선택 없음, 커지면 선택; 스톱하면 (8 + 1) = 9점', () => { + let s = rigged(2, { hands: [[2, 25, 42], [11, 15, 43]], floor: [3, 24, 40], deck: [36, 37, 38, 39], captured: [seven, [44]] }); + s.round!.goCount[0] = 1; + s.round!.lastGoScore[0] = 7; + s.round!.goOrder = [0]; + s = act(s, 'A', play(2)); // captures a 피 only → still 7 + expect(s.round!.step).toBe('play'); + expect(s.round!.turn).toBe(1); + s = act(s, 'B', play(11)); + s = act(s, 'A', play(25)); // 7월 초단 + 열끗 → 띠 6장 → 8점 + expect(s.round!.step).toBe('goStop'); + const v = gostop.view(s, 'A').goStop!; + expect(v).toMatchObject({ score: 8, goCount: 1, goNext: { goCount: 2, plus: 2, factor: 1 } }); + expect(v.stopNow.total).toBe(9); + s = act(s, 'A', { type: 'goStop', decision: 'stop' }); + expect(s.round!.outcome).toMatchObject({ type: 'stop', winner: 0, baseScore: 8, goCount: 1 }); + expect(s.totals).toEqual([9, -9]); + }); + test('고: 횟수 +1, 마지막 고 점수 기록, 다음 사람 차례', () => { + let s = rigged(2, { hands: [[25, 42], [11, 15]], floor: [24, 40], deck: [36, 37, 38, 39], captured: [seven.slice(0, -1).concat([33]), [44]] }); + s = act(s, 'A', play(25)); + expect(s.round!.step).toBe('goStop'); + s = act(s, 'A', { type: 'goStop', decision: 'go' }); + expect(s.round!.goCount[0]).toBe(1); + expect(s.round!.lastGoScore[0]).toBe(8); + expect(s.round!.turn).toBe(1); + expect(s.stats.goCalls[0]).toBe(1); + }); + test('마지막 턴에 조건이 되면 자동 스톱; goStop 단계에서 손패 0장이면 고 거부', () => { + const s = act(rigged(2, { hands: [[25], [11]], floor: [24], deck: [36, 37], captured: [seven.slice(0, -1).concat([33]), [44]] }), 'A', play(25)); + expect(s.round!.outcome).toMatchObject({ type: 'stop', winner: 0 }); + const g = rigged(2, { hands: [[], [11]], deck: [36] }); + g.round!.step = 'goStop'; + expect(reject(g, 'A', { type: 'goStop', decision: 'go' })).toBe('마지막 턴에는 고를 할 수 없어요.'); + expect(reject(g, 'A', { type: 'goStop', decision: 'stop' })).toBeNull(); + }); + test('시간 초과 → 스톱', () => { + let s = rigged(2, { hands: [[25, 42], [11, 15]], floor: [24, 40], deck: [36, 37, 38, 39], captured: [seven.slice(0, -1).concat([33]), [44]] }); + s = act(s, 'A', play(25)); + s = act(s, 'A', { type: 'timeout' }); + expect(s.round!.outcome).toMatchObject({ type: 'stop', winner: 0 }); + }); + test('국진 ask: 판 끝 정산 직전 본인이 고름, 시간 초과면 auto', () => { + const cards = [4, 12, 16, 20, 32, ...pis(9)]; + const mk = () => rigged(2, { hands: [[25, 42], [11, 15]], floor: [24, 40], deck: [36, 37, 38, 39], captured: [[0, 8, 28, ...cards.slice(0, -2)], [44]] }, { gukjinMode: 'ask', winScore: 3 }); + let s = act(mk(), 'A', play(25)); + expect(s.round!.step).toBe('goStop'); + s = act(s, 'A', { type: 'goStop', decision: 'stop' }); + expect(s.round!.step).toBe('gukjin'); + expect(gostop.view(s, 'A').gukjinAsk).toMatchObject({ player: 'A' }); + const y = act(s, 'A', { type: 'gukjin', as: 'yeol' }); + expect(y.round!.outcome!.gukjinAs).toBe('yeol'); + const auto = act(s, 'A', { type: 'timeout' }); + expect(auto.round!.outcome!.gukjinAs).toBe(settle({ ...autoInput(s) }).gukjinAs); + }); +}); + +function autoInput(s: GostopState): SettleInput { + const r = s.round!; + return { rules: s.rules, mode: s.mode, winner: r.stopBy!, participants: r.participants, captured: r.captured, goCount: r.goCount, goOrder: r.goOrder, shakes: r.shakes, bombs: r.bombs, nagariMult: r.nagariMult, gukjinChoice: r.gukjinChoice }; +} + +describe('나가리 (§5.10)', () => { + test('아무도 스톱 못 함 → 나가리, 선 유지, 다음 판 x2, 연속 x4, 상한 x8', () => { + let s = rigged(2, { hands: [[2], [11]], floor: [20], deck: [24, 36] }); + s.dealer = 1; + s.round!.participants = [1, 0]; + s.round!.turn = 1; + s = act(s, 'B', play(11)); + s = act(s, 'A', play(2)); + expect(s.round!.outcome!.type).toBe('nagari'); + expect(s.phase).toBe('roundResult'); + expect(s.dealer).toBe(1); + expect(s.nagariStreak).toBe(1); + expect(s.totals).toEqual([0, 0]); + s = act(s, 'A', { type: 'ready' }); + expect(s.phase).toBe('roundResult'); + s = act(s, 'B', { type: 'ready' }); + expect(s.roundNo).toBe(2); + expect(s.round!.nagariMult).toBe(2); + expect(s.round!.participants[0]).toBe(1); + const t = structuredClone(s); + t.nagariStreak = 2; + t.phase = 'roundResult'; + t.ready = []; + expect(act(act(t, 'A', { type: 'ready' }), 'B', { type: 'ready' }).round!.nagariMult).toBe(4); + t.nagariStreak = 5; + expect(act(act(t, 'A', { type: 'ready' }), 'B', { type: 'ready' }).round!.nagariMult).toBe(8); + expect(act(act({ ...structuredClone(t), rules: { ...t.rules, nagariMaxMultiplier: 4 } }, 'A', { type: 'ready' }), 'B', { type: 'ready' }).round!.nagariMult).toBe(4); + expect(act(act({ ...structuredClone(t), rules: { ...t.rules, nagariMultiplier: 'none' } }, 'A', { type: 'ready' }), 'B', { type: 'ready' }).round!.nagariMult).toBe(1); + }); + test('고를 부른 사람이 끝까지 점수를 더 못 내면 나가리', () => { + let s = rigged(2, { hands: [[2], [11]], floor: [20], deck: [24, 36], captured: [[0, 8, 28, 1, 5, 9, 13, 21], [44]] }); + s.round!.goCount[0] = 1; + s.round!.lastGoScore[0] = 7; + s = act(act(s, 'A', play(2)), 'B', play(11)); + expect(s.round!.outcome!.type).toBe('nagari'); + }); + test('승리하면 승자가 다음 판 선, 나가리 연속 수 초기화', () => { + const s = rigged(2, { hands: [[11], [25]], floor: [24], deck: [36, 37], captured: [[44], [0, 8, 28, 1, 5, 9, 13, 33]], turn: 1 }); + s.nagariStreak = 3; + s.round!.nagariMult = 8; + const t = act(s, 'B', play(25)); + expect(t.round!.outcome).toMatchObject({ type: 'stop', winner: 1, perLoser: 64 }); + expect(t.dealer).toBe(1); + expect(t.nagariStreak).toBe(0); + const u = act(act(t, 'A', { type: 'ready' }), 'B', { type: 'ready' }); + expect(u.round!.participants[0]).toBe(1); + expect(u.round!.nagariMult).toBe(1); + }); +}); + +describe('첫뻑·연뻑·삼뻑 (§5.11)', () => { + test('첫뻑 3점 즉시, 연뻑 6점 더, 삼뻑 즉시 승리(10점), 나가리여도 첫뻑 점수 유지', () => { + let s = rigged(2, { hands: [[2, 6, 10, 40], [11, 15, 19]], floor: [0, 4, 8, 20], deck: [3, 22, 7, 23, 11, 26, 30], captured: [[], []] }); + s = act(s, 'A', play(2)); + expect(s.round!.lastTurn!.tags).toEqual(['ppeok', 'firstPpeok']); + expect(s.totals).toEqual([3, -3]); + s = act(s, 'B', play(15)); + s = act(s, 'A', play(6)); + expect(s.round!.lastTurn!.tags).toEqual(['ppeok', 'yeonPpeok']); + expect(s.totals).toEqual([9, -9]); + s = act(s, 'B', play(19)); + // third 뻑 → 삼뻑: game over for this round, 10 × 나가리 배수. + s.round!.deck = [11, 26, 30]; + s.round!.hands[1] = [27]; + const t = act(s, 'A', play(10)); + expect(t.round!.outcome).toMatchObject({ type: 'samppeok', winner: 0, perLoser: 10 }); + expect(t.totals).toEqual([19, -19]); + // 나가리: first-뻑 points stay. + const n = act(rigged(2, { hands: [[2], []], floor: [0, 20], deck: [3] }), 'A', play(2)); + expect(n.round!.outcome!.type).toBe('nagari'); + expect(n.totals).toEqual([3, -3]); + // option off + expect(act(rigged(2, { hands: [[2, 40], [11]], floor: [0, 20], deck: [3, 22] }, { ppeokBonus: false }), 'A', play(2)).totals).toEqual([0, 0]); + }); +}); + +describe('광팔기 (§5.8)', () => { + const H = [[2, 3, 6, 7, 10, 11, 14], [0, 8, 15, 18, 19, 22, 23], [26, 27, 30, 31, 34, 35, 38], [39, 42, 43, 44, 47, 1, 5]]; + const F = [4, 9, 13, 16, 17, 20]; + const mk = (n: number, opts: Partial = {}, extra: number[][] = []) => + gostop.setup({ players: players(n), options: parse({ chongtong: 'off', ...opts }), rng: scripted([[...H.flat(), ...extra.flat(), ...F]]), now: 0 }); + test('4인: 선 다음 사람부터, 선은 sell 불가, 광 2장 → 선 제외 참가자 2명이 각 2점, 더미 21장', () => { + const s = mk(4); + expect(s.round!.step).toBe('sell'); + expect(gostop.activePlayers(s)).toEqual(['B']); + expect(reject(s, 'A', { type: 'sell', sell: true })).toBe('선은 광을 팔 수 없어요.'); + expect(reject(s, 'C', { type: 'sell', sell: true })).toBe('지금은 내가 정할 차례가 아니에요.'); + expect(gostop.view(s, 'B').sell!.mySellable!.map((c) => c.id)).toEqual([0, 8]); + expect(gostop.view(s, 'C').sell!.mySellable).toBeNull(); + const t = act(s, 'B', { type: 'sell', sell: true }); + const r = t.round!; + expect(r.participants).toEqual([0, 2, 3]); + expect(r.sellers).toEqual([{ idx: 1, shown: [0, 8], paid: 4 }]); + expect(t.totals).toEqual([0, 4, -2, -2]); + expect(r.hands[1]).toEqual([]); + expect(r.deck).toHaveLength(21); + expect(r.step).toBe('play'); + expect(r.sellDecisions.map((d) => [d.idx, d.sell, d.auto])).toEqual([[1, true, false], [2, false, true], [3, false, true]]); + expect(cardsInRound(t)).toBe(48); + // Seller's unshown cards and the deck stay secret; the shown 광 are public. + const v = JSON.stringify(gostop.view(t, 'A')); + for (const c of [15, 18, 19]) expect(v).not.toContain(`"r1c${c}"`); + expect(v).toContain('"r1sc0"'); + }); + test('치기 3명 확정 시 나머지 자동 죽기; allPlayers 지불; 시간 초과 → 죽기', () => { + let s = act(act(mk(4), 'B', { type: 'sell', sell: false }), 'C', { type: 'sell', sell: false }); + expect(s.round!.participants).toEqual([0, 1, 2]); + expect(s.round!.sellers).toEqual([{ idx: 3, shown: [44], paid: 2 }]); + s = act(mk(4, { gwangPayer: 'allPlayers', gwangPrice: 2 }), 'B', { type: 'timeout' }); + expect(s.round!.sellers).toEqual([{ idx: 1, shown: [0, 8], paid: 12 }]); + expect(s.totals).toEqual([-4, 12, -4, -4]); + s = act(mk(4, { gwangSellCards: 'gwangPlus' }), 'B', { type: 'sell', sell: false }); + expect(s.round!.step).toBe('sell'); + s = act(s, 'C', { type: 'sell', sell: true }); + expect(s.round!.sellers[0]!.shown).toEqual([]); + }); + test('5인: 2명 죽으면 나머지는 자동 치기, 더미 21장', () => { + const s = act(act(mk(5, {}, [[12, 24, 25, 28, 29, 32, 33]]), 'B', { type: 'sell', sell: true }), 'C', { type: 'sell', sell: true }); + expect(s.round!.participants).toEqual([0, 3, 4]); + expect(s.round!.deck).toHaveLength(21); + expect(s.totals.reduce((a, b) => a + b, 0)).toBe(0); + expect(gostop.playersFor!(parse({ gwangPalgi: false }))).toEqual({ min: 2, max: 3 }); + expect(gostop.playerCountError!(parse({ gwangPalgi: false }), 4)).toBeTruthy(); + }); +}); + +// ---------------------------------------------------------------- timeouts, leaving, info + +describe('시간 초과·연결 끊김 (§8.5)', () => { + test('play 자동 행동: 빈패 → 보너스패 → 가장 값진 바닥 패 → 가장 싼 패 (결정적)', () => { + const s = rigged(2, { hands: [[30, 18, 0], [11]], floor: [28, 16], deck: [36, 37] }); + expect(autoPlay(s, 0)).toEqual({ type: 'play', card: 30 }); + expect(autoPlay(structuredClone(s), 0)).toEqual(autoPlay(s, 0)); + s.round!.hands[0] = [0, 3, 44]; + expect(autoPlay(s, 0)).toEqual({ type: 'play', card: 3 }); + s.round!.floor = [{ month: 1, cards: [1, 2], ppeokBy: null, natural: false }]; + expect(autoPlay(s, 0)).toEqual({ type: 'play', card: 0, target: 1 }); + s.round!.hands[0] = [0, 3, 49]; + expect(autoPlay(s, 0)).toEqual({ type: 'play', card: 49 }); + s.round!.bombDummies[0] = 1; + expect(autoPlay(s, 0)).toEqual({ type: 'play', card: 'dummy' }); + }); + test('2회 연속 시간 초과 → autoPilot, 마감 2초; 직접 행동하면 해제', () => { + let s = rigged(2, { hands: [[2, 6, 10], [11, 15, 19]], floor: [20], deck: [24, 36, 37, 38, 39, 42, 43] }); + s = act(s, 'A', { type: 'timeout' }, 10_000); + expect(s.autoPilot[0]).toBe(false); + expect(s.deadline).toBe(10_000 + 15_000); + s = act(s, 'B', play(11), 11_000); + s = act(s, 'A', { type: 'timeout' }, 12_000); + expect(s.autoPilot[0]).toBe(true); + s = act(s, 'B', play(15), 13_000); + expect(s.deadline).toBe(13_000 + 2_000); + s = act(s, 'A', play(gostop.view(s, 'A').myHand![0]!.card.id), 14_000); + expect(s.autoPilot[0]).toBe(false); + }); + test('나가기: autoPilot, 끊긴 사람 차례 마감 2초, 결과 화면 준비에서 빠짐', () => { + let s = rigged(2, { hands: [[2, 6], [11, 15]], floor: [20], deck: [24, 36, 37, 38] }); + s = act(s, 'A', { type: 'leave' }, 5000); + expect(s.left[0]).toBe(true); + expect(s.deadline).toBe(7000); + expect(gostop.onLeave!(s, 'A')).toBeNull(); + expect(reject(s, 'A', play(2))).toBe('방을 나간 플레이어예요.'); + s = act(s, 'A', { type: 'timeout' }); + s = act(s, 'B', play(11)); + s = act(s, 'A', { type: 'timeout' }); + s = act(s, 'B', play(15)); + expect(s.phase).toBe('roundResult'); + expect(gostop.activePlayers(s)).toEqual(['B']); + s = act(s, 'B', { type: 'ready' }); + expect(s.roundNo).toBe(2); + }); + test('onTimeout는 시스템 전용 행동이고 actionSchema에 없음', () => { + expect(gostop.actionSchema.safeParse({ type: 'timeout' }).success).toBe(false); + expect(gostop.actionSchema.safeParse({ type: 'leave' }).success).toBe(false); + expect(gostop.actionSchema.safeParse({ type: 'play', card: 'dummy' }).success).toBe(true); + expect(gostop.actionSchema.safeParse({ type: 'play', card: 3, target: 2, shake: true }).success).toBe(true); + }); +}); + +describe('options', () => { + test('defaults by mode and Korean metadata', () => { + expect(gostop.defaultOptions).toMatchObject({ rounds: 10, bonusCards: 'auto', winScore: 'auto', turnSeconds: 15, decisionSeconds: 10 }); + const shape = (gostop.optionsSchema as unknown as { shape: Record }).shape; + for (const [k, v] of Object.entries(shape)) expect(v.meta()?.title, k).toBeTruthy(); + expect(() => gostop.setup({ players: players(4), options: parse({ gwangPalgi: false }), rng: rng(), now: 0 })).toThrow(); + expect(gostop.playersFor!(gostop.defaultOptions)).toEqual({ min: 2, max: 5 }); + }); + test('rounds 판을 마치면 끝, 방 점수 순위(동점 공동), 요약은 점수만', () => { + let s = rigged(2, { hands: [[25], [11]], floor: [24], deck: [36, 37], captured: [[0, 8, 28, 1, 5, 9, 13, 33], [44]] }, { rounds: 1 }); + s = act(s, 'A', play(25)); + expect(s.phase).toBe('finished'); + const res = gostop.result(s)!; + expect(res.ranking).toEqual([['A'], ['B']]); + expect(res.scores).toEqual({ A: 8, B: -8 }); + expect(res.summary).toBe('1판 끝. 1위 +8점'); + expect(gostop.activePlayers(s)).toEqual([]); + expect(gostop.deadline!(s)).toBeNull(); + const tie = act(rigged(2, { hands: [[2], [11]], floor: [20], deck: [24, 36] }, { rounds: 1 }), 'A', play(2)); + const t2 = act(tie, 'B', play(11)); + expect(gostop.result(t2)!.ranking).toEqual([['A', 'B']]); + }); +}); + +// ---------------------------------------------------------------- properties + +/** Per-step invariants: card conservation, no duplicates, zero-sum points, hand/deck balance. */ +function invariants(s: GostopState): void { + const r = s.round; + const sum = s.totals.reduce((a, b) => a + b, 0); + if (sum !== 0) throw new Error(`points not zero-sum: ${sum}`); + if (!r) return; + const all = [...r.hands.flat(), ...r.deck, ...r.floor.flatMap((g) => g.cards), ...r.captured.flat(), ...(r.pending ? [r.pending.flipped] : [])]; + if (all.length !== 48 + s.rules.nb) throw new Error(`card count ${all.length}`); + if (new Set(all).size !== all.length) throw new Error('duplicate card'); + for (const g of r.floor) if (g.cards.length < 1 || g.cards.length > 3 || g.cards.some((c) => Math.floor(c / 4) + 1 !== g.month || isBonus(c))) throw new Error('bad floor group'); + if (r.step === 'play' || r.step === 'goStop' || r.step === 'chooseFlip') { + // Deck = Σ(regular hand cards + 빈패) + 2 × hand bonus + deck bonus (§5.5, §5.6). A pending turn already flipped. + let need = 0; + for (const p of r.participants) need += r.hands[p]!.filter((c) => !isBonus(c)).length + r.bombDummies[p]! + 2 * r.hands[p]!.filter(isBonus).length; + need += r.deck.filter(isBonus).length; + if (r.deck.length !== need) throw new Error(`deck ${r.deck.length} vs needed ${need} (step ${r.step})`); + } +} + +const CONFIGS: [string, Partial][] = [ + ['기본', {}], + ['보너스 3+1·국진 ask·총통 다시', { bonusCards: '3+1', gukjinMode: 'ask', chongtong: 'redeal' }], + ['마지막 쓸 인정·자뻑 1·멍박', { lastTurnSweepBonus: true, jaPpeokSteal: 1, meongBak: true, winScore: 3 }], + ['광팔기 전부·모두 지불·총통 off', { gwangSellCards: 'gwangPlus', gwangPayer: 'allPlayers', chongtong: 'off', gwangPrice: 3 }], + ['흔들기·폭탄 끔·고박 끔', { shake: false, bomb: false, goBak: false, ppeokBonus: false, nagariMultiplier: 'none' }], +]; + +describe('random playouts', () => { + test('1,000 seeds: invariants, every legal action validates, onTimeout legal in every state', () => { + let rounds = 0; + let stops = 0; + for (let seed = 1; seed <= 1000; seed++) { + const n = 2 + (seed % 4); + const ps = players(n); + const [, opts] = CONFIGS[seed % CONFIGS.length]!; + const options = parse({ rounds: seed % 7 === 0 ? 3 : 1, ...opts }); + const r = SeededRng.fromSeed(seedN(seed)); + const chooser = SeededRng.fromSeed(seedN(seed + 9999)); + let s = gostop.setup({ players: ps, options, rng: r, now: 0 }); + let steps = 0; + for (; steps < 2000 && !gostop.result(s); steps++) { + invariants(s); + const actives = gostop.activePlayers(s); + if (actives.length === 0) throw new Error(`seed ${seed}: nobody to act`); + for (const p of actives) { + const v = gostop.validate(s, p, gostop.onTimeout(s, p)); + if (!v.ok) throw new Error(`seed ${seed}: timeout rejected ${v.reason}`); + } + const actor = actives[chooser.int(actives.length)]!; + const legal = gostop.legalActions!(s, actor); + for (const a of legal) { + const v = gostop.validate(s, actor, a); + if (!v.ok) throw new Error(`seed ${seed}: legal action rejected ${JSON.stringify(a)}: ${v.reason}`); + } + const a = chooser.int(6) === 0 ? gostop.onTimeout(s, actor) : legal[chooser.int(legal.length)]!; + const before = s.roundNo; + s = gostop.apply(s, actor, a, { rng: r, now: steps * 1000 }).state; + if (s.roundNo !== before || s.phase !== 'playing') { + if (s.round?.outcome || s.phase === 'finished') { + rounds++; + if (s.round?.outcome?.type === 'stop') stops++; + } + } + } + if (!gostop.result(s)) throw new Error(`seed ${seed}: did not finish`); + invariants(s); + expect(gostop.result(s)!.ranking.flat().sort()).toEqual([...ps].sort()); + } + expect(rounds).toBeGreaterThan(900); + expect(stops).toBeGreaterThan(100); + }, 60_000); + + test('engine randomPlayout (leaks, JSON round-trip, determinism) in every mode', () => { + for (const n of [2, 3, 4, 5]) { + for (let i = 0; i < CONFIGS.length; i++) { + const options = parse({ rounds: 1, ...CONFIGS[i]![1] }); + const ps = players(n); + const a = randomPlayout(gostop, { players: ps, options, seed: seedN(100 * n + i), maxSteps: 3000, maxApplyMs: 50, invariant: (st) => invariants(st as GostopState) }); + expect(a.finished).toBe(true); + if (i === 0) { + const b = randomPlayout(gostop, { players: ps, options, seed: seedN(100 * n + i), maxSteps: 3000, maxApplyMs: 50 }); + expect(JSON.stringify(b.finalState)).toBe(JSON.stringify(a.finalState)); + // Same seed + same action log → same result. + const rr = SeededRng.fromSeed(seedN(100 * n + i)); + let s = gostop.setup({ players: ps, options, rng: rr, now: 1_700_000_000_000 }); + let now = 1_700_000_000_000; + for (const t of a.trace) s = gostop.apply(s, t.actor, t.action as GostopAction, { rng: rr, now: (now += 1000) }).state; + expect(JSON.stringify(s)).toBe(JSON.stringify(a.finalState)); + } + } + } + }, 60_000); + + test('leak property: views never contain hidden hands / deck, events only reveal public cards', () => { + for (let seed = 1; seed <= 300; seed++) { + const n = 2 + (seed % 4); + const ps = players(n); + const options = parse({ ...CONFIGS[seed % CONFIGS.length]![1], rounds: 1 }); + const r = SeededRng.fromSeed(seedN(seed + 5)); + const chooser = SeededRng.fromSeed(seedN(seed + 77)); + let s = gostop.setup({ players: ps, options, rng: r, now: 0 }); + for (let step = 0; step < 120 && !gostop.result(s); step++) { + const views = new Map([...ps, null].map((v) => [v, JSON.stringify(gostop.view(s, v))])); + for (const owner of ps) + for (const secret of gostop.secretsOf!(s, owner)) + for (const [viewer, json] of views) + if (viewer !== owner && json.includes(JSON.stringify(secret))) throw new Error(`seed ${seed}: leak ${secret} of ${owner} to ${viewer}`); + const actives = gostop.activePlayers(s); + const actor = actives[chooser.int(actives.length)]!; + const legal = gostop.legalActions!(s, actor); + const a = chooser.int(5) === 0 ? gostop.onTimeout(s, actor) : legal[chooser.int(legal.length)]!; + const out = gostop.apply(s, actor, a, { rng: r, now: step * 1000 }); + const hidden = new Set(); + const nr = out.state.round!; + for (let p = 0; p < n; p++) for (const c of nr.hands[p]!) if (!nr.revealed[p]!.includes(c)) hidden.add(c); + for (const c of nr.deck) hidden.add(c); + for (const e of out.events) { + if (e.type === 'gwangSold') continue; // shown 광 are public even though they go back into the deck + const ids = [e.card, ...((e.cards as number[] | undefined) ?? [])].filter((x): x is number => typeof x === 'number'); + for (const id of ids) if (hidden.has(id)) throw new Error(`seed ${seed}: event ${e.type} leaks ${id}`); + } + s = out.state; + } + const deck = s.round!.deck; + if (deck.length > 3) for (const v of [...ps, null]) expect(JSON.stringify(gostop.view(s, v))).not.toContain(JSON.stringify(deck).slice(1, -1)); + } + }, 60_000); +}); diff --git a/packages/games/src/gostop/index.ts b/packages/games/src/gostop/index.ts new file mode 100644 index 0000000..7758608 --- /dev/null +++ b/packages/games/src/gostop/index.ts @@ -0,0 +1,612 @@ +/** 고스톱 / 맞고 / 광팔기 (docs/games/gostop.md). Points are room-only play points (§12). */ +import { z } from 'zod'; +import { fail, ok, type GameDefinition, type GameEvent, type GameResult, type PlayerId } from '@bg/engine'; +import { GUKJIN, bestCard, cardInfo, cardView, isBonus, monthOf, type CardId, type CardView } from './cards'; +import { + autoGukjin, + autoPlay, + chooseFlip, + currentScore, + decideChongtong, + decideGoStop, + decideGukjin, + decideSell, + floorCount, + floorGroup, + handMonthCount, + hasChongtong, + needsTarget, + playCard, + resolveRules, + sellDecider, + sellableCards, + settleInput, + startRound, + type Ctx, +} from './round'; +import { bestScore, goFactor, scoreOf, settle } from './scoring'; +import { + gostopActionSchema, + gostopOptionsSchema, + type GostopAction, + type GostopOptions, + type GostopState, + type GostopView, + type HandCardView, + type OutcomeView, + type RoundState, + type ScoreView, + type SeatView, +} from './types'; + +export * from './cards'; +export * from './types'; +export { bestScore, goFactor, scoreOf, settle, type ScoreCalc, type SettleInput, type Settlement } from './scoring'; +export { autoPlay, resolveRules } from './round'; + +const RESULT_MS = 10_000; +const AUTO_MS = 2_000; + +export function playerRange(o: Pick): { min: number; max: number } { + return { min: 2, max: o.gwangPalgi ? 5 : 3 }; +} + +const keyOf = (s: GostopState) => `r${s.roundNo}`; +const cv = (s: GostopState, id: CardId): CardView => cardView(id, s.rules.nb, keyOf(s)); +const idx = (s: GostopState, p: PlayerId): number => s.players.indexOf(p); + +/** Total cards in a round (hands + deck + floor + captured + pending flip) — 48 + bonus. */ +export function cardsInRound(s: GostopState): number { + const r = s.round; + if (!r) return 0; + return ( + r.hands.reduce((a, h) => a + h.length, 0) + + r.deck.length + + r.floor.reduce((a, g) => a + g.cards.length, 0) + + r.captured.reduce((a, c) => a + c.length, 0) + + (r.pending ? 1 : 0) + ); +} + +/** Players who must act now (§8.2 activePlayers). */ +function waiting(s: GostopState): number[] { + if (s.phase === 'finished') return []; + if (s.phase === 'roundResult') return s.players.map((_, i) => i).filter((i) => !s.left[i] && !s.ready.includes(i)); + const r = s.round; + if (!r) return []; + if (r.step === 'sell') { + const d = sellDecider(s); + return d === null ? [] : [d]; + } + if (r.step === 'done') return []; + return [r.turn]; +} + +function deadlineMs(s: GostopState): number | null { + if (s.phase === 'finished') return null; + if (s.phase === 'roundResult') return RESULT_MS; + const w = waiting(s); + if (w.length === 0) return null; + if (w.every((i) => s.autoPilot[i])) return AUTO_MS; + const step = s.round!.step; + return (step === 'play' ? s.rules.turnSeconds : s.rules.decisionSeconds) * 1000; +} + +function refreshDeadline(s: GostopState, now: number): void { + const r = s.round; + const w = waiting(s); + const key = s.phase === 'roundResult' ? `rr|${s.roundNo}` : `${s.phase}|${s.roundNo}|${r?.step}|${w.join(',')}|${s.actionSeq}|${w.every((i) => s.autoPilot[i])}`; + const ms = deadlineMs(s); + if (key !== s.waitKey) { + s.waitKey = key; + s.deadline = ms === null ? null : now + ms; + } + if (ms === null) s.deadline = null; +} + +/** Starts the next round once everybody still at the table is ready. */ +function progress(s: GostopState, ctx: Ctx): void { + if (s.phase === 'roundResult' && waiting(s).length === 0) startRound(s, ctx); +} + +// ---------------------------------------------------------------- legality + +function validatePlay(s: GostopState, p: number, a: Extract) { + const r = s.round!; + if (a.card === 'dummy') { + if (r.bombDummies[p]! <= 0) return fail('폭탄 빈패가 없어요.'); + if (a.target !== undefined || a.shake || a.bomb) return fail('빈패는 그냥 내기만 할 수 있어요.'); + return ok; + } + if (!r.hands[p]!.includes(a.card)) return fail('손에 없는 패예요.'); + if (isBonus(a.card)) { + if (a.target !== undefined || a.shake || a.bomb) return fail('보너스패는 그냥 내기만 할 수 있어요.'); + return ok; + } + const m = monthOf(a.card); + const k = floorCount(r, m); + const inHand = handMonthCount(r, p, m); + if (a.bomb) { + if (!s.rules.bomb) return fail('이 방은 폭탄을 쓰지 않아요.'); + if (a.shake) return fail('폭탄과 흔들기는 함께 할 수 없어요.'); + if (inHand !== 3 || k !== 1) return fail('폭탄은 같은 월 3장과 바닥 1장이 있어야 해요.'); + if (a.target !== undefined) return fail('폭탄은 가져올 패를 고르지 않아요.'); + return ok; + } + if (a.shake) { + if (!s.rules.shake) return fail('이 방은 흔들기를 쓰지 않아요.'); + if (inHand < 3 || k !== 0) return fail('흔들기는 같은 월 3장을 가지고 있고 바닥에 그 월이 없을 때만 할 수 있어요.'); + if (r.shakenMonths[p]!.includes(m)) return fail('이 월은 이미 흔들었어요.'); + } + if (k === 2) { + const g = floorGroup(r, m)!; + if (a.target !== undefined && !g.cards.includes(a.target)) return fail('바닥에 있는 같은 월 패 중에서 골라 주세요.'); + if (a.target === undefined && needsTarget(s, m)) return fail('가져올 패를 골라 주세요.'); + } else if (a.target !== undefined) return fail('고를 패가 없어요.'); + return ok; +} + +function legalFor(s: GostopState, p: number): GostopAction[] { + if (s.phase === 'finished' || s.left[p]) return []; + if (s.phase === 'roundResult') return s.ready.includes(p) ? [] : [{ type: 'ready' }]; + const r = s.round!; + if (!waiting(s).includes(p)) return []; + switch (r.step) { + case 'sell': + return [ + { type: 'sell', sell: true }, + { type: 'sell', sell: false }, + ]; + case 'chongtong': + return [ + { type: 'chongtong', declare: true }, + { type: 'chongtong', declare: false }, + ]; + case 'chooseFlip': + return r.pending!.options.map((target) => ({ type: 'chooseFlip', target })); + case 'goStop': + return [ + { type: 'goStop', decision: 'go' }, + { type: 'goStop', decision: 'stop' }, + ]; + case 'gukjin': + return [ + { type: 'gukjin', as: 'yeol' }, + { type: 'gukjin', as: 'pi' }, + ]; + case 'play': { + const out: GostopAction[] = []; + if (r.bombDummies[p]! > 0) out.push({ type: 'play', card: 'dummy' }); + const bombed = new Set(); + for (const c of [...r.hands[p]!].sort((a, b) => a - b)) { + if (isBonus(c)) { + out.push({ type: 'play', card: c }); + continue; + } + const m = monthOf(c); + const g = floorGroup(r, m); + if (g && g.cards.length === 2 && needsTarget(s, m)) for (const target of g.cards) out.push({ type: 'play', card: c, target }); + else out.push({ type: 'play', card: c }); + const n = handMonthCount(r, p, m); + if (s.rules.shake && n >= 3 && !g && !r.shakenMonths[p]!.includes(m)) out.push({ type: 'play', card: c, shake: true }); + if (s.rules.bomb && n === 3 && g?.cards.length === 1 && !bombed.has(m)) { + bombed.add(m); + out.push({ type: 'play', card: c, bomb: true }); + } + } + return out; + } + default: + return []; + } +} + +// ---------------------------------------------------------------- view helpers + +function scoreView(s: GostopState, cards: CardId[], as?: 'yeol' | 'pi'): ScoreView { + const { gwangPoints: _g, ...rest } = as ? scoreOf(cards, s.rules, as) : bestScore(cards, s.rules).calc; + return rest; +} + +function grouped(s: GostopState, cards: CardId[], gukjinAsPi: boolean): SeatView['captured'] { + const out: SeatView['captured'] = { gwang: [], yeol: [], tti: [], pi: [] }; + const sorted = [...cards].sort((a, b) => cardInfo(a, s.rules.nb).piValue - cardInfo(b, s.rules.nb).piValue || a - b); + for (const c of sorted) { + const k = c === GUKJIN && gukjinAsPi ? 'pi' : cardInfo(c, s.rules.nb).kind; + out[k].push(cv(s, c)); + } + for (const k of ['gwang', 'yeol', 'tti'] as const) out[k].sort((a, b) => a.id - b.id); + return out; +} + +function handHint(s: GostopState, p: number, c: CardId): string | null { + const r = s.round!; + const g = floorGroup(r, monthOf(c)); + if (!g) return null; + const take = g.cards.length === 2 ? [bestCard(g.cards, s.rules.nb)] : g.cards; + const before = bestScore(r.captured[p]!, s.rules).calc; + const after = bestScore([...r.captured[p]!, c, ...take], s.rules).calc; + const base = (n: string) => n.replace(/ .*$/, ''); + const had = new Set(before.items.map((x) => base(x.name))); + const fresh = after.items.find((x) => !had.has(base(x.name))); + if (fresh) return `${base(fresh.name)} 완성`; + if (after.total > before.total) return `+${after.total - before.total}점`; + return null; +} + +function goStopPreview(s: GostopState, p: number): GostopView['goStop'] { + const r = s.round!; + const st = settle(settleInput(s, p)); + const score = currentScore(s, p); + const notes = st.multipliers.map((m) => `${m.name} x${m.factor}`); + const next = r.goCount[p]! + 1; + return { + player: s.players[p]!, + score, + goCount: r.goCount[p]!, + stopNow: { + total: st.payments.reduce((a, x) => a + x.amount, 0), + perLoser: st.payments.map((x) => ({ player: s.players[x.from]!, amount: x.amount, baks: x.baks })), + notes, + }, + goNext: { goCount: next, plus: next, factor: goFactor(next) }, + }; +} + +function outcomeView(s: GostopState, r: RoundState): OutcomeView | null { + const o = r.outcome; + if (!o) return null; + const log = s.log.find((l) => l.no === r.no); + return { + ...o, + winner: o.winner === null ? null : s.players[o.winner]!, + payments: o.payments.map((x) => ({ from: s.players[x.from]!, amount: x.amount, baks: x.baks, coveredBy: x.coveredBy === undefined ? null : s.players[x.coveredBy]! })), + shown: o.shown.map((c) => cv(s, c)), + delta: Object.fromEntries(s.players.map((id, i) => [id, log?.delta[i] ?? 0])), + }; +} + +// ---------------------------------------------------------------- definition + +export const gostop: GameDefinition = { + id: 'gostop', + nameKo: '고스톱', + minPlayers: 2, + maxPlayers: 5, + stateVersion: 1, + defaultOptions: gostopOptionsSchema.parse({}), + optionsSchema: gostopOptionsSchema as unknown as z.ZodType, + actionSchema: gostopActionSchema as unknown as z.ZodType, + playersFor: playerRange, + playerCountError(o, count) { + return count > 3 && !o.gwangPalgi ? '광팔기를 끄면 3명까지만 할 수 있어요.' : null; + }, + + setup({ players, options, rng, now }) { + const range = playerRange(options); + if (players.length < range.min || players.length > range.max) throw new Error(`gostop: ${players.length} players not allowed`); + const n = players.length; + const zeros = () => Array.from({ length: n }, () => 0); + const s: GostopState = { + options, + rules: resolveRules(options, n), + mode: n === 2 ? 'matgo' : 'gostop', + players: [...players], + totals: zeros(), + roundNo: 0, + dealer: rng.int(n), + nagariStreak: 0, + round: null, + phase: 'playing', + ready: [], + left: players.map(() => false), + autoPilot: players.map(() => false), + timeoutStreak: zeros(), + deadline: null, + waitKey: '', + actionSeq: 0, + log: [], + stats: { wins: zeros(), goCalls: zeros(), piBakGiven: zeros(), gwangSold: zeros(), nagari: 0 }, + final: null, + }; + startRound(s, { rng, events: [] }); + refreshDeadline(s, now); + return s; + }, + + validate(s, actor, a) { + const p = idx(s, actor); + if (p < 0) return fail('이 게임의 플레이어가 아니에요.'); + if (s.phase === 'finished') return fail('게임이 이미 끝났어요.'); + if (a.type === 'leave') return s.left[p] ? fail('이미 나간 플레이어예요.') : ok; + if (a.type === 'timeout') return waiting(s).includes(p) ? ok : fail('시간 초과 대상이 아니에요.'); + if (s.left[p]) return fail('방을 나간 플레이어예요.'); + if (a.type === 'ready') { + if (s.phase !== 'roundResult') return fail('아직 판이 끝나지 않았어요.'); + if (s.ready.includes(p)) return fail('이미 준비했어요.'); + return ok; + } + const r = s.round; + if (s.phase !== 'playing' || !r) return fail('지금은 할 수 없어요.'); + const mine = waiting(s).includes(p); + switch (a.type) { + case 'sell': + if (r.step !== 'sell') return fail('지금은 광을 팔 때가 아니에요.'); + if (p === s.dealer) return fail('선은 광을 팔 수 없어요.'); + if (!mine) return fail('지금은 내가 정할 차례가 아니에요.'); + return ok; + case 'chongtong': + if (r.step !== 'chongtong' || !mine) return fail('총통을 선언할 수 없어요.'); + return ok; + case 'play': + if (r.step !== 'play') return fail(r.step === 'chooseFlip' ? '먼저 뒤집은 패로 가져올 패를 골라 주세요.' : '지금은 패를 낼 때가 아니에요.'); + if (!mine) return fail('지금은 내 차례가 아니에요.'); + return validatePlay(s, p, a); + case 'chooseFlip': + if (r.step !== 'chooseFlip' || !mine) return fail('지금은 고를 패가 없어요.'); + if (!r.pending!.options.includes(a.target)) return fail('뒤집은 패와 같은 월인 바닥 패 중에서 골라 주세요.'); + return ok; + case 'goStop': + if (r.step !== 'goStop' || !mine) return fail('지금은 고·스톱을 고를 때가 아니에요.'); + if (a.decision === 'go' && r.hands[p]!.length === 0) return fail('마지막 턴에는 고를 할 수 없어요.'); + return ok; + case 'gukjin': + if (r.step !== 'gukjin' || !mine) return fail('지금은 국진을 정할 때가 아니에요.'); + return ok; + } + return fail('알 수 없는 행동이에요.'); + }, + + apply(prev, actor, a, { rng, now }) { + const s = structuredClone(prev); + const events: GameEvent[] = []; + const ctx: Ctx = { rng, events }; + const p = idx(s, actor); + const r = s.round; + if (a.type !== 'leave') s.actionSeq += 1; + if (a.type !== 'timeout' && a.type !== 'leave') { + s.timeoutStreak[p] = 0; + if (s.autoPilot[p] && !s.left[p]) { + s.autoPilot[p] = false; + events.push({ type: 'autoPilot', player: actor, on: false }); + } + } + let act: GostopAction = a; + if (a.type === 'timeout') { + events.push({ type: 'timeout', player: actor }); + if (s.phase === 'roundResult') act = { type: 'ready' }; + else { + s.timeoutStreak[p]! += 1; + if (s.timeoutStreak[p]! >= 2 && !s.autoPilot[p]) { + s.autoPilot[p] = true; + events.push({ type: 'autoPilot', player: actor, on: true }); + } + switch (r!.step) { + case 'sell': + act = { type: 'sell', sell: true }; + break; + case 'chongtong': + act = { type: 'chongtong', declare: true }; + break; + case 'play': + act = autoPlay(s, p); + break; + case 'chooseFlip': + act = { type: 'chooseFlip', target: bestCard(r!.pending!.options, s.rules.nb) }; + break; + case 'goStop': + act = { type: 'goStop', decision: 'stop' }; + break; + case 'gukjin': + act = { type: 'gukjin', as: autoGukjin(s) }; + break; + default: + throw new Error(`gostop: timeout in step ${r!.step}`); + } + } + } + switch (act.type) { + case 'leave': + s.left[p] = true; + s.autoPilot[p] = true; + events.push({ type: 'left', player: actor }); + break; + case 'ready': + s.ready.push(p); + break; + case 'sell': + decideSell(s, p, act.sell, ctx); + break; + case 'chongtong': + decideChongtong(s, p, act.declare, ctx); + break; + case 'play': + playCard(s, p, act, ctx); + break; + case 'chooseFlip': + chooseFlip(s, act.target, ctx); + break; + case 'goStop': + decideGoStop(s, p, act.decision, ctx); + break; + case 'gukjin': + decideGukjin(s, p, act.as, ctx); + break; + } + progress(s, ctx); + refreshDeadline(s, now); + return { state: s, events }; + }, + + view(s, viewer) { + const r = s.round!; + const meIdx = viewer === null ? -1 : idx(s, viewer); + const me = meIdx >= 0 ? viewer : null; + const step = s.phase === 'playing' ? r.step : null; + const turnIdx = step && step !== 'sell' && step !== 'done' ? r.turn : null; + const gukjinPi = r.outcome?.gukjinAs === 'pi'; + const holder = r.participants.find((q) => r.captured[q]!.includes(GUKJIN)); + const seats: SeatView[] = s.players.map((id, i) => { + const seller = r.sellers.find((x) => x.idx === i); + return { + id, + total: s.totals[i]!, + participant: r.participants.includes(i), + seller: seller ? { shown: seller.shown.map((c) => cardView(c, s.rules.nb, `${keyOf(s)}s`)), paid: seller.paid } : null, + handCount: r.hands[i]!.length, + dummies: r.bombDummies[i]!, + revealed: r.revealed[i]!.map((c) => cv(s, c)), + captured: grouped(s, r.captured[i]!, gukjinPi && holder === i), + score: scoreView(s, r.captured[i]!, r.outcome?.gukjinAs && holder === i ? r.outcome.gukjinAs : undefined), + goCount: r.goCount[i]!, + shakes: r.shakes[i]!, + bombs: r.bombs[i]!, + ppeok: r.ppeokCount[i]!, + left: s.left[i]!, + autoPilot: s.autoPilot[i]!, + ready: s.phase === 'roundResult' && s.ready.includes(i), + isDealer: r.participants.length ? r.participants[0] === i : s.dealer === i, + isTurn: turnIdx === i || (step === 'sell' && sellDecider(s) === i), + }; + }); + let myHand: HandCardView[] | null = null; + if (me && s.phase === 'playing' && r.participants.includes(meIdx)) { + myHand = [...r.hands[meIdx]!] + .sort((a, b) => monthOf(a) - monthOf(b) || a - b) + .map((c) => { + if (isBonus(c)) return { card: cv(s, c), matches: [], needTarget: false, canShake: false, canBomb: false, hint: s.rules.hints ? '보너스패: 내고 한 장 더 받아요' : null }; + const m = monthOf(c); + const g = floorGroup(r, m); + const n = handMonthCount(r, meIdx, m); + return { + card: cv(s, c), + matches: g ? [...g.cards] : [], + needTarget: !!g && g.cards.length === 2 && needsTarget(s, m), + canShake: s.rules.shake && n >= 3 && !g && !r.shakenMonths[meIdx]!.includes(m), + canBomb: s.rules.bomb && n === 3 && g?.cards.length === 1, + hint: s.rules.hints ? handHint(s, meIdx, c) : null, + }; + }); + } else if (me && r.step === 'sell' && s.phase === 'playing') { + myHand = [...r.hands[meIdx]!].sort((a, b) => monthOf(a) - monthOf(b) || a - b).map((c) => ({ card: cv(s, c), matches: [], needTarget: false, canShake: false, canBomb: false, hint: null })); + } + const decider = step === 'sell' ? sellDecider(s) : null; + const lt = r.lastTurn; + const stopper = step === 'gukjin' ? r.stopBy : null; + return { + rules: s.rules, + mode: s.mode, + me, + phase: s.phase, + step, + roundNo: s.roundNo, + dealer: s.players[r.participants[0] ?? s.dealer]!, + nagariStreak: s.nagariStreak, + nagariMult: r.nagariMult, + turn: turnIdx === null ? null : s.players[turnIdx]!, + deckCount: r.deck.length, + floor: r.floor.map((g) => ({ month: g.month, cards: g.cards.map((c) => cv(s, c)), ppeokBy: g.ppeokBy === null ? null : s.players[g.ppeokBy]!, natural: g.natural })), + seats, + myHand, + myDummies: meIdx >= 0 ? r.bombDummies[meIdx]! : 0, + pendingFlip: + step === 'chooseFlip' && r.pending + ? { player: s.players[r.turn]!, flipped: cv(s, r.pending.flipped), options: r.pending.options.map((c) => cv(s, c)) } + : null, + goStop: step === 'goStop' ? goStopPreview(s, r.turn) : null, + gukjinAsk: + step === 'gukjin' && stopper !== null + ? { + player: s.players[r.turn]!, + yeolScore: scoreOf(r.captured[r.turn]!, s.rules, 'yeol').total, + piScore: scoreOf(r.captured[r.turn]!, s.rules, 'pi').total, + } + : null, + chongtongAsk: step === 'chongtong' ? { player: s.players[r.turn]!, months: meIdx === r.turn ? hasChongtong(r, r.turn) : null } : null, + sell: + r.sellOrder.length > 0 + ? { + current: decider === null ? null : s.players[decider]!, + decisions: r.sellDecisions.map((d) => ({ player: s.players[d.idx]!, sell: d.sell, auto: d.auto })), + mySellable: decider !== null && decider === meIdx ? sellableCards(s, meIdx).map((c) => cv(s, c)) : null, + canSell: decider !== null && decider !== s.dealer, + } + : null, + lastTurn: lt + ? { + seq: lt.seq, + tags: lt.tags, + bonusPoints: lt.bonusPoints, + player: s.players[lt.player]!, + played: lt.played.map((c) => cv(s, c)), + flipped: lt.flipped.map((c) => cv(s, c)), + captured: lt.captured.map((c) => cv(s, c)), + stolen: lt.stolen.map(([q, cs]) => ({ from: s.players[q]!, cards: cs.map((c) => cv(s, c)) })), + } + : null, + outcome: outcomeView(s, r), + log: s.log.map((l) => ({ ...l, winner: l.winner === null ? null : s.players[l.winner]!, delta: Object.fromEntries(s.players.map((id, i) => [id, l.delta[i] ?? 0])) })), + ready: s.ready.map((i) => s.players[i]!), + deadline: s.deadline, + result: s.final, + stats: statsView(s), + }; + }, + + activePlayers(s) { + return waiting(s).map((i) => s.players[i]!); + }, + + deadline(s) { + return s.phase === 'finished' ? null : s.deadline; + }, + + onTimeout() { + return { type: 'timeout' }; + }, + + onLeave(s, player) { + const p = idx(s, player); + if (p < 0 || s.left[p] || s.phase === 'finished') return null; + return { type: 'leave' }; + }, + + result(s): GameResult | null { + if (!s.final) return null; + return { ranking: s.final.ranking, scores: s.final.scores, summary: s.final.summary, reason: 'normal' }; + }, + + legalActions(s, player) { + const p = idx(s, player); + return p < 0 ? [] : legalFor(s, p); + }, + + /** Unrevealed hand cards of `player` plus the deck order (keys as used in views). */ + secretsOf(s, player) { + const r = s.round; + const p = idx(s, player); + if (!r || p < 0) return []; + const key = (c: CardId) => `${keyOf(s)}c${c}`; + const own = r.hands[p]!.filter((c) => !r.revealed[p]!.includes(c)).map(key); + return [...own, ...r.deck.map(key)]; + }, +}; + +function statsView(s: GostopState): GostopView['stats'] { + let best: GostopView['stats']['bestRound'] = null; + for (const l of s.log) { + if (l.winner !== null && (!best || l.amount > best.amount)) best = { player: s.players[l.winner]!, amount: l.amount, roundNo: l.no, breakdown: l.breakdown }; + } + return { + rounds: s.log.length, + nagari: s.stats.nagari, + bestRound: best, + perPlayer: s.players.map((id, i) => ({ + player: id, + wins: s.stats.wins[i]!, + goCalls: s.stats.goCalls[i]!, + piBakGiven: s.stats.piBakGiven[i]!, + gwangSold: s.stats.gwangSold[i]!, + })), + }; +} diff --git a/packages/games/src/gostop/round.ts b/packages/games/src/gostop/round.ts new file mode 100644 index 0000000..3272800 --- /dev/null +++ b/packages/games/src/gostop/round.ts @@ -0,0 +1,744 @@ +/** 고스톱 판 진행 (docs/games/gostop.md §4, §5): 패 돌리기, 광팔기, 총통, 먹기 판정, 피 뺏기, 고/스톱, 정산. */ +import type { GameEvent, Rng } from '@bg/engine'; +import { BONUS_COUNT, bestCard, cardInfo, cardValue, fullDeck, isBonus, monthOf, sameKind, type CardId } from './cards'; +import { bestScore, settle } from './scoring'; +import type { FloorGroup, GostopOptions, GostopRules, GostopState, PlayAction, RoundOutcome, RoundState, TurnCtx, TurnRecord } from './types'; + +export interface Ctx { + rng: Rng; + events: GameEvent[]; +} + +const REDEAL_GUARD = 50; + +export function resolveRules(o: GostopOptions, players: number): GostopRules { + const matgo = players === 2; + const bonusCards = o.bonusCards === 'auto' ? (matgo ? '2+1' : 'none') : o.bonusCards; + return { + ...o, + bonusCards, + nb: BONUS_COUNT[bonusCards], + winScore: o.winScore === 'auto' ? (matgo ? 7 : 3) : o.winScore, + piBakLimit: o.piBakLimit === 'auto' ? (matgo ? 7 : 5) : o.piBakLimit, + chongtongPoints: o.chongtongPoints === 'auto' ? (matgo ? 10 : 5) : o.chongtongPoints, + }; +} + +// ---------------------------------------------------------------- small helpers + +export const floorGroup = (r: RoundState, month: number): FloorGroup | undefined => r.floor.find((g) => g.month === month); +export const floorCount = (r: RoundState, month: number): number => floorGroup(r, month)?.cards.length ?? 0; +export const handMonthCount = (r: RoundState, p: number, month: number): number => r.hands[p]!.filter((c) => !isBonus(c) && monthOf(c) === month).length; + +function removeFrom(arr: CardId[], id: CardId): void { + const i = arr.indexOf(id); + if (i < 0) throw new Error(`gostop: card ${id} not found`); + arr.splice(i, 1); +} + +function putOnFloor(r: RoundState, id: CardId): void { + const m = monthOf(id); + const g = floorGroup(r, m); + if (g) g.cards.push(id); + else { + r.floor.push({ month: m, cards: [id], ppeokBy: null, natural: false }); + r.floor.sort((a, b) => a.month - b.month); + } +} + +function takeFromFloor(r: RoundState, ids: CardId[]): void { + for (const id of ids) { + const g = floorGroup(r, monthOf(id)); + if (!g) throw new Error(`gostop: floor card ${id} missing`); + removeFrom(g.cards, id); + if (g.cards.length === 0) r.floor = r.floor.filter((x) => x !== g); + } +} + +function capture(r: RoundState, ctx: TurnCtx, ids: CardId[]): void { + r.captured[ctx.player]!.push(...ids); + ctx.captured.push(...ids); +} + +/** Players from `start` in turn order (seat order). */ +export function orderFrom(n: number, start: number): number[] { + return Array.from({ length: n }, (_, i) => (start + i) % n); +} + +export function currentScore(s: GostopState, p: number): number { + return bestScore(s.round!.captured[p]!, s.rules).calc.total; +} + +export function hasChongtong(r: RoundState, p: number): number[] { + const out: number[] = []; + for (let m = 1; m <= 12; m++) if (handMonthCount(r, p, m) === 4) out.push(m); + return out; +} + +// ---------------------------------------------------------------- dealing + +function emptyRound(s: GostopState): RoundState { + const n = s.players.length; + const arr = (f: () => T): T[] => Array.from({ length: n }, f); + const rules = s.rules; + return { + no: s.roundNo, + participants: [], + sellOrder: [], + sellCursor: 0, + sellDecisions: [], + sellers: [], + hands: arr(() => []), + bombDummies: arr(() => 0), + revealed: arr(() => []), + shakenMonths: arr(() => []), + deck: [], + floor: [], + captured: arr(() => []), + gukjinChoice: arr(() => null), + goCount: arr(() => 0), + lastGoScore: arr(() => 0), + goOrder: [], + shakes: arr(() => 0), + bombs: arr(() => 0), + ppeokCount: arr(() => 0), + ppeokTurns: arr(() => []), + turnsTaken: arr(() => 0), + turn: s.dealer, + step: 'play', + pending: null, + stopBy: null, + chongtongBy: null, + nagariMult: rules.nagariMultiplier === 'x2' ? Math.min(2 ** s.nagariStreak, rules.nagariMaxMultiplier) : 1, + instant: arr(() => 0), + turnSeq: 0, + lastTurn: null, + outcome: null, + redeals: 0, + }; +} + +/** + * Shuffles and deals to `who` (§4.3), then cleans the floor (§4.4): floor bonus cards go to the dealer + * with a refill from the deck; four of a month on the floor → shuffle again. + */ +function deal(s: GostopState, r: RoundState, who: number[], ctx: Ctx): void { + const rules = s.rules; + const handSize = s.mode === 'matgo' ? 10 : 7; + const floorSize = s.mode === 'matgo' ? 8 : 6; + for (let guard = 0; ; guard++) { + const deck = ctx.rng.shuffle(fullDeck(rules.nb)); + for (const p of who) r.hands[p] = deck.splice(0, handSize); + const floor = deck.splice(0, floorSize); + const taken: CardId[] = []; + for (let i = 0; i < floor.length; i++) { + while (isBonus(floor[i]!)) { + taken.push(floor[i]!); + floor[i] = deck.shift()!; + } + } + const counts = new Map(); + for (const c of floor) counts.set(monthOf(c), (counts.get(monthOf(c)) ?? 0) + 1); + if ([...counts.values()].some((v) => v === 4) && guard < REDEAL_GUARD) { + r.redeals++; + ctx.events.push({ type: 'redeal', reason: 'floorFour' }); + continue; + } + r.deck = deck; + r.captured = r.captured.map(() => []); + r.captured[s.dealer] = taken; + r.floor = []; + for (const c of floor) putOnFloor(r, c); + for (const g of r.floor) if (g.cards.length === 3) g.natural = true; + ctx.events.push({ type: 'dealt', players: who.map((p) => s.players[p]), handSize, floor: [...floor] }); + if (taken.length) ctx.events.push({ type: 'bonusTaken', player: s.players[s.dealer], cards: taken }); + return; + } +} + +export function startRound(s: GostopState, ctx: Ctx): void { + s.roundNo += 1; + s.phase = 'playing'; + s.ready = []; + const r = emptyRound(s); + s.round = r; + const n = s.players.length; + const order = orderFrom(n, s.dealer); + ctx.events.push({ type: 'roundStart', roundNo: s.roundNo, dealer: s.players[s.dealer], nagariMult: r.nagariMult }); + deal(s, r, order, ctx); + if (n >= 4) { + r.step = 'sell'; + r.sellOrder = order.slice(1); + r.sellCursor = 0; + progressSell(s, ctx); + } else { + r.participants = order; + afterDeal(s, ctx); + } +} + +// ---------------------------------------------------------------- 광팔기 (§5.8) + +export function sellableCards(s: GostopState, p: number): CardId[] { + const r = s.round!; + return r.hands[p]!.filter((c) => { + const i = cardInfo(c, s.rules.nb); + if (i.kind === 'gwang') return true; + return s.rules.gwangSellCards === 'gwangPlus' && (i.gukjin || i.bonus || (i.kind === 'pi' && i.piValue === 2)); + }).sort((a, b) => a - b); +} + +/** Who must decide next, or null when the remaining decisions are automatic. */ +export function sellDecider(s: GostopState): number | null { + const r = s.round!; + if (r.step !== 'sell' || r.sellCursor >= r.sellOrder.length) return null; + const n = s.players.length; + const plays = 1 + r.sellDecisions.filter((d) => !d.sell).length; + const dies = r.sellDecisions.filter((d) => d.sell).length; + if (plays >= 3 || dies >= n - 3) return null; + return r.sellOrder[r.sellCursor]!; +} + +export function decideSell(s: GostopState, p: number, sell: boolean, ctx: Ctx): void { + const r = s.round!; + r.sellDecisions.push({ idx: p, sell, auto: false }); + r.sellCursor++; + ctx.events.push({ type: sell ? 'sellDie' : 'sellPlay', player: s.players[p] }); + progressSell(s, ctx); +} + +function progressSell(s: GostopState, ctx: Ctx): void { + const r = s.round!; + const n = s.players.length; + while (r.sellCursor < r.sellOrder.length) { + const plays = 1 + r.sellDecisions.filter((d) => !d.sell).length; + const dies = r.sellDecisions.filter((d) => d.sell).length; + if (plays < 3 && dies < n - 3) return; // a real decision is needed + const p = r.sellOrder[r.sellCursor]!; + r.sellDecisions.push({ idx: p, sell: plays >= 3, auto: true }); + r.sellCursor++; + } + finishSelling(s, ctx); +} + +function finishSelling(s: GostopState, ctx: Ctx): void { + const r = s.round!; + const rules = s.rules; + const order = orderFrom(s.players.length, s.dealer); + const dying = new Set(r.sellDecisions.filter((d) => d.sell).map((d) => d.idx)); + r.participants = order.filter((p) => !dying.has(p)); + const payers = rules.gwangPayer === 'nonDealerPlayers' ? r.participants.slice(1) : r.participants; + const back: CardId[] = []; + for (const p of order) { + if (!dying.has(p)) continue; + const shown = sellableCards(s, p); + const each = shown.length * rules.gwangPrice; + for (const q of payers) { + s.totals[q]! -= each; + r.instant[q]! -= each; + } + s.totals[p]! += each * payers.length; + r.instant[p]! += each * payers.length; + s.stats.gwangSold[p]! += shown.length; + r.sellers.push({ idx: p, shown, paid: each * payers.length }); + ctx.events.push({ type: 'gwangSold', player: s.players[p], cards: shown, points: each * payers.length }); + back.push(...r.hands[p]!); + r.hands[p] = []; + } + r.deck = ctx.rng.shuffle([...r.deck, ...back]); + afterDeal(s, ctx); +} + +// ---------------------------------------------------------------- 총통 (§5.7) + +function afterDeal(s: GostopState, ctx: Ctx): void { + const r = s.round!; + for (let guard = 0; guard <= REDEAL_GUARD; guard++) { + const holder = s.rules.chongtong === 'off' ? undefined : r.participants.find((p) => hasChongtong(r, p).length > 0); + if (holder === undefined) break; + if (s.rules.chongtong === 'win') { + r.step = 'chongtong'; + r.chongtongBy = holder; + r.turn = holder; + return; + } + if (guard === REDEAL_GUARD) break; + r.redeals++; + ctx.events.push({ type: 'redeal', reason: 'chongtong' }); + deal(s, r, r.participants, ctx); + } + startPlay(s); +} + +function startPlay(s: GostopState): void { + const r = s.round!; + r.step = 'play'; + r.chongtongBy = null; + r.turn = r.participants[0]!; +} + +export function decideChongtong(s: GostopState, p: number, declare: boolean, ctx: Ctx): void { + const r = s.round!; + if (!declare) { + ctx.events.push({ type: 'chongtongSkip', player: s.players[p] }); + startPlay(s); + return; + } + const months = hasChongtong(r, p); + const shown = r.hands[p]!.filter((c) => !isBonus(c) && monthOf(c) === months[0]).sort((a, b) => a - b); + r.revealed[p]!.push(...shown); + ctx.events.push({ type: 'chongtong', player: s.players[p], cards: shown }); + instantWin(s, p, 'chongtong', shown, ctx); +} + +/** 총통·삼뻑: points × 나가리 배수 from every opponent, no 박 (§5.7, §5.11). */ +function instantWin(s: GostopState, p: number, type: 'chongtong' | 'samppeok', shown: CardId[], ctx: Ctx): void { + const r = s.round!; + const amount = s.rules.chongtongPoints * r.nagariMult; + const outcome: RoundOutcome = { + type, + winner: p, + baseScore: s.rules.chongtongPoints, + breakdown: [{ name: type === 'chongtong' ? '총통' : '삼뻑', points: s.rules.chongtongPoints }], + goCount: 0, + multipliers: r.nagariMult > 1 ? [{ name: '나가리 배수', factor: r.nagariMult }] : [], + perLoser: amount, + payments: r.participants.filter((q) => q !== p).map((q) => ({ from: q, amount, baks: [] })), + gukjinAs: null, + shown, + }; + endRound(s, outcome, ctx); +} + +// ---------------------------------------------------------------- a turn (§5.1–5.6) + +/** Two floor cards that differ → the player must pick (§5.2). */ +export function needsTarget(s: GostopState, month: number): boolean { + const g = floorGroup(s.round!, month); + return !!g && g.cards.length === 2 && !sameKind(g.cards[0]!, g.cards[1]!, s.rules.nb); +} + +function flip(s: GostopState, ctx: TurnCtx): CardId { + const r = s.round!; + for (;;) { + const c = r.deck.shift(); + if (c === undefined) throw new Error('gostop: deck exhausted'); + ctx.flipped.push(c); + if (!isBonus(c)) return c; + capture(r, ctx, [c]); + ctx.tags.push('bonusFlip'); + } +} + +function pileSteal(s: GostopState, g: FloorGroup, ctx: TurnCtx): void { + if (g.ppeokBy === ctx.player) { + ctx.steal += s.rules.jaPpeokSteal; + ctx.tags.push('jaPpeok'); + } else if (g.ppeokBy !== null) { + ctx.steal += 1; + ctx.tags.push('ppeokEat'); + } else if (g.natural) { + if (s.rules.naturalThreeSteal) ctx.steal += 1; + ctx.tags.push('ppeokEat'); + } +} + +const sweepCounts = (s: GostopState, ctx: TurnCtx): boolean => !ctx.lastOfRound || s.rules.lastTurnSweepBonus; + +export function playCard(s: GostopState, p: number, a: PlayAction, ctx: Ctx): void { + const r = s.round!; + const nb = s.rules.nb; + const t: TurnCtx = { player: p, played: [], flipped: [], captured: [], tags: [], steal: 0, lastOfRound: false }; + // 보너스패: goes to my pile, I take the deck top and play again in the same turn (§5.6). + if (a.card !== 'dummy' && isBonus(a.card)) { + removeFrom(r.hands[p]!, a.card); + capture(r, t, [a.card]); + t.played.push(a.card); + t.tags.push('bonus'); + const drawn = r.deck.shift(); + if (drawn === undefined) throw new Error('gostop: deck exhausted (bonus draw)'); + r.hands[p]!.push(drawn); + ctx.events.push({ type: 'bonusPlayed', player: s.players[p], card: a.card }); + record(s, t, ctx); + return; + } + let H: CardId | null = null; + let m1 = 0; + let k = 0; + if (a.card === 'dummy') { + r.bombDummies[p]! -= 1; + t.tags.push('dummy'); + ctx.events.push({ type: 'dummyPlayed', player: s.players[p] }); + } else if (a.bomb) { + const m = monthOf(a.card); + const mine = r.hands[p]!.filter((c) => !isBonus(c) && monthOf(c) === m).sort((x, y) => x - y); + for (const c of mine) removeFrom(r.hands[p]!, c); + r.revealed[p] = r.revealed[p]!.filter((c) => !mine.includes(c)); + const g = floorGroup(r, m)!; + const floorCards = [...g.cards]; + takeFromFloor(r, floorCards); + t.played.push(...mine); + capture(r, t, [...mine, ...floorCards]); + t.steal += 1; + t.tags.push('bomb'); + r.bombs[p]! += 1; + r.bombDummies[p]! += 2; + ctx.events.push({ type: 'bomb', player: s.players[p], cards: mine }); + } else { + H = a.card; + m1 = monthOf(H); + k = floorCount(r, m1); + removeFrom(r.hands[p]!, H); + r.revealed[p] = r.revealed[p]!.filter((c) => c !== H); + t.played.push(H); + if (a.shake) { + const rest = r.hands[p]!.filter((c) => !isBonus(c) && monthOf(c) === m1); + r.shakes[p]! += 1; + r.shakenMonths[p]!.push(m1); + for (const c of rest) if (!r.revealed[p]!.includes(c)) r.revealed[p]!.push(c); + t.tags.push('shake'); + ctx.events.push({ type: 'shake', player: s.players[p], cards: [H, ...rest].sort((x, y) => x - y) }); + } + ctx.events.push({ type: 'played', player: s.players[p], card: H }); + } + const F = flip(s, t); + t.lastOfRound = r.deck.length === 0; + ctx.events.push({ type: 'flipped', player: s.players[p], cards: [...t.flipped] }); + if (H !== null) { + const g = floorGroup(r, m1); + if (monthOf(F) === m1) { + if (k === 0) { + capture(r, t, [H, F]); + t.tags.push('jjok'); + if (sweepCounts(s, t)) t.steal += 1; + } else if (k === 1) { + g!.cards.push(H, F); + g!.ppeokBy = p; + g!.natural = false; + r.ppeokCount[p]! += 1; + r.ppeokTurns[p]!.push(r.turnsTaken[p]!); + t.tags.push('ppeok'); + } else { + // k = 2 → 따닥 (k = 3 is impossible: the 4th card of the month was H). + const cards = [...g!.cards]; + takeFromFloor(r, cards); + capture(r, t, [...cards, H, F]); + t.tags.push('ttadak'); + t.steal += 1; + } + finishTurn(s, t, ctx); + return; + } + if (k === 0) putOnFloor(r, H); + else if (k === 1) { + const c = g!.cards[0]!; + takeFromFloor(r, [c]); + capture(r, t, [H, c]); + } else if (k === 2) { + const target = a.target ?? bestCard(g!.cards, nb); + takeFromFloor(r, [target]); + capture(r, t, [H, target]); + } else { + const cards = [...g!.cards]; + pileSteal(s, g!, t); + takeFromFloor(r, cards); + capture(r, t, [...cards, H]); + } + } + resolveFlip(s, F, t, ctx); +} + +/** F 처리 (§5.2): j = floor cards of F's month. */ +function resolveFlip(s: GostopState, F: CardId, t: TurnCtx, ctx: Ctx): void { + const r = s.round!; + const nb = s.rules.nb; + const g = floorGroup(r, monthOf(F)); + const j = g?.cards.length ?? 0; + if (j === 0) putOnFloor(r, F); + else if (j === 1) { + const c = g!.cards[0]!; + takeFromFloor(r, [c]); + capture(r, t, [F, c]); + } else if (j === 2) { + if (sameKind(g!.cards[0]!, g!.cards[1]!, nb)) { + const c = bestCard(g!.cards, nb); + takeFromFloor(r, [c]); + capture(r, t, [F, c]); + } else { + r.pending = { flipped: F, options: [...g!.cards].sort((a, b) => a - b), ctx: t }; + r.step = 'chooseFlip'; + return; + } + } else { + const cards = [...g!.cards]; + pileSteal(s, g!, t); + takeFromFloor(r, cards); + capture(r, t, [...cards, F]); + } + finishTurn(s, t, ctx); +} + +export function chooseFlip(s: GostopState, target: CardId, ctx: Ctx): void { + const r = s.round!; + const pend = r.pending!; + r.pending = null; + r.step = 'play'; + takeFromFloor(r, [target]); + capture(r, pend.ctx, [pend.flipped, target]); + finishTurn(s, pend.ctx, ctx); +} + +/** Gives `n` 피 per opponent, lowest value first (일반 피 → 쌍피·2피 → 3피), then lowest id (§5.3). */ +function steal(s: GostopState, t: TurnCtx): [number, CardId[]][] { + const r = s.round!; + const nb = s.rules.nb; + const out: [number, CardId[]][] = []; + for (const q of r.participants) { + if (q === t.player) continue; + const pis = r.captured[q]!.filter((c) => cardInfo(c, nb).kind === 'pi').sort((a, b) => cardInfo(a, nb).piValue - cardInfo(b, nb).piValue || a - b); + const given = pis.slice(0, t.steal); + for (const c of given) removeFrom(r.captured[q]!, c); + r.captured[t.player]!.push(...given); + if (given.length) out.push([q, given]); + } + return out; +} + +function record(s: GostopState, t: TurnCtx, ctx: Ctx, stolen: [number, CardId[]][] = [], bonusPoints = 0): TurnRecord { + const r = s.round!; + r.turnSeq += 1; + const rec: TurnRecord = { seq: r.turnSeq, player: t.player, played: t.played, flipped: t.flipped, captured: t.captured, tags: [...t.tags], stolen, bonusPoints }; + r.lastTurn = rec; + return rec; +} + +function finishTurn(s: GostopState, t: TurnCtx, ctx: Ctx): void { + const r = s.round!; + const p = t.player; + const others = r.participants.filter((q) => q !== p); + if (r.floor.length === 0) { + t.tags.push('sseul'); + if (sweepCounts(s, t)) t.steal += 1; + } + const stolen = t.steal > 0 ? steal(s, t) : []; + const turnIdx = r.turnsTaken[p]!; + r.turnsTaken[p]! += 1; + // 첫뻑·연뻑·삼뻑 (§5.11) + let bonusPoints = 0; + let samppeok = false; + if (t.tags.includes('ppeok') && s.rules.ppeokBonus) { + if (r.ppeokCount[p]! >= 3) { + samppeok = true; + t.tags.push('samPpeok'); + } else if (turnIdx === 0) { + bonusPoints = 3; + t.tags.push('firstPpeok'); + } else if (turnIdx === 1 && r.ppeokTurns[p]!.includes(0)) { + bonusPoints = 6; + t.tags.push('yeonPpeok'); + } + for (const q of others) { + s.totals[q]! -= bonusPoints; + r.instant[q]! -= bonusPoints; + s.totals[p]! += bonusPoints; + r.instant[p]! += bonusPoints; + } + } + for (const tag of t.tags) if (!['bonus', 'bonusFlip', 'dummy', 'bomb', 'shake'].includes(tag)) ctx.events.push({ type: tag, player: s.players[p] }); + if (stolen.length) ctx.events.push({ type: 'piStolen', player: s.players[p], from: stolen.map(([q, cs]) => ({ player: s.players[q], cards: cs })) }); + record(s, t, ctx, stolen, bonusPoints); + if (samppeok) { + instantWin(s, p, 'samppeok', [], ctx); + return; + } + const score = currentScore(s, p); + ctx.events.push({ type: 'scoreUpdate', player: s.players[p], score }); + if (score >= s.rules.winScore && (r.goCount[p] === 0 || score > r.lastGoScore[p]!)) { + if (r.hands[p]!.length === 0) { + ctx.events.push({ type: 'stop', player: s.players[p], auto: true }); + stop(s, p, ctx); + } else { + r.step = 'goStop'; + r.turn = p; + ctx.events.push({ type: 'goStopPrompt', player: s.players[p], score }); + } + return; + } + advance(s, p, ctx); +} + +/** Next player with something to play; nobody left → 나가리 (§5.10). */ +function advance(s: GostopState, from: number, ctx: Ctx): void { + const r = s.round!; + const parts = r.participants; + const i = parts.indexOf(from); + for (let step = 1; step <= parts.length; step++) { + const q = parts[(i + step) % parts.length]!; + if (r.hands[q]!.length > 0 || r.bombDummies[q]! > 0) { + r.step = 'play'; + r.turn = q; + return; + } + } + if (r.deck.length !== 0) throw new Error('gostop: hands empty but deck not empty'); + ctx.events.push({ type: 'nagari' }); + endRound( + s, + { type: 'nagari', winner: null, baseScore: 0, breakdown: [], goCount: 0, multipliers: [], perLoser: 0, payments: [], gukjinAs: null, shown: [] }, + ctx, + ); +} + +export function decideGoStop(s: GostopState, p: number, decision: 'go' | 'stop', ctx: Ctx): void { + const r = s.round!; + if (decision === 'stop') { + ctx.events.push({ type: 'stop', player: s.players[p], auto: false }); + stop(s, p, ctx); + return; + } + r.goCount[p]! += 1; + r.lastGoScore[p] = currentScore(s, p); + r.goOrder.push(p); + s.stats.goCalls[p]! += 1; + ctx.events.push({ type: 'go', player: s.players[p], count: r.goCount[p] }); + advance(s, p, ctx); +} + +/** The 국진 owner who still has to choose (ask mode) before settling. */ +export function gukjinPending(s: GostopState): number | null { + const r = s.round!; + if (s.rules.gukjinMode !== 'ask') return null; + const holder = r.participants.find((q) => r.captured[q]!.includes(32)); + return holder !== undefined && r.gukjinChoice[holder] === null ? holder : null; +} + +function stop(s: GostopState, p: number, ctx: Ctx): void { + const r = s.round!; + r.stopBy = p; + if (gukjinPending(s) !== null) { + r.step = 'gukjin'; + r.turn = gukjinPending(s)!; + return; + } + settleStop(s, ctx); +} + +export function settleInput(s: GostopState, winner: number) { + const r = s.round!; + return { + rules: s.rules, + mode: s.mode, + winner, + participants: r.participants, + captured: r.captured, + goCount: r.goCount, + goOrder: r.goOrder, + shakes: r.shakes, + bombs: r.bombs, + nagariMult: r.nagariMult, + gukjinChoice: r.gukjinChoice, + }; +} + +export function decideGukjin(s: GostopState, p: number, as: 'yeol' | 'pi', ctx: Ctx): void { + const r = s.round!; + r.gukjinChoice[p] = as; + ctx.events.push({ type: 'gukjin', player: s.players[p], as }); + settleStop(s, ctx); +} + +/** The automatic 국진 rule for the pending owner (timeout in ask mode). */ +export function autoGukjin(s: GostopState): 'yeol' | 'pi' { + return settle(settleInput(s, s.round!.stopBy!)).gukjinAs ?? 'yeol'; +} + +function settleStop(s: GostopState, ctx: Ctx): void { + const r = s.round!; + const st = settle(settleInput(s, r.stopBy!)); + const { calcs: _c, ...rest } = st; + endRound(s, { type: 'stop', ...rest, shown: [] }, ctx); +} + +// ---------------------------------------------------------------- round end + +function endRound(s: GostopState, outcome: RoundOutcome, ctx: Ctx): void { + const r = s.round!; + r.outcome = outcome; + r.step = 'done'; + r.pending = null; + const delta = [...r.instant]; + if (outcome.winner !== null) { + for (const pay of outcome.payments) { + s.totals[pay.from]! -= pay.amount; + s.totals[outcome.winner]! += pay.amount; + delta[pay.from]! -= pay.amount; + delta[outcome.winner]! += pay.amount; + if (pay.baks.includes('pi')) s.stats.piBakGiven[outcome.winner]! += 1; + } + s.stats.wins[outcome.winner]! += 1; + s.nagariStreak = 0; + s.dealer = outcome.winner; + } else { + s.stats.nagari += 1; + s.nagariStreak += 1; + } + const amount = outcome.payments.reduce((a, x) => a + x.amount, 0); + s.log.push({ + no: s.roundNo, + type: outcome.type, + winner: outcome.winner, + amount, + delta, + breakdown: [...outcome.breakdown.map((b) => `${b.name} ${b.points}점`), ...outcome.multipliers.map((m) => `${m.name} x${m.factor}`)], + }); + ctx.events.push({ type: 'roundEnded', roundNo: s.roundNo, outcome: outcome.type, winner: outcome.winner === null ? null : s.players[outcome.winner], amount }); + if (s.roundNo >= s.rules.rounds) finishGame(s, ctx); + else { + s.phase = 'roundResult'; + s.ready = []; + } +} + +function finishGame(s: GostopState, ctx: Ctx): void { + s.phase = 'finished'; + const ids = s.players.map((_, i) => i).sort((a, b) => s.totals[b]! - s.totals[a]! || a - b); + const ranking: string[][] = []; + let last: number | null = null; + for (const i of ids) { + if (last !== null && s.totals[i] === last) ranking[ranking.length - 1]!.push(s.players[i]!); + else ranking.push([s.players[i]!]); + last = s.totals[i]!; + } + const scores = Object.fromEntries(s.players.map((p, i) => [p, s.totals[i]!])); + const top = s.totals[ids[0]!]!; + const tie = ranking[0]!.length > 1 ? ` (공동 ${ranking[0]!.length}명)` : ''; + const nag = s.stats.nagari ? `, 나가리 ${s.stats.nagari}번` : ''; + const summary = `${s.roundNo}판 끝${nag}. 1위 ${top >= 0 ? '+' : ''}${top.toLocaleString('ko-KR')}점${tie}`; + s.final = { ranking, scores, summary }; + ctx.events.push({ type: 'setEnded', ranking }); +} + +// ---------------------------------------------------------------- automatic play (§8.5) + +/** Deterministic play for timeouts: 빈패 → 보너스패 → 가장 값진 바닥 패를 먹는 패 → 가장 싼 패. */ +export function autoPlay(s: GostopState, p: number): PlayAction { + const r = s.round!; + const nb = s.rules.nb; + if (r.bombDummies[p]! > 0) return { type: 'play', card: 'dummy' }; + const hand = [...r.hands[p]!].sort((a, b) => a - b); + const bonus = hand.find(isBonus); + if (bonus !== undefined) return { type: 'play', card: bonus }; + let best: { card: CardId; value: number } | null = null; + for (const c of hand) { + const g = floorGroup(r, monthOf(c)); + if (!g) continue; + const value = Math.max(...g.cards.map((x) => cardValue(x, nb))); + if (!best || value > best.value) best = { card: c, value }; + } + if (best) { + const g = floorGroup(r, monthOf(best.card))!; + return g.cards.length === 2 && needsTarget(s, g.month) ? { type: 'play', card: best.card, target: bestCard(g.cards, nb) } : { type: 'play', card: best.card }; + } + const cheapest = [...hand].sort((a, b) => cardValue(a, nb) - cardValue(b, nb) || a - b)[0]!; + return { type: 'play', card: cheapest }; +} diff --git a/packages/games/src/gostop/scoring.ts b/packages/games/src/gostop/scoring.ts new file mode 100644 index 0000000..d520692 --- /dev/null +++ b/packages/games/src/gostop/scoring.ts @@ -0,0 +1,151 @@ +/** 고스톱 점수 계산 (docs/games/gostop.md §6): 족보 합, 국진, 최종 공식, 박. */ +import { GUKJIN, cardInfo, type CardId } from './cards'; +import type { BakKind, GostopRules, Payment, RoundOutcome, ScoreView } from './types'; + +export interface ScoreCalc extends ScoreView { + /** Points from 광 족보 (삼광·비삼광·사광·오광) — 광박 needs this > 0. */ + gwangPoints: number; +} + +/** 족보 합 (§6.1). `gukjinAs` = how the 9월 열끗 counts if it is among `cards`. */ +export function scoreOf(cards: readonly CardId[], rules: Pick, gukjinAs: 'yeol' | 'pi' = 'yeol'): ScoreCalc { + let gwang = 0; + let bi = false; + let yeol = 0; + let tti = 0; + let pi = 0; + let hong = 0; + let cheong = 0; + let cho = 0; + let godori = 0; + for (const id of cards) { + const c = cardInfo(id, rules.nb); + if (c.gukjin && gukjinAs === 'pi') { + pi += 2; + continue; + } + if (c.kind === 'gwang') { + gwang++; + if (c.bi) bi = true; + } else if (c.kind === 'yeol') { + yeol++; + if (c.godori) godori++; + } else if (c.kind === 'tti') { + tti++; + if (c.dan === 'hong') hong++; + else if (c.dan === 'cheong') cheong++; + else if (c.dan === 'cho') cho++; + } else pi += c.piValue; + } + const items: { name: string; points: number }[] = []; + let gwangPoints = 0; + if (gwang >= 5) { + gwangPoints = rules.ogwangPoints; + items.push({ name: '오광', points: gwangPoints }); + } else if (gwang === 4) { + gwangPoints = 4; + items.push({ name: '사광', points: 4 }); + } else if (gwang === 3) { + gwangPoints = bi ? rules.biGwangThree : 3; + if (gwangPoints > 0) items.push({ name: bi ? '비삼광' : '삼광', points: gwangPoints }); + } + if (yeol >= 5) items.push({ name: `열끗 ${yeol}장`, points: yeol - 4 }); + if (godori === 3) items.push({ name: '고도리', points: 5 }); + if (tti >= 5) items.push({ name: `띠 ${tti}장`, points: tti - 4 }); + if (hong === 3) items.push({ name: '홍단', points: 3 }); + if (cheong === 3) items.push({ name: '청단', points: 3 }); + if (cho === 3) items.push({ name: '초단', points: 3 }); + if (pi >= 10) items.push({ name: `피 ${pi}`, points: pi - 9 }); + const total = items.reduce((a, x) => a + x.points, 0); + return { total, items, gwang, yeol, tti, pi, hong, cheong, cho, godori, gwangPoints }; +} + +/** Score with the 국진 counted the way that gives more points (§6.1 auto; also used for reaching winScore). */ +export function bestScore(cards: readonly CardId[], rules: GostopRules): { calc: ScoreCalc; as: 'yeol' | 'pi' } { + const asYeol = scoreOf(cards, rules, 'yeol'); + if (rules.gukjinMode === 'yeolOnly' || !cards.includes(GUKJIN)) return { calc: asYeol, as: 'yeol' }; + const asPi = scoreOf(cards, rules, 'pi'); + return asPi.total > asYeol.total ? { calc: asPi, as: 'pi' } : { calc: asYeol, as: 'yeol' }; +} + +export const goFactor = (go: number): number => (go >= 3 ? 2 ** (go - 2) : 1); + +export interface SettleInput { + rules: GostopRules; + mode: 'matgo' | 'gostop'; + winner: number; + participants: number[]; + captured: CardId[][]; + goCount: number[]; + goOrder: number[]; + shakes: number[]; + bombs: number[]; + nagariMult: number; + /** Explicit 국진 choice (ask mode) or null for the automatic rule. */ + gukjinChoice: (null | 'yeol' | 'pi')[]; +} + +export type Settlement = Omit & { calcs: Record }; + +function compute(inp: SettleInput, holder: number, as: 'yeol' | 'pi'): Settlement { + const { rules, winner } = inp; + const calcs: Record = {}; + for (const p of inp.participants) calcs[p] = scoreOf(inp.captured[p]!, rules, p === holder ? as : 'yeol'); + const w = calcs[winner]!; + const go = inp.goCount[winner]!; + const meong = rules.meongTta && w.yeol >= 7; + const multipliers: { name: string; factor: number }[] = []; + if (go >= 3) multipliers.push({ name: `${go}고`, factor: goFactor(go) }); + if (inp.shakes[winner]! > 0) multipliers.push({ name: `흔들기 ${inp.shakes[winner]}번`, factor: 2 ** inp.shakes[winner]! }); + if (inp.bombs[winner]! > 0) multipliers.push({ name: `폭탄 ${inp.bombs[winner]}번`, factor: 2 ** inp.bombs[winner]! }); + if (meong) multipliers.push({ name: '멍따', factor: 2 }); + if (inp.nagariMult > 1) multipliers.push({ name: '나가리 배수', factor: inp.nagariMult }); + const T = multipliers.reduce((a, m) => a * m.factor, w.total + go); + const breakdown = [...w.items]; + if (go > 0) breakdown.push({ name: `${go}고`, points: go }); + const losers = inp.participants.filter((p) => p !== winner); + const payments: Payment[] = losers.map((l) => { + const c = calcs[l]!; + const baks: BakKind[] = []; + if (w.pi >= 10 && ((c.pi >= 1 && c.pi <= rules.piBakLimit) || (c.pi === 0 && !rules.piBakZeroExempt))) baks.push('pi'); + if (rules.gwangBak && w.gwangPoints > 0 && c.gwang === 0) baks.push('gwang'); + if (rules.meongBak && meong && c.yeol === 0) baks.push('meong'); + let amount = T * 2 ** baks.length; + if (rules.goBak && inp.goCount[l]! > 0 && inp.mode === 'matgo') { + baks.push('go'); + amount *= 2; + } + return { from: l, amount, baks }; + }); + if (rules.goBak && inp.mode === 'gostop') { + // 독박: the loser who called 고 last pays every loser's share (§6.2). + const dok = [...inp.goOrder].reverse().find((p) => p !== winner && losers.includes(p)); + if (dok !== undefined && losers.length > 1) { + const sum = payments.reduce((a, x) => a + x.amount, 0); + for (const pay of payments) { + if (pay.from === dok) { + pay.amount = sum; + pay.baks.push('go'); + } else { + pay.amount = 0; + pay.coveredBy = dok; + } + } + } + } + return { winner, baseScore: w.total, breakdown, goCount: go, multipliers, perLoser: T, payments, gukjinAs: holder >= 0 ? as : null, calcs }; +} + +/** Final settlement of a stopped round (§6.2), resolving the 국진 for its owner (§6.1). */ +export function settle(inp: SettleInput): Settlement { + const holder = inp.participants.find((p) => inp.captured[p]!.includes(GUKJIN)) ?? -1; + if (holder < 0) return compute(inp, -1, 'yeol'); + const fixed = inp.rules.gukjinMode === 'yeolOnly' ? 'yeol' : inp.gukjinChoice[holder]; + if (fixed) return compute(inp, holder, fixed); + const y = compute(inp, holder, 'yeol'); + const p = compute(inp, holder, 'pi'); + const sum = (x: Settlement) => x.payments.reduce((a, b) => a + b.amount, 0); + if (holder === inp.winner) return sum(p) > sum(y) ? p : y; + const paid = (x: Settlement) => x.payments.find((b) => b.from === holder)?.amount ?? 0; + return paid(p) < paid(y) ? p : y; +} diff --git a/packages/games/src/gostop/types.ts b/packages/games/src/gostop/types.ts new file mode 100644 index 0000000..5c86131 --- /dev/null +++ b/packages/games/src/gostop/types.ts @@ -0,0 +1,372 @@ +/** 고스톱 options, actions, state and view types (docs/games/gostop.md §2, §8). */ +import { z } from 'zod'; +import type { PlayerId } from '@bg/engine'; +import type { BonusSetting, CardId, CardView } from './cards'; + +const lit = (...vals: T[]) => + z.union(vals.map((v) => z.literal(v)) as unknown as [z.ZodLiteral, z.ZodLiteral, ...z.ZodLiteral[]]); + +export const gostopOptionsSchema = z.object({ + rounds: lit(1, 3, 5, 10, 20) + .default(10) + .meta({ title: '판 수(나가리 판도 1판)', labels: { 1: '1판', 3: '3판', 5: '5판', 10: '10판', 20: '20판' } }), + bonusCards: lit<'auto' | BonusSetting>('auto', 'none', '2+1', '3+1') + .default('auto') + .meta({ + title: '보너스패', + labels: { auto: '자동(맞고 2피 2장+3피 1장, 고스톱 없음)', none: '없음', '2+1': '2피 2장 + 3피 1장', '3+1': '2피 3장 + 3피 1장' }, + }), + winScore: lit<'auto' | 3 | 5 | 7>('auto', 3, 5, 7) + .default('auto') + .meta({ title: '고·스톱 기준 점수', labels: { auto: '자동(맞고 7점, 고스톱 3점)', 3: '3점', 5: '5점', 7: '7점' } }), + piBakLimit: lit<'auto' | 5 | 6 | 7>('auto', 5, 6, 7) + .default('auto') + .meta({ title: '피박 기준(패자 피 값이 이 이하)', labels: { auto: '자동(맞고 7, 고스톱 5)', 5: '5 이하', 6: '6 이하', 7: '7 이하' } }), + piBakZeroExempt: z.boolean().default(true).meta({ title: '피가 하나도 없으면 피박 면제' }), + gwangBak: z.boolean().default(true).meta({ title: '광박' }), + meongTta: z.boolean().default(true).meta({ title: '멍따(열끗 7장 이상 x2)' }), + meongBak: z.boolean().default(false).meta({ title: '멍박(멍따일 때 패자 열끗 0장 x2)' }), + goBak: z.boolean().default(true).meta({ title: '고박(맞고 x2, 3인 독박)' }), + biGwangThree: lit(2, 0).default(2).meta({ title: '비광 낀 3광', labels: { 2: '2점', 0: '점수 없음' } }), + ogwangPoints: lit(15, 30).default(15).meta({ title: '5광 점수', labels: { 15: '15점', 30: '30점' } }), + gukjinMode: z + .enum(['auto', 'ask', 'yeolOnly']) + .default('auto') + .meta({ title: '9월 열끗(국진)', labels: { auto: '유리한 쪽으로 자동', ask: '판 끝에 직접 고르기', yeolOnly: '열끗으로만' } }), + shake: z.boolean().default(true).meta({ title: '흔들기(x2)' }), + bomb: z.boolean().default(true).meta({ title: '폭탄(x2, 피 1장씩)' }), + chongtong: z + .enum(['win', 'redeal', 'off']) + .default('win') + .meta({ title: '총통(같은 월 4장)', labels: { win: '즉시 승리', redeal: '다시 돌리기', off: '그냥 진행' } }), + chongtongPoints: lit<'auto' | 5 | 7 | 10 | 20>('auto', 5, 7, 10, 20) + .default('auto') + .meta({ title: '총통·삼뻑 승리 점수', labels: { auto: '자동(맞고 10점, 고스톱 5점)', 5: '5점', 7: '7점', 10: '10점', 20: '20점' } }), + ppeokBonus: z.boolean().default(true).meta({ title: '첫뻑·연뻑 보너스, 삼뻑 즉시 승리' }), + jaPpeokSteal: lit(1, 2).default(2).meta({ title: '자뻑 때 뺏는 피', labels: { 1: '1장씩', 2: '2장씩' } }), + lastTurnSweepBonus: z.boolean().default(false).meta({ title: '마지막 턴의 쓸·쪽 피 뺏기' }), + naturalThreeSteal: z.boolean().default(true).meta({ title: '처음 깔린 같은 월 3장을 먹으면 피 뺏기' }), + nagariMultiplier: z.enum(['x2', 'none']).default('x2').meta({ title: '나가리 다음 판', labels: { x2: '점수 2배(연속이면 누적)', none: '배수 없음' } }), + nagariMaxMultiplier: lit(2, 4, 8).default(8).meta({ title: '나가리 배수 상한', labels: { 2: 'x2', 4: 'x4', 8: 'x8' } }), + gwangPalgi: z.boolean().default(true).meta({ title: '4~5명이면 광팔기' }), + gwangPrice: lit(1, 2, 3).default(1).meta({ title: '광 1장 값', labels: { 1: '1점', 2: '2점', 3: '3점' } }), + gwangPayer: z + .enum(['nonDealerPlayers', 'allPlayers']) + .default('nonDealerPlayers') + .meta({ title: '광값 내는 사람', labels: { nonDealerPlayers: '선을 뺀 참가자', allPlayers: '참가자 3명 모두' } }), + gwangSellCards: z + .enum(['gwang', 'gwangPlus']) + .default('gwang') + .meta({ title: '팔 수 있는 패', labels: { gwang: '광만', gwangPlus: '광 + 쌍피·국진·보너스' } }), + turnSeconds: lit(5, 10, 15, 20, 30, 60) + .default(15) + .meta({ title: '한 턴 제한 시간', labels: { 5: '5초', 10: '10초', 15: '15초', 20: '20초', 30: '30초', 60: '60초' } }), + decisionSeconds: lit(5, 10, 15, 20, 30) + .default(10) + .meta({ title: '고·스톱·선택 제한 시간', labels: { 5: '5초', 10: '10초', 15: '15초', 20: '20초', 30: '30초' } }), + hints: z.boolean().default(true).meta({ title: '먹을 수 있는 패·족보 힌트' }), +}); +export type GostopOptions = z.infer; + +/** Options with the mode-dependent `auto` values resolved (§2: 2명 맞고, 3~5명 고스톱 기본값). */ +export interface GostopRules extends Omit { + bonusCards: BonusSetting; + /** Number of bonus cards (0, 3 or 4). */ + nb: number; + winScore: number; + piBakLimit: number; + chongtongPoints: number; +} + +export const gostopActionSchema = z.discriminatedUnion('type', [ + z.object({ type: z.literal('sell'), sell: z.boolean() }), + z.object({ type: z.literal('chongtong'), declare: z.boolean() }), + z.object({ + type: z.literal('play'), + card: z.union([z.number().int().min(0).max(51), z.literal('dummy')]), + target: z.number().int().min(0).max(51).optional(), + shake: z.boolean().optional(), + bomb: z.boolean().optional(), + }), + z.object({ type: z.literal('chooseFlip'), target: z.number().int().min(0).max(51) }), + z.object({ type: z.literal('goStop'), decision: z.enum(['go', 'stop']) }), + z.object({ type: z.literal('gukjin'), as: z.enum(['yeol', 'pi']) }), + z.object({ type: z.literal('ready') }), +]); +export type GostopClientAction = z.infer; +export type PlayAction = Extract; +/** Client actions plus system-only ones (timer, leaving) that are not in the schema. */ +export type GostopAction = GostopClientAction | { type: 'timeout' } | { type: 'leave' }; + +// ---------------------------------------------------------------- state + +export interface FloorGroup { + month: number; + /** 1..3 cards; 3 = 뻑 무더기 or 자연 뻑. */ + cards: CardId[]; + ppeokBy: number | null; + natural: boolean; +} + +export type TurnTag = + | 'bonus' // 손에서 보너스패를 냄 + | 'bonusFlip' // 더미에서 보너스패가 뒤집힘 + | 'jjok' + | 'ppeok' + | 'ttadak' + | 'sseul' + | 'bomb' + | 'shake' + | 'ppeokEat' // 남의 뻑 / 자연 뻑 먹기 + | 'jaPpeok' + | 'firstPpeok' + | 'yeonPpeok' + | 'samPpeok' + | 'dummy'; + +/** Public summary of the latest turn (drives the screen's event toasts). */ +export interface TurnRecord { + seq: number; + player: number; + played: CardId[]; + flipped: CardId[]; + captured: CardId[]; + tags: TurnTag[]; + /** 피 cards taken from each opponent: [opponent, cards]. */ + stolen: [number, CardId[]][]; + /** 첫뻑·연뻑 points received from each opponent. */ + bonusPoints: number; +} + +export interface TurnCtx { + player: number; + played: CardId[]; + flipped: CardId[]; + captured: CardId[]; + tags: TurnTag[]; + steal: number; + /** This turn flipped the last deck card (§5.2 lastTurnSweepBonus). */ + lastOfRound: boolean; +} + +export type BakKind = 'pi' | 'gwang' | 'meong' | 'go'; + +export interface Payment { + from: number; + amount: number; + baks: BakKind[]; + /** 독박: this loser pays nothing because the go-caller pays for them. */ + coveredBy?: number; +} + +export interface RoundOutcome { + type: 'stop' | 'chongtong' | 'samppeok' | 'nagari'; + winner: number | null; + baseScore: number; + breakdown: { name: string; points: number }[]; + goCount: number; + multipliers: { name: string; factor: number }[]; + /** Amount before the per-loser 박 multipliers. */ + perLoser: number; + payments: Payment[]; + gukjinAs: 'yeol' | 'pi' | null; + /** 총통 cards (public once declared). */ + shown: CardId[]; +} + +export interface Seller { + idx: number; + shown: CardId[]; + /** Total points received for the shown cards. */ + paid: number; +} + +export interface RoundState { + no: number; + /** Player indexes playing this round, in turn order from the dealer. */ + participants: number[]; + sellOrder: number[]; + sellCursor: number; + sellDecisions: { idx: number; sell: boolean; auto: boolean }[]; + sellers: Seller[]; + hands: CardId[][]; + bombDummies: number[]; + /** 흔들기·총통 선언으로 공개된 손패. */ + revealed: CardId[][]; + shakenMonths: number[][]; + deck: CardId[]; + floor: FloorGroup[]; + captured: CardId[][]; + gukjinChoice: (null | 'yeol' | 'pi')[]; + goCount: number[]; + lastGoScore: number[]; + goOrder: number[]; + shakes: number[]; + bombs: number[]; + ppeokCount: number[]; + ppeokTurns: number[][]; + turnsTaken: number[]; + turn: number; + step: 'sell' | 'chongtong' | 'play' | 'chooseFlip' | 'goStop' | 'gukjin' | 'done'; + pending: { flipped: CardId; options: CardId[]; ctx: TurnCtx } | null; + /** Player who stopped, waiting for the 국진 choice (step 'gukjin'). */ + stopBy: number | null; + chongtongBy: number | null; + nagariMult: number; + /** Net 첫뻑·연뻑·광값 points moved during this round, per player. */ + instant: number[]; + turnSeq: number; + lastTurn: TurnRecord | null; + outcome: RoundOutcome | null; + redeals: number; +} + +export interface RoundLog { + no: number; + type: RoundOutcome['type']; + winner: number | null; + amount: number; + /** Net change per player this round (판 점수 + 광값 + 첫뻑·연뻑). */ + delta: number[]; + breakdown: string[]; +} + +export interface GostopFinal { + ranking: PlayerId[][]; + scores: Record; + summary: string; +} + +export interface GostopState { + options: GostopOptions; + rules: GostopRules; + mode: 'matgo' | 'gostop'; + players: PlayerId[]; + totals: number[]; + roundNo: number; + dealer: number; + nagariStreak: number; + round: RoundState | null; + phase: 'playing' | 'roundResult' | 'finished'; + ready: number[]; + left: boolean[]; + autoPilot: boolean[]; + timeoutStreak: number[]; + deadline: number | null; + waitKey: string; + actionSeq: number; + log: RoundLog[]; + stats: { wins: number[]; goCalls: number[]; piBakGiven: number[]; gwangSold: number[]; nagari: number }; + final: GostopFinal | null; +} + +// ---------------------------------------------------------------- view + +export interface ScoreView { + total: number; + items: { name: string; points: number }[]; + gwang: number; + yeol: number; + tti: number; + pi: number; + hong: number; + cheong: number; + cho: number; + godori: number; +} + +export interface SeatView { + id: PlayerId; + total: number; + participant: boolean; + seller: { shown: CardView[]; paid: number } | null; + handCount: number; + dummies: number; + revealed: CardView[]; + captured: { gwang: CardView[]; yeol: CardView[]; tti: CardView[]; pi: CardView[] }; + score: ScoreView; + goCount: number; + shakes: number; + bombs: number; + ppeok: number; + left: boolean; + autoPilot: boolean; + ready: boolean; + isDealer: boolean; + isTurn: boolean; +} + +export interface HandCardView { + card: CardView; + /** Floor cards of the same month (먹을 수 있는 패). */ + matches: CardId[]; + /** Two different floor cards: the player must pick one. */ + needTarget: boolean; + canShake: boolean; + canBomb: boolean; + /** One-line hint, e.g. "홍단 완성" (hints option). */ + hint: string | null; +} + +export interface GoStopPreview { + score: number; + goCount: number; + /** Points if stopping now: amount per loser and the multiplier notes. */ + stopNow: { total: number; perLoser: { player: PlayerId; amount: number; baks: BakKind[] }[]; notes: string[] }; + /** After one more 고: count, added points and the 고 multiplier. */ + goNext: { goCount: number; plus: number; factor: number }; +} + +export interface OutcomeView extends Omit { + winner: PlayerId | null; + payments: { from: PlayerId; amount: number; baks: BakKind[]; coveredBy: PlayerId | null }[]; + shown: CardView[]; + delta: Record; +} + +export interface GostopView { + rules: GostopRules; + mode: GostopState['mode']; + me: PlayerId | null; + phase: GostopState['phase']; + step: RoundState['step'] | null; + roundNo: number; + dealer: PlayerId; + nagariStreak: number; + nagariMult: number; + turn: PlayerId | null; + deckCount: number; + floor: { month: number; cards: CardView[]; ppeokBy: PlayerId | null; natural: boolean }[]; + seats: SeatView[]; + myHand: HandCardView[] | null; + myDummies: number; + pendingFlip: { player: PlayerId; flipped: CardView; options: CardView[] } | null; + goStop: (GoStopPreview & { player: PlayerId }) | null; + gukjinAsk: { player: PlayerId; yeolScore: number; piScore: number } | null; + chongtongAsk: { player: PlayerId; months: number[] | null } | null; + sell: { + current: PlayerId | null; + decisions: { player: PlayerId; sell: boolean; auto: boolean }[]; + /** Sellable cards of the deciding player (only in their own view). */ + mySellable: CardView[] | null; + canSell: boolean; + } | null; + lastTurn: (Omit & { + player: PlayerId; + played: CardView[]; + flipped: CardView[]; + captured: CardView[]; + stolen: { from: PlayerId; cards: CardView[] }[]; + }) | null; + outcome: OutcomeView | null; + log: (Omit & { winner: PlayerId | null; delta: Record })[]; + ready: PlayerId[]; + deadline: number | null; + result: GostopFinal | null; + stats: { + rounds: number; + nagari: number; + bestRound: { player: PlayerId; amount: number; roundNo: number; breakdown: string[] } | null; + perPlayer: { player: PlayerId; wins: number; goCalls: number; piBakGiven: number; gwangSold: number }[]; + }; +} diff --git a/packages/games/src/index.ts b/packages/games/src/index.ts index a4d2a12..eca071b 100644 --- a/packages/games/src/index.ts +++ b/packages/games/src/index.ts @@ -11,6 +11,7 @@ import { yut } from './yut'; import { oneCard } from './one-card'; import { rummikub } from './rummikub'; import { davinci } from './davinci-code'; +import { gostop } from './gostop'; export const GAMES: Record = { [omok.id]: omok, @@ -25,4 +26,5 @@ export const GAMES: Record = { [oneCard.id]: oneCard, [rummikub.id]: rummikub, [davinci.id]: davinci, + [gostop.id]: gostop, }; diff --git a/packages/shared/src/catalog.ts b/packages/shared/src/catalog.ts index b3bd701..6afee79 100644 --- a/packages/shared/src/catalog.ts +++ b/packages/shared/src/catalog.ts @@ -22,6 +22,7 @@ export const CATALOG: CatalogEntry[] = [ { id: 'yut', nameKo: '윷놀이', minPlayers: 2, maxPlayers: 8, minutes: '15~25', level: '쉬움', blurb: '도·개·걸·윷·모! 개인전 또는 팀전', available: true }, { id: 'one-card', nameKo: '원카드', minPlayers: 2, maxPlayers: 10, minutes: '10~15', level: '쉬움', blurb: '같은 무늬·숫자를 내고 공격 카드로 먹이기! · 색깔 카드 모드', available: true }, { id: 'rummikub', nameKo: '숫자 타일', minPlayers: 2, maxPlayers: 4, minutes: '30~60', level: '보통', blurb: '같은 숫자, 이어지는 숫자로 묶어 먼저 다 내려놓기!', available: true }, + { id: 'gostop', nameKo: '고스톱', minPlayers: 2, maxPlayers: 5, minutes: '20~40', level: '보통', blurb: '같은 월을 맞춰 먹고 고·스톱! 2인 맞고·3인·광팔기 · 놀이용 칩', available: true }, { id: 'davinci-code', nameKo: '다빈치 코드', minPlayers: 2, maxPlayers: 4, minutes: '10~15', level: '쉬움', blurb: '검은·흰 숫자 타일의 숫자를 추리해 맞히기', available: true }, ];