Last updated on

Codebreaking puzzles

Points
100 pts
Topics
Specifications, Higher-order functions, Modularity
Files to submit
src/main/scala/codebreaking/Common.scala
src/main/scala/codebreaking/Wordle.scala
src/main/scala/codebreaking/BullsAndCows.scala
src/main/scala/codebreaking/MasterMind.scala

The aim of this exercise is to build solvers for multiple closely-related code-breaking and word-guessing games. In these games, one player, the secret-holder, picks a sequence of $n$ digits or letters, and the other, the code-breaker, tries to guess the sequence.

On every turn, the code-breaker makes a guess, and the secret holder gives it a score, revealing partial information about the secret. Each game of this form is represented as an instance of the Puzzle abstract class:

abstract class Puzzle[Word, Score]:
  def score(guess: Word, secret: Word): Score
  def guess(previousGuesses: List[ScoredGuess[Word, Score]]): GuessResult[Word]

codebreaking/src/main/scala/codebreaking/Common.scala

We will study three games:

Part 1: Scoring functions (60 pts)

Your first task is to write a scoring function for each of these games. The scoring function takes a guess and a secret, and returns a score:

def score(guess: Word, secret: Word): Score =
  require(guess.length == wordLength, f"Guess length must be $wordLength.")
  require(secret.length == wordLength, f"Secret length must be $wordLength.")
  require(guess.distinct == guess, f"Guess $guess cannot have duplicates.")
  require(secret.distinct == secret, f"Secret $secret cannot have duplicates.")
  ???

codebreaking/src/main/scala/codebreaking/Wordle.scala

def score(guess: Word, secret: Word): Score =
  require(guess.length == wordLength, f"Guess must have length $wordLength.")
  require(secret.length == wordLength, f"Secret must have length $wordLength.")
  require(secret.distinct == secret, f"Secret $secret cannot have duplicates.")
  ???

codebreaking/src/main/scala/codebreaking/BullsAndCows.scala

def score(guess: Word, secret: Word): Score =
  require(guess.length == wordLength, f"Guess must have length $wordLength.")
  require(secret.length == wordLength, f"Secret must have length $wordLength.")
  ???

codebreaking/src/main/scala/codebreaking/MasterMind.scala

You can run specific tests using the testOnly command. As the tests contain spaces and special characters, make sure to quote your patterns appropriately. For example:

sbt:codebreaking> testOnly -- "*BullsAndCows: *"
sbt:codebreaking> testOnly -- "*Wordle.score(\\\"purchasing\\\", \\\"bracketing\\\")*"
def score(guess: Word, secret: Word): Score =
  require(guess.length == wordLength, f"Guess length must be $wordLength.")
  require(secret.length == wordLength, f"Secret length must be $wordLength.")
  require(guess.distinct == guess, f"Guess $guess cannot have duplicates.")
  require(secret.distinct == secret, f"Secret $secret cannot have duplicates.")
  guess.zip(secret).toList.map { (g, s) =>
    if g == s then Result.Green
    else if secret.contains(g) then Result.Yellow
    else Result.Grey
  }

codebreaking/src/main/scala/codebreaking/Wordle.scala

def score(guess: Word, secret: Word): Score =
  require(guess.length == wordLength, f"Guess must have length $wordLength.")
  require(secret.length == wordLength, f"Secret must have length $wordLength.")
  require(secret.distinct == secret, f"Secret $secret cannot have duplicates.")
  val bulls = guess.zip(secret).count(_ == _)
  Score(bulls, secret.count(guess.contains) - bulls)

codebreaking/src/main/scala/codebreaking/BullsAndCows.scala

def score(guess: Word, secret: Word): Score =
  require(guess.length == wordLength, f"Guess must have length $wordLength.")
  require(secret.length == wordLength, f"Secret must have length $wordLength.")
  val exact = guess.zip(secret).count(_ == _)
  val all = allColors.map(c => math.min(guess.count(_ == c), secret.count(_ == c))).sum
  Score(exact, all - exact)

codebreaking/src/main/scala/codebreaking/MasterMind.scala

Part 2: Guessing functions (40 pts)

A guessing function is a function which takes a sequence of previous guesses and their scores and either proposes a new guess or reports that the puzzle is solved and gives its solution.

With a guessing function, solving a game is simple. We start with an empty list of guesses, then repeatedly call the guessing function. If it reports that the puzzle is solved, then we are done. Otherwise we score the guess that it returns and call the guessing function again with this additional scored guess. Our testing code includes an implementation of this algorithm.

Example

Imagine that we are given the following guesses:

List(1, 2, 3): 1 bull, 1 cow
List(5, 3, 2): 2 bulls, 0 cows

At this point, only two possible solutions remain: List(7, 2, 1) and List(3, 2, 5).

The guessing function for Bulls and Cows could guess List(7, 2, 1). The solver would then submit this guess to the secret holder, who would return a score. Assume that score is “1 bull, 0 cows”: the solver would then call the guessing function again with the additional guess and its score:

List(1, 2, 3): 1 bull, 1 cow
List(5, 3, 2): 2 bulls, 0 cows
List(7, 2, 1): 1 bull, 0 cows # Added

With this additional information, the guessing function would then return List(3, 2, 5) as the solution (the one possibility left).

