Snake/SnakeGame.cs

Core game logic for a single run of Snake. It manages the arena, snake, apple spawner, score, ticks, and stepping the simulation one logical tick at a time, plus restarting and turn input.

namespace Coilgarden;

/// <summary>
/// One run, as pure rules: the arena, the snake, the apple and the score, with a single
/// <see cref="Step"/> that advances all of them by one logical tick.
/// <para>
/// This is the whole game as far as correctness is concerned. It has no engine dependency
/// at all, which is what lets the rules be exercised thousands of times in a headless test
/// run, and it is the seam that stops the feel layer from ever becoming load-bearing:
/// <see cref="GameSession"/> decides <em>when</em> to step, and the views decide how it
/// looks, but neither can change what happens.
/// </para>
/// </summary>
public sealed class SnakeGame
{
	private readonly AppleSpawner spawner;

	public SnakeGame( GameRules rules, int seed )
	{
		// Clamped here rather than trusted, so there is no way to construct a run whose
		// rules cannot be played - whatever the caller read them from.
		Rules = rules.Clamped();

		Arena = new Arena( Rules.Width, Rules.Height );
		Snake = new Snake( Arena, Rules.StartLength, Rules.StartDirection, Rules.MaxBufferedTurns );
		spawner = new AppleSpawner( Arena, seed );

		Restart();
	}

	/// <summary>Convenience for the common case and for tests that do not vary the rules.</summary>
	public SnakeGame( int seed ) : this( GameRules.Default, seed )
	{
	}

	/// <summary>The rules this run is being played under. Fixed for its lifetime.</summary>
	public GameRules Rules { get; }

	public Arena Arena { get; }

	public Snake Snake { get; }

	/// <summary>Where the apple is, or null when the arena had no room for one.</summary>
	public GridPos? Apple { get; private set; }

	public int Score { get; private set; }

	public int ApplesEaten { get; private set; }

	/// <summary>Ticks survived this run. The difficulty curve and the HUD both read it.</summary>
	public int Ticks { get; private set; }

	/// <summary>Set once the run has ended, and the reason why.</summary>
	public StepOutcome? EndedBy { get; private set; }

	public bool IsOver => EndedBy.HasValue;

	/// <summary>
	/// Returns every field to its starting value. Written as a single method that touches
	/// all of them so a field added later has one obvious place to be reset, rather than
	/// being forgotten in one of several branches.
	/// <para>
	/// The apple RNG is deliberately <em>not</em> reseeded: consecutive runs in one sitting
	/// should not open with the same apple in the same place.
	/// </para>
	/// </summary>
	public void Restart()
	{
		Snake.Reset( Rules.StartLength, Rules.StartDirection );

		Score = 0;
		ApplesEaten = 0;
		Ticks = 0;
		EndedBy = null;
		Apple = spawner.Pick( Snake );
	}

	/// <summary>Records a turn for an upcoming tick. Ignored once the run is over.</summary>
	public bool TryTurn( Direction direction ) => !IsOver && Snake.TryTurn( direction );

	/// <summary>
	/// Would heading this way on the next tick keep the run alive?
	/// <para>
	/// Answers from the same rule <see cref="Step"/> resolves with rather than a second copy
	/// of it, so the two cannot drift apart. Used by the test and self-test bots to play the
	/// game through the public API, and it is the query a hint or accessibility cue would
	/// be built on.
	/// </para>
	/// <para>
	/// Note this asks about a <em>heading</em>, not about whether the turn is legal -
	/// <see cref="TryTurn"/> answers that.
	/// </para>
	/// </summary>
	public bool WouldSurvive( Direction direction )
	{
		if ( IsOver ) return false;

		var target = Snake.Head + direction.Delta();

		if ( !Arena.Contains( target ) ) return false;

		var growing = Apple.HasValue && Apple.Value == target;

		return !Snake.CollidesWithBody( target, growing );
	}

	/// <summary>
	/// Advances one logical tick. Does nothing once the run has ended, so a session that
	/// keeps ticking through a death animation cannot accidentally step past it.
	/// </summary>
	public StepResult Step()
	{
		if ( IsOver )
		{
			return new StepResult( EndedBy.Value, Snake.Head, Snake.Head, Snake.Direction, false, 0 );
		}

		var result = Snake.Step( Apple );

		Ticks++;

		if ( result.Grew )
		{
			ApplesEaten++;

			var points = ScoreRules.ApplePoints( ApplesEaten, Rules );
			Score += points;

			result = result with { ScoreGained = points };

			// A null here means the snake now covers every cell, which the step already
			// reported as FilledArena. Leaving Apple null is correct: there is nowhere to
			// put one, and the run is over anyway.
			Apple = spawner.Pick( Snake );
		}

		if ( result.IsTerminal )
		{
			EndedBy = result.Outcome;
		}

		return result;
	}
}