Part 1

In the day 2 challenge, we're playing Rock Papers Scissors.

We're given a strategy guide like so:

A Y
B X
C Z

Left column is "their move": A means Rock, B means Paper, C means Scissors. Right column is "our move": X means Rock, Y means Paper, Z means Scissors.

Each line corresponds to a turn, and we must calculate the total score we get. Picking "Rock" gives 1 point, "Paper" gives 2 points, and "Scissors" gives 3. Losing the round gives 0 points, drawing gives 3, winning it gives 6.

Line A Y means they picked "Rock", we picked "Paper" (2 points), and we won (6 points), so our score goes up by 8.

Line B X means they picked "Paper", we picked "Rock" (1 point), and we lost, so our score goes up by 1.

Line C Z means we both picked "Scissors" (3 points) and it's a draw (3 points), so our score goes up by 6, for a grand total of 8 + 1 + 6 = 15.

Okay! So! First off, what will our types look like?

For every round we can do one of three moves, that sounds like a sum type, let's make an enum for it:

Rust code
#[derive(Debug, Clone, Copy)]
enum Move {
    Rock,
    Paper,
    Scissors,
}

Every round is one of our moves and one of their moves - sounds like a struct, let's make one:

Rust code
#[derive(Debug, Clone, Copy)]
struct Round {
    theirs: Move,
    ours: Move,
}

We should be able to parse a Move from either ABC or XYZ, let's implement the TryFrom trait to convert from char. Not From, because our conversion is fallible: if we get the letter J, we'll error out.

Rust code
impl TryFrom<char> for Move {
    type Error = color_eyre::Report;

    fn try_from(c: char) -> Result<Self, Self::Error> {
        match c {
            'A' | 'X' => Ok(Move::Rock),
            'B' | 'Y' => Ok(Move::Paper),
            'C' | 'Z' => Ok(Move::Scissors),
            _ => Err(color_eyre::eyre::eyre!("not a valid move: {c:?}")),
        }
    }
}

Now we can, say, parse a Round given a &str (a single line):

Rust code
impl FromStr for Round {
    type Err = color_eyre::Report;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        let mut chars = s.chars();
        let (Some(theirs), Some(' '), Some(ours), None) = (chars.next(), chars.next(), chars.next(), chars.next()) else {
            return Err(color_eyre::eyre::eyre!("expected <theirs>SP<ours>EOF, got {s:?}"));
        };

        Ok(Self {
            theirs: theirs.try_into()?,
            ours: ours.try_into()?,
        })
    }
}

The FromStr trait is what we used in day 1 when we had to turn String / &str values into u64.

And then our main function can look something like this:

Rust code
fn main() -> color_eyre::Result<()> {
    color_eyre::install()?;

    for round in include_str!("input.txt")
        .lines()
        .map(|line| line.parse::<Round>())
    {
        let round = round?;
        println!("{round:?}");
    }

    Ok(())
}

Just like last time, we're splitting input.txt into a bunch of lines, then parsing each of them into a Round. We now have an Iterator<Item = Result<Round, Report>>.

The let round = round?; line takes care of propagating any parsing errors, and then we just print each individual round.

Let's try it out:

Shell session
$ cargo run --quiet
warning: fields `theirs` and `ours` are never read
  --> src/main.rs:25:5
   |
24 | struct Round {
   |        ----- fields in this struct
25 |     theirs: Move,
   |     ^^^^^^
26 |     ours: Move,
   |     ^^^^
   |
   = note: `#[warn(dead_code)]` on by default
   = note: `Round` has a derived impl for the trait `Debug`, but this is intentionally ignored during dead code analysis

Round { theirs: Rock, ours: Paper }
Round { theirs: Paper, ours: Rock }
Round { theirs: Scissors, ours: Scissors }

Let's ignore the warnings for now (we'll use that code soon enough...) and check that it matches what we expected. Instant replay please?

Line A Y means they picked "Rock", we picked "Paper" (2 points), and we won (6 points), so our score goes up by 8.

Line B X means they picked "Paper", we picked "Rock" (1 point), and we lost, so our score goes up by 1.

