Files
Mods/AlinasMapMod/validation_framework.md

27 KiB

Validation Framework Documentation

Overview

The AlinasMapMod validation framework provides a standardized approach to validating spliney builder components and serialized objects. This framework uses a field-based validation system with fluent API extensions and comprehensive error reporting.

Core Architecture

ValidationBuilder (Field-Based Validation)

The validation system is built around ValidationBuilder<T> which requires a field name for context:

// Create a validation builder for a specific field
var builder = new ValidationBuilder<string>("PrefabUri");

// Add validation rules using fluent API
builder.AsUri()
       .AsVanillaPrefab(allowedPrefabs);

// Validate a value
var result = builder.Validate("vanilla://locomotive_steam_small");

ValidationResult System

The framework provides detailed validation feedback with manual result creation:

public class ValidationResult
{
    public bool IsValid { get; set; } = true;
    public List<ValidationError> Errors { get; set; } = new List<ValidationError>();
    public List<ValidationWarning> Warnings { get; set; } = new List<ValidationWarning>();
    
    public void ThrowIfInvalid(); // Throws ValidationException if invalid
    
    // Combines multiple validation results with null handling
    public static ValidationResult Combine(params ValidationResult[] results);
}

ValidationError and ValidationWarning

Detailed error information with context:

public class ValidationError
{
    public string Field { get; set; }     // Field name being validated
    public string Message { get; set; }   // Human-readable error message
    public string Code { get; set; }      // Machine-readable error code
    public object Value { get; set; }     // The invalid value
}

public class ValidationWarning
{
    public string Field { get; set; }
    public string Message { get; set; }
    public object Value { get; set; }
}

Validation Rules

Core Validation Rules

  • RequiredRule: Validates non-null/non-empty values with whitespace control
  • WhitelistRule: Validates values against allowed lists (includes null validation)
  • MinValueRule: Validates minimum values for comparable types
  • EnumValidationRule: Validates enum values are defined
  • CustomRule: Allows custom validation logic

URI Validation Rules

Basic URI Rules

  • UriFormatRule: Validates URI format (contains "://")
  • UriSchemeValidationRule: Validates supported schemes (case-insensitive: path, scenery, vanilla, empty)

Scheme-Specific Rules (Restrictive)

  • VanillaPrefabRule: Only allows vanilla:// URIs, validates prefab names with path extraction
  • PathUriRule: Only allows path:// URIs, validates scene path format
  • SceneryUriRule: Only allows scenery:// URIs, rejects identifiers with paths
  • EmptyUriRule: Only allows empty:// URIs or null/empty values

Comprehensive Rules (Permissive)

  • GameObjectUriRule: Allows multiple URI schemes, validates each according to its rules

Cache Validation Rules

  • CacheValidationRule: Validates cache key existence with null handling and exception safety
  • CacheTypeValidationRule: Validates cached object types with comprehensive error handling

Builder Standardization

SplineyBuilderBase Class

All builders inherit from SplineyBuilderBase which provides standardized patterns for logging, error handling, validation, and object creation:

public abstract class SplineyBuilderBase : StrangeCustoms.ISplineyBuilder
{
    protected static readonly Serilog.ILogger Logger = Log.ForContext<SplineyBuilderBase>();
    
    // Main build method with standardized error handling
    public GameObject BuildSpliney(string id, Transform parentTransform, JObject data)
    {
        return SafeBuildSpliney(id, parentTransform, data, () => BuildSplineyInternal(id, parentTransform, data));
    }
    
    // To be implemented by derived classes
    protected abstract GameObject BuildSplineyInternal(string id, Transform parentTransform, JObject data);
}

Core Builder Features

Input Validation

protected virtual void ValidateInput(string id, JObject data)
{
    if (string.IsNullOrEmpty(id))
        throw new ValidationException("ID cannot be null or empty");
        
    if (data == null)
        throw new ValidationException("Data cannot be null");
}

GameObject Creation

protected virtual GameObject CreateGameObject(string id, Transform parentTransform, string objectTypeName = null)
{
    var gameObject = new GameObject(id);
    
    if (parentTransform != null)
    {
        gameObject.transform.SetParent(parentTransform);
    }
    else
    {
        var parent = Utils.GetParent(objectTypeName ?? "Unknown");
        if (parent != null)
            gameObject.transform.SetParent(parent.transform);
    }
    
    return gameObject;
}

