Initial commit: terminal Minesweeper in C# for .NET 10
A playable console Minesweeper (arrow keys, chording, flags, question marks, pause, difficulty presets) plus a deduction-and-guess solver that can play the game headlessly. Game logic lives in src/Minesweeper/Game and has no console dependencies, so it is covered by 65 unit tests. Includes publishing helpers for self-contained, trimmed single-file binaries (publish.sh) and an .editorconfig that the sources conform to: UTF-8 BOM and 4-space indents for C#, 2-space indents for project files, LF endings and 2-space indents for shell scripts, System-first usings, no redundant `this.`, and s_/ _ prefixed private static and instance fields.
This commit is contained in:
Executable
+246
@@ -0,0 +1,246 @@
|
||||
# EditorConfig is awesome: https://EditorConfig.org
|
||||
# Copypasted from https://github.com/dotnet/roslyn/blob/master/.editorconfig
|
||||
|
||||
# top-most EditorConfig file
|
||||
root = true
|
||||
|
||||
# Don't use tabs for indentation.
|
||||
[*]
|
||||
indent_style = space
|
||||
# (Please don't specify an indent_size here; that has too many unintended consequences.)
|
||||
|
||||
# Code files
|
||||
[*.{cs,csx,vb,vbx}]
|
||||
indent_size = 4
|
||||
insert_final_newline = true
|
||||
charset = utf-8-bom
|
||||
|
||||
# XML project files
|
||||
[*.{csproj,vbproj,vcxproj,vcxproj.filters,proj,projitems,shproj}]
|
||||
indent_size = 2
|
||||
|
||||
# XML config files
|
||||
[*.{props,targets,ruleset,config,nuspec,resx,vsixmanifest,vsct}]
|
||||
indent_size = 2
|
||||
|
||||
# JSON files
|
||||
[*.json]
|
||||
indent_size = 2
|
||||
|
||||
# Powershell files
|
||||
[*.ps1]
|
||||
indent_size = 2
|
||||
|
||||
# Shell script files
|
||||
[*.sh]
|
||||
end_of_line = lf
|
||||
indent_size = 2
|
||||
|
||||
# Dotnet code style settings:
|
||||
[*.{cs,vb}]
|
||||
# Sort using and Import directives with System.* appearing first
|
||||
dotnet_sort_system_directives_first = true
|
||||
# Avoid "this." and "Me." if not necessary
|
||||
dotnet_style_qualification_for_field = false:refactoring
|
||||
dotnet_style_qualification_for_property = false:refactoring
|
||||
dotnet_style_qualification_for_method = false:refactoring
|
||||
dotnet_style_qualification_for_event = false:refactoring
|
||||
|
||||
# Use language keywords instead of framework type names for type references
|
||||
dotnet_style_predefined_type_for_locals_parameters_members = true:suggestion
|
||||
dotnet_style_predefined_type_for_member_access = true:suggestion
|
||||
|
||||
# Suggest more modern language features when available
|
||||
dotnet_style_object_initializer = true:suggestion
|
||||
dotnet_style_collection_initializer = true:suggestion
|
||||
dotnet_style_coalesce_expression = true:suggestion
|
||||
dotnet_style_null_propagation = true:suggestion
|
||||
dotnet_style_explicit_tuple_names = true:suggestion
|
||||
|
||||
# Non-private static fields are PascalCase
|
||||
dotnet_naming_rule.non_private_static_fields_should_be_pascal_case.severity = suggestion
|
||||
dotnet_naming_rule.non_private_static_fields_should_be_pascal_case.symbols = non_private_static_fields
|
||||
dotnet_naming_rule.non_private_static_fields_should_be_pascal_case.style = non_private_static_field_style
|
||||
|
||||
dotnet_naming_symbols.non_private_static_fields.applicable_kinds = field
|
||||
dotnet_naming_symbols.non_private_static_fields.applicable_accessibilities = public, protected, internal, protected_internal, private_protected
|
||||
dotnet_naming_symbols.non_private_static_fields.required_modifiers = static
|
||||
|
||||
dotnet_naming_style.non_private_static_field_style.capitalization = pascal_case
|
||||
|
||||
# Non-private readonly fields are PascalCase
|
||||
dotnet_naming_rule.non_private_readonly_fields_should_be_pascal_case.severity = suggestion
|
||||
dotnet_naming_rule.non_private_readonly_fields_should_be_pascal_case.symbols = non_private_readonly_fields
|
||||
dotnet_naming_rule.non_private_readonly_fields_should_be_pascal_case.style = non_private_readonly_field_style
|
||||
|
||||
dotnet_naming_symbols.non_private_readonly_fields.applicable_kinds = field
|
||||
dotnet_naming_symbols.non_private_readonly_fields.applicable_accessibilities = public, protected, internal, protected_internal, private_protected
|
||||
dotnet_naming_symbols.non_private_readonly_fields.required_modifiers = readonly
|
||||
|
||||
dotnet_naming_style.non_private_readonly_field_style.capitalization = pascal_case
|
||||
|
||||
# Non-private Constants are PascalCase
|
||||
dotnet_naming_rule.non_private_constants_should_be_camel_case.severity = suggestion
|
||||
dotnet_naming_rule.non_private_constants_should_be_camel_case.symbols = non_private_constants
|
||||
dotnet_naming_rule.non_private_constants_should_be_camel_case.style = non_private_constant_style
|
||||
|
||||
dotnet_naming_symbols.non_private_constants.applicable_kinds = field, local
|
||||
dotnet_naming_symbols.non_private_constants.applicable_accessibilities = public, protected, internal, protected_internal, private_protected
|
||||
dotnet_naming_symbols.non_private_constants.required_modifiers = const
|
||||
|
||||
dotnet_naming_style.non_private_constant_style.capitalization = pascal_case
|
||||
|
||||
# Non-private Instance fields are PascalCase and start with _
|
||||
dotnet_naming_rule.non_private_instance_fields_should_be_camel_case.severity = suggestion
|
||||
dotnet_naming_rule.non_private_instance_fields_should_be_camel_case.symbols = non_private_instance_fields
|
||||
dotnet_naming_rule.non_private_instance_fields_should_be_camel_case.style = non_private_instance_field_style
|
||||
|
||||
dotnet_naming_symbols.non_private_instance_fields.applicable_kinds = field
|
||||
dotnet_naming_symbols.non_private_instance_fields.applicable_accessibilities = public, protected, internal, protected_internal, private_protected
|
||||
|
||||
dotnet_naming_style.non_private_instance_field_style.capitalization = pascal_case
|
||||
|
||||
# Constants are camelCase and start with _
|
||||
dotnet_naming_rule.constants_should_be_camel_case.severity = suggestion
|
||||
dotnet_naming_rule.constants_should_be_camel_case.symbols = constants
|
||||
dotnet_naming_rule.constants_should_be_camel_case.style = constant_style
|
||||
|
||||
dotnet_naming_symbols.constants.applicable_kinds = field, local
|
||||
dotnet_naming_symbols.constants.required_modifiers = const
|
||||
|
||||
dotnet_naming_style.constant_style.capitalization = camel_case
|
||||
dotnet_naming_style.constant_style.required_prefix = _
|
||||
|
||||
# Static fields are camelCase and start with s_
|
||||
dotnet_naming_rule.static_fields_should_be_camel_case.severity = suggestion
|
||||
dotnet_naming_rule.static_fields_should_be_camel_case.symbols = static_fields
|
||||
dotnet_naming_rule.static_fields_should_be_camel_case.style = static_field_style
|
||||
|
||||
dotnet_naming_symbols.static_fields.applicable_kinds = field
|
||||
dotnet_naming_symbols.static_fields.required_modifiers = static
|
||||
|
||||
dotnet_naming_style.static_field_style.capitalization = camel_case
|
||||
dotnet_naming_style.static_field_style.required_prefix = s_
|
||||
|
||||
# Instance fields are camelCase and start with _
|
||||
dotnet_naming_rule.instance_fields_should_be_camel_case.severity = suggestion
|
||||
dotnet_naming_rule.instance_fields_should_be_camel_case.symbols = instance_fields
|
||||
dotnet_naming_rule.instance_fields_should_be_camel_case.style = instance_field_style
|
||||
|
||||
dotnet_naming_symbols.instance_fields.applicable_kinds = field
|
||||
|
||||
dotnet_naming_style.instance_field_style.capitalization = camel_case
|
||||
dotnet_naming_style.instance_field_style.required_prefix = _
|
||||
|
||||
# Locals and parameters are camelCase
|
||||
dotnet_naming_rule.locals_should_be_camel_case.severity = suggestion
|
||||
dotnet_naming_rule.locals_should_be_camel_case.symbols = locals_and_parameters
|
||||
dotnet_naming_rule.locals_should_be_camel_case.style = camel_case_style
|
||||
|
||||
dotnet_naming_symbols.locals_and_parameters.applicable_kinds = parameter, local
|
||||
|
||||
dotnet_naming_style.camel_case_style.capitalization = camel_case
|
||||
|
||||
# Local functions are PascalCase
|
||||
dotnet_naming_rule.local_functions_should_be_pascal_case.severity = suggestion
|
||||
dotnet_naming_rule.local_functions_should_be_pascal_case.symbols = local_functions
|
||||
dotnet_naming_rule.local_functions_should_be_pascal_case.style = local_function_style
|
||||
|
||||
dotnet_naming_symbols.local_functions.applicable_kinds = local_function
|
||||
|
||||
dotnet_naming_style.local_function_style.capitalization = pascal_case
|
||||
|
||||
# By default, name items with PascalCase
|
||||
dotnet_naming_rule.members_should_be_pascal_case.severity = suggestion
|
||||
dotnet_naming_rule.members_should_be_pascal_case.symbols = all_members
|
||||
dotnet_naming_rule.members_should_be_pascal_case.style = pascal_case_style
|
||||
|
||||
dotnet_naming_symbols.all_members.applicable_kinds = *
|
||||
|
||||
dotnet_naming_style.pascal_case_style.capitalization = pascal_case
|
||||
|
||||
# CSharp code style settings:
|
||||
[*.cs]
|
||||
# Newline settings
|
||||
csharp_new_line_before_open_brace = all
|
||||
csharp_new_line_before_else = true
|
||||
csharp_new_line_before_catch = true
|
||||
csharp_new_line_before_finally = true
|
||||
csharp_new_line_before_members_in_object_initializers = true
|
||||
csharp_new_line_before_members_in_anonymous_types = true
|
||||
csharp_new_line_between_query_expression_clauses = true
|
||||
|
||||
# Indentation preferences
|
||||
csharp_indent_block_contents = true
|
||||
csharp_indent_braces = false
|
||||
csharp_indent_case_contents = true
|
||||
csharp_indent_case_contents_when_block = true
|
||||
csharp_indent_switch_labels = true
|
||||
csharp_indent_labels = flush_left
|
||||
|
||||
# Prefer "var" everywhere
|
||||
csharp_style_var_for_built_in_types = true:suggestion
|
||||
csharp_style_var_when_type_is_apparent = true:suggestion
|
||||
csharp_style_var_elsewhere = true:suggestion
|
||||
|
||||
# Prefer method-like constructs to have a block body
|
||||
csharp_style_expression_bodied_methods = false:none
|
||||
csharp_style_expression_bodied_constructors = false:none
|
||||
csharp_style_expression_bodied_operators = false:none
|
||||
|
||||
# Prefer property-like constructs to have an expression-body
|
||||
csharp_style_expression_bodied_properties = true:none
|
||||
csharp_style_expression_bodied_indexers = true:none
|
||||
csharp_style_expression_bodied_accessors = true:none
|
||||
|
||||
# Suggest more modern language features when available
|
||||
csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion
|
||||
csharp_style_pattern_matching_over_as_with_null_check = true:suggestion
|
||||
csharp_style_inlined_variable_declaration = true:suggestion
|
||||
csharp_style_throw_expression = true:suggestion
|
||||
csharp_style_conditional_delegate_call = true:suggestion
|
||||
|
||||
# Space preferences
|
||||
csharp_space_after_cast = false
|
||||
csharp_space_after_colon_in_inheritance_clause = true
|
||||
csharp_space_after_comma = true
|
||||
csharp_space_after_dot = false
|
||||
csharp_space_after_keywords_in_control_flow_statements = true
|
||||
csharp_space_after_semicolon_in_for_statement = true
|
||||
csharp_space_around_binary_operators = before_and_after
|
||||
csharp_space_around_declaration_statements = do_not_ignore
|
||||
csharp_space_before_colon_in_inheritance_clause = true
|
||||
csharp_space_before_comma = false
|
||||
csharp_space_before_dot = false
|
||||
csharp_space_before_open_square_brackets = false
|
||||
csharp_space_before_semicolon_in_for_statement = false
|
||||
csharp_space_between_empty_square_brackets = false
|
||||
csharp_space_between_method_call_empty_parameter_list_parentheses = false
|
||||
csharp_space_between_method_call_name_and_opening_parenthesis = false
|
||||
csharp_space_between_method_call_parameter_list_parentheses = false
|
||||
csharp_space_between_method_declaration_empty_parameter_list_parentheses = false
|
||||
csharp_space_between_method_declaration_name_and_open_parenthesis = false
|
||||
csharp_space_between_method_declaration_parameter_list_parentheses = false
|
||||
csharp_space_between_parentheses = false
|
||||
csharp_space_between_square_brackets = false
|
||||
|
||||
# Blocks are allowed
|
||||
csharp_prefer_braces = true:silent
|
||||
csharp_preserve_single_line_blocks = true
|
||||
csharp_preserve_single_line_statements = true
|
||||
|
||||
#resharper-rider
|
||||
resharper_wrap_before_first_method_call = false
|
||||
resharper_csharp_wrap_after_invocation_lpar = true
|
||||
resharper_csharp_wrap_arguments_style = chop_if_long
|
||||
resharper_csharp_wrap_before_declaration_rpar = false
|
||||
resharper_csharp_wrap_parameters_style = chop_if_long
|
||||
resharper_keep_existing_arrangement = true
|
||||
resharper_keep_existing_attribute_arrangement = true
|
||||
resharper_keep_existing_declaration_parens_arrangement = true
|
||||
resharper_keep_existing_embedded_arrangement = false
|
||||
resharper_keep_existing_initializer_arrangement = true
|
||||
resharper_place_simple_embedded_statement_on_same_line = false
|
||||
resharper_wrap_before_primary_constructor_declaration_lpar = false
|
||||
|
||||
place_single_method_argument_lambda_on_same_line = false
|
||||
Executable
+262
@@ -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/
|
||||
@@ -0,0 +1,8 @@
|
||||
<Solution>
|
||||
<Folder Name="/src/">
|
||||
<Project Path="src/Minesweeper/Minesweeper.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/tests/">
|
||||
<Project Path="tests/Minesweeper.Tests/Minesweeper.Tests.csproj" />
|
||||
</Folder>
|
||||
</Solution>
|
||||
@@ -0,0 +1,111 @@
|
||||
# Minesweeper
|
||||
|
||||
A terminal game of Minesweeper in C# for .NET 10, plus a built-in solver so the game can be
|
||||
played, scripted, and tested without a keyboard.
|
||||
|
||||
```bash
|
||||
dotnet run --project src/Minesweeper # play (Beginner)
|
||||
dotnet run --project src/Minesweeper -- -d expert
|
||||
dotnet run --project src/Minesweeper -- --auto # watch the solver play
|
||||
```
|
||||
|
||||
## Playing
|
||||
|
||||
| Key | Action |
|
||||
| --- | --- |
|
||||
| arrow keys, `Home`, `End` | move the selector |
|
||||
| `space` / `enter` | open a square, or chord when standing on a number |
|
||||
| `c` | chord the number under the selector |
|
||||
| `f` (or `/`) | flag a suspect square |
|
||||
| `q` (or `?`) | question mark a square |
|
||||
| `p` | pause (freezes the clock) |
|
||||
| `n` | new game on the same board |
|
||||
| `d`, `1`, `2`, `3` | next difficulty, or jump to Beginner / Intermediate / Expert |
|
||||
| `s` | dump the board as text, then return |
|
||||
| `h` or `F1` | key list |
|
||||
| `esc` | quit and print the final board |
|
||||
|
||||
Rules are the classic ones: the first click is never a mine (and by default opens an area),
|
||||
a number with exactly as many flags around it as it needs opens the rest, and flagged squares
|
||||
hold back a flood fill. Losing shows every mine and marks flags that were on safe ground.
|
||||
|
||||
## Options
|
||||
|
||||
`--help` lists everything. Highlights:
|
||||
|
||||
```bash
|
||||
minesweeper -d intermediate # 16x16 with 40 mines
|
||||
minesweeper -s 30x20x120 # any board up to 120x60
|
||||
minesweeper -s 12x8 -m 5 # size plus mine count
|
||||
minesweeper --seed 42 # reproducible mines and solver guesses
|
||||
minesweeper --compact --ascii # one plain ASCII character per square
|
||||
minesweeper --no-color # monochrome (NO_COLOR is honoured too)
|
||||
minesweeper --auto-trace --seed 7 # print every solver decision
|
||||
minesweeper --list-levels # show the presets
|
||||
```
|
||||
|
||||
## Auto mode
|
||||
|
||||
`--auto` runs the solver in `src/Minesweeper/Game/Solver.cs`: it applies the two deductions
|
||||
every player uses (a number touching exactly as many hidden squares as it still needs is all
|
||||
mines; a satisfied number chords), then guesses the lowest risk square when logic runs out.
|
||||
It prints the finished board and exits with `0` on a win, `1` on a loss, which makes it a
|
||||
handy end-to-end check:
|
||||
|
||||
```bash
|
||||
dotnet run --project src/Minesweeper -- --auto -d beginner --seed 7
|
||||
```
|
||||
|
||||
## Layout
|
||||
|
||||
| Path | What lives there |
|
||||
| --- | --- |
|
||||
| `src/Minesweeper/Game/Board.cs` | grid, mine placement, flood fill, chording |
|
||||
| `src/Minesweeper/Game/MinesweeperGame.cs` | one round: safe first click, flags, win/loss |
|
||||
| `src/Minesweeper/Game/Solver.cs` | the deduction and guessing strategy |
|
||||
| `src/Minesweeper/Cli/ConsoleRenderer.cs` | flicker-free drawing (only changed squares are written) |
|
||||
| `src/Minesweeper/Cli/InteractiveRunner.cs` | keyboard loop, clock, pause, difficulty switching |
|
||||
| `src/Minesweeper/Cli/Options.cs` | command line parsing |
|
||||
| `tests/Minesweeper.Tests` | rules, solver, difficulty and CLI tests |
|
||||
|
||||
The game logic has no console dependencies, so it can be unit tested or driven headlessly.
|
||||
|
||||
## Building and testing
|
||||
|
||||
```bash
|
||||
dotnet build # or dotnet run --project src/Minesweeper
|
||||
dotnet test # 65 tests
|
||||
```
|
||||
|
||||
## Standalone binaries
|
||||
|
||||
`./publish.sh` produces a self-contained, trimmed, single-file binary for each runtime under
|
||||
`artifacts/<runtime>/`. Nothing has to be installed on the machine that runs them.
|
||||
|
||||
```bash
|
||||
./publish.sh # linux-x64 and win-x64
|
||||
./publish.sh linux-arm64 # any runtime identifier
|
||||
|
||||
dotnet publish src/Minesweeper -c Release -r win-x64 --self-contained \
|
||||
-p:PublishTrimmed=true -p:PublishSingleFile=true # the same thing by hand
|
||||
```
|
||||
|
||||
| Binary | Size | Runs on |
|
||||
| --- | --- | --- |
|
||||
| `artifacts/linux-x64/minesweeper` | 14 MB | 64-bit Linux with glibc |
|
||||
| `artifacts/win-x64/minesweeper.exe` | 13 MB | 64-bit Windows 10 or newer |
|
||||
|
||||
Trimming keeps only the runtime the game touches, and the publish is warning free. Windows
|
||||
binaries can be cross-compiled from Linux or macOS; `--self-contained false` gives a small
|
||||
framework-dependent binary instead.
|
||||
|
||||
## Notes
|
||||
|
||||
* Interactive play needs a real terminal. With output redirected the game says so and points
|
||||
at `--auto`.
|
||||
* Board squares are two characters wide so the field looks square; the game quietly drops to
|
||||
one character when the terminal is too narrow, and refuses boards that still cannot fit.
|
||||
* Colors come off automatically when `NO_COLOR` is set; `--ascii` swaps the shaded blocks,
|
||||
flags and mines for `#`, `F`, `*` if a terminal struggles with them.
|
||||
* A board keeps at least one safe square: asking for more mines than fit is an error, and a
|
||||
board too small for the safe zone simply ends up with fewer mines.
|
||||
Executable
+33
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env bash
|
||||
# Builds self-contained, trimmed, single-file binaries for Linux and Windows.
|
||||
# Usage: ./publish.sh [linux-x64|win-x64|linux-arm64|win-arm64 ...] (default: linux-x64 win-x64)
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
rids=("$@")
|
||||
if [ ${#rids[@]} -eq 0 ]; then
|
||||
rids=(linux-x64 win-x64)
|
||||
fi
|
||||
|
||||
for rid in "${rids[@]}"; do
|
||||
out="artifacts/$rid"
|
||||
echo "==> publishing $rid"
|
||||
dotnet publish src/Minesweeper \
|
||||
--configuration Release \
|
||||
--runtime "$rid" \
|
||||
--self-contained true \
|
||||
-p:PublishTrimmed=true \
|
||||
-p:PublishSingleFile=true \
|
||||
-p:IncludeNativeLibrariesForSelfExtract=true \
|
||||
-p:DebugType=none \
|
||||
-p:DebugSymbols=false \
|
||||
--output "$out"
|
||||
binary="$out/minesweeper"
|
||||
case "$rid" in
|
||||
win-*) binary="$binary.exe" ;;
|
||||
esac
|
||||
du -h "$binary" | sed 's/^/ /'
|
||||
done
|
||||
|
||||
echo "done - binaries are under artifacts/<runtime>/"
|
||||
@@ -0,0 +1,39 @@
|
||||
using System.Diagnostics;
|
||||
using Minesweeper.Game;
|
||||
|
||||
namespace Minesweeper.Cli;
|
||||
|
||||
/// <summary>Hands a board to <see cref="Solver"/> and reports what happened.</summary>
|
||||
public static class AutoRunner
|
||||
{
|
||||
public static int Run(MinesweeperGame game, Options options, Random random)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(game);
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
ArgumentNullException.ThrowIfNull(random);
|
||||
|
||||
var board = game.Board;
|
||||
var seed = options.Seed.HasValue ? options.Seed.Value.ToString() : "random";
|
||||
Console.Out.WriteLine(
|
||||
$"{options.Difficulty.Name} {board.Width}x{board.Height} {board.MineCount} mines seed {seed}");
|
||||
|
||||
var action = options.AutoTrace ? new Action<string>(Console.Out.WriteLine) : null;
|
||||
var solver = new Solver(game, random, options.AutoMaxSteps, action);
|
||||
var clock = Stopwatch.StartNew();
|
||||
var result = solver.Run();
|
||||
clock.Stop();
|
||||
|
||||
if (options.Snapshot)
|
||||
{
|
||||
Console.Out.WriteLine();
|
||||
Console.Out.WriteLine(TextRender.Board(game));
|
||||
}
|
||||
|
||||
Console.Out.WriteLine(
|
||||
$"{result.Status} steps {result.Steps} opened {game.RevealedCount}/{game.TotalSafeCells} " +
|
||||
$"opens {result.Openings} chords {result.Chords} flags {result.Flags} guesses {result.Guesses} " +
|
||||
$"time {clock.Elapsed.TotalSeconds:0.##}s");
|
||||
|
||||
return result.Status == GameStatus.Won ? 0 : 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
using Minesweeper.Game;
|
||||
|
||||
namespace Minesweeper.Cli;
|
||||
|
||||
public sealed record Frame(MinesweeperGame Game, CellRef Cursor, int Seconds, bool Paused, string Message, bool Help);
|
||||
|
||||
/// <summary>
|
||||
/// Draws the board with the console cursor, redrawing only the squares whose glyph or color
|
||||
/// actually changed so the frame never flickers.
|
||||
/// </summary>
|
||||
public sealed class ConsoleRenderer
|
||||
{
|
||||
private static readonly ConsoleColor[] s_numberColors =
|
||||
[
|
||||
ConsoleColor.Gray, ConsoleColor.Blue, ConsoleColor.Green, ConsoleColor.Red,
|
||||
ConsoleColor.DarkBlue, ConsoleColor.DarkRed, ConsoleColor.DarkCyan, ConsoleColor.White,
|
||||
ConsoleColor.DarkGray,
|
||||
];
|
||||
|
||||
private static readonly string[] s_helpBlock =
|
||||
[
|
||||
"",
|
||||
"Keys",
|
||||
" arrows move space/enter open, or chord on a number",
|
||||
" f flag q question mark",
|
||||
" p pause n new game (same board size)",
|
||||
" d next level 1 2 3 beginner / intermediate / expert",
|
||||
" s dump board h toggle this list",
|
||||
" esc quit",
|
||||
"Rules",
|
||||
" The first click never hits a mine. A number with exactly as many flags as it needs",
|
||||
" opens its remaining neighbors (chording). Flags hold back a flood fill.",
|
||||
];
|
||||
|
||||
private readonly bool _color;
|
||||
private readonly int _margin;
|
||||
private readonly int _cellWidth;
|
||||
private readonly int _frameWidth;
|
||||
private readonly GlyphSet _glyphs;
|
||||
private readonly Board _board;
|
||||
private readonly string _levelName;
|
||||
private readonly List<List<Glyph>> _previous = [];
|
||||
private readonly ConsoleColor _defaultForeground;
|
||||
private readonly ConsoleColor _defaultBackground;
|
||||
private ConsoleColor? _appliedForeground;
|
||||
private ConsoleColor? _appliedBackground;
|
||||
|
||||
public ConsoleRenderer(Options options, Board board, string levelName)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
_board = board ?? throw new ArgumentNullException(nameof(board));
|
||||
_levelName = levelName ?? throw new ArgumentNullException(nameof(levelName));
|
||||
_color = options.Color;
|
||||
_glyphs = options.Ascii ? GlyphSet.Ascii : GlyphSet.Unicode;
|
||||
_cellWidth = options.Compact ? 1 : 2;
|
||||
_margin = board.Width * _cellWidth + 1 <= SafeWindowWidth() ? 1 : 0;
|
||||
_frameWidth = Math.Min(Math.Max(_margin + board.Width * _cellWidth, 46), SafeWindowWidth());
|
||||
|
||||
if (_color)
|
||||
{
|
||||
_defaultForeground = Console.ForegroundColor;
|
||||
_defaultBackground = Console.BackgroundColor;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms the board fits the terminal, quietly switching to one character squares when
|
||||
/// two would overflow.
|
||||
/// </summary>
|
||||
public static bool Fits(Options options, Board board, out string? error)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
ArgumentNullException.ThrowIfNull(board);
|
||||
error = null;
|
||||
|
||||
var width = SafeWindowWidth();
|
||||
var height = SafeWindowHeight();
|
||||
|
||||
if (board.Height + 7 > height)
|
||||
{
|
||||
error = $"A {board.Height} row board needs {board.Height + 7} terminal rows but only {height} are available. " +
|
||||
"Use a shorter board with --size or maximize the window.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!options.Compact && board.Width * 2 + 1 > width)
|
||||
options.Compact = true;
|
||||
|
||||
if (board.Width + 1 > width)
|
||||
{
|
||||
error = $"A {board.Width} column board needs {board.Width + 1} terminal columns but only {width} are available. " +
|
||||
"Use a narrower board with --size or maximize the window.";
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Number of terminal rows the frame occupies.</summary>
|
||||
public int Height(bool help) => _board.Height + 6 + (help ? s_helpBlock.Length : 0);
|
||||
|
||||
public void Draw(Frame frame)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(frame);
|
||||
var panel = BuildPanel(frame);
|
||||
|
||||
for (var row = 0; row < panel.Count; row++)
|
||||
{
|
||||
var glyphs = panel[row];
|
||||
var stale = row < _previous.Count ? _previous[row] : null;
|
||||
var column = 0;
|
||||
for (var index = 0; index < glyphs.Count; index++)
|
||||
{
|
||||
var glyph = glyphs[index];
|
||||
if (stale is not null && index < stale.Count && stale[index] == glyph)
|
||||
{
|
||||
column += glyph.Text.Length;
|
||||
continue;
|
||||
}
|
||||
|
||||
Console.SetCursorPosition(column, row);
|
||||
Write(glyph);
|
||||
column += glyph.Text.Length;
|
||||
}
|
||||
}
|
||||
|
||||
// A shorter frame (help turned off) must not leave ghost rows behind.
|
||||
for (var row = panel.Count; row < _previous.Count; row++)
|
||||
{
|
||||
Console.SetCursorPosition(0, row);
|
||||
Reset();
|
||||
Console.Out.Write(new string(' ', _previous[row].Sum(glyph => glyph.Text.Length)));
|
||||
}
|
||||
|
||||
_previous.Clear();
|
||||
_previous.AddRange(panel);
|
||||
Reset();
|
||||
Console.Out.Flush();
|
||||
}
|
||||
|
||||
/// <summary>Moves the terminal caret onto the selected square, so color is not the only cue.</summary>
|
||||
public void PlaceCaret(CellRef cursor)
|
||||
{
|
||||
var column = _margin + cursor.X * _cellWidth;
|
||||
if (column < SafeWindowWidth())
|
||||
Console.SetCursorPosition(column, GridTop + cursor.Y);
|
||||
}
|
||||
|
||||
/// <summary>Forgets the diff cache, for example after <see cref="Console.Clear"/>.</summary>
|
||||
public void Forget() => _previous.Clear();
|
||||
|
||||
private static int GridTop => 3;
|
||||
|
||||
private List<List<Glyph>> BuildPanel(Frame frame)
|
||||
{
|
||||
var game = frame.Game;
|
||||
var rows = new List<List<Glyph>>();
|
||||
|
||||
var counters = new RowBuilder(this, _frameWidth);
|
||||
counters.Append(" Mines ", ConsoleColor.DarkGray);
|
||||
counters.Append(FormatCounter(game.MinesRemaining), ConsoleColor.Yellow);
|
||||
counters.Append(" Time ", ConsoleColor.DarkGray);
|
||||
counters.Append(FormatCounter(frame.Seconds), ConsoleColor.Cyan);
|
||||
counters.Append($" Square {frame.Cursor.X},{frame.Cursor.Y}", ConsoleColor.DarkGray);
|
||||
counters.Pad();
|
||||
rows.Add(counters.Glyphs);
|
||||
|
||||
var title = new RowBuilder(this, _frameWidth);
|
||||
title.Append(" Minesweeper", ConsoleColor.Gray);
|
||||
title.Append($" {_levelName} {_board.Width}x{_board.Height}", ConsoleColor.DarkGray);
|
||||
title.Append($" {game.TotalSafeCells - game.RevealedCount} safe left", ConsoleColor.DarkGray);
|
||||
title.Pad();
|
||||
rows.Add(title.Glyphs);
|
||||
|
||||
rows.Add(Rule());
|
||||
|
||||
var finished = game.Status != GameStatus.Playing;
|
||||
for (var y = 0; y < _board.Height; y++)
|
||||
{
|
||||
var row = new List<Glyph>();
|
||||
var used = 0;
|
||||
if (_margin > 0)
|
||||
{
|
||||
row.Add(new Glyph(" ", null, null));
|
||||
used++;
|
||||
}
|
||||
|
||||
for (var x = 0; x < _board.Width; x++)
|
||||
{
|
||||
var glyph = CellGlyph(_board.At(x, y), frame.Cursor.X == x && frame.Cursor.Y == y, finished);
|
||||
row.Add(glyph);
|
||||
used += glyph.Text.Length;
|
||||
}
|
||||
|
||||
for (; used < _frameWidth; used++)
|
||||
row.Add(new Glyph(" ", null, null));
|
||||
rows.Add(row);
|
||||
}
|
||||
|
||||
rows.Add(Rule());
|
||||
|
||||
var (statusText, statusColor) = game.Status switch
|
||||
{
|
||||
GameStatus.Won => ($"Cleared in {frame.Seconds}s", ConsoleColor.Green),
|
||||
GameStatus.Lost => ($"Boom - a mine, after {frame.Seconds}s", ConsoleColor.Red),
|
||||
_ when frame.Paused => ("Paused", ConsoleColor.Magenta),
|
||||
_ => ("Playing", ConsoleColor.Gray),
|
||||
};
|
||||
var status = new RowBuilder(this, _frameWidth);
|
||||
status.Append($" {statusText}", statusColor);
|
||||
status.Append(_frameWidth >= 86
|
||||
? " space open f flag q ? p pause n new d level s dump h help esc quit"
|
||||
: " h help n new esc quit", ConsoleColor.DarkGray);
|
||||
status.Pad();
|
||||
rows.Add(status.Glyphs);
|
||||
|
||||
var message = new RowBuilder(this, _frameWidth);
|
||||
message.Append($" {frame.Message}", ConsoleColor.DarkGray);
|
||||
message.Pad();
|
||||
rows.Add(message.Glyphs);
|
||||
|
||||
if (frame.Help)
|
||||
{
|
||||
foreach (var line in s_helpBlock)
|
||||
{
|
||||
var help = new RowBuilder(this, _frameWidth);
|
||||
var heading = line is "Keys" or "Rules";
|
||||
help.Append(" " + line, heading ? ConsoleColor.Gray : ConsoleColor.DarkGray);
|
||||
help.Pad();
|
||||
rows.Add(help.Glyphs);
|
||||
}
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
private List<Glyph> Rule()
|
||||
{
|
||||
var row = new List<Glyph>();
|
||||
for (var column = 0; column < _frameWidth; column++)
|
||||
row.Add(new Glyph(_glyphs.Separator, ConsoleColor.DarkGray, null));
|
||||
return row;
|
||||
}
|
||||
|
||||
private Glyph CellGlyph(Cell cell, bool selected, bool finished)
|
||||
{
|
||||
string text;
|
||||
ConsoleColor? foreground;
|
||||
ConsoleColor? background;
|
||||
|
||||
if (cell.IsFlagged)
|
||||
{
|
||||
var wrong = finished && !cell.IsMine;
|
||||
text = Tile(wrong ? _glyphs.WrongFlag : _glyphs.Flag);
|
||||
foreground = wrong ? ConsoleColor.Red : ConsoleColor.Yellow;
|
||||
background = ConsoleColor.DarkGray;
|
||||
}
|
||||
else if (cell.State == CellState.Question)
|
||||
{
|
||||
text = Tile(_glyphs.Question);
|
||||
foreground = ConsoleColor.Black;
|
||||
background = ConsoleColor.Gray;
|
||||
}
|
||||
else if (cell.IsRevealed)
|
||||
{
|
||||
if (cell.IsMine)
|
||||
{
|
||||
text = Tile(_glyphs.Mine);
|
||||
foreground = cell.Exploded ? ConsoleColor.White : ConsoleColor.Red;
|
||||
background = cell.Exploded ? ConsoleColor.Red : null;
|
||||
}
|
||||
else if (cell.AdjacentMines == 0)
|
||||
{
|
||||
text = Tile(" ");
|
||||
foreground = null;
|
||||
background = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
var digit = cell.AdjacentMines.ToString();
|
||||
text = _cellWidth == 2 ? digit + " " : digit;
|
||||
foreground = s_numberColors[Math.Min(cell.AdjacentMines, s_numberColors.Length - 1)];
|
||||
background = null;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
text = Tile(_glyphs.Hidden);
|
||||
foreground = ConsoleColor.DarkGray;
|
||||
background = ConsoleColor.Gray;
|
||||
}
|
||||
|
||||
if (selected)
|
||||
background = cell.IsRevealed ? ConsoleColor.Gray : ConsoleColor.DarkCyan;
|
||||
|
||||
return new Glyph(text, _color ? foreground : null, _color ? background : null);
|
||||
}
|
||||
|
||||
private string Tile(string glyph) => _cellWidth == 2 ? glyph + glyph : glyph;
|
||||
|
||||
private static string FormatCounter(int value) => value switch
|
||||
{
|
||||
> 999 => "999+",
|
||||
>= 0 => value.ToString("D3"),
|
||||
< -9 => "--",
|
||||
_ => "-" + Math.Abs(value),
|
||||
};
|
||||
|
||||
private static int SafeWindowWidth()
|
||||
{
|
||||
try
|
||||
{
|
||||
return Console.WindowWidth > 0 ? Console.WindowWidth : 80;
|
||||
}
|
||||
catch (Exception exception) when (exception is InvalidOperationException or IOException)
|
||||
{
|
||||
return 80;
|
||||
}
|
||||
}
|
||||
|
||||
private static int SafeWindowHeight()
|
||||
{
|
||||
try
|
||||
{
|
||||
return Console.WindowHeight > 0 ? Console.WindowHeight : 24;
|
||||
}
|
||||
catch (Exception exception) when (exception is InvalidOperationException or IOException)
|
||||
{
|
||||
return 24;
|
||||
}
|
||||
}
|
||||
|
||||
private void Write(Glyph glyph)
|
||||
{
|
||||
if (_color)
|
||||
{
|
||||
var foreground = glyph.Foreground ?? _defaultForeground;
|
||||
var background = glyph.Background ?? _defaultBackground;
|
||||
if (foreground != _appliedForeground)
|
||||
{
|
||||
Console.ForegroundColor = foreground;
|
||||
_appliedForeground = foreground;
|
||||
}
|
||||
if (background != _appliedBackground)
|
||||
{
|
||||
Console.BackgroundColor = background;
|
||||
_appliedBackground = background;
|
||||
}
|
||||
}
|
||||
|
||||
Console.Out.Write(glyph.Text);
|
||||
}
|
||||
|
||||
private void Reset()
|
||||
{
|
||||
if (!_color)
|
||||
return;
|
||||
Console.ForegroundColor = _defaultForeground;
|
||||
Console.BackgroundColor = _defaultBackground;
|
||||
_appliedForeground = null;
|
||||
_appliedBackground = null;
|
||||
}
|
||||
|
||||
private readonly record struct Glyph(string Text, ConsoleColor? Foreground, ConsoleColor? Background);
|
||||
|
||||
private sealed record GlyphSet(string Hidden, string Mine, string Flag, string Question, string WrongFlag, string Separator)
|
||||
{
|
||||
public static GlyphSet Unicode { get; } = new("▒", "✱", "⚑", "?", "✗", "─");
|
||||
|
||||
public static GlyphSet Ascii { get; } = new("#", "*", "F", "?", "X", "-");
|
||||
}
|
||||
|
||||
private sealed class RowBuilder
|
||||
{
|
||||
private readonly ConsoleRenderer _renderer;
|
||||
private readonly int _width;
|
||||
|
||||
public RowBuilder(ConsoleRenderer renderer, int width)
|
||||
{
|
||||
_renderer = renderer;
|
||||
_width = width;
|
||||
Glyphs = [];
|
||||
}
|
||||
|
||||
public List<Glyph> Glyphs { get; }
|
||||
|
||||
public RowBuilder Append(string text, ConsoleColor? foreground = null)
|
||||
{
|
||||
foreach (var character in text)
|
||||
{
|
||||
if (Glyphs.Count >= _width)
|
||||
break;
|
||||
Glyphs.Add(new Glyph(character.ToString(), _renderer._color ? foreground : null, null));
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
public void Pad()
|
||||
{
|
||||
while (Glyphs.Count < _width)
|
||||
Glyphs.Add(new Glyph(" ", null, null));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace Minesweeper.Cli;
|
||||
|
||||
/// <summary>Round timer that starts on the first click and can be paused.</summary>
|
||||
public sealed class GameClock
|
||||
{
|
||||
private readonly Stopwatch _stopwatch = new();
|
||||
|
||||
public bool IsRunning { get; private set; }
|
||||
|
||||
public bool Paused { get; private set; }
|
||||
|
||||
public int ElapsedSeconds => (int)_stopwatch.Elapsed.TotalSeconds;
|
||||
|
||||
public void Start()
|
||||
{
|
||||
if (IsRunning)
|
||||
return;
|
||||
IsRunning = true;
|
||||
_stopwatch.Start();
|
||||
}
|
||||
|
||||
public void TogglePause()
|
||||
{
|
||||
if (!IsRunning)
|
||||
return;
|
||||
if (Paused)
|
||||
{
|
||||
Paused = false;
|
||||
_stopwatch.Start();
|
||||
}
|
||||
else
|
||||
{
|
||||
Paused = true;
|
||||
_stopwatch.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
_stopwatch.Stop();
|
||||
Paused = false;
|
||||
IsRunning = false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using System.Text;
|
||||
using Minesweeper.Game;
|
||||
|
||||
namespace Minesweeper.Cli;
|
||||
|
||||
public static class HelpText
|
||||
{
|
||||
public static void Write(TextWriter output)
|
||||
{
|
||||
output.Write(Usage);
|
||||
}
|
||||
|
||||
public static string Usage { get; } = BuildUsage();
|
||||
|
||||
private static string BuildUsage()
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
builder.AppendLine("minesweeper - a terminal game of Minesweeper");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine("Usage: minesweeper [options]");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine("Options:");
|
||||
builder.AppendLine(" -d, --difficulty <name> beginner | intermediate | expert");
|
||||
builder.AppendLine(" (aliases: novice, normal, hard, b/i/e, 1/2/3)");
|
||||
builder.AppendLine(" -s, --size <WxHxM> custom board, e.g. 14x10x25 (mines optional)");
|
||||
builder.AppendLine(" -m, --mines <count> override the mine count for the chosen size");
|
||||
builder.AppendLine(" --seed <number> reproducible mine placement and guesses");
|
||||
builder.AppendLine(" --no-safe-zone first click is only guaranteed mine-free itself");
|
||||
builder.AppendLine(" --no-question-marks drop the ? mark, flags toggle straight off");
|
||||
builder.AppendLine(" --compact one character per square (narrow terminals)");
|
||||
builder.AppendLine(" --ascii plain ASCII squares instead of shaded blocks");
|
||||
builder.AppendLine(" --no-color monochrome output (also honors NO_COLOR)");
|
||||
builder.AppendLine(" --auto let the built-in solver play and print the board");
|
||||
builder.AppendLine(" --auto-trace one line per solver decision (implies --auto)");
|
||||
builder.AppendLine(" --auto-max-steps <n> solver step budget (default 20000)");
|
||||
builder.AppendLine(" --no-snapshot do not print the final board as text");
|
||||
builder.AppendLine(" --list-levels show the difficulty presets");
|
||||
builder.AppendLine(" -h, --help show this help");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine("Playing:");
|
||||
builder.AppendLine(" arrow keys move, space/enter opens (or chords on a number),");
|
||||
builder.AppendLine(" f flags, q question marks, p pauses, n new game, d next difficulty,");
|
||||
builder.AppendLine(" s prints the board as text, h shows help, esc quits.");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine("The first click is always safe and, by default, opens an area.");
|
||||
builder.AppendLine("--auto exits with 0 when the solver wins and 1 when it steps on a mine.");
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
public static string Levels()
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
foreach (var preset in DifficultyProfile.Presets)
|
||||
builder.AppendLine($"{preset.Name,-13} {preset.Width,3}x{preset.Height,-3} {preset.Mines,3} mines {preset.MineDensity}");
|
||||
return builder.ToString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
using Minesweeper.Game;
|
||||
|
||||
namespace Minesweeper.Cli;
|
||||
|
||||
public enum RoundOutcome
|
||||
{
|
||||
Quit,
|
||||
NewGame,
|
||||
|
||||
/// <summary>The round could not start, for example a board too large for the terminal.</summary>
|
||||
Failed,
|
||||
}
|
||||
|
||||
/// <summary>The keyboard-driven game loop.</summary>
|
||||
public static class InteractiveRunner
|
||||
{
|
||||
private static volatile bool s_interrupted;
|
||||
|
||||
public static int Run(Options options, Random random)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
ArgumentNullException.ThrowIfNull(random);
|
||||
|
||||
if (Console.IsInputRedirected || Console.IsOutputRedirected)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
"Interactive play needs a real terminal. Run with --auto to watch the solver play and print the board.");
|
||||
return 3;
|
||||
}
|
||||
|
||||
var difficulty = options.Difficulty;
|
||||
MinesweeperGame? lastGame = null;
|
||||
var failed = false;
|
||||
|
||||
Console.CancelKeyPress += (_, eventArgs) =>
|
||||
{
|
||||
eventArgs.Cancel = true;
|
||||
s_interrupted = true;
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
Console.CursorVisible = false;
|
||||
while (!s_interrupted)
|
||||
{
|
||||
var round = PlayRound(options, random, difficulty);
|
||||
if (round.Game is not null)
|
||||
lastGame = round.Game;
|
||||
if (round.NextDifficulty is not null)
|
||||
difficulty = round.NextDifficulty;
|
||||
if (round.Outcome is RoundOutcome.Quit or RoundOutcome.Failed)
|
||||
{
|
||||
failed = round.Outcome == RoundOutcome.Failed;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
RestoreTerminal();
|
||||
}
|
||||
|
||||
if (options.Snapshot && lastGame is not null && !failed)
|
||||
{
|
||||
// The frame leaves the caret in the middle of the board, so start a clean screen.
|
||||
Console.Clear();
|
||||
Console.Out.WriteLine();
|
||||
Console.Out.Write(TextRender.Board(lastGame));
|
||||
Console.Out.WriteLine();
|
||||
Console.Out.WriteLine(
|
||||
$"Status {lastGame.Status} mines {lastGame.Board.MineCount} flags {lastGame.FlagsPlaced} " +
|
||||
$"open squares {lastGame.RevealedCount}/{lastGame.TotalSafeCells}");
|
||||
Console.Out.WriteLine();
|
||||
}
|
||||
|
||||
if (failed)
|
||||
return 4;
|
||||
|
||||
return s_interrupted ? 130 : 0;
|
||||
}
|
||||
|
||||
private static (RoundOutcome Outcome, MinesweeperGame? Game, DifficultyProfile? NextDifficulty) PlayRound(
|
||||
Options options,
|
||||
Random random,
|
||||
DifficultyProfile difficulty)
|
||||
{
|
||||
var board = new Board(difficulty.Width, difficulty.Height, difficulty.Mines);
|
||||
if (!ConsoleRenderer.Fits(options, board, out var fitError))
|
||||
{
|
||||
Console.Error.WriteLine(fitError);
|
||||
return (RoundOutcome.Failed, null, null);
|
||||
}
|
||||
|
||||
var game = new MinesweeperGame(board, random) { ReserveSafeZone = options.ReserveSafeZone };
|
||||
var renderer = new ConsoleRenderer(options, board, difficulty.Name);
|
||||
var clock = new GameClock();
|
||||
var cursor = new CellRef(board.Width / 2, board.Height / 2);
|
||||
var message = "Arrow keys move, space opens. The first square is always safe.";
|
||||
var help = false;
|
||||
|
||||
Console.Clear();
|
||||
renderer.Forget();
|
||||
|
||||
void Move(int dx, int dy) => cursor = new CellRef(
|
||||
Math.Clamp(cursor.X + dx, 0, board.Width - 1),
|
||||
Math.Clamp(cursor.Y + dy, 0, board.Height - 1));
|
||||
|
||||
void OpenOrChord(bool forceChord)
|
||||
{
|
||||
if (!game.HasOpenedFirstCell)
|
||||
clock.Start();
|
||||
|
||||
var wasOpen = board.At(cursor).IsRevealed;
|
||||
var result = forceChord || wasOpen ? game.Chord(cursor) : game.Open(cursor);
|
||||
|
||||
if (game.Status != GameStatus.Playing)
|
||||
clock.Stop();
|
||||
|
||||
message = game.Status switch
|
||||
{
|
||||
GameStatus.Lost => "Boom. n for a new board, esc to quit.",
|
||||
GameStatus.Won => $"Cleared in {clock.ElapsedSeconds}s. n for a new board.",
|
||||
_ when !result.AnyOpened => forceChord || wasOpen
|
||||
? $"Square {cursor.X},{cursor.Y} needs exactly {board.At(cursor).AdjacentMines} flags to chord."
|
||||
: "Nothing to do there.",
|
||||
_ => $"+{result.Opened.Count} square{(result.Opened.Count == 1 ? "" : "s")} opened",
|
||||
};
|
||||
}
|
||||
|
||||
void Flag()
|
||||
{
|
||||
if (game.ToggleFlag(cursor))
|
||||
message = board.At(cursor).IsFlagged ? "Flagged." : "Flag cleared.";
|
||||
else if (game.Status != GameStatus.Playing)
|
||||
message = "Round over. n starts a new board.";
|
||||
else
|
||||
message = "That square is already open.";
|
||||
}
|
||||
|
||||
void Question()
|
||||
{
|
||||
if (game.ToggleQuestion(cursor, options.QuestionMarks))
|
||||
message = board.At(cursor).State == CellState.Question ? "Marked with a question." : "Mark cleared.";
|
||||
else if (!options.QuestionMarks)
|
||||
message = "Question marks are disabled.";
|
||||
else
|
||||
message = "That square is already open.";
|
||||
}
|
||||
|
||||
void ShowDump()
|
||||
{
|
||||
Console.Clear();
|
||||
Reset();
|
||||
Console.Out.WriteLine(TextRender.Board(game));
|
||||
Console.Out.WriteLine("Press any key to return to the game.");
|
||||
Console.ReadKey(intercept: true);
|
||||
Console.Clear();
|
||||
renderer.Forget();
|
||||
}
|
||||
|
||||
while (true)
|
||||
{
|
||||
if (s_interrupted)
|
||||
return (RoundOutcome.Quit, game, null);
|
||||
|
||||
var seconds = clock.ElapsedSeconds;
|
||||
renderer.Draw(new Frame(game, cursor, seconds, clock.Paused, message, help));
|
||||
renderer.PlaceCaret(cursor);
|
||||
|
||||
while (!Console.KeyAvailable)
|
||||
{
|
||||
if (s_interrupted)
|
||||
return (RoundOutcome.Quit, game, null);
|
||||
Thread.Sleep(60);
|
||||
if (clock.ElapsedSeconds == seconds)
|
||||
continue;
|
||||
seconds = clock.ElapsedSeconds;
|
||||
renderer.Draw(new Frame(game, cursor, seconds, clock.Paused, message, help));
|
||||
renderer.PlaceCaret(cursor);
|
||||
}
|
||||
|
||||
var key = Console.ReadKey(intercept: true);
|
||||
|
||||
if (clock.Paused && key.Key is not (ConsoleKey.P or ConsoleKey.N or ConsoleKey.R or ConsoleKey.D
|
||||
or ConsoleKey.Escape or ConsoleKey.X or ConsoleKey.D1 or ConsoleKey.D2 or ConsoleKey.D3))
|
||||
{
|
||||
message = "Paused - press p to resume.";
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (key.Key)
|
||||
{
|
||||
case ConsoleKey.LeftArrow:
|
||||
Move(-1, 0);
|
||||
break;
|
||||
case ConsoleKey.RightArrow:
|
||||
Move(1, 0);
|
||||
break;
|
||||
case ConsoleKey.UpArrow:
|
||||
Move(0, -1);
|
||||
break;
|
||||
case ConsoleKey.DownArrow:
|
||||
Move(0, 1);
|
||||
break;
|
||||
case ConsoleKey.Home:
|
||||
cursor = new CellRef(0, 0);
|
||||
break;
|
||||
case ConsoleKey.End:
|
||||
cursor = new CellRef(board.Width - 1, board.Height - 1);
|
||||
break;
|
||||
case ConsoleKey.Spacebar:
|
||||
case ConsoleKey.Enter:
|
||||
OpenOrChord(forceChord: false);
|
||||
break;
|
||||
case ConsoleKey.C:
|
||||
OpenOrChord(forceChord: true);
|
||||
break;
|
||||
case ConsoleKey.F:
|
||||
Flag();
|
||||
break;
|
||||
case ConsoleKey.Oem2 when key.KeyChar == '?':
|
||||
Question();
|
||||
break;
|
||||
case ConsoleKey.Oem2:
|
||||
Flag();
|
||||
break;
|
||||
case ConsoleKey.Q:
|
||||
Question();
|
||||
break;
|
||||
case ConsoleKey.P:
|
||||
clock.TogglePause();
|
||||
message = clock.Paused ? "Paused." : "Resumed.";
|
||||
break;
|
||||
case ConsoleKey.S:
|
||||
ShowDump();
|
||||
break;
|
||||
case ConsoleKey.H:
|
||||
case ConsoleKey.F1:
|
||||
help = !help;
|
||||
message = help ? "Help shown again with h." : "Help hidden.";
|
||||
break;
|
||||
case ConsoleKey.N:
|
||||
case ConsoleKey.R:
|
||||
return (RoundOutcome.NewGame, game, null);
|
||||
case ConsoleKey.D:
|
||||
return (RoundOutcome.NewGame, game, NextDifficulty(difficulty));
|
||||
case ConsoleKey.D1:
|
||||
return (RoundOutcome.NewGame, game, DifficultyProfile.Beginner);
|
||||
case ConsoleKey.D2:
|
||||
return (RoundOutcome.NewGame, game, DifficultyProfile.Intermediate);
|
||||
case ConsoleKey.D3:
|
||||
return (RoundOutcome.NewGame, game, DifficultyProfile.Expert);
|
||||
case ConsoleKey.Escape:
|
||||
case ConsoleKey.X:
|
||||
clock.Stop();
|
||||
return (RoundOutcome.Quit, game, null);
|
||||
default:
|
||||
message = "Press h for the key list.";
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static DifficultyProfile NextDifficulty(DifficultyProfile current)
|
||||
{
|
||||
var presets = DifficultyProfile.Presets;
|
||||
for (var index = 0; index < presets.Count; index++)
|
||||
{
|
||||
if (string.Equals(presets[index].Name, current.Name, StringComparison.Ordinal))
|
||||
return presets[(index + 1) % presets.Count];
|
||||
}
|
||||
|
||||
return presets[0];
|
||||
}
|
||||
|
||||
private static void Reset()
|
||||
{
|
||||
Console.ResetColor();
|
||||
}
|
||||
|
||||
private static void RestoreTerminal()
|
||||
{
|
||||
try
|
||||
{
|
||||
Console.ResetColor();
|
||||
Console.CursorVisible = true;
|
||||
}
|
||||
catch (Exception exception) when (exception is InvalidOperationException or IOException)
|
||||
{
|
||||
// A detached terminal cannot restore the caret; nothing useful to do.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
using System.Globalization;
|
||||
using Minesweeper.Game;
|
||||
|
||||
namespace Minesweeper.Cli;
|
||||
|
||||
public sealed class Options
|
||||
{
|
||||
public DifficultyProfile Difficulty { get; set; } = DifficultyProfile.Beginner;
|
||||
|
||||
public int? Seed { get; set; }
|
||||
|
||||
public bool ReserveSafeZone { get; set; } = true;
|
||||
|
||||
public bool QuestionMarks { get; set; } = true;
|
||||
|
||||
/// <summary>One character per square instead of two, for narrow terminals.</summary>
|
||||
public bool Compact { get; set; }
|
||||
|
||||
public bool Ascii { get; set; }
|
||||
|
||||
public bool Color { get; set; } = true;
|
||||
|
||||
public bool Auto { get; set; }
|
||||
|
||||
public bool AutoTrace { get; set; }
|
||||
|
||||
public int AutoMaxSteps { get; set; } = 20000;
|
||||
|
||||
/// <summary>Print the board as plain text when the round ends.</summary>
|
||||
public bool Snapshot { get; set; } = true;
|
||||
|
||||
public bool ListLevels { get; set; }
|
||||
}
|
||||
|
||||
public static class CommandLine
|
||||
{
|
||||
public static bool TryParse(string[] args, out Options options, out List<string> errors, out bool showHelp)
|
||||
{
|
||||
options = new Options();
|
||||
errors = [];
|
||||
showHelp = false;
|
||||
|
||||
DifficultyProfile? requested = null;
|
||||
int? mines = null;
|
||||
var colorOverride = false;
|
||||
|
||||
for (var i = 0; i < args.Length; i++)
|
||||
{
|
||||
var argument = args[i];
|
||||
switch (argument)
|
||||
{
|
||||
case "-h":
|
||||
case "-?":
|
||||
case "--help":
|
||||
showHelp = true;
|
||||
break;
|
||||
case "-d":
|
||||
case "--difficulty":
|
||||
case "--level":
|
||||
if (!TryValue(args, ref i, argument, errors, out var difficulty))
|
||||
continue;
|
||||
if (!DifficultyProfile.TryResolve(difficulty, out var resolved, out var resolutionError))
|
||||
{
|
||||
errors.Add(resolutionError!);
|
||||
continue;
|
||||
}
|
||||
requested = resolved;
|
||||
break;
|
||||
case "-s":
|
||||
case "--size":
|
||||
if (!TryValue(args, ref i, argument, errors, out var size))
|
||||
continue;
|
||||
if (!DifficultyProfile.TryResolve(size, out var custom, out var sizeError))
|
||||
{
|
||||
errors.Add(sizeError!);
|
||||
continue;
|
||||
}
|
||||
requested = custom;
|
||||
break;
|
||||
case "-m":
|
||||
case "--mines":
|
||||
if (!TryValue(args, ref i, argument, errors, out var mineText))
|
||||
continue;
|
||||
if (!int.TryParse(mineText, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedMines) || parsedMines < 0)
|
||||
{
|
||||
errors.Add($"--mines expects a non-negative number, got '{mineText}'.");
|
||||
continue;
|
||||
}
|
||||
mines = parsedMines;
|
||||
break;
|
||||
case "--seed":
|
||||
if (!TryValue(args, ref i, argument, errors, out var seedText))
|
||||
continue;
|
||||
if (!int.TryParse(seedText, NumberStyles.Integer, CultureInfo.InvariantCulture, out var seed))
|
||||
{
|
||||
errors.Add($"--seed expects a whole number, got '{seedText}'.");
|
||||
continue;
|
||||
}
|
||||
options.Seed = seed;
|
||||
break;
|
||||
case "--auto-max-steps":
|
||||
if (!TryValue(args, ref i, argument, errors, out var stepsText))
|
||||
continue;
|
||||
if (!int.TryParse(stepsText, NumberStyles.Integer, CultureInfo.InvariantCulture, out var steps) || steps <= 0)
|
||||
{
|
||||
errors.Add($"--auto-max-steps expects a positive number, got '{stepsText}'.");
|
||||
continue;
|
||||
}
|
||||
options.AutoMaxSteps = steps;
|
||||
break;
|
||||
case "--safe-zone":
|
||||
options.ReserveSafeZone = true;
|
||||
break;
|
||||
case "--no-safe-zone":
|
||||
options.ReserveSafeZone = false;
|
||||
break;
|
||||
case "--question-marks":
|
||||
options.QuestionMarks = true;
|
||||
break;
|
||||
case "--no-question-marks":
|
||||
options.QuestionMarks = false;
|
||||
break;
|
||||
case "--compact":
|
||||
options.Compact = true;
|
||||
break;
|
||||
case "--wide":
|
||||
options.Compact = false;
|
||||
break;
|
||||
case "--ascii":
|
||||
options.Ascii = true;
|
||||
break;
|
||||
case "--unicode":
|
||||
options.Ascii = false;
|
||||
break;
|
||||
case "--color":
|
||||
colorOverride = true;
|
||||
options.Color = true;
|
||||
break;
|
||||
case "--no-color":
|
||||
colorOverride = true;
|
||||
options.Color = false;
|
||||
break;
|
||||
case "--auto":
|
||||
options.Auto = true;
|
||||
break;
|
||||
case "--auto-trace":
|
||||
options.Auto = true;
|
||||
options.AutoTrace = true;
|
||||
break;
|
||||
case "--snapshot":
|
||||
options.Snapshot = true;
|
||||
break;
|
||||
case "--no-snapshot":
|
||||
options.Snapshot = false;
|
||||
break;
|
||||
case "--list-levels":
|
||||
options.ListLevels = true;
|
||||
break;
|
||||
default:
|
||||
errors.Add($"Unknown option '{argument}'.");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (showHelp)
|
||||
return errors.Count == 0;
|
||||
|
||||
if (requested is not null)
|
||||
options.Difficulty = requested;
|
||||
|
||||
if (mines is not null)
|
||||
{
|
||||
var profile = options.Difficulty;
|
||||
if (mines.Value > (long)profile.Width * profile.Height - 1)
|
||||
errors.Add($"{mines.Value} mines do not leave a safe square on a {profile.Width}x{profile.Height} board.");
|
||||
else
|
||||
options.Difficulty = profile with { Mines = mines.Value, Name = "Custom" };
|
||||
}
|
||||
|
||||
if (!colorOverride)
|
||||
options.Color = Console.IsOutputRedirected ? false : Environment.GetEnvironmentVariable("NO_COLOR") is not { Length: > 0 };
|
||||
|
||||
if (options.Auto)
|
||||
options.Snapshot = true;
|
||||
|
||||
return errors.Count == 0;
|
||||
}
|
||||
|
||||
private static bool TryValue(string[] args, ref int index, string name, List<string> errors, out string value)
|
||||
{
|
||||
if (index + 1 < args.Length)
|
||||
{
|
||||
value = args[++index];
|
||||
return true;
|
||||
}
|
||||
|
||||
errors.Add($"{name} needs a value.");
|
||||
value = string.Empty;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using System.Text;
|
||||
using Minesweeper.Game;
|
||||
|
||||
namespace Minesweeper.Cli;
|
||||
|
||||
/// <summary>Plain ASCII board dumps, used for snapshots and piped output.</summary>
|
||||
public static class TextRender
|
||||
{
|
||||
public const char HiddenMark = '#';
|
||||
|
||||
public static string Board(MinesweeperGame game)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(game);
|
||||
var board = game.Board;
|
||||
var finished = game.Status != GameStatus.Playing;
|
||||
var builder = new StringBuilder();
|
||||
|
||||
builder.AppendLine($"{game.Board.MineCount} mines on a {board.Width}x{board.Height} board - {game.Status}");
|
||||
for (var y = 0; y < board.Height; y++)
|
||||
{
|
||||
for (var x = 0; x < board.Width; x++)
|
||||
builder.Append(Glyph(board.At(x, y), finished));
|
||||
builder.AppendLine();
|
||||
}
|
||||
|
||||
builder.Append("legend: # hidden . empty 1-8 numbers F flag ? question * mine @ exploded x wrong flag");
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private static char Glyph(Cell cell, bool finished)
|
||||
{
|
||||
if (cell.IsFlagged)
|
||||
return finished && !cell.IsMine ? 'x' : 'F';
|
||||
if (cell.State == CellState.Question)
|
||||
return '?';
|
||||
if (!cell.IsRevealed)
|
||||
return HiddenMark;
|
||||
if (cell.IsMine)
|
||||
return cell.Exploded ? '@' : '*';
|
||||
return cell.AdjacentMines == 0 ? '.' : (char)('0' + cell.AdjacentMines);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
namespace Minesweeper.Game;
|
||||
|
||||
/// <summary>
|
||||
/// The Minesweeper grid: mine placement, opening squares (including flood fill)
|
||||
/// and chording. Holds no notion of game progress - see <see cref="MinesweeperGame"/>.
|
||||
/// </summary>
|
||||
public sealed class Board
|
||||
{
|
||||
private readonly Cell[,] _cells;
|
||||
|
||||
public Board(int width, int height, int mineCount)
|
||||
{
|
||||
if (width <= 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(width), width, "Board width must be positive.");
|
||||
if (height <= 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(height), height, "Board height must be positive.");
|
||||
if (mineCount < 0 || mineCount > (long)width * height - 1)
|
||||
throw new ArgumentOutOfRangeException(nameof(mineCount), mineCount, "A board must keep at least one safe square.");
|
||||
|
||||
Width = width;
|
||||
Height = height;
|
||||
MineCount = mineCount;
|
||||
_cells = new Cell[width, height];
|
||||
for (var x = 0; x < width; x++)
|
||||
for (var y = 0; y < height; y++)
|
||||
_cells[x, y] = new Cell();
|
||||
}
|
||||
|
||||
public int Width { get; }
|
||||
|
||||
public int Height { get; }
|
||||
|
||||
/// <summary>Total mines on the board. Reduced before the first click if the board is too small.</summary>
|
||||
public int MineCount { get; private set; }
|
||||
|
||||
public int CellCount => Width * Height;
|
||||
|
||||
public bool MinesPlanted { get; private set; }
|
||||
|
||||
public Cell At(CellRef reference) => At(reference.X, reference.Y);
|
||||
|
||||
public Cell At(int x, int y)
|
||||
{
|
||||
if (!InBounds(x, y))
|
||||
throw new ArgumentOutOfRangeException(nameof(x), x, $"Square ({x},{y}) is outside the {Width}x{Height} board.");
|
||||
return _cells[x, y];
|
||||
}
|
||||
|
||||
public bool InBounds(CellRef reference) => InBounds(reference.X, reference.Y);
|
||||
|
||||
public bool InBounds(int x, int y) => x >= 0 && y >= 0 && x < Width && y < Height;
|
||||
|
||||
public IEnumerable<CellRef> AllCells()
|
||||
{
|
||||
for (var y = 0; y < Height; y++)
|
||||
for (var x = 0; x < Width; x++)
|
||||
yield return new CellRef(x, y);
|
||||
}
|
||||
|
||||
public IEnumerable<CellRef> NeighborsOf(CellRef reference) => NeighborsOf(reference.X, reference.Y);
|
||||
|
||||
public IEnumerable<CellRef> NeighborsOf(int x, int y)
|
||||
{
|
||||
for (var dy = -1; dy <= 1; dy++)
|
||||
{
|
||||
for (var dx = -1; dx <= 1; dx++)
|
||||
{
|
||||
if (dx == 0 && dy == 0)
|
||||
continue;
|
||||
|
||||
var nx = x + dx;
|
||||
var ny = y + dy;
|
||||
if (InBounds(nx, ny))
|
||||
yield return new CellRef(nx, ny);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public IEnumerable<CellRef> MineCells()
|
||||
{
|
||||
foreach (var reference in AllCells())
|
||||
if (At(reference).IsMine)
|
||||
yield return reference;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Randomly scatters <see cref="MineCount"/> mines after the first click, never on
|
||||
/// <paramref name="safeSpot"/> and - when <paramref name="reserveSafeZone"/> is set - never
|
||||
/// next to it either, so the opening click always reveals an area.
|
||||
/// </summary>
|
||||
public void PlantMines(CellRef safeSpot, bool reserveSafeZone, Random random)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(random);
|
||||
if (MinesPlanted)
|
||||
throw new InvalidOperationException("Mines have already been planted.");
|
||||
|
||||
var forbidden = new HashSet<CellRef> { safeSpot };
|
||||
if (reserveSafeZone)
|
||||
foreach (var neighbor in NeighborsOf(safeSpot))
|
||||
forbidden.Add(neighbor);
|
||||
|
||||
var candidates = new List<CellRef>();
|
||||
foreach (var reference in AllCells())
|
||||
if (!forbidden.Contains(reference))
|
||||
candidates.Add(reference);
|
||||
|
||||
var toPlant = Math.Min(MineCount, candidates.Count);
|
||||
for (var i = 0; i < toPlant; i++)
|
||||
{
|
||||
var swap = random.Next(i, candidates.Count);
|
||||
(candidates[i], candidates[swap]) = (candidates[swap], candidates[i]);
|
||||
At(candidates[i]).IsMine = true;
|
||||
}
|
||||
|
||||
MineCount = toPlant;
|
||||
MinesPlanted = true;
|
||||
ComputeNumbers();
|
||||
}
|
||||
|
||||
/// <summary>Plants an exact set of mines - handy for puzzles and tests.</summary>
|
||||
public void PlantMines(IEnumerable<CellRef> mines)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(mines);
|
||||
if (MinesPlanted)
|
||||
throw new InvalidOperationException("Mines have already been planted.");
|
||||
|
||||
var planted = new HashSet<CellRef>();
|
||||
foreach (var mine in mines)
|
||||
{
|
||||
At(mine).IsMine = true;
|
||||
planted.Add(mine);
|
||||
}
|
||||
|
||||
MineCount = planted.Count;
|
||||
MinesPlanted = true;
|
||||
ComputeNumbers();
|
||||
}
|
||||
|
||||
/// <summary>Recomputes every adjacency number from the current mine layout.</summary>
|
||||
public void ComputeNumbers()
|
||||
{
|
||||
foreach (var reference in AllCells())
|
||||
{
|
||||
var count = 0;
|
||||
foreach (var neighbor in NeighborsOf(reference))
|
||||
if (At(neighbor).IsMine)
|
||||
count++;
|
||||
At(reference).AdjacentMines = count;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens one square. Numbered squares open alone; blank squares flood outwards and
|
||||
/// stop at flagged squares, mirroring the classic game.
|
||||
/// </summary>
|
||||
public OpenResult Open(CellRef reference)
|
||||
{
|
||||
var cell = At(reference);
|
||||
if (!cell.IsHidden)
|
||||
return OpenResult.None;
|
||||
|
||||
if (cell.IsMine)
|
||||
{
|
||||
cell.State = CellState.Revealed;
|
||||
return OpenResult.Mine(reference);
|
||||
}
|
||||
|
||||
if (cell.AdjacentMines > 0)
|
||||
{
|
||||
cell.State = CellState.Revealed;
|
||||
return new OpenResult(new[] { reference });
|
||||
}
|
||||
|
||||
var opened = new List<CellRef>();
|
||||
var queued = new HashSet<CellRef> { reference };
|
||||
var pending = new Stack<CellRef>();
|
||||
pending.Push(reference);
|
||||
|
||||
while (pending.Count > 0)
|
||||
{
|
||||
var current = pending.Pop();
|
||||
var currentCell = At(current);
|
||||
if (currentCell.State is CellState.Revealed or CellState.Flagged)
|
||||
continue;
|
||||
|
||||
currentCell.State = CellState.Revealed;
|
||||
opened.Add(current);
|
||||
if (currentCell.AdjacentMines > 0)
|
||||
continue;
|
||||
|
||||
foreach (var neighbor in NeighborsOf(current))
|
||||
if (queued.Add(neighbor))
|
||||
pending.Push(neighbor);
|
||||
}
|
||||
|
||||
return new OpenResult(opened);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Chords a revealed number: when exactly the right number of flags surround it, every
|
||||
/// remaining hidden neighbor opens. A mismatch just "pops" without opening anything.
|
||||
/// </summary>
|
||||
public OpenResult Chord(CellRef reference)
|
||||
{
|
||||
var cell = At(reference);
|
||||
if (!cell.IsRevealed || cell.AdjacentMines == 0)
|
||||
return OpenResult.None;
|
||||
|
||||
var hidden = new List<CellRef>();
|
||||
var flagged = 0;
|
||||
foreach (var neighbor in NeighborsOf(reference))
|
||||
{
|
||||
var neighborCell = At(neighbor);
|
||||
if (neighborCell.IsFlagged)
|
||||
flagged++;
|
||||
else if (neighborCell.IsHidden)
|
||||
hidden.Add(neighbor);
|
||||
}
|
||||
|
||||
if (hidden.Count == 0 || flagged != cell.AdjacentMines)
|
||||
return OpenResult.None;
|
||||
|
||||
var opened = new List<CellRef>();
|
||||
CellRef? boom = null;
|
||||
foreach (var candidate in hidden)
|
||||
{
|
||||
var result = Open(candidate);
|
||||
opened.AddRange(result.Opened);
|
||||
if (result.HitMine)
|
||||
{
|
||||
boom = result.Boom;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return new OpenResult(opened, boom);
|
||||
}
|
||||
|
||||
/// <summary>Marks a square as the losing mine and shows every other mine.</summary>
|
||||
public void Explode(CellRef reference)
|
||||
{
|
||||
var cell = At(reference);
|
||||
cell.Exploded = true;
|
||||
cell.State = CellState.Revealed;
|
||||
}
|
||||
|
||||
/// <summary>Reveals unflagged mines, used when the game is lost or the solution is shown.</summary>
|
||||
public void RevealAllMines()
|
||||
{
|
||||
foreach (var mine in MineCells())
|
||||
if (!At(mine).IsFlagged)
|
||||
At(mine).State = CellState.Revealed;
|
||||
}
|
||||
|
||||
/// <summary>Forces a mine square to show a flag, used when the player wins.</summary>
|
||||
public void ForceFlag(CellRef reference)
|
||||
{
|
||||
At(reference).State = CellState.Flagged;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
namespace Minesweeper.Game;
|
||||
|
||||
/// <summary>How a single board square is currently shown to the player.</summary>
|
||||
public enum CellState
|
||||
{
|
||||
Hidden,
|
||||
Revealed,
|
||||
Flagged,
|
||||
Question,
|
||||
}
|
||||
|
||||
public sealed class Cell
|
||||
{
|
||||
public bool IsMine { get; set; }
|
||||
|
||||
/// <summary>Number of mines in the eight surrounding squares.</summary>
|
||||
public int AdjacentMines { get; set; }
|
||||
|
||||
public CellState State { get; set; } = CellState.Hidden;
|
||||
|
||||
/// <summary>True for the mine the player opened to end the game.</summary>
|
||||
public bool Exploded { get; set; }
|
||||
|
||||
public bool IsRevealed => State == CellState.Revealed;
|
||||
|
||||
public bool IsFlagged => State == CellState.Flagged;
|
||||
|
||||
/// <summary>Anything the player can still open or mark.</summary>
|
||||
public bool IsHidden => State is CellState.Hidden or CellState.Question;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
namespace Minesweeper.Game;
|
||||
|
||||
/// <summary>A board coordinate, where (0,0) is the top-left square.</summary>
|
||||
public readonly record struct CellRef(int X, int Y);
|
||||
@@ -0,0 +1,103 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace Minesweeper.Game;
|
||||
|
||||
/// <summary>A board size plus its mine count.</summary>
|
||||
public sealed record DifficultyProfile(string Name, int Width, int Height, int Mines)
|
||||
{
|
||||
public const int MaxWidth = 120;
|
||||
public const int MaxHeight = 60;
|
||||
|
||||
public static DifficultyProfile Beginner { get; } = new("Beginner", 9, 9, 10);
|
||||
|
||||
public static DifficultyProfile Intermediate { get; } = new("Intermediate", 16, 16, 40);
|
||||
|
||||
public static DifficultyProfile Expert { get; } = new("Expert", 30, 16, 99);
|
||||
|
||||
public static IReadOnlyList<DifficultyProfile> Presets { get; } = [Beginner, Intermediate, Expert];
|
||||
|
||||
public string SizeLabel => $"{Width}x{Height}";
|
||||
|
||||
public string MineDensity => (100d * Mines / Math.Max(1, Width * Height)).ToString("0.#", CultureInfo.InvariantCulture) + "%";
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a user supplied name: a preset alias (<c>expert</c>, <c>e</c>, <c>3</c>) or a
|
||||
/// custom size such as <c>14x10x25</c> (the mine count may be omitted for ~15% density).
|
||||
/// </summary>
|
||||
public static bool TryResolve(string value, out DifficultyProfile? profile, out string? error)
|
||||
{
|
||||
profile = null;
|
||||
error = null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
error = "A difficulty name or board size is required.";
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (value.Trim().ToLowerInvariant())
|
||||
{
|
||||
case "1":
|
||||
case "b":
|
||||
case "beginner":
|
||||
case "novice":
|
||||
case "easy":
|
||||
profile = Beginner;
|
||||
return true;
|
||||
case "2":
|
||||
case "i":
|
||||
case "intermediate":
|
||||
case "normal":
|
||||
case "medium":
|
||||
profile = Intermediate;
|
||||
return true;
|
||||
case "3":
|
||||
case "e":
|
||||
case "expert":
|
||||
case "hard":
|
||||
profile = Expert;
|
||||
return true;
|
||||
}
|
||||
|
||||
var parts = value.Trim().Split(['x', 'X', '*', ',', ':', '-'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
if (parts.Length is < 2 or > 3)
|
||||
{
|
||||
error = $"Unknown difficulty '{value}'. Use beginner, intermediate, expert, or a size such as 16x16x40.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!int.TryParse(parts[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out var width) ||
|
||||
!int.TryParse(parts[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var height))
|
||||
{
|
||||
error = $"Could not read a width and height from '{value}'. Try 16x16x40.";
|
||||
return false;
|
||||
}
|
||||
|
||||
var mines = parts.Length == 3
|
||||
? int.TryParse(parts[2], NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed)
|
||||
? parsed
|
||||
: 0
|
||||
: Math.Max(1, (int)Math.Round(width * height * 0.15));
|
||||
|
||||
if (parts.Length == 3 && mines <= 0)
|
||||
{
|
||||
error = $"Could not read a mine count from '{value}'. Try 16x16x40.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (width < 1 || width > MaxWidth || height < 1 || height > MaxHeight)
|
||||
{
|
||||
error = $"Board size must fit within {MaxWidth}x{MaxHeight}.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (mines > (long)width * height - 1)
|
||||
{
|
||||
error = $"{mines} mines do not leave a safe square on a {width}x{height} board.";
|
||||
return false;
|
||||
}
|
||||
|
||||
profile = new DifficultyProfile("Custom", width, height, mines);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
namespace Minesweeper.Game;
|
||||
|
||||
public enum GameStatus
|
||||
{
|
||||
Playing,
|
||||
Won,
|
||||
Lost,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tracks one round of Minesweeper on top of a <see cref="Board"/>: the safe first click,
|
||||
/// flags, and win/loss detection. Deliberately free of any console code so it can be tested
|
||||
/// or driven by the solver.
|
||||
/// </summary>
|
||||
public sealed class MinesweeperGame
|
||||
{
|
||||
private readonly Random _random;
|
||||
|
||||
public MinesweeperGame(Board board, Random random)
|
||||
{
|
||||
Board = board ?? throw new ArgumentNullException(nameof(board));
|
||||
_random = random ?? throw new ArgumentNullException(nameof(random));
|
||||
}
|
||||
|
||||
public Board Board { get; }
|
||||
|
||||
public GameStatus Status { get; private set; } = GameStatus.Playing;
|
||||
|
||||
/// <summary>
|
||||
/// Mines only exist after the first click, which is always safe. A board handed to the
|
||||
/// game with mines already planted skips that step.
|
||||
/// </summary>
|
||||
public bool HasOpenedFirstCell => Board.MinesPlanted;
|
||||
|
||||
/// <summary>When true the first click also guarantees a mine-free neighborhood.</summary>
|
||||
public bool ReserveSafeZone { get; init; } = true;
|
||||
|
||||
public int RevealedCount { get; private set; }
|
||||
|
||||
public int FlagsPlaced { get; private set; }
|
||||
|
||||
public CellRef? Explosion { get; private set; }
|
||||
|
||||
public int MinesRemaining => Board.MineCount - FlagsPlaced;
|
||||
|
||||
/// <summary>Safe squares still hidden - the classic "holes left" counter.</summary>
|
||||
public int SafeCellsRemaining => Math.Max(0, Board.CellCount - Board.MineCount - RevealedCount);
|
||||
|
||||
public int TotalSafeCells => Board.CellCount - Board.MineCount;
|
||||
|
||||
public OpenResult Open(int x, int y) => Open(new CellRef(x, y));
|
||||
|
||||
public OpenResult Open(CellRef reference)
|
||||
{
|
||||
if (Status != GameStatus.Playing)
|
||||
return OpenResult.None;
|
||||
|
||||
var cell = Board.At(reference);
|
||||
if (!cell.IsHidden)
|
||||
return OpenResult.None;
|
||||
|
||||
if (!Board.MinesPlanted)
|
||||
Board.PlantMines(reference, ReserveSafeZone, _random);
|
||||
|
||||
var result = Board.Open(reference);
|
||||
if (result.HitMine)
|
||||
{
|
||||
Lose(result.Boom!.Value);
|
||||
return result;
|
||||
}
|
||||
|
||||
RevealedCount += result.Opened.Count;
|
||||
CheckWin();
|
||||
return result;
|
||||
}
|
||||
|
||||
public OpenResult Chord(CellRef reference)
|
||||
{
|
||||
if (Status != GameStatus.Playing)
|
||||
return OpenResult.None;
|
||||
if (!Board.At(reference).IsRevealed)
|
||||
return OpenResult.None;
|
||||
|
||||
var result = Board.Chord(reference);
|
||||
if (result.HitMine)
|
||||
{
|
||||
Lose(result.Boom!.Value);
|
||||
return result;
|
||||
}
|
||||
|
||||
if (result.AnyOpened)
|
||||
{
|
||||
RevealedCount += result.Opened.Count;
|
||||
CheckWin();
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>Flag or clear a square; a question mark becomes a cleared square.</summary>
|
||||
public bool ToggleFlag(CellRef reference) => Board.At(reference).State switch
|
||||
{
|
||||
CellState.Hidden => TryMark(reference, CellState.Flagged),
|
||||
CellState.Flagged => TryMark(reference, CellState.Hidden),
|
||||
CellState.Question => TryMark(reference, CellState.Hidden),
|
||||
_ => false,
|
||||
};
|
||||
|
||||
/// <summary>Question mark or clear a square. Does nothing when question marks are off.</summary>
|
||||
public bool ToggleQuestion(CellRef reference, bool questionMarksEnabled)
|
||||
{
|
||||
if (!questionMarksEnabled)
|
||||
return false;
|
||||
|
||||
return Board.At(reference).State switch
|
||||
{
|
||||
CellState.Hidden => TryMark(reference, CellState.Question),
|
||||
CellState.Flagged => TryMark(reference, CellState.Hidden),
|
||||
CellState.Question => TryMark(reference, CellState.Hidden),
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Sets the mark on a hidden square, keeping the flag counter in step.</summary>
|
||||
public bool TryMark(CellRef reference, CellState target)
|
||||
{
|
||||
if (Status != GameStatus.Playing)
|
||||
return false;
|
||||
if (target is not (CellState.Hidden or CellState.Flagged or CellState.Question))
|
||||
return false;
|
||||
|
||||
var cell = Board.At(reference);
|
||||
if (cell.IsRevealed || cell.State == target)
|
||||
return false;
|
||||
|
||||
if (cell.IsFlagged)
|
||||
FlagsPlaced--;
|
||||
if (target == CellState.Flagged)
|
||||
FlagsPlaced++;
|
||||
|
||||
cell.State = target;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Shows the solution: every mine, plus which flags were wrong.</summary>
|
||||
public void RevealSolution()
|
||||
{
|
||||
if (Status == GameStatus.Playing)
|
||||
return;
|
||||
Board.RevealAllMines();
|
||||
}
|
||||
|
||||
private void Lose(CellRef reference)
|
||||
{
|
||||
Status = GameStatus.Lost;
|
||||
Explosion = reference;
|
||||
Board.Explode(reference);
|
||||
Board.RevealAllMines();
|
||||
}
|
||||
|
||||
private void CheckWin()
|
||||
{
|
||||
if (Status != GameStatus.Playing)
|
||||
return;
|
||||
if (RevealedCount < TotalSafeCells)
|
||||
return;
|
||||
|
||||
Status = GameStatus.Won;
|
||||
foreach (var mine in Board.MineCells())
|
||||
Board.ForceFlag(mine);
|
||||
FlagsPlaced = Board.MineCount;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace Minesweeper.Game;
|
||||
|
||||
/// <summary>What an open or chord action changed on the board.</summary>
|
||||
public sealed record OpenResult(IReadOnlyList<CellRef> Opened, CellRef? Boom = null)
|
||||
{
|
||||
public static OpenResult None { get; } = new(Array.Empty<CellRef>());
|
||||
|
||||
public static OpenResult Mine(CellRef reference) => new(new[] { reference }, reference);
|
||||
|
||||
public bool HitMine => Boom.HasValue;
|
||||
|
||||
public bool AnyOpened => Opened.Count > 0;
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
namespace Minesweeper.Game;
|
||||
|
||||
public sealed record SolverResult(
|
||||
GameStatus Status,
|
||||
int Steps,
|
||||
int Openings,
|
||||
int Chords,
|
||||
int Flags,
|
||||
int Guesses);
|
||||
|
||||
/// <summary>
|
||||
/// Plays a board with the two deductions every human uses - a number touching exactly as many
|
||||
/// hidden squares as it still needs is all mines, and a satisfied number chords - then guesses
|
||||
/// the lowest risk square when logic runs out.
|
||||
/// </summary>
|
||||
public sealed class Solver
|
||||
{
|
||||
private readonly Action<string>? _onStep;
|
||||
private readonly MinesweeperGame _game;
|
||||
private readonly int _maxSteps;
|
||||
private readonly Random _random;
|
||||
|
||||
public Solver(MinesweeperGame game, Random random, int maxSteps = 20000, Action<string>? onStep = null)
|
||||
{
|
||||
_game = game ?? throw new ArgumentNullException(nameof(game));
|
||||
_random = random ?? throw new ArgumentNullException(nameof(random));
|
||||
_maxSteps = maxSteps > 0 ? maxSteps : throw new ArgumentOutOfRangeException(nameof(maxSteps));
|
||||
_onStep = onStep;
|
||||
}
|
||||
|
||||
public SolverResult Run()
|
||||
{
|
||||
var steps = 0;
|
||||
var openings = 0;
|
||||
var chords = 0;
|
||||
var flags = 0;
|
||||
var guesses = 0;
|
||||
|
||||
while (_game.Status == GameStatus.Playing && steps < _maxSteps)
|
||||
{
|
||||
steps++;
|
||||
|
||||
if (!_game.HasOpenedFirstCell)
|
||||
{
|
||||
var first = new CellRef(_game.Board.Width / 2, _game.Board.Height / 2);
|
||||
var opened = _game.Open(first);
|
||||
openings++;
|
||||
Report($"open ({first.X},{first.Y}) first click +{opened.Opened.Count,-3} revealed");
|
||||
continue;
|
||||
}
|
||||
|
||||
var deduction = DeduceOnce();
|
||||
if (deduction.Changed)
|
||||
{
|
||||
chords += deduction.Chords;
|
||||
flags += deduction.Flags;
|
||||
continue;
|
||||
}
|
||||
|
||||
var guess = PickGuess();
|
||||
if (guess is null)
|
||||
break;
|
||||
|
||||
var result = _game.Open(guess.Value);
|
||||
guesses++;
|
||||
openings++;
|
||||
Report($"guess ({guess.Value.X},{guess.Value.Y}) risk {Risk(guess.Value):0.##} +{result.Opened.Count,-3} revealed");
|
||||
}
|
||||
|
||||
return new SolverResult(_game.Status, steps, openings, chords, flags, guesses);
|
||||
}
|
||||
|
||||
private (bool Changed, int Chords, int Flags) DeduceOnce()
|
||||
{
|
||||
var changed = false;
|
||||
var chords = 0;
|
||||
var flags = 0;
|
||||
|
||||
foreach (var reference in _game.Board.AllCells().ToArray())
|
||||
{
|
||||
if (_game.Status != GameStatus.Playing)
|
||||
break;
|
||||
|
||||
var cell = _game.Board.At(reference);
|
||||
if (!cell.IsRevealed || cell.AdjacentMines == 0)
|
||||
continue;
|
||||
|
||||
var neighborhood = Inspect(reference);
|
||||
if (neighborhood.Hidden.Count == 0)
|
||||
continue;
|
||||
|
||||
if (neighborhood.Flagged == cell.AdjacentMines)
|
||||
{
|
||||
var result = _game.Chord(reference);
|
||||
if (!result.AnyOpened && !result.HitMine)
|
||||
continue;
|
||||
chords++;
|
||||
changed = true;
|
||||
Report($"chord ({reference.X},{reference.Y}) number {cell.AdjacentMines} satisfied +{result.Opened.Count,-3} revealed");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (neighborhood.Hidden.Count == cell.AdjacentMines - neighborhood.Flagged)
|
||||
{
|
||||
var marked = 0;
|
||||
foreach (var hidden in neighborhood.Hidden)
|
||||
if (_game.TryMark(hidden, CellState.Flagged))
|
||||
marked++;
|
||||
if (marked == 0)
|
||||
continue;
|
||||
flags += marked;
|
||||
changed = true;
|
||||
Report($"flag ({reference.X},{reference.Y}) number {cell.AdjacentMines} forces +{marked,-3} flags");
|
||||
}
|
||||
}
|
||||
|
||||
return (changed, chords, flags);
|
||||
}
|
||||
|
||||
private CellRef? PickGuess()
|
||||
{
|
||||
CellRef? best = null;
|
||||
var bestRisk = double.MaxValue;
|
||||
var bestInfo = int.MinValue;
|
||||
|
||||
foreach (var reference in _game.Board.AllCells())
|
||||
{
|
||||
if (!_game.Board.At(reference).IsHidden)
|
||||
continue;
|
||||
|
||||
// Jitter keeps ties interesting without disturbing real risk comparisons.
|
||||
var risk = Risk(reference) + (_random.NextDouble() - 0.5) * 1e-6;
|
||||
var info = Inspect(reference).Hidden.Count;
|
||||
|
||||
if (risk < bestRisk - 1e-9 || (Math.Abs(risk - bestRisk) <= 1e-9 && info > bestInfo))
|
||||
{
|
||||
best = reference;
|
||||
bestRisk = risk;
|
||||
bestInfo = info;
|
||||
}
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
/// <summary>Rough odds that a hidden square hides a mine, based on the numbers touching it.</summary>
|
||||
private double Risk(CellRef reference)
|
||||
{
|
||||
var total = 0d;
|
||||
var terms = 0;
|
||||
|
||||
foreach (var neighbor in _game.Board.NeighborsOf(reference))
|
||||
{
|
||||
var cell = _game.Board.At(neighbor);
|
||||
if (!cell.IsRevealed || cell.AdjacentMines == 0)
|
||||
continue;
|
||||
|
||||
var neighborhood = Inspect(neighbor);
|
||||
var remaining = cell.AdjacentMines - neighborhood.Flagged;
|
||||
if (remaining <= 0 || neighborhood.Hidden.Count == 0)
|
||||
continue;
|
||||
|
||||
total += (double)remaining / neighborhood.Hidden.Count;
|
||||
terms++;
|
||||
}
|
||||
|
||||
return terms == 0 ? 0d : total / terms;
|
||||
}
|
||||
|
||||
private Neighborhood Inspect(CellRef reference)
|
||||
{
|
||||
var hidden = new List<CellRef>();
|
||||
var flagged = 0;
|
||||
foreach (var neighbor in _game.Board.NeighborsOf(reference))
|
||||
{
|
||||
var cell = _game.Board.At(neighbor);
|
||||
if (cell.IsFlagged)
|
||||
flagged++;
|
||||
else if (cell.IsHidden)
|
||||
hidden.Add(neighbor);
|
||||
}
|
||||
|
||||
return new Neighborhood(hidden, flagged);
|
||||
}
|
||||
|
||||
private void Report(string message) => _onStep?.Invoke(message);
|
||||
|
||||
private sealed record Neighborhood(List<CellRef> Hidden, int Flagged);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<RootNamespace>Minesweeper</RootNamespace>
|
||||
<AssemblyName>minesweeper</AssemblyName>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<InvariantGlobalization>true</InvariantGlobalization>
|
||||
<Version>1.0.0</Version>
|
||||
<Description>A terminal game of Minesweeper with a built-in solver.</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -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);
|
||||
@@ -0,0 +1,216 @@
|
||||
using Minesweeper.Game;
|
||||
using Xunit;
|
||||
|
||||
namespace Minesweeper.Tests;
|
||||
|
||||
public class BoardTests
|
||||
{
|
||||
private static readonly string[] s_floodLayout =
|
||||
[
|
||||
".....",
|
||||
".....",
|
||||
".....",
|
||||
".....",
|
||||
"...x.",
|
||||
];
|
||||
|
||||
private static readonly string[] s_twinMineLayout =
|
||||
[
|
||||
"x.x",
|
||||
"...",
|
||||
"...",
|
||||
];
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, 5, 1)]
|
||||
[InlineData(5, 0, 1)]
|
||||
[InlineData(3, 3, -1)]
|
||||
[InlineData(3, 3, 9)]
|
||||
public void ConstructorRejectsImpossibleBoards(int width, int height, int mines) =>
|
||||
Assert.ThrowsAny<ArgumentOutOfRangeException>(() => new Board(width, height, mines));
|
||||
|
||||
[Fact]
|
||||
public void FirstClickKeepsTheChosenSquareClear()
|
||||
{
|
||||
for (var seed = 0; seed < 50; seed++)
|
||||
{
|
||||
var board = new Board(9, 9, 10);
|
||||
board.PlantMines(new CellRef(4, 4), reserveSafeZone: false, new Random(seed));
|
||||
|
||||
Assert.False(board.At(4, 4).IsMine);
|
||||
Assert.Equal(10, board.MineCount);
|
||||
Assert.Equal(10, board.MineCells().Count());
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FirstClickCanAlsoKeepItsNeighborhoodClear()
|
||||
{
|
||||
for (var seed = 0; seed < 50; seed++)
|
||||
{
|
||||
var board = new Board(9, 9, 10);
|
||||
board.PlantMines(new CellRef(4, 4), reserveSafeZone: true, new Random(seed));
|
||||
|
||||
Assert.Equal(0, board.MineCells().Count(m => m == new CellRef(4, 4) || board.NeighborsOf(4, 4).Contains(m)));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PlantingTwiceThrows()
|
||||
{
|
||||
var board = TestBoards.Create(s_floodLayout);
|
||||
Assert.Throws<InvalidOperationException>(() => board.PlantMines([]));
|
||||
Assert.Throws<InvalidOperationException>(() => board.PlantMines(new CellRef(0, 0), true, new Random(1)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NumbersComeFromTheMineLayout()
|
||||
{
|
||||
var board = TestBoards.Create(s_floodLayout);
|
||||
|
||||
Assert.True(board.At(3, 4).IsMine);
|
||||
Assert.Equal(0, board.At(0, 0).AdjacentMines);
|
||||
Assert.Equal(1, board.At(2, 3).AdjacentMines);
|
||||
Assert.Equal(1, board.At(4, 4).AdjacentMines);
|
||||
Assert.Equal(1, board.MineCount);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, 0, 3)]
|
||||
[InlineData(2, 0, 5)]
|
||||
[InlineData(2, 2, 8)]
|
||||
public void NeighborCountsRespectTheEdges(int x, int y, int expected)
|
||||
{
|
||||
var board = new Board(5, 5, 0);
|
||||
Assert.Equal(expected, board.NeighborsOf(x, y).Count());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OpeningABlankSquareFloodsUntilTheNumbers()
|
||||
{
|
||||
var board = TestBoards.Create(s_floodLayout);
|
||||
var result = board.Open(new CellRef(0, 0));
|
||||
|
||||
Assert.False(result.HitMine);
|
||||
Assert.Equal(23, result.Opened.Count);
|
||||
Assert.False(board.At(3, 4).IsRevealed);
|
||||
Assert.True(board.At(2, 3).IsRevealed);
|
||||
Assert.Equal(23, Revealed(board));
|
||||
|
||||
// (4,4) only touches other numbers and the mine, so a flood can never reach it.
|
||||
Assert.False(board.At(4, 4).IsRevealed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FlagsHoldBackTheFloodFill()
|
||||
{
|
||||
var board = TestBoards.Create(s_floodLayout);
|
||||
board.At(0, 2).State = CellState.Flagged;
|
||||
|
||||
var result = board.Open(new CellRef(0, 0));
|
||||
|
||||
Assert.Equal(22, result.Opened.Count);
|
||||
Assert.True(board.At(0, 2).IsFlagged);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OpeningANumberedSquareRevealsOnlyIt()
|
||||
{
|
||||
var board = TestBoards.Create("x..", "...", "...");
|
||||
var result = board.Open(new CellRef(1, 0));
|
||||
|
||||
Assert.Single(result.Opened);
|
||||
Assert.True(board.At(1, 0).IsRevealed);
|
||||
Assert.False(board.At(2, 2).IsRevealed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OpeningAMineReportsTheBoom()
|
||||
{
|
||||
var board = TestBoards.Create(s_floodLayout);
|
||||
var result = board.Open(new CellRef(3, 4));
|
||||
|
||||
Assert.True(result.HitMine);
|
||||
Assert.Equal(new CellRef(3, 4), result.Boom!.Value);
|
||||
Assert.True(board.At(3, 4).IsRevealed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChordOpensTheRestWhenTheFlagsMatch()
|
||||
{
|
||||
var board = TestBoards.Create(s_twinMineLayout);
|
||||
board.Open(new CellRef(1, 0));
|
||||
board.At(0, 0).State = CellState.Flagged;
|
||||
board.At(2, 0).State = CellState.Flagged;
|
||||
|
||||
var result = board.Chord(new CellRef(1, 0));
|
||||
|
||||
Assert.False(result.HitMine);
|
||||
Assert.Equal(3, result.Opened.Count);
|
||||
Assert.True(board.At(0, 1).IsRevealed);
|
||||
Assert.True(board.At(1, 1).IsRevealed);
|
||||
Assert.True(board.At(2, 1).IsRevealed);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1)]
|
||||
[InlineData(3)]
|
||||
public void ChordWithTheWrongFlagCountJustPops(int flags)
|
||||
{
|
||||
var board = TestBoards.Create(s_twinMineLayout);
|
||||
board.Open(new CellRef(1, 0));
|
||||
board.At(0, 0).State = CellState.Flagged;
|
||||
if (flags > 1)
|
||||
board.At(0, 1).State = CellState.Flagged;
|
||||
if (flags > 2)
|
||||
board.At(1, 1).State = CellState.Flagged;
|
||||
|
||||
var result = board.Chord(new CellRef(1, 0));
|
||||
|
||||
Assert.False(result.AnyOpened);
|
||||
Assert.False(result.HitMine);
|
||||
Assert.Equal(1, Revealed(board));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChordCanStepOnAnUnflaggedMine()
|
||||
{
|
||||
var board = TestBoards.Create(s_twinMineLayout);
|
||||
board.Open(new CellRef(1, 0));
|
||||
board.At(0, 0).State = CellState.Flagged;
|
||||
board.At(0, 1).State = CellState.Flagged;
|
||||
|
||||
var result = board.Chord(new CellRef(1, 0));
|
||||
|
||||
Assert.True(result.HitMine);
|
||||
Assert.Equal(new CellRef(2, 0), result.Boom!.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChordIgnoresHiddenSquares()
|
||||
{
|
||||
var board = TestBoards.Create(s_twinMineLayout);
|
||||
Assert.False(board.Chord(new CellRef(1, 1)).AnyOpened);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TinyBoardsLoseMinesTheyCannotFit()
|
||||
{
|
||||
var board = new Board(2, 2, 3);
|
||||
board.PlantMines(new CellRef(0, 0), reserveSafeZone: true, new Random(3));
|
||||
|
||||
Assert.Equal(0, board.MineCount);
|
||||
Assert.Empty(board.MineCells());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OutOfRangeSquaresThrow()
|
||||
{
|
||||
var board = new Board(3, 3, 1);
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => board.At(3, 0));
|
||||
Assert.False(board.InBounds(-1, 2));
|
||||
Assert.True(board.InBounds(2, 2));
|
||||
}
|
||||
|
||||
private static int Revealed(Board board) => board.AllCells().Count(reference => board.At(reference).IsRevealed);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using Minesweeper.Cli;
|
||||
using Minesweeper.Game;
|
||||
using Xunit;
|
||||
|
||||
namespace Minesweeper.Tests;
|
||||
|
||||
public class CommandLineTests
|
||||
{
|
||||
[Fact]
|
||||
public void DefaultsAreBeginner()
|
||||
{
|
||||
Assert.True(CommandLine.TryParse([], out var options, out var errors, out var help));
|
||||
Assert.Empty(errors);
|
||||
Assert.False(help);
|
||||
Assert.Equal(DifficultyProfile.Beginner, options.Difficulty);
|
||||
Assert.True(options.ReserveSafeZone);
|
||||
Assert.True(options.QuestionMarks);
|
||||
Assert.False(options.Auto);
|
||||
Assert.Null(options.Seed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PresetAndCustomSizesCombine()
|
||||
{
|
||||
Assert.True(CommandLine.TryParse(["-d", "expert"], out var options, out _, out _));
|
||||
Assert.Equal(30, options.Difficulty.Width);
|
||||
Assert.Equal(99, options.Difficulty.Mines);
|
||||
|
||||
Assert.True(CommandLine.TryParse(["--size", "12x8", "--mines", "5"], out options, out _, out _));
|
||||
Assert.Equal((12, 8, 5), (options.Difficulty.Width, options.Difficulty.Height, options.Difficulty.Mines));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SwitchesAreHonoured()
|
||||
{
|
||||
Assert.True(CommandLine.TryParse(
|
||||
["--auto-trace", "--seed", "42", "--no-safe-zone", "--no-question-marks", "--compact", "--ascii", "--no-color"],
|
||||
out var options, out var errors, out _));
|
||||
|
||||
Assert.Empty(errors);
|
||||
Assert.True(options.Auto);
|
||||
Assert.True(options.AutoTrace);
|
||||
Assert.Equal(42, options.Seed);
|
||||
Assert.False(options.ReserveSafeZone);
|
||||
Assert.False(options.QuestionMarks);
|
||||
Assert.True(options.Compact);
|
||||
Assert.True(options.Ascii);
|
||||
Assert.False(options.Color);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HelpShortCircuitsEverythingElse()
|
||||
{
|
||||
Assert.True(CommandLine.TryParse(["--help"], out _, out var errors, out var help));
|
||||
Assert.True(help);
|
||||
Assert.Empty(errors);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(new[] { "--nope" }, "Unknown option")]
|
||||
[InlineData(new[] { "--seed" }, "needs a value")]
|
||||
[InlineData(new[] { "--seed", "abc" }, "expects a whole number")]
|
||||
[InlineData(new[] { "-d", "impossible" }, "Unknown difficulty")]
|
||||
[InlineData(new[] { "--size", "9x9", "--mines", "900" }, "do not leave a safe square")]
|
||||
[InlineData(new[] { "--auto-max-steps", "0" }, "expects a positive number")]
|
||||
public void MistakesAreReported(string[] args, string expectedFragment)
|
||||
{
|
||||
Assert.False(CommandLine.TryParse(args, out _, out var errors, out _));
|
||||
Assert.Contains(errors, error => error.Contains(expectedFragment, StringComparison.Ordinal));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using Minesweeper.Game;
|
||||
using Xunit;
|
||||
|
||||
namespace Minesweeper.Tests;
|
||||
|
||||
public class DifficultyTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("beginner", 9, 9, 10)]
|
||||
[InlineData("NOVICE", 9, 9, 10)]
|
||||
[InlineData("1", 9, 9, 10)]
|
||||
[InlineData("intermediate", 16, 16, 40)]
|
||||
[InlineData("normal", 16, 16, 40)]
|
||||
[InlineData("2", 16, 16, 40)]
|
||||
[InlineData("expert", 30, 16, 99)]
|
||||
[InlineData("e", 30, 16, 99)]
|
||||
[InlineData("3", 30, 16, 99)]
|
||||
[InlineData("14x10x25", 14, 10, 25)]
|
||||
[InlineData("12x8", 12, 8, 14)]
|
||||
[InlineData("10,10,20", 10, 10, 20)]
|
||||
public void NamesResolve(string value, int width, int height, int mines)
|
||||
{
|
||||
Assert.True(DifficultyProfile.TryResolve(value, out var profile, out var error));
|
||||
Assert.Null(error);
|
||||
Assert.Equal(width, profile!.Width);
|
||||
Assert.Equal(height, profile.Height);
|
||||
Assert.Equal(mines, profile.Mines);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("banana")]
|
||||
[InlineData("16x16x900")]
|
||||
[InlineData("300x300x10")]
|
||||
[InlineData("16x16xzero")]
|
||||
public void BadNamesAreRejected(string value)
|
||||
{
|
||||
Assert.False(DifficultyProfile.TryResolve(value, out var profile, out var error));
|
||||
Assert.Null(profile);
|
||||
Assert.False(string.IsNullOrWhiteSpace(error));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PresetsMatchTheClassicBoards()
|
||||
{
|
||||
Assert.Equal(
|
||||
new[] { "Beginner", "Intermediate", "Expert" },
|
||||
DifficultyProfile.Presets.Select(preset => preset.Name));
|
||||
Assert.Equal("30x16", DifficultyProfile.Expert.SizeLabel);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../src/Minesweeper/Minesweeper.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,152 @@
|
||||
using Minesweeper.Game;
|
||||
using Xunit;
|
||||
|
||||
namespace Minesweeper.Tests;
|
||||
|
||||
public class MinesweeperGameTests
|
||||
{
|
||||
private static readonly string[] s_twinMineLayout =
|
||||
[
|
||||
"x.x",
|
||||
"...",
|
||||
"...",
|
||||
];
|
||||
|
||||
private static readonly string[] s_cornerMineLayout =
|
||||
[
|
||||
"x..",
|
||||
"...",
|
||||
"...",
|
||||
];
|
||||
|
||||
[Fact]
|
||||
public void TheFirstClickPlantsMinesAndIsSafe()
|
||||
{
|
||||
var board = new Board(9, 9, 10);
|
||||
var game = new MinesweeperGame(board, new Random(7));
|
||||
|
||||
Assert.False(game.HasOpenedFirstCell);
|
||||
var result = game.Open(new CellRef(4, 4));
|
||||
|
||||
Assert.True(board.MinesPlanted);
|
||||
Assert.True(game.HasOpenedFirstCell);
|
||||
Assert.False(result.HitMine);
|
||||
Assert.True(result.Opened.Count > 1, "the default safe zone should open an area");
|
||||
Assert.Equal(GameStatus.Playing, game.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EverySafeSquareOpeningWinsAndFlagsTheMines()
|
||||
{
|
||||
var game = TestBoards.Game(1, s_cornerMineLayout);
|
||||
var result = game.Open(new CellRef(2, 0));
|
||||
|
||||
Assert.Equal(8, result.Opened.Count);
|
||||
Assert.Equal(GameStatus.Won, game.Status);
|
||||
Assert.Equal(1, game.FlagsPlaced);
|
||||
Assert.Equal(0, game.MinesRemaining);
|
||||
Assert.All(game.Board.MineCells(), mine => Assert.True(game.Board.At(mine).IsFlagged));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SteppingOnAMineEndsTheRoundAndShowsTheBoard()
|
||||
{
|
||||
var game = TestBoards.SingleMine(4, 4);
|
||||
game.ToggleFlag(new CellRef(3, 3));
|
||||
|
||||
var result = game.Open(new CellRef(0, 0));
|
||||
|
||||
Assert.True(result.HitMine);
|
||||
Assert.Equal(GameStatus.Lost, game.Status);
|
||||
Assert.Equal(new CellRef(0, 0), game.Explosion!.Value);
|
||||
Assert.True(game.Board.At(0, 0).Exploded);
|
||||
Assert.All(game.Board.MineCells(), mine => Assert.True(game.Board.At(mine).IsRevealed || game.Board.At(mine).IsFlagged));
|
||||
|
||||
// A flag on safe ground stays put so the renderer can mark it as wrong.
|
||||
Assert.True(game.Board.At(3, 3).IsFlagged);
|
||||
Assert.False(game.Board.At(3, 3).IsMine);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FinishedRoundsIgnoreFurtherMoves()
|
||||
{
|
||||
var game = TestBoards.SingleMine();
|
||||
game.Open(new CellRef(0, 0));
|
||||
var revealed = game.RevealedCount;
|
||||
|
||||
Assert.False(game.Open(new CellRef(2, 2)).AnyOpened);
|
||||
Assert.False(game.ToggleFlag(new CellRef(2, 3)));
|
||||
Assert.Equal(revealed, game.RevealedCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FlagsMoveTheCounterBothWays()
|
||||
{
|
||||
var game = TestBoards.SingleMine();
|
||||
Assert.Equal(1, game.MinesRemaining);
|
||||
|
||||
Assert.True(game.ToggleFlag(new CellRef(1, 1)));
|
||||
Assert.Equal(0, game.MinesRemaining);
|
||||
|
||||
Assert.True(game.ToggleFlag(new CellRef(2, 2)));
|
||||
Assert.Equal(-1, game.MinesRemaining);
|
||||
|
||||
Assert.True(game.ToggleFlag(new CellRef(1, 1)));
|
||||
Assert.Equal(0, game.MinesRemaining);
|
||||
Assert.True(game.ToggleFlag(new CellRef(1, 1)), "a cleared square can be flagged again");
|
||||
Assert.Equal(-1, game.MinesRemaining);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FlaggedSquaresCannotBeOpened()
|
||||
{
|
||||
var game = TestBoards.SingleMine();
|
||||
game.ToggleFlag(new CellRef(1, 1));
|
||||
|
||||
Assert.False(game.Open(new CellRef(1, 1)).AnyOpened);
|
||||
Assert.True(game.Board.At(1, 1).IsFlagged);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void QuestionMarksAreOptionalAndStayOpenable()
|
||||
{
|
||||
var game = TestBoards.SingleMine();
|
||||
|
||||
Assert.False(game.ToggleQuestion(new CellRef(1, 1), questionMarksEnabled: false));
|
||||
|
||||
Assert.True(game.ToggleQuestion(new CellRef(1, 1), questionMarksEnabled: true));
|
||||
Assert.Equal(CellState.Question, game.Board.At(1, 1).State);
|
||||
|
||||
Assert.True(game.Open(new CellRef(1, 1)).AnyOpened);
|
||||
Assert.True(game.Board.At(1, 1).IsRevealed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChordingThroughTheGameCountsTheNewSquares()
|
||||
{
|
||||
var game = TestBoards.Game(1, s_twinMineLayout);
|
||||
game.Open(new CellRef(1, 0));
|
||||
game.ToggleFlag(new CellRef(0, 0));
|
||||
game.ToggleFlag(new CellRef(2, 0));
|
||||
|
||||
var result = game.Chord(new CellRef(1, 0));
|
||||
|
||||
Assert.Equal(3, result.Opened.Count);
|
||||
Assert.Equal(4, game.RevealedCount);
|
||||
Assert.Equal(GameStatus.Playing, game.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RevealSolutionOnlyWorksAfterTheRound()
|
||||
{
|
||||
var game = TestBoards.SingleMine();
|
||||
game.RevealSolution();
|
||||
|
||||
Assert.False(game.Board.At(0, 0).IsRevealed);
|
||||
|
||||
game.Open(new CellRef(0, 0));
|
||||
game.RevealSolution();
|
||||
|
||||
Assert.True(game.Board.At(0, 0).IsRevealed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using Minesweeper.Game;
|
||||
using Xunit;
|
||||
|
||||
namespace Minesweeper.Tests;
|
||||
|
||||
public class SolverTests
|
||||
{
|
||||
[Fact]
|
||||
public void AnEmptyMinefieldIsClearedInOneGo()
|
||||
{
|
||||
var game = new MinesweeperGame(new Board(5, 5, 0), new Random(1));
|
||||
var result = new Solver(game, new Random(2)).Run();
|
||||
|
||||
Assert.Equal(GameStatus.Won, result.Status);
|
||||
Assert.Equal(25, game.RevealedCount);
|
||||
Assert.Equal(0, result.Guesses);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TheStepBudgetStopsTheSearch()
|
||||
{
|
||||
var game = new MinesweeperGame(new Board(16, 16, 40), new Random(3));
|
||||
var result = new Solver(game, new Random(4), maxSteps: 1).Run();
|
||||
|
||||
Assert.Equal(1, result.Steps);
|
||||
Assert.Equal(GameStatus.Playing, game.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TheSameSeedPlaysTheSameGame()
|
||||
{
|
||||
var first = Play(21, out var firstGame);
|
||||
var second = Play(21, out var secondGame);
|
||||
|
||||
Assert.Equal(first.Status, second.Status);
|
||||
Assert.Equal(firstGame.RevealedCount, secondGame.RevealedCount);
|
||||
Assert.Equal(first.Guesses, second.Guesses);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeductionsClearMostBeginnerBoards()
|
||||
{
|
||||
var wins = 0;
|
||||
for (var seed = 1; seed <= 10; seed++)
|
||||
{
|
||||
if (Play(seed, out _).Status == GameStatus.Won)
|
||||
wins++;
|
||||
}
|
||||
|
||||
Assert.True(wins >= 8, $"expected the solver to win at least 8 of 10 beginner boards, won {wins}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SolverNeverLeavesAHiddenSafeSquareBehind()
|
||||
{
|
||||
for (var seed = 1; seed <= 5; seed++)
|
||||
{
|
||||
Play(seed, out var game);
|
||||
if (game.Status != GameStatus.Won)
|
||||
continue;
|
||||
|
||||
Assert.Equal(game.TotalSafeCells, game.RevealedCount);
|
||||
}
|
||||
}
|
||||
|
||||
private static SolverResult Play(int seed, out MinesweeperGame game)
|
||||
{
|
||||
var random = new Random(seed);
|
||||
game = new MinesweeperGame(new Board(9, 9, 10), random) { ReserveSafeZone = true };
|
||||
return new Solver(game, random).Run();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using Minesweeper.Game;
|
||||
|
||||
namespace Minesweeper.Tests;
|
||||
|
||||
/// <summary>Builds fixed boards from a layout where 'x' marks a mine.</summary>
|
||||
internal static class TestBoards
|
||||
{
|
||||
public const char Mine = 'x';
|
||||
|
||||
public static Board Create(params string[] layout)
|
||||
{
|
||||
if (layout.Length == 0)
|
||||
throw new ArgumentException("A layout needs at least one row.", nameof(layout));
|
||||
|
||||
var height = layout.Length;
|
||||
var width = layout[0].Length;
|
||||
var mines = new List<CellRef>();
|
||||
|
||||
for (var y = 0; y < height; y++)
|
||||
{
|
||||
if (layout[y].Length != width)
|
||||
throw new ArgumentException($"Layout row {y} is {layout[y].Length} wide, expected {width}.", nameof(layout));
|
||||
for (var x = 0; x < width; x++)
|
||||
if (layout[y][x] == Mine)
|
||||
mines.Add(new CellRef(x, y));
|
||||
}
|
||||
|
||||
var board = new Board(width, height, 0);
|
||||
board.PlantMines(mines);
|
||||
return board;
|
||||
}
|
||||
|
||||
public static MinesweeperGame Game(int seed, params string[] layout) =>
|
||||
new(Create(layout), new Random(seed));
|
||||
|
||||
public static MinesweeperGame SingleMine(int width = 4, int height = 4, int seed = 1) =>
|
||||
Game(seed, Rows(width, height, new CellRef(0, 0)));
|
||||
|
||||
public static string[] Rows(int width, int height, params CellRef[] mines)
|
||||
{
|
||||
var rows = new string[height];
|
||||
for (var y = 0; y < height; y++)
|
||||
{
|
||||
var characters = new char[width];
|
||||
for (var x = 0; x < width; x++)
|
||||
characters[x] = mines.Contains(new CellRef(x, y)) ? Mine : '.';
|
||||
rows[y] = new string(characters);
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user