위협 가이드 제거: 오목 위험 자리, 체스·장기 위험 기물 경고, 바둑 단수 경고
- 오목: 상대 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:
@@ -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);
|
||||||
|
|||||||
@@ -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 }}>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 ? '▲ 자세한 설정 닫기' : '▼ 자세한 설정'}
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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' })} />
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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. 참고 자료
|
||||||
|
|||||||
@@ -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. 참고 자료
|
||||||
|
|||||||
@@ -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 사용 지점)
|
||||||
|
|||||||
@@ -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. 참고 자료
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)),
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user