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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user