fix(game-api): 시나리오 아이템 표시 순서 통일
JSONB에 저장된 품목과 수량을 유지하며 원본 시나리오 순서로 구매·명장일람·유산·전투 시뮬레이터 선택지를 구성한다.
This commit is contained in:
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
};
|
||||
@@ -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>;
|
||||
|
||||
@@ -353,6 +353,39 @@ describe('battle router orchestration', () => {
|
||||
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 () => {
|
||||
const battleSim = new QueuedBattleSimTransport();
|
||||
const state: WorldStateRow = {
|
||||
|
||||
@@ -110,6 +110,7 @@ const buildContext = (options: {
|
||||
rankRows?: Array<{ type: string; value: number }>;
|
||||
inheritanceLogs?: Array<{ id: number; year: number; month: number; text: string; createdAt: Date }>;
|
||||
configConst?: Record<string, unknown>;
|
||||
scenarioCode?: string;
|
||||
configMap?: Record<string, unknown>;
|
||||
daemonResult?: TurnDaemonCommandResult;
|
||||
requestId?: string;
|
||||
@@ -141,9 +142,10 @@ const buildContext = (options: {
|
||||
const webPushOutboxCreateMany = vi.fn(async () => ({ count: 1 }));
|
||||
const activeWorldState =
|
||||
options.configConst === undefined && options.configMap === undefined
|
||||
? worldState
|
||||
? { ...worldState, scenarioCode: options.scenarioCode ?? worldState.scenarioCode }
|
||||
: {
|
||||
...worldState,
|
||||
scenarioCode: options.scenarioCode ?? worldState.scenarioCode,
|
||||
config: {
|
||||
...worldState.config,
|
||||
...(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 () => {
|
||||
const createdAt = new Date('2026-07-26T00:00:00Z');
|
||||
const fixture = buildContext({
|
||||
|
||||
@@ -107,6 +107,8 @@ const buildContext = (options?: {
|
||||
includeOwnerDisplayName?: boolean;
|
||||
profileId?: string;
|
||||
generals?: RankingGeneralRow[];
|
||||
scenarioCode?: string;
|
||||
allItems?: Record<string, Record<string, number>>;
|
||||
rankRows?: Array<{ generalId: number; type: string; value: number }>;
|
||||
gameHistoryFindMany?: (args: unknown) => Promise<Array<{ season: number; scenario: number; scenarioName: string }>>;
|
||||
}): GameApiContext => {
|
||||
@@ -157,10 +159,11 @@ const buildContext = (options?: {
|
||||
},
|
||||
worldState: {
|
||||
findFirst: async () => ({
|
||||
scenarioCode: options?.scenarioCode ?? 'default',
|
||||
meta: { isUnited: options?.isUnited ? 1 : 0 },
|
||||
config: {
|
||||
const: {
|
||||
allItems: {
|
||||
allItems: options?.allItems ?? {
|
||||
horse: { che_명마_15_적토마: 2 },
|
||||
weapon: {},
|
||||
book: {},
|
||||
@@ -287,6 +290,27 @@ describe('ranking.getBestGeneral', () => {
|
||||
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 () => {
|
||||
const result = await appRouter.createCaller(buildContext()).ranking.getBestGeneral({ view: 'npc' });
|
||||
expect(result.sections[0]?.entries.map((entry) => entry.id)).toEqual([3]);
|
||||
|
||||
Reference in New Issue
Block a user