Thanks to my sponsors: Aiden Scandella, Bob Ippolito, Chris Sims, Marie Janssen, SeniorMars, David White, Jean Manguy, Ramen, Luke Yue, Marcin Kołodziej, Tyler Bloom, Olivia Crain, Aalekh Patel, Joonas Koivunen, Ronen Ulanovsky, Andronik, Chris Walker, Jacob Cheriathundam, Luiz Irber, Luke Konopka and 235 more
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
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.
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.
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":
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 Outcome
s:
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 :)
Here's another article just for you:
Pin and suffering
I'd like to think that my understanding of "async Rust" has increased over the past year or so. I'm 100% onboard with the basic principle: I would like to handle thousands of concurrent tasks using a handful of threads. That sounds great!
And to become proficient with async Rust, I've accepted a lot of things. There are blue functions and red functions, and red (async) functions are contagious.