위협 가이드 제거: 오목 위험 자리, 체스·장기 위험 기물 경고, 바둑 단수 경고

- 오목: 상대 3·4를 막으라는 빨간 동그라미·노란 점선(hints 옵션, view.hints, threatHints) 삭제
- 체스·장기: hints 'movesAndThreats'(주황 점선 위험 기물) 삭제 → 'off' / 'moves'만 남김
- 바둑: atariWarning 옵션과 atariWarnings(깜빡이는 단수 돌) 삭제
- 도움말 문구·설정 화면·docs 정리, 테스트는 "어떤 view에도 위협 표시 없음"으로 바꿈
- 갈 수 있는 칸 점, 금수 ×, 둘 수 없는 점 같은 규칙 표시는 그대로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
EJClaw
2026-10-07 15:34:58 +09:00
parent 53fbbc1c9d
commit 4cb71d8cbe
22 changed files with 40 additions and 135 deletions

View File

@@ -69,7 +69,6 @@ export function helpNow(v: BadukView, _me: string | null, players: Record<string
lines.push(`내 차례예요(${mine}). 상대가 패스했어요. 더 둘 곳이 없으면 나도 [✋ 패스]를 눌러요. 그러면 대국이 멈추고 사석 확인으로 넘어가요. 더 둘 곳이 있으면 계속 둬도 돼요.`); lines.push(`내 차례예요(${mine}). 상대가 패스했어요. 더 둘 곳이 없으면 나도 [✋ 패스]를 눌러요. 그러면 대국이 멈추고 사석 확인으로 넘어가요. 더 둘 곳이 있으면 계속 둬도 돼요.`);
if (v.rules.scoring === 'area') lines.push('이 방은 영역 계산이라, 끝내기 전에 양쪽 사이의 빈 곳(공배)을 메워 두는 게 좋아요.'); if (v.rules.scoring === 'area') lines.push('이 방은 영역 계산이라, 끝내기 전에 양쪽 사이의 빈 곳(공배)을 메워 두는 게 좋아요.');
} else lines.push(`내 차례예요(${mine}). 선이 만나는 빈 점을 누르면 돌이 놓여요. 둘 곳이 없으면 [✋ 패스]를 눌러요.`); } else lines.push(`내 차례예요(${mine}). 선이 만나는 빈 점을 누르면 돌이 놓여요. 둘 곳이 없으면 [✋ 패스]를 눌러요.`);
if (v.atariWarnings.length) lines.push('빨갛게 깜빡이는 내 돌은 단수예요(숨 쉴 곳이 하나만 남음). 돌을 잇거나 달아나지 않으면 잡혀요.');
if (v.undo.pending || v.draw.pendingBy) lines.push(`내 ${v.undo.pending ? '무르기 요청' : '무승부 제안'}에 상대가 답하기를 기다리는 중이에요. 그만두려면 [요청 취소]를 눌러요.`); if (v.undo.pending || v.draw.pendingBy) lines.push(`내 ${v.undo.pending ? '무르기 요청' : '무승부 제안'}에 상대가 답하기를 기다리는 중이에요. 그만두려면 [요청 취소]를 눌러요.`);
if (v.illegalPoints.some((p) => p.reason !== 'suicide')) lines.push('빨간 네모 자리는 패(바로 되따내기 금지)나 같은 모양 반복 때문에 지금은 둘 수 없어요. 다른 곳에 한 수 둔 뒤에 다시 봐요.'); if (v.illegalPoints.some((p) => p.reason !== 'suicide')) lines.push('빨간 네모 자리는 패(바로 되따내기 금지)나 같은 모양 반복 때문에 지금은 둘 수 없어요. 다른 곳에 한 수 둔 뒤에 다시 봐요.');
lines.push(caps); lines.push(caps);

View File

