M7: 달무티 규칙·화면·테스트

- 규칙 모듈 packages/games/src/dalmuti: 80장(광대 2장) 덱, 첫 신분 뽑기(동점 재뽑기),
  신분 순서 분배, 혁명 확인 단계(혁명/대혁명), 세금 자동 이동과 돌려주기, 트릭 진행
  (광대 와일드·13, passLocksOut), 라운드 종료와 순위 점수/마지막 라운드 순위,
  시간 초과 자동 행동(리드: 가장 큰 숫자 전부, 따라 내기: 패스, 세금: 가장 큰 숫자),
  연속 3회 시간 초과 시 5초 마감, 나가도 탈락 없이 자동 진행, view 정보 숨김
- 테스트: 문서 10절 시나리오 전부 + 1,000 시드 무작위 대국(카드 보존, 유출 검사,
  onTimeout 합법, 결정성) + 엔진 randomPlayout
- 화면 apps/web/src/games/dalmuti: 자체 카드 그림, 숫자별 묶음 선택·장수 조절·광대 섞기,
  내기/패스, 바닥 기준 표시, 신분 자리, 혁명 확인·세금 교환 화면, 규칙·옵션
- 등록: GAMES, catalog, registry, GAME_ICON
- E2E: PC 방장 + 휴대폰 3명, 1라운드 끝까지 진행 후 2라운드 세금 교환 화면 확인

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
EJClaw
2026-10-05 14:49:44 +09:00
parent 559fb8507c
commit dfe4ce1ca4
9 changed files with 2855 additions and 0 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -19,6 +19,7 @@ export const GAME_UI: Record<string, () => Promise<{ default: GameUI }>> = {
bang: () => import('./bang'), bang: () => import('./bang'),
'halli-galli': () => import('./halli-galli'), 'halli-galli': () => import('./halli-galli'),
marble: () => import('./marble'), marble: () => import('./marble'),
dalmuti: () => import('./dalmuti'),
}; };
const cache = new Map<string, GameUI>(); const cache = new Map<string, GameUI>();

View File

@@ -16,6 +16,7 @@ export const GAME_ICON: Record<string, string> = {
marble: '🏙️', marble: '🏙️',
bang: '🤠', bang: '🤠',
'halli-galli': '🔔', 'halli-galli': '🔔',
dalmuti: '👑',
}; };
/** Shown on the home screen so users know what's coming (docs/12-roadmap.md). */ /** Shown on the home screen so users know what's coming (docs/12-roadmap.md). */

View File

@@ -260,6 +260,71 @@ async function marbleStep(p: Page, stats: Record<string, number>): Promise<strin
return null; return null;
} }
/**
* 달무티: one UI step for `p` — confirm the hand, give tax cards back (suggested cards), or on its turn pick a
* number group (a "광대 필요" group first; leads mix a jester in via the toggle) and press "내기", passing now
* and then. The host uses the keyboard (focus + Enter) for the group. Returns what it did, or null.
*/
async function dalmutiStep(p: Page, isHost: boolean, st: Record<string, number>): Promise<string | null> {
const enabled = async (l: ReturnType<Page['locator']>) => (await l.count()) > 0 && (await l.first().isEnabled().catch(() => false));
const confirm = p.getByRole('button', { name: '손패 확인', exact: true });
if (await enabled(confirm)) {
await confirm.click({ timeout: 1500 });
return 'confirm';
}
const giveBack = p.getByRole('button', { name: /^돌려주기/ });
if (await enabled(giveBack)) {
if (!st[`taxshot-${tag(p)}`] && (await p.getByText('1라운드 결과 → 새 신분').count())) {
st[`taxshot-${tag(p)}`] = 1;
await p.screenshot({ path: join(SHOTS, `dalmuti-tax-${tag(p)}.png`) });
await p.emulateMedia({ colorScheme: 'dark' });
await p.waitForTimeout(400);
await p.screenshot({ path: join(SHOTS, `dalmuti-tax-${tag(p)}-dark.png`) });
await p.emulateMedia({ colorScheme: 'light' });
}
await giveBack.click({ timeout: 1500 });
return 'return';
}
const status = (await p.locator('.turn-status').innerText().catch(() => '')).trim();
if (!status.includes('내 차례예요')) return null;
const pass = p.getByRole('button', { name: '패스', exact: true });
const canPass = await enabled(pass);
const legal = p.locator('button.dm-group[data-legal="true"]');
const n = await legal.count();
if (n === 0 || (canPass && st.turns! % 4 === 3)) {
if (!canPass) return null;
await pass.click({ timeout: 1500 });
st.turns!++;
st.passes = (st.passes ?? 0) + 1;
return 'pass';
}
const needs = p.locator('button.dm-group[data-legal="true"]', { hasText: '광대 필요' });
const lead = status.includes('새로 시작');
const jesterGroup = p.locator('button.dm-group[data-legal="true"][data-n="13"]');
let target = legal.last();
if (await needs.count()) target = needs.last();
else if (lead && (await jesterGroup.count()) && n > 1) target = legal.nth(n - 2);
if (isHost) {
await target.focus();
await p.keyboard.press('Enter');
st.hostKeys = (st.hostKeys ?? 0) + 1;
} else await target.click({ timeout: 1500 });
const toggle = p.locator('.dm-toggle');
if (lead && (await enabled(toggle)) && (await toggle.getAttribute('aria-pressed')) === 'false') await toggle.click({ timeout: 1500 });
const play = p.getByRole('button', { name: /^내기 \(/ });
if (!(await enabled(play))) return null;
const summary = await p.locator('.dm-pick .small').innerText().catch(() => '');
if (!st[`pickshot-${tag(p)}`]) {
st[`pickshot-${tag(p)}`] = 1;
await p.screenshot({ path: join(SHOTS, `dalmuti-pick-${tag(p)}.png`) });
}
if (summary.includes('광대')) st.jesterPlays = (st.jesterPlays ?? 0) + 1;
await play.click({ timeout: 1500 });
st.turns!++;
st.plays = (st.plays ?? 0) + 1;
return 'play';
}
const PLANS: Record<string, Plan> = { const PLANS: Record<string, Plan> = {
rummikub: { rummikub: {
// PC host + phone guest, first meld "없음" so hint plays happen early; 10 real turns through the UI. // PC host + phone guest, first meld "없음" so hint plays happen early; 10 real turns through the UI.
@@ -879,6 +944,70 @@ const PLANS: Record<string, Plan> = {
console.log(` marble: finished after ${actions} UI actions ${JSON.stringify(stats)}`); console.log(` marble: finished after ${actions} UI actions ${JSON.stringify(stats)}`);
}, },
}, },
dalmuti: {
// PC host + 3 phone guests, 9~12 removed (38 cards) for a shorter round: real turns through the UI (sets with
// jesters, passes) until round 1 ends, then the round-2 hand check and the tax exchange (세금 카드 교환) screen.
players: 4,
lobby: async (host) => {
await host.getByRole('button', { name: '▼ 자세한 설정' }).click();
await host.getByRole('group', { name: '9~12 카드 빼기' }).getByRole('button', { name: '켜기' }).click();
await host.getByRole('group', { name: '한 턴 제한 시간' }).getByRole('button', { name: '45초' }).click();
await host.getByRole('button', { name: '설정 저장' }).click();
await host.getByText(/9~12 빼기/).first().waitFor();
},
drive: async (pages) => {
const [host, guest] = pages as [Page, Page];
const st: Record<string, number> = { turns: 0 };
let actions = 0;
let midShot = false;
let round2 = false;
const until = Date.now() + 300_000;
const playingAgain = () => host.locator('.dm-table').count();
while (Date.now() < until) {
let acted = false;
for (const [i, p] of pages.entries()) {
if (await dalmutiStep(p, i === 0, st)) {
acted = true;
actions++;
await p.waitForTimeout(120);
break;
}
}
if (!midShot && (st.plays ?? 0) >= 6 && (await host.locator('.dm-set.top').count())) {
midShot = true;
await host.waitForTimeout(400);
await host.screenshot({ path: join(SHOTS, 'dalmuti-mid-pc.png') });
await guest.screenshot({ path: join(SHOTS, 'dalmuti-mid-phone.png') });
await guest.screenshot({ path: join(SHOTS, 'dalmuti-mid-phone-full.png'), fullPage: true });
for (const p of [host, guest]) await p.emulateMedia({ colorScheme: 'dark' });
await host.waitForTimeout(400);
await host.screenshot({ path: join(SHOTS, 'dalmuti-mid-pc-dark.png') });
await guest.screenshot({ path: join(SHOTS, 'dalmuti-mid-phone-dark.png') });
for (const p of [host, guest]) await p.emulateMedia({ colorScheme: 'light' });
}
if (!round2 && (await host.getByText('1라운드 결과 → 새 신분').count())) {
round2 = true;
await host.waitForTimeout(300);
await host.screenshot({ path: join(SHOTS, 'dalmuti-round2-check-pc.png') });
await guest.screenshot({ path: join(SHOTS, 'dalmuti-round2-check-phone.png'), fullPage: true });
}
if (round2 && st.taxDone === undefined && (await host.getByRole('heading', { name: '세금 카드 교환' }).count())) {
st.taxDone = 0;
await host.waitForTimeout(300);
await host.screenshot({ path: join(SHOTS, 'dalmuti-round2-tax-pc.png') });
await guest.screenshot({ path: join(SHOTS, 'dalmuti-round2-tax-phone.png'), fullPage: true });
}
// Done: round 2's tax exchange happened (or a revolution skipped it) and the table is back, with a jester set seen.
if (round2 && (await playingAgain()) && (st.jesterPlays ?? 0) > 0) break;
if (!acted) await host.waitForTimeout(120);
}
if (!round2) throw new Error(`round 1 did not finish (${actions} UI actions: ${JSON.stringify(st)})`);
if (!(await playingAgain())) throw new Error(`round 2 did not start playing (${JSON.stringify(st)})`);
if (!st.passes || (st.plays ?? 0) < 8 || !st.jesterPlays) throw new Error(`too few real turns / no jester set: ${JSON.stringify(st)}`);
await host.screenshot({ path: join(SHOTS, 'dalmuti-round2-pc.png') });
console.log(` dalmuti: ${actions} UI actions ${JSON.stringify(st)}${st.taxDone === 0 ? ', tax screen shown' : ', revolution (no tax)'}`);
},
},
}; };
const games = process.argv.slice(2).length ? process.argv.slice(2) : Object.keys(PLANS); const games = process.argv.slice(2).length ? process.argv.slice(2) : Object.keys(PLANS);

View File

