Framework/Settings/GameSetting.cs

Defines GameSetting and GameSettings. GameSetting wraps a sandbox-safe PropertyDescription with UI metadata (label, description, group, range, step, enum names) and helpers to get/set values and enumerate discrete numeric steps. GameSettings discovers [Setting] properties via the engine TypeLibrary, caches per type, builds GameSetting instances, and filters settings for live game instances.

Reflection
namespace CardGames;

/// <summary>What kind of control a <see cref="GameSetting"/> needs.</summary>
public enum SettingKind
{
	Bool,   // a toggle
	Number, // a stepped value or +/- stepper
	Enum    // a one-of-N choice
}

/// <summary>
/// A single configurable setting on a game, discovered from a <c>[Setting]</c> property by
/// <see cref="GameSettings"/>. Wraps the reflected <see cref="PropertyDescription"/> (the engine's
/// sandbox-safe reflection) plus the display metadata pulled from <c>[Title]</c>/<c>[Description]</c>/
/// <c>[Group]</c>/<c>[Range]</c>/<c>[Step]</c>, so the UI never touches reflection directly.
/// </summary>
public sealed class GameSetting
{
	public required PropertyDescription Property { get; init; }
	public required SettingKind Kind { get; init; }

	/// <summary>
	/// Display label (defaults to the prettified property name; overridden by <c>[Title]</c>).
	/// </summary>
	public required string Label { get; init; }
	public string Description { get; init; } = "";

	/// <summary>
	/// Section heading from <c>[Group]</c>, or empty for ungrouped.
	/// </summary>
	public string Group { get; init; } = "";

	// Numeric bounds (from [Range]) and increment (from [Step]). Max <= Min means "unbounded".
	public float Min { get; init; }
	public float Max { get; init; }
	public float Step { get; init; } = 1f;

	/// <summary>
	/// For <see cref="SettingKind.Enum"/>: the choice names, in declaration order.
	/// </summary>
	public IReadOnlyList<string> EnumNames { get; init; } = Array.Empty<string>();

	/// <summary>
	/// The property's name - stable id used for sync hashing and lookups.
	/// </summary>
	public string Name => Property.Name;

	public object Get( CardGame game ) => Property.GetValue( game );

	// SetValue runs the engine's Translation, which coerces our UI value to the property type: a float
	// for numbers, an enum name string for enums, a bool for toggles.
	public void Set( CardGame game, object value ) => Property.SetValue( game, value );

	/// <summary>
	/// Current value as a float (for numeric controls).
	/// </summary>
	public float GetNumber( CardGame game ) => Get( game ) switch
	{
		int i => i,
		long l => l,
		short s => s,
		byte b => b,
		float f => f,
		double d => (float)d,
		_ => 0f,
	};

	/// <summary>
	/// Current value as the enum name / bool / number string (for read-only display).
	/// </summary>
	public string GetText( CardGame game ) => Get( game )?.ToString() ?? "";

	/// <summary>
	/// True when this numeric setting has a small, finite set of steps worth rendering as buttons.
	/// </summary>
	public bool HasDiscreteSteps => Kind == SettingKind.Number && Max > Min && Step > 0f && (Max - Min) / Step <= 12f;

	/// <summary>
	/// The discrete values Min..Max by Step, for a segmented control. Empty when not discrete.
	/// </summary>
	public IReadOnlyList<float> Steps()
	{
		if ( !HasDiscreteSteps ) return Array.Empty<float>();

		var list = new List<float>();
		for ( float v = Min; v <= Max + 0.0001f; v += Step )
			list.Add( v );
		return list;
	}
}

/// <summary>
/// Discovers and describes <c>[Setting]</c>-marked properties on a <see cref="CardGame"/> type using the
/// engine's <c>TypeLibrary</c> (the sandbox-safe reflection path). Results are cached per type once the
/// type is registered. Used by the lobby settings panel.
/// </summary>
public static class GameSettings
{
	// Reset on hotload so an edit that adds/removes a [Setting] is picked up live, not masked by a stale entry.
	[Sandbox.SkipHotload]
	private static readonly Dictionary<Type, IReadOnlyList<GameSetting>> _cache = new();

