From 373231b5533314faa283d38baef19ccd43051a7c Mon Sep 17 00:00:00 2001 From: EJClaw Date: Wed, 7 Oct 2026 15:59:32 +0900 Subject: [PATCH] =?UTF-8?q?=EC=9C=84=ED=98=91=20=EA=B0=80=EC=9D=B4?= =?UTF-8?q?=EB=93=9C=EB=A5=BC=20=EA=B4=80=EB=A6=AC=EC=9E=90=20=EC=A0=84?= =?UTF-8?q?=EC=9A=A9=20=EC=98=B5=EC=85=98=EC=9C=BC=EB=A1=9C(=EA=B8=B0?= =?UTF-8?q?=EB=B3=B8=20=EA=BA=BC=EC=A7=90)=20+=20=EC=B5=9C=EA=B3=A0?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=EC=9E=90=20=EC=B6=94=EA=B0=80=20+=20?= =?UTF-8?q?=EA=B3=84=EC=A0=95=20=EB=A9=94=EB=89=B4=EC=97=90=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=EC=9E=90=20=EB=A9=94=EB=89=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 엔진: GameDefinition.adminOnlyOptions — 방은 항상 관리자 값 사용(잠금처럼 강제, 잠금 안내는 안 띄움) - 오목·체스·장기 threatHints, 바둑 atariWarning: 기본 false, 대기실 설정에서 뺌, 관리자 화면 게임 기본 규칙에서만 켬 - 체스·장기 hints는 off / moves만 남김 - 최고관리자 기본값에 1352267557213573160 추가(.env.example·문서 포함) - 상단 계정 메뉴: 관리자·최고관리자면 [🛠 관리자 메뉴] → /admin - 테스트: admin.test(관리자 전용 옵션 강제, 두 번째 최고관리자), admin.e2e(계정 메뉴, 위협 가이드 켜기 저장) Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- apps/server/src/admin.test.ts | 35 +++++++++++++++++++++++- apps/server/src/config.ts | 4 +-- apps/server/src/rooms/room.ts | 9 ++++-- apps/web/src/components/SiteHeader.tsx | 8 +++++- apps/web/src/games/baduk/help.ts | 1 + apps/web/src/games/baduk/index.tsx | 7 +++++ apps/web/src/games/chess/help.ts | 1 + apps/web/src/games/chess/index.tsx | 4 +++ apps/web/src/games/janggi/help.ts | 1 + apps/web/src/games/janggi/index.tsx | 5 ++++ apps/web/src/games/omok/help.ts | 2 ++ apps/web/src/games/omok/index.tsx | 4 ++- deploy/.env.example | 2 +- docs/11-deployment-ops.md | 2 +- docs/14-admin.md | 5 ++-- docs/games/baduk.md | 5 ++-- docs/games/chess.md | 9 +++--- docs/games/janggi.md | 5 ++-- docs/games/omok.md | 7 +++-- e2e/admin.e2e.ts | 16 ++++++++++- packages/engine/src/types.ts | 2 ++ packages/games/src/baduk/baduk.test.ts | 13 ++++++--- packages/games/src/baduk/board.ts | 6 ++++ packages/games/src/baduk/index.ts | 10 +++++-- packages/games/src/chess/chess.test.ts | 18 ++++++------ packages/games/src/chess/index.ts | 6 ++++ packages/games/src/chess/rules.ts | 9 ++++++ packages/games/src/janggi/index.ts | 13 +++++++++ packages/games/src/janggi/janggi.test.ts | 8 +++--- packages/games/src/omok/index.ts | 23 ++++++++++++++++ packages/games/src/omok/omok.test.ts | 10 +++++-- 32 files changed, 207 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index 87542eb..216bffb 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ cd deploy && docker compose up -d --build 자세한 절차는 [docs/11-deployment-ops.md](docs/11-deployment-ops.md). ## 관리자 -`/admin`. 슈퍼어드민은 디스코드 ID `293719842274541579`(환경 변수 `SUPERADMIN_DISCORD_IDS`), 어드민은 관리자 화면에서 디스코드 ID로 추가한다. 자세한 내용은 [docs/14-admin.md](docs/14-admin.md). +`/admin`. 슈퍼어드민은 디스코드 ID `293719842274541579`, `1352267557213573160`(환경 변수 `SUPERADMIN_DISCORD_IDS`), 어드민은 관리자 화면에서 디스코드 ID로 추가한다. 자세한 내용은 [docs/14-admin.md](docs/14-admin.md). ## 구조 - `packages/engine` — 게임 공통 타입, 시드 난수, 테스트 도구 diff --git a/apps/server/src/admin.test.ts b/apps/server/src/admin.test.ts index e29b31e..d13d73c 100644 --- a/apps/server/src/admin.test.ts +++ b/apps/server/src/admin.test.ts @@ -47,6 +47,8 @@ describe('roles', () => { const me = await api(s.base, '/api/admin/me', { cookie: sup.cookie }); expect(me.body.role).toBe('superadmin'); expect((await api(s.base, '/api/me', { cookie: sup.cookie })).body.role).toBe('superadmin'); + const sup2 = await discordLogin(s, '1352267557213573160'); + expect((await api(s.base, '/api/admin/me', { cookie: sup2.cookie })).body.role).toBe('superadmin'); }); test('superadmin adds/removes an admin by Discord ID; takes effect immediately; admin cannot manage users', async () => { @@ -58,7 +60,7 @@ describe('roles', () => { // Pre-register before the person ever logged in. const add = await api(s.base, '/api/admin/admins', { method: 'POST', cookie: sup.cookie, body: JSON.stringify({ discordId: adminDiscord, note: '친구1' }) }); expect(add.res.status).toBe(200); - expect(add.body.admins.map((a: any) => a.discordId)).toEqual([SUPER, adminDiscord]); + expect(add.body.admins.map((a: any) => a.discordId)).toEqual([SUPER, '1352267557213573160', adminDiscord]); const adm = await discordLogin(s, adminDiscord); expect((await api(s.base, '/api/admin/me', { cookie: adm.cookie })).body.role).toBe('admin'); expect((await api(s.base, '/api/admin/users', { cookie: adm.cookie })).res.status).toBe(403); @@ -139,6 +141,37 @@ describe('game settings', () => { }); }); +describe('admin-only options (threat guides)', () => { + test('off by default; the host cannot turn them on; the admin default always wins and is not shown as a lock', async () => { + const s = start(); + const host = await guest(s.base, '방장'); + const room = await api(s.base, '/api/rooms', { method: 'POST', cookie: host.cookie, body: JSON.stringify({ gameId: 'omok' }) }); + const c = await new Client(s.base, host.cookie, host.id).connect(); + clients.push(c); + c.send({ t: 'join', code: room.body.code, as: 'player' }); + const first = await c.next('room'); + expect((first.room.options as any).threatHints).toBe(false); + expect(first.room.lockedOptions).toEqual([]); + c.send({ t: 'config', options: { ...(first.room.options as any), threatHints: true, boardSize: 19 } }); + const after = await c.next('room', (m) => (m.room.options as any).boardSize === 19); + expect((after.room.options as any).threatHints).toBe(false); + + const sup = await discordLogin(s, SUPER); + const put = await api(s.base, '/api/admin/games/omok', { method: 'PUT', cookie: sup.cookie, body: JSON.stringify({ defaultOptions: { threatHints: true } }) }); + expect(put.res.status).toBe(200); + const room2 = await api(s.base, '/api/rooms', { method: 'POST', cookie: host.cookie, body: JSON.stringify({ gameId: 'omok' }) }); + const c2 = await new Client(s.base, host.cookie, host.id).connect(); + clients.push(c2); + c2.send({ t: 'join', code: room2.body.code, as: 'player' }); + const r2 = await c2.next('room'); + expect((r2.room.options as any).threatHints).toBe(true); + expect(r2.room.lockedOptions).toEqual([]); + c2.send({ t: 'config', options: { ...(r2.room.options as any), threatHints: false, boardSize: 19 } }); + const after2 = await c2.next('room', (m) => (m.room.options as any).boardSize === 19); + expect((after2.room.options as any).threatHints).toBe(true); + }); +}); + describe('site settings, rooms, users', () => { test('maintenance blocks room creation; banned words masked in chat and rejected in nicknames', async () => { const s = start(); diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 8a56a9e..3afacb9 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -14,8 +14,8 @@ export interface Config { loadTestNoHttpLimits: boolean; } -/** The site owner's Discord ID (docs/14-admin.md §2). */ -export const DEFAULT_SUPERADMIN = '293719842274541579'; +/** Superadmin Discord IDs, comma-separated (docs/14-admin.md §2). */ +export const DEFAULT_SUPERADMIN = '293719842274541579,1352267557213573160'; export function loadConfig(env: Record = process.env): Config { const publicOrigin = (env.PUBLIC_ORIGIN ?? 'http://localhost:5173').replace(/\/$/, ''); diff --git a/apps/server/src/rooms/room.ts b/apps/server/src/rooms/room.ts index cecdd44..6f4f5fc 100644 --- a/apps/server/src/rooms/room.ts +++ b/apps/server/src/rooms/room.ts @@ -175,9 +175,14 @@ export class Room { return min <= max ? { min, max } : base; } + /** Options forced to the admin value: admin-locked ones plus the game's admin-only options (e.g. threat guides). */ private locked(gameId: string): { defaults: unknown; paths: string[] } { const eff = this.deps.settings?.game(gameId); - return { defaults: eff?.defaultOptions, paths: eff?.lockedOptions ?? [] }; + const adminOnly = this.deps.games[gameId]?.adminOnlyOptions ?? []; + return { + defaults: eff?.defaultOptions ?? this.deps.games[gameId]?.defaultOptions, + paths: [...new Set([...(eff?.lockedOptions ?? []), ...adminOnly])], + }; } seatOf(userId: string): number { @@ -321,7 +326,7 @@ export class Room { rematchVotes: [...this.rematchVotes], sessionStats: this.sessionStats, seedHash: this.game?.seedHash ?? null, - lockedOptions: this.locked(this.config.gameId).paths, + lockedOptions: this.deps.settings?.game(this.config.gameId)?.lockedOptions ?? [], lastSeed: this.lastSeed, lastGamePk: this.game?.finished ? this.game.gamePk : null, }; diff --git a/apps/web/src/components/SiteHeader.tsx b/apps/web/src/components/SiteHeader.tsx index 41488b5..d071be3 100644 --- a/apps/web/src/components/SiteHeader.tsx +++ b/apps/web/src/components/SiteHeader.tsx @@ -56,9 +56,10 @@ function ThemeToggle() { ); } -/** 로그인한 뒤: 닉네임(디스코드 연결 시 디스코드 이름) → 누르면 [설정] [로그아웃] 메뉴. */ +/** 로그인한 뒤: 닉네임(디스코드 연결 시 디스코드 이름) → 누르면 [설정] ([관리자 메뉴]) [로그아웃] 메뉴. */ function AccountMenu() { const me = useSession((s) => s.me)!; + const role = useSession((s) => s.role); const nav = useNavigate(); const [open, setOpen] = useState(false); const [confirm, setConfirm] = useState(false); @@ -90,6 +91,11 @@ function AccountMenu() { + {role !== 'user' && ( + + )} diff --git a/apps/web/src/games/baduk/help.ts b/apps/web/src/games/baduk/help.ts index bf4d2a3..471f32e 100644 --- a/apps/web/src/games/baduk/help.ts +++ b/apps/web/src/games/baduk/help.ts @@ -69,6 +69,7 @@ export function helpNow(v: BadukView, _me: string | null, players: Record p.reason !== 'suicide')) lines.push('빨간 네모 자리는 패(바로 되따내기 금지)나 같은 모양 반복 때문에 지금은 둘 수 없어요. 다른 곳에 한 수 둔 뒤에 다시 봐요.'); lines.push(caps); diff --git a/apps/web/src/games/baduk/index.tsx b/apps/web/src/games/baduk/index.tsx index c54632d..7445f19 100644 --- a/apps/web/src/games/baduk/index.tsx +++ b/apps/web/src/games/baduk/index.tsx @@ -279,6 +279,12 @@ function BadukBoard({ view: v, act, pending, tapConfirm }: BoardProps {v.lastMove && lastColor && ( )} + {myTurn && + v.atariWarnings.map((p) => ( + + + + ))} {ghost && v.myColor && } {hoverMark && } {(myTurn || marking) && kbd && } @@ -633,6 +639,7 @@ function statusText(v: BadukView, _me: string | null, players: Record 0) return '상대가 패스했어요. 끝내려면 패스하세요'; + if (v.atariWarnings.length) return `내 차례예요 (${COLOR_KO[v.turn]}). 단수에 몰린 돌이 있어요!`; return `내 차례예요 (${COLOR_KO[v.turn]}). 둘 곳을 눌러 주세요`; } const nick = players[v.players[v.turn]]?.nickname ?? COLOR_KO[v.turn]; diff --git a/apps/web/src/games/chess/help.ts b/apps/web/src/games/chess/help.ts index adf0625..21e7a23 100644 --- a/apps/web/src/games/chess/help.ts +++ b/apps/web/src/games/chess/help.ts @@ -43,6 +43,7 @@ export function helpNow(v: ChessView, _me: string | null, players: Record m.promotion)) lines.push(v.autoQueen ? '폰을 끝 줄로 보내면 자동으로 퀸이 돼요(승진).' : '폰을 끝 줄로 보내면 퀸·룩·비숍·나이트 중 무엇으로 바꿀지 고르는 창이 떠요(승진). 보통은 퀸이에요.'); if (legal.some((m) => m.castle)) lines.push('지금 캐슬링을 할 수 있어요. 킹을 누르면 캐슬링할 칸도 표시돼요.'); + if (v.threatened?.length) lines.push('주황 점선 네모가 쳐진 내 기물은 지켜 주는 기물 없이 공격받고 있어요. 조심하세요.'); if (v.drawMode === 'fide' && v.claimable) lines.push(`[⚖ 무승부 주장]을 누르면 상대 동의 없이 바로 비길 수 있어요(${v.claimable === 'threefold' ? '같은 국면 3번' : '50수 동안 폰 이동·잡기 없음'}).`); if (mat) lines.push(mat); diff --git a/apps/web/src/games/chess/index.tsx b/apps/web/src/games/chess/index.tsx index 8c08097..1849342 100644 --- a/apps/web/src/games/chess/index.tsx +++ b/apps/web/src/games/chess/index.tsx @@ -328,6 +328,10 @@ function ChessBoard({ view: v, act, pending }: BoardProps) { {lastFrom && } {lastTo && } {selected && } + {v.threatened?.map((sq) => { + const c = cell(sq); + return ; + })} {check && } {/* Coordinates: files on the bottom row, ranks on the left column. */} {Array.from({ length: 8 }, (_, i) => ( diff --git a/apps/web/src/games/janggi/help.ts b/apps/web/src/games/janggi/help.ts index efa6655..bd12b78 100644 --- a/apps/web/src/games/janggi/help.ts +++ b/apps/web/src/games/janggi/help.ts @@ -75,6 +75,7 @@ export function helpNow(v: JanggiView, _me: string | null, players: Record; })} + {myTurn && + v.threatened.map((q) => { + const s = toScreen(q); + return ; + })} {[...vias].map((i) => { const s = toScreen(fromKey(i)); return ; diff --git a/apps/web/src/games/omok/help.ts b/apps/web/src/games/omok/help.ts index 5bf62dd..1267054 100644 --- a/apps/web/src/games/omok/help.ts +++ b/apps/web/src/games/omok/help.ts @@ -18,6 +18,8 @@ export function helpNow(v: OmokView, _me: string | null, players: Record) {stars(n).map((p) => ( ))} + {myTurn && v.hints?.urgent.map((p) => )} + {myTurn && v.hints?.warn.map((p) => )} {v.forbidden.map((p) => ( @@ -468,7 +470,7 @@ function Rules({ options }: { options?: OmokOptions }) { 자유룰: 금지된 수가 없어요. 설정에 따라 5개 이상이면 이기거나, 딱 5개일 때만 이겨요.

)} -