Line C Z means we both picked "Scissors" (3 points) and it's a draw (3 points), so our score goes up by 6, for a grand total of 8 + 1 + 6 = 15.

Okay, that tracks.

Next up, let's add code that determines how many points we get for picking a move:

Rust code
impl Move {
    /// How many points do we get for picking that move?
    fn inherent_points(self) -> usize {
        match self {
            Move::Rock => 1,
            Move::Paper => 2,
            Move::Scissors => 3,
        }
    }
}

Also, which move beats which. Or rather... what outcome there is when matching two moves together: it's not just a boolean, there's three possible outcomes:

Rust code
#[derive(Debug, Clone, Copy)]
enum Outcome {
    Win,
    Draw,
    Loss,
}

And now we can use this to implement Move::outcome:

Rust code
impl Move {
    fn outcome(self, theirs: Move) -> Outcome {
        match (self, theirs) {
            (Move::Rock, Move::Rock) => Outcome::Draw,
            (Move::Rock, Move::Paper) => Outcome::Loss,
            (Move::Rock, Move::Scissors) => Outcome::Win,
            (Move::Paper, Move::Rock) => Outcome::Win,
            (Move::Paper, Move::Paper) => Outcome::Draw,
            (Move::Paper, Move::Scissors) => Outcome::Loss,
            (Move::Scissors, Move::Rock) => Outcome::Loss,
            (Move::Scissors, Move::Paper) => Outcome::Win,
            (Move::Scissors, Move::Scissors) => Outcome::Draw,
        }
    }
}

I don't love the look of this though, it seems like it'd be really easy for a mistake to slip in there.

Let's try something else:

Rust code
impl Move {
    fn beats(self, other: Move) -> bool {
        matches!(
            (self, other),
            (Self::Rock, Self::Scissors)
                | (Self::Paper, Self::Rock)
                | (Self::Scissors, Self::Paper)
        )
    }

    fn outcome(self, theirs: Move) -> Outcome {
        if self.beats(theirs) {
            Outcome::Win
        } else if theirs.beats(self) {
            Outcome::Loss
        } else {
            Outcome::Draw
        }
    }
}

Mhh yes, I like this more: the logic is encoded once, and we pray to the optimizing compiler gods that it's just as efficient as the other one.

Now we can also add an inherent_points method on Outcome:

Rust code
impl Outcome {
    fn inherent_points(self) -> usize {
        match self {
            Outcome::Win => 6,
            Outcome::Draw => 3,
            Outcome::Loss => 0,
        }
    }
}

And now, a score method on Round:

Rust code
impl Round {
    fn outcome(self) -> Outcome {
        self.ours.outcome(self.theirs)
    }

    fn our_score(self) -> usize {
        self.ours.inherent_points() + self.outcome().inherent_points()
    }
}

Let's dump everything we know about the script (sorry, "strategy guide") we were given:

Rust code
fn main() -> color_eyre::Result<()> {
    color_eyre::install()?;

    for round in include_str!("input.txt")
        .lines()
        .map(|line| line.parse::<Round>())
    {
        let round = round?;
        println!(
            "{round:?}: outcome={outcome:?}, our score={our_score}",
            outcome = round.outcome(),
            our_score = round.our_score()
        );
    }

    Ok(())
}

And now we just need to calculate the sum of scores!

Rust code
fn main() -> color_eyre::Result<()> {
    color_eyre::install()?;

    let total_score = include_str!("input.txt")
        .lines()
        .map(|line| line.parse::<Round>())
        .map(|round| round.our_score())
        .sum();

    Ok(())
}
Shell session
$ cargo run --quiet
error[E0599]: no method named `our_score` found for enum `Result` in the current scope
   --> src/main.rs:108:28
    |
108 |         .map(|round| round.our_score())
    |                            ^^^^^^^^^ method not found in `Result<Round, ErrReport>`
    |
note: the method `our_score` exists on the type `Round`
   --> src/main.rs:81:5
    |
