From 14ffa76c497f6ace2e1c0a66cf4db7614a28df21 Mon Sep 17 00:00:00 2001 From: hided62 Date: Sun, 23 Aug 2026 13:59:31 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=98=88=EC=95=BD=20=EC=98=A4=ED=94=88?= =?UTF-8?q?=20=EC=9D=BC=EC=A0=95=EC=9D=84=20=EB=B9=8C=EB=93=9C=20=EC=A0=84?= =?UTF-8?q?=EC=97=90=20=EA=B3=B5=EA=B0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 초기화 예약의 공개 선택을 operation payload에 저장하고 로비에서 준비 단계별로 표시합니다. RESERVED 인계와 STOPPED 무요청 경계를 테스트합니다. --- app/gateway-api/src/adminRouter.ts | 53 ++++++- .../src/lobby/profileStatusService.ts | 149 +++++++++++++++++- .../src/orchestrator/profileRepository.ts | 23 ++- app/gateway-api/test/adminOperations.test.ts | 33 +++- .../test/profileStatusService.test.ts | 104 ++++++++++++ .../e2e/lobby-game-auth.spec.ts | 144 ++++++++++++++++- .../e2e/server-operations.spec.ts | 28 ++++ app/gateway-frontend/src/views/LobbyView.vue | 105 +++++++++++- .../src/views/ServerOperationsView.vue | 32 +++- 9 files changed, 645 insertions(+), 26 deletions(-) create mode 100644 app/gateway-api/test/profileStatusService.test.ts diff --git a/app/gateway-api/src/adminRouter.ts b/app/gateway-api/src/adminRouter.ts index dfd67bf6..99451b11 100644 --- a/app/gateway-api/src/adminRouter.ts +++ b/app/gateway-api/src/adminRouter.ts @@ -7,7 +7,12 @@ import { gatewayProfileCapabilities } from '@sammo-ts/common'; import type { GatewayPrisma } from '@sammo-ts/infra'; 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 { toPublicUser } from './auth/userRepository.js'; import type { AdminAuthContext } from './adminAuth.js'; @@ -499,6 +504,20 @@ const SYSTEM_PROFILE_RESET_DEFAULTS: z.infer = { joinMode: 'full', autorunUser: null, }; + +const buildResetOtherTextInfo = (install: z.infer): 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 zResetSourceMode = z.enum(['CURRENT', 'BRANCH', 'COMMIT']); @@ -1151,6 +1170,7 @@ export const adminRouter = router({ sourceRef: z.string().min(1).max(128).optional(), install: zOperationInstallOptions, scheduledAt: z.string().datetime().optional(), + publishSchedule: z.boolean().optional().default(false), reason: z.string().max(200).optional(), }) ) @@ -1176,6 +1196,15 @@ export const adminRouter = router({ const scheduledAt = input.scheduledAt ? new Date(input.scheduledAt) : null; const openAt = input.install.openAt ? new Date(input.install.openAt) : 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) { throw new TRPCError({ code: 'BAD_REQUEST', @@ -1225,6 +1254,7 @@ export const adminRouter = router({ : 'sourceRef is required.', }); } + let selectedScenario: ScenarioPreview | undefined; try { const resolved = sourceMode === 'BRANCH' @@ -1234,7 +1264,8 @@ export const adminRouter = router({ sourceRef = 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.'); } } catch (error) { @@ -1257,6 +1288,24 @@ export const adminRouter = router({ install: input.install, requestedSource: input.sourceMode, 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, reason: input.reason, requestedBy: adminAuth.user.id, diff --git a/app/gateway-api/src/lobby/profileStatusService.ts b/app/gateway-api/src/lobby/profileStatusService.ts index 31cd3cb3..ed2591e7 100644 --- a/app/gateway-api/src/lobby/profileStatusService.ts +++ b/app/gateway-api/src/lobby/profileStatusService.ts @@ -1,6 +1,7 @@ import { gatewayProfileCapabilities, type GatewayProfileCapabilities } from '@sammo-ts/common'; import type { GatewayOrchestratorHandle } from '../orchestrator/gatewayOrchestrator.js'; import type { + GatewayOperationRecord, GatewayProfileRecord, GatewayProfileRepository, GatewayProfileStatus, @@ -19,6 +20,27 @@ export type LobbyGeneralStatus = { 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 = { profileName: string; profile: string; @@ -38,6 +60,7 @@ export type LobbyProfileStatus = { battleSimRunning: boolean; tournamentRunning: boolean; }; + upcomingReset?: LobbyUpcomingReset | null; korName: string; color: string; }; @@ -67,14 +90,32 @@ export class InMemoryProfileStatusService implements GatewayProfileStatusService export class RepositoryProfileStatusService implements GatewayProfileStatusService { constructor( private readonly profiles: GatewayProfileRepository, - private readonly orchestrator: GatewayOrchestratorHandle + private readonly orchestrator: GatewayOrchestratorHandle, + private readonly now: () => Date = () => new Date() ) {} async listLobbyProfiles(): Promise { - 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 runtimeMap = new Map(runtimeStates.map((state) => [state.profileName, state])); - return rows.map((row) => this.mapProfile(row, runtimeMap)); + const announcementMap = new Map(); + 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( @@ -88,7 +129,8 @@ export class RepositoryProfileStatusService implements GatewayProfileStatusServi battleSimRunning: boolean; tournamentRunning: boolean; } - > + >, + announcementMap: Map ): LobbyProfileStatus { const meta = row.meta; return { @@ -110,8 +152,107 @@ export class RepositoryProfileStatusService implements GatewayProfileStatusServi battleSimRunning: false, tournamentRunning: false, }, + upcomingReset: announcementMap.get(row.profileName) ?? null, korName: resolveGatewayProfileKoreanName(row.profile, meta.korName), color: (meta.color as string | undefined) ?? '#ffffff', }; } } + +const asRecord = (value: unknown): Record | null => + value !== null && typeof value === 'object' && !Array.isArray(value) ? (value as Record) : 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(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, + }; +}; diff --git a/app/gateway-api/src/orchestrator/profileRepository.ts b/app/gateway-api/src/orchestrator/profileRepository.ts index f4b3f720..081a1811 100644 --- a/app/gateway-api/src/orchestrator/profileRepository.ts +++ b/app/gateway-api/src/orchestrator/profileRepository.ts @@ -158,7 +158,12 @@ export interface GatewayProfileRepository { updateLastError(profileName: string, lastError: string | null): Promise; updateWorkspaceUsage(profileName: string, workspace: string, lastUsedAt: string): Promise; clearWorkspaceUsage(profileNames: string[]): Promise; - listOperations(options?: { profileName?: string; limit?: number }): Promise; + listOperations(options?: { + profileName?: string; + statuses?: GatewayOperationStatus[]; + types?: GatewayOperationType[]; + limit?: number; + }): Promise; listActiveOperationProfileNames?(now: Date): Promise; getOperation(id: string): Promise; listOperationLogs(id: string, afterCursor?: string, limit?: number): Promise; @@ -546,9 +551,21 @@ export const createGatewayProfileRepository = (prisma: GatewayPrismaClient): Gat }, }); }, - async listOperations(options?: { profileName?: string; limit?: number }): Promise { + async listOperations(options?: { + profileName?: string; + statuses?: GatewayOperationStatus[]; + types?: GatewayOperationType[]; + limit?: number; + }): Promise { 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' }, take: Math.min(Math.max(options?.limit ?? 50, 1), 200), }); diff --git a/app/gateway-api/test/adminOperations.test.ts b/app/gateway-api/test/adminOperations.test.ts index 025c10a9..b5b6d826 100644 --- a/app/gateway-api/test/adminOperations.test.ts +++ b/app/gateway-api/test/adminOperations.test.ts @@ -734,13 +734,30 @@ describe('admin operation API', () => { sourceMode: 'COMMIT', sourceRef: 'HEAD', scheduledAt: '2099-01-01T00:00:00.000Z', + publishSchedule: true, install, }); expect(harness.createdInputs[0]).toMatchObject({ type: 'RESET', 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( @@ -755,6 +772,20 @@ describe('admin operation API', () => { code: 'BAD_REQUEST', 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 () => { diff --git a/app/gateway-api/test/profileStatusService.test.ts b/app/gateway-api/test/profileStatusService.test.ts new file mode 100644 index 00000000..c79deaa1 --- /dev/null +++ b/app/gateway-api/test/profileStatusService.test.ts @@ -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(); + }); +}); diff --git a/app/gateway-frontend/e2e/lobby-game-auth.spec.ts b/app/gateway-frontend/e2e/lobby-game-auth.spec.ts index 35597a2f..d9aa1fad 100644 --- a/app/gateway-frontend/e2e/lobby-game-auth.spec.ts +++ b/app/gateway-frontend/e2e/lobby-game-auth.spec.ts @@ -63,8 +63,25 @@ type LobbyFixtureOptions = { limitMinutes: number; options: string[]; } | 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; - profileStatus?: 'RUNNING' | 'PREOPEN' | 'PAUSED' | 'COMPLETED' | 'STOPPED'; + profileStatus?: 'RUNNING' | 'PREOPEN' | 'PAUSED' | 'COMPLETED' | 'STOPPED' | 'RESERVED'; includeStoppedProfile?: boolean; }; @@ -98,10 +115,12 @@ const installFixture = async (page: Page, options: LobbyFixtureOptions = {}) => korName = 'hwe', otherTextInfo = '', autorunUser = null, + upcomingReset = null, lobbyBundleFailures = 0, profileStatus = 'RUNNING', includeStoppedProfile = false, } = options; + const runtimeAvailable = ['RUNNING', 'PREOPEN', 'PAUSED', 'COMPLETED'].includes(profileStatus); let remainingLobbyBundleFailures = lobbyBundleFailures; const gameOperations: Array<{ operation: string; authorization: string | undefined }> = []; if (authenticated) { @@ -139,22 +158,23 @@ const installFixture = async (page: Page, options: LobbyFixtureOptions = {}) => scenario: '903', status: profileStatus, lifecycle: { - runtimeExpected: profileStatus !== 'STOPPED', - userAccessible: profileStatus !== 'STOPPED', + runtimeExpected: runtimeAvailable, + userAccessible: runtimeAvailable, turnsRunning: profileStatus === 'RUNNING', operatorResumable: profileStatus === 'PAUSED' || profileStatus === 'STOPPED', dataInitialized: true, }, apiPort: 15015, runtime: { - apiRunning: true, - daemonRunning: true, - auctionRunning: true, - battleSimRunning: true, - tournamentRunning: true, + apiRunning: runtimeAvailable, + daemonRunning: runtimeAvailable, + auctionRunning: runtimeAvailable, + battleSimRunning: runtimeAvailable, + tournamentRunning: runtimeAvailable, }, korName, color: '#ffffff', + upcomingReset, localAccountPolicy: { accessAllowed: true, 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 }); }); +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 }) => { const gameOperations = await installFixture(page, { lobbyBundleFailures: 1 }); diff --git a/app/gateway-frontend/e2e/server-operations.spec.ts b/app/gateway-frontend/e2e/server-operations.spec.ts index c9ed73c1..f48a20e2 100644 --- a/app/gateway-frontend/e2e/server-operations.spec.ts +++ b/app/gateway-frontend/e2e/server-operations.spec.ts @@ -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-preopen-at').fill('2030-08-13T10: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'); await scheduledHelp.hover(); 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('"scenarioId":5'); 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('"openAt":"2030-08-13T02:00:00.000Z"'); 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; }); 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 .getByTestId('server-profile-tabs') .locator('a') diff --git a/app/gateway-frontend/src/views/LobbyView.vue b/app/gateway-frontend/src/views/LobbyView.vue index 5a4ebf38..4b8a5205 100644 --- a/app/gateway-frontend/src/views/LobbyView.vue +++ b/app/gateway-frontend/src/views/LobbyView.vue @@ -111,8 +111,7 @@ const formatAnnouncementDate = (value: string | null | undefined): string => const profileScenarioTitle = (profileName: string): string => profileDetails.value[profileName]?.scenarioTitle.trim() || '-'; const npcModeText = (mode: number): string => ['불가', '가능', '선택 생성'][mode] ?? '불가'; -const autorunDetailText = (info: LobbyInfo): string => { - const autorun = info.autorunUser; +const autorunDetailText = (autorun: LobbyInfo['autorunUser']): string => { if (!autorun) return ''; const enabled = new Set(autorun.options); @@ -134,8 +133,14 @@ const autorunDetailText = (info: LobbyInfo): string => { labels.push(limit); return labels.join(', '); }; -const autorunTooltipId = (profileName: string): string => - `profile-autorun-${profileName.replaceAll(/[^a-zA-Z0-9_-]/g, '-')}`; +const autorunTooltipId = (profileName: string, scope = 'current'): string => + '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 unavailableProfileText = (profile: LobbyProfile): string => { if (!profile.lifecycle.dataInitialized) return '- DB 초기화 전 · 접근 불가 -'; @@ -503,8 +508,75 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => { +
+
+ {{ upcomingResetPhaseText(profile) }} +
+
+ - 초기화 시작 : + {{ formatAnnouncementDate(profile.upcomingReset.scheduledAt) }} - +
+
+ - 가오픈 일시 : + {{ formatAnnouncementDate(profile.upcomingReset.preopenAt) }} - +
+
+ - 오픈 일시 : {{ formatAnnouncementDate(profile.upcomingReset.openAt) }} - +
+
+ + {{ profile.upcomingReset.scenarioTitle }} {{ ' ' }} + + {{ profile.upcomingReset.turnTermMinutes }}분 턴 서버 + +
+
+ (상성 설정:{{ profile.upcomingReset.fictionMode }}), (빙의 여부:{{ + npcModeText(profile.upcomingReset.npcMode) + }}), (최대 스탯:{{ profile.upcomingReset.defaultStatTotal }}), (기타 + 설정:자율행동[{{ + autorunDetailText(profile.upcomingReset.autorunUser) + }}]) +
+