위협 가이드 제거: 오목 위험 자리, 체스·장기 위험 기물 경고, 바둑 단수 경고
- 오목: 상대 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:
@@ -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', () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user