Utility/CookieSource.cs
using System.Text.Json;
using System.Text.Json.Serialization;

/// <summary>
/// We want to save the properties of this instance as cookies.
/// </summary>
public interface ICookieSource
{
	/// <summary>
	/// Prefix to put before the names of any cookies, without a trailing period.
	/// For example: <c>"tool.balloon"</c>
	/// </summary>
	string CookiePrefix { get; }
}

/// <summary>
/// Extension methods for <see cref="ICookieSource"/>.
/// </summary>
public static class CookieSourceExtensions
{
	private static bool IsCookieProperty( PropertyDescription property )
	{
		// TODO: maybe we want to support static properties too?

		if ( property.IsStatic ) return false;
		if ( !property.HasAttribute<PropertyAttribute>() ) return false;
		if ( property.HasAttribute<JsonIgnoreAttribute>() ) return false;
		if ( !property.CanWrite || !property.CanRead ) return false;

		return true;
	}

	private static string GetCookieName( PropertyDescription property, string prefix )
	{
		var name = property.GetCustomAttribute<JsonPropertyNameAttribute>()?.Name ?? property.Name.ToLowerInvariant();

		return !string.IsNullOrEmpty( prefix ) ? $"{prefix}.{name}" : name;
	}

	extension( ICookieSource source )
	{
		private IEnumerable<(string CookieName, PropertyDescription Property)> GetCookieProperties()
		{
			var typeDesc = TypeLibrary.GetType( source.GetType() );
			if ( typeDesc is null ) return [];

			var prefix = source.CookiePrefix;

			return typeDesc.Properties.Where( IsCookieProperty )
				.Select( x => (GetCookieName( x, prefix ), x) );
		}

		/// <summary>
		/// Saves any instance properties with a <see cref="PropertyAttribute"/>, excluding any
		/// with <see cref="JsonIgnoreAttribute"/>, into <see cref="Game.Cookies"/>.
		/// </summary>
		public void SaveCookies()
		{
			foreach ( var (cookieName, property) in source.GetCookieProperties() )
			{
				try
				{
					var cookieValue = property.GetValue( source );

					Game.Cookies.SetString( cookieName, JsonSerializer.Serialize( cookieValue, property.PropertyType ) );
				}
				catch ( Exception ex )
				{
					Log.Warning( ex, $"Exception while saving cookie \"{cookieName}\"." );
				}
			}
		}

		/// <summary>
		/// Loads any instance properties with a <see cref="PropertyAttribute"/>, excluding any
		/// with <see cref="JsonIgnoreAttribute"/>, from <see cref="Game.Cookies"/>.
		/// </summary>
		public void LoadCookies()
		{
			foreach ( var (cookieName, property) in source.GetCookieProperties() )
			{
				if ( !Game.Cookies.TryGetString( cookieName, out var jsonString ) ) continue;

				try
				{
					var cookieValue = JsonSerializer.Deserialize( jsonString, property.PropertyType );

					property.SetValue( source, cookieValue );
				}
				catch ( Exception ex )
				{
					Log.Warning( ex, $"Exception while loading cookie \"{cookieName}\"." );
				}
			}
		}
	}
}