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