Standardized Error Handling

Safe execution with structured logging and error handling:

protected GameObject SafeBuildSpliney(string id, Transform parentTransform, JObject data, Func<GameObject> buildAction)
{
    try
    {
        ValidateInput(id, data);
        Logger.Information("Building {BuilderType} with ID {Id}", GetType().Name, id);
        
        var result = buildAction();
        
        Logger.Information("Successfully built {BuilderType} with ID {Id}", GetType().Name, id);
        return result;
    }
    catch (ValidationException ex)
    {
        Logger.Error(ex, "Validation failed for {BuilderType} {Id}", GetType().Name, id);
        throw new ValidationException($"Validation failed for {GetType().Name} {id}: {ex.Message}", ex);
    }
    catch (Exception ex)
    {
        Logger.Error(ex, "Failed to create {BuilderType} {Id}", GetType().Name, id);
        throw new InvalidOperationException($"Failed to create {GetType().Name} {id}", ex);
    }
}

Transactional Building with Cleanup

For builders that need cleanup on failure:

protected GameObject SafeBuildSplineyWithCleanup(string id, Transform parentTransform, JObject data, Func<IBuilderTransaction, GameObject> buildAction)
{
    var transaction = new BuilderTransaction();
    try
    {
        ValidateInput(id, data);
        Logger.Information("Building {BuilderType} with ID {Id}", GetType().Name, id);
        
        var result = buildAction(transaction);
        transaction.Commit();
        
        Logger.Information("Successfully built {BuilderType} with ID {Id}", GetType().Name, id);
        return result;
    }
    catch (Exception ex)
    {
        transaction.Rollback();
        Logger.Error(ex, "Failed to create {BuilderType} {Id}", GetType().Name, id);
        throw;
    }
}

Deserialization and Validation

Helper method for deserializing and validating data objects:

protected TData DeserializeAndValidate<TData>(JObject data) where TData : IValidatable
{
    var deserializedData = data.ToObject<TData>();
    if (deserializedData == null)
        throw new ValidationException($"Failed to deserialize data as {typeof(TData).Name}");
        
    // Use rich validation for detailed error reporting
    var validationResult = deserializedData.ValidateWithDetails();
    if (!validationResult.IsValid)
    {
        foreach (var error in validationResult.Errors)
        {
            Logger.Error("Validation error in {Field}: {Message}", error.Field, error.Message);
        }
    }
    
    // Log warnings but continue
    foreach (var warning in validationResult.Warnings)
    {
        Logger.Warning("Validation warning in {Field}: {Message}", warning.Field, warning.Message);
    }
    
    validationResult.ThrowIfInvalid();
    return deserializedData;
}

Component Building Pattern

For building from serialized components that implement ICreatableComponent:

protected virtual GameObject BuildFromCreatableComponent<TComponent, TSerialized>(string id, JObject data)
    where TComponent : Component
    where TSerialized : ICreatableComponent<TComponent>, IValidatable
{
    var serialized = DeserializeAndValidate<TSerialized>(data);
    Logger.Information("Creating {ObjectType} {Id} using serialized component", typeof(TComponent).Name, id);
    return serialized.Create(id).gameObject;
}

Serialized Component Framework

SerializedComponentBase

Serialized components can inherit from SerializedComponentBase<T> for standardized validation functionality:

public abstract class SerializedComponentBase<T> : ISerializedPatchableComponent<T>, IValidatable 
    where T : Component
{
    private readonly Dictionary<string, Func<ValidationResult>> _validators = new Dictionary<string, Func<ValidationResult>>();
    private bool _validationConfigured = false;
    
    // Override to configure validation rules
    protected virtual void ConfigureValidation() { }
    
    // Abstract methods to be implemented by derived classes
    public abstract T Create(string id);
    public abstract void Write(T comp);
    public abstract void Read(T comp);
    
    // IValidatable implementation
    public virtual void Validate() => ValidateWithDetails().ThrowIfInvalid();
    public virtual ValidationResult ValidateWithDetails() { /* implementation */ }
}

Property-Based Validation Rules

Use the RuleFor method to create validation rules for properties:

