Scoring/HighScoreData.cs

Data class that stores persistent high score info for the game: version, best score, best length, runs played and total apples eaten. It validates and updates totals via Submit and normalises values with Clamp.

namespace Coilgarden;

/// <summary>
/// What survives between runs. Deliberately separate from <see cref="SnakeGame"/>: that
/// owns one run's score, this owns the record across all of them, and conflating the two is
/// how a restart ends up clearing a personal best.
/// <para>
/// A plain serialisable class with no engine dependency, so the rules about what counts as
/// a new best are testable headlessly. <see cref="HighScoreStore"/> is the only part that
/// touches the filesystem.
/// </para>
/// </summary>
public sealed class HighScoreData
{
	/// <summary>
	/// Bumped when the shape of this file changes in a way older builds cannot read. Kept
	/// so a future change has somewhere to hang a migration rather than having to refuse
	/// the file.
	/// </summary>
	public int Version { get; set; } = CurrentVersion;

	public const int CurrentVersion = 1;

	public int BestScore { get; set; }

	public int BestLength { get; set; }

	public int RunsPlayed { get; set; }

	public int ApplesEatenTotal { get; set; }

	/// <summary>
	/// Files a finished run and reports whether it beat the record.
	/// <para>
	/// Score and length are tracked independently because they can genuinely come apart
	/// once scoring stops being flat - the longest snake need not be the highest score, and
	/// a player who cares about one should not have the other quietly overwrite it.
	/// </para>
	/// <para>
	/// Strictly greater than, not greater-or-equal: matching your best is not beating it,
	/// and reporting it as a new record every time would make the celebration meaningless.
	/// </para>
	/// </summary>
	public bool Submit( int score, int length, int applesEaten )
	{
		RunsPlayed++;
		ApplesEatenTotal += Math.Max( 0, applesEaten );

		if ( length > BestLength ) BestLength = length;

		if ( score <= BestScore ) return false;

		BestScore = score;
		return true;
	}

	/// <summary>
	/// Forces every value into a range this build considers possible.
	/// <para>
	/// The file is on disk, so it can be hand-edited, truncated, or written by a different
	/// build. A negative best would display as a negative record; nothing else in the game
	/// should have to defend against that.
	/// </para>
	/// </summary>
	public void Clamp()
	{
		Version = CurrentVersion;
		BestScore = Math.Max( 0, BestScore );
		BestLength = Math.Max( 0, BestLength );
		RunsPlayed = Math.Max( 0, RunsPlayed );
		ApplesEatenTotal = Math.Max( 0, ApplesEatenTotal );
	}
}