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

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

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

View File

@@ -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();

View File

@@ -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<string, string | undefined> = process.env): Config {
const publicOrigin = (env.PUBLIC_ORIGIN ?? 'http://localhost:5173').replace(/\/$/, '');

View File

@@ -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,
};