commit 2ba405b19c69cb0689ae5f3c0c6e34cb9407620a Author: drobor-ai Date: Tue Sep 8 07:12:51 2026 +0000 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. diff --git a/.editorconfig b/.editorconfig new file mode 100755 index 0000000..b50e8e2 --- /dev/null +++ b/.editorconfig @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100755 index 0000000..381b4e6 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/Minesweeper.slnx b/Minesweeper.slnx new file mode 100644 index 0000000..8581352 --- /dev/null +++ b/Minesweeper.slnx @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/README.md b/README.md new file mode 100644 index 0000000..312ad0b --- /dev/null +++ b/README.md @@ -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//`. 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. diff --git a/publish.sh b/publish.sh new file mode 100755 index 0000000..3e3a2a8 --- /dev/null +++ b/publish.sh @@ -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//" diff --git a/src/Minesweeper/Cli/AutoRunner.cs b/src/Minesweeper/Cli/AutoRunner.cs new file mode 100644 index 0000000..28f1592 --- /dev/null +++ b/src/Minesweeper/Cli/AutoRunner.cs @@ -0,0 +1,39 @@ +using System.Diagnostics; +using Minesweeper.Game; + +namespace Minesweeper.Cli; + +/// Hands a board to and reports what happened. +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(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; + } +} diff --git a/src/Minesweeper/Cli/ConsoleRenderer.cs b/src/Minesweeper/Cli/ConsoleRenderer.cs new file mode 100644 index 0000000..2776946 --- /dev/null +++ b/src/Minesweeper/Cli/ConsoleRenderer.cs @@ -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); + +/// +/// Draws the board with the console cursor, redrawing only the squares whose glyph or color +/// actually changed so the frame never flickers. +/// +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> _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; + } + } + + /// + /// Confirms the board fits the terminal, quietly switching to one character squares when + /// two would overflow. + /// + 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; + } + + /// Number of terminal rows the frame occupies. + 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(); + } + + /// Moves the terminal caret onto the selected square, so color is not the only cue. + public void PlaceCaret(CellRef cursor) + { + var column = _margin + cursor.X * _cellWidth; + if (column < SafeWindowWidth()) + Console.SetCursorPosition(column, GridTop + cursor.Y); + } + + /// Forgets the diff cache, for example after . + public void Forget() => _previous.Clear(); + + private static int GridTop => 3; + + private List> BuildPanel(Frame frame) + { + var game = frame.Game; + var rows = new List>(); + + 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(); + 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 Rule() + { + var row = new List(); + 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 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)); + } + } +} diff --git a/src/Minesweeper/Cli/GameClock.cs b/src/Minesweeper/Cli/GameClock.cs new file mode 100644 index 0000000..da8e561 --- /dev/null +++ b/src/Minesweeper/Cli/GameClock.cs @@ -0,0 +1,46 @@ +using System.Diagnostics; + +namespace Minesweeper.Cli; + +/// Round timer that starts on the first click and can be paused. +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; + } +} diff --git a/src/Minesweeper/Cli/HelpText.cs b/src/Minesweeper/Cli/HelpText.cs new file mode 100644 index 0000000..d2a31c7 --- /dev/null +++ b/src/Minesweeper/Cli/HelpText.cs @@ -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 beginner | intermediate | expert"); + builder.AppendLine(" (aliases: novice, normal, hard, b/i/e, 1/2/3)"); + builder.AppendLine(" -s, --size custom board, e.g. 14x10x25 (mines optional)"); + builder.AppendLine(" -m, --mines override the mine count for the chosen size"); + builder.AppendLine(" --seed 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 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(); + } +} diff --git a/src/Minesweeper/Cli/InteractiveRunner.cs b/src/Minesweeper/Cli/InteractiveRunner.cs new file mode 100644 index 0000000..2b1b2b1 --- /dev/null +++ b/src/Minesweeper/Cli/InteractiveRunner.cs @@ -0,0 +1,293 @@ +using Minesweeper.Game; + +namespace Minesweeper.Cli; + +public enum RoundOutcome +{ + Quit, + NewGame, + + /// The round could not start, for example a board too large for the terminal. + Failed, +} + +/// The keyboard-driven game loop. +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. + } + } +} diff --git a/src/Minesweeper/Cli/Options.cs b/src/Minesweeper/Cli/Options.cs new file mode 100644 index 0000000..1d3e275 --- /dev/null +++ b/src/Minesweeper/Cli/Options.cs @@ -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; + + /// One character per square instead of two, for narrow terminals. + 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; + + /// Print the board as plain text when the round ends. + 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 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 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; + } +} diff --git a/src/Minesweeper/Cli/TextRender.cs b/src/Minesweeper/Cli/TextRender.cs new file mode 100644 index 0000000..bdc3f79 --- /dev/null +++ b/src/Minesweeper/Cli/TextRender.cs @@ -0,0 +1,42 @@ +using System.Text; +using Minesweeper.Game; + +namespace Minesweeper.Cli; + +/// Plain ASCII board dumps, used for snapshots and piped output. +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); + } +} diff --git a/src/Minesweeper/Game/Board.cs b/src/Minesweeper/Game/Board.cs new file mode 100644 index 0000000..50f0928 --- /dev/null +++ b/src/Minesweeper/Game/Board.cs @@ -0,0 +1,260 @@ +namespace Minesweeper.Game; + +/// +/// The Minesweeper grid: mine placement, opening squares (including flood fill) +/// and chording. Holds no notion of game progress - see . +/// +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; } + + /// Total mines on the board. Reduced before the first click if the board is too small. + 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 AllCells() + { + for (var y = 0; y < Height; y++) + for (var x = 0; x < Width; x++) + yield return new CellRef(x, y); + } + + public IEnumerable NeighborsOf(CellRef reference) => NeighborsOf(reference.X, reference.Y); + + public IEnumerable 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 MineCells() + { + foreach (var reference in AllCells()) + if (At(reference).IsMine) + yield return reference; + } + + /// + /// Randomly scatters mines after the first click, never on + /// and - when is set - never + /// next to it either, so the opening click always reveals an area. + /// + 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 { safeSpot }; + if (reserveSafeZone) + foreach (var neighbor in NeighborsOf(safeSpot)) + forbidden.Add(neighbor); + + var candidates = new List(); + 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(); + } + + /// Plants an exact set of mines - handy for puzzles and tests. + public void PlantMines(IEnumerable mines) + { + ArgumentNullException.ThrowIfNull(mines); + if (MinesPlanted) + throw new InvalidOperationException("Mines have already been planted."); + + var planted = new HashSet(); + foreach (var mine in mines) + { + At(mine).IsMine = true; + planted.Add(mine); + } + + MineCount = planted.Count; + MinesPlanted = true; + ComputeNumbers(); + } + + /// Recomputes every adjacency number from the current mine layout. + 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; + } + } + + /// + /// Opens one square. Numbered squares open alone; blank squares flood outwards and + /// stop at flagged squares, mirroring the classic game. + /// + 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(); + var queued = new HashSet { reference }; + var pending = new Stack(); + 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); + } + + /// + /// 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. + /// + public OpenResult Chord(CellRef reference) + { + var cell = At(reference); + if (!cell.IsRevealed || cell.AdjacentMines == 0) + return OpenResult.None; + + var hidden = new List(); + 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? 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); + } + + /// Marks a square as the losing mine and shows every other mine. + public void Explode(CellRef reference) + { + var cell = At(reference); + cell.Exploded = true; + cell.State = CellState.Revealed; + } + + /// Reveals unflagged mines, used when the game is lost or the solution is shown. + public void RevealAllMines() + { + foreach (var mine in MineCells()) + if (!At(mine).IsFlagged) + At(mine).State = CellState.Revealed; + } + + /// Forces a mine square to show a flag, used when the player wins. + public void ForceFlag(CellRef reference) + { + At(reference).State = CellState.Flagged; + } +} diff --git a/src/Minesweeper/Game/Cell.cs b/src/Minesweeper/Game/Cell.cs new file mode 100644 index 0000000..4d98d0b --- /dev/null +++ b/src/Minesweeper/Game/Cell.cs @@ -0,0 +1,30 @@ +namespace Minesweeper.Game; + +/// How a single board square is currently shown to the player. +public enum CellState +{ + Hidden, + Revealed, + Flagged, + Question, +} + +public sealed class Cell +{ + public bool IsMine { get; set; } + + /// Number of mines in the eight surrounding squares. + public int AdjacentMines { get; set; } + + public CellState State { get; set; } = CellState.Hidden; + + /// True for the mine the player opened to end the game. + public bool Exploded { get; set; } + + public bool IsRevealed => State == CellState.Revealed; + + public bool IsFlagged => State == CellState.Flagged; + + /// Anything the player can still open or mark. + public bool IsHidden => State is CellState.Hidden or CellState.Question; +} diff --git a/src/Minesweeper/Game/CellRef.cs b/src/Minesweeper/Game/CellRef.cs new file mode 100644 index 0000000..385ebd9 --- /dev/null +++ b/src/Minesweeper/Game/CellRef.cs @@ -0,0 +1,4 @@ +namespace Minesweeper.Game; + +/// A board coordinate, where (0,0) is the top-left square. +public readonly record struct CellRef(int X, int Y); diff --git a/src/Minesweeper/Game/DifficultyProfile.cs b/src/Minesweeper/Game/DifficultyProfile.cs new file mode 100644 index 0000000..c775be9 --- /dev/null +++ b/src/Minesweeper/Game/DifficultyProfile.cs @@ -0,0 +1,103 @@ +using System.Globalization; + +namespace Minesweeper.Game; + +/// A board size plus its mine count. +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 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) + "%"; + + /// + /// Resolves a user supplied name: a preset alias (expert, e, 3) or a + /// custom size such as 14x10x25 (the mine count may be omitted for ~15% density). + /// + 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; + } +} diff --git a/src/Minesweeper/Game/MinesweeperGame.cs b/src/Minesweeper/Game/MinesweeperGame.cs new file mode 100644 index 0000000..08e1bf4 --- /dev/null +++ b/src/Minesweeper/Game/MinesweeperGame.cs @@ -0,0 +1,173 @@ +namespace Minesweeper.Game; + +public enum GameStatus +{ + Playing, + Won, + Lost, +} + +/// +/// Tracks one round of Minesweeper on top of a : 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. +/// +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; + + /// + /// Mines only exist after the first click, which is always safe. A board handed to the + /// game with mines already planted skips that step. + /// + public bool HasOpenedFirstCell => Board.MinesPlanted; + + /// When true the first click also guarantees a mine-free neighborhood. + 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; + + /// Safe squares still hidden - the classic "holes left" counter. + 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; + } + + /// Flag or clear a square; a question mark becomes a cleared square. + 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, + }; + + /// Question mark or clear a square. Does nothing when question marks are off. + 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, + }; + } + + /// Sets the mark on a hidden square, keeping the flag counter in step. + 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; + } + + /// Shows the solution: every mine, plus which flags were wrong. + 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; + } +} diff --git a/src/Minesweeper/Game/OpenResult.cs b/src/Minesweeper/Game/OpenResult.cs new file mode 100644 index 0000000..1907454 --- /dev/null +++ b/src/Minesweeper/Game/OpenResult.cs @@ -0,0 +1,13 @@ +namespace Minesweeper.Game; + +/// What an open or chord action changed on the board. +public sealed record OpenResult(IReadOnlyList Opened, CellRef? Boom = null) +{ + public static OpenResult None { get; } = new(Array.Empty()); + + public static OpenResult Mine(CellRef reference) => new(new[] { reference }, reference); + + public bool HitMine => Boom.HasValue; + + public bool AnyOpened => Opened.Count > 0; +} diff --git a/src/Minesweeper/Game/Solver.cs b/src/Minesweeper/Game/Solver.cs new file mode 100644 index 0000000..2044e11 --- /dev/null +++ b/src/Minesweeper/Game/Solver.cs @@ -0,0 +1,189 @@ +namespace Minesweeper.Game; + +public sealed record SolverResult( + GameStatus Status, + int Steps, + int Openings, + int Chords, + int Flags, + int Guesses); + +/// +/// 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. +/// +public sealed class Solver +{ + private readonly Action? _onStep; + private readonly MinesweeperGame _game; + private readonly int _maxSteps; + private readonly Random _random; + + public Solver(MinesweeperGame game, Random random, int maxSteps = 20000, Action? 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; + } + + /// Rough odds that a hidden square hides a mine, based on the numbers touching it. + 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(); + 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 Hidden, int Flagged); +} diff --git a/src/Minesweeper/Minesweeper.csproj b/src/Minesweeper/Minesweeper.csproj new file mode 100644 index 0000000..a3f6bf3 --- /dev/null +++ b/src/Minesweeper/Minesweeper.csproj @@ -0,0 +1,15 @@ + + + + Exe + net10.0 + Minesweeper + minesweeper + enable + enable + true + 1.0.0 + A terminal game of Minesweeper with a built-in solver. + + + diff --git a/src/Minesweeper/Program.cs b/src/Minesweeper/Program.cs new file mode 100644 index 0000000..8f968e0 --- /dev/null +++ b/src/Minesweeper/Program.cs @@ -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); diff --git a/tests/Minesweeper.Tests/BoardTests.cs b/tests/Minesweeper.Tests/BoardTests.cs new file mode 100644 index 0000000..31934b4 --- /dev/null +++ b/tests/Minesweeper.Tests/BoardTests.cs @@ -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(() => 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(() => board.PlantMines([])); + Assert.Throws(() => 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(() => 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); +} diff --git a/tests/Minesweeper.Tests/CommandLineTests.cs b/tests/Minesweeper.Tests/CommandLineTests.cs new file mode 100644 index 0000000..50031b9 --- /dev/null +++ b/tests/Minesweeper.Tests/CommandLineTests.cs @@ -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)); + } +} diff --git a/tests/Minesweeper.Tests/DifficultyTests.cs b/tests/Minesweeper.Tests/DifficultyTests.cs new file mode 100644 index 0000000..b73c95e --- /dev/null +++ b/tests/Minesweeper.Tests/DifficultyTests.cs @@ -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); + } +} diff --git a/tests/Minesweeper.Tests/Minesweeper.Tests.csproj b/tests/Minesweeper.Tests/Minesweeper.Tests.csproj new file mode 100644 index 0000000..cf99169 --- /dev/null +++ b/tests/Minesweeper.Tests/Minesweeper.Tests.csproj @@ -0,0 +1,25 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + diff --git a/tests/Minesweeper.Tests/MinesweeperGameTests.cs b/tests/Minesweeper.Tests/MinesweeperGameTests.cs new file mode 100644 index 0000000..fb1cfa9 --- /dev/null +++ b/tests/Minesweeper.Tests/MinesweeperGameTests.cs @@ -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); + } +} diff --git a/tests/Minesweeper.Tests/SolverTests.cs b/tests/Minesweeper.Tests/SolverTests.cs new file mode 100644 index 0000000..5c73d3d --- /dev/null +++ b/tests/Minesweeper.Tests/SolverTests.cs @@ -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(); + } +} diff --git a/tests/Minesweeper.Tests/TestBoards.cs b/tests/Minesweeper.Tests/TestBoards.cs new file mode 100644 index 0000000..4a6d8eb --- /dev/null +++ b/tests/Minesweeper.Tests/TestBoards.cs @@ -0,0 +1,52 @@ +using Minesweeper.Game; + +namespace Minesweeper.Tests; + +/// Builds fixed boards from a layout where 'x' marks a mine. +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(); + + 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; + } +}