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

- 엔진: 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

@@ -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', () => {