81  |     fn our_score(self) -> usize {
    |     ^^^^^^^^^^^^^^^^^^^^^^^^^^^
help: consider using `Result::expect` to unwrap the `Round` value, panicking if the value is a `Result::Err`
    |
108 |         .map(|round| round.expect("REASON").our_score())
    |                           +++++++++++++++++

For more information about this error, try `rustc --explain E0599`.
error: could not compile `day2` due to previous error

Oh, that's right! Parsing a &str into a Round is fallible. Well, the compiler gives a suggestion (and holy fuck I didn't see that one coming), but here's a neat trick we can use instead: collect() can collect into a Result<Vec<_>, _> (where _ lets the compiler figure it out).

Rust code
fn main() -> color_eyre::Result<()> {
    color_eyre::install()?;

    let rounds: Vec<Round> = include_str!("input.txt")
        .lines()
        .map(|line| line.parse())
        .collect::<Result<_, _>>()?;
    let total_score: usize = rounds.iter().map(|r| r.our_score()).sum();
    dbg!(total_score);

    Ok(())
}

In fact there's several neat things going on here: the first _ in Result<_, _> is inferred to be Vec<Round> because we specify the type in let rounds: Vec<Round>.

Similarly, we don't need a turbofish for sum because we assign it to let total_score: usize.

We can play around with this, settling on whichever alternative we like best:

Rust code
fn main() -> color_eyre::Result<()> {
    color_eyre::install()?;

    //             👇 now inferred 
    let rounds: Vec<_> = include_str!("input.txt")
        .lines()
        //    👇 thanks to this
        .map(Round::from_str)
        .collect::<Result<_, _>>()?;
    let total_score: usize = rounds.iter().map(|r| r.our_score()).sum();
    dbg!(total_score);

    Ok(())
}

Oh, and the dbg! macro is "pass-through": it evaluates to whatever its argument is, but it also prints it with the file name and line number:

Shell session
$ cargo run --quiet
[src/main.rs:110] total_score = 15

But, wait... why collect into a Vec? We just need to sum everything.

Ah, you're right. We don't want to collect everything, because we don't need to. But then... error handling gets complicated.

It's not if we use imperative code, like so:

Rust code
fn main() -> color_eyre::Result<()> {
    color_eyre::install()?;

    let mut total_score = 0;
    for round in include_str!("input.txt").lines().map(Round::from_str) {
        total_score += round?.our_score();
    }
    dbg!(total_score);

    Ok(())
}

...and maybe that's the version we ought to stick with.

But let's preoccupy ourself with whether we could (stick with iterators / functional style), rather than whether we really should.

Shell session
$ cargo add itertools
(cut)
Rust code
fn main() -> color_eyre::Result<()> {
    color_eyre::install()?;

    let total_score: usize = itertools::process_results(
        include_str!("input.txt")
            .lines()
            .map(Round::from_str)
            .map(|r| r.map(|r| r.our_score())),
        |it| it.sum(),
    )?;
    dbg!(total_score);

    Ok(())
}

Or even:

Rust code
// 👋 new!
use itertools::{process_results, Itertools};

fn main() -> color_eyre::Result<()> {
    color_eyre::install()?;

    let total_score: usize = itertools::process_results(
        include_str!("input.txt")
            .lines()
            .map(Round::from_str)
            // 👇 this is provided by `Itertools`
            .map_ok(|r| r.our_score()),
        |it| it.sum(),
    )?;
    dbg!(total_score);

    Ok(())
}

This works just as well:

Shell session
$ cargo run --quiet
[src/main.rs:114] total_score = 15

And, running it on my puzzle input, this gets me to part two.

Part 2

In part two, we learn that X, Y and Z do not, in fact, mean "Rock", "Paper" and "Scissors", but they indicate how the round must end. "X" means loss, "Y" means draw" and "Z" means win — it's on us to figure out what we need to play.

Before we do anything, let's re-order our Outcome enum so it matches that:

Rust code
#[derive(Debug, Clone, Copy)]
enum Outcome {
    Loss,
    Draw,
    Win,
}

This is completely unnecessary but it makes me very happy.

Well, as long as you're happy.

Ok, next we'll want... we'll want some helpers. Like so:

Rust code
impl Move {
    const ALL_MOVES: [Move; 3] = [Move::Rock, Move::Paper, Move::Scissors];

    fn winning_move(self) -> Self {
        Self::ALL_MOVES
            .iter()
            .copied()
            .find(|m| m.beats(self))
            .expect("at least one move beats us")
    }

    fn losing_move(self) -> Self {
        Self::ALL_MOVES
            .iter()
            .copied()
            .find(|&m| self.beats(m))
            .expect("we beat at least one move")
    }

    fn drawing_move(self) -> Self {
        self
    }
}

So this is probably not the fastest code, but I like it a lot, because again, we've encoded the logic just once. I think it's very elegant.

Are you done congratulating yourself? Can we move on?

Listen bear, you gotta stop and smell the roses. Even if you're the one who grew them.

Uh-huh.

Code is not... written. It's discovered. I am merely the vessel through which-

MOVING ON.

Ok now, we'll want to change our Move parser so it only parses from "ABC" and not "XYZ":

Rust code
impl TryFrom<char> for Move {
    type Error = color_eyre::Report;

    fn try_from(c: char) -> Result<Self, Self::Error> {
        match c {
            'A' => Ok(Move::Rock),
            'B' => Ok(Move::Paper),
            'C' => Ok(Move::Scissors),
            _ => Err(color_eyre::eyre::eyre!("not a valid move: {c:?}")),
        }
    }
}

And then, parse Outcomes:

Rust code
impl TryFrom<char> for Outcome {
    type Error = color_eyre::Report;

    fn try_from(c: char) -> Result<Self, Self::Error> {
        match c {
            'X' => Ok(Outcome::Loss),
            'Y' => Ok(Outcome::Draw),
            'Z' => Ok(Outcome::Win),
            _ => Err(color_eyre::eyre::eyre!("not a valid outcome: {c:?}")),
        }
    }
}

We can even add a helper to Outcome itself, to find the move that matches, given their move:

Rust code
impl Outcome {
    fn matching_move(self, theirs: Move) -> Move {
        match self {
            Outcome::Win => theirs.winning_move(),
            Outcome::Draw => theirs.drawing_move(),
            Outcome::Loss => theirs.losing_move(),
        }
    }
}

Now, we can change Round's FromStr implementation to parse their move, the desired outcome, and decide what our move should be:

Rust code
impl FromStr for Round {
    type Err = color_eyre::Report;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        let mut chars = s.chars();
        let (Some(theirs), Some(' '), Some(outcome), None) = (chars.next(), chars.next(), chars.next(), chars.next()) else {
            return Err(color_eyre::eyre::eyre!("expected <theirs>SP<outcome>EOF, got {s:?}"));
        };
        let theirs = Move::try_from(theirs)?;
        let outcome = Outcome::try_from(outcome)?;
        let ours = outcome.matching_move(theirs);

        Ok(Self { theirs, ours })
    }
}

Let's try it again with the sample input, adding a couple of well-placed dbg! invocations to look at how the Round values were parsed and what the individual score was:

Rust code
    let total_score: usize = itertools::process_results(
        include_str!("input.txt")
            .lines()
            .map(Round::from_str)
            //    there 👇   👇
            .map_ok(|r| dbg!(dbg!(r).our_score())),
        |it| it.sum(),
    )?;
    dbg!(total_score);
Shell session
$ cargo run
   Compiling day2 v0.1.0 (/home/amos/bearcove/aoc2022/day2)
    Finished dev [unoptimized + debuginfo] target(s) in 0.67s
     Running `target/debug/day2`
[src/main.rs:154] r = Round {
    theirs: Rock,
    ours: Rock,
}
[src/main.rs:154] dbg!(r).our_score() = 4
[src/main.rs:154] r = Round {
    theirs: Paper,
    ours: Rock,
}
[src/main.rs:154] dbg!(r).our_score() = 1
[src/main.rs:154] r = Round {
    theirs: Scissors,
    ours: Rock,
}
[src/main.rs:154] dbg!(r).our_score() = 7
[src/main.rs:157] total_score = 12

And, with my real puzzle input, that gets us through part two!

I love it when a plan comes together :)