Of course, the guessing function could pick a different combination (any sequence of three digits would be valid):

  • If it had picked List(1, 3, 7), then the secret holder would have reported “0 bulls, 1 cow”, and the guessing function could have again solved the game on the next round.

  • If it had picked List(0, 0, 8), then the secret holder would have reported “0 bulls, 0 cows”; this time the guessing function could not have completed the game on the next round, since more than one possible secret would remain.

Your second task is to implement guessing functions for our three games:

def guess(previousGuesses: List[ScoredGuess[Word, Score]]): GuessResult[Word] =
  require(
    previousGuesses.forall(_._1.length == wordLength),
    f"Previous guesses must have length $wordLength."
  )
  require(
    previousGuesses.map(_._1).forall(wordList.contains),
    "Previous guesses must all be valid words."
  )
  ???

codebreaking/src/main/scala/codebreaking/Wordle.scala

def guess(previousGuesses: List[ScoredGuess[Word, Score]]): GuessResult[Word] =
  require(
    previousGuesses.forall(_._1.length == wordLength),
    f"Previous guesses must have length $wordLength"
  )
  ???

codebreaking/src/main/scala/codebreaking/BullsAndCows.scala

def guess(previousGuesses: List[ScoredGuess[Word, Score]]): GuessResult[Word] =
  require(
    previousGuesses.forall(_._1.length == wordLength),
    f"Previous guesses must have length $wordLength"
  )
  ???

codebreaking/src/main/scala/codebreaking/MasterMind.scala

In each case, you receive a list of previous guesses and their scores, and you must return one of three results:

enum GuessResult[+Word]:
  case StillGuessing(nextGuess: Word)
  case FoundSolution(solution: Word)
  case NoSolution extends GuessResult[Nothing]

codebreaking/src/main/scala/codebreaking/Common.scala

  • Each complete game (repeated guesses until the secret is found) may not take more than 10 seconds. Since our CI machine may not run as fast as the VDI machines, we recommend staying well below that limit to be safe.

  • The tests are increasingly difficult to pass: the last tests require an efficient guessing strategy. They are hard to pass and worth very few points: do not waste time on them!

  • Your code must be deterministic. If you use Random, initialize it with a seed: using val rng = scala.util.Random(0) will make sure that running your code twice produces the same results.

The solving algorithm is the same for all three games:

  1. Enumerate all possible words;
  2. Filter them to keep only those compatible with previous guesses;
  3. Pick a guess if more than one possible value remains.

Enumerating words

Step 1 requires a game-specific way to enumerate words, so we add a field allSecrets in the Puzzle abstract class:

lazy val allSecrets: Iterable[Word]

codebreaking/src/main/scala/codebreaking/Common.scala

For Wordle, we simply filter the word list (validGuesses will become useful later):

lazy val allSecrets = wordList.filter(w => w.length == wordLength && w.distinct == w)

codebreaking/src/main/scala/codebreaking/Wordle.scala

For MasterMind and Bulls and cows, we use a new function iterWords to generate all products…

def iterWords[T](letters: Iterable[T], wordLength: Int): LazyList[List[T]] =
  if wordLength == 0 then LazyList(List())
  else
    for
      w <- iterWords(letters, wordLength - 1)
      l <- letters.to(LazyList)
    yield l :: w

codebreaking/src/main/scala/codebreaking/Common.scala

… and use it to define allSecrets for both games:

lazy val allSecrets = iterWords(allColors.toSet, wordLength)

codebreaking/src/main/scala/codebreaking/MasterMind.scala

lazy val validGuesses = iterWords(allDigits, wordLength)
lazy val allSecrets = validGuesses.filter(w => w.distinct == w)

codebreaking/src/main/scala/codebreaking/BullsAndCows.scala

Filtering guesses

The filtering process reuses the score function, so it does not depend on the puzzle: it can be implemented in Common.scala:

def remainingSecrets(previousGuesses: List[ScoredGuess[Word, Score]]): Iterable[Word] =
  allSecrets.filter: s =>
    previousGuesses.forall(g => score(g.guess, s) == g.score)

codebreaking/src/main/scala/codebreaking/Common.scala

The guessing process does not depend on the specific choice of puzzle either: we just check how many possible secrets remain using remainingSecrets, and pick one:

val pickGuess = guessRandom
def guess(previousGuesses: List[ScoredGuess[Word, Score]]): GuessResult[Word] =
  val remaining = remainingSecrets(previousGuesses)
  if remaining.isEmpty then NoSolution
  else if remaining.tail.isEmpty then FoundSolution(remaining.head)
  else StillGuessing(pickGuess(remaining))

codebreaking/src/main/scala/codebreaking/Common.scala

The last part is to decide how to pick a guess:

  • The following passes almost all the tests, but is not optimal:

    def guessFirst(remainingWords: Iterable[Word]) =
      remainingWords.head
    

    codebreaking/src/main/scala/codebreaking/Common.scala

  • The following is barely more complicated and passes all the tests:

    val rng = scala.util.Random(0)
    def guessRandom(remainingWords: Iterable[Word]) =
      rng.shuffle(remainingWords).head
    

    codebreaking/src/main/scala/codebreaking/Common.scala

  • Better strategies are relatively easy to implement (10-15 lines): a particularly good one is to pick the a guess that has the best “worst possible answer”, i.e. a guess that has the smallest possible set of remaining secrets across all possible answers from the secret-holder. Such a strategy was not needed to pass all tests.