diff --git a/apps/web/src/components/GameHelp.tsx b/apps/web/src/components/GameHelp.tsx new file mode 100644 index 0000000..f8d09ee --- /dev/null +++ b/apps/web/src/components/GameHelp.tsx @@ -0,0 +1,156 @@ +/** + * 초보자 도움 (docs/07 §5): "30초 규칙 요약" cards (first game of a kind on this device) and the + * in-game [?] sheet ("지금 할 수 있는 것" + full rules + summary again). Content lives in each game's chunk. + */ +import { useCallback, useEffect, useState } from 'react'; +import { createPortal } from 'react-dom'; +import type { PublicUser, RoomView } from '@bg/shared/lite'; +import { Sheet } from './ui'; +import { useRoom } from '../net/socket'; +import type { GameUI, QuickCard } from '../games/types'; + +const seenKey = (gameId: string) => `bg:qs:${gameId}`; + +export function quickStartSeen(gameId: string): boolean { + try { + return localStorage.getItem(seenKey(gameId)) === '1'; + } catch { + return true; // storage blocked: don't nag every game + } +} + +function markQuickStartSeen(gameId: string) { + try { + localStorage.setItem(seenKey(gameId), '1'); + } catch { + /* ignore */ + } +} + +/** The 3 summary cards with [다음] / [건너뛰기] / [시작하기]. */ +export function QuickStartCards({ title, cards, onClose, finish = '시작하기' }: { title: string; cards: QuickCard[]; onClose: () => void; finish?: string }) { + const [i, setI] = useState(0); + const card = cards[i]!; + const last = i === cards.length - 1; + return ( + +

+ {title} 30초 규칙 요약 · {i + 1}/{cards.length} +

+
+ {card.pic} +
+

+ {card.title} +

+

{card.text}

+
+ {cards.map((_, k) => ( + + ))} +
+ {last ? ( + + ) : ( +
+ + +
+ )} +
+ ); +} + +/** "30초 요약 보기" button for places outside a game (rules page). */ +export function QuickStartButton({ title, cards }: { title: string; cards: QuickCard[] | undefined }) { + const [open, setOpen] = useState(false); + const close = useCallback(() => setOpen(false), []); + if (!cards?.length) return null; + return ( + <> + + {open && } + + ); +} + +/** + * [?] button for the room bar while a game is on screen. Also opens the summary cards by itself + * the first time this device sees a running game of this kind. + */ +export function GameHelp({ room, ui, title, me, players }: { room: RoomView; ui: GameUI; title: string; me: string; players: Record }) { + const view = useRoom((s) => s.game?.view); + const [open, setOpen] = useState(false); + const [rules, setRules] = useState(false); + const [cards, setCards] = useState(false); + const playing = room.status === 'playing'; + const gameId = room.gameId; + + useEffect(() => { + if (playing && ui.quickStart?.length && !quickStartSeen(gameId)) setCards(true); + }, [playing, gameId, ui]); + + // Stable callbacks: Sheet re-runs its focus effect when onClose changes (every game update otherwise). + const closeCards = useCallback(() => { + markQuickStartSeen(gameId); + setCards(false); + }, [gameId]); + const closeHelp = useCallback(() => setOpen(false), []); + const seated = room.seats.some((s) => s?.user.id === me); + const now = + room.status === 'paused' + ? '오류로 게임이 멈췄어요. 방장이 메뉴에서 게임을 끝낼 수 있어요.' + : view === undefined + ? '' + : (ui.helpNow ?? ui.statusText)(view, me, players); + + return ( + <> + + {open && + createPortal( + +

지금 할 수 있는 것

+ {!seated &&

지금은 구경하고 있어요. 게임에는 손댈 수 없어요.

} +

+ {now} +

+
+ + {rules && ( +
+ +
+ )} + {!!ui.quickStart?.length && ( + + )} + +
+
, + document.body, + )} + {cards && ui.quickStart && createPortal(, document.body)} + + ); +} diff --git a/apps/web/src/games/help-kit.test-util.ts b/apps/web/src/games/help-kit.test-util.ts new file mode 100644 index 0000000..958ee4a --- /dev/null +++ b/apps/web/src/games/help-kit.test-util.ts @@ -0,0 +1,66 @@ +/** + * Test helpers for the beginner help (docs/07 §5): real views from the rule modules, so help text + * is checked against the actual view shape. Only imported by *.test.ts files. + */ +import { GAMES } from '@bg/games'; +import type { PublicUser } from '@bg/shared/lite'; +import { SeededRng, type RngState } from '../../../../packages/engine/src/rng'; + +export const NICKS = ['호랑이', '고양이', '토끼', '거북이', '여우', '사슴', '곰', '부엉이', '다람쥐', '오리']; + +/** Player ids are 'A', 'B', ...; nicknames from NICKS. */ +export function seatIds(n: number): string[] { + return Array.from({ length: n }, (_, i) => String.fromCharCode(65 + i)); +} + +export function usersOf(ids: string[]): Record { + return Object.fromEntries(ids.map((id, i) => [id, { id, nickname: NICKS[i % NICKS.length]!, avatar: null, kind: 'guest' as const }])); +} + +/** Fresh state for a game with `n` players and options merged over the defaults. */ +export function setupGame(id: string, n: number, options: Record = {}, seed: RngState = [1, 2, 3, 4], now = 1_700_000_000_000) { + const def = GAMES[id]!; + const players = seatIds(n); + const opts = def.optionsSchema.parse({ ...(def.defaultOptions as object), ...options }); + const rng = SeededRng.fromSeed(seed); + const state = def.setup({ players, options: opts, rng, now }); + return { def, players, rng, state: state as any, now }; +} + +/** Apply an action (validated) and return the new state. */ +export function applyAction(id: string, state: any, actor: string, action: unknown, rng = SeededRng.fromSeed([9, 9, 9, 9]), now = 1_700_000_100_000): any { + const def = GAMES[id]!; + const v = def.validate(state, actor, action, { now }); + if (!v.ok) throw new Error(`${id}: rejected ${JSON.stringify(action)}: ${v.reason}`); + return def.apply(state, actor, action, { rng, now }).state; +} + +/** + * Random playout that hands every intermediate view (each player + spectator) to `visit`. + * Uses legal actions when the module lists them, otherwise the timeout action. + */ +export function eachView(id: string, n: number, visit: (view: any, viewer: string | null, state: any) => void, { steps = 400, seed = [5, 6, 7, 8] as RngState, options = {} as Record } = {}) { + const { def, players, rng, state: s0 } = setupGame(id, n, options, seed); + const chooser = SeededRng.fromSeed([seed[3], seed[2], seed[1], seed[0] ^ 0x9e3779b9]); + let state = s0; + let now = 1_700_000_000_000; + for (let step = 0; step <= steps; step++) { + for (const p of [...players, null]) visit(def.view(state, p), p, state); + if (def.result(state)) return; + const actives = def.activePlayers(state); + if (actives.length === 0) return; + const actor = actives[chooser.int(actives.length)]!; + const legal = def.legalActions?.(state, actor) ?? []; + const action = legal.length > 0 ? legal[chooser.int(legal.length)] : def.onTimeout(state, actor); + now += 1000; + if (!def.validate(state, actor, action, { now }).ok) return; + state = def.apply(state, actor, action, { rng, now }).state; + } +} + +/** Help text must read as finished Korean: no leaked placeholders. */ +export function assertCleanText(text: string, where: string) { + if (typeof text !== 'string' || text.trim().length === 0) throw new Error(`${where}: empty help`); + const bad = /undefined|NaN|null|\[object|\$\{|님님/.exec(text); + if (bad) throw new Error(`${where}: "${bad[0]}" in help: ${text}`); +} diff --git a/apps/web/src/games/help.test.ts b/apps/web/src/games/help.test.ts new file mode 100644 index 0000000..6d2f4ea --- /dev/null +++ b/apps/web/src/games/help.test.ts @@ -0,0 +1,47 @@ +/** Beginner help (docs/07 §5) for every game: 3 summary cards, and help text on every view of a random game. */ +import { describe, expect, test } from 'bun:test'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { GAMES } from '@bg/games'; +import { GAME_UI } from './registry'; +import { assertCleanText, eachView, seatIds, usersOf } from './help-kit.test-util'; + +const IDS = Object.keys(GAME_UI); + +test('모든 게임에 도움말이 있어요', () => { + expect(IDS).toHaveLength(26); + for (const id of IDS) { + const src = readFileSync(join(import.meta.dir, id, 'index.tsx'), 'utf8'); + expect(src, id).toMatch(/\bquickStart,/); + expect(src, id).toMatch(/\bhelpNow,/); + } +}); + +describe.each(IDS)('%s', (id) => { + test('30초 요약 카드 3장', async () => { + const { quickStart } = await import(`./${id}/quick`); + expect(quickStart).toHaveLength(3); + for (const c of quickStart) { + expect(c.pic).toBeTruthy(); + expect(c.alt.length).toBeGreaterThan(4); + expect(c.title.length).toBeGreaterThan(1); + expect(c.title.length).toBeLessThanOrEqual(20); + expect(c.text.length).toBeGreaterThan(10); + expect(c.text.length).toBeLessThanOrEqual(110); + } + }); + + test('무작위 한 판의 모든 화면(각 자리 + 관전)에서 지금 할 수 있는 것', async () => { + 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)); + const users = usersOf(seatIds(n)); + let count = 0; + eachView(id, n, (view, who) => { + assertCleanText(helpNow(view, who, users), `${id} viewer=${who}`); + count++; + }); + expect(count).toBeGreaterThan(n); + }); +}); diff --git a/apps/web/src/games/omok/help.test.ts b/apps/web/src/games/omok/help.test.ts new file mode 100644 index 0000000..8b0f76e --- /dev/null +++ b/apps/web/src/games/omok/help.test.ts @@ -0,0 +1,26 @@ +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.omok!.view(s, who) as any; + +test('오목: 내 차례 / 상대 차례 / 관전 / 첫 수 가운데', () => { + const { state } = setupGame('omok', 2, { colorAssignment: 'hostBlack', ruleSet: 'renju' }); + expect(helpNow(view(state, 'A'), 'A', users)).toContain('첫 수는 판 한가운데'); + expect(helpNow(view(state, 'B'), 'B', users)).toContain('상대(호랑이님) 차례예요'); + expect(helpNow(view(state, null), null, users)).toContain('호랑이님(흑) 차례예요'); + const s2 = applyAction('omok', state, 'A', { type: 'place', x: 7, y: 7 }); + const t = helpNow(view(s2, 'B'), 'B', users); + expect(t).toContain('내 차례예요(백)'); + expect(t).toContain('5개'); +}); + +test('오목: 무르기 요청을 받으면 수락/거절 안내', () => { + const { state } = setupGame('omok', 2, { colorAssignment: 'hostBlack', ruleSet: 'free' }); + let s = applyAction('omok', state, 'A', { type: 'place', x: 7, y: 7 }); + s = applyAction('omok', 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/omok/help.ts b/apps/web/src/games/omok/help.ts new file mode 100644 index 0000000..1267054 --- /dev/null +++ b/apps/web/src/games/omok/help.ts @@ -0,0 +1,25 @@ +/** 오목 [?] "지금 할 수 있는 것" (docs/07 §5). Pure: only reads the client view. */ +import type { OmokView } from '@bg/games/omok'; +import type { PublicUser } from '@bg/shared/lite'; + +const COLOR_KO = { black: '흑', white: '백' } as const; + +export function helpNow(v: OmokView, _me: string | null, players: Record): string { + const goal = '가로·세로·대각선 중 한 줄로 내 돌 5개를 먼저 이으면 이겨요.'; + 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]}) 차례예요. ${goal}`; + 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 '상대가 무승부를 제안했어요. [수락]하면 이 판은 비기고 끝나요. 계속하려면 [거절]을 눌러 주세요.'; + if (v.myColor !== v.turn) { + const waiting = v.undo.pending ? ' 내 무르기 요청에 상대가 답하기를 기다리는 중이에요.' : v.draw.pendingBy ? ' 내 무승부 제안에 상대가 답하기를 기다리는 중이에요.' : ''; + return `상대(${turnNick}님) 차례예요. 상대가 돌을 놓을 때까지 기다려 주세요.${waiting} 나는 ${mine}이에요.`; + } + if (v.moveCount === 0 && v.firstMoveCenter) return `내 차례예요(${mine}). 첫 수는 판 한가운데에만 놓을 수 있어요.`; + const lines = [`내 차례예요(${mine}). 빈 곳을 눌러 돌을 놓아요. ${goal}`]; + if (v.hints?.urgent.length) lines.push('빨간 동그라미 자리를 막지 않으면 상대가 다음에 5개를 이어요!'); + else if (v.hints?.warn.length) lines.push('노란 점선 동그라미는 상대가 4개를 만들 수 있는 자리예요. 막는 게 좋아요.'); + if (v.forbidden.length) lines.push('빨간 ✕ 자리는 금수라서 둘 수 없어요.'); + return lines.join('\n'); +} diff --git a/apps/web/src/games/omok/index.tsx b/apps/web/src/games/omok/index.tsx index 3900c86..6af1309 100644 --- a/apps/web/src/games/omok/index.tsx +++ b/apps/web/src/games/omok/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: '백' }; @@ -483,6 +485,8 @@ const ui: GameUI = { Options, optionsSummary, Rules, + quickStart, + helpNow, }; export default ui; diff --git a/apps/web/src/games/omok/quick.tsx b/apps/web/src/games/omok/quick.tsx new file mode 100644 index 0000000..da74d41 --- /dev/null +++ b/apps/web/src/games/omok/quick.tsx @@ -0,0 +1,47 @@ +/** 오목 30초 규칙 요약 (docs/07 §5). */ +import type { QuickCard } from '../types'; + +function MiniBoard({ stones, marks = [] }: { stones: [number, number, 'b' | 'w'][]; marks?: [number, number][] }) { + const n = 7; + return ( + + + {Array.from({ length: n }, (_, i) => ( + + + + + ))} + {stones.map(([x, y, c]) => ( + + ))} + {marks.map(([x, y]) => ( + + + + + ))} + + ); +} + +export const quickStart: QuickCard[] = [ + { + pic: , + alt: '바둑판 위에 검은 돌과 흰 돌이 번갈아 놓여 있어요', + title: '번갈아 한 개씩 놓아요', + text: '흑이 먼저 시작해요. 내 차례에 빈 곳(줄이 만나는 점)을 누르면 돌이 놓여요.', + }, + { + pic: , + alt: '검은 돌 5개가 대각선으로 이어져 있어요', + title: '5개를 이으면 이겨요', + text: '가로·세로·대각선 어느 쪽이든 내 돌 5개를 먼저 한 줄로 이으면 이겨요.', + }, + { + pic: , + alt: '빨간 ✕ 표시가 있는 자리는 둘 수 없어요', + title: '✕ 자리는 못 둬요', + text: '규칙에 따라 둘 수 없는 자리(금수)는 ✕로 보여 줘요. 상대가 4개를 이으면 꼭 막아요.', + }, +]; diff --git a/apps/web/src/games/types.ts b/apps/web/src/games/types.ts index 18382c4..2e707a1 100644 --- a/apps/web/src/games/types.ts +++ b/apps/web/src/games/types.ts @@ -1,4 +1,4 @@ -import type { ComponentType } from 'react'; +import type { ComponentType, ReactNode } from 'react'; import type { PublicUser } from '@bg/shared/lite'; export interface BoardProps { @@ -17,6 +17,16 @@ export interface OptionsProps { onChange: (next: O) => void; } +/** One card of the "30초 규칙 요약" (docs/07 §5): picture first, a sentence or two. */ +export interface QuickCard { + /** Picture: inline SVG / existing card components (no image files). Decorative; `alt` describes it. */ + pic: ReactNode; + /** What the picture shows, for screen readers. */ + alt: string; + title: string; + text: string; +} + export interface GameUI { /** The board / table only (center of the game screen). */ Board: ComponentType>; @@ -29,4 +39,8 @@ export interface GameUI { Rules: ComponentType<{ options?: O }>; /** The board has its own chat channels; the room chat panel is hidden while the game runs (마피아). */ ownChat?: boolean; + /** 30초 규칙 요약: 3 cards shown the first time this device plays the game (docs/07 §5). */ + quickStart?: QuickCard[]; + /** [?] "지금 할 수 있는 것": the current situation in plain words for this viewer (docs/07 §5). */ + helpNow?(view: V, me: string | null, players: Record): string; } diff --git a/apps/web/src/pages/RoomPage.tsx b/apps/web/src/pages/RoomPage.tsx index 5f4ac00..3c1bf34 100644 --- a/apps/web/src/pages/RoomPage.tsx +++ b/apps/web/src/pages/RoomPage.tsx @@ -14,6 +14,7 @@ import type { GameUI } from '../games/types'; import { sounds } from '../lib/feedback'; import { ChatPanel, EmoteBubbles, useChatSeen } from '../components/ChatPanel'; import { useWide } from '../lib/media'; +import { GameHelp } from '../components/GameHelp'; export default function RoomPage() { const { code = '' } = useParams(); @@ -159,6 +160,7 @@ function Room({ code, me }: { code: string; me: PublicUser }) { 방 🔗} done="링크 복사됨 ✓" className="btn btn-ghost btn-sm" /> + {inGame && game && } {!roomChatClosed && ( setChatOpen(true)} /> diff --git a/apps/web/src/pages/RulesPage.tsx b/apps/web/src/pages/RulesPage.tsx index 3418466..9ec9522 100644 --- a/apps/web/src/pages/RulesPage.tsx +++ b/apps/web/src/pages/RulesPage.tsx @@ -4,6 +4,7 @@ import { TopBar } from '../components/ui'; import { loadGameUI } from '../games/registry'; import type { GameUI } from '../games/types'; import { GameNotice, gameName } from '../components/GameNotice'; +import { QuickStartButton } from '../components/GameHelp'; export default function RulesPage() { const { gameId = '' } = useParams(); @@ -18,6 +19,7 @@ export default function RulesPage() { nav(-1)} />
+ {ui && } {ui === undefined ?

불러오는 중…

: ui ? :

아직 준비 중인 게임이에요.

}