Ruby/Crystal/Rust Advent of Code 2022 - Day 2

Ruby/Crystal/Rust Advent of Code 2022 - Day 2

Welcome to the second day of Advent of Code for 2022! The Elves have been busy setting up camp on the beach, but there are decisions to be made, and the Elves, who always seem to want to do things the hard way, have a plan - Rock Paper Scissors.

The Repository

All of the code for this day's solutions, as well as the data input file, can be found at:

https://github.com/wyhaines/advent-of-code-2022/tree/main/day_2

Part 1

To see the full details of the challenge, visit the Advent of Code 2022 - Day 2 page. However, the tl;dr version of the challenge is as follows:

Rock Paper Scissors is a game where two players take turns choosing one of three options: Rock, Paper, or Scissors. The winner of each round is determined by the rules of the game, where Rock beats Scissors, Scissors beats Paper, and Paper beats Rock. If both players choose the same option, the round is a draw. The winner of the game is the player with the highest score, which is calculated by adding up the scores for each round. The score for a single round is determined by the option chosen by the player and the outcome of the round. Rock is worth 1 point, Paper is worth 2 points, and Scissors is worth 3 points. If the player loses the round, they get 0 points for that round. If the round is a draw, they get 3 points. If the player wins, they get 6 points.

In the scenario described in the text, the Elves are having a Rock Paper Scissors tournament to decide whose tent gets to be closest to the snack storage. An Elf gives you an encrypted strategy guide that they say will help you win the tournament. Your task is to calculate your total score if you follow the strategy guide exactly. For example, if the guide says "A Y," this means that in the first round, your opponent will choose Rock and you should choose Paper. This would result in a win for you, with a score of 8 points (2 for choosing Paper + 6 for winning the round).

So, for example, given the following input:

A Y
B X
C Z
  • In the first round, your opponent will choose Rock, and you will choose Paper, resulting in a win and 8 points (2 for Paper + 6 for the win).

  • In the second round, your opponent will choose Paper, and you will choose Rock, resulting in one point (1 for Rock), and a loss.

  • In the third round, your opponent will choose Scissors, and you will choose Scissors, resulting in a draw and 6 points (3 for Scissors + 3 for the win).

  • The total points is 8 + 1 + 6 == 15.

The Approach

  1. Read the input into memory, splitting each line into an array with two elements, one for each elf's choice.

  2. Decode the ABCXYZ into ROCK, PAPER, or SCISSORS, to have a single representation of each choice, mostly because it will make the rest of the code easier to read and to reason about.

  3. Play each hand, calculating a score for each hand, and saving that score.

  4. Find the sum of all of the scores.

Ruby Solution

# frozen_string_literal: true

class CheatAtRockPaperScissors
  def initialize(filename)
    plays = File.read(filename).split("\n").map { |line| line.split(' ') }

    score, wins, losses, draws = simulate_games(decode plays)

    puts "Wins: #{wins}"
    puts "Losses: #{losses}"
    puts "Draws: #{draws}"
    puts "Total Score: #{score}"
  end

  def decode(plays)
    plays.map do |play|
      case play
      in ['A', 'X']
        %i[ROCK ROCK]
      in ['A', 'Y']
        %i[ROCK PAPER]
      in ['A', 'Z']
        %i[ROCK SCISSORS]
      in ['B', 'X']
        %i[PAPER ROCK]
      in ['B', 'Y']
        %i[PAPER PAPER]
      in ['B', 'Z']
        %i[PAPER SCISSORS]
      in ['C', 'X']
        %i[SCISSORS ROCK]
      in ['C', 'Y']
        %i[SCISSORS PAPER]
      in ['C', 'Z']
        %i[SCISSORS SCISSORS]
      end
    end
  end

  def simulate_games(plays)
    score = 0
    wins = 0
    losses = 0
    draws = 0

    plays.each do |play|
      case play
      in [:ROCK, :ROCK]
        draws += 1
        score += 4
      in [:ROCK, :PAPER]
        wins += 1
        score += 8
      in [:ROCK, :SCISSORS]
        losses += 1
        score += 3
      in [:PAPER, :ROCK]
        losses += 1
        score += 1
      in [:PAPER, :PAPER]
        draws += 1
        score += 5
      in [:PAPER, :SCISSORS]
        wins += 1
        score += 9
      in [:SCISSORS, :ROCK]
        wins += 1
        score += 7
      in [:SCISSORS, :PAPER]
        losses += 1
        score += 2
      in [:SCISSORS, :SCISSORS]
        draws += 1
        score += 6
      end
    end
    [score, wins, losses, draws]
  end
