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:
+10
@@ -0,0 +1,10 @@
|
|||||||
|
bin/
|
||||||
|
obj/
|
||||||
|
*.user
|
||||||
|
.vs/
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
TestResults/
|
||||||
|
*.coverage
|
||||||
|
*.coveragexml
|
||||||
|
artifacts/
|
||||||
@@ -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>
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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
|
||||||
|
""";
|
||||||
|
}
|
||||||
@@ -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)),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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})";
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user