diff --git a/app/game-api/src/router/dynasty/index.ts b/app/game-api/src/router/dynasty/index.ts index 89cd8a4c..c7c79380 100644 --- a/app/game-api/src/router/dynasty/index.ts +++ b/app/game-api/src/router/dynasty/index.ts @@ -4,11 +4,13 @@ import { z } from 'zod'; import { asRecord } from '@sammo-ts/common'; import { procedure, router } from '../../trpc.js'; +import type { LegacyEmperorRow } from '../../services/legacyArchiveStore.js'; import { findLegacyEmperor, - findLegacyEmperors, + findLegacyEmperorsByProfile, findLegacyGeneralsForServer, findLegacyNations, + isLegacyArchiveProfile, } from '../../services/legacyArchiveStore.js'; const zDynastyDetailInput = z.object({ @@ -65,7 +67,7 @@ const firstText = (...values: unknown[]): string => { return ''; }; -const legacyEmperorListEntry = (row: Awaited>[number]) => { +const legacyEmperorListEntry = (row: LegacyEmperorRow) => { const data = asRecord(row.data); return { id: Number(row.id), @@ -132,7 +134,9 @@ const formatNationLevel = (level: number | null): string => { export const dynastyRouter = router({ getList: procedure.input(zDynastyListInput).query(async ({ ctx, input }) => { if ((input?.source ?? 'current') === 'legacy') { - const rows = await findLegacyEmperors(ctx.db); + const rows = isLegacyArchiveProfile(ctx.profile.id) + ? await findLegacyEmperorsByProfile(ctx.db, ctx.profile.id) + : []; return { source: 'legacy' as const, current: null, @@ -186,7 +190,12 @@ export const dynastyRouter = router({ }), getDetail: procedure.input(zDynastyDetailInput).query(async ({ ctx, input }) => { if (input.source === 'legacy') { - const archived = await findLegacyEmperor(ctx.db, input.emperorId); + const archived = isLegacyArchiveProfile(ctx.profile.id) + ? await findLegacyEmperor(ctx.db, { + id: input.emperorId, + sourceProfile: ctx.profile.id, + }) + : null; if (!archived) { throw new TRPCError({ code: 'NOT_FOUND', message: '이전 서버 왕조 정보를 찾을 수 없습니다.' }); } diff --git a/app/game-api/src/services/legacyArchiveStore.ts b/app/game-api/src/services/legacyArchiveStore.ts index 8a35346a..b65f932c 100644 --- a/app/game-api/src/services/legacyArchiveStore.ts +++ b/app/game-api/src/services/legacyArchiveStore.ts @@ -270,7 +270,26 @@ export const findLegacyEmperors = async ( `); }; -export const findLegacyEmperor = async (db: LegacyArchiveDatabase, id: number): Promise => { +export const findLegacyEmperorsByProfile = async ( + db: LegacyArchiveDatabase, + sourceProfile: LegacyArchiveProfile +): Promise => + db.$queryRaw(GamePrisma.sql` + SELECT + "id", + "source_profile" AS "sourceProfile", + "legacy_id" AS "legacyId", + "server_id" AS "serverId", + "data" + FROM "legacy_archive"."emperor" + WHERE "source_profile" = ${sourceProfile} + ORDER BY "id" DESC + `); + +export const findLegacyEmperor = async ( + db: LegacyArchiveDatabase, + input: { id: number; sourceProfile: LegacyArchiveProfile } +): Promise => { const rows = await db.$queryRaw(GamePrisma.sql` SELECT "id", @@ -279,7 +298,8 @@ export const findLegacyEmperor = async (db: LegacyArchiveDatabase, id: number): "server_id" AS "serverId", "data" FROM "legacy_archive"."emperor" - WHERE "id" = ${id} + WHERE "id" = ${input.id} + AND "source_profile" = ${input.sourceProfile} LIMIT 1 `); return rows[0] ?? null; diff --git a/app/game-api/test/dynastyRouter.test.ts b/app/game-api/test/dynastyRouter.test.ts index e4cc7f25..c71327f4 100644 --- a/app/game-api/test/dynastyRouter.test.ts +++ b/app/game-api/test/dynastyRouter.test.ts @@ -120,20 +120,24 @@ const authFor = (userId: string, roles: string[] = []): GameSessionTokenPayload const buildContext = ( auth: GameSessionTokenPayload | null, - oldNations: Array> = [oldNation, deletedOldNation] + oldNations: Array> = [oldNation, deletedOldNation], + profileId = profile.id ): GameApiContext => { + const selectedProfile = { ...profile, id: profileId, name: `${profileId}:default` }; const db = { - $queryRaw: async (query: { strings?: readonly string[] }) => { + $queryRaw: async (query: { strings?: readonly string[]; values?: unknown[] }) => { const sql = query.strings?.join(' ') ?? ''; if (sql.includes('legacy_archive"."emperor')) { + if (!query.values?.includes(selectedProfile.id)) return []; + if (sql.includes('WHERE "id"') && !query.values.includes(101)) return []; return [ { id: 101n, - sourceProfile: 'hwe', + sourceProfile: selectedProfile.id, legacyId: 7, serverId: emperor.serverId, data: { - phase: '이전 훼2기', + phase: `이전 ${selectedProfile.id.toUpperCase()} 2기`, nation_count: emperor.nationCount, nation_name: emperor.nationName, nation_hist: emperor.nationHist, @@ -170,9 +174,10 @@ const buildContext = ( ]; } if (sql.includes('legacy_archive"."nation')) { + if (!query.values?.includes(selectedProfile.id)) return []; return [ { - sourceProfile: 'hwe', + sourceProfile: selectedProfile.id, legacyId: oldNation.id, serverId: oldNation.serverId, nation: oldNation.nation, @@ -182,6 +187,7 @@ const buildContext = ( ]; } if (sql.includes('legacy_archive"."general')) { + if (!query.values?.includes(selectedProfile.id)) return []; return [ { generalNo: 11, name: '유비', lastYearMonth: 21504 }, { generalNo: 12, name: '제갈량', lastYearMonth: 21504 }, @@ -217,13 +223,13 @@ const buildContext = ( db: db as unknown as DatabaseClient, turnDaemon: new InMemoryTurnDaemonTransport(), battleSim: new InMemoryBattleSimTransport(), - profile, + profile: selectedProfile, auth, uploadDir: 'uploads', uploadPath: '/uploads', uploadPublicUrl: null, redis, - accessTokenStore: new RedisAccessTokenStore(redis, profile.name), + accessTokenStore: new RedisAccessTokenStore(redis, selectedProfile.name), flushStore: new InMemoryFlushStore(), gameTokenSecret: 'test-secret', }; @@ -252,27 +258,31 @@ describe('dynasty public read model', () => { ]); }); - it('reads previous-server dynasties only when the archive source is selected', async () => { - const caller = appRouter.createCaller(buildContext(null)); - const list = await caller.dynasty.getList({ source: 'legacy' }); - expect(list).toMatchObject({ + it('scopes previous-server dynasties and detail to the request profile', async () => { + const cheCaller = appRouter.createCaller(buildContext(null)); + const cheList = await cheCaller.dynasty.getList({ source: 'legacy' }); + expect(cheList).toMatchObject({ source: 'legacy', current: null, entries: [ expect.objectContaining({ id: 101, source: 'legacy', - sourceProfile: 'hwe', - phase: '이전 훼2기', + sourceProfile: 'che', + phase: '이전 CHE 2기', }), ], }); - const detail = await caller.dynasty.getDetail({ emperorId: 101, source: 'legacy' }); - expect(detail).toMatchObject({ + const staleListInput = { source: 'legacy' as const, sourceProfile: 'hwe' as const }; + const staleList = await cheCaller.dynasty.getList(staleListInput); + expect(staleList.entries.map((entry) => entry.sourceProfile)).toEqual(['che']); + + const cheDetail = await cheCaller.dynasty.getDetail({ emperorId: 101, source: 'legacy' }); + expect(cheDetail).toMatchObject({ source: 'legacy', - sourceProfile: 'hwe', - emperor: expect.objectContaining({ id: 101, phase: '이전 훼2기', name: '촉' }), + sourceProfile: 'che', + emperor: expect.objectContaining({ id: 101, phase: '이전 CHE 2기', name: '촉' }), nations: [ expect.objectContaining({ name: '촉', @@ -283,6 +293,22 @@ describe('dynasty public read model', () => { }), ], }); + + const staleDetailInput = { emperorId: 101, source: 'legacy' as const, sourceProfile: 'hwe' as const }; + const staleDetail = await cheCaller.dynasty.getDetail(staleDetailInput); + expect(staleDetail.sourceProfile).toBe('che'); + + const hweCaller = appRouter.createCaller(buildContext(null, undefined, 'hwe')); + const hweList = await hweCaller.dynasty.getList({ source: 'legacy' }); + expect(hweList.entries).toEqual([expect.objectContaining({ sourceProfile: 'hwe', phase: '이전 HWE 2기' })]); + const hweDetail = await hweCaller.dynasty.getDetail({ emperorId: 101, source: 'legacy' }); + expect(hweDetail.sourceProfile).toBe('hwe'); + + const developmentCaller = appRouter.createCaller(buildContext(null, undefined, 'development')); + await expect(developmentCaller.dynasty.getList({ source: 'legacy' })).resolves.toMatchObject({ entries: [] }); + await expect(developmentCaller.dynasty.getDetail({ emperorId: 101, source: 'legacy' })).rejects.toMatchObject({ + code: 'NOT_FOUND', + }); }); it('exposes the same public DTO to anonymous, general owners and admins', async () => { diff --git a/app/game-frontend/e2e/inGameMenus.spec.ts b/app/game-frontend/e2e/inGameMenus.spec.ts index 54ab7cd7..afd6bcc7 100644 --- a/app/game-frontend/e2e/inGameMenus.spec.ts +++ b/app/game-frontend/e2e/inGameMenus.spec.ts @@ -1231,7 +1231,9 @@ test('내 정보&설정의 지난 플레이는 기본 탐색과 분리되어 오 const actions = element.querySelector('.title-actions')!.getBoundingClientRect(); const navigation = element.querySelector('.navigation-actions')!.getBoundingClientRect(); const back = element.querySelector('.navigation-actions a')!.getBoundingClientRect(); - const refresh = element.querySelector('.navigation-actions button')!.getBoundingClientRect(); + const refresh = element + .querySelector('.navigation-actions button')! + .getBoundingClientRect(); const past = element.querySelector('.past-plays-link')!.getBoundingClientRect(); const pastStyle = getComputedStyle(element.querySelector('.past-plays-link')!); return { @@ -1269,6 +1271,77 @@ test('내 정보&설정의 지난 플레이는 기본 탐색과 분리되어 오 } }); +test('내 정보&설정에서 모바일 메인 패널을 드래그하거나 버튼으로 재정렬하고 Ref 순서로 복원한다', async ({ page }) => { + const state: FixtureState = { permission: 'head', myset: 3, settingMutations: [], accessPages: [] }; + await install(page, state); + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto('my-page'); + + await page.getByRole('button', { name: '순서 바꾸기', exact: true }).click(); + const dialog = page.getByRole('dialog', { name: '모바일 레이아웃 순서 바꾸기' }); + await expect(dialog).toBeVisible(); + const readOrder = () => + dialog + .locator('[data-mobile-layout-id]') + .evaluateAll((elements) => elements.map((element) => element.getAttribute('data-mobile-layout-id'))); + const defaultOrder = [ + 'commands', + 'nation-menu', + 'nation', + 'general', + 'city', + 'map', + 'records', + 'global-menu', + 'messages', + ]; + await expect.poll(readOrder).toEqual(defaultOrder); + + await dialog + .locator('[data-mobile-layout-id="messages"]') + .dragTo(dialog.locator('[data-mobile-layout-id="commands"]')); + await expect + .poll(readOrder) + .toEqual(['messages', 'commands', 'nation-menu', 'nation', 'general', 'city', 'map', 'records', 'global-menu']); + await dialog.getByRole('button', { name: '지도 위로' }).click(); + await expect + .poll(readOrder) + .toEqual(['messages', 'commands', 'nation-menu', 'nation', 'general', 'map', 'city', 'records', 'global-menu']); + + const dialogGeometry = await dialog.evaluate((element) => { + const rect = element.getBoundingClientRect(); + const firstItem = element.querySelector('[data-mobile-layout-id]')?.getBoundingClientRect(); + const moveButton = element.querySelector('[aria-label$="아래로"]')?.getBoundingClientRect(); + return { + rect: rect.toJSON(), + firstItem: firstItem?.toJSON() ?? null, + moveButton: moveButton?.toJSON() ?? null, + overflowX: getComputedStyle(element).overflowX, + documentWidth: document.documentElement.scrollWidth, + }; + }); + expect(dialogGeometry.rect.left).toBeGreaterThanOrEqual(0); + expect(dialogGeometry.rect.right).toBeLessThanOrEqual(390); + expect(dialogGeometry.firstItem?.height).toBeGreaterThanOrEqual(44); + expect(dialogGeometry.moveButton?.width).toBeGreaterThanOrEqual(36); + expect(dialogGeometry.documentWidth).toBe(390); + await persistParityArtifact(page, 'core-my-page-mobile-layout-order-dialog', dialogGeometry); + + await dialog.getByRole('button', { name: '적용', exact: true }).click(); + await expect(dialog).toBeHidden(); + await expect + .poll(() => page.evaluate(() => JSON.parse(localStorage.getItem('sam.mobileMainPanelOrder.v1') ?? '[]'))) + .toEqual(['messages', 'commands', 'nation-menu', 'nation', 'general', 'map', 'city', 'records', 'global-menu']); + + await page.getByRole('button', { name: '순서 바꾸기', exact: true }).click(); + await dialog.getByRole('button', { name: 'Ref 초깃값' }).click(); + await expect.poll(readOrder).toEqual(defaultOrder); + await dialog.getByRole('button', { name: '적용', exact: true }).click(); + await expect + .poll(() => page.evaluate(() => JSON.parse(localStorage.getItem('sam.mobileMainPanelOrder.v1') ?? '[]'))) + .toEqual(defaultOrder); +}); + for (const [label, failure] of [ ['daemon timeout', 'TIMEOUT'], ['engine transaction 오류', 'INTERNAL_SERVER_ERROR'], diff --git a/app/game-frontend/e2e/legacyArchiveViews.spec.ts b/app/game-frontend/e2e/legacyArchiveViews.spec.ts index 3e02076b..12b1b42b 100644 --- a/app/game-frontend/e2e/legacyArchiveViews.spec.ts +++ b/app/game-frontend/e2e/legacyArchiveViews.spec.ts @@ -11,6 +11,7 @@ const isLegacyRequest = (route: Route): boolean => const installArchiveViews = async (page: Page) => { const hallRequests: string[] = []; + const dynastyRequests: string[] = []; await page.addInitScript((profile) => { localStorage.setItem('sammo-game-token', 'ga_archive_views'); localStorage.setItem('sammo-game-profile', profile); @@ -21,6 +22,9 @@ const installArchiveViews = async (page: Page) => { if (operations.some((operation) => operation.startsWith('ranking.getHallOfFame'))) { hallRequests.push(decodeURIComponent(`${route.request().url()} ${route.request().postData() ?? ''}`)); } + if (operations.some((operation) => operation.startsWith('dynasty.'))) { + dynastyRequests.push(decodeURIComponent(`${route.request().url()} ${route.request().postData() ?? ''}`)); + } const results = operations.map((operation) => { if (operation === 'auth.status') return response({ ok: true }); if (operation === 'lobby.info') return response({ myGeneral: { id: 1, name: '기록장수' } }); @@ -67,8 +71,8 @@ const installArchiveViews = async (page: Page) => { { id: legacy ? 101 : 1, source: legacy ? 'legacy' : 'current', - sourceProfile: legacy ? 'hwe' : 'che', - serverId: legacy ? 'hwe-old-1' : 'che-current-1', + sourceProfile: 'che', + serverId: legacy ? 'che-old-1' : 'che-current-1', phase: legacy ? '이전 1기' : '현재 1기', name: '촉', year: 215, @@ -93,10 +97,10 @@ const installArchiveViews = async (page: Page) => { if (operation === 'dynasty.getDetail') { return response({ source: 'legacy', - sourceProfile: 'hwe', + sourceProfile: 'che', emperor: { id: 101, - serverId: 'hwe-old-1', + serverId: 'che-old-1', winnerNationId: 1, phase: '이전 1기', nationCount: '1 / 2', @@ -189,7 +193,7 @@ const installArchiveViews = async (page: Page) => { }); await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(results) }); }); - return { hallRequests }; + return { dynastyRequests, hallRequests }; }; test('명예의 전당은 현재 profile의 현재·이전 서버 기록만 조회한다', async ({ page }, testInfo) => { @@ -217,19 +221,36 @@ test('명예의 전당은 현재 profile의 현재·이전 서버 기록만 조 await page.screenshot({ path: testInfo.outputPath('hall-profile-scope-mobile.png'), fullPage: true }); }); -test('왕조 일람과 상세는 이전 서버 source와 profile을 유지한다', async ({ page }) => { - await installArchiveViews(page); +test('왕조 일람과 상세는 현재 profile의 이전 서버 기록만 조회한다', async ({ page }, testInfo) => { + const state = await installArchiveViews(page); await page.setViewportSize({ width: 1200, height: 800 }); await page.goto('dynasty'); await expect(page.getByText('현재 1기')).toBeVisible(); + await page.getByLabel('기록 구분').focus(); + await expect(page.getByLabel('기록 구분')).toBeFocused(); await page.getByLabel('기록 구분').selectOption('legacy'); - await expect(page.getByText(/이전 1기.*HWE 이전 서버/)).toBeVisible(); + await expect(page.getByText(/이전 1기.*이전 서버/)).toBeVisible(); + await expect(page.getByText(/CHE 이전 서버|HWE 이전 서버/)).toHaveCount(0); + await expect(page.locator('.dynasty-page')).toHaveCSS('width', '1000px'); + await expect(page.locator('.dynasty-table')).toHaveCSS('height', '139px'); + await expect(page.locator('.dynasty-table .phase-heading')).toHaveCSS('background-color', 'rgb(135, 206, 235)'); + await page.screenshot({ path: testInfo.outputPath('dynasty-list-profile-scope-desktop.png'), fullPage: true }); + + await page.setViewportSize({ width: 390, height: 844 }); + await expect(page.locator('.dynasty-page')).toHaveCSS('width', '1000px'); + await page.screenshot({ path: testInfo.outputPath('dynasty-list-profile-scope-mobile.png'), fullPage: true }); + + await page.setViewportSize({ width: 1200, height: 800 }); const detailLink = page.getByRole('link', { name: '자세히' }); await expect(detailLink).toHaveAttribute('href', /dynasty\/101\?source=legacy$/); await detailLink.click(); - await expect(page.getByText(/이전 1기.*HWE 이전 서버/)).toBeVisible(); + await expect(page.getByText(/이전 1기.*이전 서버/)).toBeVisible(); + await expect(page.getByText(/CHE 이전 서버|HWE 이전 서버/)).toHaveCount(0); await expect(page.locator('.dynasty-page')).toHaveCSS('width', '1000px'); + expect(state.dynastyRequests.some((request) => request.includes('legacy'))).toBe(true); + expect(state.dynastyRequests.every((request) => !request.includes('sourceProfile'))).toBe(true); + await page.screenshot({ path: testInfo.outputPath('dynasty-detail-profile-scope-desktop.png'), fullPage: true }); }); test('연감 국가 라벨은 밝은 배경에 검정, 어두운 배경에 흰 글자를 사용한다', async ({ page }, testInfo) => { diff --git a/app/game-frontend/e2e/mainNavigation.spec.ts b/app/game-frontend/e2e/mainNavigation.spec.ts index 4e53a936..a7fb2e50 100644 --- a/app/game-frontend/e2e/mainNavigation.spec.ts +++ b/app/game-frontend/e2e/mainNavigation.spec.ts @@ -95,11 +95,10 @@ type DashboardBundleInput = { const operationInput = (route: Route, index: number): DashboardBundleInput => { const request = route.request(); const queryInput = new URL(request.url()).searchParams.get('input'); - const parsed = (request.postData() - ? request.postDataJSON() - : queryInput - ? JSON.parse(queryInput) - : {}) as Record; + const parsed = (request.postData() ? request.postDataJSON() : queryInput ? JSON.parse(queryInput) : {}) as Record< + string, + unknown + >; const entry = (parsed[String(index)] ?? parsed) as { json?: DashboardBundleInput }; return entry.json ?? (entry as DashboardBundleInput); }; @@ -253,32 +252,32 @@ const commandTableFixture = (large: boolean, blockedCount = 0, refCategories = f general: draftCommands ? draftCommandGroups : refCategories - ? refCommandCategoryFixture - : large - ? ['내정', '군사', '계략'].map((category, categoryIndex) => ({ - category, - values: Array.from({ length: 16 }, (_, localIndex) => { - const index = categoryIndex * 16 + localIndex; - return { - key: `command-${index}`, - name: index === 0 ? '주민 선정과 장기 도시 개발' : `명령 ${index}`, - reqArg: index % 2 === 0, - possible: index >= blockedCount, - status: index >= blockedCount ? 'available' : 'blocked', - inputFields: [ - { - key: 'amount', - label: '수량', - kind: 'number', - required: true, - min: 1, - max: 10_000, - }, - ], - }; - }), - })) - : [], + ? refCommandCategoryFixture + : large + ? ['내정', '군사', '계략'].map((category, categoryIndex) => ({ + category, + values: Array.from({ length: 16 }, (_, localIndex) => { + const index = categoryIndex * 16 + localIndex; + return { + key: `command-${index}`, + name: index === 0 ? '주민 선정과 장기 도시 개발' : `명령 ${index}`, + reqArg: index % 2 === 0, + possible: index >= blockedCount, + status: index >= blockedCount ? 'available' : 'blocked', + inputFields: [ + { + key: 'amount', + label: '수량', + kind: 'number', + required: true, + min: 1, + max: 10_000, + }, + ], + }; + }), + })) + : [], nation: [], inputOptions: { cities: Array.from({ length: 20 }, (_, index) => ({ value: index + 1, label: `도시 ${index + 1}` })), @@ -500,7 +499,7 @@ const installFixture = async (page: Page, state: NavigationFixture) => { ? response({ id: 'user-7', username: 'menu-user', displayName: '메뉴 사용자' }) : operation === 'navigation.get' ? response(runtimeNavigation) - : response({ ok: true }) + : response({ ok: true }) ); await route.fulfill({ status: 200, @@ -1558,10 +1557,7 @@ test('message targets keep reply behavior and use nation-color contrast in label await page.setViewportSize({ width: 500, height: 900 }); const mobilePanel = page.locator('.mobile-message-panel'); - await expect(mobilePanel.locator('.msg-plate[data-id="101"] .msg-target')).toHaveCSS( - 'color', - 'rgb(255, 255, 255)' - ); + await expect(mobilePanel.locator('.msg-plate[data-id="101"] .msg-target')).toHaveCSS('color', 'rgb(255, 255, 255)'); await expect(mobilePanel.locator('.msg-plate[data-id="103"] .msg-target')).toHaveCSS('color', 'rgb(0, 0, 0)'); await expect(mobilePanel.locator('#mailbox_list optgroup[label="밝은국"]')).toHaveCSS('color', 'rgb(0, 0, 0)'); await persistArtifact(page, `${basePath.slice(1)}-message-nation-contrast-mobile-500`); @@ -2217,6 +2213,49 @@ test('the 939/940 boundary switches to the Ref-style 500px single document', asy ]) { await expect(page.locator(selector)).toBeVisible(); } + const readMobilePanelOrder = () => + page + .locator('.layout-mobile > [data-mobile-panel-id]') + .evaluateAll((elements) => elements.map((element) => element.getAttribute('data-mobile-panel-id'))); + await expect + .poll(readMobilePanelOrder) + .toEqual(['commands', 'nation-menu', 'nation', 'general', 'city', 'map', 'records', 'global-menu', 'messages']); + await page.evaluate(() => { + localStorage.setItem( + 'sam.mobileMainPanelOrder.v1', + JSON.stringify([ + 'messages', + 'map', + 'commands', + 'nation-menu', + 'nation', + 'general', + 'city', + 'records', + 'global-menu', + ]) + ); + document.dispatchEvent(new CustomEvent('sam-mobile-main-panel-order-changed')); + }); + await expect + .poll(readMobilePanelOrder) + .toEqual(['messages', 'map', 'commands', 'nation-menu', 'nation', 'general', 'city', 'records', 'global-menu']); + const customPanelGeometry = await page.locator('.layout-mobile > [data-mobile-panel-id]').evaluateAll((elements) => + elements.map((element) => { + const rect = element.getBoundingClientRect(); + return { + id: element.getAttribute('data-mobile-panel-id'), + top: rect.top, + bottom: rect.bottom, + left: rect.left, + right: rect.right, + }; + }) + ); + expect(customPanelGeometry.every(({ left, right }) => left >= 0 && right <= 500)).toBe(true); + expect( + customPanelGeometry.every((panel, index) => index === 0 || panel.top >= customPanelGeometry[index - 1]!.bottom) + ).toBe(true); await persistArtifact(page, `${basePath.slice(1)}-mobile-500`); }); @@ -3040,9 +3079,9 @@ for (const viewport of [ await refreshActivityAndCommands(); await expect(picker.getByLabel('장비 종류', { exact: true })).toHaveValue('weapon'); await expect(picker.getByLabel('장비', { exact: true })).toHaveValue('청룡언월도'); - await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual( - viewport.width - ); + await expect + .poll(() => page.evaluate(() => document.documentElement.scrollWidth)) + .toBeLessThanOrEqual(viewport.width); }); } diff --git a/app/game-frontend/e2e/nationCityOfficeIntegration.spec.ts b/app/game-frontend/e2e/nationCityOfficeIntegration.spec.ts new file mode 100644 index 00000000..ed3dfc5f --- /dev/null +++ b/app/game-frontend/e2e/nationCityOfficeIntegration.spec.ts @@ -0,0 +1,420 @@ +import { expect, test, type Page, type Route } from '@playwright/test'; + +import { gameProfile, gameTrpcRoute } from './gameTestPaths.js'; + +type Role = 'head' | 'member'; +type AppointmentInput = { destGeneralId: number; destCityId: number; officerLevel: number }; +type FixtureState = { + role: Role; + appointed: boolean; + secretForbidden?: boolean; + appointmentInputs: AppointmentInput[]; +}; + +const response = (data: unknown) => ({ result: { data } }); +const errorResponse = (path: string, message: string, code = 'BAD_REQUEST') => ({ + error: { message, code: -32000, data: { code, httpStatus: code === 'FORBIDDEN' ? 403 : 400, path } }, +}); +const operations = (route: Route): string[] => + decodeURIComponent(new URL(route.request().url()).pathname.split('/trpc/')[1] ?? '').split(','); +const requestInput = (route: Route, index: number): Record => { + const body: unknown = route.request().postData() ? route.request().postDataJSON() : {}; + const record = body && typeof body === 'object' ? (body as Record) : {}; + const raw = record[String(index)] ?? record; + const payload = raw && typeof raw === 'object' ? (raw as Record) : {}; + const input = payload.input && typeof payload.input === 'object' ? (payload.input as Record) : {}; + const json = payload.json ?? input.json ?? payload; + return json && typeof json === 'object' ? (json as Record) : {}; +}; + +const cities = [ + { + id: 1, + name: '허창', + level: 7, + region: 2, + population: 99_000, + populationMax: 100_000, + agriculture: 9_500, + agricultureMax: 10_000, + commerce: 8_000, + commerceMax: 10_000, + security: 8_000, + securityMax: 10_000, + trust: 80, + trade: 100, + defence: 4_500, + defenceMax: 5_000, + wall: 4_500, + wallMax: 5_000, + supplyState: 1, + frontState: 0, + incomes: { gold: 1000, rice: 900, wall: 800 }, + }, + { + id: 2, + name: '낙양', + level: 6, + region: 2, + population: 60_000, + populationMax: 100_000, + agriculture: 5_000, + agricultureMax: 10_000, + commerce: 5_000, + commerceMax: 10_000, + security: 5_000, + securityMax: 10_000, + trust: 70, + trade: 90, + defence: 2_500, + defenceMax: 5_000, + wall: 2_500, + wallMax: 5_000, + supplyState: 1, + frontState: 0, + incomes: { gold: 800, rice: 700, wall: 600 }, + }, +] as const; + +const overviewFixture = (state: FixtureState) => ({ + me: { id: state.role === 'head' ? 20 : 21, officerLevel: state.role === 'head' ? 5 : 1 }, + nation: { + id: 1, + name: '위', + color: '#008000', + level: 3, + typeCode: 'che_법가', + capitalCityId: 1, + rate: 20, + }, + chiefStatMin: 65, + cities: cities.map((city) => ({ + ...city, + officers: { + 4: state.appointed + ? { id: 21, name: '장료', npcState: 0, officerLevel: 4, cityId: 1, cityName: '허창' } + : null, + 3: null, + 2: null, + }, + })), + generals: [ + { + id: 1, + name: '조조', + npcState: 0, + officerLevel: 12, + cityId: 1, + officerCity: 0, + stats: { leadership: 90, strength: 80, intelligence: 90 }, + }, + { + id: 20, + name: '순욱', + npcState: 0, + officerLevel: 5, + cityId: 1, + officerCity: 0, + stats: { leadership: 75, strength: 70, intelligence: 90 }, + }, + { + id: 21, + name: '장료', + npcState: 0, + officerLevel: state.appointed ? 4 : 1, + cityId: 1, + officerCity: state.appointed ? 1 : 0, + stats: { leadership: 80, strength: 70, intelligence: 50 }, + }, + { + id: 22, + name: '조홍', + npcState: 2, + officerLevel: 1, + cityId: 2, + officerCity: 0, + stats: { leadership: 60, strength: 65, intelligence: 40 }, + }, + ], +}); + +const secretGeneral = (id: number, name: string, cityId: number, overrides: Record = {}) => ({ + id, + name, + npcState: 0, + injury: 0, + stats: { leadership: 70, strength: 70, intelligence: 70 }, + leadershipBonus: 0, + experienceLevel: 9, + troopId: 0, + troopName: null, + gold: 1000, + rice: 2000, + cityId, + cityName: cityId === 1 ? '허창' : '낙양', + defenceTrain: 90, + defenceTrainText: '☆', + crewTypeId: 1, + crewTypeName: '보병', + crew: 300, + train: 90, + atmos: 90, + killTurn: 7, + turnTime: '2026-01-01T01:02:00.000Z', + reservedCommands: ['농지 개간', '훈련'], + ...overrides, +}); + +const secretFixture = () => ({ + nation: { id: 1, name: '위', color: '#008000', level: 3 }, + viewer: { generalId: 20, permission: 1 }, + summary: { + gold: 4000, + rice: 8000, + crew: 1200, + generalCount: 4, + averageGold: 1000, + averageRice: 2000, + readiness: { + 90: { crew: 1200, generals: 4 }, + 80: { crew: 1200, generals: 4 }, + 60: { crew: 1200, generals: 4 }, + }, + }, + generals: [ + secretGeneral(1, '조조', 1, { leadershipBonus: 6 }), + secretGeneral(20, '순욱', 1, { leadershipBonus: 3 }), + secretGeneral(21, '장료', 1, { + stats: { leadership: 80, strength: 70, intelligence: 50 }, + }), + secretGeneral(22, '조홍', 2, { npcState: 2, reservedCommands: [] }), + ], +}); + +const personnelGeneral = (id: number, name: string, officerLevel: number, overrides: Record = {}) => ({ + id, + name, + npcState: 0, + officerLevel, + cityId: 1, + cityName: '허창', + troopId: 0, + troopName: null, + picture: null, + imageServer: 0, + officerCity: officerLevel >= 2 && officerLevel <= 4 ? 1 : 0, + officerCityName: officerLevel >= 2 && officerLevel <= 4 ? '허창' : null, + stats: { leadership: 70, strength: 70, intelligence: 70 }, + experience: 100, + dedication: 200, + injury: 0, + gold: 1000, + rice: 1000, + crew: 100, + personality: null, + specialDomestic: null, + specialWar: null, + belong: 10, + permission: 'normal', + ...overrides, +}); + +const personnelFixture = (state: FixtureState) => { + const allGenerals = [ + personnelGeneral(1, '조조', 12), + personnelGeneral(20, '순욱', 5, { stats: { leadership: 75, strength: 70, intelligence: 90 } }), + personnelGeneral(21, '장료', state.appointed ? 4 : 1, { + stats: { leadership: 80, strength: 70, intelligence: 50 }, + }), + personnelGeneral(22, '조홍', 1, { + npcState: 2, + cityId: 2, + cityName: '낙양', + stats: { leadership: 60, strength: 65, intelligence: 40 }, + }), + ]; + const canManage = state.role === 'head'; + return { + me: { + id: canManage ? 20 : 21, + officerLevel: canManage ? 5 : 1, + canManage, + canChangePermissions: false, + canKick: canManage, + }, + nation: { + id: 1, + name: '위', + color: '#008000', + level: 3, + typeCode: 'che_법가', + capitalCityId: 1, + chiefSet: 0, + }, + chiefStatMin: 65, + generals: canManage ? allGenerals : [], + chiefAssignments: { 12: allGenerals[0], 5: allGenerals[1] }, + cityAssignments: cities.map((city) => ({ + id: city.id, + name: city.name, + level: city.level, + region: city.region, + officerSet: city.id === 1 && state.appointed ? 1 << 4 : 0, + officers: { + 4: city.id === 1 && state.appointed ? allGenerals[2] : null, + 3: null, + 2: null, + }, + })), + awards: { tigers: [], eagles: [] }, + permissionCandidates: { ambassadors: [], auditors: [] }, + }; +}; + +const install = async (page: Page, state: FixtureState): Promise => { + await page.addInitScript((profile) => { + localStorage.setItem('sammo-game-token', 'ga_city_office'); + localStorage.setItem('sammo-game-profile', profile); + }, gameProfile); + await page.route(gameTrpcRoute, async (route) => { + const result = operations(route).map((operation, index) => { + if (operation === 'auth.status') return response({ ok: true }); + if (operation === 'lobby.info') return response({ myGeneral: { id: 20, name: '순욱' } }); + if (operation === 'join.getConfig') return response({}); + if (operation === 'nation.getCityOverview') return response(overviewFixture(state)); + if (operation === 'nation.getSecretGeneralList') { + return state.secretForbidden + ? errorResponse( + operation, + '권한이 부족합니다. 수뇌부가 아니거나 사관년도가 부족합니다.', + 'FORBIDDEN' + ) + : response(secretFixture()); + } + if (operation === 'nation.getPersonnelInfo') return response(personnelFixture(state)); + if (operation === 'nation.appoint') { + const input = requestInput(route, index); + state.appointmentInputs.push({ + destGeneralId: Number(input.destGeneralId), + destCityId: Number(input.destCityId), + officerLevel: Number(input.officerLevel), + }); + state.appointed = true; + return response({ ok: true }); + } + return errorResponse(operation, `Unhandled fixture operation: ${operation}`); + }); + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(result) }); + }); +}; + +test('암행부 행을 도시별로 나누고 수뇌의 인사부 즉시 임명을 반영한다', async ({ page }, testInfo) => { + const state: FixtureState = { role: 'head', appointed: false, appointmentInputs: [] }; + await install(page, state); + await page.setViewportSize({ width: 1200, height: 900 }); + await page.goto('nation/cities'); + + await expect(page.locator('.nation-cities-page')).toBeVisible(); + await expect(page.locator('.city-user-table')).toHaveCount(0); + await expect(page.getByRole('button', { name: '인사부 연동' })).toHaveCount(0); + + await page.getByRole('button', { name: '암행부 연동' }).click(); + await expect(page.locator('.city-user-table')).toHaveCount(2); + await expect(page.locator('.city[data-city-id="1"] .city-user-table tr[data-general-id="21"]')).toContainText( + '장료' + ); + await expect(page.locator('.city[data-city-id="2"] .city-user-table tr[data-general-id="22"]')).toContainText( + '조홍' + ); + await expect(page.locator('.city[data-city-id="2"] .city-user-table tr[data-general-id="21"]')).toHaveCount(0); + await expect(page.locator('.city[data-city-id="1"] tr[data-general-id="21"] .command-attention')).toHaveText( + '농지 개간' + ); + + const integratedBox = await page.locator('.city[data-city-id="1"] .city-user-table').evaluate((element) => { + const rect = element.getBoundingClientRect(); + const style = getComputedStyle(element); + return { width: rect.width, borderCollapse: style.borderCollapse, fontSize: style.fontSize }; + }); + expect(integratedBox).toEqual({ width: 941, borderCollapse: 'collapse', fontSize: '14px' }); + + await page.getByRole('button', { name: '인사부 연동' }).click(); + const ordinaryRow = page.locator('.city[data-city-id="1"] tr[data-general-id="21"]'); + await expect(ordinaryRow.locator('.appointment-button')).toHaveCount(3); + await expect(ordinaryRow.locator('.mode-4')).toBeEnabled(); + await expect(ordinaryRow.locator('.mode-3')).toBeDisabled(); + await expect(ordinaryRow.locator('.mode-2')).toBeEnabled(); + await expect(page.locator('tr[data-general-id="1"] .appointment-button')).toHaveCount(0); + + const disabledStyle = await ordinaryRow.locator('.mode-3').evaluate((button) => { + const style = getComputedStyle(button); + return { borderTopWidth: style.borderTopWidth, backgroundColor: style.backgroundColor }; + }); + expect(disabledStyle).toEqual({ borderTopWidth: '0px', backgroundColor: 'rgba(0, 0, 0, 0)' }); + const appointButton = page.getByRole('button', { name: '장료을(를) 허창 태수로 임명' }); + await appointButton.hover(); + expect(await appointButton.evaluate((button) => getComputedStyle(button).cursor)).toBe('pointer'); + await appointButton.focus(); + await expect(appointButton).toBeFocused(); + + await page.screenshot({ path: testInfo.outputPath('nation-city-integrated-desktop.png'), fullPage: true }); + await appointButton.click(); + await expect.poll(() => state.appointmentInputs).toEqual([{ destGeneralId: 21, destCityId: 1, officerLevel: 4 }]); + await expect(page.locator('.city[data-city-id="1"] .officer-4-value')).toHaveText('장료'); + await expect(page.locator('.city[data-city-id="1"] .officer-4-value')).toHaveClass(/effective-officer/u); + await expect(page.locator('.city[data-city-id="1"] tr[data-general-id="21"] .mode-4')).toBeDisabled(); + + await page.setViewportSize({ width: 500, height: 900 }); + expect(await page.locator('.nation-cities-page').evaluate((element) => element.getBoundingClientRect().width)).toBe( + 1000 + ); + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeGreaterThanOrEqual(1000); + await page.screenshot({ path: testInfo.outputPath('nation-city-integrated-mobile.png'), fullPage: true }); +}); + +test('수뇌 대상은 재확인하고 일반 장수에게는 임명 버튼을 열지 않는다', async ({ page }) => { + const headState: FixtureState = { role: 'head', appointed: false, appointmentInputs: [] }; + await install(page, headState); + await page.goto('nation/cities'); + await page.getByRole('button', { name: '암행부 연동' }).click(); + await page.getByRole('button', { name: '인사부 연동' }).click(); + + const chiefButton = page.getByRole('button', { name: '순욱을(를) 허창 태수로 임명' }); + expect(await chiefButton.evaluate((button) => getComputedStyle(button).color)).toBe('rgb(255, 0, 0)'); + page.once('dialog', async (dialog) => { + expect(dialog.message()).toBe('수뇌입니다. 임명할까요?'); + await dialog.dismiss(); + }); + await chiefButton.click(); + await expect.poll(() => headState.appointmentInputs.length).toBe(0); + + await page.unroute(gameTrpcRoute); + const memberState: FixtureState = { role: 'member', appointed: false, appointmentInputs: [] }; + await install(page, memberState); + await page.reload(); + await page.getByRole('button', { name: '암행부 연동' }).click(); + page.once('dialog', async (dialog) => { + expect(dialog.message()).toBe('수뇌가 아닙니다!'); + await dialog.accept(); + }); + await page.getByRole('button', { name: '인사부 연동' }).click(); + await expect(page.locator('.appointment-button')).toHaveCount(0); + expect(memberState.appointmentInputs).toEqual([]); +}); + +test('암행부 권한 거부는 도시 기밀 행과 인사부 연동을 열지 않는다', async ({ page }) => { + const state: FixtureState = { + role: 'member', + appointed: false, + secretForbidden: true, + appointmentInputs: [], + }; + await install(page, state); + await page.goto('nation/cities'); + await page.getByRole('button', { name: '암행부 연동' }).click(); + + await expect(page.locator('.integration-error')).toContainText('권한이 부족합니다.'); + await expect(page.locator('.city-user-table')).toHaveCount(0); + await expect(page.getByRole('button', { name: '인사부 연동' })).toHaveCount(0); + expect(state.appointmentInputs).toEqual([]); +}); diff --git a/app/game-frontend/e2e/nationOffices.spec.ts b/app/game-frontend/e2e/nationOffices.spec.ts index 1aff2cc9..0ed26756 100644 --- a/app/game-frontend/e2e/nationOffices.spec.ts +++ b/app/game-frontend/e2e/nationOffices.spec.ts @@ -11,6 +11,8 @@ type FixtureState = { failPersonnelLoad?: boolean; rate: number; appointedGeneralId?: number; + appointedCityId?: number; + appointedOfficerLevel?: number; noticeMutationInput?: string; scoutMutationInput?: string; uploadDataUrl?: string; @@ -230,7 +232,9 @@ const installFixture = async (page: Page, state: FixtureState) => { } if (operation === 'nation.getStratFinan') return response(financeFixture(state)); if (operation === 'nation.appoint') { - state.appointedGeneralId = 6; + state.appointedGeneralId = Number(jsonInput.destGeneralId ?? 0); + state.appointedCityId = Number(jsonInput.destCityId ?? 0); + state.appointedOfficerLevel = Number(jsonInput.officerLevel ?? 0); return response({ ok: true }); } if (operation === 'nation.kick' || operation === 'nation.changePermission') return response({ ok: true }); @@ -281,7 +285,7 @@ const screenshot = async (page: Page, name: string) => { await page.screenshot({ path: resolve(artifactRoot, name), fullPage: true }); }; -test('personnel matches the 1000px legacy table geometry, textures, image, and interaction states', async ({ +test('personnel keeps the legacy frame while presenting modern appointment cards and interaction states', async ({ page, }) => { await installFixture(page, { role: 'leader', rate: 20 }); @@ -309,6 +313,8 @@ test('personnel matches the 1000px legacy table geometry, textures, image, and i heading: box('.heading-table'), status: box('.chief-status'), icon: box('.general-icon'), + appointmentCard: box('.appointment-card'), + selectionTrigger: box('.selection-trigger'), documentWidth: document.documentElement.scrollWidth, }; }); @@ -318,16 +324,17 @@ test('personnel matches the 1000px legacy table geometry, textures, image, and i expect(computed.status.width).toBe(1000); expect(computed.icon.width).toBeCloseTo(64.7, 0); expect(computed.icon.height).toBeCloseTo(64, 0); + expect(computed.appointmentCard.width).toBeGreaterThan(450); + expect(computed.appointmentCard.height).toBeGreaterThan(140); + expect(computed.selectionTrigger.height).toBeGreaterThanOrEqual(70); expect(computed.container.fontFamily).toContain('Pretendard'); expect(computed.container.fontSize).toBe('14px'); expect(computed.container.lineHeight).toBe('18.2px'); expect(computed.status.backgroundImage).toContain('back_walnut.jpg'); expect(computed.documentWidth).toBe(1000); - const appointButton = page.getByRole('button', { name: '임명' }).first(); - expect(await appointButton.evaluate((button) => getComputedStyle(button).backgroundColor)).toBe( - 'rgb(108, 117, 125)' - ); + const appointButton = page.getByRole('button', { name: '주부 임명', exact: true }); + expect(await appointButton.evaluate((button) => getComputedStyle(button).backgroundColor)).toBe('rgb(55, 104, 70)'); await appointButton.hover(); expect(await appointButton.evaluate((button) => getComputedStyle(button).cursor)).toBe('pointer'); await appointButton.focus(); @@ -335,6 +342,44 @@ test('personnel matches the 1000px legacy table geometry, textures, image, and i await screenshot(page, 'core-personnel-desktop-leader.png'); }); +test('personnel selects an informed general and reports the JosaUtil-composed result in a toast', async ({ page }) => { + const state: FixtureState = { role: 'head', rate: 20 }; + await installFixture(page, state); + await page.setViewportSize({ width: 1000, height: 900 }); + await gotoOffice(page, 'nation/personnel'); + + await page.getByRole('button', { name: '주부 장수 선택', exact: true }).click(); + const picker = page.getByTestId('personnel-selection-dialog'); + await expect(picker).toBeVisible(); + await expect(picker.getByRole('heading', { name: '주부 임명 대상 선택' })).toBeVisible(); + await picker.getByPlaceholder('장수명·도시·관직·특성 검색').fill('장료'); + const candidate = picker.getByRole('button', { name: /장료/ }); + await expect(candidate).toContainText('허창 · 일반 장수'); + await expect(candidate).toContainText('통솔70'); + await expect(candidate).toContainText('소속10년'); + await expect(candidate).toContainText('병력100'); + await candidate.hover(); + expect(await candidate.evaluate((button) => getComputedStyle(button).cursor)).toBe('pointer'); + await candidate.focus(); + expect(await candidate.evaluate((button) => getComputedStyle(button).outlineStyle)).not.toBe('none'); + await screenshot(page, 'core-personnel-desktop-general-picker.png'); + await candidate.click(); + + await expect(page.getByRole('button', { name: '주부 장수 선택', exact: true })).toContainText('장료'); + page.once('dialog', async (dialog) => { + expect(dialog.message()).toBe('장료를 주부직에 임명하시겠습니까?'); + await dialog.accept(); + }); + await page.getByRole('button', { name: '주부 임명', exact: true }).click(); + + await expect(page.getByTestId('game-toast')).toContainText('장료를 임명했습니다.'); + expect(state.appointedGeneralId).toBe(6); + expect(state.appointedCityId).toBe(0); + expect(state.appointedOfficerLevel).toBe(11); + await expect(page.locator('.feedback.status')).toHaveCount(0); + await screenshot(page, 'core-personnel-appointment-toast.png'); +}); + test('personnel preserves the legacy fixed 1000px document on a 500px viewport', async ({ page }) => { await installFixture(page, { role: 'head', rate: 20 }); await page.setViewportSize({ width: 500, height: 900 }); @@ -346,6 +391,38 @@ test('personnel preserves the legacy fixed 1000px document on a 500px viewport', expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(1000); await expect(page.getByRole('combobox', { name: '외교권자' })).toHaveCount(0); await expect(page.getByRole('combobox', { name: '추방 대상 장수' })).toBeVisible(); + + await page.getByRole('button', { name: '태수 도시 선택', exact: true }).click(); + const picker = page.getByTestId('personnel-selection-dialog'); + await expect(picker).toBeVisible(); + expect(await picker.evaluate((element) => getComputedStyle(element).transitionDuration)).toContain('0.15s'); + await expect(picker).toHaveCSS('transform', 'none'); + const pickerGeometry = await picker.evaluate((element) => { + const rect = element.getBoundingClientRect(); + const style = getComputedStyle(element); + return { + left: rect.left, + right: rect.right, + bottom: rect.bottom, + width: rect.width, + maxHeight: style.maxHeight, + borderTopLeftRadius: style.borderTopLeftRadius, + }; + }); + expect(pickerGeometry.left).toBeGreaterThanOrEqual(0); + expect(pickerGeometry.right).toBeLessThanOrEqual(500); + expect(pickerGeometry.bottom).toBe(900); + expect(pickerGeometry.width).toBeGreaterThan(480); + expect(pickerGeometry.borderTopLeftRadius).toBe('16px'); + await expect(picker.getByRole('button', { name: /낙양/ })).toContainText('중원 · 중도시'); + await expect(picker.getByRole('button', { name: /허창/ })).toContainText('현재 태수하후돈'); + await picker.getByRole('button', { name: /낙양/ }).focus(); + expect( + await picker.getByRole('button', { name: /낙양/ }).evaluate((button) => getComputedStyle(button).outlineStyle) + ).not.toBe('none'); + await screenshot(page, 'core-personnel-mobile-city-picker.png'); + await picker.getByRole('button', { name: /낙양/ }).click(); + await expect(page.getByRole('button', { name: '태수 도시 선택', exact: true })).toContainText('낙양'); await screenshot(page, 'core-personnel-mobile-head.png'); }); diff --git a/app/game-frontend/e2e/playwright.config.mjs b/app/game-frontend/e2e/playwright.config.mjs index 8fe08af6..734b6cee 100644 --- a/app/game-frontend/e2e/playwright.config.mjs +++ b/app/game-frontend/e2e/playwright.config.mjs @@ -21,6 +21,7 @@ export default defineConfig({ 'troop.spec.ts', 'board.spec.ts', 'inGameInfo.spec.ts', + 'nationCityOfficeIntegration.spec.ts', 'inGameMenus.spec.ts', 'nationOffices.spec.ts', 'diplomacy.spec.ts', diff --git a/app/game-frontend/src/components/personnel/PersonnelSelectionDialog.vue b/app/game-frontend/src/components/personnel/PersonnelSelectionDialog.vue new file mode 100644 index 00000000..173ba8aa --- /dev/null +++ b/app/game-frontend/src/components/personnel/PersonnelSelectionDialog.vue @@ -0,0 +1,528 @@ + + + + + diff --git a/app/game-frontend/src/utils/mobileMainPanelOrder.ts b/app/game-frontend/src/utils/mobileMainPanelOrder.ts new file mode 100644 index 00000000..b337c833 --- /dev/null +++ b/app/game-frontend/src/utils/mobileMainPanelOrder.ts @@ -0,0 +1,85 @@ +export const MOBILE_MAIN_PANEL_ORDER_STORAGE_KEY = 'sam.mobileMainPanelOrder.v1'; +export const MOBILE_MAIN_PANEL_ORDER_CHANGED_EVENT = 'sam-mobile-main-panel-order-changed'; + +export const MOBILE_MAIN_PANEL_DEFINITIONS = [ + { id: 'commands', label: '명령 목록' }, + { id: 'nation-menu', label: '국가 메뉴' }, + { id: 'nation', label: '국가 정보' }, + { id: 'general', label: '장수 정보' }, + { id: 'city', label: '도시 정보' }, + { id: 'map', label: '지도' }, + { id: 'records', label: '기록 영역' }, + { id: 'global-menu', label: '공통 메뉴' }, + { id: 'messages', label: '서신' }, +] as const; + +export type MobileMainPanelId = (typeof MOBILE_MAIN_PANEL_DEFINITIONS)[number]['id']; + +export const DEFAULT_MOBILE_MAIN_PANEL_ORDER: readonly MobileMainPanelId[] = MOBILE_MAIN_PANEL_DEFINITIONS.map( + ({ id }) => id +); + +const mobilePanelIds = new Set(DEFAULT_MOBILE_MAIN_PANEL_ORDER); + +export const normalizeMobileMainPanelOrder = (value: unknown): MobileMainPanelId[] => { + const source = Array.isArray(value) ? value : []; + const seen = new Set(); + const normalized: MobileMainPanelId[] = []; + + for (const item of source) { + if (typeof item !== 'string' || !mobilePanelIds.has(item) || seen.has(item)) continue; + seen.add(item); + normalized.push(item as MobileMainPanelId); + } + + for (const item of DEFAULT_MOBILE_MAIN_PANEL_ORDER) { + if (!seen.has(item)) normalized.push(item); + } + + return normalized; +}; + +export const parseMobileMainPanelOrder = (raw: string | null): MobileMainPanelId[] => { + if (!raw) return [...DEFAULT_MOBILE_MAIN_PANEL_ORDER]; + try { + return normalizeMobileMainPanelOrder(JSON.parse(raw)); + } catch { + return [...DEFAULT_MOBILE_MAIN_PANEL_ORDER]; + } +}; + +export const loadMobileMainPanelOrder = (storage: Pick = window.localStorage) => + parseMobileMainPanelOrder(storage.getItem(MOBILE_MAIN_PANEL_ORDER_STORAGE_KEY)); + +export const saveMobileMainPanelOrder = ( + value: readonly MobileMainPanelId[], + storage: Pick = window.localStorage +): MobileMainPanelId[] => { + const normalized = normalizeMobileMainPanelOrder(value); + storage.setItem(MOBILE_MAIN_PANEL_ORDER_STORAGE_KEY, JSON.stringify(normalized)); + if (typeof document !== 'undefined') { + document.dispatchEvent(new CustomEvent(MOBILE_MAIN_PANEL_ORDER_CHANGED_EVENT)); + } + return normalized; +}; + +export const moveMobileMainPanel = ( + value: readonly MobileMainPanelId[], + fromIndex: number, + toIndex: number +): MobileMainPanelId[] => { + const normalized = normalizeMobileMainPanelOrder(value); + if ( + fromIndex < 0 || + fromIndex >= normalized.length || + toIndex < 0 || + toIndex >= normalized.length || + fromIndex === toIndex + ) { + return normalized; + } + const [moved] = normalized.splice(fromIndex, 1); + if (!moved) return normalized; + normalized.splice(toIndex, 0, moved); + return normalized; +}; diff --git a/app/game-frontend/src/views/BestGeneralView.vue b/app/game-frontend/src/views/BestGeneralView.vue index 434f2d65..56d652cc 100644 --- a/app/game-frontend/src/views/BestGeneralView.vue +++ b/app/game-frontend/src/views/BestGeneralView.vue @@ -87,8 +87,20 @@ watch(viewMode, () => {
- - + +
diff --git a/app/game-frontend/src/views/DynastyDetailView.vue b/app/game-frontend/src/views/DynastyDetailView.vue index ce963b3c..cd743757 100644 --- a/app/game-frontend/src/views/DynastyDetailView.vue +++ b/app/game-frontend/src/views/DynastyDetailView.vue @@ -92,9 +92,7 @@ onMounted(loadDetail); {{ data.emperor.phase }} - + diff --git a/app/game-frontend/src/views/DynastyListView.vue b/app/game-frontend/src/views/DynastyListView.vue index cec29466..88da66cf 100644 --- a/app/game-frontend/src/views/DynastyListView.vue +++ b/app/game-frontend/src/views/DynastyListView.vue @@ -98,9 +98,7 @@ watch(selectedSource, loadDynasty); {{ entry.phase - }} [이전 서버] -
- - +
+ + + +
+ +
+ - -
+
-
- + + + +
+ +
+ + + +
+ +
+ + + +
+ +
+ + + +
+ +
+ + + +
+ +
+
기록이 없습니다.
+
+ + + + +
+ +
+
기록이 없습니다.
+
+ + + + +
+ +
+
기록이 없습니다.
+
+ +
+ + -
-
- - - - - - - - - -
- -
- - - -
- -
- - - -
- -
-
기록이 없습니다.
-
- - - - -
- -
-
기록이 없습니다.
-
- - - - -
- -
-
기록이 없습니다.
-
- -
- - - -
- -
+
+ +
+
@@ -798,8 +841,8 @@ button { gap: 0; } -.layout-mobile > .mobile-panel:nth-of-type(3) { - gap: 0; +.layout-mobile > .mobile-panel--flush { + margin-top: -4px; } .layout-mobile [data-main-target='commands'] { diff --git a/app/game-frontend/src/views/MyPageView.vue b/app/game-frontend/src/views/MyPageView.vue index ae8f57d7..9798859c 100644 --- a/app/game-frontend/src/views/MyPageView.vue +++ b/app/game-frontend/src/views/MyPageView.vue @@ -9,11 +9,19 @@ 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 { + DEFAULT_MOBILE_MAIN_PANEL_ORDER, + loadMobileMainPanelOrder, + MOBILE_MAIN_PANEL_DEFINITIONS, + moveMobileMainPanel, + saveMobileMainPanelOrder, + 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 { error: showErrorToast, showDialog } = useGameFeedback(); +const { success: showSuccessToast, error: showErrorToast, showDialog } = useGameFeedback(); type ScreenMode = 'auto' | '500px' | '1000px'; type LogType = 'generalHistory' | 'battleDetail' | 'battleResult' | 'generalAction'; type ItemSlotKey = 'horse' | 'weapon' | 'book' | 'item'; @@ -48,6 +56,9 @@ const screenMode = ref('auto'); const customCss = ref(''); const selectedIconId = ref(''); const cssSaving = ref(false); +const mobileLayoutDialog = ref(null); +const mobileLayoutOrder = ref(loadMobileMainPanelOrder()); +const mobileLayoutDragIndex = ref(null); const session = useSessionStore(); let cssTimer: number | null = null; const readPendingDieOnPrestartId = (): string => { @@ -169,6 +180,43 @@ const items = computed data.value?.iconChoices ?? []); const selectedIcon = computed(() => iconChoices.value.find((icon) => icon.id === selectedIconId.value) ?? null); +const mobileLayoutLabels = Object.fromEntries( + MOBILE_MAIN_PANEL_DEFINITIONS.map(({ id, label }) => [id, label]) +) as Record; + +const openMobileLayoutDialog = () => { + mobileLayoutOrder.value = loadMobileMainPanelOrder(); + mobileLayoutDialog.value?.showModal(); + window.requestAnimationFrame(() => mobileLayoutDialog.value?.querySelector('button')?.focus()); +}; + +const moveMobileLayoutItem = (fromIndex: number, toIndex: number) => { + mobileLayoutOrder.value = moveMobileMainPanel(mobileLayoutOrder.value, fromIndex, toIndex); +}; + +const startMobileLayoutDrag = (event: DragEvent, index: number) => { + mobileLayoutDragIndex.value = index; + event.dataTransfer?.setData('text/plain', mobileLayoutOrder.value[index] ?? ''); + if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move'; +}; + +const dropMobileLayoutItem = (event: DragEvent, targetIndex: number) => { + event.preventDefault(); + const sourceIndex = mobileLayoutDragIndex.value; + mobileLayoutDragIndex.value = null; + if (sourceIndex === null) return; + moveMobileLayoutItem(sourceIndex, targetIndex); +}; + +const resetMobileLayoutOrder = () => { + mobileLayoutOrder.value = [...DEFAULT_MOBILE_MAIN_PANEL_ORDER]; +}; + +const applyMobileLayoutOrder = () => { + mobileLayoutOrder.value = saveMobileMainPanelOrder(mobileLayoutOrder.value); + mobileLayoutDialog.value?.close(); + showSuccessToast('모바일 메인 레이아웃 순서를 저장했습니다.'); +}; const autorunUser = computed(() => asRecord(world.value?.meta.autorun_user)); const showAutoNationTurn = computed(() => asRecord(autorunUser.value.options).chief !== false); @@ -581,6 +629,16 @@ onMounted(() => {
+
+ + 모바일 레이아웃 순서 바꾸기
+ 500px 메인 화면의 패널 순서를 이 기기에 저장합니다. +
+ +
+
아이템 파기
+ +
+

항목을 끌어 놓거나 위·아래 버튼으로 상대 순서를 바꿉니다.

+
    +
  1. + + + {{ index + 1 }} + {{ mobileLayoutLabels[panelId] }} + + + + + +
  2. +
+
+ +
+ +
+ @@ -822,6 +935,125 @@ button:disabled { align-items: center; margin: 14px 0; } +.mobile-layout-setting-row { + display: grid; + grid-template-columns: minmax(0, 1fr) 128px; + align-items: center; + gap: 8px; + margin: 14px 0; +} +.mobile-layout-setting-row small { + color: orange; +} +.mobile-layout-open { + min-height: 34px; + background: #315f86; + font-weight: 700; +} +.mobile-layout-dialog { + box-sizing: border-box; + width: min(460px, calc(100vw - 24px)); + max-height: calc(100dvh - 24px); + margin: auto; + overflow: auto; + border: 1px solid #777; + border-radius: 4px; + padding: 12px; + background: #171717 var(--sammo-texture-walnut); + color: #fff; + font: 14px/1.3 var(--sammo-font-sans); +} +.mobile-layout-dialog::backdrop { + background: rgb(0 0 0 / 72%); +} +.mobile-layout-dialog__header, +.mobile-layout-dialog__actions, +.mobile-layout-move-buttons { + display: flex; + align-items: center; +} +.mobile-layout-dialog__header { + justify-content: space-between; + gap: 12px; +} +.mobile-layout-dialog__header h2, +.mobile-layout-dialog p { + margin: 0 0 10px; +} +.mobile-layout-dialog__header h2 { + color: skyblue; + font-size: 18px; +} +.mobile-layout-dialog__header form, +.mobile-layout-dialog__actions form { + margin: 0; +} +.mobile-layout-dialog__header button { + min-width: 32px; + min-height: 32px; + font-size: 20px; +} +.mobile-layout-list { + display: grid; + gap: 6px; + margin: 0; + padding: 0; + list-style: none; +} +.mobile-layout-list > li { + display: grid; + grid-template-columns: 28px minmax(0, 1fr) auto; + min-height: 44px; + align-items: center; + border: 1px solid #777; + background: #172a52 var(--sammo-texture-blue); + cursor: grab; +} +.mobile-layout-list > li:active { + cursor: grabbing; +} +.mobile-layout-handle { + color: #aaa; + text-align: center; + font-size: 20px; +} +.mobile-layout-label { + min-width: 0; + font-weight: 700; +} +.mobile-layout-position { + display: inline-grid; + width: 22px; + height: 22px; + place-items: center; + margin-right: 4px; + border: 1px solid #7186a7; + border-radius: 50%; + font-size: 12px; +} +.mobile-layout-move-buttons { + gap: 4px; + padding-right: 5px; +} +.mobile-layout-move-buttons button { + width: 36px; + min-height: 34px; + background: #315f86; + font-weight: 700; +} +.mobile-layout-dialog__actions { + justify-content: flex-end; + gap: 6px; + margin-top: 12px; +} +.mobile-layout-dialog__actions button { + min-height: 34px; + padding: 4px 10px; +} +.mobile-layout-dialog__actions .mobile-layout-apply { + background: #225500; + font-weight: 700; +} .button-group { display: flex; } @@ -933,6 +1165,12 @@ button:disabled { grid-template-columns: 1fr; gap: 6px; } + .mobile-layout-setting-row { + grid-template-columns: 1fr; + } + .mobile-layout-open { + width: 100%; + } .button-group { overflow-x: auto; } diff --git a/app/game-frontend/src/views/NationCitiesView.vue b/app/game-frontend/src/views/NationCitiesView.vue index 10669e95..ae7a786b 100644 --- a/app/game-frontend/src/views/NationCitiesView.vue +++ b/app/game-frontend/src/views/NationCitiesView.vue @@ -1,16 +1,28 @@