import { readFileSync } from 'node:fs'; import { readdirSync } from 'node:fs'; import path from 'node:path'; import { PRESET_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(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 { 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>('src/data/characterOverrides.json'); if (!expectPlainObject(errors, 'characterOverrides', overrides)) return; const characterIds = new Set(PRESET_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>('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>('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>('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(PRESET_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>('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>('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>('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();