Files
Genarrative/scripts/validate-overrides.ts
kdletters cbc27bad4a
Some checks failed
CI / verify (push) Has been cancelled
init with react+axum+spacetimedb
2026-04-26 18:06:23 +08:00

278 lines
10 KiB
TypeScript

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();