@@ -0,0 +1,678 @@
import { describe, expect, test } from 'bun:test';
import { SeededRng, randomPlayout, seedN, type PlayerId, type RngState } from '@bg/engine';
import {
FULL_DECK,
allCardIds,
dalmuti,
deckFor,
drawFirstRanks,
finalOf,
type DCard,
type DalmutiAction,
type DalmutiOptions,
type DalmutiState,
type DalmutiView,
} from './index';
// ---------------------------------------------------------------- helpers
const BY_ID = new Map(FULL_DECK.map((c) => [c.id, c]));
const card = (id: string): DCard => {
const c = BY_ID.get(id);
if (!c) throw new Error(`no card ${id}`);
return c;
};
/** k-th copy of number n ("c7-2"). */
const C = (n: number, k = 1) => `c${n}-${k}`;
const J1 = 'j1';
const J2 = 'j2';
const P5 = ['A', 'B', 'C', 'D', 'E'];
const P4 = ['A', 'B', 'C', 'D'];
let clock = 1_000_000;
function make(opts: Partial<DalmutiOptions> = {}, players = P5, seed = 1): DalmutiState {
const options = dalmuti.optionsSchema.parse(opts);
return dalmuti.setup({ players, options, rng: SeededRng.fromSeed(seedN(seed)), now: 0 });
}
/** Seats `order`, gives hands (rest of the deck → discard) and starts a trick (optionally with a top set). */
function rig(
s: DalmutiState,
r: { order?: string[]; hands: Record<string, string[]>; current?: string; top?: { by: string; ids: string[] } },
): DalmutiState {
if (r.order) s.order = [...r.order];
for (const p of s.players) s.hands[p] = (r.hands[p] ?? []).map(card);
const used = new Set([...Object.values(r.hands).flat(), ...(r.top?.ids ?? [])]);
s.discard = deckFor(s.options.removeHighCards)
.filter((c) => !used.has(c.id))
.map((c) => c.id);
s.phase = { kind: 'playing' };
s.finishOrder = [];
s.taxLog = [];
const lead = r.current ?? s.order[0]!;
s.trick = { leader: r.top?.by ?? lead, top: null, passesSinceTop: [], lockedOut: [], pile: [] };
if (r.top) {
const cards = r.top.ids.map(card);
const nums = cards.filter((c) => c.n !== 13);
const top = { cardIds: r.top.ids, count: cards.length, rank: nums.length ? nums[0]!.n : 13, by: r.top.by };
s.trick.top = top;
s.trick.pile = [top];
}
s.current = lead;
return s;
}
const act = (s: DalmutiState, p: string, a: DalmutiAction): DalmutiState => {
const v = dalmuti.validate(s, p, a);
if (!v.ok) throw new Error(`${p} ${JSON.stringify(a)}: ${v.reason}`);
return dalmuti.apply(s, p, a, { rng: SeededRng.fromSeed([3, 3, 3, 3]), now: (clock += 1000) }).state;
};
const play = (s: DalmutiState, p: string, ids: string[]) => act(s, p, { type: 'play', cardIds: ids });
const pass = (s: DalmutiState, p: string) => act(s, p, { type: 'pass' });
const reject = (s: DalmutiState, p: string, a: DalmutiAction): string | null => {
const v = dalmuti.validate(s, p, a);
return v.ok ? null : v.reason;
};
const ids = (s: DalmutiState, p: string) => s.hands[p]!.map((c) => c.id);
const nums = (s: DalmutiState, p: string) => s.hands[p]!.map((c) => c.n);
const confirmAll = (s: DalmutiState, except: Record<string, DalmutiAction> = {}) => {
for (const p of s.players) s = act(s, p, except[p] ?? { type: 'confirmHand' });
return s;
};
/** A revolutionCheck state with given hands (5 players, order A..E unless given). */
function atRevolutionCheck(hands: Record<string, string[]>, opts: Partial<DalmutiOptions> = {}, order = P5): DalmutiState {
const s = rig(make(opts), { order, hands });
s.phase = { kind: 'revolutionCheck', confirmed: Object.fromEntries(s.players.map((p) => [p, false])) };
return s;
}
/** Splits the full deck so that the given cards land where wanted and the rest fill hands round-robin. */
function handsWith(order: string[], fixed: Record<string, string[]>): Record<string, string[]> {
const taken = new Set(Object.values(fixed).flat());
const rest = FULL_DECK.map((c) => c.id).filter((id) => !taken.has(id));
const hands: Record<string, string[]> = Object.fromEntries(order.map((p) => [p, [...(fixed[p] ?? [])]]));
const per = Math.floor(80 / order.length);
for (const p of order) while (hands[p]!.length < per) hands[p]!.push(rest.shift()!);
for (let i = 0; rest.length; i++) hands[order[i % order.length]!]!.push(rest.shift()!);
return hands;
}
// ---------------------------------------------------------------- deck / setup
describe('덱과 셋업 (§3, §4)', () => {
test('80 cards: n copies of n, 2 jesters; removeHighCards → 38', () => {
expect(FULL_DECK).toHaveLength(80);
for (let n = 1; n <= 12; n++) expect(FULL_DECK.filter((c) => c.n === n)).toHaveLength(n);
expect(FULL_DECK.filter((c) => c.n === 13)).toHaveLength(2);
expect(new Set(FULL_DECK.map((c) => c.id)).size).toBe(80);
expect(deckFor(true)).toHaveLength(38);
expect(deckFor(true).some((c) => c.n >= 9 && c.n <= 12)).toBe(false);
});
test('6 players: 14, 14, 13, 13, 13, 13 in rank order; all deals add up', () => {
const s = make({}, ['A', 'B', 'C', 'D', 'E', 'F'], 3);
expect(s.order.map((p) => s.hands[p]!.length)).toEqual([14, 14, 13, 13, 13, 13]);
const sizes: Record<number, number[]> = { 4: [20, 20, 20, 20], 5: [16, 16, 16, 16, 16], 7: [12, 12, 12, 11, 11, 11, 11], 8: [10, 10, 10, 10, 10, 10, 10, 10] };
for (const [n, want] of Object.entries(sizes)) {
const t = make({}, Array.from({ length: Number(n) }, (_, i) => `P${i}`), 4);
expect(t.order.map((p) => t.hands[p]!.length)).toEqual(want);
expect(allCardIds(t).sort()).toEqual(FULL_DECK.map((c) => c.id).sort());
}
const r = make({ removeHighCards: true }, Array.from({ length: 8 }, (_, i) => `P${i}`), 4);
expect(r.order.map((p) => r.hands[p]!.length)).toEqual([5, 5, 5, 5, 5, 5, 4, 4]);
});
test('player range 4..8', () => {
expect(dalmuti.minPlayers).toBe(4);
expect(dalmuti.maxPlayers).toBe(8);
expect(() => make({}, ['A', 'B', 'C'])).toThrow();
expect(() => make({}, 'ABCDEFGHI'.split(''))).toThrow();
});
test('first rank draw: same seed → same result; ties re-draw until the order is fixed (lower first)', () => {
expect(JSON.stringify(make({}, P5, 9))).toBe(JSON.stringify(make({}, P5, 9)));
let sawTie = 0;
for (let seed = 1; seed <= 400; seed++) {
const players = Array.from({ length: 8 }, (_, i) => `P${i}`);
const { order, draws } = drawFirstRanks(players, FULL_DECK as DCard[], SeededRng.fromSeed(seedN(seed)));
expect([...order].sort()).toEqual([...players].sort());
if (Object.values(draws).some((d) => d.length > 1)) sawTie++;
// Lexicographic on the draw sequence: tied players share a prefix and re-drew together.
for (let i = 0; i + 1 < order.length; i++) {
const a = draws[order[i]!]!;
const b = draws[order[i + 1]!]!;
const k = a.findIndex((x, j) => x !== b[j]);
expect(k).toBeGreaterThanOrEqual(0);
expect(a[k]!).toBeLessThan(b[k]!);
}
}
expect(sawTie).toBeGreaterThan(10);
const s = make({}, P5, 9);
expect(s.firstDraw).not.toBeNull();
expect(make({ firstRanking: 'random' }, P5, 9).firstDraw).toBeNull();
});
test('revolution on → everyone confirms first; off → tax right away', () => {
expect(make().phase.kind).toBe('revolutionCheck');
const s = make({ revolution: false });
expect(s.phase.kind).toBe('taxReturn');
const o = s.order as string[];
expect(s.taxLog.map((t) => [t.from, t.to, t.cardIds.length])).toEqual([
[o[4]!, o[0]!, 2],
[o[3]!, o[1]!, 1],
]);
});
});
// ---------------------------------------------------------------- tax
describe('세금 (§4-5)', () => {
const bottomHand = [J1, J2, C(3, 1), C(3, 2), C(7, 1), C(9, 1), C(12, 1)];
const hands = () => handsWith(P5, { E: bottomHand, D: [C(2, 1), C(5, 1), C(11, 1)] });
test('jesterTaxable off: 대농노 [광대, 광대, 3, 3, 7…] gives 3, 3; 소농노 gives its lowest number', () => {
let s = atRevolutionCheck(hands(), { jesterTaxable: false });
const dBefore = s.hands.D!.map((c) => c.n).filter((n) => n !== 13);
s = confirmAll(s);
expect(s.phase.kind).toBe('taxReturn');
expect(s.taxLog[0]).toEqual({ kind: 'tax', from: 'E', to: 'A', cardIds: [C(3, 1), C(3, 2)] });
expect(s.taxLog[1]!.from).toBe('D');
expect(s.taxLog[1]!.to).toBe('B');
expect(card(s.taxLog[1]!.cardIds[0]!).n).toBe(Math.min(...dBefore));
expect(ids(s, 'E')).toContain(J1);
expect(ids(s, 'A')).toEqual(expect.arrayContaining([C(3, 1), C(3, 2)]));
});
test('jesterTaxable on: the two jesters go', () => {
const s = confirmAll(atRevolutionCheck(hands(), { jesterTaxable: true }));
expect(s.taxLog[0]!.cardIds).toEqual([J1, J2]);
});
test('returning: 대달무티 may give back what it just got; wrong count refused; both done → A leads', () => {
let s = confirmAll(atRevolutionCheck(hands()));
const got = s.taxLog[0]!.cardIds;
expect(reject(s, 'A', { type: 'returnTax', cardIds: [got[0]!] })).toBe('돌려줄 카드 2장을 골라 주세요.');
expect(reject(s, 'B', { type: 'returnTax', cardIds: [ids(s, 'B')[0]!, ids(s, 'B')[1]!] })).toBe('돌려줄 카드 1장을 골라 주세요.');
expect(reject(s, 'C', { type: 'returnTax', cardIds: [ids(s, 'C')[0]!] })).toBe('돌려줄 카드가 없어요.');
expect(reject(s, 'A', { type: 'returnTax', cardIds: [got[0]!, ids(s, 'E')[0]!] })).toBe('손에 없는 카드예요.');
s = act(s, 'A', { type: 'returnTax', cardIds: [...got] });
expect(ids(s, 'E')).toEqual(expect.arrayContaining([C(3, 1), C(3, 2)]));
expect(s.phase.kind).toBe('taxReturn');
expect(reject(s, 'A', { type: 'returnTax', cardIds: [...got] })).toBe('이미 카드를 돌려줬어요.');
s = act(s, 'B', { type: 'returnTax', cardIds: [ids(s, 'B')[0]!] });
expect(s.phase.kind).toBe('playing');
expect(s.current).toBe('A');
expect(s.hands.A!.length).toBe(16);
expect(allCardIds(s).sort()).toEqual(FULL_DECK.map((c) => c.id).sort());
});
test('4 players: 소달무티 ↔ 소농노 and 대달무티 ↔ 대농노 only', () => {
const s = confirmAll(atRevolutionCheck(handsWith(P4, {}), {}, P4));
expect(s.taxLog.map((t) => [t.from, t.to, t.cardIds.length])).toEqual([
['D', 'A', 2],
['C', 'B', 1],
]);
});
});
// ---------------------------------------------------------------- revolution
describe('혁명 (§4-4)', () => {
test('a commoner with both jesters declares → no tax, same ranks, 대달무티 leads', () => {
const s0 = atRevolutionCheck(handsWith(P5, { C: [J1, J2] }));
expect(reject(s0, 'A', { type: 'confirmHand', declareRevolution: true })).toBe('광대 2장이 있어야 혁명할 수 있어요.');
const s = confirmAll(s0, { C: { type: 'confirmHand', declareRevolution: true } });
expect(s.revolution).toBe('revolution');
expect(s.revolutionBy).toBe('C');
expect(s.taxLog).toEqual([]);
expect(s.order).toEqual(P5);
expect(s.phase.kind).toBe('playing');
expect(s.current).toBe('A');
});
test('대농노 declares → great revolution: order reversed, the new 대달무티 leads, no tax', () => {
const s = confirmAll(atRevolutionCheck(handsWith(P5, { E: [J1, J2] })), { E: { type: 'confirmHand', declareRevolution: true } });
expect(s.revolution).toBe('great');
expect(s.order).toEqual(['E', 'D', 'C', 'B', 'A']);
expect(s.current).toBe('E');
expect(s.taxLog).toEqual([]);
const v = dalmuti.view(s, 'A');
expect(v.seats[0]!).toMatchObject({ id: 'E', title: '대달무티' });
expect(v.seats[4]!).toMatchObject({ id: 'A', title: '대농노' });
});
test('not declaring is allowed: holder confirms → normal tax', () => {
const s = confirmAll(atRevolutionCheck(handsWith(P5, { B: [J1, J2] })));
expect(s.revolution).toBe('none');
expect(s.phase.kind).toBe('taxReturn');
});
test('no info leak: the check phase looks the same with or without a holder; canDeclareRevolution only in the holder view', () => {
const withHolder = atRevolutionCheck(handsWith(P5, { C: [J1, J2] }));
const without = atRevolutionCheck(handsWith(P5, { C: [J1], D: [J2] }));
for (const s of [withHolder, without]) {
expect(s.phase.kind).toBe('revolutionCheck');
for (const p of ['A', 'B', 'D', 'E']) {
const v = JSON.stringify(dalmuti.view(s, p));
expect(v).not.toContain('canDeclareRevolution');
}
expect(JSON.stringify(dalmuti.view(s, null))).not.toContain('canDeclareRevolution');
expect(dalmuti.deadline!(s)).toBe(s.turnStartedAt + 20_000);
}
expect(dalmuti.view(withHolder, 'C').my!.canDeclareRevolution).toBe(true);
expect(JSON.stringify(dalmuti.view(without, 'C'))).not.toContain('canDeclareRevolution');
// Other views of both states differ only in the hidden hand contents (same shape, same keys).
const keys = (v: DalmutiView) => JSON.stringify({ ...v, my: v.my ? Object.keys(v.my) : null });
expect(keys(dalmuti.view(withHolder, 'A')).replace(/"hand":\[.*?\]/, '')).toBe(keys(dalmuti.view(without, 'A')).replace(/"hand":\[.*?\]/, ''));
});
});
// ---------------------------------------------------------------- playing
describe('트릭 진행 (§5.1)', () => {
test('following 9×3: 8×2 refused (count), 9×3 refused (same number), 4+4+광대 ok, 광대×2+1 ok (1×3)', () => {
const s = rig(make(), {
order: P5,
hands: { A: [C(8, 1), C(8, 2), C(9, 4), C(9, 5), C(9, 6), C(4, 1), C(4, 2), J1, C(1, 1), J2], B: [C(5, 1)], C: [C(6, 1)], D: [C(7, 1)], E: [C(10, 1)] },
top: { by: 'E', ids: [C(9, 1), C(9, 2), C(9, 3)] },
current: 'A',
});
expect(reject(s, 'A', { type: 'play', cardIds: [C(8, 1), C(8, 2)] })).toBe('앞사람과 같은 장수(3장)로 내야 해요.');
expect(reject(s, 'A', { type: 'play', cardIds: [C(9, 4), C(9, 5), C(9, 6)] })).toBe('더 낮은 숫자만 낼 수 있어요.');
expect(reject(s, 'A', { type: 'play', cardIds: [C(4, 1), C(8, 1), J1] })).toBe('같은 숫자끼리만 낼 수 있어요.');
expect(reject(s, 'A', { type: 'play', cardIds: [C(4, 1), C(4, 2), J1] })).toBeNull();
expect(reject(s, 'A', { type: 'play', cardIds: [J1, J2, C(1, 1)] })).toBeNull();
const after = play(s, 'A', [J1, J2, C(1, 1)]);
expect(after.trick.top).toMatchObject({ count: 3, rank: 1, by: 'A' });
// The view offers the same choices.
const ch = dalmuti.view(s, 'A').my!.choices;
expect(ch.find((c) => c.rank === 4)).toEqual({ rank: 4, have: 2, counts: [3], needsJester: true });
expect(ch.find((c) => c.rank === 1)).toEqual({ rank: 1, have: 1, counts: [3], needsJester: true });
expect(ch.find((c) => c.rank === 8)).toEqual({ rank: 8, have: 2, counts: [3], needsJester: true });
expect(ch.some((c) => c.rank === 9 || c.rank === 13)).toBe(false);
});
test('jester alone: on 12×1 refused (13 is worse); as a lead allowed (13×1), then any single number beats it', () => {
let s = rig(make(), { order: P5, hands: { A: [J1, C(12, 2)], B: [C(12, 3), C(5, 1)], C: [C(6, 1)], D: [C(7, 1)], E: [C(10, 1)] }, top: { by: 'E', ids: [C(12, 1)] }, current: 'A' });
expect(reject(s, 'A', { type: 'play', cardIds: [J1] })).toBe('더 낮은 숫자만 낼 수 있어요.');
s = rig(make(), { order: P5, hands: { A: [J1, C(12, 2)], B: [C(12, 3), C(5, 1)], C: [C(6, 1)], D: [C(7, 1)], E: [C(10, 1)] }, current: 'A' });
s = play(s, 'A', [J1]);
expect(s.trick.top).toMatchObject({ count: 1, rank: 13 });
expect(reject(s, 'B', { type: 'play', cardIds: [C(12, 3)] })).toBeNull();
});
test('a lead cannot pass; leads may be any size of one number', () => {
const s = rig(make(), { order: P5, hands: { A: [C(7, 1), C(7, 2), C(7, 3), J1], B: [C(5, 1)], C: [C(6, 1)], D: [C(8, 1)], E: [C(10, 1)] }, current: 'A' });
expect(reject(s, 'A', { type: 'pass' })).toBe('새로 시작할 때는 패스할 수 없어요.');
expect(reject(s, 'A', { type: 'play', cardIds: [C(7, 1), C(7, 2), C(7, 3), J1] })).toBeNull();
expect(dalmuti.view(s, 'A').my!.choices.find((c) => c.rank === 7)!.counts).toEqual([1, 2, 3, 4]);
});
test('§5.3 example: everyone but D passes → D leads the next trick', () => {
let s = rig(make(), {
order: P5,
hands: { A: [C(7, 1), C(7, 2), C(3, 1)], B: [C(5, 1), C(5, 2), C(9, 1)], C: [C(10, 1), C(11, 1)], D: [C(2, 1), J1, C(8, 1)], E: [C(12, 1), C(6, 1)] },
});
s = play(s, 'A', [C(7, 1), C(7, 2)]);
s = play(s, 'B', [C(5, 1), C(5, 2)]);
s = pass(s, 'C');
s = play(s, 'D', [C(2, 1), J1]);
for (const p of ['E', 'A', 'B']) s = pass(s, p);
expect(s.current).toBe('C');
expect(s.trick.top!.by).toBe('D');
s = pass(s, 'C');
expect(s.trick.top).toBeNull();
expect(s.trick.leader).toBe('D');
expect(s.current).toBe('D');
expect(s.discard).toEqual(expect.arrayContaining([C(7, 1), C(5, 1), C(2, 1), J1]));
});
test('going out with the top set → the next player with cards leads', () => {
let s = rig(make(), { order: P5, hands: { A: [C(3, 1)], B: [C(5, 1), C(5, 2)], C: [C(10, 1)], D: [C(8, 1)], E: [C(12, 1), C(12, 2)] }, current: 'E' });
s = play(s, 'E', [C(12, 1)]);
s = play(s, 'A', [C(3, 1)]); // A is out on the top set
expect(s.finishOrder).toEqual(['A']);
for (const p of ['B', 'C', 'D']) s = pass(s, p);
expect(s.current).toBe('E');
s = pass(s, 'E');
expect(s.trick.top).toBeNull();
expect(s.current).toBe('B');
});
test('passLocksOut off: a passer may play when the turn comes back; on: skipped for the rest of the trick', () => {
const hands = { A: [C(10, 1), C(1, 1)], B: [C(9, 1), C(2, 1)], C: [C(8, 1), C(12, 1)], D: [C(12, 2), C(12, 3)], E: [C(12, 4), C(12, 5)] };
const run = (passLocksOut: boolean) => {
let s = rig(make({ passLocksOut }), { order: P5, hands });
s = play(s, 'A', [C(10, 1)]);
s = pass(s, 'B');
s = play(s, 'C', [C(8, 1)]);
for (const p of ['D', 'E', 'A']) s = pass(s, p);
return s;
};
const off = run(false);
expect(off.current).toBe('B');
expect(reject(off, 'B', { type: 'play', cardIds: [C(2, 1)] })).toBeNull();
const on = run(true);
expect(on.trick.top).toBeNull();
expect(on.current).toBe('C');
// Mid-trick, a locked-out player is skipped in the turn order.
let m = rig(make({ passLocksOut: true }), { order: P5, hands });
m = play(m, 'A', [C(10, 1)]);
m = pass(m, 'B');
m = play(m, 'C', [C(8, 1)]);
m = pass(m, 'D');
m = pass(m, 'E');
m = play(m, 'A', [C(1, 1)]);
expect(m.current).toBe('C');
expect(dalmuti.view(m, null).seats.find((x) => x.id === 'B')!.lockedOut).toBe(true);
});
});
// ---------------------------------------------------------------- rounds / scoring
describe('라운드 종료와 점수 (§5.2, §6)', () => {
test('one player left with cards → round over; that player is the next 대농노; next order = finish order', () => {
let s = rig(make({ rounds: 3 }), { order: P5, hands: { A: [C(3, 1)], B: [C(4, 1)], C: [C(5, 1)], D: [C(6, 1)], E: [C(12, 1), C(12, 2)] }, current: 'D' });
s = play(s, 'D', [C(6, 1)]);
s = pass(s, 'E');
s = play(s, 'A', [C(3, 1)]); // A out (1st)
s = pass(s, 'B'); // B: 4 > 3
s = pass(s, 'C');
// D is out already; E passes → trick over, A is out → next with cards after A = B leads.
s = pass(s, 'E');
expect(s.current).toBe('B');
s = play(s, 'B', [C(4, 1)]); // B out (3rd? no: D 1st, A 2nd, B 3rd)
expect(s.finishOrder).toEqual(['D', 'A', 'B']);
s = pass(s, 'C');
s = pass(s, 'E');
expect(s.current).toBe('C');
s = play(s, 'C', [C(5, 1)]); // C out → only E left → round over
expect(s.round).toBe(2);
expect(s.roundResults[0]!.order).toEqual(['D', 'A', 'B', 'C', 'E']);
expect(s.roundResults[0]!.points).toEqual({ D: 4, A: 3, B: 2, C: 1, E: 0 });
expect(s.order).toEqual(['D', 'A', 'B', 'C', 'E']);
expect(s.phase.kind).toBe('revolutionCheck');
expect(s.order.map((p) => s.hands[p]!.length)).toEqual([16, 16, 16, 16, 16]);
expect(dalmuti.view(s, 'E').seats.find((x) => x.id === 'E')!.title).toBe('대농노');
expect(allCardIds(s).sort()).toEqual(FULL_DECK.map((c) => c.id).sort());
});
test('5 players, 3 rounds: rank points add up; ties go to the better last-round rank; last-round mode', () => {
const rr = (round: number, order: string[]) => ({ round, order, revolution: 'none' as const, points: Object.fromEntries(order.map((p, i) => [p, 4 - i])) });
const roundResults = [rr(1, ['A', 'B', 'C', 'D', 'E']), rr(2, ['C', 'A', 'B', 'E', 'D']), rr(3, ['B', 'C', 'E', 'A', 'D'])];
const totals: Record<string, number> = {};
for (const r of roundResults) for (const [p, pt] of Object.entries(r.points)) totals[p] = (totals[p] ?? 0) + pt;
expect(totals).toEqual({ A: 8, B: 9, C: 9, D: 1, E: 3 });
const base = { roundResults, totals, players: P5, options: dalmuti.optionsSchema.parse({}) };
const f = finalOf(base);
// B and C tie on 9 → B was 1st in the last round, C 2nd.
expect(f.ranking).toEqual([['B'], ['C'], ['A'], ['E'], ['D']]);
expect(f.players.find((p) => p.player === 'A')!.roundRanks).toEqual([1, 2, 4]);
expect(f.summary).toContain('같은 점수는 마지막 라운드 순위');
expect(f.summary).toContain('3라운드');
const last = finalOf({ ...base, options: dalmuti.optionsSchema.parse({ scoring: 'last-round' }) });
expect(last.ranking).toEqual([['B'], ['C'], ['E'], ['A'], ['D']]);
});
test('the game ends after `rounds` rounds with a result', () => {
let s = rig(make({ rounds: 1 }), { order: P4.concat('E'), hands: { A: [C(3, 1)], B: [C(4, 1)], C: [C(5, 1)], D: [C(6, 1)], E: [C(7, 1)] }, current: 'E' });
s = play(s, 'E', [C(7, 1)]);
s = play(s, 'A', [C(3, 1)]);
for (const p of ['B', 'C', 'D']) s = pass(s, p);
s = play(s, 'B', [C(4, 1)]);
s = pass(s, 'C');
s = pass(s, 'D');
s = play(s, 'C', [C(5, 1)]);
expect(s.phase.kind).toBe('finished');
const res = dalmuti.result(s)!;
expect(res.ranking).toEqual([['E'], ['A'], ['B'], ['C'], ['D']]);
expect(res.scores).toEqual({ E: 4, A: 3, B: 2, C: 1, D: 0 });
expect(reject(s, 'D', { type: 'pass' })).toBe('게임이 이미 끝났어요.');
});
});
// ---------------------------------------------------------------- timeouts / leaving
describe('시간 초과와 나가기 (§8.5)', () => {
test('lead timeout plays every card of the highest number; follow timeout passes; jesters only → all jesters', () => {
let s = rig(make(), { order: P5, hands: { A: [C(2, 1), C(11, 1), C(11, 2), J1], B: [C(5, 1)], C: [C(6, 1)], D: [C(7, 1)], E: [C(10, 1)] } });
expect(dalmuti.onTimeout(s, 'A')).toEqual({ type: 'timeout' });
s = act(s, 'A', { type: 'timeout' });
expect(s.trick.top).toMatchObject({ by: 'A', count: 2, rank: 11 });
expect(ids(s, 'A')).toEqual([C(2, 1), J1]);
s = act(s, 'B', { type: 'timeout' });
expect(s.trick.passesSinceTop).toEqual(['B']);
const j = act(rig(make(), { order: P5, hands: { A: [J1, J2], B: [C(5, 1)], C: [C(6, 1)], D: [C(7, 1)], E: [C(10, 1)] } }), 'A', { type: 'timeout' });
expect(j.trick.top).toMatchObject({ count: 2, rank: 13 });
});
test('tax return timeout gives back the highest number cards; revolutionCheck timeout never declares', () => {
let s = atRevolutionCheck(handsWith(P5, { A: [C(12, 1), C(12, 2), J1, J2] }));
for (const p of P5) s = act(s, p, { type: 'timeout' });
expect(s.revolution).toBe('none');
expect(s.phase.kind).toBe('taxReturn');
const hiA = Math.max(...nums(s, 'A').filter((n) => n !== 13));
s = act(s, 'A', { type: 'timeout' });
const back = s.taxLog.find((t) => t.kind === 'return' && t.from === 'A')!;
expect(back.cardIds.map((id) => card(id).n)).toEqual([hiA, hiA]);
expect(back.cardIds).not.toContain(J1);
});
test('3 timeouts in a row → away, deadline 5 s; acting again clears it', () => {
let s = rig(make(), { order: P5, hands: { A: [C(1, 1), C(9, 1), C(9, 2), C(9, 3)], B: [C(12, 1), C(12, 2)], C: [C(12, 3)], D: [C(12, 4)], E: [C(12, 5)] } });
s.timeoutsInRow.B = 2;
s = play(s, 'A', [C(9, 1)]);
expect(dalmuti.deadline!(s)).toBe(s.turnStartedAt + 20_000);
s = act(s, 'B', { type: 'timeout' });
expect(s.away.B).toBe(true);
for (const p of ['C', 'D', 'E']) s = pass(s, p);
s = play(s, 'A', [C(9, 2)]);
expect(s.current).toBe('B');
expect(dalmuti.deadline!(s)).toBe(s.turnStartedAt + 5000);
expect(dalmuti.timeoutNotBefore!(s)).toBe(s.turnStartedAt + 1200);
s = pass(s, 'B');
expect(s.away.B).toBe(false);
});
test('leaving does not eliminate: the seat stays, marked away, and keeps being auto-played', () => {
let s = rig(make(), { order: P5, hands: { A: [C(9, 1)], B: [C(12, 1), C(5, 1)], C: [C(12, 3)], D: [C(12, 4)], E: [C(12, 5)] } });
expect(dalmuti.onLeave!(s, 'B')).toEqual({ type: 'leave' });
s = act(s, 'B', { type: 'leave' });
expect(s.left.B).toBe(true);
expect(s.away.B).toBe(true);
expect(s.order).toContain('B');
expect(dalmuti.onLeave!(s, 'B')).toBeNull();
expect(dalmuti.view(s, null).seats.find((x) => x.id === 'B')!.left).toBe(true);
});
});
// ---------------------------------------------------------------- validation / leaks / options
describe('검증과 정보 유출 (§8.3, §10)', () => {
test('not my turn / card not in hand / resent packet are refused and change nothing', () => {
let s = rig(make(), { order: P5, hands: { A: [C(9, 1), C(9, 2)], B: [C(5, 1)], C: [C(6, 1)], D: [C(7, 1)], E: [C(10, 1)] } });
const snap = JSON.stringify(s);
expect(reject(s, 'B', { type: 'play', cardIds: [C(5, 1)] })).toBe('지금은 내 차례가 아니에요.');
expect(reject(s, 'A', { type: 'play', cardIds: [C(5, 1)] })).toBe('손에 없는 카드예요.');
expect(reject(s, 'A', { type: 'play', cardIds: [C(9, 1), C(9, 1)] })).toBe('같은 카드를 두 번 고를 수 없어요.');
expect(reject(s, 'Z', { type: 'pass' })).toBe('이 게임의 플레이어가 아니에요.');
expect(reject(s, 'B', { type: 'timeout' })).toBe('시간 초과 대상이 아니에요.');
expect(JSON.stringify(s)).toBe(snap);
s = play(s, 'A', [C(9, 1)]);
const snap2 = JSON.stringify(s);
expect(reject(s, 'A', { type: 'play', cardIds: [C(9, 1)] })).toBe('지금은 내 차례가 아니에요.');
expect(JSON.stringify(s)).toBe(snap2);
});
test('spectators and other players see counts only; tax card contents only for the two involved', () => {
const s = confirmAll(atRevolutionCheck(handsWith(P5, { E: [J1, J2, C(3, 1), C(3, 2)] })));
const taxIds = s.taxLog.flatMap((t) => t.cardIds);
for (const viewer of [null, 'C']) {
const v = dalmuti.view(s, viewer);
const json = JSON.stringify(v);
for (const p of P5) if (p !== viewer) for (const id of ids(s, p)) expect(json).not.toContain(`"${id}"`);
for (const id of taxIds) expect(json).not.toContain(`"${id}"`);
expect(v.tax.every((t) => t.cards === null)).toBe(true);
expect(v.tax.map((t) => t.count)).toEqual([2, 1]);
}
const ve = dalmuti.view(s, 'E');
expect(ve.tax[0]!.cards).toEqual([3, 3]);
expect(ve.tax[1]!.cards).toBeNull();
const va = dalmuti.view(s, 'A');
expect(va.tax[0]!.cards).toEqual([3, 3]);
expect(va.my!.receivedIds).toEqual([C(3, 1), C(3, 2)]);
expect(va.my!.owe).toEqual({ to: 'E', count: 2 });
expect(va.my!.suggestedReturn.map((id) => card(id).n)).toEqual(worst2(s, 'A'));
// After A gives the 3s back, A's view must not keep their ids.
const t = act(s, 'A', { type: 'returnTax', cardIds: [C(3, 1), C(3, 2)] });
expect(JSON.stringify(dalmuti.view(t, 'A'))).not.toContain(`"${C(3, 1)}"`);
expect(dalmuti.view(t, 'E').tax[2]!.cards).toEqual([3, 3]);
});
test('options: defaults, Korean metadata on every option', () => {
const o = dalmuti.defaultOptions;
expect(o).toEqual({ rounds: 3, scoring: 'rank-points', firstRanking: 'draw', revolution: true, jesterTaxable: false, passLocksOut: false, removeHighCards: false, turnSeconds: 20 });
const shape = (dalmuti.optionsSchema as unknown as { shape: Record<string, { meta(): { title?: string } | undefined }> }).shape;
for (const [k, f] of Object.entries(shape)) {
expect(f.meta()?.title, k).toMatch(/[가-힣]/);
}
});
test('client actions only (system actions are not in the schema)', () => {
expect(dalmuti.actionSchema.safeParse({ type: 'timeout' }).success).toBe(false);
expect(dalmuti.actionSchema.safeParse({ type: 'leave' }).success).toBe(false);
expect(dalmuti.actionSchema.safeParse({ type: 'pass' }).success).toBe(true);
expect(dalmuti.actionSchema.safeParse({ type: 'play', cardIds: [] }).success).toBe(false);
});
});
function worst2(s: DalmutiState, p: string): number[] {
const n = nums(s, p).filter((x) => x !== 13).sort((a, b) => b - a);
return n.slice(0, 2);
}
// ---------------------------------------------------------------- random playouts
function invariants(s: DalmutiState): void {
const all = allCardIds(s);
const deck = deckFor(s.options.removeHighCards);
if (all.length !== deck.length || new Set(all).size !== deck.length) throw new Error(`card count ${all.length} (unique ${new Set(all).size}) ≠ ${deck.length}`);
if (s.phase.kind === 'playing' && s.hands[s.current]!.length === 0) throw new Error('current player has no cards');
if (s.phase.kind !== 'finished' && s.order.length !== s.players.length) throw new Error('order lost a player');
}
function checkLeaks(s: DalmutiState, players: PlayerId[], viewers: (PlayerId | null)[] = [null, ...players]): void {
for (const viewer of viewers) {
const v = dalmuti.view(s, viewer);
const json = JSON.stringify(v);
for (const p of players) {
if (p === viewer) continue;
for (const c of s.hands[p]!) if (json.includes(`"${c.id}"`)) throw new Error(`view for ${viewer} leaks ${c.id} of ${p}`);
}
for (const t of v.tax) if (t.cards && viewer !== t.from && viewer !== t.to) throw new Error(`tax contents leak to ${viewer}`);
const holder = viewer !== null && s.phase.kind === 'revolutionCheck' && s.hands[viewer]!.filter((c) => c.n === 13).length === 2;
if (!holder && json.includes('canDeclareRevolution')) throw new Error(`canDeclareRevolution in the view for ${viewer}`);
}
}
interface FastReport {
steps: number;
finished: boolean;
trace: string;
final: DalmutiState;
}
/** Same chooser and selection as `randomPlayout`, with lighter per-step checks so 1,000 games stay fast. */
function fastPlayout(players: PlayerId[], options: DalmutiOptions, 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 = dalmuti.setup({ players, options, rng: r, now });
const trace: string[] = [];
for (let step = 0; step < maxSteps; step++) {
invariants(s);
if (step % 15 === 0) checkLeaks(s, players, [null, players[(step / 15) % players.length]!]);
if (step % 60 === 0 && JSON.stringify(JSON.parse(JSON.stringify(s))) !== JSON.stringify(s)) throw new Error('not JSON round-trippable');
for (const p of dalmuti.timeoutPlayers!(s)) {
const v = dalmuti.validate(s, p, dalmuti.onTimeout(s, p));
if (!v.ok) throw new Error(`onTimeout rejected for ${p}: ${v.reason}`);
}
if (dalmuti.result(s)) {
checkLeaks(s, players);
return { steps: step, finished: true, trace: trace.join(';'), final: s };
}
const actives = dalmuti.activePlayers(s);
if (!actives.length) throw new Error('no active players');
const actor = actives[chooser.int(actives.length)]!;
// Some turns go through the timer path, like real away players.
const legal = chooser.int(12) === 0 ? [] : dalmuti.legalActions!(s, actor);
const action = legal.length > 0 ? legal[chooser.int(legal.length)]! : dalmuti.onTimeout(s, actor);
const v = dalmuti.validate(s, actor, action);
if (!v.ok) throw new Error(`generated action rejected: ${v.reason} ${JSON.stringify(action)}`);
now += 1000;
const before = step % 25 === 0 ? JSON.stringify(s) : null;
const out = dalmuti.apply(s, actor, action, { rng: r, now });
if (before !== null && JSON.stringify(s) !== before) throw new Error('apply mutated the previous state');
if (JSON.stringify(out.events).match(/"c\d+-\d+"|"j[12]"/)) throw new Error('event carries a card id');
s = out.state;
trace.push(`${actor}:${JSON.stringify(action)}`);
}
return { steps: maxSteps, finished: false, trace: trace.join(';'), final: s };
}
const PRESETS: Partial<DalmutiOptions>[] = [
{ rounds: 1 },
{ rounds: 3 },
{ rounds: 1, passLocksOut: true, jesterTaxable: true },
{ rounds: 3, revolution: false, firstRanking: 'random', scoring: 'last-round' },
{ rounds: 1, removeHighCards: true },
{ rounds: 5, removeHighCards: true, passLocksOut: true },
];
describe('random playouts (1,000 seeds)', () => {
test('1,000 seeded games finish, cards conserved, no leaks, onTimeout always legal, deterministic', () => {
let finished = 0;
let revolutions = 0;
let great = 0;
for (let i = 1; i <= 1000; i++) {
const n = 4 + (i % 5);
const players = Array.from({ length: n }, (_, k) => `P${k}`);
const options = dalmuti.optionsSchema.parse(PRESETS[i % PRESETS.length]!);
const a = fastPlayout(players, options, seedN(i), 20_000);
if (!a.finished) throw new Error(`seed ${i} (${n} players) did not finish in 20,000 steps`);
finished++;
const res = dalmuti.result(a.final)!;
expect(res.ranking.flat().sort()).toEqual([...players].sort());
expect(a.final.roundResults).toHaveLength(options.rounds);
revolutions += a.final.roundResults.filter((x) => x.revolution === 'revolution').length;
great += a.final.roundResults.filter((x) => x.revolution === 'great').length;
if (i % 50 === 0) {
const b = fastPlayout(players, options, seedN(i), 20_000);
expect(b.trace).toBe(a.trace);
expect(JSON.stringify(b.final)).toBe(JSON.stringify(a.final));
}
}
expect(finished).toBe(1000);
expect(revolutions).toBeGreaterThan(0);
expect(great).toBeGreaterThan(0);
}, 60_000);
test.each([
[4, {}],
[5, { rounds: 1, passLocksOut: true }],
[8, { rounds: 1, revolution: false, removeHighCards: true }],
] as const)('randomPlayout with the engine’s per-step leak / round-trip checks: %i players', (n, opts) => {
const players = Array.from({ length: n }, (_, k) => `P${k}`);
const options = dalmuti.optionsSchema.parse(opts);
const conserve = (st: unknown) => invariants(st as DalmutiState);
for (let i = 1; i <= 2; i++) {
const a = randomPlayout(dalmuti, { players, options, seed: seedN(2000 + i), maxSteps: 20_000, invariant: conserve, maxApplyMs: 30 });
expect(a.finished).toBe(true);
if (i === 1) {
const b = randomPlayout(dalmuti, { players, options, seed: seedN(2000 + i), maxSteps: 20_000, maxApplyMs: 30 });
expect(JSON.stringify(b.trace)).toBe(JSON.stringify(a.trace));
}
}
}, 60_000);
});

