From f5264352f9c3797666a02a03f6e6dac31141dd82 Mon Sep 17 00:00:00 2001 From: hided62 Date: Wed, 23 Sep 2026 01:32:35 +0000 Subject: [PATCH] =?UTF-8?q?fix(game-api):=20=EC=8B=9C=EB=82=98=EB=A6=AC?= =?UTF-8?q?=EC=98=A4=20=EC=95=84=EC=9D=B4=ED=85=9C=20=ED=91=9C=EC=8B=9C=20?= =?UTF-8?q?=EC=88=9C=EC=84=9C=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JSONB에 저장된 품목과 수량을 유지하며 원본 시나리오 순서로 구매·명장일람·유산·전투 시뮬레이터 선택지를 구성한다. --- .../src/battleSim/simulatorOptions.ts | 30 +++++----- app/game-api/src/router/battle/index.ts | 17 +++++- app/game-api/src/router/inherit/index.ts | 4 +- app/game-api/src/router/ranking/index.ts | 6 +- .../src/services/scenarioItemOrder.ts | 44 ++++++++++++++ app/game-api/src/turns/commandInput.ts | 22 +------ app/game-api/test/battleSimRouter.test.ts | 33 ++++++++++ app/game-api/test/inheritRouter.test.ts | 24 +++++++- app/game-api/test/rankingRouter.test.ts | 26 +++++++- app/game-frontend/e2e/battleSimulator.spec.ts | 60 ++++++++++++++++++- 10 files changed, 222 insertions(+), 44 deletions(-) create mode 100644 app/game-api/src/services/scenarioItemOrder.ts diff --git a/app/game-api/src/battleSim/simulatorOptions.ts b/app/game-api/src/battleSim/simulatorOptions.ts index 0d084147..8fb500db 100644 --- a/app/game-api/src/battleSim/simulatorOptions.ts +++ b/app/game-api/src/battleSim/simulatorOptions.ts @@ -124,27 +124,25 @@ export const loadBattleSimTraitOptions = async (): Promise<{ return cachedTraitOptions; }; -let cachedItemOptions: Promise | null = null; +let cachedItemModules: Promise | null = null; const toItemOption = (module: ItemModule): BattleSimItemOption => ({ key: module.key, name: module.name, }); -export const loadBattleSimItemOptions = async (): Promise => { - 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, + itemOrder: readonly string[] | undefined +): Promise => { + 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; }; diff --git a/app/game-api/src/router/battle/index.ts b/app/game-api/src/router/battle/index.ts index 6746ba4f..1a729c14 100644 --- a/app/game-api/src/router/battle/index.ts +++ b/app/game-api/src/router/battle/index.ts @@ -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: { diff --git a/app/game-api/src/router/inherit/index.ts b/app/game-api/src/router/inherit/index.ts index 928f7a30..30a0d5da 100644 --- a/app/game-api/src/router/inherit/index.ts +++ b/app/game-api/src/router/inherit/index.ts @@ -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[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); } diff --git a/app/game-api/src/router/ranking/index.ts b/app/game-api/src/router/ranking/index.ts index c154796d..7aa4e482 100644 --- a/app/game-api/src/router/ranking/index.ts +++ b/app/game-api/src/router/ranking/index.ts @@ -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) { diff --git a/app/game-api/src/services/scenarioItemOrder.ts b/app/game-api/src/services/scenarioItemOrder.ts new file mode 100644 index 00000000..5535e7cc --- /dev/null +++ b/app/game-api/src/services/scenarioItemOrder.ts @@ -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 => { + 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 = ( + entries: Record, + 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]; +}; diff --git a/app/game-api/src/turns/commandInput.ts b/app/game-api/src/turns/commandInput.ts index 3dd31af4..da49afaa 100644 --- a/app/game-api/src/turns/commandInput.ts +++ b/app/game-api/src/turns/commandInput.ts @@ -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 => { - 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; diff --git a/app/game-api/test/battleSimRouter.test.ts b/app/game-api/test/battleSimRouter.test.ts index 0d80113f..cffa3c2c 100644 --- a/app/game-api/test/battleSimRouter.test.ts +++ b/app/game-api/test/battleSimRouter.test.ts @@ -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 = { diff --git a/app/game-api/test/inheritRouter.test.ts b/app/game-api/test/inheritRouter.test.ts index 34a4e00c..99bf3a11 100644 --- a/app/game-api/test/inheritRouter.test.ts +++ b/app/game-api/test/inheritRouter.test.ts @@ -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; + scenarioCode?: string; configMap?: Record; 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({ diff --git a/app/game-api/test/rankingRouter.test.ts b/app/game-api/test/rankingRouter.test.ts index f6b7a424..56727a58 100644 --- a/app/game-api/test/rankingRouter.test.ts +++ b/app/game-api/test/rankingRouter.test.ts @@ -107,6 +107,8 @@ const buildContext = (options?: { includeOwnerDisplayName?: boolean; profileId?: string; generals?: RankingGeneralRow[]; + scenarioCode?: string; + allItems?: Record>; rankRows?: Array<{ generalId: number; type: string; value: number }>; gameHistoryFindMany?: (args: unknown) => Promise>; }): 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]); diff --git a/app/game-frontend/e2e/battleSimulator.spec.ts b/app/game-frontend/e2e/battleSimulator.spec.ts index c2de1976..2a30ae85 100644 --- a/app/game-frontend/e2e/battleSimulator.spec.ts +++ b/app/game-frontend/e2e/battleSimulator.spec.ts @@ -1,5 +1,5 @@ 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 { fileURLToPath } from 'node:url'; import { @@ -63,7 +63,20 @@ const simulatorFormOptions = { eventDomesticTraits: [{ key: 'che_event_신산', name: '신산', info: '계략 강화' }], warTraits: [{ 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: [ { level: 0, 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 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(); await expect(nationTypeSelects).toHaveValue('che_도적'); await expect(nationTypeSelects.locator('option[value="che_중립"]')).toHaveCount(0);