commit fd270095df281673a01ede7788154d9c1cebfcb0 Author: openhands Date: Sat Sep 5 21:50:11 2026 +0000 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1140c2d --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +bin/ +obj/ +*.user +.vs/ +.vscode/ +.idea/ +TestResults/ +*.coverage +*.coveragexml +artifacts/ diff --git a/Minesweeper.slnx b/Minesweeper.slnx new file mode 100644 index 0000000..f922d36 --- /dev/null +++ b/Minesweeper.slnx @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/README.md b/README.md new file mode 100644 index 0000000..2f47d57 --- /dev/null +++ b/README.md @@ -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 " " to reveal, f to flag, ? for help, q to quit +> +``` + +## Play it + +```bash +dotnet run --project src/Minesweeper.Cli +``` + +| Option | Meaning | +| --- | --- | +| `-d, --difficulty ` | board preset (default: beginner) | +| `-b`, `-i`, `-e` | shorthand for the three presets | +| `-k, --keys` | arrow-key play instead of typed commands | +| `--seed ` | 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 | +| --- | --- | +| ` ` or `reveal ` | open a cell (`r`, `open` are aliases) | +| `flag ` | place or remove a flag (`f`, `mark`) | +| `chord ` | open the neighbours of a number once its flags match (`c`) | +| `go ` | move the cursor without opening anything (`goto`) | +| `difficulty ` | 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 +``` diff --git a/src/Minesweeper.Cli/AnsiPalette.cs b/src/Minesweeper.Cli/AnsiPalette.cs new file mode 100644 index 0000000..9ee8972 --- /dev/null +++ b/src/Minesweeper.Cli/AnsiPalette.cs @@ -0,0 +1,52 @@ +namespace Minesweeper.Cli; + +/// ANSI styling with a no-color fallback, so the board stays readable when output is +/// redirected or NO_COLOR is set. +public sealed class AnsiPalette(bool enabled) +{ + /// Classic Minesweeper palette: 1 blue, 2 green, 3 red, 4 dark blue, 5 maroon, 6 teal, 7 black, 8 gray. + 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()); + + /// Colours using the classic colour for . + /// Callers pass an already-padded token so alignment survives the escape codes. + 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"; + } +} diff --git a/src/Minesweeper.Cli/BoardRenderer.cs b/src/Minesweeper.Cli/BoardRenderer.cs new file mode 100644 index 0000000..34d7555 --- /dev/null +++ b/src/Minesweeper.Cli/BoardRenderer.cs @@ -0,0 +1,106 @@ +using System.Text; +using Minesweeper.Core; + +namespace Minesweeper.Cli; + +/// Draws a 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. +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; + } + + /// Renders the grid with lettered columns and numbered rows, highlighting + /// when supplied. + 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(); + } + + /// Column labels are letters so they never look like a typed row number. + public static string ColumnName(int index) => index < 26 + ? ((char)('A' + index)).ToString() + : $"{(char)('A' + ((index / 26) - 1))}{(char)('A' + (index % 26))}"; + + /// The single character a cell displays, without any colouring. + 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; +} diff --git a/src/Minesweeper.Cli/CommandLine.cs b/src/Minesweeper.Cli/CommandLine.cs new file mode 100644 index 0000000..133b468 --- /dev/null +++ b/src/Minesweeper.Cli/CommandLine.cs @@ -0,0 +1,103 @@ +using System.Diagnostics.CodeAnalysis; +using Minesweeper.Core; + +namespace Minesweeper.Cli; + +/// Startup options, parsed from the process arguments. +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 board preset (default: beginner) + -b, -i, -e shorthand for the three presets + -k, --keys arrow-key play instead of typed commands + --seed 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; +} diff --git a/src/Minesweeper.Cli/CommandParser.cs b/src/Minesweeper.Cli/CommandParser.cs new file mode 100644 index 0000000..bfdc0bc --- /dev/null +++ b/src/Minesweeper.Cli/CommandParser.cs @@ -0,0 +1,94 @@ +using System.Globalization; +using Minesweeper.Core; + +namespace Minesweeper.Cli; + +/// Turns typed text and raw keystrokes into instances. This is the +/// only place that translates the one-based coordinates a player sees into engine coordinates. +public static class CommandParser +{ + /// Parses a typed line. Coordinates are one-based, e.g. reveal 3 4. + 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); + } + + /// Maps a keystroke from keyboard-driven mode onto a command. + 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 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; +} diff --git a/src/Minesweeper.Cli/Commands.cs b/src/Minesweeper.Cli/Commands.cs new file mode 100644 index 0000000..7d854b0 --- /dev/null +++ b/src/Minesweeper.Cli/Commands.cs @@ -0,0 +1,40 @@ +using Minesweeper.Core; + +namespace Minesweeper.Cli; + +/// A player intent, produced either by a keystroke or a typed command. All coordinates +/// carried here are zero-based engine coordinates; is the single place +/// that converts the one-based numbers a player types. +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; + + /// Keyboard-driven variants that act on wherever the cursor currently sits. + 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; + + /// Input that means nothing, such as a bare Enter or an unmapped key. + public sealed record Noop : Command; + + /// Unrecognised input; the text is echoed back to the player. + public sealed record Unknown(string Input) : Command; +} diff --git a/src/Minesweeper.Cli/GameSession.cs b/src/Minesweeper.Cli/GameSession.cs new file mode 100644 index 0000000..94eb648 --- /dev/null +++ b/src/Minesweeper.Cli/GameSession.cs @@ -0,0 +1,217 @@ +using System.Diagnostics; +using Minesweeper.Core; + +namespace Minesweeper.Cli; + +/// Owns the current game plus the UI-only state around it (cursor, clock, difficulty), +/// and applies values to it. All console I/O stays outside this class. +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; + + /// Swaps in a prepared board, e.g. a hand-authored puzzle. + public void LoadGame(MinesweeperGame game) + { + Game = game; + Cursor = new CellLocation(game.BoardSize.Rows / 2, game.BoardSize.Columns / 2); + _clock.Reset(); + } + + /// Applies a command and returns a line to show the player, or . + 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 open a cell (aliases: r, open — or just " ") + flag place or remove a flag (aliases: f, mark) + chord open neighbours once flags match the number (alias: c) + go move the cursor without opening anything (alias: goto) + difficulty 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."; + } + + /// Stops the clock and builds the end-of-game message, or returns null if play continues. + private string? FinishIfOver(string lossMessage) + { + if (!IsOver) + { + return null; + } + + _clock.Stop(); + return Game.Status == GameStatus.Won ? $"Cleared in {(int)Elapsed.TotalSeconds}s." : lossMessage; + } +} diff --git a/src/Minesweeper.Cli/Minesweeper.Cli.csproj b/src/Minesweeper.Cli/Minesweeper.Cli.csproj new file mode 100644 index 0000000..9b34dba --- /dev/null +++ b/src/Minesweeper.Cli/Minesweeper.Cli.csproj @@ -0,0 +1,14 @@ + + + + + + + + Exe + net10.0 + enable + enable + + + diff --git a/src/Minesweeper.Cli/Program.cs b/src/Minesweeper.Cli/Program.cs new file mode 100644 index 0000000..05f81a6 --- /dev/null +++ b/src/Minesweeper.Cli/Program.cs @@ -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 \" \" to reveal, f 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 + """; +} diff --git a/src/Minesweeper.Core/BoardSize.cs b/src/Minesweeper.Core/BoardSize.cs new file mode 100644 index 0000000..c72512a --- /dev/null +++ b/src/Minesweeper.Core/BoardSize.cs @@ -0,0 +1,22 @@ +namespace Minesweeper.Core; + +/// Preset board configurations, mirroring the classic Windows difficulties. +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)), + }; +} diff --git a/src/Minesweeper.Core/CellLocation.cs b/src/Minesweeper.Core/CellLocation.cs new file mode 100644 index 0000000..c38e714 --- /dev/null +++ b/src/Minesweeper.Core/CellLocation.cs @@ -0,0 +1,7 @@ +namespace Minesweeper.Core; + +/// A cell position. grows downwards, rightwards. +public readonly record struct CellLocation(int Row, int Column) +{ + public override string ToString() => $"({Row},{Column})"; +} diff --git a/src/Minesweeper.Core/CellState.cs b/src/Minesweeper.Core/CellState.cs new file mode 100644 index 0000000..5b44455 --- /dev/null +++ b/src/Minesweeper.Core/CellState.cs @@ -0,0 +1,12 @@ +namespace Minesweeper.Core; + +/// How far a single cell has been progressed by the player. +public enum CellState +{ + Hidden, + Revealed, + Flagged, + + /// A flag that was not on a mine, shown only after a loss. + WrongFlag, +} diff --git a/src/Minesweeper.Core/CellView.cs b/src/Minesweeper.Core/CellView.cs new file mode 100644 index 0000000..2f5abe2 --- /dev/null +++ b/src/Minesweeper.Core/CellView.cs @@ -0,0 +1,4 @@ +namespace Minesweeper.Core; + +/// An immutable description of one cell, ready for a renderer to print. +public readonly record struct CellView(CellLocation Location, CellState State, bool IsMine, int AdjacentMines); diff --git a/src/Minesweeper.Core/GameOverException.cs b/src/Minesweeper.Core/GameOverException.cs new file mode 100644 index 0000000..028e89c --- /dev/null +++ b/src/Minesweeper.Core/GameOverException.cs @@ -0,0 +1,8 @@ +namespace Minesweeper.Core; + +/// Thrown when a move is attempted on a board that has already been won or lost. +public sealed class GameOverException(GameStatus status) : InvalidOperationException( + $"The game is already {status}.") +{ + public GameStatus Status { get; } = status; +} diff --git a/src/Minesweeper.Core/GameStatus.cs b/src/Minesweeper.Core/GameStatus.cs new file mode 100644 index 0000000..a57414b --- /dev/null +++ b/src/Minesweeper.Core/GameStatus.cs @@ -0,0 +1,10 @@ +namespace Minesweeper.Core; + +public enum GameStatus +{ + /// Created but not yet played; the first click seeds the mines. + Ready, + Playing, + Won, + Lost, +} diff --git a/src/Minesweeper.Core/Minesweeper.Core.csproj b/src/Minesweeper.Core/Minesweeper.Core.csproj new file mode 100644 index 0000000..b760144 --- /dev/null +++ b/src/Minesweeper.Core/Minesweeper.Core.csproj @@ -0,0 +1,9 @@ + + + + net10.0 + enable + enable + + + diff --git a/src/Minesweeper.Core/MinesweeperGame.cs b/src/Minesweeper.Core/MinesweeperGame.cs new file mode 100644 index 0000000..1e779eb --- /dev/null +++ b/src/Minesweeper.Core/MinesweeperGame.cs @@ -0,0 +1,411 @@ +namespace Minesweeper.Core; + +/// +/// 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. +/// +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; + } + + /// 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. + public static MinesweeperGame FromMines(BoardSize size, IEnumerable 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 Mines + { + get + { + var mines = new List(_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)]; + + /// Mine that ended the game, or when the game is not lost. + public CellLocation? LostOn { get; private set; } + + /// Snapshot of every cell, for rendering. + 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; + } + + /// + /// 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. + /// + /// The cells that this action newly revealed. + public IReadOnlyList 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; + } + + /// Toggles a flag on a hidden cell. Revealed cells cannot be flagged. + 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; + } + + /// + /// Chord: on a revealed number whose adjacent flags match its count, reveal the remaining + /// hidden neighbours. Returns the cells newly revealed by the chord. + /// + public IReadOnlyList 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(); + 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 Neighbours(CellLocation location) + { + var result = new List(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); + } + } + + /// Places mines, keeping the first click safe. Safe-first-click is relaxed when the + /// board is too dense to exclude the whole neighbourhood. + private void SeedMines(CellLocation safeCell) + { + var safe = new HashSet { 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(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; + } + + /// Reveals the cell, expanding into neighbours while the numbers are zero. + private IReadOnlyList FloodReveal(int startIndex) + { + var revealed = new List(); + var pending = new Stack(); + _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); +} diff --git a/tests/Minesweeper.Core.Tests/BoardRendererTests.cs b/tests/Minesweeper.Core.Tests/BoardRendererTests.cs new file mode 100644 index 0000000..07f98c9 --- /dev/null +++ b/tests/Minesweeper.Core.Tests/BoardRendererTests.cs @@ -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); + + /// 3x4 board with a fixed layout used across the rendering assertions. + /// + /// M 1 1 M + /// 2 2 2 1 + /// 1 M 1 0 + /// + 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); + + /// Grid rows with the row-number gutter trimmed off. + private static List Rows(string rendered) => + rendered.Split('\n', StringSplitOptions.RemoveEmptyEntries) + .Skip(1) + .Select(line => line.Substring(3)) + .ToList(); + + /// The glyph in each three-character cell of one grid row. + private static List Glyphs(string rendered, int row) + { + var line = Rows(rendered)[row]; + var glyphs = new List(); + 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()); + } +} diff --git a/tests/Minesweeper.Core.Tests/CommandLineTests.cs b/tests/Minesweeper.Core.Tests/CommandLineTests.cs new file mode 100644 index 0000000..de420cc --- /dev/null +++ b/tests/Minesweeper.Core.Tests/CommandLineTests.cs @@ -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); + } + } +} diff --git a/tests/Minesweeper.Core.Tests/CommandParserTests.cs b/tests/Minesweeper.Core.Tests/CommandParserTests.cs new file mode 100644 index 0000000..7bb5bcc --- /dev/null +++ b/tests/Minesweeper.Core.Tests/CommandParserTests.cs @@ -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(CommandParser.Parse(input)); + + [Theory] + [InlineData("n")] + [InlineData("new")] + public void Parse_NewGame(string input) => + Assert.IsType(CommandParser.Parse(input)); + + [Theory] + [InlineData("h")] + [InlineData("help")] + [InlineData("?")] + [InlineData("HELP")] + public void Parse_Help(string input) => + Assert.IsType(CommandParser.Parse(input)); + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void Parse_BlankInput_IsANoOp(string input) => + Assert.IsType(CommandParser.Parse(input)); + + [Fact] + public void Parse_RevealVerb_IsZeroBased() + { + var command = Assert.IsType(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(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(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(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(CommandParser.Parse(input)); + + Assert.Equal(new CellLocation(3, 4), command.Cell); + } + + [Fact] + public void Parse_GotoMovesTheCursor() + { + var command = Assert.IsType(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(CommandParser.Parse(input)); + + Assert.Equal(expected, command.Difficulty); + } + + [Fact] + public void Parse_DifficultyWithoutValue_ShowsHelp() => + Assert.IsType(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(CommandParser.Parse(input)); + + Assert.NotEmpty(command.Input); + } + + [Fact] + public void Parse_KeepsUnknownTextForEchoing() + { + var command = Assert.IsType(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(CommandParser.FromKey(Key(key))); + + Assert.Equal(row, command.RowDelta); + Assert.Equal(column, command.ColumnDelta); + } + + [Fact] + public void FromKey_EnterAndSpaceRevealAtCursor() + { + Assert.IsType(CommandParser.FromKey(Key(ConsoleKey.Enter))); + Assert.IsType(CommandParser.FromKey(Key(ConsoleKey.Spacebar))); + } + + [Fact] + public void FromKey_FlagAndChordActOnCursor() + { + Assert.IsType(CommandParser.FromKey(Key(ConsoleKey.F, 'f'))); + Assert.IsType(CommandParser.FromKey(Key(ConsoleKey.C, 'c'))); + } + + [Fact] + public void FromKey_DifficultyShortcuts() + { + Assert.Equal(GameDifficulty.Beginner, + Assert.IsType(CommandParser.FromKey(Key(ConsoleKey.D1, '1'))).Difficulty); + Assert.Equal(GameDifficulty.Intermediate, + Assert.IsType(CommandParser.FromKey(Key(ConsoleKey.D2, '2'))).Difficulty); + Assert.Equal(GameDifficulty.Expert, + Assert.IsType(CommandParser.FromKey(Key(ConsoleKey.D3, '3'))).Difficulty); + } + + [Fact] + public void FromKey_NewAndQuit() + { + Assert.IsType(CommandParser.FromKey(Key(ConsoleKey.N, 'n'))); + Assert.IsType(CommandParser.FromKey(Key(ConsoleKey.Escape))); + Assert.IsType(CommandParser.FromKey(Key(ConsoleKey.Q, 'q'))); + } + + [Fact] + public void FromKey_UnmappedKey_IsANoOp() => + Assert.IsType(CommandParser.FromKey(Key(ConsoleKey.B, 'b'))); + + private static ConsoleKeyInfo Key(ConsoleKey key, char character = '\0') => + new(character, key, shift: false, alt: false, control: false); +} diff --git a/tests/Minesweeper.Core.Tests/GameSessionTests.cs b/tests/Minesweeper.Core.Tests/GameSessionTests.cs new file mode 100644 index 0000000..dac6292 --- /dev/null +++ b/tests/Minesweeper.Core.Tests/GameSessionTests.cs @@ -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 mines) + { + var session = new GameSession(); + session.LoadGame(MinesweeperGame.FromMines(size, mines)); + return session; + } + + /// 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. + 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; +} diff --git a/tests/Minesweeper.Core.Tests/Minesweeper.Core.Tests.csproj b/tests/Minesweeper.Core.Tests/Minesweeper.Core.Tests.csproj new file mode 100644 index 0000000..e0a07b6 --- /dev/null +++ b/tests/Minesweeper.Core.Tests/Minesweeper.Core.Tests.csproj @@ -0,0 +1,26 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/Minesweeper.Core.Tests/MinesweeperGameTests.cs b/tests/Minesweeper.Core.Tests/MinesweeperGameTests.cs new file mode 100644 index 0000000..c82fda8 --- /dev/null +++ b/tests/Minesweeper.Core.Tests/MinesweeperGameTests.cs @@ -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); + + /// 3x4 board where only the far corner reads zero, so floods stay small and chording + /// matters. + /// + /// M 1 1 M + /// 2 2 2 1 + /// 1 M 1 0 + /// + private static MinesweeperGame PuzzleBoard() => + MinesweeperGame.FromMines(new BoardSize(3, 4, 3), [new CellLocation(0, 0), new CellLocation(0, 3), new CellLocation(2, 1)]); + + /// 2x3 board with a single mine, small enough to win with one chord. + /// + /// 0 1 M + /// 0 1 1 + /// + 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(() => 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."); + } + + /// 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. + /// + /// 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 + /// + 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(() => game.Reveal(new CellLocation(3, 0))); + Assert.Throws(() => game.Reveal(new CellLocation(-1, 0))); + Assert.Throws(() => game.ToggleFlag(new CellLocation(0, 4))); + Assert.Throws(() => 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(() => game.Reveal(new CellLocation(0, 1))); + Assert.Equal(GameStatus.Won, exception.Status); + Assert.Throws(() => game.ToggleFlag(new CellLocation(0, 1))); + Assert.Throws(() => 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(() => 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)); + } + + /// Every revealed cell, used to assert a chord opened nothing extra. + private static List RevealedCells(IEnumerable 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(() => + MinesweeperGame.FromMines(new BoardSize(3, 3, 2), [new CellLocation(0, 0)])); + Assert.Throws(() => + MinesweeperGame.FromMines(new BoardSize(3, 3, 1), [new CellLocation(5, 5)])); + Assert.Throws(() => + 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 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); + } + } + } +}