Initial commit: terminal Minesweeper in C# for .NET 10

A playable console Minesweeper (arrow keys, chording, flags, question marks,
pause, difficulty presets) plus a deduction-and-guess solver that can play the
game headlessly. Game logic lives in src/Minesweeper/Game and has no console
dependencies, so it is covered by 65 unit tests.

Includes publishing helpers for self-contained, trimmed single-file binaries
(publish.sh) and an .editorconfig that the sources conform to: UTF-8 BOM and
4-space indents for C#, 2-space indents for project files, LF endings and
2-space indents for shell scripts, System-first usings, no redundant `this.`,
and s_/ _ prefixed private static and instance fields.
This commit is contained in:
drobor-ai
2026-09-08 07:12:51 +00:00
commit 2ba405b19c
28 changed files with 3212 additions and 0 deletions
+216
View File
@@ -0,0 +1,216 @@
using Minesweeper.Game;
using Xunit;
namespace Minesweeper.Tests;
public class BoardTests
{
private static readonly string[] s_floodLayout =
[
".....",
".....",
".....",
".....",
"...x.",
];
private static readonly string[] s_twinMineLayout =
[
"x.x",
"...",
"...",
];
[Theory]
[InlineData(0, 5, 1)]
[InlineData(5, 0, 1)]
[InlineData(3, 3, -1)]
[InlineData(3, 3, 9)]
public void ConstructorRejectsImpossibleBoards(int width, int height, int mines) =>
Assert.ThrowsAny<ArgumentOutOfRangeException>(() => new Board(width, height, mines));
[Fact]
public void FirstClickKeepsTheChosenSquareClear()
{
for (var seed = 0; seed < 50; seed++)
{
var board = new Board(9, 9, 10);
board.PlantMines(new CellRef(4, 4), reserveSafeZone: false, new Random(seed));
Assert.False(board.At(4, 4).IsMine);
Assert.Equal(10, board.MineCount);
Assert.Equal(10, board.MineCells().Count());
}
}
[Fact]
public void FirstClickCanAlsoKeepItsNeighborhoodClear()
{
for (var seed = 0; seed < 50; seed++)
{
var board = new Board(9, 9, 10);
board.PlantMines(new CellRef(4, 4), reserveSafeZone: true, new Random(seed));
Assert.Equal(0, board.MineCells().Count(m => m == new CellRef(4, 4) || board.NeighborsOf(4, 4).Contains(m)));
}
}
[Fact]
public void PlantingTwiceThrows()
{
var board = TestBoards.Create(s_floodLayout);
Assert.Throws<InvalidOperationException>(() => board.PlantMines([]));
Assert.Throws<InvalidOperationException>(() => board.PlantMines(new CellRef(0, 0), true, new Random(1)));
}
[Fact]
public void NumbersComeFromTheMineLayout()
{
var board = TestBoards.Create(s_floodLayout);
Assert.True(board.At(3, 4).IsMine);
Assert.Equal(0, board.At(0, 0).AdjacentMines);
Assert.Equal(1, board.At(2, 3).AdjacentMines);
Assert.Equal(1, board.At(4, 4).AdjacentMines);
Assert.Equal(1, board.MineCount);
}
[Theory]
[InlineData(0, 0, 3)]
[InlineData(2, 0, 5)]
[InlineData(2, 2, 8)]
public void NeighborCountsRespectTheEdges(int x, int y, int expected)
{
var board = new Board(5, 5, 0);
Assert.Equal(expected, board.NeighborsOf(x, y).Count());
}
[Fact]
public void OpeningABlankSquareFloodsUntilTheNumbers()
{
var board = TestBoards.Create(s_floodLayout);
var result = board.Open(new CellRef(0, 0));
Assert.False(result.HitMine);
Assert.Equal(23, result.Opened.Count);
Assert.False(board.At(3, 4).IsRevealed);
Assert.True(board.At(2, 3).IsRevealed);
Assert.Equal(23, Revealed(board));
// (4,4) only touches other numbers and the mine, so a flood can never reach it.
Assert.False(board.At(4, 4).IsRevealed);
}
[Fact]
public void FlagsHoldBackTheFloodFill()
{
var board = TestBoards.Create(s_floodLayout);
board.At(0, 2).State = CellState.Flagged;
var result = board.Open(new CellRef(0, 0));
Assert.Equal(22, result.Opened.Count);
Assert.True(board.At(0, 2).IsFlagged);
}
[Fact]
public void OpeningANumberedSquareRevealsOnlyIt()
{
var board = TestBoards.Create("x..", "...", "...");
var result = board.Open(new CellRef(1, 0));
Assert.Single(result.Opened);
Assert.True(board.At(1, 0).IsRevealed);
Assert.False(board.At(2, 2).IsRevealed);
}
[Fact]
public void OpeningAMineReportsTheBoom()
{
var board = TestBoards.Create(s_floodLayout);
var result = board.Open(new CellRef(3, 4));
Assert.True(result.HitMine);
Assert.Equal(new CellRef(3, 4), result.Boom!.Value);
Assert.True(board.At(3, 4).IsRevealed);
}
[Fact]
public void ChordOpensTheRestWhenTheFlagsMatch()
{
var board = TestBoards.Create(s_twinMineLayout);
board.Open(new CellRef(1, 0));
board.At(0, 0).State = CellState.Flagged;
board.At(2, 0).State = CellState.Flagged;
var result = board.Chord(new CellRef(1, 0));
Assert.False(result.HitMine);
Assert.Equal(3, result.Opened.Count);
Assert.True(board.At(0, 1).IsRevealed);
Assert.True(board.At(1, 1).IsRevealed);
Assert.True(board.At(2, 1).IsRevealed);
}
[Theory]
[InlineData(1)]
[InlineData(3)]
public void ChordWithTheWrongFlagCountJustPops(int flags)
{
var board = TestBoards.Create(s_twinMineLayout);
board.Open(new CellRef(1, 0));
board.At(0, 0).State = CellState.Flagged;
if (flags > 1)
board.At(0, 1).State = CellState.Flagged;
if (flags > 2)
board.At(1, 1).State = CellState.Flagged;
var result = board.Chord(new CellRef(1, 0));
Assert.False(result.AnyOpened);
Assert.False(result.HitMine);
Assert.Equal(1, Revealed(board));
}
[Fact]
public void ChordCanStepOnAnUnflaggedMine()
{
var board = TestBoards.Create(s_twinMineLayout);
board.Open(new CellRef(1, 0));
board.At(0, 0).State = CellState.Flagged;
board.At(0, 1).State = CellState.Flagged;
var result = board.Chord(new CellRef(1, 0));
Assert.True(result.HitMine);
Assert.Equal(new CellRef(2, 0), result.Boom!.Value);
}
[Fact]
public void ChordIgnoresHiddenSquares()
{
var board = TestBoards.Create(s_twinMineLayout);
Assert.False(board.Chord(new CellRef(1, 1)).AnyOpened);
}
[Fact]
public void TinyBoardsLoseMinesTheyCannotFit()
{
var board = new Board(2, 2, 3);
board.PlantMines(new CellRef(0, 0), reserveSafeZone: true, new Random(3));
Assert.Equal(0, board.MineCount);
Assert.Empty(board.MineCells());
}
[Fact]
public void OutOfRangeSquaresThrow()
{
var board = new Board(3, 3, 1);
Assert.Throws<ArgumentOutOfRangeException>(() => board.At(3, 0));
Assert.False(board.InBounds(-1, 2));
Assert.True(board.InBounds(2, 2));
}
private static int Revealed(Board board) => board.AllCells().Count(reference => board.At(reference).IsRevealed);
}
@@ -0,0 +1,71 @@
using Minesweeper.Cli;
using Minesweeper.Game;
using Xunit;
namespace Minesweeper.Tests;
public class CommandLineTests
{
[Fact]
public void DefaultsAreBeginner()
{
Assert.True(CommandLine.TryParse([], out var options, out var errors, out var help));
Assert.Empty(errors);
Assert.False(help);
Assert.Equal(DifficultyProfile.Beginner, options.Difficulty);
Assert.True(options.ReserveSafeZone);
Assert.True(options.QuestionMarks);
Assert.False(options.Auto);
Assert.Null(options.Seed);
}
[Fact]
public void PresetAndCustomSizesCombine()
{
Assert.True(CommandLine.TryParse(["-d", "expert"], out var options, out _, out _));
Assert.Equal(30, options.Difficulty.Width);
Assert.Equal(99, options.Difficulty.Mines);
Assert.True(CommandLine.TryParse(["--size", "12x8", "--mines", "5"], out options, out _, out _));
Assert.Equal((12, 8, 5), (options.Difficulty.Width, options.Difficulty.Height, options.Difficulty.Mines));
}
[Fact]
public void SwitchesAreHonoured()
{
Assert.True(CommandLine.TryParse(
["--auto-trace", "--seed", "42", "--no-safe-zone", "--no-question-marks", "--compact", "--ascii", "--no-color"],
out var options, out var errors, out _));
Assert.Empty(errors);
Assert.True(options.Auto);
Assert.True(options.AutoTrace);
Assert.Equal(42, options.Seed);
Assert.False(options.ReserveSafeZone);
Assert.False(options.QuestionMarks);
Assert.True(options.Compact);
Assert.True(options.Ascii);
Assert.False(options.Color);
}
[Fact]
public void HelpShortCircuitsEverythingElse()
{
Assert.True(CommandLine.TryParse(["--help"], out _, out var errors, out var help));
Assert.True(help);
Assert.Empty(errors);
}
[Theory]
[InlineData(new[] { "--nope" }, "Unknown option")]
[InlineData(new[] { "--seed" }, "needs a value")]
[InlineData(new[] { "--seed", "abc" }, "expects a whole number")]
[InlineData(new[] { "-d", "impossible" }, "Unknown difficulty")]
[InlineData(new[] { "--size", "9x9", "--mines", "900" }, "do not leave a safe square")]
[InlineData(new[] { "--auto-max-steps", "0" }, "expects a positive number")]
public void MistakesAreReported(string[] args, string expectedFragment)
{
Assert.False(CommandLine.TryParse(args, out _, out var errors, out _));
Assert.Contains(errors, error => error.Contains(expectedFragment, StringComparison.Ordinal));
}
}
@@ -0,0 +1,52 @@
using Minesweeper.Game;
using Xunit;
namespace Minesweeper.Tests;
public class DifficultyTests
{
[Theory]
[InlineData("beginner", 9, 9, 10)]
[InlineData("NOVICE", 9, 9, 10)]
[InlineData("1", 9, 9, 10)]
[InlineData("intermediate", 16, 16, 40)]
[InlineData("normal", 16, 16, 40)]
[InlineData("2", 16, 16, 40)]
[InlineData("expert", 30, 16, 99)]
[InlineData("e", 30, 16, 99)]
[InlineData("3", 30, 16, 99)]
[InlineData("14x10x25", 14, 10, 25)]
[InlineData("12x8", 12, 8, 14)]
[InlineData("10,10,20", 10, 10, 20)]
public void NamesResolve(string value, int width, int height, int mines)
{
Assert.True(DifficultyProfile.TryResolve(value, out var profile, out var error));
Assert.Null(error);
Assert.Equal(width, profile!.Width);
Assert.Equal(height, profile.Height);
Assert.Equal(mines, profile.Mines);
}
[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData("banana")]
[InlineData("16x16x900")]
[InlineData("300x300x10")]
[InlineData("16x16xzero")]
public void BadNamesAreRejected(string value)
{
Assert.False(DifficultyProfile.TryResolve(value, out var profile, out var error));
Assert.Null(profile);
Assert.False(string.IsNullOrWhiteSpace(error));
}
[Fact]
public void PresetsMatchTheClassicBoards()
{
Assert.Equal(
new[] { "Beginner", "Intermediate", "Expert" },
DifficultyProfile.Presets.Select(preset => preset.Name));
Assert.Equal("30x16", DifficultyProfile.Expert.SizeLabel);
}
}
@@ -0,0 +1,25 @@
<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/Minesweeper.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,152 @@
using Minesweeper.Game;
using Xunit;
namespace Minesweeper.Tests;
public class MinesweeperGameTests
{
private static readonly string[] s_twinMineLayout =
[
"x.x",
"...",
"...",
];
private static readonly string[] s_cornerMineLayout =
[
"x..",
"...",
"...",
];
[Fact]
public void TheFirstClickPlantsMinesAndIsSafe()
{
var board = new Board(9, 9, 10);
var game = new MinesweeperGame(board, new Random(7));
Assert.False(game.HasOpenedFirstCell);
var result = game.Open(new CellRef(4, 4));
Assert.True(board.MinesPlanted);
Assert.True(game.HasOpenedFirstCell);
Assert.False(result.HitMine);
Assert.True(result.Opened.Count > 1, "the default safe zone should open an area");
Assert.Equal(GameStatus.Playing, game.Status);
}
[Fact]
public void EverySafeSquareOpeningWinsAndFlagsTheMines()
{
var game = TestBoards.Game(1, s_cornerMineLayout);
var result = game.Open(new CellRef(2, 0));
Assert.Equal(8, result.Opened.Count);
Assert.Equal(GameStatus.Won, game.Status);
Assert.Equal(1, game.FlagsPlaced);
Assert.Equal(0, game.MinesRemaining);
Assert.All(game.Board.MineCells(), mine => Assert.True(game.Board.At(mine).IsFlagged));
}
[Fact]
public void SteppingOnAMineEndsTheRoundAndShowsTheBoard()
{
var game = TestBoards.SingleMine(4, 4);
game.ToggleFlag(new CellRef(3, 3));
var result = game.Open(new CellRef(0, 0));
Assert.True(result.HitMine);
Assert.Equal(GameStatus.Lost, game.Status);
Assert.Equal(new CellRef(0, 0), game.Explosion!.Value);
Assert.True(game.Board.At(0, 0).Exploded);
Assert.All(game.Board.MineCells(), mine => Assert.True(game.Board.At(mine).IsRevealed || game.Board.At(mine).IsFlagged));
// A flag on safe ground stays put so the renderer can mark it as wrong.
Assert.True(game.Board.At(3, 3).IsFlagged);
Assert.False(game.Board.At(3, 3).IsMine);
}
[Fact]
public void FinishedRoundsIgnoreFurtherMoves()
{
var game = TestBoards.SingleMine();
game.Open(new CellRef(0, 0));
var revealed = game.RevealedCount;
Assert.False(game.Open(new CellRef(2, 2)).AnyOpened);
Assert.False(game.ToggleFlag(new CellRef(2, 3)));
Assert.Equal(revealed, game.RevealedCount);
}
[Fact]
public void FlagsMoveTheCounterBothWays()
{
var game = TestBoards.SingleMine();
Assert.Equal(1, game.MinesRemaining);
Assert.True(game.ToggleFlag(new CellRef(1, 1)));
Assert.Equal(0, game.MinesRemaining);
Assert.True(game.ToggleFlag(new CellRef(2, 2)));
Assert.Equal(-1, game.MinesRemaining);
Assert.True(game.ToggleFlag(new CellRef(1, 1)));
Assert.Equal(0, game.MinesRemaining);
Assert.True(game.ToggleFlag(new CellRef(1, 1)), "a cleared square can be flagged again");
Assert.Equal(-1, game.MinesRemaining);
}
[Fact]
public void FlaggedSquaresCannotBeOpened()
{
var game = TestBoards.SingleMine();
game.ToggleFlag(new CellRef(1, 1));
Assert.False(game.Open(new CellRef(1, 1)).AnyOpened);
Assert.True(game.Board.At(1, 1).IsFlagged);
}
[Fact]
public void QuestionMarksAreOptionalAndStayOpenable()
{
var game = TestBoards.SingleMine();
Assert.False(game.ToggleQuestion(new CellRef(1, 1), questionMarksEnabled: false));
Assert.True(game.ToggleQuestion(new CellRef(1, 1), questionMarksEnabled: true));
Assert.Equal(CellState.Question, game.Board.At(1, 1).State);
Assert.True(game.Open(new CellRef(1, 1)).AnyOpened);
Assert.True(game.Board.At(1, 1).IsRevealed);
}
[Fact]
public void ChordingThroughTheGameCountsTheNewSquares()
{
var game = TestBoards.Game(1, s_twinMineLayout);
game.Open(new CellRef(1, 0));
game.ToggleFlag(new CellRef(0, 0));
game.ToggleFlag(new CellRef(2, 0));
var result = game.Chord(new CellRef(1, 0));
Assert.Equal(3, result.Opened.Count);
Assert.Equal(4, game.RevealedCount);
Assert.Equal(GameStatus.Playing, game.Status);
}
[Fact]
public void RevealSolutionOnlyWorksAfterTheRound()
{
var game = TestBoards.SingleMine();
game.RevealSolution();
Assert.False(game.Board.At(0, 0).IsRevealed);
game.Open(new CellRef(0, 0));
game.RevealSolution();
Assert.True(game.Board.At(0, 0).IsRevealed);
}
}
+72
View File
@@ -0,0 +1,72 @@
using Minesweeper.Game;
using Xunit;
namespace Minesweeper.Tests;
public class SolverTests
{
[Fact]
public void AnEmptyMinefieldIsClearedInOneGo()
{
var game = new MinesweeperGame(new Board(5, 5, 0), new Random(1));
var result = new Solver(game, new Random(2)).Run();
Assert.Equal(GameStatus.Won, result.Status);
Assert.Equal(25, game.RevealedCount);
Assert.Equal(0, result.Guesses);
}
[Fact]
public void TheStepBudgetStopsTheSearch()
{
var game = new MinesweeperGame(new Board(16, 16, 40), new Random(3));
var result = new Solver(game, new Random(4), maxSteps: 1).Run();
Assert.Equal(1, result.Steps);
Assert.Equal(GameStatus.Playing, game.Status);
}
[Fact]
public void TheSameSeedPlaysTheSameGame()
{
var first = Play(21, out var firstGame);
var second = Play(21, out var secondGame);
Assert.Equal(first.Status, second.Status);
Assert.Equal(firstGame.RevealedCount, secondGame.RevealedCount);
Assert.Equal(first.Guesses, second.Guesses);
}
[Fact]
public void DeductionsClearMostBeginnerBoards()
{
var wins = 0;
for (var seed = 1; seed <= 10; seed++)
{
if (Play(seed, out _).Status == GameStatus.Won)
wins++;
}
Assert.True(wins >= 8, $"expected the solver to win at least 8 of 10 beginner boards, won {wins}");
}
[Fact]
public void SolverNeverLeavesAHiddenSafeSquareBehind()
{
for (var seed = 1; seed <= 5; seed++)
{
Play(seed, out var game);
if (game.Status != GameStatus.Won)
continue;
Assert.Equal(game.TotalSafeCells, game.RevealedCount);
}
}
private static SolverResult Play(int seed, out MinesweeperGame game)
{
var random = new Random(seed);
game = new MinesweeperGame(new Board(9, 9, 10), random) { ReserveSafeZone = true };
return new Solver(game, random).Run();
}
}
+52
View File
@@ -0,0 +1,52 @@
using Minesweeper.Game;
namespace Minesweeper.Tests;
/// <summary>Builds fixed boards from a layout where 'x' marks a mine.</summary>
internal static class TestBoards
{
public const char Mine = 'x';
public static Board Create(params string[] layout)
{
if (layout.Length == 0)
throw new ArgumentException("A layout needs at least one row.", nameof(layout));
var height = layout.Length;
var width = layout[0].Length;
var mines = new List<CellRef>();
for (var y = 0; y < height; y++)
{
if (layout[y].Length != width)
throw new ArgumentException($"Layout row {y} is {layout[y].Length} wide, expected {width}.", nameof(layout));
for (var x = 0; x < width; x++)
if (layout[y][x] == Mine)
mines.Add(new CellRef(x, y));
}
var board = new Board(width, height, 0);
board.PlantMines(mines);
return board;
}
public static MinesweeperGame Game(int seed, params string[] layout) =>
new(Create(layout), new Random(seed));
public static MinesweeperGame SingleMine(int width = 4, int height = 4, int seed = 1) =>
Game(seed, Rows(width, height, new CellRef(0, 0)));
public static string[] Rows(int width, int height, params CellRef[] mines)
{
var rows = new string[height];
for (var y = 0; y < height; y++)
{
var characters = new char[width];
for (var x = 0; x < width; x++)
characters[x] = mines.Contains(new CellRef(x, y)) ? Mine : '.';
rows[y] = new string(characters);
}
return rows;
}
}