diff --git a/app/game-frontend/e2e/commandArguments.spec.ts b/app/game-frontend/e2e/commandArguments.spec.ts index c1d8afa2..4bb3bc29 100644 --- a/app/game-frontend/e2e/commandArguments.spec.ts +++ b/app/game-frontend/e2e/commandArguments.spec.ts @@ -953,9 +953,7 @@ test('shows and reserves the Ref spy command for a user on desktop and mobile', await expect(spy).toBeFocused(); await spy.click(); const form = picker.getByTestId('command-argument-form'); - await expect(form.getByTestId('command-argument-guidance')).toContainText( - '선택한 도시에 첩보를 실행합니다.' - ); + await expect(form.getByTestId('command-argument-guidance')).toContainText('선택한 도시에 첩보를 실행합니다.'); await expect(form.getByTestId('command-argument-guidance')).toContainText( '인접 도시에서는 더 많은 정보를 얻습니다.' ); @@ -979,9 +977,7 @@ test('shows and reserves the Ref spy command for a user on desktop and mobile', await picker.screenshot({ path: test.info().outputPath('spy-command-mobile-500.png') }); }); -test('defaults founding to a Ref-selectable nation trait and paints color option labels', async ({ - page, -}) => { +test('defaults founding to a Ref-selectable nation trait and paints color option labels', async ({ page }) => { const foundingColors = [ { value: 0, label: '색상 1', color: '#FF0000' }, { value: 15, label: '색상 16', color: '#6495ED' }, @@ -1284,7 +1280,9 @@ test('keeps general and chief command categories after input and across page rel const reloadedChiefPicker = chiefPage.getByTestId('command-picker'); await expect(reloadedChiefPicker.getByRole('button', { name: '전략', exact: true })).toHaveClass(/active/); await expect(reloadedChiefPicker.getByRole('button', { name: '필사즉생', exact: true })).toBeVisible(); - await expect.poll(() => reloadedChiefPicker.evaluate((element) => element.getBoundingClientRect().height)).toBeGreaterThan(200); + await expect + .poll(() => reloadedChiefPicker.evaluate((element) => element.getBoundingClientRect().height)) + .toBeGreaterThan(200); await reloadedChiefPicker.screenshot({ path: test.info().outputPath('chief-category-after-reload-mobile-500.png'), }); @@ -1825,6 +1823,61 @@ test('shows city or capital maps for every requested chief command', async ({ pa await page.screenshot({ path: test.info().outputPath('chief-command-map-guidance.png'), fullPage: true }); }); +test('prioritizes own cities for assignment while retaining other map targets', async ({ page }) => { + const assignmentTable = structuredClone(commandTable); + assignmentTable.inputOptions.cities = [ + { value: 2, label: '허창 (적국)', description: '적국 · 예주 · 대도시' }, + { value: 3, label: '단양 (무주)' }, + { value: 1, label: '업 (아국)' }, + ]; + + await install(page, false, assignmentTable); + await page.setViewportSize({ width: 1200, height: 900 }); + await page.goto('/che/chief-center'); + await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click(); + const picker = page.getByTestId('command-picker'); + await picker.getByRole('button', { name: /^(?:국가:)?인사$/, exact: true }).click(); + await picker.getByRole('button', { name: /발령/ }).click(); + + const form = picker.getByTestId('command-argument-form'); + const citySelect = form.locator('#command-arg-destCityId'); + await expect(form.getByTestId('command-argument-map')).toBeVisible(); + await expect(citySelect.locator('option')).toHaveText(['업 (아국)', '허창 (적국)', '단양 (무주)']); + await expect(citySelect).toHaveValue('1'); + + await form.getByTestId('command-argument-map').locator('.city-base').nth(1).click(); + await expect(citySelect).toHaveValue('2'); + await expect(form.getByTestId('command-map-selection-status')).toContainText('선택 도시허창'); + await expect(page).toHaveURL(/\/che\/chief-center$/); + await form.screenshot({ path: test.info().outputPath('chief-assignment-own-city-priority.png') }); + + await page.setViewportSize({ width: 500, height: 900 }); + await page.goto('/che/chief-center'); + await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click(); + const mobilePicker = page.getByTestId('command-picker'); + await mobilePicker.getByRole('button', { name: /^(?:국가:)?인사$/, exact: true }).click(); + await mobilePicker.getByRole('button', { name: /발령/ }).click(); + + const mobileForm = mobilePicker.getByTestId('command-argument-form'); + const mobileMap = mobileForm.getByTestId('command-argument-map'); + const mobileCitySelect = mobileForm.locator('#command-arg-destCityId'); + await expect(mobileMap).toBeVisible(); + await expect(mobileCitySelect.locator('option')).toHaveText(['업 (아국)', '허창 (적국)', '단양 (무주)']); + const mobileGeometry = await mobilePicker.evaluate((element) => ({ + width: element.getBoundingClientRect().width, + overflow: element.scrollWidth - element.clientWidth, + })); + expect(mobileGeometry).toEqual({ width: 500, overflow: 0 }); + const mapGeometry = await mobileMap.locator('.map-area').evaluate((element) => { + const rect = element.getBoundingClientRect(); + return { width: rect.width, height: rect.height }; + }); + expect(mapGeometry.width / mapGeometry.height).toBeCloseTo(7 / 5, 2); + await mobileMap.screenshot({ path: test.info().outputPath('chief-assignment-map-mobile.png') }); + await mobileCitySelect.scrollIntoViewIfNeeded(); + await mobilePicker.screenshot({ path: test.info().outputPath('chief-assignment-own-city-priority-mobile.png') }); +}); + test('prioritizes current nation targets while preserving every choice', async ({ page }) => { await install(page); await page.setViewportSize({ width: 1200, height: 900 }); diff --git a/app/game-frontend/e2e/inGameInfo.spec.ts b/app/game-frontend/e2e/inGameInfo.spec.ts index 4715b3d6..6702bb7b 100644 --- a/app/game-frontend/e2e/inGameInfo.spec.ts +++ b/app/game-frontend/e2e/inGameInfo.spec.ts @@ -467,6 +467,112 @@ test('국가 정보의 작위는 Ref 국가 등급 이름으로 표시된다', a await expect(root).not.toContainText('작 위1'); }); +test('map keeps desktop hover navigation and lets touch users choose one-tap or two-tap city navigation', async ({ + browser, + page, +}, testInfo) => { + await install(page); + await page.setViewportSize({ width: 1200, height: 900 }); + await go(page, 'global-info'); + + const desktopCity = page.locator('.city-base').first(); + await expect(page.locator('.map-toggle-single-tap')).toHaveCount(0); + await desktopCity.hover(); + await expect(page.locator('.map-tooltip .tooltip-title')).toHaveText('【하북|특】업'); + await desktopCity.click(); + await expect(page).toHaveURL(/\/current-city\?cityId=1$/u); + + const configuredBaseUrl = testInfo.project.use.baseURL; + if (typeof configuredBaseUrl !== 'string') { + throw new Error('Playwright baseURL is required for the mobile map contract'); + } + const context = await browser.newContext({ + baseURL: configuredBaseUrl, + viewport: { width: 390, height: 844 }, + screen: { width: 390, height: 844 }, + deviceScaleFactor: 1, + isMobile: true, + hasTouch: true, + colorScheme: 'dark', + }); + const mobilePage = await context.newPage(); + + try { + await install(mobilePage); + await go(mobilePage, 'global-info'); + + const twoTapButton = mobilePage.getByRole('button', { name: '두번 탭 해 도시 이동 끄기' }); + await expect(twoTapButton).toBeVisible(); + await expect(twoTapButton).toHaveAttribute('aria-pressed', 'false'); + const controlGeometry = await twoTapButton.evaluate((element) => { + const rect = element.getBoundingClientRect(); + const mapRect = element.closest('.map-area')?.getBoundingClientRect(); + const style = getComputedStyle(element); + return { + right: rect.right, + bottom: rect.bottom, + mapRight: mapRect?.right, + mapBottom: mapRect?.bottom, + fontSize: style.fontSize, + lineHeight: style.lineHeight, + documentWidth: document.documentElement.scrollWidth, + viewportWidth: document.documentElement.clientWidth, + overflowing: Array.from(document.querySelectorAll('body *')) + .filter( + (candidate) => candidate.getBoundingClientRect().right > document.documentElement.clientWidth + ) + .map((candidate) => ({ + tag: candidate.tagName, + className: candidate.className, + right: candidate.getBoundingClientRect().right, + })) + .slice(0, 8), + }; + }); + expect(controlGeometry).toMatchObject({ + fontSize: '11px', + lineHeight: '18px', + viewportWidth: 500, + overflowing: [], + }); + expect(controlGeometry.documentWidth).toBeLessThanOrEqual(controlGeometry.viewportWidth + 1); + expect(controlGeometry.mapRight! - controlGeometry.right).toBeCloseTo(4, 1); + expect(controlGeometry.mapBottom! - controlGeometry.bottom).toBeCloseTo(4, 1); + + const mobileCities = mobilePage.locator('.city-base'); + await mobileCities.nth(0).tap(); + await expect(mobilePage).toHaveURL(/\/global-info$/u); + await expect(mobilePage.locator('.map-tooltip .tooltip-title')).toHaveText('【하북|특】업'); + + await mobileCities.nth(1).tap(); + await expect(mobilePage).toHaveURL(/\/global-info$/u); + await expect(mobilePage.locator('.map-tooltip .tooltip-title')).toHaveText('【하북|수】성2'); + await mobilePage.screenshot({ path: testInfo.outputPath('mobile-map-first-tap-tooltip.png'), fullPage: true }); + + await mobileCities.nth(1).tap(); + await expect(mobilePage).toHaveURL(/\/current-city\?cityId=2$/u); + + await go(mobilePage, 'global-info'); + await mobilePage.getByRole('button', { name: '두번 탭 해 도시 이동 끄기' }).click(); + const singleTapButton = mobilePage.getByRole('button', { name: '두번 탭 해 도시 이동 켜기' }); + await expect(singleTapButton).toHaveAttribute('aria-pressed', 'true'); + expect(await mobilePage.evaluate(() => localStorage.getItem('sam.toggleSingleTap'))).toBe('yes'); + + await mobilePage.reload(); + await expect(singleTapButton).toBeVisible(); + await mobilePage.locator('.city-base').nth(2).tap(); + await expect(mobilePage).toHaveURL(/\/current-city\?cityId=3$/u); + + await go(mobilePage, 'global-info'); + await mobilePage.getByRole('button', { name: '두번 탭 해 도시 이동 켜기' }).click(); + expect(await mobilePage.evaluate(() => localStorage.getItem('sam.toggleSingleTap'))).toBe('no'); + await mobilePage.locator('.city-base').first().tap(); + await expect(mobilePage).toHaveURL(/\/global-info$/u); + } finally { + await context.close(); + } +}); + test('global-info renders the ref nation summary columns beside the map', async ({ page }) => { await install(page); await page.setViewportSize({ width: 1200, height: 900 }); diff --git a/app/game-frontend/src/components/command/commandArgumentOptions.ts b/app/game-frontend/src/components/command/commandArgumentOptions.ts new file mode 100644 index 00000000..e8c84dc7 --- /dev/null +++ b/app/game-frontend/src/components/command/commandArgumentOptions.ts @@ -0,0 +1,21 @@ +import type { CommandMapData, CommandOption } from './types'; + +export const commandCityOptions = ( + commandKey: string, + options: readonly CommandOption[], + mapData?: CommandMapData | null +): CommandOption[] => { + if (commandKey !== 'che_발령' || typeof mapData?.myNation !== 'number') return [...options]; + + const nationByCityId = new Map(mapData.cityList.map(([cityId, , , nationId]) => [cityId, nationId])); + return options + .map((option, index) => ({ option, index })) + .sort((left, right) => { + const leftOwned = + typeof left.option.value === 'number' && nationByCityId.get(left.option.value) === mapData.myNation; + const rightOwned = + typeof right.option.value === 'number' && nationByCityId.get(right.option.value) === mapData.myNation; + return Number(rightOwned) - Number(leftOwned) || left.index - right.index; + }) + .map(({ option }) => option); +}; diff --git a/app/game-frontend/src/components/main/CommandArgumentForm.vue b/app/game-frontend/src/components/main/CommandArgumentForm.vue index c6b7fc67..57e266d4 100644 --- a/app/game-frontend/src/components/main/CommandArgumentForm.vue +++ b/app/game-frontend/src/components/main/CommandArgumentForm.vue @@ -2,6 +2,7 @@ import { computed, reactive, watch, type CSSProperties } from 'vue'; import MapViewer from './MapViewer.vue'; import { commandArgumentPresentation } from '../command/commandArgumentPresentation'; +import { commandCityOptions } from '../command/commandArgumentOptions'; import { commandArgumentFieldContract, shouldPreserveCommandArgumentValue, @@ -64,6 +65,9 @@ const optionsFor = (field: CommandInputField): CommandOption[] => { if (field.optionSource === 'nations') { return props.options.nationTargets?.[props.commandKey] ?? props.options.nations; } + if (field.optionSource === 'cities') { + return commandCityOptions(props.commandKey, props.options.cities, props.mapData); + } if (field.optionSource === 'items') { return props.options.items[String(values.itemType ?? '')] ?? []; } @@ -104,12 +108,7 @@ const synchronizeValues = () => { for (const field of props.fields) { const preserve = !commandChanged && - shouldPreserveCommandArgumentValue( - field, - previousFieldContracts.get(field.key), - values, - optionsFor(field) - ); + shouldPreserveCommandArgumentValue(field, previousFieldContracts.get(field.key), values, optionsFor(field)); if (!preserve) values[field.key] = defaultValue(field); } const itemCodeField = props.fields.find((field) => field.key === 'itemCode'); diff --git a/app/game-frontend/src/components/main/MapCityBasic.vue b/app/game-frontend/src/components/main/MapCityBasic.vue index 85a1840b..73122b41 100644 --- a/app/game-frontend/src/components/main/MapCityBasic.vue +++ b/app/game-frontend/src/components/main/MapCityBasic.vue @@ -28,6 +28,8 @@ const props = defineProps<{ const emit = defineEmits<{ (event: 'hover', cityId: number): void; (event: 'leave'): void; + (event: 'touch', cityId: number, touchEvent: TouchEvent): void; + (event: 'touchleave'): void; (event: 'select', cityId: number): void; }>(); @@ -37,6 +39,25 @@ const stateOffset = computed(() => -6 * props.mapScale); const selectCity = () => { if (!props.readonly) emit('select', props.city.id); }; + +let touchOnTrack = false; + +const touchstart = () => { + touchOnTrack = true; +}; + +const touchmove = () => { + touchOnTrack = false; +}; + +const touchend = (event: TouchEvent) => { + if (touchOnTrack) { + event.stopPropagation(); + emit('touch', props.city.id, event); + return; + } + emit('touchleave'); +};