end

CheatAtRockPaperScissors.new(ARGV[0] || 'input.txt')

For Day 2, let's up the ante on our program structure a little bit. Let's start using more structure, with classes and methods for each important piece of behavior.

class CheatAtRockPaperScissors
  def initialize(filename)
    plays = File.read(filename).split("\n").map { |line| line.split(' ') }

    score, wins, losses, draws = simulate_games(decode plays)

    puts "Wins: #{wins}"
    puts "Losses: #{losses}"
    puts "Draws: #{draws}"
    puts "Total Score: #{score}"
  end

All of the logic for the solution is placed into a class. The initialize() method for the class will coordinate finding the solution. In this implementation, it receives a filename for the input file and then uses a familiar File.read(filename).split("\n") sequence to read the whole file into memory, and split it into an array of lines delimited by newlines.

For this solution, each of those lines is then split using the space character as a delimiter. Referring back to the structure of the input data for this challenge, that should result in an array of arrays, where each of the inner arrays contains two elements representing the two Rock Paper Scissors moves.

The next step is to decode all of the ABCXYZ into ROCK, PAPER, and SCISSORS.

  def decode(plays)
    plays.map do |play|
      case play
      in ['A', 'X']
        %i[ROCK ROCK]
      in ['A', 'Y']
        %i[ROCK PAPER]
      in ['A', 'Z']
        %i[ROCK SCISSORS]
      in ['B', 'X']
        %i[PAPER ROCK]
      in ['B', 'Y']
        %i[PAPER PAPER]
      in ['B', 'Z']
        %i[PAPER SCISSORS]
      in ['C', 'X']
        %i[SCISSORS ROCK]
      in ['C', 'Y']
        %i[SCISSORS PAPER]
      in ['C', 'Z']
        %i[SCISSORS SCISSORS]
      end
    end
  end

There are a few ways that this could have been done, but in the end, given the limited number of possible combinations, I chose to do a direct conversion. Ruby's case statement with pattern-matching syntax works well to match the raw combinations and return decoded versions.

The final part of the task is to simulate all of the games and tally the results.

def simulate_games(plays)
    score = 0
    wins = 0
    losses = 0
    draws = 0

    plays.each do |play|
      case play
      in [:ROCK, :ROCK]
        draws += 1
        score += 4
      in [:ROCK, :PAPER]
        wins += 1
        score += 8
      in [:ROCK, :SCISSORS]
        losses += 1
        score += 3
      in [:PAPER, :ROCK]
        losses += 1
        score += 1
      in [:PAPER, :PAPER]
        draws += 1
        score += 5
      in [:PAPER, :SCISSORS]
        wins += 1
        score += 9
      in [:SCISSORS, :ROCK]
        wins += 1
        score += 7
      in [:SCISSORS, :PAPER]
        losses += 1
        score += 2
      in [:SCISSORS, :SCISSORS]
        draws += 1
        score += 6
      end
    end
    [score, wins, losses, draws]
  end

Though the task doesn't call for it, I thought that it would be interesting to record the number of wins, losses, and draws along with the score total. This method works identically to the previous method that was used to decode the hands, with a case statement and the pattern-matching syntax.

If the goal was to make the code as terse as possible, I could have skipped the normalization step and could have just gone directly from a pattern like ['A', 'Y'] to wins += 1 and score += 8. However, I felt like that combination would make it more difficult for someone to look at the code and to reason about it, so I separated the two steps.

The final part of the code just instantiates an instance of the class that was just defined, causing the whole sequence of steps to be run.

CheatAtRockPaperScissors.new(ARGV[0] || 'input.txt')

The only piece that is of note here is that I chose to do basic command line argument processing for the name of the input file. If the program is run with an argument, the argument is assumed to be the name of the input file. Otherwise, it is assumed to be input.txt.

Crystal Solution

