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
RockB
PaperC
Scissors
And our move is encoded as:
X
RockY
PaperZ
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 RoundStrategy
s 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 RoundStragety
s 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 loseY
we need to cause a drawZ
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!