Add console Minesweeper with a UI-free engine

Rules and board state live in Minesweeper.Core with no console calls, so the same board drives both the CLI and the tests.

Engine: seeded or random boards with classic presets; first click always safe (and its ring too when the board has room); cascading reveal that stops at numbers; flagging and chording; win by clearing every safe cell; FromMines() for deterministic layouts.

CLI: typed commands ("3 4", "f 2 5", "chord 6 7") plus optional arrow-key mode. Rows numbered, columns lettered, player input 1-based while engine coordinates stay 0-based, with CommandParser the only place that converts. ANSI colour when writing to a terminal, plain when redirected or NO_COLOR is set.

Tests: 186 covering flood-fill bounds, first-click safety, chording, win/loss, command parsing, session behaviour, and renderer alignment with colour on and off.

Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
openhands
2026-09-05 21:50:11 +00:00
commit fd270095df
25 changed files with 2932 additions and 0 deletions
+94
View File
@@ -0,0 +1,94 @@
using System.Globalization;
using Minesweeper.Core;
namespace Minesweeper.Cli;
/// <summary>Turns typed text and raw keystrokes into <see cref="Command"/> instances. This is the
/// only place that translates the one-based coordinates a player sees into engine coordinates.</summary>
public static class CommandParser
{
/// <summary>Parses a typed line. Coordinates are one-based, e.g. <c>reveal 3 4</c>.</summary>
public static Command Parse(string input)
{
var text = input.Trim();
if (text.Length == 0)
{
return new Command.Noop();
}
var parts = text.Split([' ', ',', ';', '\t'], StringSplitOptions.RemoveEmptyEntries);
var verb = parts[0].ToLowerInvariant();
switch (verb)
{
case "q" or "quit" or "exit":
return new Command.Quit();
case "n" or "new":
return new Command.NewGame();
case "h" or "help" or "?":
return new Command.ShowHelp();
case "d" or "difficulty":
return ParseDifficulty(parts, text);
case "r" or "reveal" or "open":
return ParseCell(parts, 1, cell => new Command.Reveal(cell), text);
case "f" or "flag" or "mark":
return ParseCell(parts, 1, cell => new Command.Flag(cell), text);
case "c" or "chord":
return ParseCell(parts, 1, cell => new Command.Chord(cell), text);
case "go" or "goto":
return ParseCell(parts, 1, cell => new Command.SetCursor(cell), text);
}
// A bare "3 4" reveals that cell, since revealing is by far the most common action.
return ParseCell(parts, 0, cell => new Command.Reveal(cell), text);
}
/// <summary>Maps a keystroke from keyboard-driven mode onto a command.</summary>
public static Command FromKey(ConsoleKeyInfo key) => key.Key switch
{
ConsoleKey.UpArrow => new Command.MoveCursor(-1, 0),
ConsoleKey.DownArrow => new Command.MoveCursor(1, 0),
ConsoleKey.LeftArrow => new Command.MoveCursor(0, -1),
ConsoleKey.RightArrow => new Command.MoveCursor(0, 1),
ConsoleKey.Enter or ConsoleKey.Spacebar => new Command.RevealAtCursor(),
ConsoleKey.F => new Command.FlagAtCursor(),
ConsoleKey.C => new Command.ChordAtCursor(),
ConsoleKey.N => new Command.NewGame(),
ConsoleKey.D1 => new Command.ChangeDifficulty(GameDifficulty.Beginner),
ConsoleKey.D2 => new Command.ChangeDifficulty(GameDifficulty.Intermediate),
ConsoleKey.D3 => new Command.ChangeDifficulty(GameDifficulty.Expert),
ConsoleKey.Escape or ConsoleKey.Q => new Command.Quit(),
_ => new Command.Noop(),
};
private static Command ParseDifficulty(string[] parts, string text)
{
if (parts.Length < 2)
{
return new Command.ShowHelp();
}
return parts[1].ToLowerInvariant() switch
{
"1" or "b" or "beginner" or "easy" => new Command.ChangeDifficulty(GameDifficulty.Beginner),
"2" or "i" or "intermediate" or "medium" => new Command.ChangeDifficulty(GameDifficulty.Intermediate),
"3" or "e" or "expert" or "hard" => new Command.ChangeDifficulty(GameDifficulty.Expert),
_ => new Command.Unknown(text),
};
}
private static Command ParseCell(string[] parts, int offset, Func<CellLocation, Command> factory, string text)
{
if (parts.Length - offset < 2 ||
!TryParseCoordinate(parts[offset], out var row) ||
!TryParseCoordinate(parts[offset + 1], out var column))
{
return new Command.Unknown(text);
}
return factory(new CellLocation(row - 1, column - 1));
}
private static bool TryParseCoordinate(string token, out int value) =>
int.TryParse(token, NumberStyles.None, CultureInfo.InvariantCulture, out value) && value > 0;
}