@@ -279,12 +279,6 @@ function BadukBoard({ view: v, act, pending, tapConfirm }: BoardProps<BadukView>
{v.lastMove && lastColor && ( {v.lastMove && lastColor && (
<circle cx={v.lastMove.x} cy={v.lastMove.y} r={0.2} fill="none" stroke={lastColor === 'black' ? '#fff' : '#111'} strokeWidth={0.08} /> <circle cx={v.lastMove.x} cy={v.lastMove.y} r={0.2} fill="none" stroke={lastColor === 'black' ? '#fff' : '#111'} strokeWidth={0.08} />
)} )}
{myTurn &&
v.atariWarnings.map((p) => (
<circle key={`a${p.x}-${p.y}`} cx={p.x} cy={p.y} r={0.5} fill="none" stroke="#dc2626" strokeWidth={0.08}>
<animate attributeName="opacity" values="1;0.25;1" dur="1.2s" repeatCount="indefinite" />
</circle>
))}
{ghost && v.myColor && <Stone color={v.myColor} cx={ghost.x} cy={ghost.y} r={0.47} ghost />} {ghost && v.myColor && <Stone color={v.myColor} cx={ghost.x} cy={ghost.y} r={0.47} ghost />}
{hoverMark && <circle cx={hoverMark.x} cy={hoverMark.y} r={0.5} fill="none" stroke="var(--focus)" strokeWidth={0.07} strokeDasharray="0.15 0.1" />} {hoverMark && <circle cx={hoverMark.x} cy={hoverMark.y} r={0.5} fill="none" stroke="var(--focus)" strokeWidth={0.07} strokeDasharray="0.15 0.1" />}
{(myTurn || marking) && kbd && <rect x={cursor.x - 0.5} y={cursor.y - 0.5} width={1} height={1} fill="none" stroke="var(--focus)" strokeWidth={0.06} />} {(myTurn || marking) && kbd && <rect x={cursor.x - 0.5} y={cursor.y - 0.5} width={1} height={1} fill="none" stroke="var(--focus)" strokeWidth={0.06} />}
@@ -639,7 +633,6 @@ function statusText(v: BadukView, _me: string | null, players: Record<string, Pu
if (v.myColor && v.draw.pendingBy && v.draw.pendingBy !== v.myColor) return '상대가 무승부를 제안했어요'; if (v.myColor && v.draw.pendingBy && v.draw.pendingBy !== v.myColor) return '상대가 무승부를 제안했어요';
if (v.myColor === v.turn) { if (v.myColor === v.turn) {
if (v.consecutivePasses > 0) return '상대가 패스했어요. 끝내려면 패스하세요'; if (v.consecutivePasses > 0) return '상대가 패스했어요. 끝내려면 패스하세요';
if (v.atariWarnings.length) return `내 차례예요 (${COLOR_KO[v.turn]}). 단수에 몰린 돌이 있어요!`;
return `내 차례예요 (${COLOR_KO[v.turn]}). 둘 곳을 눌러 주세요`; return `내 차례예요 (${COLOR_KO[v.turn]}). 둘 곳을 눌러 주세요`;
} }
const nick = players[v.players[v.turn]]?.nickname ?? COLOR_KO[v.turn]; const nick = players[v.players[v.turn]]?.nickname ?? COLOR_KO[v.turn];
@@ -784,8 +777,7 @@ function Options({ value: o, onChange }: OptionsProps<BadukOptions>) {
<Seg value={o.undo} options={[['off', '안 씀'], ['1', '1번'], ['3', '3번'], ['unlimited', '무제한']]} onChange={(undo) => set({ undo })} /> <Seg value={o.undo} options={[['off', '안 씀'], ['1', '1번'], ['3', '3번'], ['unlimited', '무제한']]} onChange={(undo) => set({ undo })} />
</div> </div>
<div className="stack" style={{ gap: 6 }}> <div className="stack" style={{ gap: 6 }}>
<strong>도움 표시</strong> <strong>무승부 제안</strong>
<Seg value={o.atariWarning ? 'on' : 'off'} options={[['on', '단수 경고 켜기'], ['off', '단수 경고 끄기']]} onChange={(k) => set({ atariWarning: k === 'on' })} />
<Seg value={o.drawOffer ? 'on' : 'off'} options={[['on', '무승부 제안 허용'], ['off', '무승부 제안 안 함']]} onChange={(k) => set({ drawOffer: k === 'on' })} /> <Seg value={o.drawOffer ? 'on' : 'off'} options={[['on', '무승부 제안 허용'], ['off', '무승부 제안 안 함']]} onChange={(k) => set({ drawOffer: k === 'on' })} />
</div> </div>
<div className="stack" style={{ gap: 6 }}> <div className="stack" style={{ gap: 6 }}>

View File

@@ -43,7 +43,6 @@ export function helpNow(v: ChessView, _me: string | null, players: Record<string
if (legal.some((m) => m.promotion)) if (legal.some((m) => m.promotion))
lines.push(v.autoQueen ? '폰을 끝 줄로 보내면 자동으로 퀸이 돼요(승진).' : '폰을 끝 줄로 보내면 퀸·룩·비숍·나이트 중 무엇으로 바꿀지 고르는 창이 떠요(승진). 보통은 퀸이에요.'); lines.push(v.autoQueen ? '폰을 끝 줄로 보내면 자동으로 퀸이 돼요(승진).' : '폰을 끝 줄로 보내면 퀸·룩·비숍·나이트 중 무엇으로 바꿀지 고르는 창이 떠요(승진). 보통은 퀸이에요.');
if (legal.some((m) => m.castle)) lines.push('지금 캐슬링을 할 수 있어요. 킹을 누르면 캐슬링할 칸도 표시돼요.'); if (legal.some((m) => m.castle)) lines.push('지금 캐슬링을 할 수 있어요. 킹을 누르면 캐슬링할 칸도 표시돼요.');
if (v.threatened?.length) lines.push('주황 점선 네모가 쳐진 내 기물은 지켜 주는 기물 없이 공격받고 있어요. 조심하세요.');
if (v.drawMode === 'fide' && v.claimable) if (v.drawMode === 'fide' && v.claimable)
lines.push(`[⚖ 무승부 주장]을 누르면 상대 동의 없이 바로 비길 수 있어요(${v.claimable === 'threefold' ? '같은 국면 3번' : '50수 동안 폰 이동·잡기 없음'}).`); lines.push(`[⚖ 무승부 주장]을 누르면 상대 동의 없이 바로 비길 수 있어요(${v.claimable === 'threefold' ? '같은 국면 3번' : '50수 동안 폰 이동·잡기 없음'}).`);
if (mat) lines.push(mat); if (mat) lines.push(mat);

View File

@@ -328,10 +328,6 @@ function ChessBoard({ view: v, act, pending }: BoardProps<ChessView>) {
{lastFrom && <rect x={lastFrom.x} y={lastFrom.y} width={1} height={1} fill="#cdd26a" opacity={0.75} />} {lastFrom && <rect x={lastFrom.x} y={lastFrom.y} width={1} height={1} fill="#cdd26a" opacity={0.75} />}
{lastTo && <rect x={lastTo.x} y={lastTo.y} width={1} height={1} fill="#aaa23a" opacity={0.6} />} {lastTo && <rect x={lastTo.x} y={lastTo.y} width={1} height={1} fill="#aaa23a" opacity={0.6} />}
{selected && <rect x={cell(selected).x} y={cell(selected).y} width={1} height={1} fill="#4f7a28" opacity={0.55} />} {selected && <rect x={cell(selected).x} y={cell(selected).y} width={1} height={1} fill="#4f7a28" opacity={0.55} />}
{v.threatened?.map((sq) => {
const c = cell(sq);
return <rect key={`t${sq}`} x={c.x + 0.04} y={c.y + 0.04} width={0.92} height={0.92} fill="none" stroke="#f59e0b" strokeWidth={0.07} strokeDasharray="0.14 0.08" />;
})}
{check && <rect x={check.x} y={check.y} width={1} height={1} fill="url(#chess-check)" />} {check && <rect x={check.x} y={check.y} width={1} height={1} fill="url(#chess-check)" />}
{/* Coordinates: files on the bottom row, ranks on the left column. */} {/* Coordinates: files on the bottom row, ranks on the left column. */}
{Array.from({ length: 8 }, (_, i) => ( {Array.from({ length: 8 }, (_, i) => (
@@ -757,7 +753,7 @@ function Options({ value: o, onChange }: OptionsProps<ChessOptions>) {
</div> </div>
<div className="stack" style={{ gap: 6 }}> <div className="stack" style={{ gap: 6 }}>
<strong>초보 도움말</strong> <strong>초보 도움말</strong>
<Seg value={o.hints} options={[['moves', '갈 수 있는 칸 표시'], ['movesAndThreats', '+ 위험한 기물 경고'], ['off', '끄기']]} onChange={(hints) => set({ hints })} /> <Seg value={o.hints} options={[['moves', '갈 수 있는 칸 표시'], ['off', '끄기']]} onChange={(hints) => set({ hints })} />
</div> </div>
<button type="button" className="btn btn-ghost" onClick={() => setMore(!more)} aria-expanded={more}> <button type="button" className="btn btn-ghost" onClick={() => setMore(!more)} aria-expanded={more}>
{more ? '▲ 자세한 설정 닫기' : '▼ 자세한 설정'} {more ? '▲ 자세한 설정 닫기' : '▼ 자세한 설정'}

View File

@@ -75,7 +75,6 @@ export function helpNow(v: JanggiView, _me: string | null, players: Record<strin
? '같은 국면이 3번 나왔어요. 한 번 더 반복하면 실격패예요!' ? '같은 국면이 3번 나왔어요. 한 번 더 반복하면 실격패예요!'
: `같은 국면이 3번 나왔어요. 한 번 더 반복하면 ${judge}.`, : `같은 국면이 3번 나왔어요. 한 번 더 반복하면 ${judge}.`,
); );
if (v.threatened.length) lines.push('주황 점선 동그라미가 쳐진 내 기물은 상대가 잡을 수 있어요. 조심하세요.');
lines.push(score); lines.push(score);
return lines.join('\n'); return lines.join('\n');
} }

View File

@@ -354,11 +354,6 @@ function JanggiBoard({ view: v, act, pending, tapConfirm }: BoardProps<JanggiVie
const kind: PieceKind = setupGhost[k] === 'M' ? 'ma' : 'sang'; const kind: PieceKind = setupGhost[k] === 'M' ? 'ma' : 'sang';
return <PieceGlyph key={`sg${x}`} piece={{ side: v.mySide!, kind }} cx={x} cy={9} hangul={hangul} ghost={!!setupHover || mustChoose} />; return <PieceGlyph key={`sg${x}`} piece={{ side: v.mySide!, kind }} cx={x} cy={9} hangul={hangul} ghost={!!setupHover || mustChoose} />;
})} })}
{myTurn &&
v.threatened.map((q) => {
const s = toScreen(q);
return <circle key={`t${sqKey(q)}`} cx={s.x} cy={s.y} r={0.5} fill="none" stroke="#f59e0b" strokeWidth={0.06} strokeDasharray="0.14 0.09" />;
})}
{[...vias].map((i) => { {[...vias].map((i) => {
const s = toScreen(fromKey(i)); const s = toScreen(fromKey(i));
return <circle key={`via${i}`} cx={s.x} cy={s.y} r={0.5} fill="none" stroke="#7c3aed" strokeWidth={0.05} strokeDasharray="0.1 0.08" opacity={0.8} />; return <circle key={`via${i}`} cx={s.x} cy={s.y} r={0.5} fill="none" stroke="#7c3aed" strokeWidth={0.05} strokeDasharray="0.1 0.08" opacity={0.8} />;
@@ -890,7 +885,7 @@ function Options({ value: o, onChange }: OptionsProps<JanggiOptions>) {
<Seg value={o.undo} options={[['off', '안 씀'], ['1', '1번'], ['3', '3번'], ['unlimited', '무제한']]} onChange={(undo) => set({ undo })} /> <Seg value={o.undo} options={[['off', '안 씀'], ['1', '1번'], ['3', '3번'], ['unlimited', '무제한']]} onChange={(undo) => set({ undo })} />
</Field> </Field>
<Field title="도움 표시"> <Field title="도움 표시">
<Seg value={o.hints} options={[['moves', '갈 수 있는 곳'], ['movesAndThreats', '+ 잡힐 위험'], ['off', '끄기']]} onChange={(hints) => set({ hints })} /> <Seg value={o.hints} options={[['moves', '갈 수 있는 곳'], ['off', '끄기']]} onChange={(hints) => set({ hints })} />
</Field> </Field>
<Field title="그 밖에"> <Field title="그 밖에">
<Seg value={o.allowPass ? 'on' : 'off'} options={[['on', '한수쉼 허용'], ['off', '한수쉼 안 함']]} onChange={(k) => set({ allowPass: k === 'on' })} /> <Seg value={o.allowPass ? 'on' : 'off'} options={[['on', '한수쉼 허용'], ['off', '한수쉼 안 함']]} onChange={(k) => set({ allowPass: k === 'on' })} />

View File

@@ -18,8 +18,6 @@ export function helpNow(v: OmokView, _me: string | null, players: Record<string,
} }
if (v.moveCount === 0 && v.firstMoveCenter) return `내 차례예요(${mine}). 첫 수는 판 한가운데에만 놓을 수 있어요.`; if (v.moveCount === 0 && v.firstMoveCenter) return `내 차례예요(${mine}). 첫 수는 판 한가운데에만 놓을 수 있어요.`;
const lines = [`내 차례예요(${mine}). 빈 곳을 눌러 돌을 놓아요. ${goal}`]; 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('빨간 ✕ 자리는 금수라서 둘 수 없어요.'); if (v.forbidden.length) lines.push('빨간 ✕ 자리는 금수라서 둘 수 없어요.');
return lines.join('\n'); return lines.join('\n');
} }

View File

@@ -157,8 +157,6 @@ function OmokBoard({ view: v, act, pending, tapConfirm }: BoardProps<OmokView>)
{stars(n).map((p) => ( {stars(n).map((p) => (
<circle key={`s${p.x}-${p.y}`} cx={p.x} cy={p.y} r={0.11} fill="var(--wood-line)" /> <circle key={`s${p.x}-${p.y}`} cx={p.x} cy={p.y} r={0.11} fill="var(--wood-line)" />
))} ))}
{myTurn && v.hints?.urgent.map((p) => <circle key={`u${p.x}-${p.y}`} cx={p.x} cy={p.y} r={0.42} fill="none" stroke="#dc2626" strokeWidth={0.09} />)}
{myTurn && v.hints?.warn.map((p) => <circle key={`w${p.x}-${p.y}`} cx={p.x} cy={p.y} r={0.38} fill="none" stroke="#f59e0b" strokeWidth={0.07} strokeDasharray="0.15 0.1" />)}
{v.forbidden.map((p) => ( {v.forbidden.map((p) => (
<g key={`f${p.x}-${p.y}`} stroke="#b91c1c" strokeWidth={0.08} opacity={0.8}> <g key={`f${p.x}-${p.y}`} stroke="#b91c1c" strokeWidth={0.08} opacity={0.8}>
<line x1={p.x - 0.2} y1={p.y - 0.2} x2={p.x + 0.2} y2={p.y + 0.2} /> <line x1={p.x - 0.2} y1={p.y - 0.2} x2={p.x + 0.2} y2={p.y + 0.2} />
@@ -429,7 +427,6 @@ function Options({ value: o, onChange }: OptionsProps<OmokOptions>) {
<div className="stack" style={{ gap: 6 }}> <div className="stack" style={{ gap: 6 }}>
<strong>도움 표시</strong> <strong>도움 표시</strong>
<Seg value={o.showForbidden ? 'on' : 'off'} options={[['on', '금수 자리 표시'], ['off', '금수 표시 안 함']]} onChange={(k) => set({ showForbidden: k === 'on' })} /> <Seg value={o.showForbidden ? 'on' : 'off'} options={[['on', '금수 자리 표시'], ['off', '금수 표시 안 함']]} onChange={(k) => set({ showForbidden: k === 'on' })} />
<Seg value={o.hints} options={[['warn', '위험한 자리 알려 주기'], ['off', '알려 주지 않기']]} onChange={(hints) => set({ hints })} />
<Seg value={o.drawOffer ? 'on' : 'off'} options={[['on', '무승부 제안 허용'], ['off', '무승부 제안 안 함']]} onChange={(k) => set({ drawOffer: k === 'on' })} /> <Seg value={o.drawOffer ? 'on' : 'off'} options={[['on', '무승부 제안 허용'], ['off', '무승부 제안 안 함']]} onChange={(k) => set({ drawOffer: k === 'on' })} />
</div> </div>
</> </>
@@ -471,7 +468,7 @@ function Rules({ options }: { options?: OmokOptions }) {
<strong>자유룰</strong>: 금지된 수가 없어요. 설정에 따라 5개 이상이면 이기거나, 딱 5개일 때만 이겨요. <strong>자유룰</strong>: 금지된 수가 없어요. 설정에 따라 5개 이상이면 이기거나, 딱 5개일 때만 이겨요.
</p> </p>
)} )}
<p>둘 수 없는 자리는 판에 빨간 ×로 표시돼요. 상대가 곧 이길 수 있는 자리는 빨간 동그라미, 위험한 자리는 노란 점선으로 알려 줘요(설정에서 끌 수 있어요).</p> <p>둘 수 없는 자리는 판에 빨간 ×로 표시돼요(설정에서 끌 수 있어요).</p>
<p>휴대폰에서는 한 번 누르면 미리 보기 돌이 놓이고, 같은 곳을 한 번 더 누르거나 아래 [두기] 버튼을 누르면 확정돼요.</p> <p>휴대폰에서는 한 번 누르면 미리 보기 돌이 놓이고, 같은 곳을 한 번 더 누르거나 아래 [두기] 버튼을 누르면 확정돼요.</p>
</div> </div>
); );

View File

@@ -24,7 +24,6 @@
| `idleLimitSec` | `none`일 때 무응답 한도 | 300 / 600 | 600 | | `idleLimitSec` | `none`일 때 무응답 한도 | 300 / 600 | 600 |
| `undo` | 무르기 허용 횟수(1인당, 상대 동의) | `off` / `1` / `3` / `unlimited` | `3` | | `undo` | 무르기 허용 횟수(1인당, 상대 동의) | `off` / `1` / `3` / `unlimited` | `3` |
| `drawOffer` | 무승부(빅) 합의 제안 허용 | `true` / `false` | `true` | | `drawOffer` | 무승부(빅) 합의 제안 허용 | `true` / `false` | `true` |
| `atariWarning` | 단수 경고 힌트(내 돌이 단수에 몰리면 표시) | `true` / `false` | `true` |
| `scoringDeadlineSec` | 사석 확인 단계 제한 시간 | 120 / 180 / 300 | 180 | | `scoringDeadlineSec` | 사석 확인 단계 제한 시간 | 120 / 180 / 300 | 180 |
| `disconnectGraceSec` | 연결 끊김 유예 | 60 / 120 / 300 | 120 | | `disconnectGraceSec` | 연결 끊김 유예 | 60 / 120 / 300 | 120 |
@@ -192,7 +191,7 @@ interface BadukState {
### 8.3 공개/비공개 정보 (view) ### 8.3 공개/비공개 정보 (view)
- 모두 공개(플레이어·관전자 동일): 판, 차례, 포로 수, 기보, 단계, 사석/빅 표시와 계가 미리보기, 양쪽 동의 여부, 시계(기본 시간, 초읽기 남은 횟수, `deadline`), 대기 중인 요청, 결과. - 모두 공개(플레이어·관전자 동일): 판, 차례, 포로 수, 기보, 단계, 사석/빅 표시와 계가 미리보기, 양쪽 동의 여부, 시계(기본 시간, 초읽기 남은 횟수, `deadline`), 대기 중인 요청, 결과.
- 차례인 플레이어에게만 편의 정보: `illegalPoints`(패·자충으로 둘 수 없는 점), `atariWarnings`. - 차례인 플레이어에게만 편의 정보: `illegalPoints`(패·자충으로 둘 수 없는 점). 단수 경고 같은 위협 표시는 보내지 않는다.
- view에서 제외: `rng`, `positionHistory`(크기 문제로 제외, 필요하면 기보로 재구성), `situationCounts`. - view에서 제외: `rng`, `positionHistory`(크기 문제로 제외, 필요하면 기보로 재구성), `situationCounts`.
### 8.4 랜덤 요소 (시드 RNG 사용 지점) ### 8.4 랜덤 요소 (시드 RNG 사용 지점)
@@ -255,7 +254,7 @@ interface BadukResult {
- [ ] `scoring` 단계 시간 초과 → 미응답자 자동 동의 후 계가. - [ ] `scoring` 단계 시간 초과 → 미응답자 자동 동의 후 계가.
- [ ] 연결 끊김 유예 후 시간패, 재접속 시 유예 해제. - [ ] 연결 끊김 유예 후 시간패, 재접속 시 유예 해제.
- [ ] 무르기 수락 시 따낸 돌이 복원되고 포로 수·슈퍼코 이력이 재생으로 정확히 복원. - [ ] 무르기 수락 시 따낸 돌이 복원되고 포로 수·슈퍼코 이력이 재생으로 정확히 복원.
- [ ] 정보 누출: view에 `rng`, `positionHistory`, `situationCounts`가 없는지. 관전자 view에 `illegalPoints`/`atariWarnings` 없음. - [ ] 정보 누출: view에 `rng`, `positionHistory`, `situationCounts`가 없는지. 관전자 view에 `illegalPoints` 없음.
- [ ] 리플레이 결정성: 같은 시드·액션 로그 → 같은 결과. - [ ] 리플레이 결정성: 같은 시드·액션 로그 → 같은 결과.
## 11. 참고 자료 ## 11. 참고 자료

View File

@@ -20,7 +20,7 @@
| `drawOffer` | 무승부 제안 | `true` / `false` | `true` | | `drawOffer` | 무승부 제안 | `true` / `false` | `true` |
| `firstMoveSec` | 첫 수 제한(넘기면 대국 취소) | 15 / 30 / 60 | 30 | | `firstMoveSec` | 첫 수 제한(넘기면 대국 취소) | 15 / 30 / 60 | 30 |
| `idleLimitSec` | `timeControl=none`일 때 무응답 한도 | 300 / 600 | 600 | | `idleLimitSec` | `timeControl=none`일 때 무응답 한도 | 300 / 600 | 600 |
| `hints` | 초보 도움말(이동 가능 칸 표시, 공격받는 기물 경고) | `off` / `moves` / `movesAndThreats` | `moves` | | `hints` | 초보 도움말(이동 가능 칸 표시). 공격받는 기물 경고 같은 위협 표시는 두지 않는다 | `off` / `moves` | `moves` |
| `disconnectGraceSec` | 연결 끊김 유예 | 30 / 60 / 120 | 60 | | `disconnectGraceSec` | 연결 끊김 유예 | 30 / 60 / 120 | 60 |
## 3. 구성물 ## 3. 구성물
@@ -140,9 +140,9 @@ interface ChessState {
### 8.3 공개/비공개 정보 (view) ### 8.3 공개/비공개 정보 (view)
- 공통 공개: `fen`, 기보(SAN), 마지막 수, 체크 여부, 시계, `deadline`, 잡은 기물 목록과 기물 점수 차(표시용: 퀸9 룩5 비숍3 나이트3 폰1), 대기 중인 요청, `claimable`, 결과. - 공통 공개: `fen`, 기보(SAN), 마지막 수, 체크 여부, 시계, `deadline`, 잡은 기물 목록과 기물 점수 차(표시용: 퀸9 룩5 비숍3 나이트3 폰1), 대기 중인 요청, `claimable`, 결과.
- 차례인 플레이어에게: `legalMoves: { from; to; promotion?; castle? }[]`(하이라이트용). `hints=movesAndThreats`면 `threatenedSquares`(내 기물 중 공격받고 방어 안 된 칸). - 차례인 플레이어에게: `legalMoves: { from; to; promotion?; castle? }[]`(하이라이트용).
- 차례가 아닌 플레이어: 프리무브 UI를 위해 자기 기물의 의사 합법 수를 클라이언트에서 계산하지 않고, v1은 프리무브 미지원(단순성·공정성). - 차례가 아닌 플레이어: 프리무브 UI를 위해 자기 기물의 의사 합법 수를 클라이언트에서 계산하지 않고, v1은 프리무브 미지원(단순성·공정성).
- 관전자: `legalMoves`/`threatenedSquares` 없음. - 관전자: `legalMoves` 없음.
- 제외: `rng`(960 배치는 셋업 직후 공개되므로 문제없지만 다음 대국 배치 예측 방지). - 제외: `rng`(960 배치는 셋업 직후 공개되므로 문제없지만 다음 대국 배치 예측 방지).
### 8.4 랜덤 요소 (시드 RNG 사용 지점) ### 8.4 랜덤 요소 (시드 RNG 사용 지점)
@@ -203,7 +203,7 @@ interface ChessResult {
- [ ] Chess960: n=518이 표준 배치, 960개 번호 모두 비숍 반대 색·킹이 두 룩 사이 조건 만족·서로 다름, 킹이 g열에 이미 있는 배치에서 킹사이드 캐슬링(킹 0칸 이동), 룩 경로·킹 경로·도착 칸 막힘/공격, 룩이 도착 칸을 가리던 경우, 권리 상실, 무르기 후 권리 복원, 무작위 960 대국(`chess.test.ts`), perft(`chess.perft.test.ts`). - [ ] Chess960: n=518이 표준 배치, 960개 번호 모두 비숍 반대 색·킹이 두 룩 사이 조건 만족·서로 다름, 킹이 g열에 이미 있는 배치에서 킹사이드 캐슬링(킹 0칸 이동), 룩 경로·킹 경로·도착 칸 막힘/공격, 룩이 도착 칸을 가리던 경우, 권리 상실, 무르기 후 권리 복원, 무작위 960 대국(`chess.test.ts`), perft(`chess.perft.test.ts`).
- [ ] 무르기: 1/2 플라이 복원 후 `repetition` 카운트 정확, 거절 시 무변화. - [ ] 무르기: 1/2 플라이 복원 후 `repetition` 카운트 정확, 거절 시 무변화.
- [ ] 연결 끊김 유예 후 시간패 처리. - [ ] 연결 끊김 유예 후 시간패 처리.
- [ ] 정보 누출: 관전자·비차례 플레이어 view에 `legalMoves`/`threatenedSquares` 없음, 모든 view에 `rng` 없음. - [ ] 정보 누출: 관전자·비차례 플레이어 view에 `legalMoves` 없음, 모든 view에 `rng` 없음.
- [ ] 리플레이 결정성(같은 시드 → 같은 960 배치와 결과). - [ ] 리플레이 결정성(같은 시드 → 같은 960 배치와 결과).
## 11. 참고 자료 ## 11. 참고 자료

View File

@@ -23,7 +23,7 @@
| `idleLimitSec` | `none`일 때 무응답 한도 | 300 / 600 | 600 | | `idleLimitSec` | `none`일 때 무응답 한도 | 300 / 600 | 600 |
| `undo` | 무르기 허용 횟수(상대 동의) | `off` / `1` / `3` / `unlimited` | `3` | | `undo` | 무르기 허용 횟수(상대 동의) | `off` / `1` / `3` / `unlimited` | `3` |
| `drawOffer` | 무승부 제안 | `true` / `false` | `true` | | `drawOffer` | 무승부 제안 | `true` / `false` | `true` |
| `hints` | 이동 가능 칸 표시, 장군 위험 경고 | `off` / `moves` / `movesAndThreats` | `moves` | | `hints` | 이동 가능 칸 표시. 잡힐 위험 같은 위협 경고는 두지 않는다 | `off` / `moves` | `moves` |
| `disconnectGraceSec` | 연결 끊김 유예 | 60 / 120 | 60 | | `disconnectGraceSec` | 연결 끊김 유예 | 60 / 120 | 60 |
## 3. 구성물 ## 3. 구성물
@@ -180,7 +180,7 @@ interface JanggiState {
### 8.3 공개/비공개 정보 (view) ### 8.3 공개/비공개 정보 (view)
- 공개: 판, 차례, 기보, 잡은 기물, 현재 점수(덤 포함), 장군 상태, 빅장 응수 대기 여부, 반복 경고(같은 국면 3회째 도달 시 "한 번 더 반복하면 둘 수 없어요"), 시계, `deadline`, 대기 중인 요청, 결과. - 공개: 판, 차례, 기보, 잡은 기물, 현재 점수(덤 포함), 장군 상태, 빅장 응수 대기 여부, 반복 경고(같은 국면 3회째 도달 시 "한 번 더 반복하면 둘 수 없어요"), 시계, `deadline`, 대기 중인 요청, 결과.
- `setup` 단계 + `simultaneous`: 상대 선택값은 "선택 완료"(true/false)만 보이고 배치는 둘 다 확정될 때까지 숨긴다(플레이어·관전자 모두). `hanFirst`에서는 한의 선택이 즉시 공개. - `setup` 단계 + `simultaneous`: 상대 선택값은 "선택 완료"(true/false)만 보이고 배치는 둘 다 확정될 때까지 숨긴다(플레이어·관전자 모두). `hanFirst`에서는 한의 선택이 즉시 공개.
- 차례인 플레이어에게만: `legalMoves`(하이라이트용), 힌트 옵션 시 `threatened`(잡힐 위험 기물). - 차례인 플레이어에게만: `legalMoves`(하이라이트용).
- 제외: `rng`, 상대의 미공개 상차림. - 제외: `rng`, 상대의 미공개 상차림.
### 8.4 랜덤 요소 (시드 RNG 사용 지점) ### 8.4 랜덤 요소 (시드 RNG 사용 지점)

View File

@@ -24,7 +24,6 @@
| `undo` | 무르기 요청 허용 횟수(1인당, 상대 동의 필요) | `off` / `1` / `3` / `unlimited` | `3` | | `undo` | 무르기 요청 허용 횟수(1인당, 상대 동의 필요) | `off` / `1` / `3` / `unlimited` | `3` |
| `drawOffer` | 무승부 제안 허용 | `true` / `false` | `true` | | `drawOffer` | 무승부 제안 허용 | `true` / `false` | `true` |
| `showForbidden` | 금수 자리 표시 (금수 있는 규칙만) | `true` / `false` | `true` | | `showForbidden` | 금수 자리 표시 (금수 있는 규칙만) | `true` / `false` | `true` |
| `hints` | 초보 도움말: 상대의 열린 3·4 경고 표시 | `off` / `warn` | `warn` |
## 3. 구성물 ## 3. 구성물
- 15×15(교차점 225개) 또는 19×19(361개) 판. 돌은 교차점에 놓는다. - 15×15(교차점 225개) 또는 19×19(361개) 판. 돌은 교차점에 놓는다.
@@ -253,8 +252,8 @@ interface OmokState {
### 8.3 공개/비공개 정보 (view) ### 8.3 공개/비공개 정보 (view)
- 플레이어·관전자 공통 공개: 판, 차례, 기보, 승리 줄, 양쪽 남은 시간과 `deadline`, 대기 중인 무르기/무승부 요청 여부(누가 요청했는지), 남은 무르기 횟수, 결과. - 플레이어·관전자 공통 공개: 판, 차례, 기보, 승리 줄, 양쪽 남은 시간과 `deadline`, 대기 중인 무르기/무승부 요청 여부(누가 요청했는지), 남은 무르기 횟수, 결과.
- 차례인 플레이어에게만: `forbiddenPoints: Pt[]` (금수 표시 옵션이 켜져 있을 때), `hintThreats`(힌트 옵션: 상대의 열린 삼/사 위치). - 차례인 플레이어에게만: `forbiddenPoints: Pt[]` (금수 표시 옵션이 켜져 있을 때). 상대의 3·4를 막으라는 경고(위협 표시)는 보내지 않는다.
- 관전자에게는 `forbiddenPoints`를 흑 기준으로 보여줄 수 있다(공개 정보이므로 허용, 해설용). 단 `hints`는 관전자에게 보내지 않는다(관전자가 플레이어에게 훈수 전달하는 것을 굳이 돕지 않기 위함). - 관전자에게는 `forbiddenPoints`를 흑 기준으로 보여줄 수 있다(공개 정보이므로 허용, 해설용).
- 절대 포함 금지: `rng`(시드/상태 — 자동 착수 위치 예측 가능), 서버 내부 메모이제이션. - 절대 포함 금지: `rng`(시드/상태 — 자동 착수 위치 예측 가능), 서버 내부 메모이제이션.
### 8.4 랜덤 요소 (시드 RNG 사용 지점) ### 8.4 랜덤 요소 (시드 RNG 사용 지점)
@@ -314,7 +313,7 @@ interface OmokResult {
- [ ] `perMove + autoMove`: 시간 초과 시 자동 착수가 합법 수이고 금수가 아니며, 같은 시드에서 항상 같은 자리. 3연속 자동 착수 시 시간패. - [ ] `perMove + autoMove`: 시간 초과 시 자동 착수가 합법 수이고 금수가 아니며, 같은 시드에서 항상 같은 자리. 3연속 자동 착수 시 시간패.
- [ ] `fischer`: 남은 시간 0 이후 도착한 착수는 거부되고 시간패, 정상 착수 시 증가분 가산. - [ ] `fischer`: 남은 시간 0 이후 도착한 착수는 거부되고 시간패, 정상 착수 시 증가분 가산.
- [ ] 연결 끊김: 끊긴 플레이어 차례에 유예 후 `onTimeout` 동작, 재접속 시 유예 타이머 해제. - [ ] 연결 끊김: 끊긴 플레이어 차례에 유예 후 `onTimeout` 동작, 재접속 시 유예 타이머 해제.
- [ ] 정보 누출: 플레이어/관전자 view JSON에 `rng`가 없는지, 관전자 view에 `hintThreats`가 없는지 스냅샷 테스트. - [ ] 정보 누출: 플레이어/관전자 view JSON에 `rng`가 없는지, 어떤 view에도 위협 표시(`hints`)가 없는지 스냅샷 테스트.
- [ ] 리플레이: 같은 시드 + 같은 액션 로그로 재실행 시 최종 상태 해시 동일. - [ ] 리플레이: 같은 시드 + 같은 액션 로그로 재실행 시 최종 상태 해시 동일.
## 11. 참고 자료 ## 11. 참고 자료

View File

@@ -531,7 +531,7 @@ describe('undo, draw, resign', () => {
}); });
describe('views and determinism', () => { describe('views and determinism', () => {
test('no hidden internals in any view; mover-only hints', () => { test('no hidden internals in any view; mover-only illegal points; no atari warning', () => {
let s = make({}, { black: ['B1', 'A2'], white: ['A3', 'C1'] }, 'white'); let s = make({}, { black: ['B1', 'A2'], white: ['A3', 'C1'] }, 'white');
s = play(s, 'B2'); // black B1/A2... put black stones in atari s = play(s, 'B2'); // black B1/A2... put black stones in atari
for (const viewer of ['A', 'B', null]) { for (const viewer of ['A', 'B', null]) {
@@ -541,14 +541,9 @@ describe('views and determinism', () => {
expect(json).not.toContain('rng'); expect(json).not.toContain('rng');
} }
const mover = baduk.view(s, 'A'); const mover = baduk.view(s, 'A');
expect(mover.atariWarnings.length).toBeGreaterThan(0);
expect(mover.illegalPoints.length).toBeGreaterThan(0); expect(mover.illegalPoints.length).toBeGreaterThan(0);
for (const viewer of ['B', null]) { for (const viewer of ['B', null]) expect(baduk.view(s, viewer).illegalPoints).toEqual([]);
const v = baduk.view(s, viewer); for (const viewer of ['A', 'B', null]) expect(JSON.stringify(baduk.view(s, viewer))).not.toMatch(/atari/i);
expect(v.illegalPoints).toEqual([]);
expect(v.atariWarnings).toEqual([]);
}
expect(baduk.view(make({ atariWarning: false }, { black: ['B1'], white: ['A1', 'C1'] }), 'A').atariWarnings).toEqual([]);
}); });
test('nigiri is seeded and deterministic', () => { test('nigiri is seeded and deterministic', () => {

View File

@@ -238,12 +238,6 @@ export function illegalPoints(
return out; return out;
} }
/** Stones of colour c that are in atari (chains with exactly one liberty). */
export function atariStones(board: readonly Cell[], size: number, c: Stone): number[] {
const { chains } = mapChains(board, size);
return chains.filter((ch) => ch.color === c && ch.libs.length === 1).flatMap((ch) => ch.stones);
}
// ------------------------------------------------------------------ scoring // ------------------------------------------------------------------ scoring
export interface Regions { export interface Regions {

View File

@@ -2,7 +2,6 @@
import { z } from 'zod'; import { z } from 'zod';
import { fail, ok, type GameDefinition, type GameEvent, type GameResult, type PlayerId } from '@bg/engine'; import { fail, ok, type GameDefinition, type GameEvent, type GameResult, type PlayerId } from '@bg/engine';
import { import {
atariStones,
chainAt, chainAt,
emptyRegionAt, emptyRegionAt,
hashBoard, hashBoard,
@@ -79,7 +78,6 @@ export const badukOptionsSchema = z
.meta({ title: '무르기(1인당, 상대 동의)', labels: { off: '안 씀', 1: '1번', 3: '3번', unlimited: '무제한' } }), .meta({ title: '무르기(1인당, 상대 동의)', labels: { off: '안 씀', 1: '1번', 3: '3번', unlimited: '무제한' } }),
drawOffer: z.boolean().default(true).meta({ title: '무승부 제안 허용' }), drawOffer: z.boolean().default(true).meta({ title: '무승부 제안 허용' }),
drawReofferMoves: z.number().int().min(0).max(50).default(5).meta({ title: '무승부 다시 제안까지 둬야 하는 수' }), drawReofferMoves: z.number().int().min(0).max(50).default(5).meta({ title: '무승부 다시 제안까지 둬야 하는 수' }),
atariWarning: z.boolean().default(true).meta({ title: '단수 경고(내 돌이 단수에 몰리면 표시)' }),
scoringDeadlineSec: z scoringDeadlineSec: z
.union([z.literal(120), z.literal(180), z.literal(300)]) .union([z.literal(120), z.literal(180), z.literal(300)])
.default(180) .default(180)
@@ -176,7 +174,7 @@ export interface BadukView {
myColor: Color | null; myColor: Color | null;
turn: Color; turn: Color;
phase: BadukState['phase']; phase: BadukState['phase'];
rules: { scoring: BadukOptions['scoring']; koRule: BadukOptions['koRule']; komi: number; handicap: number; atariWarning: boolean }; rules: { scoring: BadukOptions['scoring']; koRule: BadukOptions['koRule']; komi: number; handicap: number };
captures: Record<Color, number>; captures: Record<Color, number>;
moveCount: number; moveCount: number;
moves: { color: Color; pt: Pt | null; captured: number }[]; moves: { color: Color; pt: Pt | null; captured: number }[];
@@ -200,8 +198,6 @@ export interface BadukView {
draw: { allowed: boolean; pendingBy: Color | null }; draw: { allowed: boolean; pendingBy: Color | null };
/** Only for the player to move. */ /** Only for the player to move. */
illegalPoints: (Pt & { reason: IllegalReason })[]; illegalPoints: (Pt & { reason: IllegalReason })[];
/** Only for the player to move (atariWarning option): own stones in atari. */
atariWarnings: Pt[];
scoring: { scoring: {
dead: Pt[]; dead: Pt[];
/** Every point of seki-marked regions. */ /** Every point of seki-marked regions. */
@@ -758,7 +754,7 @@ export const baduk: GameDefinition<BadukState, BadukAction, BadukView, BadukOpti
myColor, myColor,
turn: s.turn, turn: s.turn,
phase: s.phase, phase: s.phase,
rules: { scoring: s.options.scoring, koRule: s.options.koRule, komi: s.komi, handicap: s.handicap, atariWarning: s.options.atariWarning }, rules: { scoring: s.options.scoring, koRule: s.options.koRule, komi: s.komi, handicap: s.handicap },
captures: s.captures, captures: s.captures,
moveCount: s.moves.length, moveCount: s.moves.length,
moves: s.moves.map((mv) => ({ color: mv.color, pt: mv.kind === 'place' ? mv.pt : null, captured: mv.kind === 'place' ? mv.captured.length : 0 })), moves: s.moves.map((mv) => ({ color: mv.color, pt: mv.kind === 'place' ? mv.pt : null, captured: mv.kind === 'place' ? mv.captured.length : 0 })),
@@ -787,7 +783,6 @@ export const baduk: GameDefinition<BadukState, BadukAction, BadukView, BadukOpti
}, },
draw: { allowed: s.options.drawOffer, pendingBy: s.draw.pendingBy }, draw: { allowed: s.options.drawOffer, pendingBy: s.draw.pendingBy },
illegalPoints: myTurn ? [...illegalMap(s)].map(([i, reason]) => ({ ...toPt(i, s.size), reason })) : [], illegalPoints: myTurn ? [...illegalMap(s)].map(([i, reason]) => ({ ...toPt(i, s.size), reason })) : [],
atariWarnings: myTurn && s.options.atariWarning ? atariStones(s.board, s.size, STONE[myColor]).map((i) => toPt(i, s.size)) : [],
scoring: m scoring: m
? { ? {
dead: m.dead.map((i) => toPt(i, s.size)), dead: m.dead.map((i) => toPt(i, s.size)),

View File

@@ -1,7 +1,7 @@
import { describe, expect, test } from 'bun:test'; import { describe, expect, test } from 'bun:test';
import { SeededRng, randomPlayout, seedN } from '@bg/engine'; import { SeededRng, randomPlayout, seedN } from '@bg/engine';
import { chess, type ChessAction, type ChessOptions, type ChessState } from './index'; import { chess, type ChessAction, type ChessOptions, type ChessState } from './index';
import { START_FEN, adapterPerft, cannotMate, chess960Fen, fullFen, insufficientMaterial, keyOf, load, positionKey, threatenedSquares } from './rules'; import { START_FEN, adapterPerft, cannotMate, chess960Fen, fullFen, insufficientMaterial, keyOf, load, positionKey } from './rules';
const rng = () => SeededRng.fromSeed([1, 2, 3, 4]); const rng = () => SeededRng.fromSeed([1, 2, 3, 4]);
@@ -323,19 +323,17 @@ describe('requests', () => {
}); });
describe('views and playouts', () => { describe('views and playouts', () => {
test('legalMoves / threatened only for the player to move; no rng anywhere', () => { test('legalMoves only for the player to move; no threat warnings; no rng anywhere', () => {
const s = make({ hints: 'movesAndThreats' }, '4k3/8/8/3q4/8/8/3N4/K7 w - - 0 1'); const s = make({}, '4k3/8/8/3q4/8/8/3N4/K7 w - - 0 1');
const mine = chess.view(s, 'A'); const mine = chess.view(s, 'A');
expect(mine.legalMoves?.length).toBeGreaterThan(0); expect(mine.legalMoves?.length).toBeGreaterThan(0);
expect(mine.threatened).toEqual(['d2']); for (const v of [chess.view(s, 'B'), chess.view(s, null)]) expect(v.legalMoves).toBeNull();
for (const v of [chess.view(s, 'B'), chess.view(s, null)]) { for (const v of [mine, chess.view(s, 'B'), chess.view(s, null)]) {
expect(v.legalMoves).toBeNull(); expect(v).not.toHaveProperty('threatened');
expect(v.threatened).toBeNull(); expect(JSON.stringify(v)).not.toMatch(/rng/i);
} }
expect(chess.view(make(), 'A').threatened).toBeNull();
for (const v of [mine, chess.view(s, 'B'), chess.view(s, null)]) expect(JSON.stringify(v)).not.toMatch(/rng/i);
expect(JSON.stringify(s)).not.toMatch(/rng/i); expect(JSON.stringify(s)).not.toMatch(/rng/i);
expect(threatenedSquares(load('4k3/8/8/3q4/8/8/3N4/K2Q4 w - - 0 1'))).toEqual([]); expect(chess.optionsSchema.safeParse({ hints: 'movesAndThreats' }).success).toBe(false);
}); });
test('view shape: board, check square, captured pieces and material, promotion flag', () => { test('view shape: board, check square, captured pieces and material, promotion flag', () => {
const s = play(make(), 'e2e4', 'd7d5', 'e4d5', 'd8d5', 'b1c3', 'd5e5'); const s = play(make(), 'e2e4', 'd7d5', 'e4d5', 'd8d5', 'b1c3', 'd5e5');

View File

@@ -20,7 +20,6 @@ import {
positionKey, positionKey,
pseudoReachable, pseudoReachable,
startFenFor, startFenFor,
threatenedSquares,
toSide, toSide,
type Color, type Color,
type LegalMove, type LegalMove,
@@ -73,9 +72,9 @@ export const chessOptionsSchema = z.object({
.default(600) .default(600)
.meta({ title: '시간 제한 없음일 때 무응답 한도', labels: { 300: '5분', 600: '10분' } }), .meta({ title: '시간 제한 없음일 때 무응답 한도', labels: { 300: '5분', 600: '10분' } }),
hints: z hints: z
.enum(['off', 'moves', 'movesAndThreats']) .enum(['off', 'moves'])
.default('moves') .default('moves')
.meta({ title: '초보 도움말', labels: { off: '끄기', moves: '갈 수 있는 칸 표시', movesAndThreats: '갈 수 있는 칸 + 위험한 기물 경고' } }), .meta({ title: '초보 도움말', labels: { off: '끄기', moves: '갈 수 있는 칸 표시' } }),
}); });
export type ChessOptions = z.infer<typeof chessOptionsSchema>; export type ChessOptions = z.infer<typeof chessOptionsSchema>;
@@ -181,8 +180,6 @@ export interface ChessView {
hints: ChessOptions['hints']; hints: ChessOptions['hints'];
/** Only for the player to move. */ /** Only for the player to move. */
legalMoves: LegalMove[] | null; legalMoves: LegalMove[] | null;
/** Only for the player to move with hints=movesAndThreats. */
threatened: string[] | null;
result: ChessResult | null; result: ChessResult | null;
} }
@@ -589,7 +586,6 @@ export const chess: GameDefinition<ChessState, ChessAction, ChessView, ChessOpti
autoQueen: s.options.autoQueen, autoQueen: s.options.autoQueen,
hints: s.options.hints, hints: s.options.hints,
legalMoves: mover ? legalMoves(c) : null, legalMoves: mover ? legalMoves(c) : null,
threatened: mover && s.options.hints === 'movesAndThreats' ? threatenedSquares(c) : null,
result: s.result, result: s.result,
}; };
}, },

View File

@@ -193,15 +193,6 @@ export function cannotMate(c: Position, side: 'w' | 'b'): boolean {
return false; return false;
} }
/** The side to move's non-king pieces that are attacked and not defended (hints=movesAndThreats). */
export function threatenedSquares(c: Position): string[] {
const us = c.turn();
const them = us === 'w' ? 'b' : 'w';
return pieces(c)
.filter((p) => p.color === us && p.type !== 'k' && c.isAttacked(p.square, them) && !c.isAttacked(p.square, us))
.map((p) => p.square);
}
export function kingSquare(c: Position, side: 'w' | 'b'): string { export function kingSquare(c: Position, side: 'w' | 'b'): string {
return c.kingSquare(side); return c.kingSquare(side);
} }

View File

@@ -17,7 +17,6 @@ import {
placeArrangement, placeArrangement,
positionKey, positionKey,
pseudoMoves, pseudoMoves,
attacks,
sqOf, sqOf,
type Arrangement, type Arrangement,
type Board, type Board,
@@ -71,9 +70,9 @@ export const janggiOptionsSchema = z.object({
drawOffer: z.boolean().default(true).meta({ title: '무승부 제안 허용' }), drawOffer: z.boolean().default(true).meta({ title: '무승부 제안 허용' }),
drawReofferMoves: z.number().int().min(0).max(50).default(5).meta({ title: '무승부 다시 제안까지 둬야 하는 수' }), drawReofferMoves: z.number().int().min(0).max(50).default(5).meta({ title: '무승부 다시 제안까지 둬야 하는 수' }),
hints: z hints: z
.enum(['off', 'moves', 'movesAndThreats']) .enum(['off', 'moves'])
.default('moves') .default('moves')
.meta({ title: '도움 표시', labels: { off: '끄기', moves: '갈 수 있는 곳', movesAndThreats: '갈 수 있는 곳 + 잡힐 위험' } }), .meta({ title: '도움 표시', labels: { off: '끄기', moves: '갈 수 있는 곳' } }),
}); });
export type JanggiOptions = z.infer<typeof janggiOptionsSchema>; export type JanggiOptions = z.infer<typeof janggiOptionsSchema>;
@@ -179,7 +178,6 @@ export interface JanggiView {
/** Only for the player to move. */ /** Only for the player to move. */
legalMoves: JanggiLegalMove[] | null; legalMoves: JanggiLegalMove[] | null;
canPass: boolean; canPass: boolean;
threatened: Sq[];
rules: Pick<JanggiOptions, 'scoring' | 'bikjang' | 'repetition' | 'allowPass' | 'moveLimit' | 'hints' | 'timeControl' | 'idleLimitSec' | 'setupTimeSec'>; rules: Pick<JanggiOptions, 'scoring' | 'bikjang' | 'repetition' | 'allowPass' | 'moveLimit' | 'hints' | 'timeControl' | 'idleLimitSec' | 'setupTimeSec'>;
clock: JanggiState['clock']; clock: JanggiState['clock'];
undo: { allowed: boolean; remaining: Record<Side, number | null>; pending: { by: Side; plies: number } | null }; undo: { allowed: boolean; remaining: Record<Side, number | null>; pending: { by: Side; plies: number } | null };
@@ -653,14 +651,6 @@ export const janggi: GameDefinition<JanggiState, JanggiAction, JanggiView, Jangg
const myTurn = playing && mySide === s.turn; const myTurn = playing && mySide === s.turn;
const captured: Record<Side, PieceKind[]> = { cho: [], han: [] }; const captured: Record<Side, PieceKind[]> = { cho: [], han: [] };
for (const m of s.moves) if (m.kind === 'move' && m.captured) captured[m.side].push(m.captured); for (const m of s.moves) if (m.kind === 'move' && m.captured) captured[m.side].push(m.captured);
let threatened: Sq[] = [];
if (myTurn && s.options.hints === 'movesAndThreats') {
const opp = other(s.turn);
threatened = [];
s.board.forEach((p, i) => {
if (p && p.side === s.turn && p.kind !== 'king' && attacks(s.board, opp, i)) threatened.push(sqOf(i));
});
}
const last = [...s.moves].reverse().find((m) => m.kind === 'move'); const last = [...s.moves].reverse().find((m) => m.kind === 'move');
const limit = undoLimit(s.options); const limit = undoLimit(s.options);
const o = s.options; const o = s.options;
@@ -686,7 +676,6 @@ export const janggi: GameDefinition<JanggiState, JanggiAction, JanggiView, Jangg
repeatWarning: mo?.warn ?? false, repeatWarning: mo?.warn ?? false,
legalMoves: myTurn && mo ? mo.moves.map(legalToView) : null, legalMoves: myTurn && mo ? mo.moves.map(legalToView) : null,
canPass: myTurn && !!mo?.pass, canPass: myTurn && !!mo?.pass,
threatened,
rules: { rules: {
scoring: o.scoring, scoring: o.scoring,
bikjang: o.bikjang, bikjang: o.bikjang,

View File

@@ -511,16 +511,17 @@ describe('requests, undo, views', () => {
expect(s.result).toMatchObject({ winner: 'cho', reason: 'resign' }); expect(s.result).toMatchObject({ winner: 'cho', reason: 'resign' });
}); });
test('view: no rng, legal moves only for the player to move, threats when enabled', () => { test('view: no rng, legal moves only for the player to move, no threat warnings', () => {
const s = standard({ hints: 'movesAndThreats' }); const s = standard();
expect(JSON.stringify(janggi.view(s, 'A'))).not.toContain('rng'); expect(JSON.stringify(janggi.view(s, 'A'))).not.toContain('rng');
expect(janggi.view(s, null).legalMoves).toBeNull(); expect(janggi.view(s, null).legalMoves).toBeNull();
expect(janggi.view(s, 'B').legalMoves).toBeNull(); expect(janggi.view(s, 'B').legalMoves).toBeNull();
const v = janggi.view(s, 'A'); const v = janggi.view(s, 'A');
expect(v.legalMoves!.length).toBeGreaterThan(20); expect(v.legalMoves!.length).toBeGreaterThan(20);
expect(v.canPass).toBe(true); expect(v.canPass).toBe(true);
const t = custom([...kingsOnly, ['cho', 'ma', 4, 4], ['han', 'cha', 4, 7]], { hints: 'movesAndThreats' }); const t = custom([...kingsOnly, ['cho', 'ma', 4, 4], ['han', 'cha', 4, 7]]);
expect(janggi.view(t, 'A').threatened).toEqual([{ f: 4, r: 4 }]); expect(janggi.view(t, 'A')).not.toHaveProperty('threatened');
expect(janggi.optionsSchema.safeParse({ hints: 'movesAndThreats' }).success).toBe(false);
}); });
test('포 legal moves carry their screen for the UI', () => { test('포 legal moves carry their screen for the UI', () => {

View File

@@ -3,10 +3,8 @@ import { z } from 'zod';
import { fail, ok, type GameDefinition, type GameEvent, type GameResult, type PlayerId, type Rng } from '@bg/engine'; import { fail, ok, type GameDefinition, type GameEvent, type GameResult, type PlayerId, type Rng } from '@bg/engine';
import { import {
Board, Board,
candidateCells,
forbiddenCells, forbiddenCells,
forbiddenKind, forbiddenKind,
straightFourCount,
winningLine, winningLine,
type Cell, type Cell,
type RuleConfig, type RuleConfig,
@@ -64,10 +62,6 @@ export const omokOptionsSchema = z.object({
drawOffer: z.boolean().default(true).meta({ title: '무승부 제안 허용' }), drawOffer: z.boolean().default(true).meta({ title: '무승부 제안 허용' }),
drawReofferMoves: z.number().int().min(0).max(50).default(3).meta({ title: '무승부 다시 제안까지 둬야 하는 수' }), drawReofferMoves: z.number().int().min(0).max(50).default(3).meta({ title: '무승부 다시 제안까지 둬야 하는 수' }),
showForbidden: z.boolean().default(true).meta({ title: '금수 자리 표시' }), showForbidden: z.boolean().default(true).meta({ title: '금수 자리 표시' }),
hints: z
.enum(['off', 'warn'])
.default('warn')
.meta({ title: '위험한 자리 알려 주기', labels: { off: '끄기', warn: '켜기' } }),
}); });
export type OmokOptions = z.infer<typeof omokOptionsSchema>; export type OmokOptions = z.infer<typeof omokOptionsSchema>;
@@ -126,7 +120,6 @@ export interface OmokView {
undo: { allowed: boolean; remaining: Record<Color, number | null>; pending: { by: Color; plies: number } | null }; undo: { allowed: boolean; remaining: Record<Color, number | null>; pending: { by: Color; plies: number } | null };
draw: { allowed: boolean; pendingBy: Color | null }; draw: { allowed: boolean; pendingBy: Color | null };
forbidden: Pt[]; forbidden: Pt[];
hints: { urgent: Pt[]; warn: Pt[] } | null;
result: OmokState['result']; result: OmokState['result'];
} }
@@ -253,22 +246,6 @@ function chooseAutoMove(s: OmokState, rng: Rng): Pt {
return pick(near.length ? near : legal); return pick(near.length ? near : legal);
} }
function threatHints(s: OmokState): { urgent: Pt[]; warn: Pt[] } {
const b = boardOf(s);
const opp = STONE[other(s.turn)];
const r = rule(s.options);
const urgent: Pt[] = [];
const warn: Pt[] = [];
for (const i of candidateCells(b, opp, 2)) {
const { x, y } = toPt(i, s.size);
b.set(x, y, opp);
if (winningLine(b, x, y, opp, r)) urgent.push({ x, y });
else if (straightFourCount(b, x, y, opp, r) > 0) warn.push({ x, y });
b.set(x, y, 0);
}
return { urgent, warn };
}
function pendingResponder(s: OmokState): Color | null { function pendingResponder(s: OmokState): Color | null {
if (s.undo.pending) return other(s.undo.pending.by); if (s.undo.pending) return other(s.undo.pending.by);
if (s.draw.pendingBy) return other(s.draw.pendingBy); if (s.draw.pendingBy) return other(s.draw.pendingBy);
@@ -460,7 +437,6 @@ export const omok: GameDefinition<OmokState, OmokAction, OmokView, OmokOptions>
const myColor = viewer ? colorOf(s, viewer) : null; const myColor = viewer ? colorOf(s, viewer) : null;
const limit = undoLimit(s.options); const limit = undoLimit(s.options);
const showForbidden = s.options.showForbidden && s.phase === 'playing' && (myColor === null || myColor === s.turn); const showForbidden = s.options.showForbidden && s.phase === 'playing' && (myColor === null || myColor === s.turn);
const showHints = s.options.hints === 'warn' && s.phase === 'playing' && myColor !== null && myColor === s.turn;
return { return {
size: s.size, size: s.size,
board: s.board, board: s.board,
@@ -487,7 +463,6 @@ export const omok: GameDefinition<OmokState, OmokAction, OmokView, OmokOptions>
}, },
draw: { allowed: s.options.drawOffer, pendingBy: s.draw.pendingBy }, draw: { allowed: s.options.drawOffer, pendingBy: s.draw.pendingBy },
forbidden: showForbidden ? s.forbidden.map((i) => toPt(i, s.size)) : [], forbidden: showForbidden ? s.forbidden.map((i) => toPt(i, s.size)) : [],
hints: showHints ? threatHints(s) : null,
result: s.result, result: s.result,
}; };
}, },

View File

@@ -259,11 +259,9 @@ describe('time', () => {
}); });
describe('views and playouts', () => { describe('views and playouts', () => {
test('hints are only for the player to move', () => { test('no threat guide (where to block) in any view', () => {
const s = make({}, { black: ['F8', 'G8', 'H8'], white: ['A1', 'A3'] }, 'white'); const s = make({}, { black: ['F8', 'G8', 'H8', 'I8'], white: ['A1', 'A3', 'A5'] }, 'white');
expect(omok.view(s, 'B').hints?.warn.length).toBeGreaterThan(0); for (const viewer of ['A', 'B', null]) expect(omok.view(s, viewer)).not.toHaveProperty('hints');
expect(omok.view(s, 'A').hints).toBeNull();
expect(omok.view(s, null).hints).toBeNull();
}); });
test('random playouts finish, are deterministic and fast', () => { test('random playouts finish, are deterministic and fast', () => {