protected ValidationBuilder<TValue> RuleFor<TValue>(Expression<Func<TValue>> propertyExpression)
{
    // Creates a ValidationBuilder for the specified property
    var propertyName = GetPropertyName(propertyExpression);
    var builder = new ValidationBuilder<TValue>(propertyName);
    
    // Store validator function for later execution
    _validators[propertyName] = () => {
        var propertyValue = GetPropertyValue<TValue>(propertyExpression);
        var context = new ValidationContext { Owner = this };
        return builder.Validate(propertyValue, context);
    };
    
    return builder;
}

Example Implementation

public class SerializedLoader : SerializedComponentBase<LoaderInstance>
{
    public string Prefab { get; set; }
    public int Capacity { get; set; }
    public Vector3 Position { get; set; }
    
    protected override void ConfigureValidation()
    {
        RuleFor(() => Prefab)
            .Required()
            .AsUri()
            .AsVanillaPrefab(VanillaPrefabs.AvailableLoaderPrefabs);
            
        RuleFor(() => Capacity)
            .GreaterThan(0);
    }
    
    public override LoaderInstance Create(string id)
    {
        // Create and configure the component
        var gameObject = new GameObject(id);
        var loader = gameObject.AddComponent<LoaderInstance>();
        
        // Apply configuration
        loader.SetPrefab(Prefab);
        loader.SetCapacity(Capacity);
        gameObject.transform.position = Position;
        
        return loader;
    }
    
    public override void Write(LoaderInstance comp)
    {
        Prefab = comp.GetPrefab();
        Capacity = comp.GetCapacity();
        Position = comp.transform.position;
    }
    
    public override void Read(LoaderInstance comp)
    {
        comp.SetPrefab(Prefab);
        comp.SetCapacity(Capacity);
        comp.transform.position = Position;
    }
}

Fluent Validation API

The framework provides a fluent API through extension methods on ValidationBuilder<T>:

var builder = new ValidationBuilder<string>("PrefabField");

// Basic validation
builder.Required()                                    // Value must not be null/empty
       .AsUri();                                     // Must contain "://"

// URI scheme validation
builder.AsUriScheme()                                // Must be supported scheme
       .AsVanillaPrefab(allowedPrefabs)              // Specific to vanilla:// URIs
       .AsPathUri()                                  // Specific to path:// URIs
       .AsSceneryUri()                               // Specific to scenery:// URIs
       .AsEmptyUri()                                 // Specific to empty:// URIs
       .AsGameObjectUri(allowedPrefabs);             // Multiple URI forms allowed

// Numeric validation
builder.GreaterThan(0)                               // Value > minimum
       .GreaterThanOrEqual(0);                       // Value >= minimum

// Enum validation
builder.AsValidEnum<MyEnum>();                       // Must be defined enum value

// Custom validation
builder.Custom((value, context) => {                 // Custom validation logic
    var result = new ValidationResult { IsValid = true };
    if (/* custom condition */) {
        result.IsValid = false;
        result.Errors.Add(new ValidationError {
            Field = context.FieldName,
            Message = "Custom validation failed",
            Code = "CUSTOM_ERROR",
            Value = value
        });
    }
    return result;
});

Complete Builder Implementation Example

Creating a New Builder

Here's how to implement a complete builder following the standardized patterns:

public class MyCustomBuilder : SplineyBuilderBase, IObjectFactory
{
    public string Name => "My Custom Component";
    public bool Enabled => true;
    public Type ObjectType => typeof(MyCustomComponent);
    
    protected override GameObject BuildSplineyInternal(string id, Transform parentTransform, JObject data)
    {
        // Use the standardized pattern for building from serialized components
        return BuildFromCreatableComponent<MyCustomComponent, SerializedMyCustomComponent>(id, data);
    }
    
    public IEditableObject CreateObject(PatchEditor editor, string id)
    {
        // Create a default instance for the editor
        return new SerializedMyCustomComponent
        {
            Prefab = "vanilla://default_prefab",
            Scale = 1.0f
        }.Create(id);
    }
}

Corresponding Serialized Component

public class SerializedMyCustomComponent : SerializedComponentBase<MyCustomComponent>
{
    public string Prefab { get; set; }
    public float Scale { get; set; } = 1.0f;
    public bool IsActive { get; set; } = true;
    
