From bdb4e22028606d5c5efbf88db45c119744fa5467 Mon Sep 17 00:00:00 2001 From: hided62 Date: Mon, 7 Sep 2026 12:30:47 +0000 Subject: [PATCH] =?UTF-8?q?=EC=9E=A5=EB=B9=84=20=EA=B5=AC=EB=A7=A4=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=EC=9D=84=20=EC=8B=9C=EB=82=98=EB=A6=AC?= =?UTF-8?q?=EC=98=A4=20JSON=20=EC=88=9C=EC=84=9C=EB=A1=9C=20=ED=91=9C?= =?UTF-8?q?=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/game-api/src/router/turns/index.ts | 4 + app/game-api/src/turns/commandInput.ts | 29 +++++++- app/game-api/test/commandInput.test.ts | 73 +++++++++++++++++- app/game-frontend/e2e/mainNavigation.spec.ts | 78 +++++++++++++++++++- 4 files changed, 179 insertions(+), 5 deletions(-) diff --git a/app/game-api/src/router/turns/index.ts b/app/game-api/src/router/turns/index.ts index ec72cd0d..37f60fed 100644 --- a/app/game-api/src/router/turns/index.ts +++ b/app/game-api/src/router/turns/index.ts @@ -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, diff --git a/app/game-api/src/turns/commandInput.ts b/app/game-api/src/turns/commandInput.ts index 1788bd59..68eb7598 100644 --- a/app/game-api/src/turns/commandInput.ts +++ b/app/game-api/src/turns/commandInput.ts @@ -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 => { + 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; + 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; diff --git a/app/game-api/test/commandInput.test.ts b/app/game-api/test/commandInput.test.ts index c728a928..ffa02c75 100644 --- a/app/game-api/test/commandInput.test.ts +++ b/app/game-api/test/commandInput.test.ts @@ -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>; + 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 · 환약 · 설명'); }); diff --git a/app/game-frontend/e2e/mainNavigation.spec.ts b/app/game-frontend/e2e/mainNavigation.spec.ts index 03cc681e..5de7ddb0 100644 --- a/app/game-frontend/e2e/mainNavigation.spec.ts +++ b/app/game-frontend/e2e/mainNavigation.spec.ts @@ -2,7 +2,9 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { resolve } from 'node:path'; import { expect, test, type Locator, type Page, type Route } from '@playwright/test'; -import { buildEquipmentTradeItemOptions } from '../../game-api/src/turns/commandInput.js'; +import { buildEquipmentTradeItemOptions, loadEquipmentTradeItemOrder } from '../../game-api/src/turns/commandInput.js'; +import { loadScenarioDefinitionById } from '@sammo-ts/game-engine/scenario/scenarioLoader.js'; +import { ITEM_KEYS, loadItemModules } from '@sammo-ts/logic/items/index.js'; const response = (data: unknown) => ({ result: { data } }); const runtimeNavigation = JSON.parse( @@ -5130,6 +5132,80 @@ for (const viewport of [ { name: 'desktop', width: 1200, height: 900 }, { name: 'mobile', width: 500, height: 900 }, ] as const) { + test(`preserves scenario JSON equipment purchase order on ${viewport.name}`, async ({ page }) => { + const scenario = await loadScenarioDefinitionById(2701); + const modules = await loadItemModules([...ITEM_KEYS]); + const pool = scenario.config.const.allItems as Record>; + const expectedKeys = Object.keys(pool.item!).filter( + (key) => pool.item![key]! <= 0 && modules.some((item) => item.key === key && item.buyable) + ); + const items = buildEquipmentTradeItemOptions({ + configConst: { + allItems: Object.fromEntries( + Object.entries(pool).map(([slot, entries]) => [ + slot, + Object.fromEntries(Object.entries(entries).reverse()), + ]) + ), + }, + itemOrder: await loadEquipmentTradeItemOrder('2701'), + itemModules: modules, + currentSecurity: 5000, + generalGold: 100000, + ownedItems: { horse: null, weapon: null, book: null, item: null }, + }); + const state: NavigationFixture = { + officerLevel: 5, + permission: 2, + nationLevel: 3, + stage: 0, + npcMode: 1, + generalMeCalls: 0, + operations: [], + draftCommandTable: true, + equipmentItemOptions: items.item.map((option) => ({ ...option, value: String(option.value) })), + reservedTurns: Array.from({ length: 30 }, (_, index) => ({ index, action: '휴식', args: {} })), + }; + await installFixture(page, state); + await page.setViewportSize({ width: viewport.width, height: viewport.height }); + await waitForMain(page); + await page.getByRole('button', { name: '1턴 명령 입력', exact: true }).click(); + const picker = page.getByTestId('command-picker'); + await picker.getByRole('button', { name: '국가', exact: true }).click(); + await picker.getByRole('button', { name: '장비 매매', exact: true }).click(); + await picker.getByLabel('장비 종류', { exact: true }).selectOption('item'); + const equipment = picker.getByLabel('장비', { exact: true }); + await equipment.click(); + await equipment.press('Escape'); + expect( + await equipment + .locator('option') + .evaluateAll((options) => options.map((option) => (option as HTMLOptionElement).value)) + ).toEqual(['None', ...expectedKeys]); + await expect(equipment.locator('option').nth(1)).toHaveText('환약(치료)'); + await equipment.selectOption('che_훈련_청주'); + await equipment.focus(); + await expect(equipment).toBeFocused(); + await page.evaluate(() => document.fonts.ready); + const artifact = await equipment.evaluate((element) => ({ + rect: element.getBoundingClientRect().toJSON(), + font: getComputedStyle(element).font, + color: getComputedStyle(element).color, + html: element.outerHTML, + selected: (element as HTMLSelectElement).value, + documentWidth: document.documentElement.scrollWidth, + })); + expect(artifact.documentWidth).toBeLessThanOrEqual(viewport.width); + await writeFile( + test.info().outputPath(`equipment-order-${viewport.name}.json`), + JSON.stringify(artifact, null, 2) + ); + await picker.screenshot({ path: test.info().outputPath(`equipment-order-${viewport.name}.png`) }); + const request = page.waitForRequest((entry) => entry.url().includes('turns.reserved.setGeneral')); + await picker.getByRole('button', { name: '입력', exact: true }).click(); + expect(JSON.stringify((await request).postDataJSON())).toContain('"itemCode":"che_훈련_청주"'); + }); + test(`reserves owned unique equipment sales and preserves the slot brief on ${viewport.name}`, async ({ page }) => { const items = buildEquipmentTradeItemOptions({ configConst: {},