메인 지도 연월 툴팁에 유니크 보유 한도 표시
This commit is contained in:
@@ -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,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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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 }) => {
|
||||||
|
|||||||
@@ -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;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user