Core/GameRules.cs

Immutable record struct that holds all per-run game configuration for the snake game, including grid size, start length/direction, buffered turns and apple points. It provides a Default from GameConfig, a Clamped() method that forces values into safe ranges, and a computed CellCount.

File Access
namespace Coilgarden;

/// <summary>
/// Every value that changes how a run behaves, as one immutable bundle fixed for the
/// lifetime of that run.
/// <para>
/// This exists so the rules have no hidden dependency on static configuration. Before it,
/// <see cref="SnakeGame"/> took its arena size as arguments but read start length, buffer
/// size and apple value straight out of <see cref="GameConfig"/> - which meant a test could
/// not vary them, and half the game's configurability was compile-time only.
/// </para>
/// <para>
/// Immutable per run on purpose: a rule that can change mid-run is a rule a restart has to
/// remember to put back. Changing the rules means starting a run with different ones.
/// </para>
/// </summary>
public readonly record struct GameRules(
	int Width,
	int Height,
	int StartLength,
	Direction StartDirection,
	int MaxBufferedTurns,
	int ApplePoints )
{
	/// <summary>The designed values, from <see cref="GameConfig"/>.</summary>
	public static GameRules Default => new(
		GameConfig.GridWidth,
		GameConfig.GridHeight,
		GameConfig.StartLength,
		GameConfig.StartDirection,
		GameConfig.MaxBufferedTurns,
		GameConfig.ApplePoints );

	/// <summary>
	/// The same rules with every value forced into a range a run can actually be played
	/// with.
	/// <para>
	/// The point is that nothing downstream has to defend itself. These values arrive from
	/// editor sliders and one day from a settings file, so a zero or a negative is a matter
	/// of time - and an arena of width 0 would otherwise reach <see cref="Arena"/>, which
	/// throws, and take the whole scene down rather than starting a slightly odd game.
	/// </para>
	/// </summary>
	public GameRules Clamped()
	{
		var width = Math.Clamp( Width, GameConfig.MinGridSize, GameConfig.MaxGridSize );
		var height = Math.Clamp( Height, GameConfig.MinGridSize, GameConfig.MaxGridSize );

		// The body is laid out behind a head sitting at the centre, so the limit is the
		// distance from the centre to the wall it runs towards. Asking the arena keeps this
		// the same arithmetic the snake is actually built with.
		var longest = Math.Max( 1, Arena.MaxStartLength( width, height, StartDirection ) );

		return this with
		{
			Width = width,
			Height = height,
			StartLength = Math.Clamp( StartLength, 1, longest ),
			MaxBufferedTurns = Math.Clamp( MaxBufferedTurns, 1, GameConfig.MaxBufferedTurnsLimit ),
			ApplePoints = Math.Max( 0, ApplePoints )
		};
	}

	public int CellCount => Width * Height;
}