From b0b40b87aaffb76457df67a223fec4783202fbde Mon Sep 17 00:00:00 2001 From: hided62 Date: Thu, 30 Jul 2026 02:27:15 +0000 Subject: [PATCH] feat: add composable scenario resources --- .vscode/settings.json | 2 +- app/game-engine/src/index.ts | 1 + .../src/scenario/scenarioComposition.ts | 89 +++++++++++ .../src/scenario/scenarioLoader.ts | 13 +- .../test/scenarioComposition.test.ts | 125 +++++++++++++++ app/game-engine/test/scenarioLoader.test.ts | 22 +++ .../src/scenario/scenarioCatalog.ts | 14 +- .../logic/src/resources/scenarioSchema.ts | 21 ++- .../tools-scripts/src/validate-resources.ts | 25 ++- resources/schema/scenario.json | 143 +++++++++++++++++- 10 files changed, 439 insertions(+), 16 deletions(-) create mode 100644 app/game-engine/src/scenario/scenarioComposition.ts create mode 100644 app/game-engine/test/scenarioComposition.test.ts create mode 100644 app/game-engine/test/scenarioLoader.test.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index 4915e0b..09c3394 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -5,7 +5,7 @@ "url": "./resources/schema/map.json" }, { - "fileMatch": ["resources/scenario/*.json"], + "fileMatch": ["resources/scenario/*.json", "resources/scenario/**/*.json"], "url": "./resources/schema/scenario.json" }, { diff --git a/app/game-engine/src/index.ts b/app/game-engine/src/index.ts index 1e1885e..d730560 100644 --- a/app/game-engine/src/index.ts +++ b/app/game-engine/src/index.ts @@ -11,6 +11,7 @@ export * from './lifecycle/inMemoryControlQueue.js'; export * from './lifecycle/turnDaemonLifecycle.js'; export * from './lifecycle/getNextTickTime.js'; export * from './scenario/scenarioLoader.js'; +export * from './scenario/scenarioComposition.js'; export * from './scenario/databaseUrl.js'; export * from './scenario/mapLoader.js'; export * from './scenario/scenarioSeeder.js'; diff --git a/app/game-engine/src/scenario/scenarioComposition.ts b/app/game-engine/src/scenario/scenarioComposition.ts new file mode 100644 index 0000000..4f31371 --- /dev/null +++ b/app/game-engine/src/scenario/scenarioComposition.ts @@ -0,0 +1,89 @@ +import path from 'node:path'; + +export type ScenarioResourceReader = (relativePath: string) => Promise; + +type JsonObject = Record; + +const MAX_COMPOSITION_DEPTH = 64; + +const isJsonObject = (value: unknown): value is JsonObject => + typeof value === 'object' && value !== null && !Array.isArray(value); + +const normalizeResourcePath = (resourcePath: string, sourcePath?: string): string => { + if (!resourcePath || resourcePath.includes('\\') || path.posix.isAbsolute(resourcePath)) { + throw new Error(`Scenario extension path is invalid: ${resourcePath || ''}.`); + } + const basePath = sourcePath ? path.posix.dirname(sourcePath) : '.'; + const normalized = path.posix.normalize(path.posix.join(basePath, resourcePath)); + if (normalized === '..' || normalized.startsWith('../') || !normalized.endsWith('.json')) { + throw new Error(`Scenario extension path escapes the scenario resource root: ${resourcePath}.`); + } + return normalized; +}; + +const readExtensionPaths = (value: unknown, sourcePath: string): string[] => { + if (value === undefined) { + return []; + } + const entries = typeof value === 'string' ? [value] : value; + if (!Array.isArray(entries) || entries.length === 0 || entries.some((entry) => typeof entry !== 'string')) { + throw new Error(`Scenario resource ${sourcePath} has an invalid extends field.`); + } + return entries.map((entry) => normalizeResourcePath(entry as string, sourcePath)); +}; + +export const mergeScenarioResources = (base: unknown, override: unknown): unknown => { + if (!isJsonObject(base) || !isJsonObject(override)) { + return override; + } + + const merged: JsonObject = { ...base }; + for (const [key, value] of Object.entries(override)) { + Object.defineProperty(merged, key, { + configurable: true, + enumerable: true, + writable: true, + value: key in merged ? mergeScenarioResources(merged[key], value) : value, + }); + } + return merged; +}; + +/** + * `extends`를 왼쪽부터 합성하고 마지막에 현재 파일을 적용합니다. + * 객체는 재귀 병합하고 배열과 scalar는 뒤 레이어의 값으로 교체합니다. + */ +export const composeScenarioResource = async ( + entryPath: string, + readResource: ScenarioResourceReader +): Promise => { + const rootEntry = normalizeResourcePath(entryPath); + + const compose = async (resourcePath: string, stack: string[]): Promise => { + if (stack.length >= MAX_COMPOSITION_DEPTH) { + throw new Error(`Scenario composition exceeds ${MAX_COMPOSITION_DEPTH} layers at ${resourcePath}.`); + } + if (stack.includes(resourcePath)) { + throw new Error(`Scenario composition cycle: ${[...stack, resourcePath].join(' -> ')}.`); + } + + const raw = await readResource(resourcePath); + if (!isJsonObject(raw)) { + throw new Error(`Scenario resource ${resourcePath} must be a JSON object.`); + } + + let result: unknown = {}; + const nextStack = [...stack, resourcePath]; + for (const extensionPath of readExtensionPaths(raw.extends, resourcePath)) { + result = mergeScenarioResources(result, await compose(extensionPath, nextStack)); + } + const resourceBody = Object.fromEntries(Object.entries(raw).filter(([key]) => key !== 'extends')); + result = mergeScenarioResources(result, resourceBody); + if (!isJsonObject(result)) { + throw new Error(`Scenario resource ${resourcePath} did not compose to a JSON object.`); + } + return result; + }; + + return compose(rootEntry, []); +}; diff --git a/app/game-engine/src/scenario/scenarioLoader.ts b/app/game-engine/src/scenario/scenarioLoader.ts index 329299a..b6b27f0 100644 --- a/app/game-engine/src/scenario/scenarioLoader.ts +++ b/app/game-engine/src/scenario/scenarioLoader.ts @@ -9,6 +9,7 @@ import { } from '@sammo-ts/logic'; import { resolveWorkspaceRoot } from '../paths.js'; +import { composeScenarioResource } from './scenarioComposition.js'; const REPO_ROOT = resolveWorkspaceRoot(); const DEFAULT_SCENARIO_ROOT = path.resolve(REPO_ROOT, 'resources', 'scenario'); @@ -41,8 +42,16 @@ export const loadScenarioDefinition = async ( scenarioPath: string, defaults: ScenarioDefaults ): Promise => { - // 시나리오 본문을 읽고 기본값과 합쳐서 파싱한다. - const raw = await readJsonFile(scenarioPath); + // 시나리오 확장 조각을 먼저 합성한 뒤 기본값과 함께 정규화한다. + const scenarioRoot = path.dirname(scenarioPath); + const raw = await composeScenarioResource(path.basename(scenarioPath), async (relativePath) => { + const resolvedPath = path.resolve(scenarioRoot, relativePath); + const rootPrefix = `${path.resolve(scenarioRoot)}${path.sep}`; + if (!resolvedPath.startsWith(rootPrefix)) { + throw new Error(`Scenario resource path escapes the configured root: ${relativePath}.`); + } + return readJsonFile(resolvedPath); + }); return parseScenarioDefinition(raw, defaults); }; diff --git a/app/game-engine/test/scenarioComposition.test.ts b/app/game-engine/test/scenarioComposition.test.ts new file mode 100644 index 0000000..cab1b4f --- /dev/null +++ b/app/game-engine/test/scenarioComposition.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from 'vitest'; + +import { + composeScenarioResource, + mergeScenarioResources, + type ScenarioResourceReader, +} from '../src/scenario/scenarioComposition.js'; + +const createReader = + (resources: Record): ScenarioResourceReader => + async (relativePath) => { + if (!(relativePath in resources)) { + throw new Error(`Missing fixture: ${relativePath}`); + } + return resources[relativePath]; + }; + +describe('scenario composition', () => { + it('deep-merges objects while replacing arrays and scalar values', () => { + expect( + mergeScenarioResources( + { + const: { + allItems: { + horse: { baseHorse: 1 }, + }, + availableSpecialWar: ['base'], + }, + events: [['base']], + }, + { + const: { + allItems: { + item: { addedItem: 2 }, + }, + availableSpecialWar: ['extended'], + nestedMetadata: { extends: 'ordinary-value' }, + }, + events: [['extended']], + } + ) + ).toEqual({ + const: { + allItems: { + horse: { baseHorse: 1 }, + item: { addedItem: 2 }, + }, + availableSpecialWar: ['extended'], + nestedMetadata: { extends: 'ordinary-value' }, + }, + events: [['extended']], + }); + }); + + it('applies extensions from left to right before the scenario body', async () => { + const result = await composeScenarioResource( + 'scenario_1.json', + createReader({ + 'scenario_1.json': { + title: 'composed', + extends: ['extensions/base.json', 'extensions/items.json'], + const: { + limit: 30, + }, + }, + 'extensions/base.json': { + map: { mapName: 'che', unitSet: 'che' }, + const: { limit: 10, baseOnly: true }, + events: [['base']], + }, + 'extensions/items.json': { + extends: '../shared/item-base.json', + const: { + allItems: { + item: { eventItem: 1 }, + }, + }, + }, + 'shared/item-base.json': { + const: { + availableSpecialWar: ['che_귀병'], + allItems: { + horse: { uniqueHorse: 2 }, + }, + }, + }, + }) + ); + + expect(result).toEqual({ + title: 'composed', + map: { mapName: 'che', unitSet: 'che' }, + const: { + limit: 30, + baseOnly: true, + availableSpecialWar: ['che_귀병'], + allItems: { + horse: { uniqueHorse: 2 }, + item: { eventItem: 1 }, + }, + }, + events: [['base']], + }); + expect(result).not.toHaveProperty('extends'); + }); + + it('rejects cycles and paths outside the scenario root', async () => { + const cyclicReader = createReader({ + 'scenario_1.json': { title: 'cycle', extends: 'extensions/a.json' }, + 'extensions/a.json': { extends: '../scenario_1.json' }, + }); + + await expect(composeScenarioResource('scenario_1.json', cyclicReader)).rejects.toThrow( + 'Scenario composition cycle' + ); + await expect( + composeScenarioResource( + 'scenario_1.json', + createReader({ + 'scenario_1.json': { title: 'escape', extends: '../outside.json' }, + }) + ) + ).rejects.toThrow('escapes the scenario resource root'); + }); +}); diff --git a/app/game-engine/test/scenarioLoader.test.ts b/app/game-engine/test/scenarioLoader.test.ts new file mode 100644 index 0000000..25ecb50 --- /dev/null +++ b/app/game-engine/test/scenarioLoader.test.ts @@ -0,0 +1,22 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +import { loadScenarioDefinitionById, resolveScenarioDefaultsPath } from '../src/scenario/scenarioLoader.js'; + +describe('tracked scenario resources', () => { + it('loads every scenario through its composed resource graph', async () => { + const scenarioRoot = path.dirname(resolveScenarioDefaultsPath()); + const files = await fs.readdir(scenarioRoot); + const scenarioIds = files + .map((fileName) => /^scenario_(\d+)\.json$/.exec(fileName)) + .filter((match): match is RegExpExecArray => match !== null) + .map((match) => Number(match[1])) + .sort((left, right) => left - right); + + expect(scenarioIds).toHaveLength(80); + const scenarios = await Promise.all(scenarioIds.map((scenarioId) => loadScenarioDefinitionById(scenarioId))); + expect(scenarios.every((scenario) => scenario.title.length > 0)).toBe(true); + }); +}); diff --git a/app/gateway-api/src/scenario/scenarioCatalog.ts b/app/gateway-api/src/scenario/scenarioCatalog.ts index 6d94ea8..cd5a266 100644 --- a/app/gateway-api/src/scenario/scenarioCatalog.ts +++ b/app/gateway-api/src/scenario/scenarioCatalog.ts @@ -3,7 +3,11 @@ import path from 'node:path'; import { spawn } from 'node:child_process'; import { fileURLToPath } from 'node:url'; -import { loadScenarioDefinitionById, resolveScenarioDefaultsPath } from '@sammo-ts/game-engine'; +import { + composeScenarioResource, + loadScenarioDefinitionById, + resolveScenarioDefaultsPath, +} from '@sammo-ts/game-engine'; import { parseScenarioDefaults, parseScenarioDefinition, type ScenarioDefaults } from '@sammo-ts/logic'; import { resolveWorkspaceRoot } from '../orchestrator/workspaceRoot.js'; @@ -172,7 +176,9 @@ const listScenarioIdsFromGit = async (commitSha: string): Promise => { return ids.sort((a, b) => a - b); }; -const buildNationIdResolver = (nations: Array<{ id: number; name: string }>): ((value: number | string | null) => number | null) => { +const buildNationIdResolver = ( + nations: Array<{ id: number; name: string }> +): ((value: number | string | null) => number | null) => { const byName = new Map(nations.map((nation) => [nation.name, nation.id])); return (value) => { if (typeof value === 'number' && Number.isFinite(value)) { @@ -243,7 +249,9 @@ const loadScenarioDefaultsFromGit = async (commitSha: string): Promise => { const defaults = await loadScenarioDefaultsFromGit(commitSha); - const rawScenario = await readGitJson(commitSha, path.join(SCENARIO_ROOT, `scenario_${scenarioId}.json`)); + const rawScenario = await composeScenarioResource(`scenario_${scenarioId}.json`, (relativePath) => + readGitJson(commitSha, path.posix.join(SCENARIO_ROOT, relativePath)) + ); const scenario = parseScenarioDefinition(rawScenario, defaults); const resolveNationId = buildNationIdResolver(scenario.nations); diff --git a/packages/logic/src/resources/scenarioSchema.ts b/packages/logic/src/resources/scenarioSchema.ts index f84abd6..d661c52 100644 --- a/packages/logic/src/resources/scenarioSchema.ts +++ b/packages/logic/src/resources/scenarioSchema.ts @@ -17,9 +17,11 @@ export const ScenarioDefaultsInputSchema = z.object({ iconPath: z.string().optional(), }); -export const ScenarioDefinitionInputSchema = z +export const ScenarioExtendsInputSchema = z.union([z.string().min(1), z.array(z.string().min(1)).min(1)]); + +const ScenarioBodyInputSchema = z .object({ - title: z.string(), + extends: ScenarioExtendsInputSchema.optional(), startYear: z.number().optional(), life: z.number().optional(), fiction: z.number().optional(), @@ -41,7 +43,20 @@ export const ScenarioDefinitionInputSchema = z }) .passthrough(); -export const ScenarioResourceSchema = z.union([ScenarioDefaultsInputSchema, ScenarioDefinitionInputSchema]); +export const ScenarioDefinitionInputSchema = ScenarioBodyInputSchema.extend({ + title: z.string(), +}); + +export const ScenarioFragmentInputSchema = ScenarioBodyInputSchema.extend({ + title: z.never().optional(), +}); + +export const ScenarioResourceSchema = z.union([ + ScenarioDefaultsInputSchema, + ScenarioDefinitionInputSchema, + ScenarioFragmentInputSchema, +]); export type ScenarioDefaultsInput = z.infer; export type ScenarioDefinitionInput = z.infer; +export type ScenarioFragmentInput = z.infer; diff --git a/packages/tools-scripts/src/validate-resources.ts b/packages/tools-scripts/src/validate-resources.ts index af2254f..4107d22 100644 --- a/packages/tools-scripts/src/validate-resources.ts +++ b/packages/tools-scripts/src/validate-resources.ts @@ -4,6 +4,8 @@ import { fileURLToPath } from 'node:url'; import { MapResourceSchema, + ScenarioDefinitionInputSchema, + ScenarioFragmentInputSchema, ScenarioResourceSchema, TurnCommandProfileInputSchema, UnitSetDefinitionInputSchema, @@ -23,10 +25,17 @@ const RESOURCE_SCHEMAS: Record void> = { const listJsonFiles = async (dirPath: string): Promise => { const entries = await fs.readdir(dirPath, { withFileTypes: true }); - return entries - .filter((entry) => entry.isFile() && entry.name.endsWith('.json')) - .map((entry) => entry.name) - .sort((a, b) => a.localeCompare(b)); + const files = await Promise.all( + entries.map(async (entry): Promise => { + if (entry.isDirectory()) { + return (await listJsonFiles(path.join(dirPath, entry.name))).map((fileName) => + path.join(entry.name, fileName) + ); + } + return entry.isFile() && entry.name.endsWith('.json') ? [entry.name] : []; + }) + ); + return files.flat().sort((a, b) => a.localeCompare(b)); }; const validateFolder = async (folder: string): Promise => { @@ -50,7 +59,13 @@ const validateFolder = async (folder: string): Promise => { continue; } try { - schema(parsed); + if (folder === 'scenario' && /^scenario_\d+\.json$/.test(fileName)) { + ScenarioDefinitionInputSchema.parse(parsed); + } else if (folder === 'scenario' && fileName.startsWith(`extensions${path.sep}`)) { + ScenarioFragmentInputSchema.parse(parsed); + } else { + schema(parsed); + } } catch (error) { const message = error instanceof Error ? error.message : String(error); errors.push(`${path.relative(REPO_ROOT, filePath)}: ${message}`); diff --git a/resources/schema/scenario.json b/resources/schema/scenario.json index a22203a..8a9a886 100644 --- a/resources/schema/scenario.json +++ b/resources/schema/scenario.json @@ -40,8 +40,21 @@ { "type": "object", "properties": { - "title": { - "type": "string" + "extends": { + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "minItems": 1, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + } + ] }, "startYear": { "type": "number" @@ -140,12 +153,138 @@ }, "ignoreDefaultEvents": { "type": "boolean" + }, + "title": { + "type": "string" } }, "required": [ "title" ], "additionalProperties": {} + }, + { + "type": "object", + "properties": { + "extends": { + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "minItems": 1, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + } + ] + }, + "startYear": { + "type": "number" + }, + "life": { + "type": "number" + }, + "fiction": { + "type": "number" + }, + "history": { + "type": "array", + "items": { + "type": "string" + } + }, + "iconPath": { + "type": "string" + }, + "stat": { + "type": "object", + "properties": { + "total": { + "type": "number" + }, + "min": { + "type": "number" + }, + "max": { + "type": "number" + }, + "npcTotal": { + "type": "number" + }, + "npcMax": { + "type": "number" + }, + "npcMin": { + "type": "number" + }, + "chiefMin": { + "type": "number" + } + }, + "additionalProperties": false + }, + "map": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "const": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "nation": { + "type": "array", + "items": {} + }, + "diplomacy": { + "type": "array", + "items": {} + }, + "general": { + "type": "array", + "items": {} + }, + "general_ex": { + "type": "array", + "items": {} + }, + "general_neutral": { + "type": "array", + "items": {} + }, + "cities": { + "type": "array", + "items": {} + }, + "events": { + "type": "array", + "items": {} + }, + "initialEvents": { + "type": "array", + "items": {} + }, + "initialActions": { + "type": "array", + "items": {} + }, + "ignoreDefaultEvents": { + "type": "boolean" + }, + "title": { + "not": {} + } + }, + "additionalProperties": {} } ] }