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
+10
View File
@@ -0,0 +1,10 @@
bin/
obj/
*.user
.vs/
.vscode/
.idea/
TestResults/
*.coverage
*.coveragexml
artifacts/
+9
View File
@@ -0,0 +1,9 @@
<Solution>
<Folder Name="/src/">
<Project Path="src/Minesweeper.Cli/Minesweeper.Cli.csproj" />
<Project Path="src/Minesweeper.Core/Minesweeper.Core.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/Minesweeper.Core.Tests/Minesweeper.Core.Tests.csproj" />
</Folder>
</Solution>
+81
View File
@@ -0,0 +1,81 @@
# Minesweeper
A console Minesweeper for the terminal, built on .NET 10. The rules live in a UI-free engine, so
the same board can be driven by the console client or by tests.
```
9x9 mines left 10 flags 0/10 time 12s
Reveal every cell that is not a mine.
A B C D E F G H I
1 # # 1 · · 1 # # #
2 1 1 1 · · 1 1 2 1
3 · · · · · · · · ·
4 2 2 1 · · · · · ·
5 # # 1 · · · · · ·
6 # # 2 1 2 1 1 · ·
7 # # # # # # 2 2 2
8 # # # # # # # # #
9 # # # # # # # # #
type "<row> <col>" to reveal, f <row> <col> to flag, ? for help, q to quit
>
```
## Play it
```bash
dotnet run --project src/Minesweeper.Cli
```
| Option | Meaning |
| --- | --- |
| `-d, --difficulty <beginner\|intermediate\|expert>` | board preset (default: beginner) |
| `-b`, `-i`, `-e` | shorthand for the three presets |
| `-k, --keys` | arrow-key play instead of typed commands |
| `--seed <n>` | deterministic mines, for reproducible games |
| `-h, --help` | usage, command list, and legend |
Beginner is 9x9 with 10 mines, intermediate 16x16 with 40, expert 30x16 with 99.
## Commands
Rows are numbered `1..N` down the left, columns are lettered `A..Z` (then `AA..`) across the top.
| Command | Does |
| --- | --- |
| `<row> <col>` or `reveal <row> <col>` | open a cell (`r`, `open` are aliases) |
| `flag <row> <col>` | place or remove a flag (`f`, `mark`) |
| `chord <row> <col>` | open the neighbours of a number once its flags match (`c`) |
| `go <row> <col>` | move the cursor without opening anything (`goto`) |
| `difficulty <name>` | switch preset, by name or `1`/`2`/`3` |
| `new` | restart at the current difficulty |
| `help` / `?` | list the commands |
| `quit` / `q` | leave |
In `--keys` mode: arrows move, `Enter`/`Space` reveals, `F` flags, `C` chords, `1`/`2`/`3` change
difficulty, `N` starts a new board, `Q` or `Esc` quits.
Colours are on when writing to a terminal and off when the output is redirected; set `NO_COLOR`
to force them off. The process exits `0` after a win, `1` otherwise.
## Rules
- The first click is always safe. On boards with room, the eight cells around it are safe too.
- Opening a cell with no adjacent mines cascades outward until it hits numbers.
- Chording a number whose surrounding flag count matches opens the rest of its neighbours — so a
mistaken flag costs you the game.
- You win by opening every cell that is not a mine; remaining mines are flagged for you.
## Layout
```
src/Minesweeper.Core rules and board state, no console calls
src/Minesweeper.Cli command parsing, ANSI rendering, the game loop
tests/ xUnit tests for the engine, parser, session, and renderer
```
```bash
dotnet build # compiles all three projects
dotnet test # runs the suite
```
+52
View File
@@ -0,0 +1,52 @@
namespace Minesweeper.Cli;
/// <summary>ANSI styling with a no-color fallback, so the board stays readable when output is
/// redirected or <c>NO_COLOR</c> is set.</summary>
public sealed class AnsiPalette(bool enabled)
{
/// <summary>Classic Minesweeper palette: 1 blue, 2 green, 3 red, 4 dark blue, 5 maroon, 6 teal, 7 black, 8 gray.</summary>
private static readonly int[] NumberColors = [0, 12, 10, 9, 4, 13, 14, 7, 8];
public static AnsiPalette Detect()
{
var noColor = Environment.GetEnvironmentVariable("NO_COLOR");
return new AnsiPalette(!Console.IsOutputRedirected && string.IsNullOrEmpty(noColor));
}
public string Number(int count) => Number(count, count.ToString());
/// <summary>Colours <paramref name="display"/> using the classic colour for <paramref name="count"/>.
/// Callers pass an already-padded token so alignment survives the escape codes.</summary>
public string Number(int count, string display) =>
count is < 1 or > 8 ? Plain(display) : Color(display, NumberColors[count]);
public string Flag(string text) => Color(text, 11);
public string Mine(string text) => Color(text, 9, bold: true);
public string WrongFlag(string text) => Color(text, 9);
public string ExplodedMine(string text) => Color(text, 7, background: 1, bold: true);
public string Hidden(string text) => Dim(text);
public string Cursor(string text) => Color(text, 0, background: 6, bold: true);
public string Bold(string text) => enabled ? $"\e[1m{text}\e[0m" : text;
public string Dim(string text) => enabled ? $"\e[2m{text}\e[0m" : text;
public string Plain(string text) => text;
private string Color(string text, int code, int? background = null, bool bold = false)
{
if (!enabled)
{
return text;
}
var prefix = bold ? "1;" : string.Empty;
var suffix = background is null ? string.Empty : $";{background}";
return $"\e[{prefix}{code}{suffix}m{text}\e[0m";
}
}
+106
View File
@@ -0,0 +1,106 @@
using System.Text;
using Minesweeper.Core;
namespace Minesweeper.Cli;
/// <summary>Draws a <see cref="MinesweeperGame"/> as monospace text: one header line, a status
/// line, then a grid of fixed three-character cells. Free of console calls so the output can be
/// asserted directly in tests.</summary>
public sealed class BoardRenderer(AnsiPalette palette)
{
public const char HiddenGlyph = '#';
public const char EmptyGlyph = '·';
public const char FlagGlyph = 'P';
public const char MineGlyph = '*';
public const char WrongFlagGlyph = 'X';
public AnsiPalette Palette { get; } = palette;
public string RenderHeader(MinesweeperGame game, TimeSpan elapsed)
{
var size = game.BoardSize;
var minesLeft = Math.Max(0, size.Mines - game.FlagsUsed);
var status = game.Status switch
{
GameStatus.Won => Palette.Bold("Board cleared — you win!"),
GameStatus.Lost => Palette.WrongFlag("Boom! You hit a mine.") +
$" It was on row {game.LostOn!.Value.Row + 1}, column {ColumnName(game.LostOn.Value.Column)}.",
GameStatus.Ready => "First click is always safe.",
_ => "Reveal every cell that is not a mine.",
};
return
$"{Palette.Bold($"{size.Columns}x{size.Rows}")} " +
$"mines left {Palette.Mine(minesLeft.ToString())} " +
$"flags {game.FlagsUsed}/{size.Mines} " +
$"time {(int)elapsed.TotalSeconds}s\n" +
status;
}
/// <summary>Renders the grid with lettered columns and numbered rows, highlighting
/// <paramref name="cursor"/> when supplied.</summary>
public string RenderBoard(MinesweeperGame game, CellLocation? cursor = null)
{
var view = game.GetView();
var rows = view.GetLength(0);
var columns = view.GetLength(1);
var builder = new StringBuilder();
builder.Append(new string(' ', GutterWidth));
for (var column = 0; column < columns; column++)
{
builder.Append(Palette.Dim(ColumnName(column).PadLeft(CellWidth)));
}
builder.Append('\n');
for (var row = 0; row < rows; row++)
{
builder.Append(Palette.Dim($"{row + 1}".PadLeft(GutterWidth)));
for (var column = 0; column < columns; column++)
{
var location = new CellLocation(row, column);
var glyph = GlyphFor(game, view[row, column]);
var isCursor = cursor == location;
// Pad before colouring: escape codes inflate the string, which would break alignment.
builder.Append(isCursor
? Palette.Cursor($"[{glyph}]")
: StyleFor(game, view[row, column], glyph.ToString().PadLeft(CellWidth)));
}
builder.Append('\n');
}
return builder.ToString();
}
/// <summary>Column labels are letters so they never look like a typed row number.</summary>
public static string ColumnName(int index) => index < 26
? ((char)('A' + index)).ToString()
: $"{(char)('A' + ((index / 26) - 1))}{(char)('A' + (index % 26))}";
/// <summary>The single character a cell displays, without any colouring.</summary>
public static char GlyphFor(MinesweeperGame game, CellView cell) => cell.State switch
{
CellState.Flagged => FlagGlyph,
CellState.WrongFlag => WrongFlagGlyph,
CellState.Revealed when cell.IsMine => MineGlyph,
CellState.Revealed when cell.AdjacentMines > 0 => (char)('0' + cell.AdjacentMines),
CellState.Revealed => EmptyGlyph,
_ => HiddenGlyph,
};
private string StyleFor(MinesweeperGame game, CellView cell, string paddedGlyph) => cell.State switch
{
CellState.Flagged => Palette.Flag(paddedGlyph),
CellState.WrongFlag => Palette.WrongFlag(paddedGlyph),
CellState.Revealed when cell.IsMine && game.LostOn == cell.Location => Palette.ExplodedMine(paddedGlyph),
CellState.Revealed when cell.IsMine => Palette.Mine(paddedGlyph),
CellState.Revealed when cell.AdjacentMines > 0 => Palette.Number(cell.AdjacentMines, paddedGlyph),
_ => Palette.Hidden(paddedGlyph),
};
private const int CellWidth = 3;
private const int GutterWidth = 3;
}
+103
View File
@@ -0,0 +1,103 @@
using System.Diagnostics.CodeAnalysis;
using Minesweeper.Core;
namespace Minesweeper.Cli;
/// <summary>Startup options, parsed from the process arguments.</summary>
public sealed record CommandLine(GameDifficulty Difficulty, int? Seed, bool UseKeys, bool ShowHelp)
{
public const string Usage = """
Minesweeper - console edition
Usage: Minesweeper.Cli [options]
Options:
-d, --difficulty <beginner|intermediate|expert> board preset (default: beginner)
-b, -i, -e shorthand for the three presets
-k, --keys arrow-key play instead of typed commands
--seed <n> deterministic mines, for reproducible games
-h, --help show this help
""";
public static CommandLine Default { get; } = new(GameDifficulty.Beginner, null, false, false);
public static bool TryParse(string[] args, [NotNullWhen(true)] out CommandLine? options, out string error)
{
var difficulty = GameDifficulty.Beginner;
int? seed = null;
var useKeys = false;
var showHelp = false;
error = string.Empty;
for (var i = 0; i < args.Length; i++)
{
switch (args[i])
{
case "-h" or "--help":
showHelp = true;
break;
case "-k" or "--keys":
useKeys = true;
break;
case "-b" or "--beginner":
difficulty = GameDifficulty.Beginner;
break;
case "-i" or "--intermediate":
difficulty = GameDifficulty.Intermediate;
break;
case "-e" or "--expert":
difficulty = GameDifficulty.Expert;
break;
case "-d" or "--difficulty":
if (!TryDifficulty(ReadValue(args, ref i), out difficulty, out error))
{
options = null;
return false;
}
break;
case "--seed":
var value = ReadValue(args, ref i);
if (value is null || !int.TryParse(value, out var parsed))
{
error = "--seed needs an integer.";
options = null;
return false;
}
seed = parsed;
break;
default:
error = $"Unknown option: {args[i]}";
options = null;
return false;
}
}
options = new CommandLine(difficulty, seed, useKeys, showHelp);
return true;
}
private static bool TryDifficulty(string? value, out GameDifficulty difficulty, out string error)
{
error = $"--difficulty needs beginner, intermediate or expert (got \"{value}\").";
switch (value?.ToLowerInvariant())
{
case "1" or "b" or "beginner" or "easy":
difficulty = GameDifficulty.Beginner;
return true;
case "2" or "i" or "intermediate" or "medium":
difficulty = GameDifficulty.Intermediate;
return true;
case "3" or "e" or "expert" or "hard":
difficulty = GameDifficulty.Expert;
return true;
default:
difficulty = GameDifficulty.Beginner;
return false;
}
}
private static string? ReadValue(string[] args, ref int index) =>
index + 1 < args.Length ? args[++index] : null;
}
+94
View File
@@ -0,0 +1,94 @@
using System.Globalization;
using Minesweeper.Core;
namespace Minesweeper.Cli;
/// <summary>Turns typed text and raw keystrokes into <see cref="Command"/> instances. This is the
/// only place that translates the one-based coordinates a player sees into engine coordinates.</summary>
public static class CommandParser
{
/// <summary>Parses a typed line. Coordinates are one-based, e.g. <c>reveal 3 4</c>.</summary>
public static Command Parse(string input)
{
var text = input.Trim();
if (text.Length == 0)
{
return new Command.Noop();
}
var parts = text.Split([' ', ',', ';', '\t'], StringSplitOptions.RemoveEmptyEntries);
var verb = parts[0].ToLowerInvariant();
switch (verb)
{
case "q" or "quit" or "exit":
return new Command.Quit();
case "n" or "new":
return new Command.NewGame();
case "h" or "help" or "?":
return new Command.ShowHelp();
case "d" or "difficulty":
return ParseDifficulty(parts, text);
case "r" or "reveal" or "open":
return ParseCell(parts, 1, cell => new Command.Reveal(cell), text);
case "f" or "flag" or "mark":
return ParseCell(parts, 1, cell => new Command.Flag(cell), text);
case "c" or "chord":
return ParseCell(parts, 1, cell => new Command.Chord(cell), text);
case "go" or "goto":
return ParseCell(parts, 1, cell => new Command.SetCursor(cell), text);
}
// A bare "3 4" reveals that cell, since revealing is by far the most common action.
return ParseCell(parts, 0, cell => new Command.Reveal(cell), text);
}
/// <summary>Maps a keystroke from keyboard-driven mode onto a command.</summary>
public static Command FromKey(ConsoleKeyInfo key) => key.Key switch
{
ConsoleKey.UpArrow => new Command.MoveCursor(-1, 0),
ConsoleKey.DownArrow => new Command.MoveCursor(1, 0),
ConsoleKey.LeftArrow => new Command.MoveCursor(0, -1),
ConsoleKey.RightArrow => new Command.MoveCursor(0, 1),
ConsoleKey.Enter or ConsoleKey.Spacebar => new Command.RevealAtCursor(),
ConsoleKey.F => new Command.FlagAtCursor(),
ConsoleKey.C => new Command.ChordAtCursor(),
ConsoleKey.N => new Command.NewGame(),
ConsoleKey.D1 => new Command.ChangeDifficulty(GameDifficulty.Beginner),
ConsoleKey.D2 => new Command.ChangeDifficulty(GameDifficulty.Intermediate),
ConsoleKey.D3 => new Command.ChangeDifficulty(GameDifficulty.Expert),
ConsoleKey.Escape or ConsoleKey.Q => new Command.Quit(),
_ => new Command.Noop(),
};
private static Command ParseDifficulty(string[] parts, string text)
{
if (parts.Length < 2)
{
return new Command.ShowHelp();
}
return parts[1].ToLowerInvariant() switch
{
"1" or "b" or "beginner" or "easy" => new Command.ChangeDifficulty(GameDifficulty.Beginner),
"2" or "i" or "intermediate" or "medium" => new Command.ChangeDifficulty(GameDifficulty.Intermediate),
"3" or "e" or "expert" or "hard" => new Command.ChangeDifficulty(GameDifficulty.Expert),
_ => new Command.Unknown(text),
};
}
private static Command ParseCell(string[] parts, int offset, Func<CellLocation, Command> factory, string text)
{
if (parts.Length - offset < 2 ||
!TryParseCoordinate(parts[offset], out var row) ||
!TryParseCoordinate(parts[offset + 1], out var column))
{
return new Command.Unknown(text);
}
return factory(new CellLocation(row - 1, column - 1));
}
private static bool TryParseCoordinate(string token, out int value) =>
int.TryParse(token, NumberStyles.None, CultureInfo.InvariantCulture, out value) && value > 0;
}
+40
View File
@@ -0,0 +1,40 @@
using Minesweeper.Core;
namespace Minesweeper.Cli;
/// <summary>A player intent, produced either by a keystroke or a typed command. All coordinates
/// carried here are zero-based engine coordinates; <see cref="CommandParser"/> is the single place
/// that converts the one-based numbers a player types.</summary>
public abstract record Command
{
public sealed record Reveal(CellLocation Cell) : Command;
public sealed record Flag(CellLocation Cell) : Command;
public sealed record Chord(CellLocation Cell) : Command;
/// <summary>Keyboard-driven variants that act on wherever the cursor currently sits.</summary>
public sealed record RevealAtCursor : Command;
public sealed record FlagAtCursor : Command;
public sealed record ChordAtCursor : Command;
public sealed record MoveCursor(int RowDelta, int ColumnDelta) : Command;
public sealed record SetCursor(CellLocation Cell) : Command;
public sealed record ChangeDifficulty(GameDifficulty Difficulty) : Command;
public sealed record NewGame : Command;
public sealed record ShowHelp : Command;
public sealed record Quit : Command;
/// <summary>Input that means nothing, such as a bare Enter or an unmapped key.</summary>
public sealed record Noop : Command;
/// <summary>Unrecognised input; the text is echoed back to the player.</summary>
public sealed record Unknown(string Input) : Command;
}
+217
View File
@@ -0,0 +1,217 @@
using System.Diagnostics;
using Minesweeper.Core;
namespace Minesweeper.Cli;
/// <summary>Owns the current game plus the UI-only state around it (cursor, clock, difficulty),
/// and applies <see cref="Command"/> values to it. All console I/O stays outside this class.</summary>
public sealed class GameSession
{
private readonly Stopwatch _clock = new();
public GameSession(GameDifficulty difficulty = GameDifficulty.Beginner, int? seed = null)
{
Difficulty = difficulty;
Seed = seed;
Game = NewGame(difficulty, seed);
Cursor = new CellLocation(Game.BoardSize.Rows / 2, Game.BoardSize.Columns / 2);
}
public MinesweeperGame Game { get; private set; }
public GameDifficulty Difficulty { get; private set; }
public int? Seed { get; }
public CellLocation Cursor { get; private set; }
public TimeSpan Elapsed => _clock.Elapsed;
public bool IsTiming => _clock.IsRunning;
public bool IsOver => Game.Status is GameStatus.Won or GameStatus.Lost;
/// <summary>Swaps in a prepared board, e.g. a hand-authored puzzle.</summary>
public void LoadGame(MinesweeperGame game)
{
Game = game;
Cursor = new CellLocation(game.BoardSize.Rows / 2, game.BoardSize.Columns / 2);
_clock.Reset();
}
/// <summary>Applies a command and returns a line to show the player, or <see langword="null"/>.</summary>
public string? Apply(Command command)
{
switch (command)
{
case Command.Quit:
return null;
case Command.ShowHelp:
return HelpText;
case Command.NewGame:
Reset(Difficulty, Seed);
return "New board.";
case Command.ChangeDifficulty change:
Reset(change.Difficulty, Seed);
return $"{change.Difficulty} board: {Game.BoardSize.Columns}x{Game.BoardSize.Rows} with {Game.BoardSize.Mines} mines.";
case Command.MoveCursor move:
MoveCursor(move.RowDelta, move.ColumnDelta);
return null;
case Command.SetCursor set:
return SetCursor(set.Cell);
case Command.Reveal reveal:
return Reveal(reveal.Cell);
case Command.RevealAtCursor:
return Reveal(Cursor);
case Command.Flag flag:
return ToggleFlag(flag.Cell);
case Command.FlagAtCursor:
return ToggleFlag(Cursor);
case Command.Chord chord:
return Chord(chord.Cell);
case Command.ChordAtCursor:
return Chord(Cursor);
case Command.Unknown unknown:
return $"I don't understand \"{unknown.Input}\". Type ? for help.";
case Command.Noop:
return null;
default:
throw new ArgumentOutOfRangeException(nameof(command), command, "Unhandled command.");
}
}
public const string HelpText = """
Commands
reveal <r> <c> open a cell (aliases: r, open — or just "<r> <c>")
flag <r> <c> place or remove a flag (aliases: f, mark)
chord <r> <c> open neighbours once flags match the number (alias: c)
go <r> <c> move the cursor without opening anything (alias: goto)
difficulty <name> beginner | intermediate | expert, or 1, 2, 3
new restart at the current difficulty
help | ? this list
quit | q leave
Rows are numbered 1..N down the left, columns are lettered A..Z across the top.
With --keys, use the arrow keys to move, Enter/Space to reveal, F to flag, C to chord.
""";
private static MinesweeperGame NewGame(GameDifficulty difficulty, int? seed) =>
new(BoardSize.For(difficulty), seed);
private void Reset(GameDifficulty difficulty, int? seed)
{
Difficulty = difficulty;
Game = NewGame(difficulty, seed);
Cursor = new CellLocation(Game.BoardSize.Rows / 2, Game.BoardSize.Columns / 2);
// Stay paused: the clock only starts once the first cell is opened.
_clock.Reset();
}
private void MoveCursor(int rowDelta, int columnDelta)
{
var rows = Game.BoardSize.Rows;
var columns = Game.BoardSize.Columns;
var row = ((Cursor.Row + rowDelta) % rows + rows) % rows;
var column = ((Cursor.Column + columnDelta) % columns + columns) % columns;
Cursor = new CellLocation(row, column);
}
private string? SetCursor(CellLocation location)
{
if (!Game.IsWithinBounds(location))
{
return $"Cell {location.Row + 1},{BoardRenderer.ColumnName(location.Column)} is off the board.";
}
Cursor = location;
return null;
}
private string? Reveal(CellLocation location)
{
if (IsOver)
{
return Game.Status == GameStatus.Won
? "You already won — type new to play again."
: "You already hit a mine — type new to try again.";
}
if (!Game.IsWithinBounds(location))
{
return $"Cell {location.Row + 1},{BoardRenderer.ColumnName(location.Column)} is off the board.";
}
Cursor = location;
if (Game.IsFirstMove)
{
_clock.Start();
}
var opened = Game.Reveal(location);
return FinishIfOver("That was a mine.")
?? (opened.Count > 1 ? $"Opened {opened.Count} cells." : null);
}
private string? ToggleFlag(CellLocation location)
{
if (IsOver)
{
return "The game is over — type new to play again.";
}
if (!Game.IsWithinBounds(location))
{
return $"Cell {location.Row + 1},{BoardRenderer.ColumnName(location.Column)} is off the board.";
}
Cursor = location;
if (!Game.ToggleFlag(location))
{
return "That cell is already open, so it cannot be flagged.";
}
return $"Mines left: {Game.BoardSize.Mines - Game.FlagsUsed}.";
}
private string? Chord(CellLocation location)
{
if (IsOver)
{
return "The game is over — type new to play again.";
}
if (!Game.IsWithinBounds(location))
{
return $"Cell {location.Row + 1},{BoardRenderer.ColumnName(location.Column)} is off the board.";
}
Cursor = location;
if (Game.GetState(location) != CellState.Revealed)
{
return "Chording only works on a cell that is already open.";
}
var opened = Game.Chord(location);
if (FinishIfOver("That flagged cell was not a mine.") is { } outcome)
{
return outcome;
}
return opened.Count == 0
? "Flag count does not match that number, so nothing happened."
: $"Chorded open {opened.Count} cells.";
}
/// <summary>Stops the clock and builds the end-of-game message, or returns null if play continues.</summary>
private string? FinishIfOver(string lossMessage)
{
if (!IsOver)
{
return null;
}
_clock.Stop();
return Game.Status == GameStatus.Won ? $"Cleared in {(int)Elapsed.TotalSeconds}s." : lossMessage;
}
}
@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\Minesweeper.Core\Minesweeper.Core.csproj" />
</ItemGroup>
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
+114
View File
@@ -0,0 +1,114 @@
using Minesweeper.Core;
namespace Minesweeper.Cli;
public static class Program
{
public static int Main(string[] args)
{
if (!CommandLine.TryParse(args, out var options, out var error))
{
Console.Error.WriteLine(error);
Console.Error.WriteLine();
Console.Error.WriteLine(CommandLine.Usage);
return 2;
}
if (options.ShowHelp)
{
Console.WriteLine(CommandLine.Usage);
Console.WriteLine();
Console.WriteLine(GameSession.HelpText);
Console.WriteLine();
Console.WriteLine(Legend);
return 0;
}
var palette = AnsiPalette.Detect();
var renderer = new BoardRenderer(palette);
var session = new GameSession(options.Difficulty, options.Seed);
var keys = options.UseKeys;
if (keys && Console.IsInputRedirected)
{
Console.Error.WriteLine("Input is redirected, so keyboard mode is unavailable; using typed commands.");
keys = false;
}
Run(session, renderer, palette, keys);
return session.Game.Status == GameStatus.Won ? 0 : 1;
}
private static void Run(GameSession session, BoardRenderer renderer, AnsiPalette palette, bool keys)
{
var announced = false;
while (true)
{
Draw(session, renderer, palette, keys);
var command = keys ? ReadKeyCommand() : ReadLineCommand();
if (command is Command.Quit)
{
return;
}
var message = session.Apply(command);
if (message is not null)
{
Console.Error.WriteLine(message);
}
if (session.IsOver && !announced)
{
announced = true;
Console.Error.WriteLine("Type new for another board, or q to quit.");
}
else if (!session.IsOver)
{
announced = false;
}
}
}
private static void Draw(GameSession session, BoardRenderer renderer, AnsiPalette palette, bool keys)
{
if (!Console.IsOutputRedirected)
{
Console.Clear();
}
Console.WriteLine(renderer.RenderHeader(session.Game, session.Elapsed));
Console.WriteLine();
Console.Write(renderer.RenderBoard(session.Game, session.Cursor));
Console.WriteLine();
if (session.IsOver)
{
Console.WriteLine(palette.Bold("Game over. Type new for a fresh board, or q to quit."));
}
else if (keys)
{
Console.WriteLine(palette.Dim("arrows move · enter reveal · f flag · c chord · 1/2/3 difficulty · n new · q quit"));
}
else
{
Console.WriteLine(palette.Dim("type \"<row> <col>\" to reveal, f <row> <col> to flag, ? for help, q to quit"));
}
}
private static Command ReadLineCommand()
{
Console.Write("> ");
var line = Console.ReadLine();
return line is null ? new Command.Quit() : CommandParser.Parse(line);
}
private static Command ReadKeyCommand() => CommandParser.FromKey(Console.ReadKey(intercept: true));
private static string Legend => $"""
Legend
{BoardRenderer.HiddenGlyph} hidden {BoardRenderer.EmptyGlyph} no adjacent mines
{BoardRenderer.FlagGlyph} flagged {BoardRenderer.MineGlyph} mine (shown at game over)
{BoardRenderer.WrongFlagGlyph} wrong flag [x] cursor (arrow-key mode)
1-8 mines touching that cell
""";
}
+22
View File
@@ -0,0 +1,22 @@
namespace Minesweeper.Core;
/// <summary>Preset board configurations, mirroring the classic Windows difficulties.</summary>
public enum GameDifficulty
{
Beginner,
Intermediate,
Expert,
}
public readonly record struct BoardSize(int Rows, int Columns, int Mines)
{
public int CellCount => Rows * Columns;
public static BoardSize For(GameDifficulty difficulty) => difficulty switch
{
GameDifficulty.Beginner => new BoardSize(9, 9, 10),
GameDifficulty.Intermediate => new BoardSize(16, 16, 40),
GameDifficulty.Expert => new BoardSize(16, 30, 99),
_ => throw new ArgumentOutOfRangeException(nameof(difficulty)),
};
}
+7
View File
@@ -0,0 +1,7 @@
namespace Minesweeper.Core;
/// <summary>A cell position. <see cref="Row"/> grows downwards, <see cref="Column"/> rightwards.</summary>
public readonly record struct CellLocation(int Row, int Column)
{
public override string ToString() => $"({Row},{Column})";
}
+12
View File
@@ -0,0 +1,12 @@
namespace Minesweeper.Core;
/// <summary>How far a single cell has been progressed by the player.</summary>
public enum CellState
{
Hidden,
Revealed,
Flagged,
/// <summary>A flag that was not on a mine, shown only after a loss.</summary>
WrongFlag,
}
+4
View File
@@ -0,0 +1,4 @@
namespace Minesweeper.Core;
/// <summary>An immutable description of one cell, ready for a renderer to print.</summary>
public readonly record struct CellView(CellLocation Location, CellState State, bool IsMine, int AdjacentMines);
@@ -0,0 +1,8 @@
namespace Minesweeper.Core;
/// <summary>Thrown when a move is attempted on a board that has already been won or lost.</summary>
public sealed class GameOverException(GameStatus status) : InvalidOperationException(
$"The game is already {status}.")
{
public GameStatus Status { get; } = status;
}
+10
View File
@@ -0,0 +1,10 @@
namespace Minesweeper.Core;
public enum GameStatus
{
/// <summary>Created but not yet played; the first click seeds the mines.</summary>
Ready,
Playing,
Won,
Lost,
}
@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
+411
View File
@@ -0,0 +1,411 @@
namespace Minesweeper.Core;
/// <summary>
/// Pure game state and rules for a single Minesweeper board. Has no dependency on
/// any UI, so it can be driven by the console client, tests, or another frontend.
/// </summary>
public sealed class MinesweeperGame
{
private static readonly CellLocation[] Offsets =
[
new(-1, -1), new(-1, 0), new(-1, 1),
new(0, -1), new(0, 1),
new(1, -1), new(1, 0), new(1, 1),
];
private readonly bool[] _isMine;
private readonly int[] _adjacentMines;
private readonly CellState[] _states;
private readonly int _rows;
private readonly int _columns;
private readonly int _mineCount;
private readonly Random _random;
public MinesweeperGame(BoardSize size, int? seed = null)
{
if (size.Rows <= 0)
{
throw new ArgumentOutOfRangeException(nameof(size), "Board needs at least one row.");
}
if (size.Columns <= 0)
{
throw new ArgumentOutOfRangeException(nameof(size), "Board needs at least one column.");
}
if (size.Mines <= 0 || size.Mines >= size.CellCount)
{
throw new ArgumentOutOfRangeException(nameof(size), "Mines must be between 1 and one less than the cell count.");
}
_rows = size.Rows;
_columns = size.Columns;
_mineCount = size.Mines;
_isMine = new bool[size.CellCount];
_adjacentMines = new int[size.CellCount];
_states = new CellState[size.CellCount];
_random = seed is null ? new Random() : new Random(seed.Value);
Status = GameStatus.Ready;
BoardSize = size;
}
/// <summary>Builds a board with an explicit mine layout, skipping random placement and the
/// first-click safety rule. Useful for tests and for hand-authored puzzles.</summary>
public static MinesweeperGame FromMines(BoardSize size, IEnumerable<CellLocation> mines)
{
var game = new MinesweeperGame(size);
var locations = mines.ToArray();
if (locations.Length != size.Mines)
{
throw new ArgumentException(
$"Expected {size.Mines} mines for a {size.Columns}x{size.Rows} board, got {locations.Length}.",
nameof(mines));
}
foreach (var mine in locations)
{
if (!game.IsWithinBounds(mine))
{
throw new ArgumentException($"Mine {mine} is outside the board.", nameof(mines));
}
if (game._isMine[game.ToIndex(mine)])
{
throw new ArgumentException($"Mine {mine} was listed twice.", nameof(mines));
}
game._isMine[game.ToIndex(mine)] = true;
}
for (var index = 0; index < game._isMine.Length; index++)
{
game._adjacentMines[index] = game.CountAdjacentMines(game.ToLocation(index));
}
game.Status = GameStatus.Playing;
return game;
}
public BoardSize BoardSize { get; }
public GameStatus Status { get; private set; }
public int FlagsUsed { get; private set; }
public int RevealedCount { get; private set; }
public int UnrevealedCount => BoardSize.CellCount - RevealedCount;
public int MineCount => _mineCount;
public bool IsFirstMove => Status == GameStatus.Ready;
public IReadOnlyList<CellLocation> Mines
{
get
{
var mines = new List<CellLocation>(_mineCount);
for (var index = 0; index < _isMine.Length; index++)
{
if (_isMine[index])
{
mines.Add(ToLocation(index));
}
}
return mines;
}
}
public bool IsWithinBounds(CellLocation location) =>
location.Row >= 0 && location.Row < _rows &&
location.Column >= 0 && location.Column < _columns;
public CellState GetState(CellLocation location) => _states[ToIndex(location)];
public int GetAdjacentMineCount(CellLocation location) => _adjacentMines[ToIndex(location)];
public bool IsMine(CellLocation location) => _isMine[ToIndex(location)];
/// <summary>Mine that ended the game, or <see langword="null"/> when the game is not lost.</summary>
public CellLocation? LostOn { get; private set; }
/// <summary>Snapshot of every cell, for rendering.</summary>
public CellView[,] GetView()
{
var view = new CellView[_rows, _columns];
for (var row = 0; row < _rows; row++)
{
for (var column = 0; column < _columns; column++)
{
var index = ToIndex(row, column);
view[row, column] = new CellView(
new CellLocation(row, column),
_states[index],
_isMine[index],
_adjacentMines[index]);
}
}
return view;
}
/// <summary>
/// Reveals a cell. On the first move of the game the board is seeded first so that the
/// clicked cell (and ideally its neighbourhood) is guaranteed mine-free.
/// </summary>
/// <returns>The cells that this action newly revealed.</returns>
public IReadOnlyList<CellLocation> Reveal(CellLocation location)
{
EnsurePlayable();
if (!IsWithinBounds(location))
{
throw new ArgumentOutOfRangeException(nameof(location), $"Cell {location} is outside the board.");
}
if (_states[ToIndex(location)] != CellState.Hidden)
{
return [];
}
if (Status == GameStatus.Ready)
{
SeedMines(location);
Status = GameStatus.Playing;
}
var index = ToIndex(location);
if (_isMine[index])
{
Lose(location);
return [location];
}
var revealed = FloodReveal(index);
if (RevealedCount == BoardSize.CellCount - _mineCount)
{
Win();
}
return revealed;
}
/// <summary>Toggles a flag on a hidden cell. Revealed cells cannot be flagged.</summary>
public bool ToggleFlag(CellLocation location)
{
EnsurePlayable();
if (!IsWithinBounds(location))
{
throw new ArgumentOutOfRangeException(nameof(location), $"Cell {location} is outside the board.");
}
var index = ToIndex(location);
var state = _states[index];
if (state == CellState.Revealed)
{
return false;
}
if (state == CellState.Flagged)
{
_states[index] = CellState.Hidden;
FlagsUsed--;
}
else
{
_states[index] = CellState.Flagged;
FlagsUsed++;
}
return true;
}
/// <summary>
/// Chord: on a revealed number whose adjacent flags match its count, reveal the remaining
/// hidden neighbours. Returns the cells newly revealed by the chord.
/// </summary>
public IReadOnlyList<CellLocation> Chord(CellLocation location)
{
EnsurePlayable();
if (!IsWithinBounds(location))
{
throw new ArgumentOutOfRangeException(nameof(location), $"Cell {location} is outside the board.");
}
if (_states[ToIndex(location)] != CellState.Revealed)
{
return [];
}
var neighbours = Neighbours(location);
var flaggedNeighbours = neighbours.Count(n => _states[ToIndex(n)] == CellState.Flagged);
if (flaggedNeighbours != _adjacentMines[ToIndex(location)])
{
return [];
}
var revealed = new List<CellLocation>();
foreach (var neighbour in neighbours)
{
if (_states[ToIndex(neighbour)] == CellState.Hidden)
{
revealed.AddRange(Reveal(neighbour));
if (Status is not (GameStatus.Playing or GameStatus.Ready))
{
break;
}
}
}
return revealed;
}
public IReadOnlyList<CellLocation> Neighbours(CellLocation location)
{
var result = new List<CellLocation>(8);
foreach (var offset in Offsets)
{
var candidate = new CellLocation(location.Row + offset.Row, location.Column + offset.Column);
if (IsWithinBounds(candidate))
{
result.Add(candidate);
}
}
return result;
}
private void EnsurePlayable()
{
if (Status is GameStatus.Won or GameStatus.Lost)
{
throw new GameOverException(Status);
}
}
/// <summary>Places mines, keeping the first click safe. Safe-first-click is relaxed when the
/// board is too dense to exclude the whole neighbourhood.</summary>
private void SeedMines(CellLocation safeCell)
{
var safe = new HashSet<int> { ToIndex(safeCell) };
if (_mineCount <= BoardSize.CellCount - safe.Count - Neighbours(safeCell).Count)
{
foreach (var neighbour in Neighbours(safeCell))
{
safe.Add(ToIndex(neighbour));
}
}
var candidates = new List<int>(BoardSize.CellCount);
for (var index = 0; index < _isMine.Length; index++)
{
if (!safe.Contains(index))
{
candidates.Add(index);
}
}
// Partial Fisher-Yates: picks _mineCount distinct cells without shuffling the whole board.
for (var i = 0; i < _mineCount; i++)
{
var swap = _random.Next(i, candidates.Count);
(candidates[i], candidates[swap]) = (candidates[swap], candidates[i]);
_isMine[candidates[i]] = true;
}
for (var index = 0; index < _isMine.Length; index++)
{
_adjacentMines[index] = CountAdjacentMines(ToLocation(index));
}
}
private int CountAdjacentMines(CellLocation location)
{
var count = 0;
foreach (var neighbour in Neighbours(location))
{
if (_isMine[ToIndex(neighbour)])
{
count++;
}
}
return count;
}
/// <summary>Reveals the cell, expanding into neighbours while the numbers are zero.</summary>
private IReadOnlyList<CellLocation> FloodReveal(int startIndex)
{
var revealed = new List<CellLocation>();
var pending = new Stack<int>();
_states[startIndex] = CellState.Revealed;
RevealedCount++;
revealed.Add(ToLocation(startIndex));
pending.Push(startIndex);
while (pending.Count > 0)
{
var current = pending.Pop();
if (_adjacentMines[current] != 0)
{
continue;
}
foreach (var neighbour in Neighbours(ToLocation(current)))
{
var neighbourIndex = ToIndex(neighbour);
if (_states[neighbourIndex] != CellState.Hidden || _isMine[neighbourIndex])
{
// Flags act as a placeholder: classic behaviour keeps them hidden during a cascade.
continue;
}
_states[neighbourIndex] = CellState.Revealed;
RevealedCount++;
revealed.Add(neighbour);
pending.Push(neighbourIndex);
}
}
return revealed;
}
private void Win()
{
Status = GameStatus.Won;
foreach (var mine in Mines)
{
_states[ToIndex(mine)] = CellState.Flagged;
}
// Extra flags on safe cells are meaningless once the board is clear; keep the counter sane.
FlagsUsed = _mineCount;
}
private void Lose(CellLocation explodedOn)
{
Status = GameStatus.Lost;
LostOn = explodedOn;
for (var index = 0; index < _isMine.Length; index++)
{
if (_isMine[index])
{
_states[index] = CellState.Revealed;
}
else if (_states[index] == CellState.Flagged)
{
_states[index] = CellState.WrongFlag;
}
}
RevealedCount = BoardSize.CellCount;
}
private int ToIndex(CellLocation location) => ToIndex(location.Row, location.Column);
private int ToIndex(int row, int column) => (row * _columns) + column;
private CellLocation ToLocation(int index) => new(index / _columns, index % _columns);
}
@@ -0,0 +1,311 @@
using System.Text.RegularExpressions;
using Minesweeper.Cli;
using Minesweeper.Core;
namespace Minesweeper.Core.Tests;
public class BoardRendererTests
{
private static readonly AnsiPalette Plain = new(enabled: false);
private static readonly AnsiPalette Colored = new(enabled: true);
/// <summary>3x4 board with a fixed layout used across the rendering assertions.
/// <code>
/// M 1 1 M
/// 2 2 2 1
/// 1 M 1 0
/// </code></summary>
private static MinesweeperGame Puzzle() =>
MinesweeperGame.FromMines(new BoardSize(3, 4, 3),
[new CellLocation(0, 0), new CellLocation(0, 3), new CellLocation(2, 1)]);
private static BoardRenderer Renderer(AnsiPalette palette) => new(palette);
/// <summary>Grid rows with the row-number gutter trimmed off.</summary>
private static List<string> Rows(string rendered) =>
rendered.Split('\n', StringSplitOptions.RemoveEmptyEntries)
.Skip(1)
.Select(line => line.Substring(3))
.ToList();
/// <summary>The glyph in each three-character cell of one grid row.</summary>
private static List<string> Glyphs(string rendered, int row)
{
var line = Rows(rendered)[row];
var glyphs = new List<string>();
for (var index = 2; index < line.Length; index += 3)
{
glyphs.Add(line[index].ToString());
}
return glyphs;
}
private static string StripAnsi(string text) => Regex.Replace(text, "\e\\[[0-9;]*m", string.Empty);
[Fact]
public void RenderBoard_BeforeAnyMove_ShowsEveryCellHidden()
{
var board = Renderer(Plain).RenderBoard(Puzzle());
Assert.Equal(3, Rows(board).Count);
Assert.Equal(["#", "#", "#", "#"], Glyphs(board, 0));
Assert.DoesNotContain("*", board);
Assert.DoesNotContain("·", board);
}
[Fact]
public void RenderBoard_HidesMinesUntilTheGameEnds()
{
var renderer = Renderer(Plain);
Assert.DoesNotContain("*", renderer.RenderBoard(Puzzle()));
var lost = Puzzle();
lost.Reveal(new CellLocation(0, 0));
Assert.Contains("*", renderer.RenderBoard(lost));
}
[Fact]
public void RenderBoard_RevealedCellsShowNumbersAndBlanks()
{
var game = Puzzle();
game.Reveal(new CellLocation(2, 3));
Assert.Equal(["#", "#", "1", "·"], Glyphs(Renderer(Plain).RenderBoard(game), 2));
}
[Fact]
public void RenderBoard_ShowsFlagsThenWrongFlags()
{
var renderer = Renderer(Plain);
var game = Puzzle();
game.ToggleFlag(new CellLocation(1, 1));
Assert.Equal(["#", "P", "#", "#"], Glyphs(renderer.RenderBoard(game), 1));
game.Reveal(new CellLocation(0, 0));
Assert.Contains("X", renderer.RenderBoard(game));
}
[Fact]
public void RenderBoard_AfterWinFlagsEveryMine()
{
var game = MinesweeperGame.FromMines(new BoardSize(3, 3, 1), [new CellLocation(2, 2)]);
game.Reveal(new CellLocation(0, 0));
var board = Renderer(Plain).RenderBoard(game);
Assert.Equal(["·", "1", "P"], Glyphs(board, 2));
Assert.DoesNotContain("*", board);
}
[Fact]
public void RenderBoard_AfterLossExposesEveryMine()
{
var game = Puzzle();
game.Reveal(new CellLocation(0, 0));
var board = string.Concat(Rows(Renderer(Plain).RenderBoard(game)));
Assert.Equal(3, board.Count(c => c == '*'));
}
[Fact]
public void RenderBoard_UsesLetteredColumnsAndNumberedRows()
{
var lines = Renderer(Plain).RenderBoard(Puzzle()).Split('\n');
Assert.Equal(" A B C D", lines[0]);
Assert.StartsWith(" 1", lines[1]);
Assert.StartsWith(" 3", lines[3]);
}
[Fact]
public void RenderBoard_LabelsColumnsWithUpToTwoLetters()
{
Assert.Equal("A", BoardRenderer.ColumnName(0));
Assert.Equal("Z", BoardRenderer.ColumnName(25));
Assert.Equal("AA", BoardRenderer.ColumnName(26));
Assert.Equal("AB", BoardRenderer.ColumnName(27));
}
[Fact]
public void RenderBoard_KeepsEveryRowTheSameWidth()
{
var game = new MinesweeperGame(new BoardSize(9, 30, 99), seed: 3);
game.Reveal(new CellLocation(4, 15));
var lines = Renderer(Plain).RenderBoard(game).Split('\n', StringSplitOptions.RemoveEmptyEntries);
Assert.Equal(10, lines.Length);
Assert.All(lines, line => Assert.Equal(93, line.Length));
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public void RenderBoard_ColorsDoNotDisturbAlignment(bool withCursor)
{
// 5x5 with a mine wall down column C, so half the board stays hidden while the other half
// opens into blanks, numbers, and flags.
var game = MinesweeperGame.FromMines(new BoardSize(5, 5, 4),
[new CellLocation(0, 2), new CellLocation(1, 2), new CellLocation(2, 2), new CellLocation(3, 2)]);
game.Reveal(new CellLocation(0, 0));
game.ToggleFlag(new CellLocation(0, 2));
game.ToggleFlag(new CellLocation(4, 4));
CellLocation? cursor = withCursor ? new CellLocation(2, 1) : null;
var plain = Renderer(Plain).RenderBoard(game, cursor);
var colored = StripAnsi(Renderer(Colored).RenderBoard(game, cursor));
Assert.Equal(GameStatus.Playing, game.Status);
Assert.Equal(plain, colored);
Assert.All(plain.Split('\n', StringSplitOptions.RemoveEmptyEntries), line => Assert.Equal(18, line.Length));
}
[Fact]
public void RenderBoard_ColorsDoNotDisturbAlignmentOnLostAndWonBoards()
{
var lost = Puzzle();
lost.Reveal(new CellLocation(0, 0));
var won = MinesweeperGame.FromMines(new BoardSize(3, 3, 1), [new CellLocation(2, 2)]);
won.Reveal(new CellLocation(0, 0));
foreach (var game in new[] { lost, won })
{
Assert.Equal(
Renderer(Plain).RenderBoard(game),
StripAnsi(Renderer(Colored).RenderBoard(game)));
}
}
[Fact]
public void RenderBoard_MarksTheCursorEvenWithoutColor()
{
var plain = Renderer(Plain);
var game = Puzzle();
Assert.Contains("[#]", plain.RenderBoard(game, new CellLocation(0, 1)));
Assert.Equal(plain.RenderBoard(game).Length, plain.RenderBoard(game, new CellLocation(1, 2)).Length);
}
[Fact]
public void RenderBoard_CursorOffBoardDoesNotChangeOutput()
{
var renderer = Renderer(Plain);
var game = Puzzle();
Assert.Equal(renderer.RenderBoard(game), renderer.RenderBoard(game, new CellLocation(9, 9)));
}
[Fact]
public void RenderHeader_ReportsSizeMinesAndTime()
{
var header = Renderer(Plain).RenderHeader(Puzzle(), TimeSpan.FromSeconds(42));
Assert.Contains("4x3", header);
Assert.Contains("mines left 3", header);
Assert.Contains("42s", header);
}
[Fact]
public void RenderHeader_CountsDownMinesAsFlagsArePlaced()
{
var game = Puzzle();
var renderer = Renderer(Plain);
game.ToggleFlag(new CellLocation(1, 1));
Assert.Contains("mines left 2", renderer.RenderHeader(game, TimeSpan.Zero));
Assert.Contains("flags 1/3", renderer.RenderHeader(game, TimeSpan.Zero));
}
[Fact]
public void RenderHeader_HintsBeforeTheFirstMove()
{
var game = new MinesweeperGame(new BoardSize(5, 5, 3), seed: 1);
Assert.Contains("First click is always safe.", Renderer(Plain).RenderHeader(game, TimeSpan.Zero));
}
[Fact]
public void RenderHeader_CelebratesAWin()
{
var game = MinesweeperGame.FromMines(new BoardSize(3, 3, 1), [new CellLocation(2, 2)]);
game.Reveal(new CellLocation(0, 0));
Assert.Contains("you win", Renderer(Plain).RenderHeader(game, TimeSpan.FromSeconds(3)));
}
[Fact]
public void RenderHeader_ExplainsALossUsingOneBasedCoordinates()
{
var game = Puzzle();
game.Reveal(new CellLocation(0, 0));
var header = Renderer(Plain).RenderHeader(game, TimeSpan.FromSeconds(1));
Assert.Contains("Boom", header);
Assert.Contains("row 1, column A", header);
}
[Fact]
public void GlyphFor_MapsEachStateToItsOwnCharacter()
{
var game = Puzzle();
var view = game.GetView();
Assert.Equal('#', BoardRenderer.GlyphFor(game, view[0, 1]));
game.Reveal(new CellLocation(2, 3));
view = game.GetView();
Assert.Equal('·', BoardRenderer.GlyphFor(game, view[2, 3]));
Assert.Equal('1', BoardRenderer.GlyphFor(game, view[2, 2]));
game.ToggleFlag(new CellLocation(0, 1));
view = game.GetView();
Assert.Equal('P', BoardRenderer.GlyphFor(game, view[0, 1]));
game.Reveal(new CellLocation(0, 0));
view = game.GetView();
Assert.Equal('*', BoardRenderer.GlyphFor(game, view[0, 3]));
Assert.Equal('X', BoardRenderer.GlyphFor(game, view[0, 1]));
}
[Fact]
public void Palette_WithoutColorEmitsNoEscapeCodes()
{
var game = Puzzle();
game.Reveal(new CellLocation(0, 1));
var rendered = Renderer(Plain).RenderBoard(game) + Renderer(Plain).RenderHeader(game, TimeSpan.Zero);
Assert.DoesNotContain("\e[", rendered);
}
[Fact]
public void Palette_GivesEachNumberItsOwnColor()
{
Assert.Equal(8, Enumerable.Range(1, 8).Select(Colored.Number).Distinct().Count());
Assert.Equal("9", Colored.Number(9));
Assert.DoesNotContain("\e[", Plain.Number(3));
}
[Fact]
public void Palette_DistinguishesEveryCellKind()
{
var styles = new[]
{
Colored.Cursor("[#]"),
Colored.Hidden(" #"),
Colored.Flag(" P"),
Colored.Mine(" *"),
Colored.WrongFlag(" X"),
Colored.ExplodedMine(" *"),
Colored.Number(1, " 1"),
};
Assert.Equal(styles.Length, styles.Distinct().Count());
}
}
@@ -0,0 +1,149 @@
using Minesweeper.Cli;
using Minesweeper.Core;
namespace Minesweeper.Core.Tests;
public class CommandLineTests
{
[Fact]
public void TryParse_WithoutArguments_UsesBeginnerDefaults()
{
Assert.True(CommandLine.TryParse([], out var options, out var error));
Assert.Empty(error);
Assert.Equal(GameDifficulty.Beginner, options.Difficulty);
Assert.Null(options.Seed);
Assert.False(options.UseKeys);
Assert.False(options.ShowHelp);
}
[Theory]
[InlineData("-d", "expert", GameDifficulty.Expert)]
[InlineData("--difficulty", "EXPERT", GameDifficulty.Expert)]
[InlineData("-d", "hard", GameDifficulty.Expert)]
[InlineData("-d", "3", GameDifficulty.Expert)]
[InlineData("-d", "e", GameDifficulty.Expert)]
[InlineData("-d", "beginner", GameDifficulty.Beginner)]
[InlineData("-d", "easy", GameDifficulty.Beginner)]
[InlineData("-d", "1", GameDifficulty.Beginner)]
[InlineData("-d", "intermediate", GameDifficulty.Intermediate)]
[InlineData("-d", "medium", GameDifficulty.Intermediate)]
[InlineData("-d", "2", GameDifficulty.Intermediate)]
public void TryParse_DifficultySwitch(string flag, string value, GameDifficulty expected)
{
Assert.True(CommandLine.TryParse([flag, value], out var options, out _));
Assert.Equal(expected, options.Difficulty);
}
[Theory]
[InlineData(new[] { "-b" }, GameDifficulty.Beginner)]
[InlineData(new[] { "-i" }, GameDifficulty.Intermediate)]
[InlineData(new[] { "-e" }, GameDifficulty.Expert)]
public void TryParse_DifficultyShorthands(string[] args, GameDifficulty expected)
{
Assert.True(CommandLine.TryParse(args, out var options, out _));
Assert.Equal(expected, options.Difficulty);
}
[Fact]
public void TryParse_CombinesOptions()
{
Assert.True(CommandLine.TryParse(["-e", "--seed", "42", "-k"], out var options, out _));
Assert.Equal(GameDifficulty.Expert, options.Difficulty);
Assert.Equal(42, options.Seed);
Assert.True(options.UseKeys);
}
[Theory]
[InlineData("0")]
[InlineData("-7")]
[InlineData("999999999")]
public void TryParse_AcceptsAnyIntegerSeed(string seed)
{
Assert.True(CommandLine.TryParse(["--seed", seed], out var options, out _));
Assert.Equal(int.Parse(seed), options.Seed);
}
[Fact]
public void TryParse_Help()
{
Assert.True(CommandLine.TryParse(["--help"], out var options, out _));
Assert.True(options.ShowHelp);
Assert.True(CommandLine.TryParse(["-h"], out options, out _));
Assert.True(options.ShowHelp);
}
[Fact]
public void TryParse_Keys()
{
Assert.True(CommandLine.TryParse(["--keys"], out var options, out _));
Assert.True(options.UseKeys);
Assert.True(CommandLine.TryParse(["-k"], out options, out _));
Assert.True(options.UseKeys);
}
[Theory]
[InlineData("--difficulty")]
[InlineData("-d")]
public void TryParse_DifficultyWithoutValue_Fails(string flag)
{
Assert.False(CommandLine.TryParse([flag], out var options, out var error));
Assert.Null(options);
Assert.Contains("difficulty", error);
}
[Theory]
[InlineData("impossible")]
[InlineData("")]
public void TryParse_UnknownDifficulty_Fails(string value)
{
Assert.False(CommandLine.TryParse(["-d", value], out var options, out var error));
Assert.Null(options);
Assert.Contains("difficulty", error);
}
[Theory]
[InlineData("--seed")]
[InlineData("--seed", "abc")]
[InlineData("--seed", "1.5")]
public void TryParse_BadSeed_Fails(params string[] args)
{
Assert.False(CommandLine.TryParse(args, out var options, out var error));
Assert.Null(options);
Assert.Contains("seed", error);
}
[Fact]
public void TryParse_UnknownOption_FailsAndNamesIt()
{
Assert.False(CommandLine.TryParse(["--turbo"], out var options, out var error));
Assert.Null(options);
Assert.Contains("--turbo", error);
}
[Fact]
public void Usage_MentionsEveryOption()
{
Assert.Contains("--difficulty", CommandLine.Usage);
Assert.Contains("--seed", CommandLine.Usage);
Assert.Contains("--keys", CommandLine.Usage);
Assert.Contains("--help", CommandLine.Usage);
}
[Fact]
public void HelpText_ListsEveryCommandVerb()
{
foreach (var verb in new[] { "reveal", "flag", "chord", "go", "difficulty", "new", "help", "quit" })
{
Assert.Contains(verb, GameSession.HelpText);
}
}
}
@@ -0,0 +1,187 @@
using Minesweeper.Cli;
using Minesweeper.Core;
namespace Minesweeper.Core.Tests;
public class CommandParserTests
{
[Theory]
[InlineData("q")]
[InlineData("quit")]
[InlineData("EXIT")]
public void Parse_QuitVerbs(string input) =>
Assert.IsType<Command.Quit>(CommandParser.Parse(input));
[Theory]
[InlineData("n")]
[InlineData("new")]
public void Parse_NewGame(string input) =>
Assert.IsType<Command.NewGame>(CommandParser.Parse(input));
[Theory]
[InlineData("h")]
[InlineData("help")]
[InlineData("?")]
[InlineData("HELP")]
public void Parse_Help(string input) =>
Assert.IsType<Command.ShowHelp>(CommandParser.Parse(input));
[Theory]
[InlineData("")]
[InlineData(" ")]
public void Parse_BlankInput_IsANoOp(string input) =>
Assert.IsType<Command.Noop>(CommandParser.Parse(input));
[Fact]
public void Parse_RevealVerb_IsZeroBased()
{
var command = Assert.IsType<Command.Reveal>(CommandParser.Parse("reveal 3 4"));
Assert.Equal(new CellLocation(2, 3), command.Cell);
}
[Theory]
[InlineData("2 5")]
[InlineData("2,5")]
[InlineData(" 2 ; 5 ")]
[InlineData("2 5")]
public void Parse_BareCoordinates_Reveal(string input)
{
var command = Assert.IsType<Command.Reveal>(CommandParser.Parse(input));
Assert.Equal(new CellLocation(1, 4), command.Cell);
}
[Theory]
[InlineData("r 1 1")]
[InlineData("open 1 1")]
public void Parse_RevealAliases(string input) =>
Assert.IsType<Command.Reveal>(CommandParser.Parse(input));
[Theory]
[InlineData("f 2 3")]
[InlineData("flag 2 3")]
[InlineData("mark 2 3")]
public void Parse_FlagAliases(string input)
{
var command = Assert.IsType<Command.Flag>(CommandParser.Parse(input));
Assert.Equal(new CellLocation(1, 2), command.Cell);
}
[Theory]
[InlineData("c 4 5")]
[InlineData("chord 4 5")]
public void Parse_ChordAliases(string input)
{
var command = Assert.IsType<Command.Chord>(CommandParser.Parse(input));
Assert.Equal(new CellLocation(3, 4), command.Cell);
}
[Fact]
public void Parse_GotoMovesTheCursor()
{
var command = Assert.IsType<Command.SetCursor>(CommandParser.Parse("go 6 7"));
Assert.Equal(new CellLocation(5, 6), command.Cell);
}
[Theory]
[InlineData("d 1", GameDifficulty.Beginner)]
[InlineData("difficulty beginner", GameDifficulty.Beginner)]
[InlineData("d EASY", GameDifficulty.Beginner)]
[InlineData("d 2", GameDifficulty.Intermediate)]
[InlineData("difficulty INTERMEDIATE", GameDifficulty.Intermediate)]
[InlineData("d medium", GameDifficulty.Intermediate)]
[InlineData("d 3", GameDifficulty.Expert)]
[InlineData("difficulty expert", GameDifficulty.Expert)]
[InlineData("d HARD", GameDifficulty.Expert)]
public void Parse_DifficultyAliases(string input, GameDifficulty expected)
{
var command = Assert.IsType<Command.ChangeDifficulty>(CommandParser.Parse(input));
Assert.Equal(expected, command.Difficulty);
}
[Fact]
public void Parse_DifficultyWithoutValue_ShowsHelp() =>
Assert.IsType<Command.ShowHelp>(CommandParser.Parse("difficulty"));
[Theory]
[InlineData("banana")]
[InlineData("reveal 3")]
[InlineData("reveal x y")]
[InlineData("flag -1 4")]
[InlineData("flag 0 4")]
[InlineData("reveal 1 0")]
[InlineData("d nonsense")]
[InlineData(" reveal")]
public void Parse_InvalidInput_ReportsUnknown(string input)
{
var command = Assert.IsType<Command.Unknown>(CommandParser.Parse(input));
Assert.NotEmpty(command.Input);
}
[Fact]
public void Parse_KeepsUnknownTextForEchoing()
{
var command = Assert.IsType<Command.Unknown>(CommandParser.Parse(" frobnicate 9 "));
Assert.Equal("frobnicate 9", command.Input);
}
[Theory]
[InlineData(ConsoleKey.UpArrow, -1, 0)]
[InlineData(ConsoleKey.DownArrow, 1, 0)]
[InlineData(ConsoleKey.LeftArrow, 0, -1)]
[InlineData(ConsoleKey.RightArrow, 0, 1)]
public void FromKey_ArrowsMoveTheCursor(ConsoleKey key, int row, int column)
{
var command = Assert.IsType<Command.MoveCursor>(CommandParser.FromKey(Key(key)));
Assert.Equal(row, command.RowDelta);
Assert.Equal(column, command.ColumnDelta);
}
[Fact]
public void FromKey_EnterAndSpaceRevealAtCursor()
{
Assert.IsType<Command.RevealAtCursor>(CommandParser.FromKey(Key(ConsoleKey.Enter)));
Assert.IsType<Command.RevealAtCursor>(CommandParser.FromKey(Key(ConsoleKey.Spacebar)));
}
[Fact]
public void FromKey_FlagAndChordActOnCursor()
{
Assert.IsType<Command.FlagAtCursor>(CommandParser.FromKey(Key(ConsoleKey.F, 'f')));
Assert.IsType<Command.ChordAtCursor>(CommandParser.FromKey(Key(ConsoleKey.C, 'c')));
}
[Fact]
public void FromKey_DifficultyShortcuts()
{
Assert.Equal(GameDifficulty.Beginner,
Assert.IsType<Command.ChangeDifficulty>(CommandParser.FromKey(Key(ConsoleKey.D1, '1'))).Difficulty);
Assert.Equal(GameDifficulty.Intermediate,
Assert.IsType<Command.ChangeDifficulty>(CommandParser.FromKey(Key(ConsoleKey.D2, '2'))).Difficulty);
Assert.Equal(GameDifficulty.Expert,
Assert.IsType<Command.ChangeDifficulty>(CommandParser.FromKey(Key(ConsoleKey.D3, '3'))).Difficulty);
}
[Fact]
public void FromKey_NewAndQuit()
{
Assert.IsType<Command.NewGame>(CommandParser.FromKey(Key(ConsoleKey.N, 'n')));
Assert.IsType<Command.Quit>(CommandParser.FromKey(Key(ConsoleKey.Escape)));
Assert.IsType<Command.Quit>(CommandParser.FromKey(Key(ConsoleKey.Q, 'q')));
}
[Fact]
public void FromKey_UnmappedKey_IsANoOp() =>
Assert.IsType<Command.Noop>(CommandParser.FromKey(Key(ConsoleKey.B, 'b')));
private static ConsoleKeyInfo Key(ConsoleKey key, char character = '\0') =>
new(character, key, shift: false, alt: false, control: false);
}
@@ -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;
}
@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Minesweeper.Core\Minesweeper.Core.csproj" />
<ProjectReference Include="..\..\src\Minesweeper.Cli\Minesweeper.Cli.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,575 @@
using Minesweeper.Core;
namespace Minesweeper.Core.Tests;
public class MinesweeperGameTests
{
private static readonly CellLocation Mine = new(0, 0);
private static readonly CellLocation Numbered = new(0, 1);
private static readonly CellLocation SecondNumbered = new(1, 1);
private static readonly CellLocation FarCorner = new(2, 3);
/// <summary>3x4 board where only the far corner reads zero, so floods stay small and chording
/// matters.
/// <code>
/// M 1 1 M
/// 2 2 2 1
/// 1 M 1 0
/// </code></summary>
private static MinesweeperGame PuzzleBoard() =>
MinesweeperGame.FromMines(new BoardSize(3, 4, 3), [new CellLocation(0, 0), new CellLocation(0, 3), new CellLocation(2, 1)]);
/// <summary>2x3 board with a single mine, small enough to win with one chord.
/// <code>
/// 0 1 M
/// 0 1 1
/// </code></summary>
private static MinesweeperGame ChordBoard() =>
MinesweeperGame.FromMines(new BoardSize(2, 3, 1), [new CellLocation(0, 2)]);
[Theory]
[InlineData(0, 5, 3)]
[InlineData(5, 0, 3)]
[InlineData(2, 2, 4)]
[InlineData(2, 2, 0)]
[InlineData(3, 3, -1)]
public void Constructor_RejectsImpossibleBoards(int rows, int columns, int mines) =>
Assert.Throws<ArgumentOutOfRangeException>(() => new MinesweeperGame(new BoardSize(rows, columns, mines)));
[Theory]
[InlineData(GameDifficulty.Beginner, 9, 9, 10)]
[InlineData(GameDifficulty.Intermediate, 16, 16, 40)]
[InlineData(GameDifficulty.Expert, 16, 30, 99)]
public void BoardSize_For_HasClassicDimensions(GameDifficulty difficulty, int rows, int columns, int mines)
{
var size = BoardSize.For(difficulty);
Assert.Equal(rows, size.Rows);
Assert.Equal(columns, size.Columns);
Assert.Equal(mines, size.Mines);
Assert.Equal(rows * columns, size.CellCount);
}
[Fact]
public void NewGame_StartsWithEverythingHiddenAndNoMinesPlaced()
{
var game = new MinesweeperGame(new BoardSize(5, 5, 4), seed: 1);
Assert.Equal(GameStatus.Ready, game.Status);
Assert.True(game.IsFirstMove);
Assert.Equal(0, game.RevealedCount);
Assert.Equal(25, game.UnrevealedCount);
Assert.Equal(0, game.FlagsUsed);
Assert.Empty(game.Mines);
Assert.Null(game.LostOn);
Assert.All(AllLocations(5, 5), l => Assert.Equal(CellState.Hidden, game.GetState(l)));
}
[Theory]
[InlineData(1)]
[InlineData(2)]
[InlineData(37)]
[InlineData(9999)]
public void FirstClick_IsNeverAMine(int seed)
{
var game = new MinesweeperGame(new BoardSize(9, 9, 10), seed);
var clicked = new CellLocation(4, 4);
game.Reveal(clicked);
Assert.False(game.IsMine(clicked));
Assert.Equal(CellState.Revealed, game.GetState(clicked));
Assert.DoesNotContain(clicked, game.Mines);
Assert.False(game.IsFirstMove);
}
[Fact]
public void FirstClick_KeepsNeighboursSafeWhenBoardHasRoom()
{
var game = new MinesweeperGame(new BoardSize(5, 5, 4), seed: 3);
var clicked = new CellLocation(2, 2);
game.Reveal(clicked);
Assert.All(game.Neighbours(clicked), neighbour => Assert.False(game.IsMine(neighbour)));
Assert.Equal(0, game.GetAdjacentMineCount(clicked));
}
[Fact]
public void FirstClick_RelaxesNeighbourSafetyOnDenseBoards()
{
// Every cell touches the centre of a 3x3 board, so only the clicked cell can stay safe.
var game = new MinesweeperGame(new BoardSize(3, 3, 8), seed: 5);
var clicked = new CellLocation(1, 1);
game.Reveal(clicked);
Assert.Equal(8, game.Mines.Count);
Assert.False(game.IsMine(clicked));
Assert.Equal(GameStatus.Won, game.Status);
}
[Fact]
public void FirstClick_SeedsExactlyTheRequestedMineCount()
{
var game = new MinesweeperGame(new BoardSize(16, 30, 99), seed: 12);
game.Reveal(new CellLocation(8, 15));
Assert.Equal(99, game.Mines.Count);
Assert.Equal(99, game.Mines.Distinct().Count());
Assert.All(game.Mines, mine => Assert.True(game.IsMine(mine)));
}
[Fact]
public void AdjacentMineCount_MatchesNeighbouringMines()
{
var game = new MinesweeperGame(new BoardSize(9, 9, 10), seed: 21);
game.Reveal(new CellLocation(4, 4));
foreach (var location in AllLocations(9, 9))
{
Assert.Equal(game.Neighbours(location).Count(game.IsMine), game.GetAdjacentMineCount(location));
}
}
[Fact]
public void SameSeed_ProducesIdenticalBoard()
{
var first = new MinesweeperGame(new BoardSize(9, 9, 10), seed: 77);
var second = new MinesweeperGame(new BoardSize(9, 9, 10), seed: 77);
first.Reveal(new CellLocation(0, 0));
second.Reveal(new CellLocation(0, 0));
Assert.Equal(first.Mines, second.Mines);
}
[Fact]
public void DifferentSeeds_ProduceDifferentBoards()
{
var layouts = Enumerable.Range(0, 12)
.Select(seed =>
{
var game = new MinesweeperGame(new BoardSize(9, 9, 10), seed);
game.Reveal(new CellLocation(4, 4));
return game.Mines;
})
.Distinct()
.Count();
Assert.True(layouts > 1, "Expected distinct layouts across twelve seeds.");
}
/// <summary>5x5 board whose mines form a partial wall down column 2, so a click in the corner
/// opens exactly the ten cells to the left of it and nothing beyond.
/// <code>
/// 0 1 M 1 0
/// 0 2 M 2 1
/// 0 2 M 2 1
/// 0 2 M 2 1
/// 0 1 1 1 0
/// </code></summary>
private static MinesweeperGame WalledBoard() => MinesweeperGame.FromMines(new BoardSize(5, 5, 4),
[new CellLocation(0, 2), new CellLocation(1, 2), new CellLocation(2, 2), new CellLocation(3, 2)]);
[Fact]
public void Reveal_EmptyRegion_FloodsUntilNumbers()
{
var game = WalledBoard();
var revealed = game.Reveal(new CellLocation(0, 0));
Assert.Equal(10, revealed.Count);
Assert.All(revealed, l => Assert.Equal(CellState.Revealed, game.GetState(l)));
Assert.All(revealed, l => Assert.False(game.IsMine(l)));
Assert.Equal(GameStatus.Playing, game.Status);
// The numbers along the wall open, but the flood does not continue past them.
Assert.Equal(CellState.Revealed, game.GetState(new CellLocation(4, 1)));
Assert.Equal(1, game.GetAdjacentMineCount(new CellLocation(4, 1)));
Assert.All(AllLocations(5, 5).Where(l => l.Column >= 3), l => Assert.Equal(CellState.Hidden, game.GetState(l)));
Assert.All(game.Mines, mine => Assert.Equal(CellState.Hidden, game.GetState(mine)));
}
[Fact]
public void Reveal_FloodStopsAtNumbers()
{
var game = MinesweeperGame.FromMines(new BoardSize(1, 5, 1), [new CellLocation(0, 4)]);
var revealed = game.Reveal(new CellLocation(0, 0));
Assert.Equal(
[new CellLocation(0, 0), new CellLocation(0, 1), new CellLocation(0, 2), new CellLocation(0, 3)],
revealed.OrderBy(l => l.Column));
Assert.Equal(1, game.GetAdjacentMineCount(new CellLocation(0, 3)));
}
[Fact]
public void Reveal_FloodDoesNotOpenFlaggedCells()
{
var game = MinesweeperGame.FromMines(new BoardSize(5, 5, 1), [new CellLocation(4, 4)]);
game.ToggleFlag(new CellLocation(2, 2));
var revealed = game.Reveal(new CellLocation(0, 0));
Assert.DoesNotContain(new CellLocation(2, 2), revealed);
Assert.Equal(CellState.Flagged, game.GetState(new CellLocation(2, 2)));
Assert.Equal(1, game.FlagsUsed);
}
[Fact]
public void Reveal_OnNumberedCell_RevealsOnlyItself()
{
var game = PuzzleBoard();
var revealed = game.Reveal(Numbered);
Assert.Equal([Numbered], revealed);
Assert.Equal(1, game.GetAdjacentMineCount(Numbered));
}
[Fact]
public void Reveal_AlreadyRevealedCell_IsANoOp()
{
var game = PuzzleBoard();
game.Reveal(Numbered);
Assert.Empty(game.Reveal(Numbered));
Assert.Equal(GameStatus.Playing, game.Status);
}
[Fact]
public void Reveal_FlaggedCell_IsANoOp()
{
var game = PuzzleBoard();
game.ToggleFlag(Numbered);
Assert.Empty(game.Reveal(Numbered));
Assert.Equal(CellState.Flagged, game.GetState(Numbered));
}
[Fact]
public void Reveal_OutsideBoard_Throws()
{
var game = PuzzleBoard();
Assert.Throws<ArgumentOutOfRangeException>(() => game.Reveal(new CellLocation(3, 0)));
Assert.Throws<ArgumentOutOfRangeException>(() => game.Reveal(new CellLocation(-1, 0)));
Assert.Throws<ArgumentOutOfRangeException>(() => game.ToggleFlag(new CellLocation(0, 4)));
Assert.Throws<ArgumentOutOfRangeException>(() => game.Chord(new CellLocation(0, -3)));
}
[Fact]
public void Reveal_OnMine_LosesAndExposesEveryMine()
{
var mines = new[] { new CellLocation(0, 0), new CellLocation(2, 2) };
var game = MinesweeperGame.FromMines(new BoardSize(3, 3, 2), mines);
var revealed = game.Reveal(new CellLocation(2, 2));
Assert.Equal(GameStatus.Lost, game.Status);
Assert.Equal(new CellLocation(2, 2), game.LostOn);
Assert.Equal([new CellLocation(2, 2)], revealed);
Assert.All(mines, l => Assert.Equal(CellState.Revealed, game.GetState(l)));
}
[Fact]
public void Reveal_OnMine_MarksIncorrectFlagsAsWrong()
{
var game = MinesweeperGame.FromMines(new BoardSize(3, 3, 1), [Mine]);
game.ToggleFlag(new CellLocation(2, 2));
game.Reveal(Mine);
Assert.Equal(GameStatus.Lost, game.Status);
Assert.Equal(CellState.WrongFlag, game.GetState(new CellLocation(2, 2)));
}
[Fact]
public void Reveal_LastSafeCell_WinsAndAutoFlagsMines()
{
var game = PuzzleBoard();
var mines = game.Mines.ToHashSet();
var safeCells = AllLocations(3, 4).Where(l => !mines.Contains(l)).ToList();
foreach (var cell in safeCells.Take(safeCells.Count - 1))
{
if (game.GetState(cell) == CellState.Hidden)
{
game.Reveal(cell);
}
}
Assert.Equal(GameStatus.Playing, game.Status);
game.Reveal(safeCells[^1]);
Assert.Equal(GameStatus.Won, game.Status);
Assert.Equal(9, game.RevealedCount);
Assert.Equal(3, game.FlagsUsed);
Assert.All(mines, mine => Assert.Equal(CellState.Flagged, game.GetState(mine)));
}
[Fact]
public void Reveal_FloodCoveringAllSafeCells_Wins()
{
var game = MinesweeperGame.FromMines(new BoardSize(3, 3, 1), [new CellLocation(2, 2)]);
game.Reveal(new CellLocation(0, 0));
Assert.Equal(GameStatus.Won, game.Status);
Assert.Equal(8, game.RevealedCount);
}
[Fact]
public void Move_AfterWin_ThrowsGameOver()
{
var game = MinesweeperGame.FromMines(new BoardSize(3, 3, 1), [new CellLocation(2, 2)]);
game.Reveal(new CellLocation(0, 0));
var exception = Assert.Throws<GameOverException>(() => game.Reveal(new CellLocation(0, 1)));
Assert.Equal(GameStatus.Won, exception.Status);
Assert.Throws<GameOverException>(() => game.ToggleFlag(new CellLocation(0, 1)));
Assert.Throws<GameOverException>(() => game.Chord(new CellLocation(0, 1)));
}
[Fact]
public void Move_AfterLoss_ThrowsGameOver()
{
var game = MinesweeperGame.FromMines(new BoardSize(3, 3, 1), [Mine]);
game.Reveal(Mine);
var exception = Assert.Throws<GameOverException>(() => game.Reveal(new CellLocation(2, 2)));
Assert.Equal(GameStatus.Lost, exception.Status);
}
[Fact]
public void ToggleFlag_RoundTripsThroughHidden()
{
var game = PuzzleBoard();
Assert.True(game.ToggleFlag(Numbered));
Assert.Equal(CellState.Flagged, game.GetState(Numbered));
Assert.Equal(1, game.FlagsUsed);
Assert.True(game.ToggleFlag(Numbered));
Assert.Equal(CellState.Hidden, game.GetState(Numbered));
Assert.Equal(0, game.FlagsUsed);
}
[Fact]
public void ToggleFlag_OnRevealedCell_IsRejected()
{
var game = PuzzleBoard();
game.Reveal(Numbered);
Assert.False(game.ToggleFlag(Numbered));
Assert.Equal(CellState.Revealed, game.GetState(Numbered));
Assert.Equal(0, game.FlagsUsed);
}
[Fact]
public void ToggleFlag_DoesNotChangeRevealedCount()
{
var game = PuzzleBoard();
game.Reveal(FarCorner);
var before = game.RevealedCount;
game.ToggleFlag(Mine);
Assert.Equal(before, game.RevealedCount);
Assert.Equal(1, game.FlagsUsed);
}
[Fact]
public void Chord_OnHiddenCell_IsANoOp()
{
var game = PuzzleBoard();
Assert.Empty(game.Chord(Numbered));
}
[Fact]
public void Chord_WithTooFewFlags_IsANoOp()
{
var game = PuzzleBoard();
game.Reveal(Numbered);
Assert.Equal(1, game.GetAdjacentMineCount(Numbered));
Assert.Empty(game.Chord(Numbered));
Assert.Equal(GameStatus.Playing, game.Status);
Assert.Equal([Numbered], RevealedCells(AllLocations(3, 4), game));
}
/// <summary>Every revealed cell, used to assert a chord opened nothing extra.</summary>
private static List<CellLocation> RevealedCells(IEnumerable<CellLocation> locations, MinesweeperGame game) =>
locations.Where(l => game.GetState(l) == CellState.Revealed).ToList();
[Fact]
public void Chord_WithCorrectFlags_OpensRemainingNeighbours()
{
var game = ChordBoard();
game.Reveal(new CellLocation(1, 0));
game.ToggleFlag(new CellLocation(0, 2));
var opened = game.Chord(new CellLocation(0, 1));
Assert.Equal([new CellLocation(1, 2)], opened);
Assert.Equal(GameStatus.Won, game.Status);
}
[Fact]
public void Chord_OnMistakenFlag_LosesGame()
{
var game = ChordBoard();
game.Reveal(new CellLocation(1, 0));
game.ToggleFlag(new CellLocation(1, 2));
game.Chord(new CellLocation(0, 1));
Assert.Equal(GameStatus.Lost, game.Status);
Assert.Equal(new CellLocation(0, 2), game.LostOn);
Assert.Equal(CellState.WrongFlag, game.GetState(new CellLocation(1, 2)));
}
[Fact]
public void Chord_OnlyCountsItsOwnAdjacentFlags()
{
var game = PuzzleBoard();
game.Reveal(Numbered);
game.ToggleFlag(new CellLocation(0, 3));
// The flag sits on the far mine, which does not touch (0,1), so the counts still mismatch.
Assert.DoesNotContain(new CellLocation(0, 3), game.Neighbours(Numbered));
Assert.Empty(game.Chord(Numbered));
}
[Fact]
public void Chord_OnZeroCell_OpensNothingNewAfterItsFlood()
{
var game = PuzzleBoard();
game.Reveal(FarCorner);
Assert.Equal(0, game.GetAdjacentMineCount(FarCorner));
Assert.Empty(game.Chord(FarCorner));
}
[Fact]
public void Neighbours_RespectBoardEdges()
{
var game = PuzzleBoard();
Assert.Equal(3, game.Neighbours(new CellLocation(0, 0)).Count);
Assert.Equal(5, game.Neighbours(new CellLocation(0, 1)).Count);
Assert.Equal(8, game.Neighbours(new CellLocation(1, 1)).Count);
Assert.All(game.Neighbours(new CellLocation(0, 0)), n => Assert.True(game.IsWithinBounds(n)));
}
[Fact]
public void Neighbours_NeverIncludeTheCellItself()
{
var game = PuzzleBoard();
Assert.DoesNotContain(SecondNumbered, game.Neighbours(SecondNumbered));
}
[Fact]
public void IsWithinBounds_CoversExactlyTheGrid()
{
var game = new MinesweeperGame(new BoardSize(2, 4, 1), seed: 1);
Assert.True(game.IsWithinBounds(new CellLocation(1, 3)));
Assert.False(game.IsWithinBounds(new CellLocation(2, 0)));
Assert.False(game.IsWithinBounds(new CellLocation(1, 4)));
Assert.False(game.IsWithinBounds(new CellLocation(-1, 0)));
}
[Fact]
public void GetView_ExposesPerCellData()
{
var game = MinesweeperGame.FromMines(new BoardSize(4, 5, 3),
[new CellLocation(0, 0), new CellLocation(2, 2), new CellLocation(3, 4)]);
game.Reveal(new CellLocation(0, 4));
game.ToggleFlag(new CellLocation(1, 0));
var view = game.GetView();
Assert.Equal(4, view.GetLength(0));
Assert.Equal(5, view.GetLength(1));
foreach (var location in AllLocations(4, 5))
{
var cell = view[location.Row, location.Column];
Assert.Equal(location, cell.Location);
Assert.Equal(game.IsMine(location), cell.IsMine);
Assert.Equal(game.GetState(location), cell.State);
Assert.Equal(game.GetAdjacentMineCount(location), cell.AdjacentMines);
}
}
[Fact]
public void GetView_ReturnsAFreshSnapshotEachCall()
{
var game = MinesweeperGame.FromMines(new BoardSize(3, 3, 1), [new CellLocation(2, 2)]);
var view = game.GetView();
view[0, 0] = default;
Assert.Equal(CellState.Hidden, game.GetState(new CellLocation(0, 0)));
Assert.Equal(new CellLocation(0, 0), game.GetView()[0, 0].Location);
}
[Fact]
public void FromMines_RejectsWrongMineCountOrBadLayout()
{
Assert.Throws<ArgumentException>(() =>
MinesweeperGame.FromMines(new BoardSize(3, 3, 2), [new CellLocation(0, 0)]));
Assert.Throws<ArgumentException>(() =>
MinesweeperGame.FromMines(new BoardSize(3, 3, 1), [new CellLocation(5, 5)]));
Assert.Throws<ArgumentException>(() =>
MinesweeperGame.FromMines(new BoardSize(3, 3, 2), [new CellLocation(0, 0), new CellLocation(0, 0)]));
}
[Fact]
public void FromMines_StartsInPlayingStateWithNumbersComputed()
{
var game = PuzzleBoard();
Assert.Equal(GameStatus.Playing, game.Status);
Assert.False(game.IsFirstMove);
Assert.Equal(2, game.GetAdjacentMineCount(SecondNumbered));
Assert.Equal(0, game.GetAdjacentMineCount(FarCorner));
}
[Fact]
public void SolvingRandomBeginnerBoards_AlwaysWins()
{
for (var seed = 0; seed < 40; seed++)
{
var game = new MinesweeperGame(new BoardSize(9, 9, 10), seed);
game.Reveal(new CellLocation(4, 4));
var mines = game.Mines.ToHashSet();
foreach (var cell in AllLocations(9, 9).Where(l => !mines.Contains(l)))
{
if (game.GetState(cell) == CellState.Hidden)
{
game.Reveal(cell);
}
}
Assert.Equal(GameStatus.Won, game.Status);
Assert.Equal(71, game.RevealedCount);
}
}
private static IEnumerable<CellLocation> AllLocations(int rows, int columns)
{
for (var row = 0; row < rows; row++)
{
for (var column = 0; column < columns; column++)
{
yield return new CellLocation(row, column);
}
}
}
}