From d7b15e57815481b6f1ba026000d5ce378acbd29b Mon Sep 17 00:00:00 2001 From: hided62 Date: Sun, 2 Aug 2026 04:51:14 +0000 Subject: [PATCH] fix(game): restore global info nation summary --- app/game-api/src/router/world/index.ts | 25 ++++--- app/game-api/test/inGameInfoRouter.test.ts | 54 +++++++++++++- app/game-frontend/e2e/inGameInfo.spec.ts | 65 ++++++++++++++++ .../src/views/GlobalInfoView.vue | 74 +++++++++++++++++-- 4 files changed, 199 insertions(+), 19 deletions(-) diff --git a/app/game-api/src/router/world/index.ts b/app/game-api/src/router/world/index.ts index 63a9496..2c9d29f 100644 --- a/app/game-api/src/router/world/index.ts +++ b/app/game-api/src/router/world/index.ts @@ -82,15 +82,22 @@ export const worldRouter = router({ throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'World state is not initialized.' }); } const nationRows = nations - .map((nation) => ({ - id: nation.id, - name: nation.name, - color: nation.color, - capitalCityId: nation.capitalCityId ?? 0, - level: nation.level, - power: typeof asRecord(nation.meta).power === 'number' ? Number(asRecord(nation.meta).power) : 0, - cities: cities.filter((city) => city.nationId === nation.id).map((city) => city.name), - })) + .map((nation) => { + const meta = asRecord(nation.meta); + return { + id: nation.id, + name: nation.name, + color: nation.color, + capitalCityId: nation.capitalCityId ?? 0, + level: nation.level, + power: typeof meta.power === 'number' && Number.isFinite(meta.power) ? meta.power : 0, + generalCount: + typeof meta.gennum === 'number' && Number.isFinite(meta.gennum) + ? Math.max(0, Math.trunc(meta.gennum)) + : 0, + cities: cities.filter((city) => city.nationId === nation.id).map((city) => city.name), + }; + }) .sort((left, right) => right.power - left.power || left.id - right.id); const matrix: Record> = {}; for (const nation of nationRows) { diff --git a/app/game-api/test/inGameInfoRouter.test.ts b/app/game-api/test/inGameInfoRouter.test.ts index 9cb7c5d..18f37b2 100644 --- a/app/game-api/test/inGameInfoRouter.test.ts +++ b/app/game-api/test/inGameInfoRouter.test.ts @@ -106,6 +106,7 @@ const context = ( findFirst: vi.fn(async ({ where }: { where: { userId: string } }) => where.userId === me.userId ? me : null ), + findUnique: vi.fn(async ({ where }: { where: { id: number } }) => (where.id === me.id ? me : null)), findMany: vi.fn(async (args: { where?: Record; select?: Record }) => { if (args.where?.nationId === 1 && args.select?.cityId) return [ @@ -128,14 +129,52 @@ const context = ( meta: options.nationMeta ?? {}, })), findMany: vi.fn(async () => [ - { id: 1, name: '아국', color: '#008000', level: 1, capitalCityId: 1, meta: { power: 100 } }, - { id: 2, name: '적국', color: '#800000', level: 1, capitalCityId: 2, meta: { power: 90 } }, + { + id: 1, + name: '아국', + color: '#008000', + level: 1, + capitalCityId: 1, + meta: { power: 100, gennum: 4 }, + }, + { + id: 2, + name: '적국', + color: '#800000', + level: 1, + capitalCityId: 2, + meta: { power: 90, gennum: 3 }, + }, ]), }, city: { findMany: vi.fn(async () => cities) }, - worldState: { findFirst: vi.fn(async () => ({ meta: { turntime: '2026-01-01' } })) }, + worldState: { + findFirst: vi.fn(async () => ({ + currentYear: 200, + currentMonth: 1, + config: { startYear: 180 }, + meta: { turntime: '2026-01-01' }, + })), + }, generalTurn: { findMany: vi.fn(async () => []) }, diplomacy: { findMany: vi.fn(async () => []) }, + $queryRaw: vi + .fn() + .mockResolvedValueOnce( + cities.map((item) => ({ + id: item.id, + level: item.level, + nationId: item.nationId, + region: item.region, + supplyState: item.supplyState, + meta: item.meta, + })) + ) + .mockResolvedValueOnce([ + { id: 1, name: '아국', color: '#008000', capitalCityId: 1, meta: {} }, + { id: 2, name: '적국', color: '#800000', capitalCityId: 2, meta: {} }, + ]) + .mockResolvedValueOnce([{ cityId: me.cityId }]), }; const redis = { get: vi.fn(async () => null), set: vi.fn(async () => null) } as unknown as RedisConnector['client']; const accessTokenStore = new RedisAccessTokenStore(redis, 'che:default'); @@ -156,6 +195,15 @@ const context = ( }; describe('in-game information permissions', () => { + it('returns the ref nation summary fields in descending power order', async () => { + const result = await appRouter.createCaller(context()).world.getGlobalInfo(); + + expect(result.nations).toEqual([ + expect.objectContaining({ id: 1, name: '아국', power: 100, generalCount: 4, cities: ['도시1', '도시80'] }), + expect.objectContaining({ id: 2, name: '적국', power: 90, generalCount: 3, cities: ['도시2', '도시3'] }), + ]); + }); + it('does not expose nation-only pages to a wandering general', async () => { const caller = appRouter.createCaller(context({ me: general({ nationId: 0, officerLevel: 0 }) })); await expect(caller.nation.getNationInfo()).rejects.toMatchObject({ code: 'PRECONDITION_FAILED' }); diff --git a/app/game-frontend/e2e/inGameInfo.spec.ts b/app/game-frontend/e2e/inGameInfo.spec.ts index 528ddb9..a2c34ae 100644 --- a/app/game-frontend/e2e/inGameInfo.spec.ts +++ b/app/game-frontend/e2e/inGameInfo.spec.ts @@ -219,6 +219,7 @@ const install = async (page: Page, mode: 'member' | 'wanderer' | 'admin' = 'memb capitalCityId: 1, level: 1, power: 1234, + generalCount: 2, cities: ['업'], }, { @@ -228,6 +229,7 @@ const install = async (page: Page, mode: 'member' | 'wanderer' | 'admin' = 'memb capitalCityId: 2, level: 1, power: 1000, + generalCount: 1, cities: ['허창'], }, ], @@ -350,6 +352,69 @@ test('four legacy menu pages keep the 1000px desktop table contract', async ({ p } }); +test('global-info renders the ref nation summary columns beside the map', async ({ page }) => { + await install(page); + await page.setViewportSize({ width: 1200, height: 900 }); + await go(page, 'global-info'); + + const summary = page.locator('.simple-nation-list'); + await expect(summary).toBeVisible(); + await expect(summary.locator('thead')).toContainText('국명'); + await expect(summary.locator('thead')).toContainText('국력'); + await expect(summary.locator('thead')).toContainText('장수'); + await expect(summary.locator('thead')).toContainText('속령'); + await expect(summary.locator('tbody tr').first()).toHaveText(/아국\s*1,234\s*2\s*1/u); + await expect(summary.locator('tbody tr').first().locator('td').last()).toHaveAttribute('title', '업'); + + const geometry = await summary.evaluate((element) => { + const rect = element.getBoundingClientRect(); + const headings = Array.from(element.querySelectorAll('th')).map((heading) => heading.getBoundingClientRect().width); + return { x: rect.x, width: rect.width, headings }; + }); + expect(geometry).toMatchObject({ x: 800, width: 300 }); + expect(geometry.headings[0]).toBeCloseTo((300 * 44) / 97, 0); + expect(geometry.headings[1]).toBeCloseTo((300 * 23) / 97, 0); + expect(geometry.headings[2]).toBeCloseTo((300 * 15) / 97, 0); + expect(geometry.headings[3]).toBeCloseTo((300 * 15) / 97, 0); + + if (artifactRoot) { + await mkdir(artifactRoot, { recursive: true }); + await writeFile( + resolve(artifactRoot, 'core-global-info-computed-dom.json'), + `${JSON.stringify( + { + geometry, + headings: await summary.locator('th').allTextContents(), + rows: await summary.locator('tbody tr').allTextContents(), + cityTitles: await summary.locator('tbody td:last-child').evaluateAll((cells) => + cells.map((cell) => cell.getAttribute('title')) + ), + }, + null, + 2 + )}\n`, + 'utf8' + ); + await page.screenshot({ path: resolve(artifactRoot, 'core-global-info-desktop.png'), fullPage: true }); + } + + await page.setViewportSize({ width: 390, height: 844 }); + const mobileGeometry = await page.locator('.map-grid').evaluate((element) => { + const map = element.querySelector('.map-viewer')?.getBoundingClientRect(); + const summary = element.querySelector('.simple-nation-list')?.getBoundingClientRect(); + return { + map: map ? { y: map.y, width: map.width, bottom: map.bottom } : null, + summary: summary ? { y: summary.y, width: summary.width } : null, + }; + }); + expect(mobileGeometry.map?.width).toBe(500); + expect(mobileGeometry.summary?.width).toBe(500); + expect(mobileGeometry.summary?.y).toBe(mobileGeometry.map?.bottom); + if (artifactRoot) { + await page.screenshot({ path: resolve(artifactRoot, 'core-global-info-mobile.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/src/views/GlobalInfoView.vue b/app/game-frontend/src/views/GlobalInfoView.vue index 6abd86c..582230f 100644 --- a/app/game-frontend/src/views/GlobalInfoView.vue +++ b/app/game-frontend/src/views/GlobalInfoView.vue @@ -11,6 +11,18 @@ const error = ref(''); const state = (value: number) => ({ 0: '★', 1: '▲', 2: '', 7: '@' })[value] ?? 'ㆍ'; const stateClass = (value: number) => `state-${value}`; const nationMap = computed(() => new Map(data.value?.nations.map((nation) => [nation.id, nation]) ?? [])); +const isBrightColor = (color: string): boolean => { + const normalized = color.trim().replace(/^#/u, ''); + if (!/^[0-9a-f]{6}$/iu.test(normalized)) return false; + const red = Number.parseInt(normalized.slice(0, 2), 16); + const green = Number.parseInt(normalized.slice(2, 4), 16); + const blue = Number.parseInt(normalized.slice(4, 6), 16); + return red * 0.299 + green * 0.587 + blue * 0.114 > 170; +}; +const nationNameStyle = (color: string) => ({ + backgroundColor: color, + color: isBrightColor(color) ? '#000' : '#fff', +}); onMounted(async () => { try { [data.value, layout.value] = await Promise.all([ @@ -96,10 +108,29 @@ onMounted(async () => {
-
- 【{{ nation.name }}】 {{ nation.power.toLocaleString() - }}
{{ nation.cities.join(', ') }} -
+ + + + + + + + + + + + + + + + + +
국명국력장수속령
{{ nation.name }}{{ nation.power.toLocaleString() }}{{ nation.generalCount.toLocaleString() }} + {{ nation.cities.length.toLocaleString() }} +
@@ -218,9 +249,38 @@ onMounted(async () => { display: grid; grid-template-columns: 700px 300px; } -.nation-list > div { - padding: 6px; - border-bottom: 1px solid #666; +.simple-nation-list { + width: 100%; + border-collapse: collapse; +} +.simple-nation-list thead { + background-color: #ccc; + color: #000; + text-align: center; +} +.simple-nation-list th { + border: 0; + border-left: 1px solid gray; + padding: 2px 6px; + font-weight: 700; +} +.simple-nation-list td { + border: 0; + border-left: 1px solid gray; + padding: 1px 6px; + text-align: right; +} +.simple-nation-list td:first-child { + text-align: left; +} +.nation-name-column { + width: 44%; +} +.nation-power-column { + width: 23%; +} +.nation-count-column { + width: 15%; } .footer { margin-top: 20px;