From b6ef5d211436a80acdef5bc40c2d968d783cc258 Mon Sep 17 00:00:00 2001 From: hided62 Date: Fri, 21 Aug 2026 01:13:55 +0000 Subject: [PATCH 1/4] =?UTF-8?q?test:=20=EA=B3=B5=EB=B0=B1=EC=A7=80=20?= =?UTF-8?q?=EC=8B=9C=EB=82=98=EB=A6=AC=EC=98=A4=EC=9D=98=20=EB=B9=84?= =?UTF-8?q?=EA=B8=89=20=ED=99=95=EC=9E=A5=20=EB=B2=94=EC=9C=84=EB=A5=BC=20?= =?UTF-8?q?=EA=B3=A0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ref의 시나리오별 전투 특기 아이템 계약과 Core 리소스 합성 범위를 문서화한다. 일반 공백지로 공통 확장이 누출되지 않고 특수 공백지의 개별 목록과 수량이 보존되는지 회귀 테스트한다. --- app/game-engine/test/scenarioLoader.test.ts | 42 +++++++++++++++++++++ docs/architecture/scenario-composition.md | 20 ++++++++++ 2 files changed, 62 insertions(+) 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/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 설치 결과가 달라집니다. + ## 검증 확장 파일을 추가하거나 합성 순서를 바꾼 뒤 다음 검사를 실행해 주세요. From 4cc9038c33afcfab00108dd545ddd70e5a778344 Mon Sep 17 00:00:00 2001 From: hided62 Date: Fri, 21 Aug 2026 01:25:16 +0000 Subject: [PATCH 2/4] =?UTF-8?q?test:=20=EC=8B=9C=EB=82=98=EB=A6=AC?= =?UTF-8?q?=EC=98=A4=20=EC=8B=9C=EB=93=9C=EC=9D=98=20=EB=B9=84=EA=B8=89=20?= =?UTF-8?q?=EB=B2=94=EC=9C=84=EB=A5=BC=20=EA=B2=80=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/game-engine/test/scenarioSeeder.test.ts | 43 +++++++++++++++++++++ 1 file changed, 43 insertions(+) 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'; From ba5f870b0662b2a3edf18c67e2d9adcb05b0d727 Mon Sep 17 00:00:00 2001 From: hided62 Date: Fri, 21 Aug 2026 01:26:51 +0000 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20=EB=AA=A8=EB=B0=94=EC=9D=BC=20?= =?UTF-8?q?=EA=B3=A0=EA=B8=89=20=ED=84=B4=20=EB=93=9C=EB=9E=98=EA=B7=B8=20?= =?UTF-8?q?=EC=84=A0=ED=83=9D=20=EB=B3=B5=EA=B5=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ref DragSelect와 같이 활성 선택 영역에 touch-action none을 적용해 브라우저 스크롤의 pointercancel을 막는다. 일반·사령턴의 trusted mobile touch 선택과 일반 모드 스크롤 계약을 production Chromium으로 검증한다. --- .../e2e/commandArguments.spec.ts | 57 +++++++++++++++++++ .../src/components/command/DragSelect.vue | 4 +- 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/app/game-frontend/e2e/commandArguments.spec.ts b/app/game-frontend/e2e/commandArguments.spec.ts index 4ca368ca..2d248099 100644 --- a/app/game-frontend/e2e/commandArguments.spec.ts +++ b/app/game-frontend/e2e/commandArguments.spec.ts @@ -3,6 +3,7 @@ import { readFile } from 'node:fs/promises'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { gameProfile, gameTrpcRoute } from './gameTestPaths.js'; +import { touchDrag } from './touchDrag.js'; const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..'); const imageRoots = [ @@ -2302,6 +2303,62 @@ test('uses drag selection, clipboard paste, and a stored template in advanced mo await page.screenshot({ path: test.info().outputPath('advanced-command-editor.png'), fullPage: true }); }); +test('physical mobile touch drag selects general and nation turns in advanced mode', async ({ browser }, testInfo) => { + const configuredBaseUrl = testInfo.project.use.baseURL; + if (typeof configuredBaseUrl !== 'string') { + throw new Error('Playwright baseURL is required for the mobile touch contract'); + } + const context = await browser.newContext({ + baseURL: configuredBaseUrl, + viewport: { width: 390, height: 844 }, + screen: { width: 390, height: 844 }, + deviceScaleFactor: 1, + isMobile: true, + hasTouch: true, + colorScheme: 'dark', + }); + const mobilePage = await context.newPage(); + try { + await install(mobilePage); + await mobilePage.goto(configuredBaseUrl); + + const editor = mobilePage.locator('[data-command-scope="general"]'); + await expect(editor).toBeVisible(); + await expect(editor.locator('.date-column.drag-select')).toHaveCSS('touch-action', 'auto'); + await editor.getByRole('button', { name: '고급 모드', exact: true }).click(); + await expect(editor.locator('.index-column.drag-select')).toHaveCSS('touch-action', 'none'); + const cells = editor.locator('.index-column > button'); + await touchDrag(mobilePage, cells.nth(0), cells.nth(2), { targetYRatio: 0.9 }); + + await expect(editor.locator('.index-column > button.selected')).toHaveCount(3); + const dates = editor.locator('.date-column > div'); + await touchDrag(mobilePage, dates.nth(4), dates.nth(6), { targetYRatio: 0.9 }); + await expect + .poll(() => editor.locator('.index-column > button.selected').allTextContents()) + .toEqual(['5', '6', '7']); + await mobilePage.screenshot({ + path: testInfo.outputPath('advanced-general-command-editor-mobile-touch.png'), + fullPage: true, + }); + + await mobilePage.goto(new URL('chief-center', configuredBaseUrl).href); + const chiefEditor = mobilePage.locator('[data-command-scope="nation"]:visible'); + await expect(chiefEditor).toBeVisible(); + await expect(chiefEditor.locator('.date-column.drag-select')).toHaveCSS('touch-action', 'auto'); + await chiefEditor.getByRole('button', { name: '고급 모드', exact: true }).click(); + await expect(chiefEditor.locator('.index-column.drag-select')).toHaveCSS('touch-action', 'none'); + const chiefCells = chiefEditor.locator('.index-column > button'); + await touchDrag(mobilePage, chiefCells.nth(0), chiefCells.nth(2), { targetYRatio: 0.9 }); + await expect(chiefEditor.locator('.index-column > button.selected')).toHaveCount(3); + await mobilePage.screenshot({ + path: testInfo.outputPath('advanced-nation-command-editor-mobile-touch.png'), + fullPage: true, + }); + } finally { + await context.close(); + } +}); + test('keeps the shared main and chief shell geometry and interaction states', async ({ page }) => { const requests = await install(page); await page.setViewportSize({ width: 1000, height: 900 }); diff --git a/app/game-frontend/src/components/command/DragSelect.vue b/app/game-frontend/src/components/command/DragSelect.vue index 106fb386..686683f7 100644 --- a/app/game-frontend/src/components/command/DragSelect.vue +++ b/app/game-frontend/src/components/command/DragSelect.vue @@ -80,7 +80,9 @@ onBeforeUnmount(() => {