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
+14 -16
View File
@@ -124,27 +124,25 @@ export const loadBattleSimTraitOptions = async (): Promise<{
return cachedTraitOptions;
};
let cachedItemOptions: Promise<BattleSimItemOptions> | null = null;
let cachedItemModules: Promise<ItemModule[]> | null = null;
const toItemOption = (module: ItemModule): BattleSimItemOption => ({
key: module.key,
name: module.name,
});
export const loadBattleSimItemOptions = async (): Promise<BattleSimItemOptions> => {
if (!cachedItemOptions) {
cachedItemOptions = loadItemModules([...ITEM_KEYS]).then((modules) => {
const items: BattleSimItemOptions = {
horse: [],
weapon: [],
book: [],
item: [],
};
for (const module of modules) {
items[module.slot].push(toItemOption(module));
}
return items;
});
export const loadBattleSimItemOptions = async (
allowedKeys: ReadonlySet<string>,
itemOrder: readonly string[] | undefined
): Promise<BattleSimItemOptions> => {
cachedItemModules ??= loadItemModules([...ITEM_KEYS]);
const modules = await cachedItemModules;
const catalog = new Map(modules.map((module) => [module.key, module]));
const items: BattleSimItemOptions = { horse: [], weapon: [], book: [], item: [] };
for (const key of new Set([...(itemOrder ?? []), ...ITEM_KEYS])) {
if (!allowedKeys.has(key)) continue;
const module = catalog.get(key);
if (module) items[module.slot].push(toItemOption(module));
}
return cachedItemOptions;
return items;
};
+16 -1
View File
@@ -3,6 +3,10 @@ import { z } from 'zod';
import { asRecord } from '@sammo-ts/common';
import { getDexLevel } from '@sammo-ts/logic';
import {
resolveLegacyCompatibleUniqueConfig,
resolveLegacyPurchasableItemKeys,
} from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js';
import {
accessAuthedInputProcedure,
@@ -25,6 +29,7 @@ import {
loadBattleSimTraitOptions,
} from '../../battleSim/simulatorOptions.js';
import { getMyGeneral } from '../shared/general.js';
import { loadScenarioItemOrder } from '../../services/scenarioItemOrder.js';
const readNumber = (value: unknown, fallback = 0): number => {
if (typeof value === 'number' && Number.isFinite(value)) {
@@ -98,7 +103,17 @@ export const battleRouter = router({
}
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 {
world: {
+3 -1
View File
@@ -23,6 +23,7 @@ import {
} from '../../services/inheritance.js';
import type { GameApiContext, WorldStateRow } from '../../context.js';
import { openAuctionWithDaemon } from '../../auction/open.js';
import { loadScenarioItemOrder, orderScenarioItemEntries } from '../../services/scenarioItemOrder.js';
const BUFF_KEYS: InheritBuffType[] = [
'warAvoidRatio',
@@ -65,10 +66,11 @@ const loadAvailableUniqueItems = async (worldState: WorldStateRow) => {
const configConst = asRecord(asRecord(worldState.config).const);
const loader = new ItemLoader();
const { allItems } = await resolveLegacyCompatibleUniqueConfig(configConst, loader);
const itemOrder = await loadScenarioItemOrder(worldState.scenarioCode);
const enabledKeys: Array<Parameters<ItemLoader['load']>[0]> = [];
for (const slot of UNIQUE_ITEM_SLOT_ORDER) {
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)) {
enabledKeys.push(key);
}
+4 -2
View File
@@ -16,6 +16,7 @@ import {
findLegacyHallRows,
isLegacyArchiveProfile,
} from '../../services/legacyArchiveStore.js';
import { loadScenarioItemOrder, orderScenarioItemEntries } from '../../services/scenarioItemOrder.js';
const DEFAULT_BG_COLOR = '#330000';
const DEFAULT_FG_COLOR = '#ffffff';
@@ -98,7 +99,7 @@ export const rankingRouter = router({
.optional()
).query(async ({ ctx, input }) => {
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 isUnited = typeof meta.isUnited === 'number' && meta.isUnited !== 0;
@@ -309,6 +310,7 @@ export const rankingRouter = router({
if (Object.keys(uniqueConfig.allItems).length === 0) {
uniqueConfig.allItems = buildLegacyDefaultUniqueItemPool(itemRegistry);
}
const itemOrder = await loadScenarioItemOrder(worldState?.scenarioCode ?? '');
const activeAuctions = await ctx.db.auction.findMany({
where: {
type: 'UNIQUE_ITEM',
@@ -330,7 +332,7 @@ export const rankingRouter = router({
item: '도 구',
} as const;
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 item = itemRegistry.get(itemKey);
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 { resolveLegacyPurchasableItemKeys } from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js';
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';
@@ -129,25 +129,7 @@ const plainLegacyInfo = (value: string): string =>
.replace(/\s+/gu, ' ')
.trim();
// JSONB는 객체 key 순서를 보존하지 않으므로 표시 순서는 배포된 원본 resource에서 읽는다.
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 loadEquipmentTradeItemOrder = loadScenarioItemOrder;
export const buildEquipmentTradeItemOptions = (options: {
configConst: Record<string, unknown>;