fix: 보유 장비 판매 선택지와 판매가 표시를 복원

구매 pool 밖 보유 유니크도 장비 이름으로 판매를 선택할 수 있게 한다. 기존 슬롯명 판매 brief와 None 예약 인자는 유지하고 API 및 Chromium 회귀를 검증한다.
This commit is contained in:
2026-09-06 07:20:31 +00:00
parent ce7d15eba9
commit d3b2bcd155
5 changed files with 137 additions and 6 deletions
+6
View File
@@ -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) => ({
+20 -6
View File
@@ -125,14 +125,28 @@ export const buildEquipmentTradeItemOptions = (options: {
itemModules: readonly EquipmentTradeItemModule[];
currentSecurity: number;
generalGold: number;
ownedItems: Readonly<Record<ItemModule['slot'], string | null>>;
}): 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)) {
+21
View File
@@ -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: {
@@ -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<string, unknown> }>;
};
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,
@@ -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) {