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
Executable
+246
View File
@@ -0,0 +1,246 @@
# EditorConfig is awesome: https://EditorConfig.org
# Copypasted from https://github.com/dotnet/roslyn/blob/master/.editorconfig
# top-most EditorConfig file
root = true
# Don't use tabs for indentation.
[*]
indent_style = space
# (Please don't specify an indent_size here; that has too many unintended consequences.)
# Code files
[*.{cs,csx,vb,vbx}]
indent_size = 4
insert_final_newline = true
charset = utf-8-bom
# XML project files
[*.{csproj,vbproj,vcxproj,vcxproj.filters,proj,projitems,shproj}]
indent_size = 2
# XML config files
[*.{props,targets,ruleset,config,nuspec,resx,vsixmanifest,vsct}]
indent_size = 2
# JSON files
[*.json]
indent_size = 2
# Powershell files
[*.ps1]
indent_size = 2
# Shell script files
[*.sh]
end_of_line = lf
indent_size = 2
# Dotnet code style settings:
[*.{cs,vb}]
# Sort using and Import directives with System.* appearing first
dotnet_sort_system_directives_first = true
# Avoid "this." and "Me." if not necessary
dotnet_style_qualification_for_field = false:refactoring
dotnet_style_qualification_for_property = false:refactoring
dotnet_style_qualification_for_method = false:refactoring
dotnet_style_qualification_for_event = false:refactoring
# Use language keywords instead of framework type names for type references
dotnet_style_predefined_type_for_locals_parameters_members = true:suggestion
dotnet_style_predefined_type_for_member_access = true:suggestion
# Suggest more modern language features when available
dotnet_style_object_initializer = true:suggestion
dotnet_style_collection_initializer = true:suggestion
dotnet_style_coalesce_expression = true:suggestion
dotnet_style_null_propagation = true:suggestion
dotnet_style_explicit_tuple_names = true:suggestion
# Non-private static fields are PascalCase
dotnet_naming_rule.non_private_static_fields_should_be_pascal_case.severity = suggestion
dotnet_naming_rule.non_private_static_fields_should_be_pascal_case.symbols = non_private_static_fields
dotnet_naming_rule.non_private_static_fields_should_be_pascal_case.style = non_private_static_field_style
dotnet_naming_symbols.non_private_static_fields.applicable_kinds = field
dotnet_naming_symbols.non_private_static_fields.applicable_accessibilities = public, protected, internal, protected_internal, private_protected
dotnet_naming_symbols.non_private_static_fields.required_modifiers = static
dotnet_naming_style.non_private_static_field_style.capitalization = pascal_case
# Non-private readonly fields are PascalCase
dotnet_naming_rule.non_private_readonly_fields_should_be_pascal_case.severity = suggestion
dotnet_naming_rule.non_private_readonly_fields_should_be_pascal_case.symbols = non_private_readonly_fields
dotnet_naming_rule.non_private_readonly_fields_should_be_pascal_case.style = non_private_readonly_field_style
dotnet_naming_symbols.non_private_readonly_fields.applicable_kinds = field
dotnet_naming_symbols.non_private_readonly_fields.applicable_accessibilities = public, protected, internal, protected_internal, private_protected
dotnet_naming_symbols.non_private_readonly_fields.required_modifiers = readonly
dotnet_naming_style.non_private_readonly_field_style.capitalization = pascal_case
# Non-private Constants are PascalCase
dotnet_naming_rule.non_private_constants_should_be_camel_case.severity = suggestion
dotnet_naming_rule.non_private_constants_should_be_camel_case.symbols = non_private_constants
dotnet_naming_rule.non_private_constants_should_be_camel_case.style = non_private_constant_style
dotnet_naming_symbols.non_private_constants.applicable_kinds = field, local
dotnet_naming_symbols.non_private_constants.applicable_accessibilities = public, protected, internal, protected_internal, private_protected
dotnet_naming_symbols.non_private_constants.required_modifiers = const
dotnet_naming_style.non_private_constant_style.capitalization = pascal_case
# Non-private Instance fields are PascalCase and start with _
dotnet_naming_rule.non_private_instance_fields_should_be_camel_case.severity = suggestion
dotnet_naming_rule.non_private_instance_fields_should_be_camel_case.symbols = non_private_instance_fields
dotnet_naming_rule.non_private_instance_fields_should_be_camel_case.style = non_private_instance_field_style
dotnet_naming_symbols.non_private_instance_fields.applicable_kinds = field
dotnet_naming_symbols.non_private_instance_fields.applicable_accessibilities = public, protected, internal, protected_internal, private_protected
dotnet_naming_style.non_private_instance_field_style.capitalization = pascal_case
# Constants are camelCase and start with _
dotnet_naming_rule.constants_should_be_camel_case.severity = suggestion
dotnet_naming_rule.constants_should_be_camel_case.symbols = constants
dotnet_naming_rule.constants_should_be_camel_case.style = constant_style
dotnet_naming_symbols.constants.applicable_kinds = field, local
dotnet_naming_symbols.constants.required_modifiers = const
dotnet_naming_style.constant_style.capitalization = camel_case
dotnet_naming_style.constant_style.required_prefix = _
# Static fields are camelCase and start with s_
dotnet_naming_rule.static_fields_should_be_camel_case.severity = suggestion
dotnet_naming_rule.static_fields_should_be_camel_case.symbols = static_fields
dotnet_naming_rule.static_fields_should_be_camel_case.style = static_field_style
dotnet_naming_symbols.static_fields.applicable_kinds = field
dotnet_naming_symbols.static_fields.required_modifiers = static
dotnet_naming_style.static_field_style.capitalization = camel_case
dotnet_naming_style.static_field_style.required_prefix = s_
# Instance fields are camelCase and start with _
dotnet_naming_rule.instance_fields_should_be_camel_case.severity = suggestion
dotnet_naming_rule.instance_fields_should_be_camel_case.symbols = instance_fields
dotnet_naming_rule.instance_fields_should_be_camel_case.style = instance_field_style
dotnet_naming_symbols.instance_fields.applicable_kinds = field
dotnet_naming_style.instance_field_style.capitalization = camel_case
dotnet_naming_style.instance_field_style.required_prefix = _
# Locals and parameters are camelCase
dotnet_naming_rule.locals_should_be_camel_case.severity = suggestion
dotnet_naming_rule.locals_should_be_camel_case.symbols = locals_and_parameters
dotnet_naming_rule.locals_should_be_camel_case.style = camel_case_style
dotnet_naming_symbols.locals_and_parameters.applicable_kinds = parameter, local
dotnet_naming_style.camel_case_style.capitalization = camel_case
# Local functions are PascalCase
dotnet_naming_rule.local_functions_should_be_pascal_case.severity = suggestion
dotnet_naming_rule.local_functions_should_be_pascal_case.symbols = local_functions
dotnet_naming_rule.local_functions_should_be_pascal_case.style = local_function_style
dotnet_naming_symbols.local_functions.applicable_kinds = local_function
dotnet_naming_style.local_function_style.capitalization = pascal_case
# By default, name items with PascalCase
dotnet_naming_rule.members_should_be_pascal_case.severity = suggestion
dotnet_naming_rule.members_should_be_pascal_case.symbols = all_members
dotnet_naming_rule.members_should_be_pascal_case.style = pascal_case_style
dotnet_naming_symbols.all_members.applicable_kinds = *
dotnet_naming_style.pascal_case_style.capitalization = pascal_case
# CSharp code style settings:
[*.cs]
# Newline settings
csharp_new_line_before_open_brace = all
csharp_new_line_before_else = true
csharp_new_line_before_catch = true
csharp_new_line_before_finally = true
csharp_new_line_before_members_in_object_initializers = true
csharp_new_line_before_members_in_anonymous_types = true
csharp_new_line_between_query_expression_clauses = true
# Indentation preferences
csharp_indent_block_contents = true
csharp_indent_braces = false
csharp_indent_case_contents = true
csharp_indent_case_contents_when_block = true
csharp_indent_switch_labels = true
csharp_indent_labels = flush_left
# Prefer "var" everywhere
csharp_style_var_for_built_in_types = true:suggestion
csharp_style_var_when_type_is_apparent = true:suggestion
csharp_style_var_elsewhere = true:suggestion
# Prefer method-like constructs to have a block body
csharp_style_expression_bodied_methods = false:none
csharp_style_expression_bodied_constructors = false:none
csharp_style_expression_bodied_operators = false:none
# Prefer property-like constructs to have an expression-body
csharp_style_expression_bodied_properties = true:none
csharp_style_expression_bodied_indexers = true:none
csharp_style_expression_bodied_accessors = true:none
# Suggest more modern language features when available
csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion
csharp_style_pattern_matching_over_as_with_null_check = true:suggestion
csharp_style_inlined_variable_declaration = true:suggestion
csharp_style_throw_expression = true:suggestion
csharp_style_conditional_delegate_call = true:suggestion
# Space preferences
csharp_space_after_cast = false
csharp_space_after_colon_in_inheritance_clause = true
csharp_space_after_comma = true
csharp_space_after_dot = false
csharp_space_after_keywords_in_control_flow_statements = true
csharp_space_after_semicolon_in_for_statement = true
csharp_space_around_binary_operators = before_and_after
csharp_space_around_declaration_statements = do_not_ignore
csharp_space_before_colon_in_inheritance_clause = true
csharp_space_before_comma = false
csharp_space_before_dot = false
csharp_space_before_open_square_brackets = false
csharp_space_before_semicolon_in_for_statement = false
csharp_space_between_empty_square_brackets = false
csharp_space_between_method_call_empty_parameter_list_parentheses = false
csharp_space_between_method_call_name_and_opening_parenthesis = false
csharp_space_between_method_call_parameter_list_parentheses = false
csharp_space_between_method_declaration_empty_parameter_list_parentheses = false
csharp_space_between_method_declaration_name_and_open_parenthesis = false
csharp_space_between_method_declaration_parameter_list_parentheses = false
csharp_space_between_parentheses = false
csharp_space_between_square_brackets = false
# Blocks are allowed
csharp_prefer_braces = true:silent
csharp_preserve_single_line_blocks = true
csharp_preserve_single_line_statements = true
#resharper-rider
resharper_wrap_before_first_method_call = false
resharper_csharp_wrap_after_invocation_lpar = true
resharper_csharp_wrap_arguments_style = chop_if_long
resharper_csharp_wrap_before_declaration_rpar = false
resharper_csharp_wrap_parameters_style = chop_if_long
resharper_keep_existing_arrangement = true
resharper_keep_existing_attribute_arrangement = true
resharper_keep_existing_declaration_parens_arrangement = true
resharper_keep_existing_embedded_arrangement = false
resharper_keep_existing_initializer_arrangement = true
resharper_place_simple_embedded_statement_on_same_line = false
resharper_wrap_before_primary_constructor_declaration_lpar = false
place_single_method_argument_lambda_on_same_line = false
Executable
+262
View File
@@ -0,0 +1,262 @@
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
# User-specific files
*.suo
*.user
*.userosscache
*.sln.docstates
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
bld/
[Bb]in/
[Oo]bj/
[Ll]og/
# Visual Studio 2015 cache/options directory
.vs/
# Uncomment if you have tasks that create the project's static files in wwwroot
#wwwroot/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
# NUNIT
*.VisualState.xml
TestResult.xml
# Build Results of an ATL Project
[Dd]ebugPS/
[Rr]eleasePS/
dlldata.c
# DNX
project.lock.json
project.fragment.lock.json
artifacts/
*_i.c
*_p.c
*_i.h
*.ilk
*.meta
*.obj
*.pch
*.pdb
*.pgc
*.pgd
*.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*.log
*.vspscc
*.vssscc
.builds
*.pidb
*.svclog
*.scc
# Chutzpah Test files
_Chutzpah*
# Visual C++ cache files
ipch/
*.aps
*.ncb
*.opendb
*.opensdf
*.sdf
*.cachefile
*.VC.db
*.VC.VC.opendb
# Visual Studio profiler
*.psess
*.vsp
*.vspx
*.sap
# TFS 2012 Local Workspace
$tf/
# Guidance Automation Toolkit
*.gpState
# ReSharper is a .NET coding add-in
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
# JustCode is a .NET coding add-in
.JustCode
# TeamCity is a build add-in
_TeamCity*
# DotCover is a Code Coverage Tool
*.dotCover
# NCrunch
_NCrunch_*
.*crunch*.local.xml
nCrunchTemp_*
# MightyMoose
*.mm.*
AutoTest.Net/
# Web workbench (sass)
.sass-cache/
# Installshield output folder
[Ee]xpress/
# DocProject is a documentation generator add-in
DocProject/buildhelp/
DocProject/Help/*.HxT
DocProject/Help/*.HxC
DocProject/Help/*.hhc
DocProject/Help/*.hhk
DocProject/Help/*.hhp
DocProject/Help/Html2
DocProject/Help/html
# Click-Once directory
publish/
# Publish Web Output
*.[Pp]ublish.xml
*.azurePubxml
# TODO: Comment the next line if you want to checkin your web deploy settings
# but database connection strings (with potential passwords) will be unencrypted
#*.pubxml
*.publishproj
# Microsoft Azure Web App publish settings. Comment the next line if you want to
# checkin your Azure Web App publish settings, but sensitive information contained
# in these scripts will be unencrypted
PublishScripts/
# NuGet Packages
*.nupkg
# The packages folder can be ignored because of Package Restore
**/packages/*
# except build/, which is used as an MSBuild target.
!**/packages/build/
# Uncomment if necessary however generally it will be regenerated when needed
#!**/packages/repositories.config
# NuGet v3's project.json files produces more ignoreable files
*.nuget.props
*.nuget.targets
# Microsoft Azure Build Output
csx/
*.build.csdef
# Microsoft Azure Emulator
ecf/
rcf/
# Windows Store app package directories and files
AppPackages/
BundleArtifacts/
Package.StoreAssociation.xml
_pkginfo.txt
# Visual Studio cache files
# files ending in .cache can be ignored
*.[Cc]ache
# but keep track of directories ending in .cache
!*.[Cc]ache/
# Others
ClientBin/
~$*
*~
*.dbmdl
*.dbproj.schemaview
*.jfm
*.publishsettings
orleans.codegen.cs
# Since there are multiple workflows, uncomment next line to ignore bower_components
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
#bower_components/
# RIA/Silverlight projects
Generated_Code/
# Backup & report files from converting an old project file
# to a newer Visual Studio version. Backup files are not needed,
# because we have git ;-)
_UpgradeReport_Files/
Backup*/
UpgradeLog*.XML
UpgradeLog*.htm
# SQL Server files
*.mdf
*.ldf
# Business Intelligence projects
*.rdl.data
*.bim.layout
*.bim_*.settings
# Microsoft Fakes
FakesAssemblies/
# GhostDoc plugin setting file
*.GhostDoc.xml
# Node.js Tools for Visual Studio
.ntvs_analysis.dat
# Visual Studio 6 build log
*.plg
# Visual Studio 6 workspace options file
*.opt
# Visual Studio LightSwitch build output
**/*.HTMLClient/GeneratedArtifacts
**/*.DesktopClient/GeneratedArtifacts
**/*.DesktopClient/ModelManifest.xml
**/*.Server/GeneratedArtifacts
**/*.Server/ModelManifest.xml
_Pvt_Extensions
# Paket dependency manager
.paket/paket.exe
paket-files/
# FAKE - F# Make
.fake/
# JetBrains Rider
.idea/
*.sln.iml
# CodeRush
.cr/
# Python Tools for Visual Studio (PTVS)
__pycache__/
*.pyc
*.diagsession
artifacts/
+8
View File
@@ -0,0 +1,8 @@
<Solution>
<Folder Name="/src/">
<Project Path="src/Minesweeper/Minesweeper.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/Minesweeper.Tests/Minesweeper.Tests.csproj" />
</Folder>
</Solution>
+111
View File
@@ -0,0 +1,111 @@
# Minesweeper
A terminal game of Minesweeper in C# for .NET 10, plus a built-in solver so the game can be
played, scripted, and tested without a keyboard.
```bash
dotnet run --project src/Minesweeper # play (Beginner)
dotnet run --project src/Minesweeper -- -d expert
dotnet run --project src/Minesweeper -- --auto # watch the solver play
```
## Playing
| Key | Action |
| --- | --- |
| arrow keys, `Home`, `End` | move the selector |
| `space` / `enter` | open a square, or chord when standing on a number |
| `c` | chord the number under the selector |
| `f` (or `/`) | flag a suspect square |
| `q` (or `?`) | question mark a square |
| `p` | pause (freezes the clock) |
| `n` | new game on the same board |
| `d`, `1`, `2`, `3` | next difficulty, or jump to Beginner / Intermediate / Expert |
| `s` | dump the board as text, then return |
| `h` or `F1` | key list |
| `esc` | quit and print the final board |
Rules are the classic ones: the first click is never a mine (and by default opens an area),
a number with exactly as many flags around it as it needs opens the rest, and flagged squares
hold back a flood fill. Losing shows every mine and marks flags that were on safe ground.
## Options
`--help` lists everything. Highlights:
```bash
minesweeper -d intermediate # 16x16 with 40 mines
minesweeper -s 30x20x120 # any board up to 120x60
minesweeper -s 12x8 -m 5 # size plus mine count
minesweeper --seed 42 # reproducible mines and solver guesses
minesweeper --compact --ascii # one plain ASCII character per square
minesweeper --no-color # monochrome (NO_COLOR is honoured too)
minesweeper --auto-trace --seed 7 # print every solver decision
minesweeper --list-levels # show the presets
```
## Auto mode
`--auto` runs the solver in `src/Minesweeper/Game/Solver.cs`: it applies the two deductions
every player uses (a number touching exactly as many hidden squares as it still needs is all
mines; a satisfied number chords), then guesses the lowest risk square when logic runs out.
It prints the finished board and exits with `0` on a win, `1` on a loss, which makes it a
handy end-to-end check:
```bash
dotnet run --project src/Minesweeper -- --auto -d beginner --seed 7
```
## Layout
| Path | What lives there |
| --- | --- |
| `src/Minesweeper/Game/Board.cs` | grid, mine placement, flood fill, chording |
| `src/Minesweeper/Game/MinesweeperGame.cs` | one round: safe first click, flags, win/loss |
| `src/Minesweeper/Game/Solver.cs` | the deduction and guessing strategy |
| `src/Minesweeper/Cli/ConsoleRenderer.cs` | flicker-free drawing (only changed squares are written) |
| `src/Minesweeper/Cli/InteractiveRunner.cs` | keyboard loop, clock, pause, difficulty switching |
| `src/Minesweeper/Cli/Options.cs` | command line parsing |
| `tests/Minesweeper.Tests` | rules, solver, difficulty and CLI tests |
The game logic has no console dependencies, so it can be unit tested or driven headlessly.
## Building and testing
```bash
dotnet build # or dotnet run --project src/Minesweeper
dotnet test # 65 tests
```
## Standalone binaries
`./publish.sh` produces a self-contained, trimmed, single-file binary for each runtime under
`artifacts/<runtime>/`. Nothing has to be installed on the machine that runs them.
```bash
./publish.sh # linux-x64 and win-x64
./publish.sh linux-arm64 # any runtime identifier
dotnet publish src/Minesweeper -c Release -r win-x64 --self-contained \
-p:PublishTrimmed=true -p:PublishSingleFile=true # the same thing by hand
```
| Binary | Size | Runs on |
| --- | --- | --- |
| `artifacts/linux-x64/minesweeper` | 14 MB | 64-bit Linux with glibc |
| `artifacts/win-x64/minesweeper.exe` | 13 MB | 64-bit Windows 10 or newer |
Trimming keeps only the runtime the game touches, and the publish is warning free. Windows
binaries can be cross-compiled from Linux or macOS; `--self-contained false` gives a small
framework-dependent binary instead.
## Notes
* Interactive play needs a real terminal. With output redirected the game says so and points
at `--auto`.
* Board squares are two characters wide so the field looks square; the game quietly drops to
one character when the terminal is too narrow, and refuses boards that still cannot fit.
* Colors come off automatically when `NO_COLOR` is set; `--ascii` swaps the shaded blocks,
flags and mines for `#`, `F`, `*` if a terminal struggles with them.
* A board keeps at least one safe square: asking for more mines than fit is an error, and a
board too small for the safe zone simply ends up with fewer mines.
Executable
+33
View File
@@ -0,0 +1,33 @@
#!/usr/bin/env bash
# Builds self-contained, trimmed, single-file binaries for Linux and Windows.
# Usage: ./publish.sh [linux-x64|win-x64|linux-arm64|win-arm64 ...] (default: linux-x64 win-x64)
set -euo pipefail
cd "$(dirname "$0")"
rids=("$@")
if [ ${#rids[@]} -eq 0 ]; then
rids=(linux-x64 win-x64)
fi
for rid in "${rids[@]}"; do
out="artifacts/$rid"
echo "==> publishing $rid"
dotnet publish src/Minesweeper \
--configuration Release \
--runtime "$rid" \
--self-contained true \
-p:PublishTrimmed=true \
-p:PublishSingleFile=true \
-p:IncludeNativeLibrariesForSelfExtract=true \
-p:DebugType=none \
-p:DebugSymbols=false \
--output "$out"
binary="$out/minesweeper"
case "$rid" in
win-*) binary="$binary.exe" ;;
esac
du -h "$binary" | sed 's/^/ /'
done
echo "done - binaries are under artifacts/<runtime>/"
+39
View File
@@ -0,0 +1,39 @@
using System.Diagnostics;
using Minesweeper.Game;
namespace Minesweeper.Cli;
/// <summary>Hands a board to <see cref="Solver"/> and reports what happened.</summary>
public static class AutoRunner
{
public static int Run(MinesweeperGame game, Options options, Random random)
{
ArgumentNullException.ThrowIfNull(game);
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(random);
var board = game.Board;
var seed = options.Seed.HasValue ? options.Seed.Value.ToString() : "random";
Console.Out.WriteLine(
$"{options.Difficulty.Name} {board.Width}x{board.Height} {board.MineCount} mines seed {seed}");
var action = options.AutoTrace ? new Action<string>(Console.Out.WriteLine) : null;
var solver = new Solver(game, random, options.AutoMaxSteps, action);
var clock = Stopwatch.StartNew();
var result = solver.Run();
clock.Stop();
if (options.Snapshot)
{
Console.Out.WriteLine();
Console.Out.WriteLine(TextRender.Board(game));
}
Console.Out.WriteLine(
$"{result.Status} steps {result.Steps} opened {game.RevealedCount}/{game.TotalSafeCells} " +
$"opens {result.Openings} chords {result.Chords} flags {result.Flags} guesses {result.Guesses} " +
$"time {clock.Elapsed.TotalSeconds:0.##}s");
return result.Status == GameStatus.Won ? 0 : 1;
}
}
+405
View File
@@ -0,0 +1,405 @@
using Minesweeper.Game;
namespace Minesweeper.Cli;
public sealed record Frame(MinesweeperGame Game, CellRef Cursor, int Seconds, bool Paused, string Message, bool Help);
/// <summary>
/// Draws the board with the console cursor, redrawing only the squares whose glyph or color
/// actually changed so the frame never flickers.
/// </summary>
public sealed class ConsoleRenderer
{
private static readonly ConsoleColor[] s_numberColors =
[
ConsoleColor.Gray, ConsoleColor.Blue, ConsoleColor.Green, ConsoleColor.Red,
ConsoleColor.DarkBlue, ConsoleColor.DarkRed, ConsoleColor.DarkCyan, ConsoleColor.White,
ConsoleColor.DarkGray,
];
private static readonly string[] s_helpBlock =
[
"",
"Keys",
" arrows move space/enter open, or chord on a number",
" f flag q question mark",
" p pause n new game (same board size)",
" d next level 1 2 3 beginner / intermediate / expert",
" s dump board h toggle this list",
" esc quit",
"Rules",
" The first click never hits a mine. A number with exactly as many flags as it needs",
" opens its remaining neighbors (chording). Flags hold back a flood fill.",
];
private readonly bool _color;
private readonly int _margin;
private readonly int _cellWidth;
private readonly int _frameWidth;
private readonly GlyphSet _glyphs;
private readonly Board _board;
private readonly string _levelName;
private readonly List<List<Glyph>> _previous = [];
private readonly ConsoleColor _defaultForeground;
private readonly ConsoleColor _defaultBackground;
private ConsoleColor? _appliedForeground;
private ConsoleColor? _appliedBackground;
public ConsoleRenderer(Options options, Board board, string levelName)
{
ArgumentNullException.ThrowIfNull(options);
_board = board ?? throw new ArgumentNullException(nameof(board));
_levelName = levelName ?? throw new ArgumentNullException(nameof(levelName));
_color = options.Color;
_glyphs = options.Ascii ? GlyphSet.Ascii : GlyphSet.Unicode;
_cellWidth = options.Compact ? 1 : 2;
_margin = board.Width * _cellWidth + 1 <= SafeWindowWidth() ? 1 : 0;
_frameWidth = Math.Min(Math.Max(_margin + board.Width * _cellWidth, 46), SafeWindowWidth());
if (_color)
{
_defaultForeground = Console.ForegroundColor;
_defaultBackground = Console.BackgroundColor;
}
}
/// <summary>
/// Confirms the board fits the terminal, quietly switching to one character squares when
/// two would overflow.
/// </summary>
public static bool Fits(Options options, Board board, out string? error)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(board);
error = null;
var width = SafeWindowWidth();
var height = SafeWindowHeight();
if (board.Height + 7 > height)
{
error = $"A {board.Height} row board needs {board.Height + 7} terminal rows but only {height} are available. " +
"Use a shorter board with --size or maximize the window.";
return false;
}
if (!options.Compact && board.Width * 2 + 1 > width)
options.Compact = true;
if (board.Width + 1 > width)
{
error = $"A {board.Width} column board needs {board.Width + 1} terminal columns but only {width} are available. " +
"Use a narrower board with --size or maximize the window.";
return false;
}
return true;
}
/// <summary>Number of terminal rows the frame occupies.</summary>
public int Height(bool help) => _board.Height + 6 + (help ? s_helpBlock.Length : 0);
public void Draw(Frame frame)
{
ArgumentNullException.ThrowIfNull(frame);
var panel = BuildPanel(frame);
for (var row = 0; row < panel.Count; row++)
{
var glyphs = panel[row];
var stale = row < _previous.Count ? _previous[row] : null;
var column = 0;
for (var index = 0; index < glyphs.Count; index++)
{
var glyph = glyphs[index];
if (stale is not null && index < stale.Count && stale[index] == glyph)
{
column += glyph.Text.Length;
continue;
}
Console.SetCursorPosition(column, row);
Write(glyph);
column += glyph.Text.Length;
}
}
// A shorter frame (help turned off) must not leave ghost rows behind.
for (var row = panel.Count; row < _previous.Count; row++)
{
Console.SetCursorPosition(0, row);
Reset();
Console.Out.Write(new string(' ', _previous[row].Sum(glyph => glyph.Text.Length)));
}
_previous.Clear();
_previous.AddRange(panel);
Reset();
Console.Out.Flush();
}
/// <summary>Moves the terminal caret onto the selected square, so color is not the only cue.</summary>
public void PlaceCaret(CellRef cursor)
{
var column = _margin + cursor.X * _cellWidth;
if (column < SafeWindowWidth())
Console.SetCursorPosition(column, GridTop + cursor.Y);
}
/// <summary>Forgets the diff cache, for example after <see cref="Console.Clear"/>.</summary>
public void Forget() => _previous.Clear();
private static int GridTop => 3;
private List<List<Glyph>> BuildPanel(Frame frame)
{
var game = frame.Game;
var rows = new List<List<Glyph>>();
var counters = new RowBuilder(this, _frameWidth);
counters.Append(" Mines ", ConsoleColor.DarkGray);
counters.Append(FormatCounter(game.MinesRemaining), ConsoleColor.Yellow);
counters.Append(" Time ", ConsoleColor.DarkGray);
counters.Append(FormatCounter(frame.Seconds), ConsoleColor.Cyan);
counters.Append($" Square {frame.Cursor.X},{frame.Cursor.Y}", ConsoleColor.DarkGray);
counters.Pad();
rows.Add(counters.Glyphs);
var title = new RowBuilder(this, _frameWidth);
title.Append(" Minesweeper", ConsoleColor.Gray);
title.Append($" {_levelName} {_board.Width}x{_board.Height}", ConsoleColor.DarkGray);
title.Append($" {game.TotalSafeCells - game.RevealedCount} safe left", ConsoleColor.DarkGray);
title.Pad();
rows.Add(title.Glyphs);
rows.Add(Rule());
var finished = game.Status != GameStatus.Playing;
for (var y = 0; y < _board.Height; y++)
{
var row = new List<Glyph>();
var used = 0;
if (_margin > 0)
{
row.Add(new Glyph(" ", null, null));
used++;
}
for (var x = 0; x < _board.Width; x++)
{
var glyph = CellGlyph(_board.At(x, y), frame.Cursor.X == x && frame.Cursor.Y == y, finished);
row.Add(glyph);
used += glyph.Text.Length;
}
for (; used < _frameWidth; used++)
row.Add(new Glyph(" ", null, null));
rows.Add(row);
}
rows.Add(Rule());
var (statusText, statusColor) = game.Status switch
{
GameStatus.Won => ($"Cleared in {frame.Seconds}s", ConsoleColor.Green),
GameStatus.Lost => ($"Boom - a mine, after {frame.Seconds}s", ConsoleColor.Red),
_ when frame.Paused => ("Paused", ConsoleColor.Magenta),
_ => ("Playing", ConsoleColor.Gray),
};
var status = new RowBuilder(this, _frameWidth);
status.Append($" {statusText}", statusColor);
status.Append(_frameWidth >= 86
? " space open f flag q ? p pause n new d level s dump h help esc quit"
: " h help n new esc quit", ConsoleColor.DarkGray);
status.Pad();
rows.Add(status.Glyphs);
var message = new RowBuilder(this, _frameWidth);
message.Append($" {frame.Message}", ConsoleColor.DarkGray);
message.Pad();
rows.Add(message.Glyphs);
if (frame.Help)
{
foreach (var line in s_helpBlock)
{
var help = new RowBuilder(this, _frameWidth);
var heading = line is "Keys" or "Rules";
help.Append(" " + line, heading ? ConsoleColor.Gray : ConsoleColor.DarkGray);
help.Pad();
rows.Add(help.Glyphs);
}
}
return rows;
}
private List<Glyph> Rule()
{
var row = new List<Glyph>();
for (var column = 0; column < _frameWidth; column++)
row.Add(new Glyph(_glyphs.Separator, ConsoleColor.DarkGray, null));
return row;
}
private Glyph CellGlyph(Cell cell, bool selected, bool finished)
{
string text;
ConsoleColor? foreground;
ConsoleColor? background;
if (cell.IsFlagged)
{
var wrong = finished && !cell.IsMine;
text = Tile(wrong ? _glyphs.WrongFlag : _glyphs.Flag);
foreground = wrong ? ConsoleColor.Red : ConsoleColor.Yellow;
background = ConsoleColor.DarkGray;
}
else if (cell.State == CellState.Question)
{
text = Tile(_glyphs.Question);
foreground = ConsoleColor.Black;
background = ConsoleColor.Gray;
}
else if (cell.IsRevealed)
{
if (cell.IsMine)
{
text = Tile(_glyphs.Mine);
foreground = cell.Exploded ? ConsoleColor.White : ConsoleColor.Red;
background = cell.Exploded ? ConsoleColor.Red : null;
}
else if (cell.AdjacentMines == 0)
{
text = Tile(" ");
foreground = null;
background = null;
}
else
{
var digit = cell.AdjacentMines.ToString();
text = _cellWidth == 2 ? digit + " " : digit;
foreground = s_numberColors[Math.Min(cell.AdjacentMines, s_numberColors.Length - 1)];
background = null;
}
}
else
{
text = Tile(_glyphs.Hidden);
foreground = ConsoleColor.DarkGray;
background = ConsoleColor.Gray;
}
if (selected)
background = cell.IsRevealed ? ConsoleColor.Gray : ConsoleColor.DarkCyan;
return new Glyph(text, _color ? foreground : null, _color ? background : null);
}
private string Tile(string glyph) => _cellWidth == 2 ? glyph + glyph : glyph;
private static string FormatCounter(int value) => value switch
{
> 999 => "999+",
>= 0 => value.ToString("D3"),
< -9 => "--",
_ => "-" + Math.Abs(value),
};
private static int SafeWindowWidth()
{
try
{
return Console.WindowWidth > 0 ? Console.WindowWidth : 80;
}
catch (Exception exception) when (exception is InvalidOperationException or IOException)
{
return 80;
}
}
private static int SafeWindowHeight()
{
try
{
return Console.WindowHeight > 0 ? Console.WindowHeight : 24;
}
catch (Exception exception) when (exception is InvalidOperationException or IOException)
{
return 24;
}
}
private void Write(Glyph glyph)
{
if (_color)
{
var foreground = glyph.Foreground ?? _defaultForeground;
var background = glyph.Background ?? _defaultBackground;
if (foreground != _appliedForeground)
{
Console.ForegroundColor = foreground;
_appliedForeground = foreground;
}
if (background != _appliedBackground)
{
Console.BackgroundColor = background;
_appliedBackground = background;
}
}
Console.Out.Write(glyph.Text);
}
private void Reset()
{
if (!_color)
return;
Console.ForegroundColor = _defaultForeground;
Console.BackgroundColor = _defaultBackground;
_appliedForeground = null;
_appliedBackground = null;
}
private readonly record struct Glyph(string Text, ConsoleColor? Foreground, ConsoleColor? Background);
private sealed record GlyphSet(string Hidden, string Mine, string Flag, string Question, string WrongFlag, string Separator)
{
public static GlyphSet Unicode { get; } = new("▒", "✱", "⚑", "?", "✗", "─");
public static GlyphSet Ascii { get; } = new("#", "*", "F", "?", "X", "-");
}
private sealed class RowBuilder
{
private readonly ConsoleRenderer _renderer;
private readonly int _width;
public RowBuilder(ConsoleRenderer renderer, int width)
{
_renderer = renderer;
_width = width;
Glyphs = [];
}
public List<Glyph> Glyphs { get; }
public RowBuilder Append(string text, ConsoleColor? foreground = null)
{
foreach (var character in text)
{
if (Glyphs.Count >= _width)
break;
Glyphs.Add(new Glyph(character.ToString(), _renderer._color ? foreground : null, null));
}
return this;
}
public void Pad()
{
while (Glyphs.Count < _width)
Glyphs.Add(new Glyph(" ", null, null));
}
}
}
+46
View File
@@ -0,0 +1,46 @@
using System.Diagnostics;
namespace Minesweeper.Cli;
/// <summary>Round timer that starts on the first click and can be paused.</summary>
public sealed class GameClock
{
private readonly Stopwatch _stopwatch = new();
public bool IsRunning { get; private set; }
public bool Paused { get; private set; }
public int ElapsedSeconds => (int)_stopwatch.Elapsed.TotalSeconds;
public void Start()
{
if (IsRunning)
return;
IsRunning = true;
_stopwatch.Start();
}
public void TogglePause()
{
if (!IsRunning)
return;
if (Paused)
{
Paused = false;
_stopwatch.Start();
}
else
{
Paused = true;
_stopwatch.Stop();
}
}
public void Stop()
{
_stopwatch.Stop();
Paused = false;
IsRunning = false;
}
}
+57
View File
@@ -0,0 +1,57 @@
using System.Text;
using Minesweeper.Game;
namespace Minesweeper.Cli;
public static class HelpText
{
public static void Write(TextWriter output)
{
output.Write(Usage);
}
public static string Usage { get; } = BuildUsage();
private static string BuildUsage()
{
var builder = new StringBuilder();
builder.AppendLine("minesweeper - a terminal game of Minesweeper");
builder.AppendLine();
builder.AppendLine("Usage: minesweeper [options]");
builder.AppendLine();
builder.AppendLine("Options:");
builder.AppendLine(" -d, --difficulty <name> beginner | intermediate | expert");
builder.AppendLine(" (aliases: novice, normal, hard, b/i/e, 1/2/3)");
builder.AppendLine(" -s, --size <WxHxM> custom board, e.g. 14x10x25 (mines optional)");
builder.AppendLine(" -m, --mines <count> override the mine count for the chosen size");
builder.AppendLine(" --seed <number> reproducible mine placement and guesses");
builder.AppendLine(" --no-safe-zone first click is only guaranteed mine-free itself");
builder.AppendLine(" --no-question-marks drop the ? mark, flags toggle straight off");
builder.AppendLine(" --compact one character per square (narrow terminals)");
builder.AppendLine(" --ascii plain ASCII squares instead of shaded blocks");
builder.AppendLine(" --no-color monochrome output (also honors NO_COLOR)");
builder.AppendLine(" --auto let the built-in solver play and print the board");
builder.AppendLine(" --auto-trace one line per solver decision (implies --auto)");
builder.AppendLine(" --auto-max-steps <n> solver step budget (default 20000)");
builder.AppendLine(" --no-snapshot do not print the final board as text");
builder.AppendLine(" --list-levels show the difficulty presets");
builder.AppendLine(" -h, --help show this help");
builder.AppendLine();
builder.AppendLine("Playing:");
builder.AppendLine(" arrow keys move, space/enter opens (or chords on a number),");
builder.AppendLine(" f flags, q question marks, p pauses, n new game, d next difficulty,");
builder.AppendLine(" s prints the board as text, h shows help, esc quits.");
builder.AppendLine();
builder.AppendLine("The first click is always safe and, by default, opens an area.");
builder.AppendLine("--auto exits with 0 when the solver wins and 1 when it steps on a mine.");
return builder.ToString();
}
public static string Levels()
{
var builder = new StringBuilder();
foreach (var preset in DifficultyProfile.Presets)
builder.AppendLine($"{preset.Name,-13} {preset.Width,3}x{preset.Height,-3} {preset.Mines,3} mines {preset.MineDensity}");
return builder.ToString();
}
}
+293
View File
@@ -0,0 +1,293 @@
using Minesweeper.Game;
namespace Minesweeper.Cli;
public enum RoundOutcome
{
Quit,
NewGame,
/// <summary>The round could not start, for example a board too large for the terminal.</summary>
Failed,
}
/// <summary>The keyboard-driven game loop.</summary>
public static class InteractiveRunner
{
private static volatile bool s_interrupted;
public static int Run(Options options, Random random)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(random);
if (Console.IsInputRedirected || Console.IsOutputRedirected)
{
Console.Error.WriteLine(
"Interactive play needs a real terminal. Run with --auto to watch the solver play and print the board.");
return 3;
}
var difficulty = options.Difficulty;
MinesweeperGame? lastGame = null;
var failed = false;
Console.CancelKeyPress += (_, eventArgs) =>
{
eventArgs.Cancel = true;
s_interrupted = true;
};
try
{
Console.CursorVisible = false;
while (!s_interrupted)
{
var round = PlayRound(options, random, difficulty);
if (round.Game is not null)
lastGame = round.Game;
if (round.NextDifficulty is not null)
difficulty = round.NextDifficulty;
if (round.Outcome is RoundOutcome.Quit or RoundOutcome.Failed)
{
failed = round.Outcome == RoundOutcome.Failed;
break;
}
}
}
finally
{
RestoreTerminal();
}
if (options.Snapshot && lastGame is not null && !failed)
{
// The frame leaves the caret in the middle of the board, so start a clean screen.
Console.Clear();
Console.Out.WriteLine();
Console.Out.Write(TextRender.Board(lastGame));
Console.Out.WriteLine();
Console.Out.WriteLine(
$"Status {lastGame.Status} mines {lastGame.Board.MineCount} flags {lastGame.FlagsPlaced} " +
$"open squares {lastGame.RevealedCount}/{lastGame.TotalSafeCells}");
Console.Out.WriteLine();
}
if (failed)
return 4;
return s_interrupted ? 130 : 0;
}
private static (RoundOutcome Outcome, MinesweeperGame? Game, DifficultyProfile? NextDifficulty) PlayRound(
Options options,
Random random,
DifficultyProfile difficulty)
{
var board = new Board(difficulty.Width, difficulty.Height, difficulty.Mines);
if (!ConsoleRenderer.Fits(options, board, out var fitError))
{
Console.Error.WriteLine(fitError);
return (RoundOutcome.Failed, null, null);
}
var game = new MinesweeperGame(board, random) { ReserveSafeZone = options.ReserveSafeZone };
var renderer = new ConsoleRenderer(options, board, difficulty.Name);
var clock = new GameClock();
var cursor = new CellRef(board.Width / 2, board.Height / 2);
var message = "Arrow keys move, space opens. The first square is always safe.";
var help = false;
Console.Clear();
renderer.Forget();
void Move(int dx, int dy) => cursor = new CellRef(
Math.Clamp(cursor.X + dx, 0, board.Width - 1),
Math.Clamp(cursor.Y + dy, 0, board.Height - 1));
void OpenOrChord(bool forceChord)
{
if (!game.HasOpenedFirstCell)
clock.Start();
var wasOpen = board.At(cursor).IsRevealed;
var result = forceChord || wasOpen ? game.Chord(cursor) : game.Open(cursor);
if (game.Status != GameStatus.Playing)
clock.Stop();
message = game.Status switch
{
GameStatus.Lost => "Boom. n for a new board, esc to quit.",
GameStatus.Won => $"Cleared in {clock.ElapsedSeconds}s. n for a new board.",
_ when !result.AnyOpened => forceChord || wasOpen
? $"Square {cursor.X},{cursor.Y} needs exactly {board.At(cursor).AdjacentMines} flags to chord."
: "Nothing to do there.",
_ => $"+{result.Opened.Count} square{(result.Opened.Count == 1 ? "" : "s")} opened",
};
}
void Flag()
{
if (game.ToggleFlag(cursor))
message = board.At(cursor).IsFlagged ? "Flagged." : "Flag cleared.";
else if (game.Status != GameStatus.Playing)
message = "Round over. n starts a new board.";
else
message = "That square is already open.";
}
void Question()
{
if (game.ToggleQuestion(cursor, options.QuestionMarks))
message = board.At(cursor).State == CellState.Question ? "Marked with a question." : "Mark cleared.";
else if (!options.QuestionMarks)
message = "Question marks are disabled.";
else
message = "That square is already open.";
}
void ShowDump()
{
Console.Clear();
Reset();
Console.Out.WriteLine(TextRender.Board(game));
Console.Out.WriteLine("Press any key to return to the game.");
Console.ReadKey(intercept: true);
Console.Clear();
renderer.Forget();
}
while (true)
{
if (s_interrupted)
return (RoundOutcome.Quit, game, null);
var seconds = clock.ElapsedSeconds;
renderer.Draw(new Frame(game, cursor, seconds, clock.Paused, message, help));
renderer.PlaceCaret(cursor);
while (!Console.KeyAvailable)
{
if (s_interrupted)
return (RoundOutcome.Quit, game, null);
Thread.Sleep(60);
if (clock.ElapsedSeconds == seconds)
continue;
seconds = clock.ElapsedSeconds;
renderer.Draw(new Frame(game, cursor, seconds, clock.Paused, message, help));
renderer.PlaceCaret(cursor);
}
var key = Console.ReadKey(intercept: true);
if (clock.Paused && key.Key is not (ConsoleKey.P or ConsoleKey.N or ConsoleKey.R or ConsoleKey.D
or ConsoleKey.Escape or ConsoleKey.X or ConsoleKey.D1 or ConsoleKey.D2 or ConsoleKey.D3))
{
message = "Paused - press p to resume.";
continue;
}
switch (key.Key)
{
case ConsoleKey.LeftArrow:
Move(-1, 0);
break;
case ConsoleKey.RightArrow:
Move(1, 0);
break;
case ConsoleKey.UpArrow:
Move(0, -1);
break;
case ConsoleKey.DownArrow:
Move(0, 1);
break;
case ConsoleKey.Home:
cursor = new CellRef(0, 0);
break;
case ConsoleKey.End:
cursor = new CellRef(board.Width - 1, board.Height - 1);
break;
case ConsoleKey.Spacebar:
case ConsoleKey.Enter:
OpenOrChord(forceChord: false);
break;
case ConsoleKey.C:
OpenOrChord(forceChord: true);
break;
case ConsoleKey.F:
Flag();
break;
case ConsoleKey.Oem2 when key.KeyChar == '?':
Question();
break;
case ConsoleKey.Oem2:
Flag();
break;
case ConsoleKey.Q:
Question();
break;
case ConsoleKey.P:
clock.TogglePause();
message = clock.Paused ? "Paused." : "Resumed.";
break;
case ConsoleKey.S:
ShowDump();
break;
case ConsoleKey.H:
case ConsoleKey.F1:
help = !help;
message = help ? "Help shown again with h." : "Help hidden.";
break;
case ConsoleKey.N:
case ConsoleKey.R:
return (RoundOutcome.NewGame, game, null);
case ConsoleKey.D:
return (RoundOutcome.NewGame, game, NextDifficulty(difficulty));
case ConsoleKey.D1:
return (RoundOutcome.NewGame, game, DifficultyProfile.Beginner);
case ConsoleKey.D2:
return (RoundOutcome.NewGame, game, DifficultyProfile.Intermediate);
case ConsoleKey.D3:
return (RoundOutcome.NewGame, game, DifficultyProfile.Expert);
case ConsoleKey.Escape:
case ConsoleKey.X:
clock.Stop();
return (RoundOutcome.Quit, game, null);
default:
message = "Press h for the key list.";
break;
}
}
}
private static DifficultyProfile NextDifficulty(DifficultyProfile current)
{
var presets = DifficultyProfile.Presets;
for (var index = 0; index < presets.Count; index++)
{
if (string.Equals(presets[index].Name, current.Name, StringComparison.Ordinal))
return presets[(index + 1) % presets.Count];
}
return presets[0];
}
private static void Reset()
{
Console.ResetColor();
}
private static void RestoreTerminal()
{
try
{
Console.ResetColor();
Console.CursorVisible = true;
}
catch (Exception exception) when (exception is InvalidOperationException or IOException)
{
// A detached terminal cannot restore the caret; nothing useful to do.
}
}
}
+201
View File
@@ -0,0 +1,201 @@
using System.Globalization;
using Minesweeper.Game;
namespace Minesweeper.Cli;
public sealed class Options
{
public DifficultyProfile Difficulty { get; set; } = DifficultyProfile.Beginner;
public int? Seed { get; set; }
public bool ReserveSafeZone { get; set; } = true;
public bool QuestionMarks { get; set; } = true;
/// <summary>One character per square instead of two, for narrow terminals.</summary>
public bool Compact { get; set; }
public bool Ascii { get; set; }
public bool Color { get; set; } = true;
public bool Auto { get; set; }
public bool AutoTrace { get; set; }
public int AutoMaxSteps { get; set; } = 20000;
/// <summary>Print the board as plain text when the round ends.</summary>
public bool Snapshot { get; set; } = true;
public bool ListLevels { get; set; }
}
public static class CommandLine
{
public static bool TryParse(string[] args, out Options options, out List<string> errors, out bool showHelp)
{
options = new Options();
errors = [];
showHelp = false;
DifficultyProfile? requested = null;
int? mines = null;
var colorOverride = false;
for (var i = 0; i < args.Length; i++)
{
var argument = args[i];
switch (argument)
{
case "-h":
case "-?":
case "--help":
showHelp = true;
break;
case "-d":
case "--difficulty":
case "--level":
if (!TryValue(args, ref i, argument, errors, out var difficulty))
continue;
if (!DifficultyProfile.TryResolve(difficulty, out var resolved, out var resolutionError))
{
errors.Add(resolutionError!);
continue;
}
requested = resolved;
break;
case "-s":
case "--size":
if (!TryValue(args, ref i, argument, errors, out var size))
continue;
if (!DifficultyProfile.TryResolve(size, out var custom, out var sizeError))
{
errors.Add(sizeError!);
continue;
}
requested = custom;
break;
case "-m":
case "--mines":
if (!TryValue(args, ref i, argument, errors, out var mineText))
continue;
if (!int.TryParse(mineText, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedMines) || parsedMines < 0)
{
errors.Add($"--mines expects a non-negative number, got '{mineText}'.");
continue;
}
mines = parsedMines;
break;
case "--seed":
if (!TryValue(args, ref i, argument, errors, out var seedText))
continue;
if (!int.TryParse(seedText, NumberStyles.Integer, CultureInfo.InvariantCulture, out var seed))
{
errors.Add($"--seed expects a whole number, got '{seedText}'.");
continue;
}
options.Seed = seed;
break;
case "--auto-max-steps":
if (!TryValue(args, ref i, argument, errors, out var stepsText))
continue;
if (!int.TryParse(stepsText, NumberStyles.Integer, CultureInfo.InvariantCulture, out var steps) || steps <= 0)
{
errors.Add($"--auto-max-steps expects a positive number, got '{stepsText}'.");
continue;
}
options.AutoMaxSteps = steps;
break;
case "--safe-zone":
options.ReserveSafeZone = true;
break;
case "--no-safe-zone":
options.ReserveSafeZone = false;
break;
case "--question-marks":
options.QuestionMarks = true;
break;
case "--no-question-marks":
options.QuestionMarks = false;
break;
case "--compact":
options.Compact = true;
break;
case "--wide":
options.Compact = false;
break;
case "--ascii":
options.Ascii = true;
break;
case "--unicode":
options.Ascii = false;
break;
case "--color":
colorOverride = true;
options.Color = true;
break;
case "--no-color":
colorOverride = true;
options.Color = false;
break;
case "--auto":
options.Auto = true;
break;
case "--auto-trace":
options.Auto = true;
options.AutoTrace = true;
break;
case "--snapshot":
options.Snapshot = true;
break;
case "--no-snapshot":
options.Snapshot = false;
break;
case "--list-levels":
options.ListLevels = true;
break;
default:
errors.Add($"Unknown option '{argument}'.");
break;
}
}
if (showHelp)
return errors.Count == 0;
if (requested is not null)
options.Difficulty = requested;
if (mines is not null)
{
var profile = options.Difficulty;
if (mines.Value > (long)profile.Width * profile.Height - 1)
errors.Add($"{mines.Value} mines do not leave a safe square on a {profile.Width}x{profile.Height} board.");
else
options.Difficulty = profile with { Mines = mines.Value, Name = "Custom" };
}
if (!colorOverride)
options.Color = Console.IsOutputRedirected ? false : Environment.GetEnvironmentVariable("NO_COLOR") is not { Length: > 0 };
if (options.Auto)
options.Snapshot = true;
return errors.Count == 0;
}
private static bool TryValue(string[] args, ref int index, string name, List<string> errors, out string value)
{
if (index + 1 < args.Length)
{
value = args[++index];
return true;
}
errors.Add($"{name} needs a value.");
value = string.Empty;
return false;
}
}
+42
View File
@@ -0,0 +1,42 @@
using System.Text;
using Minesweeper.Game;
namespace Minesweeper.Cli;
/// <summary>Plain ASCII board dumps, used for snapshots and piped output.</summary>
public static class TextRender
{
public const char HiddenMark = '#';
public static string Board(MinesweeperGame game)
{
ArgumentNullException.ThrowIfNull(game);
var board = game.Board;
var finished = game.Status != GameStatus.Playing;
var builder = new StringBuilder();
builder.AppendLine($"{game.Board.MineCount} mines on a {board.Width}x{board.Height} board - {game.Status}");
for (var y = 0; y < board.Height; y++)
{
for (var x = 0; x < board.Width; x++)
builder.Append(Glyph(board.At(x, y), finished));
builder.AppendLine();
}
builder.Append("legend: # hidden . empty 1-8 numbers F flag ? question * mine @ exploded x wrong flag");
return builder.ToString();
}
private static char Glyph(Cell cell, bool finished)
{
if (cell.IsFlagged)
return finished && !cell.IsMine ? 'x' : 'F';
if (cell.State == CellState.Question)
return '?';
if (!cell.IsRevealed)
return HiddenMark;
if (cell.IsMine)
return cell.Exploded ? '@' : '*';
return cell.AdjacentMines == 0 ? '.' : (char)('0' + cell.AdjacentMines);
}
}
+260
View File
@@ -0,0 +1,260 @@
namespace Minesweeper.Game;
/// <summary>
/// The Minesweeper grid: mine placement, opening squares (including flood fill)
/// and chording. Holds no notion of game progress - see <see cref="MinesweeperGame"/>.
/// </summary>
public sealed class Board
{
private readonly Cell[,] _cells;
public Board(int width, int height, int mineCount)
{
if (width <= 0)
throw new ArgumentOutOfRangeException(nameof(width), width, "Board width must be positive.");
if (height <= 0)
throw new ArgumentOutOfRangeException(nameof(height), height, "Board height must be positive.");
if (mineCount < 0 || mineCount > (long)width * height - 1)
throw new ArgumentOutOfRangeException(nameof(mineCount), mineCount, "A board must keep at least one safe square.");
Width = width;
Height = height;
MineCount = mineCount;
_cells = new Cell[width, height];
for (var x = 0; x < width; x++)
for (var y = 0; y < height; y++)
_cells[x, y] = new Cell();
}
public int Width { get; }
public int Height { get; }
/// <summary>Total mines on the board. Reduced before the first click if the board is too small.</summary>
public int MineCount { get; private set; }
public int CellCount => Width * Height;
public bool MinesPlanted { get; private set; }
public Cell At(CellRef reference) => At(reference.X, reference.Y);
public Cell At(int x, int y)
{
if (!InBounds(x, y))
throw new ArgumentOutOfRangeException(nameof(x), x, $"Square ({x},{y}) is outside the {Width}x{Height} board.");
return _cells[x, y];
}
public bool InBounds(CellRef reference) => InBounds(reference.X, reference.Y);
public bool InBounds(int x, int y) => x >= 0 && y >= 0 && x < Width && y < Height;
public IEnumerable<CellRef> AllCells()
{
for (var y = 0; y < Height; y++)
for (var x = 0; x < Width; x++)
yield return new CellRef(x, y);
}
public IEnumerable<CellRef> NeighborsOf(CellRef reference) => NeighborsOf(reference.X, reference.Y);
public IEnumerable<CellRef> NeighborsOf(int x, int y)
{
for (var dy = -1; dy <= 1; dy++)
{
for (var dx = -1; dx <= 1; dx++)
{
if (dx == 0 && dy == 0)
continue;
var nx = x + dx;
var ny = y + dy;
if (InBounds(nx, ny))
yield return new CellRef(nx, ny);
}
}
}
public IEnumerable<CellRef> MineCells()
{
foreach (var reference in AllCells())
if (At(reference).IsMine)
yield return reference;
}
/// <summary>
/// Randomly scatters <see cref="MineCount"/> mines after the first click, never on
/// <paramref name="safeSpot"/> and - when <paramref name="reserveSafeZone"/> is set - never
/// next to it either, so the opening click always reveals an area.
/// </summary>
public void PlantMines(CellRef safeSpot, bool reserveSafeZone, Random random)
{
ArgumentNullException.ThrowIfNull(random);
if (MinesPlanted)
throw new InvalidOperationException("Mines have already been planted.");
var forbidden = new HashSet<CellRef> { safeSpot };
if (reserveSafeZone)
foreach (var neighbor in NeighborsOf(safeSpot))
forbidden.Add(neighbor);
var candidates = new List<CellRef>();
foreach (var reference in AllCells())
if (!forbidden.Contains(reference))
candidates.Add(reference);
var toPlant = Math.Min(MineCount, candidates.Count);
for (var i = 0; i < toPlant; i++)
{
var swap = random.Next(i, candidates.Count);
(candidates[i], candidates[swap]) = (candidates[swap], candidates[i]);
At(candidates[i]).IsMine = true;
}
MineCount = toPlant;
MinesPlanted = true;
ComputeNumbers();
}
/// <summary>Plants an exact set of mines - handy for puzzles and tests.</summary>
public void PlantMines(IEnumerable<CellRef> mines)
{
ArgumentNullException.ThrowIfNull(mines);
if (MinesPlanted)
throw new InvalidOperationException("Mines have already been planted.");
var planted = new HashSet<CellRef>();
foreach (var mine in mines)
{
At(mine).IsMine = true;
planted.Add(mine);
}
MineCount = planted.Count;
MinesPlanted = true;
ComputeNumbers();
}
/// <summary>Recomputes every adjacency number from the current mine layout.</summary>
public void ComputeNumbers()
{
foreach (var reference in AllCells())
{
var count = 0;
foreach (var neighbor in NeighborsOf(reference))
if (At(neighbor).IsMine)
count++;
At(reference).AdjacentMines = count;
}
}
/// <summary>
/// Opens one square. Numbered squares open alone; blank squares flood outwards and
/// stop at flagged squares, mirroring the classic game.
/// </summary>
public OpenResult Open(CellRef reference)
{
var cell = At(reference);
if (!cell.IsHidden)
return OpenResult.None;
if (cell.IsMine)
{
cell.State = CellState.Revealed;
return OpenResult.Mine(reference);
}
if (cell.AdjacentMines > 0)
{
cell.State = CellState.Revealed;
return new OpenResult(new[] { reference });
}
var opened = new List<CellRef>();
var queued = new HashSet<CellRef> { reference };
var pending = new Stack<CellRef>();
pending.Push(reference);
while (pending.Count > 0)
{
var current = pending.Pop();
var currentCell = At(current);
if (currentCell.State is CellState.Revealed or CellState.Flagged)
continue;
currentCell.State = CellState.Revealed;
opened.Add(current);
if (currentCell.AdjacentMines > 0)
continue;
foreach (var neighbor in NeighborsOf(current))
if (queued.Add(neighbor))
pending.Push(neighbor);
}
return new OpenResult(opened);
}
/// <summary>
/// Chords a revealed number: when exactly the right number of flags surround it, every
/// remaining hidden neighbor opens. A mismatch just "pops" without opening anything.
/// </summary>
public OpenResult Chord(CellRef reference)
{
var cell = At(reference);
if (!cell.IsRevealed || cell.AdjacentMines == 0)
return OpenResult.None;
var hidden = new List<CellRef>();
var flagged = 0;
foreach (var neighbor in NeighborsOf(reference))
{
var neighborCell = At(neighbor);
if (neighborCell.IsFlagged)
flagged++;
else if (neighborCell.IsHidden)
hidden.Add(neighbor);
}
if (hidden.Count == 0 || flagged != cell.AdjacentMines)
return OpenResult.None;
var opened = new List<CellRef>();
CellRef? boom = null;
foreach (var candidate in hidden)
{
var result = Open(candidate);
opened.AddRange(result.Opened);
if (result.HitMine)
{
boom = result.Boom;
break;
}
}
return new OpenResult(opened, boom);
}
/// <summary>Marks a square as the losing mine and shows every other mine.</summary>
public void Explode(CellRef reference)
{
var cell = At(reference);
cell.Exploded = true;
cell.State = CellState.Revealed;
}
/// <summary>Reveals unflagged mines, used when the game is lost or the solution is shown.</summary>
public void RevealAllMines()
{
foreach (var mine in MineCells())
if (!At(mine).IsFlagged)
At(mine).State = CellState.Revealed;
}
/// <summary>Forces a mine square to show a flag, used when the player wins.</summary>
public void ForceFlag(CellRef reference)
{
At(reference).State = CellState.Flagged;
}
}
+30
View File
@@ -0,0 +1,30 @@
namespace Minesweeper.Game;
/// <summary>How a single board square is currently shown to the player.</summary>
public enum CellState
{
Hidden,
Revealed,
Flagged,
Question,
}
public sealed class Cell
{
public bool IsMine { get; set; }
/// <summary>Number of mines in the eight surrounding squares.</summary>
public int AdjacentMines { get; set; }
public CellState State { get; set; } = CellState.Hidden;
/// <summary>True for the mine the player opened to end the game.</summary>
public bool Exploded { get; set; }
public bool IsRevealed => State == CellState.Revealed;
public bool IsFlagged => State == CellState.Flagged;
/// <summary>Anything the player can still open or mark.</summary>
public bool IsHidden => State is CellState.Hidden or CellState.Question;
}
+4
View File
@@ -0,0 +1,4 @@
namespace Minesweeper.Game;
/// <summary>A board coordinate, where (0,0) is the top-left square.</summary>
public readonly record struct CellRef(int X, int Y);
+103
View File
@@ -0,0 +1,103 @@
using System.Globalization;
namespace Minesweeper.Game;
/// <summary>A board size plus its mine count.</summary>
public sealed record DifficultyProfile(string Name, int Width, int Height, int Mines)
{
public const int MaxWidth = 120;
public const int MaxHeight = 60;
public static DifficultyProfile Beginner { get; } = new("Beginner", 9, 9, 10);
public static DifficultyProfile Intermediate { get; } = new("Intermediate", 16, 16, 40);
public static DifficultyProfile Expert { get; } = new("Expert", 30, 16, 99);
public static IReadOnlyList<DifficultyProfile> Presets { get; } = [Beginner, Intermediate, Expert];
public string SizeLabel => $"{Width}x{Height}";
public string MineDensity => (100d * Mines / Math.Max(1, Width * Height)).ToString("0.#", CultureInfo.InvariantCulture) + "%";
/// <summary>
/// Resolves a user supplied name: a preset alias (<c>expert</c>, <c>e</c>, <c>3</c>) or a
/// custom size such as <c>14x10x25</c> (the mine count may be omitted for ~15% density).
/// </summary>
public static bool TryResolve(string value, out DifficultyProfile? profile, out string? error)
{
profile = null;
error = null;
if (string.IsNullOrWhiteSpace(value))
{
error = "A difficulty name or board size is required.";
return false;
}
switch (value.Trim().ToLowerInvariant())
{
case "1":
case "b":
case "beginner":
case "novice":
case "easy":
profile = Beginner;
return true;
case "2":
case "i":
case "intermediate":
case "normal":
case "medium":
profile = Intermediate;
return true;
case "3":
case "e":
case "expert":
case "hard":
profile = Expert;
return true;
}
var parts = value.Trim().Split(['x', 'X', '*', ',', ':', '-'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (parts.Length is < 2 or > 3)
{
error = $"Unknown difficulty '{value}'. Use beginner, intermediate, expert, or a size such as 16x16x40.";
return false;
}
if (!int.TryParse(parts[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out var width) ||
!int.TryParse(parts[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var height))
{
error = $"Could not read a width and height from '{value}'. Try 16x16x40.";
return false;
}
var mines = parts.Length == 3
? int.TryParse(parts[2], NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed)
? parsed
: 0
: Math.Max(1, (int)Math.Round(width * height * 0.15));
if (parts.Length == 3 && mines <= 0)
{
error = $"Could not read a mine count from '{value}'. Try 16x16x40.";
return false;
}
if (width < 1 || width > MaxWidth || height < 1 || height > MaxHeight)
{
error = $"Board size must fit within {MaxWidth}x{MaxHeight}.";
return false;
}
if (mines > (long)width * height - 1)
{
error = $"{mines} mines do not leave a safe square on a {width}x{height} board.";
return false;
}
profile = new DifficultyProfile("Custom", width, height, mines);
return true;
}
}
+173
View File
@@ -0,0 +1,173 @@
namespace Minesweeper.Game;
public enum GameStatus
{
Playing,
Won,
Lost,
}
/// <summary>
/// Tracks one round of Minesweeper on top of a <see cref="Board"/>: the safe first click,
/// flags, and win/loss detection. Deliberately free of any console code so it can be tested
/// or driven by the solver.
/// </summary>
public sealed class MinesweeperGame
{
private readonly Random _random;
public MinesweeperGame(Board board, Random random)
{
Board = board ?? throw new ArgumentNullException(nameof(board));
_random = random ?? throw new ArgumentNullException(nameof(random));
}
public Board Board { get; }
public GameStatus Status { get; private set; } = GameStatus.Playing;
/// <summary>
/// Mines only exist after the first click, which is always safe. A board handed to the
/// game with mines already planted skips that step.
/// </summary>
public bool HasOpenedFirstCell => Board.MinesPlanted;
/// <summary>When true the first click also guarantees a mine-free neighborhood.</summary>
public bool ReserveSafeZone { get; init; } = true;
public int RevealedCount { get; private set; }
public int FlagsPlaced { get; private set; }
public CellRef? Explosion { get; private set; }
public int MinesRemaining => Board.MineCount - FlagsPlaced;
/// <summary>Safe squares still hidden - the classic "holes left" counter.</summary>
public int SafeCellsRemaining => Math.Max(0, Board.CellCount - Board.MineCount - RevealedCount);
public int TotalSafeCells => Board.CellCount - Board.MineCount;
public OpenResult Open(int x, int y) => Open(new CellRef(x, y));
public OpenResult Open(CellRef reference)
{
if (Status != GameStatus.Playing)
return OpenResult.None;
var cell = Board.At(reference);
if (!cell.IsHidden)
return OpenResult.None;
if (!Board.MinesPlanted)
Board.PlantMines(reference, ReserveSafeZone, _random);
var result = Board.Open(reference);
if (result.HitMine)
{
Lose(result.Boom!.Value);
return result;
}
RevealedCount += result.Opened.Count;
CheckWin();
return result;
}
public OpenResult Chord(CellRef reference)
{
if (Status != GameStatus.Playing)
return OpenResult.None;
if (!Board.At(reference).IsRevealed)
return OpenResult.None;
var result = Board.Chord(reference);
if (result.HitMine)
{
Lose(result.Boom!.Value);
return result;
}
if (result.AnyOpened)
{
RevealedCount += result.Opened.Count;
CheckWin();
}
return result;
}
/// <summary>Flag or clear a square; a question mark becomes a cleared square.</summary>
public bool ToggleFlag(CellRef reference) => Board.At(reference).State switch
{
CellState.Hidden => TryMark(reference, CellState.Flagged),
CellState.Flagged => TryMark(reference, CellState.Hidden),
CellState.Question => TryMark(reference, CellState.Hidden),
_ => false,
};
/// <summary>Question mark or clear a square. Does nothing when question marks are off.</summary>
public bool ToggleQuestion(CellRef reference, bool questionMarksEnabled)
{
if (!questionMarksEnabled)
return false;
return Board.At(reference).State switch
{
CellState.Hidden => TryMark(reference, CellState.Question),
CellState.Flagged => TryMark(reference, CellState.Hidden),
CellState.Question => TryMark(reference, CellState.Hidden),
_ => false,
};
}
/// <summary>Sets the mark on a hidden square, keeping the flag counter in step.</summary>
public bool TryMark(CellRef reference, CellState target)
{
if (Status != GameStatus.Playing)
return false;
if (target is not (CellState.Hidden or CellState.Flagged or CellState.Question))
return false;
var cell = Board.At(reference);
if (cell.IsRevealed || cell.State == target)
return false;
if (cell.IsFlagged)
FlagsPlaced--;
if (target == CellState.Flagged)
FlagsPlaced++;
cell.State = target;
return true;
}
/// <summary>Shows the solution: every mine, plus which flags were wrong.</summary>
public void RevealSolution()
{
if (Status == GameStatus.Playing)
return;
Board.RevealAllMines();
}
private void Lose(CellRef reference)
{
Status = GameStatus.Lost;
Explosion = reference;
Board.Explode(reference);
Board.RevealAllMines();
}
private void CheckWin()
{
if (Status != GameStatus.Playing)
return;
if (RevealedCount < TotalSafeCells)
return;
Status = GameStatus.Won;
foreach (var mine in Board.MineCells())
Board.ForceFlag(mine);
FlagsPlaced = Board.MineCount;
}
}
+13
View File
@@ -0,0 +1,13 @@
namespace Minesweeper.Game;
/// <summary>What an open or chord action changed on the board.</summary>
public sealed record OpenResult(IReadOnlyList<CellRef> Opened, CellRef? Boom = null)
{
public static OpenResult None { get; } = new(Array.Empty<CellRef>());
public static OpenResult Mine(CellRef reference) => new(new[] { reference }, reference);
public bool HitMine => Boom.HasValue;
public bool AnyOpened => Opened.Count > 0;
}
+189
View File
@@ -0,0 +1,189 @@
namespace Minesweeper.Game;
public sealed record SolverResult(
GameStatus Status,
int Steps,
int Openings,
int Chords,
int Flags,
int Guesses);
/// <summary>
/// Plays a board with the two deductions every human uses - a number touching exactly as many
/// hidden squares as it still needs is all mines, and a satisfied number chords - then guesses
/// the lowest risk square when logic runs out.
/// </summary>
public sealed class Solver
{
private readonly Action<string>? _onStep;
private readonly MinesweeperGame _game;
private readonly int _maxSteps;
private readonly Random _random;
public Solver(MinesweeperGame game, Random random, int maxSteps = 20000, Action<string>? onStep = null)
{
_game = game ?? throw new ArgumentNullException(nameof(game));
_random = random ?? throw new ArgumentNullException(nameof(random));
_maxSteps = maxSteps > 0 ? maxSteps : throw new ArgumentOutOfRangeException(nameof(maxSteps));
_onStep = onStep;
}
public SolverResult Run()
{
var steps = 0;
var openings = 0;
var chords = 0;
var flags = 0;
var guesses = 0;
while (_game.Status == GameStatus.Playing && steps < _maxSteps)
{
steps++;
if (!_game.HasOpenedFirstCell)
{
var first = new CellRef(_game.Board.Width / 2, _game.Board.Height / 2);
var opened = _game.Open(first);
openings++;
Report($"open ({first.X},{first.Y}) first click +{opened.Opened.Count,-3} revealed");
continue;
}
var deduction = DeduceOnce();
if (deduction.Changed)
{
chords += deduction.Chords;
flags += deduction.Flags;
continue;
}
var guess = PickGuess();
if (guess is null)
break;
var result = _game.Open(guess.Value);
guesses++;
openings++;
Report($"guess ({guess.Value.X},{guess.Value.Y}) risk {Risk(guess.Value):0.##} +{result.Opened.Count,-3} revealed");
}
return new SolverResult(_game.Status, steps, openings, chords, flags, guesses);
}
private (bool Changed, int Chords, int Flags) DeduceOnce()
{
var changed = false;
var chords = 0;
var flags = 0;
foreach (var reference in _game.Board.AllCells().ToArray())
{
if (_game.Status != GameStatus.Playing)
break;
var cell = _game.Board.At(reference);
if (!cell.IsRevealed || cell.AdjacentMines == 0)
continue;
var neighborhood = Inspect(reference);
if (neighborhood.Hidden.Count == 0)
continue;
if (neighborhood.Flagged == cell.AdjacentMines)
{
var result = _game.Chord(reference);
if (!result.AnyOpened && !result.HitMine)
continue;
chords++;
changed = true;
Report($"chord ({reference.X},{reference.Y}) number {cell.AdjacentMines} satisfied +{result.Opened.Count,-3} revealed");
continue;
}
if (neighborhood.Hidden.Count == cell.AdjacentMines - neighborhood.Flagged)
{
var marked = 0;
foreach (var hidden in neighborhood.Hidden)
if (_game.TryMark(hidden, CellState.Flagged))
marked++;
if (marked == 0)
continue;
flags += marked;
changed = true;
Report($"flag ({reference.X},{reference.Y}) number {cell.AdjacentMines} forces +{marked,-3} flags");
}
}
return (changed, chords, flags);
}
private CellRef? PickGuess()
{
CellRef? best = null;
var bestRisk = double.MaxValue;
var bestInfo = int.MinValue;
foreach (var reference in _game.Board.AllCells())
{
if (!_game.Board.At(reference).IsHidden)
continue;
// Jitter keeps ties interesting without disturbing real risk comparisons.
var risk = Risk(reference) + (_random.NextDouble() - 0.5) * 1e-6;
var info = Inspect(reference).Hidden.Count;
if (risk < bestRisk - 1e-9 || (Math.Abs(risk - bestRisk) <= 1e-9 && info > bestInfo))
{
best = reference;
bestRisk = risk;
bestInfo = info;
}
}
return best;
}
/// <summary>Rough odds that a hidden square hides a mine, based on the numbers touching it.</summary>
private double Risk(CellRef reference)
{
var total = 0d;
var terms = 0;
foreach (var neighbor in _game.Board.NeighborsOf(reference))
{
var cell = _game.Board.At(neighbor);
if (!cell.IsRevealed || cell.AdjacentMines == 0)
continue;
var neighborhood = Inspect(neighbor);
var remaining = cell.AdjacentMines - neighborhood.Flagged;
if (remaining <= 0 || neighborhood.Hidden.Count == 0)
continue;
total += (double)remaining / neighborhood.Hidden.Count;
terms++;
}
return terms == 0 ? 0d : total / terms;
}
private Neighborhood Inspect(CellRef reference)
{
var hidden = new List<CellRef>();
var flagged = 0;
foreach (var neighbor in _game.Board.NeighborsOf(reference))
{
var cell = _game.Board.At(neighbor);
if (cell.IsFlagged)
flagged++;
else if (cell.IsHidden)
hidden.Add(neighbor);
}
return new Neighborhood(hidden, flagged);
}
private void Report(string message) => _onStep?.Invoke(message);
private sealed record Neighborhood(List<CellRef> Hidden, int Flagged);
}
+15
View File
@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<RootNamespace>Minesweeper</RootNamespace>
<AssemblyName>minesweeper</AssemblyName>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<InvariantGlobalization>true</InvariantGlobalization>
<Version>1.0.0</Version>
<Description>A terminal game of Minesweeper with a built-in solver.</Description>
</PropertyGroup>
</Project>
+42
View File
@@ -0,0 +1,42 @@
using Minesweeper.Cli;
using Minesweeper.Game;
// NO_COLOR wins by default, but an explicit --color should still mean color. The runtime reads
// that variable the first time the console is touched, so clear it before anything else runs.
if (args.Contains("--color", StringComparer.Ordinal))
Environment.SetEnvironmentVariable("NO_COLOR", null);
if (!CommandLine.TryParse(args, out var options, out var errors, out var showHelp))
{
foreach (var error in errors)
Console.Error.WriteLine($"error: {error}");
Console.Error.WriteLine();
HelpText.Write(Console.Error);
return 2;
}
if (showHelp)
{
HelpText.Write(Console.Out);
return 0;
}
if (options.ListLevels)
{
Console.Out.Write(HelpText.Levels());
return 0;
}
// NO_COLOR wins by default, but an explicit --color should still mean color - the runtime
// checks that variable the first time a color is written, so clear it before anything is drawn.
var random = options.Seed.HasValue ? new Random(options.Seed.Value) : Random.Shared;
if (options.Auto)
{
var profile = options.Difficulty;
var board = new Board(profile.Width, profile.Height, profile.Mines);
var game = new MinesweeperGame(board, random) { ReserveSafeZone = options.ReserveSafeZone };
return AutoRunner.Run(game, options, random);
}
return InteractiveRunner.Run(options, random);
+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;
}
}