From d8c9a639152dc60488b767aebe4171a0a1469999 Mon Sep 17 00:00:00 2001 From: hided62 Date: Mon, 7 Sep 2026 15:28:24 +0000 Subject: [PATCH 1/2] =?UTF-8?q?NPC=EC=99=80=20=EC=95=8C=EB=A6=BC=20?= =?UTF-8?q?=EB=A9=94=EC=8B=9C=EC=A7=80=EC=9D=98=20=EC=9E=A5=EC=88=98=20?= =?UTF-8?q?=EC=95=84=EC=9D=B4=EC=BD=98=20=EA=B2=BD=EB=A1=9C=EB=A5=BC=20?= =?UTF-8?q?=EA=B3=B5=ED=86=B5=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/game-api/src/messages/targets.ts | 5 +- app/game-engine/src/auction/bidder.ts | 3 +- app/game-engine/src/auction/finalizer.ts | 3 +- .../src/turn/monthlyNationBettingAction.ts | 3 +- .../src/turn/reservedTurnHandler.ts | 3 +- .../src/turn/unificationHandler.ts | 3 +- .../src/turn/unificationPersistence.ts | 3 +- .../test/npcGeneralDomesticTurn.test.ts | 9 +- app/game-frontend/e2e/mainNavigation.spec.ts | 86 +++++++++++++++++++ app/game-frontend/src/utils/generalIcon.ts | 4 +- app/game-frontend/test/generalIcon.test.ts | 7 ++ 11 files changed, 116 insertions(+), 13 deletions(-) diff --git a/app/game-api/src/messages/targets.ts b/app/game-api/src/messages/targets.ts index 7990f85f..644bd168 100644 --- a/app/game-api/src/messages/targets.ts +++ b/app/game-api/src/messages/targets.ts @@ -7,8 +7,6 @@ const DEFAULT_NATION = { color: '#000000', }; -const DEFAULT_SHARED_ICON_PUBLIC_URL = 'https://sam-image.hided.net/icons'; - export const resolveNationInfo = async ( db: DatabaseClient, nationId: number @@ -25,14 +23,13 @@ export const resolveNationInfo = async ( export const buildTargetFromGeneral = async (db: DatabaseClient, general: GeneralRow): Promise => { const nation = await resolveNationInfo(db, general.nationId); - const picture = general.picture?.trim() || 'default.jpg'; return { generalId: general.id, generalName: general.name, nationId: general.nationId, nationName: nation.name, color: nation.color, - icon: general.imageServer ? `d_pic/${picture}` : `${DEFAULT_SHARED_ICON_PUBLIC_URL}/${picture}`, + icon: resolveMessageTargetIcon({ picture: general.picture, imageServer: general.imageServer }), }; }; diff --git a/app/game-engine/src/auction/bidder.ts b/app/game-engine/src/auction/bidder.ts index b0c9a5a2..7b68fbec 100644 --- a/app/game-engine/src/auction/bidder.ts +++ b/app/game-engine/src/auction/bidder.ts @@ -1,3 +1,4 @@ +import { resolveMessageTargetIcon } from '@sammo-ts/logic'; import { randomUUID } from 'node:crypto'; import { createGamePostgresConnector, GamePrisma, type GamePrismaClient } from '@sammo-ts/infra'; @@ -106,7 +107,7 @@ export const buildAuctionOutbidRefundMessage = (options: { nationId: options.bidder.nationId, nationName: options.nation?.name ?? '재야', color: options.nation?.color ?? '#000000', - icon: options.bidder.picture ?? '', + icon: resolveMessageTargetIcon(options.bidder), }, text: `${options.auctionId}번 ${options.title ?? '경매'}에 상회입찰자가 나타났습니다.`, time: new Date(options.time.getTime()), diff --git a/app/game-engine/src/auction/finalizer.ts b/app/game-engine/src/auction/finalizer.ts index 4700dd7b..deaffc17 100644 --- a/app/game-engine/src/auction/finalizer.ts +++ b/app/game-engine/src/auction/finalizer.ts @@ -1,3 +1,4 @@ +import { resolveMessageTargetIcon } from '@sammo-ts/logic'; import { createGamePostgresConnector, GamePrisma } from '@sammo-ts/infra'; import { ActionLogger, ItemLoader, LogFormat, isItemKey, type MessageDraft } from '@sammo-ts/logic'; import { resolveLegacyCompatibleUniqueConfig } from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js'; @@ -124,7 +125,7 @@ export const buildAuctionBidderSystemMessage = (options: { nationId: options.bidder.nationId, nationName: options.nation?.name ?? '재야', color: options.nation?.color ?? '#000000', - icon: options.bidder.picture ?? '', + icon: resolveMessageTargetIcon(options.bidder), }, text: options.text, time: new Date(options.time.getTime()), diff --git a/app/game-engine/src/turn/monthlyNationBettingAction.ts b/app/game-engine/src/turn/monthlyNationBettingAction.ts index d769b4bf..807d1d85 100644 --- a/app/game-engine/src/turn/monthlyNationBettingAction.ts +++ b/app/game-engine/src/turn/monthlyNationBettingAction.ts @@ -1,3 +1,4 @@ +import { resolveMessageTargetIcon } from '@sammo-ts/logic'; import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic'; import type { InMemoryTurnWorld } from './inMemoryWorld.js'; @@ -127,7 +128,7 @@ export const createOpenNationBettingHandler = (options: { nationId: general.nationId, nationName: nation?.name ?? '재야', color: nation?.color ?? '#000000', - icon: general.picture ?? '', + icon: resolveMessageTargetIcon(general), }, text, time: now, diff --git a/app/game-engine/src/turn/reservedTurnHandler.ts b/app/game-engine/src/turn/reservedTurnHandler.ts index b33e4651..adad2135 100644 --- a/app/game-engine/src/turn/reservedTurnHandler.ts +++ b/app/game-engine/src/turn/reservedTurnHandler.ts @@ -1,3 +1,4 @@ +import { resolveMessageTargetIcon } from '@sammo-ts/logic'; import type { ActionContextBase, ActionContextBuilder, @@ -2121,7 +2122,7 @@ export const createReservedTurnHandler = async (options: { nationId: currentGeneral.nationId, nationName: currentNation?.name ?? '재야', color: currentNation?.color ?? '#000000', - icon: currentGeneral.picture ?? '', + icon: resolveMessageTargetIcon(currentGeneral), }; messages.push({ msgType: 'public', diff --git a/app/game-engine/src/turn/unificationHandler.ts b/app/game-engine/src/turn/unificationHandler.ts index 5e85643f..cf60ca39 100644 --- a/app/game-engine/src/turn/unificationHandler.ts +++ b/app/game-engine/src/turn/unificationHandler.ts @@ -1,3 +1,4 @@ +import { resolveMessageTargetIcon } from '@sammo-ts/logic'; import { createHash } from 'node:crypto'; import { asNumber, asRecord, JosaUtil } from '@sammo-ts/common'; @@ -156,7 +157,7 @@ export const createUnificationHandler = (options: { nationId: winner.id, nationName: winner.name, color: winner.color, - icon: recipient.picture ?? '', + icon: resolveMessageTargetIcon(recipient), }, text: `이벤트 게임으로 이민족[${invader.difficulty}]을 소환`, time: context.turnTime, diff --git a/app/game-engine/src/turn/unificationPersistence.ts b/app/game-engine/src/turn/unificationPersistence.ts index d4cfbc8e..6f51db89 100644 --- a/app/game-engine/src/turn/unificationPersistence.ts +++ b/app/game-engine/src/turn/unificationPersistence.ts @@ -1,3 +1,4 @@ +import { resolveMessageTargetIcon } from '@sammo-ts/logic'; import { areSeasonRecordsFinalized } from './seasonRecords.js'; import { asRecord, HALL_OF_FAME_TYPES, resolveLegacyTextColor, type HallOfFameType } from '@sammo-ts/common'; import { @@ -246,7 +247,7 @@ const cancelPendingUniqueAuctions = async ( nationId: bidder.nationId, nationName: nation?.name ?? '재야', color: nation?.color ?? '#000000', - icon: bidder.picture ?? '', + icon: resolveMessageTargetIcon(bidder), }, text: `${planned.auctionId}번 ${planned.title}가 취소되었습니다.`, time: input.completedAt, diff --git a/app/game-engine/test/npcGeneralDomesticTurn.test.ts b/app/game-engine/test/npcGeneralDomesticTurn.test.ts index a16ea720..1bbf2f45 100644 --- a/app/game-engine/test/npcGeneralDomesticTurn.test.ts +++ b/app/game-engine/test/npcGeneralDomesticTurn.test.ts @@ -105,6 +105,8 @@ describe('NPC 일반 내정 턴', () => { { id: 1, name: 'NPC_무장', + picture: '롤시나리오/다이애나.png', + imageServer: 0, nationId: 1, cityId: 1, troopId: 0, @@ -297,7 +299,12 @@ describe('NPC 일반 내정 턴', () => { expect.objectContaining({ msgType: 'public', text: '기부는 저처럼 돈 많은 사람들이 많이 하면 됩니다', - src: expect.objectContaining({ generalId: 1, generalName: 'NPC_무장', nationId: 1 }), + src: expect.objectContaining({ + generalId: 1, + generalName: 'NPC_무장', + nationId: 1, + icon: 'https://sam-image.hided.net/icons/롤시나리오/다이애나.png', + }), time: logicalGameNow, }) ); diff --git a/app/game-frontend/e2e/mainNavigation.spec.ts b/app/game-frontend/e2e/mainNavigation.spec.ts index c7241af9..269f50bd 100644 --- a/app/game-frontend/e2e/mainNavigation.spec.ts +++ b/app/game-frontend/e2e/mainNavigation.spec.ts @@ -6302,3 +6302,89 @@ for (const viewport of [ } }); } + +for (const viewport of [ + { width: 1200, height: 900 }, + { width: 390, height: 844 }, +]) { + test(`NPC message portraits resolve stored and new scenario icons at ${viewport.width}px`, async ({ + page, + }, testInfo) => { + const picture = '롤시나리오/다이애나.png'; + const iconUrl = `https://sam-image.hided.net/icons/${picture.split('/').map(encodeURIComponent).join('/')}`; + const asset = await readFile(resolve(process.cwd(), '../../../image/icons', picture)); + const defaultAsset = await readFile(resolve(process.cwd(), '../../../image/icons/default.jpg')); + const state: NavigationFixture = { + officerLevel: 1, + permission: 0, + nationLevel: 1, + stage: 0, + npcMode: 1, + generalMeCalls: 0, + operations: [], + messages: { + ...emptyMessages(0), + public: [picture, `https://sam-image.hided.net/icons/${picture}`, ''].map((icon, index) => ({ + id: 801 + index, + text: '새로운 달이 떠오르고 있다.', + time: '2026-09-07 12:00:00', + msgType: 'public', + src: { + generalId: 22 + index, + generalName: index === 2 ? '유저' : '다이애나', + nationId: 1, + nationName: '위', + color: '#008000', + icon, + }, + dest: null, + option: {}, + })), + }, + }; + await installFixture(page, state); + await page.route('https://sam-image.hided.net/icons/**', (route) => + route.fulfill({ + contentType: route.request().url() === iconUrl ? 'image/png' : 'image/jpeg', + body: route.request().url() === iconUrl ? asset : defaultAsset, + }) + ); + await page.setViewportSize(viewport); + await waitForMain(page); + const measurements = []; + for (const id of [801, 802, 803]) { + const icon = page.locator(`.msg-plate[data-id="${id}"]:visible img.general-icon`).first(); + await expect(icon).toBeVisible(); + await expect + .poll(() => icon.evaluate((element) => (element as HTMLImageElement).naturalWidth)) + .toBe(id === 803 ? 64 : 128); + const result = await icon.evaluate((element) => { + const image = element as HTMLImageElement; + const rect = image.getBoundingClientRect(); + return { + src: image.src, + width: rect.width, + height: rect.height, + naturalWidth: image.naturalWidth, + naturalHeight: image.naturalHeight, + objectFit: getComputedStyle(image).objectFit, + }; + }); + expect(result.src).toBe(id === 803 ? 'https://sam-image.hided.net/icons/default.jpg' : iconUrl); + expect(result.width).toBe(64); + expect(result.height).toBe(64); + measurements.push(result); + } + await page.reload(); + await expect(page.locator('.msg-plate[data-id="801"]:visible img').first()).toHaveAttribute('src', iconUrl); + await writeFile(testInfo.outputPath('icon-geometry.json'), JSON.stringify({ viewport, measurements }, null, 2)); + await writeFile( + testInfo.outputPath('messages.html'), + await page + .locator('.msg-plate[data-id="801"]:visible') + .first() + .evaluate((el) => el.outerHTML) + ); + await page.screenshot({ path: testInfo.outputPath('npc-message-icons.png'), fullPage: true }); + }); +} diff --git a/app/game-frontend/src/utils/generalIcon.ts b/app/game-frontend/src/utils/generalIcon.ts index 17be127f..4f6c07eb 100644 --- a/app/game-frontend/src/utils/generalIcon.ts +++ b/app/game-frontend/src/utils/generalIcon.ts @@ -4,7 +4,6 @@ import { DEFAULT_USER_ICON_PUBLIC_URL, externalizeLegacyImageUrl, } from './imageAssets.ts'; -import { gameFrontendRuntimeConfig } from '../config/runtimeConfig.ts'; export const DEFAULT_GENERAL_ICON_URL = `${configuredSharedIconPublicUrl()}/default.jpg`; export const DEFAULT_GATEWAY_USER_ICON_BASE_URL = DEFAULT_USER_ICON_PUBLIC_URL; @@ -69,7 +68,8 @@ export const resolveMessageGeneralIconUrl = ( if (normalized.startsWith('/') || /^https?:\/\//iu.test(normalized)) { return normalized; } - return `${gameFrontendRuntimeConfig.appBasePath}${normalized.replace(/^\/+/u, '')}`; + // 기존 NPC 대사와 알림에는 URL 대신 picture 상대 경로가 저장되어 있다. + return resolveGeneralIconUrl({ picture: normalized, imageServer: 0 }); }; export const useDefaultGeneralIcon = (event: Event): void => { diff --git a/app/game-frontend/test/generalIcon.test.ts b/app/game-frontend/test/generalIcon.test.ts index c2fc3ed7..8d67cbf5 100644 --- a/app/game-frontend/test/generalIcon.test.ts +++ b/app/game-frontend/test/generalIcon.test.ts @@ -52,6 +52,13 @@ void describe('generalIcon', () => { ); }); + void it('resolves stored NPC message pictures like directory icons across scenarios', () => { + for (const picture of ['롤시나리오/다이애나.png', '장수/관우 1.png', '22.jpg', 'default.jpg']) { + assert.equal(resolveMessageGeneralIconUrl(picture), resolveGeneralIconUrl({ picture, imageServer: 0 })); + } + assert.equal(resolveMessageGeneralIconUrl(null), resolveGeneralIconUrl({ picture: null })); + }); + void it('translates legacy message d_pic references without changing absolute or external icons', () => { assert.equal( resolveMessageGeneralIconUrl('d_pic/users/core2026/user name.jpg', '/gateway/api/user-icons/'), From c1fd79da195773fcb1efb7026f78608221694d3b Mon Sep 17 00:00:00 2001 From: hided62 Date: Tue, 8 Sep 2026 00:49:39 +0000 Subject: [PATCH 2/2] =?UTF-8?q?=EB=A9=94=EC=9D=B8=20=EC=A7=80=EB=8F=84=20?= =?UTF-8?q?=EC=97=B0=EC=9B=94=20=ED=88=B4=ED=8C=81=EC=97=90=20=EC=9C=A0?= =?UTF-8?q?=EB=8B=88=ED=81=AC=20=EB=B3=B4=EC=9C=A0=20=ED=95=9C=EB=8F=84=20?= =?UTF-8?q?=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/game-api/src/maps/worldMap.ts | 32 ++++++++- .../test/worldMapRevisionCache.test.ts | 65 +++++++++++++++++-- app/game-frontend/e2e/mainNavigation.spec.ts | 35 ++++++++-- .../src/components/main/MapViewer.vue | 6 ++ 4 files changed, 126 insertions(+), 12 deletions(-) diff --git a/app/game-api/src/maps/worldMap.ts b/app/game-api/src/maps/worldMap.ts index 53dd257e..b4f1b672 100644 --- a/app/game-api/src/maps/worldMap.ts +++ b/app/game-api/src/maps/worldMap.ts @@ -1,5 +1,6 @@ import type { GameApiContext, WorldStateRow } from '../context.js'; import { asRecord, isRecord } from '@sammo-ts/common'; +import { resolveUniqueConfig } from '@sammo-ts/logic/rewards/uniqueLottery.js'; import { readMapWorldSourceRevision } from './worldMapSourceRevision.js'; export type MapCityCompact = [number, number, number, number, number, number]; @@ -16,6 +17,7 @@ export type BaseMapResult = { initialLevel: number; increaseYears: number; }; + uniqueItemLimit: { count: number; until: { year: number; month: number } | null }; cityList: MapCityCompact[]; nationList: MapNationCompact[]; }; @@ -52,7 +54,7 @@ const MAP_VERSION = 0 as const; const BASE_MAP_TTL_SECONDS = 30; const PUBLIC_MAP_TTL_SECONDS = 600; -const resolveStartYear = (worldState: WorldStateRow): number => { +const resolveStartYear = (worldState: Pick): number => { const meta = asRecord(worldState.meta); const scenarioMeta = asRecord(meta.scenarioMeta); const startYear = scenarioMeta.startYear; @@ -87,6 +89,28 @@ const resolveTechLevelLimit = (worldState: WorldStateRow): BaseMapResult['techLe }; }; +// 획득/경매와 같은 시나리오 설정을 사용하며, 장수 개인의 보유 수는 공개하지 않는다. +export const resolveMapUniqueItemLimit = ( + worldState: Pick +): BaseMapResult['uniqueItemLimit'] => { + const config = resolveUniqueConfig(asRecord(asRecord(worldState.config).const)); + const startYear = resolveStartYear(worldState); + const relativeYear = worldState.currentYear - startYear; + const slotCount = Object.keys(config.allItems).length; + let count = Math.min(1, slotCount); + for (const [targetYear, targetCount] of config.maxUniqueItemLimit) { + const nextCount = Math.min(targetCount, slotCount); + if (relativeYear < targetYear) { + if (nextCount !== count) { + return { count, until: { year: startYear + targetYear - 1, month: 12 } }; + } + } else { + count = nextCount; + } + } + return { count, until: null }; +}; + const normalizeNumberRecord = (value: unknown): Record => { if (!isRecord(value)) { return {}; @@ -154,7 +178,10 @@ const loadBaseMap = async ( } if (cached) { try { - return JSON.parse(cached) as BaseMapResult; + const parsed = JSON.parse(cached) as BaseMapResult; + if (parsed.uniqueItemLimit) { + return parsed; + } } catch { // Ignore cache parse errors. } @@ -207,6 +234,7 @@ const loadBaseMap = async ( year: worldState.currentYear, month: worldState.currentMonth, techLevelLimit: resolveTechLevelLimit(worldState), + uniqueItemLimit: resolveMapUniqueItemLimit(worldState), cityList, nationList, }; diff --git a/app/game-api/test/worldMapRevisionCache.test.ts b/app/game-api/test/worldMapRevisionCache.test.ts index 2681eb57..33ee8370 100644 --- a/app/game-api/test/worldMapRevisionCache.test.ts +++ b/app/game-api/test/worldMapRevisionCache.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest'; import type { GameApiContext } from '../src/context.js'; -import { loadWorldMap, buildRevisionedBaseMapCacheKey } from '../src/maps/worldMap.js'; +import { loadWorldMap, buildRevisionedBaseMapCacheKey, resolveMapUniqueItemLimit } from '../src/maps/worldMap.js'; import { readMapWorldSourceRevision } from '../src/maps/worldMapSourceRevision.js'; const revisionRow = (overrides: Record = {}) => ({ @@ -18,16 +18,14 @@ describe('world map revision cache', () => { db: { $queryRaw: queryRaw }, } as unknown as GameApiContext; - await expect(buildRevisionedBaseMapCacheKey(ctx)).resolves.toBe( - 'sammo:map:base:hwe:scenario_2400:pg12' - ); + await expect(buildRevisionedBaseMapCacheKey(ctx)).resolves.toBe('sammo:map:base:hwe:scenario_2400:pg12'); await expect(buildRevisionedBaseMapCacheKey(ctx, 'public')).resolves.toBe( 'sammo:map:public:hwe:scenario_2400:pg12' ); expect(queryRaw).toHaveBeenCalledTimes(2); const statement = queryRaw.mock.calls[0]?.[0] as { sql: string; values: unknown[] }; expect(statement.sql).toContain('read_model_revision_meta'); - expect(statement.sql).toContain("revision.\"domain\" = 'map.world'"); + expect(statement.sql).toContain('revision."domain" = \'map.world\''); expect(statement.values).toEqual([]); }); @@ -98,6 +96,7 @@ describe('world map revision cache', () => { const first = await loadWorldMap(ctx, { generalId: 7, useCache: true }); const second = await loadWorldMap(ctx, { generalId: 8, useCache: true }); + expect(first?.uniqueItemLimit).toEqual({ count: 0, until: null }); expect(first).toMatchObject({ myCity: 3, myNation: 2, spyList: { 5: 9 } }); expect(second).toMatchObject({ myCity: 4, myNation: 3, spyList: { 6: 8 } }); expect(redis.set).toHaveBeenCalledTimes(1); @@ -106,6 +105,12 @@ describe('world map revision cache', () => { expect(shared).not.toHaveProperty('shownByGeneralList'); expect(shared).not.toHaveProperty('myCity'); expect(shared).not.toHaveProperty('myNation'); + // 배포 전 캐시는 새 안내 필드를 포함하도록 DB에서 다시 만든다. + delete shared.uniqueItemLimit; + cache.set(cache.keys().next().value!, JSON.stringify(shared)); + const refreshed = await loadWorldMap(ctx, { generalId: 7, useCache: true }); + expect(refreshed?.uniqueItemLimit).toEqual({ count: 0, until: null }); + expect(redis.set).toHaveBeenCalledTimes(2); }); it('does not read or write Redis when PostgreSQL revision authority is unavailable', async () => { @@ -121,7 +126,9 @@ describe('world map revision cache', () => { redis, db: { $queryRaw: queryRaw, - worldState: { findFirst: vi.fn(async () => ({ currentYear: 185, currentMonth: 1, config: {}, meta: {} })) }, + worldState: { + findFirst: vi.fn(async () => ({ currentYear: 185, currentMonth: 1, config: {}, meta: {} })), + }, }, } as unknown as GameApiContext; @@ -130,3 +137,49 @@ describe('world map revision cache', () => { expect(redis.set).not.toHaveBeenCalled(); }); }); + +describe('map unique ownership limit', () => { + const state = (currentYear: number, constValues: Record = {}) => ({ + currentYear, + meta: { scenarioMeta: { startYear: 180 } }, + config: { const: { allItems: { horse: {}, weapon: {}, book: {}, item: {} }, ...constValues } }, + }); + + it.each([ + [179, 1, 182], + [181, 1, 182], + [182, 1, 182], + [183, 2, 189], + [189, 2, 189], + [190, 3, 199], + [200, 4, null], + ])('projects year %i and the inclusive last month', (year, count, untilYear) => { + expect(resolveMapUniqueItemLimit(state(year!))).toEqual({ + count, + until: untilYear === null ? null : { year: untilYear, month: 12 }, + }); + }); + + it('uses custom thresholds, skips unchanged caps and respects the slot pool', () => { + expect( + resolveMapUniqueItemLimit( + state(181, { + maxUniqueItemLimit: [ + [-1, 1], + [2, 1], + [4, 3], + [8, 4], + ], + }) + ) + ).toEqual({ count: 1, until: { year: 183, month: 12 } }); + expect( + resolveMapUniqueItemLimit( + state(183, { + allItems: { horse: {}, weapon: {} }, + }) + ) + ).toEqual({ count: 2, until: null }); + expect(resolveMapUniqueItemLimit(state(181, { allItems: {} }))).toEqual({ count: 0, until: null }); + }); +}); diff --git a/app/game-frontend/e2e/mainNavigation.spec.ts b/app/game-frontend/e2e/mainNavigation.spec.ts index 269f50bd..3f4933fc 100644 --- a/app/game-frontend/e2e/mainNavigation.spec.ts +++ b/app/game-frontend/e2e/mainNavigation.spec.ts @@ -65,6 +65,7 @@ type NavigationFixture = { draftCommandTable?: boolean; equipmentItemOptions?: Array<{ value: string; label: string; description?: string }>; refCommandCategories?: boolean; + uniqueItemLimit?: { count: number; until: { year: number; month: number } | null }; currentYear?: number; currentMonth?: number; mapName?: string; @@ -753,6 +754,7 @@ const installFixture = async (page: Page | BrowserContext, state: NavigationFixt } if (operation === 'world.getMap') { return response({ + uniqueItemLimit: state.uniqueItemLimit, result: true, version: 0, startYear: 180, @@ -3561,8 +3563,9 @@ test('main map year exposes the Ref restriction and technology limit on desktop nationLevel: 3, stage: 6, npcMode: 1, - currentYear: 182, + currentYear: 181, currentMonth: 1, + uniqueItemLimit: { count: 1, until: { year: 182, month: 12 } }, generalMeCalls: 0, operations: [], validMapImages: true, @@ -3575,13 +3578,15 @@ test('main map year exposes the Ref restriction and technology limit on desktop const mapPanel = page.locator(`.layout-${layout} [data-main-target="map"]`); const title = mapPanel.locator('.map-title'); const tooltip = mapPanel.getByRole('tooltip'); - await expect(title).toHaveText(/182年 1月/u); - await expect(title).toHaveCSS('color', 'rgb(255, 255, 0)'); + await expect(title).toHaveText(/181年 1月/u); + await expect(title).toHaveCSS('color', 'rgb(255, 165, 0)'); await expect(title).toHaveAttribute('tabindex', '0'); await expect(tooltip).toBeHidden(); await title.hover(); await expect(tooltip).toBeVisible(); - await expect(tooltip).toHaveText('초반제한 기간 : 0년 12개월 (183년)기술등급 제한 : 1등급 (185년 해제)'); + await expect(tooltip).toHaveText( + '초반제한 기간 : 1년 12개월 (183년)기술등급 제한 : 1등급 (185년 해제)보유 유니크 한도: 182년 12월까지 1개' + ); const geometry = await mapPanel.evaluate((panel) => { const panelRect = panel.getBoundingClientRect(); const titleRect = panel.querySelector('.map-title')?.getBoundingClientRect(); @@ -3608,6 +3613,9 @@ test('main map year exposes the Ref restriction and technology limit on desktop return geometry; }; + await page.evaluate(async () => { + await document.fonts.ready; + }); const desktopGeometry = await assertTitleTooltip('desktop'); await testInfo.attach('main-map-year-tooltip-desktop.png', { body: await page.screenshot({ fullPage: false }), @@ -3624,10 +3632,29 @@ test('main map year exposes the Ref restriction and technology limit on desktop body: await page.screenshot({ fullPage: false }), contentType: 'image/png', }); + await testInfo.attach('main-map-year-tooltip-dom.html', { + body: Buffer.from(await page.locator('.layout-mobile [data-main-target="map"]').evaluate((el) => el.outerHTML)), + contentType: 'text/html', + }); await testInfo.attach('main-map-year-tooltip-geometry.json', { body: Buffer.from(`${JSON.stringify({ desktop: desktopGeometry, mobile: mobileGeometry }, null, 2)}\n`), contentType: 'application/json', }); + state.currentYear = 183; + state.uniqueItemLimit = { count: 2, until: { year: 189, month: 12 } }; + await waitForMain(page); + const mobileMap = page.locator('.layout-mobile [data-main-target="map"]'); + await mobileMap.locator('.map-title').focus(); + await expect(mobileMap.getByRole('tooltip')).toContainText('보유 유니크 한도: 189년 12월까지 2개'); + state.currentYear = 200; + state.uniqueItemLimit = { count: 4, until: null }; + await waitForMain(page); + await mobileMap.locator('.map-title').focus(); + await expect(mobileMap.getByRole('tooltip')).toContainText('보유 유니크 한도: 4개 (최종)'); + state.uniqueItemLimit = undefined; + await waitForMain(page); + await mobileMap.locator('.map-title').focus(); + await expect(mobileMap.getByRole('tooltip')).not.toContainText('보유 유니크 한도'); }); test('the 939/940 boundary switches to the Ref-style 500px single document', async ({ page }) => { diff --git a/app/game-frontend/src/components/main/MapViewer.vue b/app/game-frontend/src/components/main/MapViewer.vue index 32be5f5b..a1ab31b1 100644 --- a/app/game-frontend/src/components/main/MapViewer.vue +++ b/app/game-frontend/src/components/main/MapViewer.vue @@ -20,6 +20,7 @@ interface MapSummary { initialLevel: number; increaseYears: number; }; + uniqueItemLimit?: { count: number; until: { year: number; month: number } | null }; cityList: [number, number, number, number, number, number][]; nationList: [number, string, string, number][]; myCity?: number | null; @@ -335,6 +336,11 @@ const titleTooltipLines = computed(() => { } else { lines.push(`기술등급 제한 : ${currentLevel}등급 (${currentLevel * limit.increaseYears + startYear}년 해제)`); } + const uniqueLimit = props.mapData.uniqueItemLimit; + if (uniqueLimit) { + const period = uniqueLimit.until ? `${uniqueLimit.until.year}년 ${uniqueLimit.until.month}월까지 ` : ''; + lines.push(`보유 유니크 한도: ${period}${uniqueLimit.count}개${uniqueLimit.until ? '' : ' (최종)'}`); + } return lines; });