This post contains inline annotations/footnotes to help add context, helpful tips or expand upon a tangent in the text. Expand them by clicking or tapping on them Annotations might have their own annotations Such as this one! inside of them too.

As with yesterdays solution I ended up just taking a fairly naive approach here rather than decomposing the problem into the math problem that others have done.

Part One

What would your total score be if everything goes exactly according to your strategy guide?

Our input has a round’s strategy per line, where a strategy is the opponent’s move and our move, separated by a space. The opponent’s move is encoded as:

  • A Rock
  • B Paper
  • C Scissors

And our move is encoded as:

  • X Rock
  • Y Paper
  • Z Scissors

Additionally, we find out that each move has a score assigned to it:

  • Rock => 1
  • Paper => 2
  • Scissors => 3

Finally, we have a set of rules about which move defeats which other move:

  • Rock defeats Scissors
  • Scissors defeats Paper
  • Paper defeats Scissors

So we now know that our problem space is confined to these three moves and their respective rules, each move has a score assigned to it and that our input uses two different characters to represent the same move. We could take this information to make a few functions like the following:

func score(_ char: String) -> Int {
    switch char {
        case "A", "X": return 1
        case "B", "Y": return 2
        case "C", "Z": return 3
    }
}

func defeats(_ char: String) -> String {
    switch char {
        case "A": return "Y"
        case "B": return "Z"
        case "C": return "X"
    }
}

But this presents us with a few problems:

  • Our codes problem space isn’t confined to the three moves because strings can be a lot more than just those 6 characters. Additionally, the compiler won’t alert us if we make a typo so we’re not really taking advantage of the language as well as we could
  • Our code is intrinsically tied to the input format
  • It’s difficult to read and quickly comprehend, we’ll need comments everywhere denoting what "A" is

A good way to work around these problems is to ensure that our code only operates within a confined problem space, letting the language do the work of validating that we stay within the bounds and setting up a boundary between the input and our problem space in the form of a function that’ll map the input. This narrows the place where we worry about the input format down to one space and free up our solution code to be both more expressive and decoupled. To do this we’ll make an enum backed by the scores as integer values:

enum Move: Int {
    case rock = 1
    case paper = 2
    case scissors = 3
}

Using this enum, we can easily map both A and X to Move.rock, so we can make a quick mapping function that’ll take all 6 characters in our input and convert them to our nice enum. This establishes the boundary between the input and our problem space and is the only place we have to worry about the input format. In other words, the decoupling here lets us easy switch out characters and handle adapting to different input formats in the future, leading to more maintainable code.

func mapStringToMove(_ part: Substring) -> Move {
    switch part {
        case "A", "X": return .rock
        case "B", "Y": return .paper
        case "C", "Z": return .scissors
        default: fatalError("Unrecognized input \(part)!")
    }
}

Now that we can map our input down to our problem space, we’ll make a little container that’ll represent a single round and make one last parsing related function that’ll convert a line from our input into a RoundStrategy. This container isn’t strictly necessary as you could use a tuple, but I find the expressiveness of the struct a lot more maintainable and lends itself to more “self-explaining code”:

struct RoundStrategy {
    let theirMove: Move
    let myMove: Move
}

func parseLine(_ line: Substring) -> RoundStrategy {
    let parts = line.split(separator: " ", maxSplits: 1)

    return .init(
        theirMove: stringToMove(parts[0]),
        myMove: stringToMove(parts[1])
    )
}

We’ll add a few helper functions to the RoundStrategy container in a little bit but for now this will be enough. Now we should be able to map our entire input file into an array of RoundStrategys which lets us focus on building out our scoring functionality.

We know that for a single round, if the opponents move is the same as mine, then the round is a draw and the scoring is simply the score for our move plus 3. Let’s add a score function to our RoundStrategy struct and encode this first rule:

struct RoundStrategy {
    let theirMove: Move
    let myMove: Move

    func score() -> Int {
        if theirMove == myMove {
            return myMove.rawValue + 3
        }
    }
}

Now we’ve got two other outcomes to consider:

  • My move defeats their move
  • Their move defeats my move

To try and keep the expressiveness we’ve already established, and trying to anticipate any changes we’ll need for part two, we can make another container and a small lookup function to map one move to the move that it’ll defeat. Then we can simply run their move through the lookup helper to get which move it’ll defeat. If that looked up move is ours, we know we’ve lost and otherwise we know we’ve won:

struct CounterMoves {
    let losingMove: Move
}

// Rock defeats Scissors, defeated by Paper
// Paper defeats Rock, defeated by Scissors
// Scissors defeats Paper, defeated by Rock
func possibleCountersFor(_ move: Move) -> CounterMoves {
    switch move {
        case .rock: return .init(losingMove: .scissors)
        case .paper: return .init(losingMove: .rock)
        case .scissors: return .init(losingMove: .paper)
    }
}

