diff --git a/app/game-frontend/e2e/chiefCommandMap.live.playwright.config.mjs b/app/game-frontend/e2e/chiefCommandMap.live.playwright.config.mjs new file mode 100644 index 00000000..37ce9b79 --- /dev/null +++ b/app/game-frontend/e2e/chiefCommandMap.live.playwright.config.mjs @@ -0,0 +1,68 @@ +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { defineConfig, devices } from '@playwright/test'; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..'); +const frontendPort = Number(process.env.PLAYWRIGHT_FRONTEND_PORT ?? 15161); +const apiPort = Number(process.env.PLAYWRIGHT_GAME_API_PORT ?? 15162); +const basePath = '/che'; +const profileId = 'chief_command_map_live_integration'; +const scenario = '2'; +const gameProfile = `${profileId}:${scenario}`; +const databaseUrl = process.env.CHIEF_COMMAND_MAP_LIVE_DATABASE_URL ?? ''; +const redisUrl = process.env.CHIEF_COMMAND_MAP_LIVE_REDIS_URL ?? ''; +const gameSecret = process.env.CHIEF_COMMAND_MAP_LIVE_GAME_SECRET ?? ''; +const imageUploadSecretFile = resolve(repositoryRoot, 'app/game-frontend/e2e/fixtures/image-upload-secret.example'); + +if (databaseUrl && new URL(databaseUrl).searchParams.get('schema') !== profileId) { + throw new Error('Chief command map live database must use its dedicated schema.'); +} + +export default defineConfig({ + testDir: '.', + testMatch: ['chiefCommandMapLive.spec.ts'], + fullyParallel: false, + workers: 1, + timeout: 90_000, + expect: { timeout: 15_000 }, + reporter: [['list']], + outputDir: resolve(repositoryRoot, 'test-results/chief-command-map-live'), + use: { + baseURL: `http://127.0.0.1:${frontendPort}${basePath}/`, + ...devices['Desktop Chrome'], + deviceScaleFactor: 1, + locale: 'ko-KR', + timezoneId: 'Asia/Seoul', + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + }, + webServer: [ + { + command: 'node app/game-api/dist/index.js', + cwd: repositoryRoot, + url: `http://127.0.0.1:${apiPort}/healthz`, + reuseExistingServer: false, + timeout: 120_000, + env: { + DATABASE_URL: databaseUrl, + REDIS_URL: redisUrl, + GAME_TOKEN_SECRET: gameSecret, + GAME_IMAGE_UPLOAD_SECRET_FILE: imageUploadSecretFile, + GAME_API_ROLE: 'server', + GAME_API_HOST: '127.0.0.1', + GAME_API_PORT: String(apiPort), + PROFILE: profileId, + SCENARIO: scenario, + GAME_PROFILE_NAME: gameProfile, + DAEMON_REQUEST_TIMEOUT_MS: '15000', + }, + }, + { + command: `VITE_APP_BASE_PATH=${basePath} VITE_GAME_API_URL=http://127.0.0.1:${apiPort}/trpc VITE_GAME_PROFILE=${gameProfile} VITE_GATEWAY_WEB_URL=/gateway/ pnpm --filter @sammo-ts/game-frontend build && VITE_APP_BASE_PATH=${basePath} pnpm --filter @sammo-ts/game-frontend preview --host 127.0.0.1 --port ${frontendPort}`, + cwd: repositoryRoot, + url: `http://127.0.0.1:${frontendPort}${basePath}/`, + reuseExistingServer: false, + timeout: 120_000, + }, + ], +}); diff --git a/app/game-frontend/e2e/chiefCommandMapLive.spec.ts b/app/game-frontend/e2e/chiefCommandMapLive.spec.ts new file mode 100644 index 00000000..83f9b71c --- /dev/null +++ b/app/game-frontend/e2e/chiefCommandMapLive.spec.ts @@ -0,0 +1,299 @@ +import { randomUUID } from 'node:crypto'; +import { readFile } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { expect, test, type Page } from '@playwright/test'; +import { encryptGameSessionToken } from '../../../packages/common/dist/auth/gameToken.js'; +import { createGamePostgresConnector } from '../../../packages/infra/dist/index.js'; +import { seedScenarioToDatabase } from '../../game-engine/dist/index.js'; + +const databaseUrl = process.env.CHIEF_COMMAND_MAP_LIVE_DATABASE_URL; +const redisUrl = process.env.CHIEF_COMMAND_MAP_LIVE_REDIS_URL; +const gameTokenSecret = process.env.CHIEF_COMMAND_MAP_LIVE_GAME_SECRET; +const profile = 'chief_command_map_live_integration:2'; +const profileId = profile.split(':', 1)[0]!; +const hasLiveFixture = Boolean(databaseUrl && redisUrl && gameTokenSecret); +const actorId = 7_761; +const actorUserId = 'chief-command-map-live-user'; +const ownNationId = 9_918; +const targetNationId = 9_919; +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..'); +const imageRoots = [ + resolve(repositoryRoot, '../image'), + resolve(repositoryRoot, '../../image'), + resolve(repositoryRoot, '../sam_rebuild/image'), + resolve(repositoryRoot, '../../sam_rebuild/image'), +]; + +const readImage = async (relativePath: string): Promise => { + if (relativePath.includes('..')) throw new Error(`Unsafe fixture image path: ${relativePath}`); + for (const root of imageRoots) { + try { + return await readFile(resolve(root, relativePath)); + } catch { + // Product checkout and worktrees have different image-root parents. + } + } + throw new Error(`Fixture image not found: ${relativePath}`); +}; + +const imageContentType = (relativePath: string): string => { + if (relativePath.endsWith('.png')) return 'image/png'; + if (relativePath.endsWith('.gif')) return 'image/gif'; + return 'image/jpeg'; +}; + +const installSession = async (page: Page): Promise => { + const issuedAt = new Date(); + const gameToken = encryptGameSessionToken( + { + version: 1, + profile, + issuedAt: issuedAt.toISOString(), + expiresAt: new Date(issuedAt.getTime() + 3_600_000).toISOString(), + sessionId: `chief-command-map-live-${randomUUID()}`, + user: { + id: actorUserId, + username: actorUserId, + displayName: '사령부지도검증', + roles: ['user'], + canUseGeneralPicture: false, + }, + sanctions: {}, + identity: { + kakaoVerified: true, + canCreateGeneral: true, + requiresKakaoVerification: false, + graceEndsAt: null, + }, + }, + gameTokenSecret! + ); + await page.addInitScript( + ({ token, gameProfile }) => { + localStorage.setItem('sammo-game-token', token); + localStorage.setItem('sammo-game-profile', gameProfile); + }, + { token: gameToken, gameProfile: profile } + ); + await page.route('**/image/**', async (route) => { + const relativePath = decodeURIComponent(new URL(route.request().url()).pathname.split('/image/')[1] ?? ''); + try { + await route.fulfill({ + status: 200, + contentType: imageContentType(relativePath), + body: await readImage(relativePath), + }); + } catch { + await route.fulfill({ status: 404, body: '' }); + } + }); + await page.route('**/game/**', async (route) => { + const relativePath = decodeURIComponent(new URL(route.request().url()).pathname.split('/game/')[1] ?? ''); + try { + await route.fulfill({ + status: 200, + contentType: imageContentType(relativePath), + body: await readImage(`game/${relativePath}`), + }); + } catch { + await route.fulfill({ status: 404, body: '' }); + } + }); +}; + +test('shows and operates a city target map through live PostgreSQL, Game API, and production Chromium', async ({ + browser, + page, +}, testInfo) => { + test.skip(!hasLiveFixture, 'dedicated PostgreSQL, Redis, and game token secret are required'); + page.setDefaultTimeout(15_000); + + const schema = new URL(databaseUrl!).searchParams.get('schema'); + if (schema !== profileId || !schema.endsWith('chief_command_map_live_integration')) { + throw new Error('Refusing a non-dedicated chief command map schema.'); + } + + const previousSeed = process.env.INTEGRATION_WORLD_SEED; + process.env.INTEGRATION_WORLD_SEED = 'chief-command-map-live-seed'; + try { + await seedScenarioToDatabase({ + scenarioId: 2, + databaseUrl: databaseUrl!, + now: new Date('2099-08-01T00:00:00.000Z'), + gameClockMode: 'manual', + installOptions: { + turnTermMinutes: 5, + joinMode: 'full', + npcMode: 0, + showImgLevel: 3, + serverId: profile, + season: 1, + }, + }); + } finally { + if (previousSeed === undefined) delete process.env.INTEGRATION_WORLD_SEED; + else process.env.INTEGRATION_WORLD_SEED = previousSeed; + } + + const connector = createGamePostgresConnector({ url: databaseUrl! }); + await connector.connect(); + try { + const db = connector.prisma; + const [ownCity, targetCity] = await db.city.findMany({ orderBy: { id: 'asc' }, take: 2 }); + if (!ownCity || !targetCity) throw new Error('The seeded scenario needs at least two cities.'); + + await db.general.deleteMany({ where: { userId: actorUserId } }); + await db.nation.upsert({ + where: { id: ownNationId }, + create: { + id: ownNationId, + name: '지도아국', + color: '#225500', + capitalCityId: ownCity.id, + level: 7, + meta: { gennum: 1, scout: 0 }, + }, + update: { + name: '지도아국', + color: '#225500', + capitalCityId: ownCity.id, + level: 7, + meta: { gennum: 1, scout: 0 }, + }, + }); + await db.nation.upsert({ + where: { id: targetNationId }, + create: { + id: targetNationId, + name: '지도적국', + color: '#772222', + capitalCityId: targetCity.id, + level: 1, + meta: { gennum: 0, scout: 0 }, + }, + update: { + name: '지도적국', + color: '#772222', + capitalCityId: targetCity.id, + level: 1, + meta: { gennum: 0, scout: 0 }, + }, + }); + await db.city.update({ where: { id: ownCity.id }, data: { nationId: ownNationId, supplyState: 1 } }); + await db.city.update({ where: { id: targetCity.id }, data: { nationId: targetNationId, supplyState: 1 } }); + await db.general.create({ + data: { + id: actorId, + userId: actorUserId, + name: '지도군주', + nationId: ownNationId, + cityId: ownCity.id, + troopId: 0, + npcState: 0, + leadership: 80, + strength: 70, + intel: 60, + officerLevel: 12, + experience: 100, + dedication: 100, + gold: 100_000, + rice: 100_000, + turnTime: new Date('2099-08-01T00:05:00.000Z'), + turnTick: null, + meta: { killturn: 960, belong: 1 }, + penalty: {}, + }, + }); + + await installSession(page); + const mapResponses: number[] = []; + page.on('response', (response) => { + if (response.url().includes('world.getMap')) mapResponses.push(response.status()); + }); + await page.goto('chief-center'); + await expect(page.getByRole('heading', { name: '사령부', exact: true })).toBeVisible(); + 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: '천도', exact: true }).click(); + + const form = picker.getByTestId('command-argument-form'); + const map = form.getByTestId('command-argument-map'); + await expect(map).toBeVisible(); + await expect.poll(() => mapResponses.length).toBeGreaterThan(0); + expect(mapResponses.every((status) => status >= 200 && status < 300)).toBe(true); + + const target = map.locator('.city-base').nth(1); + await expect(target).toBeVisible(); + await target.click(); + await expect(form.getByRole('combobox', { name: '대상 도시' })).toHaveValue(String(targetCity.id)); + await expect(form.getByTestId('command-map-selection-status')).toContainText(`선택 도시${targetCity.name}`); + await expect(form.getByTestId('command-map-target-summary')).toContainText(targetCity.name); + + const geometry = await map.evaluate((element) => { + const rect = element.getBoundingClientRect(); + return { + width: rect.width, + height: rect.height, + display: getComputedStyle(element).display, + }; + }); + expect(geometry.width).toBeGreaterThan(400); + expect(geometry.height).toBeGreaterThan(250); + expect(geometry.display).not.toBe('none'); + await page.screenshot({ path: testInfo.outputPath('chief-command-city-map-live.png'), fullPage: true }); + + await page + .getByRole('button', { name: '명령 입력 닫기', exact: true }) + .click({ timeout: 2_000 }) + .catch(() => undefined); + await page.getByRole('button', { name: '2턴 명령 입력', exact: true }).click(); + const nationPicker = page.getByTestId('command-picker'); + await nationPicker.getByRole('button', { name: /^(?:국가:)?외교$/, exact: true }).click(); + await nationPicker.getByRole('button', { name: '선전포고', exact: true }).click(); + const nationForm = nationPicker.getByTestId('command-argument-form'); + const nationMap = nationForm.getByTestId('command-argument-map'); + await expect(nationMap).toBeVisible(); + await nationMap.locator('.city-base').nth(1).click(); + await expect(nationForm.getByRole('combobox', { name: '대상 국가' })).toHaveValue(String(targetNationId)); + await expect(nationForm.getByTestId('command-map-selection-status')).toContainText('선택 국가지도적국'); + await expect(nationForm.getByTestId('command-map-target-summary')).toContainText( + `지도적국 · 수도 ${targetCity.name} · 도시 1개` + ); + await page.screenshot({ path: testInfo.outputPath('chief-command-nation-map-live.png'), fullPage: true }); + + const mobileContext = await browser.newContext({ + viewport: { width: 500, height: 900 }, + deviceScaleFactor: 1, + locale: 'ko-KR', + timezoneId: 'Asia/Seoul', + }); + const mobilePage = await mobileContext.newPage(); + mobilePage.setDefaultTimeout(15_000); + await installSession(mobilePage); + const configuredBaseUrl = testInfo.project.use.baseURL; + if (typeof configuredBaseUrl !== 'string') throw new Error('The live Chromium baseURL is required.'); + await mobilePage.goto(new URL('chief-center', configuredBaseUrl).href); + await mobilePage.getByRole('button', { name: '1턴 명령 입력', exact: true }).click(); + const mobilePicker = mobilePage.getByTestId('command-picker'); + await mobilePicker.getByRole('button', { name: /^(?:국가:)?특수$/, exact: true }).click(); + await mobilePicker.getByRole('button', { name: '천도', exact: true }).click(); + const mobileMap = mobilePicker.getByTestId('command-argument-map'); + await expect(mobileMap).toBeVisible(); + const mobileGeometry = await mobilePicker.evaluate((element) => ({ + pickerWidth: element.getBoundingClientRect().width, + pickerOverflow: element.scrollWidth - element.clientWidth, + documentOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth, + })); + expect(mobileGeometry).toEqual({ pickerWidth: 500, pickerOverflow: 0, documentOverflow: 0 }); + await mobilePage.screenshot({ + path: testInfo.outputPath('chief-command-city-map-live-mobile.png'), + fullPage: true, + }); + await mobileContext.close(); + } finally { + await connector.disconnect(); + } +}); diff --git a/app/game-frontend/e2e/commandArguments.spec.ts b/app/game-frontend/e2e/commandArguments.spec.ts index 617400df..a9580002 100644 --- a/app/game-frontend/e2e/commandArguments.spec.ts +++ b/app/game-frontend/e2e/commandArguments.spec.ts @@ -852,7 +852,8 @@ const install = async (page: Page, rejectGeneral = false, commandTableResponse: nationCnt: 2, }); if (name === 'join.getConfig') return response({}); - if (name === 'world.getMap') + if (name === 'world.getMap') { + requests.push({ operation: name, body }); return response({ result: true, version: 0, @@ -872,6 +873,7 @@ const install = async (page: Page, rejectGeneral = false, commandTableResponse: myCity: 1, myNation: 1, }); + } if (name === 'turns.getCommandTable') return response(commandTableResponse); if (name === 'nation.getChiefCenter') return response(chiefCenter); if (name === 'turns.reserved.getGeneral') @@ -1531,7 +1533,10 @@ test('shows full nation command briefs in every chief card', async ({ page }) => const desktopSummary = page.locator('.layout-desktop .chief-card').first().locator('.row-action').first(); await expect(desktopSummary).toHaveText('【관우】 쌀 300 포상'); await expect(desktopSummary).toHaveAttribute('title', '【관우】 쌀 300 포상'); - await page.screenshot({ path: test.info().outputPath('chief-card-command-brief-desktop-1200.png'), fullPage: true }); + await page.screenshot({ + path: test.info().outputPath('chief-card-command-brief-desktop-1200.png'), + fullPage: true, + }); await page.setViewportSize({ width: 500, height: 900 }); const mobileSummary = page.locator('.chief-overview .chief-card').first().locator('.row-action').first(); @@ -1811,7 +1816,7 @@ test('keeps arbitrary direct recruitment and mercenary amounts for all four arms }); test('uses the map to choose a nation target in the chief command window', async ({ page }) => { - await install(page); + const requests = await install(page); await page.setViewportSize({ width: 1200, height: 900 }); await page.goto('/che/chief-center'); await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click(); @@ -1827,6 +1832,7 @@ test('uses the map to choose a nation target in the chief command window', async await expect(form.getByTestId('current-city-marker')).toHaveAttribute('aria-label', '현재 도시 업'); await expect(form.getByTestId('command-map-target-summary')).toContainText('수도 허창 · 도시 1개'); await expect(page).toHaveURL(/\/che\/chief-center$/); + expect(JSON.stringify(requests)).toContain('"generalId":1'); await page.screenshot({ path: test.info().outputPath('chief-nation-map-option.png'), fullPage: true }); }); diff --git a/app/game-frontend/e2e/fixtures/image-upload-secret.example b/app/game-frontend/e2e/fixtures/image-upload-secret.example new file mode 100644 index 00000000..2dde6e76 --- /dev/null +++ b/app/game-frontend/e2e/fixtures/image-upload-secret.example @@ -0,0 +1 @@ +chief-command-map-live-test-placeholder diff --git a/app/game-frontend/package.json b/app/game-frontend/package.json index 19180b39..efe54a59 100644 --- a/app/game-frontend/package.json +++ b/app/game-frontend/package.json @@ -22,6 +22,7 @@ "test:e2e:join-live": "playwright test --config e2e/joinGeneral.live.playwright.config.mjs --tsconfig e2e/playwright.live.tsconfig.json", "test:e2e:npc-possession": "playwright test npcPossession.spec.ts --config e2e/playwright.config.mjs", "test:e2e:join-layout": "playwright test joinLayout.spec.ts --config e2e/playwright.config.mjs", + "test:e2e:chief-command-map-live": "playwright test --config e2e/chiefCommandMap.live.playwright.config.mjs --tsconfig e2e/playwright.live.tsconfig.json", "test:e2e:npc-possession-live": "pnpm --filter @sammo-ts/infra prisma:generate && pnpm --filter @sammo-ts/common build && pnpm --filter @sammo-ts/logic build && pnpm --filter @sammo-ts/infra build && pnpm --filter @sammo-ts/game-engine build && pnpm --filter @sammo-ts/game-api build && playwright test --config e2e/npcPossession.live.playwright.config.mjs --tsconfig e2e/playwright.live.tsconfig.json", "test:e2e:die-on-prestart-live": "pnpm --filter @sammo-ts/infra prisma:generate && pnpm --filter @sammo-ts/common build && pnpm --filter @sammo-ts/logic build && pnpm --filter @sammo-ts/infra build && pnpm --filter @sammo-ts/game-engine build && pnpm --filter @sammo-ts/game-api build && playwright test --config e2e/dieOnPrestart.live.playwright.config.mjs --tsconfig e2e/playwright.live.tsconfig.json", "test:e2e:default-recruit-commands-live": "pnpm --filter @sammo-ts/infra prisma:generate && pnpm --filter @sammo-ts/common build && pnpm --filter @sammo-ts/logic build && pnpm --filter @sammo-ts/infra build && pnpm --filter @sammo-ts/game-engine build && pnpm --filter @sammo-ts/game-api build && playwright test --config e2e/defaultRecruitCommands.live.playwright.config.mjs --tsconfig e2e/playwright.live.tsconfig.json", diff --git a/app/game-frontend/src/views/ChiefCenterView.vue b/app/game-frontend/src/views/ChiefCenterView.vue index b6c0889d..ff8c9bba 100644 --- a/app/game-frontend/src/views/ChiefCenterView.vue +++ b/app/game-frontend/src/views/ChiefCenterView.vue @@ -56,7 +56,14 @@ const chiefApi = trpc as unknown as { }; }; world: { - getMap: { query: () => Promise }; + getMap: { + query: (input: { + generalId: number; + neutralView?: boolean; + showMe?: boolean; + useCache?: boolean; + }) => Promise; + }; getMapLayout: { query: () => Promise }; }; }; @@ -118,7 +125,7 @@ const loadCommandTable = async (generalId: number) => { try { const [nextCommandTable, nextWorldMap, nextMapLayout] = await Promise.all([ chiefApi.turns.getCommandTable.query({ generalId }), - chiefApi.world.getMap.query().catch(() => null), + chiefApi.world.getMap.query({ generalId, showMe: true, useCache: true }).catch(() => null), chiefApi.world.getMapLayout.query().catch(() => null), ]); commandTable.value = nextCommandTable;