Merge pull request #308 from pgruderman/mastermind

Mastermind
This commit is contained in:
Jeff Atwood
2021-09-27 10:12:27 -07:00
committed by GitHub
12 changed files with 920 additions and 0 deletions

View File

@@ -0,0 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net5.0</TargetFramework>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,91 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace Game
{
/// <summary>
/// Represents a secret code in the game.
/// </summary>
public class Code
{
private readonly int[] m_colors;
/// <summary>
/// Initializes a new instance of the Code class from the given set
/// of positions.
/// </summary>
/// <param name="colors">
/// Contains the color for each position.
/// </param>
public Code(IEnumerable<int> colors)
{
m_colors = colors.ToArray();
if (m_colors.Length == 0)
throw new ArgumentException("A code must contain at least one position");
}
/// <summary>
/// Compares this code with the given code.
/// </summary>
/// <param name="other">
/// The code to compare.
/// </param>
/// <returns>
/// A number of black pegs and a number of white pegs. The number
/// of black pegs is the number of positions that contain the same
/// color in both codes. The number of white pegs is the number of
/// colors that appear in both codes, but in the wrong positions.
/// </returns>
public (int blacks, int whites) Compare(Code other)
{
// What follows is the O(N^2) from the original BASIC program
// (where N is the number of positions in the code). Note that
// there is an O(N) algorithm. (Finding it is left as an
// exercise for the reader.)
if (other.m_colors.Length != m_colors.Length)
throw new ArgumentException("Only codes of the same length can be compared");
// Keeps track of which positions in the other code have already
// been marked as exact or close matches.
var consumed = new bool[m_colors.Length];
var blacks = 0;
var whites = 0;
for (var i = 0; i < m_colors.Length; ++i)
{
if (m_colors[i] == other.m_colors[i])
{
++blacks;
consumed[i] = true;
}
else
{
// Check if the current color appears elsewhere in the
// other code. We must be careful not to consider
// positions that are also exact matches.
for (var j = 0; j < m_colors.Length; ++j)
{
if (!consumed[j] &&
m_colors[i] == other.m_colors[j] &&
m_colors[j] != other.m_colors[j])
{
++whites;
consumed[j] = true;
break;
}
}
}
}
return (blacks, whites);
}
/// <summary>
/// Gets a string representation of the code.
/// </summary>
public override string ToString() =>
new (m_colors.Select(index => Colors.List[index].ShortName).ToArray());
}
}

View File

@@ -0,0 +1,92 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace Game
{
/// <summary>
/// Provides methods for generating codes with a given number of positions
/// and colors.
/// </summary>
public class CodeFactory
{
/// <summary>
/// Gets the number of colors in codes generated by this factory.
/// </summary>
public int Colors { get; }
/// <summary>
/// Gets the number of positions in codes generated by this factory.
/// </summary>
public int Positions { get; }
/// <summary>
/// Gets the number of distinct codes that this factory can
/// generate.
/// </summary>
public int Possibilities { get; }
/// <summary>
/// Initializes a new instance of the CodeFactory class.
/// </summary>
/// <param name="positions">
/// The number of positions.
/// </param>
/// <param name="colors">
/// The number of colors.
/// </param>
public CodeFactory(int positions, int colors)
{
if (positions < 1)
throw new ArgumentException("A code must contain at least one position");
if (colors < 1)
throw new ArgumentException("A code must contain at least one color");
if (colors > Game.Colors.List.Length)
throw new ArgumentException($"A code can contain no more than {Game.Colors.List.Length} colors");
Positions = positions;
Colors = colors;
Possibilities = (int)Math.Pow(colors, positions);
}
/// <summary>
/// Creates a specified code.
/// </summary>
/// <param name="number">
/// The number of the code to create from 0 to Possibilities - 1.
/// </param>
public Code Create(int number) =>
EnumerateCodes().Skip(number).First();
/// <summary>
/// Creates a random code using the provided random number generator.
/// </summary>
/// <param name="random">
/// The random number generator.
/// </param>
public Code Create(Random random) =>
Create(random.Next(Possibilities));
/// <summary>
/// Generates a collection of codes containing every code that this
/// factory can create exactly once.
/// </summary>
public IEnumerable<Code> EnumerateCodes()
{
var current = new int[Positions];
var position = default(int);
do
{
yield return new Code(current);
position = 0;
while (position < Positions && ++current[position] == Colors)
current[position++] = 0;
}
while (position < Positions);
}
}
}