Using a struct here means that if we need to, say, encode the move that’ll defeat their move as well as which move is defeated by their move, we can just add another field to the struct without too many code changes.

Using this in practice looks a bit like this:

let myPossibleMoves = possibleCountersFor(theirMove)
if myPossibleMoves.losingMove == myMove {
    // I've lost :-(
} else {
  // I've won! :-D
}

Let’s add that to our RoundStragety#score() function:

func score() -> Int {
    if theirMove == myMove {
        return myMove.rawValue + 3
    }

    let myPossibleMoves = possibleCountersFor(theirMove)
    if myPossibleMoves.losingMove == myMove {
        return myMove.rawValue + 0
    }

    return myMove.rawValue + 6
}

With that we should be able to write some sanity check tests around the scoring functionality for all possible combinations and ensure that it scores correctly. For example, using the puzzles example:

final class day02Tests: XCTestCase {
    func testRoundScoring() throws {
        XCTAssertEqual(RoundStrategy(theirMove: .rock, myMove: .paper).score(), 8)
        XCTAssertEqual(RoundStrategy(theirMove: .paper, myMove: .rock).score(), 1)
        XCTAssertEqual(RoundStrategy(theirMove: .scissors, myMove: .scissors).score(), 6)
    }
}

With tests written and passing we can tie everything together, parsing our full puzzle input into an array of RoundStragetys and then simply map them to each round’s score and sum all the scores up. You can do this in one go using a reduce as well:

rounds.reduce(0, { memo, round in memo + round.score() })

Part Two

Following the Elf’s instructions for the second column, what would your total score be if everything goes exactly according to your strategy guide?

This just in: we were wrong! The second character for each round isn’t our move but the outcome of the round. We’ll have to figure out what move to make based off of our opponents move and the outcome of the round. The outcome is mapped as such:

  • X we need to lose
  • Y we need to cause a draw
  • Z we need to win

There are a couple of different ways to tackle this problem, some of which will be a little more expressive than the approach I’m going to take. We’ve got a problem with our current RoundStrategy with this twist: the .myMove is actually the outcome of the game and we have to narrow it down to which move we should actually take. However, after we figure that move out, the scoring stays the same. To make things easier, why don’t we just make a utility that’ll take in an incorrect RoundStrategy and produce a corrected RoundStrategy with the correct myMove? We can start off with the easy case: when the outcome is a draw, or when the incoming myMove is a .paper:

// We've got their side and the outcome is under `myMove` because naming is hard
// myMove .rock => lose
// myMove .paper => draw
// myMove .scissors => win
func remapFromOutcomeToMyMove(_ round: RoundStrategy) -> RoundStrategy {
    // Draw so myMove is their move
    if round.myMove == .paper {
        return .init(theirMove: round.theirMove, myMove: round.theirMove)
    }
}

From here we can reuse our existing possibleCountersFor() function to get the case of when the outcome is a loss, or when .myMove is a .rock:

let myPossibleMoves = possibleCountersFor(round.theirMove)

// I need to lose, what their move wins against is my move
if round.myMove == .rock {
    return .init(theirMove: round.theirMove, myMove: myPossibleMoves.losingMove)
}

To find the correct move for the last case, where we need to win we have a few options but I think it’d be easiest to simply add another field to our CounterMoves struct, one for the corresponding move that’ll defeat the looked-up move instead:

struct CounterMoves {
    let losingMove: Move
    let winningMove: Move
}

And now we just need to adjust our look up function to include this winningMove as well:

// Rock defeats Scissors, defeated by Paper
// Paper defeats Rock, defeated by Scissors
// Scissors defeats Paper, defeated by Rock
func possibleCountersFor(_ move: Move) -> CounterMoves {
    switch move {
        case .rock: return .init(losingMove: .scissors, winningMove: .paper)
        case .paper: return .init(losingMove: .rock, winningMove: .scissors)
        case .scissors: return .init(losingMove: .paper, winningMove: .rock)
    }
}

And that’s it, the secret sauce which gives us the final case:

let myPossibleMoves = possibleCountersFor(round.theirMove)

// I need to win, what their move loses against is my move
return .init(theirMove: round.theirMove, myMove: myPossibleMoves.winningMove)

Now all we have to do is adjust our reduce call from part one to first remap the struct to get our correct moves in place before calculating the score:

rounds.reduce(0, { memo, round in
    memo + RoundStrategy.remapFromOutcomeToMyMove(round).score()
})

And with that, part two is also solved!