diff --git a/app/game-frontend/e2e/npcPolicy.spec.ts b/app/game-frontend/e2e/npcPolicy.spec.ts new file mode 100644 index 0000000..e627d63 --- /dev/null +++ b/app/game-frontend/e2e/npcPolicy.spec.ts @@ -0,0 +1,338 @@ +import { expect, test, type Page, type Route } from '@playwright/test'; +import { mkdir, readFile } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +type FixtureState = { + permissionLevel: number; + failNextMutation?: boolean; + failLoad?: boolean; + mutations: string[]; +}; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..'); +const artifactRoot = process.env.NPC_POLICY_PARITY_ARTIFACT_DIR + ? resolve(process.env.NPC_POLICY_PARITY_ARTIFACT_DIR) + : null; +const imageRoots = [resolve(repositoryRoot, '../image'), resolve(repositoryRoot, '../../image')]; +const referenceAsset = async (relativePath: string): Promise => { + for (const root of imageRoots) { + try { + return await readFile(resolve(root, relativePath)); + } catch { + // Nested worktrees and the primary checkout have different image parents. + } + } + throw new Error(`Reference image not found: ${relativePath}`); +}; + +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 operationName = (route: Route): string => { + const url = new URL(route.request().url()); + return decodeURIComponent(url.pathname.slice(url.pathname.lastIndexOf('/trpc/') + 6)); +}; +const fulfillJson = (route: Route, body: unknown) => + route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) }); + +const nationPriority = [ + '불가침제의', + '선전포고', + '천도', + '유저장긴급포상', + '부대전방발령', + '유저장구출발령', + '유저장후방발령', + '부대유저장후방발령', + '유저장전방발령', + '유저장포상', + '부대구출발령', + '부대후방발령', + 'NPC긴급포상', + 'NPC구출발령', + 'NPC후방발령', + 'NPC포상', + 'NPC전방발령', + '유저장내정발령', + 'NPC내정발령', + 'NPC몰수', +]; +const generalPriority = [ + 'NPC사망대비', + '귀환', + '금쌀구매', + '출병', + '긴급내정', + '전투준비', + '전방워프', + 'NPC헌납', + '징병', + '후방워프', + '전쟁내정', + '소집해제', + '일반내정', + '내정워프', +]; +const policy = { + reqNationGold: 10_000, + reqNationRice: 12_000, + CombatForce: {}, + SupportForce: [], + DevelopForce: [], + reqHumanWarUrgentGold: 0, + reqHumanWarUrgentRice: 0, + reqHumanWarRecommandGold: 0, + reqHumanWarRecommandRice: 0, + reqHumanDevelGold: 10_000, + reqHumanDevelRice: 10_000, + reqNPCWarGold: 0, + reqNPCWarRice: 0, + reqNPCDevelGold: 0, + reqNPCDevelRice: 500, + minimumResourceActionAmount: 1_000, + maximumResourceActionAmount: 10_000, + minNPCWarLeadership: 40, + minWarCrew: 1_500, + minNPCRecruitCityPopulation: 50_000, + safeRecruitCityPopulationRatio: 0.5, + properWarTrainAtmos: 90, + cureThreshold: 10, +}; + +const policyFixture = (state: FixtureState) => ({ + nationId: 1, + nationName: '위', + nationLevel: 3, + defaultNationPolicy: policy, + currentNationPolicy: policy, + zeroPolicy: { + ...policy, + reqHumanWarUrgentGold: 7_600, + reqHumanWarUrgentRice: 7_600, + reqHumanWarRecommandGold: 15_200, + reqHumanWarRecommandRice: 15_200, + reqNPCWarGold: 2_700, + reqNPCWarRice: 2_700, + reqNPCDevelGold: 540, + }, + defaultNationPriority: nationPriority, + currentNationPriority: nationPriority, + availableNationPriorityItems: nationPriority, + defaultGeneralActionPriority: generalPriority, + currentGeneralActionPriority: generalPriority, + availableGeneralActionPriorityItems: generalPriority, + lastSetters: { + policy: { setter: null, date: null }, + nation: { setter: null, date: null }, + general: { setter: null, date: null }, + }, + defaultStatMax: 70, + defaultStatNpcMax: 75, + permissionLevel: state.permissionLevel, +}); + +const installFixture = async (page: Page, state: FixtureState) => { + await page.addInitScript(() => { + localStorage.setItem('sammo-game-token', 'ga_npc_policy_playwright'); + localStorage.setItem('sammo-game-profile', 'che:default'); + }); + for (const filename of ['back_walnut.jpg', 'back_green.jpg', 'back_blue.jpg']) { + await page.route(`**/image/game/${filename}`, async (route) => + route.fulfill({ + status: 200, + contentType: 'image/jpeg', + body: await referenceAsset(`game/${filename}`), + }) + ); + } + await page.route('**/che/api/trpc/**', async (route) => { + const operations = operationName(route).split(','); + const results = operations.map((operation) => { + if (operation === 'lobby.info') return response({ myGeneral: { id: 22, name: '정책담당' } }); + if (operation === 'join.getConfig') return response({}); + if (operation === 'npc.getPolicy') { + return state.failLoad + ? errorResponse(operation, '권한이 부족합니다.', 'FORBIDDEN') + : response(policyFixture(state)); + } + if ( + operation === 'npc.setNationPolicy' || + operation === 'npc.setNationPriority' || + operation === 'npc.setGeneralPriority' + ) { + state.mutations.push(operation); + if (state.failNextMutation) { + state.failNextMutation = false; + return errorResponse(operation, '권한이 부족합니다.', 'FORBIDDEN'); + } + return response({ ok: true }); + } + return errorResponse(operation, `Unhandled fixture operation: ${operation}`); + }); + await fulfillJson(route, results); + }); +}; + +const gotoPolicy = async (page: Page) => { + await page.goto('npc-control'); +}; + +const screenshot = async (page: Page, name: string) => { + if (!artifactRoot) return; + await mkdir(artifactRoot, { recursive: true }); + await page.screenshot({ path: resolve(artifactRoot, name), fullPage: true }); +}; + +test('desktop geometry, typography, textures, drag, focus, tooltip, and successful save match the reference', async ({ + page, +}) => { + const state: FixtureState = { permissionLevel: 4, mutations: [] }; + await installFixture(page, state); + await page.setViewportSize({ width: 1000, height: 900 }); + await gotoPolicy(page); + await expect(page.locator('#container')).toBeVisible(); + + const computed = await page.evaluate(() => { + const measure = (selector: string) => { + const element = document.querySelector(selector)!; + const rect = element.getBoundingClientRect(); + const style = getComputedStyle(element); + return { + x: rect.x, + y: rect.y, + width: rect.width, + height: rect.height, + display: style.display, + gridTemplateColumns: style.gridTemplateColumns, + fontFamily: style.fontFamily, + fontSize: style.fontSize, + lineHeight: style.lineHeight, + color: style.color, + backgroundImage: style.backgroundImage, + backgroundColor: style.backgroundColor, + }; + }; + return { + body: measure('body'), + container: measure('#container'), + topBar: measure('.top-back-bar'), + section: measure('.section_bar'), + form: measure('.form_list'), + field: measure('.policy-field'), + input: measure('.field-row input'), + control: measure('.control_bar'), + reset: measure('.reset_btn'), + submit: measure('.submit_btn'), + priorityPanel: measure('.priority-panel'), + priorityList: measure('.priority-list'), + inactiveHeader: measure('.inactive-header'), + activeItem: measure('.priority-column:nth-child(2) .priority-item'), + help: measure('.help-button'), + documentWidth: document.documentElement.scrollWidth, + }; + }); + + expect(computed.body).toMatchObject({ width: 1000, fontSize: '14px', lineHeight: '21px' }); + expect(computed.body.fontFamily).toContain('Pretendard'); + expect(computed.container).toMatchObject({ x: 0, y: 32, width: 1000 }); + expect(computed.container.backgroundImage).toContain('back_walnut.jpg'); + expect(computed.topBar).toMatchObject({ width: 1000, height: 32 }); + expect(computed.section).toMatchObject({ x: 1, y: 33, width: 998, height: 23 }); + expect(computed.section.backgroundImage).toContain('back_green.jpg'); + expect(computed.form).toMatchObject({ x: 9, width: 982 }); + expect(computed.form.gridTemplateColumns).toBe('491px 491px'); + expect(computed.field.width).toBeCloseTo(491, 0); + expect(computed.input).toMatchObject({ width: 224, height: 34 }); + expect(computed.reset).toMatchObject({ width: 150, height: 35.5, backgroundColor: 'rgb(48, 48, 48)' }); + expect(computed.submit).toMatchObject({ width: 150, height: 35.5, backgroundColor: 'rgb(55, 90, 127)' }); + expect(computed.priorityPanel.width).toBeCloseTo(499, 0); + expect(computed.priorityList.width).toBeCloseTo(229, 0); + expect(computed.inactiveHeader).toMatchObject({ height: 37, backgroundColor: 'rgb(214, 214, 214)' }); + expect(computed.activeItem.height).toBe(37); + expect(computed.help).toMatchObject({ width: 24, height: 22.375 }); + expect(computed.documentWidth).toBe(1000); + await screenshot(page, 'core-npc-policy-desktop-baseline.png'); + + const goldInput = page.getByLabel('국가 권장 금'); + await goldInput.focus(); + await expect(goldInput).toBeFocused(); + expect(await goldInput.evaluate((element) => getComputedStyle(element).outlineStyle)).not.toBe('none'); + + const help = page.getByRole('button', { name: '불가침제의 설명' }); + await help.hover(); + await expect.poll(() => help.evaluate((element) => getComputedStyle(element, '::after').opacity)).toBe('1'); + + const active = page.locator('.priority-panel').first().locator('.priority-column').nth(1).getByText('불가침제의'); + await active.dragTo( + page.locator('.priority-panel').first().locator('.priority-column').first().locator('.priority-list') + ); + await expect( + page.locator('.priority-panel').first().locator('.priority-column').first().getByText('불가침제의') + ).toBeVisible(); + + await goldInput.fill('12345'); + page.once('dialog', (dialog) => dialog.accept()); + await page.locator('#container > .control_bar').getByRole('button', { name: '설정' }).click(); + await expect(page.getByRole('status')).toContainText('NPC 정책이 반영되었습니다.'); + expect(state.mutations).toContain('npc.setNationPolicy'); + await screenshot(page, 'core-npc-policy-desktop.png'); +}); + +test('500px layout stacks policy fields and priority panels like the reference', async ({ page }) => { + await installFixture(page, { permissionLevel: 4, mutations: [] }); + await page.setViewportSize({ width: 500, height: 900 }); + await gotoPolicy(page); + await expect(page.locator('#container')).toBeVisible(); + + const geometry = await page.evaluate(() => { + const rect = (selector: string) => { + const value = document.querySelector(selector)!.getBoundingClientRect(); + return { x: value.x, y: value.y, width: value.width, height: value.height }; + }; + return { + container: rect('#container'), + form: rect('.form_list'), + firstField: rect('.policy-field'), + panels: [...document.querySelectorAll('.priority-panel')].map((element) => { + const value = element.getBoundingClientRect(); + return { x: value.x, y: value.y, width: value.width }; + }), + documentWidth: document.documentElement.scrollWidth, + }; + }); + + expect(geometry.container).toMatchObject({ x: 0, y: 32, width: 500 }); + expect(geometry.form).toMatchObject({ x: 9, width: 482 }); + expect(geometry.firstField.width).toBeCloseTo(482, 0); + expect(geometry.panels).toHaveLength(2); + expect(geometry.panels[0]).toMatchObject({ x: 1, width: 498 }); + expect(geometry.panels[1]?.x).toBe(1); + expect(geometry.panels[1]?.y).toBeGreaterThan(geometry.panels[0]?.y ?? 0); + expect(geometry.documentWidth).toBe(500); + await screenshot(page, 'core-npc-policy-mobile.png'); +}); + +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); + await gotoPolicy(page); + + const input = page.getByLabel('국가 권장 금'); + await expect(input).toBeEnabled(); + await input.fill('23456'); + page.once('dialog', (dialog) => dialog.accept()); + await page.locator('#container > .control_bar').getByRole('button', { name: '설정' }).click(); + await expect(page.getByRole('alert')).toContainText('권한이 부족합니다.'); + await expect(input).toHaveValue('23456'); + expect(state.mutations).toEqual(['npc.setNationPolicy']); +}); + +test('a user below secret read permission receives a recoverable page error', async ({ page }) => { + await installFixture(page, { permissionLevel: 0, failLoad: true, mutations: [] }); + await gotoPolicy(page); + await expect(page.getByRole('alert')).toContainText('권한이 부족합니다.'); + await expect(page.locator('#container')).toHaveCount(0); + await expect(page.getByRole('button', { name: '다시 시도' })).toBeVisible(); +}); diff --git a/app/game-frontend/e2e/playwright.config.mjs b/app/game-frontend/e2e/playwright.config.mjs index ddee57a..72a8a3f 100644 --- a/app/game-frontend/e2e/playwright.config.mjs +++ b/app/game-frontend/e2e/playwright.config.mjs @@ -8,7 +8,7 @@ const baseURL = `http://127.0.0.1:${port}/che/`; export default defineConfig({ testDir: '.', - testMatch: ['troop.spec.ts', 'board.spec.ts', 'inGameInfo.spec.ts', 'nationOffices.spec.ts'], + testMatch: ['troop.spec.ts', 'board.spec.ts', 'inGameInfo.spec.ts', 'nationOffices.spec.ts', 'npcPolicy.spec.ts'], fullyParallel: false, workers: 1, timeout: 30_000, diff --git a/app/game-frontend/package.json b/app/game-frontend/package.json index 48884db..a60d1d3 100644 --- a/app/game-frontend/package.json +++ b/app/game-frontend/package.json @@ -9,6 +9,7 @@ "preview": "vite preview", "test:e2e:troop": "playwright test troop.spec.ts --config e2e/playwright.config.mjs", "test:e2e:nation-offices": "playwright test nationOffices.spec.ts --config e2e/playwright.config.mjs", + "test:e2e:npc-policy": "playwright test npcPolicy.spec.ts --config e2e/playwright.config.mjs", "test:e2e:board": "playwright test board.spec.ts --config e2e/playwright.config.mjs", "lint": "eslint .", "lint:fix": "eslint . --fix", diff --git a/app/game-frontend/src/views/NpcControlView.vue b/app/game-frontend/src/views/NpcControlView.vue index e5b572c..94e34b9 100644 --- a/app/game-frontend/src/views/NpcControlView.vue +++ b/app/game-frontend/src/views/NpcControlView.vue @@ -1,78 +1,61 @@ diff --git a/docs/frontend-legacy-parity.md b/docs/frontend-legacy-parity.md index 0162c50..48721b0 100644 --- a/docs/frontend-legacy-parity.md +++ b/docs/frontend-legacy-parity.md @@ -57,8 +57,9 @@ storage, route guards, and image loading. | survey | `hwe/v_vote.php`, `hwe/ts/PageVote.vue` | 1000/500px fixed container, blue title/green table textures, list/detail/results/comments, selection/focus/hover, submit and retained-selection API error | | nation personnel | `hwe/b_myBossInfo.php` | fixed 1000px document at both viewports, chief icon columns, officer/permission/city/kick controls, role redaction | | nation finance | `hwe/v_nationStratFinan.php` | 1000/500px at the legacy 940px breakpoint, exact diplomacy grid, policy controls, role gating and failed-mutation rollback | +| NPC policy | `hwe/v_NPCControl.php` | 1000/500px form and priority-list geometry, walnut/green textures, dynamic zero hints, drag/focus/tooltip, successful save and permission failures | | tournament | `hwe/b_tournament.php` | fixed 2000px canvas, 16×125px bracket, eight 250px group tables, walnut texture, 1024px overflow, hover/focus | -| tournament betting | `hwe/b_betting.php` | fixed 1120px canvas, 16×70px candidates, four 280px rank tables, exact title/button geometry, retained selection on error | +| tournament betting | `hwe/b_betting.php` | fixed 1120px canvas, 16×70px candidates, four 280px rank tables, exact title/button geometry, retained selection on error | The global game baseline is black, white, Pretendard 14px. Legacy texture helpers intentionally follow `common.orig.css`: `bg0` is walnut, `bg1` is @@ -96,6 +97,22 @@ reference collection, `tools/frontend-legacy-parity/reference-nation-offices.mjs records desktop/500px computed DOM and screenshots from the PHP service without changing its product code. +The NPC policy suite and its reference collector can be run independently: + +```sh +PLAYWRIGHT_FRONTEND_PORT=15126 \ + pnpm --filter @sammo-ts/game-frontend test:e2e:npc-policy + +REF_PARITY_USER=refuser1 \ +REF_PARITY_PASSWORD_FILE=/path/to/password-file \ +REF_PARITY_BASE_URL=http://127.0.0.1:3400/sam/ \ + node tools/frontend-legacy-parity/reference-npc-policy.mjs +``` + +The collector writes desktop and 500px screenshots plus computed DOM JSON only +when `REF_PARITY_ARTIFACT_DIR` is set. It requires an existing reference general +owned by the supplied account and never accepts a password on the command line. + For a review run that also writes full-page screenshots, create an ignored artifact directory and set `FRONTEND_PARITY_ARTIFACT_DIR` before invoking the suite. The ordinary CI run does not write screenshots after successful tests. diff --git a/tools/frontend-legacy-parity/reference-npc-policy.mjs b/tools/frontend-legacy-parity/reference-npc-policy.mjs new file mode 100644 index 0000000..16e6a8c --- /dev/null +++ b/tools/frontend-legacy-parity/reference-npc-policy.mjs @@ -0,0 +1,117 @@ +import { chromium } from '@playwright/test'; +import { createHash } from 'node:crypto'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; + +const baseUrl = process.env.REF_PARITY_URL ?? 'http://127.0.0.1:3400/sam/'; +const username = process.env.REF_PARITY_USER ?? 'refadmin'; +const passwordFile = process.env.REF_PARITY_PASSWORD_FILE; +const artifactRoot = resolve(process.env.REF_PARITY_ARTIFACT_DIR ?? 'test-results/reference-npc-policy'); + +if (!passwordFile) { + throw new Error('REF_PARITY_PASSWORD_FILE is required.'); +} + +const password = (await readFile(passwordFile, 'utf8')).trim(); +await mkdir(artifactRoot, { recursive: true }); + +const login = async (context, page) => { + await page.goto(baseUrl, { waitUntil: 'networkidle' }); + const globalSalt = await page.locator('#global_salt').inputValue(); + const passwordHash = createHash('sha512') + .update(globalSalt + password + globalSalt) + .digest('hex'); + const response = await context.request.post(new URL('api.php?path=Login/LoginByID', baseUrl).toString(), { + data: { username, password: passwordHash }, + }); + const result = await response.json(); + if (!response.ok() || result.result !== true) { + throw new Error('Reference login failed.'); + } +}; + +const browser = await chromium.launch({ headless: true }); +try { + const result = {}; + for (const viewport of [ + { name: 'desktop', width: 1000, height: 900 }, + { name: 'mobile', width: 500, height: 900 }, + ]) { + const context = await browser.newContext({ + viewport: { width: viewport.width, height: viewport.height }, + deviceScaleFactor: 1, + locale: 'ko-KR', + timezoneId: 'Asia/Seoul', + colorScheme: 'dark', + }); + const page = await context.newPage(); + await login(context, page); + await page.goto(new URL('hwe/', baseUrl).toString(), { waitUntil: 'networkidle' }); + await page.goto(new URL('hwe/v_NPCControl.php', baseUrl).toString(), { waitUntil: 'networkidle' }); + try { + await page.locator('#container').waitFor({ timeout: 10_000 }); + } catch { + throw new Error( + `Reference NPC policy failed to mount: ${JSON.stringify({ + url: page.url(), + text: (await page.locator('body').innerText()).slice(0, 500), + })}` + ); + } + result[viewport.name] = await page.evaluate(() => { + const measure = (selector) => { + const element = document.querySelector(selector); + if (!element) return null; + const rect = element.getBoundingClientRect(); + const style = getComputedStyle(element); + return { + rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height }, + style: { + display: style.display, + gridTemplateColumns: style.gridTemplateColumns, + fontFamily: style.fontFamily, + fontSize: style.fontSize, + lineHeight: style.lineHeight, + color: style.color, + backgroundColor: style.backgroundColor, + backgroundImage: style.backgroundImage, + borderColor: style.borderColor, + padding: style.padding, + margin: style.margin, + cursor: style.cursor, + }, + }; + }; + return { + body: measure('body'), + container: measure('#container'), + topBackBar: measure('body > :first-child'), + sectionBar: measure('.section_bar'), + formList: measure('.form_list'), + firstField: measure('.form_list > .col'), + firstInput: measure('input[type="number"]'), + firstInfoButton: measure('.form_list button'), + controlBar: measure('.control_bar'), + resetButton: measure('.reset_btn'), + submitButton: measure('.submit_btn'), + priorityGrid: measure('.half_section_left'), + priorityColumn: measure('.priority-list'), + priorityItem: measure('.priority-list .list-group-item'), + helpButton: measure('.priority_info button'), + document: { + width: document.documentElement.scrollWidth, + height: document.documentElement.scrollHeight, + }, + }; + }); + await page.screenshot({ + path: resolve(artifactRoot, `ref-npc-policy-${viewport.name}.png`), + fullPage: true, + }); + await context.close(); + } + await writeFile(resolve(artifactRoot, 'computed-dom.json'), `${JSON.stringify(result, null, 2)}\n`); + process.stdout.write(`${JSON.stringify({ ok: true, artifactRoot, viewports: Object.keys(result) })}\n`); +} finally { + await browser.close(); +}