Spawner/MountSpawner.cs
/// <summary>
/// Like <see cref="PropSpawner"/>, but attaches <see cref="MountMetadata"/> to the spawned object
/// so clients without the mount installed can show a fallback cube and install prompt.
/// </summary>
public sealed class MountSpawner : ISpawner
{
	record Metadata( string GameTitle );

	public string DisplayName { get; private set; }
	public string Icon => Path;
	public string Data => Path;
	public BBox Bounds => Model.IsValid() ? Model.Bounds : default;
	public bool IsReady => Model.IsValid();
	public Task<bool> Loading { get; }

	public Model Model { get; private set; }
	public string Path { get; }

	readonly Metadata _meta;

	public MountSpawner( string path, string metadataJson )
	{
		Path = path;

		if ( string.IsNullOrEmpty( metadataJson ) )
		{
			Log.Warning( $"[MountSpawner] No metadata JSON for '{path}'" );
			_meta = new Metadata( string.Empty );
		}
		else
		{
			_meta = Json.Deserialize<Metadata>( metadataJson );
			if ( _meta is null )
				Log.Warning( $"[MountSpawner] Failed to deserialize metadata for '{path}': {metadataJson}" );
			_meta ??= new Metadata( string.Empty );
		}

		DisplayName = System.IO.Path.GetFileNameWithoutExtension( path );
		Loading = LoadAsync();
	}

	private async Task<bool> LoadAsync()
	{
		Model = await ResourceLibrary.LoadAsync<Model>( Path );
		Log.Info( $"[MountSpawner] path='{Path}' model={(Model.IsValid() ? "loaded" : "missing")} title='{_meta.GameTitle}'" );
		return true; // missing model uses placeholder
	}

	/// <summary>Serialize mount metadata to pass through the Spawn RPC.</summary>
	public static string SerializeMetadata( string gameTitle )
		=> Json.Serialize( new Metadata( gameTitle ) );

	public void DrawPreview( Transform transform, Material overrideMaterial )
	{
		var bounds = Bounds;
		var t = transform;
		t = new Transform( t.PointToWorld( bounds.Center ), t.Rotation, t.Scale * ( bounds.Size / 50f ) );
		Game.ActiveScene.DebugOverlay.Model( Model.IsValid() ? Model : Model.Cube, transform: t, overlay: false, materialOveride: overrideMaterial );
	}

	public Task<List<GameObject>> Spawn( Transform transform, Player player )
	{
		var effectiveBounds = Model.IsValid() ? Model.Bounds : new BBox( -Vector3.One * 8f, Vector3.One * 8f );
		var depth = Model.IsValid() ? -Model.Bounds.Mins.z : effectiveBounds.Size.z / 2f;
		transform.Position += transform.Up * depth;

		var go = new GameObject( false, "prop" );
		go.Tags.Add( "removable" );
		go.WorldTransform = transform;

		if ( Model.IsValid() )
		{
			var prop = go.AddComponent<Prop>();
			prop.Model = Model;

			if ( (Model.Physics?.Parts?.Count ?? 0) == 0 )
			{
				var collider = go.AddComponent<BoxCollider>();
				collider.Scale = Model.Bounds.Size;
				collider.Center = Model.Bounds.Center;
				go.AddComponent<Rigidbody>();
			}
		}
		else
		{
			var collider = go.AddComponent<BoxCollider>();
			collider.Scale = effectiveBounds.Size;
			collider.Center = effectiveBounds.Center;
			go.AddComponent<Rigidbody>();
		}

		var meta = go.AddComponent<MountMetadata>();
		meta.GameTitle = _meta.GameTitle;
		meta.BoundsSize = effectiveBounds.Size;
		meta.BoundsCenter = effectiveBounds.Center;

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

		return Task.FromResult( new List<GameObject> { go } );
	}
}