Framework/BoardGame.Undo.cs

Undo/redo subsystem for a board game. It takes full-board snapshots (mementos) of every card's pile, index and face state and the game state, and restores those snapshots to implement undo, redo, push, and clearing history.

using System.Collections.Generic;
using System.Linq;

namespace CardGames;

/// <summary>
/// Undo/redo for board games. A full-board snapshot ("memento") is pushed before each action; reversing
/// any action is just restoring the previous board, so we don't track what each action did. Cheap for
/// ~52 cards, and restoring re-targets cards through the normal layout so it snaps cleanly into place.
/// </summary>
public abstract partial class BoardGame
{
	/// <summary>
	/// One card's place + face state within a snapshot.
	/// </summary>
	private readonly record struct CardSnapshot( CardObject Card, Pile Pile, int Index, bool FaceUp, int FaceIndex );

	/// <summary>
	/// A whole-board snapshot: every card's pile/order/face, plus the game phase.
	/// </summary>
	private sealed class BoardMemento
	{
		public List<CardSnapshot> Cards { get; init; }
		public GameState State { get; init; }
		public string StatusText { get; init; }
	}

	private readonly Stack<BoardMemento> _undo = new();
	private readonly Stack<BoardMemento> _redo = new();

	public bool CanUndo => _undo.Count > 0;
	public bool CanRedo => _redo.Count > 0;

	private BoardMemento Snapshot()
	{
		var cards = new List<CardSnapshot>();
		foreach ( var pile in Scene.GetAllComponents<Pile>() )
			for ( int i = 0; i < pile.Cards.Count; i++ )
			{
				var c = pile.Cards[i];
				cards.Add( new CardSnapshot( c, pile, i, c.FaceUp, c.FaceIndex ) );
			}

		return new BoardMemento { Cards = cards, State = State, StatusText = StatusText };
	}

	private void Restore( BoardMemento m )
	{
		// Rebuild every pile from the snapshot, then snap the cards home.
		var piles = Scene.GetAllComponents<Pile>().ToList();
		foreach ( var pile in piles )
			pile.Cards.Clear();

		foreach ( var snap in m.Cards.OrderBy( s => s.Index ) )
		{
			if ( !snap.Card.IsValid() || !snap.Pile.IsValid() ) continue;
			snap.Pile.Add( snap.Card );     // re-sets the synced Pile / StackIndex
			snap.Card.FaceUp = snap.FaceUp;
			snap.Card.FaceIndex = snap.FaceIndex;
		}

		foreach ( var pile in piles )
			pile.Arrange( animate: false ); // undo/redo snaps instantly - no glide or flip

		State = m.State;
		StatusText = m.StatusText;
	}

	/// <summary>
	/// Host: snapshot the board before an action so it can be undone. Forks the redo branch. No-ops when
	/// the game opted out via <see cref="SupportsUndo"/>.
	/// </summary>
	protected void PushUndo()
	{
		if ( !SupportsUndo ) return;
		_undo.Push( Snapshot() );
		_redo.Clear();
	}

	/// <summary>
	/// Host: take back the last action.
	/// </summary>
	protected void Undo()
	{
		if ( _undo.Count == 0 ) return;
		_redo.Push( Snapshot() );
		Restore( _undo.Pop() );
	}

	/// <summary>
	/// Host: re-apply the last undone action.
	/// </summary>
	protected void Redo()
	{
		if ( _redo.Count == 0 ) return;
		_undo.Push( Snapshot() );
		Restore( _redo.Pop() );
	}

	/// <summary>
	/// Host: drop all undo/redo history (e.g. on a fresh deal).
	/// </summary>
	protected void ClearHistory()
	{
		_undo.Clear();
		_redo.Clear();
	}
}