fix(game-api): 시나리오 아이템 표시 순서 통일

JSONB에 저장된 품목과 수량을 유지하며 원본 시나리오 순서로 구매·명장일람·유산·전투 시뮬레이터 선택지를 구성한다.
This commit is contained in:
2026-09-23 01:32:35 +00:00
parent 908ae72cba
commit f5264352f9
10 changed files with 222 additions and 44 deletions
+13 -15
View File
@@ -124,27 +124,25 @@ export const loadBattleSimTraitOptions = async (): Promise<{
return cachedTraitOptions; return cachedTraitOptions;
}; };
let cachedItemOptions: Promise<BattleSimItemOptions> | null = null; let cachedItemModules: Promise<ItemModule[]> | null = null;
const toItemOption = (module: ItemModule): BattleSimItemOption => ({ const toItemOption = (module: ItemModule): BattleSimItemOption => ({
key: module.key, key: module.key,
name: module.name, name: module.name,
}); });
export const loadBattleSimItemOptions = async (): Promise<BattleSimItemOptions> => { export const loadBattleSimItemOptions = async (
if (!cachedItemOptions) { allowedKeys: ReadonlySet<string>,
cachedItemOptions = loadItemModules([...ITEM_KEYS]).then((modules) => { itemOrder: readonly string[] | undefined
const items: BattleSimItemOptions = { ): Promise<BattleSimItemOptions> => {
horse: [], cachedItemModules ??= loadItemModules([...ITEM_KEYS]);
weapon: [], const modules = await cachedItemModules;
book: [], const catalog = new Map(modules.map((module) => [module.key, module]));
item: [], const items: BattleSimItemOptions = { horse: [], weapon: [], book: [], item: [] };
}; for (const key of new Set([...(itemOrder ?? []), ...ITEM_KEYS])) {
for (const module of modules) { if (!allowedKeys.has(key)) continue;
items[module.slot].push(toItemOption(module)); const module = catalog.get(key);
if (module) items[module.slot].push(toItemOption(module));
} }
return items; return items;
});
}
return cachedItemOptions;
}; };
+16 -1
View File
@@ -3,6 +3,10 @@ import { z } from 'zod';
import { asRecord } from '@sammo-ts/common'; import { asRecord } from '@sammo-ts/common';
import { getDexLevel } from '@sammo-ts/logic'; import { getDexLevel } from '@sammo-ts/logic';
import {
resolveLegacyCompatibleUniqueConfig,
resolveLegacyPurchasableItemKeys,
} from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js';
import { import {
accessAuthedInputProcedure, accessAuthedInputProcedure,
@@ -25,6 +29,7 @@ import {
loadBattleSimTraitOptions, loadBattleSimTraitOptions,
} from '../../battleSim/simulatorOptions.js'; } from '../../battleSim/simulatorOptions.js';
import { getMyGeneral } from '../shared/general.js'; import { getMyGeneral } from '../shared/general.js';
import { loadScenarioItemOrder } from '../../services/scenarioItemOrder.js';
const readNumber = (value: unknown, fallback = 0): number => { const readNumber = (value: unknown, fallback = 0): number => {
if (typeof value === 'number' && Number.isFinite(value)) { if (typeof value === 'number' && Number.isFinite(value)) {
@@ -98,7 +103,17 @@ export const battleRouter = router({
} }
const environment = await buildBattleSimEnvironment(worldState, ctx.profile.id); const environment = await buildBattleSimEnvironment(worldState, ctx.profile.id);
const [traits, items] = await Promise.all([loadBattleSimTraitOptions(), loadBattleSimItemOptions()]); const configConst = asRecord(asRecord(worldState.config).const);
const [traits, itemOrder, uniqueConfig] = await Promise.all([
loadBattleSimTraitOptions(),
loadScenarioItemOrder(worldState.scenarioCode),
resolveLegacyCompatibleUniqueConfig(configConst),
]);
const allowedItems = new Set([
...resolveLegacyPurchasableItemKeys(configConst),
...Object.values(uniqueConfig.allItems).flatMap((entries) => Object.keys(entries ?? {})),
]);
const items = await loadBattleSimItemOptions(allowedItems, itemOrder);
return { return {
world: { world: {
+3 -1
View File
@@ -23,6 +23,7 @@ import {
} from '../../services/inheritance.js'; } from '../../services/inheritance.js';
import type { GameApiContext, WorldStateRow } from '../../context.js'; import type { GameApiContext, WorldStateRow } from '../../context.js';
import { openAuctionWithDaemon } from '../../auction/open.js'; import { openAuctionWithDaemon } from '../../auction/open.js';
import { loadScenarioItemOrder, orderScenarioItemEntries } from '../../services/scenarioItemOrder.js';
const BUFF_KEYS: InheritBuffType[] = [ const BUFF_KEYS: InheritBuffType[] = [
'warAvoidRatio', 'warAvoidRatio',
@@ -65,10 +66,11 @@ const loadAvailableUniqueItems = async (worldState: WorldStateRow) => {
const configConst = asRecord(asRecord(worldState.config).const); const configConst = asRecord(asRecord(worldState.config).const);
const loader = new ItemLoader(); const loader = new ItemLoader();
const { allItems } = await resolveLegacyCompatibleUniqueConfig(configConst, loader); const { allItems } = await resolveLegacyCompatibleUniqueConfig(configConst, loader);
const itemOrder = await loadScenarioItemOrder(worldState.scenarioCode);
const enabledKeys: Array<Parameters<ItemLoader['load']>[0]> = []; const enabledKeys: Array<Parameters<ItemLoader['load']>[0]> = [];
for (const slot of UNIQUE_ITEM_SLOT_ORDER) { for (const slot of UNIQUE_ITEM_SLOT_ORDER) {
const entries = allItems[slot] ?? {}; const entries = allItems[slot] ?? {};
for (const [key, amount] of Object.entries(asRecord(entries))) { for (const [key, amount] of orderScenarioItemEntries(asRecord(entries), itemOrder)) {
if (asNumber(amount, 0) !== 0 && isItemKey(key)) { if (asNumber(amount, 0) !== 0 && isItemKey(key)) {
enabledKeys.push(key); enabledKeys.push(key);
} }
+4 -2
View File
@@ -16,6 +16,7 @@ import {
findLegacyHallRows, findLegacyHallRows,
isLegacyArchiveProfile, isLegacyArchiveProfile,
} from '../../services/legacyArchiveStore.js'; } from '../../services/legacyArchiveStore.js';
import { loadScenarioItemOrder, orderScenarioItemEntries } from '../../services/scenarioItemOrder.js';
const DEFAULT_BG_COLOR = '#330000'; const DEFAULT_BG_COLOR = '#330000';
const DEFAULT_FG_COLOR = '#ffffff'; const DEFAULT_FG_COLOR = '#ffffff';
@@ -98,7 +99,7 @@ export const rankingRouter = router({
.optional() .optional()
).query(async ({ ctx, input }) => { ).query(async ({ ctx, input }) => {
const worldState = await ctx.db.worldState.findFirst({ const worldState = await ctx.db.worldState.findFirst({
select: { meta: true, config: true }, select: { meta: true, config: true, scenarioCode: true },
}); });
const meta = asRecord(worldState?.meta); const meta = asRecord(worldState?.meta);
const isUnited = typeof meta.isUnited === 'number' && meta.isUnited !== 0; const isUnited = typeof meta.isUnited === 'number' && meta.isUnited !== 0;
@@ -309,6 +310,7 @@ export const rankingRouter = router({
if (Object.keys(uniqueConfig.allItems).length === 0) { if (Object.keys(uniqueConfig.allItems).length === 0) {
uniqueConfig.allItems = buildLegacyDefaultUniqueItemPool(itemRegistry); uniqueConfig.allItems = buildLegacyDefaultUniqueItemPool(itemRegistry);
} }
const itemOrder = await loadScenarioItemOrder(worldState?.scenarioCode ?? '');
const activeAuctions = await ctx.db.auction.findMany({ const activeAuctions = await ctx.db.auction.findMany({
where: { where: {
type: 'UNIQUE_ITEM', type: 'UNIQUE_ITEM',
@@ -330,7 +332,7 @@ export const rankingRouter = router({
item: '도 구', item: '도 구',
} as const; } as const;
const itemEntries = (['horse', 'weapon', 'book', 'item'] as const).map((slot) => { const itemEntries = (['horse', 'weapon', 'book', 'item'] as const).map((slot) => {
const configuredItems = Object.entries(uniqueConfig.allItems[slot] ?? {}).reverse(); const configuredItems = orderScenarioItemEntries(uniqueConfig.allItems[slot] ?? {}, itemOrder).reverse();
const entries = configuredItems.flatMap(([itemKey, rawCount]) => { const entries = configuredItems.flatMap(([itemKey, rawCount]) => {
const item = itemRegistry.get(itemKey); const item = itemRegistry.get(itemKey);
if (!item || item.buyable) { if (!item || item.buyable) {
@@ -0,0 +1,44 @@
import { asRecord, isRecord } from '@sammo-ts/common';
import { loadScenarioDefinitionById } from '@sammo-ts/game-engine/scenario/scenarioLoader.js';
import {
loadLegacyDefaultUniqueItemPool,
resolveLegacyPurchasableItemKeys,
} from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js';
/** JSONB does not retain the item key order declared by the scenario resource. */
export const loadScenarioItemOrder = async (scenarioCode: string): Promise<readonly string[] | undefined> => {
const normalized = scenarioCode.replace(/^scenario_/i, '').replace(/\.json$/i, '');
if (!/^\d+$/.test(normalized) || !Number.isSafeInteger(Number(normalized))) {
return undefined;
}
try {
const scenario = await loadScenarioDefinitionById(Number(normalized));
const configConst = scenario.config.const;
const keys = Object.values(asRecord(configConst.allItems)).flatMap((entries) => Object.keys(asRecord(entries)));
if (keys.length > 0) return keys;
const defaultUniques = await loadLegacyDefaultUniqueItemPool();
return [
...resolveLegacyPurchasableItemKeys(configConst),
...Object.values(defaultUniques).flatMap((entries) => Object.keys(entries ?? {})),
];
} catch (error) {
// Old saved seasons can outlive their scenario resource. Keep the stored entries available.
if (isRecord(error) && error.code === 'ENOENT') return undefined;
throw error;
}
};
export const orderScenarioItemEntries = <T>(
entries: Record<string, T>,
itemOrder: readonly string[] | undefined
): [string, T][] => {
const ordered: [string, T][] = [];
const remaining = new Map(Object.entries(entries));
for (const key of itemOrder ?? []) {
if (!remaining.has(key)) continue;
ordered.push([key, remaining.get(key)!]);
remaining.delete(key);
}
return [...ordered, ...remaining];
};
+2 -20
View File
@@ -11,7 +11,7 @@ import { asRecord, isRecord } from '@sammo-ts/common';
import type { ItemModule } from '@sammo-ts/logic/items/types.js'; import type { ItemModule } from '@sammo-ts/logic/items/types.js';
import { resolveLegacyPurchasableItemKeys } from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js'; import { resolveLegacyPurchasableItemKeys } from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js';
import { z } from 'zod'; import { z } from 'zod';
import { loadScenarioDefinitionById } from '@sammo-ts/game-engine/scenario/scenarioLoader.js'; import { loadScenarioItemOrder } from '../services/scenarioItemOrder.js';
import { loadScenarioTurnCommandProfile } from '@sammo-ts/game-engine/turn/turnCommandProfile.js'; import { loadScenarioTurnCommandProfile } from '@sammo-ts/game-engine/turn/turnCommandProfile.js';
@@ -129,25 +129,7 @@ const plainLegacyInfo = (value: string): string =>
.replace(/\s+/gu, ' ') .replace(/\s+/gu, ' ')
.trim(); .trim();
// JSONB는 객체 key 순서를 보존하지 않으므로 표시 순서는 배포된 원본 resource에서 읽는다. export const loadEquipmentTradeItemOrder = loadScenarioItemOrder;
export const loadEquipmentTradeItemOrder = async (scenarioCode: string): Promise<readonly string[] | undefined> => {
const normalized = scenarioCode.replace(/^scenario_/i, '').replace(/\.json$/i, '');
if (!/^\d+$/.test(normalized) || !Number.isSafeInteger(Number(normalized))) {
return undefined;
}
try {
const scenario = await loadScenarioDefinitionById(Number(normalized));
const configConst = scenario.config.const;
const keys = Object.values(asRecord(configConst.allItems)).flatMap((entries) => Object.keys(asRecord(entries)));
return keys.length > 0 ? keys : [...resolveLegacyPurchasableItemKeys(configConst)];
} catch (error) {
// 보존된 시즌의 resource가 없는 경우에도 현재 DB의 구매/판매 선택지는 유지한다.
if (isRecord(error) && error.code === 'ENOENT') {
return undefined;
}
throw error;
}
};
export const buildEquipmentTradeItemOptions = (options: { export const buildEquipmentTradeItemOptions = (options: {
configConst: Record<string, unknown>; configConst: Record<string, unknown>;
+33
View File
@@ -353,6 +353,39 @@ describe('battle router orchestration', () => {
expect(worldStateReads).toBe(1); expect(worldStateReads).toBe(1);
}); });
it('offers simulator equipment and consumables in source order for the selected scenario', async () => {
const state: WorldStateRow = {
id: 1,
scenarioCode: '905',
currentYear: 200,
currentMonth: 1,
tickSeconds: 600,
config: {
const: {
allItems: {
item: { che_필살_둔갑천서: 1, che_의술_정력견혈산: 1, che_치료_환약: 0 },
horse: { che_명마_15_적토마: 1, che_명마_07_백마: 1, che_명마_01_노기: 0 },
},
},
},
meta: {},
updatedAt: new Date('2026-01-01T00:00:00Z'),
};
const caller = appRouter.createCaller(buildContext({ state, battleSim: new QueuedBattleSimTransport() }));
const context = await caller.battle.getSimulatorContext();
expect(context.items.horse.map(({ key }) => key)).toEqual([
'che_명마_01_노기',
'che_명마_07_백마',
'che_명마_15_적토마',
]);
expect(context.items.item.map(({ key }) => key)).toEqual([
'che_치료_환약',
'che_의술_정력견혈산',
'che_필살_둔갑천서',
]);
});
it('rejects the neutral storage nation type before preparing or queuing a simulation', async () => { it('rejects the neutral storage nation type before preparing or queuing a simulation', async () => {
const battleSim = new QueuedBattleSimTransport(); const battleSim = new QueuedBattleSimTransport();
const state: WorldStateRow = { const state: WorldStateRow = {
+23 -1
View File
@@ -110,6 +110,7 @@ const buildContext = (options: {
rankRows?: Array<{ type: string; value: number }>; rankRows?: Array<{ type: string; value: number }>;
inheritanceLogs?: Array<{ id: number; year: number; month: number; text: string; createdAt: Date }>; inheritanceLogs?: Array<{ id: number; year: number; month: number; text: string; createdAt: Date }>;
configConst?: Record<string, unknown>; configConst?: Record<string, unknown>;
scenarioCode?: string;
configMap?: Record<string, unknown>; configMap?: Record<string, unknown>;
daemonResult?: TurnDaemonCommandResult; daemonResult?: TurnDaemonCommandResult;
requestId?: string; requestId?: string;
@@ -141,9 +142,10 @@ const buildContext = (options: {
const webPushOutboxCreateMany = vi.fn(async () => ({ count: 1 })); const webPushOutboxCreateMany = vi.fn(async () => ({ count: 1 }));
const activeWorldState = const activeWorldState =
options.configConst === undefined && options.configMap === undefined options.configConst === undefined && options.configMap === undefined
? worldState ? { ...worldState, scenarioCode: options.scenarioCode ?? worldState.scenarioCode }
: { : {
...worldState, ...worldState,
scenarioCode: options.scenarioCode ?? worldState.scenarioCode,
config: { config: {
...worldState.config, ...worldState.config,
...(options.configConst === undefined ? {} : { const: options.configConst }), ...(options.configConst === undefined ? {} : { const: options.configConst }),
@@ -435,6 +437,26 @@ describe('inherit router actor and permission boundaries', () => {
]); ]);
}); });
it('restores source order for unique candidates when a scenario config has JSONB key order', async () => {
const fixture = buildContext({
scenarioCode: '905',
configConst: {
allItems: {
item: { che_필살_둔갑천서: 1, che_의술_정력견혈산: 1 },
horse: { che_명마_15_적토마: 1, che_명마_07_백마: 1 },
},
},
});
const status = await appRouter.createCaller(fixture.context).inherit.getStatus();
expect(status.availableUnique.map(({ key }) => key)).toEqual([
'che_명마_07_백마',
'che_명마_15_적토마',
'che_의술_정력견혈산',
'che_필살_둔갑천서',
]);
});
it('loads the first inheritance-log page without an out-of-range integer cursor', async () => { it('loads the first inheritance-log page without an out-of-range integer cursor', async () => {
const createdAt = new Date('2026-07-26T00:00:00Z'); const createdAt = new Date('2026-07-26T00:00:00Z');
const fixture = buildContext({ const fixture = buildContext({
+25 -1
View File
@@ -107,6 +107,8 @@ const buildContext = (options?: {
includeOwnerDisplayName?: boolean; includeOwnerDisplayName?: boolean;
profileId?: string; profileId?: string;
generals?: RankingGeneralRow[]; generals?: RankingGeneralRow[];
scenarioCode?: string;
allItems?: Record<string, Record<string, number>>;
rankRows?: Array<{ generalId: number; type: string; value: number }>; rankRows?: Array<{ generalId: number; type: string; value: number }>;
gameHistoryFindMany?: (args: unknown) => Promise<Array<{ season: number; scenario: number; scenarioName: string }>>; gameHistoryFindMany?: (args: unknown) => Promise<Array<{ season: number; scenario: number; scenarioName: string }>>;
}): GameApiContext => { }): GameApiContext => {
@@ -157,10 +159,11 @@ const buildContext = (options?: {
}, },
worldState: { worldState: {
findFirst: async () => ({ findFirst: async () => ({
scenarioCode: options?.scenarioCode ?? 'default',
meta: { isUnited: options?.isUnited ? 1 : 0 }, meta: { isUnited: options?.isUnited ? 1 : 0 },
config: { config: {
const: { const: {
allItems: { allItems: options?.allItems ?? {
horse: { che_명마_15_적토마: 2 }, horse: { che_명마_15_적토마: 2 },
weapon: {}, weapon: {},
book: {}, book: {},
@@ -287,6 +290,27 @@ describe('ranking.getBestGeneral', () => {
expect(JSON.stringify(result)).not.toContain('private-user-id'); expect(JSON.stringify(result)).not.toContain('private-user-id');
}); });
it('uses the source scenario order for unique equipment and item cards after JSONB reorders their keys', async () => {
const result = await appRouter
.createCaller(
buildContext({
scenarioCode: '905',
allItems: {
horse: { che_명마_07_백마: 1, che_명마_15_적토마: 1 },
item: { che_의술_정력견혈산: 1, che_필살_둔갑천서: 1 },
},
})
)
.ranking.getBestGeneral({ view: 'user' });
expect(
result.uniqueItems.find((section) => section.slot === 'horse')?.entries.map((entry) => entry.itemKey)
).toEqual(['che_명마_15_적토마', 'che_명마_07_백마']);
expect(
result.uniqueItems.find((section) => section.slot === 'item')?.entries.map((entry) => entry.itemKey)
).toEqual(['che_필살_둔갑천서', 'che_의술_정력견혈산']);
});
it('separates autonomous NPCs from users and possessed generals', async () => { it('separates autonomous NPCs from users and possessed generals', async () => {
const result = await appRouter.createCaller(buildContext()).ranking.getBestGeneral({ view: 'npc' }); const result = await appRouter.createCaller(buildContext()).ranking.getBestGeneral({ view: 'npc' });
expect(result.sections[0]?.entries.map((entry) => entry.id)).toEqual([3]); expect(result.sections[0]?.entries.map((entry) => entry.id)).toEqual([3]);
+58 -2
View File
@@ -1,5 +1,5 @@
import { expect, test, type Page, type Route } from '@playwright/test'; import { expect, test, type Page, type Route } from '@playwright/test';
import { readFile } from 'node:fs/promises'; import { readFile, writeFile } from 'node:fs/promises';
import { dirname, resolve } from 'node:path'; import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { import {
@@ -63,7 +63,20 @@ const simulatorFormOptions = {
eventDomesticTraits: [{ key: 'che_event_신산', name: '신산', info: '계략 강화' }], eventDomesticTraits: [{ key: 'che_event_신산', name: '신산', info: '계략 강화' }],
warTraits: [{ key: 'che_필살', name: '필살', info: '필살 확률 증가' }], warTraits: [{ key: 'che_필살', name: '필살', info: '필살 확률 증가' }],
personalities: [{ key: 'che_대담', name: '대담', info: '공격적인 성격' }], personalities: [{ key: 'che_대담', name: '대담', info: '공격적인 성격' }],
items: { horse: [], weapon: [], book: [], item: [] }, items: {
horse: [
{ key: 'che_명마_01_노기', name: '노기' },
{ key: 'che_명마_07_백마', name: '백마' },
{ key: 'che_명마_15_적토마', name: '적토마' },
],
weapon: [],
book: [],
item: [
{ key: 'che_치료_환약', name: '환약' },
{ key: 'che_의술_정력견혈산', name: '정력견혈산' },
{ key: 'che_필살_둔갑천서', name: '둔갑천서' },
],
},
nationLevels: [ nationLevels: [
{ level: 0, name: '방랑군' }, { level: 0, name: '방랑군' },
{ level: 1, name: '소국' }, { level: 1, name: '소국' },
@@ -372,6 +385,49 @@ test('operates independent/game presets, imports my general, and renders battle
await page.setViewportSize({ width: 1280, height: 900 }); await page.setViewportSize({ width: 1280, height: 900 });
await gotoSimulator(page); await gotoSimulator(page);
expect(
await page
.getByRole('combobox', { name: '명마' })
.first()
.locator('option')
.evaluateAll((nodes) => nodes.map((node) => (node as HTMLOptionElement).value))
).toEqual(['-', 'che_명마_01_노기', 'che_명마_07_백마', 'che_명마_15_적토마']);
expect(
await page
.getByRole('combobox', { name: '도구' })
.first()
.locator('option')
.evaluateAll((nodes) => nodes.map((node) => (node as HTMLOptionElement).value))
).toEqual(['-', 'che_치료_환약', 'che_의술_정력견혈산', 'che_필살_둔갑천서']);
if (artifactRoot) {
const selects = await page.getByRole('combobox', { name: /^(명마|도구)$/ }).evaluateAll((nodes) =>
nodes.map((node) => {
const element = node as HTMLSelectElement;
const rect = element.getBoundingClientRect();
const style = getComputedStyle(element);
return {
label: element.labels?.[0]?.textContent?.trim(),
options: Array.from(element.options, (option) => ({ value: option.value, text: option.text })),
rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
style: { fontSize: style.fontSize, color: style.color, display: style.display },
};
})
);
await writeFile(
resolve(artifactRoot, 'scenario-item-order-dom.json'),
JSON.stringify(
{
url: page.url(),
viewport: page.viewportSize(),
dpr: await page.evaluate(() => devicePixelRatio),
selects,
},
null,
2
)
);
}
const nationTypeSelects = page.locator('[data-parity-id="attacker-nation"] select').first(); const nationTypeSelects = page.locator('[data-parity-id="attacker-nation"] select').first();
await expect(nationTypeSelects).toHaveValue('che_도적'); await expect(nationTypeSelects).toHaveValue('che_도적');
await expect(nationTypeSelects.locator('option[value="che_중립"]')).toHaveCount(0); await expect(nationTypeSelects.locator('option[value="che_중립"]')).toHaveCount(0);