It will come as no surprise if you know Crystal, but just like the Day 1 solutions, the Crystal implementation hews very closely to the Ruby implementation.

 class CheatAtRockPaperScissors
  def initialize(filename)
    plays = File.read(filename).split("\n").map { |line| line.split(" ") }

    score, wins, losses, draws = simulate_games(decode plays)

    puts "Wins: #{wins}"
    puts "Losses: #{losses}"
    puts "Draws: #{draws}"
    puts "Total Score: #{score}"
  end

  def decode(plays)
    plays.map do |play|
      case play
      when ["A", "X"]
        %i[ROCK ROCK]
      when ["A", "Y"]
        %i[ROCK PAPER]
      when ["A", "Z"]
        %i[ROCK SCISSORS]
      when ["B", "X"]
        %i[PAPER ROCK]
      when ["B", "Y"]
        %i[PAPER PAPER]
      when ["B", "Z"]
        %i[PAPER SCISSORS]
      when ["C", "X"]
        %i[SCISSORS ROCK]
      when ["C", "Y"]
        %i[SCISSORS PAPER]
      when ["C", "Z"]
        %i[SCISSORS SCISSORS]
      end
    end
  end

  def simulate_games(plays)
    score = 0
    wins = 0
    losses = 0
    draws = 0

    plays.each do |play|
      case play
      when [:ROCK, :ROCK]
        draws += 1
        score += 4
      when [:ROCK, :PAPER]
        wins += 1
        score += 8
      when [:ROCK, :SCISSORS]
        losses += 1
        score += 3
      when [:PAPER, :ROCK]
        losses += 1
        score += 1
      when [:PAPER, :PAPER]
        draws += 1
        score += 5
      when [:PAPER, :SCISSORS]
        wins += 1
        score += 9
      when [:SCISSORS, :ROCK]
        wins += 1
        score += 7
      when [:SCISSORS, :PAPER]
        losses += 1
        score += 2
      when [:SCISSORS, :SCISSORS]
        draws += 1
        score += 6
      end
    end
    [score, wins, losses, draws]
  end
end

CheatAtRockPaperScissors.new(ARGV[0]? || "input.txt")

Crystal's type inference engine is capable of figuring out the types of all everything in the program without any explicit type annotation, so the only changes necessary for Crystal are to ensure that all strings are quoted with double quotes ("") instead of single quotes, the case syntax, and the syntax to access the command line argument.

  def decode(plays)
    plays.map do |play|
      case play
      when ["A", "X"]
        %i[ROCK ROCK]
      when ["A", "Y"]
        %i[ROCK PAPER]
      when ["A", "Z"]
        %i[ROCK SCISSORS]
      when ["B", "X"]
        %i[PAPER ROCK]
      when ["B", "Y"]
        %i[PAPER PAPER]
      when ["B", "Z"]
        %i[PAPER SCISSORS]
      when ["C", "X"]
        %i[SCISSORS ROCK]
      when ["C", "Y"]
        %i[SCISSORS PAPER]
      when ["C", "Z"]
        %i[SCISSORS SCISSORS]
      end
    end

Instead of using in ['A', 'B'] as with the Ruby example, which leveraged Ruby's pattern-matching engine, the Crystal version relies on the syntax that it shares with Ruby for a standard case comparison, using when. In truth, the Ruby version could have been written like this, too, and for this particular use case, would have worked identically. So, the method, written as above, is also valid Ruby code, and will, in either language, match the raw encoded arrays and return decoded arrays.

CheatAtRockPaperScissors.new(ARGV[0]? || "input.txt")

The ARGV[0]? in that line is the other significant difference. In Crystal, attempting to access an index in an array that is beyond the bounds of the array will result in an error. So, in the case of an ARGV array that contains no command line arguments, attempting to access one results in an error similar to the following:

Unhandled exception: Index out of bounds (IndexError)

Just like in Ruby, when accessing an array as in ARGV[0], the code is calling a method, [], on the object in ARGV. Ruby will return nil if an attempt is made to access an array index that is out of bounds, but Crystal defaults to an error. However, there are occasions where explicit error handling is overkill for the needs of the code, and all that a person wants is Ruby-like behavior. For that case, the Crystal convention is to offer another method with a trailing ? character, []?. The convention is that instead of throwing an error, the methods with the trailing ? will instead return nil. This makes that line functionally equivalent to the line without the question mark in the Ruby version of the code.

Rust Solution

