M4: 다빈치 코드 규칙·화면·테스트

- 규칙 모듈(packages/games/src/davinci-code): 2~4명, 시작 색 고르기(동시 선택·15초),
  정렬 규칙(같은 숫자 검정/흰색 먼저 옵션), 조커 위치 직접 고르기, 맞힘 뒤 계속/멈춤,
  틀리면 가져온 타일 공개, 더미가 빈 뒤 자기 타일 공개 벌칙(옵션), 탈락 순서로 순위.
  숨은 타일은 view에 색·위치·틀린 추리만 보내고 타일 ID(숫자 포함)는 아는 사람에게만 보냄.
  시간 초과는 시스템 전용 timeout 행동으로 처리하고, 자동 추리는 그 사람의 view만으로
  후보를 계산해 결정적으로 고름. 3턴 연속 시간 초과면 마감 5초. 나가면 탈락·타일 공개.
- 테스트: 문서 10절 시나리오 전부, 시간 초과·연결 끊김·기권, 300판 이벤트/뷰 유출 검사,
  시드 1,000판 무작위 대국(타일 보존, 줄 정렬, 결정성, 내장 유출 검사, onTimeout 합법).
- 화면(apps/web/src/games/davinci-code): 상대 줄·더미·내 줄, 숫자 패드(0~11, 조커),
  한 번 더/멈추기, 조커 자리 고르기, 자기 타일 공개, 시작 색 고르기, 추리 기록, 규칙 그림.
- 등록: GAMES, 카탈로그(available), 웹 레지스트리, 아이콘, '곧 나와요'에서 제외.
- E2E: games.e2e.ts에 davinci-code 계획(PC 방장 + 폰 친구, 5턴 실제 진행).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
EJClaw
2026-10-05 04:25:50 +09:00
parent 5dd8e6ea07
commit bd081936e0
8 changed files with 2433 additions and 1 deletions

View File

