Editor/Data/ArchStorage.cs
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 static string PlanDirectory => ArchDataRoot.For( "plans" );
public static string KitDirectory => ArchDataRoot.For( "kits" );
public static string ArchetypeDirectory => ArchDataRoot.For( "archetypes" );
public const int CurrentPlanVersion = 4;
// Rebuilt on assembly change — STJ freezes converters, hotload carries stale statics
static JsonSerializerOptions indented;
static JsonSerializerOptions compact;
static string builtFor;
static JsonSerializerOptions Options => Current( ref indented, true );
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 is null during hotload — remember last resolved path
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";
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;
}
public static bool Supports( ArchPlan plan ) => plan is null || plan.Version <= CurrentPlanVersion;
public static void Migrate( ArchPlan plan ) {
if ( plan is null || plan.Version >= CurrentPlanVersion ) {
return;
}
plan.Version = CurrentPlanVersion;
}
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;
}
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 );
}
public static string Snapshot( ArchPlan plan ) {
return plan is null ? null : JsonSerializer.Serialize( plan, Options );
}
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;
}
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.Cornices, kit.Cornices );
shelves.Stock( ArchShelf.Cabinets, kit.Cabinets );
shelves.Stock( ArchShelf.RoadLines, kit.RoadLines );
kit.Cabinets?.RemoveAll( type => type.Hidden );
}
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() );
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 );
}
// Authored = delete file; shipped = write Hidden flag
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 ) );
}
var held = Read<ArchArchetype>( ArchetypePathFor( name ) ) ?? shipped;
held.Hidden = true;
return Write( ArchetypePathFor( name ), held );
}
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;
}
public static string CabinetPathFor( string name ) => $"{ArchCabinetCatalog.Directory}/{name}.cabinet.json";
public static bool RemoveCabinetType( string name ) {
if ( string.IsNullOrWhiteSpace( name ) ) {
return false;
}
var shipped = ArchCabinetTypes.Shipped()
.FirstOrDefault( type => string.Equals( type.Name, name, StringComparison.OrdinalIgnoreCase ) );
if ( shipped is null ) {
return Delete( CabinetPathFor( name ) );
}
var held = Read<ArchCabinetType>( CabinetPathFor( name ) ) ?? shipped;
held.Hidden = true;
return Write( CabinetPathFor( name ), held );
}
public static int RestoreCabinetTypes() {
var restored = 0;
foreach ( var file in Files( ArchCabinetCatalog.Directory, ".cabinet.json" ) ) {
var path = $"{ArchCabinetCatalog.Directory}/{file}";
if ( Read<ArchCabinetType>( path ) is not { Hidden: true } held ) {
continue;
}
held.Hidden = false;
if ( Write( path, held ) ) {
restored++;
}
}
return restored;
}
public static List<string> HiddenCabinetTypes() {
return ArchShelved.Authors<ArchCabinetType>( ArchShelf.Cabinets )
.Where( type => type.Hidden )
.Select( type => type.Name )
.OrderBy( name => name )
.ToList();
}
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 );
}
// FileSystem.Content is read-only — must go through System.IO
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 );
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;
}
}
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 ) );
// Write-verify-swap so a bad serialize never replaces a good file
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 );
public static bool DeleteDocument( string path ) => Delete( path );
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;
}
}
}