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

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

View File

@@ -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' });