View File

@@ -0,0 +1,20 @@
using System;
namespace Game
{
/// <summary>
/// Stores information about a color.
/// </summary>
public record ColorInfo
{
/// <summary>
/// Gets a single character that represents the color.
/// </summary>
public char ShortName { get; init; }
/// <summary>
/// Gets the color's full name.
/// </summary>
public string LongName { get; init; } = String.Empty;
}
}

View File

@@ -0,0 +1,20 @@
namespace Game
{
/// <summary>
/// Provides information about the colors that can be used in codes.
/// </summary>
public static class Colors
{
public static readonly ColorInfo[] List = new[]
{
new ColorInfo { ShortName = 'B', LongName = "BLACK" },
new ColorInfo { ShortName = 'W', LongName = "WHITE" },
new ColorInfo { ShortName = 'R', LongName = "RED" },
new ColorInfo { ShortName = 'G', LongName = "GREEN" },
new ColorInfo { ShortName = 'O', LongName = "ORANGE" },
new ColorInfo { ShortName = 'Y', LongName = "YELLOW" },
new ColorInfo { ShortName = 'P', LongName = "PURPLE" },
new ColorInfo { ShortName = 'T', LongName = "TAN" }
};
}
}

View File

@@ -0,0 +1,13 @@
namespace Game
{
/// <summary>
/// Enumerates the different commands that the user can issue during
/// the game.
/// </summary>
public enum Command
{
MakeGuess,
ShowBoard,
Quit
}
}

View File

