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:
@@ -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 });
|
||||
|
||||
@@ -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' });
|
||||
|
||||
Reference in New Issue
Block a user