diff --git a/60_Mastermind/java/Mastermind.java b/60_Mastermind/java/Mastermind.java new file mode 100644 index 00000000..a8b7d422 --- /dev/null +++ b/60_Mastermind/java/Mastermind.java @@ -0,0 +1,423 @@ +import java.util.ArrayList; +import java.util.Arrays; +import java.util.BitSet; +import java.util.List; +import java.util.Objects; +import java.util.Random; +import java.util.Scanner; +import java.util.function.Function; +import java.util.function.Predicate; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +/** + * A port of the BASIC Mastermind game in java. + * + * Differences between this and the original BASIC: + * Uses a number base conversion approach to converting solution ids to + * color code strings. The original performs an inefficient add by 1 + * with carry technique where every carry increments the next positions + * color id. + * + * Implements a ceiling check on the number of positions in a secret code + * to not run out of memory. Because of the algorithm that the computer + * uses to deduce the players secret code, it searches through the entire + * possible spectrum of solutions. This can be a very large number because + * it's (number of colors) ^ (number of positions). The original will + * happily try to allocate all the memory on the system if this number is + * too large. If it did successfully allocate the memory on a large solution + * set then it would also take too long to compute code strings via its + * technique mentioned in the previous note. + * + * An extra message is given at the start to alert the player to the + * BOARD and QUIT commands. + */ +public class Mastermind { + final Random random = new Random(); + // some less verbose printing methods + static private void pf(String s, Object... o){ System.out.printf(s, o);} + static private void pl(String s){System.out.println(s);} + static private void pl(){System.out.println();} + + public static void main(String[] args) { + title(); + Mastermind game = setup(); + game.play(); + } + + /** + * The eight possible color codes. + */ + private enum Color { + B("BLACK"), W("WHITE"), R("RED"), G("GREEN"), + O("ORANGE"), Y("YELLOW"), P("PURPLE"), T("TAN"); + public final String name; + + Color(String name) { + this.name = name; + } + } + + /** + * Represents a guess and the subsequent number of colors in the correct + * position (blacks), and the number of colors present but not in the correct + * position (whites.) + */ + private record Guess(int guessNum, String guess, int blacks, int whites){} + + + private void play() { + IntStream.rangeClosed(1,rounds).forEach(this::playRound); + pl("GAME OVER"); + pl("FINAL SCORE: "); + pl(getScore()); + } + + /** + * Builder-ish pattern for creating Mastermind game + * @return Mastermind game object + */ + private static Mastermind setup() { + int numOfColors; + pf("NUMBER OF COLORS? > "); + numOfColors = getPositiveNumberUpTo(Color.values().length); + int maxPositions = getMaxPositions(numOfColors); + pf("NUMBER OF POSITIONS (MAX %d)? > ", maxPositions); + int positions = getPositiveNumberUpTo(maxPositions); + pf("NUMBER OF ROUNDS? > "); + int rounds = getPositiveNumber(); + pl("ON YOUR TURN YOU CAN ENTER 'BOARD' TO DISPLAY YOUR PREVIOUS GUESSES,"); + pl("OR 'QUIT' TO GIVE UP."); + return new Mastermind(numOfColors, positions, rounds, 10); + } + + /** + * Computes the number of allowable positions to prevent the total possible + * solution set that the computer has to check to a reasonable number, and + * to prevent out of memory errors. + * + * The computer guessing algorithm uses a BitSet which has a limit of 2^31 + * bits (Integer.MAX_VALUE bits). Since the number of possible solutions to + * any mastermind game is (numColors) ^ (numPositions) we need find the + * maximum number of positions by finding the Log|base-NumOfColors|(2^31) + * + * @param numOfColors number of different colors + * @return max number of positions in the secret code. + */ + private static int getMaxPositions(int numOfColors){ + return (int)(Math.log(Integer.MAX_VALUE)/Math.log(numOfColors)); + } + + final int numOfColors, positions, rounds, possibilities; + int humanMoves, computerMoves; + final BitSet solutionSet; + final Color[] colors; + final int maxTries; + + // A recording of human guesses made during the round for the BOARD command. + final List guesses = new ArrayList<>(); + + // A regular expression to validate user guess strings + final String guessValidatorRegex; + + public Mastermind(int numOfColors, int positions, int rounds, int maxTries) { + this.numOfColors = numOfColors; + this.positions = positions; + this.rounds = rounds; + this.maxTries = maxTries; + this.humanMoves = 0; + this.computerMoves = 0; + String colorCodes = Arrays.stream(Color.values()) + .limit(numOfColors) + .map(Color::toString) + .collect(Collectors.joining()); + // regex that limits the number of color codes and quantity for a guess. + this.guessValidatorRegex = "^[" + colorCodes + "]{" + positions + "}$"; + this.colors = Color.values(); + this.possibilities = (int) Math.round(Math.pow(numOfColors, positions)); + pf("TOTAL POSSIBILITIES =% d%n", possibilities); + this.solutionSet = new BitSet(possibilities); + displayColorCodes(numOfColors); + } + + private void playRound(int round) { + pf("ROUND NUMBER % d ----%n%n",round); + humanTurn(); + computerTurn(); + pl(getScore()); + } + + private void humanTurn() { + guesses.clear(); + String secretCode = generateColorCode(); + pl("GUESS MY COMBINATION. \n"); + int guessNumber = 1; + while (true) { // User input loop + pf("MOVE #%d GUESS ?", guessNumber); + final String guess = getWord(); + if (guess.equals(secretCode)) { + guesses.add(new Guess(guessNumber, guess, positions, 0)); + pf("YOU GUESSED IT IN %d MOVES!%n", guessNumber); + humanMoves++; + pl(getScore()); + return; + } else if ("BOARD".equals(guess)) { displayBoard(); + } else if ("QUIT".equals(guess)) { quit(secretCode); + } else if (!validateGuess(guess)) { pl(guess + " IS UNRECOGNIZED."); + } else { + Guess g = evaluateGuess(guessNumber, guess, secretCode); + pf("YOU HAVE %d BLACKS AND %d WHITES.%n", g.blacks(), g.whites()); + guesses.add(g); + humanMoves++; + guessNumber++; + } + if (guessNumber > maxTries) { + pl("YOU RAN OUT OF MOVES! THAT'S ALL YOU GET!"); + pl("THE ACTUAL COMBINATION WAS: " + secretCode); + return; + } + } + } + + private void computerTurn(){ + while (true) { + pl("NOW I GUESS. THINK OF A COMBINATION."); + pl("HIT RETURN WHEN READY:"); + solutionSet.set(0, possibilities); // set all bits to true + getInput("RETURN KEY", Scanner::nextLine, Objects::nonNull); + int guessNumber = 1; + while(true){ + if (solutionSet.cardinality() == 0) { + // user has given wrong information, thus we have cancelled out + // any remaining possible valid solution. + pl("YOU HAVE GIVEN ME INCONSISTENT INFORMATION."); + pl("TRY AGAIN, AND THIS TIME PLEASE BE MORE CAREFUL."); + break; + } + // Randomly pick an untried solution. + int solution = solutionSet.nextSetBit(generateSolutionID()); + if (solution == -1) { + solution = solutionSet.nextSetBit(0); + } + String guess = solutionIdToColorCode(solution); + pf("MY GUESS IS: %s BLACKS, WHITES ? ",guess); + int[] bAndWPegs = getPegCount(positions); + if (bAndWPegs[0] == positions) { + pf("I GOT IT IN % d MOVES!%n", guessNumber); + computerMoves+=guessNumber; + return; + } + // wrong guess, first remove this guess from solution set + solutionSet.clear(solution); + int index = 0; + // Cycle through remaining solution set, marking any solutions as invalid + // that don't exactly match what the user said about our guess. + while ((index = solutionSet.nextSetBit(index)) != -1) { + String solutionStr = solutionIdToColorCode(index); + Guess possibleSolution = evaluateGuess(0, solutionStr, guess); + if (possibleSolution.blacks() != bAndWPegs[0] || + possibleSolution.whites() != bAndWPegs[1]) { + solutionSet.clear(index); + } + index++; + } + guessNumber++; + } + } + } + + // tally black and white pegs + private Guess evaluateGuess(int guessNum, String guess, String secretCode) { + int blacks = 0, whites = 0; + char[] g = guess.toCharArray(); + char[] sc = secretCode.toCharArray(); + // An incremented number that marks this position as having been counted + // as a black or white peg already. + char visited = 0x8000; + // Cycle through guess letters and check for color and position match + // with the secretCode. If both match, mark it black. + // Else cycle through remaining secretCode letters and check if color + // matches. If this matches, a preventative check must be made against + // the guess letter matching the secretCode letter at this position in + // case it would be counted as a black in one of the next passes. + for (int j = 0; j < positions; j++) { + if (g[j] == sc[j]) { + blacks++; + g[j] = visited++; + sc[j] = visited++; + } + for (int k = 0; k < positions; k++) { + if (g[j] == sc[k] && g[k] != sc[k]) { + whites++; + g[j] = visited++; + sc[k] = visited++; + } + } + } + return new Guess(guessNum, guess, blacks, whites); + } + + private boolean validateGuess(String guess) { + return guess.length() == positions && guess.matches(guessValidatorRegex); + } + + private String getScore() { + return "SCORE:%n\tCOMPUTER \t%d%n\tHUMAN \t%d%n" + .formatted(computerMoves, humanMoves); + } + + private void printGuess(Guess g){ + pf("% 3d%9s% 15d% 10d%n",g.guessNum(),g.guess(),g.blacks(),g.whites()); + } + + private void displayBoard() { + pl(); + pl("BOARD"); + pl("MOVE GUESS BLACK WHITE"); + guesses.forEach(this::printGuess); + pl(); + } + + private void quit(String secretCode) { + pl("QUITTER! MY COMBINATION WAS: " + secretCode); + pl("GOOD BYE"); + System.exit(0); + } + + /** + * Generates a set of color codes randomly. + */ + private String generateColorCode() { + int solution = generateSolutionID(); + return solutionIdToColorCode(solution); + } + + /** + * From the total possible number of solutions created at construction, choose + * one randomly. + * + * @return one of many possible solutions + */ + private int generateSolutionID() { + return random.nextInt(0, this.possibilities); + } + + /** + * Given the number of colors and positions in a secret code, decode one of + * those permutations, a solution number, into a string of letters + * representing colored pegs. + * + * The pattern can be decoded easily as a number with base `numOfColors` and + * `positions` representing the digits. For example if numOfColors is 5 and + * positions is 3 then the pattern is converted to a number that is base 5 + * with three digits. Each digit then maps to a particular color. + * + * @param solution one of many possible solutions + * @return String representing this solution's color combination. + */ + private String solutionIdToColorCode(final int solution) { + StringBuilder secretCode = new StringBuilder(); + int pos = possibilities; + int remainder = solution; + for (int i = positions - 1; i > 0; i--) { + pos = pos / numOfColors; + secretCode.append(colors[remainder / pos].toString()); + remainder = remainder % pos; + } + secretCode.append(colors[remainder].toString()); + return secretCode.toString(); + } + + private static void displayColorCodes(int numOfColors) { + pl("\n\nCOLOR LETTER\n===== ======"); + Arrays.stream(Color.values()) + .limit(numOfColors) + .map(c -> c.name + " ".repeat(13 - c.name.length()) + c) + .forEach(Mastermind::pl); + pl();pl(); + } + + private static void title() { + pl(""" + MASTERMIND + CREATIVE COMPUTING MORRISTOWN, NEW JERSEY%n%n%n + """); + } + + ///////////////////////////////////////////////////////// + // User input functions from here on + + /** + * Base input function to be called from a specific input function. + * Re-prompts upon unexpected or invalid user input. + * Discards any remaining input in the line of user entered input once + * it gets what it wants. + * @param descriptor Describes explicit type of input expected + * @param extractor The method performed against a Scanner object to parse + * the type of input. + * @param conditional A test that the input meets a minimum validation. + * @param Input type returned. + * @return input type for this line of user input. + */ + private static T getInput(String descriptor, + Function extractor, + Predicate conditional) { + + Scanner scanner = new Scanner(System.in); + while (true) { + try { + T input = extractor.apply(scanner); + if (conditional.test(input)) { + return input; + } + } catch (Exception ex) { + try { + // If we are here then a call on the scanner was most likely unable to + // parse the input. We need to flush whatever is leftover from this + // line of interactive user input so that we can re-prompt for new input. + scanner.nextLine(); + } catch (Exception ns_ex) { + // if we are here then the input has been closed, or we received an + // EOF (end of file) signal, usually in the form of a ctrl-d or + // in the case of Windows, a ctrl-z. + pl("END OF INPUT, STOPPING PROGRAM."); + System.exit(1); + } + } + pf("!%s EXPECTED - RETRY INPUT LINE%n? ", descriptor); + } + } + + private static int getPositiveNumber() { + return getInput("NUMBER", Scanner::nextInt, num -> num > 0); + } + + private static int getPositiveNumberUpTo(long to) { + return getInput( + "NUMBER FROM 1 TO " + to, + Scanner::nextInt, + num -> num > 0 && num <= to); + } + + private static int[] getPegCount(int upperBound) { + int[] nums = {Integer.MAX_VALUE, Integer.MAX_VALUE}; + while (true) { + String input = getInput( + "NUMBER, NUMBER", + Scanner::nextLine, + s -> s.matches("\\d+[\\s,]+\\d+$")); + String[] numbers = input.split("[\\s,]+"); + nums[0] = Integer.parseInt(numbers[0].trim()); + nums[1] = Integer.parseInt(numbers[1].trim()); + if (nums[0] <= upperBound && nums[1] <= upperBound && + nums[0] >= 0 && nums[1] >= 0) { + return nums; + } + pf("NUMBERS MUST BE FROM 0 TO %d.%n? ", upperBound); + } + } + + private static String getWord() { + return getInput("WORD", Scanner::next, word -> !"".equals(word)); + } +}