구십구: 코리아보드게임즈 공식 규칙으로 맞춤
- 공식 상품 페이지 규칙 설명 이미지 기준: 5장씩, 시작 카드(2명 40·3명 20·4명 이상 0), 0 카드는 방향 유지/반대 선택, 칩(목숨)은 공식 규칙이 아니라 하우스 룰 - handSize 'auto'(전용 카드 5, 트럼프 3), startTotal 옵션, 카드 종류 'zero'와 dir 액션 - stateVersion 2, 진행 중인 v1 게임은 옛 규칙 그대로 이어서 하도록 migrate - 특수 카드 장수(±9·±10·0)는 공개 자료가 없어 각 4장 유지 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -11,15 +11,16 @@ test('구십구: 내 차례 / 상대 차례 / 관전', () => {
|
||||
const { state } = setupGame('ninety-nine', 3);
|
||||
const cur = state.current as string;
|
||||
const other = ['A', 'B', 'C'].find((p) => p !== cur)!;
|
||||
expect(helpNow(view(state, cur), cur, users)).toContain('내 차례예요(지금 합계 0)');
|
||||
// 3 players: the start card says 20.
|
||||
expect(helpNow(view(state, cur), cur, users)).toContain('내 차례예요(지금 합계 20)');
|
||||
const t = helpNow(view(state, other), other, users);
|
||||
expect(t).toContain(`${nickOf(cur)}님 차례예요(지금 합계 0)`);
|
||||
expect(t).toContain(`${nickOf(cur)}님 차례예요(지금 합계 20)`);
|
||||
expect(t).toContain('바로 탈락해요');
|
||||
const spec = helpNow(view(state, null), null, users);
|
||||
expect(spec).toContain(`${nickOf(cur)}님 차례예요`);
|
||||
expect(spec).toContain('99를 넘게');
|
||||
const card = view(state, cur).my.hand.find((c: any) => c.kind !== 'joker');
|
||||
const s2 = applyAction('ninety-nine', state, cur, { type: 'play', cardId: card.id, ...(card.choices.length > 1 ? (card.choices[0].sign ? { sign: '+' } : { aceValue: 1 }) : {}) });
|
||||
const s2 = applyAction('ninety-nine', state, cur, { type: 'play', cardId: card.id, ...(card.choices.length > 1 ? (card.choices[0].sign ? { sign: '+' } : card.choices[0].dir ? { dir: 'keep' } : { aceValue: 1 }) : {}) });
|
||||
expect(helpNow(view(s2, s2.current), s2.current, users)).toContain(`지금 합계 ${s2.total}`);
|
||||
});
|
||||
|
||||
@@ -35,6 +36,8 @@ test('구십구: 합계 95에서 낼 수 있는 카드만 밝게, ±카드 안
|
||||
const t = helpNow(view(state, cur), cur, users);
|
||||
expect(t).toContain('밝게 보이는 카드만 낼 수 있어요(1장)');
|
||||
expect(t).toContain('±카드는');
|
||||
state.hands[cur].push({ id: 'q4', kind: 'zero', face: { label: '0' } });
|
||||
expect(helpNow(view(state, cur), cur, users)).toContain('0 카드는 합계를 그대로 둬요. 누른 뒤 [방향 그대로]와 [방향 바꾸기] 중 하나를 골라요.');
|
||||
});
|
||||
|
||||
test('구십구: 목숨 규칙과 탈락', () => {
|
||||
@@ -42,7 +45,7 @@ test('구십구: 목숨 규칙과 탈락', () => {
|
||||
const cur = state.current as string;
|
||||
const other = ['A', 'B', 'C'].find((p) => p !== cur)!;
|
||||
const t = helpNow(view(state, other), other, users);
|
||||
expect(t).toContain('목숨 1개를 잃고');
|
||||
expect(t).toContain('목숨 1개를 잃고 합계 20부터 새 판을 해요');
|
||||
expect(t).toContain('내 목숨은 3개예요');
|
||||
state.alive[other] = false;
|
||||
expect(helpNow(view(state, other), other, users)).toContain('탈락했어요');
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
/** 구십구 [?] "지금 할 수 있는 것" (docs/07 §5). Pure: only reads the client view. */
|
||||
import type { NinetyNineView } from '@bg/games/ninety-nine';
|
||||
import type { NNOptions, NinetyNineView } from '@bg/games/ninety-nine';
|
||||
import type { PublicUser } from '@bg/shared/lite';
|
||||
|
||||
/** Same as the engine's: `auto` deals 5 with the dedicated cards, 3 with trump cards. */
|
||||
export const resolvedHandSize = (o: Pick<NNOptions, 'mode' | 'handSize'>): number => (o.handSize === 'auto' ? (o.mode === 'classic' ? 3 : 5) : o.handSize);
|
||||
/** Same as the engine's: the start card shows 40 for 2 players, 20 for 3, 0 for 4 or more. */
|
||||
export const resolvedStartTotal = (o: Pick<NNOptions, 'mode' | 'startTotal'>, players: number): number =>
|
||||
o.mode === 'kr-product' && o.startTotal === 'auto' ? (players <= 2 ? 40 : players === 3 ? 20 : 0) : 0;
|
||||
|
||||
const nm = (players: Record<string, PublicUser>, id: string | null | undefined) => `${(id && players[id]?.nickname) || '다른 사람'}님`;
|
||||
|
||||
/** What happens to someone who has no card to play (option dependent). */
|
||||
function loseText(v: NinetyNineView): string {
|
||||
return v.livesStart > 0 ? '목숨 1개를 잃고 합계 0부터 새 판을 해요' : '바로 탈락해요';
|
||||
if (v.livesStart === 0) return '바로 탈락해요';
|
||||
return `목숨 1개를 잃고 합계 ${resolvedStartTotal(v.options, v.seats.filter((s) => s.alive).length)}부터 새 판을 해요`;
|
||||
}
|
||||
|
||||
export function helpNow(v: NinetyNineView, me: string | null, players: Record<string, PublicUser>): string {
|
||||
@@ -44,6 +51,7 @@ export function helpNow(v: NinetyNineView, me: string | null, players: Record<st
|
||||
if (legal.some((c) => c.kind === 'ace')) lines.push('A는 누른 뒤 더할 값을 골라요.');
|
||||
if (legal.some((c) => c.kind === 'joker')) lines.push(`조커는 합계를 ${v.jokerRange.min}~${v.jokerRange.max} 중 원하는 수로 만들어요. 수를 고르고 [조커 내기]를 눌러요.`);
|
||||
const face = (k: string) => legal.find((c) => c.kind === k)?.face.label;
|
||||
if (face('zero')) lines.push(`${face('zero')} 카드는 합계를 그대로 둬요. 누른 뒤 [방향 그대로]와 [방향 바꾸기] 중 하나를 골라요.`);
|
||||
if (face('reverse')) lines.push(`${face('reverse')} 카드는 합계를 그대로 두고 진행 방향만 바꿔요.`);
|
||||
if (face('hold')) lines.push(`${face('hold')} 카드는 합계를 그대로 두고 다음 사람에게 넘겨요.`);
|
||||
if (face('to99')) lines.push(`${face('to99')} 카드는 합계를 바로 99로 만들어요.`);
|
||||
|
||||
@@ -7,7 +7,7 @@ import { TurnTimer } from '../../components/TurnTimer';
|
||||
import { PlayingCard } from '../../components/cards/PlayingCard';
|
||||
import { haptic, sounds } from '../../lib/feedback';
|
||||
import type { BoardProps, GameUI, OptionsProps } from '../types';
|
||||
import { helpNow } from './help';
|
||||
import { helpNow, resolvedHandSize } from './help';
|
||||
import { quickStart } from './quick';
|
||||
|
||||
const MODE_KO: Record<NNOptions['mode'], string> = { 'kr-product': '전용 카드', classic: '트럼프 카드' };
|
||||
@@ -18,6 +18,10 @@ const TRUMP_RANK: Record<string, string> = { '10': 'T' };
|
||||
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 resolvedLives = (o: NNOptions) => (o.lives === 'auto' ? (o.mode === 'classic' ? 3 : 0) : o.lives);
|
||||
/** Rounds start from the start card's number (2명 40 · 3명 20 · 4명 이상 0) rather than always 0. */
|
||||
const startCard = (o: NNOptions) => o.mode === 'kr-product' && o.startTotal === 'auto';
|
||||
/** Where a (new) round starts, in words: "시작 숫자" or "0". */
|
||||
const startWord = (o: NNOptions) => (startCard(o) ? '시작 숫자' : '0');
|
||||
|
||||
/** Short name, e.g. "±9", "조커", "♥K". */
|
||||
function faceName(f: CardFace): string {
|
||||
@@ -98,7 +102,7 @@ const CSS = `
|
||||
.nn-recent .who { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 90px; }
|
||||
.nn-mini { display: inline-grid; place-items: center; min-width: 30px; height: 22px; border-radius: 5px; background: #fffef9; color: #1f2937; font-weight: 900; font-size: .8rem; padding: 0 4px; text-shadow: none; }
|
||||
.nn-mini[data-k='plusMinus'] { background: #ede9fe; color: #5b21b6; }
|
||||
.nn-mini[data-k='reverse'] { background: #ccfbf1; color: #115e59; }
|
||||
.nn-mini[data-k='reverse'], .nn-mini[data-k='zero'] { background: #ccfbf1; color: #115e59; }
|
||||
.nn-mini[data-k='joker'], .nn-mini[data-k='to99'] { background: #fef3c7; color: #92400e; }
|
||||
.nn-mini[data-red='true'] { color: #dc2626; }
|
||||
.nn-hand-wrap { background: var(--surface); border-radius: 16px; box-shadow: var(--shadow); padding: 10px 12px 12px; display: flex; flex-direction: column; gap: 8px; }
|
||||
@@ -126,7 +130,7 @@ const CSS = `
|
||||
.nn-art[data-k='add'] { --band: #1d4ed8; }
|
||||
.nn-art[data-k='add'][data-big='true'] { --band: #b91c1c; }
|
||||
.nn-art[data-k='plusMinus'] { --band: #6d28d9; background: linear-gradient(160deg, #fffef9 55%, #ede9fe); }
|
||||
.nn-art[data-k='reverse'] { --band: #0f766e; background: linear-gradient(160deg, #fffef9 55%, #ccfbf1); }
|
||||
.nn-art[data-k='reverse'], .nn-art[data-k='zero'] { --band: #0f766e; background: linear-gradient(160deg, #fffef9 55%, #ccfbf1); }
|
||||
.nn-art[data-k='joker'] { --band: #b45309; background: radial-gradient(circle at 50% 40%, #fef3c7, #fffef9 70%); }
|
||||
.nn-pick { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; justify-content: center; }
|
||||
.nn-pick .btn { flex: 1 1 140px; max-width: 280px; font-size: 1.1rem; font-weight: 800; }
|
||||
@@ -163,7 +167,7 @@ const CSS = `
|
||||
|
||||
// ---------------------------------------------------------------- cards
|
||||
|
||||
const KR_SUB: Partial<Record<CardKind, string>> = { plusMinus: '더하기·빼기', reverse: '방향 바꾸기', joker: '' };
|
||||
const KR_SUB: Partial<Record<CardKind, string>> = { plusMinus: '더하기·빼기', reverse: '방향 바꾸기', zero: '방향 고르기', joker: '' };
|
||||
|
||||
function trumpId(f: CardFace): string {
|
||||
return `${TRUMP_RANK[f.label] ?? f.label}${f.suit}`;
|
||||
@@ -309,7 +313,7 @@ function Table({ v, players, me, deadline }: { v: NinetyNineView; players: Recor
|
||||
))}
|
||||
</ol>
|
||||
) : (
|
||||
<span className="nn-chip">{v.round > 1 ? `${v.round}판 시작! 합계 0` : '첫 카드를 기다려요'}</span>
|
||||
<span className="nn-chip">{v.round > 1 ? `${v.round}판 시작! 합계 ${v.total}` : v.total > 0 ? `시작 숫자 ${v.total} · 첫 카드를 기다려요` : '첫 카드를 기다려요'}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -328,9 +332,9 @@ function cardAria(c: HandCardView, myTurn: boolean): string {
|
||||
function previewOf(c: HandCardView): { text: string; cls: string } {
|
||||
if (c.joker) return { text: `${c.joker.min}~${c.joker.max}`, cls: 'ok' };
|
||||
if (c.badge) return { text: c.badge, cls: 'no' };
|
||||
const ok = c.choices.filter((x) => x.legal);
|
||||
if (ok.length === 1) return { text: `→ ${ok[0]!.total}`, cls: 'ok' };
|
||||
return { text: ok.map((x) => `${x.total}`).join(' / '), cls: 'ok' };
|
||||
const totals = [...new Set(c.choices.filter((x) => x.legal).map((x) => x.total))];
|
||||
if (totals.length === 1) return { text: `→ ${totals[0]}`, cls: 'ok' };
|
||||
return { text: totals.join(' / '), cls: 'ok' };
|
||||
}
|
||||
|
||||
function JokerDial({ c, total, pending, onPlay }: { c: HandCardView; total: number; pending: boolean; onPlay: (n: number) => void }) {
|
||||
@@ -478,8 +482,8 @@ function MyHand({ v, act, pending }: { v: NinetyNineView; act: (a: unknown) => v
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={!x.legal || pending}
|
||||
aria-label={x.legal ? `${x.label}로 내기, 합계 ${x.total}` : `${x.label}: ${x.reason ?? '낼 수 없어요'}`}
|
||||
onClick={() => send(chosen, { ...(x.sign ? { sign: x.sign } : {}), ...(x.aceValue !== undefined ? { aceValue: x.aceValue } : {}) })}
|
||||
aria-label={x.legal ? `${x.dir ? `${x.label}로 두고` : `${x.label}로`} 내기, 합계 ${x.total}` : `${x.label}: ${x.reason ?? '낼 수 없어요'}`}
|
||||
onClick={() => send(chosen, { ...(x.sign ? { sign: x.sign } : {}), ...(x.aceValue !== undefined ? { aceValue: x.aceValue } : {}), ...(x.dir ? { dir: x.dir } : {}) })}
|
||||
>
|
||||
{single(chosen) ? `내기 → ${x.total}` : arrow(x)}
|
||||
</button>
|
||||
@@ -499,7 +503,16 @@ function logText(e: LogEntry, players: Record<string, PublicUser>): string {
|
||||
const n = (id: string) => nick(players, id);
|
||||
switch (e.t) {
|
||||
case 'play': {
|
||||
const how = e.kind === 'joker' ? `${e.total}로 만들었어요` : e.kind === 'reverse' ? `방향을 바꿨어요(${e.total} 그대로)` : e.kind === 'hold' ? `그대로 넘겼어요(${e.total})` : `${e.from} → ${e.total}`;
|
||||
const how =
|
||||
e.kind === 'joker'
|
||||
? `${e.total}로 만들었어요`
|
||||
: e.kind === 'reverse'
|
||||
? `방향을 바꿨어요(${e.total} 그대로)`
|
||||
: e.kind === 'zero'
|
||||
? `합계 ${e.total} 그대로`
|
||||
: e.kind === 'hold'
|
||||
? `그대로 넘겼어요(${e.total})`
|
||||
: `${e.from} → ${e.total}`;
|
||||
return `${n(e.p)}: ${faceName(e.face)}${e.choice && e.kind !== 'joker' ? `(${e.choice})` : ''} ${how}${e.auto ? ' · 자동' : ''}`;
|
||||
}
|
||||
case 'noCard':
|
||||
@@ -509,7 +522,7 @@ function logText(e: LogEntry, players: Record<string, PublicUser>): string {
|
||||
case 'out':
|
||||
return e.why === 'left' ? `${n(e.p)}님이 나가서 탈락했어요` : `${n(e.p)}님이 탈락했어요`;
|
||||
case 'round':
|
||||
return `${e.round}판 시작 · ${n(e.starter)}님부터, 합계 0`;
|
||||
return `${e.round}판 시작 · ${n(e.starter)}님부터, 합계 ${e.total ?? 0}`;
|
||||
case 'reshuffle':
|
||||
return `버린 카드 ${e.n}장을 섞어 새 더미를 만들었어요`;
|
||||
case 'timeout':
|
||||
@@ -565,8 +578,13 @@ function Board({ view: v, me, players, act, pending, deadline }: BoardProps<Nine
|
||||
|
||||
function rulesLine(o: NNOptions): string {
|
||||
const lives = resolvedLives(o);
|
||||
const parts = [`손패 ${o.handSize}장`, o.refill ? '내고 1장 보충' : '보충 없음', lives > 0 ? `목숨 ${lives}개${o.onTheBoard ? '(+마지막 기회)' : ''}` : '목숨 없이 바로 탈락'];
|
||||
if (lives === 0) parts.push(o.afterElimination === 'reset' ? '탈락 뒤 0부터' : '탈락 뒤 합계 그대로');
|
||||
const parts = [
|
||||
`손패 ${resolvedHandSize(o)}장`,
|
||||
startCard(o) ? '시작 숫자 2명 40 · 3명 20 · 4명 이상 0' : '0부터 시작',
|
||||
o.refill ? '내고 1장 보충' : '보충 없음',
|
||||
lives > 0 ? `목숨 ${lives}개${o.onTheBoard ? '(+마지막 기회)' : ''}` : '목숨 없이 바로 탈락',
|
||||
];
|
||||
if (lives === 0) parts.push(o.afterElimination === 'reset' ? `탈락 뒤 ${startWord(o)}부터` : '탈락 뒤 합계 그대로');
|
||||
parts.push(o.floorAtZero === 'forbid' ? '0 미만 빼기 안 됨' : '0 미만은 0');
|
||||
if (o.mode === 'kr-product') parts.push(`조커 ${o.jokerRange === '0-99' ? '0~99' : '60~99'}`);
|
||||
else parts.push(`A는 1 또는 ${o.aceHigh}`);
|
||||
@@ -694,9 +712,14 @@ function Options({ value: o, onChange }: OptionsProps<NNOptions>) {
|
||||
<Seg label="규칙" value={o.mode} options={[['kr-product', '전용 카드'], ['classic', '트럼프 카드']]} onChange={(mode) => set({ mode })} />
|
||||
</Field>
|
||||
<Field label="손패 장수">
|
||||
<Seg label="손패 장수" value={o.handSize} options={[[3, '3장'], [4, '4장'], [5, '5장']]} onChange={(handSize) => set({ handSize })} />
|
||||
<Seg label="손패 장수" value={o.handSize} options={[['auto', kr ? '5장(기본)' : '3장(기본)'], [3, '3장'], [4, '4장'], [5, '5장']]} onChange={(handSize) => set({ handSize })} />
|
||||
</Field>
|
||||
<Field label="목숨" help={lives > 0 ? '낼 카드가 없으면 목숨 1개를 잃고 0부터 새 판을 해요.' : '낼 카드가 없으면 바로 탈락해요.'}>
|
||||
{kr && (
|
||||
<Field label="시작 숫자" help={o.startTotal === 'auto' ? '가운데 시작 카드처럼 2명이면 40, 3명이면 20, 4명 이상이면 0에서 시작해요.' : '인원과 상관없이 0에서 시작해요.'}>
|
||||
<Seg label="시작 숫자" value={o.startTotal} options={[['auto', '시작 카드(기본)'], ['zero', '항상 0']]} onChange={(startTotal) => set({ startTotal })} />
|
||||
</Field>
|
||||
)}
|
||||
<Field label="목숨" help={lives > 0 ? `낼 카드가 없으면 목숨 1개를 잃고 ${startWord(o)}부터 새 판을 해요.` : '낼 카드가 없으면 바로 탈락해요.'}>
|
||||
<Seg
|
||||
label="목숨"
|
||||
value={o.lives}
|
||||
@@ -708,8 +731,8 @@ function Options({ value: o, onChange }: OptionsProps<NNOptions>) {
|
||||
<Seg label="한 턴 제한 시간" value={o.turnSeconds} options={[[10, '10초'], [15, '15초'], [20, '20초'], [30, '30초']]} onChange={(turnSeconds) => set({ turnSeconds })} />
|
||||
</Field>
|
||||
{kr && (
|
||||
<button type="button" className="btn btn-secondary" onClick={() => set({ jokerRange: '0-99', handSize: 4 })}>
|
||||
어린이 추천 설정(조커 0~99, 손패 4장)
|
||||
<button type="button" className="btn btn-secondary" onClick={() => set({ jokerRange: '0-99', handSize: 'auto' })}>
|
||||
어린이 추천 설정(조커 0~99, 손패 5장)
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className="btn btn-ghost" onClick={() => setMore(!more)} aria-expanded={more}>
|
||||
@@ -724,7 +747,7 @@ function Options({ value: o, onChange }: OptionsProps<NNOptions>) {
|
||||
<Seg label="목숨을 다 잃어도 한 번 더" value={o.onTheBoard} options={[[false, '끄기'], [true, '켜기']]} onChange={(onTheBoard) => set({ onTheBoard })} />
|
||||
</Field>
|
||||
<Field label="(목숨 없음) 탈락 뒤 합계">
|
||||
<Seg label="탈락 뒤 합계" value={o.afterElimination} options={[['continue', '그대로 이어 가기'], ['reset', '0으로 하고 다시 나누기']]} onChange={(afterElimination) => set({ afterElimination })} />
|
||||
<Seg label="탈락 뒤 합계" value={o.afterElimination} options={[['continue', '그대로 이어 가기'], ['reset', `${startWord(o)}${startCard(o) ? '로' : '으로'} 하고 다시 나누기`]]} onChange={(afterElimination) => set({ afterElimination })} />
|
||||
</Field>
|
||||
<Field label="빼서 0보다 작아질 때">
|
||||
<Seg label="빼서 0보다 작아질 때" value={o.floorAtZero} options={[['forbid', '못 빼요'], ['clamp', '0으로 맞춰요']]} onChange={(floorAtZero) => set({ floorAtZero })} />
|
||||
@@ -756,7 +779,8 @@ function Options({ value: o, onChange }: OptionsProps<NNOptions>) {
|
||||
|
||||
function optionsSummary(o: NNOptions): string {
|
||||
const lives = resolvedLives(o);
|
||||
const parts = [MODE_KO[o.mode], `손패 ${o.handSize}장`, lives > 0 ? `목숨 ${lives}개` : '목숨 없음'];
|
||||
const parts = [MODE_KO[o.mode], `손패 ${resolvedHandSize(o)}장`, lives > 0 ? `목숨 ${lives}개` : '목숨 없음'];
|
||||
if (o.mode === 'kr-product' && o.startTotal === 'zero') parts.push('0부터 시작');
|
||||
if (!o.refill) parts.push('보충 없음');
|
||||
if (o.mode === 'kr-product' && o.jokerRange === '0-99') parts.push('조커 0~99');
|
||||
parts.push(`${o.turnSeconds}초`);
|
||||
@@ -774,7 +798,7 @@ function SpecialTable({ o }: { o: NNOptions }) {
|
||||
[C('7', 'add'), '1~8, 10', '그 숫자만큼 더해요. 9 숫자 카드는 없어요.'],
|
||||
[C('±9', 'plusMinus'), '±9', `9를 더하거나 빼요. ${floor}`],
|
||||
[C('±10', 'plusMinus'), '±10', `10을 더하거나 빼요. ${floor}`],
|
||||
[C('0', 'reverse'), '0', '합계는 그대로, 진행 방향이 반대로 바뀌어요.'],
|
||||
[C('0', 'zero'), '0', '합계는 그대로예요. 진행 방향을 그대로 둘지 반대로 바꿀지 골라요.'],
|
||||
[C('조커', 'joker'), '조커', `앞 수와 상관없이 ${o.jokerRange === '0-99' ? '0~99' : '60~99'} 중 원하는 수로 만들어요.`],
|
||||
]
|
||||
: [
|
||||
@@ -805,7 +829,8 @@ function SpecialTable({ o }: { o: NNOptions }) {
|
||||
function Rules({ options }: { options?: NNOptions }) {
|
||||
const o: NNOptions = {
|
||||
mode: 'kr-product',
|
||||
handSize: 3,
|
||||
handSize: 'auto',
|
||||
startTotal: 'auto',
|
||||
refill: true,
|
||||
lives: 'auto',
|
||||
onTheBoard: false,
|
||||
@@ -819,26 +844,28 @@ function Rules({ options }: { options?: NNOptions }) {
|
||||
...options,
|
||||
};
|
||||
const lives = resolvedLives(o);
|
||||
const start = startWord(o);
|
||||
return (
|
||||
<div className="stack">
|
||||
<p>
|
||||
<strong>99를 넘기면 안 돼요!</strong> 차례대로 카드를 1장씩 내며 숫자를 더해 가요. 낸 카드만큼 합계가 바뀌고, 합계를 99보다 크게 만드는 카드는 낼 수 없어요.
|
||||
</p>
|
||||
<p>
|
||||
처음에 {o.handSize}장씩 받고, {o.refill ? '카드를 내면 1장을 다시 가져와요.' : '카드를 내도 다시 가져오지 않아요.'} 낼 수 있는 카드가 있으면 꼭 1장을 내야 해요(넘기기 없음).
|
||||
처음에 {resolvedHandSize(o)}장씩 받고, {o.refill ? '카드를 내면 1장을 다시 가져와요.' : '카드를 내도 다시 가져오지 않아요.'} 낼 수 있는 카드가 있으면 꼭 1장을 내야 해요(넘기기 없음).{' '}
|
||||
{startCard(o) ? '합계는 가운데 시작 카드의 숫자에서 시작해요. 2명이면 40, 3명이면 20, 4명 이상이면 0이에요.' : '합계는 0에서 시작해요.'}
|
||||
</p>
|
||||
<p>
|
||||
<strong>낼 카드가 없으면</strong>:{' '}
|
||||
{lives > 0
|
||||
? `목숨 1개를 잃고, 0부터 새 판을 시작해요. 목숨 ${lives}개를 다 잃으면 ${o.onTheBoard ? '한 번 더 질 때' : '바로'} 탈락해요.`
|
||||
? `목숨 1개를 잃고, ${start}부터 새 판을 시작해요. 목숨 ${lives}개를 다 잃으면 ${o.onTheBoard ? '한 번 더 질 때' : '바로'} 탈락해요.`
|
||||
: o.afterElimination === 'reset'
|
||||
? '바로 탈락하고, 합계를 0으로 해서 카드를 다시 나눠요.'
|
||||
? `바로 탈락하고, 합계를 ${start}${startCard(o) ? '로' : '으로'} 해서 카드를 다시 나눠요.`
|
||||
: '바로 탈락하고, 같은 합계로 다음 사람이 이어 가요.'}{' '}
|
||||
마지막까지 남은 1명이 이겨요.
|
||||
</p>
|
||||
<SpecialTable o={o} />
|
||||
<p className="small muted">
|
||||
시간 안에 내지 않으면 합계가 가장 작아지는 카드를 자동으로 내요. 어린이와 할 때는 조커 0~99, 손패 4장을 추천해요. 목숨(하트)은 이 판에서만 쓰는 표시예요.
|
||||
시간 안에 내지 않으면 합계가 가장 작아지는 카드를 자동으로 내요. 어린이와 할 때는 조커 0~99를 추천해요. 목숨(하트)은 이 판에서만 쓰는 표시예요.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -67,6 +67,6 @@ export const quickStart: QuickCard[] = [
|
||||
),
|
||||
alt: '특수 카드 ±9, ±10, 0, 조커',
|
||||
title: '특수 카드로 버텨요',
|
||||
text: '±9·±10은 빼기도 돼요. 0은 방향만 바꾸고, 조커는 합계를 원하는 수(보통 60~99)로 만들어요. 끝까지 남으면 이겨요.',
|
||||
text: '±9·±10은 빼기도 돼요. 0은 합계를 그대로 두고 방향을 바꿀지 고르고, 조커는 합계를 원하는 수(보통 60~99)로 만들어요. 끝까지 남으면 이겨요.',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -12,30 +12,31 @@
|
||||
- 나무위키 "99(플레잉 카드)"도 2~10명이다.
|
||||
- 그래서 2~10명으로 한다.
|
||||
|
||||
### 확인된 것과 확인되지 않은 것 (오너 확인 필요)
|
||||
- 확인됨(코리아보드게임즈 매거진 기사, 공식 상품 페이지)
|
||||
### 확인된 것과 확인되지 않은 것
|
||||
- 확인됨(코리아보드게임즈 공식 상품 페이지의 상세 이미지에 실린 규칙, 매거진 기사)
|
||||
- 1~8 카드 각 7장, 10 카드 21장.
|
||||
- 특수 카드 ±9, ±10, 0, 조커(정확히 2장).
|
||||
- 자기 차례에 1장을 내고 이전 수에 더한 새 수를 말한다.
|
||||
- 99를 넘게 만드는 카드는 낼 수 없고, 낼 카드가 1장도 없으면 탈락한다. 혼자 남으면 승리한다.
|
||||
- ±9·±10은 더하거나 뺄 수 있다. 0은 수 변화 없이 진행 방향을 바꾼다. 조커는 앞 수와 상관없이 60~99 중 원하는 수를 부른다.
|
||||
- 확인되지 않음(공식 규칙서 원문을 구하지 못함. 아래는 이 문서의 기본값이고 모두 옵션)
|
||||
- 준비: 가운데에 시작 카드를 놓는다. 시작 카드에는 인원별 시작 숫자가 있다. 2명 40, 3명 20, 4명 이상 0. 카드를 섞어 모두에게 5장씩 나눈다.
|
||||
- 이미지에서 시작 카드 글씨는 작지만, 4명 예시는 분명히 0에서 시작한다.
|
||||
- 자기 차례에 1장을 내고 이전 수에 더한 새 수를 말한 뒤, 더미에서 1장을 가져온다.
|
||||
- 99를 넘게 만드는 카드는 낼 수 없다. 낼 카드가 1장도 없으면 탈락하고, 합계는 그대로 다음 사람에게 넘어가 게임이 이어진다. 혼자 남으면 승리한다.
|
||||
- ±9·±10은 더하거나 뺄 수 있다. 조커는 앞 수와 상관없이 60~99 중 원하는 수를 부른다.
|
||||
- 0은 수 변화 없이 지금 합계를 그대로 말한다. 0을 낼 때 진행 방향을 그대로 둘지 반대로 바꿀지 낸 사람이 고른다.
|
||||
- 공식 규칙에는 칩이나 목숨이 없다. `lives` 옵션은 전통 99의 토큰 3개 규칙을 빌려 온 하우스 룰이다(`kr-product` 기본값은 목숨 없음).
|
||||
- 확인되지 않음(이 문서의 기본값이고 모두 옵션)
|
||||
- ±9, ±10, 0 카드의 장수. 기본값은 각 4장(추정)이다.
|
||||
- 시작 손패 장수. 기본값은 3장(전통 99와 같음)이다.
|
||||
- 카드를 낸 뒤 1장 보충 여부. 기본값은 보충한다(전통 99와 같음).
|
||||
- 빼기로 0 미만이 될 때 처리. 기본값은 0 미만이 되는 빼기는 금지다.
|
||||
- 탈락자가 생긴 뒤 합계를 이어 갈지 초기화할지. 기본값은 이어 간다.
|
||||
- 칩(목숨) 변형이 공식 규칙서에 있는지. 일부 소개 글에 "칩 3개, 99를 넘기면 1개 잃음" 변형이 언급되지만 원문으로 확인하지 못했다. 전통 99의 토큰 3개 규칙과 같은 구조라 `lives` 옵션으로 둔다.
|
||||
|
||||
## 2. 모드와 옵션
|
||||
| 옵션 키 | 설명 | 선택지 | 기본값 |
|
||||
|---|---|---|---|
|
||||
| `mode` | 규칙 | `kr-product` / `classic` | `kr-product` |
|
||||
| `handSize` | 손패 장수 | 3 / 4 / 5 | 3 |
|
||||
| `handSize` | 손패 장수 | `auto` / 3 / 4 / 5 | `auto`(`kr-product` 5, `classic` 3) |
|
||||
| `startTotal` | (`kr-product`) 시작 숫자 | `auto`(시작 카드: 2명 40, 3명 20, 4명 이상 0) / `zero`(항상 0) | `auto` |
|
||||
| `refill` | 카드를 낸 뒤 1장 보충 | on / off | on |
|
||||
| `lives` | 목숨(토큰) 수. 0이면 목숨 없이 바로 탈락 | 0 / 1 / 2 / 3 / 5 | `kr-product` 0, `classic` 3 |
|
||||
| `lives` | 목숨(토큰) 수. 0이면 목숨 없이 바로 탈락. `kr-product`에서는 하우스 룰 | `auto` / 0 / 1 / 2 / 3 / 5 | `auto`(`kr-product` 0, `classic` 3) |
|
||||
| `onTheBoard` | 목숨을 다 잃은 뒤에도 한 번 더 질 때까지 남음(전통 변형) | on / off | off |
|
||||
| `afterElimination` | (`lives=0`) 탈락 뒤 합계 처리 | `continue`(그대로 이어 감) / `reset`(0으로, 다시 나눔) | `continue` |
|
||||
| `afterElimination` | (`lives=0`) 탈락 뒤 합계 처리 | `continue`(그대로 이어 감, 공식) / `reset`(시작 숫자로, 다시 나눔) | `continue` |
|
||||
| `floorAtZero` | 빼기 결과가 0 미만일 때 | `forbid`(그런 빼기는 못 냄) / `clamp`(0으로 맞춤) | `forbid` |
|
||||
| `specialCounts` | (`kr-product`) ±9, ±10, 0 각 장수 | 2 / 3 / 4 / 5 / 6 | 4 |
|
||||
| `jokerRange` | (`kr-product`) 조커로 부를 수 있는 범위 | `60-99`(원작) / `0-99` | `60-99` |
|
||||
@@ -52,10 +53,11 @@
|
||||
| 10 | 10 더함 | 21장 |
|
||||
| ±9 | 9를 더하거나 뺌(낸 사람이 선택) | `specialCounts`(기본 4, 추정) |
|
||||
| ±10 | 10을 더하거나 뺌 | `specialCounts`(기본 4, 추정) |
|
||||
| 0 | 수 변화 없음, 진행 방향 반대 | `specialCounts`(기본 4, 추정) |
|
||||
| 0 | 수 변화 없음. 진행 방향을 그대로 둘지 반대로 바꿀지 낸 사람이 고름 | `specialCounts`(기본 4, 추정) |
|
||||
| 조커 | 앞 수와 상관없이 60~99 중 원하는 수로 만듦 | 2장 |
|
||||
|
||||
- 9 숫자 카드는 없다(±9만 있음).
|
||||
- 시작 카드 1장(인원별 시작 숫자 표시). 화면에서는 판 가운데 합계로 보여 주므로 카드로 만들지 않는다.
|
||||
- 목숨을 쓰면(`lives > 0`) 플레이어마다 목숨 토큰을 그만큼 준다. 방 안에서만 쓰는 표시용이다.
|
||||
|
||||
`classic` (트럼프 52장, Bicycle 규칙 기준)
|
||||
@@ -75,8 +77,10 @@
|
||||
## 4. 준비(셋업)
|
||||
1. 모드에 맞는 덱을 만들고 시드 RNG로 섞는다.
|
||||
2. 선 플레이어를 RNG로 정한다. 진행은 시계 방향이다.
|
||||
3. 각자 `handSize`장을 받는다. 남은 카드는 뽑을 더미가 된다.
|
||||
4. 합계는 0에서 시작한다.
|
||||
3. 각자 `handSize`장을 받는다(`auto`면 `kr-product` 5장, `classic` 3장). 남은 카드는 뽑을 더미가 된다.
|
||||
4. 합계는 시작 숫자에서 시작한다.
|
||||
- `kr-product`이고 `startTotal = auto`면 시작 카드대로 2명 40, 3명 20, 4명 이상 0.
|
||||
- `startTotal = zero`이거나 `classic`이면 항상 0.
|
||||
5. 목숨을 쓰면 각자 `lives`개를 받는다.
|
||||
|
||||
## 5. 진행 규칙
|
||||
@@ -85,10 +89,10 @@
|
||||
1. **탈락 판정(턴 시작 때 서버 자동)**: 현재 플레이어가 낼 수 있는 카드가 하나도 없으면 바로 "패배 처리"(5.3)를 한다. 패배 처리 결과 탈락하지 않은 경우(목숨이 남음)에는 새 라운드가 시작된다.
|
||||
2. **카드 내기**: 낼 수 있는 카드 1장을 낸다. ±카드는 더하기/빼기를, A는 1/11을, 조커는 숫자를 함께 고른다. 새 합계가 공개된다.
|
||||
3. **보충**(`refill = on`): 뽑을 더미에서 1장을 가져온다. 더미가 비었으면 버린 카드를 모두 섞어 새 더미를 만든다(합계는 그대로). 그래도 없으면 보충하지 않는다.
|
||||
4. 다음 차례로 넘긴다. 0(또는 `classic`의 4)이면 방향을 반대로 바꾼 뒤 넘긴다.
|
||||
4. 다음 차례로 넘긴다. `classic`의 4이면 방향을 반대로 바꾼 뒤 넘긴다. `kr-product`의 0이면 낸 사람이 고른 대로 방향을 그대로 두거나 반대로 바꾼 뒤 넘긴다.
|
||||
|
||||
- 패스는 없다. 낼 수 있는 카드가 있으면 반드시 1장을 내야 한다.
|
||||
- 2인일 때 방향 반대 카드는 방향만 바뀌고 다음 차례는 여전히 상대다.
|
||||
- 2인일 때 방향을 바꿔도 다음 차례는 여전히 상대다.
|
||||
|
||||
### 5.2 낼 수 있는 카드(합법 조건)
|
||||
합계를 S, 카드를 낸 뒤 합계를 S'라 할 때 S' ≤ 99여야 한다.
|
||||
@@ -99,7 +103,7 @@
|
||||
- 더하기면 S' = S + 값.
|
||||
- 빼기면 S' = S - 값. `floorAtZero = forbid`이면 S' ≥ 0이어야 하고, `clamp`이면 S' = max(0, S - 값).
|
||||
- 두 방향 중 하나라도 합법이면 낼 수 있다.
|
||||
- 0: S' = S. 항상 낼 수 있다.
|
||||
- 0: S' = S. 항상 낼 수 있다. 방향(`keep` 그대로 / `reverse` 반대)을 함께 고른다.
|
||||
- 조커: S'를 `jokerRange`(60~99) 안의 아무 정수로 고른다. 항상 낼 수 있다. 현재 합계와 같은 수를 골라도 된다.
|
||||
|
||||
`classic`
|
||||
@@ -113,17 +117,17 @@
|
||||
- `lives = 0`(`kr-product` 기본)
|
||||
- 그 플레이어는 탈락한다. 손패는 버린 더미로 간다.
|
||||
- `afterElimination = continue`면 같은 합계로 다음 사람 차례가 된다(다음 사람도 낼 카드가 없으면 연달아 탈락할 수 있다).
|
||||
- `reset`이면 합계를 0으로 하고 남은 사람 손패를 모두 거둬 다시 섞어 나눈다. 탈락자의 다음 사람이 시작한다.
|
||||
- `reset`이면 합계를 시작 숫자(남은 인원 기준, 4절)로 하고 남은 사람 손패를 모두 거둬 다시 섞어 나눈다. 탈락자의 다음 사람이 시작한다.
|
||||
- `lives > 0`
|
||||
- 그 플레이어가 목숨 1개를 잃는다. 이번 라운드는 끝난다.
|
||||
- 목숨이 0이 되면 탈락한다. `onTheBoard = on`이면 목숨 0인 상태로 계속 하고, 그 상태에서 한 번 더 지면 탈락한다.
|
||||
- 새 라운드: 모든 카드를 거둬 섞고, 남은 사람에게 다시 나누고, 합계 0에서 시작한다. 진 사람이 탈락하지 않았으면 진 사람이 먼저 하고, 탈락했으면 그 다음 사람이 먼저 한다.
|
||||
- 새 라운드: 모든 카드를 거둬 섞고, 남은 사람에게 다시 나누고, 시작 숫자(남은 인원 기준, 4절)에서 시작한다. 진 사람이 탈락하지 않았으면 진 사람이 먼저 하고, 탈락했으면 그 다음 사람이 먼저 한다.
|
||||
- 남은 사람이 1명이면 게임이 끝난다.
|
||||
|
||||
### 5.4 예시 (`kr-product`, 3인)
|
||||
### 5.4 예시 (`kr-product`, 3인, 시작 숫자 20에서 진행된 판의 중간. 손패는 짧게 3장만 적음)
|
||||
- 합계 85에서 A가 10을 내 95가 된다. A가 보충한다.
|
||||
- B의 손패는 [8, 7, ±9]이다. 8과 7은 99를 넘는다. ±9를 빼기로 내서 86이 된다.
|
||||
- C의 손패는 [10, 10, 0]이다. 0을 내서 86 그대로이고 방향이 반대가 되어 B 차례가 된다.
|
||||
- C의 손패는 [10, 10, 0]이다. 0을 내며 방향을 바꾸기로 고른다. 86 그대로이고 방향이 반대가 되어 B 차례가 된다.
|
||||
- B의 손패는 [8, 7, 6](보충한 6)이다. 8 → 94, 7 → 93, 6 → 92 모두 가능하다. B가 7을 내서 93.
|
||||
- 방향이 바뀌었으므로 다음은 A다. A의 손패가 [8, 10, 7]이면 101, 103, 100이라 낼 카드가 없다. A는 턴 시작 때 탈락하고 C 차례가 된다.
|
||||
|
||||
@@ -134,8 +138,9 @@
|
||||
- 점수, 칩, 돈은 없다. 목숨 토큰은 이 판에서만 쓰는 표시이고 방 밖으로 이어지지 않는다.
|
||||
|
||||
## 7. 지역 룰 / 하우스 룰 (옵션으로 켜고 끄기)
|
||||
- 목숨(칩) 변형(`lives`, `onTheBoard`): 전통 99의 토큰 3개 규칙이다. 구십구 소개 글 일부에서도 "칩 3개" 변형이 언급된다(원문 미확인).
|
||||
- 목숨(칩) 변형(`lives`, `onTheBoard`): 전통 99의 토큰 3개 규칙이다. 구십구 공식 규칙에는 없다.
|
||||
- 손패 장수(`handSize`), 보충 여부(`refill`).
|
||||
- 시작 숫자를 항상 0으로(`startTotal = zero`).
|
||||
- 탈락 뒤 합계 처리(`afterElimination`).
|
||||
- 0 미만 빼기 처리(`floorAtZero`).
|
||||
- 조커 범위 확장(`jokerRange`, 어린이용 0~99).
|
||||
@@ -151,7 +156,8 @@
|
||||
type NNCard =
|
||||
| { id: string; kind: 'add'; v: number } // kr: 1..8, 10 / classic: 2,3,5,6,7,8, J/Q(10)
|
||||
| { id: string; kind: 'plusMinus'; v: 9 | 10 } // kr ±9, ±10
|
||||
| { id: string; kind: 'reverse' } // kr 0 / classic 4
|
||||
| { id: string; kind: 'reverse' } // classic 4 (항상 방향 반대)
|
||||
| { id: string; kind: 'zero' } // kr 0 (방향은 낸 사람이 고름)
|
||||
| { id: string; kind: 'hold' } // classic 9
|
||||
| { id: string; kind: 'minus'; v: 10 } // classic 10
|
||||
| { id: string; kind: 'ace' } // classic A (1 또는 aceHigh)
|
||||
@@ -184,13 +190,14 @@ interface NinetyNineState {
|
||||
### 8.2 액션
|
||||
| type | payload | 누가 / 언제 | 검증 조건 |
|
||||
|---|---|---|---|
|
||||
| `play` | `{ cardId: string; sign?: '+' \| '-'; aceValue?: 1 \| 10 \| 11; jokerValue?: number }` | `current`, playing | 카드가 손에 있음. ±카드는 `sign` 필수, A는 `aceValue` 필수(1 또는 `aceHigh`), 조커는 `jokerValue` 필수이고 `jokerRange` 안의 정수. 5.2 조건으로 계산한 S' ≤ 99(그리고 `forbid`면 S' ≥ 0) |
|
||||
| `play` | `{ cardId: string; sign?: '+' \| '-'; aceValue?: 1 \| 10 \| 11; jokerValue?: number; dir?: 'keep' \| 'reverse' }` | `current`, playing | 카드가 손에 있음. ±카드는 `sign` 필수, A는 `aceValue` 필수(1 또는 `aceHigh`), 조커는 `jokerValue` 필수이고 `jokerRange` 안의 정수, `kr-product`의 0은 `dir` 필수(다른 카드에 `dir`이 있으면 거부). 5.2 조건으로 계산한 S' ≤ 99(그리고 `forbid`면 S' ≥ 0) |
|
||||
|
||||
- 플레이어가 보내는 액션은 `play` 하나뿐이다. 탈락, 목숨 감소, 라운드 재시작, 보충은 `apply` 안에서 자동으로 처리한다.
|
||||
- `play`를 적용한 직후 다음 플레이어를 정한다. 그 플레이어에게 합법 수가 없으면 같은 `apply` 안에서 패배 처리를 하고, 다시 다음 사람을 검사한다(반복). 이 과정을 이벤트로 순서대로 남긴다.
|
||||
- 이렇게 하면 "낼 카드가 없는 사람이 타이머를 기다리는" 상황이 없다.
|
||||
- 이벤트 예: `cardPlayed{player, face, total}`, `directionChanged`, `cardDrawn{player}`, `reshuffled`, `lifeLost{player, left}`, `eliminated{player}`, `roundStarted{round, starter}`.
|
||||
- `reason` 예: "99를 넘어서 낼 수 없어요", "0보다 작아져서 뺄 수 없어요", "더하기와 빼기 중 하나를 골라 주세요", "조커는 60부터 99 사이 숫자만 고를 수 있어요".
|
||||
- 이벤트 예: `cardPlayed{player, face, total, choice?}`(0은 choice가 "방향 그대로"/"방향 바꾸기"), `directionChanged`, `cardDrawn{player}`, `reshuffled`, `lifeLost{player, left}`, `eliminated{player}`, `roundStarted{round, starter, total}`.
|
||||
- `reason` 예: "99를 넘어서 낼 수 없어요", "0보다 작아져서 뺄 수 없어요", "더하기와 빼기 중 하나를 골라 주세요", "조커는 60부터 99 사이 숫자만 고를 수 있어요", "0 카드는 방향을 그대로 둘지 바꿀지 골라 주세요", "방향은 0 카드를 낼 때만 고를 수 있어요".
|
||||
- `stateVersion` 2: `handSize = auto`, `startTotal`, `zero` 카드가 생겼다. 진행 중이던 v1 게임은 시작할 때 규칙대로 이어 간다(나눈 손패 장수 그대로, 새 라운드도 0부터, 그 게임의 0 카드는 늘 방향 반대). v1로 끝난 게임은 다시 보기를 할 수 없다.
|
||||
|
||||
### 8.3 공개/비공개 정보 (view)
|
||||
- 모두(관전자 포함): 현재 합계, 방향, 현재 차례, 각자 손패 장수, 목숨, 생존 여부, 탈락 순서, 뽑을 더미 장수, 최근에 낸 카드 몇 장과 그때의 합계 기록.
|
||||
@@ -209,6 +216,7 @@ interface NinetyNineState {
|
||||
- `deadline = turnStartedAt + turnSeconds*1000`.
|
||||
- `onTimeout(state, p)`: 합법인 (카드, 선택값) 조합 중 결과 합계 S'가 가장 작은 것을 고른다.
|
||||
- 같으면 우선순위: 0/4/9(변화 없음) → 일반 숫자 카드 → ±카드 → K/조커. 그래도 같으면 카드 ID 순.
|
||||
- `kr-product`의 0은 방향을 그대로 두는 쪽으로 낸다.
|
||||
- 조커는 `jokerRange` 최소값(60)으로 낸다.
|
||||
- 이 규칙은 본인 손패와 공개 합계만 쓰므로 정보 유출이 없고 결정적이다.
|
||||
- 합법 수가 없는 경우는 타이머까지 가지 않고 `apply`에서 이미 처리된다(8.2).
|
||||
@@ -228,28 +236,31 @@ interface GameResult {
|
||||
- 모바일 세로 화면
|
||||
- 가운데: 현재 합계를 아주 크게(예: 72pt) 보여 준다. 99에 가까울수록 색이 초록 → 노랑 → 빨강으로 바뀌는 게이지 링을 두른다.
|
||||
- 위: 상대 아바타(손패 장수, 목숨 하트, 차례 표시), 진행 방향 화살표.
|
||||
- 아래: 내 손패 3장을 큰 카드로 보여 준다(최소 72×100px).
|
||||
- 아래: 내 손패(기본 5장)를 큰 카드로 보여 준다(최소 62×87px).
|
||||
- 조작
|
||||
- 카드를 탭하면 "내면 → 81"처럼 결과 합계 미리보기가 카드 위에 뜬다. 한 번 더 탭하면 낸다.
|
||||
- ±카드를 탭하면 [+9 → 95] [−9 → 77] 두 버튼이 뜬다. 불가능한 쪽은 비활성이다.
|
||||
- 0을 탭하면 [방향 그대로 → 86] [방향 바꾸기 → 86] 두 버튼이 뜬다.
|
||||
- 조커를 탭하면 60~99 숫자 다이얼(+/− 버튼과 빠른 선택 60/90/99)이 뜬다.
|
||||
- 낼 수 없는 카드는 흐리게 하고 "99 초과" 배지를 붙인다.
|
||||
- 애니메이션: 합계 숫자가 올라가는 카운트 애니메이션, 방향 전환 화살표 회전, 탈락 때 아바타 흑백 처리, 목숨 하트가 깨지는 연출.
|
||||
- 초보 도움말: "99를 넘기면 안 돼요!" 한 줄 규칙과 특수 카드 표. 어린이 방 추천 설정(조커 0~99, 손패 4장).
|
||||
- 초보 도움말: "99를 넘기면 안 돼요!" 한 줄 규칙과 특수 카드 표. 어린이 방 추천 설정(조커 0~99, 손패 5장).
|
||||
- 실시간 감각: 턴이 빨라서 타이머 링을 크게 보여 주고, 내 차례가 되면 진동(모바일 지원 시)으로 알린다.
|
||||
|
||||
## 10. 테스트 체크리스트
|
||||
- [ ] 덱 구성: `kr-product` 기본값 91장(1~8 각 7, 10이 21, ±9/±10/0 각 4, 조커 2). `specialCounts=2`면 85장. `classic` 52장, 7인 이상 `auto`면 104장.
|
||||
- [ ] 같은 시드면 같은 분배, 같은 선이 나온다.
|
||||
- [ ] 손패 `auto`: `kr-product` 5장, `classic` 3장. 3/4/5를 고르면 그 장수.
|
||||
- [ ] 시작 숫자: `kr-product` 2명 40, 3명 20, 4명 이상 0. `startTotal = zero`와 `classic`은 항상 0. 목숨 모드 새 라운드와 `reset`도 남은 인원 기준 시작 숫자로 시작한다.
|
||||
- [ ] 합계 95에서 손패 [8, 5, 4] (`kr-product`)이면 4만 합법이다. 4를 내서 99가 된다. 다음 사람이 [1, 2, 3]만 있으면 턴 시작 때 바로 탈락한다.
|
||||
- [ ] ±10: 합계 5에서 빼기는 `forbid`면 거부된다. `clamp`면 0이 된다. 더하기는 15로 허용된다.
|
||||
- [ ] 조커: 합계 20에서 조커로 99를 고르면 99가 된다. 59는 거부되고(`60-99`), 100도 거부된다.
|
||||
- [ ] 0 카드: 3인에서 A → B 차례에 B가 0을 내면 방향이 반대가 되어 다음이 A다. 2인에서는 상대 차례 그대로다.
|
||||
- [ ] 0 카드: 3인에서 A → B 차례에 B가 0을 "방향 바꾸기"로 내면 다음이 A, "방향 그대로"로 내면 다음이 C다. 2인에서는 상대 차례 그대로다. `dir` 없이 내면 거부, 0이 아닌 카드에 `dir`이 있어도 거부된다. `classic`의 4는 고르지 않고 항상 방향이 바뀐다.
|
||||
- [ ] `classic`: 합계 90에서 K → 99, 그다음 사람은 4/9/10/K만 낼 수 있다. A는 1이어도 100이라 거부된다. 합계 88에서 A를 11로 내면 99로 허용된다.
|
||||
- [ ] `lives=3`: 진 사람이 목숨 1개를 잃고, 합계 0으로 새 라운드가 시작되며 진 사람이 먼저 한다. 목숨 0이면 탈락하고 다음 사람이 시작한다. `onTheBoard`면 0개에서 한 번 더 져야 탈락한다.
|
||||
- [ ] `lives=3`: 진 사람이 목숨 1개를 잃고, 시작 숫자로 새 라운드가 시작되며 진 사람이 먼저 한다. 목숨 0이면 탈락하고 다음 사람이 시작한다. `onTheBoard`면 0개에서 한 번 더 져야 탈락한다.
|
||||
- [ ] 연쇄 탈락(`lives=0`, `continue`): 합계 98에서 다음 두 사람 모두 낼 카드가 없으면 한 번의 `apply` 안에서 두 명이 순서대로 탈락하고, 세 번째 사람 차례가 된다. 이벤트 순서가 맞다.
|
||||
- [ ] 더미 소진: 보충할 때 더미가 비면 버린 카드가 섞여 보충되고 합계는 그대로다.
|
||||
- [ ] 시간 초과: 손패 [10, 3, 0], 합계 50이면 자동 행동은 0(결과 50, 가장 작음)을 낸다. 손패 [±9, 3, 0], 합계 50이면 −9(결과 41)를 낸다.
|
||||
- [ ] 시간 초과: 손패 [10, 3, 0], 합계 50이면 자동 행동은 0(결과 50, 가장 작음)을 방향 그대로로 낸다. 손패 [±9, 3, 0], 합계 50이면 −9(결과 41)를 낸다.
|
||||
- [ ] 연결 끊김: 끊긴 플레이어 차례가 자동으로 진행되고 게임이 멈추지 않는다.
|
||||
- [ ] 정보 유출: 관전자와 상대 view에 남의 손패, 보충 카드 내용, 더미 순서가 없다. 탈락자 손패도 공개되지 않는다.
|
||||
- [ ] 차례가 아닌 `play`, `sign` 없는 ±카드, 범위 밖 `jokerValue`, 중복 패킷이 거부되고 상태가 바뀌지 않는다.
|
||||
@@ -257,6 +268,7 @@ interface GameResult {
|
||||
## 11. 참고 자료
|
||||
- 코리아보드게임즈 매거진 "숫자를 더해나가며 한계에 도전하라!": https://www.koreaboardgames.com/magazine/menuDetail?boardCd=contents&postNo=947 (1~8 각 7장, 10이 21장, ±9·±10·0·조커 효과, 조커 2장 60~99, 99 초과 금지, 낼 카드 없으면 탈락)
|
||||
- 코리아보드게임즈 상품 페이지 "구십구": https://www.koreaboardgames.com/product/detail?prdCd=PD2024002211B1JR (2~10명, 만 8세+, 5분)
|
||||
- 상세 이미지(공식 규칙의 근거): https://cdnfile.koreaboardgames.com/_data/attach/202303/28/1df6a23faae2d4fd4415f07ba703309d.jpg (시작 카드 2명 40·3명 20·4명 이상 0, 5장씩 나눔, 내고 1장 가져옴, 낼 카드 없으면 탈락하고 합계는 그대로 다음 사람, 조커 60~99, ±9·±10, 0은 방향 유지 또는 반대 선택. 칩·목숨 없음)
|
||||
- 나무위키 "99(플레잉 카드)": https://namu.wiki/w/99(플레잉%20카드) (트럼프판 2~10명, 손패 3장, 내고 1장 보충, A 1/10, 4 반대, 9는 0, 10은 −10, J·Q 10, K는 99)
|
||||
- Bicycle Cards "99 (Ninety Nine)": https://bicyclecards.com/how-to-play/99-ninety-nine (토큰 3개, 손패 3장, A 1/11, 4 반대, 9 패스, 10 −10, K 99, 넘기면 토큰 1개 잃고 라운드 종료)
|
||||
- Wikipedia "Ninety-nine (addition card game)": https://en.wikipedia.org/wiki/Ninety-nine_(addition_card_game) (토큰 3개, 지역 변형 목록)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* 구십구 computer player: plays plain number cards while they still fit (biggest first, they go dead as the total
|
||||
* climbs) and saves the "safe" cards — 0/reverse, 9/hold, −10, ±9/±10, K, joker — for when the total is high.
|
||||
* climbs) and saves the "safe" cards — 0, 4/reverse, 9/hold, −10, ±9/±10, K, joker — for when the total is high.
|
||||
* A forced safe card picks the cheapest one; ± goes down near 99, and K / the joker push the next player to 99 only
|
||||
* when I still hold another safe card myself.
|
||||
*/
|
||||
@@ -9,13 +9,13 @@ import { best, pick, playable } from '../bots/policy';
|
||||
import type { CardKind, HandCardView, NinetyNineView, NNAction, PlayAction } from './types';
|
||||
|
||||
/** How much a card is worth keeping: it can be played on (almost) any total. */
|
||||
const KEEP: Record<CardKind, number> = { add: 0, ace: 0, reverse: 4, hold: 4, minus: 5, plusMinus: 5, to99: 6, joker: 8 };
|
||||
const KEEP: Record<CardKind, number> = { add: 0, ace: 0, reverse: 4, zero: 4, hold: 4, minus: 5, plusMinus: 5, to99: 6, joker: 8 };
|
||||
const isSafe = (c: HandCardView) => KEEP[c.kind] > 0;
|
||||
|
||||
/** Total after `a` (null if it cannot be worked out from the view). */
|
||||
function totalAfter(a: PlayAction, c: HandCardView): number | null {
|
||||
if (c.kind === 'joker') return a.jokerValue ?? null;
|
||||
const ch = c.choices.find((x) => x.legal && x.sign === a.sign && x.aceValue === a.aceValue);
|
||||
const ch = c.choices.find((x) => x.legal && x.sign === a.sign && x.aceValue === a.aceValue && x.dir === a.dir);
|
||||
return ch ? ch.total : null;
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ export function krDeck(specialCounts: number): NNCard[] {
|
||||
for (let i = 0; i < 21; i++) out.push({ id: id(), kind: 'add', v: 10, face: { label: '10' } });
|
||||
for (let i = 0; i < specialCounts; i++) out.push({ id: id(), kind: 'plusMinus', v: 9, face: { label: '±9' } });
|
||||
for (let i = 0; i < specialCounts; i++) out.push({ id: id(), kind: 'plusMinus', v: 10, face: { label: '±10' } });
|
||||
for (let i = 0; i < specialCounts; i++) out.push({ id: id(), kind: 'reverse', face: { label: '0' } });
|
||||
for (let i = 0; i < specialCounts; i++) out.push({ id: id(), kind: 'zero', face: { label: '0' } });
|
||||
for (let i = 0; i < 2; i++) out.push({ id: id(), kind: 'joker', face: { label: '조커' } });
|
||||
return out;
|
||||
}
|
||||
@@ -43,6 +43,8 @@ export const jokerRange = (o: Pick<NNOptions, 'jokerRange'>): { min: number; max
|
||||
|
||||
export const OVER = '99를 넘어서 낼 수 없어요';
|
||||
export const UNDER = '0보다 작아져서 뺄 수 없어요';
|
||||
export const PICK_DIR = '0 카드는 방향을 그대로 둘지 바꿀지 골라 주세요';
|
||||
export const DIR_ONLY_ZERO = '방향은 0 카드를 낼 때만 고를 수 있어요';
|
||||
|
||||
function choice(label: string, total: number, o: NNOptions, extra: Partial<ChoiceView> = {}, minus = false): ChoiceView {
|
||||
let t = total;
|
||||
@@ -63,6 +65,8 @@ export function choicesFor(c: NNCard, s: number, o: NNOptions): ChoiceView[] {
|
||||
return [choice(`−${c.v}`, s - c.v, o, {}, true)];
|
||||
case 'reverse':
|
||||
return [choice('방향 바꾸기', s, o)];
|
||||
case 'zero':
|
||||
return [choice('방향 그대로', s, o, { dir: 'keep' }), choice('방향 바꾸기', s, o, { dir: 'reverse' })];
|
||||
case 'hold':
|
||||
return [choice('그대로', s, o)];
|
||||
case 'to99':
|
||||
@@ -81,15 +85,16 @@ export function playable(c: NNCard, s: number, o: NNOptions): boolean {
|
||||
|
||||
/** The total a play makes, or the Korean reason it is not allowed (§8.2). */
|
||||
export function resolvePlay(c: NNCard, s: number, o: NNOptions, a: PlayAction): { ok: true; total: number; label: string } | { ok: false; reason: string } {
|
||||
const extra = (k: 'sign' | 'aceValue' | 'jokerValue') => a[k] !== undefined;
|
||||
const extra = (k: 'sign' | 'aceValue' | 'jokerValue' | 'dir') => a[k] !== undefined;
|
||||
if (c.kind === 'joker') {
|
||||
if (extra('sign') || extra('aceValue')) return { ok: false, reason: '조커는 숫자만 골라 주세요' };
|
||||
if (extra('sign') || extra('aceValue') || extra('dir')) return { ok: false, reason: '조커는 숫자만 골라 주세요' };
|
||||
const r = jokerRange(o);
|
||||
if (a.jokerValue === undefined) return { ok: false, reason: `조커로 만들 수(${r.min}~${r.max})를 골라 주세요` };
|
||||
if (!Number.isInteger(a.jokerValue) || a.jokerValue < r.min || a.jokerValue > r.max) return { ok: false, reason: `조커는 ${r.min}부터 ${r.max} 사이 숫자만 고를 수 있어요` };
|
||||
return { ok: true, total: a.jokerValue, label: String(a.jokerValue) };
|
||||
}
|
||||
if (extra('jokerValue')) return { ok: false, reason: '숫자는 조커를 낼 때만 고를 수 있어요' };
|
||||
if (c.kind !== 'zero' && extra('dir')) return { ok: false, reason: DIR_ONLY_ZERO };
|
||||
const options = choicesFor(c, s, o);
|
||||
let pick: ChoiceView | undefined;
|
||||
if (c.kind === 'plusMinus') {
|
||||
@@ -101,6 +106,9 @@ export function resolvePlay(c: NNCard, s: number, o: NNOptions, a: PlayAction):
|
||||
if (a.aceValue === undefined) return { ok: false, reason: `A를 1로 낼지 ${o.aceHigh}로 낼지 골라 주세요` };
|
||||
pick = options.find((x) => x.aceValue === a.aceValue);
|
||||
if (!pick) return { ok: false, reason: `A는 1이나 ${o.aceHigh}로만 낼 수 있어요` };
|
||||
} else if (c.kind === 'zero') {
|
||||
if (extra('sign') || extra('aceValue') || !a.dir) return { ok: false, reason: PICK_DIR };
|
||||
pick = options.find((x) => x.dir === a.dir);
|
||||
} else {
|
||||
if (extra('sign') || extra('aceValue')) return { ok: false, reason: '이 카드는 고를 것 없이 바로 내요' };
|
||||
pick = options[0];
|
||||
@@ -111,11 +119,21 @@ export function resolvePlay(c: NNCard, s: number, o: NNOptions, a: PlayAction):
|
||||
}
|
||||
|
||||
/** §8.5 tie-break group: no change → plain numbers → ± cards → K / joker. */
|
||||
const GROUP: Record<NNCard['kind'], number> = { reverse: 0, hold: 0, add: 1, minus: 1, ace: 1, plusMinus: 2, to99: 3, joker: 3 };
|
||||
const GROUP: Record<NNCard['kind'], number> = { reverse: 0, zero: 0, hold: 0, add: 1, minus: 1, ace: 1, plusMinus: 2, to99: 3, joker: 3 };
|
||||
|
||||
/** The `play` action for one of `choicesFor`'s choices. */
|
||||
export const actionFor = (cardId: string, x: ChoiceView): PlayAction => ({
|
||||
type: 'play',
|
||||
cardId,
|
||||
...(x.sign ? { sign: x.sign } : {}),
|
||||
...(x.aceValue !== undefined ? { aceValue: x.aceValue } : {}),
|
||||
...(x.dir ? { dir: x.dir } : {}),
|
||||
});
|
||||
|
||||
/**
|
||||
* Timer move (§8.5): the legal (card, choice) with the smallest resulting total; ties by the group order above,
|
||||
* then card id. Uses only the player's own hand and the public total.
|
||||
* then card id, then the choice order (so a 0 card keeps the direction). Uses only the player's own hand and the
|
||||
* public total.
|
||||
*/
|
||||
export function safestPlay(hand: NNCard[], s: number, o: NNOptions): PlayAction | null {
|
||||
let best: { a: PlayAction; total: number; g: number; id: string } | null = null;
|
||||
@@ -127,7 +145,7 @@ export function safestPlay(hand: NNCard[], s: number, o: NNOptions): PlayAction
|
||||
} else {
|
||||
for (const x of choicesFor(c, s, o)) {
|
||||
if (!x.legal) continue;
|
||||
cands.push({ a: { type: 'play', cardId: c.id, ...(x.sign ? { sign: x.sign } : {}), ...(x.aceValue !== undefined ? { aceValue: x.aceValue } : {}) }, total: x.total });
|
||||
cands.push({ a: actionFor(c.id, x), total: x.total });
|
||||
}
|
||||
}
|
||||
for (const k of cands) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/** 구십구 (docs/games/ninety-nine.md). Pure and deterministic; state is JSON. */
|
||||
import { fail, ok, type GameDefinition, type GameEvent, type GameResult, type PlayerId, type Rng, type Validation } from '@bg/engine';
|
||||
import { choicesFor, classicDeck, jokerRange, krDeck, playable, resolvePlay, safestPlay } from './cards';
|
||||
import { actionFor, choicesFor, classicDeck, jokerRange, krDeck, playable, resolvePlay, safestPlay } from './cards';
|
||||
import {
|
||||
ninetyNineActionSchema,
|
||||
ninetyNineOptionsSchema,
|
||||
@@ -56,6 +56,14 @@ function cloneState(s: NinetyNineState): NinetyNineState {
|
||||
}
|
||||
|
||||
export const resolvedLives = (o: Pick<NNOptions, 'mode' | 'lives'>): number => (o.lives === 'auto' ? (o.mode === 'classic' ? 3 : 0) : o.lives);
|
||||
/** Cards dealt to each player: `auto` is 5 with the dedicated cards (official rules), 3 with trump cards. */
|
||||
export const resolvedHandSize = (o: Pick<NNOptions, 'mode' | 'handSize'>): number => (o.handSize === 'auto' ? (o.mode === 'classic' ? 3 : 5) : o.handSize);
|
||||
/**
|
||||
* Starting total of a round dealt to `players` people. The dedicated game's start card shows 40 for 2 players,
|
||||
* 20 for 3 and 0 for 4 or more; trump cards (and `startTotal = zero`) always start at 0.
|
||||
*/
|
||||
export const resolvedStartTotal = (o: Pick<NNOptions, 'mode' | 'startTotal'>, players: number): number =>
|
||||
o.mode === 'kr-product' && o.startTotal === 'auto' ? (players <= 2 ? 40 : players === 3 ? 20 : 0) : 0;
|
||||
export const resolvedDecks = (o: Pick<NNOptions, 'mode' | 'deckCount'>, players: number): 1 | 2 =>
|
||||
o.mode !== 'classic' ? 1 : o.deckCount === 'auto' ? (players >= 7 ? 2 : 1) : o.deckCount;
|
||||
export const fullDeck = (s: Pick<NinetyNineState, 'mode' | 'options' | 'decks'>): NNCard[] =>
|
||||
@@ -84,23 +92,24 @@ function nextAlive(s: NinetyNineState, from: PlayerId, dir: 1 | -1): PlayerId {
|
||||
|
||||
const hasPlay = (s: NinetyNineState, p: PlayerId): boolean => (s.hands[p] ?? []).some((c) => playable(c, s.total, s.options));
|
||||
|
||||
/** Collects every card, shuffles, deals `handSize` to everyone still in from `starter` clockwise; total 0 (§5.3). */
|
||||
/** Collects every card, shuffles, deals the hand size to everyone still in from `starter` clockwise; total back to the start number (§5.3). */
|
||||
function dealRound(s: NinetyNineState, starter: PlayerId, ctx: Ctx): void {
|
||||
const pool = [...s.drawPile, ...s.discard, ...s.seats.flatMap((p) => s.hands[p] ?? [])];
|
||||
s.drawPile = ctx.rng.shuffle(pool);
|
||||
s.discard = [];
|
||||
s.hands = Object.fromEntries(s.seats.map((p) => [p, [] as NNCard[]]));
|
||||
s.total = 0;
|
||||
const n = aliveSeats(s).length;
|
||||
s.total = resolvedStartTotal(s.options, n);
|
||||
s.direction = 1;
|
||||
const order: PlayerId[] = [];
|
||||
for (let k = 0, p = starter; k < aliveSeats(s).length; k++, p = nextAlive(s, p, 1)) order.push(p);
|
||||
for (let r = 0; r < s.options.handSize; r++) for (const p of order) {
|
||||
for (let k = 0, p = starter; k < n; k++, p = nextAlive(s, p, 1)) order.push(p);
|
||||
for (let r = 0, h = resolvedHandSize(s.options); r < h; r++) for (const p of order) {
|
||||
const c = s.drawPile.shift();
|
||||
if (c) s.hands[p]!.push(c);
|
||||
}
|
||||
s.round += 1;
|
||||
ctx.events.push({ type: 'roundStarted', round: s.round, starter });
|
||||
log(s, { t: 'round', round: s.round, starter });
|
||||
ctx.events.push({ type: 'roundStarted', round: s.round, starter, total: s.total });
|
||||
log(s, { t: 'round', round: s.round, starter, total: s.total });
|
||||
}
|
||||
|
||||
function eliminate(s: NinetyNineState, p: PlayerId, why: 'noCard' | 'left', ctx: Ctx): void {
|
||||
@@ -207,10 +216,10 @@ function playCard(s: NinetyNineState, p: PlayerId, a: PlayAction, ctx: Ctx, auto
|
||||
s.hands[p] = hand.filter((c) => c.id !== card.id);
|
||||
s.discard.push(card);
|
||||
s.total = r.total;
|
||||
const choice = card.kind === 'plusMinus' || card.kind === 'ace' || card.kind === 'joker' ? r.label : undefined;
|
||||
const choice = card.kind === 'plusMinus' || card.kind === 'ace' || card.kind === 'joker' || card.kind === 'zero' ? r.label : undefined;
|
||||
ctx.events.push({ type: 'cardPlayed', player: p, face: card.face, kind: card.kind, total: s.total, ...(choice ? { choice } : {}) });
|
||||
log(s, { t: 'play', p, face: card.face, kind: card.kind, from, total: s.total, ...(choice ? { choice } : {}), ...(auto ? { auto: true } : {}) });
|
||||
if (card.kind === 'reverse') {
|
||||
if (card.kind === 'reverse' || (card.kind === 'zero' && a.dir === 'reverse')) {
|
||||
s.direction = s.direction === 1 ? -1 : 1;
|
||||
ctx.events.push({ type: 'directionChanged', direction: s.direction });
|
||||
}
|
||||
@@ -272,7 +281,8 @@ export const ninetyNine: Def = {
|
||||
nameKo: '구십구',
|
||||
minPlayers: 2,
|
||||
maxPlayers: 10,
|
||||
stateVersion: 1,
|
||||
// 2: handSize `auto`, startTotal, and the dedicated 0 card (`zero`) with a chosen direction.
|
||||
stateVersion: 2,
|
||||
defaultOptions: ninetyNineOptionsSchema.parse({}),
|
||||
optionsSchema: ninetyNineOptionsSchema as unknown as Def['optionsSchema'],
|
||||
actionSchema: ninetyNineActionSchema as unknown as Def['actionSchema'],
|
||||
@@ -290,7 +300,7 @@ export const ninetyNine: Def = {
|
||||
hands: {},
|
||||
drawPile: [],
|
||||
discard: [],
|
||||
total: 0,
|
||||
total: resolvedStartTotal(options, players.length),
|
||||
direction: 1,
|
||||
current: players[0]!,
|
||||
alive: rec(true),
|
||||
@@ -318,7 +328,7 @@ export const ninetyNine: Def = {
|
||||
const order: PlayerId[] = [];
|
||||
for (let k = 0, p = starter; k < players.length; k++, p = nextAlive(s, p, 1)) order.push(p);
|
||||
s.hands = Object.fromEntries(players.map((p) => [p, [] as NNCard[]]));
|
||||
for (let r = 0; r < options.handSize; r++) for (const p of order) s.hands[p]!.push(s.drawPile.shift()!);
|
||||
for (let r = 0, h = resolvedHandSize(options); r < h; r++) for (const p of order) s.hands[p]!.push(s.drawPile.shift()!);
|
||||
// Someone may already have nothing playable (e.g. only trump 10s at 0).
|
||||
startTurn(s, starter, ctx);
|
||||
return s;
|
||||
@@ -421,6 +431,16 @@ export const ninetyNine: Def = {
|
||||
return { type: 'timeout' };
|
||||
},
|
||||
|
||||
/**
|
||||
* v1 → v2 for games in progress: they keep playing by the rules they started with — the hand size they were
|
||||
* dealt, start number 0 for later rounds, and 0 cards that always reverse (their kind stays `reverse`).
|
||||
*/
|
||||
migrate(old, fromVersion) {
|
||||
const s = old as NinetyNineState;
|
||||
if (fromVersion >= 2) return s;
|
||||
return { ...s, options: ninetyNineOptionsSchema.parse({ ...s.options, startTotal: 'zero' }) };
|
||||
},
|
||||
|
||||
onLeave(s, player) {
|
||||
if (s.phase !== 'playing' || !s.seats.includes(player) || !s.alive[player]) return null;
|
||||
return { type: 'leave' };
|
||||
@@ -444,10 +464,7 @@ export const ninetyNine: Def = {
|
||||
for (const jokerValue of vals) out.push({ type: 'play', cardId: c.id, jokerValue });
|
||||
continue;
|
||||
}
|
||||
for (const x of choicesFor(c, s.total, s.options)) {
|
||||
if (!x.legal) continue;
|
||||
out.push({ type: 'play', cardId: c.id, ...(x.sign ? { sign: x.sign } : {}), ...(x.aceValue !== undefined ? { aceValue: x.aceValue } : {}) });
|
||||
}
|
||||
for (const x of choicesFor(c, s.total, s.options)) if (x.legal) out.push(actionFor(c.id, x));
|
||||
}
|
||||
return out;
|
||||
},
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
fullDeck,
|
||||
krDeck,
|
||||
ninetyNine,
|
||||
resolvedHandSize,
|
||||
resolvedStartTotal,
|
||||
safestPlay,
|
||||
type NNAction,
|
||||
type NNCard,
|
||||
@@ -82,7 +84,8 @@ describe('덱 구성과 셋업 (§3, §4)', () => {
|
||||
expect(count((c) => c.kind === 'add' && c.v === 9)).toBe(0);
|
||||
expect(count((c) => c.kind === 'plusMinus' && c.v === 9)).toBe(4);
|
||||
expect(count((c) => c.kind === 'plusMinus' && c.v === 10)).toBe(4);
|
||||
expect(count((c) => c.kind === 'reverse')).toBe(4);
|
||||
expect(count((c) => c.kind === 'zero' && c.face.label === '0')).toBe(4);
|
||||
expect(count((c) => c.kind === 'reverse')).toBe(0);
|
||||
expect(count((c) => c.kind === 'joker')).toBe(2);
|
||||
expect(krDeck(2)).toHaveLength(85);
|
||||
expect(new Set(d.map((c) => c.id)).size).toBe(91);
|
||||
@@ -103,19 +106,50 @@ describe('덱 구성과 셋업 (§3, §4)', () => {
|
||||
expect(cardTotal(make({ mode: 'classic', deckCount: 1 }, ['A', 'B', 'C', 'D', 'E', 'F', 'G']))).toBe(52);
|
||||
});
|
||||
|
||||
test('same seed → same deal and first player; handSize cards each; total 0; lives per mode', () => {
|
||||
test('same seed → same deal and first player; 5 cards each (auto); 4 players start at 0; lives per mode', () => {
|
||||
const a = make({}, ['A', 'B', 'C', 'D'], 5);
|
||||
const b = make({}, ['A', 'B', 'C', 'D'], 5);
|
||||
expect(JSON.stringify(a)).toBe(JSON.stringify(b));
|
||||
expect(JSON.stringify(make({}, ['A', 'B', 'C', 'D'], 6).hands)).not.toBe(JSON.stringify(a.hands));
|
||||
const starters = new Set(Array.from({ length: 20 }, (_, i) => make({}, ['A', 'B', 'C', 'D'], i).current));
|
||||
expect(starters.size).toBeGreaterThan(1);
|
||||
for (const p of a.seats) expect(a.hands[p]).toHaveLength(3);
|
||||
for (const p of a.seats) expect(a.hands[p]).toHaveLength(5);
|
||||
expect(a.total).toBe(0);
|
||||
expect(cardTotal(a)).toBe(91);
|
||||
expect(a.drawPile).toHaveLength(91 - 4 * 5);
|
||||
expect(a.livesStart).toBe(0);
|
||||
expect(make({ mode: 'classic' }).livesStart).toBe(3);
|
||||
expect(make({ mode: 'classic', lives: 0 }).livesStart).toBe(0);
|
||||
expect(make({ handSize: 5 }).hands.A).toHaveLength(5);
|
||||
expect(make({ handSize: 3 }).hands.A).toHaveLength(3);
|
||||
});
|
||||
|
||||
test('handSize auto: 5 with the dedicated cards (official rules), 3 with trump cards; 3/4/5 still selectable', () => {
|
||||
expect(ninetyNine.defaultOptions.handSize).toBe('auto');
|
||||
expect(resolvedHandSize(ninetyNine.defaultOptions)).toBe(5);
|
||||
expect(resolvedHandSize({ mode: 'classic', handSize: 'auto' })).toBe(3);
|
||||
for (const p of ['A', 'B', 'C']) {
|
||||
expect(make().hands[p]).toHaveLength(5);
|
||||
expect(make({ mode: 'classic' }).hands[p]).toHaveLength(3);
|
||||
expect(make({ handSize: 4 }).hands[p]).toHaveLength(4);
|
||||
expect(make({ mode: 'classic', handSize: 5 }).hands[p]).toHaveLength(5);
|
||||
}
|
||||
expect(make({}, ['A', 'B']).drawPile).toHaveLength(91 - 10);
|
||||
expect(make({ mode: 'classic' }, ['A', 'B']).drawPile).toHaveLength(52 - 6);
|
||||
});
|
||||
|
||||
test('start card: 2 players start at 40, 3 at 20, 4 or more at 0; startTotal=zero and trump cards always 0', () => {
|
||||
expect(make({}, ['A', 'B']).total).toBe(40);
|
||||
expect(make({}, ['A', 'B', 'C']).total).toBe(20);
|
||||
expect(make({}, ['A', 'B', 'C', 'D']).total).toBe(0);
|
||||
expect(make({}, Array.from({ length: 10 }, (_, i) => `P${i}`)).total).toBe(0);
|
||||
expect(make({ startTotal: 'zero' }, ['A', 'B']).total).toBe(0);
|
||||
expect(make({ startTotal: 'zero' }, ['A', 'B', 'C']).total).toBe(0);
|
||||
expect(make({ mode: 'classic' }, ['A', 'B']).total).toBe(0);
|
||||
expect(make({ mode: 'classic' }, ['A', 'B', 'C']).total).toBe(0);
|
||||
expect([2, 3, 4, 7].map((n) => resolvedStartTotal(ninetyNine.defaultOptions, n))).toEqual([40, 20, 0, 0]);
|
||||
// The view and the first turn use it: nobody has played yet.
|
||||
const v = ninetyNine.view(make({}, ['A', 'B']), 'A');
|
||||
expect([v.total, v.recent.length]).toEqual([40, 0]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -176,20 +210,53 @@ describe('낼 수 있는 카드 (§5.2, §10)', () => {
|
||||
expect(ninetyNine.view(kid, 'A').my!.hand[0]!.joker).toEqual({ min: 0, max: 99 });
|
||||
});
|
||||
|
||||
test('0 card: with 3 players B’s 0 turns the direction and A plays next; with 2 players it stays the opponent', () => {
|
||||
let s = rig(make(), { hands: { A: ['1', '2'], B: ['0', '3'], C: ['4', '5'] }, total: 30, current: 'B' });
|
||||
const out = actE(s, 'B', play(s, 'B', '0'));
|
||||
test('0 card: 3 players, B’s 0 keeping the direction passes to C; reversing it gives A the next turn', () => {
|
||||
const base = () => rig(make(), { hands: { A: ['1', '2'], B: ['0', '3'], C: ['4', '5'] }, total: 30, current: 'B' });
|
||||
let s = base();
|
||||
expect(ninetyNine.view(s, 'B').my!.hand[0]!.choices).toEqual([
|
||||
{ label: '방향 그대로', total: 30, legal: true, dir: 'keep' },
|
||||
{ label: '방향 바꾸기', total: 30, legal: true, dir: 'reverse' },
|
||||
]);
|
||||
const keep = actE(s, 'B', play(s, 'B', '0', { dir: 'keep' }));
|
||||
expect([keep.state.total, keep.state.direction, keep.state.current]).toEqual([30, 1, 'C']);
|
||||
expect(types(keep.events)).not.toContain('directionChanged');
|
||||
expect(keep.events[0]).toMatchObject({ type: 'cardPlayed', kind: 'zero', choice: '방향 그대로' });
|
||||
expect(keep.state.log.at(-1)).toMatchObject({ t: 'play', face: { label: '0' }, kind: 'zero', from: 30, total: 30, choice: '방향 그대로' });
|
||||
s = base();
|
||||
const out = actE(s, 'B', play(s, 'B', '0', { dir: 'reverse' }));
|
||||
s = out.state;
|
||||
expect(s.total).toBe(30);
|
||||
expect(s.direction).toBe(-1);
|
||||
expect(s.current).toBe('A');
|
||||
expect([s.total, s.direction, s.current]).toEqual([30, -1, 'A']);
|
||||
expect(types(out.events)).toContain('directionChanged');
|
||||
expect(s.log.at(-1)).toMatchObject({ t: 'play', kind: 'zero', choice: '방향 바꾸기' });
|
||||
s = act(s, 'A', play(s, 'A', '1'));
|
||||
expect(s.current).toBe('C');
|
||||
let two = rig(make({}, ['A', 'B']), { hands: { A: ['1'], B: ['0', '3'] }, total: 30, current: 'B' });
|
||||
two = act(two, 'B', play(two, 'B', '0'));
|
||||
two = act(two, 'B', play(two, 'B', '0', { dir: 'reverse' }));
|
||||
expect(two.direction).toBe(-1);
|
||||
expect(two.current).toBe('A');
|
||||
// Both ways are offered to the test helper (and so to bots).
|
||||
const legal = ninetyNine.legalActions!(base(), 'B').filter((a) => a.type === 'play' && a.cardId === idOf(base(), 'B', '0'));
|
||||
expect(legal.map((a) => (a as PlayAction).dir)).toEqual(['keep', 'reverse']);
|
||||
});
|
||||
|
||||
test('0 card needs a direction; a direction on any other card is refused; trump 4 still always reverses', () => {
|
||||
const s = rig(make(), { hands: { A: ['0', '3', '±9', '조커'], B: ['1'], C: ['2'] }, total: 30 });
|
||||
const snap = JSON.stringify(s);
|
||||
expect(reject(s, 'A', play(s, 'A', '0'))).toBe('0 카드는 방향을 그대로 둘지 바꿀지 골라 주세요');
|
||||
expect(reject(s, 'A', play(s, 'A', '0', { sign: '+' }))).toBe('0 카드는 방향을 그대로 둘지 바꿀지 골라 주세요');
|
||||
expect(reject(s, 'A', play(s, 'A', '3', { dir: 'keep' }))).toBe('방향은 0 카드를 낼 때만 고를 수 있어요');
|
||||
expect(reject(s, 'A', play(s, 'A', '±9', { sign: '+', dir: 'reverse' }))).toBe('방향은 0 카드를 낼 때만 고를 수 있어요');
|
||||
expect(reject(s, 'A', play(s, 'A', '조커', { jokerValue: 70, dir: 'keep' }))).toBe('조커는 숫자만 골라 주세요');
|
||||
expect(ninetyNine.actionSchema.safeParse({ type: 'play', cardId: 'k1', dir: 'left' }).success).toBe(false);
|
||||
expect(ninetyNine.actionSchema.safeParse({ type: 'play', cardId: 'k1', dir: 'keep' }).success).toBe(true);
|
||||
expect(JSON.stringify(s)).toBe(snap);
|
||||
const o = { mode: 'classic' as const, lives: 0 as const };
|
||||
const four = rig(make(o), { hands: { A: ['4', '2'], B: ['3'], C: ['3'] }, total: 50 });
|
||||
expect(reject(four, 'A', play(four, 'A', '4', { dir: 'keep' }))).toBe('방향은 0 카드를 낼 때만 고를 수 있어요');
|
||||
const f = actE(four, 'A', play(four, 'A', '4'));
|
||||
expect([f.state.total, f.state.direction, f.state.current]).toEqual([50, -1, 'C']);
|
||||
expect(f.state.log.at(-1)).not.toHaveProperty('choice');
|
||||
expect(ninetyNine.view(four, 'A').my!.hand[0]!.choices).toEqual([{ label: '방향 바꾸기', total: 50, legal: true }]);
|
||||
});
|
||||
|
||||
test('classic: K at 90 → 99; then only 4 / 9 / 10 / K; A is refused even as 1; A as 11 at 88 makes 99', () => {
|
||||
@@ -235,7 +302,7 @@ describe('낼 수 있는 카드 (§5.2, §10)', () => {
|
||||
// ---------------------------------------------------------------- losing
|
||||
|
||||
describe('패배 처리 (§5.3, §10)', () => {
|
||||
test('lives=3: the loser loses one life, a new round starts at 0 with the loser first; at 0 lives → out, the next one starts', () => {
|
||||
test('lives=3: the loser loses one life, a new round starts at the start number with the loser first; at 0 lives → out, the next one starts', () => {
|
||||
let s = rig(make({ lives: 3 }), { hands: { A: ['4'], B: ['5', '6', '7'], C: ['1'] }, total: 95 });
|
||||
const out = actE(s, 'A', play(s, 'A', '4'));
|
||||
s = out.state;
|
||||
@@ -243,8 +310,11 @@ describe('패배 처리 (§5.3, §10)', () => {
|
||||
expect(s.lives.B).toBe(2);
|
||||
expect(s.livesLost.B).toBe(1);
|
||||
expect(s.alive.B).toBe(true);
|
||||
expect([s.total, s.round, s.current, s.direction]).toEqual([0, 2, 'B', 1]);
|
||||
for (const p of s.seats) expect(s.hands[p]).toHaveLength(3);
|
||||
// Still 3 players: the new round starts at 20 (start card), 5 cards each.
|
||||
expect([s.total, s.round, s.current, s.direction]).toEqual([20, 2, 'B', 1]);
|
||||
expect(out.events.at(-1)).toEqual({ type: 'roundStarted', round: 2, starter: 'B', total: 20 });
|
||||
expect(s.log.at(-1)).toMatchObject({ t: 'round', round: 2, starter: 'B', total: 20 });
|
||||
for (const p of s.seats) expect(s.hands[p]).toHaveLength(5);
|
||||
expect(s.discard).toEqual([]);
|
||||
expect(cardTotal(s)).toBe(91);
|
||||
// B on the last life loses again → out; C (next after B) starts round 3.
|
||||
@@ -254,9 +324,10 @@ describe('패배 처리 (§5.3, §10)', () => {
|
||||
expect(s.alive.B).toBe(false);
|
||||
expect(s.lives.B).toBe(0);
|
||||
expect(s.eliminationOrder).toEqual(['B']);
|
||||
expect([s.total, s.round, s.current]).toEqual([0, 3, 'C']);
|
||||
// Two left: round 3 starts at 40.
|
||||
expect([s.total, s.round, s.current]).toEqual([40, 3, 'C']);
|
||||
expect(s.hands.B).toEqual([]);
|
||||
expect(s.hands.A).toHaveLength(3);
|
||||
expect(s.hands.A).toHaveLength(5);
|
||||
expect(s.eliminatedAtRound.B).toBe(2);
|
||||
});
|
||||
|
||||
@@ -281,14 +352,20 @@ describe('패배 처리 (§5.3, §10)', () => {
|
||||
expect(cardTotal(s)).toBe(91);
|
||||
});
|
||||
|
||||
test('reset after an elimination: total back to 0, everyone redealt, the next player starts', () => {
|
||||
test('reset after an elimination: total back to the start number for those left, everyone redealt, the next player starts', () => {
|
||||
let s = rig(make({ afterElimination: 'reset' }), { hands: { A: ['4'], B: ['5', '6', '7'], C: ['1'] }, total: 95 });
|
||||
s = act(s, 'A', play(s, 'A', '4'));
|
||||
expect(s.alive.B).toBe(false);
|
||||
expect([s.total, s.current, s.round]).toEqual([0, 'C', 2]);
|
||||
expect(s.hands.A).toHaveLength(3);
|
||||
expect(s.hands.C).toHaveLength(3);
|
||||
expect([s.total, s.current, s.round]).toEqual([40, 'C', 2]);
|
||||
expect(s.hands.A).toHaveLength(5);
|
||||
expect(s.hands.C).toHaveLength(5);
|
||||
expect(cardTotal(s)).toBe(91);
|
||||
let z = rig(make({ afterElimination: 'reset', startTotal: 'zero', handSize: 3 }), { hands: { A: ['4'], B: ['5', '6', '7'], C: ['1'] }, total: 95 });
|
||||
z = act(z, 'A', play(z, 'A', '4'));
|
||||
expect([z.total, z.hands.A!.length]).toEqual([0, 3]);
|
||||
let cls = rig(make({ mode: 'classic', lives: 0, afterElimination: 'reset' }), { hands: { A: ['2'], B: ['5', '6', '7'], C: ['3'] }, total: 95 });
|
||||
cls = act(cls, 'A', play(cls, 'A', '2'));
|
||||
expect([cls.alive.B, cls.total, cls.hands.A!.length]).toEqual([false, 0, 3]);
|
||||
});
|
||||
|
||||
test('last one standing wins: ranking by elimination order, Korean summary, lives lost in the result', () => {
|
||||
@@ -335,14 +412,21 @@ describe('더미 소진 (§5.1)', () => {
|
||||
// ---------------------------------------------------------------- timer / away / leave
|
||||
|
||||
describe('시간 초과, 연결 끊김, 나가기 (§8.5)', () => {
|
||||
test('timeout plays the smallest resulting total: [10, 3, 0] at 50 → 0; [±9, 3, 0] at 50 → −9 (41)', () => {
|
||||
test('timeout plays the smallest resulting total: [10, 3, 0] at 50 → 0 keeping the direction; [±9, 3, 0] at 50 → −9 (41)', () => {
|
||||
let s = rig(make(), { hands: { A: ['10', '3', '0'], B: ['1'], C: ['1'] }, total: 50 });
|
||||
expect(ninetyNine.onTimeout(s, 'A')).toEqual({ type: 'timeout' });
|
||||
expect(ninetyNine.actionSchema.safeParse({ type: 'timeout' }).success).toBe(false);
|
||||
const auto = safestPlay(s.hands.A!, 50, s.options)!;
|
||||
expect(auto).toEqual({ type: 'play', cardId: idOf(s, 'A', '0'), dir: 'keep' });
|
||||
expect(reject(s, 'A', auto)).toBeNull();
|
||||
const out = actE(s, 'A', { type: 'timeout' });
|
||||
expect(labelsOf(out.state, 'A')).not.toContain('0');
|
||||
expect([out.state.total, out.state.direction, out.state.current]).toEqual([50, -1, 'C']);
|
||||
expect(out.state.log.at(-1)).toMatchObject({ t: 'play', face: { label: '0' }, auto: true });
|
||||
expect([out.state.total, out.state.direction, out.state.current]).toEqual([50, 1, 'B']);
|
||||
expect(out.state.log.at(-1)).toMatchObject({ t: 'play', face: { label: '0' }, kind: 'zero', choice: '방향 그대로', auto: true });
|
||||
// Same move from a reversed direction: it stays reversed.
|
||||
s = rig(make(), { hands: { A: ['10', '3', '0'], B: ['1'], C: ['1'] }, total: 50, direction: -1 });
|
||||
const back = act(s, 'A', { type: 'timeout' });
|
||||
expect([back.direction, back.current]).toEqual([-1, 'C']);
|
||||
s = rig(make(), { hands: { A: ['±9', '3', '0'], B: ['1'], C: ['1'] }, total: 50 });
|
||||
expect(act(s, 'A', { type: 'timeout' }).total).toBe(41);
|
||||
// Joker only → the lowest joker number; tie at the same total → the no-change card first.
|
||||
@@ -350,7 +434,7 @@ describe('시간 초과, 연결 끊김, 나가기 (§8.5)', () => {
|
||||
expect(act(s, 'A', { type: 'timeout' }).total).toBe(60);
|
||||
s = rig(make(), { hands: { A: ['조커', '0'], B: ['1'], C: ['1'] }, total: 60 });
|
||||
const t = act(s, 'A', { type: 'timeout' });
|
||||
expect([t.total, t.direction]).toEqual([60, -1]);
|
||||
expect([t.total, t.direction]).toEqual([60, 1]);
|
||||
const cls = rig(make({ mode: 'classic' }), { hands: { A: ['K', 'A', '9'], B: ['2'], C: ['2'] }, total: 95 });
|
||||
expect(safestPlay(cls.hands.A!, 95, cls.options)).toEqual({ type: 'play', cardId: idOf(cls, 'A', '9') });
|
||||
});
|
||||
@@ -443,7 +527,7 @@ describe('검증과 정보 유출 (§8.3, §10)', () => {
|
||||
|
||||
test('options: defaults, Korean metadata on every option; client actions only in the schema', () => {
|
||||
const d = ninetyNine.defaultOptions;
|
||||
expect(d).toMatchObject({ mode: 'kr-product', handSize: 3, refill: true, lives: 'auto', onTheBoard: false, afterElimination: 'continue', floorAtZero: 'forbid', specialCounts: 4, jokerRange: '60-99', aceHigh: 11, deckCount: 'auto', turnSeconds: 15 });
|
||||
expect(d).toMatchObject({ mode: 'kr-product', handSize: 'auto', startTotal: 'auto', refill: true, lives: 'auto', onTheBoard: false, afterElimination: 'continue', floorAtZero: 'forbid', specialCounts: 4, jokerRange: '60-99', aceHigh: 11, deckCount: 'auto', turnSeconds: 15 });
|
||||
const shape = (ninetyNine.optionsSchema as unknown as { shape: Record<string, { meta(): { title?: string; labels?: Record<string, string> } | undefined }> }).shape;
|
||||
for (const [k, f] of Object.entries(shape)) {
|
||||
const m = f.meta();
|
||||
@@ -455,6 +539,29 @@ describe('검증과 정보 유출 (§8.3, §10)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- saved games
|
||||
|
||||
describe('saved games (stateVersion 1 → 2)', () => {
|
||||
test('a v1 game in progress keeps its rules: dealt hand size, later rounds from 0, its 0 cards always reverse', () => {
|
||||
expect(ninetyNine.stateVersion).toBe(2);
|
||||
// A v1 save: options without startTotal and a numeric handSize, the dedicated 0 card stored as `reverse`.
|
||||
const cur = rig(make({ lives: 3, handSize: 3 }), { hands: { A: ['4', '0'], B: ['5', '6', '7'], C: ['1'] }, total: 95 });
|
||||
const { startTotal: _drop, ...oldOptions } = cur.options;
|
||||
const asV1 = (c: NNCard) => (c.kind === 'zero' ? { ...c, kind: 'reverse' } : c);
|
||||
const v1 = JSON.parse(JSON.stringify({ ...cur, options: oldOptions, hands: Object.fromEntries(Object.entries(cur.hands).map(([p, h]) => [p, h.map(asV1)])), drawPile: cur.drawPile.map(asV1) }));
|
||||
const m = ninetyNine.migrate!(v1, 1);
|
||||
expect(m.options).toEqual({ ...cur.options, startTotal: 'zero' });
|
||||
expect(ninetyNine.migrate!(cur, 2)).toBe(cur);
|
||||
// Its old 0 card still reverses without a direction.
|
||||
const zero = m.hands.A!.find((c) => c.face.label === '0')!;
|
||||
expect(zero.kind).toBe('reverse');
|
||||
expect(act(m, 'A', { type: 'play', cardId: zero.id }).direction).toBe(-1);
|
||||
// B loses a life: the new round starts at 0 with 3 cards, as the game began.
|
||||
const next = act(m, 'A', play(m, 'A', '4'));
|
||||
expect([next.round, next.total, next.hands.B!.length]).toEqual([2, 0, 3]);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- random playouts
|
||||
|
||||
interface Report {
|
||||
@@ -547,6 +654,7 @@ const PRESETS: Partial<NNOptions>[] = [
|
||||
{ lives: 3 },
|
||||
{ afterElimination: 'reset', floorAtZero: 'clamp', specialCounts: 6 },
|
||||
{ refill: false, handSize: 5 },
|
||||
{ handSize: 3, startTotal: 'zero', lives: 2 },
|
||||
{ jokerRange: '0-99', handSize: 4, lives: 2, onTheBoard: true },
|
||||
{ mode: 'classic' },
|
||||
{ mode: 'classic', lives: 0, aceHigh: 10 },
|
||||
|
||||
@@ -10,7 +10,13 @@ export const ninetyNineOptionsSchema = z.object({
|
||||
.enum(['kr-product', 'classic'])
|
||||
.default('kr-product')
|
||||
.meta({ title: '규칙', labels: { 'kr-product': '전용 카드(±9·±10·0·조커)', classic: '트럼프 카드(전통 99)' } }),
|
||||
handSize: lit(3, 4, 5).default(3).meta({ title: '손패 장수', labels: { 3: '3장', 4: '4장', 5: '5장' } }),
|
||||
handSize: lit<'auto' | 3 | 4 | 5>('auto', 3, 4, 5)
|
||||
.default('auto')
|
||||
.meta({ title: '손패 장수', labels: { auto: '규칙 기본(전용 카드 5장, 트럼프 3장)', 3: '3장', 4: '4장', 5: '5장' } }),
|
||||
startTotal: z
|
||||
.enum(['auto', 'zero'])
|
||||
.default('auto')
|
||||
.meta({ title: '(전용 카드) 시작 숫자', labels: { auto: '시작 카드(2명 40 · 3명 20 · 4명 이상 0)', zero: '항상 0' } }),
|
||||
refill: z.boolean().default(true).meta({ title: '카드를 낸 뒤 1장 보충', labels: { true: '보충해요', false: '보충 안 해요' } }),
|
||||
lives: lit<'auto' | 0 | 1 | 2 | 3 | 5>('auto', 0, 1, 2, 3, 5)
|
||||
.default('auto')
|
||||
@@ -25,7 +31,7 @@ export const ninetyNineOptionsSchema = z.object({
|
||||
afterElimination: z
|
||||
.enum(['continue', 'reset'])
|
||||
.default('continue')
|
||||
.meta({ title: '(목숨 없음) 탈락 뒤 합계', labels: { continue: '그대로 이어 가기', reset: '0으로 하고 다시 나누기' } }),
|
||||
.meta({ title: '(목숨 없음) 탈락 뒤 합계', labels: { continue: '그대로 이어 가기', reset: '시작 숫자로 하고 다시 나누기' } }),
|
||||
floorAtZero: z
|
||||
.enum(['forbid', 'clamp'])
|
||||
.default('forbid')
|
||||
@@ -54,6 +60,8 @@ export const ninetyNineActionSchema = z.object({
|
||||
sign: z.enum(['+', '-']).optional(),
|
||||
aceValue: lit(1, 10, 11).optional(),
|
||||
jokerValue: z.number().int().min(0).max(99).optional(),
|
||||
/** Dedicated 0 card only: keep the turn direction or reverse it. */
|
||||
dir: z.enum(['keep', 'reverse']).optional(),
|
||||
});
|
||||
export type PlayAction = z.infer<typeof ninetyNineActionSchema>;
|
||||
/** Client actions plus system-only ones (timer, leaving) that are not in the schema. */
|
||||
@@ -72,7 +80,10 @@ export interface CardFace {
|
||||
export type NNCard =
|
||||
| { id: string; kind: 'add'; v: number; face: CardFace }
|
||||
| { id: string; kind: 'plusMinus'; v: 9 | 10; face: CardFace }
|
||||
/** Classic 4: always reverses. */
|
||||
| { id: string; kind: 'reverse'; face: CardFace }
|
||||
/** Dedicated 0: no change; the player keeps or reverses the direction. */
|
||||
| { id: string; kind: 'zero'; face: CardFace }
|
||||
| { id: string; kind: 'hold'; face: CardFace }
|
||||
| { id: string; kind: 'minus'; v: 10; face: CardFace }
|
||||
| { id: string; kind: 'ace'; face: CardFace }
|
||||
@@ -86,7 +97,8 @@ export type LogEntry =
|
||||
| { seq: number; t: 'life'; p: PlayerId; left: number }
|
||||
| { seq: number; t: 'out'; p: PlayerId; why: 'noCard' | 'left' }
|
||||
| { seq: number; t: 'noCard'; p: PlayerId }
|
||||
| { seq: number; t: 'round'; round: number; starter: PlayerId }
|
||||
/** `total` is the round's starting number (missing in logs saved before stateVersion 2, where it was 0). */
|
||||
| { seq: number; t: 'round'; round: number; starter: PlayerId; total?: number }
|
||||
| { seq: number; t: 'reshuffle'; n: number }
|
||||
| { seq: number; t: 'timeout'; p: PlayerId };
|
||||
|
||||
@@ -147,6 +159,7 @@ export interface ChoiceView {
|
||||
label: string;
|
||||
sign?: '+' | '-';
|
||||
aceValue?: 1 | 10 | 11;
|
||||
dir?: 'keep' | 'reverse';
|
||||
/** Total after playing (may be over 99 or below 0 when not legal). */
|
||||
total: number;
|
||||
legal: boolean;
|
||||
|
||||
Reference in New Issue
Block a user