This commit is contained in:
277
scripts/validate-overrides.ts
Normal file
277
scripts/validate-overrides.ts
Normal file
@@ -0,0 +1,277 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { readdirSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { ROLE_TEMPLATE_CHARACTERS } from '../src/data/characterPresets.ts';
|
||||
import { MONSTER_PRESETS_BY_WORLD } from '../src/data/hostileNpcPresets.ts';
|
||||
import { buildItemCatalogId } from '../src/data/itemCatalog.ts';
|
||||
import { getScenePresetsByWorld } from '../src/data/scenePresets.ts';
|
||||
import { buildStateFunctionDefinitions } from '../src/data/stateFunctions.ts';
|
||||
import { WorldType } from '../src/types.ts';
|
||||
|
||||
function readJsonFile<T>(relativePath: string): T {
|
||||
const absolutePath = path.resolve(process.cwd(), relativePath);
|
||||
return JSON.parse(readFileSync(absolutePath, 'utf8')) as T;
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isKnownGender(value: unknown): value is 'male' | 'female' {
|
||||
return value === 'male' || value === 'female';
|
||||
}
|
||||
|
||||
function expectPlainObject(errors: string[], label: string, value: unknown) {
|
||||
if (!isPlainObject(value)) {
|
||||
errors.push(`[override] ${label} must be an object map`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function validateCharacterOverrides(errors: string[]) {
|
||||
const overrides = readJsonFile<Record<string, unknown>>('src/data/characterOverrides.json');
|
||||
if (!expectPlainObject(errors, 'characterOverrides', overrides)) return;
|
||||
|
||||
const characterIds = new Set(ROLE_TEMPLATE_CHARACTERS.map(character => character.id));
|
||||
const sceneIds = new Set(
|
||||
[WorldType.WUXIA, WorldType.XIANXIA].flatMap(worldType => getScenePresetsByWorld(worldType).map(scene => scene.id)),
|
||||
);
|
||||
|
||||
Object.entries(overrides).forEach(([characterId, override]) => {
|
||||
if (!characterIds.has(characterId)) {
|
||||
errors.push(`[override] characterOverrides contains unknown character id "${characterId}"`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isPlainObject(override)) {
|
||||
errors.push(`[override] characterOverrides["${characterId}"] must be an object`);
|
||||
return;
|
||||
}
|
||||
|
||||
const gender = override.gender;
|
||||
if (gender !== undefined && !isKnownGender(gender)) {
|
||||
errors.push(`[override] characterOverrides["${characterId}"].gender must be "male" or "female"`);
|
||||
}
|
||||
|
||||
const sceneBindings = override.sceneBindings;
|
||||
if (sceneBindings !== undefined) {
|
||||
if (!isPlainObject(sceneBindings)) {
|
||||
errors.push(`[override] characterOverrides["${characterId}"].sceneBindings must be an object`);
|
||||
} else {
|
||||
Object.entries(sceneBindings).forEach(([worldKey, binding]) => {
|
||||
if (!isPlainObject(binding)) {
|
||||
errors.push(`[override] characterOverrides["${characterId}"].sceneBindings["${worldKey}"] must be an object`);
|
||||
return;
|
||||
}
|
||||
|
||||
const homeSceneId = binding.homeSceneId;
|
||||
if (homeSceneId !== undefined && (typeof homeSceneId !== 'string' || !sceneIds.has(homeSceneId))) {
|
||||
errors.push(`[override] characterOverrides["${characterId}"] has invalid homeSceneId "${String(homeSceneId)}"`);
|
||||
}
|
||||
|
||||
const npcSceneIds = binding.npcSceneIds;
|
||||
if (npcSceneIds !== undefined) {
|
||||
if (!Array.isArray(npcSceneIds) || npcSceneIds.some(sceneId => typeof sceneId !== 'string' || !sceneIds.has(sceneId))) {
|
||||
errors.push(`[override] characterOverrides["${characterId}"] has invalid npcSceneIds`);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function validateMonsterOverrides(errors: string[]) {
|
||||
const overrides = readJsonFile<Record<string, unknown>>('src/data/monsterOverrides.json');
|
||||
if (!expectPlainObject(errors, 'monsterOverrides', overrides)) return;
|
||||
|
||||
const hostilePresetIds = new Set(
|
||||
[...MONSTER_PRESETS_BY_WORLD[WorldType.WUXIA], ...MONSTER_PRESETS_BY_WORLD[WorldType.XIANXIA]].map(monster => monster.id),
|
||||
);
|
||||
|
||||
Object.entries(overrides).forEach(([monsterId, override]) => {
|
||||
if (!hostilePresetIds.has(monsterId)) {
|
||||
errors.push(`[override] monsterOverrides contains unknown monster id "${monsterId}"`);
|
||||
return;
|
||||
}
|
||||
if (!isPlainObject(override)) {
|
||||
errors.push(`[override] monsterOverrides["${monsterId}"] must be an object`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function validateSceneOverrides(errors: string[]) {
|
||||
const overrides = readJsonFile<Record<string, unknown>>('src/data/sceneOverrides.json');
|
||||
if (!expectPlainObject(errors, 'sceneOverrides', overrides)) return;
|
||||
|
||||
const sceneIds = new Set(
|
||||
[WorldType.WUXIA, WorldType.XIANXIA].flatMap(worldType => getScenePresetsByWorld(worldType).map(scene => scene.id)),
|
||||
);
|
||||
Object.entries(overrides).forEach(([sceneId, override]) => {
|
||||
if (!sceneIds.has(sceneId)) {
|
||||
errors.push(`[override] sceneOverrides contains unknown scene id "${sceneId}"`);
|
||||
return;
|
||||
}
|
||||
if (!isPlainObject(override)) {
|
||||
errors.push(`[override] sceneOverrides["${sceneId}"] must be an object`);
|
||||
return;
|
||||
}
|
||||
|
||||
const forwardSceneId = override.forwardSceneId;
|
||||
if (forwardSceneId !== undefined && (typeof forwardSceneId !== 'string' || !sceneIds.has(forwardSceneId))) {
|
||||
errors.push(`[override] sceneOverrides["${sceneId}"] has invalid forwardSceneId "${String(forwardSceneId)}"`);
|
||||
}
|
||||
|
||||
const connectedSceneIds = override.connectedSceneIds;
|
||||
if (connectedSceneIds !== undefined) {
|
||||
if (!Array.isArray(connectedSceneIds) || connectedSceneIds.some(id => typeof id !== 'string' || !sceneIds.has(id))) {
|
||||
errors.push(`[override] sceneOverrides["${sceneId}"] has invalid connectedSceneIds`);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function validateSceneNpcOverrides(errors: string[]) {
|
||||
const overrides = readJsonFile<Record<string, unknown>>('src/data/sceneNpcOverrides.json');
|
||||
if (!expectPlainObject(errors, 'sceneNpcOverrides', overrides)) return;
|
||||
|
||||
const npcIds = new Set(
|
||||
[WorldType.WUXIA, WorldType.XIANXIA].flatMap(worldType =>
|
||||
getScenePresetsByWorld(worldType).flatMap(scene => scene.npcs.map(npc => npc.id)),
|
||||
),
|
||||
);
|
||||
const characterIds = new Set(ROLE_TEMPLATE_CHARACTERS.map(character => character.id));
|
||||
|
||||
Object.entries(overrides).forEach(([npcId, override]) => {
|
||||
if (!npcIds.has(npcId)) {
|
||||
errors.push(`[override] sceneNpcOverrides contains unknown npc id "${npcId}"`);
|
||||
return;
|
||||
}
|
||||
if (!isPlainObject(override)) {
|
||||
errors.push(`[override] sceneNpcOverrides["${npcId}"] must be an object`);
|
||||
return;
|
||||
}
|
||||
|
||||
const gender = override.gender;
|
||||
if (gender !== undefined && !isKnownGender(gender)) {
|
||||
errors.push(`[override] sceneNpcOverrides["${npcId}"].gender must be "male" or "female"`);
|
||||
}
|
||||
|
||||
const characterId = override.characterId;
|
||||
if (characterId !== undefined && (typeof characterId !== 'string' || !characterIds.has(characterId))) {
|
||||
errors.push(`[override] sceneNpcOverrides["${npcId}"] has invalid characterId "${String(characterId)}"`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function validateStateFunctionOverrides(errors: string[]) {
|
||||
const overrides = readJsonFile<Record<string, unknown>>('src/data/stateFunctionOverrides.json');
|
||||
if (!expectPlainObject(errors, 'stateFunctionOverrides', overrides)) return;
|
||||
|
||||
const functionIds = new Set(buildStateFunctionDefinitions().map(definition => definition.id));
|
||||
Object.entries(overrides).forEach(([functionId, override]) => {
|
||||
if (!functionIds.has(functionId)) {
|
||||
errors.push(`[override] stateFunctionOverrides contains unknown function id "${functionId}"`);
|
||||
return;
|
||||
}
|
||||
if (!isPlainObject(override)) {
|
||||
errors.push(`[override] stateFunctionOverrides["${functionId}"] must be an object`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function validateNpcVisualOverrides(errors: string[]) {
|
||||
const overrides = readJsonFile<Record<string, unknown>>('src/data/npcVisualOverrides.json');
|
||||
if (!expectPlainObject(errors, 'npcVisualOverrides', overrides)) return;
|
||||
|
||||
const npcIds = new Set(
|
||||
[WorldType.WUXIA, WorldType.XIANXIA].flatMap(worldType =>
|
||||
getScenePresetsByWorld(worldType).flatMap(scene => scene.npcs.map(npc => npc.id)),
|
||||
),
|
||||
);
|
||||
|
||||
Object.entries(overrides).forEach(([npcId, override]) => {
|
||||
if (!npcIds.has(npcId)) {
|
||||
errors.push(`[override] npcVisualOverrides contains unknown npc id "${npcId}"`);
|
||||
return;
|
||||
}
|
||||
if (!isPlainObject(override)) {
|
||||
errors.push(`[override] npcVisualOverrides["${npcId}"] must be an object`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function collectItemAssetPaths(rootDir: string, relativeDir = 'Icons'): string[] {
|
||||
const entries = readdirSync(rootDir, { withFileTypes: true });
|
||||
const collected: string[] = [];
|
||||
|
||||
entries.forEach(entry => {
|
||||
const absolutePath = path.join(rootDir, entry.name);
|
||||
const relativePath = `${relativeDir}/${entry.name}`.replace(/\\/g, '/');
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
collected.push(...collectItemAssetPaths(absolutePath, relativePath));
|
||||
return;
|
||||
}
|
||||
|
||||
if (entry.isFile() && entry.name.toLowerCase().endsWith('.png')) {
|
||||
collected.push(relativePath);
|
||||
}
|
||||
});
|
||||
|
||||
return collected;
|
||||
}
|
||||
|
||||
function validateItemOverrides(errors: string[]) {
|
||||
const overrides = readJsonFile<Record<string, unknown>>('src/data/itemOverrides.json');
|
||||
if (!expectPlainObject(errors, 'itemOverrides', overrides)) return;
|
||||
|
||||
const validItemIds = new Set(
|
||||
collectItemAssetPaths(path.resolve(process.cwd(), 'public/Icons'))
|
||||
.map(assetPath => buildItemCatalogId(assetPath)),
|
||||
);
|
||||
|
||||
Object.entries(overrides).forEach(([itemId, override]) => {
|
||||
if (!validItemIds.has(itemId)) {
|
||||
errors.push(`[override] itemOverrides contains unknown item id "${itemId}"`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isPlainObject(override)) {
|
||||
errors.push(`[override] itemOverrides["${itemId}"] must be an object`);
|
||||
return;
|
||||
}
|
||||
|
||||
const tags = override.tags;
|
||||
if (tags !== undefined) {
|
||||
if (!Array.isArray(tags) || tags.some(tag => typeof tag !== 'string' || !tag.trim())) {
|
||||
errors.push(`[override] itemOverrides["${itemId}"] has invalid tags`);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function main() {
|
||||
const errors: string[] = [];
|
||||
|
||||
validateCharacterOverrides(errors);
|
||||
validateMonsterOverrides(errors);
|
||||
validateSceneOverrides(errors);
|
||||
validateSceneNpcOverrides(errors);
|
||||
validateStateFunctionOverrides(errors);
|
||||
validateNpcVisualOverrides(errors);
|
||||
validateItemOverrides(errors);
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.error(`Override validation failed with ${errors.length} issue(s):`);
|
||||
errors.forEach(error => console.error(`- ${error}`));
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Override validation passed.');
|
||||
}
|
||||
|
||||
main();
|
||||
Reference in New Issue
Block a user