장비 구매 목록을 시나리오 JSON 순서로 표시
This commit is contained in:
@@ -17,6 +17,7 @@ import {
|
|||||||
assertReservedTurnActionAvailable,
|
assertReservedTurnActionAvailable,
|
||||||
assertReservedTurnArgsPassLegacyBasicValidation,
|
assertReservedTurnArgsPassLegacyBasicValidation,
|
||||||
buildEquipmentTradeItemOptions,
|
buildEquipmentTradeItemOptions,
|
||||||
|
loadEquipmentTradeItemOrder,
|
||||||
parseReservedTurnArgs,
|
parseReservedTurnArgs,
|
||||||
TURN_COMMAND_NATION_COLORS,
|
TURN_COMMAND_NATION_COLORS,
|
||||||
type TurnCommandInputOptions,
|
type TurnCommandInputOptions,
|
||||||
@@ -272,6 +273,7 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
|
|||||||
traits,
|
traits,
|
||||||
moduleBundle,
|
moduleBundle,
|
||||||
map,
|
map,
|
||||||
|
itemOrder,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
general.cityId > 0
|
general.cityId > 0
|
||||||
? ctx.db.city.findUnique({
|
? ctx.db.city.findUnique({
|
||||||
@@ -330,6 +332,7 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
|
|||||||
loadBattleSimTraitOptions(),
|
loadBattleSimTraitOptions(),
|
||||||
moduleBundlePromise,
|
moduleBundlePromise,
|
||||||
loadMapDefinitionByName(resolveMapName(worldState, ctx.profile.id)),
|
loadMapDefinitionByName(resolveMapName(worldState, ctx.profile.id)),
|
||||||
|
loadEquipmentTradeItemOrder(worldState.scenarioCode),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const nationById = new Map(nations.map((entry) => [entry.id, entry]));
|
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])),
|
troopNames: new Map(troops.map((entry) => [entry.troopLeaderId, entry.name])),
|
||||||
});
|
});
|
||||||
const items = buildEquipmentTradeItemOptions({
|
const items = buildEquipmentTradeItemOptions({
|
||||||
|
itemOrder,
|
||||||
configConst: asRecord(asRecord(worldState.config).const),
|
configConst: asRecord(asRecord(worldState.config).const),
|
||||||
itemModules: moduleBundle.itemModules,
|
itemModules: moduleBundle.itemModules,
|
||||||
currentSecurity: city?.security ?? 0,
|
currentSecurity: city?.security ?? 0,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { asRecord, isRecord } from '@sammo-ts/common';
|
|||||||
import type { ItemModule } from '@sammo-ts/logic/items/types.js';
|
import type { ItemModule } from '@sammo-ts/logic/items/types.js';
|
||||||
import { resolveLegacyPurchasableItemKeys } from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js';
|
import { resolveLegacyPurchasableItemKeys } from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
import { loadScenarioDefinitionById } from '@sammo-ts/game-engine/scenario/scenarioLoader.js';
|
||||||
|
|
||||||
import { loadScenarioTurnCommandProfile } from '@sammo-ts/game-engine/turn/turnCommandProfile.js';
|
import { loadScenarioTurnCommandProfile } from '@sammo-ts/game-engine/turn/turnCommandProfile.js';
|
||||||
|
|
||||||
@@ -120,8 +121,29 @@ const plainLegacyInfo = (value: string): string =>
|
|||||||
.replace(/\s+/gu, ' ')
|
.replace(/\s+/gu, ' ')
|
||||||
.trim();
|
.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 buildEquipmentTradeItemOptions = (options: {
|
export const buildEquipmentTradeItemOptions = (options: {
|
||||||
configConst: Record<string, unknown>;
|
configConst: Record<string, unknown>;
|
||||||
|
itemOrder?: readonly string[];
|
||||||
itemModules: readonly EquipmentTradeItemModule[];
|
itemModules: readonly EquipmentTradeItemModule[];
|
||||||
currentSecurity: number;
|
currentSecurity: number;
|
||||||
generalGold: number;
|
generalGold: number;
|
||||||
@@ -148,8 +170,11 @@ export const buildEquipmentTradeItemOptions = (options: {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const item of options.itemModules) {
|
// 원본 순서를 우선하되 구매 권한은 현재 DB 설정만 따른다. 추가된 품목은 뒤에 유지한다.
|
||||||
if (!item.buyable || !purchasableItemKeys.has(item.key)) {
|
const orderedKeys = new Set([...(options.itemOrder ?? []), ...purchasableItemKeys]);
|
||||||
|
for (const key of orderedKeys) {
|
||||||
|
const item = catalog.get(key);
|
||||||
|
if (!item?.buyable || !purchasableItemKeys.has(key)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const cost = item.cost ?? 0;
|
const cost = item.cost ?? 0;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { describe, expect, it } from 'vitest';
|
|||||||
import {
|
import {
|
||||||
assertReservedTurnArgsPassLegacyBasicValidation,
|
assertReservedTurnArgsPassLegacyBasicValidation,
|
||||||
buildEquipmentTradeItemOptions,
|
buildEquipmentTradeItemOptions,
|
||||||
|
loadEquipmentTradeItemOrder,
|
||||||
buildTurnCommandInputFields,
|
buildTurnCommandInputFields,
|
||||||
parseReservedTurnArgs,
|
parseReservedTurnArgs,
|
||||||
sanitizeReservedTurnArgs,
|
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<string, Record<string, number>>;
|
||||||
|
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 () => {
|
it('keeps owned unique equipment sales outside the purchase pool in every slot', async () => {
|
||||||
for (const slot of ['horse', 'weapon', 'book', 'item'] as const) {
|
for (const slot of ['horse', 'weapon', 'book', 'item'] as const) {
|
||||||
const owned = { ...buildShopItem('owned_unique', '보유 유니크'), slot, buyable: false, cost: 10001 };
|
const owned = { ...buildShopItem('owned_unique', '보유 유니크'), slot, buyable: false, cost: 10001 };
|
||||||
@@ -245,12 +298,28 @@ describe('turn command argument input', () => {
|
|||||||
const items = buildEquipmentTradeItemOptions({
|
const items = buildEquipmentTradeItemOptions({
|
||||||
ownedItems: { horse: null, weapon: null, book: null, item: null },
|
ownedItems: { horse: null, weapon: null, book: null, item: null },
|
||||||
configConst: {},
|
configConst: {},
|
||||||
itemModules: [buildShopItem('che_치료_환약', '환약'), buildShopItem('event_전투특기_격노', '격노의 비급')],
|
itemModules: [
|
||||||
|
buildShopItem('event_전투특기_격노', '격노의 비급'),
|
||||||
|
buildShopItem('che_계략_향낭', '향낭'),
|
||||||
|
buildShopItem('che_계략_이추', '이추'),
|
||||||
|
buildShopItem('che_훈련_청주', '청주'),
|
||||||
|
buildShopItem('che_사기_탁주', '탁주'),
|
||||||
|
buildShopItem('che_저격_수극', '수극'),
|
||||||
|
buildShopItem('che_치료_환약', '환약'),
|
||||||
|
],
|
||||||
currentSecurity: 5000,
|
currentSecurity: 5000,
|
||||||
generalGold: 1000,
|
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 · 환약 · 설명');
|
expect(items.item[1]?.description).toBe('현재 구입 가능 · 가격 100 · 환약 · 설명');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|||||||
import { resolve } from 'node:path';
|
import { resolve } from 'node:path';
|
||||||
import { expect, test, type Locator, type Page, type Route } from '@playwright/test';
|
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 response = (data: unknown) => ({ result: { data } });
|
||||||
const runtimeNavigation = JSON.parse(
|
const runtimeNavigation = JSON.parse(
|
||||||
@@ -5130,6 +5132,80 @@ for (const viewport of [
|
|||||||
{ name: 'desktop', width: 1200, height: 900 },
|
{ name: 'desktop', width: 1200, height: 900 },
|
||||||
{ name: 'mobile', width: 500, height: 900 },
|
{ name: 'mobile', width: 500, height: 900 },
|
||||||
] as const) {
|
] 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<string, Record<string, number>>;
|
||||||
|
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 }) => {
|
test(`reserves owned unique equipment sales and preserves the slot brief on ${viewport.name}`, async ({ page }) => {
|
||||||
const items = buildEquipmentTradeItemOptions({
|
const items = buildEquipmentTradeItemOptions({
|
||||||
configConst: {},
|
configConst: {},
|
||||||
|
|||||||
Reference in New Issue
Block a user