M9: 초보자 도움 틀 — 30초 규칙 요약 카드, 게임 중 [?] 지금 할 수 있는 것, 오목 내용

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
EJClaw
2026-10-06 03:04:24 +09:00
parent c27cb0a11a
commit 57753a037c
10 changed files with 390 additions and 1 deletions

View File

@@ -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 (
<Sheet onClose={onClose} label={`${title} 30초 규칙 요약`}>
<p className="muted small" style={{ margin: 0, textAlign: 'center' }}>
{title} 30초 규칙 요약 · {i + 1}/{cards.length}
</p>
<div
role="img"
aria-label={card.alt}
style={{ minHeight: 170, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8, flexWrap: 'wrap', padding: 12, borderRadius: 16, background: 'var(--bg)' }}
>
{card.pic}
</div>
<h2 style={{ margin: 0, textAlign: 'center' }} aria-live="polite">
{card.title}
</h2>
<p style={{ margin: 0, fontSize: '1.15rem', lineHeight: 1.6, textAlign: 'center' }}>{card.text}</p>
<div className="row" style={{ justifyContent: 'center', gap: 6 }} aria-hidden>
{cards.map((_, k) => (
<span key={k} className="dot" style={{ width: 10, height: 10, background: k === i ? 'var(--primary)' : 'var(--border)' }} />
))}
</div>
{last ? (
<button className="btn btn-primary btn-block" style={{ minHeight: 60 }} onClick={onClose} autoFocus>
{finish}
</button>
) : (
<div className="row" style={{ flexWrap: 'nowrap' }}>
<button className="btn btn-secondary" style={{ flex: 1 }} onClick={onClose}>
건너뛰기
</button>
<button className="btn btn-primary" style={{ flex: 2, minHeight: 60 }} onClick={() => setI(i + 1)}>
다음 →
</button>
</div>
)}
</Sheet>
);
}
/** "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 (
<>
<button className="btn btn-secondary btn-block" onClick={() => setOpen(true)}>
🃏 30초 요약 보기
</button>
{open && <QuickStartCards title={title} cards={cards} finish="닫기" onClose={close} />}
</>
);
}
/**
* [?] 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<string, PublicUser> }) {
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 (
<>
<button className="btn btn-ghost btn-sm" onClick={() => (setRules(false), setOpen(true))} aria-label="도움말: 지금 할 수 있는 것" aria-haspopup="dialog">
❓ 도움
</button>
{open &&
createPortal(
<Sheet onClose={closeHelp} label="지금 할 수 있는 것">
<h2 style={{ margin: 0 }}>지금 할 수 있는 것</h2>
{!seated && <p className="muted" style={{ margin: 0 }}>지금은 구경하고 있어요. 게임에는 손댈 수 없어요.</p>}
<p className="help-now" style={{ margin: 0, fontSize: '1.2rem', lineHeight: 1.6, whiteSpace: 'pre-line', fontWeight: 600 }}>
{now}
</p>
<div className="stack" style={{ gap: 8 }}>
<button className="btn btn-secondary btn-block" aria-expanded={rules} onClick={() => setRules(!rules)}>
📖 {rules ? '규칙 접기' : '전체 규칙 보기'}
</button>
{rules && (
<section className="card stack" aria-label={`${title} 규칙`}>
<ui.Rules options={room.options} />
</section>
)}
{!!ui.quickStart?.length && (
<button className="btn btn-secondary btn-block" onClick={() => (setOpen(false), setCards(true))}>
🃏 30초 요약 다시 보기
</button>
)}
<button className="btn btn-primary btn-block" onClick={() => setOpen(false)}>
닫기
</button>
</div>
</Sheet>,
document.body,
)}
{cards && ui.quickStart && createPortal(<QuickStartCards title={title} cards={ui.quickStart} onClose={closeCards} />, document.body)}
</>
);
}

View File

@@ -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<string, PublicUser> {
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<string, unknown> = {}, 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<string, unknown> } = {}) {
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}`);
}

View File

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

View File

@@ -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('무르기 요청');
});

View File

@@ -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, PublicUser>): 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');
}

View File

@@ -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<Color, string> = { black: '흑', white: '백' };
@@ -483,6 +485,8 @@ const ui: GameUI<OmokView, OmokOptions> = {
Options,
optionsSummary,
Rules,
quickStart,
helpNow,
};
export default ui;

View File

@@ -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 (
<svg viewBox="-0.7 -0.7 7.4 7.4" width={170} height={170} aria-hidden>
<rect x={-0.7} y={-0.7} width={7.4} height={7.4} rx={0.4} fill="#e9c27a" />
{Array.from({ length: n }, (_, i) => (
<g key={i} stroke="#7a5a2a" strokeWidth={0.05}>
<line x1={0} y1={i} x2={n - 1} y2={i} />
<line x1={i} y1={0} x2={i} y2={n - 1} />
</g>
))}
{stones.map(([x, y, c]) => (
<circle key={`${x}-${y}`} cx={x} cy={y} r={0.44} fill={c === 'b' ? '#151515' : '#fbfbf8'} stroke={c === 'b' ? '#000' : '#666'} strokeWidth={0.06} />
))}
{marks.map(([x, y]) => (
<g key={`m${x}-${y}`} stroke="#b91c1c" strokeWidth={0.12}>
<line x1={x - 0.25} y1={y - 0.25} x2={x + 0.25} y2={y + 0.25} />
<line x1={x + 0.25} y1={y - 0.25} x2={x - 0.25} y2={y + 0.25} />
</g>
))}
</svg>
);
}
export const quickStart: QuickCard[] = [
{
pic: <MiniBoard stones={[[3, 3, 'b'], [4, 3, 'w'], [3, 4, 'b'], [2, 2, 'w']]} />,
alt: '바둑판 위에 검은 돌과 흰 돌이 번갈아 놓여 있어요',
title: '번갈아 한 개씩 놓아요',
text: '흑이 먼저 시작해요. 내 차례에 빈 곳(줄이 만나는 점)을 누르면 돌이 놓여요.',
},
{
pic: <MiniBoard stones={[[1, 5, 'b'], [2, 4, 'b'], [3, 3, 'b'], [4, 2, 'b'], [5, 1, 'b'], [2, 3, 'w'], [4, 4, 'w']]} />,
alt: '검은 돌 5개가 대각선으로 이어져 있어요',
title: '5개를 이으면 이겨요',
text: '가로·세로·대각선 어느 쪽이든 내 돌 5개를 먼저 한 줄로 이으면 이겨요.',
},
{
pic: <MiniBoard stones={[[2, 3, 'b'], [3, 3, 'b'], [4, 4, 'b'], [4, 5, 'b'], [1, 1, 'w'], [5, 1, 'w']]} marks={[[4, 3]]} />,
alt: '빨간 ✕ 표시가 있는 자리는 둘 수 없어요',
title: '✕ 자리는 못 둬요',
text: '규칙에 따라 둘 수 없는 자리(금수)는 ✕로 보여 줘요. 상대가 4개를 이으면 꼭 막아요.',
},
];

View File

@@ -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<V = unknown> {
@@ -17,6 +17,16 @@ export interface OptionsProps<O = unknown> {
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<V = any, O = any> {
/** The board / table only (center of the game screen). */
Board: ComponentType<BoardProps<V>>;
@@ -29,4 +39,8 @@ export interface GameUI<V = any, O = any> {
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, PublicUser>): string;
}

View File

@@ -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 }) {
</strong>
<CopyButton text={`${location.origin}/r/${code}`} label={<>방 <CodeLabel code={code} /> 🔗</>} done="링크 복사됨 ✓" className="btn btn-ghost btn-sm" />
<span style={{ flex: 1 }} />
{inGame && game && <GameHelp room={room} ui={ui} title={g.nameKo} me={me.id} players={players} />}
{!roomChatClosed && (
<span className="phone-only">
<ChatButton onOpen={() => setChatOpen(true)} />

View File

@@ -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() {
<TopBar title={`${g?.nameKo ?? ''} 규칙`} onBack={() => nav(-1)} />
<main className="page">
<GameNotice gameId={gameId} />
{ui && <QuickStartButton title={g.nameKo} cards={ui.quickStart} />}
{ui === undefined ? <p className="muted">불러오는 중…</p> : ui ? <ui.Rules /> : <p>아직 준비 중인 게임이에요.</p>}
</main>
</>