diff --git a/app/game-api/src/router/turns/index.ts b/app/game-api/src/router/turns/index.ts index 83bf1e88..ec72cd0d 100644 --- a/app/game-api/src/router/turns/index.ts +++ b/app/game-api/src/router/turns/index.ts @@ -387,6 +387,12 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number itemModules: moduleBundle.itemModules, currentSecurity: city?.security ?? 0, generalGold: general.gold, + ownedItems: { + horse: general.horseCode, + weapon: general.weaponCode, + book: general.bookCode, + item: general.itemCode, + }, }); const inputOptions: TurnCommandInputOptions = { cities: cities.map((entry) => ({ diff --git a/app/game-api/src/turns/commandInput.ts b/app/game-api/src/turns/commandInput.ts index e3feb2ad..1788bd59 100644 --- a/app/game-api/src/turns/commandInput.ts +++ b/app/game-api/src/turns/commandInput.ts @@ -125,14 +125,28 @@ export const buildEquipmentTradeItemOptions = (options: { itemModules: readonly EquipmentTradeItemModule[]; currentSecurity: number; generalGold: number; + ownedItems: Readonly>; }): TurnCommandInputOptions['items'] => { const purchasableItemKeys = resolveLegacyPurchasableItemKeys(options.configConst); - const items: TurnCommandInputOptions['items'] = { - horse: [{ value: 'None', label: '판매/해제' }], - weapon: [{ value: 'None', label: '판매/해제' }], - book: [{ value: 'None', label: '판매/해제' }], - item: [{ value: 'None', label: '판매/해제' }], - }; + const items: TurnCommandInputOptions['items'] = {}; + const catalog = new Map(options.itemModules.map((item) => [item.key, item])); + // Ref의 소유 물품 판매는 구매 허용 목록과 독립적으로 전체 catalog에서 해석한다. + for (const slot of ['horse', 'weapon', 'book', 'item'] as const) { + const ownedCode = options.ownedItems[slot]; + const ownedItem = ownedCode && ownedCode !== 'None' ? catalog.get(ownedCode) : undefined; + const slotName = STATIC_LABELS.itemType?.[slot] ?? slot; + items[slot] = [ + { + value: 'None', + label: ownedItem ? `${ownedItem.name} 판매` : `${slotName} 판매`, + description: ownedItem + ? `소유 물품 판매 · 판매가 ${Math.floor((ownedItem.cost ?? 0) / 2).toLocaleString()}금` + : ownedCode && ownedCode !== 'None' + ? '보유 장비 정보를 확인할 수 없습니다.' + : '현재 보유한 장비가 없습니다.', + }, + ]; + } for (const item of options.itemModules) { if (!item.buyable || !purchasableItemKeys.has(item.key)) { diff --git a/app/game-api/test/commandInput.test.ts b/app/game-api/test/commandInput.test.ts index 8dec9695..c728a928 100644 --- a/app/game-api/test/commandInput.test.ts +++ b/app/game-api/test/commandInput.test.ts @@ -222,8 +222,28 @@ describe('turn command argument input', () => { ); }); + 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 }; + const items = buildEquipmentTradeItemOptions({ + configConst: {}, + itemModules: [owned], + currentSecurity: 0, + generalGold: 0, + ownedItems: { horse: null, weapon: null, book: null, item: null, [slot]: owned.key }, + }); + expect(items[slot]).toEqual([ + { value: 'None', label: '보유 유니크 판매', description: '소유 물품 판매 · 판매가 5,000금' }, + ]); + await expect( + parseReservedTurnArgs('general', 'che_장비매매', { itemType: slot, itemCode: 'None' }) + ).resolves.toEqual({ itemType: slot, itemCode: 'None' }); + } + }); + it('limits equipment trade options to the Ref default items when a scenario omits allItems', () => { const items = buildEquipmentTradeItemOptions({ + ownedItems: { horse: null, weapon: null, book: null, item: null }, configConst: {}, itemModules: [buildShopItem('che_치료_환약', '환약'), buildShopItem('event_전투특기_격노', '격노의 비급')], currentSecurity: 5000, @@ -236,6 +256,7 @@ describe('turn command argument input', () => { it('shows only zero-count buyable items selected by an explicit scenario pool', () => { const items = buildEquipmentTradeItemOptions({ + ownedItems: { horse: null, weapon: null, book: null, item: null }, configConst: { allItems: { item: { diff --git a/app/game-frontend/e2e/mainNavigation.spec.ts b/app/game-frontend/e2e/mainNavigation.spec.ts index f3a9b0e4..1046e964 100644 --- a/app/game-frontend/e2e/mainNavigation.spec.ts +++ b/app/game-frontend/e2e/mainNavigation.spec.ts @@ -2,6 +2,8 @@ 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'; + const response = (data: unknown) => ({ result: { data } }); const runtimeNavigation = JSON.parse( await readFile(new URL('../../../resources/navigation.json', import.meta.url), 'utf8') @@ -766,6 +768,17 @@ const installFixture = async (page: Page, state: NavigationFixture) => { autorunLimit: state.autorunLimit ?? null, }); } + if (operation === 'turns.reserved.setGeneralBulk' && state.equipmentItemOptions) { + const input = operationInput(route, index) as { + entries: Array<{ turnList: number[]; action: string; args: Record }>; + }; + for (const entry of input.entries) { + for (const turnIndex of entry.turnList) { + state.reservedTurns![turnIndex] = { index: turnIndex, action: entry.action, args: entry.args }; + } + } + return response({ ok: true, revision: 1, turns: state.reservedTurns, autorunLimit: null }); + } if (operation === 'messages.getRecent') { return response(state.messages ?? emptyMessages(state.permission)); } @@ -5061,6 +5074,80 @@ for (const viewport of [ { name: 'desktop', width: 1200, height: 900 }, { name: 'mobile', width: 500, height: 900 }, ] as const) { + test(`reserves owned unique equipment sales and preserves the slot brief on ${viewport.name}`, async ({ page }) => { + const items = buildEquipmentTradeItemOptions({ + configConst: {}, + itemModules: [ + { + key: 'owned_unique', + slot: 'item', + name: '옥벽(보물)', + info: '보유 유니크', + cost: 10001, + reqSecu: 0, + buyable: false, + }, + ], + currentSecurity: 0, + generalGold: 0, + ownedItems: { horse: null, weapon: null, book: null, item: 'owned_unique' }, + }); + 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'); + await equipment.selectOption({ label: '옥벽(보물) 판매' }); + await expect(equipment).toHaveValue('None'); + await expect(equipment.locator('option')).toHaveText(['옥벽(보물) 판매']); + await expect(picker).toContainText('소유 물품 판매 · 판매가 5,000금'); + await equipment.focus(); + await expect(equipment).toBeFocused(); + await page.evaluate(() => document.fonts.ready); + const geometry = await equipment.evaluate((element) => { + const style = getComputedStyle(element); + return { + rect: element.getBoundingClientRect().toJSON(), + font: style.font, + color: style.color, + background: style.backgroundColor, + outline: style.outline, + html: element.outerHTML, + documentWidth: document.documentElement.scrollWidth, + }; + }); + expect(geometry.documentWidth).toBeLessThanOrEqual(viewport.width); + await writeFile(test.info().outputPath(`owned-sale-${viewport.name}.json`), JSON.stringify(geometry, null, 2)); + await picker.screenshot({ path: test.info().outputPath(`owned-sale-${viewport.name}.png`) }); + const request = page.waitForRequest((request) => request.url().includes('turns.reserved.setGeneralBulk')); + await picker.getByRole('button', { name: '입력', exact: true }).click(); + const payload = (await request).postDataJSON(); + expect(JSON.stringify(payload)).toContain('"itemCode":"None"'); + expect(JSON.stringify(payload)).toContain('"itemType":"item"'); + await expect(page.locator('.action-column [title="【도구】를 판매."]')).toHaveText('【도구】를 판매.'); + await page.reload(); + await expect(page.locator('.action-column [title="【도구】를 판매."]')).toHaveText('【도구】를 판매.'); + await page.screenshot({ path: test.info().outputPath(`owned-sale-brief-${viewport.name}.png`) }); + }); + test(`renders only scenario-scoped equipment items on ${viewport.name}`, async ({ page }) => { const state: NavigationFixture = { officerLevel: 5, diff --git a/app/game-frontend/test/reservedCommandBrief.test.ts b/app/game-frontend/test/reservedCommandBrief.test.ts index 71a80f87..f6e99e92 100644 --- a/app/game-frontend/test/reservedCommandBrief.test.ts +++ b/app/game-frontend/test/reservedCommandBrief.test.ts @@ -166,6 +166,9 @@ void test('개인·인사·국가 명령의 Ref brief 변형을 보존한다', ( ['che_징병', { crewType: 1100, amount: 3200 }, '【보병】 3200명 징병'], ['che_숙련전환', { srcArmType: 0, destArmType: 1 }, '【보병】숙련을 【궁병】숙련으로 전환'], ['che_장비매매', { itemType: 'horse', itemCode: 'None' }, '【명마】를 판매.'], + ['che_장비매매', { itemType: 'weapon', itemCode: 'None' }, '【무기】를 판매.'], + ['che_장비매매', { itemType: 'book', itemCode: 'None' }, '【서적】을 판매.'], + ['che_장비매매', { itemType: 'item', itemCode: 'None' }, '【도구】를 판매.'], ['che_장비매매', { itemType: 'horse', itemCode: 'che_명마_01_노기' }, '【노기(+1)】를 구입'], ]; for (const [action, args, expected] of cases) {