Spawner/DuplicatorSpawner.cs
using System.Text.Json;
using System.Text.Json.Nodes;

/// <summary>
/// Payload for spawning a duplicator contraption.
/// </summary>
public sealed class DuplicatorSpawner : ISpawner
{
	public string DisplayName { get; private set; } = "Duplication";
	public string Icon { get; init; }
	public BBox Bounds => Dupe?.Bounds ?? default;
	public bool IsReady => Dupe is not null && _packagesReady;
	public Task<bool> Loading { get; }

	public string Data => Sandbox.Json.Serialize( new DupeInfo( Icon, Json ) );

	public DuplicationData Dupe { get; private set; }

	public string Json { get; private set; }

	private bool _packagesReady;

	public DuplicatorSpawner( DuplicationData dupe, string json, string name = null, string icon = null )
	{
		Dupe = dupe;
		Json = json;
		Icon = icon;
		DisplayName = name ?? "Duplication";
		Loading = InstallPackages();
	}

	/// <summary>
	/// Create a duplicator spawner from a storage or workshop ident.
	/// Resolution and package installation happen asynchronously via <see cref="ISpawner.Loading"/>.
	/// </summary>
	public DuplicatorSpawner( string id, string source )
	{
		Loading = ResolveAndLoad( id, source );
	}

	private async Task<bool> ResolveAndLoad( string id, string source )
	{
		if ( !ulong.TryParse( id, out var fileId ) )
			return false;

		string json;
		string name = null;

		if ( source == "workshop" )
		{
			var query = new Storage.Query { FileIds = [fileId] };

			var result = await query.Run();
			var item = result.Items?.FirstOrDefault();
			if ( item is null ) return false;

			var installed = await item.Install();
			if ( installed is null ) return false;

			json = await installed.Files.ReadAllTextAsync( "/dupe.json" );
			name = item.Title;
		}
		else
		{
			var entry = Storage.GetAll( "dupe" ).FirstOrDefault( x => x.Id.ToString() == fileId.ToString() );
			if ( entry is null ) return false;

			json = await entry.Files.ReadAllTextAsync( "/dupe.json" );
			name = entry.GetMeta<string>( "name" );
		}

		Dupe = Sandbox.Json.Deserialize<DuplicationData>( json );
		Json = json;
		DisplayName = name ?? "Duplication";

		return await InstallPackages();
	}

	/// <summary>
	/// Create from raw dupe JSON (e.g. from a storage entry). No icon.
	/// </summary>
	public static DuplicatorSpawner FromJson( string json, string name = null, string icon = null )
	{
		var dupe = Sandbox.Json.Deserialize<DuplicationData>( json );
		return new DuplicatorSpawner( dupe, json, name, icon );
	}

	/// <summary>
	/// Creates a duplicator spawner from the serialized data string. This is what gets synced to clients, so it includes the icon and raw JSON.
	/// </summary>
	/// <param name="data"></param>
	/// <returns></returns>
	public static DuplicatorSpawner FromData( string data )
	{
		var payload = Sandbox.Json.Deserialize<DupeInfo>( data );
		var dupe = Sandbox.Json.Deserialize<DuplicationData>( payload.Json );
		return new DuplicatorSpawner( dupe, payload.Json, icon: payload.Icon );
	}

	private record DupeInfo( string Icon, string Json );

	private async Task<bool> InstallPackages()
	{
		if ( Dupe?.Packages is null || Dupe.Packages.Count == 0 )
		{
			_packagesReady = true;
			return true;
		}

		foreach ( var pkg in Dupe.Packages )
		{
			if ( Cloud.IsInstalled( pkg ) )
				continue;

			await Cloud.Load( pkg );
		}

		_packagesReady = true;
		return true;
	}

	public void DrawPreview( Transform transform, Material overrideMaterial )
	{
		if ( Dupe is null ) return;

		foreach ( var model in Dupe.PreviewModels )
		{
			if ( model.Model is null )
				continue;

			if ( model.Model.IsError )
			{
				var bounds = model.Bounds;
				if ( bounds.Size.IsNearlyZero() ) continue;

				var t = transform.ToWorld( model.Transform );
				t = new Transform( t.PointToWorld( bounds.Center ), t.Rotation, t.Scale * (bounds.Size / 50) );
				Game.ActiveScene.DebugOverlay.Model( Model.Cube, transform: t, overlay: false, materialOveride: overrideMaterial );
			}
			else
			{
				Game.ActiveScene.DebugOverlay.Model( model.Model, transform: transform.ToWorld( model.Transform ), overlay: false, materialOveride: overrideMaterial, localBoneTransforms: model.Bones );
			}
		}
	}

	public Task<List<GameObject>> Spawn( Transform transform, Player player )
	{
		var jsonObject = Sandbox.Json.ToNode( Dupe ) as JsonObject;
		SceneUtility.MakeIdGuidsUnique( jsonObject );

		var results = new List<GameObject>();

		using ( Game.ActiveScene.BatchGroup() )
		{
			foreach ( var entry in jsonObject["Objects"] as JsonArray )
			{
				if ( entry is not JsonObject obj )
					continue;

				var pos = entry["Position"]?.Deserialize<Vector3>() ?? default;
				var rot = entry["Rotation"]?.Deserialize<Rotation>() ?? Rotation.Identity;
				var scl = entry["Scale"]?.Deserialize<Vector3>() ?? Vector3.One;

				var world = transform.ToWorld( new Transform( pos, rot ) );
				world.Scale = scl;

				var go = new GameObject( false );
				go.Deserialize( obj, new GameObject.DeserializeOptions { TransformOverride = world } );

				Ownable.Set( go, player.Network.Owner );
				go.NetworkSpawn( true, null );

				results.Add( go );
			}
		}

		return Task.FromResult( results );
	}
}