diff --git a/app/game-api/src/router/world/index.ts b/app/game-api/src/router/world/index.ts index 5719247..80d1ca7 100644 --- a/app/game-api/src/router/world/index.ts +++ b/app/game-api/src/router/world/index.ts @@ -7,6 +7,7 @@ import { authedProcedure } from '../../trpc.js'; import { asRecord, isRecord } from '@sammo-ts/common'; import { loadWorldMap } from '../../maps/worldMap.js'; import { loadMapLayout } from '../../maps/mapLayout.js'; +import { loadUnitSetDefinitionByName } from '../../battleSim/unitSetLoader.js'; import { getMyGeneral, getOwnedGeneral } from '../shared/general.js'; const isWorldAdmin = (roles: readonly string[]): boolean => @@ -33,6 +34,29 @@ const defenceTrain = (meta: unknown): number => { return typeof raw === 'number' && Number.isFinite(raw) ? raw : 0; }; +const unitSetName = (world: WorldStateRow | null, fallback: string): string => { + const config = asRecord(world?.config); + const environment = asRecord(config.environment ?? config.map); + return typeof environment.unitSet === 'string' && environment.unitSet.trim() ? environment.unitSet : fallback; +}; + +const crewTypeNameCache = new Map>>(); +const loadCrewTypeNames = (name: string): Promise> => { + const cached = crewTypeNameCache.get(name); + if (cached) return cached; + const pending = loadUnitSetDefinitionByName(name) + .then((definition) => new Map((definition.crewTypes ?? []).map((crewType) => [crewType.id, crewType.name]))) + .catch(() => new Map()); + crewTypeNameCache.set(name, pending); + return pending; +}; + +const leadershipBonus = (officerLevel: number, nationLevel: number): number => { + if (officerLevel === 12) return nationLevel * 2; + if (officerLevel >= 5) return nationLevel; + return 0; +}; + const toWorldStateSnapshot = (row: WorldStateRow) => ({ scenarioCode: row.scenarioCode, currentYear: row.currentYear, @@ -122,6 +146,8 @@ export const worldRouter = router({ if (me.officerLevel > 0 && me.nationId > 0) { cities.filter((city) => city.nationId === me.nationId).forEach((city) => selectable.add(city.id)); nationGenerals.forEach((general) => selectable.add(general.cityId)); + } + if ((nation?.level ?? 0) > 0) { Object.keys(spy).forEach((id) => selectable.add(Number(id))); } if (admin) cities.forEach((city) => selectable.add(city.id)); @@ -150,6 +176,8 @@ export const worldRouter = router({ turnMap.set(turn.generalId, list); } const nationMap = new Map(nations.map((item) => [item.id, item])); + const selectedNation = nationMap.get(selected.nationId); + const crewTypeNames = await loadCrewTypeNames(unitSetName(world, ctx.profile.id)); const officers = await ctx.db.general.findMany({ where: { officerLevel: { in: [2, 3, 4] } }, select: { name: true, officerLevel: true, meta: true }, @@ -175,14 +203,59 @@ export const worldRouter = router({ intelligence: general.intel, injury: general.injury, officerLevel: general.officerLevel, + leadershipBonus: leadershipBonus(general.officerLevel, nationMap.get(general.nationId)?.level ?? 0), defenceTrain: ours ? defenceTrain(general.meta) : null, crewTypeId: ours ? general.crewTypeId : null, + crewTypeName: ours ? (crewTypeNames.get(general.crewTypeId) ?? null) : null, crew: ours || full ? general.crew : null, train: ours ? general.train : null, atmos: ours ? general.atmos : null, turns: ours && general.npcState <= 1 ? (turnMap.get(general.id) ?? []) : [], }; }); + const forceSummary = mappedGenerals.reduce( + (summary, general) => { + if (general.nationId > 0 && me.nationId > 0 && general.nationId !== me.nationId) { + summary.enemyGenerals += 1; + if (general.crew !== null && general.crew >= 0) summary.enemyCrew += general.crew; + if (general.crew !== null && general.crew > 0) summary.enemyArmedGenerals += 1; + return summary; + } + if (me.nationId <= 0 || general.nationId !== me.nationId) return summary; + summary.ownGenerals += 1; + summary.ownCrew += general.crew ?? 0; + if ((general.crew ?? 0) <= 0) return summary; + summary.ownArmedGenerals += 1; + const readiness = Math.min(general.train ?? -1, general.atmos ?? -1); + if (readiness >= 90) { + summary.ready90Crew += general.crew ?? 0; + summary.ready90Generals += 1; + } + if (readiness >= 60) { + summary.ready60Crew += general.crew ?? 0; + summary.ready60Generals += 1; + } + if (general.defenceTrain !== null && readiness >= general.defenceTrain) { + summary.defenceReadyCrew += general.crew ?? 0; + summary.defenceReadyGenerals += 1; + } + return summary; + }, + { + enemyCrew: 0, + enemyArmedGenerals: 0, + enemyGenerals: 0, + ownCrew: 0, + ownArmedGenerals: 0, + ownGenerals: 0, + ready90Crew: 0, + ready90Generals: 0, + ready60Crew: 0, + ready60Generals: 0, + defenceReadyCrew: 0, + defenceReadyGenerals: 0, + } + ); return { me: { id: me.id, nationId: me.nationId, officerLevel: me.officerLevel, admin }, options: [...selectable] @@ -194,6 +267,7 @@ export const worldRouter = router({ id: selected.id, name: selected.name, nationId: selected.nationId, + nationColor: selectedNation?.color ?? '#000000', level: selected.level, region: selected.region, population: redact(selected.population), @@ -217,8 +291,11 @@ export const worldRouter = router({ }, }, generals: mappedGenerals, + forceSummary, lastExecute: - typeof asRecord(world?.meta).turntime === 'string' ? String(asRecord(world?.meta).turntime) : '', + typeof asRecord(world?.meta).turntime === 'string' + ? String(asRecord(world?.meta).turntime).slice(5, 19) + : '', }; }), getState: procedure.query(async ({ ctx }) => { diff --git a/app/game-api/test/inGameInfoRouter.test.ts b/app/game-api/test/inGameInfoRouter.test.ts index 8a788cd..9cb7c5d 100644 --- a/app/game-api/test/inGameInfoRouter.test.ts +++ b/app/game-api/test/inGameInfoRouter.test.ts @@ -53,13 +53,13 @@ const general = (overrides: Partial = {}): GeneralRow => ({ updatedAt: now, ...overrides, }); -const auth = (roles: string[] = []): GameSessionTokenPayload => ({ +const auth = (roles: string[] = [], userId = 'user-1'): GameSessionTokenPayload => ({ version: 1, profile: 'che:default', issuedAt: now.toISOString(), expiresAt: new Date(now.getTime() + 86400000).toISOString(), sessionId: 'session', - user: { id: 'user-1', username: 'tester', displayName: 'Tester', roles }, + user: { id: userId, username: 'tester', displayName: 'Tester', roles }, sanctions: {}, }); const city = (id: number, nationId: number) => ({ @@ -88,15 +88,30 @@ const city = (id: number, nationId: number) => ({ meta: {}, }); -const context = (options: { me?: GeneralRow; roles?: string[]; nationMeta?: Record } = {}) => { +const context = ( + options: { + me?: GeneralRow; + roles?: string[]; + userId?: string; + nationMeta?: Record; + nationLevel?: number; + stationCityId?: number; + } = {} +) => { const me = options.me ?? general(); const cities = [city(1, 1), city(2, 2), city(3, 2), city(80, 1)]; const foreign = general({ id: 2, userId: 'user-2', name: '적군', nationId: 2, cityId: 2, crew: 777 }); const db = { general: { - findFirst: vi.fn(async () => me), + findFirst: vi.fn(async ({ where }: { where: { userId: string } }) => + where.userId === me.userId ? me : null + ), findMany: vi.fn(async (args: { where?: Record; select?: Record }) => { - if (args.where?.nationId === 1 && args.select?.cityId) return [{ cityId: me.cityId }]; + if (args.where?.nationId === 1 && args.select?.cityId) + return [ + { cityId: me.cityId }, + ...(options.stationCityId ? [{ cityId: options.stationCityId }] : []), + ]; if (args.where?.cityId === 2) return [foreign]; if (args.where?.cityId === 3) return [foreign]; if (args.where?.officerLevel) return []; @@ -108,7 +123,7 @@ const context = (options: { me?: GeneralRow; roles?: string[]; nationMeta?: Reco id: 1, name: '아국', color: '#008000', - level: 1, + level: options.nationLevel ?? 1, capitalCityId: 1, meta: options.nationMeta ?? {}, })), @@ -130,7 +145,7 @@ const context = (options: { me?: GeneralRow; roles?: string[]; nationMeta?: Reco turnDaemon: {} as GameApiContext['turnDaemon'], battleSim: {} as GameApiContext['battleSim'], profile: { id: 'che', scenario: 'default', name: 'che:default' }, - auth: auth(options.roles), + auth: auth(options.roles, options.userId), uploadDir: 'uploads', uploadPath: '/uploads', uploadPublicUrl: null, @@ -157,6 +172,25 @@ describe('in-game information permissions', () => { expect(result.generals).toEqual([]); }); + it('derives the actor from the session user instead of accepting another user general', async () => { + const caller = appRouter.createCaller(context({ userId: 'user-2' })); + await expect(caller.world.getCurrentCity({ cityId: 1 })).rejects.toMatchObject({ code: 'NOT_FOUND' }); + }); + + it('allows a nation member to select a city occupied by another general of the same nation', async () => { + const result = await appRouter.createCaller(context({ stationCityId: 2 })).world.getCurrentCity({ cityId: 2 }); + expect(result.options.map((entry) => entry.id)).toContain(2); + expect(result.visibility.full).toBe(true); + }); + + it('does not grant a spy city while the nation has no active level', async () => { + const result = await appRouter + .createCaller(context({ nationMeta: { spy: { 2: 2 } }, nationLevel: 0 })) + .world.getCurrentCity({ cityId: 2 }); + expect(result.options.map((entry) => entry.id)).not.toContain(2); + expect(result.visibility.full).toBe(false); + }); + it('keeps adjacent foreign detail redacted and never reveals military fields', async () => { const result = await appRouter .createCaller(context({ me: general({ cityId: 80 }) })) @@ -174,12 +208,20 @@ describe('in-game information permissions', () => { expect(result.visibility.full).toBe(true); expect(result.city.population).toBe(1000); expect(result.generals[0]).toMatchObject({ crew: 777, train: null, atmos: null, crewTypeId: null }); + expect(result.forceSummary).toMatchObject({ + enemyCrew: 777, + enemyArmedGenerals: 1, + enemyGenerals: 1, + }); }); - it('allows administrative roles to inspect all city and general fields', async () => { - const result = await appRouter.createCaller(context({ roles: ['admin'] })).world.getCurrentCity({ cityId: 3 }); - expect(result.options).toHaveLength(4); - expect(result.visibility.full).toBe(true); - expect(result.generals[0]).toMatchObject({ crew: 777, train: 90, atmos: 90, crewTypeId: 1 }); - }); + it.each(['admin', 'superuser', 'admin.superuser'])( + 'allows the %s role to inspect all city and general fields', + async (role) => { + const result = await appRouter.createCaller(context({ roles: [role] })).world.getCurrentCity({ cityId: 3 }); + expect(result.options).toHaveLength(4); + expect(result.visibility.full).toBe(true); + expect(result.generals[0]).toMatchObject({ crew: 777, train: 90, atmos: 90, crewTypeId: 1 }); + } + ); }); diff --git a/app/game-frontend/e2e/inGameInfo.spec.ts b/app/game-frontend/e2e/inGameInfo.spec.ts index 98e9421..b7da581 100644 --- a/app/game-frontend/e2e/inGameInfo.spec.ts +++ b/app/game-frontend/e2e/inGameInfo.spec.ts @@ -1,6 +1,28 @@ import { expect, test, type Page, type Route } from '@playwright/test'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; const response = (data: unknown) => ({ result: { data } }); +const artifactRoot = process.env.CITY_PARITY_ARTIFACT_DIR; +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..'); +const imageRoots = [resolve(repositoryRoot, '../image'), resolve(repositoryRoot, '../../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 feature worktrees have different image-root parents. + } + } + throw new Error(`Fixture image not found: ${relativePath}`); +}; +const imageContentType = (relativePath: string) => { + if (relativePath.endsWith('.png')) return 'image/png'; + if (relativePath.endsWith('.gif')) return 'image/gif'; + return 'image/jpeg'; +}; const operationNames = (route: Route) => decodeURIComponent(new URL(route.request().url()).pathname.split('/trpc/')[1] ?? '').split(','); const city = { @@ -46,19 +68,68 @@ const layout = { regionMap: { 1: '하북' }, levelMap: { 8: '특' }, }; +const generalContext = { + general: { + id: 1, + name: '장수', + nationId: 1, + cityId: 1, + officerLevel: 1, + npcState: 0, + troopId: 0, + picture: null, + imageServer: 0, + stats: { leadership: 70, strength: 60, intelligence: 50 }, + gold: 1000, + rice: 1000, + crew: 500, + train: 90, + atmos: 90, + injury: 0, + experience: 0, + dedication: 0, + items: { horse: 'None', weapon: 'None', book: 'None', item: 'None' }, + }, + city, + nation: { id: 1, name: '아국', color: '#008000', level: 1 }, + settings: {}, + penalties: {}, +}; +const emptyMessages = { + private: [], + national: [], + public: [], + diplomacy: [], + sequence: -1, + hasMore: { private: false, national: false, public: false, diplomacy: false }, + latestRead: { private: 0, national: 0, public: 0, diplomacy: 0 }, + canRespondDiplomacy: false, +}; const install = async (page: Page, mode: 'member' | 'wanderer' | 'admin' = 'member') => { await page.addInitScript(() => { localStorage.setItem('sammo-game-token', 'ga_info'); localStorage.setItem('sammo-game-profile', 'che:default'); }); - await page.route('**/image/game/**', (route) => - route.fulfill({ status: 200, contentType: 'image/jpeg', body: Buffer.from('') }) - ); + await page.route('**/image/**', async (route) => { + const relativePath = decodeURIComponent(new URL(route.request().url()).pathname.split('/image/')[1] ?? ''); + await route.fulfill({ + status: 200, + contentType: imageContentType(relativePath), + body: await readImage(relativePath), + }); + }); await page.route('**/che/api/trpc/**', async (route) => { const results = operationNames(route).map((operation) => { if (operation === 'lobby.info') return response({ myGeneral: { id: 1, name: '장수' } }); if (operation === 'join.getConfig') return response({}); + if (operation === 'general.me') return response(generalContext); + if (operation === 'world.getMap') return response(map); + if (operation === 'turns.getCommandTable') return response({ general: [], nation: [] }); + if (operation === 'turns.reserved.getGeneral') return response([]); + if (operation === 'messages.getRecent') return response(emptyMessages); + if (operation === 'board.getAccess') return response({ canMeeting: false, canSecret: false }); + if (operation === 'tournament.getState') return response({ stage: 0 }); if (operation === 'nation.getNationInfo') return response({ nation: { @@ -158,6 +229,7 @@ const install = async (page: Page, mode: 'member' | 'wanderer' | 'admin' = 'memb id: 1, name: '업', nationId: 1, + nationColor: '#008000', level: 8, region: 1, population: mode === 'wanderer' ? null : 150000, @@ -193,14 +265,30 @@ const install = async (page: Page, mode: 'member' | 'wanderer' | 'admin' = 'memb intelligence: 50, injury: 0, officerLevel: 1, + leadershipBonus: 0, defenceTrain: 80, crewTypeId: 1, + crewTypeName: '보병', crew: 500, train: 90, atmos: 90, turns: ['징병'], }, ], + forceSummary: { + enemyCrew: 0, + enemyArmedGenerals: 0, + enemyGenerals: 0, + ownCrew: mode === 'wanderer' ? 0 : 500, + ownArmedGenerals: mode === 'wanderer' ? 0 : 1, + ownGenerals: mode === 'wanderer' ? 0 : 1, + ready90Crew: mode === 'wanderer' ? 0 : 500, + ready90Generals: mode === 'wanderer' ? 0 : 1, + ready60Crew: mode === 'wanderer' ? 0 : 500, + ready60Generals: mode === 'wanderer' ? 0 : 1, + defenceReadyCrew: mode === 'wanderer' ? 0 : 500, + defenceReadyGenerals: mode === 'wanderer' ? 0 : 1, + }, lastExecute: '2026-07-26', }); return { error: { message: `unhandled ${operation}`, data: { code: 'BAD_REQUEST' } } }; @@ -215,11 +303,11 @@ const go = async (page: Page, path: string) => { test('four legacy menu pages keep the 1000px desktop table contract', async ({ page }) => { await install(page); await page.setViewportSize({ width: 1200, height: 900 }); - for (const [path, selector] of [ - ['nation/info', '.legacy-info-page'], - ['nation/cities', '.nation-cities-page'], - ['global-info', '.global-page'], - ['current-city', '.city-page'], + for (const [path, selector, fontSize, fontFamily, borderCollapse] of [ + ['nation/info', '.legacy-info-page', '14px', 'Pretendard', 'collapse'], + ['nation/cities', '.nation-cities-page', '14px', 'Pretendard', 'collapse'], + ['global-info', '.global-page', '14px', 'Pretendard', 'collapse'], + ['current-city', '.city-page', '16px', 'Times New Roman', 'separate'], ] as const) { await go(page, path); await expect(page.locator(selector)).toBeVisible(); @@ -230,14 +318,14 @@ test('four legacy menu pages keep the 1000px desktop table contract', async ({ p }); expect(box.width).toBe(1000); expect(box.x).toBe(100); - expect(box.fontSize).toBe('14px'); - expect(box.fontFamily).toContain('Pretendard'); + expect(box.fontSize).toBe(fontSize); + expect(box.fontFamily).toContain(fontFamily); expect( await page .locator('table') .first() .evaluate((el) => getComputedStyle(el).borderCollapse) - ).toBe('collapse'); + ).toBe(borderCollapse); } }); @@ -250,7 +338,100 @@ test('current-city hides values and general rows for a wandering user', async ({ test('current-city exposes own general details to a member and admin fixture', async ({ page }) => { await install(page, 'admin'); + await page.setViewportSize({ width: 1200, height: 900 }); await go(page, 'current-city'); await expect(page.locator('.generals')).toContainText('장수'); await expect(page.locator('.generals')).toContainText('90'); + const legacyGeometry = await page.evaluate(() => { + const rect = (selector: string) => { + const box = document.querySelector(selector)?.getBoundingClientRect(); + return box ? { x: box.x, y: box.y, width: box.width, height: box.height } : null; + }; + const icon = document.querySelector('.general-icon'); + return { + selector: rect('#citySelector'), + stats: rect('.stats'), + generals: rect('.generals'), + titleAlign: getComputedStyle(document.querySelector('.city-page > table:first-child td')!).textAlign, + icon: icon + ? { + ...rect('.general-icon'), + naturalWidth: icon.naturalWidth, + naturalHeight: icon.naturalHeight, + } + : null, + }; + }); + expect(legacyGeometry.selector).toMatchObject({ width: 400, height: 19 }); + expect(legacyGeometry.stats).toEqual({ x: 100, y: 178, width: 1000, height: 136 }); + expect(legacyGeometry.generals).toMatchObject({ x: 88, y: 332, width: 1024 }); + expect(legacyGeometry.titleAlign).toBe('start'); + expect(legacyGeometry.icon).toMatchObject({ width: 64, height: 64, naturalWidth: 64, naturalHeight: 64 }); + if (artifactRoot) { + await mkdir(artifactRoot, { recursive: true }); + const computedDom = await page.evaluate(() => { + const measure = (selector: string) => { + const element = document.querySelector(selector); + if (!element) return null; + const box = element.getBoundingClientRect(); + const style = getComputedStyle(element); + return { + rect: { x: box.x, y: box.y, width: box.width, height: box.height }, + style: { + fontFamily: style.fontFamily, + fontSize: style.fontSize, + lineHeight: style.lineHeight, + color: style.color, + backgroundColor: style.backgroundColor, + backgroundImage: style.backgroundImage, + borderCollapse: style.borderCollapse, + padding: style.padding, + textAlign: style.textAlign, + }, + }; + }; + const icon = document.querySelector('.general-icon'); + return { + body: measure('body'), + page: measure('.city-page'), + selector: measure('#citySelector'), + stats: measure('.stats'), + generals: measure('.generals'), + title: measure('.city-title'), + firstIcon: icon + ? { + ...measure('.general-icon'), + naturalWidth: icon.naturalWidth, + naturalHeight: icon.naturalHeight, + } + : null, + document: { + width: document.documentElement.scrollWidth, + height: document.documentElement.scrollHeight, + }, + }; + }); + await writeFile( + resolve(artifactRoot, 'core-current-city-computed-dom.json'), + `${JSON.stringify(computedDom, null, 2)}\n` + ); + await page.screenshot({ + path: resolve(artifactRoot, 'core-current-city-desktop.png'), + fullPage: true, + animations: 'disabled', + }); + } +}); + +test('a Chromium map click opens the clicked city route and keeps the legacy pointer interaction', async ({ page }) => { + await install(page); + await page.setViewportSize({ width: 1200, height: 900 }); + await go(page, ''); + const cityLink = page.locator('.map-city').first(); + await expect(cityLink).toBeVisible(); + await cityLink.hover(); + await expect(cityLink).toHaveCSS('cursor', 'pointer'); + await cityLink.click(); + await expect(page).toHaveURL(/\/che\/current-city\?cityId=1$/); + await expect(page.locator('.stats')).toContainText('업'); }); diff --git a/app/game-frontend/src/components/main/MapCityBasic.vue b/app/game-frontend/src/components/main/MapCityBasic.vue index ff072f4..d678446 100644 --- a/app/game-frontend/src/components/main/MapCityBasic.vue +++ b/app/game-frontend/src/components/main/MapCityBasic.vue @@ -34,8 +34,9 @@ const stateOffset = computed(() => -6 * props.mapScale); diff --git a/docs/frontend-legacy-parity.md b/docs/frontend-legacy-parity.md index 42a4c80..368ab85 100644 --- a/docs/frontend-legacy-parity.md +++ b/docs/frontend-legacy-parity.md @@ -43,23 +43,24 @@ storage, route guards, and image loading. ## Enforced contracts -| Screen | Ref entry point | Current automated contract | -| -------------------- | ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| gateway login/status | `index.php` | 450/700px desktop widths, mobile collapse, Pretendard title, real login mutation/session storage, actual seasonal map asset | -| gateway account | `i_entrance/user_info.php` | 550px × minimum 575px panel, 14px Pretendard, three legacy textures, success and API-error password flows | -| gateway OAuth join | `oauth_kakao/join.php` | 700px centered registration card, Kakao exchange/register success, retained-input API error, hover/focus | -| game login hand-off | unauthenticated `hwe/index.php` redirect | `/che/login` delegates to `/gateway/` | -| troop | `hwe/v_troop.php` | existing `app/game-frontend/e2e/troop.spec.ts` desktop/mobile geometry and interaction suite | -| hall of fame | `hwe/a_hallOfFame.php` | 500/1000px container, 100px ranking cells, 64px natural image, walnut/green textures, Pretendard, close-button focus | -| yearbook | `hwe/v_history.php` | 1000px 700+300 desktop grid, 500px stacked grid, month navigation, legacy textures, success and API-error flows | -| inheritance | `hwe/v_inheritPoint.php` | 1000px 3-column desktop and 500px stacked layout, walnut/green textures, Pretendard 14px, scenario unique selector, buff purchase success and retained-input API error | -| nation betting | `hwe/v_nationBetting.php` | 1000px/6-column desktop and 500px/3-column mobile grids, picked card style, payout table, success and retained-form error | -| public NPC list | `hwe/a_npcList.php` | 1000px 12-column table with Chromium-expanded legacy widths, NPC color, eight sorts, retained table/sort after API error | -| 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 | -| 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 | +| Screen | Ref entry point | Current automated contract | +| -------------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| gateway login/status | `index.php` | 450/700px desktop widths, mobile collapse, Pretendard title, real login mutation/session storage, actual seasonal map asset | +| gateway account | `i_entrance/user_info.php` | 550px × minimum 575px panel, 14px Pretendard, three legacy textures, success and API-error password flows | +| gateway OAuth join | `oauth_kakao/join.php` | 700px centered registration card, Kakao exchange/register success, retained-input API error, hover/focus | +| game login hand-off | unauthenticated `hwe/index.php` redirect | `/che/login` delegates to `/gateway/` | +| troop | `hwe/v_troop.php` | existing `app/game-frontend/e2e/troop.spec.ts` desktop/mobile geometry and interaction suite | +| current city | `hwe/b_currentCity.php` | ref-specific 16px Times New Roman, 1000px summary/1024px general tables, 400px selector, 64px icon, nation title color, force summary, actor/spy/admin redaction, and map-click query navigation | +| hall of fame | `hwe/a_hallOfFame.php` | 500/1000px container, 100px ranking cells, 64px natural image, walnut/green textures, Pretendard, close-button focus | +| yearbook | `hwe/v_history.php` | 1000px 700+300 desktop grid, 500px stacked grid, month navigation, legacy textures, success and API-error flows | +| inheritance | `hwe/v_inheritPoint.php` | 1000px 3-column desktop and 500px stacked layout, walnut/green textures, Pretendard 14px, scenario unique selector, buff purchase success and retained-input API error | +| nation betting | `hwe/v_nationBetting.php` | 1000px/6-column desktop and 500px/3-column mobile grids, picked card style, payout table, success and retained-form error | +| public NPC list | `hwe/a_npcList.php` | 1000px 12-column table with Chromium-expanded legacy widths, NPC color, eight sorts, retained table/sort after API error | +| 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 | +| 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 | The global game baseline is black, white, Pretendard 14px. Legacy texture helpers intentionally follow `common.orig.css`: `bg0` is walnut, `bg1` is @@ -97,6 +98,14 @@ 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 current-city reference can be collected independently with +`tools/frontend-legacy-parity/reference-current-city.mjs`. It requires +`REF_PARITY_PASSWORD_FILE` and accepts `REF_PARITY_URL`, `REF_PARITY_USER`, and +`REF_PARITY_ARTIFACT_DIR`; the password is read from the ignored secret file +and is never written to the artifact. The matching core fixture is +`app/game-frontend/e2e/inGameInfo.spec.ts`, which writes its computed DOM and +screenshot only when `CITY_PARITY_ARTIFACT_DIR` is set. + 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-current-city.mjs b/tools/frontend-legacy-parity/reference-current-city.mjs new file mode 100644 index 0000000..0ef2309 --- /dev/null +++ b/tools/frontend-legacy-parity/reference-current-city.mjs @@ -0,0 +1,128 @@ +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-current-city'); + +if (!passwordFile) { + throw new Error('REF_PARITY_PASSWORD_FILE is required.'); +} + +const password = (await readFile(passwordFile, 'utf8')).trim(); +await mkdir(artifactRoot, { recursive: true }); + +const browser = await chromium.launch({ headless: true }); +try { + const context = await browser.newContext({ + viewport: { width: 1200, height: 900 }, + deviceScaleFactor: 1, + locale: 'ko-KR', + timezoneId: 'Asia/Seoul', + colorScheme: 'dark', + }); + const page = await context.newPage(); + 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 loginResponse = await context.request.post(new URL('api.php?path=Login/LoginByID', baseUrl).toString(), { + data: { username, password: passwordHash }, + }); + const loginResult = await loginResponse.json(); + if (!loginResponse.ok() || loginResult.result !== true) { + throw new Error('Reference login failed.'); + } + + await page.goto(new URL('hwe/', baseUrl).toString(), { waitUntil: 'networkidle' }); + const mapCity = page.locator('a[href*="b_currentCity.php?citylist="]').first(); + const hasMapCity = await mapCity.isVisible({ timeout: 8_000 }).catch(() => false); + let mapInteraction; + if (hasMapCity) { + mapInteraction = await mapCity.evaluate((element) => ({ + available: true, + href: element.getAttribute('href'), + cursor: getComputedStyle(element).cursor, + rect: (() => { + const box = element.getBoundingClientRect(); + return { x: box.x, y: box.y, width: box.width, height: box.height }; + })(), + })); + await mapCity.click(); + await page.waitForLoadState('networkidle'); + if (!page.url().includes('b_currentCity.php?citylist=')) { + throw new Error(`Reference map click did not open current city: ${page.url()}`); + } + } else { + mapInteraction = { + available: false, + pageUrl: page.url(), + pageTitle: await page.title(), + }; + await page.goto(new URL('hwe/b_currentCity.php', baseUrl).toString(), { waitUntil: 'networkidle' }); + } + + const measurements = await page.evaluate(() => { + const measure = (element) => { + const box = element.getBoundingClientRect(); + const style = getComputedStyle(element); + return { + rect: { x: box.x, y: box.y, width: box.width, height: box.height }, + style: { + fontFamily: style.fontFamily, + fontSize: style.fontSize, + lineHeight: style.lineHeight, + color: style.color, + backgroundColor: style.backgroundColor, + backgroundImage: style.backgroundImage, + borderCollapse: style.borderCollapse, + padding: style.padding, + textAlign: style.textAlign, + }, + }; + }; + const tables = [...document.querySelectorAll('table')]; + const selector = document.querySelector('#citySelector'); + const stats = tables.find((table) => table.textContent?.includes('90병장')); + const generals = document.querySelector('#general_list')?.closest('table'); + const firstIcon = document.querySelector('.generalIcon'); + const title = stats?.querySelector('tr:first-child td'); + return { + body: measure(document.body), + tables: tables.map(measure), + selector: selector ? measure(selector) : null, + stats: stats ? measure(stats) : null, + generals: generals ? measure(generals) : null, + firstIcon: firstIcon + ? { + ...measure(firstIcon), + naturalWidth: firstIcon.naturalWidth, + naturalHeight: firstIcon.naturalHeight, + } + : null, + title: title ? measure(title) : null, + document: { + width: document.documentElement.scrollWidth, + height: document.documentElement.scrollHeight, + }, + }; + }); + + await page.screenshot({ + path: resolve(artifactRoot, 'reference-current-city-desktop.png'), + fullPage: true, + animations: 'disabled', + }); + await writeFile( + resolve(artifactRoot, 'reference-current-city-computed-dom.json'), + `${JSON.stringify({ mapInteraction, currentCity: measurements }, null, 2)}\n` + ); + process.stdout.write(`${JSON.stringify({ ok: true, artifactRoot })}\n`); + await context.close(); +} finally { + await browser.close(); +}