diff --git a/AlinasMapMod/AlinasMapMod.dll b/AlinasMapMod/AlinasMapMod.dll new file mode 100644 index 00000000..8e8434c5 Binary files /dev/null and b/AlinasMapMod/AlinasMapMod.dll differ diff --git a/AlinasMapMod/AlinasMapMod.json b/AlinasMapMod/AlinasMapMod.json new file mode 100644 index 00000000..cfcee48a --- /dev/null +++ b/AlinasMapMod/AlinasMapMod.json @@ -0,0 +1,636 @@ +{ + "items": { + "AN_Sylva_Interchange_Yard": { + "identifier": "AN_Sylva_Interchange_Yard", + "name": "Sylva Interchange Yard", + "groupIds": [ + "AN_Sylva_Interchange_Yard" + ], + "description": "A yard that can be useful for organizing west bound trains and storing cars if the Interchange is filled to capacity.", + "deliveryPhases": [ + { + "cost": 2000, + "deliveries": [ + { + "carTypeFilter": "GB*", + "count": 4, + "load": "ballast", + "direction": 0 + }, + { + "carTypeFilter": "GB*", + "count": 12, + "load": "gravel", + "direction": 0 + } + ] + }, + { + "cost": 2000, + "deliveries": [ + { + "carTypeFilter": "GB*", + "count": 2, + "load": "gravel", + "direction": 0 + }, + { + "carTypeFilter": "GB*", + "count": 8, + "load": "ties", + "direction": 0 + }, + { + "carTypeFilter": "FM*", + "count": 6, + "load": "rails", + "direction": 0 + } + ] + } + ], + "prerequisiteSections": [ + "s1" + ], + "area": "sylva", + "trackSpans": [ + "PAN_Sylva_Interchange_Yard_00" + ], + "industryComponent": "AN_Sylva_Interchange_Yard.interchange-yard-site" + }, + "AN_Sylva_Wye": { + "identifier": "AN_Sylva_Wye", + "name": "Sylva Wye", + "groupIds": [ + "AN_Sylva_Wye" + ], + "description": "Adds a Wye at the Sylva Interchange, great for turning around those massive Berks.", + "area": "sylva", + "trackSpans": [ + "PAN_Sylva_Wye_00" + ], + "deliveryPhases": [ + { + "cost": 2000, + "deliveries": [ + { + "carTypeFilter": "FM", + "count": 1, + "load": "mow-machinery", + "direction": 0 + }, + { + "carTypeFilter": "GB", + "count": 100, + "load": "debris", + "direction": 0 + } + ] + }, + { + "cost": 2000, + "deliveries": [ + { + "carTypeFilter": "FM", + "count": 1, + "load": "mow-machinery", + "direction": 1 + }, + { + "carTypeFilter": "GB*", + "count": 6, + "load": "ballast", + "direction": 0 + }, + { + "carTypeFilter": "GB*", + "count": 5, + "load": "gravel", + "direction": 0 + }, + { + "carTypeFilter": "GB*", + "count": 2, + "load": "ties", + "direction": 0 + }, + { + "carTypeFilter": "FM*", + "count": 2, + "load": "rails", + "direction": 0 + } + ] + } + ], + "prerequisiteSections": [ + "s1", + "AN_Sylva_Interchange_Yard" + ], + "industryComponent": "AN_Sylva_Wye.wye-site" + }, + "AN_Sylva_Paper_Crossover": { + "identifier": "AN_Sylva_Paper_Crossover", + "name": "Sylva Paper Crossovers", + "groupIds": [ + "AN_Sylva_Paper_Crossover" + ], + "description": "Adds two Crossovers at Sylva Paperboard.", + "area": "sylva", + "trackSpans": [ + "PAN_Sylva_Paper_Crossover_00" + ], + "deliveryPhases": [ + { + "cost": 1000, + "deliveries": [ + { + "carTypeFilter": "GB*", + "count": 1, + "load": "ballast", + "direction": 0 + }, + { + "carTypeFilter": "GB*", + "count": 1, + "load": "ties", + "direction": 0 + }, + { + "carTypeFilter": "FM*", + "count": 1, + "load": "rails", + "direction": 0 + } + ] + } + ], + "prerequisiteSections": [ + "s1" + ], + "industryComponent": "AN_Sylva_Paper_Crossover.sylva-paper-crossover-site" + }, + "AN_Sylva_Pax_Storage": { + "identifier": "AN_Sylva_Pax_Storage", + "name": "Sylva Pax Storage", + "groupIds": [ + "AN_Sylva_Pax_Storage" + ], + "description": "Adds two storage tracks to Sylva Station.", + "area": "sylva", + "trackSpans": [ + "PAN_Sylva_Pax_Storage_00" + ], + "deliveryPhases": [ + { + "cost": 2000, + "deliveries": [ + { + "carTypeFilter": "GB*", + "count": 2, + "load": "ballast", + "direction": 0 + }, + { + "carTypeFilter": "GB*", + "count": 2, + "load": "ties", + "direction": 0 + }, + { + "carTypeFilter": "FM*", + "count": 2, + "load": "rails", + "direction": 0 + } + ] + } + ], + "prerequisiteSections": [ + "s1" + ], + "industryComponent": "AN_Sylva_Pax_Storage.sylva-station-site" + }, + "AN_Whittier_Yard_Sawmill": { + "identifier": "AN_Whittier_Yard_Sawmill", + "name": "Whittier Sawmill Connection", + "groupIds": [ + "AN_Whittier_Yard_Sawmill" + ], + "description": "Extend the sawmill track over to the interchange", + "trackSpans": [ + "PAN_Whittier_Yard_00" + ], + "area": "whittier", + "industryComponent": "AN_Whittier_Yard.sawmill-site", + "deliveryPhases": [ + { + "cost": 2000, + "deliveries": [ + { + "carTypeFilter": "GB*", + "count": 4, + "load": "ballast", + "direction": 0 + }, + { + "carTypeFilter": "GB*", + "count": 2, + "load": "gravel", + "direction": 0 + }, + { + "carTypeFilter": "GB*", + "count": 2, + "load": "ties", + "direction": 0 + }, + { + "carTypeFilter": "FM*", + "count": 1, + "load": "rails", + "direction": 0 + } + ] + } + ] + }, + "AN_Whittier_Yard_1": { + "identifier": "AN_Whittier_Yard_1", + "name": "Whittier Yard ", + "groupIds": [ + "AN_Whittier_Yard_Yard_Lead", + "AN_Whittier_Yard_Yard_Track_6", + "AN_Whittier_Yard_Yard_Track_7", + "AN_Whittier_Yard_Yard_Track_8" + ], + "trackSpans": [ + "PAN_Whittier_Yard_00" + ], + "industryComponent": "AN_Whittier_Yard.yard-site-1", + "area": "whittier", + "description": "A yard that can be useful for organizing trains and storing cars.", + "prerequisiteSections": [ + "AN_Whittier_Yard_Sawmill" + ], + "deliveryPhases": [ + { + "cost": 2000, + "deliveries": [ + { + "carTypeFilter": "GB*", + "count": 8, + "load": "ballast", + "direction": 0 + }, + { + "carTypeFilter": "GB*", + "count": 12, + "load": "gravel", + "direction": 0 + } + ] + }, + { + "cost": 2000, + "deliveries": [ + { + "carTypeFilter": "GB*", + "count": 6, + "load": "gravel", + "direction": 0 + }, + { + "carTypeFilter": "GB*", + "count": 8, + "load": "ties", + "direction": 0 + }, + { + "carTypeFilter": "FM*", + "count": 6, + "load": "rails", + "direction": 0 + } + ] + } + ] + }, + "AN_Whittier_Yard_2": { + "identifier": "AN_Whittier_Yard_2", + "name": "Whittier Yard Extension 2", + "groupIds": [ + "AN_Whittier_Yard_Yard_Track_3", + "AN_Whittier_Yard_Yard_Track_4", + "AN_Whittier_Yard_Yard_Track_5" + ], + "trackSpans": [ + "PAN_Whittier_Yard_00" + ], + "industryComponent": "AN_Whittier_Yard.yard-site-2", + "area": "whittier", + "description": "An additional 3 tracks for the Whittier yard", + "prerequisiteSections": [ + "AN_Whittier_Yard_Sawmill", + "AN_Whittier_Yard_1" + ], + "deliveryPhases": [ + { + "cost": 2000, + "deliveries": [ + { + "carTypeFilter": "GB*", + "count": 8, + "load": "ballast", + "direction": 0 + }, + { + "carTypeFilter": "GB*", + "count": 12, + "load": "gravel", + "direction": 0 + } + ] + }, + { + "cost": 2000, + "deliveries": [ + { + "carTypeFilter": "GB*", + "count": 6, + "load": "gravel", + "direction": 0 + }, + { + "carTypeFilter": "GB*", + "count": 8, + "load": "ties", + "direction": 0 + }, + { + "carTypeFilter": "FM*", + "count": 6, + "load": "rails", + "direction": 0 + } + ] + } + ] + }, + "AN_Whittier_Yard_3": { + "identifier": "AN_Whittier_Yard_3", + "name": "Whittier Yard Extension 3", + "groupIds": [ + "AN_Whittier_Yard_Yard_Track_0", + "AN_Whittier_Yard_Yard_Track_1", + "AN_Whittier_Yard_Yard_Track_2" + ], + "trackSpans": [ + "PAN_Whittier_Yard_00" + ], + "industryComponent": "AN_Whittier_Yard.yard-site-3", + "area": "whittier", + "description": "An additional 3 tracks for the Whittier yard", + "prerequisiteSections": [ + "AN_Whittier_Yard_Sawmill", + "AN_Whittier_Yard_1", + "AN_Whittier_Yard_2" + ], + "deliveryPhases": [ + { + "cost": 2000, + "deliveries": [ + { + "carTypeFilter": "GB*", + "count": 8, + "load": "ballast", + "direction": 0 + }, + { + "carTypeFilter": "GB*", + "count": 12, + "load": "gravel", + "direction": 0 + } + ] + }, + { + "cost": 2000, + "deliveries": [ + { + "carTypeFilter": "GB*", + "count": 6, + "load": "gravel", + "direction": 0 + }, + { + "carTypeFilter": "GB*", + "count": 8, + "load": "ties", + "direction": 0 + }, + { + "carTypeFilter": "FM*", + "count": 6, + "load": "rails", + "direction": 0 + } + ] + } + ] + }, + "AN_Andrews_Interchange_Yard": { + "identifier": "AN_Andrews_Interchange_Yard", + "name": "Andrews Interchange Yard", + "groupIds": [ + "AN_Andrews_Interchange_Yard" + ], + "description": "A yard that can be useful for organizing east bound trains and storing cars if the Interchange is filled to capacity.", + "trackSpans": [ + "PAN_Andrews_Interchange_Yard_00" + ], + "industryComponent": "AN_Andrews_Interchange_Yard.interchange-yard-site", + "deliveryPhases": [ + { + "cost": 2000, + "deliveries": [ + { + "carTypeFilter": "GB*", + "count": 4, + "load": "ballast", + "direction": 0 + }, + { + "carTypeFilter": "GB*", + "count": 12, + "load": "gravel", + "direction": 0 + } + ] + }, + { + "cost": 2000, + "deliveries": [ + { + "carTypeFilter": "GB*", + "count": 2, + "load": "gravel", + "direction": 0 + }, + { + "carTypeFilter": "GB*", + "count": 8, + "load": "ties", + "direction": 0 + }, + { + "carTypeFilter": "FM*", + "count": 6, + "load": "rails", + "direction": 0 + } + ] + } + ], + "prerequisiteSections": [ + "s6" + ], + "area": "andrews" + }, + "AN_Alarka_Jct_Additional": { + "identifier": "AN_Alarka_Jct_Additional", + "name": "Alarka Jct Additional Tracks", + "groupIds": [ + "AN_Alarka_Jct_Additional" + ], + "description": "Additional tracks in Alarka Jct, currently just a bypass around the interchange.", + "prerequisiteSections": [ + "alarka-jct" + ], + "trackSpans": [ + "Pevc" + ], + "industryComponent": "AN_Alarka_Jct_Additional.alarka-bypass-site", + "area": "alarka-jct", + "deliveryPhases": [ + { + "cost": 2000, + "deliveries": [ + { + "carTypeFilter": "GB*", + "count": 4, + "load": "ballast", + "direction": 0 + }, + { + "carTypeFilter": "GB*", + "count": 2, + "load": "gravel", + "direction": 0 + }, + { + "carTypeFilter": "GB*", + "count": 2, + "load": "ties", + "direction": 0 + }, + { + "carTypeFilter": "FM*", + "count": 1, + "load": "rails", + "direction": 0 + } + ] + } + ] + }, + "AN_Alarka_Pax_Storage": { + "identifier": "AN_Alarka_Pax_Storage", + "name": "Alarka Pax Storage", + "groupIds": [ + "AN_Alarka_Pax_Storage" + ], + "description": "Adds two storage tracks to Alarka Station.", + "area": "alarka", + "trackSpans": [ + "PAN_Alarka_Pax_Storage_00" + ], + "deliveryPhases": [ + { + "cost": 2000, + "deliveries": [ + { + "carTypeFilter": "GB*", + "count": 2, + "load": "ballast", + "direction": 0 + }, + { + "carTypeFilter": "GB*", + "count": 2, + "load": "ties", + "direction": 0 + }, + { + "carTypeFilter": "FM*", + "count": 2, + "load": "rails", + "direction": 0 + } + ] + } + ], + "prerequisiteSections": [ + "alarka-branch" + ], + "industryComponent": "AN_Alarka_Pax_Storage.alarka-station-site" + }, + "AN_Alarka_Loop": { + "identifier": "AN_Alarka_Loop", + "name": "Alarka Balloon Loop", + "groupIds": [ + "AN_Alarka_Loop" + ], + "description": "Balloon loop in Alarka, no more fighting with a Wye.", + "prerequisiteSections": [ + "alarka-branch" + ], + "trackSpans": [ + "PAN_Alarka_Loop_00" + ], + "industryComponent": "AN_Alarka_Loop.alarka-bypass-site", + "area": "alarka", + "deliveryPhases": [ + { + "cost": 2000, + "deliveries": [ + { + "carTypeFilter": "GB*", + "count": 4, + "load": "ballast", + "direction": 0 + }, + { + "carTypeFilter": "GB*", + "count": 2, + "load": "gravel", + "direction": 0 + }, + { + "carTypeFilter": "GB*", + "count": 2, + "load": "ties", + "direction": 0 + }, + { + "carTypeFilter": "FM*", + "count": 1, + "load": "rails", + "direction": 0 + } + ] + } + ] + } + } +} diff --git a/AlinasMapMod/Definition.json b/AlinasMapMod/Definition.json new file mode 100644 index 00000000..64b957db --- /dev/null +++ b/AlinasMapMod/Definition.json @@ -0,0 +1,22 @@ +{ + "manifestVersion": 5, + "id": "AlinaNova21.AlinasMapMod", + "name": "Alina's Map Mod", + "version": "1.7.25304.436", + "assemblies": [ "AlinasMapMod" ], + "updateUrl": "https://railroader.alinanova.dev/update.json", + "requires": [ + { + "id": "railroader", + "notBefore": "2024.6.10" + }, + { + "id": "railloader", + "notBefore": "1.10.0.2" + }, + { + "id": "Zamu.StrangeCustoms", + "notBefore": "1.10.25017.313" + } + ] +} diff --git a/AlinasMapMod/mapeditor_integration.md b/AlinasMapMod/mapeditor_integration.md new file mode 100644 index 00000000..51e4d6ea --- /dev/null +++ b/AlinasMapMod/mapeditor_integration.md @@ -0,0 +1,8 @@ +# Map Editor Integration + +There are new classes adding in AlinasMapMod that allows +itself and other mods to register their objects in MapEditor. +This allows MapEditor to edit and save these objects. + +There not much documentation yet, but there is a few implementations in the Loaders, Map, and Station folders. The main interfaces are IEditableObject, ITransformableObject, and IObjectFactory. There is also ICustomHelper for custom helper objects. + diff --git a/AlinasMapMod/paxstations.md b/AlinasMapMod/paxstations.md new file mode 100644 index 00000000..d9735307 --- /dev/null +++ b/AlinasMapMod/paxstations.md @@ -0,0 +1,36 @@ +# Pax stations + +Industry: +```json +{ + "barkers": { + "industries": { + "barkers-station": { + "name": "Barkers Station", + "localPosition": { "x": 0, "y": 0, "z": 0}, + "usesContract": false, + "components": { + "ammBarkersStation": { + "name": "Barkers Station", + "type": "AlinasMapMod.PaxStationComponent", + "timetableCode": "BC", + // Reference values: Whittier: 30, Ela: 25, Bryson: 50 + "basePopulation": 10, + "loadId": "passengers", + "trackSpans": [ // Spans for loading/unloading + "PAN_Test_Mod_00" + ], + // Future support for custom branches, currently supported is "Main" and "Alarka Branch" + "branch": "Main", + // List of ids of other passenger stations. + // Unsure of exact impact + "neighborIds": [], + "carTypeFilter": "*", + "sharedStorage": true + } + } + } + } + } +} +``` \ No newline at end of file diff --git a/AlinasMapMod/progressions.md b/AlinasMapMod/progressions.md new file mode 100644 index 00000000..76a8ae10 --- /dev/null +++ b/AlinasMapMod/progressions.md @@ -0,0 +1,88 @@ +# Progressions + +```json +// progressions json format +{ + "mapFeatures": { + "sampleMapFeature": { + "displayName": "Sample MapFeature", + "name": "Sample MapFeature", + "description": "Description", + "prerequisites": { + "anotherMapFeature": true + }, + "areasEnableOnUnlock": { + "sampleArea": true + }, + "defaultEnableInSandbox": false, + "gameObjectsEnableOnUnlock": { + // Existing object in world, primary here for dumps. + "path://scene/world/path/to/gameObject/in/tree": true, + // Requires scenery to be defined in a game-graph + "scenery://sampleSceneryId": true + }, + "trackGroupsAvailableOnUnlock": { + "sampleGroup": true + }, + "trackGroupsEnableOnUnlock": { + "sampleGroup": true + }, + "unlockExcludeIndustries": { + "sampleIndustry": true + }, + "unlockIncludeIndustries": { + // Includes components by default + "sampleIndustry": true + }, + "unlockIncludeIndustryComponents": { + "sampleIndustryComponent": true + } + } + }, + "progressions": { + "ewh": { // ewh is the only current progression tree. + "sections": { + "sampleSection": { + "displayName": "sample milestone", + "description": "Description here", + "prerequisiteSections": { + "anotherSampleSection": true + }, + "deliveryPhases": [ + // Can have as many phases as you want here. + { + "cost": 1234, + "industryComponent": "sampleIndustryId.componentId", + "deliveries": [ + // Can be empty for cost only milestones. + { + "carTypeFilter": "GB*", + "count": 8, + "load": "ballast", + "direction": 0 // 0 = LoadToIndustry, 1 = LoadFromIndustry + }, + { + "carTypeFilter": "GB*", + "count": 12, + "load": "gravel", + "direction": 0 // 0 = LoadToIndustry, 1 = LoadFromIndustry + } + ] + } + ], + // Important note: You cannot both disable and enable the same feature, not even in seperate sections. + "disableFeaturesOnUnlock": { + "sampleMapFeature": true + }, + "enableFeaturesOnUnlock": { + "sampleMapFeature": true + }, + "enableFeaturesOnAvailable": { + "sampleMapFeature": true + } + } + } + } + } +} +``` \ No newline at end of file diff --git a/AlinasMapMod/splineys.md b/AlinasMapMod/splineys.md new file mode 100644 index 00000000..d41cfe54 --- /dev/null +++ b/AlinasMapMod/splineys.md @@ -0,0 +1,158 @@ +# Splineys + +All listed values are defaults, and may usually be omitted unless otherwise noted + +- [Prefab Formats](#prefab-formats) +- [Telegraph Poles](#telegraph-poles) +- [Turntables](#turntables) +- [Loaders](#loaders) +- [Passenger Station Agent](#passenger-station-agent-includes-building)] +- [Passenger Stations](#passenger-stations) +- [Map Labels](#map-labels) + + +## Prefab formats +All prefabs use the following formats: +- Path based: `path://scene/world/path/to/gameObject/in/tree` +- Scenery: `scenery://sampleSceneryId` +- Vanilla: `vanilla://vanillaObjectid` + This is a special one for specific structures, + as of 05/10/2025, it has the following: + - roundhouseStall + - roundhouseStart + - roundhouseEnd + - coalConveyor + - coalTower + - dieselFuelingStand + - waterTower + - waterColumn + - flagStopStation + - brysonDepot + - dillsboroStation + - southernCombinationDepot + +## Telegraph poles + +```json +{ + "handler": "AlinasMapMod.TelegraphPoleBuilder", + "polesToRaise": [1,2,3] +} +``` + +```json +{ + "handler": "AlinasMapMod.TelegraphPoleMover", + "polesToMove": [1,2,3], + "poleMovement": [ + [0,1,2], + [22,2,0], + [33,3,5], + ] +} + +``` +## Turntables + +```json +{ + "handler":"AlinasMapMod.Turntable.TurntableBuilder", + "radius": 15, + "subdivisions": 32, + "position": { "x": 0, "y": 0, "z": 0 }, + "rotation": { "x": 0, "y": 0, "z": 0 }, + "roundhouseStalls": 0, + "roundhouseTrackLength": 46, + "stallPrefab": "vanilla://roundhouseStall", + "startPrefab": "vanilla://roundhouseStart", + "endPrefab": "vanilla://roundhouseEnd" +} +``` + +## Loaders +```json +{ + "handler": "AlinasMapMod.LoaderBuilder", + "position": { "x": 0, "y": 0, "z": 0 }, + "rotation": { "x": 0, "y": 0, "z": 0 }, + // Must be set to one of the coal, diesel, or water prefabs + "prefab": "empty://", + // Required for coal and diesel, see example industry below + "industry": "", +} +``` + +Example industry: +```json +{ + "loader-industry-example": { + "name": "Example industry for loaders", + "localPosition": { "x": -271.6577, "y": 0.0, "z": -22.8286133 }, + "usesContract": false, + "components": { + "coaling": { + "type": "Model.Ops.IndustryUnloader", + "name": "Example Coaling Tower", + "trackSpans": [ "PExampleSpan" ], + "carTypeFilter": "HM,HT", + "sharedStorage": true, + "loadId": "coal", + "storageChangeRate": 0.0, + "maxStorage": 300000.0, + "orderAroundEmpties": false, + "carTransferRate": 1E+07, + "orderAroundLoaded": false + }, + "diesel": { + "type": "Model.Ops.IndustryUnloader", + "name": "Example Diesel Stand", + "trackSpans": [ "PExampleSpan" ], + "carTypeFilter": "TM", + "sharedStorage": true, + "loadId": "diesel-fuel", + "storageChangeRate": 0.0, + "maxStorage": 16000.0, + "orderAroundEmpties": false, + "carTransferRate": 32000.0, + "orderAroundLoaded": false + } + } + } +} +``` + +## Passenger Station Agent (Includes building) + +```json +{ + "handler": "AlinasMapMod.StationAgentBuilder", + "position": { + "x": 12886, + "y": 562, + "z": 4703 + }, + "rotation": { + "x": 0, + "y": 0, + "z": 0 + }, + "prefab": "vanilla://flagStopStation", + "passengerStop": "whittier" +} +``` + +## Passenger Stations + +### ***DEPRECATED: Use the industry component instead*** + +## Map Labels +### Note that live editing may have ssizing issues, these are usually resolved on a save reload. + +```json +{ + "handler": "AlinasMapMod.Map.MapLabelBuilder", + "position": { "x": 0, "y": 0, "z": 0 }, + "text": "Map Label", +} + +``` \ No newline at end of file diff --git a/AlinasMapMod/validation_framework.md b/AlinasMapMod/validation_framework.md new file mode 100644 index 00000000..b06df4bb --- /dev/null +++ b/AlinasMapMod/validation_framework.md @@ -0,0 +1,861 @@ +# 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` which requires a field name for context: + +```csharp +// Create a validation builder for a specific field +var builder = new ValidationBuilder("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: + +```csharp +public class ValidationResult +{ + public bool IsValid { get; set; } = true; + public List Errors { get; set; } = new List(); + public List Warnings { get; set; } = new List(); + + 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: + +```csharp +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: + +```csharp +public abstract class SplineyBuilderBase : StrangeCustoms.ISplineyBuilder +{ + protected static readonly Serilog.ILogger Logger = Log.ForContext(); + + // 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 +```csharp +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 +```csharp +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: + +```csharp +protected GameObject SafeBuildSpliney(string id, Transform parentTransform, JObject data, Func 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: + +```csharp +protected GameObject SafeBuildSplineyWithCleanup(string id, Transform parentTransform, JObject data, Func 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: + +```csharp +protected TData DeserializeAndValidate(JObject data) where TData : IValidatable +{ + var deserializedData = data.ToObject(); + 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`: + +```csharp +protected virtual GameObject BuildFromCreatableComponent(string id, JObject data) + where TComponent : Component + where TSerialized : ICreatableComponent, IValidatable +{ + var serialized = DeserializeAndValidate(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` for standardized validation functionality: + +```csharp +public abstract class SerializedComponentBase : ISerializedPatchableComponent, IValidatable + where T : Component +{ + private readonly Dictionary> _validators = new Dictionary>(); + 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: + +```csharp +protected ValidationBuilder RuleFor(Expression> propertyExpression) +{ + // Creates a ValidationBuilder for the specified property + var propertyName = GetPropertyName(propertyExpression); + var builder = new ValidationBuilder(propertyName); + + // Store validator function for later execution + _validators[propertyName] = () => { + var propertyValue = GetPropertyValue(propertyExpression); + var context = new ValidationContext { Owner = this }; + return builder.Validate(propertyValue, context); + }; + + return builder; +} +``` + +### Example Implementation + +```csharp +public class SerializedLoader : SerializedComponentBase +{ + 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(); + + // 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`: + +```csharp +var builder = new ValidationBuilder("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(); // 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: + +```csharp +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(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 + +```csharp +public class SerializedMyCustomComponent : SerializedComponentBase +{ + 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(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(GameObject gameObject) where TComponent : Component + { + return gameObject.GetComponent() ?? gameObject.AddComponent(); + } + + 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:** +```csharp +public class OldBuilder : ISplineyBuilder +{ + public GameObject BuildSpliney(string id, Transform parent, JObject data) + { + try + { + var serialized = data.ToObject(); + serialized.Validate(); // Simple validation + return serialized.Create(id).gameObject; + } + catch (Exception ex) + { + Console.WriteLine($"Error: {ex.Message}"); + throw; + } + } +} +``` + +**After:** +```csharp +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(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:** +```csharp +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:** +```csharp +public class NewSerializedComponent : SerializedComponentBase +{ + 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 + +```csharp +// Find existing components by ID using caches +protected virtual TComponent FindExistingComponent(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(true) + .FirstOrDefault(c => GetComponentId(c) == id); +} +``` + +### Component ID Resolution + +```csharp +// 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 + +```csharp +// 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 + +```csharp +// Validate a URI field +var uriBuilder = new ValidationBuilder("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("Subdivisions"); +numberBuilder.GreaterThan(2); + +var numberResult = numberBuilder.Validate(subdivisions); +``` + +#### Creating Validation Rules + +```csharp +// Create a custom validation rule +public class PositiveNumberRule : ValidationRule +{ + 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("MyNumber"); +builder.AddRule(new PositiveNumberRule()); +``` + +#### Working with ValidationContext + +```csharp +var context = new ValidationContext { FieldName = "PrefabField" }; +var rule = new RequiredRule(); +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 + +```csharp +// This will ONLY accept vanilla:// URIs +var vanillaBuilder = new ValidationBuilder("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 + +```csharp +// This will accept multiple URI forms +var gameObjectBuilder = new ValidationBuilder("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: + +```csharp +// 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/...`: + +```csharp +// VALID: "path://scene/SomeGameObject" +// VALID: "path://scene/Parent/Child" +// INVALID: "path://notscene/SomeObject" +``` + +#### Scenery URI Format +Scenery URIs must be simple identifiers without paths: + +```csharp +// VALID: "scenery://tree_oak" +// INVALID: "scenery://category/tree_oak" // Contains path separator +``` + +### Case Sensitivity +URI scheme validation is case-insensitive: + +```csharp +// 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 \ No newline at end of file