    protected override void ConfigureValidation()
    {
        RuleFor(() => Prefab)
            .Required()
            .AsUri()
            .AsVanillaPrefab(VanillaPrefabs.AvailableComponentPrefabs);
            
        RuleFor(() => Scale)
            .GreaterThan(0.1f);
    }
    
    public override MyCustomComponent Create(string id)
    {
        var gameObject = CreateGameObject(id);
        var component = GetOrAddComponent<MyCustomComponent>(gameObject);
        
        ConfigureWithActivation(gameObject, () => {
            component.SetPrefab(Prefab);
            component.SetScale(Scale);
            component.SetActive(IsActive);
        });
        
        return component;
    }
    
    public override void Write(MyCustomComponent comp)
    {
        Prefab = comp.GetPrefab();
        Scale = comp.GetScale();
        IsActive = comp.IsActive();
    }
    
    public override void Read(MyCustomComponent comp)
    {
        comp.SetPrefab(Prefab);
        comp.SetScale(Scale);
        comp.SetActive(IsActive);
    }
    
    private GameObject CreateGameObject(string id)
    {
        var gameObject = new GameObject(id);
        return gameObject;
    }
    
    private TComponent GetOrAddComponent<TComponent>(GameObject gameObject) where TComponent : Component
    {
        return gameObject.GetComponent<TComponent>() ?? gameObject.AddComponent<TComponent>();
    }
    
    private void ConfigureWithActivation(GameObject gameObject, System.Action configureAction)
    {
        var wasActive = gameObject.activeSelf;
        gameObject.SetActive(false);
        try
        {
            configureAction();
        }
        finally
        {
            gameObject.SetActive(wasActive);
        }
    }
}

Migration Guide

Updating Existing Builders

  1. Inherit from SplineyBuilderBase: Replace custom base classes with SplineyBuilderBase
  2. Use BuildFromCreatableComponent: Replace manual deserialization with the standardized pattern
  3. Implement IObjectFactory: Add CreateObject method for editor integration
  4. Remove custom logging: Use the inherited Logger instance

Before:

public class OldBuilder : ISplineyBuilder
{
    public GameObject BuildSpliney(string id, Transform parent, JObject data)
    {
        try 
        {
            var serialized = data.ToObject<SerializedComponent>();
            serialized.Validate(); // Simple validation
            return serialized.Create(id).gameObject;
        }
        catch (Exception ex)
        {
            Console.WriteLine($"Error: {ex.Message}");
            throw;
        }
    }
}

After:

public class NewBuilder : SplineyBuilderBase, IObjectFactory
{
    public string Name => "My Component";
    public bool Enabled => true;
    public Type ObjectType => typeof(MyComponent);
    
    protected override GameObject BuildSplineyInternal(string id, Transform parentTransform, JObject data)
    {
        return BuildFromCreatableComponent<MyComponent, SerializedComponent>(id, data);
    }
    
    public IEditableObject CreateObject(PatchEditor editor, string id) => 
        new SerializedComponent().Create(id);
}

Updating Serialized Components

  1. Inherit from SerializedComponentBase: Replace custom validation with the standardized framework
  2. Override ConfigureValidation: Use RuleFor to set up validation rules
  3. Use ValidationBuilder: Replace manual validation with fluent API

Before:

public class OldSerializedComponent : IValidatable
{
    public string Prefab { get; set; }
    
    public void Validate()
    {
        if (string.IsNullOrEmpty(Prefab))
            throw new ValidationException("Prefab is required");
    }
    
    public ValidationResult ValidateWithDetails()
    {
        // Manual validation result creation
    }
}

After:

public class NewSerializedComponent : SerializedComponentBase<MyComponent>
{
    public string Prefab { get; set; }
    
    protected override void ConfigureValidation()
    {
        RuleFor(() => Prefab)
            .Required()
            .AsUri()
            .AsVanillaPrefab(allowedPrefabs);
    }
    
    // Implement Create, Write, Read methods
}

Builder Helper Methods

The SplineyBuilderBase class provides additional helper methods for common builder tasks:

Finding Existing Components

