장비 구매 목록을 시나리오 JSON 순서로 표시

This commit is contained in:
2026-09-07 12:30:47 +00:00
parent b2be86a9a2
commit bdb4e22028
4 changed files with 179 additions and 5 deletions
+4
View File
@@ -17,6 +17,7 @@ import {
assertReservedTurnActionAvailable,
assertReservedTurnArgsPassLegacyBasicValidation,
buildEquipmentTradeItemOptions,
loadEquipmentTradeItemOrder,
parseReservedTurnArgs,
TURN_COMMAND_NATION_COLORS,
type TurnCommandInputOptions,
@@ -272,6 +273,7 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
traits,
moduleBundle,
map,
itemOrder,
] = await Promise.all([
general.cityId > 0
? ctx.db.city.findUnique({
@@ -330,6 +332,7 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
loadBattleSimTraitOptions(),
moduleBundlePromise,
loadMapDefinitionByName(resolveMapName(worldState, ctx.profile.id)),
loadEquipmentTradeItemOrder(worldState.scenarioCode),
]);
const nationById = new Map(nations.map((entry) => [entry.id, entry]));
@@ -383,6 +386,7 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
troopNames: new Map(troops.map((entry) => [entry.troopLeaderId, entry.name])),
});
const items = buildEquipmentTradeItemOptions({
itemOrder,
configConst: asRecord(asRecord(worldState.config).const),
itemModules: moduleBundle.itemModules,
currentSecurity: city?.security ?? 0,
+27 -2
View File
@@ -11,6 +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 { loadScenarioTurnCommandProfile } from '@sammo-ts/game-engine/turn/turnCommandProfile.js';
@@ -120,8 +121,29 @@ 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 buildEquipmentTradeItemOptions = (options: {
configConst: Record<string, unknown>;
itemOrder?: readonly string[];
itemModules: readonly EquipmentTradeItemModule[];
currentSecurity: number;
generalGold: number;
@@ -148,8 +170,11 @@ export const buildEquipmentTradeItemOptions = (options: {
];
}
for (const item of options.itemModules) {
if (!item.buyable || !purchasableItemKeys.has(item.key)) {
// 원본 순서를 우선하되 구매 권한은 현재 DB 설정만 따른다. 추가된 품목은 뒤에 유지한다.
const orderedKeys = new Set([...(options.itemOrder ?? []), ...purchasableItemKeys]);
for (const key of orderedKeys) {
const item = catalog.get(key);
if (!item?.buyable || !purchasableItemKeys.has(key)) {
continue;
}
const cost = item.cost ?? 0;
+71 -2
View File
@@ -10,6 +10,7 @@ import { describe, expect, it } from 'vitest';
import {
assertReservedTurnArgsPassLegacyBasicValidation,
buildEquipmentTradeItemOptions,
loadEquipmentTradeItemOrder,
buildTurnCommandInputFields,
parseReservedTurnArgs,
sanitizeReservedTurnArgs,
@@ -222,6 +223,58 @@ describe('turn command argument input', () => {
);
});
it('preserves scenario order while filtering unavailable catalog entries in every slot', () => {
for (const slot of ['horse', 'weapon', 'book', 'item'] as const) {
const items = buildEquipmentTradeItemOptions({
configConst: {
allItems: { [slot]: { z_last_code: 0, missing: 0, unique: 1, blocked: 0, a_first_code: -1 } },
},
itemModules: [
{ ...buildShopItem('a_first_code', '첫 코드'), slot },
{ ...buildShopItem('z_last_code', '마지막 코드'), slot },
{ ...buildShopItem('unique', '유니크'), slot },
{ ...buildShopItem('blocked', '비매품'), slot, buyable: false },
],
currentSecurity: 5000,
generalGold: 1000,
ownedItems: { horse: null, weapon: null, book: null, item: null },
});
expect(items[slot].map((item) => item.value)).toEqual(['None', 'z_last_code', 'a_first_code']);
}
});
it('restores original scenario order after DB key reordering without restoring removed purchase permissions', async () => {
const scenario = await loadScenarioDefinitionById(2701);
const pool = scenario.config.const.allItems as Record<string, Record<string, number>>;
const originalKeys = Object.keys(pool.item!);
const buyableKeys = originalKeys.filter((key) => pool.item![key]! <= 0);
const removedKey = buyableKeys[1]!;
const reorderedPool = Object.fromEntries(Object.entries(pool.item!).reverse());
reorderedPool[removedKey] = 1;
reorderedPool.runtime_added = 0;
const items = buildEquipmentTradeItemOptions({
configConst: { allItems: { item: reorderedPool } },
itemOrder: await loadEquipmentTradeItemOrder('scenario_2701.json'),
itemModules: [...buyableKeys]
.reverse()
.concat('runtime_added')
.map((key) => buildShopItem(key, key)),
currentSecurity: 5000,
generalGold: 1000,
ownedItems: { horse: null, weapon: null, book: null, item: null },
});
expect(items.item.map((item) => item.value)).toEqual([
'None',
...buyableKeys.filter((key) => key !== removedKey),
'runtime_added',
]);
expect((await loadEquipmentTradeItemOrder('1'))?.filter((key) => key.startsWith('che_치료'))).toEqual([
'che_치료_환약',
]);
expect(await loadEquipmentTradeItemOrder('unknown')).toBeUndefined();
expect(await loadEquipmentTradeItemOrder('999999')).toBeUndefined();
});
it('keeps owned unique equipment sales outside the purchase pool in every slot', async () => {
for (const slot of ['horse', 'weapon', 'book', 'item'] as const) {
const owned = { ...buildShopItem('owned_unique', '보유 유니크'), slot, buyable: false, cost: 10001 };
@@ -245,12 +298,28 @@ describe('turn command argument input', () => {
const items = buildEquipmentTradeItemOptions({
ownedItems: { horse: null, weapon: null, book: null, item: null },
configConst: {},
itemModules: [buildShopItem('che_치료_환약', '환약'), buildShopItem('event_전투특기_격노', '격노의 비급')],
itemModules: [
buildShopItem('event_전투특기_격노', '격노의 비급'),
buildShopItem('che_계략_향낭', '향낭'),
buildShopItem('che_계략_이추', '이추'),
buildShopItem('che_훈련_청주', '청주'),
buildShopItem('che_사기_탁주', '탁주'),
buildShopItem('che_저격_수극', '수극'),
buildShopItem('che_치료_환약', '환약'),
],
currentSecurity: 5000,
generalGold: 1000,
});
expect(items.item.map((item) => item.value)).toEqual(['None', 'che_치료_환약']);
expect(items.item.map((item) => item.value)).toEqual([
'None',
'che_치료_환약',
'che_저격_수극',
'che_사기_탁주',
'che_훈련_청주',
'che_계략_이추',
'che_계략_향낭',
]);
expect(items.item[1]?.description).toBe('현재 구입 가능 · 가격 100 · 환약 · 설명');
});