From 481197a84cbb9242cabd74360d9b445f7c45b3fa Mon Sep 17 00:00:00 2001 From: hided62 Date: Thu, 20 Aug 2026 16:43:54 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=A9=94=EC=9D=B8=20=EB=8F=84=EC=8B=9C?= =?UTF-8?q?=20=EC=A0=95=EB=B3=B4=EB=A5=BC=20Ref=EC=99=80=20=EC=9D=BC?= =?UTF-8?q?=EC=B9=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 도시 소유 국가색과 태수·군사·종사를 general.me에 추가한다. 주민 영역, 반응형 grid와 국가색 대비를 Ref 카드와 같은 geometry로 맞추고 API 및 production Chromium 회귀 검증을 추가한다. --- app/game-api/src/router/general/index.ts | 102 ++++++---- .../test/inGameMenuPermissions.test.ts | 58 ++++++ app/game-frontend/e2e/inGameMenus.spec.ts | 75 +++++++- app/game-frontend/e2e/mainNavigation.spec.ts | 9 + .../src/components/main/CityBasicCard.vue | 181 +++++++++++++----- app/game-frontend/src/views/MainView.vue | 7 +- .../reference-progress-bars.mjs | 25 ++- 7 files changed, 372 insertions(+), 85 deletions(-) diff --git a/app/game-api/src/router/general/index.ts b/app/game-api/src/router/general/index.ts index 085ff572..4a61ce2c 100644 --- a/app/game-api/src/router/general/index.ts +++ b/app/game-api/src/router/general/index.ts @@ -34,6 +34,7 @@ import { resolveNationBlockWar, resolveNationNotice, resolveNationRate, + resolveOfficerCity, type TraitNameMap, } from '../nation/shared.js'; import { @@ -348,38 +349,73 @@ export const getGeneralContext = async (ctx: GameApiContext) => { ]); const nation = queriedNation ?? NEUTRAL_NATION_CONTEXT; - const [capitalCity, cityNation, troopLeaderCity, nationPopulation, nationCrew, topChiefRows] = await Promise.all([ - nation.capitalCityId - ? ctx.db.city.findUnique({ where: { id: nation.capitalCityId }, select: { name: true } }) - : Promise.resolve(null), - city && city.nationId > 0 - ? ctx.db.nation.findUnique({ where: { id: city.nationId }, select: { name: true } }) - : Promise.resolve(null), - troopLeader && troopLeader.cityId > 0 - ? ctx.db.city.findUnique({ where: { id: troopLeader.cityId }, select: { name: true } }) - : Promise.resolve(null), - nation.id > 0 - ? ctx.db.city.aggregate({ - where: { nationId: nation.id }, - _count: true, - _sum: { population: true, populationMax: true }, - }) - : Promise.resolve({ _count: 0, _sum: { population: 0, populationMax: 0 } }), - nation.id > 0 - ? ctx.db.general.aggregate({ - where: { nationId: nation.id, npcState: { not: 5 } }, - _count: true, - _sum: { crew: true, leadership: true }, - }) - : Promise.resolve({ _count: 0, _sum: { crew: 0, leadership: 0 } }), - nation.id > 0 - ? ctx.db.general.findMany({ - where: { nationId: nation.id, officerLevel: { gte: 11 } }, - select: { id: true, name: true, npcState: true, officerLevel: true }, - orderBy: { id: 'asc' }, - }) - : Promise.resolve([]), - ]); + const [capitalCity, cityNation, troopLeaderCity, nationPopulation, nationCrew, chiefAndCityOfficerRows] = + await Promise.all([ + nation.capitalCityId + ? ctx.db.city.findUnique({ where: { id: nation.capitalCityId }, select: { name: true } }) + : Promise.resolve(null), + city && city.nationId > 0 + ? ctx.db.nation.findUnique({ where: { id: city.nationId }, select: { name: true, color: true } }) + : Promise.resolve(null), + troopLeader && troopLeader.cityId > 0 + ? ctx.db.city.findUnique({ where: { id: troopLeader.cityId }, select: { name: true } }) + : Promise.resolve(null), + nation.id > 0 + ? ctx.db.city.aggregate({ + where: { nationId: nation.id }, + _count: true, + _sum: { population: true, populationMax: true }, + }) + : Promise.resolve({ _count: 0, _sum: { population: 0, populationMax: 0 } }), + nation.id > 0 + ? ctx.db.general.aggregate({ + where: { nationId: nation.id, npcState: { not: 5 } }, + _count: true, + _sum: { crew: true, leadership: true }, + }) + : Promise.resolve({ _count: 0, _sum: { crew: 0, leadership: 0 } }), + nation.id > 0 || city + ? ctx.db.general.findMany({ + where: { + OR: [ + { nationId: nation.id > 0 ? nation.id : -1, officerLevel: { gte: 11 } }, + { + officerLevel: { in: [2, 3, 4] }, + meta: { path: ['officerCity'], equals: city?.id ?? -1 }, + }, + { + officerLevel: { in: [2, 3, 4] }, + meta: { path: ['officer_city'], equals: city?.id ?? -1 }, + }, + ], + }, + select: { id: true, name: true, npcState: true, nationId: true, officerLevel: true, meta: true }, + orderBy: { id: 'asc' }, + }) + : Promise.resolve([]), + ]); + const topChiefRows = chiefAndCityOfficerRows.filter( + (chief) => nation.id > 0 && chief.nationId === nation.id && chief.officerLevel >= 11 + ); + const cityOfficers: Record<2 | 3 | 4, { id: number; name: string; npcState: number } | null> = { + 2: null, + 3: null, + 4: null, + }; + if (city) { + for (const officer of chiefAndCityOfficerRows) { + if ( + (officer.officerLevel === 2 || officer.officerLevel === 3 || officer.officerLevel === 4) && + resolveOfficerCity(asRecord(officer.meta)) === city.id + ) { + cityOfficers[officer.officerLevel] = { + id: officer.id, + name: officer.name, + npcState: officer.npcState, + }; + } + } + } const [personalityNames, domesticNames, warNames, nationTypeNames, crewTypeNames, itemNames] = await Promise.all([ loadTraitNames([general.personalCode], 'personality'), loadTraitNames([general.specialCode], 'domestic'), @@ -528,6 +564,8 @@ export const getGeneralContext = async (ctx: GameApiContext) => { levelName: resolveCityLevelName(city.level), regionName: resolveRegionName(city.region), nationName: city.nationId > 0 ? (cityNation?.name ?? '-') : '공백지', + nationColor: city.nationId > 0 ? (cityNation?.color ?? '#000000') : '#000000', + officers: cityOfficers, } : null, nation: { diff --git a/app/game-api/test/inGameMenuPermissions.test.ts b/app/game-api/test/inGameMenuPermissions.test.ts index 15192ca2..b83cffb2 100644 --- a/app/game-api/test/inGameMenuPermissions.test.ts +++ b/app/game-api/test/inGameMenuPermissions.test.ts @@ -290,6 +290,64 @@ describe('in-game my information ownership', () => { ); }); + it('returns the current city nation color and its Ref city officers', async () => { + const me = buildGeneral(); + const fixture = createContext({ + me, + targets: [ + me, + buildGeneral({ id: 8, name: '태수장', officerLevel: 4, npcState: 0, meta: { officerCity: 1 } }), + buildGeneral({ id: 9, name: '군사장', officerLevel: 3, npcState: 2, meta: { officer_city: 1 } }), + buildGeneral({ id: 10, name: '타도시종사', officerLevel: 2, npcState: 6, meta: { officerCity: 2 } }), + ], + city: { + id: 1, + name: '업', + level: 8, + nationId: 1, + population: 1_000, + populationMax: 2_000, + agriculture: 100, + agricultureMax: 200, + commerce: 100, + commerceMax: 200, + security: 100, + securityMax: 200, + trust: 70, + trade: 100, + defence: 100, + defenceMax: 200, + wall: 100, + wallMax: 200, + region: 2, + supplyState: 1, + frontState: 0, + }, + }); + + await expect(appRouter.createCaller(fixture.context).general.me()).resolves.toMatchObject({ + city: { + nationName: '위', + nationColor: '#777777', + officers: { + 4: { id: 8, name: '태수장', npcState: 0 }, + 3: { id: 9, name: '군사장', npcState: 2 }, + 2: null, + }, + }, + }); + expect(fixture.db.general.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + OR: expect.arrayContaining([ + expect.objectContaining({ meta: { path: ['officerCity'], equals: 1 } }), + expect.objectContaining({ meta: { path: ['officer_city'], equals: 1 } }), + ]), + }, + }) + ); + }); + it('returns Ref display names instead of numeric levels and internal codes for the main GUI', async () => { const fixture = createContext({ me: buildGeneral({ diff --git a/app/game-frontend/e2e/inGameMenus.spec.ts b/app/game-frontend/e2e/inGameMenus.spec.ts index 99bcf16b..c06c6da0 100644 --- a/app/game-frontend/e2e/inGameMenus.spec.ts +++ b/app/game-frontend/e2e/inGameMenus.spec.ts @@ -123,6 +123,7 @@ const myGeneral = (state: FixtureState) => ({ regionName: '중원', nationId: 1, nationName: '위', + nationColor: '#ffff00', population: 1000, populationMax: 2000, agriculture: 100, @@ -139,6 +140,11 @@ const myGeneral = (state: FixtureState) => ({ wallMax: 200, supplyState: 1, frontState: 0, + officers: { + 4: { id: 8, name: '태수장', npcState: 0 }, + 3: { id: 9, name: '군사장', npcState: 2 }, + 2: { id: 10, name: '종사장', npcState: 6 }, + }, }, nation: state.buildNationCandidateEnabled ? { @@ -679,11 +685,76 @@ test('메인 카드의 국가·수도·관직·계급·병종은 Ref 출력명 await expect(generalCard).toContainText('계급 29품관'); const cityCard = page.locator('.city-card'); - await expect(cityCard.locator('.title')).toContainText('【중원 | 특】 업'); - await expect(cityCard.locator('.title')).toContainText('지배 국가 【 위 】'); + await expect(cityCard.locator('.city-title')).toHaveText('【중원 | 특】 업'); + await expect(cityCard.locator('.city-nation')).toHaveText('지배 국가 【 위 】'); await expect(page.locator('.main-page')).not.toContainText('che_'); }); +for (const viewport of [ + { name: 'desktop', width: 1000, height: 900, columns: 4, cardWidth: 700, cardHeight: 125.390625 }, + { name: 'mobile', width: 500, height: 900, columns: 3, cardWidth: 500, cardHeight: 147.390625 }, +] as const) { + test(`메인 도시 카드는 Ref의 국가색·주민 폭·도시 관직 배치를 유지한다 (${viewport.name})`, async ({ page }) => { + const state: FixtureState = { + permission: 'head', + myset: 3, + settingMutations: [], + accessPages: [], + }; + await install(page, state); + await page.setViewportSize({ width: viewport.width, height: viewport.height }); + await page.goto(''); + + const cityCard = page.locator('[data-city-basic-card]'); + await expect(cityCard.locator('.city-title')).toHaveText('【중원 | 특】 업'); + await expect(cityCard.locator('.city-nation')).toHaveText('지배 국가 【 위 】'); + await expect(cityCard.locator('[data-city-officer="4"]')).toContainText('태수장'); + await expect(cityCard.locator('[data-city-officer="3"]')).toContainText('군사장'); + await expect(cityCard.locator('[data-city-officer="2"]')).toContainText('종사장'); + + const geometry = await cityCard.evaluate((element) => { + const rect = (selector: string) => element.querySelector(selector)!.getBoundingClientRect(); + const style = (selector: string) => getComputedStyle(element.querySelector(selector)!); + const cardRect = element.getBoundingClientRect(); + const populationRect = rect('[data-city-progress="주민"]'); + const trustRect = rect('[data-city-progress="민심"]'); + const populationBarRect = rect('[data-city-progress="주민"] [role="progressbar"]'); + const trustBarRect = rect('[data-city-progress="민심"] [role="progressbar"]'); + return { + card: { width: cardRect.width, height: cardRect.height }, + columns: style('.city-grid').gridTemplateColumns.split(' ').length, + title: { + backgroundColor: style('.city-title').backgroundColor, + color: style('.city-title').color, + }, + nation: { + backgroundColor: style('.city-nation').backgroundColor, + color: style('.city-nation').color, + }, + populationWidth: populationRect.width, + trustWidth: trustRect.width, + populationBarWidth: populationBarRect.width, + trustBarWidth: trustBarRect.width, + officerColors: [4, 3, 2].map( + (level) => style(`[data-city-officer="${level}"] .city-officer__name`).color + ), + overflow: document.documentElement.scrollWidth - document.documentElement.clientWidth, + }; + }); + + expect(geometry.columns).toBe(viewport.columns); + expect(geometry.card.width).toBeCloseTo(viewport.cardWidth, 0); + expect(geometry.card.height).toBeCloseTo(viewport.cardHeight, 0); + expect(geometry.title).toEqual({ backgroundColor: 'rgb(255, 255, 0)', color: 'rgb(0, 0, 0)' }); + expect(geometry.nation).toEqual({ backgroundColor: 'rgb(255, 255, 0)', color: 'rgb(0, 0, 0)' }); + expect(geometry.populationWidth).toBeCloseTo(geometry.trustWidth * 2, 0); + expect(geometry.populationBarWidth).toBeGreaterThan(geometry.trustBarWidth * 2.4); + expect(geometry.officerColors).toEqual(['rgb(255, 255, 255)', 'rgb(0, 255, 255)', 'rgb(102, 205, 170)']); + expect(geometry.overflow).toBe(0); + await persistParityArtifact(page, `main-city-card-${viewport.name}`, geometry); + }); +} + test('메인 장수 동향과 개인 전투 기록은 Ref 행 간격·색상·글자 크기를 유지한다', async ({ page }) => { const state: FixtureState = { permission: 'head', diff --git a/app/game-frontend/e2e/mainNavigation.spec.ts b/app/game-frontend/e2e/mainNavigation.spec.ts index 81a3271b..14ad75c9 100644 --- a/app/game-frontend/e2e/mainNavigation.spec.ts +++ b/app/game-frontend/e2e/mainNavigation.spec.ts @@ -419,8 +419,12 @@ const generalContext = (state: NavigationFixture) => ({ id: 1, name: '업', level: 8, + levelName: '특', nationId: 1, + nationName: '위', + nationColor: state.nationColor ?? '#008000', region: 1, + regionName: '중원', population: 100_000, populationMax: 200_000, agriculture: 1_000, @@ -438,6 +442,11 @@ const generalContext = (state: NavigationFixture) => ({ supplyState: 1, frontState: 0, state: state.cityState ?? 0, + officers: { + 4: { id: 8, name: '태수장', npcState: 0 }, + 3: { id: 9, name: '군사장', npcState: 2 }, + 2: { id: 10, name: '종사장', npcState: 6 }, + }, }, nation: { id: 1, diff --git a/app/game-frontend/src/components/main/CityBasicCard.vue b/app/game-frontend/src/components/main/CityBasicCard.vue index 8d81b588..72dca256 100644 --- a/app/game-frontend/src/components/main/CityBasicCard.vue +++ b/app/game-frontend/src/components/main/CityBasicCard.vue @@ -3,6 +3,14 @@ import { computed } from 'vue'; import SkeletonLines from '../ui/SkeletonLines.vue'; import LegacyProgressBar from '../ui/LegacyProgressBar.vue'; import { ratioPercent } from '../../utils/legacyProgress'; +import { legacyLuminanceTextColor } from '../../utils/legacyNationColor'; +import { getNpcColor } from '../../utils/npcColor'; + +interface CityOfficer { + id: number; + name: string; + npcState: number; +} interface CityInfo { id: number; @@ -12,6 +20,7 @@ interface CityInfo { regionName: string; nationId: number; nationName: string; + nationColor: string; population: number; populationMax: number; agriculture: number; @@ -28,6 +37,7 @@ interface CityInfo { wallMax: number; supplyState: number; frontState: number; + officers?: Partial>; } const props = defineProps<{ @@ -59,97 +69,170 @@ const metrics = computed(() => { { label: '시세', percent: city.trade ? ratioPercent(city.trade - 95, 10) : 0, text: tradeText }, ]; }); + +const officerRows = computed(() => { + const city = props.city; + if (!city) return []; + return [ + { level: 4 as const, label: '태수', officer: city.officers?.[4] }, + { level: 3 as const, label: '군사', officer: city.officers?.[3] }, + { level: 2 as const, label: '종사', officer: city.officers?.[2] }, + ]; +}); + +const nationStyle = computed(() => { + const backgroundColor = props.city?.nationColor ?? '#000000'; + return { + backgroundColor, + color: legacyLuminanceTextColor(backgroundColor), + }; +}); diff --git a/app/game-frontend/src/views/MainView.vue b/app/game-frontend/src/views/MainView.vue index ef828879..047a2995 100644 --- a/app/game-frontend/src/views/MainView.vue +++ b/app/game-frontend/src/views/MainView.vue @@ -756,7 +756,12 @@ button { display: none; } -[data-main-target='map'] :deep(.panel-body) { +[data-main-target='city'] :deep(.panel-header) { + display: none; +} + +[data-main-target='map'] :deep(.panel-body), +[data-main-target='city'] :deep(.panel-body) { padding: 0; } diff --git a/tools/frontend-legacy-parity/reference-progress-bars.mjs b/tools/frontend-legacy-parity/reference-progress-bars.mjs index 86d0c1fe..b6126228 100644 --- a/tools/frontend-legacy-parity/reference-progress-bars.mjs +++ b/tools/frontend-legacy-parity/reference-progress-bars.mjs @@ -63,6 +63,7 @@ try { `reference main progress bars missing: ${JSON.stringify({ url: page.url(), title: await page.title(), + bodyText: (await page.locator('body').innerText()).slice(0, 500), cityCards: await page.locator('.city-card-basic').count(), generalCards: await page.locator('.general-card-basic').count(), legacyBars: await page.locator('.bar_out').count(), @@ -84,11 +85,33 @@ try { fillBackgroundImage: fillStyle?.backgroundImage ?? '', }; }; + const inspectPanel = (selector) => { + const element = document.querySelector(selector); + if (!(element instanceof HTMLElement)) return null; + const rect = element.getBoundingClientRect(); + const style = getComputedStyle(element); + return { + text: element.textContent?.replace(/\s+/gu, ' ').trim() ?? '', + rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height }, + backgroundColor: style.backgroundColor, + color: style.color, + }; + }; + const cityCard = document.querySelector('.city-card-basic'); + if (!(cityCard instanceof HTMLElement)) throw new Error('reference city card missing'); return { viewport: { width: innerWidth, height: innerHeight }, city: [...document.querySelectorAll('.city-card-basic .sammo-bar')].map(inspect), general: [...document.querySelectorAll('.general-card-basic .sammo-bar')].map(inspect), - cityCard: document.querySelector('.city-card-basic').getBoundingClientRect().toJSON(), + cityCard: cityCard.getBoundingClientRect().toJSON(), + cityLayout: { + gridTemplateColumns: getComputedStyle(cityCard).gridTemplateColumns, + title: inspectPanel('.city-card-basic .cityNamePanel'), + nation: inspectPanel('.city-card-basic .nationNamePanel'), + population: inspectPanel('.city-card-basic .popPanel'), + trust: inspectPanel('.city-card-basic .trustPanel'), + officers: [4, 3, 2].map((level) => inspectPanel(`.city-card-basic .officer${level}Panel`)), + }, generalCard: document.querySelector('.general-card-basic').getBoundingClientRect().toJSON(), }; });