This is part of the series Advent of Code 2022.
Day 2 - Advent of Code 2022

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:

RoundOpponentYouScore
1RockPaper8
2PaperRock1
3ScissorsScissors6

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

HandWinLose
RockPaperScissors
PaperScissorsRock
ScissorsRockPaper

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.