	/// <summary>
	/// Every configurable setting on a game type, in declaration order. Walks the type's base classes too, so
	/// a setting declared on a base game (e.g. the universal "Card throwing" toggle on <see cref="CardGame"/>)
	/// is surfaced for every subclass - not only settings declared on the leaf type.
	/// </summary>
	public static IReadOnlyList<GameSetting> For( Type gameType )
	{
		if ( gameType is null ) return Array.Empty<GameSetting>();
		if ( _cache.TryGetValue( gameType, out var cached ) ) return cached;

		var type = TypeLibrary.GetType( gameType );
		if ( type is null )
			return Array.Empty<GameSetting>(); // not registered yet - don't cache, so a later call retries

		var list = new List<GameSetting>();
		var seen = new HashSet<string>();
		for ( var t = type; t is not null; t = t.BaseType )
		{
			foreach ( var member in t.DeclaredMembers )
			{
				if ( member is not PropertyDescription prop ) continue;
				if ( !prop.HasAttribute<SettingAttribute>() || !prop.CanRead || !prop.CanWrite ) continue;
				if ( !seen.Add( prop.Name ) ) continue; // a derived override wins; never list the same setting twice

				if ( Build( prop ) is { } setting )
					list.Add( setting );
			}
		}

		IReadOnlyList<GameSetting> result = list;
		_cache[gameType] = result;
		return result;
	}

	/// <summary>
	/// The settings to show for a live game instance: the type's <c>[Setting]</c>s minus any the game hides
	/// right now (<see cref="CardGame.ShowSetting"/>), e.g. the card-throwing toggle when it's unsupported.
	/// </summary>
	public static IReadOnlyList<GameSetting> For( CardGame game )
	{
		if ( game is null ) return Array.Empty<GameSetting>();
		return For( game.GetType() ).Where( s => game.ShowSetting( s.Name ) ).ToList();
	}

	/// <summary>
	/// Does this game type have any configurable settings? (Drives whether the lobby shows the panel.)
	/// </summary>
	public static bool Any( Type gameType ) => For( gameType ).Count > 0;

	/// <summary>
	/// Does this live game have any settings to show right now (after per-instance hiding)?
	/// </summary>
	public static bool Any( CardGame game ) => For( game ).Count > 0;

	private static GameSetting Build( PropertyDescription prop )
	{
		var pt = prop.PropertyType;

		SettingKind kind;
		IReadOnlyList<string> enumNames = Array.Empty<string>();

		if ( pt == typeof( bool ) )
		{
			kind = SettingKind.Bool;
		}
		else if ( pt.IsEnum )
		{
			kind = SettingKind.Enum;
			enumNames = pt.GetEnumNames();
		}
		else if ( IsNumeric( pt ) )
		{
			kind = SettingKind.Number;
		}
		else
		{
			return null; // unsupported type - skip rather than render a broken control
		}

		var display = prop.GetDisplayInfo();
		var range = prop.GetCustomAttribute<RangeAttribute>();
		var step = prop.GetCustomAttribute<StepAttribute>();

		return new GameSetting
		{
			Property = prop,
			Kind = kind,
			Label = string.IsNullOrWhiteSpace( display.Name ) ? prop.Name : display.Name,
			Description = display.Description ?? "",
			Group = display.Group ?? "",
			Min = range?.Min ?? 0f,
			Max = range?.Max ?? 0f,
			Step = step?.Step ?? 1f,
			EnumNames = enumNames,
		};
	}

	private static bool IsNumeric( Type t )
		=> t == typeof( int ) || t == typeof( long ) || t == typeof( short ) || t == typeof( byte )
		|| t == typeof( float ) || t == typeof( double );
}