View File

@@ -0,0 +1,749 @@
/** 달무티 (docs/games/dalmuti.md). Pure and deterministic; state is JSON. */
import { fail, ok, type GameDefinition, type GameEvent, type GameResult, type PlayerId, type Rng, type Validation } from '@bg/engine';
import {
JESTER,
cardOrder,
dalmutiActionSchema,
dalmutiOptionsSchema,
deckFor,
type Choice,
type DCard,
type DalmutiAction,
type DalmutiFinal,
type DalmutiOptions,
type DalmutiState,
type DalmutiView,
type LogEntry,
type Play,
type PlayAction,
type PlayView,
type Title,
} from './types';
export * from './types';
const REVOLUTION_MS = 20_000;
const TAX_MS = 30_000;
const AWAY_MS = 5000;
const AWAY_AFTER = 3;
/** Auto moves for away / departed players wait this long so others can follow the table. */
const MIN_TURN_MS = 1200;
const LOG_MAX = 40;
const LOG_VIEW = 20;
const MAX_REDRAWS = 50;
type Def = GameDefinition<DalmutiState, DalmutiAction, DalmutiView, DalmutiOptions>;
interface Ctx {
rng: Rng;
now: number;
events: GameEvent[];
}
// ---------------------------------------------------------------- helpers
/** Copy for `apply`: containers are copied; cards, plays, log entries and round results are never mutated. */
function cloneState(s: DalmutiState): DalmutiState {
const ph = s.phase;
const phase: DalmutiState['phase'] =
ph.kind === 'revolutionCheck'
? { kind: ph.kind, confirmed: { ...ph.confirmed } }
: ph.kind === 'taxReturn'
? { kind: ph.kind, owed: Object.fromEntries(Object.entries(ph.owed).map(([p, o]) => [p, { ...o }])) }
: { ...ph };
return {
...s,
order: [...s.order],
hands: Object.fromEntries(Object.entries(s.hands).map(([p, h]) => [p, [...h]])),
discard: [...s.discard],
phase,
taxLog: [...s.taxLog],
trick: { ...s.trick, passesSinceTop: [...s.trick.passesSinceTop], lockedOut: [...s.trick.lockedOut], pile: [...s.trick.pile] },
finishOrder: [...s.finishOrder],
roundResults: [...s.roundResults],
totals: { ...s.totals },
timeoutsInRow: { ...s.timeoutsInRow },
away: { ...s.away },
left: { ...s.left },
log: [...s.log],
};
}
export const titleOf = (rank: number, n: number): Title =>
rank === 0 ? '대달무티' : rank === 1 ? '소달무티' : rank === n - 1 ? '대농노' : rank === n - 2 ? '소농노' : '평민';
const byCard = (a: DCard, b: DCard) => cardOrder(a.id) - cardOrder(b.id);
export const sortHand = (h: DCard[]): DCard[] => [...h].sort(byCard);
const isJ = (c: DCard) => c.n === JESTER;
const withCards = (s: DalmutiState): PlayerId[] => s.order.filter((p) => s.hands[p]!.length > 0);
export const deckSize = (o: Pick<DalmutiOptions, 'removeHighCards'>): number => deckFor(o.removeHighCards).length;
/** Every card id of the deck, exactly once, across hands, the current trick and the discard (test invariant). */
export const allCardIds = (s: DalmutiState): string[] => [
...s.players.flatMap((p) => s.hands[p]!.map((c) => c.id)),
...s.trick.pile.flatMap((pl) => pl.cardIds),
...s.discard,
];
function log(s: DalmutiState, e: Record<string, unknown> & { t: LogEntry['t'] }): void {
s.log.push({ seq: s.seq, ...e } as LogEntry);
if (s.log.length > LOG_MAX) s.log.splice(0, s.log.length - LOG_MAX);
}
/** Rank of a set: the number of its number cards, 13 for jesters alone; null if numbers differ. */
export function setRank(cards: DCard[]): number | null {
const nums = cards.filter((c) => !isJ(c));
if (nums.length === 0) return JESTER;
return nums.every((c) => c.n === nums[0]!.n) ? nums[0]!.n : null;
}
/** Next player after `from` in turn order who still has cards and is not locked out. */
function nextActor(s: DalmutiState, from: PlayerId): PlayerId {
const n = s.order.length;
const i0 = s.order.indexOf(from);
for (let k = 1; k <= n; k++) {
const p = s.order[(i0 + k) % n]!;
if (s.hands[p]!.length > 0 && !s.trick.lockedOut.includes(p)) return p;
}
return from;
}
/** Next player after `from` (exclusive) who still has cards. */
function nextWithCards(s: DalmutiState, from: PlayerId): PlayerId {
const n = s.order.length;
const i0 = s.order.indexOf(from);
for (let k = 1; k <= n; k++) {
const p = s.order[(i0 + k) % n]!;
if (s.hands[p]!.length > 0) return p;
}
return from;
}
function newTurn(s: DalmutiState, p: PlayerId): void {
s.current = p;
s.turnNo += 1;
}
function startTrick(s: DalmutiState, leader: PlayerId, ctx: Ctx): void {
for (const pl of s.trick.pile) s.discard.push(...pl.cardIds);
s.trick = { leader, top: null, passesSinceTop: [], lockedOut: [], pile: [] };
ctx.events.push({ type: 'trickStart', leader });
newTurn(s, leader);
}
// ---------------------------------------------------------------- setup / rounds
/** §4-1: everyone draws one card; ties re-draw among themselves (fresh shuffle each pass). */
export function drawFirstRanks(players: PlayerId[], deck: DCard[], rng: Rng): { order: PlayerId[]; draws: Record<PlayerId, number[]> } {
const draws: Record<PlayerId, number[]> = Object.fromEntries(players.map((p) => [p, [] as number[]]));
let passes = 0;
const resolve = (group: PlayerId[]): PlayerId[] => {
if (group.length <= 1) return group;
if (passes++ >= MAX_REDRAWS) return group;
const shuffled = rng.shuffle(deck);
group.forEach((p, i) => draws[p]!.push(shuffled[i]!.n));
const drawn = new Map(group.map((p, i) => [p, shuffled[i]!.n]));
const nums = [...new Set(drawn.values())].sort((a, b) => a - b);
// Buckets first: re-draws inside one bucket must not change another bucket's membership.
const buckets = nums.map((n) => group.filter((p) => drawn.get(p) === n));
return buckets.flatMap((b) => resolve(b));
};
return { order: resolve([...players]), draws };
}
/** §4-3: shuffle and deal one at a time from the top rank; earlier ranks get the extra cards. */
function deal(s: DalmutiState, ctx: Ctx): void {
const deck = ctx.rng.shuffle(deckFor(s.options.removeHighCards));
s.hands = Object.fromEntries(s.players.map((p) => [p, [] as DCard[]]));
deck.forEach((c, i) => s.hands[s.order[i % s.order.length]!]!.push(c));
for (const p of s.players) s.hands[p] = sortHand(s.hands[p]!);
s.discard = [];
s.taxLog = [];
s.finishOrder = [];
s.revolution = 'none';
s.revolutionBy = null;
s.trick = { leader: s.order[0]!, top: null, passesSinceTop: [], lockedOut: [], pile: [] };
s.current = s.order[0]!;
ctx.events.push({ type: 'dealt', round: s.round, order: [...s.order], counts: s.order.map((p) => s.hands[p]!.length) });
if (s.options.revolution) {
s.phase = { kind: 'revolutionCheck', confirmed: Object.fromEntries(s.players.map((p) => [p, false])) };
s.turnNo += 1;
} else collectTax(s, ctx);
}
/** Best cards for tax (§4-5): lowest numbers by card order; jesters first only with `jesterTaxable`. */
export function taxCards(hand: DCard[], count: number, jesterTaxable: boolean): DCard[] {
const sorted = sortHand(hand);
const pool = jesterTaxable ? [...sorted.filter(isJ), ...sorted.filter((c) => !isJ(c))] : [...sorted.filter((c) => !isJ(c)), ...sorted.filter(isJ)];
return pool.slice(0, count);
}
/** Default return / timeout lead: highest number cards first, jesters last. */
export function worstCards(hand: DCard[], count: number): DCard[] {
const sorted = sortHand(hand);
return [...sorted.filter((c) => !isJ(c)).reverse(), ...sorted.filter(isJ)].slice(0, count);
}
function moveCards(s: DalmutiState, from: PlayerId, to: PlayerId, ids: string[]): void {
const moving = s.hands[from]!.filter((c) => ids.includes(c.id));
s.hands[from] = s.hands[from]!.filter((c) => !ids.includes(c.id));
s.hands[to] = sortHand([...s.hands[to]!, ...moving]);
}
function collectTax(s: DalmutiState, ctx: Ctx): void {
const o = s.order;
const n = o.length;
const pairs: [PlayerId, PlayerId, number][] = [
[o[n - 1]!, o[0]!, 2],
[o[n - 2]!, o[1]!, 1],
];
const owed: Extract<DalmutiState['phase'], { kind: 'taxReturn' }>['owed'] = {};
for (const [from, to, count] of pairs) {
const ids = taxCards(s.hands[from]!, count, s.options.jesterTaxable).map((c) => c.id);
moveCards(s, from, to, ids);
s.taxLog.push({ kind: 'tax', from, to, cardIds: ids });
owed[to] = { to: from, count, done: false };
ctx.events.push({ type: 'tax', from, to, count });
log(s, { t: 'tax', from, to, n: count });
}
s.phase = { kind: 'taxReturn', owed };
s.turnNo += 1;
}
function startPlaying(s: DalmutiState, ctx: Ctx): void {
s.phase = { kind: 'playing' };
s.trick = { leader: s.order[0]!, top: null, passesSinceTop: [], lockedOut: [], pile: [] };
ctx.events.push({ type: 'trickStart', leader: s.order[0]! });
newTurn(s, s.order[0]!);
}
function endRevolutionCheck(s: DalmutiState, ctx: Ctx): void {
const by = s.revolutionBy;
if (by) {
const great = by === s.order[s.order.length - 1];
s.revolution = great ? 'great' : 'revolution';
if (great) s.order = [...s.order].reverse();
ctx.events.push({ type: 'revolution', player: by, kind: s.revolution, order: [...s.order] });
log(s, { t: 'revolution', p: by, kind: s.revolution });
startPlaying(s, ctx);
return;
}
collectTax(s, ctx);
}
function endRound(s: DalmutiState, ctx: Ctx): void {
const last = withCards(s);
const order = [...s.finishOrder, ...last];
for (const p of last) {
s.discard.push(...s.hands[p]!.map((c) => c.id));
s.hands[p] = [];
}
for (const pl of s.trick.pile) s.discard.push(...pl.cardIds);
s.trick = { ...s.trick, top: null, passesSinceTop: [], lockedOut: [], pile: [] };
const n = order.length;
const points = Object.fromEntries(order.map((p, i) => [p, n - 1 - i]));
for (const p of order) s.totals[p] = (s.totals[p] ?? 0) + points[p]!;
s.roundResults.push({ round: s.round, order, revolution: s.revolution, points });
ctx.events.push({ type: 'roundEnd', round: s.round, order });
log(s, { t: 'round', round: s.round });
if (s.round >= s.options.rounds) {
finish(s, ctx);
return;
}
s.round += 1;
s.order = order;
deal(s, ctx);
}
/** Final ranking (§6): total points (ties → better last-round rank) or the last round's order. */
export function finalOf(s: Pick<DalmutiState, 'roundResults' | 'totals' | 'players' | 'options'>): DalmutiFinal {
const last = s.roundResults[s.roundResults.length - 1]!;
const lastRank = (p: PlayerId) => last.order.indexOf(p);
const sorted =
s.options.scoring === 'last-round' ? [...last.order] : [...s.players].sort((a, b) => s.totals[b]! - s.totals[a]! || lastRank(a) - lastRank(b));
const players = sorted.map((p, i) => ({
player: p,
rank: i + 1,
total: s.totals[p] ?? 0,
roundRanks: s.roundResults.map((r) => r.order.indexOf(p) + 1),
}));
const rounds = s.roundResults.length;
let summary: string;
if (s.options.scoring === 'last-round') {
summary = `${rounds}라운드를 마쳤어요. 마지막 라운드에서 가장 먼저 손패를 다 낸 사람이 1등이에요!`;
} else {
const top = players[0]!;
const tied = players.filter((x) => x.total === top.total).length > 1;
summary =
rounds === 1
? '가장 먼저 손패를 다 낸 사람이 1등이에요!'
: `${rounds}라운드 동안 순위 점수 ${top.total}점을 모아 가장 높은 신분을 지켰어요!${tied ? ' (같은 점수는 마지막 라운드 순위로 정했어요)' : ''}`;
}
return { ranking: sorted.map((p) => [p]), players, summary };
}
function finish(s: DalmutiState, ctx: Ctx): void {
s.final = finalOf(s);
s.phase = { kind: 'finished' };
ctx.events.push({ type: 'gameEnd', ranking: s.final.ranking });
}
// ---------------------------------------------------------------- playing
function playCards(s: DalmutiState, p: PlayerId, ids: string[], ctx: Ctx): void {
const hand = s.hands[p]!;
const cards = ids.map((id) => hand.find((c) => c.id === id)!);
const rank = setRank(cards)!;
const play: Play & { by: PlayerId } = { cardIds: [...ids], count: ids.length, rank, by: p };
s.hands[p] = hand.filter((c) => !ids.includes(c.id));
s.trick.top = play;
s.trick.pile.push(play);
s.trick.passesSinceTop = [];
const faces = sortHand(cards).map((c) => c.n);
ctx.events.push({ type: 'played', player: p, cards: faces, count: play.count, rank });
log(s, { t: 'play', p, cards: faces, count: play.count, rank });
if (s.hands[p]!.length === 0) {
s.finishOrder.push(p);
ctx.events.push({ type: 'playerOut', player: p, place: s.finishOrder.length });
log(s, { t: 'out', p, place: s.finishOrder.length });
}
if (withCards(s).length <= 1) {
endRound(s, ctx);
return;
}
afterMove(s, p, ctx);
}
function pass(s: DalmutiState, p: PlayerId, ctx: Ctx): void {
s.trick.passesSinceTop.push(p);
if (s.options.passLocksOut && !s.trick.lockedOut.includes(p)) s.trick.lockedOut.push(p);
ctx.events.push({ type: 'passed', player: p });
log(s, { t: 'pass', p });
afterMove(s, p, ctx);
}
/** §8.2: trick ends when everyone with cards except the top player has passed (or is locked out). */
function afterMove(s: DalmutiState, p: PlayerId, ctx: Ctx): void {
const top = s.trick.top!;
const waitingOn = withCards(s).filter((x) => x !== top.by && !s.trick.passesSinceTop.includes(x) && !s.trick.lockedOut.includes(x));
if (waitingOn.length === 0) {
const leader = s.hands[top.by]!.length > 0 ? top.by : nextWithCards(s, top.by);
log(s, { t: 'trick', p: leader });
startTrick(s, leader, ctx);
return;
}
newTurn(s, nextActor(s, p));
}
/** What `p` may play now (rank, set sizes). */
export function choicesFor(s: DalmutiState, p: PlayerId): Choice[] {
if (s.phase.kind !== 'playing' || s.current !== p) return [];
const hand = s.hands[p]!;
const J = hand.filter(isJ).length;
const top = s.trick.top;
const out: Choice[] = [];
for (let r = 1; r <= 12; r++) {
const have = hand.filter((c) => c.n === r).length;
if (have === 0) continue;
if (!top) out.push({ rank: r, have, counts: Array.from({ length: have + J }, (_, i) => i + 1), needsJester: false });
else if (r < top.rank && have + J >= top.count) out.push({ rank: r, have, counts: [top.count], needsJester: have < top.count });
}
if (J > 0 && !top) out.push({ rank: JESTER, have: J, counts: Array.from({ length: J }, (_, i) => i + 1), needsJester: false });
return out;
}
/** Card ids for a set: `count` cards of `rank`, using at least `minJesters` jesters (more if needed). */
export function pickSet(hand: DCard[], rank: number, count: number, minJesters = 0): string[] | null {
const jesters = sortHand(hand.filter(isJ));
if (rank === JESTER) return count <= jesters.length ? jesters.slice(0, count).map((c) => c.id) : null;
const nums = sortHand(hand.filter((c) => c.n === rank));
const j = Math.max(minJesters, count - nums.length);
if (j > jesters.length || count - j < 1 || count - j > nums.length) return null;
return [...nums.slice(0, count - j), ...jesters.slice(0, j)].map((c) => c.id);
}
/** §8.5 lead timeout: all cards of the highest number; only jesters left → all jesters. */
export function timeoutLead(hand: DCard[]): string[] {
const nums = hand.filter((c) => !isJ(c));
if (nums.length === 0) return hand.map((c) => c.id);
const hi = Math.max(...nums.map((c) => c.n));
return sortHand(nums.filter((c) => c.n === hi)).map((c) => c.id);
}
function validatePlay(s: DalmutiState, actor: PlayerId, a: PlayAction): Validation {
if (s.phase.kind !== 'playing') return fail('지금은 카드를 낼 수 없어요.');
if (s.current !== actor) return fail('지금은 내 차례가 아니에요.');
if (s.trick.lockedOut.includes(actor)) return fail('이번 트릭에서는 이미 패스했어요.');
if (new Set(a.cardIds).size !== a.cardIds.length) return fail('같은 카드를 두 번 고를 수 없어요.');
const hand = s.hands[actor]!;
const cards = a.cardIds.map((id) => hand.find((c) => c.id === id));
if (cards.some((c) => !c)) return fail('손에 없는 카드예요.');
const rank = setRank(cards as DCard[]);
if (rank === null) return fail('같은 숫자끼리만 낼 수 있어요.');
const top = s.trick.top;
if (top) {
if (a.cardIds.length !== top.count) return fail(`앞사람과 같은 장수(${top.count}장)로 내야 해요.`);
if (rank >= top.rank) return fail('더 낮은 숫자만 낼 수 있어요.');
}
return ok;
}
function validateReturn(s: DalmutiState, actor: PlayerId, ids: string[]): Validation {
if (s.phase.kind !== 'taxReturn') return fail('지금은 카드를 돌려줄 때가 아니에요.');
const owe = s.phase.owed[actor];
if (!owe) return fail('돌려줄 카드가 없어요.');
if (owe.done) return fail('이미 카드를 돌려줬어요.');
if (ids.length !== owe.count) return fail(`돌려줄 카드 ${owe.count}장을 골라 주세요.`);
if (new Set(ids).size !== ids.length) return fail('같은 카드를 두 번 고를 수 없어요.');
if (ids.some((id) => !s.hands[actor]!.some((c) => c.id === id))) return fail('손에 없는 카드예요.');
return ok;
}
function returnTax(s: DalmutiState, p: PlayerId, ids: string[], ctx: Ctx): void {
if (s.phase.kind !== 'taxReturn') throw new Error('not in taxReturn');
const owe = s.phase.owed[p]!;
moveCards(s, p, owe.to, ids);
owe.done = true;
s.taxLog.push({ kind: 'return', from: p, to: owe.to, cardIds: [...ids] });
ctx.events.push({ type: 'taxReturned', from: p, to: owe.to, count: ids.length });
log(s, { t: 'return', from: p, to: owe.to, n: ids.length });
if (Object.values(s.phase.owed).every((x) => x.done)) startPlaying(s, ctx);
}
function confirmHand(s: DalmutiState, p: PlayerId, declare: boolean, ctx: Ctx): void {
if (s.phase.kind !== 'revolutionCheck') throw new Error('not in revolutionCheck');
s.phase.confirmed[p] = true;
if (declare) {
s.revolutionBy = p;
ctx.events.push({ type: 'revolutionDeclared', player: p });
}
if (s.players.every((x) => (s.phase as { confirmed: Record<PlayerId, boolean> }).confirmed[x])) endRevolutionCheck(s, ctx);
}
const hasBothJesters = (s: DalmutiState, p: PlayerId) => s.hands[p]!.filter(isJ).length === 2;
const canDeclare = (s: DalmutiState, p: PlayerId) =>
s.phase.kind === 'revolutionCheck' && !s.phase.confirmed[p] && s.revolutionBy === null && hasBothJesters(s, p);
function waiting(s: DalmutiState): PlayerId[] {
const ph = s.phase;
switch (ph.kind) {
case 'revolutionCheck':
return s.players.filter((p) => !ph.confirmed[p]);
case 'taxReturn':
return s.players.filter((p) => ph.owed[p] && !ph.owed[p]!.done);
case 'playing':
return [s.current];
case 'finished':
return [];
}
}
function deadlineMs(s: DalmutiState): number | null {
const w = waiting(s);
const allAway = w.length > 0 && w.every((p) => s.away[p]);
switch (s.phase.kind) {
case 'revolutionCheck':
return allAway ? AWAY_MS : REVOLUTION_MS;
case 'taxReturn':
return allAway ? AWAY_MS : TAX_MS;
case 'playing':
return allAway ? AWAY_MS : s.options.turnSeconds * 1000;
case 'finished':
return null;
}
}
function timeoutMove(s: DalmutiState, p: PlayerId, ctx: Ctx): void {
const ph = s.phase;
switch (ph.kind) {
case 'revolutionCheck':
confirmHand(s, p, false, ctx);
break;
case 'taxReturn':
returnTax(s, p, worstCards(s.hands[p]!, ph.owed[p]!.count).map((c) => c.id), ctx);
break;
case 'playing':
if (s.trick.top) pass(s, p, ctx);
else playCards(s, p, timeoutLead(s.hands[p]!), ctx);
break;
}
}
// ---------------------------------------------------------------- view
const faceOf = (id: string): number => (id.startsWith('j') ? JESTER : Number(id.slice(1, id.indexOf('-'))));
function playView(pl: Play & { by: PlayerId }): PlayView {
return { by: pl.by, count: pl.count, rank: pl.rank, cards: pl.cardIds.map(faceOf).sort((a, b) => a - b) };
}
// ---------------------------------------------------------------- definition
export const dalmuti: Def = {
id: 'dalmuti',
nameKo: '달무티',
minPlayers: 4,
maxPlayers: 8,
stateVersion: 1,
defaultOptions: dalmutiOptionsSchema.parse({}),
optionsSchema: dalmutiOptionsSchema as unknown as Def['optionsSchema'],
actionSchema: dalmutiActionSchema as unknown as Def['actionSchema'],
setup({ players, options, rng, now }) {
if (players.length < 4 || players.length > 8) throw new Error(`dalmuti: ${players.length} players not allowed`);
const deck = deckFor(options.removeHighCards);
let order: PlayerId[];
let firstDraw: Record<PlayerId, number[]> | null = null;
if (options.firstRanking === 'draw') {
const d = drawFirstRanks(players, deck, rng);
order = d.order;
firstDraw = d.draws;
} else order = rng.shuffle(players);
const s: DalmutiState = {
options,
players: [...players],
order,
round: 1,
hands: {},
discard: [],
phase: { kind: 'playing' },
revolution: 'none',
revolutionBy: null,
firstDraw,
taxLog: [],
trick: { leader: order[0]!, top: null, passesSinceTop: [], lockedOut: [], pile: [] },
current: order[0]!,
finishOrder: [],
roundResults: [],
totals: Object.fromEntries(players.map((p) => [p, 0])),
turnStartedAt: now,
turnNo: 0,
seq: 0,
timeoutsInRow: Object.fromEntries(players.map((p) => [p, 0])),
away: Object.fromEntries(players.map((p) => [p, false])),
left: Object.fromEntries(players.map((p) => [p, false])),
log: [],
final: null,
};
deal(s, { rng, now, events: [] });
return s;
},
validate(s, actor, a) {
if (!s.players.includes(actor)) return fail('이 게임의 플레이어가 아니에요.');
if (s.phase.kind === 'finished') return fail('게임이 이미 끝났어요.');
switch (a.type) {
case 'leave':
return s.left[actor] ? fail('이미 나간 플레이어예요.') : ok;
case 'timeout':
return waiting(s).includes(actor) ? ok : fail('시간 초과 대상이 아니에요.');
case 'confirmHand': {
const ph = s.phase;
if (ph.kind !== 'revolutionCheck') return fail('지금은 손패를 확인할 때가 아니에요.');
if (ph.confirmed[actor]) return fail('이미 확인했어요.');
if (a.declareRevolution && !hasBothJesters(s, actor)) return fail('광대 2장이 있어야 혁명할 수 있어요.');
return ok;
}
case 'returnTax':
return validateReturn(s, actor, a.cardIds);
case 'play':
return validatePlay(s, actor, a);
case 'pass':
if (s.phase.kind !== 'playing') return fail('지금은 패스할 수 없어요.');
if (s.current !== actor) return fail('지금은 내 차례가 아니에요.');
if (!s.trick.top) return fail('새로 시작할 때는 패스할 수 없어요.');
return ok;
}
},
apply(prev, actor, a, { rng, now }) {
const s = cloneState(prev);
const ctx: Ctx = { rng, now, events: [] };
const before = `${s.turnNo}|${s.phase.kind}`;
s.seq += 1;
if (a.type !== 'timeout' && a.type !== 'leave') {
s.timeoutsInRow[actor] = 0;
s.away[actor] = !!s.left[actor];
}
switch (a.type) {
case 'confirmHand':
confirmHand(s, actor, !!a.declareRevolution, ctx);
break;
case 'returnTax':
returnTax(s, actor, a.cardIds, ctx);
break;
case 'play':
playCards(s, actor, a.cardIds, ctx);
break;
case 'pass':
pass(s, actor, ctx);
break;
case 'leave':
// Not eliminated (§8.5): the seat keeps playing on automatic moves.
s.left[actor] = true;
s.away[actor] = true;
ctx.events.push({ type: 'left', player: actor });
log(s, { t: 'left', p: actor });
break;
case 'timeout': {
s.timeoutsInRow[actor] = (s.timeoutsInRow[actor] ?? 0) + 1;
if (s.timeoutsInRow[actor]! >= AWAY_AFTER && !s.away[actor]) {
s.away[actor] = true;
ctx.events.push({ type: 'away', player: actor });
}
ctx.events.push({ type: 'timeout', player: actor });
log(s, { t: 'timeout', p: actor });
timeoutMove(s, actor, ctx);
break;
}
}
if (`${s.turnNo}|${s.phase.kind}` !== before) s.turnStartedAt = now;
return { state: s, events: ctx.events };
},
view(s, viewer) {
const me = viewer && s.players.includes(viewer) ? viewer : null;
const ph = s.phase;
const n = s.order.length;
const top = s.trick.top;
const w = waiting(s);
let my: DalmutiView['my'] = null;
if (me) {
const hand = s.hands[me]!;
const mineNow = ph.kind === 'playing' && s.current === me;
const owe = ph.kind === 'taxReturn' && ph.owed[me] && !ph.owed[me]!.done ? { to: ph.owed[me]!.to, count: ph.owed[me]!.count } : null;
const handIds = new Set(hand.map((c) => c.id));
const received = s.taxLog.filter((t) => t.to === me).flatMap((t) => t.cardIds).filter((id) => handIds.has(id));
my = {
hand,
choices: choicesFor(s, me),
jesters: hand.filter(isJ).length,
canPlay: mineNow,
canPass: mineNow && !!top,
leading: mineNow && !top,
lockedOut: s.trick.lockedOut.includes(me),
canConfirm: ph.kind === 'revolutionCheck' && !ph.confirmed[me],
...(canDeclare(s, me) ? { canDeclareRevolution: true } : {}),
owe,
receivedIds: received,
suggestedReturn: owe ? worstCards(hand, owe.count).map((c) => c.id) : [],
};
}
const playing = ph.kind === 'playing';
return {
options: s.options,
me,
round: s.round,
phase: ph.kind,
seats: s.order.map((p, i) => ({
id: p,
rank: i,
title: titleOf(i, n),
cards: s.hands[p]!.length,
isTurn: w.includes(p),
passed: playing && s.trick.passesSinceTop.includes(p),
lockedOut: playing && s.trick.lockedOut.includes(p),
place: s.finishOrder.includes(p) ? s.finishOrder.indexOf(p) + 1 : null,
away: !!s.away[p],
left: !!s.left[p],
confirmed: ph.kind === 'revolutionCheck' ? !!ph.confirmed[p] : false,
total: s.totals[p] ?? 0,
})),
current: playing ? s.current : null,
trick: playing ? { leader: s.trick.leader, top: top ? playView(top) : null, pile: s.trick.pile.map((pl) => playView(pl)) } : null,
finishOrder: [...s.finishOrder],
revolution: s.revolution,
revolutionBy: s.revolutionBy,
firstDraw: s.round === 1 ? s.firstDraw : null,
tax: s.taxLog.map((t) => ({
kind: t.kind,
from: t.from,
to: t.to,
count: t.cardIds.length,
cards: me && (me === t.from || me === t.to) ? t.cardIds.map(faceOf).sort((a, b) => a - b) : null,
})),
taxPending: ph.kind === 'taxReturn' ? Object.entries(ph.owed).map(([from, o]) => ({ from, to: o.to, count: o.count, done: o.done })) : [],
roundResults: s.roundResults,
discardCount: s.discard.length,
deckSize: deckSize(s.options),
my,
turnStartedAt: s.turnStartedAt,
deadlineTotalMs: deadlineMs(s) ?? 0,
turnNo: s.turnNo,
log: s.log.slice(-LOG_VIEW),
result: s.final,
};
},
activePlayers: waiting,
deadline(s) {
const ms = deadlineMs(s);
return ms === null ? null : s.turnStartedAt + ms;
},
timeoutPlayers: waiting,
timeoutNotBefore(s) {
return s.phase.kind === 'playing' ? s.turnStartedAt + MIN_TURN_MS : null;
},
onTimeout() {
return { type: 'timeout' };
},
onLeave(s, player) {
if (!s.players.includes(player) || s.left[player] || s.phase.kind === 'finished') return null;
return { type: 'leave' };
},
result(s): GameResult | null {
if (!s.final) return null;
return { ranking: s.final.ranking, scores: Object.fromEntries(s.final.players.map((p) => [p.player, p.total])), summary: s.final.summary, reason: 'normal' };
},
legalActions(s, p) {
if (!s.players.includes(p) || s.phase.kind === 'finished') return [];
const ph = s.phase;
const out: DalmutiAction[] = [];
if (ph.kind === 'revolutionCheck') {
if (ph.confirmed[p]) return [];
out.push({ type: 'confirmHand' });
if (canDeclare(s, p)) out.push({ type: 'confirmHand', declareRevolution: true });
return out;
}
if (ph.kind === 'taxReturn') {
const owe = ph.owed[p];
if (!owe || owe.done) return [];
// One representative card per number keeps the list small; pairs of the same number included.
const hand = sortHand(s.hands[p]!);
const reps: DCard[] = [];
for (const c of hand) if (reps.filter((r) => r.n === c.n).length < owe.count) reps.push(c);
if (owe.count === 1) return reps.map((c) => ({ type: 'returnTax', cardIds: [c.id] }));
for (let i = 0; i < reps.length; i++) for (let j = i + 1; j < reps.length; j++) out.push({ type: 'returnTax', cardIds: [reps[i]!.id, reps[j]!.id] });
return out;
}
if (s.current !== p) return [];
const hand = s.hands[p]!;
const J = hand.filter(isJ).length;
for (const ch of choicesFor(s, p)) {
for (const count of ch.counts) {
if (ch.rank === JESTER) {
out.push({ type: 'play', cardIds: pickSet(hand, JESTER, count)! });
continue;
}
const minJ = Math.max(0, count - ch.have);
for (let j = minJ; j <= Math.min(J, count - 1); j++) {
const ids = pickSet(hand, ch.rank, count, j);
if (ids) out.push({ type: 'play', cardIds: ids });
}
}
}
if (s.trick.top) out.push({ type: 'pass' });
return out;
},
/** Own hand (card ids exactly as they would appear in a view). */
secretsOf(s, player) {
return (s.hands[player] ?? []).map((c) => c.id);
},
};

