diff --git a/app/game-api/src/router/world/directory.ts b/app/game-api/src/router/world/directory.ts index 866df89c..58dbb72a 100644 --- a/app/game-api/src/router/world/directory.ts +++ b/app/game-api/src/router/world/directory.ts @@ -134,6 +134,7 @@ export const getNationDirectory = authedProcedure.query(async ({ ctx }) => { generalsByNation.set(general.nationId, list); } const citiesByNation = new Map(); + const cityNameById = new Map(cities.map((city) => [city.id, city.name] as const)); for (const city of cities) { const list = citiesByNation.get(city.nationId) ?? []; list.push(city); @@ -153,6 +154,7 @@ export const getNationDirectory = authedProcedure.query(async ({ ctx }) => { : null, }; }); + const ruler = officers.find((officer) => officer.officerLevel === 12)?.general; const secretPermissions = nationGenerals.map((general) => ({ general, permission: resolveSecretPermission( @@ -178,6 +180,7 @@ export const getNationDirectory = authedProcedure.query(async ({ ctx }) => { }, power: readMetaNumber(nation.meta, 'power'), capitalCityId: nation.capitalCityId ?? 0, + rulerCityName: ruler ? (cityNameById.get(ruler.cityId) ?? null) : null, generalCount: readMetaNumber(nation.meta, 'gennum', nationGenerals.length), cityCount: nationCities.length, officers, diff --git a/app/game-api/test/directoryRouter.test.ts b/app/game-api/test/directoryRouter.test.ts index 3fe3e2e0..dc24942f 100644 --- a/app/game-api/test/directoryRouter.test.ts +++ b/app/game-api/test/directoryRouter.test.ts @@ -183,6 +183,11 @@ const createContext = ( auth?: GameSessionTokenPayload | null; me?: GeneralRow | null; isUnited?: number; + directory?: { + nations: typeof nationRows; + generals: typeof globalGenerals; + cities: Array<{ id: number; name: string; nationId: number }>; + }; } = {} ): { context: GameApiContext; requestCommand: ReturnType } => { const token = options.auth === undefined ? auth() : options.auth; @@ -192,17 +197,20 @@ const createContext = ( const db = { general: { findFirst: vi.fn(async () => me), - findMany: vi.fn(async () => globalGenerals), + findMany: vi.fn(async () => options.directory?.generals ?? globalGenerals), }, nation: { - findMany: vi.fn(async () => nationRows), + findMany: vi.fn(async () => options.directory?.nations ?? nationRows), }, city: { - findMany: vi.fn(async () => [ - { id: 1, name: '허창', nationId: 1 }, - { id: 2, name: '성도', nationId: 2 }, - { id: 3, name: '낙양', nationId: 0 }, - ]), + findMany: vi.fn( + async () => + options.directory?.cities ?? [ + { id: 1, name: '허창', nationId: 1 }, + { id: 2, name: '성도', nationId: 2 }, + { id: 3, name: '낙양', nationId: 0 }, + ] + ), }, generalAccessLog: { findMany: vi.fn(async () => [ @@ -307,6 +315,43 @@ describe('legacy global nation/general directories', () => { expect(result[2]).toMatchObject({ name: '재 야', generalCount: 1, cityCount: 1 }); }); + it('projects a level-zero nation location from its ruler city even when that city belongs to another nation', async () => { + const roamingNation = { + ...nationRows[0]!, + id: 3, + name: '방랑군', + capitalCityId: 0, + chiefGeneralId: 40, + level: 0, + meta: { power: 500 }, + }; + const roamingRuler = directoryGeneral({ + id: 40, + name: '방랑군주', + nationId: 3, + cityId: 2, + officerLevel: 12, + }); + const result = await appRouter + .createCaller( + createContext({ + directory: { + nations: [roamingNation], + generals: [roamingRuler], + cities: [{ id: 2, name: '성도', nationId: 2 }], + }, + }).context + ) + .world.getNationDirectory(); + + expect(result[0]).toMatchObject({ + id: 3, + level: 0, + cityCount: 0, + rulerCityName: '성도', + }); + }); + it('implements all legacy sort boundaries and never exposes user ids or raw metadata', async () => { const caller = appRouter.createCaller(createContext().context); const defaultResult = await caller.world.getGeneralDirectory(); diff --git a/app/game-frontend/e2e/directoryLists.spec.ts b/app/game-frontend/e2e/directoryLists.spec.ts index 311cb126..a769b899 100644 --- a/app/game-frontend/e2e/directoryLists.spec.ts +++ b/app/game-frontend/e2e/directoryLists.spec.ts @@ -200,7 +200,8 @@ const install = async ( mode: 'general' | 'no-general' | 'error-after-load' = 'general', accessPages: string[] = [], requestedOperations: string[] = [], - directoryRows = generals + directoryRows = generals, + nationDirectoryRows = nationDirectory ) => { let generalDirectoryCalls = 0; await page.addInitScript((profile) => { @@ -249,7 +250,7 @@ const install = async ( return response({ myGeneral: mode === 'no-general' ? null : { id: 1, name: '조회자' } }); } if (operation === 'join.getConfig') return response({}); - if (operation === 'world.getNationDirectory') return response(nationDirectory); + if (operation === 'world.getNationDirectory') return response(nationDirectoryRows); if (operation === 'public.recordAccess') { const payload = requestBody?.[String(operationIndex)]; const pageName = payload?.json?.page ?? payload?.page; @@ -322,6 +323,31 @@ const installAccessBoundary = async (page: Page, accessPages: string[]) => { }); }; +test('a level-zero nation shows the ruler current city even without territory there', async ({ page }) => { + const roamingNation = { + ...nationDirectory[0]!, + id: 3, + name: '방랑군', + level: 0, + capitalCityId: 0, + cityCount: 0, + officers: Array.from({ length: 8 }, (_, index) => ({ + officerLevel: 12 - index, + general: index === 0 ? { id: 40, name: '방랑군주', npcState: 0, cityId: 2 } : null, + })), + rulerCityName: '성도', + cities: [], + generals: [{ id: 40, name: '방랑군주', npcState: 0 }], + }; + await install(page, 'general', [], [], generals, [roamingNation]); + + await page.goto('nation-list'); + + const table = page.locator('[data-nation-id="3"]'); + await expect(table).toContainText('현재 위치 : 성도'); + await expect(table.locator('.roaming-city')).toHaveCSS('color', 'rgb(255, 255, 0)'); +}); + test('PYA-scale general directory mounts only the active responsive layout and defers portraits', async ( { page }, testInfo diff --git a/app/game-frontend/src/views/NationListView.vue b/app/game-frontend/src/views/NationListView.vue index 552a99d8..3d932003 100644 --- a/app/game-frontend/src/views/NationListView.vue +++ b/app/game-frontend/src/views/NationListView.vue @@ -51,10 +51,6 @@ const displayAmbassadorName = (nation: Nation, name: string) => { return general ? displayGeneralName(general) : name; }; -const roamingCityName = (nation: Nation): string => { - const chief = officerName(nation, 12); - return nation.cities.find((city) => city.id === chief?.cityId)?.name ?? '-'; -}; const closeWindow = () => window.close(); const officerLevelAt = (row: number, column: number, columns: number) => 13 - ((row - 1) * columns + column); @@ -275,7 +271,8 @@ onBeforeUnmount(() => { 현재 위치 : + {{ nation.rulerCityName ?? '-' }}