Day 2 (Advent of Code 2022)

👋 This page was last updated ~2 years ago. Just so you know.

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:

#[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:

#[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.

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):

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:

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:

$ 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:

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:

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

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

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:

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:

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:

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:

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!

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(())
}
$ 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).

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:

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:

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

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:

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.

$ cargo add itertools
(cut)
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:

// 👋 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:

$ 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:

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

This is completely unnecessary but it makes me very happy.

Cool bear

Well, as long as you're happy.

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

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.

Cool bear

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.

Cool bear

Uh-huh.

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

Cool bear

MOVING ON.

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

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:

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:

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:

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:

    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);
$ 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 :)

Comment on /r/fasterthanlime

(JavaScript is required to see this. Or maybe my stuff broke)

Here's another article just for you:

Request coalescing in async Rust

As the popular saying goes, there are only two hard problems in computer science: caching, off-by-one errors, and getting a Rust job that isn't cryptocurrency-related.

Today, we'll discuss caching! Or rather, we'll discuss... "request coalescing", or "request deduplication", or "single-flighting" - there's many names for that concept, which we'll get into fairly soon.