M1.5 관리자 사이트: 슈퍼어드민/어드민 권한, 게임·사이트 설정, 방·사용자 관리, 기록
- 권한: 디스코드 ID로 요청마다 계산. 슈퍼어드민 293719842274541579(SUPERADMIN_DISCORD_IDS), 어드민은 슈퍼어드민이 디스코드 ID로 추가(로그인 전 미리 등록 가능), 고정 슈퍼어드민은 삭제 불가 - 게임 설정: 사용 여부, 표시 이름·설명·안내문, 원작 범위 안 인원, 모든 규칙 옵션 기본값, 옵션별 방장 변경 금지(잠금), 초기화. 입력 화면은 옵션 스키마(zod→JSON Schema)로 자동 생성 - 오목 옵션 전체에 한국어 제목·선택지 메타, 엔진 고정값(자동 착수 허용 횟수, 무승부 재제안 간격, 한 수 제한 초)을 옵션으로 꺼냄 - 사이트 설정: 이름, 공지, 점검 모드, 금지어, 방 수 제한, 연결 유예, 채팅 기본값 - 방 관리(목록·닫기), 사용자 관리(검색·닉네임 변경·이용 제한·강제 로그아웃·삭제, 슈퍼어드민 전용), 관리자 작업 기록(전/후 값) - 이용 제한: 세션 삭제 + 접속 종료 + 디스코드 재로그인 거부 - 테스트: 관리자 API 7개(권한·설정 반영·잠금·점검·금지어·방 닫기·이용 제한·기록), 오목 옵션 2개, e2e/admin.e2e.ts(슈퍼어드민 로그인→규칙 변경·잠금→어드민 추가→게스트 새 방 반영) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -17,6 +17,7 @@ bun run typecheck
|
||||
bun test # 규칙·서버 통합·방 단위 테스트
|
||||
(cd apps/web && bunx vite build) # 웹 빌드
|
||||
bun e2e/omok.e2e.ts # 브라우저 두 개로 오목 한 판 (Chrome 필요)
|
||||
bun e2e/admin.e2e.ts # 관리자: 슈퍼어드민 로그인 → 규칙 변경·잠금 → 새 방 반영
|
||||
```
|
||||
|
||||
## 배포
|
||||
@@ -26,6 +27,9 @@ 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).
|
||||
|
||||
## 구조
|
||||
- `packages/engine` — 게임 공통 타입, 시드 난수, 테스트 도구
|
||||
- `packages/shared` — 프로토콜, 닉네임·방 코드 규칙, 게임 목록 (`/lite`는 브라우저용)
|
||||
|
||||
208
apps/server/src/admin.test.ts
Normal file
208
apps/server/src/admin.test.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
/** Admin site tests (docs/14-admin.md §9). */
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import { Client, api, boot, guest, sleep } from './test-utils';
|
||||
|
||||
const SUPER = '293719842274541579';
|
||||
type Booted = ReturnType<typeof boot>;
|
||||
const running: Booted[] = [];
|
||||
const clients: Client[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
for (const c of clients.splice(0)) c.close();
|
||||
for (const s of running.splice(0)) await s.stop();
|
||||
});
|
||||
|
||||
function start(): Booted {
|
||||
const s = boot(undefined, {
|
||||
discordExchange: async (code) => ({ id: code, username: `user${code.slice(-4)}`, global_name: `디코${code.slice(-4)}`, avatar: null }),
|
||||
});
|
||||
s.config.discord = { clientId: 'cid', clientSecret: 'sec' };
|
||||
running.push(s);
|
||||
return s;
|
||||
}
|
||||
|
||||
/** Logs in through the (mocked) Discord flow; returns the session cookie and user id. */
|
||||
async function discordLogin(s: Booted, discordId: string): Promise<{ cookie: string; id: string }> {
|
||||
const st = await api(s.base, '/api/auth/discord/start?next=/');
|
||||
const state = new URL(st.res.headers.get('location')!).searchParams.get('state')!;
|
||||
const oauth = st.setCookie!.split(';')[0]!;
|
||||
const cb = await api(s.base, `/api/auth/discord/callback?code=${discordId}&state=${state}`, { cookie: oauth });
|
||||
const loc = cb.res.headers.get('location');
|
||||
if (loc !== '/') throw new Error(`login redirect ${loc}`);
|
||||
const sid = cb.setCookie!.split(/,(?=\s*\w+=)/).find((c) => c.trim().startsWith('sid='))!.split(';')[0]!.trim();
|
||||
const me = await api(s.base, '/api/me', { cookie: sid });
|
||||
return { cookie: sid, id: me.body.me.id };
|
||||
}
|
||||
|
||||
describe('roles', () => {
|
||||
test('guest and plain discord users get 401/403; superadmin by fixed Discord ID', async () => {
|
||||
const s = start();
|
||||
const g = await guest(s.base, '손님');
|
||||
expect((await api(s.base, '/api/admin/me', { cookie: g.cookie })).res.status).toBe(403);
|
||||
expect((await api(s.base, '/api/admin/me')).res.status).toBe(401);
|
||||
const plain = await discordLogin(s, '111111111111111111');
|
||||
expect((await api(s.base, '/api/admin/overview', { cookie: plain.cookie })).res.status).toBe(403);
|
||||
expect((await api(s.base, '/api/me', { cookie: plain.cookie })).body.role).toBe('user');
|
||||
const sup = await discordLogin(s, SUPER);
|
||||
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');
|
||||
});
|
||||
|
||||
test('superadmin adds/removes an admin by Discord ID; takes effect immediately; admin cannot manage users', async () => {
|
||||
const s = start();
|
||||
const sup = await discordLogin(s, SUPER);
|
||||
const adminDiscord = '222222222222222222';
|
||||
const bad = await api(s.base, '/api/admin/admins', { method: 'POST', cookie: sup.cookie, body: JSON.stringify({ discordId: '12ab' }) });
|
||||
expect(bad.res.status).toBe(400);
|
||||
// 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]);
|
||||
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);
|
||||
expect((await api(s.base, '/api/admin/admins', { cookie: adm.cookie })).res.status).toBe(403);
|
||||
expect((await api(s.base, '/api/admin/games', { cookie: adm.cookie })).res.status).toBe(200);
|
||||
// Fixed superadmin cannot be removed.
|
||||
expect((await api(s.base, `/api/admin/admins/${SUPER}`, { method: 'DELETE', cookie: sup.cookie })).res.status).toBe(400);
|
||||
expect((await api(s.base, `/api/admin/admins/${adminDiscord}`, { method: 'DELETE', cookie: sup.cookie })).res.status).toBe(200);
|
||||
expect((await api(s.base, '/api/admin/me', { cookie: adm.cookie })).res.status).toBe(403);
|
||||
const audit = await api(s.base, '/api/admin/audit', { cookie: sup.cookie });
|
||||
expect(audit.body.entries.map((e: any) => e.action)).toEqual(['admin.remove', 'admin.add']);
|
||||
});
|
||||
|
||||
test('admin API rejects foreign-origin writes', async () => {
|
||||
const s = start();
|
||||
const sup = await discordLogin(s, SUPER);
|
||||
const res = await fetch(s.base + '/api/admin/site', {
|
||||
method: 'PUT',
|
||||
headers: { origin: 'https://evil.example', cookie: sup.cookie, 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ maintenance: true }),
|
||||
});
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('game settings', () => {
|
||||
test('schema exposed with Korean titles; defaults apply to new rooms; locked options win; invalid values rejected', async () => {
|
||||
const s = start();
|
||||
const sup = await discordLogin(s, SUPER);
|
||||
const games = await api(s.base, '/api/admin/games', { cookie: sup.cookie });
|
||||
const omok = games.body.games.find((g: any) => g.id === 'omok');
|
||||
expect(omok.schema.properties.ruleSet.title).toBe('규칙');
|
||||
expect(omok.schema.properties.autoMoveLimit.title).toContain('자동 착수');
|
||||
const bad = await api(s.base, '/api/admin/games/omok', {
|
||||
method: 'PUT',
|
||||
cookie: sup.cookie,
|
||||
body: JSON.stringify({ defaultOptions: { ruleSet: 'nope' } }),
|
||||
});
|
||||
expect(bad.res.status).toBe(400);
|
||||
const tooMany = await api(s.base, '/api/admin/games/omok', { method: 'PUT', cookie: sup.cookie, body: JSON.stringify({ maxPlayers: 3 }) });
|
||||
expect(tooMany.res.status).toBe(400);
|
||||
const put = await api(s.base, '/api/admin/games/omok', {
|
||||
method: 'PUT',
|
||||
cookie: sup.cookie,
|
||||
body: JSON.stringify({
|
||||
nameKo: '오목(친구룰)',
|
||||
notice: '렌주룰로만 해요',
|
||||
defaultOptions: { ruleSet: 'renju', timeControl: { kind: 'perMove', perMoveSec: 45 } },
|
||||
lockedOptions: ['ruleSet'],
|
||||
}),
|
||||
});
|
||||
expect(put.res.status).toBe(200);
|
||||
expect(put.body.effective.defaultOptions.timeControl.perMoveSec).toBe(45);
|
||||
const cfg = await api(s.base, '/api/config');
|
||||
expect(cfg.body.catalog.find((g: any) => g.id === 'omok')).toMatchObject({ nameKo: '오목(친구룰)', notice: '렌주룰로만 해요' });
|
||||
|
||||
// A host creates a room → admin defaults; trying to change the locked rule is overridden.
|
||||
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).ruleSet).toBe('renju');
|
||||
expect(first.room.lockedOptions).toEqual(['ruleSet']);
|
||||
c.send({ t: 'config', options: { ...(first.room.options as any), ruleSet: 'free', boardSize: 19 } });
|
||||
const after = await c.next('room', (m) => (m.room.options as any).boardSize === 19);
|
||||
expect((after.room.options as any).ruleSet).toBe('renju');
|
||||
|
||||
// Disable the game → creation refused and hidden from catalog.
|
||||
await api(s.base, '/api/admin/games/omok', { method: 'PUT', cookie: sup.cookie, body: JSON.stringify({ enabled: false }) });
|
||||
const refused = await api(s.base, '/api/rooms', { method: 'POST', cookie: host.cookie, body: JSON.stringify({ gameId: 'omok' }) });
|
||||
expect(refused.res.status).toBe(400);
|
||||
expect((await api(s.base, '/api/config')).body.catalog.find((g: any) => g.id === 'omok').available).toBe(false);
|
||||
// Reset restores code defaults.
|
||||
await api(s.base, '/api/admin/games/omok/reset', { method: 'POST', cookie: sup.cookie });
|
||||
expect((await api(s.base, '/api/config')).body.catalog.find((g: any) => g.id === 'omok')).toMatchObject({ nameKo: '오목', available: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('site settings, rooms, users', () => {
|
||||
test('maintenance blocks room creation; banned words masked in chat and rejected in nicknames', async () => {
|
||||
const s = start();
|
||||
const sup = await discordLogin(s, SUPER);
|
||||
const site = (await api(s.base, '/api/admin/site', { cookie: sup.cookie })).body.site;
|
||||
await api(s.base, '/api/admin/site', { method: 'PUT', cookie: sup.cookie, body: JSON.stringify({ ...site, maintenance: true, announcement: '점검 중이에요' }) });
|
||||
const g = await guest(s.base, '방장님');
|
||||
const r = await api(s.base, '/api/rooms', { method: 'POST', cookie: g.cookie, body: JSON.stringify({ gameId: 'omok' }) });
|
||||
expect(r.res.status).toBe(429);
|
||||
expect(r.body.error).toBe('점검 중이에요');
|
||||
expect((await api(s.base, '/api/config')).body).toMatchObject({ maintenance: true, announcement: '점검 중이에요' });
|
||||
await api(s.base, '/api/admin/site', { method: 'PUT', cookie: sup.cookie, body: JSON.stringify({ ...site, bannedWords: ['바보'] }) });
|
||||
expect((await api(s.base, '/api/auth/guest', { method: 'POST', body: JSON.stringify({ nickname: '왕바보' }) })).res.status).toBe(400);
|
||||
const room = await api(s.base, '/api/rooms', { method: 'POST', cookie: g.cookie, body: JSON.stringify({ gameId: 'omok' }) });
|
||||
const c = await new Client(s.base, g.cookie, g.id).connect();
|
||||
clients.push(c);
|
||||
c.send({ t: 'join', code: room.body.code, as: 'player' });
|
||||
await c.next('room');
|
||||
const chat = c.next('chat');
|
||||
c.send({ t: 'chat', text: '너 바보야' });
|
||||
expect((await chat).text).toBe('너 **야');
|
||||
});
|
||||
|
||||
test('admin closes a room: everyone is told and disconnected from it', async () => {
|
||||
const s = start();
|
||||
const sup = await discordLogin(s, SUPER);
|
||||
const g = await guest(s.base, '방주인');
|
||||
const room = await api(s.base, '/api/rooms', { method: 'POST', cookie: g.cookie, body: JSON.stringify({ gameId: 'omok' }) });
|
||||
const c = await new Client(s.base, g.cookie, g.id).connect();
|
||||
clients.push(c);
|
||||
c.send({ t: 'join', code: room.body.code, as: 'player' });
|
||||
await c.next('room');
|
||||
const list = await api(s.base, '/api/admin/rooms', { cookie: sup.cookie });
|
||||
expect(list.body.rooms.map((r: any) => r.code)).toContain(room.body.code);
|
||||
const bye = c.next('bye');
|
||||
expect((await api(s.base, `/api/admin/rooms/${room.body.code}/close`, { method: 'POST', cookie: sup.cookie })).res.status).toBe(200);
|
||||
expect((await bye).reason).toBe('closed');
|
||||
expect((await api(s.base, `/api/rooms/${room.body.code}`)).res.status).toBe(404);
|
||||
});
|
||||
|
||||
test('superadmin: search, rename, ban (sessions revoked, socket closed, discord re-login refused), unban', async () => {
|
||||
const s = start();
|
||||
const sup = await discordLogin(s, SUPER);
|
||||
const victim = await discordLogin(s, '333333333333333333');
|
||||
const c = await new Client(s.base, victim.cookie, victim.id).connect();
|
||||
clients.push(c);
|
||||
const found = await api(s.base, '/api/admin/users?q=333333333333333333', { cookie: sup.cookie });
|
||||
expect(found.body.users.map((u: any) => u.id)).toEqual([victim.id]);
|
||||
const ren = await api(s.base, `/api/admin/users/${victim.id}`, { method: 'PATCH', cookie: sup.cookie, body: JSON.stringify({ nickname: '바뀐 이름' }) });
|
||||
expect(ren.res.status).toBe(200);
|
||||
expect((await api(s.base, '/api/me', { cookie: victim.cookie })).body.me.nickname).toBe('바뀐 이름');
|
||||
const ban = await api(s.base, `/api/admin/users/${victim.id}`, { method: 'PATCH', cookie: sup.cookie, body: JSON.stringify({ ban: { reason: '도배' } }) });
|
||||
expect(ban.res.status).toBe(200);
|
||||
for (let i = 0; i < 20 && !c.closed; i++) await sleep(25);
|
||||
expect(c.closed?.code).toBe(4403);
|
||||
expect((await api(s.base, '/api/me', { cookie: victim.cookie })).body.me).toBeNull();
|
||||
await expect(discordLogin(s, '333333333333333333')).rejects.toThrow('/?login=banned');
|
||||
// Superadmin cannot ban themselves.
|
||||
const self = await api(s.base, `/api/admin/users/${sup.id}`, { method: 'PATCH', cookie: sup.cookie, body: JSON.stringify({ ban: { reason: 'x' } }) });
|
||||
expect(self.res.status).toBe(400);
|
||||
await api(s.base, `/api/admin/users/${victim.id}`, { method: 'PATCH', cookie: sup.cookie, body: JSON.stringify({ ban: null }) });
|
||||
const again = await discordLogin(s, '333333333333333333');
|
||||
expect(again.id).toBe(victim.id);
|
||||
const actions = (await api(s.base, '/api/admin/audit', { cookie: sup.cookie })).body.entries.map((e: any) => e.action);
|
||||
expect(actions).toEqual(['user.unban', 'user.ban', 'user.rename']);
|
||||
});
|
||||
});
|
||||
90
apps/server/src/admin/roles.ts
Normal file
90
apps/server/src/admin/roles.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
/** Role resolution by linked Discord ID (docs/14-admin.md §2). Computed per request. */
|
||||
import type { Database } from 'bun:sqlite';
|
||||
|
||||
export type Role = 'user' | 'admin' | 'superadmin';
|
||||
|
||||
export class Roles {
|
||||
constructor(
|
||||
private db: Database,
|
||||
private superadminDiscordIds: string[],
|
||||
) {}
|
||||
|
||||
discordIdOf(userId: string): string | null {
|
||||
return (
|
||||
this.db
|
||||
.query<{ provider_user_id: string }, [string]>("SELECT provider_user_id FROM oauth_accounts WHERE user_id = ? AND provider = 'discord'")
|
||||
.get(userId)?.provider_user_id ?? null
|
||||
);
|
||||
}
|
||||
|
||||
roleOfDiscord(discordId: string | null): Role {
|
||||
if (!discordId) return 'user';
|
||||
if (this.superadminDiscordIds.includes(discordId)) return 'superadmin';
|
||||
const row = this.db.query<{ n: number }, [string]>('SELECT COUNT(*) AS n FROM admins WHERE discord_id = ?').get(discordId);
|
||||
return (row?.n ?? 0) > 0 ? 'admin' : 'user';
|
||||
}
|
||||
|
||||
roleOf(userId: string | null): Role {
|
||||
return userId ? this.roleOfDiscord(this.discordIdOf(userId)) : 'user';
|
||||
}
|
||||
|
||||
isSuperadminDiscord(discordId: string): boolean {
|
||||
return this.superadminDiscordIds.includes(discordId);
|
||||
}
|
||||
|
||||
listAdmins(): { discordId: string; note: string | null; addedBy: string | null; addedAt: number; userId: string | null; nickname: string | null; fixed: boolean }[] {
|
||||
const rows = this.db
|
||||
.query<{ discord_id: string; note: string | null; added_by: string | null; added_at: number; user_id: string | null; nickname: string | null }, []>(
|
||||
`SELECT a.discord_id, a.note, a.added_by, a.added_at, o.user_id, u.nickname FROM admins a
|
||||
LEFT JOIN oauth_accounts o ON o.provider = 'discord' AND o.provider_user_id = a.discord_id
|
||||
LEFT JOIN users u ON u.id = o.user_id ORDER BY a.added_at`,
|
||||
)
|
||||
.all()
|
||||
.map((r) => ({ discordId: r.discord_id, note: r.note, addedBy: r.added_by, addedAt: r.added_at, userId: r.user_id, nickname: r.nickname, fixed: false }));
|
||||
const fixed = this.superadminDiscordIds.map((d) => {
|
||||
const u = this.db
|
||||
.query<{ user_id: string; nickname: string }, [string]>(
|
||||
"SELECT o.user_id, u.nickname FROM oauth_accounts o JOIN users u ON u.id = o.user_id WHERE o.provider = 'discord' AND o.provider_user_id = ?",
|
||||
)
|
||||
.get(d);
|
||||
return { discordId: d, note: '슈퍼어드민(설정 고정)', addedBy: null, addedAt: 0, userId: u?.user_id ?? null, nickname: u?.nickname ?? null, fixed: true };
|
||||
});
|
||||
return [...fixed, ...rows];
|
||||
}
|
||||
|
||||
addAdmin(discordId: string, note: string | null, by: string, now = Date.now()): boolean {
|
||||
return this.db.query('INSERT OR IGNORE INTO admins (discord_id, note, added_by, added_at) VALUES (?, ?, ?, ?)').run(discordId, note, by, now).changes > 0;
|
||||
}
|
||||
|
||||
removeAdmin(discordId: string): boolean {
|
||||
return this.db.query('DELETE FROM admins WHERE discord_id = ?').run(discordId).changes > 0;
|
||||
}
|
||||
}
|
||||
|
||||
export class Audit {
|
||||
constructor(private db: Database) {}
|
||||
|
||||
log(actorId: string, action: string, target: string | null, before: unknown, after: unknown, now = Date.now()): void {
|
||||
this.db
|
||||
.query('INSERT INTO admin_audit (actor_id, action, target, before_json, after_json, at) VALUES (?, ?, ?, ?, ?, ?)')
|
||||
.run(actorId, action, target, before === undefined ? null : JSON.stringify(before), after === undefined ? null : JSON.stringify(after), now);
|
||||
}
|
||||
|
||||
list(limit: number, offset: number) {
|
||||
return this.db
|
||||
.query<{ id: number; actor_id: string; nickname: string | null; action: string; target: string | null; before_json: string | null; after_json: string | null; at: number }, [number, number]>(
|
||||
'SELECT a.*, u.nickname FROM admin_audit a LEFT JOIN users u ON u.id = a.actor_id ORDER BY a.id DESC LIMIT ? OFFSET ?',
|
||||
)
|
||||
.all(limit, offset)
|
||||
.map((r) => ({
|
||||
id: r.id,
|
||||
actorId: r.actor_id,
|
||||
actor: r.nickname,
|
||||
action: r.action,
|
||||
target: r.target,
|
||||
before: r.before_json ? JSON.parse(r.before_json) : null,
|
||||
after: r.after_json ? JSON.parse(r.after_json) : null,
|
||||
at: r.at,
|
||||
}));
|
||||
}
|
||||
}
|
||||
159
apps/server/src/admin/settings.ts
Normal file
159
apps/server/src/admin/settings.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* Admin-editable site and game settings with an in-memory cache (docs/14-admin.md §4–§5).
|
||||
* Changes apply to new rooms / new games only.
|
||||
*/
|
||||
import type { Database } from 'bun:sqlite';
|
||||
import { z } from 'zod';
|
||||
import type { AnyGameDefinition } from '@bg/engine';
|
||||
import { CATALOG, type CatalogEntry } from '@bg/shared';
|
||||
|
||||
export const SiteSettingsSchema = z.object({
|
||||
siteName: z.string().trim().min(1).max(40).default('같이 놀자 보드게임'),
|
||||
announcement: z.string().trim().max(300).default(''),
|
||||
maintenance: z.boolean().default(false),
|
||||
bannedWords: z.array(z.string().trim().min(1).max(30)).max(500).default([]),
|
||||
maxRoomsPerUser: z.number().int().min(1).max(50).default(3),
|
||||
maxRooms: z.number().int().min(10).max(20000).default(2000),
|
||||
graceSec: z.number().int().min(10).max(600).default(60),
|
||||
chatEnabledDefault: z.boolean().default(true),
|
||||
chatFilterDefault: z.boolean().default(true),
|
||||
});
|
||||
export type SiteSettings = z.infer<typeof SiteSettingsSchema>;
|
||||
|
||||
export const GameSettingsSchema = z.object({
|
||||
enabled: z.boolean().optional(),
|
||||
nameKo: z.string().trim().min(1).max(30).optional(),
|
||||
blurb: z.string().trim().max(80).optional(),
|
||||
notice: z.string().trim().max(500).optional(),
|
||||
minPlayers: z.number().int().min(1).max(32).optional(),
|
||||
maxPlayers: z.number().int().min(1).max(32).optional(),
|
||||
defaultOptions: z.unknown().optional(),
|
||||
lockedOptions: z.array(z.string().max(60)).max(100).optional(),
|
||||
});
|
||||
export type GameSettings = z.infer<typeof GameSettingsSchema>;
|
||||
|
||||
export interface EffectiveGame {
|
||||
id: string;
|
||||
enabled: boolean;
|
||||
nameKo: string;
|
||||
blurb: string;
|
||||
notice: string;
|
||||
minPlayers: number;
|
||||
maxPlayers: number;
|
||||
defaultOptions: unknown;
|
||||
lockedOptions: string[];
|
||||
}
|
||||
|
||||
export class SettingsStore {
|
||||
private site: SiteSettings;
|
||||
private games = new Map<string, GameSettings>();
|
||||
|
||||
constructor(
|
||||
private db: Database,
|
||||
private defs: Record<string, AnyGameDefinition>,
|
||||
) {
|
||||
const row = db.query<{ json: string }, [string]>("SELECT json FROM site_settings WHERE key = ?").get('site');
|
||||
this.site = SiteSettingsSchema.parse(row ? JSON.parse(row.json) : {});
|
||||
for (const r of db.query<{ game_id: string; json: string }, []>('SELECT game_id, json FROM game_settings').all()) {
|
||||
const parsed = GameSettingsSchema.safeParse(JSON.parse(r.json));
|
||||
if (parsed.success) this.games.set(r.game_id, parsed.data);
|
||||
}
|
||||
}
|
||||
|
||||
getSite(): SiteSettings {
|
||||
return this.site;
|
||||
}
|
||||
|
||||
setSite(next: SiteSettings, by: string, now = Date.now()): void {
|
||||
this.db
|
||||
.query('INSERT INTO site_settings (key, json, updated_by, updated_at) VALUES (?, ?, ?, ?) ON CONFLICT(key) DO UPDATE SET json = excluded.json, updated_by = excluded.updated_by, updated_at = excluded.updated_at')
|
||||
.run('site', JSON.stringify(next), by, now);
|
||||
this.site = next;
|
||||
}
|
||||
|
||||
rawGame(id: string): GameSettings {
|
||||
return this.games.get(id) ?? {};
|
||||
}
|
||||
|
||||
/** Validates against the game's own schemas. Returns an error message or null. */
|
||||
validateGame(id: string, s: GameSettings): string | null {
|
||||
const def = this.defs[id];
|
||||
if (!def) return '없는 게임이에요.';
|
||||
if (s.defaultOptions !== undefined) {
|
||||
const r = def.optionsSchema.safeParse(s.defaultOptions);
|
||||
if (!r.success) return `기본 규칙 값이 올바르지 않아요: ${r.error.issues.map((i) => i.path.join('.') + ' ' + i.message).join(', ')}`;
|
||||
}
|
||||
const min = s.minPlayers ?? def.minPlayers;
|
||||
const max = s.maxPlayers ?? def.maxPlayers;
|
||||
if (min < def.minPlayers || max > def.maxPlayers || min > max) {
|
||||
return `인원은 원작 범위(${def.minPlayers}~${def.maxPlayers}명) 안에서만 정할 수 있어요.`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
setGame(id: string, s: GameSettings, by: string, now = Date.now()): void {
|
||||
const def = this.defs[id]!;
|
||||
const clean: GameSettings = { ...s };
|
||||
if (clean.defaultOptions !== undefined) clean.defaultOptions = def.optionsSchema.parse(clean.defaultOptions);
|
||||
this.db
|
||||
.query('INSERT INTO game_settings (game_id, json, updated_by, updated_at) VALUES (?, ?, ?, ?) ON CONFLICT(game_id) DO UPDATE SET json = excluded.json, updated_by = excluded.updated_by, updated_at = excluded.updated_at')
|
||||
.run(id, JSON.stringify(clean), by, now);
|
||||
this.games.set(id, clean);
|
||||
}
|
||||
|
||||
resetGame(id: string): void {
|
||||
this.db.query('DELETE FROM game_settings WHERE game_id = ?').run(id);
|
||||
this.games.delete(id);
|
||||
}
|
||||
|
||||
game(id: string): EffectiveGame | null {
|
||||
const def = this.defs[id];
|
||||
if (!def) return null;
|
||||
const meta = CATALOG.find((c) => c.id === id);
|
||||
const s = this.rawGame(id);
|
||||
let defaults: unknown = def.defaultOptions;
|
||||
if (s.defaultOptions !== undefined) {
|
||||
const r = def.optionsSchema.safeParse(s.defaultOptions);
|
||||
if (r.success) defaults = r.data;
|
||||
}
|
||||
return {
|
||||
id,
|
||||
enabled: s.enabled ?? meta?.available ?? true,
|
||||
nameKo: s.nameKo ?? meta?.nameKo ?? def.nameKo,
|
||||
blurb: s.blurb ?? meta?.blurb ?? '',
|
||||
notice: s.notice ?? '',
|
||||
minPlayers: s.minPlayers ?? def.minPlayers,
|
||||
maxPlayers: s.maxPlayers ?? def.maxPlayers,
|
||||
defaultOptions: defaults,
|
||||
lockedOptions: s.lockedOptions ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
/** Catalog for /api/config: code metadata merged with admin overrides. */
|
||||
catalog(): (CatalogEntry & { notice: string })[] {
|
||||
return CATALOG.map((c) => {
|
||||
const g = this.game(c.id);
|
||||
if (!g) return { ...c, available: false, notice: '' };
|
||||
return { ...c, nameKo: g.nameKo, blurb: g.blurb, minPlayers: g.minPlayers, maxPlayers: g.maxPlayers, available: g.enabled, notice: g.notice };
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Forces locked option paths (dot-separated) to the admin default values. */
|
||||
export function applyLocks(options: unknown, defaults: unknown, locked: string[]): unknown {
|
||||
if (!locked.length || typeof options !== 'object' || options === null) return options;
|
||||
const out = structuredClone(options) as Record<string, unknown>;
|
||||
for (const path of locked) {
|
||||
const keys = path.split('.');
|
||||
let src: unknown = defaults;
|
||||
for (const k of keys) src = (src as Record<string, unknown> | undefined)?.[k];
|
||||
if (src === undefined) continue;
|
||||
let dst = out;
|
||||
for (const k of keys.slice(0, -1)) {
|
||||
if (typeof dst[k] !== 'object' || dst[k] === null) dst[k] = {};
|
||||
dst = dst[k] as Record<string, unknown>;
|
||||
}
|
||||
dst[keys[keys.length - 1]!] = structuredClone(src);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -32,7 +32,7 @@ export class Sessions {
|
||||
const row = this.db
|
||||
.query<{ user_id: string; expires_at: number; last_used_at: number; kind: 'guest' | 'member' }, [string]>(
|
||||
`SELECT s.user_id, s.expires_at, s.last_used_at, u.kind FROM sessions s JOIN users u ON u.id = s.user_id
|
||||
WHERE s.token_hash = ? AND u.deleted_at IS NULL`,
|
||||
WHERE s.token_hash = ? AND u.deleted_at IS NULL AND u.banned_at IS NULL`,
|
||||
)
|
||||
.get(hash);
|
||||
if (!row) return null;
|
||||
|
||||
@@ -14,6 +14,8 @@ export interface UserRow {
|
||||
created_at: number;
|
||||
last_seen_at: number;
|
||||
deleted_at: number | null;
|
||||
banned_at: number | null;
|
||||
banned_reason: string | null;
|
||||
}
|
||||
|
||||
export const NICK_CHANGES_PER_DAY = 10;
|
||||
@@ -111,6 +113,39 @@ export class Users {
|
||||
}
|
||||
}
|
||||
|
||||
/** Admin rename: ignores the daily limit. */
|
||||
forceNickname(userId: string, nickname: string): void {
|
||||
this.db.query('UPDATE users SET nickname = ? WHERE id = ?').run(nickname, userId);
|
||||
}
|
||||
|
||||
setBan(userId: string, reason: string | null, now = Date.now()): void {
|
||||
if (reason === null) this.db.query('UPDATE users SET banned_at = NULL, banned_reason = NULL WHERE id = ?').run(userId);
|
||||
else this.db.query('UPDATE users SET banned_at = ?, banned_reason = ? WHERE id = ?').run(now, reason, userId);
|
||||
}
|
||||
|
||||
search(q: string, limit: number, offset: number) {
|
||||
const like = `%${q.replace(/[%_]/g, (m) => '\\' + m)}%`;
|
||||
const rows = this.db
|
||||
.query<UserRow & { discord_id: string | null; discord_name: string | null }, [string, string, string, string, number, number]>(
|
||||
`SELECT u.*, o.provider_user_id AS discord_id, o.username AS discord_name FROM users u
|
||||
LEFT JOIN oauth_accounts o ON o.user_id = u.id AND o.provider = 'discord'
|
||||
WHERE u.deleted_at IS NULL AND (? = '' OR u.nickname LIKE ? ESCAPE '\\' OR u.id = ? OR o.provider_user_id = ?)
|
||||
ORDER BY u.last_seen_at DESC LIMIT ? OFFSET ?`,
|
||||
)
|
||||
.all(q, like, q, q, limit, offset);
|
||||
const total = this.db.query<{ n: number }, []>('SELECT COUNT(*) AS n FROM users WHERE deleted_at IS NULL').get()!.n;
|
||||
return { rows, total };
|
||||
}
|
||||
|
||||
counts(): { total: number; guests: number; members: number; banned: number } {
|
||||
return this.db
|
||||
.query<{ total: number; guests: number; members: number; banned: number }, []>(
|
||||
`SELECT COUNT(*) AS total, SUM(kind = 'guest') AS guests, SUM(kind = 'member') AS members, SUM(banned_at IS NOT NULL) AS banned
|
||||
FROM users WHERE deleted_at IS NULL`,
|
||||
)
|
||||
.get()!;
|
||||
}
|
||||
|
||||
touch(userId: string, now = Date.now()): void {
|
||||
this.db.query('UPDATE users SET last_seen_at = ? WHERE id = ?').run(now, userId);
|
||||
}
|
||||
|
||||
@@ -9,8 +9,12 @@ export interface Config {
|
||||
secureCookies: boolean;
|
||||
/** Trust X-Forwarded-For (only when running behind our reverse proxy). */
|
||||
trustProxy: boolean;
|
||||
superadminDiscordIds: string[];
|
||||
}
|
||||
|
||||
/** The site owner's Discord ID (docs/14-admin.md §2). */
|
||||
export const DEFAULT_SUPERADMIN = '293719842274541579';
|
||||
|
||||
export function loadConfig(env: Record<string, string | undefined> = process.env): Config {
|
||||
const publicOrigin = (env.PUBLIC_ORIGIN ?? 'http://localhost:5173').replace(/\/$/, '');
|
||||
const extra = (env.ALLOWED_ORIGINS ?? '').split(',').map((s) => s.trim()).filter(Boolean);
|
||||
@@ -29,5 +33,6 @@ export function loadConfig(env: Record<string, string | undefined> = process.env
|
||||
sessionSecret: sessionSecret || 'dev-only-insecure-session-secret-change-me',
|
||||
secureCookies: publicOrigin.startsWith('https://'),
|
||||
trustProxy: env.TRUST_PROXY === '1',
|
||||
superadminDiscordIds: (env.SUPERADMIN_DISCORD_IDS ?? DEFAULT_SUPERADMIN).split(',').map((s) => s.trim()).filter(Boolean),
|
||||
};
|
||||
}
|
||||
|
||||
34
apps/server/src/db/migrations/002_admin.sql
Normal file
34
apps/server/src/db/migrations/002_admin.sql
Normal file
@@ -0,0 +1,34 @@
|
||||
CREATE TABLE admins (
|
||||
discord_id TEXT PRIMARY KEY,
|
||||
note TEXT,
|
||||
added_by TEXT,
|
||||
added_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE game_settings (
|
||||
game_id TEXT PRIMARY KEY,
|
||||
json TEXT NOT NULL,
|
||||
updated_by TEXT,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE site_settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
json TEXT NOT NULL,
|
||||
updated_by TEXT,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE admin_audit (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
actor_id TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
target TEXT,
|
||||
before_json TEXT,
|
||||
after_json TEXT,
|
||||
at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX admin_audit_at ON admin_audit(at);
|
||||
|
||||
ALTER TABLE users ADD COLUMN banned_at INTEGER;
|
||||
ALTER TABLE users ADD COLUMN banned_reason TEXT;
|
||||
239
apps/server/src/http/admin.ts
Normal file
239
apps/server/src/http/admin.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
/** Admin API (docs/14-admin.md §8). Mounted under /api/admin by createHttpApp. */
|
||||
import { Hono, type Context } from 'hono';
|
||||
import { z } from 'zod';
|
||||
import type { AnyGameDefinition } from '@bg/engine';
|
||||
import { nicknameError, normalizeNickname } from '@bg/shared';
|
||||
import type { Audit, Role, Roles } from '../admin/roles';
|
||||
import { GameSettingsSchema, SiteSettingsSchema, type SettingsStore } from '../admin/settings';
|
||||
import type { Sessions } from '../auth/sessions';
|
||||
import { toPublicUser, type Users } from '../auth/users';
|
||||
import type { RoomManager } from '../rooms/manager';
|
||||
|
||||
export interface AdminDeps {
|
||||
users: Users;
|
||||
sessions: Sessions;
|
||||
rooms: RoomManager;
|
||||
roles: Roles;
|
||||
audit: Audit;
|
||||
settings: SettingsStore;
|
||||
games: Record<string, AnyGameDefinition>;
|
||||
disconnectUser: (userId: string) => void;
|
||||
stats: () => Record<string, unknown>;
|
||||
}
|
||||
|
||||
type Env = { Variables: { userId: string | null; role: Role } };
|
||||
|
||||
const DISCORD_ID = /^\d{17,20}$/;
|
||||
|
||||
export function createAdminApp(d: AdminDeps) {
|
||||
const app = new Hono<Env>();
|
||||
|
||||
app.use('*', async (c, next) => {
|
||||
const role = d.roles.roleOf(c.get('userId'));
|
||||
if (role === 'user') return c.json({ error: '권한이 없어요.' }, c.get('userId') ? 403 : 401);
|
||||
c.set('role', role);
|
||||
await next();
|
||||
});
|
||||
|
||||
const superOnly = async (c: Context<Env>, next: () => Promise<void>) => {
|
||||
if (c.get('role') !== 'superadmin') return c.json({ error: '슈퍼어드민만 할 수 있어요.' }, 403);
|
||||
await next();
|
||||
};
|
||||
const actor = (c: Context<Env>) => c.get('userId')!;
|
||||
const body = async (c: Context<Env>) => c.req.json().catch(() => null);
|
||||
|
||||
app.get('/me', (c) => c.json({ role: c.get('role'), me: toPublicUser(d.users.get(actor(c))!) }));
|
||||
|
||||
app.get('/overview', (c) => c.json({ users: d.users.counts(), ...d.stats() }));
|
||||
|
||||
// ------------------------------------------------------------ games
|
||||
app.get('/games', (c) =>
|
||||
c.json({
|
||||
games: Object.values(d.games).map((def) => ({
|
||||
id: def.id,
|
||||
codeDefaults: { nameKo: def.nameKo, minPlayers: def.minPlayers, maxPlayers: def.maxPlayers, defaultOptions: def.defaultOptions },
|
||||
raw: d.settings.rawGame(def.id),
|
||||
effective: d.settings.game(def.id),
|
||||
schema: z.toJSONSchema(def.optionsSchema, { io: 'input', unrepresentable: 'any' }),
|
||||
})),
|
||||
}),
|
||||
);
|
||||
|
||||
app.put('/games/:id', async (c) => {
|
||||
const id = c.req.param('id');
|
||||
if (!d.games[id]) return c.json({ error: '없는 게임이에요.' }, 404);
|
||||
const parsed = GameSettingsSchema.safeParse(await body(c));
|
||||
if (!parsed.success) return c.json({ error: '입력 값이 올바르지 않아요.' }, 400);
|
||||
const err = d.settings.validateGame(id, parsed.data);
|
||||
if (err) return c.json({ error: err }, 400);
|
||||
const before = d.settings.rawGame(id);
|
||||
d.settings.setGame(id, parsed.data, actor(c));
|
||||
d.audit.log(actor(c), 'game.update', id, before, d.settings.rawGame(id));
|
||||
return c.json({ effective: d.settings.game(id), raw: d.settings.rawGame(id) });
|
||||
});
|
||||
|
||||
app.post('/games/:id/reset', (c) => {
|
||||
const id = c.req.param('id');
|
||||
if (!d.games[id]) return c.json({ error: '없는 게임이에요.' }, 404);
|
||||
const before = d.settings.rawGame(id);
|
||||
d.settings.resetGame(id);
|
||||
d.audit.log(actor(c), 'game.reset', id, before, null);
|
||||
return c.json({ effective: d.settings.game(id), raw: {} });
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------ site
|
||||
app.get('/site', (c) => c.json({ site: d.settings.getSite() }));
|
||||
app.put('/site', async (c) => {
|
||||
const parsed = SiteSettingsSchema.safeParse(await body(c));
|
||||
if (!parsed.success) return c.json({ error: `입력 값이 올바르지 않아요: ${parsed.error.issues.map((i) => i.path.join('.')).join(', ')}` }, 400);
|
||||
const before = d.settings.getSite();
|
||||
d.settings.setSite(parsed.data, actor(c));
|
||||
d.audit.log(actor(c), 'site.update', null, before, parsed.data);
|
||||
return c.json({ site: parsed.data });
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------ rooms
|
||||
app.get('/rooms', (c) =>
|
||||
c.json({
|
||||
rooms: d.rooms.all().map((r) => ({
|
||||
code: r.code,
|
||||
gameId: r.config.gameId,
|
||||
status: r.status,
|
||||
host: d.users.get(r.hostId)?.nickname ?? null,
|
||||
seats: r.seats.map((s) => (s ? { id: s.userId, nickname: d.users.get(s.userId)?.nickname ?? '?', connected: !!r.presence.get(s.userId)?.connected } : null)),
|
||||
spectators: r.spectators.size,
|
||||
connections: r.conns.size,
|
||||
visibility: r.config.visibility,
|
||||
gameNo: r.gameNo,
|
||||
lastActiveAt: r.lastActiveAt,
|
||||
})),
|
||||
}),
|
||||
);
|
||||
app.post('/rooms/:code/close', (c) => {
|
||||
const code = c.req.param('code');
|
||||
if (!d.rooms.closeByAdmin(code)) return c.json({ error: '방을 찾을 수 없어요.' }, 404);
|
||||
d.audit.log(actor(c), 'room.close', code, null, null);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------ audit
|
||||
app.get('/audit', (c) => {
|
||||
const page = Math.max(0, Number(c.req.query('page') ?? 0) || 0);
|
||||
return c.json({ entries: d.audit.list(50, page * 50) });
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------ users (superadmin)
|
||||
app.use('/users/*', superOnly);
|
||||
app.use('/users', superOnly);
|
||||
app.get('/users', (c) => {
|
||||
const q = (c.req.query('q') ?? '').trim().slice(0, 60);
|
||||
const page = Math.max(0, Number(c.req.query('page') ?? 0) || 0);
|
||||
const { rows, total } = d.users.search(q, 30, page * 30);
|
||||
return c.json({
|
||||
total,
|
||||
users: rows.map((u) => ({
|
||||
id: u.id,
|
||||
nickname: u.nickname,
|
||||
kind: u.kind,
|
||||
discordId: u.discord_id,
|
||||
discordName: u.discord_name,
|
||||
role: d.roles.roleOfDiscord(u.discord_id),
|
||||
banned: u.banned_at ? { at: u.banned_at, reason: u.banned_reason } : null,
|
||||
createdAt: u.created_at,
|
||||
lastSeenAt: u.last_seen_at,
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/users/:id', (c) => {
|
||||
const u = d.users.get(c.req.param('id'));
|
||||
if (!u) return c.json({ error: '사용자를 찾을 수 없어요.' }, 404);
|
||||
const discordId = d.roles.discordIdOf(u.id);
|
||||
return c.json({
|
||||
user: {
|
||||
...toPublicUser(u),
|
||||
discordId,
|
||||
role: d.roles.roleOfDiscord(discordId),
|
||||
banned: u.banned_at ? { at: u.banned_at, reason: u.banned_reason } : null,
|
||||
createdAt: u.created_at,
|
||||
lastSeenAt: u.last_seen_at,
|
||||
activeRoom: d.rooms.activeRoomFor(u.id),
|
||||
},
|
||||
stats: d.users.stats(u.id),
|
||||
});
|
||||
});
|
||||
|
||||
app.patch('/users/:id', async (c) => {
|
||||
const id = c.req.param('id');
|
||||
const u = d.users.get(id);
|
||||
if (!u) return c.json({ error: '사용자를 찾을 수 없어요.' }, 404);
|
||||
const parsed = z
|
||||
.object({ nickname: z.string().max(100).optional(), ban: z.object({ reason: z.string().trim().min(1).max(200) }).nullable().optional() })
|
||||
.safeParse(await body(c));
|
||||
if (!parsed.success) return c.json({ error: '입력 값이 올바르지 않아요.' }, 400);
|
||||
const discordId = d.roles.discordIdOf(id);
|
||||
if (parsed.data.ban && discordId && d.roles.isSuperadminDiscord(discordId)) return c.json({ error: '슈퍼어드민은 제한할 수 없어요.' }, 400);
|
||||
if (parsed.data.nickname !== undefined) {
|
||||
const nick = normalizeNickname(parsed.data.nickname);
|
||||
const e = nicknameError(nick, d.settings.getSite().bannedWords);
|
||||
if (e) return c.json({ error: e }, 400);
|
||||
d.users.forceNickname(id, nick);
|
||||
d.audit.log(actor(c), 'user.rename', id, u.nickname, nick);
|
||||
}
|
||||
if (parsed.data.ban !== undefined) {
|
||||
if (parsed.data.ban) {
|
||||
d.users.setBan(id, parsed.data.ban.reason);
|
||||
d.sessions.revokeAll(id);
|
||||
d.disconnectUser(id);
|
||||
d.audit.log(actor(c), 'user.ban', id, null, parsed.data.ban);
|
||||
} else {
|
||||
d.users.setBan(id, null);
|
||||
d.audit.log(actor(c), 'user.unban', id, { reason: u.banned_reason }, null);
|
||||
}
|
||||
}
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
app.post('/users/:id/logout', (c) => {
|
||||
const id = c.req.param('id');
|
||||
if (!d.users.get(id)) return c.json({ error: '사용자를 찾을 수 없어요.' }, 404);
|
||||
d.sessions.revokeAll(id);
|
||||
d.disconnectUser(id);
|
||||
d.audit.log(actor(c), 'user.logout', id, null, null);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
app.delete('/users/:id', (c) => {
|
||||
const id = c.req.param('id');
|
||||
const u = d.users.get(id);
|
||||
if (!u) return c.json({ error: '사용자를 찾을 수 없어요.' }, 404);
|
||||
const discordId = d.roles.discordIdOf(id);
|
||||
if (discordId && d.roles.isSuperadminDiscord(discordId)) return c.json({ error: '슈퍼어드민은 삭제할 수 없어요.' }, 400);
|
||||
d.disconnectUser(id);
|
||||
d.users.softDelete(id);
|
||||
d.audit.log(actor(c), 'user.delete', id, { nickname: u.nickname, discordId }, null);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------ admins (superadmin)
|
||||
app.use('/admins/*', superOnly);
|
||||
app.use('/admins', superOnly);
|
||||
app.get('/admins', (c) => c.json({ admins: d.roles.listAdmins() }));
|
||||
app.post('/admins', async (c) => {
|
||||
const parsed = z.object({ discordId: z.string().trim(), note: z.string().trim().max(100).optional() }).safeParse(await body(c));
|
||||
if (!parsed.success || !DISCORD_ID.test(parsed.data.discordId)) return c.json({ error: '디스코드 ID는 숫자 17~20자리예요.' }, 400);
|
||||
if (d.roles.isSuperadminDiscord(parsed.data.discordId)) return c.json({ error: '이미 슈퍼어드민이에요.' }, 400);
|
||||
if (!d.roles.addAdmin(parsed.data.discordId, parsed.data.note ?? null, actor(c))) return c.json({ error: '이미 등록된 어드민이에요.' }, 400);
|
||||
d.audit.log(actor(c), 'admin.add', parsed.data.discordId, null, { note: parsed.data.note ?? null });
|
||||
return c.json({ admins: d.roles.listAdmins() });
|
||||
});
|
||||
app.delete('/admins/:discordId', (c) => {
|
||||
const id = c.req.param('discordId');
|
||||
if (d.roles.isSuperadminDiscord(id)) return c.json({ error: '설정 파일의 슈퍼어드민은 지울 수 없어요.' }, 400);
|
||||
if (!d.roles.removeAdmin(id)) return c.json({ error: '등록되지 않은 디스코드 ID예요.' }, 404);
|
||||
d.audit.log(actor(c), 'admin.remove', id, null, null);
|
||||
return c.json({ admins: d.roles.listAdmins() });
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -2,13 +2,16 @@
|
||||
import { Hono, type Context } from 'hono';
|
||||
import { deleteCookie, getCookie, setCookie } from 'hono/cookie';
|
||||
import { z } from 'zod';
|
||||
import { CATALOG, catalogById, nicknameError, normalizeNickname, normalizeRoomCode, formatRoomCode } from '@bg/shared';
|
||||
import { nicknameError, normalizeNickname, normalizeRoomCode, formatRoomCode } from '@bg/shared';
|
||||
import type { Config } from '../config';
|
||||
import { authorizeUrl, avatarUrl, exchangeCode, safeNext, type DiscordUser } from '../auth/discord';
|
||||
import { SESSION_COOKIE, SESSION_TTL, randomToken, signValue, unsignValue, type Sessions } from '../auth/sessions';
|
||||
import { toPublicUser, type Users } from '../auth/users';
|
||||
import type { RoomManager } from '../rooms/manager';
|
||||
import { WindowLimiter } from '../ws/rate-limit';
|
||||
import { createAdminApp, type AdminDeps } from './admin';
|
||||
import type { Roles } from '../admin/roles';
|
||||
import type { SettingsStore } from '../admin/settings';
|
||||
|
||||
export interface HttpDeps {
|
||||
config: Config;
|
||||
@@ -19,6 +22,9 @@ export interface HttpDeps {
|
||||
discordExchange?: (code: string, redirectUri: string) => Promise<DiscordUser>;
|
||||
ipOf: (req: Request) => string;
|
||||
health: () => { ok: boolean; [k: string]: unknown };
|
||||
roles: Roles;
|
||||
settings: SettingsStore;
|
||||
admin: Omit<AdminDeps, 'users' | 'sessions' | 'rooms' | 'roles' | 'settings'>;
|
||||
}
|
||||
|
||||
type Env = { Variables: { userId: string | null } };
|
||||
@@ -68,14 +74,23 @@ export function createHttpApp(d: HttpDeps) {
|
||||
return c.json(h, h.ok ? 200 : 503);
|
||||
});
|
||||
|
||||
app.get('/api/config', (c) => c.json({ discord: d.config.discord !== null, catalog: CATALOG }));
|
||||
app.get('/api/config', (c) => {
|
||||
const site = d.settings.getSite();
|
||||
return c.json({
|
||||
discord: d.config.discord !== null,
|
||||
catalog: d.settings.catalog(),
|
||||
siteName: site.siteName,
|
||||
announcement: site.announcement,
|
||||
maintenance: site.maintenance,
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/api/auth/guest', async (c) => {
|
||||
if (!guestLimiter.allow(d.ipOf(c.req.raw))) return c.json({ error: '잠시 후 다시 시도해 주세요.' }, 429);
|
||||
const body = z.object({ nickname: z.string().max(100) }).safeParse(await c.req.json().catch(() => null));
|
||||
if (!body.success) return c.json({ error: '닉네임을 입력해 주세요.' }, 400);
|
||||
const nick = normalizeNickname(body.data.nickname);
|
||||
const e = nicknameError(nick);
|
||||
const e = nicknameError(nick, d.settings.getSite().bannedWords);
|
||||
if (e) return c.json({ error: e }, 400);
|
||||
const current = requireUser(c);
|
||||
if (current) {
|
||||
@@ -119,6 +134,7 @@ export function createHttpApp(d: HttpDeps) {
|
||||
}
|
||||
const avatar = avatarUrl(du);
|
||||
const existing = d.users.findByOauth('discord', du.id);
|
||||
if (existing?.banned_at) return c.redirect('/?login=banned');
|
||||
const current = requireUser(c) ? d.users.get(requireUser(c)!) : null;
|
||||
let userId: string;
|
||||
let merged = false;
|
||||
@@ -170,6 +186,7 @@ export function createHttpApp(d: HttpDeps) {
|
||||
me: toPublicUser(row),
|
||||
settings: JSON.parse(row.settings_json),
|
||||
useAvatar: !!row.use_avatar,
|
||||
role: d.roles.roleOf(row.id),
|
||||
hasDiscordAvatar: !!row.avatar_url,
|
||||
activeRoom: d.rooms.activeRoomFor(row.id),
|
||||
});
|
||||
@@ -197,7 +214,7 @@ export function createHttpApp(d: HttpDeps) {
|
||||
if (!body.success) return c.json({ error: '잘못된 요청이에요.' }, 400);
|
||||
if (body.data.nickname !== undefined) {
|
||||
const nick = normalizeNickname(body.data.nickname);
|
||||
const e = nicknameError(nick) ?? d.users.changeNickname(u, nick);
|
||||
const e = nicknameError(nick, d.settings.getSite().bannedWords) ?? d.users.changeNickname(u, nick);
|
||||
if (e) return c.json({ error: e }, 400);
|
||||
}
|
||||
d.users.updateSettings(u, { useAvatar: body.data.useAvatar, settings: body.data.settings });
|
||||
@@ -227,7 +244,7 @@ export function createHttpApp(d: HttpDeps) {
|
||||
const body = z
|
||||
.object({ gameId: z.string().max(40), visibility: z.enum(['private', 'public']).optional() })
|
||||
.safeParse(await c.req.json().catch(() => null));
|
||||
if (!body.success || !catalogById(body.data.gameId)?.available) return c.json({ error: '없는 게임이에요.' }, 400);
|
||||
if (!body.success || !d.settings.game(body.data.gameId)?.enabled) return c.json({ error: '없는 게임이에요.' }, 400);
|
||||
const r = d.rooms.create(u, body.data.gameId, body.data.visibility);
|
||||
if (typeof r === 'string') return c.json({ error: r }, 429);
|
||||
return c.json({ code: r.code });
|
||||
@@ -237,7 +254,7 @@ export function createHttpApp(d: HttpDeps) {
|
||||
const u = requireUser(c);
|
||||
if (!u) return c.json({ error: '로그인이 필요해요.' }, 401);
|
||||
const gameId = c.req.param('gameId');
|
||||
if (!catalogById(gameId)?.available) return c.json({ error: '없는 게임이에요.' }, 400);
|
||||
if (!d.settings.game(gameId)?.enabled) return c.json({ error: '없는 게임이에요.' }, 400);
|
||||
if (!createLimiter.allow(d.ipOf(c.req.raw))) return c.json({ error: '잠시 후 다시 시도해 주세요.' }, 429);
|
||||
const r = d.rooms.quickPlay(u, gameId);
|
||||
if (typeof r === 'string') return c.json({ error: r }, 429);
|
||||
@@ -264,5 +281,10 @@ export function createHttpApp(d: HttpDeps) {
|
||||
|
||||
app.get('/api/games/:gameId/rooms', (c) => c.json({ rooms: d.rooms.publicRooms(c.req.param('gameId')) }));
|
||||
|
||||
app.route(
|
||||
'/api/admin',
|
||||
createAdminApp({ ...d.admin, users: d.users, sessions: d.sessions, rooms: d.rooms, roles: d.roles, settings: d.settings }),
|
||||
);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { AnyGameDefinition } from '@bg/engine';
|
||||
import { isAllowedRoomCode, type PublicUser } from '@bg/shared';
|
||||
import { Room, type RoomConfig, type RoomDeps, type RoomSnapshot } from './room';
|
||||
import type { RoomStore } from './store';
|
||||
import type { SettingsStore } from '../admin/settings';
|
||||
|
||||
export const MAX_ROOMS = 2000;
|
||||
export const MAX_ROOMS_PER_USER = 3;
|
||||
@@ -17,6 +18,7 @@ export interface ManagerDeps {
|
||||
now?: () => number;
|
||||
log?: RoomDeps['log'];
|
||||
random?: () => number;
|
||||
settings?: SettingsStore;
|
||||
}
|
||||
|
||||
export class RoomManager {
|
||||
@@ -37,6 +39,7 @@ export class RoomManager {
|
||||
users: this.deps.users,
|
||||
now: this.now,
|
||||
log: this.deps.log,
|
||||
settings: this.deps.settings,
|
||||
onSeatChange: (room, userId, seated) => {
|
||||
const set = this.seatIndex.get(userId) ?? new Set<string>();
|
||||
if (seated) set.add(room.code);
|
||||
@@ -69,19 +72,26 @@ export class RoomManager {
|
||||
/** Returns the room or a Korean error message. */
|
||||
create(hostId: string, gameId: string, visibility: 'private' | 'public' = 'private'): Room | string {
|
||||
const def = this.deps.games[gameId];
|
||||
if (!def) return '없는 게임이에요.';
|
||||
if (this.byCode.size >= MAX_ROOMS) return '지금은 방이 너무 많아요. 잠시 후 다시 시도해 주세요.';
|
||||
const eff = this.deps.settings?.game(gameId);
|
||||
const site = this.deps.settings?.getSite();
|
||||
if (!def || (eff && !eff.enabled)) return '없는 게임이에요.';
|
||||
if (site?.maintenance) return site.announcement || '지금은 점검 중이라 새 방을 만들 수 없어요.';
|
||||
const maxRooms = site?.maxRooms ?? MAX_ROOMS;
|
||||
const perUser = site?.maxRoomsPerUser ?? MAX_ROOMS_PER_USER;
|
||||
if (this.byCode.size >= maxRooms) return '지금은 방이 너무 많아요. 잠시 후 다시 시도해 주세요.';
|
||||
const mine = [...(this.createdBy.get(hostId) ?? [])].filter((c) => this.byCode.has(c));
|
||||
if (mine.length >= MAX_ROOMS_PER_USER) return `방은 한 번에 ${MAX_ROOMS_PER_USER}개까지 만들 수 있어요.`;
|
||||
if (mine.length >= perUser) return `방은 한 번에 ${perUser}개까지 만들 수 있어요.`;
|
||||
const options = eff?.defaultOptions ?? def.defaultOptions;
|
||||
const base = def.playersFor ? def.playersFor(options).max : def.maxPlayers;
|
||||
const config: RoomConfig = {
|
||||
gameId,
|
||||
options: def.defaultOptions,
|
||||
maxPlayers: def.playersFor ? def.playersFor(def.defaultOptions).max : def.maxPlayers,
|
||||
options,
|
||||
maxPlayers: Math.min(base, eff?.maxPlayers ?? base),
|
||||
visibility,
|
||||
allowSpectators: true,
|
||||
chatEnabled: true,
|
||||
chatFilter: true,
|
||||
graceSec: 60,
|
||||
chatEnabled: site?.chatEnabledDefault ?? true,
|
||||
chatFilter: site?.chatFilterDefault ?? true,
|
||||
graceSec: site?.graceSec ?? 60,
|
||||
};
|
||||
const code = this.newCode();
|
||||
const id = ulid(this.now());
|
||||
@@ -174,6 +184,14 @@ export class RoomManager {
|
||||
}
|
||||
}
|
||||
|
||||
closeByAdmin(code: string): boolean {
|
||||
const room = this.byCode.get(code);
|
||||
if (!room) return false;
|
||||
room.closeByAdmin();
|
||||
this.close(room);
|
||||
return true;
|
||||
}
|
||||
|
||||
close(room: Room): void {
|
||||
room.shutdown();
|
||||
this.deps.store.close(room.id, this.now());
|
||||
|
||||
@@ -7,6 +7,7 @@ import { SeededRng, createSeed, seedToHex, type AnyGameDefinition, type GameResu
|
||||
import { maskProfanity, type PublicUser, type RoomView, type ServerMessage, type SeatView } from '@bg/shared';
|
||||
import { sha256Hex } from '../auth/sessions';
|
||||
import type { RoomStore } from './store';
|
||||
import { applyLocks, type EffectiveGame, type SiteSettings } from '../admin/settings';
|
||||
|
||||
export interface Conn {
|
||||
userId: string;
|
||||
@@ -76,6 +77,8 @@ export interface RoomDeps {
|
||||
log?: (level: 'info' | 'warn' | 'error', msg: string, extra?: Record<string, unknown>) => void;
|
||||
onEmpty?: (room: Room) => void;
|
||||
onSeatChange?: (room: Room, userId: string, seated: boolean) => void;
|
||||
/** Admin settings (docs/14-admin.md). Optional so unit tests can run without them. */
|
||||
settings?: { game(id: string): EffectiveGame | null; getSite(): SiteSettings };
|
||||
}
|
||||
|
||||
const CHAT_HISTORY = 30;
|
||||
@@ -126,8 +129,23 @@ export class Room {
|
||||
}
|
||||
|
||||
playerRange(): { min: number; max: number } {
|
||||
const d = this.def;
|
||||
return d.playersFor ? d.playersFor(this.config.options) : { min: d.minPlayers, max: d.maxPlayers };
|
||||
return this.rangeFor(this.config.gameId, this.config.options);
|
||||
}
|
||||
|
||||
/** Game's own range (per options) intersected with the admin-configured range. */
|
||||
private rangeFor(gameId: string, options: unknown): { min: number; max: number } {
|
||||
const d = this.deps.games[gameId]!;
|
||||
const base = d.playersFor ? d.playersFor(options) : { min: d.minPlayers, max: d.maxPlayers };
|
||||
const eff = this.deps.settings?.game(gameId);
|
||||
if (!eff) return base;
|
||||
const min = Math.max(base.min, eff.minPlayers);
|
||||
const max = Math.min(base.max, eff.maxPlayers);
|
||||
return min <= max ? { min, max } : base;
|
||||
}
|
||||
|
||||
private locked(gameId: string): { defaults: unknown; paths: string[] } {
|
||||
const eff = this.deps.settings?.game(gameId);
|
||||
return { defaults: eff?.defaultOptions, paths: eff?.lockedOptions ?? [] };
|
||||
}
|
||||
|
||||
seatOf(userId: string): number {
|
||||
@@ -239,6 +257,7 @@ export class Room {
|
||||
rematchVotes: [...this.rematchVotes],
|
||||
sessionStats: this.sessionStats,
|
||||
seedHash: this.game?.seedHash ?? null,
|
||||
lockedOptions: this.locked(this.config.gameId).paths,
|
||||
lastSeed: this.lastSeed,
|
||||
};
|
||||
}
|
||||
@@ -446,10 +465,11 @@ export class Room {
|
||||
const next: RoomConfig = { ...this.config };
|
||||
if (patch.gameId !== undefined && patch.gameId !== this.config.gameId) {
|
||||
const def = this.deps.games[patch.gameId];
|
||||
if (!def) return '없는 게임이에요.';
|
||||
const eff = this.deps.settings?.game(patch.gameId);
|
||||
if (!def || (eff && !eff.enabled)) return '없는 게임이에요.';
|
||||
next.gameId = patch.gameId;
|
||||
next.options = def.defaultOptions;
|
||||
next.maxPlayers = def.maxPlayers;
|
||||
next.options = eff?.defaultOptions ?? def.defaultOptions;
|
||||
next.maxPlayers = eff?.maxPlayers ?? def.maxPlayers;
|
||||
}
|
||||
const def = this.deps.games[next.gameId]!;
|
||||
if (patch.options !== undefined) {
|
||||
@@ -457,7 +477,10 @@ export class Room {
|
||||
if (!parsed.success) return '설정 값이 올바르지 않아요.';
|
||||
next.options = parsed.data;
|
||||
}
|
||||
const range = def.playersFor ? def.playersFor(next.options) : { min: def.minPlayers, max: def.maxPlayers };
|
||||
// Admin-locked options always keep the admin value (docs/14-admin.md §4).
|
||||
const lk = this.locked(next.gameId);
|
||||
if (lk.paths.length) next.options = def.optionsSchema.parse(applyLocks(next.options, lk.defaults, lk.paths));
|
||||
const range = this.rangeFor(next.gameId, next.options);
|
||||
if (patch.maxPlayers !== undefined) next.maxPlayers = patch.maxPlayers;
|
||||
next.maxPlayers = Math.min(Math.max(next.maxPlayers, range.min), range.max);
|
||||
if (next.maxPlayers < this.seatedIds().length) return `이미 ${this.seatedIds().length}명이 앉아 있어요.`;
|
||||
@@ -517,7 +540,7 @@ export class Room {
|
||||
const msg: Extract<ServerMessage, { t: 'chat' }> = {
|
||||
t: 'chat',
|
||||
from: this.deps.users.publicUser(userId),
|
||||
text: this.config.chatFilter ? maskProfanity(clean) : clean,
|
||||
text: this.config.chatFilter ? maskProfanity(clean, this.deps.settings?.getSite().bannedWords ?? []) : clean,
|
||||
at: now,
|
||||
};
|
||||
this.chatLog.push(msg);
|
||||
@@ -570,8 +593,12 @@ export class Room {
|
||||
|
||||
private startGame(players: string[]): string | null {
|
||||
const def = this.def;
|
||||
const parsed = def.optionsSchema.safeParse(this.config.options);
|
||||
const lk = this.locked(def.id);
|
||||
const parsed = def.optionsSchema.safeParse(lk.paths.length ? applyLocks(this.config.options, lk.defaults, lk.paths) : this.config.options);
|
||||
if (!parsed.success) return '설정 값이 올바르지 않아요.';
|
||||
const eff = this.deps.settings?.game(def.id);
|
||||
if (eff && !eff.enabled) return '관리자가 이 게임을 잠시 막아 두었어요.';
|
||||
this.config = { ...this.config, options: parsed.data };
|
||||
const seed = createSeed();
|
||||
const seedHex = seedToHex(seed);
|
||||
const rng = SeededRng.fromSeed(seed);
|
||||
@@ -800,6 +827,20 @@ export class Room {
|
||||
if (st) conn.send(st);
|
||||
}
|
||||
|
||||
/** Admin closes the room: void any running game, tell everyone, disconnect. */
|
||||
closeByAdmin(): void {
|
||||
if (this.game && !this.game.finished) {
|
||||
this.finishGame({ ranking: [this.game.players], summary: '관리자가 방을 닫아 무효 처리됐어요.', reason: 'abandoned' }, true);
|
||||
}
|
||||
this.notice('closed', '관리자가 방을 닫았어요.');
|
||||
this.clearTimer();
|
||||
for (const c of this.conns.values()) {
|
||||
c.send({ t: 'bye', reason: 'closed' });
|
||||
c.close(4002, 'closed');
|
||||
}
|
||||
this.conns.clear();
|
||||
}
|
||||
|
||||
shutdown(): void {
|
||||
this.clearTimer();
|
||||
for (const c of this.conns.values()) {
|
||||
|
||||
@@ -11,6 +11,8 @@ import { createHttpApp, type HttpDeps } from './http/app';
|
||||
import { RoomManager } from './rooms/manager';
|
||||
import { RoomStore } from './rooms/store';
|
||||
import { Gateway, type WsData } from './ws/gateway';
|
||||
import { Audit, Roles } from './admin/roles';
|
||||
import { SettingsStore } from './admin/settings';
|
||||
|
||||
export type LogFn = (level: 'info' | 'warn' | 'error', msg: string, extra?: Record<string, unknown>) => void;
|
||||
|
||||
@@ -38,7 +40,11 @@ export function startServer(opts: StartOptions) {
|
||||
const u = users.get(id);
|
||||
return u ? toPublicUser(u) : null;
|
||||
};
|
||||
const rooms = new RoomManager({ store, games: GAMES, users: { publicUser }, log });
|
||||
const settings = new SettingsStore(db, GAMES);
|
||||
const roles = new Roles(db, config.superadminDiscordIds);
|
||||
const audit = new Audit(db);
|
||||
const startedAt = Date.now();
|
||||
const rooms = new RoomManager({ store, games: GAMES, users: { publicUser }, log, settings });
|
||||
const restored = rooms.restoreAll();
|
||||
log('info', 'rooms restored', restored);
|
||||
const gateway = new Gateway({ rooms, me: publicUser, log });
|
||||
@@ -57,6 +63,24 @@ export function startServer(opts: StartOptions) {
|
||||
ipOf,
|
||||
discordExchange: opts.discordExchange,
|
||||
health: () => ({ ok: ready, rooms: rooms.all().length, connections: gateway.connectionCount }),
|
||||
roles,
|
||||
settings,
|
||||
admin: {
|
||||
audit,
|
||||
games: GAMES,
|
||||
disconnectUser: (id) => gateway.disconnectUser(id),
|
||||
stats: () => {
|
||||
const all = rooms.all();
|
||||
const day = Date.now() - 24 * 3600_000;
|
||||
return {
|
||||
connections: gateway.connectionCount,
|
||||
rooms: { total: all.length, lobby: all.filter((r) => r.status === 'lobby').length, playing: all.filter((r) => r.status === 'playing').length },
|
||||
games: db.query<{ total: number; today: number }, [number]>('SELECT COUNT(*) AS total, SUM(started_at > ?) AS today FROM games').get(day),
|
||||
uptimeSec: Math.round((Date.now() - startedAt) / 1000),
|
||||
memoryMb: Math.round(process.memoryUsage().rss / 1048576),
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const staticDir = opts.staticDir === undefined ? join(import.meta.dir, '../../web/dist') : opts.staticDir;
|
||||
@@ -114,7 +138,7 @@ export function startServer(opts: StartOptions) {
|
||||
db.close();
|
||||
};
|
||||
|
||||
return { server, stop, rooms, users, sessions, db, gateway };
|
||||
return { server, stop, rooms, users, sessions, db, gateway, settings, roles };
|
||||
}
|
||||
|
||||
async function serveFile(root: string, pathname: string): Promise<Response> {
|
||||
|
||||
@@ -195,6 +195,14 @@ export class Gateway {
|
||||
if (e) err(e);
|
||||
}
|
||||
|
||||
/** Drop every connection of a user (ban / forced logout). */
|
||||
disconnectUser(userId: string): void {
|
||||
for (const ws of [...(this.byUser.get(userId) ?? [])]) {
|
||||
this.send(ws, { t: 'bye', reason: 'kicked' });
|
||||
ws.close(4403, 'banned');
|
||||
}
|
||||
}
|
||||
|
||||
/** Graceful shutdown: tell everyone to reconnect shortly (docs/03 §10). */
|
||||
shutdown(): void {
|
||||
this.closing = true;
|
||||
|
||||
18
apps/web/src/components/GameNotice.tsx
Normal file
18
apps/web/src/components/GameNotice.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { catalogById } from '@bg/shared/lite';
|
||||
import { useSession } from '../store/session';
|
||||
|
||||
/** Display name with admin overrides applied. */
|
||||
export function gameName(id: string): string {
|
||||
return useSession.getState().catalog.find((c) => c.id === id)?.nameKo ?? catalogById(id)?.nameKo ?? '게임';
|
||||
}
|
||||
|
||||
/** Admin notice for a game (docs/14-admin.md §4). */
|
||||
export function GameNotice({ gameId }: { gameId: string }) {
|
||||
const notice = useSession((s) => s.catalog.find((c) => c.id === gameId)?.notice);
|
||||
if (!notice) return null;
|
||||
return (
|
||||
<p className="small" style={{ background: 'var(--primary-soft)', borderRadius: 10, padding: '8px 12px', whiteSpace: 'pre-wrap' }}>
|
||||
📢 {notice}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -335,8 +335,14 @@ function Options({ value: o, onChange }: OptionsProps<OmokOptions>) {
|
||||
<strong>시간</strong>
|
||||
<Seg
|
||||
value={timeKey(o.timeControl)}
|
||||
options={TIME_PRESETS.map(([k, l]) => [k, l] as [string, string])}
|
||||
onChange={(k) => set({ timeControl: { ...o.timeControl, ...TIME_PRESETS.find((p) => p[0] === k)![2] } })}
|
||||
options={[
|
||||
...TIME_PRESETS.map(([k, l]) => [k, l] as [string, string]),
|
||||
...(TIME_PRESETS.some((p) => p[0] === timeKey(o.timeControl)) ? [] : [[timeKey(o.timeControl), `${timeLabel(o.timeControl)}(관리자 설정)`] as [string, string]]),
|
||||
]}
|
||||
onChange={(k) => {
|
||||
const preset = TIME_PRESETS.find((p) => p[0] === k);
|
||||
if (preset) set({ timeControl: { ...o.timeControl, ...preset[2] } });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
@@ -388,8 +394,16 @@ function Options({ value: o, onChange }: OptionsProps<OmokOptions>) {
|
||||
);
|
||||
}
|
||||
|
||||
function timeLabel(tc: OmokOptions['timeControl']): string {
|
||||
if (tc.kind === 'none') return '시간 제한 없음';
|
||||
if (tc.kind === 'perMove') return `한 수 ${tc.perMoveSec}초`;
|
||||
const m = Math.floor(tc.baseSec / 60);
|
||||
const sec = tc.baseSec % 60;
|
||||
return `${m}분${sec ? ` ${sec}초` : ''} + ${tc.incSec}초`;
|
||||
}
|
||||
|
||||
function optionsSummary(o: OmokOptions): string {
|
||||
const t = TIME_PRESETS.find((p) => p[0] === timeKey(o.timeControl))?.[1] ?? '';
|
||||
const t = timeLabel(o.timeControl);
|
||||
return [RULE_KO[o.ruleSet], `${o.boardSize}줄`, t].join(' · ');
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,19 @@ const GamePage = lazy(() => import('./pages/GamePage'));
|
||||
const MePage = lazy(() => import('./pages/MePage'));
|
||||
const RulesPage = lazy(() => import('./pages/RulesPage'));
|
||||
const NotFound = lazy(() => import('./pages/NotFound'));
|
||||
const AdminApp = lazy(() => import('./pages/admin/AdminApp'));
|
||||
|
||||
function Announcement() {
|
||||
const text = useSession((s) => s.announcement);
|
||||
const maintenance = useSession((s) => s.maintenance);
|
||||
if (!text && !maintenance) return null;
|
||||
return (
|
||||
<div role="note" style={{ background: maintenance ? 'var(--warning-soft)' : 'var(--primary-soft)', padding: '8px 16px', textAlign: 'center', fontWeight: 600 }}>
|
||||
{maintenance && '🛠 점검 중 · '}
|
||||
{text || '새 방을 잠시 만들 수 없어요.'}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Shell() {
|
||||
const loaded = useSession((s) => s.loaded);
|
||||
@@ -29,6 +42,7 @@ function Shell() {
|
||||
const q = new URLSearchParams(loc.search);
|
||||
if (q.get('linked') === '1') toast('디스코드 계정과 연결했어요.');
|
||||
if (q.get('login') === 'failed') toast('디스코드 로그인에 실패했어요. 다시 시도해 주세요.', 'error');
|
||||
if (q.get('login') === 'banned') toast('이용이 제한된 계정이에요.', 'error', 6000);
|
||||
if (q.has('linked') || q.has('login')) {
|
||||
q.delete('linked');
|
||||
q.delete('login');
|
||||
@@ -39,6 +53,7 @@ function Shell() {
|
||||
return (
|
||||
<>
|
||||
<ConnectionBanner />
|
||||
<Announcement />
|
||||
<Toasts />
|
||||
<Suspense fallback={<div className="page muted">불러오는 중…</div>}>
|
||||
<Routes>
|
||||
@@ -48,6 +63,7 @@ function Shell() {
|
||||
<Route path="/g/:gameId" element={<GamePage />} />
|
||||
<Route path="/rules/:gameId" element={<RulesPage />} />
|
||||
<Route path="/me" element={<MePage />} />
|
||||
<Route path="/admin/*" element={<AdminApp />} />
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
|
||||
@@ -35,8 +35,11 @@ export interface Settings {
|
||||
tapConfirm?: boolean;
|
||||
}
|
||||
|
||||
export type Role = 'user' | 'admin' | 'superadmin';
|
||||
|
||||
export interface MeResponse {
|
||||
me: PublicUser | null;
|
||||
role?: Role;
|
||||
settings?: Settings;
|
||||
useAvatar?: boolean;
|
||||
hasDiscordAvatar?: boolean;
|
||||
@@ -54,7 +57,8 @@ export interface RoomPreview {
|
||||
}
|
||||
|
||||
export const api = {
|
||||
config: () => call<{ discord: boolean; catalog: CatalogEntry[] }>('GET', '/api/config'),
|
||||
config: () =>
|
||||
call<{ discord: boolean; catalog: (CatalogEntry & { notice?: string })[]; siteName: string; announcement: string; maintenance: boolean }>('GET', '/api/config'),
|
||||
me: () => call<MeResponse>('GET', '/api/me'),
|
||||
guest: (nickname: string) => call<{ me: PublicUser }>('POST', '/api/auth/guest', { nickname }),
|
||||
updateMe: (patch: { nickname?: string; useAvatar?: boolean; settings?: Settings }) => call<{ me: PublicUser }>('PATCH', '/api/me', patch),
|
||||
@@ -69,4 +73,6 @@ export const api = {
|
||||
call<{ rooms: { code: string; host: string; seated: number; max: number }[] }>('GET', `/api/games/${gameId}/rooms`),
|
||||
};
|
||||
|
||||
export { call as apiCall };
|
||||
|
||||
export const discordLoginUrl = (next: string) => `/api/auth/discord/start?next=${encodeURIComponent(next)}`;
|
||||
|
||||
@@ -11,7 +11,8 @@ import { GAME_ICON } from '../lib/catalog-ui';
|
||||
|
||||
export default function GamePage() {
|
||||
const { gameId = '' } = useParams();
|
||||
const g = catalogById(gameId);
|
||||
const catalog = useSession((s) => s.catalog);
|
||||
const g = catalog.find((c) => c.id === gameId) ?? catalogById(gameId);
|
||||
const nav = useNavigate();
|
||||
const me = useSession((s) => s.me);
|
||||
const [rooms, setRooms] = useState<{ code: string; host: string; seated: number; max: number }[]>([]);
|
||||
|
||||
@@ -11,7 +11,7 @@ import { formatRoomCode } from '@bg/shared/lite';
|
||||
import { GAME_ICON, UPCOMING } from '../lib/catalog-ui';
|
||||
|
||||
export function Home() {
|
||||
const { me, activeRoom } = useSession();
|
||||
const { me, activeRoom, siteName } = useSession();
|
||||
const catalog = useSession((s) => (s.catalog.length ? s.catalog : CATALOG));
|
||||
const nav = useNavigate();
|
||||
const [picker, setPicker] = useState(false);
|
||||
@@ -37,7 +37,7 @@ export function Home() {
|
||||
return (
|
||||
<main className="page">
|
||||
<header className="row" style={{ justifyContent: 'space-between' }}>
|
||||
<h1>같이 놀자 보드게임</h1>
|
||||
<h1>{siteName}</h1>
|
||||
<Link to="/me" className="btn btn-ghost" aria-label="내 정보">
|
||||
{me ? <Avatar user={me} size={36} /> : '👤'} <span className="small">{me ? me.nickname : '내 정보'}</span>
|
||||
</Link>
|
||||
|
||||
@@ -25,7 +25,7 @@ function Seg<T extends string>({ value, options, onChange, label }: { value: T;
|
||||
|
||||
export default function MePage() {
|
||||
const nav = useNavigate();
|
||||
const { me, settings, discordEnabled, useAvatar, hasDiscordAvatar, updateSettings } = useSession();
|
||||
const { me, settings, discordEnabled, useAvatar, hasDiscordAvatar, updateSettings, role } = useSession();
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [nick, setNick] = useState(me?.nickname ?? '');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -128,6 +128,12 @@ export default function MePage() {
|
||||
)}
|
||||
</section>
|
||||
|
||||
{role !== 'user' && (
|
||||
<button className="btn btn-soft btn-block" onClick={() => nav('/admin')}>
|
||||
🛠 관리자 페이지 ({role === 'superadmin' ? '슈퍼어드민' : '어드민'})
|
||||
</button>
|
||||
)}
|
||||
|
||||
{display}
|
||||
|
||||
<section className="card stack">
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { create } from 'zustand';
|
||||
import { useNavigate, useParams } from 'react-router';
|
||||
import { CHAT_MAX, EMOTE_LABEL, EMOTES, catalogById, formatRoomCode, type PublicUser, type RoomView } from '@bg/shared/lite';
|
||||
import { CHAT_MAX, EMOTE_LABEL, EMOTES, formatRoomCode, type PublicUser, type RoomView } from '@bg/shared/lite';
|
||||
import { Avatar, CodeLabel, ConfirmSheet, CopyButton, ShareButton, Sheet, TopBar } from '../components/ui';
|
||||
import { NicknameForm } from '../components/NicknameForm';
|
||||
import { GameNotice, gameName } from '../components/GameNotice';
|
||||
import { QrModal } from '../components/QrModal';
|
||||
import { api, type RoomPreview } from '../net/api';
|
||||
import { socket, useRoom } from '../net/socket';
|
||||
@@ -44,7 +45,7 @@ export default function RoomPage() {
|
||||
{preview ? (
|
||||
<>
|
||||
<h1>
|
||||
{catalogById(preview.gameId)?.nameKo ?? '게임'} 방에 초대받았어요
|
||||
{gameName(preview.gameId)} 방에 초대받았어요
|
||||
</h1>
|
||||
<p className="muted">
|
||||
방장 {preview.host} · {preview.seated}/{preview.maxPlayers}명 · 코드 {preview.codeLabel}
|
||||
@@ -132,7 +133,7 @@ function Room({ code, me }: { code: string; me: PublicUser }) {
|
||||
);
|
||||
}
|
||||
|
||||
const g = catalogById(room.gameId);
|
||||
const g = { nameKo: gameName(room.gameId) };
|
||||
const inGame = room.status === 'playing' || room.status === 'paused' || (room.status === 'finished' && !!game);
|
||||
const isHost = room.hostId === me.id;
|
||||
const doLeave = () => {
|
||||
@@ -241,9 +242,9 @@ function Lobby({ room, ui, me }: { room: RoomView; ui: GameUI; me: PublicUser })
|
||||
const seated = room.seats.filter(Boolean).length;
|
||||
const need = Math.max(0, room.minPlayers - seated);
|
||||
const url = `${location.origin}/r/${room.code}`;
|
||||
const gameName = catalogById(room.gameId)?.nameKo ?? '게임';
|
||||
const gName = gameName(room.gameId);
|
||||
const host = room.seats.find((s) => s?.user.id === room.hostId)?.user.nickname ?? me.nickname;
|
||||
const shareText = `${host}님이 ${gameName} 방에 초대했어요! 코드 ${formatRoomCode(room.code)}`;
|
||||
const shareText = `${host}님이 ${gName} 방에 초대했어요! 코드 ${formatRoomCode(room.code)}`;
|
||||
const notReady = room.seats.filter((s) => s && !s.ready && s.user.id !== room.hostId).length;
|
||||
const [askStart, setAskStart] = useState(false);
|
||||
|
||||
@@ -257,7 +258,7 @@ function Lobby({ room, ui, me }: { room: RoomView; ui: GameUI; me: PublicUser })
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
|
||||
<CopyButton text={room.code} label="🔢 코드 복사" />
|
||||
<CopyButton text={url} label="🔗 링크 복사" />
|
||||
<ShareButton title={`${gameName} 같이 해요`} text={shareText} url={url} />
|
||||
<ShareButton title={`${gName} 같이 해요`} text={shareText} url={url} />
|
||||
<button className="btn btn-secondary" onClick={() => setQr(true)}>
|
||||
📷 QR 보기
|
||||
</button>
|
||||
@@ -315,6 +316,8 @@ function Lobby({ room, ui, me }: { room: RoomView; ui: GameUI; me: PublicUser })
|
||||
<p className="muted small">
|
||||
{room.visibility === 'public' ? '공개 방 (누구나 들어올 수 있어요)' : '친구만 (코드·링크로만 들어와요)'}
|
||||
</p>
|
||||
{room.lockedOptions.length > 0 && <p className="muted small">🔒 일부 규칙은 관리자가 정해 두어서 바꿀 수 없어요.</p>}
|
||||
<GameNotice gameId={room.gameId} />
|
||||
</section>
|
||||
|
||||
<div className="bottom-bar">
|
||||
@@ -406,6 +409,7 @@ function SettingsSheet({ room, ui, onClose }: { room: RoomView; ui: GameUI; onCl
|
||||
return (
|
||||
<Sheet onClose={onClose} label="게임 설정">
|
||||
<h2>게임 설정</h2>
|
||||
{room.lockedOptions.length > 0 && <p className="muted small">🔒 관리자가 고정한 규칙은 바꿔도 저장되지 않아요.</p>}
|
||||
<ui.Options value={options} onChange={setOptions} />
|
||||
<hr style={{ border: 0, borderTop: '1px solid var(--border)', width: '100%' }} />
|
||||
<strong>방 공개</strong>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router';
|
||||
import { catalogById } from '@bg/shared/lite';
|
||||
import { TopBar } from '../components/ui';
|
||||
import { loadGameUI } from '../games/registry';
|
||||
import type { GameUI } from '../games/types';
|
||||
import { GameNotice, gameName } from '../components/GameNotice';
|
||||
|
||||
export default function RulesPage() {
|
||||
const { gameId = '' } = useParams();
|
||||
@@ -12,11 +12,12 @@ export default function RulesPage() {
|
||||
useEffect(() => {
|
||||
loadGameUI(gameId).then(setUi);
|
||||
}, [gameId]);
|
||||
const g = catalogById(gameId);
|
||||
const g = { nameKo: gameName(gameId) };
|
||||
return (
|
||||
<>
|
||||
<TopBar title={`${g?.nameKo ?? ''} 규칙`} onBack={() => nav(-1)} />
|
||||
<main className="page">
|
||||
<GameNotice gameId={gameId} />
|
||||
{ui === undefined ? <p className="muted">불러오는 중…</p> : ui ? <ui.Rules /> : <p>아직 준비 중인 게임이에요.</p>}
|
||||
</main>
|
||||
</>
|
||||
|
||||
657
apps/web/src/pages/admin/AdminApp.tsx
Normal file
657
apps/web/src/pages/admin/AdminApp.tsx
Normal file
@@ -0,0 +1,657 @@
|
||||
/** 관리자 사이트 (docs/14-admin.md). Downloaded only when /admin is opened. */
|
||||
import { useCallback, useEffect, useState, type ReactNode } from 'react';
|
||||
import { NavLink, Route, Routes, useNavigate, useParams } from 'react-router';
|
||||
import { formatRoomCode } from '@bg/shared/lite';
|
||||
import { ConfirmSheet, Sheet, TopBar } from '../../components/ui';
|
||||
import { apiCall, discordLoginUrl, type Role } from '../../net/api';
|
||||
import { useSession } from '../../store/session';
|
||||
import { toast } from '../../store/toast';
|
||||
import { SchemaForm, type JsonSchema } from './SchemaForm';
|
||||
|
||||
const a = {
|
||||
get: <T,>(p: string) => apiCall<T>('GET', `/api/admin${p}`),
|
||||
put: <T,>(p: string, b: unknown) => apiCall<T>('PUT', `/api/admin${p}`, b),
|
||||
post: <T,>(p: string, b: unknown = {}) => apiCall<T>('POST', `/api/admin${p}`, b),
|
||||
patch: <T,>(p: string, b: unknown) => apiCall<T>('PATCH', `/api/admin${p}`, b),
|
||||
del: <T,>(p: string) => apiCall<T>('DELETE', `/api/admin${p}`),
|
||||
};
|
||||
|
||||
function useLoad<T>(path: string): [T | null, () => void, string | null] {
|
||||
const [data, setData] = useState<T | null>(null);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const load = useCallback(() => {
|
||||
a.get<T>(path).then(
|
||||
(d) => (setData(d), setErr(null)),
|
||||
(e) => setErr((e as Error).message),
|
||||
);
|
||||
}, [path]);
|
||||
useEffect(load, [load]);
|
||||
return [data, load, err];
|
||||
}
|
||||
|
||||
const time = (ms: number) => (ms ? new Date(ms).toLocaleString('ko-KR', { dateStyle: 'short', timeStyle: 'short' }) : '-');
|
||||
|
||||
export default function AdminApp() {
|
||||
const nav = useNavigate();
|
||||
const [role, setRole] = useState<Role | null | 'denied'>(null);
|
||||
useEffect(() => {
|
||||
a.get<{ role: Role }>('/me').then(
|
||||
(r) => setRole(r.role),
|
||||
() => setRole('denied'),
|
||||
);
|
||||
}, []);
|
||||
if (role === null) return <main className="page muted">확인 중…</main>;
|
||||
if (role === 'denied' || role === 'user') return <Denied />;
|
||||
const sup = role === 'superadmin';
|
||||
const tabs: [string, string, boolean][] = [
|
||||
['', '대시보드', true],
|
||||
['games', '게임', true],
|
||||
['rooms', '방', true],
|
||||
['site', '사이트', true],
|
||||
['users', '사용자', sup],
|
||||
['admins', '관리자', sup],
|
||||
['audit', '기록', true],
|
||||
];
|
||||
return (
|
||||
<>
|
||||
<TopBar title={`관리자 · ${sup ? '슈퍼어드민' : '어드민'}`} onBack={() => nav('/me')} />
|
||||
<nav className="row" style={{ padding: '8px 12px', gap: 6, overflowX: 'auto', flexWrap: 'nowrap', borderBottom: '1px solid var(--border)' }} aria-label="관리자 메뉴">
|
||||
{tabs
|
||||
.filter((t) => t[2])
|
||||
.map(([to, label]) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to ? `/admin/${to}` : "/admin"}
|
||||
end={to === ''}
|
||||
className={({ isActive }) => `btn btn-sm ${isActive ? 'btn-primary' : 'btn-secondary'}`}
|
||||
style={{ whiteSpace: 'nowrap' }}
|
||||
>
|
||||
{label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
<main className="page">
|
||||
<Routes>
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="games" element={<Games />} />
|
||||
<Route path="games/:id" element={<GameEditor />} />
|
||||
<Route path="rooms" element={<Rooms />} />
|
||||
<Route path="site" element={<Site />} />
|
||||
{sup && <Route path="users" element={<Users />} />}
|
||||
{sup && <Route path="admins" element={<Admins />} />}
|
||||
<Route path="audit" element={<Audit />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Denied() {
|
||||
const me = useSession((s) => s.me);
|
||||
const discord = useSession((s) => s.discordEnabled);
|
||||
return (
|
||||
<main className="page">
|
||||
<section className="card stack">
|
||||
<h1>권한이 없어요</h1>
|
||||
<p className="muted">관리자 페이지는 관리자로 등록된 디스코드 계정만 볼 수 있어요.</p>
|
||||
{discord && me?.kind !== 'member' && (
|
||||
<a className="btn btn-primary btn-block" href={discordLoginUrl('/admin')}>
|
||||
디스코드로 로그인
|
||||
</a>
|
||||
)}
|
||||
<a className="btn btn-secondary btn-block" href="/">
|
||||
홈으로
|
||||
</a>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ label, value }: { label: string; value: ReactNode }) {
|
||||
return (
|
||||
<div className="card" style={{ padding: 14 }}>
|
||||
<div className="muted small">{label}</div>
|
||||
<div style={{ fontSize: '1.6rem', fontWeight: 800 }}>{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ dashboard
|
||||
|
||||
function Dashboard() {
|
||||
const [d] = useLoad<any>('/overview');
|
||||
if (!d) return <p className="muted">불러오는 중…</p>;
|
||||
return (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(150px, 1fr))', gap: 12 }}>
|
||||
<Stat label="지금 접속" value={d.connections} />
|
||||
<Stat label="열린 방" value={d.rooms.total} />
|
||||
<Stat label="게임 중인 방" value={d.rooms.playing} />
|
||||
<Stat label="오늘 시작한 판" value={d.games?.today ?? 0} />
|
||||
<Stat label="전체 판" value={d.games?.total ?? 0} />
|
||||
<Stat label="사용자" value={d.users.total} />
|
||||
<Stat label="게스트 / 디스코드" value={`${d.users.guests ?? 0} / ${d.users.members ?? 0}`} />
|
||||
<Stat label="이용 제한" value={d.users.banned ?? 0} />
|
||||
<Stat label="가동 시간" value={`${Math.floor(d.uptimeSec / 3600)}시간 ${Math.floor((d.uptimeSec % 3600) / 60)}분`} />
|
||||
<Stat label="메모리" value={`${d.memoryMb}MB`} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ games
|
||||
|
||||
interface AdminGame {
|
||||
id: string;
|
||||
codeDefaults: { nameKo: string; minPlayers: number; maxPlayers: number; defaultOptions: unknown };
|
||||
raw: Record<string, unknown>;
|
||||
effective: { enabled: boolean; nameKo: string; blurb: string; notice: string; minPlayers: number; maxPlayers: number; defaultOptions: unknown; lockedOptions: string[] };
|
||||
schema: JsonSchema;
|
||||
}
|
||||
|
||||
function Games() {
|
||||
const [d] = useLoad<{ games: AdminGame[] }>('/games');
|
||||
const nav = useNavigate();
|
||||
if (!d) return <p className="muted">불러오는 중…</p>;
|
||||
return (
|
||||
<div className="stack">
|
||||
<p className="muted">설정을 바꾸면 새로 만드는 방과 새로 시작하는 판부터 적용돼요. 진행 중인 판은 그대로예요.</p>
|
||||
{d.games.map((g) => (
|
||||
<button key={g.id} className="card row" style={{ border: 0, cursor: 'pointer', textAlign: 'left', justifyContent: 'space-between' }} onClick={() => nav(`/admin/games/${g.id}`)}>
|
||||
<div>
|
||||
<strong>{g.effective.nameKo}</strong> <span className="muted small">({g.id})</span>
|
||||
<div className="muted small">
|
||||
{g.effective.minPlayers}~{g.effective.maxPlayers}명 · 잠긴 옵션 {g.effective.lockedOptions.length}개
|
||||
</div>
|
||||
</div>
|
||||
{g.effective.enabled ? <span className="badge success">사용 중</span> : <span className="badge warning">꺼짐</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GameEditor() {
|
||||
const { id = '' } = useParams();
|
||||
const nav = useNavigate();
|
||||
const [d, reload] = useLoad<{ games: AdminGame[] }>('/games');
|
||||
const g = d?.games.find((x) => x.id === id);
|
||||
const [form, setForm] = useState<{ enabled: boolean; nameKo: string; blurb: string; notice: string; minPlayers: number; maxPlayers: number; defaultOptions: unknown; locked: Set<string> } | null>(null);
|
||||
const [ask, setAsk] = useState(false);
|
||||
useEffect(() => {
|
||||
if (g) setForm({ ...g.effective, locked: new Set(g.effective.lockedOptions) });
|
||||
}, [g?.id, d]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
if (!d) return <p className="muted">불러오는 중…</p>;
|
||||
if (!g || !form) return <p>없는 게임이에요.</p>;
|
||||
const cd = g.codeDefaults;
|
||||
const save = async () => {
|
||||
try {
|
||||
await a.put(`/games/${id}`, {
|
||||
enabled: form.enabled,
|
||||
nameKo: form.nameKo,
|
||||
blurb: form.blurb,
|
||||
notice: form.notice,
|
||||
minPlayers: form.minPlayers,
|
||||
maxPlayers: form.maxPlayers,
|
||||
defaultOptions: form.defaultOptions,
|
||||
lockedOptions: [...form.locked],
|
||||
});
|
||||
toast('저장했어요.');
|
||||
reload();
|
||||
void useSession.getState().refresh();
|
||||
} catch (e) {
|
||||
toast((e as Error).message, 'error', 6000);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className="stack" style={{ gap: 16 }}>
|
||||
<button className="btn btn-ghost" style={{ alignSelf: 'flex-start' }} onClick={() => nav('/admin/games')}>
|
||||
← 게임 목록
|
||||
</button>
|
||||
<h1>{g.effective.nameKo} 설정</h1>
|
||||
<section className="card stack">
|
||||
<h2>기본 정보</h2>
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<strong>사용</strong>
|
||||
<div className="segmented">
|
||||
<button type="button" aria-pressed={form.enabled} onClick={() => setForm({ ...form, enabled: true })}>
|
||||
사용
|
||||
</button>
|
||||
<button type="button" aria-pressed={!form.enabled} onClick={() => setForm({ ...form, enabled: false })}>
|
||||
끄기(새 방 막기)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<label className="field">
|
||||
표시 이름 <span className="muted small">(원래: {cd.nameKo})</span>
|
||||
<input className="input" value={form.nameKo} maxLength={30} onChange={(e) => setForm({ ...form, nameKo: e.target.value })} />
|
||||
</label>
|
||||
<label className="field">
|
||||
한 줄 설명
|
||||
<input className="input" value={form.blurb} maxLength={80} onChange={(e) => setForm({ ...form, blurb: e.target.value })} />
|
||||
</label>
|
||||
<label className="field">
|
||||
안내문(규칙 화면 위에 표시)
|
||||
<textarea className="input" style={{ minHeight: 90, padding: 12 }} value={form.notice} maxLength={500} onChange={(e) => setForm({ ...form, notice: e.target.value })} />
|
||||
</label>
|
||||
<div className="row">
|
||||
<label className="field" style={{ flex: 1 }}>
|
||||
최소 인원 ({cd.minPlayers}~{cd.maxPlayers})
|
||||
<input className="input" type="number" min={cd.minPlayers} max={cd.maxPlayers} value={form.minPlayers} onChange={(e) => setForm({ ...form, minPlayers: Number(e.target.value) })} />
|
||||
</label>
|
||||
<label className="field" style={{ flex: 1 }}>
|
||||
최대 인원 ({cd.minPlayers}~{cd.maxPlayers})
|
||||
<input className="input" type="number" min={cd.minPlayers} max={cd.maxPlayers} value={form.maxPlayers} onChange={(e) => setForm({ ...form, maxPlayers: Number(e.target.value) })} />
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
<section className="card stack">
|
||||
<h2>기본 규칙</h2>
|
||||
<p className="muted small">방을 만들 때 처음 들어가는 값이에요. 🔒을 켜면 방장이 바꿀 수 없어요.</p>
|
||||
<SchemaForm
|
||||
schema={g.schema}
|
||||
value={form.defaultOptions}
|
||||
onChange={(v) => setForm({ ...form, defaultOptions: v })}
|
||||
locked={form.locked}
|
||||
onToggleLock={(p) => {
|
||||
const next = new Set(form.locked);
|
||||
if (next.has(p)) next.delete(p);
|
||||
else next.add(p);
|
||||
setForm({ ...form, locked: next });
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
<div className="bottom-bar">
|
||||
<div className="bottom-bar-inner">
|
||||
<button className="btn btn-primary" onClick={save}>
|
||||
저장
|
||||
</button>
|
||||
<button className="btn btn-secondary" onClick={() => setAsk(true)}>
|
||||
처음 값으로
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{ask && (
|
||||
<ConfirmSheet
|
||||
title="코드 기본값으로 되돌릴까요?"
|
||||
body="이 게임의 관리자 설정이 모두 지워져요."
|
||||
confirm="되돌리기"
|
||||
danger
|
||||
onConfirm={async () => {
|
||||
await a.post(`/games/${id}/reset`);
|
||||
toast('처음 값으로 되돌렸어요.');
|
||||
reload();
|
||||
void useSession.getState().refresh();
|
||||
}}
|
||||
onClose={() => setAsk(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ rooms
|
||||
|
||||
function Rooms() {
|
||||
const [d, reload] = useLoad<{ rooms: any[] }>('/rooms');
|
||||
const [closing, setClosing] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
const t = setInterval(reload, 5000);
|
||||
return () => clearInterval(t);
|
||||
}, [reload]);
|
||||
const catalog = useSession((s) => s.catalog);
|
||||
if (!d) return <p className="muted">불러오는 중…</p>;
|
||||
const STATUS: Record<string, string> = { lobby: '대기 중', playing: '게임 중', finished: '끝남', paused: '멈춤', broken: '복구 실패' };
|
||||
return (
|
||||
<div className="stack">
|
||||
{d.rooms.length === 0 && <p className="muted">열린 방이 없어요.</p>}
|
||||
{d.rooms.map((r) => (
|
||||
<div key={r.code} className="card stack" style={{ gap: 6 }}>
|
||||
<div className="row" style={{ justifyContent: 'space-between' }}>
|
||||
<strong>
|
||||
{catalog.find((c) => c.id === r.gameId)?.nameKo ?? r.gameId} · {formatRoomCode(r.code)}
|
||||
</strong>
|
||||
<span className="badge">{STATUS[r.status] ?? r.status}</span>
|
||||
</div>
|
||||
<div className="small">
|
||||
{r.seats.map((s: any, i: number) => (s ? `${s.nickname}${s.connected ? '' : '(끊김)'}` : `빈자리${i + 1}`)).join(', ')}
|
||||
</div>
|
||||
<div className="muted small">
|
||||
방장 {r.host ?? '-'} · 관전 {r.spectators} · 접속 {r.connections} · {r.visibility === 'public' ? '공개' : '비공개'} · {r.gameNo}판 · 마지막 활동 {time(r.lastActiveAt)}
|
||||
</div>
|
||||
<div className="row">
|
||||
<a className="btn btn-secondary btn-sm" href={`/r/${r.code}`}>
|
||||
들어가 보기
|
||||
</a>
|
||||
<button className="btn btn-danger btn-sm" onClick={() => setClosing(r.code)}>
|
||||
방 닫기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{closing && (
|
||||
<ConfirmSheet
|
||||
title={`방 ${formatRoomCode(closing)}을 닫을까요?`}
|
||||
body="진행 중인 판은 무효가 되고 모두 방에서 나가요."
|
||||
confirm="닫기"
|
||||
danger
|
||||
onConfirm={async () => {
|
||||
await a.post(`/rooms/${closing}/close`).catch((e) => toast((e as Error).message, 'error'));
|
||||
reload();
|
||||
}}
|
||||
onClose={() => setClosing(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ site
|
||||
|
||||
function Site() {
|
||||
const [d] = useLoad<{ site: any }>('/site');
|
||||
const [s, setS] = useState<any>(null);
|
||||
useEffect(() => {
|
||||
if (d) setS({ ...d.site, bannedText: d.site.bannedWords.join('\n') });
|
||||
}, [d]);
|
||||
if (!s) return <p className="muted">불러오는 중…</p>;
|
||||
const num = (k: string, label: string, min: number, max: number) => (
|
||||
<label className="field">
|
||||
{label} <span className="muted small">({min}~{max})</span>
|
||||
<input className="input" type="number" min={min} max={max} value={s[k]} onChange={(e) => setS({ ...s, [k]: Number(e.target.value) })} />
|
||||
</label>
|
||||
);
|
||||
const bool = (k: string, label: string, on = '켜기', off = '끄기') => (
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<strong>{label}</strong>
|
||||
<div className="segmented">
|
||||
<button type="button" aria-pressed={s[k]} onClick={() => setS({ ...s, [k]: true })}>
|
||||
{on}
|
||||
</button>
|
||||
<button type="button" aria-pressed={!s[k]} onClick={() => setS({ ...s, [k]: false })}>
|
||||
{off}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
const save = async () => {
|
||||
const { bannedText, ...rest } = s;
|
||||
try {
|
||||
await a.put('/site', { ...rest, bannedWords: String(bannedText).split('\n').map((w: string) => w.trim()).filter(Boolean) });
|
||||
toast('저장했어요.');
|
||||
void useSession.getState().refresh();
|
||||
} catch (e) {
|
||||
toast((e as Error).message, 'error', 6000);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className="stack" style={{ gap: 16 }}>
|
||||
<section className="card stack">
|
||||
<label className="field">
|
||||
사이트 이름
|
||||
<input className="input" value={s.siteName} maxLength={40} onChange={(e) => setS({ ...s, siteName: e.target.value })} />
|
||||
</label>
|
||||
<label className="field">
|
||||
공지(모든 화면 위에 표시, 비우면 숨김)
|
||||
<textarea className="input" style={{ minHeight: 80, padding: 12 }} maxLength={300} value={s.announcement} onChange={(e) => setS({ ...s, announcement: e.target.value })} />
|
||||
</label>
|
||||
{bool('maintenance', '점검 모드(새 방 만들기 막기)', '점검 중', '정상')}
|
||||
</section>
|
||||
<section className="card stack">
|
||||
{num('maxRoomsPerUser', '사용자당 동시에 만들 수 있는 방', 1, 50)}
|
||||
{num('maxRooms', '서버 전체 방 수 제한', 10, 20000)}
|
||||
{num('graceSec', '연결 끊김 유예(초, 새 방 기본값)', 10, 600)}
|
||||
{bool('chatEnabledDefault', '새 방 채팅 기본값', '켜기', '끄기')}
|
||||
{bool('chatFilterDefault', '새 방 욕설 필터 기본값', '켜기', '끄기')}
|
||||
</section>
|
||||
<section className="card stack">
|
||||
<label className="field">
|
||||
금지어(한 줄에 하나, 채팅에서 가리고 닉네임에 못 씀)
|
||||
<textarea className="input" style={{ minHeight: 140, padding: 12 }} value={s.bannedText} onChange={(e) => setS({ ...s, bannedText: e.target.value })} />
|
||||
</label>
|
||||
</section>
|
||||
<div className="bottom-bar">
|
||||
<div className="bottom-bar-inner">
|
||||
<button className="btn btn-primary" onClick={save}>
|
||||
저장
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ users (superadmin)
|
||||
|
||||
function Users() {
|
||||
const [q, setQ] = useState('');
|
||||
const [query, setQuery] = useState('');
|
||||
const [d, reload] = useLoad<{ total: number; users: any[] }>(`/users?q=${encodeURIComponent(query)}`);
|
||||
const [open, setOpen] = useState<string | null>(null);
|
||||
return (
|
||||
<div className="stack">
|
||||
<form
|
||||
className="row"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
setQuery(q.trim());
|
||||
}}
|
||||
>
|
||||
<input className="input" style={{ flex: 1 }} placeholder="닉네임, 사용자 ID, 디스코드 ID" value={q} onChange={(e) => setQ(e.target.value)} />
|
||||
<button className="btn btn-primary">검색</button>
|
||||
</form>
|
||||
{d && <p className="muted small">전체 {d.total}명</p>}
|
||||
{d?.users.map((u) => (
|
||||
<button key={u.id} className="card row" style={{ border: 0, cursor: 'pointer', textAlign: 'left', justifyContent: 'space-between' }} onClick={() => setOpen(u.id)}>
|
||||
<div>
|
||||
<strong>{u.nickname}</strong> {u.role !== 'user' && <span className="badge">{u.role === 'superadmin' ? '슈퍼어드민' : '어드민'}</span>}
|
||||
<div className="muted small">
|
||||
{u.kind === 'member' ? `디스코드 ${u.discordName ?? ''} (${u.discordId})` : '게스트'} · 마지막 접속 {time(u.lastSeenAt)}
|
||||
</div>
|
||||
</div>
|
||||
{u.banned && <span className="badge warning">제한</span>}
|
||||
</button>
|
||||
))}
|
||||
{open && <UserSheet id={open} onClose={() => (setOpen(null), reload())} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UserSheet({ id, onClose }: { id: string; onClose: () => void }) {
|
||||
const [d, reload] = useLoad<{ user: any; stats: any[] }>(`/users/${id}`);
|
||||
const [nick, setNick] = useState('');
|
||||
const [reason, setReason] = useState('');
|
||||
const [ask, setAsk] = useState<null | 'delete' | 'logout'>(null);
|
||||
useEffect(() => {
|
||||
if (d) setNick(d.user.nickname);
|
||||
}, [d]);
|
||||
const run = async (p: Promise<unknown>, msg: string) => {
|
||||
try {
|
||||
await p;
|
||||
toast(msg);
|
||||
reload();
|
||||
} catch (e) {
|
||||
toast((e as Error).message, 'error');
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Sheet onClose={onClose} label="사용자 관리">
|
||||
{!d ? (
|
||||
<p className="muted">불러오는 중…</p>
|
||||
) : (
|
||||
<>
|
||||
<h2>{d.user.nickname}</h2>
|
||||
<p className="muted small">
|
||||
ID {d.user.id}
|
||||
<br />
|
||||
{d.user.discordId ? `디스코드 ID ${d.user.discordId}` : '게스트'} · 가입 {time(d.user.createdAt)} · 마지막 접속 {time(d.user.lastSeenAt)}
|
||||
{d.user.activeRoom && <> · 지금 방 {formatRoomCode(d.user.activeRoom)}</>}
|
||||
</p>
|
||||
<p className="small">{d.stats.length ? d.stats.map((s) => `${s.gameId} ${s.played}판 ${s.wins}승`).join(' · ') : '기록 없음'}</p>
|
||||
<div className="row">
|
||||
<input className="input" style={{ flex: 1 }} value={nick} onChange={(e) => setNick(e.target.value)} maxLength={20} aria-label="닉네임" />
|
||||
<button className="btn btn-secondary" onClick={() => run(a.patch(`/users/${id}`, { nickname: nick }), '닉네임을 바꿨어요.')}>
|
||||
닉네임 변경
|
||||
</button>
|
||||
</div>
|
||||
{d.user.banned ? (
|
||||
<div className="card stack" style={{ background: 'var(--warning-soft)' }}>
|
||||
<p>
|
||||
이용 제한 중: {d.user.banned.reason} ({time(d.user.banned.at)})
|
||||
</p>
|
||||
<button className="btn btn-primary" onClick={() => run(a.patch(`/users/${id}`, { ban: null }), '제한을 풀었어요.')}>
|
||||
제한 해제
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="row">
|
||||
<input className="input" style={{ flex: 1 }} placeholder="제한 사유" value={reason} onChange={(e) => setReason(e.target.value)} maxLength={200} />
|
||||
<button className="btn btn-danger" disabled={!reason.trim()} onClick={() => run(a.patch(`/users/${id}`, { ban: { reason } }), '이용을 제한했어요.')}>
|
||||
이용 제한
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<button className="btn btn-secondary btn-block" onClick={() => setAsk('logout')}>
|
||||
모든 기기에서 로그아웃시키기
|
||||
</button>
|
||||
<button className="btn btn-danger btn-block" onClick={() => setAsk('delete')}>
|
||||
계정 삭제
|
||||
</button>
|
||||
<button className="btn btn-ghost btn-block" onClick={onClose}>
|
||||
닫기
|
||||
</button>
|
||||
{ask === 'logout' && <ConfirmSheet title="로그아웃시킬까요?" confirm="로그아웃" onConfirm={() => run(a.post(`/users/${id}/logout`), '로그아웃시켰어요.')} onClose={() => setAsk(null)} />}
|
||||
{ask === 'delete' && (
|
||||
<ConfirmSheet
|
||||
title="계정을 삭제할까요?"
|
||||
body="되돌릴 수 없어요."
|
||||
confirm="삭제"
|
||||
danger
|
||||
onConfirm={async () => {
|
||||
await run(a.del(`/users/${id}`), '삭제했어요.');
|
||||
onClose();
|
||||
}}
|
||||
onClose={() => setAsk(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ admins (superadmin)
|
||||
|
||||
function Admins() {
|
||||
const [d, reload] = useLoad<{ admins: any[] }>('/admins');
|
||||
const [discordId, setDiscordId] = useState('');
|
||||
const [note, setNote] = useState('');
|
||||
const [removing, setRemoving] = useState<string | null>(null);
|
||||
const add = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await a.post('/admins', { discordId: discordId.trim(), note: note.trim() || undefined });
|
||||
toast('어드민을 추가했어요.');
|
||||
setDiscordId('');
|
||||
setNote('');
|
||||
reload();
|
||||
} catch (ex) {
|
||||
toast((ex as Error).message, 'error');
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className="stack">
|
||||
<form className="card stack" onSubmit={add}>
|
||||
<h2>어드민 추가</h2>
|
||||
<label className="field">
|
||||
디스코드 ID(숫자)
|
||||
<input className="input" inputMode="numeric" value={discordId} onChange={(e) => setDiscordId(e.target.value)} placeholder="예: 293719842274541579" />
|
||||
</label>
|
||||
<label className="field">
|
||||
메모(선택)
|
||||
<input className="input" value={note} maxLength={100} onChange={(e) => setNote(e.target.value)} />
|
||||
</label>
|
||||
<p className="muted small">디스코드 설정 → 고급 → 개발자 모드를 켠 뒤, 프로필을 우클릭해 "사용자 ID 복사"로 얻을 수 있어요. 아직 사이트에 들어온 적 없는 사람도 미리 추가할 수 있어요.</p>
|
||||
<button className="btn btn-primary btn-block" disabled={!/^\d{17,20}$/.test(discordId.trim())}>
|
||||
추가
|
||||
</button>
|
||||
</form>
|
||||
{d?.admins.map((x) => (
|
||||
<div key={x.discordId} className="card row" style={{ justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<strong>{x.nickname ?? '(아직 로그인 안 함)'}</strong> <span className="badge">{x.fixed ? '슈퍼어드민' : '어드민'}</span>
|
||||
<div className="muted small">
|
||||
{x.discordId}
|
||||
{x.note ? ` · ${x.note}` : ''}
|
||||
{x.addedAt ? ` · ${time(x.addedAt)}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
{!x.fixed && (
|
||||
<button className="btn btn-danger btn-sm" onClick={() => setRemoving(x.discordId)}>
|
||||
삭제
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{removing && (
|
||||
<ConfirmSheet
|
||||
title="어드민에서 뺄까요?"
|
||||
body={`디스코드 ID ${removing}`}
|
||||
confirm="빼기"
|
||||
danger
|
||||
onConfirm={async () => {
|
||||
await a.del(`/admins/${removing}`).catch((e) => toast((e as Error).message, 'error'));
|
||||
reload();
|
||||
}}
|
||||
onClose={() => setRemoving(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ audit
|
||||
|
||||
const ACTION_KO: Record<string, string> = {
|
||||
'game.update': '게임 설정 변경',
|
||||
'game.reset': '게임 설정 초기화',
|
||||
'site.update': '사이트 설정 변경',
|
||||
'room.close': '방 닫기',
|
||||
'user.rename': '닉네임 변경',
|
||||
'user.ban': '이용 제한',
|
||||
'user.unban': '제한 해제',
|
||||
'user.logout': '강제 로그아웃',
|
||||
'user.delete': '계정 삭제',
|
||||
'admin.add': '어드민 추가',
|
||||
'admin.remove': '어드민 삭제',
|
||||
};
|
||||
|
||||
function Audit() {
|
||||
const [page, setPage] = useState(0);
|
||||
const [d] = useLoad<{ entries: any[] }>(`/audit?page=${page}`);
|
||||
return (
|
||||
<div className="stack">
|
||||
{d?.entries.length === 0 && <p className="muted">기록이 없어요.</p>}
|
||||
{d?.entries.map((e) => (
|
||||
<details key={e.id} className="card">
|
||||
<summary style={{ cursor: 'pointer' }}>
|
||||
<strong>{ACTION_KO[e.action] ?? e.action}</strong> {e.target && <span className="muted">· {e.target}</span>}
|
||||
<div className="muted small">
|
||||
{time(e.at)} · {e.actor ?? e.actorId}
|
||||
</div>
|
||||
</summary>
|
||||
<pre className="small" style={{ whiteSpace: 'pre-wrap', overflowWrap: 'anywhere' }}>
|
||||
전: {JSON.stringify(e.before, null, 1)}
|
||||
{'\n'}후: {JSON.stringify(e.after, null, 1)}
|
||||
</pre>
|
||||
</details>
|
||||
))}
|
||||
<div className="row">
|
||||
<button className="btn btn-secondary btn-sm" disabled={page === 0} onClick={() => setPage(page - 1)}>
|
||||
← 최근
|
||||
</button>
|
||||
<button className="btn btn-secondary btn-sm" disabled={(d?.entries.length ?? 0) < 50} onClick={() => setPage(page + 1)}>
|
||||
이전 기록 →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
139
apps/web/src/pages/admin/SchemaForm.tsx
Normal file
139
apps/web/src/pages/admin/SchemaForm.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Renders a form from a zod-generated JSON Schema (docs/14-admin.md §4).
|
||||
* Every option of a game appears automatically; titles/labels come from `.meta()`.
|
||||
*/
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export interface JsonSchema {
|
||||
type?: string | string[];
|
||||
title?: string;
|
||||
description?: string;
|
||||
labels?: Record<string, string>;
|
||||
enum?: (string | number)[];
|
||||
const?: string | number | boolean;
|
||||
anyOf?: JsonSchema[];
|
||||
properties?: Record<string, JsonSchema>;
|
||||
minimum?: number;
|
||||
maximum?: number;
|
||||
default?: unknown;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
schema: JsonSchema;
|
||||
value: unknown;
|
||||
onChange: (v: unknown) => void;
|
||||
locked?: Set<string>;
|
||||
onToggleLock?: (path: string) => void;
|
||||
}
|
||||
|
||||
function choices(s: JsonSchema): (string | number | boolean)[] | null {
|
||||
if (s.enum) return s.enum;
|
||||
if (s.anyOf && s.anyOf.every((a) => a.const !== undefined)) return s.anyOf.map((a) => a.const!);
|
||||
return null;
|
||||
}
|
||||
|
||||
export function SchemaForm({ schema, value, onChange, locked, onToggleLock }: Props) {
|
||||
return <Field schema={schema} value={value} onChange={onChange} path="" locked={locked} onToggleLock={onToggleLock} root />;
|
||||
}
|
||||
|
||||
function Field({ schema, value, onChange, path, locked, onToggleLock, root }: Props & { path: string; root?: boolean }) {
|
||||
const title = schema.title ?? path.split('.').pop() ?? '';
|
||||
const lock = onToggleLock && path ? (
|
||||
<label className="row small" style={{ gap: 4, cursor: 'pointer', marginLeft: 'auto' }}>
|
||||
<input type="checkbox" checked={locked?.has(path) ?? false} onChange={() => onToggleLock(path)} style={{ width: 20, height: 20 }} />
|
||||
🔒 방장 변경 금지
|
||||
</label>
|
||||
) : null;
|
||||
|
||||
if (schema.type === 'object' && schema.properties) {
|
||||
const obj = (value ?? {}) as Record<string, unknown>;
|
||||
const inner = Object.entries(schema.properties).map(([k, sub]) => (
|
||||
<Field
|
||||
key={k}
|
||||
schema={sub}
|
||||
value={obj[k]}
|
||||
onChange={(v) => onChange({ ...obj, [k]: v })}
|
||||
path={path ? `${path}.${k}` : k}
|
||||
locked={locked}
|
||||
onToggleLock={onToggleLock}
|
||||
/>
|
||||
));
|
||||
if (root) return <div className="stack" style={{ gap: 14 }}>{inner}</div>;
|
||||
return (
|
||||
<fieldset className="stack" style={{ border: '2px solid var(--border)', borderRadius: 12, padding: 12, gap: 12 }}>
|
||||
<legend style={{ fontWeight: 700, padding: '0 6px' }}>{title}</legend>
|
||||
{lock}
|
||||
{inner}
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
let control: ReactNode;
|
||||
const opts = choices(schema);
|
||||
if (opts) {
|
||||
const cur = value ?? schema.default;
|
||||
control = (
|
||||
<select
|
||||
className="input"
|
||||
value={String(cur)}
|
||||
onChange={(e) => {
|
||||
const picked = opts.find((o) => String(o) === e.target.value);
|
||||
onChange(picked);
|
||||
}}
|
||||
>
|
||||
{opts.map((o) => (
|
||||
<option key={String(o)} value={String(o)}>
|
||||
{schema.labels?.[String(o)] ?? String(o)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
} else if (schema.type === 'boolean') {
|
||||
const cur = (value ?? schema.default) === true;
|
||||
control = (
|
||||
<div className="segmented">
|
||||
<button type="button" aria-pressed={cur} onClick={() => onChange(true)}>
|
||||
켜기
|
||||
</button>
|
||||
<button type="button" aria-pressed={!cur} onClick={() => onChange(false)}>
|
||||
끄기
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
} else if (schema.type === 'integer' || schema.type === 'number') {
|
||||
const cur = (value ?? schema.default ?? '') as number | '';
|
||||
control = (
|
||||
<div className="row">
|
||||
<input
|
||||
className="input"
|
||||
style={{ maxWidth: 180 }}
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
value={cur}
|
||||
min={schema.minimum}
|
||||
max={schema.maximum}
|
||||
step={schema.type === 'integer' ? 1 : 'any'}
|
||||
onChange={(e) => onChange(e.target.value === '' ? undefined : Number(e.target.value))}
|
||||
/>
|
||||
{(schema.minimum !== undefined || schema.maximum !== undefined) && (
|
||||
<span className="muted small">
|
||||
{schema.minimum ?? ''} ~ {schema.maximum ?? ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
control = <input className="input" value={String(value ?? schema.default ?? '')} onChange={(e) => onChange(e.target.value)} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<div className="row" style={{ gap: 8 }}>
|
||||
<strong>{title}</strong>
|
||||
{lock}
|
||||
</div>
|
||||
{schema.description && <p className="muted small">{schema.description}</p>}
|
||||
{control}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -36,7 +36,11 @@ interface SessionState {
|
||||
me: PublicUser | null;
|
||||
activeRoom: string | null;
|
||||
discordEnabled: boolean;
|
||||
catalog: CatalogEntry[];
|
||||
catalog: (CatalogEntry & { notice?: string })[];
|
||||
role: 'user' | 'admin' | 'superadmin';
|
||||
siteName: string;
|
||||
announcement: string;
|
||||
maintenance: boolean;
|
||||
settings: Required<Settings>;
|
||||
useAvatar: boolean;
|
||||
hasDiscordAvatar: boolean;
|
||||
@@ -51,23 +55,32 @@ export const useSession = create<SessionState>((set, get) => ({
|
||||
activeRoom: null,
|
||||
discordEnabled: false,
|
||||
catalog: [],
|
||||
role: 'user',
|
||||
siteName: '같이 놀자 보드게임',
|
||||
announcement: '',
|
||||
maintenance: false,
|
||||
settings: loadLocal(),
|
||||
useAvatar: true,
|
||||
hasDiscordAvatar: false,
|
||||
async refresh() {
|
||||
const [cfg, me] = await Promise.all([
|
||||
api.config().catch(() => ({ discord: false, catalog: [] as CatalogEntry[] })),
|
||||
api.config().catch(() => ({ discord: false, catalog: [] as CatalogEntry[], siteName: '같이 놀자 보드게임', announcement: '', maintenance: false })),
|
||||
api.me().catch(() => ({ me: null }) as Awaited<ReturnType<typeof api.me>>),
|
||||
]);
|
||||
const settings = { ...get().settings, ...(me.settings ?? {}) };
|
||||
applySettings(settings);
|
||||
localStorage.setItem(LS_KEY, JSON.stringify(settings));
|
||||
document.title = cfg.siteName;
|
||||
set({
|
||||
loaded: true,
|
||||
me: me.me,
|
||||
activeRoom: me.activeRoom ?? null,
|
||||
discordEnabled: cfg.discord,
|
||||
catalog: cfg.catalog,
|
||||
role: me.role ?? 'user',
|
||||
siteName: cfg.siteName,
|
||||
announcement: cfg.announcement,
|
||||
maintenance: cfg.maintenance,
|
||||
settings,
|
||||
useAvatar: me.useAvatar ?? true,
|
||||
hasDiscordAvatar: me.hasDiscordAvatar ?? false,
|
||||
|
||||
130
e2e/admin.e2e.ts
Normal file
130
e2e/admin.e2e.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Admin E2E (docs/14-admin.md §9): superadmin logs in with (mocked) Discord, edits omok defaults
|
||||
* with a lock, adds an admin; a guest's new room reflects the settings.
|
||||
* Run: bun e2e/admin.e2e.ts (needs apps/web/dist built)
|
||||
*/
|
||||
import { chromium } from 'playwright-core';
|
||||
import { mkdirSync, mkdtempSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { loadConfig } from '../apps/server/src/config';
|
||||
import { startServer } from '../apps/server/src/server';
|
||||
|
||||
const PORT = Number(process.env.E2E_PORT ?? 4518);
|
||||
const ORIGIN = `http://localhost:${PORT}`;
|
||||
const SHOTS = process.env.E2E_SHOTS ?? join(import.meta.dir, '../test-results');
|
||||
mkdirSync(SHOTS, { recursive: true });
|
||||
const SUPER = '293719842274541579';
|
||||
|
||||
const config = loadConfig({ PORT: String(PORT), DB_PATH: join(mkdtempSync(join(process.env.TMPDIR ?? '/tmp', 'bg-e2e-')), 'app.db'), PUBLIC_ORIGIN: ORIGIN });
|
||||
config.discord = { clientId: 'e2e', clientSecret: 'e2e' };
|
||||
const app = startServer({
|
||||
config,
|
||||
log: () => {},
|
||||
staticDir: join(import.meta.dir, '../apps/web/dist'),
|
||||
discordExchange: async (code) => ({ id: code, username: 'owner', global_name: '사금향', avatar: null }),
|
||||
});
|
||||
|
||||
const browser = await chromium.launch({ executablePath: process.env.CHROME ?? '/usr/bin/google-chrome', headless: true });
|
||||
let failed = false;
|
||||
const step = async (name: string, fn: () => Promise<void>) => {
|
||||
const t0 = performance.now();
|
||||
try {
|
||||
await fn();
|
||||
console.log(`✓ ${name} (${Math.round(performance.now() - t0)}ms)`);
|
||||
} catch (e) {
|
||||
console.log(`✗ ${name}: ${(e as Error).message.split('\n')[0]}`);
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const adminCtx = await browser.newContext({ viewport: { width: 390, height: 844 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true, locale: 'ko-KR' });
|
||||
// Fake Discord: Playwright does not route redirect targets, so intercept our own /start, let the server
|
||||
// set its state cookie, and bounce straight back to the callback with the superadmin's ID as the code.
|
||||
await adminCtx.route(`${ORIGIN}/api/auth/discord/start*`, async (route) => {
|
||||
const res = await fetch(route.request().url(), { redirect: 'manual' });
|
||||
const loc = new URL(res.headers.get('location')!);
|
||||
const back = `${ORIGIN}/api/auth/discord/callback?code=${SUPER}&state=${loc.searchParams.get('state')}`;
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
headers: { 'content-type': 'text/html', 'set-cookie': res.headers.get('set-cookie') ?? '' },
|
||||
body: `<script>location.replace(${JSON.stringify(back)})</script>`,
|
||||
});
|
||||
});
|
||||
const a = await adminCtx.newPage();
|
||||
const errors: string[] = [];
|
||||
a.on('pageerror', (e) => errors.push(e.message));
|
||||
|
||||
await step('일반 사용자는 관리자 페이지 접근 불가', async () => {
|
||||
await a.goto(ORIGIN + '/admin');
|
||||
await a.getByText('권한이 없어요').waitFor();
|
||||
});
|
||||
|
||||
await step('슈퍼어드민 디스코드 로그인 → 계정 페이지에 관리자 버튼', async () => {
|
||||
await a.goto(ORIGIN + '/me');
|
||||
await a.getByRole('link', { name: '디스코드로 로그인' }).click();
|
||||
await a.waitForURL(`${ORIGIN}/me`);
|
||||
await a.getByRole('button', { name: /관리자 페이지 \(슈퍼어드민\)/ }).click();
|
||||
await a.getByText('지금 접속').waitFor();
|
||||
await a.screenshot({ path: join(SHOTS, 'admin-1-dashboard.png') });
|
||||
});
|
||||
|
||||
await step('게임 설정: 오목 렌주룰 고정 + 한 수 45초 저장', async () => {
|
||||
await a.getByRole('link', { name: '게임' }).click();
|
||||
await a.getByRole('button', { name: /오목/ }).click();
|
||||
await a.getByText('기본 규칙').waitFor();
|
||||
const rule = a.locator('div.stack:has(> div.row > strong:text-is("규칙"))').first();
|
||||
await rule.locator('select').selectOption('renju');
|
||||
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');
|
||||
await a.screenshot({ path: join(SHOTS, 'admin-2-game-editor.png'), fullPage: true });
|
||||
await a.getByRole('button', { name: '저장', exact: true }).click();
|
||||
await a.getByText('저장했어요.').waitFor();
|
||||
});
|
||||
|
||||
await step('관리자 탭: 디스코드 ID로 어드민 추가', async () => {
|
||||
await a.getByRole('link', { name: '관리자' }).click();
|
||||
await a.getByLabel('디스코드 ID(숫자)').fill('222222222222222222');
|
||||
await a.getByLabel('메모(선택)').fill('친구');
|
||||
await a.getByRole('button', { name: '추가', exact: true }).click();
|
||||
await a.getByText('222222222222222222 · 친구').waitFor();
|
||||
await a.screenshot({ path: join(SHOTS, 'admin-3-admins.png') });
|
||||
});
|
||||
|
||||
await step('기록 탭에 변경 내역', async () => {
|
||||
await a.getByRole('link', { name: '기록' }).click();
|
||||
await a.getByText('어드민 추가').waitFor();
|
||||
await a.getByText('게임 설정 변경').waitFor();
|
||||
});
|
||||
|
||||
await step('게스트의 새 오목 방: 렌주룰 · 45초 + 잠금 안내', async () => {
|
||||
const g = await (await browser.newContext({ viewport: { width: 390, height: 844 }, locale: 'ko-KR' })).newPage();
|
||||
g.on('pageerror', (e) => errors.push(e.message));
|
||||
await g.goto(ORIGIN + '/');
|
||||
await g.getByRole('button', { name: '+ 방 만들기' }).click();
|
||||
await g.getByLabel('내 이름(닉네임)').fill('손님');
|
||||
await g.getByRole('button', { name: '이 이름으로 시작하기' }).click();
|
||||
await g.waitForURL(/\/r\/\d{6}$/);
|
||||
await g.getByText('렌주룰 · 15줄 · 한 수 45초').waitFor();
|
||||
await g.getByText('일부 규칙은 관리자가 정해 두어서').waitFor();
|
||||
// Trying to switch to 한국식 is overridden by the server.
|
||||
await g.getByRole('button', { name: '설정 바꾸기' }).click();
|
||||
await g.getByRole('button', { name: '한국식(쌍삼 금지)' }).click();
|
||||
await g.getByRole('button', { name: '저장' }).click();
|
||||
await g.waitForTimeout(300);
|
||||
await g.getByText('렌주룰 · 15줄 · 한 수 45초').waitFor();
|
||||
await g.goto(ORIGIN + '/admin');
|
||||
await g.getByText('권한이 없어요').waitFor();
|
||||
});
|
||||
|
||||
if (errors.length) throw new Error(`page errors: ${errors.join(' | ')}`);
|
||||
} catch (e) {
|
||||
failed = true;
|
||||
console.error(e);
|
||||
} finally {
|
||||
await browser.close();
|
||||
await app.stop();
|
||||
}
|
||||
console.log(failed ? 'E2E FAILED' : 'E2E PASSED');
|
||||
process.exit(failed ? 1 : 0);
|
||||
@@ -19,25 +19,55 @@ const other = (c: Color): Color => (c === 'black' ? 'white' : 'black');
|
||||
const COLOR_KO: Record<Color, string> = { black: '흑', white: '백' };
|
||||
|
||||
export const omokOptionsSchema = z.object({
|
||||
ruleSet: z.enum(['korean', 'renju', 'free']).default('korean'),
|
||||
freeWin: z.enum(['fivePlus', 'exactFive']).default('fivePlus'),
|
||||
boardSize: z.union([z.literal(15), z.literal(19)]).default(15),
|
||||
firstMoveCenter: z.enum(['auto', 'on', 'off']).default('auto'),
|
||||
colorAssignment: z.enum(['random', 'hostBlack', 'hostWhite', 'alternate']).default('random'),
|
||||
ruleSet: z
|
||||
.enum(['korean', 'renju', 'free'])
|
||||
.default('korean')
|
||||
.meta({ title: '규칙', labels: { korean: '한국식(흑백 쌍삼 금지)', renju: '렌주룰(흑만 금수)', free: '자유룰(금수 없음)' } }),
|
||||
freeWin: z
|
||||
.enum(['fivePlus', 'exactFive'])
|
||||
.default('fivePlus')
|
||||
.meta({ title: '자유룰 승리 조건', labels: { fivePlus: '5목 이상', exactFive: '정확히 5목' } }),
|
||||
boardSize: z
|
||||
.union([z.literal(15), z.literal(19)])
|
||||
.default(15)
|
||||
.meta({ title: '판 크기', labels: { 15: '15줄', 19: '19줄' } }),
|
||||
firstMoveCenter: z
|
||||
.enum(['auto', 'on', 'off'])
|
||||
.default('auto')
|
||||
.meta({ title: '흑 첫 수 가운데 강제', labels: { auto: '렌주룰일 때만', on: '항상', off: '안 함' } }),
|
||||
colorAssignment: z
|
||||
.enum(['random', 'hostBlack', 'hostWhite', 'alternate'])
|
||||
.default('random')
|
||||
.meta({ title: '흑백 정하기', labels: { random: '무작위', hostBlack: '방장이 흑', hostWhite: '방장이 백', alternate: '판마다 번갈아' } }),
|
||||
timeControl: z
|
||||
.object({
|
||||
kind: z.enum(['none', 'perMove', 'fischer']).default('perMove'),
|
||||
perMoveSec: z.union([z.literal(10), z.literal(20), z.literal(30), z.literal(60)]).default(30),
|
||||
baseSec: z.number().int().min(60).max(1800).default(300),
|
||||
incSec: z.number().int().min(0).max(30).default(3),
|
||||
kind: z
|
||||
.enum(['none', 'perMove', 'fischer'])
|
||||
.default('perMove')
|
||||
.meta({ title: '시간 방식', labels: { none: '제한 없음', perMove: '한 수당 제한', fischer: '전체 시간 + 수당 추가' } }),
|
||||
perMoveSec: z.number().int().min(5).max(600).default(30).meta({ title: '한 수 제한(초)' }),
|
||||
baseSec: z.number().int().min(60).max(3600).default(300).meta({ title: '전체 시간(초)' }),
|
||||
incSec: z.number().int().min(0).max(60).default(3).meta({ title: '한 수마다 추가(초)' }),
|
||||
})
|
||||
.default({ kind: 'perMove', perMoveSec: 30, baseSec: 300, incSec: 3 }),
|
||||
timeoutPolicy: z.enum(['autoMove', 'lose']).default('autoMove'),
|
||||
idleLimitSec: z.union([z.literal(120), z.literal(300), z.literal(600)]).default(300),
|
||||
undo: z.enum(['off', '1', '3', 'unlimited']).default('3'),
|
||||
drawOffer: z.boolean().default(true),
|
||||
showForbidden: z.boolean().default(true),
|
||||
hints: z.enum(['off', 'warn']).default('warn'),
|
||||
.default({ kind: 'perMove', perMoveSec: 30, baseSec: 300, incSec: 3 })
|
||||
.meta({ title: '시간 제한' }),
|
||||
timeoutPolicy: z
|
||||
.enum(['autoMove', 'lose'])
|
||||
.default('autoMove')
|
||||
.meta({ title: '한 수 시간이 다 되면', labels: { autoMove: '자동으로 두기', lose: '시간패' } }),
|
||||
autoMoveLimit: z.number().int().min(0).max(20).default(2).meta({ title: '자동 착수 허용 횟수(넘으면 시간패)' }),
|
||||
idleLimitSec: z.number().int().min(60).max(3600).default(300).meta({ title: '시간 제한 없음일 때 무응답 한도(초)' }),
|
||||
undo: z
|
||||
.enum(['off', '1', '3', 'unlimited'])
|
||||
.default('3')
|
||||
.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(3).meta({ title: '무승부 다시 제안까지 둬야 하는 수' }),
|
||||
showForbidden: z.boolean().default(true).meta({ title: '금수 자리 표시' }),
|
||||
hints: z
|
||||
.enum(['off', 'warn'])
|
||||
.default('warn')
|
||||
.meta({ title: '위험한 자리 알려 주기', labels: { off: '끄기', warn: '켜기' } }),
|
||||
});
|
||||
export type OmokOptions = z.infer<typeof omokOptionsSchema>;
|
||||
|
||||
@@ -322,8 +352,9 @@ export const omok: GameDefinition<OmokState, OmokAction, OmokView, OmokOptions>
|
||||
if (!s.options.drawOffer) return fail('이 방은 무승부 제안을 쓰지 않아요.');
|
||||
if (s.undo.pending || s.draw.pendingBy) return fail('이미 대기 중인 요청이 있어요.');
|
||||
const last = s.draw.lastOfferPly[me];
|
||||
if (last !== null && s.moves.slice(last).filter((m) => m.color === me).length < 3) {
|
||||
return fail('무승부 제안은 세 수를 더 둔 뒤에 다시 할 수 있어요.');
|
||||
const gap = s.options.drawReofferMoves;
|
||||
if (last !== null && s.moves.slice(last).filter((m) => m.color === me).length < gap) {
|
||||
return fail(`무승부 제안은 ${gap}수를 더 둔 뒤에 다시 할 수 있어요.`);
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
@@ -363,7 +394,7 @@ export const omok: GameDefinition<OmokState, OmokAction, OmokView, OmokOptions>
|
||||
}
|
||||
case 'timeout': {
|
||||
const tc = s.options.timeControl.kind;
|
||||
if (tc === 'perMove' && s.options.timeoutPolicy === 'autoMove' && s.clock.autoMoveStreak[me] < 2) {
|
||||
if (tc === 'perMove' && s.options.timeoutPolicy === 'autoMove' && s.clock.autoMoveStreak[me] < s.options.autoMoveLimit) {
|
||||
s.clock.autoMoveStreak[me]++;
|
||||
const pt = chooseAutoMove(s, rng);
|
||||
events.push({ type: 'timeoutAutoMove', color: me });
|
||||
|
||||
@@ -228,6 +228,21 @@ describe('time', () => {
|
||||
expect(s.result?.reason).toBe('timeLoss');
|
||||
});
|
||||
|
||||
test('autoMoveLimit option: 0 means the first timeout already loses', () => {
|
||||
const s = omok.apply(make({ autoMoveLimit: 0 }), 'A', { type: 'timeout' }, { rng: SeededRng.fromSeed([1, 1, 1, 1]), now: 0 }).state;
|
||||
expect(s.result?.reason).toBe('timeLoss');
|
||||
});
|
||||
|
||||
test('drawReofferMoves option controls the re-offer gap', () => {
|
||||
const rng = SeededRng.fromSeed([1, 1, 1, 1]);
|
||||
let s = play(make({ drawReofferMoves: 1 }), 'H8');
|
||||
s = omok.apply(s, 'A', { type: 'offerDraw' }, { rng, now: 1 }).state;
|
||||
s = omok.apply(s, 'B', { type: 'respondDraw', accept: false }, { rng, now: 2 }).state;
|
||||
expect(omok.validate(s, 'A', { type: 'offerDraw' })).toEqual({ ok: false, reason: '무승부 제안은 1수를 더 둔 뒤에 다시 할 수 있어요.' });
|
||||
s = play(play(s, 'A1'), 'A15');
|
||||
expect(omok.validate(s, 'A', { type: 'offerDraw' }).ok).toBe(true);
|
||||
});
|
||||
|
||||
test('perMove lose policy', () => {
|
||||
const s = omok.apply(make({ timeoutPolicy: 'lose' }), 'A', { type: 'timeout' }, { rng: SeededRng.fromSeed([1, 1, 1, 1]), now: 0 }).state;
|
||||
expect(s.result).toMatchObject({ winner: 'white', reason: 'timeLoss' });
|
||||
|
||||
@@ -11,14 +11,15 @@ export function normalizeNickname(raw: string): string {
|
||||
return raw.normalize('NFC').trim().replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
export function containsProfanity(text: string): boolean {
|
||||
export function containsProfanity(text: string, extra: readonly string[] = []): boolean {
|
||||
const squashed = text.normalize('NFC').toLowerCase().replace(/[\s_.\-*0-9]/g, '');
|
||||
return BANNED.some((w) => squashed.includes(w));
|
||||
return [...BANNED, ...extra.map((w) => w.toLowerCase())].some((w) => w && squashed.includes(w));
|
||||
}
|
||||
|
||||
export function maskProfanity(text: string): string {
|
||||
export function maskProfanity(text: string, extra: readonly string[] = []): string {
|
||||
let out = text;
|
||||
for (const w of BANNED) {
|
||||
for (const w of [...BANNED, ...extra]) {
|
||||
if (!w) continue;
|
||||
out = out.replace(new RegExp(w.split('').map(escapeRe).join('[\\s_.\\-*]*'), 'gi'), (m) => '*'.repeat(m.length));
|
||||
}
|
||||
return out;
|
||||
@@ -29,12 +30,12 @@ function escapeRe(s: string): string {
|
||||
}
|
||||
|
||||
/** Returns a Korean error message, or null when valid. Input must already be normalized. */
|
||||
export function nicknameError(nick: string): string | null {
|
||||
export function nicknameError(nick: string, extraBanned: readonly string[] = []): string | null {
|
||||
const len = [...nick].length;
|
||||
if (len < NICK_MIN) return `닉네임은 ${NICK_MIN}글자 이상이어야 해요.`;
|
||||
if (len > NICK_MAX) return `닉네임은 ${NICK_MAX}글자까지 쓸 수 있어요.`;
|
||||
if (!ALLOWED.test(nick)) return '한글, 영어, 숫자와 _ - . 만 쓸 수 있어요.';
|
||||
if (containsProfanity(nick)) return '다른 닉네임을 써 주세요.';
|
||||
if (containsProfanity(nick, extraBanned)) return '다른 닉네임을 써 주세요.';
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -50,6 +50,8 @@ export interface RoomView {
|
||||
rematchVotes: string[];
|
||||
sessionStats: Record<string, { played: number; wins: number }>;
|
||||
seedHash: string | null;
|
||||
/** Option paths fixed by an admin (hosts cannot change them). */
|
||||
lockedOptions: string[];
|
||||
/** Seed revealed after the game for fairness check. */
|
||||
lastSeed: string | null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user