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>
This commit is contained in:
EJClaw
2026-10-05 04:40:21 +09:00
parent 5dd8e6ea07
commit 4c5c3e5707
12 changed files with 3762 additions and 1 deletions

View File

@@ -0,0 +1,153 @@
/**
* 색깔 카드 artwork (own design, docs/games/one-card.md §9, §12): solid colour field, a white diamond in the
* middle, a colour-blind pattern per colour (빨강 점 · 노랑 줄 · 초록 격자 · 파랑 물결) and the colour name.
*/
import { useId } from 'react';
import type { CardFace, Color } from '@bg/games/one-card';
export const COLOR_HEX: Record<Color, string> = { R: '#d32f2f', Y: '#f4b400', G: '#2e7d32', B: '#1565c0' };
export const COLOR_INK: Record<Color, string> = { R: '#ffffff', Y: '#1f2937', G: '#ffffff', B: '#ffffff' };
export const COLOR_KO: Record<Color, string> = { R: '빨강', Y: '노랑', G: '초록', B: '파랑' };
const PATTERN_KO: Record<Color, string> = { R: '점무늬', Y: '줄무늬', G: '격자무늬', B: '물결무늬' };
function Pattern({ color, id }: { color: Color; id: string }) {
const ink = color === 'Y' ? 'rgba(0,0,0,0.16)' : 'rgba(255,255,255,0.22)';
switch (color) {
case 'R':
return (
<pattern id={id} width="12" height="12" patternUnits="userSpaceOnUse">
<circle cx="6" cy="6" r="2.2" fill={ink} />
</pattern>
);
case 'Y':
return (
<pattern id={id} width="10" height="10" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">
<rect width="4" height="10" fill={ink} />
</pattern>
);
case 'G':
return (
<pattern id={id} width="12" height="12" patternUnits="userSpaceOnUse">
<path d="M0 0H12M0 0V12" stroke={ink} strokeWidth="2.4" fill="none" />
</pattern>
);
case 'B':
return (
<pattern id={id} width="20" height="10" patternUnits="userSpaceOnUse">
<path d="M0 6 Q5 1 10 6 T20 6" stroke={ink} strokeWidth="2.2" fill="none" />
</pattern>
);
}
}
/** Four colour quadrants used by the wild cards. */
function Quadrants({ cx, cy, r }: { cx: number; cy: number; r: number }) {
const q = (dx: number, dy: number, c: Color) => (
<path d={`M${cx} ${cy} L${cx + dx * r} ${cy} A${r} ${r} 0 0 ${dx * dy > 0 ? 1 : 0} ${cx} ${cy + dy * r} Z`} fill={COLOR_HEX[c]} />
);
return (
<g>
{q(1, -1, 'R')}
{q(1, 1, 'B')}
{q(-1, 1, 'G')}
{q(-1, -1, 'Y')}
</g>
);
}
function SkipIcon({ cx, cy, r, color }: { cx: number; cy: number; r: number; color: string }) {
return (
<g stroke={color} strokeWidth={r * 0.28} fill="none" strokeLinecap="round">
<circle cx={cx} cy={cy} r={r} />
<path d={`M${cx - r * 0.7} ${cy + r * 0.7} L${cx + r * 0.7} ${cy - r * 0.7}`} />
</g>
);
}
function ReverseIcon({ cx, cy, s, color }: { cx: number; cy: number; s: number; color: string }) {
return (
<g stroke={color} strokeWidth={s * 0.22} fill="none" strokeLinecap="round" strokeLinejoin="round">
<path d={`M${cx - s} ${cy - s * 0.35} H${cx + s * 0.8} M${cx + s * 0.35} ${cy - s * 0.8} L${cx + s * 0.8} ${cy - s * 0.35} L${cx + s * 0.35} ${cy + s * 0.1}`} />
<path d={`M${cx + s} ${cy + s * 0.35} H${cx - s * 0.8} M${cx - s * 0.35} ${cy + s * 0.8} L${cx - s * 0.8} ${cy + s * 0.35} L${cx - s * 0.35} ${cy - s * 0.1}`} />
</g>
);
}
export function colorCardLabel(c: CardFace): string {
switch (c.kind) {
case 'num':
return `${COLOR_KO[c.color]} ${c.value}`;
case 'skip':
return `${COLOR_KO[c.color]} 건너뛰기`;
case 'reverse':
return `${COLOR_KO[c.color]} 방향 바꾸기`;
case 'draw2':
return `${COLOR_KO[c.color]} +2`;
case 'wild':
return '색 바꾸기';
case 'wild4':
return '색 바꾸기+4';
default:
return '';
}
}
/** Pure artwork (no interaction); size comes from the parent box (viewBox 100 × 140). */
export function ColorCardArt({ card }: { card: CardFace | null }) {
const pid = `occ${useId().replace(/[^a-zA-Z0-9_-]/g, '')}`;
if (!card) {
return (
<svg viewBox="0 0 100 140" width="100%" height="100%" aria-hidden>
<rect x="1.5" y="1.5" width="97" height="137" rx="10" fill="#1f2433" stroke="#f8fafc" strokeWidth="3" />
<rect x="9" y="9" width="82" height="122" rx="7" fill="none" stroke="#475569" strokeWidth="2" />
<Quadrants cx={50} cy={70} r={22} />
<circle cx="50" cy="70" r="9" fill="#1f2433" />
</svg>
);
}
const wild = card.kind === 'wild' || card.kind === 'wild4';
const color: Color | null = 'color' in card ? card.color : null;
const bg = color ? COLOR_HEX[color] : '#1f2433';
const ink = color ? COLOR_INK[color] : '#f8fafc';
const corner = card.kind === 'num' ? String(card.value) : card.kind === 'draw2' ? '+2' : card.kind === 'wild4' ? '+4' : card.kind === 'skip' ? '⊘' : card.kind === 'reverse' ? '⇄' : '★';
const centerInk = color ? COLOR_HEX[color] : '#1f2433';
return (
<svg viewBox="0 0 100 140" width="100%" height="100%" aria-hidden>
{color && (
<defs>
<Pattern color={color} id={pid} />
</defs>
)}
<rect x="1.5" y="1.5" width="97" height="137" rx="10" fill={bg} stroke="#f8fafc" strokeWidth="3" />
{color && <rect x="1.5" y="1.5" width="97" height="137" rx="10" fill={`url(#${pid})`} />}
{/* white diamond (not an oval) */}
<path d="M50 22 L86 70 L50 118 L14 70 Z" fill="#fffef9" stroke={ink} strokeOpacity="0.25" strokeWidth="1.5" />
{wild ? (
<>
<Quadrants cx={50} cy={70} r={card.kind === 'wild4' ? 19 : 24} />
{card.kind === 'wild4' && (
<text x="50" y="79" fontSize="26" fontWeight="900" textAnchor="middle" fill="#fffef9" stroke="#1f2433" strokeWidth="1.5" fontFamily="Pretendard Variable, sans-serif">
+4
</text>
)}
</>
) : card.kind === 'skip' ? (
<SkipIcon cx={50} cy={70} r={17} color={centerInk} />
) : card.kind === 'reverse' ? (
<ReverseIcon cx={50} cy={70} s={19} color={centerInk} />
) : (
<text x="50" y={card.kind === 'draw2' ? 82 : 86} fontSize={card.kind === 'draw2' ? 34 : 46} fontWeight="900" textAnchor="middle" fill={centerInk} fontFamily="Pretendard Variable, sans-serif">
{card.kind === 'draw2' ? '+2' : corner}
</text>
)}
<text x="9" y="25" fontSize="20" fontWeight="900" fill={ink} fontFamily="Pretendard Variable, sans-serif">
{corner}
</text>
<text x="50" y="134" fontSize="12" fontWeight="800" textAnchor="middle" fill={ink} fontFamily="Pretendard Variable, sans-serif">
{color ? COLOR_KO[color] : wild ? '색 바꾸기' : ''}
</text>
</svg>
);
}
export const patternName = (c: Color): string => PATTERN_KO[c];

File diff suppressed because it is too large Load Diff

View File

@@ -9,6 +9,7 @@ export const GAME_UI: Record<string, () => Promise<{ default: GameUI }>> = {
baduk: () => import('./baduk'),
seotda: () => import('./seotda'),
poker: () => import('./poker'),
'one-card': () => import('./one-card'),
};
const cache = new Map<string, GameUI>();

View File

@@ -8,7 +8,8 @@ export const GAME_ICON: Record<string, string> = {
mahjong: '🀇',
yut: '🪵',
othello: '◐',
'one-card': '♣',
};
/** Shown on the home screen so users know what's coming (docs/12-roadmap.md). */
export const UPCOMING = ['윷놀이', '원카드', '숫자 타일', '다빈치 코드', '요트 다이스', '마작', '고스톱', '뱅', '할리갈리'];
export const UPCOMING = ['윷놀이', '숫자 타일', '다빈치 코드', '요트 다이스', '마작', '고스톱', '뱅', '할리갈리'];

View File

