From 9ed75fb5da0be1ac35f0d8129ad69de9ab5f0afa Mon Sep 17 00:00:00 2001 From: Victor Giers Date: Mon, 13 Apr 2026 16:17:50 +0200 Subject: [PATCH] Add validation for NPC entities in scene documents --- src/document/scene-document-validation.ts | 122 ++++++++++++++++++++++ 1 file changed, 122 insertions(+) diff --git a/src/document/scene-document-validation.ts b/src/document/scene-document-validation.ts index 560735bb..2d943d5b 100644 --- a/src/document/scene-document-validation.ts +++ b/src/document/scene-document-validation.ts @@ -19,6 +19,7 @@ import { getPlayerStartColliderHeight, type EntityInstance, type InteractableEntity, + type NpcEntity, type PointLightEntity, type PlayerStartEntity, type SceneEntryEntity, @@ -2381,6 +2382,101 @@ function validateSceneEntryEntity( } } +function validateNpcModelAssetId( + entity: NpcEntity, + path: string, + document: SceneDocument, + diagnostics: SceneDiagnostic[] +) { + if (entity.modelAssetId === null) { + return; + } + + if ( + typeof entity.modelAssetId !== "string" || + entity.modelAssetId.trim().length === 0 + ) { + diagnostics.push( + createDiagnostic( + "error", + "invalid-npc-model-asset-id", + "NPC modelAssetId must be null or reference a non-empty model asset id.", + `${path}.modelAssetId` + ) + ); + return; + } + + const asset = document.assets[entity.modelAssetId]; + + if (asset === undefined) { + diagnostics.push( + createDiagnostic( + "error", + "missing-npc-model-asset", + `NPC model asset ${entity.modelAssetId} does not exist.`, + `${path}.modelAssetId` + ) + ); + return; + } + + if (asset.kind !== "model") { + diagnostics.push( + createDiagnostic( + "error", + "invalid-npc-model-asset-kind", + "NPC modelAssetId must reference a model asset.", + `${path}.modelAssetId` + ) + ); + } +} + +function validateNpcEntity( + entity: NpcEntity, + path: string, + document: SceneDocument, + diagnostics: SceneDiagnostic[] +) { + validateAuthoredEntityState(entity, path, diagnostics); + + if (!isFiniteVec3(entity.position)) { + diagnostics.push( + createDiagnostic( + "error", + "invalid-npc-position", + "NPC position must remain finite on every axis.", + `${path}.position` + ) + ); + } + + if (!isFiniteNumber(entity.yawDegrees)) { + diagnostics.push( + createDiagnostic( + "error", + "invalid-npc-yaw", + "NPC yaw must remain a finite number.", + `${path}.yawDegrees` + ) + ); + } + + if (typeof entity.actorId !== "string" || entity.actorId.trim().length === 0) { + diagnostics.push( + createDiagnostic( + "error", + "invalid-npc-actor-id", + "NPC actorId must remain a non-empty string.", + `${path}.actorId` + ) + ); + } + + validateNpcModelAssetId(entity, path, document, diagnostics); +} + function validateSceneExitEntity( entity: SceneExitEntity, path: string, @@ -3276,6 +3372,8 @@ export function validateSceneDocument( validateModelInstance(modelInstance, path, document, diagnostics); } + const seenNpcActorIds = new Map(); + for (const [entityKey, entity] of Object.entries(document.entities)) { const path = `entities.${entityKey}`; @@ -3306,6 +3404,30 @@ export function validateSceneDocument( case "sceneEntry": validateSceneEntryEntity(entity, path, diagnostics); break; + case "npc": { + validateNpcEntity(entity, path, document, diagnostics); + + const normalizedActorId = + typeof entity.actorId === "string" ? entity.actorId.trim() : ""; + + if (normalizedActorId.length > 0) { + const previousPath = seenNpcActorIds.get(normalizedActorId); + + if (previousPath !== undefined) { + diagnostics.push( + createDiagnostic( + "error", + "duplicate-npc-actor-id", + `NPC actorId ${normalizedActorId} is already used by ${previousPath}.`, + `${path}.actorId` + ) + ); + } else { + seenNpcActorIds.set(normalizedActorId, path); + } + } + break; + } case "soundEmitter": validateSoundEmitterEntity(entity, path, document, diagnostics); break;