Editor utility for reading, writing and managing serialized architecture documents (plans, kits, archetypes) under project assets. It resolves absolute paths, serializes with System.Text.Json using build-specific options, guards against overwriting unreadable files, migrates plan versions, lists files, and exposes helpers to snapshot/restore and manage hidden archetypes.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Serialization;
using Sandbox;
namespace Sunless.Architecture;
public static class ArchStorage
{
public const string PlanDirectory = "arch/by-scene";
public const string KitDirectory = "arch/kits";
public const string ArchetypeDirectory = "arch/archetypes";
// Layer metadata joined the schema with version 2; nothing older may be written back as 2. Version 4 replaced
// the polymorphic "$kind" discriminator with an ordinary "Kind" field, so an older editor must refuse it.
public const int CurrentPlanVersion = 4;
// Rebuilt whenever the editor assembly changes, and never cached across one. System.Text.Json freezes a type's
// resolved converters onto the options instance the first time it serializes through it, and hotload carries a
// static instance over - so a plan saved after any code edit went to disk through the metadata the editor had
// BEFORE the edit. That is not a stale read; it is a stale write, and it silently drops whatever the edit added.
static JsonSerializerOptions indented;
static JsonSerializerOptions compact;
static string builtFor;
static JsonSerializerOptions Options => Current( ref indented, true );
// For a cache key, never for disk: indentation is bytes nobody reads and this runs per layer per build.
static JsonSerializerOptions Compact => Current( ref compact, false );
static JsonSerializerOptions Current( ref JsonSerializerOptions held, bool indent )
{
var assembly = typeof( ArchUnit ).Assembly.FullName;
if ( builtFor != assembly )
{
builtFor = assembly;
indented = null;
compact = null;
}
return held ??= new JsonSerializerOptions
{
WriteIndented = indent,
Converters = { new JsonStringEnumConverter() }
};
}
// Scene.Source reads null during a hotload; remember the last resolved path, else don't persist at all.
static readonly Dictionary<Guid, string> located = new();
public static string PlanPathFor( Scene scene )
{
if ( scene is null )
{
return null;
}
var source = scene.Source?.ResourcePath;
if ( string.IsNullOrWhiteSpace( source ) )
{
return located.TryGetValue( scene.Id, out var remembered ) ? remembered : null;
}
var slug = source.Replace( '\\', '/' ).TrimStart( '/' ).Replace( ".scene", "" );
var path = $"{PlanDirectory}/{slug}.archplan.json";
located[scene.Id] = path;
return path;
}
public static string KitPathFor( string name ) => $"{KitDirectory}/{name}.archkit.json";
// Every plan authored in this project, at whatever depth - the by-scene tree mirrors the scene tree.
public static List<string> PlanPaths()
{
var folder = Absolute( PlanDirectory );
try
{
if ( folder is null || !System.IO.Directory.Exists( folder ) )
{
return new List<string>();
}
return System.IO.Directory.GetFiles( folder, "*.archplan.json", System.IO.SearchOption.AllDirectories )
.Select( file => $"{PlanDirectory}/{file[folder.Length..].Replace( '\\', '/' ).TrimStart( '/' )}" )
.OrderBy( path => path )
.ToList();
}
catch ( Exception exception )
{
Log.Warning( $"Architecture could not list {PlanDirectory}: {exception.Message}" );
return new List<string>();
}
}
public static ArchPlan LoadPlan( Scene scene )
{
var path = PlanPathFor( scene );
var plan = (path is null ? null : Read<ArchPlan>( path )) ?? new ArchPlan();
Migrate( plan );
plan.Normalize();
return plan;
}
// A plan from a future editor must never be silently downgraded and written back.
public static bool Supports( ArchPlan plan ) => plan is null || plan.Version <= CurrentPlanVersion;
// v1 → v2: the layer records and links joined the schema, and their empty defaults already are
// the v2 shape - so the migration is the version stamp itself.
// v2 → v3: the two top-level lists became one. The lift is ArchPlan.Adopt, run from Normalize, because a
// plan built in memory has to fold the same way one read off disk does.
public static void Migrate( ArchPlan plan )
{
if ( plan is null || plan.Version >= CurrentPlanVersion )
{
return;
}
plan.Version = CurrentPlanVersion;
}
// An empty plan never overwrites one on disk that has content; a newer plan is never overwritten at all.
public static bool SavePlan( Scene scene, ArchPlan plan )
{
if ( !Supports( plan ) )
{
Log.Warning( $"Architecture: refused to save a plan at version {plan?.Version}, newer than this editor supports ({CurrentPlanVersion})." );
return false;
}
var path = PlanPathFor( scene );
if ( path is null )
{
Log.Warning( "Architecture: this scene has no asset path yet, so its plan cannot be saved. Save the scene first." );
return false;
}
// Before the empty-plan guard, because that guard re-reads through the same path that failed: an
// unreadable file reads as null, null has no content, and the check it was meant to make cannot fire.
if ( Unreadable<ArchPlan>( path ) )
{
Log.Warning( $"Architecture: refused to save over {path} - it is on disk but does not read back, so saving would replace a layout this editor cannot see." );
return false;
}
if ( plan is not null && !plan.HasContent && Read<ArchPlan>( path )?.HasContent == true )
{
Log.Warning( $"Architecture: refused to save an empty plan over {path}, which has a layout in it. Reopen the scene to load it back." );
return false;
}
return Write( path, plan );
}
// Undo stores the plan only; geometry is regenerated, so the two can't diverge.
public static string Snapshot( ArchPlan plan )
{
return plan is null ? null : JsonSerializer.Serialize( plan, Options );
}
// Reusable assets serialize through the same options as the plan.
public static string Serialize<T>( T value ) => JsonSerializer.Serialize( value, Options );
public static string Keyed<T>( T value ) => JsonSerializer.Serialize( value, Compact );
public static T Deserialize<T>( string text ) where T : class
{
if ( string.IsNullOrWhiteSpace( text ) )
{
return null;
}
try
{
return JsonSerializer.Deserialize<T>( text, Options );
}
catch ( Exception exception )
{
Log.Warning( $"Architecture could not deserialize {typeof( T ).Name}: {exception.Message}" );
return null;
}
}
public static ArchPlan Restore( string snapshot )
{
if ( string.IsNullOrWhiteSpace( snapshot ) )
{
return null;
}
try
{
var plan = JsonSerializer.Deserialize<ArchPlan>( snapshot, Options );
Migrate( plan );
plan?.Normalize();
return plan;
}
catch ( Exception exception )
{
Log.Warning( $"Architecture could not restore a plan snapshot: {exception.Message}" );
return null;
}
}
public static ArchKit LoadKit( string name )
{
if ( string.IsNullOrWhiteSpace( name ) )
{
return new ArchKit();
}
var kit = Read<ArchKit>( KitPathFor( name ) ) ?? new ArchKit { Name = name };
Stock( kit );
return kit;
}
// The kit carries every module's catalog, so a kit written by an editor with roads installed still reads on
// one without - the shelf simply has nobody to top it up and the tuned entries stand as they were saved.
static void Stock( ArchKit kit )
{
var shelves = ArchCatalogs.Load();
shelves.Stock( ArchShelf.Openings, kit.Openings );
shelves.Stock( ArchShelf.Walls, kit.Walls );
shelves.Stock( ArchShelf.Profiles, kit.Profiles );
shelves.Stock( ArchShelf.RoadLines, kit.RoadLines );
}
public static bool SaveKit( ArchKit kit )
{
return Write( KitPathFor( kit.Name ), kit );
}
public static string ArchetypePathFor( string name ) => $"{ArchetypeDirectory}/{name}.archetype.json";
public static List<ArchArchetype> LoadArchetypes() => Listed( ArchCatalogs.Load() );
// Shipped stands under authored and a type saying it is hidden stands nowhere. The folder used to BE the
// catalog, so a built-in was written out on first use and deleting one only bought a load's worth of quiet.
public static List<ArchArchetype> Listed( ArchCatalogs shelves )
{
var listed = new List<ArchArchetype>();
shelves.Stock( ArchShelf.Types, listed );
return listed.Where( archetype => !archetype.Hidden ).OrderBy( archetype => archetype.Title ).ToList();
}
public static bool SaveArchetype( ArchArchetype archetype )
{
return ArchShelved.Save( ArchShelf.Types, archetype );
}
// One gesture, two outcomes: an authored type IS its file, so removing it deletes it, while a type core ships
// has no file to delete - that one is written out carrying the flag that hides it, which is also its way back.
public static bool RemoveArchetype( string name )
{
if ( string.IsNullOrWhiteSpace( name ) )
{
return false;
}
var shipped = ArchShelved.Ships<ArchArchetype>( ArchShelf.Types )
.FirstOrDefault( archetype => Named( archetype, name ) );
if ( shipped is null )
{
return Delete( ArchetypePathFor( name ) );
}
// Whatever is on disk, not a stub over it - a tuned type must come back tuned when it is restored.
var held = Read<ArchArchetype>( ArchetypePathFor( name ) ) ?? shipped;
held.Hidden = true;
return Write( ArchetypePathFor( name ), held );
}
// Every hidden type at once, because a grid emptied by removals has no card left to reach one through.
public static int RestoreArchetypes()
{
var restored = 0;
foreach ( var file in Files( ArchetypeDirectory, ".archetype.json" ) )
{
var path = $"{ArchetypeDirectory}/{file}";
if ( Read<ArchArchetype>( path ) is not { Hidden: true } held )
{
continue;
}
held.Hidden = false;
if ( Write( path, held ) )
{
restored++;
}
}
return restored;
}
// Titled rather than counted, so the way back can say what it will bring.
public static List<string> HiddenArchetypes()
{
return ArchShelved.Authors<ArchArchetype>( ArchShelf.Types )
.Where( archetype => archetype.Hidden )
.Select( archetype => archetype.Title )
.OrderBy( title => title )
.ToList();
}
static bool Named( ArchArchetype archetype, string name )
{
return string.Equals( archetype.Name, name, StringComparison.OrdinalIgnoreCase );
}
// Never through Editor.FileSystem.Content - it is a Zio aggregate, read-only, and every write throws.
static string Absolute( string path )
{
var assets = Project.Current?.GetAssetsPath();
return string.IsNullOrWhiteSpace( assets )
? null
: System.IO.Path.Combine( assets, path.Replace( '/', System.IO.Path.DirectorySeparatorChar ) );
}
static bool Exists( string path )
{
var file = Absolute( path );
return file is not null && System.IO.File.Exists( file );
}
public static bool DocumentExists( string path ) => Exists( path );
static List<string> Files( string directory, string suffix )
{
var folder = Absolute( directory );
try
{
if ( folder is null || !System.IO.Directory.Exists( folder ) )
{
return new List<string>();
}
return System.IO.Directory.GetFiles( folder, $"*{suffix}" )
.Select( System.IO.Path.GetFileName )
.ToList();
}
catch ( Exception exception )
{
Log.Warning( $"Architecture could not list {directory}: {exception.Message}" );
return new List<string>();
}
}
public static List<string> DocumentFiles( string directory, string suffix ) => Files( directory, suffix );
static T Read<T>( string path ) where T : class
{
var file = Absolute( path );
try
{
if ( file is null || !System.IO.File.Exists( file ) )
{
return null;
}
return JsonSerializer.Deserialize<T>( System.IO.File.ReadAllText( file ), Options );
}
catch ( Exception exception )
{
Log.Warning( $"Architecture could not read {path}: {exception.Message}" );
return null;
}
}
public static T ReadDocument<T>( string path ) where T : class => Read<T>( path );
// A file that is there and does not parse is authored content this editor cannot read - most often a plan
// naming a kind this build has no manifest for. Absent is not unreadable.
static bool Unreadable<T>( string path ) where T : class
{
var file = Absolute( path );
if ( file is null || !System.IO.File.Exists( file ) )
{
return false;
}
try
{
return JsonSerializer.Deserialize<T>( System.IO.File.ReadAllText( file ), Options ) is null;
}
catch
{
return true;
}
}
// A baked scene, not one of our documents - same System.IO path as Absolute.
public static bool WriteAsset( string path, string text )
{
var file = Absolute( path );
if ( file is null )
{
Log.Warning( $"Architecture cannot write {path}: no project is open." );
return false;
}
try
{
System.IO.Directory.CreateDirectory( System.IO.Path.GetDirectoryName( file ) );
System.IO.File.WriteAllText( file, text );
return true;
}
catch ( Exception exception )
{
Log.Warning( $"Architecture could not write {path}: {exception.Message}" );
return false;
}
}
public static string ReadAssetText( string path )
{
var file = Absolute( path );
try
{
return file is not null && System.IO.File.Exists( file ) ? System.IO.File.ReadAllText( file ) : null;
}
catch ( Exception exception )
{
Log.Warning( $"Architecture could not read {path}: {exception.Message}" );
return null;
}
}
static bool Write<T>( string path, T value )
{
var file = Absolute( path );
if ( file is null )
{
Log.Warning( $"Architecture cannot save {path}: no project is open, so the plan only exists in this tool instance and a hotload will lose it." );
return false;
}
try
{
System.IO.Directory.CreateDirectory( System.IO.Path.GetDirectoryName( file ) );
// Never replace a good file with one that cannot be read back - write to a sibling, verify,
// then swap. The previous file stays intact until the swap succeeds.
var temporary = file + ".tmp";
var text = JsonSerializer.Serialize( value, Options );
System.IO.File.WriteAllText( temporary, text );
if ( JsonSerializer.Deserialize<T>( System.IO.File.ReadAllText( temporary ), Options ) is null )
{
System.IO.File.Delete( temporary );
Log.Warning( $"Architecture could not save {path}: the serialized document does not read back, so nothing was replaced." );
return false;
}
if ( System.IO.File.Exists( file ) )
{
System.IO.File.Replace( temporary, file, file + ".bak" );
System.IO.File.Delete( file + ".bak" );
}
else
{
System.IO.File.Move( temporary, file );
}
return true;
}
catch ( Exception exception )
{
Log.Warning( $"Architecture could not write {path}: {exception.Message} - the plan only exists in this tool instance and a hotload will lose it." );
return false;
}
}
public static bool WriteDocument<T>( string path, T value ) => Write( path, value );
static bool Delete( string path )
{
var file = Absolute( path );
try
{
if ( file is null || !System.IO.File.Exists( file ) )
{
return false;
}
System.IO.File.Delete( file );
return true;
}
catch ( Exception exception )
{
Log.Warning( $"Architecture could not delete {path}: {exception.Message}" );
return false;
}
}
}