Add console Minesweeper with a UI-free engine

Rules and board state live in Minesweeper.Core with no console calls, so the same board drives both the CLI and the tests.

Engine: seeded or random boards with classic presets; first click always safe (and its ring too when the board has room); cascading reveal that stops at numbers; flagging and chording; win by clearing every safe cell; FromMines() for deterministic layouts.

CLI: typed commands ("3 4", "f 2 5", "chord 6 7") plus optional arrow-key mode. Rows numbered, columns lettered, player input 1-based while engine coordinates stay 0-based, with CommandParser the only place that converts. ANSI colour when writing to a terminal, plain when redirected or NO_COLOR is set.

Tests: 186 covering flood-fill bounds, first-click safety, chording, win/loss, command parsing, session behaviour, and renderer alignment with colour on and off.

Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
openhands
2026-09-05 21:50:11 +00:00
commit fd270095df
25 changed files with 2932 additions and 0 deletions
@@ -0,0 +1,361 @@
using Minesweeper.Cli;
using Minesweeper.Core;
namespace Minesweeper.Core.Tests;
public class GameSessionTests
{
[Fact]
public void NewSession_StartsAtBeginnerWithCentredCursor()
{
var session = new GameSession();
Assert.Equal(GameDifficulty.Beginner, session.Difficulty);
Assert.Equal(9, session.Game.BoardSize.Rows);
Assert.Equal(10, session.Game.MineCount);
Assert.Equal(new CellLocation(4, 4), session.Cursor);
Assert.False(session.IsOver);
}
[Fact]
public void NewSession_UsesRequestedDifficulty()
{
var session = new GameSession(GameDifficulty.Expert);
Assert.Equal(16, session.Game.BoardSize.Rows);
Assert.Equal(30, session.Game.BoardSize.Columns);
Assert.Equal(99, session.Game.MineCount);
Assert.Equal(new CellLocation(8, 15), session.Cursor);
}
[Fact]
public void Apply_RevealByCoordinates_OpensTheChosenCell()
{
var session = SeededSession(seed: 5);
var target = SafeCell(session);
session.Apply(new Command.Reveal(target));
Assert.Equal(CellState.Revealed, session.Game.GetState(target));
Assert.Equal(target, session.Cursor);
}
[Fact]
public void Apply_RevealAtCursor_UsesCurrentCursor()
{
var session = SeededSession(seed: 5);
var target = SafeCell(session);
session.Apply(new Command.SetCursor(target));
session.Apply(new Command.RevealAtCursor());
Assert.Equal(CellState.Revealed, session.Game.GetState(target));
}
[Fact]
public void Apply_OffBoardCoordinate_ReportsWithoutThrowing()
{
var session = SeededSession(seed: 5);
var message = session.Apply(new Command.Reveal(new CellLocation(99, 99)));
Assert.NotNull(message);
Assert.Contains("off the board", message);
Assert.Equal(GameStatus.Ready, session.Game.Status);
}
[Fact]
public void Apply_FlagTogglesAndReportsMinesLeft()
{
var session = SeededSession(seed: 5);
var target = SafeCell(session);
Assert.Contains("Mines left: 9", session.Apply(new Command.Flag(target)));
Assert.Equal(CellState.Flagged, session.Game.GetState(target));
Assert.Contains("Mines left: 10", session.Apply(new Command.Flag(target)));
Assert.Equal(CellState.Hidden, session.Game.GetState(target));
}
[Fact]
public void Apply_FlagOnRevealedCell_IsExplained()
{
var session = SeededSession(seed: 5);
var target = SafeCell(session);
session.Apply(new Command.Reveal(target));
var message = session.Apply(new Command.Flag(target));
Assert.Contains("cannot be flagged", message);
}
[Fact]
public void Apply_ChordOnHiddenCell_IsExplained()
{
var session = SeededSession(seed: 5);
var hidden = HiddenCell(session);
var message = session.Apply(new Command.Chord(hidden));
Assert.Contains("already open", message);
}
[Fact]
public void Apply_ChordWithMismatchedFlags_IsExplained()
{
var session = SessionWithLayout(new BoardSize(3, 4, 3),
[new CellLocation(0, 0), new CellLocation(0, 3), new CellLocation(2, 1)]);
session.Apply(new Command.Reveal(new CellLocation(0, 1)));
var message = session.Apply(new Command.Chord(new CellLocation(0, 1)));
Assert.Contains("does not match", message);
Assert.False(session.IsOver);
}
[Theory]
[InlineData(0, 0, -1, 0, 8, 0)]
[InlineData(0, 0, 1, 0, 1, 0)]
[InlineData(0, 0, 0, -1, 0, 8)]
[InlineData(0, 0, 0, 1, 0, 1)]
[InlineData(8, 8, 1, 0, 0, 8)]
[InlineData(8, 8, 0, 1, 8, 0)]
public void Apply_MoveCursor_WrapsAroundEdges(int startRow, int startColumn, int rowDelta, int columnDelta,
int expectedRow, int expectedColumn)
{
var session = new GameSession();
session.Apply(new Command.SetCursor(new CellLocation(startRow, startColumn)));
session.Apply(new Command.MoveCursor(rowDelta, columnDelta));
Assert.Equal(new CellLocation(expectedRow, expectedColumn), session.Cursor);
}
[Fact]
public void Apply_SetCursorOffBoard_LeavesCursorUnchanged()
{
var session = new GameSession();
var message = session.Apply(new Command.SetCursor(new CellLocation(20, 20)));
Assert.Contains("off the board", message);
Assert.Equal(new CellLocation(4, 4), session.Cursor);
}
[Fact]
public void Apply_NewGame_ResetsBoardAndClock()
{
var session = SeededSession(seed: 5);
session.Apply(new Command.Reveal(SafeCell(session)));
var revealed = session.Game.RevealedCount;
var message = session.Apply(new Command.NewGame());
Assert.Equal("New board.", message);
Assert.Equal(0, session.Game.RevealedCount);
Assert.True(revealed > 0);
Assert.Equal(GameStatus.Ready, session.Game.Status);
}
[Fact]
public void Apply_ChangeDifficulty_SwitchesBoardSize()
{
var session = new GameSession();
var message = session.Apply(new Command.ChangeDifficulty(GameDifficulty.Expert));
Assert.Contains("30x16", message);
Assert.Equal(GameDifficulty.Expert, session.Difficulty);
Assert.Equal(99, session.Game.MineCount);
}
[Fact]
public void Apply_ChangeDifficulty_KeepsSeedForReproducibleBoards()
{
var first = new GameSession(seed: 42);
first.Apply(new Command.ChangeDifficulty(GameDifficulty.Intermediate));
var layout = first.Game.GetView();
var second = new GameSession(seed: 42);
second.Apply(new Command.ChangeDifficulty(GameDifficulty.Intermediate));
for (var row = 0; row < 16; row++)
{
for (var column = 0; column < 16; column++)
{
Assert.Equal(layout[row, column].IsMine, second.Game.GetView()[row, column].IsMine);
}
}
}
[Fact]
public void Apply_Help_ReturnsTheCommandList()
{
var session = new GameSession();
var message = session.Apply(new Command.ShowHelp());
Assert.Equal(GameSession.HelpText, message);
}
[Fact]
public void Apply_Unknown_EchoesWhatWasTyped()
{
var session = new GameSession();
var message = session.Apply(new Command.Unknown("xyzzy"));
Assert.Contains("xyzzy", message);
Assert.Contains("for help", message);
}
[Fact]
public void Apply_Noop_SaysNothing()
{
var session = new GameSession();
Assert.Null(session.Apply(new Command.Noop()));
Assert.Null(session.Apply(new Command.MoveCursor(0, 0)));
}
[Fact]
public void Apply_Quit_SignalsExit() =>
Assert.Null(new GameSession().Apply(new Command.Quit()));
[Fact]
public void AfterWin_FurtherMovesAreRefusedWithAGuide()
{
var session = SessionWithLayout(new BoardSize(3, 3, 1), [new CellLocation(2, 2)]);
session.Apply(new Command.Reveal(new CellLocation(0, 0)));
Assert.True(session.IsOver);
Assert.Contains("won", session.Apply(new Command.Reveal(new CellLocation(0, 1))));
Assert.Contains("new", session.Apply(new Command.Reveal(new CellLocation(0, 1))));
Assert.Contains("new", session.Apply(new Command.Flag(new CellLocation(0, 1))));
Assert.Contains("new", session.Apply(new Command.Chord(new CellLocation(0, 1))));
}
[Fact]
public void AfterLoss_FurtherMovesPointToNewGame()
{
var session = SessionWithLayout(new BoardSize(3, 3, 1), [new CellLocation(0, 0)]);
session.Apply(new Command.Reveal(new CellLocation(0, 0)));
Assert.True(session.IsOver);
Assert.Contains("new", session.Apply(new Command.Reveal(new CellLocation(2, 2))));
}
[Fact]
public void LosingThenStartingNewGame_ClearsGameOverState()
{
var session = SessionWithLayout(new BoardSize(3, 3, 1), [new CellLocation(0, 0)]);
session.Apply(new Command.Reveal(new CellLocation(0, 0)));
session.Apply(new Command.NewGame());
Assert.False(session.IsOver);
Assert.Null(session.Game.LostOn);
Assert.Equal(CellState.Hidden, session.Game.GetState(new CellLocation(0, 0)));
}
[Fact]
public void Clock_KeepsTimeAcrossReveals()
{
var session = SeededSession(seed: 5);
session.Apply(new Command.Reveal(SafeCell(session)));
Assert.True(session.Elapsed >= TimeSpan.Zero);
}
[Fact]
public void WinningMove_ReportsTheTime()
{
var session = SessionWithLayout(new BoardSize(3, 3, 1), [new CellLocation(2, 2)]);
var message = session.Apply(new Command.Reveal(new CellLocation(0, 0)));
Assert.Contains("Cleared in", message);
Assert.False(session.IsTiming);
}
[Fact]
public void FirstReveal_StartsTheClock()
{
var session = SeededSession(seed: 5);
Assert.False(session.IsTiming);
session.Apply(new Command.Reveal(SafeCell(session)));
Assert.True(session.IsTiming);
}
[Fact]
public void LosingMove_StopsTheClock()
{
var session = SessionWithLayout(new BoardSize(3, 3, 1), [new CellLocation(0, 0)]);
session.Apply(new Command.Reveal(new CellLocation(0, 0)));
Assert.False(session.IsTiming);
}
[Fact]
public void RevealThatOpensACascade_ReportsHowManyCellsOpened()
{
var session = SessionWithLayout(new BoardSize(5, 5, 4),
[new CellLocation(0, 2), new CellLocation(1, 2), new CellLocation(2, 2), new CellLocation(3, 2)]);
var message = session.Apply(new Command.Reveal(new CellLocation(0, 0)));
Assert.Contains("Opened 10 cells", message);
}
private static GameSession SeededSession(int seed) => new(GameDifficulty.Beginner, seed);
private static GameSession SessionWithLayout(BoardSize size, IReadOnlyList<CellLocation> mines)
{
var session = new GameSession();
session.LoadGame(MinesweeperGame.FromMines(size, mines));
return session;
}
/// <summary>A cell that opens safely on a fresh board of the session's seed and leaves the game
/// still in play, so tests can make a first move without risking an instant win or loss.</summary>
private static CellLocation SafeCell(GameSession session)
{
for (var row = 0; row < session.Game.BoardSize.Rows; row++)
{
for (var column = 0; column < session.Game.BoardSize.Columns; column++)
{
var candidate = new CellLocation(row, column);
var trial = new MinesweeperGame(session.Game.BoardSize, session.Seed);
trial.Reveal(candidate);
if (!trial.IsMine(candidate) && trial.Status == GameStatus.Playing)
{
return candidate;
}
}
}
throw new InvalidOperationException("No safe opening cell found.");
}
private static CellLocation HiddenCell(GameSession session)
{
for (var row = 0; row < session.Game.BoardSize.Rows; row++)
{
for (var column = 0; column < session.Game.BoardSize.Columns; column++)
{
var candidate = new CellLocation(row, column);
if (session.Game.GetState(candidate) == CellState.Hidden)
{
return candidate;
}
}
}
throw new InvalidOperationException("No hidden cell found.");
}
private static int SeedOf(GameSession session) => session.Seed ?? 0;
}