From f8fb570dac9b647bd63db6c71316de148d12878a Mon Sep 17 00:00:00 2001 From: hided62 Date: Mon, 24 Aug 2026 01:55:31 +0000 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=EC=98=A4=ED=94=84=EB=84=88?= =?UTF-8?q?=EC=9D=98=20=EC=B2=9C=ED=95=98=ED=86=B5=EC=9D=BC=20=EC=84=9C?= =?UTF-8?q?=EB=B2=84=20=EC=A0=95=EB=A6=AC=EB=A5=BC=20=ED=97=88=EC=9A=A9?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 시나리오 초기화 권한 범위에서 실제 game DB의 통일 상태를 재검증한 뒤 완료 서버만 STOPPED로 정리한다. 런타임 전체 권한은 확대하지 않고 DB와 완료 기록을 보존한다. --- app/gateway-api/src/adminCapabilities.ts | 4 +- app/gateway-api/src/adminRouter.ts | 25 ++++++ .../src/orchestrator/gatewayOrchestrator.ts | 3 + app/gateway-api/test/adminOperations.test.ts | 60 ++++++++++++++ .../e2e/admin-runtime-actions.spec.ts | 79 +++++++++++++++++-- app/gateway-frontend/src/views/AdminView.vue | 44 +++++++++-- 6 files changed, 203 insertions(+), 12 deletions(-) diff --git a/app/gateway-api/src/adminCapabilities.ts b/app/gateway-api/src/adminCapabilities.ts index 85d7bdb8..71e7ca88 100644 --- a/app/gateway-api/src/adminCapabilities.ts +++ b/app/gateway-api/src/adminCapabilities.ts @@ -62,7 +62,8 @@ export const ADMIN_CAPABILITIES: readonly AdminCapabilityDefinition[] = [ { permission: 'admin.scenarios.reset', label: '시나리오 초기화', - description: '지정 profile의 현재 배포 버전으로 게임 DB와 시나리오를 초기화합니다.', + description: + '지정 profile의 현재 배포 버전으로 게임 DB와 시나리오를 초기화하고, 천하통일 서버를 닫아 정리합니다.', risk: 'CRITICAL', scope: 'PROFILE', }, @@ -123,6 +124,7 @@ export const resolveAdminActionCapability = (path: string, rawInput?: unknown): if (path.endsWith('.profiles.requestAction') && rawInput && typeof rawInput === 'object') { const action = (rawInput as { action?: unknown }).action; if (action === 'RESET_SCHEDULED') return 'admin.reset.schedule'; + if (action === 'CLOSE_COMPLETED') return 'admin.scenarios.reset'; if (action === 'RESUME') return 'admin.resume.when-stopped'; if (action === 'UPDATE_RUNTIME_SETTINGS') return 'admin.profiles.runtime'; if (action === 'OPEN_SURVEY') return 'admin.survey.open'; diff --git a/app/gateway-api/src/adminRouter.ts b/app/gateway-api/src/adminRouter.ts index 99451b11..4307d71a 100644 --- a/app/gateway-api/src/adminRouter.ts +++ b/app/gateway-api/src/adminRouter.ts @@ -45,6 +45,7 @@ const zServerAction = z.enum([ 'RESUME', 'PAUSE', 'STOP', + 'CLOSE_COMPLETED', 'ACCELERATE', 'DELAY', 'UPDATE_RUNTIME_SETTINGS', @@ -2227,6 +2228,7 @@ export const adminRouter = router({ canManageProfiles || hasScopedPermission(adminAuth, ROLE_RESUME_WHEN_STOPPED, profile.profileName); const canOpenSurvey = canManageProfiles || hasScopedPermission(adminAuth, ROLE_SURVEY_OPEN, profile.profileName); + const canResetScenario = hasScopedPermission(adminAuth, ROLE_ADMIN_SCENARIO_RESET, profile.profileName); if (input.action === 'RESUME') { if (profile.status !== 'STOPPED' && profile.status !== 'PAUSED') { @@ -2257,6 +2259,28 @@ export const adminRouter = router({ code: 'BAD_REQUEST', message: 'Stop is allowed only while the profile runtime is available.', }); + } else if (input.action === 'CLOSE_COMPLETED') { + if (!canResetScenario) { + throw new TRPCError({ + code: 'FORBIDDEN', + message: 'Scenario reset permission is required.', + }); + } + if (!gatewayProfileCapabilities(profile.status).runtimeExpected) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'Completed cleanup is allowed only while the profile runtime is available.', + }); + } + const [runtimeSettings] = + (await ctx.orchestrator.listRuntimeSettings?.([profile.profileName])) ?? []; + const isUnited = profile.status === 'COMPLETED' || Number(runtimeSettings?.isUnited ?? 0) !== 0; + if (!isUnited) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'Only a unified game can be closed by a scenario opener.', + }); + } } else if (input.action === 'OPEN_SURVEY') { if (!canOpenSurvey) { throw new TRPCError({ @@ -2323,6 +2347,7 @@ export const adminRouter = router({ RESUME: 'RUNNING', PAUSE: 'PAUSED', STOP: 'STOPPED', + CLOSE_COMPLETED: 'STOPPED', SHUTDOWN: 'DISABLED', } as const; const mappedStatus = statusMap[input.action as keyof typeof statusMap]; diff --git a/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts b/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts index c29fd7bc..522f1021 100644 --- a/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts +++ b/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts @@ -111,6 +111,8 @@ export interface ProfileRuntimeSnapshot extends ProfileRuntimeState { export interface ProfileRuntimeSettingsSnapshot { profileName: string; + /** Ref game_env.isunited compatibility value. Any non-zero value means unification has begun. */ + isUnited: number; turnTermMinutes: number; blockGeneralCreate: 0 | 1 | 2; autorunUser: { @@ -933,6 +935,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { ); return { profileName, + isUnited: Number(meta.isunited ?? meta.isUnited ?? 0), turnTermMinutes: Math.max(1, Math.round(row.tickSeconds / 60)), blockGeneralCreate, autorunUser: diff --git a/app/gateway-api/test/adminOperations.test.ts b/app/gateway-api/test/adminOperations.test.ts index b5b6d826..1c35892d 100644 --- a/app/gateway-api/test/adminOperations.test.ts +++ b/app/gateway-api/test/adminOperations.test.ts @@ -31,6 +31,7 @@ const buildCaller = async ( initialProfileStatus?: GatewayProfileRecord['status']; profileScenario?: string | null; profileMeta?: GatewayProfileRecord['meta']; + gameIsUnited?: number; releaseCommitSha?: string; initialOperation?: GatewayOperationRecord; profileLogVisibilityAfterPolls?: number; @@ -291,6 +292,7 @@ const buildCaller = async ( listRuntimeSettings: async () => [ { profileName: 'che:2', + isUnited: options.gameIsUnited ?? 0, turnTermMinutes: 20, blockGeneralCreate: 2, autorunUser: { limitMinutes: 720, options: ['develop', 'recruit_high', 'chief'] }, @@ -405,6 +407,7 @@ describe('admin profile navigation API', () => { expect(result[0]?.runtimeSettings).toEqual({ profileName: 'che:2', + isUnited: 0, turnTermMinutes: 20, blockGeneralCreate: 2, autorunUser: { limitMinutes: 720, options: ['develop', 'recruit_high', 'chief'] }, @@ -1427,6 +1430,63 @@ describe('admin runtime clock action API', () => { expect(harness.getReconcileCount()).toBe(1); }); + it('lets a scoped scenario opener close a unified game without runtime authority', async () => { + const harness = await buildCaller(unusedCreateOperation, { + adminRoles: ['user', 'admin.scenarios.reset:che:2'], + firstUserIsAdmin: false, + initialProfileStatus: 'RUNNING', + gameIsUnited: 2, + }); + + await expect( + harness.caller.admin.profiles.requestAction({ + profileName: 'che:2', + action: 'CLOSE_COMPLETED', + }) + ).resolves.toMatchObject({ ok: true }); + expect(harness.updatedStatuses).toEqual(['STOPPED']); + expect(harness.getReconcileCount()).toBe(1); + expect(harness.auditEvents.at(-1)).toMatchObject({ capability: 'admin.scenarios.reset' }); + }); + + it('does not let a scenario opener close a game before unification', async () => { + const harness = await buildCaller(unusedCreateOperation, { + adminRoles: ['user', 'admin.scenarios.reset:che:2'], + firstUserIsAdmin: false, + initialProfileStatus: 'RUNNING', + gameIsUnited: 0, + }); + + await expect( + harness.caller.admin.profiles.requestAction({ + profileName: 'che:2', + action: 'CLOSE_COMPLETED', + }) + ).rejects.toMatchObject({ + code: 'BAD_REQUEST', + message: 'Only a unified game can be closed by a scenario opener.', + }); + expect(harness.updatedStatuses).toEqual([]); + expect(harness.getReconcileCount()).toBe(0); + }); + + it('does not turn runtime authority into scenario-opener cleanup authority', async () => { + const harness = await buildCaller(unusedCreateOperation, { + adminRoles: ['user', 'admin.profiles.runtime:che:2'], + firstUserIsAdmin: false, + initialProfileStatus: 'RUNNING', + gameIsUnited: 2, + }); + + await expect( + harness.caller.admin.profiles.requestAction({ + profileName: 'che:2', + action: 'CLOSE_COMPLETED', + }) + ).rejects.toMatchObject({ code: 'FORBIDDEN' }); + expect(harness.updatedStatuses).toEqual([]); + }); + it('creates a first-class clock action owned by the authenticated administrator', async () => { const harness = await buildCaller(unusedCreateOperation); diff --git a/app/gateway-frontend/e2e/admin-runtime-actions.spec.ts b/app/gateway-frontend/e2e/admin-runtime-actions.spec.ts index bd942605..7c27a78b 100644 --- a/app/gateway-frontend/e2e/admin-runtime-actions.spec.ts +++ b/app/gateway-frontend/e2e/admin-runtime-actions.spec.ts @@ -37,8 +37,10 @@ const installFixture = async ( initialActions?: RuntimeAction[]; afterRequestActions?: RuntimeAction[]; pendingProfileReads?: number; - profileStatus?: 'RUNNING' | 'PAUSED' | 'STOPPED'; + profileStatus?: 'RUNNING' | 'PAUSED' | 'COMPLETED' | 'STOPPED'; currentScenario?: string | null; + gameIsUnited?: number; + openerOnly?: boolean; } = {} ) => { let requested = false; @@ -46,6 +48,7 @@ const installFixture = async ( let installActive = false; let postRequestProfileReads = 0; let requestedRuntimeSettings = false; + let completedCloseRequested = false; const requestBodies: unknown[] = []; let releaseRequest = (): void => {}; const requestGate = options.deferRequest @@ -67,7 +70,9 @@ const installFixture = async ( const operations = operationNames(route); if (operations.includes('admin.profiles.requestAction')) { requested = true; - requestedRuntimeSettings = JSON.stringify(body).includes('UPDATE_RUNTIME_SETTINGS'); + const requestJson = JSON.stringify(body); + requestedRuntimeSettings = requestJson.includes('UPDATE_RUNTIME_SETTINGS'); + completedCloseRequested = requestJson.includes('CLOSE_COMPLETED'); requestBodies.push(body); await requestGate; } @@ -94,7 +99,7 @@ const installFixture = async ( return response({ enabled: true }); } if (operation === 'admin.capabilities.list') { - return response([ + const capabilities = [ { permission: 'admin.users.manage', label: '사용자·제재 관리', @@ -134,7 +139,12 @@ const installFixture = async ( scope: 'PROFILE', scopes: ['*'], }, - ]); + ]; + return response( + options.openerOnly + ? capabilities.filter((entry) => entry.permission === 'admin.scenarios.reset') + : capabilities + ); } if (operation === 'admin.profiles.listScenarios') { return response([ @@ -170,11 +180,12 @@ const installFixture = async ( currentScenario: options.currentScenario === undefined ? '1010' : options.currentScenario, scenario: options.currentScenario ?? 'default', apiPort: 15015, - status: options.profileStatus ?? 'RUNNING', + status: completedCloseRequested ? 'STOPPED' : (options.profileStatus ?? 'RUNNING'), buildStatus: 'SUCCEEDED', meta: {}, runtimeSettings: requestedRuntimeSettings ? { + isUnited: options.gameIsUnited ?? 0, turnTermMinutes: 20, blockGeneralCreate: 1, autorunUser: { @@ -183,6 +194,7 @@ const installFixture = async ( }, } : { + isUnited: options.gameIsUnited ?? 0, turnTermMinutes: 10, blockGeneralCreate: 2, autorunUser: null, @@ -384,6 +396,63 @@ test('distinguishes a turn pause from an inaccessible stopped server in operator await expect(page.getByRole('button', { name: '중지', exact: true })).toBeEnabled(); }); +test('lets a scenario opener close only a unified server and keeps the control usable on mobile', async ({ + page, +}, testInfo) => { + const fixture = await installFixture(page, { gameIsUnited: 2, openerOnly: true }); + + await page.goto('/gateway/admin/servers/hwe%3Adefault'); + await expect(page.getByTestId('profile-lifecycle-description')).toContainText('천하통일 완료'); + await expect(page.getByTestId('runtime-settings')).toHaveCount(0); + const cleanup = page.getByTestId('completed-profile-cleanup'); + await expect(cleanup).toContainText('현재 기수 DB와 완료 기록은 보존'); + const submit = page.getByTestId('completed-profile-cleanup-submit'); + const desktopGeometry = await cleanup.evaluate((element) => { + const rect = element.getBoundingClientRect(); + const style = getComputedStyle(element); + return { + left: rect.left, + right: rect.right, + viewport: window.innerWidth, + fontSize: style.fontSize, + color: style.color, + }; + }); + expect(desktopGeometry.left).toBeGreaterThanOrEqual(0); + expect(desktopGeometry.right).toBeLessThanOrEqual(desktopGeometry.viewport); + expect(desktopGeometry.fontSize).toBe('14px'); + expect(desktopGeometry.color).not.toBe('rgba(0, 0, 0, 0)'); + const buttonBackground = await submit.evaluate((element) => getComputedStyle(element).backgroundColor); + await submit.hover(); + const hoverBackground = await submit.evaluate((element) => getComputedStyle(element).backgroundColor); + expect(hoverBackground).not.toBe(buttonBackground); + await submit.focus(); + await expect(submit).toBeFocused(); + await page.screenshot({ path: testInfo.outputPath('completed-profile-cleanup-desktop.png'), fullPage: true }); + + await submit.click(); + await expect.poll(() => JSON.stringify(fixture.requestBodies)).toContain('CLOSE_COMPLETED'); + await expect(page.getByTestId('completed-profile-cleanup')).toHaveCount(0); + await expect(page.getByTestId('profile-lifecycle-description')).toContainText('게임 접근 불가'); + + await page.setViewportSize({ width: 390, height: 844 }); + const lifecycle = page.getByTestId('profile-lifecycle-description'); + const geometry = await lifecycle.evaluate((element) => { + const rect = element.getBoundingClientRect(); + return { left: rect.left, right: rect.right, viewport: window.innerWidth }; + }); + expect(geometry.left).toBeGreaterThanOrEqual(0); + expect(geometry.right).toBeLessThanOrEqual(geometry.viewport); + await page.screenshot({ path: testInfo.outputPath('completed-profile-cleanup-mobile.png'), fullPage: true }); +}); + +test('does not offer completed cleanup to a scenario opener before unification', async ({ page }) => { + await installFixture(page, { gameIsUnited: 0, openerOnly: true }); + + await page.goto('/gateway/admin/servers/hwe%3Adefault'); + await expect(page.getByTestId('completed-profile-cleanup')).toHaveCount(0); +}); + test('shows an initialized STOPPED server as inaccessible and only restartable', async ({ page }) => { await installFixture(page, { profileStatus: 'STOPPED' }); diff --git a/app/gateway-frontend/src/views/AdminView.vue b/app/gateway-frontend/src/views/AdminView.vue index a9ae4a46..1722cdbc 100644 --- a/app/gateway-frontend/src/views/AdminView.vue +++ b/app/gateway-frontend/src/views/AdminView.vue @@ -217,12 +217,16 @@ type AdminProfile = { }>; }; -type ProfileRuntimeSettings = Pick; +type ProfileRuntimeSettings = Pick & { + isUnited: number; +}; +type ProfileRuntimeSettingsUpdate = Omit; type AdminAction = | 'RESUME' | 'PAUSE' | 'STOP' + | 'CLOSE_COMPLETED' | 'ACCELERATE' | 'DELAY' | 'UPDATE_RUNTIME_SETTINGS' @@ -366,7 +370,7 @@ type AdminClient = { profileName: string; action: AdminAction; durationMinutes?: number; - runtimeSettings?: ProfileRuntimeSettings; + runtimeSettings?: ProfileRuntimeSettingsUpdate; scheduledAt?: string; reason?: string; }) => Promise<{ ok: boolean; action?: AdminProfile['runtimeActions'][number] }>; @@ -446,13 +450,15 @@ const runtimeActionPending = (profile: AdminProfile): boolean => { const profileLifecycleText = (profile: AdminProfile): string => { if (profile.currentScenario === null) return 'DB 초기화 전 · 게임 접근 불가'; - if (profile.status === 'PAUSED') return '턴 일시정지 · 게임 조회와 예약턴 입력 가능 · 운영자 재개 가능'; if (profile.status === 'STOPPED') return '서버 프로세스 중지 · 게임 접근 불가 · 운영자 서버 재개 가능'; + if (profile.status === 'CANCELLED') return '취소 게임 · 접근 및 재개 불가 · 새 시나리오 초기화 필요'; + if (profile.status === 'DISABLED') return '비활성 · 게임 접근 불가'; + if (Number(profile.runtimeSettings?.isUnited ?? 0) !== 0) + return '천하통일 완료 · 종료 기수 조회 가능 · 외부 목록 정리 대기'; + if (profile.status === 'PAUSED') return '턴 일시정지 · 게임 조회와 예약턴 입력 가능 · 운영자 재개 가능'; if (profile.status === 'RUNNING') return '서버 운영 및 턴 진행 중'; if (profile.status === 'PREOPEN') return '서버 접근 가능 · 개장 전 턴 정지'; if (profile.status === 'COMPLETED') return '종료 기수 조회 가능 · 턴 정지'; - if (profile.status === 'CANCELLED') return '취소 게임 · 접근 및 재개 불가 · 새 시나리오 초기화 필요'; - if (profile.status === 'DISABLED') return '비활성 · 게임 접근 불가'; return '준비 중 · 게임 접근 불가'; }; @@ -460,6 +466,9 @@ const canResumeProfile = (profile: AdminProfile): boolean => profile.currentScenario !== null && gatewayProfileCapabilities(profile.status).operatorResumable; const canPauseProfile = (profile: AdminProfile): boolean => profile.status === 'RUNNING'; const canStopProfile = (profile: AdminProfile): boolean => gatewayProfileCapabilities(profile.status).runtimeExpected; +const canCloseCompletedProfile = (profile: AdminProfile): boolean => + gatewayProfileCapabilities(profile.status).runtimeExpected && + (profile.status === 'COMPLETED' || Number(profile.runtimeSettings?.isUnited ?? 0) !== 0); const validDuration = (profileName: string): boolean => { const value = Number(profileActions.value[profileName]?.durationMinutes); @@ -885,7 +894,7 @@ const requestProfileAction = async (profileName: string, action: AdminAction) => ? serverDateTimeInputToIso(actionState.scheduledAt) : undefined; const reason = actionState?.reason.trim() || undefined; - const runtimeSettings: ProfileRuntimeSettings | undefined = + const runtimeSettings: ProfileRuntimeSettingsUpdate | undefined = action === 'UPDATE_RUNTIME_SETTINGS' && actionState ? { turnTermMinutes: actionState.turnTermMinutes, @@ -2206,6 +2215,29 @@ onMounted(() => {
빌드 커밋: {{ profile.buildCommitSha ?? '미지정' }}
+
+
천하통일 서버 정리
+

+ 서버를 외부 목록에서 닫고 실행 프로세스를 중지합니다. 현재 기수 DB와 완료 기록은 + 보존되며, 다음 시나리오 초기화로 다시 열 수 있습니다. +

+ +
+
Date: Mon, 24 Aug 2026 01:57:15 +0000 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20=EC=8B=A0=EA=B7=9C=20=EC=9E=A5?= =?UTF-8?q?=EC=88=98=EC=9D=98=20=EB=B9=88=20=ED=8A=B9=EA=B8=B0=EB=A5=BC=20?= =?UTF-8?q?=EC=A6=89=EC=8B=9C=20=EC=A0=95=EA=B7=9C=ED=99=94=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../test/createGeneral.integration.test.ts | 8 ++++++++ .../src/turn/joinCreateGeneralService.ts | 19 +++++++++++++------ .../test/joinCreateGeneralService.test.ts | 8 ++++++++ 3 files changed, 29 insertions(+), 6 deletions(-) diff --git a/app/game-api/test/createGeneral.integration.test.ts b/app/game-api/test/createGeneral.integration.test.ts index 00464f1f..ad0c613f 100644 --- a/app/game-api/test/createGeneral.integration.test.ts +++ b/app/game-api/test/createGeneral.integration.test.ts @@ -299,6 +299,10 @@ integration('generic general creation through the durable turn daemon', () => { userId, name: created.name, cityId: city.id, + role: { + specialDomestic: null, + specialWar: null, + }, inheritancePoints: { previous: 7351, }, @@ -363,6 +367,10 @@ integration('generic general creation through the durable turn daemon', () => { userId, name: created.name, cityId: city.id, + role: { + specialDomestic: null, + specialWar: null, + }, inheritancePoints: { previous: 7351, }, diff --git a/app/game-engine/src/turn/joinCreateGeneralService.ts b/app/game-engine/src/turn/joinCreateGeneralService.ts index f3898a9f..99c8697d 100644 --- a/app/game-engine/src/turn/joinCreateGeneralService.ts +++ b/app/game-engine/src/turn/joinCreateGeneralService.ts @@ -90,6 +90,9 @@ const fail = (code: JoinCreateGeneralErrorCode, message: string): never => { const normalizeJoinName = (value: string): string => normalizeTroopName(value).replace(LEGACY_JOIN_REMOVED_CHARACTERS, ''); +export const normalizeJoinSpecialityCode = (value: unknown): string | null => + typeof value === 'string' && value !== '' && value !== 'None' ? value : null; + export const resolveLegacyPenalty = ( rawPenalty: Record | undefined, profileId: string, @@ -658,8 +661,8 @@ export const createGeneralFromJoin = async (options: { const scenarioId = Number(worldMeta.scenarioId ?? worldState.scenarioCode); let specialityDomesticAge = resolveSpecialityAge(retirementYear, age, relativeYear, 12); let specialityWarAge = resolveSpecialityAge(retirementYear, age, relativeYear, 6); - let specialWar = typeof configConst.defaultSpecialWar === 'string' ? configConst.defaultSpecialWar : 'None'; - let specialWarName = specialWar; + let specialWar = normalizeJoinSpecialityCode(configConst.defaultSpecialWar); + let specialWarName = specialWar ?? 'None'; if (genius) { specialityWarAge = age; if (input.inheritSpecial) { @@ -677,10 +680,10 @@ export const createGeneralFromJoin = async (options: { ) ?? specialWar; } const [trait] = await loadWarTraitModules( - [specialWar].filter((key) => isWarTraitKey(key)), + specialWar && isWarTraitKey(specialWar) ? [specialWar] : [], new WarTraitLoader() ); - specialWarName = trait?.name ?? specialWar; + specialWarName = trait?.name ?? specialWar ?? 'None'; } if (Number.isFinite(scenarioId) && scenarioId >= 1000) { specialityDomesticAge = age + 3; @@ -762,8 +765,12 @@ export const createGeneralFromJoin = async (options: { startAge: age, role: { personality, - specialDomestic: - typeof configConst.defaultSpecialDomestic === 'string' ? configConst.defaultSpecialDomestic : 'None', + // Ref persists an empty speciality as the sentinel `None`, while + // the Core in-memory domain uses null. Keeping the sentinel here + // makes a newly joined general differ from the same row after a + // daemon reload and prevents the monthly speciality handler from + // recognizing the empty slot until that reload. + specialDomestic: normalizeJoinSpecialityCode(configConst.defaultSpecialDomestic), specialWar, items: { horse: null, diff --git a/app/game-engine/test/joinCreateGeneralService.test.ts b/app/game-engine/test/joinCreateGeneralService.test.ts index 18d4ff3e..c5997376 100644 --- a/app/game-engine/test/joinCreateGeneralService.test.ts +++ b/app/game-engine/test/joinCreateGeneralService.test.ts @@ -4,6 +4,7 @@ import { buildJoinCreateGeneralSeed, cutJoinTurnTime, JOIN_WELCOME_MESSAGE, + normalizeJoinSpecialityCode, resolveJoinTurnTime, } from '../src/turn/joinCreateGeneralService.js'; @@ -53,4 +54,11 @@ describe('generic join legacy time contracts', () => { expect(JOIN_WELCOME_MESSAGE).toBe('삼국지 모의전투 HiDCHe의 세계에 오신 것을 환영합니다 ^o^'); expect(JOIN_WELCOME_MESSAGE).not.toContain('PHP'); }); + + it('normalizes the Ref empty-speciality sentinel at the join boundary', () => { + expect(normalizeJoinSpecialityCode(undefined)).toBeNull(); + expect(normalizeJoinSpecialityCode('')).toBeNull(); + expect(normalizeJoinSpecialityCode('None')).toBeNull(); + expect(normalizeJoinSpecialityCode('che_견고')).toBe('che_견고'); + }); }); From 15cb8cc481940d9e2eb4862cbc4dbed6083dea69 Mon Sep 17 00:00:00 2001 From: hided62 Date: Mon, 24 Aug 2026 03:15:00 +0000 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20=EC=98=88=EC=95=BD=20=EA=B3=B5?= =?UTF-8?q?=EC=A7=80=20=EB=B3=B8=EB=AC=B8=EA=B3=BC=20=EC=84=9C=EB=B2=84=20?= =?UTF-8?q?=ED=96=89=20=EA=B5=AC=EB=B6=84=EC=9D=84=20=EB=B0=94=EB=A1=9C?= =?UTF-8?q?=EC=9E=A1=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 선공개와 일반 가오픈 공지를 공유 컴포넌트로 렌더링해 드래그 문자열을 일치시킨다. 예약 공지 배지에는 실제 빌드 시작 시각 툴팁만 제공하고 서버별 행 경계를 강화한다. --- .../e2e/lobby-game-auth.spec.ts | 103 +++++--- .../components/ProfilePreopenAnnouncement.vue | 216 ++++++++++++++++ app/gateway-frontend/src/views/LobbyView.vue | 231 ++++-------------- 3 files changed, 336 insertions(+), 214 deletions(-) create mode 100644 app/gateway-frontend/src/components/ProfilePreopenAnnouncement.vue diff --git a/app/gateway-frontend/e2e/lobby-game-auth.spec.ts b/app/gateway-frontend/e2e/lobby-game-auth.spec.ts index 24d57871..ecd34de9 100644 --- a/app/gateway-frontend/e2e/lobby-game-auth.spec.ts +++ b/app/gateway-frontend/e2e/lobby-game-auth.spec.ts @@ -502,7 +502,7 @@ 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 compact upcoming reset announcement without contacting the stopped runtime', async ({ +test('matches the normal preopen copy text and labels an immediate announcement with its build time', async ({ page, }, testInfo) => { const gameOperations = await installFixture(page, { @@ -525,15 +525,15 @@ test('shows a compact upcoming reset announcement without contacting the stopped options: ['develop', 'battle'], }, }, + includeStoppedProfile: true, }); 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 marker = announcement.getByTestId('upcoming-reset-options-marker'); - const swatch = announcement.getByTestId('upcoming-reset-options-swatch'); - const tooltip = announcement.getByTestId('upcoming-reset-options-tooltip'); + const badge = announcement.getByTestId('reserved-announcement-badge'); + const tooltip = announcement.getByTestId('reserved-announcement-build-tooltip'); await expect(row.getByTestId('upcoming-reset-phase')).toHaveCount(0); await expect(row.getByTestId('upcoming-reset-scheduled-at')).toHaveCount(0); await expect(row.getByTestId('upcoming-reset-preopen-at')).toHaveText('- 가오픈 일시 : 2026-08-27 14:30:00 -'); @@ -542,14 +542,15 @@ test('shows a compact upcoming reset announcement without contacting the stopped await expect( row.getByTestId('upcoming-reset-scenario-announcement').locator(':scope > .text-green-400') ).toHaveText('60분 턴 서버'); - expect( - (await announcement.evaluate((element) => (element as HTMLElement).innerText)).replace(/\s+/g, ' ').trim() - ).toBe( - '- 가오픈 일시 : 2026-08-27 14:30:00 - - 오픈 일시 : 2026-08-27 20:00:00 - 【가상】황건적의 난 60분 턴 서버' + await expect(announcement.locator('.profile-announcement-settings')).toHaveText( + '(상성 설정:가상), (빙의 여부:가능), (최대 스탯:70), ' + + '(기타 설정:랜덤 임관, 자율행동[내정, 출병, 24시간 유효])' ); await expect(tooltip).toBeHidden(); - await expect(marker).toHaveAttribute('aria-label', '초기화 옵션 보기'); - await expect(swatch).toHaveCSS('background-color', 'rgb(217, 119, 6)'); + await expect(badge).toHaveText(/예약 공지/); + await expect(badge).toHaveAttribute('aria-label', '예약 공지의 실제 빌드 시작 시각 보기'); + await expect(badge).toHaveCSS('user-select', 'none'); + await expect(badge).toHaveCSS('border-top-color', 'rgb(217, 119, 6)'); await expect(row).not.toContainText('서버 중지 · 접근 불가'); await expect(row).not.toContainText('정보를 불러오는 중'); await expect(row.getByRole('button', { name: '입장' })).toHaveCount(0); @@ -557,39 +558,63 @@ test('shows a compact upcoming reset announcement without contacting the stopped expect(gameOperations).toEqual([]); const desktopGeometry = await announcement.evaluate((element) => { - const marker = element.querySelector('[data-testid="upcoming-reset-options-marker"]'); - const swatch = element.querySelector('[data-testid="upcoming-reset-options-swatch"]'); - if (!marker || !swatch) throw new Error('expected upcoming reset option marker'); + const badge = element.querySelector('[data-testid="reserved-announcement-badge"]'); + if (!badge) throw new Error('expected reserved announcement badge'); return { announcement: element.getBoundingClientRect().toJSON(), cell: element.parentElement?.getBoundingClientRect().toJSON(), - marker: marker.getBoundingClientRect().toJSON(), - swatch: swatch.getBoundingClientRect().toJSON(), + badge: badge.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); - expect(desktopGeometry.marker.width).toBe(18); - expect(desktopGeometry.marker.height).toBe(18); - expect(desktopGeometry.swatch.width).toBe(8); - expect(desktopGeometry.swatch.height).toBe(8); - await marker.hover(); + expect(desktopGeometry.badge.width).toBeGreaterThan(40); + expect(desktopGeometry.badge.height).toBe(18); + const desktopRowDivider = await page + .locator('tbody tr') + .first() + .evaluate((element) => { + const style = getComputedStyle(element); + return { color: style.borderBottomColor, width: style.borderBottomWidth }; + }); + expect(desktopRowDivider).toEqual({ color: 'rgb(82, 82, 91)', width: '1px' }); + await badge.hover(); await expect(tooltip).toBeVisible(); - await expect(tooltip).toContainText('초기화 옵션'); - await expect(tooltip).toContainText('상성 설정: 가상 · 빙의 여부: 가능 · 최대 스탯: 70'); - await expect(tooltip).toContainText('기타 설정: 랜덤 임관 · 자율행동: 내정, 출병, 24시간 유효'); - await expect(tooltip).not.toContainText('초기화 시작'); - await expect(tooltip).not.toContainText('빌드 대기'); + await expect(tooltip).toHaveText('실제 빌드 시작 : 2026-08-27 14:00:00'); + await expect(tooltip).not.toContainText('초기화 옵션'); const desktopTooltipGeometry = await tooltip.evaluate((element) => element.getBoundingClientRect().toJSON()); expect(desktopTooltipGeometry.left).toBeGreaterThanOrEqual(8); expect(desktopTooltipGeometry.right).toBeLessThanOrEqual(desktopGeometry.viewportWidth - 8); expect(desktopTooltipGeometry.top).toBeGreaterThanOrEqual(desktopGeometry.cell?.top ?? 0); expect(desktopTooltipGeometry.bottom).toBeLessThanOrEqual(desktopGeometry.cell?.bottom ?? 0); - await marker.focus(); - await expect(marker).toBeFocused(); - await expect(marker).toHaveCSS('outline-width', '2px'); + await badge.focus(); + await expect(badge).toBeFocused(); + await expect(badge).toHaveCSS('outline-width', '2px'); + + await page.mouse.click(8, 8); + const selectionStart = await announcement.getByTestId('upcoming-reset-preopen-at').boundingBox(); + const selectionEnd = await announcement.locator('.profile-announcement-settings').boundingBox(); + if (!selectionStart || !selectionEnd) throw new Error('expected upcoming announcement selection geometry'); + await page.mouse.move(selectionStart.x + 1, selectionStart.y + selectionStart.height / 2); + await page.mouse.down(); + await page.mouse.move(selectionEnd.x + selectionEnd.width - 1, selectionEnd.y + selectionEnd.height / 2, { + steps: 24, + }); + await page.mouse.up(); + const compactSelection = (await page.evaluate(() => window.getSelection()?.toString() ?? '')) + .replace(/\s+/g, ' ') + .trim(); + expect(compactSelection).toBe( + '- 가오픈 일시 : 2026-08-27 14:30:00 - - 오픈 일시 : 2026-08-27 20:00:00 - ' + + '【가상】황건적의 난 60분 턴 서버 ' + + '(상성 설정:가상), (빙의 여부:가능), (최대 스탯:70), ' + + '(기타 설정:랜덤 임관, 자율행동[내정, 출병, 24시간 유효])' + ); + expect(compactSelection).not.toContain('예약 공지'); + expect(compactSelection).not.toContain('실제 빌드 시작'); + await page.evaluate(() => window.getSelection()?.removeAllRanges()); await page.screenshot({ path: testInfo.outputPath('gateway-upcoming-reset-desktop.png'), fullPage: true }); await page.setViewportSize({ width: 390, height: 844 }); @@ -606,7 +631,7 @@ test('shows a compact upcoming reset announcement without contacting the stopped expect(mobileGeometry.left).toBeGreaterThanOrEqual(0); expect(mobileGeometry.right).toBeLessThanOrEqual(mobileGeometry.viewportWidth); expect(mobileGeometry.documentWidth).toBe(mobileGeometry.viewportWidth); - await marker.hover(); + await badge.hover(); await expect(tooltip).toBeVisible(); const mobileTooltipGeometry = await tooltip.evaluate((element) => element.getBoundingClientRect().toJSON()); expect(mobileTooltipGeometry.left).toBeGreaterThanOrEqual(8); @@ -636,12 +661,14 @@ test('keeps the announcement through the RESERVED handoff after the build comple await page.goto('lobby'); const row = page.locator('tbody tr').filter({ hasText: 'hwe섭' }); - const marker = row.getByTestId('upcoming-reset-options-marker'); + const badge = row.getByTestId('reserved-announcement-badge'); await expect(row.getByTestId('upcoming-reset-phase')).toHaveCount(0); await expect(row.getByTestId('upcoming-reset-scenario-title')).toHaveText('【가상】황건적의 난'); - await marker.hover(); - await expect(row.getByTestId('upcoming-reset-options-tooltip')).toContainText('초기화 옵션'); - await expect(row.getByTestId('upcoming-reset-options-tooltip')).not.toContainText('오픈 준비 완료'); + await badge.hover(); + await expect(row.getByTestId('reserved-announcement-build-tooltip')).toHaveText( + '실제 빌드 시작 : 2026-08-27 14:00:00' + ); + await expect(row.getByTestId('reserved-announcement-build-tooltip')).not.toContainText('오픈 준비 완료'); await expect(row).not.toContainText('준 비 중 · 접근 불가'); await expect(row.getByRole('button', { name: '입장' })).toHaveCount(0); expect(gameOperations).toEqual([]); @@ -690,6 +717,8 @@ test('uses two-row mobile server cards without horizontal scrolling and keeps re const rowRect = row.getBoundingClientRect(); const buttonRect = button.getBoundingClientRect(); const style = getComputedStyle(button); + const bodyStyle = getComputedStyle(scrollElement.querySelector('tbody')!); + const firstRowStyle = getComputedStyle(rows[0]!); const rect = (element: Element) => { const value = element.getBoundingClientRect(); return { top: value.top, right: value.right, bottom: value.bottom, left: value.left, width: value.width }; @@ -715,6 +744,10 @@ test('uses two-row mobile server cards without horizontal scrolling and keeps re viewportWidth: window.innerWidth, outlineStyle: style.outlineStyle, outlineWidth: style.outlineWidth, + rowGap: bodyStyle.rowGap, + dividerBackground: bodyStyle.backgroundColor, + firstRowBorderColor: firstRowStyle.borderBottomColor, + firstRowBorderWidth: firstRowStyle.borderBottomWidth, }; }); expect(geometry.pageScrollWidth).toBe(geometry.viewportWidth); @@ -724,6 +757,10 @@ test('uses two-row mobile server cards without horizontal scrolling and keeps re expect(geometry.rows).toHaveLength(2); expect(geometry.rows[0]?.bottom).toBeLessThanOrEqual(geometry.rows[1]?.top ?? 0); expect(geometry.rows.every((item) => item.width === geometry.scroll.clientWidth)).toBe(true); + expect(geometry.rowGap).toBe('2px'); + expect(geometry.dividerBackground).toBe('rgb(63, 63, 70)'); + expect(geometry.firstRowBorderColor).toBe('rgb(82, 82, 91)'); + expect(geometry.firstRowBorderWidth).toBe('1px'); expect(geometry.button.left).toBeGreaterThanOrEqual(geometry.scroll.left); expect(geometry.button.right).toBeLessThanOrEqual(geometry.scroll.right); expect(geometry.cells.server.top).toBeCloseTo(geometry.cells.info.top, 0); diff --git a/app/gateway-frontend/src/components/ProfilePreopenAnnouncement.vue b/app/gateway-frontend/src/components/ProfilePreopenAnnouncement.vue new file mode 100644 index 00000000..870db45e --- /dev/null +++ b/app/gateway-frontend/src/components/ProfilePreopenAnnouncement.vue @@ -0,0 +1,216 @@ + + + + + diff --git a/app/gateway-frontend/src/views/LobbyView.vue b/app/gateway-frontend/src/views/LobbyView.vue index c63087e9..57851bc7 100644 --- a/app/gateway-frontend/src/views/LobbyView.vue +++ b/app/gateway-frontend/src/views/LobbyView.vue @@ -7,6 +7,7 @@ import type { AppRouter } from '@sammo-ts/gateway-api'; import { writeGameSessionTransfer } from '@sammo-ts/common/auth/gameSessionTransfer'; import DefaultLayout from '../layouts/DefaultLayout.vue'; import MapPreview from '../components/MapPreview.vue'; +import ProfilePreopenAnnouncement from '../components/ProfilePreopenAnnouncement.vue'; import { useToast } from '../composables/useToast'; import { trpc } from '../utils/trpc'; import { createGameTrpc } from '../utils/gameTrpc'; @@ -106,11 +107,8 @@ const handleMapTabKeydown = (event: KeyboardEvent, profileName: string): void => const formatGraceEndsAt = (value: string | null | undefined): string => formatServerDateTime(value); const serverSeasonStatus = (info: LobbyInfo) => resolveServerSeasonStatus(info); -const formatAnnouncementDate = (value: string | null | undefined): string => - formatServerDateTime(value, { fallback: '-' }); const profileScenarioTitle = (profileName: string): string => profileDetails.value[profileName]?.scenarioTitle.trim() || '-'; -const npcModeText = (mode: number): string => ['불가', '가능', '선택 생성'][mode] ?? '불가'; const autorunDetailText = (autorun: LobbyInfo['autorunUser']): string => { if (!autorun) return ''; @@ -133,10 +131,8 @@ const autorunDetailText = (autorun: LobbyInfo['autorunUser']): string => { labels.push(limit); return labels.join(', '); }; -const autorunTooltipId = (profileName: string, scope = 'current'): string => - 'profile-autorun-' + scope + '-' + profileName.replaceAll(/[^a-zA-Z0-9_-]/g, '-'); -const upcomingResetOptionsTooltipId = (profileName: string): string => - 'upcoming-reset-options-' + profileName.replaceAll(/[^a-zA-Z0-9_-]/g, '-'); +const autorunTooltipId = (profileName: string): string => + 'profile-autorun-current-' + profileName.replaceAll(/[^a-zA-Z0-9_-]/g, '-'); const isProfileRuntimeAvailable = (profile: LobbyProfile): boolean => profile.lifecycle.userAccessible; const unavailableProfileText = (profile: LobbyProfile): string => { if (!profile.lifecycle.dataInitialized) return '- DB 초기화 전 · 접근 불가 -'; @@ -453,7 +449,7 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => { 선 택 - + { -
-
- - 가오픈 일시 : - {{ formatAnnouncementDate(profile.upcomingReset.preopenAt) }} - -
-
- - 오픈 일시 : {{ formatAnnouncementDate(profile.upcomingReset.openAt) }} - -
-
- - {{ profile.upcomingReset.scenarioTitle }} {{ ' ' }} - - {{ profile.upcomingReset.turnTermMinutes }}분 턴 서버 - - - - - 초기화 옵션 - 상성 설정: {{ profile.upcomingReset.fictionMode }} · 빙의 여부: - {{ npcModeText(profile.upcomingReset.npcMode) }} · 최대 스탯: - {{ profile.upcomingReset.defaultStatTotal }} - 기타 설정: {{ profile.upcomingReset.otherTextInfo || '없음' - }} - - -
-
+ :profile-name="profile.profileName" + :preopen-at="profile.upcomingReset.preopenAt" + :open-at="profile.upcomingReset.openAt" + :scenario-title="profile.upcomingReset.scenarioTitle" + :turn-term-minutes="profile.upcomingReset.turnTermMinutes" + :fiction-mode="profile.upcomingReset.fictionMode" + :npc-mode="profile.upcomingReset.npcMode" + :default-stat-total="profile.upcomingReset.defaultStatTotal" + :other-text-info="profile.upcomingReset.otherTextInfo" + :autorun-user="profile.upcomingReset.autorunUser" + :scheduled-at="profile.upcomingReset.scheduledAt" + test-id-prefix="upcoming-reset" + />