feat: add composable scenario resources

This commit is contained in:
2026-07-30 02:27:15 +00:00
parent 96b41ef4b4
commit b0b40b87aa
10 changed files with 439 additions and 16 deletions
+1
View File
@@ -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';
@@ -0,0 +1,89 @@
import path from 'node:path';
export type ScenarioResourceReader = (relativePath: string) => Promise<unknown>;
type JsonObject = Record<string, unknown>;
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 || '<empty>'}.`);
}
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<JsonObject> => {
const rootEntry = normalizeResourcePath(entryPath);
const compose = async (resourcePath: string, stack: string[]): Promise<JsonObject> => {
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, []);
};
+11 -2
View File
@@ -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<ScenarioDefinition> => {
// 시나리오 본문을 읽고 기본값과 합쳐서 파싱한다.
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);
};
@@ -0,0 +1,125 @@
import { describe, expect, it } from 'vitest';
import {
composeScenarioResource,
mergeScenarioResources,
type ScenarioResourceReader,
} from '../src/scenario/scenarioComposition.js';
const createReader =
(resources: Record<string, unknown>): 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');
});
});
@@ -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);
});
});
@@ -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<number[]> => {
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<ScenarioD
const buildScenarioPreviewFromGit = async (commitSha: string, scenarioId: number): Promise<ScenarioPreview> => {
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);