Merge branch 'main' into refactor/typed-action-protocol
# Conflicts: # docs/developer/index.md
This commit is contained in:
@@ -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, []);
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user