From 3b933a75b8351bfe06a84af9e15beb536af258eb Mon Sep 17 00:00:00 2001 From: hided62 Date: Fri, 21 Aug 2026 02:11:40 +0000 Subject: [PATCH] =?UTF-8?q?fix:=20=EC=8B=9C=EB=82=98=EB=A6=AC=EC=98=A4?= =?UTF-8?q?=EB=B3=84=20=EC=9E=A5=EB=B9=84=20=EB=A7=A4=EB=A7=A4=20=ED=92=88?= =?UTF-8?q?=EB=AA=A9=20=EB=B2=94=EC=9C=84=EB=A5=BC=20=EB=B3=B4=EC=A1=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 현재 시나리오 allItems를 API 선택지와 턴 실행 검증에 함께 적용한다. 빈 설정은 Ref 기본 장비 24종으로 복원하고 명시된 비급 시나리오는 기존 품목을 유지한다. --- app/game-api/src/router/turns/index.ts | 30 +++------- app/game-api/src/turns/commandInput.ts | 45 ++++++++++++++ app/game-api/test/commandInput.test.ts | 50 +++++++++++++++- .../src/turn/reservedTurnCommands.ts | 2 + app/game-engine/test/scenarioLoader.test.ts | 15 +++++ app/game-frontend/e2e/mainNavigation.spec.ts | 60 ++++++++++++++++++- docs/architecture/scenario-composition.md | 9 +++ packages/logic/src/actions/turn/commandEnv.ts | 1 + .../src/actions/turn/general/che_장비매매.ts | 6 +- .../logic/src/rewards/legacyUniqueItemPool.ts | 50 ++++++++++++++++ packages/logic/test/itemActionEvents.test.ts | 31 ++++++++++ .../logic/test/legacyUniqueItemPool.test.ts | 30 +++++++++- 12 files changed, 301 insertions(+), 28 deletions(-) diff --git a/app/game-api/src/router/turns/index.ts b/app/game-api/src/router/turns/index.ts index 10c6264c..39043bad 100644 --- a/app/game-api/src/router/turns/index.ts +++ b/app/game-api/src/router/turns/index.ts @@ -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, diff --git a/app/game-api/src/turns/commandInput.ts b/app/game-api/src/turns/commandInput.ts index 70661544..51c446c2 100644 --- a/app/game-api/src/turns/commandInput.ts +++ b/app/game-api/src/turns/commandInput.ts @@ -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; + +const plainLegacyInfo = (value: string): string => + value + .replace(//giu, ' · ') + .replace(/<[^>]+>/gu, '') + .replace(/\s+/gu, ' ') + .trim(); + +export const buildEquipmentTradeItemOptions = (options: { + configConst: Record; + 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', diff --git a/app/game-api/test/commandInput.test.ts b/app/game-api/test/commandInput.test.ts index ad464973..1a8216e3 100644 --- a/app/game-api/test/commandInput.test.ts +++ b/app/game-api/test/commandInput.test.ts @@ -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}
설명`, + 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 필요'); + }); }); diff --git a/app/game-engine/src/turn/reservedTurnCommands.ts b/app/game-engine/src/turn/reservedTurnCommands.ts index f9b1c907..5a89fae0 100644 --- a/app/game-engine/src/turn/reservedTurnCommands.ts +++ b/app/game-engine/src/turn/reservedTurnCommands.ts @@ -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), }; }; diff --git a/app/game-engine/test/scenarioLoader.test.ts b/app/game-engine/test/scenarioLoader.test.ts index 53013df5..71fc9388 100644 --- a/app/game-engine/test/scenarioLoader.test.ts +++ b/app/game-engine/test/scenarioLoader.test.ts @@ -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>; @@ -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); + }); }); diff --git a/app/game-frontend/e2e/mainNavigation.spec.ts b/app/game-frontend/e2e/mainNavigation.spec.ts index 81412299..6921b970 100644 --- a/app/game-frontend/e2e/mainNavigation.spec.ts +++ b/app/game-frontend/e2e/mainNavigation.spec.ts @@ -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, }) => { diff --git a/docs/architecture/scenario-composition.md b/docs/architecture/scenario-composition.md index 093b5fea..6335cd83 100644 --- a/docs/architecture/scenario-composition.md +++ b/docs/architecture/scenario-composition.md @@ -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 자체가 전투 특기 아이템 풀을 직접 정의합니다. 이 네 시나리오는 최신 공통 확장과 항목 또는 유니크 수량이 diff --git a/packages/logic/src/actions/turn/commandEnv.ts b/packages/logic/src/actions/turn/commandEnv.ts index 92bc1bcc..d2011c49 100644 --- a/packages/logic/src/actions/turn/commandEnv.ts +++ b/packages/logic/src/actions/turn/commandEnv.ts @@ -61,6 +61,7 @@ export interface TurnCommandEnv { npcSeizureMessageProb?: number; maxResourceActionAmount: number; itemCatalog?: Record; + purchasableItemKeys?: ReadonlySet; generalActionModules?: RefOrderedActionStack; warActionModules?: RefOrderedActionStack; nationTraitModules?: Array; diff --git a/packages/logic/src/actions/turn/general/che_장비매매.ts b/packages/logic/src/actions/turn/general/che_장비매매.ts index cea56f2c..979c09e8 100644 --- a/packages/logic/src/actions/turn/general/che_장비매매.ts +++ b/packages/logic/src/actions/turn/general/che_장비매매.ts @@ -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; diff --git a/packages/logic/src/rewards/legacyUniqueItemPool.ts b/packages/logic/src/rewards/legacyUniqueItemPool.ts index 00835f14..a0e96cfd 100644 --- a/packages/logic/src/rewards/legacyUniqueItemPool.ts +++ b/packages/logic/src/rewards/legacyUniqueItemPool.ts @@ -113,6 +113,56 @@ const LEGACY_UNIQUE_ITEM_KEYS: Readonly> = { + 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): ReadonlySet => { + 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(); + 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): UniqueItemPool => { const pool: UniqueItemPool = { horse: {}, weapon: {}, book: {}, item: {} }; for (const slot of ['horse', 'weapon', 'book', 'item'] as const) { diff --git a/packages/logic/test/itemActionEvents.test.ts b/packages/logic/test/itemActionEvents.test.ts index f03acd6d..daee0fa4 100644 --- a/packages/logic/test/itemActionEvents.test.ts +++ b/packages/logic/test/itemActionEvents.test.ts @@ -252,6 +252,37 @@ describe('typed item lifecycle events', () => { }); }); + it('시나리오 상점 목록에 없는 전역 구매 가능 아이템을 거부한다', () => { + const itemKey = 'event_전투특기_격노'; + const catalog: Record = { + [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])); diff --git a/packages/logic/test/legacyUniqueItemPool.test.ts b/packages/logic/test/legacyUniqueItemPool.test.ts index 6d339e3a..8a6425c0 100644 --- a/packages/logic/test/legacyUniqueItemPool.test.ts +++ b/packages/logic/test/legacyUniqueItemPool.test.ts @@ -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_전투특기_격노']); + }); });