Merge branch 'm4/one-card' into codex/owner/grp_discord_joke-app
# Conflicts: # apps/web/src/games/registry.ts # apps/web/src/lib/catalog-ui.ts # e2e/games.e2e.ts # packages/games/src/index.ts # packages/shared/src/catalog.ts
This commit is contained in:
@@ -8,6 +8,7 @@ import { seotda } from './seotda';
|
||||
import { poker } from './poker';
|
||||
import { yacht } from './yacht';
|
||||
import { yut } from './yut';
|
||||
import { oneCard } from './one-card';
|
||||
|
||||
export const GAMES: Record<string, AnyGameDefinition> = {
|
||||
[omok.id]: omok,
|
||||
@@ -19,4 +20,5 @@ export const GAMES: Record<string, AnyGameDefinition> = {
|
||||
[poker.id]: poker,
|
||||
[yacht.id]: yacht,
|
||||
[yut.id]: yut,
|
||||
[oneCard.id]: oneCard,
|
||||
};
|
||||
|
||||
100
packages/games/src/one-card/cards.ts
Normal file
100
packages/games/src/one-card/cards.ts
Normal 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);
|
||||
1044
packages/games/src/one-card/index.ts
Normal file
1044
packages/games/src/one-card/index.ts
Normal file
File diff suppressed because it is too large
Load Diff
869
packages/games/src/one-card/one-card.test.ts
Normal file
869
packages/games/src/one-card/one-card.test.ts
Normal 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);
|
||||
});
|
||||
|
||||
165
packages/games/src/one-card/rules.ts
Normal file
165
packages/games/src/one-card/rules.ts
Normal 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 '숫자·기호가 같아요';
|
||||
}
|
||||
263
packages/games/src/one-card/types.ts
Normal file
263
packages/games/src/one-card/types.ts
Normal 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;
|
||||
}
|
||||
@@ -20,6 +20,7 @@ export const CATALOG: CatalogEntry[] = [
|
||||
{ id: 'poker', nameKo: '포커', minPlayers: 2, maxPlayers: 10, minutes: '20~60', level: '보통', blurb: '홀덤·7포커·인디언 등 7가지 모드 · 놀이용 칩', available: true },
|
||||
{ id: 'yacht', nameKo: '요트 다이스', minPlayers: 1, maxPlayers: 8, minutes: '10~30', level: '쉬움', blurb: '주사위 다섯 개로 족보 만들기 · 혼자서도 OK', available: true },
|
||||
{ id: 'yut', nameKo: '윷놀이', minPlayers: 2, maxPlayers: 8, minutes: '15~25', level: '쉬움', blurb: '도·개·걸·윷·모! 개인전 또는 팀전', available: true },
|
||||
{ id: 'one-card', nameKo: '원카드', minPlayers: 2, maxPlayers: 10, minutes: '10~15', level: '쉬움', blurb: '같은 무늬·숫자를 내고 공격 카드로 먹이기! · 색깔 카드 모드', available: true },
|
||||
];
|
||||
|
||||
export const catalogById = (id: string): CatalogEntry | undefined => CATALOG.find((g) => g.id === id);
|
||||
|
||||
Reference in New Issue
Block a user