@@ -0,0 +1,175 @@
using System;
using System.Collections.Immutable;
using System.Linq;
namespace Game
{
/// <summary>
/// Contains functions for getting input from the end user.
/// </summary>
public static class Controller
{
/// <summary>
/// Maps the letters for each color to the integer value representing
/// that color.
/// </summary>
/// <remarks>
/// We derive this map from the Colors list rather than defining the
/// entries directly in order to keep all color related information
/// in one place. (This makes it easier to change the color options
/// later.)
/// </remarks>
private static ImmutableDictionary<char, int> ColorsByKey = Colors.List
.Select((info, index) => (key: info.ShortName, index))
.ToImmutableDictionary(entry => entry.key, entry => entry.index);
/// <summary>
/// Gets the number of colors to use in the secret code.
/// </summary>
public static int GetNumberOfColors()
{
var maximumColors = Colors.List.Length;
var colors = 0;
while (colors < 1 || colors > maximumColors)
{
colors = GetInteger(View.PromptNumberOfColors);
if (colors > maximumColors)
View.NotifyTooManyColors(maximumColors);
}
return colors;
}
/// <summary>
/// Gets the number of positions in the secret code.
/// </summary>
/// <returns></returns>
public static int GetNumberOfPositions()
{
// Note: We should probably ensure that the user enters a sane
// number of positions here. (Things go south pretty quickly
// with a large number of positions.) But since the original
// program did not, neither will we.
return GetInteger(View.PromptNumberOfPositions);
}
/// <summary>
/// Gets the number of rounds to play.
/// </summary>
public static int GetNumberOfRounds()
{
// Note: Silly numbers of rounds (like 0, or a negative number)
// are harmless, but it would still make sense to validate.
return GetInteger(View.PromptNumberOfRounds);
}
/// <summary>
/// Gets a command from the user.
/// </summary>
/// <param name="moveNumber">
/// The current move number.
/// </param>
/// <param name="positions">
/// The number of code positions.
/// </param>
/// <param name="colors">
/// The maximum number of code colors.
/// </param>
/// <returns>
/// The entered command and guess (if applicable).
/// </returns>
public static (Command command, Code? guess) GetCommand(int moveNumber, int positions, int colors)
{
while (true)
{
View.PromptGuess (moveNumber);
var input = Console.ReadLine();
if (input is null)
Environment.Exit(0);
switch (input.ToUpperInvariant())
{
case "BOARD":
return (Command.ShowBoard, null);
case "QUIT":
return (Command.Quit, null);
default:
if (input.Length != positions)
View.NotifyBadNumberOfPositions();
else
if (input.FindFirstIndex(c => !TranslateColor(c).HasValue) is int invalidPosition)
View.NotifyInvalidColor(input[invalidPosition]);
else
return (Command.MakeGuess, new Code(input.Select(c => TranslateColor(c)!.Value)));
break;
}
}
}
/// <summary>
/// Waits until the user indicates that he or she is ready to continue.
/// </summary>
public static void WaitUntilReady()
{
View.PromptReady();
var input = Console.ReadLine();
if (input is null)
Environment.Exit(0);
}
/// <summary>
/// Gets the number of blacks and whites for the given code from the
/// user.
/// </summary>
public static (int blacks, int whites) GetBlacksWhites(Code code)
{
while (true)
{
View.PromptBlacksWhites(code);
var input = Console.ReadLine();
if (input is null)
Environment.Exit(0);
var parts = input.Split(',');
if (parts.Length != 2)
View.PromptTwoValues();
else
if (!Int32.TryParse(parts[0], out var blacks) || !Int32.TryParse(parts[1], out var whites))
View.PromptValidInteger();
else
return (blacks, whites);
}
}
/// <summary>
/// Gets an integer value from the user.
/// </summary>
private static int GetInteger(Action prompt)
{
while (true)
{
prompt();
var input = Console.ReadLine();
if (input is null)
Environment.Exit(0);
if (Int32.TryParse(input, out var result))
return result;
else
View.PromptValidInteger();
}
}
/// <summary>
/// Translates the given character into the corresponding color.
/// </summary>
private static int? TranslateColor(char c) =>
ColorsByKey.TryGetValue(c, out var index) ? index : null;
}
}

View File

@@ -0,0 +1,88 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace Game
{
/// <summary>
/// Provides additional methods for the <see cref="IEnumerable{T}"/>
/// interface.
/// </summary>
public static class EnumerableExtensions
{
/// <summary>
/// Cycles through the integer values in the range [0, count).
/// </summary>
/// <param name="start">
/// The first value to return.
/// </param>
/// <param name="count">
/// The number of values to return.
/// </param>
public static IEnumerable<int> Cycle(int start, int count)
{
if (count < 1)
throw new ArgumentException("count must be at least 1");
if (start < 0 || start >= count)
throw new ArgumentException("start must be in the range [0, count)");
for (var i = start; i < count; ++i)
yield return i;
for (var i = 0; i < start; ++i)
yield return i;
}
/// <summary>
/// Finds the index of the first item in the given sequence that
/// satisfies the given predicate.
/// </summary>
/// <typeparam name="T">
/// The type of elements in the sequence.
/// </typeparam>
/// <param name="source">
/// The source sequence.
/// </param>
/// <param name="predicate">
/// The predicate function.
/// </param>
/// <returns>
/// The index of the first element in the source sequence for which
/// predicate(element) is true. If there is no such element, return
/// is null.
/// </returns>
public static int? FindFirstIndex<T>(this IEnumerable<T> source, Func<T, bool> predicate) =>
source.Select((element, index) => predicate(element) ? index : default(int?))
.FirstOrDefault(index => index.HasValue);
/// <summary>
/// Returns the first item in the given sequence that matches the
/// given predicate.
/// </summary>
/// <typeparam name="T">
/// The type of elements in the sequence.
/// </typeparam>
/// <param name="source">
/// The source sequence.
/// </param>
/// <param name="predicate">
/// The predicate to check against each element.
/// </param>
/// <param name="defaultValue">
/// The value to return if no elements match the predicate.
/// </param>
/// <returns>
/// The first item in the source sequence that matches the given
/// predicate, or the provided default value if none do.
/// </returns>
public static T FirstOrDefault<T>(this IEnumerable<T> source, Func<T, bool> predicate, T defaultValue)
{
foreach (var element in source)
if (predicate(element))
return element;
return defaultValue;
}
}
}