View File

@@ -0,0 +1,287 @@
/** 달무티 options, cards, actions, state and view types (docs/games/dalmuti.md §2, §3, §8). */
import { z } from 'zod';
import type { PlayerId } from '@bg/engine';
// ---------------------------------------------------------------- cards
/** A card: n = 1..12, jester (광대) = 13. */
export interface DCard {
id: string;
n: number;
}
export const JESTER = 13;
/**
* Card names shown on screen. The original rank names are the publisher's wording (§12), so the site
* uses its own generic names with the same numbers.
*/
export const CARD_NAME: Record<number, string> = {
1: '왕',
2: '왕비',
3: '재상',
4: '공작',
5: '백작',
6: '기사',
7: '학자',
8: '상인',
9: '요리사',
10: '목동',
11: '광부',
12: '농부',
13: '광대',
};
/** 80 cards: n copies of n (1..12) + 2 jesters, in a fixed order (n, copy). */
export const FULL_DECK: readonly DCard[] = [
...Array.from({ length: 12 }, (_, i) => i + 1).flatMap((n) => Array.from({ length: n }, (_, k) => ({ id: `c${n}-${k + 1}`, n }))),
{ id: 'j1', n: JESTER },
{ id: 'j2', n: JESTER },
];
const ORDER = new Map(FULL_DECK.map((c, i) => [c.id, i]));
/** Fixed card order (number, then copy) used for tie-breaks and sorting. */
export const cardOrder = (id: string): number => ORDER.get(id) ?? 999;
export function deckFor(removeHighCards: boolean): DCard[] {
return FULL_DECK.filter((c) => !removeHighCards || c.n <= 8 || c.n === JESTER);
}
// ---------------------------------------------------------------- options / actions
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 dalmutiOptionsSchema = z.object({
rounds: lit<1 | 3 | 5 | 7>(1, 3, 5, 7).default(3).meta({ title: '라운드 수', labels: { 1: '1라운드', 3: '3라운드', 5: '5라운드', 7: '7라운드' } }),
scoring: z
.enum(['rank-points', 'last-round'])
.default('rank-points')
.meta({ title: '최종 순위 방식', labels: { 'rank-points': '라운드별 순위 점수 합', 'last-round': '마지막 라운드 순위' } }),
firstRanking: z
.enum(['draw', 'random'])
.default('draw')
.meta({ title: '첫 라운드 신분 정하기', labels: { draw: '카드 한 장씩 뽑기', random: '무작위' } }),
revolution: z.boolean().default(true).meta({ title: '혁명 규칙 사용(광대 2장으로 선언)' }),
jesterTaxable: z.boolean().default(false).meta({ title: '세금 낼 때 광대도 좋은 카드로 내기' }),
passLocksOut: z.boolean().default(false).meta({ title: '한 번 패스하면 그 트릭에서 다시 못 내기' }),
removeHighCards: z.boolean().default(false).meta({ title: '9~12 카드를 빼고 하기(38장)' }),
turnSeconds: lit<15 | 20 | 30 | 45>(15, 20, 30, 45)
.default(20)
.meta({ title: '한 턴 제한 시간', labels: { 15: '15초', 20: '20초', 30: '30초', 45: '45초' } }),
});
export type DalmutiOptions = z.infer<typeof dalmutiOptionsSchema>;
export const dalmutiActionSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('confirmHand'), declareRevolution: z.boolean().optional() }),
z.object({ type: z.literal('returnTax'), cardIds: z.array(z.string().max(8)).min(1).max(2) }),
z.object({ type: z.literal('play'), cardIds: z.array(z.string().max(8)).min(1).max(14) }),
z.object({ type: z.literal('pass') }),
]);
/** Client actions plus system-only ones (server timer, leaving) that are not in the schema. */
export type DalmutiAction = z.infer<typeof dalmutiActionSchema> | { type: 'timeout' } | { type: 'leave' };
export type PlayAction = Extract<DalmutiAction, { type: 'play' }>;
// ---------------------------------------------------------------- state
/** A set on the table. rank: the number of the number cards, or 13 for jesters alone. */
export interface Play {
cardIds: string[];
count: number;
rank: number;
}
export type RevolutionKind = 'none' | 'revolution' | 'great';
/**
* The draw (§4-1) and the end of a round (§8.2 `roundOver`) are resolved at once; their results stay in
* `firstDraw` / `roundResults` for the screen. So the state only rests in these phases.
*/
export type Phase =
| { kind: 'revolutionCheck'; confirmed: Record<PlayerId, boolean> }
| { kind: 'taxReturn'; owed: Record<PlayerId, { to: PlayerId; count: number; done: boolean }> }
| { kind: 'playing' }
| { kind: 'finished' };
export interface TaxMove {
kind: 'tax' | 'return';
from: PlayerId;
to: PlayerId;
cardIds: string[];
}
export interface Trick {
leader: PlayerId;
top: (Play & { by: PlayerId }) | null;
passesSinceTop: PlayerId[];
lockedOut: PlayerId[];
pile: (Play & { by: PlayerId })[];
}
export interface RoundResult {
round: number;
/** Finish order = next round's seating; [0] got the most points. */
order: PlayerId[];
revolution: RevolutionKind;
points: Record<PlayerId, number>;
}
/** Public log line (structured; the screen turns it into Korean with nicknames). */
export type LogEntry =
| { seq: number; t: 'play'; p: PlayerId; cards: number[]; count: number; rank: number }
| { seq: number; t: 'pass'; p: PlayerId }
| { seq: number; t: 'trick'; p: PlayerId }
| { seq: number; t: 'out'; p: PlayerId; place: number }
| { seq: number; t: 'revolution'; p: PlayerId; kind: 'revolution' | 'great' }
| { seq: number; t: 'tax'; from: PlayerId; to: PlayerId; n: number }
| { seq: number; t: 'return'; from: PlayerId; to: PlayerId; n: number }
| { seq: number; t: 'round'; round: number }
| { seq: number; t: 'timeout'; p: PlayerId }
| { seq: number; t: 'left'; p: PlayerId };
export interface PlayerFinal {
player: PlayerId;
rank: number;
total: number;
roundRanks: number[];
}
export interface DalmutiFinal {
ranking: PlayerId[][];
players: PlayerFinal[];
summary: string;
}
export interface DalmutiState {
options: DalmutiOptions;
/** Participants (fixed). */
players: PlayerId[];
/** This round's ranks = turn order; [0] = 대달무티. */
order: PlayerId[];
round: number;
/** Secret. */
hands: Record<PlayerId, DCard[]>;
/** Cards from finished tricks and the last player's leftovers (not sent to clients). */
discard: string[];
phase: Phase;
revolution: RevolutionKind;
revolutionBy: PlayerId | null;
/** First-round rank draw (§4-1): numbers each player drew, re-draws included. null for `random`. */
firstDraw: Record<PlayerId, number[]> | null;
/** This round's tax moves; card contents only for the two people involved. */
taxLog: TaxMove[];
trick: Trick;
current: PlayerId;
finishOrder: PlayerId[];
roundResults: RoundResult[];
totals: Record<PlayerId, number>;
turnStartedAt: number;
/** Changes whenever a new turn or phase starts (resets the timer). */
turnNo: number;
seq: number;
timeoutsInRow: Record<PlayerId, number>;
away: Record<PlayerId, boolean>;
left: Record<PlayerId, boolean>;
log: LogEntry[];
final: DalmutiFinal | null;
}
// ---------------------------------------------------------------- view
export type Title = '대달무티' | '소달무티' | '평민' | '소농노' | '대농노';
export interface SeatView {
id: PlayerId;
/** 0-based rank this round (= turn order). */
rank: number;
title: Title;
cards: number;
isTurn: boolean;
/** Passed since the current top set. */
passed: boolean;
lockedOut: boolean;
/** Finish place this round (1-based) once out of cards. */
place: number | null;
away: boolean;
left: boolean;
/** revolutionCheck: has pressed "확인". */
confirmed: boolean;
total: number;
}
export interface PlayView {
by: PlayerId;
count: number;
rank: number;
/** Faces (13 = jester), no ids. */
cards: number[];
}
/** One number the player can play now. */
export interface Choice {
/** 1..12, or 13 for jesters alone. */
rank: number;
/** Number cards of this rank in hand (jesters for rank 13). */
have: number;
/** Legal set sizes. */
counts: number[];
/** Only possible by mixing in jesters (following). */
needsJester: boolean;
}
export interface TaxMoveView {
kind: 'tax' | 'return';
from: PlayerId;
to: PlayerId;
count: number;
/** Faces, only for the giver and the receiver. */
cards: number[] | null;
}
export interface DalmutiView {
options: DalmutiOptions;
me: PlayerId | null;
round: number;
phase: Phase['kind'];
seats: SeatView[];
current: PlayerId | null;
trick: {
leader: PlayerId;
top: PlayView | null;
pile: PlayView[];
} | null;
finishOrder: PlayerId[];
revolution: RevolutionKind;
revolutionBy: PlayerId | null;
firstDraw: Record<PlayerId, number[]> | null;
tax: TaxMoveView[];
/** taxReturn: who still has to give cards back. */
taxPending: { from: PlayerId; to: PlayerId; count: number; done: boolean }[];
roundResults: RoundResult[];
discardCount: number;
deckSize: number;
my: {
hand: DCard[];
choices: Choice[];
jesters: number;
canPlay: boolean;
canPass: boolean;
/** Leading a new trick (must play). */
leading: boolean;
lockedOut: boolean;
canConfirm: boolean;
/** Only in the viewer's own view (§8.3). */
canDeclareRevolution?: boolean;
/** Cards to give back now (taxReturn). */
owe: { to: PlayerId; count: number } | null;
/** Received tax cards still in hand (highlight). */
receivedIds: string[];
/** Default suggestion for the return: the highest number cards. */
suggestedReturn: string[];
} | null;
turnStartedAt: number;
deadlineTotalMs: number;
turnNo: number;
log: LogEntry[];
result: DalmutiFinal | null;
}