@@ -77,6 +77,54 @@ const PLANS: Record<string, Plan> = {
await finishHand(pages);
},
},
'one-card': {
// 3 players, default 원카드: whoever has the turn plays a highlighted card (tap to lift, tap again to play,
// pick a suit for 7) or draws; the 원카드 button is pressed whenever it lights up. 60 turns or until the game ends.
players: 3,
drive: async (pages) => {
const statusOf = (p: Page) => p.locator('.turn-status').innerText().catch(() => '');
let turns = 0;
let plays = 0;
let draws = 0;
let finished = false;
for (let t = 0; t < 600 && turns < 60; t++) {
if (await pages[0]!.getByRole('dialog', { name: '게임 결과' }).count()) {
finished = true;
break;
}
let acted = false;
for (const p of pages) {
const one = p.locator('.oc-onebtn');
if ((await one.count()) && (await one.isEnabled().catch(() => false))) await one.click({ timeout: 1000 }).catch(() => {});
const before = await statusOf(p);
if (!before.includes('내 차례예요')) continue;
const accept = p.getByRole('button', { name: /^받아들이기/ });
const card = p.locator('button.oc-card[data-legal="true"]').first();
if (await accept.count()) await accept.click();
else if (await card.count()) {
await card.click();
await card.click();
const pick = p.locator('.oc-pick button').first();
if (await pick.waitFor({ timeout: 400 }).then(() => true, () => false)) await pick.click();
plays++;
} else {
const pass = p.getByRole('button', { name: '넘기기' });
if (await pass.count()) await pass.click();
else await p.getByRole('button', { name: /장 먹기$/ }).first().click();
draws++;
}
await p.waitForFunction((s) => document.querySelector('.turn-status')?.textContent?.trim() !== s, before.trim(), { timeout: 5000 }).catch(() => {});
turns++;
acted = true;
break;
}
if (!acted) await pages[0]!.waitForTimeout(150);
}
if (!finished && turns < 30) throw new Error(`only ${turns} turns were played`);
if (finished) await pages[0]!.screenshot({ path: join(SHOTS, 'one-card-3-result-pc.png') });
console.log(` one-card: ${turns} turns (${plays} plays, ${draws} draws)${finished ? ', game finished' : ''}`);
},
},
othello: {
// cursor starts at d4 (3,3): black d3 = up; then white c3 = up, left
moves: [['ArrowUp', 'Enter'], ['ArrowUp', 'ArrowLeft', 'Enter']],

View File

@@ -6,6 +6,7 @@ import { janggi } from './janggi';
import { baduk } from './baduk';
import { seotda } from './seotda';
import { poker } from './poker';
import { oneCard } from './one-card';
export const GAMES: Record<string, AnyGameDefinition> = {
[omok.id]: omok,
@@ -15,4 +16,5 @@ export const GAMES: Record<string, AnyGameDefinition> = {
[baduk.id]: baduk,
[seotda.id]: seotda,
[poker.id]: poker,
[oneCard.id]: oneCard,
};

View File

@@ -0,0 +1,100 @@
/** 원카드 카드 정의 (docs/games/one-card.md §3, §8.1). */
export type Suit = 'S' | 'H' | 'D' | 'C';
export type Color = 'R' | 'Y' | 'G' | 'B';
export const SUITS: Suit[] = ['S', 'H', 'D', 'C'];
export const COLORS: Color[] = ['R', 'Y', 'G', 'B'];
/** 1 = A, 11 = J, 12 = Q, 13 = K. */
export type ClassicCard =
| { id: string; kind: 'std'; suit: Suit; rank: number }
| { id: string; kind: 'joker'; joker: 'black' | 'color' };
export type UnoCard =
| { id: string; kind: 'num'; color: Color; value: number }
| { id: string; kind: 'skip' | 'reverse' | 'draw2'; color: Color }
| { id: string; kind: 'wild' | 'wild4' };
export type Card = ClassicCard | UnoCard;
export const SUIT_SYMBOL: Record<Suit, string> = { S: '♠', H: '♥', D: '◆', C: '♣' };
export const SUIT_KO: Record<Suit, string> = { S: '스페이드', H: '하트', D: '다이아', C: '클로버' };
export const COLOR_KO: Record<Color, string> = { R: '빨강', Y: '노랑', G: '초록', B: '파랑' };
const RANK_KO: Record<number, string> = { 1: 'A', 11: 'J', 12: 'Q', 13: 'K' };
export const rankLabel = (r: number): string => RANK_KO[r] ?? String(r);
/** Classic deck(s): ids "S1".."C13", "JB"/"JC"; the second deck adds a "b" suffix. */
export function classicDeck(decks: 1 | 2): ClassicCard[] {
const out: ClassicCard[] = [];
for (let d = 0; d < decks; d++) {
const sfx = d === 0 ? '' : 'b';
for (const suit of SUITS) for (let rank = 1; rank <= 13; rank++) out.push({ id: `${suit}${rank}${sfx}`, kind: 'std', suit, rank });
out.push({ id: `JB${sfx}`, kind: 'joker', joker: 'black' });
out.push({ id: `JC${sfx}`, kind: 'joker', joker: 'color' });
}
return out;
}
/** Colour-card deck, 108 cards: per colour 0 ×1, 1–9 ×2, skip/reverse/+2 ×2; 4 wild, 4 wild +4. */
export function unoDeck(): UnoCard[] {
const out: UnoCard[] = [];
for (const color of COLORS) {
out.push({ id: `${color}0`, kind: 'num', color, value: 0 });
for (const copy of ['a', 'b']) {
for (let value = 1; value <= 9; value++) out.push({ id: `${color}${value}${copy}`, kind: 'num', color, value });
out.push({ id: `${color}S${copy}`, kind: 'skip', color });
out.push({ id: `${color}R${copy}`, kind: 'reverse', color });
out.push({ id: `${color}D${copy}`, kind: 'draw2', color });
}
}
for (let i = 1; i <= 4; i++) out.push({ id: `W${i}`, kind: 'wild' });
for (let i = 1; i <= 4; i++) out.push({ id: `F${i}`, kind: 'wild4' });
return out;
}
export const isStd = (c: Card): c is Extract<ClassicCard, { kind: 'std' }> => c.kind === 'std';
export const isJoker = (c: Card): c is Extract<ClassicCard, { kind: 'joker' }> => c.kind === 'joker';
export const isWild = (c: Card): boolean => c.kind === 'wild' || c.kind === 'wild4';
export const colorOf = (c: Card): Color | null => ('color' in c ? c.color : null);
export const isSpadeAce = (c: Card): boolean => isStd(c) && c.rank === 1 && c.suit === 'S';
/** Korean name, e.g. "♥7", "흑백 조커", "빨강 5", "색 바꾸기+4". */
export function cardName(c: Card): string {
switch (c.kind) {
case 'std':
return `${SUIT_SYMBOL[c.suit]}${rankLabel(c.rank)}`;
case 'joker':
return c.joker === 'black' ? '흑백 조커' : '컬러 조커';
case 'num':
return `${COLOR_KO[c.color]} ${c.value}`;
case 'skip':
return `${COLOR_KO[c.color]} 건너뛰기`;
case 'reverse':
return `${COLOR_KO[c.color]} 방향 바꾸기`;
case 'draw2':
return `${COLOR_KO[c.color]} +2`;
case 'wild':
return '색 바꾸기';
case 'wild4':
return '색 바꾸기+4';
}
}
/** Card points: classic tie-break (number, J/Q/K 10, A 1, joker 20); colour cards per §6. */
export function cardPoints(c: Card): number {
switch (c.kind) {
case 'std':
return c.rank >= 11 ? 10 : c.rank;
case 'joker':
return 20;
case 'num':
return c.value;
case 'skip':
case 'reverse':
case 'draw2':
return 20;
case 'wild':
case 'wild4':
return 50;
}
}
export const handPoints = (cards: readonly Card[]): number => cards.reduce((a, c) => a + cardPoints(c), 0);

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,869 @@
import { describe, expect, test } from 'bun:test';
import { SeededRng, randomPlayout, seedN, type GameEvent, type PlayerId, type RngState } from '@bg/engine';
import {
cardTotal,
classicDeck,
deckSize,
oneCard,
unoDeck,
type Card,
type OneCardAction,
type OneCardOptions,
type OneCardState,
} from './index';
// ---------------------------------------------------------------- helpers
const ALL: Card[] = [...classicDeck(2), ...unoDeck()];
const card = (id: string): Card => {
const c = ALL.find((x) => x.id === id);
if (!c) throw new Error(`no card ${id}`);
return c;
};
const rng = () => SeededRng.fromSeed([7, 7, 7, 7]);
let clock = 1_000_000;
function make(opts: Partial<OneCardOptions> = {}, players = ['A', 'B', 'C'], seed = 1): OneCardState {
const options = oneCard.optionsSchema.parse(opts);
return oneCard.setup({ players, options, rng: SeededRng.fromSeed(seedN(seed)), now: 0 });
}
/** Replaces hands / discard / turn; the rest of the deck becomes the draw pile (unless given). */
function rig(
s: OneCardState,
r: { hands: Record<string, string[]>; discard: string[]; current?: string; drawPile?: string[]; pending?: number },
): OneCardState {
for (const p of s.seats) s.hands[p] = (r.hands[p] ?? []).map(card);
s.discard = r.discard.map(card);
s.current = r.current ?? s.seats[0]!;
s.phase = { kind: 'turn' };
s.pendingAttack = r.pending ?? 0;
s.declaredSuit = null;
s.declaredColor = null;
s.direction = 1;
s.extraTurn = false;
s.oneCardWindow = null;
if (r.drawPile) s.drawPile = r.drawPile.map(card);
else {
const used = new Set([...Object.values(r.hands).flat(), ...r.discard]);
const deck = s.mode === 'classic' ? classicDeck(s.decks) : unoDeck();
s.drawPile = deck.filter((c) => !used.has(c.id));
}
return s;
}
function actE(s: OneCardState, p: string, a: OneCardAction): { state: OneCardState; events: GameEvent[] } {
const v = oneCard.validate(s, p, a);
if (!v.ok) throw new Error(`${p} ${JSON.stringify(a)}: ${v.reason}`);
return oneCard.apply(s, p, a, { rng: rng(), now: (clock += 1000) });
}
const act = (s: OneCardState, p: string, a: OneCardAction) => actE(s, p, a).state;
const play = (s: OneCardState, p: string, ids: string[], extra: Partial<Extract<OneCardAction, { type: 'play' }>> = {}) =>
act(s, p, { type: 'play', cardIds: ids, ...extra });
const reject = (s: OneCardState, p: string, a: OneCardAction): string | null => {
const v = oneCard.validate(s, p, a);
return v.ok ? null : v.reason;
};
const ids = (s: OneCardState, p: string) => s.hands[p]!.map((c) => c.id);
const topId = (s: OneCardState) => s.discard[s.discard.length - 1]!.id;
// ---------------------------------------------------------------- setup
describe('셋업 (§4)', () => {
test('same seed → same hands and start card; 7 cards each; classic start card is a plain number', () => {
const a = make({}, ['A', 'B', 'C'], 5);
const b = make({}, ['A', 'B', 'C'], 5);
expect(JSON.stringify(a)).toBe(JSON.stringify(b));
expect(JSON.stringify(make({}, ['A', 'B', 'C'], 6).hands)).not.toBe(JSON.stringify(a.hands));
for (const p of a.seats) expect(a.hands[p]).toHaveLength(7);
expect(cardTotal(a)).toBe(54);
});
test('special start cards (2/A/3/7/J/Q/K/joker) go to the bottom in order until a plain number shows', () => {
let sawSpecial = 0;
for (let seed = 1; seed <= 300; seed++) {
const players = ['A', 'B', 'C'];
const s = make({}, players, seed);
// Replay the RNG: first player, then the shuffle (§8.4).
const r = SeededRng.fromSeed(seedN(seed));
const starter = players[r.int(3)]!;
const deck = r.shuffle(classicDeck(1));
expect(s.roundStarter).toBe(starter);
const rest = deck.slice(21);
const firstPlain = rest.findIndex((c) => c.kind === 'std' && [4, 5, 6, 8, 9, 10].includes(c.rank));
const specials = rest.slice(0, firstPlain);
expect(s.discard).toEqual([rest[firstPlain]!]);
expect(s.drawPile.slice(s.drawPile.length - specials.length)).toEqual(specials);
for (const c of specials) expect(c.kind === 'joker' || [1, 2, 3, 7, 11, 12, 13].includes((c as { rank: number }).rank)).toBe(true);
if (specials.length) sawSpecial++;
}
expect(sawSpecial).toBeGreaterThan(50);
});
test('deck count: auto uses 2 decks from 6 players; 1 deck is upgraded when it cannot deal', () => {
expect(make({}, ['A', 'B', 'C', 'D', 'E']).decks).toBe(1);
expect(make({}, ['A', 'B', 'C', 'D', 'E', 'F']).decks).toBe(2);
expect(cardTotal(make({}, 'ABCDEF'.split('')))).toBe(108);
expect(make({ deckCount: 2 }, ['A', 'B']).decks).toBe(2);
expect(make({ deckCount: 1 }, 'ABCDEFG'.split('')).decks).toBe(2);
expect(make({ deckCount: 1, handSize: 5 }, 'ABCDEFG'.split('')).decks).toBe(1);
});
test('player range per mode; classic refuses 10 players', () => {
expect(oneCard.playersFor!(oneCard.defaultOptions)).toEqual({ min: 2, max: 9 });
expect(oneCard.playersFor!({ ...oneCard.defaultOptions, mode: 'uno-style' })).toEqual({ min: 2, max: 10 });
expect(() => make({}, 'ABCDEFGHIJ'.split(''))).toThrow();
expect(make({ mode: 'uno-style' }, 'ABCDEFGHIJ'.split('')).seats).toHaveLength(10);
});
});
// ---------------------------------------------------------------- classic attacks
describe('공격과 방어 (§5.2 A)', () => {
test('♥2 (2장) → ♥A 받아치기 → 누적 5장; the next player draws exactly 5 and the turn ends', () => {
let s = rig(make(), { hands: { A: ['H2', 'C4'], B: ['H1', 'D9'], C: ['S9', 'S10'] }, discard: ['H5'] });
s = play(s, 'A', ['H2']);
expect(s.pendingAttack).toBe(2);
expect(s.current).toBe('B');
s = play(s, 'B', ['H1']);
expect(s.pendingAttack).toBe(5);
expect(s.current).toBe('C');
s = act(s, 'C', { type: 'draw' });
expect(s.hands.C).toHaveLength(7);
expect(s.pendingAttack).toBe(0);
expect(s.current).toBe('A');
expect(topId(s)).toBe('H1');
});
test('♥2 위에 ♣A는 거부, ♣2는 허용', () => {
const s = rig(make(), { hands: { A: ['H2', 'C4'], B: ['C1', 'C2', 'D9'] }, discard: ['H5'] });
const t = play(s, 'A', ['H2']);
expect(reject(t, 'B', { type: 'play', cardIds: ['C1'] })).toBe('공격을 받고 있어요. 막을 카드를 내거나 카드를 먹으세요.');
expect(play(t, 'B', ['C2']).pendingAttack).toBe(4);
});
test('♠A: ♥A 거부, 흑백 조커 허용; 흑백 조커 위 컬러 조커 허용, 컬러 조커 위 흑백 조커 거부', () => {
let s = rig(make(), { hands: { A: ['S1', 'C4'], B: ['H1', 'JB', 'D9'], C: ['JC', 'S9'] }, discard: ['S5'] });
s = play(s, 'A', ['S1']);
expect(s.pendingAttack).toBe(5);
expect(reject(s, 'B', { type: 'play', cardIds: ['H1'] })).not.toBeNull();
s = play(s, 'B', ['JB']);
expect(s.pendingAttack).toBe(10);
s = play(s, 'C', ['JC']);
expect(s.pendingAttack).toBe(17);
const t = rig(make(), { hands: { A: ['JC', 'C4'], B: ['JB', 'D9'] }, discard: ['S5'] });
const u = play(t, 'A', ['JC']);
expect(reject(u, 'B', { type: 'play', cardIds: ['JB'] })).not.toBeNull();
});
test('two decks: the same attack card answers itself (♠A on ♠A, 흑백 조커 on 흑백 조커)', () => {
let s = rig(make({ deckCount: 2 }), { hands: { A: ['S1', 'C4'], B: ['S1b', 'D9'], C: ['JB', 'JBb', 'S9'] }, discard: ['S5'] });
s = play(s, 'A', ['S1']);
s = play(s, 'B', ['S1b']);
expect(s.pendingAttack).toBe(10);
s = play(s, 'C', ['JB']);
s.current = 'C';
s.phase = { kind: 'turn' };
expect(play(s, 'C', ['JBb']).pendingAttack).toBe(20);
});
test('threeDefense: ♦A 공격에 ♦3 → 누적 0, 다음 사람은 ♦3 기준 일반 턴; ♣3 거부; 조커 공격에 3 거부', () => {
let s = rig(make(), { hands: { A: ['D1', 'C4'], B: ['D3', 'C3', 'S9'], C: ['D7', 'S8', 'H3'] }, discard: ['D5'] });
s = play(s, 'A', ['D1']);
expect(reject(s, 'B', { type: 'play', cardIds: ['C3'] })).not.toBeNull();
s = play(s, 'B', ['D3']);
expect(s.pendingAttack).toBe(0);
expect(s.current).toBe('C');
expect(s.log.some((e) => e.t === 'blocked')).toBe(true);
expect(reject(s, 'C', { type: 'play', cardIds: ['S8'] })).toBe('같은 무늬나 같은 숫자 카드만 낼 수 있어요.');
expect(reject(s, 'C', { type: 'play', cardIds: ['H3'] })).toBeNull();
expect(play(s, 'C', ['D7'], { chooseSuit: 'S' }).declaredSuit).toBe('S');
const j = play(rig(make(), { hands: { A: ['JB', 'C4'], B: ['S3', 'H3'] }, discard: ['S5'] }), 'A', ['JB']);
expect(reject(j, 'B', { type: 'play', cardIds: ['S3'] })).not.toBeNull();
const off = play(rig(make({ threeDefense: 'off' }), { hands: { A: ['D1', 'C4'], B: ['D3', 'S9'] }, discard: ['D5'] }), 'A', ['D1']);
expect(reject(off, 'B', { type: 'play', cardIds: ['D3'] })).not.toBeNull();
});
});
// ---------------------------------------------------------------- classic specials
describe('특수 카드 (§5.2 B·C)', () => {
test('7 + ♠ 지정: 다음 사람은 ♠ 카드나 7만; 바닥 7의 원래 무늬(♥)는 안 됨', () => {
let s = rig(make(), { hands: { A: ['H7', 'C4'], B: ['H5', 'C7', 'S9', 'H9'] }, discard: ['H8'] });
expect(reject(s, 'A', { type: 'play', cardIds: ['H7'] })).toBe('무늬를 골라 주세요.');
s = play(s, 'A', ['H7'], { chooseSuit: 'S' });
expect(s.declaredSuit).toBe('S');
expect(reject(s, 'B', { type: 'play', cardIds: ['H5'] })).toBe('♠ 무늬나 7만 낼 수 있어요.');
expect(reject(s, 'B', { type: 'play', cardIds: ['H9'] })).not.toBeNull();
expect(reject(s, 'B', { type: 'play', cardIds: ['C7'], chooseSuit: 'H' })).toBeNull();
const t = play(s, 'B', ['S9']);
expect(t.declaredSuit).toBeNull();
expect(oneCard.view(s, 'B').my!.legalCardIds.sort()).toEqual(['C7', 'S9']);
});
test('J in 2 players → same player again; Q in 2 players (default) → opponent; as-skip → again', () => {
let s = rig(make({}, ['A', 'B']), { hands: { A: ['H11', 'H12', 'C4'], B: ['S9', 'S10'] }, discard: ['H5'] });
s = play(s, 'A', ['H11']);
expect(s.current).toBe('A');
s = play(s, 'A', ['H12']);
expect(s.current).toBe('B');
const q = play(rig(make({ qInTwoPlayer: 'as-skip' }, ['A', 'B']), { hands: { A: ['H12', 'C4'], B: ['S9'] }, discard: ['H5'] }), 'A', ['H12']);
expect(q.current).toBe('A');
});
test('J skips one, Q reverses with 3+ players', () => {
let s = rig(make({}, ['A', 'B', 'C', 'D']), { hands: { A: ['H11', 'C4'], B: ['S9'], C: ['H12', 'S2'], D: ['S10'] }, discard: ['H5'] });
s = play(s, 'A', ['H11']);
expect(s.current).toBe('C');
s = play(s, 'C', ['H12']);
expect(s.direction).toBe(-1);
expect(s.current).toBe('B');
});
test('K extra-turn: nothing playable on the extra turn → draws 1; K as the last card → out at once', () => {
let s = rig(make({ voluntaryDraw: false }, ['A', 'B']), { hands: { A: ['H13', 'C4', 'D9'], B: ['S9', 'S10'] }, discard: ['H5'] });
s = play(s, 'A', ['H13']);
expect(s.current).toBe('A');
expect(s.extraTurn).toBe(true);
expect(oneCard.view(s, 'A').my!.legalCardIds).toEqual([]);
s = act(s, 'A', { type: 'draw' });
expect(s.hands.A).toHaveLength(3);
expect(s.current).toBe('B');
const k = play(rig(make({}, ['A', 'B']), { hands: { A: ['H13'], B: ['S9', 'S10'] }, discard: ['H5'] }), 'A', ['H13']);
expect(k.phase.kind).toBe('finished');
expect(oneCard.result(k)!.ranking[0]).toEqual(['A']);
});
test('K skip-two: back to the player with 3 people, two skipped with 4', () => {
const three = play(rig(make({ kEffect: 'skip-two' }), { hands: { A: ['H13', 'C4'], B: ['S9'], C: ['S8'] }, discard: ['H5'] }), 'A', ['H13']);
expect(three.current).toBe('A');
const four = play(rig(make({ kEffect: 'skip-two' }, ['A', 'B', 'C', 'D']), { hands: { A: ['H13', 'C4'], B: ['S9'], C: ['S8'], D: ['S7'] }, discard: ['H5'] }), 'A', ['H13']);
expect(four.current).toBe('D');
});
test('afterJoker: previous → judged by the card under the joker; any → anything', () => {
let s = rig(make(), { hands: { A: ['JB', 'C4'], B: ['C2', 'D9', 'H9', 'S5'], C: ['S8'] }, discard: ['H5'] });
s = play(s, 'A', ['JB']);
s = act(s, 'B', { type: 'draw' });
s.current = 'B';
s.phase = { kind: 'turn' };
expect(oneCard.view(s, 'B').reference?.id).toBe('H5');
expect(reject(s, 'B', { type: 'play', cardIds: ['D9'] })).not.toBeNull();
expect(reject(s, 'B', { type: 'play', cardIds: ['H9'] })).toBeNull();
expect(reject(s, 'B', { type: 'play', cardIds: ['S5'] })).toBeNull();
let t = rig(make({ afterJoker: 'any' }), { hands: { A: ['JB', 'C4'], B: ['D9', 'H9'] }, discard: ['H5'] });
t = play(t, 'A', ['JB']);
t.pendingAttack = 0;
expect(reject(t, 'B', { type: 'play', cardIds: ['D9'] })).toBeNull();
});
test('multiPlay: same rank only; 2+2 = 4 attack; J+J skips two; Q+Q keeps the direction; K+K one extra turn', () => {
const base = (hands: Record<string, string[]>) => rig(make({ multiPlay: true }, ['A', 'B', 'C', 'D']), { hands, discard: ['H5'] });
const off = rig(make(), { hands: { A: ['H2', 'S2', 'C4'] }, discard: ['H5'] });
expect(reject(off, 'A', { type: 'play', cardIds: ['H2', 'S2'] })).toBe('한 번에 한 장만 낼 수 있어요.');
const two = play(base({ A: ['H2', 'S2', 'C4'], B: ['S9'], C: ['S8'], D: ['S7'] }), 'A', ['H2', 'S2']);
expect(two.pendingAttack).toBe(4);
expect(topId(two)).toBe('S2');
expect(reject(base({ A: ['H2', 'S3', 'C4'] }), 'A', { type: 'play', cardIds: ['H2', 'S3'] })).toBe('같은 숫자 카드만 함께 낼 수 있어요.');
expect(reject(base({ A: ['S2', 'H2', 'C4'] }), 'A', { type: 'play', cardIds: ['S2', 'H2'] })).not.toBeNull();
expect(play(base({ A: ['H11', 'S11', 'C4'], B: ['S9'], C: ['S8'], D: ['S7'] }), 'A', ['H11', 'S11']).current).toBe('D');
const qq = play(base({ A: ['H12', 'S12', 'C4'], B: ['S9'], C: ['S8'], D: ['S7'] }), 'A', ['H12', 'S12']);
expect(qq.direction).toBe(1);
expect(qq.current).toBe('B');
const kk = play(base({ A: ['H13', 'S13', 'S4', 'C5'], B: ['S9'], C: ['S8'], D: ['S7'] }), 'A', ['H13', 'S13']);
expect(kk.current).toBe('A');
expect(play(kk, 'A', ['S4']).current).toBe('B');
});
test('lastCardRule = joker: [조커, ♥5] → ♥5 leaves the joker, which may not be played as the last card', () => {
let s = rig(make({}, ['A', 'B']), { hands: { A: ['JB', 'H5'], B: ['H9', 'S9', 'C9'] }, discard: ['H8'] });
s = play(s, 'A', ['H5'], { declareOneCard: true });
s = play(s, 'B', ['H9']);
expect(reject(s, 'A', { type: 'play', cardIds: ['JB'] })).toBe('마지막 카드로 조커는 낼 수 없어요.');
expect(oneCard.view(s, 'A').my!.legalCardIds).toEqual([]);
expect(oneCard.view(s, 'A').my!.canDraw).toBe(true);
const attack = rig(make({ lastCardRule: 'attack' }, ['A', 'B']), { hands: { A: ['H2'], B: ['S9'] }, discard: ['H8'] });
expect(reject(attack, 'A', { type: 'play', cardIds: ['H2'] })).toBe('마지막 카드로 공격 카드(2·A·조커)는 낼 수 없어요.');
const special = rig(make({ lastCardRule: 'special' }, ['A', 'B']), { hands: { A: ['H11'], B: ['S9'] }, discard: ['H8'] });
expect(reject(special, 'A', { type: 'play', cardIds: ['H11'] })).toBe('마지막 카드로 특수 카드는 낼 수 없어요.');
const none = rig(make({ lastCardRule: 'none' }, ['A', 'B']), { hands: { A: ['JC'], B: ['S9'] }, discard: ['H8'] });
expect(play(none, 'A', ['JC']).phase.kind).toBe('finished');
});
test('voluntaryDraw off: drawing is refused while a card fits; playAfterDraw on: only the drawn card, or pass', () => {
const s = rig(make({ voluntaryDraw: false }), { hands: { A: ['H9', 'C4'] }, discard: ['H5'] });
expect(reject(s, 'A', { type: 'draw' })).toBe('낼 수 있는 카드가 있으면 먹을 수 없어요.');
let t = rig(make({ playAfterDraw: 'on' }), { hands: { A: ['C4', 'C9'], B: ['S9'] }, discard: ['H5'], drawPile: ['H10', 'S4'] });
t = act(t, 'A', { type: 'draw' });
expect(t.phase).toEqual({ kind: 'afterDraw', drawnCardId: 'H10' });
expect(reject(t, 'A', { type: 'play', cardIds: ['C4'] })).toBe('방금 먹은 카드만 낼 수 있어요.');
expect(reject(t, 'A', { type: 'draw' })).not.toBeNull();
expect(play(t, 'A', ['H10']).current).toBe('B');
expect(act(t, 'A', { type: 'pass' }).current).toBe('B');
// Classic default: no play after drawing.
const d = act(rig(make(), { hands: { A: ['C4', 'C9'], B: ['S9'] }, discard: ['H5'], drawPile: ['H10'] }), 'A', { type: 'draw' });
expect(d.current).toBe('B');
});
});
// ---------------------------------------------------------------- one card
describe('원카드 선언 (§5.3)', () => {
const setupOne = (opts: Partial<OneCardOptions> = {}) =>
rig(make(opts), { hands: { A: ['H5', 'C9'], B: ['S9', 'S10'], C: ['D9', 'D10'] }, discard: ['H8'] });
test('caught: B presses first → A takes the penalty (1장) and the window closes', () => {
let s = play(setupOne(), 'A', ['H5']);
expect(s.oneCardWindow).toMatchObject({ player: 'A', declared: false });
expect(oneCard.activePlayers(s).sort()).toEqual(['A', 'B', 'C']);
const out = actE(s, 'B', { type: 'declareOneCard' });
s = out.state;
expect(s.hands.A).toHaveLength(2);
expect(s.oneCardWindow).toBeNull();
expect(out.events).toContainEqual({ type: 'oneCardCaught', catcher: 'B', target: 'A', penalty: 1 });
expect(s.current).toBe('B');
});
test('A presses first → safe; B is ignored afterwards', () => {
let s = play(setupOne(), 'A', ['H5']);
s = act(s, 'A', { type: 'declareOneCard' });
expect(s.oneCardWindow).toMatchObject({ declared: true });
const before = JSON.stringify(s);
expect(reject(s, 'B', { type: 'declareOneCard' })).toBe('이미 원카드를 외쳤어요.');
expect(JSON.stringify(s)).toBe(before);
});
test('play with declareOneCard: true cannot be caught; autoDeclare does it for everyone', () => {
const s = play(setupOne(), 'A', ['H5'], { declareOneCard: true });
expect(reject(s, 'B', { type: 'declareOneCard' })).not.toBeNull();
const auto = play(setupOne({ autoDeclare: true }), 'A', ['H5']);
expect(auto.oneCardWindow?.declared).toBe(true);
});
test('classic window lifetime and the colour-card rule (closes on the next player’s first action)', () => {
let s = rig(make({}, ['A', 'B', 'C']), { hands: { A: ['H5', 'C9'], B: ['H9', 'S10', 'S4'], C: ['H10', 'D10', 'S3'] }, discard: ['H8'] });
s = play(s, 'A', ['H5']);
s = play(s, 'B', ['H9']);
s = play(s, 'C', ['H10']);
expect(s.oneCardWindow?.player).toBe('A');
s = act(s, 'A', { type: 'draw' });
expect(s.oneCardWindow).toBeNull();
let u = rig(make({ mode: 'uno-style' }, ['A', 'B', 'C']), { hands: { A: ['R5a', 'G9a'], B: ['R9a', 'B1a', 'B2a'], C: ['Y1a'] }, discard: ['R8a'] });
u = play(u, 'A', ['R5a']);
expect(u.oneCardWindow?.player).toBe('A');
u = play(u, 'B', ['R9a']);
expect(u.oneCardWindow).toBeNull();
expect(reject(u, 'C', { type: 'declareOneCard' })).not.toBeNull();
});
test('colour cards: being caught costs 2', () => {
let u = rig(make({ mode: 'uno-style' }, ['A', 'B']), { hands: { A: ['R5a', 'G9a'], B: ['R9a', 'B1a'] }, discard: ['R8a'] });
u = play(u, 'A', ['R5a']);
u = act(u, 'B', { type: 'declareOneCard' });
expect(u.hands.A).toHaveLength(3);
});
});
// ---------------------------------------------------------------- bankrupt & draw pile
describe('파산과 드로우 더미 (§5.4, §5.5)', () => {
const fifteen = ['C3', 'C4', 'C5', 'C6', 'C7', 'C8', 'C9', 'C10', 'C11', 'C12', 'C13', 'D3', 'D4', 'D6', 'D8'];
test('15장에서 5장을 먹어 20장 → 즉시 파산; cards go under the draw pile; 2 players → opponent wins', () => {
let s = rig(make({}, ['A', 'B']), { hands: { A: ['JB', 'S9'], B: fifteen }, discard: ['S5'] });
s = play(s, 'A', ['JB']);
const pileBefore = s.drawPile.length;
const out = actE(s, 'B', { type: 'draw' });
s = out.state;
expect(s.status.B).toBe('bankrupt');
expect(s.hands.B).toEqual([]);
expect(s.drawPile).toHaveLength(pileBefore + 15);
expect(s.drawPile.slice(-20).map((c) => c.id).slice(0, 15)).toEqual(fifteen);
expect(out.events.some((e) => e.type === 'bankrupt')).toBe(true);
expect(s.phase.kind).toBe('finished');
expect(oneCard.result(s)!.ranking).toEqual([['A'], ['B']]);
expect(cardTotal(s)).toBe(54);
});
test('3 players: the bankrupt player leaves the order and the next one plays a normal turn', () => {
let s = rig(make({}, ['A', 'B', 'C']), { hands: { A: ['JB', 'S9'], B: fifteen, C: ['S10', 'H4'] }, discard: ['S5'] });
s = play(s, 'A', ['JB']);
s = act(s, 'B', { type: 'draw' });
expect(s.status.B).toBe('bankrupt');
expect(s.current).toBe('C');
expect(s.pendingAttack).toBe(0);
s = play(s, 'C', ['S10']);
expect(s.current).toBe('A');
expect(oneCard.view(s, null).seats.find((x) => x.id === 'B')).toMatchObject({ status: 'bankrupt', cards: 0 });
});
test('draw pile empty: the discard pile (minus the top) is shuffled back; still short → draw what exists', () => {
let s = rig(make(), { hands: { A: ['C4'], B: ['C9'], C: ['D9'] }, discard: ['S3', 'S4', 'S5', 'S6', 'S8', 'S9', 'S10', 'S11', 'S12', 'JC'], drawPile: [], pending: 7 });
s.current = 'B';
const out = actE(s, 'B', { type: 'draw' });
s = out.state;
expect(s.hands.B).toHaveLength(8);
expect(s.discard.map((c) => c.id)).toEqual(['S12', 'JC']);
expect(s.drawPile).toHaveLength(1);
expect(out.events.some((e) => e.type === 'reshuffled')).toBe(true);
let t = rig(make(), { hands: { A: ['C4'], B: ['C9'], C: ['D9'] }, discard: ['S3', 'S4', 'S5', 'JC'], drawPile: [], pending: 7 });
t.current = 'B';
t = act(t, 'B', { type: 'draw' });
// The joker on top and the card under it (what decides the next play) stay; only ♠3·♠4 come back.
expect(t.discard.map((c) => c.id)).toEqual(['S5', 'JC']);
expect(t.hands.B).toHaveLength(3);
expect(t.pendingAttack).toBe(0);
expect(t.current).toBe('C');
});
});
// ---------------------------------------------------------------- colour cards
describe('색깔 카드 (§5.6)', () => {
const uno = (opts: Partial<OneCardOptions> = {}, players = ['A', 'B', 'C']) => make({ mode: 'uno-style', ...opts }, players);
test('108 cards: 4 colours × 25 + 8 wild', () => {
const d = unoDeck();
expect(d).toHaveLength(108);
expect(new Set(d.map((c) => c.id)).size).toBe(108);
expect(d.filter((c) => c.kind === 'wild' || c.kind === 'wild4')).toHaveLength(8);
expect(d.filter((c) => 'color' in c && c.color === 'R')).toHaveLength(25);
});
test('colour or symbol match; wild needs a colour', () => {
const s = rig(uno(), { hands: { A: ['G5a', 'B7a', 'RSa', 'W1', 'Y2a'] }, discard: ['R5a'] });
expect(oneCard.view(s, 'A').my!.legalCardIds.sort()).toEqual(['G5a', 'RSa', 'W1']);
expect(reject(s, 'A', { type: 'play', cardIds: ['W1'] })).toBe('색을 골라 주세요.');
const t = play(s, 'A', ['W1'], { chooseColor: 'Y' });
expect(t.declaredColor).toBe('Y');
expect(oneCard.view(t, 'B').currentColor).toBe('Y');
});
test('strict: red 3 in hand → 색 바꾸기+4 refused; no red, only blue +2 → allowed', () => {
const s = rig(uno(), { hands: { A: ['R3a', 'F1', 'G1a'] }, discard: ['R5a'] });
expect(reject(s, 'A', { type: 'play', cardIds: ['F1'], chooseColor: 'G' })).toBe('지금 색 카드가 손에 있으면 색 바꾸기+4를 낼 수 없어요.');
let t = rig(uno(), { hands: { A: ['BDa', 'F1', 'G1a'], B: ['Y1a', 'Y2a'], C: ['Y3a'] }, discard: ['R5a'] });
t = play(t, 'A', ['F1'], { chooseColor: 'B' });
expect(t.hands.B).toHaveLength(6);
expect(t.current).toBe('C');
expect(t.declaredColor).toBe('B');
});
test('challenge: violation → the player of +4 draws 4; fine → the challenger draws 6; the hand is shown to the challenger only', () => {
let s = rig(uno({ unoWild4Rule: 'challenge' }), { hands: { A: ['R3a', 'F1', 'G1a'], B: ['Y1a', 'Y2a'], C: ['Y3a'] }, discard: ['R5a'] });
s = play(s, 'A', ['F1'], { chooseColor: 'G' });
expect(s.phase.kind).toBe('wild4Response');
expect(oneCard.activePlayers(s)).toEqual(['B']);
expect(JSON.stringify(oneCard.view(s, 'B'))).not.toContain('illegal');
s = act(s, 'B', { type: 'challengeWild4' });
expect(s.hands.A).toHaveLength(6);
expect(s.hands.B).toHaveLength(2);
expect(s.current).toBe('B');
const vb = oneCard.view(s, 'B');
expect(vb.reveal).toMatchObject({ by: 'A', illegal: true });
expect(vb.reveal!.cards).toHaveLength(2);
expect(oneCard.view(s, 'C').reveal).toBeNull();
expect(oneCard.view(s, 'A').reveal).toBeNull();
expect(oneCard.view(s, null).reveal).toBeNull();
// The revealed cards carry no ids, so nobody learns which physical card is where.
for (const c of s.hands.A!) expect(JSON.stringify(oneCard.view(s, 'B'))).not.toContain(`"${c.id}"`);
let t = rig(uno({ unoWild4Rule: 'challenge' }), { hands: { A: ['B3a', 'F1', 'G1a'], B: ['Y1a', 'Y2a'], C: ['Y3a'] }, discard: ['R5a'] });
t = play(t, 'A', ['F1'], { chooseColor: 'G' });
t = act(t, 'B', { type: 'challengeWild4' });
expect(t.hands.B).toHaveLength(8);
expect(t.hands.A).toHaveLength(2);
expect(t.current).toBe('C');
expect(oneCard.view(t, 'B').reveal).toMatchObject({ illegal: false });
let u = rig(uno({ unoWild4Rule: 'challenge' }), { hands: { A: ['B3a', 'F1', 'G1a'], B: ['Y1a', 'Y2a'], C: ['Y3a'] }, discard: ['R5a'] });
u = play(u, 'A', ['F1'], { chooseColor: 'G' });
u = act(u, 'B', { type: 'acceptWild4' });
expect(u.hands.B).toHaveLength(6);
expect(u.current).toBe('C');
});
test('2 players: 방향 바꾸기 works as 건너뛰기; +2 makes the opponent draw 2 and lose the turn', () => {
const s = rig(uno({}, ['A', 'B']), { hands: { A: ['RRa', 'RDa', 'G1a'], B: ['Y1a', 'Y2a'] }, discard: ['R5a'] });
const r = play(s, 'A', ['RRa']);
expect(r.current).toBe('A');
const d = play(r, 'A', ['RDa']);
expect(d.hands.B).toHaveLength(4);
expect(d.current).toBe('A');
});
test('3 players: reverse turns the direction; skip skips one', () => {
const s = rig(uno(), { hands: { A: ['RRa', 'G1a'], B: ['Y1a'], C: ['RSa', 'Y3a'] }, discard: ['R5a'] });
const r = play(s, 'A', ['RRa']);
expect(r.direction).toBe(-1);
expect(r.current).toBe('C');
expect(play(r, 'C', ['RSa']).current).toBe('A');
});
test('start card +2: the first player draws 2 and loses the turn; skip / reverse / wild starts', () => {
const seen = new Set<string>();
for (let seed = 1; seed <= 3000 && seen.size < 4; seed++) {
const s = make({ mode: 'uno-style' }, ['A', 'B', 'C'], seed);
const start = s.discard[0]!;
expect(s.discard).toHaveLength(1);
expect(start.kind).not.toBe('wild4');
const first = s.roundStarter;
const i = s.seats.indexOf(first);
if (start.kind === 'draw2' && !seen.has('draw2')) {
seen.add('draw2');
expect(s.hands[first]).toHaveLength(9);
expect(s.current).toBe(s.seats[(i + 1) % 3]!);
}
if (start.kind === 'skip' && !seen.has('skip')) {
seen.add('skip');
expect(s.current).toBe(s.seats[(i + 1) % 3]!);
}
if (start.kind === 'reverse' && !seen.has('reverse')) {
seen.add('reverse');
expect(s.direction).toBe(-1);
expect(s.current).toBe(s.seats[(i + 2) % 3]!);
}
if (start.kind === 'wild' && !seen.has('wild')) {
seen.add('wild');
expect(s.current).toBe(first);
// The first player may follow with any colour.
expect(oneCard.view(s, first).my!.legalCardIds).toHaveLength(7);
}
}
expect(seen.size).toBe(4);
});
test('stacking: +2 on +2 adds up; +4 only on +4; the next player takes the total', () => {
let s = rig(uno({ unoStacking: true }), { hands: { A: ['RDa', 'G1a'], B: ['BDa', 'Y2a'], C: ['RDb', 'F1', 'Y3a'] }, discard: ['R5a'] });
s = play(s, 'A', ['RDa']);
expect(s.current).toBe('B');
s = play(s, 'B', ['BDa']);
expect(s.pendingAttack).toBe(4);
s = play(s, 'C', ['F1'], { chooseColor: 'Y' });
expect(s.pendingAttack).toBe(8);
expect(reject(s, 'A', { type: 'play', cardIds: ['G1a'] })).not.toBeNull();
s = act(s, 'A', { type: 'draw' });
expect(s.hands.A).toHaveLength(9);
expect(s.current).toBe('B');
});
test('scoring: B=[7, +2], C=[색 바꾸기], D=[0, 3] → the winner gets 80', () => {
let s = rig(make({ mode: 'uno-style' }, ['A', 'B', 'C', 'D']), { hands: { A: ['R5a'], B: ['R7a', 'BDa'], C: ['W1'], D: ['G0', 'Y3a'] }, discard: ['R2a'] });
s = play(s, 'A', ['R5a']);
const r = oneCard.result(s)!;
expect(r.scores).toEqual({ A: 80, B: 0, C: 0, D: 0 });
// Fewer cards first, then lower card points: C (1장) → D (2장, 3점) → B (2장, 27점).
expect(r.ranking).toEqual([['A'], ['C'], ['D'], ['B']]);
expect(s.final!.players.find((x) => x.player === 'A')).toMatchObject({ rank: 1, points: 80, cardsLeft: 0 });
expect(r.summary).toContain('80점');
});
test('going out on +2 still makes the next player draw (counted in the points)', () => {
let s = rig(make({ mode: 'uno-style' }, ['A', 'B']), { hands: { A: ['RDa'], B: ['R7a'] }, discard: ['R2a'] });
s = play(s, 'A', ['RDa']);
expect(s.hands.B).toHaveLength(3);
expect(oneCard.result(s)!.scores!.A).toBe(7 + s.hands.B!.slice(1).reduce((a, c) => a + (c.kind === 'num' ? c.value : c.kind === 'wild' || c.kind === 'wild4' ? 50 : 20), 0));
});
test('target score: rounds continue until someone reaches it', () => {
let s = rig(make({ mode: 'uno-style', unoTargetScore: 200 }, ['A', 'B']), { hands: { A: ['R5a'], B: ['W1', 'W2'] }, discard: ['R2a'] });
s = play(s, 'A', ['R5a']);
expect(s.phase.kind).toBe('roundEnd');
expect(s.scores.A).toBe(100);
expect(oneCard.result(s)).toBeNull();
expect(oneCard.deadline!(s)).toBe(s.turnStartedAt + 8000);
s = act(s, 'A', { type: 'ready' });
expect(reject(s, 'A', { type: 'ready' })).not.toBeNull();
s = act(s, 'B', { type: 'timeout' });
expect(s.round).toBe(2);
expect(s.phase.kind).not.toBe('roundEnd');
expect(cardTotal(s)).toBe(108);
s = rig(s, { hands: { A: ['W3'], B: ['W1', 'W2'] }, discard: ['R2a'] });
s = play(s, 'A', ['W3'], { chooseColor: 'R' });
expect(s.phase.kind).toBe('finished');
expect(oneCard.result(s)!.scores).toEqual({ A: 200, B: 0 });
expect(oneCard.result(s)!.summary).toContain('목표 200점');
});
});
// ---------------------------------------------------------------- timers, leaving
describe('시간 초과, 연결 끊김, 나가기 (§8.5)', () => {
test('timeout while attacked → takes the attack; afterDraw → pass; wild4Response → accept', () => {
let s = rig(make(), { hands: { A: ['H2', 'C4'], B: ['S2', 'S9'], C: ['D9'] }, discard: ['H5'] });
s = play(s, 'A', ['H2']);
expect(oneCard.onTimeout(s, 'B')).toEqual({ type: 'timeout' });
s = act(s, 'B', oneCard.onTimeout(s, 'B'));
expect(s.hands.B).toHaveLength(4);
expect(s.current).toBe('C');
let t = rig(make({ playAfterDraw: 'on' }), { hands: { A: ['C4', 'C9'], B: ['S9'] }, discard: ['H5'], drawPile: ['H10', 'S4'] });
t = act(t, 'A', { type: 'draw' });
t = act(t, 'A', { type: 'timeout' });
expect(t.current).toBe('B');
expect(t.hands.A).toHaveLength(3);
let u = rig(make({ mode: 'uno-style', unoWild4Rule: 'challenge' }), { hands: { A: ['F1', 'G1a'], B: ['Y1a'], C: ['Y3a'] }, discard: ['R5a'] });
u = play(u, 'A', ['F1'], { chooseColor: 'G' });
expect(oneCard.deadline!(u)).toBe(u.turnStartedAt + 10_000);
u = act(u, 'B', { type: 'timeout' });
expect(u.hands.B).toHaveLength(5);
});
test('normal-turn timeout draws one card and never plays one, even with a playable card', () => {
let s = rig(make({ playAfterDraw: 'on', voluntaryDraw: false }), { hands: { A: ['H9', 'C4'], B: ['S9'] }, discard: ['H5'], drawPile: ['H10'] });
s = act(s, 'A', { type: 'timeout' });
expect(ids(s, 'A').sort()).toEqual(['C4', 'H10', 'H9']);
expect(s.current).toBe('B');
});
test('a disconnected player keeps being auto-drawn on the normal timer; after 3 in a row the limit is 5 s', () => {
let s = make({}, ['A', 'B']);
const victim = s.current;
const other = s.seats.find((p) => p !== victim)!;
for (let k = 0; k < 3; k++) {
expect(oneCard.deadline!(s)).toBe(s.turnStartedAt + 20_000);
s = act(s, victim, { type: 'timeout' });
// The opponent plays on (draws or plays).
if (s.current === other) {
const legal = oneCard.legalActions!(s, other).filter((a) => a.type === 'draw');
s = act(s, other, legal[0] ?? { type: 'timeout' });
}
if (s.oneCardWindow) s.oneCardWindow = null;
}
expect(s.away[victim]).toBe(true);
expect(s.current).toBe(victim);
expect(oneCard.deadline!(s)).toBe(s.turnStartedAt + 5000);
expect(oneCard.view(s, null).seats.find((x) => x.id === victim)!.away).toBe(true);
s = act(s, victim, { type: 'draw' });
expect(s.away[victim]).toBe(false);
});
test('leaving: the current player leaves → next player; 2 players → the other one wins', () => {
let s = rig(make(), { hands: { A: ['H2', 'C4'], B: ['S2', 'S9'], C: ['D9'] }, discard: ['H5'] });
expect(oneCard.onLeave!(s, 'A')).toEqual({ type: 'leave' });
s = act(s, 'A', { type: 'leave' });
expect(s.status.A).toBe('left');
expect(s.current).toBe('B');
expect(cardTotal(s)).toBe(54);
expect(oneCard.onLeave!(s, 'A')).toBeNull();
s = act(s, 'B', { type: 'leave' });
expect(s.phase.kind).toBe('finished');
expect(oneCard.result(s)!.ranking).toEqual([['C'], ['B'], ['A']]);
});
});
// ---------------------------------------------------------------- validation & leaks
describe('검증과 정보 유출 (§8.3, §10)', () => {
test('not my turn / card not in hand / resent packet are refused and change nothing', () => {
const s = rig(make(), { hands: { A: ['H9', 'C4'], B: ['H10', 'S9'] }, discard: ['H5'] });
const before = JSON.stringify(s);
expect(reject(s, 'B', { type: 'play', cardIds: ['H10'] })).toBe('지금은 내 차례가 아니에요.');
expect(reject(s, 'A', { type: 'play', cardIds: ['H10'] })).toBe('손에 없는 카드예요.');
expect(reject(s, 'A', { type: 'play', cardIds: ['H9', 'H9'] })).not.toBeNull();
expect(reject(s, 'Z', { type: 'draw' })).toBe('이 게임의 플레이어가 아니에요.');
expect(JSON.stringify(s)).toBe(before);
const t = play(s, 'A', ['H9']);
expect(reject(t, 'A', { type: 'play', cardIds: ['H9'] })).not.toBeNull();
t.current = 'A';
expect(reject(t, 'A', { type: 'play', cardIds: ['H9'] })).toBe('손에 없는 카드예요.');
});
test('views: opponents and spectators see counts only; cardsDrawn events carry counts only', () => {
let s = make({}, ['A', 'B', 'C'], 9);
const chooser = SeededRng.fromSeed([1, 2, 3, 4]);
for (let k = 0; k < 120 && !oneCard.result(s); k++) {
for (const viewer of [...s.seats, null]) {
const json = JSON.stringify(oneCard.view(s, viewer));
for (const owner of s.seats) if (owner !== viewer) for (const c of s.hands[owner]!) expect(json).not.toContain(`"${c.id}"`);
for (const c of s.drawPile) expect(json).not.toContain(`"${c.id}"`);
}
const actives = oneCard.activePlayers(s);
const p = actives[chooser.int(actives.length)]!;
const legal = oneCard.legalActions!(s, p);
const out = oneCard.apply(s, p, legal[chooser.int(legal.length)]!, { rng: rng(), now: k * 1000 });
for (const e of out.events) {
if (e.type === 'cardsDrawn') expect(Object.keys(e).sort()).toEqual(['count', 'player', 'type', 'why']);
const json = JSON.stringify(e);
for (const owner of s.seats) for (const c of out.state.hands[owner]!) expect(json).not.toContain(`"${c.id}"`);
}
s = out.state;
}
const v = oneCard.view(s, 'A');
expect(v.seats.every((x) => typeof x.cards === 'number')).toBe(true);
expect(oneCard.view(s, null).my).toBeNull();
});
test('options: defaults, Korean metadata on every option, mode-dependent defaults resolved in the view', () => {
expect(oneCard.defaultOptions).toMatchObject({ mode: 'classic', handSize: 7, attack2: 2, attackA: 3, attackSpadeA: 5, attackBlackJoker: 5, attackColorJoker: 7, turnSeconds: 20 });
const shape = (oneCard.optionsSchema as unknown as { shape: Record<string, { meta(): { title?: string } | undefined }> }).shape;
for (const [k, v] of Object.entries(shape)) expect(v.meta()?.title, k).toBeTruthy();
const c = oneCard.view(make(), 'A');
expect([c.penalty, c.bankruptLimit, c.playAfterDraw]).toEqual([1, 20, false]);
const u = oneCard.view(make({ mode: 'uno-style' }), 'A');
expect([u.penalty, u.bankruptLimit, u.playAfterDraw]).toEqual([2, null, true]);
expect(oneCard.optionsSchema.safeParse({ attackColorJoker: 14 }).success).toBe(false);
});
test('client actions only (system actions are not in the schema)', () => {
expect(oneCard.actionSchema.safeParse({ type: 'timeout' }).success).toBe(false);
expect(oneCard.actionSchema.safeParse({ type: 'leave' }).success).toBe(false);
expect(oneCard.actionSchema.safeParse({ type: 'play', cardIds: ['H9'], chooseSuit: 'S' }).success).toBe(true);
expect(oneCard.actionSchema.safeParse({ type: 'play', cardIds: [] }).success).toBe(false);
});
});
// ---------------------------------------------------------------- properties
const TOKEN = /"[^"\\]*"/g;
/** Checks the given viewers (default: every player and a spectator) against every player's secrets. */
function checkLeaks(s: OneCardState, players: PlayerId[], viewers: (PlayerId | null)[] = [...players, null]): void {
// Card ids are plain strings, so every JSON string token of a view is compared with the secret ids.
const owners = new Map<string, PlayerId[]>();
for (const owner of players)
for (const secret of oneCard.secretsOf!(s, owner)) {
const k = `"${secret}"`;
const list = owners.get(k);
if (list) list.push(owner);
else owners.set(k, [owner]);
}
for (const viewer of viewers)
for (const tok of JSON.stringify(oneCard.view(s, viewer)).match(TOKEN) ?? []) {
const of = owners.get(tok);
if (of && of.some((o) => o !== viewer)) throw new Error(`leak ${tok} of ${of.join(',')} to ${viewer}`);
}
}
/** Events go to everyone: no card id that is hidden after the action may appear in them. */
function checkEvents(events: GameEvent[], s: OneCardState): void {
const json = JSON.stringify(events);
const hidden = new Set([...s.drawPile, ...s.seats.flatMap((p) => s.hands[p]!)].map((c) => `"${c.id}"`));
for (const tok of json.match(TOKEN) ?? []) if (hidden.has(tok)) throw new Error(`event leaks ${tok}`);
}
function invariants(s: OneCardState): void {
if (cardTotal(s) !== deckSize(s)) throw new Error(`cards not conserved: ${cardTotal(s)} vs ${deckSize(s)}`);
const seen = new Set<string>();
for (const c of [...s.drawPile, ...s.discard, ...s.seats.flatMap((p) => s.hands[p]!)]) {
if (seen.has(c.id)) throw new Error(`duplicate card ${c.id}`);
seen.add(c.id);
}
if (s.pendingAttack < 0) throw new Error('negative attack');
for (const p of s.seats) if (s.status[p] !== 'playing' && s.hands[p]!.length > 0 && s.phase.kind !== 'roundEnd') throw new Error(`${p} out of play but holds cards`);
if (s.oneCardWindow && s.hands[s.oneCardWindow.player]!.length !== 1) throw new Error('one-card window without exactly one card');
if ((s.phase.kind === 'turn' || s.phase.kind === 'afterDraw') && s.status[s.current] !== 'playing') throw new Error('current player is not playing');
}
interface FastReport {
steps: number;
finished: boolean;
trace: string;
final: OneCardState;
}
/**
* Same chooser and action selection as `randomPlayout`, with cheaper checks so 1,000 games per mode stay fast:
* invariants and onTimeout legality every step; view / event leaks (rotating viewer) and JSON round trip on a schedule.
*/
function fastPlayout(players: PlayerId[], options: OneCardOptions, seed: RngState, maxSteps: number): FastReport {
const r = SeededRng.fromSeed(seed);
const chooser = SeededRng.fromSeed([seed[3], seed[2], seed[1], seed[0] ^ 0x9e3779b9]);
let now = 1_700_000_000_000;
let s = oneCard.setup({ players, options, rng: r, now });
const trace: string[] = [];
for (let step = 0; step < maxSteps; step++) {
invariants(s);
// A spectator and one rotating player every 10th step; everyone at the end.
if (step % 10 === 0) checkLeaks(s, players, [null, players[(step / 10) % players.length]!]);
if (step % 50 === 0 && JSON.stringify(JSON.parse(JSON.stringify(s))) !== JSON.stringify(s)) throw new Error('not JSON round-trippable');
for (const p of oneCard.timeoutPlayers!(s)) {
const v = oneCard.validate(s, p, oneCard.onTimeout(s, p));
if (!v.ok) throw new Error(`onTimeout rejected for ${p}: ${v.reason}`);
}
if (oneCard.result(s)) {
checkLeaks(s, players);
return { steps: step, finished: true, trace: trace.join(';'), final: s };
}
const actives = oneCard.activePlayers(s);
if (!actives.length) throw new Error('no active players');
const actor = actives[chooser.int(actives.length)]!;
const legal = oneCard.legalActions!(s, actor);
const action = legal.length > 0 ? legal[chooser.int(legal.length)]! : oneCard.onTimeout(s, actor);
const v = oneCard.validate(s, actor, action);
if (!v.ok) throw new Error(`generated action rejected: ${v.reason} ${JSON.stringify(action)}`);
now += 1000;
const out = oneCard.apply(s, actor, action, { rng: r, now });
// Events never carry card ids at all (played cards are sent as faces); a full check every 10th step.
if (JSON.stringify(out.events).includes('"id"')) throw new Error('event carries a card id');
if (step % 10 === 5) checkEvents(out.events, out.state);
s = out.state;
trace.push(`${actor}:${JSON.stringify(action)}`);
}
return { steps: maxSteps, finished: false, trace: trace.join(';'), final: s };
}
const PRESETS: Record<'classic' | 'uno-style', Partial<OneCardOptions>[]> = {
classic: [
{},
{ voluntaryDraw: false, multiPlay: true },
{ kEffect: 'skip-two', qInTwoPlayer: 'as-skip', lastCardRule: 'special', afterJoker: 'any', threeDefense: 'off' },
{ endCondition: 'last-standing', bankruptLimit: 15, handSize: 5, deckCount: 2, playAfterDraw: 'on' },
{ multiPlay: true, lastCardRule: 'attack', bankruptLimit: 'none', autoDeclare: true, attackColorJoker: 13, attackSpadeA: 10 },
],
'uno-style': [
{},
{ voluntaryDraw: false, unoWild4Rule: 'challenge' },
{ unoStacking: true, unoWild4Rule: 'challenge', bankruptLimit: 25, voluntaryDraw: false },
{ endCondition: 'last-standing', unoWild4Rule: 'free', bankruptLimit: 18, playAfterDraw: 'off', handSize: 5 },
{ unoTargetScore: 200, voluntaryDraw: false, bankruptLimit: 20, autoDeclare: true },
],
};
describe('random playouts (1,000 seeds per mode)', () => {
for (const mode of ['classic', 'uno-style'] as const) {
test(`${mode}: 1,000 seeded games finish, cards conserved, no leaks, onTimeout always legal, deterministic`, () => {
const max = mode === 'classic' ? 9 : 10;
let finished = 0;
for (let i = 1; i <= 1000; i++) {
const n = 2 + (i % (max - 1));
const players = Array.from({ length: n }, (_, k) => `P${k}`);
const options = oneCard.optionsSchema.parse({ mode, ...PRESETS[mode][i % PRESETS[mode].length]! });
const a = fastPlayout(players, options, seedN(i), 30_000);
if (!a.finished) throw new Error(`seed ${i} (${n} players) did not finish in 30,000 steps`);
finished++;
const res = oneCard.result(a.final)!;
expect(res.ranking.flat().sort()).toEqual([...players].sort());
if (i % 25 === 0) {
const b = fastPlayout(players, options, seedN(i), 30_000);
expect(b.trace).toBe(a.trace);
expect(JSON.stringify(b.final)).toBe(JSON.stringify(a.final));
}
}
expect(finished).toBe(1000);
}, 120_000);
}
test.each([
['classic', 2, {}],
['classic', 3, { multiPlay: true, voluntaryDraw: false }],
['classic', 6, { endCondition: 'last-standing', voluntaryDraw: false }],
['uno-style', 2, {}],
['uno-style', 3, { unoStacking: true, unoWild4Rule: 'challenge', voluntaryDraw: false }],
['uno-style', 4, { unoTargetScore: 200, voluntaryDraw: false }],
] as const)('randomPlayout with the engine’s per-step leak / round-trip checks: %s %i players', (mode, n, opts) => {
const players = Array.from({ length: n }, (_, k) => `P${k}`);
const options = oneCard.optionsSchema.parse({ mode, ...opts });
const conserve = (st: unknown) => invariants(st as OneCardState);
for (let i = 1; i <= 3; i++) {
const a = randomPlayout(oneCard, { players, options, seed: seedN(1000 + i), maxSteps: 30_000, invariant: conserve, maxApplyMs: 30 });
expect(a.finished).toBe(true);
if (i === 1) {
const b = randomPlayout(oneCard, { players, options, seed: seedN(1000 + i), maxSteps: 30_000, maxApplyMs: 30 });
expect(JSON.stringify(b.trace)).toBe(JSON.stringify(a.trace));
}
}
}, 60_000);
});

View File

@@ -0,0 +1,165 @@
/** Card legality for both modes (docs/games/one-card.md §5.2, §5.6). Pure helpers over the state. */
import type { PlayerId } from '@bg/engine';
import { COLOR_KO, SUIT_SYMBOL, colorOf, isJoker, isSpadeAce, isStd, rankLabel, type Card, type Color } from './cards';
import type { OneCardOptions, OneCardState } from './types';
export const resolvedPenalty = (o: OneCardOptions): number => (o.oneCardPenalty === 'auto' ? (o.mode === 'classic' ? 1 : 2) : o.oneCardPenalty);
export const resolvedBankrupt = (o: OneCardOptions): number | null =>
o.bankruptLimit === 'auto' ? (o.mode === 'classic' ? 20 : null) : o.bankruptLimit === 'none' ? null : o.bankruptLimit;
export const resolvedPlayAfterDraw = (o: OneCardOptions): boolean => (o.playAfterDraw === 'auto' ? o.mode === 'uno-style' : o.playAfterDraw === 'on');
export const top = (s: OneCardState): Card => s.discard[s.discard.length - 1]!;
/** Classic: the first non-joker from the top (the start card is never a joker). */
export function referenceCard(s: OneCardState): Card {
for (let i = s.discard.length - 1; i >= 0; i--) if (!isJoker(s.discard[i]!)) return s.discard[i]!;
return top(s);
}
/** Attack size of one classic card (0 = not an attack card). */
export function attackOf(o: OneCardOptions, c: Card): number {
if (c.kind === 'joker') return c.joker === 'black' ? o.attackBlackJoker : o.attackColorJoker;
if (c.kind !== 'std') return 0;
if (c.rank === 2) return o.attack2;
if (c.rank === 1) return c.suit === 'S' ? o.attackSpadeA : o.attackA;
return 0;
}
/** §5.2 A: may `c` be played on top of the attack card `t`? */
export function canCounter(t: Card, c: Card, twoDecks: boolean): boolean {
if (t.kind === 'joker') {
if (c.kind !== 'joker') return false;
if (t.joker === 'black') return c.joker === 'color' || twoDecks;
return twoDecks && c.joker === 'color';
}
if (!isStd(t)) return false;
if (isJoker(c)) return true;
if (!isStd(c)) return false;
if (t.rank === 2) return c.rank === 2 || (c.rank === 1 && c.suit === t.suit);
if (t.rank === 1 && t.suit !== 'S') return c.rank === 1;
if (isSpadeAce(t)) return twoDecks && isSpadeAce(c);
return false;
}
/** Same-suit 3 on a 2 / A attack (threeDefense). */
export function canBlockWithThree(o: OneCardOptions, t: Card, c: Card): boolean {
return o.threeDefense === 'same-suit' && isStd(t) && (t.rank === 1 || t.rank === 2) && isStd(c) && c.rank === 3 && c.suit === t.suit;
}
/** Classic normal turn (§5.2 B). */
export function classicMatches(s: OneCardState, c: Card): boolean {
if (isJoker(c)) return true;
if (!isStd(c)) return false;
const t = top(s);
if (isJoker(t) && s.options.afterJoker === 'any') return true;
const ref = referenceCard(s);
if (!isStd(ref)) return true;
if (s.declaredSuit) return c.suit === s.declaredSuit || c.rank === 7;
return c.suit === ref.suit || c.rank === ref.rank;
}
/** §5.2 D: cards that may not be the last card(s) of a hand. */
export function forbiddenAsLast(o: OneCardOptions, c: Card): boolean {
switch (o.lastCardRule) {
case 'none':
return false;
case 'joker':
return isJoker(c);
case 'attack':
return isJoker(c) || (isStd(c) && (c.rank === 1 || c.rank === 2));
case 'special':
return isJoker(c) || (isStd(c) && [1, 2, 3, 7, 11, 12, 13].includes(c.rank));
}
}
/** Colour cards: the colour to follow (null when a starting wild left it open). */
export function currentColor(s: OneCardState): Color | null {
return s.declaredColor ?? colorOf(top(s));
}
function unoSymbolMatch(t: Card, c: Card): boolean {
if (t.kind === 'num' && c.kind === 'num') return t.value === c.value;
return (t.kind === 'skip' || t.kind === 'reverse' || t.kind === 'draw2') && t.kind === c.kind;
}
/** Colour cards normal turn (§5.6), including the strict +4 rule against `hand`. */
export function unoMatches(s: OneCardState, c: Card, hand: readonly Card[]): boolean {
const color = currentColor(s);
if (c.kind === 'wild') return true;
if (c.kind === 'wild4') {
if (s.options.unoWild4Rule !== 'strict' || color === null) return true;
return !hand.some((h) => h.id !== c.id && colorOf(h) === color);
}
if (color === null) return true;
return colorOf(c) === color || unoSymbolMatch(top(s), c);
}
/** Colour cards stacking (§5.6 unoStacking): +2 on +2, +4 on +2 or +4. */
export function unoCanStack(t: Card, c: Card): boolean {
if (t.kind === 'draw2') return c.kind === 'draw2' || c.kind === 'wild4';
if (t.kind === 'wild4') return c.kind === 'wild4';
return false;
}
/**
* May `c` start a play by `p` right now (phase turn / afterDraw / wild4Response stacking)?
* The last-card rule is checked against a single-card play here; multi-play callers re-check.
*/
export function canStartWith(s: OneCardState, p: PlayerId, c: Card): boolean {
const hand = s.hands[p] ?? [];
if (s.mode === 'classic') {
let ok: boolean;
if (s.pendingAttack > 0) ok = canCounter(top(s), c, s.decks === 2) || canBlockWithThree(s.options, top(s), c);
else ok = classicMatches(s, c);
if (!ok) return false;
if (hand.length === 1 && forbiddenAsLast(s.options, c)) return false;
return true;
}
if (s.pendingAttack > 0) return s.options.unoStacking && unoCanStack(top(s), c);
return unoMatches(s, c, hand);
}
/** Card ids `p` may play now (first card of a play), respecting phase. */
export function legalCardIds(s: OneCardState, p: PlayerId): string[] {
if (s.current !== p && !(s.phase.kind === 'wild4Response' && s.phase.target === p)) return [];
const hand = s.hands[p] ?? [];
switch (s.phase.kind) {
case 'turn':
if (s.current !== p) return [];
return hand.filter((c) => canStartWith(s, p, c)).map((c) => c.id);
case 'afterDraw': {
const id = s.phase.drawnCardId;
const c = hand.find((x) => x.id === id);
return c && canStartWith(s, p, c) ? [id] : [];
}
case 'wild4Response':
if (s.phase.target !== p || !s.options.unoStacking) return [];
return hand.filter((c) => c.kind === 'wild4').map((c) => c.id);
default:
return [];
}
}
/** Beginner hint for why a card fits (§9 "힌트"). */
export function hintFor(s: OneCardState, c: Card): string {
const t = top(s);
if (s.mode === 'classic') {
if (s.pendingAttack > 0) {
if (canBlockWithThree(s.options, t, c) && !canCounter(t, c, s.decks === 2)) return '같은 무늬 3으로 공격을 막아요';
return '공격을 받아쳐요';
}
if (isJoker(c)) return '조커는 언제든 낼 수 있어요';
if (isJoker(t) && s.options.afterJoker === 'any') return '조커 다음이라 아무 카드나 돼요';
const ref = referenceCard(s);
if (!isStd(c) || !isStd(ref)) return '';
if (s.declaredSuit) return c.suit === s.declaredSuit ? `지정된 ${SUIT_SYMBOL[s.declaredSuit]} 무늬예요` : '7은 7 위에 낼 수 있어요';
if (c.suit === ref.suit) return `${SUIT_SYMBOL[c.suit]} 무늬가 같아요`;
return `${rankLabel(c.rank)} 숫자가 같아요`;
}
if (s.pendingAttack > 0) return '받아쳐서 다음 사람에게 넘겨요';
if (c.kind === 'wild' || c.kind === 'wild4') return '색을 바꾸는 카드예요';
const color = currentColor(s);
if (color === null) return '아무 색이나 낼 수 있어요';
if (colorOf(c) === color) return `${COLOR_KO[color]} 색이 같아요`;
return '숫자·기호가 같아요';
}

View File

@@ -0,0 +1,263 @@
/** 원카드 options, actions, state and view types (docs/games/one-card.md §2, §8). */
import { z } from 'zod';
import type { PlayerId } from '@bg/engine';
import type { Card, Color, Suit } from './cards';
const lit = <T extends string | number>(...vals: T[]) =>
z.union(vals.map((v) => z.literal(v)) as unknown as [z.ZodLiteral<T>, z.ZodLiteral<T>, ...z.ZodLiteral<T>[]]);
export const oneCardOptionsSchema = z.object({
mode: z
.enum(['classic', 'uno-style'])
.default('classic')
.meta({ title: '게임 모드', labels: { classic: '원카드(트럼프)', 'uno-style': '색깔 카드' } }),
handSize: lit(5, 6, 7).default(7).meta({ title: '처음 받는 카드 수', labels: { 5: '5장', 6: '6장', 7: '7장' } }),
deckCount: lit<'auto' | 1 | 2>('auto', 1, 2)
.default('auto')
.meta({ title: '(원카드) 덱 수', labels: { auto: '자동(6명부터 2벌)', 1: '1벌', 2: '2벌' } }),
attack2: lit(1, 2, 3).default(2).meta({ title: '(원카드) 2 공격 장수', labels: { 1: '1장', 2: '2장', 3: '3장' } }),
attackA: lit(2, 3, 4, 5).default(3).meta({ title: '(원카드) A 공격 장수(♠ 제외)', labels: { 2: '2장', 3: '3장', 4: '4장', 5: '5장' } }),
attackSpadeA: z.number().int().min(3).max(10).default(5).meta({ title: '(원카드) ♠A 공격 장수(3~10)' }),
attackBlackJoker: z.number().int().min(5).max(10).default(5).meta({ title: '(원카드) 흑백 조커 공격 장수(5~10)' }),
attackColorJoker: z.number().int().min(5).max(13).default(7).meta({ title: '(원카드) 컬러 조커 공격 장수(5~13)' }),
threeDefense: z
.enum(['off', 'same-suit'])
.default('same-suit')
.meta({ title: '(원카드) 3으로 막기', labels: { off: '안 씀', 'same-suit': '같은 무늬 3으로 2·A 공격 막기' } }),
kEffect: z
.enum(['extra-turn', 'skip-two', 'none'])
.default('extra-turn')
.meta({ title: '(원카드) K 효과', labels: { 'extra-turn': '한 번 더', 'skip-two': '두 명 건너뛰기', none: '효과 없음' } }),
qInTwoPlayer: z
.enum(['no-effect', 'as-skip'])
.default('no-effect')
.meta({ title: '(원카드) 2명일 때 Q', labels: { 'no-effect': '효과 없음', 'as-skip': 'J처럼 한 번 더' } }),
lastCardRule: z
.enum(['none', 'joker', 'attack', 'special'])
.default('joker')
.meta({
title: '(원카드) 마지막 카드로 낼 수 없는 카드',
labels: { none: '제한 없음', joker: '조커', attack: '공격 카드(2·A·조커)', special: '특수 카드 모두' },
}),
afterJoker: z
.enum(['previous', 'any'])
.default('previous')
.meta({ title: '(원카드) 조커 다음에 낼 카드', labels: { previous: '조커 밑 카드 기준', any: '아무 카드' } }),
multiPlay: z.boolean().default(false).meta({ title: '(원카드) 같은 숫자 여러 장 한꺼번에 내기' }),
oneCardPenalty: lit<'auto' | 1 | 2 | 3>('auto', 1, 2, 3)
.default('auto')
.meta({ title: '원카드를 못 외치고 잡히면 먹는 장수', labels: { auto: '모드 기본(원카드 1장, 색깔 카드 2장)', 1: '1장', 2: '2장', 3: '3장' } }),
autoDeclare: z.boolean().default(false).meta({ title: '1장 남으면 자동으로 원카드 외치기' }),
bankruptLimit: lit<'auto' | 15 | 18 | 20 | 25 | 'none'>('auto', 15, 18, 20, 25, 'none')
.default('auto')
.meta({
title: '파산 장수(이만큼 쌓이면 탈락)',
labels: { auto: '모드 기본(원카드 20장, 색깔 카드 없음)', 15: '15장', 18: '18장', 20: '20장', 25: '25장', none: '파산 없음' },
}),
voluntaryDraw: z.boolean().default(true).meta({ title: '낼 카드가 있어도 1장 먹고 넘기기' }),
playAfterDraw: lit('auto', 'on', 'off')
.default('auto')
.meta({ title: '먹은 카드를 바로 내기', labels: { auto: '모드 기본(원카드 안 됨, 색깔 카드 됨)', on: '바로 낼 수 있음', off: '안 됨' } }),
endCondition: z
.enum(['first-out', 'last-standing'])
.default('first-out')
.meta({ title: '끝나는 때', labels: { 'first-out': '1등이 나오면', 'last-standing': '꼴찌가 정해질 때까지' } }),
turnSeconds: lit(10, 15, 20, 30, 60)
.default(20)
.meta({ title: '한 턴 제한 시간', labels: { 10: '10초', 15: '15초', 20: '20초', 30: '30초', 60: '60초' } }),
unoStacking: z.boolean().default(false).meta({ title: '(색깔 카드) +2·+4 받아치기' }),
unoWild4Rule: z
.enum(['strict', 'challenge', 'free'])
.default('strict')
.meta({
title: '(색깔 카드) 색 바꾸기+4 내는 조건',
labels: { strict: '지금 색 카드가 없을 때만', challenge: '아무 때나(다음 사람이 의심하기 가능)', free: '제한 없음' },
}),
unoTargetScore: lit(0, 200, 500)
.default(0)
.meta({ title: '(색깔 카드) 목표 점수(여러 판)', labels: { 0: '없음(한 판)', 200: '200점', 500: '500점' } }),
});
export type OneCardOptions = z.infer<typeof oneCardOptionsSchema>;
export type OneCardMode = OneCardOptions['mode'];
export const oneCardActionSchema = z.discriminatedUnion('type', [
z.object({
type: z.literal('play'),
cardIds: z.array(z.string().max(8)).min(1).max(8),
chooseSuit: z.enum(['S', 'H', 'D', 'C']).optional(),
chooseColor: z.enum(['R', 'Y', 'G', 'B']).optional(),
declareOneCard: z.boolean().optional(),
}),
z.object({ type: z.literal('draw') }),
z.object({ type: z.literal('pass') }),
z.object({ type: z.literal('declareOneCard') }),
z.object({ type: z.literal('challengeWild4') }),
z.object({ type: z.literal('acceptWild4') }),
z.object({ type: z.literal('ready') }),
]);
/** Client actions plus system-only ones (timer, leaving) that are not in the schema. */
export type OneCardAction = z.infer<typeof oneCardActionSchema> | { type: 'timeout' } | { type: 'leave' };
export type PlayAction = Extract<OneCardAction, { type: 'play' }>;
export type PlayerStatus = 'playing' | 'out' | 'bankrupt' | 'left';
export type Phase =
| { kind: 'turn' }
| { kind: 'afterDraw'; drawnCardId: string }
/** `illegal`: the +4 was played while holding a card of `prevColor` (secret until challenged). */
| { kind: 'wild4Response'; by: PlayerId; target: PlayerId; prevColor: Color | null; illegal: boolean }
| { kind: 'roundEnd' }
| { kind: 'finished' };
/** Public log line (structured; the screen turns it into Korean with nicknames). */
export type LogEntry =
/** Played cards without ids: ids are reused after a reshuffle and must not hint at hidden cards. */
| { seq: number; t: 'play'; p: PlayerId; cards: CardFace[]; suit?: Suit; color?: Color }
| { seq: number; t: 'draw'; p: PlayerId; n: number; why: 'normal' | 'attack' | 'penalty' | 'draw2' | 'wild4' | 'challenge' | 'start' }
| { seq: number; t: 'pass'; p: PlayerId }
| { seq: number; t: 'blocked'; p: PlayerId }
| { seq: number; t: 'declare'; p: PlayerId }
| { seq: number; t: 'caught'; p: PlayerId; by: PlayerId; n: number }
| { seq: number; t: 'bankrupt'; p: PlayerId }
| { seq: number; t: 'out'; p: PlayerId; place: number }
| { seq: number; t: 'left'; p: PlayerId }
| { seq: number; t: 'timeout'; p: PlayerId }
| { seq: number; t: 'reshuffle'; n: number }
| { seq: number; t: 'challenge'; p: PlayerId; by: PlayerId; illegal: boolean }
| { seq: number; t: 'skip'; p: PlayerId }
| { seq: number; t: 'reverse' }
| { seq: number; t: 'round'; round: number; winner: PlayerId | null; points: number };
/** A card without its id (shown to the +4 challenger, so leak checks on ids stay meaningful). */
export type CardFace = Card extends infer C ? (C extends Card ? Omit<C, 'id'> : never) : never;
export interface PlayerResult {
player: PlayerId;
rank: number;
status: PlayerStatus;
cardsLeft: number;
/** Classic: remaining card points (lower is better). Colour cards: points won this game. */
points: number;
}
export interface OneCardFinal {
ranking: PlayerId[][];
players: PlayerResult[];
scores?: Record<PlayerId, number>;
summary: string;
}
export interface RoundRecord {
round: number;
winner: PlayerId | null;
points: number;
}
export interface OneCardState {
mode: OneCardMode;
options: OneCardOptions;
/** Decks actually in use (classic). */
decks: 1 | 2;
seats: PlayerId[];
hands: Record<PlayerId, Card[]>;
/** Secret; [0] is the top. */
drawPile: Card[];
/** Public; the last card is the top. */
discard: Card[];
declaredSuit: Suit | null;
declaredColor: Color | null;
direction: 1 | -1;
current: PlayerId;
phase: Phase;
pendingAttack: number;
extraTurn: boolean;
oneCardWindow: { player: PlayerId; declared: boolean; openedSeq: number } | null;
status: Record<PlayerId, PlayerStatus>;
finishOrder: PlayerId[];
bankruptOrder: PlayerId[];
leftOrder: PlayerId[];
turnStartedAt: number;
/** Applied actions. */
seq: number;
/** Turn counter (changes whenever a new turn starts). */
turnNo: number;
timeoutsInRow: Record<PlayerId, number>;
away: Record<PlayerId, boolean>;
/** Colour-card multi-game scoring (§6). */
round: number;
roundStarter: PlayerId;
scores: Record<PlayerId, number>;
rounds: RoundRecord[];
ready: PlayerId[];
/** Hand shown to the challenger after a +4 challenge (only in that player's view). */
reveal: { to: PlayerId; by: PlayerId; cards: CardFace[]; illegal: boolean; seq: number } | null;
log: LogEntry[];
final: OneCardFinal | null;
}
// ---------------------------------------------------------------- view
export interface SeatView {
id: PlayerId;
cards: number;
status: PlayerStatus;
isTurn: boolean;
away: boolean;
/** One-card window open for this player (declared = safe). */
oneCard: 'open' | 'declared' | null;
score: number;
ready: boolean;
}
export interface OneCardView {
mode: OneCardMode;
options: OneCardOptions;
decks: 1 | 2;
penalty: number;
bankruptLimit: number | null;
playAfterDraw: boolean;
me: PlayerId | null;
seats: SeatView[];
current: PlayerId | null;
direction: 1 | -1;
phase: Phase['kind'];
turnNo: number;
extraTurn: boolean;
/** Last few discards, top last. */
recent: Card[];
/** Classic: the card below jokers that decides what may follow (afterJoker = previous). */
reference: Card | null;
declaredSuit: Suit | null;
declaredColor: Color | null;
/** Colour cards: colour to follow (null = any). */
currentColor: Color | null;
pendingAttack: number;
drawPileCount: number;
discardCount: number;
oneCardWindow: { player: PlayerId; declared: boolean } | null;
wild4: { by: PlayerId; target: PlayerId } | null;
my: {
hand: Card[];
/** Cards that may be played now (first card of a play). */
legalCardIds: string[];
/** Why each legal card fits (beginner hint). */
hints: Record<string, string>;
canDraw: boolean;
/** Cards a draw takes now. */
drawCount: number;
canPass: boolean;
drawnCardId: string | null;
canDeclare: boolean;
canChallenge: boolean;
canReady: boolean;
/** Must follow the multi-play rule (same rank). */
multiPlay: boolean;
} | null;
reveal: { by: PlayerId; cards: CardFace[]; illegal: boolean } | null;
turnStartedAt: number;
deadlineTotalMs: number;
round: number;
rounds: RoundRecord[];
log: LogEntry[];
result: OneCardFinal | null;
}

View File

@@ -18,6 +18,7 @@ export const CATALOG: CatalogEntry[] = [
{ id: 'baduk', nameKo: '바둑', minPlayers: 2, maxPlayers: 2, minutes: '15~60', level: '어려움', blurb: '집을 더 많이 지은 쪽이 승리', available: true },
{ id: 'seotda', nameKo: '섯다', minPlayers: 2, maxPlayers: 10, minutes: '20~40', level: '보통', blurb: '화투 두 장으로 겨루는 족보 승부 · 놀이용 칩', available: true },
{ id: 'poker', nameKo: '포커', minPlayers: 2, maxPlayers: 10, minutes: '20~60', level: '보통', blurb: '홀덤·7포커·인디언 등 7가지 모드 · 놀이용 칩', available: true },
{ id: 'one-card', nameKo: '원카드', minPlayers: 2, maxPlayers: 10, minutes: '10~15', level: '쉬움', blurb: '같은 무늬·숫자를 내고 공격 카드로 먹이기! · 색깔 카드 모드', available: true },
];
export const catalogById = (id: string): CatalogEntry | undefined => CATALOG.find((g) => g.id === id);