feat: add composable scenario resources
This commit is contained in:
Vendored
+1
-1
@@ -5,7 +5,7 @@
|
|||||||
"url": "./resources/schema/map.json"
|
"url": "./resources/schema/map.json"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"fileMatch": ["resources/scenario/*.json"],
|
"fileMatch": ["resources/scenario/*.json", "resources/scenario/**/*.json"],
|
||||||
"url": "./resources/schema/scenario.json"
|
"url": "./resources/schema/scenario.json"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export * from './lifecycle/inMemoryControlQueue.js';
|
|||||||
export * from './lifecycle/turnDaemonLifecycle.js';
|
export * from './lifecycle/turnDaemonLifecycle.js';
|
||||||
export * from './lifecycle/getNextTickTime.js';
|
export * from './lifecycle/getNextTickTime.js';
|
||||||
export * from './scenario/scenarioLoader.js';
|
export * from './scenario/scenarioLoader.js';
|
||||||
|
export * from './scenario/scenarioComposition.js';
|
||||||
export * from './scenario/databaseUrl.js';
|
export * from './scenario/databaseUrl.js';
|
||||||
export * from './scenario/mapLoader.js';
|
export * from './scenario/mapLoader.js';
|
||||||
export * from './scenario/scenarioSeeder.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';
|
} from '@sammo-ts/logic';
|
||||||
|
|
||||||
import { resolveWorkspaceRoot } from '../paths.js';
|
import { resolveWorkspaceRoot } from '../paths.js';
|
||||||
|
import { composeScenarioResource } from './scenarioComposition.js';
|
||||||
|
|
||||||
const REPO_ROOT = resolveWorkspaceRoot();
|
const REPO_ROOT = resolveWorkspaceRoot();
|
||||||
const DEFAULT_SCENARIO_ROOT = path.resolve(REPO_ROOT, 'resources', 'scenario');
|
const DEFAULT_SCENARIO_ROOT = path.resolve(REPO_ROOT, 'resources', 'scenario');
|
||||||
@@ -41,8 +42,16 @@ export const loadScenarioDefinition = async (
|
|||||||
scenarioPath: string,
|
scenarioPath: string,
|
||||||
defaults: ScenarioDefaults
|
defaults: ScenarioDefaults
|
||||||
): Promise<ScenarioDefinition> => {
|
): 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);
|
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 { spawn } from 'node:child_process';
|
||||||
import { fileURLToPath } from 'node:url';
|
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 { parseScenarioDefaults, parseScenarioDefinition, type ScenarioDefaults } from '@sammo-ts/logic';
|
||||||
import { resolveWorkspaceRoot } from '../orchestrator/workspaceRoot.js';
|
import { resolveWorkspaceRoot } from '../orchestrator/workspaceRoot.js';
|
||||||
|
|
||||||
@@ -172,7 +176,9 @@ const listScenarioIdsFromGit = async (commitSha: string): Promise<number[]> => {
|
|||||||
return ids.sort((a, b) => a - b);
|
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]));
|
const byName = new Map(nations.map((nation) => [nation.name, nation.id]));
|
||||||
return (value) => {
|
return (value) => {
|
||||||
if (typeof value === 'number' && Number.isFinite(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 buildScenarioPreviewFromGit = async (commitSha: string, scenarioId: number): Promise<ScenarioPreview> => {
|
||||||
const defaults = await loadScenarioDefaultsFromGit(commitSha);
|
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 scenario = parseScenarioDefinition(rawScenario, defaults);
|
||||||
const resolveNationId = buildNationIdResolver(scenario.nations);
|
const resolveNationId = buildNationIdResolver(scenario.nations);
|
||||||
|
|
||||||
|
|||||||
@@ -17,9 +17,11 @@ export const ScenarioDefaultsInputSchema = z.object({
|
|||||||
iconPath: z.string().optional(),
|
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({
|
.object({
|
||||||
title: z.string(),
|
extends: ScenarioExtendsInputSchema.optional(),
|
||||||
startYear: z.number().optional(),
|
startYear: z.number().optional(),
|
||||||
life: z.number().optional(),
|
life: z.number().optional(),
|
||||||
fiction: z.number().optional(),
|
fiction: z.number().optional(),
|
||||||
@@ -41,7 +43,20 @@ export const ScenarioDefinitionInputSchema = z
|
|||||||
})
|
})
|
||||||
.passthrough();
|
.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<typeof ScenarioDefaultsInputSchema>;
|
export type ScenarioDefaultsInput = z.infer<typeof ScenarioDefaultsInputSchema>;
|
||||||
export type ScenarioDefinitionInput = z.infer<typeof ScenarioDefinitionInputSchema>;
|
export type ScenarioDefinitionInput = z.infer<typeof ScenarioDefinitionInputSchema>;
|
||||||
|
export type ScenarioFragmentInput = z.infer<typeof ScenarioFragmentInputSchema>;
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { fileURLToPath } from 'node:url';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
MapResourceSchema,
|
MapResourceSchema,
|
||||||
|
ScenarioDefinitionInputSchema,
|
||||||
|
ScenarioFragmentInputSchema,
|
||||||
ScenarioResourceSchema,
|
ScenarioResourceSchema,
|
||||||
TurnCommandProfileInputSchema,
|
TurnCommandProfileInputSchema,
|
||||||
UnitSetDefinitionInputSchema,
|
UnitSetDefinitionInputSchema,
|
||||||
@@ -23,10 +25,17 @@ const RESOURCE_SCHEMAS: Record<string, (value: unknown) => void> = {
|
|||||||
|
|
||||||
const listJsonFiles = async (dirPath: string): Promise<string[]> => {
|
const listJsonFiles = async (dirPath: string): Promise<string[]> => {
|
||||||
const entries = await fs.readdir(dirPath, { withFileTypes: true });
|
const entries = await fs.readdir(dirPath, { withFileTypes: true });
|
||||||
return entries
|
const files = await Promise.all(
|
||||||
.filter((entry) => entry.isFile() && entry.name.endsWith('.json'))
|
entries.map(async (entry): Promise<string[]> => {
|
||||||
.map((entry) => entry.name)
|
if (entry.isDirectory()) {
|
||||||
.sort((a, b) => a.localeCompare(b));
|
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<string[]> => {
|
const validateFolder = async (folder: string): Promise<string[]> => {
|
||||||
@@ -50,7 +59,13 @@ const validateFolder = async (folder: string): Promise<string[]> => {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
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);
|
schema(parsed);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
errors.push(`${path.relative(REPO_ROOT, filePath)}: ${message}`);
|
errors.push(`${path.relative(REPO_ROOT, filePath)}: ${message}`);
|
||||||
|
|||||||
@@ -40,8 +40,21 @@
|
|||||||
{
|
{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"title": {
|
"extends": {
|
||||||
"type": "string"
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"minItems": 1,
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"startYear": {
|
"startYear": {
|
||||||
"type": "number"
|
"type": "number"
|
||||||
@@ -140,12 +153,138 @@
|
|||||||
},
|
},
|
||||||
"ignoreDefaultEvents": {
|
"ignoreDefaultEvents": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"type": "string"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
"title"
|
"title"
|
||||||
],
|
],
|
||||||
"additionalProperties": {}
|
"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": {}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user