M3: 섯다·포커 규칙과 화면, 카드 게임 E2E
- 섯다(packages/games/seotda): 2장·3장 섯다, 1장씩/한 번에 나눠 주기, 족보(광땡·땡·알리…망통), 땡잡이·암행어사·구사/멍텅구리구사 재경기, 동점 재경기·나눔, 리바이·탈락, 사이드 팟. 규칙 테스트 54개 - 포커(packages/games/poker): 홀덤·오마하·7포커(초이스·보스)·7포커 하이로우(동시 선언)·바둑이(아침·점심·저녁)· 5카드 드로우·인디언 포커(이마 카드, 동점 이월, 다이 벌칙). 한국식/노리밋/팟리밋, 블라인드·앤티, 리바이·레벨 상승·최후 1인. 규칙 테스트 52개 + 족보 판정 24개, 무작위 1,000판(칩 보존·비공개 카드 유출 없음) - 웹: 섯다·포커 화면(테이블 좌석, 공용 카드, 사이드 팟, 한국식 버튼/레이즈 슬라이더, 초이스·교환·선언, 체크 가능할 때 포기 확인, 리바이 안내), 섯다 결과 화면에서 가져간 칩 표시 - 엔진 betting.buildPots: 라운드 진행 중에는 아직 콜할 수 있는 플레이어도 각 팟 자격자로 표시 - 서버 테스트: 크래시 흉내 후 소켓 종료로 다시 걸린 타이머가 닫힌 DB를 건드리던 누수 수정 - E2E: 3인(PC + 휴대폰 2) 섯다·7포커 한 판, 6개 게임 모두 통과 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
197
packages/games/src/seotda/hands.ts
Normal file
197
packages/games/src/seotda/hands.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* 섯다 족보와 쇼다운 판정 (docs/games/seotda.md §6.1–6.3). Pure functions, table-tested.
|
||||
*
|
||||
* Card ids are the shared HWATU ids from SEOTDA_DECK (packages/engine/src/cards.ts):
|
||||
* month m (1..10) → special card id (m-1)*4, plain card id (m-1)*4+1.
|
||||
*/
|
||||
|
||||
export type SpecialKind = 'ttaengjabi' | 'amhaeng' | 'gusa' | 'mungGusa';
|
||||
|
||||
export interface HandRules {
|
||||
gwangTtaeng: 'all' | 'only38';
|
||||
ttaengjabi: 'off' | 'strict' | 'loose';
|
||||
amhaeng: boolean;
|
||||
gusa: boolean;
|
||||
mungGusa: 'off' | 'upTo8' | 'upTo9' | 'upToJang';
|
||||
}
|
||||
|
||||
export interface HandEval {
|
||||
/** Comparison value of the normal hand (special hands keep their normal value). */
|
||||
value: number;
|
||||
/** Display name: the special name when a special is active, otherwise the normal name. */
|
||||
name: string;
|
||||
/** The normal name ("3끗", "알리", …). */
|
||||
baseName: string;
|
||||
special: SpecialKind | null;
|
||||
}
|
||||
|
||||
export const monthOf = (id: number): number => Math.floor(id / 4) + 1;
|
||||
/** Slot 0 of each month: 광 for 1·3·8월, 열끗 for the others. */
|
||||
export const isSpecialCard = (id: number): boolean => id % 4 === 0;
|
||||
export const isGwang = (id: number): boolean => isSpecialCard(id) && [1, 3, 8].includes(monthOf(id));
|
||||
/** 열끗 of a month (4·7·9월 matter for special hands). */
|
||||
export const isYeol = (id: number, month: number): boolean => isSpecialCard(id) && monthOf(id) === month;
|
||||
|
||||
export const SPECIAL_NAME: Record<SpecialKind, string> = {
|
||||
ttaengjabi: '땡잡이',
|
||||
amhaeng: '암행어사',
|
||||
gusa: '구사',
|
||||
mungGusa: '멍텅구리구사',
|
||||
};
|
||||
|
||||
const PAIRS: [number, number, number, string][] = [
|
||||
[1, 2, 806, '알리'],
|
||||
[1, 4, 805, '독사'],
|
||||
[1, 9, 804, '구삥'],
|
||||
[1, 10, 803, '장삥'],
|
||||
[4, 10, 802, '장사'],
|
||||
[4, 6, 801, '세륙'],
|
||||
];
|
||||
|
||||
function normal(a: number, b: number, rules: HandRules): { value: number; name: string } {
|
||||
const ma = monthOf(a);
|
||||
const mb = monthOf(b);
|
||||
if (isGwang(a) && isGwang(b)) {
|
||||
const set = new Set([ma, mb]);
|
||||
if (set.has(3) && set.has(8)) return { value: 1000, name: '38광땡' };
|
||||
if (rules.gwangTtaeng === 'all') {
|
||||
if (set.has(1) && set.has(8)) return { value: 990, name: '18광땡' };
|
||||
if (set.has(1) && set.has(3)) return { value: 980, name: '13광땡' };
|
||||
}
|
||||
}
|
||||
if (ma === mb) {
|
||||
if (ma === 10) return { value: 910, name: '장땡' };
|
||||
if (ma === 1) return { value: 901, name: '삥땡' };
|
||||
return { value: 900 + ma, name: `${ma}땡` };
|
||||
}
|
||||
const lo = Math.min(ma, mb);
|
||||
const hi = Math.max(ma, mb);
|
||||
for (const [x, y, v, n] of PAIRS) if (lo === x && hi === y) return { value: v, name: n };
|
||||
const k = (ma + mb) % 10;
|
||||
if (k === 9) return { value: 9, name: '갑오' };
|
||||
if (k === 0) return { value: 0, name: '망통' };
|
||||
return { value: k, name: `${k}끗` };
|
||||
}
|
||||
|
||||
function specialOf(a: number, b: number, rules: HandRules): SpecialKind | null {
|
||||
const months = new Set([monthOf(a), monthOf(b)]);
|
||||
const has = (m: number) => months.has(m);
|
||||
const yeol = (m: number) => isYeol(a, m) || isYeol(b, m);
|
||||
if (has(3) && has(7)) {
|
||||
if (rules.ttaengjabi === 'loose') return 'ttaengjabi';
|
||||
if (rules.ttaengjabi === 'strict' && isGwang(a) !== isGwang(b) && (isGwang(a) ? monthOf(a) : monthOf(b)) === 3 && yeol(7)) return 'ttaengjabi';
|
||||
}
|
||||
if (rules.amhaeng && has(4) && has(7) && yeol(4) && yeol(7)) return 'amhaeng';
|
||||
if (rules.gusa && has(4) && has(9)) {
|
||||
if (rules.mungGusa !== 'off' && yeol(4) && yeol(9)) return 'mungGusa';
|
||||
return 'gusa';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 족보 of two cards (order-independent). */
|
||||
export function evalSeotda(a: number, b: number, rules: HandRules): HandEval {
|
||||
const n = normal(a, b, rules);
|
||||
const special = specialOf(a, b, rules);
|
||||
return { value: n.value, baseName: n.name, name: special ? SPECIAL_NAME[special] : n.name, special };
|
||||
}
|
||||
|
||||
/** 구사 재경기 기준값 (§6.2): the best other normal hand must be ≤ this. */
|
||||
export function gusaThreshold(special: SpecialKind | null, rules: HandRules): number | null {
|
||||
if (special === 'gusa') return 806;
|
||||
if (special === 'mungGusa') return rules.mungGusa === 'upTo9' ? 909 : rules.mungGusa === 'upToJang' ? 910 : 908;
|
||||
return null;
|
||||
}
|
||||
|
||||
export type Resolution =
|
||||
| { type: 'win'; winners: string[]; reason: 'normal' | 'ttaengjabi' | 'amhaeng' }
|
||||
| { type: 'split'; winners: string[] }
|
||||
| { type: 'rematch'; reason: 'gusa' | 'mungGusa' | 'tie'; participants: string[] };
|
||||
|
||||
/**
|
||||
* Showdown judgement for one pot (§6.3). `players` are the pot's eligible contenders in
|
||||
* seat order from the dealer (선) — split winners keep that order for odd chips.
|
||||
*/
|
||||
export function resolve(players: { id: string; hand: HandEval }[], tieRule: 'rematch' | 'split', rules: HandRules): Resolution {
|
||||
const vmax = Math.max(...players.map((p) => p.hand.value));
|
||||
const holders = (k: SpecialKind) => players.filter((p) => p.hand.special === k).map((p) => p.id);
|
||||
let W: string[];
|
||||
let reason: 'normal' | 'ttaengjabi' | 'amhaeng' = 'normal';
|
||||
if (holders('ttaengjabi').length > 0 && vmax >= 901 && vmax <= 909) {
|
||||
W = holders('ttaengjabi');
|
||||
reason = 'ttaengjabi';
|
||||
} else if (holders('amhaeng').length > 0 && (vmax === 980 || vmax === 990)) {
|
||||
W = holders('amhaeng');
|
||||
reason = 'amhaeng';
|
||||
} else {
|
||||
W = players.filter((p) => p.hand.value === vmax).map((p) => p.id);
|
||||
}
|
||||
// 구사 재경기: a 구사 holder who does not already win, with every other hand ≤ its threshold.
|
||||
let gusa: 'gusa' | 'mungGusa' | null = null;
|
||||
for (const h of players) {
|
||||
if (h.hand.special !== 'gusa' && h.hand.special !== 'mungGusa') continue;
|
||||
if (W.includes(h.id)) continue;
|
||||
const t = gusaThreshold(h.hand.special, rules);
|
||||
const best = Math.max(...players.filter((q) => q.id !== h.id).map((q) => q.hand.value));
|
||||
if (t !== null && best <= t) gusa = gusa === 'mungGusa' ? gusa : h.hand.special;
|
||||
}
|
||||
if (gusa) return { type: 'rematch', reason: gusa, participants: players.map((p) => p.id) };
|
||||
if (W.length === 1) return { type: 'win', winners: W, reason };
|
||||
return tieRule === 'rematch' ? { type: 'rematch', reason: 'tie', participants: W } : { type: 'split', winners: W };
|
||||
}
|
||||
|
||||
/** The three 2-card combinations of a 3-card hand. */
|
||||
export function combos(hand: number[]): [number, number][] {
|
||||
const out: [number, number][] = [];
|
||||
for (let i = 0; i < hand.length; i++) for (let j = i + 1; j < hand.length; j++) out.push([hand[i]!, hand[j]!]);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Auto choice (§8.5): highest normal value, then a special combo, then the smaller id sum. */
|
||||
export function bestChoice(hand: number[], rules: HandRules): [number, number] {
|
||||
let best: [number, number] | null = null;
|
||||
let bestEval: HandEval | null = null;
|
||||
for (const c of combos(hand)) {
|
||||
const e = evalSeotda(c[0], c[1], rules);
|
||||
if (!best || !bestEval) {
|
||||
best = c;
|
||||
bestEval = e;
|
||||
continue;
|
||||
}
|
||||
const better =
|
||||
e.value > bestEval.value ||
|
||||
(e.value === bestEval.value && !!e.special && !bestEval.special) ||
|
||||
(e.value === bestEval.value && !!e.special === !!bestEval.special && c[0] + c[1] < best[0] + best[1]);
|
||||
if (better) {
|
||||
best = c;
|
||||
bestEval = e;
|
||||
}
|
||||
}
|
||||
return [Math.min(...best!), Math.max(...best!)] as [number, number];
|
||||
}
|
||||
|
||||
/** Rough strength for beginner hints (§9). */
|
||||
export function strengthOf(e: HandEval): 'high' | 'mid' | 'low' {
|
||||
if (e.value >= 801) return 'high';
|
||||
if (e.value >= 5 || e.special === 'gusa' || e.special === 'mungGusa' || e.special === 'ttaengjabi' || e.special === 'amhaeng') return 'mid';
|
||||
return 'low';
|
||||
}
|
||||
|
||||
/** Beginner sentence for my current hand (hints option). */
|
||||
export function hintText(e: HandEval, rules: HandRules): string {
|
||||
const lvl = { high: '아주 센 패예요.', mid: '중간 정도 패예요.', low: '약한 패예요.' }[strengthOf(e)];
|
||||
switch (e.special) {
|
||||
case 'ttaengjabi':
|
||||
return '땡잡이예요. 상대 최고 패가 1땡~9땡이면 이겨요. 아니면 망통이에요.';
|
||||
case 'amhaeng':
|
||||
return '암행어사예요. 상대 최고 패가 13광땡이나 18광땡이면 이겨요. 아니면 1끗이에요.';
|
||||
case 'gusa':
|
||||
return '구사예요. 상대가 알리 이하면 재경기예요. 아니면 3끗이에요.';
|
||||
case 'mungGusa': {
|
||||
const upTo = rules.mungGusa === 'upTo9' ? '9땡' : rules.mungGusa === 'upToJang' ? '장땡' : '8땡';
|
||||
return `멍텅구리구사예요. 상대가 ${upTo} 이하면 재경기예요. 아니면 3끗이에요.`;
|
||||
}
|
||||
default:
|
||||
return `지금 패는 ${e.name}이에요. ${lvl}`;
|
||||
}
|
||||
}
|
||||
1259
packages/games/src/seotda/index.ts
Normal file
1259
packages/games/src/seotda/index.ts
Normal file
File diff suppressed because it is too large
Load Diff
703
packages/games/src/seotda/seotda.test.ts
Normal file
703
packages/games/src/seotda/seotda.test.ts
Normal file
@@ -0,0 +1,703 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { HWATU, SEOTDA_DECK, SeededRng, randomPlayout, seedN, type GameEvent } from '@bg/engine';
|
||||
import {
|
||||
bestChoice,
|
||||
chipsTotal,
|
||||
evalSeotda,
|
||||
resolve,
|
||||
seotda,
|
||||
type HandEval,
|
||||
type HandRules,
|
||||
type SeotdaAction,
|
||||
type SeotdaOptions,
|
||||
type SeotdaState,
|
||||
type SpecialKind,
|
||||
} from './index';
|
||||
|
||||
// ---------------------------------------------------------------- helpers
|
||||
|
||||
/** Special card of a month (1·3·8 광, others 열끗) and the plain card. */
|
||||
const S = (m: number) => (m - 1) * 4;
|
||||
const P = (m: number) => (m - 1) * 4 + 1;
|
||||
const DEFAULT_RULES: HandRules = { gwangTtaeng: 'all', ttaengjabi: 'strict', amhaeng: true, gusa: true, mungGusa: 'upTo8' };
|
||||
const ev = (a: number, b: number, r: Partial<HandRules> = {}) => evalSeotda(a, b, { ...DEFAULT_RULES, ...r });
|
||||
|
||||
let clock = 1000;
|
||||
const rng = () => SeededRng.fromSeed([7, 7, 7, 7]);
|
||||
|
||||
/** Fresh game whose first dealer is `dealer` (seat order = players). */
|
||||
function make(opts: Partial<SeotdaOptions> = {}, players = ['A', 'B', 'C'], dealer = 'A'): SeotdaState {
|
||||
const options = seotda.optionsSchema.parse(opts);
|
||||
for (let seed = 1; seed < 500; seed++) {
|
||||
const s = seotda.setup({ players, options, rng: SeededRng.fromSeed(seedN(seed)), now: 0 });
|
||||
if (s.hand!.dealer === dealer) return s;
|
||||
}
|
||||
throw new Error('no seed with that dealer');
|
||||
}
|
||||
|
||||
/** Replaces the dealt cards; `next` are the next cards off the deck (in dealing order). */
|
||||
function rig(s: SeotdaState, cards: Record<string, number[]>, next: number[] = []): SeotdaState {
|
||||
const h = s.hand!;
|
||||
for (const [id, cs] of Object.entries(cards)) h.cards[id] = [...cs];
|
||||
const used = new Set([...Object.values(h.cards).flat(), ...next]);
|
||||
h.deck = [...next, ...SEOTDA_DECK.filter((c) => !used.has(c))];
|
||||
return s;
|
||||
}
|
||||
|
||||
function act(s: SeotdaState, p: string, a: SeotdaAction, now = (clock += 1000)): SeotdaState {
|
||||
const v = seotda.validate(s, p, a);
|
||||
if (!v.ok) throw new Error(`${p} ${JSON.stringify(a)}: ${v.reason}`);
|
||||
return seotda.apply(s, p, a, { rng: rng(), now }).state;
|
||||
}
|
||||
function actE(s: SeotdaState, p: string, a: SeotdaAction): { state: SeotdaState; events: GameEvent[] } {
|
||||
const v = seotda.validate(s, p, a);
|
||||
if (!v.ok) throw new Error(`${p} ${JSON.stringify(a)}: ${v.reason}`);
|
||||
return seotda.apply(s, p, a, { rng: rng(), now: (clock += 1000) });
|
||||
}
|
||||
const reject = (s: SeotdaState, p: string, a: SeotdaAction) => {
|
||||
const v = seotda.validate(s, p, a);
|
||||
return v.ok ? null : v.reason;
|
||||
};
|
||||
function readyAll(s: SeotdaState): SeotdaState {
|
||||
for (const p of seotda.activePlayers(s)) s = act(s, p, { type: 'ready' });
|
||||
return s;
|
||||
}
|
||||
const stack = (s: SeotdaState, id: string) => s.seats.find((x) => x.id === id)!.stack;
|
||||
const total = (s: SeotdaState) => s.seats.length * s.options.startingChips + s.seats.reduce((a, x) => a + x.rebuys, 0) * s.options.startingChips;
|
||||
|
||||
// ---------------------------------------------------------------- 족보
|
||||
|
||||
/** Independent reference straight from the §6.1/§6.2 tables, using the shared HWATU kinds. */
|
||||
function reference(a: number, b: number, r: HandRules): { value: number; special: SpecialKind | null } {
|
||||
const ca = HWATU[a]!;
|
||||
const cb = HWATU[b]!;
|
||||
const ms = [ca.month, cb.month].sort((x, y) => x - y) as [number, number];
|
||||
const both = (x: number, y: number) => ms[0] === x && ms[1] === y;
|
||||
const gw = ca.kind === 'gwang' && cb.kind === 'gwang';
|
||||
const yeol = (m: number) => [ca, cb].some((c) => c.month === m && c.kind === 'yeol');
|
||||
const gwangOf = (m: number) => [ca, cb].some((c) => c.month === m && c.kind === 'gwang');
|
||||
let value: number;
|
||||
if (gw && both(3, 8)) value = 1000;
|
||||
else if (gw && both(1, 8) && r.gwangTtaeng === 'all') value = 990;
|
||||
else if (gw && both(1, 3) && r.gwangTtaeng === 'all') value = 980;
|
||||
else if (ms[0] === ms[1]) value = 900 + ms[0];
|
||||
else if (both(1, 2)) value = 806;
|
||||
else if (both(1, 4)) value = 805;
|
||||
else if (both(1, 9)) value = 804;
|
||||
else if (both(1, 10)) value = 803;
|
||||
else if (both(4, 10)) value = 802;
|
||||
else if (both(4, 6)) value = 801;
|
||||
else value = (ms[0] + ms[1]) % 10;
|
||||
let special: SpecialKind | null = null;
|
||||
if (both(3, 7) && (r.ttaengjabi === 'loose' || (r.ttaengjabi === 'strict' && gwangOf(3) && yeol(7)))) special = 'ttaengjabi';
|
||||
else if (both(4, 7) && r.amhaeng && yeol(4) && yeol(7)) special = 'amhaeng';
|
||||
else if (both(4, 9) && r.gusa) special = r.mungGusa !== 'off' && yeol(4) && yeol(9) ? 'mungGusa' : 'gusa';
|
||||
return { value, special };
|
||||
}
|
||||
|
||||
describe('족보 (§6.1, §6.2)', () => {
|
||||
const pairs: [number, number][] = [];
|
||||
for (let i = 0; i < SEOTDA_DECK.length; i++) for (let j = i + 1; j < SEOTDA_DECK.length; j++) pairs.push([SEOTDA_DECK[i]!, SEOTDA_DECK[j]!]);
|
||||
|
||||
test('190 two-card combinations', () => expect(pairs).toHaveLength(190));
|
||||
|
||||
const variants: [string, HandRules][] = [
|
||||
['기본', DEFAULT_RULES],
|
||||
['38광땡만·땡잡이 느슨', { ...DEFAULT_RULES, gwangTtaeng: 'only38', ttaengjabi: 'loose' }],
|
||||
['특수패 모두 끔', { gwangTtaeng: 'all', ttaengjabi: 'off', amhaeng: false, gusa: false, mungGusa: 'upTo8' }],
|
||||
['멍구사 끔', { ...DEFAULT_RULES, mungGusa: 'off' }],
|
||||
['멍구사 장땡까지', { ...DEFAULT_RULES, mungGusa: 'upToJang' }],
|
||||
];
|
||||
test.each(variants)('every combination matches the table (%s)', (_n, rules) => {
|
||||
for (const [a, b] of pairs) {
|
||||
const e = evalSeotda(a, b, rules);
|
||||
expect({ value: e.value, special: e.special }).toEqual(reference(a, b, rules));
|
||||
expect(evalSeotda(b, a, rules)).toEqual(e);
|
||||
}
|
||||
});
|
||||
|
||||
test('counts per name with default options', () => {
|
||||
const count: Record<string, number> = {};
|
||||
for (const [a, b] of pairs) {
|
||||
const n = ev(a, b).name;
|
||||
count[n] = (count[n] ?? 0) + 1;
|
||||
}
|
||||
expect([count['38광땡'], count['18광땡'], count['13광땡'], count['장땡'], count['삥땡'], count['5땡']]).toEqual([1, 1, 1, 1, 1, 1]);
|
||||
expect([count['알리'], count['독사'], count['구삥'], count['장삥'], count['장사'], count['세륙']]).toEqual([4, 4, 4, 4, 4, 4]);
|
||||
expect([count['땡잡이'], count['암행어사'], count['구사'], count['멍텅구리구사']]).toEqual([1, 1, 3, 1]);
|
||||
});
|
||||
|
||||
test('1월 광 + 3월 광: 13광땡 or 4끗; 1월 띠 + 8월 광 = 갑오; 3월 띠 + 8월 광 = 1끗', () => {
|
||||
expect(ev(S(1), S(3)).name).toBe('13광땡');
|
||||
expect(ev(S(1), S(3), { gwangTtaeng: 'only38' })).toMatchObject({ name: '4끗', value: 4 });
|
||||
expect(ev(S(1), S(8), { gwangTtaeng: 'only38' })).toMatchObject({ name: '갑오', value: 9 });
|
||||
expect(ev(P(1), S(8))).toMatchObject({ name: '갑오', value: 9 });
|
||||
expect(ev(P(3), S(8))).toMatchObject({ name: '1끗', value: 1 });
|
||||
expect(ev(P(2), S(8))).toMatchObject({ name: '망통', value: 0 });
|
||||
expect(ev(S(10), P(10))).toMatchObject({ name: '장땡', value: 910 });
|
||||
});
|
||||
|
||||
test('special hands keep their normal value', () => {
|
||||
expect(ev(S(3), S(7))).toMatchObject({ special: 'ttaengjabi', value: 0, baseName: '망통' });
|
||||
expect(ev(S(4), S(7))).toMatchObject({ special: 'amhaeng', value: 1 });
|
||||
expect(ev(P(4), S(9))).toMatchObject({ special: 'gusa', value: 3 });
|
||||
expect(ev(S(4), S(9))).toMatchObject({ special: 'mungGusa', value: 3 });
|
||||
expect(ev(S(4), S(9), { mungGusa: 'off' }).special).toBe('gusa');
|
||||
expect(ev(S(4), S(9), { gusa: false })).toMatchObject({ special: null, name: '3끗' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('쇼다운 판정 (§6.3, §6.4)', () => {
|
||||
const run = (hands: [string, HandEval][], tie: 'rematch' | 'split' = 'rematch', r: Partial<HandRules> = {}) =>
|
||||
resolve(hands.map(([id, hand]) => ({ id, hand })), tie, { ...DEFAULT_RULES, ...r });
|
||||
const gusa = ev(P(4), S(9));
|
||||
const mung = ev(S(4), S(9));
|
||||
const tj = ev(S(3), S(7));
|
||||
const amh = ev(S(4), S(7));
|
||||
|
||||
test('1. 38광땡 vs 암행어사 → 38광땡', () => expect(run([['A', ev(S(3), S(8))], ['B', amh]])).toEqual({ type: 'win', winners: ['A'], reason: 'normal' }));
|
||||
test('2. 13광땡, 암행어사, 장땡 → 암행어사', () =>
|
||||
expect(run([['A', ev(S(1), S(3))], ['B', amh], ['C', ev(S(10), P(10))]])).toEqual({ type: 'win', winners: ['B'], reason: 'amhaeng' }));
|
||||
test('3. 9땡, 땡잡이, 알리 → 땡잡이', () =>
|
||||
expect(run([['A', ev(S(9), P(9))], ['B', tj], ['C', ev(S(1), P(2))]])).toEqual({ type: 'win', winners: ['B'], reason: 'ttaengjabi' }));
|
||||
test('4. 장땡 vs 땡잡이 → 장땡', () => expect(run([['A', ev(S(10), P(10))], ['B', tj]])).toMatchObject({ winners: ['A'] }));
|
||||
test('5. 땡잡이 vs 알리 → 알리', () => expect(run([['A', tj], ['B', ev(S(1), P(2))]])).toMatchObject({ winners: ['B'] }));
|
||||
test('6. 구사 vs 알리 → 구사 재경기', () =>
|
||||
expect(run([['A', gusa], ['B', ev(S(1), P(2))]])).toEqual({ type: 'rematch', reason: 'gusa', participants: ['A', 'B'] }));
|
||||
test('7. 구사 vs 삥땡 → 삥땡', () => expect(run([['A', gusa], ['B', ev(S(1), P(1))]])).toMatchObject({ type: 'win', winners: ['B'] }));
|
||||
test('8. 구사, 2끗, 망통 → 구사 승(재경기 없음)', () =>
|
||||
expect(run([['A', gusa], ['B', ev(P(5), P(7))], ['C', ev(P(2), P(8))]])).toMatchObject({ type: 'win', winners: ['A'] }));
|
||||
test('9. 멍텅구리구사 vs 8땡 → 재경기(upTo8); 9땡 → 승; upToJang이면 장땡까지 재경기; 광땡은 이김', () => {
|
||||
expect(run([['A', mung], ['B', ev(S(8), P(8))]])).toMatchObject({ type: 'rematch', reason: 'mungGusa' });
|
||||
expect(run([['A', mung], ['B', ev(S(9), P(9))]])).toMatchObject({ type: 'win', winners: ['B'] });
|
||||
expect(run([['A', mung], ['B', ev(S(9), P(9))]], 'rematch', { mungGusa: 'upTo9' })).toMatchObject({ type: 'rematch' });
|
||||
expect(run([['A', mung], ['B', ev(S(10), P(10))]], 'rematch', { mungGusa: 'upToJang' })).toMatchObject({ type: 'rematch' });
|
||||
expect(run([['A', mung], ['B', ev(S(1), S(3))]], 'rematch', { mungGusa: 'upToJang' })).toMatchObject({ type: 'win', winners: ['B'] });
|
||||
});
|
||||
test('10. 5끗 동점 → 재경기 또는 나눔', () => {
|
||||
const a = ev(P(2), P(3));
|
||||
const b = ev(P(6), P(9));
|
||||
expect(run([['A', a], ['B', b]])).toEqual({ type: 'rematch', reason: 'tie', participants: ['A', 'B'] });
|
||||
expect(run([['A', a], ['B', b]], 'split')).toEqual({ type: 'split', winners: ['A', 'B'] });
|
||||
});
|
||||
test('11. 구사, 5땡, 땡잡이 → 땡잡이; 멍구사였다면 재경기', () => {
|
||||
expect(run([['A', gusa], ['B', ev(S(5), P(5))], ['C', tj]])).toEqual({ type: 'win', winners: ['C'], reason: 'ttaengjabi' });
|
||||
expect(run([['A', mung], ['B', ev(S(5), P(5))], ['C', ev(P(3), P(7))]], 'rematch', { ttaengjabi: 'loose' })).toMatchObject({ type: 'rematch', reason: 'mungGusa' });
|
||||
});
|
||||
test('12. 3장 섯다: 1광·8광·4열끗 → 18광땡 조합', () => {
|
||||
expect(bestChoice([S(1), S(8), S(4)], DEFAULT_RULES)).toEqual([S(1), S(8)]);
|
||||
});
|
||||
|
||||
test('strict 땡잡이: 3월 띠 + 7월 열끗은 망통이라 9땡을 못 잡고, loose면 잡음', () => {
|
||||
const weak = ev(P(3), S(7));
|
||||
expect(weak).toMatchObject({ special: null, name: '망통' });
|
||||
expect(run([['A', weak], ['B', ev(S(9), P(9))]])).toMatchObject({ winners: ['B'] });
|
||||
const loose = ev(P(3), S(7), { ttaengjabi: 'loose' });
|
||||
expect(run([['A', loose], ['B', ev(S(9), P(9))]], 'rematch', { ttaengjabi: 'loose' })).toMatchObject({ winners: ['A'], reason: 'ttaengjabi' });
|
||||
});
|
||||
|
||||
test('loose 땡잡이 두 명 + 5땡 → 땡잡이끼리 동점 처리', () => {
|
||||
const r: Partial<HandRules> = { ttaengjabi: 'loose' };
|
||||
const hands: [string, HandEval][] = [
|
||||
['A', ev(S(3), P(7), r)],
|
||||
['B', ev(S(5), P(5), r)],
|
||||
['C', ev(P(3), S(7), r)],
|
||||
];
|
||||
expect(run(hands, 'rematch', r)).toEqual({ type: 'rematch', reason: 'tie', participants: ['A', 'C'] });
|
||||
expect(run(hands, 'split', r)).toEqual({ type: 'split', winners: ['A', 'C'] });
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- betting & flow
|
||||
|
||||
describe('betting flow', () => {
|
||||
test('§5.4 example: 학교 30 → 삥 → 하프 35 → 따당 70 → 콜 → 콜 = 240', () => {
|
||||
let s = make();
|
||||
const v0 = seotda.view(s, 'A');
|
||||
expect(v0.pot).toBe(30);
|
||||
expect(v0.legal.map((l) => l.type)).toEqual(['check', 'ping', 'quarter', 'half', 'die']);
|
||||
s = act(s, 'A', { type: 'ping' });
|
||||
expect(seotda.view(s, 'B').legal.find((l) => l.type === 'half')!.amount).toBe(35);
|
||||
s = act(s, 'B', { type: 'half' });
|
||||
expect(seotda.view(s, 'C').legal.find((l) => l.type === 'ddadang')!.amount).toBe(70);
|
||||
s = act(s, 'C', { type: 'ddadang' });
|
||||
expect(seotda.view(s, 'A').legal.map((l) => l.type)).toEqual(['call', 'die']);
|
||||
s = act(s, 'A', { type: 'call' });
|
||||
expect(seotda.view(s, null).pot).toBe(205);
|
||||
s = act(s, 'B', { type: 'call' });
|
||||
expect(seotda.view(s, null).pot).toBe(240);
|
||||
// twoRounds: second card dealt, second betting round starts with the dealer.
|
||||
expect(s.hand!.street).toBe(1);
|
||||
expect(s.hand!.cards.A).toHaveLength(2);
|
||||
expect(seotda.view(s, null).toAct).toBe('A');
|
||||
expect(chipsTotal(s)).toBe(3000);
|
||||
});
|
||||
|
||||
test('check or call, then no raise in the same round; half-only room rejects 풀', () => {
|
||||
let s = make();
|
||||
expect(reject(s, 'A', { type: 'full' })).toBe('이 방은 하프까지만 올릴 수 있어요.');
|
||||
expect(reject(s, 'B', { type: 'check' })).toBe('지금은 내 차례가 아니에요.');
|
||||
s = act(s, 'A', { type: 'check' });
|
||||
s = act(s, 'B', { type: 'ping' });
|
||||
expect(reject(s, 'C', { type: 'check' })).toBe('누가 이미 걸어서 체크할 수 없어요.');
|
||||
expect(reject(s, 'C', { type: 'ping' })).toBe('누가 이미 걸어서 삥을 할 수 없어요.');
|
||||
s = act(s, 'C', { type: 'call' });
|
||||
expect(reject(s, 'A', { type: 'half' })).toBe('이번 라운드에는 더 올릴 수 없어요(콜 또는 다이).');
|
||||
expect(reject(s, 'A', { type: 'ddadang' })).toBe('이번 라운드에는 더 올릴 수 없어요(콜 또는 다이).');
|
||||
s = act(s, 'A', { type: 'call' });
|
||||
expect(s.hand!.street).toBe(1);
|
||||
const full = make({ maxRaise: 'full' });
|
||||
expect(seotda.view(full, 'A').legal.map((l) => l.type)).toContain('full');
|
||||
});
|
||||
|
||||
test('everyone checks → next street; second round starts after a dead dealer', () => {
|
||||
let s = make();
|
||||
s = act(s, 'A', { type: 'die' });
|
||||
s = act(s, 'B', { type: 'check' });
|
||||
s = act(s, 'C', { type: 'check' });
|
||||
expect(s.hand!.street).toBe(1);
|
||||
expect(seotda.view(s, null).toAct).toBe('B');
|
||||
expect(seotda.view(s, null).pot).toBe(30);
|
||||
});
|
||||
|
||||
test('dealMode=oneRound: one betting round then showdown', () => {
|
||||
let s = make({ dealMode: 'oneRound' });
|
||||
expect(s.hand!.cards.A).toHaveLength(2);
|
||||
s = rig(s, { A: [S(10), P(10)], B: [S(1), P(2)], C: [P(3), P(5)] });
|
||||
s = act(s, 'A', { type: 'check' });
|
||||
s = act(s, 'B', { type: 'check' });
|
||||
s = act(s, 'C', { type: 'check' });
|
||||
expect(s.hand!.phase).toBe('handEnd');
|
||||
expect(s.hand!.showdown!.pots).toEqual([{ amount: 30, eligible: ['A', 'B', 'C'], type: 'win', winners: ['A'], reason: 'normal' }]);
|
||||
expect(stack(s, 'A')).toBe(1020);
|
||||
expect(seotda.view(s, 'B').seats.find((x) => x.id === 'A')!.handName).toBe('장땡');
|
||||
});
|
||||
|
||||
test('fold to one: winner takes the pot (uncalled bet back), no cards revealed', () => {
|
||||
let s = make({ dealMode: 'oneRound' });
|
||||
s = act(s, 'A', { type: 'half' }); // 15
|
||||
s = act(s, 'B', { type: 'die' });
|
||||
s = act(s, 'C', { type: 'die' });
|
||||
expect(s.hand!.foldWin).toEqual({ winner: 'A', amount: 30 }); // A's uncalled 15 came back first
|
||||
expect(stack(s, 'A')).toBe(1020);
|
||||
expect(s.hand!.showdown).toBeNull();
|
||||
const bView = seotda.view(s, 'B');
|
||||
expect(bView.seats.find((x) => x.id === 'A')!.cards).toEqual([{ hidden: true }, { hidden: true }]);
|
||||
expect(seotda.view(s, null).seats.every((x) => x.cards.every((c) => 'hidden' in c))).toBe(true);
|
||||
s = readyAll(s);
|
||||
expect(s.handsPlayed).toBe(1);
|
||||
expect(s.hand!.dealer).toBe('A'); // dealerRule=winner
|
||||
});
|
||||
|
||||
test('dealerRule=rotate moves the dealer clockwise', () => {
|
||||
let s = make({ dealMode: 'oneRound', dealerRule: 'rotate' });
|
||||
s = act(s, 'A', { type: 'die' });
|
||||
s = act(s, 'B', { type: 'die' });
|
||||
s = readyAll(s);
|
||||
expect(s.hand!.dealer).toBe('B');
|
||||
});
|
||||
|
||||
test('all-in run-out with a side pot at showdown', () => {
|
||||
let s = make({ dealMode: 'oneRound' });
|
||||
// A has only 20 chips left after the 학교.
|
||||
s.seats[0]!.stack = 20;
|
||||
s.hand!.betting!.players[0]!.stack = 20;
|
||||
s = rig(s, { A: [S(10), P(10)], B: [S(1), P(2)], C: [P(3), P(5)] });
|
||||
const before = chipsTotal(s);
|
||||
s = act(s, 'A', { type: 'ping' }); // 10
|
||||
s = act(s, 'B', { type: 'half' }); // 10 + 25 = 35
|
||||
s = act(s, 'C', { type: 'call' }); // 35
|
||||
s = act(s, 'A', { type: 'call' }); // only 10 left → all-in
|
||||
expect(s.hand!.phase).toBe('handEnd');
|
||||
const pots = s.hand!.showdown!.pots;
|
||||
expect(pots).toEqual([
|
||||
{ amount: 30, eligible: ['B', 'C'], type: 'win', winners: ['B'], reason: 'normal' },
|
||||
{ amount: 90, eligible: ['A', 'B', 'C'], type: 'win', winners: ['A'], reason: 'normal' },
|
||||
]);
|
||||
expect(stack(s, 'A')).toBe(90);
|
||||
expect(chipsTotal(s)).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
describe('재경기', () => {
|
||||
test('구사 재경기: 모인 칩 유지, 학교 재징수 없음, 쇼다운 생존자만', () => {
|
||||
let s = make({ dealMode: 'oneRound' });
|
||||
s = rig(s, { A: [P(4), S(9)], B: [S(1), P(2)], C: [P(5), P(6)] });
|
||||
s = act(s, 'A', { type: 'check' });
|
||||
s = act(s, 'B', { type: 'check' });
|
||||
s = act(s, 'C', { type: 'die' });
|
||||
expect(s.hand!.showdown!.pots[0]).toMatchObject({ type: 'rematch', reason: 'gusa', winners: ['A', 'B'] });
|
||||
expect(s.hand!.next).toBe('rematch');
|
||||
s = readyAll(s);
|
||||
const h = s.hand!;
|
||||
expect([h.handNo, h.rematchNo, s.handsPlayed]).toEqual([1, 1, 0]);
|
||||
expect(h.order).toEqual(['A', 'B']);
|
||||
expect(h.dealer).toBe('A');
|
||||
expect([stack(s, 'A'), stack(s, 'B'), stack(s, 'C')]).toEqual([990, 990, 990]);
|
||||
const v = seotda.view(s, 'A');
|
||||
expect([v.pot, v.rematchNo, v.rematchReason]).toEqual([30, 1, 'gusa']);
|
||||
// 하프 in a rematch sees the carried pot: 10 + floor(30/2) → 15.
|
||||
expect(v.legal.find((l) => l.type === 'half')!.amount).toBe(15);
|
||||
expect(reject(s, 'C', { type: 'check' })).toBe('이번 판에 참가하지 않았어요.');
|
||||
// Rematch resolves the carried pot; winner takes it all.
|
||||
s = rig(s, { A: [S(10), P(10)], B: [P(2), P(3)] });
|
||||
s = act(s, 'A', { type: 'ping' });
|
||||
s = act(s, 'B', { type: 'call' });
|
||||
expect(s.hand!.showdown!.pots).toEqual([{ amount: 50, eligible: ['A', 'B'], type: 'win', winners: ['A'], reason: 'normal' }]);
|
||||
expect(stack(s, 'A')).toBe(1030);
|
||||
s = readyAll(s);
|
||||
expect(s.handsPlayed).toBe(1);
|
||||
expect(s.stats.rematches).toBe(1);
|
||||
});
|
||||
|
||||
function gusaHand(opts: Partial<SeotdaOptions>) {
|
||||
let s = make({ dealMode: 'oneRound', ...opts });
|
||||
s = rig(s, { A: [P(4), S(9)], B: [S(1), P(2)], C: [P(5), P(6)] });
|
||||
s = act(s, 'A', { type: 'check' });
|
||||
s = act(s, 'B', { type: 'check' });
|
||||
s = act(s, 'C', { type: 'die' });
|
||||
return readyAll(s);
|
||||
}
|
||||
|
||||
test('rejoinCost=half: the folded player pays half (rounded down) and joins', () => {
|
||||
let s = gusaHand({ rejoinCost: 'half', baseUnit: 2, startingChips: 500 });
|
||||
expect(s.hand!.phase).toBe('rejoin');
|
||||
expect(seotda.activePlayers(s)).toEqual(['C']);
|
||||
expect(seotda.view(s, 'C').rejoin).toEqual({ pending: ['C'], cost: 3, mine: true, canAfford: true });
|
||||
const before = chipsTotal(s);
|
||||
s = act(s, 'C', { type: 'rejoin', accept: true });
|
||||
expect(s.hand!.rematchNo).toBe(1);
|
||||
expect(s.hand!.order).toEqual(['A', 'B', 'C']);
|
||||
expect(stack(s, 'C')).toBe(498 - 3);
|
||||
expect(seotda.view(s, null).pot).toBe(9);
|
||||
expect(chipsTotal(s)).toBe(before);
|
||||
});
|
||||
|
||||
test('rejoin: not enough chips → refused; timeout → not joining', () => {
|
||||
let s = gusaHand({ rejoinCost: 'full' });
|
||||
expect(s.hand!.rejoinCost).toBe(30);
|
||||
s.seats[2]!.stack = 20;
|
||||
expect(reject(s, 'C', { type: 'rejoin', accept: true })).toBe('칩이 모자라 재참가할 수 없어요.');
|
||||
expect(seotda.legalActions!(s, 'C')).toEqual([{ type: 'rejoin', accept: false }]);
|
||||
s = act(s, 'C', { type: 'timeout' });
|
||||
expect(s.hand!.order).toEqual(['A', 'B']);
|
||||
expect(stack(s, 'C')).toBe(20);
|
||||
});
|
||||
|
||||
test('tie rematch 3 times in a row, then the 4th tie splits (odd chip to the dealer side)', () => {
|
||||
let s = make({ dealMode: 'oneRound' });
|
||||
const fives = { A: [P(2), P(3)], B: [P(6), S(9)] };
|
||||
s = rig(s, { ...fives, C: [P(5), P(6)] });
|
||||
s = act(s, 'A', { type: 'half' }); // 15 → pot 45
|
||||
s = act(s, 'B', { type: 'call' });
|
||||
s = act(s, 'C', { type: 'call' }); // pot 75, C loses with 1끗
|
||||
expect(s.hand!.showdown!.pots[0]).toMatchObject({ amount: 75, type: 'rematch', reason: 'tie', winners: ['A', 'B'] });
|
||||
for (let k = 1; k <= 3; k++) {
|
||||
s = readyAll(s);
|
||||
expect(s.hand!.rematchNo).toBe(k);
|
||||
expect(s.hand!.order).toEqual(['A', 'B']);
|
||||
s = rig(s, fives);
|
||||
s = act(s, 'A', { type: 'check' });
|
||||
s = act(s, 'B', { type: 'check' });
|
||||
}
|
||||
expect(s.hand!.showdown!.pots).toEqual([{ amount: 75, eligible: ['A', 'B'], type: 'split', winners: ['A', 'B'], reason: 'limit' }]);
|
||||
expect([stack(s, 'A'), stack(s, 'B'), stack(s, 'C')]).toEqual([975 + 38, 975 + 37, 975]);
|
||||
expect(chipsTotal(s)).toBe(3000);
|
||||
});
|
||||
|
||||
test('side pot + rematch: side pot paid at once, only the main pot is replayed', () => {
|
||||
let s = make({ dealMode: 'oneRound' });
|
||||
s.seats[0]!.stack = 20;
|
||||
s.hand!.betting!.players[0]!.stack = 20;
|
||||
s = rig(s, { A: [P(2), P(3)], B: [P(6), S(9)], C: [P(5), P(8)] });
|
||||
s = act(s, 'A', { type: 'ping' });
|
||||
s = act(s, 'B', { type: 'half' });
|
||||
s = act(s, 'C', { type: 'call' });
|
||||
s = act(s, 'A', { type: 'call' }); // all-in for the main pot only
|
||||
expect(s.hand!.showdown!.pots).toEqual([
|
||||
{ amount: 30, eligible: ['B', 'C'], type: 'win', winners: ['B'], reason: 'normal' },
|
||||
{ amount: 90, eligible: ['A', 'B', 'C'], type: 'rematch', winners: ['A', 'B'], reason: 'tie' },
|
||||
]);
|
||||
expect(stack(s, 'B')).toBe(1000 - 45 + 30); // 학교 10 + 하프 35, side pot back
|
||||
expect(s.hand!.carried).toEqual([{ amount: 90, eligible: ['A', 'B'] }]);
|
||||
const sum = chipsTotal(s);
|
||||
// A is all-in: the rematch has no betting decisions and goes straight to showdown.
|
||||
s = readyAll(s);
|
||||
expect(s.hand!.rematchNo).toBe(1);
|
||||
expect(s.hand!.phase).toBe('handEnd');
|
||||
const pot = s.hand!.showdown!.pots[0]!;
|
||||
expect([pot.amount, pot.eligible]).toEqual([90, ['A', 'B']]);
|
||||
expect(chipsTotal(s)).toBe(sum);
|
||||
});
|
||||
});
|
||||
|
||||
describe('3장 섯다', () => {
|
||||
function three() {
|
||||
let s = make({ cardMode: 'three' });
|
||||
s = rig(s, { A: [S(1), S(8)], B: [P(5), P(6)], C: [P(2), P(7)] }, [S(4), P(9), P(10)]);
|
||||
s = act(s, 'A', { type: 'check' });
|
||||
s = act(s, 'B', { type: 'check' });
|
||||
s = act(s, 'C', { type: 'check' });
|
||||
return s;
|
||||
}
|
||||
|
||||
test('simultaneous secret choice before the last betting round', () => {
|
||||
let s = three();
|
||||
expect(s.hand!.phase).toBe('choose');
|
||||
expect(s.hand!.cards.A).toEqual([S(1), S(8), S(4)]);
|
||||
expect(seotda.activePlayers(s).sort()).toEqual(['A', 'B', 'C']);
|
||||
const mine = seotda.view(s, 'A');
|
||||
expect(mine.myCombos!.find((c) => c.best)!.name).toBe('18광땡');
|
||||
expect(mine.myHand!.name).toBe('최고 조합 18광땡');
|
||||
expect(reject(s, 'A', { type: 'choose', cardIds: [S(1), S(1)] })).toBe('카드 두 장을 골라 주세요.');
|
||||
expect(reject(s, 'A', { type: 'choose', cardIds: [S(1), P(9)] })).toBe('카드 두 장을 골라 주세요.');
|
||||
s = act(s, 'A', { type: 'choose', cardIds: [S(1), S(4)] }); // 독사 on purpose
|
||||
expect(reject(s, 'A', { type: 'choose', cardIds: [S(1), S(8)] })).toBe('이미 골랐어요.');
|
||||
const other = seotda.view(s, 'B');
|
||||
expect(other.seats.find((x) => x.id === 'A')!.chosen).toBe(true);
|
||||
expect(other.seats.find((x) => x.id === 'A')!.cards).toEqual([{ hidden: true }, { hidden: true }]);
|
||||
expect(JSON.stringify(other)).not.toContain('"id":0,');
|
||||
expect(other.myChosen).toBeNull();
|
||||
expect(seotda.view(s, 'A').myDiscard!.id).toBe(S(8));
|
||||
});
|
||||
|
||||
test('timeout picks the best combination; the discard never appears in any view or event', () => {
|
||||
let s = three();
|
||||
let events: GameEvent[] = [];
|
||||
for (const p of ['A', 'B', 'C']) {
|
||||
const r = actE(s, p, { type: 'timeout' });
|
||||
s = r.state;
|
||||
events = events.concat(r.events);
|
||||
}
|
||||
expect(s.hand!.chosen.A).toEqual([S(1), S(8)]);
|
||||
expect(s.hand!.phase).toBe('betting');
|
||||
expect(s.hand!.street).toBe(1);
|
||||
for (const p of ['A', 'B', 'C']) {
|
||||
const r = actE(s, p, { type: 'check' });
|
||||
s = r.state;
|
||||
events = events.concat(r.events);
|
||||
}
|
||||
expect(s.hand!.showdown!.hands.find((x) => x.id === 'A')!.name).toBe('18광땡');
|
||||
for (const p of ['A', 'B', 'C']) {
|
||||
const discard = s.hand!.cards[p]!.find((c) => !s.hand!.chosen[p]!.includes(c))!;
|
||||
const key = `"key":"h1r0c${discard}"`;
|
||||
for (const viewer of ['A', 'B', 'C', null]) {
|
||||
if (viewer === p) continue;
|
||||
expect(JSON.stringify(seotda.view(s, viewer))).not.toContain(key);
|
||||
}
|
||||
const shown = events.filter((e) => e.type === 'showdown').flatMap((e) => (e.hands as { cards: number[] }[]).flatMap((x) => x.cards));
|
||||
expect(shown).not.toContain(discard);
|
||||
}
|
||||
expect(events.filter((e) => e.type !== 'showdown').every((e) => !('cards' in e) && !('card' in e))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('timeouts, away, leaving', () => {
|
||||
test('timeout: check when nobody bet, otherwise die; two in a row → away with a 5 s limit', () => {
|
||||
let s = make();
|
||||
const t0 = s.deadlineAt!;
|
||||
expect(t0).toBe(20_000);
|
||||
s = act(s, 'A', { type: 'timeout' }, 21_000);
|
||||
expect(s.hand!.lastAction.A).toEqual({ type: 'check', amount: 0 });
|
||||
s = act(s, 'B', { type: 'ping' }, 22_000);
|
||||
s = act(s, 'C', { type: 'timeout' }, 43_000);
|
||||
expect(s.hand!.lastAction.C!.type).toBe('die');
|
||||
s = act(s, 'A', { type: 'timeout' }, 64_000);
|
||||
expect(s.hand!.lastAction.A!.type).toBe('die');
|
||||
expect(s.seats[0]!.away).toBe(true);
|
||||
// Next hand (result screen times out): A is away → 5 s limit on A's turn; a real action clears it.
|
||||
for (const p of seotda.activePlayers(s)) s = act(s, p, { type: 'timeout' }, 70_000);
|
||||
const h = s.hand!;
|
||||
const first = seotda.activePlayers(s)[0]!;
|
||||
expect(first).toBe(h.order[0]!);
|
||||
// Walk to A's turn.
|
||||
while (seotda.activePlayers(s)[0] !== 'A') s = act(s, seotda.activePlayers(s)[0]!, { type: 'check' }, 100_000);
|
||||
expect(s.deadlineAt).toBe(100_000 + 5000);
|
||||
s = act(s, 'A', { type: 'check' }, 101_000);
|
||||
expect(s.seats[0]!.away).toBe(false);
|
||||
});
|
||||
|
||||
test('disconnected dealer: timeout acts after the deadline, then normal play resumes', () => {
|
||||
let s = make();
|
||||
expect(seotda.timeoutPlayers!(s)).toEqual(['A']);
|
||||
expect(seotda.onTimeout(s, 'A')).toEqual({ type: 'timeout' });
|
||||
expect(reject(s, 'B', { type: 'timeout' })).toBe('시간 초과 대상이 아니에요.');
|
||||
s = act(s, 'A', { type: 'timeout' });
|
||||
s = act(s, 'B', { type: 'check' });
|
||||
s = act(s, 'C', { type: 'check' });
|
||||
s = act(s, 'A', { type: 'ping' }); // reconnected
|
||||
expect(s.seats[0]!.timeoutsInRow).toBe(0);
|
||||
});
|
||||
|
||||
test('a player who leaves auto-dies; with one connected player left the game ends after the hand', () => {
|
||||
let s = make({}, ['A', 'B', 'C']);
|
||||
s = act(s, 'A', { type: 'ping' });
|
||||
s = act(s, 'B', seotda.onLeave!(s, 'B')!);
|
||||
expect(s.hand!.betting!.players.find((p) => p.id === 'B')!.status).toBe('folded');
|
||||
expect(seotda.activePlayers(s)).toEqual(['C']);
|
||||
s = act(s, 'C', { type: 'leave' });
|
||||
expect(s.hand!.foldWin!.winner).toBe('A');
|
||||
s = readyAll(s);
|
||||
expect(s.phase).toBe('finished');
|
||||
const r = seotda.result(s)!;
|
||||
expect(r.ranking[0]).toEqual(['A']);
|
||||
expect(seotda.onLeave!(s, 'A')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('rebuy, elimination, scores', () => {
|
||||
/** Ends the current hand by folds, then sets stacks before the hand-end processing. */
|
||||
function toHandEnd(opts: Partial<SeotdaOptions>, players = ['A', 'B', 'C']): SeotdaState {
|
||||
let s = make({ dealMode: 'oneRound', ...opts }, players);
|
||||
while (s.hand!.phase !== 'handEnd') s = act(s, seotda.activePlayers(s)[0]!, { type: 'die' });
|
||||
return s;
|
||||
}
|
||||
|
||||
test('rebuy: busted player may take chips again; hands score = chips - start × rebuys', () => {
|
||||
let s = toHandEnd({ rebuy: 'limited', rebuyLimit: 1, handLimit: 10 });
|
||||
s.seats[0]!.stack += s.seats[1]!.stack;
|
||||
s.seats[1]!.stack = 0;
|
||||
s = readyAll(s);
|
||||
expect(s.phase).toBe('rebuy');
|
||||
expect(seotda.activePlayers(s)).toEqual(['B']);
|
||||
expect(seotda.view(s, 'B').rebuy).toEqual({ pending: ['B'], mine: true, left: 1 });
|
||||
s = act(s, 'B', { type: 'rebuy', accept: true });
|
||||
expect([s.phase, stack(s, 'B'), s.seats[1]!.rebuys]).toEqual(['playing', 990, 1]); // refilled, then paid the next 학교
|
||||
expect(chipsTotal(s)).toBe(total(s));
|
||||
// Last hand: no rebuy question, the game ends.
|
||||
while (s.hand!.phase !== 'handEnd') s = act(s, seotda.activePlayers(s)[0]!, { type: 'die' });
|
||||
s.handsPlayed = 9;
|
||||
s = readyAll(s);
|
||||
expect(s.phase).toBe('finished');
|
||||
const r = seotda.result(s)!;
|
||||
for (const x of s.seats) expect(r.scores![x.id]).toBe(x.stack - 1000 * x.rebuys);
|
||||
expect(r.ranking.flat().sort()).toEqual(['A', 'B', 'C']);
|
||||
expect(r.summary).toMatch(/^10판 끝\. 1위/);
|
||||
});
|
||||
|
||||
test('limited rebuys used up → eliminated; declining also eliminates', () => {
|
||||
let s = toHandEnd({ rebuy: 'limited', rebuyLimit: 1 });
|
||||
s.seats[1]!.rebuys = 1;
|
||||
s.seats[0]!.stack += s.seats[1]!.stack;
|
||||
s.seats[1]!.stack = 0;
|
||||
s = readyAll(s);
|
||||
expect(s.phase).toBe('playing');
|
||||
expect(s.seats[1]!.status).toBe('eliminated');
|
||||
expect(s.hand!.order).not.toContain('B');
|
||||
let t = toHandEnd({});
|
||||
t.seats[0]!.stack += t.seats[1]!.stack;
|
||||
t.seats[1]!.stack = 0;
|
||||
t = readyAll(t);
|
||||
t = act(t, 'B', { type: 'timeout' });
|
||||
expect(t.seats[1]!.status).toBe('eliminated');
|
||||
});
|
||||
|
||||
test('lastStanding: same-hand eliminations ranked by chips at hand start', () => {
|
||||
let s = toHandEnd({ endCondition: 'lastStanding', rebuy: 'off' });
|
||||
s.seats[1]!.stackAtHandStart = 300;
|
||||
s.seats[2]!.stackAtHandStart = 100;
|
||||
s.seats[0]!.stack += s.seats[1]!.stack + s.seats[2]!.stack;
|
||||
s.seats[1]!.stack = 0;
|
||||
s.seats[2]!.stack = 0;
|
||||
s = readyAll(s);
|
||||
expect(s.phase).toBe('finished');
|
||||
const r = seotda.result(s)!;
|
||||
expect(r.ranking).toEqual([['A'], ['B'], ['C']]);
|
||||
expect(r.scores).toEqual({ A: 3, B: 2, C: 1 });
|
||||
});
|
||||
|
||||
test('학교 doubles every N hands (capped at startingChips / 5)', () => {
|
||||
let s = make({ dealMode: 'oneRound', schoolUpEvery: 5, startingChips: 500, baseUnit: 20 });
|
||||
for (let i = 0; i < 10; i++) {
|
||||
while (s.hand!.phase !== 'handEnd') s = act(s, seotda.activePlayers(s)[0]!, { type: 'die' });
|
||||
s = readyAll(s);
|
||||
}
|
||||
expect(s.baseUnit).toBe(80);
|
||||
expect(seotda.view(s, null).baseUnit).toBe(80);
|
||||
});
|
||||
});
|
||||
|
||||
describe('options', () => {
|
||||
test('schema defaults and validation', () => {
|
||||
expect(seotda.defaultOptions).toMatchObject({ cardMode: 'two', dealMode: 'twoRounds', startingChips: 1000, baseUnit: 10, rebuy: 'unlimited' });
|
||||
expect(seotda.optionsSchema.safeParse({ startingChips: 500, baseUnit: 50 }).success).toBe(false);
|
||||
expect(seotda.playersFor!({ ...seotda.defaultOptions, cardMode: 'three' })).toEqual({ min: 2, max: 6 });
|
||||
expect(seotda.playersFor!(seotda.defaultOptions)).toEqual({ min: 2, max: 10 });
|
||||
expect(() => seotda.setup({ players: 'ABCDEFG'.split(''), options: { ...seotda.defaultOptions, cardMode: 'three' }, rng: rng(), now: 0 })).toThrow();
|
||||
});
|
||||
|
||||
test('every option has Korean metadata', () => {
|
||||
const shape = (seotda.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();
|
||||
});
|
||||
|
||||
test('client actions only (system actions are not in the schema)', () => {
|
||||
expect(seotda.actionSchema.safeParse({ type: 'timeout' }).success).toBe(false);
|
||||
expect(seotda.actionSchema.safeParse({ type: 'leave' }).success).toBe(false);
|
||||
expect(seotda.actionSchema.safeParse({ type: 'ddadang' }).success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- properties
|
||||
|
||||
describe('random playouts', () => {
|
||||
const conserve = (st: unknown) => {
|
||||
const s = st as SeotdaState;
|
||||
if (chipsTotal(s) !== total(s)) throw new Error(`chips not conserved: ${chipsTotal(s)} vs ${total(s)}`);
|
||||
if (s.seats.some((x) => x.stack < 0)) throw new Error('negative stack');
|
||||
};
|
||||
const configs: [string, Partial<SeotdaOptions>, number, number][] = [
|
||||
['기본', {}, 4, 4],
|
||||
['2장 한 번에·나눔·풀', { dealMode: 'oneRound', tieRule: 'split', maxRaise: 'full' }, 5, 3],
|
||||
['3장 섯다·재참가 절반', { cardMode: 'three', rejoinCost: 'half', ttaengjabi: 'loose' }, 6, 3],
|
||||
['최후 1인·리바이 없음·회전', { endCondition: 'lastStanding', rebuy: 'off', dealerRule: 'rotate', baseUnit: 50, startingChips: 1000 }, 3, 4],
|
||||
['10명·전액 재참가', { rejoinCost: 'full', mungGusa: 'upToJang', handLimit: 10, startingChips: 500, baseUnit: 2 }, 10, 2],
|
||||
['2명·학교 상승·제한 리바이', { schoolUpEvery: 5, rebuy: 'limited', rebuyLimit: 2, handLimit: 30 }, 2, 4],
|
||||
];
|
||||
test.each(configs)('%s: finishes, chips conserved, no leaks, deterministic', (_n, opts, n, runs) => {
|
||||
const options = seotda.optionsSchema.parse(opts);
|
||||
const players = Array.from({ length: n }, (_, i) => `P${i}`);
|
||||
for (let i = 1; i <= runs; i++) {
|
||||
const a = randomPlayout(seotda, { players, options, seed: seedN(i), maxSteps: 20_000, invariant: conserve, maxApplyMs: 30 });
|
||||
expect(a.finished).toBe(true);
|
||||
expect(seotda.result(a.finalState as SeotdaState)!.ranking.flat().sort()).toEqual([...players].sort());
|
||||
if (i === 1) {
|
||||
const b = randomPlayout(seotda, { players, options, seed: seedN(i), maxSteps: 20_000, maxApplyMs: 30 });
|
||||
expect(JSON.stringify(b.finalState)).toBe(JSON.stringify(a.finalState));
|
||||
}
|
||||
}
|
||||
}, 15_000);
|
||||
|
||||
test('leak property: 1,000 seeds × first hand, every viewer and every event', () => {
|
||||
for (let seed = 1; seed <= 1000; seed++) {
|
||||
const n = 2 + (seed % 5);
|
||||
const players = Array.from({ length: n }, (_, i) => `P${i}`);
|
||||
const options = seotda.optionsSchema.parse({ cardMode: seed % 3 === 0 ? 'three' : 'two', dealMode: seed % 4 === 1 ? 'oneRound' : 'twoRounds' });
|
||||
const r = SeededRng.fromSeed(seedN(seed));
|
||||
const chooser = SeededRng.fromSeed(seedN(seed + 77));
|
||||
let s = seotda.setup({ players, options, rng: r, now: 0 });
|
||||
for (let step = 0; step < 200 && s.handsPlayed === 0 && s.phase !== 'finished'; step++) {
|
||||
// Same check as checkNoLeak, computing each view once.
|
||||
const views = new Map([...players, null].map((v) => [v, JSON.stringify(seotda.view(s, v))]));
|
||||
for (const owner of players)
|
||||
for (const secret of seotda.secretsOf!(s, owner))
|
||||
for (const [viewer, json] of views) if (viewer !== owner && json.includes(JSON.stringify(secret))) throw new Error(`leak ${secret} of ${owner} to ${viewer}`);
|
||||
const actives = seotda.activePlayers(s);
|
||||
const actor = actives[chooser.int(actives.length)]!;
|
||||
const legal = seotda.legalActions!(s, actor);
|
||||
const a = chooser.int(5) === 0 ? seotda.onTimeout(s, actor) : legal[chooser.int(legal.length)]!;
|
||||
const out = seotda.apply(s, actor, a, { rng: r, now: step * 1000 });
|
||||
const revealed = new Set(out.state.hand?.showdown?.hands.flatMap((x) => x.cards) ?? []);
|
||||
for (const e of out.events) {
|
||||
if (e.type === 'showdown') {
|
||||
for (const c of (e.hands as { cards: number[] }[]).flatMap((x) => x.cards)) if (!revealed.has(c)) throw new Error('showdown event leaks');
|
||||
} else if ('cards' in e || 'card' in e || 'hand' in e) throw new Error(`event leaks cards: ${e.type}`);
|
||||
}
|
||||
s = out.state;
|
||||
}
|
||||
// Deck order is never part of any view.
|
||||
const deck = JSON.stringify(s.hand?.deck ?? []);
|
||||
if (s.hand && s.hand.deck.length > 3) for (const p of [...players, null]) expect(JSON.stringify(seotda.view(s, p))).not.toContain(deck.slice(1, -1));
|
||||
}
|
||||
}, 20_000);
|
||||
});
|
||||
Reference in New Issue
Block a user