위협 가이드를 관리자 전용 옵션으로(기본 꺼짐) + 최고관리자 추가 + 계정 메뉴에 관리자 메뉴
- 엔진: 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:
@@ -33,7 +33,7 @@ cd deploy && docker compose up -d --build
|
|||||||
자세한 절차는 [docs/11-deployment-ops.md](docs/11-deployment-ops.md).
|
자세한 절차는 [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` — 게임 공통 타입, 시드 난수, 테스트 도구
|
- `packages/engine` — 게임 공통 타입, 시드 난수, 테스트 도구
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ describe('roles', () => {
|
|||||||
const me = await api(s.base, '/api/admin/me', { cookie: sup.cookie });
|
const me = await api(s.base, '/api/admin/me', { cookie: sup.cookie });
|
||||||
expect(me.body.role).toBe('superadmin');
|
expect(me.body.role).toBe('superadmin');
|
||||||
expect((await api(s.base, '/api/me', { cookie: sup.cookie })).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 () => {
|
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.
|
// 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' }) });
|
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.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);
|
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/me', { cookie: adm.cookie })).body.role).toBe('admin');
|
||||||
expect((await api(s.base, '/api/admin/users', { cookie: adm.cookie })).res.status).toBe(403);
|
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', () => {
|
describe('site settings, rooms, users', () => {
|
||||||
test('maintenance blocks room creation; banned words masked in chat and rejected in nicknames', async () => {
|
test('maintenance blocks room creation; banned words masked in chat and rejected in nicknames', async () => {
|
||||||
const s = start();
|
const s = start();
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ export interface Config {
|
|||||||
loadTestNoHttpLimits: boolean;
|
loadTestNoHttpLimits: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The site owner's Discord ID (docs/14-admin.md §2). */
|
/** Superadmin Discord IDs, comma-separated (docs/14-admin.md §2). */
|
||||||
export const DEFAULT_SUPERADMIN = '293719842274541579';
|
export const DEFAULT_SUPERADMIN = '293719842274541579,1352267557213573160';
|
||||||
|
|
||||||
export function loadConfig(env: Record<string, string | undefined> = process.env): Config {
|
export function loadConfig(env: Record<string, string | undefined> = process.env): Config {
|
||||||
const publicOrigin = (env.PUBLIC_ORIGIN ?? 'http://localhost:5173').replace(/\/$/, '');
|
const publicOrigin = (env.PUBLIC_ORIGIN ?? 'http://localhost:5173').replace(/\/$/, '');
|
||||||
|
|||||||
@@ -175,9 +175,14 @@ export class Room {
|
|||||||
return min <= max ? { min, max } : base;
|
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[] } {
|
private locked(gameId: string): { defaults: unknown; paths: string[] } {
|
||||||
const eff = this.deps.settings?.game(gameId);
|
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 {
|
seatOf(userId: string): number {
|
||||||
@@ -321,7 +326,7 @@ export class Room {
|
|||||||
rematchVotes: [...this.rematchVotes],
|
rematchVotes: [...this.rematchVotes],
|
||||||
sessionStats: this.sessionStats,
|
sessionStats: this.sessionStats,
|
||||||
seedHash: this.game?.seedHash ?? null,
|
seedHash: this.game?.seedHash ?? null,
|
||||||
lockedOptions: this.locked(this.config.gameId).paths,
|
lockedOptions: this.deps.settings?.game(this.config.gameId)?.lockedOptions ?? [],
|
||||||
lastSeed: this.lastSeed,
|
lastSeed: this.lastSeed,
|
||||||
lastGamePk: this.game?.finished ? this.game.gamePk : null,
|
lastGamePk: this.game?.finished ? this.game.gamePk : null,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -56,9 +56,10 @@ function ThemeToggle() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 로그인한 뒤: 닉네임(디스코드 연결 시 디스코드 이름) → 누르면 [설정] [로그아웃] 메뉴. */
|
/** 로그인한 뒤: 닉네임(디스코드 연결 시 디스코드 이름) → 누르면 [설정] ([관리자 메뉴]) [로그아웃] 메뉴. */
|
||||||
function AccountMenu() {
|
function AccountMenu() {
|
||||||
const me = useSession((s) => s.me)!;
|
const me = useSession((s) => s.me)!;
|
||||||
|
const role = useSession((s) => s.role);
|
||||||
const nav = useNavigate();
|
const nav = useNavigate();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [confirm, setConfirm] = useState(false);
|
const [confirm, setConfirm] = useState(false);
|
||||||
@@ -90,6 +91,11 @@ function AccountMenu() {
|
|||||||
<button type="button" role="menuitem" onClick={() => (setOpen(false), nav('/me'))}>
|
<button type="button" role="menuitem" onClick={() => (setOpen(false), nav('/me'))}>
|
||||||
⚙️ 설정
|
⚙️ 설정
|
||||||
</button>
|
</button>
|
||||||
|
{role !== 'user' && (
|
||||||
|
<button type="button" role="menuitem" onClick={() => (setOpen(false), nav('/admin'))}>
|
||||||
|
🛠 관리자 메뉴
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button type="button" role="menuitem" onClick={() => (setOpen(false), setConfirm(true))}>
|
<button type="button" role="menuitem" onClick={() => (setOpen(false), setConfirm(true))}>
|
||||||
🚪 로그아웃
|
🚪 로그아웃
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ export function helpNow(v: BadukView, _me: string | null, players: Record<string
|
|||||||
lines.push(`내 차례예요(${mine}). 상대가 패스했어요. 더 둘 곳이 없으면 나도 [✋ 패스]를 눌러요. 그러면 대국이 멈추고 사석 확인으로 넘어가요. 더 둘 곳이 있으면 계속 둬도 돼요.`);
|
lines.push(`내 차례예요(${mine}). 상대가 패스했어요. 더 둘 곳이 없으면 나도 [✋ 패스]를 눌러요. 그러면 대국이 멈추고 사석 확인으로 넘어가요. 더 둘 곳이 있으면 계속 둬도 돼요.`);
|
||||||
if (v.rules.scoring === 'area') lines.push('이 방은 영역 계산이라, 끝내기 전에 양쪽 사이의 빈 곳(공배)을 메워 두는 게 좋아요.');
|
if (v.rules.scoring === 'area') lines.push('이 방은 영역 계산이라, 끝내기 전에 양쪽 사이의 빈 곳(공배)을 메워 두는 게 좋아요.');
|
||||||
} else lines.push(`내 차례예요(${mine}). 선이 만나는 빈 점을 누르면 돌이 놓여요. 둘 곳이 없으면 [✋ 패스]를 눌러요.`);
|
} else lines.push(`내 차례예요(${mine}). 선이 만나는 빈 점을 누르면 돌이 놓여요. 둘 곳이 없으면 [✋ 패스]를 눌러요.`);
|
||||||
|
if (v.atariWarnings.length) lines.push('빨갛게 깜빡이는 내 돌은 단수예요(숨 쉴 곳이 하나만 남음). 돌을 잇거나 달아나지 않으면 잡혀요.');
|
||||||
if (v.undo.pending || v.draw.pendingBy) lines.push(`내 ${v.undo.pending ? '무르기 요청' : '무승부 제안'}에 상대가 답하기를 기다리는 중이에요. 그만두려면 [요청 취소]를 눌러요.`);
|
if (v.undo.pending || v.draw.pendingBy) lines.push(`내 ${v.undo.pending ? '무르기 요청' : '무승부 제안'}에 상대가 답하기를 기다리는 중이에요. 그만두려면 [요청 취소]를 눌러요.`);
|
||||||
if (v.illegalPoints.some((p) => p.reason !== 'suicide')) lines.push('빨간 네모 자리는 패(바로 되따내기 금지)나 같은 모양 반복 때문에 지금은 둘 수 없어요. 다른 곳에 한 수 둔 뒤에 다시 봐요.');
|
if (v.illegalPoints.some((p) => p.reason !== 'suicide')) lines.push('빨간 네모 자리는 패(바로 되따내기 금지)나 같은 모양 반복 때문에 지금은 둘 수 없어요. 다른 곳에 한 수 둔 뒤에 다시 봐요.');
|
||||||
lines.push(caps);
|
lines.push(caps);
|
||||||
|
|||||||
@@ -279,6 +279,12 @@ function BadukBoard({ view: v, act, pending, tapConfirm }: BoardProps<BadukView>
|
|||||||
{v.lastMove && lastColor && (
|
{v.lastMove && lastColor && (
|
||||||
<circle cx={v.lastMove.x} cy={v.lastMove.y} r={0.2} fill="none" stroke={lastColor === 'black' ? '#fff' : '#111'} strokeWidth={0.08} />
|
<circle cx={v.lastMove.x} cy={v.lastMove.y} r={0.2} fill="none" stroke={lastColor === 'black' ? '#fff' : '#111'} strokeWidth={0.08} />
|
||||||
)}
|
)}
|
||||||
|
{myTurn &&
|
||||||
|
v.atariWarnings.map((p) => (
|
||||||
|
<circle key={`a${p.x}-${p.y}`} cx={p.x} cy={p.y} r={0.5} fill="none" stroke="#dc2626" strokeWidth={0.08}>
|
||||||
|
<animate attributeName="opacity" values="1;0.25;1" dur="1.2s" repeatCount="indefinite" />
|
||||||
|
</circle>
|
||||||
|
))}
|
||||||
{ghost && v.myColor && <Stone color={v.myColor} cx={ghost.x} cy={ghost.y} r={0.47} ghost />}
|
{ghost && v.myColor && <Stone color={v.myColor} cx={ghost.x} cy={ghost.y} r={0.47} ghost />}
|
||||||
{hoverMark && <circle cx={hoverMark.x} cy={hoverMark.y} r={0.5} fill="none" stroke="var(--focus)" strokeWidth={0.07} strokeDasharray="0.15 0.1" />}
|
{hoverMark && <circle cx={hoverMark.x} cy={hoverMark.y} r={0.5} fill="none" stroke="var(--focus)" strokeWidth={0.07} strokeDasharray="0.15 0.1" />}
|
||||||
{(myTurn || marking) && kbd && <rect x={cursor.x - 0.5} y={cursor.y - 0.5} width={1} height={1} fill="none" stroke="var(--focus)" strokeWidth={0.06} />}
|
{(myTurn || marking) && kbd && <rect x={cursor.x - 0.5} y={cursor.y - 0.5} width={1} height={1} fill="none" stroke="var(--focus)" strokeWidth={0.06} />}
|
||||||
@@ -633,6 +639,7 @@ function statusText(v: BadukView, _me: string | null, players: Record<string, Pu
|
|||||||
if (v.myColor && v.draw.pendingBy && v.draw.pendingBy !== v.myColor) return '상대가 무승부를 제안했어요';
|
if (v.myColor && v.draw.pendingBy && v.draw.pendingBy !== v.myColor) return '상대가 무승부를 제안했어요';
|
||||||
if (v.myColor === v.turn) {
|
if (v.myColor === v.turn) {
|
||||||
if (v.consecutivePasses > 0) return '상대가 패스했어요. 끝내려면 패스하세요';
|
if (v.consecutivePasses > 0) return '상대가 패스했어요. 끝내려면 패스하세요';
|
||||||
|
if (v.atariWarnings.length) return `내 차례예요 (${COLOR_KO[v.turn]}). 단수에 몰린 돌이 있어요!`;
|
||||||
return `내 차례예요 (${COLOR_KO[v.turn]}). 둘 곳을 눌러 주세요`;
|
return `내 차례예요 (${COLOR_KO[v.turn]}). 둘 곳을 눌러 주세요`;
|
||||||
}
|
}
|
||||||
const nick = players[v.players[v.turn]]?.nickname ?? COLOR_KO[v.turn];
|
const nick = players[v.players[v.turn]]?.nickname ?? COLOR_KO[v.turn];
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ export function helpNow(v: ChessView, _me: string | null, players: Record<string
|
|||||||
if (legal.some((m) => m.promotion))
|
if (legal.some((m) => m.promotion))
|
||||||
lines.push(v.autoQueen ? '폰을 끝 줄로 보내면 자동으로 퀸이 돼요(승진).' : '폰을 끝 줄로 보내면 퀸·룩·비숍·나이트 중 무엇으로 바꿀지 고르는 창이 떠요(승진). 보통은 퀸이에요.');
|
lines.push(v.autoQueen ? '폰을 끝 줄로 보내면 자동으로 퀸이 돼요(승진).' : '폰을 끝 줄로 보내면 퀸·룩·비숍·나이트 중 무엇으로 바꿀지 고르는 창이 떠요(승진). 보통은 퀸이에요.');
|
||||||
if (legal.some((m) => m.castle)) lines.push('지금 캐슬링을 할 수 있어요. 킹을 누르면 캐슬링할 칸도 표시돼요.');
|
if (legal.some((m) => m.castle)) lines.push('지금 캐슬링을 할 수 있어요. 킹을 누르면 캐슬링할 칸도 표시돼요.');
|
||||||
|
if (v.threatened?.length) lines.push('주황 점선 네모가 쳐진 내 기물은 지켜 주는 기물 없이 공격받고 있어요. 조심하세요.');
|
||||||
if (v.drawMode === 'fide' && v.claimable)
|
if (v.drawMode === 'fide' && v.claimable)
|
||||||
lines.push(`[⚖ 무승부 주장]을 누르면 상대 동의 없이 바로 비길 수 있어요(${v.claimable === 'threefold' ? '같은 국면 3번' : '50수 동안 폰 이동·잡기 없음'}).`);
|
lines.push(`[⚖ 무승부 주장]을 누르면 상대 동의 없이 바로 비길 수 있어요(${v.claimable === 'threefold' ? '같은 국면 3번' : '50수 동안 폰 이동·잡기 없음'}).`);
|
||||||
if (mat) lines.push(mat);
|
if (mat) lines.push(mat);
|
||||||
|
|||||||
@@ -328,6 +328,10 @@ function ChessBoard({ view: v, act, pending }: BoardProps<ChessView>) {
|
|||||||
{lastFrom && <rect x={lastFrom.x} y={lastFrom.y} width={1} height={1} fill="#cdd26a" opacity={0.75} />}
|
{lastFrom && <rect x={lastFrom.x} y={lastFrom.y} width={1} height={1} fill="#cdd26a" opacity={0.75} />}
|
||||||
{lastTo && <rect x={lastTo.x} y={lastTo.y} width={1} height={1} fill="#aaa23a" opacity={0.6} />}
|
{lastTo && <rect x={lastTo.x} y={lastTo.y} width={1} height={1} fill="#aaa23a" opacity={0.6} />}
|
||||||
{selected && <rect x={cell(selected).x} y={cell(selected).y} width={1} height={1} fill="#4f7a28" opacity={0.55} />}
|
{selected && <rect x={cell(selected).x} y={cell(selected).y} width={1} height={1} fill="#4f7a28" opacity={0.55} />}
|
||||||
|
{v.threatened?.map((sq) => {
|
||||||
|
const c = cell(sq);
|
||||||
|
return <rect key={`t${sq}`} x={c.x + 0.04} y={c.y + 0.04} width={0.92} height={0.92} fill="none" stroke="#f59e0b" strokeWidth={0.07} strokeDasharray="0.14 0.08" />;
|
||||||
|
})}
|
||||||
{check && <rect x={check.x} y={check.y} width={1} height={1} fill="url(#chess-check)" />}
|
{check && <rect x={check.x} y={check.y} width={1} height={1} fill="url(#chess-check)" />}
|
||||||
{/* Coordinates: files on the bottom row, ranks on the left column. */}
|
{/* Coordinates: files on the bottom row, ranks on the left column. */}
|
||||||
{Array.from({ length: 8 }, (_, i) => (
|
{Array.from({ length: 8 }, (_, i) => (
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ export function helpNow(v: JanggiView, _me: string | null, players: Record<strin
|
|||||||
? '같은 국면이 3번 나왔어요. 한 번 더 반복하면 실격패예요!'
|
? '같은 국면이 3번 나왔어요. 한 번 더 반복하면 실격패예요!'
|
||||||
: `같은 국면이 3번 나왔어요. 한 번 더 반복하면 ${judge}.`,
|
: `같은 국면이 3번 나왔어요. 한 번 더 반복하면 ${judge}.`,
|
||||||
);
|
);
|
||||||
|
if (v.threatened.length) lines.push('주황 점선 동그라미가 쳐진 내 기물은 상대가 잡을 수 있어요. 조심하세요.');
|
||||||
lines.push(score);
|
lines.push(score);
|
||||||
return lines.join('\n');
|
return lines.join('\n');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -354,6 +354,11 @@ function JanggiBoard({ view: v, act, pending, tapConfirm }: BoardProps<JanggiVie
|
|||||||
const kind: PieceKind = setupGhost[k] === 'M' ? 'ma' : 'sang';
|
const kind: PieceKind = setupGhost[k] === 'M' ? 'ma' : 'sang';
|
||||||
return <PieceGlyph key={`sg${x}`} piece={{ side: v.mySide!, kind }} cx={x} cy={9} hangul={hangul} ghost={!!setupHover || mustChoose} />;
|
return <PieceGlyph key={`sg${x}`} piece={{ side: v.mySide!, kind }} cx={x} cy={9} hangul={hangul} ghost={!!setupHover || mustChoose} />;
|
||||||
})}
|
})}
|
||||||
|
{myTurn &&
|
||||||
|
v.threatened.map((q) => {
|
||||||
|
const s = toScreen(q);
|
||||||
|
return <circle key={`t${sqKey(q)}`} cx={s.x} cy={s.y} r={0.5} fill="none" stroke="#f59e0b" strokeWidth={0.06} strokeDasharray="0.14 0.09" />;
|
||||||
|
})}
|
||||||
{[...vias].map((i) => {
|
{[...vias].map((i) => {
|
||||||
const s = toScreen(fromKey(i));
|
const s = toScreen(fromKey(i));
|
||||||
return <circle key={`via${i}`} cx={s.x} cy={s.y} r={0.5} fill="none" stroke="#7c3aed" strokeWidth={0.05} strokeDasharray="0.1 0.08" opacity={0.8} />;
|
return <circle key={`via${i}`} cx={s.x} cy={s.y} r={0.5} fill="none" stroke="#7c3aed" strokeWidth={0.05} strokeDasharray="0.1 0.08" opacity={0.8} />;
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ export function helpNow(v: OmokView, _me: string | null, players: Record<string,
|
|||||||
}
|
}
|
||||||
if (v.moveCount === 0 && v.firstMoveCenter) return `내 차례예요(${mine}). 첫 수는 판 한가운데에만 놓을 수 있어요.`;
|
if (v.moveCount === 0 && v.firstMoveCenter) return `내 차례예요(${mine}). 첫 수는 판 한가운데에만 놓을 수 있어요.`;
|
||||||
const lines = [`내 차례예요(${mine}). 빈 곳을 눌러 돌을 놓아요. ${goal}`];
|
const lines = [`내 차례예요(${mine}). 빈 곳을 눌러 돌을 놓아요. ${goal}`];
|
||||||
|
if (v.hints?.urgent.length) lines.push('빨간 동그라미 자리를 막지 않으면 상대가 다음에 5개를 이어요!');
|
||||||
|
else if (v.hints?.warn.length) lines.push('노란 점선 동그라미는 상대가 4개를 만들 수 있는 자리예요. 막는 게 좋아요.');
|
||||||
if (v.forbidden.length) lines.push('빨간 ✕ 자리는 금수라서 둘 수 없어요.');
|
if (v.forbidden.length) lines.push('빨간 ✕ 자리는 금수라서 둘 수 없어요.');
|
||||||
return lines.join('\n');
|
return lines.join('\n');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -157,6 +157,8 @@ function OmokBoard({ view: v, act, pending, tapConfirm }: BoardProps<OmokView>)
|
|||||||
{stars(n).map((p) => (
|
{stars(n).map((p) => (
|
||||||
<circle key={`s${p.x}-${p.y}`} cx={p.x} cy={p.y} r={0.11} fill="var(--wood-line)" />
|
<circle key={`s${p.x}-${p.y}`} cx={p.x} cy={p.y} r={0.11} fill="var(--wood-line)" />
|
||||||
))}
|
))}
|
||||||
|
{myTurn && v.hints?.urgent.map((p) => <circle key={`u${p.x}-${p.y}`} cx={p.x} cy={p.y} r={0.42} fill="none" stroke="#dc2626" strokeWidth={0.09} />)}
|
||||||
|
{myTurn && v.hints?.warn.map((p) => <circle key={`w${p.x}-${p.y}`} cx={p.x} cy={p.y} r={0.38} fill="none" stroke="#f59e0b" strokeWidth={0.07} strokeDasharray="0.15 0.1" />)}
|
||||||
{v.forbidden.map((p) => (
|
{v.forbidden.map((p) => (
|
||||||
<g key={`f${p.x}-${p.y}`} stroke="#b91c1c" strokeWidth={0.08} opacity={0.8}>
|
<g key={`f${p.x}-${p.y}`} stroke="#b91c1c" strokeWidth={0.08} opacity={0.8}>
|
||||||
<line x1={p.x - 0.2} y1={p.y - 0.2} x2={p.x + 0.2} y2={p.y + 0.2} />
|
<line x1={p.x - 0.2} y1={p.y - 0.2} x2={p.x + 0.2} y2={p.y + 0.2} />
|
||||||
@@ -468,7 +470,7 @@ function Rules({ options }: { options?: OmokOptions }) {
|
|||||||
<strong>자유룰</strong>: 금지된 수가 없어요. 설정에 따라 5개 이상이면 이기거나, 딱 5개일 때만 이겨요.
|
<strong>자유룰</strong>: 금지된 수가 없어요. 설정에 따라 5개 이상이면 이기거나, 딱 5개일 때만 이겨요.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<p>둘 수 없는 자리는 판에 빨간 ×로 표시돼요(설정에서 끌 수 있어요).</p>
|
<p>둘 수 없는 자리는 판에 빨간 ×로 표시돼요(설정에서 끌 수 있어요).{options?.threatHints ? ' 이 방은 상대가 곧 이길 수 있는 자리를 빨간 동그라미, 위험한 자리를 노란 점선으로 알려 줘요.' : ''}</p>
|
||||||
<p>휴대폰에서는 한 번 누르면 미리 보기 돌이 놓이고, 같은 곳을 한 번 더 누르거나 아래 [두기] 버튼을 누르면 확정돼요.</p>
|
<p>휴대폰에서는 한 번 누르면 미리 보기 돌이 놓이고, 같은 곳을 한 번 더 누르거나 아래 [두기] 버튼을 누르면 확정돼요.</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,4 +8,4 @@ DISCORD_CLIENT_ID=
|
|||||||
DISCORD_CLIENT_SECRET=
|
DISCORD_CLIENT_SECRET=
|
||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
# Super admin Discord user IDs (comma separated). Default is the site owner.
|
# Super admin Discord user IDs (comma separated). Default is the site owner.
|
||||||
SUPERADMIN_DISCORD_IDS=293719842274541579
|
SUPERADMIN_DISCORD_IDS=293719842274541579,1352267557213573160
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ deploy/
|
|||||||
| `DISCORD_CLIENT_ID` / `DISCORD_CLIENT_SECRET` | 디스코드 앱 |
|
| `DISCORD_CLIENT_ID` / `DISCORD_CLIENT_SECRET` | 디스코드 앱 |
|
||||||
| `SESSION_SECRET` | 짧은 수명 서명 쿠키(OAuth state)용 32바이트 |
|
| `SESSION_SECRET` | 짧은 수명 서명 쿠키(OAuth state)용 32바이트 |
|
||||||
| `DB_PATH` | 기본 `/data/app.db` |
|
| `DB_PATH` | 기본 `/data/app.db` |
|
||||||
| `SUPERADMIN_DISCORD_IDS` | 슈퍼어드민 디스코드 ID(쉼표 구분). 기본 `293719842274541579` |
|
| `SUPERADMIN_DISCORD_IDS` | 슈퍼어드민 디스코드 ID(쉼표 구분). 기본 `293719842274541579,1352267557213573160` |
|
||||||
| `TRUST_PROXY` | `1`이면 Caddy가 붙인 X-Forwarded-For로 IP 판단 |
|
| `TRUST_PROXY` | `1`이면 Caddy가 붙인 X-Forwarded-For로 IP 판단 |
|
||||||
| `PORT` | 기본 3000 |
|
| `PORT` | 기본 3000 |
|
||||||
| `LOG_LEVEL` | info |
|
| `LOG_LEVEL` | info |
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
## 2. 권한
|
## 2. 권한
|
||||||
| 등급 | 누가 | 할 수 있는 것 |
|
| 등급 | 누가 | 할 수 있는 것 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 슈퍼어드민 | 디스코드 ID `293719842274541579` (설정 `SUPERADMIN_DISCORD_IDS`, 기본값이 이 ID) | 모든 것: 아래 어드민 권한 + 사용자 관리 + 어드민 추가/삭제 |
|
| 슈퍼어드민 | 디스코드 ID `293719842274541579`, `1352267557213573160` (설정 `SUPERADMIN_DISCORD_IDS`, 기본값이 이 두 ID) | 모든 것: 아래 어드민 권한 + 사용자 관리 + 어드민 추가/삭제 |
|
||||||
| 어드민 | 슈퍼어드민이 디스코드 ID로 등록한 사람 | 관리자 사이트 보기, 게임 설정, 사이트 설정, 방 관리, 기록 보기 |
|
| 어드민 | 슈퍼어드민이 디스코드 ID로 등록한 사람 | 관리자 사이트 보기, 게임 설정, 사이트 설정, 방 관리, 기록 보기 |
|
||||||
| 일반 | 그 외(게스트 포함) | 관리자 사이트 접근 불가(관리자 주소로 들어오면 "권한이 없어요") |
|
| 일반 | 그 외(게스트 포함) | 관리자 사이트 접근 불가(관리자 주소로 들어오면 "권한이 없어요") |
|
||||||
|
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
| 관리자 | × | ○ | 어드민 목록, 디스코드 ID로 추가(메모 포함), 삭제 |
|
| 관리자 | × | ○ | 어드민 목록, 디스코드 ID로 추가(메모 포함), 삭제 |
|
||||||
| 기록 | ○ | ○ | 관리자 작업 기록(누가, 언제, 무엇을, 바꾸기 전/후) |
|
| 기록 | ○ | ○ | 관리자 작업 기록(누가, 언제, 무엇을, 바꾸기 전/후) |
|
||||||
|
|
||||||
계정 페이지(`/me`)에 관리자에게만 [관리자 페이지] 버튼을 보여 준다.
|
계정 페이지(`/me`)와 상단 계정 메뉴에 관리자에게만 [관리자 페이지]·[관리자 메뉴]를 보여 준다.
|
||||||
|
|
||||||
## 4. 게임 설정 (게임마다)
|
## 4. 게임 설정 (게임마다)
|
||||||
| 항목 | 설명 |
|
| 항목 | 설명 |
|
||||||
@@ -42,6 +42,7 @@
|
|||||||
|
|
||||||
- 화면의 입력 칸은 게임 모듈의 옵션 스키마(zod → JSON Schema)에서 **자동으로 만든다**. 새 옵션이 생기면 관리자 화면에 자동으로 나타나므로 빠지는 항목이 없다.
|
- 화면의 입력 칸은 게임 모듈의 옵션 스키마(zod → JSON Schema)에서 **자동으로 만든다**. 새 옵션이 생기면 관리자 화면에 자동으로 나타나므로 빠지는 항목이 없다.
|
||||||
- 그래서 모든 게임 모듈은 옵션마다 한국어 제목과 선택지 이름을 `meta`로 붙인다(`09-game-engine.md` 2절). 게임 규칙 안에 숫자로 박혀 있던 값(예: 오목 자동 착수 허용 횟수, 무승부 재제안 간격)도 옵션으로 꺼내서 관리자가 바꿀 수 있게 한다.
|
- 그래서 모든 게임 모듈은 옵션마다 한국어 제목과 선택지 이름을 `meta`로 붙인다(`09-game-engine.md` 2절). 게임 규칙 안에 숫자로 박혀 있던 값(예: 오목 자동 착수 허용 횟수, 무승부 재제안 간격)도 옵션으로 꺼내서 관리자가 바꿀 수 있게 한다.
|
||||||
|
- **관리자 전용 옵션**: 게임 모듈의 `adminOnlyOptions`에 적힌 옵션은 대기실 설정에 나오지 않고, 잠금과 같이 항상 관리자 값으로 고정된다. 지금은 위협 가이드(상대 수를 막으라는 표시)가 여기에 해당하고 기본은 꺼짐: 오목·체스·장기 `threatHints`, 바둑 `atariWarning`. 켜려면 관리자 화면 → 게임 → 기본 규칙에서 켠다.
|
||||||
- 저장할 때 스키마로 검사하고, 통과하지 못하면 저장하지 않는다.
|
- 저장할 때 스키마로 검사하고, 통과하지 못하면 저장하지 않는다.
|
||||||
- 바꾼 설정은 **새로 만드는 방과 새로 시작하는 판**부터 적용된다. 진행 중인 판의 규칙은 중간에 바뀌지 않는다(공정성).
|
- 바꾼 설정은 **새로 만드는 방과 새로 시작하는 판**부터 적용된다. 진행 중인 판의 규칙은 중간에 바뀌지 않는다(공정성).
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
| `idleLimitSec` | `none`일 때 무응답 한도 | 300 / 600 | 600 |
|
| `idleLimitSec` | `none`일 때 무응답 한도 | 300 / 600 | 600 |
|
||||||
| `undo` | 무르기 허용 횟수(1인당, 상대 동의) | `off` / `1` / `3` / `unlimited` | `3` |
|
| `undo` | 무르기 허용 횟수(1인당, 상대 동의) | `off` / `1` / `3` / `unlimited` | `3` |
|
||||||
| `drawOffer` | 무승부(빅) 합의 제안 허용 | `true` / `false` | `true` |
|
| `drawOffer` | 무승부(빅) 합의 제안 허용 | `true` / `false` | `true` |
|
||||||
|
| `atariWarning` | **관리자 전용** 위협 가이드: 단수에 몰린 내 돌 표시(방장은 못 바꿈) | `true` / `false` | `false` |
|
||||||
| `scoringDeadlineSec` | 사석 확인 단계 제한 시간 | 120 / 180 / 300 | 180 |
|
| `scoringDeadlineSec` | 사석 확인 단계 제한 시간 | 120 / 180 / 300 | 180 |
|
||||||
| `disconnectGraceSec` | 연결 끊김 유예 | 60 / 120 / 300 | 120 |
|
| `disconnectGraceSec` | 연결 끊김 유예 | 60 / 120 / 300 | 120 |
|
||||||
|
|
||||||
@@ -191,7 +192,7 @@ interface BadukState {
|
|||||||
|
|
||||||
### 8.3 공개/비공개 정보 (view)
|
### 8.3 공개/비공개 정보 (view)
|
||||||
- 모두 공개(플레이어·관전자 동일): 판, 차례, 포로 수, 기보, 단계, 사석/빅 표시와 계가 미리보기, 양쪽 동의 여부, 시계(기본 시간, 초읽기 남은 횟수, `deadline`), 대기 중인 요청, 결과.
|
- 모두 공개(플레이어·관전자 동일): 판, 차례, 포로 수, 기보, 단계, 사석/빅 표시와 계가 미리보기, 양쪽 동의 여부, 시계(기본 시간, 초읽기 남은 횟수, `deadline`), 대기 중인 요청, 결과.
|
||||||
- 차례인 플레이어에게만 편의 정보: `illegalPoints`(패·자충으로 둘 수 없는 점). 단수 경고 같은 위협 표시는 보내지 않는다.
|
- 차례인 플레이어에게만 편의 정보: `illegalPoints`(패·자충으로 둘 수 없는 점), `atariWarnings`.
|
||||||
- view에서 제외: `rng`, `positionHistory`(크기 문제로 제외, 필요하면 기보로 재구성), `situationCounts`.
|
- view에서 제외: `rng`, `positionHistory`(크기 문제로 제외, 필요하면 기보로 재구성), `situationCounts`.
|
||||||
|
|
||||||
### 8.4 랜덤 요소 (시드 RNG 사용 지점)
|
### 8.4 랜덤 요소 (시드 RNG 사용 지점)
|
||||||
@@ -254,7 +255,7 @@ interface BadukResult {
|
|||||||
- [ ] `scoring` 단계 시간 초과 → 미응답자 자동 동의 후 계가.
|
- [ ] `scoring` 단계 시간 초과 → 미응답자 자동 동의 후 계가.
|
||||||
- [ ] 연결 끊김 유예 후 시간패, 재접속 시 유예 해제.
|
- [ ] 연결 끊김 유예 후 시간패, 재접속 시 유예 해제.
|
||||||
- [ ] 무르기 수락 시 따낸 돌이 복원되고 포로 수·슈퍼코 이력이 재생으로 정확히 복원.
|
- [ ] 무르기 수락 시 따낸 돌이 복원되고 포로 수·슈퍼코 이력이 재생으로 정확히 복원.
|
||||||
- [ ] 정보 누출: view에 `rng`, `positionHistory`, `situationCounts`가 없는지. 관전자 view에 `illegalPoints` 없음.
|
- [ ] 정보 누출: view에 `rng`, `positionHistory`, `situationCounts`가 없는지. 관전자 view에 `illegalPoints`/`atariWarnings` 없음.
|
||||||
- [ ] 리플레이 결정성: 같은 시드·액션 로그 → 같은 결과.
|
- [ ] 리플레이 결정성: 같은 시드·액션 로그 → 같은 결과.
|
||||||
|
|
||||||
## 11. 참고 자료
|
## 11. 참고 자료
|
||||||
|
|||||||
@@ -20,7 +20,8 @@
|
|||||||
| `drawOffer` | 무승부 제안 | `true` / `false` | `true` |
|
| `drawOffer` | 무승부 제안 | `true` / `false` | `true` |
|
||||||
| `firstMoveSec` | 첫 수 제한(넘기면 대국 취소) | 15 / 30 / 60 | 30 |
|
| `firstMoveSec` | 첫 수 제한(넘기면 대국 취소) | 15 / 30 / 60 | 30 |
|
||||||
| `idleLimitSec` | `timeControl=none`일 때 무응답 한도 | 300 / 600 | 600 |
|
| `idleLimitSec` | `timeControl=none`일 때 무응답 한도 | 300 / 600 | 600 |
|
||||||
| `hints` | 초보 도움말(이동 가능 칸 표시). 공격받는 기물 경고 같은 위협 표시는 두지 않는다 | `off` / `moves` | `moves` |
|
| `hints` | 초보 도움말(이동 가능 칸 표시) | `off` / `moves` | `moves` |
|
||||||
|
| `threatHints` | **관리자 전용** 위협 가이드: 공격받고 방어 안 된 내 기물 표시(방장은 못 바꿈) | `true` / `false` | `false` |
|
||||||
| `disconnectGraceSec` | 연결 끊김 유예 | 30 / 60 / 120 | 60 |
|
| `disconnectGraceSec` | 연결 끊김 유예 | 30 / 60 / 120 | 60 |
|
||||||
|
|
||||||
## 3. 구성물
|
## 3. 구성물
|
||||||
@@ -140,9 +141,9 @@ interface ChessState {
|
|||||||
|
|
||||||
### 8.3 공개/비공개 정보 (view)
|
### 8.3 공개/비공개 정보 (view)
|
||||||
- 공통 공개: `fen`, 기보(SAN), 마지막 수, 체크 여부, 시계, `deadline`, 잡은 기물 목록과 기물 점수 차(표시용: 퀸9 룩5 비숍3 나이트3 폰1), 대기 중인 요청, `claimable`, 결과.
|
- 공통 공개: `fen`, 기보(SAN), 마지막 수, 체크 여부, 시계, `deadline`, 잡은 기물 목록과 기물 점수 차(표시용: 퀸9 룩5 비숍3 나이트3 폰1), 대기 중인 요청, `claimable`, 결과.
|
||||||
- 차례인 플레이어에게: `legalMoves: { from; to; promotion?; castle? }[]`(하이라이트용).
|
- 차례인 플레이어에게: `legalMoves: { from; to; promotion?; castle? }[]`(하이라이트용). `threatHints`가 켜졌으면 `threatenedSquares`(내 기물 중 공격받고 방어 안 된 칸).
|
||||||
- 차례가 아닌 플레이어: 프리무브 UI를 위해 자기 기물의 의사 합법 수를 클라이언트에서 계산하지 않고, v1은 프리무브 미지원(단순성·공정성).
|
- 차례가 아닌 플레이어: 프리무브 UI를 위해 자기 기물의 의사 합법 수를 클라이언트에서 계산하지 않고, v1은 프리무브 미지원(단순성·공정성).
|
||||||
- 관전자: `legalMoves` 없음.
|
- 관전자: `legalMoves`/`threatenedSquares` 없음.
|
||||||
- 제외: `rng`(960 배치는 셋업 직후 공개되므로 문제없지만 다음 대국 배치 예측 방지).
|
- 제외: `rng`(960 배치는 셋업 직후 공개되므로 문제없지만 다음 대국 배치 예측 방지).
|
||||||
|
|
||||||
### 8.4 랜덤 요소 (시드 RNG 사용 지점)
|
### 8.4 랜덤 요소 (시드 RNG 사용 지점)
|
||||||
@@ -203,7 +204,7 @@ interface ChessResult {
|
|||||||
- [ ] Chess960: n=518이 표준 배치, 960개 번호 모두 비숍 반대 색·킹이 두 룩 사이 조건 만족·서로 다름, 킹이 g열에 이미 있는 배치에서 킹사이드 캐슬링(킹 0칸 이동), 룩 경로·킹 경로·도착 칸 막힘/공격, 룩이 도착 칸을 가리던 경우, 권리 상실, 무르기 후 권리 복원, 무작위 960 대국(`chess.test.ts`), perft(`chess.perft.test.ts`).
|
- [ ] Chess960: n=518이 표준 배치, 960개 번호 모두 비숍 반대 색·킹이 두 룩 사이 조건 만족·서로 다름, 킹이 g열에 이미 있는 배치에서 킹사이드 캐슬링(킹 0칸 이동), 룩 경로·킹 경로·도착 칸 막힘/공격, 룩이 도착 칸을 가리던 경우, 권리 상실, 무르기 후 권리 복원, 무작위 960 대국(`chess.test.ts`), perft(`chess.perft.test.ts`).
|
||||||
- [ ] 무르기: 1/2 플라이 복원 후 `repetition` 카운트 정확, 거절 시 무변화.
|
- [ ] 무르기: 1/2 플라이 복원 후 `repetition` 카운트 정확, 거절 시 무변화.
|
||||||
- [ ] 연결 끊김 유예 후 시간패 처리.
|
- [ ] 연결 끊김 유예 후 시간패 처리.
|
||||||
- [ ] 정보 누출: 관전자·비차례 플레이어 view에 `legalMoves` 없음, 모든 view에 `rng` 없음.
|
- [ ] 정보 누출: 관전자·비차례 플레이어 view에 `legalMoves`/`threatenedSquares` 없음, 모든 view에 `rng` 없음.
|
||||||
- [ ] 리플레이 결정성(같은 시드 → 같은 960 배치와 결과).
|
- [ ] 리플레이 결정성(같은 시드 → 같은 960 배치와 결과).
|
||||||
|
|
||||||
## 11. 참고 자료
|
## 11. 참고 자료
|
||||||
|
|||||||
@@ -23,7 +23,8 @@
|
|||||||
| `idleLimitSec` | `none`일 때 무응답 한도 | 300 / 600 | 600 |
|
| `idleLimitSec` | `none`일 때 무응답 한도 | 300 / 600 | 600 |
|
||||||
| `undo` | 무르기 허용 횟수(상대 동의) | `off` / `1` / `3` / `unlimited` | `3` |
|
| `undo` | 무르기 허용 횟수(상대 동의) | `off` / `1` / `3` / `unlimited` | `3` |
|
||||||
| `drawOffer` | 무승부 제안 | `true` / `false` | `true` |
|
| `drawOffer` | 무승부 제안 | `true` / `false` | `true` |
|
||||||
| `hints` | 이동 가능 칸 표시. 잡힐 위험 같은 위협 경고는 두지 않는다 | `off` / `moves` | `moves` |
|
| `hints` | 이동 가능 칸 표시 | `off` / `moves` | `moves` |
|
||||||
|
| `threatHints` | **관리자 전용** 위협 가이드: 잡힐 위험이 있는 내 기물 표시(방장은 못 바꿈) | `true` / `false` | `false` |
|
||||||
| `disconnectGraceSec` | 연결 끊김 유예 | 60 / 120 | 60 |
|
| `disconnectGraceSec` | 연결 끊김 유예 | 60 / 120 | 60 |
|
||||||
|
|
||||||
## 3. 구성물
|
## 3. 구성물
|
||||||
@@ -180,7 +181,7 @@ interface JanggiState {
|
|||||||
### 8.3 공개/비공개 정보 (view)
|
### 8.3 공개/비공개 정보 (view)
|
||||||
- 공개: 판, 차례, 기보, 잡은 기물, 현재 점수(덤 포함), 장군 상태, 빅장 응수 대기 여부, 반복 경고(같은 국면 3회째 도달 시 "한 번 더 반복하면 둘 수 없어요"), 시계, `deadline`, 대기 중인 요청, 결과.
|
- 공개: 판, 차례, 기보, 잡은 기물, 현재 점수(덤 포함), 장군 상태, 빅장 응수 대기 여부, 반복 경고(같은 국면 3회째 도달 시 "한 번 더 반복하면 둘 수 없어요"), 시계, `deadline`, 대기 중인 요청, 결과.
|
||||||
- `setup` 단계 + `simultaneous`: 상대 선택값은 "선택 완료"(true/false)만 보이고 배치는 둘 다 확정될 때까지 숨긴다(플레이어·관전자 모두). `hanFirst`에서는 한의 선택이 즉시 공개.
|
- `setup` 단계 + `simultaneous`: 상대 선택값은 "선택 완료"(true/false)만 보이고 배치는 둘 다 확정될 때까지 숨긴다(플레이어·관전자 모두). `hanFirst`에서는 한의 선택이 즉시 공개.
|
||||||
- 차례인 플레이어에게만: `legalMoves`(하이라이트용).
|
- 차례인 플레이어에게만: `legalMoves`(하이라이트용), `threatHints`가 켜졌을 때 `threatened`(잡힐 위험 기물).
|
||||||
- 제외: `rng`, 상대의 미공개 상차림.
|
- 제외: `rng`, 상대의 미공개 상차림.
|
||||||
|
|
||||||
### 8.4 랜덤 요소 (시드 RNG 사용 지점)
|
### 8.4 랜덤 요소 (시드 RNG 사용 지점)
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
| `undo` | 무르기 요청 허용 횟수(1인당, 상대 동의 필요) | `off` / `1` / `3` / `unlimited` | `3` |
|
| `undo` | 무르기 요청 허용 횟수(1인당, 상대 동의 필요) | `off` / `1` / `3` / `unlimited` | `3` |
|
||||||
| `drawOffer` | 무승부 제안 허용 | `true` / `false` | `true` |
|
| `drawOffer` | 무승부 제안 허용 | `true` / `false` | `true` |
|
||||||
| `showForbidden` | 금수 자리 표시 (금수 있는 규칙만) | `true` / `false` | `true` |
|
| `showForbidden` | 금수 자리 표시 (금수 있는 규칙만) | `true` / `false` | `true` |
|
||||||
|
| `threatHints` | **관리자 전용** 위협 가이드: 상대 3·4를 막을 자리 표시(방장은 못 바꿈) | `true` / `false` | `false` |
|
||||||
|
|
||||||
## 3. 구성물
|
## 3. 구성물
|
||||||
- 15×15(교차점 225개) 또는 19×19(361개) 판. 돌은 교차점에 놓는다.
|
- 15×15(교차점 225개) 또는 19×19(361개) 판. 돌은 교차점에 놓는다.
|
||||||
@@ -252,8 +253,8 @@ interface OmokState {
|
|||||||
|
|
||||||
### 8.3 공개/비공개 정보 (view)
|
### 8.3 공개/비공개 정보 (view)
|
||||||
- 플레이어·관전자 공통 공개: 판, 차례, 기보, 승리 줄, 양쪽 남은 시간과 `deadline`, 대기 중인 무르기/무승부 요청 여부(누가 요청했는지), 남은 무르기 횟수, 결과.
|
- 플레이어·관전자 공통 공개: 판, 차례, 기보, 승리 줄, 양쪽 남은 시간과 `deadline`, 대기 중인 무르기/무승부 요청 여부(누가 요청했는지), 남은 무르기 횟수, 결과.
|
||||||
- 차례인 플레이어에게만: `forbiddenPoints: Pt[]` (금수 표시 옵션이 켜져 있을 때). 상대의 3·4를 막으라는 경고(위협 표시)는 보내지 않는다.
|
- 차례인 플레이어에게만: `forbiddenPoints: Pt[]` (금수 표시 옵션이 켜져 있을 때), `hints`(`threatHints`가 켜졌을 때만: 상대가 5목·4를 만들 자리).
|
||||||
- 관전자에게는 `forbiddenPoints`를 흑 기준으로 보여줄 수 있다(공개 정보이므로 허용, 해설용).
|
- 관전자에게는 `forbiddenPoints`를 흑 기준으로 보여줄 수 있다(공개 정보이므로 허용, 해설용). 단 `hints`는 관전자에게 보내지 않는다(관전자가 플레이어에게 훈수 전달하는 것을 굳이 돕지 않기 위함).
|
||||||
- 절대 포함 금지: `rng`(시드/상태 — 자동 착수 위치 예측 가능), 서버 내부 메모이제이션.
|
- 절대 포함 금지: `rng`(시드/상태 — 자동 착수 위치 예측 가능), 서버 내부 메모이제이션.
|
||||||
|
|
||||||
### 8.4 랜덤 요소 (시드 RNG 사용 지점)
|
### 8.4 랜덤 요소 (시드 RNG 사용 지점)
|
||||||
@@ -313,7 +314,7 @@ interface OmokResult {
|
|||||||
- [ ] `perMove + autoMove`: 시간 초과 시 자동 착수가 합법 수이고 금수가 아니며, 같은 시드에서 항상 같은 자리. 3연속 자동 착수 시 시간패.
|
- [ ] `perMove + autoMove`: 시간 초과 시 자동 착수가 합법 수이고 금수가 아니며, 같은 시드에서 항상 같은 자리. 3연속 자동 착수 시 시간패.
|
||||||
- [ ] `fischer`: 남은 시간 0 이후 도착한 착수는 거부되고 시간패, 정상 착수 시 증가분 가산.
|
- [ ] `fischer`: 남은 시간 0 이후 도착한 착수는 거부되고 시간패, 정상 착수 시 증가분 가산.
|
||||||
- [ ] 연결 끊김: 끊긴 플레이어 차례에 유예 후 `onTimeout` 동작, 재접속 시 유예 타이머 해제.
|
- [ ] 연결 끊김: 끊긴 플레이어 차례에 유예 후 `onTimeout` 동작, 재접속 시 유예 타이머 해제.
|
||||||
- [ ] 정보 누출: 플레이어/관전자 view JSON에 `rng`가 없는지, 어떤 view에도 위협 표시(`hints`)가 없는지 스냅샷 테스트.
|
- [ ] 정보 누출: 플레이어/관전자 view JSON에 `rng`가 없는지, 관전자 view에 `hintThreats`가 없는지 스냅샷 테스트.
|
||||||
- [ ] 리플레이: 같은 시드 + 같은 액션 로그로 재실행 시 최종 상태 해시 동일.
|
- [ ] 리플레이: 같은 시드 + 같은 액션 로그로 재실행 시 최종 상태 해시 동일.
|
||||||
|
|
||||||
## 11. 참고 자료
|
## 11. 참고 자료
|
||||||
|
|||||||
@@ -69,7 +69,15 @@ try {
|
|||||||
await a.screenshot({ path: join(SHOTS, 'admin-1-dashboard.png') });
|
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.locator('.admin-side').getByRole('link', { name: '게임', exact: true }).click();
|
||||||
await a.getByRole('button', { name: /오목/ }).click();
|
await a.getByRole('button', { name: /오목/ }).click();
|
||||||
await a.getByText('기본 규칙').waitFor();
|
await a.getByText('기본 규칙').waitFor();
|
||||||
@@ -78,9 +86,13 @@ try {
|
|||||||
await rule.getByLabel(/방장 변경 금지/).check();
|
await rule.getByLabel(/방장 변경 금지/).check();
|
||||||
const per = a.locator('div.stack:has(> div.row > strong:text-is("한 수 제한(초)"))').first();
|
const per = a.locator('div.stack:has(> div.row > strong:text-is("한 수 제한(초)"))').first();
|
||||||
await per.locator('input[type=number]').fill('45');
|
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.screenshot({ path: join(SHOTS, 'admin-2-game-editor.png') });
|
||||||
await a.getByRole('button', { name: '저장', exact: true }).click();
|
await a.getByRole('button', { name: '저장', exact: true }).click();
|
||||||
await a.getByText('저장했어요.').waitFor();
|
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 () => {
|
await step('관리자 탭: 디스코드 ID로 어드민 추가', async () => {
|
||||||
@@ -109,8 +121,10 @@ try {
|
|||||||
await g.waitForURL(/\/r\/\d{6}$/);
|
await g.waitForURL(/\/r\/\d{6}$/);
|
||||||
await g.getByText('렌주룰 · 15줄 · 한 수 45초').waitFor();
|
await g.getByText('렌주룰 · 15줄 · 한 수 45초').waitFor();
|
||||||
await g.getByText('일부 규칙은 관리자가 정해 두어서').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.
|
// Trying to switch to 한국식 is overridden by the server.
|
||||||
await g.getByRole('button', { name: '설정 바꾸기' }).click();
|
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.getByRole('button', { name: '저장' }).click();
|
await g.getByRole('button', { name: '저장' }).click();
|
||||||
await g.waitForTimeout(300);
|
await g.waitForTimeout(300);
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ export interface GameDefinition<S, A, V, O> {
|
|||||||
stateVersion: number;
|
stateVersion: number;
|
||||||
defaultOptions: O;
|
defaultOptions: O;
|
||||||
optionsSchema: ZodType<O>;
|
optionsSchema: ZodType<O>;
|
||||||
|
/** 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<A>;
|
actionSchema: ZodType<A>;
|
||||||
playersFor?(options: O): { min: number; max: number };
|
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. */
|
/** The game runs its own (filtered) chat channels: the room chat and quick reactions are closed while it is played. */
|
||||||
|
|||||||
@@ -531,8 +531,8 @@ describe('undo, draw, resign', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('views and determinism', () => {
|
describe('views and determinism', () => {
|
||||||
test('no hidden internals in any view; mover-only illegal points; no atari warning', () => {
|
test('no hidden internals in any view; mover-only hints', () => {
|
||||||
let s = make({}, { black: ['B1', 'A2'], white: ['A3', 'C1'] }, 'white');
|
let s = make({ atariWarning: true }, { black: ['B1', 'A2'], white: ['A3', 'C1'] }, 'white');
|
||||||
s = play(s, 'B2'); // black B1/A2... put black stones in atari
|
s = play(s, 'B2'); // black B1/A2... put black stones in atari
|
||||||
for (const viewer of ['A', 'B', null]) {
|
for (const viewer of ['A', 'B', null]) {
|
||||||
const json = JSON.stringify(baduk.view(s, viewer));
|
const json = JSON.stringify(baduk.view(s, viewer));
|
||||||
@@ -541,9 +541,14 @@ describe('views and determinism', () => {
|
|||||||
expect(json).not.toContain('rng');
|
expect(json).not.toContain('rng');
|
||||||
}
|
}
|
||||||
const mover = baduk.view(s, 'A');
|
const mover = baduk.view(s, 'A');
|
||||||
|
expect(mover.atariWarnings.length).toBeGreaterThan(0);
|
||||||
expect(mover.illegalPoints.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 ['B', null]) {
|
||||||
for (const viewer of ['A', 'B', null]) expect(JSON.stringify(baduk.view(s, viewer))).not.toMatch(/atari/i);
|
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', () => {
|
test('nigiri is seeded and deterministic', () => {
|
||||||
|
|||||||
@@ -238,6 +238,12 @@ export function illegalPoints(
|
|||||||
return out;
|
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
|
// ------------------------------------------------------------------ scoring
|
||||||
|
|
||||||
export interface Regions {
|
export interface Regions {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { fail, ok, type GameDefinition, type GameEvent, type GameResult, type PlayerId } from '@bg/engine';
|
import { fail, ok, type GameDefinition, type GameEvent, type GameResult, type PlayerId } from '@bg/engine';
|
||||||
import {
|
import {
|
||||||
|
atariStones,
|
||||||
chainAt,
|
chainAt,
|
||||||
emptyRegionAt,
|
emptyRegionAt,
|
||||||
hashBoard,
|
hashBoard,
|
||||||
@@ -78,6 +79,7 @@ export const badukOptionsSchema = z
|
|||||||
.meta({ title: '무르기(1인당, 상대 동의)', labels: { off: '안 씀', 1: '1번', 3: '3번', unlimited: '무제한' } }),
|
.meta({ title: '무르기(1인당, 상대 동의)', labels: { off: '안 씀', 1: '1번', 3: '3번', unlimited: '무제한' } }),
|
||||||
drawOffer: z.boolean().default(true).meta({ title: '무승부 제안 허용' }),
|
drawOffer: z.boolean().default(true).meta({ title: '무승부 제안 허용' }),
|
||||||
drawReofferMoves: z.number().int().min(0).max(50).default(5).meta({ title: '무승부 다시 제안까지 둬야 하는 수' }),
|
drawReofferMoves: z.number().int().min(0).max(50).default(5).meta({ title: '무승부 다시 제안까지 둬야 하는 수' }),
|
||||||
|
atariWarning: z.boolean().default(false).meta({ title: '위협 가이드(관리자 전용): 단수에 몰린 내 돌 표시' }),
|
||||||
scoringDeadlineSec: z
|
scoringDeadlineSec: z
|
||||||
.union([z.literal(120), z.literal(180), z.literal(300)])
|
.union([z.literal(120), z.literal(180), z.literal(300)])
|
||||||
.default(180)
|
.default(180)
|
||||||
@@ -174,7 +176,7 @@ export interface BadukView {
|
|||||||
myColor: Color | null;
|
myColor: Color | null;
|
||||||
turn: Color;
|
turn: Color;
|
||||||
phase: BadukState['phase'];
|
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>;
|
captures: Record<Color, number>;
|
||||||
moveCount: number;
|
moveCount: number;
|
||||||
moves: { color: Color; pt: Pt | null; captured: number }[];
|
moves: { color: Color; pt: Pt | null; captured: number }[];
|
||||||
@@ -198,6 +200,8 @@ export interface BadukView {
|
|||||||
draw: { allowed: boolean; pendingBy: Color | null };
|
draw: { allowed: boolean; pendingBy: Color | null };
|
||||||
/** Only for the player to move. */
|
/** Only for the player to move. */
|
||||||
illegalPoints: (Pt & { reason: IllegalReason })[];
|
illegalPoints: (Pt & { reason: IllegalReason })[];
|
||||||
|
/** Only for the player to move (atariWarning option): own stones in atari. */
|
||||||
|
atariWarnings: Pt[];
|
||||||
scoring: {
|
scoring: {
|
||||||
dead: Pt[];
|
dead: Pt[];
|
||||||
/** Every point of seki-marked regions. */
|
/** Every point of seki-marked regions. */
|
||||||
@@ -475,6 +479,7 @@ export const baduk: GameDefinition<BadukState, BadukAction, BadukView, BadukOpti
|
|||||||
stateVersion: 1,
|
stateVersion: 1,
|
||||||
defaultOptions: badukOptionsSchema.parse({}),
|
defaultOptions: badukOptionsSchema.parse({}),
|
||||||
optionsSchema: badukOptionsSchema,
|
optionsSchema: badukOptionsSchema,
|
||||||
|
adminOnlyOptions: ['atariWarning'],
|
||||||
actionSchema: badukActionSchema as z.ZodType<BadukAction>,
|
actionSchema: badukActionSchema as z.ZodType<BadukAction>,
|
||||||
|
|
||||||
setup({ players, options, rng, now }) {
|
setup({ players, options, rng, now }) {
|
||||||
@@ -754,7 +759,7 @@ export const baduk: GameDefinition<BadukState, BadukAction, BadukView, BadukOpti
|
|||||||
myColor,
|
myColor,
|
||||||
turn: s.turn,
|
turn: s.turn,
|
||||||
phase: s.phase,
|
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,
|
captures: s.captures,
|
||||||
moveCount: s.moves.length,
|
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 })),
|
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 },
|
draw: { allowed: s.options.drawOffer, pendingBy: s.draw.pendingBy },
|
||||||
illegalPoints: myTurn ? [...illegalMap(s)].map(([i, reason]) => ({ ...toPt(i, s.size), reason })) : [],
|
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
|
scoring: m
|
||||||
? {
|
? {
|
||||||
dead: m.dead.map((i) => toPt(i, s.size)),
|
dead: m.dead.map((i) => toPt(i, s.size)),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, expect, test } from 'bun:test';
|
import { describe, expect, test } from 'bun:test';
|
||||||
import { SeededRng, randomPlayout, seedN } from '@bg/engine';
|
import { SeededRng, randomPlayout, seedN } from '@bg/engine';
|
||||||
import { chess, type ChessAction, type ChessOptions, type ChessState } from './index';
|
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]);
|
const rng = () => SeededRng.fromSeed([1, 2, 3, 4]);
|
||||||
|
|
||||||
@@ -323,17 +323,19 @@ describe('requests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('views and playouts', () => {
|
describe('views and playouts', () => {
|
||||||
test('legalMoves only for the player to move; no threat warnings; no rng anywhere', () => {
|
test('legalMoves / threatened only for the player to move; no rng anywhere', () => {
|
||||||
const s = make({}, '4k3/8/8/3q4/8/8/3N4/K7 w - - 0 1');
|
const s = make({ threatHints: true }, '4k3/8/8/3q4/8/8/3N4/K7 w - - 0 1');
|
||||||
const mine = chess.view(s, 'A');
|
const mine = chess.view(s, 'A');
|
||||||
expect(mine.legalMoves?.length).toBeGreaterThan(0);
|
expect(mine.legalMoves?.length).toBeGreaterThan(0);
|
||||||
for (const v of [chess.view(s, 'B'), chess.view(s, null)]) expect(v.legalMoves).toBeNull();
|
expect(mine.threatened).toEqual(['d2']);
|
||||||
for (const v of [mine, chess.view(s, 'B'), chess.view(s, null)]) {
|
for (const v of [chess.view(s, 'B'), chess.view(s, null)]) {
|
||||||
expect(v).not.toHaveProperty('threatened');
|
expect(v.legalMoves).toBeNull();
|
||||||
expect(JSON.stringify(v)).not.toMatch(/rng/i);
|
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(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', () => {
|
test('view shape: board, check square, captured pieces and material, promotion flag', () => {
|
||||||
const s = play(make(), 'e2e4', 'd7d5', 'e4d5', 'd8d5', 'b1c3', 'd5e5');
|
const s = play(make(), 'e2e4', 'd7d5', 'e4d5', 'd8d5', 'b1c3', 'd5e5');
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
positionKey,
|
positionKey,
|
||||||
pseudoReachable,
|
pseudoReachable,
|
||||||
startFenFor,
|
startFenFor,
|
||||||
|
threatenedSquares,
|
||||||
toSide,
|
toSide,
|
||||||
type Color,
|
type Color,
|
||||||
type LegalMove,
|
type LegalMove,
|
||||||
@@ -75,6 +76,7 @@ export const chessOptionsSchema = z.object({
|
|||||||
.enum(['off', 'moves'])
|
.enum(['off', 'moves'])
|
||||||
.default('moves')
|
.default('moves')
|
||||||
.meta({ title: '초보 도움말', labels: { off: '끄기', moves: '갈 수 있는 칸 표시' } }),
|
.meta({ title: '초보 도움말', labels: { off: '끄기', moves: '갈 수 있는 칸 표시' } }),
|
||||||
|
threatHints: z.boolean().default(false).meta({ title: '위협 가이드(관리자 전용): 공격받는 내 기물 표시' }),
|
||||||
});
|
});
|
||||||
export type ChessOptions = z.infer<typeof chessOptionsSchema>;
|
export type ChessOptions = z.infer<typeof chessOptionsSchema>;
|
||||||
|
|
||||||
@@ -180,6 +182,8 @@ export interface ChessView {
|
|||||||
hints: ChessOptions['hints'];
|
hints: ChessOptions['hints'];
|
||||||
/** Only for the player to move. */
|
/** Only for the player to move. */
|
||||||
legalMoves: LegalMove[] | null;
|
legalMoves: LegalMove[] | null;
|
||||||
|
/** Only for the player to move with threatHints (admin-only option). */
|
||||||
|
threatened: string[] | null;
|
||||||
result: ChessResult | null;
|
result: ChessResult | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -314,6 +318,7 @@ export const chess: GameDefinition<ChessState, ChessAction, ChessView, ChessOpti
|
|||||||
stateVersion: 2,
|
stateVersion: 2,
|
||||||
defaultOptions: chessOptionsSchema.parse({}),
|
defaultOptions: chessOptionsSchema.parse({}),
|
||||||
optionsSchema: chessOptionsSchema,
|
optionsSchema: chessOptionsSchema,
|
||||||
|
adminOnlyOptions: ['threatHints'],
|
||||||
actionSchema: chessActionSchema as z.ZodType<ChessAction>,
|
actionSchema: chessActionSchema as z.ZodType<ChessAction>,
|
||||||
|
|
||||||
setup({ players, options, rng, now, gameNo }) {
|
setup({ players, options, rng, now, gameNo }) {
|
||||||
@@ -586,6 +591,7 @@ export const chess: GameDefinition<ChessState, ChessAction, ChessView, ChessOpti
|
|||||||
autoQueen: s.options.autoQueen,
|
autoQueen: s.options.autoQueen,
|
||||||
hints: s.options.hints,
|
hints: s.options.hints,
|
||||||
legalMoves: mover ? legalMoves(c) : null,
|
legalMoves: mover ? legalMoves(c) : null,
|
||||||
|
threatened: mover && s.options.threatHints ? threatenedSquares(c) : null,
|
||||||
result: s.result,
|
result: s.result,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -193,6 +193,15 @@ export function cannotMate(c: Position, side: 'w' | 'b'): boolean {
|
|||||||
return false;
|
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 {
|
export function kingSquare(c: Position, side: 'w' | 'b'): string {
|
||||||
return c.kingSquare(side);
|
return c.kingSquare(side);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
placeArrangement,
|
placeArrangement,
|
||||||
positionKey,
|
positionKey,
|
||||||
pseudoMoves,
|
pseudoMoves,
|
||||||
|
attacks,
|
||||||
sqOf,
|
sqOf,
|
||||||
type Arrangement,
|
type Arrangement,
|
||||||
type Board,
|
type Board,
|
||||||
@@ -73,6 +74,7 @@ export const janggiOptionsSchema = z.object({
|
|||||||
.enum(['off', 'moves'])
|
.enum(['off', 'moves'])
|
||||||
.default('moves')
|
.default('moves')
|
||||||
.meta({ title: '도움 표시', labels: { off: '끄기', moves: '갈 수 있는 곳' } }),
|
.meta({ title: '도움 표시', labels: { off: '끄기', moves: '갈 수 있는 곳' } }),
|
||||||
|
threatHints: z.boolean().default(false).meta({ title: '위협 가이드(관리자 전용): 잡힐 위험이 있는 내 기물 표시' }),
|
||||||
});
|
});
|
||||||
export type JanggiOptions = z.infer<typeof janggiOptionsSchema>;
|
export type JanggiOptions = z.infer<typeof janggiOptionsSchema>;
|
||||||
|
|
||||||
@@ -178,6 +180,7 @@ export interface JanggiView {
|
|||||||
/** Only for the player to move. */
|
/** Only for the player to move. */
|
||||||
legalMoves: JanggiLegalMove[] | null;
|
legalMoves: JanggiLegalMove[] | null;
|
||||||
canPass: boolean;
|
canPass: boolean;
|
||||||
|
threatened: Sq[];
|
||||||
rules: Pick<JanggiOptions, 'scoring' | 'bikjang' | 'repetition' | 'allowPass' | 'moveLimit' | 'hints' | 'timeControl' | 'idleLimitSec' | 'setupTimeSec'>;
|
rules: Pick<JanggiOptions, 'scoring' | 'bikjang' | 'repetition' | 'allowPass' | 'moveLimit' | 'hints' | 'timeControl' | 'idleLimitSec' | 'setupTimeSec'>;
|
||||||
clock: JanggiState['clock'];
|
clock: JanggiState['clock'];
|
||||||
undo: { allowed: boolean; remaining: Record<Side, number | null>; pending: { by: Side; plies: number } | null };
|
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,
|
stateVersion: 1,
|
||||||
defaultOptions: janggiOptionsSchema.parse({}),
|
defaultOptions: janggiOptionsSchema.parse({}),
|
||||||
optionsSchema: janggiOptionsSchema,
|
optionsSchema: janggiOptionsSchema,
|
||||||
|
adminOnlyOptions: ['threatHints'],
|
||||||
actionSchema: janggiActionSchema as z.ZodType<JanggiAction>,
|
actionSchema: janggiActionSchema as z.ZodType<JanggiAction>,
|
||||||
|
|
||||||
setup({ players, options, rng, now, gameNo }) {
|
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 myTurn = playing && mySide === s.turn;
|
||||||
const captured: Record<Side, PieceKind[]> = { cho: [], han: [] };
|
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);
|
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 last = [...s.moves].reverse().find((m) => m.kind === 'move');
|
||||||
const limit = undoLimit(s.options);
|
const limit = undoLimit(s.options);
|
||||||
const o = s.options;
|
const o = s.options;
|
||||||
@@ -676,6 +688,7 @@ export const janggi: GameDefinition<JanggiState, JanggiAction, JanggiView, Jangg
|
|||||||
repeatWarning: mo?.warn ?? false,
|
repeatWarning: mo?.warn ?? false,
|
||||||
legalMoves: myTurn && mo ? mo.moves.map(legalToView) : null,
|
legalMoves: myTurn && mo ? mo.moves.map(legalToView) : null,
|
||||||
canPass: myTurn && !!mo?.pass,
|
canPass: myTurn && !!mo?.pass,
|
||||||
|
threatened,
|
||||||
rules: {
|
rules: {
|
||||||
scoring: o.scoring,
|
scoring: o.scoring,
|
||||||
bikjang: o.bikjang,
|
bikjang: o.bikjang,
|
||||||
|
|||||||
@@ -511,7 +511,7 @@ describe('requests, undo, views', () => {
|
|||||||
expect(s.result).toMatchObject({ winner: 'cho', reason: 'resign' });
|
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();
|
const s = standard();
|
||||||
expect(JSON.stringify(janggi.view(s, 'A'))).not.toContain('rng');
|
expect(JSON.stringify(janggi.view(s, 'A'))).not.toContain('rng');
|
||||||
expect(janggi.view(s, null).legalMoves).toBeNull();
|
expect(janggi.view(s, null).legalMoves).toBeNull();
|
||||||
@@ -519,9 +519,9 @@ describe('requests, undo, views', () => {
|
|||||||
const v = janggi.view(s, 'A');
|
const v = janggi.view(s, 'A');
|
||||||
expect(v.legalMoves!.length).toBeGreaterThan(20);
|
expect(v.legalMoves!.length).toBeGreaterThan(20);
|
||||||
expect(v.canPass).toBe(true);
|
expect(v.canPass).toBe(true);
|
||||||
const t = custom([...kingsOnly, ['cho', 'ma', 4, 4], ['han', 'cha', 4, 7]]);
|
expect(janggi.view(custom([...kingsOnly, ['cho', 'ma', 4, 4], ['han', 'cha', 4, 7]]), 'A').threatened).toEqual([]);
|
||||||
expect(janggi.view(t, 'A')).not.toHaveProperty('threatened');
|
const t = custom([...kingsOnly, ['cho', 'ma', 4, 4], ['han', 'cha', 4, 7]], { threatHints: true });
|
||||||
expect(janggi.optionsSchema.safeParse({ hints: 'movesAndThreats' }).success).toBe(false);
|
expect(janggi.view(t, 'A').threatened).toEqual([{ f: 4, r: 4 }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('포 legal moves carry their screen for the UI', () => {
|
test('포 legal moves carry their screen for the UI', () => {
|
||||||
|
|||||||
@@ -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 { fail, ok, type GameDefinition, type GameEvent, type GameResult, type PlayerId, type Rng } from '@bg/engine';
|
||||||
import {
|
import {
|
||||||
Board,
|
Board,
|
||||||
|
candidateCells,
|
||||||
forbiddenCells,
|
forbiddenCells,
|
||||||
forbiddenKind,
|
forbiddenKind,
|
||||||
|
straightFourCount,
|
||||||
winningLine,
|
winningLine,
|
||||||
type Cell,
|
type Cell,
|
||||||
type RuleConfig,
|
type RuleConfig,
|
||||||
@@ -62,6 +64,7 @@ export const omokOptionsSchema = z.object({
|
|||||||
drawOffer: z.boolean().default(true).meta({ title: '무승부 제안 허용' }),
|
drawOffer: z.boolean().default(true).meta({ title: '무승부 제안 허용' }),
|
||||||
drawReofferMoves: z.number().int().min(0).max(50).default(3).meta({ title: '무승부 다시 제안까지 둬야 하는 수' }),
|
drawReofferMoves: z.number().int().min(0).max(50).default(3).meta({ title: '무승부 다시 제안까지 둬야 하는 수' }),
|
||||||
showForbidden: z.boolean().default(true).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>;
|
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 };
|
undo: { allowed: boolean; remaining: Record<Color, number | null>; pending: { by: Color; plies: number } | null };
|
||||||
draw: { allowed: boolean; pendingBy: Color | null };
|
draw: { allowed: boolean; pendingBy: Color | null };
|
||||||
forbidden: Pt[];
|
forbidden: Pt[];
|
||||||
|
hints: { urgent: Pt[]; warn: Pt[] } | null;
|
||||||
result: OmokState['result'];
|
result: OmokState['result'];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -246,6 +250,22 @@ function chooseAutoMove(s: OmokState, rng: Rng): Pt {
|
|||||||
return pick(near.length ? near : legal);
|
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 {
|
function pendingResponder(s: OmokState): Color | null {
|
||||||
if (s.undo.pending) return other(s.undo.pending.by);
|
if (s.undo.pending) return other(s.undo.pending.by);
|
||||||
if (s.draw.pendingBy) return other(s.draw.pendingBy);
|
if (s.draw.pendingBy) return other(s.draw.pendingBy);
|
||||||
@@ -260,6 +280,7 @@ export const omok: GameDefinition<OmokState, OmokAction, OmokView, OmokOptions>
|
|||||||
stateVersion: 1,
|
stateVersion: 1,
|
||||||
defaultOptions: omokOptionsSchema.parse({}),
|
defaultOptions: omokOptionsSchema.parse({}),
|
||||||
optionsSchema: omokOptionsSchema,
|
optionsSchema: omokOptionsSchema,
|
||||||
|
adminOnlyOptions: ['threatHints'],
|
||||||
actionSchema: omokActionSchema as z.ZodType<OmokAction>,
|
actionSchema: omokActionSchema as z.ZodType<OmokAction>,
|
||||||
|
|
||||||
setup({ players, options, rng, now, gameNo }) {
|
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 myColor = viewer ? colorOf(s, viewer) : null;
|
||||||
const limit = undoLimit(s.options);
|
const limit = undoLimit(s.options);
|
||||||
const showForbidden = s.options.showForbidden && s.phase === 'playing' && (myColor === null || myColor === s.turn);
|
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 {
|
return {
|
||||||
size: s.size,
|
size: s.size,
|
||||||
board: s.board,
|
board: s.board,
|
||||||
@@ -463,6 +485,7 @@ export const omok: GameDefinition<OmokState, OmokAction, OmokView, OmokOptions>
|
|||||||
},
|
},
|
||||||
draw: { allowed: s.options.drawOffer, pendingBy: s.draw.pendingBy },
|
draw: { allowed: s.options.drawOffer, pendingBy: s.draw.pendingBy },
|
||||||
forbidden: showForbidden ? s.forbidden.map((i) => toPt(i, s.size)) : [],
|
forbidden: showForbidden ? s.forbidden.map((i) => toPt(i, s.size)) : [],
|
||||||
|
hints: showHints ? threatHints(s) : null,
|
||||||
result: s.result,
|
result: s.result,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -259,9 +259,13 @@ describe('time', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('views and playouts', () => {
|
describe('views and playouts', () => {
|
||||||
test('no threat guide (where to block) in any view', () => {
|
test('threat guide is off by default; when the admin turns it on, only the player to move sees it', () => {
|
||||||
const s = make({}, { black: ['F8', 'G8', 'H8', 'I8'], white: ['A1', 'A3', 'A5'] }, 'white');
|
const off = make({}, { black: ['F8', 'G8', 'H8'], white: ['A1', 'A3'] }, 'white');
|
||||||
for (const viewer of ['A', 'B', null]) expect(omok.view(s, viewer)).not.toHaveProperty('hints');
|
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', () => {
|
test('random playouts finish, are deterministic and fast', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user