diff --git a/apps/web/src/games/baduk/help.test.ts b/apps/web/src/games/baduk/help.test.ts new file mode 100644 index 0000000..8a0b32e --- /dev/null +++ b/apps/web/src/games/baduk/help.test.ts @@ -0,0 +1,53 @@ +import { expect, test } from 'bun:test'; +import { applyAction, setupGame, usersOf } from '../help-kit.test-util'; +import { GAMES } from '@bg/games'; +import { helpNow } from './help'; + +const users = usersOf(['A', 'B']); +const view = (s: unknown, who: string | null) => GAMES.baduk!.view(s, who) as any; +const opts = { colorAssignment: 'hostBlack', boardSize: 9 }; + +test('바둑: 내 차례 / 상대 차례 / 관전 / 덤 안내', () => { + const { state } = setupGame('baduk', 2, opts); + const a = helpNow(view(state, 'A'), 'A', users); + expect(a).toContain('내 차례예요(흑)'); + expect(a).toContain('[✋ 패스]'); + expect(a).toContain('덤'); + expect(helpNow(view(state, 'B'), 'B', users)).toContain('상대(호랑이님) 차례예요'); + expect(helpNow(view(state, null), null, users)).toContain('호랑이님(흑) 차례예요'); + const s2 = applyAction('baduk', state, 'A', { type: 'place', x: 4, y: 4 }); + const b = helpNow(view(s2, 'B'), 'B', users); + expect(b).toContain('내 차례예요(백)'); + expect(b).toContain('따낸 돌은 흑 0개, 백 0개'); +}); + +test('바둑: 접바둑이면 백이 먼저 둬요', () => { + const { state } = setupGame('baduk', 2, { ...opts, handicap: 2 }); + expect(helpNow(view(state, 'B'), 'B', users)).toContain('접바둑이라 흑 돌 2개'); +}); + +test('바둑: 패스 → 사석 확인 → 동의', () => { + const { state } = setupGame('baduk', 2, opts); + let s = applyAction('baduk', state, 'A', { type: 'place', x: 4, y: 4 }); + s = applyAction('baduk', s, 'B', { type: 'pass' }); + expect(helpNow(view(s, 'A'), 'A', users)).toContain('상대가 패스했어요'); + expect(helpNow(view(s, 'B'), 'B', users)).toContain('내가 패스했어요'); + s = applyAction('baduk', s, 'A', { type: 'pass' }); + const a = helpNow(view(s, 'A'), 'A', users); + expect(a).toContain('[✕ 사석 표시]'); + expect(a).toContain('[✔ 이 표시대로 동의]'); + expect(a).toContain('흑 '); + s = applyAction('baduk', s, 'A', { type: 'agreeScore' }); + expect(helpNow(view(s, 'A'), 'A', users)).toContain('나는 동의했어요'); + expect(helpNow(view(s, null), null, users)).toContain('고양이님이 확인 중'); + s = applyAction('baduk', s, 'B', { type: 'agreeScore' }); + expect(helpNow(view(s, 'A'), 'A', users)).toContain('대국이 끝났어요'); +}); + +test('바둑: 무르기 요청을 받으면 수락/거절 안내', () => { + const { state } = setupGame('baduk', 2, opts); + let s = applyAction('baduk', state, 'A', { type: 'place', x: 4, y: 4 }); + s = applyAction('baduk', s, 'A', { type: 'requestUndo' }); + expect(helpNow(view(s, 'B'), 'B', users)).toContain('[수락]'); + expect(helpNow(view(s, 'A'), 'A', users)).toContain('[요청 취소]'); +}); diff --git a/apps/web/src/games/baduk/help.ts b/apps/web/src/games/baduk/help.ts new file mode 100644 index 0000000..471f32e --- /dev/null +++ b/apps/web/src/games/baduk/help.ts @@ -0,0 +1,77 @@ +/** 바둑 [?] "지금 할 수 있는 것" (docs/07 §5). Pure: only reads the client view. */ +import type { BadukView } from '@bg/games/baduk'; +import type { PublicUser } from '@bg/shared/lite'; + +const COLOR_KO = { black: '흑', white: '백' } as const; + +/** "2집 반" / "반집" (territory) or "1.5점" (area), like the result summary. */ +function margin(m: number, method: 'territory' | 'area'): string { + if (method === 'area') return `${m}점`; + const whole = Math.floor(m); + const half = m - whole >= 0.5; + if (whole === 0) return half ? '반집' : '0집'; + return `${whole}집${half ? ' 반' : ''}`; +} + +export function helpNow(v: BadukView, _me: string | null, players: Record): string { + if (v.phase === 'finished') { + const how = v.result?.score ? '\n판의 작은 네모는 누구의 집(영역)인지, ✕는 사석(죽은 돌)으로 센 돌이에요.' : ''; + return `대국이 끝났어요. ${v.result?.summaryKo ?? ''}`.trim() + how + '\n결과 창에서 [한 판 더]를 누르면 다시 할 수 있어요.'; + } + const nick = (c: 'black' | 'white') => `${players[v.players[c]]?.nickname ?? COLOR_KO[c]}님`; + const start = + v.moveCount === 0 + ? [ + v.rules.handicap ? `접바둑이라 흑 돌 ${v.rules.handicap}개가 미리 놓여 있고 백이 먼저 둬요.` : '', + v.rules.komi > 0 ? `백은 계가할 때 덤 ${margin(v.rules.komi, v.rules.scoring)}을 더 받아요(덤: 백에게 더 주는 점수).` : '', + ] + .filter(Boolean) + .join(' ') + : ''; + const caps = start || `따낸 돌은 흑 ${v.captures.black}개, 백 ${v.captures.white}개예요.`; + + if (v.phase === 'scoring' && v.scoring) { + const p = v.scoring.preview; + const unit = p.method === 'territory' ? '집 계산' : '영역 계산'; + const lead = p.diff === 0 ? '동점이에요' : `${p.diff > 0 ? '흑' : '백'}이 ${margin(Math.abs(p.diff), p.method)} 앞서요`; + const now = `지금 표시대로 세면(${unit}) 흑 ${p.black.total} : 백 ${p.white.total}, ${lead}.`; + const agreed = v.scoring.agreed; + if (!v.myColor) { + const who = (['black', 'white'] as const).filter((c) => !agreed[c]).map(nick); + return `두 사람이 연달아 패스해서 대국이 멈췄어요. 지금은 사석(죽은 돌) 확인 중이에요${who.length ? `(${who.join('·')}이 확인 중)` : ''}.\n${now}`; + } + if (agreed[v.myColor]) return `나는 동의했어요. 상대가 [✔ 이 표시대로 동의]를 누르면 계가가 끝나요.\n상대가 표시를 바꾸면 내 동의가 풀리니 다시 확인해 주세요.\n${now}`; + return [ + '두 사람이 연달아 패스해서 대국이 멈췄어요. 이제 사석(더는 살 수 없는 죽은 돌)을 정해요.', + '[✕ 사석 표시]를 고른 채 죽은 돌 묶음을 누르면 ✕가 붙고, 다시 누르면 풀려요. 처음 표시는 컴퓨터의 추정이라 틀릴 수 있어요.', + '빅(서로 잡을 수 없이 함께 사는 모양)이 있으면 [◆ 빅 표시]를 고르고 그 안의 빈 곳을 눌러요.', + `표시가 맞으면 [✔ 이 표시대로 동의]를 눌러요. 두 사람 모두 동의하면 끝나요. 의견이 다르면 [이의 – 대국 재개]로 돌아가 직접 둬서 가려요. 제한 시간이 지나면 지금 표시대로 동의한 것으로 봐요.`, + now, + ].join('\n'); + } + + if (!v.myColor) { + const passed = v.consecutivePasses > 0 ? ` ${nick(v.turn === 'black' ? 'white' : 'black')}이 방금 패스했어요.` : ''; + return `${nick(v.turn)}(${COLOR_KO[v.turn]}) 차례예요.${passed} 자기 돌로 둘러싼 빈 곳(집)이 더 많은 쪽이 이겨요.\n${caps}`; + } + const mine = COLOR_KO[v.myColor]; + if (v.undo.pending && v.undo.pending.by !== v.myColor) return `상대가 ${v.undo.pending.plies}수 무르기를 부탁했어요. [수락]을 누르면 돌과 따낸 돌이 그 전으로 돌아가고, [거절]을 누르면 그대로 계속해요.`; + if (v.draw.pendingBy && v.draw.pendingBy !== v.myColor) return '상대가 무승부를 제안했어요. [수락]하면 이 판은 비기고 끝나요. 계속하려면 [거절]을 눌러 주세요.'; + if (v.turn !== v.myColor) { + const lines = [`상대(${nick(v.turn)}) 차례예요. 상대가 둘 때까지 기다려 주세요. 나는 ${mine}이에요.`]; + if (v.consecutivePasses > 0) lines.push('내가 패스했어요. 상대도 패스하면 대국이 멈추고 사석 확인으로 넘어가요.'); + if (v.undo.pending || v.draw.pendingBy) lines.push(`내 ${v.undo.pending ? '무르기 요청' : '무승부 제안'}에 상대가 답하기를 기다리는 중이에요. 그만두려면 [요청 취소]를 눌러요.`); + lines.push(caps); + return lines.join('\n'); + } + const lines: string[] = []; + if (v.consecutivePasses > 0) { + lines.push(`내 차례예요(${mine}). 상대가 패스했어요. 더 둘 곳이 없으면 나도 [✋ 패스]를 눌러요. 그러면 대국이 멈추고 사석 확인으로 넘어가요. 더 둘 곳이 있으면 계속 둬도 돼요.`); + if (v.rules.scoring === 'area') lines.push('이 방은 영역 계산이라, 끝내기 전에 양쪽 사이의 빈 곳(공배)을 메워 두는 게 좋아요.'); + } else lines.push(`내 차례예요(${mine}). 선이 만나는 빈 점을 누르면 돌이 놓여요. 둘 곳이 없으면 [✋ 패스]를 눌러요.`); + if (v.atariWarnings.length) lines.push('빨갛게 깜빡이는 내 돌은 단수예요(숨 쉴 곳이 하나만 남음). 돌을 잇거나 달아나지 않으면 잡혀요.'); + if (v.undo.pending || v.draw.pendingBy) lines.push(`내 ${v.undo.pending ? '무르기 요청' : '무승부 제안'}에 상대가 답하기를 기다리는 중이에요. 그만두려면 [요청 취소]를 눌러요.`); + if (v.illegalPoints.some((p) => p.reason !== 'suicide')) lines.push('빨간 네모 자리는 패(바로 되따내기 금지)나 같은 모양 반복 때문에 지금은 둘 수 없어요. 다른 곳에 한 수 둔 뒤에 다시 봐요.'); + lines.push(caps); + return lines.join('\n'); +} diff --git a/apps/web/src/games/baduk/index.tsx b/apps/web/src/games/baduk/index.tsx index 757153a..ef69bec 100644 --- a/apps/web/src/games/baduk/index.tsx +++ b/apps/web/src/games/baduk/index.tsx @@ -6,6 +6,8 @@ import { Avatar, ConfirmSheet, Sheet } from '../../components/ui'; import { TurnTimer, useServerNow } from '../../components/TurnTimer'; import { haptic, sounds } from '../../lib/feedback'; import type { BoardProps, GameUI, OptionsProps } from '../types'; +import { helpNow } from './help'; +import { quickStart } from './quick'; type Color = 'black' | 'white'; const COLOR_KO: Record = { black: '흑', white: '백' }; @@ -854,6 +856,8 @@ const ui: GameUI = { Options, optionsSummary, Rules, + quickStart, + helpNow, }; export default ui; diff --git a/apps/web/src/games/baduk/quick.tsx b/apps/web/src/games/baduk/quick.tsx new file mode 100644 index 0000000..af065ef --- /dev/null +++ b/apps/web/src/games/baduk/quick.tsx @@ -0,0 +1,55 @@ +/** 바둑 30초 규칙 요약 (docs/07 §5). */ +import type { QuickCard } from '../types'; + +type St = [number, number, 'b' | 'w']; + +function MiniBoard({ stones, terr = [], marks = [] }: { stones: St[]; terr?: [number, number, 'b' | 'w'][]; marks?: [number, number][] }) { + const n = 7; + return ( + + + {Array.from({ length: n }, (_, i) => ( + + + + + ))} + {terr.map(([x, y, c]) => ( + + ))} + {stones.map(([x, y, c]) => ( + + ))} + {marks.map(([x, y]) => ( + + ))} + + ); +} + +/** Left part black, right part white, each surrounding empty points (small squares = territory). */ +const wallB: St[] = [0, 1, 2, 3, 4, 5, 6].map((y) => [2, y, 'b'] as St); +const wallW: St[] = [0, 1, 2, 3, 4, 5, 6].map((y) => [4, y, 'w'] as St); +const terrB: [number, number, 'b'][] = [0, 1].flatMap((x) => [0, 1, 2, 3, 4, 5, 6].map((y) => [x, y, 'b'] as [number, number, 'b'])); +const terrW: [number, number, 'w'][] = [5, 6].flatMap((x) => [0, 1, 2, 3, 4, 5, 6].map((y) => [x, y, 'w'] as [number, number, 'w'])); + +export const quickStart: QuickCard[] = [ + { + pic: , + alt: '바둑판의 선이 만나는 점 위에 검은 돌과 흰 돌이 놓여 있어요', + title: '번갈아 한 수씩 둬요', + text: '보통 흑이 먼저 시작해요. 내 차례에 선이 만나는 빈 점을 누르면 돌이 놓여요. 놓은 돌은 옮기지 않아요.', + }, + { + pic: , + alt: '흰 돌 하나를 검은 돌 네 개가 위아래 양옆에서 둘러싸고 있어요', + title: '둘러싸면 따내요', + text: '상대 돌의 위·아래·양옆 빈 곳(활로)을 모두 막으면 그 돌을 판에서 들어내요. 이걸 따낸다고 해요.', + }, + { + pic: , + alt: '검은 돌과 흰 돌이 각자 판의 한쪽을 울타리처럼 둘러싸고 빈 곳에 작은 네모가 있어요', + title: '집이 많으면 이겨요', + text: '둘 다 연달아 [✋ 패스]하면 끝나요. 내 돌로 둘러싼 빈 곳(집)이 더 많은 쪽이 이겨요. 처음이면 9줄 판을 추천해요.', + }, +]; diff --git a/apps/web/src/games/bang/help.test.ts b/apps/web/src/games/bang/help.test.ts new file mode 100644 index 0000000..0224bc3 --- /dev/null +++ b/apps/web/src/games/bang/help.test.ts @@ -0,0 +1,79 @@ +import { expect, test } from 'bun:test'; +import { applyAction, setupGame, usersOf } from '../help-kit.test-util'; +import { GAMES } from '@bg/games'; +import { CARDS } from '@bg/games/bang'; +import { helpNow } from './help'; + +const users = usersOf(['A', 'B', 'C', 'D']); +const view = (s: unknown, who: string | null) => GAMES.bang!.view(s, who) as any; +const nick = (id: string) => `${users[id]!.nickname}님`; +const cardOf = (kind: string) => CARDS.find((c) => c.kind === kind)!.id; + +/** Started game (sheriff to play); `who` holds exactly `hand`. */ +function game(handFor?: { who: (s: any) => string; hand: string[] }) { + const { state } = setupGame('bang', 4, { characterDraft: 'random' }); + const s = structuredClone(state); + const sheriff: string = s.players.find((p: any) => p.role === 'sheriff').id; + const other: string = s.players.find((p: any) => p.id !== sheriff).id; + // Plain characters so no draw-phase ability or unlimited 뱅! changes the texts. + for (const p of s.players) p.character = 'paul'; + if (handFor) { + const who = handFor.who({ sheriff, other }); + for (const p of s.players) p.hand = p.hand.filter((id: string) => !handFor.hand.includes(id)); + s.deck = s.deck.filter((id: string) => !handFor.hand.includes(id)); + s.players.find((p: any) => p.id === who).hand = [...handFor.hand]; + } + return { s, sheriff, other }; +} + +test('뱅!: 내 차례(카드 내기) / 남의 차례 / 관전은 보안관만 공개', () => { + const { s, sheriff, other } = game(); + expect(s.turn.phase).toBe('play'); + const mine = helpNow(view(s, sheriff), sheriff, users); + expect(mine).toContain('[턴 마치기]'); + expect(mine).toContain('사거리'); + expect(mine).toContain('한 턴에 1번'); + expect(helpNow(view(s, other), other, users)).toContain(`${nick(sheriff)} 차례예요`); + const spec = helpNow(view(s, null), null, users); + expect(spec).toContain(`⭐ 보안관(${nick(sheriff)})만 역할이 공개돼요`); + for (const role of ['부관', '무법자', '배신자']) expect(spec).not.toContain(role); + // My own hidden role is not spelled out either. + const otherText = helpNow(view(s, other), other, users); + for (const role of ['부관', '무법자', '배신자']) expect(otherText).not.toContain(role); +}); + +test('뱅!: 뱅!을 받으면 빗나감!으로 막거나 맞기', () => { + const missed = cardOf('missed'); + const { s, sheriff, other } = game({ who: (x) => x.other, hand: [missed] }); + s.reactions = [{ kind: 'bang', target: other, source: sheriff, via: 'bang', needed: 1, barrelTried: false, jourTried: false, deadline: 1_700_000_015_000 }]; + const t = helpNow(view(s, other), other, users); + expect(t).toContain(`${nick(sheriff)}이 쏜 뱅!이 날아왔어요`); + expect(t).toContain('[빗나감! 내기]'); + expect(t).toContain('[맞기 (생명 −1)]'); + expect(helpNow(view(s, null), null, users)).toContain(`${nick(other)}이 ${nick(sheriff)}의 뱅!을 빗나감!으로 막을지`); + expect(helpNow(view(s, sheriff), sheriff, users)).toContain('다른 사람의 반응을 기다리는 중'); + // Answering ends the reaction window. + const s2 = applyAction('bang', s, other, { type: 'respond', cardIds: [missed] }); + expect(helpNow(view(s2, sheriff), sheriff, users)).toContain('[턴 마치기]'); +}); + +test('뱅!: 쓰러지기 직전 / 버리기 / 캐릭터 고르기', () => { + const beer = cardOf('beer'); + const { s, sheriff, other } = game({ who: (x) => x.other, hand: [beer] }); + const o = s.players.find((p: any) => p.id === other); + o.life = 0; + s.reactions = [{ kind: 'dying', player: other, killer: sheriff, lost: 1, source: sheriff, deadline: 1_700_000_015_000 }]; + expect(helpNow(view(s, other), other, users)).toContain('[맥주 1장 마시기]'); + + const d = game().s; + const me = d.players.find((p: any) => p.id === d.turn.player); + me.life = 1; + d.turn.phase = 'discard'; + const need = me.hand.length - 1; + expect(helpNow(view(d, me.id), me.id, users)).toContain(`[${need}장 버리기]`); + + const { state: c } = setupGame('bang', 4, { characterDraft: 'choose2' }); + expect(helpNow(view(c, 'A'), 'A', users)).toContain('캐릭터 2명 중 1명'); + const c2 = applyAction('bang', c, 'A', { type: 'pickCharacter', character: c.players.find((p: any) => p.id === 'A').characterOffer[0] }); + expect(helpNow(view(c2, 'A'), 'A', users)).toContain('모두 캐릭터를 고르는 중'); +}); diff --git a/apps/web/src/games/bang/help.ts b/apps/web/src/games/bang/help.ts new file mode 100644 index 0000000..bda8e8f --- /dev/null +++ b/apps/web/src/games/bang/help.ts @@ -0,0 +1,134 @@ +/** + * 뱅! [?] "지금 할 수 있는 것" (docs/07 §5). Pure: only reads the client view. + * Roles stay secret: only the sheriff (public to everyone) is ever named; my own role is not spelled + * out either, because the screen only shows it on the hold-to-see card. + */ +import type { BangView, ReactionView } from '@bg/games/bang'; +import type { PublicUser } from '@bg/shared/lite'; + +/** The two card names the reaction buttons use (display names otherwise live in index.tsx). */ +const NAME = { bang: '뱅!', missed: '빗나감!' } as const; + +export function helpNow(v: BangView, me: string | null, players: Record): string { + const n = (id: string | null | undefined) => `${(id && players[id]?.nickname) || '다른 사람'}님`; + if (v.result || v.winner) { + return `게임이 끝났어요. ${v.result?.summary ?? ''}`.trim() + '\n이제 모든 사람의 역할이 공개돼요. 결과 창에서 [한 판 더]를 누르면 다시 할 수 있어요.'; + } + const my = v.my; + const meP = my ? v.players.find((p) => p.id === me) : undefined; + const sheriff = v.players.find((p) => p.role === 'sheriff'); + const tip = `⭐ 보안관${sheriff ? `(${n(sheriff.id)})` : ''}만 역할이 공개돼요. 다른 사람의 역할은 쓰러질 때 공개돼요.`; + + // Who the game is waiting for, in neutral words (spectators, dead players, everyone waiting). + const waitingText = (rs: ReactionView[]): string => { + const groups = new Map(); + for (const r of rs) { + const key = `${r.kind}-${r.source ?? ''}-${r.via ?? ''}`; + const g = groups.get(key) ?? { r, who: [] }; + if (!g.who.includes(r.who)) g.who.push(r.who); + groups.set(key, g); + } + return [...groups.values()] + .map(({ r, who }) => { + const w = who.map(n).join(', '); + switch (r.kind) { + case 'bang': + return `${w}이 ${n(r.source)}의 ${r.via === 'gatling' ? '개틀링' : '뱅!'}을 빗나감!으로 막을지 고르는 중이에요.`; + case 'indians': + return `${w}이 인디언!에 뱅!을 버릴지 고르는 중이에요.`; + case 'duel': + return `${w}이 결투에서 뱅!을 낼 차례예요.`; + case 'store': + return `${w}이 잡화점에서 카드를 고르는 중이에요.`; + case 'dying': + return `${w}이 쓰러지기 직전이에요. 맥주가 있으면 살아날 수 있어요.`; + case 'kitKeep': + return `${w}이 받을 카드를 고르는 중이에요.`; + case 'choose2': + return `${w}이 캐릭터를 고르는 중이에요.`; + } + return ''; + }) + .join('\n'); + }; + const situation = (): string => { + if (v.phase === 'draft') return '모두 캐릭터를 고르는 중이에요.'; + if (v.reactions.length) return `${n(v.current)} 차례예요.\n${waitingText(v.reactions)}`; + return `${n(v.current)} 차례예요. 카드를 내며 공격하거나 준비하고 있어요.`; + }; + + if (!my) return `${situation()}\n${tip}`; + if (!meP?.alive) return `나는 쓰러졌어요. 남은 게임을 지켜봐요.\n${situation()}`; + + const life = `지금 내 생명: ${meP.life}/${meP.maxLife}.`; + const sid = my.canSidHeal ? '\n시드 케첨 능력으로 카드 2장을 버리고 생명 1을 회복할 수도 있어요.' : ''; + const p = my.prompt; + if (p) { + switch (p.kind) { + case 'choose2': + return '캐릭터 2명 중 1명을 눌러 골라요. 이름 아래에 능력이 적혀 있어요.\n고른 캐릭터에 따라 생명 수와 특별한 능력이 달라요.'; + case 'bang': { + const what = p.via === 'gatling' ? `${n(p.source)}의 개틀링` : `${n(p.source)}이 쏜 뱅!`; + const hand = p.options.map((id) => my.hand.find((c) => c.id === id)!).filter(Boolean); + hand.sort((a, b) => Number(a.kind !== 'missed') - Number(b.kind !== 'missed')); + const use = hand.slice(0, p.needed); + const need = p.needed > 1 ? ` 이 뱅!은 빗나감! ${p.needed}장이 있어야 막을 수 있어요.` : ''; + const lines = [`${what}이 날아왔어요!${need}`]; + if (use.length >= p.needed) { + const label = use.map((c) => NAME[c.kind as 'bang' | 'missed'] ?? '빗나감!').join(' + '); + lines.push(`[${label} 내기]를 누르면 막아요. 막지 않으려면 [맞기 (생명 −1)]를 눌러요.`); + if (v.options.autoDefend) lines.push('시간 안에 고르지 않으면 막을 카드를 자동으로 내요.'); + } else lines.push(`막을 카드가 ${p.options.length > 0 ? '모자라요' : '없어요'}. [맞기 (생명 −1)]를 눌러요.`); + if (p.canBarrel) lines.push('[술통 판정하기]를 누르면 카드를 한 장 뒤집어 ♥가 나올 때 피해요.'); + lines.push(life); + return lines.join('\n') + sid; + } + case 'indians': + case 'duel': { + const card = p.options.map((id) => my.hand.find((c) => c.id === id)!).filter(Boolean).sort((a, b) => Number(a.kind !== 'bang') - Number(b.kind !== 'bang'))[0]; + const give = card ? `[${NAME[card.kind as 'bang' | 'missed'] ?? '뱅!'} 내기]` : ''; + const hit = p.kind === 'duel' ? '[포기하고 맞기 (생명 −1)]' : '[맞기 (생명 −1)]'; + const head = + p.kind === 'indians' + ? `${n(p.source)}의 인디언!이에요. 뱅! 1장을 버리거나 생명 1을 잃어요.` + : `${n(p.opponent)}과 결투 중이에요. 번갈아 뱅!을 내고, 먼저 못 내는 사람이 생명 1을 잃어요.`; + const how = card ? `${give}를 누르거나 ${hit}를 눌러요.` : `낼 뱅!이 없어요. ${hit}를 눌러요.`; + return `${head}\n${how}\n${life}${sid}`; + } + case 'dying': { + if (!p.beerAllowed) return `생명이 다 떨어졌어요. 두 명만 남아서 맥주로는 살아날 수 없어요. [쓰러지기]를 눌러요.${sid}`; + if (p.beers.length >= p.needed) return `생명이 다 떨어졌어요! [맥주 ${p.needed}장 마시기]를 누르면 살아나요. 아니면 [쓰러지기]예요.${sid}`; + return `생명이 다 떨어졌어요. 살아나려면 맥주 ${p.needed}장이 필요한데 모자라요. [쓰러지기]를 눌러요.${sid}`; + } + case 'store': + return '잡화점이에요. 펼쳐진 카드 중 1장을 눌러 가져와요. 순서대로 한 사람씩 1장씩 가져가요.'; + case 'kitKeep': + return '카드 3장 중 받을 2장을 눌러 고른 뒤 [이 2장 받기]를 눌러요. 남은 1장은 카드 더미 맨 위로 돌아가요.'; + case 'draw': { + const alt = [p.pedro ? '버린 카드 맨 위 1장' : '', p.jesseTargets.length ? '다른 사람 손패에서 무작위 1장' : ''].filter(Boolean).join(' 또는 '); + return `내 차례예요. 이번 턴 카드 2장을 받아요.\n[카드 더미에서 2장 받기]를 누르세요.${alt ? ` 캐릭터 능력으로 첫 1장은 ${alt}으로 받을 수도 있어요.` : ''}`; + } + case 'discard': + return `턴을 마치려면 손패를 생명 수(${meP.life}장)까지 줄여야 해요.\n버릴 카드 ${p.count}장을 눌러 고르고 [${p.count}장 버리기]를 눌러요.${sid}`; + } + } + + if (v.phase === 'draft') return `${situation()} 다 고르면 보안관부터 시작해요.`; + const mine = v.current === me; + if (mine && v.reactions.length) return `내 차례예요. 다른 사람의 반응을 기다리는 중이에요.\n${waitingText(v.reactions)}`; + if (mine && v.phase === 'play') { + const usable = my.plays.filter((o) => o.ok).length; + const lines = [ + usable > 0 + ? '내 차례예요. 초록 테두리 카드를 누른 뒤 아래 버튼으로 내요. 대상이 필요한 카드는 초록 테두리 사람을 먼저 눌러요.' + : '내 차례예요. 지금 낼 수 있는 카드가 없어요. [턴 마치기]를 눌러요.', + ]; + if (usable > 0) lines.push('흐린 카드를 누르면 지금 쓸 수 없는 이유를 알려 줘요. 다 냈으면 [턴 마치기]를 눌러요.'); + const bang = my.unlimitedBang ? '뱅!은 이번 턴에 몇 번이든 쓸 수 있어요.' : v.bangsPlayed > 0 ? '이번 턴에는 뱅!을 이미 썼어요(한 턴에 1번).' : '뱅!은 한 턴에 1번만 쓸 수 있어요.'; + lines.push(`내 사거리(${my.range})보다 거리가 먼 사람은 뱅!으로 쏠 수 없어요. ${bang}`); + lines.push(`${life} 턴이 끝날 때 손패는 생명 수까지만 남길 수 있어요.${sid}`); + return lines.join('\n'); + } + if (mine) return `내 차례예요. 잠시만 기다려 주세요.\n${life}`; + return `${situation()}\n기다리는 동안 공격을 받으면 막을지 묻는 창이 떠요. ${life}`; +} diff --git a/apps/web/src/games/bang/index.tsx b/apps/web/src/games/bang/index.tsx index 3feca9c..95f33e8 100644 --- a/apps/web/src/games/bang/index.tsx +++ b/apps/web/src/games/bang/index.tsx @@ -6,6 +6,8 @@ import { Avatar } from '../../components/ui'; import { TurnTimer } from '../../components/TurnTimer'; import { haptic, sounds } from '../../lib/feedback'; import type { BoardProps, GameUI, OptionsProps } from '../types'; +import { helpNow } from './help'; +import { quickStart } from './quick'; // ---------------------------------------------------------------- names (display names live only here, docs §12) @@ -1280,6 +1282,8 @@ const ui: GameUI = { Options, optionsSummary, Rules, + quickStart, + helpNow, }; export default ui; diff --git a/apps/web/src/games/bang/quick.tsx b/apps/web/src/games/bang/quick.tsx new file mode 100644 index 0000000..80ad1f8 --- /dev/null +++ b/apps/web/src/games/bang/quick.tsx @@ -0,0 +1,92 @@ +/** 뱅! 30초 규칙 요약 (docs/07 §5). */ +import type { QuickCard } from '../types'; + +/** A small play card: brown (instant) or blue (stays in front of you). */ +function Mini({ icon, name, blue = false, back = false }: { icon: string; name: string; blue?: boolean; back?: boolean }) { + return ( +
+ {!back && {icon}} + {!back && {name}} +
+ ); +} + +const row = { display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8, width: 250, height: 150 } as const; +const arrow = { fontSize: 22, fontWeight: 900, color: '#374151' } as const; + +function Badge({ icon, name, color, hidden = false }: { icon: string; name: string; color: string; hidden?: boolean }) { + return ( +
+ {hidden ? '🂠' : icon} + +
+ ); +} + +export const quickStart: QuickCard[] = [ + { + pic: ( +
+
+ + +
+ ➜ + + +
+ ), + alt: '카드 2장을 받은 뒤 뱅! 카드를 내고 파란 술통 카드를 앞에 놓아요', + title: '받고, 내고, 마쳐요', + text: '내 차례엔 카드 2장을 받고, 원하는 만큼 카드를 내요. 파란 카드는 내 앞에 놓여 계속 효과가 있어요. 다 냈으면 [턴 마치기].', + }, + { + pic: ( +
+
+ 🤠 + +
+
+ ➜ + 거리 1 +
+
+ 🤠 + +
+
+ ), + alt: '한 사람이 뱅!을 쏘고, 거리 1인 상대가 빗나감!으로 막아요', + title: '뱅!은 빗나감!으로 막아요', + text: '뱅!은 사거리 안의 한 명을 쏴요(한 턴에 1번). 맞은 사람은 빗나감!을 내서 막거나 생명 1을 잃어요. 생명이 다 떨어지면 쓰러져요.', + }, + { + pic: ( +
+ +
+ ), + alt: '보안관만 공개되어 있고 다른 사람들의 역할은 비밀로 가려져 있어요', + title: '역할마다 목표가 달라요', + text: '⭐보안관만 공개돼요. 보안관·부관은 무법자와 배신자를, 무법자는 보안관을 쓰러뜨리면 이겨요. 배신자는 끝까지 혼자 남아야 해요.', + }, +]; diff --git a/apps/web/src/games/catchmind/help.test.ts b/apps/web/src/games/catchmind/help.test.ts new file mode 100644 index 0000000..0cd2d60 --- /dev/null +++ b/apps/web/src/games/catchmind/help.test.ts @@ -0,0 +1,59 @@ +import { expect, test } from 'bun:test'; +import { applyAction, setupGame, usersOf } from '../help-kit.test-util'; +import { GAMES } from '@bg/games'; +import { helpNow } from './help'; + +const users = usersOf(['A', 'B', 'C']); +const view = (s: unknown, who: string | null) => GAMES.catchmind!.view(s, who) as any; + +test('그림 맞히기: 단어 고르기 — 출제자 / 맞히는 사람 / 관전', () => { + const { state } = setupGame('catchmind', 3); + const d: string = state.turn.drawer; + const g = ['A', 'B', 'C'].find((p) => p !== d)!; + const dn = users[d]!.nickname; + expect(helpNow(view(state, d), d, users)).toContain('단어 하나를 눌러 골라요'); + const gt = helpNow(view(state, g), g, users); + expect(gt).toContain(`${dn}님이 그릴 단어를 고르고 있어요`); + expect(gt).not.toContain(state.turn.choices[0].word); + expect(helpNow(view(state, null), null, users)).toContain(`${dn}님이 그릴 단어를 고르고 있어요`); +}); + +test('그림 맞히기: 그리기 — 출제자는 단어를 보고, 맞히는 사람은 힌트만', () => { + const { state } = setupGame('catchmind', 3); + const d: string = state.turn.drawer; + const [g1, g2] = ['A', 'B', 'C'].filter((p) => p !== d) as [string, string]; + const s = applyAction('catchmind', state, d, { type: 'chooseWord', index: 0 }); + const word: string = s.turn.word.word; + const dt = helpNow(view(s, d), d, users); + expect(dt).toContain(`"${word}"`); + expect(dt).toContain('글자나 숫자는 쓰지 말고'); + expect(dt).toContain('[거의 다 왔어요]'); + const gt = helpNow(view(s, g1), g1, users); + expect(gt).toContain('[맞히기]'); + expect(gt).toContain('글자예요'); + expect(gt).not.toContain(word); + const spec = helpNow(view(s, null), null, users); + expect(spec).toContain('그림을 그리고 있어요'); + expect(spec).not.toContain(word); + + const s2 = applyAction('catchmind', s, g1, { type: 'guess', text: word }); + const st = helpNow(view(s2, g1), g1, users); + expect(st).toContain('맞혔어요!'); + expect(st).toContain('이미 맞힌 사람에게만'); + expect(helpNow(view(s2, g2), g2, users)).not.toContain(word); +}); + +test('그림 맞히기: 공개 / 끝', () => { + const { state } = setupGame('catchmind', 2, { guessMode: 'firstOnly' }); + const d: string = state.turn.drawer; + const g = d === 'A' ? 'B' : 'A'; + let s = applyAction('catchmind', state, d, { type: 'chooseWord', index: 0 }); + const word: string = s.turn.word.word; + expect(helpNow(view(s, g), g, users)).toContain('가장 먼저 맞힌 사람만'); + s = applyAction('catchmind', s, g, { type: 'guess', text: word }); + expect(s.turn.phase).toBe('reveal'); + expect(helpNow(view(s, g), g, users)).toContain(`정답은 "${word}"였어요`); + s.finished = true; + s.final = { ranking: [], turns: [], summary: '호랑이님 우승!' }; + expect(helpNow(view(s, null), null, users)).toContain('호랑이님 우승!'); +}); diff --git a/apps/web/src/games/catchmind/help.ts b/apps/web/src/games/catchmind/help.ts new file mode 100644 index 0000000..1a7f729 --- /dev/null +++ b/apps/web/src/games/catchmind/help.ts @@ -0,0 +1,48 @@ +/** 그림 맞히기 [?] "지금 할 수 있는 것" (docs/07 §5). Pure: only reads the client view. */ +import type { CatchmindView } from '@bg/games/catchmind'; +import type { PublicUser } from '@bg/shared/lite'; + +function hintLine(v: CatchmindView): string { + if (!v.hint) return '이 방은 힌트가 없어요.'; + const letters = v.hint.filter((c) => c !== ' ').length; + const more = v.options.hints === 'full' ? ' 시간이 지나면 첫소리(초성)나 글자 하나가 더 보일 수 있어요.' : ''; + return `그림 위 힌트 칸을 보면 정답은 ${letters}글자예요.${more}`; +} + +export function helpNow(v: CatchmindView, me: string | null, players: Record): string { + const nick = (id: string) => players[id]?.nickname ?? '다른 사람'; + const drawer = `${nick(v.drawer)}님`; + const turn = `${v.round}/${v.rounds}라운드`; + if (v.phase === 'ended') return `게임이 끝났어요. ${v.result?.summary ?? ''}`.trim() + '\n최종 순위와 지난 문제 목록에서 누가 무엇을 맞혔는지 볼 수 있어요.'; + const away = !!me && v.away.includes(me) ? '\n자리 비움으로 표시돼 그릴 차례를 건너뛰고 있어요. [돌아왔어요]를 누르면 다시 차례가 와요.' : ''; + const left = !!me && v.left.includes(me); + if (v.phase === 'reveal') { + const ans = v.word ? `정답은 "${v.word}"였어요. ` : ''; + const chat = v.canChat ? ' 아래 채팅 칸에서 이야기를 나눌 수 있어요.' : ''; + return `이번 그림이 끝났어요. ${ans}곧 다음 사람이 그려요.${chat}${away}`; + } + if (v.role === 'spectator' || left) { + if (v.phase === 'choosing') return `${turn} · ${drawer}이 그릴 단어를 고르고 있어요. 곧 그림이 시작돼요.`; + return `${turn} · ${drawer}이 그림을 그리고 있어요. 다른 사람들이 채팅으로 정답을 맞히는 중이에요.`; + } + if (v.role === 'drawer') { + if (v.phase === 'choosing') { + const pick = v.choices && v.choices.length > 1 ? '그림판 위 단어 버튼 중 그릴 단어 하나를 눌러 골라요. 10초 안에 안 고르면 첫 번째 단어로 정해져요.' : '그릴 단어를 눌러 정해요.'; + return `내가 그릴 차례예요. ${pick}\n단어는 나만 볼 수 있어요.`; + } + const word = v.word ? `그릴 단어는 "${v.word}"예요(나와 맞힌 사람만 보여요). ` : ''; + const report = v.options.letterReport ? ' 글자를 쓰면 신고를 받아 0점이 될 수 있어요.' : ''; + return `${word}그림판에 손가락이나 마우스로 그려요. 아래 버튼으로 색, 굵기, 지우개(🧽), 되돌리기(↶), 전체 지우기(🗑)를 쓸 수 있어요.\n글자나 숫자는 쓰지 말고 그림으로만 설명해요.${report}\n그리는 동안 채팅은 못 하고 [거의 다 왔어요]·[비슷해요]·[아니에요] 반응 버튼만 보낼 수 있어요.`; + } + // guesser / solver + if (v.phase === 'choosing') return `${turn} · ${drawer}이 그릴 단어를 고르고 있어요. 그림이 시작되면 보고 정답을 입력해요.${v.canChat ? ' 지금은 채팅만 할 수 있어요.' : ''}${away}`; + if (v.role === 'solver') { + const word = v.word ? ` 정답은 "${v.word}"예요.` : ''; + return `맞혔어요!${word} 다른 사람들이 맞힐 때까지 기다려요.\n지금 보내는 채팅은 그린 사람과 이미 맞힌 사람에게만 보여요. 정답은 말하지 마세요.${away}`; + } + const pts = v.options.guessMode === 'firstOnly' ? '가장 먼저 맞힌 사람만 점수를 받고 바로 다음 그림으로 넘어가요.' : '먼저 맞힐수록 점수가 높아요(10, 8, 6, 4점...).'; + const lines = [`${drawer}이 그리고 있어요. 그림을 보고 생각나는 답을 아래 칸에 써서 [맞히기]를 눌러요. 여러 번 써도 돼요. ${pts}`, hintLine(v)]; + if (v.canReport) lines.push(`그림 대신 글자를 썼다면 [🚩 글자를 썼어요]로 신고할 수 있어요(지금 ${v.reports}/${v.reportsNeeded}명).`); + else if (v.reportedByMe) lines.push(`신고했어요(${v.reports}/${v.reportsNeeded}명). 절반이 넘으면 이번 그림이 끝나요.`); + return lines.join('\n') + away; +} diff --git a/apps/web/src/games/catchmind/index.tsx b/apps/web/src/games/catchmind/index.tsx index 50db193..6b2068b 100644 --- a/apps/web/src/games/catchmind/index.tsx +++ b/apps/web/src/games/catchmind/index.tsx @@ -8,6 +8,8 @@ import { haptic, sounds } from '../../lib/feedback'; import { useWide } from '../../lib/media'; import { socket } from '../../net/socket'; import type { BoardProps, GameUI, OptionsProps } from '../types'; +import { helpNow } from './help'; +import { quickStart } from './quick'; // ---------------------------------------------------------------- constants (mirror packages/games/src/catchmind/types.ts) @@ -1239,6 +1241,8 @@ const ui: GameUI = { Options, optionsSummary, Rules, + quickStart, + helpNow, ownChat: true, }; diff --git a/apps/web/src/games/catchmind/quick.tsx b/apps/web/src/games/catchmind/quick.tsx new file mode 100644 index 0000000..9599e4d --- /dev/null +++ b/apps/web/src/games/catchmind/quick.tsx @@ -0,0 +1,130 @@ +/** 그림 맞히기 30초 규칙 요약 (docs/07 §5). */ +import type { ReactNode } from 'react'; +import type { QuickCard } from '../types'; + +function Pic({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} + +/** White drawing paper with a simple cat doodle. */ +function Paper({ x, y, w = 120, blank = false }: { x: number; y: number; w?: number; blank?: boolean }) { + const k = w / 120; + return ( + + + {!blank && } + + ); +} + +function Cat() { + return ( + <> + + + + + + + + + + + ); +} + +function Bubble({ x, y, text, ok }: { x: number; y: number; text: string; ok?: boolean }) { + return ( + + + + {text} + + + ); +} + +export const quickStart: QuickCard[] = [ + { + pic: ( + + + + 🖍 + + + + + + ), + alt: '그림판에 고양이 그림이 있고, 옆에 사람들이 정답을 써 보낸 말풍선이 있어요', + title: '한 명이 그리고 모두 맞혀요', + text: '차례대로 한 사람이 단어를 그림으로 그리고, 나머지는 그림을 보고 정답을 채팅 칸에 써서 맞혀요.', + }, + { + pic: ( + + + + 고양이 + + + + + + + {['○', '○', '○'].map((c, i) => ( + + + + {c} + + + ))} + {['ㄱ', 'ㅇ', 'ㅇ'].map((c, i) => ( + + + + {c} + + + ))} + + + ), + alt: '그림판에 글자를 쓴 것에 빨간 ✕ 표시가 있고, 옆에 글자 수와 첫소리 힌트 칸이 있어요', + title: '글자는 쓰면 안 돼요', + text: '그리는 사람만 단어를 보고, 글자나 숫자 없이 그림으로만 설명해요. 방 설정에 따라 글자 수·첫소리 힌트가 보여요.', + }, + { + pic: ( + + {[ + ['1등', '+10', 48], + ['2등', '+8', 125], + ['3등', '+6', 202], + ].map(([a, b, x]) => ( + + + + {a} + + + {b} + + + ))} + + 빨리 맞힐수록 점수가 커요 + + + ), + alt: '먼저 맞힌 순서대로 1등 10점, 2등 8점, 3등 6점을 받는 모습이에요', + title: '빨리 맞히면 점수가 커요', + text: '먼저 맞힐수록 점수를 많이 받고, 누가 맞히면 그린 사람도 점수를 받아요. 모두 다 그린 뒤 점수가 가장 높으면 우승!', + }, +]; diff --git a/apps/web/src/games/chess/help.test.ts b/apps/web/src/games/chess/help.test.ts new file mode 100644 index 0000000..ba82026 --- /dev/null +++ b/apps/web/src/games/chess/help.test.ts @@ -0,0 +1,53 @@ +import { expect, test } from 'bun:test'; +import { SeededRng } from '../../../../../packages/engine/src/rng'; +import { applyAction, setupGame, usersOf } from '../help-kit.test-util'; +import { GAMES } from '@bg/games'; +import { helpNow } from './help'; + +const users = usersOf(['A', 'B']); +const view = (s: unknown, who: string | null) => GAMES.chess!.view(s, who) as any; +// Act a second after the previous action so the first-move limit (대국 취소) never fires. +let clock = 1_700_000_000_000; +const act = (s: any, who: string, a: unknown) => applyAction('chess', s, who, a, SeededRng.fromSeed([9, 9, 9, 9]), (clock += 1000)); +const move = (s: any, who: string, from: string, to: string) => act(s, who, { type: 'move', from, to }); + +test('체스: 내 차례(첫 수) / 상대 차례 / 관전', () => { + const { state } = setupGame('chess', 2, { colorAssignment: 'hostWhite', hints: 'moves' }); + const mine = helpNow(view(state, 'A'), 'A', users); + expect(mine).toContain('내 차례예요(백)'); + expect(mine).toContain('점이 찍혀요'); + expect(mine).toContain('대국이 취소돼요'); + expect(helpNow(view(state, 'B'), 'B', users)).toContain('상대(호랑이님) 차례예요'); + expect(helpNow(view(state, null), null, users)).toContain('호랑이님(백) 차례예요'); +}); + +test('체스: 체크를 받은 쪽과 건 쪽, 관전자', () => { + const { state } = setupGame('chess', 2, { colorAssignment: 'hostWhite' }); + let s = move(state, 'A', 'e2', 'e4'); + s = move(s, 'B', 'f7', 'f6'); + s = move(s, 'A', 'd1', 'h5'); + expect(helpNow(view(s, 'B'), 'B', users)).toContain('체크예요!'); + expect(helpNow(view(s, 'A'), 'A', users)).toContain('체크를 걸었어요'); + expect(helpNow(view(s, null), null, users)).toContain('흑 킹이 체크'); +}); + +test('체스: 승진 안내(선택 창 / 자동 퀸)와 기물 점수', () => { + for (const autoQueen of [false, true]) { + const { state } = setupGame('chess', 2, { colorAssignment: 'hostWhite', autoQueen }); + const s = structuredClone(state); + s.fen = '4k3/P7/8/8/8/8/8/4K3 w - - 0 1'; + const t = helpNow(view(s, 'A'), 'A', users); + expect(t).toContain(autoQueen ? '자동으로 퀸' : '고르는 창'); + expect(t).toContain('백이 1점 앞서요'); + } +}); + +test('체스: 무승부 제안을 받으면 수락/거절, 끝나면 결과', () => { + const { state } = setupGame('chess', 2, { colorAssignment: 'hostWhite', drawOffer: true }); + let s = move(state, 'A', 'e2', 'e4'); + s = act(s, 'A', { type: 'offerDraw' }); + expect(helpNow(view(s, 'B'), 'B', users)).toContain('무승부를 제안'); + expect(helpNow(view(s, 'A'), 'A', users)).toContain('[요청 취소]'); + s = act(s, 'B', { type: 'respondDraw', accept: true }); + expect(helpNow(view(s, null), null, users)).toContain('게임이 끝났어요. 무승부'); +}); diff --git a/apps/web/src/games/chess/help.ts b/apps/web/src/games/chess/help.ts new file mode 100644 index 0000000..21e7a23 --- /dev/null +++ b/apps/web/src/games/chess/help.ts @@ -0,0 +1,51 @@ +/** 체스 [?] "지금 할 수 있는 것" (docs/07 §5). Pure: only reads the client view. */ +import type { ChessView } from '@bg/games/chess'; +import type { PublicUser } from '@bg/shared/lite'; + +const COLOR_KO = { white: '백', black: '흑' } as const; + +/** Material lead as shown next to the player bars ("+3"). */ +function material(v: ChessView): string { + if (v.materialDiff === 0) return ''; + const lead = v.materialDiff > 0 ? 'white' : 'black'; + return `기물 점수는 ${COLOR_KO[lead]}이 ${Math.abs(v.materialDiff)}점 앞서요(퀸 9, 룩 5, 비숍·나이트 3, 폰 1).`; +} + +export function helpNow(v: ChessView, _me: string | null, players: Record): string { + if (v.phase === 'finished') return `게임이 끝났어요. ${v.result?.summary ?? ''}`.trim() + '\n결과 창에서 [한 판 더]를 누르면 다시 할 수 있어요.'; + const turnNick = players[v.players[v.turn]]?.nickname ?? COLOR_KO[v.turn]; + const mat = material(v); + if (!v.myColor) { + const check = v.checkSquare ? ` ${COLOR_KO[v.turn]} 킹이 체크(공격)를 받고 있어요.` : ''; + return [`${turnNick}님(${COLOR_KO[v.turn]}) 차례예요.${check} 상대 킹을 피할 곳 없이 잡히게 만들면(체크메이트) 이겨요.`, mat].filter(Boolean).join('\n'); + } + const mine = COLOR_KO[v.myColor]; + if (v.undo.pending && v.undo.pending.by !== v.myColor) + return `상대가 ${v.undo.pending.plies}수 무르기를 부탁했어요. [수락]을 누르면 기물이 되돌아가고(시계는 그대로), [거절]을 누르면 그대로 계속해요.`; + if (v.draw.pendingBy && v.draw.pendingBy !== v.myColor) return '상대가 무승부를 제안했어요. [수락]하면 이 판은 비기고 끝나요. 계속하려면 [거절]을 눌러 주세요.'; + if (v.myColor !== v.turn) { + const lines = [`상대(${turnNick}님) 차례예요. 상대가 둘 때까지 기다려 주세요. 나는 ${mine}이에요.`]; + if (v.checkSquare) lines.push('내가 상대 킹에 체크를 걸었어요. 상대는 킹을 지켜야 해요.'); + if (v.undo.pending || v.draw.pendingBy) lines.push(`내 ${v.undo.pending ? '무르기 요청' : '무승부 제안'}에 상대가 답하기를 기다리는 중이에요. 그만두려면 [요청 취소]를 눌러요.`); + if (mat) lines.push(mat); + return lines.join('\n'); + } + const lines: string[] = []; + if (v.checkSquare) lines.push('체크예요! 내 킹이 공격받고 있어요(빨갛게 빛나는 칸). 킹을 피하거나, 사이를 막거나, 공격한 기물을 잡는 수만 둘 수 있어요.'); + else lines.push(`내 차례예요(${mine}).`); + lines.push( + v.hints === 'off' + ? '움직일 기물을 누른 뒤 갈 칸을 누르거나, 기물을 끌어다 놓아요.' + : '움직일 기물을 누르면 갈 수 있는 칸에 점이 찍혀요(잡을 수 있는 칸은 동그라미). 그 칸을 누르거나 끌어다 놓아요.', + ); + if (!v.started[v.myColor]) lines.push(`첫 수는 ${v.firstMoveSec}초 안에 둬야 해요. 안 두면 대국이 취소돼요.`); + const legal = v.legalMoves ?? []; + if (legal.some((m) => m.promotion)) + lines.push(v.autoQueen ? '폰을 끝 줄로 보내면 자동으로 퀸이 돼요(승진).' : '폰을 끝 줄로 보내면 퀸·룩·비숍·나이트 중 무엇으로 바꿀지 고르는 창이 떠요(승진). 보통은 퀸이에요.'); + if (legal.some((m) => m.castle)) lines.push('지금 캐슬링을 할 수 있어요. 킹을 누르면 캐슬링할 칸도 표시돼요.'); + if (v.threatened?.length) lines.push('주황 점선 네모가 쳐진 내 기물은 지켜 주는 기물 없이 공격받고 있어요. 조심하세요.'); + if (v.drawMode === 'fide' && v.claimable) + lines.push(`[⚖ 무승부 주장]을 누르면 상대 동의 없이 바로 비길 수 있어요(${v.claimable === 'threefold' ? '같은 국면 3번' : '50수 동안 폰 이동·잡기 없음'}).`); + if (mat) lines.push(mat); + return lines.join('\n'); +} diff --git a/apps/web/src/games/chess/index.tsx b/apps/web/src/games/chess/index.tsx index 3fa282a..9070536 100644 --- a/apps/web/src/games/chess/index.tsx +++ b/apps/web/src/games/chess/index.tsx @@ -6,6 +6,8 @@ import { Avatar, ConfirmSheet, Sheet } from '../../components/ui'; import { TurnTimer, useServerNow } from '../../components/TurnTimer'; import { haptic, sounds } from '../../lib/feedback'; import type { BoardProps, GameUI, OptionsProps } from '../types'; +import { helpNow } from './help'; +import { quickStart } from './quick'; type Color = 'white' | 'black'; type PieceType = 'k' | 'q' | 'r' | 'b' | 'n' | 'p'; @@ -851,6 +853,8 @@ const ui: GameUI = { Options, optionsSummary, Rules, + quickStart, + helpNow, }; export default ui; diff --git a/apps/web/src/games/chess/quick.tsx b/apps/web/src/games/chess/quick.tsx new file mode 100644 index 0000000..7187506 --- /dev/null +++ b/apps/web/src/games/chess/quick.tsx @@ -0,0 +1,77 @@ +/** 체스 30초 규칙 요약 (docs/07 §5). */ +import type { QuickCard } from '../types'; + +const GLYPH: Record = { k: '♚', q: '♛', r: '♜', b: '♝', n: '♞', p: '♟' }; + +/** One piece as a text glyph (filled shape, coloured by side). `︎` keeps ♟ from turning into an emoji. */ +function Piece({ t, x, y }: { t: string; x: number; y: number }) { + const white = t === t.toUpperCase(); + return ( + + {GLYPH[t.toLowerCase()]} + {'︎'} + + ); +} + +/** Small chess board; `rows` use FEN letters (uppercase = white) and '.' for empty. */ +function MiniBoard({ rows, dots = [], check, size = 150 }: { rows: string[]; dots?: [number, number][]; check?: [number, number]; size?: number }) { + const h = rows.length; + const w = rows[0]!.length; + const scale = size / Math.max(w, h); + return ( + + {rows.map((r, y) => + r.split('').map((_, x) => ), + )} + {check && } + {dots.map(([x, y]) => ( + + ))} + {rows.map((r, y) => r.split('').map((c, x) => (c === '.' ? null : )))} + + ); +} + +const Arrow = () => ( + + → + +); + +export const quickStart: QuickCard[] = [ + { + pic: , + alt: '흰 룩이 검은 킹을 공격하고, 킹은 자기 폰에 막혀 피할 곳이 없어요', + title: '킹을 꼼짝 못 하게', + text: '백이 먼저 두고 번갈아 한 수씩 둬요. 상대 킹을 공격해서 피할 곳이 없게 만들면(체크메이트) 이겨요.', + }, + { + pic: , + alt: '가운데 나이트 둘레로 ㄱ자 모양 여덟 칸에 점이 찍혀 있어요', + title: '기물마다 길이 달라요', + text: '기물을 누르면 갈 수 있는 칸에 점이 찍혀요. 그 칸을 누르면 움직여요. 상대 기물이 있는 칸으로 가면 잡아요.', + }, + { + pic: ( + <> + + + + + ), + alt: '흰 폰이 끝 줄에 닿아 퀸으로 바뀌어요', + title: '폰이 끝까지 가면 변신', + text: '폰이 끝 줄에 닿으면 퀸 같은 강한 기물로 바뀌어요(승진). 내 킹이 공격받으면(체크) 꼭 먼저 피하거나 막아야 해요.', + }, +]; diff --git a/apps/web/src/games/cockroach-poker/help.test.ts b/apps/web/src/games/cockroach-poker/help.test.ts new file mode 100644 index 0000000..c29472f --- /dev/null +++ b/apps/web/src/games/cockroach-poker/help.test.ts @@ -0,0 +1,64 @@ +import { expect, test } from 'bun:test'; +import { applyAction, setupGame, usersOf } from '../help-kit.test-util'; +import { GAMES } from '@bg/games'; +import { helpNow } from './help'; + +const ids = ['A', 'B', 'C']; +const users = usersOf(ids); +const view = (s: unknown, who: string | null) => GAMES['cockroach-poker']!.view(s, who) as any; +const nickOf = (id: string) => users[id]!.nickname; + +test('바퀴벌레 포커: 건네기 차례 / 기다림 / 관전', () => { + const { state } = setupGame('cockroach-poker', 3); + const starter = state.round.starter as string; + const other = ids.find((p) => p !== starter)!; + expect(helpNow(view(state, starter), starter, users)).toContain('[건네기]'); + const wait = helpNow(view(state, other), other, users); + expect(wait).toContain(`${nickOf(starter)}님이 건넬 카드를 고르는 중`); + expect(wait).toContain('4장 모이면 져요'); + expect(helpNow(view(state, null), null, users)).toContain(`${nickOf(starter)}님이 건넬 카드를 고르는 중`); +}); + +test('바퀴벌레 포커: 받은 사람의 판정 / 몰래 보고 넘기기', () => { + const { state } = setupGame('cockroach-poker', 3); + const starter = state.round.starter as string; + const [to, third] = ids.filter((p) => p !== starter) as [string, string]; + const card = state.hands[starter][0] as string; + let s = applyAction('cockroach-poker', state, starter, { type: 'offer', cardId: card, to, claim: 'rat' }); + const t = helpNow(view(s, to), to, users); + expect(t).toContain('"이건 쥐야!"'); + expect(t).toContain('[⭕ 진짜!]'); + expect(t).toContain('[👁 몰래 보고 넘기기]'); + expect(helpNow(view(s, starter), starter, users)).toContain('다시 받지 않아요'); + expect(helpNow(view(s, null), null, users)).toContain(`${nickOf(to)}님이 진짜인지 고민하는 중`); + + s = applyAction('cockroach-poker', s, to, { type: 'peek' }); + const p = helpNow(view(s, to), to, users); + expect(p).toContain('몰래 본 카드는'); + expect(p).toContain('[넘기기]'); + expect(helpNow(view(s, third), third, users)).toContain('나에게 넘어올 수도'); + + s = applyAction('cockroach-poker', s, to, { type: 'pass', to: third, claim: 'bat' }); + const last = helpNow(view(s, third), third, users); + expect(last).toContain('"이건 박쥐야!"'); + expect(last).toContain('넘길 사람이 없어요'); +}); + +test('바퀴벌레 포커: 2명은 넘기기 없음, 위험 경고, 게임 끝', () => { + const { state } = setupGame('cockroach-poker', 2); + const starter = state.round.starter as string; + const to = ids.slice(0, 2).find((p) => p !== starter)!; + // 받는 사람 앞마당에 쥐 4장(2명은 5장이면 패배) → 위험 경고. + const rats = ['rat-0', 'rat-1', 'rat-2', 'rat-3', 'rat-4']; + for (const p of ['A', 'B']) state.hands[p] = state.hands[p].filter((c: string) => !rats.includes(c)); + state.fronts[to] = rats.slice(0, 4); + state.hands[starter].push('rat-4'); + let s = applyAction('cockroach-poker', state, starter, { type: 'offer', cardId: 'rat-4', to, claim: 'rat' }); + const t = helpNow(view(s, to), to, users); + expect(t).toContain('2명이 할 때는 몰래 보고 넘기기를 할 수 없어요'); + expect(t).toContain('조심! 내 앞마당의 쥐 4장'); + s = applyAction('cockroach-poker', s, to, { type: 'call', call: 'false' }); + expect(helpNow(view(s, to), to, users)).toContain('내가 졌어요'); + expect(helpNow(view(s, starter), starter, users)).toContain('나는 이겼어요'); + expect(helpNow(view(s, null), null, users)).toContain(`${nickOf(to)}님이 졌고`); +}); diff --git a/apps/web/src/games/cockroach-poker/help.ts b/apps/web/src/games/cockroach-poker/help.ts new file mode 100644 index 0000000..692bc72 --- /dev/null +++ b/apps/web/src/games/cockroach-poker/help.ts @@ -0,0 +1,86 @@ +/** 바퀴벌레 포커 [?] "지금 할 수 있는 것" (docs/07 §5). Pure: only reads the client view. */ +import type { CockroachView, Kind } from '@bg/games/cockroach-poker'; +import type { PublicUser } from '@bg/shared/lite'; + +// Kept local (like index.tsx) so the screen chunk does not pull in the rules module. +const KIND_KO: Record = { + cockroach: '바퀴벌레', + bat: '박쥐', + fly: '파리', + toad: '두꺼비', + rat: '쥐', + scorpion: '전갈', + spider: '거미', + stinkbug: '노린재', +}; + +const hasBatchim = (word: string) => { + const c = word.charCodeAt(word.length - 1); + return c >= 0xac00 && c <= 0xd7a3 && (c - 0xac00) % 28 !== 0; +}; +/** "이건 쥐야!" / "이건 전갈이야!" — the same words as the screen. */ +const claimLine = (k: Kind) => `이건 ${KIND_KO[k]}${hasBatchim(KIND_KO[k]) ? '이야' : '야'}!`; +const kindIs = (k: Kind) => `${KIND_KO[k]}${hasBatchim(KIND_KO[k]) ? '이에요' : '예요'}`; + +export function helpNow(v: CockroachView, me: string | null, players: Record): string { + const mine = me && v.order.includes(me) ? me : null; + const name = (id: string) => `${players[id]?.nickname ?? '다른 사람'}님`; + const subj = (id: string) => (id === mine ? '내가' : `${name(id)}이`); + const goal = `같은 벌레가 앞마당(내 앞에 펼친 카드)에 ${v.loseCount}장 모이면 져요.`; + + if (v.phase === 'finished') { + const f = v.final; + if (!f) return '게임이 끝났어요.'; + const who = f.loser === mine ? '내가 졌어요.' : mine ? `${name(f.loser)}이 졌고, 나는 이겼어요!` : `${name(f.loser)}이 졌고, 나머지 모두 이겼어요.`; + const why = + f.reason === 'four' && f.losingKind + ? `앞마당에 ${KIND_KO[f.losingKind]} ${v.loseCount}장이 모였어요.` + : f.reason === 'eight' + ? '앞마당에 벌레 8종이 모두 모였어요.' + : f.reason === 'emptyHand' + ? '카드를 건넬 차례인데 손에 카드가 없었어요.' + : '한 사람이 게임을 나갔어요.'; + return `게임이 끝났어요. ${why}\n${who}`; + } + + const r = v.round; + const danger = mine ? (Object.keys(v.fronts[mine]?.level ?? {}) as Kind[]).filter((k) => v.fronts[mine]!.level[k] === 'danger') : []; + const warn = danger.length ? `조심! 내 앞마당의 ${danger.map((k) => `${KIND_KO[k]} ${v.fronts[mine!]!.counts[k]}장`).join(', ')}은 한 장만 더 오면 져요.` : null; + const lines: string[] = []; + + if (r.holder === null) { + // 건네기: the round starter picks a card, a receiver and what to say. + if (r.starter === mine) { + lines.push('내 차례예요. 카드 한 장을 뒤집어 다른 사람에게 건네며 무슨 벌레인지 말해요. 진짜를 말해도, 거짓말을 해도 돼요.'); + lines.push('아래에서 ① 건넬 카드 ② 받을 사람 ③ 할 말(벌레 그림)을 차례로 고르고, 맨 아래 [건네기] 버튼을 눌러요.'); + } else { + lines.push(`${r.no}라운드예요. ${subj(r.starter)} 건넬 카드를 고르는 중이에요.${mine ? ' 나에게 올 수도 있으니 기다려 주세요.' : ''}`); + lines.push(goal); + } + } else { + const holder = r.holder; + const passer = r.lastPasser ?? r.starter; + const said = r.claim ? `"${claimLine(r.claim)}"` : '카드'; + if (holder === mine && !r.peeked) { + lines.push(`${name(passer)}이 ${said}라며 카드를 줬어요. 진짜 같으면 [⭕ 진짜!], 거짓말 같으면 [❌ 거짓말!]을 눌러요.`); + lines.push(`맞히면 ${name(passer)}이, 틀리면 내가 이 카드를 앞마당에 놓아요.`); + if (v.my?.canPeek) lines.push('자신 없으면 [👁 몰래 보고 넘기기]로 카드를 본 뒤, 아직 안 본 사람에게 넘길 수 있어요. 보고 나면 판정은 못 해요.'); + else if (v.my?.peekBlocked) lines.push(v.my.peekBlocked); + } else if (holder === mine) { + const known = v.card && v.card.known ? `몰래 본 카드는 ${kindIs(v.card.kind)}. ` : ''; + lines.push(`${known}이제 판정은 못 하고 꼭 다른 사람에게 넘겨야 해요.`); + lines.push('아직 이 카드를 안 본 사람(눌리는 버튼)과 할 말을 고르고 [넘기기] 버튼을 눌러요. 진짜를 말해도, 거짓말을 해도 돼요.'); + } else { + if (r.peeked) lines.push(`${subj(holder)} 몰래 본 카드를 넘길 사람을 고르는 중이에요.`); + else lines.push(`${subj(passer)} ${name(holder)}에게 ${said}라며 카드를 건넸어요. ${subj(holder)} 진짜인지 고민하는 중이에요.`); + if (mine && r.seen.includes(mine)) { + const known = v.card && v.card.known ? ` 실제 카드는 ${kindIs(v.card.kind)}.` : ''; + lines.push(`나는 이 카드를 봤기 때문에 다시 받지 않아요.${known}`); + } else if (mine && r.peeked) lines.push('아직 이 카드를 안 봤으니 나에게 넘어올 수도 있어요.'); + else if (mine) lines.push('기다려 주세요.'); + lines.push(goal); + } + } + if (warn) lines.push(warn); + return lines.join('\n'); +} diff --git a/apps/web/src/games/cockroach-poker/index.tsx b/apps/web/src/games/cockroach-poker/index.tsx index faa2f89..c2930a4 100644 --- a/apps/web/src/games/cockroach-poker/index.tsx +++ b/apps/web/src/games/cockroach-poker/index.tsx @@ -6,6 +6,8 @@ import { Avatar } from '../../components/ui'; import { TurnTimer } from '../../components/TurnTimer'; import { haptic, sounds } from '../../lib/feedback'; import type { BoardProps, GameUI, OptionsProps } from '../types'; +import { helpNow } from './help'; +import { quickStart } from './quick'; // Kept local (type-only import above) so the screen chunk does not pull in the rules module + zod. const KINDS: Kind[] = ['cockroach', 'bat', 'fly', 'toad', 'rat', 'scorpion', 'spider', 'stinkbug']; @@ -1054,6 +1056,8 @@ const ui: GameUI = { Options, optionsSummary: (o) => optionsSummary(o), Rules, + quickStart, + helpNow, }; export default ui; diff --git a/apps/web/src/games/cockroach-poker/quick.tsx b/apps/web/src/games/cockroach-poker/quick.tsx new file mode 100644 index 0000000..4ef6640 --- /dev/null +++ b/apps/web/src/games/cockroach-poker/quick.tsx @@ -0,0 +1,106 @@ +/** 바퀴벌레 포커 30초 규칙 요약 (docs/07 §5). Simple inline SVG + emoji; the screen's critter art lives in index.tsx. */ +import type { QuickCard } from '../types'; + +/** A small upright card: face-down (pattern) or face-up with a critter emoji. */ +function Card({ x, y, face, w = 44, h = 62, tilt = 0, red = false }: { x: number; y: number; face?: string; w?: number; h?: number; tilt?: number; red?: boolean }) { + return ( + + + {face ? ( + + {face} + + ) : ( + + ? + + )} + + ); +} + +function Person({ x, y, label }: { x: number; y: number; label: string }) { + return ( + + + + 🙂 + + + {label} + + + ); +} + +const GivePic = ( + + + + + + + + + + + + + + 이건 쥐야! + + +); + +const ChoosePic = ( + + + + + ⭕ 진짜! + + + + ❌ 거짓말! + + + + 👁 몰래 보고 넘기기 + + +); + +const LosePic = ( + + + + + + + + + 4장이면 패배 + + +); + +export const quickStart: QuickCard[] = [ + { + pic: GivePic, + alt: '내가 뒤집은 카드를 다른 사람에게 건네며 "이건 쥐야!"라고 말해요', + title: '카드를 건네며 말해요', + text: '내 차례에 카드 한 장을 뒤집어 다른 사람에게 주며 무슨 벌레인지 말해요. 진짜를 말해도, 거짓말을 해도 돼요.', + }, + { + pic: ChoosePic, + alt: '받은 카드 아래에 진짜, 거짓말, 몰래 보고 넘기기 버튼이 있어요', + title: '받으면 맞히거나 넘겨요', + text: '받은 사람은 [진짜!]나 [거짓말!]을 골라요. 맞히면 건넨 사람이, 틀리면 받은 사람이 카드를 앞마당에 놓아요. 3명 이상이면 몰래 보고 넘길 수도 있어요.', + }, + { + pic: LosePic, + alt: '앞마당에 쥐 카드 4장이 빨간 테두리로 모여 있어요', + title: '같은 벌레가 모이면 져요', + text: '앞마당에 같은 벌레가 4장(2명이면 5장, 방 설정에 따라 다름) 모인 사람 한 명이 지고, 나머지는 모두 이겨요.', + }, +]; diff --git a/apps/web/src/games/dalmuti/help.test.ts b/apps/web/src/games/dalmuti/help.test.ts new file mode 100644 index 0000000..2a68bd0 --- /dev/null +++ b/apps/web/src/games/dalmuti/help.test.ts @@ -0,0 +1,68 @@ +import { expect, test } from 'bun:test'; +import { applyAction, setupGame, usersOf } from '../help-kit.test-util'; +import { GAMES } from '@bg/games'; +import { helpNow } from './help'; + +const ids = ['A', 'B', 'C', 'D']; +const users = usersOf(ids); +const view = (s: unknown, who: string | null) => GAMES.dalmuti!.view(s, who) as any; +const nickOf = (id: string) => users[id]!.nickname; + +/** Everyone confirms the hand without a revolution, then the two 달무티 give back their suggested cards. */ +function toPlaying(state: any) { + let s = state; + for (const p of ids) s = applyAction('dalmuti', s, p, { type: 'confirmHand' }); + return s; +} + +test('달무티: 손패 확인 단계와 혁명', () => { + const { state } = setupGame('dalmuti', 4); + expect(state.phase.kind).toBe('revolutionCheck'); + const top = state.order[0] as string; + const last = state.order[3] as string; + expect(helpNow(view(state, top), top, users)).toContain('[손패 확인]'); + expect(helpNow(view(state, top), top, users)).toContain('대농노에게서 가장 좋은 카드 2장'); + expect(helpNow(view(state, last), last, users)).toContain('대달무티에게 자동으로'); + expect(helpNow(view(state, null), null, users)).toContain('준비 중이에요'); + // Give both jesters to the 대농노: the 혁명 button appears and is explained as 대혁명. + const jesters = Object.values(state.hands).flat().filter((c: any) => c.n === 13); + for (const p of ids) state.hands[p] = state.hands[p].filter((c: any) => c.n !== 13); + state.hands[last].push(...jesters); + const t = helpNow(view(state, last), last, users); + expect(t).toContain('[혁명!]'); + expect(t).toContain('대혁명'); +}); + +test('달무티: 세금 돌려주기', () => { + const { state } = setupGame('dalmuti', 4); + const s = toPlaying(state); + expect(s.phase.kind).toBe('taxReturn'); + const top = s.order[0] as string; + const last = s.order[3] as string; + const t = helpNow(view(s, top), top, users); + expect(t).toContain('돌려줄 카드 2장'); + expect(t).toContain('[돌려주기]'); + expect(helpNow(view(s, last), last, users)).toContain('세금으로 내 가장 좋은 카드'); + expect(helpNow(view(s, null), null, users)).toContain('세금 카드를 주고받는 중'); +}); + +test('달무티: 새로 시작 / 따라 내기 / 기다리기 / 관전', () => { + const { state } = setupGame('dalmuti', 4); + let s = toPlaying(state); + for (const p of [s.order[0], s.order[1]]) { + const v = view(s, p); + s = applyAction('dalmuti', s, p, { type: 'returnTax', cardIds: v.my.suggestedReturn }); + } + expect(s.phase.kind).toBe('playing'); + const lead = s.current as string; + expect(helpNow(view(s, lead), lead, users)).toContain('새로 시작해요'); + const other = ids.find((p) => p !== lead)!; + expect(helpNow(view(s, other), other, users)).toContain(`${nickOf(lead)}님 차례예요. 기다려 주세요`); + expect(helpNow(view(s, null), null, users)).toContain(`${nickOf(lead)}님 차례예요`); + const card = s.hands[lead].find((c: any) => c.n === 12) ?? s.hands[lead].find((c: any) => c.n !== 13); + s = applyAction('dalmuti', s, lead, { type: 'play', cardIds: [card.id] }); + const next = s.current as string; + const t = helpNow(view(s, next), next, users); + expect(t).toContain(`지금 바닥은 ${card.n} × 1장`); + expect(t).toContain('[패스]'); +}); diff --git a/apps/web/src/games/dalmuti/help.ts b/apps/web/src/games/dalmuti/help.ts new file mode 100644 index 0000000..dfdea30 --- /dev/null +++ b/apps/web/src/games/dalmuti/help.ts @@ -0,0 +1,102 @@ +/** 달무티 [?] "지금 할 수 있는 것" (docs/07 §5). Pure: only reads the client view. */ +import type { DalmutiView } from '@bg/games/dalmuti'; +import type { PublicUser } from '@bg/shared/lite'; + +const JESTER = 13; +const nm = (players: Record, id: string | null | undefined) => `${(id && players[id]?.nickname) || '다른 사람'}님`; +const rankText = (r: number) => (r === JESTER ? '광대(13)' : String(r)); +const faces = (cards: number[]) => cards.map((n) => (n === JESTER ? '광대' : String(n))).join(', '); + +/** What the tax step will do for this title (no revolution). */ +function taxPlan(title: string): string { + switch (title) { + case '대달무티': + return '혁명이 없으면 대농노에게서 가장 좋은 카드 2장을 받고, 아무 카드 2장을 골라 돌려줘요.'; + case '소달무티': + return '혁명이 없으면 소농노에게서 가장 좋은 카드 1장을 받고, 아무 카드 1장을 골라 돌려줘요.'; + case '소농노': + return '혁명이 없으면 내 가장 좋은(가장 낮은 숫자) 카드 1장이 소달무티에게 자동으로 넘어가요.'; + case '대농노': + return '혁명이 없으면 내 가장 좋은(가장 낮은 숫자) 카드 2장이 대달무티에게 자동으로 넘어가요.'; + default: + return '평민은 세금을 내지도 받지도 않아요.'; + } +} + +export function helpNow(v: DalmutiView, me: string | null, players: Record): string { + const goal = '숫자가 낮을수록 좋은 카드예요. 카드를 먼저 다 낼수록 다음 라운드에서 높은 신분이 돼요.'; + if (v.phase === 'finished') return `게임이 끝났어요. ${v.result?.summary ?? ''}`.trim() + '\n결과 창에서 [한 판 더]를 누르면 다시 할 수 있어요.'; + + const seat = me ? v.seats.find((s) => s.id === me) : undefined; + const my = seat ? v.my : null; + const roundText = `${v.round}/${v.options.rounds}라운드`; + + if (v.phase === 'revolutionCheck') { + const done = v.seats.filter((s) => s.confirmed).length; + if (!my) return `${roundText} 준비 중이에요. 모두 자기 카드를 보고 확인하는 중이에요(${done}/${v.seats.length}).\n${goal}`; + const lines: string[] = []; + if (my.canDeclareRevolution) { + const great = v.seats[v.seats.length - 1]?.id === me; + lines.push(`광대 2장이 다 있어요! [혁명!]을 누르면 광대 2장을 보여 주고, 이번 라운드는 세금이 없어요.${great ? ' 나는 대농노라서 대혁명이 되어 신분이 완전히 뒤집혀요.' : ''}`); + lines.push('혁명하지 않으려면 [손패 확인]을 눌러요.'); + return lines.join('\n'); + } + if (my.canConfirm) { + lines.push(`${roundText} 준비예요(내 신분: ${seat!.title}). 내 카드를 보고 [손패 확인]을 눌러 주세요.`); + lines.push(taxPlan(seat!.title)); + } else { + lines.push(`손패를 확인했어요. 다른 사람이 확인하기를 기다리는 중이에요(${done}/${v.seats.length}).`); + lines.push(taxPlan(seat!.title)); + } + return lines.join('\n'); + } + + if (v.phase === 'taxReturn') { + const pending = v.taxPending.filter((p) => !p.done); + const pendingText = pending.map((p) => nm(players, p.from)).join(', '); + if (!my) return `세금 카드를 주고받는 중이에요. 농노는 가장 좋은 카드를 달무티에게 주고, 달무티는 아무 카드나 같은 장수만큼 돌려줘요.${pendingText ? `\n${pendingText}이 돌려줄 카드를 고르고 있어요.` : ''}`; + if (my.owe) { + const got = v.tax.find((t) => t.kind === 'tax' && t.to === me); + const gotText = got?.cards ? `세금으로 ${nm(players, got.from)}에게서 좋은 카드를 받았어요(받은 카드: ${faces(got.cards)}). ` : ''; + return `${gotText}${nm(players, my.owe.to)}에게 돌려줄 카드 ${my.owe.count}장을 눌러 고르고 [돌려주기]를 눌러요.\n처음에는 가장 큰 숫자 카드가 골라져 있어요. 받은 카드를 돌려줘도 돼요.`; + } + const paid = v.tax.find((t) => t.kind === 'tax' && t.from === me); + const lines: string[] = []; + if (paid) lines.push(`세금으로 내 가장 좋은 카드${paid.cards ? `(${faces(paid.cards)})` : ''} ${paid.count}장이 ${nm(players, paid.to)}에게 넘어갔어요. 곧 ${paid.count}장을 돌려받아요.`); + else lines.push('세금 카드를 주고받는 중이에요.'); + if (pendingText) lines.push(`${pendingText}이 돌려줄 카드를 고르고 있어요. 기다려 주세요.`); + return lines.join('\n'); + } + + // playing + const top = v.trick?.top ?? null; + const topText = top ? `${rankText(top.rank)} × ${top.count}장` : ''; + const cur = v.current; + if (my && cur === me) { + if (my.leading) { + const lines = ['내 차례예요. 새로 시작해요. 같은 숫자 카드를 원하는 장수만큼 낼 수 있어요.', '숫자 묶음을 누르고 −/+로 장수를 정한 뒤 [내기]를 눌러요. 새로 시작할 때는 패스할 수 없어요.']; + if (my.jesters > 0) lines.push('광대는 [🃏 광대 섞기]로 다른 숫자와 함께 내면 그 숫자로 쳐요. 광대만 내면 13이에요.'); + return lines.join('\n'); + } + const lines = [`내 차례예요. 지금 바닥은 ${topText}이에요.`]; + if (top!.rank === 1) lines.push('1보다 낮은 숫자는 없으니 [패스]를 눌러 주세요.'); + else { + lines.push(`같은 ${top!.count}장으로 ${rankText(top!.rank)}보다 낮은 숫자를 내거나 [패스]를 눌러요.`); + if (my.choices.length > 0) { + lines.push('밝게 보이는 숫자 묶음만 낼 수 있어요. 묶음을 누른 뒤 [내기]를 눌러요.'); + if (my.choices.some((c) => c.needsJester)) lines.push("'광대 필요'라고 적힌 숫자는 광대를 섞어야 장수가 맞아요."); + } else lines.push('낼 수 있는 카드가 없어요. [패스]를 눌러 주세요.'); + } + lines.push(v.options.passLocksOut ? '이 방 규칙에서는 패스하면 이번 트릭이 끝날 때까지 못 내요.' : '패스해도 차례가 다시 오면 낼 수 있어요.'); + return lines.join('\n'); + } + + const curNick = nm(players, cur); + const tableLine = top ? `지금 바닥은 ${topText}(${nm(players, top.by)}이 냄)이에요.` : `${curNick}이 새로 시작해요.`; + if (!my || !seat) return `${curNick} 차례예요. ${tableLine}\n${goal}`; + if (seat.place !== null) return `카드를 ${seat.place}번째로 다 냈어요! 다음 라운드에서 그만큼 높은 신분이 돼요.\n${curNick} 차례예요. 남은 사람을 지켜봐요.`; + const lines = [`${curNick} 차례예요. 기다려 주세요. ${tableLine}`]; + if (my.lockedOut) lines.push('이번 트릭은 패스해서 빠졌어요. 다음 트릭을 기다려요.'); + lines.push(`내 신분은 ${seat.title}, 남은 카드는 ${my.hand.length}장이에요.`); + return lines.join('\n'); +} diff --git a/apps/web/src/games/dalmuti/index.tsx b/apps/web/src/games/dalmuti/index.tsx index 196d8b9..e652009 100644 --- a/apps/web/src/games/dalmuti/index.tsx +++ b/apps/web/src/games/dalmuti/index.tsx @@ -6,6 +6,8 @@ import { Avatar } from '../../components/ui'; import { TurnTimer } from '../../components/TurnTimer'; import { haptic, sounds } from '../../lib/feedback'; import type { BoardProps, GameUI, OptionsProps } from '../types'; +import { helpNow } from './help'; +import { quickStart } from './quick'; const JESTER = 13; /** Same names as the rules module (kept here so the screen chunk does not pull in the rules code). */ @@ -1002,6 +1004,8 @@ const ui: GameUI = { Options, optionsSummary, Rules, + quickStart, + helpNow, }; export default ui; diff --git a/apps/web/src/games/dalmuti/quick.tsx b/apps/web/src/games/dalmuti/quick.tsx new file mode 100644 index 0000000..060269f --- /dev/null +++ b/apps/web/src/games/dalmuti/quick.tsx @@ -0,0 +1,85 @@ +/** 달무티 30초 규칙 요약 (docs/07 §5). */ +import type { QuickCard } from '../types'; + +const HUE: Record = { 1: '#b7791f', 2: '#7c3aed', 3: '#4338ca', 5: '#0f766e', 7: '#4d7c0f', 9: '#c2410c', 12: '#57534e', 13: '#be185d' }; + +/** One card in a 40 × 56 box at (x, y). */ +function Card({ n, x, y }: { n: number; x: number; y: number }) { + const c = HUE[n] ?? '#475569'; + return ( + + + + + {n === 13 ? '★' : n} + + + ); +} + +function Set({ n, count, x, y }: { n: number; count: number; x: number; y: number }) { + return ( + + {Array.from({ length: count }, (_, i) => ( + + ))} + + ); +} + +export const quickStart: QuickCard[] = [ + { + pic: ( + + + + + + 좋음 + 나쁨 + 광대 + + + + + + + + ), + alt: '숫자 1, 5, 12 카드와 광대 카드. 1이 가장 좋고 12로 갈수록 나빠요', + title: '낮은 숫자가 좋아요', + text: '숫자 n인 카드는 n장씩 있어요. 숫자가 낮을수록 좋아요. 광대는 다른 숫자와 같이 내면 그 숫자가 돼요.', + }, + { + pic: ( + + + → + + 9 × 3장 + 5 × 3장 + 같은 장수 · 더 낮은 숫자 + + ), + alt: '9 세 장 위에 5 세 장을 내는 모습', + title: '같은 장수로 더 낮게', + text: '새로 시작하는 사람은 같은 숫자를 원하는 장수만큼 내요. 다음 사람은 같은 장수로 더 낮은 숫자를 내거나 패스해요.', + }, + { + pic: ( + + 👑 + 대달무티 + 🧺 + 대농노 + + + ← 세금 + 먼저 다 내면 다음엔 👑 + + ), + alt: '대농노가 좋은 카드 2장을 대달무티에게 세금으로 주는 모습', + title: '먼저 털면 높은 신분', + text: '카드를 먼저 다 낸 순서로 다음 신분이 정해져요. 농노는 가장 좋은 카드를 달무티에게 세금으로 주고, 같은 장수를 돌려받아요.', + }, +]; diff --git a/apps/web/src/games/davinci-code/help.test.ts b/apps/web/src/games/davinci-code/help.test.ts new file mode 100644 index 0000000..915ecad --- /dev/null +++ b/apps/web/src/games/davinci-code/help.test.ts @@ -0,0 +1,50 @@ +import { expect, test } from 'bun:test'; +import { applyAction, setupGame, usersOf } from '../help-kit.test-util'; +import { GAMES } from '@bg/games'; +import { helpNow } from './help'; + +const users = usersOf(['A', 'B']); +const view = (s: unknown, who: string | null) => GAMES['davinci-code']!.view(s, who) as any; + +function dealt() { + const { state } = setupGame('davinci-code', 2, { startTiles: 4 }); + expect(helpNow(view(state, 'A'), 'A', users)).toContain('[이렇게 받기]'); + let s = applyAction('davinci-code', state, 'A', { type: 'chooseStart', black: 2, white: 2 }); + expect(helpNow(view(s, 'A'), 'A', users)).toContain('검정 2개, 흰색 2개를 골랐어요'); + expect(helpNow(view(s, null), null, users)).toContain('(1/2)'); + s = applyAction('davinci-code', s, 'B', { type: 'chooseStart', black: 2, white: 2 }); + const cur: string = s.current; + const other = cur === 'A' ? 'B' : 'A'; + return { s, cur, other }; +} + +test('다빈치 코드: 시작 색 고르기 → 가져오기 / 기다리기 / 관전', () => { + const { s, cur, other } = dealt(); + expect(helpNow(view(s, cur), cur, users)).toContain('색 더미 중 원하는 색'); + const curNick = users[cur]!.nickname; + expect(helpNow(view(s, other), other, users)).toContain(`${curNick}님이 타일을 가져오는 중이에요`); + expect(helpNow(view(s, null), null, users)).toContain(`${curNick}님 차례예요`); +}); + +test('다빈치 코드: 추리 → 맞히면 계속/멈추기, 틀리면 공개', () => { + const { s: s0, cur, other } = dealt(); + const s = applyAction('davinci-code', s0, cur, { type: 'draw', color: 'black' }); + const g = helpNow(view(s, cur), cur, users); + expect(g).toContain('가져온 타일(검정'); + expect(g).toContain('[이 숫자!]'); + expect(g).toContain('가져온 타일이 모두에게 공개돼요'); + expect(helpNow(view(s, other), other, users)).toContain('추리하는 중이에요'); + // Guess correctly using the real hidden state. + const t = s.rows[other][0]; + const n = t.id.slice(1) === 'J' ? 'joker' : Number(t.id.slice(1)); + const s2 = applyAction('davinci-code', s, cur, { type: 'guess', target: other, index: 0, value: n }); + const after = helpNow(view(s2, cur), cur, users); + expect(after).toContain('[한 번 더 맞히기]'); + expect(after).toContain('[여기서 멈추기]'); +}); + +test('다빈치 코드: 더미가 빈 뒤 틀리면 내 타일 공개', () => { + const { s: s0, cur } = dealt(); + const s = { ...s0, phase: { kind: 'revealOwn' } }; + expect(helpNow(view(s, cur), cur, users)).toContain('내 숨은 타일 하나를 공개해야 해요'); +}); diff --git a/apps/web/src/games/davinci-code/help.ts b/apps/web/src/games/davinci-code/help.ts new file mode 100644 index 0000000..cad50b9 --- /dev/null +++ b/apps/web/src/games/davinci-code/help.ts @@ -0,0 +1,65 @@ +/** 다빈치 코드 [?] "지금 할 수 있는 것" (docs/07 §5). Pure: only reads the client view. */ +import type { DavinciView, TileValue } from '@bg/games/davinci-code'; +import type { PublicUser } from '@bg/shared/lite'; + +const COLOR = { black: '검정', white: '흰색' } as const; +const nm = (players: Record, id: string | null | undefined) => `${(id && players[id]?.nickname) || '다른 사람'}님`; +const valueKo = (n: TileValue | undefined) => (n === 'joker' ? '조커' : n === undefined ? '' : String(n)); +const hidden = (v: DavinciView, p: string) => (v.rows[p] ?? []).filter((t) => !t.revealed).length; + +export function helpNow(v: DavinciView, me: string | null, players: Record): string { + const goal = '상대 타일의 숫자를 맞혀 모두 드러내면 그 상대는 탈락해요. 마지막까지 숨은 타일이 남은 사람이 이겨요.'; + if (v.phase === 'finished') return `게임이 끝났어요. ${v.final?.summary ?? ''}`.trim() + '\n결과 창에서 [한 판 더]를 누르면 다시 할 수 있어요.'; + + const seated = !!me && v.seats.includes(me); + const alive = seated && !!v.alive[me!]; + const cur = v.current; + const curNick = nm(players, cur); + + if (v.phase === 'chooseStart') { + const done = v.picked ? Object.values(v.picked).filter(Boolean).length : 0; + const total = v.seats.filter((p) => v.alive[p]).length; + if (!alive) return `모두 시작 타일 ${v.startCount}개의 색을 고르는 중이에요(${done}/${total}).\n${goal}`; + if (v.myPick) return `검정 ${v.myPick.black}개, 흰색 ${v.myPick.white}개를 골랐어요. 다른 사람을 기다리는 중이에요(${done}/${total}).`; + return `시작 타일 ${v.startCount}개를 검정과 흰색 몇 개씩 받을지 골라 [이렇게 받기]를 눌러요.\n숫자는 고른 색 안에서 무작위로 정해지고, 내 줄에 작은 수부터 자동으로 놓여요.`; + } + + if (!alive) { + const head = seated ? '탈락했어요. 끝까지 구경할 수 있어요.\n' : ''; + return `${head}${curNick} 차례예요. ${goal}`; + } + + if (cur !== me) { + const doing: Record = { + draw: '타일을 가져오는 중이에요', + guess: '추리하는 중이에요', + afterCorrect: '맞혀서 계속할지 고르는 중이에요', + placeJoker: '조커를 넣을 자리를 고르는 중이에요', + revealOwn: '틀려서 자기 타일을 공개하는 중이에요', + }; + return `${curNick}이 ${doing[v.phase] ?? '차례를 진행하는 중이에요'}. 기다려 주세요.\n내 숨은 타일은 ${hidden(v, me!)}개 남았어요. 공개된 숫자와 위치, 추리 기록을 보며 상대 숫자를 짐작해 보세요.`; + } + + const pend = v.pending?.n !== undefined ? `가져온 타일(${COLOR[v.pending.color]} ${valueKo(v.pending.n)})은 나만 볼 수 있어요. ` : ''; + switch (v.phase) { + case 'draw': + return v.options.chooseColors + ? '내 차례예요. 가운데 검정·흰색 더미 중 원하는 색을 눌러 타일을 1개 가져와요.\n가져온 타일의 숫자는 나만 봐요.' + : '내 차례예요. 가운데 [타일 가져오기]를 눌러 타일을 1개 가져와요.\n가져온 타일의 숫자는 나만 봐요.'; + case 'guess': { + const penalty = v.pending ? '가져온 타일이 모두에게 공개돼요' : v.options.emptyPoolPenalty === 'reveal-own' ? '내 숨은 타일 하나를 공개해야 해요' : '차례가 넘어가요'; + const dim = v.options.deductionHelper ? '숫자판에서 흐린 숫자는 이미 보이는 숫자이거나 공개된 정보로 보면 어려운 숫자예요.' : '숫자판에서 흐린 숫자는 이미 다른 곳에 보이는 숫자예요.'; + return `${pend}상대 줄에서 파란 테두리가 있는 숨은 타일을 누르고, 숫자를 골라 [이 숫자!] 버튼을 눌러요.\n타일은 작은 수부터 놓여 있으니 옆에 공개된 숫자가 힌트예요. ${dim}\n틀리면 ${penalty}.`; + } + case 'afterCorrect': + return v.pending + ? '맞혔어요! [한 번 더 맞히기]로 계속하거나 [여기서 멈추기]를 눌러요.\n멈추면 가져온 타일이 숨긴 채 내 줄에 들어가요. 계속하다 틀리면 그 타일이 공개돼요.' + : `맞혔어요! [한 번 더 맞히기]로 계속하거나 [여기서 멈추기]를 눌러요.\n더미가 비어서 ${v.options.emptyPoolPenalty === 'reveal-own' ? '계속하다 틀리면 내 숨은 타일 하나를 공개해야 해요' : '계속하다 틀려도 벌칙 없이 차례만 넘어가요'}.`; + case 'placeJoker': + return '조커를 넣을 자리를 골라요. 내 줄의 [여기] 버튼 중 하나를 누르세요.\n조커는 어느 틈에나 넣을 수 있고, 한 번 놓으면 옮길 수 없어요.'; + case 'revealOwn': + return '틀렸어요. 더미가 비어서 내 숨은 타일 하나를 공개해야 해요.\n내 줄에서 파란 테두리 타일 중 하나를 누르세요.'; + default: + return `내 차례예요. ${goal}`; + } +} diff --git a/apps/web/src/games/davinci-code/index.tsx b/apps/web/src/games/davinci-code/index.tsx index 122daf7..36cea37 100644 --- a/apps/web/src/games/davinci-code/index.tsx +++ b/apps/web/src/games/davinci-code/index.tsx @@ -6,6 +6,8 @@ import { Avatar } from '../../components/ui'; import { TurnTimer } from '../../components/TurnTimer'; import { haptic, sounds } from '../../lib/feedback'; import type { BoardProps, GameUI, OptionsProps } from '../types'; +import { helpNow } from './help'; +import { quickStart } from './quick'; const COLOR_KO: Record = { black: '검은색', white: '흰색' }; const COLOR_SHORT: Record = { black: '검정', white: '흰색' }; @@ -951,6 +953,8 @@ const ui: GameUI = { Options, optionsSummary, Rules, + quickStart, + helpNow, }; export default ui; diff --git a/apps/web/src/games/davinci-code/quick.tsx b/apps/web/src/games/davinci-code/quick.tsx new file mode 100644 index 0000000..52852ca --- /dev/null +++ b/apps/web/src/games/davinci-code/quick.tsx @@ -0,0 +1,54 @@ +/** 다빈치 코드 30초 규칙 요약 (docs/07 §5). */ +import type { QuickCard } from '../types'; + +type T = { c: 'b' | 'w'; n?: number | string; open?: boolean; mark?: 'ok' | 'ask' }; + +/** A row of tiles: black/white bodies; `n` shown when known, otherwise a hidden back. */ +function Row({ tiles, label }: { tiles: T[]; label?: string }) { + const w = 40; + const gap = 8; + const width = tiles.length * (w + gap) - gap; + return ( + + {label && ( + + {label} + + )} + {tiles.map((t, i) => { + const x = i * (w + gap); + const dark = t.c === 'b'; + return ( + + + + {t.n === undefined ? '?' : t.n} + + {t.open && } + + ); + })} + + ); +} + +export const quickStart: QuickCard[] = [ + { + pic: , + alt: '검정 1, 흰색 4, 흰색 7, 검정 10 타일이 작은 수부터 차례로 놓여 있어요', + title: '작은 수부터 놓여요', + text: '검정·흰색 숫자 타일(0~11)이 왼쪽부터 작은 수 순서로 자동으로 놓여요. 상대는 내 타일의 색만 보고 숫자는 몰라요.', + }, + { + pic: , + alt: '상대 줄의 숨은 검정 타일 하나에 파란 테두리가 있고, 옆에 공개된 흰색 2와 검정 9가 보여요', + title: '가져오고, 맞혀요', + text: '내 차례엔 더미에서 타일 1개를 가져온 뒤, 상대의 숨은 타일 하나를 골라 숫자를 말해요. 옆 숫자가 힌트예요.', + }, + { + pic: , + alt: '맞힌 검정 5 타일이 공개되어 초록 점이 붙어 있어요', + title: '다 드러나면 탈락', + text: '맞히면 그 타일이 공개되고 더 맞힐지 멈출지 골라요. 틀리면 가져온 타일이 공개돼요. 마지막까지 숨은 타일이 남은 사람이 이겨요.', + }, +]; diff --git a/apps/web/src/games/dobble/help.test.ts b/apps/web/src/games/dobble/help.test.ts new file mode 100644 index 0000000..9061483 --- /dev/null +++ b/apps/web/src/games/dobble/help.test.ts @@ -0,0 +1,47 @@ +import { expect, test } from 'bun:test'; +import { applyAction, setupGame, usersOf } from '../help-kit.test-util'; +import { GAMES } from '@bg/games'; +import { answerArt, pairsFor } from '@bg/games/dobble'; +import { helpNow } from './help'; + +const users = usersOf(['A', 'B', 'C']); +const view = (s: unknown, who: string | null) => GAMES.dobble!.view(s, who) as any; + +function playing(options: Record = {}) { + const { state } = setupGame('dobble', 3, options); + state.phase = 'playing'; + return state; +} + +test('도블: 카운트다운 / 탑 쌓기 진행 중 / 관전', () => { + const { state: s0 } = setupGame('dobble', 3); + expect(helpNow(view(s0, 'A'), 'A', users)).toContain('곧 카드가 공개돼요'); + const s = playing(); + const t = helpNow(view(s, 'A'), 'A', users); + expect(t).toContain('탑 쌓기'); + expect(t).toContain('가운데 카드와 내 카드'); + expect(t).toContain('1초 동안'); + const spec = helpNow(view(s, null), null, users); + expect(spec).toContain('게임 중이에요'); + expect(spec).not.toContain('누르면 돼요'); +}); + +test('도블: 뜨거운 감자 — 대상 고르기, 탈출, 라운드 끝', () => { + const s = playing({ miniGame: 'hotPotato', wrongLockoutMs: 0 }); + expect(helpNow(view(s, 'A'), 'A', users)).toContain('넘길 사람을 먼저 골라요'); + expect(helpNow(view(s, 'A'), 'A', users)).toContain('잠기지 않아요'); + const pair = pairsFor(s, 'A')[0]!; + const s2 = applyAction('dobble', s, 'A', { type: 'claim', base: pair.base, target: pair.target!, mine: pair.mine!, symbol: answerArt(s, 'A', pair)! }); + expect(helpNow(view(s2, 'A'), 'A', users)).toContain('탈출했어요'); + s2.phase = 'roundEnd'; + s2.lastRound = { round: 1, loser: 'B', cards: 2 }; + expect(helpNow(view(s2, 'C'), 'C', users)).toContain('고양이님이 카드 2장을 벌점으로'); +}); + +test('도블: 독이 든 선물 목표와 게임 끝', () => { + const s = playing({ miniGame: 'poisonedGift' }); + expect(helpNow(view(s, 'B'), 'B', users)).toContain('가장 적게 받으면 1등'); + s.phase = 'ended'; + s.final = { miniGame: 'poisonedGift', ranking: [], stats: {}, summary: '독이 든 선물 1등 호랑이님!' }; + expect(helpNow(view(s, 'B'), 'B', users)).toContain('1등 호랑이님'); +}); diff --git a/apps/web/src/games/dobble/help.ts b/apps/web/src/games/dobble/help.ts new file mode 100644 index 0000000..333b3af --- /dev/null +++ b/apps/web/src/games/dobble/help.ts @@ -0,0 +1,45 @@ +/** 도블 [?] "지금 할 수 있는 것" (docs/07 §5). Pure: only reads the client view. */ +import type { DobbleView, MiniGame } from '@bg/games/dobble'; +import type { PublicUser } from '@bg/shared/lite'; + +const MINI_KO: Record = { tower: '탑 쌓기', well: '우물', hotPotato: '뜨거운 감자', poisonedGift: '독이 든 선물' }; + +/** What to compare, what a find does, how to win (§5.2). */ +const GOAL: Record = { + tower: '가운데 카드와 내 카드에 똑같이 있는 그림 하나를 찾아 눌러요. 먼저 찾으면 가운데 카드를 내 탑으로 가져와요. 카드를 가장 많이 모으면 1등이에요.', + well: '가운데 카드와 내 카드에 똑같이 있는 그림 하나를 찾아 눌러요. 먼저 찾으면 내 카드를 가운데에 내려놓아요. 내 카드를 먼저 다 비우면 1등이에요.', + hotPotato: + '내 카드와 다른 사람 카드에 똑같이 있는 그림 하나를 찾아 눌러요. 먼저 찾으면 내 카드 묶음을 그 사람에게 넘기고 이번 라운드는 빠져요. 마지막까지 카드를 들고 있으면 그 카드가 벌점이에요.', + poisonedGift: '가운데 카드와 다른 사람 카드에 똑같이 있는 그림 하나를 찾아 눌러요. 먼저 찾으면 가운데 카드를 그 사람에게 줘요. 카드를 가장 적게 받으면 1등이에요.', +}; + +export function helpNow(v: DobbleView, me: string | null, players: Record): string { + const nick = (id: string) => players[id]?.nickname ?? '다른 사람'; + const mode = v.options.miniGame; + const name = MINI_KO[mode]; + if (v.phase === 'ended') return `게임이 끝났어요. ${v.result?.summary ?? ''}`.trim() + '\n최종 순위에서 각자 카드 수와 찾은 횟수를 볼 수 있어요.'; + const round = mode === 'hotPotato' ? ` (${v.round}/${v.rounds}라운드)` : ''; + if (v.phase === 'countdown') return `곧 카드가 공개돼요. 이번 게임은 '${name}'${round}이에요. 차례 없이 모두 동시에 찾아요.\n${GOAL[mode]}`; + if (v.phase === 'roundEnd') { + const r = v.lastRound; + const who = r?.loser ? (r.loser === me ? '내가' : `${nick(r.loser)}님이`) : null; + const what = r && who ? `${r.round}라운드에서 ${who} 카드 ${r.cards}장을 벌점으로 받았어요. ` : ''; + return `라운드가 끝났어요. ${what}잠시 뒤 다음 라운드가 시작돼요.`; + } + const mine = v.seats.find((s) => s.id === me) ?? null; + if (!mine) return `'${name}' 게임 중이에요${round}. 모두 동시에 두 카드의 같은 그림을 찾고 있어요.\n${GOAL[mode]}`; + if (mine.status === 'finished') return `내 카드를 다 비웠어요!${mine.place ? ` ${mine.place}등이에요.` : ''} 남은 사람들의 게임을 지켜봐요.`; + if (mine.status === 'escaped') return `이번 라운드는 탈출했어요${round}. 남은 사람들이 카드를 넘기는 걸 지켜보고 다음 라운드를 기다려요.`; + if (mine.status === 'forfeit' || mine.status === 'left') return '게임에서 빠졌어요. 남은 사람들의 게임을 지켜봐요.'; + if (v.pairs.length === 0) return `지금은 비교할 카드가 없어요. 잠시 기다려 주세요.\n${GOAL[mode]}`; + const lines = [`'${name}'${round} 게임 중이에요. 차례 없이 모두 동시에 찾아요. 어느 두 카드든 똑같은 그림은 딱 하나예요.`, GOAL[mode]]; + lines.push(`두 카드 중 아무 쪽에서나 그 그림을 누르면 돼요.${v.options.symbolStyle === 'classic' ? ' 크기와 방향이 달라도 같은 그림이에요.' : ''}`); + if ((mode === 'hotPotato' || mode === 'poisonedGift') && v.pairs.length > 1) { + lines.push(mode === 'poisonedGift' ? '위쪽 사람 목록에서 선물을 줄 사람을 먼저 골라요.' : '위쪽 사람 목록에서 카드를 넘길 사람을 먼저 골라요.'); + } + const o = v.options; + const lock = o.wrongLockoutMs ? `틀린 그림을 누르면 ${o.wrongLockoutMs / 1000}초 동안 못 눌러요.` : '이 방은 틀려도 잠기지 않아요.'; + const give = mode === 'tower' && o.wrongGivesCard ? ' 틀리면 내 탑 맨 위 카드도 1장 돌려줘요.' : ''; + lines.push(lock + give); + return lines.join('\n'); +} diff --git a/apps/web/src/games/dobble/index.tsx b/apps/web/src/games/dobble/index.tsx index 42ac46b..fe2ba98 100644 --- a/apps/web/src/games/dobble/index.tsx +++ b/apps/web/src/games/dobble/index.tsx @@ -7,6 +7,8 @@ import { useServerNow } from '../../components/TurnTimer'; import { haptic, sounds } from '../../lib/feedback'; import { socket, useRoom } from '../../net/socket'; import type { BoardProps, GameUI, OptionsProps } from '../types'; +import { helpNow } from './help'; +import { quickStart } from './quick'; import { ICON_NAMES, SymbolArt } from './art'; /** Taps this soon after one of the two cards changed are dropped as late (they were aimed at the previous card). */ @@ -849,6 +851,8 @@ const ui: GameUI = { Options, optionsSummary, Rules, + quickStart, + helpNow, }; export default ui; diff --git a/apps/web/src/games/dobble/quick.tsx b/apps/web/src/games/dobble/quick.tsx new file mode 100644 index 0000000..c6d4201 --- /dev/null +++ b/apps/web/src/games/dobble/quick.tsx @@ -0,0 +1,98 @@ +/** 도블 30초 규칙 요약 (docs/07 §5). Pictures reuse the game's own icons (art.tsx). */ +import type { ReactNode } from 'react'; +import { ICONS } from './art'; +import type { QuickCard } from '../types'; + +// Icon ids (art.tsx order): 사과 0, 해 1, 별 3, 하트 4, 꽃 11, 고양이 18, 물고기 20, 열쇠 28, 모자 36, 종 38, 자동차 41, 주사위 45. +const SLOTS: [number, number][] = [ + [0, 0], + [0, -26], + [24, -12], + [24, 14], + [0, 28], + [-24, 14], + [-24, -12], +]; + +function Icon({ art, x, y, size = 20, ring }: { art: number; x: number; y: number; size?: number; ring?: 'ok' | 'bad' }) { + const draw = ICONS[art]; + return ( + + {ring && } + {draw ? draw() : null} + + ); +} + +/** A round card at (cx, cy) with up to 7 icons; `rings` marks icons by id. */ +function RoundCard({ cx, cy, arts, rings = {} }: { cx: number; cy: number; arts: number[]; rings?: Record }) { + return ( + + + {arts.map((a, i) => ( + + ))} + + ); +} + +function Pic({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} + +const A = [18, 0, 1, 3, 11, 28, 45]; +const B = [20, 41, 18, 36, 4, 38, 33]; + +export const quickStart: QuickCard[] = [ + { + pic: ( + + + + + 가운데 카드 + + + 내 카드 + + + ), + alt: '그림이 여러 개 그려진 동그란 카드 두 장이 나란히 있어요', + title: '두 카드를 비교해요', + text: '동그란 카드 두 장에는 똑같은 그림이 언제나 딱 하나 있어요. 차례 없이 모두 동시에 그 그림을 찾아요.', + }, + { + pic: ( + + + + + 고양이 찾았다! + + + ), + alt: '두 카드 모두에 있는 고양이 그림에 초록 동그라미, 한쪽에만 있는 물고기에 빨간 동그라미가 있어요', + title: '같은 그림을 먼저 눌러요', + text: '찾은 그림을 두 카드 중 아무 쪽에서나 누르면 돼요. 가장 먼저 누른 사람만 성공이에요. 틀리면 방 규칙에 따라 잠깐 못 눌러요.', + }, + { + pic: ( + + {[0, 1, 2, 3].map((i) => ( + + ))} + + + 🏆 + + + ), + alt: '동그란 카드가 탑처럼 쌓여 있고 옆에 트로피가 있어요', + title: '미니게임마다 목표가 달라요', + text: '탑 쌓기는 많이 모으면, 우물은 내 카드를 먼저 비우면, 독이 든 선물은 적게 받으면, 뜨거운 감자는 벌점이 적으면 이겨요.', + }, +]; diff --git a/apps/web/src/games/gostop/help.test.ts b/apps/web/src/games/gostop/help.test.ts new file mode 100644 index 0000000..c467988 --- /dev/null +++ b/apps/web/src/games/gostop/help.test.ts @@ -0,0 +1,148 @@ +import { expect, test } from 'bun:test'; +import { applyAction, assertCleanText, eachView, seatIds, setupGame, usersOf } from '../help-kit.test-util'; +import { GAMES } from '@bg/games'; +import { helpNow } from './help'; + +const users = usersOf(seatIds(5)); +const view = (s: unknown, who: string | null) => GAMES.gostop!.view(s, who) as any; +const help = (s: unknown, who: string | null) => helpNow(view(s, who), who, users); + +// Card ids: month m → (m-1)*4 + slot. 1월 0광 1홍단, 2월 5홍단, 3월 8광 9홍단 10·11피, 4월 13초단, 6월 21청단, 8월 28광, 9월 32국진. +const SEVEN = [0, 8, 28, 1, 5, 9, 13, 21]; // 삼광 3 + 홍단 3 + 띠 5장 1 = 7점 + +/** 2인 맞고 state with exactly these cards, A to act. */ +function rig(spec: { hands: number[][]; floor?: number[]; deck?: number[]; captured?: number[][] }, opts: Record = {}) { + const { state } = setupGame('gostop', 2, { chongtong: 'off', bonusCards: 'none', ...opts }); + const r = state.round; + r.participants = [0, 1]; + r.turn = 0; + r.step = 'play'; + r.hands = spec.hands.map((h) => [...h]); + r.deck = [...(spec.deck ?? [36, 37, 38, 39])]; + r.captured = [spec.captured?.[0] ?? [], spec.captured?.[1] ?? []]; + const floor: { month: number; cards: number[]; ppeokBy: null; natural: boolean }[] = []; + for (const c of spec.floor ?? []) { + const m = Math.floor(c / 4) + 1; + const g = floor.find((x) => x.month === m); + if (g) g.cards.push(c); + else floor.push({ month: m, cards: [c], ppeokBy: null, natural: false }); + } + r.floor = floor.sort((a, b) => a.month - b.month); + return state; +} + +test('고스톱: 내 차례 / 상대 차례 / 관전 (맞고)', () => { + const s = rig({ hands: [[2, 25, 42], [11, 15, 43]], floor: [3, 24, 40], captured: [[0, 8], [44]] }); + const a = help(s, 'A'); + expect(a).toContain('내 차례예요'); + expect(a).toContain('초록 테두리 패'); + expect(a).toContain('지금 내 점수 0점'); + expect(a).toContain('7점부터 고·스톱'); + const b = help(s, 'B'); + expect(b).toContain('호랑이님 차례예요'); + expect(b).toContain('기다려 주세요'); + const spec = help(s, null); + expect(spec).toContain('호랑이님 차례예요'); + expect(spec).not.toContain('내 점수'); +}); + +test('고스톱: 흔들기·폭탄·두 장 중 고르기·빈패 안내', () => { + // 5월 3장(바닥 없음) → 흔들기, 7월 3장 + 바닥 1장 → 폭탄, 3월(바닥 광·피) → 고르기. + const s = rig({ hands: [[16, 17, 18, 24, 25, 26, 11], [2]], floor: [27, 8, 10] }); + s.round.bombDummies[0] = 2; + const a = help(s, 'A'); + expect(a).toContain('[흔들고 내기]'); + expect(a).toContain('[폭탄!]'); + expect(a).toContain('어느 패를 가져올까요?'); + expect(a).toContain('[빈패 내기 (2장 남음)]'); +}); + +test('고스톱: 고/스톱 선택 — 버튼 이름, 받을 점수, 상대가 낼 점수', () => { + let s = rig({ hands: [[25, 42], [11, 15]], floor: [24, 40], captured: [SEVEN.slice(0, -1).concat([33]), [44]] }); + s = applyAction('gostop', s, 'A', { type: 'play', card: 25 }); + expect(s.round.step).toBe('goStop'); + const a = help(s, 'A'); + expect(a).toContain('[고]'); + expect(a).toContain('[스톱]'); + expect(a).toContain('8점이 났어요'); + expect(a).toContain('고박'); + const b = help(s, 'B'); + expect(b).toContain('호랑이님이 8점을 내서 고 또는 스톱을 고르는 중'); + expect(b).toMatch(/나는 \d+점을 내요/); + expect(help(s, null)).toContain('고 또는 스톱을 고르는 중'); + // 스톱 → 판 결과 + const r = applyAction('gostop', s, 'A', { type: 'goStop', decision: 'stop' }); + expect(r.phase).toBe('roundResult'); + expect(help(r, 'A')).toContain('내가 스톱해서 이겼어요'); + expect(help(r, 'A')).toContain('[다음 판으로]'); + expect(help(r, 'B')).toContain('호랑이님이 스톱해서 이겼어요'); + expect(help(r, null)).toContain('모두 준비하면'); +}); + +test('고스톱: 마지막 판이 끝나면 최종 결과', () => { + let s = rig({ hands: [[25, 42], [11, 15]], floor: [24, 40], captured: [SEVEN.slice(0, -1).concat([33]), [44]] }, { rounds: 1 }); + s = applyAction('gostop', s, 'A', { type: 'play', card: 25 }); + s = applyAction('gostop', s, 'A', { type: 'goStop', decision: 'stop' }); + expect(s.phase).toBe('finished'); + expect(help(s, 'A')).toContain('게임이 끝났어요'); + expect(help(s, 'A')).toContain('[한 판 더]'); + expect(help(s, null)).not.toContain('[한 판 더]'); +}); + +test('고스톱: 뒤집은 패 고르기, 국진, 총통', () => { + const f = rig({ hands: [[25], [11]], floor: [8, 9] }); + f.round.step = 'chooseFlip'; + f.round.pending = { flipped: 10, options: [8, 9], ctx: { player: 0, played: [25], flipped: [10], captured: [], tags: [], steal: 0, lastOfRound: false } }; + expect(help(f, 'A')).toContain('창에서 가져올 패를 눌러 골라요'); + expect(help(f, 'B')).toContain('호랑이님이 뒤집은 패'); + + const g = rig({ hands: [[25], [11]], captured: [[0, 8, 28, 4, 12, 16, 20, 32, 2, 3, 6, 7, 10, 11, 14], [44]] }, { gukjinMode: 'ask' }); + g.round.step = 'gukjin'; + g.round.stopBy = 0; + const gk = help(g, 'A'); + expect(gk).toContain('[열끗으로]'); + expect(gk).toContain('[쌍피로]'); + expect(help(g, 'B')).toContain('호랑이님이 9월 열끗(국진)'); + + const c = rig({ hands: [[0, 1, 2, 3, 4], [11]] }, { chongtong: 'win' }); + c.round.step = 'chongtong'; + const ct = help(c, 'A'); + expect(ct).toContain('[총통 선언]'); + expect(ct).toContain('[그냥 치기]'); + expect(ct).toContain('1월'); + const other = help(c, 'B'); + expect(other).toContain('총통'); + expect(other).not.toContain('1월'); +}); + +test('고스톱: 4명 광팔기 — 고르는 사람 / 기다리는 사람 / 관전', () => { + const { state } = setupGame('gostop', 4, {}); + expect(state.round.step).toBe('sell'); + const cur = view(state, null).sell.current as string; + expect(help(state, cur)).toContain('[치기]'); + expect(help(state, cur)).toContain('[죽기'); + const dealer = view(state, null).dealer as string; + expect(help(state, dealer)).toContain('나는 선이라'); + expect(help(state, null)).toContain('칠지(치기)'); +}); + +test('고스톱: 2명·4명 무작위 판의 모든 화면에서 깨끗한 안내', () => { + for (const [n, options] of [ + [2, {}], + [2, { gukjinMode: 'ask', rounds: 3 }], + [4, { rounds: 3 }], + ] as const) { + const u = usersOf(seatIds(n)); + let count = 0; + eachView( + 'gostop', + n, + (v, who) => { + assertCleanText(helpNow(v, who, u), `gostop n=${n} viewer=${who} step=${v.step} phase=${v.phase}`); + count++; + }, + { steps: 600, options: options as Record }, + ); + expect(count).toBeGreaterThan(n * 10); + } +}); diff --git a/apps/web/src/games/gostop/help.ts b/apps/web/src/games/gostop/help.ts new file mode 100644 index 0000000..0a1e503 --- /dev/null +++ b/apps/web/src/games/gostop/help.ts @@ -0,0 +1,165 @@ +/** 고스톱 [?] "지금 할 수 있는 것" (docs/07 §5). Pure: only reads the client view. */ +import type { BakKind, GostopView, SeatView } from '@bg/games/gostop'; +import type { PublicUser } from '@bg/shared/lite'; + +const BAK: Record = { pi: '피박', gwang: '광박', meong: '멍박', go: '고박' }; + +function myScoreLine(v: GostopView, seat: SeatView): string { + const s = seat.score; + const items = s.items.length ? `(${s.items.map((x) => `${x.name} ${x.points}점`).join(' · ')})` : ''; + const go = seat.goCount > 0 ? ` 나는 지금 ${seat.goCount}고예요.` : ''; + return `지금 내 점수 ${s.total}점${items}, ${v.rules.winScore}점부터 고·스톱을 골라요.${go}`; +} + +export function helpNow(v: GostopView, me: string | null, players: Record): string { + const nm = (id: string | null | undefined) => { + const n = id ? players[id]?.nickname : undefined; + return n ? `${n}님` : '다른 사람'; + }; + const seat = me ? v.seats.find((s) => s.id === me) : undefined; + const seatOf = (id: string | null) => v.seats.find((s) => s.id === id); + + if (v.phase === 'finished') { + const lines = [`게임이 끝났어요. ${v.result?.summary ?? ''}`.trim()]; + lines.push(seat ? `내 방 점수 합계는 ${seat.total > 0 ? '+' : ''}${seat.total}점이에요. '최종 순위'에서 순위를 볼 수 있어요.` : "'최종 순위'에서 순위를 볼 수 있어요."); + if (seat) lines.push('[한 판 더]를 누르면 다시 할 수 있어요.'); + return lines.join('\n'); + } + + if (v.phase === 'roundResult') { + const o = v.outcome; + const lines: string[] = []; + if (!o || o.type === 'nagari') { + const next = v.rules.nagariMultiplier === 'x2' ? Math.min(2 ** v.nagariStreak, v.rules.nagariMaxMultiplier) : 1; + lines.push(`${v.roundNo}판은 나가리예요. 아무도 나지 못해서 점수가 오가지 않았어요.${next > 1 ? ` 다음 판은 점수 x${next}이에요.` : ''}`); + } else { + const who = o.winner && o.winner === me ? '내가' : `${nm(o.winner)}이`; + const how = o.type === 'chongtong' ? '총통으로' : o.type === 'samppeok' ? '삼뻑으로' : '스톱해서'; + const d = seat ? (o.delta[seat.id] ?? 0) : 0; + lines.push(`${v.roundNo}판은 ${who} ${how} 이겼어요.${seat ? ` 이번 판 내 점수 변화는 ${d > 0 ? '+' : ''}${d}점이에요.` : ''} 결과 표에서 족보별 점수와 배수를 볼 수 있어요.`); + } + if (seat && !seat.left) { + lines.push(seat.ready ? '다른 사람이 [다음 판으로]를 누르기를 기다리는 중이에요.' : '[다음 판으로]를 눌러 주세요. 10초가 지나면 저절로 넘어가요.'); + } else lines.push('모두 준비하면 다음 판이 시작돼요.'); + return lines.join('\n'); + } + + const lines: string[] = []; + if (seat?.autoPilot && !seat.left) lines.push('시간을 두 번 넘겨서 지금은 자동으로 진행돼요. 직접 고르면 자동이 풀려요.'); + const mult = v.nagariMult > 1 ? ` 이번 판은 나가리 다음 판이라 점수 x${v.nagariMult}이에요.` : ''; + + switch (v.step) { + case 'sell': { + const s = v.sell; + const cur = s?.current ?? null; + if (seat && cur === me && s) { + const n = s.mySellable?.length ?? 0; + const gain = n * v.rules.gwangPrice * (v.rules.gwangPayer === 'nonDealerPlayers' ? 2 : 3); + lines.push('광팔기 차례예요. 4~5명이면 이번 판은 3명만 쳐요(선은 꼭 쳐요).'); + lines.push( + `[치기]를 누르면 이번 판에 참가해요. ${n > 0 ? `[죽기 · 광 ${n}장 팔기]를 누르면 ${gain}점을 받고 이번 판은 쉬어요.` : '[죽기]를 누르면 팔 패가 없어 그냥 쉬어요.'} 시간이 지나면 죽기로 정해져요.`, + ); + } else if (seat) { + const mine = s?.decisions.find((d) => d.player === me); + const self = seat.isDealer ? '나는 선이라 이번 판에 꼭 쳐요.' : mine ? `나는 ${mine.sell ? '죽기(광 팔기)' : '치기'}로 정했어요.` : '내 차례가 오면 칠지 죽을지 골라요.'; + lines.push(`${cur ? `${nm(cur)}이 이번 판에 칠지 죽을지 고르는 중이에요.` : '광팔기를 정리하는 중이에요.'} ${self}`); + } else { + lines.push(`${cur ? `${nm(cur)}이 이번 판에 칠지(치기), 광을 팔고 쉴지(죽기) 고르는 중이에요.` : '광팔기를 정리하는 중이에요.'} 4~5명이면 3명만 쳐요.`); + } + return lines.join('\n'); + } + case 'chongtong': { + const who = v.chongtongAsk?.player ?? v.turn; + if (seat && who === me) { + const months = v.chongtongAsk?.months ?? []; + const others = v.seats.filter((x) => x.participant && x.id !== me).length; + const each = v.rules.chongtongPoints * v.nagariMult; + lines.push(`총통이에요! 손에 같은 월 4장${months.length ? `(${months.map((m) => `${m}월`).join(', ')})` : ''}이 있어요.`); + lines.push(`[총통 선언]을 누르면 이번 판을 바로 이기고 상대마다 ${each}점, 모두 ${each * others}점을 받아요. [그냥 치기]를 누르면 판을 계속하고, 그 월로 흔들기나 폭탄을 할 수 있어요.`); + lines.push('시간이 지나면 총통 선언으로 정해져요.'); + } else { + lines.push(`${nm(who)}이 총통(손에 같은 월 4장)을 선언할지 고르는 중이에요. 선언하면 ${nm(who)}이 이번 판을 바로 이겨요.`); + } + return lines.join('\n'); + } + case 'play': { + const who = v.turn; + if (seat && who === me && seat.participant) { + lines.push(`내 차례예요. 손패에서 낼 패를 눌러요(패가 살짝 올라오면 한 번 더 눌러요). 낸 패와 더미에서 뒤집은 패가 바닥의 같은 월 패와 맞으면 함께 가져와요.${mult}`); + const hand = v.myHand ?? []; + const extra: string[] = []; + if (v.rules.hints) { + const canEat = hand.filter((h) => h.matches.length > 0).length; + extra.push(canEat > 0 ? '초록 테두리 패는 바닥에 같은 월이 있어 먹을 수 있어요.' : '지금은 바닥과 맞는 패가 없어요. 덜 아까운 패를 내요.'); + const tips = hand.filter((h) => h.hint && h.card.kind !== 'bonus').slice(0, 2); + if (tips.length) extra.push(`힌트: ${tips.map((h) => `${h.card.name} → ${h.hint}`).join(', ')}.`); + } + if (hand.some((h) => h.card.kind === 'bonus')) extra.push('보너스패는 내면 바로 내 피가 되고, 더미에서 1장을 받아 한 번 더 내요.'); + if (hand.some((h) => h.canBomb)) extra.push("'폭탄' 표시 패를 누르면 [폭탄!]과 [1장만 내기] 중에서 골라요. 폭탄은 같은 월 3장을 한꺼번에 내서 바닥 1장까지 가져오고, 상대마다 피 1장을 받아요(이기면 x2)."); + if (hand.some((h) => h.canShake)) extra.push("'흔들기' 표시 패를 누르면 [흔들고 내기]와 [그냥 내기] 중에서 골라요. 흔들면 같은 월 3장을 보여 주고, 이번 판을 이기면 점수가 2배예요."); + if (hand.some((h) => h.needTarget)) extra.push("바닥에 같은 월이 2장인 패를 내면 '어느 패를 가져올까요?' 창에서 가져올 패를 눌러 골라요."); + if (v.myDummies > 0) extra.push(`[빈패 내기 (${v.myDummies}장 남음)]를 누르면 손패를 쓰지 않고 더미만 뒤집어요.`); + lines.push(...extra); + lines.push(myScoreLine(v, seat)); + return lines.join('\n'); + } + if (seat && !seat.participant) { + lines.push(`이번 판은 광을 팔고 쉬어요. 지금은 ${nm(who)} 차례예요.`); + return lines.join('\n'); + } + if (seat) { + lines.push(`${nm(who)} 차례예요. 패를 낼 때까지 기다려 주세요.${mult}`); + lines.push(myScoreLine(v, seat)); + return lines.join('\n'); + } + const ws = seatOf(who); + lines.push(`${nm(who)} 차례예요. 손패 1장을 내고 더미에서 1장을 뒤집어, 바닥의 같은 월 패를 가져가요.${mult}`); + if (ws) lines.push(`${nm(who)}은 지금 ${ws.score.total}점이에요${ws.goCount > 0 ? `(${ws.goCount}고)` : ''}. ${v.rules.winScore}점부터 고·스톱을 골라요.`); + return lines.join('\n'); + } + case 'chooseFlip': { + const pf = v.pendingFlip; + const who = pf?.player ?? v.turn; + if (seat && who === me && pf) { + lines.push(`뒤집은 ${pf.flipped.name}과(와) 같은 월 패가 바닥에 2장(${pf.options.map((c) => c.name).join(', ')}) 있어요. 창에서 가져올 패를 눌러 골라요.`); + lines.push('시간이 지나면 더 좋은 패를 저절로 가져와요.'); + } else { + lines.push(`${nm(who)}이 뒤집은 패${pf ? `(${pf.flipped.name})` : ''}로 가져올 바닥 패를 고르는 중이에요.`); + } + if (seat?.participant && who !== me) lines.push(myScoreLine(v, seat)); + return lines.join('\n'); + } + case 'goStop': { + const g = v.goStop; + const who = g?.player ?? v.turn; + if (seat && who === me && g) { + const notes = g.stopNow.notes.length ? `(${g.stopNow.notes.join(', ')})` : ''; + const baks = g.stopNow.perLoser.filter((x) => x.baks.length).map((x) => `${nm(x.player)} ${x.baks.map((b) => BAK[b]).join('·')}`); + const risk = v.rules.goBak ? (v.mode === 'matgo' ? ' 고를 하고 상대가 먼저 나면 고박으로 2배를 내요.' : ' 고를 하고 다른 사람이 먼저 나면 독박으로 진 사람 몫을 혼자 다 낼 수 있어요.') : ' 고를 하고 다른 사람이 먼저 나면 내가 지게 돼요.'; + lines.push(`${g.score}점이 났어요! [고] 또는 [스톱]을 골라요.${g.goCount > 0 ? ` 지금까지 ${g.goCount}고 했어요.` : ''}`); + lines.push(`[스톱]: 판을 끝내고 ${g.stopNow.total}점을 받아요${notes}.${baks.length ? ` ${baks.join(', ')}이 붙어요.` : ''}`); + lines.push(`[고]: ${g.goNext.goCount}고로 판을 계속해요. +${g.goNext.plus}점${g.goNext.factor > 1 ? `, 점수 x${g.goNext.factor}` : ''}.${risk}`); + lines.push('시간이 지나면 스톱으로 정해져요.'); + } else { + lines.push(`${nm(who)}이 ${g ? `${g.score}점을 내서 ` : ''}고 또는 스톱을 고르는 중이에요. 스톱하면 이번 판이 끝나요.`); + const mine = g?.stopNow.perLoser.find((x) => x.player === me); + if (seat && mine) lines.push(`${nm(who)}이 지금 스톱하면 나는 ${mine.amount}점을 내요${mine.baks.length ? `(${mine.baks.map((b) => BAK[b]).join('·')})` : ''}.`); + } + return lines.join('\n'); + } + case 'gukjin': { + const g = v.gukjinAsk; + const who = g?.player ?? v.turn; + if (seat && who === me && g) { + lines.push('판이 끝나기 전에 내가 가진 9월 열끗(국진)을 어떻게 셀지 골라요.'); + lines.push(`[열끗으로]: 내 점수 ${g.yeolScore}점. [쌍피로](피 2장 값): 내 점수 ${g.piScore}점. 시간이 지나면 나에게 유리한 쪽으로 정해져요.`); + } else { + lines.push(`스톱이 나왔어요. ${nm(who)}이 9월 열끗(국진)을 열끗으로 셀지 쌍피로 셀지 정하는 중이에요. 정하면 이번 판 점수를 계산해요.`); + } + return lines.join('\n'); + } + default: + lines.push('잠시만 기다려 주세요.'); + return lines.join('\n'); + } +} diff --git a/apps/web/src/games/gostop/index.tsx b/apps/web/src/games/gostop/index.tsx index 4c17a66..cc7b06e 100644 --- a/apps/web/src/games/gostop/index.tsx +++ b/apps/web/src/games/gostop/index.tsx @@ -8,6 +8,8 @@ import { HwatuCard, type HwatuInfo } from '../../components/cards/HwatuCard'; import { ChipIcon } from '../../components/cards/Chips'; import { haptic, sounds } from '../../lib/feedback'; import type { BoardProps, GameUI, OptionsProps } from '../types'; +import { helpNow } from './help'; +import { quickStart } from './quick'; const nick = (players: Record, id: string | null | undefined) => (id ? (players[id]?.nickname ?? '알 수 없음') : ''); const userOf = (players: Record, id: string): PublicUser => players[id] ?? { id, nickname: '알 수 없음', avatar: null, kind: 'guest' }; @@ -1169,6 +1171,8 @@ const ui: GameUI = { Options, optionsSummary, Rules, + quickStart, + helpNow, }; export default ui; diff --git a/apps/web/src/games/gostop/quick.tsx b/apps/web/src/games/gostop/quick.tsx new file mode 100644 index 0000000..6673869 --- /dev/null +++ b/apps/web/src/games/gostop/quick.tsx @@ -0,0 +1,87 @@ +/** 고스톱 30초 규칙 요약 (docs/07 §5). */ +import { HwatuCard, type HwatuInfo } from '../../components/cards/HwatuCard'; +import type { QuickCard } from '../types'; + +const card = (id: number, month: number, kind: HwatuInfo['kind'], name: string, tti?: HwatuInfo['tti']): HwatuInfo => ({ id, month, kind, name, tti }); + +const row = { display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6 } as const; +const label = { fontWeight: 900, fontSize: '1.4rem' } as const; + +function Match() { + return ( +
+
+ + 내 손패 +
+ → +
+ + 바닥 +
+ = +
+ + +
+
+ ); +} + +function Jokbo() { + return ( +
+
+ + + + 홍단 3점 +
+
+ + + + 삼광 3점 +
+
+ ); +} + +function GoStop() { + const btn = (bg: string, text: string, sub: string) => ( +
+ {text} + {sub} +
+ ); + return ( +
+ 7점 났어요! +
+ {btn('#dc2626', '고', '계속해요')} + {btn('#2563eb', '스톱', '받고 끝내요')} +
+
+ ); +} + +export const quickStart: QuickCard[] = [ + { + pic: , + alt: '손패의 3월 광을 내서 바닥의 3월 피와 짝지어 두 장을 가져와요', + title: '같은 월끼리 먹어요', + text: '내 차례에 손패 1장을 내고 더미에서 1장을 뒤집어요. 바닥에 같은 월(숫자) 패가 있으면 함께 가져와요.', + }, + { + pic: , + alt: '홍단 띠 3장은 3점, 광 3장은 3점이라는 그림이에요', + title: '모은 패로 점수를 내요', + text: '광 3장, 홍단·청단·초단 띠 3장, 피 10장(쌍피는 2장 값)처럼 족보를 모으면 점수가 나요. 게임 중 [족보표]에서 볼 수 있어요.', + }, + { + pic: , + alt: '7점이 나서 빨간 고 버튼과 파란 스톱 버튼이 보여요', + title: '고? 스톱?', + text: '기준 점수(기본 맞고 7점, 고스톱 3점)가 나면 스톱으로 끝내고 이기거나, 고로 더 키워요. 고 뒤에 남이 먼저 나면 손해예요.', + }, +]; diff --git a/apps/web/src/games/halli-galli/help.test.ts b/apps/web/src/games/halli-galli/help.test.ts new file mode 100644 index 0000000..65b7cf0 --- /dev/null +++ b/apps/web/src/games/halli-galli/help.test.ts @@ -0,0 +1,55 @@ +import { expect, test } from 'bun:test'; +import { setupGame, usersOf } from '../help-kit.test-util'; +import { GAMES } from '@bg/games'; +import { helpNow } from './help'; + +const users = usersOf(['A', 'B', 'C']); +const view = (s: unknown, who: string | null) => GAMES['halli-galli']!.view(s, who) as any; + +function playing(options: Record = {}) { + const { state } = setupGame('halli-galli', 3, options); + state.phase = 'playing'; + return state; +} + +test('할리갈리: 카운트다운 / 내 차례 / 남의 차례 / 관전', () => { + const { state: s0 } = setupGame('halli-galli', 3); + expect(helpNow(view(s0, 'A'), 'A', users)).toContain('곧 시작해요'); + const s = playing(); + const turn: string = s.turn; + const other = ['A', 'B', 'C'].find((p) => p !== turn)!; + const t = helpNow(view(s, turn), turn, users); + expect(t).toContain('[뒤집기]'); + expect(t).toContain('딱 5개'); + expect(t).toContain('3초'); + const o = helpNow(view(s, other), other, users); + expect(o).toContain(`${users[turn]!.nickname}님이 뒤집을 차례예요`); + expect(o).toContain('종은 언제든'); + expect(helpNow(view(s, null), null, users)).toContain(`${users[turn]!.nickname}님이 카드를 뒤집을 차례예요`); +}); + +test('할리갈리: 자동 뒤집기·벌칙 규칙과 덱 0장', () => { + const s = playing({ autoFlip: true, wrongToPot: true, strikeOut: true }); + const turn: string = s.turn; + const t = helpNow(view(s, turn), turn, users); + expect(t).toContain('자동으로 뒤집혀요'); + expect(t).toContain('종 밑에'); + expect(t).toContain('3번 틀리면 탈락'); + const other = ['A', 'B', 'C'].find((p) => p !== turn)!; + s.decks[other] = []; + expect(helpNow(view(s, other), other, users)).toContain('틀리면 바로 탈락'); +}); + +test('할리갈리: 2명 남은 원작 규칙 / 탈락 / 끝', () => { + const s = playing({ endMode: 'official' }); + const [a, b, c] = s.order as string[]; + s.active = [a, b]; + s.eliminated = [c]; + s.outReason = { [c!]: 'empty' }; + s.officialDuel = true; + expect(helpNow(view(s, a!), a!, users)).toContain('다음 종으로 게임이 끝나고'); + expect(helpNow(view(s, c!), c!, users)).toContain('탈락했어요'); + s.phase = 'ended'; + s.final = { endReason: 'official', ranking: [], summary: '호랑이님 우승!' }; + expect(helpNow(view(s, a!), a!, users)).toContain('호랑이님 우승!'); +}); diff --git a/apps/web/src/games/halli-galli/help.ts b/apps/web/src/games/halli-galli/help.ts new file mode 100644 index 0000000..9298cf5 --- /dev/null +++ b/apps/web/src/games/halli-galli/help.ts @@ -0,0 +1,45 @@ +/** 할리갈리 [?] "지금 할 수 있는 것" (docs/07 §5). Pure: only reads the client view. */ +import type { HalliGalliView } from '@bg/games/halli-galli'; +import type { PublicUser } from '@bg/shared/lite'; + +const BELL = '펼쳐진 카드 맨 위 그림을 모두 합쳐 한 과일이 딱 5개가 되면 가운데 [종!]을 먼저 눌러요. 4개나 6개일 때는 누르면 안 돼요.'; + +function penalty(v: HalliGalliView): string { + const o = v.options; + const where = o.wrongToPot ? '종 밑에 카드를 넣어야 해요' : '다른 사람들에게 카드를 1장씩 줘야 해요'; + const more = o.penaltyEscalation ? '(틀릴 때마다 벌칙 장수가 늘어요)' : ''; + const strike = o.strikeOut ? ' 3번 틀리면 탈락이에요.' : ''; + const alive = v.seats.filter((s) => s.status === 'playing').length; + if (alive === 2 && o.duelWrongLoses && !v.officialDuel) return '1대1이라 이 방 규칙에서는 잘못 누르면 바로 져요.'; + return `잘못 누르면 ${where}${more}.${strike}`; +} + +export function helpNow(v: HalliGalliView, me: string | null, players: Record): string { + const nick = (id: string | null) => (id ? (players[id]?.nickname ?? '다른 사람') : '다른 사람'); + if (v.phase === 'ended') return `게임이 끝났어요. ${v.result?.summary ?? ''}`.trim() + '\n최종 순위에서 각자 남은 카드 수를 볼 수 있어요.'; + if (v.phase === 'countdown') { + return `곧 시작해요. 차례대로 카드를 한 장씩 뒤집어요.\n${BELL}`; + } + const mine = v.seats.find((s) => s.id === me) ?? null; + const duel = v.officialDuel ? '\n2명만 남았어요. 다음 종으로 게임이 끝나고 카드가 많은 사람이 이겨요.' : ''; + if (!mine) { + return `${nick(v.turn)}님이 카드를 뒤집을 차례예요. 한 과일이 딱 5개가 되는 순간 종을 먼저 누른 사람이 펼친 카드를 모두 가져가요.${duel}`; + } + if (mine.status !== 'playing') { + return `${mine.status === 'left' ? '게임에서 나갔어요.' : '탈락했어요.'} 남은 사람들의 게임을 지켜봐요. 지금은 ${nick(v.turn)}님이 뒤집을 차례예요.`; + } + const lines: string[] = []; + if (v.turn === me) { + if (v.options.autoFlip) lines.push('내 차례예요. 이 방은 카드가 자동으로 뒤집혀요.'); + else if (v.pendingFlip) lines.push('내 차례예요. 뒤집기를 눌렀어요. 곧 카드가 펼쳐져요.'); + else if (mine.deck === 0) lines.push('내 차례인데 내 덱(뒤집을 카드 더미)이 비었어요.'); + else lines.push(`내 차례예요. 아래 [뒤집기]를 눌러 카드를 한 장 펼쳐요. ${v.flipTotalMs / 1000}초가 지나면 자동으로 뒤집혀요.`); + } else { + lines.push(`${nick(v.turn)}님이 뒤집을 차례예요. 차례가 아니어도 종은 언제든 누를 수 있어요.`); + if (mine.deck === 0) lines.push('내 덱이 0장이에요. 내 차례가 오기 전에 종을 맞히면 카드를 얻어 계속할 수 있지만, 틀리면 바로 탈락이에요.'); + } + lines.push(BELL); + if (v.options.kidMode) lines.push('아래 과일 합계 막대에서 숫자가 5인 과일이 있는지 보세요.'); + lines.push(`맞히면 펼친 카드를 모두 가져가요. ${penalty(v)}`); + return lines.join('\n') + duel; +} diff --git a/apps/web/src/games/halli-galli/index.tsx b/apps/web/src/games/halli-galli/index.tsx index 98faa89..a244d24 100644 --- a/apps/web/src/games/halli-galli/index.tsx +++ b/apps/web/src/games/halli-galli/index.tsx @@ -7,6 +7,8 @@ import { useServerNow } from '../../components/TurnTimer'; import { haptic, sounds } from '../../lib/feedback'; import { socket, useRoom } from '../../net/socket'; import type { BoardProps, GameUI, OptionsProps } from '../types'; +import { helpNow } from './help'; +import { quickStart } from './quick'; const FRUITS: Fruit[] = ['strawberry', 'banana', 'lime', 'plum']; const FRUIT_KO: Record = { strawberry: '딸기', banana: '바나나', lime: '라임', plum: '자두' }; @@ -955,6 +957,8 @@ const ui: GameUI = { Options, optionsSummary, Rules, + quickStart, + helpNow, }; export default ui; diff --git a/apps/web/src/games/halli-galli/quick.tsx b/apps/web/src/games/halli-galli/quick.tsx new file mode 100644 index 0000000..25eb6c9 --- /dev/null +++ b/apps/web/src/games/halli-galli/quick.tsx @@ -0,0 +1,130 @@ +/** 할리갈리 30초 규칙 요약 (docs/07 §5). Simple redraws (the real card art lives in index.tsx). */ +import type { ReactNode } from 'react'; +import type { QuickCard } from '../types'; + +type F = 'strawberry' | 'banana'; +const INK: Record = { strawberry: '#e11d48', banana: '#ca8a04' }; + +/** One fruit centred at (x, y), about 16 units wide. */ +function Fruit({ f, x, y }: { f: F; x: number; y: number }) { + if (f === 'strawberry') { + return ( + + + + + ); + } + return ( + + + + ); +} + +const SPOTS: Record = { + 1: [[20, 28]], + 2: [[20, 16], [20, 40]], + 3: [[11, 14], [20, 28], [29, 42]], + 4: [[11, 15], [29, 15], [11, 41], [29, 41]], + 5: [[11, 13], [29, 13], [20, 28], [11, 43], [29, 43]], +}; + +/** A fruit card 40×56 placed at (x, y). */ +function Card({ f, n, x, y }: { f: F; n: number; x: number; y: number }) { + return ( + + + {SPOTS[n]!.map(([fx, fy], i) => ( + + ))} + + ); +} + +function Back({ x, y }: { x: number; y: number }) { + return ( + + + + + ); +} + +function Bell({ x, y }: { x: number; y: number }) { + return ( + + + + + + + ); +} + +function Table({ children }: { children: ReactNode }) { + return ( + + + {children} + + ); +} + +export const quickStart: QuickCard[] = [ + { + pic: ( + + + + + + + + +
+ ), + alt: '뒤집힌 내 카드 더미에서 한 장을 펼쳐 내 앞에 놓는 모습이에요', + title: '차례대로 한 장씩 펼쳐요', + text: '내 차례에 [뒤집기]를 누르면 내 카드가 한 장 펼쳐져요. 각자 앞에 맨 위 카드만 보여요.', + }, + { + pic: ( + + + + + + 딸기 2+3 = 5 + + + + 땡! + +
+ ), + alt: '딸기 2개 카드와 딸기 3개 카드가 보여서 딸기가 모두 5개예요. 종을 치면 땡!', + title: '딱 5개면 종을 쳐요', + text: '펼쳐진 카드에서 한 과일이 모두 합쳐 딱 5개면 가운데 [종!]을 먼저 눌러요. 맞히면 펼친 카드를 다 가져가요.', + }, + { + pic: ( + + + + + + 딸기 6개 + + + + + + +
+ ), + alt: '딸기가 모두 6개라서 종을 치면 안 된다는 빨간 ✕ 표시예요', + title: '틀리면 카드를 줘요', + text: '4개나 6개일 때 누르면 벌칙으로 카드를 내줘요. 카드를 다 잃으면 탈락이고, 끝까지 남은 사람(시간이 끝나면 카드가 가장 많은 사람)이 이겨요.', + }, +]; diff --git a/apps/web/src/games/help.test.ts b/apps/web/src/games/help.test.ts index 6d2f4ea..9991fa1 100644 --- a/apps/web/src/games/help.test.ts +++ b/apps/web/src/games/help.test.ts @@ -35,7 +35,10 @@ describe.each(IDS)('%s', (id) => { const { helpNow } = await import(`./${id}/help`); const def = GAMES[id]!; const range = def.playersFor?.(def.defaultOptions) ?? { min: def.minPlayers, max: def.maxPlayers }; - const n = Math.min(range.max, Math.max(range.min, 3)); + // 3 players where allowed; otherwise the nearest count the game accepts (e.g. 벽 쌓기 길찾기: 2 or 4). + const want = Math.min(range.max, Math.max(range.min, 3)); + const counts = Array.from({ length: range.max - range.min + 1 }, (_, i) => range.min + i).sort((a, b) => Math.abs(a - want) - Math.abs(b - want) || a - b); + const n = counts.find((c) => !def.playerCountError?.(def.defaultOptions, c)) ?? want; const users = usersOf(seatIds(n)); let count = 0; eachView(id, n, (view, who) => { diff --git a/apps/web/src/games/janggi/help.test.ts b/apps/web/src/games/janggi/help.test.ts new file mode 100644 index 0000000..222e307 --- /dev/null +++ b/apps/web/src/games/janggi/help.test.ts @@ -0,0 +1,72 @@ +import { expect, test } from 'bun:test'; +import { applyAction, setupGame, usersOf } from '../help-kit.test-util'; +import { GAMES } from '@bg/games'; +import { helpNow } from './help'; + +const users = usersOf(['A', 'B']); +const view = (s: unknown, who: string | null) => GAMES.janggi!.view(s, who) as any; +const mv = (f1: number, r1: number, f2: number, r2: number) => ({ type: 'move', from: { f: f1, r: r1 }, to: { f: f2, r: r2 } }); + +/** A = 초, B = 한; both 상차림 chosen (한 first), 초 to move. */ +function started(o: Record = {}) { + const { state } = setupGame('janggi', 2, { sideAssignment: 'hostCho', setupOrder: 'hanFirst', ...o }); + const s = applyAction('janggi', state, 'B', { type: 'chooseSetup', arrangement: 'MSSM' }); + return applyAction('janggi', s, 'A', { type: 'chooseSetup', arrangement: 'SMMS' }); +} + +/** Custom position in play (kept consistent with the rule module: 장군 flag from the real board). */ +function custom(pieces: [string, string, number, number][], turn: 'cho' | 'han', inCheck: 'cho' | 'han' | null) { + const s = started(); + s.board = new Array(90).fill(null); + for (const [side, kind, f, r] of pieces) s.board[r * 9 + f] = { side, kind }; + s.startBoard = s.board.slice(); + s.turn = turn; + s.inCheck = inCheck; + s.positionCounts = {}; + return s; +} + +test('장기: 상차림 단계 (한 먼저 고르기)', () => { + const { state } = setupGame('janggi', 2, { sideAssignment: 'hostCho', setupOrder: 'hanFirst' }); + expect(helpNow(view(state, 'B'), 'B', users)).toContain('상차림을 골라 주세요(나는 한)'); + const cho = helpNow(view(state, 'A'), 'A', users); + expect(cho).toContain('한이 먼저 상차림을 골라요'); + expect(cho).toContain('고양이님이 상차림을 고르는 중'); + expect(helpNow(view(state, null), null, users)).toContain('고양이님이 상차림'); + const s = applyAction('janggi', state, 'B', { type: 'chooseSetup', arrangement: 'MSSM' }); + expect(helpNow(view(s, 'A'), 'A', users)).toContain('한이 안상차림을 골랐어요'); +}); + +test('장기: 내 차례 / 상대 차례 / 관전 (점수 포함)', () => { + const s = started(); + const mine = helpNow(view(s, 'A'), 'A', users); + expect(mine).toContain('내 차례예요(초)'); + expect(mine).toContain('초록 점'); + expect(mine).toContain('[⏸ 한수쉼]'); + expect(mine).toContain('초 72점, 한 73.5점'); + expect(helpNow(view(s, 'B'), 'B', users)).toContain('나는 한이에요'); + expect(helpNow(view(s, null), null, users)).toContain('호랑이님(초) 차례예요'); +}); + +test('장기: 장군을 받으면 한수쉼 없이 궁을 지키라고 안내', () => { + const s = custom([['cho', 'king', 3, 0], ['han', 'king', 4, 9], ['cho', 'cha', 0, 9]], 'han', 'han'); + const t = helpNow(view(s, 'B'), 'B', users); + expect(t).toContain('장군이에요!'); + expect(t).not.toContain('[⏸ 한수쉼]'); + expect(helpNow(view(s, 'A'), 'A', users)).toContain('내가 장군을 불렀어요'); + expect(helpNow(view(s, null), null, users)).toContain('한 궁이 장군을 받았어요'); +}); + +test('장기: 빅장을 받은 쪽 안내 (점수제/비점수제)', () => { + for (const scoring of [true, false]) { + const s0 = started({ bikjang: 'kja30', scoring }); + const s = custom([['cho', 'king', 4, 1], ['cho', 'jol', 4, 4], ['cho', 'cha', 0, 0], ['han', 'king', 4, 8], ['han', 'jol', 0, 6], ['han', 'cha', 8, 5], ['han', 'sa', 3, 9]], 'cho', null); + s.options = s0.options; + const after = applyAction('janggi', s, 'A', mv(4, 4, 3, 4)); + expect(after.bikjang?.calledBy).toBe('cho'); + const t = helpNow(view(after, 'B'), 'B', users); + expect(t).toContain('빅장이에요!'); + expect(t).toContain(scoring ? '점수로 승부를 가려요' : '무승부로 끝나요'); + expect(helpNow(view(after, null), null, users)).toContain('초가 빅장을 불렀어요'); + } +}); diff --git a/apps/web/src/games/janggi/help.ts b/apps/web/src/games/janggi/help.ts new file mode 100644 index 0000000..bd12b78 --- /dev/null +++ b/apps/web/src/games/janggi/help.ts @@ -0,0 +1,81 @@ +/** 장기 [?] "지금 할 수 있는 것" (docs/07 §5). Pure: only reads the client view. */ +import type { Arrangement, JanggiView, Side } from '@bg/games/janggi'; +import type { PublicUser } from '@bg/shared/lite'; + +const SIDE_KO: Record = { cho: '초', han: '한' }; +const ARR_KO: Record = { MSSM: '안상차림', SMMS: '바깥상차림', SMSM: '왼상차림', MSMS: '오른상차림' }; +const other = (s: Side): Side => (s === 'cho' ? 'han' : 'cho'); +/** 초가 / 한이 (subject particle). */ +const SUBJ: Record = { cho: '초가', han: '한이' }; +/** 초예요 / 한이에요. */ +const IS: Record = { cho: '초예요', han: '한이에요' }; + +export function helpNow(v: JanggiView, _me: string | null, players: Record): string { + if (v.phase === 'finished') return `대국이 끝났어요. ${v.result?.summaryKo ?? ''}`.trim() + '\n결과 창에서 [한 판 더]를 누르면 다시 할 수 있어요.'; + const nick = (s: Side) => `${players[v.players[s]]?.nickname ?? SIDE_KO[s]}님`; + const judge = v.rules.scoring ? '점수로 승부를 가려요' : '무승부로 끝나요'; + const score = `지금 기물 점수는 초 ${v.scores.cho}점, 한 ${v.scores.han}점이에요${v.rules.scoring ? '(한은 덤 1.5점 포함)' : ''}.`; + const me = v.mySide; + + if (v.phase === 'setup') { + const waiting = v.setup.waitingFor; + if (!me) return waiting.length ? `${waiting.map(nick).join('·')}이 상차림(뒷줄 마·상 자리)을 고르는 중이에요. 다 고르면 초부터 둬요.` : '곧 대국이 시작돼요.'; + if (waiting.includes(me)) { + const han = v.setup.choice.han; + const lines = [ + me === 'cho' && v.setup.order === 'hanFirst' && han ? `한이 ${ARR_KO[han]}을 골랐어요. 이제 내 상차림을 골라요(나는 초).` : `상차림을 골라 주세요(나는 ${SIDE_KO[me]}).`, + '아래 네 가지(안상차림·바깥상차림·왼상차림·오른상차림) 중 하나를 누르면 뒷줄의 마·상 자리가 정해져요. 내 쪽에서 본 왼쪽→오른쪽 순서예요.', + ]; + if (v.setup.order === 'simultaneous') lines.push('상대는 내가 고른 것을 대국이 시작될 때까지 볼 수 없어요.'); + return lines.join('\n'); + } + const mine = v.setup.choice[me]; + const head = mine ? `${ARR_KO[mine]}을 골랐어요. ` : v.setup.order === 'hanFirst' ? '이 방은 한이 먼저 상차림을 골라요. ' : ''; + return `${head}${waiting.length ? `${waiting.map(nick).join('·')}이 상차림을 고르는 중이에요. 기다려 주세요.` : '곧 대국이 시작돼요.'}\n[대국 취소]를 누르면 승패 없이 그만둘 수 있어요.`; + } + + const lastMove = v.moves[v.moves.length - 1]; + if (!me) { + const lines = [`${nick(v.turn)}(${SIDE_KO[v.turn]}) 차례예요. 상대 궁을 피할 곳 없이 몰면(외통) 이겨요.`]; + if (v.inCheck) lines.push(`${SIDE_KO[v.inCheck]} 궁이 장군을 받았어요.`); + if (v.bikjang) lines.push(`${SUBJ[v.bikjang.calledBy]} 빅장을 불렀어요. ${SUBJ[other(v.bikjang.calledBy)]} 막지 못하면 ${judge}.`); + lines.push(score); + return lines.join('\n'); + } + if (v.undo.pending && v.undo.pending.by !== me) return `상대가 ${v.undo.pending.plies}수 무르기를 부탁했어요. [수락]을 누르면 기물이 되돌아가고, [거절]을 누르면 그대로 계속해요.`; + if (v.draw.pendingBy && v.draw.pendingBy !== me) return '상대가 무승부를 제안했어요. [수락]하면 이 판은 비기고 끝나요. 계속하려면 [거절]을 눌러 주세요.'; + + if (v.turn !== me) { + const lines = [`상대(${nick(v.turn)}) 차례예요. 상대가 둘 때까지 기다려 주세요. 나는 ${IS[me]}`]; + if (v.inCheck === v.turn) lines.push('내가 장군을 불렀어요. 상대는 궁을 지켜야 해요.'); + if (v.bikjang?.calledBy === me) lines.push(`내가 빅장을 불렀어요. 상대가 막지 못하면 ${judge}.`); + if (v.undo.pending || v.draw.pendingBy) lines.push(`내 ${v.undo.pending ? '무르기 요청' : '무승부 제안'}에 상대가 답하기를 기다리는 중이에요. 그만두려면 [요청 취소]를 눌러요.`); + lines.push(score); + return lines.join('\n'); + } + + const lines: string[] = []; + if (v.inCheck === me) lines.push('장군이에요! 내 궁이 공격받고 있어요(빨간 동그라미). 궁을 피하거나, 사이를 막거나, 공격한 기물을 잡아야 해요(멍군). 한수쉼은 할 수 없어요.'); + else lines.push(`내 차례예요(${SIDE_KO[me]}).`); + if (v.bikjang && v.bikjang.calledBy !== me) lines.push(`빅장이에요! 두 궁이 마주보고 있어요. 사이에 기물을 넣거나 궁을 옆으로 옮기지 않으면 ${judge}.`); + lines.push( + v.rules.hints === 'off' + ? '움직일 기물을 누른 뒤 갈 자리를 눌러요. 못 가는 자리면 이유를 알려 줘요.' + : '움직일 기물을 누르면 갈 수 있는 곳에 초록 점이 찍혀요(잡을 수 있는 상대 기물은 빨간 동그라미). 그 자리를 누르면 둬요.', + ); + if (v.canPass) { + if (lastMove?.kind === 'pass' && lastMove.side !== me) lines.push(`상대가 방금 한수쉼을 했어요. 나도 [⏸ 한수쉼]을 누르면 대국이 끝나고 ${judge}.`); + else lines.push('기물을 움직이지 않고 [⏸ 한수쉼]으로 차례를 넘길 수도 있어요.'); + } + if (v.repeatWarning) + lines.push( + v.rules.repetition === 'forbid' + ? '같은 국면이 3번 나왔어요. 한 번 더 반복하는 수는 둘 수 없어요.' + : v.rules.repetition === 'lose' + ? '같은 국면이 3번 나왔어요. 한 번 더 반복하면 실격패예요!' + : `같은 국면이 3번 나왔어요. 한 번 더 반복하면 ${judge}.`, + ); + if (v.threatened.length) lines.push('주황 점선 동그라미가 쳐진 내 기물은 상대가 잡을 수 있어요. 조심하세요.'); + lines.push(score); + return lines.join('\n'); +} diff --git a/apps/web/src/games/janggi/index.tsx b/apps/web/src/games/janggi/index.tsx index 533c248..54b535a 100644 --- a/apps/web/src/games/janggi/index.tsx +++ b/apps/web/src/games/janggi/index.tsx @@ -6,6 +6,8 @@ import { Avatar, ConfirmSheet, Sheet } from '../../components/ui'; import { TurnTimer, useServerNow } from '../../components/TurnTimer'; import { haptic, sounds } from '../../lib/feedback'; import type { BoardProps, GameUI, OptionsProps } from '../types'; +import { helpNow } from './help'; +import { quickStart } from './quick'; const SIDE_KO: Record = { cho: '초', han: '한' }; const INK: Record = { cho: '#1d4ed8', han: '#c0262d' }; @@ -983,6 +985,8 @@ const ui: GameUI = { Options, optionsSummary, Rules, + quickStart, + helpNow, }; export default ui; diff --git a/apps/web/src/games/janggi/quick.tsx b/apps/web/src/games/janggi/quick.tsx new file mode 100644 index 0000000..d49a961 --- /dev/null +++ b/apps/web/src/games/janggi/quick.tsx @@ -0,0 +1,106 @@ +/** 장기 30초 규칙 요약 (docs/07 §5). */ +import type { ReactNode } from 'react'; +import type { QuickCard } from '../types'; + +type Side = 'cho' | 'han'; +const INK: Record = { cho: '#1d4ed8', han: '#c0262d' }; + +function octagon(cx: number, cy: number, r: number): string { + return Array.from({ length: 8 }, (_, i) => { + const a = Math.PI / 8 + (i * Math.PI) / 4; + return `${(cx + r * Math.cos(a)).toFixed(3)},${(cy + r * Math.sin(a)).toFixed(3)}`; + }).join(' '); +} + +/** Octagonal piece with its hanja, blue for 초 and red for 한. */ +function Piece({ ch, side, x, y, r = 0.4 }: { ch: string; side: Side; x: number; y: number; r?: number }) { + return ( + + + + {ch} + + + ); +} + +/** Wooden board of w×h line crossings; `palace` draws the X of a 3×3 palace whose top-left crossing is given. */ +function Board({ w, h, size, palace, children }: { w: number; h: number; size: number; palace?: [number, number]; children: ReactNode }) { + const pad = 0.55; + const vw = w - 1 + 2 * pad; + const vh = h - 1 + 2 * pad; + const scale = size / Math.max(vw, vh); + return ( + + + + {Array.from({ length: w }, (_, x) => ( + + ))} + {Array.from({ length: h }, (_, y) => ( + + ))} + {palace && ( + <> + + + + )} + + {children} + + ); +} + +const Dot = ({ x, y }: { x: number; y: number }) => ; +const Cross = ({ x, y }: { x: number; y: number }) => ( + + + + +); + +export const quickStart: QuickCard[] = [ + { + pic: ( + + + + + + ), + alt: '파란 차가 궁성 안의 빨간 궁을 곧게 겨누고 있어요(장군)', + title: '상대 궁을 몰아요', + text: '초(파랑)가 먼저 두고 번갈아 한 수씩 둬요. 상대 궁이 어디로 피해도 잡히게 만들면(외통) 이겨요.', + }, + { + pic: ( + + {[[0, 1], [4, 1], [0, 3], [4, 3], [1, 4], [3, 4]].map(([x, y]) => ( + + ))} + + + + + + ), + alt: '가운데 마 둘레에 갈 수 있는 곳이 초록 점으로, 바로 위가 막힌 쪽은 빨간 ✕로 보여요', + title: '누르면 갈 곳이 보여요', + text: '기물을 누르면 갈 수 있는 곳에 초록 점이 찍혀요. 기물은 칸이 아니라 선이 만나는 점에 놓여요. 마·상은 바로 앞(멱)이 막히면 못 가요.', + }, + { + pic: ( + + + + + + + + ), + alt: '포가 기물 하나를 뛰어넘어 그 너머 점으로 가는 모습이에요', + title: '포는 넘어서 가요', + text: '포는 꼭 기물 하나를 뛰어넘어서만 움직여요. 장군을 받으면 다른 수보다 먼저 궁을 피하거나 막아야 해요.', + }, +]; diff --git a/apps/web/src/games/las-vegas/help.test.ts b/apps/web/src/games/las-vegas/help.test.ts new file mode 100644 index 0000000..086854c --- /dev/null +++ b/apps/web/src/games/las-vegas/help.test.ts @@ -0,0 +1,45 @@ +import { expect, test } from 'bun:test'; +import { applyAction, setupGame, usersOf } from '../help-kit.test-util'; +import { GAMES } from '@bg/games'; +import { helpNow } from './help'; + +const users = usersOf(['A', 'B', 'C']); +const view = (s: unknown, who: string | null) => GAMES['las-vegas']!.view(s, who) as any; + +test('라스베가스: 굴리기 차례 / 기다리기 / 관전', () => { + const { state } = setupGame('las-vegas', 3); + const cur: string = state.seats[state.current]; + const other = state.seats.find((p: string) => p !== cur)!; + const nick = users[cur]!.nickname; + const mine = helpNow(view(state, cur), cur, users); + expect(mine).toContain('[🎲 주사위 굴리기]'); + expect(mine).toContain('남은 주사위 8개'); + const wait = helpNow(view(state, other), other, users); + expect(wait).toContain(`${nick}님이 주사위를 굴릴 차례예요`); + expect(wait).toContain('내 주사위는 8개 남았어요'); + const spec = helpNow(view(state, null), null, users); + expect(spec).toContain(`${nick}님이 주사위를 굴릴 차례예요`); + expect(spec).toContain('1/4라운드'); +}); + +test('라스베가스: 굴린 뒤 숫자 고르기와 미리 보기', () => { + const { state } = setupGame('las-vegas', 2); + const cur: string = state.seats[state.current]; + const other = state.seats.find((p: string) => p !== cur)!; + const s = applyAction('las-vegas', state, cur, { type: 'ROLL' }); + const t = helpNow(view(s, cur), cur, users); + expect(t).toContain('개 놓기] 버튼'); + expect(t).toContain('지금 상황에서 놓으면'); + expect(t).toContain('일부만 놓을 수는 없어요'); + expect(helpNow(view(s, other), other, users)).toContain('놓을 숫자를 고르는 중이에요'); + // 2명: 흰 주사위 4개를 함께 가져요. + expect(helpNow(view(state, other), other, users)).toContain('흰 주사위 4개 포함'); +}); + +test('라스베가스: 기권하면 구경', () => { + const { state } = setupGame('las-vegas', 3); + const cur: string = state.seats[state.current]; + const other = state.seats.find((p: string) => p !== cur)!; + const s = applyAction('las-vegas', state, other, { type: 'CONCEDE' }); + expect(helpNow(view(s, other), other, users)).toContain('기권해서'); +}); diff --git a/apps/web/src/games/las-vegas/help.ts b/apps/web/src/games/las-vegas/help.ts new file mode 100644 index 0000000..50b00d9 --- /dev/null +++ b/apps/web/src/games/las-vegas/help.ts @@ -0,0 +1,53 @@ +/** 라스베가스 주사위 [?] "지금 할 수 있는 것" (docs/07 §5). Pure: only reads the client view. */ +import type { LasVegasChoice, LasVegasView } from '@bg/games/las-vegas'; +import type { PublicUser } from '@bg/shared/lite'; + +const STANDING: Record string> = { + first: (c) => `1등${c.note !== null ? `(${c.note}점 지폐)` : ''}`, + award: (c) => (c.note !== null ? `${c.note}점 지폐` : '지폐 받음'), + tie: () => '동점이라 꽝', + none: (c) => (c.own === 0 ? '흰 주사위만 놓임' : '받을 지폐 없음'), +}; + +const choiceText = (c: LasVegasChoice) => `${c.value}번에 ${c.own + c.neutral}개 → ${STANDING[c.standing](c)}`; + +export function helpNow(v: LasVegasView, me: string | null, players: Record): string { + if (v.phase === 'finished' || !v.current) return `게임이 끝났어요. ${v.result?.summary ?? ''}`.trim() + '\n결과 창에서 [한 판 더]를 누르면 다시 할 수 있어요.'; + const round = `${v.round}/${v.rounds}라운드`; + const who = `${players[v.current]?.nickname ?? '다른 사람'}님`; + const rule = '라운드가 끝나면 카지노마다 주사위가 가장 많은 사람부터 큰 지폐를 가져가요. 개수가 같은 사람끼리는 모두 꽝이에요.'; + const seated = !!me && v.seats.includes(me); + + if (!seated) { + const doing = v.phase === 'roll' ? '주사위를 굴릴 차례예요.' : '나온 숫자 중 하나를 골라 카지노에 놓는 중이에요.'; + return `${who}이 ${doing} (${round})\n${rule}`; + } + if (v.conceded.includes(me!)) return `나는 기권해서 지금은 구경만 할 수 있어요. ${who} 차례예요.`; + + const hand = v.hand[me!]; + const left = hand ? hand.own + hand.neutral : 0; + const mine = v.money[me!]; + const score = mine && mine.total !== null ? `지금까지 내 점수는 ${mine.total}점(지폐 ${mine.count}장)이에요.` : ''; + + if (v.current !== me) { + const doing = v.phase === 'roll' ? '주사위를 굴릴 차례예요.' : '놓을 숫자를 고르는 중이에요.'; + const lines = [`${who}이 ${doing} 기다려 주세요. (${round})`]; + lines.push(left > 0 ? `내 주사위는 ${left}개 남았어요${hand!.neutral > 0 ? `(흰 주사위 ${hand!.neutral}개 포함)` : ''}.` : '내 주사위는 이번 라운드에 다 놓았어요. 다른 사람이 다 놓을 때까지 기다려요.'); + if (score) lines.push(score); + return lines.join('\n'); + } + + // My turn. + if (v.phase === 'roll') { + const lines = [`내 차례예요(${round}). [🎲 주사위 굴리기]를 누르면 남은 주사위 ${left}개를 모두 굴려요.`]; + lines.push('굴린 뒤 숫자 하나를 고르면, 그 숫자가 나온 주사위를 모두 같은 번호 카지노에 놓아요.'); + if (v.houseRoll && v.houseRoll.round === v.round && hand?.own === 8) lines.push(`이번 라운드 시작에 남는 흰 주사위가 ${v.houseRoll.values.map((x) => `${x}번`).join(', ')} 카지노에 놓였어요.`); + if (score) lines.push(score); + return lines.join('\n'); + } + const lines = [`내 차례예요(${round}). 나온 숫자(또는 같은 번호 카지노)를 누르면 미리 보기가 나와요. 그다음 [… 카지노에 …개 놓기] 버튼을 눌러 확정해요.`]; + lines.push('고른 숫자의 주사위는 한꺼번에 모두 놓여요. 일부만 놓을 수는 없어요.'); + if (v.choices.length) lines.push(`지금 상황에서 놓으면: ${v.choices.map(choiceText).join(' · ')}`); + if (v.choices.some((c) => c.standing === 'tie')) lines.push('동점이 되는 곳은 그대로 끝나면 같은 개수인 사람 모두 아무것도 못 받아요.'); + return lines.join('\n'); +} diff --git a/apps/web/src/games/las-vegas/index.tsx b/apps/web/src/games/las-vegas/index.tsx index 2bceb2f..a9cb83e 100644 --- a/apps/web/src/games/las-vegas/index.tsx +++ b/apps/web/src/games/las-vegas/index.tsx @@ -6,6 +6,8 @@ import { Avatar, ConfirmSheet } from '../../components/ui'; import { TurnTimer } from '../../components/TurnTimer'; import { haptic, sounds } from '../../lib/feedback'; import type { BoardProps, GameUI, OptionsProps } from '../types'; +import { helpNow } from './help'; +import { quickStart } from './quick'; const ROLL_MS = 650; const HOUSE = 'house'; @@ -811,6 +813,8 @@ const ui: GameUI = { Options, optionsSummary, Rules, + quickStart, + helpNow, }; export default ui; diff --git a/apps/web/src/games/las-vegas/quick.tsx b/apps/web/src/games/las-vegas/quick.tsx new file mode 100644 index 0000000..661adaa --- /dev/null +++ b/apps/web/src/games/las-vegas/quick.tsx @@ -0,0 +1,131 @@ +/** 라스베가스 주사위 30초 규칙 요약 (docs/07 §5). */ +import type { QuickCard } from '../types'; + +const PIPS: Record = { + 1: [[50, 50]], + 2: [[28, 28], [72, 72]], + 3: [[26, 26], [50, 50], [74, 74]], + 4: [[28, 28], [72, 28], [28, 72], [72, 72]], + 5: [[26, 26], [74, 26], [50, 50], [26, 74], [74, 74]], + 6: [[28, 24], [72, 24], [28, 50], [72, 50], [28, 76], [72, 76]], +}; + +const RED = '#dc2626'; +const BLUE = '#2563eb'; +const GREEN = '#16a34a'; + +/** A coloured die at (x, y), `s` units wide (pips white on colour). */ +function Die({ x, y, value, color, s = 30, dim = false }: { x: number; y: number; value: number; color: string; s?: number; dim?: boolean }) { + const k = s / 100; + return ( + + + {PIPS[value]!.map(([cx, cy], i) => ( + + ))} + + ); +} + +function Bill({ x, y, n }: { x: number; y: number; n: number }) { + return ( + + + + {n}점 + + + ); +} + +/** Casino card: number on top, bills inside. */ +function Casino({ x, y, n, bills }: { x: number; y: number; n: number; bills: number[] }) { + return ( + + + + {n}번 + + {bills.map((b, i) => ( + + ))} + + ); +} + +function PlacePic() { + const roll = [4, 2, 4, 6, 1, 4, 5, 2]; + return ( + + {roll.map((d, i) => ( + + ))} + + ➜ + + + + 4가 3개 → 모두 4번에! + + + ); +} + +function TiePic() { + return ( + + + {[RED, RED].map((c, i) => ( + + ))} + {[BLUE, BLUE].map((c, i) => ( + + ))} + + + ✕ 동점 꽝 + + + → 8점 받음 + + + ); +} + +function WinPic() { + return ( + + + + + + + = 25점 + + + 🏆 + + + ); +} + +export const quickStart: QuickCard[] = [ + { + pic: , + alt: '빨간 주사위 8개 중 4가 나온 3개를 모두 4번 카지노에 놓아요', + title: '굴리고 한 숫자 고르기', + text: '내 차례에 남은 주사위를 모두 굴려요. 숫자 하나를 고르면 그 숫자가 나온 주사위를 모두 같은 번호 카지노에 놓아요.', + }, + { + pic: , + alt: '2번 카지노에 빨강 2개, 파랑 2개가 동점이라 꽝이고, 초록 1개가 8점 지폐를 받아요', + title: '같은 개수면 둘 다 꽝', + text: '모두 다 놓으면 카지노마다 주사위가 많은 사람부터 큰 지폐를 받아요. 개수가 같은 사람끼리는 모두 아무것도 못 받아요.', + }, + { + pic: , + alt: '지폐 9, 7, 5, 4점을 더해 25점, 트로피', + title: '점수가 높으면 이겨요', + text: '정해진 라운드가 모두 끝나면 받은 지폐의 점수를 더해요. 합계가 가장 높은 사람이 이겨요.', + }, +]; diff --git a/apps/web/src/games/mafia/help.test.ts b/apps/web/src/games/mafia/help.test.ts new file mode 100644 index 0000000..12ad7fb --- /dev/null +++ b/apps/web/src/games/mafia/help.test.ts @@ -0,0 +1,57 @@ +import { expect, test } from 'bun:test'; +import { applyAction, seatIds, setupGame, usersOf } from '../help-kit.test-util'; +import { GAMES } from '@bg/games'; +import { helpNow } from './help'; + +const ids = seatIds(7); +const users = usersOf(ids); +const view = (s: unknown, who: string | null) => GAMES.mafia!.view(s, who) as any; +const T = 1_700_000_000_000; + +function game() { + const { state } = setupGame('mafia', 7, { mafiaCount: 2 }); + const byRole = (r: string) => state.players.filter((p: any) => p.role === r).map((p: any) => p.id as string); + return { state, mafia: byRole('mafia'), police: byRole('police')[0]!, doctor: byRole('doctor')[0]!, citizen: byRole('citizen')[0]! }; +} +const night = { kind: 'night', startedAt: T, endsAt: T + 30_000, mafiaTarget: null, mafiaPickedBy: null, investigate: null, heal: null, mediumPeek: null }; + +test('마피아: 직업 확인 / 관전', () => { + const { state, citizen } = game(); + expect(helpNow(view(state, citizen), citizen, users)).toContain('[내 직업] 카드를 꾹'); + const spec = helpNow(view(state, null), null, users); + expect(spec).toContain('관전 중에는 전체 채팅과 발표만'); + expect(spec).not.toContain('마피아예요'); +}); + +test('마피아: 밤 안내는 내 직업에 맞게, 남의 직업은 말하지 않아요', () => { + const g = game(); + const s = { ...g.state, day: 1, phase: night }; + const [m1, m2] = g.mafia; + const mt = helpNow(view(s, m1!), m1!, users); + expect(mt).toContain('나는 마피아예요'); + expect(mt).toContain(`동료 마피아는 ${users[m2!]!.nickname}님`); + expect(helpNow(view(s, g.police), g.police, users)).toContain('나는 경찰이에요'); + expect(helpNow(view(s, g.doctor), g.doctor, users)).toContain('나는 의사예요'); + const c = helpNow(view(s, g.citizen), g.citizen, users); + expect(c).toContain('지금은 할 일이 없어요'); + for (const t of [c, helpNow(view(s, null), null, users)]) { + expect(t).not.toContain('마피아예요'); + expect(t).not.toContain('경찰'); + expect(t).not.toContain('의사'); + } + // 마피아가 고르면 동료에게 대상과 고른 사람이 보여요. + const s2 = applyAction('mafia', s, m1!, { type: 'mafiaTarget', target: g.citizen }); + expect(helpNow(view(s2, m2!), m2!, users)).toContain(`${users[m1!]!.nickname}님이 골랐어요`); +}); + +test('마피아: 지목 투표 → 찬반 투표, 탈락한 사람', () => { + const g = game(); + const vote = { ...g.state, day: 2, phase: { kind: 'vote', startedAt: T, endsAt: T + 20_000, votes: {} } }; + expect(helpNow(view(vote, g.citizen), g.citizen, users)).toContain('[기권]'); + const confirm = { ...g.state, day: 2, phase: { kind: 'confirm', startedAt: T, endsAt: T + 10_000, accused: g.citizen, ballots: {} } }; + expect(helpNow(view(confirm, g.police), g.police, users)).toContain('[찬성 (탈락)]'); + expect(helpNow(view(confirm, g.citizen), g.citizen, users)).toContain('나를 탈락시킬지'); + expect(helpNow(view(confirm, null), null, users)).toContain(`${users[g.citizen]!.nickname}님을 탈락시킬지`); + const dead = { ...confirm, players: g.state.players.map((p: any) => (p.id === g.doctor ? { ...p, alive: false, diedDay: 1, diedBy: 'mafia' } : p)) }; + expect(helpNow(view(dead, g.doctor), g.doctor, users)).toContain('[죽은 사람] 채팅'); +}); diff --git a/apps/web/src/games/mafia/help.ts b/apps/web/src/games/mafia/help.ts new file mode 100644 index 0000000..a7a9ae3 --- /dev/null +++ b/apps/web/src/games/mafia/help.ts @@ -0,0 +1,131 @@ +/** + * 마피아 [?] "지금 할 수 있는 것" (docs/07 §5). Pure: only reads the client view. + * Only what this viewer may know: the own role is named only where the screen already shows + * role-specific things (night actions, after death, after the game). + */ +import type { MafiaView } from '@bg/games/mafia'; +import type { PublicUser } from '@bg/shared/lite'; + +const PHASE_KO: Record = { + roleReveal: '직업 확인', + greeting: '인사 시간', + discussion: '낮 토론', + vote: '지목 투표', + defense: '최후의 반론', + confirm: '찬반 투표', + night: '밤', + ended: '게임 끝', +}; + +/** "~예요" after a vowel, "~이에요" after a final consonant. */ +function yeyo(word: string): string { + const c = word.charCodeAt(word.length - 1) - 0xac00; + return c >= 0 && c < 11172 && c % 28 !== 0 ? `${word}이에요` : `${word}예요`; +} + +const WIN = '시민 팀은 마피아를 모두 찾아내면, 마피아 팀은 살아 있는 마피아가 나머지 사람만큼 많아지면 이겨요.'; + +export function helpNow(v: MafiaView, me: string | null, players: Record): string { + const n = (id: string) => `${players[id]?.nickname ?? '다른 사람'}님`; + const ph = v.phase; + const head = `${v.day}일차 ${yeyo(PHASE_KO[ph.kind])}.`; + if (ph.kind === 'ended') { + const chats = v.options.revealChatsAtEnd ? ' 모든 채팅방 기록도 이제 볼 수 있어요.' : ''; + return `게임이 끝났어요. ${v.final?.summary ?? ''}`.trim() + `\n최종 공개 표에서 모든 사람의 직업을 볼 수 있어요.${chats}`; + } + const my = v.my; + + // Spectators see the day chat and announcements only. + if (!my) { + const what: Record = { + roleReveal: '모두 자기 직업을 확인하고 있어요.', + greeting: '살아 있는 사람들이 전체 채팅으로 인사하고 있어요. 오늘은 투표가 없어요.', + discussion: '살아 있는 사람들이 누가 마피아인지 이야기하고 있어요.', + vote: '살아 있는 사람들이 마피아로 의심되는 사람을 지목하고 있어요.', + defense: ph.accused ? `${n(ph.accused)}이 지목돼서 마지막으로 변론하고 있어요.` : '지목된 사람이 변론하고 있어요.', + confirm: ph.accused ? `${n(ph.accused)}을 탈락시킬지 찬반 투표를 하고 있어요.` : '찬반 투표를 하고 있어요.', + night: '각자 직업에 따라 몰래 행동하는 시간이에요. 아침에 발표가 있어요.', + }; + return `${head} ${what[ph.kind] ?? ''}\n관전 중에는 전체 채팅과 발표만 볼 수 있어요.`; + } + + const idle = my.idleWarning ? '\n오늘도 아무것도 하지 않으면 자리 비움으로 탈락해요.' : ''; + + if (!my.alive) { + const see = v.options.deadSeeAll ? ' 이 방에서는 탈락하면 모든 직업과 마피아 채팅도 볼 수 있어요.' : ''; + return `${head} 나는 탈락했어요. [죽은 사람] 채팅에서만 이야기할 수 있어요.${see}\n살아 있는 사람에게 정보를 알려 주지 말아 주세요(디스코드 같은 다른 대화방도요).`; + } + + switch (ph.kind) { + case 'roleReveal': + return `직업을 확인하는 시간이에요. 위의 [내 직업] 카드를 꾹 누르고 있는 동안만 직업이 보여요(옆 사람에게 보이지 않게).\n곧 ${v.options.firstPhase === 'day' ? '인사 시간' : '첫 밤'}이 시작돼요. ${WIN}`; + case 'greeting': + return `${head} [전체] 채팅으로 인사해요. 오늘은 투표가 없고, 끝나면 밤이 와요.\n다 됐으면 [인사 끝내기]를 눌러요. 살아 있는 사람이 모두 누르면 바로 넘어가요(${ph.readyCount}/${ph.readyNeeded}).${idle}`; + case 'discussion': { + const adj = my.canAdjust ? '\n[+15초]·[−15초]로 토론 시간을 하루 한 번 바꿀 수 있어요.' : ''; + return `${head} [전체] 채팅으로 누가 마피아 같은지 이야기해요. 직업을 밝힐지는 내 선택이에요.${adj}\n다 이야기했으면 [토론 끝내기]를 눌러요. 모두 누르면 바로 지목 투표로 넘어가요(${ph.readyCount}/${ph.readyNeeded}).${idle}`; + } + case 'vote': { + const now = my.vote === null ? '아직 고르지 않았어요(안 고르면 기권).' : my.vote === 'abstain' ? '지금은 기권으로 되어 있어요.' : `지금 ${n(my.vote)}을 골랐어요.`; + return `${head} '사람들' 목록에서 초록 테두리 사람을 눌러 마피아로 의심되는 사람을 골라요. 시간 안에는 바꿀 수 있고, [기권]도 돼요.\n${now} 표가 가장 많은 한 명이 지목돼요. 같은 표가 나오면 아무도 지목되지 않아요.${idle}`; + } + case 'defense': + if (ph.accused === me) return `${head} 내가 지목됐어요! [전체] 채팅으로 내가 왜 마피아가 아닌지 마지막으로 말해요. 지금은 나만 말할 수 있어요.`; + return `${head} ${ph.accused ? n(ph.accused) : '지목된 사람'}의 마지막 변론을 들어요. 지금은 지목된 사람만 채팅할 수 있어요. 곧 찬반 투표를 해요.`; + case 'confirm': { + const who = ph.accused ? n(ph.accused) : '지목된 사람'; + if (!my.canConfirm) return `${head} 다른 사람들이 나를 탈락시킬지 찬반 투표를 하고 있어요. 결과를 기다려 주세요.`; + const now = my.ballot ? `지금 ${my.ballot === 'yes' ? '찬성' : '반대'}했어요. 시간 안에는 바꿀 수 있어요.` : '안 누르면 집계에서 빠져요.'; + return `${head} ${who}을 탈락시킬지 정해요. [찬성 (탈락)] 또는 [반대 (살리기)]를 눌러요.\n찬성이 반대보다 많아야 탈락해요. ${now}${idle}`; + } + case 'night': + return nightHelp(v, me, n) + idle; + } + return head; +} + +function nightHelp(v: MafiaView, me: string | null, n: (id: string) => string): string { + const my = v.my!; + const nv = my.night; + const pick = "'사람들' 목록에서 초록 테두리 사람을 눌러 골라요. 밤이 끝날 때 고른 사람으로 정해지고, 그 전엔 바꿀 수 있어요."; + const lines: string[] = []; + const isAlive = (id: string) => v.players.some((p) => p.id === id && p.alive); + const mates = my.mates.filter(isAlive); + switch (nv?.action ?? null) { + case null: { + lines.push('밤이에요. 지금은 할 일이 없어요. 아침 발표를 기다려 주세요.'); + if (my.role === 'lover' && mates.length) lines.push(`밤에는 [연인] 채팅으로 ${mates.map(n).join(', ')}과 둘이서만 이야기할 수 있어요.`); + break; + } + case 'mafiaTarget': { + lines.push(`밤이에요. 나는 마피아예요. 오늘 밤 노릴 사람을 ${pick}`); + if (mates.length) lines.push(`마피아끼리 한 명을 함께 정해요. 누가 고르든 마지막에 고른 사람이 대상이에요. 살아 있는 동료 마피아는 ${mates.map(n).join(', ')}이에요. [마피아] 채팅으로 의논해요.`); + const none = v.options.mafiaMustKill === 'random' ? '아무도 안 고르면 무작위로 한 명이 탈락해요.' : '아무도 안 고르면 오늘 밤은 아무도 탈락하지 않아요.'; + lines.push(nv!.choice ? `지금 대상: ${n(nv!.choice)}${nv!.pickedBy && nv!.pickedBy !== me ? ` (${n(nv!.pickedBy)}이 골랐어요)` : ''}. [선택 취소]로 지울 수 있어요.` : none); + break; + } + case 'investigate': { + const what = v.options.policeRevealRole ? '그 사람의 직업이' : '마피아인지 아닌지가'; + lines.push(`밤이에요. 나는 경찰이에요. 조사할 사람을 ${pick}`); + lines.push(`밤이 끝나면 ${what} '조사 결과'에 나만 보이게 나와요.${nv!.choice ? ` 지금 고른 사람: ${n(nv!.choice)}.` : ''}`); + break; + } + case 'heal': { + const self = v.options.doctorSelfHeal ? '나 자신도 고를 수 있어요.' : '나 자신은 고를 수 없어요.'; + const rep = v.options.doctorRepeat ? '' : ' 어젯밤 지킨 사람은 오늘 다시 고를 수 없어요.'; + lines.push(`밤이에요. 나는 의사예요. 지켜 줄 사람을 ${pick}`); + lines.push(`마피아가 그 사람을 노리면 살아나요. ${self}${rep}${nv!.choice ? ` 지금 고른 사람: ${n(nv!.choice)}.` : ''}`); + break; + } + case 'mediumPeek': { + if (nv!.targets.length === 0) lines.push('밤이에요. 나는 영매예요. 아직 탈락한 사람이 없어서 알아볼 사람이 없어요.'); + else { + lines.push(`밤이에요. 나는 영매예요. 직업을 알아볼 탈락한 사람을 ${pick}`); + lines.push(`결과는 밤이 끝나면 '영매 결과'에 나만 보이게 나와요.${nv!.choice ? ` 지금 고른 사람: ${n(nv!.choice)}.` : ''}`); + } + lines.push('밤에는 [죽은 사람] 채팅을 읽고 쓸 수 있어요.'); + break; + } + } + return lines.join('\n'); +} diff --git a/apps/web/src/games/mafia/index.tsx b/apps/web/src/games/mafia/index.tsx index c2ff267..29ef1d7 100644 --- a/apps/web/src/games/mafia/index.tsx +++ b/apps/web/src/games/mafia/index.tsx @@ -13,6 +13,8 @@ import { Avatar } from '../../components/ui'; import { useServerNow } from '../../components/TurnTimer'; import { haptic, sounds } from '../../lib/feedback'; import type { BoardProps, GameUI, OptionsProps } from '../types'; +import { helpNow } from './help'; +import { quickStart } from './quick'; // ---------------------------------------------------------------- names (original names only, docs §12) @@ -1024,6 +1026,8 @@ const ui: GameUI = { Options, optionsSummary, Rules, + quickStart, + helpNow, ownChat: true, }; diff --git a/apps/web/src/games/mafia/quick.tsx b/apps/web/src/games/mafia/quick.tsx new file mode 100644 index 0000000..366186d --- /dev/null +++ b/apps/web/src/games/mafia/quick.tsx @@ -0,0 +1,41 @@ +/** 마피아 30초 규칙 요약 (docs/07 §5). */ +import type { QuickCard } from '../types'; + +/** Big emoji composition on a soft panel (no images). */ +function Scene({ bg, rows }: { bg: string; rows: { e: string; label?: string }[][] }) { + return ( +
+ {rows.map((row, i) => ( +
+ {row.map((c, j) => ( +
+ {c.e} + {c.label && {c.label}} +
+ ))} +
+ ))} +
+ ); +} + +export const quickStart: QuickCard[] = [ + { + pic: , + alt: '해가 뜬 낮에 사람들이 토론하고 투표해서 한 명을 가리켜요', + title: '낮: 토론하고 투표', + text: '낮에는 [전체] 채팅으로 누가 마피아 같은지 이야기하고, 투표로 한 명을 골라 찬성이 많으면 탈락시켜요.', + }, + { + pic: , + alt: '달이 뜬 밤에 마피아, 경찰, 의사가 몰래 행동해요', + title: '밤: 직업대로 몰래', + text: '밤에는 마피아가 한 명을 노리고, 경찰은 한 명을 조사하고, 의사는 한 명을 지켜요. 내 직업은 [내 직업] 카드를 꾹 눌러 봐요.', + }, + { + pic: , + alt: '시민 팀과 마피아 팀이 마주 보고 있어요', + title: '팀이 함께 이겨요', + text: '시민 팀은 마피아를 모두 찾으면, 마피아 팀은 시민만큼 많이 남으면 이겨요. 탈락하면 살아 있는 사람에게 힌트를 주면 안 돼요.', + }, +]; diff --git a/apps/web/src/games/mahjong/help.test.ts b/apps/web/src/games/mahjong/help.test.ts new file mode 100644 index 0000000..074590d --- /dev/null +++ b/apps/web/src/games/mahjong/help.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, test } from 'bun:test'; +import { GAMES } from '@bg/games'; +import { applyAction, assertCleanText, eachView, setupGame, usersOf } from '../help-kit.test-util'; +import { helpNow } from './help'; + +const users = usersOf(['A', 'B', 'C', 'D']); +const view = (s: unknown, who: string | null) => GAMES.mahjong!.view(s, who) as any; + +/** First view (from random games) that matches `pred`, with its state and viewer. */ +function findView(n: number, pred: (v: any, who: string | null) => boolean, options: Record = {}) { + for (let seed = 1; seed <= 12; seed++) { + let found: { v: any; who: string | null; state: any } | null = null; + try { + eachView( + 'mahjong', + n, + (v, who, state) => { + if (pred(v, who)) { + found = { v, who, state }; + throw new Error('found'); + } + }, + { steps: 4000, seed: [seed, 7, 3, 9], options }, + ); + } catch (e) { + if (!found) throw e; + } + if (found) return found as { v: any; who: string | null; state: any }; + } + throw new Error('no such view'); +} + +test('마작: 내 차례 / 상대 차례 / 관전', () => { + const { state } = setupGame('mahjong', 4); + const mine = helpNow(view(state, 'A'), 'A', users); + expect(mine).toContain('내 차례예요'); + expect(mine).toContain('방금 가져온 패는'); + expect(mine).toContain('[○○ 버리기]'); + expect(helpNow(view(state, 'B'), 'B', users)).toContain('호랑이님 차례예요. 기다려 주세요'); + const spec = helpNow(view(state, null), null, users); + expect(spec).toContain('호랑이님 차례예요. 1장을 가져오고(쯔모)'); + expect(spec).toContain('동1국'); +}); + +test('마작: 버린 뒤 응답 창 — 4인은 치·퐁·깡, 3인은 퐁·깡', () => { + for (const n of [4, 3]) { + const { state } = setupGame('mahjong', n); + const v0 = view(state, null); + const dealer: string = v0.seats[v0.turn].id; + const tile = view(state, dealer).my.discardable[0]; + const s = applyAction('mahjong', state, dealer, { type: 'discard', tile }); + const spec = helpNow(view(s, null), null, users); + expect(spec).toContain(`${users[dealer]!.nickname}님이`); + expect(spec).toContain('버렸어요'); + if (n === 4) expect(spec).toContain('치·퐁·깡'); + else expect(spec).not.toContain('치·'); + expect(helpNow(view(s, dealer), dealer, users)).toContain('내가'); + } +}); + +test('마작: 론·치 버튼 안내', () => { + const ron = findView(4, (v) => !!v.my?.claim?.ron && !v.my.claim.responded); + const t = helpNow(ron.v, ron.who, users); + expect(t).toContain('[론!]'); + expect(t).toContain('[패스]'); + expect(t).toContain('후리텐'); + const chi = findView(4, (v) => (v.my?.claim?.chi.length ?? 0) > 0 && !v.my.claim.responded); + expect(helpNow(chi.v, chi.who, users)).toContain('[치'); +}); + +test('마작: 리치할 수 있을 때와 리치한 뒤', () => { + const f = findView(4, (v) => (v.my?.riichiDiscards.length ?? 0) > 0); + const t = helpNow(f.v, f.who, users); + expect(t).toContain('[리치]'); + expect(t).toContain("'텐' 표시"); + let s = applyAction('mahjong', f.state, f.who!, { type: 'discard', tile: f.v.my.riichiDiscards[0], riichi: true }); + // Riichi is accepted once the claim window on that discard closes (its timer runs out, nobody calls). + const def = GAMES.mahjong!; + for (let i = 0; i < 8 && !view(s, f.who).my.riichi; i++) { + const at = (def.deadline?.(s) ?? 1_700_000_100_000) + 1; + for (const p of def.timeoutPlayers?.(s) ?? []) s = applyAction('mahjong', s, p, def.onTimeout(s, p), undefined, at); + } + const after = helpNow(view(s, f.who), f.who, users); + expect(after).toContain('리치 중'); + expect(after).toContain('대기패'); + expect(helpNow(view(s, null), null, users)).toContain('리치한 사람'); +}); + +test('마작: 국 결과와 대국 끝', () => { + const r = findView(4, (v, who) => v.phase === 'handResult' && v.result?.kind === 'agari' && who !== null); + const t = helpNow(r.v, r.who, users); + expect(t).toContain('화료예요'); + expect(t).toContain('내 점수'); + expect(t).toMatch(/\[(다음 국으로|최종 결과 보기)\]/); + expect(helpNow(view(r.state, null), null, users)).toContain('모두 확인하면'); + const end = findView(3, (v) => v.phase === 'finished'); + expect(helpNow(end.v, 'A', users)).toContain('대국이 끝났어요'); + expect(helpNow(end.v, null, users)).toContain('1위는'); +}); + +describe('마작: 다른 규칙에서도 도움말이 깨지지 않아요', () => { + const variants: [number, Record][] = [ + [4, {}], + [4, { beginnerMode: false, length: 'tonpuu', akaDora: 0, kuitan: false }], + [3, { beginnerMode: false, sanmaTsumo: 'northBisection', abortiveDraws: false }], + ]; + test.each(variants)('%d인 %o', (n, options) => { + let count = 0; + eachView( + 'mahjong', + n, + (v, who) => { + assertCleanText(helpNow(v, who, users), `mahjong ${n}p viewer=${who}`); + count++; + }, + { steps: 1500, seed: [3, 1, 4, 1], options }, + ); + expect(count).toBeGreaterThan(n * 10); + }); +}); diff --git a/apps/web/src/games/mahjong/help.ts b/apps/web/src/games/mahjong/help.ts new file mode 100644 index 0000000..6f7605d --- /dev/null +++ b/apps/web/src/games/mahjong/help.ts @@ -0,0 +1,170 @@ +/** 마작 [?] "지금 할 수 있는 것" (docs/07 §5). Pure: only reads the client view (my own tiles + public info). */ +import type { HandResultView, MahjongView, MyView, WaitView } from '@bg/games/mahjong'; +import type { PublicUser } from '@bg/shared/lite'; + +const WIND = ['동', '남', '서', '북']; +const HONOR = ['동', '남', '서', '북', '백', '발', '중']; +const SUIT: Record = { m: '만', p: '통', s: '삭' }; +const ABORT: Record = { kyuushu: '구종구패', suufon: '사풍연타', suukan: '사깡유국', suucha: '사가리치', sanchaho: '삼가화' }; +const LIMIT: Record = { mangan: '만관', haneman: '하네만', baiman: '배만', sanbaiman: '삼배만', kazoe: '헤아림 역만', yakuman: '역만' }; + +/** "5m0r" / "5m" → "5만", "1z" → "동" (same as Tile.tsx tileShort). */ +const short = (code: string) => (code[1] === 'z' ? (HONOR[Number(code[0]) - 1] ?? '자패') : `${code[0]}${SUIT[code[1] ?? ''] ?? ''}`); +const fmt = (n: number) => n.toLocaleString('ko-KR'); +const signed = (n: number) => (n > 0 ? `+${fmt(n)}` : n < 0 ? `-${fmt(-n)}` : '0'); +/** 을/를 by the last syllable. */ +const eulReul = (w: string) => { + const c = w.charCodeAt(w.length - 1); + return c >= 0xac00 && c <= 0xd7a3 && (c - 0xac00) % 28 === 0 ? `${w}를` : `${w}을`; +}; +/** Same format as the screen: "3삭(2장), 6삭(4장, 역 없음)". */ +const waitText = (ws: WaitView[]) => ws.map((w) => `${short(w.kind)}(${w.left}장${w.yaku ? '' : ', 역 없음'})`).join(', '); +const roundLabel = (x: { roundWind: number; handNumber: number; honba: number }) => `${WIND[x.roundWind] ?? ''}${x.handNumber + 1}국${x.honba ? ` ${x.honba}본장` : ''}`; + +export function helpNow(v: MahjongView, me: string | null, players: Record): string { + const nameOf = (seat: number | null | undefined) => { + const id = seat === null || seat === undefined ? undefined : v.seats[seat]?.id; + const n = id ? players[id]?.nickname : undefined; + return n ? `${n}님` : '다른 사람'; + }; + const my = me !== null ? v.my : null; + const sanma = v.mode === 'sanma'; + const calls = sanma ? '퐁·깡' : '치·퐁·깡'; + + if (v.phase === 'finished') return finished(v, me, players); + if (v.phase === 'handResult') return handResult(v, my, nameOf); + + // ---- playing + const riichiSeats = v.seats.filter((s) => s.riichi && s.seat !== my?.seat).map((s) => nameOf(s.seat)); + const riichiLine = riichiSeats.length ? `리치한 사람: ${riichiSeats.join(', ')}.` : ''; + + if (v.stage === 'claim' && v.claimOn) { + const c = v.claimOn; + const tile = `${short(c.tile)} 패`; + const mine = !!my && c.from === my.seat; + const who = mine ? '내가' : `${nameOf(c.from)}이`; + const what = + c.source === 'discard' ? `${who} ${eulReul(tile)} 버렸어요.` : c.source === 'kakan' ? `${who} ${eulReul(tile)} 가깡(퐁에 1장 더 붙이기)했어요.` : c.source === 'ankan' ? `${who} ${short(c.tile)} 4장으로 안깡했어요.` : `${who} 북을 옆에 빼 두었어요(북 뽑기).`; + if (my?.claim && !my.claim.responded) return claimHelp(v, my, what, calls); + if (my?.claim?.responded) return `${what}\n응답했어요. 다른 사람이 고르기를 기다리는 중이에요.`; + const how = `${c.source === 'discard' ? `울기(${calls})나 ` : ''}론`; + const lines = [`${what} 다른 사람이 이 패로 ${how}을 할지 확인하는 중이에요.`]; + lines.push('잠깐 기다리면 다음 차례로 넘어가요.'); + if (my) waitsLine(v, my, lines); + return lines.join('\n'); + } + + const turn = v.turn; + if (turn === null || (v.stage !== 'afterDraw' && v.stage !== 'afterCall')) return '잠시만 기다려 주세요. 곧 다음 차례가 시작돼요.'; + + if (!my) { + const lines = [v.stage === 'afterCall' ? `${nameOf(turn)} 차례예요. 방금 울어서(${calls}) 패를 가져오지 않고 바로 1장을 버려요.` : `${nameOf(turn)} 차례예요. 1장을 가져오고(쯔모) 1장을 버려요.`]; + lines.push(`${roundLabel(v)} · 남은 패 ${v.wallCount}장.${riichiLine ? ` ${riichiLine}` : ''}`); + lines.push('손패 4면자(같은 패 3장이나 이어진 3장) + 머리(같은 패 2장)를 먼저 완성하는 사람이 이겨요(화료).'); + return lines.join('\n'); + } + + if (!my.canDiscard) { + const lines = [`${nameOf(turn)} 차례예요. 기다려 주세요.`]; + if (my.autoPilot) lines.push('지금 자리 비움 상태라 자동으로 쳐요. 아무 패나 누르면 돌아와요.'); + waitsLine(v, my, lines); + if (riichiLine) lines.push(riichiLine); + return lines.join('\n'); + } + return myTurn(v, my, riichiSeats.length > 0, calls); +} + +/** 리치·텐파이 대기패 / 후리텐 (only what the view already has). */ +function waitsLine(v: MahjongView, my: MyView, lines: string[]) { + if (my.riichi) lines.push('나는 리치 중이에요. 화료패(손패를 완성하는 패)가 나오면 [론!], 내가 가져오면 [쯔모!]가 떠요.'); + if (my.waits.length) { + const noYaku = my.waits.some((w) => !w.yaku); + lines.push(`텐파이(1장만 더 오면 완성)예요. 대기패: ${waitText(my.waits)}.${noYaku ? ' "역 없음" 패로는 론할 수 없어요.' : ''}`); + } + if (my.furiten && (my.waits.length || my.riichi)) lines.push('후리텐이에요: 내 대기패를 전에 버렸거나 론을 넘겨서 론은 못 하고, 내가 직접 가져오는 쯔모로만 화료할 수 있어요.'); + const beginnerOn = v.options.beginnerMode && my.prefs.beginner; + if (beginnerOn && my.yakuHints.length) lines.push(`💡 ${my.yakuHints.slice(0, 2).join(' ')}`); +} + +function myTurn(v: MahjongView, my: MyView, othersRiichi: boolean, calls: string): string { + const beginnerOn = v.options.beginnerMode && my.prefs.beginner; + const lines: string[] = []; + if (my.canTsumo) lines.push('화료할 수 있어요! [쯔모!]를 누르면 가져온 패로 손패 완성(쯔모 화료)이에요.'); + if (my.riichi) { + lines.push(`내 차례예요. 리치 중이라 손패를 바꿀 수 없어서 방금 가져온 패만 버릴 수 있어요(나머지는 흐리게 보여요).`); + } else if (v.stage === 'afterCall') { + lines.push(`내 차례예요. 방금 울었으니(${calls}) 패를 가져오지 않고 바로 1장을 버려요.`); + if (my.locked.length) lines.push('🔒 표시 패는 방금 울은 패와 같은 패(또는 스지)라 지금 버릴 수 없어요.'); + } else { + lines.push(`내 차례예요. ${my.drawn ? `방금 가져온 패는 ${short(my.drawn)} 패예요(맨 오른쪽에 조금 떨어진 패). ` : ''}손패에서 1장을 골라 버려요.`); + } + lines.push(`${my.riichi ? '가져온 패를' : '버릴 패를'} 누른 뒤 한 번 더 누르거나 [○○ 버리기] 버튼을 누르면 버려요(한 번에 놓기 설정이면 바로 버려져요).`); + if (my.riichiDiscards.length) lines.push('[리치]: 텐파이(1장만 더 오면 완성)라 리치할 수 있어요. [리치]를 누르고 밝은 패를 버리면 1,000점을 내고 리치를 걸어요. 리치는 역(점수가 나는 조건)이 되지만, 그다음부터는 가져온 패만 버려요.'); + const tenpai = Object.keys(my.tenpaiAfter); + if (beginnerOn && !my.riichi && tenpai.length) { + const kinds = [...new Set(tenpai.map(short))]; + lines.push(`'텐' 표시가 붙은 패(${kinds.slice(0, 4).join(', ')}${kinds.length > 4 ? ' 등' : ''})를 버리면 텐파이예요. 패를 누르면 기다리는 패와 남은 장수가 아래에 나와요.`); + } + if (my.ankan.length || my.kakan.length) lines.push('[깡]: 같은 패 4장을 모아 깡을 할 수 있어요. 깡을 하면 패를 1장 더 가져와요.'); + if (my.canKita) lines.push('[북 뽑기]: 북을 옆에 빼 두고 패를 1장 더 가져와요. 빼 둔 북은 도라(보너스 1판)예요.'); + if (my.canKyuushu) lines.push('[구종구패(유국)]: 1·9·자패가 9종류 이상이라 이번 국을 무승부로 끝낼 수 있어요(안 해도 돼요).'); + if (my.furiten && !my.canTsumo) lines.push('후리텐이에요: 론은 못 하고 쯔모로만 화료할 수 있어요.'); + if (othersRiichi && !my.riichi && !my.canTsumo) lines.push('리치한 사람이 이미 버린 패와 같은 패는 그 사람에게 론 당하지 않아요.'); + if (beginnerOn && my.yakuHints.length) lines.push(`💡 ${my.yakuHints.slice(0, 2).join(' ')}`); + else if (!beginnerOn && v.options.beginnerMode) lines.push("'내 설정'에서 초보 모드를 켜면 텐파이 표시와 대기패·역 힌트를 볼 수 있어요."); + lines.push(v.stage === 'afterDraw' ? '시간이 지나면 방금 가져온 패를 저절로 버려요.' : '시간이 지나면 패 1장이 저절로 버려져요.'); + return lines.join('\n'); +} + +function claimHelp(v: MahjongView, my: MyView, what: string, calls: string): string { + const c = my.claim!; + const lines = [what]; + if (c.ron) lines.push('이 패로 화료할 수 있어요! [론!]을 누르면 남이 낸 패로 손패 완성(론 화료)이에요.'); + if (c.pon.length) lines.push('[퐁]: 같은 패 2장에 이 패를 더해 3장을 내놓아요.'); + if (c.kan.length) lines.push('[깡]: 같은 패 3장에 이 패를 더해 4장을 내놓고, 패를 1장 더 가져와요.'); + if (c.chi.length) lines.push(`[치${c.chi.length > 1 ? ` (${c.chi.length}가지)` : ''}]: 바로 앞 사람이 버린 패로 이어진 3장을 만들어요.${c.chi.length > 1 ? ' 누른 뒤 쓸 조합을 골라요.' : ''}`); + lines.push(`[패스]: 그냥 넘겨요. 시간이 지나면 저절로 패스돼요.${c.ron ? ' 론을 넘기면 후리텐이 되어 한동안(리치 중이면 이번 국 끝까지) 론을 못 해요.' : ''}`); + if ((c.pon.length || c.kan.length || c.chi.length) && !my.riichi) lines.push(`울면(${calls}) 리치를 못 해요. 역패·탕야오처럼 다른 역(점수가 나는 조건)이 있어야 화료할 수 있어요.`); + if (v.mode === 'sanma' && c.chi.length === 0 && (c.pon.length || c.kan.length)) lines.push('3인 마작은 치가 없어요.'); + return lines.join('\n'); +} + +function handResult(v: MahjongView, my: MyView | null, nameOf: (seat: number | null | undefined) => string): string { + const r = v.result as HandResultView | null; + const lines: string[] = []; + if (!r) lines.push('이번 국이 끝났어요.'); + else if (r.kind === 'agari') { + for (const w of r.winners) { + const how = w.from === null ? '쯔모(직접 가져온 패로 완성)' : `론(${nameOf(w.from)}이 버린 패로 완성)`; + const yaku = w.yaku.filter((y) => !/^(도라|적도라|우라도라|북) /.test(y.name)).map((y) => y.name); + const size = w.limit === 'yakuman' ? (w.yakuman > 1 ? `${w.yakuman}배 역만` : '역만') : `${w.han}판 ${w.fu}부${LIMIT[w.limit] ? ` · ${LIMIT[w.limit]}` : ''}`; + lines.push(`${roundLabel(r)}: ${nameOf(w.seat)} ${how} 화료예요. 역: ${yaku.join(', ') || '없음'} · ${size} · ${fmt(w.points)}점.`); + } + } else if (r.kind === 'exhaustive') { + lines.push(`${roundLabel(r)}: 패가 다 떨어져 유국(무승부)이에요. 텐파이(1장만 더 오면 완성)였던 사람: ${r.tenpai.length ? r.tenpai.map(nameOf).join(', ') : '없음'}.`); + if (r.nagashi.length) lines.push(`유국만관: ${r.nagashi.map(nameOf).join(', ')}.`); + else if (r.tenpai.length && r.tenpai.length < v.n) lines.push('텐파이가 아닌 사람이 텐파이인 사람에게 점수를 줘요.'); + } else { + lines.push(`${roundLabel(r)}: 도중유국(${ABORT[r.abortiveReason ?? ''] ?? '중간에 무효'})이에요. 점수는 그대로예요.`); + } + if (r && my) { + const d = r.deltas[my.seat] ?? 0; + lines.push(`내 점수: ${signed(d)} → ${fmt(r.pointsAfter[my.seat] ?? 0)}점.`); + } + const ready = my ? v.seats[my.seat]?.ready : false; + const next = v.gameOver ? '대국이 끝나요.' : v.nextHand ? `다음은 ${roundLabel(v.nextHand)}이에요.` : ''; + if (!my) lines.push(`${next} 모두 확인하면 넘어가요.`.trim()); + else if (ready) lines.push(`${next} 다른 사람을 기다리는 중이에요 (${v.seats.filter((s) => s.ready).length}/${v.n}).`.trim()); + else lines.push(`${next} 표를 확인하고 [${v.gameOver ? '최종 결과 보기' : '다음 국으로'}]를 눌러 주세요. 시간이 지나면 저절로 넘어가요.`.trim()); + return lines.join('\n'); +} + +function finished(v: MahjongView, me: string | null, players: Record): string { + const f = v.final; + const lines = [`대국이 끝났어요.${f ? ` ${f.summary}` : ''}`]; + const mine = f?.ranking.find((x) => x.player === me); + if (mine) lines.push(`나는 ${mine.rank}위 · ${fmt(mine.points)}점이에요.`); + else if (f?.ranking[0]) lines.push(`1위는 ${players[f.ranking[0].player]?.nickname ? `${players[f.ranking[0].player]!.nickname}님` : '다른 사람'}이에요.`); + lines.push('최종 결과 표의 "결과(우마·오카)"는 순위 보너스를 더한 놀이용 점수예요. 결과 창에서 [한 판 더]를 누르면 다시 할 수 있어요.'); + return lines.join('\n'); +} diff --git a/apps/web/src/games/mahjong/index.tsx b/apps/web/src/games/mahjong/index.tsx index 330e272..db221a0 100644 --- a/apps/web/src/games/mahjong/index.tsx +++ b/apps/web/src/games/mahjong/index.tsx @@ -6,6 +6,8 @@ import { Avatar } from '../../components/ui'; import { TurnTimer } from '../../components/TurnTimer'; import { haptic, sounds } from '../../lib/feedback'; import type { BoardProps, GameUI, OptionsProps } from '../types'; +import { helpNow } from './help'; +import { quickStart } from './quick'; import { Tile, tileName, tileOrder, tileShort } from './Tile'; const WIND = ['동', '남', '서', '북']; @@ -1266,6 +1268,8 @@ const ui: GameUI = { Options, optionsSummary, Rules, + quickStart, + helpNow, }; export default ui; diff --git a/apps/web/src/games/mahjong/quick.tsx b/apps/web/src/games/mahjong/quick.tsx new file mode 100644 index 0000000..9860ac4 --- /dev/null +++ b/apps/web/src/games/mahjong/quick.tsx @@ -0,0 +1,98 @@ +/** 마작 30초 규칙 요약 (docs/07 §5). */ +import type { CSSProperties, ReactNode } from 'react'; +import type { QuickCard } from '../types'; +import { Tile } from './Tile'; + +const col: CSSProperties = { display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 6 }; +const row: CSSProperties = { display: 'flex', alignItems: 'flex-end', gap: 6 }; +const label: CSSProperties = { fontSize: 12, fontWeight: 800, color: 'var(--text-sub, #555)', textAlign: 'center' }; + +function Group({ codes, w, note }: { codes: string[]; w: number; note?: string }) { + return ( + + + {codes.map((c, i) => ( + + ))} + + {note && {note}} + + ); +} + +function Badge({ children, bg, color = '#fff' }: { children: ReactNode; bg: string; color?: string }) { + return {children}; +} + +export const quickStart: QuickCard[] = [ + { + pic: ( +
+
+ + +
+
+ + + +
+
+ ), + alt: '이어진 3장, 같은 3장 묶음 4개와 같은 패 2장으로 완성된 마작 손패', + title: '1장 가져오고 1장 버려요', + text: '차례마다 1장을 가져오고(쯔모) 1장을 버려요. 3장 묶음 4개 + 같은 패 2장을 먼저 만들면 이겨요(화료).', + }, + { + pic: ( +
+
+ 리치 + + + + + +
+
+ 탕야오 + + + +
+
+ 역패 + + + +
+
+ ), + alt: '쉬운 역 세 가지: 리치 선언, 2~8만 모은 탕야오, 백 3장 역패', + title: '역이 꼭 있어야 해요', + text: "손패를 완성해도 '역'(점수가 나는 조건)이 1개는 있어야 이겨요. 쉬운 역: 리치, 탕야오(2~8만), 역패(백·발·중 3장).", + }, + { + pic: ( +
+
+ {['3m', '4m', '6p', '7p', '8p'].map((c, i) => ( + + {i === 4 && ( + 텐 + )} + + + ))} +
+
+ 쯔모! + 론! +
+
+ ), + alt: "'텐' 표시가 붙은 패와 빨간 쯔모 버튼, 론 버튼", + title: '쯔모! 론! 으로 이겨요', + text: "필요한 패를 직접 가져오면 [쯔모!], 남이 버리면 [론!]을 눌러요. 초보 모드는 '텐' 표시와 기다리는 패를 알려 줘요.", + }, +]; diff --git a/apps/web/src/games/marble/help.test.ts b/apps/web/src/games/marble/help.test.ts new file mode 100644 index 0000000..ea79e83 --- /dev/null +++ b/apps/web/src/games/marble/help.test.ts @@ -0,0 +1,68 @@ +import { expect, test } from 'bun:test'; +import { applyAction, setupGame, usersOf } from '../help-kit.test-util'; +import { GAMES } from '@bg/games'; +import { helpNow } from './help'; + +const users = usersOf(['A', 'B']); +const view = (s: unknown, who: string | null) => GAMES.marble!.view(s, who) as any; +/** Scripted dice: int(6) returns the queued faces (1..6). */ +const dice = (...faces: number[]) => ({ next: () => 0, int: () => faces.shift()! - 1, shuffle: (a: readonly T[]) => [...a] }) as any; + +function fresh(opts: Record = {}) { + const { state } = setupGame('marble', 2, opts); + state.order = ['A', 'B']; + state.current = 0; + return state; +} + +test('땅따먹기: 굴리기 차례 / 상대 차례 / 관전', () => { + const s = fresh(); + const a = helpNow(view(s, 'A'), 'A', users); + expect(a).toContain('[🎲 주사위 굴리기]'); + expect(a).toContain('지금 내 포인트는 300P'); + const b = helpNow(view(s, 'B'), 'B', users); + expect(b).toContain('호랑이님 차례예요(1/20라운드). 기다려 주세요.'); + expect(helpNow(view(s, null), null, users)).toContain('호랑이님 차례예요(1/20라운드).'); +}); + +test('땅따먹기: 빈 땅에 서면 사기 / 내 땅에 다시 오면 짓기', () => { + let s = fresh(); + s = applyAction('marble', s, 'A', { type: 'ROLL' }, dice(2, 4)); // → 6 방콕 + const t = helpNow(view(s, 'A'), 'A', users); + expect(t).toContain('방콕에 왔어요. 주인 없는 땅이라 10P에 살 수 있어요.'); + expect(t).toContain('[사기]'); + expect(helpNow(view(s, 'B'), 'B', users)).toContain('방콕에서 할 일을 고르는 중이에요'); + const s2 = fresh(); + s2.owners[6] = { owner: 'A', level: 0 }; + const s3 = applyAction('marble', s2, 'A', { type: 'ROLL' }, dice(2, 4)); + const u = helpNow(view(s3, 'A'), 'A', users); + expect(u).toContain('내 땅 방콕에 왔어요. 별장을 5P에 지을 수 있어요. 지으면 통행료가 6P가 돼요.'); + expect(u).toContain('[짓기]'); +}); + +test('땅따먹기: 무인도, 더블, 우주여행, 모자란 포인트', () => { + let s = fresh(); + s.players.A.pos = 10; + s.players.A.islandLeft = 2; + expect(helpNow(view(s, 'A'), 'A', users)).toContain('무인도에 갇혔어요(2번 남음)'); + expect(helpNow(view(s, 'B'), 'B', users)).toContain('무인도에 갇혀 있어요(2번 남음)'); + + s = fresh(); + s = applyAction('marble', s, 'A', { type: 'ROLL' }, dice(3, 3)); // → 6 방콕, double + s = applyAction('marble', s, 'A', { type: 'PASS' }); + expect(helpNow(view(s, 'A'), 'A', users)).toContain('더블이 나와서 한 번 더 굴려요'); + + s = fresh(); + s.players.A.spacePending = true; + s.players.A.pos = 30; + expect(helpNow(view(s, 'A'), 'A', users)).toContain('우주여행이에요'); + + s = fresh(); + s.owners[6] = { owner: 'B', level: 3 }; + s.owners[39] = { owner: 'A', level: 0 }; + s.players.A.cash = 5; + s = applyAction('marble', s, 'A', { type: 'ROLL' }, dice(2, 4)); // 통행료 25 > 5 + expect(s.phase).toBe('debt'); + expect(helpNow(view(s, 'A'), 'A', users)).toContain('20P가 모자라요'); + expect(helpNow(view(s, 'B'), 'B', users)).toContain('모자란 포인트 20P'); +}); diff --git a/apps/web/src/games/marble/help.ts b/apps/web/src/games/marble/help.ts new file mode 100644 index 0000000..3bc02a7 --- /dev/null +++ b/apps/web/src/games/marble/help.ts @@ -0,0 +1,94 @@ +/** 주사위 땅따먹기 [?] "지금 할 수 있는 것" (docs/07 §5). Pure: only reads the client view (+ the pure board data). */ +import type { MarbleView } from '@bg/games/marble'; +import { SPACES } from '@bg/games/marble/board'; +import type { PublicUser } from '@bg/shared/lite'; + +const LEVEL_KO = ['땅', '별장', '빌딩', '호텔', '랜드마크']; +const P = (n: number) => `${n}P`; +const batchim = (w: string) => { + const c = w.charCodeAt(w.length - 1); + return c >= 0xac00 && c <= 0xd7a3 && (c - 0xac00) % 28 !== 0; +}; +const eul = (w: string) => `${w}${batchim(w) ? '을' : '를'}`; +const space = (pos: number) => SPACES[pos]?.name ?? `${pos}번 칸`; + +export function helpNow(v: MarbleView, me: string | null, players: Record): string { + if (v.phase === 'finished') return `게임이 끝났어요. ${v.result?.summary ?? ''}`.trim() + '\n결과 창에서 [한 판 더]를 누르면 다시 할 수 있어요.'; + const name = (id: string | null | undefined) => `${(id && players[id]?.nickname) || '다른 사람'}님`; + const round = v.maxRounds > 0 ? `${v.round}/${v.maxRounds}라운드` : `${v.round}라운드`; + const meP = me ? v.players.find((p) => p.id === me) : undefined; + const m = v.my; + + // What is happening, told neutrally (for waiting players and spectators). + const situation = (): string => { + if (v.phase === 'debt' && v.debt) return `${name(v.debt.debtor)}이 모자란 포인트 ${P(v.debt.short)}를 땅이나 건물을 팔아 채우는 중이에요.`; + if (v.phase === 'tradeResponse' && v.trade) return `${name(v.trade.to)}이 ${name(v.trade.from)}의 교환 제안을 보는 중이에요.`; + const cur = v.current ? v.players.find((p) => p.id === v.current) : undefined; + let s = `${name(v.current)} 차례예요(${round}).`; + if (v.phase === 'decide' && v.decision) s += ` ${space(v.decision.pos)}에서 할 일을 고르는 중이에요.`; + else if (cur?.spacePending) s += ' 우주여행으로 갈 칸을 고르는 중이에요.'; + else if (cur && cur.islandLeft > 0) s += ` 무인도에 갇혀 있어요(${cur.islandLeft}번 남음).`; + return s; + }; + + if (!meP) return `${situation()}\n주사위를 굴려 판을 돌며 땅을 사고 건물을 지어요. 남의 땅에 서면 통행료를 내요.`; + if (!meP.alive) return `나는 ${meP.conceded ? '기권' : '파산'}해서 지금은 구경만 할 수 있어요.\n${situation()}`; + + const mine = `지금 내 포인트는 ${P(meP.cash)}, 내 땅은 ${meP.lands.length}곳이에요.`; + + if (m?.respond && v.trade) return `${name(v.trade.from)}이 교환을 제안했어요. 주고받을 땅과 포인트를 잘 보고 [받기] 또는 [거절]을 눌러 주세요.`; + + if (m?.sell && v.debt) { + const lines = [`내야 할 ${P(v.debt.amount)} 중에 ${P(v.debt.short)}가 모자라요. 땅이나 건물을 은행에 팔아(낸 값의 절반을 받아요) 채워야 해요.`]; + lines.push(m.autoSell ? '[가장 싼 것부터 팔기] 버튼을 누르거나, ‘내 땅’ 목록에서 직접 골라 팔아요. 다 모이면 자동으로 내요.' : '‘내 땅’ 목록에서 팔 것을 골라 주세요. 다 모이면 자동으로 내요.'); + return lines.join('\n'); + } + + if (m?.decide) { + const d = m.decide; + const where = space(d.pos); + if (d.buy !== null) { + return `${where}에 왔어요. 주인 없는 땅이라 ${P(d.buy)}에 살 수 있어요.\n[사기]를 누르면 내 땅이 되고, 다른 사람이 여기 서면 나에게 통행료를 내요. 사지 않으려면 [안 사기]를 눌러요.\n${mine}`; + } + if (d.build) { + const toll = SPACES[d.pos]?.tolls?.[d.build.level]; + const after = toll !== undefined ? ` 지으면 통행료가 ${P(toll)}가 돼요.` : ''; + const head = d.boughtThisVisit ? `방금 산 ${where}에 이 방 규칙으로 바로` : `내 땅 ${where}에 왔어요.`; + return `${head} ${eul(LEVEL_KO[d.build.level]!)} ${P(d.build.cost)}에 지을 수 있어요.${after}\n[짓기] 또는 [안 짓기]를 눌러 주세요. 건물은 한 번 올 때마다 한 단계씩 지어요.\n${mine}`; + } + if (d.takeover !== null) { + return `${where}에서 통행료를 냈어요. 이 방 규칙에서는 ${P(d.takeover)}를 내고 이 땅을 건물째 가져올(인수) 수 있어요.\n[인수하기] 또는 [안 하기]를 눌러 주세요.\n${mine}`; + } + } + + if (m?.space) return `🚀 우주여행이에요! 판에서 반짝이는 칸을 누르면 미리 보기가 나오고, 거기서 갈 곳을 정해요.\n출발 칸을 지나가면 월급 ${P(v.options.salary)}를 받아요. [출발로 가기]를 눌러도 돼요.\n${mine}`; + + if (m?.island) { + const isl = m.island; + const lines = [`🏝️ 무인도에 갇혔어요(${isl.left}번 남음). [🎲 탈출 굴리기]를 눌러 더블(두 주사위가 같은 눈)이 나오면 바로 나가요.`]; + const ways: string[] = []; + if (isl.hasCard) ways.push('[탈출권 쓰기]'); + if (isl.fee !== null) ways.push(`[탈출비 ${P(isl.fee)} 내기]${isl.canPayFee ? '' : '(포인트가 모자라 지금은 못 눌러요)'}`); + if (ways.length) lines.push(`${ways.join(' 또는 ')}로 바로 나갈 수도 있어요.`); + lines.push('무인도에 있어도 내 땅의 통행료는 받아요.'); + return lines.join('\n'); + } + + if (m?.canRoll) { + const lines: string[] = []; + if (v.extraRoll) lines.push(`더블이 나와서 한 번 더 굴려요! [🎲 한 번 더 굴리기]를 눌러 주세요.${v.doublesThisTurn === 2 ? ' 또 더블이 나오면 무인도로 가요.' : ''}`); + else lines.push(`내 차례예요(${round}). [🎲 주사위 굴리기]를 누르면 나온 수만큼 앞으로 가요.`); + lines.push('주인 없는 땅에 서면 살 수 있고, 남의 땅에 서면 통행료를 내요. 더블이면 한 번 더 굴려요.'); + if (m.canPropose) lines.push(`굴리기 전에 [🤝 교환 제안하기]로 다른 사람과 땅·포인트를 바꾸자고 할 수 있어요.`); + lines.push(mine); + return lines.join('\n'); + } + + // Waiting for someone else. + const lines = [`${situation()} 기다려 주세요.`]; + if (v.phase === 'tradeResponse' && v.trade?.from === me) lines[0] = `내 교환 제안에 ${name(v.trade.to)}이 답하기를 기다리는 중이에요.`; + if (meP.islandLeft > 0) lines.push(`나는 무인도에 있어요(${meP.islandLeft}번 남음). 그래도 내 땅의 통행료는 받아요.`); + else if (meP.spacePending) lines.push('다음 내 차례에 우주여행으로 원하는 칸에 갈 수 있어요.'); + lines.push(mine); + return lines.join('\n'); +} diff --git a/apps/web/src/games/marble/index.tsx b/apps/web/src/games/marble/index.tsx index 67e304b..f9c09ba 100644 --- a/apps/web/src/games/marble/index.tsx +++ b/apps/web/src/games/marble/index.tsx @@ -7,6 +7,8 @@ import { Avatar, ConfirmSheet, Sheet } from '../../components/ui'; import { TurnTimer } from '../../components/TurnTimer'; import { haptic, sounds } from '../../lib/feedback'; import type { BoardProps, GameUI, OptionsProps } from '../types'; +import { helpNow } from './help'; +import { quickStart } from './quick'; type GameEvent = MarbleView['log'][number]; @@ -1388,6 +1390,8 @@ const ui: GameUI = { Options, optionsSummary, Rules, + quickStart, + helpNow, }; export default ui; diff --git a/apps/web/src/games/marble/quick.tsx b/apps/web/src/games/marble/quick.tsx new file mode 100644 index 0000000..c2dacfc --- /dev/null +++ b/apps/web/src/games/marble/quick.tsx @@ -0,0 +1,87 @@ +/** 주사위 땅따먹기 30초 규칙 요약 (docs/07 §5). */ +import type { ReactNode } from 'react'; +import type { QuickCard } from '../types'; + +const PIPS: Record = { + 1: [[50, 50]], + 2: [[28, 28], [72, 72]], + 3: [[26, 26], [50, 50], [74, 74]], + 4: [[28, 28], [72, 28], [28, 72], [72, 72]], + 5: [[26, 26], [74, 26], [50, 50], [26, 74], [74, 74]], + 6: [[28, 24], [72, 24], [28, 50], [72, 50], [28, 76], [72, 76]], +}; + +function Die({ value }: { value: number }) { + return ( + + + {PIPS[value]!.map(([x, y], i) => ( + + ))} + + ); +} + +/** A city square like on the board: colour band, name, and a line below. */ +function Tile({ name, color, children, owner }: { name: string; color: string; children: ReactNode; owner?: string }) { + return ( +
+
+
{name}
+
{children}
+
+ ); +} + +const row = { display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 10 } as const; + +export const quickStart: QuickCard[] = [ + { + pic: ( +
+ + + ➜ + + 10P에 사기 + +
+ ), + alt: '주사위 3과 5를 굴려 방콕 칸에 도착하고, 10P에 살 수 있어요', + title: '굴리고 땅을 사요', + text: '주사위 2개를 굴린 만큼 판을 돌아요. 주인 없는 땅에 서면 [사기]로 내 땅으로 만들 수 있어요.', + }, + { + pic: ( +
+ + 🏠🏢🏨 + +
+
🚶
+ 통행료 +
+ 40P +
+
+ ), + alt: '별장·빌딩·호텔을 지은 로마 칸에 다른 사람이 와서 통행료 40P를 내요', + title: '짓고 통행료 받기', + text: '내 땅에 다시 오면 별장→빌딩→호텔을 한 단계씩 지어요. 남이 내 땅에 서면 통행료를 받고, 건물이 클수록 비싸요.', + }, + { + pic: ( +
+
+
🏝️
무인도 +
+
+
🏆
총자산 1등 +
+
+ ), + alt: '무인도 섬과 총자산 1등 트로피 그림', + title: '총자산이 많으면 이겨요', + text: '무인도에선 몇 차례 쉬어요(더블이면 바로 탈출). 라운드가 다 끝날 때 총자산(포인트+땅+건물)이 가장 많거나, 남이 모두 파산하면 이겨요.', + }, +]; diff --git a/apps/web/src/games/ninety-nine/help.test.ts b/apps/web/src/games/ninety-nine/help.test.ts new file mode 100644 index 0000000..f210494 --- /dev/null +++ b/apps/web/src/games/ninety-nine/help.test.ts @@ -0,0 +1,49 @@ +import { expect, test } from 'bun:test'; +import { applyAction, setupGame, usersOf } from '../help-kit.test-util'; +import { GAMES } from '@bg/games'; +import { helpNow } from './help'; + +const users = usersOf(['A', 'B', 'C']); +const view = (s: unknown, who: string | null) => GAMES['ninety-nine']!.view(s, who) as any; +const nickOf = (id: string) => users[id]!.nickname; + +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)'); + const t = helpNow(view(state, other), other, users); + expect(t).toContain(`${nickOf(cur)}님 차례예요(지금 합계 0)`); + 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 }) : {}) }); + expect(helpNow(view(s2, s2.current), s2.current, users)).toContain(`지금 합계 ${s2.total}`); +}); + +test('구십구: 합계 95에서 낼 수 있는 카드만 밝게, ±카드 안내', () => { + const { state } = setupGame('ninety-nine', 3); + const cur = state.current as string; + state.total = 95; + state.hands[cur] = [ + { id: 'q1', kind: 'add', v: 8, face: { label: '8' } }, + { id: 'q2', kind: 'add', v: 7, face: { label: '7' } }, + { id: 'q3', kind: 'plusMinus', v: 9, face: { label: '±9' } }, + ]; + const t = helpNow(view(state, cur), cur, users); + expect(t).toContain('밝게 보이는 카드만 낼 수 있어요(1장)'); + expect(t).toContain('±카드는'); +}); + +test('구십구: 목숨 규칙과 탈락', () => { + const { state } = setupGame('ninety-nine', 3, { lives: 3 }); + 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('내 목숨은 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 new file mode 100644 index 0000000..ba30b61 --- /dev/null +++ b/apps/web/src/games/ninety-nine/help.ts @@ -0,0 +1,53 @@ +/** 구십구 [?] "지금 할 수 있는 것" (docs/07 §5). Pure: only reads the client view. */ +import type { NinetyNineView } from '@bg/games/ninety-nine'; +import type { PublicUser } from '@bg/shared/lite'; + +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부터 새 판을 해요' : '바로 탈락해요'; +} + +export function helpNow(v: NinetyNineView, me: string | null, players: Record): string { + const goal = '카드를 1장씩 내며 합계를 더해요. 합계가 99를 넘게 될 카드밖에 없으면 져요.'; + if (v.phase === 'finished') return `게임이 끝났어요. ${v.result?.summary ?? ''}`.trim() + '\n결과 창에서 [한 판 더]를 누르면 다시 할 수 있어요.'; + + const seat = me ? v.seats.find((s) => s.id === me) : undefined; + const my = seat ? v.my : null; + const cur = v.current; + const curNick = nm(players, cur); + const turnLine = cur ? `${curNick} 차례예요(지금 합계 ${v.total}).` : `지금 합계는 ${v.total}까지 왔어요.`; + + if (!seat || !my) return `${turnLine}\n${goal}`; + if (!seat.alive) return `${seat.left ? '게임에서 나갔어요.' : '탈락했어요. 남은 사람의 게임을 지켜봐요.'}\n${turnLine}`; + + const lifeLine = + v.livesStart > 0 + ? seat.onBoard + ? '목숨을 다 잃어서 마지막 기회예요. 한 번 더 지면 탈락해요.' + : `내 목숨은 ${seat.lives}개예요.` + : ''; + + if (!my.myTurn) { + const lines = [`${turnLine} 기다려 주세요.`]; + lines.push(`내 차례에 낼 수 있는 카드가 없으면 ${loseText(v)}. 카드를 누르면 낼 때 합계를 미리 볼 수 있어요.`); + if (lifeLine) lines.push(lifeLine); + return lines.join('\n'); + } + + const legal = my.hand.filter((c) => c.legal); + const lines = [`내 차례예요(지금 합계 ${v.total}). 합계가 99를 넘지 않게 카드 1장을 내요.`]; + if (legal.length < my.hand.length) lines.push(`초록 테두리로 밝게 보이는 카드만 낼 수 있어요(${legal.length}장). 어두운 카드는 99를 넘거나 0보다 작아져요.`); + lines.push('카드를 누르면 낸 뒤 합계가 보여요. 한 번 더 누르면 내요. 패스는 없어요.'); + if (legal.some((c) => c.kind === 'plusMinus')) lines.push('±카드는 누른 뒤 더하기(+)와 빼기(−) 중 하나를 골라요. 합계가 클 때는 빼기가 좋아요.'); + 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('reverse')) lines.push(`${face('reverse')} 카드는 합계를 그대로 두고 진행 방향만 바꿔요.`); + if (face('hold')) lines.push(`${face('hold')} 카드는 합계를 그대로 두고 다음 사람에게 넘겨요.`); + if (face('to99')) lines.push(`${face('to99')} 카드는 합계를 바로 99로 만들어요.`); + if (face('minus')) lines.push(`${face('minus')} 카드는 합계에서 10을 빼요.`); + if (lifeLine) lines.push(lifeLine); + return lines.join('\n'); +} diff --git a/apps/web/src/games/ninety-nine/index.tsx b/apps/web/src/games/ninety-nine/index.tsx index ac33bf3..f883fd3 100644 --- a/apps/web/src/games/ninety-nine/index.tsx +++ b/apps/web/src/games/ninety-nine/index.tsx @@ -7,6 +7,8 @@ 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 { quickStart } from './quick'; const MODE_KO: Record = { 'kr-product': '전용 카드', classic: '트럼프 카드' }; const SUIT_KO: Record = { S: '스페이드', H: '하트', D: '다이아', C: '클로버' }; @@ -850,6 +852,8 @@ const ui: GameUI = { Options, optionsSummary, Rules, + quickStart, + helpNow, }; export default ui; diff --git a/apps/web/src/games/ninety-nine/quick.tsx b/apps/web/src/games/ninety-nine/quick.tsx new file mode 100644 index 0000000..b9b6aae --- /dev/null +++ b/apps/web/src/games/ninety-nine/quick.tsx @@ -0,0 +1,72 @@ +/** 구십구 30초 규칙 요약 (docs/07 §5). */ +import type { QuickCard } from '../types'; + +/** A 44 × 62 card at (x, y). */ +function Card({ label, x, y, color = '#1d4ed8', dim, ok }: { label: string; x: number; y: number; color?: string; dim?: boolean; ok?: boolean }) { + return ( + + {ok && } + + 2 ? 15 : 22} fontWeight={900} fill={color}> + {label} + + + ); +} + +function Dial({ n, x, y, r = 30, hot }: { n: number; x: number; y: number; r?: number; hot?: boolean }) { + return ( + + + + {n} + + + ); +} + +export const quickStart: QuickCard[] = [ + { + pic: ( + + + + + + = + + + ), + alt: '합계 85에 10 카드를 내서 95가 되는 모습', + title: '내 카드를 더해요', + text: '차례마다 카드 1장을 내고 그 수만큼 합계를 바꿔요. 보통은 낸 뒤 1장을 다시 받아요.', + }, + { + pic: ( + + + + 103 ✕ + + 102 ✕ + + −9 → 86 + + ), + alt: '합계 95에서 8과 7은 99를 넘어 어둡고, ±9 카드만 빼기로 낼 수 있어요', + title: '99를 넘기면 안 돼요', + text: '합계가 99를 넘게 되는 카드는 낼 수 없어요. 낼 카드가 하나도 없으면 져요(탈락하거나 목숨을 잃어요).', + }, + { + pic: ( + + + + + + + ), + alt: '특수 카드 ±9, ±10, 0, 조커', + title: '특수 카드로 버텨요', + text: '±9·±10은 빼기도 돼요. 0은 방향만 바꾸고, 조커는 합계를 원하는 수(보통 60~99)로 만들어요. 끝까지 남으면 이겨요.', + }, +]; diff --git a/apps/web/src/games/one-card/help.test.ts b/apps/web/src/games/one-card/help.test.ts new file mode 100644 index 0000000..2844abb --- /dev/null +++ b/apps/web/src/games/one-card/help.test.ts @@ -0,0 +1,75 @@ +import { expect, test } from 'bun:test'; +import { applyAction, setupGame, usersOf } from '../help-kit.test-util'; +import { GAMES } from '@bg/games'; +import { helpNow } from './help'; + +const users = usersOf(['A', 'B', 'C']); +const view = (s: unknown, who: string | null) => GAMES['one-card']!.view(s, who) as any; +const nickOf = (id: string) => users[id]!.nickname; + +test('원카드: 내 차례 / 상대 차례 / 관전', () => { + const { state } = setupGame('one-card', 3); + const cur = state.current as string; + const other = ['A', 'B', 'C'].find((p) => p !== cur)!; + const mine = helpNow(view(state, cur), cur, users); + expect(mine).toContain('내 차례예요'); + expect(mine).toContain('무늬나 숫자가 같은 카드'); + expect(mine).toMatch(/초록 테두리|낼 수 있는 카드가 없어요/); + expect(helpNow(view(state, other), other, users)).toContain(`${nickOf(cur)}님 차례예요. 기다려 주세요`); + const spec = helpNow(view(state, null), null, users); + expect(spec).toContain(`${nickOf(cur)}님 차례예요`); + expect(spec).not.toContain('내 차례'); +}); + +test('원카드: 공격을 받으면 받아치기/먹기 안내', () => { + const { state } = setupGame('one-card', 3); + const cur = state.current as string; + state.discard.push({ id: 'H2', kind: 'std', suit: 'H', rank: 2 }); + state.pendingAttack = 2; + state.hands[cur] = [{ id: 'S9', kind: 'std', suit: 'S', rank: 9 }, { id: 'C5', kind: 'std', suit: 'C', rank: 5 }]; + const t = helpNow(view(state, cur), cur, users); + expect(t).toContain('공격 2장을 받고 있어요'); + expect(t).toContain('[2장 먹기]'); + state.hands[cur].push({ id: 'H3', kind: 'std', suit: 'H', rank: 3 }); + expect(helpNow(view(state, cur), cur, users)).toContain('같은 무늬 3'); + const other = ['A', 'B', 'C'].find((p) => p !== cur)!; + expect(helpNow(view(state, other), other, users)).toContain(`${nickOf(cur)}님이 공격 2장을 받고 있어요`); +}); + +test('원카드: 7로 정한 무늬, 원카드 잡기', () => { + const { state } = setupGame('one-card', 3); + const cur = state.current as string; + state.declaredSuit = 'H'; + state.discard.push({ id: 'S7', kind: 'std', suit: 'S', rank: 7 }); + expect(helpNow(view(state, cur), cur, users)).toContain('♥ 하트로 정해졌어요'); + const other = ['A', 'B', 'C'].find((p) => p !== cur)!; + state.oneCardWindow = { player: other, declared: false, openedSeq: state.seq }; + state.hands[other] = [state.hands[other][0]]; + expect(helpNow(view(state, other), other, users)).toContain('[원카드!] 버튼을 빨리 눌러요'); + expect(helpNow(view(state, cur), cur, users)).toContain(`${nickOf(other)}님이 1장 남았는데`); +}); + +test('색깔 카드: 색 바꾸기+4 의심하기', () => { + const { state } = setupGame('one-card', 3, { mode: 'uno-style', unoWild4Rule: 'challenge' }); + const by = state.current as string; + const target = state.seats[(state.seats.indexOf(by) + 1) % 3] as string; + state.phase = { kind: 'wild4Response', by, target, prevColor: 'R', illegal: false }; + state.pendingAttack = 4; + state.current = target; + const t = helpNow(view(state, target), target, users); + expect(t).toContain('[의심하기]'); + expect(t).toContain('[받아들이기]'); + expect(helpNow(view(state, by), by, users)).toContain('의심할지 고르는 중'); + expect(helpNow(view(state, null), null, users)).toContain('의심할지 고르는 중'); +}); + +test('원카드: 끝나면 결과 안내', () => { + const def = GAMES['one-card']!; + let { state: s } = setupGame('one-card', 2, { handSize: 5 }); + for (let i = 0; i < 2000 && !def.result(s); i++) { + const actor = def.activePlayers(s)[0]!; + s = applyAction('one-card', s, actor, def.legalActions!(s, actor)[0]); + } + expect(def.result(s)).toBeTruthy(); + expect(helpNow(view(s, 'A'), 'A', usersOf(['A', 'B']))).toContain('게임이 끝났어요'); +}); diff --git a/apps/web/src/games/one-card/help.ts b/apps/web/src/games/one-card/help.ts new file mode 100644 index 0000000..717e2a4 --- /dev/null +++ b/apps/web/src/games/one-card/help.ts @@ -0,0 +1,130 @@ +/** 원카드 [?] "지금 할 수 있는 것" (docs/07 §5). Pure: only reads the client view. */ +import type { OneCardView } from '@bg/games/one-card'; +import type { PublicUser } from '@bg/shared/lite'; + +const SUIT_KO = { S: '♠ 스페이드', H: '♥ 하트', D: '◆ 다이아', C: '♣ 클로버' } as const; +const COLOR_KO = { R: '빨강', Y: '노랑', G: '초록', B: '파랑' } as const; + +const LAST_CARD = { + none: '', + joker: '이 방 규칙에서는 마지막 1장으로 조커를 낼 수 없어요.', + attack: '이 방 규칙에서는 마지막 1장으로 공격 카드(2·A·조커)를 낼 수 없어요.', + special: '이 방 규칙에서는 마지막 1장으로 특수 카드를 낼 수 없어요.', +} as const; + +const nm = (players: Record, id: string | null | undefined) => `${(id && players[id]?.nickname) || '다른 사람'}님`; + +/** What may be played on a normal turn (matches the table chips: 지정 무늬, 지금 색, 조커 밑 기준). */ +function matchRule(v: OneCardView): string { + if (v.mode === 'uno-style') { + if (!v.currentColor) return '지금은 아무 색이나 낼 수 있어요.'; + return `지금 색(${COLOR_KO[v.currentColor]})이 같거나 숫자·기호가 같은 카드, 또는 색 바꾸기 카드를 낼 수 있어요.`; + } + if (v.declaredSuit) return `7로 무늬가 ${SUIT_KO[v.declaredSuit]}로 정해졌어요. 그 무늬 카드나 7, 조커를 낼 수 있어요.`; + if (v.reference) return '맨 위가 조커라서 조커 밑 카드를 기준으로 무늬나 숫자를 맞춰요.'; + return '바닥 맨 위 카드와 무늬나 숫자가 같은 카드, 또는 조커를 낼 수 있어요.'; +} + +function attackLine(v: OneCardView, who: string): string { + return v.mode === 'uno-style' + ? `${who} ${v.pendingAttack}장을 먹어야 하는 상황이에요.` + : `${who} 공격 ${v.pendingAttack}장을 받고 있어요.`; +} + +function myTurn(v: OneCardView): string[] { + const my = v.my!; + const lines: string[] = []; + const legal = my.legalCardIds.length; + const hand = my.hand; + if (v.phase === 'afterDraw') { + lines.push('내 차례예요. 방금 먹은 카드를 바로 낼 수 있어요.'); + lines.push('그 카드를 눌러 [내기]를 누르거나, 내지 않으려면 [넘기기]를 눌러요.'); + return lines; + } + if (v.pendingAttack > 0) { + lines.push(`내 차례예요. ${attackLine(v, '내가')}`); + if (legal > 0) { + const three = hand.some((c) => my.legalCardIds.includes(c.id) && c.kind === 'std' && c.rank === 3) ? ' (같은 무늬 3을 내면 공격이 없어져요)' : ''; + lines.push(`초록 테두리로 밝게 보이는 카드로 받아칠 수 있어요${three}. 아니면 [${my.drawCount}장 먹기]를 눌러요.`); + } else lines.push(`막을 카드가 없어요. [${my.drawCount}장 먹기]를 누르거나 카드 더미를 눌러 주세요.`); + if (v.bankruptLimit && hand.length + v.pendingAttack >= v.bankruptLimit) lines.push(`먹으면 카드가 ${v.bankruptLimit}장 이상이 돼서 파산(탈락)해요!`); + return lines; + } + lines.push(v.extraTurn ? '내 차례예요. K를 내서 한 번 더 해요.' : '내 차례예요.'); + lines.push(matchRule(v)); + if (legal > 0) { + lines.push('초록 테두리로 밝게 보이는 카드만 낼 수 있어요. 카드를 눌러 올린 뒤 한 번 더 누르거나 [내기]를 눌러요.'); + if (my.multiPlay) lines.push('같은 숫자 카드를 더 눌러서 한꺼번에 낼 수도 있어요.'); + const legalCards = hand.filter((c) => my.legalCardIds.includes(c.id)); + if (legalCards.some((c) => c.kind === 'std' && c.rank === 7)) lines.push('7을 내면 다음 무늬를 고르는 창이 떠요.'); + if (legalCards.some((c) => c.kind === 'wild' || c.kind === 'wild4')) lines.push('색 바꾸기 카드를 내면 다음 색을 고르는 창이 떠요.'); + if (my.canDraw) lines.push('내고 싶지 않으면 [1장 먹기]를 눌러도 돼요.'); + } else { + lines.push('낼 수 있는 카드가 없어요. [1장 먹기]를 누르거나 카드 더미를 눌러 주세요.'); + } + if (hand.length === 1 && legal === 0 && v.mode === 'classic' && v.options.lastCardRule !== 'none') lines.push(LAST_CARD[v.options.lastCardRule]); + if (hand.length === 2 && !v.options.autoDeclare && legal > 0) lines.push('내고 나면 1장이 남아요. [내면서 원카드 외치기]에 체크돼 있는지 확인해요.'); + return lines; +} + +export function helpNow(v: OneCardView, me: string | null, players: Record): string { + const goal = '손에 든 카드를 가장 먼저 모두 내면 이겨요.'; + if (v.phase === 'finished') return `게임이 끝났어요. ${v.result?.summary ?? ''}`.trim() + '\n결과 창에서 [한 판 더]를 누르면 다시 할 수 있어요.'; + + const seat = me ? v.seats.find((s) => s.id === me) : undefined; + const my = seat ? v.my : null; + const w = v.oneCardWindow; + + if (v.phase === 'roundEnd') { + const last = v.rounds[v.rounds.length - 1]; + const res = last?.winner ? `${v.round}판은 ${nm(players, last.winner)}이 이겨서 ${last.points}점을 얻었어요.` : `${v.round}판이 끝났어요.`; + const target = ` 먼저 ${v.options.unoTargetScore}점에 닿는 사람이 이겨요.`; + if (my?.canReady) return `${res}${target}\n[다음 판으로]를 눌러 주세요.`; + return `${res}${target}\n다른 사람이 준비하기를 기다리는 중이에요.`; + } + + // Someone with one card has not called "원카드!" yet: anyone still in the game can press the button. + const catchLine = (() => { + if (!my?.canDeclare || !w || w.declared) return ''; + if (w.player === me) return `카드가 1장 남았어요! 동그란 [원카드!] 버튼을 빨리 눌러요. 다른 사람이 먼저 누르면 ${v.penalty}장을 먹어요.`; + return `${nm(players, w.player)}이 1장 남았는데 아직 원카드를 안 외쳤어요. [원카드!]를 먼저 누르면 ${nm(players, w.player)}이 ${v.penalty}장을 먹어요.`; + })(); + const withCatch = (lines: string[]) => (catchLine ? [catchLine, ...lines] : lines).join('\n'); + + const cur = v.current; + const curNick = nm(players, cur); + + // Spectator, or a player who already finished / went bankrupt / left. + if (!seat || !my || seat.status !== 'playing') { + const lines: string[] = []; + if (seat?.status === 'out') { + const place = v.result?.players.find((p) => p.player === me)?.rank; + lines.push(`카드를 모두 냈어요${place ? `(${place}등)` : ''}! 남은 게임을 지켜봐요.`); + } else if (seat?.status === 'bankrupt') lines.push('카드가 너무 많이 쌓여 파산했어요. 남은 게임을 지켜봐요.'); + else if (seat?.status === 'left') lines.push('게임에서 나갔어요.'); + if (v.phase === 'wild4Response' && v.wild4) lines.push(`${nm(players, v.wild4.by)}이 색 바꾸기+4를 냈어요. ${nm(players, v.wild4.target)}이 의심할지 고르는 중이에요.`); + else if (cur) lines.push(`${curNick} 차례예요.${v.pendingAttack > 0 ? ` ${attackLine(v, `${curNick}이`)}` : ''}`); + if (!seat) lines.push(goal); + return withCatch(lines); + } + + if (v.phase === 'wild4Response' && v.wild4) { + const by = nm(players, v.wild4.by); + if (my.canChallenge) { + const lines = [ + `${by}이 색 바꾸기+4를 냈어요. 의심할지 골라 주세요.`, + `[의심하기]: ${by}이 지금 색 카드를 갖고 있었으면 ${by}이 ${v.pendingAttack}장을 먹고, 아니면 내가 ${v.pendingAttack + 2}장을 먹어요.`, + `[받아들이기]: 내가 ${v.pendingAttack}장을 먹고 차례를 넘겨요.`, + ]; + if (my.legalCardIds.length > 0) lines.push('내 색 바꾸기+4로 받아칠 수도 있어요.'); + return withCatch(lines); + } + return withCatch([`${by}이 색 바꾸기+4를 냈어요. ${nm(players, v.wild4.target)}이 의심할지 고르는 중이에요. 기다려 주세요.`]); + } + + if (cur === me) return withCatch(myTurn(v)); + + const lines = [`${curNick} 차례예요. 기다려 주세요.${v.pendingAttack > 0 ? ` ${attackLine(v, `${curNick}이`)}` : ''}`]; + lines.push(`내 카드는 ${my.hand.length}장이에요. ${goal}`); + return withCatch(lines); +} diff --git a/apps/web/src/games/one-card/index.tsx b/apps/web/src/games/one-card/index.tsx index a185f67..04a6e2b 100644 --- a/apps/web/src/games/one-card/index.tsx +++ b/apps/web/src/games/one-card/index.tsx @@ -7,6 +7,8 @@ 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 { quickStart } from './quick'; import { COLOR_HEX, COLOR_INK, COLOR_KO, ColorCardArt, colorCardLabel, patternName } from './ColorCard'; const SUITS: Suit[] = ['S', 'H', 'D', 'C']; @@ -1109,6 +1111,8 @@ const ui: GameUI = { Options, optionsSummary, Rules, + quickStart, + helpNow, }; export default ui; diff --git a/apps/web/src/games/one-card/quick.tsx b/apps/web/src/games/one-card/quick.tsx new file mode 100644 index 0000000..45b4b00 --- /dev/null +++ b/apps/web/src/games/one-card/quick.tsx @@ -0,0 +1,73 @@ +/** 원카드 30초 규칙 요약 (docs/07 §5). */ +import { PlayingCard } from '../../components/cards/PlayingCard'; +import type { QuickCard } from '../types'; + +const row = { display: 'flex', alignItems: 'center', gap: 8 } as const; +const label = { fontSize: 13, fontWeight: 800, color: '#374151', textAlign: 'center' } as const; + +export const quickStart: QuickCard[] = [ + { + pic: ( +
+
+ + 바닥 +
+ ← +
+
+ + + +
+ 내 카드 +
+
+ ), + alt: '바닥에 하트 7이 있고, 내 카드 중 하트 3과 스페이드 7은 밝게, 클로버 9는 어둡게 보여요', + title: '무늬나 숫자를 맞춰 내요', + text: '바닥 카드와 무늬(색깔 카드 모드는 색)나 숫자가 같은 카드를 1장 내요. 낼 카드가 없으면 1장을 먹어요.', + }, + { + pic: ( +
+
+ + + +
+ +2장 · +3장 · +5장 +
+ ), + alt: '공격 카드인 다이아 2, 클로버 A, 조커와 먹이는 장수', + title: '2·A·조커는 공격 카드', + text: '공격 카드를 내면 다음 사람이 카드를 먹어요. 맞는 공격 카드로 받아치면 다음 사람에게 넘어가고, 못 막으면 쌓인 만큼 먹어요.', + }, + { + pic: ( +
+ + + 원카드! + +
+ ), + alt: '카드 1장과 노란 원카드 버튼', + title: '1장 남으면 원카드!', + text: '카드를 먼저 다 내면 이겨요. 1장 남으면 [원카드!]를 얼른 눌러요. 다른 사람이 먼저 누르면 벌로 카드를 먹어요.', + }, +]; diff --git a/apps/web/src/games/othello/help.test.ts b/apps/web/src/games/othello/help.test.ts new file mode 100644 index 0000000..549455a --- /dev/null +++ b/apps/web/src/games/othello/help.test.ts @@ -0,0 +1,40 @@ +import { expect, test } from 'bun:test'; +import { applyAction, setupGame, usersOf } from '../help-kit.test-util'; +import { GAMES } from '@bg/games'; +import { helpNow } from './help'; + +const users = usersOf(['A', 'B']); +const view = (s: unknown, who: string | null) => GAMES.othello!.view(s, who) as any; + +test('리버시: 내 차례 / 상대 차례 / 관전', () => { + const { state } = setupGame('othello', 2, { colorAssignment: 'hostBlack', showLegalMoves: true }); + const mine = helpNow(view(state, 'A'), 'A', users); + expect(mine).toContain('내 차례예요(흑)'); + expect(mine).toContain('작은 점이 있는 칸(4곳)'); + expect(mine).toContain('흑 2개, 백 2개'); + expect(helpNow(view(state, 'B'), 'B', users)).toContain('상대(호랑이님) 차례예요'); + expect(helpNow(view(state, null), null, users)).toContain('호랑이님(흑) 차례예요'); + const s2 = applyAction('othello', state, 'A', { type: 'place', x: 3, y: 2 }); + expect(helpNow(view(s2, 'B'), 'B', users)).toContain('내 차례예요(백)'); + expect(helpNow(view(s2, null), null, users)).toContain('흑 4개, 백 1개'); +}); + +test('리버시: 둘 곳 표시를 끈 방 / 상대가 패스해서 내 차례가 또 옴', () => { + const { state } = setupGame('othello', 2, { colorAssignment: 'hostBlack', showLegalMoves: false }); + expect(helpNow(view(state, 'A'), 'A', users)).toContain('끼울 수 있는 빈칸'); + const s = structuredClone(state); + s.moves.push({ kind: 'pass', color: 'white' }); + expect(helpNow(view(s, 'A'), 'A', users)).toContain('내 차례가 한 번 더'); +}); + +test('리버시: 무르기 요청과 기권 후 결과', () => { + const { state } = setupGame('othello', 2, { colorAssignment: 'hostBlack', undo: '3' }); + let s = applyAction('othello', state, 'A', { type: 'place', x: 3, y: 2 }); + s = applyAction('othello', s, 'A', { type: 'requestUndo' }); + expect(helpNow(view(s, 'B'), 'B', users)).toContain('[수락]'); + expect(helpNow(view(s, 'A'), 'A', users)).toContain('무르기 요청'); + const done = applyAction('othello', state, 'B', { type: 'resign' }); + const t = helpNow(view(done, null), null, users); + expect(t).toContain('게임이 끝났어요'); + expect(t).toContain('기권'); +}); diff --git a/apps/web/src/games/othello/help.ts b/apps/web/src/games/othello/help.ts new file mode 100644 index 0000000..27d62dc --- /dev/null +++ b/apps/web/src/games/othello/help.ts @@ -0,0 +1,29 @@ +/** 리버시(오셀로) [?] "지금 할 수 있는 것" (docs/07 §5). Pure: only reads the client view. */ +import type { OthelloView } from '@bg/games/othello'; +import type { PublicUser } from '@bg/shared/lite'; + +const COLOR_KO = { black: '흑', white: '백' } as const; + +export function helpNow(v: OthelloView, _me: string | null, players: Record): string { + const score = `지금 돌 수는 흑 ${v.discs.black}개, 백 ${v.discs.white}개예요.`; + if (v.phase === 'finished') return `게임이 끝났어요. ${v.result?.summary ?? ''}`.trim() + '\n결과 창에서 [한 판 더]를 누르면 다시 할 수 있어요.'; + const turnNick = players[v.players[v.turn]]?.nickname ?? COLOR_KO[v.turn]; + if (!v.myColor) return `${turnNick}님(${COLOR_KO[v.turn]}) 차례예요. 상대 돌을 내 돌 사이에 끼우면 내 색으로 뒤집혀요.\n${score}`; + const mine = COLOR_KO[v.myColor]; + if (v.undo.pending && v.undo.pending.by !== v.myColor) return '상대가 무르기를 부탁했어요. [수락]을 누르면 상대가 마지막에 둔 수 전으로 돌아가고, [거절]을 누르면 그대로 계속해요.'; + if (v.draw.pendingBy && v.draw.pendingBy !== v.myColor) return '상대가 무승부를 제안했어요. [수락]하면 이 판은 비기고 끝나요. 계속하려면 [거절]을 눌러 주세요.'; + const last = v.moves[v.moves.length - 1]; + if (v.myColor !== v.turn) { + const waiting = v.undo.pending ? '\n내 무르기 요청에 상대가 답하기를 기다리는 중이에요. 그만두려면 [요청 취소]를 눌러요.' : v.draw.pendingBy ? '\n내 무승부 제안에 상대가 답하기를 기다리는 중이에요. 그만두려면 [요청 취소]를 눌러요.' : ''; + const passed = last?.kind === 'pass' && last.color === v.myColor ? '\n나는 둘 곳이 없어서 차례가 자동으로 넘어갔어요.' : ''; + return `상대(${turnNick}님) 차례예요. 상대가 둘 때까지 기다려 주세요. 나는 ${mine}이에요.${passed}${waiting}\n${score}`; + } + const lines: string[] = []; + if (last?.kind === 'pass' && last.color !== v.myColor) lines.push('상대가 둘 곳이 없어서 내 차례가 한 번 더 왔어요.'); + const n = v.legalMoves?.length ?? 0; + if (v.showLegalMoves) lines.push(`내 차례예요(${mine}). 작은 점이 있는 칸(${n}곳) 중 하나를 눌러 돌을 놓아요.${v.showFlipCount ? ' 점 안의 숫자는 뒤집히는 돌 개수예요.' : ''}`); + else lines.push(`내 차례예요(${mine}). 상대 돌을 내 돌 사이에 끼울 수 있는 빈칸을 눌러 돌을 놓아요.`); + lines.push('끼인 상대 돌은 모두 내 색으로 뒤집혀요. 하나도 못 뒤집는 칸에는 둘 수 없어요.'); + lines.push(score); + return lines.join('\n'); +} diff --git a/apps/web/src/games/othello/index.tsx b/apps/web/src/games/othello/index.tsx index a6937d7..50f91f7 100644 --- a/apps/web/src/games/othello/index.tsx +++ b/apps/web/src/games/othello/index.tsx @@ -7,6 +7,8 @@ import { TurnTimer, useServerNow } from '../../components/TurnTimer'; import { haptic, sounds } from '../../lib/feedback'; import { toast } from '../../store/toast'; import type { BoardProps, GameUI, OptionsProps } from '../types'; +import { helpNow } from './help'; +import { quickStart } from './quick'; type Color = 'black' | 'white'; interface Pt { @@ -613,6 +615,8 @@ const ui: GameUI = { Options, optionsSummary, Rules, + quickStart, + helpNow, }; export default ui; diff --git a/apps/web/src/games/othello/quick.tsx b/apps/web/src/games/othello/quick.tsx new file mode 100644 index 0000000..d28baf0 --- /dev/null +++ b/apps/web/src/games/othello/quick.tsx @@ -0,0 +1,62 @@ +/** 리버시(오셀로) 30초 규칙 요약 (docs/07 §5). */ +import type { QuickCard } from '../types'; + +function Disc({ c, x, y }: { c: 'B' | 'W'; x: number; y: number }) { + return ; +} + +/** Small green board; `rows` are strings of 'B' | 'W' | '.'; `dots` mark playable cells, `ring` a highlighted cell. */ +function MiniBoard({ rows, dots = [], ring, size = 150 }: { rows: string[]; dots?: [number, number][]; ring?: [number, number]; size?: number }) { + const h = rows.length; + const w = rows[0]!.length; + return ( + + + {rows.map((r, y) => + r.split('').map((c, x) => ( + + + {c !== '.' && } + + )), + )} + {dots.map(([x, y]) => ( + + ))} + {ring && } + + ); +} + +const Arrow = () => ( + + → + +); + +export const quickStart: QuickCard[] = [ + { + pic: ( + <> + + + + + ), + alt: '흑 돌 사이에 백 돌 두 개를 끼우자 백 돌이 흑으로 뒤집혀요', + title: '끼우면 뒤집혀요', + text: '상대 돌을 내 돌 두 개 사이에 끼우면 끼인 돌이 모두 내 색으로 바뀌어요. 가로·세로·대각선 다 돼요.', + }, + { + pic: , + alt: '가운데 돌 네 개 둘레에 둘 수 있는 칸이 작은 점으로 표시돼 있어요', + title: '뒤집을 수 있는 곳에만', + text: '상대 돌을 하나라도 뒤집는 칸에만 둘 수 있어요. 둘 곳이 없으면 차례가 저절로 넘어가요.', + }, + { + pic: , + alt: '판이 흑 돌로 거의 가득하고 왼쪽 위 모서리에 노란 테두리가 있어요', + title: '돌이 많으면 이겨요', + text: '둘 다 둘 곳이 없으면 끝나고, 내 색 돌이 더 많으면 이겨요. 네 모서리는 다시 뒤집히지 않아 아주 좋아요.', + }, +]; diff --git a/apps/web/src/games/poker/help.test.ts b/apps/web/src/games/poker/help.test.ts new file mode 100644 index 0000000..aac1b74 --- /dev/null +++ b/apps/web/src/games/poker/help.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, test } from 'bun:test'; +import { GAMES } from '@bg/games'; +import { applyAction, assertCleanText, eachView, seatIds, setupGame, usersOf } from '../help-kit.test-util'; +import { helpNow } from './help'; + +const users = usersOf(['A', 'B', 'C']); +const def = GAMES.poker!; +const view = (s: unknown, who: string | null) => def.view(s, who) as any; +const help = (s: unknown, who: string | null) => helpNow(view(s, who), who, users); +const nickOf = (id: string) => `${users[id]!.nickname}님`; + +/** Plays the hand forward (checks/calls, first choice, pass on draw, high on declare) until `stop` is true. */ +function drive(state: any, stop: (v: any) => boolean, limit = 60): any { + let s = state; + for (let i = 0; i < limit; i++) { + if (stop(view(s, null))) return s; + const actor = def.activePlayers(s)[0]!; + const legal = (def.legalActions!(s, actor) as any[]) ?? []; + const pick = + legal.find((a) => /check|call/.test(a.type)) ?? + legal.find((a) => a.type === 'draw' && a.cardIds.length === 0) ?? + legal.find((a) => a.type === 'declare' && a.side === 'high') ?? + legal[0]; + s = applyAction('poker', s, actor, pick); + } + throw new Error('poker: target situation not reached'); +} + +test('7포커: 초이스(동시에 고르기) → 4구 베팅 내 차례 / 상대 차례 / 관전', () => { + const { state } = setupGame('poker', 3, { mode: 'sevenPoker' }); + expect(view(state, null).handPhase).toBe('choice'); + const a = help(state, 'A'); + expect(a).toContain('① 버릴 카드 1장'); + expect(a).toContain('[확인]'); + expect(a).toContain('남는 2장은 나만 봐요'); + expect(a).toContain('지금 내 족보:'); + expect(help(state, null)).toContain('모두 동시에 받은 4장 중'); + + const choice = view(state, 'A').my.choice as string[]; + let s = applyAction('poker', state, 'A', { type: 'choose', discardId: choice[0], openId: choice[1] }); + expect(help(s, 'A')).toContain('골랐어요'); + expect(help(s, null)).toContain('아직 고르는 사람:'); + + s = drive(s, (v) => v.handPhase === 'betting'); + const v = view(s, null); + const turn: string = v.toAct; + const other = ['A', 'B', 'C'].find((id) => id !== turn)!; + const mine = help(s, turn); + expect(mine).toContain('내 차례예요. 지금은 4구 베팅이에요.'); + expect(mine).toContain('[다이] [체크] [삥]'); + expect(mine).toContain('[하프] 모인 칩의 절반만큼 더'); + expect(mine).toContain('내가 보스(먼저 베팅)'); + expect(mine).toContain('지금 내 족보:'); + const theirs = help(s, other); + expect(theirs).toContain(`${nickOf(turn)} 차례예요(4구 베팅). 기다려 주세요.`); + expect(theirs).toContain(`보스(공개 카드가 가장 좋은 사람, 먼저 베팅)는 ${nickOf(turn)}이에요.`); + const spec = help(s, null); + expect(spec).toContain('7포커 1/20판이에요.'); + expect(spec).toContain('지금 모인 칩은 30 칩이에요.'); + expect(spec).not.toContain('지금 내 족보'); + + // Someone pinged: the next player must call or die, and the earlier checker may not raise again. + s = applyAction('poker', s, turn, { type: 'k.ping' }); + const next: string = view(s, null).toAct; + expect(help(s, next)).toContain('앞사람에게 맞추려면 10 칩이 필요해요([콜])'); + expect(help(s, next)).toContain('[따당] 앞사람이 건 만큼의 두 배'); +}); + +test('텍사스 홀덤: 노리밋 버튼과 공용 카드, 모두 포기하면 판 결과', () => { + const { state } = setupGame('poker', 2, { mode: 'holdem' }); + const v = view(state, null); + const turn: string = v.toAct; + const other = turn === 'A' ? 'B' : 'A'; + const mine = help(state, turn); + expect(mine).toContain('내 차례예요. 지금은 프리플랍 베팅이에요. 누를 수 있는 버튼: [폴드] [콜] [레이즈] [올인]'); + expect(mine).toContain('[레이즈]를 누르면 올릴 칩(이번 라운드 총 20 칩~1,000 칩)을 고르고 [확인]'); + expect(mine).toContain('공용 카드는 아직 없어요'); + expect(mine).toContain('내 카드 2장과 공용 카드를 합쳐'); + expect(help(state, other)).toContain(`${nickOf(turn)} 차례예요(프리플랍 베팅). 기다려 주세요.`); + expect(help(state, null)).toContain('각자 카드 2장과 공용 카드를 합쳐'); + + const flop = drive(state, (x) => x.handPhase === 'betting' && x.board.length === 3); + expect(help(flop, null)).toContain('가운데 공용 카드 3장이 펼쳐져 있어요'); + + const s = applyAction('poker', state, turn, { type: 'nl.fold' }); + expect(view(s, null).handPhase).toBe('handEnd'); + const w = help(s, other); + expect(w).toContain('다른 사람이 모두 포기해서 내가 모인 칩을 가져갔어요'); + expect(w).toContain('[다음 판으로]'); + expect(help(s, null)).toContain(`${nickOf(other)}이 모인 칩을 가져갔어요`); +}); + +test('바둑이 / 5카드 드로우: 교환 단계', () => { + const b = drive(setupGame('poker', 2, { mode: 'badugi' }).state, (v) => v.handPhase === 'draw'); + const mine = help(b, 'A'); + expect(mine).toContain('아침 교환: 바꿀 카드를 눌러 위로 올린 뒤'); + expect(mine).toContain('[패스(안 바꾸기)]'); + expect(mine).toContain('4장까지 바꿀 수 있어요'); + expect(help(b, null)).toContain('모두 동시에 바꿀 카드를 고르는 중이에요(아침 교환)'); + const b2 = applyAction('poker', b, 'A', { type: 'draw', cardIds: [] }); + expect(help(b2, 'A')).toContain('바꿀 카드를 냈어요'); + + const f = drive(setupGame('poker', 2, { mode: 'fiveDraw', drawLimit: 'three' }).state, (v) => v.handPhase === 'draw'); + expect(help(f, 'B')).toContain('교환: 바꿀 카드를'); + expect(help(f, 'B')).toContain('3장까지 바꿀 수 있어요(A를 남기면 4장)'); + const after = drive(f, (v) => v.handPhase === 'betting'); + expect(help(after, null)).toContain('카드를 바꾼 뒤 마지막 베팅이에요'); +}); + +test('7포커 하이로우: 선언 단계', () => { + const s = drive(setupGame('poker', 2, { mode: 'sevenHiLo' }).state, (v) => v.handPhase === 'declare'); + const mine = help(s, 'A'); + expect(mine).toContain('[하이]·[로우]·[스윙] 중 하나를 누르세요'); + expect(mine).toContain('하이 1등과 로우 1등이 칩을 반씩'); + expect(mine).toContain('지금 내 족보:'); + expect(mine).not.toContain('로우 로우'); + const s2 = applyAction('poker', s, 'A', { type: 'declare', side: 'low' }); + expect(help(s2, 'A')).toContain('로우(으)로 선언했어요'); + expect(help(s2, null)).toContain('하이·로우·스윙 중 하나를 몰래 고르는 중'); +}); + +test('인디언 포커: 내 이마 카드는 모른다고 안내하고, 관전자에게도 숨김', () => { + const { state } = setupGame('poker', 3, { mode: 'indian' }); + const turn: string = view(state, null).toAct; + const mine = help(state, turn); + expect(mine).toContain('내 이마 카드는 나만 못 봐요'); + expect(mine).not.toContain('지금 내 족보'); + expect(help(state, null)).toContain('구경할 때는 이마 카드가 판이 끝나야 보여요'); +}); + +test('리바이와 게임 끝', () => { + const { state } = setupGame('poker', 2, { mode: 'sevenPoker', rebuy: 'limited', rebuyLimit: 2 }); + const r = structuredClone(state); + r.phase = 'rebuy'; + r.rebuyPending = ['A']; + r.seats.find((x: any) => x.id === 'A').stack = 0; + r.seats.find((x: any) => x.id === 'A').status = 'busted'; + const a = help(r, 'A'); + expect(a).toContain('[칩 다시 받기]'); + expect(a).toContain('(남은 횟수 2번)'); + expect(a).toContain('[그만하기]'); + expect(help(r, 'B')).toContain('호랑이님이 칩을 다시 받을지 고르는 중이에요'); + + const f = structuredClone(state); + f.phase = 'finished'; + f.result = { ranking: [['A'], ['B']], scores: { A: 10, B: -10 }, summary: '호랑이님 1위', abandoned: false }; + expect(help(f, 'B')).toContain('게임이 끝났어요. 호랑이님 1위'); + expect(help(f, null)).toContain('[한 판 더]'); +}); + +describe('모든 모드의 무작위 한 판: 깨끗한 안내문', () => { + const modes: [string, Record][] = [ + ['holdem', { mode: 'holdem' }], + ['omaha', { mode: 'omaha' }], + ['sevenPoker', { mode: 'sevenPoker' }], + ['sevenHiLo', { mode: 'sevenHiLo', hiLoLow: 'aceToFive', hiLoQualifier: 'eight' }], + ['badugi', { mode: 'badugi' }], + ['fiveDraw', { mode: 'fiveDraw', drawLimit: 'three' }], + ['indian 1', { mode: 'indian' }], + ['indian 2', { mode: 'indian', indianCards: 2 }], + ['korean holdem, hints off', { mode: 'holdem', betting: 'korean', hints: false, koreanMaxRaise: 'full' }], + ['sevenPoker noLimit', { mode: 'sevenPoker', betting: 'noLimit', startingChips: 500, rebuy: 'limited' }], + ]; + test.each(modes)('%s', (_name, options) => { + for (const n of [2, 4]) { + const u = usersOf(seatIds(n)); + const phases = new Set(); + eachView( + 'poker', + n, + (v, who) => { + phases.add(`${v.phase}/${v.handPhase}`); + assertCleanText(helpNow(v, who, u), `poker ${JSON.stringify(options)} n=${n} viewer=${who} ${v.phase}/${v.handPhase}`); + }, + { options, steps: 600 }, + ); + expect(phases.has('playing/handEnd')).toBe(true); + } + }); +}); diff --git a/apps/web/src/games/poker/help.ts b/apps/web/src/games/poker/help.ts new file mode 100644 index 0000000..10d0d51 --- /dev/null +++ b/apps/web/src/games/poker/help.ts @@ -0,0 +1,252 @@ +/** 포커 [?] "지금 할 수 있는 것" (docs/07 §5). Pure: only reads the client view. Chips are play points only. */ +import type { BetActionType, LegalBetView, PokerMode, PokerView, SeatView } from '@bg/games/poker'; +import type { PublicUser } from '@bg/shared/lite'; + +/** Button labels exactly as the betting bar renders them (index.tsx BET_KO). */ +const BET_KO: Record = { + 'k.check': '체크', + 'k.ping': '삥', + 'k.call': '콜', + 'k.ddadang': '따당', + 'k.quarter': '쿼터', + 'k.half': '하프', + 'k.full': '풀', + 'k.die': '다이', + 'nl.fold': '폴드', + 'nl.check': '체크', + 'nl.call': '콜', + 'nl.raise': '레이즈', + 'nl.allIn': '올인', +}; +/** Button order of the betting bar. */ +const ORDER: BetActionType[] = ['k.die', 'k.check', 'k.ping', 'k.call', 'k.ddadang', 'k.quarter', 'k.half', 'k.full', 'nl.fold', 'nl.check', 'nl.call', 'nl.raise', 'nl.allIn']; +const KOREAN_RAISE: Partial> = { + 'k.ping': '기본 칩만큼', + 'k.ddadang': '앞사람이 건 만큼의 두 배', + 'k.quarter': '모인 칩의 4분의 1만큼 더', + 'k.half': '모인 칩의 절반만큼 더', + 'k.full': '모인 칩만큼 더', +}; +const MODE_KO: Record = { + holdem: '텍사스 홀덤', + omaha: '오마하', + sevenPoker: '7포커', + sevenHiLo: '7포커 하이로우', + badugi: '바둑이', + fiveDraw: '5카드 드로우', + indian: '인디언 포커', +}; +const SIDE_KO = { high: '하이', low: '로우', swing: '스윙' } as const; +const DRAW_NAMES = ['아침', '점심', '저녁']; + +const chips = (n: number) => `${Math.max(0, Math.round(n)).toLocaleString('ko-KR')} 칩`; +const nickOf = (players: Record, id: string | null | undefined) => { + const n = id ? players[id]?.nickname : undefined; + return n ? `${n}님` : '다른 사람'; +}; +const namesOf = (players: Record, ids: string[]) => ids.map((id) => nickOf(players, id)).join(', '); +const btn = (t: BetActionType) => `[${BET_KO[t]}]`; + +/** "지금 내 족보" from the hints option (the view sends null when hints are off or the mode has none). */ +function handLine(v: PokerView): string | null { + const { handName, lowName } = v.my; + if (!handName) return null; + const low = lowName ? (lowName === '로우 없음' ? ' · 로우 없음' : ` · 로우 ${lowName}`) : ''; + return `지금 내 족보: ${handName}${low}`; +} + +/** What this betting round is about, per mode. `mine` = written for a seated player in the hand. */ +function modeLine(v: PokerView, mine: boolean, players: Record, me: string | null): string { + const n = v.board.length; + switch (v.mode) { + case 'holdem': + case 'omaha': { + const board = n === 0 ? '공용 카드는 아직 없어요(SB·BB 표시는 미리 칩을 낸 블라인드예요).' : `가운데 공용 카드 ${n}장이 펼쳐져 있어요${n === 5 ? '(다 나왔어요)' : ''}.`; + const make = + v.mode === 'holdem' + ? `${mine ? '내' : '각자'} 카드 2장과 공용 카드를 합쳐 가장 좋은 5장을 만들어요.` + : `${mine ? '내' : '각자'} 카드 4장 중 꼭 2장 + 공용 카드 3장으로 5장을 만들어요.`; + return `${board} ${make}`; + } + case 'sevenPoker': + case 'sevenHiLo': { + const boss = v.seats.find((s) => s.boss); + const parts: string[] = []; + if (boss) parts.push(boss.id === me ? '공개 카드가 가장 좋아서 내가 보스(먼저 베팅)예요.' : `보스(공개 카드가 가장 좋은 사람, 먼저 베팅)는 ${nickOf(players, boss.id)}이에요.`); + if (v.street === 3) parts.push(`마지막 7번째 카드는 ${mine ? '나만' : '본인만'} 보는 히든 카드예요.`); + if (v.mode === 'sevenHiLo' && v.street === 3) parts.push('이 베팅이 끝나면 하이·로우·스윙을 선언해요.'); + return parts.join(' '); + } + case 'badugi': + return v.street < 3 + ? `이 베팅이 끝나면 ${DRAW_NAMES[v.street] ?? ''} 교환(카드 바꾸기)이 있어요. 숫자도 무늬도 겹치지 않는 낮은 카드일수록 좋아요.` + : '마지막 베팅이에요. 끝나면 패를 비교해요(낮을수록 좋아요).'; + case 'fiveDraw': + return v.street === 0 ? '이 베팅이 끝나면 카드를 한 번 바꿀 수 있어요.' : '카드를 바꾼 뒤 마지막 베팅이에요.'; + case 'indian': { + const hand = v.options.indianCards === 2 && v.street === 1 ? ` 손 카드는 ${mine ? '나만' : '본인만'} 보고, 이마 + 손 2장으로 겨뤄요.` : ''; + return mine + ? `내 이마 카드는 나만 못 봐요. 다른 사람 이마 카드와 베팅을 보고 내 카드를 짐작해요.${hand}` + : `구경할 때는 이마 카드가 판이 끝나야 보여요.${hand}`; + } + } +} + +const roundName = (v: PokerView) => { + const s = v.streetName || '베팅'; + return s.endsWith('베팅') ? s : `${s} 베팅`; +}; + +function myBetting(v: PokerView, players: Record, me: string): string[] { + const legal = new Map(v.my.legal.map((l) => [l.type, l])); + const usable = ORDER.filter((t) => legal.has(t)); + const lines = [`내 차례예요. 지금은 ${roundName(v)}이에요. 누를 수 있는 버튼: ${usable.map(btn).join(' ')}`]; + const call = legal.get('k.call') ?? legal.get('nl.call'); + const check = legal.has('k.check') || legal.has('nl.check'); + const fold = v.betStyle === 'korean' ? '[다이]' : '[폴드]'; + if (call?.allIn) lines.push(`가진 칩이 모자라서 [콜]하면 올인이에요. 이번 판을 포기하려면 ${fold}.`); + else if (call) lines.push(`앞사람에게 맞추려면 ${chips(v.my.toCall)}이 필요해요([콜]). 이번 판을 포기하려면 ${fold}.`); + else if (check) lines.push('칩을 더 내지 않고 넘기려면 [체크]를 누르세요.'); + if (v.betStyle === 'korean') { + const raises = usable.filter((t) => KOREAN_RAISE[t]); + if (raises.length) lines.push(`올리기: ${raises.map((t) => `${btn(t)} ${KOREAN_RAISE[t]}`).join(' · ')}. 버튼 아래 숫자만큼 칩이 바로 나가요.`); + else if (!call?.allIn) lines.push('한국식 베팅은 한 라운드에 한 번만 올릴 수 있고, 체크나 콜을 한 뒤에는 못 올려요.'); + } else { + const raise = legal.get('nl.raise'); + if (raise && raise.minTo !== undefined && raise.maxTo !== undefined) { + lines.push(`[레이즈]를 누르면 올릴 칩(이번 라운드 총 ${chips(raise.minTo)}~${chips(raise.maxTo)})을 고르고 [확인]을 눌러요.`); + } + if (legal.has('nl.allIn')) lines.push('[올인]은 가진 칩을 모두 걸어요.'); + } + const hand = handLine(v); + if (hand) lines.push(hand); + const mode = modeLine(v, true, players, me); + if (mode) lines.push(mode); + return lines; +} + +function bettingWatch(v: PokerView, players: Record, me: string | null, mine: boolean): string[] { + const who = v.toAct ? `${nickOf(players, v.toAct)} 차례예요(${roundName(v)}).` : `${roundName(v)}이에요.`; + const lines = [mine ? `${who} 기다려 주세요.` : `${who} 지금 모인 칩은 ${chips(v.pot)}이에요.`]; + if (mine) { + const hand = handLine(v); + if (hand) lines.push(hand); + } + const mode = modeLine(v, mine, players, me); + if (mode) lines.push(mode); + return lines; +} + +/** Neutral description of the current step (spectators, players out of this hand). */ +function watchText(v: PokerView, players: Record, me: string | null): string { + const waiting = v.waiting.length ? ` 아직 고르는 사람: ${namesOf(players, v.waiting)}.` : ''; + switch (v.handPhase) { + case 'betting': + return bettingWatch(v, players, me, false).join('\n'); + case 'choice': + return `모두 동시에 받은 4장 중 버릴 카드 1장과 모두에게 보여 줄 카드 1장을 고르는 중이에요. 다 고르면 보여 줄 카드가 한꺼번에 공개돼요.${waiting}`; + case 'draw': + return `모두 동시에 바꿀 카드를 고르는 중이에요(${drawName(v)}). 버린 카드는 공개되지 않고, 바꾼 장수만 각 자리에 보여요.${waiting}`; + case 'declare': + return `남은 사람들이 하이·로우·스윙 중 하나를 몰래 고르는 중이에요. 모두 고르면 한꺼번에 공개돼요.${waiting}`; + default: + return '카드를 나눠 주는 중이에요. 잠시만 기다려 주세요.'; + } +} + +const drawName = (v: PokerView) => (v.mode === 'badugi' ? `${DRAW_NAMES[v.street - 1] ?? ''} 교환`.trim() : '교환'); + +function handEndText(v: PokerView, players: Record, me: string | null, seat: SeatView | undefined): string { + const lines: string[] = []; + const sdName = (id: string) => (v.mode === 'sevenHiLo' ? undefined : v.showdown?.find((e) => e.id === id)?.handName); + const who = (id: string) => (id === me ? '내가' : `${nickOf(players, id)}이`); + if (v.foldWin && v.winners.length === 1) { + lines.push(`다른 사람이 모두 포기해서 ${who(v.winners[0]!)} 모인 칩을 가져갔어요. 이럴 때는 패를 공개하지 않아요.`); + } else if (v.winners.length === 1) { + const name = sdName(v.winners[0]!); + lines.push(`이번 판은 ${who(v.winners[0]!)} ${name ? `${name}(으)로 ` : ''}이겼어요.`); + } else if (v.winners.length > 1) { + lines.push(`이번 판에 칩을 가져간 사람: ${v.winners.map((id) => (id === me ? '나' : nickOf(players, id))).join(', ')}.`); + } else if (v.carryOver > 0) { + lines.push(`동점이라 모인 칩 ${chips(v.carryOver)}이 다음 판으로 넘어가요.`); + } else { + lines.push('이번 판이 끝났어요. 건 칩은 돌려받았어요.'); + } + lines.push('누가 칩을 얼마나 가져갔는지는 [이번 판 결과]에 나와요.'); + if (v.mode === 'indian') lines.push(`판이 끝나서 ${seat?.inHand ? '내 이마 카드를 포함해 ' : ''}모든 카드가 공개됐어요.`); + const last = v.options.endCondition === 'hands' && v.handNo >= v.options.handLimit; + if (v.my.canReady) lines.push(`[다음 판으로]를 누르면 준비 완료예요. 모두 누르거나 시간이 지나면 ${last ? '최종 순위가 나와요' : '다음 판이 시작돼요'}.`); + else if (seat?.inHand && seat.ready) lines.push('준비했어요. 다른 사람을 기다리는 중이에요.'); + else if (last) lines.push('마지막 판이었어요. 곧 최종 순위가 나와요.'); + return lines.join('\n'); +} + +export function helpNow(v: PokerView, me: string | null, players: Record): string { + if (v.phase === 'finished') { + return `게임이 끝났어요. ${v.result?.summary ?? ''}`.trim() + '\n[최종 순위]에서 순위를 볼 수 있고, 결과 창에서 [한 판 더]를 누르면 다시 할 수 있어요.'; + } + const seat = me ? v.seats.find((s) => s.id === me) : undefined; + const modeName = `${MODE_KO[v.mode]} ${Math.max(1, v.handNo)}${v.options.endCondition === 'hands' ? `/${v.options.handLimit}` : ''}판`; + + if (v.phase === 'rebuy') { + if (v.my.canRebuy) { + const left = v.my.rebuysLeft !== null ? ` (남은 횟수 ${v.my.rebuysLeft}번)` : ''; + return `칩을 모두 잃었어요. [칩 다시 받기]를 누르면 놀이용 칩 ${chips(v.options.startingChips)}을 다시 받고 계속해요${left}. 다시 받은 만큼 최종 점수에서 빠져요.\n[그만하기]를 누르면 탈락하고 구경만 해요.`; + } + const pending = v.seats.filter((s) => s.rebuyPending).map((s) => s.id); + return `${pending.length ? namesOf(players, pending) : '칩이 떨어진 사람'}이 칩을 다시 받을지 고르는 중이에요. 잠시 기다려 주세요.`; + } + + if (v.handPhase === 'handEnd') return handEndText(v, players, me, seat); + const away = seat?.away ? "\n시간을 두 번 넘겨 '자리 비움'이 됐어요. 직접 버튼을 누르면 풀려요." : ''; + + if (!seat) return `${modeName}이에요. ${watchText(v, players, me)}`; + if (!seat.inHand) { + const why = seat.status === 'eliminated' ? '탈락해서 이제 구경만 해요.' : seat.status === 'left' ? '방을 나가서 이번 판에는 빠졌어요.' : '이번 판에는 참가하지 않아요. 다음 판을 기다려 주세요.'; + return `${why}\n${watchText(v, players, me)}`; + } + if (seat.folded) { + return `이번 판은 포기했어요(${v.betStyle === 'korean' ? '다이' : '폴드'}). 다음 판을 기다려 주세요. 내 카드는 나만 볼 수 있어요.\n${watchText(v, players, me)}${away}`; + } + + const m = v.my; + const hand = handLine(v); + const withHand = (lines: string[]) => (hand ? [...lines, hand] : lines).join('\n') + away; + switch (v.handPhase) { + case 'betting': { + if (me && v.toAct === me) return myBetting(v, players, me).join('\n') + away; + const lines = bettingWatch(v, players, me, true); + if (seat.allIn) lines.splice(1, 0, '나는 올인해서 이번 판에는 더 걸 수 없어요. 카드는 끝까지 받고, 건 만큼까지 겨뤄요.'); + return lines.join('\n') + away; + } + case 'choice': + if (m.canChoose) { + return withHand([ + '7포커 초이스: 받은 4장 중 ① 버릴 카드 1장을 먼저 누르고(✕ 표시) ② 모두에게 보여 줄 카드 1장을 누른 뒤 [확인]을 눌러요. 잘못 골랐으면 [다시 고르기].', + '버린 카드는 아무도 못 보고, 남는 2장은 나만 봐요. 모두 동시에 골라요.', + ]); + } + return withHand(['골랐어요. 모두 고르면 보여 줄 카드가 한꺼번에 공개돼요. 기다려 주세요.']); + case 'draw': + if (m.canDraw) { + const limit = m.drawAceBonus ? '3장까지 바꿀 수 있어요(A를 남기면 4장).' : `${m.drawMax}장까지 바꿀 수 있어요.`; + const tip = v.mode === 'badugi' ? ' 숫자나 무늬가 겹치는 카드, 높은 카드를 바꾸면 좋아요.' : ''; + return withHand([ + `${drawName(v)}: 바꿀 카드를 눌러 위로 올린 뒤 [2장 바꾸기]처럼 장수가 적힌 버튼을 누르세요. 안 바꾸려면 아무것도 고르지 말고 [패스(안 바꾸기)].`, + `${limit} 모두 동시에 내고, 버린 카드는 공개되지 않아요.${tip}`, + ]); + } + return withHand(['바꿀 카드를 냈어요. 모두 내면 새 카드를 받아요. 기다려 주세요.']); + case 'declare': + if (m.canDeclare) { + return withHand([ + '[하이]·[로우]·[스윙] 중 하나를 누르세요. 하이는 높은 족보, 로우는 낮은 패로 겨뤄요. 스윙은 둘 다 이겨야 모두 가져가고, 하나라도 지면 못 받아요.', + '모두 고른 뒤 한꺼번에 공개되고, 하이 1등과 로우 1등이 칩을 반씩 나눠요.', + ]); + } + return withHand([`${m.declare ? `${SIDE_KO[m.declare]}(으)로 선언했어요. ` : ''}다른 사람이 선언할 때까지 기다려 주세요.`]); + default: + return `${watchText(v, players, me)}${away}`; + } +} + diff --git a/apps/web/src/games/poker/index.tsx b/apps/web/src/games/poker/index.tsx index 1a945dc..d89801f 100644 --- a/apps/web/src/games/poker/index.tsx +++ b/apps/web/src/games/poker/index.tsx @@ -8,6 +8,8 @@ import { PlayingCard } from '../../components/cards/PlayingCard'; import { Chips, PlayChipNotice, formatChips } from '../../components/cards/Chips'; import { haptic, sounds } from '../../lib/feedback'; import type { BoardProps, GameUI, OptionsProps } from '../types'; +import { helpNow } from './help'; +import { quickStart } from './quick'; const MODE_KO: Record = { holdem: '텍사스 홀덤', @@ -985,6 +987,8 @@ const ui: GameUI = { Options, optionsSummary, Rules, + quickStart, + helpNow, }; export default ui; diff --git a/apps/web/src/games/poker/quick.tsx b/apps/web/src/games/poker/quick.tsx new file mode 100644 index 0000000..323894d --- /dev/null +++ b/apps/web/src/games/poker/quick.tsx @@ -0,0 +1,79 @@ +/** 포커 30초 규칙 요약 (docs/07 §5). Chips are play points only. */ +import { PlayingCard } from '../../components/cards/PlayingCard'; +import { ChipIcon } from '../../components/cards/Chips'; +import type { QuickCard } from '../types'; + +function Fan({ cards, label }: { cards: string[]; label: string }) { + return ( +
+
+ {cards.map((c, i) => ( + 0 ? { marginLeft: -16 } : undefined} /> + ))} +
+ {label} +
+ ); +} + +function MiniButton({ text, sub, color }: { text: string; sub: string; color: string }) { + return ( + + {text} + {sub} + + ); +} + +function BetPic() { + return ( +
+
+ + 모인 칩 120 +
+
+ + + + +
+
+ ); +} + +function HandPic() { + return ( +
+
+ + + +
+ 지금: 원페어 +
+ ); +} + +export const quickStart: QuickCard[] = [ + { + pic: , + alt: '카드 5장(K 세 장과 7 두 장)이 펼쳐져 있고 아래에 풀하우스라고 적혀 있어요', + title: '족보가 센 사람이 이겨요', + text: '모드마다 카드를 받는 방법은 달라도, 마지막까지 남은 사람끼리 카드를 비교해 족보가 가장 센 사람이 모인 칩을 가져가요.', + }, + { + pic: , + alt: '모인 칩 숫자 아래에 다이, 체크, 콜, 하프 버튼이 나란히 있어요', + title: '차례마다 칩을 걸어요', + text: '내 차례에 [체크]로 넘기거나 [콜]로 앞사람만큼 맞추거나 더 올려요. [다이]·[폴드]를 누르면 이번 판은 포기해요.', + }, + { + pic: , + alt: '퀸 두 장과 7 한 장 아래에 지금 원페어라고 노란 글씨로 적혀 있어요', + title: '내 족보는 "지금:"에', + text: '족보 표시가 켜진 방에서는 내 카드 아래 "지금:"에 족보가 보여요. 순서는 [족보] 칸에 있어요. 모두 포기하면 남은 한 명이 가져가요.', + }, +]; diff --git a/apps/web/src/games/quoridor/help.test.ts b/apps/web/src/games/quoridor/help.test.ts new file mode 100644 index 0000000..842348c --- /dev/null +++ b/apps/web/src/games/quoridor/help.test.ts @@ -0,0 +1,60 @@ +import { expect, test } from 'bun:test'; +import { applyAction, setupGame, usersOf } from '../help-kit.test-util'; +import { GAMES } from '@bg/games'; +import { helpNow } from './help'; + +const users = usersOf(['A', 'B', 'C', 'D']); +const view = (s: unknown, who: string | null) => GAMES.quoridor!.view(s, who) as any; +const seatOf = (s: any, id: string) => s.seats.find((x: any) => x.playerId === id); + +test('벽 쌓기 길찾기: 내 차례 / 상대 차례 / 관전', () => { + // joinOrder: A=남(빨강, 먼저), B=북(파랑). + const { state } = setupGame('quoridor', 2, { seatOrder: 'joinOrder' }); + const mine = helpNow(view(state, 'A'), 'A', users); + expect(mine).toContain('내 차례예요(빨강 말)'); + expect(mine).toContain('[말 움직이기]'); + expect(mine).toContain('[벽 세우기 확인]'); + expect(mine).toContain('남은 벽 10개'); + expect(mine).toContain('내 남은 길 8칸'); + expect(mine).not.toContain('쿼리도'); + expect(helpNow(view(state, 'B'), 'B', users)).toContain('호랑이님(빨강) 차례예요. 기다려 주세요'); + const spec = helpNow(view(state, null), null, users); + expect(spec).toContain('호랑이님(빨강) 차례예요'); + expect(spec).toContain('고양이님(파랑) 8칸'); +}); + +test('벽 쌓기 길찾기: 벽을 다 쓰면 말만, 뛰어넘기 안내', () => { + const { state } = setupGame('quoridor', 2, { seatOrder: 'joinOrder' }); + seatOf(state, 'A').wallsLeft = 0; + seatOf(state, 'A').pos = { c: 4, r: 3 }; + seatOf(state, 'B').pos = { c: 4, r: 4 }; + const t = helpNow(view(state, 'A'), 'A', users); + expect(t).toContain('남은 벽이 없어서 이제 말만'); + expect(t).toContain('뛰어넘는 칸'); +}); + +test('벽 쌓기 길찾기: 무르기 요청과 무승부 제안', () => { + const { state } = setupGame('quoridor', 2, { seatOrder: 'joinOrder' }); + let s = applyAction('quoridor', state, 'A', { type: 'move', to: { c: 4, r: 1 } }); + s = applyAction('quoridor', s, 'A', { type: 'requestUndo' }); + expect(helpNow(view(s, 'B'), 'B', users)).toContain('호랑이님(빨강)이 무르기를 부탁했어요. [수락]'); + expect(helpNow(view(s, 'A'), 'A', users)).toContain('[요청 취소]'); + s = applyAction('quoridor', s, 'B', { type: 'respondUndo', accept: false }); + s = applyAction('quoridor', s, 'B', { type: 'offerDraw' }); + expect(helpNow(view(s, 'A'), 'A', users)).toContain('무승부를 제안했어요'); + s = applyAction('quoridor', s, 'A', { type: 'respondDraw', accept: true }); + expect(helpNow(view(s, 'A'), 'A', users)).toContain('무승부'); +}); + +test('벽 쌓기 길찾기: 4명, 도착하면 끝', () => { + const { state } = setupGame('quoridor', 4, { seatOrder: 'joinOrder' }); + const t = helpNow(view(state, 'A'), 'A', users); + expect(t).toContain('남은 벽 5개'); + // 남(A)을 목표 바로 앞에 두고 한 칸 올라가면 끝. + seatOf(state, 'A').pos = { c: 0, r: 7 }; + const s = applyAction('quoridor', state, 'A', { type: 'move', to: { c: 0, r: 8 } }); + expect(helpNow(view(s, 'A'), 'A', users)).toContain('내가 이겼어요'); + expect(helpNow(view(s, 'C'), 'C', users)).toContain('호랑이님(빨강)이 1등이에요'); + expect(helpNow(view(s, 'C'), 'C', users)).toMatch(/나는 \d등이에요/); + expect(helpNow(view(s, null), null, users)).toContain('게임이 끝났어요'); +}); diff --git a/apps/web/src/games/quoridor/help.ts b/apps/web/src/games/quoridor/help.ts new file mode 100644 index 0000000..f219aaa --- /dev/null +++ b/apps/web/src/games/quoridor/help.ts @@ -0,0 +1,75 @@ +/** 벽 쌓기 길찾기 [?] "지금 할 수 있는 것" (docs/07 §5). Pure: only reads the client view. */ +import type { QuoridorSeatView, QuoridorView, Seat } from '@bg/games/quoridor'; +import type { PublicUser } from '@bg/shared/lite'; + +// Same words as the screen (index.tsx): pawns are told apart by colour. +const COLOR_KO: Record = { S: '빨강', W: '초록', N: '파랑', E: '주황' }; + +export function helpNow(v: QuoridorView, me: string | null, players: Record): string { + const name = (x: QuoridorSeatView) => `${players[x.playerId]?.nickname ?? '다른 사람'}님(${COLOR_KO[x.seat]})`; + const mine = v.seats.find((x) => x.seat === v.mySeat) ?? null; + const turn = v.seats.find((x) => x.seat === v.turn) ?? v.seats[0]!; + const four = v.seats.length === 4; + + if (v.phase === 'finished') { + const r = v.result; + if (!r) return '게임이 끝났어요.'; + if (r.reason === 'drawAgreed') return '게임이 끝났어요. 모두 동의해서 무승부가 됐어요.'; + const firsts = r.ranking.filter((x) => x.rank === 1).map((x) => x.playerId); + const winners = v.seats.filter((x) => firsts.includes(x.playerId)).map(name).join(', '); + const why = r.reason === 'resign' ? ' (기권으로)' : r.reason === 'timeLoss' ? ' (시간패로)' : r.reason === 'stalemate' ? ' (아무도 움직일 수 없어 남은 길로 순위를 정했어요)' : ''; + const myRank = mine ? r.ranking.find((x) => x.playerId === mine.playerId)?.rank : undefined; + const tail = myRank === 1 ? '내가 이겼어요!' : myRank ? (four ? `나는 ${myRank}등이에요.` : '아쉽게 졌어요.') : ''; + return `게임이 끝났어요. ${winners}이 1등이에요${why}. ${tail}`.trim(); + } + + const goal = '내 말을 맨 위 줄(내 색 띠)까지 먼저 보내면 이겨요.'; + const dist = (x: QuoridorSeatView) => (x.distance === null ? '' : `${x.distance}칸`); + + // Out of the game but still watching (4명: 먼저 도착 / 기권·시간패). + if (mine && mine.status !== 'playing') { + const head = mine.status === 'finished' ? '목표에 도착했어요! 남은 사람들의 순위가 정해질 때까지 지켜봐요.' : '게임에서 빠졌어요. 계속 지켜볼 수 있어요.'; + return `${head}\n지금은 ${name(turn)} 차례예요.`; + } + + if (!mine) { + const left = v.seats + .filter((x) => x.status === 'playing' && x.distance !== null) + .map((x) => `${name(x)} ${dist(x)}`) + .join(', '); + return `${name(turn)} 차례예요. 각자 자기 색 띠가 있는 반대쪽 끝줄에 먼저 닿으면 이겨요.${left ? `\n목표까지 남은 길: ${left}` : ''}`; + } + + // Undo / draw requests. + const u = v.undo.pending; + if (u && u.by !== mine.seat && !u.accepted.includes(mine.seat)) { + const by = v.seats.find((x) => x.seat === u.by); + return `${by ? name(by) : '다른 사람'}이 무르기를 부탁했어요. [수락]하면 ${u.plies}개의 행동이 되돌려지고, [거절]하면 그대로 계속해요.${four ? ' 다른 사람도 모두 수락해야 무를 수 있어요.' : ''}`; + } + const d = v.draw.pendingBy; + if (d && d !== mine.seat && !v.draw.accepted.includes(mine.seat)) { + const by = v.seats.find((x) => x.seat === d); + return `${by ? name(by) : '다른 사람'}이 무승부를 제안했어요. [수락]하면 이 판은 무승부로 끝나요. 계속하려면 [거절]을 눌러 주세요.${four ? ' 남은 사람이 모두 수락해야 끝나요.' : ''}`; + } + const extra = + u?.by === mine.seat || d === mine.seat + ? '\n내 요청에 다른 사람이 답하기를 기다리는 중이에요. [요청 취소]로 거둘 수 있어요.' + : u || d + ? '\n다른 사람들의 답을 기다리는 중이에요.' + : ''; + + const others = v.seats.filter((x) => x.seat !== mine.seat && x.status === 'playing' && x.distance !== null); + const race = `내 남은 길 ${dist(mine) || '?'}${others.length ? `, ${others.map((x) => `${name(x)} ${dist(x)}`).join(', ')}` : ''}.`; + + if (v.turn !== mine.seat) { + return `${name(turn)} 차례예요. 기다려 주세요.\n${race} 나는 ${COLOR_KO[mine.seat]} 말이에요.${extra}`; + } + + const lines = [`내 차례예요(${COLOR_KO[mine.seat]} 말). 말을 움직이거나 벽을 하나 세워요. 둘 중 하나만 해요.`]; + const jump = !!mine.pos && (v.legalPawnMoves ?? []).some((c) => Math.abs(c.c - mine.pos!.c) + Math.abs(c.r - mine.pos!.r) !== 1); + lines.push(`🚶 [말 움직이기]: 밝게 표시된 칸을 눌러요.${jump ? ' 바로 옆 말을 뛰어넘는 칸도 밝게 보여요.' : ''} ${goal}`); + if (mine.wallsLeft > 0) lines.push(`🧱 [벽 세우기]: 칸 사이 교차점을 누르고 [↻]로 가로·세로를 바꾼 뒤 [벽 세우기 확인]을 눌러요. 남은 벽 ${mine.wallsLeft}개. 누구의 길도 완전히 막을 수는 없어요.`); + else lines.push('남은 벽이 없어서 이제 말만 움직일 수 있어요.'); + lines.push(`${race}${v.showPath && v.myShortestPath ? ' 점선은 내 가장 짧은 길이에요.' : ''}`); + return lines.join('\n') + extra; +} diff --git a/apps/web/src/games/quoridor/index.tsx b/apps/web/src/games/quoridor/index.tsx index ebd5343..a7f44ea 100644 --- a/apps/web/src/games/quoridor/index.tsx +++ b/apps/web/src/games/quoridor/index.tsx @@ -7,6 +7,8 @@ import { TurnTimer, useServerNow } from '../../components/TurnTimer'; import { haptic, sounds } from '../../lib/feedback'; import { toast } from '../../store/toast'; import type { BoardProps, GameUI, OptionsProps } from '../types'; +import { helpNow } from './help'; +import { quickStart } from './quick'; type O = 'h' | 'v'; interface Pt { @@ -931,6 +933,8 @@ const ui: GameUI = { Options, optionsSummary, Rules, + quickStart, + helpNow, }; export default ui; diff --git a/apps/web/src/games/quoridor/quick.tsx b/apps/web/src/games/quoridor/quick.tsx new file mode 100644 index 0000000..3375ecb --- /dev/null +++ b/apps/web/src/games/quoridor/quick.tsx @@ -0,0 +1,64 @@ +/** 벽 쌓기 길찾기 30초 규칙 요약 (docs/07 §5). A small corner of the board, drawn inline. */ +import type { QuickCard } from '../types'; + +type Pawn = [number, number, string]; +/** Wall in grooves: [x, y, 'h' | 'v'] — 'h' lies under row y spanning columns x..x+1, 'v' right of column x spanning rows y..y+1. */ +type W = [number, number, 'h' | 'v']; + +function MiniBoard({ n = 5, pawns, walls = [], path = [], ghost = [], band = '#dc2626', bad = false }: { n?: number; pawns: Pawn[]; walls?: W[]; path?: [number, number][]; ghost?: W[]; band?: string; bad?: boolean }) { + const s = 150 / n; + const g = 5; + const cell = (i: number) => 10 + i * s; + const wallRect = ([x, y, o]: W) => + o === 'h' ? { x: cell(x) + 1, y: cell(y + 1) - g / 2 - 1.5, width: 2 * s - 6, height: g + 3 } : { x: cell(x + 1) - g / 2 - 1.5, y: cell(y) + 1, width: g + 3, height: 2 * s - 6 }; + return ( + + + + {Array.from({ length: n * n }, (_, k) => ( + + ))} + {path.length > 1 && ( + `${cell(x) + (s - g) / 2},${cell(y) + (s - g) / 2}`).join(' ')} fill="none" stroke="#2563eb" strokeWidth={3} strokeDasharray="5 4" strokeLinecap="round" /> + )} + {walls.map((w, k) => ( + + ))} + {ghost.map((w, k) => ( + + ))} + {pawns.map(([x, y, c], k) => ( + + ))} + + ); +} + +export const quickStart: QuickCard[] = [ + { + pic: , + alt: '빨간 말이 점선을 따라 맨 위 빨간 띠 줄로 올라가요', + title: '맨 위 줄까지 가면 이겨요', + text: '내 말을 반대편 끝줄(내 색 띠)까지 먼저 보내면 이겨요. 내 차례에는 말을 한 칸 움직이거나 벽을 하나 세워요.', + }, + { + pic: , + alt: '두 칸 길이의 벽이 빨간 말 앞을 막아서 옆으로 돌아가야 해요', + title: '벽으로 길을 막아요', + text: '벽은 칸 2개 길이로 칸 사이에 세워요. 상대 앞에 세우면 상대가 빙 돌아가야 해요. 세운 벽은 치울 수 없어요.', + }, + { + pic: ( + + ), + alt: '벽 사이 마지막 틈을 막으려는 벽이 빨갛게 보여요. 길을 완전히 막을 수는 없어요', + title: '길을 다 막으면 안 돼요', + text: '누구든 목표까지 갈 길이 하나는 남아 있어야 해요. 세울 수 없는 자리는 빨갛게 보여요. 벽은 아껴 두었다 결정적일 때 써요.', + }, +]; diff --git a/apps/web/src/games/rummikub/help.test.ts b/apps/web/src/games/rummikub/help.test.ts new file mode 100644 index 0000000..b5a8f5f --- /dev/null +++ b/apps/web/src/games/rummikub/help.test.ts @@ -0,0 +1,85 @@ +import { expect, test } from 'bun:test'; +import { applyAction, setupGame, usersOf } from '../help-kit.test-util'; +import { GAMES } from '@bg/games'; +import { helpNow } from './help'; + +const users = usersOf(['A', 'B', 'C']); +const view = (s: unknown, who: string | null) => GAMES.rummikub!.view(s, who) as any; +const nickOf = (id: string) => `${users[id]!.nickname}님`; + +/** Fresh game; the current player gets a known rack (view-only edit, enough for help text). */ +function game(options: Record = {}, rack?: string[]) { + const { state, players } = setupGame('rummikub', 3, options); + const cur: string = state.current; + const other = players.find((p) => p !== cur)!; + if (rack) state.racks[cur] = rack; + return { state, cur, other }; +} + +test('숫자 타일: 내 차례(첫 등록 전) / 상대 차례 / 관전', () => { + const { state, cur, other } = game(); + const mine = helpNow(view(state, cur), cur, users); + expect(mine).toContain('내 차례예요'); + expect(mine).toContain('30점 이상'); + expect(mine).toContain('(지금 0점)'); + expect(mine).toContain('[한 장 가져오기]'); + expect(mine).toContain('[도와줘]'); + const theirs = helpNow(view(state, other), other, users); + expect(theirs).toContain(`${nickOf(cur)} 차례예요. 기다려 주세요.`); + expect(theirs).toContain('나는 아직 첫 등록 전'); + expect(theirs).toContain('내 받침대 14장'); + const spec = helpNow(view(state, null), null, users); + expect(spec).toContain(`${nickOf(cur)} 차례예요(받침대 14장)`); + expect(spec).toContain('첫 등록 전'); + expect(spec).not.toContain('내 받침대'); +}); + +test('숫자 타일: 방 옵션 반영(첫 등록 없음, 힌트 끔)', () => { + const { state, cur } = game({ initialMeldPoints: 0, hints: false }); + const t = helpNow(view(state, cur), cur, users); + expect(t).not.toContain('첫 등록'); + expect(t).not.toContain('[도와줘]'); + expect(t).toContain('바닥 묶음을 쪼개고 합쳐'); +}); + +test('숫자 타일: 틀린 배치 / 올바른 배치 / 조커', () => { + const rack = ['R10a', 'R11a', 'R12a', 'K1a', 'JK1']; + const { state, cur } = game({}, rack); + const bad = applyAction('rummikub', state, cur, { type: 'setDraft', sets: [{ tileIds: ['K1a'] }] }); + const tb = helpNow(view(bad, cur), cur, users); + expect(tb).toContain('아직 확정할 수 없어요'); + expect(tb).toContain('[되돌리기]'); + expect(tb).toContain('3장을 가져와요'); + expect(tb).toContain('조커'); + const good = applyAction('rummikub', state, cur, { type: 'setDraft', sets: [{ tileIds: ['R10a', 'R11a', 'R12a'] }] }); + const tg = helpNow(view(good, cur), cur, users); + expect(tg).toContain('(지금 33점)'); + expect(tg).toContain('[확정]을 눌러야'); + + const p3 = game({ invalidCommitPenalty: 'penalty3', invalidTimeoutPenalty: 1 }, rack); + const bad3 = applyAction('rummikub', p3.state, p3.cur, { type: 'setDraft', sets: [{ tileIds: ['K1a'] }] }); + const t3 = helpNow(view(bad3, p3.cur), p3.cur, users); + expect(t3).toContain('틀린 채로 [확정]하면'); + expect(t3).toContain('1장을 가져와요'); +}); + +test('숫자 타일: 판 끝(다음 판 시작) / 게임 끝', () => { + const rack = ['R10a', 'R11a', 'R12a']; + const m = game({ gamesPerMatch: '2' }, rack); + let s = applyAction('rummikub', m.state, m.cur, { type: 'setDraft', sets: [{ tileIds: rack }] }); + s = applyAction('rummikub', s, m.cur, { type: 'commit' }); + expect(s.phase).toBe('roundEnd'); + const t = helpNow(view(s, m.other), m.other, users); + expect(t).toContain(`${nickOf(m.cur)}이 타일을 모두 내려놓아`); + expect(t).toContain('[다음 판 시작]'); + expect(t).toContain('이번 판 내 점수는 -'); + const s2 = applyAction('rummikub', s, m.other, { type: 'ready' }); + expect(helpNow(view(s2, m.other), m.other, users)).toContain('나는 준비했어요'); + expect(helpNow(view(s2, null), null, users)).toContain('기다리는 중'); + + const one = game({}, rack); + let f = applyAction('rummikub', one.state, one.cur, { type: 'setDraft', sets: [{ tileIds: rack }] }); + f = applyAction('rummikub', f, one.cur, { type: 'commit' }); + expect(f.phase).toBe('finished'); + expect(helpNow(view(f, one.other), one.other, users)).toContain('게임이 끝났어요'); +}); diff --git a/apps/web/src/games/rummikub/help.ts b/apps/web/src/games/rummikub/help.ts new file mode 100644 index 0000000..c6f363c --- /dev/null +++ b/apps/web/src/games/rummikub/help.ts @@ -0,0 +1,103 @@ +/** 숫자 타일 [?] "지금 할 수 있는 것" (docs/07 §5). Pure: only reads the client view. */ +import type { RummikubView } from '@bg/games/rummikub'; +import type { PublicUser } from '@bg/shared/lite'; + +const who = (players: Record, id: string) => { + const n = players[id]?.nickname; + return n ? `${n}님` : '다른 사람'; +}; + +/** First meld rule for someone who has not melded yet (only when the room has a minimum). */ +function meldRule(v: RummikubView, now?: number): string { + const pts = v.options.initialMeldPoints; + const touch = v.options.initialMeldAllowExtend + ? `${pts}점을 채운 뒤에는 같은 차례에 바닥 묶음에도 붙일 수 있어요.` + : '첫 등록하는 차례에는 바닥 묶음을 건드릴 수 없어요.'; + const cur = now === undefined ? '' : `(지금 ${now}점)`; + return `첫 등록(처음 내려놓기)은 받침대 타일만으로 만든 새 묶음의 숫자 합이 ${pts}점 이상이어야 해요${cur}. 조커는 대신하는 숫자로 세요. ${touch}`; +} + +function roundHead(v: RummikubView, players: Record): string { + const r = v.round; + const no = v.match.total > 1 ? `${r?.gameNo ?? v.match.gameNo}판(전체 ${v.match.total}판)이 끝났어요.` : '판이 끝났어요.'; + if (!r) return no; + if (r.endReason === 'rackEmpty') return `${r.winners.map((w) => who(players, w)).join(', ')}이 타일을 모두 내려놓아 ${no}`; + if (r.endReason === 'stalemate') return `더 낼 수 있는 사람이 없어 ${no} 남은 타일 점수가 가장 적은 사람이 이겨요.`; + return no; +} + +export function helpNow(v: RummikubView, me: string | null, players: Record): string { + const seat = me ? v.seats.find((s) => s.id === me) : undefined; + const my = seat ? v.my : null; + + if (v.phase === 'finished') { + const sum = v.result?.summary ?? ''; + return `게임이 끝났어요. ${sum}`.trim() + '\n결과 창에서 [한 판 더]를 누르면 다시 할 수 있어요.'; + } + + if (v.phase === 'roundEnd') { + const lines = [roundHead(v, players)]; + const mine = me && v.round ? v.round.scores[me] : undefined; + if (seat && mine !== undefined) lines.push(`이번 판 내 점수는 ${mine > 0 ? '+' : ''}${mine}점이에요. 아래 표에서 각자 남은 타일을 볼 수 있어요.`); + if (my?.needReady) lines.push('[다음 판 시작]을 눌러 주세요. 모두 누르거나 30초가 지나면 다음 판이 시작돼요.'); + else if (seat && !seat.left) lines.push('나는 준비했어요. 다른 사람이 [다음 판 시작]을 누르기를 기다리는 중이에요.'); + else lines.push('다음 판이 시작되기를 기다리는 중이에요.'); + return lines.join('\n'); + } + + // playing + const cur = v.current; + const curName = cur ? who(players, cur) : '다른 사람'; + const curSeat = cur ? v.seats.find((s) => s.id === cur) : undefined; + const pool = v.poolCount > 0 ? `가져올 수 있는 남은 타일은 ${v.poolCount}장이에요.` : '남은 타일이 다 떨어져서 가져오기 대신 넘기기만 할 수 있어요.'; + + if (!seat || !my) { + const lines = [`${curName} 차례예요${curSeat ? `(받침대 ${curSeat.tiles}장)` : ''}. ${pool}`]; + if (curSeat && !curSeat.melded) lines.push(`${curName}은 아직 첫 등록 전이라 받침대 타일만으로 ${v.options.initialMeldPoints}점 이상을 만들어 내야 해요.`); + if (v.draft) lines.push(`바닥에는 ${curName}이 고민 중인 배치가 보여요. [확정] 전이라 바뀔 수 있어요.`); + else if (v.draftEdits > 0) lines.push(`${curName}이 타일을 옮겨 보며 생각 중이에요.`); + if (curSeat?.away) lines.push(`${curName}은 자리 비움 상태라 차례가 금방 넘어가요.`); + lines.push('같은 숫자 다른 색 3~4장, 또는 같은 색 이어진 숫자 3장 이상으로 묶어 받침대를 먼저 비우면 이겨요.'); + return lines.join('\n'); + } + + if (seat.left) return `나는 방을 나가서 이 판에는 참여하지 않아요. 지금은 ${curName} 차례예요.`; + + const hasJoker = my.rack.some((id) => id === 'JK1' || id === 'JK2'); + const jokerTip = hasJoker + ? `내 받침대에 조커(가운데 J가 있는 4색 동그라미 타일)가 있어요. 조커는 아무 숫자·색이나 대신해요. 끝날 때 받침대에 남으면 ${v.options.jokerPenalty}점 감점이에요.` + : ''; + + if (cur !== me) { + const lines = [`${curName} 차례예요. 기다려 주세요.${v.draft ? ` 바닥에 ${curName}이 고민 중인 배치가 보여요(확정 전).` : v.draftEdits > 0 ? ` ${curName}이 생각 중이에요.` : ''}`]; + lines.push(`내 받침대 ${my.rack.length}장 · ${pool}`); + if (!my.melded) lines.push(`나는 아직 첫 등록 전이에요. ${meldRule(v)}`); + if (seat.away) lines.push('시간 초과가 이어져 자리 비움 상태예요. 내 차례가 3초 만에 넘어가니, 차례가 오면 바로 무엇이든 해 주세요.'); + if (jokerTip) lines.push(jokerTip); + lines.push('기다리는 동안 [색깔순]·[숫자순]으로 받침대를 정리해 보세요.'); + return lines.join('\n'); + } + + // My turn. + const check = my.check; + const drawLabel = v.poolCount > 0 ? '[한 장 가져오기]' : '[넘기기]'; + const lines = [`내 차례예요(받침대 ${my.rack.length}장). 타일을 눌러 고른 뒤, 바닥 묶음의 ◀ 앞·▶ 뒤나 [새 묶음]을 눌러 놓아요.`]; + if (!my.melded) lines.push(meldRule(v, check?.meldPoints ?? 0)); + else lines.push('바닥 묶음을 쪼개고 합쳐 내 타일을 끼워 넣어도 돼요. 바닥 타일은 받침대로 가져올 수 없어요.'); + + if (!my.changed) { + const hint = v.options.hints ? ' [도와줘]를 누르면 받침대 타일로 바로 낼 수 있는 묶음을 보라색으로 찾아 줘요.' : ''; + lines.push(`낼 게 없으면 ${drawLabel}를 눌러요. 그러면 차례가 끝나요.${hint} ${pool}`); + } else if (check?.ok) { + lines.push(`바닥이 모두 올바라요. [확정]을 눌러야 차례가 끝나요.${v.options.timeoutValidDraft === 'revert' ? ' 시간이 다 되면 놓은 타일이 돌아오고 1장을 가져오니 서둘러요.' : ''}`); + } else { + lines.push(`아직 확정할 수 없어요: ${check?.reason ?? '바닥 묶음을 확인해 주세요.'} 고치기 어려우면 [되돌리기](한 단계 전)나 [처음으로](차례 시작 상태)를 눌러요.`); + lines.push( + v.options.invalidCommitPenalty === 'penalty3' + ? `이 방에서는 틀린 채로 [확정]하면 놓은 타일이 돌아오고 3장을 가져와요. 시간이 다 돼도 처음으로 돌아가고 ${v.options.invalidTimeoutPenalty}장을 가져와요.` + : `틀린 채로 시간이 다 되면 처음으로 돌아가고 ${v.options.invalidTimeoutPenalty}장을 가져와요. ${drawLabel}를 누르면 확인 창이 뜨고, 바꾼 배치를 되돌린 뒤 차례를 끝내요.`, + ); + } + if (jokerTip) lines.push(jokerTip); + return lines.join('\n'); +} diff --git a/apps/web/src/games/rummikub/index.tsx b/apps/web/src/games/rummikub/index.tsx index 82e88c9..9f48fff 100644 --- a/apps/web/src/games/rummikub/index.tsx +++ b/apps/web/src/games/rummikub/index.tsx @@ -8,6 +8,8 @@ import { Avatar, ConfirmSheet } from '../../components/ui'; import { TurnTimer } from '../../components/TurnTimer'; import { haptic, sounds } from '../../lib/feedback'; import type { BoardProps, GameUI, OptionsProps } from '../types'; +import { helpNow } from './help'; +import { quickStart } from './quick'; const nick = (players: Record, id: string) => players[id]?.nickname ?? '알 수 없음'; const userOf = (players: Record, id: string): PublicUser => players[id] ?? { id, nickname: '알 수 없음', avatar: null, kind: 'guest' }; @@ -818,6 +820,8 @@ const ui: GameUI = { Options, optionsSummary, Rules, + quickStart, + helpNow, }; export default ui; diff --git a/apps/web/src/games/rummikub/quick.tsx b/apps/web/src/games/rummikub/quick.tsx new file mode 100644 index 0000000..b352643 --- /dev/null +++ b/apps/web/src/games/rummikub/quick.tsx @@ -0,0 +1,101 @@ +/** 숫자 타일 30초 규칙 요약 (docs/07 §5). Tiles are redrawn here as simple SVG (the screen's Tile lives in index.tsx). */ +import type { ReactNode } from 'react'; +import type { QuickCard } from '../types'; + +type C = 'K' | 'R' | 'B' | 'O' | 'J'; +const INK: Record = { K: '#111827', R: '#c62828', B: '#1d4ed8', O: '#a14a00', J: '#111827' }; +const MARK: Record = { K: '●', R: '♥', B: '◆', O: '★', J: '' }; + +/** One tile at (x, y), 28 x 40. `n` 0 = joker. */ +function T({ x, y, c, n, fresh }: { x: number; y: number; c: C; n: number; fresh?: boolean }) { + return ( + + + {c === 'J' ? ( + + + + + + + + J + + + ) : ( + <> + + {n} + + + {MARK[c]} + + + )} + + ); +} + +/** A row of tiles starting at x; returns the tiles for one set (gap 2). */ +const row = (x: number, y: number, tiles: [C, number][], fresh: number[] = []) => + tiles.map(([c, n], i) => ); + +function Felt({ children, h = 150 }: { children: ReactNode; h?: number }) { + return ( + + + {children} + + ); +} + +const label = (x: number, y: number, s: string) => ( + + {s} + +); + +export const quickStart: QuickCard[] = [ + { + pic: ( + + {label(48, 22, '같은 숫자')} + {row(5, 30, [['K', 7], ['R', 7], ['B', 7]])} + {label(185, 22, '이어진 숫자')} + {row(126, 30, [['B', 3], ['B', 4], ['B', 5], ['B', 6]])} + + {row(35, 94, [['O', 2], ['K', 9], ['R', 12], ['J', 0], ['B', 1], ['O', 13]])} + + ), + alt: '바닥에 같은 숫자 7 세 장과 파란 3·4·5·6이 놓여 있고, 아래 받침대에 내 타일이 있어요', + title: '묶음을 만들어 내요', + text: '같은 숫자 다른 색 3~4장, 또는 같은 색 이어진 숫자 3장 이상을 바닥에 내요. 낼 게 없으면 한 장 가져와요.', + }, + { + pic: ( + + {row(80, 22, [['R', 10], ['R', 11], ['R', 12]])} + {label(125, 88, '10 + 11 + 12 = 33점')} + {label(125, 110, '첫 등록 30점 넘었어요!')} + + ), + alt: '빨간 10, 11, 12 타일 세 장, 합이 33점이라 첫 등록 30점을 넘었어요', + title: '첫 등록은 점수가 필요해요', + text: '처음 낼 때는 내 타일만으로 만든 묶음의 숫자 합이 방에서 정한 점수(보통 30점) 이상이어야 해요.', + }, + { + pic: ( + + {row(50, 10, [['R', 4], ['R', 5], ['R', 6], ['R', 7], ['R', 8]])} + + ▼ 내 빨강 6을 넣어 나눠요 + + {row(10, 90, [['R', 4], ['R', 5], ['R', 6]], [2])} + {row(150, 90, [['R', 6], ['R', 7], ['R', 8]])} + + ), + alt: '빨간 4부터 8까지 묶음에 내 빨강 6을 넣어 4·5·6과 6·7·8 두 묶음으로 나눈 모습', + title: '바닥을 고쳐서 끼워요', + text: '첫 등록 뒤에는 바닥 묶음을 쪼개고 합쳐 내 타일을 끼울 수 있어요. 조커는 아무 타일이나 대신해요. 받침대를 먼저 비우면 이겨요.', + }, +]; diff --git a/apps/web/src/games/seotda/help.test.ts b/apps/web/src/games/seotda/help.test.ts new file mode 100644 index 0000000..78087e9 --- /dev/null +++ b/apps/web/src/games/seotda/help.test.ts @@ -0,0 +1,117 @@ +import { expect, test } from 'bun:test'; +import { applyAction, setupGame, usersOf } from '../help-kit.test-util'; +import { GAMES } from '@bg/games'; +import { helpNow } from './help'; + +const users = usersOf(['A', 'B', 'C']); +const view = (s: unknown, who: string | null) => GAMES.seotda!.view(s, who) as any; +const nickOf = (id: string) => users[id]!.nickname; + +test('섯다: 내 차례(버튼·족보) / 상대 차례 / 관전', () => { + const { state } = setupGame('seotda', 3); + const turn = view(state, null).toAct as string; + const other = ['A', 'B', 'C'].find((id) => id !== turn)!; + + const mine = helpNow(view(state, turn), turn, users); + expect(mine).toContain('내 차례예요'); + expect(mine).toContain('첫 베팅'); + expect(mine).toContain('[다이]'); + expect(mine).toContain('[체크]'); + expect(mine).toContain('[삥]'); + expect(mine).not.toContain('[콜]'); + expect(mine).toContain('월 한 장만 받았어요'); + + const waiting = helpNow(view(state, other), other, users); + expect(waiting).toContain(`${nickOf(turn)}님 차례예요(첫 베팅)`); + expect(waiting).toContain('기다려 주세요'); + + const spec = helpNow(view(state, null), null, users); + expect(spec).toContain(`${nickOf(turn)}님 차례예요`); + expect(spec).toContain('모인 칩'); + expect(spec).not.toContain('내 족보'); + expect(spec).not.toContain('월 한 장'); + + // After a 삥 the next player sees [콜] with the amount to match. + const s2 = applyAction('seotda', state, turn, { type: 'ping' }); + const next = view(s2, null).toAct as string; + const t = helpNow(view(s2, next), next, users); + expect(t).toContain('[콜]'); + expect(t).toContain('[따당]'); + expect(t).toContain('[콜]은 그만큼(10 칩) 맞추는'); +}); + +test('섯다: 2장째를 받으면 지금 내 족보와 마지막 베팅, 족보 숨기기 옵션', () => { + let { state } = setupGame('seotda', 2); + for (let i = 0; i < 2; i++) state = applyAction('seotda', state, view(state, null).toAct, { type: 'check' }); + const v = view(state, null); + expect(v.street).toBe(1); + const t = helpNow(view(state, v.toAct), v.toAct, users); + expect(t).toContain('마지막 베팅'); + expect(t).toMatch(/지금 내 족보: .+\(|예요\./); + + const off = setupGame('seotda', 2, { hints: false }).state; + const p = view(off, null).toAct as string; + expect(helpNow(view(off, p), p, users)).toContain("'족보표'를 보고"); +}); + +test('섯다: 3장 섯다 고르기 단계', () => { + let { state } = setupGame('seotda', 3, { cardMode: 'three' }); + while (view(state, null).handPhase === 'betting') state = applyAction('seotda', state, view(state, null).toAct, { type: 'check' }); + expect(view(state, null).handPhase).toBe('choose'); + const t = helpNow(view(state, 'A'), 'A', users); + expect(t).toContain('2장을 눌러 고르면'); + expect(t).toContain('정하기'); + expect(t).toContain('[추천 조합]'); + expect(helpNow(view(state, null), null, users)).toContain('몰래 고르는 중'); + + const v = view(state, 'A'); + const s2 = applyAction('seotda', state, 'A', { type: 'choose', cardIds: v.myCombos[0].cardIds }); + expect(helpNow(view(s2, 'A'), 'A', users)).toContain('골랐어요'); +}); + +test('섯다: 모두 다이 → 판 결과와 [다음 판으로], 다이한 사람', () => { + let { state } = setupGame('seotda', 3); + const first = view(state, null).toAct as string; + state = applyAction('seotda', state, first, { type: 'die' }); + expect(helpNow(view(state, first), first, users)).toContain('이번 판은 다이했어요'); + state = applyAction('seotda', state, view(state, null).toAct, { type: 'die' }); + expect(view(state, null).handPhase).toBe('handEnd'); + const t = helpNow(view(state, 'A'), 'A', users); + expect(t).toContain('모두 다이해서'); + expect(t).toContain('[다음 판으로]'); +}); + +test('섯다: 칩 다시 받기 / 구사 재경기 참가 / 재경기 표시', () => { + const { state } = setupGame('seotda', 3); + const rebuy = structuredClone(state); + rebuy.phase = 'rebuy'; + rebuy.hand = null; + rebuy.rebuyPending = ['B']; + rebuy.seats.find((s: any) => s.id === 'B').status = 'busted'; + rebuy.seats.find((s: any) => s.id === 'B').stack = 0; + const b = helpNow(view(rebuy, 'B'), 'B', users); + expect(b).toContain('[칩 다시 받기]'); + expect(b).toContain('[그만하기]'); + expect(helpNow(view(rebuy, 'A'), 'A', users)).toContain('고양이님이 칩을 다시 받을지'); + + const rejoin = structuredClone(state); + rejoin.hand.phase = 'rejoin'; + rejoin.hand.rejoinPending = ['C']; + rejoin.hand.rejoinCost = 50; + expect(helpNow(view(rejoin, 'C'), 'C', users)).toContain('[50 칩 내고 참가]'); + expect(helpNow(view(rejoin, 'A'), 'A', users)).toContain('다시 들어올지'); + + const re = structuredClone(state); + re.hand.rematchNo = 1; + re.hand.rematchReason = 'gusa'; + expect(helpNow(view(re, null), null, users)).toContain('재경기 1회차(구사)'); +}); + +test('섯다: 게임 끝', () => { + const { state } = setupGame('seotda', 2); + const end = structuredClone(state); + end.phase = 'finished'; + end.hand = null; + end.final = { ranking: [['A'], ['B']], scores: { A: 10, B: -10 }, summary: '20판 끝. 1위 +10칩' }; + expect(helpNow(view(end, 'A'), 'A', users)).toContain('게임이 끝났어요. 20판 끝'); +}); diff --git a/apps/web/src/games/seotda/help.ts b/apps/web/src/games/seotda/help.ts new file mode 100644 index 0000000..19335b8 --- /dev/null +++ b/apps/web/src/games/seotda/help.ts @@ -0,0 +1,160 @@ +/** 섯다 [?] "지금 할 수 있는 것" (docs/07 §5). Pure: only reads the client view (never other players' cards). */ +import type { BetType, SeotdaSeatView, SeotdaView } from '@bg/games/seotda'; +import type { PublicUser } from '@bg/shared/lite'; + +/** Same order and labels as the bet buttons in index.tsx. */ +const BET_ORDER: BetType[] = ['die', 'check', 'ping', 'call', 'ddadang', 'quarter', 'half', 'full']; +const BET_KO: Record = { check: '체크', ping: '삥', call: '콜', ddadang: '따당', quarter: '쿼터', half: '하프', full: '풀', die: '다이' }; +const RAISES: BetType[] = ['ping', 'ddadang', 'quarter', 'half', 'full']; +const STRENGTH_KO = { high: '센 패', mid: '중간 패', low: '약한 패' } as const; +const REMATCH_KO = { gusa: '구사', mungGusa: '멍텅구리구사', tie: '같은 족보(동점)' } as const; +const REASON_KO = { gusa: '구사', mungGusa: '멍텅구리구사', tie: '동점' } as const; + +const chips = (n: number) => `${Math.max(0, Math.round(n)).toLocaleString('ko-KR')} 칩`; + +function who(players: Record, id: string): string { + return `${players[id]?.nickname ?? '다른 사람'}님`; +} + +/** Which betting round this is, in plain words. */ +function roundText(v: SeotdaView): string { + const o = v.options; + if (o.cardMode === 'three') + return v.street === 0 + ? '2장을 받고 하는 첫 베팅이에요. 끝나면 1장을 더 받아 3장 중 쓸 2장을 골라요.' + : '고른 2장으로 하는 마지막 베팅이에요. 끝나면 남은 사람끼리 패를 공개해요.'; + if (o.dealMode === 'oneRound') return '2장을 받고 한 번만 베팅해요. 끝나면 남은 사람끼리 패를 공개해요.'; + return v.street === 0 + ? '1장을 받고 하는 첫 베팅이에요. 끝나면 1장을 더 받아요.' + : '2장째를 받고 하는 마지막 베팅이에요. 끝나면 남은 사람끼리 패를 공개해요.'; +} + +/** My current hand as the view gives it (hints option on). */ +function handLine(v: SeotdaView, seat: SeotdaSeatView): string { + const h = v.myHand; + if (!h) return seat.cards.length > 0 ? "이 방은 족보 이름을 따로 보여 주지 않아요. '족보표'를 보고 내 패를 맞춰 보세요." : ''; + if (h.special) return h.hint; + if (h.value === null) return `지금은 ${h.name}만 받았어요. ${h.hint}`; + return `지금 내 족보: ${h.name}${h.strength ? `(${STRENGTH_KO[h.strength]})` : ''}. 족보표에 '내 패'로 표시돼요.`; +} + +function rematchLine(v: SeotdaView): string { + if (v.rematchNo === 0 || !v.rematchReason) return ''; + return `재경기 ${v.rematchNo}회차(${REMATCH_KO[v.rematchReason]})예요. 모인 칩은 그대로 두고, 학교(기본 칩)는 다시 내지 않아요.`; +} + +function resultLine(v: SeotdaView, players: Record): string { + if (v.foldWin) return `모두 다이해서 ${who(players, v.foldWin.winner)}이 ${chips(v.foldWin.amount)}을 가져갔어요. 이때는 패를 공개하지 않아요.`; + if (!v.showdown) return '이번 판이 끝났어요.'; + return v.showdown.pots + .map((p) => { + const names = p.winners.map((id) => players[id]?.nickname ?? '다른 사람').join('·') + '님'; + const name = v.showdown!.hands.find((h) => h.id === p.winners[0])?.name ?? ''; + if (p.type === 'rematch') return `${REASON_KO[p.reason as keyof typeof REASON_KO] ?? '동점'} 재경기: ${names}이 다시 겨뤄요. 모인 칩은 그대로 남아요.`; + if (p.type === 'split') return `${names}이 ${chips(p.amount)}을 나눠 가졌어요.`; + return `${names}이 ${name ? `${name}(으)로 ` : ''}${chips(p.amount)}을 가져갔어요.`; + }) + .join('\n'); +} + +function waitingLine(v: SeotdaView, players: Record): string { + if (v.handPhase === 'betting') { + const oneRound = v.options.cardMode === 'two' && v.options.dealMode === 'oneRound'; + const round = oneRound ? '베팅' : v.street === 0 ? '첫 베팅' : '마지막 베팅'; + return v.toAct ? `${who(players, v.toAct)} 차례예요(${round}).` : '카드를 나눠 주는 중이에요.'; + } + if (v.handPhase === 'choose') return '모두 3장 중 쓸 2장을 몰래 고르는 중이에요. 다 고르면 마지막 베팅을 해요.'; + if (v.handPhase === 'rejoin') return '구사 재경기 전에, 다이했던 사람이 다시 들어올지 고르는 중이에요.'; + if (v.handPhase === 'handEnd') return resultLine(v, players); + return '잠시만 기다려 주세요.'; +} + +/** Spectators, eliminated or left players: describe the table neutrally (no hidden cards). */ +function watching(v: SeotdaView, players: Record, prefix = ''): string { + if (v.phase === 'rebuy') { + const ids = v.rebuy?.pending ?? []; + const names = ids.length ? ids.map((id) => players[id]?.nickname ?? '다른 사람').join('·') + '님이' : '칩을 모두 잃은 사람이'; + return `${prefix}${names} 칩을 다시 받을지 고르는 중이에요.`; + } + return [prefix + waitingLine(v, players), v.handPhase === 'betting' ? `모인 칩은 ${chips(v.pot)}이에요.` : '', rematchLine(v)].filter(Boolean).join('\n'); +} + +function myBetting(v: SeotdaView, seat: SeotdaSeatView): string { + const legal = new Map(v.legal.map((l) => [l.type, l])); + const buttons = BET_ORDER.filter((t) => (t !== 'full' || v.options.maxRaise === 'full') && legal.has(t)); + const lines = [`내 차례예요. ${roundText(v)}`]; + const hand = handLine(v, seat); + if (hand) lines.push(hand); + lines.push(`누를 수 있는 버튼: ${buttons.map((t) => `[${BET_KO[t]}]`).join(' ')}. 버튼 아래 적힌 만큼 칩이 바로 나가요.`); + const tips: string[] = []; + const call = legal.get('call'); + if (call) tips.push(`앞사람이 칩을 걸었어요. [콜]은 그만큼(${chips(call.amount)}) 맞추는 거예요.`); + else if (legal.has('ping')) tips.push(`[체크]는 칩을 걸지 않고 넘기고, [삥]은 학교(기본 칩)만큼 ${chips(v.baseUnit)}을 걸어요.`); + if (!buttons.some((t) => RAISES.includes(t)) && seat.lastAction) tips.push('이번 라운드에 이미 한 번 해서 더 올릴 수는 없어요(올리기는 한 라운드에 한 번).'); + if (v.legal.some((l) => l.allIn)) tips.push('칩이 모자라면 가진 칩을 다 내는 올인이 돼요.'); + tips.push('패가 약하면 [다이]로 이번 판을 포기할 수 있어요.'); + lines.push(tips.join(' ')); + return lines.join('\n'); +} + +export function helpNow(v: SeotdaView, me: string | null, players: Record): string { + if (v.phase === 'finished') return `게임이 끝났어요. ${v.result?.summary ?? ''}`.trim() + "\n'최종 순위'에서 순위를 볼 수 있어요. 결과 창에서 [한 판 더]를 누르면 다시 할 수 있어요."; + const seat = me ? v.seats.find((s) => s.id === me) : undefined; + if (!seat) return watching(v, players); + if (seat.status === 'eliminated') return watching(v, players, '나는 탈락해서 구경만 할 수 있어요.\n'); + if (seat.left) return watching(v, players, '게임에서 나갔어요.\n'); + + if (v.phase === 'rebuy') { + if (!v.rebuy?.mine) return watching(v, players) + ' 잠시 기다려 주세요.'; + const left = v.rebuy.left !== null ? `(남은 횟수 ${v.rebuy.left}번)` : ''; + return [ + `칩을 모두 잃었어요. [칩 다시 받기]를 누르면 놀이용 칩 ${chips(v.options.startingChips)}을 다시 받고 계속해요${left}.`, + '[그만하기]를 누르면 탈락하고 구경만 해요. 다시 받은 만큼 최종 점수에서 빠져요.', + '시간 안에 고르지 않으면 그만하는 것으로 처리돼요.', + ].join('\n'); + } + + const notIn = v.rematchNo > 0 ? '이번 재경기에는 참가하지 않아요. 끝날 때까지 기다려 주세요.' : '이번 판에는 참가하지 않아요. 다음 판을 기다려 주세요.'; + switch (v.handPhase) { + case 'betting': { + const re = rematchLine(v); + if (!seat.inHand) return [notIn, watching(v, players)].join('\n'); + if (seat.folded) return `이번 판은 다이했어요. 다음 판을 기다려 주세요. 내 패는 나만 볼 수 있어요.\n${waitingLine(v, players)}`; + if (v.toAct === me) return [re, myBetting(v, seat)].filter(Boolean).join('\n'); + const hand = handLine(v, seat); + if (seat.allIn) return ['올인했어요(가진 칩을 다 걸었어요). 더 걸지 않고 결과를 기다리면 돼요.', hand, re].filter(Boolean).join('\n'); + return [`${waitingLine(v, players)} 내 차례가 올 때까지 기다려 주세요.`, hand, re].filter(Boolean).join('\n'); + } + case 'choose': { + if (seat.chosen === null) return seat.folded ? '이번 판은 다이했어요. 다음 판을 기다려 주세요.' : [notIn, watching(v, players)].join('\n'); + if (seat.chosen) { + const name = v.myHand ? `(${v.myHand.name})` : ''; + return `쓸 카드 2장을 골랐어요${name}. 다른 사람이 다 고를 때까지 기다려 주세요. 다 고르면 마지막 베팅을 해요.`; + } + const lines = ["3장 중 쓸 카드 2장을 눌러 고르면 아래 버튼이 '○○(으)로 정하기'로 바뀌어요. 그 버튼을 눌러 정해 주세요. 남는 1장은 아무에게도 보이지 않아요."]; + const best = v.options.hints ? v.myCombos?.find((c) => c.best) : undefined; + if (best) lines.push(`[추천 조합]을 누르면 가장 센 조합(${best.name})이 골라져요.`); + lines.push('모두 동시에 몰래 고르고, 시간이 지나면 가장 센 조합으로 저절로 골라져요.'); + return lines.join('\n'); + } + case 'rejoin': { + if (!v.rejoin?.mine) return `${waitingLine(v, players)} 잠시 기다려 주세요.`; + const cost = chips(v.rejoin.cost); + return [ + `구사 재경기가 열려요. 다이했던 나도 모인 칩에 ${cost}을 더 내면 다시 들어갈 수 있어요.`, + v.rejoin.canAfford ? `[${cost} 내고 참가] 또는 [참가 안 함]을 눌러 주세요.` : '칩이 모자라서 참가할 수 없어요. [참가 안 함]을 눌러 주세요.', + '시간이 지나면 참가하지 않는 것으로 처리돼요.', + ].join('\n'); + } + case 'handEnd': { + const lines = [resultLine(v, players)]; + if (seat.status === 'playing') { + const label = v.next === 'rematch' ? '재경기 시작' : '다음 판으로'; + lines.push(seat.ready ? '다른 사람을 기다리는 중이에요.' : `[${label}] 버튼을 누르거나 잠시 기다리면 넘어가요.`); + } + return lines.join('\n'); + } + default: + return '잠시만 기다려 주세요.'; + } +} diff --git a/apps/web/src/games/seotda/index.tsx b/apps/web/src/games/seotda/index.tsx index e5566de..aec4cc8 100644 --- a/apps/web/src/games/seotda/index.tsx +++ b/apps/web/src/games/seotda/index.tsx @@ -17,6 +17,8 @@ import { HwatuCard, type HwatuInfo } from '../../components/cards/HwatuCard'; import { Chips, PlayChipNotice, formatChips } from '../../components/cards/Chips'; import { haptic, sounds } from '../../lib/feedback'; import type { BoardProps, GameUI, OptionsProps } from '../types'; +import { helpNow } from './help'; +import { quickStart } from './quick'; const BET_ORDER: BetType[] = ['die', 'check', 'ping', 'call', 'ddadang', 'quarter', 'half', 'full']; const BET_KO: Record = { check: '체크', ping: '삥', call: '콜', ddadang: '따당', quarter: '쿼터', half: '하프', full: '풀', die: '다이' }; @@ -826,6 +828,8 @@ const ui: GameUI = { Options, optionsSummary, Rules, + quickStart, + helpNow, }; export default ui; diff --git a/apps/web/src/games/seotda/quick.tsx b/apps/web/src/games/seotda/quick.tsx new file mode 100644 index 0000000..5e05ea8 --- /dev/null +++ b/apps/web/src/games/seotda/quick.tsx @@ -0,0 +1,92 @@ +/** 섯다 30초 규칙 요약 (docs/07 §5). */ +import type { CSSProperties, ReactNode } from 'react'; +import { HwatuCard, type HwatuInfo } from '../../components/cards/HwatuCard'; +import type { QuickCard } from '../types'; + +/** Illustration card: month + 광/열끗 (special) or 띠/기러기. Same idea as the 족보표 in index.tsx. */ +function hw(month: number, special: boolean): HwatuInfo { + const gwang = [1, 3, 8].includes(month); + const kind: HwatuInfo['kind'] = special ? (gwang ? 'gwang' : 'yeol') : month === 8 ? 'pi' : 'tti'; + return { + id: (month - 1) * 4 + (special ? 0 : 1), + month, + kind, + ...(kind === 'tti' ? { tti: 'plain' as const } : {}), + name: `${month}월 ${special ? (gwang ? '광' : '열끗') : month === 8 ? '기러기' : '띠'}`, + }; +} + +function Pair({ a, b, label, size = 'sm' }: { a: [number, boolean]; b: [number, boolean]; label: string; size?: 'sm' | 'md' }) { + return ( +
+
+ + +
+ {label} +
+ ); +} + +function Row({ children, gap = 10 }: { children: ReactNode; gap?: number }) { + return
{children}
; +} + +const btn = (bg: string): CSSProperties => ({ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + minWidth: 74, + height: 40, + borderRadius: 10, + background: bg, + color: '#fff', + fontWeight: 800, + fontSize: '1.05rem', +}); + +function Buttons() { + return ( +
+ 다이 + 체크 + 콜 + 하프 +
+ ); +} + +const gt = >; + +export const quickStart: QuickCard[] = [ + { + pic: ( + + + + ), + alt: '3월 광과 8월 광 화투 두 장이 놓여 있고 38광땡이라고 적혀 있어요', + title: '2장 조합이 센 사람이 이겨요', + text: '화투 2장의 조합(족보)을 겨뤄요. 가장 센 족보를 가진 사람이 가운데 모인 칩을 모두 가져가요.', + }, + { + pic: , + alt: '다이, 체크, 콜, 하프라고 적힌 버튼 네 개가 있어요', + title: '차례가 오면 칩을 걸어요', + text: '[체크]·[콜]·[하프] 같은 버튼으로 칩을 걸고, 패가 약하면 [다이]로 이번 판을 포기해요. 다른 사람이 다 다이하면 내가 가져가요.', + }, + { + pic: ( + + + {gt} + + {gt} + + + ), + alt: '장땡, 알리, 8끗 순서로 화투 두 장씩 놓여 있고 사이에 큰 쪽 표시가 있어요', + title: '땡 > 알리 > 끗 순서예요', + text: "광땡 > 땡(같은 달 2장) > 알리·독사 등 > 끗(두 달을 더한 일의 자리) 순으로 세요. 화면의 '족보표'를 보면 쉬워요.", + }, +]; diff --git a/apps/web/src/games/splendor/help.test.ts b/apps/web/src/games/splendor/help.test.ts new file mode 100644 index 0000000..d333216 --- /dev/null +++ b/apps/web/src/games/splendor/help.test.ts @@ -0,0 +1,67 @@ +import { expect, test } from 'bun:test'; +import { applyAction, setupGame, usersOf } from '../help-kit.test-util'; +import { GAMES } from '@bg/games'; +import { helpNow } from './help'; + +const users = usersOf(['A', 'B', 'C']); +const view = (s: unknown, who: string | null) => GAMES.splendor!.view(s, who) as any; +const nickOf = (id: string) => users[id]!.nickname; +const tokens = (t: Partial>) => ({ W: 0, U: 0, G: 0, R: 0, K: 0, gold: 0, ...t }); + +test('보석 상인: 내 차례 / 다른 사람 차례 / 관전', () => { + const { state } = setupGame('splendor', 2); + const [first, second] = state.order as [string, string]; + const t = helpNow(view(state, first), first, users); + expect(t).toContain('내 차례예요. 아래 중 하나만 해요'); + expect(t).toContain('서로 다른 색 3개'); + expect(t).toContain('[💎 가져오기]'); + expect(t).toContain('같은 색을 두 번 누르면 2개'); + expect(t).toContain('[📌 예약]'); + expect(t).toContain('지금 살 수 있는 카드는 없어요'); + expect(t).toContain('목표 15점'); + const w = helpNow(view(state, second), second, users); + expect(w).toContain(`${nickOf(first)}님 차례예요. 기다려 주세요`); + const spec = helpNow(view(state, null), null, users); + expect(spec).toContain(`${nickOf(first)}님 차례예요`); + expect(spec).toContain('점수:'); +}); + +test('보석 상인: 살 수 있는 카드 안내(힌트 켬/끔)', () => { + const { state } = setupGame('splendor', 2); + const first = state.order[0] as string; + // 색마다 2개 + 황금 2개면 1단계 카드는 모두 살 수 있어요. + state.players[first].tokens = tokens({ W: 2, U: 2, G: 2, R: 2, K: 2, gold: 2 }); + const t = helpNow(view(state, first), first, users); + expect(t).toContain('초록 테두리(✓ 가능)'); + expect(t).toContain('[🛒 사기]'); + const off = setupGame('splendor', 2, { showHints: false }).state; + off.players[first].tokens = tokens({ W: 2, U: 2, G: 2, R: 2, K: 2, gold: 2 }); + expect(helpNow(view(off, first), first, users)).toContain('카드를 누르면 살 수 있는지 보여요'); +}); + +test('보석 상인: 보석 돌려놓기와 귀족 고르기', () => { + const { state } = setupGame('splendor', 2); + const [first, second] = state.order as [string, string]; + state.players[first].tokens = tokens({ W: 3, U: 3, G: 3 }); + const s = applyAction('splendor', state, first, { type: 'TAKE_DIFFERENT', colors: ['R', 'K', 'W'] }); + expect(s.phase).toBe('discard'); + const d = helpNow(view(s, first), first, users); + expect(d).toContain('2개를 고르고 [돌려놓기]'); + expect(helpNow(view(s, second), second, users)).toContain('보석을 돌려놓는 중'); + + const n = structuredClone(state); + n.phase = 'noble'; + n.pendingNobleChoices = n.nobles.slice(0, 2); + expect(helpNow(view(n, first), first, users)).toContain('귀족 두 명 이상이 찾아왔어요'); + expect(helpNow(view(n, null), null, users)).toContain('귀족 중 한 명을 고르는 중'); +}); + +test('보석 상인: 기권으로 끝', () => { + const { state } = setupGame('splendor', 2); + const [first, second] = state.order as [string, string]; + const s = applyAction('splendor', state, second, { type: 'CONCEDE' }); + expect(s.phase).toBe('finished'); + expect(helpNow(view(s, first), first, users)).toContain('내가 이겼어요'); + expect(helpNow(view(s, second), second, users)).toContain('나는 2위'); + expect(helpNow(view(s, null), null, users)).toContain(`${nickOf(first)}님이 1위예요`); +}); diff --git a/apps/web/src/games/splendor/help.ts b/apps/web/src/games/splendor/help.ts new file mode 100644 index 0000000..f8d87cf --- /dev/null +++ b/apps/web/src/games/splendor/help.ts @@ -0,0 +1,72 @@ +/** 보석 상인 [?] "지금 할 수 있는 것" (docs/07 §5). Pure: only reads the client view. */ +import type { Color, SplendorView } from '@bg/games/splendor'; +import type { PublicUser } from '@bg/shared/lite'; + +// Same short colour words as the screen (index.tsx). +const SHORT: Record = { W: '흰', U: '파', G: '초', R: '빨', K: '검' }; + +export function helpNow(v: SplendorView, me: string | null, players: Record): string { + const name = (id: string) => `${players[id]?.nickname ?? '다른 사람'}님`; + const mine = me ? v.players[me] : undefined; + const target = v.options.targetPoints; + const scores = v.order.map((p) => `${p === me ? '나' : name(p)} ${v.players[p]?.points ?? 0}점`).join(', '); + const lastRound = v.endTriggered ? `누군가 ${target}점에 닿아서 이번 라운드가 마지막이에요.` : null; + + if (v.phase === 'finished') { + const r = v.result; + if (!r) return '게임이 끝났어요.'; + const win = r.winners.map(name).join(', '); + const myRow = me ? r.ranking.find((x) => x.player === me) : undefined; + const tail = !myRow ? '' : r.winners.includes(me!) ? (r.winners.length > 1 ? ' 내가 공동 1위예요!' : ' 내가 이겼어요!') : ` 나는 ${myRow.rank}위(${myRow.points}점)예요.`; + const tie = r.ranking.length > 1 && r.ranking[0]!.points === r.ranking[1]!.points ? '\n점수가 같으면 산 카드가 적은 사람이 위예요.' : ''; + return `게임이 끝났어요. ${win}이 1위예요.${tail}${tie}`; + } + + const cur = v.current; + const doing = v.phase === 'discard' ? ' 보석을 돌려놓는 중이에요.' : v.phase === 'noble' ? ' 찾아온 귀족 중 한 명을 고르는 중이에요.' : ''; + const curText = cur ? `${name(cur)} 차례예요.${doing}` : '잠시만 기다려 주세요.'; + + if (!mine || mine.conceded || !v.me) { + const head = mine?.conceded ? '기권해서 구경하는 중이에요. ' : ''; + return [`${head}${curText}`, `점수: ${scores} (${target}점이면 그 라운드까지 하고 끝나요)`, lastRound].filter(Boolean).join('\n'); + } + + const my = v.me; + const status = `내 점수 ${mine.points}점 / 목표 ${target}점. 내 보석 ${mine.tokenCount}개(10개까지).`; + + if (!my.myTurn) { + return [`${curText} 기다려 주세요.`, '카드를 눌러 자세히 볼 수 있어요.', status, lastRound].filter(Boolean).join('\n'); + } + + if (v.phase === 'discard') { + return `보석은 10개까지만 가질 수 있어요. 내 보석을 눌러 ${my.discardCount}개를 고르고 [돌려놓기]를 눌러 주세요.`; + } + if (v.phase === 'noble') { + return '귀족 두 명 이상이 찾아왔어요. 한 명을 눌러 골라 주세요(귀족은 3점).\n고르지 않은 귀족은 다음 차례 이후에 다시 찾아올 수 있어요.'; + } + + if (my.canPass) return '할 수 있는 행동이 하나도 없어요. [차례 넘기기]를 눌러 주세요.'; + + const lines = ['내 차례예요. 아래 중 하나만 해요.']; + const td = my.takeDifferent; + if (td && td.count === 3) lines.push('💎 보석 은행에서 서로 다른 색 3개를 누르고 [💎 가져오기].'); + else if (td) lines.push(`💎 남은 색이 적어서 서로 다른 색 ${td.count}개를 가져올 수 있어요. 고른 뒤 [💎 가져오기].`); + if (my.takeSame.length) lines.push(`💎 같은 색을 두 번 누르면 2개를 가져와요(4개 이상 남은 색만: ${my.takeSame.map((c) => SHORT[c]).join('·')}).`); + const buyable = Object.values(my.buy); + if (v.options.showHints) { + if (buyable.length) { + const gold = buyable.some((b) => b.gold > 0) ? ' 금색 테두리는 황금을 써야 살 수 있어요.' : ''; + lines.push(`🛒 초록 테두리(✓ 가능) 카드는 지금 살 수 있어요(${buyable.length}장). 카드를 누르고 [🛒 사기].${gold}`); + } else lines.push('🛒 지금 살 수 있는 카드는 없어요. 보석을 모아요.'); + } else lines.push('🛒 카드를 누르면 살 수 있는지 보여요. 살 수 있으면 [🛒 사기].'); + if (my.canReserve) lines.push(`📌 카드나 더미를 누르고 [📌 예약]하면 내 손에 두고${my.goldOnReserve ? ' 황금 1개를 받아요' : ' 나중에 살 수 있어요'}(예약 ${mine.reservedCount}/3).`); + else if (mine.reservedCount >= 3) lines.push('📌 예약은 3장이 꽉 찼어요. 예약한 카드를 사면 다시 예약할 수 있어요.'); + const nearest = Object.values(my.nobleNeed); + if (v.options.showHints && nearest.length) { + const n = Math.min(...nearest); + lines.push(n === 0 ? '👑 조건을 채운 귀족이 있어서 이번 차례가 끝나면 찾아와요(3점).' : `👑 가장 가까운 귀족까지 보너스(산 카드) ${n}개가 더 필요해요.`); + } + lines.push(status); + if (lastRound) lines.push(lastRound); + return lines.join('\n'); +} diff --git a/apps/web/src/games/splendor/index.tsx b/apps/web/src/games/splendor/index.tsx index 0543a49..d70b714 100644 --- a/apps/web/src/games/splendor/index.tsx +++ b/apps/web/src/games/splendor/index.tsx @@ -19,6 +19,8 @@ import { Avatar, ConfirmSheet } from '../../components/ui'; import { TurnTimer } from '../../components/TurnTimer'; import { haptic, sounds } from '../../lib/feedback'; import type { BoardProps, GameUI, OptionsProps } from '../types'; +import { helpNow } from './help'; +import { quickStart } from './quick'; // Type-only imports above keep the rules module out of the bundle; the few constants live here. const COLORS: Color[] = ['W', 'U', 'G', 'R', 'K']; @@ -1233,6 +1235,8 @@ const ui: GameUI = { Options, optionsSummary, Rules, + quickStart, + helpNow, }; export default ui; diff --git a/apps/web/src/games/splendor/quick.tsx b/apps/web/src/games/splendor/quick.tsx new file mode 100644 index 0000000..1001078 --- /dev/null +++ b/apps/web/src/games/splendor/quick.tsx @@ -0,0 +1,142 @@ +/** 보석 상인 30초 규칙 요약 (docs/07 §5). Gems, cards and a noble drawn as small inline SVG. */ +import type { QuickCard } from '../types'; + +type C = 'W' | 'U' | 'G' | 'R' | 'K'; +const FILL: Record = { W: '#f8fafc', U: '#2563eb', G: '#16a34a', R: '#dc2626', K: '#1f2937' }; +const SHORT: Record = { W: '흰', U: '파', G: '초', R: '빨', K: '검' }; + +function Gem({ x, y, c, r = 16 }: { x: number; y: number; c: C; r?: number }) { + return ( + + + + + ); +} + +/** A development card: points top-left, bonus gem top-right, cost pips at the bottom. */ +function DevCard({ x, y, bonus, points, cost }: { x: number; y: number; bonus: C; points: number; cost: [C, number][] }) { + return ( + + + + {points > 0 && ( + + {points} + + )} + + {cost.map(([c, n], i) => ( + + + + {n} + + + ))} + + ); +} + +const TakePic = ( + + + + + + 3개 + + + 또는 + + + + + 같은 색 2개 + + + (4개 이상 남았을 때) + + +); + +const BuyPic = ( + + + + + + + + + + + 할인 1 + + + 다음 카드부터 + + + 파란 보석 1개 덜 내요 + + + 왼쪽 위 숫자 = 점수 + + + 아래 = 내야 할 보석 + + +); + +const WinPic = ( + + + + 👑 + + + 3 + + {(['W', 'U', 'G'] as C[]).map((c, i) => ( + + + + 3 + + + ))} + + {`${SHORT.W}·${SHORT.U}·${SHORT.G} 카드 3장씩`} + + + 15점 + + + 먼저 모으면 + + + 그 라운드까지 + + +); + +export const quickStart: QuickCard[] = [ + { + pic: TakePic, + alt: '빨강·파랑·초록 보석 3개, 또는 검은 보석 2개를 가져오는 모습', + title: '보석을 가져와요', + text: '차례마다 하나만 해요. 서로 다른 색 보석 3개, 또는 같은 색 2개(4개 이상 남은 색)를 가져와요. 보석은 10개까지 가질 수 있어요.', + }, + { + pic: BuyPic, + alt: '빨간 보석 4개로 산 카드가 파란 보석 할인 1이 되는 그림', + title: '카드를 사면 할인돼요', + text: '보석을 내고 카드를 사요. 산 카드의 보석은 다음부터 영구 할인(보너스)이 돼요. 예약하면 황금(아무 색 대신 쓰는 보석)이 남아 있을 때 1개 받아요.', + }, + { + pic: WinPic, + alt: '흰·파·초 카드를 3장씩 모으면 찾아오는 3점 귀족과 15점 목표', + title: '귀족과 15점', + text: '산 카드 보너스가 귀족 조건을 채우면 귀족이 와서 3점을 줘요. 누군가 15점(방 설정에 따라 10점)이 되면 그 라운드까지 하고 끝나요.', + }, +]; diff --git a/apps/web/src/games/yacht/help.test.ts b/apps/web/src/games/yacht/help.test.ts new file mode 100644 index 0000000..018c998 --- /dev/null +++ b/apps/web/src/games/yacht/help.test.ts @@ -0,0 +1,55 @@ +import { expect, test } from 'bun:test'; +import { applyAction, setupGame, usersOf } from '../help-kit.test-util'; +import { GAMES } from '@bg/games'; +import { helpNow } from './help'; + +const users = usersOf(['A', 'B']); +const view = (s: unknown, who: string | null) => GAMES.yacht!.view(s, who) as any; +const nickOf = (id: string) => users[id]!.nickname; + +test('요트: 굴리기 전 내 차례 / 상대 차례 / 관전', () => { + const { state } = setupGame('yacht', 2); + const cur: string = state.order[0]; + const other = cur === 'A' ? 'B' : 'A'; + const mine = helpNow(view(state, cur), cur, users); + expect(mine).toContain('내 차례예요(1/12라운드)'); + expect(mine).toContain('[🎲 굴리기]'); + const theirs = helpNow(view(state, other), other, users); + expect(theirs).toContain(`${nickOf(cur)}님 차례예요`); + expect(theirs).toContain('기다려 주세요'); + expect(theirs).toContain('보너스 35점'); + expect(helpNow(view(state, null), null, users)).toContain(`${nickOf(cur)}님 차례예요(1/12라운드). 아직 주사위를 굴리지 않았어요.`); +}); + +test('요트: 굴린 뒤 남은 굴림·고정·추천 칸, 3번 다 굴리면 칸 고르기', () => { + const { state } = setupGame('yacht', 2); + const cur: string = state.order[0]; + let s = applyAction('yacht', state, cur, { type: 'roll' }); + let t = helpNow(view(s, cur), cur, users); + expect(t).toContain('1번 굴렸어요(남은 굴림 2번)'); + expect(t).toContain('🔒 고정'); + expect(t).toContain('가장 높은 칸은'); + s = applyAction('yacht', s, cur, { type: 'hold', held: [true, true, true, true, true] }); + expect(helpNow(view(s, cur), cur, users)).toContain('모두 고정해서 더 굴릴 수 없어요'); + s = applyAction('yacht', s, cur, { type: 'hold', held: [false, false, false, false, false] }); + s = applyAction('yacht', s, cur, { type: 'roll' }); + s = applyAction('yacht', s, cur, { type: 'roll' }); + t = helpNow(view(s, cur), cur, users); + expect(t).toContain('3번 다 굴렸어요'); + expect(t).not.toContain('🔒'); + expect(helpNow(view(s, null), null, users)).toContain('3번 굴렸어요(남은 굴림 0번)'); +}); + +test('요트: 13칸 변형 강제 순서, 기권, 게임 끝', () => { + const { state } = setupGame('yacht', 2, { scoring: 'yahtzee', yahtzeeJoker: 'forced' }); + const cur: string = state.order[0]; + const s = structuredClone(state); + s.sheets[cur].scores.yacht = 50; + s.dice = [5, 5, 5, 5, 5]; + s.rollsUsed = 1; + expect(helpNow(view(s, cur), cur, users)).toContain('누를 수 있는 칸에만'); + const other = cur === 'A' ? 'B' : 'A'; + const s2 = applyAction('yacht', state, cur, { type: 'concede' }); + expect(helpNow(view(s2, cur), cur, users)).toContain('게임이 끝났어요'); + expect(helpNow(view(s2, other), other, users)).toContain('결과 창'); +}); diff --git a/apps/web/src/games/yacht/help.ts b/apps/web/src/games/yacht/help.ts new file mode 100644 index 0000000..795b164 --- /dev/null +++ b/apps/web/src/games/yacht/help.ts @@ -0,0 +1,72 @@ +/** 요트 다이스 [?] "지금 할 수 있는 것" (docs/07 §5). Pure: only reads the client view. */ +import type { Category, YachtView } from '@bg/games/yacht'; +import type { PublicUser } from '@bg/shared/lite'; + +const NAME: Record = { + ones: '에이스', + twos: '듀스', + threes: '트레이', + fours: '포', + fives: '파이브', + sixes: '식스', + choice: '초이스', + threeKind: '쓰리 카인드', + fourKind: '포 카인드', + fullHouse: '풀 하우스', + smallStraight: '스몰 스트레이트', + largeStraight: '라지 스트레이트', + yacht: '요트', +}; +const UPPER: Category[] = ['ones', 'twos', 'threes', 'fours', 'fives', 'sixes']; + +const catName = (c: Category, v: YachtView) => (c === 'choice' && v.options.scoring === 'yahtzee' ? '찬스' : NAME[c]); + +/** Highest-scoring category the current dice may go into (table order breaks ties). */ +function best(v: YachtView): string { + if (!v.preview) return ''; + let top: Category | null = null; + for (const c of v.categories) { + const p = v.preview[c]; + if (p === undefined || !v.allowed.includes(c)) continue; + if (top === null || p > v.preview[top]!) top = c; + } + if (top === null || v.preview[top]! <= 0) return ''; + return `지금 주사위로 가장 높은 칸은 ${catName(top, v)} ${v.preview[top]}점이에요.`; +} + +function rollState(v: YachtView): string { + if (v.rollsUsed === 0) return '아직 주사위를 굴리지 않았어요.'; + return `주사위를 ${v.rollsUsed}번 굴렸어요(남은 굴림 ${v.rollsLeft}번).`; +} + +export function helpNow(v: YachtView, me: string | null, players: Record): string { + if (v.phase === 'finished' || !v.current) return `게임이 끝났어요. ${v.result?.summary ?? ''}`.trim() + '\n결과 창에서 [한 판 더]를 누르면 다시 할 수 있어요.'; + const round = `${v.round}/${v.totalRounds}라운드`; + const sheet = me ? v.sheets[me] : undefined; + if (v.current !== me) { + const who = `${players[v.current]?.nickname ?? '다른 사람'}님`; + const head = `${who} 차례예요(${round}). ${rollState(v)}`; + if (!sheet) return `${head}\n주사위 5개를 최대 3번 굴려서 점수표의 빈 칸 하나에 점수를 적어요.`; + if (sheet.left) return `${head}\n나는 기권해서 지금은 구경만 할 수 있어요.`; + const lines = [`${head} 기다려 주세요.`, `내 차례가 오면 주사위를 굴려요. 지금 내 총점은 ${sheet.total}점이에요.`]; + if (sheet.upperBonus === 0) lines.push(`윗칸(에이스~식스) 합이 ${v.upperBonusThreshold}점이 되면 보너스 35점을 받아요. 지금 ${sheet.upper}점이에요.`); + return lines.join('\n'); + } + // My turn. + if (v.rollsUsed === 0) return `내 차례예요(${round}). [🎲 굴리기] 버튼을 눌러 주사위 5개를 굴려요.\n한 차례에 최대 3번 굴릴 수 있고, 굴린 뒤 점수표의 빈 칸 하나에 점수를 적으면 차례가 끝나요.`; + const lines: string[] = []; + const open = v.categories.filter((c) => sheet && sheet.scores[c] === undefined).length; + if (v.rollsLeft > 0) { + lines.push(`내 차례예요(${round}). ${rollState(v)}`); + if (v.held.every(Boolean)) lines.push('주사위를 모두 고정해서 더 굴릴 수 없어요. 다시 굴리려면 주사위를 눌러 하나 이상 풀어 주세요.'); + else lines.push(`남길 주사위를 누르면 🔒 고정돼요. [🎲 굴리기]를 누르면 고정 안 한 주사위만 다시 굴려요.`); + lines.push('지금 주사위가 마음에 들면 점수표의 빈 칸을 눌러 바로 적어도 돼요.'); + } else { + lines.push(`내 차례예요(${round}). 3번 다 굴렸어요. 이제 점수표의 빈 칸 하나를 눌러 점수를 적어야 해요.`); + } + if (v.allowed.length > 0 && v.allowed.length < open) lines.push('이번 요트(5개 같은 눈)는 정해진 순서가 있어서 누를 수 있는 칸에만 적을 수 있어요.'); + const b = best(v); + if (b) lines.push(b); + lines.push('조건에 맞지 않는 칸에 적으면 0점이에요. 한 번 적은 칸은 바꿀 수 없어요.'); + return lines.join('\n'); +} diff --git a/apps/web/src/games/yacht/index.tsx b/apps/web/src/games/yacht/index.tsx index 3be8cfe..e0d46ad 100644 --- a/apps/web/src/games/yacht/index.tsx +++ b/apps/web/src/games/yacht/index.tsx @@ -6,6 +6,8 @@ import { Avatar, ConfirmSheet } from '../../components/ui'; import { TurnTimer } from '../../components/TurnTimer'; import { haptic, sounds } from '../../lib/feedback'; import type { BoardProps, GameUI, OptionsProps } from '../types'; +import { helpNow } from './help'; +import { quickStart } from './quick'; // The client draws from the view only; names and hints live here so the rules module isn't bundled. const NAME: Record = { @@ -662,6 +664,8 @@ const ui: GameUI = { Options, optionsSummary, Rules, + quickStart, + helpNow, }; export default ui; diff --git a/apps/web/src/games/yacht/quick.tsx b/apps/web/src/games/yacht/quick.tsx new file mode 100644 index 0000000..3740d7f --- /dev/null +++ b/apps/web/src/games/yacht/quick.tsx @@ -0,0 +1,64 @@ +/** 요트 다이스 30초 규칙 요약 (docs/07 §5). */ +import type { QuickCard } from '../types'; + +const PIPS: Record = { + 1: [[50, 50]], + 2: [[28, 28], [72, 72]], + 3: [[26, 26], [50, 50], [74, 74]], + 4: [[28, 28], [72, 28], [28, 72], [72, 72]], + 5: [[26, 26], [74, 26], [50, 50], [26, 74], [74, 74]], + 6: [[28, 24], [72, 24], [28, 50], [72, 50], [28, 76], [72, 76]], +}; + +/** One die at (x, y), 100 units wide; `held` draws it lower with a lock tag like the game screen. */ +function Die({ x, y, value, held = false }: { x: number; y: number; value: number; held?: boolean }) { + return ( + + + {PIPS[value]!.map(([cx, cy], i) => ( + + ))} + {held && 🔒} + + ); +} + +function DiceRow({ dice, held = [] }: { dice: number[]; held?: number[] }) { + return ( + + {dice.map((d, i) => ( + + ))} + + ); +} + +function Pic({ dice, held, caption }: { dice: number[]; held?: number[]; caption: string }) { + return ( +
+ + {caption} +
+ ); +} + +export const quickStart: QuickCard[] = [ + { + pic: , + alt: '주사위 5개 중 6이 나온 3개가 자물쇠로 고정돼 있어요', + title: '3번까지 굴려요', + text: '내 차례에 주사위 5개를 최대 3번 굴려요. 남길 주사위를 누르면 고정되고, 나머지만 다시 굴려요.', + }, + { + pic: , + alt: '주사위 3, 3, 5, 5, 5와 풀 하우스 21점 칸', + title: '빈 칸 하나에 적어요', + text: '굴린 뒤 점수표의 빈 칸 하나를 눌러 점수를 적으면 차례가 끝나요. 조건에 안 맞는 칸은 0점이에요.', + }, + { + pic: , + alt: '주사위 5개가 모두 5로 같아서 요트 50점', + title: '총점이 높으면 이겨요', + text: '모든 칸을 채우면 끝나요. 5개가 모두 같으면 요트 50점! 윗칸(에이스~식스) 합이 63점이면 보너스 35점이에요.', + }, +]; diff --git a/apps/web/src/games/yut/help.test.ts b/apps/web/src/games/yut/help.test.ts new file mode 100644 index 0000000..9179f5c --- /dev/null +++ b/apps/web/src/games/yut/help.test.ts @@ -0,0 +1,62 @@ +import { expect, test } from 'bun:test'; +import { applyAction, setupGame, usersOf } from '../help-kit.test-util'; +import { GAMES } from '@bg/games'; +import { helpNow } from './help'; + +const users = usersOf(['A', 'B']); +const view = (s: unknown, who: string | null) => GAMES.yut!.view(s, who) as any; + +/** Scripted sticks: F = 배 up, R = 등 up (see packages/games/src/yut/yut.test.ts). */ +const F = 0; +const R = 0.99; +const STICKS = { do: [R, F, R, R], gae: [F, F, R, R], geol: [F, F, F, R], yut: [F, F, F, F], mo: [R, R, R, R] } as const; +const sticks = (...ts: (keyof typeof STICKS)[]) => { + const vals: number[] = ts.flatMap((t) => [...STICKS[t]]); + return { next: () => vals.shift()!, int: (n: number) => Math.floor(vals.shift()! * n), shuffle: (a: readonly T[]) => [...a] } as any; +}; + +function fresh() { + const { state } = setupGame('yut', 2); + state.turnSide = 0; + state.current = state.sides[0].members[0]; + const me: string = state.current; + return { state, me, other: me === 'A' ? 'B' : 'A' }; +} + +test('윷: 던지기 차례 / 상대 차례 / 관전', () => { + const { state, me, other } = fresh(); + const t = helpNow(view(state, me), me, users); + expect(t).toContain('[🥢 윷 던지기]'); + expect(t).toContain('대기 4개'); + expect(helpNow(view(state, other), other, users)).toContain(`${users[me]!.nickname}님(빨강 말) 차례예요. 윷을 던지는 중이에요. 기다려 주세요.`); + expect(helpNow(view(state, null), null, users)).toContain('자기 편 말을 모두'); +}); + +test('윷: 윷이 나오면 한 번 더, 그다음 결과를 골라 말 옮기기', () => { + const { state, me, other } = fresh(); + let s = applyAction('yut', state, me, { type: 'THROW' }, sticks('yut')); + expect(helpNow(view(s, me), me, users)).toContain('윷이나 모가 나와서 한 번 더'); + s = applyAction('yut', s, me, { type: 'THROW' }, sticks('geol')); + const t = helpNow(view(s, me), me, users); + expect(t).toContain('모아 둔 결과(윷, 걸)'); + expect(t).toContain('반짝이는 말'); + expect(helpNow(view(s, other), other, users)).toContain('말을 옮기는 중이에요. 모아 둔 결과: 윷, 걸.'); +}); + +test('윷: 잡을 수 있으면 알려 주고, 잡으면 한 번 더 던지기', () => { + const { state, me } = fresh(); + const opp = state.sides[1].pieces[0]; + Object.assign(opp, { status: 'board', node: 2, trail: [1, 2], lapped: false }); + let s = applyAction('yut', state, me, { type: 'THROW' }, sticks('gae')); + const t = helpNow(view(s, me), me, users); + expect(t).toContain('상대 말을 잡을 수 있어요(잡으면 한 번 더 던져요)'); + expect(t).toContain('[자동 이동]'); + s = applyAction('yut', s, me, { type: 'MOVE', throwIndex: 0, from: 'home' }); + expect(helpNow(view(s, me), me, users)).toContain('상대 말을 잡아서 한 번 더 던져요'); +}); + +test('윷: 게임 끝', () => { + const { state, me, other } = fresh(); + const s = applyAction('yut', state, other, { type: 'CONCEDE' }); + expect(helpNow(view(s, me), me, users)).toContain('게임이 끝났어요'); +}); diff --git a/apps/web/src/games/yut/help.ts b/apps/web/src/games/yut/help.ts new file mode 100644 index 0000000..5902203 --- /dev/null +++ b/apps/web/src/games/yut/help.ts @@ -0,0 +1,71 @@ +/** 윷놀이 [?] "지금 할 수 있는 것" (docs/07 §5). Pure: only reads the client view. */ +import type { Throw, YutView } from '@bg/games/yut'; +import type { PublicUser } from '@bg/shared/lite'; + +const THROW_KO: Record = { do: '도', gae: '개', geol: '걸', yut: '윷', mo: '모', backdo: '빽도' }; + +const pendingText = (v: YutView) => v.pending.map((t) => THROW_KO[t]).join(', '); + +/** Why the current player throws (again): fresh turn, 윷·모, or a capture bonus. */ +function throwReason(v: YutView): 'fresh' | 'yutmo' | 'capture' { + for (let i = v.log.length - 1; i >= 0; i--) { + const e = v.log[i]!; + if (e.type === 'bonusThrow') return 'capture'; + if (e.type === 'thrown') return e.result === 'yut' || e.result === 'mo' ? 'yutmo' : 'fresh'; + if (e.type === 'turnChanged' || e.type === 'nak' || e.type === 'moved') return 'fresh'; + } + return 'fresh'; +} + +export function helpNow(v: YutView, me: string | null, players: Record): string { + const goal = '내 말을 모두 먼저 한 바퀴 돌려 판 밖으로 내보내면(날) 이겨요.'; + if (v.phase === 'finished') return `게임이 끝났어요. ${v.result?.summary ?? ''}`.trim() + '\n결과 창에서 [한 판 더]를 누르면 다시 할 수 있어요.'; + const turnSide = v.sides[v.turnSide]; + const sideTag = turnSide ? `(${turnSide.name}${v.team ? '' : ' 말'})` : ''; + const who = `${players[v.current]?.nickname ?? '다른 사람'}님`; + const doing = v.phase === 'throw' ? '윷을 던지는 중이에요.' : `말을 옮기는 중이에요. 모아 둔 결과: ${pendingText(v)}.`; + const mySide = v.mySide !== null ? v.sides[v.mySide] : undefined; + const progress = mySide ? `${v.team ? '우리 팀 말' : '내 말'}: 대기 ${mySide.home}개, 판 위 ${mySide.onBoard}개, 난 말 ${mySide.done}/${v.options.pieces}개.` : ''; + + if (!mySide || !me) return `${who}${sideTag} 차례예요. ${doing}\n${goal.replace('내 말을', '자기 편 말을')}`; + if (mySide.conceded.includes(me)) return `나는 기권해서 지금은 구경만 할 수 있어요. ${who}${sideTag} 차례예요.`; + + if (v.current !== me) { + const ours = v.team && v.mySide === v.turnSide ? '(우리 팀)' : sideTag; + return `${who}${ours} 차례예요. ${doing} 기다려 주세요.\n${progress}`; + } + + // My turn. + if (v.phase === 'throw') { + const reason = throwReason(v); + const lines: string[] = []; + if (reason === 'yutmo') lines.push('윷이나 모가 나와서 한 번 더 던져요! [🥢 한 번 더 던지기]를 눌러 주세요.'); + else if (reason === 'capture') lines.push('상대 말을 잡아서 한 번 더 던져요! [🥢 한 번 더 던지기]를 눌러 주세요.'); + else lines.push(`내 차례예요. [🥢 윷 던지기]를 눌러 윷을 던져요.`); + if (v.pending.length) lines.push(`모아 둔 결과(${pendingText(v)})는 다 던진 뒤에 원하는 순서로 써요.`); + else lines.push(`도 1칸, 개 2칸, 걸 3칸, 윷 4칸, 모 5칸 움직여요. 윷·모가 나오면 한 번 더 던져요.`); + lines.push(progress); + return lines.join('\n'); + } + + const moves = v.legalMoves ?? []; + const lines = [`내 차례예요. 모아 둔 결과(${pendingText(v)})를 하나씩 써서 말을 움직여요.`]; + if (moves.length === 1) { + lines.push('지금 움직일 수 있는 방법이 하나뿐이에요. 초록색 [자동 이동] 버튼을 누르면 바로 움직여요.'); + } else { + lines.push('아래 결과를 먼저 누르고(한 가지뿐이면 이미 골라져 있어요), 반짝이는 말을 누르면 갈 자리가 보여요. 한 번 더 누르면 움직여요.'); + if (mySide.home > 0 && moves.some((m) => m.from === 'home')) lines.push(`새 말은 [대기 ${mySide.home}개] 버튼을 눌러 판에 올려요.`); + } + const usable = new Set(moves.map((m) => m.throw)); + if (v.pending.some((t) => !usable.has(t))) lines.push('지금 쓸 곳이 없는 결과는 눌리지 않아요. 다른 결과를 먼저 써 보세요.'); + const tips: string[] = []; + if (moves.some((m) => m.captures > 0)) { + const bonus = moves.some((m) => m.captures > 0 && (m.throw !== 'backdo' || v.options.backdoCaptureBonus)); + tips.push(`상대 말을 잡을 수 있어요${bonus ? '(잡으면 한 번 더 던져요)' : ''}`); + } + if (moves.some((m) => m.finishes)) tips.push('판 밖으로 낼(날) 수 있는 말이 있어요'); + if (moves.some((m) => m.stacks)) tips.push('내 말끼리 업어서 함께 움직일 수 있어요'); + if (tips.length) lines.push(`${tips.join('. ')}.`); + lines.push(progress); + return lines.join('\n'); +} diff --git a/apps/web/src/games/yut/index.tsx b/apps/web/src/games/yut/index.tsx index f5fca28..7ad3b1d 100644 --- a/apps/web/src/games/yut/index.tsx +++ b/apps/web/src/games/yut/index.tsx @@ -6,6 +6,8 @@ import { Avatar, ConfirmSheet } from '../../components/ui'; import { TurnTimer } from '../../components/TurnTimer'; import { haptic, sounds } from '../../lib/feedback'; import type { BoardProps, GameUI, OptionsProps } from '../types'; +import { helpNow } from './help'; +import { quickStart } from './quick'; type From = number | 'home'; type GameEvent = YutView['log'][number]; @@ -861,6 +863,8 @@ const ui: GameUI = { Options, optionsSummary, Rules, + quickStart, + helpNow, }; export default ui; diff --git a/apps/web/src/games/yut/quick.tsx b/apps/web/src/games/yut/quick.tsx new file mode 100644 index 0000000..451b20e --- /dev/null +++ b/apps/web/src/games/yut/quick.tsx @@ -0,0 +1,128 @@ +/** 윷놀이 30초 규칙 요약 (docs/07 §5). */ +import type { ReactNode } from 'react'; +import type { QuickCard } from '../types'; + +/** One 윷가락: 배(flat, light) or 등(round, dark with marks). */ +function Stick({ x, flat }: { x: number; flat: boolean }) { + return ( + + + {flat ? ( + + ) : ( + [20, 32, 44, 56].map((y) => ) + )} + + ); +} + +function SticksPic() { + const groups: [boolean[], string][] = [ + [[true, true, true, false], '걸 · 3칸'], + [[true, true, true, true], '윷 · 4칸+1번'], + ]; + return ( + + {groups.map(([flats, label], g) => ( + + {flats.map((f, i) => ( + + ))} + + {label} + + + ))} + + ); +} + +// Board geometry like the game screen: 참먹이 bottom-right, pieces go anticlockwise. +const LO = 8; +const HI = 92; +const GAP = (HI - LO) / 5; +const POS: [number, number][] = (() => { + const p: [number, number][] = []; + p[0] = [HI, HI]; + for (let i = 1; i <= 4; i++) p[i] = [HI, HI - GAP * i]; + p[5] = [HI, LO]; + for (let i = 6; i <= 9; i++) p[i] = [HI - GAP * (i - 5), LO]; + p[10] = [LO, LO]; + for (let i = 11; i <= 14; i++) p[i] = [LO, LO + GAP * (i - 10)]; + p[15] = [LO, HI]; + for (let i = 16; i <= 19; i++) p[i] = [LO + GAP * (i - 15), HI]; + const lerp = (a: [number, number], b: [number, number], t: number): [number, number] => [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]; + [20, 21, 22, 23, 24].forEach((n, k) => (p[n] = lerp(p[5]!, p[15]!, (k + 1) / 6))); + [25, 26].forEach((n, k) => (p[n] = lerp(p[10]!, p[0]!, (k + 1) / 6))); + [27, 28].forEach((n, k) => (p[n] = lerp(p[10]!, p[0]!, (k + 4) / 6))); + return p; +})(); +const LINES: [number, number][] = [ + ...Array.from({ length: 20 }, (_, i) => [i, (i + 1) % 20] as [number, number]), + [5, 20], [20, 21], [21, 22], [22, 23], [23, 24], [24, 15], + [10, 25], [25, 26], [26, 22], [22, 27], [27, 28], [28, 0], +]; +const BIG = new Set([0, 5, 10, 15, 22]); + +function Board({ children, path }: { children?: ReactNode; path?: number[] }) { + return ( + + {LINES.map(([a, b]) => ( + + ))} + {path && POS[n]!.join(',')).join(' ')} fill="none" stroke="#2563eb" strokeWidth={2.4} strokeDasharray="3 2" />} + {POS.map(([x, y], n) => ( + + ))} + {children} + + ); +} + +const Red = ({ n, dx = 0, dy = 0 }: { n: number; dx?: number; dy?: number }) => ( + +); +const Blue = ({ n }: { n: number }) => { + const [x, y] = POS[n]!; + return ; +}; +const Label = ({ n, text, dy = -9 }: { n: number; text: string; dy?: number }) => ( + + {text} + +); + +export const quickStart: QuickCard[] = [ + { + pic: , + alt: '윷가락 네 개: 세 개가 배면 걸 3칸, 네 개 모두 배면 윷 4칸과 한 번 더', + title: '윷 던지고 말 옮겨요', + text: '도 1칸, 개 2칸, 걸 3칸, 윷 4칸, 모 5칸. 윷·모가 나오면 한 번 더 던지고, 나온 결과로 말을 움직여요.', + }, + { + pic: ( + + + + + ), + alt: '빨간 말이 파란 말을 잡고, 다른 빨간 말 두 개는 업혀 있는 윷판', + title: '잡기와 업기', + text: '상대 말이 있는 자리에 멈추면 잡아서 처음으로 보내고 한 번 더 던져요. 내 말 자리에 멈추면 업혀서 같이 가요.', + }, + { + pic: ( + + + + ), + alt: '오른쪽 위 모서리에서 가운데 방을 지나 출발점으로 가는 지름길', + title: '지름길로 빨리 나가요', + text: '모서리나 가운데 방에 멈췄다가 출발하면 안쪽 지름길로 가요. 내 말을 모두 먼저 판 밖으로 내보내면 이겨요.', + }, +]; diff --git a/docs/07-ui-ux.md b/docs/07-ui-ux.md index 120d68a..60bf122 100644 --- a/docs/07-ui-ux.md +++ b/docs/07-ui-ux.md @@ -116,8 +116,13 @@ ## 5. 초보자 도움 - 처음 하는 게임이면(기기 기준) 시작할 때 "30초 규칙 요약" 카드 3장(그림 위주), [건너뛰기]. + - 구현: 게임이 진행 중인 화면을 이 기기에서 처음 볼 때(시작하거나 진행 중인 방에 들어올 때, 관전 포함) 카드 3장을 띄운다. [다음]·[건너뛰기], 마지막 장은 [시작하기]. 본 게임은 `localStorage` `bg:qs:<게임id>`에 기억한다. 규칙 페이지의 [30초 요약 보기], 게임 중 [?]의 [30초 요약 다시 보기]로 언제든 다시 본다. + - 그림은 이미지 파일 없이 기존 카드 컴포넌트(트럼프·화투·마작패 등)나 작은 인라인 SVG·이모지로 그리고, 그림마다 대체 글(`alt`)을 단다. - 게임 중 [?] → 지금 상황에서 할 수 있는 것 설명("지금은 낼 카드를 고르는 차례예요. 밝게 보이는 카드만 낼 수 있어요"). + - 구현: 차례 안내 줄 오른쪽의 [? 도움] → "지금 할 수 있는 것" 시트. 게임마다 지금 화면(view)만 보고 문장을 만든다(숨은 정보는 쓰지 않음). 시트에서 [전체 규칙 보기](그 게임의 규칙 설명을 펼침)와 [30초 요약 다시 보기]로 이어진다. 관전자에게는 "구경하고 있어요"를 먼저 알려 준다. + - 내용은 게임별 `apps/web/src/games//quick.tsx`(카드 3장)·`help.ts`(`helpNow`)에 있고 게임 화면 묶음과 함께 필요할 때만 내려받는다. - 족보·점수가 복잡한 게임(섯다, 포커, 고스톱, 마작)은 내 패 옆에 현재 족보 이름 표시("지금: 7끗", "투페어"), 족보표 버튼. + - 구현 확인: 섯다·포커는 "지금: <족보>"(방 설정 힌트가 켜졌을 때)와 족보표, 고스톱은 내 점수 내역(광·열끗·띠·피 진행, 점수 항목, 고 횟수)과 족보표, 마작은 대기패·버리면 텐파이 표시(초보 모드)와 역 목록. [?] 설명에도 지금 족보·점수·대기패를 함께 보여 준다. - 힌트 옵션(방 설정): 끄면 하이라이트만, 켜면 추천 수 표시(초보 방). ## 6. 소리·진동