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
- Inherit from SplineyBuilderBase: Replace custom base classes with
SplineyBuilderBase - Use BuildFromCreatableComponent: Replace manual deserialization with the standardized pattern
- Implement IObjectFactory: Add
CreateObjectmethod for editor integration - Remove custom logging: Use the inherited
Loggerinstance
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
- Inherit from SerializedComponentBase: Replace custom validation with the standardized framework
- Override ConfigureValidation: Use
RuleForto set up validation rules - 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/emptyREQUIRED_NOT_WHITESPACE: Value cannot be whitespace-onlyINVALID_VALUE: Value not in allowed whitelistMIN_VALUE_VIOLATION: Value below minimum thresholdINVALID_ENUM_VALUE: Enum value not defined
URI Validation Errors
INVALID_URI_FORMAT: URI missing "://" separatorINVALID_URI_SCHEME: Unsupported URI schemeNON_VANILLA_URI_NOT_ALLOWED: Non-vanilla URI in vanilla-only contextNON_PATH_URI_NOT_ALLOWED: Non-path URI in path-only contextNON_SCENERY_URI_NOT_ALLOWED: Non-scenery URI in scenery-only contextNON_EMPTY_URI_NOT_ALLOWED: Non-empty URI in empty-only contextINVALID_VANILLA_PREFAB: Vanilla prefab not in allowed listINVALID_PATH_URI_FORMAT: Path URI format incorrectMISSING_PATH_COMPONENTS: Path URI missing GameObject pathMISSING_SCENERY_IDENTIFIER: Scenery URI missing identifierSCENERY_IDENTIFIER_CONTAINS_PATH: Scenery identifier contains path separatorsVALUE_MUST_BE_EMPTY: Value must be empty or null
Cache Validation Errors
CACHE_KEY_NULL_OR_EMPTY: Cache key cannot be null or emptyCACHE_NOT_FOUND: Cache key not foundCACHE_ACCESS_ERROR: Error accessing cacheCACHE_OBJECT_NULL: Cached object is nullINVALID_CACHE_OBJECT_TYPE: Cached object has wrong type
Benefits
- Field-Based Context: Validation tied to specific fields provides clear error context
- Comprehensive Error Reporting: Detailed error messages with codes, fields, and values
- Flexible Architecture: Support for both restrictive and permissive validation patterns
- URI Scheme Awareness: Intelligent handling of different URI schemes and formats
- Exception Safety: Proper null handling and exception management
- Extensibility: Easy to add new validation rules through inheritance
- Performance: Efficient validation with early returns and minimal overhead
- Testability: Comprehensive test coverage ensures reliability
Migration Notes
When updating validation code:
- ValidationBuilder Constructor: Always provide a field name
- Manual ValidationResult Creation: No static
Valid/Invalidproperties - URI Rule Selection: Choose restrictive vs permissive rules based on requirements
- Null Handling: Validation rules now properly handle null values
- Error Codes: Use standardized error codes for consistent error handling
Best Practices
- Use Descriptive Field Names:
"PrefabUri"instead of"field1" - Chain Validation Rules: Use fluent API for multiple validation rules
- Handle ValidationResult: Always check
IsValidbefore proceeding - Log Validation Errors: Use structured logging with error context
- Choose Appropriate URI Rules: Restrictive for specific schemes, permissive for flexibility
- Test Validation Logic: Write tests for both valid and invalid cases