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

- 오목: 상대 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

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

View File

@@ -238,12 +238,6 @@ export function illegalPoints(
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
export interface Regions {

View File

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

View File

@@ -1,7 +1,7 @@
import { describe, expect, test } from 'bun:test';
import { SeededRng, randomPlayout, seedN } from '@bg/engine';
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]);
@@ -323,19 +323,17 @@ describe('requests', () => {
});
describe('views and playouts', () => {
test('legalMoves / threatened only for the player to move; no rng anywhere', () => {
const s = make({ hints: 'movesAndThreats' }, '4k3/8/8/3q4/8/8/3N4/K7 w - - 0 1');
test('legalMoves only for the player to move; no threat warnings; no rng anywhere', () => {
const s = make({}, '4k3/8/8/3q4/8/8/3N4/K7 w - - 0 1');
const mine = chess.view(s, 'A');
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();
expect(v.threatened).toBeNull();
for (const v of [chess.view(s, 'B'), chess.view(s, null)]) expect(v.legalMoves).toBeNull();
for (const v of [mine, chess.view(s, 'B'), chess.view(s, null)]) {
expect(v).not.toHaveProperty('threatened');
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(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', () => {
const s = play(make(), 'e2e4', 'd7d5', 'e4d5', 'd8d5', 'b1c3', 'd5e5');

View File

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

View File

@@ -193,15 +193,6 @@ export function cannotMate(c: Position, side: 'w' | 'b'): boolean {
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 {
return c.kingSquare(side);
}

View File

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

View File

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

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 {
Board,
candidateCells,
forbiddenCells,
forbiddenKind,
straightFourCount,
winningLine,
type Cell,
type RuleConfig,
@@ -64,10 +62,6 @@ export const omokOptionsSchema = z.object({
drawOffer: z.boolean().default(true).meta({ title: '무승부 제안 허용' }),
drawReofferMoves: z.number().int().min(0).max(50).default(3).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>;
@@ -126,7 +120,6 @@ export interface OmokView {
undo: { allowed: boolean; remaining: Record<Color, number | null>; pending: { by: Color; plies: number } | null };
draw: { allowed: boolean; pendingBy: Color | null };
forbidden: Pt[];
hints: { urgent: Pt[]; warn: Pt[] } | null;
result: OmokState['result'];
}
@@ -253,22 +246,6 @@ function chooseAutoMove(s: OmokState, rng: Rng): Pt {
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 {
if (s.undo.pending) return other(s.undo.pending.by);
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 limit = undoLimit(s.options);
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 {
size: s.size,
board: s.board,
@@ -487,7 +463,6 @@ export const omok: GameDefinition<OmokState, OmokAction, OmokView, OmokOptions>
},
draw: { allowed: s.options.drawOffer, pendingBy: s.draw.pendingBy },
forbidden: showForbidden ? s.forbidden.map((i) => toPt(i, s.size)) : [],
hints: showHints ? threatHints(s) : null,
result: s.result,
};
},

View File

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