M9: 게임 26종 30초 요약 카드·[?] 지금 할 수 있는 것 내용
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
53
apps/web/src/games/chess/help.test.ts
Normal file
53
apps/web/src/games/chess/help.test.ts
Normal 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('게임이 끝났어요. 무승부');
|
||||
});
|
||||
51
apps/web/src/games/chess/help.ts
Normal file
51
apps/web/src/games/chess/help.ts
Normal 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');
|
||||
}
|
||||
@@ -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<ChessView, ChessOptions> = {
|
||||
Options,
|
||||
optionsSummary,
|
||||
Rules,
|
||||
quickStart,
|
||||
helpNow,
|
||||
};
|
||||
|
||||
export default ui;
|
||||
|
||||
77
apps/web/src/games/chess/quick.tsx
Normal file
77
apps/web/src/games/chess/quick.tsx
Normal 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: '폰이 끝 줄에 닿으면 퀸 같은 강한 기물로 바뀌어요(승진). 내 킹이 공격받으면(체크) 꼭 먼저 피하거나 막아야 해요.',
|
||||
},
|
||||
];
|
||||
Reference in New Issue
Block a user