feat: 예약 오픈 일정을 빌드 전에 공개
초기화 예약의 공개 선택을 operation payload에 저장하고 로비에서 준비 단계별로 표시합니다. RESERVED 인계와 STOPPED 무요청 경계를 테스트합니다.
This commit is contained in:
@@ -7,7 +7,12 @@ import { gatewayProfileCapabilities } from '@sammo-ts/common';
|
|||||||
import type { GatewayPrisma } from '@sammo-ts/infra';
|
import type { GatewayPrisma } from '@sammo-ts/infra';
|
||||||
|
|
||||||
import { procedure, router } from './trpc.js';
|
import { procedure, router } from './trpc.js';
|
||||||
import { listScenarioPreviews, resolveGitBranchCommitSha, resolveGitCommitSha } from './scenario/scenarioCatalog.js';
|
import {
|
||||||
|
listScenarioPreviews,
|
||||||
|
resolveGitBranchCommitSha,
|
||||||
|
resolveGitCommitSha,
|
||||||
|
type ScenarioPreview,
|
||||||
|
} from './scenario/scenarioCatalog.js';
|
||||||
import type { UserSanctions, UserServerRestriction } from './auth/userRepository.js';
|
import type { UserSanctions, UserServerRestriction } from './auth/userRepository.js';
|
||||||
import { toPublicUser } from './auth/userRepository.js';
|
import { toPublicUser } from './auth/userRepository.js';
|
||||||
import type { AdminAuthContext } from './adminAuth.js';
|
import type { AdminAuthContext } from './adminAuth.js';
|
||||||
@@ -499,6 +504,20 @@ const SYSTEM_PROFILE_RESET_DEFAULTS: z.infer<typeof zProfileResetDefaults> = {
|
|||||||
joinMode: 'full',
|
joinMode: 'full',
|
||||||
autorunUser: null,
|
autorunUser: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const buildResetOtherTextInfo = (install: z.infer<typeof zOperationInstallOptions>): string => {
|
||||||
|
const settings: string[] = [];
|
||||||
|
if (!install.sync) settings.push('시간동기화 없음');
|
||||||
|
if (!install.extend) settings.push('확장 NPC 미포함');
|
||||||
|
if (install.blockGeneralCreate === 1) settings.push('장수 생성 불가');
|
||||||
|
if (install.blockGeneralCreate === 2) settings.push('장수명 무작위');
|
||||||
|
if (install.joinMode === 'onlyRandom') settings.push('랜덤 임관');
|
||||||
|
if (install.showImgLevel !== SYSTEM_PROFILE_RESET_DEFAULTS.showImgLevel) {
|
||||||
|
settings.push(['이미지 표시 안함', '전콘 표시', '전콘/병종 표시'][install.showImgLevel] ?? '이미지 표시');
|
||||||
|
}
|
||||||
|
if (!install.tournamentTrig) settings.push('토너먼트 수동 시작');
|
||||||
|
return settings.join(', ');
|
||||||
|
};
|
||||||
const zSourceMode = z.enum(['BRANCH', 'COMMIT']);
|
const zSourceMode = z.enum(['BRANCH', 'COMMIT']);
|
||||||
const zResetSourceMode = z.enum(['CURRENT', 'BRANCH', 'COMMIT']);
|
const zResetSourceMode = z.enum(['CURRENT', 'BRANCH', 'COMMIT']);
|
||||||
|
|
||||||
@@ -1151,6 +1170,7 @@ export const adminRouter = router({
|
|||||||
sourceRef: z.string().min(1).max(128).optional(),
|
sourceRef: z.string().min(1).max(128).optional(),
|
||||||
install: zOperationInstallOptions,
|
install: zOperationInstallOptions,
|
||||||
scheduledAt: z.string().datetime().optional(),
|
scheduledAt: z.string().datetime().optional(),
|
||||||
|
publishSchedule: z.boolean().optional().default(false),
|
||||||
reason: z.string().max(200).optional(),
|
reason: z.string().max(200).optional(),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
@@ -1176,6 +1196,15 @@ export const adminRouter = router({
|
|||||||
const scheduledAt = input.scheduledAt ? new Date(input.scheduledAt) : null;
|
const scheduledAt = input.scheduledAt ? new Date(input.scheduledAt) : null;
|
||||||
const openAt = input.install.openAt ? new Date(input.install.openAt) : null;
|
const openAt = input.install.openAt ? new Date(input.install.openAt) : null;
|
||||||
const preopenAt = input.install.preopenAt ? new Date(input.install.preopenAt) : null;
|
const preopenAt = input.install.preopenAt ? new Date(input.install.preopenAt) : null;
|
||||||
|
if (
|
||||||
|
input.publishSchedule &&
|
||||||
|
(!input.scheduledAt || !input.install.preopenAt || !input.install.openAt)
|
||||||
|
) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: '로비 일정 공개에는 초기화 시작, 가오픈 시작과 정식 오픈이 모두 필요합니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
if (preopenAt && !openAt) {
|
if (preopenAt && !openAt) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: 'BAD_REQUEST',
|
code: 'BAD_REQUEST',
|
||||||
@@ -1225,6 +1254,7 @@ export const adminRouter = router({
|
|||||||
: 'sourceRef is required.',
|
: 'sourceRef is required.',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
let selectedScenario: ScenarioPreview | undefined;
|
||||||
try {
|
try {
|
||||||
const resolved =
|
const resolved =
|
||||||
sourceMode === 'BRANCH'
|
sourceMode === 'BRANCH'
|
||||||
@@ -1234,7 +1264,8 @@ export const adminRouter = router({
|
|||||||
sourceRef = resolved;
|
sourceRef = resolved;
|
||||||
}
|
}
|
||||||
const scenarios = await listScenarioPreviews({ gitRef: resolved });
|
const scenarios = await listScenarioPreviews({ gitRef: resolved });
|
||||||
if (!scenarios.some((scenario) => scenario.id === input.install.scenarioId)) {
|
selectedScenario = scenarios.find((scenario) => scenario.id === input.install.scenarioId);
|
||||||
|
if (!selectedScenario) {
|
||||||
throw new Error('Scenario not found at source.');
|
throw new Error('Scenario not found at source.');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -1257,6 +1288,24 @@ export const adminRouter = router({
|
|||||||
install: input.install,
|
install: input.install,
|
||||||
requestedSource: input.sourceMode,
|
requestedSource: input.sourceMode,
|
||||||
releaseSource: { mode: sourceMode, ref: sourceRef },
|
releaseSource: { mode: sourceMode, ref: sourceRef },
|
||||||
|
...(input.publishSchedule && selectedScenario
|
||||||
|
? {
|
||||||
|
publicAnnouncement: {
|
||||||
|
enabled: true,
|
||||||
|
scenarioId: selectedScenario.id,
|
||||||
|
scenarioTitle: selectedScenario.title,
|
||||||
|
scheduledAt: input.scheduledAt,
|
||||||
|
preopenAt: input.install.preopenAt,
|
||||||
|
openAt: input.install.openAt,
|
||||||
|
turnTermMinutes: input.install.turnTermMinutes,
|
||||||
|
fictionMode: input.install.fiction === 1 ? '가상' : '사실',
|
||||||
|
npcMode: input.install.npcMode,
|
||||||
|
defaultStatTotal: selectedScenario.defaultStatTotal,
|
||||||
|
otherTextInfo: buildResetOtherTextInfo(input.install),
|
||||||
|
autorunUser: input.install.autorunUser ?? null,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
} as GatewayPrisma.JsonObject,
|
} as GatewayPrisma.JsonObject,
|
||||||
reason: input.reason,
|
reason: input.reason,
|
||||||
requestedBy: adminAuth.user.id,
|
requestedBy: adminAuth.user.id,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { gatewayProfileCapabilities, type GatewayProfileCapabilities } from '@sammo-ts/common';
|
import { gatewayProfileCapabilities, type GatewayProfileCapabilities } from '@sammo-ts/common';
|
||||||
import type { GatewayOrchestratorHandle } from '../orchestrator/gatewayOrchestrator.js';
|
import type { GatewayOrchestratorHandle } from '../orchestrator/gatewayOrchestrator.js';
|
||||||
import type {
|
import type {
|
||||||
|
GatewayOperationRecord,
|
||||||
GatewayProfileRecord,
|
GatewayProfileRecord,
|
||||||
GatewayProfileRepository,
|
GatewayProfileRepository,
|
||||||
GatewayProfileStatus,
|
GatewayProfileStatus,
|
||||||
@@ -19,6 +20,27 @@ export type LobbyGeneralStatus = {
|
|||||||
updatedAt: string | null;
|
updatedAt: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const PUBLIC_AUTORUN_OPTIONS = ['develop', 'warp', 'recruit', 'recruit_high', 'train', 'battle', 'chief'] as const;
|
||||||
|
type PublicAutorunOption = (typeof PUBLIC_AUTORUN_OPTIONS)[number];
|
||||||
|
|
||||||
|
export type LobbyUpcomingReset = {
|
||||||
|
phase: 'SCHEDULED' | 'PREPARING' | 'READY' | 'DELAYED';
|
||||||
|
scheduledAt: string;
|
||||||
|
preopenAt: string;
|
||||||
|
openAt: string;
|
||||||
|
scenarioId: number;
|
||||||
|
scenarioTitle: string;
|
||||||
|
turnTermMinutes: number;
|
||||||
|
fictionMode: string;
|
||||||
|
npcMode: number;
|
||||||
|
defaultStatTotal: number;
|
||||||
|
otherTextInfo: string;
|
||||||
|
autorunUser: {
|
||||||
|
limitMinutes: number;
|
||||||
|
options: PublicAutorunOption[];
|
||||||
|
} | null;
|
||||||
|
};
|
||||||
|
|
||||||
export type LobbyProfileStatus = {
|
export type LobbyProfileStatus = {
|
||||||
profileName: string;
|
profileName: string;
|
||||||
profile: string;
|
profile: string;
|
||||||
@@ -38,6 +60,7 @@ export type LobbyProfileStatus = {
|
|||||||
battleSimRunning: boolean;
|
battleSimRunning: boolean;
|
||||||
tournamentRunning: boolean;
|
tournamentRunning: boolean;
|
||||||
};
|
};
|
||||||
|
upcomingReset?: LobbyUpcomingReset | null;
|
||||||
korName: string;
|
korName: string;
|
||||||
color: string;
|
color: string;
|
||||||
};
|
};
|
||||||
@@ -67,14 +90,32 @@ export class InMemoryProfileStatusService implements GatewayProfileStatusService
|
|||||||
export class RepositoryProfileStatusService implements GatewayProfileStatusService {
|
export class RepositoryProfileStatusService implements GatewayProfileStatusService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly profiles: GatewayProfileRepository,
|
private readonly profiles: GatewayProfileRepository,
|
||||||
private readonly orchestrator: GatewayOrchestratorHandle
|
private readonly orchestrator: GatewayOrchestratorHandle,
|
||||||
|
private readonly now: () => Date = () => new Date()
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async listLobbyProfiles(): Promise<LobbyProfileStatus[]> {
|
async listLobbyProfiles(): Promise<LobbyProfileStatus[]> {
|
||||||
const rows = orderGatewayProfiles(await this.profiles.listProfiles());
|
const [profileRows, recentResetOperations] = await Promise.all([
|
||||||
|
this.profiles.listProfiles(),
|
||||||
|
this.profiles.listOperations({
|
||||||
|
statuses: ['QUEUED', 'RUNNING', 'SUCCEEDED'],
|
||||||
|
types: ['RESET'],
|
||||||
|
limit: 200,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const rows = orderGatewayProfiles(profileRows);
|
||||||
const runtimeStates = await this.orchestrator.listRuntimeStates(rows.map((profile) => profile.profileName));
|
const runtimeStates = await this.orchestrator.listRuntimeStates(rows.map((profile) => profile.profileName));
|
||||||
const runtimeMap = new Map(runtimeStates.map((state) => [state.profileName, state]));
|
const runtimeMap = new Map(runtimeStates.map((state) => [state.profileName, state]));
|
||||||
return rows.map((row) => this.mapProfile(row, runtimeMap));
|
const announcementMap = new Map<string, LobbyUpcomingReset>();
|
||||||
|
const profileStatusMap = new Map(rows.map((row) => [row.profileName, row.status]));
|
||||||
|
const now = this.now();
|
||||||
|
for (const operation of recentResetOperations) {
|
||||||
|
if (announcementMap.has(operation.profileName)) continue;
|
||||||
|
if (!shouldExposeUpcomingReset(operation, profileStatusMap.get(operation.profileName))) continue;
|
||||||
|
const announcement = resolveUpcomingResetAnnouncement(operation, now);
|
||||||
|
if (announcement) announcementMap.set(operation.profileName, announcement);
|
||||||
|
}
|
||||||
|
return rows.map((row) => this.mapProfile(row, runtimeMap, announcementMap));
|
||||||
}
|
}
|
||||||
|
|
||||||
private mapProfile(
|
private mapProfile(
|
||||||
@@ -88,7 +129,8 @@ export class RepositoryProfileStatusService implements GatewayProfileStatusServi
|
|||||||
battleSimRunning: boolean;
|
battleSimRunning: boolean;
|
||||||
tournamentRunning: boolean;
|
tournamentRunning: boolean;
|
||||||
}
|
}
|
||||||
>
|
>,
|
||||||
|
announcementMap: Map<string, LobbyUpcomingReset>
|
||||||
): LobbyProfileStatus {
|
): LobbyProfileStatus {
|
||||||
const meta = row.meta;
|
const meta = row.meta;
|
||||||
return {
|
return {
|
||||||
@@ -110,8 +152,107 @@ export class RepositoryProfileStatusService implements GatewayProfileStatusServi
|
|||||||
battleSimRunning: false,
|
battleSimRunning: false,
|
||||||
tournamentRunning: false,
|
tournamentRunning: false,
|
||||||
},
|
},
|
||||||
|
upcomingReset: announcementMap.get(row.profileName) ?? null,
|
||||||
korName: resolveGatewayProfileKoreanName(row.profile, meta.korName),
|
korName: resolveGatewayProfileKoreanName(row.profile, meta.korName),
|
||||||
color: (meta.color as string | undefined) ?? '#ffffff',
|
color: (meta.color as string | undefined) ?? '#ffffff',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const asRecord = (value: unknown): Record<string, unknown> | null =>
|
||||||
|
value !== null && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : null;
|
||||||
|
|
||||||
|
const readDateTime = (value: unknown): string | null =>
|
||||||
|
typeof value === 'string' && Number.isFinite(new Date(value).getTime()) ? value : null;
|
||||||
|
|
||||||
|
const readFiniteNumber = (value: unknown): number | null =>
|
||||||
|
typeof value === 'number' && Number.isFinite(value) ? value : null;
|
||||||
|
|
||||||
|
const readAutorun = (value: unknown): LobbyUpcomingReset['autorunUser'] | undefined => {
|
||||||
|
if (value === null) return null;
|
||||||
|
const autorun = asRecord(value);
|
||||||
|
const limitMinutes = readFiniteNumber(autorun?.limitMinutes);
|
||||||
|
if (!autorun || !Number.isInteger(limitMinutes) || (limitMinutes ?? 0) <= 0 || !Array.isArray(autorun.options)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const allowed = new Set<string>(PUBLIC_AUTORUN_OPTIONS);
|
||||||
|
if (
|
||||||
|
!autorun.options.every(
|
||||||
|
(option): option is PublicAutorunOption => typeof option === 'string' && allowed.has(option)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
limitMinutes: limitMinutes as number,
|
||||||
|
options: [...autorun.options],
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const shouldExposeUpcomingReset = (
|
||||||
|
operation: GatewayOperationRecord,
|
||||||
|
profileStatus: GatewayProfileStatus | undefined
|
||||||
|
): boolean =>
|
||||||
|
operation.type === 'RESET' &&
|
||||||
|
(operation.status === 'QUEUED' ||
|
||||||
|
operation.status === 'RUNNING' ||
|
||||||
|
(operation.status === 'SUCCEEDED' && profileStatus === 'RESERVED'));
|
||||||
|
|
||||||
|
export const resolveUpcomingResetAnnouncement = (
|
||||||
|
operation: GatewayOperationRecord,
|
||||||
|
now: Date
|
||||||
|
): LobbyUpcomingReset | null => {
|
||||||
|
if (operation.type !== 'RESET' || !['QUEUED', 'RUNNING', 'SUCCEEDED'].includes(operation.status)) return null;
|
||||||
|
const payload = asRecord(operation.payload);
|
||||||
|
const announcement = asRecord(payload?.publicAnnouncement);
|
||||||
|
if (!announcement || announcement.enabled !== true) return null;
|
||||||
|
|
||||||
|
const scheduledAt = readDateTime(announcement.scheduledAt);
|
||||||
|
const preopenAt = readDateTime(announcement.preopenAt);
|
||||||
|
const openAt = readDateTime(announcement.openAt);
|
||||||
|
const scenarioId = readFiniteNumber(announcement.scenarioId);
|
||||||
|
const turnTermMinutes = readFiniteNumber(announcement.turnTermMinutes);
|
||||||
|
const npcMode = readFiniteNumber(announcement.npcMode);
|
||||||
|
const defaultStatTotal = readFiniteNumber(announcement.defaultStatTotal);
|
||||||
|
const autorunUser = readAutorun(announcement.autorunUser);
|
||||||
|
if (
|
||||||
|
!scheduledAt ||
|
||||||
|
!preopenAt ||
|
||||||
|
!openAt ||
|
||||||
|
!Number.isInteger(scenarioId) ||
|
||||||
|
!Number.isInteger(turnTermMinutes) ||
|
||||||
|
!Number.isInteger(npcMode) ||
|
||||||
|
!Number.isInteger(defaultStatTotal) ||
|
||||||
|
typeof announcement.scenarioTitle !== 'string' ||
|
||||||
|
!announcement.scenarioTitle.trim() ||
|
||||||
|
typeof announcement.fictionMode !== 'string' ||
|
||||||
|
typeof announcement.otherTextInfo !== 'string' ||
|
||||||
|
autorunUser === undefined
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nowMs = now.getTime();
|
||||||
|
const phase =
|
||||||
|
nowMs >= new Date(preopenAt).getTime()
|
||||||
|
? 'DELAYED'
|
||||||
|
: operation.status === 'SUCCEEDED'
|
||||||
|
? 'READY'
|
||||||
|
: operation.status === 'RUNNING' || nowMs >= new Date(scheduledAt).getTime()
|
||||||
|
? 'PREPARING'
|
||||||
|
: 'SCHEDULED';
|
||||||
|
return {
|
||||||
|
phase,
|
||||||
|
scheduledAt,
|
||||||
|
preopenAt,
|
||||||
|
openAt,
|
||||||
|
scenarioId: scenarioId as number,
|
||||||
|
scenarioTitle: announcement.scenarioTitle.trim(),
|
||||||
|
turnTermMinutes: turnTermMinutes as number,
|
||||||
|
fictionMode: announcement.fictionMode,
|
||||||
|
npcMode: npcMode as number,
|
||||||
|
defaultStatTotal: defaultStatTotal as number,
|
||||||
|
otherTextInfo: announcement.otherTextInfo,
|
||||||
|
autorunUser,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|||||||
@@ -158,7 +158,12 @@ export interface GatewayProfileRepository {
|
|||||||
updateLastError(profileName: string, lastError: string | null): Promise<void>;
|
updateLastError(profileName: string, lastError: string | null): Promise<void>;
|
||||||
updateWorkspaceUsage(profileName: string, workspace: string, lastUsedAt: string): Promise<void>;
|
updateWorkspaceUsage(profileName: string, workspace: string, lastUsedAt: string): Promise<void>;
|
||||||
clearWorkspaceUsage(profileNames: string[]): Promise<void>;
|
clearWorkspaceUsage(profileNames: string[]): Promise<void>;
|
||||||
listOperations(options?: { profileName?: string; limit?: number }): Promise<GatewayOperationRecord[]>;
|
listOperations(options?: {
|
||||||
|
profileName?: string;
|
||||||
|
statuses?: GatewayOperationStatus[];
|
||||||
|
types?: GatewayOperationType[];
|
||||||
|
limit?: number;
|
||||||
|
}): Promise<GatewayOperationRecord[]>;
|
||||||
listActiveOperationProfileNames?(now: Date): Promise<string[]>;
|
listActiveOperationProfileNames?(now: Date): Promise<string[]>;
|
||||||
getOperation(id: string): Promise<GatewayOperationRecord | null>;
|
getOperation(id: string): Promise<GatewayOperationRecord | null>;
|
||||||
listOperationLogs(id: string, afterCursor?: string, limit?: number): Promise<GatewayOperationLogRecord[]>;
|
listOperationLogs(id: string, afterCursor?: string, limit?: number): Promise<GatewayOperationLogRecord[]>;
|
||||||
@@ -546,9 +551,21 @@ export const createGatewayProfileRepository = (prisma: GatewayPrismaClient): Gat
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
async listOperations(options?: { profileName?: string; limit?: number }): Promise<GatewayOperationRecord[]> {
|
async listOperations(options?: {
|
||||||
|
profileName?: string;
|
||||||
|
statuses?: GatewayOperationStatus[];
|
||||||
|
types?: GatewayOperationType[];
|
||||||
|
limit?: number;
|
||||||
|
}): Promise<GatewayOperationRecord[]> {
|
||||||
const rows = await prisma.gatewayOperation.findMany({
|
const rows = await prisma.gatewayOperation.findMany({
|
||||||
where: options?.profileName ? { profileName: options.profileName } : undefined,
|
where:
|
||||||
|
options?.profileName || options?.statuses?.length || options?.types?.length
|
||||||
|
? {
|
||||||
|
...(options.profileName ? { profileName: options.profileName } : {}),
|
||||||
|
...(options.statuses?.length ? { status: { in: options.statuses } } : {}),
|
||||||
|
...(options.types?.length ? { type: { in: options.types } } : {}),
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
take: Math.min(Math.max(options?.limit ?? 50, 1), 200),
|
take: Math.min(Math.max(options?.limit ?? 50, 1), 200),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -734,13 +734,30 @@ describe('admin operation API', () => {
|
|||||||
sourceMode: 'COMMIT',
|
sourceMode: 'COMMIT',
|
||||||
sourceRef: 'HEAD',
|
sourceRef: 'HEAD',
|
||||||
scheduledAt: '2099-01-01T00:00:00.000Z',
|
scheduledAt: '2099-01-01T00:00:00.000Z',
|
||||||
|
publishSchedule: true,
|
||||||
install,
|
install,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(harness.createdInputs[0]).toMatchObject({
|
expect(harness.createdInputs[0]).toMatchObject({
|
||||||
type: 'RESET',
|
type: 'RESET',
|
||||||
scheduledAt: '2099-01-01T00:00:00.000Z',
|
scheduledAt: '2099-01-01T00:00:00.000Z',
|
||||||
payload: { install },
|
payload: {
|
||||||
|
install,
|
||||||
|
publicAnnouncement: {
|
||||||
|
enabled: true,
|
||||||
|
scenarioId: 1010,
|
||||||
|
scenarioTitle: expect.any(String),
|
||||||
|
scheduledAt: '2099-01-01T00:00:00.000Z',
|
||||||
|
preopenAt: install.preopenAt,
|
||||||
|
openAt: install.openAt,
|
||||||
|
turnTermMinutes: 60,
|
||||||
|
fictionMode: '가상',
|
||||||
|
npcMode: 0,
|
||||||
|
defaultStatTotal: expect.any(Number),
|
||||||
|
otherTextInfo: expect.any(String),
|
||||||
|
autorunUser: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
@@ -755,6 +772,20 @@ describe('admin operation API', () => {
|
|||||||
code: 'BAD_REQUEST',
|
code: 'BAD_REQUEST',
|
||||||
message: 'preopenAt cannot be earlier than scheduledAt.',
|
message: 'preopenAt cannot be earlier than scheduledAt.',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
harness.caller.admin.operations.requestReset({
|
||||||
|
profileName: 'che:2',
|
||||||
|
sourceMode: 'COMMIT',
|
||||||
|
sourceRef: 'HEAD',
|
||||||
|
scheduledAt: '2099-01-01T00:00:00.000Z',
|
||||||
|
publishSchedule: true,
|
||||||
|
install: { ...install, preopenAt: undefined },
|
||||||
|
})
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: '로비 일정 공개에는 초기화 시작, 가오픈 시작과 정식 오픈이 모두 필요합니다.',
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns validated profile reset defaults to a scenario-only operator', async () => {
|
it('returns validated profile reset defaults to a scenario-only operator', async () => {
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { resolveUpcomingResetAnnouncement, shouldExposeUpcomingReset } from '../src/lobby/profileStatusService.js';
|
||||||
|
import type { GatewayOperationRecord } from '../src/orchestrator/profileRepository.js';
|
||||||
|
|
||||||
|
const buildOperation = (status: GatewayOperationRecord['status'] = 'QUEUED'): GatewayOperationRecord => ({
|
||||||
|
id: '11111111-1111-4111-8111-111111111111',
|
||||||
|
profileName: 'che:2',
|
||||||
|
type: 'RESET',
|
||||||
|
status,
|
||||||
|
sourceMode: 'BRANCH',
|
||||||
|
sourceRef: 'private/source-ref',
|
||||||
|
payload: {
|
||||||
|
install: { scenarioId: 1010 },
|
||||||
|
requestedSource: 'CURRENT',
|
||||||
|
publicAnnouncement: {
|
||||||
|
enabled: true,
|
||||||
|
scenarioId: 1010,
|
||||||
|
scenarioTitle: '황건적의 난',
|
||||||
|
scheduledAt: '2026-08-27T05:00:00.000Z',
|
||||||
|
preopenAt: '2026-08-27T05:30:00.000Z',
|
||||||
|
openAt: '2026-08-27T11:00:00.000Z',
|
||||||
|
turnTermMinutes: 60,
|
||||||
|
fictionMode: '가상',
|
||||||
|
npcMode: 1,
|
||||||
|
defaultStatTotal: 70,
|
||||||
|
otherTextInfo: '랜덤 임관',
|
||||||
|
autorunUser: {
|
||||||
|
limitMinutes: 1440,
|
||||||
|
options: ['develop', 'battle'],
|
||||||
|
},
|
||||||
|
requestedBy: 'must-not-leak',
|
||||||
|
reason: 'must-not-leak',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
reason: 'private reason',
|
||||||
|
requestedBy: 'admin-id',
|
||||||
|
scheduledAt: '2026-08-27T05:00:00.000Z',
|
||||||
|
error: 'private error',
|
||||||
|
createdAt: '2026-08-23T00:00:00.000Z',
|
||||||
|
updatedAt: '2026-08-23T00:00:00.000Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolveUpcomingResetAnnouncement', () => {
|
||||||
|
it('projects only the public snapshot while the delayed build is queued', () => {
|
||||||
|
const result = resolveUpcomingResetAnnouncement(buildOperation(), new Date('2026-08-27T04:00:00.000Z'));
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
phase: 'SCHEDULED',
|
||||||
|
scheduledAt: '2026-08-27T05:00:00.000Z',
|
||||||
|
preopenAt: '2026-08-27T05:30:00.000Z',
|
||||||
|
openAt: '2026-08-27T11:00:00.000Z',
|
||||||
|
scenarioId: 1010,
|
||||||
|
scenarioTitle: '황건적의 난',
|
||||||
|
turnTermMinutes: 60,
|
||||||
|
fictionMode: '가상',
|
||||||
|
npcMode: 1,
|
||||||
|
defaultStatTotal: 70,
|
||||||
|
otherTextInfo: '랜덤 임관',
|
||||||
|
autorunUser: {
|
||||||
|
limitMinutes: 1440,
|
||||||
|
options: ['develop', 'battle'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(result).not.toHaveProperty('sourceRef');
|
||||||
|
expect(result).not.toHaveProperty('requestedBy');
|
||||||
|
expect(result).not.toHaveProperty('reason');
|
||||||
|
expect(result).not.toHaveProperty('error');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moves from preparation to a truthful delay state without changing the profile lifecycle', () => {
|
||||||
|
expect(
|
||||||
|
resolveUpcomingResetAnnouncement(buildOperation('RUNNING'), new Date('2026-08-27T05:10:00.000Z'))
|
||||||
|
).toMatchObject({ phase: 'PREPARING' });
|
||||||
|
expect(
|
||||||
|
resolveUpcomingResetAnnouncement(buildOperation('RUNNING'), new Date('2026-08-27T05:31:00.000Z'))
|
||||||
|
).toMatchObject({ phase: 'DELAYED' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a completed build ready for RESERVED handoff and removes cancelled or failed announcements', () => {
|
||||||
|
const succeeded = buildOperation('SUCCEEDED');
|
||||||
|
expect(resolveUpcomingResetAnnouncement(succeeded, new Date('2026-08-27T05:20:00.000Z'))).toMatchObject({
|
||||||
|
phase: 'READY',
|
||||||
|
});
|
||||||
|
expect(shouldExposeUpcomingReset(succeeded, 'RESERVED')).toBe(true);
|
||||||
|
expect(shouldExposeUpcomingReset(succeeded, 'PREOPEN')).toBe(false);
|
||||||
|
expect(shouldExposeUpcomingReset(succeeded, 'RUNNING')).toBe(false);
|
||||||
|
for (const status of ['CANCELLED', 'FAILED'] as const) {
|
||||||
|
expect(
|
||||||
|
resolveUpcomingResetAnnouncement(buildOperation(status), new Date('2026-08-27T04:00:00.000Z'))
|
||||||
|
).toBeNull();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails closed for an unpublished or incomplete snapshot', () => {
|
||||||
|
const unpublished = buildOperation();
|
||||||
|
unpublished.payload = { publicAnnouncement: { enabled: false } };
|
||||||
|
expect(resolveUpcomingResetAnnouncement(unpublished, new Date('2026-08-27T04:00:00.000Z'))).toBeNull();
|
||||||
|
|
||||||
|
const incomplete = buildOperation();
|
||||||
|
incomplete.payload = { publicAnnouncement: { enabled: true, scenarioTitle: '황건적의 난' } };
|
||||||
|
expect(resolveUpcomingResetAnnouncement(incomplete, new Date('2026-08-27T04:00:00.000Z'))).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -63,8 +63,25 @@ type LobbyFixtureOptions = {
|
|||||||
limitMinutes: number;
|
limitMinutes: number;
|
||||||
options: string[];
|
options: string[];
|
||||||
} | null;
|
} | null;
|
||||||
|
upcomingReset?: {
|
||||||
|
phase: 'SCHEDULED' | 'PREPARING' | 'READY' | 'DELAYED';
|
||||||
|
scheduledAt: string;
|
||||||
|
preopenAt: string;
|
||||||
|
openAt: string;
|
||||||
|
scenarioId: number;
|
||||||
|
scenarioTitle: string;
|
||||||
|
turnTermMinutes: number;
|
||||||
|
fictionMode: string;
|
||||||
|
npcMode: number;
|
||||||
|
defaultStatTotal: number;
|
||||||
|
otherTextInfo: string;
|
||||||
|
autorunUser: {
|
||||||
|
limitMinutes: number;
|
||||||
|
options: string[];
|
||||||
|
} | null;
|
||||||
|
} | null;
|
||||||
lobbyBundleFailures?: number;
|
lobbyBundleFailures?: number;
|
||||||
profileStatus?: 'RUNNING' | 'PREOPEN' | 'PAUSED' | 'COMPLETED' | 'STOPPED';
|
profileStatus?: 'RUNNING' | 'PREOPEN' | 'PAUSED' | 'COMPLETED' | 'STOPPED' | 'RESERVED';
|
||||||
includeStoppedProfile?: boolean;
|
includeStoppedProfile?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -98,10 +115,12 @@ const installFixture = async (page: Page, options: LobbyFixtureOptions = {}) =>
|
|||||||
korName = 'hwe',
|
korName = 'hwe',
|
||||||
otherTextInfo = '',
|
otherTextInfo = '',
|
||||||
autorunUser = null,
|
autorunUser = null,
|
||||||
|
upcomingReset = null,
|
||||||
lobbyBundleFailures = 0,
|
lobbyBundleFailures = 0,
|
||||||
profileStatus = 'RUNNING',
|
profileStatus = 'RUNNING',
|
||||||
includeStoppedProfile = false,
|
includeStoppedProfile = false,
|
||||||
} = options;
|
} = options;
|
||||||
|
const runtimeAvailable = ['RUNNING', 'PREOPEN', 'PAUSED', 'COMPLETED'].includes(profileStatus);
|
||||||
let remainingLobbyBundleFailures = lobbyBundleFailures;
|
let remainingLobbyBundleFailures = lobbyBundleFailures;
|
||||||
const gameOperations: Array<{ operation: string; authorization: string | undefined }> = [];
|
const gameOperations: Array<{ operation: string; authorization: string | undefined }> = [];
|
||||||
if (authenticated) {
|
if (authenticated) {
|
||||||
@@ -139,22 +158,23 @@ const installFixture = async (page: Page, options: LobbyFixtureOptions = {}) =>
|
|||||||
scenario: '903',
|
scenario: '903',
|
||||||
status: profileStatus,
|
status: profileStatus,
|
||||||
lifecycle: {
|
lifecycle: {
|
||||||
runtimeExpected: profileStatus !== 'STOPPED',
|
runtimeExpected: runtimeAvailable,
|
||||||
userAccessible: profileStatus !== 'STOPPED',
|
userAccessible: runtimeAvailable,
|
||||||
turnsRunning: profileStatus === 'RUNNING',
|
turnsRunning: profileStatus === 'RUNNING',
|
||||||
operatorResumable: profileStatus === 'PAUSED' || profileStatus === 'STOPPED',
|
operatorResumable: profileStatus === 'PAUSED' || profileStatus === 'STOPPED',
|
||||||
dataInitialized: true,
|
dataInitialized: true,
|
||||||
},
|
},
|
||||||
apiPort: 15015,
|
apiPort: 15015,
|
||||||
runtime: {
|
runtime: {
|
||||||
apiRunning: true,
|
apiRunning: runtimeAvailable,
|
||||||
daemonRunning: true,
|
daemonRunning: runtimeAvailable,
|
||||||
auctionRunning: true,
|
auctionRunning: runtimeAvailable,
|
||||||
battleSimRunning: true,
|
battleSimRunning: runtimeAvailable,
|
||||||
tournamentRunning: true,
|
tournamentRunning: runtimeAvailable,
|
||||||
},
|
},
|
||||||
korName,
|
korName,
|
||||||
color: '#ffffff',
|
color: '#ffffff',
|
||||||
|
upcomingReset,
|
||||||
localAccountPolicy: {
|
localAccountPolicy: {
|
||||||
accessAllowed: true,
|
accessAllowed: true,
|
||||||
canCreateGeneral,
|
canCreateGeneral,
|
||||||
@@ -482,6 +502,114 @@ test('does not contact a STOPPED game runtime and labels it inaccessible', async
|
|||||||
await page.screenshot({ path: testInfo.outputPath('gateway-stopped-profile-lobby.png'), fullPage: true });
|
await page.screenshot({ path: testInfo.outputPath('gateway-stopped-profile-lobby.png'), fullPage: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('shows a complete upcoming reset announcement without contacting the stopped runtime', async ({
|
||||||
|
page,
|
||||||
|
}, testInfo) => {
|
||||||
|
const gameOperations = await installFixture(page, {
|
||||||
|
profileStatus: 'STOPPED',
|
||||||
|
korName: '체',
|
||||||
|
upcomingReset: {
|
||||||
|
phase: 'SCHEDULED',
|
||||||
|
scheduledAt: '2026-08-27T05:00:00.000Z',
|
||||||
|
preopenAt: '2026-08-27T05:30:00.000Z',
|
||||||
|
openAt: '2026-08-27T11:00:00.000Z',
|
||||||
|
scenarioId: 1010,
|
||||||
|
scenarioTitle: '【가상】황건적의 난',
|
||||||
|
turnTermMinutes: 60,
|
||||||
|
fictionMode: '가상',
|
||||||
|
npcMode: 1,
|
||||||
|
defaultStatTotal: 70,
|
||||||
|
otherTextInfo: '랜덤 임관',
|
||||||
|
autorunUser: {
|
||||||
|
limitMinutes: 1440,
|
||||||
|
options: ['develop', 'battle'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await page.setViewportSize({ width: 1200, height: 900 });
|
||||||
|
|
||||||
|
await page.goto('lobby');
|
||||||
|
const row = page.locator('tbody tr').filter({ hasText: '체섭' });
|
||||||
|
const announcement = row.getByTestId('upcoming-reset-announcement');
|
||||||
|
const autorun = announcement.locator('.copyable-autorun');
|
||||||
|
const tooltip = announcement.locator('.copyable-autorun-detail');
|
||||||
|
await expect(row.getByTestId('upcoming-reset-phase')).toHaveText('오픈 예정 · 빌드 대기');
|
||||||
|
await expect(row.getByTestId('upcoming-reset-scheduled-at')).toHaveText('- 초기화 시작 : 2026-08-27 14:00:00 -');
|
||||||
|
await expect(row.getByTestId('upcoming-reset-preopen-at')).toHaveText('- 가오픈 일시 : 2026-08-27 14:30:00 -');
|
||||||
|
await expect(row.getByTestId('upcoming-reset-open-at')).toHaveText('- 오픈 일시 : 2026-08-27 20:00:00 -');
|
||||||
|
await expect(row.getByTestId('upcoming-reset-scenario-announcement')).toHaveText(
|
||||||
|
'【가상】황건적의 난 60분 턴 서버'
|
||||||
|
);
|
||||||
|
expect((await announcement.textContent())?.replace(/\s+/g, ' ')).toContain(
|
||||||
|
'(상성 설정:가상), (빙의 여부:가능), (최대 스탯:70), (기타 설정:랜덤 임관, 자율행동[내정, 출병, 24시간 유효])'
|
||||||
|
);
|
||||||
|
await expect(row).not.toContainText('서버 중지 · 접근 불가');
|
||||||
|
await expect(row.getByRole('button', { name: '입장' })).toHaveCount(0);
|
||||||
|
await expect(page.getByRole('tab', { name: 'hwe섭' })).toHaveCount(0);
|
||||||
|
expect(gameOperations).toEqual([]);
|
||||||
|
|
||||||
|
const desktopGeometry = await announcement.evaluate((element) => ({
|
||||||
|
announcement: element.getBoundingClientRect().toJSON(),
|
||||||
|
cell: element.parentElement?.getBoundingClientRect().toJSON(),
|
||||||
|
documentWidth: document.documentElement.scrollWidth,
|
||||||
|
viewportWidth: window.innerWidth,
|
||||||
|
}));
|
||||||
|
expect(desktopGeometry.announcement.left).toBeGreaterThanOrEqual(desktopGeometry.cell?.left ?? 0);
|
||||||
|
expect(desktopGeometry.announcement.right).toBeLessThanOrEqual(desktopGeometry.cell?.right ?? 0);
|
||||||
|
await autorun.hover();
|
||||||
|
await expect(tooltip).toBeVisible();
|
||||||
|
await autorun.focus();
|
||||||
|
await expect(autorun).toBeFocused();
|
||||||
|
await expect(autorun).toHaveCSS('outline-width', '2px');
|
||||||
|
await page.screenshot({ path: testInfo.outputPath('gateway-upcoming-reset-desktop.png'), fullPage: true });
|
||||||
|
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
await announcement.scrollIntoViewIfNeeded();
|
||||||
|
const mobileGeometry = await announcement.evaluate((element) => {
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
left: rect.left,
|
||||||
|
right: rect.right,
|
||||||
|
documentWidth: document.documentElement.scrollWidth,
|
||||||
|
viewportWidth: window.innerWidth,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(mobileGeometry.left).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(mobileGeometry.right).toBeLessThanOrEqual(mobileGeometry.viewportWidth);
|
||||||
|
expect(mobileGeometry.documentWidth).toBe(mobileGeometry.viewportWidth);
|
||||||
|
await autorun.hover();
|
||||||
|
await expect(tooltip).toBeVisible();
|
||||||
|
await page.screenshot({ path: testInfo.outputPath('gateway-upcoming-reset-mobile.png'), fullPage: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps the announcement through the RESERVED handoff after the build completes', async ({ page }) => {
|
||||||
|
const gameOperations = await installFixture(page, {
|
||||||
|
profileStatus: 'RESERVED',
|
||||||
|
upcomingReset: {
|
||||||
|
phase: 'READY',
|
||||||
|
scheduledAt: '2026-08-27T05:00:00.000Z',
|
||||||
|
preopenAt: '2026-08-27T05:30:00.000Z',
|
||||||
|
openAt: '2026-08-27T11:00:00.000Z',
|
||||||
|
scenarioId: 1010,
|
||||||
|
scenarioTitle: '【가상】황건적의 난',
|
||||||
|
turnTermMinutes: 60,
|
||||||
|
fictionMode: '가상',
|
||||||
|
npcMode: 1,
|
||||||
|
defaultStatTotal: 70,
|
||||||
|
otherTextInfo: '',
|
||||||
|
autorunUser: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto('lobby');
|
||||||
|
const row = page.locator('tbody tr').filter({ hasText: 'hwe섭' });
|
||||||
|
await expect(row.getByTestId('upcoming-reset-phase')).toHaveText('오픈 준비 완료 · 가오픈 대기');
|
||||||
|
await expect(row.getByTestId('upcoming-reset-scenario-title')).toHaveText('【가상】황건적의 난');
|
||||||
|
await expect(row).not.toContainText('준 비 중 · 접근 불가');
|
||||||
|
await expect(row.getByRole('button', { name: '입장' })).toHaveCount(0);
|
||||||
|
expect(gameOperations).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
test('automatically recovers profile details after a transient update outage', async ({ page }) => {
|
test('automatically recovers profile details after a transient update outage', async ({ page }) => {
|
||||||
const gameOperations = await installFixture(page, { lobbyBundleFailures: 1 });
|
const gameOperations = await installFixture(page, { lobbyBundleFailures: 1 });
|
||||||
|
|
||||||
|
|||||||
@@ -604,6 +604,19 @@ test('separates branch and commit semantics and submits a reset from the dedicat
|
|||||||
await page.getByTestId('reset-scheduled-at').fill('2030-08-13T09:30');
|
await page.getByTestId('reset-scheduled-at').fill('2030-08-13T09:30');
|
||||||
await page.getByTestId('reset-preopen-at').fill('2030-08-13T10:00');
|
await page.getByTestId('reset-preopen-at').fill('2030-08-13T10:00');
|
||||||
await page.getByTestId('reset-open-at').fill('2030-08-13T11:00');
|
await page.getByTestId('reset-open-at').fill('2030-08-13T11:00');
|
||||||
|
const publishSchedule = page.getByTestId('reset-publish-schedule');
|
||||||
|
await publishSchedule.check();
|
||||||
|
await page.getByTestId('reset-open-at').focus();
|
||||||
|
await page.keyboard.press('Tab');
|
||||||
|
await publishSchedule.focus();
|
||||||
|
await expect(publishSchedule).toBeFocused();
|
||||||
|
const publishFocusStyle = await publishSchedule.evaluate((element) => {
|
||||||
|
const style = getComputedStyle(element);
|
||||||
|
return { style: style.outlineStyle, width: Number.parseFloat(style.outlineWidth) };
|
||||||
|
});
|
||||||
|
expect(publishFocusStyle.style).not.toBe('none');
|
||||||
|
expect(publishFocusStyle.width).toBeGreaterThanOrEqual(2);
|
||||||
|
await expect(page.getByText('빌드는 초기화 시작 시각까지 대기합니다.')).toBeVisible();
|
||||||
const scheduledHelp = page.getByTestId('reset-help-scheduled-at');
|
const scheduledHelp = page.getByTestId('reset-help-scheduled-at');
|
||||||
await scheduledHelp.hover();
|
await scheduledHelp.hover();
|
||||||
await expect(page.getByTestId('reset-help-scheduled-at-tooltip')).toContainText(
|
await expect(page.getByTestId('reset-help-scheduled-at-tooltip')).toContainText(
|
||||||
@@ -664,6 +677,7 @@ test('separates branch and commit semantics and submits a reset from the dedicat
|
|||||||
expect(JSON.stringify(resetRequest?.body)).toContain('0123456789abcdef0123456789abcdef01234567');
|
expect(JSON.stringify(resetRequest?.body)).toContain('0123456789abcdef0123456789abcdef01234567');
|
||||||
expect(JSON.stringify(resetRequest?.body)).toContain('"scenarioId":5');
|
expect(JSON.stringify(resetRequest?.body)).toContain('"scenarioId":5');
|
||||||
expect(JSON.stringify(resetRequest?.body)).toContain('"scheduledAt":"2030-08-13T00:30:00.000Z"');
|
expect(JSON.stringify(resetRequest?.body)).toContain('"scheduledAt":"2030-08-13T00:30:00.000Z"');
|
||||||
|
expect(JSON.stringify(resetRequest?.body)).toContain('"publishSchedule":true');
|
||||||
expect(JSON.stringify(resetRequest?.body)).toContain('"preopenAt":"2030-08-13T01:00:00.000Z"');
|
expect(JSON.stringify(resetRequest?.body)).toContain('"preopenAt":"2030-08-13T01:00:00.000Z"');
|
||||||
expect(JSON.stringify(resetRequest?.body)).toContain('"openAt":"2030-08-13T02:00:00.000Z"');
|
expect(JSON.stringify(resetRequest?.body)).toContain('"openAt":"2030-08-13T02:00:00.000Z"');
|
||||||
await page.screenshot({ path: testInfo.outputPath('reset-operation-log-desktop.png'), fullPage: true });
|
await page.screenshot({ path: testInfo.outputPath('reset-operation-log-desktop.png'), fullPage: true });
|
||||||
@@ -681,6 +695,20 @@ test('separates branch and commit semantics and submits a reset from the dedicat
|
|||||||
return children;
|
return children;
|
||||||
});
|
});
|
||||||
expect(mobileGeometry[0]!.width).toBeLessThanOrEqual(390);
|
expect(mobileGeometry[0]!.width).toBeLessThanOrEqual(390);
|
||||||
|
const mobilePublishGeometry = await publishSchedule.evaluate((element) => {
|
||||||
|
const label = element.closest('label');
|
||||||
|
if (!label) throw new Error('expected publish schedule label');
|
||||||
|
const rect = label.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
left: rect.left,
|
||||||
|
right: rect.right,
|
||||||
|
documentWidth: document.documentElement.scrollWidth,
|
||||||
|
viewportWidth: window.innerWidth,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(mobilePublishGeometry.left).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(mobilePublishGeometry.right).toBeLessThanOrEqual(mobilePublishGeometry.viewportWidth);
|
||||||
|
expect(mobilePublishGeometry.documentWidth).toBe(mobilePublishGeometry.viewportWidth);
|
||||||
const mobileTabs = await page
|
const mobileTabs = await page
|
||||||
.getByTestId('server-profile-tabs')
|
.getByTestId('server-profile-tabs')
|
||||||
.locator('a')
|
.locator('a')
|
||||||
|
|||||||
@@ -111,8 +111,7 @@ const formatAnnouncementDate = (value: string | null | undefined): string =>
|
|||||||
const profileScenarioTitle = (profileName: string): string =>
|
const profileScenarioTitle = (profileName: string): string =>
|
||||||
profileDetails.value[profileName]?.scenarioTitle.trim() || '-';
|
profileDetails.value[profileName]?.scenarioTitle.trim() || '-';
|
||||||
const npcModeText = (mode: number): string => ['불가', '가능', '선택 생성'][mode] ?? '불가';
|
const npcModeText = (mode: number): string => ['불가', '가능', '선택 생성'][mode] ?? '불가';
|
||||||
const autorunDetailText = (info: LobbyInfo): string => {
|
const autorunDetailText = (autorun: LobbyInfo['autorunUser']): string => {
|
||||||
const autorun = info.autorunUser;
|
|
||||||
if (!autorun) return '';
|
if (!autorun) return '';
|
||||||
|
|
||||||
const enabled = new Set(autorun.options);
|
const enabled = new Set(autorun.options);
|
||||||
@@ -134,8 +133,14 @@ const autorunDetailText = (info: LobbyInfo): string => {
|
|||||||
labels.push(limit);
|
labels.push(limit);
|
||||||
return labels.join(', ');
|
return labels.join(', ');
|
||||||
};
|
};
|
||||||
const autorunTooltipId = (profileName: string): string =>
|
const autorunTooltipId = (profileName: string, scope = 'current'): string =>
|
||||||
`profile-autorun-${profileName.replaceAll(/[^a-zA-Z0-9_-]/g, '-')}`;
|
'profile-autorun-' + scope + '-' + profileName.replaceAll(/[^a-zA-Z0-9_-]/g, '-');
|
||||||
|
const upcomingResetPhaseText = (profile: LobbyProfile): string => {
|
||||||
|
if (profile.upcomingReset?.phase === 'DELAYED') return '준비 지연 · 일정 확인 중';
|
||||||
|
if (profile.upcomingReset?.phase === 'READY') return '오픈 준비 완료 · 가오픈 대기';
|
||||||
|
if (profile.upcomingReset?.phase === 'PREPARING') return '오픈 준비 중';
|
||||||
|
return '오픈 예정 · 빌드 대기';
|
||||||
|
};
|
||||||
const isProfileRuntimeAvailable = (profile: LobbyProfile): boolean => profile.lifecycle.userAccessible;
|
const isProfileRuntimeAvailable = (profile: LobbyProfile): boolean => profile.lifecycle.userAccessible;
|
||||||
const unavailableProfileText = (profile: LobbyProfile): string => {
|
const unavailableProfileText = (profile: LobbyProfile): string => {
|
||||||
if (!profile.lifecycle.dataInitialized) return '- DB 초기화 전 · 접근 불가 -';
|
if (!profile.lifecycle.dataInitialized) return '- DB 초기화 전 · 접근 불가 -';
|
||||||
@@ -503,8 +508,75 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
|
|||||||
|
|
||||||
<!-- Server Info -->
|
<!-- Server Info -->
|
||||||
<td class="profile-info-cell px-4 py-4 border-r border-zinc-800">
|
<td class="profile-info-cell px-4 py-4 border-r border-zinc-800">
|
||||||
|
<div
|
||||||
|
v-if="profile.upcomingReset"
|
||||||
|
class="upcoming-reset-announcement"
|
||||||
|
data-testid="upcoming-reset-announcement"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="upcoming-reset-phase"
|
||||||
|
:class="{ 'is-delayed': profile.upcomingReset.phase === 'DELAYED' }"
|
||||||
|
data-testid="upcoming-reset-phase"
|
||||||
|
>
|
||||||
|
{{ upcomingResetPhaseText(profile) }}
|
||||||
|
</div>
|
||||||
|
<div data-testid="upcoming-reset-scheduled-at">
|
||||||
|
- 초기화 시작 :
|
||||||
|
{{ formatAnnouncementDate(profile.upcomingReset.scheduledAt) }} -
|
||||||
|
</div>
|
||||||
|
<div data-testid="upcoming-reset-preopen-at">
|
||||||
|
- 가오픈 일시 :
|
||||||
|
{{ formatAnnouncementDate(profile.upcomingReset.preopenAt) }} -
|
||||||
|
</div>
|
||||||
|
<div data-testid="upcoming-reset-open-at">
|
||||||
|
- 오픈 일시 : {{ formatAnnouncementDate(profile.upcomingReset.openAt) }} -
|
||||||
|
</div>
|
||||||
|
<div data-testid="upcoming-reset-scenario-announcement">
|
||||||
|
<span class="text-orange-400" data-testid="upcoming-reset-scenario-title">
|
||||||
|
{{ profile.upcomingReset.scenarioTitle }} </span
|
||||||
|
>{{ ' ' }}
|
||||||
|
<span class="text-green-400">
|
||||||
|
{{ profile.upcomingReset.turnTermMinutes }}분 턴 서버
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="profile-announcement-settings text-xs text-zinc-500">
|
||||||
|
(상성 설정:{{ profile.upcomingReset.fictionMode }}), (빙의 여부:{{
|
||||||
|
npcModeText(profile.upcomingReset.npcMode)
|
||||||
|
}}), (최대 스탯:{{ profile.upcomingReset.defaultStatTotal }}), (기타
|
||||||
|
설정:<template v-if="profile.upcomingReset.otherTextInfo"
|
||||||
|
>{{ profile.upcomingReset.otherTextInfo
|
||||||
|
}}<template v-if="profile.upcomingReset.autorunUser"
|
||||||
|
>,
|
||||||
|
</template></template
|
||||||
|
><span
|
||||||
|
v-if="profile.upcomingReset.autorunUser"
|
||||||
|
class="copyable-autorun"
|
||||||
|
tabindex="0"
|
||||||
|
:aria-describedby="autorunTooltipId(profile.profileName, 'upcoming')"
|
||||||
|
>자율행동<span
|
||||||
|
:id="autorunTooltipId(profile.profileName, 'upcoming')"
|
||||||
|
class="copyable-autorun-detail"
|
||||||
|
role="tooltip"
|
||||||
|
><span class="copyable-autorun-bracket">[</span
|
||||||
|
><span>{{
|
||||||
|
autorunDetailText(profile.upcomingReset.autorunUser)
|
||||||
|
}}</span
|
||||||
|
><span class="copyable-autorun-bracket">]</span></span
|
||||||
|
></span
|
||||||
|
><template
|
||||||
|
v-if="
|
||||||
|
!profile.upcomingReset.otherTextInfo &&
|
||||||
|
!profile.upcomingReset.autorunUser
|
||||||
|
"
|
||||||
|
>없음</template
|
||||||
|
>)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<template v-if="profileDetails[profile.profileName]">
|
<template v-if="profileDetails[profile.profileName]">
|
||||||
<div class="space-y-1">
|
<div
|
||||||
|
class="space-y-1"
|
||||||
|
:class="{ 'mt-3 border-t border-zinc-800 pt-3': profile.upcomingReset }"
|
||||||
|
>
|
||||||
<template v-if="profile.status === 'PREOPEN'">
|
<template v-if="profile.status === 'PREOPEN'">
|
||||||
<div
|
<div
|
||||||
v-if="profileDetails[profile.profileName]?.preopenAt"
|
v-if="profileDetails[profile.profileName]?.preopenAt"
|
||||||
@@ -586,7 +658,9 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
|
|||||||
role="tooltip"
|
role="tooltip"
|
||||||
><span class="copyable-autorun-bracket">[</span
|
><span class="copyable-autorun-bracket">[</span
|
||||||
><span>{{
|
><span>{{
|
||||||
autorunDetailText(profileDetails[profile.profileName]!)
|
autorunDetailText(
|
||||||
|
profileDetails[profile.profileName]!.autorunUser
|
||||||
|
)
|
||||||
}}</span
|
}}</span
|
||||||
><span class="copyable-autorun-bracket">]</span></span
|
><span class="copyable-autorun-bracket">]</span></span
|
||||||
></span
|
></span
|
||||||
@@ -594,7 +668,7 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="!isProfileRuntimeAvailable(profile)">
|
<template v-else-if="!isProfileRuntimeAvailable(profile) && !profile.upcomingReset">
|
||||||
<div class="text-center text-zinc-600 py-2">
|
<div class="text-center text-zinc-600 py-2">
|
||||||
{{ unavailableProfileText(profile) }}
|
{{ unavailableProfileText(profile) }}
|
||||||
</div>
|
</div>
|
||||||
@@ -929,6 +1003,23 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
|
|||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.upcoming-reset-announcement {
|
||||||
|
border-left: 2px solid #f59e0b;
|
||||||
|
padding-left: 10px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upcoming-reset-phase {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
color: #fbbf24;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upcoming-reset-phase.is-delayed {
|
||||||
|
color: #fca5a5;
|
||||||
|
}
|
||||||
|
|
||||||
.copyable-autorun {
|
.copyable-autorun {
|
||||||
position: relative;
|
position: relative;
|
||||||
cursor: help;
|
cursor: help;
|
||||||
|
|||||||
@@ -152,6 +152,7 @@ const form = reactive({
|
|||||||
openAt: '',
|
openAt: '',
|
||||||
preopenAt: '',
|
preopenAt: '',
|
||||||
scheduledAt: '',
|
scheduledAt: '',
|
||||||
|
publishSchedule: false,
|
||||||
reason: '',
|
reason: '',
|
||||||
});
|
});
|
||||||
const RESET_AUTORUN_FORM_KEYS = {
|
const RESET_AUTORUN_FORM_KEYS = {
|
||||||
@@ -653,12 +654,16 @@ const requestReset = async () => {
|
|||||||
errorMessage.value = '초기화 소스와 시나리오를 먼저 선택해주세요.';
|
errorMessage.value = '초기화 소스와 시나리오를 먼저 선택해주세요.';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (form.publishSchedule && (!form.scheduledAt || !form.preopenAt || !form.openAt)) {
|
||||||
|
errorMessage.value = '로비 일정 공개에는 초기화 시작, 가오픈 시작과 정식 오픈을 모두 입력해주세요.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
const scenarioId = form.scenarioId;
|
const scenarioId = form.scenarioId;
|
||||||
const sourceLabel =
|
const sourceLabel =
|
||||||
form.sourceMode === 'CURRENT' ? '서버 지정 버전' : form.sourceMode === 'BRANCH' ? '브랜치' : '커밋';
|
form.sourceMode === 'CURRENT' ? '서버 지정 버전' : form.sourceMode === 'BRANCH' ? '브랜치' : '커밋';
|
||||||
if (
|
if (
|
||||||
!window.confirm(
|
!window.confirm(
|
||||||
`${selectedProfileName.value}의 게임 DB를 초기화합니다.\n${sourceLabel}${form.sourceMode === 'CURRENT' ? '' : `: ${form.sourceRef}`}\n시나리오: ${scenarioId}`
|
`${selectedProfileName.value}의 게임 DB를 초기화합니다.\n${sourceLabel}${form.sourceMode === 'CURRENT' ? '' : `: ${form.sourceRef}`}\n시나리오: ${scenarioId}${form.publishSchedule ? '\n예약 등록 즉시 로비에 오픈 일정을 공개합니다.' : ''}`
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
@@ -670,6 +675,7 @@ const requestReset = async () => {
|
|||||||
sourceMode: form.sourceMode,
|
sourceMode: form.sourceMode,
|
||||||
sourceRef: form.sourceMode === 'CURRENT' ? undefined : form.sourceRef.trim(),
|
sourceRef: form.sourceMode === 'CURRENT' ? undefined : form.sourceRef.trim(),
|
||||||
scheduledAt: toIso(form.scheduledAt),
|
scheduledAt: toIso(form.scheduledAt),
|
||||||
|
publishSchedule: form.publishSchedule,
|
||||||
reason: form.reason.trim() || undefined,
|
reason: form.reason.trim() || undefined,
|
||||||
install: {
|
install: {
|
||||||
scenarioId,
|
scenarioId,
|
||||||
@@ -1384,6 +1390,23 @@ onBeforeUnmount(() => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<label
|
||||||
|
class="flex cursor-pointer items-start gap-3 rounded border border-zinc-800 bg-zinc-950/50 px-3 py-2 text-sm text-zinc-200"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-model="form.publishSchedule"
|
||||||
|
type="checkbox"
|
||||||
|
class="publish-schedule-checkbox mt-0.5 h-4 w-4 accent-amber-500"
|
||||||
|
data-testid="reset-publish-schedule"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<span class="block font-medium">예약 등록 즉시 로비에 오픈 일정 공개</span>
|
||||||
|
<span class="mt-0.5 block text-xs leading-5 text-zinc-500">
|
||||||
|
빌드는 초기화 시작 시각까지 대기합니다. 공개하려면 위 세 시각을 모두 입력해야
|
||||||
|
합니다.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
@@ -1859,3 +1882,10 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
</AdminConsoleLayout>
|
</AdminConsoleLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.publish-schedule-checkbox:focus-visible {
|
||||||
|
outline: 2px solid #fcd34d;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user