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:
@@ -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