diff --git a/app/game-api/test/commandInput.test.ts b/app/game-api/test/commandInput.test.ts index 97d5c0c1..ad464973 100644 --- a/app/game-api/test/commandInput.test.ts +++ b/app/game-api/test/commandInput.test.ts @@ -6,10 +6,7 @@ import { } from '@sammo-ts/logic'; import { describe, expect, it } from 'vitest'; -import { - buildTurnCommandInputFields, - parseReservedTurnArgs, -} from '../src/turns/commandInput.js'; +import { buildTurnCommandInputFields, parseReservedTurnArgs } from '../src/turns/commandInput.js'; describe('turn command argument input', () => { it('builds supported fields for every argument-bearing command module', async () => { @@ -22,6 +19,9 @@ describe('turn command argument input', () => { key: spec.key, fields: buildTurnCommandInputFields(spec), })); + const nationFields = nation + .filter((spec) => spec.reqArg) + .map((spec) => ({ key: spec.key, fields: buildTurnCommandInputFields(spec) })); expect(argumentSpecs).toHaveLength(44); expect(fields.every((entry) => entry.fields.length > 0)).toBe(true); @@ -32,6 +32,34 @@ describe('turn command argument input', () => { { key: 'destNationId', kind: 'select', optionSource: 'nations' }, { key: 'amountList', kind: 'numberTuple' }, ]); + + expect( + nationFields + .filter((entry) => entry.key !== 'che_발령') + .flatMap((entry) => + entry.fields + .filter((field) => field.optionSource === 'cities' || field.optionSource === 'nations') + .map((field) => `${entry.key}:${field.optionSource}`) + ) + .sort() + ).toEqual( + [ + 'che_급습:nations', + 'che_물자원조:nations', + 'che_백성동원:cities', + 'che_불가침제의:nations', + 'che_불가침파기제의:nations', + 'che_선전포고:nations', + 'che_수몰:cities', + 'che_이호경식:nations', + 'che_종전제의:nations', + 'che_천도:cities', + 'che_초토화:cities', + 'che_피장파장:nations', + 'che_허보:cities', + 'cr_인구이동:cities', + ].sort() + ); }); it('normalizes valid arguments and rejects malformed or wrong-scope commands', async () => { @@ -50,8 +78,6 @@ describe('turn command argument input', () => { amount: 200, destGeneralId: 7, }); - await expect(parseReservedTurnArgs('general', 'che_포상', {})).rejects.toThrow( - 'Unknown general turn command' - ); + await expect(parseReservedTurnArgs('general', 'che_포상', {})).rejects.toThrow('Unknown general turn command'); }); }); 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-engine/test/scenarioLoader.test.ts b/app/game-engine/test/scenarioLoader.test.ts index 66608a52..53013df5 100644 --- a/app/game-engine/test/scenarioLoader.test.ts +++ b/app/game-engine/test/scenarioLoader.test.ts @@ -5,6 +5,16 @@ import { describe, expect, it } from 'vitest'; import { loadScenarioDefinitionById, resolveScenarioDefaultsPath } from '../src/scenario/scenarioLoader.js'; +type LoadedScenario = Awaited>; + +const readItemSlot = (scenario: LoadedScenario, slot: string): Record => { + const allItems = scenario.config.const.allItems as Record> | undefined; + return allItems?.[slot] ?? {}; +}; + +const readAvailableSpecialWar = (scenario: LoadedScenario): string[] => + (scenario.config.const.availableSpecialWar as string[] | undefined) ?? []; + describe('tracked scenario resources', () => { it('loads every scenario through its composed resource graph', async () => { const scenarioRoot = path.dirname(resolveScenarioDefaultsPath()); @@ -35,4 +45,36 @@ describe('tracked scenario resources', () => { ]); } }); + + it('keeps the buyable war-special pack scoped to each Ref scenario contract', async () => { + const [ordinaryBlank, legacySecretBlank, mirrorBlank, multiUnitBlank, moreEffectBlank, composedAddon] = + await Promise.all( + [0, 902, 910, 912, 913, 2141].map((scenarioId) => loadScenarioDefinitionById(scenarioId)) + ); + + expect( + Object.keys(readItemSlot(ordinaryBlank, 'item')).filter((key) => key.startsWith('event_전투특기_')) + ).toEqual([]); + + const legacySecretItems = readItemSlot(legacySecretBlank, 'item'); + expect(Object.keys(legacySecretItems).filter((key) => key.startsWith('event_전투특기_'))).toHaveLength(19); + expect(legacySecretItems).not.toHaveProperty('event_전투특기_견고'); + expect(readAvailableSpecialWar(legacySecretBlank)).not.toContain('che_견고'); + + const mirrorItems = readItemSlot(mirrorBlank, 'item'); + expect(Object.keys(mirrorItems).filter((key) => key.startsWith('event_전투특기_'))).toHaveLength(19); + expect(mirrorItems).not.toHaveProperty('event_전투특기_척사'); + expect(readAvailableSpecialWar(mirrorBlank)).not.toContain('che_척사'); + + const multiUnitItems = readItemSlot(multiUnitBlank, 'item'); + expect(Object.keys(multiUnitItems).filter((key) => key.startsWith('event_전투특기_'))).toHaveLength(19); + expect(multiUnitItems).not.toHaveProperty('event_전투특기_견고'); + + const moreEffectItems = readItemSlot(moreEffectBlank, 'item'); + const composedAddonItems = readItemSlot(composedAddon, 'item'); + expect(Object.keys(moreEffectItems).filter((key) => key.startsWith('event_전투특기_'))).toHaveLength(20); + expect(Object.keys(composedAddonItems).filter((key) => key.startsWith('event_전투특기_'))).toHaveLength(20); + expect(readItemSlot(moreEffectBlank, 'horse').che_명마_07_백마).toBe(4); + expect(readItemSlot(composedAddon, 'horse').che_명마_07_백마).toBe(2); + }); }); diff --git a/app/game-engine/test/scenarioSeeder.test.ts b/app/game-engine/test/scenarioSeeder.test.ts index c6d587c5..46b06dff 100644 --- a/app/game-engine/test/scenarioSeeder.test.ts +++ b/app/game-engine/test/scenarioSeeder.test.ts @@ -93,6 +93,49 @@ const canRun = await canConnectToDatabase(databaseUrl); const describeDb = describe.runIf(canRun); describeDb('scenario database seed', () => { + test('persists each blank-land scenario item contract without leaking the shared addon', async () => { + const readPersistedItemContract = async (targetScenarioId: number) => { + const { applied } = await seedScenarioToDatabase({ + scenarioId: targetScenarioId, + databaseUrl, + }); + + const connector = createGamePostgresConnector({ url: databaseUrl }); + await connector.connect(); + try { + const worldState = await connector.prisma.worldState.findFirstOrThrow(); + const config = worldState.config as Record; + const scenarioConst = (config.const ?? {}) as Record; + const allItems = (scenarioConst.allItems ?? {}) as Record>; + const items = allItems.item ?? {}; + const availableSpecialWar = (scenarioConst.availableSpecialWar ?? []) as string[]; + + return { + applied, + battleTraitItemCount: Object.keys(items).filter((key) => key.startsWith('event_전투특기_')).length, + availableSpecialWar, + items, + }; + } finally { + await connector.disconnect(); + } + }; + + const ordinaryBlank = await readPersistedItemContract(0); + const legacySecretBlank = await readPersistedItemContract(902); + + expect(ordinaryBlank).toMatchObject({ + applied: true, + battleTraitItemCount: 0, + availableSpecialWar: [], + }); + expect(legacySecretBlank.applied).toBe(true); + expect(legacySecretBlank.battleTraitItemCount).toBe(19); + expect(legacySecretBlank.availableSpecialWar).toHaveLength(19); + expect(legacySecretBlank.items).not.toHaveProperty('event_전투특기_견고'); + expect(legacySecretBlank.availableSpecialWar).not.toContain('che_견고'); + }); + test('snapshots the complete opening inheritance balance before game activity', async () => { const serverId = 'scenario-seeder-inheritance-baseline'; const userId = 'scenario-seeder-inheritance-user'; diff --git a/app/game-frontend/e2e/commandArguments.spec.ts b/app/game-frontend/e2e/commandArguments.spec.ts index c1d8afa2..4ca368ca 100644 --- a/app/game-frontend/e2e/commandArguments.spec.ts +++ b/app/game-frontend/e2e/commandArguments.spec.ts @@ -522,6 +522,21 @@ const commandTable = { buildCityCommand('che_백성동원', '백성동원'), buildCityCommand('che_수몰', '수몰'), buildCityCommand('che_허보', '허보'), + buildNationCommand('che_이호경식', '이호경식'), + buildNationCommand('che_급습', '급습'), + { + ...buildNationCommand('che_피장파장', '피장파장'), + inputFields: [ + ...buildNationCommand('che_피장파장', '피장파장').inputFields, + { + key: 'commandType', + label: '대응 명령', + kind: 'select', + required: true, + options: [{ value: 'che_수몰', label: '수몰' }], + }, + ], + }, ], }, ], @@ -815,7 +830,7 @@ const install = async (page: Page, rejectGeneral = false, commandTableResponse: { id: 2, name: '허창', level: 7, region: 2, x: 240, y: 180, path: [1] }, ], regionMap: { 1: '하북', 2: '예주' }, - levelMap: { 8: '특' }, + levelMap: { 8: '특', 7: '대' }, }); if (name === 'auth.status') return response({ ok: true }); if (name === 'lobby.info') @@ -953,9 +968,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 +992,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 +1295,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'), }); @@ -1780,18 +1793,25 @@ test('uses the map to choose a nation target in the chief command window', async await page.screenshot({ path: test.info().outputPath('chief-nation-map-option.png'), fullPage: true }); }); -test('shows city or capital maps for every requested chief command', async ({ page }) => { +test('shows a map and target details for every city or nation argument chief command except assignment', async ({ + page, +}) => { await install(page); await page.setViewportSize({ width: 1200, height: 900 }); const cases = [ - { category: '인사', action: '발령', mode: 'city' }, { category: '특수', action: '초토화', mode: 'city' }, { category: '특수', action: '천도', mode: 'city' }, - { category: '특수', action: '증축', mode: 'capital' }, - { category: '특수', action: '감축', mode: 'capital' }, { category: '전략', action: '수몰', mode: 'city' }, { category: '전략', action: '허보', mode: 'city' }, { category: '전략', action: '백성동원', mode: 'city' }, + { category: '외교', action: '원조', mode: 'nation' }, + { category: '외교', action: '불가침 제의', mode: 'nation' }, + { category: '외교', action: '선전포고', mode: 'nation' }, + { category: '외교', action: '종전 제의', mode: 'nation' }, + { category: '외교', action: '불가침 파기 제의', mode: 'nation' }, + { category: '전략', action: '이호경식', mode: 'nation' }, + { category: '전략', action: '급습', mode: 'nation' }, + { category: '전략', action: '피장파장', mode: 'nation' }, ]; for (const entry of cases) { @@ -1805,24 +1825,106 @@ test('shows city or capital maps for every requested chief command', async ({ pa await expect(map, `${entry.action} 지도`).toBeVisible(); await expect(form.getByTestId('command-argument-guidance')).toBeVisible(); - if (entry.mode === 'capital') { - await expect(form.getByTestId('command-map-selection-status')).toContainText('현재 수도업'); - await expect(form.getByTestId('command-map-target-summary')).toContainText('현재 수도'); - await expect(map.locator('.city-base').first()).toHaveJSProperty('tagName', 'DIV'); - expect( - await map - .locator('.city-base') - .first() - .evaluate((node) => getComputedStyle(node).cursor) - ).toBe('default'); - } else { - await map.locator('.city-base').nth(1).click(); + await map.locator('.city-base').nth(1).click(); + if (entry.mode === 'city') { await expect(form.locator('#command-arg-destCityId')).toHaveValue('2'); await expect(form.getByTestId('command-map-selection-status')).toContainText('선택 도시허창'); + await expect(form.getByTestId('command-map-target-summary')).toContainText( + '허창 · 적국 · 예주 · 대 · 현재 도시에서 1칸' + ); + } else { + await expect(form.locator('#command-arg-destNationId')).toHaveValue('2'); + await expect(form.getByTestId('command-map-selection-status')).toContainText('선택 국가적국'); + await expect(form.getByTestId('command-map-target-summary')).toContainText('적국 · 수도 허창 · 도시 1개'); } + await expect(form.getByTestId('current-city-marker')).toHaveAttribute('aria-label', '현재 도시 업'); } - await page.screenshot({ path: test.info().outputPath('chief-command-map-guidance.png'), fullPage: true }); + await page.screenshot({ path: test.info().outputPath('chief-command-target-map-details.png'), fullPage: true }); + + await page.setViewportSize({ width: 500, 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 map = form.getByTestId('command-argument-map'); + const targetCity = map.locator('.city-base').nth(1); + await targetCity.hover(); + expect(await targetCity.evaluate((node) => getComputedStyle(node).cursor)).toBe('pointer'); + await targetCity.focus(); + await expect(targetCity).toBeFocused(); + await targetCity.click(); + await expect(form.locator('#command-arg-destNationId')).toHaveValue('2'); + const geometry = await picker.evaluate((element) => ({ + pickerWidth: element.getBoundingClientRect().width, + pickerOverflow: element.scrollWidth - element.clientWidth, + documentOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth, + mapInsidePicker: + element.querySelector('[data-testid="command-argument-map"]')!.getBoundingClientRect().right <= + element.getBoundingClientRect().right, + })); + expect(geometry).toEqual({ pickerWidth: 500, pickerOverflow: 0, documentOverflow: 0, mapInsidePicker: true }); + await page.screenshot({ + path: test.info().outputPath('chief-command-target-map-details-mobile.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 }) => { 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/e2e/inGameMenus.spec.ts b/app/game-frontend/e2e/inGameMenus.spec.ts index 442a0535..3a9515cf 100644 --- a/app/game-frontend/e2e/inGameMenus.spec.ts +++ b/app/game-frontend/e2e/inGameMenus.spec.ts @@ -2,6 +2,7 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { basename, resolve } from 'node:path'; import { expect, test, type Locator, type Page, type Route } from '@playwright/test'; import { gameBasePath, gameProfile, gameTrpcRoute } from './gameTestPaths.js'; +import { touchDrag } from './touchDrag.js'; const response = (data: unknown) => ({ result: { data } }); const parityArtifactDir = process.env.MENU_PARITY_ARTIFACT_DIR; @@ -1469,6 +1470,70 @@ test('내 정보&설정에서 모바일 메인 패널을 드래그하거나 버 .toEqual(defaultOrder); }); +test('실제 모바일 터치로 메인 패널 순서를 재정렬한다', async ({ browser }, testInfo) => { + const configuredBaseUrl = testInfo.project.use.baseURL; + if (typeof configuredBaseUrl !== 'string') { + throw new Error('Playwright baseURL is required for the mobile touch 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 { + const state: FixtureState = { permission: 'head', myset: 3, settingMutations: [], accessPages: [] }; + await install(mobilePage, state); + await mobilePage.goto('my-page'); + await mobilePage.getByRole('button', { name: '순서 바꾸기', exact: true }).click(); + + const dialog = mobilePage.getByRole('dialog', { name: '모바일 레이아웃 순서 바꾸기' }); + const commands = dialog.locator('[data-mobile-layout-id="commands"]'); + const nationMenu = dialog.locator('[data-mobile-layout-id="nation-menu"]'); + await touchDrag(mobilePage, nationMenu, commands); + + await expect + .poll(() => + dialog + .locator('[data-mobile-layout-id]') + .evaluateAll((elements) => elements.map((element) => element.getAttribute('data-mobile-layout-id'))) + ) + .toEqual([ + 'nation-menu', + 'commands', + 'nation', + 'general', + 'city', + 'map', + 'records', + 'global-menu', + 'messages', + ]); + await dialog.screenshot({ path: testInfo.outputPath('mobile-main-panel-touch-dialog.png') }); + await dialog.getByRole('button', { name: '적용', exact: true }).click(); + await expect + .poll(() => mobilePage.evaluate(() => JSON.parse(localStorage.getItem('sam.mobileMainPanelOrder.v1') ?? '[]'))) + .toEqual([ + 'nation-menu', + 'commands', + 'nation', + 'general', + 'city', + 'map', + 'records', + 'global-menu', + 'messages', + ]); + await mobilePage.screenshot({ path: testInfo.outputPath('mobile-main-panel-touch.png'), fullPage: true }); + } finally { + await context.close(); + } +}); + for (const [label, failure] of [ ['daemon timeout', 'TIMEOUT'], ['engine transaction 오류', 'INTERNAL_SERVER_ERROR'], diff --git a/app/game-frontend/e2e/mainNavigation.spec.ts b/app/game-frontend/e2e/mainNavigation.spec.ts index df63fc59..81412299 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); @@ -1102,7 +1102,9 @@ test('scopes the new-survey notice cursor to the reset-specific server ID', asyn expect(await page.evaluate(() => localStorage.getItem('state.che.lastVote'))).toBe('99'); }); -test('desktop menus preserve ref columns, prefix-safe routes, and controlled dropdown behavior', async ({ page }) => { +test('desktop menus preserve ref columns, prefix-safe routes, and controlled dropdown behavior', async ({ + page, +}, testInfo) => { const state: NavigationFixture = { officerLevel: 5, permission: 2, @@ -1125,9 +1127,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분'); @@ -1252,6 +1252,34 @@ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dro const versionDialog = page.getByRole('dialog', { name: '게임 정보' }); await expect(versionDialog).toBeVisible(); await expect(versionDialog).toContainText('메인 화면 검증 시나리오'); + await expect(versionDialog.getByText('빌드 커밋', { exact: true })).toBeVisible(); + await expect(versionDialog.locator('code')).toHaveText('0123456789abcdef0123456789abcdef01234567'); + const versionGeometry = await versionDialog.evaluate((dialog) => { + const code = dialog.querySelector('code'); + if (!code) throw new Error('game version commit is missing'); + const dialogStyle = getComputedStyle(dialog); + const codeStyle = getComputedStyle(code); + return { + dialog: dialog.getBoundingClientRect().toJSON(), + code: code.getBoundingClientRect().toJSON(), + dialogBackground: dialogStyle.backgroundColor, + dialogColor: dialogStyle.color, + codeColor: codeStyle.color, + codeFontFamily: codeStyle.fontFamily, + viewportWidth: window.innerWidth, + }; + }); + expect(versionGeometry.dialog.width).toBeLessThanOrEqual(versionGeometry.viewportWidth - 32); + expect(versionGeometry.code.left).toBeGreaterThanOrEqual(versionGeometry.dialog.left); + expect(versionGeometry.code.right).toBeLessThanOrEqual(versionGeometry.dialog.right); + expect(versionGeometry.dialogBackground).toBe('rgb(32, 32, 32)'); + expect(versionGeometry.dialogColor).toBe('rgb(255, 255, 255)'); + expect(versionGeometry.codeColor).toBe('rgb(215, 215, 215)'); + await writeFile( + testInfo.outputPath('desktop-game-version-dialog.json'), + `${JSON.stringify(versionGeometry, null, 2)}\n` + ); + await versionDialog.screenshot({ path: testInfo.outputPath('desktop-game-version-dialog.png') }); await versionDialog.getByRole('button', { name: '닫기' }).click(); await expect(versionDialog).toBeHidden(); @@ -1279,7 +1307,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, @@ -1479,6 +1509,30 @@ test('the repeated bottom global menu opens upward on the mobile document', asyn expect(geometry.caretBorderTopWidth).toBe('0px'); expect(geometry.caretBorderBottomWidth).toBe('4px'); await bottomGlobal.screenshot({ path: testInfo.outputPath('mobile-bottom-global-dropup.png') }); + await page.setViewportSize({ width: 390, height: 844 }); + await bottomGlobal.locator('[data-navigation-id="version"]').click(); + const versionDialog = page.getByRole('dialog', { name: '게임 정보' }); + await expect(versionDialog).toBeVisible(); + await expect(versionDialog.locator('code')).toHaveText('0123456789abcdef0123456789abcdef01234567'); + const versionGeometry = await versionDialog.evaluate((dialog) => { + const code = dialog.querySelector('code'); + if (!code) throw new Error('game version commit is missing'); + return { + dialog: dialog.getBoundingClientRect().toJSON(), + code: code.getBoundingClientRect().toJSON(), + viewportWidth: window.innerWidth, + documentScrollWidth: document.documentElement.scrollWidth, + }; + }); + expect(versionGeometry.dialog.width).toBeLessThanOrEqual(versionGeometry.viewportWidth - 32); + expect(versionGeometry.code.left).toBeGreaterThanOrEqual(versionGeometry.dialog.left); + expect(versionGeometry.code.right).toBeLessThanOrEqual(versionGeometry.dialog.right); + expect(versionGeometry.documentScrollWidth).toBe(500); + await writeFile( + testInfo.outputPath('mobile-game-version-dialog.json'), + `${JSON.stringify(versionGeometry, null, 2)}\n` + ); + await versionDialog.screenshot({ path: testInfo.outputPath('mobile-game-version-dialog.png') }); await persistArtifact(page, `${basePath.slice(1)}-mobile-bottom-dropup`); }); @@ -2794,6 +2848,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/e2e/npcPolicy.spec.ts b/app/game-frontend/e2e/npcPolicy.spec.ts index 81fd4f2c..97e0b2ab 100644 --- a/app/game-frontend/e2e/npcPolicy.spec.ts +++ b/app/game-frontend/e2e/npcPolicy.spec.ts @@ -3,6 +3,7 @@ import { mkdir, readFile } from 'node:fs/promises'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { gameProfile, gameTrpcRoute } from './gameTestPaths.js'; +import { touchDrag } from './touchDrag.js'; type FixtureState = { permissionLevel: number; @@ -320,6 +321,58 @@ test('500px layout stacks policy fields and priority panels like the reference', await screenshot(page, 'core-npc-policy-mobile.png'); }); +test('physical mobile touch reorders NPC priority across active and inactive lists', async ({ browser }, testInfo) => { + const configuredBaseUrl = testInfo.project.use.baseURL; + if (typeof configuredBaseUrl !== 'string') { + throw new Error('Playwright baseURL is required for the mobile touch 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 installFixture(mobilePage, { permissionLevel: 4, mutations: [] }); + await gotoPolicy(mobilePage); + await expect(mobilePage.locator('#container')).toBeVisible(); + + const nationPanel = mobilePage.locator('.priority-panel').first(); + const activeList = nationPanel.locator('.priority-column').nth(1).locator('.priority-list'); + const activeRows = activeList.locator('.priority-item'); + await touchDrag( + mobilePage, + activeRows.nth(0), + activeRows.nth(3), + { targetYRatio: 0.9 } + ); + await expect + .poll(() => + activeList + .locator('.priority-item .priority_info > span:nth-child(2)') + .first() + .textContent() + ) + .toBe('선전포고'); + + const activeItem = activeList.getByText('불가침제의', { exact: true }); + const inactiveList = nationPanel.locator('.priority-column').first().locator('.priority-list'); + await touchDrag(mobilePage, activeItem, inactiveList.locator('.inactive-header')); + + await expect(inactiveList.getByText('불가침제의', { exact: true })).toBeVisible(); + await expect( + activeList.getByText('불가침제의', { exact: true }) + ).toHaveCount(0); + await mobilePage.screenshot({ path: testInfo.outputPath('npc-priority-mobile-touch.png'), fullPage: true }); + } finally { + await context.close(); + } +}); + test('a read-level user sees enabled legacy controls but a forbidden save retains the draft', async ({ page }) => { const state: FixtureState = { permissionLevel: 1, failNextMutation: true, mutations: [] }; await installFixture(page, state); diff --git a/app/game-frontend/e2e/playwright.config.mjs b/app/game-frontend/e2e/playwright.config.mjs index 734b6cee..55766660 100644 --- a/app/game-frontend/e2e/playwright.config.mjs +++ b/app/game-frontend/e2e/playwright.config.mjs @@ -9,11 +9,12 @@ const gameProfile = process.env.PLAYWRIGHT_GAME_PROFILE ?? 'che:default'; const baseURL = `http://127.0.0.1:${port}${basePath}/`; const gameApiUrl = process.env.PLAYWRIGHT_GAME_API_URL ?? `${basePath}/api/trpc`; const gatewayWebUrl = process.env.PLAYWRIGHT_GATEWAY_WEB_URL ?? '/gateway/'; +const buildCommitSha = process.env.PLAYWRIGHT_BUILD_COMMIT_SHA ?? '0123456789abcdef0123456789abcdef01234567'; const useProductionBundle = process.env.PLAYWRIGHT_FRONTEND_MODE === 'production'; const frontendEnv = `VITE_APP_BASE_PATH=${basePath} VITE_GAME_API_URL=${gameApiUrl} ` + `VITE_GAME_PROFILE=${gameProfile} VITE_GATEWAY_WEB_URL=${gatewayWebUrl} ` + - 'VITE_GATEWAY_API_URL=/gateway/api/trpc'; + `VITE_GATEWAY_API_URL=/gateway/api/trpc VITE_BUILD_COMMIT_SHA=${buildCommitSha}`; export default defineConfig({ testDir: '.', diff --git a/app/game-frontend/e2e/touchDrag.ts b/app/game-frontend/e2e/touchDrag.ts new file mode 100644 index 00000000..c79bcb99 --- /dev/null +++ b/app/game-frontend/e2e/touchDrag.ts @@ -0,0 +1,77 @@ +import type { Locator, Page } from '@playwright/test'; + +type TouchPoint = { + x: number; + y: number; +}; + +type TouchDragOptions = { + targetYRatio?: number; +}; + +const pointIn = async (locator: Locator, yRatio = 0.5): Promise => { + const box = await locator.boundingBox(); + if (!box) { + throw new Error('Touch drag target has no visible bounding box'); + } + return { + x: box.x + box.width / 2, + y: box.y + box.height * yRatio, + }; +}; + +export const touchDrag = async ( + page: Page, + source: Locator, + target: Locator, + options: TouchDragOptions = {} +): Promise => { + await source.scrollIntoViewIfNeeded(); + await target.scrollIntoViewIfNeeded(); + const from = await pointIn(source); + const to = await pointIn(target, options.targetYRatio); + const cdp = await page.context().newCDPSession(page); + await page.evaluate(() => { + document.documentElement.removeAttribute('data-playwright-touch-trusted'); + document.addEventListener( + 'touchstart', + (event) => document.documentElement.setAttribute('data-playwright-touch-trusted', String(event.isTrusted)), + { capture: true, once: true } + ); + }); + + await cdp.send('Input.dispatchTouchEvent', { + type: 'touchStart', + touchPoints: [{ ...from, id: 0, radiusX: 1, radiusY: 1, force: 1 }], + }); + await page.waitForTimeout(50); + const dispatchMove = async (ratio: number) => { + await cdp.send('Input.dispatchTouchEvent', { + type: 'touchMove', + touchPoints: [ + { + x: from.x + (to.x - from.x) * ratio, + y: from.y + (to.y - from.y) * ratio, + id: 0, + radiusX: 1, + radiusY: 1, + force: 1, + }, + ], + }); + }; + await dispatchMove(0.05); + await page.waitForTimeout(100); + for (let step = 2; step <= 20; step += 1) { + await dispatchMove(step / 20); + await page.waitForTimeout(16); + } + await page.waitForTimeout(50); + await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] }); + const trusted = await page.evaluate( + () => document.documentElement.getAttribute('data-playwright-touch-trusted') === 'true' + ); + if (!trusted) { + throw new Error('Chromium did not dispatch a trusted touchstart event'); + } +}; diff --git a/app/game-frontend/package.json b/app/game-frontend/package.json index 9e9c26fb..45ecd77a 100644 --- a/app/game-frontend/package.json +++ b/app/game-frontend/package.json @@ -48,6 +48,7 @@ "mitt": "^3.0.1", "pinia": "^3.0.4", "vue": "^3.5.26", + "vuedraggable-es": "4.1.1", "vue-router": "^4.6.4", "zod": "^4.3.5" }, 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/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/command/commandArgumentPresentation.ts b/app/game-frontend/src/components/command/commandArgumentPresentation.ts index c5e80015..0e98a3a2 100644 --- a/app/game-frontend/src/components/command/commandArgumentPresentation.ts +++ b/app/game-frontend/src/components/command/commandArgumentPresentation.ts @@ -1,6 +1,10 @@ +import type { CommandInputField } from './types'; + +export type CommandArgumentMapTarget = 'city' | 'nation' | 'capital'; + export type CommandArgumentPresentation = { lines: string[]; - mapTarget?: 'city' | 'nation' | 'capital'; + mapTarget?: CommandArgumentMapTarget; }; const cityTarget = (lines: string[]): CommandArgumentPresentation => ({ lines, mapTarget: 'city' }); @@ -98,4 +102,18 @@ const PRESENTATIONS: Record = { export const commandArgumentPresentation = (commandKey: string): CommandArgumentPresentation => PRESENTATIONS[commandKey] ?? { lines: [] }; +/** + * 대상 지도는 명령명 목록이 아니라 API가 내린 실제 인자 계약을 우선한다. + * 인자가 없는 증축·감축의 수도 확인 지도만 presentation의 명시적 target을 사용한다. + */ +export const resolveCommandArgumentMapTarget = ( + commandKey: string, + fields: readonly CommandInputField[] +): CommandArgumentMapTarget | undefined => { + const selectableTargets = fields.filter((field) => field.kind === 'select'); + if (selectableTargets.some((field) => field.optionSource === 'cities')) return 'city'; + if (selectableTargets.some((field) => field.optionSource === 'nations')) return 'nation'; + return commandArgumentPresentation(commandKey).mapTarget; +}; + export const presentedCommandKeys = (): string[] => Object.keys(PRESENTATIONS); diff --git a/app/game-frontend/src/components/main/CommandArgumentForm.vue b/app/game-frontend/src/components/main/CommandArgumentForm.vue index c6b7fc67..5f7278a4 100644 --- a/app/game-frontend/src/components/main/CommandArgumentForm.vue +++ b/app/game-frontend/src/components/main/CommandArgumentForm.vue @@ -1,7 +1,8 @@ @@ -584,6 +589,7 @@ button { } .game-version-dialog { + box-sizing: border-box; width: min(420px, calc(100vw - 32px)); border: 1px solid #555; border-radius: 4px; @@ -607,6 +613,18 @@ button { justify-content: center; } +.game-version-dialog__commit { + display: flex; + flex-direction: column; + gap: 4px; +} + +.game-version-dialog__commit code { + overflow-wrap: anywhere; + color: #d7d7d7; + font-size: 0.85em; +} + /* * Ref's main document does not clip horizontally; the map panel below manages * its own overflow. diff --git a/app/game-frontend/src/views/MyPageView.vue b/app/game-frontend/src/views/MyPageView.vue index 28d367d7..bb6e5512 100644 --- a/app/game-frontend/src/views/MyPageView.vue +++ b/app/game-frontend/src/views/MyPageView.vue @@ -1,14 +1,16 @@