merge: 시나리오별 장비 매매 범위 교정을 main에 통합

This commit is contained in:
2026-08-21 02:15:57 +00:00
12 changed files with 301 additions and 28 deletions
+7 -23
View File
@@ -13,6 +13,7 @@ import {
} from '../../turns/commandTable.js';
import { loadMapDefinitionByName } from '../../maps/mapDefinition.js';
import {
buildEquipmentTradeItemOptions,
parseReservedTurnArgs,
TURN_COMMAND_NATION_COLORS,
type TurnCommandInputOptions,
@@ -283,29 +284,12 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
cityNames: new Map(cities.map((entry) => [entry.id, entry.name])),
troopNames: new Map(troops.map((entry) => [entry.troopLeaderId, entry.name])),
});
const items: TurnCommandInputOptions['items'] = {
horse: [{ value: 'None', label: '판매/해제' }],
weapon: [{ value: 'None', label: '판매/해제' }],
book: [{ value: 'None', label: '판매/해제' }],
item: [{ value: 'None', label: '판매/해제' }],
};
for (const item of moduleBundle.itemModules) {
if (item.buyable) {
const cost = item.cost ?? 0;
const currentSecurity = city?.security ?? 0;
const availability =
currentSecurity < item.reqSecu
? `현재 구입 불가: 치안 ${item.reqSecu.toLocaleString()} 필요`
: general.gold < cost
? `현재 구입 불가: 자금 ${cost.toLocaleString()} 필요`
: '현재 구입 가능';
items[item.slot].push({
value: item.key,
label: item.name,
description: `${availability} · 가격 ${cost.toLocaleString()} · ${plainLegacyInfo(item.info)}`,
});
}
}
const items = buildEquipmentTradeItemOptions({
configConst: asRecord(asRecord(worldState.config).const),
itemModules: moduleBundle.itemModules,
currentSecurity: city?.security ?? 0,
generalGold: general.gold,
});
const inputOptions: TurnCommandInputOptions = {
cities: cities.map((entry) => ({
value: entry.id,
+45
View File
@@ -5,6 +5,8 @@ import {
type NationTurnCommandSpec,
} from '@sammo-ts/logic';
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 { loadTurnCommandProfile } from '@sammo-ts/game-engine/turn/turnCommandProfile.js';
@@ -103,6 +105,49 @@ export interface TurnCommandInputOptions {
};
}
type EquipmentTradeItemModule = Pick<ItemModule, 'key' | 'slot' | 'name' | 'info' | 'cost' | 'reqSecu' | 'buyable'>;
const plainLegacyInfo = (value: string): string =>
value
.replace(/<br\s*\/?>/giu, ' · ')
.replace(/<[^>]+>/gu, '')
.replace(/\s+/gu, ' ')
.trim();
export const buildEquipmentTradeItemOptions = (options: {
configConst: Record<string, unknown>;
itemModules: readonly EquipmentTradeItemModule[];
currentSecurity: number;
generalGold: number;
}): 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: '판매/해제' }],
};
for (const item of options.itemModules) {
if (!item.buyable || !purchasableItemKeys.has(item.key)) {
continue;
}
const cost = item.cost ?? 0;
const availability =
options.currentSecurity < item.reqSecu
? `현재 구입 불가: 치안 ${item.reqSecu.toLocaleString()} 필요`
: options.generalGold < cost
? `현재 구입 불가: 자금 ${cost.toLocaleString()} 필요`
: '현재 구입 가능';
items[item.slot].push({
value: item.key,
label: item.name,
description: `${availability} · 가격 ${cost.toLocaleString()} · ${plainLegacyInfo(item.info)}`,
});
}
return items;
};
// 레거시 및 명령 실행 모듈의 인덱스 순서와 동일해야 한다.
export const TURN_COMMAND_NATION_COLORS = [
'#FF0000',
+49 -1
View File
@@ -6,7 +6,24 @@ import {
} from '@sammo-ts/logic';
import { describe, expect, it } from 'vitest';
import { buildTurnCommandInputFields, parseReservedTurnArgs } from '../src/turns/commandInput.js';
import {
buildEquipmentTradeItemOptions,
buildTurnCommandInputFields,
parseReservedTurnArgs,
} from '../src/turns/commandInput.js';
const buildShopItem = (key: string, name: string) => ({
key,
rawName: name,
name,
info: `${name}<br>설명`,
slot: 'item' as const,
cost: 100,
buyable: true,
consumable: false,
reqSecu: 3000,
unique: false,
});
describe('turn command argument input', () => {
it('builds supported fields for every argument-bearing command module', async () => {
@@ -80,4 +97,35 @@ describe('turn command argument input', () => {
});
await expect(parseReservedTurnArgs('general', 'che_포상', {})).rejects.toThrow('Unknown general turn command');
});
it('limits equipment trade options to the Ref default items when a scenario omits allItems', () => {
const items = buildEquipmentTradeItemOptions({
configConst: {},
itemModules: [buildShopItem('che_치료_환약', '환약'), buildShopItem('event_전투특기_격노', '격노의 비급')],
currentSecurity: 5000,
generalGold: 1000,
});
expect(items.item.map((item) => item.value)).toEqual(['None', 'che_치료_환약']);
expect(items.item[1]?.description).toBe('현재 구입 가능 · 가격 100 · 환약 · 설명');
});
it('shows only zero-count buyable items selected by an explicit scenario pool', () => {
const items = buildEquipmentTradeItemOptions({
configConst: {
allItems: {
item: {
che_치료_환약: 1,
event_전투특기_격노: 0,
},
},
},
itemModules: [buildShopItem('che_치료_환약', '환약'), buildShopItem('event_전투특기_격노', '격노의 비급')],
currentSecurity: 2000,
generalGold: 50,
});
expect(items.item.map((item) => item.value)).toEqual(['None', 'event_전투특기_격노']);
expect(items.item[1]?.description).toContain('현재 구입 불가: 치안 3,000 필요');
});
});
@@ -15,6 +15,7 @@ import {
loadActionModuleBundle,
} from '@sammo-ts/logic';
import { asRecord } from '@sammo-ts/common';
import { resolveLegacyPurchasableItemKeys } from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js';
import { createRuntimeTrace } from './runtimeTrace.js';
// legacy GameConstBase 기본값
@@ -146,6 +147,7 @@ export const buildCommandEnv = (config: ScenarioConfig, unitSet?: UnitSetDefinit
['maxResourceActionAmount'],
DEFAULT_MAX_RESOURCE_ACTION_AMOUNT
),
purchasableItemKeys: resolveLegacyPurchasableItemKeys(constValues),
};
};
@@ -4,6 +4,7 @@ import path from 'node:path';
import { describe, expect, it } from 'vitest';
import { loadScenarioDefinitionById, resolveScenarioDefaultsPath } from '../src/scenario/scenarioLoader.js';
import { buildCommandEnv } from '../src/turn/reservedTurnCommands.js';
type LoadedScenario = Awaited<ReturnType<typeof loadScenarioDefinitionById>>;
@@ -77,4 +78,18 @@ describe('tracked scenario resources', () => {
expect(readItemSlot(moreEffectBlank, 'horse').che_명마_07_백마).toBe(4);
expect(readItemSlot(composedAddon, 'horse').che_명마_07_백마).toBe(2);
});
it('projects ordinary and explicit secret-item scenario pools into command execution', async () => {
const [ordinaryBlank, secretScenario] = await Promise.all(
[1, 2701].map((scenarioId) => loadScenarioDefinitionById(scenarioId))
);
const ordinaryKeys = buildCommandEnv(ordinaryBlank.config).purchasableItemKeys;
const secretScenarioKeys = buildCommandEnv(secretScenario.config).purchasableItemKeys;
expect(ordinaryKeys?.size).toBe(24);
expect(ordinaryKeys?.has('che_치료_환약')).toBe(true);
expect([...ordinaryKeys!].filter((key) => key.startsWith('event_전투특기_'))).toEqual([]);
expect([...secretScenarioKeys!].filter((key) => key.startsWith('event_전투특기_'))).toHaveLength(20);
expect(secretScenarioKeys?.has('event_전투특기_격노')).toBe(true);
});
});
+58 -2
View File
@@ -48,6 +48,7 @@ type NavigationFixture = {
accessLimitAfterCalls?: number;
largeCommandTable?: boolean;
draftCommandTable?: boolean;
equipmentItemOptions?: Array<{ value: string; label: string; description?: string }>;
refCommandCategories?: boolean;
currentYear?: number;
currentMonth?: number;
@@ -235,6 +236,7 @@ const draftCommandGroups = [
options: [
{ value: 'horse', label: '명마' },
{ value: 'weapon', label: '무기' },
{ value: 'item', label: '도구' },
],
},
{
@@ -250,7 +252,13 @@ const draftCommandGroups = [
},
];
const commandTableFixture = (large: boolean, blockedCount = 0, refCategories = false, draftCommands = false) => ({
const commandTableFixture = (
large: boolean,
blockedCount = 0,
refCategories = false,
draftCommands = false,
equipmentItemOptions?: Array<{ value: string; label: string; description?: string }>
) => ({
general: draftCommands
? draftCommandGroups
: refCategories
@@ -309,6 +317,7 @@ const commandTableFixture = (large: boolean, blockedCount = 0, refCategories = f
{ value: 'None', label: '없음' },
{ value: '청룡언월도', label: '청룡언월도' },
],
item: equipmentItemOptions ?? [{ value: 'None', label: '없음' }],
}
: {},
context: draftCommands
@@ -595,7 +604,8 @@ const installFixture = async (page: Page, state: NavigationFixture) => {
state.largeCommandTable === true,
state.commandBlockedCount,
state.refCommandCategories === true,
state.draftCommandTable === true
state.draftCommandTable === true,
state.equipmentItemOptions
),
}
: input.known.commandTable === currentCommandTableRevision
@@ -3455,6 +3465,52 @@ for (const viewport of [
});
}
for (const viewport of [
{ name: 'desktop', width: 1200, height: 900 },
{ name: 'mobile', width: 500, height: 900 },
] as const) {
test(`renders only scenario-scoped equipment items on ${viewport.name}`, async ({ page }) => {
const state: NavigationFixture = {
officerLevel: 5,
permission: 2,
nationLevel: 3,
stage: 0,
npcMode: 1,
generalMeCalls: 0,
operations: [],
draftCommandTable: true,
equipmentItemOptions: [
{ value: 'None', label: '판매/해제' },
{
value: 'che_치료_환약',
label: '환약',
description: '현재 구입 가능 · 가격 100 · 부상 회복',
},
],
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 itemSelect = picker.getByLabel('장비', { exact: true });
await expect(itemSelect.locator('option')).toHaveText(['판매/해제', '환약']);
await expect(itemSelect.locator('option', { hasText: '비급' })).toHaveCount(0);
await itemSelect.selectOption('che_치료_환약');
await expect(picker).toContainText('현재 구입 가능 · 가격 100 · 부상 회복');
await expect
.poll(() => page.evaluate(() => document.documentElement.scrollWidth))
.toBeLessThanOrEqual(viewport.width);
await picker.screenshot({ path: test.info().outputPath(`scenario-item-shop-${viewport.name}.png`) });
});
}
test('realtime read-model events skip clock-only work, merge bursts, patch in place, and stop off-route', async ({
page,
}) => {
@@ -71,6 +71,15 @@ Core의 `extends`는 이 중복 값을 소스에서 재사용하기 위한 합
따라서 일반 공백지 시나리오에는
`extensions/items/buyable-war-special-uniques.json`이 암묵적으로 적용되지 않습니다.
장비 매매의 구매 목록과 실행 검증도 같은 경계를 사용합니다. 합성된
`const.allItems`에서 수량이 `0` 이하이고 item module이 `buyable`인 항목만 구매할
수 있습니다. `allItems`가 생략되었거나 빈 객체 또는 과거 문자열 `"{}"`이면 Ref
`GameConstBase`의 기본 구매 가능 장비 24종(부위별 6종)을 복원합니다. 반대로
`allItems`가 명시된 시나리오는 그 목록에 없는 전역 item module을 UI 선택지에
노출하지 않고, 조작된 예약 명령으로도 구매하지 못합니다. 유니크 장비의 판매와
로그 표시에는 전체 item catalog가 계속 필요하므로 구매 허용 목록과 catalog 자체를
분리합니다.
공백지 중 `scenario_902`(천지비급), `scenario_910`(거울세계),
`scenario_912`(다병종), `scenario_913`(무한대흥)은 Ref 자체가 전투 특기 아이템
풀을 직접 정의합니다. 이 네 시나리오는 최신 공통 확장과 항목 또는 유니크 수량이
@@ -61,6 +61,7 @@ export interface TurnCommandEnv {
npcSeizureMessageProb?: number;
maxResourceActionAmount: number;
itemCatalog?: Record<string, TurnCommandItemCatalogEntry>;
purchasableItemKeys?: ReadonlySet<string>;
generalActionModules?: RefOrderedActionStack<GeneralActionModule>;
warActionModules?: RefOrderedActionStack<WarActionModule>;
nationTraitModules?: Array<NationTraitModule>;
@@ -79,7 +79,11 @@ export class ActionDefinition<
if (!item) {
return null;
}
if (item.slot !== itemType || !item.buyable) {
if (
item.slot !== itemType ||
!item.buyable ||
(this.env.purchasableItemKeys !== undefined && !this.env.purchasableItemKeys.has(itemCode))
) {
return null;
}
return args;
@@ -113,6 +113,56 @@ const LEGACY_UNIQUE_ITEM_KEYS: Readonly<Record<ItemModule['slot'], readonly stri
],
};
const LEGACY_DEFAULT_BUYABLE_ITEM_KEYS: Readonly<Record<ItemModule['slot'], readonly string[]>> = {
horse: [
'che_명마_01_노기',
'che_명마_02_조랑',
'che_명마_03_노새',
'che_명마_04_나귀',
'che_명마_05_갈색마',
'che_명마_06_흑색마',
],
weapon: [
'che_무기_01_단도',
'che_무기_02_단궁',
'che_무기_03_단극',
'che_무기_04_목검',
'che_무기_05_죽창',
'che_무기_06_소부',
],
book: [
'che_서적_01_효경전',
'che_서적_02_회남자',
'che_서적_03_변도론',
'che_서적_04_건상역주',
'che_서적_05_여씨춘추',
'che_서적_06_사민월령',
],
item: ['che_치료_환약', 'che_저격_수극', 'che_사기_탁주', 'che_훈련_청주', 'che_계략_이추', 'che_계략_향낭'],
};
/**
* Ref 장비 매매는 GameConst::$allItems에 있고 수량이 0 이하인 구매 가능 아이템만
* 노출합니다. 생략/옛 문자열 빈 객체는 GameConstBase의 기본 24종으로 복원합니다.
*/
export const resolveLegacyPurchasableItemKeys = (configConst: Record<string, unknown>): ReadonlySet<string> => {
const { allItems } = resolveUniqueConfig(configConst);
const hasExplicitPool = Object.values(allItems).some((entries) => Object.keys(entries ?? {}).length > 0);
if (!hasExplicitPool) {
return new Set(Object.values(LEGACY_DEFAULT_BUYABLE_ITEM_KEYS).flat());
}
const result = new Set<string>();
for (const entries of Object.values(allItems)) {
for (const [itemKey, count] of Object.entries(entries ?? {})) {
if (count <= 0) {
result.add(itemKey);
}
}
}
return result;
};
export const buildLegacyDefaultUniqueItemPool = (itemRegistry: Map<string, ItemModule>): UniqueItemPool => {
const pool: UniqueItemPool = { horse: {}, weapon: {}, book: {}, item: {} };
for (const slot of ['horse', 'weapon', 'book', 'item'] as const) {
@@ -252,6 +252,37 @@ describe('typed item lifecycle events', () => {
});
});
it('시나리오 상점 목록에 없는 전역 구매 가능 아이템을 거부한다', () => {
const itemKey = 'event_전투특기_격노';
const catalog: Record<string, TurnCommandItemCatalogEntry> = {
[itemKey]: {
slot: 'item',
name: '격노의 비급',
rawName: '격노의 비급',
cost: 100,
reqSecu: 0,
buyable: true,
unique: false,
},
};
const denied = new TradeItemAction({
...BASE_ENV,
itemCatalog: catalog,
purchasableItemKeys: new Set(),
});
const allowed = new TradeItemAction({
...BASE_ENV,
itemCatalog: catalog,
purchasableItemKeys: new Set([itemKey]),
});
expect(denied.parseArgs({ itemType: 'item', itemCode: itemKey })).toBeNull();
expect(allowed.parseArgs({ itemType: 'item', itemCode: itemKey })).toEqual({
itemType: 'item',
itemCode: itemKey,
});
});
it('계략 성공 capability만 소비하며 typed 결과로 소비 item을 반환한다', () => {
const general = makeGeneral('che_계략_이추');
const itemModules = createItemActionModules(createItemModuleRegistry([strategyItemModule]));
@@ -1,6 +1,9 @@
import { describe, expect, it } from 'vitest';
import { resolveLegacyCompatibleUniqueConfig } from '../src/rewards/legacyUniqueItemPool.js';
import {
resolveLegacyCompatibleUniqueConfig,
resolveLegacyPurchasableItemKeys,
} from '../src/rewards/legacyUniqueItemPool.js';
describe('legacy-compatible unique item pool', () => {
it.each([undefined, {}, '{}'] as const)('restores the Ref default pool when allItems is %j', async (allItems) => {
@@ -27,4 +30,29 @@ describe('legacy-compatible unique item pool', () => {
},
});
});
it.each([undefined, {}, '{}'] as const)('restores the Ref default shop items when allItems is %j', (allItems) => {
const keys = resolveLegacyPurchasableItemKeys(allItems === undefined ? {} : { allItems });
expect(keys.size).toBe(24);
expect(keys.has('che_명마_01_노기')).toBe(true);
expect(keys.has('che_치료_환약')).toBe(true);
expect(keys.has('event_전투특기_격노')).toBe(false);
});
it('uses only non-limited entries from an explicit scenario shop pool', () => {
const keys = resolveLegacyPurchasableItemKeys({
allItems: {
weapon: {
che_무기_01_단도: 0,
che_무기_12_칠성검: 2,
},
item: {
event_전투특기_격노: 0,
},
},
});
expect([...keys]).toEqual(['che_무기_01_단도', 'event_전투특기_격노']);
});
});