diff --git a/app/game-api/src/maps/worldMap.ts b/app/game-api/src/maps/worldMap.ts index 18289014..9bd65a3a 100644 --- a/app/game-api/src/maps/worldMap.ts +++ b/app/game-api/src/maps/worldMap.ts @@ -10,6 +10,11 @@ export type BaseMapResult = { startYear: number; year: number; month: number; + techLevelLimit: { + maxLevel: number; + initialLevel: number; + increaseYears: number; + }; cityList: MapCityCompact[]; nationList: MapNationCompact[]; }; @@ -64,6 +69,23 @@ const readState = (meta: Record): number => { return 0; }; +const readPositiveInteger = (value: unknown, fallback: number): number => { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return fallback; + } + const normalized = Math.floor(value); + return normalized > 0 ? normalized : fallback; +}; + +const resolveTechLevelLimit = (worldState: WorldStateRow): BaseMapResult['techLevelLimit'] => { + const constValues = asRecord(asRecord(worldState.config).const); + return { + maxLevel: readPositiveInteger(constValues.maxTechLevel, 12), + initialLevel: readPositiveInteger(constValues.initialAllowedTechLevel, 1), + increaseYears: readPositiveInteger(constValues.techLevelIncYear, 5), + }; +}; + const normalizeNumberRecord = (value: unknown): Record => { if (!isRecord(value)) { return {}; @@ -180,6 +202,7 @@ const loadBaseMap = async ( startYear: resolveStartYear(worldState), year: worldState.currentYear, month: worldState.currentMonth, + techLevelLimit: resolveTechLevelLimit(worldState), cityList, nationList, }; diff --git a/app/game-api/test/publicCachedMapHistory.test.ts b/app/game-api/test/publicCachedMapHistory.test.ts index fce501a4..8fc17e45 100644 --- a/app/game-api/test/publicCachedMapHistory.test.ts +++ b/app/game-api/test/publicCachedMapHistory.test.ts @@ -26,7 +26,13 @@ const buildContext = () => { findFirst: vi.fn(async () => ({ currentYear: 190, currentMonth: 3, - config: {}, + config: { + const: { + maxTechLevel: 10, + initialAllowedTechLevel: 2, + techLevelIncYear: 4, + }, + }, meta: { scenarioMeta: { startYear: 184 } }, })), }, @@ -38,12 +44,8 @@ const buildContext = () => { }, $queryRaw: vi .fn() - .mockResolvedValueOnce([ - { id: 1, level: 5, nationId: 1, region: 1, supplyState: 1, meta: { state: 0 } }, - ]) - .mockResolvedValueOnce([ - { id: 1, name: '촉', color: '#ff0000', capitalCityId: 1, meta: {} }, - ]), + .mockResolvedValueOnce([{ id: 1, level: 5, nationId: 1, region: 1, supplyState: 1, meta: { state: 0 } }]) + .mockResolvedValueOnce([{ id: 1, name: '촉', color: '#ff0000', capitalCityId: 1, meta: {} }]), }; const context: GameApiContext = { db: db as unknown as DatabaseClient, @@ -70,6 +72,11 @@ describe('public.getCachedMap', () => { expect(result).toMatchObject({ year: 190, month: 3, + techLevelLimit: { + maxLevel: 10, + initialLevel: 2, + increaseYears: 4, + }, history: [ { id: 9, text: '최근 정세' }, { id: 8, text: '이전 정세' }, diff --git a/app/game-frontend/e2e/inGameInfo.spec.ts b/app/game-frontend/e2e/inGameInfo.spec.ts index d15403eb..a4d3dec3 100644 --- a/app/game-frontend/e2e/inGameInfo.spec.ts +++ b/app/game-frontend/e2e/inGameInfo.spec.ts @@ -71,6 +71,7 @@ const map = { startYear: 180, year: 200, month: 1, + techLevelLimit: { maxLevel: 12, initialLevel: 1, increaseYears: 5 }, cityList: castleFixtures.map(({ id, level }) => [id, level, 0, 1, 1, 1]), nationList: [[1, '아국', '#008000', 1]], spyList: {}, @@ -134,7 +135,8 @@ const install = async ( page: Page, mode: 'member' | 'wanderer' | 'admin' = 'member', trade: number | null = 100, - globalNationCount = 2 + globalNationCount = 2, + mapFixture = map ) => { await page.addInitScript((profile) => { localStorage.setItem('sammo-game-token', 'ga_info'); @@ -148,13 +150,22 @@ const install = async ( body: await readImage(relativePath), }); }); + await page.route('**/game/**', async (route) => { + const relativePath = decodeURIComponent(new URL(route.request().url()).pathname.split('/game/')[1] ?? ''); + const fixturePath = `game/${relativePath}`; + await route.fulfill({ + status: 200, + contentType: imageContentType(fixturePath), + body: await readImage(fixturePath), + }); + }); await page.route(`**${gameBasePath}/api/trpc/**`, async (route) => { const results = operationNames(route).map((operation) => { if (operation === 'auth.status') return response({ ok: true }); if (operation === 'lobby.info') return response({ myGeneral: { id: 1, name: '장수' } }); if (operation === 'join.getConfig') return response({}); if (operation === 'general.me') return response(generalContext); - if (operation === 'world.getMap') return response(map); + if (operation === 'world.getMap') return response(mapFixture); if (operation === 'turns.getCommandTable') return response({ general: [], nation: [] }); if (operation === 'turns.reserved.getGeneral' || operation === 'turns.reserved.getNation') { return response({ turns: [], revision: 0 }); @@ -258,7 +269,7 @@ const install = async ( ].slice(0, globalNationCount), diplomacy: { 1: { 1: 2, 2: 0 }, 2: { 1: 0, 2: 2 } }, conflict: [], - map, + map: mapFixture, }); if (operation === 'world.getMapLayout') return response(layout); if (operation === 'world.getCurrentCity') @@ -380,6 +391,66 @@ test('global-info renders the ref nation summary columns beside the map', async await page.setViewportSize({ width: 1200, height: 900 }); await go(page, 'global-info'); + const mapTitle = page.locator('.map-title'); + await expect(mapTitle).toHaveText(/200年 1月/u); + const titleBackground = await page + .locator('.map-top') + .evaluate((element) => getComputedStyle(element).backgroundImage); + expect(titleBackground).toContain('ltitle.jpg'); + expect(titleBackground).toContain('rtitle.jpg'); + const titleTextBackground = await mapTitle.evaluate((element) => getComputedStyle(element).backgroundImage); + expect(titleTextBackground).toContain('ad.gif'); + expect(titleTextBackground).toContain('spring.gif'); + await mapTitle.hover(); + const titleTooltip = page.locator('.map-title-tooltip'); + await expect(titleTooltip).toBeVisible(); + await expect(titleTooltip).toContainText('기술등급 제한 : 5등급 (205년 해제)'); + const titleGeometry = await page.locator('.map-top').evaluate((element) => { + const band = element.getBoundingClientRect(); + const title = element.querySelector('.map-title')?.getBoundingClientRect(); + const tooltip = element.querySelector('.map-title-tooltip')?.getBoundingClientRect(); + return { + band: { width: band.width, height: band.height }, + title: title ? { width: title.width, height: title.height } : null, + tooltip: tooltip ? { width: tooltip.width, height: tooltip.height } : null, + }; + }); + expect(titleGeometry).toEqual({ + band: { width: 700, height: 20 }, + title: { width: 160, height: 20 }, + tooltip: { width: 220, height: 28 }, + }); + if (artifactRoot) { + await mkdir(artifactRoot, { recursive: true }); + await page.screenshot({ path: resolve(artifactRoot, 'core-global-info-title-hover.png'), fullPage: true }); + } + + const hoveredCastle = page.locator('.city-base').first(); + await hoveredCastle.hover(); + const cityTooltip = page.locator('.map-tooltip'); + await expect(cityTooltip).toBeVisible(); + await expect(cityTooltip.locator('.tooltip-title')).toHaveText('【하북|특】업'); + await expect(cityTooltip.locator('.tooltip-body')).toHaveText('아국'); + const cityTooltipStyle = await cityTooltip.evaluate((element) => { + const style = getComputedStyle(element); + const rect = element.getBoundingClientRect(); + return { + width: rect.width, + backgroundColor: style.backgroundColor, + fontSize: style.fontSize, + lineHeight: style.lineHeight, + }; + }); + expect(cityTooltipStyle.width).toBeGreaterThanOrEqual(120); + expect(cityTooltipStyle).toMatchObject({ + backgroundColor: 'rgb(30, 164, 255)', + fontSize: '14px', + lineHeight: '15px', + }); + if (artifactRoot) { + await page.screenshot({ path: resolve(artifactRoot, 'core-global-info-city-hover.png'), fullPage: true }); + } + await expect .poll(async () => { const [matrixWrapBox, matrixBox] = await Promise.all([ @@ -395,7 +466,9 @@ test('global-info renders the ref nation summary columns beside the map', async .poll(() => page .locator('.map-area .city-icon') - .evaluateAll((images: HTMLImageElement[]) => images.every((image) => image.complete && image.naturalWidth > 0)) + .evaluateAll((images: HTMLImageElement[]) => + images.every((image) => image.complete && image.naturalWidth > 0) + ) ) .toBe(true); const castleGeometry = await page.locator('.map-area').evaluate((mapArea) => { @@ -460,6 +533,10 @@ test('global-info renders the ref nation summary columns beside the map', async resolve(artifactRoot, 'core-global-info-computed-dom.json'), `${JSON.stringify( { + titleBackground, + titleTextBackground, + titleGeometry, + cityTooltipStyle, geometry, castleGeometry, headings: await summary.locator('th').allTextContents(), @@ -512,11 +589,45 @@ test('global-info renders the ref nation summary columns beside the map', async expect(mobileGeometry.map?.width).toBe(500); expect(mobileGeometry.summary?.width).toBe(500); expect(mobileGeometry.summary?.y).toBe(mobileGeometry.map?.bottom); + await mapTitle.hover(); + await expect(titleTooltip).toBeVisible(); + const mobileTitleGeometry = await page.locator('.map-top').evaluate((element) => { + const band = element.getBoundingClientRect(); + const tooltip = element.querySelector('.map-title-tooltip')?.getBoundingClientRect(); + return { + band: { width: band.width, height: band.height }, + tooltip: tooltip ? { x: tooltip.x, width: tooltip.width } : null, + documentWidth: document.documentElement.scrollWidth, + }; + }); + expect(mobileTitleGeometry.band).toEqual({ width: 500, height: 20 }); + expect(mobileTitleGeometry.tooltip?.width).toBe(220); + expect(mobileTitleGeometry.documentWidth).toBe(500); + await hoveredCastle.hover(); + await expect(cityTooltip).toBeVisible(); + await expect(cityTooltip.locator('.tooltip-title')).toHaveText('【하북|특】업'); if (artifactRoot) { + await page.screenshot({ + path: resolve(artifactRoot, 'core-global-info-mobile-city-hover.png'), + fullPage: true, + }); await page.screenshot({ path: resolve(artifactRoot, 'core-global-info-mobile.png'), fullPage: true }); } }); +test('map title keeps the ref early-game restriction boundary and color', async ({ page }) => { + await install(page, 'member', 100, 2, { ...map, startYear: 198 }); + await page.setViewportSize({ width: 1200, height: 900 }); + await go(page, 'global-info'); + + const mapTitle = page.locator('.map-title'); + await expect(mapTitle).toHaveCSS('color', 'rgb(255, 255, 0)'); + await mapTitle.hover(); + await expect(page.locator('.map-title-tooltip')).toHaveText( + '초반제한 기간 : 0년 12개월 (201년)기술등급 제한 : 1등급 (203년 해제)' + ); +}); + test('global-info diplomacy height follows the active nation count', async ({ page }) => { await install(page, 'member', 100, 1); await go(page, 'global-info'); diff --git a/app/game-frontend/src/components/main/MapViewer.vue b/app/game-frontend/src/components/main/MapViewer.vue index ba8b6ab9..b21cd294 100644 --- a/app/game-frontend/src/components/main/MapViewer.vue +++ b/app/game-frontend/src/components/main/MapViewer.vue @@ -13,6 +13,11 @@ interface MapSummary { year: number; month: number; startYear: number; + techLevelLimit?: { + maxLevel: number; + initialLevel: number; + increaseYears: number; + }; cityList: [number, number, number, number, number, number][]; nationList: [number, string, string, number][]; myCity?: number | null; @@ -198,9 +203,77 @@ const mapSummary = computed(() => { if (!props.mapData) { return ''; } - return `${props.mapData.year}년 ${props.mapData.month}월`; + return `${props.mapData.year}年 ${props.mapData.month}月`; }); +const titleColor = computed(() => { + if (!props.mapData) { + return undefined; + } + const { startYear, year } = props.mapData; + if (year < startYear + 1) { + return 'magenta'; + } + if (year < startYear + 2) { + return 'orange'; + } + if (year < startYear + 3) { + return 'yellow'; + } + return undefined; +}); + +const titleTooltipLines = computed(() => { + if (!props.mapData) { + return []; + } + + const { startYear, year, month } = props.mapData; + const lines: string[] = []; + if (year <= startYear + 3) { + // Ref uses joinYearMonth(startYear + 3, 0) as the limit boundary. + const remainingMonths = (startYear + 3) * 12 - 1 - (year * 12 + month - 1); + const remainYear = Math.trunc(remainingMonths / 12); + const remainMonth = (remainingMonths % 12) + 1; + lines.push( + `초반제한 기간 : ${remainYear}년${remainMonth > 0 ? ` ${remainMonth}개월` : ''} (${startYear + 3}년)` + ); + } + + const limit = props.mapData.techLevelLimit ?? { + maxLevel: 12, + initialLevel: 1, + increaseYears: 5, + }; + const currentLevel = Math.min( + limit.maxLevel, + Math.max(1, Math.floor((year - startYear) / limit.increaseYears) + limit.initialLevel) + ); + if (currentLevel === limit.maxLevel) { + lines.push(`기술등급 제한 : ${currentLevel}등급 (최종)`); + } else { + lines.push(`기술등급 제한 : ${currentLevel}등급 (${currentLevel * limit.increaseYears + startYear}년 해제)`); + } + return lines; +}); + +const titleBandStyle = computed(() => + detailMode.value + ? { + backgroundImage: `url('${resolveAsset('ltitle.jpg')}'), url('${resolveAsset('rtitle.jpg')}')`, + } + : {} +); + +const titleTextStyle = computed(() => + detailMode.value + ? { + color: titleColor.value, + backgroundImage: `url('${resolveAsset('ad.gif')}'), url('${resolveAsset(`${mapSeason.value}.gif`)}')`, + } + : { color: titleColor.value } +); + const mapThemeClass = computed(() => { return `map-theme-${mapTheme.value}`; }); @@ -269,6 +342,24 @@ const hoveredCity = computed(() => { return cityViews.value.find((city) => city.id === hoveredCityId.value) ?? null; }); +const hoveredCityTitle = computed(() => { + if (!hoveredCity.value) { + return ''; + } + return `【${hoveredCity.value.regionName}|${hoveredCity.value.levelName}】${hoveredCity.value.name}`; +}); + +const tooltipPosition = computed(() => { + const width = 120; + const offset = 10; + const mapPixelWidth = BASE_MAP_WIDTH * mapScale.value; + const left = elementX.value + width + offset > mapPixelWidth ? elementX.value - width - 5 : elementX.value + offset; + return { + left: `${Math.max(0, left)}px`, + top: `${elementY.value + 30}px`, + }; +}); + const setHoveredCity = (cityId: number | null) => { mapStore.setHoveredCity(cityId); }; @@ -280,8 +371,13 @@ const selectCity = (cityId: number) => {