diff --git a/app/game-api/test/messagesRouter.test.ts b/app/game-api/test/messagesRouter.test.ts index 3572cc13..993eeb79 100644 --- a/app/game-api/test/messagesRouter.test.ts +++ b/app/game-api/test/messagesRouter.test.ts @@ -260,6 +260,116 @@ describe('messages router missing-flow compatibility', () => { expect(queryRaw.mock.calls[0]?.slice(1)).toEqual(expect.arrayContaining([9002, 'diplomacy'])); }); + it('allows a ruler to send a recruitment advertisement to the wanderer mailbox', async () => { + const ruler = { ...general, officerLevel: 12 } as GeneralRow; + const queryRaw = vi.fn(async () => [{ id: 53 }]); + const changeJournal = new ChangeJournal(); + const { caller } = buildContext( + { + $queryRaw: queryRaw, + general: { + findUnique: vi.fn(async () => ruler), + findMany: vi.fn(async () => []), + }, + nation: { + findMany: vi.fn(async () => []), + findUnique: vi.fn(async () => ({ id: 1, name: '위', color: '#112233', meta: {} })), + }, + }, + { changeJournal } + ); + + const result = await caller.messages.send({ + generalId: ruler.id, + mailbox: 9000, + text: '우리 나라로 와주세요', + }); + + expect(result.msgType).toBe('diplomacy'); + expect(queryRaw.mock.calls[0]?.slice(1)).toEqual(expect.arrayContaining([9000, 'diplomacy'])); + expect(queryRaw).toHaveBeenCalledTimes(2); + expect(changeJournal.snapshot()).toEqual([ + { domain: 'messages.mailbox', entityId: 9000 }, + { domain: 'messages.mailbox', entityId: 9001 }, + ]); + }); + + it('keeps the wanderer mailbox unavailable to a non-diplomat on the server', async () => { + const queryRaw = vi.fn(async () => [{ id: 54 }]); + const { caller } = buildContext({ + $queryRaw: queryRaw, + nation: { + findMany: vi.fn(async () => []), + findUnique: vi.fn(async () => ({ id: 1, name: '위', color: '#112233', meta: {} })), + }, + }); + + const result = await caller.messages.send({ + generalId: general.id, + mailbox: 9000, + text: '권한 없는 재야 광고', + }); + + expect(result.msgType).toBe('national'); + expect(queryRaw).toHaveBeenCalledTimes(1); + expect(queryRaw.mock.calls[0]?.slice(1)).toEqual(expect.arrayContaining([9001, 'national'])); + }); + + it('shows a wanderer the advertisement stored in mailbox 9000 without diplomacy redaction', async () => { + const wanderer = { ...general, nationId: 0, officerLevel: 0 } as GeneralRow; + const advertisementRow = { + id: 55, + mailbox: 9000, + type: 'diplomacy', + src: 9001, + dest: 9000, + time: new Date(), + valid_until: new Date('9999-12-31T00:00:00Z'), + message: { + src: { + generalId: 1, + generalName: '위왕', + nationId: 1, + nationName: '위', + color: '#112233', + icon: '', + }, + dest: { + generalId: 0, + generalName: '', + nationId: 0, + nationName: '재야', + color: '#000000', + icon: '', + }, + text: '우리 나라로 와주세요', + option: {}, + }, + }; + const queryRaw = vi.fn(async (...args: unknown[]) => { + const values = args.slice(1); + return values.includes(9000) && values.includes('diplomacy') ? [advertisementRow] : []; + }); + const { caller } = buildContext({ + $queryRaw: queryRaw, + general: { + findUnique: vi.fn(async () => wanderer), + findMany: vi.fn(async () => []), + }, + }); + + const result = await caller.messages.getRecent({ generalId: wanderer.id }); + + expect(result.permission).toBe(-1); + expect(result.diplomacy).toEqual([ + expect.objectContaining({ + text: '우리 나라로 와주세요', + dest: expect.objectContaining({ nationId: 0, nationName: '재야' }), + option: {}, + }), + ]); + }); + it('blocks private messages between foreign ambassadors', async () => { const ambassador = { ...general, diff --git a/app/game-frontend/e2e/mainNavigation.spec.ts b/app/game-frontend/e2e/mainNavigation.spec.ts index df63fc59..2d752e4d 100644 --- a/app/game-frontend/e2e/mainNavigation.spec.ts +++ b/app/game-frontend/e2e/mainNavigation.spec.ts @@ -885,9 +885,9 @@ const expectMobilePanelVisualOrder = async (page: Page, expectedOrder: readonly expect(audit.panels.map(({ id }) => id)).toEqual(expectedOrder); expect(audit.visualOrder).toEqual(expectedOrder); expect(audit.panels.every(({ left, right, width }) => left >= 0 && right <= 500 && width === 500)).toBe(true); - expect( - audit.panels.every((panel, index) => index === 0 || panel.top >= audit.panels[index - 1]!.bottom) - ).toBe(true); + expect(audit.panels.every((panel, index) => index === 0 || panel.top >= audit.panels[index - 1]!.bottom)).toBe( + true + ); for (const panel of audit.panels) { expect(panel.display, `${panel.id}: display`).not.toBe('none'); expect(['static', 'relative'], `${panel.id}: position`).toContain(panel.position); @@ -1125,9 +1125,7 @@ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dro await expect(page.locator('.main-mobile-bottom')).toBeHidden(); await expect(page.locator('.layout-desktop')).toBeVisible(); await expect(page.locator('.layout-mobile')).toHaveCount(0); - await expect(page.getByRole('heading', { name: '메인 화면 검증 시나리오 체섭 101기', exact: true })).toHaveCount( - 1 - ); + await expect(page.getByRole('heading', { name: '메인 화면 검증 시나리오 체섭 101기', exact: true })).toHaveCount(1); await expect(page.locator('.game-shell__subtitle')).toHaveCount(0); await expect(page.locator('.legacy-game-info')).toContainText('현재: 185년 1월'); await expect(page.locator('.legacy-game-info')).toContainText('턴: 10분'); @@ -1279,7 +1277,9 @@ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dro await persistArtifact(page, `${basePath.slice(1)}-desktop-1200`); }); -test('shows the persisted official game index beside the scenario title without viewport overflow', async ({ page }) => { +test('shows the persisted official game index beside the scenario title without viewport overflow', async ({ + page, +}) => { const state: NavigationFixture = { officerLevel: 5, permission: 2, @@ -2794,6 +2794,139 @@ test('real mobile devices initially fit the complete 500px game canvas', async ( } }); +test('automatic screen mode switches wide mobile screens to the 1000px layout at the Ref boundary', async ({ + browser, +}, testInfo) => { + test.setTimeout(60_000); + const configuredBaseUrl = testInfo.project.use.baseURL; + if (typeof configuredBaseUrl !== 'string') { + throw new Error('Playwright baseURL is required for the automatic screen-mode contract'); + } + + const measurements: Record = {}; + for (const deviceWidth of [699, 700, 820]) { + const context = await browser.newContext({ + baseURL: configuredBaseUrl, + viewport: { width: deviceWidth, height: 1180 }, + screen: { width: deviceWidth, height: 1180 }, + deviceScaleFactor: 1, + isMobile: true, + hasTouch: true, + colorScheme: 'dark', + }); + const mobilePage = await context.newPage(); + const state: NavigationFixture = { + officerLevel: 5, + permission: 2, + nationLevel: 3, + stage: 6, + npcMode: 1, + generalMeCalls: 0, + operations: [], + }; + await installFixture(mobilePage, state); + await waitForMain(mobilePage); + + const expectedWideLayout = deviceWidth >= 700; + await expect(mobilePage.locator(expectedWideLayout ? '.layout-desktop' : '.layout-mobile')).toBeVisible(); + expect( + await mobilePage.evaluate(() => document.querySelector('meta[name="viewport"]')?.content) + ).toBe(expectedWideLayout ? 'width=1000' : 'width=device-width, initial-scale=1'); + const modeMeasurements: Record = { + auto: await mobilePage.locator('.main-page').evaluate((element) => { + const rect = element.getBoundingClientRect(); + const mobileLayout = document.querySelector('.layout-mobile'); + const desktopLayout = document.querySelector('.layout-desktop'); + return { + viewportMeta: document.querySelector('meta[name="viewport"]')?.content, + screenWidth: screen.availWidth, + innerWidth: window.innerWidth, + layoutViewportWidth: document.documentElement.clientWidth, + visualViewportWidth: window.visualViewport?.width ?? null, + visualViewportScale: window.visualViewport?.scale ?? null, + mobileDisplay: mobileLayout ? getComputedStyle(mobileLayout).display : null, + desktopDisplay: desktopLayout ? getComputedStyle(desktopLayout).display : null, + canvas: { left: rect.left, right: rect.right, width: rect.width }, + }; + }), + }; + + if (deviceWidth === 820) { + await mobilePage.evaluate(() => { + localStorage.setItem('sam.screenMode', '500px'); + document.dispatchEvent(new CustomEvent('tryChangeScreenMode')); + }); + await expect(mobilePage.locator('.layout-mobile')).toBeVisible(); + expect( + await mobilePage.evaluate( + () => document.querySelector('meta[name="viewport"]')?.content + ) + ).toBe('width=500'); + modeMeasurements.forced500 = await mobilePage.locator('.main-page').evaluate(() => { + const mobileLayout = document.querySelector('.layout-mobile'); + const desktopLayout = document.querySelector('.layout-desktop'); + return { + viewportMeta: document.querySelector('meta[name="viewport"]')?.content, + layoutViewportWidth: document.documentElement.clientWidth, + visualViewportWidth: window.visualViewport?.width ?? null, + mobileDisplay: mobileLayout ? getComputedStyle(mobileLayout).display : null, + desktopDisplay: desktopLayout ? getComputedStyle(desktopLayout).display : null, + }; + }); + + await mobilePage.evaluate(() => { + localStorage.setItem('sam.screenMode', '1000px'); + document.dispatchEvent(new CustomEvent('tryChangeScreenMode')); + }); + await expect(mobilePage.locator('.layout-desktop')).toBeVisible(); + expect( + await mobilePage.evaluate( + () => document.querySelector('meta[name="viewport"]')?.content + ) + ).toBe('width=1000'); + modeMeasurements.forced1000 = await mobilePage.locator('.main-page').evaluate(() => { + const mobileLayout = document.querySelector('.layout-mobile'); + const desktopLayout = document.querySelector('.layout-desktop'); + return { + viewportMeta: document.querySelector('meta[name="viewport"]')?.content, + layoutViewportWidth: document.documentElement.clientWidth, + visualViewportWidth: window.visualViewport?.width ?? null, + mobileDisplay: mobileLayout ? getComputedStyle(mobileLayout).display : null, + desktopDisplay: desktopLayout ? getComputedStyle(desktopLayout).display : null, + }; + }); + + await mobilePage.evaluate(() => { + localStorage.setItem('sam.screenMode', 'auto'); + document.dispatchEvent(new CustomEvent('tryChangeScreenMode')); + }); + await expect(mobilePage.locator('.layout-desktop')).toBeVisible(); + expect( + await mobilePage.evaluate( + () => document.querySelector('meta[name="viewport"]')?.content + ) + ).toBe('width=1000'); + } + + measurements[String(deviceWidth)] = modeMeasurements; + if (artifactRoot) { + await mkdir(artifactRoot, { recursive: true }); + await mobilePage.screenshot({ + path: resolve(artifactRoot, `auto-screen-mode-${deviceWidth}.png`), + fullPage: true, + }); + } + await context.close(); + } + + if (artifactRoot) { + await writeFile( + resolve(artifactRoot, 'auto-screen-mode-computed-dom.json'), + `${JSON.stringify(measurements, null, 2)}\n` + ); + } +}); + test('nation menu presentation follows the server-derived permission matrix', async ({ page }) => { const state: NavigationFixture = { officerLevel: 1, diff --git a/app/game-frontend/e2e/nationGeneralSecret.spec.ts b/app/game-frontend/e2e/nationGeneralSecret.spec.ts index c4a95cf8..8259b737 100644 --- a/app/game-frontend/e2e/nationGeneralSecret.spec.ts +++ b/app/game-frontend/e2e/nationGeneralSecret.spec.ts @@ -294,8 +294,19 @@ test('nation generals filter buttons open Ref operator menus and apply compound await page.setViewportSize({ width: 500, height: 900 }); expect(await page.locator('.general-page').evaluate((element) => element.getBoundingClientRect().width)).toBe(1000); + const generalSearch = page.getByLabel('장수명 필터'); + await expect(generalSearch).toHaveCSS('touch-action', 'manipulation'); + const viewportContract = await page.evaluate(() => ({ + content: document.querySelector('meta[name="viewport"]')?.content ?? '', + scale: window.visualViewport?.scale ?? 1, + })); + expect(viewportContract.content).not.toMatch(/(?:user-scalable|minimum-scale|maximum-scale)/u); + await generalSearch.focus(); + await expect(generalSearch).toBeFocused(); + expect(await page.evaluate(() => window.visualViewport?.scale ?? 1)).toBe(viewportContract.scale); await nameMenuButton.click(); await expect(namePopup).toBeVisible(); + await expect(page.getByLabel('장수명 첫 번째 필터 값')).toHaveCSS('touch-action', 'manipulation'); expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeGreaterThanOrEqual(1000); await page.screenshot({ path: testInfo.outputPath('core-mobile-filter-menu.png'), fullPage: true }); }); diff --git a/app/game-frontend/src/assets/main.css b/app/game-frontend/src/assets/main.css index 4e0d1317..65148076 100644 --- a/app/game-frontend/src/assets/main.css +++ b/app/game-frontend/src/assets/main.css @@ -63,6 +63,15 @@ textarea { font: inherit; } +/* + * Firefox for Android may zoom to a focused search field. `manipulation` + * suppresses that focus-only zoom while retaining ordinary pan and pinch zoom. + */ +input[type='search'], +input[inputmode='search'] { + touch-action: manipulation; +} + /* * Ref's `.bg0/.bg1/.bg2` set a background image and nothing else, so the * element stays transparent where the texture does not cover it. Adding a diff --git a/app/game-frontend/src/main.ts b/app/game-frontend/src/main.ts index 025d426c..fa3cafb7 100644 --- a/app/game-frontend/src/main.ts +++ b/app/game-frontend/src/main.ts @@ -4,8 +4,10 @@ import App from './App.vue'; import router from './router'; import './assets/main.css'; import { installImageAssetCssVariables } from './utils/imageAssets'; +import { installScreenModeViewport } from './utils/screenModeViewport'; installImageAssetCssVariables(); +installScreenModeViewport(); const app = createApp(App); diff --git a/app/game-frontend/src/stores/mainDashboard.ts b/app/game-frontend/src/stores/mainDashboard.ts index 2b313082..37ad2a7f 100644 --- a/app/game-frontend/src/stores/mainDashboard.ts +++ b/app/game-frontend/src/stores/mainDashboard.ts @@ -220,7 +220,7 @@ export const useMainDashboardStore = defineStore('mainDashboard', () => { label: '외교메시지', color: '#000000', options: contacts - .filter((nation) => nation.mailbox !== ownMailbox && nation.nationId > 0) + .filter((nation) => nation.mailbox !== ownMailbox) .map((nation) => ({ label: nation.name, value: nation.mailbox, diff --git a/app/game-frontend/src/utils/screenModeViewport.ts b/app/game-frontend/src/utils/screenModeViewport.ts new file mode 100644 index 00000000..260fec55 --- /dev/null +++ b/app/game-frontend/src/utils/screenModeViewport.ts @@ -0,0 +1,78 @@ +export const SCREEN_MODE_KEY = 'sam.screenMode'; +export const SCREEN_MODE_CHANGE_EVENT = 'tryChangeScreenMode'; + +export type ScreenMode = 'auto' | '500px' | '1000px'; + +export type AutoViewportMeasurements = { + deviceWidth: number; + viewportHeight: number; + targetHeight?: number; +}; + +export const normalizeScreenMode = (value: string | null): ScreenMode => + value === '500px' || value === '1000px' ? value : 'auto'; + +export const resolveAutoViewportContent = ({ + deviceWidth, + viewportHeight, + targetHeight = 700, +}: AutoViewportMeasurements): string => { + if (deviceWidth < 500) { + return 'width=500'; + } + + if (viewportHeight < targetHeight) { + const widthAtTargetHeight = (deviceWidth / viewportHeight) * targetHeight; + return widthAtTargetHeight >= 700 ? 'width=1000' : `height=${Math.ceil(targetHeight)}`; + } + + return deviceWidth >= 700 ? 'width=1000' : 'width=device-width, initial-scale=1'; +}; + +export const resolveViewportContent = (mode: ScreenMode, measurements: AutoViewportMeasurements): string => { + if (mode === '500px') return 'width=500'; + if (mode === '1000px') return 'width=1000'; + return resolveAutoViewportContent(measurements); +}; + +const findOrCreateViewportMeta = (): HTMLMetaElement => { + const existing = document.querySelector('meta[name="viewport"]'); + if (existing) return existing; + + const viewportMeta = document.createElement('meta'); + viewportMeta.name = 'viewport'; + document.head.appendChild(viewportMeta); + return viewportMeta; +}; + +export const installScreenModeViewport = (targetHeight = 700): void => { + if (typeof window === 'undefined' || typeof document === 'undefined') return; + + const viewportMeta = findOrCreateViewportMeta(); + let previousMode: ScreenMode | null = null; + let previousDeviceWidth: number | null = null; + + const adjustViewport = () => { + const mode = normalizeScreenMode(window.localStorage.getItem(SCREEN_MODE_KEY)); + const deviceWidth = window.screen.availWidth; + + if (mode === previousMode && mode === 'auto' && deviceWidth === previousDeviceWidth) return; + if (mode === previousMode && mode !== 'auto') return; + + previousMode = mode; + previousDeviceWidth = deviceWidth; + viewportMeta.content = resolveViewportContent(mode, { + deviceWidth, + viewportHeight: window.innerHeight, + targetHeight, + }); + }; + + adjustViewport(); + window.addEventListener('resize', adjustViewport); + window.addEventListener('orientationchange', adjustViewport); + window.addEventListener('storage', (event) => { + if (event.key === SCREEN_MODE_KEY) adjustViewport(); + }); + document.addEventListener(SCREEN_MODE_CHANGE_EVENT, adjustViewport); +}; diff --git a/app/game-frontend/src/views/MyPageView.vue b/app/game-frontend/src/views/MyPageView.vue index 7d644259..bb6e5512 100644 --- a/app/game-frontend/src/views/MyPageView.vue +++ b/app/game-frontend/src/views/MyPageView.vue @@ -10,6 +10,7 @@ import { resolveGeneralIconUrl, useDefaultGeneralIcon } from '../utils/generalIc import LegacyGeneralProgress from '../components/ui/LegacyGeneralProgress.vue'; import GeneralBasicCard from '../components/main/GeneralBasicCard.vue'; import { useGameFeedback } from '../composables/useGameFeedback'; +import { SCREEN_MODE_CHANGE_EVENT, SCREEN_MODE_KEY, type ScreenMode } from '../utils/screenModeViewport'; import { DEFAULT_MOBILE_MAIN_PANEL_ORDER, loadMobileMainPanelOrder, @@ -19,11 +20,9 @@ import { type MobileMainPanelId, } from '../utils/mobileMainPanelOrder'; -const SCREEN_MODE_KEY = 'sam.screenMode'; const CUSTOM_CSS_KEY = 'sam_customCSS'; const PENDING_DIE_ON_PRESTART_KEY = 'sam.pending.dieOnPrestart'; const { success: showSuccessToast, error: showErrorToast, showDialog } = useGameFeedback(); -type ScreenMode = 'auto' | '500px' | '1000px'; type LogType = 'generalHistory' | 'battleDetail' | 'battleResult' | 'generalAction'; type ItemSlotKey = 'horse' | 'weapon' | 'book' | 'item'; type MyGeneralResponse = Awaited>; @@ -373,7 +372,7 @@ const dropItem = (item: { key: ItemSlotKey; slotName: string; displayName: strin watch(screenMode, (mode) => { localStorage.setItem(SCREEN_MODE_KEY, mode); - document.dispatchEvent(new CustomEvent('tryChangeScreenMode')); + document.dispatchEvent(new CustomEvent(SCREEN_MODE_CHANGE_EVENT)); }); watch(customCss, (text) => { diff --git a/app/game-frontend/test/screenModeViewport.test.ts b/app/game-frontend/test/screenModeViewport.test.ts new file mode 100644 index 00000000..45e426a9 --- /dev/null +++ b/app/game-frontend/test/screenModeViewport.test.ts @@ -0,0 +1,35 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + normalizeScreenMode, + resolveAutoViewportContent, + resolveViewportContent, +} from '../src/utils/screenModeViewport.ts'; + +void test('automatic mode follows the Ref physical-screen thresholds', () => { + assert.equal(resolveAutoViewportContent({ deviceWidth: 390, viewportHeight: 844 }), 'width=500'); + assert.equal( + resolveAutoViewportContent({ deviceWidth: 699, viewportHeight: 900 }), + 'width=device-width, initial-scale=1' + ); + assert.equal(resolveAutoViewportContent({ deviceWidth: 700, viewportHeight: 900 }), 'width=1000'); + assert.equal(resolveAutoViewportContent({ deviceWidth: 820, viewportHeight: 1180 }), 'width=1000'); +}); + +void test('automatic mode preserves the Ref short-viewport aspect-ratio branch', () => { + assert.equal(resolveAutoViewportContent({ deviceWidth: 600, viewportHeight: 650 }), 'height=700'); + assert.equal(resolveAutoViewportContent({ deviceWidth: 650, viewportHeight: 600 }), 'width=1000'); +}); + +void test('explicit modes override automatic measurements and invalid storage falls back to auto', () => { + const phone = { deviceWidth: 390, viewportHeight: 844 }; + const tablet = { deviceWidth: 820, viewportHeight: 1180 }; + + assert.equal(resolveViewportContent('1000px', phone), 'width=1000'); + assert.equal(resolveViewportContent('500px', tablet), 'width=500'); + assert.equal(normalizeScreenMode('1000px'), '1000px'); + assert.equal(normalizeScreenMode('500px'), '500px'); + assert.equal(normalizeScreenMode('unexpected'), 'auto'); + assert.equal(normalizeScreenMode(null), 'auto'); +}); diff --git a/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts b/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts index 4cd21d8a..32d1b86c 100644 --- a/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts +++ b/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts @@ -158,6 +158,20 @@ export const planProfileReconcile = ( }; }; +export const resolveResetLifecycleStatus = ( + now: Date, + preopenAt: Date | null, + openAt: Date | null +): Extract => { + if (preopenAt && preopenAt.getTime() > now.getTime()) { + return 'RESERVED'; + } + if (openAt && openAt.getTime() > now.getTime()) { + return 'PREOPEN'; + } + return 'RUNNING'; +}; + type GatewayAdminActionStatus = 'REQUESTED' | 'APPLIED' | 'FAILED' | 'IGNORED'; interface GatewayAdminActionRecord { @@ -880,7 +894,9 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { const now = this.now(); const due = await this.repository.listReservedToStart(now); for (const profile of due) { - if (!profile.preopenAt || !profile.openAt) { + const preopenAt = parseDateTime(profile.preopenAt); + const openAt = parseDateTime(profile.openAt); + if (!preopenAt || !openAt) { await this.repository.updateLastError( profile.profileName, 'Reserved profile is missing preopen/open schedule.' @@ -894,6 +910,18 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { ); continue; } + if (profile.currentScenario !== null && profile.buildStatus === 'SUCCEEDED' && profile.buildWorkspace) { + await this.repository.updateStatus( + profile.profileName, + resolveResetLifecycleStatus(now, preopenAt, openAt), + { + preopenAt: profile.preopenAt, + openAt: profile.openAt, + } + ); + await this.repository.updateLastError(profile.profileName, null); + continue; + } const queued = profile.buildStatus === 'QUEUED' || profile.buildStatus === 'RUNNING'; if (!queued) { await this.repository.updateBuildStatus(profile.profileName, 'QUEUED', { @@ -1884,8 +1912,7 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { await assertLease?.(); const completedAt = this.now().toISOString(); const now = this.now(); - const shouldPreopen = openAt ? openAt.getTime() > now.getTime() : false; - const desiredStatus = shouldPreopen ? 'PREOPEN' : 'RUNNING'; + const desiredStatus = resolveResetLifecycleStatus(now, preopenAt, openAt); const publishedProfile = await updateClaimedProfile( { currentScenario: String(scenarioId), @@ -1917,30 +1944,37 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle { } ); releasePrepared = true; - const builtProfile = publishedProfile ?? { - ...profile, - currentScenario: String(scenarioId), - scenario: String(scenarioId), - status: desiredStatus, - buildWorkspace: workspace.root, - }; - await appendLog('switch', '초기화된 profile process를 시작합니다.'); - const started = await this.startProfile(builtProfile, assertLease); - await appendLog('readiness', 'profile process readiness를 확인합니다.'); - const ready = started && (await this.waitForProfileReadiness(builtProfile, assertLease)); - if (!ready) { - if (started) { - await this.stopProfile(builtProfile, assertLease); - } - const detail = started - ? 'reset completed but profile processes failed readiness' - : 'reset completed but profile processes failed to start'; - await updateClaimedProfile({ status: 'STOPPED', lastError: detail }, () => - this.repository.updateStatus(profile.profileName, 'STOPPED') + if (desiredStatus === 'RESERVED') { + await appendLog( + 'schedule', + `${preopenAt?.toISOString() ?? '가오픈 시각'}까지 RESERVED 상태로 접속을 차단합니다.` ); - return { status: 'FAILED', detail }; + } else { + const builtProfile = publishedProfile ?? { + ...profile, + currentScenario: String(scenarioId), + scenario: String(scenarioId), + status: desiredStatus, + buildWorkspace: workspace.root, + }; + await appendLog('switch', '초기화된 profile process를 시작합니다.'); + const started = await this.startProfile(builtProfile, assertLease); + await appendLog('readiness', 'profile process readiness를 확인합니다.'); + const ready = started && (await this.waitForProfileReadiness(builtProfile, assertLease)); + if (!ready) { + if (started) { + await this.stopProfile(builtProfile, assertLease); + } + const detail = started + ? 'reset completed but profile processes failed readiness' + : 'reset completed but profile processes failed to start'; + await updateClaimedProfile({ status: 'STOPPED', lastError: detail }, () => + this.repository.updateStatus(profile.profileName, 'STOPPED') + ); + return { status: 'FAILED', detail }; + } + await appendLog('readiness', 'profile readiness 확인을 통과했습니다.'); } - await appendLog('readiness', 'profile readiness 확인을 통과했습니다.'); await updateClaimedProfile({ lastError: null }, async () => { await this.repository.updateLastError(profile.profileName, null); return this.repository.getProfile(profile.profileName); diff --git a/app/gateway-api/test/adminOperations.test.ts b/app/gateway-api/test/adminOperations.test.ts index e5a484b2..0d5402fd 100644 --- a/app/gateway-api/test/adminOperations.test.ts +++ b/app/gateway-api/test/adminOperations.test.ts @@ -700,6 +700,63 @@ describe('admin operation API', () => { }); }); + it('keeps reset start, preopen, and formal open as an ordered lifecycle', async () => { + const harness = await buildCaller(async (input) => ({ + id: '77777777-7777-4777-8777-777777777777', + profileName: input.profileName, + type: 'RESET', + status: 'QUEUED', + sourceMode: input.sourceMode, + sourceRef: input.sourceRef, + payload: input.payload ?? {}, + requestedBy: input.requestedBy, + scheduledAt: input.scheduledAt, + createdAt: '2026-08-08T00:00:00.000Z', + updatedAt: '2026-08-08T00:00:00.000Z', + })); + const install = { + scenarioId: 1010, + turnTermMinutes: 60, + sync: false, + fiction: 1 as const, + extend: false, + blockGeneralCreate: 0 as const, + npcMode: 0 as const, + showImgLevel: 0 as const, + tournamentTrig: false, + joinMode: 'full' as const, + preopenAt: '2099-01-01T01:00:00.000Z', + openAt: '2099-01-01T02:00:00.000Z', + }; + + await harness.caller.admin.operations.requestReset({ + profileName: 'che:2', + sourceMode: 'COMMIT', + sourceRef: 'HEAD', + scheduledAt: '2099-01-01T00:00:00.000Z', + install, + }); + + expect(harness.createdInputs[0]).toMatchObject({ + type: 'RESET', + scheduledAt: '2099-01-01T00:00:00.000Z', + payload: { install }, + }); + + await expect( + harness.caller.admin.operations.requestReset({ + profileName: 'che:2', + sourceMode: 'COMMIT', + sourceRef: 'HEAD', + scheduledAt: '2099-01-01T01:30:00.000Z', + install, + }) + ).rejects.toMatchObject({ + code: 'BAD_REQUEST', + message: 'preopenAt cannot be earlier than scheduledAt.', + }); + }); + it('returns validated profile reset defaults to a scenario-only operator', async () => { const harness = await buildCaller( async () => { diff --git a/app/gateway-api/test/orchestratorOperations.test.ts b/app/gateway-api/test/orchestratorOperations.test.ts index bd84fd50..aab4f046 100644 --- a/app/gateway-api/test/orchestratorOperations.test.ts +++ b/app/gateway-api/test/orchestratorOperations.test.ts @@ -48,6 +48,9 @@ const createHarness = ( startGate?: Promise, options: { profile?: GatewayProfileRecord; + profiles?: GatewayProfileRecord[]; + reservedToStart?: GatewayProfileRecord[]; + now?: () => Date; cancelGame?: GatewayOrchestratorOptions['cancelGame']; } = {} ) => { @@ -59,10 +62,11 @@ const createHarness = ( const started: ProcessDefinition[] = []; const stopped: string[] = []; const deleted: string[] = []; + const buildStatuses: string[] = []; const logs: Array<{ phase: string; message: string; level: string }> = []; const repository: GatewayProfileRepository = { - listProfiles: async () => [harnessProfile], + listProfiles: async () => options.profiles ?? [harnessProfile], getProfile: async () => harnessProfile, upsertProfile: async () => harnessProfile, updateCurrentScenario: async () => harnessProfile, @@ -70,9 +74,12 @@ const createHarness = ( statuses.push(status); return { ...harnessProfile, status }; }, - updateBuildStatus: async () => harnessProfile, + updateBuildStatus: async (_profileName, status) => { + buildStatuses.push(status); + return { ...harnessProfile, buildStatus: status }; + }, updateMeta: async () => harnessProfile, - listReservedToStart: async () => [], + listReservedToStart: async () => options.reservedToStart ?? [], findQueuedBuild: async () => null, updateLastError: async () => {}, updateWorkspaceUsage: async () => {}, @@ -167,10 +174,11 @@ const createHarness = ( scheduleIntervalMs: 60_000, buildIntervalMs: 60_000, adminActionIntervalMs: 60_000, + now: options.now, cancelGame: options.cancelGame, }); - return { orchestrator, statuses, completions, completionFields, started, stopped, deleted, logs }; + return { orchestrator, statuses, buildStatuses, completions, completionFields, started, stopped, deleted, logs }; }; describe('GatewayOrchestrator first-class operations', () => { @@ -267,6 +275,81 @@ describe('GatewayOrchestrator first-class operations', () => { expect(harness.deleted).toEqual([]); }); + it('opens a prepared reserved profile without rebuilding it again', async () => { + const now = new Date('2030-01-01T01:00:00.000Z'); + const reservedProfile: GatewayProfileRecord = { + ...profile, + status: 'RESERVED', + currentScenario: '1010', + scenario: '1010', + buildStatus: 'SUCCEEDED', + buildWorkspace: '/srv/sammo/worktrees/0123456789abcdef', + preopenAt: now.toISOString(), + openAt: '2030-01-01T02:00:00.000Z', + }; + const harness = createHarness(buildOperation('START'), false, false, false, false, undefined, undefined, { + profile: reservedProfile, + profiles: [], + reservedToStart: [reservedProfile], + now: () => now, + }); + + await harness.orchestrator.runScheduleNow(); + + expect(harness.statuses).toEqual(['PREOPEN']); + expect(harness.buildStatuses).toEqual([]); + }); + + it('starts turns when a prepared reserved profile is handled after formal open', async () => { + const now = new Date('2030-01-01T02:00:00.000Z'); + const reservedProfile: GatewayProfileRecord = { + ...profile, + status: 'RESERVED', + currentScenario: '1010', + scenario: '1010', + buildStatus: 'SUCCEEDED', + buildWorkspace: '/srv/sammo/worktrees/0123456789abcdef', + preopenAt: '2030-01-01T01:00:00.000Z', + openAt: now.toISOString(), + }; + const harness = createHarness(buildOperation('START'), false, false, false, false, undefined, undefined, { + profile: reservedProfile, + profiles: [], + reservedToStart: [reservedProfile], + now: () => now, + }); + + await harness.orchestrator.runScheduleNow(); + + expect(harness.statuses).toEqual(['RUNNING']); + expect(harness.buildStatuses).toEqual([]); + }); + + it('retains the legacy build queue for an unprepared reserved profile', async () => { + const now = new Date('2030-01-01T01:00:00.000Z'); + const reservedProfile: GatewayProfileRecord = { + ...profile, + status: 'RESERVED', + currentScenario: null, + scenario: 'default', + buildStatus: 'IDLE', + buildWorkspace: undefined, + preopenAt: now.toISOString(), + openAt: '2030-01-01T02:00:00.000Z', + }; + const harness = createHarness(buildOperation('START'), false, false, false, false, undefined, undefined, { + profile: reservedProfile, + profiles: [], + reservedToStart: [reservedProfile], + now: () => now, + }); + + await harness.orchestrator.runScheduleNow(); + + expect(harness.statuses).toEqual([]); + expect(harness.buildStatuses).toEqual(['QUEUED']); + }); + it('starts every profile process and records success', async () => { const harness = createHarness(buildOperation('START')); diff --git a/app/gateway-api/test/orchestratorPlan.test.ts b/app/gateway-api/test/orchestratorPlan.test.ts index 11d013cd..6fdbda66 100644 --- a/app/gateway-api/test/orchestratorPlan.test.ts +++ b/app/gateway-api/test/orchestratorPlan.test.ts @@ -8,6 +8,7 @@ import { buildProcessDefinitions, buildWorkspaceCommands, planProfileReconcile, + resolveResetLifecycleStatus, } from '../src/orchestrator/gatewayOrchestrator.js'; import { sanitizeManagedProcessEnv } from '../src/orchestrator/processManager.js'; import type { GatewayProfileRecord } from '../src/orchestrator/profileRepository.js'; @@ -108,6 +109,29 @@ describe('planProfileReconcile', () => { }); }); +describe('resolveResetLifecycleStatus', () => { + const now = new Date('2030-01-01T00:00:00.000Z'); + + it('keeps an initialized profile reserved until the configured preopen time', () => { + expect( + resolveResetLifecycleStatus(now, new Date('2030-01-01T01:00:00.000Z'), new Date('2030-01-01T02:00:00.000Z')) + ).toBe('RESERVED'); + }); + + it('moves through preopen before the formal open time', () => { + expect( + resolveResetLifecycleStatus(now, new Date('2029-12-31T23:00:00.000Z'), new Date('2030-01-01T02:00:00.000Z')) + ).toBe('PREOPEN'); + }); + + it('runs immediately when no future lifecycle boundary remains', () => { + expect(resolveResetLifecycleStatus(now, null, null)).toBe('RUNNING'); + expect( + resolveResetLifecycleStatus(now, new Date('2029-12-31T22:00:00.000Z'), new Date('2029-12-31T23:00:00.000Z')) + ).toBe('RUNNING'); + }); +}); + describe('buildProcessDefinitions', () => { const processConfig = { workspaceRoot: '/srv/sammo/main', diff --git a/app/gateway-frontend/e2e/server-operations.spec.ts b/app/gateway-frontend/e2e/server-operations.spec.ts index 86369af4..2fb6f730 100644 --- a/app/gateway-frontend/e2e/server-operations.spec.ts +++ b/app/gateway-frontend/e2e/server-operations.spec.ts @@ -600,7 +600,15 @@ test('separates branch and commit semantics and submits a reset from the dedicat await page.getByTestId('source-ref').fill('0123456789abcdef0123456789abcdef01234567'); await page.getByTestId('load-scenarios').click(); await page.getByTestId('scenario-select').selectOption('5'); - await page.getByLabel('작업 예약 (서버 시간 UTC+9)').fill('2026-08-13T09:30'); + await expect(page.getByText('초기화 시작 → 가오픈 시작 → 정식 오픈 순서입니다.')).toBeVisible(); + 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 scheduledHelp = page.getByTestId('reset-help-scheduled-at'); + await scheduledHelp.hover(); + await expect(page.getByTestId('reset-help-scheduled-at-tooltip')).toContainText( + '완료되어도 가오픈 전에는 접속을 차단합니다.' + ); await page.getByTestId('request-reset').hover(); await page.getByTestId('request-reset').click(); @@ -655,7 +663,9 @@ test('separates branch and commit semantics and submits a reset from the dedicat expect(JSON.stringify(resetRequest?.body)).toContain('"sourceMode":"COMMIT"'); expect(JSON.stringify(resetRequest?.body)).toContain('0123456789abcdef0123456789abcdef01234567'); expect(JSON.stringify(resetRequest?.body)).toContain('"scenarioId":5'); - expect(JSON.stringify(resetRequest?.body)).toContain('"scheduledAt":"2026-08-13T00:30:00.000Z"'); + expect(JSON.stringify(resetRequest?.body)).toContain('"scheduledAt":"2030-08-13T00:30: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"'); await page.screenshot({ path: testInfo.outputPath('reset-operation-log-desktop.png'), fullPage: true }); await page.setViewportSize({ width: 390, height: 844 }); @@ -1046,7 +1056,7 @@ test('uses ref reset terms with compact hover, focus, and mobile help', async ({ ]); const helpButtons = page.getByRole('button', { name: /도움말$/ }); - await expect(helpButtons).toHaveCount(10); + await expect(helpButtons).toHaveCount(13); const fictionHelp = page.getByTestId('reset-help-fiction'); const fictionTooltip = page.getByTestId('reset-help-fiction-tooltip'); await expect(fictionTooltip).toBeHidden(); diff --git a/app/gateway-frontend/src/views/ServerOperationsView.vue b/app/gateway-frontend/src/views/ServerOperationsView.vue index 9249d86f..6044c425 100644 --- a/app/gateway-frontend/src/views/ServerOperationsView.vue +++ b/app/gateway-frontend/src/views/ServerOperationsView.vue @@ -162,6 +162,20 @@ const RESET_AUTORUN_FORM_KEYS = { battle: 'autorunBattle', chief: 'autorunChief', } as const satisfies Record; +const RESET_SCHEDULE_COPY = { + scheduledAt: { + label: '초기화 시작', + help: 'Gateway가 빌드, DB 초기화와 시나리오 생성을 시작합니다. 비우면 즉시 시작하며, 완료되어도 가오픈 전에는 접속을 차단합니다.', + }, + preopenAt: { + label: '가오픈 시작', + help: '게임 접속과 장수 생성, 예약턴 입력을 허용하지만 턴은 진행하지 않습니다. 가오픈을 비우고 정식 오픈만 지정하면 초기화 완료 후 바로 가오픈합니다.', + }, + openAt: { + label: '정식 오픈', + help: '턴 진행을 시작합니다. 비우면 초기화가 완료되는 즉시 정식 오픈합니다.', + }, +} as const; const gatewayForm = reactive({ sourceMode: 'BRANCH' as 'BRANCH' | 'COMMIT', sourceRef: 'main', @@ -1313,31 +1327,66 @@ onBeforeUnmount(() => { -
- - - +
+

+ 초기화 시작 → 가오픈 시작 → 정식 오픈 순서입니다. 초기화 시작을 비우면 바로 작업합니다. +

+
+
+
+ + + (선택 · UTC+9) +
+ +
+
+
+ + + (선택 · UTC+9) +
+ +
+
+
+ + + (선택 · UTC+9) +
+ +
+
({ result: { data } }); const errorResponse = (path: string, message: string) => ({ error: { @@ -42,7 +45,35 @@ const general = { const generalContext = { general, city: null, - nation: null, + nation: { + id: 1, + name: '테스트국', + color: '#d32f2f', + level: 1, + levelName: '군벌', + gold: 1000, + rice: 1000, + tech: 1000, + typeCode: 'test', + typeName: '테스트', + typePros: '-', + typeCons: '-', + capitalCityId: 1, + capitalCityName: '낙양', + population: { cityCount: 1, current: 10000, max: 20000 }, + crew: { generalCount: 2, current: 1000, max: 16000 }, + power: 100, + bill: 10, + taxRate: 10, + strategicCommandLimit: 0, + diplomaticLimit: 0, + prohibitScout: false, + prohibitWar: false, + techLevel: 1, + techLimited: false, + topChiefs: { 12: null, 11: null }, + impossibleStrategicCommands: [], + }, settings: {}, penalties: {}, }; @@ -373,14 +404,25 @@ for (const viewport of [ boxShadow: getComputedStyle(element).boxShadow, })) ).toEqual({ outlineWidth: '0px', boxShadow: 'none' }); + + if (artifactRoot) { + await mkdir(artifactRoot, { recursive: true }); + await page.locator('.MessagePanel').screenshot({ + path: resolve(artifactRoot, `message-panel-${viewport.width}.png`), + animations: 'disabled', + }); + } }); } -test('exposes ambassador targets, reply, read, delete, and successful send interactions', async ({ page }) => { +test('exposes nation targets including wanderers, reply, read, delete, and successful send interactions', async ({ + page, +}) => { const mutations = await installFixture(page, { permission: 4 }); await openMessages(page, { width: 500, height: 900 }); const select = page.getByLabel('메시지 수신 대상'); + await expect(select.locator('optgroup[label="외교메시지"] option[value="9000"]')).toHaveText('재야'); await expect(select.locator('option[value="9002"]')).toHaveCount(1); await expect(select.locator('option[value="8"]')).toBeDisabled(); await expect(select.locator('option[value="9"]')).toBeEnabled(); @@ -396,11 +438,21 @@ test('exposes ambassador targets, reply, read, delete, and successful send inter await deleteButton.click(); await expect.poll(() => mutations.filter((entry) => entry.operation === 'messages.delete').length).toBe(1); - await select.selectOption('9999'); - await page.getByLabel('메시지 입력').fill('전송 성공'); + await select.selectOption('9000'); + await page.getByLabel('메시지 입력').fill('우리 나라로 와주세요'); + if (artifactRoot) { + await mkdir(artifactRoot, { recursive: true }); + await page.locator('.MessageInputForm').screenshot({ + path: resolve(artifactRoot, 'wanderer-recruitment-target-500.png'), + animations: 'disabled', + }); + } await page.getByRole('button', { name: '서신전달&갱신' }).click(); await expect(page.getByLabel('메시지 입력')).toHaveValue(''); await expect.poll(() => mutations.filter((entry) => entry.operation === 'messages.send').length).toBe(1); + expect(JSON.stringify(mutations.find((entry) => entry.operation === 'messages.send')?.body)).toContain( + '"mailbox":9000' + ); }); test('accepts recruitment and declines invader prompts through private-message controls', async ({ page }) => { @@ -442,6 +494,7 @@ test('redacts diplomacy for a low-permission general and preserves the failed-se await openMessages(page, { width: 500, height: 900 }); const select = page.getByLabel('메시지 수신 대상'); + await expect(select.locator('option[value="9000"]')).toHaveCount(0); await expect(select.locator('option[value="9002"]')).toHaveCount(0); await expect(page.locator('.DiplomacyTalk')).toContainText('삭제된 메시지입니다'); await expect(page.locator('.DiplomacyTalk')).not.toContainText('외교 메시지 본문'); diff --git a/tools/frontend-legacy-parity/reference-ingame-message.mjs b/tools/frontend-legacy-parity/reference-ingame-message.mjs index 45c147eb..3f2e447e 100644 --- a/tools/frontend-legacy-parity/reference-ingame-message.mjs +++ b/tools/frontend-legacy-parity/reference-ingame-message.mjs @@ -65,6 +65,14 @@ const measure = async (browser, name, viewport) => { await ensureGeneral(page); await page.locator('.MessagePanel').waitFor({ state: 'visible', timeout: 30_000 }); await page.locator('.BoardHeader').first().waitFor({ state: 'visible' }); + const mailboxOptions = await page.locator('.MessageInputForm select option').evaluateAll((options) => + options.map((option) => ({ + value: option.value, + label: option.textContent?.trim() ?? '', + group: option.parentElement?.tagName === 'OPTGROUP' ? option.parentElement.label : '', + disabled: option.disabled, + })) + ); const marker = `computed-dom-${name}-${Date.now()}`; await page.locator('.MessageInputForm select').selectOption('9999'); await page.locator('.MessageInputForm input').fill(marker); @@ -160,7 +168,11 @@ const measure = async (browser, name, viewport) => { page.once('dialog', (dialog) => dialog.accept()); await deleteButton.click(); } - return { ...result, interaction: { hover, focus } }; + return { + ...result, + mailboxOptions, + interaction: { hover, focus }, + }; } finally { await context.close(); }