Compare commits

..

10 Commits

Author SHA1 Message Date
EJClaw
4de9ca467c M5·M6: 마작·고스톱·뱅·할리갈리·주사위 땅따먹기 합치기
- 다섯 게임 브랜치 병합, 등록 목록·E2E 계획 충돌 정리(E2E 계획은 기존 게임 계획 그대로 + 새 계획 5개)
- 홈 '곧 나와요' 목록을 M7·M8 게임으로 갱신
- 로드맵 M5·M6 진행 결과 기록

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 14:20:18 +09:00
EJClaw
c41803ac45 Merge branch 'm5/marble' 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
2026-10-05 14:07:23 +09:00
EJClaw
f812789bee Merge branch 'm5/halli-galli' 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
2026-10-05 14:07:23 +09:00
EJClaw
489c017a96 Merge branch 'm5/bang' 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
2026-10-05 14:07:23 +09:00
EJClaw
f340f790b5 Merge branch 'm5/gostop' into codex/owner/grp_discord_joke-app
# Conflicts:
#	apps/web/src/games/registry.ts
#	apps/web/src/lib/catalog-ui.ts
#	packages/games/src/index.ts
2026-10-05 14:07:23 +09:00
EJClaw
c0fb44e395 M5: 마작 규칙·점수 계산·화면·테스트
- 규칙 모듈 packages/games/src/mahjong (tiles·agari·scoring·types·index): 4인 리치 마작(동풍전/반장전)과
  3인 산마, 옵션 전부 한국어 meta, 응답 창(론 > 퐁·깡 > 치, 조기 확정, 최소 400ms, 시간 초과 = 패스),
  리치·일발·더블리치, 후리텐(버림패·동순·리치), 구이가에, 깡 도라 시점, 사깡·사풍·사가리치·구종구패·삼가화,
  유국만관, 책임지불, 북 뽑기, 고점법 점수 계산(역·부·만관~더블 역만, 본장·공탁, 산마 쯔모손/북가절반),
  토비·아가리야메·서입 서든데스, 우마·오카 결과, 초보 모드 힌트, autoPilot·나가기 처리
- 응답 창 타이머는 아직 응답하지 않은 응답 가능자에게만 걸어 연결 끊긴 사람은 바로 패스, 확정 뒤에는 방총자
  타이머로 닫음(끊긴 방총자 때문에 타이머가 헛도는 일 방지). 공개 시계에는 응답 창 남은 시간을 싣지 않음
- 테스트: 점수 계산기 161건(1~4판 × 20~110부 표 전 칸, 만관 이상, 역·부 예시, 산마 지불),
  규칙 시나리오 77건(§10 체크리스트), 무작위 대국 1,000판(4인 반장전 240 · 동풍전 400 · 산마 360,
  옵션 무작위, 패·점수 보존, onTimeout 합법, 재현성) + 엔진 randomPlayout과 매 단계 정보 누출 검사
- 화면 apps/web/src/games/mahjong: 직접 그린 SVG 패, 4방향(3인은 3방향) 버림패 테이블, 탭 두 번 버리기,
  리치·쯔모·깡·북·구종구패, 론·퐁·치(조합 선택)·깡·패스 응답, 국 결과(역·판·부·점수 변화)와 최종 순위,
  초보 모드(텐파이 타패·대기·남은 장수·역 힌트·후리텐 배지), PC 2단 배치·휴대폰 한 줄 손패, 키보드·스크린리더
- 등록: GAMES, 카탈로그(available), 웹 registry, '곧 나와요'에서 마작 제거
- E2E: PC 방장 + 휴대폰 3명 4인 대국으로 한 국을 끝까지(타패·패스·유국 결과 확인·다음 국 시작)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 14:06:45 +09:00
EJClaw
d6496dc1fa M6: 뱅 규칙·화면·테스트
- packages/games/src/bang: 뱅!(4~7명) 규칙 모듈. 카드 80장·캐릭터 16종 데이터, 거리/사거리,
  효과·반응 스택(뱅!/개틀링/인디언!/결투/잡화점/빈사/킷 칼슨/choose2), 다이너마이트·감옥 판정,
  처치 보상·보안관 벌칙·벌처 샘, 승리 판정, 역할·손패·덱 비공개 view, onTimeout/onLeave,
  legalActions·secretsOf. 하우스 룰 옵션 4종.
- 테스트 62개: 문서 §10 시나리오 전부, 시드 1,000개 4~7명 무작위 대국(카드 총수 보존, 생명 범위,
  정보 유출, onTimeout 합법, 결정성) + 엔진 randomPlayout 실행, 재생 결정성.
- apps/web/src/games/bang: 원형 테이블(생명 탄환·장착·거리·역할 배지), 꾹 눌러 보는 내 역할 카드,
  카드 선택 → 대상 강조 → 확인, 반응 시트와 타이머, 기록·카드 도감, PC/휴대폰, 라이트/다크.
- 등록: GAMES, 카탈로그(available), 웹 레지스트리, 아이콘(UPCOMING에서 '뱅' 제거).
- e2e: bang 플랜(PC 방장 + 휴대폰 3명, 반응 25초, 실제 턴 10번: 뱅!·기타 카드·반응·버리기).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 13:38:00 +09:00
EJClaw
f5e4a7ce59 M5: 고스톱·맞고 규칙·화면·테스트
- packages/games/src/gostop: 화투 48장 + 보너스패(cards), 족보·국진·최종 공식·박·독박 정산(scoring),
  패 돌리기·바닥 정리·광팔기·총통·먹기 판정(쪽/뻑/따닥/쓸/폭탄/흔들기/보너스패)·피 뺏기·고/스톱·나가리·
  첫뻑/연뻑/삼뻑(round), validate·view(남의 손패·더미 숨김, 매칭/점수 미리 계산)·시간 초과·나가기(index).
  2인 맞고, 3인 고스톱, 4~5인 광팔기, 여러 판 세트(방 점수 제로섬).
- 테스트: 문서 §10 체크리스트 전부, 1,000 시드 무작위 진행(카드·점수 보존, onTimeout 합법),
  engine randomPlayout(누출·JSON·결정성), 정보 누출 속성 테스트.
- 화면 apps/web/src/games/gostop: 월별 바닥, 매칭 하이라이트, 두 장 중 고르기, 흔들기·폭탄·고/스톱·총통·
  국진·광팔기 선택, 먹은 패 종류별 묶음과 족보 진행, 이벤트 알림, 판 결과 계산 과정, PC·휴대폰·다크 모드.
- HwatuCard: 보너스패(2피·3피) 그림 추가. 게임 목록·레지스트리 등록, 홈 "준비 중"에서 고스톱 제거.
- E2E: 맞고 PC 방장 + 휴대폰 친구가 실제 화면 조작으로 고·스톱까지 진행.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 13:35:21 +09:00
EJClaw
595d3cdf4f M6: 할리갈리 규칙·화면·테스트
- packages/games/src/halli-galli: 56장 과일 카드, 차례 뒤집기(flipGuard 예약), 언제나 칠 수 있는 종
  (tableVersion으로 늦은 종 "늦었어요" 무벌칙, 서버 도착 순서 우선), 오답 벌칙·탈락, official/시간 제한 종료,
  하우스 룰(1대1 오답 패배, 3진 아웃, 벌칙 증가, 벌칙 종 밑), latencyComp light 50ms 창,
  view는 덱 내용을 본인에게도 숨김(장수만), secretsOf/legalActions 제공
- 테스트: §10 체크리스트 전부, 1,000판 시드 랜덤 플레이아웃(카드 보존·누출·onTimeout 합법·결정성) +
  엔진 randomPlayout, 서버 Room 테스트(동시 종 도착 순서, 늦은 종 거절, 자리 비움 자동 뒤집기)
- 화면: 원형 좌석 배치, 큰 종 버튼(스페이스 바), 뒤집기(Enter), SVG 과일 카드, 땡/삐 효과음·진동,
  카드 이동 애니메이션, 어린이 모드 합계 막대, 다크 모드·동작 줄이기 대응
- 공용 변경(하위 호환): GameDefinition.timeoutNotBefore 선택 훅 + Room 타이머 반영(자리 비움 시
  바쁜 반복 방지), feedback.ts에 ding/buzz 소리 추가
- 등록: GAMES, CATALOG, 웹 레지스트리·아이콘, 출시 예정 목록에서 제거, E2E 계획 추가

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 13:34:43 +09:00
EJClaw
ad7af30daa M6: 주사위 땅따먹기 규칙·화면·테스트
- packages/games/src/marble: 판 데이터(board.ts, 40칸·가격표·행운카드 20장), 상태 기계(rules.ts),
  게임 정의(index.ts). 더블·3더블 무인도, 무인도/탈출권/탈출비, 우주여행·탑승료, 사회복지기금,
  통행료(관광지 2곳 이상 2배·면제권), 빚 갚기·파산(채권자에게 매각 대금), 인수·랜드마크·독점 승리·교환
  옵션, 라운드 제한과 총자산 순위(동점이면 포인트 비교). 카드 더미 순서는 비공개(남은 장 수만),
  시스템 전용 TIMEOUT(굴리기/안 사기/싼 것부터 매각/거절), onLeave = 기권.
- 테스트 80개: 문서 §10 체크리스트 전 항목, 1,000판 시드 무작위 대국(포인트 장부·소유 일관성·
  onTimeout 합법), 엔진 randomPlayout·리플레이 결정성, 시간 초과만으로도 끝나는지.
- 화면: SVG 정사각형 판(도시 이름·소유 색·건물 표시·말), 가운데 주사위·결정 프롬프트, 행운카드 공개,
  우주여행 칸 고르기와 미리 보기, 빚 갚기 막대·매각 목록, 교환 시트, 진행 기록, PC·휴대폰·다크 모드.
  화폐 대신 놀이용 포인트(P) 표기, no-money-wording 검사 대상에 추가.
- 등록: GAMES, CATALOG(주사위 땅따먹기), 웹 registry, GAME_ICON, @bg/games/marble/board export.
- E2E marble 계획: PC 방장 + 휴대폰 손님, 15라운드·200P로 결과 화면까지 실제 진행.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 13:31:22 +09:00
50 changed files with 22515 additions and 4 deletions

View File

@@ -0,0 +1,123 @@
/** 할리갈리 through a real Room: bell arrival order, "늦었어요" rejects, away players' auto flips (docs/games/halli-galli.md §5.4, §8.5). */
import { describe, expect, test } from 'bun:test';
import type { ServerMessage } from '@bg/shared';
import { GAMES } from '@bg/games';
import { CARDS, type HalliGalliState } from '@bg/games/halli-galli';
import { openDb } from '../db';
import { Room, type Conn, type RoomConfig } from './room';
import { RoomStore } from './store';
function harness(players: string[]) {
const db = openDb(':memory:');
const store = new RoomStore(db);
let now = 1_000_000;
const clock = { now: () => now, advance: (ms: number) => (now += ms), set: (t: number) => (now = t) };
const games = { 'halli-galli': GAMES['halli-galli']! };
const deps = { store, games, users: { publicUser: (id: string) => ({ id, nickname: id, avatar: null, kind: 'guest' as const }) }, now: clock.now };
const config: RoomConfig = {
gameId: 'halli-galli',
options: games['halli-galli'].defaultOptions,
maxPlayers: players.length,
visibility: 'private',
allowSpectators: true,
chatEnabled: true,
chatFilter: true,
graceSec: 60,
};
const room = new Room('room1', '482913', config, players[0]!, deps);
store.insertRoom({ id: 'room1', code: '482913', hostId: players[0]!, gameId: 'halli-galli', status: 'lobby', visibility: 'private', config, stateJson: '{}', now });
const conns: Record<string, Conn & { msgs: ServerMessage[] }> = {};
for (const p of players) {
const msgs: ServerMessage[] = [];
conns[p] = { userId: p, msgs, send: (m) => msgs.push(m), close: () => {} };
room.join(conns[p], 'player');
}
expect(room.start(players[0]!)).toBeNull();
room.clearTimer();
// 3s countdown → playing.
clock.advance(3000);
room.onTimer();
room.clearTimer();
const state = () => room.game!.state as HalliGalliState;
return { room, clock, conns, state };
}
/** Moves the named cards (fruit + count) from wherever they are onto the players' face-up piles. */
function placeTops(s: HalliGalliState, tops: Record<string, [string, number]>): void {
for (const p of s.order) {
s.decks[p]!.push(...s.faceUp[p]!);
s.faceUp[p] = [];
}
for (const [p, [fruit, count]] of Object.entries(tops)) {
const c = CARDS.find((x) => x.fruit === fruit && x.count === count && !Object.values(s.faceUp).flat().includes(x.id))!;
for (const q of s.order) s.decks[q] = s.decks[q]!.filter((id) => id !== c.id);
s.pot = s.pot.filter((id) => id !== c.id);
s.faceUp[p]!.push(c.id);
}
}
describe('할리갈리 room flow', () => {
test('거의 동시에 친 두 종: 서버 도착 순서로 먼저 온 사람만 수거, 나중 사람은 "늦었어요" 거절(벌칙 없음)', () => {
const { room, conns, state } = harness(['A', 'B', 'C']);
// §5.5: banana 3 + strawberry 2 + banana 2 → banana 5.
const [x, y, z] = state().order as [string, string, string];
placeTops(state(), { [x]: ['banana', 3], [y]: ['strawberry', 2], [z]: ['banana', 2] });
const v = state().tableVersion;
const decks = Object.fromEntries(state().order.map((p) => [p, state().decks[p]!.length]));
const pot = state().pot.length; // 3 players: 2 leftover cards under the bell
// Same millisecond: the room's single-threaded queue decides — B's message is handled first.
room.act('B', 1, { type: 'ring', version: v });
room.act('C', 1, { type: 'ring', version: v });
room.clearTimer();
expect(state().decks.B).toHaveLength(decks.B! + 3 + pot);
expect(state().decks.C).toHaveLength(decks.C!);
expect(state().wrongCount.C).toBe(0);
expect(state().turn).toBe('B');
expect(conns.C!.msgs.filter((m) => m.t === 'reject')).toEqual([{ t: 'reject', cs: 1, reason: '늦었어요! 다른 사람이 먼저 종을 쳤어요.' }]);
// B got the success echo (state with its cs) and everyone sees who rang.
expect(conns.B!.msgs.some((m) => m.t === 'state' && m.cs === 1)).toBe(true);
const last = conns.C!.msgs.filter((m) => m.t === 'state').at(-1) as Extract<ServerMessage, { t: 'state' }>;
expect((last.view as { lastBell: { player: string; correct: boolean } }).lastBell).toMatchObject({ player: 'B', correct: true });
});
test('틀린 종은 바로 벌칙, 버전이 지난 종은 무시', () => {
const { room, clock, conns, state } = harness(['A', 'B']);
placeTops(state(), { A: ['lime', 1] });
const v = state().tableVersion;
const before = state().decks.B!.length;
room.act('B', 1, { type: 'ring', version: v });
room.clearTimer();
expect(state().decks.B).toHaveLength(before - 1);
// The turn player flips (after the guard) — a bell for the old table is ignored as late.
clock.advance(1000);
room.act(state().turn, 2, { type: 'flip' });
room.clearTimer();
expect(state().tableVersion).toBe(v + 1);
room.act('A', 3, { type: 'ring', version: v });
expect(conns.A!.msgs.find((m) => m.t === 'reject')).toMatchObject({ reason: expect.stringContaining('늦었어요') });
});
test('연결이 끊겨 자리 비움이 된 사람도 자동 뒤집기로 계속 진행(1초 간격, 바쁜 반복 없음)', () => {
const { room, clock, conns, state } = harness(['A', 'B']);
const away = state().turn;
const other = away === 'A' ? 'B' : 'A';
room.disconnect(conns[away]!);
room.clearTimer();
clock.advance(61_000); // grace period (60s) over
const gaps: [string, number][] = [];
for (let i = 0; i < 6; i++) {
const s = state();
const d = room.effectiveDeadline()!;
gaps.push([s.turn, d - s.turnStartedAt]);
if (d > clock.now()) clock.set(d);
const flips = s.faceUp[s.turn]!.length;
room.onTimer();
room.clearTimer();
expect(state().faceUp[s.turn]!.length).toBe(flips + 1);
}
// The first (overdue) flip happens at once; afterwards the away player flips 1s into each turn,
// the connected player gets the normal 3 seconds.
for (const [who, gap] of gaps.slice(1)) expect(gap).toBe(who === other ? 3000 : 1000);
expect(room.status).toBe('playing');
});
});

View File

@@ -766,6 +766,7 @@ export class Room {
if (!g || g.finished || this.status !== 'playing') return null;
const def = this.def;
const base = def.deadline?.(g.state) ?? null;
const notBefore = def.timeoutNotBefore?.(g.state) ?? null;
const timed = def.timeoutPlayers ? def.timeoutPlayers(g.state) : def.activePlayers(g.state);
let best: number | null = null;
for (const u of timed) {
@@ -777,6 +778,7 @@ export class Room {
}
if (d === null) continue;
if (g.deadlineFloor !== null) d = Math.max(d, g.deadlineFloor);
if (notBefore !== null) d = Math.max(d, notBefore);
best = best === null ? d : Math.min(best, d);
}
return best;
@@ -803,6 +805,7 @@ export class Room {
const def = this.def;
const now = this.now();
const base = def.deadline?.(g.state) ?? null;
const notBefore = def.timeoutNotBefore?.(g.state) ?? null;
const timed = def.timeoutPlayers ? def.timeoutPlayers(g.state) : def.activePlayers(g.state);
for (const u of timed) {
let d = base;
@@ -812,6 +815,7 @@ export class Room {
d = d === null ? grace : Math.min(d, grace);
}
if (g.deadlineFloor !== null && d !== null) d = Math.max(d, g.deadlineFloor);
if (notBefore !== null && d !== null) d = Math.max(d, notBefore);
if (d === null || d > now) continue;
const action = def.onTimeout(g.state, u);
const err = this.runAction(u, action, undefined, true);

View File

@@ -145,7 +145,13 @@ export function HwatuCard({
<Tag style={s} onClick={onClick} aria-label={card.name} aria-pressed={onClick ? !!selected : undefined} title={card.name}>
<svg viewBox="0 0 100 160" width={w} height={h} aria-hidden>
<rect x="1" y="1" width="98" height="158" rx="8" fill="#fffaf0" stroke={selected ? '#2563eb' : '#1f2937'} strokeWidth={selected ? 6 : 3} />
<Motif month={card.month} color={color} />
{card.kind === 'bonus' ? (
<text x="50" y="102" fontSize="34" fontWeight="900" fill="#b45309" textAnchor="middle" fontFamily="Pretendard Variable, sans-serif">
{card.piValue ?? 2}피
</text>
) : (
<Motif month={card.month} color={color} />
)}
{card.kind === 'tti' && (
<g>
<rect x="58" y="30" width="18" height="90" rx="3" fill={TTI_COLOR[card.tti ?? 'plain']} />
@@ -170,7 +176,7 @@ export function HwatuCard({
{card.month || '+'}
</text>
<text x="50" y="154" fontSize="13" fontWeight="700" fill="#374151" textAnchor="middle" fontFamily="Pretendard Variable, sans-serif">
{MONTH_NAME[card.month]} · {card.kind === 'tti' ? (TTI_TEXT[card.tti ?? 'plain'] || '띠') : KIND_BADGE[card.kind] === '光' ? '광' : KIND_BADGE[card.kind] === '열' ? '열끗' : KIND_BADGE[card.kind]}
{MONTH_NAME[card.month]} · {card.kind === 'bonus' ? `${card.piValue ?? 2}피` : card.kind === 'tti' ? (TTI_TEXT[card.tti ?? 'plain'] || '띠') : KIND_BADGE[card.kind] === '光' ? '광' : KIND_BADGE[card.kind] === '열' ? '열끗' : KIND_BADGE[card.kind]}
</text>
</svg>
</Tag>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,960 @@
/** 할리갈리 화면 (docs/games/halli-galli.md §9). */
import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react';
import type { Fruit, FruitCount, HalliGalliOptions, HalliGalliView, LogEntry, SeatView, TopCard } from '@bg/games/halli-galli';
import type { PublicUser } from '@bg/shared/lite';
import { Avatar } from '../../components/ui';
import { useServerNow } from '../../components/TurnTimer';
import { haptic, sounds } from '../../lib/feedback';
import { socket, useRoom } from '../../net/socket';
import type { BoardProps, GameUI, OptionsProps } from '../types';
const FRUITS: Fruit[] = ['strawberry', 'banana', 'lime', 'plum'];
const FRUIT_KO: Record<Fruit, string> = { strawberry: '딸기', banana: '바나나', lime: '라임', plum: '자두' };
const FRUIT_INK: Record<Fruit, string> = { strawberry: '#e11d48', banana: '#ca8a04', lime: '#4d7c0f', plum: '#7e22ce' };
const nick = (players: Record<string, PublicUser>, id: string) => players[id]?.nickname ?? '알 수 없음';
const userOf = (players: Record<string, PublicUser>, id: string): PublicUser =>
players[id] ?? { id, nickname: '알 수 없음', avatar: null, kind: 'guest' };
const cardName = (c: Pick<TopCard, 'fruit' | 'count'>) => `${FRUIT_KO[c.fruit]} ${c.count}개`;
const CSS = `
.hg { width: 100%; max-width: 1100px; display: flex; flex-direction: column; gap: 10px; align-self: center; }
.hg-top { display: flex; flex-wrap: wrap; gap: 6px 10px; align-items: center; justify-content: space-between; font-weight: 700; }
.hg-top .grp { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
.hg-pill { display: inline-flex; align-items: center; gap: 5px; border-radius: 999px; padding: 3px 10px; background: var(--surface); box-shadow: var(--shadow); font-size: .92rem; font-variant-numeric: tabular-nums; }
.hg-pill.warn { background: var(--warning-soft); color: var(--warning); }
.hg-dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; }
.hg-table { position: relative; width: 100%; height: clamp(440px, 64vh, 600px); border-radius: 28px; border: 8px solid var(--wood); box-shadow: var(--shadow);
background: radial-gradient(ellipse at center, rgba(255,255,255,.14), rgba(255,255,255,0) 62%), var(--felt); overflow: hidden; touch-action: manipulation; }
.hg-seat { position: absolute; transform: translate(-50%, -50%); width: var(--sw); display: flex; flex-direction: column; align-items: center; gap: 3px;
padding: 5px 5px 6px; border-radius: 14px; background: rgba(0,0,0,.28); color: #fff; border: 3px solid transparent; z-index: 2; }
.hg-seat[data-me='true'] { background: rgba(0,0,0,.42); }
.hg-seat[data-turn='true'] { border-color: #fde047; animation: hg-blink 1s ease-in-out infinite; }
.hg-seat[data-out='true'] { opacity: .6; }
.hg-seat .nm { display: flex; align-items: center; gap: 4px; max-width: 100%; font-weight: 800; font-size: .86rem; line-height: 1.2; }
.hg-seat .nm span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.hg-pile { position: relative; width: var(--cw); height: calc(var(--cw) * 1.4); }
.hg-pile .stack { position: absolute; inset: 0; border-radius: 9px; }
.hg-pile .empty { position: absolute; inset: 0; border-radius: 9px; border: 2px dashed rgba(255,255,255,.45); }
.hg-pile .shadow1 { position: absolute; inset: 0; transform: translate(3px, 3px); border-radius: 9px; background: #e7e2d3; border: 1px solid #b9b2a0; }
.hg-pile .face { position: absolute; inset: 0; animation: hg-flip .22s ease-out; }
.hg-pile .gauge { position: absolute; right: -12px; bottom: -8px; width: 30px; height: 30px; }
.hg-meta { display: flex; gap: 6px; align-items: center; font-size: .82rem; font-weight: 800; font-variant-numeric: tabular-nums; }
.hg-back { display: inline-block; width: 11px; height: 15px; border-radius: 3px; background: #1e3a8a; border: 1.5px solid #93c5fd; vertical-align: -2px; }
.hg-tag { border-radius: 999px; padding: 0 6px; font-size: .72rem; font-weight: 800; line-height: 1.6; white-space: nowrap; }
.hg-center { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); display: flex; flex-direction: column; align-items: center; z-index: 3; }
.hg-bell { width: var(--bell); height: var(--bell); border-radius: 50%; border: 0; padding: 0; background: radial-gradient(circle at 40% 35%, #fff7d1, #fbbf24 45%, #b45309 100%);
box-shadow: 0 8px 0 #7c2d12, 0 12px 24px rgba(0,0,0,.35); cursor: pointer; position: relative; display: flex; align-items: center; justify-content: center;
transition: transform .06s ease, box-shadow .06s ease; -webkit-tap-highlight-color: transparent; touch-action: manipulation; user-select: none; }
.hg-bell .art { width: 70%; height: 70%; display: block; pointer-events: none; }
.hg-bell .art svg { width: 100%; height: 100%; display: block; }
.hg-bell .art[data-ring='true'] { animation: hg-ring .45s ease-in-out; }
.hg-bell:active:not(:disabled) { transform: translateY(6px) scale(.97); box-shadow: 0 2px 0 #7c2d12, 0 4px 10px rgba(0,0,0,.35); }
.hg-bell:disabled { cursor: not-allowed; filter: grayscale(.85) brightness(.85); }
.hg-bell:focus-visible { outline: 4px solid var(--focus); outline-offset: 6px; }
.hg-bell .lock { position: absolute; inset: -6px; border-radius: 50%; pointer-events: none; }
.hg-bell .lbl { position: absolute; bottom: 14%; left: 0; right: 0; text-align: center; font-weight: 900; color: #451a03; font-size: calc(var(--bell) * .11); pointer-events: none; }
.hg-keys { color: var(--text-sub); font-weight: 600; font-size: .85rem; }
.hg-pot { position: absolute; left: 50%; top: -2px; transform: translateX(-50%); color: #fff; font-weight: 800; font-size: .78rem; z-index: 4; pointer-events: none;
background: #1e3a8a; border: 2px solid #93c5fd; border-radius: 999px; padding: 1px 8px; white-space: nowrap; }
.hg-bubble { position: absolute; left: 50%; top: 2px; transform: translateX(-50%); z-index: 6; background: #fff; color: #111827; border-radius: 16px; padding: 8px 16px;
font-weight: 900; font-size: 1.25rem; box-shadow: 0 6px 18px rgba(0,0,0,.3); animation: hg-pop .3s ease; max-width: 92%; text-align: center; pointer-events: none; }
.hg-bubble.ok { border: 4px solid #16a34a; }
.hg-bubble.bad { border: 4px solid #dc2626; }
.hg-bubble.late { border: 4px solid #6b7280; font-size: .95rem; }
.hg-count { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; z-index: 7; background: rgba(0,0,0,.35); color: #fff;
font-size: 5rem; font-weight: 900; text-shadow: 0 3px 8px rgba(0,0,0,.5); pointer-events: none; flex-direction: column; }
.hg-count small { font-size: 1.1rem; }
.hg-ghost { position: absolute; width: var(--cw); height: calc(var(--cw) * 1.4); transform: translate(-50%, -50%); z-index: 5; pointer-events: none;
animation: hg-fly .6s cubic-bezier(.5,.1,.3,1) forwards; animation-delay: var(--d, 0ms); left: var(--fx); top: var(--fy); }
.hg-mine { display: flex; gap: 10px; align-items: stretch; }
.hg-mine .info { background: var(--surface); border-radius: 16px; box-shadow: var(--shadow); padding: 8px 12px; display: flex; flex-direction: column; justify-content: center; min-width: 96px; }
.hg-mine .info b { font-size: 1.5rem; font-variant-numeric: tabular-nums; }
.hg-flipbtn { flex: 1; min-height: 64px; font-size: 1.25rem !important; }
.hg-kid { background: var(--surface); border-radius: 16px; box-shadow: var(--shadow); padding: 10px 12px; display: grid; gap: 6px; }
.hg-bar { display: grid; grid-template-columns: 28px 52px 1fr 28px; gap: 8px; align-items: center; font-weight: 800; }
.hg-bar .track { position: relative; height: 14px; border-radius: 999px; background: var(--surface-2); overflow: hidden; }
.hg-bar .track i { position: absolute; left: 0; top: 0; bottom: 0; border-radius: 999px; }
.hg-bar .track::after { content: ''; position: absolute; left: calc(100% * 5 / 8); top: -2px; bottom: -2px; width: 2px; background: var(--text); }
.hg-bar[data-five='true'] { color: var(--success); }
.hg-panel { background: var(--surface); border-radius: 16px; box-shadow: var(--shadow); padding: 12px; }
.hg-log { list-style: none; margin: 8px 0 0; padding: 0; display: grid; gap: 3px; max-height: 220px; overflow: auto; }
.hg-fruit-ic { width: 26px; height: 26px; display: inline-block; vertical-align: middle; }
@keyframes hg-blink { 0%,100% { box-shadow: 0 0 0 0 rgba(253,224,71,.0); } 50% { box-shadow: 0 0 0 6px rgba(253,224,71,.55); } }
@keyframes hg-flip { from { transform: rotateY(85deg) scale(.9); } to { transform: none; } }
@keyframes hg-pop { 0% { transform: translateX(-50%) scale(.6); opacity: 0; } 70% { transform: translateX(-50%) scale(1.06); opacity: 1; } 100% { transform: translateX(-50%) scale(1); } }
@keyframes hg-ring { 0%,100% { transform: rotate(0); } 20% { transform: rotate(-14deg); } 40% { transform: rotate(12deg); } 60% { transform: rotate(-8deg); } 80% { transform: rotate(5deg); } }
@keyframes hg-fly { from { left: var(--fx); top: var(--fy); opacity: 1; } 85% { opacity: .9; } to { left: var(--tx); top: var(--ty); opacity: 0; transform: translate(-50%, -50%) scale(.6); } }
@media (max-width: 719px) {
.hg-table { height: 450px; border-width: 6px; border-radius: 22px; }
.hg-seat { padding: 3px 3px 4px; gap: 2px; }
.hg-seat .nm { font-size: .78rem; }
.hg-meta { font-size: .76rem; gap: 4px; }
.hg-bubble { font-size: .95rem; padding: 6px 12px; }
}
@media (prefers-reduced-motion: reduce) {
.hg-seat[data-turn='true'] { animation: none; box-shadow: 0 0 0 4px rgba(253,224,71,.55); }
.hg-pile .face, .hg-bell .art[data-ring='true'], .hg-bubble { animation: none; }
.hg-ghost { display: none; }
}
`;
// ---------------------------------------------------------------- fruit art
/** One fruit in a 40×40 box. Distinct silhouettes so colour is never the only cue. */
function FruitShape({ fruit }: { fruit: Fruit }) {
switch (fruit) {
case 'strawberry':
return (
<g>
<path d="M20 38 C9 30 4 20 7.5 13.5 C10.5 8.5 29.5 8.5 32.5 13.5 C36 20 31 30 20 38 Z" fill="#e11d48" stroke="#9f1239" strokeWidth="1.5" />
{[
[14, 17],
[20, 15],
[26, 17],
[12, 23],
[18, 22],
[24, 22],
[28, 23],
[16, 28],
[22, 28],
[19, 33],
].map(([x, y]) => (
<ellipse key={`${x}-${y}`} cx={x} cy={y} rx="1.1" ry="1.6" fill="#fde68a" />
))}
<path d="M11 11 L16 9 L20 4 L24 9 L29 11 L23.5 12.5 L20 15 L16.5 12.5 Z" fill="#16a34a" stroke="#14532d" strokeWidth="1" />
</g>
);
case 'banana':
return (
<g>
<path d="M7 9 C7 27 22 36 36 30 C37 28 36 27 34 27.5 C22 30 13 22 12 8 C11 6 8 6 7 9 Z" fill="#facc15" stroke="#a16207" strokeWidth="1.6" strokeLinejoin="round" />
<path d="M11 12 C12 22 19 28 30 29" fill="none" stroke="#eab308" strokeWidth="1.4" />
<path d="M7 9 L8.5 4.5 L11.5 5.5 L12 8" fill="#78350f" />
<circle cx="35.6" cy="29" r="1.6" fill="#78350f" />
</g>
);
case 'lime':
return (
<g>
<circle cx="20" cy="20" r="16" fill="#3f6212" />
<circle cx="20" cy="20" r="13.5" fill="#f7fee7" />
<circle cx="20" cy="20" r="12" fill="#a3e635" />
{Array.from({ length: 8 }, (_, i) => {
const a = (i * Math.PI) / 4;
return <line key={i} x1="20" y1="20" x2={20 + Math.cos(a) * 12} y2={20 + Math.sin(a) * 12} stroke="#f7fee7" strokeWidth="1.6" />;
})}
<circle cx="20" cy="20" r="2.2" fill="#f7fee7" />
</g>
);
case 'plum':
return (
<g>
<path d="M20 8 C30 8 34 17 33 25 C32 33 26 37 20 37 C14 37 8 33 7 25 C6 17 10 8 20 8 Z" fill="#7e22ce" stroke="#4c1d95" strokeWidth="1.5" />
<path d="M20 10 C17 18 17 28 20 36" fill="none" stroke="#4c1d95" strokeWidth="1.6" />
<ellipse cx="13" cy="18" rx="2.4" ry="4.5" fill="#c4b5fd" opacity=".75" />
<path d="M20 9 L21 3" stroke="#78350f" strokeWidth="2" strokeLinecap="round" />
<path d="M21 5 C25 2 30 3 31 5 C28 8 24 8 21 5 Z" fill="#16a34a" />
</g>
);
}
}
const SPOTS: Record<FruitCount, [number, number][]> = {
1: [[50, 70]],
2: [
[50, 38],
[50, 102],
],
3: [
[28, 34],
[50, 70],
[72, 106],
],
4: [
[29, 38],
[71, 38],
[29, 102],
[71, 102],
],
5: [
[27, 32],
[73, 32],
[50, 70],
[27, 108],
[73, 108],
],
};
export function FruitCardArt({ card }: { card: Pick<TopCard, 'fruit' | 'count'> | null }) {
if (!card) {
return (
<svg viewBox="0 0 100 140" width="100%" height="100%" aria-hidden>
<rect x="2" y="2" width="96" height="136" rx="11" fill="#1e3a8a" stroke="#93c5fd" strokeWidth="3" />
<rect x="12" y="12" width="76" height="116" rx="7" fill="none" stroke="#93c5fd" strokeWidth="2" strokeDasharray="6 5" />
<circle cx="50" cy="70" r="16" fill="#fbbf24" stroke="#fde68a" strokeWidth="3" />
</svg>
);
}
const size = card.count === 1 ? 54 : card.count === 5 ? 37 : 41;
return (
<svg viewBox="0 0 100 140" width="100%" height="100%" aria-hidden>
<rect x="2" y="2" width="96" height="136" rx="11" fill="#fffef8" stroke={FRUIT_INK[card.fruit]} strokeWidth="4" />
{SPOTS[card.count].map(([x, y], i) => (
<g key={i} transform={`translate(${x - size / 2} ${y - size / 2}) scale(${size / 40})`}>
<FruitShape fruit={card.fruit} />
</g>
))}
</svg>
);
}
function FruitIcon({ fruit }: { fruit: Fruit }) {
return (
<svg viewBox="0 0 40 40" className="hg-fruit-ic" aria-hidden>
<FruitShape fruit={fruit} />
</svg>
);
}
function BellArt() {
return (
<svg viewBox="0 0 100 100" aria-hidden>
<ellipse cx="50" cy="80" rx="40" ry="9" fill="#78350f" />
<path d="M18 76 C18 50 30 26 50 26 C70 26 82 50 82 76 Z" fill="#fde68a" stroke="#92400e" strokeWidth="3" />
<path d="M30 70 C30 52 37 36 48 33" fill="none" stroke="#fffbeb" strokeWidth="5" strokeLinecap="round" opacity=".8" />
<rect x="43" y="14" width="14" height="13" rx="4" fill="#92400e" />
<circle cx="50" cy="12" r="8" fill="#b91c1c" stroke="#7f1d1d" strokeWidth="2" />
</svg>
);
}
// ---------------------------------------------------------------- geometry
interface Pos {
left: string;
top: string;
}
const CENTER: Pos = { left: '50%', top: '50%' };
/**
* Seat centres on an ellipse (me at the bottom, then clockwise: left → top → right). The radii are
* "half the table minus half a seat", so seats always stay inside the felt whatever its size.
* `tall` (narrow phone table): seats that are not level with the bell move further up/down so they clear it.
*/
function seatPositions(n: number, seat: { w: number; h: number }, maxRx: number, tall: boolean): Pos[] {
const f = (x: number) => (Math.abs(x) < 1e-9 ? 0 : Number(x.toFixed(4)));
return Array.from({ length: n }, (_, k) => {
const t = (2 * Math.PI * k) / n;
const c = f(Math.cos(t));
const y = tall && c !== 0 ? Math.sign(c) * Math.max(Math.abs(c), 0.75) : c;
return {
left: `calc(50% - ${f(Math.sin(t))} * min(50% - ${seat.w / 2 + 6}px, ${maxRx}px))`,
top: `calc(50% + ${y} * (50% - ${seat.h / 2 + 6}px))`,
};
});
}
function usePhone(): boolean {
const q = '(max-width: 719px)';
const [m, setM] = useState(() => typeof window !== 'undefined' && window.matchMedia(q).matches);
useEffect(() => {
const mq = window.matchMedia(q);
const on = () => setM(mq.matches);
mq.addEventListener('change', on);
return () => mq.removeEventListener('change', on);
}, []);
return m;
}
// ---------------------------------------------------------------- seat
function Gauge({ start, end, now }: { start: number; end: number; now: number }) {
const frac = Math.max(0, Math.min(1, (end - now) / Math.max(1, end - start)));
const r = 12;
const c = 2 * Math.PI * r;
const color = frac < 0.34 ? '#ef4444' : frac < 0.67 ? '#f59e0b' : '#22c55e';
return (
<svg className="gauge" viewBox="0 0 30 30" aria-hidden>
<circle cx="15" cy="15" r={r + 2} fill="#111827" />
<circle cx="15" cy="15" r={r} fill="none" stroke="#374151" strokeWidth="4" />
<circle cx="15" cy="15" r={r} fill="none" stroke={color} strokeWidth="4" strokeDasharray={c} strokeDashoffset={c * (1 - frac)} transform="rotate(-90 15 15)" strokeLinecap="round" />
</svg>
);
}
function seatLabel(s: SeatView, players: Record<string, PublicUser>, me: string | null): string {
const parts = [`${nick(players, s.id)}${s.id === me ? '(나)' : ''}`];
parts.push(s.top ? `펼친 카드 ${cardName(s.top)}` : '펼친 카드 없음');
parts.push(`덱 ${s.deck}장`);
if (s.pile > 1) parts.push(`펼친 더미 ${s.pile}장`);
if (s.status === 'out') parts.push('탈락');
if (s.status === 'left') parts.push('나감');
if (s.isTurn) parts.push('뒤집을 차례');
return parts.join(', ');
}
function Seat({ s, v, pos, players, me, now }: { s: SeatView; v: HalliGalliView; pos: Pos; players: Record<string, PublicUser>; me: string | null; now: number }) {
const u = userOf(players, s.id);
const locked = s.lockedUntil > now && s.status === 'playing';
return (
<li
className="hg-seat"
data-turn={s.isTurn && v.phase === 'playing'}
data-out={s.status !== 'playing'}
data-me={s.id === me}
data-seat={s.id}
style={{ left: pos.left, top: pos.top }}
aria-label={seatLabel(s, players, me)}
>
<div className="nm">
<Avatar user={u} size={18} />
<span>{s.id === me ? '나' : u.nickname}</span>
</div>
<div className="hg-pile">
{s.pile > 1 && <span className="shadow1" />}
{s.top ? (
<span key={s.top.key} className="face">
<FruitCardArt card={s.top} />
</span>
) : (
<span className="empty" />
)}
{s.isTurn && v.phase === 'playing' && <Gauge start={v.turnStartedAt} end={v.turnDeadline} now={now} />}
</div>
<div className="hg-meta" aria-hidden>
<span>
<i className="hg-back" /> {s.deck}
</span>
{s.status === 'out' && (
<span className="hg-tag" style={{ background: '#dc2626' }}>
탈락
</span>
)}
{s.status === 'left' && (
<span className="hg-tag" style={{ background: '#6b7280' }}>
나감
</span>
)}
{locked && (
<span className="hg-tag" style={{ background: '#f59e0b', color: '#111827' }}>
잠금
</span>
)}
</div>
</li>
);
}
// ---------------------------------------------------------------- bell feedback
interface Bubble {
key: string;
kind: 'ok' | 'bad' | 'late';
text: string;
}
interface Ghost {
key: string;
from: Pos;
to: Pos;
card: Pick<TopCard, 'fruit' | 'count'> | null;
delay: number;
}
function bellText(v: HalliGalliView, players: Record<string, PublicUser>): Bubble | null {
const b = v.lastBell;
if (!b) return null;
const who = b.player === v.me ? '내가' : `${nick(players, b.player)}님이`;
if (b.correct) return { key: `b${b.seq}`, kind: 'ok', text: `땡! ${who} 먼저 쳤어요 · ${b.fruit ? FRUIT_KO[b.fruit] : ''} 5개 · ${b.cards}장 가져가요` };
const duel = v.phase === 'ended' && (v.options.duelWrongLoses || v.officialDuel);
if (duel) return { key: `b${b.seq}`, kind: 'bad', text: `삐! ${who} 틀렸어요 · 승부가 끝났어요` };
const lost = b.cards === 0 ? '' : v.options.wrongToPot ? ` · 종 밑에 ${b.cards}장` : ` · ${b.cards}장 나눠 줘요`;
return { key: `b${b.seq}`, kind: 'bad', text: `삐! ${who} 틀렸어요${lost}` };
}
// ---------------------------------------------------------------- board
function HalliGalliBoard({ view: v, me, players, act, pending }: BoardProps<HalliGalliView>) {
const now = useServerNow(100);
const phone = usePhone();
const rtt = useRoom((s) => s.rttMs);
const [bubble, setBubble] = useState<Bubble | null>(null);
const [ghosts, setGhosts] = useState<Ghost[]>([]);
const [hit, setHit] = useState(0);
const [announce, setAnnounce] = useState('');
const sentFor = useRef<number | null>(null);
const prev = useRef<HalliGalliView | null>(null);
// Seats rotated so that I sit at the bottom (spectators: first seat at the bottom).
const myIdx = v.seats.findIndex((s) => s.id === me);
const rot = myIdx < 0 ? v.seats : [...v.seats.slice(myIdx), ...v.seats.slice(0, myIdx)];
const many = rot.length >= 5;
const cw = phone ? (many ? 46 : 54) : many ? 70 : 86;
const sw = phone ? (many ? 74 : 86) : many ? 116 : 132;
const pos = seatPositions(rot.length, { w: sw, h: cw * 1.4 + (phone ? 50 : 58) }, phone ? 400 : 380, phone);
const posOf = (id: string) => pos[rot.findIndex((s) => s.id === id)] ?? CENTER;
const mine = v.seats.find((s) => s.id === me) ?? null;
const playing = v.phase === 'playing';
const lockedLeft = mine ? Math.max(0, mine.lockedUntil - now) : 0;
const canRing = !!mine && mine.status === 'playing' && playing && lockedLeft === 0;
const myTurn = !!mine && playing && v.turn === me;
const canFlip = myTurn && !v.options.autoFlip && !v.pendingFlip && mine.deck > 0 && !pending;
const ring = () => {
if (!canRing || sentFor.current === v.tableVersion) return;
sentFor.current = v.tableVersion;
setHit((h) => h + 1);
sounds.ding();
haptic(30);
act({ type: 'ring', version: v.tableVersion, ...(v.options.latencyComp === 'light' && rtt !== null ? { rtt } : {}) });
};
const flip = () => {
if (!canFlip) return;
act({ type: 'flip' });
};
const ringRef = useRef(ring);
const flipRef = useRef(flip);
ringRef.current = ring;
flipRef.current = flip;
// Keyboard: Space = bell, Enter = flip (not while typing; Enter on a focused button keeps its own meaning).
useEffect(() => {
const typing = (t: EventTarget | null) => t instanceof HTMLElement && (t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName));
const down = (e: KeyboardEvent) => {
if (typing(e.target) || e.altKey || e.ctrlKey || e.metaKey) return;
if (e.code === 'Space' || e.key === ' ') {
e.preventDefault();
if (!e.repeat) ringRef.current();
} else if (e.key === 'Enter' && !(e.target instanceof HTMLButtonElement || e.target instanceof HTMLAnchorElement)) {
if (!e.repeat) flipRef.current();
}
};
const up = (e: KeyboardEvent) => {
if ((e.code === 'Space' || e.key === ' ') && !typing(e.target)) e.preventDefault();
};
window.addEventListener('keydown', down);
window.addEventListener('keyup', up);
return () => {
window.removeEventListener('keydown', down);
window.removeEventListener('keyup', up);
};
}, []);
// "늦었어요" and other bell rejects come back as `reject` messages.
useEffect(
() =>
socket.subscribe((m) => {
if (m.t === 'reject' && m.reason.startsWith('늦었어요')) {
setBubble({ key: `late${Date.now()}`, kind: 'late', text: m.reason });
setAnnounce(m.reason);
}
}),
[],
);
// Diff against the previous view: flips, bells, card flights.
useEffect(() => {
const p = prev.current;
prev.current = v;
if (!p) return;
const b = v.lastBell;
if (b && b.seq !== p.lastBell?.seq) {
const bub = bellText(v, players);
if (bub) {
setBubble(bub);
setAnnounce(bub.text);
}
const to = posOf(b.player);
const fly: Ghost[] = [];
if (b.correct) {
if (b.player !== me) sounds.ding();
else haptic([40, 30, 80]);
p.seats.forEach((s, i) => s.top && fly.push({ key: `g${b.seq}-${s.id}`, from: posOf(s.id), to, card: s.top, delay: i * 40 }));
if (p.pot > 0) fly.push({ key: `g${b.seq}-pot`, from: CENTER, to, card: null, delay: 0 });
} else {
sounds.buzz();
if (b.player === me) haptic([90, 50, 90]);
const from = posOf(b.player);
const targets = v.options.wrongToPot ? [CENTER] : p.seats.filter((s) => s.id !== b.player && s.status === 'playing').map((s) => posOf(s.id));
targets.slice(0, Math.max(1, b.cards)).forEach((t, i) => fly.push({ key: `w${b.seq}-${i}`, from, to: t, card: null, delay: i * 60 }));
}
setGhosts(fly);
return;
}
if (v.tableVersion !== p.tableVersion && v.lastFlipBy) {
sounds.stone();
const s = v.seats.find((x) => x.id === v.lastFlipBy);
if (s?.top) setAnnounce(`${s.id === me ? '내가' : `${nick(players, s.id)}님이`} ${cardName(s.top)}를 뒤집었어요`);
}
if (v.turn === me && p.turn !== me && playing) haptic(20);
}, [v]); // eslint-disable-line react-hooks/exhaustive-deps
// A new table (or a judged bell) lets me ring again.
useEffect(() => {
if (sentFor.current !== null && sentFor.current !== v.tableVersion) sentFor.current = null;
}, [v.tableVersion]);
useEffect(() => {
if (!ghosts.length) return;
const t = setTimeout(() => setGhosts([]), 1000);
return () => clearTimeout(t);
}, [ghosts]);
useEffect(() => {
if (!bubble) return;
const t = setTimeout(() => setBubble(null), bubble.kind === 'late' ? 1300 : 1900);
return () => clearTimeout(t);
}, [bubble]);
const bellSize = phone ? (rot.length >= 5 ? 128 : 'min(40vw, 156px)') : 'clamp(140px, 20vh, 190px)';
const countdown = v.phase === 'countdown' ? Math.max(0, Math.ceil((v.countdownEndsAt - now) / 1000)) : 0;
const totals = FRUITS.map((f) => ({ f, n: v.seats.reduce((a, s) => a + (s.top && s.top.fruit === f ? s.top.count : 0), 0) }));
const timeLeft = v.endsAt !== null ? Math.max(0, v.endsAt - Math.max(now, v.countdownEndsAt)) : null;
const ping = rtt;
return (
<div className="hg">
<style>{CSS}</style>
<div className="hg-top">
<div className="grp">
{timeLeft !== null && (
<span className={`hg-pill${timeLeft < 60_000 ? ' warn' : ''}`} role="timer" aria-label={`남은 시간 ${Math.floor(timeLeft / 60_000)}분 ${Math.floor((timeLeft % 60_000) / 1000)}초`}>
⏱ {Math.floor(timeLeft / 60_000)}:{String(Math.floor((timeLeft % 60_000) / 1000)).padStart(2, '0')}
</span>
)}
{v.officialDuel && playing && <span className="hg-pill warn">2명 남음 · 다음 종으로 끝나요</span>}
</div>
<div className="grp">
{!phone && <span className="hg-pill hg-keys">스페이스 바 = 종 · Enter = 뒤집기</span>}
{v.options.showRtt && ping !== null && (
<span className={`hg-pill${ping >= 200 ? ' warn' : ''}`} aria-label={`내 연결 지연 ${ping}밀리초`}>
<i className="hg-dot" style={{ background: ping < 80 ? '#16a34a' : ping < 200 ? '#eab308' : '#dc2626' }} aria-hidden />
{ping >= 200 ? `연결이 느려요 · ${ping}ms` : `핑 ${ping}ms`}
</span>
)}
</div>
</div>
<div
className="hg-table"
role="region"
aria-label="카드 테이블"
style={{ '--bell': typeof bellSize === 'number' ? `${bellSize}px` : bellSize, '--cw': `${cw}px`, '--sw': `${sw}px` } as CSSProperties}
>
<ul style={{ listStyle: 'none', margin: 0, padding: 0 }} aria-label="사람들의 펼친 카드">
{rot.map((s, i) => (
<Seat key={s.id} s={s} v={v} pos={pos[i]!} players={players} me={me} now={now} />
))}
</ul>
<div className="hg-center">
<button
type="button"
className="hg-bell"
disabled={!canRing}
onPointerDown={(e) => {
// Fire on press, not release: every millisecond counts.
if (e.button !== 0) return;
e.preventDefault();
ring();
}}
onClick={(e) => {
// Keyboard activation (Enter on the focused bell); pointer presses were handled on pointerdown.
if (e.detail === 0) ring();
}}
aria-label={canRing ? '종 치기 (스페이스 바)' : lockedLeft > 0 ? '종 잠금 중이에요' : '종 (지금은 칠 수 없어요)'}
aria-keyshortcuts="Space"
>
<span key={hit} className="art" data-ring={hit > 0}>
<BellArt />
</span>
<span className="lbl" aria-hidden>
종!
</span>
{lockedLeft > 0 && (
<span
className="lock"
aria-hidden
style={{ background: `conic-gradient(rgba(17,24,39,.55) ${(lockedLeft / Math.max(1, v.options.wrongRingLockoutMs)) * 360}deg, transparent 0)` }}
/>
)}
</button>
{v.pot > 0 && (
<span className="hg-pot" aria-label={`종 밑 카드 ${v.pot}장`}>
종 밑 {v.pot}장
</span>
)}
</div>
{ghosts.map((g) => (
<span
key={g.key}
className="hg-ghost"
style={{ '--fx': g.from.left, '--fy': g.from.top, '--tx': g.to.left, '--ty': g.to.top, '--d': `${g.delay}ms` } as CSSProperties}
>
<FruitCardArt card={g.card} />
</span>
))}
{bubble && (
<div key={bubble.key} className={`hg-bubble ${bubble.kind}`} aria-hidden>
{bubble.text}
</div>
)}
{countdown > 0 && (
<div className="hg-count" aria-hidden>
{countdown}
<small>과일이 딱 5개가 되면 종을 쳐요!</small>
</div>
)}
</div>
{mine && mine.status === 'playing' && v.phase !== 'ended' && (
<div className="hg-mine">
<div className="info" aria-label={`내 덱 ${mine.deck}장`}>
<span className="small muted">내 덱</span>
<b>
<i className="hg-back" /> {mine.deck}장
</b>
</div>
<button type="button" className={`btn ${myTurn ? 'btn-primary' : 'btn-secondary'} hg-flipbtn`} disabled={!canFlip} onClick={flip} aria-keyshortcuts="Enter">
{v.options.autoFlip ? (myTurn ? '곧 자동으로 뒤집혀요' : '자동 뒤집기') : v.pendingFlip && myTurn ? '곧 뒤집혀요…' : myTurn ? '뒤집기' : `${v.turn ? nick(players, v.turn) : ''}님 차례`}
</button>
</div>
)}
{mine && mine.status !== 'playing' && v.phase !== 'ended' && (
<div className="hg-panel">
<strong>{mine.status === 'left' ? '게임에서 나갔어요.' : '탈락했어요. 남은 게임을 지켜봐요.'}</strong>
</div>
)}
{v.options.kidMode && v.phase !== 'ended' && (
<div className="hg-kid" aria-label="과일 합계">
<strong>5개가 되면 종을 쳐요</strong>
{totals.map(({ f, n }) => (
<div key={f} className="hg-bar" data-five={n === 5}>
<FruitIcon fruit={f} />
<span>{FRUIT_KO[f]}</span>
<span className="track">
<i style={{ width: `${Math.min(100, (n / 8) * 100)}%`, background: FRUIT_INK[f] }} />
</span>
<span>{n}</span>
</div>
))}
</div>
)}
<p className="sr-only" aria-live="polite">
{announce}
</p>
</div>
);
}
// ---------------------------------------------------------------- side
function logText(e: LogEntry, players: Record<string, PublicUser>, me: string | null): string {
const n = (id: string) => (id === me ? '내가' : `${nick(players, id)}님이`);
switch (e.t) {
case 'collect':
return `${n(e.p)} 종을 쳐서 ${e.cards}장을 가져갔어요 (${FRUIT_KO[e.fruit]} 5개)`;
case 'wrong':
return e.toPot ? `${n(e.p)} 틀려서 ${e.gave}장을 종 밑에 넣었어요` : `${n(e.p)} 틀려서 ${e.gave}장을 나눠 줬어요`;
case 'duelWrong':
return `${n(e.p)} 틀려서 ${nick(players, e.by)}님이 ${e.cards}장을 가져갔어요`;
case 'out':
return e.why === 'left' ? `${n(e.p)} 나갔어요` : e.why === 'strikeOut' ? `${n(e.p)} 3번 틀려서 탈락했어요` : `${n(e.p)} 탈락했어요`;
}
}
function rulesLine(o: HalliGalliOptions): string {
const parts = [o.endMode === 'official' ? '2명 남으면 다음 종에서 끝' : '한 명 남을 때까지', o.timeLimitMin ? `${o.timeLimitMin}분 제한` : '시간 제한 없음'];
parts.push(o.autoFlip ? `자동 뒤집기 ${o.kidMode ? 8 : o.flipSeconds}초` : `뒤집기 ${o.kidMode ? 8 : o.flipSeconds}초`);
if (o.wrongRingLockoutMs) parts.push(`틀리면 종 ${o.wrongRingLockoutMs / 1000}초 잠금`);
if (o.penaltyEscalation) parts.push('벌칙 점점 늘어남');
if (o.wrongToPot) parts.push('벌칙은 종 밑으로');
if (o.strikeOut) parts.push('3번 틀리면 탈락');
if (o.duelWrongLoses) parts.push('1대1 오답은 패배');
if (o.latencyComp === 'light') parts.push('지연 보정');
return parts.join(' · ');
}
function HalliGalliSide({ view: v, me, players }: BoardProps<HalliGalliView>) {
return (
<>
<style>{CSS}</style>
<div className="card stack" style={{ gap: 6, padding: 12 }}>
<strong>할리갈리{v.phase === 'ended' ? ' · 게임 끝' : ''}</strong>
<span className="small muted">{rulesLine(v.options)}</span>
<ol style={{ listStyle: 'none', margin: 0, padding: 0, display: 'grid', gap: 4 }}>
{v.seats.map((s) => (
<li key={s.id} className="row" style={{ gap: 6, flexWrap: 'nowrap', opacity: s.status === 'playing' ? 1 : 0.55 }} aria-label={`${seatLabel(s, players, me)}, 정답 ${s.correct}번, 오답 ${s.wrong}번`}>
<Avatar user={userOf(players, s.id)} size={24} />
<span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontWeight: s.id === me ? 800 : 600 }}>
{s.isTurn ? '▶ ' : ''}
{nick(players, s.id)}
{s.id === me ? ' (나)' : ''}
{s.status === 'out' ? ' · 탈락' : s.status === 'left' ? ' · 나감' : ''}
</span>
<span className="small" aria-hidden>
✓{s.correct} ✗{s.wrong}
</span>
<strong style={{ fontVariantNumeric: 'tabular-nums' }} aria-hidden>
{s.cards}장
</strong>
</li>
))}
</ol>
{(v.pot > 0 || v.removed > 0) && (
<span className="small muted">
{v.pot > 0 ? `종 밑 ${v.pot}장` : ''}
{v.pot > 0 && v.removed > 0 ? ' · ' : ''}
{v.removed > 0 ? `게임에서 뺀 카드 ${v.removed}장` : ''}
</span>
)}
</div>
{v.result && (
<div className="card stack" style={{ gap: 4, padding: 12 }}>
<strong>최종 순위</strong>
{v.result.ranking.map((r) => (
<span key={r.id}>
{r.rank}등 {nick(players, r.id)}
<span className="muted small">
{' '}
· {r.cards}장 · 정답 {r.correct} · 오답 {r.wrong}
</span>
</span>
))}
<span className="small muted">{v.result.summary}</span>
</div>
)}
{v.log.length > 0 && (
<details className="card wide-open" open style={{ padding: 12 }}>
<summary style={{ cursor: 'pointer', fontWeight: 700 }}>기록</summary>
<ol className="hg-log">
{[...v.log].reverse().map((e) => (
<li key={e.n} className="small">
{logText(e, players, me)}
</li>
))}
</ol>
</details>
)}
</>
);
}
// ---------------------------------------------------------------- status
function isMyTurn(v: HalliGalliView, me: string | null): boolean {
return !!me && v.phase === 'playing' && v.turn === me && v.seats.some((s) => s.id === me && s.status === 'playing');
}
function statusText(v: HalliGalliView, me: string | null, players: Record<string, PublicUser>): string {
if (v.phase === 'ended') return v.result?.summary ?? '게임이 끝났어요';
if (v.phase === 'countdown') return '곧 시작해요! 과일이 딱 5개가 되면 종을 쳐요';
const mine = v.seats.find((s) => s.id === me);
if (mine && mine.status !== 'playing') return '탈락했어요 · 남은 게임을 지켜봐요';
if (isMyTurn(v, me)) return v.options.autoFlip ? '내 차례예요 · 곧 자동으로 뒤집혀요' : '내 차례예요 · 카드를 뒤집어 주세요';
return `${v.turn ? nick(players, v.turn) : ''}님 차례예요 · 5개가 되면 종!`;
}
// ---------------------------------------------------------------- options
function Seg<T extends string | number | boolean | null>({ value, options, onChange, label }: { value: T; options: [T, string][]; onChange: (v: T) => void; label: string }) {
return (
<div className="segmented" role="group" aria-label={label}>
{options.map(([k, l]) => (
<button key={String(k)} type="button" aria-pressed={value === k} onClick={() => onChange(k)}>
{l}
</button>
))}
</div>
);
}
function Field({ label, children, help }: { label: string; children: ReactNode; help?: string }) {
return (
<div className="stack" style={{ gap: 6 }}>
<strong>{label}</strong>
{children}
{help && <span className="small muted">{help}</span>}
</div>
);
}
const onOff: [boolean, string][] = [
[true, '켜기'],
[false, '끄기'],
];
function Options({ value: o, onChange }: OptionsProps<HalliGalliOptions>) {
const [more, setMore] = useState(false);
const set = (patch: Partial<HalliGalliOptions>) => onChange({ ...o, ...patch });
return (
<div className="stack" style={{ gap: 16 }}>
<Field label="끝나는 방식" help={o.endMode === 'official' ? '3명 이상일 때, 2명이 남으면 다음 종에서 카드가 많은 사람이 이겨요.' : '한 사람이 남을 때까지 해요.'}>
<Seg
label="끝나는 방식"
value={o.endMode}
options={[
['lastStanding', '한 명 남을 때까지'],
['official', '2명 남으면 다음 종에서'],
]}
onChange={(endMode) => set({ endMode })}
/>
</Field>
<Field label="전체 제한 시간" help="시간이 다 되면 카드가 많은 사람이 이겨요.">
<Seg
label="전체 제한 시간"
value={o.timeLimitMin}
options={[
[5, '5분'],
[10, '10분'],
[15, '15분'],
[null, '없음'],
]}
onChange={(timeLimitMin) => set({ timeLimitMin })}
/>
</Field>
<Field label="뒤집기 제한 시간" help="시간 안에 안 뒤집으면 자동으로 뒤집혀요.">
<Seg
label="뒤집기 제한 시간"
value={o.flipSeconds}
options={[
[2, '2초'],
[3, '3초'],
[5, '5초'],
[8, '8초'],
]}
onChange={(flipSeconds) => set({ flipSeconds })}
/>
</Field>
<Field label="어린이 모드" help="8초 뒤집기, 과일 합계 막대를 보여 줘요.">
<Seg label="어린이 모드" value={o.kidMode} options={onOff} onChange={(kidMode) => set({ kidMode })} />
</Field>
<button type="button" className="btn btn-ghost" onClick={() => setMore(!more)} aria-expanded={more}>
{more ? '▲ 자세한 설정 닫기' : '▼ 자세한 설정'}
</button>
{more && (
<>
<Field label="카드를 자동으로 뒤집기">
<Seg label="카드를 자동으로 뒤집기" value={o.autoFlip} options={onOff} onChange={(autoFlip) => set({ autoFlip })} />
</Field>
<Field label="뒤집은 뒤 다음 뒤집기까지 최소 간격" help="연결이 느린 사람도 같은 카드를 보고 종을 칠 시간이에요.">
<Seg
label="뒤집기 최소 간격"
value={o.flipGuardMs}
options={[
[400, '0.4초'],
[600, '0.6초'],
[1000, '1초'],
]}
onChange={(flipGuardMs) => set({ flipGuardMs })}
/>
</Field>
<Field label="틀린 종 뒤 종 잠금">
<Seg
label="틀린 종 뒤 종 잠금"
value={o.wrongRingLockoutMs}
options={[
[0, '없음'],
[1000, '1초'],
[2000, '2초'],
]}
onChange={(wrongRingLockoutMs) => set({ wrongRingLockoutMs })}
/>
</Field>
<Field label="나누고 남은 카드">
<Seg
label="나누고 남은 카드"
value={o.leftoverCards}
options={[
['pot', '종 밑에 두기'],
['remove', '빼기'],
]}
onChange={(leftoverCards) => set({ leftoverCards })}
/>
</Field>
<Field label="지연 보정" help="켜면 연결 지연의 절반(최대 0.08초)만큼 먼저 친 것으로 봐요.">
<Seg
label="지연 보정"
value={o.latencyComp}
options={[
['off', '끄기'],
['light', '가볍게'],
]}
onChange={(latencyComp) => set({ latencyComp })}
/>
</Field>
<Field label="내 핑 표시">
<Seg label="내 핑 표시" value={o.showRtt} options={onOff} onChange={(showRtt) => set({ showRtt })} />
</Field>
<Field label="1대1에서 틀린 종을 치면 바로 패배">
<Seg label="1대1 오답 패배" value={o.duelWrongLoses} options={onOff} onChange={(duelWrongLoses) => set({ duelWrongLoses })} />
</Field>
<Field label="틀린 종 3번이면 탈락">
<Seg label="오답 3번 탈락" value={o.strikeOut} options={onOff} onChange={(strikeOut) => set({ strikeOut })} />
</Field>
<Field label="틀릴 때마다 벌칙 장수 늘리기">
<Seg label="벌칙 늘리기" value={o.penaltyEscalation} options={onOff} onChange={(penaltyEscalation) => set({ penaltyEscalation })} />
</Field>
<Field label="벌칙 카드를 종 밑으로">
<Seg label="벌칙 카드를 종 밑으로" value={o.wrongToPot} options={onOff} onChange={(wrongToPot) => set({ wrongToPot })} />
</Field>
</>
)}
</div>
);
}
function optionsSummary(o: HalliGalliOptions): string {
return rulesLine(o) + (o.kidMode ? ' · 어린이 모드' : '');
}
// ---------------------------------------------------------------- rules
function Rules({ options }: { options?: HalliGalliOptions }) {
const o = options;
return (
<div className="stack">
<p>
과일 카드 56장(딸기·바나나·라임·자두)을 똑같이 나눠 받고, 차례대로 내 덱 맨 위 카드를 뒤집어 내 앞에 펼쳐요. 펼쳐진 카드 맨 위에 보이는 과일 가운데 <strong>한 종류가 딱 5개</strong>가 되면 가운데 종을 쳐요.
</p>
<div className="row" style={{ gap: 8, alignItems: 'center' }}>
<span style={{ width: 44, height: 62, display: 'inline-block' }}>
<FruitCardArt card={{ fruit: 'strawberry', count: 2 }} />
</span>
+
<span style={{ width: 44, height: 62, display: 'inline-block' }}>
<FruitCardArt card={{ fruit: 'strawberry', count: 3 }} />
</span>
+
<span style={{ width: 44, height: 62, display: 'inline-block' }}>
<FruitCardArt card={{ fruit: 'banana', count: 4 }} />
</span>
<span>= 딸기 5개 → 땡!</span>
</div>
<p>
<strong>먼저 친 사람</strong>(서버에 먼저 도착한 사람)이 펼쳐진 카드를 모두 가져가 덱 맨 아래에 넣고, 다음에 뒤집어요. 6개나 4개일 때 치면 틀린 거예요. 틀리면 다른 사람에게 1장씩 줘요
{o?.wrongToPot ? '(이 방에서는 종 밑에 넣어요)' : ''}. 조금 늦게 친 종은 "늦었어요"로 무시되고 벌칙은 없어요.
</p>
<p>
차례가 왔는데 덱이 비어 있으면 탈락해요. 덱이 비어도 차례가 오기 전에 종을 맞히면 다시 살아나요.{' '}
{o?.endMode === 'official' ? '2명이 남으면 다음 종에서 카드가 많은 사람이 이겨요.' : '마지막까지 남은 사람이 이겨요.'}{' '}
{o?.timeLimitMin === null ? '' : `${o?.timeLimitMin ?? 10}분이 지나면 카드(덱 + 내 펼친 카드)가 많은 사람이 이겨요.`}
</p>
<p>
PC에서는 <strong>스페이스 바</strong>로 종을, <strong>Enter</strong>로 뒤집기를 할 수 있어요. 휴대폰은 종 그림을 누르면 바로 울려요.
</p>
</div>
);
}
const ui: GameUI<HalliGalliView, HalliGalliOptions> = {
Board: HalliGalliBoard,
Side: HalliGalliSide,
statusText,
isMyTurn,
Options,
optionsSummary,
Rules,
};
export default ui;

View File

@@ -0,0 +1,323 @@
/**
* 마작패 그림 (docs/games/mahjong.md §9): 직접 그린 SVG. 만수 = 숫자 + "만", 통수 = 동그라미, 삭수 = 막대, 자패 = 한글.
* 작은 크기(버림패)에서는 숫자를 크게 쓴 간단한 그림으로 바꿔 휴대폰에서도 읽히게 한다. 수패는 종류마다 안쪽 테두리
* 무늬(실선·점선·긴 점선)가 달라 색만으로 구별하지 않아도 된다.
*/
import type { CSSProperties } from 'react';
const SUIT_KO: Record<string, string> = { m: '만수', p: '통수', s: '삭수' };
const SUIT_SHORT: Record<string, string> = { m: '만', p: '통', s: '삭' };
const HONOR_KO = ['동', '남', '서', '북', '백', '발', '중'];
const INK = '#1c1e21';
const RED = '#c8202a';
const BLUE = '#1f5fbf';
const GREEN = '#17804a';
/** "5m0r" → { n: 5, suit: 'm', red: true }; honors use suit 'z' and n 1..7. */
export function parseCode(code: string): { n: number; suit: 'm' | 'p' | 's' | 'z'; red: boolean; kind: string } {
return { n: Number(code[0]), suit: code[1] as 'm' | 'p' | 's' | 'z', red: code.endsWith('r'), kind: code.slice(0, 2) };
}
/** Screen-reader name: "만수 5", "적도라 통수 5", "동", "중". */
export function tileName(code: string): string {
const t = parseCode(code);
if (t.suit === 'z') return HONOR_KO[t.n - 1]!;
return `${t.red ? '적도라 ' : ''}${SUIT_KO[t.suit]} ${t.n}`;
}
/** Short name: "5만", "3통", "7삭", "동" (kind codes like "5m" work too). */
export function tileShort(code: string): string {
const t = parseCode(code);
return t.suit === 'z' ? HONOR_KO[t.n - 1]! : `${t.n}${SUIT_SHORT[t.suit]}`;
}
/** Sort key: 만 → 통 → 삭 → 자, by number, red five first. */
export function tileOrder(code: string): number {
const t = parseCode(code);
return 'mpsz'.indexOf(t.suit) * 100 + t.n * 10 + (t.red ? 0 : 1) + Number(code[2] ?? 0) / 10;
}
const PIN_POS: Record<number, [number, number][]> = {
1: [[30, 38]],
2: [
[30, 22],
[30, 54],
],
3: [
[16, 18],
[30, 38],
[44, 58],
],
4: [
[18, 22],
[42, 22],
[18, 54],
[42, 54],
],
5: [
[17, 18],
[43, 18],
[30, 38],
[17, 58],
[43, 58],
],
6: [
[19, 16],
[41, 16],
[19, 38],
[41, 38],
[19, 60],
[41, 60],
],
7: [
[14, 13],
[30, 21],
[46, 29],
[19, 46],
[41, 46],
[19, 63],
[41, 63],
],
8: [
[19, 12],
[41, 12],
[19, 30],
[41, 30],
[19, 47],
[41, 47],
[19, 64],
[41, 64],
],
9: [
[14, 16],
[30, 16],
[46, 16],
[14, 38],
[30, 38],
[46, 38],
[14, 60],
[30, 60],
[46, 60],
],
};
function pinColor(n: number, i: number, red: boolean): string {
if (red) return RED;
if (n === 1) return RED;
if (n === 2) return i === 0 ? GREEN : BLUE;
if (n === 3) return [BLUE, RED, GREEN][i]!;
if (n === 4) return i === 0 || i === 3 ? BLUE : GREEN;
if (n === 5) return i === 2 ? RED : i === 0 || i === 4 ? BLUE : GREEN;
if (n === 6) return i < 2 ? GREEN : RED;
if (n === 7) return i < 3 ? GREEN : RED;
if (n === 8) return BLUE;
return i < 3 ? BLUE : i < 6 ? RED : GREEN;
}
function Pins({ n, red }: { n: number; red: boolean }) {
const pos = PIN_POS[n]!;
const r = n === 1 ? 17 : n <= 3 ? 9.5 : n <= 5 ? 8.5 : n <= 7 ? 7 : 7;
return (
<>
{pos.map(([x, y], i) => {
const c = pinColor(n, i, red);
return (
<g key={i}>
<circle cx={x} cy={y} r={r} fill="none" stroke={c} strokeWidth={n === 1 ? 4 : 2.6} />
<circle cx={x} cy={y} r={r * 0.42} fill={c} />
</g>
);
})}
</>
);
}
/** Bamboo sticks: rows of [x positions], with red sticks for some centres. */
const SOU_ROWS: Record<number, number[][]> = {
2: [[30], [30]],
3: [[30], [18, 42]],
4: [
[20, 40],
[20, 40],
],
5: [[18, 42], [30], [18, 42]],
6: [
[15, 30, 45],
[15, 30, 45],
],
7: [[30], [15, 30, 45], [15, 30, 45]],
8: [
[12, 24, 36, 48],
[12, 24, 36, 48],
],
9: [
[15, 30, 45],
[15, 30, 45],
[15, 30, 45],
],
};
function Sticks({ n, red }: { n: number; red: boolean }) {
if (n === 1) {
// 1삭: one big stick with knots and a red top.
return (
<g>
<rect x={24} y={12} width={12} height={54} rx={6} fill={GREEN} />
<rect x={24} y={12} width={12} height={12} rx={6} fill={RED} />
<line x1={24} y1={39} x2={36} y2={39} stroke="#fff" strokeWidth={2} />
<line x1={24} y1={52} x2={36} y2={52} stroke="#fff" strokeWidth={2} />
</g>
);
}
const rows = SOU_ROWS[n]!;
const top = 8;
const bottom = 70;
const gap = 4;
const h = (bottom - top - gap * (rows.length - 1)) / rows.length;
const w = rows.some((r) => r.length === 4) ? 7 : 8;
return (
<>
{rows.map((xs, ri) =>
xs.map((x, ci) => {
const y = top + ri * (h + gap);
const center = (n === 5 && ri === 1) || (n === 7 && ri === 0) || (n === 9 && ci === 1);
const c = red ? RED : center ? RED : GREEN;
return (
<g key={`${ri}-${ci}`}>
<rect x={x - w / 2} y={y} width={w} height={h} rx={w / 2} fill={c} />
<line x1={x - w / 2} y1={y + h / 2} x2={x + w / 2} y2={y + h / 2} stroke="#fff" strokeWidth={1.4} />
</g>
);
}),
)}
</>
);
}
/** Inner frame pattern per suit (colour-blind help, §9). */
const FRAME_DASH: Record<string, string | undefined> = { m: undefined, p: '1.5 3', s: '6 3' };
function Face({ code, compact }: { code: string; compact: boolean }) {
const t = parseCode(code);
const frame = t.suit !== 'z' && (
<rect x={5} y={5} width={50} height={66} rx={5} fill="none" stroke="#c9bfa8" strokeWidth={1.6} strokeDasharray={FRAME_DASH[t.suit]} />
);
if (t.suit === 'z') {
const ch = HONOR_KO[t.n - 1]!;
if (t.n === 5) {
return (
<>
<rect x={10} y={10} width={40} height={56} rx={4} fill="none" stroke={BLUE} strokeWidth={4} />
<text x={30} y={48} textAnchor="middle" fontSize={compact ? 28 : 22} fontWeight={800} fill={BLUE}>
{ch}
</text>
</>
);
}
const color = t.n === 6 ? GREEN : t.n === 7 ? RED : INK;
return (
<text x={30} y={compact ? 53 : 52} textAnchor="middle" fontSize={compact ? 40 : 36} fontWeight={900} fill={color}>
{ch}
</text>
);
}
const numColor = t.red ? RED : INK;
if (compact) {
if (t.suit === 'm') {
return (
<>
{frame}
<text x={30} y={50} textAnchor="middle" fontSize={44} fontWeight={900} fill={numColor}>
{t.n}
</text>
<rect x={14} y={58} width={32} height={7} rx={3.5} fill={RED} />
</>
);
}
if (t.suit === 'p') {
return (
<>
{frame}
<circle cx={30} cy={38} r={23} fill="none" stroke={t.red ? RED : BLUE} strokeWidth={4} />
<text x={30} y={53} textAnchor="middle" fontSize={40} fontWeight={900} fill={t.red ? RED : BLUE}>
{t.n}
</text>
</>
);
}
return (
<>
{frame}
<rect x={7} y={12} width={6} height={52} rx={3} fill={t.red ? RED : GREEN} />
<rect x={47} y={12} width={6} height={52} rx={3} fill={t.red ? RED : GREEN} />
<text x={30} y={53} textAnchor="middle" fontSize={40} fontWeight={900} fill={t.red ? RED : GREEN}>
{t.n}
</text>
</>
);
}
if (t.suit === 'm') {
return (
<>
{frame}
<text x={30} y={40} textAnchor="middle" fontSize={34} fontWeight={900} fill={numColor}>
{t.n}
</text>
<text x={30} y={66} textAnchor="middle" fontSize={21} fontWeight={800} fill={RED}>
만
</text>
</>
);
}
return (
<>
{frame}
{t.suit === 'p' ? <Pins n={t.n} red={t.red} /> : <Sticks n={t.n} red={t.red} />}
<text x={52} y={14} textAnchor="end" fontSize={11} fontWeight={800} fill={t.red ? RED : '#6b6352'}>
{t.n}
</text>
</>
);
}
export interface TileProps {
/** Tile code ("5m0r") or kind code ("5m"); null draws the back. */
code: string | null;
/** Width in px (height is 4:3). */
w: number;
sideways?: boolean;
dim?: boolean;
className?: string;
style?: CSSProperties;
/** aria-hidden by default: the parent describes the tile. */
label?: string;
}
export function Tile({ code, w, sideways, dim, className, style, label }: TileProps) {
const h = Math.round((w * 4) / 3);
const compact = w < 30;
const svg = (
<svg viewBox="0 0 60 80" width={w} height={h} style={{ display: 'block', ...(sideways ? { transform: 'rotate(-90deg)' } : {}) }} aria-hidden>
{code === null ? (
<>
<rect x={1} y={1} width={58} height={78} rx={8} fill="#1d5a8c" stroke="#123b5c" strokeWidth={2} />
<rect x={7} y={7} width={46} height={62} rx={5} fill="#2a73ad" />
</>
) : (
<>
<rect x={1} y={1} width={58} height={78} rx={8} fill="#d9cdb0" stroke="#9c8f72" strokeWidth={2} />
<rect x={2} y={2} width={56} height={71} rx={7} fill="#fffcf3" />
<Face code={code} compact={compact} />
</>
)}
</svg>
);
const box: CSSProperties = sideways
? { width: h, height: w, display: 'inline-flex', alignItems: 'center', justifyContent: 'center', flex: 'none', ...style }
: { width: w, height: h, display: 'inline-block', flex: 'none', ...style };
return (
<span className={`mj-tile${dim ? ' dim' : ''}${className ? ` ${className}` : ''}`} style={box} role={label ? 'img' : undefined} aria-label={label} aria-hidden={label ? undefined : true}>
{svg}
</span>
);
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -14,6 +14,11 @@ export const GAME_UI: Record<string, () => Promise<{ default: GameUI }>> = {
'one-card': () => import('./one-card'),
rummikub: () => import('./rummikub'),
'davinci-code': () => import('./davinci-code'),
mahjong: () => import('./mahjong'),
gostop: () => import('./gostop'),
bang: () => import('./bang'),
'halli-galli': () => import('./halli-galli'),
marble: () => import('./marble'),
};
const cache = new Map<string, GameUI>();

View File

@@ -12,7 +12,11 @@ export const GAME_ICON: Record<string, string> = {
'one-card': '♣',
rummikub: '🔢',
'davinci-code': '🔐',
gostop: '🌸',
marble: '🏙️',
bang: '🤠',
'halli-galli': '🔔',
};
/** Shown on the home screen so users know what's coming (docs/12-roadmap.md). */
export const UPCOMING = ['마작', '고스톱', '뱅', '할리갈리'];
export const UPCOMING = ['달무티', '보석 상인', '쿼리도', '라스베가스', '바퀴벌레 포커', '구십구', '도블', '그림 맞히기', '마피아'];

View File

@@ -38,6 +38,13 @@ export const sounds = {
warn: () => tone(520, 90, 'square', 0.06),
win: () => [523, 659, 784, 1046].forEach((f, i) => tone(f, 180, 'triangle', 0.18, i * 120)),
lose: () => [392, 330].forEach((f, i) => tone(f, 220, 'sine', 0.15, i * 180)),
/** Bell "땡!" (할리갈리 correct bell). */
ding: () => {
tone(1568, 700, 'sine', 0.22);
tone(2350, 450, 'sine', 0.08);
},
/** Buzzer "삐!" (wrong answer). */
buzz: () => tone(180, 260, 'square', 0.08),
};
export function haptic(pattern: number | number[]): void {

View File

@@ -6,7 +6,7 @@ import { expect, test } from 'bun:test';
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
import { join } from 'node:path';
const ROOTS = ['games/poker', 'games/seotda', 'games/gostop', 'components/cards'].map((p) => join(import.meta.dir, p));
const ROOTS = ['games/poker', 'games/seotda', 'games/gostop', 'games/marble', 'components/cards'].map((p) => join(import.meta.dir, p));
const FORBIDDEN: [RegExp, string][] = [
[/₩|\$\s?\d|\d\s?\$/, '통화 기호'],
[/\d[\d,]*\s*(만\s*)?원(?![가-힣])/, '"원" 단위'],

View File

@@ -46,9 +46,19 @@
## M5. 마작·고스톱
- 리치 마작(4인, 3인), 점수 계산기 테스트(역·부 계산 사례 100개 이상), 초보 모드.
- 고스톱/맞고.
- **진행 결과(2026-10-05)**: 마작(4인 동풍전·반장전, 3인 산마, 초보 모드)·고스톱(맞고·3인·광팔기) 규칙 + 화면 완료.
마작 점수 계산 테스트 161(1~4판 × 20~110부 표 전체, 만관~더블 역만, 손패 계산 사례 약 60) + 규칙 77,
고스톱 규칙 58. 게임마다 무작위 플레이 1,000판(패·점수 보존, 비공개 정보 유출 없음, 시간 초과 행동 합법).
마작 울기 창은 남은 시간을 공개하지 않음(누가 울 수 있는지 드러나지 않게). 3인 최종 점수는 합 0 보정.
E2E: 마작 4인(PC 방장 + 휴대폰 3명) 첫 국 끝까지 → 다음 국 시작, 맞고 한 판(고·스톱 포함).
## M6. 뱅 + 실시간 반응 + 땅따먹기
- 뱅(반응 창 처리), 할리갈리(서버 도착 순서 판정), 주사위 땅따먹기.
- **진행 결과(2026-10-05)**: 세 게임 + 할리갈리 규칙 + 화면 완료. 규칙 테스트 뱅 62·할리갈리 46(+방 단위 서버 테스트)·땅따먹기 80,
게임마다 무작위 플레이 1,000판. 할리갈리는 같은 밀리초에 두 사람이 쳐도 서버에 먼저 도착한 사람만 인정(서버 테스트로 확인).
엔진에 선택 훅 `timeoutNotBefore` 추가(접속 끊긴 사람 차례에 타이머가 0ms로 반복 발동하던 문제 방지, 기존 게임 영향 없음).
뱅은 내 역할을 꾹 눌러야 보이게(옆 사람 훔쳐보기 방지). 땅따먹기 금액은 놀이용 포인트(P) 표기.
E2E: 뱅 4인 10턴, 할리갈리 3인 24번 뒤집기(맞는 종·틀린 종), 땅따먹기 15라운드 한 판 끝까지.
## M7. 보드게임카페 인기 게임
- 달무티, 보석 상인, 쿼리도, 라스베가스, 바퀴벌레 포커, 구십구.

View File

@@ -150,6 +150,116 @@ async function yutStep(p: Page): Promise<boolean> {
}
}
/**
* 고스톱: one UI step for `p` if it has something to do — answer an open decision sheet (고/스톱: 고 once, then 스톱;
* choose a floor card; 흔들기/폭탄; 총통 → 그냥 치기; 국진) or play a hand card (green-bordered match first; phones
* use tap-to-lift, so a lifted card is tapped again). Returns what it did, or null.
*/
async function gostopStep(p: Page, state: { go: number; stop: number; picks: number; plays: number; sheets: number }): Promise<string | null> {
const dialog = p.getByRole('dialog');
if (await dialog.count()) {
const name = (await dialog.first().getAttribute('aria-label')) ?? '';
if (name === '게임 결과') return null;
if (name === '고 또는 스톱') {
const goFirst = state.go === 0;
if (goFirst && state.stop === 0) await p.screenshot({ path: join(SHOTS, 'gostop-gostop-dialog.png') });
await dialog.getByRole('button', { name: goFirst ? /^고/ : /^스톱/ }).click({ timeout: 1500 });
if (goFirst) state.go++;
else state.stop++;
return goFirst ? 'go' : 'stop';
}
if (name === '총통') {
await dialog.getByRole('button', { name: /^그냥 치기/ }).click({ timeout: 1500 });
return 'chongtong';
}
const pick = dialog.locator('.gs-pick button, .gs-big .btn').first();
if (await pick.count()) {
if (state.sheets === 0) await p.screenshot({ path: join(SHOTS, 'gostop-choice-dialog.png') });
await pick.click({ timeout: 1500 });
state.sheets++;
if (name.includes('고르기')) state.picks++;
return name;
}
return null;
}
const status = (await p.locator('.turn-status').innerText().catch(() => '')).trim();
if (!status.includes('내 차례예요')) return null;
const card = p.locator('.gs-hcard[data-match="true"]:not([disabled])').first();
const any = p.locator('.gs-hcard:not([disabled])').first();
const target = (await card.count()) ? card : any;
if (!(await target.count())) {
const dummy = p.getByRole('button', { name: /^빈패 내기/ });
if (await dummy.isEnabled().catch(() => false)) {
await dummy.click({ timeout: 1500 });
state.plays++;
return 'dummy';
}
return null;
}
const label = await target.getAttribute('aria-label');
await target.click({ timeout: 1500 });
const lifted = p.locator(`.gs-hcard[data-lifted="true"][aria-label="${label}"]`);
if (await lifted.count()) await lifted.click({ timeout: 1500 });
state.plays++;
return 'play';
}
/** 할리갈리: fruit totals of the face-up tops, read from the seat labels ("펼친 카드 딸기 3개"). */
async function halliTotals(p: Page): Promise<Record<string, number>> {
const labels = await p.locator('.hg-seat').evaluateAll((els) => els.map((e) => e.getAttribute('aria-label') ?? ''));
const t: Record<string, number> = { 딸기: 0, 바나나: 0, 라임: 0, 자두: 0 };
for (const l of labels) {
const m = /펼친 카드 (딸기|바나나|라임|자두) (\d)개/.exec(l);
if (m) t[m[1]!]! += Number(m[2]);
}
return t;
}
/**
* 주사위 땅따먹기: one UI step for whoever can act on `p` — answer a trade, sell, buy/build (or skip),
* pick a space-travel square on the board, escape the island or roll. Returns the label pressed.
*/
const tag = (p: Page) => ((p.viewportSize()?.width ?? 0) < 600 ? 'phone' : 'pc');
async function marbleStep(p: Page, stats: Record<string, number>): Promise<string | null> {
const prompt = p.locator('.mb-prompt.mine');
if (!(await prompt.count())) return null;
const btn = (name: string | RegExp) => prompt.getByRole('button', { name, exact: typeof name === 'string' });
const press = async (name: string | RegExp) => {
const b = btn(name);
if (!(await b.count()) || !(await b.first().isEnabled().catch(() => false))) return false;
return b.first().click({ timeout: 1500 }).then(() => true, () => false);
};
// Space travel: tap a glowing square on the board, check the preview, fly there.
if (await btn(/^출발로 가기/).count()) {
const cell = p.locator('g.mb-cell.pick').nth(5 + (stats.space ?? 0) * 7);
await cell.click({ timeout: 1500 }).catch(() => {});
const go = p.getByRole('button', { name: '🚀 여기로 가기' });
if (await go.waitFor({ timeout: 1500 }).then(() => true, () => false)) {
await p.getByText('여기로 가면').waitFor({ timeout: 1000 });
if (!stats.space) await p.screenshot({ path: join(SHOTS, `marble-space-${tag(p)}.png`) });
await go.click();
stats.space = (stats.space ?? 0) + 1;
return 'space';
}
return (await press(/^출발로 가기/)) ? 'space-start' : null;
}
// First buy prompt on each screen size, for the visual check.
const shotKey = `shot-${tag(p)}`;
if (!stats[shotKey] && (await btn('사기').count())) {
stats[shotKey] = 1;
await p.screenshot({ path: join(SHOTS, `marble-buy-${tag(p)}.png`) });
}
for (const name of ['거절', /^가장 싼 것부터 팔기/, '사기', '짓기', '인수하기', '🎲 탈출 굴리기', '🎲 주사위 굴리기', '🎲 한 번 더 굴리기', '안 사기', '안 짓기', '안 하기'] as const) {
if (await press(name)) {
const key = typeof name === 'string' ? name : 'sell';
stats[key] = (stats[key] ?? 0) + 1;
return key;
}
}
return null;
}
const PLANS: Record<string, Plan> = {
rummikub: {
// PC host + phone guest, first meld "없음" so hint plays happen early; 10 real turns through the UI.
@@ -401,6 +511,374 @@ const PLANS: Record<string, Plan> = {
['ArrowUp', 'ArrowUp', 'Enter', 'ArrowUp', 'Enter'],
],
},
mahjong: {
// 4인 리치 마작 (PC host + 3 phones): whoever has the turn taps the right-most legal tile (lift) and presses
// the "… 버리기" button; claim windows are answered with 패스 (론/쯔모 when offered). Plays until the first hand
// ends (win or exhaustive draw, ~70 discards), checks the result screen on every device and confirms it.
players: 4,
drive: async (pages) => {
const statusOf = (p: Page) => p.locator('.turn-status').innerText().catch(() => '');
let discards = 0;
let passes = 0;
let wins = 0;
let results = 0;
const shotClaim = new Set<string>();
for (let t = 0; t < 4000 && discards < 100 && results < 1; t++) {
let acted = false;
for (const [pi, p] of pages.entries()) {
const status = await statusOf(p);
const win = p.getByRole('button', { name: /^(쯔모|론)!$/ });
if ((await win.count()) && (await win.first().isEnabled().catch(() => false))) {
await win.first().click({ timeout: 1000 }).catch(() => {});
wins++;
acted = true;
break;
}
const pass = p.getByRole('group', { name: '울기·론 선택' }).getByRole('button', { name: '패스' });
if (await pass.count()) {
const dev = pi === 0 ? 'pc' : 'phone';
if (!shotClaim.has(dev)) {
await p.screenshot({ path: join(SHOTS, `mahjong-3-claim-${dev}.png`) });
shotClaim.add(dev);
}
await pass.click({ timeout: 1000 }).catch(() => {});
passes++;
acted = true;
break;
}
const next = p.getByRole('button', { name: /^(다음 국으로|최종 결과 보기)$/ });
if ((await next.count()) && (await next.isEnabled().catch(() => false))) {
// The hand is over: every device shows the result, then everyone confirms → the next hand starts.
await pages[0]!.screenshot({ path: join(SHOTS, 'mahjong-4-result-pc.png') });
await pages[1]!.screenshot({ path: join(SHOTS, 'mahjong-4-result-phone.png'), fullPage: true });
for (const q of pages) {
await q.getByRole('region', { name: /결과$/ }).waitFor({ timeout: 5000 });
const b = q.getByRole('button', { name: /^(다음 국으로|최종 결과 보기)$/ });
if (await b.isEnabled().catch(() => false)) await b.click({ timeout: 1000 }).catch(() => {});
}
await pages[0]!.getByText(/^동1국|^동2국/).first().waitFor({ timeout: 10_000 });
results++;
acted = true;
break;
}
if (!status.includes('내 차례예요')) continue;
const tiles = p.locator('button.mj-htile[data-legal="true"]');
const n = await tiles.count();
if (!n) continue;
const tile = tiles.nth(n - 1);
await tile.click({ timeout: 1000 }).catch(() => {});
if (discards === 4 && pi > 0) await p.screenshot({ path: join(SHOTS, 'mahjong-2-selected-phone.png') });
const send = p.getByRole('button', { name: /버리기$/ });
if ((await send.count()) && (await send.isEnabled().catch(() => false))) await send.click({ timeout: 1000 }).catch(() => {});
await p.waitForFunction((s) => document.querySelector('.turn-status')?.textContent?.trim() !== s, status.trim(), { timeout: 5000 }).catch(() => {});
discards++;
acted = true;
break;
}
if (!acted) await pages[0]!.waitForTimeout(120);
}
if (results === 0) throw new Error(`no hand finished after ${discards} discards`);
// The next hand has started: the table and my hand are back on every screen.
for (const q of pages) await q.locator('.mj-table').waitFor({ timeout: 10_000 });
console.log(` mahjong: ${discards} discards, ${passes} passes, ${wins} wins, ${results} hand finished → next hand started`);
},
},
gostop: {
// 맞고: PC host + phone guest, 1판. Real turns through the UI — hand cards (tap twice on the phone), floor-card choices,
// 흔들기/폭탄 sheets, one 고 and then 스톱 — until the hand ends (스톱 or 나가리) and the game result shows.
lobby: async (host) => {
await host.getByRole('group', { name: '판 수' }).getByRole('button', { name: '1판', exact: true }).click();
await host.getByRole('button', { name: '설정 저장' }).click();
await host.getByText(/^1판 · /).first().waitFor();
},
drive: async (pages) => {
const [host, guest] = pages as [Page, Page];
const state = { go: 0, stop: 0, picks: 0, plays: 0, sheets: 0 };
let shot = false;
const until = Date.now() + 240_000;
const over = async () => (await host.getByRole('dialog', { name: '게임 결과' }).count()) > 0;
while (Date.now() < until && !(await over())) {
let acted = false;
for (const p of pages) {
const did = await gostopStep(p, state).catch(() => null);
if (did) {
acted = true;
await p.waitForTimeout(250);
break;
}
}
if (state.plays >= 8 && !shot) {
shot = true;
await host.waitForTimeout(1500);
await host.screenshot({ path: join(SHOTS, 'gostop-mid-pc.png') });
await guest.screenshot({ path: join(SHOTS, 'gostop-mid-phone.png'), fullPage: true });
await guest.emulateMedia({ colorScheme: 'dark' });
await host.emulateMedia({ colorScheme: 'dark' });
await guest.screenshot({ path: join(SHOTS, 'gostop-mid-phone-dark.png'), fullPage: true });
await host.screenshot({ path: join(SHOTS, 'gostop-mid-pc-dark.png') });
await guest.emulateMedia({ colorScheme: 'light' });
await host.emulateMedia({ colorScheme: 'light' });
}
if (!acted) await host.waitForTimeout(150);
}
if (!(await over())) throw new Error(`hand did not end (${JSON.stringify(state)})`);
await guest.getByRole('dialog', { name: '게임 결과' }).waitFor();
await host.screenshot({ path: join(SHOTS, 'gostop-result-pc.png') });
await guest.screenshot({ path: join(SHOTS, 'gostop-result-phone.png') });
// Close the result sheet to see the round breakdown underneath.
await host.keyboard.press('Escape');
await host.getByText(/1판 결과/).first().waitFor();
await host.screenshot({ path: join(SHOTS, 'gostop-breakdown-pc.png') });
if (state.plays < 10) throw new Error(`only ${state.plays} cards were played`);
console.log(` gostop: ${state.plays} plays, ${state.sheets} choice sheets (${state.picks} card picks), 고 ${state.go}, 스톱 ${state.stop}`);
},
},
bang: {
// PC host + 3 phone guests, reaction time 25s: real turns through the UI — draw, play usable cards
// (뱅! at a highlighted target → confirm, other untargeted cards by double tap), answer reaction
// sheets (빗나감!/맥주/결투/인디언!/잡화점), discard down to the hand limit, end the turn.
players: 4,
lobby: async (host) => {
await host.getByRole('group', { name: '반응 제한 시간' }).getByRole('button', { name: '25초' }).click();
await host.getByRole('button', { name: '설정 저장' }).click();
await host.getByText(/반응 25초/).first().waitFor();
},
drive: async (pages) => {
const stats = { turns: 0, bangs: 0, plays: 0, reactions: 0, discards: 0 };
let finished = false;
let shot = false;
let midShot = false;
const tryClick = (l: ReturnType<Page['locator']>) => l.click({ timeout: 1500 }).then(() => true, () => false);
/** One UI step on this page; true when something was pressed. */
const step = async (p: Page): Promise<boolean> => {
const modal = p.locator('.bg-modal');
if (await modal.count()) {
const store = modal.locator('button.bg-card');
const title = (await modal.locator('h2').innerText().catch(() => '')).trim();
if (title.startsWith('3장 중')) {
await store.nth(0).click();
await store.nth(1).click();
await modal.getByRole('button', { name: '이 2장 받기' }).click();
} else if (title.startsWith('잡화점')) await store.first().click();
else {
const use = modal.getByRole('button', { name: /(내기|마시기)$/ });
if (!(await use.count()) || !(await tryClick(use.first()))) {
if (!(await tryClick(modal.getByRole('button', { name: /^(포기하고 맞기|맞기|쓰러지기)/ }).first()))) return false;
}
}
stats.reactions++;
return true;
}
const draw = p.getByRole('button', { name: '카드 더미에서 2장 받기' });
if (await draw.count()) return tryClick(draw);
const discardBtn = p.getByRole('button', { name: /장 버리기$/ });
if (await discardBtn.count()) {
const need = Number((await p.locator('.bg-actions').innerText()).match(/버릴 카드 (\d+)장/)?.[1] ?? 0);
const cards = p.locator('.bg-me .bg-hand button.bg-card');
for (let i = 0; i < need; i++) await cards.nth(i).click();
await discardBtn.click();
stats.discards++;
return true;
}
const end = p.getByRole('button', { name: '턴 마치기' });
if (!(await end.count()) || !(await end.isEnabled())) return false;
// Shoot first (once per turn is enough), then other untargeted cards, then end the turn.
const bang = p.locator('.bg-me button.bg-card[data-kind="bang"][data-ok="true"]').first();
if (await bang.count()) {
await bang.click();
const target = p.locator('button.bg-seat[data-target="ok"]').first();
await target.waitFor({ timeout: 2000 });
await target.click();
const fire = p.getByRole('button', { name: /에게 뱅!$/ });
await fire.click();
stats.bangs++;
if (!shot) {
shot = true;
await p.waitForTimeout(400);
for (const [i, q] of pages.entries()) await q.screenshot({ path: join(SHOTS, `bang-3-shot-${i === 0 ? 'pc' : `phone${i}`}.png`) });
}
return true;
}
const simple = ['beer', 'stagecoach', 'wellsFargo', 'saloon', 'barrel', 'mustang', 'scope', 'schofield', 'remington', 'carabine', 'winchester', 'volcanic', 'indians', 'gatling', 'generalStore'];
for (const k of simple) {
const c = p.locator(`.bg-me button.bg-card[data-kind="${k}"][data-ok="true"]`).first();
if (!(await c.count())) continue;
await c.click();
await c.click();
stats.plays++;
return true;
}
await end.click();
stats.turns++;
return true;
};
const until = Date.now() + 240_000;
while (Date.now() < until && stats.turns < 10) {
if (await pages[0]!.getByRole('dialog', { name: '게임 결과' }).count()) {
finished = true;
break;
}
let acted = false;
for (const p of pages) {
if (await step(p)) {
acted = true;
await p.waitForTimeout(250);
break;
}
}
if (stats.turns >= 4 && !midShot) {
midShot = true;
await pages[0]!.screenshot({ path: join(SHOTS, 'bang-4-mid-pc.png') });
await pages[1]!.screenshot({ path: join(SHOTS, 'bang-4-mid-phone.png'), fullPage: true });
await pages[1]!.emulateMedia({ colorScheme: 'dark' });
await pages[1]!.screenshot({ path: join(SHOTS, 'bang-4-mid-phone-dark.png') });
await pages[1]!.emulateMedia({ colorScheme: 'light' });
}
if (!acted) await pages[0]!.waitForTimeout(150);
}
console.log(` bang: ${stats.turns} turns, ${stats.bangs} 뱅!, ${stats.plays} other cards, ${stats.reactions} reactions, ${stats.discards} discards${finished ? ', game finished' : ''}`);
if (!finished && stats.turns < 8) throw new Error(`only ${stats.turns} turns were played`);
if (stats.bangs < 1) throw new Error('no 뱅! was played');
},
},
'halli-galli': {
// PC host + 2 phones. Real flips (Enter on PC, taps on phones); whenever some fruit totals exactly five a player
// rings the bell (PC: Space, phone: tap), plus at least one deliberate wrong bell. 24+ flips through the UI.
players: 3,
lobby: async (host) => {
await host.getByRole('group', { name: '뒤집기 제한 시간' }).getByRole('button', { name: '5초' }).click();
await host.getByRole('button', { name: '설정 저장' }).click();
await host.getByText(/뒤집기 5초/).first().waitFor();
},
drive: async (pages) => {
const [host, guest] = pages as [Page, Page, Page];
const myTurn = (p: Page) => p.evaluate(() => !!document.querySelector('.turn-status')?.textContent?.includes('내 차례예요'));
const ended = () => host.getByRole('dialog', { name: '게임 결과' }).count();
await host.locator('.hg-seat').first().waitFor();
// 3-second countdown first.
await host.waitForFunction(() => !document.querySelector('.hg-count'), null, { timeout: 8000 });
let flips = 0;
let rights = 0;
let wrongs = 0;
let lates = 0;
let shots = 0;
let ringer = 0;
const until = Date.now() + 150_000;
const bubble = (p: Page) => p.locator('.hg-bubble').first().innerText({ timeout: 2500 }).catch(() => '');
while (Date.now() < until && !(await ended()) && (flips < 24 || rights < 1 || wrongs < 1)) {
const totals = await halliTotals(host);
const five = Object.values(totals).includes(5);
const wantWrong = !five && wrongs === 0 && flips >= 4 && Object.values(totals).some((n) => n > 0);
if (five || wantWrong) {
const p = pages[ringer++ % pages.length]!;
const bell = p.locator('.hg-bell');
if (await bell.isEnabled().catch(() => false)) {
if (p === host) {
await host.evaluate(() => (document.activeElement as HTMLElement | null)?.blur());
await host.keyboard.press('Space');
} else await bell.tap();
const text = await bubble(p);
if (text.includes('땡')) rights++;
else if (text.includes('삐')) wrongs++;
else if (text.includes('늦었어요')) lates++;
if (shots === 0 && text) {
shots++;
await host.screenshot({ path: join(SHOTS, 'halli-galli-bell-pc.png') });
await guest.screenshot({ path: join(SHOTS, 'halli-galli-bell-phone.png') });
}
await host.waitForTimeout(400);
continue;
}
}
let flipped = false;
for (const p of pages) {
if (!(await myTurn(p))) continue;
const btn = p.getByRole('button', { name: '뒤집기', exact: true });
if (!(await btn.isEnabled().catch(() => false))) break;
const before = await p.locator('.hg-seat[data-me="true"] .hg-meta').innerText();
if (p === host) {
await host.evaluate(() => (document.activeElement as HTMLElement | null)?.blur());
await host.keyboard.press('Enter');
} else await btn.tap();
await p.waitForFunction((b) => document.querySelector('.hg-seat[data-me="true"] .hg-meta')?.textContent !== b, before, { timeout: 3000 }).catch(() => {});
flips++;
flipped = true;
break;
}
if (flips === 12 && flipped && shots < 2) {
shots = 2;
await host.waitForTimeout(300);
await host.screenshot({ path: join(SHOTS, 'halli-galli-mid-pc.png') });
await guest.screenshot({ path: join(SHOTS, 'halli-galli-mid-phone.png') });
await guest.emulateMedia({ colorScheme: 'dark' });
await guest.waitForTimeout(400); // let the theme transition finish
await guest.screenshot({ path: join(SHOTS, 'halli-galli-mid-phone-dark.png') });
await guest.emulateMedia({ colorScheme: 'light' });
await host.emulateMedia({ colorScheme: 'dark' });
await host.waitForTimeout(400);
await host.screenshot({ path: join(SHOTS, 'halli-galli-mid-pc-dark.png') });
await host.emulateMedia({ colorScheme: 'light' });
}
await host.waitForTimeout(flipped ? 650 : 120);
}
console.log(` halli-galli: ${flips} UI flips, ${rights} correct bells, ${wrongs} wrong bells, ${lates} late`);
if (!(await ended()) && (flips < 20 || wrongs < 1)) throw new Error('not enough flips / bells');
if (rights < 1 && !(await ended())) throw new Error('no correct bell happened');
},
},
marble: {
// PC host + phone guest, 15 rounds (shortest limit) played through the UI to the result: roll, buy or
// build (skip when short), pay tolls, sell when short, space travel via a board square. Mid-game shots.
lobby: async (host) => {
await host.getByRole('group', { name: '라운드 제한' }).getByRole('button', { name: '15라운드' }).click();
await host.getByRole('group', { name: '시작 포인트' }).getByRole('button', { name: '200P' }).click();
await host.getByRole('button', { name: '설정 저장' }).click();
await host.getByText(/^15라운드 · 시작 200P/).first().waitFor();
},
drive: async (pages) => {
const [host, guest] = pages as [Page, Page];
const stats: Record<string, number> = {};
let actions = 0;
let shot = false;
const until = Date.now() + 420_000;
const over = () => host.getByRole('dialog', { name: '게임 결과' }).count();
while (Date.now() < until && !(await over())) {
let acted = false;
for (const p of pages) {
if (await marbleStep(p, stats)) {
acted = true;
actions++;
await p.waitForTimeout(80);
break;
}
}
if (actions >= 40 && !shot && (stats['사기'] ?? 0) >= 3) {
shot = true;
await host.waitForTimeout(1300);
await host.screenshot({ path: join(SHOTS, 'marble-mid-pc.png') });
await guest.screenshot({ path: join(SHOTS, 'marble-mid-phone.png') });
await guest.screenshot({ path: join(SHOTS, 'marble-mid-phone-full.png'), fullPage: true });
await host.emulateMedia({ colorScheme: 'dark' });
await guest.emulateMedia({ colorScheme: 'dark' });
await host.waitForTimeout(400); // let the button colour transitions settle
await host.screenshot({ path: join(SHOTS, 'marble-mid-pc-dark.png') });
await guest.screenshot({ path: join(SHOTS, 'marble-mid-phone-dark.png') });
await host.emulateMedia({ colorScheme: 'light' });
await guest.emulateMedia({ colorScheme: 'light' });
}
if (acted && !stats.toll && (await host.locator('.mb-log li', { hasText: /→.*통행료/ }).count())) stats.toll = 1;
if (!acted) await host.waitForTimeout(120);
}
if (!(await over())) throw new Error(`game did not finish (${actions} UI actions: ${JSON.stringify(stats)})`);
await guest.getByRole('dialog', { name: '게임 결과' }).waitFor();
await host.waitForTimeout(600); // result dialog fade-in
await host.screenshot({ path: join(SHOTS, 'marble-result-pc.png') });
await guest.screenshot({ path: join(SHOTS, 'marble-result-phone.png') });
const rolls = (stats['🎲 주사위 굴리기'] ?? 0) + (stats['🎲 한 번 더 굴리기'] ?? 0) + (stats['🎲 탈출 굴리기'] ?? 0);
if (rolls < 15 || !stats['사기']) throw new Error(`too few real turns: ${JSON.stringify(stats)}`);
console.log(` marble: finished after ${actions} UI actions ${JSON.stringify(stats)}`);
},
},
};
const games = process.argv.slice(2).length ? process.argv.slice(2) : Object.keys(PLANS);

View File

@@ -57,6 +57,11 @@ export interface GameDefinition<S, A, V, O> {
deadline?(state: S): number | null;
/** Players the deadline applies to (default: activePlayers). */
timeoutPlayers?(state: S): PlayerId[];
/**
* Earliest time any timeout may fire, even for away players (whose deadline the room otherwise
* pulls forward to the end of the grace period). Real-time games use it to keep a minimum pace.
*/
timeoutNotBefore?(state: S): number | null;
onTimeout(state: S, player: PlayerId): A;
onLeave?(state: S, player: PlayerId): A | null;
result(state: S): GameResult | null;

View File

@@ -6,6 +6,7 @@
"exports": {
".": "./src/index.ts",
"./rummikub/rules": "./src/rummikub/rules.ts",
"./marble/board": "./src/marble/board.ts",
"./*": "./src/*/index.ts"
},
"dependencies": {

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,167 @@
/** 뱅! cards, characters and roles (docs/games/bang.md §3, §4). */
export type Suit = 'S' | 'H' | 'D' | 'C';
export type Rank = 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 'J' | 'Q' | 'K' | 'A';
export type CardKind =
| 'bang'
| 'missed'
| 'beer'
| 'saloon'
| 'stagecoach'
| 'wellsFargo'
| 'generalStore'
| 'panic'
| 'catBalou'
| 'duel'
| 'indians'
| 'gatling'
| 'barrel'
| 'mustang'
| 'scope'
| 'jail'
| 'dynamite'
| 'volcanic'
| 'schofield'
| 'remington'
| 'carabine'
| 'winchester';
/** Card ids are strings "c0".."c79" (fixed per card) so hidden-info checks can look for them in views. */
export interface Card {
id: string;
kind: CardKind;
suit: Suit;
rank: Rank;
}
/** A card without its id (logs, draw! results, stolen-card notes). */
export type CardFace = Omit<Card, 'id'>;
export type Role = 'sheriff' | 'deputy' | 'outlaw' | 'renegade';
export type CharacterId =
| 'bart'
| 'blackJack'
| 'calamity'
| 'elGringo'
| 'jesse'
| 'jourdonnais'
| 'kit'
| 'lucky'
| 'paul'
| 'pedro'
| 'rose'
| 'sid'
| 'slab'
| 'suzy'
| 'vulture'
| 'willy';
export const CHARACTER_IDS: CharacterId[] = [
'bart',
'blackJack',
'calamity',
'elGringo',
'jesse',
'jourdonnais',
'kit',
'lucky',
'paul',
'pedro',
'rose',
'sid',
'slab',
'suzy',
'vulture',
'willy',
];
export const CHARACTER_LIFE: Record<CharacterId, number> = {
bart: 4,
blackJack: 4,
calamity: 4,
elGringo: 3,
jesse: 4,
jourdonnais: 4,
kit: 4,
lucky: 4,
paul: 3,
pedro: 4,
rose: 4,
sid: 4,
slab: 4,
suzy: 4,
vulture: 4,
willy: 4,
};
/** §3.3 table: rank+suit per copy. */
const DECK_SPEC: [CardKind, string][] = [
['bang', 'AS 2D 3D 4D 5D 6D 7D 8D 9D 10D JD QD KD AD 2C 3C 4C 5C 6C 7C 8C 9C QH KH AH'],
['missed', '10C JC QC KC AC 2S 3S 4S 5S 6S 7S 8S'],
['beer', '6H 7H 8H 9H 10H JH'],
['panic', 'JH QH AH 8D'],
['catBalou', 'KH 9D 10D JD'],
['duel', 'QD JS 8C'],
['generalStore', '9C QS'],
['indians', 'KD AD'],
['stagecoach', '9S 9S'],
['gatling', '10H'],
['saloon', '5H'],
['wellsFargo', '3H'],
['barrel', 'QS KS'],
['mustang', '8H 9H'],
['scope', 'AS'],
['jail', 'JS 10S 4H'],
['dynamite', '2H'],
['volcanic', '10S 10C'],
['schofield', 'JC QC KS'],
['remington', 'KC'],
['carabine', 'AC'],
['winchester', '8S'],
];
function parseRank(r: string): Rank {
if (r === 'J' || r === 'Q' || r === 'K' || r === 'A') return r;
return Number(r) as Rank;
}
/** The 80 cards in a fixed order; `CARDS[n].id === "c" + n`. */
export const CARDS: Card[] = (() => {
const out: Card[] = [];
for (const [kind, list] of DECK_SPEC) {
for (const code of list.split(' ')) {
out.push({ id: `c${out.length}`, kind, suit: code.slice(-1) as Suit, rank: parseRank(code.slice(0, -1)) });
}
}
return out;
})();
const BY_ID = new Map(CARDS.map((c) => [c.id, c]));
export function cardById(id: string): Card {
const c = BY_ID.get(id);
if (!c) throw new Error(`bang: unknown card ${id}`);
return c;
}
export const isCardId = (id: string): boolean => BY_ID.has(id);
export const kindOf = (id: string): CardKind => cardById(id).kind;
export function faceOf(id: string): CardFace {
const { id: _id, ...face } = cardById(id);
return face;
}
export const BLUE_KINDS: CardKind[] = ['barrel', 'mustang', 'scope', 'jail', 'dynamite', 'volcanic', 'schofield', 'remington', 'carabine', 'winchester'];
export const WEAPON_RANGE: Partial<Record<CardKind, number>> = { volcanic: 1, schofield: 2, remington: 3, carabine: 4, winchester: 5 };
export const isBlue = (k: CardKind): boolean => BLUE_KINDS.includes(k);
export const isWeapon = (k: CardKind): boolean => WEAPON_RANGE[k] !== undefined;
/** Dynamite explodes on ♠2–♠9. */
export const explodes = (f: CardFace): boolean => f.suit === 'S' && typeof f.rank === 'number' && f.rank >= 2 && f.rank <= 9;
export const isHeart = (f: CardFace): boolean => f.suit === 'H';
/** §4 role table. */
export function rolesFor(n: number, deputyCount7 = false): Role[] {
const table: Record<number, [number, number, number]> = { 4: [0, 2, 1], 5: [1, 2, 1], 6: [1, 3, 1], 7: deputyCount7 ? [1, 4, 1] : [2, 3, 1] };
const row = table[n];
if (!row) throw new Error(`bang: ${n} players not supported`);
const [dep, out, ren] = row;
return ['sheriff', ...Array<Role>(dep).fill('deputy'), ...Array<Role>(out).fill('outlaw'), ...Array<Role>(ren).fill('renegade')];
}

View File

@@ -0,0 +1,43 @@
/** Distance and range (docs/games/bang.md §5.1). */
import type { PlayerId } from '@bg/engine';
import { WEAPON_RANGE, kindOf, type CardKind } from './cards';
import type { BangState, PlayerState } from './types';
export const hasEquip = (p: PlayerState, kind: CardKind): boolean => p.inPlay.some((id) => kindOf(id) === kind);
export function weaponOf(p: PlayerState): CardKind | null {
for (const id of p.inPlay) {
const k = kindOf(id);
if (WEAPON_RANGE[k] !== undefined) return k;
}
return null;
}
/** Weapon range, or 1 for the default Colt .45. */
export function rangeOf(p: PlayerState): number {
const w = weaponOf(p);
return w ? WEAPON_RANGE[w]! : 1;
}
/** Seats between two living players, the shorter way round, counting living players only. */
export function seatDistance(s: BangState, a: PlayerId, b: PlayerId): number {
const alive = s.players.filter((p) => p.alive);
const ia = alive.findIndex((p) => p.id === a);
const ib = alive.findIndex((p) => p.id === b);
if (ia < 0 || ib < 0) return Infinity;
const d = Math.abs(ia - ib);
return Math.min(d, alive.length - d);
}
/** distance(A→B) = max(1, seats + B's mustang/Paul Regret − A's scope/Rose Doolan). */
export function distance(s: BangState, a: PlayerId, b: PlayerId): number {
if (a === b) return 0;
const pa = s.players.find((p) => p.id === a)!;
const pb = s.players.find((p) => p.id === b)!;
let d = seatDistance(s, a, b);
if (hasEquip(pb, 'mustang')) d += 1;
if (pb.character === 'paul' && !pb.characterOffer) d += 1;
if (hasEquip(pa, 'scope')) d -= 1;
if (pa.character === 'rose' && !pa.characterOffer) d -= 1;
return Math.max(1, d);
}

View File

@@ -0,0 +1,829 @@
/**
* 뱅! effects and the turn / reaction state machine (docs/games/bang.md §5, §8).
* Every function mutates the (already copied) state passed in; randomness only via `ctx.rng`.
*/
import type { GameEvent, PlayerId, Rng } from '@bg/engine';
import { CHARACTER_LIFE, cardById, explodes, faceOf, isBlue, isHeart, isWeapon, kindOf, type CardFace, type CharacterId } from './cards';
import { hasEquip } from './distance';
import type { BangFinal, BangState, CheckPurpose, LogEntry, PlayAction, PlayerState, Reaction, Team } from './types';
export interface Ctx {
rng: Rng;
now: number;
events: GameEvent[];
}
export const AWAY_MS = 5000;
export const AWAY_AFTER = 3;
export const DISCARD_MIN_MS = 20_000;
const LOG_MAX = 60;
// ---------------------------------------------------------------- lookups
export const P = (s: BangState, id: PlayerId): PlayerState => {
const p = s.players.find((x) => x.id === id);
if (!p) throw new Error(`bang: no player ${id}`);
return p;
};
export const findPlayer = (s: BangState, id: PlayerId): PlayerState | undefined => s.players.find((x) => x.id === id);
export const living = (s: BangState): PlayerState[] => s.players.filter((p) => p.alive);
/** Character ability is active (picked, not still a hidden choose2 offer). */
export const is = (p: PlayerState, c: CharacterId): boolean => p.character === c && !p.characterOffer;
/** Who must answer this reaction. */
export function responder(r: Reaction): PlayerId {
switch (r.kind) {
case 'bang':
case 'indians':
return r.target;
case 'duel':
return r.actor;
case 'store':
return r.picker;
case 'dying':
case 'kitKeep':
case 'choose2':
return r.player;
}
}
export const reactionOf = (s: BangState, p: PlayerId): Reaction | undefined => s.reactions.find((r) => responder(r) === p);
/** Next living player clockwise after `from` (which may be dead). */
export function nextAlive(s: BangState, from: PlayerId): PlayerId {
const n = s.players.length;
const i = s.players.findIndex((p) => p.id === from);
for (let k = 1; k <= n; k++) {
const p = s.players[(i + k) % n]!;
if (p.alive) return p.id;
}
return from;
}
/** Living players clockwise starting with `from` (included when alive). */
export function clockwiseFrom(s: BangState, from: PlayerId): PlayerId[] {
const n = s.players.length;
const i = s.players.findIndex((p) => p.id === from);
const out: PlayerId[] = [];
for (let k = 0; k < n; k++) {
const p = s.players[(i + k) % n]!;
if (p.alive) out.push(p.id);
}
return out;
}
export function reactionMs(s: BangState, p: PlayerState): number {
return p.away ? AWAY_MS : s.options.reactionSeconds * 1000;
}
export function turnMs(s: BangState, p: PlayerState): number {
return p.away ? AWAY_MS : s.options.turnSeconds * 1000;
}
/** Cards kept inside reactions (general store, Kit Carlson) — counted for conservation. */
export function limboCards(s: BangState): string[] {
const out: string[] = [];
for (const r of s.reactions) {
if (r.kind === 'store') out.push(...r.revealed);
if (r.kind === 'kitKeep') out.push(...r.options);
}
return out;
}
// ---------------------------------------------------------------- log
type LogBody = LogEntry extends infer E ? (E extends LogEntry ? Omit<E, 'seq'> : never) : never;
export function log(s: BangState, ctx: Ctx, e: LogBody): void {
const entry = { seq: s.seq, ...e } as LogEntry;
s.log.push(entry);
if (s.log.length > LOG_MAX) s.log.splice(0, s.log.length - LOG_MAX);
// Events go to everyone: private card faces are dropped.
const { seq: _seq, t, ...rest } = entry as LogEntry & { vis?: PlayerId[]; card?: CardFace };
if ('vis' in rest) delete (rest as { card?: CardFace }).card;
ctx.events.push({ type: t, ...rest });
}
// ---------------------------------------------------------------- cards
function refill(s: BangState, ctx: Ctx): void {
if (s.deck.length > 0 || s.discard.length === 0) return;
const n = s.discard.length;
s.deck = ctx.rng.shuffle(s.discard);
s.discard = [];
log(s, ctx, { t: 'reshuffle', n });
}
/** Top card of the deck (refilling from the discard pile once); null when both are empty (§5.7). */
export function drawTop(s: BangState, ctx: Ctx): string | null {
if (s.deck.length === 0) refill(s, ctx);
return s.deck.pop() ?? null;
}
export function drawCards(s: BangState, ctx: Ctx, p: PlayerState, n: number, why: Extract<LogEntry, { t: 'draw' }>['why']): number {
let got = 0;
for (let i = 0; i < n; i++) {
const id = drawTop(s, ctx);
if (!id) break;
p.hand.push(id);
got++;
}
if (got > 0) log(s, ctx, { t: 'draw', p: p.id, n: got, why });
return got;
}
/** Removes cards from a hand; Suzy Lafayette draws one as soon as her hand is empty (§3.2). */
export function removeFromHand(s: BangState, ctx: Ctx, p: PlayerState, ids: string[]): void {
for (const id of ids) {
const i = p.hand.indexOf(id);
if (i < 0) throw new Error(`bang: ${id} not in ${p.id}'s hand`);
p.hand.splice(i, 1);
}
suzyCheck(s, ctx, p);
}
export function suzyCheck(s: BangState, ctx: Ctx, p: PlayerState): void {
if (p.alive && is(p, 'suzy') && p.hand.length === 0 && !s.winner) drawCards(s, ctx, p, 1, 'suzy');
}
/**
* "판정"(draw!): reveal the top card (Lucky Duke: two, keeping the better one — always the favourable card,
* so it is chosen automatically), then discard. `empty` is the result when no card is left at all.
*/
export function drawCheck(s: BangState, ctx: Ctx, p: PlayerState, purpose: CheckPurpose, good: (f: CardFace) => boolean, empty: boolean): boolean {
const n = is(p, 'lucky') ? 2 : 1;
const ids: string[] = [];
for (let i = 0; i < n; i++) {
const id = drawTop(s, ctx);
if (id) ids.push(id);
}
if (ids.length === 0) return empty;
const best = ids.find((id) => good(faceOf(id))) ?? ids[0]!;
const success = good(faceOf(best));
// The chosen card ends on top of the discard pile.
s.discard.push(...ids.filter((id) => id !== best), best);
const cards = ids.map(faceOf);
s.lastCheck = { player: p.id, purpose, cards, success, seq: s.seq };
log(s, ctx, { t: 'check', p: p.id, purpose, cards, success });
return success;
}
// ---------------------------------------------------------------- life
export function heal(s: BangState, ctx: Ctx, p: PlayerState, n: number, why: 'beer' | 'saloon' | 'sid', cards?: CardFace[]): void {
const before = p.life;
p.life = Math.min(p.maxLife, p.life + n);
if (p.life !== before || why !== 'saloon') log(s, ctx, { t: 'heal', p: p.id, n: p.life - before, why, ...(cards ? { cards } : {}) });
}
/** Bart Cassidy / El Gringo after losing `n` life to `source` (§5.5). */
function lossAbilities(s: BangState, ctx: Ctx, t: PlayerState, n: number, source: PlayerId | null): void {
if (!t.alive) return;
if (is(t, 'bart')) drawCards(s, ctx, t, n, 'bart');
if (is(t, 'elGringo') && source && source !== t.id) {
const src = P(s, source);
for (let i = 0; i < n && src.alive && src.hand.length > 0; i++) {
const id = src.hand[ctx.rng.int(src.hand.length)]!;
removeFromHand(s, ctx, src, [id]);
t.hand.push(id);
log(s, ctx, { t: 'take', p: t.id, from: src.id, how: 'gringo', card: faceOf(id), vis: [t.id, src.id] });
}
}
}
/** Loses `n` life because of `source`'s card (null = dynamite). */
export function damage(s: BangState, ctx: Ctx, targetId: PlayerId, n: number, source: PlayerId | null): void {
const t = P(s, targetId);
if (!t.alive || s.winner) return;
t.life -= n;
log(s, ctx, { t: 'hit', p: t.id, n, by: source, life: t.life });
if (s.options.drawBeforeDeath) lossAbilities(s, ctx, t, n, source);
if (t.life <= 0) openDying(s, ctx, t, source, n);
else if (!s.options.drawBeforeDeath) lossAbilities(s, ctx, t, n, source);
}
export const beerWorks = (s: BangState): boolean => living(s).length > 2;
/** Most life the player could still recover while dying (beers, Sid Ketchum's 2-card heals). */
export function maxRecovery(s: BangState, p: PlayerState): number {
const beers = beerWorks(s) ? p.hand.filter((id) => kindOf(id) === 'beer').length : 0;
if (!is(p, 'sid')) return beers;
return beers + Math.floor((p.hand.length - beers) / 2);
}
function openDying(s: BangState, ctx: Ctx, t: PlayerState, killer: PlayerId | null, lost: number): void {
if (maxRecovery(s, t) >= 1 - t.life) {
s.reactions.push({ kind: 'dying', player: t.id, killer, lost, source: killer, deadline: ctx.now + reactionMs(s, t) });
} else {
eliminate(s, ctx, t.id, killer);
}
}
/** The dying player is back to 1+ life. */
export function survived(s: BangState, ctx: Ctx, r: Extract<Reaction, { kind: 'dying' }>): void {
removeReaction(s, r);
if (!s.options.drawBeforeDeath) lossAbilities(s, ctx, P(s, r.player), r.lost, r.source);
}
export function removeReaction(s: BangState, r: Reaction): void {
const i = s.reactions.indexOf(r);
if (i >= 0) s.reactions.splice(i, 1);
}
// ---------------------------------------------------------------- elimination and the end
/** Reactions that cannot go on without `id` (§5.6). */
function dropReactionsOf(s: BangState, ctx: Ctx, id: PlayerId): void {
for (const r of [...s.reactions]) {
switch (r.kind) {
case 'bang':
case 'indians':
if (r.target === id) removeReaction(s, r);
break;
case 'duel':
if (r.actor === id || r.other === id) removeReaction(s, r);
break;
case 'dying':
case 'choose2':
if (r.player === id) removeReaction(s, r);
break;
case 'kitKeep':
if (r.player === id) {
s.discard.push(...r.options);
removeReaction(s, r);
}
break;
case 'store':
r.order = r.order.filter((x) => x !== id);
if (r.picker === id) storeAdvance(s, ctx, r);
break;
}
}
}
export function eliminate(s: BangState, ctx: Ctx, id: PlayerId, killer: PlayerId | null, left = false): void {
const v = P(s, id);
if (!v.alive) return;
v.alive = false;
v.life = Math.min(v.life, 0);
s.deathOrder.push(id);
log(s, ctx, { t: 'dead', p: id, role: v.role, by: killer, ...(left ? { left: true } : {}) });
dropReactionsOf(s, ctx, id);
const k = killer && killer !== id ? findPlayer(s, killer) : undefined;
if (k?.alive) {
if (v.role === 'outlaw') {
log(s, ctx, { t: 'reward', p: k.id, n: 3 });
drawCards(s, ctx, k, 3, 'reward');
}
if (k.role === 'sheriff' && v.role === 'deputy') {
log(s, ctx, { t: 'penalty', p: k.id });
const hand = [...k.hand];
s.discard.push(...k.inPlay);
k.inPlay = [];
if (hand.length) {
removeFromHand(s, ctx, k, hand);
s.discard.push(...hand);
}
}
}
const cards = [...v.hand, ...v.inPlay];
v.hand = [];
v.inPlay = [];
const vulture = living(s).find((p) => is(p, 'vulture'));
if (vulture && cards.length) {
vulture.hand.push(...cards);
log(s, ctx, { t: 'vulture', p: vulture.id, from: id, n: cards.length });
} else {
s.discard.push(...cards);
}
if (checkWin(s, ctx)) return;
if (s.turn.player === id && s.turn.phase !== 'draft') s.turn.phase = 'ended';
}
/** §6: checked after every death. */
export function checkWin(s: BangState, ctx: Ctx): boolean {
if (s.winner) return true;
const alive = living(s);
const sheriff = s.players.find((p) => p.role === 'sheriff')!;
let team: Team | null = null;
if (!sheriff.alive) team = alive.length === 1 && alive[0]!.role === 'renegade' ? 'renegade' : 'outlaws';
else if (!alive.some((p) => p.role === 'outlaw' || p.role === 'renegade')) team = 'law';
if (!team) return false;
finish(s, ctx, team);
return true;
}
const TEAM_ROLES: Record<Team, string[]> = { law: ['sheriff', 'deputy'], outlaws: ['outlaw'], renegade: ['renegade'] };
function finish(s: BangState, ctx: Ctx, team: Team): void {
s.winner = team;
for (const c of limboCards(s)) s.discard.push(c);
s.reactions = [];
s.turn.pausedAt = null;
const order = (a: PlayerState, b: PlayerState) =>
Number(b.alive) - Number(a.alive) || s.deathOrder.indexOf(b.id) - s.deathOrder.indexOf(a.id) || a.seat - b.seat;
const won = (p: PlayerState) => TEAM_ROLES[team].includes(p.role);
const winners = s.players.filter(won).sort(order);
const losers = s.players.filter((p) => !won(p)).sort(order);
const summary =
team === 'law'
? `보안관 팀 승리! ${s.round}라운드에 무법자와 배신자를 모두 잡았어요.`
: team === 'outlaws'
? `무법자 승리! 보안관이 ${s.round}라운드에 쓰러졌어요.`
: `배신자 승리! ${s.round}라운드에 보안관까지 쓰러뜨리고 혼자 살아남았어요.`;
const final: BangFinal = {
winnerTeam: team,
players: s.players.map((p) => ({
id: p.id,
role: p.role,
character: p.character,
alive: p.alive,
won: won(p),
deathOrder: p.alive ? null : s.deathOrder.indexOf(p.id) + 1,
})),
ranking: [...winners, ...losers].map((p) => p.id),
summary,
};
s.final = final;
log(s, ctx, { t: 'end', winner: team });
}
// ---------------------------------------------------------------- turn flow
export function startTurn(s: BangState, ctx: Ctx, id: PlayerId): void {
const p = P(s, id);
if (p.role === 'sheriff') s.round += 1;
s.turn = { player: id, phase: 'start', bangsPlayed: 0, deadline: ctx.now + turnMs(s, p), pausedAt: null, dynamiteChecked: false, jailChecked: false };
log(s, ctx, { t: 'turn', p: id, round: s.round });
}
/** Deals the starting hands after characters are fixed and gives the sheriff the first turn (§4). */
export function beginGame(s: BangState, ctx: Ctx): void {
for (const p of s.players) {
p.maxLife = CHARACTER_LIFE[p.character] + (p.role === 'sheriff' ? 1 : 0);
if (!p.alive) continue;
p.life = p.maxLife;
}
// One card at a time from the sheriff, clockwise.
const sheriff = s.players.find((p) => p.role === 'sheriff')!;
const order = clockwiseFrom(s, sheriff.id).map((id) => P(s, id));
const most = Math.max(...order.map((p) => p.life));
for (let r = 0; r < most; r++) for (const p of order) if (p.hand.length < p.life) p.hand.push(s.deck.pop()!);
startTurn(s, ctx, sheriff.alive ? sheriff.id : nextAlive(s, sheriff.id));
}
/** Turn start: dynamite, then jail (§5.2). */
function runStart(s: BangState, ctx: Ctx): void {
const p = P(s, s.turn.player);
if (!s.turn.dynamiteChecked) {
s.turn.dynamiteChecked = true;
const dyn = p.inPlay.find((id) => kindOf(id) === 'dynamite');
if (dyn) {
const safe = drawCheck(s, ctx, p, 'dynamite', (f) => !explodes(f), true);
p.inPlay = p.inPlay.filter((x) => x !== dyn);
if (!safe) {
s.discard.push(dyn);
log(s, ctx, { t: 'dynamite', p: p.id, exploded: true });
damage(s, ctx, p.id, 3, null);
return;
}
const to = P(s, nextAlive(s, p.id));
to.inPlay.push(dyn);
log(s, ctx, { t: 'dynamite', p: p.id, exploded: false, to: to.id });
}
}
if (!p.alive) return;
if (!s.turn.jailChecked) {
s.turn.jailChecked = true;
const jail = p.inPlay.find((id) => kindOf(id) === 'jail');
if (jail) {
const escaped = drawCheck(s, ctx, p, 'jail', isHeart, true);
p.inPlay = p.inPlay.filter((x) => x !== jail);
s.discard.push(jail);
log(s, ctx, { t: 'jail', p: p.id, escaped });
if (!escaped) {
s.turn.phase = 'ended';
return;
}
}
}
s.turn.phase = 'draw';
}
/** Jesse Jones / Pedro Ramirez have a real choice in phase 1; everyone else draws automatically. */
export function drawChoice(s: BangState, p: PlayerState): { jesse: PlayerId[]; pedro: boolean } | null {
const jesse = is(p, 'jesse') ? living(s).filter((x) => x.id !== p.id && x.hand.length > 0).map((x) => x.id) : [];
const pedro = is(p, 'pedro') && s.discard.length > 0;
return jesse.length || pedro ? { jesse, pedro } : null;
}
export function doDraw(s: BangState, ctx: Ctx, a: { jesseFrom?: PlayerId; pedroDiscard?: boolean }): void {
const p = P(s, s.turn.player);
s.turn.phase = 'play';
if (is(p, 'kit')) {
const options: string[] = [];
for (let i = 0; i < 3; i++) {
const id = drawTop(s, ctx);
if (id) options.push(id);
}
if (options.length <= 2) {
p.hand.push(...options);
if (options.length) log(s, ctx, { t: 'draw', p: p.id, n: options.length, why: 'kit' });
} else {
s.reactions.push({ kind: 'kitKeep', player: p.id, options, deadline: ctx.now + reactionMs(s, p) });
}
return;
}
if (a.jesseFrom) {
const src = P(s, a.jesseFrom);
const id = src.hand[ctx.rng.int(src.hand.length)]!;
removeFromHand(s, ctx, src, [id]);
p.hand.push(id);
log(s, ctx, { t: 'take', p: p.id, from: src.id, how: 'jesse', card: faceOf(id), vis: [p.id, src.id] });
} else if (a.pedroDiscard) {
const id = s.discard.pop()!;
p.hand.push(id);
log(s, ctx, { t: 'pedro', p: p.id, card: faceOf(id) });
}
const special = !!(a.jesseFrom || a.pedroDiscard);
if (is(p, 'blackJack')) {
drawCards(s, ctx, p, 1, 'phase');
const second = drawTop(s, ctx);
if (second) {
p.hand.push(second);
const f = faceOf(second);
const extra = f.suit === 'H' || f.suit === 'D';
log(s, ctx, { t: 'blackJack', p: p.id, card: f, extra });
if (extra) drawCards(s, ctx, p, 1, 'phase');
}
return;
}
drawCards(s, ctx, p, special ? 1 : 2, 'phase');
}
/**
* Runs the automatic parts of the turn until a decision is needed: pauses the turn timer while reactions
* are open, finishes the draft, starts turns, resolves dynamite / jail and automatic draws.
*/
export function settle(s: BangState, ctx: Ctx): void {
for (let guard = 0; guard < 500; guard++) {
if (s.winner) return;
if (s.reactions.length > 0) {
if (s.turn.pausedAt === null && (s.turn.phase === 'play' || s.turn.phase === 'discard')) s.turn.pausedAt = ctx.now;
return;
}
if (s.turn.pausedAt !== null) {
s.turn.deadline += ctx.now - s.turn.pausedAt;
s.turn.pausedAt = null;
}
const p = P(s, s.turn.player);
switch (s.turn.phase) {
case 'draft':
beginGame(s, ctx);
continue;
case 'ended':
startTurn(s, ctx, nextAlive(s, s.turn.player));
continue;
case 'start':
runStart(s, ctx);
continue;
case 'draw':
if (drawChoice(s, p)) return;
doDraw(s, ctx, {});
continue;
case 'discard':
if (p.hand.length <= p.life) {
s.turn.phase = 'ended';
continue;
}
return;
case 'play':
return;
}
}
throw new Error('bang: settle did not converge');
}
// ---------------------------------------------------------------- playing cards
function openBang(s: BangState, ctx: Ctx, source: PlayerId, target: PlayerId, via: 'bang' | 'gatling'): void {
const t = P(s, target);
const r: Extract<Reaction, { kind: 'bang' }> = {
kind: 'bang',
target,
source,
via,
needed: via === 'bang' && is(P(s, source), 'slab') ? 2 : 1,
barrelTried: false,
jourTried: false,
deadline: ctx.now + reactionMs(s, t),
};
if (s.options.autoBarrel) {
while (r.needed > 0 && tryBarrel(s, ctx, r));
if (r.needed <= 0) {
log(s, ctx, { t: 'barrelSave', p: target });
return;
}
}
s.reactions.push(r);
}
export function barrelLeft(s: BangState, r: Extract<Reaction, { kind: 'bang' }>): boolean {
const t = P(s, r.target);
return (!r.barrelTried && hasEquip(t, 'barrel')) || (!r.jourTried && is(t, 'jourdonnais'));
}
/** One barrel draw! (the real barrel first, then Jourdonnais). False when none is left to try. */
export function tryBarrel(s: BangState, ctx: Ctx, r: Extract<Reaction, { kind: 'bang' }>): boolean {
const t = P(s, r.target);
let purpose: CheckPurpose;
if (!r.barrelTried && hasEquip(t, 'barrel')) {
r.barrelTried = true;
purpose = 'barrel';
} else if (!r.jourTried && is(t, 'jourdonnais')) {
r.jourTried = true;
purpose = 'jourdonnais';
} else return false;
if (drawCheck(s, ctx, t, purpose, isHeart, false)) r.needed -= 1;
return true;
}
function openStore(s: BangState, ctx: Ctx, actor: PlayerId): void {
const order = clockwiseFrom(s, actor);
const revealed: string[] = [];
for (let i = 0; i < order.length; i++) {
const id = drawTop(s, ctx);
if (id) revealed.push(id);
}
log(s, ctx, { t: 'storeOpen', p: actor, cards: revealed.map(faceOf) });
const r: Extract<Reaction, { kind: 'store' }> = { kind: 'store', picker: actor, order, revealed, source: actor, deadline: 0 };
s.reactions.push(r);
storeAdvance(s, ctx, r);
}
/** Next picker in order; the last card goes to the next picker automatically; leftovers are discarded. */
export function storeAdvance(s: BangState, ctx: Ctx, r: Extract<Reaction, { kind: 'store' }>): void {
for (;;) {
if (r.revealed.length === 0) {
removeReaction(s, r);
return;
}
const next = r.order.shift();
if (!next) {
s.discard.push(...r.revealed);
r.revealed = [];
removeReaction(s, r);
return;
}
const p = P(s, next);
if (!p.alive) continue;
if (r.revealed.length === 1) {
const id = r.revealed.pop()!;
p.hand.push(id);
log(s, ctx, { t: 'store', p: p.id, card: faceOf(id) });
continue;
}
r.picker = next;
r.deadline = ctx.now + reactionMs(s, p);
return;
}
}
export function storePick(s: BangState, ctx: Ctx, r: Extract<Reaction, { kind: 'store' }>, cardId: string): void {
const p = P(s, r.picker);
r.revealed = r.revealed.filter((x) => x !== cardId);
p.hand.push(cardId);
log(s, ctx, { t: 'store', p: p.id, card: faceOf(cardId) });
storeAdvance(s, ctx, r);
}
export function playCard(s: BangState, ctx: Ctx, actor: PlayerId, a: PlayAction): void {
const p = P(s, actor);
const card = cardById(a.cardId);
const kind = a.as ?? card.kind;
const face = faceOf(card.id);
removeFromHand(s, ctx, p, [card.id]);
if (isBlue(kind)) {
const owner = kind === 'jail' ? P(s, a.target!) : p;
let replaced: CardFace | undefined;
if (isWeapon(kind)) {
const old = owner.inPlay.find((id) => isWeapon(kindOf(id)));
if (old) {
owner.inPlay = owner.inPlay.filter((x) => x !== old);
s.discard.push(old);
replaced = faceOf(old);
}
}
owner.inPlay.push(card.id);
log(s, ctx, { t: 'equip', p: actor, card: face, ...(kind === 'jail' ? { target: owner.id } : {}), ...(replaced ? { replaced } : {}) });
return;
}
s.discard.push(card.id);
log(s, ctx, { t: 'play', p: actor, card: face, ...(a.as ? { as: a.as } : {}), ...(a.target ? { target: a.target } : {}) });
switch (kind) {
case 'bang':
s.turn.bangsPlayed += 1;
openBang(s, ctx, actor, a.target!, 'bang');
break;
case 'beer':
if (beerWorks(s)) heal(s, ctx, p, 1, 'beer');
break;
case 'saloon':
for (const x of living(s)) heal(s, ctx, x, 1, 'saloon');
break;
case 'stagecoach':
drawCards(s, ctx, p, 2, 'card');
break;
case 'wellsFargo':
drawCards(s, ctx, p, 3, 'card');
break;
case 'generalStore':
openStore(s, ctx, actor);
break;
case 'panic':
case 'catBalou': {
const t = P(s, a.target!);
let taken: string;
if (a.targetCard === 'hand') {
taken = t.hand[ctx.rng.int(t.hand.length)]!;
removeFromHand(s, ctx, t, [taken]);
} else {
taken = a.targetCard!;
t.inPlay = t.inPlay.filter((x) => x !== taken);
}
const fromHand = a.targetCard === 'hand';
if (kind === 'panic') {
p.hand.push(taken);
log(s, ctx, fromHand ? { t: 'take', p: actor, from: t.id, how: 'panic', card: faceOf(taken), vis: [actor, t.id] } : { t: 'take', p: actor, from: t.id, how: 'panic', equip: faceOf(taken) });
} else {
s.discard.push(taken);
log(s, ctx, { t: 'discarded', p: t.id, by: actor, card: faceOf(taken), fromHand });
}
break;
}
case 'duel': {
const t = P(s, a.target!);
s.reactions.push({ kind: 'duel', actor: t.id, other: actor, source: actor, deadline: ctx.now + reactionMs(s, t) });
break;
}
case 'indians':
for (const id of clockwiseFrom(s, actor)) {
if (id === actor) continue;
s.reactions.push({ kind: 'indians', target: id, source: actor, deadline: ctx.now + reactionMs(s, P(s, id)) });
}
break;
case 'gatling':
for (const id of clockwiseFrom(s, actor)) if (id !== actor) openBang(s, ctx, actor, id, 'gatling');
break;
default:
throw new Error(`bang: cannot play ${kind}`);
}
}
// ---------------------------------------------------------------- reactions
export function respond(s: BangState, ctx: Ctx, actor: PlayerId, cardIds: string[]): void {
const r = reactionOf(s, actor)!;
const p = P(s, actor);
const faces = cardIds.map(faceOf);
removeFromHand(s, ctx, p, cardIds);
s.discard.push(...cardIds);
switch (r.kind) {
case 'bang':
removeReaction(s, r);
log(s, ctx, { t: 'missed', p: actor, cards: faces });
break;
case 'indians':
removeReaction(s, r);
log(s, ctx, { t: 'respond', p: actor, cards: faces, why: 'indians' });
break;
case 'duel': {
log(s, ctx, { t: 'respond', p: actor, cards: faces, why: 'duel' });
const next = r.other;
r.other = r.actor;
r.actor = next;
r.deadline = ctx.now + reactionMs(s, P(s, next));
break;
}
case 'dying':
heal(s, ctx, p, cardIds.length, 'beer', faces);
if (p.life >= 1) survived(s, ctx, r);
break;
default:
throw new Error(`bang: cannot respond to ${r.kind}`);
}
}
export function takeHit(s: BangState, ctx: Ctx, actor: PlayerId): void {
const r = reactionOf(s, actor)!;
removeReaction(s, r);
switch (r.kind) {
case 'bang':
case 'indians':
damage(s, ctx, actor, 1, r.source);
break;
case 'duel':
damage(s, ctx, actor, 1, r.other);
break;
default:
throw new Error(`bang: cannot take a hit in ${r.kind}`);
}
}
export function sidHeal(s: BangState, ctx: Ctx, actor: PlayerId, cardIds: string[]): void {
const p = P(s, actor);
const faces = cardIds.map(faceOf);
removeFromHand(s, ctx, p, cardIds);
s.discard.push(...cardIds);
heal(s, ctx, p, 1, 'sid', faces);
const r = reactionOf(s, actor);
if (r?.kind === 'dying' && p.life >= 1) survived(s, ctx, r);
}
/** The dying player recovers with whatever works (timeout / auto, §8.5). */
export function autoSave(s: BangState, ctx: Ctx, actor: PlayerId): void {
const p = P(s, actor);
const r = reactionOf(s, actor) as Extract<Reaction, { kind: 'dying' }>;
const needed = 1 - p.life;
const beers = beerWorks(s) ? p.hand.filter((id) => kindOf(id) === 'beer') : [];
if (beers.length >= needed) {
respond(s, ctx, actor, beers.slice(0, needed));
return;
}
if (maxRecovery(s, p) >= needed) {
// Sid Ketchum: 2-card heals with non-beer cards first, beers for the rest.
while (p.alive && p.life <= 0) {
const beer = beerWorks(s) ? p.hand.find((id) => kindOf(id) === 'beer') : undefined;
const others = p.hand.filter((id) => kindOf(id) !== 'beer');
if (others.length >= 2) sidHeal(s, ctx, actor, others.slice(0, 2));
else if (beer) {
removeFromHand(s, ctx, p, [beer]);
s.discard.push(beer);
heal(s, ctx, p, 1, 'beer', [faceOf(beer)]);
if (p.life >= 1) survived(s, ctx, r);
} else if (p.hand.length >= 2) sidHeal(s, ctx, actor, p.hand.slice(0, 2));
else break;
}
if (p.life >= 1) return;
}
removeReaction(s, r);
eliminate(s, ctx, actor, r.killer);
}
export function kitKeep(s: BangState, ctx: Ctx, actor: PlayerId, keep: string[]): void {
const r = reactionOf(s, actor) as Extract<Reaction, { kind: 'kitKeep' }>;
const p = P(s, actor);
removeReaction(s, r);
p.hand.push(...keep);
for (const id of r.options) if (!keep.includes(id)) s.deck.push(id);
log(s, ctx, { t: 'draw', p: actor, n: keep.length, why: 'kit' });
}
export function pickCharacter(s: BangState, ctx: Ctx, actor: PlayerId, c: CharacterId): void {
const p = P(s, actor);
const r = reactionOf(s, actor);
if (r) removeReaction(s, r);
p.character = c;
p.characterOffer = null;
p.maxLife = CHARACTER_LIFE[c] + (p.role === 'sheriff' ? 1 : 0);
p.life = p.maxLife;
log(s, ctx, { t: 'character', p: actor, character: c });
}
/** Ends the play phase: the hand limit is the current life (§5.2 step 5). */
export function endPlay(s: BangState, ctx: Ctx, actor: PlayerId): void {
const p = P(s, actor);
if (p.hand.length > p.life) {
s.turn.phase = 'discard';
s.turn.deadline = Math.max(s.turn.deadline, ctx.now + DISCARD_MIN_MS);
} else s.turn.phase = 'ended';
}
export function discardDown(s: BangState, ctx: Ctx, actor: PlayerId, cardIds: string[]): void {
const p = P(s, actor);
removeFromHand(s, ctx, p, cardIds);
s.discard.push(...cardIds);
log(s, ctx, { t: 'limit', p: actor, cards: cardIds.map(faceOf) });
s.turn.phase = 'ended';
}
export function randomDiscard(s: BangState, ctx: Ctx, actor: PlayerId): void {
const p = P(s, actor);
const n = p.hand.length - p.life;
if (n <= 0) {
s.turn.phase = 'ended';
return;
}
const ids = ctx.rng.shuffle(p.hand).slice(0, n);
discardDown(s, ctx, actor, ids);
}
/** A player leaves the room: they are out of the game (no killer, cards discarded or to Vulture Sam). */
export function leave(s: BangState, ctx: Ctx, actor: PlayerId): void {
const p = P(s, actor);
if (s.turn.phase === 'draft' && p.characterOffer) {
const r = reactionOf(s, actor);
if (r) removeReaction(s, r);
p.character = p.characterOffer[0]!;
p.characterOffer = null;
}
eliminate(s, ctx, actor, null, true);
}

View File

@@ -0,0 +1,439 @@
/** 뱅! (docs/games/bang.md). Pure and deterministic; state is JSON. */
import { type GameDefinition, type GameResult, type PlayerId } from '@bg/engine';
import { CARDS, CHARACTER_IDS, CHARACTER_LIFE, cardById, kindOf, rolesFor } from './cards';
import { distance, rangeOf } from './distance';
import {
AWAY_AFTER,
P,
autoSave,
discardDown,
doDraw,
eliminate,
log,
endPlay,
kitKeep,
leave,
limboCards,
living,
pickCharacter,
playCard,
randomDiscard,
reactionOf,
respond,
responder,
settle,
sidHeal,
storePick,
takeHit,
tryBarrel,
type Ctx,
} from './effects';
import { bangLike, missedLike, playOptions, promptFor, sidHealContext, unlimitedBang, validateAction, waiting } from './rules';
import {
bangActionSchema,
bangOptionsSchema,
type BangAction,
type BangOptions,
type BangState,
type BangView,
type LogEntry,
type PlayerState,
type Reaction,
} from './types';
export * from './cards';
export * from './types';
export { distance, rangeOf, seatDistance, hasEquip, weaponOf } from './distance';
export { checkPlay, playOptions, promptFor, waiting } from './rules';
const LOG_VIEW = 30;
/** Copy for `apply`: containers are copied; log entries and options are never mutated and are shared. */
function cloneState(s: BangState): BangState {
return {
...s,
players: s.players.map((p) => ({ ...p, hand: [...p.hand], inPlay: [...p.inPlay], characterOffer: p.characterOffer ? [...p.characterOffer] : null })),
deck: [...s.deck],
discard: [...s.discard],
turn: { ...s.turn },
reactions: s.reactions.map((r) => {
if (r.kind === 'store') return { ...r, order: [...r.order], revealed: [...r.revealed] };
if (r.kind === 'kitKeep') return { ...r, options: [...r.options] };
return { ...r };
}),
deathOrder: [...s.deathOrder],
log: [...s.log],
};
}
/** Every card is in exactly one place (test invariant). */
export function allCards(s: BangState): string[] {
return [...s.deck, ...s.discard, ...s.players.flatMap((p) => [...p.hand, ...p.inPlay]), ...limboCards(s)];
}
function deadlineOf(s: BangState): number | null {
if (s.winner) return null;
if (s.reactions.length) return Math.min(...s.reactions.map((r) => r.deadline));
return s.turn.deadline;
}
/** Responders whose own deadline is the earliest (a later reaction must not be cut short). */
function timed(s: BangState): PlayerId[] {
if (s.winner) return [];
if (!s.reactions.length) return [s.turn.player];
const d = deadlineOf(s)!;
return [...new Set(s.reactions.filter((r) => r.deadline === d).map(responder))];
}
/** §8.5 automatic choice for a player who did not answer in time. */
function onTimeoutApply(s: BangState, ctx: Ctx, actor: PlayerId): void {
const p = P(s, actor);
const r = reactionOf(s, actor);
if (r) {
switch (r.kind) {
case 'choose2':
pickCharacter(s, ctx, actor, p.characterOffer![0]!);
return;
case 'kitKeep':
kitKeep(s, ctx, actor, r.options.slice(0, 2));
return;
case 'store':
storePick(s, ctx, r, r.revealed[0]!);
return;
case 'bang': {
if (!s.options.autoDefend) return takeHit(s, ctx, actor);
while (r.needed > 0 && tryBarrel(s, ctx, r));
if (r.needed <= 0) {
s.reactions.splice(s.reactions.indexOf(r), 1);
return;
}
const cards = p.hand.filter((id) => missedLike(p, id));
// Calamity Janet keeps 뱅! when a 빗나감! does the job.
cards.sort((a, b) => Number(kindOf(a) !== 'missed') - Number(kindOf(b) !== 'missed'));
if (cards.length >= r.needed) return respond(s, ctx, actor, cards.slice(0, r.needed));
return takeHit(s, ctx, actor);
}
case 'indians':
case 'duel': {
const card = s.options.autoDefend ? p.hand.find((id) => bangLike(p, id)) : undefined;
if (card) return respond(s, ctx, actor, [card]);
return takeHit(s, ctx, actor);
}
case 'dying':
return autoSave(s, ctx, actor);
}
}
switch (s.turn.phase) {
case 'draw':
return doDraw(s, ctx, {});
case 'play':
endPlay(s, ctx, actor);
if ((s.turn.phase as string) === 'discard') randomDiscard(s, ctx, actor);
return;
case 'discard':
return randomDiscard(s, ctx, actor);
default:
throw new Error(`bang: timeout in ${s.turn.phase}`);
}
}
function roleVisible(s: BangState, p: PlayerState, viewer: PlayerId | null): boolean {
return p.role === 'sheriff' || !p.alive || p.id === viewer || !!s.winner;
}
function stripPrivate(e: LogEntry, viewer: PlayerId | null, over: boolean): LogEntry {
if (e.t !== 'take' || !e.vis || over || (viewer && e.vis.includes(viewer))) return e;
const { card: _card, ...rest } = e;
return rest;
}
type Def = GameDefinition<BangState, BangAction, BangView, BangOptions>;
export const bang: Def = {
id: 'bang',
nameKo: '뱅!',
minPlayers: 4,
maxPlayers: 7,
stateVersion: 1,
defaultOptions: bangOptionsSchema.parse({}),
optionsSchema: bangOptionsSchema as unknown as Def['optionsSchema'],
actionSchema: bangActionSchema as unknown as Def['actionSchema'],
setup({ players, options, rng, now }) {
if (players.length < 4 || players.length > 7) throw new Error(`bang: ${players.length} players not allowed`);
// §4: seats, roles, characters, then the deck.
const seats = rng.shuffle(players);
const roles = rng.shuffle(rolesFor(players.length, options.deputyCount7));
const chars = rng.shuffle(CHARACTER_IDS);
const draft = options.characterDraft === 'choose2';
const ps: PlayerState[] = seats.map((id, i) => {
const offer = draft ? [chars[2 * i]!, chars[2 * i + 1]!] : null;
const character = draft ? offer![0]! : chars[i]!;
const maxLife = CHARACTER_LIFE[character] + (roles[i] === 'sheriff' ? 1 : 0);
return { id, seat: i, role: roles[i]!, character, characterOffer: offer, maxLife, life: maxLife, alive: true, hand: [], inPlay: [], timeoutsInRow: 0, away: false };
});
const s: BangState = {
options,
players: ps,
deck: rng.shuffle(CARDS.map((c) => c.id)),
discard: [],
turn: { player: ps.find((p) => p.role === 'sheriff')!.id, phase: 'draft', bangsPlayed: 0, deadline: now, pausedAt: null, dynamiteChecked: false, jailChecked: false },
reactions: draft ? ps.map((p) => ({ kind: 'choose2' as const, player: p.id, deadline: now + 30_000 })) : [],
lastCheck: null,
winner: null,
deathOrder: [],
round: 0,
seq: 0,
log: [],
final: null,
};
settle(s, { rng, now, events: [] });
return s;
},
validate: validateAction,
apply(prev, actor, a, { rng, now }) {
const s = cloneState(prev);
const ctx: Ctx = { rng, now, events: [] };
s.seq += 1;
const p = P(s, actor);
if (a.type !== 'timeout' && a.type !== 'leave') {
p.timeoutsInRow = 0;
p.away = false;
}
switch (a.type) {
case 'timeout':
p.timeoutsInRow += 1;
if (p.timeoutsInRow >= AWAY_AFTER) p.away = true;
log(s, ctx, { t: 'timeout', p: actor });
onTimeoutApply(s, ctx, actor);
break;
case 'leave':
leave(s, ctx, actor);
break;
case 'pickCharacter':
pickCharacter(s, ctx, actor, a.character);
break;
case 'draw':
doDraw(s, ctx, a);
break;
case 'kitKeep':
kitKeep(s, ctx, actor, a.keep);
break;
case 'play':
playCard(s, ctx, actor, a);
break;
case 'respond':
respond(s, ctx, actor, a.cardIds);
break;
case 'useBarrel': {
const r = reactionOf(s, actor) as Extract<Reaction, { kind: 'bang' }>;
tryBarrel(s, ctx, r);
if (r.needed <= 0) s.reactions.splice(s.reactions.indexOf(r), 1);
break;
}
case 'takeHit':
takeHit(s, ctx, actor);
break;
case 'giveUp': {
const r = reactionOf(s, actor) as Extract<Reaction, { kind: 'dying' }>;
s.reactions.splice(s.reactions.indexOf(r), 1);
eliminate(s, ctx, actor, r.killer);
break;
}
case 'storePick':
storePick(s, ctx, reactionOf(s, actor) as Extract<Reaction, { kind: 'store' }>, a.cardId);
break;
case 'sidHeal':
sidHeal(s, ctx, actor, a.cardIds);
break;
case 'endPlay':
endPlay(s, ctx, actor);
break;
case 'discard':
discardDown(s, ctx, actor, a.cardIds);
break;
}
settle(s, ctx);
return { state: s, events: ctx.events };
},
view(s, viewer) {
const me = viewer && s.players.some((p) => p.id === viewer) ? viewer : null;
const meP = me ? P(s, me) : null;
const over = !!s.winner;
const reactingOf = new Map(s.reactions.map((r) => [responder(r), r.kind]));
const players = s.players.map((p) => {
const hiddenChar = !!p.characterOffer && p.id !== me && !over;
const dist = meP && meP.alive && p.alive && p.id !== me && !p.characterOffer && !meP.characterOffer ? distance(s, me!, p.id) : null;
return {
id: p.id,
seat: p.seat,
character: hiddenChar || p.characterOffer ? null : p.character,
role: roleVisible(s, p, me) ? p.role : null,
maxLife: p.maxLife,
life: p.life,
alive: p.alive,
handCount: p.hand.length,
inPlay: p.inPlay.map(cardById),
isTurn: !over && s.turn.player === p.id && s.turn.phase !== 'draft',
reacting: reactingOf.get(p.id) ?? null,
away: p.away,
distance: dist,
inRange: dist !== null && dist <= rangeOf(meP!),
};
});
return {
options: s.options,
me,
players,
current: s.turn.player,
phase: s.turn.phase,
round: s.round,
bangsPlayed: s.turn.bangsPlayed,
deckCount: s.deck.length,
discardCount: s.discard.length,
discardTop: s.discard.slice(-4).map(cardById),
reactions: s.reactions.map((r) => ({
kind: r.kind,
who: responder(r),
...('source' in r && r.source ? { source: r.source } : {}),
...(r.kind === 'bang' ? { via: r.via, needed: r.needed } : {}),
...(r.kind === 'store' ? { revealed: r.revealed.map(cardById) } : {}),
deadline: r.deadline,
})),
lastCheck: s.lastCheck,
my: meP
? {
role: meP.role,
character: meP.characterOffer ? null : meP.character,
hand: meP.hand.map(cardById),
range: rangeOf(meP),
unlimitedBang: unlimitedBang(meP),
plays: playOptions(s, me!),
canEndPlay: !over && s.turn.player === me && s.turn.phase === 'play' && s.reactions.length === 0 && meP.alive,
canSidHeal: sidHealContext(s, me!) && meP.life < meP.maxLife && meP.hand.length >= 2,
prompt: promptFor(s, me!),
}
: null,
turnDeadline: s.turn.deadline,
deadlineTotalMs: s.options.turnSeconds * 1000,
reactionTotalMs: s.options.reactionSeconds * 1000,
log: s.log.slice(-LOG_VIEW).map((e) => stripPrivate(e, me, over)),
winner: s.winner,
result: s.final,
};
},
activePlayers: waiting,
deadline: deadlineOf,
timeoutPlayers: timed,
onTimeout() {
return { type: 'timeout' };
},
onLeave(s, player) {
const p = s.players.find((x) => x.id === player);
if (!p || !p.alive || s.winner) return null;
return { type: 'leave' };
},
result(s): GameResult | null {
const f = s.final;
if (!f) return null;
const winners = f.ranking.filter((id) => f.players.find((p) => p.id === id)!.won);
const losers = f.ranking.filter((id) => !winners.includes(id));
return {
ranking: [winners, ...losers.map((id) => [id])],
scores: Object.fromEntries(f.players.map((p) => [p.id, p.won ? 1 : 0])),
summary: f.summary,
reason: 'normal',
};
},
legalActions(s, player) {
const p = s.players.find((x) => x.id === player);
if (!p || !p.alive || s.winner) return [];
const out: BangAction[] = [];
const r = reactionOf(s, player);
const sidPairs = (): BangAction[] => (p.hand.length >= 2 ? [{ type: 'sidHeal', cardIds: [p.hand[0]!, p.hand[1]!] }] : []);
if (r) {
switch (r.kind) {
case 'choose2':
for (const c of p.characterOffer ?? []) out.push({ type: 'pickCharacter', character: c });
break;
case 'kitKeep': {
const o = r.options;
for (let i = 0; i < o.length; i++) for (let j = i + 1; j < o.length; j++) out.push({ type: 'kitKeep', keep: [o[i]!, o[j]!] });
break;
}
case 'store':
for (const id of r.revealed) out.push({ type: 'storePick', cardId: id });
break;
case 'bang': {
const cards = p.hand.filter((id) => missedLike(p, id));
if (cards.length >= r.needed) out.push({ type: 'respond', cardIds: cards.slice(0, r.needed) });
out.push({ type: 'takeHit' }, { type: 'useBarrel' }, ...sidPairs());
break;
}
case 'indians':
case 'duel': {
const card = p.hand.find((id) => bangLike(p, id));
if (card) out.push({ type: 'respond', cardIds: [card] });
out.push({ type: 'takeHit' }, ...sidPairs());
break;
}
case 'dying': {
const beers = p.hand.filter((id) => kindOf(id) === 'beer');
const need = 1 - p.life;
if (beers.length >= need) out.push({ type: 'respond', cardIds: beers.slice(0, need) });
out.push({ type: 'giveUp' }, ...sidPairs());
break;
}
}
return out.filter((a) => validateAction(s, player, a).ok);
}
if (s.reactions.length || s.turn.player !== player) return [];
switch (s.turn.phase) {
case 'draw':
out.push({ type: 'draw' }, { type: 'draw', pedroDiscard: true });
for (const q of living(s)) out.push({ type: 'draw', jesseFrom: q.id });
break;
case 'play':
for (const o of playOptions(s, player)) {
if (!o.ok) continue;
const base = { type: 'play' as const, cardId: o.cardId, ...(o.as ? { as: o.as } : {}) };
if (!o.targets) out.push(base);
else
for (const t of o.targets) {
if (!t.ok) continue;
if (t.cards) for (const tc of t.cards) out.push({ ...base, target: t.id, targetCard: tc });
else out.push({ ...base, target: t.id });
}
}
out.push({ type: 'endPlay' }, ...sidPairs());
break;
case 'discard': {
const n = p.hand.length - p.life;
if (n > 0) out.push({ type: 'discard', cardIds: p.hand.slice(0, n) }, { type: 'discard', cardIds: p.hand.slice(-n) });
out.push(...sidPairs());
break;
}
}
return out.filter((a) => validateAction(s, player, a).ok);
},
/** Own hand, own Kit Carlson / choose2 offers, and the deck — ids exactly as they would appear in a view. */
secretsOf(s, player) {
const p = s.players.find((x) => x.id === player);
if (!p) return [];
const kit = s.reactions.flatMap((r) => (r.kind === 'kitKeep' && r.player === player ? r.options : []));
return [...p.hand, ...kit, ...s.deck, ...(p.characterOffer ?? [])];
},
};
export const _internal = { cloneState, timed, deadlineOf };

View File

@@ -0,0 +1,274 @@
/** 뱅! legality checks (docs/games/bang.md §5.2–§5.4, §8.2) and what the view needs from them. */
import { fail, ok, type PlayerId, type Validation } from '@bg/engine';
import { cardById, isBlue, kindOf, type CardKind } from './cards';
import { distance, hasEquip, rangeOf } from './distance';
import { barrelLeft, beerWorks, drawChoice, findPlayer, is, living, P, reactionOf, responder } from './effects';
import type { BangAction, BangState, PlayAction, PlayOption, PlayerState, Prompt, Reaction, TargetOption } from './types';
const TARGETED: CardKind[] = ['bang', 'panic', 'catBalou', 'duel', 'jail'];
export const needsTarget = (k: CardKind): boolean => TARGETED.includes(k);
export const unlimitedBang = (p: PlayerState): boolean => is(p, 'willy') || hasEquip(p, 'volcanic');
/** Cards that answer a 뱅! (빗나감!, or 뱅! for Calamity Janet). */
export const missedLike = (p: PlayerState, id: string): boolean => kindOf(id) === 'missed' || (is(p, 'calamity') && kindOf(id) === 'bang');
/** Cards that answer 인디언! / 결투 (뱅!, or 빗나감! for Calamity Janet). */
export const bangLike = (p: PlayerState, id: string): boolean => kindOf(id) === 'bang' || (is(p, 'calamity') && kindOf(id) === 'missed');
export const myPlayTime = (s: BangState, p: PlayerId): boolean => !s.winner && s.turn.player === p && s.turn.phase === 'play' && s.reactions.length === 0;
/** null = the play is legal; otherwise the Korean reason. */
export function checkPlay(s: BangState, actor: PlayerId, a: PlayAction): string | null {
const p = P(s, actor);
if (s.turn.player !== actor || s.turn.phase !== 'play') return '지금은 카드를 낼 수 없어요.';
if (s.reactions.length > 0) return '다른 사람의 반응을 기다리고 있어요.';
if (!p.hand.includes(a.cardId)) return '손에 없는 카드예요.';
const card = cardById(a.cardId);
if (a.as !== undefined) {
if (!is(p, 'calamity')) return '캘러미티 자넷만 카드를 바꿔 낼 수 있어요.';
if (a.as !== 'bang' || card.kind !== 'missed') return '내 차례에는 빗나감!을 뱅!으로만 바꿔 낼 수 있어요.';
}
const kind: CardKind = a.as ?? card.kind;
if (kind === 'missed') return '빗나감!은 공격을 받았을 때만 낼 수 있어요.';
if (a.targetCard !== undefined && kind !== 'panic' && kind !== 'catBalou') return '이 카드는 카드를 고르지 않아요.';
if (!needsTarget(kind)) {
if (a.target !== undefined) return '이 카드는 대상을 고르지 않아요.';
} else {
if (!a.target) return '대상을 골라 주세요.';
const t = findPlayer(s, a.target);
if (!t) return '없는 플레이어예요.';
if (!t.alive) return '이미 쓰러진 사람이에요.';
}
const t = a.target ? P(s, a.target) : null;
switch (kind) {
case 'bang': {
if (t!.id === actor) return '자기 자신은 쏠 수 없어요.';
if (s.turn.bangsPlayed >= 1 && !unlimitedBang(p)) return '이번 턴에는 뱅!을 이미 썼어요.';
const d = distance(s, actor, t!.id);
const r = rangeOf(p);
if (d > r) return `사거리가 닿지 않아요(거리 ${d}, 사거리 ${r}).`;
return null;
}
case 'beer':
if (s.options.beerAtFullHealth) return null;
if (!beerWorks(s)) return '두 명만 남으면 맥주는 효과가 없어요.';
if (p.life >= p.maxLife) return '생명이 가득 차서 맥주를 쓸 수 없어요.';
return null;
case 'panic':
case 'catBalou': {
const self = t!.id === actor;
if (self && !s.options.selfTargetCatBalou) return '자기 자신에게는 쓸 수 없어요.';
const tc = a.targetCard;
if (!tc) return '가져올 카드를 골라 주세요.';
if (tc === 'hand') {
if (self) return '내 손패에는 쓸 수 없어요.';
if (t!.hand.length === 0) return '손패가 없는 사람이에요.';
} else if (!t!.inPlay.includes(tc)) return '그 사람 앞에 없는 카드예요.';
if (kind === 'panic' && !self) {
const d = distance(s, actor, t!.id);
if (d > 1) return `패닉!은 거리 1인 사람에게만 쓸 수 있어요(거리 ${d}).`;
}
return null;
}
case 'duel':
return t!.id === actor ? '자기 자신과는 결투할 수 없어요.' : null;
case 'jail':
if (t!.id === actor) return '자기 자신은 감옥에 넣을 수 없어요.';
if (t!.role === 'sheriff') return '보안관은 감옥에 넣을 수 없어요.';
if (hasEquip(t!, 'jail')) return '이미 감옥에 있는 사람이에요.';
return null;
default:
if (isBlue(kind) && hasEquip(p, kind)) return '같은 카드를 이미 앞에 놓았어요.';
return null;
}
}
/** Sid Ketchum may heal on his own turn or while answering a reaction aimed at him. */
export function sidHealContext(s: BangState, actor: PlayerId): boolean {
const p = P(s, actor);
if (!is(p, 'sid') || !p.alive || s.winner) return false;
const r = reactionOf(s, actor);
if (r) return r.kind === 'bang' || r.kind === 'indians' || r.kind === 'duel' || r.kind === 'dying';
return s.turn.player === actor && s.reactions.length === 0 && (s.turn.phase === 'play' || s.turn.phase === 'discard');
}
function distinctInHand(p: PlayerState, ids: string[]): string | null {
if (new Set(ids).size !== ids.length) return '같은 카드를 두 번 고를 수 없어요.';
if (ids.some((id) => !p.hand.includes(id))) return '손에 없는 카드예요.';
return null;
}
function checkRespond(s: BangState, p: PlayerState, r: Reaction, ids: string[]): string | null {
const bad = distinctInHand(p, ids);
if (bad) return bad;
switch (r.kind) {
case 'bang':
if (ids.length !== r.needed) return r.needed > 1 ? `빗나감! ${r.needed}장을 한꺼번에 내야 막을 수 있어요.` : '빗나감! 1장을 골라 주세요.';
if (!ids.every((id) => missedLike(p, id))) return is(p, 'calamity') ? '빗나감!이나 뱅!만 낼 수 있어요.' : '빗나감!만 낼 수 있어요.';
return null;
case 'indians':
case 'duel':
if (ids.length !== 1) return '뱅! 1장을 골라 주세요.';
if (!bangLike(p, ids[0]!)) return is(p, 'calamity') ? '뱅!이나 빗나감!만 낼 수 있어요.' : '뱅!만 낼 수 있어요.';
return null;
case 'dying': {
if (!beerWorks(s)) return '두 명만 남으면 맥주로 살아날 수 없어요.';
const need = 1 - p.life;
if (ids.length !== need) return `맥주 ${need}장이 필요해요.`;
if (!ids.every((id) => kindOf(id) === 'beer')) return '맥주만 낼 수 있어요.';
return null;
}
default:
return '지금은 카드를 낼 수 없어요.';
}
}
/** Everyone the game is waiting for (reactions first; otherwise the current player). */
export function waiting(s: BangState): PlayerId[] {
if (s.winner) return [];
if (s.reactions.length > 0) return [...new Set(s.reactions.map(responder))];
return [s.turn.player];
}
export function validateAction(s: BangState, actor: PlayerId, a: BangAction): Validation {
const p = findPlayer(s, actor);
if (!p) return fail('이 게임의 플레이어가 아니에요.');
if (s.winner) return fail('게임이 이미 끝났어요.');
if (a.type === 'leave') return p.alive ? ok : fail('이미 게임에서 빠졌어요.');
if (!p.alive) return fail('쓰러진 사람은 행동할 수 없어요.');
const r = reactionOf(s, actor);
const why = (reason: string | null): Validation => (reason ? fail(reason) : ok);
switch (a.type) {
case 'timeout':
return waiting(s).includes(actor) ? ok : fail('시간 초과 대상이 아니에요.');
case 'pickCharacter':
if (r?.kind !== 'choose2' || !p.characterOffer) return fail('지금은 캐릭터를 고를 때가 아니에요.');
return p.characterOffer.includes(a.character) ? ok : fail('받은 캐릭터 중에서 골라 주세요.');
case 'draw': {
if (s.turn.player !== actor || s.turn.phase !== 'draw' || s.reactions.length) return fail('지금은 카드를 받을 때가 아니에요.');
if (a.jesseFrom !== undefined && a.pedroDiscard) return fail('한 가지 방법만 고를 수 있어요.');
const choice = drawChoice(s, p);
if (a.jesseFrom !== undefined) {
if (!is(p, 'jesse')) return fail('제시 존스만 다른 사람 손패에서 받을 수 있어요.');
if (!choice?.jesse.includes(a.jesseFrom)) return fail('손패가 있는 다른 사람을 골라 주세요.');
}
if (a.pedroDiscard) {
if (!is(p, 'pedro')) return fail('페드로 라미레스만 버린 카드 더미에서 받을 수 있어요.');
if (!choice?.pedro) return fail('버린 카드 더미가 비어 있어요.');
}
return ok;
}
case 'kitKeep': {
if (r?.kind !== 'kitKeep') return fail('지금은 카드를 고를 때가 아니에요.');
const [x, y] = a.keep;
if (x === y) return fail('서로 다른 카드 2장을 골라 주세요.');
return r.options.includes(x) && r.options.includes(y) ? ok : fail('보이는 카드 중에서 골라 주세요.');
}
case 'play':
return why(checkPlay(s, actor, a));
case 'respond':
if (!r || !['bang', 'indians', 'duel', 'dying'].includes(r.kind)) return fail('지금은 낼 수 있는 반응이 없어요.');
return why(checkRespond(s, p, r, a.cardIds));
case 'useBarrel':
if (r?.kind !== 'bang') return fail('뱅!을 받았을 때만 술통 판정을 해요.');
if (s.options.autoBarrel) return fail('술통 판정은 자동으로 해요.');
return barrelLeft(s, r) ? ok : fail('더 할 수 있는 술통 판정이 없어요.');
case 'takeHit':
return r && (r.kind === 'bang' || r.kind === 'indians' || r.kind === 'duel') ? ok : fail('지금은 맞을 공격이 없어요.');
case 'giveUp':
return r?.kind === 'dying' ? ok : fail('지금은 쓰러질 상황이 아니에요.');
case 'storePick':
if (r?.kind !== 'store') return fail('지금은 잡화점 카드를 고를 차례가 아니에요.');
return r.revealed.includes(a.cardId) ? ok : fail('펼쳐진 카드 중에서 골라 주세요.');
case 'sidHeal': {
if (!is(p, 'sid')) return fail('시드 케첨만 쓸 수 있는 능력이에요.');
if (!sidHealContext(s, actor)) return fail('지금은 능력을 쓸 수 없어요.');
if (p.life >= p.maxLife) return fail('생명이 이미 가득 찼어요.');
return why(distinctInHand(p, a.cardIds));
}
case 'endPlay':
return myPlayTime(s, actor) ? ok : fail('지금은 턴을 마칠 수 없어요.');
case 'discard': {
if (s.turn.player !== actor || s.turn.phase !== 'discard' || s.reactions.length) return fail('지금은 카드를 버릴 때가 아니에요.');
const need = p.hand.length - p.life;
if (a.cardIds.length !== need) return fail(`카드 ${need}장을 골라 버려 주세요.`);
return why(distinctInHand(p, a.cardIds));
}
}
}
// ---------------------------------------------------------------- precomputed options for the view
export function playOptions(s: BangState, me: PlayerId): PlayOption[] {
if (!myPlayTime(s, me)) return [];
const p = P(s, me);
const out: PlayOption[] = [];
const others = living(s).filter((x) => x.id !== me);
for (const cardId of p.hand) {
const card = cardById(cardId);
const as: 'bang' | undefined = is(p, 'calamity') && card.kind === 'missed' ? 'bang' : undefined;
const kind = as ?? card.kind;
const base: PlayAction = { type: 'play', cardId, ...(as ? { as } : {}) };
if (!needsTarget(kind)) {
const reason = checkPlay(s, me, base);
out.push({ cardId, ...(as ? { as } : {}), ok: !reason, ...(reason ? { reason } : {}) });
continue;
}
const cands = [...others];
if ((kind === 'panic' || kind === 'catBalou') && s.options.selfTargetCatBalou && p.inPlay.length > 0) cands.push(p);
const targets: TargetOption[] = cands.map((t) => {
if (kind === 'panic' || kind === 'catBalou') {
const choices = ['hand', ...t.inPlay];
const reasons = choices.map((tc) => checkPlay(s, me, { ...base, target: t.id, targetCard: tc }));
const cards = choices.filter((_, i) => !reasons[i]);
return cards.length ? { id: t.id, ok: true, cards } : { id: t.id, ok: false, reason: reasons[0] ?? reasons.find(Boolean)! };
}
const reason = checkPlay(s, me, { ...base, target: t.id });
return reason ? { id: t.id, ok: false, reason } : { id: t.id, ok: true };
});
const any = targets.some((t) => t.ok);
const reason = any ? undefined : (targets[0]?.reason ?? '쓸 수 있는 대상이 없어요.');
out.push({ cardId, ...(as ? { as } : {}), ok: any, ...(reason ? { reason } : {}), targets });
}
return out;
}
export function promptFor(s: BangState, me: PlayerId): Prompt | null {
if (s.winner) return null;
const p = P(s, me);
if (!p.alive) return null;
const r = reactionOf(s, me);
if (r) {
switch (r.kind) {
case 'bang':
return {
kind: 'bang',
source: r.source,
via: r.via,
needed: r.needed,
options: p.hand.filter((id) => missedLike(p, id)),
canBarrel: !s.options.autoBarrel && barrelLeft(s, r),
};
case 'indians':
return { kind: 'indians', source: r.source, options: p.hand.filter((id) => bangLike(p, id)) };
case 'duel':
return { kind: 'duel', opponent: r.other, source: r.source, options: p.hand.filter((id) => bangLike(p, id)) };
case 'dying':
return { kind: 'dying', needed: 1 - p.life, beers: p.hand.filter((id) => kindOf(id) === 'beer'), beerAllowed: beerWorks(s), killer: r.killer };
case 'store':
return { kind: 'store', revealed: r.revealed.map(cardById) };
case 'kitKeep':
return { kind: 'kitKeep', options: r.options.map(cardById) };
case 'choose2':
return { kind: 'choose2', options: p.characterOffer ?? [] };
}
}
if (s.reactions.length || s.turn.player !== me) return null;
if (s.turn.phase === 'draw') {
const c = drawChoice(s, p);
const top = s.discard[s.discard.length - 1];
return { kind: 'draw', jesseTargets: c?.jesse ?? [], pedro: c?.pedro && top ? (({ id: _id, ...f }) => f)(cardById(top)) : null };
}
if (s.turn.phase === 'discard') return { kind: 'discard', count: Math.max(0, p.hand.length - p.life) };
return null;
}

View File

@@ -0,0 +1,255 @@
/** 뱅! options, actions, state and view types (docs/games/bang.md §2, §7, §8). */
import { z } from 'zod';
import type { PlayerId } from '@bg/engine';
import { CHARACTER_IDS, type Card, type CardFace, type CardKind, type CharacterId, type Role } from './cards';
const lit = <T extends number>(...vals: T[]) =>
z.union(vals.map((v) => z.literal(v)) as unknown as [z.ZodLiteral<T>, z.ZodLiteral<T>, ...z.ZodLiteral<T>[]]);
export const bangOptionsSchema = z.object({
characterDraft: z
.enum(['random', 'choose2'])
.default('random')
.meta({ title: '캐릭터 정하기', labels: { random: '무작위 1장', choose2: '2장 중 1장 고르기' } }),
turnSeconds: lit(30, 60, 90, 120)
.default(60)
.meta({ title: '내 턴 제한 시간', labels: { 30: '30초', 60: '60초', 90: '90초', 120: '120초' } }),
reactionSeconds: lit(8, 15, 25)
.default(15)
.meta({ title: '반응 제한 시간(빗나감!·맥주·결투·잡화점)', labels: { 8: '8초', 15: '15초', 25: '25초' } }),
autoDefend: z.boolean().default(true).meta({ title: '반응 시간이 지나면 막을 카드를 자동으로 내기' }),
autoBarrel: z.boolean().default(true).meta({ title: '술통·주르도네 판정을 묻지 않고 자동으로 하기' }),
showDistanceHelper: z.boolean().default(true).meta({ title: '다른 사람까지 거리와 사거리 표시' }),
beginnerHints: z.boolean().default(true).meta({ title: '카드를 쓸 수 있는 이유·없는 이유 알려 주기' }),
drawBeforeDeath: z.boolean().default(false).meta({ title: '(하우스 룰) 바트 캐시디·엘 그링고가 빈사 판정 전에 카드 받기' }),
beerAtFullHealth: z.boolean().default(false).meta({ title: '(하우스 룰) 효과가 없어도 맥주 내기(버리기 용도)' }),
selfTargetCatBalou: z.boolean().default(false).meta({ title: '(하우스 룰) 캣 벌루·패닉!을 내 장착 카드에 쓰기' }),
deputyCount7: z.boolean().default(false).meta({ title: '(하우스 룰) 7명일 때 부관 1 + 무법자 4' }),
});
export type BangOptions = z.infer<typeof bangOptionsSchema>;
const id = z.string().max(40);
export const bangActionSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('pickCharacter'), character: z.enum(CHARACTER_IDS as [CharacterId, ...CharacterId[]]) }),
z.object({ type: z.literal('draw'), jesseFrom: id.optional(), pedroDiscard: z.boolean().optional() }),
z.object({ type: z.literal('kitKeep'), keep: z.tuple([id, id]) }),
z.object({
type: z.literal('play'),
cardId: id,
as: z.enum(['bang', 'missed']).optional(),
target: id.optional(),
targetCard: id.optional(),
}),
z.object({ type: z.literal('respond'), cardIds: z.array(id).min(1).max(4) }),
z.object({ type: z.literal('useBarrel') }),
z.object({ type: z.literal('takeHit') }),
z.object({ type: z.literal('giveUp') }),
z.object({ type: z.literal('storePick'), cardId: id }),
z.object({ type: z.literal('sidHeal'), cardIds: z.tuple([id, id]) }),
z.object({ type: z.literal('endPlay') }),
z.object({ type: z.literal('discard'), cardIds: z.array(id).min(1).max(40) }),
]);
/** Client actions plus the system-only ones (timer, leaving) that are not in the schema. */
export type BangAction = z.infer<typeof bangActionSchema> | { type: 'timeout' } | { type: 'leave' };
export type PlayAction = Extract<BangAction, { type: 'play' }>;
export interface PlayerState {
id: PlayerId;
seat: number;
role: Role;
character: CharacterId;
/** choose2: the two offers until one is picked (secret to everyone else). */
characterOffer: CharacterId[] | null;
maxLife: number;
life: number;
alive: boolean;
hand: string[];
/** Equipment in front of the player (weapons included). */
inPlay: string[];
timeoutsInRow: number;
away: boolean;
}
export type Reaction =
| { kind: 'bang'; target: PlayerId; source: PlayerId; via: 'bang' | 'gatling'; needed: number; barrelTried: boolean; jourTried: boolean; deadline: number }
| { kind: 'indians'; target: PlayerId; source: PlayerId; deadline: number }
| { kind: 'duel'; actor: PlayerId; other: PlayerId; source: PlayerId; deadline: number }
| { kind: 'store'; picker: PlayerId; order: PlayerId[]; revealed: string[]; source: PlayerId; deadline: number }
/** `lost` / `source`: the life loss that caused it (Bart / El Gringo trigger after surviving, §5.5). */
| { kind: 'dying'; player: PlayerId; killer: PlayerId | null; lost: number; source: PlayerId | null; deadline: number }
| { kind: 'kitKeep'; player: PlayerId; options: string[]; deadline: number }
| { kind: 'choose2'; player: PlayerId; deadline: number };
export type ReactionKind = Reaction['kind'];
export type TurnPhase = 'draft' | 'start' | 'draw' | 'play' | 'discard' | 'ended';
export interface TurnState {
player: PlayerId;
phase: TurnPhase;
bangsPlayed: number;
deadline: number;
/** Turn timer is paused while reactions run during the play phase. */
pausedAt: number | null;
dynamiteChecked: boolean;
jailChecked: boolean;
}
export type CheckPurpose = 'dynamite' | 'jail' | 'barrel' | 'jourdonnais';
/** Public log line (structured; the screen turns it into Korean with nicknames). `card` with `vis` is private to those players. */
export type LogEntry = { seq: number } & (
| { t: 'turn'; p: PlayerId; round: number }
| { t: 'play'; p: PlayerId; card: CardFace; as?: CardKind; target?: PlayerId }
| { t: 'equip'; p: PlayerId; card: CardFace; target?: PlayerId; replaced?: CardFace }
| { t: 'check'; p: PlayerId; purpose: CheckPurpose; cards: CardFace[]; success: boolean }
| { t: 'dynamite'; p: PlayerId; exploded: boolean; to?: PlayerId }
| { t: 'jail'; p: PlayerId; escaped: boolean }
| { t: 'missed'; p: PlayerId; cards: CardFace[] }
| { t: 'respond'; p: PlayerId; cards: CardFace[]; why: 'indians' | 'duel' }
| { t: 'barrelSave'; p: PlayerId }
| { t: 'hit'; p: PlayerId; n: number; by: PlayerId | null; life: number }
| { t: 'heal'; p: PlayerId; n: number; why: 'beer' | 'saloon' | 'sid'; cards?: CardFace[] }
| { t: 'draw'; p: PlayerId; n: number; why: 'phase' | 'card' | 'bart' | 'reward' | 'suzy' | 'kit' }
| { t: 'blackJack'; p: PlayerId; card: CardFace; extra: boolean }
| { t: 'pedro'; p: PlayerId; card: CardFace }
| { t: 'take'; p: PlayerId; from: PlayerId; how: 'panic' | 'gringo' | 'jesse'; card?: CardFace; vis?: PlayerId[]; equip?: CardFace }
| { t: 'discarded'; p: PlayerId; by: PlayerId; card: CardFace; fromHand: boolean }
| { t: 'limit'; p: PlayerId; cards: CardFace[] }
| { t: 'store'; p: PlayerId; card: CardFace }
| { t: 'storeOpen'; p: PlayerId; cards: CardFace[] }
| { t: 'dead'; p: PlayerId; role: Role; by: PlayerId | null; left?: boolean }
| { t: 'reward'; p: PlayerId; n: number }
| { t: 'penalty'; p: PlayerId }
| { t: 'vulture'; p: PlayerId; from: PlayerId; n: number }
| { t: 'character'; p: PlayerId; character: CharacterId }
| { t: 'timeout'; p: PlayerId }
| { t: 'reshuffle'; n: number }
| { t: 'end'; winner: Team }
);
export type LogKind = LogEntry['t'];
export type Team = 'law' | 'outlaws' | 'renegade';
export interface BangFinal {
winnerTeam: Team;
players: { id: PlayerId; role: Role; character: CharacterId; alive: boolean; won: boolean; deathOrder: number | null }[];
/** Winners (alive first) → losers (alive first, then the latest death first). */
ranking: PlayerId[];
summary: string;
}
export interface BangState {
options: BangOptions;
players: PlayerState[];
/** Top = last element. Secret. */
deck: string[];
/** Top = last element. Public. */
discard: string[];
turn: TurnState;
reactions: Reaction[];
lastCheck: { player: PlayerId; purpose: CheckPurpose; cards: CardFace[]; success: boolean; seq: number } | null;
winner: Team | null;
deathOrder: PlayerId[];
round: number;
/** Applied actions. */
seq: number;
log: LogEntry[];
final: BangFinal | null;
}
// ---------------------------------------------------------------- view
export interface TargetOption {
id: PlayerId;
ok: boolean;
reason?: string;
/** Panic! / Cat Balou: "hand" and/or equipment card ids that may be chosen. */
cards?: string[];
}
/** What a hand card can do now (my turn, play phase). */
export interface PlayOption {
cardId: string;
as?: 'bang';
ok: boolean;
reason?: string;
/** Targeted cards list every other living player (ok or not, with the reason). */
targets?: TargetOption[];
}
export type Prompt =
| { kind: 'bang'; source: PlayerId; via: 'bang' | 'gatling'; needed: number; options: string[]; canBarrel: boolean }
| { kind: 'indians'; source: PlayerId; options: string[] }
| { kind: 'duel'; opponent: PlayerId; source: PlayerId; options: string[] }
| { kind: 'dying'; needed: number; beers: string[]; beerAllowed: boolean; killer: PlayerId | null }
| { kind: 'store'; revealed: Card[] }
| { kind: 'kitKeep'; options: Card[] }
| { kind: 'choose2'; options: CharacterId[] }
| { kind: 'draw'; jesseTargets: PlayerId[]; pedro: CardFace | null }
| { kind: 'discard'; count: number };
export interface PlayerView {
id: PlayerId;
seat: number;
/** null while a choose2 pick is still hidden. */
character: CharacterId | null;
/** Sheriff, dead players, myself, or everyone after the game. */
role: Role | null;
maxLife: number;
life: number;
alive: boolean;
handCount: number;
inPlay: Card[];
isTurn: boolean;
/** Waiting for this player's reaction. */
reacting: ReactionKind | null;
away: boolean;
/** From me (null for me, dead players or spectators). */
distance: number | null;
/** My 뱅! reaches this player. */
inRange: boolean;
}
export interface ReactionView {
kind: ReactionKind;
who: PlayerId;
source?: PlayerId;
via?: 'bang' | 'gatling';
needed?: number;
revealed?: Card[];
deadline: number;
}
export interface BangView {
options: BangOptions;
me: PlayerId | null;
players: PlayerView[];
current: PlayerId;
phase: TurnPhase;
round: number;
bangsPlayed: number;
deckCount: number;
discardCount: number;
/** Last few discards, top last. */
discardTop: Card[];
reactions: ReactionView[];
lastCheck: BangState['lastCheck'];
my: {
role: Role;
character: CharacterId | null;
hand: Card[];
range: number;
/** Unlimited 뱅! (볼캐닉 / 윌리 더 키드). */
unlimitedBang: boolean;
plays: PlayOption[];
canEndPlay: boolean;
canSidHeal: boolean;
prompt: Prompt | null;
} | null;
turnDeadline: number;
deadlineTotalMs: number;
reactionTotalMs: number;
log: LogEntry[];
winner: Team | null;
result: BangFinal | null;
}

View File

@@ -0,0 +1,108 @@
/** 고스톱 패 정보 (docs/games/gostop.md §3.1): 화투 48장 + 보너스패. */
import { HWATU, hwatuBonus } from '@bg/engine';
export type CardId = number;
export type CardKind = 'gwang' | 'yeol' | 'tti' | 'pi';
export type DanKind = 'hong' | 'cheong' | 'cho';
export interface CardInfo {
id: CardId;
/** 1..12, 0 for bonus cards. */
month: number;
kind: CardKind;
/** 피 값: 일반 피 1, 쌍피 2, 2피 보너스 2, 3피 보너스 3, 그 밖 0. */
piValue: 0 | 1 | 2 | 3;
bi?: true;
godori?: true;
dan?: DanKind;
gukjin?: true;
bonus?: true;
name: string;
}
export type BonusSetting = 'none' | '2+1' | '3+1';
export const BONUS_COUNT: Record<BonusSetting, number> = { none: 0, '2+1': 3, '3+1': 4 };
export const GUKJIN: CardId = 32;
export const BI_GWANG: CardId = 44;
function base(id: CardId): CardInfo {
const c = HWATU[id]!;
const kind: CardKind = c.kind === 'ssangpi' ? 'pi' : (c.kind as CardKind);
const info: CardInfo = { id, month: c.month, kind, piValue: c.piValue as 0 | 1 | 2, name: c.name };
if (c.tags.includes('rain')) info.bi = true;
if (c.tags.includes('godori')) info.godori = true;
if (c.tags.includes('gukjin')) {
info.gukjin = true;
info.name = '9월 열끗·국진(국화)';
}
if (c.tti && c.tti !== 'plain') info.dan = c.tti;
return info;
}
const REGULAR: CardInfo[] = HWATU.map((c) => base(c.id));
const BONUS_TABLES: CardInfo[][] = [0, 1, 2, 3, 4].map((n) =>
hwatuBonus(n).map((b) => ({ id: b.id, month: 0, kind: 'pi' as const, piValue: b.piValue as 2 | 3, bonus: true as const, name: b.name })),
);
/** Card info; `nb` = number of bonus cards in play (the last bonus id is the 3피). */
export function cardInfo(id: CardId, nb: number): CardInfo {
if (id < 48) {
const c = REGULAR[id];
if (!c) throw new Error(`gostop: unknown card ${id}`);
return c;
}
const c = BONUS_TABLES[nb]?.[id - 48];
if (!c) throw new Error(`gostop: unknown bonus card ${id} (bonus ${nb})`);
return c;
}
export const isBonus = (id: CardId): boolean => id >= 48;
export const monthOf = (id: CardId): number => (id >= 48 ? 0 : Math.floor(id / 4) + 1);
export function fullDeck(nb: number): CardId[] {
return Array.from({ length: 48 + nb }, (_, i) => i);
}
/** Card value for automatic choices (§8.5): 광 > 열끗 > 띠 > 쌍피(피 값 2 이상) > 피. */
export function cardValue(id: CardId, nb: number): number {
const c = cardInfo(id, nb);
if (c.kind === 'gwang') return 5;
if (c.kind === 'yeol') return 4;
if (c.kind === 'tti') return 3;
return c.piValue >= 2 ? 2 : 1;
}
/** Two floor cards of a month are interchangeable when kind and 피 값 are the same (§5.2). */
export function sameKind(a: CardId, b: CardId, nb: number): boolean {
const x = cardInfo(a, nb);
const y = cardInfo(b, nb);
return x.kind === y.kind && x.piValue === y.piValue;
}
/** Best of two (or more) cards by value, ties → lower id. */
export function bestCard(ids: CardId[], nb: number): CardId {
return [...ids].sort((a, b) => cardValue(b, nb) - cardValue(a, nb) || a - b)[0]!;
}
/** Display kind for the client (matches the shared HwatuCard component). */
export type ViewKind = 'gwang' | 'yeol' | 'tti' | 'pi' | 'ssangpi' | 'bonus';
export interface CardView {
/** Unique per round so leak tests can spot hidden cards. */
key: string;
id: CardId;
month: number;
kind: ViewKind;
tti?: 'hong' | 'cheong' | 'cho' | 'plain';
name: string;
piValue: number;
}
export function cardView(id: CardId, nb: number, keyPrefix: string): CardView {
const c = cardInfo(id, nb);
const kind: ViewKind = c.bonus ? 'bonus' : c.kind === 'pi' && c.piValue >= 2 ? 'ssangpi' : c.kind;
const out: CardView = { key: `${keyPrefix}c${id}`, id, month: c.month, kind, name: c.name, piValue: c.piValue };
if (c.kind === 'tti') out.tti = c.dan ?? 'plain';
return out;
}

View File

@@ -0,0 +1,956 @@
import { describe, expect, test } from 'bun:test';
import { SeededRng, randomPlayout, seedN, type GameEvent, type Rng } from '@bg/engine';
import {
autoPlay,
bestScore,
cardInfo,
cardsInRound,
gostop,
isBonus,
scoreOf,
settle,
type FloorGroup,
type GostopAction,
type GostopOptions,
type GostopState,
type SettleInput,
} from './index';
import { resolveRules } from './round';
// ---------------------------------------------------------------- helpers
// Card ids (§3.1): month m → (m-1)*4 + slot. 1월 0광 1홍단 2·3피, 2월 4열(고도리) 5홍단, 3월 8광 9홍단,
// 4월 12열(고도리) 13초단, 5월 16열 17초단, 6월 20열 21청단, 7월 24열 25초단, 8월 28광 29열(고도리),
// 9월 32국진 33청단, 10월 36열 37청단, 11월 40광 41쌍피, 12월 44비광 45열 46띠 47쌍피.
const NAMES = ['A', 'B', 'C', 'D', 'E'];
const players = (n: number) => NAMES.slice(0, n);
let clock = 1000;
const rng = () => SeededRng.fromSeed([9, 9, 9, 9]);
const parse = (o: Partial<GostopOptions> = {}) => gostop.optionsSchema.parse(o);
/** Rng whose first shuffles return the given prefixes (rest appended in id order), then falls back to a seeded rng. */
function scripted(orders: number[][], dealer = 0): Rng {
const fallback = SeededRng.fromSeed([5, 6, 7, 8]);
let si = 0;
let first = true;
return {
next: () => fallback.next(),
int: (n) => {
if (first) {
first = false;
return dealer % n;
}
return fallback.int(n);
},
shuffle: <T,>(arr: readonly T[]): T[] => {
const o = orders[si++] as unknown as T[] | undefined;
if (!o) return fallback.shuffle(arr);
return [...o, ...arr.filter((x) => !o.includes(x))];
},
};
}
interface Spec {
hands: number[][];
floor?: number[];
piles?: Record<number, { ppeokBy?: number | null; natural?: boolean }>;
deck: number[];
captured?: number[][];
turn?: number;
}
/** A play-phase state with exactly these cards (no conservation; dealer = A). */
function rigged(n: number, spec: Spec, opts: Partial<GostopOptions> = {}): GostopState {
const s = gostop.setup({ players: players(n), options: parse({ chongtong: 'off', ...opts }), rng: SeededRng.fromSeed(seedN(1)), now: 0 });
s.dealer = 0;
const r = s.round!;
const zeros = () => Array.from({ length: n }, () => 0);
const lists = () => Array.from({ length: n }, (): number[] => []);
Object.assign(r, {
participants: Array.from({ length: Math.min(n, 3) }, (_, i) => i),
sellOrder: [],
sellCursor: 0,
sellDecisions: [],
sellers: [],
step: 'play',
turn: spec.turn ?? 0,
pending: null,
stopBy: null,
chongtongBy: null,
bombDummies: zeros(),
revealed: lists(),
shakenMonths: lists(),
goCount: zeros(),
lastGoScore: zeros(),
goOrder: [],
shakes: zeros(),
bombs: zeros(),
ppeokCount: zeros(),
ppeokTurns: lists(),
turnsTaken: zeros(),
instant: zeros(),
});
r.hands = Array.from({ length: n }, (_, i) => [...(spec.hands[i] ?? [])]);
r.deck = [...spec.deck];
r.captured = Array.from({ length: n }, (_, i) => [...(spec.captured?.[i] ?? [])]);
const groups: FloorGroup[] = [];
for (const c of spec.floor ?? []) {
const m = Math.floor(c / 4) + 1;
const g = groups.find((x) => x.month === m);
if (g) g.cards.push(c);
else groups.push({ month: m, cards: [c], ppeokBy: null, natural: false });
}
for (const g of groups) Object.assign(g, spec.piles?.[g.month] ?? {});
r.floor = groups.sort((a, b) => a.month - b.month);
s.waitKey = '';
return s;
}
function actE(s: GostopState, p: string, a: GostopAction, now = (clock += 1000)): { state: GostopState; events: GameEvent[] } {
const v = gostop.validate(s, p, a);
if (!v.ok) throw new Error(`${p} ${JSON.stringify(a)}: ${v.reason}`);
return gostop.apply(s, p, a, { rng: rng(), now });
}
const act = (s: GostopState, p: string, a: GostopAction, now?: number) => actE(s, p, a, now).state;
const reject = (s: GostopState, p: string, a: GostopAction) => {
const v = gostop.validate(s, p, a);
return v.ok ? null : v.reason;
};
const play = (card: number | 'dummy', extra: Partial<Extract<GostopAction, { type: 'play' }>> = {}): GostopAction => ({ type: 'play', card, ...extra });
const sorted = (xs: number[]) => [...xs].sort((a, b) => a - b);
const cap = (s: GostopState, i: number) => sorted(s.round!.captured[i]!);
const floorIds = (s: GostopState) => sorted(s.round!.floor.flatMap((g) => g.cards));
function settleOf(over: Partial<SettleInput> & { captured: number[][] }, opts: Partial<GostopOptions> = {}, n = over.captured.length): ReturnType<typeof settle> {
const zeros = () => Array.from({ length: n }, () => 0);
return settle({
rules: resolveRules(parse(opts), n),
mode: n === 2 ? 'matgo' : 'gostop',
winner: 0,
participants: Array.from({ length: n }, (_, i) => i),
goCount: zeros(),
goOrder: [],
shakes: zeros(),
bombs: zeros(),
nagariMult: 1,
gukjinChoice: Array.from({ length: n }, () => null),
...over,
});
}
const pay = (st: ReturnType<typeof settle>, from: number) => st.payments.find((x) => x.from === from)!;
// pi helpers
const PIS = [2, 3, 6, 7, 10, 11, 14, 15, 18, 19, 22, 23, 26, 27, 30, 31, 34, 35, 38, 39, 42, 43];
const pis = (n: number, from = 0) => PIS.slice(from, from + n);
// ---------------------------------------------------------------- cards & scoring
describe('패 구성 (§3.1)', () => {
test('48장 + 보너스: 광 5, 열끗 9, 띠 10, 피 24(쌍피 41·47), 보너스 2피·3피', () => {
const all = Array.from({ length: 48 }, (_, i) => cardInfo(i, 0));
const count = (k: string) => all.filter((c) => c.kind === k).length;
expect([count('gwang'), count('yeol'), count('tti'), count('pi')]).toEqual([5, 9, 10, 24]);
expect(all.filter((c) => c.piValue === 2).map((c) => c.id)).toEqual([41, 47]);
expect(all.filter((c) => c.godori).map((c) => c.id)).toEqual([4, 12, 29]);
expect(cardInfo(32, 0).gukjin).toBe(true);
expect(cardInfo(44, 0).bi).toBe(true);
expect(cardInfo(46, 0).dan).toBeUndefined();
expect([48, 49, 50].map((id) => cardInfo(id, 3).piValue)).toEqual([2, 2, 3]);
expect([48, 49, 50, 51].map((id) => cardInfo(id, 4).piValue)).toEqual([2, 2, 2, 3]);
});
});
describe('점수 (§6.1)', () => {
const R = resolveRules(parse(), 2);
test('비삼광 2점, 삼광 3점, 사광 4점(비광 포함), 오광 15점(옵션 30)', () => {
expect(scoreOf([0, 8, 44], R).total).toBe(2);
expect(scoreOf([0, 8, 28], R).total).toBe(3);
expect(scoreOf([0, 8, 28, 44], R).total).toBe(4);
expect(scoreOf([0, 8, 28, 40, 44], R).total).toBe(15);
expect(scoreOf([0, 8, 28, 40, 44], resolveRules(parse({ ogwangPoints: 30 }), 2)).total).toBe(30);
expect(scoreOf([0, 8, 44], resolveRules(parse({ biGwangThree: 0 }), 2)).total).toBe(0);
expect(scoreOf([0, 8], R).total).toBe(0);
});
test('고도리 5 + 열끗 장수 점수 중복', () => {
expect(scoreOf([4, 12, 29], R).total).toBe(5);
expect(scoreOf([4, 12, 29, 16, 20], R).items).toEqual([
{ name: '열끗 5장', points: 1 },
{ name: '고도리', points: 5 },
]);
});
test('띠 5장 1점 + 홍단 3점 중복, 청단·초단, 비띠는 장수에만', () => {
expect(scoreOf([1, 5, 9, 13, 46], R).total).toBe(4);
expect(scoreOf([21, 33, 37], R).total).toBe(3);
expect(scoreOf([13, 17, 25], R).total).toBe(3);
expect(scoreOf([1, 5, 46], R).total).toBe(0);
});
test('피 값 10에 1점, 쌍피·보너스 반영', () => {
expect(scoreOf(pis(9), R).total).toBe(0);
expect(scoreOf(pis(10), R).total).toBe(1);
expect(scoreOf([...pis(6), 41, 47], R).total).toBe(1);
const R3 = resolveRules(parse({ bonusCards: '2+1' }), 2);
expect(scoreOf([...pis(5), 48, 50], R3).total).toBe(1);
expect(scoreOf([...pis(5), 48, 50], R3).pi).toBe(10);
});
test('국진 auto (예시 6): 열끗 5장(국진 포함) + 피 값 9 → 쌍피로 계산해 2점', () => {
const cards = [4, 12, 16, 20, 32, ...pis(9)];
expect(scoreOf(cards, R, 'yeol').total).toBe(1);
expect(scoreOf(cards, R, 'pi').total).toBe(2);
expect(bestScore(cards, R)).toMatchObject({ as: 'pi', calc: { total: 2 } });
expect(bestScore(cards, resolveRules(parse({ gukjinMode: 'yeolOnly' }), 2))).toMatchObject({ as: 'yeol', calc: { total: 1 } });
});
});
describe('최종 공식 (§6.2, §6.3)', () => {
// 예시 1: 삼광 3 + 피 12(3) + 띠 5장(1) = 7. 상대 광 0, 피 6 → 피박·광박 28.
const ex1 = [0, 8, 28, ...pis(12), 1, 5, 13, 21, 46];
test('예시 1: 맞고 7점 스톱, 피박·광박 → 28점', () => {
const st = settleOf({ captured: [ex1, pis(6, 12)] });
expect(st.baseScore).toBe(7);
expect(pay(st, 1)).toEqual({ from: 1, amount: 28, baks: ['pi', 'gwang'] });
});
test('예시 3: 9점·3고·흔들기 1번 → 48점, 상대 피박이면 96점', () => {
const w = [0, 8, 28, ...pis(13), 41];
expect(scoreOf(w, resolveRules(parse(), 2)).total).toBe(9);
const base = { goCount: [3, 0], goOrder: [0, 0, 0], shakes: [1, 0] };
expect(pay(settleOf({ ...base, captured: [w, [40, ...pis(8, 13)]] }), 1).amount).toBe(48);
expect(pay(settleOf({ ...base, captured: [w, [40, ...pis(7, 13)]] }), 1)).toMatchObject({ amount: 96, baks: ['pi'] });
});
test('예시 5: 3인 독박 — A가 18점, C 0점, B +18', () => {
const B = [1, 5, 9, 13, 21, ...pis(11)];
const st = settleOf({ winner: 1, captured: [pis(8, 11), B, pis(4, 19).concat([43])], goCount: [1, 0, 0], goOrder: [0] }, {}, 3);
expect(st.baseScore).toBe(6);
expect(pay(st, 0)).toMatchObject({ amount: 18, baks: ['go'] });
expect(pay(st, 2)).toMatchObject({ amount: 0, coveredBy: 0 });
// Without 고박 each loser pays their own share.
const off = settleOf({ winner: 1, captured: [pis(8, 11), B, pis(4, 19).concat([43])], goCount: [1, 0, 0], goOrder: [0] }, { goBak: false }, 3);
expect([pay(off, 0).amount, pay(off, 2).amount]).toEqual([6, 12]);
});
test('3인 독박: 고를 부른 패자가 2명이면 마지막에 부른 사람', () => {
const B = [1, 5, 9, 13, 21, ...pis(11)];
const st = settleOf({ winner: 1, captured: [pis(8, 11), B, [40, ...pis(8)]], goCount: [1, 0, 1], goOrder: [2, 0] }, {}, 3);
expect(pay(st, 0).amount).toBe(12);
expect(pay(st, 2).amount).toBe(0);
});
test('예시 7: 직전 판 나가리 → 7 x 2 = 14점', () => {
const w = [0, 8, 28, ...pis(12), 1, 5, 13, 21, 46];
expect(pay(settleOf({ nagariMult: 2, captured: [w, [40, ...pis(8, 12)]] }), 1).amount).toBe(14);
});
test('예시 8: 멍따 — 열끗 7장(고도리 포함) 8점 x2 = 16점; 멍박 옵션이면 패자 열끗 0장 x2', () => {
const w = [4, 12, 29, 16, 20, 24, 36];
expect(pay(settleOf({ captured: [w, [45, ...pis(3)]] }), 1).amount).toBe(16);
expect(pay(settleOf({ captured: [w, [45, ...pis(3)]] }, { meongTta: false }), 1).amount).toBe(8);
expect(pay(settleOf({ captured: [w, pis(3)] }, { meongBak: true }), 1)).toMatchObject({ amount: 32, baks: ['meong'] });
});
test('고 배수: 1·2고는 가산만, 3고 x2, 4고 x4, 5고 x8', () => {
const w = [0, 8, 28, ...pis(12), 1, 5, 13, 21, 46];
const amt = (go: number) => pay(settleOf({ goCount: [go, 0], goOrder: Array(go).fill(0), captured: [w, [40, ...pis(8, 12)]] }), 1).amount;
expect([0, 1, 2, 3, 4, 5].map(amt)).toEqual([7, 8, 9, 20, 44, 96]);
});
test('흔들기 2번 x4, 폭탄 x2, 패자의 흔들기는 배수 없음', () => {
const w = [0, 8, 28, ...pis(12), 1, 5, 13, 21, 46];
const L = [40, ...pis(8, 12)];
expect(pay(settleOf({ shakes: [2, 0], captured: [w, L] }), 1).amount).toBe(28);
expect(pay(settleOf({ shakes: [1, 0], bombs: [1, 0], captured: [w, L] }), 1).amount).toBe(28);
expect(pay(settleOf({ shakes: [0, 2], bombs: [0, 1], captured: [w, L] }), 1).amount).toBe(7);
});
test('피박·광박·고박이 모두 겹치면 모두 곱함(맞고)', () => {
const st = settleOf({ captured: [ex1, pis(6, 12)], goCount: [0, 1], goOrder: [1] });
expect(pay(st, 1)).toEqual({ from: 1, amount: 56, baks: ['pi', 'gwang', 'go'] });
});
});
describe('박 경계 (§6.2)', () => {
// Winner: 피 값 12 (3점) + 열끗 5장 (1점) + 고도리 (5) = 9, no 광.
const W = [4, 12, 29, 16, 20, ...pis(12)];
test('맞고 피박: 7 피박, 8 아님; 피 0장은 면제(옵션으로 끄면 피박)', () => {
expect(pay(settleOf({ captured: [W, pis(7, 12)] }), 1).baks).toEqual(['pi']);
expect(pay(settleOf({ captured: [W, pis(8, 12)] }), 1).baks).toEqual([]);
expect(pay(settleOf({ captured: [W, [36]] }), 1).baks).toEqual([]);
expect(pay(settleOf({ captured: [W, [36]] }, { piBakZeroExempt: false }), 1).baks).toEqual(['pi']);
});
test('3인 피박: 5 피박, 6 아님', () => {
const st = settleOf({ captured: [W, pis(5, 12), pis(6, 12).map((c) => c)] }, {}, 3);
expect(pay(st, 1).baks).toEqual(['pi']);
expect(pay(st, 2).baks).toEqual([]);
});
test('승자 피 점수가 없으면 피박 없음', () => {
expect(pay(settleOf({ captured: [[4, 12, 29, 16, 20, 24, 36, 45], pis(2, 12)] }, { meongTta: false }), 1).baks).toEqual([]);
});
test('광박은 승자가 광 점수를 받았을 때만', () => {
expect(pay(settleOf({ captured: [[0, 8, ...W], pis(8, 12)] }), 1).baks).toEqual([]);
expect(pay(settleOf({ captured: [[0, 8, 28, ...W], pis(8, 12)] }), 1).baks).toEqual(['gwang']);
expect(pay(settleOf({ captured: [[0, 8, 28, ...W], pis(8, 12)] }, { gwangBak: false }), 1).baks).toEqual([]);
});
test('국진: 패자는 피박을 피하는 쪽으로(3인 5→7 면제, 맞고 기준 7이면 여전히 피박)', () => {
const loser = [32, ...pis(5, 12)];
const g3 = settleOf({ captured: [W, loser, pis(8, 12)] }, {}, 3);
expect(g3.gukjinAs).toBe('pi');
expect(pay(g3, 1).baks).toEqual([]);
const g2 = settleOf({ captured: [W, loser] });
expect(pay(g2, 1).baks).toEqual(['pi']);
// ask mode with an explicit choice is honoured.
expect(settleOf({ captured: [W, loser, pis(8, 12)], gukjinChoice: [null, 'yeol', null] }, { gukjinMode: 'ask' }, 3).gukjinAs).toBe('yeol');
});
});
// ---------------------------------------------------------------- setup
describe('셋업 (§4)', () => {
test('같은 시드면 같은 배분; 맞고 10/8/20(+보너스), 3인 7/6/21', () => {
for (let seed = 1; seed <= 40; seed++) {
const a = gostop.setup({ players: players(2), options: parse(), rng: SeededRng.fromSeed(seedN(seed)), now: 0 });
const b = gostop.setup({ players: players(2), options: parse(), rng: SeededRng.fromSeed(seedN(seed)), now: 0 });
expect(JSON.stringify(a)).toBe(JSON.stringify(b));
const r = a.round!;
expect(r.hands.map((h) => h.length)).toEqual([10, 10]);
expect(r.floor.reduce((x, g) => x + g.cards.length, 0)).toBe(8);
const floorBonus = r.captured[a.dealer]!.length;
expect(r.deck.length + floorBonus).toBe(23);
expect(cardsInRound(a)).toBe(51);
const s3 = gostop.setup({ players: players(3), options: parse({ chongtong: 'off' }), rng: SeededRng.fromSeed(seedN(seed)), now: 0 });
expect(s3.round!.hands.map((h) => h.length)).toEqual([7, 7, 7]);
expect(s3.round!.deck).toHaveLength(21);
expect(s3.rules).toMatchObject({ nb: 0, winScore: 3, piBakLimit: 5, chongtongPoints: 5 });
expect(a.rules).toMatchObject({ nb: 3, winScore: 7, piBakLimit: 7, chongtongPoints: 10 });
}
});
test('바닥에 같은 월 4장 → 다시 섞기(선·판 번호 유지)', () => {
const hands = [[4, 5, 6, 8, 9, 10, 12, 13, 14, 16], [17, 18, 20, 21, 22, 24, 25, 26, 28, 29]];
const rngS = scripted([[...hands.flat(), 0, 1, 2, 3, 32, 33, 36, 37]], 1);
const s = gostop.setup({ players: players(2), options: parse({ bonusCards: 'none' }), rng: rngS, now: 0 });
expect(s.round!.redeals).toBe(1);
expect(s.roundNo).toBe(1);
expect(s.dealer).toBe(1);
const counts = new Map<number, number>();
for (const g of s.round!.floor) counts.set(g.month, g.cards.length);
expect([...counts.values()].every((v) => v < 4)).toBe(true);
expect(cardsInRound(s)).toBe(48);
});
test('바닥 3장 같은 월 → 한 무더기(자연 뻑); 4번째로 먹으면 피 1장씩', () => {
const hands = [[3, 5, 6, 8, 9, 10, 12, 13, 14, 16], [17, 18, 20, 21, 22, 24, 25, 26, 28, 29]];
const s = gostop.setup({ players: players(2), options: parse({ bonusCards: 'none' }), rng: scripted([[...hands.flat(), 0, 1, 2, 32, 33, 36, 37, 40]]), now: 0 });
const pile = s.round!.floor.find((g) => g.month === 1)!;
expect(pile).toMatchObject({ cards: [0, 1, 2], natural: true, ppeokBy: null });
s.round!.captured[1] = [41, 7];
const t = act(s, 'A', play(3));
expect(cap(t, 0)).toEqual(expect.arrayContaining([0, 1, 2, 3, 7]));
expect(t.round!.lastTurn!.tags).toContain('ppeokEat');
// naturalThreeSteal off → no 피
const s2 = gostop.setup({ players: players(2), options: parse({ bonusCards: 'none', naturalThreeSteal: false }), rng: scripted([[...hands.flat(), 0, 1, 2, 32, 33, 36, 37, 40]]), now: 0 });
s2.round!.captured[1] = [41, 7];
expect(cap(act(s2, 'A', play(3)), 1)).toEqual([7, 41]);
});
test('바닥 보너스패 → 선이 가져가고 더미에서 보충, 보충패도 보너스면 반복', () => {
const hands = [[4, 5, 6, 8, 9, 10, 12, 13, 14, 16], [17, 18, 20, 21, 22, 24, 25, 26, 28, 29]];
const s = gostop.setup({ players: players(2), options: parse(), rng: scripted([[...hands.flat(), 48, 0, 1, 32, 33, 36, 37, 40, 49, 44, 45]]), now: 0 });
const r = s.round!;
expect(sorted(r.captured[0]!)).toEqual([48, 49]);
expect(floorIds(s)).toEqual([0, 1, 32, 33, 36, 37, 40, 44]);
expect(r.deck).toHaveLength(51 - 20 - 8 - 2);
expect(r.deck[0]).toBe(45);
});
});
// ---------------------------------------------------------------- turn mechanics
describe('먹기 판정 (§5.2)', () => {
test('쪽: 바닥에 없는 월을 냈는데 같은 월이 뒤집힘 → 2장 먹고 상대마다 피 1장', () => {
const s = rigged(2, { hands: [[2, 40], [10, 11]], floor: [4, 12], deck: [3, 20, 21], captured: [[], [6]] });
const { state: t, events } = actE(s, 'A', play(2));
expect(cap(t, 0)).toEqual([2, 3, 6]);
expect(cap(t, 1)).toEqual([]);
expect(t.round!.lastTurn!.tags).toEqual(['jjok']);
expect(events.some((e) => e.type === 'jjok')).toBe(true);
expect(t.round!.turn).toBe(1);
});
test('뻑: 바닥 1장과 짝 + 같은 월 뒤집힘 → 3장 무더기, ppeokBy 기록; 남이 먹으면 1장, 자뻑은 2장씩', () => {
const base = () => rigged(2, { hands: [[2, 40], [1, 11]], floor: [0, 4], deck: [3, 20, 21, 22], captured: [[6, 7, 41], [10, 47, 14]] }, { ppeokBonus: false });
let s = act(base(), 'A', play(2));
const pile = s.round!.floor.find((g) => g.month === 1)!;
expect(pile).toMatchObject({ cards: [0, 2, 3], ppeokBy: 0, natural: false });
expect(cap(s, 0)).toEqual([6, 7, 41]);
expect(s.round!.ppeokCount[0]).toBe(1);
// B eats the pile with the 4th card → 1 피 from A (lowest value first: 6).
const t = act(s, 'B', play(1));
expect(cap(t, 1)).toEqual(expect.arrayContaining([0, 1, 2, 3, 6]));
expect(cap(t, 0)).toEqual([7, 41]);
expect(t.round!.lastTurn!.tags).toContain('ppeokEat');
// 자뻑: A eats its own pile → 2 피 from B.
s = rigged(2, { hands: [[1, 40], [11, 15]], floor: [0, 2, 3, 4], piles: { 1: { ppeokBy: 0 } }, deck: [20, 21, 22], captured: [[], [10, 47, 14]] });
const u = act(s, 'A', play(1));
expect(u.round!.lastTurn!.tags).toContain('jaPpeok');
expect(cap(u, 1)).toEqual([47]);
// jaPpeokSteal = 1
s = rigged(2, { hands: [[1, 40], [11, 15]], floor: [0, 2, 3, 4], piles: { 1: { ppeokBy: 0 } }, deck: [20, 21, 22], captured: [[], [10, 47, 14]] }, { jaPpeokSteal: 1 });
expect(cap(act(s, 'A', play(1)), 1)).toEqual([14, 47]);
});
test('따닥: 바닥 2장 + 손 1장 + 같은 월 뒤집힘 → 4장 먹고 피 1장씩', () => {
const s = rigged(2, { hands: [[1, 40], [11]], floor: [0, 2, 4], deck: [3, 20], captured: [[], [6, 7]] });
const t = act(s, 'A', play(1, { target: 0 }));
expect(cap(t, 0)).toEqual([0, 1, 2, 3, 6]);
expect(t.round!.lastTurn!.tags).toEqual(['ttadak']);
});
test('쓸: 턴 끝에 바닥이 비면 피 1장씩; 따닥 + 쓸이면 2장씩', () => {
let s = rigged(2, { hands: [[2, 40], [11]], floor: [0, 20], deck: [21, 5], captured: [[], [6, 7, 10]] });
let t = act(s, 'A', play(2));
expect(t.round!.lastTurn!.tags).toEqual(['sseul']);
expect(cap(t, 1)).toEqual([7, 10]);
s = rigged(2, { hands: [[1, 40], [11]], floor: [0, 2], deck: [3, 5], captured: [[], [6, 7, 10]] });
t = act(s, 'A', play(1, { target: 2 }));
expect(t.round!.lastTurn!.tags).toEqual(['ttadak', 'sseul']);
expect(cap(t, 1)).toEqual([10]);
});
test('마지막 턴 쓸·쪽은 lastTurnSweepBonus=false면 피 뺏기 없음(따닥은 인정)', () => {
// 쪽 on the last flip, nobody has cards left → 나가리 afterwards.
let t = act(rigged(2, { hands: [[2], []], floor: [4], deck: [3], captured: [[], [6]] }), 'A', play(2));
expect(t.round!.lastTurn).toMatchObject({ tags: ['jjok'], stolen: [] });
expect(t.round!.outcome!.type).toBe('nagari');
t = act(rigged(2, { hands: [[2], []], floor: [4], deck: [3], captured: [[], [6]] }, { lastTurnSweepBonus: true }), 'A', play(2));
expect(t.round!.lastTurn!.stolen).toEqual([[1, [6]]]);
// 쓸 on the last turn
t = act(rigged(2, { hands: [[2], []], floor: [0, 20], deck: [21], captured: [[], [6]] }), 'A', play(2));
expect(t.round!.lastTurn).toMatchObject({ tags: ['sseul'], stolen: [] });
// 따닥 on the last turn still steals
t = act(rigged(2, { hands: [[1], []], floor: [0, 2, 4], deck: [3], captured: [[], [6]] }), 'A', play(1, { target: 0 }));
expect(t.round!.lastTurn!.stolen).toEqual([[1, [6]]]);
});
test('두 장 중 고르기: 광/피면 target 없이 내면 거부; 같은 종류면 자동', () => {
const s = rigged(2, { hands: [[1, 40], [11]], floor: [0, 2, 20], deck: [21, 22] });
expect(reject(s, 'A', play(1))).toBe('가져올 패를 골라 주세요.');
expect(reject(s, 'A', play(1, { target: 3 }))).toBe('바닥에 있는 같은 월 패 중에서 골라 주세요.');
expect(reject(s, 'A', play(40, { target: 0 }))).toBe('고를 패가 없어요.');
const t = act(s, 'A', play(1, { target: 0 }));
expect(cap(t, 0)).toEqual([0, 1, 20, 21]);
expect(floorIds(t)).toEqual([2]);
const same = rigged(2, { hands: [[0, 40], [11]], floor: [2, 3, 20], deck: [21, 22] });
expect(reject(same, 'A', play(0))).toBeNull();
expect(cap(act(same, 'A', play(0)), 0)).toEqual([0, 2, 20, 21]);
});
test('뒤집은 패가 바닥 2장과 맞고 종류가 다르면 chooseFlip 단계; 같으면 자동', () => {
const s = rigged(2, { hands: [[4, 40], [11]], floor: [28, 30, 12], deck: [31, 22] });
const t = act(s, 'A', play(4));
expect(t.round!.step).toBe('chooseFlip');
expect(gostop.activePlayers(t)).toEqual(['A']);
expect(reject(t, 'A', play(40))).toBe('먼저 뒤집은 패로 가져올 패를 골라 주세요.');
expect(reject(t, 'A', { type: 'chooseFlip', target: 12 })).toBeTruthy();
expect(cardsInRound(t)).toBe(cardsInRound(s));
const u = act(t, 'A', { type: 'chooseFlip', target: 28 });
expect(cap(u, 0)).toEqual([28, 31]);
expect(floorIds(u)).toEqual([4, 12, 30]);
expect(u.round!.turn).toBe(1);
const same = act(rigged(2, { hands: [[4, 40], [11]], floor: [30, 31, 12], deck: [28, 22] }), 'A', play(4));
expect(same.round!.step).toBe('play');
expect(cap(same, 0)).toEqual([28, 30]);
});
});
describe('피 뺏기 순서 (§5.3)', () => {
test('일반 피 → 쌍피·2피 → 3피, 같은 값이면 낮은 ID; 피가 없으면 안 줌; 국진은 안 뺏김', () => {
const go = (b: number[], opts: Partial<GostopOptions> = {}) => cap(act(rigged(2, { hands: [[2, 40], [11]], floor: [4], deck: [3, 20], captured: [[], b] }, opts), 'A', play(2)), 1);
expect(go([41])).toEqual([]);
expect(go([41, 43])).toEqual([41]);
expect(go([32, 36])).toEqual([32, 36]);
expect(go([50, 48], { bonusCards: '2+1' })).toEqual([50]);
expect(go([])).toEqual([]);
});
});
describe('폭탄·흔들기·보너스패 (§5.4–5.6)', () => {
test('폭탄: 3장 + 바닥 1장 → 4장 먹고 피 1장씩, 빈패 2장, 빈패 내면 더미만 뒤집음', () => {
const s = rigged(2, { hands: [[0, 1, 2, 40], [11, 15]], floor: [3, 20, 12], deck: [21, 5, 16, 17], captured: [[], [6, 7]] });
expect(reject(s, 'A', play(40, { bomb: true }))).toBe('폭탄은 같은 월 3장과 바닥 1장이 있어야 해요.');
let t = act(s, 'A', play(1, { bomb: true }));
const r = t.round!;
expect(cap(t, 0)).toEqual([0, 1, 2, 3, 6, 20, 21]);
expect(r.hands[0]).toEqual([40]);
expect(r.bombDummies[0]).toBe(2);
expect(r.bombs[0]).toBe(1);
expect(r.lastTurn!.tags).toEqual(['bomb']);
t = act(t, 'B', play(11));
expect(gostop.view(t, 'A').myDummies).toBe(2);
expect(reject(t, 'A', play('dummy', { shake: true }))).toBeTruthy();
const deckBefore = t.round!.deck.length;
t = act(t, 'A', play('dummy'));
expect(t.round!.hands[0]).toEqual([40]);
expect(t.round!.bombDummies[0]).toBe(1);
expect(t.round!.deck.length).toBe(deckBefore - 1);
expect(reject(rigged(2, { hands: [[0, 1, 2], []], floor: [3], deck: [5] }, { bomb: false }), 'A', play(0, { bomb: true }))).toBe('이 방은 폭탄을 쓰지 않아요.');
});
test('흔들기: 공개된 패는 상대 view에 보이고 나머지 손패는 안 보임; 같은 월 두 번은 안 됨', () => {
const s = rigged(2, { hands: [[0, 1, 2, 40, 44], [11, 15]], floor: [20], deck: [21, 5, 16, 17] });
expect(reject(s, 'A', play(40, { shake: true }))).toContain('흔들기는');
const t = act(s, 'A', play(0, { shake: true }));
expect(t.round!.shakes[0]).toBe(1);
expect(sorted(t.round!.revealed[0]!)).toEqual([1, 2]);
const bView = JSON.stringify(gostop.view(t, 'B'));
expect(bView).toContain('"r1c1"');
expect(bView).toContain('"r1c2"');
expect(bView).not.toContain('"r1c40"');
expect(bView).not.toContain('"r1c44"');
expect(gostop.secretsOf!(t, 'A')).toEqual(['r1c40', 'r1c44', ...t.round!.deck.map((c) => `r1c${c}`)]);
expect(reject(rigged(2, { hands: [[0, 1, 2], []], floor: [], deck: [5] }, { shake: false }), 'A', play(0, { shake: true }))).toBe('이 방은 흔들기를 쓰지 않아요.');
const again = rigged(2, { hands: [[0, 1, 2], []], floor: [], deck: [5] });
again.round!.shakenMonths[0] = [1];
expect(reject(again, 'A', play(0, { shake: true }))).toBe('이 월은 이미 흔들었어요.');
});
test('보너스패: 손에서 내면 먹고 1장 보충 후 같은 턴 계속; 뒤집힌 보너스패는 먹고 1장 더', () => {
const opts = { bonusCards: '2+1' as const };
let s = rigged(2, { hands: [[48, 2], [11]], floor: [0], deck: [20, 49, 21, 22, 23], captured: [[], []] }, opts);
expect(reject(s, 'A', play(48, { target: 0 }))).toBe('보너스패는 그냥 내기만 할 수 있어요.');
s = act(s, 'A', play(48));
expect(s.round!.step).toBe('play');
expect(s.round!.turn).toBe(0);
expect(sorted(s.round!.hands[0]!)).toEqual([2, 20]);
expect(cap(s, 0)).toEqual([48]);
expect(s.round!.lastTurn!.tags).toEqual(['bonus']);
// Flip: 49 is a bonus → taken, then 21 flips.
s = act(s, 'A', play(2));
expect(cap(s, 0)).toEqual([0, 2, 48, 49]);
expect(s.round!.lastTurn!.flipped).toEqual([49, 21]);
expect(floorIds(s)).toEqual([21]);
expect(s.round!.turn).toBe(1);
});
});
describe('총통 (§5.7)', () => {
const hands = [[0, 1, 2, 3, 4, 5, 6, 8, 9, 10], [12, 13, 14, 16, 17, 18, 20, 21, 22, 24]];
const order = [...hands.flat(), 25, 26, 28, 29, 32, 33, 36, 37];
test('손에 같은 월 4장 → 선언 시 맞고 10점(나가리 배수만 적용); 미선언 시 진행·흔들기 가능', () => {
const s = gostop.setup({ players: players(2), options: parse({ bonusCards: 'none' }), rng: scripted([order]), now: 0 });
expect(s.round!.step).toBe('chongtong');
expect(gostop.activePlayers(s)).toEqual(['A']);
expect(gostop.view(s, 'A').chongtongAsk).toEqual({ player: 'A', months: [1] });
expect(gostop.view(s, 'B').chongtongAsk).toEqual({ player: 'A', months: null });
const t = act(s, 'A', { type: 'chongtong', declare: true });
expect(t.round!.outcome).toMatchObject({ type: 'chongtong', winner: 0, shown: [0, 1, 2, 3] });
expect(t.totals).toEqual([10, -10]);
const doubled = structuredClone(s);
doubled.round!.nagariMult = 2;
expect(act(doubled, 'A', { type: 'chongtong', declare: true }).totals).toEqual([20, -20]);
const u = act(s, 'A', { type: 'chongtong', declare: false });
expect(u.round!.step).toBe('play');
expect(reject(u, 'A', play(0, { shake: true }))).toBeNull();
// timeout → declare
expect(act(s, 'A', { type: 'timeout' }).round!.outcome!.type).toBe('chongtong');
});
test('redeal: 다시 돌림(선 유지); off: 그냥 진행', () => {
const s = gostop.setup({ players: players(2), options: parse({ bonusCards: 'none', chongtong: 'redeal' }), rng: scripted([order]), now: 0 });
expect(s.round!.redeals).toBeGreaterThanOrEqual(1);
expect(s.round!.step).toBe('play');
expect(s.dealer).toBe(0);
const off = gostop.setup({ players: players(2), options: parse({ bonusCards: 'none', chongtong: 'off' }), rng: scripted([order]), now: 0 });
expect(off.round!.step).toBe('play');
expect(sorted(off.round!.hands[0]!)).toEqual(sorted(hands[0]!));
});
});
describe('고 / 스톱 (§5.9)', () => {
// 삼광 3 + 홍단 3 + 띠 5장 1 = 7점, B has a 광 (no 광박).
const seven = [0, 8, 28, 1, 5, 9, 13, 21];
test('마지막 고 점수와 같으면 선택 없음, 커지면 선택; 스톱하면 (8 + 1) = 9점', () => {
let s = rigged(2, { hands: [[2, 25, 42], [11, 15, 43]], floor: [3, 24, 40], deck: [36, 37, 38, 39], captured: [seven, [44]] });
s.round!.goCount[0] = 1;
s.round!.lastGoScore[0] = 7;
s.round!.goOrder = [0];
s = act(s, 'A', play(2)); // captures a 피 only → still 7
expect(s.round!.step).toBe('play');
expect(s.round!.turn).toBe(1);
s = act(s, 'B', play(11));
s = act(s, 'A', play(25)); // 7월 초단 + 열끗 → 띠 6장 → 8점
expect(s.round!.step).toBe('goStop');
const v = gostop.view(s, 'A').goStop!;
expect(v).toMatchObject({ score: 8, goCount: 1, goNext: { goCount: 2, plus: 2, factor: 1 } });
expect(v.stopNow.total).toBe(9);
s = act(s, 'A', { type: 'goStop', decision: 'stop' });
expect(s.round!.outcome).toMatchObject({ type: 'stop', winner: 0, baseScore: 8, goCount: 1 });
expect(s.totals).toEqual([9, -9]);
});
test('고: 횟수 +1, 마지막 고 점수 기록, 다음 사람 차례', () => {
let s = rigged(2, { hands: [[25, 42], [11, 15]], floor: [24, 40], deck: [36, 37, 38, 39], captured: [seven.slice(0, -1).concat([33]), [44]] });
s = act(s, 'A', play(25));
expect(s.round!.step).toBe('goStop');
s = act(s, 'A', { type: 'goStop', decision: 'go' });
expect(s.round!.goCount[0]).toBe(1);
expect(s.round!.lastGoScore[0]).toBe(8);
expect(s.round!.turn).toBe(1);
expect(s.stats.goCalls[0]).toBe(1);
});
test('마지막 턴에 조건이 되면 자동 스톱; goStop 단계에서 손패 0장이면 고 거부', () => {
const s = act(rigged(2, { hands: [[25], [11]], floor: [24], deck: [36, 37], captured: [seven.slice(0, -1).concat([33]), [44]] }), 'A', play(25));
expect(s.round!.outcome).toMatchObject({ type: 'stop', winner: 0 });
const g = rigged(2, { hands: [[], [11]], deck: [36] });
g.round!.step = 'goStop';
expect(reject(g, 'A', { type: 'goStop', decision: 'go' })).toBe('마지막 턴에는 고를 할 수 없어요.');
expect(reject(g, 'A', { type: 'goStop', decision: 'stop' })).toBeNull();
});
test('시간 초과 → 스톱', () => {
let s = rigged(2, { hands: [[25, 42], [11, 15]], floor: [24, 40], deck: [36, 37, 38, 39], captured: [seven.slice(0, -1).concat([33]), [44]] });
s = act(s, 'A', play(25));
s = act(s, 'A', { type: 'timeout' });
expect(s.round!.outcome).toMatchObject({ type: 'stop', winner: 0 });
});
test('국진 ask: 판 끝 정산 직전 본인이 고름, 시간 초과면 auto', () => {
const cards = [4, 12, 16, 20, 32, ...pis(9)];
const mk = () => rigged(2, { hands: [[25, 42], [11, 15]], floor: [24, 40], deck: [36, 37, 38, 39], captured: [[0, 8, 28, ...cards.slice(0, -2)], [44]] }, { gukjinMode: 'ask', winScore: 3 });
let s = act(mk(), 'A', play(25));
expect(s.round!.step).toBe('goStop');
s = act(s, 'A', { type: 'goStop', decision: 'stop' });
expect(s.round!.step).toBe('gukjin');
expect(gostop.view(s, 'A').gukjinAsk).toMatchObject({ player: 'A' });
const y = act(s, 'A', { type: 'gukjin', as: 'yeol' });
expect(y.round!.outcome!.gukjinAs).toBe('yeol');
const auto = act(s, 'A', { type: 'timeout' });
expect(auto.round!.outcome!.gukjinAs).toBe(settle({ ...autoInput(s) }).gukjinAs);
});
});
function autoInput(s: GostopState): SettleInput {
const r = s.round!;
return { rules: s.rules, mode: s.mode, winner: r.stopBy!, participants: r.participants, captured: r.captured, goCount: r.goCount, goOrder: r.goOrder, shakes: r.shakes, bombs: r.bombs, nagariMult: r.nagariMult, gukjinChoice: r.gukjinChoice };
}
describe('나가리 (§5.10)', () => {
test('아무도 스톱 못 함 → 나가리, 선 유지, 다음 판 x2, 연속 x4, 상한 x8', () => {
let s = rigged(2, { hands: [[2], [11]], floor: [20], deck: [24, 36] });
s.dealer = 1;
s.round!.participants = [1, 0];
s.round!.turn = 1;
s = act(s, 'B', play(11));
s = act(s, 'A', play(2));
expect(s.round!.outcome!.type).toBe('nagari');
expect(s.phase).toBe('roundResult');
expect(s.dealer).toBe(1);
expect(s.nagariStreak).toBe(1);
expect(s.totals).toEqual([0, 0]);
s = act(s, 'A', { type: 'ready' });
expect(s.phase).toBe('roundResult');
s = act(s, 'B', { type: 'ready' });
expect(s.roundNo).toBe(2);
expect(s.round!.nagariMult).toBe(2);
expect(s.round!.participants[0]).toBe(1);
const t = structuredClone(s);
t.nagariStreak = 2;
t.phase = 'roundResult';
t.ready = [];
expect(act(act(t, 'A', { type: 'ready' }), 'B', { type: 'ready' }).round!.nagariMult).toBe(4);
t.nagariStreak = 5;
expect(act(act(t, 'A', { type: 'ready' }), 'B', { type: 'ready' }).round!.nagariMult).toBe(8);
expect(act(act({ ...structuredClone(t), rules: { ...t.rules, nagariMaxMultiplier: 4 } }, 'A', { type: 'ready' }), 'B', { type: 'ready' }).round!.nagariMult).toBe(4);
expect(act(act({ ...structuredClone(t), rules: { ...t.rules, nagariMultiplier: 'none' } }, 'A', { type: 'ready' }), 'B', { type: 'ready' }).round!.nagariMult).toBe(1);
});
test('고를 부른 사람이 끝까지 점수를 더 못 내면 나가리', () => {
let s = rigged(2, { hands: [[2], [11]], floor: [20], deck: [24, 36], captured: [[0, 8, 28, 1, 5, 9, 13, 21], [44]] });
s.round!.goCount[0] = 1;
s.round!.lastGoScore[0] = 7;
s = act(act(s, 'A', play(2)), 'B', play(11));
expect(s.round!.outcome!.type).toBe('nagari');
});
test('승리하면 승자가 다음 판 선, 나가리 연속 수 초기화', () => {
const s = rigged(2, { hands: [[11], [25]], floor: [24], deck: [36, 37], captured: [[44], [0, 8, 28, 1, 5, 9, 13, 33]], turn: 1 });
s.nagariStreak = 3;
s.round!.nagariMult = 8;
const t = act(s, 'B', play(25));
expect(t.round!.outcome).toMatchObject({ type: 'stop', winner: 1, perLoser: 64 });
expect(t.dealer).toBe(1);
expect(t.nagariStreak).toBe(0);
const u = act(act(t, 'A', { type: 'ready' }), 'B', { type: 'ready' });
expect(u.round!.participants[0]).toBe(1);
expect(u.round!.nagariMult).toBe(1);
});
});
describe('첫뻑·연뻑·삼뻑 (§5.11)', () => {
test('첫뻑 3점 즉시, 연뻑 6점 더, 삼뻑 즉시 승리(10점), 나가리여도 첫뻑 점수 유지', () => {
let s = rigged(2, { hands: [[2, 6, 10, 40], [11, 15, 19]], floor: [0, 4, 8, 20], deck: [3, 22, 7, 23, 11, 26, 30], captured: [[], []] });
s = act(s, 'A', play(2));
expect(s.round!.lastTurn!.tags).toEqual(['ppeok', 'firstPpeok']);
expect(s.totals).toEqual([3, -3]);
s = act(s, 'B', play(15));
s = act(s, 'A', play(6));
expect(s.round!.lastTurn!.tags).toEqual(['ppeok', 'yeonPpeok']);
expect(s.totals).toEqual([9, -9]);
s = act(s, 'B', play(19));
// third 뻑 → 삼뻑: game over for this round, 10 × 나가리 배수.
s.round!.deck = [11, 26, 30];
s.round!.hands[1] = [27];
const t = act(s, 'A', play(10));
expect(t.round!.outcome).toMatchObject({ type: 'samppeok', winner: 0, perLoser: 10 });
expect(t.totals).toEqual([19, -19]);
// 나가리: first-뻑 points stay.
const n = act(rigged(2, { hands: [[2], []], floor: [0, 20], deck: [3] }), 'A', play(2));
expect(n.round!.outcome!.type).toBe('nagari');
expect(n.totals).toEqual([3, -3]);
// option off
expect(act(rigged(2, { hands: [[2, 40], [11]], floor: [0, 20], deck: [3, 22] }, { ppeokBonus: false }), 'A', play(2)).totals).toEqual([0, 0]);
});
});
describe('광팔기 (§5.8)', () => {
const H = [[2, 3, 6, 7, 10, 11, 14], [0, 8, 15, 18, 19, 22, 23], [26, 27, 30, 31, 34, 35, 38], [39, 42, 43, 44, 47, 1, 5]];
const F = [4, 9, 13, 16, 17, 20];
const mk = (n: number, opts: Partial<GostopOptions> = {}, extra: number[][] = []) =>
gostop.setup({ players: players(n), options: parse({ chongtong: 'off', ...opts }), rng: scripted([[...H.flat(), ...extra.flat(), ...F]]), now: 0 });
test('4인: 선 다음 사람부터, 선은 sell 불가, 광 2장 → 선 제외 참가자 2명이 각 2점, 더미 21장', () => {
const s = mk(4);
expect(s.round!.step).toBe('sell');
expect(gostop.activePlayers(s)).toEqual(['B']);
expect(reject(s, 'A', { type: 'sell', sell: true })).toBe('선은 광을 팔 수 없어요.');
expect(reject(s, 'C', { type: 'sell', sell: true })).toBe('지금은 내가 정할 차례가 아니에요.');
expect(gostop.view(s, 'B').sell!.mySellable!.map((c) => c.id)).toEqual([0, 8]);
expect(gostop.view(s, 'C').sell!.mySellable).toBeNull();
const t = act(s, 'B', { type: 'sell', sell: true });
const r = t.round!;
expect(r.participants).toEqual([0, 2, 3]);
expect(r.sellers).toEqual([{ idx: 1, shown: [0, 8], paid: 4 }]);
expect(t.totals).toEqual([0, 4, -2, -2]);
expect(r.hands[1]).toEqual([]);
expect(r.deck).toHaveLength(21);
expect(r.step).toBe('play');
expect(r.sellDecisions.map((d) => [d.idx, d.sell, d.auto])).toEqual([[1, true, false], [2, false, true], [3, false, true]]);
expect(cardsInRound(t)).toBe(48);
// Seller's unshown cards and the deck stay secret; the shown 광 are public.
const v = JSON.stringify(gostop.view(t, 'A'));
for (const c of [15, 18, 19]) expect(v).not.toContain(`"r1c${c}"`);
expect(v).toContain('"r1sc0"');
});
test('치기 3명 확정 시 나머지 자동 죽기; allPlayers 지불; 시간 초과 → 죽기', () => {
let s = act(act(mk(4), 'B', { type: 'sell', sell: false }), 'C', { type: 'sell', sell: false });
expect(s.round!.participants).toEqual([0, 1, 2]);
expect(s.round!.sellers).toEqual([{ idx: 3, shown: [44], paid: 2 }]);
s = act(mk(4, { gwangPayer: 'allPlayers', gwangPrice: 2 }), 'B', { type: 'timeout' });
expect(s.round!.sellers).toEqual([{ idx: 1, shown: [0, 8], paid: 12 }]);
expect(s.totals).toEqual([-4, 12, -4, -4]);
s = act(mk(4, { gwangSellCards: 'gwangPlus' }), 'B', { type: 'sell', sell: false });
expect(s.round!.step).toBe('sell');
s = act(s, 'C', { type: 'sell', sell: true });
expect(s.round!.sellers[0]!.shown).toEqual([]);
});
test('5인: 2명 죽으면 나머지는 자동 치기, 더미 21장', () => {
const s = act(act(mk(5, {}, [[12, 24, 25, 28, 29, 32, 33]]), 'B', { type: 'sell', sell: true }), 'C', { type: 'sell', sell: true });
expect(s.round!.participants).toEqual([0, 3, 4]);
expect(s.round!.deck).toHaveLength(21);
expect(s.totals.reduce((a, b) => a + b, 0)).toBe(0);
expect(gostop.playersFor!(parse({ gwangPalgi: false }))).toEqual({ min: 2, max: 3 });
expect(gostop.playerCountError!(parse({ gwangPalgi: false }), 4)).toBeTruthy();
});
});
// ---------------------------------------------------------------- timeouts, leaving, info
describe('시간 초과·연결 끊김 (§8.5)', () => {
test('play 자동 행동: 빈패 → 보너스패 → 가장 값진 바닥 패 → 가장 싼 패 (결정적)', () => {
const s = rigged(2, { hands: [[30, 18, 0], [11]], floor: [28, 16], deck: [36, 37] });
expect(autoPlay(s, 0)).toEqual({ type: 'play', card: 30 });
expect(autoPlay(structuredClone(s), 0)).toEqual(autoPlay(s, 0));
s.round!.hands[0] = [0, 3, 44];
expect(autoPlay(s, 0)).toEqual({ type: 'play', card: 3 });
s.round!.floor = [{ month: 1, cards: [1, 2], ppeokBy: null, natural: false }];
expect(autoPlay(s, 0)).toEqual({ type: 'play', card: 0, target: 1 });
s.round!.hands[0] = [0, 3, 49];
expect(autoPlay(s, 0)).toEqual({ type: 'play', card: 49 });
s.round!.bombDummies[0] = 1;
expect(autoPlay(s, 0)).toEqual({ type: 'play', card: 'dummy' });
});
test('2회 연속 시간 초과 → autoPilot, 마감 2초; 직접 행동하면 해제', () => {
let s = rigged(2, { hands: [[2, 6, 10], [11, 15, 19]], floor: [20], deck: [24, 36, 37, 38, 39, 42, 43] });
s = act(s, 'A', { type: 'timeout' }, 10_000);
expect(s.autoPilot[0]).toBe(false);
expect(s.deadline).toBe(10_000 + 15_000);
s = act(s, 'B', play(11), 11_000);
s = act(s, 'A', { type: 'timeout' }, 12_000);
expect(s.autoPilot[0]).toBe(true);
s = act(s, 'B', play(15), 13_000);
expect(s.deadline).toBe(13_000 + 2_000);
s = act(s, 'A', play(gostop.view(s, 'A').myHand![0]!.card.id), 14_000);
expect(s.autoPilot[0]).toBe(false);
});
test('나가기: autoPilot, 끊긴 사람 차례 마감 2초, 결과 화면 준비에서 빠짐', () => {
let s = rigged(2, { hands: [[2, 6], [11, 15]], floor: [20], deck: [24, 36, 37, 38] });
s = act(s, 'A', { type: 'leave' }, 5000);
expect(s.left[0]).toBe(true);
expect(s.deadline).toBe(7000);
expect(gostop.onLeave!(s, 'A')).toBeNull();
expect(reject(s, 'A', play(2))).toBe('방을 나간 플레이어예요.');
s = act(s, 'A', { type: 'timeout' });
s = act(s, 'B', play(11));
s = act(s, 'A', { type: 'timeout' });
s = act(s, 'B', play(15));
expect(s.phase).toBe('roundResult');
expect(gostop.activePlayers(s)).toEqual(['B']);
s = act(s, 'B', { type: 'ready' });
expect(s.roundNo).toBe(2);
});
test('onTimeout는 시스템 전용 행동이고 actionSchema에 없음', () => {
expect(gostop.actionSchema.safeParse({ type: 'timeout' }).success).toBe(false);
expect(gostop.actionSchema.safeParse({ type: 'leave' }).success).toBe(false);
expect(gostop.actionSchema.safeParse({ type: 'play', card: 'dummy' }).success).toBe(true);
expect(gostop.actionSchema.safeParse({ type: 'play', card: 3, target: 2, shake: true }).success).toBe(true);
});
});
describe('options', () => {
test('defaults by mode and Korean metadata', () => {
expect(gostop.defaultOptions).toMatchObject({ rounds: 10, bonusCards: 'auto', winScore: 'auto', turnSeconds: 15, decisionSeconds: 10 });
const shape = (gostop.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(() => gostop.setup({ players: players(4), options: parse({ gwangPalgi: false }), rng: rng(), now: 0 })).toThrow();
expect(gostop.playersFor!(gostop.defaultOptions)).toEqual({ min: 2, max: 5 });
});
test('rounds 판을 마치면 끝, 방 점수 순위(동점 공동), 요약은 점수만', () => {
let s = rigged(2, { hands: [[25], [11]], floor: [24], deck: [36, 37], captured: [[0, 8, 28, 1, 5, 9, 13, 33], [44]] }, { rounds: 1 });
s = act(s, 'A', play(25));
expect(s.phase).toBe('finished');
const res = gostop.result(s)!;
expect(res.ranking).toEqual([['A'], ['B']]);
expect(res.scores).toEqual({ A: 8, B: -8 });
expect(res.summary).toBe('1판 끝. 1위 +8점');
expect(gostop.activePlayers(s)).toEqual([]);
expect(gostop.deadline!(s)).toBeNull();
const tie = act(rigged(2, { hands: [[2], [11]], floor: [20], deck: [24, 36] }, { rounds: 1 }), 'A', play(2));
const t2 = act(tie, 'B', play(11));
expect(gostop.result(t2)!.ranking).toEqual([['A', 'B']]);
});
});
// ---------------------------------------------------------------- properties
/** Per-step invariants: card conservation, no duplicates, zero-sum points, hand/deck balance. */
function invariants(s: GostopState): void {
const r = s.round;
const sum = s.totals.reduce((a, b) => a + b, 0);
if (sum !== 0) throw new Error(`points not zero-sum: ${sum}`);
if (!r) return;
const all = [...r.hands.flat(), ...r.deck, ...r.floor.flatMap((g) => g.cards), ...r.captured.flat(), ...(r.pending ? [r.pending.flipped] : [])];
if (all.length !== 48 + s.rules.nb) throw new Error(`card count ${all.length}`);
if (new Set(all).size !== all.length) throw new Error('duplicate card');
for (const g of r.floor) if (g.cards.length < 1 || g.cards.length > 3 || g.cards.some((c) => Math.floor(c / 4) + 1 !== g.month || isBonus(c))) throw new Error('bad floor group');
if (r.step === 'play' || r.step === 'goStop' || r.step === 'chooseFlip') {
// Deck = Σ(regular hand cards + 빈패) + 2 × hand bonus + deck bonus (§5.5, §5.6). A pending turn already flipped.
let need = 0;
for (const p of r.participants) need += r.hands[p]!.filter((c) => !isBonus(c)).length + r.bombDummies[p]! + 2 * r.hands[p]!.filter(isBonus).length;
need += r.deck.filter(isBonus).length;
if (r.deck.length !== need) throw new Error(`deck ${r.deck.length} vs needed ${need} (step ${r.step})`);
}
}
const CONFIGS: [string, Partial<GostopOptions>][] = [
['기본', {}],
['보너스 3+1·국진 ask·총통 다시', { bonusCards: '3+1', gukjinMode: 'ask', chongtong: 'redeal' }],
['마지막 쓸 인정·자뻑 1·멍박', { lastTurnSweepBonus: true, jaPpeokSteal: 1, meongBak: true, winScore: 3 }],
['광팔기 전부·모두 지불·총통 off', { gwangSellCards: 'gwangPlus', gwangPayer: 'allPlayers', chongtong: 'off', gwangPrice: 3 }],
['흔들기·폭탄 끔·고박 끔', { shake: false, bomb: false, goBak: false, ppeokBonus: false, nagariMultiplier: 'none' }],
];
describe('random playouts', () => {
test('1,000 seeds: invariants, every legal action validates, onTimeout legal in every state', () => {
let rounds = 0;
let stops = 0;
for (let seed = 1; seed <= 1000; seed++) {
const n = 2 + (seed % 4);
const ps = players(n);
const [, opts] = CONFIGS[seed % CONFIGS.length]!;
const options = parse({ rounds: seed % 7 === 0 ? 3 : 1, ...opts });
const r = SeededRng.fromSeed(seedN(seed));
const chooser = SeededRng.fromSeed(seedN(seed + 9999));
let s = gostop.setup({ players: ps, options, rng: r, now: 0 });
let steps = 0;
for (; steps < 2000 && !gostop.result(s); steps++) {
invariants(s);
const actives = gostop.activePlayers(s);
if (actives.length === 0) throw new Error(`seed ${seed}: nobody to act`);
for (const p of actives) {
const v = gostop.validate(s, p, gostop.onTimeout(s, p));
if (!v.ok) throw new Error(`seed ${seed}: timeout rejected ${v.reason}`);
}
const actor = actives[chooser.int(actives.length)]!;
const legal = gostop.legalActions!(s, actor);
for (const a of legal) {
const v = gostop.validate(s, actor, a);
if (!v.ok) throw new Error(`seed ${seed}: legal action rejected ${JSON.stringify(a)}: ${v.reason}`);
}
const a = chooser.int(6) === 0 ? gostop.onTimeout(s, actor) : legal[chooser.int(legal.length)]!;
const before = s.roundNo;
s = gostop.apply(s, actor, a, { rng: r, now: steps * 1000 }).state;
if (s.roundNo !== before || s.phase !== 'playing') {
if (s.round?.outcome || s.phase === 'finished') {
rounds++;
if (s.round?.outcome?.type === 'stop') stops++;
}
}
}
if (!gostop.result(s)) throw new Error(`seed ${seed}: did not finish`);
invariants(s);
expect(gostop.result(s)!.ranking.flat().sort()).toEqual([...ps].sort());
}
expect(rounds).toBeGreaterThan(900);
expect(stops).toBeGreaterThan(100);
}, 60_000);
test('engine randomPlayout (leaks, JSON round-trip, determinism) in every mode', () => {
for (const n of [2, 3, 4, 5]) {
for (let i = 0; i < CONFIGS.length; i++) {
const options = parse({ rounds: 1, ...CONFIGS[i]![1] });
const ps = players(n);
const a = randomPlayout(gostop, { players: ps, options, seed: seedN(100 * n + i), maxSteps: 3000, maxApplyMs: 50, invariant: (st) => invariants(st as GostopState) });
expect(a.finished).toBe(true);
if (i === 0) {
const b = randomPlayout(gostop, { players: ps, options, seed: seedN(100 * n + i), maxSteps: 3000, maxApplyMs: 50 });
expect(JSON.stringify(b.finalState)).toBe(JSON.stringify(a.finalState));
// Same seed + same action log → same result.
const rr = SeededRng.fromSeed(seedN(100 * n + i));
let s = gostop.setup({ players: ps, options, rng: rr, now: 1_700_000_000_000 });
let now = 1_700_000_000_000;
for (const t of a.trace) s = gostop.apply(s, t.actor, t.action as GostopAction, { rng: rr, now: (now += 1000) }).state;
expect(JSON.stringify(s)).toBe(JSON.stringify(a.finalState));
}
}
}
}, 60_000);
test('leak property: views never contain hidden hands / deck, events only reveal public cards', () => {
for (let seed = 1; seed <= 300; seed++) {
const n = 2 + (seed % 4);
const ps = players(n);
const options = parse({ ...CONFIGS[seed % CONFIGS.length]![1], rounds: 1 });
const r = SeededRng.fromSeed(seedN(seed + 5));
const chooser = SeededRng.fromSeed(seedN(seed + 77));
let s = gostop.setup({ players: ps, options, rng: r, now: 0 });
for (let step = 0; step < 120 && !gostop.result(s); step++) {
const views = new Map([...ps, null].map((v) => [v, JSON.stringify(gostop.view(s, v))]));
for (const owner of ps)
for (const secret of gostop.secretsOf!(s, owner))
for (const [viewer, json] of views)
if (viewer !== owner && json.includes(JSON.stringify(secret))) throw new Error(`seed ${seed}: leak ${secret} of ${owner} to ${viewer}`);
const actives = gostop.activePlayers(s);
const actor = actives[chooser.int(actives.length)]!;
const legal = gostop.legalActions!(s, actor);
const a = chooser.int(5) === 0 ? gostop.onTimeout(s, actor) : legal[chooser.int(legal.length)]!;
const out = gostop.apply(s, actor, a, { rng: r, now: step * 1000 });
const hidden = new Set<number>();
const nr = out.state.round!;
for (let p = 0; p < n; p++) for (const c of nr.hands[p]!) if (!nr.revealed[p]!.includes(c)) hidden.add(c);
for (const c of nr.deck) hidden.add(c);
for (const e of out.events) {
if (e.type === 'gwangSold') continue; // shown 광 are public even though they go back into the deck
const ids = [e.card, ...((e.cards as number[] | undefined) ?? [])].filter((x): x is number => typeof x === 'number');
for (const id of ids) if (hidden.has(id)) throw new Error(`seed ${seed}: event ${e.type} leaks ${id}`);
}
s = out.state;
}
const deck = s.round!.deck;
if (deck.length > 3) for (const v of [...ps, null]) expect(JSON.stringify(gostop.view(s, v))).not.toContain(JSON.stringify(deck).slice(1, -1));
}
}, 60_000);
});

View File

@@ -0,0 +1,612 @@
/** 고스톱 / 맞고 / 광팔기 (docs/games/gostop.md). Points are room-only play points (§12). */
import { z } from 'zod';
import { fail, ok, type GameDefinition, type GameEvent, type GameResult, type PlayerId } from '@bg/engine';
import { GUKJIN, bestCard, cardInfo, cardView, isBonus, monthOf, type CardId, type CardView } from './cards';
import {
autoGukjin,
autoPlay,
chooseFlip,
currentScore,
decideChongtong,
decideGoStop,
decideGukjin,
decideSell,
floorCount,
floorGroup,
handMonthCount,
hasChongtong,
needsTarget,
playCard,
resolveRules,
sellDecider,
sellableCards,
settleInput,
startRound,
type Ctx,
} from './round';
import { bestScore, goFactor, scoreOf, settle } from './scoring';
import {
gostopActionSchema,
gostopOptionsSchema,
type GostopAction,
type GostopOptions,
type GostopState,
type GostopView,
type HandCardView,
type OutcomeView,
type RoundState,
type ScoreView,
type SeatView,
} from './types';
export * from './cards';
export * from './types';
export { bestScore, goFactor, scoreOf, settle, type ScoreCalc, type SettleInput, type Settlement } from './scoring';
export { autoPlay, resolveRules } from './round';
const RESULT_MS = 10_000;
const AUTO_MS = 2_000;
export function playerRange(o: Pick<GostopOptions, 'gwangPalgi'>): { min: number; max: number } {
return { min: 2, max: o.gwangPalgi ? 5 : 3 };
}
const keyOf = (s: GostopState) => `r${s.roundNo}`;
const cv = (s: GostopState, id: CardId): CardView => cardView(id, s.rules.nb, keyOf(s));
const idx = (s: GostopState, p: PlayerId): number => s.players.indexOf(p);
/** Total cards in a round (hands + deck + floor + captured + pending flip) — 48 + bonus. */
export function cardsInRound(s: GostopState): number {
const r = s.round;
if (!r) return 0;
return (
r.hands.reduce((a, h) => a + h.length, 0) +
r.deck.length +
r.floor.reduce((a, g) => a + g.cards.length, 0) +
r.captured.reduce((a, c) => a + c.length, 0) +
(r.pending ? 1 : 0)
);
}
/** Players who must act now (§8.2 activePlayers). */
function waiting(s: GostopState): number[] {
if (s.phase === 'finished') return [];
if (s.phase === 'roundResult') return s.players.map((_, i) => i).filter((i) => !s.left[i] && !s.ready.includes(i));
const r = s.round;
if (!r) return [];
if (r.step === 'sell') {
const d = sellDecider(s);
return d === null ? [] : [d];
}
if (r.step === 'done') return [];
return [r.turn];
}
function deadlineMs(s: GostopState): number | null {
if (s.phase === 'finished') return null;
if (s.phase === 'roundResult') return RESULT_MS;
const w = waiting(s);
if (w.length === 0) return null;
if (w.every((i) => s.autoPilot[i])) return AUTO_MS;
const step = s.round!.step;
return (step === 'play' ? s.rules.turnSeconds : s.rules.decisionSeconds) * 1000;
}
function refreshDeadline(s: GostopState, now: number): void {
const r = s.round;
const w = waiting(s);
const key = s.phase === 'roundResult' ? `rr|${s.roundNo}` : `${s.phase}|${s.roundNo}|${r?.step}|${w.join(',')}|${s.actionSeq}|${w.every((i) => s.autoPilot[i])}`;
const ms = deadlineMs(s);
if (key !== s.waitKey) {
s.waitKey = key;
s.deadline = ms === null ? null : now + ms;
}
if (ms === null) s.deadline = null;
}
/** Starts the next round once everybody still at the table is ready. */
function progress(s: GostopState, ctx: Ctx): void {
if (s.phase === 'roundResult' && waiting(s).length === 0) startRound(s, ctx);
}
// ---------------------------------------------------------------- legality
function validatePlay(s: GostopState, p: number, a: Extract<GostopAction, { type: 'play' }>) {
const r = s.round!;
if (a.card === 'dummy') {
if (r.bombDummies[p]! <= 0) return fail('폭탄 빈패가 없어요.');
if (a.target !== undefined || a.shake || a.bomb) return fail('빈패는 그냥 내기만 할 수 있어요.');
return ok;
}
if (!r.hands[p]!.includes(a.card)) return fail('손에 없는 패예요.');
if (isBonus(a.card)) {
if (a.target !== undefined || a.shake || a.bomb) return fail('보너스패는 그냥 내기만 할 수 있어요.');
return ok;
}
const m = monthOf(a.card);
const k = floorCount(r, m);
const inHand = handMonthCount(r, p, m);
if (a.bomb) {
if (!s.rules.bomb) return fail('이 방은 폭탄을 쓰지 않아요.');
if (a.shake) return fail('폭탄과 흔들기는 함께 할 수 없어요.');
if (inHand !== 3 || k !== 1) return fail('폭탄은 같은 월 3장과 바닥 1장이 있어야 해요.');
if (a.target !== undefined) return fail('폭탄은 가져올 패를 고르지 않아요.');
return ok;
}
if (a.shake) {
if (!s.rules.shake) return fail('이 방은 흔들기를 쓰지 않아요.');
if (inHand < 3 || k !== 0) return fail('흔들기는 같은 월 3장을 가지고 있고 바닥에 그 월이 없을 때만 할 수 있어요.');
if (r.shakenMonths[p]!.includes(m)) return fail('이 월은 이미 흔들었어요.');
}
if (k === 2) {
const g = floorGroup(r, m)!;
if (a.target !== undefined && !g.cards.includes(a.target)) return fail('바닥에 있는 같은 월 패 중에서 골라 주세요.');
if (a.target === undefined && needsTarget(s, m)) return fail('가져올 패를 골라 주세요.');
} else if (a.target !== undefined) return fail('고를 패가 없어요.');
return ok;
}
function legalFor(s: GostopState, p: number): GostopAction[] {
if (s.phase === 'finished' || s.left[p]) return [];
if (s.phase === 'roundResult') return s.ready.includes(p) ? [] : [{ type: 'ready' }];
const r = s.round!;
if (!waiting(s).includes(p)) return [];
switch (r.step) {
case 'sell':
return [
{ type: 'sell', sell: true },
{ type: 'sell', sell: false },
];
case 'chongtong':
return [
{ type: 'chongtong', declare: true },
{ type: 'chongtong', declare: false },
];
case 'chooseFlip':
return r.pending!.options.map((target) => ({ type: 'chooseFlip', target }));
case 'goStop':
return [
{ type: 'goStop', decision: 'go' },
{ type: 'goStop', decision: 'stop' },
];
case 'gukjin':
return [
{ type: 'gukjin', as: 'yeol' },
{ type: 'gukjin', as: 'pi' },
];
case 'play': {
const out: GostopAction[] = [];
if (r.bombDummies[p]! > 0) out.push({ type: 'play', card: 'dummy' });
const bombed = new Set<number>();
for (const c of [...r.hands[p]!].sort((a, b) => a - b)) {
if (isBonus(c)) {
out.push({ type: 'play', card: c });
continue;
}
const m = monthOf(c);
const g = floorGroup(r, m);
if (g && g.cards.length === 2 && needsTarget(s, m)) for (const target of g.cards) out.push({ type: 'play', card: c, target });
else out.push({ type: 'play', card: c });
const n = handMonthCount(r, p, m);
if (s.rules.shake && n >= 3 && !g && !r.shakenMonths[p]!.includes(m)) out.push({ type: 'play', card: c, shake: true });
if (s.rules.bomb && n === 3 && g?.cards.length === 1 && !bombed.has(m)) {
bombed.add(m);
out.push({ type: 'play', card: c, bomb: true });
}
}
return out;
}
default:
return [];
}
}
// ---------------------------------------------------------------- view helpers
function scoreView(s: GostopState, cards: CardId[], as?: 'yeol' | 'pi'): ScoreView {
const { gwangPoints: _g, ...rest } = as ? scoreOf(cards, s.rules, as) : bestScore(cards, s.rules).calc;
return rest;
}
function grouped(s: GostopState, cards: CardId[], gukjinAsPi: boolean): SeatView['captured'] {
const out: SeatView['captured'] = { gwang: [], yeol: [], tti: [], pi: [] };
const sorted = [...cards].sort((a, b) => cardInfo(a, s.rules.nb).piValue - cardInfo(b, s.rules.nb).piValue || a - b);
for (const c of sorted) {
const k = c === GUKJIN && gukjinAsPi ? 'pi' : cardInfo(c, s.rules.nb).kind;
out[k].push(cv(s, c));
}
for (const k of ['gwang', 'yeol', 'tti'] as const) out[k].sort((a, b) => a.id - b.id);
return out;
}
function handHint(s: GostopState, p: number, c: CardId): string | null {
const r = s.round!;
const g = floorGroup(r, monthOf(c));
if (!g) return null;
const take = g.cards.length === 2 ? [bestCard(g.cards, s.rules.nb)] : g.cards;
const before = bestScore(r.captured[p]!, s.rules).calc;
const after = bestScore([...r.captured[p]!, c, ...take], s.rules).calc;
const base = (n: string) => n.replace(/ .*$/, '');
const had = new Set(before.items.map((x) => base(x.name)));
const fresh = after.items.find((x) => !had.has(base(x.name)));
if (fresh) return `${base(fresh.name)} 완성`;
if (after.total > before.total) return `+${after.total - before.total}점`;
return null;
}
function goStopPreview(s: GostopState, p: number): GostopView['goStop'] {
const r = s.round!;
const st = settle(settleInput(s, p));
const score = currentScore(s, p);
const notes = st.multipliers.map((m) => `${m.name} x${m.factor}`);
const next = r.goCount[p]! + 1;
return {
player: s.players[p]!,
score,
goCount: r.goCount[p]!,
stopNow: {
total: st.payments.reduce((a, x) => a + x.amount, 0),
perLoser: st.payments.map((x) => ({ player: s.players[x.from]!, amount: x.amount, baks: x.baks })),
notes,
},
goNext: { goCount: next, plus: next, factor: goFactor(next) },
};
}
function outcomeView(s: GostopState, r: RoundState): OutcomeView | null {
const o = r.outcome;
if (!o) return null;
const log = s.log.find((l) => l.no === r.no);
return {
...o,
winner: o.winner === null ? null : s.players[o.winner]!,
payments: o.payments.map((x) => ({ from: s.players[x.from]!, amount: x.amount, baks: x.baks, coveredBy: x.coveredBy === undefined ? null : s.players[x.coveredBy]! })),
shown: o.shown.map((c) => cv(s, c)),
delta: Object.fromEntries(s.players.map((id, i) => [id, log?.delta[i] ?? 0])),
};
}
// ---------------------------------------------------------------- definition
export const gostop: GameDefinition<GostopState, GostopAction, GostopView, GostopOptions> = {
id: 'gostop',
nameKo: '고스톱',
minPlayers: 2,
maxPlayers: 5,
stateVersion: 1,
defaultOptions: gostopOptionsSchema.parse({}),
optionsSchema: gostopOptionsSchema as unknown as z.ZodType<GostopOptions>,
actionSchema: gostopActionSchema as unknown as z.ZodType<GostopAction>,
playersFor: playerRange,
playerCountError(o, count) {
return count > 3 && !o.gwangPalgi ? '광팔기를 끄면 3명까지만 할 수 있어요.' : null;
},
setup({ players, options, rng, now }) {
const range = playerRange(options);
if (players.length < range.min || players.length > range.max) throw new Error(`gostop: ${players.length} players not allowed`);
const n = players.length;
const zeros = () => Array.from({ length: n }, () => 0);
const s: GostopState = {
options,
rules: resolveRules(options, n),
mode: n === 2 ? 'matgo' : 'gostop',
players: [...players],
totals: zeros(),
roundNo: 0,
dealer: rng.int(n),
nagariStreak: 0,
round: null,
phase: 'playing',
ready: [],
left: players.map(() => false),
autoPilot: players.map(() => false),
timeoutStreak: zeros(),
deadline: null,
waitKey: '',
actionSeq: 0,
log: [],
stats: { wins: zeros(), goCalls: zeros(), piBakGiven: zeros(), gwangSold: zeros(), nagari: 0 },
final: null,
};
startRound(s, { rng, events: [] });
refreshDeadline(s, now);
return s;
},
validate(s, actor, a) {
const p = idx(s, actor);
if (p < 0) return fail('이 게임의 플레이어가 아니에요.');
if (s.phase === 'finished') return fail('게임이 이미 끝났어요.');
if (a.type === 'leave') return s.left[p] ? fail('이미 나간 플레이어예요.') : ok;
if (a.type === 'timeout') return waiting(s).includes(p) ? ok : fail('시간 초과 대상이 아니에요.');
if (s.left[p]) return fail('방을 나간 플레이어예요.');
if (a.type === 'ready') {
if (s.phase !== 'roundResult') return fail('아직 판이 끝나지 않았어요.');
if (s.ready.includes(p)) return fail('이미 준비했어요.');
return ok;
}
const r = s.round;
if (s.phase !== 'playing' || !r) return fail('지금은 할 수 없어요.');
const mine = waiting(s).includes(p);
switch (a.type) {
case 'sell':
if (r.step !== 'sell') return fail('지금은 광을 팔 때가 아니에요.');
if (p === s.dealer) return fail('선은 광을 팔 수 없어요.');
if (!mine) return fail('지금은 내가 정할 차례가 아니에요.');
return ok;
case 'chongtong':
if (r.step !== 'chongtong' || !mine) return fail('총통을 선언할 수 없어요.');
return ok;
case 'play':
if (r.step !== 'play') return fail(r.step === 'chooseFlip' ? '먼저 뒤집은 패로 가져올 패를 골라 주세요.' : '지금은 패를 낼 때가 아니에요.');
if (!mine) return fail('지금은 내 차례가 아니에요.');
return validatePlay(s, p, a);
case 'chooseFlip':
if (r.step !== 'chooseFlip' || !mine) return fail('지금은 고를 패가 없어요.');
if (!r.pending!.options.includes(a.target)) return fail('뒤집은 패와 같은 월인 바닥 패 중에서 골라 주세요.');
return ok;
case 'goStop':
if (r.step !== 'goStop' || !mine) return fail('지금은 고·스톱을 고를 때가 아니에요.');
if (a.decision === 'go' && r.hands[p]!.length === 0) return fail('마지막 턴에는 고를 할 수 없어요.');
return ok;
case 'gukjin':
if (r.step !== 'gukjin' || !mine) return fail('지금은 국진을 정할 때가 아니에요.');
return ok;
}
return fail('알 수 없는 행동이에요.');
},
apply(prev, actor, a, { rng, now }) {
const s = structuredClone(prev);
const events: GameEvent[] = [];
const ctx: Ctx = { rng, events };
const p = idx(s, actor);
const r = s.round;
if (a.type !== 'leave') s.actionSeq += 1;
if (a.type !== 'timeout' && a.type !== 'leave') {
s.timeoutStreak[p] = 0;
if (s.autoPilot[p] && !s.left[p]) {
s.autoPilot[p] = false;
events.push({ type: 'autoPilot', player: actor, on: false });
}
}
let act: GostopAction = a;
if (a.type === 'timeout') {
events.push({ type: 'timeout', player: actor });
if (s.phase === 'roundResult') act = { type: 'ready' };
else {
s.timeoutStreak[p]! += 1;
if (s.timeoutStreak[p]! >= 2 && !s.autoPilot[p]) {
s.autoPilot[p] = true;
events.push({ type: 'autoPilot', player: actor, on: true });
}
switch (r!.step) {
case 'sell':
act = { type: 'sell', sell: true };
break;
case 'chongtong':
act = { type: 'chongtong', declare: true };
break;
case 'play':
act = autoPlay(s, p);
break;
case 'chooseFlip':
act = { type: 'chooseFlip', target: bestCard(r!.pending!.options, s.rules.nb) };
break;
case 'goStop':
act = { type: 'goStop', decision: 'stop' };
break;
case 'gukjin':
act = { type: 'gukjin', as: autoGukjin(s) };
break;
default:
throw new Error(`gostop: timeout in step ${r!.step}`);
}
}
}
switch (act.type) {
case 'leave':
s.left[p] = true;
s.autoPilot[p] = true;
events.push({ type: 'left', player: actor });
break;
case 'ready':
s.ready.push(p);
break;
case 'sell':
decideSell(s, p, act.sell, ctx);
break;
case 'chongtong':
decideChongtong(s, p, act.declare, ctx);
break;
case 'play':
playCard(s, p, act, ctx);
break;
case 'chooseFlip':
chooseFlip(s, act.target, ctx);
break;
case 'goStop':
decideGoStop(s, p, act.decision, ctx);
break;
case 'gukjin':
decideGukjin(s, p, act.as, ctx);
break;
}
progress(s, ctx);
refreshDeadline(s, now);
return { state: s, events };
},
view(s, viewer) {
const r = s.round!;
const meIdx = viewer === null ? -1 : idx(s, viewer);
const me = meIdx >= 0 ? viewer : null;
const step = s.phase === 'playing' ? r.step : null;
const turnIdx = step && step !== 'sell' && step !== 'done' ? r.turn : null;
const gukjinPi = r.outcome?.gukjinAs === 'pi';
const holder = r.participants.find((q) => r.captured[q]!.includes(GUKJIN));
const seats: SeatView[] = s.players.map((id, i) => {
const seller = r.sellers.find((x) => x.idx === i);
return {
id,
total: s.totals[i]!,
participant: r.participants.includes(i),
seller: seller ? { shown: seller.shown.map((c) => cardView(c, s.rules.nb, `${keyOf(s)}s`)), paid: seller.paid } : null,
handCount: r.hands[i]!.length,
dummies: r.bombDummies[i]!,
revealed: r.revealed[i]!.map((c) => cv(s, c)),
captured: grouped(s, r.captured[i]!, gukjinPi && holder === i),
score: scoreView(s, r.captured[i]!, r.outcome?.gukjinAs && holder === i ? r.outcome.gukjinAs : undefined),
goCount: r.goCount[i]!,
shakes: r.shakes[i]!,
bombs: r.bombs[i]!,
ppeok: r.ppeokCount[i]!,
left: s.left[i]!,
autoPilot: s.autoPilot[i]!,
ready: s.phase === 'roundResult' && s.ready.includes(i),
isDealer: r.participants.length ? r.participants[0] === i : s.dealer === i,
isTurn: turnIdx === i || (step === 'sell' && sellDecider(s) === i),
};
});
let myHand: HandCardView[] | null = null;
if (me && s.phase === 'playing' && r.participants.includes(meIdx)) {
myHand = [...r.hands[meIdx]!]
.sort((a, b) => monthOf(a) - monthOf(b) || a - b)
.map((c) => {
if (isBonus(c)) return { card: cv(s, c), matches: [], needTarget: false, canShake: false, canBomb: false, hint: s.rules.hints ? '보너스패: 내고 한 장 더 받아요' : null };
const m = monthOf(c);
const g = floorGroup(r, m);
const n = handMonthCount(r, meIdx, m);
return {
card: cv(s, c),
matches: g ? [...g.cards] : [],
needTarget: !!g && g.cards.length === 2 && needsTarget(s, m),
canShake: s.rules.shake && n >= 3 && !g && !r.shakenMonths[meIdx]!.includes(m),
canBomb: s.rules.bomb && n === 3 && g?.cards.length === 1,
hint: s.rules.hints ? handHint(s, meIdx, c) : null,
};
});
} else if (me && r.step === 'sell' && s.phase === 'playing') {
myHand = [...r.hands[meIdx]!].sort((a, b) => monthOf(a) - monthOf(b) || a - b).map((c) => ({ card: cv(s, c), matches: [], needTarget: false, canShake: false, canBomb: false, hint: null }));
}
const decider = step === 'sell' ? sellDecider(s) : null;
const lt = r.lastTurn;
const stopper = step === 'gukjin' ? r.stopBy : null;
return {
rules: s.rules,
mode: s.mode,
me,
phase: s.phase,
step,
roundNo: s.roundNo,
dealer: s.players[r.participants[0] ?? s.dealer]!,
nagariStreak: s.nagariStreak,
nagariMult: r.nagariMult,
turn: turnIdx === null ? null : s.players[turnIdx]!,
deckCount: r.deck.length,
floor: r.floor.map((g) => ({ month: g.month, cards: g.cards.map((c) => cv(s, c)), ppeokBy: g.ppeokBy === null ? null : s.players[g.ppeokBy]!, natural: g.natural })),
seats,
myHand,
myDummies: meIdx >= 0 ? r.bombDummies[meIdx]! : 0,
pendingFlip:
step === 'chooseFlip' && r.pending
? { player: s.players[r.turn]!, flipped: cv(s, r.pending.flipped), options: r.pending.options.map((c) => cv(s, c)) }
: null,
goStop: step === 'goStop' ? goStopPreview(s, r.turn) : null,
gukjinAsk:
step === 'gukjin' && stopper !== null
? {
player: s.players[r.turn]!,
yeolScore: scoreOf(r.captured[r.turn]!, s.rules, 'yeol').total,
piScore: scoreOf(r.captured[r.turn]!, s.rules, 'pi').total,
}
: null,
chongtongAsk: step === 'chongtong' ? { player: s.players[r.turn]!, months: meIdx === r.turn ? hasChongtong(r, r.turn) : null } : null,
sell:
r.sellOrder.length > 0
? {
current: decider === null ? null : s.players[decider]!,
decisions: r.sellDecisions.map((d) => ({ player: s.players[d.idx]!, sell: d.sell, auto: d.auto })),
mySellable: decider !== null && decider === meIdx ? sellableCards(s, meIdx).map((c) => cv(s, c)) : null,
canSell: decider !== null && decider !== s.dealer,
}
: null,
lastTurn: lt
? {
seq: lt.seq,
tags: lt.tags,
bonusPoints: lt.bonusPoints,
player: s.players[lt.player]!,
played: lt.played.map((c) => cv(s, c)),
flipped: lt.flipped.map((c) => cv(s, c)),
captured: lt.captured.map((c) => cv(s, c)),
stolen: lt.stolen.map(([q, cs]) => ({ from: s.players[q]!, cards: cs.map((c) => cv(s, c)) })),
}
: null,
outcome: outcomeView(s, r),
log: s.log.map((l) => ({ ...l, winner: l.winner === null ? null : s.players[l.winner]!, delta: Object.fromEntries(s.players.map((id, i) => [id, l.delta[i] ?? 0])) })),
ready: s.ready.map((i) => s.players[i]!),
deadline: s.deadline,
result: s.final,
stats: statsView(s),
};
},
activePlayers(s) {
return waiting(s).map((i) => s.players[i]!);
},
deadline(s) {
return s.phase === 'finished' ? null : s.deadline;
},
onTimeout() {
return { type: 'timeout' };
},
onLeave(s, player) {
const p = idx(s, player);
if (p < 0 || s.left[p] || s.phase === 'finished') return null;
return { type: 'leave' };
},
result(s): GameResult | null {
if (!s.final) return null;
return { ranking: s.final.ranking, scores: s.final.scores, summary: s.final.summary, reason: 'normal' };
},
legalActions(s, player) {
const p = idx(s, player);
return p < 0 ? [] : legalFor(s, p);
},
/** Unrevealed hand cards of `player` plus the deck order (keys as used in views). */
secretsOf(s, player) {
const r = s.round;
const p = idx(s, player);
if (!r || p < 0) return [];
const key = (c: CardId) => `${keyOf(s)}c${c}`;
const own = r.hands[p]!.filter((c) => !r.revealed[p]!.includes(c)).map(key);
return [...own, ...r.deck.map(key)];
},
};
function statsView(s: GostopState): GostopView['stats'] {
let best: GostopView['stats']['bestRound'] = null;
for (const l of s.log) {
if (l.winner !== null && (!best || l.amount > best.amount)) best = { player: s.players[l.winner]!, amount: l.amount, roundNo: l.no, breakdown: l.breakdown };
}
return {
rounds: s.log.length,
nagari: s.stats.nagari,
bestRound: best,
perPlayer: s.players.map((id, i) => ({
player: id,
wins: s.stats.wins[i]!,
goCalls: s.stats.goCalls[i]!,
piBakGiven: s.stats.piBakGiven[i]!,
gwangSold: s.stats.gwangSold[i]!,
})),
};
}

View File

@@ -0,0 +1,744 @@
/** 고스톱 판 진행 (docs/games/gostop.md §4, §5): 패 돌리기, 광팔기, 총통, 먹기 판정, 피 뺏기, 고/스톱, 정산. */
import type { GameEvent, Rng } from '@bg/engine';
import { BONUS_COUNT, bestCard, cardInfo, cardValue, fullDeck, isBonus, monthOf, sameKind, type CardId } from './cards';
import { bestScore, settle } from './scoring';
import type { FloorGroup, GostopOptions, GostopRules, GostopState, PlayAction, RoundOutcome, RoundState, TurnCtx, TurnRecord } from './types';
export interface Ctx {
rng: Rng;
events: GameEvent[];
}
const REDEAL_GUARD = 50;
export function resolveRules(o: GostopOptions, players: number): GostopRules {
const matgo = players === 2;
const bonusCards = o.bonusCards === 'auto' ? (matgo ? '2+1' : 'none') : o.bonusCards;
return {
...o,
bonusCards,
nb: BONUS_COUNT[bonusCards],
winScore: o.winScore === 'auto' ? (matgo ? 7 : 3) : o.winScore,
piBakLimit: o.piBakLimit === 'auto' ? (matgo ? 7 : 5) : o.piBakLimit,
chongtongPoints: o.chongtongPoints === 'auto' ? (matgo ? 10 : 5) : o.chongtongPoints,
};
}
// ---------------------------------------------------------------- small helpers
export const floorGroup = (r: RoundState, month: number): FloorGroup | undefined => r.floor.find((g) => g.month === month);
export const floorCount = (r: RoundState, month: number): number => floorGroup(r, month)?.cards.length ?? 0;
export const handMonthCount = (r: RoundState, p: number, month: number): number => r.hands[p]!.filter((c) => !isBonus(c) && monthOf(c) === month).length;
function removeFrom(arr: CardId[], id: CardId): void {
const i = arr.indexOf(id);
if (i < 0) throw new Error(`gostop: card ${id} not found`);
arr.splice(i, 1);
}
function putOnFloor(r: RoundState, id: CardId): void {
const m = monthOf(id);
const g = floorGroup(r, m);
if (g) g.cards.push(id);
else {
r.floor.push({ month: m, cards: [id], ppeokBy: null, natural: false });
r.floor.sort((a, b) => a.month - b.month);
}
}
function takeFromFloor(r: RoundState, ids: CardId[]): void {
for (const id of ids) {
const g = floorGroup(r, monthOf(id));
if (!g) throw new Error(`gostop: floor card ${id} missing`);
removeFrom(g.cards, id);
if (g.cards.length === 0) r.floor = r.floor.filter((x) => x !== g);
}
}
function capture(r: RoundState, ctx: TurnCtx, ids: CardId[]): void {
r.captured[ctx.player]!.push(...ids);
ctx.captured.push(...ids);
}
/** Players from `start` in turn order (seat order). */
export function orderFrom(n: number, start: number): number[] {
return Array.from({ length: n }, (_, i) => (start + i) % n);
}
export function currentScore(s: GostopState, p: number): number {
return bestScore(s.round!.captured[p]!, s.rules).calc.total;
}
export function hasChongtong(r: RoundState, p: number): number[] {
const out: number[] = [];
for (let m = 1; m <= 12; m++) if (handMonthCount(r, p, m) === 4) out.push(m);
return out;
}
// ---------------------------------------------------------------- dealing
function emptyRound(s: GostopState): RoundState {
const n = s.players.length;
const arr = <T>(f: () => T): T[] => Array.from({ length: n }, f);
const rules = s.rules;
return {
no: s.roundNo,
participants: [],
sellOrder: [],
sellCursor: 0,
sellDecisions: [],
sellers: [],
hands: arr(() => []),
bombDummies: arr(() => 0),
revealed: arr(() => []),
shakenMonths: arr(() => []),
deck: [],
floor: [],
captured: arr(() => []),
gukjinChoice: arr(() => null),
goCount: arr(() => 0),
lastGoScore: arr(() => 0),
goOrder: [],
shakes: arr(() => 0),
bombs: arr(() => 0),
ppeokCount: arr(() => 0),
ppeokTurns: arr(() => []),
turnsTaken: arr(() => 0),
turn: s.dealer,
step: 'play',
pending: null,
stopBy: null,
chongtongBy: null,
nagariMult: rules.nagariMultiplier === 'x2' ? Math.min(2 ** s.nagariStreak, rules.nagariMaxMultiplier) : 1,
instant: arr(() => 0),
turnSeq: 0,
lastTurn: null,
outcome: null,
redeals: 0,
};
}
/**
* Shuffles and deals to `who` (§4.3), then cleans the floor (§4.4): floor bonus cards go to the dealer
* with a refill from the deck; four of a month on the floor → shuffle again.
*/
function deal(s: GostopState, r: RoundState, who: number[], ctx: Ctx): void {
const rules = s.rules;
const handSize = s.mode === 'matgo' ? 10 : 7;
const floorSize = s.mode === 'matgo' ? 8 : 6;
for (let guard = 0; ; guard++) {
const deck = ctx.rng.shuffle(fullDeck(rules.nb));
for (const p of who) r.hands[p] = deck.splice(0, handSize);
const floor = deck.splice(0, floorSize);
const taken: CardId[] = [];
for (let i = 0; i < floor.length; i++) {
while (isBonus(floor[i]!)) {
taken.push(floor[i]!);
floor[i] = deck.shift()!;
}
}
const counts = new Map<number, number>();
for (const c of floor) counts.set(monthOf(c), (counts.get(monthOf(c)) ?? 0) + 1);
if ([...counts.values()].some((v) => v === 4) && guard < REDEAL_GUARD) {
r.redeals++;
ctx.events.push({ type: 'redeal', reason: 'floorFour' });
continue;
}
r.deck = deck;
r.captured = r.captured.map(() => []);
r.captured[s.dealer] = taken;
r.floor = [];
for (const c of floor) putOnFloor(r, c);
for (const g of r.floor) if (g.cards.length === 3) g.natural = true;
ctx.events.push({ type: 'dealt', players: who.map((p) => s.players[p]), handSize, floor: [...floor] });
if (taken.length) ctx.events.push({ type: 'bonusTaken', player: s.players[s.dealer], cards: taken });
return;
}
}
export function startRound(s: GostopState, ctx: Ctx): void {
s.roundNo += 1;
s.phase = 'playing';
s.ready = [];
const r = emptyRound(s);
s.round = r;
const n = s.players.length;
const order = orderFrom(n, s.dealer);
ctx.events.push({ type: 'roundStart', roundNo: s.roundNo, dealer: s.players[s.dealer], nagariMult: r.nagariMult });
deal(s, r, order, ctx);
if (n >= 4) {
r.step = 'sell';
r.sellOrder = order.slice(1);
r.sellCursor = 0;
progressSell(s, ctx);
} else {
r.participants = order;
afterDeal(s, ctx);
}
}
// ---------------------------------------------------------------- 광팔기 (§5.8)
export function sellableCards(s: GostopState, p: number): CardId[] {
const r = s.round!;
return r.hands[p]!.filter((c) => {
const i = cardInfo(c, s.rules.nb);
if (i.kind === 'gwang') return true;
return s.rules.gwangSellCards === 'gwangPlus' && (i.gukjin || i.bonus || (i.kind === 'pi' && i.piValue === 2));
}).sort((a, b) => a - b);
}
/** Who must decide next, or null when the remaining decisions are automatic. */
export function sellDecider(s: GostopState): number | null {
const r = s.round!;
if (r.step !== 'sell' || r.sellCursor >= r.sellOrder.length) return null;
const n = s.players.length;
const plays = 1 + r.sellDecisions.filter((d) => !d.sell).length;
const dies = r.sellDecisions.filter((d) => d.sell).length;
if (plays >= 3 || dies >= n - 3) return null;
return r.sellOrder[r.sellCursor]!;
}
export function decideSell(s: GostopState, p: number, sell: boolean, ctx: Ctx): void {
const r = s.round!;
r.sellDecisions.push({ idx: p, sell, auto: false });
r.sellCursor++;
ctx.events.push({ type: sell ? 'sellDie' : 'sellPlay', player: s.players[p] });
progressSell(s, ctx);
}
function progressSell(s: GostopState, ctx: Ctx): void {
const r = s.round!;
const n = s.players.length;
while (r.sellCursor < r.sellOrder.length) {
const plays = 1 + r.sellDecisions.filter((d) => !d.sell).length;
const dies = r.sellDecisions.filter((d) => d.sell).length;
if (plays < 3 && dies < n - 3) return; // a real decision is needed
const p = r.sellOrder[r.sellCursor]!;
r.sellDecisions.push({ idx: p, sell: plays >= 3, auto: true });
r.sellCursor++;
}
finishSelling(s, ctx);
}
function finishSelling(s: GostopState, ctx: Ctx): void {
const r = s.round!;
const rules = s.rules;
const order = orderFrom(s.players.length, s.dealer);
const dying = new Set(r.sellDecisions.filter((d) => d.sell).map((d) => d.idx));
r.participants = order.filter((p) => !dying.has(p));
const payers = rules.gwangPayer === 'nonDealerPlayers' ? r.participants.slice(1) : r.participants;
const back: CardId[] = [];
for (const p of order) {
if (!dying.has(p)) continue;
const shown = sellableCards(s, p);
const each = shown.length * rules.gwangPrice;
for (const q of payers) {
s.totals[q]! -= each;
r.instant[q]! -= each;
}
s.totals[p]! += each * payers.length;
r.instant[p]! += each * payers.length;
s.stats.gwangSold[p]! += shown.length;
r.sellers.push({ idx: p, shown, paid: each * payers.length });
ctx.events.push({ type: 'gwangSold', player: s.players[p], cards: shown, points: each * payers.length });
back.push(...r.hands[p]!);
r.hands[p] = [];
}
r.deck = ctx.rng.shuffle([...r.deck, ...back]);
afterDeal(s, ctx);
}
// ---------------------------------------------------------------- 총통 (§5.7)
function afterDeal(s: GostopState, ctx: Ctx): void {
const r = s.round!;
for (let guard = 0; guard <= REDEAL_GUARD; guard++) {
const holder = s.rules.chongtong === 'off' ? undefined : r.participants.find((p) => hasChongtong(r, p).length > 0);
if (holder === undefined) break;
if (s.rules.chongtong === 'win') {
r.step = 'chongtong';
r.chongtongBy = holder;
r.turn = holder;
return;
}
if (guard === REDEAL_GUARD) break;
r.redeals++;
ctx.events.push({ type: 'redeal', reason: 'chongtong' });
deal(s, r, r.participants, ctx);
}
startPlay(s);
}
function startPlay(s: GostopState): void {
const r = s.round!;
r.step = 'play';
r.chongtongBy = null;
r.turn = r.participants[0]!;
}
export function decideChongtong(s: GostopState, p: number, declare: boolean, ctx: Ctx): void {
const r = s.round!;
if (!declare) {
ctx.events.push({ type: 'chongtongSkip', player: s.players[p] });
startPlay(s);
return;
}
const months = hasChongtong(r, p);
const shown = r.hands[p]!.filter((c) => !isBonus(c) && monthOf(c) === months[0]).sort((a, b) => a - b);
r.revealed[p]!.push(...shown);
ctx.events.push({ type: 'chongtong', player: s.players[p], cards: shown });
instantWin(s, p, 'chongtong', shown, ctx);
}
/** 총통·삼뻑: points × 나가리 배수 from every opponent, no 박 (§5.7, §5.11). */
function instantWin(s: GostopState, p: number, type: 'chongtong' | 'samppeok', shown: CardId[], ctx: Ctx): void {
const r = s.round!;
const amount = s.rules.chongtongPoints * r.nagariMult;
const outcome: RoundOutcome = {
type,
winner: p,
baseScore: s.rules.chongtongPoints,
breakdown: [{ name: type === 'chongtong' ? '총통' : '삼뻑', points: s.rules.chongtongPoints }],
goCount: 0,
multipliers: r.nagariMult > 1 ? [{ name: '나가리 배수', factor: r.nagariMult }] : [],
perLoser: amount,
payments: r.participants.filter((q) => q !== p).map((q) => ({ from: q, amount, baks: [] })),
gukjinAs: null,
shown,
};
endRound(s, outcome, ctx);
}
// ---------------------------------------------------------------- a turn (§5.1–5.6)
/** Two floor cards that differ → the player must pick (§5.2). */
export function needsTarget(s: GostopState, month: number): boolean {
const g = floorGroup(s.round!, month);
return !!g && g.cards.length === 2 && !sameKind(g.cards[0]!, g.cards[1]!, s.rules.nb);
}
function flip(s: GostopState, ctx: TurnCtx): CardId {
const r = s.round!;
for (;;) {
const c = r.deck.shift();
if (c === undefined) throw new Error('gostop: deck exhausted');
ctx.flipped.push(c);
if (!isBonus(c)) return c;
capture(r, ctx, [c]);
ctx.tags.push('bonusFlip');
}
}
function pileSteal(s: GostopState, g: FloorGroup, ctx: TurnCtx): void {
if (g.ppeokBy === ctx.player) {
ctx.steal += s.rules.jaPpeokSteal;
ctx.tags.push('jaPpeok');
} else if (g.ppeokBy !== null) {
ctx.steal += 1;
ctx.tags.push('ppeokEat');
} else if (g.natural) {
if (s.rules.naturalThreeSteal) ctx.steal += 1;
ctx.tags.push('ppeokEat');
}
}
const sweepCounts = (s: GostopState, ctx: TurnCtx): boolean => !ctx.lastOfRound || s.rules.lastTurnSweepBonus;
export function playCard(s: GostopState, p: number, a: PlayAction, ctx: Ctx): void {
const r = s.round!;
const nb = s.rules.nb;
const t: TurnCtx = { player: p, played: [], flipped: [], captured: [], tags: [], steal: 0, lastOfRound: false };
// 보너스패: goes to my pile, I take the deck top and play again in the same turn (§5.6).
if (a.card !== 'dummy' && isBonus(a.card)) {
removeFrom(r.hands[p]!, a.card);
capture(r, t, [a.card]);
t.played.push(a.card);
t.tags.push('bonus');
const drawn = r.deck.shift();
if (drawn === undefined) throw new Error('gostop: deck exhausted (bonus draw)');
r.hands[p]!.push(drawn);
ctx.events.push({ type: 'bonusPlayed', player: s.players[p], card: a.card });
record(s, t, ctx);
return;
}
let H: CardId | null = null;
let m1 = 0;
let k = 0;
if (a.card === 'dummy') {
r.bombDummies[p]! -= 1;
t.tags.push('dummy');
ctx.events.push({ type: 'dummyPlayed', player: s.players[p] });
} else if (a.bomb) {
const m = monthOf(a.card);
const mine = r.hands[p]!.filter((c) => !isBonus(c) && monthOf(c) === m).sort((x, y) => x - y);
for (const c of mine) removeFrom(r.hands[p]!, c);
r.revealed[p] = r.revealed[p]!.filter((c) => !mine.includes(c));
const g = floorGroup(r, m)!;
const floorCards = [...g.cards];
takeFromFloor(r, floorCards);
t.played.push(...mine);
capture(r, t, [...mine, ...floorCards]);
t.steal += 1;
t.tags.push('bomb');
r.bombs[p]! += 1;
r.bombDummies[p]! += 2;
ctx.events.push({ type: 'bomb', player: s.players[p], cards: mine });
} else {
H = a.card;
m1 = monthOf(H);
k = floorCount(r, m1);
removeFrom(r.hands[p]!, H);
r.revealed[p] = r.revealed[p]!.filter((c) => c !== H);
t.played.push(H);
if (a.shake) {
const rest = r.hands[p]!.filter((c) => !isBonus(c) && monthOf(c) === m1);
r.shakes[p]! += 1;
r.shakenMonths[p]!.push(m1);
for (const c of rest) if (!r.revealed[p]!.includes(c)) r.revealed[p]!.push(c);
t.tags.push('shake');
ctx.events.push({ type: 'shake', player: s.players[p], cards: [H, ...rest].sort((x, y) => x - y) });
}
ctx.events.push({ type: 'played', player: s.players[p], card: H });
}
const F = flip(s, t);
t.lastOfRound = r.deck.length === 0;
ctx.events.push({ type: 'flipped', player: s.players[p], cards: [...t.flipped] });
if (H !== null) {
const g = floorGroup(r, m1);
if (monthOf(F) === m1) {
if (k === 0) {
capture(r, t, [H, F]);
t.tags.push('jjok');
if (sweepCounts(s, t)) t.steal += 1;
} else if (k === 1) {
g!.cards.push(H, F);
g!.ppeokBy = p;
g!.natural = false;
r.ppeokCount[p]! += 1;
r.ppeokTurns[p]!.push(r.turnsTaken[p]!);
t.tags.push('ppeok');
} else {
// k = 2 → 따닥 (k = 3 is impossible: the 4th card of the month was H).
const cards = [...g!.cards];
takeFromFloor(r, cards);
capture(r, t, [...cards, H, F]);
t.tags.push('ttadak');
t.steal += 1;
}
finishTurn(s, t, ctx);
return;
}
if (k === 0) putOnFloor(r, H);
else if (k === 1) {
const c = g!.cards[0]!;
takeFromFloor(r, [c]);
capture(r, t, [H, c]);
} else if (k === 2) {
const target = a.target ?? bestCard(g!.cards, nb);
takeFromFloor(r, [target]);
capture(r, t, [H, target]);
} else {
const cards = [...g!.cards];
pileSteal(s, g!, t);
takeFromFloor(r, cards);
capture(r, t, [...cards, H]);
}
}
resolveFlip(s, F, t, ctx);
}
/** F 처리 (§5.2): j = floor cards of F's month. */
function resolveFlip(s: GostopState, F: CardId, t: TurnCtx, ctx: Ctx): void {
const r = s.round!;
const nb = s.rules.nb;
const g = floorGroup(r, monthOf(F));
const j = g?.cards.length ?? 0;
if (j === 0) putOnFloor(r, F);
else if (j === 1) {
const c = g!.cards[0]!;
takeFromFloor(r, [c]);
capture(r, t, [F, c]);
} else if (j === 2) {
if (sameKind(g!.cards[0]!, g!.cards[1]!, nb)) {
const c = bestCard(g!.cards, nb);
takeFromFloor(r, [c]);
capture(r, t, [F, c]);
} else {
r.pending = { flipped: F, options: [...g!.cards].sort((a, b) => a - b), ctx: t };
r.step = 'chooseFlip';
return;
}
} else {
const cards = [...g!.cards];
pileSteal(s, g!, t);
takeFromFloor(r, cards);
capture(r, t, [...cards, F]);
}
finishTurn(s, t, ctx);
}
export function chooseFlip(s: GostopState, target: CardId, ctx: Ctx): void {
const r = s.round!;
const pend = r.pending!;
r.pending = null;
r.step = 'play';
takeFromFloor(r, [target]);
capture(r, pend.ctx, [pend.flipped, target]);
finishTurn(s, pend.ctx, ctx);
}
/** Gives `n` 피 per opponent, lowest value first (일반 피 → 쌍피·2피 → 3피), then lowest id (§5.3). */
function steal(s: GostopState, t: TurnCtx): [number, CardId[]][] {
const r = s.round!;
const nb = s.rules.nb;
const out: [number, CardId[]][] = [];
for (const q of r.participants) {
if (q === t.player) continue;
const pis = r.captured[q]!.filter((c) => cardInfo(c, nb).kind === 'pi').sort((a, b) => cardInfo(a, nb).piValue - cardInfo(b, nb).piValue || a - b);
const given = pis.slice(0, t.steal);
for (const c of given) removeFrom(r.captured[q]!, c);
r.captured[t.player]!.push(...given);
if (given.length) out.push([q, given]);
}
return out;
}
function record(s: GostopState, t: TurnCtx, ctx: Ctx, stolen: [number, CardId[]][] = [], bonusPoints = 0): TurnRecord {
const r = s.round!;
r.turnSeq += 1;
const rec: TurnRecord = { seq: r.turnSeq, player: t.player, played: t.played, flipped: t.flipped, captured: t.captured, tags: [...t.tags], stolen, bonusPoints };
r.lastTurn = rec;
return rec;
}
function finishTurn(s: GostopState, t: TurnCtx, ctx: Ctx): void {
const r = s.round!;
const p = t.player;
const others = r.participants.filter((q) => q !== p);
if (r.floor.length === 0) {
t.tags.push('sseul');
if (sweepCounts(s, t)) t.steal += 1;
}
const stolen = t.steal > 0 ? steal(s, t) : [];
const turnIdx = r.turnsTaken[p]!;
r.turnsTaken[p]! += 1;
// 첫뻑·연뻑·삼뻑 (§5.11)
let bonusPoints = 0;
let samppeok = false;
if (t.tags.includes('ppeok') && s.rules.ppeokBonus) {
if (r.ppeokCount[p]! >= 3) {
samppeok = true;
t.tags.push('samPpeok');
} else if (turnIdx === 0) {
bonusPoints = 3;
t.tags.push('firstPpeok');
} else if (turnIdx === 1 && r.ppeokTurns[p]!.includes(0)) {
bonusPoints = 6;
t.tags.push('yeonPpeok');
}
for (const q of others) {
s.totals[q]! -= bonusPoints;
r.instant[q]! -= bonusPoints;
s.totals[p]! += bonusPoints;
r.instant[p]! += bonusPoints;
}
}
for (const tag of t.tags) if (!['bonus', 'bonusFlip', 'dummy', 'bomb', 'shake'].includes(tag)) ctx.events.push({ type: tag, player: s.players[p] });
if (stolen.length) ctx.events.push({ type: 'piStolen', player: s.players[p], from: stolen.map(([q, cs]) => ({ player: s.players[q], cards: cs })) });
record(s, t, ctx, stolen, bonusPoints);
if (samppeok) {
instantWin(s, p, 'samppeok', [], ctx);
return;
}
const score = currentScore(s, p);
ctx.events.push({ type: 'scoreUpdate', player: s.players[p], score });
if (score >= s.rules.winScore && (r.goCount[p] === 0 || score > r.lastGoScore[p]!)) {
if (r.hands[p]!.length === 0) {
ctx.events.push({ type: 'stop', player: s.players[p], auto: true });
stop(s, p, ctx);
} else {
r.step = 'goStop';
r.turn = p;
ctx.events.push({ type: 'goStopPrompt', player: s.players[p], score });
}
return;
}
advance(s, p, ctx);
}
/** Next player with something to play; nobody left → 나가리 (§5.10). */
function advance(s: GostopState, from: number, ctx: Ctx): void {
const r = s.round!;
const parts = r.participants;
const i = parts.indexOf(from);
for (let step = 1; step <= parts.length; step++) {
const q = parts[(i + step) % parts.length]!;
if (r.hands[q]!.length > 0 || r.bombDummies[q]! > 0) {
r.step = 'play';
r.turn = q;
return;
}
}
if (r.deck.length !== 0) throw new Error('gostop: hands empty but deck not empty');
ctx.events.push({ type: 'nagari' });
endRound(
s,
{ type: 'nagari', winner: null, baseScore: 0, breakdown: [], goCount: 0, multipliers: [], perLoser: 0, payments: [], gukjinAs: null, shown: [] },
ctx,
);
}
export function decideGoStop(s: GostopState, p: number, decision: 'go' | 'stop', ctx: Ctx): void {
const r = s.round!;
if (decision === 'stop') {
ctx.events.push({ type: 'stop', player: s.players[p], auto: false });
stop(s, p, ctx);
return;
}
r.goCount[p]! += 1;
r.lastGoScore[p] = currentScore(s, p);
r.goOrder.push(p);
s.stats.goCalls[p]! += 1;
ctx.events.push({ type: 'go', player: s.players[p], count: r.goCount[p] });
advance(s, p, ctx);
}
/** The 국진 owner who still has to choose (ask mode) before settling. */
export function gukjinPending(s: GostopState): number | null {
const r = s.round!;
if (s.rules.gukjinMode !== 'ask') return null;
const holder = r.participants.find((q) => r.captured[q]!.includes(32));
return holder !== undefined && r.gukjinChoice[holder] === null ? holder : null;
}
function stop(s: GostopState, p: number, ctx: Ctx): void {
const r = s.round!;
r.stopBy = p;
if (gukjinPending(s) !== null) {
r.step = 'gukjin';
r.turn = gukjinPending(s)!;
return;
}
settleStop(s, ctx);
}
export function settleInput(s: GostopState, winner: number) {
const r = s.round!;
return {
rules: s.rules,
mode: s.mode,
winner,
participants: r.participants,
captured: r.captured,
goCount: r.goCount,
goOrder: r.goOrder,
shakes: r.shakes,
bombs: r.bombs,
nagariMult: r.nagariMult,
gukjinChoice: r.gukjinChoice,
};
}
export function decideGukjin(s: GostopState, p: number, as: 'yeol' | 'pi', ctx: Ctx): void {
const r = s.round!;
r.gukjinChoice[p] = as;
ctx.events.push({ type: 'gukjin', player: s.players[p], as });
settleStop(s, ctx);
}
/** The automatic 국진 rule for the pending owner (timeout in ask mode). */
export function autoGukjin(s: GostopState): 'yeol' | 'pi' {
return settle(settleInput(s, s.round!.stopBy!)).gukjinAs ?? 'yeol';
}
function settleStop(s: GostopState, ctx: Ctx): void {
const r = s.round!;
const st = settle(settleInput(s, r.stopBy!));
const { calcs: _c, ...rest } = st;
endRound(s, { type: 'stop', ...rest, shown: [] }, ctx);
}
// ---------------------------------------------------------------- round end
function endRound(s: GostopState, outcome: RoundOutcome, ctx: Ctx): void {
const r = s.round!;
r.outcome = outcome;
r.step = 'done';
r.pending = null;
const delta = [...r.instant];
if (outcome.winner !== null) {
for (const pay of outcome.payments) {
s.totals[pay.from]! -= pay.amount;
s.totals[outcome.winner]! += pay.amount;
delta[pay.from]! -= pay.amount;
delta[outcome.winner]! += pay.amount;
if (pay.baks.includes('pi')) s.stats.piBakGiven[outcome.winner]! += 1;
}
s.stats.wins[outcome.winner]! += 1;
s.nagariStreak = 0;
s.dealer = outcome.winner;
} else {
s.stats.nagari += 1;
s.nagariStreak += 1;
}
const amount = outcome.payments.reduce((a, x) => a + x.amount, 0);
s.log.push({
no: s.roundNo,
type: outcome.type,
winner: outcome.winner,
amount,
delta,
breakdown: [...outcome.breakdown.map((b) => `${b.name} ${b.points}점`), ...outcome.multipliers.map((m) => `${m.name} x${m.factor}`)],
});
ctx.events.push({ type: 'roundEnded', roundNo: s.roundNo, outcome: outcome.type, winner: outcome.winner === null ? null : s.players[outcome.winner], amount });
if (s.roundNo >= s.rules.rounds) finishGame(s, ctx);
else {
s.phase = 'roundResult';
s.ready = [];
}
}
function finishGame(s: GostopState, ctx: Ctx): void {
s.phase = 'finished';
const ids = s.players.map((_, i) => i).sort((a, b) => s.totals[b]! - s.totals[a]! || a - b);
const ranking: string[][] = [];
let last: number | null = null;
for (const i of ids) {
if (last !== null && s.totals[i] === last) ranking[ranking.length - 1]!.push(s.players[i]!);
else ranking.push([s.players[i]!]);
last = s.totals[i]!;
}
const scores = Object.fromEntries(s.players.map((p, i) => [p, s.totals[i]!]));
const top = s.totals[ids[0]!]!;
const tie = ranking[0]!.length > 1 ? ` (공동 ${ranking[0]!.length}명)` : '';
const nag = s.stats.nagari ? `, 나가리 ${s.stats.nagari}번` : '';
const summary = `${s.roundNo}판 끝${nag}. 1위 ${top >= 0 ? '+' : ''}${top.toLocaleString('ko-KR')}점${tie}`;
s.final = { ranking, scores, summary };
ctx.events.push({ type: 'setEnded', ranking });
}
// ---------------------------------------------------------------- automatic play (§8.5)
/** Deterministic play for timeouts: 빈패 → 보너스패 → 가장 값진 바닥 패를 먹는 패 → 가장 싼 패. */
export function autoPlay(s: GostopState, p: number): PlayAction {
const r = s.round!;
const nb = s.rules.nb;
if (r.bombDummies[p]! > 0) return { type: 'play', card: 'dummy' };
const hand = [...r.hands[p]!].sort((a, b) => a - b);
const bonus = hand.find(isBonus);
if (bonus !== undefined) return { type: 'play', card: bonus };
let best: { card: CardId; value: number } | null = null;
for (const c of hand) {
const g = floorGroup(r, monthOf(c));
if (!g) continue;
const value = Math.max(...g.cards.map((x) => cardValue(x, nb)));
if (!best || value > best.value) best = { card: c, value };
}
if (best) {
const g = floorGroup(r, monthOf(best.card))!;
return g.cards.length === 2 && needsTarget(s, g.month) ? { type: 'play', card: best.card, target: bestCard(g.cards, nb) } : { type: 'play', card: best.card };
}
const cheapest = [...hand].sort((a, b) => cardValue(a, nb) - cardValue(b, nb) || a - b)[0]!;
return { type: 'play', card: cheapest };
}

View File

@@ -0,0 +1,151 @@
/** 고스톱 점수 계산 (docs/games/gostop.md §6): 족보 합, 국진, 최종 공식, 박. */
import { GUKJIN, cardInfo, type CardId } from './cards';
import type { BakKind, GostopRules, Payment, RoundOutcome, ScoreView } from './types';
export interface ScoreCalc extends ScoreView {
/** Points from 광 족보 (삼광·비삼광·사광·오광) — 광박 needs this > 0. */
gwangPoints: number;
}
/** 족보 합 (§6.1). `gukjinAs` = how the 9월 열끗 counts if it is among `cards`. */
export function scoreOf(cards: readonly CardId[], rules: Pick<GostopRules, 'nb' | 'ogwangPoints' | 'biGwangThree'>, gukjinAs: 'yeol' | 'pi' = 'yeol'): ScoreCalc {
let gwang = 0;
let bi = false;
let yeol = 0;
let tti = 0;
let pi = 0;
let hong = 0;
let cheong = 0;
let cho = 0;
let godori = 0;
for (const id of cards) {
const c = cardInfo(id, rules.nb);
if (c.gukjin && gukjinAs === 'pi') {
pi += 2;
continue;
}
if (c.kind === 'gwang') {
gwang++;
if (c.bi) bi = true;
} else if (c.kind === 'yeol') {
yeol++;
if (c.godori) godori++;
} else if (c.kind === 'tti') {
tti++;
if (c.dan === 'hong') hong++;
else if (c.dan === 'cheong') cheong++;
else if (c.dan === 'cho') cho++;
} else pi += c.piValue;
}
const items: { name: string; points: number }[] = [];
let gwangPoints = 0;
if (gwang >= 5) {
gwangPoints = rules.ogwangPoints;
items.push({ name: '오광', points: gwangPoints });
} else if (gwang === 4) {
gwangPoints = 4;
items.push({ name: '사광', points: 4 });
} else if (gwang === 3) {
gwangPoints = bi ? rules.biGwangThree : 3;
if (gwangPoints > 0) items.push({ name: bi ? '비삼광' : '삼광', points: gwangPoints });
}
if (yeol >= 5) items.push({ name: `열끗 ${yeol}장`, points: yeol - 4 });
if (godori === 3) items.push({ name: '고도리', points: 5 });
if (tti >= 5) items.push({ name: `띠 ${tti}장`, points: tti - 4 });
if (hong === 3) items.push({ name: '홍단', points: 3 });
if (cheong === 3) items.push({ name: '청단', points: 3 });
if (cho === 3) items.push({ name: '초단', points: 3 });
if (pi >= 10) items.push({ name: `피 ${pi}`, points: pi - 9 });
const total = items.reduce((a, x) => a + x.points, 0);
return { total, items, gwang, yeol, tti, pi, hong, cheong, cho, godori, gwangPoints };
}
/** Score with the 국진 counted the way that gives more points (§6.1 auto; also used for reaching winScore). */
export function bestScore(cards: readonly CardId[], rules: GostopRules): { calc: ScoreCalc; as: 'yeol' | 'pi' } {
const asYeol = scoreOf(cards, rules, 'yeol');
if (rules.gukjinMode === 'yeolOnly' || !cards.includes(GUKJIN)) return { calc: asYeol, as: 'yeol' };
const asPi = scoreOf(cards, rules, 'pi');
return asPi.total > asYeol.total ? { calc: asPi, as: 'pi' } : { calc: asYeol, as: 'yeol' };
}
export const goFactor = (go: number): number => (go >= 3 ? 2 ** (go - 2) : 1);
export interface SettleInput {
rules: GostopRules;
mode: 'matgo' | 'gostop';
winner: number;
participants: number[];
captured: CardId[][];
goCount: number[];
goOrder: number[];
shakes: number[];
bombs: number[];
nagariMult: number;
/** Explicit 국진 choice (ask mode) or null for the automatic rule. */
gukjinChoice: (null | 'yeol' | 'pi')[];
}
export type Settlement = Omit<RoundOutcome, 'type' | 'shown'> & { calcs: Record<number, ScoreCalc> };
function compute(inp: SettleInput, holder: number, as: 'yeol' | 'pi'): Settlement {
const { rules, winner } = inp;
const calcs: Record<number, ScoreCalc> = {};
for (const p of inp.participants) calcs[p] = scoreOf(inp.captured[p]!, rules, p === holder ? as : 'yeol');
const w = calcs[winner]!;
const go = inp.goCount[winner]!;
const meong = rules.meongTta && w.yeol >= 7;
const multipliers: { name: string; factor: number }[] = [];
if (go >= 3) multipliers.push({ name: `${go}고`, factor: goFactor(go) });
if (inp.shakes[winner]! > 0) multipliers.push({ name: `흔들기 ${inp.shakes[winner]}번`, factor: 2 ** inp.shakes[winner]! });
if (inp.bombs[winner]! > 0) multipliers.push({ name: `폭탄 ${inp.bombs[winner]}번`, factor: 2 ** inp.bombs[winner]! });
if (meong) multipliers.push({ name: '멍따', factor: 2 });
if (inp.nagariMult > 1) multipliers.push({ name: '나가리 배수', factor: inp.nagariMult });
const T = multipliers.reduce((a, m) => a * m.factor, w.total + go);
const breakdown = [...w.items];
if (go > 0) breakdown.push({ name: `${go}고`, points: go });
const losers = inp.participants.filter((p) => p !== winner);
const payments: Payment[] = losers.map((l) => {
const c = calcs[l]!;
const baks: BakKind[] = [];
if (w.pi >= 10 && ((c.pi >= 1 && c.pi <= rules.piBakLimit) || (c.pi === 0 && !rules.piBakZeroExempt))) baks.push('pi');
if (rules.gwangBak && w.gwangPoints > 0 && c.gwang === 0) baks.push('gwang');
if (rules.meongBak && meong && c.yeol === 0) baks.push('meong');
let amount = T * 2 ** baks.length;
if (rules.goBak && inp.goCount[l]! > 0 && inp.mode === 'matgo') {
baks.push('go');
amount *= 2;
}
return { from: l, amount, baks };
});
if (rules.goBak && inp.mode === 'gostop') {
// 독박: the loser who called 고 last pays every loser's share (§6.2).
const dok = [...inp.goOrder].reverse().find((p) => p !== winner && losers.includes(p));
if (dok !== undefined && losers.length > 1) {
const sum = payments.reduce((a, x) => a + x.amount, 0);
for (const pay of payments) {
if (pay.from === dok) {
pay.amount = sum;
pay.baks.push('go');
} else {
pay.amount = 0;
pay.coveredBy = dok;
}
}
}
}
return { winner, baseScore: w.total, breakdown, goCount: go, multipliers, perLoser: T, payments, gukjinAs: holder >= 0 ? as : null, calcs };
}
/** Final settlement of a stopped round (§6.2), resolving the 국진 for its owner (§6.1). */
export function settle(inp: SettleInput): Settlement {
const holder = inp.participants.find((p) => inp.captured[p]!.includes(GUKJIN)) ?? -1;
if (holder < 0) return compute(inp, -1, 'yeol');
const fixed = inp.rules.gukjinMode === 'yeolOnly' ? 'yeol' : inp.gukjinChoice[holder];
if (fixed) return compute(inp, holder, fixed);
const y = compute(inp, holder, 'yeol');
const p = compute(inp, holder, 'pi');
const sum = (x: Settlement) => x.payments.reduce((a, b) => a + b.amount, 0);
if (holder === inp.winner) return sum(p) > sum(y) ? p : y;
const paid = (x: Settlement) => x.payments.find((b) => b.from === holder)?.amount ?? 0;
return paid(p) < paid(y) ? p : y;
}

View File

@@ -0,0 +1,372 @@
/** 고스톱 options, actions, state and view types (docs/games/gostop.md §2, §8). */
import { z } from 'zod';
import type { PlayerId } from '@bg/engine';
import type { BonusSetting, CardId, CardView } from './cards';
const lit = <T extends string | number>(...vals: T[]) =>
z.union(vals.map((v) => z.literal(v)) as unknown as [z.ZodLiteral<T>, z.ZodLiteral<T>, ...z.ZodLiteral<T>[]]);
export const gostopOptionsSchema = z.object({
rounds: lit(1, 3, 5, 10, 20)
.default(10)
.meta({ title: '판 수(나가리 판도 1판)', labels: { 1: '1판', 3: '3판', 5: '5판', 10: '10판', 20: '20판' } }),
bonusCards: lit<'auto' | BonusSetting>('auto', 'none', '2+1', '3+1')
.default('auto')
.meta({
title: '보너스패',
labels: { auto: '자동(맞고 2피 2장+3피 1장, 고스톱 없음)', none: '없음', '2+1': '2피 2장 + 3피 1장', '3+1': '2피 3장 + 3피 1장' },
}),
winScore: lit<'auto' | 3 | 5 | 7>('auto', 3, 5, 7)
.default('auto')
.meta({ title: '고·스톱 기준 점수', labels: { auto: '자동(맞고 7점, 고스톱 3점)', 3: '3점', 5: '5점', 7: '7점' } }),
piBakLimit: lit<'auto' | 5 | 6 | 7>('auto', 5, 6, 7)
.default('auto')
.meta({ title: '피박 기준(패자 피 값이 이 이하)', labels: { auto: '자동(맞고 7, 고스톱 5)', 5: '5 이하', 6: '6 이하', 7: '7 이하' } }),
piBakZeroExempt: z.boolean().default(true).meta({ title: '피가 하나도 없으면 피박 면제' }),
gwangBak: z.boolean().default(true).meta({ title: '광박' }),
meongTta: z.boolean().default(true).meta({ title: '멍따(열끗 7장 이상 x2)' }),
meongBak: z.boolean().default(false).meta({ title: '멍박(멍따일 때 패자 열끗 0장 x2)' }),
goBak: z.boolean().default(true).meta({ title: '고박(맞고 x2, 3인 독박)' }),
biGwangThree: lit(2, 0).default(2).meta({ title: '비광 낀 3광', labels: { 2: '2점', 0: '점수 없음' } }),
ogwangPoints: lit(15, 30).default(15).meta({ title: '5광 점수', labels: { 15: '15점', 30: '30점' } }),
gukjinMode: z
.enum(['auto', 'ask', 'yeolOnly'])
.default('auto')
.meta({ title: '9월 열끗(국진)', labels: { auto: '유리한 쪽으로 자동', ask: '판 끝에 직접 고르기', yeolOnly: '열끗으로만' } }),
shake: z.boolean().default(true).meta({ title: '흔들기(x2)' }),
bomb: z.boolean().default(true).meta({ title: '폭탄(x2, 피 1장씩)' }),
chongtong: z
.enum(['win', 'redeal', 'off'])
.default('win')
.meta({ title: '총통(같은 월 4장)', labels: { win: '즉시 승리', redeal: '다시 돌리기', off: '그냥 진행' } }),
chongtongPoints: lit<'auto' | 5 | 7 | 10 | 20>('auto', 5, 7, 10, 20)
.default('auto')
.meta({ title: '총통·삼뻑 승리 점수', labels: { auto: '자동(맞고 10점, 고스톱 5점)', 5: '5점', 7: '7점', 10: '10점', 20: '20점' } }),
ppeokBonus: z.boolean().default(true).meta({ title: '첫뻑·연뻑 보너스, 삼뻑 즉시 승리' }),
jaPpeokSteal: lit(1, 2).default(2).meta({ title: '자뻑 때 뺏는 피', labels: { 1: '1장씩', 2: '2장씩' } }),
lastTurnSweepBonus: z.boolean().default(false).meta({ title: '마지막 턴의 쓸·쪽 피 뺏기' }),
naturalThreeSteal: z.boolean().default(true).meta({ title: '처음 깔린 같은 월 3장을 먹으면 피 뺏기' }),
nagariMultiplier: z.enum(['x2', 'none']).default('x2').meta({ title: '나가리 다음 판', labels: { x2: '점수 2배(연속이면 누적)', none: '배수 없음' } }),
nagariMaxMultiplier: lit(2, 4, 8).default(8).meta({ title: '나가리 배수 상한', labels: { 2: 'x2', 4: 'x4', 8: 'x8' } }),
gwangPalgi: z.boolean().default(true).meta({ title: '4~5명이면 광팔기' }),
gwangPrice: lit(1, 2, 3).default(1).meta({ title: '광 1장 값', labels: { 1: '1점', 2: '2점', 3: '3점' } }),
gwangPayer: z
.enum(['nonDealerPlayers', 'allPlayers'])
.default('nonDealerPlayers')
.meta({ title: '광값 내는 사람', labels: { nonDealerPlayers: '선을 뺀 참가자', allPlayers: '참가자 3명 모두' } }),
gwangSellCards: z
.enum(['gwang', 'gwangPlus'])
.default('gwang')
.meta({ title: '팔 수 있는 패', labels: { gwang: '광만', gwangPlus: '광 + 쌍피·국진·보너스' } }),
turnSeconds: lit(5, 10, 15, 20, 30, 60)
.default(15)
.meta({ title: '한 턴 제한 시간', labels: { 5: '5초', 10: '10초', 15: '15초', 20: '20초', 30: '30초', 60: '60초' } }),
decisionSeconds: lit(5, 10, 15, 20, 30)
.default(10)
.meta({ title: '고·스톱·선택 제한 시간', labels: { 5: '5초', 10: '10초', 15: '15초', 20: '20초', 30: '30초' } }),
hints: z.boolean().default(true).meta({ title: '먹을 수 있는 패·족보 힌트' }),
});
export type GostopOptions = z.infer<typeof gostopOptionsSchema>;
/** Options with the mode-dependent `auto` values resolved (§2: 2명 맞고, 3~5명 고스톱 기본값). */
export interface GostopRules extends Omit<GostopOptions, 'bonusCards' | 'winScore' | 'piBakLimit' | 'chongtongPoints'> {
bonusCards: BonusSetting;
/** Number of bonus cards (0, 3 or 4). */
nb: number;
winScore: number;
piBakLimit: number;
chongtongPoints: number;
}
export const gostopActionSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('sell'), sell: z.boolean() }),
z.object({ type: z.literal('chongtong'), declare: z.boolean() }),
z.object({
type: z.literal('play'),
card: z.union([z.number().int().min(0).max(51), z.literal('dummy')]),
target: z.number().int().min(0).max(51).optional(),
shake: z.boolean().optional(),
bomb: z.boolean().optional(),
}),
z.object({ type: z.literal('chooseFlip'), target: z.number().int().min(0).max(51) }),
z.object({ type: z.literal('goStop'), decision: z.enum(['go', 'stop']) }),
z.object({ type: z.literal('gukjin'), as: z.enum(['yeol', 'pi']) }),
z.object({ type: z.literal('ready') }),
]);
export type GostopClientAction = z.infer<typeof gostopActionSchema>;
export type PlayAction = Extract<GostopClientAction, { type: 'play' }>;
/** Client actions plus system-only ones (timer, leaving) that are not in the schema. */
export type GostopAction = GostopClientAction | { type: 'timeout' } | { type: 'leave' };
// ---------------------------------------------------------------- state
export interface FloorGroup {
month: number;
/** 1..3 cards; 3 = 뻑 무더기 or 자연 뻑. */
cards: CardId[];
ppeokBy: number | null;
natural: boolean;
}
export type TurnTag =
| 'bonus' // 손에서 보너스패를 냄
| 'bonusFlip' // 더미에서 보너스패가 뒤집힘
| 'jjok'
| 'ppeok'
| 'ttadak'
| 'sseul'
| 'bomb'
| 'shake'
| 'ppeokEat' // 남의 뻑 / 자연 뻑 먹기
| 'jaPpeok'
| 'firstPpeok'
| 'yeonPpeok'
| 'samPpeok'
| 'dummy';
/** Public summary of the latest turn (drives the screen's event toasts). */
export interface TurnRecord {
seq: number;
player: number;
played: CardId[];
flipped: CardId[];
captured: CardId[];
tags: TurnTag[];
/** 피 cards taken from each opponent: [opponent, cards]. */
stolen: [number, CardId[]][];
/** 첫뻑·연뻑 points received from each opponent. */
bonusPoints: number;
}
export interface TurnCtx {
player: number;
played: CardId[];
flipped: CardId[];
captured: CardId[];
tags: TurnTag[];
steal: number;
/** This turn flipped the last deck card (§5.2 lastTurnSweepBonus). */
lastOfRound: boolean;
}
export type BakKind = 'pi' | 'gwang' | 'meong' | 'go';
export interface Payment {
from: number;
amount: number;
baks: BakKind[];
/** 독박: this loser pays nothing because the go-caller pays for them. */
coveredBy?: number;
}
export interface RoundOutcome {
type: 'stop' | 'chongtong' | 'samppeok' | 'nagari';
winner: number | null;
baseScore: number;
breakdown: { name: string; points: number }[];
goCount: number;
multipliers: { name: string; factor: number }[];
/** Amount before the per-loser 박 multipliers. */
perLoser: number;
payments: Payment[];
gukjinAs: 'yeol' | 'pi' | null;
/** 총통 cards (public once declared). */
shown: CardId[];
}
export interface Seller {
idx: number;
shown: CardId[];
/** Total points received for the shown cards. */
paid: number;
}
export interface RoundState {
no: number;
/** Player indexes playing this round, in turn order from the dealer. */
participants: number[];
sellOrder: number[];
sellCursor: number;
sellDecisions: { idx: number; sell: boolean; auto: boolean }[];
sellers: Seller[];
hands: CardId[][];
bombDummies: number[];
/** 흔들기·총통 선언으로 공개된 손패. */
revealed: CardId[][];
shakenMonths: number[][];
deck: CardId[];
floor: FloorGroup[];
captured: CardId[][];
gukjinChoice: (null | 'yeol' | 'pi')[];
goCount: number[];
lastGoScore: number[];
goOrder: number[];
shakes: number[];
bombs: number[];
ppeokCount: number[];
ppeokTurns: number[][];
turnsTaken: number[];
turn: number;
step: 'sell' | 'chongtong' | 'play' | 'chooseFlip' | 'goStop' | 'gukjin' | 'done';
pending: { flipped: CardId; options: CardId[]; ctx: TurnCtx } | null;
/** Player who stopped, waiting for the 국진 choice (step 'gukjin'). */
stopBy: number | null;
chongtongBy: number | null;
nagariMult: number;
/** Net 첫뻑·연뻑·광값 points moved during this round, per player. */
instant: number[];
turnSeq: number;
lastTurn: TurnRecord | null;
outcome: RoundOutcome | null;
redeals: number;
}
export interface RoundLog {
no: number;
type: RoundOutcome['type'];
winner: number | null;
amount: number;
/** Net change per player this round (판 점수 + 광값 + 첫뻑·연뻑). */
delta: number[];
breakdown: string[];
}
export interface GostopFinal {
ranking: PlayerId[][];
scores: Record<PlayerId, number>;
summary: string;
}
export interface GostopState {
options: GostopOptions;
rules: GostopRules;
mode: 'matgo' | 'gostop';
players: PlayerId[];
totals: number[];
roundNo: number;
dealer: number;
nagariStreak: number;
round: RoundState | null;
phase: 'playing' | 'roundResult' | 'finished';
ready: number[];
left: boolean[];
autoPilot: boolean[];
timeoutStreak: number[];
deadline: number | null;
waitKey: string;
actionSeq: number;
log: RoundLog[];
stats: { wins: number[]; goCalls: number[]; piBakGiven: number[]; gwangSold: number[]; nagari: number };
final: GostopFinal | null;
}
// ---------------------------------------------------------------- view
export interface ScoreView {
total: number;
items: { name: string; points: number }[];
gwang: number;
yeol: number;
tti: number;
pi: number;
hong: number;
cheong: number;
cho: number;
godori: number;
}
export interface SeatView {
id: PlayerId;
total: number;
participant: boolean;
seller: { shown: CardView[]; paid: number } | null;
handCount: number;
dummies: number;
revealed: CardView[];
captured: { gwang: CardView[]; yeol: CardView[]; tti: CardView[]; pi: CardView[] };
score: ScoreView;
goCount: number;
shakes: number;
bombs: number;
ppeok: number;
left: boolean;
autoPilot: boolean;
ready: boolean;
isDealer: boolean;
isTurn: boolean;
}
export interface HandCardView {
card: CardView;
/** Floor cards of the same month (먹을 수 있는 패). */
matches: CardId[];
/** Two different floor cards: the player must pick one. */
needTarget: boolean;
canShake: boolean;
canBomb: boolean;
/** One-line hint, e.g. "홍단 완성" (hints option). */
hint: string | null;
}
export interface GoStopPreview {
score: number;
goCount: number;
/** Points if stopping now: amount per loser and the multiplier notes. */
stopNow: { total: number; perLoser: { player: PlayerId; amount: number; baks: BakKind[] }[]; notes: string[] };
/** After one more 고: count, added points and the 고 multiplier. */
goNext: { goCount: number; plus: number; factor: number };
}
export interface OutcomeView extends Omit<RoundOutcome, 'winner' | 'payments' | 'shown'> {
winner: PlayerId | null;
payments: { from: PlayerId; amount: number; baks: BakKind[]; coveredBy: PlayerId | null }[];
shown: CardView[];
delta: Record<PlayerId, number>;
}
export interface GostopView {
rules: GostopRules;
mode: GostopState['mode'];
me: PlayerId | null;
phase: GostopState['phase'];
step: RoundState['step'] | null;
roundNo: number;
dealer: PlayerId;
nagariStreak: number;
nagariMult: number;
turn: PlayerId | null;
deckCount: number;
floor: { month: number; cards: CardView[]; ppeokBy: PlayerId | null; natural: boolean }[];
seats: SeatView[];
myHand: HandCardView[] | null;
myDummies: number;
pendingFlip: { player: PlayerId; flipped: CardView; options: CardView[] } | null;
goStop: (GoStopPreview & { player: PlayerId }) | null;
gukjinAsk: { player: PlayerId; yeolScore: number; piScore: number } | null;
chongtongAsk: { player: PlayerId; months: number[] | null } | null;
sell: {
current: PlayerId | null;
decisions: { player: PlayerId; sell: boolean; auto: boolean }[];
/** Sellable cards of the deciding player (only in their own view). */
mySellable: CardView[] | null;
canSell: boolean;
} | null;
lastTurn: (Omit<TurnRecord, 'player' | 'played' | 'flipped' | 'captured' | 'stolen'> & {
player: PlayerId;
played: CardView[];
flipped: CardView[];
captured: CardView[];
stolen: { from: PlayerId; cards: CardView[] }[];
}) | null;
outcome: OutcomeView | null;
log: (Omit<RoundLog, 'winner' | 'delta'> & { winner: PlayerId | null; delta: Record<PlayerId, number> })[];
ready: PlayerId[];
deadline: number | null;
result: GostopFinal | null;
stats: {
rounds: number;
nagari: number;
bestRound: { player: PlayerId; amount: number; roundNo: number; breakdown: string[] } | null;
perPlayer: { player: PlayerId; wins: number; goCalls: number; piBakGiven: number; gwangSold: number }[];
};
}

View File

@@ -0,0 +1,707 @@
import { describe, expect, test } from 'bun:test';
import { SeededRng, checkNoLeak, randomPlayout, seedN, type GameEvent, type PlayerId, type RngState } from '@bg/engine';
import {
AWAY_FLIP_MS,
CARDS,
COUNTDOWN_MS,
DECK_SIZE,
FRUITS,
RING_WINDOW_MS,
allCardIds,
cardOf,
fiveFruit,
halliGalli as hg,
type Fruit,
type HalliGalliAction,
type HalliGalliOptions,
type HalliGalliState,
} from './index';
// ---------------------------------------------------------------- helpers
const rng = () => SeededRng.fromSeed([7, 7, 7, 7]);
const T0 = 1_000_000;
const START = T0 + COUNTDOWN_MS;
const FRUIT_OF: Record<string, Fruit> = { S: 'strawberry', B: 'banana', L: 'lime', P: 'plum' };
function make(opts: Partial<HalliGalliOptions> = {}, players = ['A', 'B', 'C'], seed = 1): HalliGalliState {
const options = hg.optionsSchema.parse(opts);
return hg.setup({ players, options, rng: SeededRng.fromSeed(seedN(seed)), now: T0 });
}
function applyE(s: HalliGalliState, p: string, a: HalliGalliAction, now: number): { state: HalliGalliState; events: GameEvent[] } {
const v = hg.validate(s, p, a);
if (!v.ok) throw new Error(`${p} ${JSON.stringify(a)}: ${v.reason}`);
return hg.apply(s, p, a, { rng: rng(), now });
}
const act = (s: HalliGalliState, p: string, a: HalliGalliAction, now: number) => applyE(s, p, a, now).state;
const ringA = (s: HalliGalliState): HalliGalliAction => ({ type: 'ring', version: s.tableVersion });
type Spec = string | number; // "S2" = strawberry ×2, or a count of filler cards
/**
* Seats in the given order, playing phase, `turn` first player. Decks and piles are listed bottom → top.
* Every card not placed goes to `removed`, so the 56-card invariant still holds.
*/
function rig(
players: string[],
r: { decks?: Record<string, Spec[]>; faceUp?: Record<string, Spec[]>; pot?: Spec[]; turn?: string; now?: number },
opts: Partial<HalliGalliOptions> = {},
): HalliGalliState {
const s = make(opts, players);
const now = r.now ?? START;
const used = new Set<number>();
const take = (spec: string): number => {
const fruit = FRUIT_OF[spec[0]!]!;
const count = Number(spec.slice(1));
const c = CARDS.find((x) => !used.has(x.id) && x.fruit === fruit && x.count === count);
if (!c) throw new Error(`no card left for ${spec}`);
used.add(c.id);
return c.id;
};
const named = (list: Spec[] = []) => list.filter((x): x is string => typeof x === 'string').map(take);
// Named cards first so fillers never steal them.
const decks: Record<string, number[]> = {};
const piles: Record<string, number[]> = {};
const pre = new Map<Spec[], number[]>();
for (const list of [...Object.values(r.decks ?? {}), ...Object.values(r.faceUp ?? {}), r.pot ?? []]) pre.set(list, named(list));
// Fillers: plums ×1..2 never form a five with the rigged cards unless intended (tests keep piles explicit).
const filler = (): number => {
const c = CARDS.find((x) => !used.has(x.id));
if (!c) throw new Error('out of cards');
used.add(c.id);
return c.id;
};
const build = (list: Spec[] = []): number[] => {
const ids = [...(pre.get(list) ?? [])];
const out: number[] = [];
for (const x of list) {
if (typeof x === 'string') out.push(ids.shift()!);
else for (let i = 0; i < x; i++) out.push(filler());
}
return out;
};
for (const p of players) {
decks[p] = build(r.decks?.[p]);
piles[p] = build(r.faceUp?.[p]);
}
s.order = [...players];
s.active = [...players];
s.decks = decks;
s.faceUp = piles;
s.pot = build(r.pot);
s.removed = CARDS.map((c) => c.id).filter((id) => !used.has(id));
s.phase = 'playing';
s.turn = r.turn ?? players[0]!;
s.turnStartedAt = now;
s.turnDeadline = now + s.options.flipSeconds * 1000 * (s.options.kidMode ? 8 / s.options.flipSeconds : 1);
s.flipAllowedAt = now;
s.pendingFlip = null;
s.tableVersion = 5;
s.endsAt = s.options.timeLimitMin === null ? null : now + s.options.timeLimitMin * 60_000;
return s;
}
const ids = (s: HalliGalliState, p: string) => s.decks[p]!;
const top = (s: HalliGalliState, p: string) => {
const pile = s.faceUp[p]!;
const c = pile.length ? cardOf(pile[pile.length - 1]!) : null;
return c ? `${c.fruit[0]!.toUpperCase()}${c.count}` : null;
};
function conserved(s: HalliGalliState): void {
const all = allCardIds(s);
if (all.length !== DECK_SIZE) throw new Error(`card count ${all.length}`);
if (new Set(all).size !== DECK_SIZE) throw new Error('duplicate card');
}
// ---------------------------------------------------------------- §3, §4
describe('덱과 분배 (§3, §4)', () => {
test('56장: 과일별 14장, 개수별 5/3/3/2/1장, 과일 개수 총합 33', () => {
expect(CARDS).toHaveLength(56);
expect(new Set(CARDS.map((c) => c.id)).size).toBe(56);
for (const f of FRUITS) {
const mine = CARDS.filter((c) => c.fruit === f);
expect(mine).toHaveLength(14);
expect([1, 2, 3, 4, 5].map((n) => mine.filter((c) => c.count === n).length)).toEqual([5, 3, 3, 2, 1]);
expect(mine.reduce((a, c) => a + c.count, 0)).toBe(33);
}
});
test.each([
[2, 28, 0],
[3, 18, 2],
[4, 14, 0],
[5, 11, 1],
[6, 9, 2],
])('%i명: 1인당 %i장, 남는 카드 %i장 (pot / remove)', (n, per, rest) => {
const players = Array.from({ length: n }, (_, i) => `P${i}`);
const s = make({}, players);
for (const p of players) expect(s.decks[p]).toHaveLength(per);
expect(s.pot).toHaveLength(rest);
expect(s.removed).toHaveLength(0);
conserved(s);
const r = make({ leftoverCards: 'remove' }, players);
expect(r.pot).toHaveLength(0);
expect(r.removed).toHaveLength(rest);
conserved(r);
});
test('좌석·첫 플레이어·셔플은 시드로 결정되고 3초 카운트다운 후 시작', () => {
const a = make({}, ['A', 'B', 'C', 'D'], 3);
const b = make({}, ['A', 'B', 'C', 'D'], 3);
expect(JSON.stringify(a)).toBe(JSON.stringify(b));
const orders = new Set(Array.from({ length: 20 }, (_, i) => make({}, ['A', 'B', 'C', 'D'], i).order.join('')));
expect(orders.size).toBeGreaterThan(5);
expect(a.phase).toBe('countdown');
expect(hg.validate(a, a.turn, { type: 'flip' }).ok).toBe(false);
expect(hg.validate(a, a.turn, { type: 'ring', version: 0 }).ok).toBe(false);
expect(hg.deadline!(a)).toBe(START);
const early = act(a, a.turn, { type: 'tick' }, START - 1);
expect(early.phase).toBe('countdown');
const s = act(a, a.turn, { type: 'tick' }, START);
expect(s.phase).toBe('playing');
expect(s.turnDeadline).toBe(START + 3000);
expect(s.endsAt).toBe(START + 10 * 60_000);
});
});
// ---------------------------------------------------------------- §5.2 judgment
describe('종 판정 (§5.2)', () => {
const table = (piles: Record<string, Spec[]>, players = ['A', 'B', 'C']) => rig(players, { decks: { A: [3], B: [3], C: [3] }, faceUp: piles });
test('딸기 2+3 → 정답, 딸기 2+3+1 → 오답(6개)', () => {
expect(fiveFruit(table({ A: ['S2'], B: ['S3'] }))).toBe('strawberry');
expect(fiveFruit(table({ A: ['S2'], B: ['S3'], C: ['S1'] }))).toBeNull();
expect(fiveFruit(table({ A: ['S4'] }))).toBeNull();
});
test('다른 과일이 섞여도 한 과일이 정확히 5면 정답', () => {
expect(fiveFruit(table({ A: ['S2'], B: ['S3'], C: ['B4'] }))).toBe('strawberry');
expect(fiveFruit(table({ A: ['L1'], B: ['P5'], C: ['L3'] }))).toBe('plum');
expect(fiveFruit(table({ A: ['B5'] }))).toBe('banana');
});
test('맨 위 카드만 센다(아래 카드 무시)', () => {
expect(fiveFruit(table({ A: ['S5', 'B1'], B: ['L2'] }))).toBeNull();
expect(fiveFruit(table({ A: ['S5', 'B1'], B: ['B4'] }))).toBe('banana');
});
test('탈락자의 공개 더미 맨 위도 합산', () => {
const s = table({ A: ['L2'], C: ['L3'] });
s.active = ['A', 'B'];
s.eliminated = ['C'];
s.outReason = { C: 'empty' };
expect(fiveFruit(s)).toBe('lime');
const out = act(s, 'B', ringA(s), START + 100);
expect(out.decks.B).toHaveLength(3 + 2);
expect(out.faceUp.C).toEqual([]);
});
});
// ---------------------------------------------------------------- collecting
describe('정답 수거 (§5.2)', () => {
test('좌석 순으로 각 공개 더미를 아래부터, 종 밑 카드는 마지막 → 덱 맨 아래, 정답자가 다음 차례', () => {
const s = rig(['A', 'B', 'C'], { decks: { A: [2], B: [2], C: [2] }, faceUp: { A: ['P1', 'B3'], B: ['S2'], C: ['L1', 'L4', 'B2'] }, pot: ['P2', 'P3'], turn: 'C' });
const before = { A: [...s.faceUp.A!], B: [...s.faceUp.B!], C: [...s.faceUp.C!], pot: [...s.pot], deck: [...s.decks.B!] };
const { state: n, events } = applyE(s, 'B', ringA(s), START + 500);
expect(n.decks.B).toEqual([...before.A, ...before.B, ...before.C, ...before.pot, ...before.deck]);
expect(n.pot).toEqual([]);
for (const p of ['A', 'B', 'C']) expect(n.faceUp[p]).toEqual([]);
expect(n.turn).toBe('B');
expect(n.turnStartedAt).toBe(START + 500);
expect(n.flipAllowedAt).toBe(START + 500 + 600);
expect(n.tableVersion).toBe(s.tableVersion + 1);
expect(n.correctCount.B).toBe(1);
expect(n.lastBell).toMatchObject({ player: 'B', correct: true, fruit: 'banana', cards: 8 });
expect(events).toContainEqual({ type: 'ring', player: 'B', correct: true, fruit: 'banana', cards: 8 });
conserved(n);
});
test('동시 종: 같은 버전 정답 종 2개 → 먼저 도착한 사람만 수거, 두 번째는 "늦었어요" 무벌칙', () => {
// §5.5: A 바나나 3, B 딸기 2, C 바나나 2 → 바나나 5.
const s = rig(['A', 'B', 'C'], { decks: { A: [5], B: [5], C: [5] }, faceUp: { A: ['B3'], B: ['S2'], C: ['B2'] }, pot: [2], turn: 'A' });
const v = s.tableVersion;
const afterB = act(s, 'B', { type: 'ring', version: v }, START + 1000);
expect(afterB.decks.B).toHaveLength(5 + 3 + 2);
const late = hg.validate(afterB, 'A', { type: 'ring', version: v });
expect(late).toEqual({ ok: false, reason: '늦었어요! 다른 사람이 먼저 종을 쳤어요.' });
expect(afterB.decks.A).toHaveLength(5);
expect(afterB.wrongCount.A).toBe(0);
// A ring for a table that changed by a flip instead.
const flipped = act(afterB, 'B', { type: 'flip' }, START + 1000 + 600);
expect(hg.validate(flipped, 'A', { type: 'ring', version: v + 1 })).toEqual({ ok: false, reason: '늦었어요! 그 사이에 카드가 바뀌었어요.' });
});
test('§5.5 예시: 이후 A가 바나나 4 상태에서 착각해 종 → 오답 → B와 C에게 1장씩', () => {
const s = rig(['A', 'B', 'C'], { decks: { A: [6], B: [6], C: [6] }, faceUp: { A: ['B4'] }, turn: 'B' });
const n = act(s, 'A', ringA(s), START + 100);
expect(n.decks.A).toHaveLength(4);
expect(n.decks.B).toHaveLength(7);
expect(n.decks.C).toHaveLength(7);
expect(n.turn).toBe('B'); // turn unchanged
expect(n.tableVersion).toBe(s.tableVersion); // table unchanged
// the given cards go to the bottom of the receivers' decks (from the top of the giver's deck)
expect(n.decks.B![0]).toBe(s.decks.A![5]!);
expect(n.decks.C![0]).toBe(s.decks.A![4]!);
conserved(n);
});
});
// ---------------------------------------------------------------- wrong bells and elimination
describe('오답 벌칙과 탈락 (§5.2, §5.3)', () => {
test('4인에서 덱 2장인 사람이 오답 → 시계 방향 2명에게만 1장씩 주고 즉시 탈락', () => {
const s = rig(['A', 'B', 'C', 'D'], { decks: { A: [5], B: [2], C: [5], D: [5] }, faceUp: { A: ['S1'] }, turn: 'A' });
const n = act(s, 'B', ringA(s), START + 100);
expect(n.decks.B).toEqual([]);
expect(n.decks.C).toHaveLength(6);
expect(n.decks.D).toHaveLength(6);
expect(n.decks.A).toHaveLength(5);
expect(n.active).toEqual(['A', 'C', 'D']);
expect(n.eliminated).toEqual(['B']);
expect(n.outReason.B).toBe('penalty');
conserved(n);
});
test('정확히 다 줘서 0장 → 차례가 올 때까지 참가, 차례가 오면 탈락', () => {
const s = rig(['A', 'B', 'C', 'D'], { decks: { A: [5], B: [3], C: [5], D: [5] }, faceUp: { A: ['S1'] }, turn: 'A' });
let n = act(s, 'B', ringA(s), START + 100);
expect(n.decks.B).toEqual([]);
expect(n.active).toContain('B');
n = act(n, 'A', { type: 'flip' }, START + 1000); // A → B's turn with an empty deck
expect(n.active).not.toContain('B');
expect(n.outReason.B).toBe('empty');
expect(n.turn).toBe('C');
});
test('덱 0장이어도 차례 전에 정답 종 → 카드를 얻고 계속 참가', () => {
const s = rig(['A', 'B', 'C'], { decks: { A: [4], B: [], C: [4] }, faceUp: { A: ['L2'], B: ['L3'] }, turn: 'A' });
expect(hg.legalActions!(s, 'B').some((a) => a.type === 'ring')).toBe(true);
const n = act(s, 'B', ringA(s), START + 100);
expect(n.decks.B).toHaveLength(2);
expect(n.turn).toBe('B');
expect(n.active).toContain('B');
});
test('덱 0장인 사람의 오답 → 줄 카드가 없어 즉시 탈락', () => {
const s = rig(['A', 'B', 'C'], { decks: { A: [4], B: [], C: [4] }, faceUp: { A: ['L2'] }, turn: 'A' });
const n = act(s, 'B', ringA(s), START + 100);
expect(n.active).toEqual(['A', 'C']);
expect(n.outReason.B).toBe('penalty');
});
test('차례인 사람이 오답으로 덱이 0장이 되면 탈락하고 다음 사람 차례', () => {
const s = rig(['A', 'B', 'C'], { decks: { A: [2], B: [4], C: [4] }, faceUp: { B: ['L2'] }, turn: 'A' });
const n = act(s, 'A', ringA(s), START + 100);
expect(n.active).toEqual(['B', 'C']);
expect(n.turn).toBe('B');
});
test('한 명만 남으면 lastStanding 종료: 순위는 탈락 역순, 점수 3·2·1', () => {
const s = rig(['A', 'B', 'C'], { decks: { A: [1], B: [5], C: [] }, faceUp: { A: ['S1'] }, turn: 'A' });
let n = act(s, 'A', { type: 'flip' }, START + 100); // A: 0 cards, B's turn
expect(n.turn).toBe('B');
n = act(n, 'C', ringA(n), START + 200); // C wrong with 0 cards → out
n = act(n, 'A', ringA(n), START + 300); // A wrong with 0 cards → out → B wins
expect(n.phase).toBe('ended');
const r = hg.result(n)!;
expect(r.ranking).toEqual([['B'], ['A'], ['C']]);
expect(r.scores).toEqual({ B: 3, A: 2, C: 1 });
expect(n.final!.endReason).toBe('lastStanding');
expect(r.summary).toContain('이겼어요');
});
test('틀린 종 잠금: 잠금 중 종은 무시(벌칙 없음), 잠금이 끝나면 다시 칠 수 있어요', () => {
const s = rig(['A', 'B', 'C'], { decks: { A: [6], B: [6], C: [6] }, faceUp: { A: ['S1'] }, turn: 'A' });
let n = act(s, 'B', ringA(s), START + 100);
expect(n.lockedUntil.B).toBe(START + 1100);
const { state: locked, events } = applyE(n, 'B', ringA(n), START + 600);
expect(events).toEqual([{ type: 'locked', player: 'B' }]);
expect(locked.decks.B).toHaveLength(4);
n = act(locked, 'B', ringA(locked), START + 1100);
expect(n.decks.B).toHaveLength(2);
});
});
// ---------------------------------------------------------------- flipping and timers
describe('뒤집기와 시간 (§5.1, §8.5)', () => {
test('flipGuardMs: 가드 중 뒤집기는 예약 → 가드 끝에 실행, 테이블 버전은 1번만 증가', () => {
const s = rig(['A', 'B', 'C'], { decks: { A: [3], B: [3], C: [3] }, turn: 'A' });
let n = act(s, 'A', { type: 'flip' }, START + 100);
expect(n.tableVersion).toBe(s.tableVersion + 1);
expect(n.turn).toBe('B');
expect(n.flipAllowedAt).toBe(START + 700);
const { state: q, events } = applyE(n, 'B', { type: 'flip' }, START + 300);
expect(events).toEqual([{ type: 'flipQueued', player: 'B' }]);
expect(q.tableVersion).toBe(n.tableVersion);
expect(q.pendingFlip).toBe('B');
expect(hg.validate(q, 'B', { type: 'flip' }).ok).toBe(false); // no double queue
expect(hg.deadline!(q)).toBe(START + 700);
// A bell for the table everyone still sees is judged on that table.
expect(hg.validate(q, 'C', ringA(q)).ok).toBe(true);
n = act(q, 'B', hg.onTimeout(q, 'B'), START + 700);
expect(n.tableVersion).toBe(q.tableVersion + 1);
expect(n.faceUp.B).toHaveLength(1);
expect(n.turn).toBe('C');
expect(n.pendingFlip).toBeNull();
});
test('뒤집기 시간 초과 → 서버가 자동으로 뒤집기 (kidMode는 8초)', () => {
const s = rig(['A', 'B'], { decks: { A: [3], B: [3] }, turn: 'A' });
expect(hg.deadline!(s)).toBe(START + 3000);
expect(hg.timeoutPlayers!(s)).toEqual(['A']);
expect(hg.onTimeout(s, 'A')).toEqual({ type: 'tick' });
const n = act(s, 'A', { type: 'tick' }, START + 3000);
expect(n.faceUp.A).toHaveLength(1);
expect(n.turn).toBe('B');
expect(hg.deadline!(n)).toBe(START + 6000);
const kid = make({ kidMode: true });
const k = act(kid, kid.turn, { type: 'tick' }, START);
expect(k.turnDeadline).toBe(START + 8000);
});
test('자리 비움(연결 끊김): timeoutNotBefore로 가드·1초는 지키며 계속 자동 진행', () => {
const s = rig(['A', 'B'], { decks: { A: [3], B: [3] }, turn: 'A' });
expect(hg.timeoutNotBefore!(s)).toBe(START + AWAY_FLIP_MS);
// Room decided A is away and fires at the not-before time: A's card is flipped.
let n = act(s, 'A', { type: 'tick' }, START + AWAY_FLIP_MS);
expect(n.faceUp.A).toHaveLength(1);
expect(hg.timeoutNotBefore!(n)).toBe(START + AWAY_FLIP_MS + AWAY_FLIP_MS);
n = act(n, 'B', { type: 'tick' }, START + 2 * AWAY_FLIP_MS);
expect(n.faceUp.B).toHaveLength(1);
// A queued flip only waits for the guard.
const q = act(n, 'A', { type: 'flip' }, START + 2 * AWAY_FLIP_MS + 10);
expect(hg.timeoutNotBefore!(q)).toBe(q.flipAllowedAt);
});
test('autoFlip: flip 액션은 거부, 서버가 flipSeconds 간격으로 뒤집기', () => {
const s = rig(['A', 'B'], { decks: { A: [3], B: [3] }, turn: 'A' }, { autoFlip: true, flipSeconds: 2 });
expect(hg.validate(s, 'A', { type: 'flip' })).toEqual({ ok: false, reason: '이 방은 카드가 자동으로 뒤집혀요.' });
expect(hg.legalActions!(s, 'A').some((a) => a.type === 'flip')).toBe(false);
const n = act(s, 'A', { type: 'tick' }, START + 2000);
expect(n.faceUp.A).toHaveLength(1);
expect(n.turnDeadline).toBe(START + 4000);
});
test('시간 제한 만료: 보유 카드(덱 + 자기 공개 더미) 순, 동점은 정답 횟수, 그래도 같으면 공동', () => {
const s = rig(['A', 'B', 'C', 'D'], { decks: { A: [3], B: [5], C: [4], D: [4] }, faceUp: { A: ['S1', 'S1'], C: ['P1'], D: ['P1'] }, turn: 'A' }, { timeLimitMin: 5 });
s.correctCount = { A: 0, B: 0, C: 2, D: 2 };
// A 5 (3 + 2), B 5, C 5, D 5 → A,B (0 correct) below C,D (2 correct)
expect(hg.deadline!(s)).toBe(Math.min(s.endsAt!, s.turnDeadline));
const n = act(s, 'B', ringA(s), s.endsAt!); // any action at/after the end time ends the game first
expect(n.phase).toBe('ended');
expect(n.final!.endReason).toBe('timeLimit');
expect(n.decks.B).toHaveLength(5); // the bell was not judged
const r = hg.result(n)!;
expect(r.ranking).toEqual([['C', 'D'], ['A', 'B']]);
expect(n.final!.ranking.map((x) => [x.id, x.rank, x.cards])).toEqual([
['C', 1, 5],
['D', 1, 5],
['A', 3, 5],
['B', 3, 5],
]);
expect(r.summary).toContain('시간이 다 됐어요');
});
});
// ---------------------------------------------------------------- endings and house rules
describe('official 종료와 하우스 룰 (§6, §7)', () => {
test('official 3인 → 2인 남은 뒤 첫 정답 종에서 종료', () => {
const s = rig(['A', 'B', 'C'], { decks: { A: [1, 'L1'], B: [], C: [2, 'S2'] }, faceUp: { A: ['S1'], B: ['S3'] }, turn: 'A' }, { endMode: 'official' });
let n = act(s, 'A', { type: 'flip' }, START + 100); // A: L1 → B's turn with 0 cards → out → 2 left
expect(n.active).toEqual(['A', 'C']);
expect(n.officialDuel).toBe(true);
expect(n.turn).toBe('C');
n = act(n, 'C', { type: 'flip' }, START + 800); // C: S2 + B's S3 = strawberry 5
expect(n.phase).toBe('playing');
expect(fiveFruit(n)).toBe('strawberry');
const end = act(n, 'A', ringA(n), START + 900);
expect(end.phase).toBe('ended');
expect(end.final!.endReason).toBe('official');
expect(end.final!.ranking.map((r) => [r.id, r.rank, r.cards])).toEqual([
['A', 1, 5],
['C', 2, 2],
['B', 3, 0],
]);
expect(hg.result(end)!.scores).toEqual({ A: 3, C: 2, B: 1 });
});
test('official 2인 상태 오답 → 상대가 펼쳐진 카드를 모두 가져가고 종료', () => {
const s = rig(['A', 'B', 'C'], { decks: { A: [4], B: [4], C: [] }, faceUp: { A: ['L1'], C: ['P2'] }, pot: [1], turn: 'A' }, { endMode: 'official' });
s.active = ['A', 'B'];
s.eliminated = ['C'];
s.outReason = { C: 'empty' };
s.officialDuel = true;
const n = act(s, 'A', ringA(s), START + 100);
expect(n.phase).toBe('ended');
expect(n.final!.endReason).toBe('official');
expect(n.decks.B).toHaveLength(4 + 3);
expect(n.faceUp.A).toEqual([]);
expect(hg.result(n)!.ranking).toEqual([['B'], ['A'], ['C']]);
});
test('처음부터 2인이면 official도 lastStanding처럼 진행(오답으로 끝나지 않음)', () => {
const s = rig(['A', 'B'], { decks: { A: [4], B: [4] }, faceUp: { A: ['L1'] }, turn: 'A' }, { endMode: 'official' });
const n = act(s, 'A', ringA(s), START + 100);
expect(n.phase).toBe('playing');
expect(n.officialDuel).toBe(false);
expect(n.decks.B).toHaveLength(5);
});
test('duelWrongLoses: 1대1 오답은 즉시 패배', () => {
const s = rig(['A', 'B'], { decks: { A: [4], B: [4] }, faceUp: { A: ['L1'] }, turn: 'A' }, { duelWrongLoses: true });
const n = act(s, 'B', ringA(s), START + 100);
expect(n.phase).toBe('ended');
expect(hg.result(n)!.ranking).toEqual([['A'], ['B']]);
expect(n.outReason.B).toBe('duel');
conserved(n);
});
test('strikeOut: 오답 3번이면 탈락(남은 덱은 종 밑으로)', () => {
let s = rig(['A', 'B', 'C'], { decks: { A: [10], B: [10], C: [10] }, faceUp: { A: ['L1'] }, turn: 'A' }, { strikeOut: true, wrongRingLockoutMs: 0 });
s = act(s, 'B', ringA(s), START + 1);
s = act(s, 'B', ringA(s), START + 2);
expect(s.active).toContain('B');
s = act(s, 'B', ringA(s), START + 3);
expect(s.active).not.toContain('B');
expect(s.outReason.B).toBe('strikeOut');
expect(s.pot).toHaveLength(4);
conserved(s);
});
test('penaltyEscalation: 두 번째 오답은 1인당 2장', () => {
let s = rig(['A', 'B', 'C'], { decks: { A: [10], B: [10], C: [10] }, faceUp: { A: ['L1'] }, turn: 'A' }, { penaltyEscalation: true, wrongRingLockoutMs: 0 });
s = act(s, 'B', ringA(s), START + 1);
expect(s.decks.B).toHaveLength(8);
s = act(s, 'B', ringA(s), START + 2);
expect(s.decks.B).toHaveLength(4);
expect(s.decks.C).toHaveLength(13);
expect(s.decks.A).toHaveLength(13);
});
test('wrongToPot: 벌칙 카드는 종 밑으로, 다음 정답자가 가져감', () => {
let s = rig(['A', 'B', 'C'], { decks: { A: [5], B: [5], C: [5] }, faceUp: { A: ['L1'] }, turn: 'A' }, { wrongToPot: true });
s = act(s, 'B', ringA(s), START + 1);
expect(s.pot).toHaveLength(2);
expect(s.decks.A).toHaveLength(5);
s.faceUp.C = [CARDS.find((c) => c.fruit === 'lime' && c.count === 4 && s.removed.includes(c.id))!.id];
s.removed = s.removed.filter((id) => id !== s.faceUp.C![0]);
s = act(s, 'C', ringA(s), START + 2);
expect(s.decks.C).toHaveLength(5 + 2 + 2);
});
test('latencyComp light: 50ms 창 안의 정답 종을 RTT/2(최대 80ms) 보정 시각으로 비교', () => {
let s = rig(['A', 'B', 'C'], { decks: { A: [3], B: [3], C: [3] }, faceUp: { A: ['B5'] }, turn: 'A' }, { latencyComp: 'light' });
s = act(s, 'B', { type: 'ring', version: s.tableVersion, rtt: 20 }, START + 100); // adj 90
expect(s.ringWindow).not.toBeNull();
expect(s.tableVersion).toBe(5);
expect(hg.deadline!(s)).toBe(START + 100 + RING_WINDOW_MS);
expect(hg.validate(s, 'B', ringA(s)).ok).toBe(false); // already rang
// A flip during the window is queued, not applied.
s = act(s, 'A', { type: 'flip' }, START + 110);
expect(s.pendingFlip).toBe('A');
s = act(s, 'C', { type: 'ring', version: s.tableVersion, rtt: 400 }, START + 130); // adj 130 - 80 = 50 → wins
const { state: n, events } = applyE(s, 'A', { type: 'tick' }, START + 150);
expect(n.ringWindow).toBeNull();
expect(n.decks.C).toHaveLength(4);
expect(n.turn).toBe('C');
expect(n.pendingFlip).toBeNull();
expect(events).toContainEqual({ type: 'late', player: 'B' });
});
});
// ---------------------------------------------------------------- leaving, views, determinism
describe('나가기, 비공개 정보, 결정성 (§8.3, §10)', () => {
test('나가면 덱은 종 밑으로, 공개 더미는 테이블에 남고 차례가 넘어가요', () => {
const s = rig(['A', 'B', 'C'], { decks: { A: [3], B: [4], C: [3] }, faceUp: { A: ['S2'] }, turn: 'A' });
expect(hg.onLeave!(s, 'A')).toEqual({ type: 'leave' });
const n = act(s, 'A', { type: 'leave' }, START + 10);
expect(n.pot).toHaveLength(3);
expect(n.faceUp.A).toHaveLength(1);
expect(n.outReason.A).toBe('left');
expect(n.turn).toBe('B');
expect(hg.onLeave!(n, 'A')).toBeNull();
const end = act(n, 'B', { type: 'leave' }, START + 20);
expect(end.phase).toBe('ended');
expect(hg.result(end)!.ranking).toEqual([['C'], ['B'], ['A']]);
expect(hg.result(end)!.summary).toBe('다른 사람이 모두 나가서 남은 사람이 이겼어요');
});
test('view에는 덱 내용(본인 포함)·종 밑·가려진 카드가 없다: 숨긴 카드를 섞어도 view가 같다', () => {
const r = new SeededRng([1, 2, 3, 4]);
for (let seed = 1; seed <= 30; seed++) {
let s = make({}, ['A', 'B', 'C', 'D', 'E'], seed);
s = act(s, s.turn, { type: 'tick' }, START);
for (let i = 0; i < 12 && s.phase === 'playing'; i++) s = act(s, s.turn, { type: 'flip' }, START + 1000 * (i + 1));
const shuffled = structuredClone(s);
for (const p of s.order) {
shuffled.decks[p] = r.shuffle(s.decks[p]!);
const pile = s.faceUp[p]!;
shuffled.faceUp[p] = [...r.shuffle(pile.slice(0, -1)), ...pile.slice(-1)];
}
shuffled.pot = r.shuffle(s.pot);
// Swap hidden cards between players too (same counts).
const a = shuffled.decks[s.order[0]!]!;
const b = shuffled.decks[s.order[1]!]!;
if (a.length && b.length) [a[0], b[0]] = [b[0]!, a[0]!];
for (const viewer of [null, ...s.order]) expect(JSON.stringify(hg.view(shuffled, viewer))).toBe(JSON.stringify(hg.view(s, viewer)));
for (const viewer of s.order) checkNoLeak(hg, s, viewer, s.order);
}
});
test('이벤트에는 공개 카드(방금 뒤집은 카드)만 담긴다', () => {
let s = make({}, ['A', 'B', 'C'], 5);
s = act(s, s.turn, { type: 'tick' }, START);
const p = s.turn;
const { state: n, events } = applyE(s, p, { type: 'flip' }, START + 100);
const c = cardOf(n.faceUp[p]!.at(-1)!);
expect(events).toEqual([{ type: 'flip', player: p, fruit: c.fruit, count: c.count, version: n.tableVersion }]);
});
test('같은 시드 + 액션 로그(serverTs 포함) 재생 → 같은 결과', () => {
const a = fastPlayout(['A', 'B', 'C', 'D'], hg.optionsSchema.parse({}), seedN(42), 5000);
const replay = (log: { p: PlayerId; a: HalliGalliAction; now: number }[]) => {
let s = hg.setup({ players: ['A', 'B', 'C', 'D'], options: hg.optionsSchema.parse({}), rng: SeededRng.fromSeed(seedN(42)), now: 1_700_000_000_000 });
for (const x of log) s = hg.apply(s, x.p, x.a, { rng: rng(), now: x.now }).state;
return s;
};
expect(JSON.stringify(replay(a.log))).toBe(JSON.stringify(a.final));
});
});
// ---------------------------------------------------------------- random playouts
interface FastReport {
steps: number;
finished: boolean;
log: { p: PlayerId; a: HalliGalliAction; now: number }[];
final: HalliGalliState;
}
function invariants(s: HalliGalliState): void {
conserved(s);
const all = [...s.active, ...s.eliminated].sort();
if (JSON.stringify(all) !== JSON.stringify([...s.order].sort())) throw new Error('active + eliminated != players');
if (s.phase !== 'ended') {
if (!s.active.includes(s.turn)) throw new Error(`turn ${s.turn} is not active`);
if (s.phase === 'playing' && s.decks[s.turn]!.length === 0) throw new Error(`turn ${s.turn} has no cards`);
if (s.active.length < 2) throw new Error('game should have ended');
}
}
/**
* Same idea as `randomPlayout`, with time moving 0.1–1.6s per step and bells biased towards real fives
* (so collections, guards, lockouts and late bells all happen). Checks invariants and onTimeout legality every step.
*/
function fastPlayout(players: PlayerId[], options: HalliGalliOptions, seed: RngState, maxSteps: number): FastReport {
const r = SeededRng.fromSeed(seed);
const chooser = SeededRng.fromSeed([seed[3], seed[2], seed[1], seed[0] ^ 0x9e3779b9]);
let now = 1_700_000_000_000;
let s = hg.setup({ players, options, rng: r, now });
const log: FastReport['log'] = [];
for (let step = 0; step < maxSteps; step++) {
invariants(s);
if (step % 25 === 0) for (const p of [players[step % players.length]!]) checkNoLeak(hg, s, p, players);
for (const p of hg.timeoutPlayers!(s)) {
const v = hg.validate(s, p, hg.onTimeout(s, p));
if (!v.ok) throw new Error(`onTimeout rejected for ${p}: ${v.reason}`);
}
if (hg.result(s)) return { steps: step, finished: true, log, final: s };
now += 100 + chooser.int(1500);
let actor: PlayerId;
let action: HalliGalliAction;
const five = s.phase === 'playing' && fiveFruit(s) !== null;
const roll = chooser.int(10);
if (five && roll < 7) {
actor = s.active[chooser.int(s.active.length)]!;
action = { type: 'ring', version: s.tableVersion, rtt: chooser.int(300) };
// A stale bell sometimes (rejected by validate, as the server would).
if (roll === 0 && s.tableVersion > 0) action = { type: 'ring', version: s.tableVersion - 1 };
} else if (roll < 8) {
actor = s.turn;
action = hg.onTimeout(s, actor);
} else {
const actives = hg.activePlayers(s);
actor = actives[chooser.int(actives.length)]!;
const legal = hg.legalActions!(s, actor);
action = legal.length > 0 ? legal[chooser.int(legal.length)]! : hg.onTimeout(s, actor);
}
if (chooser.int(400) === 0 && s.active.length > 2) {
actor = s.active[chooser.int(s.active.length)]!;
action = { type: 'leave' };
}
if (!hg.validate(s, actor, action).ok) {
if (action.type !== 'ring') throw new Error(`generated action rejected: ${JSON.stringify(action)} ${hg.validate(s, actor, action).ok}`);
continue;
}
const out = hg.apply(s, actor, action, { rng: r, now });
for (const e of out.events) if (JSON.stringify(e).includes('"hg')) throw new Error('event carries a card key');
s = out.state;
log.push({ p: actor, a: action, now });
}
return { steps: maxSteps, finished: false, log, final: s };
}
const PRESETS: Partial<HalliGalliOptions>[] = [
{},
{ timeLimitMin: null },
{ endMode: 'official', flipGuardMs: 1000, wrongRingLockoutMs: 2000 },
{ latencyComp: 'light', leftoverCards: 'remove', flipSeconds: 2 },
{ penaltyEscalation: true, strikeOut: true, timeLimitMin: 5 },
{ wrongToPot: true, autoFlip: true, flipGuardMs: 400, wrongRingLockoutMs: 0 },
{ duelWrongLoses: true, kidMode: true, timeLimitMin: 15 },
];
describe('random playouts (§10, docs/09 §7)', () => {
test('1,000 seeded games: finish, 56 cards conserved, no leaks, onTimeout always legal, deterministic', () => {
let finished = 0;
let collects = 0;
let wrongs = 0;
for (let i = 1; i <= 1000; i++) {
const n = 2 + (i % 5);
const players = Array.from({ length: n }, (_, k) => `P${k}`);
const options = hg.optionsSchema.parse(PRESETS[i % PRESETS.length]!);
const a = fastPlayout(players, options, seedN(i), 20_000);
if (!a.finished) throw new Error(`seed ${i} (${n} players) did not finish`);
finished++;
const res = hg.result(a.final)!;
expect(res.ranking.flat().sort()).toEqual([...players].sort());
collects += Object.values(a.final.correctCount).reduce((x, y) => x + y, 0);
wrongs += Object.values(a.final.wrongCount).reduce((x, y) => x + y, 0);
if (i % 50 === 0) {
const b = fastPlayout(players, options, seedN(i), 20_000);
expect(JSON.stringify(b.log)).toBe(JSON.stringify(a.log));
expect(JSON.stringify(b.final)).toBe(JSON.stringify(a.final));
}
}
expect(finished).toBe(1000);
expect(collects).toBeGreaterThan(1000);
expect(wrongs).toBeGreaterThan(1000);
}, 60_000);
test.each([
[2, {}],
[3, { endMode: 'official' }],
[4, { latencyComp: 'light' }],
[6, { timeLimitMin: null, wrongToPot: true }],
] as const)('engine randomPlayout (per-step leak / round-trip checks): %i players %o', (n, opts) => {
const players = Array.from({ length: n }, (_, k) => `P${k}`);
const options = hg.optionsSchema.parse(opts);
const conserve = (st: unknown) => invariants(st as HalliGalliState);
for (let i = 1; i <= 5; i++) {
const a = randomPlayout(hg, { players, options, seed: seedN(500 + i), maxSteps: 20_000, invariant: conserve, maxApplyMs: 20 });
expect(a.finished).toBe(true);
if (i === 1) {
const b = randomPlayout(hg, { players, options, seed: seedN(500 + i), maxSteps: 20_000, maxApplyMs: 20 });
expect(JSON.stringify(b.trace)).toBe(JSON.stringify(a.trace));
}
}
});
});

View File

@@ -0,0 +1,609 @@
/** 할리갈리 (docs/games/halli-galli.md). Pure and deterministic; state is JSON; time only via ctx.now. */
import { fail, ok, type GameDefinition, type GameEvent, type GameResult, type PlayerId } from '@bg/engine';
import {
CARDS,
DECK_SIZE,
FRUITS,
cardOf,
halliGalliActionSchema,
halliGalliOptionsSchema,
type EndReason,
type Fruit,
type HalliGalliAction,
type HalliGalliFinal,
type HalliGalliOptions,
type HalliGalliState,
type HalliGalliView,
type LogEntry,
type OutReason,
type RankRow,
} from './types';
export * from './types';
type S = HalliGalliState;
type A = HalliGalliAction;
export const COUNTDOWN_MS = 3000;
/** `latencyComp: 'light'` bell window (§5.4). */
export const RING_WINDOW_MS = 50;
/** Max credit `latencyComp: 'light'` gives for half the RTT (§5.4). */
export const MAX_LATENCY_CREDIT_MS = 80;
/** Away players (room grace period over) flip after this much of their turn (§8.5 "1초로 줄여"). */
export const AWAY_FLIP_MS = 1000;
const LOG_KEEP = 30;
const POINTS = [3, 2, 1];
export const flipMs = (o: HalliGalliOptions): number => (o.kidMode ? 8 : o.flipSeconds) * 1000;
/** Public key of a visible card; secretsOf uses the same form so leak checks are meaningful. */
export const cardKey = (id: number): string => `hg${id}`;
// ---------------------------------------------------------------- table math
/** Sum of fruits over every face-up pile's top card (eliminated players' piles included, §5.2). */
export function fruitTotals(s: Pick<S, 'order' | 'faceUp'>): Record<Fruit, number> {
const t: Record<Fruit, number> = { strawberry: 0, banana: 0, lime: 0, plum: 0 };
for (const p of s.order) {
const pile = s.faceUp[p]!;
if (pile.length === 0) continue;
const c = cardOf(pile[pile.length - 1]!);
t[c.fruit] += c.count;
}
return t;
}
/** A fruit totalling exactly five, if any (6+ and 4- do not count). */
export function fiveFruit(s: Pick<S, 'order' | 'faceUp'>): Fruit | null {
const t = fruitTotals(s);
for (const f of FRUITS) if (t[f] === 5) return f;
return null;
}
export const cardsHeld = (s: S, p: PlayerId): number => (s.decks[p]?.length ?? 0) + (s.faceUp[p]?.length ?? 0);
/** All 56 cards, wherever they are (test invariant). */
export function allCardIds(s: S): number[] {
return [...s.order.flatMap((p) => [...s.decks[p]!, ...s.faceUp[p]!]), ...s.pot, ...s.removed];
}
// ---------------------------------------------------------------- helpers
function clone(s: S): S {
return {
...s,
order: [...s.order],
decks: Object.fromEntries(Object.entries(s.decks).map(([k, v]) => [k, [...v]])),
faceUp: Object.fromEntries(Object.entries(s.faceUp).map(([k, v]) => [k, [...v]])),
pot: [...s.pot],
removed: [...s.removed],
active: [...s.active],
eliminated: [...s.eliminated],
outReason: { ...s.outReason },
lockedUntil: { ...s.lockedUntil },
wrongCount: { ...s.wrongCount },
correctCount: { ...s.correctCount },
ringWindow: s.ringWindow ? { ...s.ringWindow, rings: s.ringWindow.rings.map((r) => ({ ...r })) } : null,
lastBell: s.lastBell ? { ...s.lastBell } : null,
log: [...s.log],
final: s.final,
};
}
interface Ctx {
now: number;
events: GameEvent[];
}
type LogBody = LogEntry extends infer E ? (E extends LogEntry ? Omit<E, 'n'> : never) : never;
function log(s: S, e: LogBody): void {
s.logNo += 1;
s.log.push({ n: s.logNo, ...e } as LogEntry);
if (s.log.length > LOG_KEEP) s.log.splice(0, s.log.length - LOG_KEEP);
}
/** Other active players clockwise, starting after `p`. */
function othersClockwise(s: S, p: PlayerId): PlayerId[] {
const i = s.order.indexOf(p);
const out: PlayerId[] = [];
for (let k = 1; k < s.order.length; k++) {
const q = s.order[(i + k) % s.order.length]!;
if (s.active.includes(q)) out.push(q);
}
return out;
}
function startTurn(s: S, p: PlayerId, now: number): void {
s.turn = p;
s.turnStartedAt = now;
s.turnDeadline = now + flipMs(s.options);
s.pendingFlip = null;
}
/** Removes `p` from play; returns true if the game ended. */
function eliminate(s: S, p: PlayerId, why: OutReason, ctx: Ctx): boolean {
if (!s.active.includes(p)) return s.phase === 'ended';
s.active = s.active.filter((q) => q !== p);
s.eliminated.push(p);
s.outReason[p] = why;
if (s.ringWindow) s.ringWindow.rings = s.ringWindow.rings.filter((r) => r.player !== p);
ctx.events.push({ type: 'out', player: p, why });
log(s, { t: 'out', p, why });
if (s.active.length <= 1) {
finish(s, 'lastStanding', ctx);
return true;
}
if (s.options.endMode === 'official' && s.order.length >= 3 && s.active.length === 2) s.officialDuel = true;
return false;
}
/** Passes the turn clockwise from `from`; players whose turn comes with an empty deck are out (§5.3). */
function advanceTurn(s: S, from: PlayerId, ctx: Ctx): void {
const n = s.order.length;
const i = s.order.indexOf(from);
for (let k = 1; k <= n; k++) {
const q = s.order[(i + k) % n]!;
if (!s.active.includes(q)) continue;
if (s.decks[q]!.length > 0) {
startTurn(s, q, ctx.now);
return;
}
if (eliminate(s, q, 'empty', ctx)) return;
}
// Unreachable while ≥ 2 players are active (someone holds cards); keep the game well-formed anyway.
finish(s, 'lastStanding', ctx);
}
function doFlip(s: S, p: PlayerId, ctx: Ctx): void {
const id = s.decks[p]!.pop();
if (id === undefined) throw new Error(`halli-galli: ${p} has no card to flip`);
s.faceUp[p]!.push(id);
s.tableVersion += 1;
s.lastChange = 'flip';
s.lastFlipBy = p;
s.pendingFlip = null;
s.flipAllowedAt = ctx.now + s.options.flipGuardMs;
const c = cardOf(id);
ctx.events.push({ type: 'flip', player: p, fruit: c.fruit, count: c.count, version: s.tableVersion });
advanceTurn(s, p, ctx);
}
/** Queues or performs the turn player's flip (guard / bell window aware). */
function flipOrQueue(s: S, p: PlayerId, ctx: Ctx): void {
if (s.ringWindow || ctx.now < s.flipAllowedAt) {
if (s.pendingFlip !== p) {
s.pendingFlip = p;
ctx.events.push({ type: 'flipQueued', player: p });
}
return;
}
doFlip(s, p, ctx);
}
/** Takes every face-up pile (seat order, bottom first) and then the pot to the bottom of `p`'s deck. */
function sweepTable(s: S, p: PlayerId): number {
const taken: number[] = [];
for (const q of s.order) {
taken.push(...s.faceUp[q]!);
s.faceUp[q] = [];
}
taken.push(...s.pot);
s.pot = [];
s.decks[p] = [...taken, ...s.decks[p]!];
s.tableVersion += 1;
s.lastChange = 'collect';
return taken.length;
}
function collect(s: S, p: PlayerId, fruit: Fruit, ctx: Ctx): void {
const cards = sweepTable(s, p);
s.correctCount[p] = (s.correctCount[p] ?? 0) + 1;
s.bellSeq += 1;
s.lastBell = { seq: s.bellSeq, player: p, correct: true, fruit, cards, at: ctx.now };
s.ringWindow = null;
ctx.events.push({ type: 'ring', player: p, correct: true, fruit, cards });
log(s, { t: 'collect', p, fruit, cards });
if (s.officialDuel) {
finish(s, 'official', ctx);
return;
}
// The winner flips next; the table must stay still for the guard time first.
startTurn(s, p, ctx.now);
s.flipAllowedAt = ctx.now + s.options.flipGuardMs;
}
function wrongBell(s: S, p: PlayerId, ctx: Ctx): void {
const o = s.options;
s.wrongCount[p] = (s.wrongCount[p] ?? 0) + 1;
s.lockedUntil[p] = ctx.now + o.wrongRingLockoutMs;
s.bellSeq += 1;
const bell = { seq: s.bellSeq, player: p, correct: false, cards: 0, at: ctx.now };
s.lastBell = bell;
// Two left: official ending / 1-on-1 house rule (§6, §7).
if (s.active.length === 2 && (s.officialDuel || o.duelWrongLoses)) {
const opp = s.active.find((q) => q !== p)!;
let cards: number;
if (s.officialDuel) {
cards = sweepTable(s, opp);
} else {
cards = s.decks[p]!.length;
s.decks[opp] = [...s.decks[p]!, ...s.decks[opp]!];
s.decks[p] = [];
}
bell.cards = cards;
ctx.events.push({ type: 'ring', player: p, correct: false, gave: 0, duel: true, by: opp });
log(s, { t: 'duelWrong', p, by: opp, cards });
if (s.officialDuel) finish(s, 'official', ctx);
else eliminate(s, p, 'duel', ctx);
return;
}
// Give one card (more with penaltyEscalation) to every other active player clockwise; short → out (§5.2).
const per = o.penaltyEscalation ? s.wrongCount[p]! : 1;
const to = othersClockwise(s, p);
let gave = 0;
let short = false;
outer: for (let r = 0; r < per; r++) {
for (const q of to) {
const id = s.decks[p]!.pop();
if (id === undefined) {
short = true;
break outer;
}
if (o.wrongToPot) s.pot.push(id);
else s.decks[q]!.unshift(id);
gave++;
}
}
bell.cards = gave;
ctx.events.push({ type: 'ring', player: p, correct: false, gave, toPot: o.wrongToPot });
log(s, { t: 'wrong', p, gave, toPot: o.wrongToPot });
let out: OutReason | null = null;
if (short) out = 'penalty';
else if (o.strikeOut && s.wrongCount[p]! >= 3) {
s.pot.push(...s.decks[p]!);
s.decks[p] = [];
out = 'strikeOut';
} else if (s.decks[p]!.length === 0 && s.turn === p) out = 'empty'; // it is already their turn and nothing is left to flip
if (!out) return;
const wasTurn = s.turn === p;
if (eliminate(s, p, out, ctx)) return;
if (wasTurn) advanceTurn(s, p, ctx);
}
/** Closes the `latencyComp` bell window: earliest adjusted time wins, ties by arrival (§5.4). */
function resolveWindow(s: S, ctx: Ctx): void {
const w = s.ringWindow!;
s.ringWindow = null;
const fruit = fiveFruit(s);
if (!fruit || w.rings.length === 0) return; // everyone in the window left
let best = w.rings[0]!;
for (const r of w.rings) if (r.adjTs < best.adjTs) best = r;
for (const r of w.rings) if (r !== best) ctx.events.push({ type: 'late', player: r.player });
collect(s, best.player, fruit, ctx);
}
function ring(s: S, p: PlayerId, version: number, rtt: number | undefined, ctx: Ctx): void {
if (s.ringWindow && ctx.now >= s.ringWindow.openedAt + RING_WINDOW_MS) resolveWindow(s, ctx);
if (s.phase !== 'playing') return;
if (version !== s.tableVersion) {
ctx.events.push({ type: 'late', player: p });
return;
}
if (ctx.now < (s.lockedUntil[p] ?? 0)) {
ctx.events.push({ type: 'locked', player: p });
return;
}
const fruit = fiveFruit(s);
if (!fruit) {
wrongBell(s, p, ctx);
return;
}
if (s.options.latencyComp === 'light') {
const adjTs = ctx.now - Math.min((rtt ?? 0) / 2, MAX_LATENCY_CREDIT_MS);
if (!s.ringWindow) s.ringWindow = { version, openedAt: ctx.now, rings: [] };
if (!s.ringWindow.rings.some((r) => r.player === p)) s.ringWindow.rings.push({ player: p, adjTs });
ctx.events.push({ type: 'ringWindow', player: p });
return;
}
collect(s, p, fruit, ctx);
}
/**
* Server timer. Runs whatever is due; for the turn player (whose clock the room drives) a tick that finds
* nothing else due means "time's up / away" → flip (queued behind the guard if needed).
*/
function tick(s: S, actor: PlayerId, ctx: Ctx): void {
if (s.phase === 'countdown') {
if (ctx.now < s.countdownEndsAt) return;
s.phase = 'playing';
startTurn(s, s.turn, ctx.now);
s.flipAllowedAt = ctx.now;
ctx.events.push({ type: 'start' });
return;
}
if (s.ringWindow) {
if (ctx.now >= s.ringWindow.openedAt + RING_WINDOW_MS) resolveWindow(s, ctx);
return;
}
const due = (s.pendingFlip !== null && ctx.now >= s.flipAllowedAt) || ctx.now >= Math.max(s.turnDeadline, s.flipAllowedAt);
if (actor === s.turn || due) flipOrQueue(s, s.turn, ctx);
}
function leave(s: S, p: PlayerId, ctx: Ctx): void {
// The leaver's face-down deck goes under the bell; their face-up pile stays on the table.
s.pot.push(...s.decks[p]!);
s.decks[p] = [];
const wasTurn = s.turn === p;
if (eliminate(s, p, 'left', ctx)) return;
if (wasTurn) advanceTurn(s, p, ctx);
}
// ---------------------------------------------------------------- ending
function finish(s: S, reason: EndReason, ctx: Ctx): void {
if (s.phase === 'ended') return;
s.phase = 'ended';
s.pendingFlip = null;
s.ringWindow = null;
const row = (id: PlayerId, rank: number): RankRow => ({ rank, id, cards: cardsHeld(s, id), correct: s.correctCount[id] ?? 0, wrong: s.wrongCount[id] ?? 0 });
const ranking: RankRow[] = [];
// Still-active players: by cards held, then correct bells; equal → shared rank (§6).
const alive = [...s.active].sort((a, b) => cardsHeld(s, b) - cardsHeld(s, a) || (s.correctCount[b] ?? 0) - (s.correctCount[a] ?? 0));
alive.forEach((id, i) => {
const prev = ranking[i - 1];
const tie = prev && cardsHeld(s, prev.id) === cardsHeld(s, id) && (s.correctCount[prev.id] ?? 0) === (s.correctCount[id] ?? 0);
ranking.push(row(id, tie ? prev.rank : i + 1));
});
// Eliminated players: last out ranks highest.
for (const id of [...s.eliminated].reverse()) ranking.push(row(id, ranking.length + 1));
const winners = ranking.filter((r) => r.rank === 1).length;
let summary: string;
if (reason === 'lastStanding') {
const allLeft = s.eliminated.every((p) => s.outReason[p] === 'left');
summary = allLeft ? '다른 사람이 모두 나가서 남은 사람이 이겼어요' : '끝까지 남아 카드를 지킨 사람이 이겼어요';
} else if (reason === 'official') {
summary = '두 사람이 남은 뒤 종으로 승부가 났어요. 카드가 가장 많은 사람이 이겼어요';
} else {
summary = '시간이 다 됐어요. 카드가 가장 많은 사람이 이겼어요';
}
if (winners > 1) summary += ` (공동 1등 ${winners}명)`;
s.final = { endReason: reason, ranking, summary } satisfies HalliGalliFinal;
ctx.events.push({ type: 'gameEnd', reason });
}
// ---------------------------------------------------------------- definition
const LATE_COLLECT = '늦었어요! 다른 사람이 먼저 종을 쳤어요.';
const LATE_FLIP = '늦었어요! 그 사이에 카드가 바뀌었어요.';
export const halliGalli: GameDefinition<S, A, HalliGalliView, HalliGalliOptions> = {
id: 'halli-galli',
nameKo: '할리갈리',
minPlayers: 2,
maxPlayers: 6,
stateVersion: 1,
defaultOptions: halliGalliOptionsSchema.parse({}),
optionsSchema: halliGalliOptionsSchema as unknown as GameDefinition<S, A, HalliGalliView, HalliGalliOptions>['optionsSchema'],
actionSchema: halliGalliActionSchema as unknown as GameDefinition<S, A, HalliGalliView, HalliGalliOptions>['actionSchema'],
setup({ players, options, rng, now }) {
if (players.length < 2 || players.length > 6) throw new Error(`halli-galli: ${players.length} players not allowed`);
// §4 / §8.4: seats, first player and the deck all come from the seeded RNG.
const order = rng.shuffle(players);
const first = order[rng.int(order.length)]!;
const deck = rng.shuffle(CARDS.map((c) => c.id));
const per = Math.floor(DECK_SIZE / order.length);
const decks: Record<PlayerId, number[]> = {};
order.forEach((p, i) => (decks[p] = deck.slice(i * per, (i + 1) * per)));
const rest = deck.slice(order.length * per);
const per0 = <T>(v: T) => Object.fromEntries(order.map((p) => [p, v])) as Record<PlayerId, T>;
const countdownEndsAt = now + COUNTDOWN_MS;
return {
options,
order,
decks,
faceUp: Object.fromEntries(order.map((p) => [p, [] as number[]])),
pot: options.leftoverCards === 'pot' ? rest : [],
removed: options.leftoverCards === 'remove' ? rest : [],
active: [...order],
eliminated: [],
outReason: {},
officialDuel: false,
turn: first,
turnStartedAt: now,
turnDeadline: countdownEndsAt + flipMs(options),
flipAllowedAt: countdownEndsAt,
pendingFlip: null,
tableVersion: 0,
lastChange: null,
lastFlipBy: null,
lockedUntil: per0(0),
wrongCount: per0(0),
correctCount: per0(0),
ringWindow: null,
phase: 'countdown',
countdownEndsAt,
startedAt: now,
endsAt: options.timeLimitMin === null ? null : countdownEndsAt + options.timeLimitMin * 60_000,
lastBell: null,
bellSeq: 0,
logNo: 0,
log: [],
final: null,
};
},
validate(s, actor, a) {
if (!s.order.includes(actor)) return fail('이 게임의 플레이어가 아니에요.');
if (s.phase === 'ended') return fail('게임이 이미 끝났어요.');
switch (a.type) {
case 'tick':
return actor === s.turn || s.active.includes(actor) ? ok : fail('시간 초과 대상이 아니에요.');
case 'leave':
return s.active.includes(actor) ? ok : fail('이미 게임에서 빠졌어요.');
case 'flip':
if (s.phase !== 'playing') return fail('곧 시작해요. 잠시만 기다려 주세요.');
if (s.options.autoFlip) return fail('이 방은 카드가 자동으로 뒤집혀요.');
if (s.turn !== actor) return fail('지금은 내 차례가 아니에요.');
if (s.pendingFlip === actor) return fail('곧 뒤집혀요. 잠시만 기다려 주세요.');
if (s.decks[actor]!.length === 0) return fail('뒤집을 카드가 없어요.');
return ok;
case 'ring':
if (s.phase !== 'playing') return fail('곧 시작해요. 잠시만 기다려 주세요.');
if (!s.active.includes(actor)) return fail('게임에서 빠져서 종을 칠 수 없어요.');
// §5.2 늦은 종: the client saw an older table → ignored without penalty.
if (a.version !== s.tableVersion) return fail(a.version < s.tableVersion && s.lastChange === 'collect' && a.version === s.tableVersion - 1 ? LATE_COLLECT : LATE_FLIP);
if (s.ringWindow?.rings.some((r) => r.player === actor)) return fail('이미 종을 쳤어요.');
return ok;
}
},
apply(prev, actor, a, { now }) {
const s = clone(prev);
const ctx: Ctx = { now, events: [] };
// §2 timeLimitMin: once time is up nothing else happens.
if (s.phase === 'playing' && s.endsAt !== null && now >= s.endsAt) {
finish(s, 'timeLimit', ctx);
return { state: s, events: ctx.events };
}
switch (a.type) {
case 'tick':
tick(s, actor, ctx);
break;
case 'flip':
flipOrQueue(s, actor, ctx);
break;
case 'ring':
ring(s, actor, a.version, a.rtt, ctx);
break;
case 'leave':
leave(s, actor, ctx);
break;
}
return { state: s, events: ctx.events };
},
view(s, viewer) {
const me = viewer && s.order.includes(viewer) ? viewer : null;
return {
options: s.options,
me,
phase: s.phase,
seats: s.order.map((p) => {
const pile = s.faceUp[p]!;
const topId = pile[pile.length - 1];
const c = topId === undefined ? null : cardOf(topId);
const why = s.outReason[p];
return {
id: p,
deck: s.decks[p]!.length,
pile: pile.length,
top: c ? { key: cardKey(c.id), fruit: c.fruit, count: c.count } : null,
cards: cardsHeld(s, p),
status: s.active.includes(p) ? 'playing' : why === 'left' ? 'left' : 'out',
isTurn: s.phase !== 'ended' && s.turn === p,
lockedUntil: s.lockedUntil[p] ?? 0,
correct: s.correctCount[p] ?? 0,
wrong: s.wrongCount[p] ?? 0,
};
}),
turn: s.phase === 'ended' ? null : s.turn,
tableVersion: s.tableVersion,
pot: s.pot.length,
removed: s.removed.length,
countdownEndsAt: s.countdownEndsAt,
turnStartedAt: s.turnStartedAt,
turnDeadline: s.turnDeadline,
flipTotalMs: flipMs(s.options),
flipAllowedAt: s.flipAllowedAt,
pendingFlip: s.pendingFlip !== null,
endsAt: s.endsAt,
windowOpen: s.ringWindow !== null,
officialDuel: s.officialDuel,
lastFlipBy: s.lastFlipBy,
lastBell: s.lastBell,
log: s.log.slice(-12),
result: s.final,
};
},
/** Everyone still in can ring at any time; the turn player also flips (§8.5). */
activePlayers(s) {
if (s.phase === 'ended') return [];
if (s.phase === 'countdown') return [s.turn];
return [...s.active];
},
deadline(s) {
if (s.phase === 'ended') return null;
if (s.phase === 'countdown') return s.countdownEndsAt;
const c: number[] = [];
if (s.endsAt !== null) c.push(s.endsAt);
if (s.ringWindow) c.push(s.ringWindow.openedAt + RING_WINDOW_MS);
else {
if (s.pendingFlip) c.push(s.flipAllowedAt);
c.push(Math.max(s.turnDeadline, s.flipAllowedAt));
}
return Math.min(...c);
},
/** Only the turn player's clock drives the server tick (flips, guard, bell window, time limit). */
timeoutPlayers(s) {
return s.phase === 'ended' ? [] : [s.turn];
},
/**
* Away players are timed out right after the room grace period; never earlier than the flip guard
* (or 1s into their turn), so an away player's flips keep a steady pace instead of a busy loop.
*/
timeoutNotBefore(s) {
if (s.phase === 'ended') return null;
if (s.phase === 'countdown') return s.countdownEndsAt;
if (s.ringWindow) return s.ringWindow.openedAt + RING_WINDOW_MS;
const flipAt = s.pendingFlip ? s.flipAllowedAt : Math.max(s.flipAllowedAt, s.turnStartedAt + AWAY_FLIP_MS);
return s.endsAt !== null ? Math.min(s.endsAt, flipAt) : flipAt;
},
onTimeout() {
return { type: 'tick' };
},
onLeave(s, player) {
if (s.phase === 'ended' || !s.active.includes(player)) return null;
return { type: 'leave' };
},
result(s): GameResult | null {
const f = s.final;
if (!f) return null;
const groups = new Map<number, PlayerId[]>();
for (const r of f.ranking) groups.set(r.rank, [...(groups.get(r.rank) ?? []), r.id]);
const scores: Record<PlayerId, number> = {};
for (const r of f.ranking) scores[r.id] = POINTS[r.rank - 1] ?? 0;
return {
ranking: [...groups.entries()].sort((a, b) => a[0] - b[0]).map(([, ids]) => ids),
scores,
summary: f.summary,
reason: 'normal',
};
},
legalActions(s, p) {
if (s.phase === 'ended' || !s.order.includes(p)) return [];
const out: A[] = [];
if (p === s.turn) out.push({ type: 'tick' });
if (s.phase === 'playing' && s.active.includes(p)) {
out.push({ type: 'ring', version: s.tableVersion });
if (p === s.turn) out.push({ type: 'flip' });
}
return out.filter((a) => halliGalli.validate(s, p, a).ok);
},
/** Every face-down or covered card (nobody may see them, including the owner — §8.3), as view keys. */
secretsOf(s, player) {
const pile = s.faceUp[player] ?? [];
return [...(s.decks[player] ?? []), ...pile.slice(0, -1), ...s.pot, ...s.removed].map(cardKey);
},
};

View File

@@ -0,0 +1,203 @@
/** 할리갈리 options, cards, actions, state and view types (docs/games/halli-galli.md §2, §3, §8). */
import { z } from 'zod';
import type { PlayerId } from '@bg/engine';
export const FRUITS = ['strawberry', 'banana', 'lime', 'plum'] as const;
export type Fruit = (typeof FRUITS)[number];
export type FruitCount = 1 | 2 | 3 | 4 | 5;
export interface Card {
id: number;
fruit: Fruit;
count: FruitCount;
}
/** Per fruit: 1×5, 2×3, 3×3, 4×2, 5×1 = 14 cards (§3). */
const COUNTS: FruitCount[] = [1, 1, 1, 1, 1, 2, 2, 2, 3, 3, 3, 4, 4, 5];
/** The 56 cards, id 0..55 (fruit-major). */
export const CARDS: readonly Card[] = FRUITS.flatMap((fruit, f) => COUNTS.map((count, i) => ({ id: f * 14 + i, fruit, count })));
export const DECK_SIZE = CARDS.length;
export const cardOf = (id: number): Card => {
const c = CARDS[id];
if (!c) throw new Error(`halli-galli: no card ${id}`);
return c;
};
const lit = <T extends string | number | null>(...vals: T[]) =>
z.union(vals.map((v) => z.literal(v)) as unknown as [z.ZodLiteral<T>, z.ZodLiteral<T>, ...z.ZodLiteral<T>[]]);
export const halliGalliOptionsSchema = z.object({
endMode: z
.enum(['lastStanding', 'official'])
.default('lastStanding')
.meta({ title: '끝나는 방식', labels: { lastStanding: '한 명이 남을 때까지', official: '2명 남으면 다음 종에서 끝(원작 규칙)' } }),
timeLimitMin: lit<5 | 10 | 15 | null>(5, 10, 15, null)
.default(10)
.meta({ title: '전체 제한 시간', labels: { 5: '5분', 10: '10분', 15: '15분', null: '없음' } }),
flipSeconds: lit<2 | 3 | 5 | 8>(2, 3, 5, 8)
.default(3)
.meta({ title: '뒤집기 제한 시간', labels: { 2: '2초', 3: '3초', 5: '5초', 8: '8초' } }),
autoFlip: z.boolean().default(false).meta({ title: '카드를 자동으로 뒤집기(뒤집기 버튼 없음)' }),
flipGuardMs: lit<400 | 600 | 1000>(400, 600, 1000)
.default(600)
.meta({ title: '뒤집은 뒤 다음 뒤집기까지 최소 간격', labels: { 400: '0.4초', 600: '0.6초', 1000: '1초' } }),
wrongRingLockoutMs: lit<0 | 1000 | 2000>(0, 1000, 2000)
.default(1000)
.meta({ title: '틀린 종 뒤 종 잠금', labels: { 0: '없음', 1000: '1초', 2000: '2초' } }),
leftoverCards: z
.enum(['pot', 'remove'])
.default('pot')
.meta({ title: '나누고 남은 카드', labels: { pot: '종 밑에 두기(다음 정답자가 가져감)', remove: '게임에서 빼기' } }),
latencyComp: z
.enum(['off', 'light'])
.default('off')
.meta({ title: '지연 보정', labels: { off: '끄기(서버 도착 순서)', light: '가볍게(최대 0.08초 보정)' } }),
showRtt: z.boolean().default(true).meta({ title: '내 핑(연결 지연) 표시' }),
kidMode: z.boolean().default(false).meta({ title: '어린이 모드(8초 뒤집기, 과일 합계 보조 표시)' }),
duelWrongLoses: z.boolean().default(false).meta({ title: '1대1에서 틀린 종을 치면 바로 패배' }),
strikeOut: z.boolean().default(false).meta({ title: '틀린 종 3번이면 탈락' }),
penaltyEscalation: z.boolean().default(false).meta({ title: '틀릴 때마다 벌칙 장수 1장씩 늘리기' }),
wrongToPot: z.boolean().default(false).meta({ title: '벌칙 카드를 종 밑으로(다른 사람에게 주지 않음)' }),
});
export type HalliGalliOptions = z.infer<typeof halliGalliOptionsSchema>;
export const halliGalliActionSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('flip') }),
z.object({
type: z.literal('ring'),
/** Table version the client was looking at (§5.2 "늦은 종"). */
version: z.number().int().min(0).max(1_000_000),
/** Client-measured round trip, used only by `latencyComp: 'light'` (clamped, max 80ms of credit). */
rtt: z.number().min(0).max(10_000).optional(),
}),
]);
/** Client actions plus system-only ones (server timer, leaving) that are not in the schema. */
export type HalliGalliAction = z.infer<typeof halliGalliActionSchema> | { type: 'tick' } | { type: 'leave' };
export type EndReason = 'lastStanding' | 'official' | 'timeLimit';
export type OutReason = 'empty' | 'penalty' | 'strikeOut' | 'duel' | 'left';
/** Public log line (structured; the screen turns it into Korean with nicknames). */
export type LogEntry =
| { n: number; t: 'collect'; p: PlayerId; fruit: Fruit; cards: number }
| { n: number; t: 'wrong'; p: PlayerId; gave: number; toPot: boolean }
| { n: number; t: 'duelWrong'; p: PlayerId; by: PlayerId; cards: number }
| { n: number; t: 'out'; p: PlayerId; why: OutReason };
export interface RankRow {
rank: number;
id: PlayerId;
cards: number;
correct: number;
wrong: number;
}
export interface HalliGalliFinal {
endReason: EndReason;
ranking: RankRow[];
summary: string;
}
export interface RingWindow {
version: number;
openedAt: number;
rings: { player: PlayerId; adjTs: number }[];
}
export interface LastBell {
/** Increments on every judged bell (for client feedback). */
seq: number;
player: PlayerId;
correct: boolean;
fruit?: Fruit;
/** Correct: cards collected. Wrong: cards given away. */
cards: number;
at: number;
}
export interface HalliGalliState {
options: HalliGalliOptions;
order: PlayerId[];
/** Face-down decks; top = last element. Hidden from everyone (including the owner). */
decks: Record<PlayerId, number[]>;
/** Face-up piles; top = last element. Only the top card is public. */
faceUp: Record<PlayerId, number[]>;
/** Cards under the bell (leftovers, leavers' decks, `wrongToPot`); next correct bell takes them. */
pot: number[];
/** `leftoverCards: 'remove'` — out of the game for good. */
removed: number[];
active: PlayerId[];
/** Elimination order (first out first). */
eliminated: PlayerId[];
outReason: Record<PlayerId, OutReason>;
/** Players with 3+ starting seats get `official` endings; 2-player games always play to the last one (§6). */
officialDuel: boolean;
turn: PlayerId;
turnStartedAt: number;
turnDeadline: number;
flipAllowedAt: number;
pendingFlip: PlayerId | null;
tableVersion: number;
lastChange: 'flip' | 'collect' | null;
lastFlipBy: PlayerId | null;
lockedUntil: Record<PlayerId, number>;
wrongCount: Record<PlayerId, number>;
correctCount: Record<PlayerId, number>;
ringWindow: RingWindow | null;
phase: 'countdown' | 'playing' | 'ended';
countdownEndsAt: number;
startedAt: number;
endsAt: number | null;
lastBell: LastBell | null;
bellSeq: number;
logNo: number;
log: LogEntry[];
final: HalliGalliFinal | null;
}
export interface TopCard {
/** Opaque public key of the visible card (never used for face-down cards). */
key: string;
fruit: Fruit;
count: FruitCount;
}
export interface SeatView {
id: PlayerId;
deck: number;
pile: number;
top: TopCard | null;
/** deck + own face-up pile (§6 "보유 카드 수"). */
cards: number;
status: 'playing' | 'out' | 'left';
isTurn: boolean;
lockedUntil: number;
correct: number;
wrong: number;
}
export interface HalliGalliView {
options: HalliGalliOptions;
me: PlayerId | null;
phase: HalliGalliState['phase'];
seats: SeatView[];
turn: PlayerId | null;
tableVersion: number;
pot: number;
removed: number;
countdownEndsAt: number;
turnStartedAt: number;
turnDeadline: number;
flipTotalMs: number;
flipAllowedAt: number;
/** The turn player's flip is queued behind the flip guard / bell window. */
pendingFlip: boolean;
endsAt: number | null;
windowOpen: boolean;
officialDuel: boolean;
lastFlipBy: PlayerId | null;
lastBell: LastBell | null;
log: LogEntry[];
result: HalliGalliFinal | null;
}

View File

@@ -11,6 +11,11 @@ import { yut } from './yut';
import { oneCard } from './one-card';
import { rummikub } from './rummikub';
import { davinci } from './davinci-code';
import { mahjong } from './mahjong';
import { gostop } from './gostop';
import { bang } from './bang';
import { halliGalli } from './halli-galli';
import { marble } from './marble';
export const GAMES: Record<string, AnyGameDefinition> = {
[omok.id]: omok,
@@ -25,4 +30,9 @@ export const GAMES: Record<string, AnyGameDefinition> = {
[oneCard.id]: oneCard,
[rummikub.id]: rummikub,
[davinci.id]: davinci,
[mahjong.id]: mahjong,
[gostop.id]: gostop,
[bang.id]: bang,
[halliGalli.id]: halliGalli,
[marble.id]: marble,
};

View File

@@ -0,0 +1,154 @@
/**
* Hand shape analysis on 34-kind count arrays (§5.6): standard 4 sets + pair, 칠대자, 국사무쌍; all decompositions
* for 고점법 (§6.2) and tenpai waits (§5.5).
*/
import { YAOCHU, type Kind } from './tiles';
export interface SetPart {
t: 'shuntsu' | 'koutsu';
/** Lowest kind of a sequence, or the triplet kind. */
k: Kind;
}
export interface Decomposition {
pair: Kind;
sets: SetPart[];
}
/** Can the counts be split into exactly `need` sets (no pair)? Mutates and restores `c`. */
function canSets(c: number[], need: number, from = 0): boolean {
let i = from;
while (i < 34 && c[i] === 0) i++;
if (i === 34) return need === 0;
if (need === 0) return false;
if (c[i]! >= 3) {
c[i]! -= 3;
const ok = canSets(c, need - 1, i);
c[i]! += 3;
if (ok) return true;
}
if (i < 27 && i % 9 <= 6 && c[i + 1]! > 0 && c[i + 2]! > 0) {
c[i]!--;
c[i + 1]!--;
c[i + 2]!--;
const ok = canSets(c, need - 1, i);
c[i]!++;
c[i + 1]!++;
c[i + 2]!++;
if (ok) return true;
}
return false;
}
export function isStandardAgari(c: number[], sets: number): boolean {
for (let p = 0; p < 34; p++) {
if (c[p]! < 2) continue;
c[p]! -= 2;
const ok = canSets(c, sets);
c[p]! += 2;
if (ok) return true;
}
return false;
}
export function isChiitoi(c: number[]): boolean {
let pairs = 0;
for (let k = 0; k < 34; k++) {
if (c[k] === 2) pairs++;
else if (c[k] !== 0) return false;
}
return pairs === 7;
}
export function isKokushi(c: number[]): boolean {
let total = 0;
let pair = false;
for (const k of YAOCHU) {
const n = c[k]!;
if (n === 0 || n > 2) return false;
if (n === 2) {
if (pair) return false;
pair = true;
}
total += n;
}
return pair && total === 14;
}
/** Winning shape for the concealed counts given `meldCount` called/kan sets. */
export function isAgariShape(c: number[], meldCount: number): boolean {
if (meldCount === 0 && (isChiitoi(c) || isKokushi(c))) return true;
return isStandardAgari(c, 4 - meldCount);
}
function collect(c: number[], need: number, from: number, acc: SetPart[], out: SetPart[][]): void {
let i = from;
while (i < 34 && c[i] === 0) i++;
if (i === 34) {
if (need === 0) out.push([...acc]);
return;
}
if (need === 0) return;
if (c[i]! >= 3) {
c[i]! -= 3;
acc.push({ t: 'koutsu', k: i });
collect(c, need - 1, i, acc, out);
acc.pop();
c[i]! += 3;
}
if (i < 27 && i % 9 <= 6 && c[i + 1]! > 0 && c[i + 2]! > 0) {
c[i]!--;
c[i + 1]!--;
c[i + 2]!--;
acc.push({ t: 'shuntsu', k: i });
collect(c, need - 1, i, acc, out);
acc.pop();
c[i]!++;
c[i + 1]!++;
c[i + 2]!++;
}
}
/** Every standard decomposition of the concealed counts into `sets` sets + 1 pair. */
export function decompositions(c: number[], sets: number): Decomposition[] {
const out: Decomposition[] = [];
const work = [...c];
for (let p = 0; p < 34; p++) {
if (work[p]! < 2) continue;
work[p]! -= 2;
const found: SetPart[][] = [];
collect(work, sets, 0, [], found);
for (const s of found) out.push({ pair: p, sets: s });
work[p]! += 2;
}
return out;
}
/**
* Tenpai waits of a hand that needs one tile (13, 10, 7, 4 or 1 concealed tiles). A kind whose four copies the
* player already holds (hand + melds, `owned`) is not a wait (§5.5).
*/
export function waitsOf(c: number[], meldCount: number, owned?: number[]): Kind[] {
const out: Kind[] = [];
for (let k = 0; k < 34; k++) {
if ((owned ? owned[k]! : c[k]!) >= 4) continue;
// Prune: a wait must touch a held tile (or be any yaochu kind for 국사무쌍).
if (!near(c, k) && !(meldCount === 0 && YAOCHU.includes(k))) continue;
c[k]!++;
const ok = isAgariShape(c, meldCount);
c[k]!--;
if (ok) out.push(k);
}
return out;
}
function near(c: number[], k: Kind): boolean {
if (c[k]! > 0) return true;
if (k >= 27) return false;
const n = k % 9;
for (let d = -2; d <= 2; d++) {
if (d === 0) continue;
const m = n + d;
if (m >= 0 && m <= 8 && c[k + d]! > 0) return true;
}
return false;
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,69 @@
/**
* 마작 무작위 대국 ③ 3인(산마) 360판 + 엔진 randomPlayout(직렬화 왕복·정보 누출 검사 내장) + 매 단계 누출 검사
* + 재현성 (docs/09-game-engine.md §7).
*/
import { describe, expect, test } from 'bun:test';
import { randomPlayout, seedN } from '@bg/engine';
import { mahjong } from './index';
import { checkPoints, checkTiles, emptyStats, playGame, variedOptions } from './playout';
const stats = emptyStats();
describe('무작위 대국: 3인 산마', () => {
for (let chunk = 0; chunk < 18; chunk++) {
test(`시드 ${2000 + chunk * 20 + 1}~${2000 + chunk * 20 + 20}`, () => {
for (let i = 1; i <= 20; i++) {
const seed = 2000 + chunk * 20 + i;
playGame({ seed, players: 3, options: { length: seed % 2 ? 'tonpuu' : 'hanchan', ...variedOptions(seed, 3) }, prefs: seed % 4 === 0, leaveAt: seed % 11 === 0 ? 150 : undefined, stats });
}
});
}
test('북 뽑기·화료·유국이 모두 나왔다', () => {
expect(stats.kita).toBeGreaterThan(500);
expect(stats.tsumo).toBeGreaterThan(50);
expect(stats.ron).toBeGreaterThan(50);
expect(stats.exhaustive).toBeGreaterThan(50);
expect(stats.maxApplyMs).toBeLessThan(25);
});
});
describe('엔진 randomPlayout (매 단계 직렬화 왕복 + 정보 누출 검사)', () => {
for (const [players, seed] of [
[['A', 'B', 'C', 'D'], 1],
[['A', 'B', 'C'], 2],
] as const) {
test(`${players.length}인 동풍전 시드 ${seed}`, () => {
const r = randomPlayout(mahjong, {
players: [...players],
options: mahjong.optionsSchema.parse({ length: 'tonpuu', westEntry: false }),
seed: seedN(seed),
maxSteps: 4000,
maxApplyMs: 25,
invariant: (s) => {
checkTiles(s as never);
checkPoints(s as never);
},
});
expect(r.steps).toBeGreaterThan(100);
});
}
});
describe('매 단계 누출 검사 (울기·리치·화료가 있는 대국)', () => {
test('4인 1판 + 3인 1판, 매 단계 모든 시점의 view 검사', () => {
const a = emptyStats();
playGame({ seed: 7, players: 4, options: { length: 'tonpuu', westEntry: false }, deep: true, stats: a });
playGame({ seed: 8, players: 3, options: { length: 'tonpuu', westEntry: false }, deep: true, stats: a });
expect(a.calls + a.riichi).toBeGreaterThan(0);
});
});
describe('재현성', () => {
test('같은 시드면 대국 전체가 같다', () => {
for (const seed of [3, 4]) {
const a = JSON.stringify(playGame({ seed, players: 4, options: variedOptions(seed, 4) }));
const b = JSON.stringify(playGame({ seed, players: 4, options: variedOptions(seed, 4) }));
expect(a).toBe(b);
}
});
});

View File

@@ -0,0 +1,27 @@
/** 마작 무작위 대국 ① 4인 반장전 240판 (docs/09-game-engine.md §7.2): 끝까지 합법 진행 + 불변식. */
import { describe, expect, test } from 'bun:test';
import { emptyStats, playGame, variedOptions } from './playout';
const stats = emptyStats();
describe('무작위 대국: 4인 반장전', () => {
for (let chunk = 0; chunk < 12; chunk++) {
test(`시드 ${0 + chunk * 20 + 1}~${0 + chunk * 20 + 20}`, () => {
for (let i = 1; i <= 20; i++) {
const seed = 0 + chunk * 20 + i;
playGame({ seed, players: 4, options: { length: 'hanchan', ...variedOptions(seed, 4) }, prefs: seed % 5 === 0, leaveAt: seed % 17 === 0 ? 400 : undefined, stats });
}
});
}
test('다양한 결과가 나왔고 apply는 빠르다', () => {
expect(stats.tsumo).toBeGreaterThan(50);
expect(stats.ron).toBeGreaterThan(100);
expect(stats.exhaustive).toBeGreaterThan(100);
expect(stats.abortive).toBeGreaterThan(0);
expect(stats.riichi).toBeGreaterThan(200);
expect(stats.kans).toBeGreaterThan(50);
expect(Object.keys(stats.endReasons).length).toBeGreaterThanOrEqual(3);
expect(stats.applyMs / stats.applies).toBeLessThan(1);
expect(stats.maxApplyMs).toBeLessThan(25);
});
});

View File

@@ -0,0 +1,22 @@
/** 마작 무작위 대국 ② 4인 동풍전 400판 (docs/09-game-engine.md §7.2). */
import { describe, expect, test } from 'bun:test';
import { emptyStats, playGame, variedOptions } from './playout';
const stats = emptyStats();
describe('무작위 대국: 4인 동풍전', () => {
for (let chunk = 0; chunk < 20; chunk++) {
test(`시드 ${1000 + chunk * 20 + 1}~${1000 + chunk * 20 + 20}`, () => {
for (let i = 1; i <= 20; i++) {
const seed = 1000 + chunk * 20 + i;
playGame({ seed, players: 4, options: { length: 'tonpuu', ...variedOptions(seed, 4) }, prefs: seed % 4 === 0, leaveAt: seed % 13 === 0 ? 200 : undefined, stats });
}
});
}
test('다양한 결과가 나왔고 apply는 빠르다', () => {
expect(stats.tsumo + stats.ron).toBeGreaterThan(150);
expect(stats.doubleRon).toBeGreaterThan(0);
expect(stats.applyMs / stats.applies).toBeLessThan(1);
expect(stats.maxApplyMs).toBeLessThan(25);
});
});

View File

@@ -0,0 +1,302 @@
/**
* Test-only fast playout driver for 마작 (docs/09-game-engine.md §7): seeded semi-random but legal play with
* invariant checks (tiles conserved, points + 공탁 conserved, onTimeout legal, JSON round trip, no leaks).
* Not imported by the game module itself.
*/
import { SeededRng, checkNoLeak, seedN } from '@bg/engine';
import { mahjong, type MahjongAction, type MahjongOptions, type MahjongState } from './index';
import { kindOf, parseTileCode } from './tiles';
export interface PlayoutStats {
steps: number;
hands: number;
tsumo: number;
ron: number;
doubleRon: number;
exhaustive: number;
abortive: number;
riichi: number;
calls: number;
kans: number;
kita: number;
yakuman: number;
maxApplyMs: number;
applyMs: number;
applies: number;
timeouts: number;
endReasons: Record<string, number>;
}
export const emptyStats = (): PlayoutStats => ({
steps: 0,
hands: 0,
tsumo: 0,
ron: 0,
doubleRon: 0,
exhaustive: 0,
abortive: 0,
riichi: 0,
calls: 0,
kans: 0,
kita: 0,
yakuman: 0,
maxApplyMs: 0,
applyMs: 0,
applies: 0,
timeouts: 0,
endReasons: {},
});
function check(cond: boolean, msg: string): void {
if (!cond) throw new Error(msg);
}
/** Every tile is in exactly one place (§3: 136 / 108). */
export function checkTiles(s: MahjongState): void {
const h = s.hand;
if (!h) return;
const out = [...h.wall, ...h.rinshan, ...h.doraInd, ...h.uraInd];
for (let i = 0; i < s.players.length; i++) {
out.push(...h.hands[i]!, ...h.kita[i]!);
for (const m of h.melds[i]!) out.push(...m.tiles);
for (const d of h.discards[i]!) if (d.calledBy === null) out.push(d.tile);
}
const n = s.mode === 'sanma' ? 108 : 136;
check(out.length === n, `tile count ${out.length} != ${n}`);
check(new Set(out).size === n, 'duplicate tile');
check(h.rinshan.length === 4 && h.doraInd.length === 5 && h.uraInd.length === 5, 'dead wall must stay 14 tiles');
if (h.stage !== 'ended') {
for (let i = 0; i < s.players.length; i++) {
const size = h.hands[i]!.length + 3 * h.melds[i]!.length;
const turnHolds = h.turn === i && (h.stage === 'afterDraw' || h.stage === 'afterCall');
check(size === (turnHolds ? 14 : 13), `seat ${i} holds ${size} tiles at stage ${h.stage}`);
}
}
}
export function checkPoints(s: MahjongState): void {
const total = s.points.reduce((a, b) => a + b, 0) + s.riichiPool;
check(total === s.options.startPoints * s.players.length, `points not conserved: ${total}`);
check(s.riichiPool >= 0 && s.riichiPool % 1000 === 0, 'bad riichi pool');
check(s.points.every((p) => Number.isInteger(p) && p % (s.mode === "sanma" ? 50 : 100) === 0), `points must be multiples of 100 (산마 본장 300 → 50): ${s.points}`);
}
/** How well a tile fits the hand: same kinds and close neighbours (honors only pair up). */
function fit(hand: number[], t: number): number {
const k = kindOf(t);
let score = 0;
for (const x of hand) {
if (x === t) continue;
const j = kindOf(x);
if (j === k) score += 3;
else if (k < 27 && j < 27 && Math.floor(j / 9) === Math.floor(k / 9)) {
const d = Math.abs(j - k);
if (d === 1) score += 2;
else if (d === 2) score += 1;
}
}
return score;
}
/** Picks an action for `seat`: wins and riichi when possible, some calls and kans, sensible discards. */
function choose(s: MahjongState, seat: number, rng: SeededRng): MahjongAction | null {
const p = s.players[seat]!;
const legal = mahjong.legalActions!(s, p);
if (legal.length === 0) return null;
const r = rng.next();
const of = (t: MahjongAction['type']) => legal.filter((a) => a.type === t);
if (s.phase === 'handResult') return legal[0]!;
const h = s.hand!;
if (h.stage === 'claim') {
const ron = legal.find((a) => a.type === 'claim' && a.call === 'ron');
if (ron && r < 0.9) return ron;
const calls = legal.filter((a) => a.type === 'claim' && a.call !== 'ron');
if (calls.length && r < 0.35) return calls[rng.int(calls.length)]!;
return { type: 'pass' };
}
if (of('tsumo').length && r < 0.92) return of('tsumo')[0]!;
if (of('kyuushu').length && r < 0.5) return of('kyuushu')[0]!;
const kans = [...of('ankan'), ...of('kakan')];
if (kans.length && r < 0.6) return kans[rng.int(kans.length)]!;
if (of('kita').length && r < 0.8) return of('kita')[0]!;
const discards = of('discard');
const riichi = discards.filter((a) => a.type === 'discard' && a.riichi);
if (riichi.length && rng.next() < 0.7) return riichi[rng.int(riichi.length)]!;
const plain = discards.filter((a) => a.type === 'discard' && !a.riichi);
if (rng.next() < 0.25) return plain[rng.int(plain.length)]!;
const hand = h.hands[seat]!;
let best = plain[0]!;
let bestScore = Infinity;
for (const a of plain) {
if (a.type !== 'discard') continue;
const sc = fit(hand, parseTileCode(a.tile)!) + rng.next();
if (sc < bestScore) {
bestScore = sc;
best = a;
}
}
return best;
}
/** House-rule variety for playouts: seed → option overrides (always legal combinations). */
export function variedOptions(seed: number, players: number): Partial<MahjongOptions> {
const r = SeededRng.fromSeed(seedN(seed + 31_337));
const pick = <T,>(xs: readonly T[]): T => xs[r.int(xs.length)]!;
if (r.next() < 0.4) return {};
return {
doubleRon: pick(['allow', 'headBump'] as const),
tripleRon: pick(['abort', 'allow'] as const),
kuitan: r.next() < 0.8,
akaDora: pick(['auto', 0, 2, 3] as const),
kiriageMangan: r.next() < 0.3,
kazoeYakuman: r.next() < 0.8,
doubleYakuman: r.next() < 0.8,
abortiveDraws: r.next() < 0.8,
nagashiMangan: r.next() < 0.8,
pao: r.next() < 0.8,
renchanOn: pick(['tenpai', 'agari'] as const),
renpuuPairFu: pick([2, 4] as const),
tobi: r.next() < 0.8,
agariYame: r.next() < 0.8,
westEntry: r.next() < 0.8,
uma: players === 3 ? pick(['auto', '0', '15'] as const) : pick(['auto', 'none', '5-10', '10-20', '10-30'] as const),
startPoints: pick(['auto', 20000, 25000, 30000] as const),
sanmaTsumo: pick(['tsumoLoss', 'northBisection'] as const),
sanmaHonba: pick([200, 300, 1000] as const),
sanmaNotenPool: pick([2000, 3000] as const),
};
}
export interface PlayoutOpts {
seed: number;
players: number;
options?: Partial<MahjongOptions>;
/** Run the (slower) JSON round-trip and view leak checks on every step. */
deep?: boolean;
/** One player leaves the room after this many steps (then plays on autopilot). */
leaveAt?: number;
/** Random 자동 화료 / 울기 안 함 preferences at the start. */
prefs?: boolean;
maxSteps?: number;
stats?: PlayoutStats;
}
/** Plays one game to the end; returns the final state. Throws on any broken invariant. */
export function playGame(o: PlayoutOpts): MahjongState {
const stats = o.stats ?? emptyStats();
const players = ['A', 'B', 'C', 'D'].slice(0, o.players);
const rng = SeededRng.fromSeed(seedN(o.seed));
const chooser = SeededRng.fromSeed(seedN(o.seed + 7_777_777));
let now = 1_700_000_000_000;
let s = mahjong.setup({ players, options: mahjong.optionsSchema.parse(o.options ?? {}), rng, now });
const maxSteps = o.maxSteps ?? 20_000;
let lastHands = 0;
if (o.prefs) {
for (const p of players) {
if (chooser.next() < 0.3) step0(p, { type: 'setPref', autoWin: true });
if (chooser.next() < 0.15) step0(p, { type: 'setPref', noCalls: true });
}
}
function step0(p: string, a: MahjongAction) {
s = mahjong.apply(s, p, a, { rng, now }).state;
}
const step = (actor: string, a: MahjongAction) => {
const v = mahjong.validate(s, actor, a);
check(v.ok, `rejected ${JSON.stringify(a)} by ${actor}: ${v.ok ? '' : v.reason}`);
now += 500;
const t0 = performance.now();
s = mahjong.apply(s, actor, a, { rng, now }).state;
const ms = performance.now() - t0;
stats.applyMs += ms;
stats.applies++;
if (ms > stats.maxApplyMs) stats.maxApplyMs = ms;
stats.steps++;
};
for (let i = 0; i < maxSteps; i++) {
if (i === o.leaveAt && s.phase !== 'finished') {
const p = players[chooser.int(players.length)]!;
const a = mahjong.onLeave!(s, p);
if (a) step(p, a);
}
checkTiles(s);
checkPoints(s);
// onTimeout is legal for everyone the timer applies to (§7.4).
for (const p of mahjong.timeoutPlayers!(s)) {
const v = mahjong.validate(s, p, mahjong.onTimeout(s, p));
check(v.ok, `onTimeout illegal for ${p}: ${v.ok ? '' : v.reason}`);
}
if (o.deep) {
const json = JSON.stringify(s);
check(JSON.stringify(JSON.parse(json)) === json, 'state is not JSON round-trippable');
for (const p of players) checkNoLeak(mahjong, s, p, players);
}
if (s.handsPlayed > lastHands) {
lastHands = s.handsPlayed;
const r = s.lastHandResult!;
stats.hands++;
if (r.kind === 'agari') {
if (r.winners[0]!.from === null) stats.tsumo++;
else stats.ron++;
if (r.winners.length > 1) stats.doubleRon++;
if (r.winners.some((w) => w.yakuman > 0)) stats.yakuman++;
for (const w of r.winners) check(w.han >= 1 && (w.fu >= 20 || w.yakuman > 0), `bad winner record ${JSON.stringify(w)}`);
} else if (r.kind === 'exhaustive') stats.exhaustive++;
else stats.abortive++;
check(r.deltas.every((d) => d % (s.mode === "sanma" ? 50 : 100) === 0), "deltas must be multiples of 100");
}
const res = mahjong.result(s);
if (res) {
check(s.phase === 'finished', 'result before the end');
const sum = Object.values(res.scores!).reduce((a, b) => a + b, 0);
check(Math.abs(sum) < 1e-9, `final scores do not sum to 0: ${sum}`);
check(res.ranking.flat().length === players.length, 'ranking must list everyone');
stats.endReasons[s.final!.endReason] = (stats.endReasons[s.final!.endReason] ?? 0) + 1;
for (const st of s.stats) {
stats.riichi += st.riichi;
stats.calls += st.calls;
}
return s;
}
if (s.phase === 'handResult') {
const waiting = s.players.filter((p, idx) => !s.readyForNext.includes(idx));
const p = waiting[chooser.int(waiting.length)]!;
if (chooser.next() < 0.1) {
stats.timeouts++;
now = Math.max(now, s.resultAt + 15_000);
step(p, mahjong.onTimeout(s, p));
} else step(p, { type: 'ready' });
continue;
}
const h = s.hand!;
if (h.stage === 'claim') {
const c = h.claim!;
if (c.decided) {
step(s.players[c.from]!, mahjong.onTimeout(s, s.players[c.from]!));
continue;
}
const pending = c.eligible.flatMap((e, seat) => (e && !c.responses[seat] ? [seat] : []));
const seat = pending[chooser.int(pending.length)]!;
if (chooser.next() < 0.05) {
stats.timeouts++;
step(s.players[seat]!, mahjong.onTimeout(s, s.players[seat]!));
continue;
}
const a = choose(s, seat, chooser)!;
step(s.players[seat]!, a);
continue;
}
const seat = h.turn;
if (s.left[seat] || chooser.next() < 0.03) {
stats.timeouts++;
now += 200_000;
step(s.players[seat]!, mahjong.onTimeout(s, s.players[seat]!));
continue;
}
const a = choose(s, seat, chooser);
check(a !== null, `no legal action for the turn seat ${seat} at stage ${h.stage}`);
if (a!.type === 'ankan' || a!.type === 'kakan') stats.kans++;
if (a!.type === 'kita') stats.kita++;
step(s.players[seat]!, a!);
}
throw new Error(`game did not finish in ${maxSteps} steps (seed ${o.seed})`);
}

View File

@@ -0,0 +1,575 @@
/** 마작 점수 계산기 (docs/games/mahjong.md §6): 역 판정, 부, 판·부 → 점수, 만관 이상, 산마 지불. */
import { describe, expect, test } from 'bun:test';
import { basicPoints, payTable, scoreWin, DEFAULT_RULES, type MeldShape, type MeldType, type WinInput, type WinScore } from './scoring';
import { doraFromIndicator, kindFromCode, type Kind } from './tiles';
/** "234m55z" → kinds. */
function k(str: string): Kind[] {
const out: Kind[] = [];
for (const m of str.matchAll(/(\d+)([mpsz])/g)) for (const d of m[1]!) out.push(kindFromCode(`${d}${m[2]}`)!);
return out;
}
const one = (s: string) => k(s)[0]!;
const meld = (type: MeldType, s: string): MeldShape => ({ type, k: Math.min(...k(s)) });
const E = 27;
const S = 28;
const W = 29;
interface Case {
hand: string;
win: string;
melds?: MeldShape[];
tsumo?: boolean;
dealer?: boolean;
extra?: Partial<WinInput>;
}
function score(c: Case): WinScore | null {
return scoreWin({
hand: k(c.hand),
win: one(c.win),
melds: c.melds ?? [],
tsumo: !!c.tsumo,
seatWind: c.dealer ? E : S,
roundWind: E,
...c.extra,
});
}
/** Points the winner receives (ron total or tsumo total) with the standard 4-player table. */
function total(sc: WinScore, dealer: boolean, tsumo: boolean): number {
const t = payTable(sc.basic, dealer);
if (!tsumo) return t.ron;
return dealer ? t.tsumoDealer * 3 : t.tsumoDealer + t.tsumoNonDealer * 2;
}
const keys = (sc: WinScore) => sc.yaku.map((y) => y.key).sort();
// ---------------------------------------------------------------- §6.4 score tables
/** 자(비친) 1~4판 표: [fu, [ron, tsumo-nondealer/tsumo-dealer] per han]; null = 불가능 칸. */
const NON_DEALER: [number, ([number | null, [number, number] | null])[]][] = [
[20, [[null, null], [null, [400, 700]], [null, [700, 1300]], [null, [1300, 2600]]]],
[25, [[null, null], [1600, null], [3200, [800, 1600]], [6400, [1600, 3200]]]],
[30, [[1000, [300, 500]], [2000, [500, 1000]], [3900, [1000, 2000]], [7700, [2000, 3900]]]],
[40, [[1300, [400, 700]], [2600, [700, 1300]], [5200, [1300, 2600]], [8000, [2000, 4000]]]],
[50, [[1600, [400, 800]], [3200, [800, 1600]], [6400, [1600, 3200]], [8000, [2000, 4000]]]],
[60, [[2000, [500, 1000]], [3900, [1000, 2000]], [7700, [2000, 3900]], [8000, [2000, 4000]]]],
[70, [[2300, [600, 1200]], [4500, [1200, 2300]], [8000, [2000, 4000]], [8000, [2000, 4000]]]],
[80, [[2600, [700, 1300]], [5200, [1300, 2600]], [8000, [2000, 4000]], [8000, [2000, 4000]]]],
[90, [[2900, [800, 1500]], [5800, [1500, 2900]], [8000, [2000, 4000]], [8000, [2000, 4000]]]],
[100, [[3200, [800, 1600]], [6400, [1600, 3200]], [8000, [2000, 4000]], [8000, [2000, 4000]]]],
[110, [[3600, [900, 1800]], [7100, [1800, 3600]], [8000, [2000, 4000]], [8000, [2000, 4000]]]],
];
/** 친 1~4판 표: [fu, [ron, tsumo 올] per han]. */
const DEALER: [number, ([number | null, number | null])[]][] = [
[20, [[null, null], [null, 700], [null, 1300], [null, 2600]]],
[25, [[null, null], [2400, null], [4800, 1600], [9600, 3200]]],
[30, [[1500, 500], [2900, 1000], [5800, 2000], [11600, 3900]]],
[40, [[2000, 700], [3900, 1300], [7700, 2600], [12000, 4000]]],
[50, [[2400, 800], [4800, 1600], [9600, 3200], [12000, 4000]]],
[60, [[2900, 1000], [5800, 2000], [11600, 3900], [12000, 4000]]],
[70, [[3400, 1200], [6800, 2300], [12000, 4000], [12000, 4000]]],
[80, [[3900, 1300], [7700, 2600], [12000, 4000], [12000, 4000]]],
[90, [[4400, 1500], [8700, 2900], [12000, 4000], [12000, 4000]]],
[100, [[4800, 1600], [9600, 3200], [12000, 4000], [12000, 4000]]],
[110, [[5300, 1800], [10600, 3600], [12000, 4000], [12000, 4000]]],
];
describe('점수표 §6.4: 1~4판 × 20~110부 (공식 = 표)', () => {
for (const [fu, row] of NON_DEALER) {
row.forEach(([ron, tsumo], i) => {
const han = i + 1;
if (ron === null && tsumo === null) return;
test(`자 ${han}판 ${fu}부 → 론 ${ron ?? '-'} / 쯔모 ${tsumo ? tsumo.join('/') : '-'}`, () => {
const { basic } = basicPoints(han, fu);
const t = payTable(basic, false);
if (ron !== null) expect(t.ron).toBe(ron);
if (tsumo) expect([t.tsumoNonDealer, t.tsumoDealer]).toEqual(tsumo);
});
});
}
for (const [fu, row] of DEALER) {
row.forEach(([ron, tsumo], i) => {
const han = i + 1;
if (ron === null && tsumo === null) return;
test(`친 ${han}판 ${fu}부 → 론 ${ron ?? '-'} / 쯔모 ${tsumo ?? '-'} 올`, () => {
const { basic } = basicPoints(han, fu);
const t = payTable(basic, true);
if (ron !== null) expect(t.ron).toBe(ron);
if (tsumo !== null) expect(t.tsumoDealer).toBe(tsumo);
});
});
}
});
describe('만관 이상 표 §6.4', () => {
const rows: [string, number, number, number, [number, number], number, number][] = [
['만관 5판', 5, 2000, 8000, [2000, 4000], 12000, 4000],
['하네만 6판', 6, 3000, 12000, [3000, 6000], 18000, 6000],
['하네만 7판', 7, 3000, 12000, [3000, 6000], 18000, 6000],
['배만 8판', 8, 4000, 16000, [4000, 8000], 24000, 8000],
['배만 10판', 10, 4000, 16000, [4000, 8000], 24000, 8000],
['삼배만 11판', 11, 6000, 24000, [6000, 12000], 36000, 12000],
['삼배만 12판', 12, 6000, 24000, [6000, 12000], 36000, 12000],
['헤아림 역만 13판', 13, 8000, 32000, [8000, 16000], 48000, 16000],
];
for (const [name, han, basic, ron, tsumo, dRon, dTsumo] of rows) {
test(name, () => {
expect(basicPoints(han, 30).basic).toBe(basic);
const nd = payTable(basic, false);
const d = payTable(basic, true);
expect(nd.ron).toBe(ron);
expect([nd.tsumoNonDealer, nd.tsumoDealer]).toEqual(tsumo);
expect(d.ron).toBe(dRon);
expect(d.tsumoDealer).toBe(dTsumo);
});
}
test('역만 / 더블 역만 지불', () => {
expect(payTable(8000, false).ron).toBe(32000);
expect(payTable(16000, false).ron).toBe(64000);
expect([payTable(16000, false).tsumoNonDealer, payTable(16000, false).tsumoDealer]).toEqual([16000, 32000]);
expect(payTable(16000, true).ron).toBe(96000);
expect(payTable(16000, true).tsumoDealer).toBe(32000);
});
test('3판 70부·4판 40부 이상은 만관, 헤아림 끄면 13판은 삼배만', () => {
expect(basicPoints(3, 70)).toEqual({ basic: 2000, limit: 'mangan' });
expect(basicPoints(4, 40)).toEqual({ basic: 2000, limit: 'mangan' });
expect(basicPoints(3, 60)).toEqual({ basic: 1920, limit: 'none' });
expect(basicPoints(13, 30, { ...DEFAULT_RULES, kazoeYakuman: false })).toEqual({ basic: 6000, limit: 'sanbaiman' });
});
test('절상만관: 4판30부·3판60부 → 만관 (옵션)', () => {
const kiri = { ...DEFAULT_RULES, kiriageMangan: true };
expect(basicPoints(4, 30, kiri).basic).toBe(2000);
expect(basicPoints(3, 60, kiri).basic).toBe(2000);
expect(basicPoints(3, 50, kiri).basic).toBe(1600);
});
});
describe('산마 지불 §5.7', () => {
test('쯔모손: 자 만관 쯔모 = 친 4000 + 자 2000 = 6000', () => {
const t = payTable(2000, false, true, 'tsumoLoss');
expect(t.tsumoDealer + t.tsumoNonDealer).toBe(6000);
});
test('쯔모손: 친 만관 쯔모 = 4000 × 2', () => {
expect(payTable(2000, true, true, 'tsumoLoss').tsumoDealer * 2).toBe(8000);
});
test('북가절반: 자 만관 쯔모 = 5000 + 3000, 친 만관 쯔모 = 6000 × 2', () => {
const t = payTable(2000, false, true, 'northBisection');
expect([t.tsumoDealer, t.tsumoNonDealer]).toEqual([5000, 3000]);
expect(payTable(2000, true, true, 'northBisection').tsumoDealer).toBe(6000);
});
test('북가절반 100 단위 올림: 자 30부 1판 쯔모 (기본 240) → 친 600, 자 400', () => {
const t = payTable(240, false, true, 'northBisection');
expect([t.tsumoDealer, t.tsumoNonDealer]).toEqual([600, 400]);
});
test('산마 론은 4인과 같다', () => {
expect(payTable(2000, false, true).ron).toBe(8000);
});
});
// ---------------------------------------------------------------- worked hands
describe('계산 예시 §6.6', () => {
test('1. 자 멘젠 론 핑후 30부 → 1000점', () => {
const sc = score({ hand: '234m567p789s22s45p', win: '3p' })!;
expect(keys(sc)).toEqual(['pinfu']);
expect([sc.han, sc.fu]).toEqual([1, 30]);
expect(total(sc, false, false)).toBe(1000);
});
test('2. 친 쯔모 리치·멘젠쯔모·탕야오·도라1, 28 → 30부, 3900 올', () => {
const sc = score({ hand: '234m678p444s33p57s', win: '6s', tsumo: true, dealer: true, extra: { riichi: 1, doraIndicators: k('1m') } })!;
expect(keys(sc)).toEqual(['dora', 'menzenTsumo', 'riichi', 'tanyao']);
expect([sc.han, sc.fu]).toEqual([4, 30]);
expect(payTable(sc.basic, true).tsumoDealer).toBe(3900);
expect(total(sc, true, true)).toBe(11700);
});
test('3. 자 론 혼일색(울기 2) + 백 + 도라 2 = 5판 만관 8000', () => {
const sc = score({ hand: '234m678m444z9m', win: '9m', melds: [meld('pon', '555z')], extra: { doraIndicators: k('8m') } })!;
expect(keys(sc)).toEqual(['dora', 'haku', 'honitsu']);
expect(sc.han).toBe(5);
expect(total(sc, false, false)).toBe(8000);
});
test('4. 칠대자 리치 론 도라 2 → 만관, 도라 0 → 3판 25부 3200', () => {
const a = score({ hand: '1133m5577p99s22z4z', win: '4z', extra: { riichi: 1, doraIndicators: k('4p') } })!;
expect(a.han).toBe(5);
expect(total(a, false, false)).toBe(8000);
const b = score({ hand: '1133m5577p99s22z4z', win: '4z', extra: { riichi: 1 } })!;
expect([b.han, b.fu, b.wait]).toEqual([3, 25, 'chiitoi']);
expect(total(b, false, false)).toBe(3200);
});
});
describe('1판 역', () => {
test('리치 + 핑후 + 일발 (론)', () => {
const sc = score({ hand: '234m567p789s22s45p', win: '6p', extra: { riichi: 1, ippatsu: true } })!;
expect(keys(sc)).toEqual(['ippatsu', 'pinfu', 'riichi']);
expect(total(sc, false, false)).toBe(3900);
});
test('일발은 리치 없이 붙지 않는다', () => {
const sc = score({ hand: '234m567p789s22s45p', win: '6p', extra: { ippatsu: true } })!;
expect(keys(sc)).toEqual(['pinfu']);
});
test('핑후 쯔모 20부: 핑후+쯔모+탕야오 3판 → 700/1300', () => {
const sc = score({ hand: '234m567p678s22p34s', win: '5s', tsumo: true })!;
expect(keys(sc)).toEqual(['menzenTsumo', 'pinfu', 'tanyao']);
expect(sc.fu).toBe(20);
const t = payTable(sc.basic, false);
expect([t.tsumoNonDealer, t.tsumoDealer]).toEqual([700, 1300]);
});
test('울기 탕야오(쿠이탕) 샤보 론 22 → 30부 1000, 쿠이탕 끄면 역 없음', () => {
const sc = score({ hand: '567p678s55s44p', win: '4p', melds: [meld('chi', '234m')] })!;
expect(keys(sc)).toEqual(['tanyao']);
expect([sc.han, sc.fu]).toEqual([1, 30]);
expect(score({ hand: '567p678s55s44p', win: '4p', melds: [meld('chi', '234m')], extra: { rules: { ...DEFAULT_RULES, kuitan: false } } })).toBeNull();
});
test('울기 핑후형 20부 → 30부', () => {
const sc = score({ hand: '567p678s22p34s', win: '5s', melds: [meld('chi', '234m')] })!;
expect([sc.han, sc.fu]).toEqual([1, 30]);
expect(total(sc, false, false)).toBe(1000);
});
test('이페코 + 핑후 2판 30부 → 2000', () => {
const sc = score({ hand: '223344m678p99p34s', win: '5s' })!;
expect(keys(sc)).toEqual(['iipeikou', 'pinfu']);
expect(total(sc, false, false)).toBe(2000);
});
test('역패 백·발·중 각 1판 (울어도)', () => {
for (const [z, key] of [
['5z', 'haku'],
['6z', 'hatsu'],
['7z', 'chun'],
] as const) {
const sc = score({ hand: '234m567p88s34s', win: '2s', melds: [meld('pon', `${z[0]}${z[0]}${z[0]}z`)] })!;
expect(keys(sc)).toEqual([key]);
expect([sc.han, sc.fu]).toEqual([1, 30]);
}
});
test('장풍 동 각쯔 1판, 자풍 남 각쯔 1판, 손님 바람 서 각쯔는 역 없음', () => {
expect(keys(score({ hand: '234m567p88s34s', win: '2s', melds: [meld('pon', '111z')] })!)).toEqual(['roundWind']);
expect(keys(score({ hand: '234m567p88s34s', win: '2s', melds: [meld('pon', '222z')] })!)).toEqual(['seatWind']);
expect(score({ hand: '234m567p88s34s', win: '2s', melds: [meld('pon', '333z')] })).toBeNull();
});
test('친의 동(장풍=자풍) 각쯔 = 2판, 30부 친 론 2900', () => {
const sc = score({ hand: '234m567p88s34s', win: '5s', melds: [meld('pon', '111z')], dealer: true })!;
expect(keys(sc)).toEqual(['roundWind', 'seatWind']);
expect([sc.han, sc.fu]).toEqual([2, 30]);
expect(total(sc, true, false)).toBe(2900);
});
test('해저로월 / 하저로어 / 영상개화 / 창깡', () => {
const base = { hand: '567p678s22p34s', melds: [meld('pon', '555z')] };
expect(keys(score({ ...base, win: '5s', tsumo: true, extra: { haitei: true } })!)).toEqual(['haitei', 'haku']);
expect(keys(score({ ...base, win: '5s', extra: { houtei: true } })!)).toEqual(['haku', 'houtei']);
expect(keys(score({ ...base, win: '5s', tsumo: true, extra: { rinshan: true } })!)).toEqual(['haku', 'rinshan']);
expect(keys(score({ ...base, win: '5s', extra: { chankan: true } })!)).toEqual(['chankan', 'haku']);
// 해저는 쯔모에만, 하저는 론에만 붙는다.
expect(keys(score({ ...base, win: '5s', extra: { haitei: true } })!)).toEqual(['haku']);
});
test('영상개화 쯔모도 +2부 (핑후 쯔모가 아님)', () => {
const sc = score({ hand: '234m567p678s22p34s', win: '5s', tsumo: true, melds: [], extra: { rinshan: true } })!;
// 멘젠 핑후형: 핑후 20부가 유지되는 해석이 더 높다.
expect(sc.fu).toBe(20);
const open = score({ hand: '567p678s22p34s', win: '5s', tsumo: true, melds: [meld('ankan', '1111m')], extra: { rinshan: true } })!;
// 20 + 쯔모 2 + 암깡(요구) 32 = 54 → 60
expect(open.fu).toBe(60);
});
});
describe('2판 역', () => {
test('더블리치 2판 (리치 대신)', () => {
const sc = score({ hand: '234m567p789s22s45p', win: '3p', extra: { riichi: 2 } })!;
expect(keys(sc)).toEqual(['doubleRiichi', 'pinfu']);
expect(sc.han).toBe(3);
});
test('칠대자 25부 고정, 같은 패 4장은 2쌍이 아니다', () => {
const sc = score({ hand: '1133m5577p99s22z4z', win: '4z' })!;
expect(keys(sc)).toEqual(['chiitoi']);
expect(sc.fu).toBe(25);
expect(score({ hand: '1111m5577p99s22z4z', win: '4z' })).toBeNull();
});
test('삼색동순 멘젠 2판 / 울면 1판', () => {
const a = score({ hand: '123m123p123s789m5s', win: '5s' })!;
expect(keys(a)).toEqual(['sanshoku']);
expect([a.han, a.fu]).toEqual([2, 40]);
const b = score({ hand: '123p123s789m5s', win: '5s', melds: [meld('chi', '123m')] })!;
expect(keys(b)).toEqual(['sanshoku']);
expect(b.han).toBe(1);
});
test('일기통관 + 핑후 3판 30부 → 3900, 울면 일통 1판', () => {
const a = score({ hand: '123456789m22p45s', win: '3s' })!;
expect(keys(a)).toEqual(['ittsu', 'pinfu']);
expect(total(a, false, false)).toBe(3900);
const b = score({ hand: '456789m22p45s', win: '3s', melds: [meld('chi', '123m')] })!;
expect(keys(b)).toEqual(['ittsu']);
expect(b.han).toBe(1);
});
test('찬타 멘젠 2판, 장풍 머리 +2부, 단기 → 50부 3200', () => {
const sc = score({ hand: '123m789p999s789s1z', win: '1z' })!;
expect(keys(sc)).toEqual(['chanta']);
expect([sc.han, sc.fu]).toEqual([2, 50]);
expect(total(sc, false, false)).toBe(3200);
});
test('또이또이 (울기) 2판 40부 → 2600', () => {
const sc = score({ hand: '555s777p9m', win: '9m', melds: [meld('pon', '222m'), meld('pon', '888p')] })!;
expect(keys(sc)).toEqual(['toitoi']);
expect([sc.han, sc.fu]).toEqual([2, 40]);
expect(total(sc, false, false)).toBe(2600);
});
test('산안커 2판 50부 → 3200 (론으로 만든 각쯔는 명각)', () => {
const sc = score({ hand: '222m444p666s78s99p', win: '9s' })!;
expect(keys(sc)).toEqual(['sanankou']);
expect([sc.han, sc.fu]).toEqual([2, 50]);
expect(total(sc, false, false)).toBe(3200);
});
test('샤보 대기 론: 론 각쯔는 명각이라 산안커가 아니다', () => {
const sc = score({ hand: '222m444p66s99p789s', win: '6s', extra: { riichi: 1 } })!;
expect(keys(sc)).toEqual(['riichi']);
// 20 + 멘젠론 10 + 암각 4 + 암각 4 + 명각 2 = 40
expect(sc.fu).toBe(40);
});
test('삼색동각 2판', () => {
const sc = score({ hand: '333p333s456m7z', win: '7z', melds: [meld('pon', '333m')] })!;
expect(keys(sc)).toEqual(['sanshokuDoukou']);
expect([sc.han, sc.fu]).toEqual([2, 40]);
});
test('산깡쯔 + 탕야오 3판 70부 → 만관', () => {
const sc = score({ hand: '345s6p', win: '6p', melds: [meld('ankan', '2222m'), meld('ankan', '5555p'), meld('daiminkan', '8888s')] })!;
expect(keys(sc)).toEqual(['sankantsu', 'tanyao']);
expect([sc.han, sc.fu]).toEqual([3, 70]);
expect(total(sc, false, false)).toBe(8000);
});
test('소삼원 2판 + 백·발 = 4판 30부 → 7700', () => {
const sc = score({ hand: '77z234m56p', win: '7p', melds: [meld('pon', '555z'), meld('pon', '666z')] })!;
expect(keys(sc)).toEqual(['haku', 'hatsu', 'shousangen']);
expect([sc.han, sc.fu]).toEqual([4, 30]);
expect(total(sc, false, false)).toBe(7700);
});
test('혼노두 + 또이또이 4판 40부 → 만관', () => {
const sc = score({ hand: '111s99s33z', win: '9s', melds: [meld('pon', '111m'), meld('pon', '999p')] })!;
expect(keys(sc)).toEqual(['honroutou', 'toitoi']);
expect([sc.han, sc.fu]).toEqual([4, 40]);
expect(total(sc, false, false)).toBe(8000);
});
test('혼노두 + 칠대자', () => {
const sc = score({ hand: '1199m1199p11s22z3z', win: '3z' })!;
expect(keys(sc)).toEqual(['chiitoi', 'honroutou']);
expect(sc.han).toBe(4);
});
});
describe('3판 / 6판 역', () => {
test('혼일색 + 일기통관 (멘젠) = 5판 만관', () => {
const sc = score({ hand: '123p456p789p333z9p', win: '9p' })!;
expect(keys(sc)).toEqual(['honitsu', 'ittsu']);
expect(sc.han).toBe(5);
expect(total(sc, false, false)).toBe(8000);
});
test('준찬타 멘젠 3판 단기 40부 → 5200', () => {
const sc = score({ hand: '123m789m123p789s9p', win: '9p' })!;
expect(keys(sc)).toEqual(['junchan']);
expect([sc.han, sc.fu]).toEqual([3, 40]);
expect(total(sc, false, false)).toBe(5200);
});
test('준찬타 + 핑후: 23p+1p는 양면이라 핑후가 붙는 해석(4판 30부 7700)이 단기보다 높다', () => {
const sc = score({ hand: '123m789m123p789s1p', win: '1p' })!;
expect(keys(sc)).toEqual(['junchan', 'pinfu']);
expect([sc.han, sc.fu]).toEqual([4, 30]);
expect(total(sc, false, false)).toBe(7700);
});
test('준찬타 울기 2판', () => {
const sc = score({ hand: '789m123p789s1p', win: '1p', melds: [meld('chi', '123m')] })!;
expect(keys(sc)).toEqual(['junchan']);
expect(sc.han).toBe(2);
});
test('량페코 vs 칠대자 (223344m 556677p 88s): 량페코 쪽이 높다', () => {
const sc = score({ hand: '223344m556677p8s', win: '8s' })!;
expect(keys(sc)).toEqual(['ryanpeikou', 'tanyao']);
expect([sc.han, sc.fu]).toEqual([4, 40]);
expect(total(sc, false, false)).toBe(8000);
});
test('청일색 멘젠 6판 + 핑후 = 7판 하네만 12000 (핑후 해석이 판수가 높다)', () => {
const sc = score({ hand: '1233455677899m', win: '9m' })!;
expect(keys(sc)).toEqual(['chinitsu', 'pinfu']);
expect(sc.limit).toBe('haneman');
expect(total(sc, false, false)).toBe(12000);
});
test('청일색 울기 5판 만관', () => {
const sc = score({ hand: '345p678p999p5p', win: '5p', melds: [meld('chi', '123p')] })!;
expect(keys(sc)).toEqual(['chinitsu']);
expect(sc.han).toBe(5);
});
test('혼일색 울기 2판', () => {
const sc = score({ hand: '345p678p444z2p', win: '2p', melds: [meld('chi', '123p')] })!;
expect(keys(sc)).toEqual(['honitsu']);
expect(sc.han).toBe(2);
});
});
describe('역만', () => {
test('국사무쌍 32000, 13면 대기는 더블(옵션)', () => {
const a = score({ hand: '19m19p19s1234566z', win: '7z' })!;
expect(keys(a)).toEqual(['kokushi']);
expect(total(a, false, false)).toBe(32000);
const b = score({ hand: '19m19p19s1234567z', win: '1m' })!;
expect(keys(b)).toEqual(['kokushi13']);
expect(b.basic).toBe(16000);
expect(total(b, false, false)).toBe(64000);
const c = score({ hand: '19m19p19s1234567z', win: '1m', extra: { rules: { ...DEFAULT_RULES, doubleYakuman: false } } })!;
expect(c.basic).toBe(8000);
});
test('사암각 쯔모 = 역만, 단기 론 = 더블, 샤보 론 = 산안커+또이또이', () => {
const tsumo = score({ hand: '222m444p666s99p88s', win: '8s', tsumo: true })!;
expect(keys(tsumo)).toEqual(['suuankou']);
const tanki = score({ hand: '222m444p666s888s9p', win: '9p' })!;
expect(keys(tanki)).toEqual(['suuankouTanki']);
expect(tanki.basic).toBe(16000);
const ron = score({ hand: '222m444p666s99p88s', win: '8s' })!;
expect(keys(ron)).toEqual(['sanankou', 'toitoi']);
});
test('대삼원', () => {
const sc = score({ hand: '777z234m5p', win: '5p', melds: [meld('pon', '555z'), meld('pon', '666z')] })!;
expect(keys(sc)).toEqual(['daisangen']);
expect(sc.basic).toBe(8000);
});
test('소사희 / 대사희(더블)', () => {
const shou = score({ hand: '333z44z23m', win: '1m', melds: [meld('pon', '111z'), meld('pon', '222z')] })!;
expect(keys(shou)).toEqual(['shousuushii']);
const dai = score({ hand: '333z5m', win: '5m', melds: [meld('pon', '111z'), meld('pon', '222z'), meld('pon', '444z')] })!;
expect(keys(dai)).toEqual(['daisuushii']);
expect(dai.basic).toBe(16000);
});
test('자일색 (칠대자형 포함)', () => {
expect(keys(score({ hand: '111z222z333z55z66z', win: '6z', tsumo: true })!)).toContain('tsuuiisou');
expect(keys(score({ hand: '1122334455667z', win: '7z' })!)).toEqual(['tsuuiisou']);
});
test('청노두', () => {
expect(keys(score({ hand: '111m999m111p99s99p', win: '9p' })!)).toEqual(['chinroutou']);
});
test('녹일색: 발 있음 / 없음 모두 인정', () => {
expect(keys(score({ hand: '234s234s666s66z88s', win: '8s' })!)).toEqual(['ryuuiisou']);
expect(keys(score({ hand: '234s234s666s888s2s', win: '2s' })!)).toEqual(['ryuuiisou']);
});
test('구련보등 / 순정구련보등(9면 대기, 더블)', () => {
const pure = score({ hand: '1112345678999m', win: '5m' })!;
expect(keys(pure)).toEqual(['junseiChuuren']);
expect(pure.basic).toBe(16000);
const plain = score({ hand: '1112345678899m', win: '9m' })!;
expect(keys(plain)).toEqual(['chuuren']);
});
test('사깡쯔', () => {
const sc = score({ hand: '5p', win: '5p', melds: [meld('ankan', '2222m'), meld('daiminkan', '6666p'), meld('kakan', '8888s'), meld('ankan', '1111z')] })!;
expect(keys(sc)).toEqual(['suukantsu']);
});
test('천화 / 지화 (역만끼리 합산: 천화 + 사암각)', () => {
const t = score({ hand: '234m567p789s22s45p', win: '3p', tsumo: true, dealer: true, extra: { tenhou: true } })!;
expect(keys(t)).toEqual(['tenhou']);
expect(total(t, true, true)).toBe(48000);
const c = score({ hand: '234m567p789s22s45p', win: '3p', tsumo: true, extra: { chiihou: true } })!;
expect(keys(c)).toEqual(['chiihou']);
const both = score({ hand: '222m444p666s99p88s', win: '8s', tsumo: true, dealer: true, extra: { tenhou: true } })!;
expect(both.yakuman).toBe(2);
});
test('헤아림 역만: 13판 이상 (옵션 끄면 삼배만)', () => {
// 리치·쯔모·핑후·이페코·일통·청일색 = 12판 + 도라 3 = 15판
const big: Case = { hand: '123456789m22m45m', win: '6m', tsumo: true, extra: { riichi: 1, doraIndicators: k('1m') } };
const sc = score(big)!;
expect(keys(sc)).toEqual(['chinitsu', 'dora', 'iipeikou', 'ittsu', 'menzenTsumo', 'pinfu', 'riichi']);
expect(sc.han).toBe(15);
expect(sc.limit).toBe('kazoe');
expect(sc.basic).toBe(8000);
const off = score({ ...big, extra: { ...big.extra, rules: { ...DEFAULT_RULES, kazoeYakuman: false } } })!;
expect(off.limit).toBe('sanbaiman');
expect(off.basic).toBe(6000);
});
test('역만이 있으면 일반 역·도라는 더하지 않는다', () => {
const sc = score({ hand: '19m19p19s1234566z', win: '7z', extra: { riichi: 1, doraIndicators: k('6z') } })!;
expect(keys(sc)).toEqual(['kokushi']);
});
});
describe('고점법 §6.2', () => {
test('34555m에 5m: 양면(핑후) 해석과 단기 해석 중 높은 쪽(핑후 2판 30부 2000)', () => {
const sc = score({ hand: '3455m234p678p345s', win: '5m', extra: { riichi: 1 } })!;
expect(keys(sc)).toEqual(['pinfu', 'riichi', 'tanyao']);
expect([sc.han, sc.fu, sc.wait]).toEqual([3, 30, 'ryanmen']);
expect(total(sc, false, false)).toBe(3900);
});
test('2344m에 4m: 핑후 없이(역패 머리 아님이지만 각쯔 있음) 간짱·단기 등 부가 높은 해석', () => {
// 444m 각쯔가 있으면 핑후 불가 → 단기(+2)가 양면보다 높다.
const sc = score({ hand: '2344m777p678p345s', win: '4m', extra: { riichi: 1 } })!;
expect(sc.wait).toBe('tanki');
expect(sc.fu).toBe(40);
});
test('가타아가리: 역이 생기는 대기(4p 탕야오)로만 화료, 1p는 역 없음', () => {
const base = { hand: '567p678s55s23p', melds: [meld('chi', '234m')] };
expect(score({ ...base, win: '1p' })).toBeNull();
expect(keys(score({ ...base, win: '4p' })!)).toEqual(['tanyao']);
});
test('역 없는 손: 도라만으로는 화료 불가', () => {
expect(score({ hand: '567p678s55s44p', win: '4p', melds: [meld('chi', '123m')], extra: { doraIndicators: k('4p'), aka: 2 } })).toBeNull();
});
});
describe('부 계산 §6.3', () => {
test('예: 멘젠 론, 중 암각 + 9s 암각 + 간짱 → 48 → 50부', () => {
const sc = score({ hand: '777z999s234m4p6p22m', win: '5p' })!;
expect(sc.fu).toBe(50);
expect(keys(sc)).toEqual(['chun']);
});
test('명각 중장 2 / 요구 4, 암각 4 / 8, 명깡 8 / 16, 암깡 16 / 32', () => {
// 울기 (명각 5m), 나머지 순쯔 → 20 + 2 = 22 → 30 (탕야오)
expect(score({ hand: '234p678s22s34m', win: '5m', melds: [meld('pon', '555m')] })!.fu).toBe(30);
// 명깡 중장 8: 20 + 8 = 28 → 30
expect(score({ hand: '234p678s22s34m', win: '5m', melds: [meld('daiminkan', '5555m')] })!.fu).toBe(30);
// 명깡 요구(백) 16: 20 + 16 = 36 → 40
expect(score({ hand: '234p678s22s34m', win: '5m', melds: [meld('daiminkan', '5555z')] })!.fu).toBe(40);
// 암깡 요구(백) 32 + 멘젠론 10 = 62 → 70 (백 1판)
expect(score({ hand: '234p678s22s34m', win: '5m', melds: [meld('ankan', '5555z')] })!.fu).toBe(70);
// 암깡 중장 16 + 멘젠론: 20+10+16 = 46 → 50 (리치)
expect(score({ hand: '234p678s22s34m', win: '5m', melds: [meld('ankan', '5555m')], extra: { riichi: 1 } })!.fu).toBe(50);
});
test('머리 부수: 삼원패 2, 자풍 2, 연풍패 4 (옵션 2)', () => {
const base = { hand: '234m567p999s34s', win: '2s', extra: { riichi: 1 as const } };
// 20 + 10 + 9s 암각 8 + 머리
expect(score({ ...base, hand: base.hand + '55z' })!.fu).toBe(40);
expect(score({ ...base, hand: base.hand + '22z' })!.fu).toBe(40);
expect(score({ ...base, hand: base.hand + '11z', dealer: true })!.fu).toBe(50);
expect(score({ ...base, hand: base.hand + '11z', dealer: true, extra: { riichi: 1, rules: { ...DEFAULT_RULES, renpuuPairFu: 2 } } })!.fu).toBe(40);
});
test('변짱 12_ / _89 +2부', () => {
const pen = score({ hand: '12m567p999s22s678p', win: '3m', extra: { riichi: 1 } })!;
expect(pen.wait).toBe('penchan');
expect(pen.fu).toBe(40);
const pen9 = score({ hand: '89m567p999s22s678p', win: '7m', extra: { riichi: 1 } })!;
expect(pen9.wait).toBe('penchan');
expect(pen9.fu).toBe(40);
});
});
describe('도라 §6.2', () => {
test('표시패 다음 패: 9m → 1m, 북 → 동, 중 → 백, 산마 1m → 9m / 9m → 1m', () => {
expect(doraFromIndicator(one('9m'), false)).toBe(one('1m'));
expect(doraFromIndicator(one('4z'), false)).toBe(one('1z'));
expect(doraFromIndicator(one('7z'), false)).toBe(one('5z'));
expect(doraFromIndicator(one('1m'), true)).toBe(one('9m'));
expect(doraFromIndicator(one('9m'), true)).toBe(one('1m'));
expect(doraFromIndicator(one('3p'), true)).toBe(one('4p'));
});
test('같은 도라가 두 번 표시되면 장당 2', () => {
const sc = score({ hand: '234m567p789s22s45p', win: '3p', extra: { doraIndicators: k('1m1m') } })!;
expect(sc.yaku.find((y) => y.key === 'dora')!.han).toBe(2);
});
test('우라도라는 리치 화료자만', () => {
const a = score({ hand: '234m567p789s22s45p', win: '3p', extra: { uraIndicators: k('1s') } })!;
expect(keys(a)).toEqual(['pinfu']);
const b = score({ hand: '234m567p789s22s45p', win: '3p', extra: { riichi: 1, uraIndicators: k('1s') } })!;
expect(b.yaku.find((y) => y.key === 'ura')!.han).toBe(2);
});
test('적도라 장당 +1, 깡의 도라는 4장', () => {
const sc = score({ hand: '234p678s22s34m', win: '5m', melds: [meld('ankan', '5555m')], extra: { riichi: 1, aka: 1, doraIndicators: k('4m') } })!;
expect(sc.yaku.find((y) => y.key === 'aka')!.han).toBe(1);
expect(sc.yaku.find((y) => y.key === 'dora')!.han).toBe(5);
});
test('북 뽑기: 장당 +1, 북이 도라면 뺀 북도 도라', () => {
const sc = score({ hand: '234p567p678s22s34p', win: '5p', extra: { riichi: 1, kita: 2, doraIndicators: k('3z'), sanma: true } })!;
expect(sc.yaku.find((y) => y.key === 'kita')!.han).toBe(2);
expect(sc.yaku.find((y) => y.key === 'dora')!.han).toBe(2);
});
});

View File

@@ -0,0 +1,395 @@
/**
* 역·부·점수 계산 (docs/games/mahjong.md §6). Pure functions on tile kinds so the calculator can be tested on its own.
* Every reading of the hand (decomposition × wait position, 칠대자, 국사) is scored and the highest one is kept (§6.2).
*/
import { decompositions, isChiitoi, isKokushi } from './agari';
import { GREEN, YAOCHU, counts34, doraFromIndicator, isDragon, isHonor, isTerminal, isWind, isYaochu, NORTH, type Kind } from './tiles';
export type MeldType = 'chi' | 'pon' | 'daiminkan' | 'kakan' | 'ankan';
/** A called / kan set by kind (chi: lowest kind). */
export interface MeldShape {
type: MeldType;
k: Kind;
}
export interface ScoreRules {
kuitan: boolean;
kiriageMangan: boolean;
kazoeYakuman: boolean;
doubleYakuman: boolean;
renpuuPairFu: number;
}
export const DEFAULT_RULES: ScoreRules = { kuitan: true, kiriageMangan: false, kazoeYakuman: true, doubleYakuman: true, renpuuPairFu: 4 };
export interface WinInput {
/** Concealed tiles without the winning tile. */
hand: Kind[];
win: Kind;
melds: MeldShape[];
tsumo: boolean;
seatWind: Kind;
roundWind: Kind;
/** 0 none, 1 리치, 2 더블리치. */
riichi?: 0 | 1 | 2;
ippatsu?: boolean;
haitei?: boolean;
houtei?: boolean;
rinshan?: boolean;
chankan?: boolean;
tenhou?: boolean;
chiihou?: boolean;
doraIndicators?: Kind[];
uraIndicators?: Kind[];
aka?: number;
kita?: number;
sanma?: boolean;
rules?: ScoreRules;
}
export type WaitType = 'ryanmen' | 'kanchan' | 'penchan' | 'tanki' | 'shanpon' | 'chiitoi' | 'kokushi';
export type LimitName = 'none' | 'mangan' | 'haneman' | 'baiman' | 'sanbaiman' | 'kazoe' | 'yakuman';
export interface YakuEntry {
key: string;
name: string;
han: number;
/** Yakuman multiplier (1 or 2) for 역만 entries. */
yakuman?: number;
}
export interface WinScore {
/** Yaku first, then dora entries (도라·적도라·우라도라·북). */
yaku: YakuEntry[];
han: number;
fu: number;
/** Total yakuman multiplier (0 = none; 헤아림 역만 counts 1). */
yakuman: number;
basic: number;
limit: LimitName;
wait: WaitType;
}
export const LIMIT_KO: Record<LimitName, string> = {
none: '',
mangan: '만관',
haneman: '하네만',
baiman: '배만',
sanbaiman: '삼배만',
kazoe: '헤아림 역만',
yakuman: '역만',
};
const ceil100 = (n: number) => Math.ceil(n / 100) * 100;
// ---------------------------------------------------------------- basic points / payments
export function basicPoints(han: number, fu: number, rules: ScoreRules = DEFAULT_RULES): { basic: number; limit: LimitName } {
if (han >= 13) return rules.kazoeYakuman ? { basic: 8000, limit: 'kazoe' } : { basic: 6000, limit: 'sanbaiman' };
if (han >= 11) return { basic: 6000, limit: 'sanbaiman' };
if (han >= 8) return { basic: 4000, limit: 'baiman' };
if (han >= 6) return { basic: 3000, limit: 'haneman' };
if (han >= 5) return { basic: 2000, limit: 'mangan' };
const b = fu * 2 ** (han + 2);
if (b >= 2000) return { basic: 2000, limit: 'mangan' };
if (rules.kiriageMangan && ((han === 4 && fu === 30) || (han === 3 && fu === 60))) return { basic: 2000, limit: 'mangan' };
return { basic: b, limit: 'none' };
}
export interface PayTable {
ron: number;
/** Tsumo: what the dealer pays (non-dealer win) — or what each player pays (dealer win). */
tsumoDealer: number;
/** Tsumo: what each other non-dealer pays (non-dealer win). */
tsumoNonDealer: number;
}
/**
* 지불액 (§6.4, 산마 §5.7). Sanma `northBisection`: the missing North share is split between the two payers
* (each payment rounded up to 100 separately).
*/
export function payTable(basic: number, dealer: boolean, sanma = false, sanmaTsumo: 'tsumoLoss' | 'northBisection' = 'tsumoLoss'): PayTable {
if (dealer) {
const extra = sanma && sanmaTsumo === 'northBisection' ? basic : 0;
return { ron: ceil100(basic * 6), tsumoDealer: ceil100(basic * 2 + extra), tsumoNonDealer: ceil100(basic * 2 + extra) };
}
const extra = sanma && sanmaTsumo === 'northBisection' ? basic / 2 : 0;
return { ron: ceil100(basic * 4), tsumoDealer: ceil100(basic * 2 + extra), tsumoNonDealer: ceil100(basic + extra) };
}
// ---------------------------------------------------------------- scoring
interface FullSet {
t: 'shuntsu' | 'koutsu' | 'kantsu';
k: Kind;
open: boolean;
/** Concealed triplet / ankan for 암각 counting (a triplet completed by ron is not). */
ankou: boolean;
}
interface Candidate {
yaku: YakuEntry[];
fu: number;
wait: WaitType;
}
const YAKUMAN_NAME: Record<string, string> = {
kokushi: '국사무쌍',
kokushi13: '국사무쌍 13면 대기',
suuankou: '사암각',
suuankouTanki: '사암각 단기',
daisangen: '대삼원',
shousuushii: '소사희',
daisuushii: '대사희',
tsuuiisou: '자일색',
chinroutou: '청노두',
ryuuiisou: '녹일색',
chuuren: '구련보등',
junseiChuuren: '순정구련보등',
suukantsu: '사깡쯔',
tenhou: '천화',
chiihou: '지화',
};
const DOUBLE_YAKUMAN = new Set(['kokushi13', 'suuankouTanki', 'junseiChuuren', 'daisuushii']);
function yakuman(key: string, rules: ScoreRules): YakuEntry {
const mult = DOUBLE_YAKUMAN.has(key) && rules.doubleYakuman ? 2 : 1;
return { key, name: YAKUMAN_NAME[key]!, han: 13 * mult, yakuman: mult };
}
const y = (key: string, name: string, han: number): YakuEntry => ({ key, name, han });
function contextYakuman(inp: WinInput, rules: ScoreRules): YakuEntry[] {
const out: YakuEntry[] = [];
if (inp.tenhou) out.push(yakuman('tenhou', rules));
if (inp.chiihou) out.push(yakuman('chiihou', rules));
return out;
}
/** Yaku that only depend on the situation (riichi, tsumo, last tile, …). */
function contextYaku(inp: WinInput, menzen: boolean): YakuEntry[] {
const out: YakuEntry[] = [];
if (inp.riichi === 2) out.push(y('doubleRiichi', '더블리치', 2));
else if (inp.riichi === 1) out.push(y('riichi', '리치', 1));
if (inp.riichi && inp.ippatsu) out.push(y('ippatsu', '일발', 1));
if (menzen && inp.tsumo) out.push(y('menzenTsumo', '멘젠쯔모', 1));
if (inp.tsumo && inp.haitei) out.push(y('haitei', '해저로월', 1));
if (!inp.tsumo && inp.houtei) out.push(y('houtei', '하저로어', 1));
if (inp.tsumo && inp.rinshan) out.push(y('rinshan', '영상개화', 1));
if (!inp.tsumo && inp.chankan) out.push(y('chankan', '창깡', 1));
return out;
}
/** 혼일색 / 청일색 / 탕야오 / 혼노두 over all tiles. */
function colourYaku(all: Kind[], menzen: boolean, rules: ScoreRules): YakuEntry[] {
const out: YakuEntry[] = [];
if (all.every((k) => !isYaochu(k)) && (menzen || rules.kuitan)) out.push(y('tanyao', '탕야오', 1));
const suits = new Set(all.filter((k) => k < 27).map((k) => Math.floor(k / 9)));
const honors = all.some(isHonor);
if (suits.size === 1) {
if (honors) out.push(y('honitsu', '혼일색', menzen ? 3 : 2));
else out.push(y('chinitsu', '청일색', menzen ? 6 : 5));
}
if (all.every(isYaochu) && honors && all.some(isTerminal)) out.push(y('honroutou', '혼노두', 2));
return out;
}
function allTileYakuman(all: Kind[], rules: ScoreRules): YakuEntry[] {
const out: YakuEntry[] = [];
if (all.every(isHonor)) out.push(yakuman('tsuuiisou', rules));
if (all.every(isTerminal)) out.push(yakuman('chinroutou', rules));
if (all.every((k) => GREEN.includes(k))) out.push(yakuman('ryuuiisou', rules));
return out;
}
function meldKinds(m: MeldShape): Kind[] {
if (m.type === 'chi') return [m.k, m.k + 1, m.k + 2];
if (m.type === 'pon') return [m.k, m.k, m.k];
return [m.k, m.k, m.k, m.k];
}
function scoreStandard(inp: WinInput, rules: ScoreRules, pair: Kind, concealed: { t: 'shuntsu' | 'koutsu'; k: Kind }[], winSet: number, all: Kind[]): Candidate {
const menzen = inp.melds.every((m) => m.type === 'ankan');
const sets: FullSet[] = concealed.map((s, i) => ({ t: s.t, k: s.k, open: false, ankou: s.t === 'koutsu' && !(i === winSet && !inp.tsumo) }));
for (const m of inp.melds) {
if (m.type === 'chi') sets.push({ t: 'shuntsu', k: m.k, open: true, ankou: false });
else if (m.type === 'pon') sets.push({ t: 'koutsu', k: m.k, open: true, ankou: false });
else sets.push({ t: 'kantsu', k: m.k, open: m.type !== 'ankan', ankou: m.type === 'ankan' });
}
const w = inp.win;
let wait: WaitType;
if (winSet < 0) wait = 'tanki';
else {
const s = concealed[winSet]!;
if (s.t === 'koutsu') wait = 'shanpon';
else if (w === s.k + 1) wait = 'kanchan';
else if ((w === s.k && s.k % 9 === 6) || (w === s.k + 2 && s.k % 9 === 0)) wait = 'penchan';
else wait = 'ryanmen';
}
const triplets = sets.filter((s) => s.t !== 'shuntsu');
const shuntsu = sets.filter((s) => s.t === 'shuntsu');
const ankou = triplets.filter((s) => s.ankou).length;
const kans = sets.filter((s) => s.t === 'kantsu').length;
const dragonSets = triplets.filter((s) => isDragon(s.k)).length;
const windSets = triplets.filter((s) => isWind(s.k)).length;
// ---- yakuman
const ym: YakuEntry[] = contextYakuman(inp, rules);
if (ankou === 4) ym.push(yakuman(wait === 'tanki' ? 'suuankouTanki' : 'suuankou', rules));
if (dragonSets === 3) ym.push(yakuman('daisangen', rules));
if (windSets === 4) ym.push(yakuman('daisuushii', rules));
else if (windSets === 3 && isWind(pair)) ym.push(yakuman('shousuushii', rules));
ym.push(...allTileYakuman(all, rules));
if (kans === 4) ym.push(yakuman('suukantsu', rules));
if (inp.melds.length === 0) {
const c = counts34(all);
const suit = all[0]! < 27 ? Math.floor(all[0]! / 9) : -1;
if (suit >= 0 && all.every((k) => k < 27 && Math.floor(k / 9) === suit)) {
const base = suit * 9;
const ok = c[base]! >= 3 && c[base + 8]! >= 3 && [1, 2, 3, 4, 5, 6, 7].every((i) => c[base + i]! >= 1);
if (ok) {
const before = counts34(inp.hand);
const pure = [3, 1, 1, 1, 1, 1, 1, 1, 3].every((n, i) => before[base + i] === n);
ym.push(yakuman(pure ? 'junseiChuuren' : 'chuuren', rules));
}
}
}
if (ym.length) return { yaku: ym, fu: 0, wait };
// ---- normal yaku
const yaku = contextYaku(inp, menzen);
const yakuhaiPair = isDragon(pair) || pair === inp.seatWind || pair === inp.roundWind;
const pinfu = menzen && inp.melds.length === 0 && shuntsu.length === 4 && !yakuhaiPair && wait === 'ryanmen';
if (pinfu) yaku.push(y('pinfu', '핑후', 1));
yaku.push(...colourYaku(all, menzen, rules).filter((e) => e.key !== 'honroutou' || triplets.length === 4));
if (menzen) {
const cnt = new Map<Kind, number>();
for (const s of shuntsu) cnt.set(s.k, (cnt.get(s.k) ?? 0) + 1);
let pairs = 0;
for (const n of cnt.values()) pairs += Math.floor(n / 2);
if (pairs >= 2) yaku.push(y('ryanpeikou', '량페코', 3));
else if (pairs === 1) yaku.push(y('iipeikou', '이페코', 1));
}
for (const s of triplets) {
if (s.k === 31) yaku.push(y('haku', '역패: 백', 1));
if (s.k === 32) yaku.push(y('hatsu', '역패: 발', 1));
if (s.k === 33) yaku.push(y('chun', '역패: 중', 1));
if (s.k === inp.roundWind) yaku.push(y('roundWind', '역패: 장풍', 1));
if (s.k === inp.seatWind) yaku.push(y('seatWind', '역패: 자풍', 1));
}
const shStarts = new Set(shuntsu.map((s) => s.k));
for (let n = 0; n < 7; n++) {
if (shStarts.has(n) && shStarts.has(9 + n) && shStarts.has(18 + n)) {
yaku.push(y('sanshoku', '삼색동순', menzen ? 2 : 1));
break;
}
}
for (let s = 0; s < 3; s++) {
if (shStarts.has(s * 9) && shStarts.has(s * 9 + 3) && shStarts.has(s * 9 + 6)) {
yaku.push(y('ittsu', '일기통관', menzen ? 2 : 1));
break;
}
}
const outside = (s: FullSet) => (s.t === 'shuntsu' ? s.k % 9 === 0 || s.k % 9 === 6 : isYaochu(s.k));
if (shuntsu.length > 0 && sets.every(outside) && isYaochu(pair)) {
const honors = isHonor(pair) || sets.some((s) => isHonor(s.k));
if (honors) yaku.push(y('chanta', '찬타', menzen ? 2 : 1));
else yaku.push(y('junchan', '준찬타', menzen ? 3 : 2));
}
if (triplets.length === 4) yaku.push(y('toitoi', '또이또이', 2));
if (ankou === 3) yaku.push(y('sanankou', '산안커', 2));
const tripNums = triplets.filter((s) => s.k < 27);
for (let n = 0; n < 9; n++) {
if ([n, 9 + n, 18 + n].every((k) => tripNums.some((s) => s.k === k))) {
yaku.push(y('sanshokuDoukou', '삼색동각', 2));
break;
}
}
if (kans === 3) yaku.push(y('sankantsu', '산깡쯔', 2));
if (dragonSets === 2 && isDragon(pair)) yaku.push(y('shousangen', '소삼원', 2));
// ---- fu (§6.3)
let fu: number;
if (pinfu) fu = inp.tsumo ? 20 : 30;
else {
let raw = 20 + (menzen && !inp.tsumo ? 10 : 0) + (inp.tsumo ? 2 : 0);
for (const s of triplets) {
let f = isYaochu(s.k) ? 4 : 2;
if (s.t === 'kantsu') f *= 4;
if (s.ankou) f *= 2;
raw += f;
}
if (isDragon(pair)) raw += 2;
else if (pair === inp.seatWind && pair === inp.roundWind) raw += rules.renpuuPairFu;
else if (pair === inp.seatWind || pair === inp.roundWind) raw += 2;
if (wait === 'kanchan' || wait === 'penchan' || wait === 'tanki') raw += 2;
if (!menzen && raw === 20) raw = 30;
fu = Math.ceil(raw / 10) * 10;
}
return { yaku, fu, wait };
}
function doraEntries(inp: WinInput, all: Kind[]): YakuEntry[] {
const sanma = !!inp.sanma;
const kita = inp.kita ?? 0;
const withKita = [...all, ...Array<Kind>(kita).fill(NORTH)];
const countFor = (inds: Kind[]) => inds.reduce((n, ind) => n + withKita.filter((k) => k === doraFromIndicator(ind, sanma)).length, 0);
const out: YakuEntry[] = [];
const dora = countFor(inp.doraIndicators ?? []);
if (dora) out.push(y('dora', `도라 ${dora}`, dora));
if (inp.aka) out.push(y('aka', `적도라 ${inp.aka}`, inp.aka));
if (inp.riichi) {
const ura = countFor(inp.uraIndicators ?? []);
if (ura) out.push(y('ura', `우라도라 ${ura}`, ura));
}
if (kita) out.push(y('kita', `북 ${kita}`, kita));
return out;
}
/** Scores a finished hand; null if it is not a winning shape or has no yaku (dora alone is not a yaku). */
export function scoreWin(inp: WinInput): WinScore | null {
const rules = inp.rules ?? DEFAULT_RULES;
const concealed = [...inp.hand, inp.win];
const all = [...concealed, ...inp.melds.flatMap(meldKinds)];
const c = counts34(concealed);
const cands: Candidate[] = [];
const menzen = inp.melds.every((m) => m.type === 'ankan');
if (inp.melds.length === 0 && isKokushi(c)) {
const before = counts34(inp.hand);
const thirteen = YAOCHU.every((k) => before[k] === 1);
cands.push({ yaku: [...contextYakuman(inp, rules), yakuman(thirteen ? 'kokushi13' : 'kokushi', rules)], fu: 0, wait: 'kokushi' });
}
if (inp.melds.length === 0 && isChiitoi(c)) {
const ym = [...contextYakuman(inp, rules), ...allTileYakuman(all, rules)];
if (ym.length) cands.push({ yaku: ym, fu: 25, wait: 'chiitoi' });
else cands.push({ yaku: [...contextYaku(inp, menzen), y('chiitoi', '칠대자', 2), ...colourYaku(all, menzen, rules)], fu: 25, wait: 'chiitoi' });
}
for (const dec of decompositions(c, 4 - inp.melds.length)) {
if (dec.pair === inp.win) cands.push(scoreStandard(inp, rules, dec.pair, dec.sets, -1, all));
const seen = new Set<string>();
dec.sets.forEach((s, i) => {
const has = s.t === 'koutsu' ? s.k === inp.win : inp.win >= s.k && inp.win <= s.k + 2;
const key = `${s.t}${s.k}`;
if (!has || seen.has(key)) return;
seen.add(key);
cands.push(scoreStandard(inp, rules, dec.pair, dec.sets, i, all));
});
}
let best: WinScore | null = null;
const dora = doraEntries(inp, all);
const doraHan = dora.reduce((n, e) => n + e.han, 0);
for (const cand of cands) {
if (cand.yaku.length === 0) continue;
let res: WinScore;
const ymult = cand.yaku.reduce((n, e) => n + (e.yakuman ?? 0), 0);
if (ymult > 0) {
res = { yaku: cand.yaku, han: cand.yaku.reduce((n, e) => n + e.han, 0), fu: cand.fu, yakuman: ymult, basic: 8000 * ymult, limit: 'yakuman', wait: cand.wait };
} else {
const han = cand.yaku.reduce((n, e) => n + e.han, 0) + doraHan;
const { basic, limit } = basicPoints(han, cand.fu, rules);
res = { yaku: [...cand.yaku, ...dora], han, fu: cand.fu, yakuman: limit === 'kazoe' ? 1 : 0, basic, limit, wait: cand.wait };
}
if (!best || res.basic > best.basic || (res.basic === best.basic && (res.han > best.han || (res.han === best.han && res.fu > best.fu)))) best = res;
}
return best;
}

View File

@@ -0,0 +1,97 @@
/**
* 마작패 (docs/games/mahjong.md §3, §8.1). Tile ids 0..135, kind = id >> 2 (0..33):
* 0-8 1m-9m, 9-17 1p-9p, 18-26 1s-9s, 27-30 동남서북, 31-33 백발중.
* Over the wire a tile is a short code "<kind><copy>[r]", e.g. "5m0r" (red five), "3p2", "7z1" (중) — unique per
* tile so hidden-information leak checks stay meaningful.
*/
export type TileId = number;
export type Kind = number;
export const EAST = 27;
export const SOUTH = 28;
export const WEST = 29;
export const NORTH = 30;
export const HAKU = 31;
export const HATSU = 32;
export const CHUN = 33;
export const KIND_CODES: string[] = [
...['m', 'p', 's'].flatMap((s) => Array.from({ length: 9 }, (_, i) => `${i + 1}${s}`)),
...Array.from({ length: 7 }, (_, i) => `${i + 1}z`),
];
const KIND_BY_CODE: Record<string, Kind> = Object.fromEntries(KIND_CODES.map((c, i) => [c, i]));
export const kindOf = (id: TileId): Kind => id >> 2;
export const isHonor = (k: Kind): boolean => k >= 27;
export const isDragon = (k: Kind): boolean => k >= 31;
export const isWind = (k: Kind): boolean => k >= 27 && k <= 30;
export const isTerminal = (k: Kind): boolean => k < 27 && (k % 9 === 0 || k % 9 === 8);
export const isYaochu = (k: Kind): boolean => k >= 27 || k % 9 === 0 || k % 9 === 8;
export const suitOf = (k: Kind): number => (k < 27 ? Math.floor(k / 9) : 3);
export const numOf = (k: Kind): number => (k < 27 ? (k % 9) + 1 : 0);
export const YAOCHU: Kind[] = [0, 8, 9, 17, 18, 26, 27, 28, 29, 30, 31, 32, 33];
export const GREEN: Kind[] = [19, 20, 21, 23, 25, HATSU];
/** Kinds missing in 3-player (산마): 2m~8m. */
export const isSanmaRemoved = (k: Kind): boolean => k >= 1 && k <= 7;
export function allTiles(sanma: boolean): TileId[] {
const out: TileId[] = [];
for (let id = 0; id < 136; id++) if (!(sanma && isSanmaRemoved(kindOf(id)))) out.push(id);
return out;
}
/** Red fives in use: 4인 3 → 5m·5p·5s, 2 → 5p·5s; 산마 → 5p·5s (or none). Copy 0 of each five is the red one. */
export function redTiles(akaDora: number, sanma: boolean): TileId[] {
if (akaDora <= 0) return [];
if (sanma || akaDora === 2) return [52, 88];
return [16, 52, 88];
}
export function tileCode(id: TileId, red: readonly TileId[]): string {
return `${KIND_CODES[kindOf(id)]}${id & 3}${red.includes(id) ? 'r' : ''}`;
}
/** Inverse of tileCode (the red marker is ignored); null when malformed. */
export function parseTileCode(code: string): TileId | null {
const m = /^([1-9][mps]|[1-7]z)([0-3])r?$/.exec(code);
if (!m) return null;
return KIND_BY_CODE[m[1]!]! * 4 + Number(m[2]);
}
export const kindCode = (k: Kind): string => KIND_CODES[k]!;
export const kindFromCode = (code: string): Kind | null => KIND_BY_CODE[code] ?? null;
/** Dora shown by an indicator (§6.2, 산마: 1m ↔ 9m). */
export function doraFromIndicator(ind: Kind, sanma: boolean): Kind {
if (ind < 27) {
if (sanma && ind === 0) return 8;
if (sanma && ind === 8) return 0;
return ind - (ind % 9) + (((ind % 9) + 1) % 9);
}
if (ind <= 30) return ind === 30 ? 27 : ind + 1;
return ind === 33 ? 31 : ind + 1;
}
const SUIT_KO = ['만수', '통수', '삭수'];
const HONOR_KO = ['동', '남', '서', '북', '백', '발', '중'];
/** Screen-reader / log name, e.g. "만수 5", "동", "중". */
export function kindNameKo(k: Kind): string {
return k < 27 ? `${SUIT_KO[suitOf(k)]} ${numOf(k)}` : HONOR_KO[k - 27]!;
}
/** Short name, e.g. "5만", "3통", "7삭", "동". */
export function kindShortKo(k: Kind): string {
return k < 27 ? `${numOf(k)}${['만', '통', '삭'][suitOf(k)]}` : HONOR_KO[k - 27]!;
}
export const WIND_KO = ['동', '남', '서', '북'];
/** Sort key: 만→통→삭→자, by number, red five before plain fives. */
export function compareTiles(a: TileId, b: TileId): number {
return a - b;
}
export function counts34(kinds: Iterable<Kind>): number[] {
const c = new Array<number>(34).fill(0);
for (const k of kinds) c[k]!++;
return c;
}

View File

@@ -0,0 +1,363 @@
/** 마작 options, actions, state and view types (docs/games/mahjong.md §2, §8). */
import { z } from 'zod';
import type { PlayerId } from '@bg/engine';
import type { MeldType, WaitType, LimitName } from './scoring';
import type { Kind, TileId } from './tiles';
const lit = <T extends string | number>(...vals: T[]) =>
z.union(vals.map((v) => z.literal(v)) as unknown as [z.ZodLiteral<T>, z.ZodLiteral<T>, ...z.ZodLiteral<T>[]]);
const POINT_CHOICES = [15000, 20000, 25000, 30000, 35000, 40000, 50000] as const;
const pointLabels = Object.fromEntries(POINT_CHOICES.map((p) => [p, `${p.toLocaleString('en-US')}점`]));
export const mahjongOptionsSchema = z.object({
length: z.enum(['tonpuu', 'hanchan']).default('hanchan').meta({ title: '대국 길이', labels: { tonpuu: '동풍전(동1~동4)', hanchan: '반장전(동1~남4)' } }),
startPoints: lit<'auto' | (typeof POINT_CHOICES)[number]>('auto', ...POINT_CHOICES)
.default('auto')
.meta({ title: '시작 점수', labels: { auto: '인원 기본(4인 25,000 · 3인 35,000)', ...pointLabels } }),
returnPoints: lit<'auto' | (typeof POINT_CHOICES)[number]>('auto', ...POINT_CHOICES)
.default('auto')
.meta({ title: '반환점(오카·서입 기준)', labels: { auto: '인원 기본(4인 30,000 · 3인 40,000)', ...pointLabels } }),
uma: z
.enum(['auto', 'none', '5-10', '10-20', '10-30', '0', '15'])
.default('auto')
.meta({
title: '순위 우마',
labels: { auto: '인원 기본(4인 10-20 · 3인 15)', none: '없음(4인)', '5-10': '5-10(4인)', '10-20': '10-20(4인)', '10-30': '10-30(4인)', '0': '없음(3인)', '15': '15(3인)' },
}),
akaDora: lit<'auto' | 0 | 2 | 3>('auto', 0, 2, 3)
.default('auto')
.meta({ title: '적도라(빨간 5)', labels: { auto: '인원 기본(4인 3장 · 3인 2장)', 0: '없음', 2: '2장(5통·5삭)', 3: '3장(5만·5통·5삭, 3인은 2장)' } }),
kuitan: z.boolean().default(true).meta({ title: '울어도 탕야오 인정(쿠이탕)' }),
doubleRon: z.enum(['allow', 'headBump']).default('allow').meta({ title: '동시 론', labels: { allow: '더블론 허용', headBump: '머리튀기(가까운 1명만)' } }),
tripleRon: z.enum(['abort', 'allow']).default('abort').meta({ title: '3명 동시 론', labels: { abort: '삼가화 유국', allow: '모두 화료' } }),
tobi: z.boolean().default(true).meta({ title: '0점 미만이면 바로 종료(토비)' }),
agariYame: z.boolean().default(true).meta({ title: '올라스트 친이 1위면 종료(아가리야메)' }),
westEntry: z.boolean().default(true).meta({ title: '반환점 넘는 사람이 없으면 연장(서입·남입)' }),
renchanOn: z.enum(['tenpai', 'agari']).default('tenpai').meta({ title: '친 연장 조건', labels: { tenpai: '친 화료 또는 유국 때 친 텐파이', agari: '친 화료 때만' } }),
kiriageMangan: z.boolean().default(false).meta({ title: '절상만관(4판30부·3판60부 → 만관)' }),
kazoeYakuman: z.boolean().default(true).meta({ title: '헤아림 역만(13판 이상)' }),
doubleYakuman: z.boolean().default(true).meta({ title: '더블 역만' }),
abortiveDraws: z.boolean().default(true).meta({ title: '도중유국(구종구패·사풍연타·사깡유국·사가리치)' }),
nagashiMangan: z.boolean().default(true).meta({ title: '유국만관' }),
pao: z.boolean().default(true).meta({ title: '책임지불(대삼원·대사희)' }),
ankanChankanKokushi: z.boolean().default(true).meta({ title: '국사무쌍은 안깡도 창깡 가능' }),
renpuuPairFu: lit(2, 4).default(4).meta({ title: '연풍패 머리 부수', labels: { 2: '2부', 4: '4부' } }),
sanmaTsumo: z.enum(['tsumoLoss', 'northBisection']).default('tsumoLoss').meta({ title: '(3인) 쯔모 지불', labels: { tsumoLoss: '쯔모손(북가 몫 없음)', northBisection: '북가 몫을 둘이 반씩' } }),
sanmaHonba: lit(200, 300, 1000).default(200).meta({ title: '(3인) 본장 1개당 점수', labels: { 200: '200점', 300: '300점', 1000: '1,000점' } }),
sanmaNotenPool: lit(2000, 3000).default(2000).meta({ title: '(3인) 유국 텐파이료 합계', labels: { 2000: '2,000점', 3000: '3,000점' } }),
turnSeconds: z.number().int().min(5).max(60).default(15).meta({ title: '타패 제한 시간(초, 5~60)' }),
claimSeconds: z.number().int().min(3).max(20).default(8).meta({ title: '울기·론 응답 시간(초, 3~20)' }),
timeBankSeconds: z.number().int().min(0).max(300).default(60).meta({ title: '개인 예비 시간(초, 0~300)' }),
beginnerMode: z.boolean().default(true).meta({ title: '초보 모드 허용(대기패·역 힌트)' }),
});
export type MahjongOptions = z.infer<typeof mahjongOptionsSchema>;
/** Options with per-mode defaults filled in (§2: 3인은 3인 기본값). */
export interface ResolvedOptions extends Omit<MahjongOptions, 'startPoints' | 'returnPoints' | 'uma' | 'akaDora'> {
startPoints: number;
returnPoints: number;
uma: number[];
umaKey: string;
akaDora: number;
}
const code = z.string().regex(/^([1-9][mps]|[1-7]z)[0-3]r?$/);
export const mahjongActionSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('discard'), tile: code, riichi: z.boolean().optional() }),
z.object({ type: z.literal('tsumo') }),
z.object({ type: z.literal('ankan'), tile: code }),
z.object({ type: z.literal('kakan'), tile: code }),
z.object({ type: z.literal('kita') }),
z.object({ type: z.literal('kyuushu') }),
z.object({ type: z.literal('claim'), call: z.enum(['ron', 'pon', 'kan', 'chi']), tiles: z.array(code).max(3).optional() }),
z.object({ type: z.literal('pass') }),
z.object({ type: z.literal('ready') }),
z.object({ type: z.literal('setPref'), autoWin: z.boolean().optional(), noCalls: z.boolean().optional(), beginner: z.boolean().optional() }),
]);
export type MahjongClientAction = z.infer<typeof mahjongActionSchema>;
/** Client actions plus system-only ones (timer, leaving) that are not in the schema. */
export type MahjongAction = MahjongClientAction | { type: 'timeout' } | { type: 'leave' };
// ---------------------------------------------------------------- state
export type Seat = number;
export interface Meld {
type: MeldType;
tiles: TileId[];
called: TileId | null;
from: Seat | null;
}
export interface DiscardEntry {
tile: TileId;
tsumogiri: boolean;
/** Shown sideways (리치 선언 패, or the next one if that was called). */
riichi: boolean;
calledBy: Seat | null;
}
export interface ClaimChoice {
ron: boolean;
pon: TileId[][];
kan: TileId[][];
chi: TileId[][];
}
export type ClaimResponse = { type: 'pass' } | { type: 'ron' } | { type: 'pon' | 'kan' | 'chi'; tiles: TileId[] };
export interface ClaimWindow {
source: 'discard' | 'kakan' | 'ankan' | 'kita';
tile: TileId;
from: Seat;
/** Server-only: legal responses per seat (null = none). */
eligible: (ClaimChoice | null)[];
/** Server-only: whether the tile completes each seat's hand (for 동순·리치 후리텐). */
winning: boolean[];
responses: (ClaimResponse | null)[];
openedAt: number;
deadline: number;
/** The window never closes before this (information-leak mitigation, §5.3.2). */
minUntil: number;
/** The outcome is settled; only the minimum delay is left. */
decided: boolean;
/** The tile is a riichi declaration (paid when the window closes without ron). */
riichiPending: { double: boolean } | null;
}
export interface RiichiState {
discardIndex: number;
double: boolean;
ippatsu: boolean;
}
export interface HandState {
wall: TileId[];
/** 영상패 (always 4; refilled from the end of the live wall). */
rinshan: TileId[];
doraInd: TileId[];
uraInd: TileId[];
doraRevealed: number;
pendingKanDora: number;
hands: TileId[][];
drawn: (TileId | null)[];
melds: Meld[][];
kita: TileId[][];
discards: DiscardEntry[][];
riichi: (RiichiState | null)[];
/** Next discard of this seat is shown sideways (the riichi tile was called). */
sidewaysNext: boolean[];
tempFuriten: boolean[];
riichiFuriten: boolean[];
/** Cached tenpai waits of the 13-tile hand (updated whenever a seat's hand settles). */
waits: Kind[][];
turn: Seat;
stage: 'afterDraw' | 'afterCall' | 'claim' | 'ended';
/** Any call (incl. 안깡) happened this hand — ends 첫 순 effects. */
interrupted: boolean;
kanCount: number;
kanBySeat: number[];
lastDrawRinshan: boolean;
/** 4 kans by 2+ players: the next discard that is not ronned aborts the hand. */
suukanPending: boolean;
/** 구이가에: kinds the caller may not discard right after the call. */
kuikae: Kind[];
pao: ({ yakuman: 'daisangen' | 'daisuushii'; liable: Seat } | null)[];
claim: ClaimWindow | null;
turnStartedAt: number;
}
export interface WinnerRecord {
seat: Seat;
from: Seat | null;
han: number;
fu: number;
yaku: { name: string; han: number; yakuman?: number }[];
yakuman: number;
limit: LimitName;
wait: WaitType;
/** Hand value received (without 본장·공탁). */
points: number;
hand: TileId[];
melds: Meld[];
winTile: TileId;
uraIndicators: TileId[];
paoSeat: Seat | null;
}
export type AbortReason = 'kyuushu' | 'suufon' | 'suukan' | 'suucha' | 'sanchaho';
export interface HandResultRecord {
/** Hand label: wind (0..3) + number (0..3) + honba. */
roundWind: number;
handNumber: number;
honba: number;
kind: 'agari' | 'exhaustive' | 'abortive';
winners: WinnerRecord[];
tenpai: Seat[];
/** Revealed hands at the end (winners / tenpai players). */
shown: { seat: Seat; hand: TileId[] }[];
nagashi: Seat[];
abortiveReason: AbortReason | null;
/** Who declared the abort (구종구패). */
abortiveSeat: Seat | null;
deltas: number[];
pointsAfter: number[];
doraIndicators: TileId[];
/** 공탁 taken by the winner closest to the discarder. */
poolTaken: number;
}
export type EndReason = 'normal' | 'tobi' | 'agariYame' | 'westEntryEnd';
export interface MahjongFinal {
ranking: { player: PlayerId; seat: Seat; rank: number; points: number; score: number }[];
rounds: number;
bestHand: { player: PlayerId; han: number; fu: number; yaku: string[]; points: number; limit: LimitName } | null;
perPlayer: { player: PlayerId; wins: number; dealIns: number; riichi: number; calls: number }[];
endReason: EndReason;
summary: string;
}
export interface MahjongState {
options: ResolvedOptions;
mode: 'yonma' | 'sanma';
players: PlayerId[];
red: TileId[];
points: number[];
roundWind: number;
handNumber: number;
honba: number;
riichiPool: number;
hand: HandState | null;
phase: 'playing' | 'handResult' | 'finished';
lastHandResult: HandResultRecord | null;
/** The game ends when everybody has seen the last hand's result. */
gameOver: EndReason | null;
/** Next hand after the result screen (null when the game is over). */
nextHand: { roundWind: number; handNumber: number; honba: number } | null;
best: { seat: Seat; han: number; fu: number; yaku: string[]; points: number; limit: LimitName } | null;
resultAt: number;
readyForNext: Seat[];
timeBank: number[];
timeoutsInRow: number[];
autoPilot: boolean[];
left: boolean[];
prefs: { autoWin: boolean; noCalls: boolean; beginner: boolean }[];
history: { label: string; kind: HandResultRecord['kind']; deltas: number[]; pointsAfter: number[] }[];
stats: { wins: number; dealIns: number; riichi: number; calls: number }[];
handsPlayed: number;
/** Public action counter / last loud event for the screen ("퐁!"). */
seq: number;
lastCall: { seq: number; seat: Seat; call: 'chi' | 'pon' | 'kan' | 'riichi' | 'kita' | 'tsumo' | 'ron' } | null;
final: MahjongFinal | null;
}
// ---------------------------------------------------------------- view (tiles are codes)
export interface MeldView {
type: MeldType;
tiles: string[];
called: string | null;
from: Seat | null;
}
export interface DiscardView {
tile: string;
tsumogiri: boolean;
riichi: boolean;
called: boolean;
}
export interface SeatView {
id: PlayerId;
seat: Seat;
/** Seat wind 0..3 (동남서북). */
wind: number;
points: number;
handCount: number;
hasDrawn: boolean;
melds: MeldView[];
kita: string[];
discards: DiscardView[];
riichi: boolean;
isDealer: boolean;
away: boolean;
left: boolean;
ready: boolean;
}
export interface WaitView {
kind: string;
left: number;
/** Winning on it has a yaku (ron, with the current situation). */
yaku: boolean;
}
export interface MyView {
seat: Seat;
hand: string[];
drawn: string | null;
canDiscard: boolean;
discardable: string[];
/** 구이가에로 못 버리는 패. */
locked: string[];
riichiDiscards: string[];
canTsumo: boolean;
ankan: string[];
kakan: string[];
canKita: boolean;
canKyuushu: boolean;
claim: { tile: string; source: ClaimWindow['source']; ron: boolean; pon: string[][]; kan: string[][]; chi: string[][]; responded: boolean; deadline: number; totalMs: number } | null;
riichi: boolean;
furiten: boolean;
/** Current waits (13-tile state), with tiles left from what I can see. */
waits: WaitView[];
/** Beginner: discard code → waits after discarding it. */
tenpaiAfter: Record<string, WaitView[]>;
yakuHints: string[];
timeBankMs: number;
autoPilot: boolean;
prefs: { autoWin: boolean; noCalls: boolean; beginner: boolean };
}
export interface WinnerView extends Omit<WinnerRecord, 'hand' | 'melds' | 'winTile' | 'uraIndicators'> {
hand: string[];
melds: MeldView[];
winTile: string;
uraIndicators: string[];
}
export interface HandResultView extends Omit<HandResultRecord, 'winners' | 'shown' | 'doraIndicators'> {
winners: WinnerView[];
shown: { seat: Seat; hand: string[] }[];
doraIndicators: string[];
}
export interface MahjongView {
mode: 'yonma' | 'sanma';
n: number;
options: ResolvedOptions;
me: PlayerId | null;
seats: SeatView[];
roundWind: number;
handNumber: number;
honba: number;
riichiPool: number;
dealer: Seat;
phase: MahjongState['phase'];
stage: HandState['stage'] | null;
turn: Seat | null;
wallCount: number;
doraIndicators: string[];
/** Public: a claim window is open on this tile (who may respond is never shown). */
claimOn: { tile: string; from: Seat; source: ClaimWindow['source'] } | null;
lastDiscard: { seat: Seat; index: number } | null;
lastCall: MahjongState['lastCall'];
turnStartedAt: number;
deadlineTotalMs: number;
my: MyView | null;
result: HandResultView | null;
/** On the result screen: the next hand (null when the game is over) and why the game ends. */
nextHand: MahjongState['nextHand'];
gameOver: EndReason | null;
history: MahjongState['history'];
final: MahjongFinal | null;
seq: number;
handsPlayed: number;
}

View File

@@ -0,0 +1,182 @@
/**
* 주사위 땅따먹기 판 데이터 (docs/games/marble.md §3). Pure data + price helpers, no zod,
* so it stays cheap to import. Every number here is copied from the doc tables (tests compare
* them with the §3.2 formulas).
*/
export type SpaceKind = 'start' | 'city' | 'tour' | 'card' | 'island' | 'fundIn' | 'fundOut' | 'space';
export type Group = 'A' | 'B' | 'C' | 'D' | 'E' | 'F' | 'G' | 'H';
/** 0 땅만, 1 별장, 2 빌딩, 3 호텔, 4 랜드마크 */
export type Level = 0 | 1 | 2 | 3 | 4;
export interface Space {
pos: number;
name: string;
kind: SpaceKind;
group: Group | null;
/** 땅값 (cities and tourist spots). */
price: number | null;
/** 건설비 per stage: [별장, 빌딩, 호텔, 랜드마크] (cities only). */
build: [number, number, number, number] | null;
/** 통행료 by level: [땅, 별장, 빌딩, 호텔, 랜드마크] (cities); tourist spots have one value. */
tolls: number[] | null;
}
type CityRow = [pos: number, name: string, group: Group, price: number, build: [number, number, number, number], tolls: [number, number, number, number, number]];
/** §3.2 */
const CITIES: CityRow[] = [
[1, '타이베이', 'A', 6, [3, 6, 9, 12], [1, 4, 9, 15, 24]],
[3, '마닐라', 'A', 6, [3, 6, 9, 12], [1, 4, 9, 15, 24]],
[4, '하노이', 'A', 8, [4, 8, 12, 16], [2, 5, 12, 20, 32]],
[6, '방콕', 'B', 10, [5, 10, 15, 20], [2, 6, 15, 25, 40]],
[8, '싱가포르', 'B', 10, [5, 10, 15, 20], [2, 6, 15, 25, 40]],
[9, '카이로', 'B', 12, [6, 12, 18, 24], [2, 7, 18, 30, 48]],
[11, '이스탄불', 'C', 14, [7, 14, 21, 28], [3, 8, 21, 35, 56]],
[13, '아테네', 'C', 14, [7, 14, 21, 28], [3, 8, 21, 35, 56]],
[14, '로마', 'C', 16, [8, 16, 24, 32], [3, 10, 24, 40, 64]],
[16, '코펜하겐', 'D', 18, [9, 18, 27, 36], [4, 11, 27, 45, 72]],
[18, '스톡홀름', 'D', 18, [9, 18, 27, 36], [4, 11, 27, 45, 72]],
[19, '베를린', 'D', 20, [10, 20, 30, 40], [4, 12, 30, 50, 80]],
[21, '부에노스아이레스', 'E', 22, [11, 22, 33, 44], [4, 13, 33, 55, 88]],
[23, '상파울루', 'E', 22, [11, 22, 33, 44], [4, 13, 33, 55, 88]],
[24, '시드니', 'E', 24, [12, 24, 36, 48], [5, 14, 36, 60, 96]],
[26, '리스본', 'F', 26, [13, 26, 39, 52], [5, 16, 39, 65, 104]],
[27, '마드리드', 'F', 26, [13, 26, 39, 52], [5, 16, 39, 65, 104]],
[29, '파리', 'F', 28, [14, 28, 42, 56], [6, 17, 42, 70, 112]],
[31, '런던', 'G', 30, [15, 30, 45, 60], [6, 18, 45, 75, 120]],
[32, '밴쿠버', 'G', 30, [15, 30, 45, 60], [6, 18, 45, 75, 120]],
[34, '샌프란시스코', 'G', 32, [16, 32, 48, 64], [6, 19, 48, 80, 128]],
[36, '뉴욕', 'H', 35, [18, 35, 53, 70], [7, 21, 53, 88, 140]],
[37, '도쿄', 'H', 35, [18, 35, 53, 70], [7, 21, 53, 88, 140]],
[39, '서울', 'H', 40, [20, 40, 60, 80], [8, 24, 60, 100, 160]],
];
/** §3.3 */
const TOURS: [pos: number, name: string, price: number, toll: number][] = [
[5, '제주도', 20, 10],
[15, '경주', 20, 10],
[25, '우주센터', 30, 15],
[35, '부산', 25, 12],
];
const OTHERS: [pos: number, name: string, kind: SpaceKind][] = [
[0, '출발', 'start'],
[10, '무인도', 'island'],
[20, '사회복지기금 받는 곳', 'fundIn'],
[30, '우주여행', 'space'],
[38, '사회복지기금 내는 곳', 'fundOut'],
...[2, 7, 12, 17, 22, 28, 33].map((p): [number, string, SpaceKind] => [p, '행운카드', 'card']),
];
export const SPACES: readonly Space[] = (() => {
const out: Space[] = [];
for (const [pos, name, group, price, build, tolls] of CITIES) out[pos] = { pos, name, kind: 'city', group, price, build, tolls };
for (const [pos, name, price, toll] of TOURS) out[pos] = { pos, name, kind: 'tour', group: null, price, build: null, tolls: [toll] };
for (const [pos, name, kind] of OTHERS) out[pos] = { pos, name, kind, group: null, price: null, build: null, tolls: null };
if (out.length !== 40 || out.some((s) => !s)) throw new Error('marble: board must have 40 spaces');
return out;
})();
export const BOARD_SIZE = 40;
export const START = 0;
export const ISLAND = 10;
export const FUND_IN = 20;
export const SPACE_TRAVEL = 30;
export const SPACE_CENTER = 25;
export const SEOUL = 39;
export const TOUR_POSITIONS = [5, 15, 25, 35];
export const CITY_POSITIONS = CITIES.map((c) => c[0]);
export const PROPERTY_POSITIONS = [...CITY_POSITIONS, ...TOUR_POSITIONS].sort((a, b) => a - b);
/** §3.1 라인(독점 판정용): 각 줄의 도시 6곳. */
export const LINES: number[][] = [0, 1, 2, 3].map((k) => CITY_POSITIONS.filter((p) => p > k * 10 && p < k * 10 + 10));
export const GROUPS: Record<Group, number[]> = (() => {
const g = {} as Record<Group, number[]>;
for (const [pos, , group] of CITIES) (g[group] ??= []).push(pos);
return g;
})();
/** §3.4 고정 금액 */
export const FUND_PAYMENT = 15;
export const SPACE_FARE = 20;
// ---------------------------------------------------------------- 행운카드 (§3.5)
export type CardId =
| 'L01' | 'L02' | 'L03' | 'L04' | 'L05' | 'L06' | 'L07' | 'L08' | 'L09' | 'L10'
| 'L11' | 'L12' | 'L13' | 'L14' | 'L15' | 'L16' | 'L17' | 'L18' | 'L19' | 'L20';
export const CARD_IDS: CardId[] = Array.from({ length: 20 }, (_, i) => `L${String(i + 1).padStart(2, '0')}` as CardId);
export const KEEP_CARDS: CardId[] = ['L11', 'L12'];
export const CARDS: Record<CardId, { name: string; text: string }> = {
L01: { name: '복권 당첨', text: '은행에서 20 받기' },
L02: { name: '장학금', text: '은행에서 10 받기' },
L03: { name: '주식 배당', text: '은행에서 15 받기' },
L04: { name: '생일 축하', text: '다른 사람 모두에게서 5씩 받기' },
L05: { name: '예금 이자', text: '은행에서 10 받기' },
L06: { name: '과속 벌금', text: '사회복지기금에 5 내기' },
L07: { name: '병원비', text: '은행에 10 내기' },
L08: { name: '이웃 돕기 성금', text: '사회복지기금에 10 내기' },
L09: { name: '건물 수리비', text: '내 건물마다 별장 3, 빌딩 6, 호텔 10, 랜드마크 15를 은행에 내기' },
L10: { name: '폭풍 경보', text: '무인도로 바로 가기(월급 없음)' },
L11: { name: '무인도 탈출권', text: '가지고 있다가 무인도에서 쓰면 바로 탈출' },
L12: { name: '통행료 면제권', text: '다음에 통행료를 낼 때 한 번 면제' },
L13: { name: '출발지로', text: '출발 칸으로 가서 월급 받기' },
L14: { name: '우주 초대장', text: '우주여행 칸으로 가기' },
L15: { name: '서울 관광', text: '서울로 가기' },
L16: { name: '뒤로 3칸', text: '뒤로 3칸 가기(월급 없음)' },
L17: { name: '기부 천사', text: '사회복지기금 받는 곳으로 가서 모인 기금 받기' },
L18: { name: '관광 안내', text: '앞쪽의 가장 가까운 관광지로 가기' },
L19: { name: '여행 경비', text: '다른 사람 모두에게 5씩 주기' },
L20: { name: '재산세', text: '내 땅 한 곳마다 2를 은행에 내기' },
};
/** L09 건물 수리비 per stage (별장, 빌딩, 호텔, 랜드마크). */
export const REPAIR_PER_STAGE = [3, 6, 10, 15];
export const PROPERTY_TAX = 2;
export const BIRTHDAY_GIFT = 5;
export const TRAVEL_GIFT = 5;
/** §3.5: from the 5th card in one turn on, card spaces do nothing. */
export const CARD_CAP = 5;
// ---------------------------------------------------------------- price helpers
export const isProperty = (pos: number) => SPACES[pos]!.kind === 'city' || SPACES[pos]!.kind === 'tour';
export const isCity = (pos: number) => SPACES[pos]!.kind === 'city';
/** Amount paid for one stage: level 0 = 땅값, 1.. = 건설비. */
export function stageCost(pos: number, level: Level): number {
const s = SPACES[pos]!;
if (level === 0) return s.price!;
return s.build![level - 1]!;
}
/** 땅값 + 지어진 건물 건설비 합 (정가). */
export function investment(pos: number, level: Level): number {
let t = 0;
for (let l = 0; l <= level; l++) t += stageCost(pos, l as Level);
return t;
}
/** 매각가: 그 단계에 낸 금액의 50%, 내림. */
export const stageSale = (pos: number, level: Level) => Math.floor(stageCost(pos, level) / 2);
/** Selling the whole land (every building, top down, then the land). */
export function fullSale(pos: number, level: Level): number {
let t = 0;
for (let l = 0; l <= level; l++) t += stageSale(pos, l as Level);
return t;
}
/** 인수가 = 2 × 투자액 (§5.7). */
export const takeoverPrice = (pos: number, level: Level) => 2 * investment(pos, level);
/** Base toll from the tables (no tourist doubling). */
export function baseToll(pos: number, level: Level): number {
const s = SPACES[pos]!;
return s.kind === 'tour' ? s.tolls![0]! : s.tolls![level]!;
}
/** Forward distance from `from` to `to` (0..39). */
export const forwardSteps = (from: number, to: number) => (to - from + BOARD_SIZE) % BOARD_SIZE;

View File

@@ -0,0 +1,559 @@
/** 주사위 땅따먹기 (docs/games/marble.md). Hidden info: the 행운카드 deck order (only its size is public). */
import type { ZodType } from 'zod';
import { fail, ok, type GameDefinition, type GameEvent, type GameResult, type PlayerId, type Validation } from '@bg/engine';
import {
CARD_IDS,
PROPERTY_POSITIONS,
SPACES,
SPACE_CENTER,
SPACE_FARE,
SPACE_TRAVEL,
forwardSteps,
fullSale,
isProperty,
stageCost,
stageSale,
takeoverPrice,
type CardId,
type Level,
} from './board';
import {
AWAY_AFTER,
AWAY_MS,
MAX_TRADES_PER_TURN,
TRADE_MS,
aliveIds,
autoSell,
canTakeoverAt,
createState,
curId,
doBuild,
doBuy,
doConcede,
doEscape,
doPass,
doPropose,
doRespond,
doRoll,
doSell,
doSpaceTravel,
doTakeover,
marbleActionSchema,
marbleOptionsSchema,
maxLevel,
ownedBy,
pl,
pushLog,
raisable,
tollAt,
totalAssets,
type Creditor,
type DebtReason,
type MarbleAction,
type MarbleClientAction,
type MarbleOptions,
type MarbleResult,
type MarbleState,
type Phase,
type Trade,
} from './rules';
export * from './board';
export * from './rules';
// ---------------------------------------------------------------- view types
export interface MarblePlayerView {
id: PlayerId;
/** Seat colour index (turn order). */
seat: number;
pos: number;
cash: number;
alive: boolean;
conceded: boolean;
bankruptAtRound: number | null;
islandLeft: number;
spacePending: boolean;
heldCards: CardId[];
lands: number[];
totalAssets: number;
away: boolean;
}
export interface MarbleOwnedView {
pos: number;
owner: PlayerId;
level: Level;
/** Toll someone else pays here right now. */
toll: number;
/** 인수가 when the takeover option applies to this square. */
takeoverPrice: number | null;
/** Sale value of the top building stage (null = no building). */
sellTop: number | null;
/** Sale value of the whole land with its buildings. */
sellAll: number;
}
export interface MarbleSellOption {
pos: number;
what: 'top' | 'all';
amount: number;
level: Level;
}
/** What the viewer can do right now, with costs precomputed (§8.3). */
export interface MarbleMyView {
canRoll: boolean;
island: { left: number; fee: number | null; canPayFee: boolean; hasCard: boolean } | null;
space: boolean;
decide: { pos: number; buy: number | null; build: { level: Level; cost: number } | null; takeover: number | null; boughtThisVisit: boolean } | null;
sell: MarbleSellOption[] | null;
autoSell: { pos: number; what: 'top' | 'all' } | null;
canPropose: boolean;
respond: boolean;
}
export interface MarbleSpacePreview {
dest: number;
salary: boolean;
owner: PlayerId | null;
toll: number;
fare: number;
}
export interface MarbleView {
options: MarbleOptions;
me: PlayerId | null;
order: PlayerId[];
current: PlayerId | null;
phase: Phase;
round: number;
maxRounds: number;
fund: number;
deckCount: number;
players: MarblePlayerView[];
owned: MarbleOwnedView[];
lastRoll: MarbleState['lastRoll'];
lastMove: MarbleState['lastMove'];
lastCard: MarbleState['lastCard'];
doublesThisTurn: number;
extraRoll: boolean;
decision: MarbleState['decision'];
debt: { debtor: PlayerId; amount: number; to: Creditor; reason: DebtReason; short: number; raisable: number } | null;
trade: Trade | null;
tradesLeft: number;
my: MarbleMyView | null;
spacePreview: MarbleSpacePreview[] | null;
turnSeq: number;
deadlineAt: number | null;
log: GameEvent[];
result: MarbleResult | null;
}
// ---------------------------------------------------------------- helpers
type ClientAction = MarbleClientAction;
function activeOf(s: MarbleState): PlayerId[] {
if (s.phase === 'finished') return [];
if (s.phase === 'debt') return [s.debt!.debtor];
if (s.phase === 'tradeResponse') return [s.pendingTrade!.to];
return [curId(s)];
}
/** The deterministic action a timeout plays (§8.5): never spends points by choice. */
export function autoAction(s: MarbleState, player: PlayerId): ClientAction {
switch (s.phase) {
case 'tradeResponse':
return { type: 'TRADE_RESPOND', accept: false };
case 'debt': {
const a = autoSell(s, player);
return a ? { type: 'SELL', ...a } : { type: 'CONCEDE' };
}
case 'decide':
return { type: 'PASS' };
default:
return pl(s, curId(s)).spacePending ? { type: 'SPACE_TRAVEL', dest: 0 } : { type: 'ROLL' };
}
}
function sellOptions(s: MarbleState, id: PlayerId): MarbleSellOption[] {
const out: MarbleSellOption[] = [];
for (const pos of ownedBy(s, id)) {
const o = s.owners[pos]!;
if (o.level >= 1) out.push({ pos, what: 'top', amount: stageSale(pos, o.level), level: o.level });
out.push({ pos, what: 'all', amount: fullSale(pos, o.level), level: o.level });
}
return out;
}
const PHASE_BUSY: Record<Phase, string> = {
turnStart: '지금은 주사위를 굴릴 차례예요.',
decide: '먼저 이 칸에서 할 일을 정해 주세요.',
debt: '먼저 모자란 포인트를 채워야 해요.',
tradeResponse: '교환 제안의 답을 기다리는 중이에요.',
finished: '게임이 이미 끝났어요.',
};
function validateTrade(s: MarbleState, actor: PlayerId, a: Extract<ClientAction, { type: 'PROPOSE_TRADE' }>): Validation {
if (!s.options.trade) return fail('교환 규칙을 쓰지 않는 방이에요.');
if (s.phase !== 'turnStart') return fail(PHASE_BUSY[s.phase]);
if (s.tradesThisTurn >= MAX_TRADES_PER_TURN) return fail('이번 차례에는 더 제안할 수 없어요(2번까지).');
const to = s.players[a.to];
if (!to || a.to === actor || !to.alive) return fail('그 사람에게는 제안할 수 없어요.');
if (a.give.lands.length + a.take.lands.length + a.give.cash + a.take.cash === 0) return fail('주거나 받을 것을 하나 이상 골라 주세요.');
const all = [...a.give.lands, ...a.take.lands];
if (new Set(all).size !== all.length || !all.every(isProperty)) return fail('땅을 다시 골라 주세요.');
if (!a.give.lands.every((p) => s.owners[p]?.owner === actor)) return fail('내 땅만 줄 수 있어요.');
if (!a.take.lands.every((p) => s.owners[p]?.owner === a.to)) return fail('그 사람의 땅만 받을 수 있어요.');
if (a.give.cash > pl(s, actor).cash) return fail('가진 포인트보다 많이 줄 수 없어요.');
if (a.take.cash > to.cash) return fail('상대가 가진 포인트보다 많이 받을 수 없어요.');
return ok;
}
function refreshDeadline(s: MarbleState, now: number): void {
if (s.phase === 'finished') {
s.deadlineAt = null;
return;
}
if (s.phase === 'tradeResponse') {
s.deadlineAt = now + TRADE_MS;
return;
}
if (s.options.turnSeconds === null) {
s.deadlineAt = null;
return;
}
const who = activeOf(s)[0]!;
s.deadlineAt = now + ((s.timeouts[who] ?? 0) >= AWAY_AFTER ? AWAY_MS : s.options.turnSeconds * 1000);
}
function spacePreview(s: MarbleState, id: PlayerId): MarbleSpacePreview[] {
const center = s.owners[SPACE_CENTER];
const fare = center && center.owner !== id && pl(s, center.owner).alive ? SPACE_FARE : 0;
const from = pl(s, id).pos;
const out: MarbleSpacePreview[] = [];
for (let dest = 0; dest < 40; dest++) {
if (dest === SPACE_TRAVEL) continue;
const o = s.owners[dest];
const other = o && o.owner !== id ? o.owner : null;
out.push({ dest, salary: from + forwardSteps(from, dest) >= 40, owner: o?.owner ?? null, toll: other ? tollAt(s, dest) : 0, fare });
}
return out;
}
// ---------------------------------------------------------------- definition
export const marble: GameDefinition<MarbleState, MarbleAction, MarbleView, MarbleOptions> = {
id: 'marble',
nameKo: '주사위 땅따먹기',
minPlayers: 2,
maxPlayers: 4,
stateVersion: 1,
defaultOptions: marbleOptionsSchema.parse({}),
optionsSchema: marbleOptionsSchema as unknown as ZodType<MarbleOptions>,
actionSchema: marbleActionSchema as unknown as ZodType<MarbleAction>,
setup({ players, options, rng, now }) {
if (players.length < 2 || players.length > 4) throw new Error('marble: 2~4명이 할 수 있어요.');
const s = createState(players, options, rng, [...CARD_IDS]);
pushLog(s, [], { type: 'turn', player: curId(s), round: 1 });
refreshDeadline(s, now);
return s;
},
validate(s, actor, a) {
const p = s.players[actor];
if (!p) return fail('이 게임의 플레이어가 아니에요.');
if (s.phase === 'finished') return fail('게임이 이미 끝났어요.');
if (!p.alive) return fail('이미 게임에서 빠졌어요.');
switch (a.type) {
case 'CONCEDE':
return ok;
case 'TIMEOUT':
return activeOf(s).includes(actor) ? ok : fail('지금은 시간 초과를 처리할 수 없어요.');
case 'TRADE_RESPOND':
return s.phase === 'tradeResponse' && s.pendingTrade?.to === actor ? ok : fail('답할 교환 제안이 없어요.');
case 'SELL': {
if (s.phase !== 'debt' || s.debt?.debtor !== actor) return fail('지금은 팔 수 없어요. 모자란 포인트를 채울 때만 팔 수 있어요.');
const o = s.owners[a.pos];
if (!o || o.owner !== actor) return fail('내 땅만 팔 수 있어요.');
if (a.what === 'top' && o.level < 1) return fail('그 땅에는 팔 건물이 없어요.');
return ok;
}
}
if (actor !== curId(s)) return fail('지금은 다른 사람 차례예요.');
const o = s.options;
const d = s.decision;
switch (a.type) {
case 'ROLL':
if (s.phase !== 'turnStart') return fail(PHASE_BUSY[s.phase]);
if (p.spacePending) return fail('우주여행 차례예요. 갈 칸을 골라 주세요.');
return ok;
case 'PAY_ISLAND_FEE':
if (s.phase !== 'turnStart') return fail(PHASE_BUSY[s.phase]);
if (p.islandLeft <= 0) return fail('무인도에 있지 않아요.');
if (o.islandFee <= 0) return fail('이 방에서는 탈출비를 내고 나갈 수 없어요.');
if (p.cash < o.islandFee) return fail('포인트가 부족해요.');
return ok;
case 'USE_ESCAPE_CARD':
if (s.phase !== 'turnStart') return fail(PHASE_BUSY[s.phase]);
if (p.islandLeft <= 0) return fail('무인도에 있지 않아요.');
if (!p.heldCards.includes('L11')) return fail('무인도 탈출권이 없어요.');
return ok;
case 'SPACE_TRAVEL':
if (s.phase !== 'turnStart' || !p.spacePending) return fail('지금은 우주여행을 할 수 없어요.');
if (a.dest === SPACE_TRAVEL) return fail('우주여행 칸으로는 갈 수 없어요. 다른 칸을 골라 주세요.');
return ok;
case 'BUY':
if (s.phase !== 'decide' || !d?.canBuy) return fail('지금은 이 땅을 살 수 없어요.');
if (p.cash < SPACES[d.pos]!.price!) return fail('포인트가 부족해요.');
return ok;
case 'BUILD': {
if (a.level === 4 && !o.landmark) return fail('이 방에서는 랜드마크를 짓지 않아요.');
if (s.phase !== 'decide' || !d || d.canBuild === null) return fail('지금은 지을 수 없어요.');
if (a.level !== d.canBuild) return fail('건물은 한 단계씩만 올릴 수 있어요.');
if (a.level > maxLevel(o)) return fail('더 지을 수 없어요.');
if (p.cash < stageCost(d.pos, a.level as Level)) return fail('포인트가 부족해요.');
return ok;
}
case 'TAKEOVER':
if (!o.takeover) return fail('인수 규칙을 쓰지 않는 방이에요.');
if (s.phase !== 'decide' || !d || d.canTakeover === null) return fail('지금은 인수할 수 없어요.');
if (canTakeoverAt(s, d.pos, actor) === null) return fail('이 땅은 인수할 수 없어요.');
if (p.cash < d.canTakeover) return fail('포인트가 부족해요.');
return ok;
case 'PASS':
return s.phase === 'decide' ? ok : fail('지금은 넘어갈 결정이 없어요.');
case 'PROPOSE_TRADE':
return validateTrade(s, actor, a);
}
return fail('알 수 없는 행동이에요.');
},
apply(prev, actor, a, { rng, now }) {
const s = structuredClone(prev);
const ev: GameEvent[] = [];
s.turnSeq += 1;
let act: ClientAction;
if (a.type === 'TIMEOUT') {
act = autoAction(s, actor);
s.timeouts[actor] = (s.timeouts[actor] ?? 0) + 1;
pushLog(s, ev, { type: 'timeout', player: actor });
if (s.timeouts[actor] === AWAY_AFTER) pushLog(s, ev, { type: 'away', player: actor });
} else {
act = a;
if (a.type !== 'CONCEDE') s.timeouts[actor] = 0;
}
switch (act.type) {
case 'ROLL':
doRoll(s, rng, ev);
break;
case 'PAY_ISLAND_FEE':
doEscape(s, 'fee', ev);
break;
case 'USE_ESCAPE_CARD':
doEscape(s, 'card', ev);
break;
case 'SPACE_TRAVEL':
doSpaceTravel(s, act.dest, ev);
break;
case 'BUY':
doBuy(s, ev);
break;
case 'BUILD':
doBuild(s, act.level as Level, ev);
break;
case 'TAKEOVER':
doTakeover(s, ev);
break;
case 'PASS':
doPass(s, ev);
break;
case 'SELL':
doSell(s, actor, act.pos, act.what, ev);
break;
case 'PROPOSE_TRADE':
doPropose(s, { from: actor, to: act.to, give: act.give, take: act.take }, ev);
break;
case 'TRADE_RESPOND':
doRespond(s, act.accept, ev);
break;
case 'CONCEDE':
doConcede(s, actor, ev);
break;
}
refreshDeadline(s, now);
return { state: s, events: ev };
},
view(s, viewer) {
const me = viewer !== null && s.players[viewer] ? viewer : null;
const cur = s.phase === 'finished' ? null : curId(s);
const players: MarblePlayerView[] = s.order.map((id, seat) => {
const p = s.players[id]!;
return {
id,
seat,
pos: p.pos,
cash: p.cash,
alive: p.alive,
conceded: p.conceded,
bankruptAtRound: p.bankruptAtRound,
islandLeft: p.islandLeft,
spacePending: p.spacePending,
heldCards: [...p.heldCards],
lands: ownedBy(s, id),
totalAssets: totalAssets(s, id),
away: p.alive && (s.timeouts[id] ?? 0) >= AWAY_AFTER,
};
});
const owned: MarbleOwnedView[] = PROPERTY_POSITIONS.filter((pos) => s.owners[pos]).map((pos) => {
const o = s.owners[pos]!;
return {
pos,
owner: o.owner,
level: o.level,
toll: tollAt(s, pos),
takeoverPrice: s.options.takeover && SPACES[pos]!.kind === 'city' && o.level < 4 ? takeoverPrice(pos, o.level) : null,
sellTop: o.level >= 1 ? stageSale(pos, o.level) : null,
sellAll: fullSale(pos, o.level),
};
});
let my: MarbleMyView | null = null;
if (me && s.phase !== 'finished' && s.players[me]!.alive) {
const p = s.players[me]!;
const mine = me === cur;
const start = mine && s.phase === 'turnStart';
const d = mine && s.phase === 'decide' ? s.decision : null;
const debtor = s.phase === 'debt' && s.debt?.debtor === me;
my = {
canRoll: start && !p.spacePending,
island:
start && p.islandLeft > 0
? { left: p.islandLeft, fee: s.options.islandFee > 0 ? s.options.islandFee : null, canPayFee: s.options.islandFee > 0 && p.cash >= s.options.islandFee, hasCard: p.heldCards.includes('L11') }
: null,
space: start && p.spacePending,
decide: d
? {
pos: d.pos,
buy: d.canBuy ? SPACES[d.pos]!.price! : null,
build: d.canBuild !== null ? { level: d.canBuild, cost: stageCost(d.pos, d.canBuild) } : null,
takeover: d.canTakeover,
boughtThisVisit: d.boughtThisVisit,
}
: null,
sell: debtor ? sellOptions(s, me) : null,
autoSell: debtor ? autoSell(s, me) : null,
canPropose: start && s.options.trade && s.tradesThisTurn < MAX_TRADES_PER_TURN && aliveIds(s).length > 1,
respond: s.phase === 'tradeResponse' && s.pendingTrade?.to === me,
};
}
const dv = s.debt;
return {
options: s.options,
me,
order: [...s.order],
current: cur,
phase: s.phase,
round: s.round,
maxRounds: s.options.maxRounds,
fund: s.fund,
deckCount: s.deck.length,
players,
owned,
lastRoll: s.lastRoll,
lastMove: s.lastMove,
lastCard: s.lastCard,
doublesThisTurn: s.doublesThisTurn,
extraRoll: s.extraRoll,
decision: s.decision,
debt: dv ? { debtor: dv.debtor, amount: dv.amount, to: dv.to, reason: dv.reason, short: Math.max(0, dv.amount - s.players[dv.debtor]!.cash), raisable: raisable(s, dv.debtor) } : null,
trade: s.pendingTrade,
tradesLeft: MAX_TRADES_PER_TURN - s.tradesThisTurn,
my,
spacePreview: cur && s.phase === 'turnStart' && s.players[cur]!.spacePending ? spacePreview(s, cur) : null,
turnSeq: s.turnSeq,
deadlineAt: s.deadlineAt,
log: s.log,
result: s.final,
};
},
activePlayers: activeOf,
deadline(s) {
return s.phase === 'finished' ? null : s.deadlineAt;
},
timeoutPlayers: activeOf,
onTimeout() {
return { type: 'TIMEOUT' };
},
onLeave(s, player) {
const p = s.players[player];
if (!p || !p.alive || s.phase === 'finished') return null;
return { type: 'CONCEDE' };
},
result(s): GameResult | null {
const f = s.final;
if (!f) return null;
const groups = new Map<number, PlayerId[]>();
for (const r of f.ranking) groups.set(r.rank, [...(groups.get(r.rank) ?? []), r.player]);
const scores: Record<PlayerId, number> = {};
for (const r of f.ranking) scores[r.player] = r.totalAssets;
const out = s.order.filter((p) => !s.players[p]!.alive);
const resign = f.reason === 'lastSurvivor' && out.length > 0 && out.every((p) => s.players[p]!.conceded);
return {
ranking: [...groups.entries()].sort((a, b) => a[0] - b[0]).map(([, ps]) => ps),
scores,
summary: f.summary,
reason: resign ? 'resign' : 'normal',
};
},
/** Test helper: every legal action (CONCEDE left out; a couple of sample trades when the option is on). */
legalActions(s, player) {
if (s.phase === 'finished' || !s.players[player]?.alive) return [];
if (s.phase === 'tradeResponse') return s.pendingTrade?.to === player ? [{ type: 'TRADE_RESPOND', accept: true }, { type: 'TRADE_RESPOND', accept: false }] : [];
if (s.phase === 'debt') return s.debt?.debtor === player ? sellOptions(s, player).map((o) => ({ type: 'SELL' as const, pos: o.pos, what: o.what })) : [];
if (player !== curId(s)) return [];
const p = s.players[player]!;
const out: MarbleAction[] = [];
if (s.phase === 'turnStart') {
if (p.spacePending) {
for (let dest = 0; dest < 40; dest++) if (dest !== SPACE_TRAVEL) out.push({ type: 'SPACE_TRAVEL', dest });
} else out.push({ type: 'ROLL' });
for (const a of [{ type: 'PAY_ISLAND_FEE' }, { type: 'USE_ESCAPE_CARD' }] as const) if (marble.validate(s, player, a).ok) out.push(a);
if (s.options.trade) {
const mine = ownedBy(s, player);
for (const q of aliveIds(s)) {
if (q === player) continue;
const theirs = ownedBy(s, q);
const offers: MarbleAction[] = [];
if (mine.length && theirs.length) offers.push({ type: 'PROPOSE_TRADE', to: q, give: { lands: [mine[0]!], cash: 0 }, take: { lands: [theirs[0]!], cash: 0 } });
if (theirs.length) offers.push({ type: 'PROPOSE_TRADE', to: q, give: { lands: [], cash: Math.min(p.cash, 30) }, take: { lands: [theirs[theirs.length - 1]!], cash: 0 } });
for (const t of offers) if (marble.validate(s, player, t).ok) out.push(t);
}
}
return out;
}
if (s.phase === 'decide') {
const d = s.decision!;
out.push({ type: 'PASS' });
if (d.canBuy && p.cash >= SPACES[d.pos]!.price!) out.push({ type: 'BUY' });
if (d.canBuild !== null && p.cash >= stageCost(d.pos, d.canBuild)) out.push({ type: 'BUILD', level: d.canBuild });
if (d.canTakeover !== null && p.cash >= d.canTakeover) out.push({ type: 'TAKEOVER' });
}
return out;
},
/** Test helper: IDs of cards that were never drawn — no view may contain them. */
secretsOf(s) {
return CARD_IDS.filter((c) => !s.revealed.includes(c));
},
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,945 @@
/**
* 주사위 땅따먹기 상태 기계 (docs/games/marble.md §5, §8). Functions here mutate a state that
* `apply` has already cloned. The RNG is only used for dice (ROLL) — the deck is shuffled once in
* setup and never again (cards go back to the bottom).
*/
import { z } from 'zod';
import { nextSeat, type GameEvent, type PlayerId, type Rng } from '@bg/engine';
import {
BIRTHDAY_GIFT,
CARD_CAP,
FUND_PAYMENT,
GROUPS,
ISLAND,
KEEP_CARDS,
LINES,
PROPERTY_POSITIONS,
PROPERTY_TAX,
REPAIR_PER_STAGE,
SEOUL,
SPACES,
SPACE_CENTER,
SPACE_FARE,
SPACE_TRAVEL,
START,
FUND_IN,
TOUR_POSITIONS,
TRAVEL_GIFT,
baseToll,
forwardSteps,
fullSale,
investment,
isCity,
stageCost,
stageSale,
takeoverPrice,
type CardId,
type Level,
} from './board';
// ---------------------------------------------------------------- options & actions
export const marbleOptionsSchema = z.object({
startMoney: z
.union([z.literal(200), z.literal(300), z.literal(500)])
.default(300)
.meta({ title: '시작 포인트', labels: { 200: '200P', 300: '300P', 500: '500P' } }),
salary: z
.union([z.literal(10), z.literal(20), z.literal(30)])
.default(20)
.meta({ title: '월급(출발 칸을 지나거나 도착할 때)', labels: { 10: '10P', 20: '20P', 30: '30P' } }),
maxRounds: z
.union([z.literal(0), z.literal(15), z.literal(20), z.literal(30)])
.default(20)
.meta({ title: '라운드 제한', labels: { 0: '무제한(파산으로만 끝)', 15: '15라운드', 20: '20라운드', 30: '30라운드' } }),
buildOnPurchase: z.boolean().default(false).meta({ title: '땅을 산 그 자리에서 별장까지 바로 짓기' }),
islandTurns: z
.union([z.literal(1), z.literal(2), z.literal(3)])
.default(3)
.meta({ title: '무인도에서 최대 쉬는 차례', labels: { 1: '1번', 2: '2번', 3: '3번' } }),
islandFee: z
.union([z.literal(0), z.literal(10), z.literal(20)])
.default(0)
.meta({ title: '무인도 탈출비(내고 바로 나가기)', labels: { 0: '없음(낼 수 없음)', 10: '10P', 20: '20P' } }),
takeover: z.boolean().default(false).meta({ title: '인수(통행료를 낸 뒤 2배 값에 사들이기)' }),
landmark: z.boolean().default(false).meta({ title: '랜드마크(호텔 다음 단계)' }),
monopolyWin: z.boolean().default(false).meta({ title: '독점 즉시 승리(트리플·라인·관광지)' }),
trade: z.boolean().default(false).meta({ title: '플레이어끼리 땅·포인트 교환' }),
turnSeconds: z
.union([z.literal(15), z.literal(30), z.literal(60), z.literal(null)])
.default(30)
.meta({ title: '결정 1번 제한 시간', labels: { 15: '15초', 30: '30초', 60: '60초', null: '무제한' } }),
});
export type MarbleOptions = z.infer<typeof marbleOptionsSchema>;
const posSchema = z.number().int().min(0).max(39);
const offerSchema = z.object({ lands: z.array(posSchema).max(28), cash: z.number().int().min(0).max(1_000_000) });
export const marbleActionSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('ROLL') }),
z.object({ type: z.literal('PAY_ISLAND_FEE') }),
z.object({ type: z.literal('USE_ESCAPE_CARD') }),
z.object({ type: z.literal('SPACE_TRAVEL'), dest: posSchema }),
z.object({ type: z.literal('BUY') }),
z.object({ type: z.literal('BUILD'), level: z.number().int().min(1).max(4) }),
z.object({ type: z.literal('TAKEOVER') }),
z.object({ type: z.literal('PASS') }),
z.object({ type: z.literal('SELL'), pos: posSchema, what: z.enum(['top', 'all']) }),
z.object({ type: z.literal('PROPOSE_TRADE'), to: z.string().min(1).max(64), give: offerSchema, take: offerSchema }),
z.object({ type: z.literal('TRADE_RESPOND'), accept: z.boolean() }),
z.object({ type: z.literal('CONCEDE') }),
]);
export type MarbleClientAction = z.infer<typeof marbleActionSchema>;
/** Client actions plus the system-only timer action (not in the schema, docs/09 §2). */
export type MarbleAction = MarbleClientAction | { type: 'TIMEOUT' };
export type TradeOffer = { lands: number[]; cash: number };
// ---------------------------------------------------------------- state
export type Creditor = { kind: 'player'; id: PlayerId } | { kind: 'bank' } | { kind: 'fund' };
export type DebtReason = 'toll' | 'card' | 'fund' | 'fare';
export interface Debt {
debtor: PlayerId;
amount: number;
to: Creditor;
reason: DebtReason;
}
/** What happens once every queued payment is settled. */
export type Cont = { k: 'land' } | { k: 'toll'; pos: number } | { k: 'space'; dest: number };
export interface MarblePlayer {
pos: number;
cash: number;
alive: boolean;
bankruptAtRound: number | null;
/** 1 = first to go bankrupt / concede. */
outSeq: number | null;
/** 0 = not on the island. */
islandLeft: number;
spacePending: boolean;
heldCards: CardId[];
/** Left by CONCEDE (not by bankruptcy). */
conceded: boolean;
}
export interface Owned {
owner: PlayerId;
level: Level;
}
export interface Decision {
pos: number;
canBuy: boolean;
canBuild: Level | null;
/** 인수가, when a takeover is offered. */
canTakeover: number | null;
boughtThisVisit: boolean;
}
export interface DebtState extends Debt {
queue: Debt[];
then: Cont;
}
export interface Trade {
from: PlayerId;
to: PlayerId;
give: TradeOffer;
take: TradeOffer;
}
export type MoveKind = 'roll' | 'card' | 'back' | 'space' | 'warp';
export interface MarbleRankEntry {
rank: number;
player: PlayerId;
totalAssets: number;
cash: number;
lands: number;
bankruptAtRound: number | null;
}
export interface MarbleResult {
reason: 'lastSurvivor' | 'roundLimit' | 'monopoly';
monopolyType?: 'triple' | 'line' | 'tour';
ranking: MarbleRankEntry[];
winners: PlayerId[];
summary: string;
}
export type Phase = 'turnStart' | 'decide' | 'debt' | 'tradeResponse' | 'finished';
export interface MarbleState {
options: MarbleOptions;
order: PlayerId[];
current: number;
round: number;
phase: Phase;
players: Record<PlayerId, MarblePlayer>;
/** Cities and tourist spots that have an owner, keyed by position. */
owners: Record<number, Owned>;
fund: number;
/** Hidden: index 0 = top. */
deck: CardId[];
/** Cards drawn at least once (their IDs are public from then on). */
revealed: CardId[];
doublesThisTurn: number;
cardsThisTurn: number;
decision: Decision | null;
debt: DebtState | null;
pendingTrade: Trade | null;
tradesThisTurn: number;
/** Doubles: the same player rolls again from turnStart. */
extraRoll: boolean;
lastRoll: { seq: number; player: PlayerId; dice: [number, number]; double: boolean; island: boolean } | null;
lastMove: { seq: number; player: PlayerId; from: number; to: number; kind: MoveKind; passedStart: boolean } | null;
lastCard: { seq: number; player: PlayerId; id: CardId } | null;
/** Increases with every roll / move / card (animation keys). */
seq: number;
turnSeq: number;
deadlineAt: number | null;
timeouts: Record<PlayerId, number>;
log: GameEvent[];
outCount: number;
/** Bank flows (points created / destroyed), for the bookkeeping invariant. */
ledger: { minted: number; burned: number };
final: MarbleResult | null;
}
// ---------------------------------------------------------------- small helpers
const LOG_MAX = 60;
export const AWAY_AFTER = 3;
export const AWAY_MS = 5000;
export const TRADE_MS = 20_000;
export const MAX_TRADES_PER_TURN = 2;
export const curId = (s: MarbleState): PlayerId => s.order[s.current]!;
export const pl = (s: MarbleState, id: PlayerId): MarblePlayer => s.players[id]!;
export const aliveIds = (s: MarbleState): PlayerId[] => s.order.filter((p) => s.players[p]!.alive);
export const maxLevel = (o: MarbleOptions): Level => (o.landmark ? 4 : 3);
export function pushLog(s: MarbleState, ev: GameEvent[], e: GameEvent): void {
ev.push(e);
s.log.push(e);
if (s.log.length > LOG_MAX) s.log.splice(0, s.log.length - LOG_MAX);
}
export const ownedBy = (s: MarbleState, id: PlayerId): number[] =>
PROPERTY_POSITIONS.filter((p) => s.owners[p]?.owner === id);
/** Toll to pay on `pos` right now (tourist spots double when the owner has 2+). */
export function tollAt(s: MarbleState, pos: number): number {
const o = s.owners[pos];
if (!o) return 0;
const base = baseToll(pos, o.level);
if (SPACES[pos]!.kind === 'tour') {
const n = TOUR_POSITIONS.filter((p) => s.owners[p]?.owner === o.owner).length;
return n >= 2 ? base * 2 : base;
}
return base;
}
/** 현금 + 모든 건물·땅 매각가 합 (§5.3). */
export const raisable = (s: MarbleState, id: PlayerId): number =>
pl(s, id).cash + ownedBy(s, id).reduce((a, p) => a + fullSale(p, s.owners[p]!.level), 0);
/** 총자산 = 현금 + Σ(땅값 + 건설비, 정가) (§6). */
export const totalAssets = (s: MarbleState, id: PlayerId): number =>
pl(s, id).cash + ownedBy(s, id).reduce((a, p) => a + investment(p, s.owners[p]!.level), 0);
export function canTakeoverAt(s: MarbleState, pos: number, by: PlayerId): number | null {
const o = s.owners[pos];
if (!s.options.takeover || !o || !isCity(pos) || o.level >= 4 || o.owner === by || !pl(s, o.owner).alive) return null;
return takeoverPrice(pos, o.level);
}
/** Bank → player. */
function mint(s: MarbleState, id: PlayerId, amount: number): void {
pl(s, id).cash += amount;
s.ledger.minted += amount;
}
/** Player → creditor (a gone player creditor counts as the bank). */
function transfer(s: MarbleState, from: PlayerId, to: Creditor, amount: number): void {
const p = pl(s, from);
if (amount > p.cash) throw new Error(`marble: ${from} cannot pay ${amount} with ${p.cash}`);
p.cash -= amount;
if (to.kind === 'player' && s.players[to.id]?.alive) s.players[to.id]!.cash += amount;
else if (to.kind === 'fund') s.fund += amount;
else s.ledger.burned += amount;
}
const bankC: Creditor = { kind: 'bank' };
const playerC = (id: PlayerId): Creditor => ({ kind: 'player', id });
function deckBottom(s: MarbleState, id: CardId): void {
s.deck.push(id);
}
// ---------------------------------------------------------------- movement
function salary(s: MarbleState, id: PlayerId, ev: GameEvent[]): void {
mint(s, id, s.options.salary);
pushLog(s, ev, { type: 'salary', player: id, amount: s.options.salary });
}
/** Clockwise move; passing or reaching 출발 pays the salary once. */
function moveForward(s: MarbleState, id: PlayerId, steps: number, kind: MoveKind, ev: GameEvent[]): void {
const p = pl(s, id);
const from = p.pos;
const passedStart = steps > 0 && from + steps >= 40;
p.pos = (from + steps) % 40;
s.lastMove = { seq: ++s.seq, player: id, from, to: p.pos, kind, passedStart };
pushLog(s, ev, { type: 'moved', player: id, from, to: p.pos, kind, passedStart });
if (passedStart) salary(s, id, ev);
}
function moveBack(s: MarbleState, id: PlayerId, steps: number, ev: GameEvent[]): void {
const p = pl(s, id);
const from = p.pos;
p.pos = (from - steps + 40) % 40;
s.lastMove = { seq: ++s.seq, player: id, from, to: p.pos, kind: 'back', passedStart: false };
pushLog(s, ev, { type: 'moved', player: id, from, to: p.pos, kind: 'back', passedStart: false });
}
function sendToIsland(s: MarbleState, id: PlayerId, why: 'landed' | 'doubles' | 'card', ev: GameEvent[]): void {
const p = pl(s, id);
const from = p.pos;
p.pos = ISLAND;
p.islandLeft = s.options.islandTurns;
s.extraRoll = false;
s.doublesThisTurn = 0;
if (from !== ISLAND) s.lastMove = { seq: ++s.seq, player: id, from, to: ISLAND, kind: 'warp', passedStart: false };
pushLog(s, ev, { type: 'toIsland', player: id, why, turns: p.islandLeft });
}
// ---------------------------------------------------------------- turn flow
/** Landing resolved (and every decision taken): roll again on doubles, else the turn ends. */
function afterLanding(s: MarbleState, ev: GameEvent[]): void {
s.decision = null;
if (s.phase === 'finished') return;
if (!pl(s, curId(s)).alive) return endTurn(s, ev);
if (s.extraRoll) {
s.phase = 'turnStart';
pushLog(s, ev, { type: 'extraRoll', player: curId(s) });
return;
}
endTurn(s, ev);
}
export function endTurn(s: MarbleState, ev: GameEvent[]): void {
s.decision = null;
s.debt = null;
s.pendingTrade = null;
s.extraRoll = false;
s.doublesThisTurn = 0;
s.cardsThisTurn = 0;
s.tradesThisTurn = 0;
if (s.phase === 'finished') return;
if (aliveIds(s).length <= 1) return finish(s, 'lastSurvivor', ev);
const from = s.current;
const next = nextSeat(from, s.order.length, (i) => !s.players[s.order[i]!]!.alive);
if (next <= from) {
pushLog(s, ev, { type: 'roundEnd', round: s.round });
if (s.options.maxRounds > 0 && s.round >= s.options.maxRounds) return finish(s, 'roundLimit', ev);
s.round += 1;
}
s.current = next;
s.phase = 'turnStart';
pushLog(s, ev, { type: 'turn', player: curId(s), round: s.round });
}
/** Arrival on the current player's square (§5.2). */
function land(s: MarbleState, ev: GameEvent[]): void {
const id = curId(s);
const p = pl(s, id);
const sp = SPACES[p.pos]!;
switch (sp.kind) {
case 'start':
return afterLanding(s, ev);
case 'city':
case 'tour': {
const o = s.owners[p.pos];
if (!o) return offerBuy(s, ev);
if (o.owner === id) return offerBuild(s, ev, false);
return payToll(s, ev);
}
case 'card':
if (s.cardsThisTurn >= CARD_CAP) {
pushLog(s, ev, { type: 'cardIgnored', player: id });
return afterLanding(s, ev);
}
return drawCard(s, ev);
case 'fundOut':
return pay(s, [{ debtor: id, amount: FUND_PAYMENT, to: { kind: 'fund' }, reason: 'fund' }], { k: 'land' }, ev);
case 'fundIn': {
const amount = s.fund;
if (amount > 0) {
s.fund = 0;
p.cash += amount;
}
pushLog(s, ev, { type: 'fundReceived', player: id, amount });
return afterLanding(s, ev);
}
case 'island':
sendToIsland(s, id, 'landed', ev);
return endTurn(s, ev);
case 'space':
p.spacePending = true;
s.extraRoll = false;
pushLog(s, ev, { type: 'spaceWait', player: id });
return endTurn(s, ev);
}
}
function offerBuy(s: MarbleState, ev: GameEvent[]): void {
const id = curId(s);
const pos = pl(s, id).pos;
const price = SPACES[pos]!.price!;
if (pl(s, id).cash < price) {
pushLog(s, ev, { type: 'cannotAfford', player: id, pos, what: 'buy', cost: price });
return afterLanding(s, ev);
}
s.decision = { pos, canBuy: true, canBuild: null, canTakeover: null, boughtThisVisit: false };
s.phase = 'decide';
}
/** Own square: offer the next building stage (one per visit). */
function offerBuild(s: MarbleState, ev: GameEvent[], justBought: boolean): void {
const id = curId(s);
const pos = pl(s, id).pos;
const o = s.owners[pos]!;
if (!isCity(pos) || o.level >= maxLevel(s.options) || (justBought && (!s.options.buildOnPurchase || o.level !== 0))) return afterLanding(s, ev);
const next = (o.level + 1) as Level;
const cost = stageCost(pos, next);
if (pl(s, id).cash < cost) {
if (!justBought) pushLog(s, ev, { type: 'cannotAfford', player: id, pos, what: 'build', cost });
return afterLanding(s, ev);
}
s.decision = { pos, canBuy: false, canBuild: next, canTakeover: null, boughtThisVisit: justBought };
s.phase = 'decide';
}
function payToll(s: MarbleState, ev: GameEvent[]): void {
const id = curId(s);
const p = pl(s, id);
const pos = p.pos;
const owner = s.owners[pos]!.owner;
const amount = tollAt(s, pos);
const waiver = p.heldCards.indexOf('L12');
if (waiver >= 0) {
p.heldCards.splice(waiver, 1);
deckBottom(s, 'L12');
pushLog(s, ev, { type: 'tollPaid', from: id, to: owner, pos, amount, waived: true });
return contToll(s, pos, ev);
}
pay(s, [{ debtor: id, amount, to: playerC(owner), reason: 'toll' }], { k: 'toll', pos }, ev);
}
/** After the toll: offer a takeover when the option allows it (§5.7). */
function contToll(s: MarbleState, pos: number, ev: GameEvent[]): void {
const id = curId(s);
const price = canTakeoverAt(s, pos, id);
if (price !== null && pl(s, id).cash >= price) {
s.decision = { pos, canBuy: false, canBuild: null, canTakeover: price, boughtThisVisit: false };
s.phase = 'decide';
return;
}
afterLanding(s, ev);
}
function runCont(s: MarbleState, then: Cont, ev: GameEvent[]): void {
if (s.phase === 'finished') return;
if (!pl(s, curId(s)).alive) return endTurn(s, ev);
if (then.k === 'land') return afterLanding(s, ev);
if (then.k === 'toll') return contToll(s, then.pos, ev);
return fly(s, then.dest, ev);
}
/** Settles payments in order; stops in the `debt` phase when someone has to sell first (§5.3). */
function pay(s: MarbleState, debts: Debt[], then: Cont, ev: GameEvent[]): void {
s.debt = null;
runQueue(s, [...debts], then, ev);
}
function runQueue(s: MarbleState, queue: Debt[], then: Cont, ev: GameEvent[]): void {
while (queue.length > 0) {
const d = queue[0]!;
const p = pl(s, d.debtor);
if (!p.alive || d.amount <= 0) {
queue.shift();
continue;
}
if (p.cash >= d.amount) {
transfer(s, d.debtor, d.to, d.amount);
logPaid(s, d, ev);
queue.shift();
continue;
}
if (raisable(s, d.debtor) < d.amount) {
queue.shift();
bankrupt(s, d.debtor, d.to, ev);
if (s.phase === 'finished') return;
if (d.debtor === curId(s)) return endTurn(s, ev);
continue;
}
s.phase = 'debt';
s.debt = { ...d, queue: queue.slice(1), then };
pushLog(s, ev, { type: 'debt', player: d.debtor, amount: d.amount, short: d.amount - p.cash, to: d.to, reason: d.reason });
return;
}
s.debt = null;
runCont(s, then, ev);
}
function logPaid(s: MarbleState, d: Debt, ev: GameEvent[]): void {
if (d.reason === 'toll' && d.to.kind === 'player') {
pushLog(s, ev, { type: 'tollPaid', from: d.debtor, to: d.to.id, pos: pl(s, d.debtor).pos, amount: d.amount, waived: false });
} else if (d.reason === 'fund') {
pushLog(s, ev, { type: 'fundPaid', player: d.debtor, amount: d.amount });
} else {
pushLog(s, ev, { type: 'paid', from: d.debtor, to: d.to, amount: d.amount, reason: d.reason });
}
}
/** Sells everything to the bank (sale value), returns the lands to the bank. */
function liquidate(s: MarbleState, id: PlayerId): number {
let got = 0;
for (const pos of ownedBy(s, id)) {
got += fullSale(pos, s.owners[pos]!.level);
delete s.owners[pos];
}
if (got > 0) mint(s, id, got);
return got;
}
/** 파산 (§5.3): sell everything, all points go to the creditor, the player is out. */
function bankrupt(s: MarbleState, id: PlayerId, to: Creditor, ev: GameEvent[]): void {
liquidate(s, id);
const p = pl(s, id);
const amount = p.cash;
if (amount > 0) transfer(s, id, to, amount);
retire(s, id);
pushLog(s, ev, { type: 'bankrupt', player: id, creditor: to, amount });
if (aliveIds(s).length <= 1) finish(s, 'lastSurvivor', ev);
}
function retire(s: MarbleState, id: PlayerId): void {
const p = pl(s, id);
for (const c of p.heldCards) deckBottom(s, c);
p.heldCards = [];
p.alive = false;
p.bankruptAtRound = s.round;
p.outSeq = ++s.outCount;
p.islandLeft = 0;
p.spacePending = false;
s.timeouts[id] = 0;
}
// ---------------------------------------------------------------- cards
function drawCard(s: MarbleState, ev: GameEvent[]): void {
const id = curId(s);
const p = pl(s, id);
const card = s.deck.shift();
if (!card) throw new Error('marble: empty deck');
s.cardsThisTurn += 1;
s.lastCard = { seq: ++s.seq, player: id, id: card };
if (!s.revealed.includes(card)) s.revealed.push(card);
pushLog(s, ev, { type: 'cardDrawn', player: id, cardId: card });
if (KEEP_CARDS.includes(card)) p.heldCards.push(card);
else deckBottom(s, card);
const others = () => {
// Seat order starting after the current player (§5.3).
const out: PlayerId[] = [];
for (let k = 1; k < s.order.length; k++) {
const q = s.order[(s.current + k) % s.order.length]!;
if (s.players[q]!.alive) out.push(q);
}
return out;
};
const gain = (n: number) => {
mint(s, id, n);
pushLog(s, ev, { type: 'cardGain', player: id, amount: n });
afterLanding(s, ev);
};
const owe = (amount: number, to: Creditor) => (amount > 0 ? pay(s, [{ debtor: id, amount, to, reason: 'card' }], { k: 'land' }, ev) : afterLanding(s, ev));
switch (card) {
case 'L01':
return gain(20);
case 'L02':
return gain(10);
case 'L03':
return gain(15);
case 'L05':
return gain(10);
case 'L04':
return pay(s, others().map((q) => ({ debtor: q, amount: BIRTHDAY_GIFT, to: playerC(id), reason: 'card' as const })), { k: 'land' }, ev);
case 'L06':
return owe(5, { kind: 'fund' });
case 'L07':
return owe(10, bankC);
case 'L08':
return owe(10, { kind: 'fund' });
case 'L09':
return owe(repairCost(s, id), bankC);
case 'L20':
return owe(ownedBy(s, id).length * PROPERTY_TAX, bankC);
case 'L19':
return pay(s, others().map((q) => ({ debtor: id, amount: TRAVEL_GIFT, to: playerC(q), reason: 'card' as const })), { k: 'land' }, ev);
case 'L10':
sendToIsland(s, id, 'card', ev);
return endTurn(s, ev);
case 'L11':
case 'L12':
return afterLanding(s, ev);
case 'L13':
moveForward(s, id, forwardSteps(p.pos, START) || 40, 'card', ev);
return land(s, ev);
case 'L14':
moveForward(s, id, forwardSteps(p.pos, SPACE_TRAVEL), 'card', ev);
return land(s, ev);
case 'L15':
moveForward(s, id, forwardSteps(p.pos, SEOUL), 'card', ev);
return land(s, ev);
case 'L16':
moveBack(s, id, 3, ev);
return land(s, ev);
case 'L17':
moveForward(s, id, forwardSteps(p.pos, FUND_IN), 'card', ev);
return land(s, ev);
case 'L18': {
const dest = TOUR_POSITIONS.map((t) => ({ t, d: forwardSteps(p.pos, t) || 40 })).sort((a, b) => a.d - b.d)[0]!;
moveForward(s, id, dest.d, 'card', ev);
return land(s, ev);
}
}
}
/** L09: 별장 3, 빌딩 6, 호텔 10, 랜드마크 15 per built stage. */
export function repairCost(s: MarbleState, id: PlayerId): number {
let t = 0;
for (const pos of ownedBy(s, id)) for (let l = 1; l <= s.owners[pos]!.level; l++) t += REPAIR_PER_STAGE[l - 1]!;
return t;
}
// ---------------------------------------------------------------- monopoly & finish
export function monopolyOf(s: MarbleState, id: PlayerId): 'triple' | 'line' | 'tour' | null {
const mine = (p: number) => s.owners[p]?.owner === id;
const groups = Object.values(GROUPS).filter((g) => g.every(mine)).length;
if (groups >= 3) return 'triple';
if (LINES.some((l) => l.every(mine))) return 'line';
if (TOUR_POSITIONS.every(mine)) return 'tour';
return null;
}
function checkMonopoly(s: MarbleState, id: PlayerId, ev: GameEvent[]): boolean {
if (!s.options.monopolyWin || s.phase === 'finished' || !pl(s, id).alive) return false;
const m = monopolyOf(s, id);
if (!m) return false;
pushLog(s, ev, { type: 'monopoly', player: id, kind: m });
finish(s, 'monopoly', ev, { player: id, kind: m });
return true;
}
const MONO_KO = { triple: '트리플 독점', line: '라인 독점', tour: '관광지 독점' } as const;
export function finish(s: MarbleState, reason: MarbleResult['reason'], ev: GameEvent[], mono?: { player: PlayerId; kind: 'triple' | 'line' | 'tour' }): void {
const entry = (p: PlayerId): Omit<MarbleRankEntry, 'rank'> => ({
player: p,
totalAssets: totalAssets(s, p),
cash: pl(s, p).cash,
lands: ownedBy(s, p).length,
bankruptAtRound: pl(s, p).bankruptAtRound,
});
const ranking: MarbleRankEntry[] = [];
let alive = aliveIds(s).map(entry);
if (mono) {
ranking.push({ rank: 1, ...entry(mono.player) });
alive = alive.filter((e) => e.player !== mono.player);
}
alive.sort((a, b) => b.totalAssets - a.totalAssets || b.cash - a.cash);
for (let i = 0; i < alive.length; i++) {
const e = alive[i]!;
const prev = alive[i - 1];
const rank = prev && prev.totalAssets === e.totalAssets && prev.cash === e.cash ? ranking[ranking.length - 1]!.rank : ranking.length + 1;
ranking.push({ rank, ...e });
}
const out = s.order.filter((p) => !pl(s, p).alive).sort((a, b) => pl(s, b).outSeq! - pl(s, a).outSeq!);
for (const p of out) ranking.push({ rank: ranking.length + 1, ...entry(p) });
const winners = ranking.filter((r) => r.rank === 1).map((r) => r.player);
const top = ranking[0]!;
let summary: string;
if (reason === 'monopoly') summary = `${MONO_KO[mono!.kind]} 달성으로 즉시 승리! (총자산 ${top.totalAssets}P)`;
else if (reason === 'lastSurvivor') summary = out.length > 0 ? `다른 사람이 모두 파산해서 끝까지 남은 사람이 이겼어요 (총자산 ${top.totalAssets}P)` : '게임이 끝났어요';
else if (winners.length > 1) summary = `${s.round}라운드 끝! 총자산 ${top.totalAssets}P로 ${winners.length}명 공동 1등`;
else summary = `${s.round}라운드 끝! 총자산 ${top.totalAssets}P로 1등`;
s.phase = 'finished';
s.decision = null;
s.debt = null;
s.pendingTrade = null;
s.extraRoll = false;
s.deadlineAt = null;
s.final = { reason, ...(mono ? { monopolyType: mono.kind } : {}), ranking, winners, summary };
pushLog(s, ev, { type: 'gameOver', reason, winners, summary });
}
// ---------------------------------------------------------------- actions
function rollDice(rng: Rng): [number, number] {
return [1 + rng.int(6), 1 + rng.int(6)];
}
export function doRoll(s: MarbleState, rng: Rng, ev: GameEvent[]): void {
const id = curId(s);
const p = pl(s, id);
const dice = rollDice(rng);
const double = dice[0] === dice[1];
const sum = dice[0] + dice[1];
const onIsland = p.islandLeft > 0;
s.lastRoll = { seq: ++s.seq, player: id, dice, double, island: onIsland };
pushLog(s, ev, { type: 'rolled', player: id, dice, double, island: onIsland });
if (onIsland) {
s.extraRoll = false;
if (double) {
p.islandLeft = 0;
pushLog(s, ev, { type: 'escaped', player: id, how: 'double' });
moveForward(s, id, sum, 'roll', ev);
return land(s, ev);
}
p.islandLeft -= 1;
pushLog(s, ev, { type: 'islandStay', player: id, left: p.islandLeft });
return endTurn(s, ev);
}
s.extraRoll = false;
if (double) {
s.doublesThisTurn += 1;
if (s.doublesThisTurn >= 3) {
sendToIsland(s, id, 'doubles', ev);
return endTurn(s, ev);
}
s.extraRoll = true;
}
moveForward(s, id, sum, 'roll', ev);
land(s, ev);
}
export function doEscape(s: MarbleState, how: 'fee' | 'card', ev: GameEvent[]): void {
const id = curId(s);
const p = pl(s, id);
if (how === 'fee') transfer(s, id, bankC, s.options.islandFee);
else {
p.heldCards.splice(p.heldCards.indexOf('L11'), 1);
deckBottom(s, 'L11');
}
p.islandLeft = 0;
pushLog(s, ev, { type: 'escaped', player: id, how, amount: how === 'fee' ? s.options.islandFee : 0 });
}
export function doSpaceTravel(s: MarbleState, dest: number, ev: GameEvent[]): void {
const id = curId(s);
pl(s, id).spacePending = false;
s.extraRoll = false;
pushLog(s, ev, { type: 'spaceTravel', player: id, dest });
const center = s.owners[SPACE_CENTER];
if (center && center.owner !== id && pl(s, center.owner).alive) {
return pay(s, [{ debtor: id, amount: SPACE_FARE, to: playerC(center.owner), reason: 'fare' }], { k: 'space', dest }, ev);
}
fly(s, dest, ev);
}
function fly(s: MarbleState, dest: number, ev: GameEvent[]): void {
const id = curId(s);
moveForward(s, id, forwardSteps(pl(s, id).pos, dest), 'space', ev);
land(s, ev);
}
export function doBuy(s: MarbleState, ev: GameEvent[]): void {
const id = curId(s);
const pos = s.decision!.pos;
const price = SPACES[pos]!.price!;
transfer(s, id, bankC, price);
s.owners[pos] = { owner: id, level: 0 };
pushLog(s, ev, { type: 'bought', player: id, pos, price });
if (checkMonopoly(s, id, ev)) return;
s.decision = null;
offerBuild(s, ev, true);
}
export function doBuild(s: MarbleState, level: Level, ev: GameEvent[]): void {
const id = curId(s);
const pos = s.decision!.pos;
const cost = stageCost(pos, level);
transfer(s, id, bankC, cost);
s.owners[pos]!.level = level;
pushLog(s, ev, { type: 'built', player: id, pos, level, cost });
afterLanding(s, ev);
}
export function doTakeover(s: MarbleState, ev: GameEvent[]): void {
const id = curId(s);
const pos = s.decision!.pos;
const o = s.owners[pos]!;
const price = takeoverPrice(pos, o.level);
const prev = o.owner;
transfer(s, id, playerC(prev), price);
o.owner = id;
pushLog(s, ev, { type: 'takeover', player: id, from: prev, pos, price });
if (checkMonopoly(s, id, ev)) return;
afterLanding(s, ev);
}
export function doPass(s: MarbleState, ev: GameEvent[]): void {
const d = s.decision;
if (d) pushLog(s, ev, { type: 'passed', player: curId(s), pos: d.pos });
afterLanding(s, ev);
}
export function doSell(s: MarbleState, id: PlayerId, pos: number, what: 'top' | 'all', ev: GameEvent[]): void {
const o = s.owners[pos]!;
let got: number;
if (what === 'top') {
got = stageSale(pos, o.level);
o.level = (o.level - 1) as Level;
} else {
got = fullSale(pos, o.level);
delete s.owners[pos];
}
mint(s, id, got);
pushLog(s, ev, { type: 'sold', player: id, pos, what, amount: got });
const d = s.debt;
if (d && d.debtor === id && pl(s, id).cash >= d.amount) {
transfer(s, id, d.to, d.amount);
logPaid(s, d, ev);
s.debt = null;
runQueue(s, [...d.queue], d.then, ev);
}
}
/** Cheapest sale first (§8.5): a top stage with the smallest sale value, else the cheapest land; ties → lower square. */
export function autoSell(s: MarbleState, id: PlayerId): { pos: number; what: 'top' | 'all' } | null {
const mine = ownedBy(s, id);
const built = mine.filter((p) => s.owners[p]!.level >= 1);
if (built.length) {
const best = [...built].sort((a, b) => stageSale(a, s.owners[a]!.level) - stageSale(b, s.owners[b]!.level) || a - b)[0]!;
return { pos: best, what: 'top' };
}
if (!mine.length) return null;
const best = [...mine].sort((a, b) => SPACES[a]!.price! - SPACES[b]!.price! || a - b)[0]!;
return { pos: best, what: 'all' };
}
export function doPropose(s: MarbleState, t: Trade, ev: GameEvent[]): void {
s.pendingTrade = { from: t.from, to: t.to, give: { lands: [...t.give.lands].sort((a, b) => a - b), cash: t.give.cash }, take: { lands: [...t.take.lands].sort((a, b) => a - b), cash: t.take.cash } };
s.tradesThisTurn += 1;
s.phase = 'tradeResponse';
pushLog(s, ev, { type: 'tradeProposed', ...structuredClone(s.pendingTrade) });
}
/** Ownership / points still match the offer? */
export function tradeStillValid(s: MarbleState, t: Trade): boolean {
return (
pl(s, t.from).alive &&
pl(s, t.to).alive &&
t.give.lands.every((p) => s.owners[p]?.owner === t.from) &&
t.take.lands.every((p) => s.owners[p]?.owner === t.to) &&
pl(s, t.from).cash >= t.give.cash &&
pl(s, t.to).cash >= t.take.cash
);
}
export function doRespond(s: MarbleState, accept: boolean, ev: GameEvent[]): void {
const t = s.pendingTrade!;
s.pendingTrade = null;
s.phase = 'turnStart';
if (!accept) {
pushLog(s, ev, { type: 'tradeResolved', from: t.from, to: t.to, accepted: false });
return;
}
if (!tradeStillValid(s, t)) {
pushLog(s, ev, { type: 'tradeResolved', from: t.from, to: t.to, accepted: false, failed: true });
return;
}
for (const p of t.give.lands) s.owners[p]!.owner = t.to;
for (const p of t.take.lands) s.owners[p]!.owner = t.from;
if (t.give.cash) transfer(s, t.from, playerC(t.to), t.give.cash);
if (t.take.cash) transfer(s, t.to, playerC(t.from), t.take.cash);
pushLog(s, ev, { type: 'tradeResolved', from: t.from, to: t.to, accepted: true });
if (checkMonopoly(s, t.from, ev)) return;
checkMonopoly(s, t.to, ev);
}
/** 기권 (§8.2 CONCEDE): like bankruptcy to the bank. A debtor in the middle of paying still settles that debt first. */
export function doConcede(s: MarbleState, id: PlayerId, ev: GameEvent[]): void {
const wasCurrent = id === curId(s);
if (s.phase === 'tradeResponse' && s.pendingTrade && (s.pendingTrade.from === id || s.pendingTrade.to === id)) {
pushLog(s, ev, { type: 'tradeResolved', from: s.pendingTrade.from, to: s.pendingTrade.to, accepted: false, cancelled: true });
s.pendingTrade = null;
s.phase = 'turnStart';
}
const d = s.debt && s.phase === 'debt' && s.debt.debtor === id ? s.debt : null;
liquidate(s, id);
let toCreditor = 0;
if (d) {
toCreditor = Math.min(pl(s, id).cash, d.amount);
if (toCreditor > 0) transfer(s, id, d.to, toCreditor);
}
const rest = pl(s, id).cash;
if (rest > 0) transfer(s, id, bankC, rest);
retire(s, id);
pl(s, id).conceded = true;
pushLog(s, ev, { type: 'conceded', player: id, ...(d ? { creditor: d.to, paid: toCreditor } : {}) });
if (aliveIds(s).length <= 1) return finish(s, 'lastSurvivor', ev);
if (wasCurrent) return endTurn(s, ev);
if (d) {
s.debt = null;
return runQueue(s, [...d.queue], d.then, ev);
}
// A takeover offer on the leaver's land is gone with the land.
if (s.phase === 'decide' && s.decision && s.decision.canTakeover !== null && !s.owners[s.decision.pos]) afterLanding(s, ev);
}
// ---------------------------------------------------------------- setup
export function createState(players: PlayerId[], options: MarbleOptions, rng: Rng, deck: CardId[]): MarbleState {
const order = rng.shuffle(players);
const ps: Record<PlayerId, MarblePlayer> = {};
const timeouts: Record<PlayerId, number> = {};
for (const p of order) {
ps[p] = { pos: START, cash: options.startMoney, alive: true, bankruptAtRound: null, outSeq: null, islandLeft: 0, spacePending: false, heldCards: [], conceded: false };
timeouts[p] = 0;
}
return {
options,
order,
current: 0,
round: 1,
phase: 'turnStart',
players: ps,
owners: {},
fund: 0,
deck: rng.shuffle(deck),
revealed: [],
doublesThisTurn: 0,
cardsThisTurn: 0,
decision: null,
debt: null,
pendingTrade: null,
tradesThisTurn: 0,
extraRoll: false,
lastRoll: null,
lastMove: null,
lastCard: null,
seq: 0,
turnSeq: 0,
deadlineAt: null,
timeouts,
log: [],
outCount: 0,
ledger: { minted: 0, burned: 0 },
final: null,
};
}

View File

@@ -22,7 +22,12 @@ export const CATALOG: CatalogEntry[] = [
{ id: 'yut', nameKo: '윷놀이', minPlayers: 2, maxPlayers: 8, minutes: '15~25', level: '쉬움', blurb: '도·개·걸·윷·모! 개인전 또는 팀전', available: true },
{ id: 'one-card', nameKo: '원카드', minPlayers: 2, maxPlayers: 10, minutes: '10~15', level: '쉬움', blurb: '같은 무늬·숫자를 내고 공격 카드로 먹이기! · 색깔 카드 모드', available: true },
{ id: 'rummikub', nameKo: '숫자 타일', minPlayers: 2, maxPlayers: 4, minutes: '30~60', level: '보통', blurb: '같은 숫자, 이어지는 숫자로 묶어 먼저 다 내려놓기!', available: true },
{ id: 'gostop', nameKo: '고스톱', minPlayers: 2, maxPlayers: 5, minutes: '20~40', level: '보통', blurb: '같은 월을 맞춰 먹고 고·스톱! 2인 맞고·3인·광팔기 · 놀이용 칩', available: true },
{ id: 'bang', nameKo: '뱅!', minPlayers: 4, maxPlayers: 7, minutes: '20~40', level: '어려움', blurb: '보안관과 무법자, 숨은 정체를 추리하며 쏘고 피하기!', available: true },
{ id: 'davinci-code', nameKo: '다빈치 코드', minPlayers: 2, maxPlayers: 4, minutes: '10~15', level: '쉬움', blurb: '검은·흰 숫자 타일의 숫자를 추리해 맞히기', available: true },
{ id: 'mahjong', nameKo: '마작', minPlayers: 3, maxPlayers: 4, minutes: '25~50', level: '어려움', blurb: '리치 마작 4인 · 3인 산마 · 초보 모드로 대기패 힌트', available: true },
{ id: 'halli-galli', nameKo: '할리갈리', minPlayers: 2, maxPlayers: 6, minutes: '5~15', level: '쉬움', blurb: '한 과일이 딱 5개가 되면 누구보다 먼저 종을 땡!', available: true },
{ id: 'marble', nameKo: '주사위 땅따먹기', minPlayers: 2, maxPlayers: 4, minutes: '30~50', level: '쉬움', blurb: '주사위로 세계 도시를 돌며 땅 사고 건물 짓기 · 놀이용 포인트', available: true },
];
export const catalogById = (id: string): CatalogEntry | undefined => CATALOG.find((g) => g.id === id);