diff --git a/apps/web/src/games/ninety-nine/help.test.ts b/apps/web/src/games/ninety-nine/help.test.ts index f210494..560da5e 100644 --- a/apps/web/src/games/ninety-nine/help.test.ts +++ b/apps/web/src/games/ninety-nine/help.test.ts @@ -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('탈락했어요'); diff --git a/apps/web/src/games/ninety-nine/help.ts b/apps/web/src/games/ninety-nine/help.ts index ba30b61..6859091 100644 --- a/apps/web/src/games/ninety-nine/help.ts +++ b/apps/web/src/games/ninety-nine/help.ts @@ -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): 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, players: number): number => + o.mode === 'kr-product' && o.startTotal === 'auto' ? (players <= 2 ? 40 : players === 3 ? 20 : 0) : 0; + const nm = (players: Record, 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 { @@ -44,6 +51,7 @@ export function helpNow(v: NinetyNineView, me: string | null, players: Record 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로 만들어요.`); diff --git a/apps/web/src/games/ninety-nine/index.tsx b/apps/web/src/games/ninety-nine/index.tsx index f883fd3..9757694 100644 --- a/apps/web/src/games/ninety-nine/index.tsx +++ b/apps/web/src/games/ninety-nine/index.tsx @@ -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 = { 'kr-product': '전용 카드', classic: '트럼프 카드' }; @@ -18,6 +18,10 @@ const TRUMP_RANK: Record = { '10': 'T' }; const nick = (players: Record, id: string) => players[id]?.nickname ?? '알 수 없음'; const userOf = (players: Record, 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> = { plusMinus: '더하기·빼기', reverse: '방향 바꾸기', joker: '' }; +const KR_SUB: Partial> = { 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 ))} ) : ( - {v.round > 1 ? `${v.round}판 시작! 합계 0` : '첫 카드를 기다려요'} + {v.round > 1 ? `${v.round}판 시작! 합계 ${v.total}` : v.total > 0 ? `시작 숫자 ${v.total} · 첫 카드를 기다려요` : '첫 카드를 기다려요'} )} @@ -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)} @@ -499,7 +503,16 @@ function logText(e: LogEntry, players: Record): 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 { 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 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) { set({ mode })} /> - set({ handSize })} /> + set({ handSize })} /> - 0 ? '낼 카드가 없으면 목숨 1개를 잃고 0부터 새 판을 해요.' : '낼 카드가 없으면 바로 탈락해요.'}> + {kr && ( + + set({ startTotal })} /> + + )} + 0 ? `낼 카드가 없으면 목숨 1개를 잃고 ${startWord(o)}부터 새 판을 해요.` : '낼 카드가 없으면 바로 탈락해요.'}> ) { set({ turnSeconds })} /> {kr && ( - )}