View File

@@ -16,6 +16,7 @@ import { gostop } from './gostop';
import { bang } from './bang'; import { bang } from './bang';
import { halliGalli } from './halli-galli'; import { halliGalli } from './halli-galli';
import { marble } from './marble'; import { marble } from './marble';
import { dalmuti } from './dalmuti';
export const GAMES: Record<string, AnyGameDefinition> = { export const GAMES: Record<string, AnyGameDefinition> = {
[omok.id]: omok, [omok.id]: omok,
@@ -35,4 +36,5 @@ export const GAMES: Record<string, AnyGameDefinition> = {
[bang.id]: bang, [bang.id]: bang,
[halliGalli.id]: halliGalli, [halliGalli.id]: halliGalli,
[marble.id]: marble, [marble.id]: marble,
[dalmuti.id]: dalmuti,
}; };

View File

@@ -28,6 +28,7 @@ export const CATALOG: CatalogEntry[] = [
{ id: 'mahjong', nameKo: '마작', minPlayers: 3, maxPlayers: 4, minutes: '25~50', level: '어려움', blurb: '리치 마작 4인 · 3인 산마 · 초보 모드로 대기패 힌트', available: true }, { id: 'mahjong', nameKo: '마작', minPlayers: 3, maxPlayers: 4, minutes: '25~50', level: '어려움', blurb: '리치 마작 4인 · 3인 산마 · 초보 모드로 대기패 힌트', available: true },
{ id: 'halli-galli', nameKo: '할리갈리', minPlayers: 2, maxPlayers: 6, minutes: '5~15', level: '쉬움', blurb: '한 과일이 딱 5개가 되면 누구보다 먼저 종을 땡!', available: true }, { id: 'halli-galli', nameKo: '할리갈리', minPlayers: 2, maxPlayers: 6, minutes: '5~15', level: '쉬움', blurb: '한 과일이 딱 5개가 되면 누구보다 먼저 종을 땡!', available: true },
{ id: 'marble', nameKo: '주사위 땅따먹기', minPlayers: 2, maxPlayers: 4, minutes: '30~50', level: '쉬움', blurb: '주사위로 세계 도시를 돌며 땅 사고 건물 짓기 · 놀이용 포인트', available: true }, { id: 'marble', nameKo: '주사위 땅따먹기', minPlayers: 2, maxPlayers: 4, minutes: '30~50', level: '쉬움', blurb: '주사위로 세계 도시를 돌며 땅 사고 건물 짓기 · 놀이용 포인트', available: true },
{ id: 'dalmuti', nameKo: '달무티', minPlayers: 4, maxPlayers: 8, minutes: '20~40', level: '쉬움', blurb: '낮은 숫자일수록 강해요! 먼저 다 털고 신분을 올리기', available: true },
]; ];
export const catalogById = (id: string): CatalogEntry | undefined => CATALOG.find((g) => g.id === id); export const catalogById = (id: string): CatalogEntry | undefined => CATALOG.find((g) => g.id === id);