// Find existing components by ID using caches
protected virtual TComponent FindExistingComponent<TComponent>(string id) where TComponent : Component
{
    // Uses type-specific caches for performance
    if (typeof(TComponent) == typeof(PaxStationAgent))
    {
        StationAgentCache.Instance.TryGetValue(id, out var stationAgent);
        return stationAgent as TComponent;
    }
    
    // Fallback to GameObject.FindObjectsOfType
    return GameObject.FindObjectsOfType<TComponent>(true)
        .FirstOrDefault(c => GetComponentId(c) == id);
}

Component ID Resolution

// Gets component ID using common patterns
protected virtual string GetComponentId(Component component)
{
    // Try common identifier patterns
    var identifiableProperty = component.GetType().GetProperty("identifier");
    if (identifiableProperty != null)
        return identifiableProperty.GetValue(component)?.ToString();
        
    var idProperty = component.GetType().GetProperty("Id");
    if (idProperty != null)
        return idProperty.GetValue(component)?.ToString();
        
    return component.name;
}

Safe Component Configuration

// Safely configure GameObject by disabling during configuration
protected virtual void ConfigureWithActivation(GameObject gameObject, System.Action configureAction)
{
    var wasActive = gameObject.activeSelf;
    gameObject.SetActive(false);
    try
    {
        configureAction();
    }
    finally
    {
        gameObject.SetActive(wasActive);
    }
}

Validation Patterns

Field-Based Validation

// Validate a URI field
var uriBuilder = new ValidationBuilder<string>("PrefabUri");
uriBuilder.Required()
          .AsUri()
          .AsVanillaPrefab(VanillaPrefabs.AvailableLoaderPrefabs);

var uriResult = uriBuilder.Validate("vanilla://locomotive_steam_small");
if (!uriResult.IsValid) 
{
    // Handle validation errors
    foreach (var error in uriResult.Errors)
    {
        Logger.Error("Validation error in {Field}: {Message}", error.Field, error.Message);
    }
}

// Validate numeric values
var numberBuilder = new ValidationBuilder<int>("Subdivisions");
numberBuilder.GreaterThan(2);

var numberResult = numberBuilder.Validate(subdivisions);

Creating Validation Rules

// Create a custom validation rule
public class PositiveNumberRule : ValidationRule<int>
{
    public override ValidationResult Validate(int value, ValidationContext context)
    {
        var result = new ValidationResult { IsValid = true };
        
        if (value <= 0)
        {
            result.IsValid = false;
            result.Errors.Add(new ValidationError
            {
                Field = context.FieldName,
                Message = $"Value must be positive, got {value}",
                Code = "MUST_BE_POSITIVE",
                Value = value
            });
        }
        
        return result;
    }
}

// Use the custom rule
var builder = new ValidationBuilder<int>("MyNumber");
builder.AddRule(new PositiveNumberRule());

Working with ValidationContext

var context = new ValidationContext { FieldName = "PrefabField" };
var rule = new RequiredRule<string>();
var result = rule.Validate(value, context);

// The context provides field information for error messages

URI Validation Architecture

Two Types of URI Rules

The validation framework supports two distinct approaches to URI validation:

Restrictive Rules (Single Scheme)

These rules only accept their specific URI scheme and reject all others:

  • VanillaPrefabRule: Only accepts vanilla:// URIs
  • PathUriRule: Only accepts path:// URIs
  • SceneryUriRule: Only accepts scenery:// URIs
  • EmptyUriRule: Only accepts empty:// URIs
// This will ONLY accept vanilla:// URIs
var vanillaBuilder = new ValidationBuilder<string>("VanillaPrefab");
vanillaBuilder.AsVanillaPrefab(allowedPrefabs);

// VALID: "vanilla://locomotive_steam_small"
// INVALID: "path://scene/SomeObject"

Permissive Rules (Multiple Schemes)

These rules accept multiple URI schemes and validate each according to its rules:

  • GameObjectUriRule: Accepts path://, scenery://, vanilla://, empty:// schemes
// This will accept multiple URI forms
var gameObjectBuilder = new ValidationBuilder<string>("GameObjectUri");
gameObjectBuilder.AsGameObjectUri(allowedVanillaPrefabs);

