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:
EJClaw
2026-10-04 04:52:38 +09:00
parent b2f998fa95
commit 242ab3ad71
31 changed files with 2009 additions and 68 deletions

View File

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