The Rust solution for this part of the day's challenge retains most of the same structure and implementation pattern of the Ruby and Crystal versions, but the deviation in overall syntax is larger than what was seen in the Day 1 Rust code.

use std::fs;
use std::env;

enum Play {
    Rock,
    Paper,
    Scissors,
    None,
}

fn main() {
    let mut filename = "input.txt".to_string();
    if let Some(arg) = env::args().nth(1) {
        filename = arg;
    }
    let text = fs::read_to_string(filename).unwrap();
    let plays = text
        .split("\n")
        .map(|line| line.trim().split_whitespace().collect::<Vec<_>>())
        .collect::<Vec<_>>();

    let (score, wins, losses, draws) = simulate_games(decode(plays));

    println!(
        "Wins: {}\nLosses: {}\nDraws: {}\nTotal Score: {}\n",
        wins, losses, draws, score
    );
}

fn decode(plays: Vec<Vec<&str>>) -> Vec<Vec<Play>> {
    plays
        .into_iter()
        .map(|play|
        // Match on the slice of the given play
        match play.as_slice() {
            ["A", "X"] => vec![Play::Rock, Play::Rock],
            ["A", "Y"] => vec![Play::Rock, Play::Paper],
            ["A", "Z"] => vec![Play::Rock, Play::Scissors],
            ["B", "X"] => vec![Play::Paper, Play::Rock],
            ["B", "Y"] => vec![Play::Paper, Play::Paper],
            ["B", "Z"] => vec![Play::Paper, Play::Scissors],
            ["C", "X"] => vec![Play::Scissors, Play::Rock],
            ["C", "Y"] => vec![Play::Scissors, Play::Paper],
            ["C", "Z"] => vec![Play::Scissors, Play::Scissors],
            _ => vec![Play::None, Play::None]
        })
        .collect()
}

fn simulate_games(plays: Vec<Vec<Play>>) -> Vec<i32> {
    let mut score = 0;
    let mut wins = 0;
    let mut losses = 0;
    let mut draws = 0;

    for play in plays {
        match play.as_slice() {
            [Play::Rock, Play::Rock] => {
                draws += 1;
                score += 4;
            }
            [Play::Rock, Play::Paper] => {
                wins += 1;
                score += 8;
            }
            [Play::Rock, Play::Scissors] => {
                losses += 1;
                score += 3;
            }
            [Play::Paper, Play::Rock] => {
                losses += 1;
                score += 1;
            }
            [Play::Paper, Play::Paper] => {
                draws += 1;
                score += 5;
            }
            [Play::Paper, Play::Scissors] => {
                wins += 1;
                score += 9;
            }
            [Play::Scissors, Play::Rock] => {
                wins += 1;
                score += 7;
            }
            [Play::Scissors, Play::Paper] => {
                losses += 1;
                score += 2;
            }
            [Play::Scissors, Play::Scissors] => {
                draws += 1;
                score += 6;
            }
            _ => {}
        }
    }

    vec![score, wins, losses, draws]
}

Starting right from the top:

use std::fs;
use std::env;

Rust doesn't provide facilities to read a file into memory or to access command line arguments in its core. One must use a couple of crates to get access to these capabilities.

std::fs provides access to a variety of filesystem-related structs and functions, and std::env provides structs, functions, and enums related to manipulating the environment of a process, which includes accessing its command line arguments.

Both Ruby and Crystal provide these capabilities as part of their core libraries.

Next follows another piece of code that is distinct to the Rust-based solution.

enum Play {
    Rock,
    Paper,
    Scissors,
    None,
}

Ruby and Crystal both allow for something called a symbol. These can be thought of as immutable labels written with a convenient syntax: :LABEL. While the actual use cases for symbols are somewhat different between Ruby and Crystal (talking about this could be an article in itself), they can be used very similarly between the two languages, and for this task, they are convenient to provide human-readable decoding of the Rock Paper Scissors moves. Rust, however, does not have any direct analog to Ruby/Crystal symbols. Rust, however, does have enums. For the purposes of this program, they serve the same purpose as the symbols in the other two languages and are being used as immutable human-readable labels.

Another option would have been to just use Strings, as they can also be immutable labels. However, using the enums does have one advantage. During the typing of the program, if I were to mistype one of the strings, the compiler wouldn't know to see that as an error. However, if I am using enums, and I mistype one of them, the compiler will let me know about the error.