// VALID: "vanilla://locomotive_steam_small"
// VALID: "path://scene/SomeObject"
// VALID: "scenery://tree_oak"
// VALID: "empty://"

URI Parsing Details

Vanilla Prefab Extraction

The VanillaPrefabRule extracts prefab names from URIs with paths:

// Input: "vanilla://Locomotive/part"
// Extracted prefab: "Locomotive"
// Validation: Check if "Locomotive" is in allowed prefabs list

Path URI Format

Path URIs must follow the format path://scene/GameObject1/GameObject2/...:

// VALID: "path://scene/SomeGameObject"
// VALID: "path://scene/Parent/Child"
// INVALID: "path://notscene/SomeObject"

Scenery URI Format

Scenery URIs must be simple identifiers without paths:

// VALID: "scenery://tree_oak"
// INVALID: "scenery://category/tree_oak"  // Contains path separator

Case Sensitivity

URI scheme validation is case-insensitive:

// All of these are equivalent:
// "PATH://scene/Object"
// "path://scene/Object"  
// "Path://scene/Object"

Common Error Codes

The validation framework uses standardized error codes for consistent error handling:

Core Validation Errors

  • REQUIRED: Value is required but null/empty
  • REQUIRED_NOT_WHITESPACE: Value cannot be whitespace-only
  • INVALID_VALUE: Value not in allowed whitelist
  • MIN_VALUE_VIOLATION: Value below minimum threshold
  • INVALID_ENUM_VALUE: Enum value not defined

URI Validation Errors

  • INVALID_URI_FORMAT: URI missing "://" separator
  • INVALID_URI_SCHEME: Unsupported URI scheme
  • NON_VANILLA_URI_NOT_ALLOWED: Non-vanilla URI in vanilla-only context
  • NON_PATH_URI_NOT_ALLOWED: Non-path URI in path-only context
  • NON_SCENERY_URI_NOT_ALLOWED: Non-scenery URI in scenery-only context
  • NON_EMPTY_URI_NOT_ALLOWED: Non-empty URI in empty-only context
  • INVALID_VANILLA_PREFAB: Vanilla prefab not in allowed list
  • INVALID_PATH_URI_FORMAT: Path URI format incorrect
  • MISSING_PATH_COMPONENTS: Path URI missing GameObject path
  • MISSING_SCENERY_IDENTIFIER: Scenery URI missing identifier
  • SCENERY_IDENTIFIER_CONTAINS_PATH: Scenery identifier contains path separators
  • VALUE_MUST_BE_EMPTY: Value must be empty or null

Cache Validation Errors

  • CACHE_KEY_NULL_OR_EMPTY: Cache key cannot be null or empty
  • CACHE_NOT_FOUND: Cache key not found
  • CACHE_ACCESS_ERROR: Error accessing cache
  • CACHE_OBJECT_NULL: Cached object is null
  • INVALID_CACHE_OBJECT_TYPE: Cached object has wrong type

Benefits

  1. Field-Based Context: Validation tied to specific fields provides clear error context
  2. Comprehensive Error Reporting: Detailed error messages with codes, fields, and values
  3. Flexible Architecture: Support for both restrictive and permissive validation patterns
  4. URI Scheme Awareness: Intelligent handling of different URI schemes and formats
  5. Exception Safety: Proper null handling and exception management
  6. Extensibility: Easy to add new validation rules through inheritance
  7. Performance: Efficient validation with early returns and minimal overhead
  8. Testability: Comprehensive test coverage ensures reliability

Migration Notes

When updating validation code:

  1. ValidationBuilder Constructor: Always provide a field name
  2. Manual ValidationResult Creation: No static Valid/Invalid properties
  3. URI Rule Selection: Choose restrictive vs permissive rules based on requirements
  4. Null Handling: Validation rules now properly handle null values
  5. Error Codes: Use standardized error codes for consistent error handling

Best Practices

  1. Use Descriptive Field Names: "PrefabUri" instead of "field1"
  2. Chain Validation Rules: Use fluent API for multiple validation rules
  3. Handle ValidationResult: Always check IsValid before proceeding
  4. Log Validation Errors: Use structured logging with error context
  5. Choose Appropriate URI Rules: Restrictive for specific schemes, permissive for flexibility
  6. Test Validation Logic: Write tests for both valid and invalid cases