diff --git a/app/game-engine/test/scenarioLoader.test.ts b/app/game-engine/test/scenarioLoader.test.ts index 66608a52..53013df5 100644 --- a/app/game-engine/test/scenarioLoader.test.ts +++ b/app/game-engine/test/scenarioLoader.test.ts @@ -5,6 +5,16 @@ import { describe, expect, it } from 'vitest'; import { loadScenarioDefinitionById, resolveScenarioDefaultsPath } from '../src/scenario/scenarioLoader.js'; +type LoadedScenario = Awaited>; + +const readItemSlot = (scenario: LoadedScenario, slot: string): Record => { + const allItems = scenario.config.const.allItems as Record> | undefined; + return allItems?.[slot] ?? {}; +}; + +const readAvailableSpecialWar = (scenario: LoadedScenario): string[] => + (scenario.config.const.availableSpecialWar as string[] | undefined) ?? []; + describe('tracked scenario resources', () => { it('loads every scenario through its composed resource graph', async () => { const scenarioRoot = path.dirname(resolveScenarioDefaultsPath()); @@ -35,4 +45,36 @@ describe('tracked scenario resources', () => { ]); } }); + + it('keeps the buyable war-special pack scoped to each Ref scenario contract', async () => { + const [ordinaryBlank, legacySecretBlank, mirrorBlank, multiUnitBlank, moreEffectBlank, composedAddon] = + await Promise.all( + [0, 902, 910, 912, 913, 2141].map((scenarioId) => loadScenarioDefinitionById(scenarioId)) + ); + + expect( + Object.keys(readItemSlot(ordinaryBlank, 'item')).filter((key) => key.startsWith('event_전투특기_')) + ).toEqual([]); + + const legacySecretItems = readItemSlot(legacySecretBlank, 'item'); + expect(Object.keys(legacySecretItems).filter((key) => key.startsWith('event_전투특기_'))).toHaveLength(19); + expect(legacySecretItems).not.toHaveProperty('event_전투특기_견고'); + expect(readAvailableSpecialWar(legacySecretBlank)).not.toContain('che_견고'); + + const mirrorItems = readItemSlot(mirrorBlank, 'item'); + expect(Object.keys(mirrorItems).filter((key) => key.startsWith('event_전투특기_'))).toHaveLength(19); + expect(mirrorItems).not.toHaveProperty('event_전투특기_척사'); + expect(readAvailableSpecialWar(mirrorBlank)).not.toContain('che_척사'); + + const multiUnitItems = readItemSlot(multiUnitBlank, 'item'); + expect(Object.keys(multiUnitItems).filter((key) => key.startsWith('event_전투특기_'))).toHaveLength(19); + expect(multiUnitItems).not.toHaveProperty('event_전투특기_견고'); + + const moreEffectItems = readItemSlot(moreEffectBlank, 'item'); + const composedAddonItems = readItemSlot(composedAddon, 'item'); + expect(Object.keys(moreEffectItems).filter((key) => key.startsWith('event_전투특기_'))).toHaveLength(20); + expect(Object.keys(composedAddonItems).filter((key) => key.startsWith('event_전투특기_'))).toHaveLength(20); + expect(readItemSlot(moreEffectBlank, 'horse').che_명마_07_백마).toBe(4); + expect(readItemSlot(composedAddon, 'horse').che_명마_07_백마).toBe(2); + }); }); diff --git a/app/game-engine/test/scenarioSeeder.test.ts b/app/game-engine/test/scenarioSeeder.test.ts index c6d587c5..46b06dff 100644 --- a/app/game-engine/test/scenarioSeeder.test.ts +++ b/app/game-engine/test/scenarioSeeder.test.ts @@ -93,6 +93,49 @@ const canRun = await canConnectToDatabase(databaseUrl); const describeDb = describe.runIf(canRun); describeDb('scenario database seed', () => { + test('persists each blank-land scenario item contract without leaking the shared addon', async () => { + const readPersistedItemContract = async (targetScenarioId: number) => { + const { applied } = await seedScenarioToDatabase({ + scenarioId: targetScenarioId, + databaseUrl, + }); + + const connector = createGamePostgresConnector({ url: databaseUrl }); + await connector.connect(); + try { + const worldState = await connector.prisma.worldState.findFirstOrThrow(); + const config = worldState.config as Record; + const scenarioConst = (config.const ?? {}) as Record; + const allItems = (scenarioConst.allItems ?? {}) as Record>; + const items = allItems.item ?? {}; + const availableSpecialWar = (scenarioConst.availableSpecialWar ?? []) as string[]; + + return { + applied, + battleTraitItemCount: Object.keys(items).filter((key) => key.startsWith('event_전투특기_')).length, + availableSpecialWar, + items, + }; + } finally { + await connector.disconnect(); + } + }; + + const ordinaryBlank = await readPersistedItemContract(0); + const legacySecretBlank = await readPersistedItemContract(902); + + expect(ordinaryBlank).toMatchObject({ + applied: true, + battleTraitItemCount: 0, + availableSpecialWar: [], + }); + expect(legacySecretBlank.applied).toBe(true); + expect(legacySecretBlank.battleTraitItemCount).toBe(19); + expect(legacySecretBlank.availableSpecialWar).toHaveLength(19); + expect(legacySecretBlank.items).not.toHaveProperty('event_전투특기_견고'); + expect(legacySecretBlank.availableSpecialWar).not.toContain('che_견고'); + }); + test('snapshots the complete opening inheritance balance before game activity', async () => { const serverId = 'scenario-seeder-inheritance-baseline'; const userId = 'scenario-seeder-inheritance-user'; diff --git a/docs/architecture/scenario-composition.md b/docs/architecture/scenario-composition.md index a8840427..093b5fea 100644 --- a/docs/architecture/scenario-composition.md +++ b/docs/architecture/scenario-composition.md @@ -58,6 +58,26 @@ 시나리오 80개 중 70개가 확장을 사용합니다. 구매 가능한 전특·유니크 표를 사용하는 10개 시나리오는 같은 item 확장을 참조합니다. +## 적용 범위와 Ref 차이 + +Ref에는 설치 시 선택한 시나리오에 별도 기능 팩을 덧붙이는 전역 애드온 단계가 +없습니다. 각 `scenario_*.json`이 `const.allItems`와 +`const.availableSpecialWar`를 직접 소유하고, `Scenario::buildConf()`가 그 값을 +`GameConst`에 반영합니다. + +Core의 `extends`는 이 중복 값을 소스에서 재사용하기 위한 합성 기능입니다. +설치 시 임의의 시나리오에 전역으로 적용되는 옵션이 아니며, 해당 +`scenario_*.json`이 확장을 명시한 경우에만 로더와 Gateway 미리보기가 합성합니다. +따라서 일반 공백지 시나리오에는 +`extensions/items/buyable-war-special-uniques.json`이 암묵적으로 적용되지 않습니다. + +공백지 중 `scenario_902`(천지비급), `scenario_910`(거울세계), +`scenario_912`(다병종), `scenario_913`(무한대흥)은 Ref 자체가 전투 특기 아이템 +풀을 직접 정의합니다. 이 네 시나리오는 최신 공통 확장과 항목 또는 유니크 수량이 +서로 달라 직접 정의를 유지합니다. 특히 902·912는 `견고`가 없는 19종, 910은 +`척사`가 없는 19종이며, 913은 20종이지만 일부 유니크 수량이 공통 확장의 2개가 +아닌 4개입니다. 이를 공통 확장으로 바꾸면 Ref 설치 결과가 달라집니다. + ## 검증 확장 파일을 추가하거나 합성 순서를 바꾼 뒤 다음 검사를 실행해 주세요.