diff --git a/app/game-api/src/router/general/index.ts b/app/game-api/src/router/general/index.ts index e5633a18..0ca59a00 100644 --- a/app/game-api/src/router/general/index.ts +++ b/app/game-api/src/router/general/index.ts @@ -18,7 +18,7 @@ import { ConflictingTurnDaemonCommandError } from '../../daemon/databaseTranspor import { resolveAccessWindows } from '../../services/generalAccess.js'; import { adjustAccountIconForUser } from '../../services/accountIconSync.js'; import { getMyGeneral } from '../shared/general.js'; -import { resolveNationNotice } from '../nation/shared.js'; +import { loadTraitNames, resolveNationNotice, type TraitNameMap } from '../nation/shared.js'; const zGeneralSettings = z.object({ tnmt: z.number().int().optional(), @@ -34,6 +34,17 @@ const zImmediateActionInput = z }) .optional(); const MAIN_RECORD_LIMIT = 15; +const NEUTRAL_NATION_CONTEXT = { + id: 0, + name: '재야', + color: '#000000', + level: 0, + gold: 0, + rice: 0, + tech: 0, + typeCode: 'None', + capitalCityId: null, +} as const; const resolveImmediateActionRequestId = ( contextRequestId: string | undefined, @@ -135,6 +146,18 @@ const normalizeItemCode = (value: string | null): string | null => { return value; }; +const resolveTraitDisplayName = (code: string, names: TraitNameMap): string => { + if (!code || code === 'None') { + return '-'; + } + const loadedName = names.get(code)?.name; + if (loadedName) { + return loadedName; + } + // Ref는 class getName()을 표시하므로 로더가 모르는 선택적 특기도 raw namespace는 노출하지 않는다. + return code.replace(/^che_(?:event_)?/u, ''); +}; + const resolveUserSettings = (meta: Record) => { // The legacy general columns are persisted at the top level of General.meta. // Keep reading the short-lived nested shape for installations that ran the @@ -265,10 +288,16 @@ export const getGeneralContext = async (ctx: GameApiContext) => { capitalCityId: true, }, }) - : null, + : Promise.resolve(NEUTRAL_NATION_CONTEXT), ctx.db.worldState.findFirst({ select: { config: true } }), ]); + const [personalityNames, domesticNames, warNames] = await Promise.all([ + loadTraitNames([general.personalCode], 'personality'), + loadTraitNames([general.specialCode], 'domestic'), + loadTraitNames([general.special2Code], 'war'), + ]); + const metaRecord = asRecord(general.meta); const worldConfig = asRecord(worldState?.config); const constValues = asRecord(worldConfig.const ?? worldConfig.consts); @@ -303,9 +332,9 @@ export const getGeneralContext = async (ctx: GameApiContext) => { turnTime: general.turnTime.toISOString(), crewTypeId: general.crewTypeId, traits: { - personal: general.personalCode, - specialWar: general.specialCode, - specialDomestic: general.special2Code, + personal: resolveTraitDisplayName(general.personalCode, personalityNames), + specialDomestic: resolveTraitDisplayName(general.specialCode, domesticNames), + specialWar: resolveTraitDisplayName(general.special2Code, warNames), }, progression: { experienceLevel: readNumber(metaRecord.explevel, 0), diff --git a/app/game-api/test/inGameMenuPermissions.test.ts b/app/game-api/test/inGameMenuPermissions.test.ts index a8f79a25..77c9dcd6 100644 --- a/app/game-api/test/inGameMenuPermissions.test.ts +++ b/app/game-api/test/inGameMenuPermissions.test.ts @@ -248,6 +248,40 @@ describe('in-game my information ownership', () => { ); }); + it('returns the Ref-style neutral nation frame and trait display names on the main read model', async () => { + const fixture = createContext({ + me: buildGeneral({ + nationId: 0, + officerLevel: 0, + personalCode: 'che_안전', + specialCode: 'che_상재', + special2Code: 'che_신산', + }), + }); + + await expect(appRouter.createCaller(fixture.context).general.me()).resolves.toMatchObject({ + general: { + traits: { + personal: '안전', + specialDomestic: '상재', + specialWar: '신산', + }, + }, + nation: { + id: 0, + name: '재야', + color: '#000000', + level: 0, + gold: 0, + rice: 0, + tech: 0, + typeCode: 'None', + capitalCityId: null, + }, + }); + expect(fixture.db.nation.findUnique).not.toHaveBeenCalled(); + }); + it('reads legacy top-level settings and dispatches only the session-owned general', async () => { const requestCommand = vi.fn(async () => ({ type: 'setMySetting', ok: true, generalId: 7 })); const fixture = createContext({ requestCommand }); diff --git a/app/game-frontend/e2e/inGameInfo.spec.ts b/app/game-frontend/e2e/inGameInfo.spec.ts index a94af82a..a4d3dec3 100644 --- a/app/game-frontend/e2e/inGameInfo.spec.ts +++ b/app/game-frontend/e2e/inGameInfo.spec.ts @@ -135,6 +135,7 @@ const install = async ( page: Page, mode: 'member' | 'wanderer' | 'admin' = 'member', trade: number | null = 100, + globalNationCount = 2, mapFixture = map ) => { await page.addInitScript((profile) => { @@ -265,7 +266,7 @@ const install = async ( generalCount: 1, cities: ['허창'], }, - ], + ].slice(0, globalNationCount), diplomacy: { 1: { 1: 2, 2: 0 }, 2: { 1: 0, 2: 2 } }, conflict: [], map: mapFixture, @@ -450,6 +451,26 @@ test('global-info renders the ref nation summary columns beside the map', async await page.screenshot({ path: resolve(artifactRoot, 'core-global-info-city-hover.png'), fullPage: true }); } + await expect + .poll(async () => { + const [matrixWrapBox, matrixBox] = await Promise.all([ + page.locator('.matrix-wrap').boundingBox(), + page.locator('.matrix').boundingBox(), + ]); + if (!matrixWrapBox || !matrixBox) return null; + return Math.abs(matrixWrapBox.height - matrixBox.height); + }) + .toBeLessThan(1); + + await expect + .poll(() => + page + .locator('.map-area .city-icon') + .evaluateAll((images: HTMLImageElement[]) => + images.every((image) => image.complete && image.naturalWidth > 0) + ) + ) + .toBe(true); const castleGeometry = await page.locator('.map-area').evaluate((mapArea) => { const mapRect = mapArea.getBoundingClientRect(); return Array.from(mapArea.querySelectorAll('.city-icon')).map((image) => { @@ -595,7 +616,7 @@ test('global-info renders the ref nation summary columns beside the map', async }); test('map title keeps the ref early-game restriction boundary and color', async ({ page }) => { - await install(page, 'member', 100, { ...map, startYear: 198 }); + await install(page, 'member', 100, 2, { ...map, startYear: 198 }); await page.setViewportSize({ width: 1200, height: 900 }); await go(page, 'global-info'); @@ -607,6 +628,62 @@ test('map title keeps the ref early-game restriction boundary and color', async ); }); +test('global-info diplomacy height follows the active nation count', async ({ page }) => { + await install(page, 'member', 100, 1); + await go(page, 'global-info'); + + const matrixWrap = page.locator('.matrix-wrap'); + const matrix = page.locator('.matrix'); + const mapSection = page.locator('.map-section'); + await expect(matrix.locator('tbody tr')).toHaveCount(1); + + for (const viewport of [ + { name: 'desktop', width: 1200, height: 900 }, + { name: 'mobile', width: 390, height: 844 }, + ]) { + await page.setViewportSize(viewport); + await expect + .poll(async () => { + const [wrapBox, matrixBox] = await Promise.all([matrixWrap.boundingBox(), matrix.boundingBox()]); + if (!wrapBox || !matrixBox) return null; + return Math.abs(wrapBox.height - matrixBox.height); + }) + .toBeLessThan(1); + + const geometry = await page.evaluate(() => { + const rect = (selector: string) => document.querySelector(selector)?.getBoundingClientRect(); + const diplomacy = rect('.section'); + const matrix = rect('.matrix'); + const matrixWrap = rect('.matrix-wrap'); + const mapSection = rect('.map-section'); + return { + diplomacyHeight: diplomacy?.height ?? null, + matrixHeight: matrix?.height ?? null, + matrixWrapHeight: matrixWrap?.height ?? null, + gapToMap: matrixWrap && mapSection ? mapSection.top - matrixWrap.bottom : null, + }; + }); + expect(geometry.matrixHeight).not.toBeNull(); + expect(geometry.matrixWrapHeight).toBeCloseTo(geometry.matrixHeight!, 0); + expect(geometry.diplomacyHeight).toBeLessThan(200); + expect(geometry.gapToMap).toBe(21); + await expect(mapSection).toBeInViewport(); + + if (artifactRoot) { + await mkdir(artifactRoot, { recursive: true }); + await writeFile( + resolve(artifactRoot, `core-global-info-one-nation-${viewport.name}.json`), + `${JSON.stringify(geometry, null, 2)}\n`, + 'utf8' + ); + await page.screenshot({ + path: resolve(artifactRoot, `core-global-info-one-nation-${viewport.name}.png`), + fullPage: true, + }); + } + } +}); + test('current-city hides values and general rows for a wandering user', async ({ page }) => { await install(page, 'wanderer'); await go(page, 'current-city'); diff --git a/app/game-frontend/e2e/inGameMenus.spec.ts b/app/game-frontend/e2e/inGameMenus.spec.ts index 9992ea83..33ceb619 100644 --- a/app/game-frontend/e2e/inGameMenus.spec.ts +++ b/app/game-frontend/e2e/inGameMenus.spec.ts @@ -45,6 +45,7 @@ type FixtureState = { adjustIconInputs?: Array>; joinConfig?: Record; createGeneralInputs?: Array>; + mainTraits?: { personal: string; specialDomestic: string; specialWar: string }; }; type TrpcRequestPayload = { @@ -75,7 +76,7 @@ const myGeneral = (state: FixtureState) => ({ age: 30, turnTime: '2026-01-01 00:10:00', crewTypeId: 1, - traits: { personal: 'None', specialDomestic: 'None', specialWar: 'None' }, + traits: state.mainTraits ?? { personal: '-', specialDomestic: '-', specialWar: '-' }, progression: { experienceLevel: 1, dedicationLevel: 2, @@ -86,7 +87,19 @@ const myGeneral = (state: FixtureState) => ({ items: { horse: 'che_명마', weapon: null, book: null, item: null }, }, city: { id: 1, name: '업', level: 8, nationId: 1 }, - nation: { id: 1, name: '위', color: '#777777', level: 3 }, + nation: state.buildNationCandidateEnabled + ? { + id: 0, + name: '재야', + color: '#000000', + level: 0, + gold: 0, + rice: 0, + tech: 0, + typeCode: 'None', + capitalCityId: null, + } + : { id: 1, name: '위', color: '#777777', level: 3 }, settings: { tnmt: 0, defence_train: 80, @@ -221,6 +234,25 @@ const install = async (page: Page, state: FixtureState) => { state.createGeneralInputs?.push(jsonInput); return response({ generalId: 9 }); } + if (operation === 'dashboard.getContextBundleDelta') { + return response({ + context: { + kind: 'snapshot', + revision: 'AAAAAAAAAAAAAAAAAAAAAA', + data: myGeneral(state), + }, + commandTable: { + kind: 'snapshot', + revision: 'BBBBBBBBBBBBBBBBBBBBBB', + data: { general: [], nation: [] }, + }, + boardAccess: { + kind: 'snapshot', + revision: 'CCCCCCCCCCCCCCCCCCCCCC', + data: { permission: 4, canMeeting: true, canSecret: true }, + }, + }); + } if (operation === 'general.me') { state.generalMeQueries = (state.generalMeQueries ?? 0) + 1; return response(myGeneral(state)); @@ -437,6 +469,49 @@ test('정화된 국가 방침은 실행 가능한 속성 없이 Chromium에 표 .toBeUndefined(); }); +test('재야 메인은 국가 틀과 성격·특기 표기명을 Chromium에 표시한다', async ({ page }) => { + const state: FixtureState = { + permission: 'member', + myset: 0, + buildNationCandidateEnabled: true, + mainTraits: { personal: '안전', specialDomestic: '상재', specialWar: '신산' }, + settingMutations: [], + accessPages: [], + }; + await install(page, state); + await page.setViewportSize({ width: 1000, height: 900 }); + await page.goto(''); + + const nationCard = page.locator('.nation-card'); + await expect(nationCard.locator('.title')).toHaveText('재야'); + await expect(nationCard.locator('.empty')).toHaveCount(0); + await expect(nationCard.locator('.grid strong')).toHaveText(Array.from({ length: 6 }, () => '해당 없음')); + + const generalCard = page.locator('.general-card'); + await expect(generalCard).toContainText('성격안전'); + await expect(generalCard).toContainText('전투특기신산'); + await expect(generalCard).toContainText('내정특기상재'); + await expect(generalCard).not.toContainText('che_'); + + const geometry = await nationCard.evaluate((element) => { + const rect = element.getBoundingClientRect(); + const title = element.querySelector('.title')!; + return { + width: rect.width, + height: rect.height, + titleBackground: getComputedStyle(title).backgroundColor, + placeholderCount: [...element.querySelectorAll('.grid strong')].filter( + (cell) => cell.textContent?.trim() === '해당 없음' + ).length, + }; + }); + expect(geometry.width).toBeGreaterThan(0); + expect(geometry.height).toBeGreaterThan(0); + expect(geometry.titleBackground).toBe('rgb(0, 0, 0)'); + expect(geometry.placeholderCount).toBe(6); + await persistParityArtifact(page, 'main-neutral-trait-display', geometry); +}); + test('접속량정보 keeps the legacy public 1016px chart geometry', async ({ page }) => { const state: FixtureState = { permission: 'member', myset: 0, settingMutations: [], accessPages: [] }; await install(page, state); diff --git a/app/game-frontend/src/components/main/NationBasicCard.vue b/app/game-frontend/src/components/main/NationBasicCard.vue index 5efa9097..6470648f 100644 --- a/app/game-frontend/src/components/main/NationBasicCard.vue +++ b/app/game-frontend/src/components/main/NationBasicCard.vue @@ -31,15 +31,19 @@ const props = defineProps<{ class="title" :style="{ backgroundColor: props.nation.color, color: legacyNationTextColor(props.nation.color) }" > - {{ props.nation.name }} (Lv {{ props.nation.level }}) + {{ props.nation.name }}
- 국고{{ props.nation.gold.toLocaleString() }} 국량{{ props.nation.rice.toLocaleString() }} 기술{{ props.nation.tech.toLocaleString() }} 체제{{ props.nation.typeCode }} 수도{{ props.nation.capitalCityId ?? '-' }} 국가 등급{{ props.nation.level }} + 국고{{ props.nation.id === 0 ? '해당 없음' : props.nation.gold.toLocaleString() }} + 국량{{ props.nation.id === 0 ? '해당 없음' : props.nation.rice.toLocaleString() }} + 기술{{ props.nation.id === 0 ? '해당 없음' : props.nation.tech.toLocaleString() }} + 체제{{ props.nation.id === 0 ? '해당 없음' : props.nation.typeCode }} + 수도{{ props.nation.id === 0 ? '해당 없음' : (props.nation.capitalCityId ?? '-') }} + 국가 등급{{ props.nation.id === 0 ? '해당 없음' : props.nation.level }}
diff --git a/app/game-frontend/src/views/GlobalInfoView.vue b/app/game-frontend/src/views/GlobalInfoView.vue index 67dcbc9b..b9ac9f48 100644 --- a/app/game-frontend/src/views/GlobalInfoView.vue +++ b/app/game-frontend/src/views/GlobalInfoView.vue @@ -1,5 +1,5 @@