feat: add resource schemas and validation scripts

- Introduced new resource schemas for maps, scenarios, unit sets, and turn commands using Zod.
- Implemented a script to generate JSON schemas from Zod schemas.
- Added a validation script to ensure resource JSON files conform to their respective schemas.
- Updated the logic package to export new resource schemas.
- Added new dependencies for schema generation and validation.
- Created a tools-scripts package for resource management tasks.
This commit is contained in:
2026-01-18 08:04:02 +00:00
parent e3f7758187
commit 81594a25c3
20 changed files with 452 additions and 94 deletions
@@ -1,6 +1,7 @@
import { GENERAL_TURN_COMMAND_KEYS, isGeneralTurnCommandKey, type GeneralTurnCommandKey } from './general/index.js';
import { NATION_TURN_COMMAND_KEYS, isNationTurnCommandKey, type NationTurnCommandKey } from './nation/index.js';
import { asStringArray, isRecord } from '@sammo-ts/common';
import { asStringArray } from '@sammo-ts/common';
import { TurnCommandProfileInputSchema } from '../../resources/turnCommandSchema.js';
export interface TurnCommandProfile {
general: GeneralTurnCommandKey[];
@@ -44,18 +45,20 @@ export const parseTurnCommandProfile = (
raw: unknown,
fallback: TurnCommandProfile = DEFAULT_TURN_COMMAND_PROFILE
): TurnCommandProfile => {
if (!isRecord(raw)) {
const parsed = TurnCommandProfileInputSchema.safeParse(raw);
if (!parsed.success) {
return fallback;
}
const data = parsed.data;
return {
general: parseKeyList({
raw: raw.general,
raw: data.general,
defaults: fallback.general,
isKey: isGeneralTurnCommandKey,
label: 'general',
}),
nation: parseKeyList({
raw: raw.nation,
raw: data.nation,
defaults: fallback.nation,
isKey: isNationTurnCommandKey,
label: 'nation',
+1
View File
@@ -7,6 +7,7 @@ export * from './logging/index.js';
export * from './messages/index.js';
export * from './items/index.js';
export { ITEM_KEYS, createItemActionModules, createItemModuleRegistry, loadItemModules } from './items/index.js';
export * from './resources/index.js';
export * from './ports/world.js';
export * from './ports/worldSnapshot.js';
export * from './scenario/index.js';
+4
View File
@@ -0,0 +1,4 @@
export * from './mapSchema.js';
export * from './scenarioSchema.js';
export * from './unitSetSchema.js';
export * from './turnCommandSchema.js';
+49
View File
@@ -0,0 +1,49 @@
import { z } from 'zod';
export const MapCityStatsSchema = z.object({
population: z.number(),
agriculture: z.number(),
commerce: z.number(),
security: z.number(),
defence: z.number(),
wall: z.number(),
});
export const MapCityDefinitionSchema = z.object({
id: z.number(),
name: z.string(),
level: z.number(),
region: z.number(),
position: z.object({
x: z.number(),
y: z.number(),
}),
connections: z.array(z.number()),
max: MapCityStatsSchema,
initial: MapCityStatsSchema,
meta: z.record(z.string(), z.unknown()).optional(),
});
export const MapDefaultsSchema = z
.object({
trust: z.number(),
trade: z.number(),
supplyState: z.number(),
frontState: z.number(),
})
.partial();
export const MapDefinitionSchema = z.object({
id: z.string(),
name: z.string(),
cities: z.array(MapCityDefinitionSchema),
defaults: MapDefaultsSchema.optional(),
meta: z.record(z.string(), z.unknown()).optional(),
});
export const RegionMapSchema = z.record(z.string(), z.record(z.string(), z.string()));
export const MapResourceSchema = z.union([MapDefinitionSchema, RegionMapSchema]);
export type MapDefinitionInput = z.infer<typeof MapDefinitionSchema>;
export type RegionMapInput = z.infer<typeof RegionMapSchema>;
@@ -0,0 +1,47 @@
import { z } from 'zod';
export const ScenarioStatBlockSchema = z
.object({
total: z.number(),
min: z.number(),
max: z.number(),
npcTotal: z.number(),
npcMax: z.number(),
npcMin: z.number(),
chiefMin: z.number(),
})
.partial();
export const ScenarioDefaultsInputSchema = z.object({
stat: ScenarioStatBlockSchema.optional(),
iconPath: z.string().optional(),
});
export const ScenarioDefinitionInputSchema = z
.object({
title: z.string(),
startYear: z.number().optional(),
life: z.number().optional(),
fiction: z.number().optional(),
history: z.array(z.string()).optional(),
iconPath: z.string().optional(),
stat: ScenarioStatBlockSchema.optional(),
map: z.record(z.string(), z.unknown()).optional(),
const: z.record(z.string(), z.unknown()).optional(),
nation: z.array(z.unknown()).optional(),
diplomacy: z.array(z.unknown()).optional(),
general: z.array(z.unknown()).optional(),
general_ex: z.array(z.unknown()).optional(),
general_neutral: z.array(z.unknown()).optional(),
cities: z.array(z.unknown()).optional(),
events: z.array(z.unknown()).optional(),
initialEvents: z.array(z.unknown()).optional(),
initialActions: z.array(z.unknown()).optional(),
ignoreDefaultEvents: z.boolean().optional(),
})
.passthrough();
export const ScenarioResourceSchema = z.union([ScenarioDefaultsInputSchema, ScenarioDefinitionInputSchema]);
export type ScenarioDefaultsInput = z.infer<typeof ScenarioDefaultsInputSchema>;
export type ScenarioDefinitionInput = z.infer<typeof ScenarioDefinitionInputSchema>;
@@ -0,0 +1,8 @@
import { z } from 'zod';
export const TurnCommandProfileInputSchema = z.object({
general: z.array(z.string()),
nation: z.array(z.string()),
});
export type TurnCommandProfileInput = z.infer<typeof TurnCommandProfileInputSchema>;
@@ -0,0 +1,54 @@
import { z } from 'zod';
const numericRecordSchema = z.record(z.string(), z.number());
const numericArraySchema = z.array(z.number());
export const CrewTypeRequirementSchema = z.union([
z.object({ type: z.literal('ReqTech'), tech: z.number() }),
z.object({ type: z.literal('ReqRegions'), regions: z.array(z.string()) }),
z.object({ type: z.literal('ReqCities'), cities: z.array(z.string()) }),
z.object({ type: z.literal('ReqCitiesWithCityLevel'), level: z.number(), cities: z.array(z.string()) }),
z.object({ type: z.literal('ReqHighLevelCities'), level: z.number(), count: z.number() }),
z.object({
type: z.literal('ReqNationAux'),
key: z.string(),
op: z.string(),
value: z.union([z.number(), z.string()]),
}),
z.object({ type: z.literal('ReqMinRelYear'), year: z.number() }),
z.object({ type: z.literal('ReqChief') }),
z.object({ type: z.literal('ReqNotChief') }),
z.object({ type: z.literal('Impossible') }),
z.looseObject({ type: z.string() }),
]);
export const CrewTypeDefinitionInputSchema = z.object({
id: z.number(),
armType: z.number(),
name: z.string(),
attack: z.number(),
defence: z.number(),
speed: z.number(),
avoid: z.number(),
magicCoef: z.number(),
cost: z.number(),
rice: z.number(),
requirements: z.array(CrewTypeRequirementSchema),
attackCoef: z.union([numericRecordSchema, numericArraySchema]),
defenceCoef: z.union([numericRecordSchema, numericArraySchema]),
info: z.array(z.string()),
initSkillTrigger: z.array(z.string()).nullable(),
phaseSkillTrigger: z.array(z.string()).nullable(),
iActionList: z.array(z.string()).nullable(),
});
export const UnitSetDefinitionInputSchema = z.object({
id: z.string(),
name: z.string(),
defaultCrewTypeId: z.number().optional(),
armTypes: z.record(z.string(), z.string()).optional(),
crewTypes: z.array(CrewTypeDefinitionInputSchema).optional(),
meta: z.record(z.string(), z.unknown()).optional(),
});
export type UnitSetDefinitionInput = z.infer<typeof UnitSetDefinitionInputSchema>;
+4 -51
View File
@@ -1,6 +1,8 @@
import { z } from 'zod';
import { asNullableNumber, asNullableString, asNumber, asString, asStringArray, isRecord } from '@sammo-ts/common';
import { ScenarioDefaultsInputSchema, ScenarioDefinitionInputSchema } from '../resources/scenarioSchema.js';
import type {
ScenarioConfig,
ScenarioDefaults,
@@ -24,56 +26,7 @@ const FALLBACK_STAT: ScenarioStatBlock = {
chiefMin: 0,
};
const toRecordOrUndefined = (value: unknown): UnknownRecord | undefined => (isRecord(value) ? value : undefined);
const toArrayOrUndefined = (value: unknown): unknown[] | undefined => (Array.isArray(value) ? value : undefined);
const zRecord = z.record(z.string(), z.unknown());
const zUnknownArray = z.array(z.unknown());
const zOptionalRecord = z.preprocess(toRecordOrUndefined, zRecord.optional());
const zOptionalArray = z.preprocess(toArrayOrUndefined, zUnknownArray.optional());
const zStatInput = z
.object({
total: z.number().optional(),
min: z.number().optional(),
max: z.number().optional(),
npcTotal: z.number().optional(),
npcMax: z.number().optional(),
npcMin: z.number().optional(),
chiefMin: z.number().optional(),
})
.partial();
const zScenarioDefaults = z
.object({
stat: z.preprocess(toRecordOrUndefined, zStatInput.optional()),
iconPath: z.string().optional(),
})
.passthrough();
const zScenarioInput = z
.object({
title: z.string(),
startYear: z.number().optional(),
life: z.number().optional(),
fiction: z.number().optional(),
history: zOptionalArray,
iconPath: z.string().optional(),
stat: z.preprocess(toRecordOrUndefined, zStatInput.optional()),
map: zOptionalRecord,
const: zOptionalRecord,
nation: zOptionalArray,
diplomacy: zOptionalArray,
general: zOptionalArray,
general_ex: zOptionalArray,
general_neutral: zOptionalArray,
cities: zOptionalArray,
events: zOptionalArray,
initialEvents: zOptionalArray,
initialActions: zOptionalArray,
ignoreDefaultEvents: z.boolean().optional(),
})
.passthrough();
const parseScenarioStatBlock = (value: unknown, fallback: ScenarioStatBlock): ScenarioStatBlock => {
const data = isRecord(value) ? value : {};
@@ -211,7 +164,7 @@ const parseDiplomacyRows = (rows: unknown[]): ScenarioDiplomacy[] =>
export const parseScenarioDefaults = (raw: unknown): ScenarioDefaults => {
// 기본 시나리오 설정값을 안전하게 읽는다.
const data = zScenarioDefaults.parse(raw);
const data = ScenarioDefaultsInputSchema.parse(raw);
const stat = parseScenarioStatBlock(data.stat, FALLBACK_STAT);
const iconPath = asString(data.iconPath, '.');
return { stat, iconPath };
@@ -219,7 +172,7 @@ export const parseScenarioDefaults = (raw: unknown): ScenarioDefaults => {
export const parseScenarioDefinition = (raw: unknown, defaults: ScenarioDefaults): ScenarioDefinition => {
// 시나리오 JSON을 런타임에서 쓰는 구조로 정규화한다.
const data = zScenarioInput.parse(raw);
const data = ScenarioDefinitionInputSchema.parse(raw);
const stat = parseScenarioStatBlock(data.stat, defaults.stat);
const mapConfig = data.map ?? {};
const constConfig = data.const ?? {};
+2 -1
View File
@@ -8,6 +8,7 @@ import {
asStringArray,
isRecord,
} from '@sammo-ts/common';
import { UnitSetDefinitionInputSchema } from '../resources/unitSetSchema.js';
const DEFAULT_REGION_MAP: Record<string, number> = {
하북: 1,
@@ -114,7 +115,7 @@ const parseCrewType = (value: unknown): CrewTypeDefinition | null => {
};
export const parseUnitSetDefinition = (value: unknown): UnitSetDefinition => {
const data = asRecord(value);
const data = UnitSetDefinitionInputSchema.parse(value);
const id = asString(data.id, 'unknown');
const name = asString(data.name, id);
const defaultCrewTypeId =
+19
View File
@@ -0,0 +1,19 @@
{
"name": "@sammo-ts/tools-scripts",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"generate:resource-schemas": "tsx src/generate-resource-schemas.ts",
"validate:resources": "tsx src/validate-resources.ts",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@sammo-ts/logic": "workspace:*",
"zod-to-json-schema": "^3.25.1"
},
"devDependencies": {
"tsx": "^4.21.0",
"typescript": "^5.9.3"
}
}
@@ -0,0 +1,36 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import type { ZodTypeAny } from 'zod';
import { zodToJsonSchema } from 'zod-to-json-schema';
import {
MapResourceSchema,
ScenarioResourceSchema,
TurnCommandProfileInputSchema,
UnitSetDefinitionInputSchema,
} from '@sammo-ts/logic';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const REPO_ROOT = path.resolve(__dirname, '..', '..', '..');
const RESOURCE_ROOT = path.join(REPO_ROOT, 'resources');
const writeSchema = async (relativePath: string, schema: ZodTypeAny, name: string): Promise<void> => {
const outputPath = path.join(RESOURCE_ROOT, relativePath);
const jsonSchema = zodToJsonSchema(schema, { name, target: 'jsonSchema7' });
const serialized = `${JSON.stringify(jsonSchema, null, 4)}\n`;
await fs.writeFile(outputPath, serialized, 'utf8');
};
const main = async (): Promise<void> => {
await writeSchema(path.join('map', 'schema.json'), MapResourceSchema, 'MapResource');
await writeSchema(path.join('scenario', 'schema.json'), ScenarioResourceSchema, 'ScenarioResource');
await writeSchema(path.join('unitset', 'schema.json'), UnitSetDefinitionInputSchema, 'UnitSetDefinition');
await writeSchema(path.join('turn-commands', 'schema.json'), TurnCommandProfileInputSchema, 'TurnCommandProfile');
};
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
@@ -0,0 +1,86 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
MapResourceSchema,
ScenarioResourceSchema,
TurnCommandProfileInputSchema,
UnitSetDefinitionInputSchema,
} from '@sammo-ts/logic';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const REPO_ROOT = path.resolve(__dirname, '..', '..', '..');
const RESOURCE_ROOT = path.join(REPO_ROOT, 'resources');
const RESOURCE_SCHEMAS: Record<string, (value: unknown) => void> = {
map: (value) => MapResourceSchema.parse(value),
scenario: (value) => ScenarioResourceSchema.parse(value),
unitset: (value) => UnitSetDefinitionInputSchema.parse(value),
'turn-commands': (value) => TurnCommandProfileInputSchema.parse(value),
};
const listJsonFiles = async (dirPath: string): Promise<string[]> => {
const entries = await fs.readdir(dirPath, { withFileTypes: true });
return entries
.filter((entry) => entry.isFile() && entry.name.endsWith('.json') && entry.name !== 'schema.json')
.map((entry) => entry.name)
.sort((a, b) => a.localeCompare(b));
};
const validateFolder = async (folder: string): Promise<string[]> => {
const schema = RESOURCE_SCHEMAS[folder];
if (!schema) {
return [`Unknown resource folder: ${folder}`];
}
const folderPath = path.join(RESOURCE_ROOT, folder);
const files = await listJsonFiles(folderPath);
const errors: string[] = [];
for (const fileName of files) {
const filePath = path.join(folderPath, fileName);
const raw = await fs.readFile(filePath, 'utf8');
let parsed: unknown;
try {
parsed = JSON.parse(raw) as unknown;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
errors.push(`${path.relative(REPO_ROOT, filePath)}: invalid JSON (${message})`);
continue;
}
try {
schema(parsed);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
errors.push(`${path.relative(REPO_ROOT, filePath)}: ${message}`);
}
}
return errors;
};
const main = async (): Promise<void> => {
const folders = Object.keys(RESOURCE_SCHEMAS).sort((a, b) => a.localeCompare(b));
const allErrors: string[] = [];
for (const folder of folders) {
const errors = await validateFolder(folder);
allErrors.push(...errors);
}
if (allErrors.length > 0) {
for (const error of allErrors) {
console.error(`[resource-validate] ${error}`);
}
process.exitCode = 1;
return;
}
console.log('[resource-validate] OK');
};
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
+20
View File
@@ -0,0 +1,20 @@
{
"extends": "../../tsconfig.paths.json",
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"target": "ES2023",
"lib": ["ES2024"],
"verbatimModuleSyntax": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noImplicitOverride": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"useUnknownInCatchVariables": true,
"noUnusedLocals": true,
"noUnusedParameters": true
},
"include": ["src"],
"references": [{ "path": "../logic" }]
}