Merge remote-tracking branch 'refs/remotes/origin/main'

This commit is contained in:
2026-09-09 22:54:38 +00:00
14 changed files with 242 additions and 25 deletions
+30 -2
View File
@@ -1,5 +1,6 @@
import type { GameApiContext, WorldStateRow } from '../context.js'; import type { GameApiContext, WorldStateRow } from '../context.js';
import { asRecord, isRecord } from '@sammo-ts/common'; import { asRecord, isRecord } from '@sammo-ts/common';
import { resolveUniqueConfig } from '@sammo-ts/logic/rewards/uniqueLottery.js';
import { readMapWorldSourceRevision } from './worldMapSourceRevision.js'; import { readMapWorldSourceRevision } from './worldMapSourceRevision.js';
export type MapCityCompact = [number, number, number, number, number, number]; export type MapCityCompact = [number, number, number, number, number, number];
@@ -16,6 +17,7 @@ export type BaseMapResult = {
initialLevel: number; initialLevel: number;
increaseYears: number; increaseYears: number;
}; };
uniqueItemLimit: { count: number; until: { year: number; month: number } | null };
cityList: MapCityCompact[]; cityList: MapCityCompact[];
nationList: MapNationCompact[]; nationList: MapNationCompact[];
}; };
@@ -52,7 +54,7 @@ const MAP_VERSION = 0 as const;
const BASE_MAP_TTL_SECONDS = 30; const BASE_MAP_TTL_SECONDS = 30;
const PUBLIC_MAP_TTL_SECONDS = 600; const PUBLIC_MAP_TTL_SECONDS = 600;
const resolveStartYear = (worldState: WorldStateRow): number => { const resolveStartYear = (worldState: Pick<WorldStateRow, 'meta'>): number => {
const meta = asRecord(worldState.meta); const meta = asRecord(worldState.meta);
const scenarioMeta = asRecord(meta.scenarioMeta); const scenarioMeta = asRecord(meta.scenarioMeta);
const startYear = scenarioMeta.startYear; const startYear = scenarioMeta.startYear;
@@ -87,6 +89,28 @@ const resolveTechLevelLimit = (worldState: WorldStateRow): BaseMapResult['techLe
}; };
}; };
// 획득/경매와 같은 시나리오 설정을 사용하며, 장수 개인의 보유 수는 공개하지 않는다.
export const resolveMapUniqueItemLimit = (
worldState: Pick<WorldStateRow, 'config' | 'meta' | 'currentYear'>
): 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<number, number> => { const normalizeNumberRecord = (value: unknown): Record<number, number> => {
if (!isRecord(value)) { if (!isRecord(value)) {
return {}; return {};
@@ -154,7 +178,10 @@ const loadBaseMap = async (
} }
if (cached) { if (cached) {
try { try {
return JSON.parse(cached) as BaseMapResult; const parsed = JSON.parse(cached) as BaseMapResult;
if (parsed.uniqueItemLimit) {
return parsed;
}
} catch { } catch {
// Ignore cache parse errors. // Ignore cache parse errors.
} }
@@ -207,6 +234,7 @@ const loadBaseMap = async (
year: worldState.currentYear, year: worldState.currentYear,
month: worldState.currentMonth, month: worldState.currentMonth,
techLevelLimit: resolveTechLevelLimit(worldState), techLevelLimit: resolveTechLevelLimit(worldState),
uniqueItemLimit: resolveMapUniqueItemLimit(worldState),
cityList, cityList,
nationList, nationList,
}; };
+1 -4
View File
@@ -7,8 +7,6 @@ const DEFAULT_NATION = {
color: '#000000', color: '#000000',
}; };
const DEFAULT_SHARED_ICON_PUBLIC_URL = 'https://sam-image.hided.net/icons';
export const resolveNationInfo = async ( export const resolveNationInfo = async (
db: DatabaseClient, db: DatabaseClient,
nationId: number nationId: number
@@ -25,14 +23,13 @@ export const resolveNationInfo = async (
export const buildTargetFromGeneral = async (db: DatabaseClient, general: GeneralRow): Promise<MessageTarget> => { export const buildTargetFromGeneral = async (db: DatabaseClient, general: GeneralRow): Promise<MessageTarget> => {
const nation = await resolveNationInfo(db, general.nationId); const nation = await resolveNationInfo(db, general.nationId);
const picture = general.picture?.trim() || 'default.jpg';
return { return {
generalId: general.id, generalId: general.id,
generalName: general.name, generalName: general.name,
nationId: general.nationId, nationId: general.nationId,
nationName: nation.name, nationName: nation.name,
color: nation.color, color: nation.color,
icon: general.imageServer ? `d_pic/${picture}` : `${DEFAULT_SHARED_ICON_PUBLIC_URL}/${picture}`, icon: resolveMessageTargetIcon({ picture: general.picture, imageServer: general.imageServer }),
}; };
}; };
@@ -1,7 +1,7 @@
import { describe, expect, it, vi } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
import type { GameApiContext } from '../src/context.js'; 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'; import { readMapWorldSourceRevision } from '../src/maps/worldMapSourceRevision.js';
const revisionRow = (overrides: Record<string, unknown> = {}) => ({ const revisionRow = (overrides: Record<string, unknown> = {}) => ({
@@ -18,16 +18,14 @@ describe('world map revision cache', () => {
db: { $queryRaw: queryRaw }, db: { $queryRaw: queryRaw },
} as unknown as GameApiContext; } as unknown as GameApiContext;
await expect(buildRevisionedBaseMapCacheKey(ctx)).resolves.toBe( await expect(buildRevisionedBaseMapCacheKey(ctx)).resolves.toBe('sammo:map:base:hwe:scenario_2400:pg12');
'sammo:map:base:hwe:scenario_2400:pg12'
);
await expect(buildRevisionedBaseMapCacheKey(ctx, 'public')).resolves.toBe( await expect(buildRevisionedBaseMapCacheKey(ctx, 'public')).resolves.toBe(
'sammo:map:public:hwe:scenario_2400:pg12' 'sammo:map:public:hwe:scenario_2400:pg12'
); );
expect(queryRaw).toHaveBeenCalledTimes(2); expect(queryRaw).toHaveBeenCalledTimes(2);
const statement = queryRaw.mock.calls[0]?.[0] as { sql: string; values: unknown[] }; const statement = queryRaw.mock.calls[0]?.[0] as { sql: string; values: unknown[] };
expect(statement.sql).toContain('read_model_revision_meta'); 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([]); expect(statement.values).toEqual([]);
}); });
@@ -98,6 +96,7 @@ describe('world map revision cache', () => {
const first = await loadWorldMap(ctx, { generalId: 7, useCache: true }); const first = await loadWorldMap(ctx, { generalId: 7, useCache: true });
const second = await loadWorldMap(ctx, { generalId: 8, 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(first).toMatchObject({ myCity: 3, myNation: 2, spyList: { 5: 9 } });
expect(second).toMatchObject({ myCity: 4, myNation: 3, spyList: { 6: 8 } }); expect(second).toMatchObject({ myCity: 4, myNation: 3, spyList: { 6: 8 } });
expect(redis.set).toHaveBeenCalledTimes(1); expect(redis.set).toHaveBeenCalledTimes(1);
@@ -106,6 +105,12 @@ describe('world map revision cache', () => {
expect(shared).not.toHaveProperty('shownByGeneralList'); expect(shared).not.toHaveProperty('shownByGeneralList');
expect(shared).not.toHaveProperty('myCity'); expect(shared).not.toHaveProperty('myCity');
expect(shared).not.toHaveProperty('myNation'); 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 () => { it('does not read or write Redis when PostgreSQL revision authority is unavailable', async () => {
@@ -121,7 +126,9 @@ describe('world map revision cache', () => {
redis, redis,
db: { db: {
$queryRaw: queryRaw, $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; } as unknown as GameApiContext;
@@ -130,3 +137,49 @@ describe('world map revision cache', () => {
expect(redis.set).not.toHaveBeenCalled(); expect(redis.set).not.toHaveBeenCalled();
}); });
}); });
describe('map unique ownership limit', () => {
const state = (currentYear: number, constValues: Record<string, unknown> = {}) => ({
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 });
});
});
+2 -1
View File
@@ -1,3 +1,4 @@
import { resolveMessageTargetIcon } from '@sammo-ts/logic';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { createGamePostgresConnector, GamePrisma, type GamePrismaClient } from '@sammo-ts/infra'; import { createGamePostgresConnector, GamePrisma, type GamePrismaClient } from '@sammo-ts/infra';
@@ -106,7 +107,7 @@ export const buildAuctionOutbidRefundMessage = (options: {
nationId: options.bidder.nationId, nationId: options.bidder.nationId,
nationName: options.nation?.name ?? '재야', nationName: options.nation?.name ?? '재야',
color: options.nation?.color ?? '#000000', color: options.nation?.color ?? '#000000',
icon: options.bidder.picture ?? '', icon: resolveMessageTargetIcon(options.bidder),
}, },
text: `${options.auctionId}${options.title ?? '경매'}에 상회입찰자가 나타났습니다.`, text: `${options.auctionId}${options.title ?? '경매'}에 상회입찰자가 나타났습니다.`,
time: new Date(options.time.getTime()), time: new Date(options.time.getTime()),
+2 -1
View File
@@ -1,3 +1,4 @@
import { resolveMessageTargetIcon } from '@sammo-ts/logic';
import { createGamePostgresConnector, GamePrisma } from '@sammo-ts/infra'; import { createGamePostgresConnector, GamePrisma } from '@sammo-ts/infra';
import { ActionLogger, ItemLoader, LogFormat, isItemKey, type MessageDraft } from '@sammo-ts/logic'; import { ActionLogger, ItemLoader, LogFormat, isItemKey, type MessageDraft } from '@sammo-ts/logic';
import { resolveLegacyCompatibleUniqueConfig } from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js'; import { resolveLegacyCompatibleUniqueConfig } from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js';
@@ -124,7 +125,7 @@ export const buildAuctionBidderSystemMessage = (options: {
nationId: options.bidder.nationId, nationId: options.bidder.nationId,
nationName: options.nation?.name ?? '재야', nationName: options.nation?.name ?? '재야',
color: options.nation?.color ?? '#000000', color: options.nation?.color ?? '#000000',
icon: options.bidder.picture ?? '', icon: resolveMessageTargetIcon(options.bidder),
}, },
text: options.text, text: options.text,
time: new Date(options.time.getTime()), time: new Date(options.time.getTime()),
@@ -1,3 +1,4 @@
import { resolveMessageTargetIcon } from '@sammo-ts/logic';
import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic'; import { LogCategory, LogFormat, LogScope } from '@sammo-ts/logic';
import type { InMemoryTurnWorld } from './inMemoryWorld.js'; import type { InMemoryTurnWorld } from './inMemoryWorld.js';
@@ -127,7 +128,7 @@ export const createOpenNationBettingHandler = (options: {
nationId: general.nationId, nationId: general.nationId,
nationName: nation?.name ?? '재야', nationName: nation?.name ?? '재야',
color: nation?.color ?? '#000000', color: nation?.color ?? '#000000',
icon: general.picture ?? '', icon: resolveMessageTargetIcon(general),
}, },
text, text,
time: now, time: now,
@@ -1,3 +1,4 @@
import { resolveMessageTargetIcon } from '@sammo-ts/logic';
import type { import type {
ActionContextBase, ActionContextBase,
ActionContextBuilder, ActionContextBuilder,
@@ -2121,7 +2122,7 @@ export const createReservedTurnHandler = async (options: {
nationId: currentGeneral.nationId, nationId: currentGeneral.nationId,
nationName: currentNation?.name ?? '재야', nationName: currentNation?.name ?? '재야',
color: currentNation?.color ?? '#000000', color: currentNation?.color ?? '#000000',
icon: currentGeneral.picture ?? '', icon: resolveMessageTargetIcon(currentGeneral),
}; };
messages.push({ messages.push({
msgType: 'public', msgType: 'public',
@@ -1,3 +1,4 @@
import { resolveMessageTargetIcon } from '@sammo-ts/logic';
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import { asNumber, asRecord, JosaUtil } from '@sammo-ts/common'; import { asNumber, asRecord, JosaUtil } from '@sammo-ts/common';
@@ -156,7 +157,7 @@ export const createUnificationHandler = (options: {
nationId: winner.id, nationId: winner.id,
nationName: winner.name, nationName: winner.name,
color: winner.color, color: winner.color,
icon: recipient.picture ?? '', icon: resolveMessageTargetIcon(recipient),
}, },
text: `이벤트 게임으로 이민족[${invader.difficulty}]을 소환`, text: `이벤트 게임으로 이민족[${invader.difficulty}]을 소환`,
time: context.turnTime, time: context.turnTime,
@@ -1,3 +1,4 @@
import { resolveMessageTargetIcon } from '@sammo-ts/logic';
import { areSeasonRecordsFinalized } from './seasonRecords.js'; import { areSeasonRecordsFinalized } from './seasonRecords.js';
import { asRecord, HALL_OF_FAME_TYPES, resolveLegacyTextColor, type HallOfFameType } from '@sammo-ts/common'; import { asRecord, HALL_OF_FAME_TYPES, resolveLegacyTextColor, type HallOfFameType } from '@sammo-ts/common';
import { import {
@@ -246,7 +247,7 @@ const cancelPendingUniqueAuctions = async (
nationId: bidder.nationId, nationId: bidder.nationId,
nationName: nation?.name ?? '재야', nationName: nation?.name ?? '재야',
color: nation?.color ?? '#000000', color: nation?.color ?? '#000000',
icon: bidder.picture ?? '', icon: resolveMessageTargetIcon(bidder),
}, },
text: `${planned.auctionId}${planned.title}가 취소되었습니다.`, text: `${planned.auctionId}${planned.title}가 취소되었습니다.`,
time: input.completedAt, time: input.completedAt,
@@ -105,6 +105,8 @@ describe('NPC 일반 내정 턴', () => {
{ {
id: 1, id: 1,
name: 'NPC_무장', name: 'NPC_무장',
picture: '롤시나리오/다이애나.png',
imageServer: 0,
nationId: 1, nationId: 1,
cityId: 1, cityId: 1,
troopId: 0, troopId: 0,
@@ -297,7 +299,12 @@ describe('NPC 일반 내정 턴', () => {
expect.objectContaining({ expect.objectContaining({
msgType: 'public', msgType: 'public',
text: '기부는 저처럼 돈 많은 사람들이 많이 하면 됩니다', 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, time: logicalGameNow,
}) })
); );
+117 -4
View File
@@ -65,6 +65,7 @@ type NavigationFixture = {
draftCommandTable?: boolean; draftCommandTable?: boolean;
equipmentItemOptions?: Array<{ value: string; label: string; description?: string }>; equipmentItemOptions?: Array<{ value: string; label: string; description?: string }>;
refCommandCategories?: boolean; refCommandCategories?: boolean;
uniqueItemLimit?: { count: number; until: { year: number; month: number } | null };
currentYear?: number; currentYear?: number;
currentMonth?: number; currentMonth?: number;
mapName?: string; mapName?: string;
@@ -753,6 +754,7 @@ const installFixture = async (page: Page | BrowserContext, state: NavigationFixt
} }
if (operation === 'world.getMap') { if (operation === 'world.getMap') {
return response({ return response({
uniqueItemLimit: state.uniqueItemLimit,
result: true, result: true,
version: 0, version: 0,
startYear: 180, startYear: 180,
@@ -3561,8 +3563,9 @@ test('main map year exposes the Ref restriction and technology limit on desktop
nationLevel: 3, nationLevel: 3,
stage: 6, stage: 6,
npcMode: 1, npcMode: 1,
currentYear: 182, currentYear: 181,
currentMonth: 1, currentMonth: 1,
uniqueItemLimit: { count: 1, until: { year: 182, month: 12 } },
generalMeCalls: 0, generalMeCalls: 0,
operations: [], operations: [],
validMapImages: true, 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 mapPanel = page.locator(`.layout-${layout} [data-main-target="map"]`);
const title = mapPanel.locator('.map-title'); const title = mapPanel.locator('.map-title');
const tooltip = mapPanel.getByRole('tooltip'); const tooltip = mapPanel.getByRole('tooltip');
await expect(title).toHaveText(/182 1/u); await expect(title).toHaveText(/181 1/u);
await expect(title).toHaveCSS('color', 'rgb(255, 255, 0)'); await expect(title).toHaveCSS('color', 'rgb(255, 165, 0)');
await expect(title).toHaveAttribute('tabindex', '0'); await expect(title).toHaveAttribute('tabindex', '0');
await expect(tooltip).toBeHidden(); await expect(tooltip).toBeHidden();
await title.hover(); await title.hover();
await expect(tooltip).toBeVisible(); 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 geometry = await mapPanel.evaluate((panel) => {
const panelRect = panel.getBoundingClientRect(); const panelRect = panel.getBoundingClientRect();
const titleRect = panel.querySelector('.map-title')?.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; return geometry;
}; };
await page.evaluate(async () => {
await document.fonts.ready;
});
const desktopGeometry = await assertTitleTooltip('desktop'); const desktopGeometry = await assertTitleTooltip('desktop');
await testInfo.attach('main-map-year-tooltip-desktop.png', { await testInfo.attach('main-map-year-tooltip-desktop.png', {
body: await page.screenshot({ fullPage: false }), 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 }), body: await page.screenshot({ fullPage: false }),
contentType: 'image/png', 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', { await testInfo.attach('main-map-year-tooltip-geometry.json', {
body: Buffer.from(`${JSON.stringify({ desktop: desktopGeometry, mobile: mobileGeometry }, null, 2)}\n`), body: Buffer.from(`${JSON.stringify({ desktop: desktopGeometry, mobile: mobileGeometry }, null, 2)}\n`),
contentType: 'application/json', 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 }) => { test('the 939/940 boundary switches to the Ref-style 500px single document', async ({ page }) => {
@@ -6305,3 +6332,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 });
});
}
@@ -20,6 +20,7 @@ interface MapSummary {
initialLevel: number; initialLevel: number;
increaseYears: number; increaseYears: number;
}; };
uniqueItemLimit?: { count: number; until: { year: number; month: number } | null };
cityList: [number, number, number, number, number, number][]; cityList: [number, number, number, number, number, number][];
nationList: [number, string, string, number][]; nationList: [number, string, string, number][];
myCity?: number | null; myCity?: number | null;
@@ -335,6 +336,11 @@ const titleTooltipLines = computed(() => {
} else { } else {
lines.push(`기술등급 제한 : ${currentLevel}등급 (${currentLevel * limit.increaseYears + startYear}년 해제)`); 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; return lines;
}); });
+2 -2
View File
@@ -4,7 +4,6 @@ import {
DEFAULT_USER_ICON_PUBLIC_URL, DEFAULT_USER_ICON_PUBLIC_URL,
externalizeLegacyImageUrl, externalizeLegacyImageUrl,
} from './imageAssets.ts'; } from './imageAssets.ts';
import { gameFrontendRuntimeConfig } from '../config/runtimeConfig.ts';
export const DEFAULT_GENERAL_ICON_URL = `${configuredSharedIconPublicUrl()}/default.jpg`; export const DEFAULT_GENERAL_ICON_URL = `${configuredSharedIconPublicUrl()}/default.jpg`;
export const DEFAULT_GATEWAY_USER_ICON_BASE_URL = DEFAULT_USER_ICON_PUBLIC_URL; 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)) { if (normalized.startsWith('/') || /^https?:\/\//iu.test(normalized)) {
return normalized; return normalized;
} }
return `${gameFrontendRuntimeConfig.appBasePath}${normalized.replace(/^\/+/u, '')}`; // 기존 NPC 대사와 알림에는 URL 대신 picture 상대 경로가 저장되어 있다.
return resolveGeneralIconUrl({ picture: normalized, imageServer: 0 });
}; };
export const useDefaultGeneralIcon = (event: Event): void => { export const useDefaultGeneralIcon = (event: Event): void => {
@@ -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', () => { void it('translates legacy message d_pic references without changing absolute or external icons', () => {
assert.equal( assert.equal(
resolveMessageGeneralIconUrl('d_pic/users/core2026/user name.jpg', '/gateway/api/user-icons/'), resolveMessageGeneralIconUrl('d_pic/users/core2026/user name.jpg', '/gateway/api/user-icons/'),