@@ -0,0 +1,634 @@
import { describe, expect, test } from 'bun:test';
import { SeededRng, randomPlayout, seedN, type GameEvent } from '@bg/engine';
import {
allTileIds,
autoAction,
candidatesFor,
davinci,
insertIndex,
sortKey,
tileOf,
type DavinciAction,
type DavinciOptions,
type DavinciState,
type DavinciView,
} from './index';
// ---------------------------------------------------------------- helpers
let clock = 1_000;
const rng = () => SeededRng.fromSeed([7, 7, 7, 7]);
function setup(opts: Partial<DavinciOptions> = {}, players = ['A', 'B'], seed = 1): DavinciState {
return davinci.setup({ players, options: davinci.optionsSchema.parse(opts), rng: SeededRng.fromSeed(seedN(seed)), now: 0 });
}
/**
* A game already in play with the given rows (left → right as written, all hidden), `first` to move
* and the pool fronts as given (rest of the tiles follow in id order).
*/
function rigged(
rows: Record<string, string[]>,
opts: Partial<DavinciOptions> = {},
pool: { black?: string[]; white?: string[] } = {},
first?: string,
): DavinciState {
const players = Object.keys(rows);
const s = setup({ chooseColors: true, ...opts }, players);
const used = new Set([...Object.values(rows).flat(), ...(pool.black ?? []), ...(pool.white ?? [])]);
for (const p of players) s.rows[p] = rows[p]!.map((id) => ({ id, revealed: false, wrong: [] }));
const rest = allTileIds(s.options.jokers).filter((id) => !used.has(id));
s.pool = {
black: [...(pool.black ?? []), ...rest.filter((id) => tileOf(id).color === 'black')],
white: [...(pool.white ?? []), ...rest.filter((id) => tileOf(id).color === 'white')],
};
s.jokerReserve = [];
s.phase = s.pool.black.length + s.pool.white.length ? { kind: 'draw' } : { kind: 'guess' };
s.current = first ?? players[0]!;
return s;
}
function actE(s: DavinciState, p: string, a: DavinciAction): { state: DavinciState; events: GameEvent[] } {
const v = davinci.validate(s, p, a);
if (!v.ok) throw new Error(`${p} ${JSON.stringify(a)}: ${v.reason}`);
return davinci.apply(s, p, a, { rng: rng(), now: (clock += 1000) });
}
const act = (s: DavinciState, p: string, a: DavinciAction) => actE(s, p, a).state;
const reject = (s: DavinciState, p: string, a: DavinciAction) => {
const v = davinci.validate(s, p, a);
return v.ok ? null : v.reason;
};
const ids = (s: DavinciState, p: string) => s.rows[p]!.map((t) => t.id);
const shown = (s: DavinciState, p: string) => s.rows[p]!.map((t) => t.revealed);
const guess = (target: string, index: number, value: number | 'joker'): DavinciAction => ({ type: 'guess', target, index, value });
/** Non-joker tiles of every row are in order; every tile exists exactly once. */
function checkInvariants(st: unknown): void {
const s = st as DavinciState;
const all = [...Object.values(s.rows).flat().map((t) => t.id), ...s.pool.black, ...s.pool.white, ...s.jokerReserve, ...(s.pending ? [s.pending] : [])];
const expected = allTileIds(s.options.jokers);
if (all.length !== expected.length || new Set(all).size !== all.length || !expected.every((id) => all.includes(id))) {
throw new Error(`tiles not conserved: ${all.length}/${expected.length}`);
}
for (const p of s.seats) {
const keys = s.rows[p]!.map((t) => tileOf(t.id)).filter((t) => t.n !== 'joker').map((t) => sortKey(t.color, t.n as number, s.options.tieOrder));
for (let i = 1; i < keys.length; i++) if (keys[i - 1]! >= keys[i]!) throw new Error(`row of ${p} not sorted: ${s.rows[p]!.map((t) => t.id)}`);
if (s.phase.kind !== 'chooseStart' && s.rows[p]!.length > 0) {
const hidden = s.rows[p]!.some((t) => !t.revealed);
if (s.alive[p] !== hidden && !s.left[p]) throw new Error(`alive flag of ${p} does not match hidden tiles`);
}
}
if (s.pool.black.some((id) => tileOf(id).color !== 'black') || s.pool.white.some((id) => tileOf(id).color !== 'white')) throw new Error('pool colour mixed');
}
/** Hidden tiles of others must be `{ color, revealed: false, wrong[, candidates] }` only. */
function checkViewShape(s: DavinciState, v: DavinciView, viewer: string | null): void {
for (const p of s.seats) {
v.rows[p]!.forEach((t, i) => {
const real = s.rows[p]![i]!;
if (t.color !== tileOf(real.id).color || t.revealed !== real.revealed) throw new Error('view row mismatch');
if (s.phase.kind === 'finished' || real.revealed || p === viewer) {
if (t.n !== tileOf(real.id).n) throw new Error('known tile without number');
} else {
const keys = Object.keys(t).sort().join(',');
if (keys !== 'color,revealed,wrong' && keys !== 'candidates,color,revealed,wrong') throw new Error(`hidden tile leaks fields: ${keys}`);
}
});
}
if (v.pending && s.current !== viewer && ('n' in v.pending || 'id' in v.pending)) throw new Error('pending tile leaks');
}
// ---------------------------------------------------------------- setup
describe('셋업 (§4)', () => {
test('2·3인 4개, 4인 3개; 시작 타일에 조커 없음; 같은 시드 = 같은 분배', () => {
for (const [n, k] of [[2, 4], [3, 4], [4, 3]] as const) {
const players = ['A', 'B', 'C', 'D'].slice(0, n);
for (let seed = 1; seed <= 40; seed++) {
const s = setup({ chooseColors: false, jokers: true }, players, seed);
for (const p of players) {
expect(s.rows[p]).toHaveLength(k);
expect(s.rows[p]!.some((t) => tileOf(t.id).n === 'joker')).toBe(false);
}
expect([...s.pool.black, ...s.pool.white].filter((id) => id.endsWith('J')).sort()).toEqual(['bJ', 'wJ']);
checkInvariants(s);
}
}
expect(JSON.stringify(setup({ chooseColors: false }, ['A', 'B', 'C'], 9))).toBe(JSON.stringify(setup({ chooseColors: false }, ['A', 'B', 'C'], 9)));
expect(setup({ startTiles: 5 }, ['A', 'B', 'C', 'D']).startCount).toBe(5);
});
test('chooseColors=on: 동시에 색을 고르고, 다 고르면 분배; 조커는 분배 뒤 더미에 섞임', () => {
let s = setup({ jokers: true }, ['A', 'B', 'C']);
expect(s.phase.kind).toBe('chooseStart');
expect(davinci.activePlayers(s)).toEqual(['A', 'B', 'C']);
for (const p of ['A', 'B', 'C']) s = act(s, p, { type: 'chooseStart', black: 1, white: 3 });
expect(s.phase.kind).toBe('draw');
for (const p of ['A', 'B', 'C']) {
expect(s.rows[p]!.map((t) => tileOf(t.id).color).filter((c) => c === 'black')).toHaveLength(1);
expect(s.rows[p]!.some((t) => t.id.endsWith('J'))).toBe(false);
}
expect(s.pool.black).toContain('bJ');
expect(s.pool.white).toContain('wJ');
checkInvariants(s);
});
});
describe('chooseStart (§8.2)', () => {
test('3인이 모두 흰4 → 12개로 충분', () => {
let s = setup({}, ['A', 'B', 'C']);
for (const p of ['A', 'B', 'C']) s = act(s, p, { type: 'chooseStart', black: 0, white: 4 });
expect(s.pool.white).toHaveLength(0);
expect(s.phase.kind).toBe('draw');
expect(Object.values(s.rows).flat().every((t) => tileOf(t.id).color === 'white')).toBe(true);
});
test('4인이 모두 검3 → 성공, 검정이 0개 남음; 남은 수를 넘는 선택은 거부', () => {
let s = setup({}, ['A', 'B', 'C', 'D']);
for (const p of ['A', 'B', 'C']) s = act(s, p, { type: 'chooseStart', black: 3, white: 0 });
expect(davinci.view(s, 'D').pickLimits).toEqual({ black: 3, white: 12 });
s = act(s, 'D', { type: 'chooseStart', black: 3, white: 0 });
expect(s.pool.black).toHaveLength(0);
expect(davinci.view(s, 'A').pool).toEqual({ black: 0, white: 12 });
expect(reject(s, s.current, { type: 'draw', color: 'black' })).toBe('검은색 타일이 더 이상 없어요.');
let t = setup({ startTiles: 5 }, ['A', 'B', 'C']);
t = act(t, 'A', { type: 'chooseStart', black: 5, white: 0 });
t = act(t, 'B', { type: 'chooseStart', black: 5, white: 0 });
expect(reject(t, 'C', { type: 'chooseStart', black: 3, white: 2 })).toBe('검은색은 2개까지 고를 수 있어요.');
expect(reject(t, 'C', { type: 'chooseStart', black: 2, white: 2 })).toBe('시작 타일 5개를 골라 주세요.');
expect(reject(t, 'A', { type: 'chooseStart', black: 0, white: 5 })).toBe('이미 골랐어요.');
expect(reject(t, 'C', { type: 'draw', color: 'white' })).toBe('먼저 시작 타일을 골라 주세요.');
});
test('다른 사람의 선택은 제출 여부만 보임; 시간 초과는 반반(홀수면 검정 하나 더)', () => {
let s = setup({ startTiles: 3 }, ['A', 'B']);
s = act(s, 'A', { type: 'chooseStart', black: 3, white: 0 });
const vb = davinci.view(s, 'B');
expect(vb.picked).toEqual({ A: true, B: false });
expect(vb.myPick).toBeNull();
expect(JSON.stringify(davinci.view(s, null))).not.toContain('"black":3,"white":0');
expect(davinci.view(s, 'A').myPick).toEqual({ black: 3, white: 0 });
expect(autoAction(s, 'B')).toEqual({ type: 'chooseStart', black: 2, white: 1 });
// The start choice has one shared deadline (15 s), not restarted by each pick.
const fresh = setup({}, ['A', 'B']);
expect(fresh.deadlineAt).toBe(15_000);
expect(davinci.apply(fresh, 'A', { type: 'chooseStart', black: 2, white: 2 }, { rng: rng(), now: 5000 }).state.deadlineAt).toBe(15_000);
// Not enough black left → the rest white.
let t = setup({ startTiles: 5 }, ['A', 'B', 'C']);
t = act(t, 'A', { type: 'chooseStart', black: 5, white: 0 });
t = act(t, 'B', { type: 'chooseStart', black: 5, white: 0 });
expect(autoAction(t, 'C')).toEqual({ type: 'chooseStart', black: 2, white: 3 });
t = act(t, 'C', { type: 'timeout' });
expect(t.rows.C!.map((x) => tileOf(x.id).color).sort()).toEqual(['black', 'black', 'white', 'white', 'white']);
});
});
// ---------------------------------------------------------------- ordering
describe('정렬 규칙 (§5.1)', () => {
function dealt(order: DavinciOptions['tieOrder']) {
let s = setup({ tieOrder: order, startTiles: 3 }, ['A', 'B']);
s.pool.black = ['b5', 'b2', ...s.pool.black.filter((x) => x !== 'b5' && x !== 'b2')];
s.pool.white = ['w5', ...s.pool.white.filter((x) => x !== 'w5')];
s = act(s, 'B', { type: 'chooseStart', black: 0, white: 3 });
s = act(s, 'A', { type: 'chooseStart', black: 2, white: 1 });
return ids(s, 'A');
}
test('[흰5, 검5, 검2] → [검2, 검5, 흰5]; white-left → [검2, 흰5, 검5]', () => {
expect(dealt('black-left')).toEqual(['b2', 'b5', 'w5']);
expect(dealt('white-left')).toEqual(['b2', 'w5', 'b5']);
});
test('insertIndex: 새 타일은 정렬 위치, 조커는 건너뛰고 제자리', () => {
const row = (xs: string[]) => xs.map(tileOf);
expect(insertIndex(row(['b2', 'w5', 'b9']), 'black', 5, 'black-left')).toBe(1);
expect(insertIndex(row(['b2', 'w5', 'b9']), 'black', 5, 'white-left')).toBe(2);
expect(insertIndex(row(['b2', 'w5']), 'white', 0, 'black-left')).toBe(0);
expect(insertIndex(row(['b2', 'w5']), 'white', 11, 'black-left')).toBe(2);
// [2, J, 7] + 5 → left of the joker: [2, 5, J, 7]
expect(insertIndex(row(['b2', 'bJ', 'w7']), 'white', 5, 'black-left')).toBe(1);
expect(insertIndex(row(['bJ', 'w7']), 'white', 9, 'black-left')).toBe(2);
});
test('새 타일은 끼워지고 기존 타일 위치(순서)는 바뀌지 않음', () => {
let s = rigged({ A: ['b1', 'w4', 'b8', 'w10'], B: ['b0', 'w2', 'b6', 'w9'] }, {}, { white: ['w7'] });
s = act(s, 'A', { type: 'draw', color: 'white' });
s = act(s, 'A', guess('B', 0, 3));
expect(ids(s, 'A')).toEqual(['b1', 'w4', 'w7', 'b8', 'w10']);
expect(s.lastPlaced).toEqual({ player: 'A', index: 2, revealed: true });
});
});
// ---------------------------------------------------------------- turns
describe('턴 진행 (§5.2)', () => {
test('맞힘 → 계속 → 틀림: 맞힌 타일은 공개된 채, 이번 턴 타일은 앞면으로 들어감', () => {
let s = rigged({ A: ['b1', 'w4', 'b8', 'w10'], B: ['b0', 'w3', 'b6', 'w11'] }, {}, { white: ['w9'] });
const r = actE(s, 'A', { type: 'draw', color: 'white' });
expect(r.events).toEqual([{ type: 'drawn', player: 'A', color: 'white' }]);
s = r.state;
expect(davinci.view(s, 'B').pending).toEqual({ color: 'white' });
expect(davinci.view(s, 'A').pending).toEqual({ color: 'white', n: 9, id: 'w9' });
s = act(s, 'A', guess('B', 1, 3));
expect(s.phase.kind).toBe('afterCorrect');
expect(shown(s, 'B')).toEqual([false, true, false, false]);
s = act(s, 'A', { type: 'continue' });
expect(s.phase.kind).toBe('guess');
s = act(s, 'A', guess('B', 3, 10));
expect(shown(s, 'B')).toEqual([false, true, false, false]);
expect(ids(s, 'A')).toEqual(['b1', 'w4', 'b8', 'w9', 'w10']);
expect(shown(s, 'A')).toEqual([false, false, false, true, false]);
expect(s.rows.B![3]!.wrong).toEqual([10]);
expect([s.current, s.phase.kind]).toEqual(['B', 'draw']);
expect(s.history.map((h) => [h.by, h.target, h.index, h.said, h.correct])).toEqual([
['A', 'B', 1, 3, true],
['A', 'B', 3, 10, false],
]);
expect(s.stats.A).toEqual({ guesses: 2, correct: 1 });
checkInvariants(s);
});
test('맞힘 → 멈춤: 이번 턴 타일은 숨긴 채로; 상대 view에는 색만', () => {
let s = rigged({ A: ['b1', 'w4', 'b8', 'w10'], B: ['b0', 'w3', 'b6', 'w11'] }, {}, { black: ['b5'] });
s = act(s, 'A', { type: 'draw', color: 'black' });
s = act(s, 'A', guess('B', 0, 0));
const r = actE(s, 'A', { type: 'stop' });
s = r.state;
expect(ids(s, 'A')).toEqual(['b1', 'w4', 'b5', 'b8', 'w10']);
expect(shown(s, 'A')).toEqual([false, false, false, false, false]);
expect(r.events.find((e) => e.type === 'placed')).toEqual({ type: 'placed', player: 'A', index: 2, revealed: false, color: 'black' });
for (const viewer of ['B', null]) {
const v = davinci.view(s, viewer);
expect(v.rows.A![2]).toEqual({ color: 'black', revealed: false, wrong: [] });
expect(JSON.stringify(v)).not.toContain('"b5"');
checkViewShape(s, v, viewer);
}
expect(davinci.view(s, 'A').rows.A![2]).toMatchObject({ n: 5, id: 'b5', revealed: false });
expect(s.current).toBe('B');
});
test('마지막 숨은 타일을 맞히면 즉시 탈락; 2인이면 바로 끝(afterCorrect 없음)', () => {
let s = rigged({ A: ['b1', 'w4', 'b8', 'w10'], B: ['b0', 'w3', 'b6', 'w11'] }, {}, { black: ['b5'] });
for (let i = 0; i < 3; i++) s.rows.B![i]!.revealed = true;
s = act(s, 'A', { type: 'draw', color: 'black' });
const r = actE(s, 'A', guess('B', 3, 11));
s = r.state;
expect(s.phase.kind).toBe('finished');
expect(s.alive.B).toBe(false);
expect(r.events.map((e) => e.type)).toEqual(['guessed', 'revealed', 'eliminated', 'placed', 'gameEnded']);
expect(davinci.result(s)).toMatchObject({ ranking: [['A'], ['B']], reason: 'normal' });
expect(davinci.result(s)!.summary).toContain('숨은 타일 5개가 끝까지 풀리지 않아 이겼어요');
// finalRows: everything is public after the game.
const v = davinci.view(s, 'B');
expect(v.rows.A!.map((t) => t.n)).toEqual([1, 4, 5, 8, 10]);
expect(davinci.secretsOf!(s, 'A').filter((x) => ids(s, 'A').includes(x))).toEqual([]);
expect(davinci.activePlayers(s)).toEqual([]);
expect(davinci.deadline!(s)).toBeNull();
});
test('3인: 한 명이 탈락해도 계속; 순위는 늦게 탈락한 순', () => {
let s = rigged({ A: ['b1', 'w4', 'b8'], B: ['b0', 'w3', 'b6'], C: ['w0', 'b2', 'w5'] }, { startTiles: 3 }, { black: ['b5'] });
for (let i = 0; i < 2; i++) s.rows.B![i]!.revealed = true;
s = act(s, 'A', { type: 'draw', color: 'black' });
s = act(s, 'A', guess('B', 2, 6));
expect([s.alive.B, s.phase.kind]).toEqual([false, 'afterCorrect']);
expect(reject(s, 'A', guess('B', 0, 0))).toBe('지금은 추리할 때가 아니에요.');
s = act(s, 'A', { type: 'continue' });
expect(reject(s, 'A', guess('B', 2, 6))).toBe('이미 탈락한 사람의 타일이에요.');
s.rows.C![0]!.revealed = true;
s.rows.C![1]!.revealed = true;
s = act(s, 'A', guess('C', 2, 5));
expect(s.phase.kind).toBe('finished');
expect(davinci.result(s)!.ranking).toEqual([['A'], ['C'], ['B']]);
});
test('더미가 빈 뒤: draw 생략, 틀리면 revealOwn, 마지막 숨은 타일 공개 시 본인 탈락', () => {
const all = allTileIds(false);
const A = ['b1', 'w4'];
const B = all.filter((x) => !A.includes(x));
let s = rigged({ A, B: B.slice(0, 11), C: B.slice(11) }, {}, {}, 'A');
expect(s.pool.black.length + s.pool.white.length).toBe(0);
expect(s.phase.kind).toBe('guess');
s.rows.A![0]!.revealed = true;
s = act(s, 'A', guess('B', 0, 11));
expect(s.phase.kind).toBe('revealOwn');
expect(reject(s, 'A', { type: 'revealOwn', index: 0 })).toBe('이미 공개된 타일이에요.');
s = act(s, 'A', { type: 'revealOwn', index: 1 });
expect(s.alive.A).toBe(false);
expect(s.eliminationOrder).toEqual(['A']);
expect([s.current, s.phase.kind]).toEqual(['B', 'guess']);
checkInvariants(s);
// 2 players: self-elimination ends the game with the opponent winning.
let t = rigged({ A: ['b1', 'w4'], B: all.filter((x) => x !== 'b1' && x !== 'w4') }, {}, {}, 'A');
t.rows.A![0]!.revealed = true;
t = act(t, 'A', guess('B', 0, 11));
t = act(t, 'A', { type: 'revealOwn', index: 1 });
expect(davinci.result(t)!.ranking).toEqual([['B'], ['A']]);
});
test('emptyPoolPenalty=none: 더미가 빈 뒤 틀려도 벌칙 없이 턴 종료', () => {
const all = allTileIds(false);
let s = rigged({ A: all.slice(0, 12), B: all.slice(12) }, { emptyPoolPenalty: 'none' }, {}, 'A');
s = act(s, 'A', guess('B', 0, 5));
expect([s.current, s.phase.kind]).toEqual(['B', 'guess']);
expect(shown(s, 'A').every((x) => !x)).toBe(true);
});
});
describe('조커 (§5.1, §5.2)', () => {
test('조커를 가져와 틀리면 placeJoker에서 고른 위치에 앞면으로; 이후 정렬은 조커를 옮기지 않음', () => {
let s = rigged({ A: ['b1', 'w4', 'b8', 'w10'], B: ['b0', 'w3', 'b6', 'w11'] }, { jokers: true }, { black: ['bJ', 'b5'], white: ['w6'] });
s = act(s, 'A', { type: 'draw', color: 'black' });
expect(davinci.view(s, 'A').pending).toEqual({ color: 'black', n: 'joker', id: 'bJ' });
s = act(s, 'A', guess('B', 0, 'joker'));
expect(s.phase).toEqual({ kind: 'placeJoker', reveal: true });
expect(reject(s, 'A', { type: 'placeJoker', index: 4 })).toBeNull();
expect(reject(s, 'A', { type: 'placeJoker', index: 5 })).toBe('그 자리에는 놓을 수 없어요.');
s = act(s, 'A', { type: 'placeJoker', index: 2 });
expect(ids(s, 'A')).toEqual(['b1', 'w4', 'bJ', 'b8', 'w10']);
expect(shown(s, 'A')).toEqual([false, false, true, false, false]);
expect(davinci.view(s, 'B').rows.A![2]).toMatchObject({ n: 'joker', revealed: true });
// B's turn, then A draws b5 → it goes left of the joker, which keeps its place.
s = act(s, 'B', { type: 'draw', color: 'white' });
s = act(s, 'B', guess('A', 0, 9));
s = act(s, 'A', { type: 'draw', color: 'black' });
s = act(s, 'A', guess('B', 0, 0));
s = act(s, 'A', { type: 'stop' });
expect(ids(s, 'A')).toEqual(['b1', 'w4', 'b5', 'bJ', 'b8', 'w10']);
checkInvariants(s);
});
test('조커를 가져와 맞히고 멈추면 숨긴 채로 고른 위치에; 시간 초과는 맨 오른쪽', () => {
let s = rigged({ A: ['b1', 'w4'], B: ['b0', 'w3'] }, { jokers: true }, { white: ['wJ'] });
s = act(s, 'A', { type: 'draw', color: 'white' });
s = act(s, 'A', guess('B', 0, 0));
s = act(s, 'A', { type: 'stop' });
expect(s.phase).toEqual({ kind: 'placeJoker', reveal: false });
expect(autoAction(s, 'A')).toEqual({ type: 'placeJoker', index: 2 });
s = act(s, 'A', { type: 'timeout' });
expect(ids(s, 'A')).toEqual(['b1', 'w4', 'wJ']);
expect(shown(s, 'A')).toEqual([false, false, false]);
expect(davinci.view(s, 'B').rows.A![2]).toEqual({ color: 'white', revealed: false, wrong: [] });
});
});
describe('거부 (§8.2)', () => {
test('공개된 타일, 탈락자 타일, 자기 타일, 범위 밖 값, 조커 꺼짐, 차례 아님', () => {
let s = rigged({ A: ['b1', 'w4', 'b8'], B: ['b0', 'w3', 'b6'], C: ['w0', 'b2', 'w5'] }, { startTiles: 3 });
expect(reject(s, 'B', { type: 'draw', color: 'black' })).toBe('지금은 내 차례가 아니에요.');
expect(reject(s, 'A', { type: 'draw' })).toBe('가져올 색을 골라 주세요.');
expect(reject(s, 'A', guess('B', 0, 0))).toBe('지금은 추리할 때가 아니에요.');
s = act(s, 'A', { type: 'draw', color: 'black' });
s.rows.B![1]!.revealed = true;
s.alive.C = false;
for (const t of s.rows.C!) t.revealed = true;
expect(reject(s, 'A', guess('B', 1, 3))).toBe('이미 공개된 타일이에요.');
expect(reject(s, 'A', guess('C', 0, 0))).toBe('이미 탈락한 사람의 타일이에요.');
expect(reject(s, 'A', guess('A', 0, 1))).toBe('자기 타일은 고를 수 없어요.');
expect(reject(s, 'A', guess('B', 3, 1))).toBe('그 자리에는 타일이 없어요.');
expect(reject(s, 'A', guess('Z', 0, 1))).toBe('이 게임의 플레이어가 아니에요.');
for (const bad of [12, -1, 3.5]) expect(reject(s, 'A', guess('B', 0, bad))).toBe('0부터 11 사이 숫자를 골라 주세요.');
expect(reject(s, 'A', guess('B', 0, 'joker'))).toBe('이 방은 조커를 쓰지 않아요.');
expect(reject(s, 'A', { type: 'stop' })).toBe('맞힌 뒤에만 멈출 수 있어요.');
expect(reject(s, 'A', { type: 'continue' })).toBe('맞힌 뒤에만 계속할 수 있어요.');
expect(reject(s, 'C', { type: 'stop' })).toBe('탈락해서 더 할 수 없어요.');
expect(reject(s, 'X', { type: 'stop' })).toBe('이 게임의 플레이어가 아니에요.');
expect(reject(s, 'B', { type: 'timeout' })).toBe('시간 초과 대상이 아니에요.');
// Logically impossible guesses (already visible combination) are still allowed (§5.2).
expect(reject(s, 'A', guess('B', 0, 3))).toBeNull();
// chooseColors=off: no colour choice.
const off = rigged({ A: ['b1'], B: ['b0'] }, { chooseColors: false });
expect(reject(off, 'A', { type: 'draw', color: 'black' })).toBe('이 방은 색을 고르지 않고 무작위로 가져와요.');
expect(reject(off, 'A', { type: 'draw' })).toBeNull();
const pool = off.pool.black.length + off.pool.white.length;
const after = act(off, 'A', { type: 'draw' });
expect(after.pool.black.length + after.pool.white.length).toBe(pool - 1);
expect(after.pending).not.toBeNull();
});
});
// ---------------------------------------------------------------- timeouts
describe('시간 초과·연결 끊김 (§8.5)', () => {
test('draw: 더 많이 남은 색(같으면 검정); afterCorrect: 멈춤; revealOwn: 가장 왼쪽', () => {
const s = rigged({ A: ['b1', 'w4'], B: ['b0', 'w3'] });
expect(autoAction(s, 'A')).toEqual({ type: 'draw', color: 'black' });
s.pool.black.pop();
expect(autoAction(s, 'A')).toEqual({ type: 'draw', color: 'white' });
s.phase = { kind: 'afterCorrect' };
expect(autoAction(s, 'A')).toEqual({ type: 'stop' });
s.phase = { kind: 'revealOwn' };
s.rows.A![0]!.revealed = true;
expect(autoAction(s, 'A')).toEqual({ type: 'revealOwn', index: 1 });
});
test('guess: 숨은 타일이 가장 적은 상대의 가장 왼쪽 타일, 가능한 가장 작은 값', () => {
let s = rigged({ A: ['b0', 'w1', 'b3'], B: ['b2', 'w5', 'b7', 'w9'], C: ['w0', 'b1', 'w8', 'b10'] }, { startTiles: 3 });
s = act(s, 'A', { type: 'draw', color: 'white' }); // w2 (first white left in id order)
s.rows.C![0]!.revealed = true;
s.rows.C![1]!.revealed = true;
// C has the fewest hidden tiles (w8, b10); its leftmost hidden tile is white and right of the revealed b1,
// so it is white 1 or more; w1 and w2 (pending) are mine → 3.
expect(autoAction(s, 'A')).toEqual({ type: 'guess', target: 'C', index: 2, value: 3 });
// Same public state, different hidden numbers → same guess (the auto-guess never reads hidden numbers).
const t = structuredClone(s);
t.rows.C![2]!.id = 'w11';
t.pool.white = t.pool.white.map((x) => (x === 'w11' ? 'w8' : x));
expect(autoAction(t, 'A')).toEqual(autoAction(s, 'A'));
// Wrong guesses are remembered per tile, so repeated timeouts eventually find the number.
s = act(s, 'A', { type: 'timeout' });
expect(s.rows.C![2]!.wrong).toEqual([3]);
expect(s.history.at(-1)).toMatchObject({ by: 'A', auto: true, said: 3, correct: false });
});
test('candidatesFor: 조커가 가능하면 후보에 넣고, 범위는 공개된 숫자 타일 사이', () => {
const v = (n?: number | 'joker', revealed = false, color: 'black' | 'white' = 'black') =>
n === undefined ? { color, revealed: false, wrong: [] } : { color, revealed, n, wrong: [] };
const rows = { me: [v(4, false, 'white')], o: [v(2, true), v(), v('joker', true, 'white'), v(), v(9, true, 'white')] };
expect(candidatesFor(rows, null, 'o', 1, { jokers: false, tieOrder: 'black-left' })).toEqual([3, 4, 5, 6, 7, 8, 9]);
expect(candidatesFor(rows, null, 'o', 3, { jokers: true, tieOrder: 'black-left' })).toEqual([3, 4, 5, 6, 7, 8, 9, 'joker']);
expect(candidatesFor(rows, null, 'o', 3, { jokers: true, tieOrder: 'white-left' })).toEqual([3, 4, 5, 6, 7, 8, 'joker']);
});
test('연결이 끊긴 플레이어: 매번 시간 초과여도 게임이 끝까지 진행; 연속 3턴이면 마감 5초', () => {
for (let seed = 1; seed <= 30; seed++) {
const players = ['A', 'B', 'C'];
let s = davinci.setup({ players, options: davinci.optionsSchema.parse({ jokers: seed % 2 === 0 }), rng: SeededRng.fromSeed(seedN(seed)), now: 0 });
const r = SeededRng.fromSeed(seedN(seed + 500));
const pick = SeededRng.fromSeed(seedN(seed + 900));
let sawShort = false;
for (let step = 0; step < 3000 && s.phase.kind !== 'finished'; step++) {
const p = davinci.timeoutPlayers!(s)[0]!;
// A never acts (always times out); B and C play randomly.
const legal = davinci.legalActions!(s, p);
const a = p === 'A' ? davinci.onTimeout(s, p) : legal[pick.int(legal.length)]!;
expect(davinci.validate(s, p, a)).toEqual({ ok: true });
s = davinci.apply(s, p, a, { rng: r, now: step * 1000 }).state;
checkInvariants(s);
if (s.phase.kind !== 'finished' && s.current === 'A' && s.phase.kind !== 'chooseStart' && s.idle.A! >= 3) {
expect(s.deadlineAt! - s.phaseStartedAt).toBe(5000);
sawShort = true;
}
}
expect(s.phase.kind).toBe('finished');
if (seed === 1) expect(sawShort).toBe(true);
}
});
test('all-AFK game (everyone times out) still ends', () => {
for (let seed = 1; seed <= 20; seed++) {
for (const emptyPoolPenalty of ['reveal-own', 'none'] as const) {
let s = setup({ emptyPoolPenalty, jokers: seed % 2 === 0 }, ['A', 'B', 'C', 'D'].slice(0, 2 + (seed % 3)), seed);
const r = SeededRng.fromSeed(seedN(seed));
let step = 0;
for (; step < 5000 && s.phase.kind !== 'finished'; step++) {
const p = davinci.timeoutPlayers!(s)[0]!;
s = davinci.apply(s, p, davinci.onTimeout(s, p), { rng: r, now: step }).state;
}
expect(s.phase.kind).toBe('finished');
}
}
});
test('기권(나가기): 탈락하고 타일 공개; 차례였으면 다음 사람으로', () => {
let s = rigged({ A: ['b1', 'w4'], B: ['b0', 'w3'], C: ['w0', 'b2'] }, {}, { black: ['b5'] });
s = act(s, 'A', { type: 'draw', color: 'black' });
expect(davinci.onLeave!(s, 'A')).toEqual({ type: 'leave' });
s = act(s, 'A', { type: 'leave' });
expect([s.alive.A, s.current, s.phase.kind]).toEqual([false, 'B', 'draw']);
expect(shown(s, 'A')).toEqual([true, true, true]);
expect(ids(s, 'A')).toEqual(['b1', 'w4', 'b5']);
expect(davinci.onLeave!(s, 'A')).toBeNull();
s = act(s, 'C', { type: 'leave' });
expect(s.phase.kind).toBe('finished');
expect(davinci.result(s)).toMatchObject({ ranking: [['B'], ['C'], ['A']], reason: 'resign' });
checkInvariants(s);
// Leaving during the start choice: the rest are dealt when they have chosen.
let t = setup({}, ['A', 'B', 'C']);
t = act(t, 'A', { type: 'chooseStart', black: 2, white: 2 });
t = act(t, 'B', { type: 'leave' });
expect(t.phase.kind).toBe('chooseStart');
t = act(t, 'C', { type: 'chooseStart', black: 2, white: 2 });
expect(t.phase.kind).toBe('draw');
expect(t.rows.B).toEqual([]);
expect(t.current).not.toBe('B');
checkInvariants(t);
});
});
// ---------------------------------------------------------------- info
describe('정보 유출 (§8.3)', () => {
test('관전자·상대 view와 이벤트에 남의 숨은 숫자·ID·이번 턴 타일 숫자가 없음', () => {
for (let seed = 1; seed <= 300; seed++) {
const n = 2 + (seed % 3);
const players = Array.from({ length: n }, (_, i) => `P${i}`);
const options = davinci.optionsSchema.parse({ jokers: seed % 2 === 0, chooseColors: seed % 3 !== 0, deductionHelper: seed % 4 === 0 });
const r = SeededRng.fromSeed(seedN(seed));
const chooser = SeededRng.fromSeed(seedN(seed + 77));
let s = davinci.setup({ players, options, rng: r, now: 0 });
for (let step = 0; step < 400 && s.phase.kind !== 'finished'; step++) {
const views = new Map([...players, null].map((v) => [v, davinci.view(s, v)] as const));
for (const [viewer, v] of views) checkViewShape(s, v, viewer);
const json = new Map([...views].map(([k, v]) => [k, JSON.stringify(v)]));
for (const owner of players)
for (const secret of davinci.secretsOf!(s, owner))
for (const [viewer, j] of json) if (viewer !== owner && j.includes(JSON.stringify(secret))) throw new Error(`leak ${secret} of ${owner} to ${viewer}`);
const actives = davinci.activePlayers(s);
const actor = actives[chooser.int(actives.length)]!;
const legal = davinci.legalActions!(s, actor);
const a = chooser.int(6) === 0 ? davinci.onTimeout(s, actor) : legal[chooser.int(legal.length)]!;
const out = davinci.apply(s, actor, a, { rng: r, now: step * 1000 });
const hiddenAfter = new Set(Object.values(out.state.rows).flatMap((row) => row.filter((t) => !t.revealed).map((t) => t.id)));
if (out.state.pending) hiddenAfter.add(out.state.pending);
for (const e of out.events) {
const ej = JSON.stringify(e);
if (out.state.phase.kind !== 'finished') for (const id of hiddenAfter) if (ej.includes(JSON.stringify(id))) throw new Error(`event leaks ${id}`);
if (e.type === 'drawn' && Object.keys(e).sort().join() !== 'color,player,type') throw new Error('drawn event leaks');
if (e.type === 'placed' && !e.revealed && 'n' in e) throw new Error('placed event leaks');
}
s = out.state;
}
}
}, 30_000);
});
// ---------------------------------------------------------------- schema
describe('options & schema', () => {
test('every option has Korean metadata; defaults match the doc', () => {
const shape = (davinci.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();
expect(davinci.defaultOptions).toEqual({
jokers: false,
tieOrder: 'black-left',
startTiles: 'auto',
chooseColors: true,
emptyPoolPenalty: 'reveal-own',
turnSeconds: 30,
deductionHelper: false,
});
expect(davinci.optionsSchema.safeParse({ startTiles: 6 }).success).toBe(false);
expect(davinci.optionsSchema.safeParse({ turnSeconds: 25 }).success).toBe(false);
});
test('client actions only (system actions are not in the schema)', () => {
expect(davinci.actionSchema.safeParse({ type: 'timeout' }).success).toBe(false);
expect(davinci.actionSchema.safeParse({ type: 'leave' }).success).toBe(false);
expect(davinci.actionSchema.safeParse({ type: 'guess', target: 'B', index: 0, value: 'joker' }).success).toBe(true);
expect(davinci.actionSchema.safeParse({ type: 'guess', target: 'B', index: 0, value: '3' }).success).toBe(false);
});
test('deductionHelper: 상대 숨은 타일에만 후보, 끄면 없음', () => {
const s = rigged({ A: ['b1', 'w4'], B: ['b0', 'w3'] }, { deductionHelper: true });
const v = davinci.view(s, 'A');
expect(v.rows.B![0]!.candidates).toEqual([0, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]);
expect(v.rows.A![0]!.candidates).toBeUndefined();
expect(davinci.view(rigged({ A: ['b1'], B: ['b0'] }), 'A').rows.B![0]!.candidates).toBeUndefined();
});
});
// ---------------------------------------------------------------- properties
describe('random playouts (docs/09 §7)', () => {
const cfg = (i: number): { players: string[]; options: DavinciOptions } => ({
players: Array.from({ length: 2 + (i % 3) }, (_, k) => `P${k}`),
options: davinci.optionsSchema.parse({
jokers: i % 2 === 0,
tieOrder: i % 7 === 0 ? 'white-left' : 'black-left',
startTiles: (['auto', 3, 4, 5] as const)[i % 4],
chooseColors: i % 5 !== 0,
emptyPoolPenalty: i % 6 === 0 ? 'none' : 'reveal-own',
deductionHelper: i % 9 === 0,
}),
});
const invariant = (st: unknown) => {
checkInvariants(st);
const s = st as DavinciState;
// onTimeout is legal for every timed player in every state, and so is the move it stands for.
for (const p of davinci.timeoutPlayers!(s)) {
const v = davinci.validate(s, p, davinci.onTimeout(s, p));
if (!v.ok) throw new Error(`onTimeout illegal: ${v.reason}`);
const c = davinci.validate(s, p, autoAction(s, p));
if (!c.ok) throw new Error(`auto action illegal: ${c.reason}`);
}
};
for (const [from, to] of [[1, 250], [251, 500], [501, 750], [751, 1000]] as const) {
test(`seeds ${from}–${to}: finish, conserve tiles, sorted rows, no leaks, onTimeout legal`, () => {
for (let i = from; i <= to; i++) {
const { players, options } = cfg(i);
const r = randomPlayout(davinci, { players, options, seed: seedN(i), maxSteps: 3000, invariant, maxApplyMs: 50 });
expect(r.finished).toBe(true);
const res = davinci.result(r.finalState as DavinciState)!;
expect(res.ranking.flat().sort()).toEqual([...players].sort());
if (i % 50 === 1) {
const b = randomPlayout(davinci, { players, options, seed: seedN(i), maxSteps: 3000, maxApplyMs: 50 });
expect(JSON.stringify(b.finalState)).toBe(JSON.stringify(r.finalState));
expect(b.trace).toEqual(r.trace);
}
}
}, 60_000);
}
});

View File

@@ -0,0 +1,801 @@
/**
* 다빈치 코드 (docs/games/davinci-code.md).
*
* Secret-id design: tile ids ('b5', 'wJ', ...) encode the number, so they only ever appear in a view
* for tiles the viewer may know (own tiles, revealed tiles, everything after the game ends). Other
* players' hidden tiles are sent as `{ color, revealed: false, wrong }` and addressed by position only.
*/
import { z } from 'zod';
import { fail, nextSeat, ok, type GameDefinition, type GameEvent, type GameResult, type PlayerId, type Rng, type Validation } from '@bg/engine';
export type TColor = 'black' | 'white';
export type TileValue = number | 'joker';
export const COLORS: TColor[] = ['black', 'white'];
export const COLOR_KO: Record<TColor, string> = { black: '검은색', white: '흰색' };
export const MAX_N = 11;
// ---------------------------------------------------------------- options
export const davinciOptionsSchema = z.object({
jokers: z.boolean().default(false).meta({ title: '조커 사용(검정·흰색 각 1개, 숫자 대신 "-")' }),
tieOrder: z
.enum(['black-left', 'white-left'])
.default('black-left')
.meta({ title: '같은 숫자일 때 색 순서', labels: { 'black-left': '검정이 왼쪽', 'white-left': '흰색이 왼쪽' } }),
startTiles: z
.union([z.literal('auto'), z.literal(3), z.literal(4), z.literal(5)])
.default('auto')
.meta({ title: '시작 타일 수', labels: { auto: '자동(2·3명 4개, 4명 3개)', 3: '3개', 4: '4개', 5: '5개' } }),
chooseColors: z.boolean().default(true).meta({ title: '타일을 가져올 때 색 고르기(끄면 무작위)' }),
emptyPoolPenalty: z
.enum(['reveal-own', 'none'])
.default('reveal-own')
.meta({ title: '더미가 빈 뒤 틀렸을 때', labels: { 'reveal-own': '내 숨은 타일 1개 공개', none: '벌칙 없음' } }),
turnSeconds: z
.union([z.literal(20), z.literal(30), z.literal(45), z.literal(60)])
.default(30)
.meta({ title: '추리 제한 시간', labels: { 20: '20초', 30: '30초', 45: '45초', 60: '60초' } }),
deductionHelper: z.boolean().default(false).meta({ title: '가능한 숫자 후보 표시(초보 도움)' }),
});
export type DavinciOptions = z.infer<typeof davinciOptionsSchema>;
// ---------------------------------------------------------------- actions
const colorSchema = z.enum(['black', 'white']);
export const davinciActionSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('chooseStart'), black: z.number().int().min(0).max(12), white: z.number().int().min(0).max(12) }),
z.object({ type: z.literal('draw'), color: colorSchema.optional() }),
// Value range / integer checks live in validate so the player gets a Korean reason.
z.object({ type: z.literal('guess'), target: z.string().max(64), index: z.number().int().min(0).max(40), value: z.union([z.number(), z.literal('joker')]) }),
z.object({ type: z.literal('continue') }),
z.object({ type: z.literal('stop') }),
z.object({ type: z.literal('placeJoker'), index: z.number().int().min(0).max(40) }),
z.object({ type: z.literal('revealOwn'), index: z.number().int().min(0).max(40) }),
]);
export type DavinciClientAction = z.infer<typeof davinciActionSchema>;
/** Client actions plus system-only actions (timer / leaving), which are not in the schema. */
export type DavinciAction = DavinciClientAction | { type: 'timeout' } | { type: 'leave' };
// ---------------------------------------------------------------- state
export interface RowTile {
id: string;
revealed: boolean;
/** Values already guessed wrong for this tile (public). */
wrong: TileValue[];
}
export type DavinciPhase =
| { kind: 'chooseStart'; picks: Record<PlayerId, { black: number; white: number } | null> }
| { kind: 'draw' }
| { kind: 'guess' }
| { kind: 'afterCorrect' }
| { kind: 'placeJoker'; reveal: boolean }
| { kind: 'revealOwn' }
| { kind: 'finished' };
export type PhaseKind = DavinciPhase['kind'];
export interface DavinciGuess {
by: PlayerId;
target: PlayerId;
/** 0-based position in the target's row at the time of the guess. */
index: number;
color: TColor;
said: TileValue;
correct: boolean;
auto: boolean;
}
export interface DavinciFinal {
ranking: PlayerId[][];
winner: PlayerId;
summary: string;
reason: 'normal' | 'resign';
}
export interface DavinciState {
options: DavinciOptions;
seats: PlayerId[];
startCount: number;
/** Hidden from everyone; drawn from the front. */
pool: { black: string[]; white: string[] };
/** Jokers wait here until the start tiles are dealt (start tiles never contain a joker). */
jokerReserve: string[];
rows: Record<PlayerId, RowTile[]>;
phase: DavinciPhase;
current: PlayerId;
/** This turn's drawn tile (only the drawer knows the number). */
pending: string | null;
alive: Record<PlayerId, boolean>;
left: Record<PlayerId, boolean>;
eliminationOrder: PlayerId[];
stats: Record<PlayerId, { guesses: number; correct: number }>;
history: DavinciGuess[];
lastPlaced: { player: PlayerId; index: number; revealed: boolean } | null;
turnNo: number;
/** Consecutive turns with a timeout; at 3 the deadline drops to 5 s. */
idle: Record<PlayerId, number>;
idleTurn: Record<PlayerId, number>;
phaseStartedAt: number;
deadlineAt: number | null;
seq: number;
final: DavinciFinal | null;
}
// ---------------------------------------------------------------- view
export interface DavinciTileView {
color: TColor;
revealed: boolean;
/** Present only when the viewer may know it (own tile, revealed, or game over). */
n?: TileValue;
id?: string;
wrong: TileValue[];
/** deductionHelper: values still possible from the viewer's information. */
candidates?: TileValue[];
}
export interface DavinciView {
options: DavinciOptions;
me: PlayerId | null;
seats: PlayerId[];
phase: PhaseKind;
current: PlayerId;
startCount: number;
rows: Record<PlayerId, DavinciTileView[]>;
pool: { black: number; white: number };
pending: { color: TColor; n?: TileValue; id?: string } | null;
/** chooseStart: who has submitted (others' choices stay secret). */
picked: Record<PlayerId, boolean> | null;
myPick: { black: number; white: number } | null;
/** chooseStart: how many of each colour I may still take. */
pickLimits: { black: number; white: number } | null;
alive: Record<PlayerId, boolean>;
left: Record<PlayerId, boolean>;
eliminationOrder: PlayerId[];
stats: Record<PlayerId, { guesses: number; correct: number }>;
history: DavinciGuess[];
lastPlaced: DavinciState['lastPlaced'];
idle: Record<PlayerId, number>;
deadlineAt: number | null;
final: DavinciFinal | null;
}
// ---------------------------------------------------------------- tiles
const CHOOSE_START_MS = 15_000;
const SHORT_MS = 10_000;
const IDLE_MS = 5_000;
const IDLE_TURNS = 3;
export function tileOf(id: string): { color: TColor; n: TileValue } {
const color: TColor = id[0] === 'b' ? 'black' : 'white';
const rest = id.slice(1);
return { color, n: rest === 'J' ? 'joker' : Number(rest) };
}
const tileId = (color: TColor, n: TileValue) => `${color[0]}${n === 'joker' ? 'J' : n}`;
export const allTileIds = (jokers: boolean): string[] => [
...COLORS.flatMap((c) => Array.from({ length: MAX_N + 1 }, (_, n) => tileId(c, n))),
...(jokers ? COLORS.map((c) => tileId(c, 'joker')) : []),
];
/** Sort key of a numbered tile (lower = further left). */
export function sortKey(color: TColor, n: number, tieOrder: DavinciOptions['tieOrder']): number {
const first: TColor = tieOrder === 'black-left' ? 'black' : 'white';
return n * 2 + (color === first ? 0 : 1);
}
/** Server-computed slot for a numbered tile: right after the rightmost numbered tile that must precede it (jokers keep their place). */
export function insertIndex(row: readonly { color: TColor; n: TileValue }[], color: TColor, n: number, tieOrder: DavinciOptions['tieOrder']): number {
const k = sortKey(color, n, tieOrder);
let at = 0;
row.forEach((t, i) => {
if (t.n !== 'joker' && sortKey(t.color, t.n, tieOrder) < k) at = i + 1;
});
return at;
}
export function startCountFor(o: DavinciOptions, players: number): number {
return o.startTiles === 'auto' ? (players >= 4 ? 3 : 4) : o.startTiles;
}
const hiddenCount = (row: readonly RowTile[]) => row.reduce((a, t) => a + (t.revealed ? 0 : 1), 0);
const aliveSeats = (s: DavinciState) => s.seats.filter((p) => s.alive[p]);
const isValue = (v: unknown, jokers: boolean): v is TileValue =>
(v === 'joker' && jokers) || (typeof v === 'number' && Number.isInteger(v) && v >= 0 && v <= MAX_N);
// ---------------------------------------------------------------- deduction (public + own info only)
/**
* Values the viewer can still consider for `rows[target][index]`, computed from a view only
* (revealed numbers, the viewer's own numbers, colours, positions and wrong guesses).
* Sound: the true value is always included. Used by the helper and by the timeout auto-guess.
*/
export function candidatesFor(
rows: Record<PlayerId, DavinciTileView[]>,
pending: DavinciView['pending'],
target: PlayerId,
index: number,
o: Pick<DavinciOptions, 'jokers' | 'tieOrder'>,
): TileValue[] {
const row = rows[target] ?? [];
const tile = row[index];
if (!tile || tile.n !== undefined) return [];
const known = new Set<string>();
for (const r of Object.values(rows)) for (const t of r) if (t.n !== undefined) known.add(tileId(t.color, t.n));
if (pending?.n !== undefined) known.add(tileId(pending.color, pending.n));
let lo = -Infinity;
let hi = Infinity;
for (let i = index - 1; i >= 0; i--) {
const t = row[i]!;
if (t.n !== undefined && t.n !== 'joker') {
lo = sortKey(t.color, t.n, o.tieOrder);
break;
}
}
for (let i = index + 1; i < row.length; i++) {
const t = row[i]!;
if (t.n !== undefined && t.n !== 'joker') {
hi = sortKey(t.color, t.n, o.tieOrder);
break;
}
}
const out: TileValue[] = [];
for (let n = 0; n <= MAX_N; n++) {
if (known.has(tileId(tile.color, n)) || tile.wrong.includes(n)) continue;
const k = sortKey(tile.color, n, o.tieOrder);
if (k > lo && k < hi) out.push(n);
}
if (o.jokers && !known.has(tileId(tile.color, 'joker')) && !tile.wrong.includes('joker')) out.push('joker');
return out;
}
// ---------------------------------------------------------------- helpers
function refreshDeadline(s: DavinciState, now: number, phaseChanged: boolean): void {
if (!phaseChanged) return;
s.phaseStartedAt = now;
let ms: number | null;
switch (s.phase.kind) {
case 'finished':
ms = null;
break;
case 'chooseStart':
ms = CHOOSE_START_MS;
break;
case 'guess':
ms = s.options.turnSeconds * 1000;
break;
default:
ms = SHORT_MS;
}
if (ms !== null && s.phase.kind !== 'chooseStart' && (s.idle[s.current] ?? 0) >= IDLE_TURNS) ms = IDLE_MS;
s.deadlineAt = ms === null ? null : now + ms;
}
function insertTile(s: DavinciState, p: PlayerId, id: string, revealed: boolean, events: GameEvent[], at?: number): void {
const row = s.rows[p]!;
const t = tileOf(id);
const index = at ?? (t.n === 'joker' ? row.length : insertIndex(row.map((r) => tileOf(r.id)), t.color, t.n, s.options.tieOrder));
row.splice(index, 0, { id, revealed, wrong: [] });
s.lastPlaced = { player: p, index, revealed };
events.push(revealed ? { type: 'placed', player: p, index, revealed, color: t.color, n: t.n } : { type: 'placed', player: p, index, revealed, color: t.color });
}
function eliminate(s: DavinciState, p: PlayerId, events: GameEvent[]): void {
if (!s.alive[p]) return;
s.alive[p] = false;
s.eliminationOrder.push(p);
events.push({ type: 'eliminated', player: p });
}
/** Ends the game if only one player keeps hidden tiles. Returns true when finished. */
function checkFinish(s: DavinciState, events: GameEvent[], byLeave = false): boolean {
const alive = aliveSeats(s);
if (alive.length > 1) return false;
if (s.pending) {
const owner = s.current;
if (s.rows[owner]) insertTile(s, owner, s.pending, !s.alive[owner], events);
s.pending = null;
}
const winner = alive[0] ?? s.eliminationOrder[s.eliminationOrder.length - 1]!;
const ranking: PlayerId[][] = [[winner], ...[...s.eliminationOrder].reverse().filter((p) => p !== winner).map((p) => [p])];
// Players who never got tiles (left during the start choice) are ranked last, in seat order.
for (const p of s.seats) if (!ranking.some((g) => g.includes(p))) ranking.push([p]);
const left = hiddenCount(s.rows[winner] ?? []);
const others = s.seats.length - 1;
const reason: DavinciFinal['reason'] = byLeave ? 'resign' : 'normal';
const st = s.stats[winner]!;
const summary =
reason === 'resign'
? `마지막 상대가 나가서 게임이 끝났어요. (추리 ${st.guesses}번 중 ${st.correct}번 맞힘)`
: `숨은 타일 ${left}개가 끝까지 풀리지 않아 이겼어요! (상대 ${others}명 탈락 · 추리 ${st.guesses}번 중 ${st.correct}번 맞힘)`;
s.final = { ranking, winner, summary, reason };
s.phase = { kind: 'finished' };
events.push({ type: 'gameEnded', winner });
return true;
}
function endTurn(s: DavinciState, events: GameEvent[]): void {
s.pending = null;
if (checkFinish(s, events)) return;
const i = s.seats.indexOf(s.current);
s.current = s.seats[nextSeat(i, s.seats.length, (j) => !s.alive[s.seats[j]!])]!;
s.turnNo += 1;
s.phase = s.pool.black.length + s.pool.white.length > 0 ? { kind: 'draw' } : { kind: 'guess' };
events.push({ type: 'turn', player: s.current });
}
/** After the start tiles are dealt: sort rows, shuffle the jokers into the pool, start the first turn. */
function afterDeal(s: DavinciState, rng: Rng, events: GameEvent[]): void {
for (const p of s.seats) {
s.rows[p]!.sort((a, b) => {
const x = tileOf(a.id);
const y = tileOf(b.id);
return sortKey(x.color, x.n as number, s.options.tieOrder) - sortKey(y.color, y.n as number, s.options.tieOrder);
});
}
for (const j of s.jokerReserve) {
const pile = s.pool[tileOf(j).color];
pile.splice(rng.int(pile.length + 1), 0, j);
}
s.jokerReserve = [];
if (!s.alive[s.current]) s.current = s.seats[nextSeat(s.seats.indexOf(s.current), s.seats.length, (j) => !s.alive[s.seats[j]!])]!;
s.phase = { kind: 'draw' };
events.push({ type: 'dealt', counts: Object.fromEntries(s.seats.map((p) => [p, s.rows[p]!.length])) });
events.push({ type: 'turn', player: s.current });
}
/** chooseStart: once every remaining player has chosen, deal from the front of each colour pile. */
function dealIfReady(s: DavinciState, rng: Rng, events: GameEvent[]): void {
const ph = s.phase;
if (ph.kind !== 'chooseStart' || !s.seats.every((p) => !s.alive[p] || ph.picks[p])) return;
for (const p of s.seats) {
const pick = ph.picks[p];
if (!pick || !s.alive[p]) continue;
const ids = [...s.pool.black.splice(0, pick.black), ...s.pool.white.splice(0, pick.white)];
s.rows[p] = ids.map((id) => ({ id, revealed: false, wrong: [] }));
}
afterDeal(s, rng, events);
}
function pickLimits(s: DavinciState): { black: number; white: number } {
const lim = { black: s.pool.black.length, white: s.pool.white.length };
if (s.phase.kind === 'chooseStart') {
for (const pick of Object.values(s.phase.picks)) {
if (!pick) continue;
lim.black -= pick.black;
lim.white -= pick.white;
}
}
return lim;
}
/** Timeout choice for the start: as even as possible (odd → one more black), within what is left. */
export function autoPick(s: DavinciState): { black: number; white: number } {
const n = s.startCount;
const lim = pickLimits(s);
let black = Math.min(Math.ceil(n / 2), lim.black);
let white = n - black;
if (white > lim.white) {
white = lim.white;
black = n - white;
}
return { black, white };
}
/**
* Deterministic auto-guess (§8.5) from the player's own view only: the living opponent with the fewest
* hidden tiles (ties: clockwise from me), their leftmost hidden tile, the smallest possible value.
*/
export function autoGuess(s: DavinciState, player: PlayerId): { type: 'guess'; target: PlayerId; index: number; value: TileValue } {
const v = davinci.view(s, player);
const me = v.seats.indexOf(player);
const order = v.seats.map((_, k) => v.seats[(me + 1 + k) % v.seats.length]!).filter((p) => p !== player && v.alive[p]);
let target = order[0]!;
let best = Infinity;
for (const p of order) {
const h = v.rows[p]!.filter((t) => !t.revealed).length;
if (h > 0 && h < best) {
best = h;
target = p;
}
}
const index = v.rows[target]!.findIndex((t) => !t.revealed);
const cands = candidatesFor(v.rows, v.pending, target, index, v.options);
const nums = cands.filter((c): c is number => c !== 'joker');
const value: TileValue = nums.length ? Math.min(...nums) : cands.length ? 'joker' : 0;
return { type: 'guess', target, index, value };
}
/** The concrete action a timeout stands for in the current state (docs §8.5). */
export function autoAction(s: DavinciState, player: PlayerId): DavinciClientAction {
switch (s.phase.kind) {
case 'chooseStart':
return { type: 'chooseStart', ...autoPick(s) };
case 'draw':
if (!s.options.chooseColors) return { type: 'draw' };
return { type: 'draw', color: s.pool.white.length > s.pool.black.length ? 'white' : 'black' };
case 'guess':
return autoGuess(s, player);
case 'afterCorrect':
return { type: 'stop' };
case 'placeJoker':
return { type: 'placeJoker', index: s.rows[player]!.length };
case 'revealOwn':
return { type: 'revealOwn', index: s.rows[player]!.findIndex((t) => !t.revealed) };
case 'finished':
throw new Error('davinci: no auto action after the game');
}
}
function waiting(s: DavinciState): PlayerId[] {
if (s.phase.kind === 'finished') return [];
if (s.phase.kind === 'chooseStart') {
const picks = s.phase.picks;
return s.seats.filter((p) => s.alive[p] && !picks[p]);
}
return [s.current];
}
function validateClient(s: DavinciState, actor: PlayerId, a: DavinciClientAction): Validation {
const o = s.options;
const ph = s.phase;
if (a.type === 'chooseStart') {
if (ph.kind !== 'chooseStart') return fail('지금은 시작 타일을 고를 때가 아니에요.');
if (ph.picks[actor]) return fail('이미 골랐어요.');
if (a.black + a.white !== s.startCount) return fail(`시작 타일 ${s.startCount}개를 골라 주세요.`);
const lim = pickLimits(s);
if (a.black > lim.black) return fail(lim.black === 0 ? '검은색 타일이 더 이상 없어요.' : `검은색은 ${lim.black}개까지 고를 수 있어요.`);
if (a.white > lim.white) return fail(lim.white === 0 ? '흰색 타일이 더 이상 없어요.' : `흰색은 ${lim.white}개까지 고를 수 있어요.`);
return ok;
}
if (ph.kind === 'chooseStart') return fail('먼저 시작 타일을 골라 주세요.');
if (s.current !== actor) return fail('지금은 내 차례가 아니에요.');
switch (a.type) {
case 'draw': {
if (ph.kind !== 'draw') return fail('지금은 타일을 가져올 때가 아니에요.');
if (!o.chooseColors) return a.color ? fail('이 방은 색을 고르지 않고 무작위로 가져와요.') : ok;
if (!a.color) return fail('가져올 색을 골라 주세요.');
if (s.pool[a.color].length === 0) return fail(`${COLOR_KO[a.color]} 타일이 더 이상 없어요.`);
return ok;
}
case 'guess': {
if (ph.kind !== 'guess') return fail('지금은 추리할 때가 아니에요.');
if (a.target === actor) return fail('자기 타일은 고를 수 없어요.');
if (!s.seats.includes(a.target)) return fail('이 게임의 플레이어가 아니에요.');
if (!s.alive[a.target]) return fail('이미 탈락한 사람의 타일이에요.');
const t = s.rows[a.target]![a.index];
if (!t) return fail('그 자리에는 타일이 없어요.');
if (t.revealed) return fail('이미 공개된 타일이에요.');
if (a.value === 'joker' && !o.jokers) return fail('이 방은 조커를 쓰지 않아요.');
if (!isValue(a.value, o.jokers)) return fail('0부터 11 사이 숫자를 골라 주세요.');
return ok;
}
case 'continue':
if (ph.kind !== 'afterCorrect') return fail('맞힌 뒤에만 계속할 수 있어요.');
return aliveSeats(s).some((p) => p !== actor) ? ok : fail('남은 상대가 없어요.');
case 'stop':
return ph.kind === 'afterCorrect' ? ok : fail('맞힌 뒤에만 멈출 수 있어요.');
case 'placeJoker':
if (ph.kind !== 'placeJoker') return fail('지금은 조커를 놓을 때가 아니에요.');
return a.index >= 0 && a.index <= s.rows[actor]!.length ? ok : fail('그 자리에는 놓을 수 없어요.');
case 'revealOwn': {
if (ph.kind !== 'revealOwn') return fail('지금은 내 타일을 공개할 때가 아니에요.');
const t = s.rows[actor]![a.index];
if (!t) return fail('그 자리에는 타일이 없어요.');
return t.revealed ? fail('이미 공개된 타일이에요.') : ok;
}
}
}
function applyClient(s: DavinciState, actor: PlayerId, a: DavinciClientAction, rng: Rng, events: GameEvent[], auto: boolean): void {
switch (a.type) {
case 'chooseStart': {
const ph = s.phase as Extract<DavinciPhase, { kind: 'chooseStart' }>;
ph.picks[actor] = { black: a.black, white: a.white };
events.push({ type: 'picked', player: actor });
dealIfReady(s, rng, events);
break;
}
case 'draw': {
let color = a.color;
if (!color) {
const nb = s.pool.black.length;
color = rng.int(nb + s.pool.white.length) < nb ? 'black' : 'white';
}
s.pending = s.pool[color].shift()!;
s.phase = { kind: 'guess' };
events.push({ type: 'drawn', player: actor, color });
break;
}
case 'guess': {
const t = s.rows[a.target]![a.index]!;
const real = tileOf(t.id);
const correct = real.n === a.value;
s.stats[actor]!.guesses += 1;
s.history.push({ by: actor, target: a.target, index: a.index, color: real.color, said: a.value, correct, auto });
events.push({ type: 'guessed', by: actor, target: a.target, index: a.index, value: a.value, correct });
if (correct) {
t.revealed = true;
s.stats[actor]!.correct += 1;
events.push({ type: 'revealed', player: a.target, index: a.index, color: real.color, n: real.n });
if (hiddenCount(s.rows[a.target]!) === 0) eliminate(s, a.target, events);
if (!checkFinish(s, events)) s.phase = { kind: 'afterCorrect' };
} else {
t.wrong.push(a.value);
if (s.pending) {
if (tileOf(s.pending).n === 'joker') s.phase = { kind: 'placeJoker', reveal: true };
else {
insertTile(s, actor, s.pending, true, events);
endTurn(s, events);
}
} else if (s.options.emptyPoolPenalty === 'reveal-own') s.phase = { kind: 'revealOwn' };
else endTurn(s, events);
}
break;
}
case 'continue':
s.phase = { kind: 'guess' };
break;
case 'stop':
if (s.pending && tileOf(s.pending).n === 'joker') s.phase = { kind: 'placeJoker', reveal: false };
else {
if (s.pending) insertTile(s, actor, s.pending, false, events);
endTurn(s, events);
}
break;
case 'placeJoker': {
const reveal = (s.phase as Extract<DavinciPhase, { kind: 'placeJoker' }>).reveal;
insertTile(s, actor, s.pending!, reveal, events, a.index);
endTurn(s, events);
break;
}
case 'revealOwn': {
const t = s.rows[actor]![a.index]!;
t.revealed = true;
const real = tileOf(t.id);
events.push({ type: 'revealed', player: actor, index: a.index, color: real.color, n: real.n, own: true });
if (hiddenCount(s.rows[actor]!) === 0) eliminate(s, actor, events);
endTurn(s, events);
break;
}
}
}
function applyLeave(s: DavinciState, actor: PlayerId, rng: Rng, events: GameEvent[]): void {
s.left[actor] = true;
events.push({ type: 'left', player: actor });
if (!s.alive[actor]) return;
if (s.phase.kind === 'chooseStart') {
// Leaving before the deal: out without tiles; the others are dealt as soon as they have all chosen.
delete s.phase.picks[actor];
eliminate(s, actor, events);
if (!checkFinish(s, events, true)) dealIfReady(s, rng, events);
return;
}
// Leaving in play: the leaver is out and their tiles are opened.
for (const t of s.rows[actor]!) t.revealed = true;
if (s.current === actor && s.pending) {
insertTile(s, actor, s.pending, true, events);
s.pending = null;
}
eliminate(s, actor, events);
if (checkFinish(s, events, true)) return;
if (s.current === actor) endTurn(s, events);
}
// ---------------------------------------------------------------- definition
export const davinci: GameDefinition<DavinciState, DavinciAction, DavinciView, DavinciOptions> = {
id: 'davinci-code',
nameKo: '다빈치 코드',
minPlayers: 2,
maxPlayers: 4,
stateVersion: 1,
defaultOptions: davinciOptionsSchema.parse({}),
optionsSchema: davinciOptionsSchema as unknown as z.ZodType<DavinciOptions>,
actionSchema: davinciActionSchema as unknown as z.ZodType<DavinciAction>,
setup({ players, options, rng, now }) {
if (players.length < 2 || players.length > 4) throw new Error(`davinci-code: ${players.length} players not allowed`);
const startCount = startCountFor(options, players.length);
const per = <T>(f: (p: PlayerId) => T) => Object.fromEntries(players.map((p) => [p, f(p)])) as Record<PlayerId, T>;
const first = players[rng.int(players.length)]!;
const numbered = allTileIds(false);
const jokers = options.jokers ? COLORS.map((c) => tileId(c, 'joker')) : [];
const s: DavinciState = {
options,
seats: [...players],
startCount,
pool: { black: [], white: [] },
jokerReserve: jokers,
rows: per(() => []),
phase: { kind: 'chooseStart', picks: per(() => null) },
current: first,
pending: null,
alive: per(() => true),
left: per(() => false),
eliminationOrder: [],
stats: per(() => ({ guesses: 0, correct: 0 })),
history: [],
lastPlaced: null,
turnNo: 0,
idle: per(() => 0),
idleTurn: per(() => -1),
phaseStartedAt: now,
deadlineAt: null,
seq: 0,
final: null,
};
const events: GameEvent[] = [];
if (options.chooseColors) {
for (const c of COLORS) s.pool[c] = rng.shuffle(numbered.filter((id) => tileOf(id).color === c));
} else {
const deck = rng.shuffle(numbered);
for (const p of players) s.rows[p] = deck.splice(0, startCount).map((id) => ({ id, revealed: false, wrong: [] }));
for (const c of COLORS) s.pool[c] = deck.filter((id) => tileOf(id).color === c);
afterDeal(s, rng, events);
}
refreshDeadline(s, now, true);
return s;
},
validate(s, actor, a) {
if (!s.seats.includes(actor)) return fail('이 게임의 플레이어가 아니에요.');
if (s.phase.kind === 'finished') return fail('게임이 이미 끝났어요.');
if (a.type === 'leave') return s.left[actor] ? fail('이미 나간 플레이어예요.') : ok;
if (!s.alive[actor]) return fail('탈락해서 더 할 수 없어요.');
if (a.type === 'timeout') return waiting(s).includes(actor) ? ok : fail('시간 초과 대상이 아니에요.');
return validateClient(s, actor, a);
},
apply(prev, actor, a, { rng, now }) {
const s = structuredClone(prev);
const events: GameEvent[] = [];
const before = s.phase.kind;
if (a.type === 'leave') applyLeave(s, actor, rng, events);
else if (a.type === 'timeout') {
const concrete = autoAction(s, actor);
const v = validateClient(s, actor, concrete);
if (!v.ok) throw new Error(`davinci: auto action rejected: ${v.reason}`);
if (s.phase.kind !== 'chooseStart' && s.idleTurn[actor] !== s.turnNo) {
s.idleTurn[actor] = s.turnNo;
s.idle[actor] = (s.idle[actor] ?? 0) + 1;
}
events.push({ type: 'timeout', player: actor });
applyClient(s, actor, concrete, rng, events, true);
} else {
s.idle[actor] = 0;
applyClient(s, actor, a, rng, events, false);
}
s.seq += 1;
// The start choice has one shared deadline; every other step restarts the clock.
refreshDeadline(s, now, !(before === 'chooseStart' && s.phase.kind === 'chooseStart'));
return { state: s, events };
},
view(s, viewer) {
const me = viewer && s.seats.includes(viewer) ? viewer : null;
const over = s.phase.kind === 'finished';
const rows: Record<PlayerId, DavinciTileView[]> = {};
for (const p of s.seats) {
rows[p] = s.rows[p]!.map((t) => {
const known = over || t.revealed || p === me;
if (!known) return { color: tileOf(t.id).color, revealed: false, wrong: [...t.wrong] };
const real = tileOf(t.id);
return { color: real.color, revealed: t.revealed, n: real.n, id: t.id, wrong: [...t.wrong] };
});
}
let pending: DavinciView['pending'] = null;
if (s.pending) {
const real = tileOf(s.pending);
pending = s.current === me ? { color: real.color, n: real.n, id: s.pending } : { color: real.color };
}
if (s.options.deductionHelper && !over) {
for (const p of s.seats) {
if (p === me) continue;
rows[p]!.forEach((t, i) => {
if (t.n === undefined) t.candidates = candidatesFor(rows, pending, p, i, s.options);
});
}
}
const ph = s.phase;
const choosing = ph.kind === 'chooseStart';
return {
options: s.options,
me,
seats: s.seats,
phase: ph.kind,
current: s.current,
startCount: s.startCount,
rows,
pool: { black: s.pool.black.length, white: s.pool.white.length },
pending,
picked: choosing ? Object.fromEntries(s.seats.map((p) => [p, !!ph.picks[p]])) : null,
myPick: choosing && me ? (ph.picks[me] ?? null) : null,
pickLimits: choosing ? pickLimits(s) : null,
alive: s.alive,
left: s.left,
eliminationOrder: s.eliminationOrder,
stats: s.stats,
history: s.history,
lastPlaced: s.lastPlaced,
idle: s.idle,
deadlineAt: s.deadlineAt,
final: s.final,
};
},
activePlayers: waiting,
deadline(s) {
return s.phase.kind === 'finished' ? null : s.deadlineAt;
},
timeoutPlayers: waiting,
onTimeout() {
return { type: 'timeout' };
},
onLeave(s, player) {
if (!s.seats.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,
summary: s.final.summary,
reason: s.final.reason,
};
},
legalActions(s, p) {
if (!s.seats.includes(p) || !s.alive[p] || s.phase.kind === 'finished') return [];
const out: DavinciAction[] = [];
const ph = s.phase;
if (ph.kind === 'chooseStart') {
if (ph.picks[p]) return [];
const lim = pickLimits(s);
for (let b = 0; b <= s.startCount; b++) {
const w = s.startCount - b;
if (b <= lim.black && w <= lim.white) out.push({ type: 'chooseStart', black: b, white: w });
}
return out;
}
if (s.current !== p) return [];
switch (ph.kind) {
case 'draw':
if (!s.options.chooseColors) return [{ type: 'draw' }];
return COLORS.filter((c) => s.pool[c].length > 0).map((color) => ({ type: 'draw', color }));
case 'guess': {
const values: TileValue[] = [...Array.from({ length: MAX_N + 1 }, (_, n) => n), ...(s.options.jokers ? ['joker' as const] : [])];
for (const target of s.seats) {
if (target === p || !s.alive[target]) continue;
s.rows[target]!.forEach((t, index) => {
if (!t.revealed) for (const value of values) out.push({ type: 'guess', target, index, value });
});
}
return out;
}
case 'afterCorrect':
return [{ type: 'continue' }, { type: 'stop' }];
case 'placeJoker':
return Array.from({ length: s.rows[p]!.length + 1 }, (_, index) => ({ type: 'placeJoker', index }));
case 'revealOwn':
return s.rows[p]!.flatMap((t, index) => (t.revealed ? [] : [{ type: 'revealOwn' as const, index }]));
}
},
/** Ids of `player`'s hidden tiles and pending tile, plus the pool (secret to everyone). */
secretsOf(s, player) {
const pool = [...s.pool.black, ...s.pool.white, ...s.jokerReserve];
if (s.phase.kind === 'finished') return pool;
const own = (s.rows[player] ?? []).filter((t) => !t.revealed).map((t) => t.id);
if (s.current === player && s.pending) own.push(s.pending);
return [...own, ...pool];
},
};

View File

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