둘 수 없는 자리는 판에 빨간 ×로 표시돼요(설정에서 끌 수 있어요).

+

둘 수 없는 자리는 판에 빨간 ×로 표시돼요(설정에서 끌 수 있어요).{options?.threatHints ? ' 이 방은 상대가 곧 이길 수 있는 자리를 빨간 동그라미, 위험한 자리를 노란 점선으로 알려 줘요.' : ''}

휴대폰에서는 한 번 누르면 미리 보기 돌이 놓이고, 같은 곳을 한 번 더 누르거나 아래 [두기] 버튼을 누르면 확정돼요.

); diff --git a/deploy/.env.example b/deploy/.env.example index 5fff799..6ea4ae8 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -8,4 +8,4 @@ DISCORD_CLIENT_ID= DISCORD_CLIENT_SECRET= LOG_LEVEL=info # Super admin Discord user IDs (comma separated). Default is the site owner. -SUPERADMIN_DISCORD_IDS=293719842274541579 +SUPERADMIN_DISCORD_IDS=293719842274541579,1352267557213573160 diff --git a/docs/11-deployment-ops.md b/docs/11-deployment-ops.md index 8f7c923..db90016 100644 --- a/docs/11-deployment-ops.md +++ b/docs/11-deployment-ops.md @@ -16,7 +16,7 @@ deploy/ | `DISCORD_CLIENT_ID` / `DISCORD_CLIENT_SECRET` | 디스코드 앱 | | `SESSION_SECRET` | 짧은 수명 서명 쿠키(OAuth state)용 32바이트 | | `DB_PATH` | 기본 `/data/app.db` | -| `SUPERADMIN_DISCORD_IDS` | 슈퍼어드민 디스코드 ID(쉼표 구분). 기본 `293719842274541579` | +| `SUPERADMIN_DISCORD_IDS` | 슈퍼어드민 디스코드 ID(쉼표 구분). 기본 `293719842274541579,1352267557213573160` | | `TRUST_PROXY` | `1`이면 Caddy가 붙인 X-Forwarded-For로 IP 판단 | | `PORT` | 기본 3000 | | `LOG_LEVEL` | info | diff --git a/docs/14-admin.md b/docs/14-admin.md index 6c8805d..546d829 100644 --- a/docs/14-admin.md +++ b/docs/14-admin.md @@ -8,7 +8,7 @@ ## 2. 권한 | 등급 | 누가 | 할 수 있는 것 | |---|---|---| -| 슈퍼어드민 | 디스코드 ID `293719842274541579` (설정 `SUPERADMIN_DISCORD_IDS`, 기본값이 이 ID) | 모든 것: 아래 어드민 권한 + 사용자 관리 + 어드민 추가/삭제 | +| 슈퍼어드민 | 디스코드 ID `293719842274541579`, `1352267557213573160` (설정 `SUPERADMIN_DISCORD_IDS`, 기본값이 이 두 ID) | 모든 것: 아래 어드민 권한 + 사용자 관리 + 어드민 추가/삭제 | | 어드민 | 슈퍼어드민이 디스코드 ID로 등록한 사람 | 관리자 사이트 보기, 게임 설정, 사이트 설정, 방 관리, 기록 보기 | | 일반 | 그 외(게스트 포함) | 관리자 사이트 접근 불가(관리자 주소로 들어오면 "권한이 없어요") | @@ -28,7 +28,7 @@ | 관리자 | × | ○ | 어드민 목록, 디스코드 ID로 추가(메모 포함), 삭제 | | 기록 | ○ | ○ | 관리자 작업 기록(누가, 언제, 무엇을, 바꾸기 전/후) | -계정 페이지(`/me`)에 관리자에게만 [관리자 페이지] 버튼을 보여 준다. +계정 페이지(`/me`)와 상단 계정 메뉴에 관리자에게만 [관리자 페이지]·[관리자 메뉴]를 보여 준다. ## 4. 게임 설정 (게임마다) | 항목 | 설명 | @@ -42,6 +42,7 @@ - 화면의 입력 칸은 게임 모듈의 옵션 스키마(zod → JSON Schema)에서 **자동으로 만든다**. 새 옵션이 생기면 관리자 화면에 자동으로 나타나므로 빠지는 항목이 없다. - 그래서 모든 게임 모듈은 옵션마다 한국어 제목과 선택지 이름을 `meta`로 붙인다(`09-game-engine.md` 2절). 게임 규칙 안에 숫자로 박혀 있던 값(예: 오목 자동 착수 허용 횟수, 무승부 재제안 간격)도 옵션으로 꺼내서 관리자가 바꿀 수 있게 한다. +- **관리자 전용 옵션**: 게임 모듈의 `adminOnlyOptions`에 적힌 옵션은 대기실 설정에 나오지 않고, 잠금과 같이 항상 관리자 값으로 고정된다. 지금은 위협 가이드(상대 수를 막으라는 표시)가 여기에 해당하고 기본은 꺼짐: 오목·체스·장기 `threatHints`, 바둑 `atariWarning`. 켜려면 관리자 화면 → 게임 → 기본 규칙에서 켠다. - 저장할 때 스키마로 검사하고, 통과하지 못하면 저장하지 않는다. - 바꾼 설정은 **새로 만드는 방과 새로 시작하는 판**부터 적용된다. 진행 중인 판의 규칙은 중간에 바뀌지 않는다(공정성). diff --git a/docs/games/baduk.md b/docs/games/baduk.md index 1c99d44..43a19b1 100644 --- a/docs/games/baduk.md +++ b/docs/games/baduk.md @@ -24,6 +24,7 @@ | `idleLimitSec` | `none`일 때 무응답 한도 | 300 / 600 | 600 | | `undo` | 무르기 허용 횟수(1인당, 상대 동의) | `off` / `1` / `3` / `unlimited` | `3` | | `drawOffer` | 무승부(빅) 합의 제안 허용 | `true` / `false` | `true` | +| `atariWarning` | **관리자 전용** 위협 가이드: 단수에 몰린 내 돌 표시(방장은 못 바꿈) | `true` / `false` | `false` | | `scoringDeadlineSec` | 사석 확인 단계 제한 시간 | 120 / 180 / 300 | 180 | | `disconnectGraceSec` | 연결 끊김 유예 | 60 / 120 / 300 | 120 | @@ -191,7 +192,7 @@ interface BadukState { ### 8.3 공개/비공개 정보 (view) - 모두 공개(플레이어·관전자 동일): 판, 차례, 포로 수, 기보, 단계, 사석/빅 표시와 계가 미리보기, 양쪽 동의 여부, 시계(기본 시간, 초읽기 남은 횟수, `deadline`), 대기 중인 요청, 결과. -- 차례인 플레이어에게만 편의 정보: `illegalPoints`(패·자충으로 둘 수 없는 점). 단수 경고 같은 위협 표시는 보내지 않는다. +- 차례인 플레이어에게만 편의 정보: `illegalPoints`(패·자충으로 둘 수 없는 점), `atariWarnings`. - view에서 제외: `rng`, `positionHistory`(크기 문제로 제외, 필요하면 기보로 재구성), `situationCounts`. ### 8.4 랜덤 요소 (시드 RNG 사용 지점) @@ -254,7 +255,7 @@ interface BadukResult { - [ ] `scoring` 단계 시간 초과 → 미응답자 자동 동의 후 계가. - [ ] 연결 끊김 유예 후 시간패, 재접속 시 유예 해제. - [ ] 무르기 수락 시 따낸 돌이 복원되고 포로 수·슈퍼코 이력이 재생으로 정확히 복원. -- [ ] 정보 누출: view에 `rng`, `positionHistory`, `situationCounts`가 없는지. 관전자 view에 `illegalPoints` 없음. +- [ ] 정보 누출: view에 `rng`, `positionHistory`, `situationCounts`가 없는지. 관전자 view에 `illegalPoints`/`atariWarnings` 없음. - [ ] 리플레이 결정성: 같은 시드·액션 로그 → 같은 결과. ## 11. 참고 자료 diff --git a/docs/games/chess.md b/docs/games/chess.md index f448795..be20af8 100644 --- a/docs/games/chess.md +++ b/docs/games/chess.md @@ -20,7 +20,8 @@ | `drawOffer` | 무승부 제안 | `true` / `false` | `true` | | `firstMoveSec` | 첫 수 제한(넘기면 대국 취소) | 15 / 30 / 60 | 30 | | `idleLimitSec` | `timeControl=none`일 때 무응답 한도 | 300 / 600 | 600 | -| `hints` | 초보 도움말(이동 가능 칸 표시). 공격받는 기물 경고 같은 위협 표시는 두지 않는다 | `off` / `moves` | `moves` | +| `hints` | 초보 도움말(이동 가능 칸 표시) | `off` / `moves` | `moves` | +| `threatHints` | **관리자 전용** 위협 가이드: 공격받고 방어 안 된 내 기물 표시(방장은 못 바꿈) | `true` / `false` | `false` | | `disconnectGraceSec` | 연결 끊김 유예 | 30 / 60 / 120 | 60 | ## 3. 구성물 @@ -140,9 +141,9 @@ interface ChessState { ### 8.3 공개/비공개 정보 (view) - 공통 공개: `fen`, 기보(SAN), 마지막 수, 체크 여부, 시계, `deadline`, 잡은 기물 목록과 기물 점수 차(표시용: 퀸9 룩5 비숍3 나이트3 폰1), 대기 중인 요청, `claimable`, 결과. -- 차례인 플레이어에게: `legalMoves: { from; to; promotion?; castle? }[]`(하이라이트용). +- 차례인 플레이어에게: `legalMoves: { from; to; promotion?; castle? }[]`(하이라이트용). `threatHints`가 켜졌으면 `threatenedSquares`(내 기물 중 공격받고 방어 안 된 칸). - 차례가 아닌 플레이어: 프리무브 UI를 위해 자기 기물의 의사 합법 수를 클라이언트에서 계산하지 않고, v1은 프리무브 미지원(단순성·공정성). -- 관전자: `legalMoves` 없음. +- 관전자: `legalMoves`/`threatenedSquares` 없음. - 제외: `rng`(960 배치는 셋업 직후 공개되므로 문제없지만 다음 대국 배치 예측 방지). ### 8.4 랜덤 요소 (시드 RNG 사용 지점) @@ -203,7 +204,7 @@ interface ChessResult { - [ ] Chess960: n=518이 표준 배치, 960개 번호 모두 비숍 반대 색·킹이 두 룩 사이 조건 만족·서로 다름, 킹이 g열에 이미 있는 배치에서 킹사이드 캐슬링(킹 0칸 이동), 룩 경로·킹 경로·도착 칸 막힘/공격, 룩이 도착 칸을 가리던 경우, 권리 상실, 무르기 후 권리 복원, 무작위 960 대국(`chess.test.ts`), perft(`chess.perft.test.ts`). - [ ] 무르기: 1/2 플라이 복원 후 `repetition` 카운트 정확, 거절 시 무변화. - [ ] 연결 끊김 유예 후 시간패 처리. -- [ ] 정보 누출: 관전자·비차례 플레이어 view에 `legalMoves` 없음, 모든 view에 `rng` 없음. +- [ ] 정보 누출: 관전자·비차례 플레이어 view에 `legalMoves`/`threatenedSquares` 없음, 모든 view에 `rng` 없음. - [ ] 리플레이 결정성(같은 시드 → 같은 960 배치와 결과). ## 11. 참고 자료 diff --git a/docs/games/janggi.md b/docs/games/janggi.md index 9a7947f..34b8be4 100644 --- a/docs/games/janggi.md +++ b/docs/games/janggi.md @@ -23,7 +23,8 @@ | `idleLimitSec` | `none`일 때 무응답 한도 | 300 / 600 | 600 | | `undo` | 무르기 허용 횟수(상대 동의) | `off` / `1` / `3` / `unlimited` | `3` | | `drawOffer` | 무승부 제안 | `true` / `false` | `true` | -| `hints` | 이동 가능 칸 표시. 잡힐 위험 같은 위협 경고는 두지 않는다 | `off` / `moves` | `moves` | +| `hints` | 이동 가능 칸 표시 | `off` / `moves` | `moves` | +| `threatHints` | **관리자 전용** 위협 가이드: 잡힐 위험이 있는 내 기물 표시(방장은 못 바꿈) | `true` / `false` | `false` | | `disconnectGraceSec` | 연결 끊김 유예 | 60 / 120 | 60 | ## 3. 구성물 @@ -180,7 +181,7 @@ interface JanggiState { ### 8.3 공개/비공개 정보 (view) - 공개: 판, 차례, 기보, 잡은 기물, 현재 점수(덤 포함), 장군 상태, 빅장 응수 대기 여부, 반복 경고(같은 국면 3회째 도달 시 "한 번 더 반복하면 둘 수 없어요"), 시계, `deadline`, 대기 중인 요청, 결과. - `setup` 단계 + `simultaneous`: 상대 선택값은 "선택 완료"(true/false)만 보이고 배치는 둘 다 확정될 때까지 숨긴다(플레이어·관전자 모두). `hanFirst`에서는 한의 선택이 즉시 공개. -- 차례인 플레이어에게만: `legalMoves`(하이라이트용). +- 차례인 플레이어에게만: `legalMoves`(하이라이트용), `threatHints`가 켜졌을 때 `threatened`(잡힐 위험 기물). - 제외: `rng`, 상대의 미공개 상차림. ### 8.4 랜덤 요소 (시드 RNG 사용 지점) diff --git a/docs/games/omok.md b/docs/games/omok.md index c8af72d..b891fa4 100644 --- a/docs/games/omok.md +++ b/docs/games/omok.md @@ -24,6 +24,7 @@ | `undo` | 무르기 요청 허용 횟수(1인당, 상대 동의 필요) | `off` / `1` / `3` / `unlimited` | `3` | | `drawOffer` | 무승부 제안 허용 | `true` / `false` | `true` | | `showForbidden` | 금수 자리 표시 (금수 있는 규칙만) | `true` / `false` | `true` | +| `threatHints` | **관리자 전용** 위협 가이드: 상대 3·4를 막을 자리 표시(방장은 못 바꿈) | `true` / `false` | `false` | ## 3. 구성물 - 15×15(교차점 225개) 또는 19×19(361개) 판. 돌은 교차점에 놓는다. @@ -252,8 +253,8 @@ interface OmokState { ### 8.3 공개/비공개 정보 (view) - 플레이어·관전자 공통 공개: 판, 차례, 기보, 승리 줄, 양쪽 남은 시간과 `deadline`, 대기 중인 무르기/무승부 요청 여부(누가 요청했는지), 남은 무르기 횟수, 결과. -- 차례인 플레이어에게만: `forbiddenPoints: Pt[]` (금수 표시 옵션이 켜져 있을 때). 상대의 3·4를 막으라는 경고(위협 표시)는 보내지 않는다. -- 관전자에게는 `forbiddenPoints`를 흑 기준으로 보여줄 수 있다(공개 정보이므로 허용, 해설용). +- 차례인 플레이어에게만: `forbiddenPoints: Pt[]` (금수 표시 옵션이 켜져 있을 때), `hints`(`threatHints`가 켜졌을 때만: 상대가 5목·4를 만들 자리). +- 관전자에게는 `forbiddenPoints`를 흑 기준으로 보여줄 수 있다(공개 정보이므로 허용, 해설용). 단 `hints`는 관전자에게 보내지 않는다(관전자가 플레이어에게 훈수 전달하는 것을 굳이 돕지 않기 위함). - 절대 포함 금지: `rng`(시드/상태 — 자동 착수 위치 예측 가능), 서버 내부 메모이제이션. ### 8.4 랜덤 요소 (시드 RNG 사용 지점) @@ -313,7 +314,7 @@ interface OmokResult { - [ ] `perMove + autoMove`: 시간 초과 시 자동 착수가 합법 수이고 금수가 아니며, 같은 시드에서 항상 같은 자리. 3연속 자동 착수 시 시간패. - [ ] `fischer`: 남은 시간 0 이후 도착한 착수는 거부되고 시간패, 정상 착수 시 증가분 가산. - [ ] 연결 끊김: 끊긴 플레이어 차례에 유예 후 `onTimeout` 동작, 재접속 시 유예 타이머 해제. -- [ ] 정보 누출: 플레이어/관전자 view JSON에 `rng`가 없는지, 어떤 view에도 위협 표시(`hints`)가 없는지 스냅샷 테스트. +- [ ] 정보 누출: 플레이어/관전자 view JSON에 `rng`가 없는지, 관전자 view에 `hintThreats`가 없는지 스냅샷 테스트. - [ ] 리플레이: 같은 시드 + 같은 액션 로그로 재실행 시 최종 상태 해시 동일. ## 11. 참고 자료 diff --git a/e2e/admin.e2e.ts b/e2e/admin.e2e.ts index 939de20..96589b3 100644 --- a/e2e/admin.e2e.ts +++ b/e2e/admin.e2e.ts @@ -69,7 +69,15 @@ try { await a.screenshot({ path: join(SHOTS, 'admin-1-dashboard.png') }); }); - await step('게임 설정: 오목 렌주룰 고정 + 한 수 45초 저장', async () => { + await step('상단 계정 메뉴에 [관리자 메뉴] → 관리자 화면', async () => { + await a.goto(ORIGIN + '/'); + await a.getByRole('button', { name: /내 계정:/ }).click(); + await a.getByRole('menuitem', { name: /관리자 메뉴/ }).click(); + await a.waitForURL(`${ORIGIN}/admin`); + await a.getByText('지금 접속').waitFor(); + }); + + await step('게임 설정: 오목 렌주룰 고정 + 한 수 45초 + 위협 가이드 켜기 저장', async () => { await a.locator('.admin-side').getByRole('link', { name: '게임', exact: true }).click(); await a.getByRole('button', { name: /오목/ }).click(); await a.getByText('기본 규칙').waitFor(); @@ -78,9 +86,13 @@ try { await rule.getByLabel(/방장 변경 금지/).check(); const per = a.locator('div.stack:has(> div.row > strong:text-is("한 수 제한(초)"))').first(); await per.locator('input[type=number]').fill('45'); + const threat = a.locator('div.stack:has(> div.row > strong:text-is("위협 가이드(관리자 전용): 상대 3·4를 막을 자리 표시"))').first(); + await threat.getByRole('button', { name: '켜기', exact: true }).click(); await a.screenshot({ path: join(SHOTS, 'admin-2-game-editor.png') }); await a.getByRole('button', { name: '저장', exact: true }).click(); await a.getByText('저장했어요.').waitFor(); + const saved = await a.evaluate(async () => (await (await fetch('/api/admin/games')).json()).games.find((x: any) => x.id === 'omok').effective.defaultOptions.threatHints); + if (saved !== true) throw new Error(`threatHints not saved: ${saved}`); }); await step('관리자 탭: 디스코드 ID로 어드민 추가', async () => { @@ -109,8 +121,10 @@ try { await g.waitForURL(/\/r\/\d{6}$/); await g.getByText('렌주룰 · 15줄 · 한 수 45초').waitFor(); await g.getByText('일부 규칙은 관리자가 정해 두어서').waitFor(); + // The admin turned the threat guide on; the host has no switch for it (checked below). // Trying to switch to 한국식 is overridden by the server. await g.getByRole('button', { name: '설정 바꾸기' }).click(); + if (await g.getByText(/위험한 자리 알려|위협 가이드/).count()) throw new Error('host sees a threat-guide switch'); await g.getByRole('button', { name: '한국식(쌍삼 금지)' }).click(); await g.getByRole('button', { name: '저장' }).click(); await g.waitForTimeout(300); diff --git a/packages/engine/src/types.ts b/packages/engine/src/types.ts index 6da011c..5b287db 100644 --- a/packages/engine/src/types.ts +++ b/packages/engine/src/types.ts @@ -50,6 +50,8 @@ export interface GameDefinition { stateVersion: number; defaultOptions: O; optionsSchema: ZodType; + /** Top-level option keys only the admin sets (game settings): hosts can't change them, rooms always use the admin value. */ + adminOnlyOptions?: readonly string[]; actionSchema: ZodType; playersFor?(options: O): { min: number; max: number }; /** The game runs its own (filtered) chat channels: the room chat and quick reactions are closed while it is played. */ diff --git a/packages/games/src/baduk/baduk.test.ts b/packages/games/src/baduk/baduk.test.ts index c2868ff..68bd547 100644 --- a/packages/games/src/baduk/baduk.test.ts +++ b/packages/games/src/baduk/baduk.test.ts @@ -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', () => { diff --git a/packages/games/src/baduk/board.ts b/packages/games/src/baduk/board.ts index 10acdd1..fdf8af5 100644 --- a/packages/games/src/baduk/board.ts +++ b/packages/games/src/baduk/board.ts @@ -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 { diff --git a/packages/games/src/baduk/index.ts b/packages/games/src/baduk/index.ts index 0f47c6d..bbeb41a 100644 --- a/packages/games/src/baduk/index.ts +++ b/packages/games/src/baduk/index.ts @@ -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; 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, setup({ players, options, rng, now }) { @@ -754,7 +759,7 @@ export const baduk: GameDefinition ({ 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 ({ ...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)), diff --git a/packages/games/src/chess/chess.test.ts b/packages/games/src/chess/chess.test.ts index d00f248..2d4bbb7 100644 --- a/packages/games/src/chess/chess.test.ts +++ b/packages/games/src/chess/chess.test.ts @@ -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'); diff --git a/packages/games/src/chess/index.ts b/packages/games/src/chess/index.ts index 660505a..7e64570 100644 --- a/packages/games/src/chess/index.ts +++ b/packages/games/src/chess/index.ts @@ -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; @@ -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, setup({ players, options, rng, now, gameNo }) { @@ -586,6 +591,7 @@ export const chess: GameDefinition 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); } diff --git a/packages/games/src/janggi/index.ts b/packages/games/src/janggi/index.ts index 089bbed..d989bf7 100644 --- a/packages/games/src/janggi/index.ts +++ b/packages/games/src/janggi/index.ts @@ -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; @@ -178,6 +180,7 @@ export interface JanggiView { /** Only for the player to move. */ legalMoves: JanggiLegalMove[] | null; canPass: boolean; + threatened: Sq[]; rules: Pick; clock: JanggiState['clock']; undo: { allowed: boolean; remaining: Record; pending: { by: Side; plies: number } | null }; @@ -443,6 +446,7 @@ export const janggi: GameDefinition, setup({ players, options, rng, now, gameNo }) { @@ -651,6 +655,14 @@ export const janggi: GameDefinition = { 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 { 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', () => { diff --git a/packages/games/src/omok/index.ts b/packages/games/src/omok/index.ts index 070b668..8a234a9 100644 --- a/packages/games/src/omok/index.ts +++ b/packages/games/src/omok/index.ts @@ -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; @@ -120,6 +123,7 @@ export interface OmokView { undo: { allowed: boolean; remaining: Record; 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 stateVersion: 1, defaultOptions: omokOptionsSchema.parse({}), optionsSchema: omokOptionsSchema, + adminOnlyOptions: ['threatHints'], actionSchema: omokActionSchema as z.ZodType, setup({ players, options, rng, now, gameNo }) { @@ -437,6 +458,7 @@ export const omok: GameDefinition 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 }, 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, }; }, diff --git a/packages/games/src/omok/omok.test.ts b/packages/games/src/omok/omok.test.ts index d7bc7b4..8c73fdd 100644 --- a/packages/games/src/omok/omok.test.ts +++ b/packages/games/src/omok/omok.test.ts @@ -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', () => {