Files
joke-app/packages/games/src/one-card/index.ts
EJClaw 4c5c3e5707 M4: 원카드 규칙·화면·테스트
- 규칙 모듈 packages/games/src/one-card: 원카드(트럼프 54장, 6명부터 2벌)와 색깔 카드(108장) 두 모드.
  공격·받아치기·3 방어·7 무늬 지정·J/Q/K·조커 기준 카드·여러 장 내기·마지막 카드 제한, 원카드 선언/잡기,
  파산, 드로우 더미 재구성, +4 의심하기(의심한 사람에게만 손패 공개), +2/+4 받아치기, 목표 점수 여러 판,
  시간 초과(자동 먹기·넘기기·받아들이기, 3번 연속이면 5초), 나가기. 모든 옵션에 한국어 메타.
- 테스트: 문서 10절 시나리오 45개 + 모드별 무작위 대국 1,000판(카드 수 보존, 종료, 결정성, 유출, onTimeout 합법)
  + 엔진 randomPlayout(매 단계 유출·직렬화 검사) 18판.
- 화면 apps/web/src/games/one-card: 상대 좌석, 판(더미·바닥·방향·지정 무늬/색·공격 누적), 손패(정렬·힌트·
  두 번 눌러 내기·끌어 놓기·방향키), 원카드 버튼, 무늬/색 고르기, 의심하기, 기록·특수 카드 표.
  색깔 카드는 직접 그린 SVG(색맹용 무늬 패턴과 색 이름 포함).
- 등록: GAMES, 카탈로그, 웹 레지스트리, 아이콘(곧 나와요 목록에서 제외), E2E 계획(PC 1 + 휴대폰 2).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 04:40:21 +09:00

1045 lines
40 KiB
TypeScript

/** 원카드 / 색깔 카드 (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 = <T>(r: Record<PlayerId, T>): Record<PlayerId, T> => ({ ...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<OneCardOptions, 'mode'>): { 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<string, unknown> & { 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<LogEntry, { t: 'draw' }>['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<OneCardOptions['lastCardRule'], string> = {
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<OneCardState, OneCardAction, OneCardView, OneCardOptions> = {
id: 'one-card',
nameKo: '원카드',
minPlayers: 2,
maxPlayers: 10,
stateVersion: 1,
defaultOptions: oneCardOptionsSchema.parse({}),
optionsSchema: oneCardOptionsSchema as unknown as GameDefinition<OneCardState, OneCardAction, OneCardView, OneCardOptions>['optionsSchema'],
actionSchema: oneCardActionSchema as unknown as GameDefinition<OneCardState, OneCardAction, OneCardView, OneCardOptions>['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 };