From a318e7d6781dbd18f839a1027bddb6a8cd26fc87 Mon Sep 17 00:00:00 2001 From: hided62 Date: Thu, 13 Aug 2026 18:04:11 +0000 Subject: [PATCH] feat: add Ref city hover details to public maps --- app/game-api/src/maps/mapDefinition.ts | 8 +- app/game-api/src/maps/mapLayout.ts | 25 ++++- app/game-api/test/mapLayout.test.ts | 16 ++- app/game-engine/src/scenario/mapLoader.ts | 21 +++- .../e2e/public-map-tabs.spec.ts | 53 ++++++++- .../src/components/MapPreview.vue | 104 ++++++++++++++++-- 6 files changed, 201 insertions(+), 26 deletions(-) diff --git a/app/game-api/src/maps/mapDefinition.ts b/app/game-api/src/maps/mapDefinition.ts index bec9cf68..9b8621e3 100644 --- a/app/game-api/src/maps/mapDefinition.ts +++ b/app/game-api/src/maps/mapDefinition.ts @@ -1,4 +1,7 @@ -import { loadMapDefinitionByName as loadRuntimeMapDefinitionByName } from '@sammo-ts/game-engine/scenario/mapLoader.js'; +import { + loadMapDefinitionByName as loadRuntimeMapDefinitionByName, + loadRegionDisplayMapByName as loadRuntimeRegionDisplayMapByName, +} from '@sammo-ts/game-engine/scenario/mapLoader.js'; import type { MapDefinition } from '@sammo-ts/logic'; const mapCache = new Map(); @@ -15,3 +18,6 @@ export const loadMapDefinitionByName = async (mapName: string): Promise> => + loadRuntimeRegionDisplayMapByName(mapName); diff --git a/app/game-api/src/maps/mapLayout.ts b/app/game-api/src/maps/mapLayout.ts index ded889c0..33d116d8 100644 --- a/app/game-api/src/maps/mapLayout.ts +++ b/app/game-api/src/maps/mapLayout.ts @@ -1,7 +1,7 @@ import { loadScenarioDefinitionById } from '@sammo-ts/game-engine/scenario/scenarioLoader.js'; import type { ScenarioDefinition } from '@sammo-ts/logic'; -import { loadMapDefinitionByName } from './mapDefinition.js'; +import { loadMapDefinitionByName, loadRegionDisplayMapByName } from './mapDefinition.js'; export interface MapLayoutCity { id: number; @@ -23,10 +23,22 @@ export interface MapLayout { export interface MapLayoutLoaderOptions { loadScenario?: (scenarioId: number) => Promise; loadMap?: typeof loadMapDefinitionByName; + loadRegionMap?: typeof loadRegionDisplayMapByName; } const layoutCache = new Map(); +const CITY_LEVEL_MAP: Record = { + 1: '수', + 2: '진', + 3: '관', + 4: '이', + 5: '소', + 6: '중', + 7: '대', + 8: '특', +}; + const parseScenarioId = (scenario: string): number | null => { const normalized = scenario.replace(/^scenario_/i, '').replace(/\.json$/i, ''); if (!/^\d+$/.test(normalized)) { @@ -56,13 +68,16 @@ const resolveMapName = async ( export const loadMapLayout = async (scenario: string, options: MapLayoutLoaderOptions = {}): Promise => { const mapName = await resolveMapName(scenario, options.loadScenario ?? loadScenarioDefinitionById); - const useCache = !options.loadScenario && !options.loadMap; + const useCache = !options.loadScenario && !options.loadMap && !options.loadRegionMap; const cached = useCache ? layoutCache.get(mapName) : undefined; if (cached) { return cached; } - const map = await (options.loadMap ?? loadMapDefinitionByName)(mapName); + const [map, regionMap] = await Promise.all([ + (options.loadMap ?? loadMapDefinitionByName)(mapName), + (options.loadRegionMap ?? loadRegionDisplayMapByName)(mapName), + ]); const layout: MapLayout = { mapName, cityList: map.cities.map((city) => ({ @@ -74,8 +89,8 @@ export const loadMapLayout = async (scenario: string, options: MapLayoutLoaderOp y: city.position.y, path: [...city.connections], })), - regionMap: {}, - levelMap: {}, + regionMap, + levelMap: CITY_LEVEL_MAP, }; if (useCache) { diff --git a/app/game-api/test/mapLayout.test.ts b/app/game-api/test/mapLayout.test.ts index d924ae6b..1075d3aa 100644 --- a/app/game-api/test/mapLayout.test.ts +++ b/app/game-api/test/mapLayout.test.ts @@ -1,8 +1,14 @@ import { describe, expect, it, vi } from 'vitest'; +import { loadRegionDisplayMapByName } from '../src/maps/mapDefinition.js'; import { loadMapLayout } from '../src/maps/mapLayout.js'; describe('map layout resource adapter', () => { + it('loads the Ref theme region labels from the shared resource', async () => { + await expect(loadRegionDisplayMapByName('che')).resolves.toMatchObject({ 1: '하북', 8: '동이' }); + await expect(loadRegionDisplayMapByName('chess')).resolves.toMatchObject({ 1: '킹', 7: '빈칸' }); + }); + it('resolves the scenario map through the shared runtime loaders', async () => { const loadScenario = vi.fn().mockResolvedValue({ config: { environment: { mapName: 'custom-map' } }, @@ -20,7 +26,9 @@ describe('map layout resource adapter', () => { ], }); - await expect(loadMapLayout('scenario_2601.json', { loadScenario, loadMap })).resolves.toEqual({ + const loadRegionMap = vi.fn().mockResolvedValue({ 2: '테스트권역' }); + + await expect(loadMapLayout('scenario_2601.json', { loadScenario, loadMap, loadRegionMap })).resolves.toEqual({ mapName: 'custom-map', cityList: [ { @@ -33,11 +41,12 @@ describe('map layout resource adapter', () => { path: [8], }, ], - regionMap: {}, - levelMap: {}, + regionMap: { 2: '테스트권역' }, + levelMap: { 1: '수', 2: '진', 3: '관', 4: '이', 5: '소', 6: '중', 7: '대', 8: '특' }, }); expect(loadScenario).toHaveBeenCalledWith(2601); expect(loadMap).toHaveBeenCalledWith('custom-map'); + expect(loadRegionMap).toHaveBeenCalledWith('custom-map'); }); it('retains the che fallback for unknown preserved scenarios', async () => { @@ -47,6 +56,7 @@ describe('map layout resource adapter', () => { loadMapLayout('custom-runtime', { loadScenario: vi.fn(), loadMap, + loadRegionMap: vi.fn().mockResolvedValue({}), }) ).resolves.toMatchObject({ mapName: 'che' }); expect(loadMap).toHaveBeenCalledWith('che'); diff --git a/app/game-engine/src/scenario/mapLoader.ts b/app/game-engine/src/scenario/mapLoader.ts index 11c8c065..7d37325d 100644 --- a/app/game-engine/src/scenario/mapLoader.ts +++ b/app/game-engine/src/scenario/mapLoader.ts @@ -1,18 +1,23 @@ import fs from 'node:fs/promises'; import path from 'node:path'; -import { MapDefinitionSchema, type MapDefinition } from '@sammo-ts/logic'; +import { MapDefinitionSchema, RegionMapSchema, type MapDefinition } from '@sammo-ts/logic'; import { resolveWorkspaceRoot } from '../paths.js'; const REPO_ROOT = resolveWorkspaceRoot(); const DEFAULT_MAP_ROOT = path.resolve(REPO_ROOT, 'resources', 'map'); +const DEFAULT_REGION_MAP_PATH = path.resolve(DEFAULT_MAP_ROOT, 'region_map.json'); export interface MapLoaderOptions { mapRoot?: string; filePrefix?: string; } +export interface RegionMapLoaderOptions { + regionMapPath?: string; +} + const readJsonFile = async (filePath: string): Promise => { const raw = await fs.readFile(filePath, 'utf8'); return JSON.parse(raw) as unknown; @@ -34,3 +39,17 @@ export const loadMapDefinitionByName = async (mapName: string, options?: MapLoad const mapPath = resolveMapDefinitionPath(mapName, options); return loadMapDefinition(mapPath); }; + +export const loadRegionDisplayMapByName = async ( + mapName: string, + options?: RegionMapLoaderOptions +): Promise> => { + const raw = await readJsonFile(options?.regionMapPath ?? DEFAULT_REGION_MAP_PATH); + const regionMaps = RegionMapSchema.parse(raw); + const selected = regionMaps[mapName] ?? {}; + return Object.fromEntries( + Object.entries(selected) + .map(([key, value]) => [Number(key), value] as const) + .filter(([key]) => Number.isSafeInteger(key)) + ); +}; diff --git a/app/gateway-frontend/e2e/public-map-tabs.spec.ts b/app/gateway-frontend/e2e/public-map-tabs.spec.ts index 44261a2b..7d8ac615 100644 --- a/app/gateway-frontend/e2e/public-map-tabs.spec.ts +++ b/app/gateway-frontend/e2e/public-map-tabs.spec.ts @@ -223,9 +223,7 @@ test('shows one public map panel and switches it by hover, click, and keyboard', body: rectOf(mapBody), road: rectOf(mapBody.querySelector('[data-testid="map-preview-road"]')), largeCastle: rectOf(mapBody.querySelector('[data-testid="map-preview-castle"]')), - largeNationBackground: rectOf( - mapBody.querySelector('[data-testid="map-preview-city-background"]') - ), + largeNationBackground: rectOf(mapBody.querySelector('[data-testid="map-preview-city-background"]')), }; }); expect(mapGeometry).toEqual({ @@ -253,6 +251,31 @@ test('shows one public map panel and switches it by hover, click, and keyboard', .toEqual([700, 500]); } + const firstCity = panel.getByTestId('map-preview-city').first(); + await expect(firstCity).not.toHaveAttribute('title'); + await firstCity.hover(); + const cityTooltip = panel.getByTestId('map-preview-city-tooltip'); + await expect(cityTooltip).toBeVisible(); + await expect(cityTooltip.locator('.tooltip-city-name')).toHaveText('【중원|특】낙양'); + await expect(cityTooltip.locator('.tooltip-nation-name')).toHaveText('위'); + const tooltipGeometry = await cityTooltip.evaluate((tooltip) => { + const rect = tooltip.getBoundingClientRect(); + const style = getComputedStyle(tooltip); + return { + width: rect.width, + height: rect.height, + backgroundColor: style.backgroundColor, + fontSize: style.fontSize, + lineHeight: style.lineHeight, + }; + }); + expect(tooltipGeometry.width).toBeGreaterThanOrEqual(120); + expect(tooltipGeometry.height).toBe(32); + expect(tooltipGeometry.backgroundColor).toBe('rgb(30, 164, 255)'); + expect(tooltipGeometry.fontSize).toBe('14px'); + expect(tooltipGeometry.lineHeight).toBe('15px'); + await page.screenshot({ path: testInfo.outputPath('public-map-city-hover-desktop.png'), fullPage: true }); + await hweTab.hover(); await expect(hweTab).toHaveAttribute('aria-selected', 'true'); await expect(panel).toContainText('유저 22 / 500'); @@ -276,10 +299,12 @@ test('shows one public map panel and switches it by hover, click, and keyboard', expect(geometry.width).toBeLessThanOrEqual(992); expect(geometry.borderLeft).toBe('1px solid rgb(63, 63, 70)'); expect(geometry.borderTopWidth).toBe('0px'); - expect(geometry.backgroundColor).toBe('rgba(9, 9, 11, 0.498)'); + expect(geometry.backgroundColor).toBe('rgba(9, 9, 11, 0.5)'); await page.screenshot({ path: testInfo.outputPath('public-map-tabs-desktop.png'), fullPage: true }); await testInfo.attach('public-map-tabs-desktop-geometry', { - body: Buffer.from(`${JSON.stringify({ panel: geometry, map: mapGeometry }, null, 2)}\n`), + body: Buffer.from( + `${JSON.stringify({ panel: geometry, map: mapGeometry, tooltip: tooltipGeometry }, null, 2)}\n` + ), contentType: 'application/json', }); }); @@ -307,6 +332,24 @@ test.describe('touch navigation', () => { expect(mapBox?.width).toBeGreaterThan(280); expect(mapBox?.width).toBeLessThanOrEqual(366); expect(mapBox?.height).toBeCloseTo((mapBox?.width ?? 0) * (5 / 7), 0); + const rightCity = panel.getByTestId('map-preview-city').nth(1); + await rightCity.hover(); + const tooltip = panel.getByTestId('map-preview-city-tooltip'); + await expect(tooltip).toContainText('【중원|수】허창'); + await expect(tooltip).toContainText('촉'); + const tooltipBounds = await tooltip.evaluate((element) => { + const tooltipRect = element.getBoundingClientRect(); + const mapRect = element.parentElement?.getBoundingClientRect(); + if (!mapRect) throw new Error('expected map preview body'); + return { + left: tooltipRect.left - mapRect.left, + right: mapRect.right - tooltipRect.right, + width: tooltipRect.width, + }; + }); + expect(tooltipBounds.left).toBeGreaterThanOrEqual(0); + expect(tooltipBounds.right).toBeGreaterThanOrEqual(0); + expect(tooltipBounds.width).toBeGreaterThanOrEqual(120); await page.screenshot({ path: testInfo.outputPath('public-map-tabs-mobile.png'), fullPage: true }); }); }); diff --git a/app/gateway-frontend/src/components/MapPreview.vue b/app/gateway-frontend/src/components/MapPreview.vue index 419db26f..90a0711f 100644 --- a/app/gateway-frontend/src/components/MapPreview.vue +++ b/app/gateway-frontend/src/components/MapPreview.vue @@ -1,5 +1,5 @@