구십구: 코리아보드게임즈 공식 규칙으로 맞춤

- 공식 상품 페이지 규칙 설명 이미지 기준: 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:
EJClaw
2026-10-06 22:16:32 +09:00
parent d8b8cdd8f3
commit 7da06d1b27
10 changed files with 325 additions and 119 deletions

View File

@@ -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('탈락했어요');

View File

@@ -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로 만들어요.`);

View File

@@ -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>
);

View File

@@ -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)로 만들어요. 끝까지 남으면 이겨요.',
},
];