Welcome to the challenge of the 2nd day. Today's challenge is to have a Rock, Papers, Scissors tournament to determine which of the elves are the closest to the snack storage.
Part 1
The input text consists of a encrypted strategy guide given to you by an elf that should help you win the tournament.
A Y
B X
C Z
The first column is the opponent is going to play, A
is for Rock
, B
for Paper
, C
for Scissors
.
You assume the second column is following a similar pattern, X
for Rock
, Y
for Paper
, Z
for Scissors
.
The winner is the player with the highest total score of all played games. To calculate your personal total score all rounds from the strategy guide have to be played and their scores summed.
The strategy guide calculates the score of each round, represented by a single line from the input. A score is defined by the outcome of the round (0 for loss, 3 for draw, 6 for win) and the shape selected (1 for Rock, 2 for Paper, 3 for Scissors).
Following the sample input the games are played as follows:
Round | Opponent | You | Score |
---|---|---|---|
1 | Rock | Paper | 8 |
2 | Paper | Rock | 1 |
3 | Scissors | Scissors | 6 |
The player has a total of 15
. Let's add this sample as a test to our project. After we created a new folder for today's challenge.
cd aoc-2022
cargo new day02
We expand the main.rs
with a test module.
// main.rs
fn main() {
//
}
#[cfg(test)]
mod tests {
use crate::*;
const INPUT: &str = r#"
A Y
B X
C Z
"#;
#[test]
fn check_part1() {
assert_eq!(15, part1(&parse(INPUT)));
}
}
The first thing to think about is how to read in the data and model the logic to deal with playing Rock, Paper, Scissors.
There are multiple different ways to model a single Hand
, as a number, an enum
, a struct
or using the New Type pattern.
I decided to use a custom type and to not use an external crate for now.
The new type Hand
looks like:
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
struct Hand(u8);
impl Hand {
const ROCK: Hand = Hand(0);
const PAPER: Hand = Hand(1);
const SCISSORS: Hand = Hand(2);
}
This is a bit like cheating and to avoid using an enum
specifically. There are crates available to convert enum variants to numbers conveniently, without implemeting conversion traits. The crates num_enum & num_derive are very useful libraries to do exactly that.
The main reason to have a new type is to ensure we are not dealing with numbers directly.
Let's implement the parse
function to read in the given input text file.
use itertools::Itertools;
/// Parses the strategy guide from the input as list of hands to play
fn parse(input: &str) -> Vec<(&str, &str)> {
input
.lines()
.map(str::trim)
.filter(|s| !s.is_empty())
.filter_map(|line| line.split(' ').collect_tuple())
.collect_vec()
}
This code makes use of the excellent itertools crate. This expands the existing Iterator
types by adding several useful methods.
The parse
method reads all lines, trims all leading & trailing whitespaces and filters any empty lines. In this particular case empty
lines have no significance, this is done to allow the INPUT
variable in the test module to be formatted as it is.
The main part is to split
each line by a whitespace and return
tuples of (&str, &str)
by using the collect_tuple
method.
Thanks to the return type the number of tuple elements (2) is inferred correctly. Otherwise we would need to add a type hint, for example
.collect_tuple::<(_, _)>()
which looks quite neat on its own.
Let's implement the part1
function to calculate the first solution.
// Basic structure to play all rounds of Rock, Paper, Scissors.
fn part1(hands: &[(&str, &str)]) -> u32 {
hands
.iter()
.map(|&(l, r)| Hand::play(Hand::from(l), Hand::from(r)))
.sum()
}
fn main() {
let hands = parse(include_str!("input.txt"));
println!("Part 1: {}", part1(&hands));
}
We paste the input text into a new file named input.txt
and load its content in main
using include_str!
the same way as on day one.
The &str
is passed into the parse
function, the result type Vec<(&str, &str)>
is passed into the part1
function. Its return
value is the total score of all played rounds. part1
iterates over all pairs of hands & sums their total.
Now we need to implement the new Hand::play
method that receives both hands and returns the score of this round.
The function interface looks like:
impl Hand {
/// Two players show their hands, outcome for right hand is counted.
pub fn play(left: Hand, right: Hand) -> u32 {
// calculate the score of the play
}
}
We do not pass a pair of &str
values into Hand::play
but rather values of type Hand
. For this to work the values need to
be converted first. We implement the From
trait to support a conversion from
&str
to Hand
. Only a few characters are allowed and these are mapped to specific Hands. The conversion looks like:
impl From<&str> for Hand {
fn from(c: &str) -> Self {
match c {
"A" | "X" => Self::ROCK,
"B" | "Y" => Self::PAPER,
"C" | "Z" => Self::SCISSORS,
_ => panic!("Unknown char found"),
}
}
}
Rather than mapping to a concrete variant of an enum
type, e.g. Hand::Rock
, a character is mapped to a Hand
type with an inner value, e.g. 0
for Rock. All possible Hand values are defined as const values to use them later instead of plain numbers 0
, 1
& 2
.
Let's implement play
now. The basic idea is to compare both hands, determine the outcome & calculate a score according to the given rules.
impl Hand {
/// Two players show their hands, outcome for right hand is counted.
pub fn play(left: Hand, right: Hand) -> u32 {
let total = match (left, right) {
(Hand::ROCK, Hand::PAPER) => 6,
(Hand::ROCK, Hand::SCISSORS) => 0,
(Hand::PAPER, Hand::ROCK) => 0,
(Hand::PAPER, Hand::SCISSORS) => 6,
(Hand::SCISSORS, Hand::ROCK) => 6,
(Hand::SCISSORS, Hand::PAPER) => 0,
_ => 3,
};
total + right.0 as u32 + 1
}
}
There is not much logic involved, a match
statement on the given tuple left
& right
the outcome maps to a value, either 0
for loss, 6
for win. The default case is a draw with a score of 3
. The last statements calculates the score of the shape plus one.
With the above code the test should now pass and we are able to determine the first solution via cargo run
.
Part 2
In the second part we are informed by the elf that the 2nd column with X
, Y
& Z
did not represent hands, but rather the outcome of every round. X
means the player needs to lose against the opponent, Y
means the round needs to end in a draw, Z
means to win.
The player therefore needs to play the matching hand to produce the desired outcome.
Given the sample input from the daily challenge the round of A Y
means the opponent plays Rock
. The player needs to end the round in a draw, therefore the player needs to play the same hand, Rock
.
The task is to calculate the total score with the new rules, all scores are calculated the same as before. The sample data is added as another test with the new expected outcome.
#[cfg(test)]
// as before
#[test]
fn check_part2() {
assert_eq!(12, part2(&parse(INPUT)));
}
}
At this point we cannot really refactor the existing logic, as there is a new rule to play, but we may able to re-use the existing play
function.
The new part2
needs to determine first which cards are needed to be played, the scoring calculation is the same. A new method play2
is added
that we called to calculate the new score.
impl Hand {
pub fn play(left: Hand, right: Hand) -> u32 { /* .. */ }
pub fn play2(left: &str, right: &str) -> u32 {
unimplemented!()
}
}
fn part2(hands: &[(&str, &str)]) -> u32 {
hands.iter().map(|&(l, r)| Hand::play2(l, r)).sum()
}
fn main() {
let hands = parse(include_str!("input.txt"));
println!("Part 1: {}", part1(&hands));
println!("Part 2: {}", part2(&hands));
}
The added test and functions should compile, but of course the test check_part2
will fail. The new method play2
has different parameters
because the meaning of what a &str
represents is different this time. The idea for part2
is to interpret the right
parameter
and find the appropriate Hand
to play.
impl Hand {
pub fn play2(left: &str, right: &str) -> u32 {
let left = Hand::from(left);
match right {
"X" => Self::play(left, left.lose()),
"Y" => Self::play(left, left),
"Z" => Self::play(left, left.win()),
_ => panic!("Unknown input found"),
}
}
}
The hand of the opponent (left
) is converted into its Hand
type. The match
statement handles all outcomes of the right
value.
X
means we need to lose, Y
to end in a draw, Z
to lose. We re-use the existing Hand::play
method and return the round's score.
The new methods lose
& win
rotate the hands in a way that ends up with either a losing or winning hand. Given the hands
Rock
, Paper
, Scissors
, shifting the hand by one to the right results in a losing hand, Scissors
, Rock
, Paper
.
Shifting the hand to the left will result in winning hand, Paper
, Scissors
, Rock
Hand | Win | Lose |
---|---|---|
Rock | Paper | Scissors |
Paper | Scissors | Rock |
Scissors | Rock | Paper |
The methods Hand::lose
& Hand::win
are implemented as:
impl Hand {
/// Pick next hand, it wins
pub fn win(&self) -> Hand {
Self((self.0 + 1) % 3)
}
/// Pick previous hand, it loses
pub fn lose(&self) -> Hand {
Self((self.0 + 2) % 3)
}
}
It's rotating the inner u8
to one of its possible values 0
, 1
or 2
.
Alternatively it's also possible to implement the PartialOrd
trait to allow two Hand
values to compare to each other.
An implementation of partial_cmp
would return an Ordering to signify
which other hand is Greater
, Less
or Equal
.
We should now have all the logic in place to calculate the 2nd solution.