Also, it is worth noting that enums are a feature that are available in Crystal, too. The syntax wouldn't even have been wildly different:

enum Play
  Rock
  Paper
  Scissors
end

Moving on to the main() function, the first thing that it needs to do is to parse the command line.

fn main() {
    let mut filename = "input.txt".to_string();
    if let Some(arg) = env::args().nth(1) {
        filename = arg;
    }

In this case, the main() function is setting up a variable called filename that holds the default value of "input.txt". It then checks to see if there are any command line arguments (which are passed in when the program is run from the command line). If there is a command line argument at position 1 (the second argument, since arrays are 0-indexed in Rust), the filename variable is set to that value.

To get the command line arguments, the code uses the env::args() function, which returns an Args iterator over the arguments. The nth() method is then used to get the argument at position 1. If there is no argument at that position, nth() returns None. If there is an argument, it returns a Some with the argument.

The next part is straightforward and is similar to the parsing in Day 1 to transform the lines of the input file.

    let text = fs::read_to_string(filename).unwrap();
    let plays = text
        .split("\n")
        .map(|line| line.trim().split_whitespace().collect::<Vec<_>>())
        .collect::<Vec<_>>();

The file contents are read into memory, then split into an Vec of lines delimited by a newline, which is then mapped into a Vec<Vec<String>>, where each of the Vec<String> contains the contents of each line (such as A Y or C X), after being split by the whitespace in the line. For the above example, this would result in a Vec that looks like this:

[ ["A", "Y"],
  ["C", "X"] ]

This is an array containing all of the Rock Paper Scissors hands which need to be scored.

The next step, just like with the Ruby and Crystal examples, is to decode this array into the enum values that have clearer meanings.

fn decode(plays: Vec<Vec<&str>>) -> Vec<Vec<Play>> {
    plays
        .into_iter()
        .map(|play|
        // Match on the slice of the given play
        match play.as_slice() {
            ["A", "X"] => vec![Play::Rock, Play::Scissors],
            ["A", "Y"] => vec![Play::Rock, Play::Rock],
            ["A", "Z"] => vec![Play::Rock, Play::Paper],
            ["B", "X"] => vec![Play::Paper, Play::Rock],
            ["B", "Y"] => vec![Play::Paper, Play::Paper],
            ["B", "Z"] => vec![Play::Paper, Play::Scissors],
            ["C", "X"] => vec![Play::Scissors, Play::Paper],
            ["C", "Y"] => vec![Play::Scissors, Play::Scissors],
            ["C", "Z"] => vec![Play::Scissors, Play::Rock],
            _ => vec![Play::None, Play::None]
        })
        .collect()
}

Rust has powerful pattern-matching capabilities, via the match keyword, which is ideal for this sort of task. A line like ["A", "X"] => vec![Play::Rock, Play::Scissors], will check the argument, which in this case is the Rock Paper Scissors hand to be played (as a slice) against the argument on the left, the pattern ["A", "X"]. If it matches, then it will return the argument on the right, vec![Play::Rock, Play::Scissors]. This is conceptually similar to what was done with case statements in the previous solutions, but it is a nicely terse syntax.

Following this, the only major task remaining is to score the hands.

fn simulate_games(plays: Vec<Vec<Play>>) -> Vec<i32> {
    let mut score = 0;
    let mut wins = 0;
    let mut losses = 0;
    let mut draws = 0;

    for play in plays {
        match play.as_slice() {
            [Play::Rock, Play::Rock] => {
                draws += 1;
                score += 4;
            }
            [Play::Rock, Play::Paper] => {
                wins += 1;
                score += 8;
            }
            [Play::Rock, Play::Scissors] => {
                losses += 1;
                score += 3;
            }
            [Play::Paper, Play::Rock] => {
                losses += 1;
                score += 1;
            }
            [Play::Paper, Play::Paper] => {
                draws += 1;
                score += 5;
            }
            [Play::Paper, Play::Scissors] => {
                wins += 1;
                score += 9;
            }
            [Play::Scissors, Play::Rock] => {
                wins += 1;
                score += 7;
            }
            [Play::Scissors, Play::Paper] => {
                losses += 1;
                score += 2;
            }
            [Play::Scissors, Play::Scissors] => {
                draws += 1;
                score += 6;
            }
            _ => {}
        }
    }

    vec![score, wins, losses, draws]

Just like the Ruby and Crystal versions used a case statement for this part, the Rust solution just reuses match for this part, and just like those previous solutions, it would have been more terse to combine this scoring operation with the previous normalization operation, but that would have made the code a lot more difficult to reason about.

Part 2

The Elf finishes helping with the tent and sneaks back over to you to explain the strategy guide for the Rock, Paper, Scissors game. The guide consists of two columns, one indicating what shape your opponent will choose and the other indicating how the round should end. The letters X, Y, and Z represent the need to lose, draw, and win, respectively. The Elf explains that you must follow the guide to achieve a certain outcome for each round and that the total score is calculated by adding the corresponding points for each round based on the shape chosen and the desired outcome.

The Approach

The only difference between what was done in Part 1, and what needs to be done to complete Part 2, is that the decode() function should turn XYZ into the proper shape to get the prescribed outcome, a win, a draw, or a loss.

Ruby Solution

The only change is to the decode() method:

  def decode(plays)
    plays.collect do |play|
      case play
      in ['A', 'X']
        %i[ROCK SCISSORS]
      in ['A', 'Y']
        %i[ROCK ROCK]
      in ['A', 'Z']
        %i[ROCK PAPER]
      in ['B', 'X']
        %i[PAPER ROCK]
      in ['B', 'Y']
        %i[PAPER PAPER]
      in ['B', 'Z']
        %i[PAPER SCISSORS]
      in ['C', 'X']
        %i[SCISSORS PAPER]
      in ['C', 'Y']
        %i[SCISSORS SCISSORS]
      in ['C', 'Z']
        %i[SCISSORS ROCK]
      end
    end
  end

With no other changes, the program will now simulate making the moves to get the prescribed outcome and will score the game according to that set of moves. For example, if the play instructions for the next move were `['C', 'Y'], then the instruction would be to find the move that must be made to create a draw game against predicted Scissors.

Crystal Solution

The Crystal solution is predictably just a slight variation on the Ruby solution.

  def decode(plays)
    plays.map do |play|
      case play
      when ["A", "X"]
        %i[ROCK SCISSORS]
      when ["A", "Y"]
        %i[ROCK ROCK]
      when ["A", "Z"]
        %i[ROCK PAPER]
      when ["B", "X"]
        %i[PAPER ROCK]
      when ["B", "Y"]
        %i[PAPER PAPER]
      when ["B", "Z"]
        %i[PAPER SCISSORS]
      when ["C", "X"]
        %i[SCISSORS PAPER]
      when ["C", "Y"]
        %i[SCISSORS SCISSORS]
      when ["C", "Z"]
        %i[SCISSORS ROCK]
      end
    end
  end

Rust Solution

Again, all that is necessary to modify the Rust solution for the new set of rules is to change the decoding function's pattern-matching rules so that they return the correct hand to achieve the desired outcome:

fn decode(plays: Vec<Vec<&str>>) -> Vec<Vec<Play>> {
    plays
        .into_iter()
        .map(|play|
        // Match on the slice of the given play
        match play.as_slice() {
            ["A", "X"] => vec![Play::Rock, Play::Scissors],
            ["A", "Y"] => vec![Play::Rock, Play::Rock],
            ["A", "Z"] => vec![Play::Rock, Play::Paper],
            ["B", "X"] => vec![Play::Paper, Play::Rock],
            ["B", "Y"] => vec![Play::Paper, Play::Paper],
            ["B", "Z"] => vec![Play::Paper, Play::Scissors],
            ["C", "X"] => vec![Play::Scissors, Play::Paper],
            ["C", "Y"] => vec![Play::Scissors, Play::Scissors],
            ["C", "Z"] => vec![Play::Scissors, Play::Rock],
            _ => vec![Play::None, Play::None]
        })
        .collect()
}

No other changes are required.

Conclusion

This challenge was fun mostly because it let me solve a problem using Rust's pattern-matching capabilities. A modest number of explicit type annotations were needed, which was pleasant. For this code, Rust's type inference engine was largely able to figure out the types of the variables. This problem was also interesting because it showcased some slightly bigger syntax differences between Crystal and Ruby, even if the overall structure of the solution was much the same.