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

View File

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