/** 원카드 / 색깔 카드 (docs/games/one-card.md). Pure and deterministic; state is JSON. */ import { fail, ok, type GameDefinition, type GameEvent, type GameResult, type PlayerId, type Rng, type Validation } from '@bg/engine'; import { COLORS, SUITS, SUIT_SYMBOL, classicDeck, colorOf, handPoints, isJoker, isStd, unoDeck, type Card, type Color, type Suit } from './cards'; import { attackOf, canBlockWithThree, canCounter, canStartWith, classicMatches, currentColor, forbiddenAsLast, hintFor, legalCardIds, referenceCard, resolvedBankrupt, resolvedPenalty, resolvedPlayAfterDraw, top, unoCanStack, unoMatches, } from './rules'; import { oneCardActionSchema, oneCardOptionsSchema, type CardFace, type LogEntry, type OneCardAction, type OneCardFinal, type OneCardOptions, type OneCardState, type OneCardView, type PlayAction, type PlayerResult, } from './types'; export * from './cards'; export * from './types'; export { attackOf, canCounter, canBlockWithThree, currentColor, legalCardIds, referenceCard, resolvedBankrupt, resolvedPenalty, resolvedPlayAfterDraw } from './rules'; const AWAY_MS = 5000; const WILD4_MS = 10_000; const ROUND_END_MS = 8000; const AWAY_AFTER = 3; const LOG_MAX = 30; const LOG_VIEW = 15; interface Ctx { rng: Rng; now: number; events: GameEvent[]; } // ---------------------------------------------------------------- small helpers /** * Copy for `apply`: containers are copied, card objects / log entries / options are shared because * nothing ever mutates them (much cheaper than structuredClone for 100+ card objects). */ function cloneState(s: OneCardState): OneCardState { const rec = (r: Record): Record => ({ ...r }); return { ...s, seats: [...s.seats], hands: Object.fromEntries(Object.entries(s.hands).map(([p, h]) => [p, [...h]])), drawPile: [...s.drawPile], discard: [...s.discard], phase: { ...s.phase }, oneCardWindow: s.oneCardWindow ? { ...s.oneCardWindow } : null, status: rec(s.status), finishOrder: [...s.finishOrder], bankruptOrder: [...s.bankruptOrder], leftOrder: [...s.leftOrder], timeoutsInRow: rec(s.timeoutsInRow), away: rec(s.away), scores: rec(s.scores), rounds: [...s.rounds], ready: [...s.ready], log: [...s.log], }; } export function playerRange(o: Pick): { min: number; max: number } { return { min: 2, max: o.mode === 'classic' ? 9 : 10 }; } const alive = (s: OneCardState): PlayerId[] => s.seats.filter((p) => s.status[p] === 'playing'); const gone = (s: OneCardState, p: PlayerId): boolean => s.leftOrder.includes(p); export const deckSize = (s: OneCardState): number => (s.mode === 'classic' ? 54 * s.decks : 108); /** Every card is in exactly one place (test invariant). */ export const cardTotal = (s: OneCardState): number => s.drawPile.length + s.discard.length + s.seats.reduce((a, p) => a + (s.hands[p]?.length ?? 0), 0); function log(s: OneCardState, e: Record & { t: LogEntry['t'] }): void { s.log.push({ seq: s.seq, ...e } as LogEntry); if (s.log.length > LOG_MAX) s.log.splice(0, s.log.length - LOG_MAX); } /** Moves `steps` seats in the current direction over players still in the round (`from` may be out). */ function advance(s: OneCardState, from: PlayerId, steps: number): PlayerId { const n = s.seats.length; if (alive(s).length === 0) return from; let idx = s.seats.indexOf(from); let moved = 0; while (moved < steps) { idx = (idx + s.direction + n) % n; if (s.status[s.seats[idx]!] === 'playing') moved++; } return s.seats[idx]!; } /** Next player after `from` skipping `skips` people; skipping everyone else comes back to `from`. */ function nextAfter(s: OneCardState, from: PlayerId, skips: number): PlayerId { const steps = 1 + skips; if (s.status[from] === 'playing' && steps >= alive(s).length) return from; return advance(s, from, steps); } function startTurn(s: OneCardState, p: PlayerId, extra = false): void { s.current = p; s.phase = { kind: 'turn' }; s.extraTurn = extra; s.turnNo += 1; } function reshuffle(s: OneCardState, ctx: Ctx): void { // Keep the top card (and, for jokers, everything down to the card that still decides what may follow). let keepFrom = s.discard.length - 1; if (s.mode === 'classic') while (keepFrom > 0 && isJoker(s.discard[keepFrom]!)) keepFrom--; const pool = s.discard.slice(0, keepFrom); if (pool.length === 0) return; s.discard = s.discard.slice(keepFrom); s.drawPile = [...s.drawPile, ...ctx.rng.shuffle(pool)]; ctx.events.push({ type: 'reshuffled', count: pool.length }); log(s, { t: 'reshuffle', n: pool.length }); } /** Draws up to `n` cards (refilling once from the discard pile), then applies bankruptcy. */ function drawCards(s: OneCardState, p: PlayerId, n: number, why: Extract['why'], ctx: Ctx): number { const hand = s.hands[p]!; let got = 0; for (let i = 0; i < n; i++) { if (s.drawPile.length === 0) reshuffle(s, ctx); const c = s.drawPile.shift(); if (!c) break; hand.push(c); got++; } ctx.events.push({ type: 'cardsDrawn', player: p, count: got, why }); log(s, { t: 'draw', p, n: got, why }); if (s.oneCardWindow?.player === p && hand.length !== 1) s.oneCardWindow = null; const limit = resolvedBankrupt(s.options); if (limit !== null && hand.length >= limit && s.status[p] === 'playing') goBankrupt(s, p, ctx); return got; } function removeFromPlay(s: OneCardState, p: PlayerId): void { // Hand goes to the bottom of the draw pile without shuffling (§5.4). s.drawPile.push(...s.hands[p]!); s.hands[p] = []; if (s.oneCardWindow?.player === p) s.oneCardWindow = null; } function goBankrupt(s: OneCardState, p: PlayerId, ctx: Ctx): void { s.status[p] = 'bankrupt'; s.bankruptOrder.push(p); removeFromPlay(s, p); ctx.events.push({ type: 'bankrupt', player: p }); log(s, { t: 'bankrupt', p }); } /** Someone left play outside their own turn action (bankrupt by a catch, or left the room). */ function repairAfterRemoval(s: OneCardState, p: PlayerId, ctx: Ctx): void { if (maybeEndRound(s, ctx)) return; const ph = s.phase; if (ph.kind === 'wild4Response') { if (ph.by === p) { // The +4 of a player who is gone no longer counts; the target just plays on. s.pendingAttack = 0; startTurn(s, ph.target); } else if (ph.target === p) { s.pendingAttack = 0; startTurn(s, nextAfter(s, p, 0)); } return; } if ((ph.kind === 'turn' || ph.kind === 'afterDraw') && s.current === p) { s.pendingAttack = 0; startTurn(s, nextAfter(s, p, 0)); } } // ---------------------------------------------------------------- rounds function dealRound(s: OneCardState, starter: PlayerId, ctx: Ctx): void { const o = s.options; const players = s.seats.filter((p) => !gone(s, p)); const deck: Card[] = s.mode === 'classic' ? classicDeck(s.decks) : unoDeck(); s.drawPile = ctx.rng.shuffle(deck); s.discard = []; s.hands = Object.fromEntries(s.seats.map((p) => [p, [] as Card[]])); for (const p of s.seats) s.status[p] = gone(s, p) ? 'left' : 'playing'; s.finishOrder = []; s.bankruptOrder = []; s.declaredSuit = null; s.declaredColor = null; s.direction = 1; s.pendingAttack = 0; s.extraTurn = false; s.oneCardWindow = null; s.reveal = null; s.ready = []; s.roundStarter = starter; // Deal one card at a time from the first player. const order: PlayerId[] = []; for (let k = 0, p = starter; k < players.length; k++, p = advance(s, p, 1)) order.push(p); for (let r = 0; r < o.handSize; r++) for (const p of order) s.hands[p]!.push(s.drawPile.shift()!); ctx.events.push({ type: 'dealt', players: order, count: o.handSize, round: s.round }); s.current = starter; s.phase = { kind: 'turn' }; if (s.mode === 'classic') { // Flip until a plain number card; 2/A/3/7/J/Q/K/jokers go to the bottom (§4, §10). for (;;) { const c = s.drawPile.shift()!; if (isStd(c) && [4, 5, 6, 8, 9, 10].includes(c.rank)) { s.discard.push(c); break; } s.drawPile.push(c); } startTurn(s, starter); return; } // Colour cards: the original start-card rules (§4). let c = s.drawPile.shift()!; while (c.kind === 'wild4') { s.drawPile = ctx.rng.shuffle([...s.drawPile, c]); c = s.drawPile.shift()!; } s.discard.push(c); ctx.events.push({ type: 'startCard', card: faceOf(c) }); switch (c.kind) { case 'draw2': drawCards(s, starter, 2, 'start', ctx); startTurn(s, nextAfter(s, starter, 0)); break; case 'skip': log(s, { t: 'skip', p: starter }); startTurn(s, nextAfter(s, starter, 0)); break; case 'reverse': s.direction = -1; log(s, { t: 'reverse' }); startTurn(s, advance(s, starter, 1)); break; default: // Numbers start as they are; a starting "색 바꾸기" leaves the colour open for the first player. startTurn(s, starter); } } function placeOf(s: OneCardState): PlayerId[][] { const groups: PlayerId[][] = s.finishOrder.map((p) => [p]); const rest = alive(s).sort((a, b) => cmpHand(s, a, b)); for (const p of rest) { const last = groups[groups.length - 1]; const prev = last?.[0]; if (last && prev && rest.includes(prev) && cmpHand(s, prev, p) === 0) last.push(p); else groups.push([p]); } for (const p of [...s.bankruptOrder].reverse()) groups.push([p]); for (const p of [...s.leftOrder].reverse()) if (s.status[p] === 'left') groups.push([p]); return groups; } function cmpHand(s: OneCardState, a: PlayerId, b: PlayerId): number { const ha = s.hands[a]!; const hb = s.hands[b]!; return ha.length - hb.length || handPoints(ha) - handPoints(hb); } function roundWinner(s: OneCardState): PlayerId | null { if (s.finishOrder[0]) return s.finishOrder[0]; const live = alive(s); return live.length === 1 ? live[0]! : null; } /** Colour-card points the winner collects from everyone else's remaining cards (§6). */ function roundPoints(s: OneCardState, winner: PlayerId | null): number { if (!winner || s.mode !== 'uno-style') return 0; return s.seats.filter((p) => p !== winner).reduce((a, p) => a + handPoints(s.hands[p]!), 0); } function maybeEndRound(s: OneCardState, ctx: Ctx): boolean { if (s.phase.kind === 'finished' || s.phase.kind === 'roundEnd') return true; const firstOut = s.options.endCondition === 'first-out' || multiRound(s); if ((firstOut && s.finishOrder.length > 0) || alive(s).length <= 1) { endRound(s, ctx); return true; } return false; } const multiRound = (s: OneCardState): boolean => s.mode === 'uno-style' && s.options.unoTargetScore > 0; function endRound(s: OneCardState, ctx: Ctx): void { s.oneCardWindow = null; s.pendingAttack = 0; const winner = roundWinner(s); const points = roundPoints(s, winner); if (!multiRound(s)) { finishSingle(s, winner, points, ctx); return; } if (winner) s.scores[winner] = (s.scores[winner] ?? 0) + points; s.rounds.push({ round: s.round, winner, points }); log(s, { t: 'round', round: s.round, winner, points }); ctx.events.push({ type: 'roundEnd', round: s.round, winner, points }); const remaining = s.seats.filter((p) => !gone(s, p)); if ((winner && s.scores[winner]! >= s.options.unoTargetScore) || remaining.length <= 1) { finishMulti(s, ctx); return; } s.phase = { kind: 'roundEnd' }; s.ready = []; s.turnNo += 1; } function results(s: OneCardState, ranking: PlayerId[][], pointsOf: (p: PlayerId) => number): PlayerResult[] { const out: PlayerResult[] = []; let rank = 1; for (const g of ranking) { for (const p of g) out.push({ player: p, rank, status: s.status[p]!, cardsLeft: s.hands[p]!.length, points: pointsOf(p) }); rank += g.length; } return out; } function finishSingle(s: OneCardState, winner: PlayerId | null, points: number, ctx: Ctx): void { const ranking = placeOf(s); const uno = s.mode === 'uno-style'; const pointsOf = (p: PlayerId) => (uno ? (p === winner ? points : 0) : handPoints(s.hands[p]!)); const bankrupts = s.bankruptOrder.length; let summary: string; if (s.finishOrder.length === 0) { summary = s.leftOrder.length > 0 && bankrupts === 0 ? '다른 사람이 모두 나가서 남은 사람이 이겼어요' : '다른 사람이 모두 파산해서 끝까지 남은 사람이 이겼어요'; } else if (s.options.endCondition === 'last-standing' && s.finishOrder.length > 1) { summary = `${s.finishOrder.length}명이 차례로 카드를 모두 냈어요. 끝까지 남은 사람이 꼴찌예요`; } else { summary = uno ? `가장 먼저 카드를 모두 내서 ${points}점을 얻었어요` : '가장 먼저 카드를 모두 낸 사람이 이겼어요'; } if (bankrupts > 0 && s.finishOrder.length > 0) summary += ` · 파산 ${bankrupts}명`; s.final = { ranking, players: results(s, ranking, pointsOf), ...(uno ? { scores: Object.fromEntries(s.seats.map((p) => [p, pointsOf(p)])) } : {}), summary, }; finishState(s, ctx); } function finishMulti(s: OneCardState, ctx: Ctx): void { const remaining = s.seats.filter((p) => !gone(s, p)).sort((a, b) => s.scores[b]! - s.scores[a]!); const ranking: PlayerId[][] = []; for (const p of remaining) { const last = ranking[ranking.length - 1]; if (last && s.scores[last[0]!] === s.scores[p]) last.push(p); else ranking.push([p]); } for (const p of [...s.leftOrder].reverse()) ranking.push([p]); const best = remaining[0]; const target = s.options.unoTargetScore; const summary = best && s.scores[best]! >= target ? `${s.round}판 만에 ${s.scores[best]}점으로 목표 ${target}점에 먼저 닿았어요` : '다른 사람이 모두 나가서 점수가 가장 높은 사람이 이겼어요'; s.final = { ranking, players: results(s, ranking, (p) => s.scores[p] ?? 0), scores: { ...s.scores }, summary }; finishState(s, ctx); } function finishState(s: OneCardState, ctx: Ctx): void { s.phase = { kind: 'finished' }; s.oneCardWindow = null; s.reveal = null; ctx.events.push({ type: 'gameEnd', ranking: s.final!.ranking }); } // ---------------------------------------------------------------- playing cards function closeWindowOnTurnAction(s: OneCardState, actor: PlayerId): void { const w = s.oneCardWindow; if (!w) return; // Classic: open until the owner's next action. Colour cards: until the next turn action by anyone (§5.3). if (s.mode === 'uno-style' || w.player === actor) s.oneCardWindow = null; } function playCards(s: OneCardState, p: PlayerId, a: PlayAction, ctx: Ctx): void { const o = s.options; const hand = s.hands[p]!; const cards = a.cardIds.map((id) => hand.find((c) => c.id === id)!); const prevColor = s.mode === 'uno-style' ? currentColor(s) : null; const wasAttacked = s.pendingAttack > 0; const topBefore = top(s); const illegalWild4 = cards[0]!.kind === 'wild4' && prevColor !== null && hand.some((c) => c.id !== cards[0]!.id && colorOf(c) === prevColor); s.hands[p] = hand.filter((c) => !a.cardIds.includes(c.id)); s.discard.push(...cards); const handLeft = s.hands[p]!.length; ctx.events.push({ type: 'cardPlayed', player: p, cards: cards.map(faceOf) }); let skips = 0; let extra = false; let immediateDraw = false; let wild4Challenge = false; if (s.mode === 'classic') { const allJokers = cards.every(isJoker); if (!(allJokers && o.afterJoker === 'previous')) s.declaredSuit = null; const attack = cards.reduce((x, c) => x + attackOf(o, c), 0); if (wasAttacked && attack === 0 && canBlockWithThree(o, topBefore, cards[0]!)) { s.pendingAttack = 0; ctx.events.push({ type: 'attackBlocked', player: p }); log(s, { t: 'blocked', p }); } else if (attack > 0) { s.pendingAttack += attack; ctx.events.push({ type: 'attackStacked', total: s.pendingAttack }); } const first = cards[0]!; const n = cards.length; if (isStd(first)) { switch (first.rank) { case 7: s.declaredSuit = a.chooseSuit!; ctx.events.push({ type: 'suitDeclared', suit: a.chooseSuit }); break; case 11: skips += n; break; case 12: if (alive(s).length === 2) { if (o.qInTwoPlayer === 'as-skip') skips += n; } else if (n % 2 === 1) { s.direction = s.direction === 1 ? -1 : 1; ctx.events.push({ type: 'directionChanged', direction: s.direction }); log(s, { t: 'reverse' }); } break; case 13: if (o.kEffect === 'extra-turn') extra = true; else if (o.kEffect === 'skip-two') skips += 2 * n; break; } } log(s, { t: 'play', p, cards: cards.map(faceOf), ...(first.kind === 'std' && first.rank === 7 ? { suit: a.chooseSuit } : {}) }); } else { const c = cards[0]!; s.declaredColor = null; switch (c.kind) { case 'skip': skips = 1; break; case 'reverse': if (alive(s).length <= 2) skips = 1; else { s.direction = s.direction === 1 ? -1 : 1; ctx.events.push({ type: 'directionChanged', direction: s.direction }); log(s, { t: 'reverse' }); } break; case 'draw2': s.pendingAttack += 2; immediateDraw = !o.unoStacking; break; case 'wild': case 'wild4': s.declaredColor = a.chooseColor!; ctx.events.push({ type: 'colorDeclared', color: a.chooseColor }); if (c.kind === 'wild4') { s.pendingAttack += 4; if (o.unoWild4Rule === 'challenge') wild4Challenge = true; else immediateDraw = !o.unoStacking; } break; } log(s, { t: 'play', p, cards: cards.map(faceOf), ...(isWildKind(c) ? { color: a.chooseColor } : {}) }); } if (handLeft === 0) { s.status[p] = 'out'; s.finishOrder.push(p); ctx.events.push({ type: 'playerOut', player: p, place: s.finishOrder.length }); log(s, { t: 'out', p, place: s.finishOrder.length }); if (s.mode === 'uno-style' && s.pendingAttack > 0 && alive(s).length > 0) { // Going out on +2/+4: the next player still takes them (counts for the points). const victim = nextAfter(s, p, 0); drawCards(s, victim, s.pendingAttack, cards[0]!.kind === 'wild4' ? 'wild4' : 'draw2', ctx); s.pendingAttack = 0; if (maybeEndRound(s, ctx)) return; startTurn(s, nextAfter(s, victim, 0)); return; } if (maybeEndRound(s, ctx)) return; startTurn(s, nextAfter(s, p, skips)); return; } if (handLeft === 1) { const declared = o.autoDeclare || !!a.declareOneCard; s.oneCardWindow = { player: p, declared, openedSeq: s.seq }; ctx.events.push({ type: 'oneCardOpen', player: p, declared }); if (declared) log(s, { t: 'declare', p }); } if (extra) { startTurn(s, p, true); return; } const next = nextAfter(s, p, skips); if (wild4Challenge) { s.current = next; s.phase = { kind: 'wild4Response', by: p, target: next, prevColor, illegal: illegalWild4 }; s.turnNo += 1; return; } if (immediateDraw) { drawCards(s, next, s.pendingAttack, cards[0]!.kind === 'wild4' ? 'wild4' : 'draw2', ctx); s.pendingAttack = 0; ctx.events.push({ type: 'skipped', player: next }); if (maybeEndRound(s, ctx)) return; startTurn(s, nextAfter(s, next, 0)); return; } if (skips > 0) { for (let k = 1; k <= skips && k < alive(s).length; k++) { const skipped = advance(s, p, k); if (skipped !== p) log(s, { t: 'skip', p: skipped }); } } startTurn(s, next); } const isWildKind = (c: Card): boolean => c.kind === 'wild' || c.kind === 'wild4'; export const faceOf = ({ id: _id, ...face }: Card): CardFace => face as CardFace; /** Normal or attack draw by the current player (also used by the timer). */ function drawTurn(s: OneCardState, p: PlayerId, ctx: Ctx, allowAfterDraw: boolean): void { if (s.pendingAttack > 0) { const n = s.pendingAttack; s.pendingAttack = 0; drawCards(s, p, n, 'attack', ctx); if (maybeEndRound(s, ctx)) return; startTurn(s, nextAfter(s, p, 0)); return; } const before = s.hands[p]!.length; drawCards(s, p, 1, 'normal', ctx); if (maybeEndRound(s, ctx)) return; if (s.status[p] === 'playing' && allowAfterDraw && resolvedPlayAfterDraw(s.options) && s.hands[p]!.length > before) { const drawn = s.hands[p]![s.hands[p]!.length - 1]!; if (canStartWith(s, p, drawn)) { s.phase = { kind: 'afterDraw', drawnCardId: drawn.id }; return; } } startTurn(s, nextAfter(s, p, 0)); } function resolveWild4(s: OneCardState, challenge: boolean, ctx: Ctx): void { const ph = s.phase; if (ph.kind !== 'wild4Response') throw new Error('not in wild4Response'); const { by, target, illegal } = ph; const total = s.pendingAttack; s.pendingAttack = 0; if (!challenge) { drawCards(s, target, total, 'wild4', ctx); if (maybeEndRound(s, ctx)) return; startTurn(s, nextAfter(s, target, 0)); return; } const cards = s.hands[by]!.map(faceOf); s.reveal = { to: target, by, cards, illegal, seq: s.seq }; ctx.events.push({ type: 'wild4Challenged', by, target, illegal }); log(s, { t: 'challenge', p: target, by, illegal }); if (illegal) { drawCards(s, by, total, 'challenge', ctx); if (maybeEndRound(s, ctx)) return; startTurn(s, target); } else { drawCards(s, target, total + 2, 'challenge', ctx); if (maybeEndRound(s, ctx)) return; startTurn(s, nextAfter(s, target, 0)); } } function declareOneCard(s: OneCardState, actor: PlayerId, ctx: Ctx): void { const w = s.oneCardWindow!; if (w.player === actor) { w.declared = true; ctx.events.push({ type: 'oneCardDeclared', player: actor }); log(s, { t: 'declare', p: actor }); return; } const target = w.player; const penalty = resolvedPenalty(s.options); s.oneCardWindow = null; ctx.events.push({ type: 'oneCardCaught', catcher: actor, target, penalty }); log(s, { t: 'caught', p: target, by: actor, n: penalty }); drawCards(s, target, penalty, 'penalty', ctx); if (s.status[target] !== 'playing') repairAfterRemoval(s, target, ctx); } function leave(s: OneCardState, p: PlayerId, ctx: Ctx): void { s.leftOrder.push(p); ctx.events.push({ type: 'left', player: p }); log(s, { t: 'left', p }); s.ready = s.ready.filter((x) => x !== p); if (s.phase.kind === 'roundEnd') { s.status[p] = 'left'; if (s.seats.filter((x) => !gone(s, x)).length <= 1) finishMulti(s, ctx); else maybeStartNextRound(s, ctx); return; } if (s.status[p] !== 'playing') return; s.status[p] = 'left'; removeFromPlay(s, p); repairAfterRemoval(s, p, ctx); } function requiredReady(s: OneCardState): PlayerId[] { return s.seats.filter((p) => !gone(s, p)); } function maybeStartNextRound(s: OneCardState, ctx: Ctx): void { if (s.phase.kind !== 'roundEnd') return; if (!requiredReady(s).every((p) => s.ready.includes(p))) return; s.round += 1; const players = s.seats.filter((p) => !gone(s, p)); // Next round starts one seat clockwise from the previous first player. const n = s.seats.length; let idx = s.seats.indexOf(s.roundStarter); do idx = (idx + 1) % n; while (!players.includes(s.seats[idx]!)); dealRound(s, s.seats[idx]!, ctx); } function waiting(s: OneCardState): PlayerId[] { switch (s.phase.kind) { case 'turn': case 'afterDraw': return [s.current]; case 'wild4Response': return [s.phase.target]; case 'roundEnd': return requiredReady(s).filter((p) => !s.ready.includes(p)); case 'finished': return []; } } function canDeclare(s: OneCardState, p: PlayerId): boolean { const w = s.oneCardWindow; if (!w || w.declared || s.phase.kind === 'finished' || s.phase.kind === 'roundEnd') return false; const st = s.status[p]; return st === 'playing' || (st === 'out' && w.player !== p); } const lastCardReason: Record = { none: '', joker: '마지막 카드로 조커는 낼 수 없어요.', attack: '마지막 카드로 공격 카드(2·A·조커)는 낼 수 없어요.', special: '마지막 카드로 특수 카드는 낼 수 없어요.', }; function validatePlay(s: OneCardState, actor: PlayerId, a: PlayAction): Validation { const ph = s.phase; const responding = ph.kind === 'wild4Response'; if (responding) { if (ph.target !== actor) return fail('지금은 내 차례가 아니에요.'); if (!s.options.unoStacking) return fail('의심하기나 받아들이기를 골라 주세요.'); } else { if (ph.kind !== 'turn' && ph.kind !== 'afterDraw') return fail('지금은 카드를 낼 수 없어요.'); if (s.current !== actor) return fail('지금은 내 차례가 아니에요.'); } const hand = s.hands[actor]!; if (new Set(a.cardIds).size !== a.cardIds.length) return fail('같은 카드를 두 번 고를 수 없어요.'); const cards = a.cardIds.map((id) => hand.find((c) => c.id === id)); if (cards.some((c) => !c)) return fail('손에 없는 카드예요.'); const cs = cards as Card[]; if (ph.kind === 'afterDraw' && (cs.length !== 1 || cs[0]!.id !== ph.drawnCardId)) return fail('방금 먹은 카드만 낼 수 있어요.'); const first = cs[0]!; if (s.mode === 'uno-style') { if (cs.length !== 1) return fail('한 번에 한 장만 낼 수 있어요.'); if (responding) { if (first.kind !== 'wild4') return fail('색 바꾸기+4로만 받아칠 수 있어요.'); } else if (s.pendingAttack > 0) { if (!unoCanStack(top(s), first)) return fail(top(s).kind === 'wild4' ? '색 바꾸기+4로 받아치거나 카드를 먹으세요.' : '+2나 색 바꾸기+4로 받아치거나 카드를 먹으세요.'); } else if (!unoMatches(s, first, hand)) { return fail(first.kind === 'wild4' ? '지금 색 카드가 손에 있으면 색 바꾸기+4를 낼 수 없어요.' : '같은 색이나 같은 숫자·기호 카드만 낼 수 있어요.'); } if (isWildKind(first) && !a.chooseColor) return fail('색을 골라 주세요.'); return ok; } if (cs.length > 1) { if (!s.options.multiPlay) return fail('한 번에 한 장만 낼 수 있어요.'); if (!cs.every((c) => isStd(c) && isStd(first) && c.rank === first.rank)) return fail('같은 숫자 카드만 함께 낼 수 있어요.'); } if (s.pendingAttack > 0) { if (!canCounter(top(s), first, s.decks === 2) && !canBlockWithThree(s.options, top(s), first)) { return fail('공격을 받고 있어요. 막을 카드를 내거나 카드를 먹으세요.'); } } else if (!classicMatches(s, first)) { return fail(s.declaredSuit ? `${SUIT_SYMBOL[s.declaredSuit]} 무늬나 7만 낼 수 있어요.` : '같은 무늬나 같은 숫자 카드만 낼 수 있어요.'); } if (cs.length === hand.length && cs.some((c) => forbiddenAsLast(s.options, c))) return fail(lastCardReason[s.options.lastCardRule]); if (isStd(first) && first.rank === 7 && !a.chooseSuit) return fail('무늬를 골라 주세요.'); return ok; } function drawAllowed(s: OneCardState, p: PlayerId): boolean { if (s.phase.kind !== 'turn' || s.current !== p) return false; return s.pendingAttack > 0 || s.options.voluntaryDraw || legalCardIds(s, p).length === 0; } function deadlineMs(s: OneCardState): number | null { switch (s.phase.kind) { case 'turn': case 'afterDraw': return s.away[s.current] ? AWAY_MS : s.options.turnSeconds * 1000; case 'wild4Response': return s.away[s.phase.target] ? AWAY_MS : WILD4_MS; case 'roundEnd': return ROUND_END_MS; case 'finished': return null; } } /** All play actions for `p` built from a set of card ids (suits / colours / declare variants). */ function playVariants(s: OneCardState, p: PlayerId, ids: string[]): PlayAction[] { const hand = s.hands[p]!; const first = hand.find((c) => c.id === ids[0])!; let out: PlayAction[] = [{ type: 'play', cardIds: ids }]; if (isStd(first) && first.rank === 7) out = SUITS.map((chooseSuit: Suit) => ({ type: 'play', cardIds: ids, chooseSuit })); if (isWildKind(first)) out = COLORS.map((chooseColor: Color) => ({ type: 'play', cardIds: ids, chooseColor })); if (hand.length - ids.length === 1) out = out.flatMap((x) => [x, { ...x, declareOneCard: true }]); return out; } // ---------------------------------------------------------------- definition export const oneCard: GameDefinition = { id: 'one-card', nameKo: '원카드', minPlayers: 2, maxPlayers: 10, stateVersion: 1, defaultOptions: oneCardOptionsSchema.parse({}), optionsSchema: oneCardOptionsSchema as unknown as GameDefinition['optionsSchema'], actionSchema: oneCardActionSchema as unknown as GameDefinition['actionSchema'], playersFor: playerRange, setup({ players, options, rng, now }) { const range = playerRange(options); if (players.length < range.min || players.length > range.max) throw new Error(`one-card: ${players.length} players not allowed in ${options.mode}`); let decks: 1 | 2 = 1; if (options.mode === 'classic') { decks = options.deckCount === 'auto' ? (players.length >= 6 ? 2 : 1) : options.deckCount; // One deck cannot deal this many hands and still leave a draw pile. if (decks === 1 && players.length * options.handSize > 44) decks = 2; } const s: OneCardState = { mode: options.mode, options, decks, seats: [...players], hands: {}, drawPile: [], discard: [], declaredSuit: null, declaredColor: null, direction: 1, current: players[0]!, phase: { kind: 'turn' }, pendingAttack: 0, extraTurn: false, oneCardWindow: null, status: Object.fromEntries(players.map((p) => [p, 'playing' as const])), finishOrder: [], bankruptOrder: [], leftOrder: [], turnStartedAt: now, seq: 0, turnNo: 0, timeoutsInRow: Object.fromEntries(players.map((p) => [p, 0])), away: Object.fromEntries(players.map((p) => [p, false])), round: 1, roundStarter: players[0]!, scores: Object.fromEntries(players.map((p) => [p, 0])), rounds: [], ready: [], reveal: null, log: [], final: null, }; const ctx: Ctx = { rng, now, events: [] }; // §4: shuffle (inside dealRound) after picking the first player with the same RNG. const starter = players[rng.int(players.length)]!; dealRound(s, starter, ctx); return s; }, validate(s, actor, a) { if (!s.seats.includes(actor)) return fail('이 게임의 플레이어가 아니에요.'); if (s.phase.kind === 'finished') return fail('게임이 이미 끝났어요.'); if (a.type === 'leave') return gone(s, actor) ? fail('이미 나간 플레이어예요.') : ok; if (gone(s, actor)) return fail('방을 나간 플레이어예요.'); switch (a.type) { case 'timeout': return waiting(s).includes(actor) ? ok : fail('시간 초과 대상이 아니에요.'); case 'ready': return s.phase.kind === 'roundEnd' && !s.ready.includes(actor) ? ok : fail('지금은 다음 판을 기다릴 때가 아니에요.'); case 'declareOneCard': { const w = s.oneCardWindow; if (!w || s.phase.kind === 'roundEnd') return fail('지금은 원카드를 외칠 때가 아니에요.'); if (w.declared) return fail('이미 원카드를 외쳤어요.'); return canDeclare(s, actor) ? ok : fail('게임에서 빠진 사람은 외칠 수 없어요.'); } case 'challengeWild4': case 'acceptWild4': if (s.phase.kind !== 'wild4Response' || s.phase.target !== actor) return fail('지금은 의심하기를 할 수 없어요.'); return ok; case 'pass': if (s.phase.kind !== 'afterDraw' || s.current !== actor) return fail('지금은 넘길 수 없어요.'); return ok; case 'draw': if (s.phase.kind === 'afterDraw' && s.current === actor) return fail('먹은 카드를 내거나 넘기기를 눌러 주세요.'); if (s.phase.kind !== 'turn' || s.current !== actor) return fail('지금은 내 차례가 아니에요.'); if (!drawAllowed(s, actor)) return fail('낼 수 있는 카드가 있으면 먹을 수 없어요.'); return ok; case 'play': return validatePlay(s, actor, a); } }, apply(prev, actor, a, { rng, now }) { const s = cloneState(prev); const ctx: Ctx = { rng, now, events: [] }; const before = `${s.turnNo}|${s.phase.kind}`; s.seq += 1; const turnAction = a.type === 'play' || a.type === 'draw' || a.type === 'pass' || a.type === 'challengeWild4' || a.type === 'acceptWild4' || a.type === 'timeout'; if (turnAction) { if (s.phase.kind !== 'roundEnd') closeWindowOnTurnAction(s, actor); if (s.reveal && s.reveal.to === actor) s.reveal = null; } if (a.type !== 'timeout' && a.type !== 'leave' && a.type !== 'declareOneCard') { s.timeoutsInRow[actor] = 0; s.away[actor] = false; } switch (a.type) { case 'play': playCards(s, actor, a, ctx); break; case 'draw': drawTurn(s, actor, ctx, true); break; case 'pass': ctx.events.push({ type: 'passed', player: actor }); log(s, { t: 'pass', p: actor }); startTurn(s, nextAfter(s, actor, 0)); break; case 'declareOneCard': declareOneCard(s, actor, ctx); break; case 'challengeWild4': resolveWild4(s, true, ctx); break; case 'acceptWild4': resolveWild4(s, false, ctx); break; case 'ready': s.ready.push(actor); maybeStartNextRound(s, ctx); break; case 'leave': leave(s, actor, ctx); break; case 'timeout': { ctx.events.push({ type: 'timeout', player: actor }); if (s.phase.kind !== 'roundEnd') { s.timeoutsInRow[actor] = (s.timeoutsInRow[actor] ?? 0) + 1; if (s.timeoutsInRow[actor]! >= AWAY_AFTER && !s.away[actor]) { s.away[actor] = true; ctx.events.push({ type: 'away', player: actor }); } log(s, { t: 'timeout', p: actor }); } switch (s.phase.kind) { case 'turn': // Never auto-plays a card (§8.5): take the attack or draw one and end the turn. drawTurn(s, actor, ctx, false); break; case 'afterDraw': log(s, { t: 'pass', p: actor }); startTurn(s, nextAfter(s, actor, 0)); break; case 'wild4Response': resolveWild4(s, false, ctx); break; case 'roundEnd': s.ready.push(actor); maybeStartNextRound(s, ctx); break; } break; } } if (`${s.turnNo}|${s.phase.kind}` !== before) s.turnStartedAt = now; return { state: s, events: ctx.events }; }, view(s, viewer) { const me = viewer && s.seats.includes(viewer) ? viewer : null; const ph = s.phase; const live = ph.kind !== 'finished' && ph.kind !== 'roundEnd'; const w = s.oneCardWindow; let my: OneCardView['my'] = null; if (me) { const hand = s.hands[me] ?? []; const legal = live ? legalCardIds(s, me) : []; my = { hand, legalCardIds: legal, hints: Object.fromEntries(legal.map((id) => [id, hintFor(s, hand.find((c) => c.id === id)!)])), canDraw: live && drawAllowed(s, me) && !gone(s, me), drawCount: s.pendingAttack > 0 ? s.pendingAttack : 1, canPass: ph.kind === 'afterDraw' && s.current === me, drawnCardId: ph.kind === 'afterDraw' && s.current === me ? ph.drawnCardId : null, canDeclare: !gone(s, me) && canDeclare(s, me), canChallenge: ph.kind === 'wild4Response' && ph.target === me && !gone(s, me), canReady: ph.kind === 'roundEnd' && !gone(s, me) && !s.ready.includes(me), multiPlay: s.mode === 'classic' && s.options.multiPlay, }; } const t = s.discard[s.discard.length - 1]; const ref = s.mode === 'classic' && t && isJoker(t) && s.options.afterJoker === 'previous' ? referenceCard(s) : null; return { mode: s.mode, options: s.options, decks: s.decks, penalty: resolvedPenalty(s.options), bankruptLimit: resolvedBankrupt(s.options), playAfterDraw: resolvedPlayAfterDraw(s.options), me, seats: s.seats.map((p) => ({ id: p, cards: s.hands[p]?.length ?? 0, status: s.status[p]!, isTurn: waiting(s).includes(p) && ph.kind !== 'roundEnd', away: !!s.away[p], oneCard: w?.player === p ? (w.declared ? 'declared' : 'open') : null, score: s.scores[p] ?? 0, ready: ph.kind === 'roundEnd' && s.ready.includes(p), })), current: live ? (ph.kind === 'wild4Response' ? ph.target : s.current) : null, direction: s.direction, phase: ph.kind, turnNo: s.turnNo, extraTurn: s.extraTurn, recent: s.discard.slice(-5), reference: ref, declaredSuit: s.declaredSuit, declaredColor: s.declaredColor, currentColor: s.mode === 'uno-style' && t ? currentColor(s) : null, pendingAttack: s.pendingAttack, drawPileCount: s.drawPile.length, discardCount: s.discard.length, oneCardWindow: w ? { player: w.player, declared: w.declared } : null, wild4: ph.kind === 'wild4Response' ? { by: ph.by, target: ph.target } : null, my, reveal: me && s.reveal && s.reveal.to === me ? { by: s.reveal.by, cards: s.reveal.cards, illegal: s.reveal.illegal } : null, turnStartedAt: s.turnStartedAt, deadlineTotalMs: deadlineMs(s) ?? 0, round: s.round, rounds: s.rounds, log: s.log.slice(-LOG_VIEW), result: s.final, }; }, activePlayers(s) { const out = waiting(s); if (s.oneCardWindow && !s.oneCardWindow.declared) for (const p of s.seats) if (!out.includes(p) && !gone(s, p) && canDeclare(s, p)) out.push(p); return out; }, deadline(s) { const ms = deadlineMs(s); return ms === null ? null : s.turnStartedAt + ms; }, timeoutPlayers: waiting, onTimeout() { return { type: 'timeout' }; }, onLeave(s, player) { if (!s.seats.includes(player) || gone(s, player) || s.phase.kind === 'finished') return null; return { type: 'leave' }; }, result(s): GameResult | null { if (!s.final) return null; return { ranking: s.final.ranking, ...(s.final.scores ? { scores: s.final.scores } : {}), summary: s.final.summary, reason: 'normal' }; }, legalActions(s, p) { if (!s.seats.includes(p) || gone(s, p) || s.phase.kind === 'finished') return []; const out: OneCardAction[] = []; const ph = s.phase; if (ph.kind === 'roundEnd') return s.ready.includes(p) ? [] : [{ type: 'ready' }]; if (canDeclare(s, p)) out.push({ type: 'declareOneCard' }); if (ph.kind === 'wild4Response' && ph.target === p) out.push({ type: 'challengeWild4' }, { type: 'acceptWild4' }); const mineNow = ((ph.kind === 'turn' || ph.kind === 'afterDraw') && s.current === p) || (ph.kind === 'wild4Response' && ph.target === p); if (mineNow) { const hand = s.hands[p]!; for (const id of legalCardIds(s, p)) { const c = hand.find((x) => x.id === id)!; const sets: string[][] = [[id]]; if (s.mode === 'classic' && s.options.multiPlay && isStd(c) && ph.kind === 'turn') { const same = hand.filter((x) => x.id !== id && isStd(x) && x.rank === c.rank).map((x) => x.id); if (same.length) sets.push([id, ...same]); } for (const set of sets) out.push(...playVariants(s, p, set)); } if (drawAllowed(s, p)) out.push({ type: 'draw' }); if (ph.kind === 'afterDraw') out.push({ type: 'pass' }); } return out.filter((a) => oneCard.validate(s, p, a).ok); }, /** Own hand plus the draw pile — card ids exactly as they would appear in a view. */ secretsOf(s, player) { return [...(s.hands[player] ?? []).map((c) => c.id), ...s.drawPile.map((c) => c.id)]; }, }; export type { OneCardFinal };