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:
@@ -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()) {
|
||||
|
||||
Reference in New Issue
Block a user