위협 가이드를 관리자 전용 옵션으로(기본 꺼짐) + 최고관리자 추가 + 계정 메뉴에 관리자 메뉴

- 엔진: GameDefinition.adminOnlyOptions — 방은 항상 관리자 값 사용(잠금처럼 강제, 잠금 안내는 안 띄움)
- 오목·체스·장기 threatHints, 바둑 atariWarning: 기본 false, 대기실 설정에서 뺌, 관리자 화면 게임 기본 규칙에서만 켬
- 체스·장기 hints는 off / moves만 남김
- 최고관리자 기본값에 1352267557213573160 추가(.env.example·문서 포함)
- 상단 계정 메뉴: 관리자·최고관리자면 [🛠 관리자 메뉴] → /admin
- 테스트: admin.test(관리자 전용 옵션 강제, 두 번째 최고관리자), admin.e2e(계정 메뉴, 위협 가이드 켜기 저장)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
EJClaw
2026-10-07 15:59:32 +09:00
parent 4cb71d8cbe
commit 373231b553
32 changed files with 207 additions and 45 deletions

View File

@@ -531,8 +531,8 @@ describe('undo, draw, resign', () => {
});
describe('views and determinism', () => {
test('no hidden internals in any view; mover-only illegal points; no atari warning', () => {
let s = make({}, { black: ['B1', 'A2'], white: ['A3', 'C1'] }, 'white');
test('no hidden internals in any view; mover-only hints', () => {
let s = make({ atariWarning: true }, { 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]) {
const json = JSON.stringify(baduk.view(s, viewer));
@@ -541,9 +541,14 @@ 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]) 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);
for (const viewer of ['B', null]) {
const v = baduk.view(s, viewer);
expect(v.illegalPoints).toEqual([]);
expect(v.atariWarnings).toEqual([]);
}
expect(baduk.view(make({}, { black: ['B1'], white: ['A1', 'C1'] }), 'A').atariWarnings).toEqual([]); // off by default
});
test('nigiri is seeded and deterministic', () => {

View File

@@ -238,6 +238,12 @@ 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,6 +2,7 @@
import { z } from 'zod';
import { fail, ok, type GameDefinition, type GameEvent, type GameResult, type PlayerId } from '@bg/engine';
import {
atariStones,
chainAt,
emptyRegionAt,
hashBoard,
@@ -78,6 +79,7 @@ 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(false).meta({ title: '위협 가이드(관리자 전용): 단수에 몰린 내 돌 표시' }),
scoringDeadlineSec: z
.union([z.literal(120), z.literal(180), z.literal(300)])
.default(180)
@@ -174,7 +176,7 @@ export interface BadukView {
myColor: Color | null;
turn: Color;
phase: BadukState['phase'];
rules: { scoring: BadukOptions['scoring']; koRule: BadukOptions['koRule']; komi: number; handicap: number };
rules: { scoring: BadukOptions['scoring']; koRule: BadukOptions['koRule']; komi: number; handicap: number; atariWarning: boolean };
captures: Record<Color, number>;
moveCount: number;
moves: { color: Color; pt: Pt | null; captured: number }[];
@@ -198,6 +200,8 @@ 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. */
@@ -475,6 +479,7 @@ export const baduk: GameDefinition<BadukState, BadukAction, BadukView, BadukOpti
stateVersion: 1,
defaultOptions: badukOptionsSchema.parse({}),
optionsSchema: badukOptionsSchema,
adminOnlyOptions: ['atariWarning'],
actionSchema: badukActionSchema as z.ZodType<BadukAction>,
setup({ players, options, rng, now }) {
@@ -754,7 +759,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 },
rules: { scoring: s.options.scoring, koRule: s.options.koRule, komi: s.komi, handicap: s.handicap, atariWarning: s.options.atariWarning },
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 })),
@@ -783,6 +788,7 @@ 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 } from './rules';
import { START_FEN, adapterPerft, cannotMate, chess960Fen, fullFen, insufficientMaterial, keyOf, load, positionKey, threatenedSquares } from './rules';
const rng = () => SeededRng.fromSeed([1, 2, 3, 4]);
@@ -323,17 +323,19 @@ describe('requests', () => {
});
describe('views and playouts', () => {
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');
test('legalMoves / threatened only for the player to move; no rng anywhere', () => {
const s = make({ threatHints: true }, '4k3/8/8/3q4/8/8/3N4/K7 w - - 0 1');
const mine = chess.view(s, 'A');
expect(mine.legalMoves?.length).toBeGreaterThan(0);
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(mine.threatened).toEqual(['d2']);
for (const v of [chess.view(s, 'B'), chess.view(s, null)]) {
expect(v.legalMoves).toBeNull();
expect(v.threatened).toBeNull();
}
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(chess.optionsSchema.safeParse({ hints: 'movesAndThreats' }).success).toBe(false);
expect(threatenedSquares(load('4k3/8/8/3q4/8/8/3N4/K2Q4 w - - 0 1'))).toEqual([]);
});
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,6 +20,7 @@ import {
positionKey,
pseudoReachable,
startFenFor,
threatenedSquares,
toSide,
type Color,
type LegalMove,
@@ -75,6 +76,7 @@ export const chessOptionsSchema = z.object({
.enum(['off', 'moves'])
.default('moves')
.meta({ title: '초보 도움말', labels: { off: '끄기', moves: '갈 수 있는 칸 표시' } }),
threatHints: z.boolean().default(false).meta({ title: '위협 가이드(관리자 전용): 공격받는 내 기물 표시' }),
});
export type ChessOptions = z.infer<typeof chessOptionsSchema>;
@@ -180,6 +182,8 @@ export interface ChessView {
hints: ChessOptions['hints'];
/** Only for the player to move. */
legalMoves: LegalMove[] | null;
/** Only for the player to move with threatHints (admin-only option). */
threatened: string[] | null;
result: ChessResult | null;
}
@@ -314,6 +318,7 @@ export const chess: GameDefinition<ChessState, ChessAction, ChessView, ChessOpti
stateVersion: 2,
defaultOptions: chessOptionsSchema.parse({}),
optionsSchema: chessOptionsSchema,
adminOnlyOptions: ['threatHints'],
actionSchema: chessActionSchema as z.ZodType<ChessAction>,
setup({ players, options, rng, now, gameNo }) {
@@ -586,6 +591,7 @@ 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.threatHints ? threatenedSquares(c) : null,
result: s.result,
};
},

View File

@@ -193,6 +193,15 @@ 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 (threatHints). */
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,6 +17,7 @@ import {
placeArrangement,
positionKey,
pseudoMoves,
attacks,
sqOf,
type Arrangement,
type Board,
@@ -73,6 +74,7 @@ export const janggiOptionsSchema = z.object({
.enum(['off', 'moves'])
.default('moves')
.meta({ title: '도움 표시', labels: { off: '끄기', moves: '갈 수 있는 곳' } }),
threatHints: z.boolean().default(false).meta({ title: '위협 가이드(관리자 전용): 잡힐 위험이 있는 내 기물 표시' }),
});
export type JanggiOptions = z.infer<typeof janggiOptionsSchema>;
@@ -178,6 +180,7 @@ 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 };
@@ -443,6 +446,7 @@ export const janggi: GameDefinition<JanggiState, JanggiAction, JanggiView, Jangg
stateVersion: 1,
defaultOptions: janggiOptionsSchema.parse({}),
optionsSchema: janggiOptionsSchema,
adminOnlyOptions: ['threatHints'],
actionSchema: janggiActionSchema as z.ZodType<JanggiAction>,
setup({ players, options, rng, now, gameNo }) {
@@ -651,6 +655,14 @@ 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.threatHints) {
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;
@@ -676,6 +688,7 @@ 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,7 +511,7 @@ describe('requests, undo, views', () => {
expect(s.result).toMatchObject({ winner: 'cho', reason: 'resign' });
});
test('view: no rng, legal moves only for the player to move, no threat warnings', () => {
test('view: no rng, legal moves only for the player to move, threats only when the admin enables them', () => {
const s = standard();
expect(JSON.stringify(janggi.view(s, 'A'))).not.toContain('rng');
expect(janggi.view(s, null).legalMoves).toBeNull();
@@ -519,9 +519,9 @@ describe('requests, undo, views', () => {
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]]);
expect(janggi.view(t, 'A')).not.toHaveProperty('threatened');
expect(janggi.optionsSchema.safeParse({ hints: 'movesAndThreats' }).success).toBe(false);
expect(janggi.view(custom([...kingsOnly, ['cho', 'ma', 4, 4], ['han', 'cha', 4, 7]]), 'A').threatened).toEqual([]);
const t = custom([...kingsOnly, ['cho', 'ma', 4, 4], ['han', 'cha', 4, 7]], { threatHints: true });
expect(janggi.view(t, 'A').threatened).toEqual([{ f: 4, r: 4 }]);
});
test('포 legal moves carry their screen for the UI', () => {

View File

@@ -3,8 +3,10 @@ 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,
@@ -62,6 +64,7 @@ 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: '금수 자리 표시' }),
threatHints: z.boolean().default(false).meta({ title: '위협 가이드(관리자 전용): 상대 3·4를 막을 자리 표시' }),
});
export type OmokOptions = z.infer<typeof omokOptionsSchema>;
@@ -120,6 +123,7 @@ 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'];
}
@@ -246,6 +250,22 @@ 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);
@@ -260,6 +280,7 @@ export const omok: GameDefinition<OmokState, OmokAction, OmokView, OmokOptions>
stateVersion: 1,
defaultOptions: omokOptionsSchema.parse({}),
optionsSchema: omokOptionsSchema,
adminOnlyOptions: ['threatHints'],
actionSchema: omokActionSchema as z.ZodType<OmokAction>,
setup({ players, options, rng, now, gameNo }) {
@@ -437,6 +458,7 @@ 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.threatHints && s.phase === 'playing' && myColor !== null && myColor === s.turn;
return {
size: s.size,
board: s.board,
@@ -463,6 +485,7 @@ 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,9 +259,13 @@ describe('time', () => {
});
describe('views and playouts', () => {
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('threat guide is off by default; when the admin turns it on, only the player to move sees it', () => {
const off = make({}, { black: ['F8', 'G8', 'H8'], white: ['A1', 'A3'] }, 'white');
for (const viewer of ['A', 'B', null]) expect(omok.view(off, viewer).hints).toBeNull();
const s = make({ threatHints: true }, { 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('random playouts finish, are deterministic and fast', () => {