View File

@@ -0,0 +1,173 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace Game
{
// MASTERMIND II
// STEVE NORTH
// CREATIVE COMPUTING
// PO BOX 789-M MORRISTOWN NEW JERSEY 07960
class Program
{
public const int MaximumGuesses = 10;
static void Main()
{
var (codeFactory, rounds) = StartGame();
var random = new Random();
var humanScore = 0;
var computerScore = 0;
for (var round = 1; round <= rounds; ++round)
{
View.ShowStartOfRound(round);
if (!HumanTakesTurn())
return;
while (!ComputerTakesTurn())
View.ShowInconsistentInformation();
}
View.ShowScores(humanScore, computerScore, isFinal: true);
/// <summary>
/// Gets the game start parameters from the user.
/// </summary>
(CodeFactory codeFactory, int rounds) StartGame()
{
View.ShowBanner();
var colors = Controller.GetNumberOfColors();
var positions = Controller.GetNumberOfPositions();
var rounds = Controller.GetNumberOfRounds();
var codeFactory = new CodeFactory(positions, colors);
View.ShowTotalPossibilities(codeFactory.Possibilities);
View.ShowColorTable(codeFactory.Colors);
return (codeFactory, rounds);
}
/// <summary>
/// Executes the human's turn.
/// </summary>
/// <returns>
/// True if thue human completed his or her turn and false if
/// he or she quit the game.
/// </returns>
bool HumanTakesTurn()
{
// Store a history of the human's guesses (used for the show
// board command below).
var history = new List<TurnResult>();
var code = codeFactory.Create(random);
var guessNumber = default(int);
for (guessNumber = 1; guessNumber <= MaximumGuesses; ++guessNumber)
{
var guess = default(Code);
while (guess is null)
{
switch (Controller.GetCommand(guessNumber, codeFactory.Positions, codeFactory.Colors))
{
case (Command.MakeGuess, Code input):
guess = input;
break;
case (Command.ShowBoard, _):
View.ShowBoard(history);
break;
case (Command.Quit, _):
View.ShowQuitGame(code);
return false;
}
}
var (blacks, whites) = code.Compare(guess);
if (blacks == codeFactory.Positions)
break;
View.ShowResults(blacks, whites);
history.Add(new TurnResult(guess, blacks, whites));
}
if (guessNumber <= MaximumGuesses)
View.ShowHumanGuessedCode(guessNumber);
else
View.ShowHumanFailedToGuessCode(code);
humanScore += guessNumber;
View.ShowScores(humanScore, computerScore, isFinal: false);
return true;
}
/// <summary>
/// Executes the computers turn.
/// </summary>
/// <returns>
/// True if the computer completes its turn successfully and false
/// if it does not (due to human error).
/// </returns>
bool ComputerTakesTurn()
{
var isCandidate = new bool[codeFactory.Possibilities];
var guessNumber = default(int);
Array.Fill(isCandidate, true);
View.ShowComputerStartTurn();
Controller.WaitUntilReady();
for (guessNumber = 1; guessNumber <= MaximumGuesses; ++guessNumber)
{
// Starting with a random code, cycle through codes until
// we find one that is still a candidate solution. If
// there are no remaining candidates, then it implies that
// the user made an error in one or more responses.
var codeNumber = EnumerableExtensions.Cycle(random.Next(codeFactory.Possibilities), codeFactory.Possibilities)
.FirstOrDefault(i => isCandidate[i], -1);
if (codeNumber < 0)
return false;
var guess = codeFactory.Create(codeNumber);
var (blacks, whites) = Controller.GetBlacksWhites(guess);
if (blacks == codeFactory.Positions)
break;
// Mark codes which are no longer potential solutions. We
// know that the current guess yields the above number of
// blacks and whites when compared to the solution, so any
// code that yields a different number of blacks or whites
// can't be the answer.
foreach (var (candidate, index) in codeFactory.EnumerateCodes().Select((candidate, index) => (candidate, index)))
{
if (isCandidate[index])
{
var (candidateBlacks, candidateWhites) = guess.Compare(candidate);
if (blacks != candidateBlacks || whites != candidateWhites)
isCandidate[index] = false;
}
}
}
if (guessNumber <= MaximumGuesses)
View.ShowComputerGuessedCode(guessNumber);
else
View.ShowComputerFailedToGuessCode();
computerScore += guessNumber;
View.ShowScores(humanScore, computerScore, isFinal: false);
return true;
}
}
}
}

