Merge branch 'm9/tutorial' into codex/owner/grp_discord_joke-app

This commit is contained in:
EJClaw
2026-10-06 13:35:23 +09:00
114 changed files with 7104 additions and 3 deletions

View File

@@ -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('게임이 끝났어요. 무승부');
});

View File

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

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 = 'white' | 'black';
type PieceType = 'k' | 'q' | 'r' | 'b' | 'n' | 'p';
@@ -909,6 +911,8 @@ const ui: GameUI<ChessView, ChessOptions> = {
Options,
optionsSummary,
Rules,
quickStart,
helpNow,
};
export default ui;

View File

@@ -0,0 +1,77 @@
/** 체스 30초 규칙 요약 (docs/07 §5). */
import type { QuickCard } from '../types';
const GLYPH: Record<string, string> = { 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 (
<text
x={x + 0.5}
y={y + 0.56}
fontSize={0.82}
textAnchor="middle"
dominantBaseline="central"
fontFamily="'Segoe UI Symbol', 'Noto Sans Symbols 2', 'DejaVu Sans', serif"
fill={white ? '#fbfbf8' : '#1b1b1b'}
stroke={white ? '#333' : '#000'}
strokeWidth={0.03}
>
{GLYPH[t.toLowerCase()]}
{'︎'}
</text>
);
}
/** 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 (
<svg viewBox={`0 0 ${w} ${h}`} width={w * scale} height={h * scale} aria-hidden style={{ borderRadius: 6 }}>
{rows.map((r, y) =>
r.split('').map((_, x) => <rect key={`${x}-${y}`} x={x} y={y} width={1} height={1} fill={(x + y) % 2 === 0 ? '#f0d9b5' : '#b58863'} />),
)}
{check && <rect x={check[0]} y={check[1]} width={1} height={1} fill="#ef4444" opacity={0.7} />}
{dots.map(([x, y]) => (
<circle key={`d${x}-${y}`} cx={x + 0.5} cy={y + 0.5} r={0.16} fill="rgba(20,85,30,0.55)" />
))}
{rows.map((r, y) => r.split('').map((c, x) => (c === '.' ? null : <Piece key={`p${x}-${y}`} t={c} x={x} y={y} />)))}
</svg>
);
}
const Arrow = () => (
<span aria-hidden style={{ fontSize: 28, fontWeight: 800 }}>
→
</span>
);
export const quickStart: QuickCard[] = [
{
pic: <MiniBoard rows={['R.k.', '.ppp']} check={[2, 0]} size={200} />,
alt: '흰 룩이 검은 킹을 공격하고, 킹은 자기 폰에 막혀 피할 곳이 없어요',
title: '킹을 꼼짝 못 하게',
text: '백이 먼저 두고 번갈아 한 수씩 둬요. 상대 킹을 공격해서 피할 곳이 없게 만들면(체크메이트) 이겨요.',
},
{
pic: <MiniBoard rows={['.....', '.....', '..N..', '.....', '.....']} dots={[[1, 0], [3, 0], [0, 1], [4, 1], [0, 3], [4, 3], [1, 4], [3, 4]]} size={150} />,
alt: '가운데 나이트 둘레로 ㄱ자 모양 여덟 칸에 점이 찍혀 있어요',
title: '기물마다 길이 달라요',
text: '기물을 누르면 갈 수 있는 칸에 점이 찍혀요. 그 칸을 누르면 움직여요. 상대 기물이 있는 칸으로 가면 잡아요.',
},
{
pic: (
<>
<MiniBoard rows={['.', 'P']} size={90} />
<Arrow />
<MiniBoard rows={['Q', '.']} size={90} />
</>
),
alt: '흰 폰이 끝 줄에 닿아 퀸으로 바뀌어요',
title: '폰이 끝까지 가면 변신',
text: '폰이 끝 줄에 닿으면 퀸 같은 강한 기물로 바뀌어요(승진). 내 킹이 공격받으면(체크) 꼭 먼저 피하거나 막아야 해요.',
},
];