Merge branch 'm4/davinci-code' into codex/owner/grp_discord_joke-app
# Conflicts: # apps/web/src/games/registry.ts # apps/web/src/lib/catalog-ui.ts # e2e/games.e2e.ts # packages/games/src/index.ts # packages/shared/src/catalog.ts
This commit is contained in:
634
packages/games/src/davinci-code/davinci-code.test.ts
Normal file
634
packages/games/src/davinci-code/davinci-code.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
801
packages/games/src/davinci-code/index.ts
Normal file
801
packages/games/src/davinci-code/index.ts
Normal 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];
|
||||
},
|
||||
};
|
||||
@@ -10,6 +10,7 @@ import { yacht } from './yacht';
|
||||
import { yut } from './yut';
|
||||
import { oneCard } from './one-card';
|
||||
import { rummikub } from './rummikub';
|
||||
import { davinci } from './davinci-code';
|
||||
|
||||
export const GAMES: Record<string, AnyGameDefinition> = {
|
||||
[omok.id]: omok,
|
||||
@@ -23,4 +24,5 @@ export const GAMES: Record<string, AnyGameDefinition> = {
|
||||
[yut.id]: yut,
|
||||
[oneCard.id]: oneCard,
|
||||
[rummikub.id]: rummikub,
|
||||
[davinci.id]: davinci,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user