View File

@@ -0,0 +1,38 @@
namespace Game
{
/// <summary>
/// Stores the result of a player's turn.
/// </summary>
public record TurnResult
{
/// <summary>
/// Gets the code guessed by the player.
/// </summary>
public Code Guess { get; }
/// <summary>
/// Gets the number of black pegs resulting from the guess.
/// </summary>
public int Blacks { get; }
/// <summary>
/// Gets the number of white pegs resulting from the guess.
/// </summary>
public int Whites { get; }
/// <summary>
/// Initializes a new instance of the TurnResult record.
/// </summary>
/// <param name="guess">
/// The player's guess.
/// </param>
/// <param name="blacks">
/// The number of black pegs.
/// </param>
/// <param name="whites">
/// The number of white pegs.
/// </param>
public TurnResult(Code guess, int blacks, int whites) =>
(Guess, Blacks, Whites) = (guess, blacks, whites);
}
}

View File

@@ -0,0 +1,178 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace Game
{
/// <summary>
/// Contains functions for displaying information to the end user.
/// </summary>
public static class View
{
public static void ShowBanner()
{
Console.WriteLine(" MASTERMIND");
Console.WriteLine(" CREATIVE COMPUTING MORRISTOWN, NEW JERSEY");
Console.WriteLine();
Console.WriteLine();
Console.WriteLine();
}
public static void ShowTotalPossibilities(int possibilities)
{
Console.WriteLine($"TOTAL POSSIBILITIES = {possibilities}");
Console.WriteLine();
}
public static void ShowColorTable(int numberOfColors)
{
Console.WriteLine();
Console.WriteLine("COLOR LETTER");
Console.WriteLine("===== ======");
foreach (var color in Colors.List.Take(numberOfColors))
Console.WriteLine($"{color.LongName,-13}{color.ShortName}");
Console.WriteLine();
}
public static void ShowStartOfRound(int roundNumber)
{
Console.WriteLine();
Console.WriteLine($"ROUND NUMBER {roundNumber} ----");
Console.WriteLine();
Console.WriteLine("GUESS MY COMBINATION.");
Console.WriteLine();
}
public static void ShowBoard(IEnumerable<TurnResult> history)
{
Console.WriteLine();
Console.WriteLine("BOARD");
Console.WriteLine("MOVE GUESS BLACK WHITE");
var moveNumber = 0;
foreach (var result in history)
Console.WriteLine($"{++moveNumber,-9}{result.Guess,-16}{result.Blacks,-10}{result.Whites}");
Console.WriteLine();
}
public static void ShowQuitGame(Code code)
{
Console.WriteLine($"QUITTER! MY COMBINATION WAS: {code}");
Console.WriteLine("GOOD BYE");
}
public static void ShowResults(int blacks, int whites)
{
Console.WriteLine($"YOU HAVE {blacks} BLACKS AND {whites} WHITES.");
}
public static void ShowHumanGuessedCode(int guessNumber)
{
Console.WriteLine($"YOU GUESSED IT IN {guessNumber} MOVES!");
}
public static void ShowHumanFailedToGuessCode(Code code)
{
// Note: The original code did not print out the combination, but
// this appears to be a bug.
Console.WriteLine("YOU RAN OUT OF MOVES! THAT'S ALL YOU GET!");
Console.WriteLine($"THE ACTUAL COMBINATION WAS: {code}");
}
public static void ShowScores(int humanScore, int computerScore, bool isFinal)
{
if (isFinal)
{
Console.WriteLine("GAME OVER");
Console.WriteLine("FINAL SCORE:");
}
else
Console.WriteLine("SCORE:");
Console.WriteLine($" COMPUTER {computerScore}");
Console.WriteLine($" HUMAN {humanScore}");
Console.WriteLine();
}
public static void ShowComputerStartTurn()
{
Console.WriteLine("NOW I GUESS. THINK OF A COMBINATION.");
}
public static void ShowInconsistentInformation()
{
Console.WriteLine("YOU HAVE GIVEN ME INCONSISTENT INFORMATION.");
Console.WriteLine("TRY AGAIN, AND THIS TIME PLEASE BE MORE CAREFUL.");
}
public static void ShowComputerGuessedCode(int guessNumber)
{
Console.WriteLine($"I GOT IT IN {guessNumber} MOVES!");
}
public static void ShowComputerFailedToGuessCode()
{
Console.WriteLine("I USED UP ALL MY MOVES!");
Console.WriteLine("I GUESS MY CPU IS JUST HAVING AN OFF DAY.");
}
public static void PromptNumberOfColors()
{
Console.Write("NUMBER OF COLORS? ");
}
public static void PromptNumberOfPositions()
{
Console.Write("NUMBER OF POSITIONS? ");
}
public static void PromptNumberOfRounds()
{
Console.Write("NUMBER OF ROUNDS? ");
}
public static void PromptGuess(int moveNumber)
{
Console.Write($"MOVE # {moveNumber} GUESS ? ");
}
public static void PromptReady()
{
Console.Write("HIT RETURN WHEN READY ? ");
}
public static void PromptBlacksWhites(Code code)
{
Console.Write($"MY GUESS IS: {code}");
Console.Write(" BLACKS, WHITES ? ");
}
public static void PromptTwoValues()
{
Console.WriteLine("PLEASE ENTER TWO VALUES, SEPARATED BY A COMMA");
}
public static void PromptValidInteger()
{
Console.WriteLine("PLEASE ENTER AN INTEGER VALUE");
}
public static void NotifyBadNumberOfPositions()
{
Console.WriteLine("BAD NUMBER OF POSITIONS");
}
public static void NotifyInvalidColor(char colorKey)
{
Console.WriteLine($"'{colorKey}' IS UNRECOGNIZED.");
}
public static void NotifyTooManyColors(int maxColors)
{
Console.WriteLine($"NO MORE THAN {maxColors}, PLEASE!");
}
}
}

View File

@@ -0,0 +1,25 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 16
VisualStudioVersion = 16.0.31321.278
MinimumVisualStudioVersion = 10.0.40219.1
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Game", "Game\Game.csproj", "{E8D63140-971D-4FBF-8138-964E54CCB7DD}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{E8D63140-971D-4FBF-8138-964E54CCB7DD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E8D63140-971D-4FBF-8138-964E54CCB7DD}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E8D63140-971D-4FBF-8138-964E54CCB7DD}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E8D63140-971D-4FBF-8138-964E54CCB7DD}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {1BDFBEE6-8345-438C-8FCE-B2C9394CC080}
EndGlobalSection
EndGlobal