diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs
new file mode 100644
index 0000000..07d7ee7
--- /dev/null
+++ b/.git-blame-ignore-revs
@@ -0,0 +1,20 @@
+# ref: Reorganize Day 1 files
+4618c80de3fc4cbe60dc339de058ffdbea397946
+
+# ref: Reorganize Day 2 files
+5ab3e6e4510d9cd5e37786746da46e1fe7f8e440
+
+# ref: Reorganize Day 3 files
+66690ddb52c70f887f90d32830ff04faa6283589
+
+# ref: Reorganize Day 4 files
+c313bcc707b50839d2e61a36ad94284d48f743d5
+
+# ref: Reorganize Day 5 files
+04dd034d9efc23d3cdc27c91c08af418fe635c53
+
+# ref: Reorganize Day 6 files
+6ea2bb3121a127830eb5f5b393765a61a3f07278
+
+# doc: Update README.md files
+3d0d817c3198d81c2c9330adb67cd49edcfbc446
diff --git a/README.md b/README.md
index 8ea4488..a735881 100644
--- a/README.md
+++ b/README.md
@@ -10,24 +10,58 @@ The project is compose of two main parts:
- A library, located in the [`lib/`](lib/) directory, containing the code for the solutions.
- A set of tests, located in the [`test/`](test/) directory, containing the tests for the solutions.
-Tests are written using the [:octocat: minitest](https://github.com/minitest/minitest) framework.
+Tests are written using the [minitest](https://github.com/minitest/minitest) framework.
To run the tests, simply run the following command:
```bash
bundle install
-bundle exec rake test
+SKIP_SLOW_TESTS=1 bundle exec rake test
```
## Puzzles
-### 2023
+
-| Day | Solution | Rank |
-|-------------------------------------------:|:--------------------------------------------------------:|:-----:|
-| [1️⃣](https://adventofcode.com/2023/day/1) | [`lib/puzzles/2023/day01.rb`](lib/puzzles/2023/day01.rb) | ⭐🌟✴️ |
-| [2️⃣](https://adventofcode.com/2023/day/2) | [`lib/puzzles/2023/day02.rb`](lib/puzzles/2023/day02.rb) | ⭐🌟✴️ |
-| [3️⃣](https://adventofcode.com/2023/day/3) | [`lib/puzzles/2023/day03.rb`](lib/puzzles/2023/day03.rb) | ⭐🌟✴️ |
-| [4️⃣](https://adventofcode.com/2023/day/4) | [`lib/puzzles/2023/day04.rb`](lib/puzzles/2023/day04.rb) | ⭐🌟✴️ |
-| [5️⃣](https://adventofcode.com/2023/day/5) | [`lib/puzzles/2023/day05.rb`](lib/puzzles/2023/day05.rb) | ⭐🌟✴️ |
-| [6️⃣](https://adventofcode.com/2023/day/6) | [`lib/puzzles/2023/day06.rb`](lib/puzzles/2023/day06.rb) | ⭐🌟✴️ |
+
+ Advent of Code - 2023
+
+
diff --git a/lib/puzzles/2023/day01/README.md b/lib/puzzles/2023/day01/README.md
new file mode 100644
index 0000000..35e1723
--- /dev/null
+++ b/lib/puzzles/2023/day01/README.md
@@ -0,0 +1,52 @@
+# [Day 1: Trebuchet?!](https://adventofcode.com/2023/day/1)
+
+## Part One
+
+Something is wrong with global snow production, and you've been selected to take a look. The Elves have even given you a map; on it, they've used stars to mark the top fifty locations that are likely to be having problems.
+
+You've been doing this long enough to know that to restore snow operations, you need to check all **fifty stars** by December 25th.
+
+Collect stars by solving puzzles. Two puzzles will be made available on each day in the Advent calendar; the second puzzle is unlocked when you complete the first. Each puzzle grants **one star**. Good luck!
+
+You try to ask why they can't just use a [weather machine](https://adventofcode.com/2015/day/1) ("not powerful enough") and where they're even sending you ("the sky") and why your map looks mostly blank ("you sure ask a lot of questions") and hang on did you just say the sky ("of course, where do you think snow comes from") when you realize that the Elves are already loading you into a [trebuchet](https://en.wikipedia.org/wiki/Trebuchet) ("please hold still, we need to strap you in").
+
+As they're making the final adjustments, they discover that their calibration document (your puzzle input) has been **amended** by a very young Elf who was apparently just excited to show off her art skills. Consequently, the Elves are having trouble reading the values on the document.
+
+The newly-improved calibration document consists of lines of text; each line originally contained a specific **calibration value** that the Elves now need to recover. On each line, the calibration value can be found by combining the **first digit** and the **last digit** (in that order) to form a single **two-digit number**.
+
+For example:
+
+```
+1abc2
+pqr3stu8vwx
+a1b2c3d4e5f
+treb7uchet
+```
+
+In this example, the calibration values of these four lines are `12`, `38`, `15`, and `77`. Adding these together produces `142`.
+
+Consider your entire calibration document. **What is the sum of all of the calibration values?**
+
+Your puzzle answer was `54450`.
+
+## Part Two
+
+Your calculation isn't quite right. It looks like some of the digits are actually **spelled out with letters**: `one`, `two`, `three`, `four`, `five`, `six`, `seven`, `eight`, and `nine` **also** count as valid "digits".
+
+Equipped with this new information, you now need to find the real first and last digit on each line. For example:
+
+```
+two1nine
+eightwothree
+abcone2threexyz
+xtwone3four
+4nineeightseven2
+zoneight234
+7pqrstsixteen
+```
+
+In this example, the calibration values are `29`, `83`, `13`, `24`, `42`, `14`, and `76`. Adding these together produces `281`.
+
+**What is the sum of all of the calibration values?**
+
+Your puzzle answer was `54265`.
diff --git a/lib/puzzles/2023/day01/day01.rb b/lib/puzzles/2023/day01/day01.rb
new file mode 100644
index 0000000..5255b89
--- /dev/null
+++ b/lib/puzzles/2023/day01/day01.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+require_relative "part1"
+require_relative "part2"
+
+module AdventOfCode
+ module Puzzles2023
+ ##
+ # {include:file:lib/puzzles/2023/day01/README.md}
+ module Day01
+ end
+ end
+end
diff --git a/test/puzzles/2023/day01/input.txt b/lib/puzzles/2023/day01/input.txt
similarity index 100%
rename from test/puzzles/2023/day01/input.txt
rename to lib/puzzles/2023/day01/input.txt
diff --git a/lib/puzzles/2023/day01/part1.rb b/lib/puzzles/2023/day01/part1.rb
new file mode 100644
index 0000000..5188053
--- /dev/null
+++ b/lib/puzzles/2023/day01/part1.rb
@@ -0,0 +1,73 @@
+# frozen_string_literal: true
+
+module AdventOfCode
+ module Puzzles2023
+ module Day01
+ ##
+ # Class for solving Day 1 - Part 1 puzzle
+ class Part1
+ ##
+ # @param file [String] file with puzzle input
+ def initialize(file: nil)
+ file ||= "#{File.dirname(__FILE__)}/input.txt"
+ @file_contents = File.readlines(file, chomp: true)
+ end
+
+ ##
+ # Compute the answer for the puzzle.
+ # The answer is the sum of all calibration values.
+ #
+ # @return [Integer] answer for the puzzle
+ def answer
+ calibration_values.sum
+ end
+
+ ##
+ # Compute calibration values based on file contents.
+ #
+ # @return [Array] calibration values
+ def calibration_values
+ @calibration_values ||= file_contents.map { |line| select_number(line) }
+ end
+
+ protected
+
+ ##
+ # @return [Array] file contents
+ attr_reader :file_contents
+
+ ##
+ # Select first and last number from line.
+ #
+ # @param line [String] line to select numbers from
+ #
+ # @return [Integer] first and last number concatenated
+ def select_number(line)
+ first_number = find_first_number(line)
+ last_number = find_last_number(line)
+ "#{first_number}#{last_number}".to_i
+ end
+
+ ##
+ # Find and return the first number in a line.
+ #
+ # @param line [String] line to search
+ #
+ # @return [Integer, nil] first number found
+ def find_first_number(line)
+ line.match(%r{^\D*(\d).*})[1]&.to_i
+ end
+
+ ##
+ # Find and return the last number in a line.
+ #
+ # @param line [String] line to search
+ #
+ # @return [Integer, nil] last number found
+ def find_last_number(line)
+ line.match(%r{.*?(\d)\D*$})[1]&.to_i
+ end
+ end
+ end
+ end
+end
diff --git a/lib/puzzles/2023/day01.rb b/lib/puzzles/2023/day01/part2.rb
similarity index 53%
rename from lib/puzzles/2023/day01.rb
rename to lib/puzzles/2023/day01/part2.rb
index 1e96bb1..8ca6631 100644
--- a/lib/puzzles/2023/day01.rb
+++ b/lib/puzzles/2023/day01/part2.rb
@@ -1,79 +1,18 @@
# frozen_string_literal: true
+require_relative "part1"
+
module AdventOfCode
module Puzzles2023
- ##
- # Advent of Code 2023 - Day 1
- # https://adventofcode.com/2023/day/1
module Day01
##
- # Class for solving Day 01 - Part 1 puzzle
- class Part1
- attr_reader :file_contents
-
- ##
- # @param file [String] file with puzzle input
- def initialize(file:)
- @file_contents = File.readlines(file, chomp: true)
- end
-
- ##
- # Compute the answer for the puzzle.
- # The answer is the sum of all calibration values.
- #
- # @return [Integer] answer for the puzzle
- def answer
- calibration_values.sum
- end
-
- ##
- # Compute calibration values based on file contents.
- #
- # @return [Array] calibration values
- def calibration_values
- @calibration_values ||= file_contents.map { |line| select_number(line) }
- end
-
- protected
-
- ##
- # Select first and last number from line.
- #
- # @param line [String] line to select numbers from
- #
- # @return [Integer] first and last number concatenated
- def select_number(line)
- first_number = find_first_number(line)
- last_number = find_last_number(line)
- "#{first_number}#{last_number}".to_i
- end
-
- ##
- # Find and return the first number in a line.
- #
- # @param line [String] line to search
- #
- # @return [Integer, nil] first number found
- def find_first_number(line)
- line.match(%r{^\D*(\d).*})[1]&.to_i
- end
-
- ##
- # Find and return the last number in a line.
- #
- # @param line [String] line to search
- #
- # @return [Integer, nil] last number found
- def find_last_number(line)
- line.match(%r{.*?(\d)\D*$})[1]&.to_i
- end
- end
-
- ##
- # Class for solving Day 01 - Part 2 puzzle
+ # Class for solving Day 1 - Part 2 puzzle
class Part2 < Part1
protected
+ ##
+ # Helper constant for translating word numbers to digits.
+ # @return [Hash] word numbers to digits
TRANSLATIONS = %i[one two three four five six seven eight nine].zip(1..9).to_h.freeze
##
diff --git a/lib/puzzles/2023/day02.rb b/lib/puzzles/2023/day02.rb
deleted file mode 100644
index 390126f..0000000
--- a/lib/puzzles/2023/day02.rb
+++ /dev/null
@@ -1,194 +0,0 @@
-# frozen_string_literal: true
-
-module AdventOfCode
- module Puzzles2023
- ##
- # Advent of Code 2023 - Day 2
- # https://adventofcode.com/2023/day/2
- module Day02
- ##
- # Set class to store the number of blue, green, and red cubes.
- class Set
- attr_reader :blue, :green, :red
-
- ##
- # @param blue [Integer] number of blue cubes
- # @param green [Integer] number of green cubes
- # @param red [Integer] number of red cubes
- def initialize(blue: 0, green: 0, red: 0)
- @blue = blue
- @green = green
- @red = red
- end
-
- ##
- # Compute the power of the set by multiplying the number of
- # blue, green, and red cubes.
- #
- # @return [Integer] power of the set
- def power
- return @power if @power
-
- @power = (blue || 1) * (green || 1) * (red || 1)
- end
- end
-
- ##
- # Game class to store the game id and its sets.
- class Game
- attr_reader :id, :sets
-
- ##
- # @param id [Integer] game id
- # @param sets [Array] sets of cubes
- def initialize(id:, sets:)
- @id = id
- @sets = sets
- end
-
- ##
- # Check if the game is valid given the number of blue, green,
- # and red cubes.
- #
- # @param blue [Integer] number of blue cubes
- # @param green [Integer] number of green cubes
- # @param red [Integer] number of red cubes
- #
- # @return [Boolean] true if the game is valid, false otherwise
- def valid?(blue:, green:, red:)
- # A game is valid only if every set has the same or less
- # number of blue, green, and red cubes as the set it is
- sets.each do |set|
- return false if set.blue > blue
- return false if set.green > green
- return false if set.red > red
- end
- true
- end
- end
-
- ##
- # Class for solving Day 02 - Part 1 puzzle
- class Part1
- attr_reader :games
-
- ##
- # @param file [String] path to the input file
- def initialize(file:)
- file_contents = File.readlines(file, chomp: true)
- load_games(file_contents)
- end
-
- ##
- # Find games that are valid given the number of blue, green,
- # and red cubes.
- #
- # @param blue [Integer] number of blue cubes
- # @param green [Integer] number of green cubes
- # @param red [Integer] number of red cubes
- #
- # @return [Array] valid games
- def valid_games(blue:, green:, red:)
- games.select { |game| game.valid?(blue:, green:, red:) }
- end
-
- ##
- # Find the sum of the game ids that are valid given the number
- # of blue, green, and red cubes.
- #
- # @param blue [Integer] number of blue cubes
- # @param green [Integer] number of green cubes
- # @param red [Integer] number of red cubes
- #
- # @return [Integer] sum of the game ids
- def answer(blue:, green:, red:)
- valid_games(blue:, green:, red:).sum(&:id)
- end
-
- protected
-
- ##
- # Load games from the file contents.
- #
- # @param file_contents [Array] file contents
- #
- # @return [Array] games
- def load_games(file_contents)
- @games = file_contents.map do |line|
- id, sets = line.split(": ")
- sets = sets.split(%r{;\s+}).map { |set| parse_set(set) }
- game_id = parse_game_id(id)
- Game.new(id: game_id, sets:)
- end
- end
-
- ##
- # Parse the game id from a string.
- # The string is in the format "Game X".
- #
- # @param game [String] game id
- #
- # @return [Integer] game id
- def parse_game_id(game)
- game.gsub("Game ", "").to_i
- end
-
- ##
- # Parse a set of cubes from a string.
- # The string is in the format "X blue, Y green, Z red".
- #
- # @param set [String] set of cubes
- #
- # @return [Set] set of cubes
- def parse_set(set)
- colors = set.split(%r{,\s+}).map(&:split)
- colors = colors.to_h { |number, color| [color.downcase.to_sym, number.to_i] }
- Set.new(**colors)
- end
- end
-
- ##
- # Class for solving Day 02 - Part 2 puzzle
- class Part2 < Part1
- ##
- # Find the sum of the powers for the minimum valid sets.
- #
- # @return [Integer] sum of the powers
- def answer
- minimum_valid_sets.values.sum(&:power)
- end
-
- ##
- # Find the minimum valid set for each game.
- #
- # @return [Hash] minimum valid sets
- def minimum_valid_sets
- @minimum_valid_sets ||= games.to_h { |game| [game, minimum_valid_set_for(game:)] }
- end
-
- protected
-
- COLORS = %i[blue green red].freeze
-
- ##
- # Find the minimum valid set for a game.
- # The minimum valid set is the set with the maximum number of
- # blue, green, and red cubes for all sets.
- #
- # @param game [Game] game to find the minimum valid set for
- #
- # @return [Set] minimum valid set
- def minimum_valid_set_for(game:)
- # Find the maximum number of blue, green, and red cubes in each set
- minimum_set = COLORS.to_h { |color| [color, 0] }
- game.sets.each do |set|
- COLORS.each do |color|
- minimum_set[color] = [set.send(color), minimum_set[color]].max
- end
- end
- Set.new(**minimum_set)
- end
- end
- end
- end
-end
diff --git a/lib/puzzles/2023/day02/README.md b/lib/puzzles/2023/day02/README.md
new file mode 100644
index 0000000..58b36a6
--- /dev/null
+++ b/lib/puzzles/2023/day02/README.md
@@ -0,0 +1,61 @@
+# [Day 2: Cube Conundrum](https://adventofcode.com/2023/day/2)
+
+## Part One
+
+You're launched high into the atmosphere! The apex of your trajectory just barely reaches the surface of a large island floating in the sky. You gently land in a fluffy pile of leaves. It's quite cold, but you don't see much snow. An Elf runs over to greet you.
+
+The Elf explains that you've arrived at **Snow Island** and apologizes for the lack of snow. He'll be happy to explain the situation, but it's a bit of a walk, so you have some time. They don't get many visitors up here; would you like to play a game in the meantime?
+
+As you walk, the Elf shows you a small bag and some cubes which are either red, green, or blue. Each time you play this game, he will hide a secret number of cubes of each color in the bag, and your goal is to figure out information about the number of cubes.
+
+To get information, once a bag has been loaded with cubes, the Elf will reach into the bag, grab a handful of random cubes, show them to you, and then put them back in the bag. He'll do this a few times per game.
+
+You play several games and record the information from each game (your puzzle input). Each game is listed with its ID number (like the `11` in `Game 11: ...`) followed by a semicolon-separated list of subsets of cubes that were revealed from the bag (like `3 red, 5 green, 4 blue`).
+
+For example, the record of a few games might look like this:
+
+```
+Game 1: 3 blue, 4 red; 1 red, 2 green, 6 blue; 2 green
+Game 2: 1 blue, 2 green; 3 green, 4 blue, 1 red; 1 green, 1 blue
+Game 3: 8 green, 6 blue, 20 red; 5 blue, 4 red, 13 green; 5 green, 1 red
+Game 4: 1 green, 3 red, 6 blue; 3 green, 6 red; 3 green, 15 blue, 14 red
+Game 5: 6 red, 1 blue, 3 green; 2 blue, 1 red, 2 green
+```
+
+In game 1, three sets of cubes are revealed from the bag (and then put back again). The first set is 3 blue cubes and 4 red cubes; the second set is 1 red cube, 2 green cubes, and 6 blue cubes; the third set is only 2 green cubes.
+
+The Elf would first like to know which games would have been possible if the bag contained **only 12 red cubes, 13 green cubes, and 14 blue cubes**?
+
+In the example above, games 1, 2, and 5 would have been **possible** if the bag had been loaded with that configuration. However, game 3 would have been **impossible** because at one point the Elf showed you 20 red cubes at once; similarly, game 4 would also have been **impossible** because the Elf showed you 15 blue cubes at once. If you add up the IDs of the games that would have been possible, you get **`8`**.
+
+Determine which games would have been possible if the bag had been loaded with only 12 red cubes, 13 green cubes, and 14 blue cubes. **What is the sum of the IDs of those games?**
+
+Your puzzle answer was `2278`.
+
+## Part Two
+
+The Elf says they've stopped producing snow because they aren't getting any **water**! He isn't sure why the water stopped; however, he can show you how to get to the water source to check it out for yourself. It's just up ahead!
+
+As you continue your walk, the Elf poses a second question: in each game you played, what is the **fewest number of cubes of each color** that could have been in the bag to make the game possible?
+
+Again consider the example games from earlier:
+
+```
+Game 1: 3 blue, 4 red; 1 red, 2 green, 6 blue; 2 green
+Game 2: 1 blue, 2 green; 3 green, 4 blue, 1 red; 1 green, 1 blue
+Game 3: 8 green, 6 blue, 20 red; 5 blue, 4 red, 13 green; 5 green, 1 red
+Game 4: 1 green, 3 red, 6 blue; 3 green, 6 red; 3 green, 15 blue, 14 red
+Game 5: 6 red, 1 blue, 3 green; 2 blue, 1 red, 2 green
+```
+
+- In game 1, the game could have been played with as few as 4 red, 2 green, and 6 blue cubes. If any color had even one fewer cube, the game would have been impossible.
+- Game 2 could have been played with a minimum of 1 red, 3 green, and 4 blue cubes.
+- Game 3 must have been played with at least 20 red, 13 green, and 6 blue cubes.
+- Game 4 required at least 14 red, 3 green, and 15 blue cubes.
+- Game 5 needed no fewer than 6 red, 3 green, and 2 blue cubes in the bag.
+
+The **power** of a set of cubes is equal to the numbers of red, green, and blue cubes multiplied together. The power of the minimum set of cubes in game 1 is `48`. In games 2-5 it was `12`, `1560`, `630`, and `36`, respectively. Adding up these five powers produces the sum **`2286`**.
+
+For each game, find the minimum set of cubes that must have been present. **What is the sum of the power of these sets?**
+
+Your puzzle answer was `67953`.
diff --git a/lib/puzzles/2023/day02/day02.rb b/lib/puzzles/2023/day02/day02.rb
new file mode 100644
index 0000000..394b106
--- /dev/null
+++ b/lib/puzzles/2023/day02/day02.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+require_relative "part1"
+require_relative "part2"
+
+module AdventOfCode
+ module Puzzles2023
+ ##
+ # {include:file:lib/puzzles/2023/day02/README.md}
+ module Day02
+ end
+ end
+end
diff --git a/lib/puzzles/2023/day02/game.rb b/lib/puzzles/2023/day02/game.rb
new file mode 100644
index 0000000..9e9da6a
--- /dev/null
+++ b/lib/puzzles/2023/day02/game.rb
@@ -0,0 +1,47 @@
+# frozen_string_literal: true
+
+module AdventOfCode
+ module Puzzles2023
+ module Day02
+ ##
+ # Game class to store the game id and its sets.
+ class Game
+ ##
+ # @return [Integer] game id
+ attr_reader :id
+
+ ##
+ # @return [Array] sets of cubes
+ attr_reader :sets
+
+ ##
+ # @param id [Integer] game id
+ # @param sets [Array] sets of cubes
+ def initialize(id:, sets:)
+ @id = id
+ @sets = sets
+ end
+
+ ##
+ # Check if the game is valid given the number of blue, green,
+ # and red cubes.
+ #
+ # @param blue [Integer] number of blue cubes
+ # @param green [Integer] number of green cubes
+ # @param red [Integer] number of red cubes
+ #
+ # @return [Boolean] true if the game is valid, false otherwise
+ def valid?(blue:, green:, red:)
+ # A game is valid only if every set has the same or less
+ # number of blue, green, and red cubes as the set it is
+ sets.each do |set|
+ return false if set.blue > blue
+ return false if set.green > green
+ return false if set.red > red
+ end
+ true
+ end
+ end
+ end
+ end
+end
diff --git a/test/puzzles/2023/day02/input.txt b/lib/puzzles/2023/day02/input.txt
similarity index 100%
rename from test/puzzles/2023/day02/input.txt
rename to lib/puzzles/2023/day02/input.txt
diff --git a/lib/puzzles/2023/day02/part1.rb b/lib/puzzles/2023/day02/part1.rb
new file mode 100644
index 0000000..53fe36e
--- /dev/null
+++ b/lib/puzzles/2023/day02/part1.rb
@@ -0,0 +1,94 @@
+# frozen_string_literal: true
+
+require_relative "game"
+require_relative "set"
+
+module AdventOfCode
+ module Puzzles2023
+ module Day02
+ ##
+ # Class for solving Day 2 - Part 1 puzzle
+ class Part1
+ ##
+ # Array of games
+ # @return [Array] the games of the puzzle
+ attr_reader :games
+
+ ##
+ # @param file [String] path to the input file
+ def initialize(file: nil)
+ file ||= "#{File.dirname(__FILE__)}/input.txt"
+ file_contents = File.readlines(file, chomp: true)
+ load_games(file_contents)
+ end
+
+ ##
+ # Find games that are valid given the number of blue, green,
+ # and red cubes.
+ #
+ # @param blue [Integer] number of blue cubes
+ # @param green [Integer] number of green cubes
+ # @param red [Integer] number of red cubes
+ #
+ # @return [Array] valid games
+ def valid_games(blue:, green:, red:)
+ games.select { |game| game.valid?(blue:, green:, red:) }
+ end
+
+ ##
+ # Find the sum of the game ids that are valid given the number
+ # of blue, green, and red cubes.
+ #
+ # @param blue [Integer] number of blue cubes
+ # @param green [Integer] number of green cubes
+ # @param red [Integer] number of red cubes
+ #
+ # @return [Integer] sum of the game ids
+ def answer(blue:, green:, red:)
+ valid_games(blue:, green:, red:).sum(&:id)
+ end
+
+ protected
+
+ ##
+ # Load games from the file contents.
+ #
+ # @param file_contents [Array] file contents
+ #
+ # @return [Array] games
+ def load_games(file_contents)
+ @games = file_contents.map do |line|
+ id, sets = line.split(": ")
+ sets = sets.split(%r{;\s+}).map { |set| parse_set(set) }
+ game_id = parse_game_id(id)
+ Game.new(id: game_id, sets:)
+ end
+ end
+
+ ##
+ # Parse the game id from a string.
+ # The string is in the format "Game X".
+ #
+ # @param game [String] game id
+ #
+ # @return [Integer] game id
+ def parse_game_id(game)
+ game.gsub("Game ", "").to_i
+ end
+
+ ##
+ # Parse a set of cubes from a string.
+ # The string is in the format "X blue, Y green, Z red".
+ #
+ # @param set [String] set of cubes
+ #
+ # @return [Set] set of cubes
+ def parse_set(set)
+ colors = set.split(%r{,\s+}).map(&:split)
+ colors = colors.to_h { |number, color| [color.downcase.to_sym, number.to_i] }
+ Set.new(**colors)
+ end
+ end
+ end
+ end
+end
diff --git a/lib/puzzles/2023/day02/part2.rb b/lib/puzzles/2023/day02/part2.rb
new file mode 100644
index 0000000..53361c6
--- /dev/null
+++ b/lib/puzzles/2023/day02/part2.rb
@@ -0,0 +1,56 @@
+# frozen_string_literal: true
+
+require_relative "part1"
+require_relative "set"
+
+module AdventOfCode
+ module Puzzles2023
+ module Day02
+ ##
+ # Class for solving Day 02 - Part 2 puzzle
+ class Part2 < Part1
+ ##
+ # Find the sum of the powers for the minimum valid sets.
+ #
+ # @return [Integer] sum of the powers
+ def answer
+ minimum_valid_sets.values.sum(&:power)
+ end
+
+ ##
+ # Find the minimum valid set for each game.
+ #
+ # @return [Hash] minimum valid sets
+ def minimum_valid_sets
+ @minimum_valid_sets ||= games.to_h { |game| [game, minimum_valid_set_for(game:)] }
+ end
+
+ protected
+
+ ##
+ # Array of colors
+ # @return [Array] the colors of the cubes
+ COLORS = %i[blue green red].freeze
+
+ ##
+ # Find the minimum valid set for a game.
+ # The minimum valid set is the set with the maximum number of
+ # blue, green, and red cubes for all sets.
+ #
+ # @param game [Game] game to find the minimum valid set for
+ #
+ # @return [Set] minimum valid set
+ def minimum_valid_set_for(game:)
+ # Find the maximum number of blue, green, and red cubes in each set
+ minimum_set = COLORS.to_h { |color| [color, 0] }
+ game.sets.each do |set|
+ COLORS.each do |color|
+ minimum_set[color] = [set.send(color), minimum_set[color]].max
+ end
+ end
+ Set.new(**minimum_set)
+ end
+ end
+ end
+ end
+end
diff --git a/lib/puzzles/2023/day02/set.rb b/lib/puzzles/2023/day02/set.rb
new file mode 100644
index 0000000..93b718c
--- /dev/null
+++ b/lib/puzzles/2023/day02/set.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+
+module AdventOfCode
+ module Puzzles2023
+ module Day02
+ ##
+ # Set class to store the number of blue, green, and red cubes.
+ class Set
+ ##
+ # @return [Integer] number of blue cubes
+ attr_reader :blue
+
+ ##
+ # @return [Integer] number of green cubes
+ attr_reader :green
+
+ ##
+ # @return [Integer] number of red cubes
+ attr_reader :red
+
+ ##
+ # @param blue [Integer] number of blue cubes
+ # @param green [Integer] number of green cubes
+ # @param red [Integer] number of red cubes
+ def initialize(blue: 0, green: 0, red: 0)
+ @blue = blue
+ @green = green
+ @red = red
+ end
+
+ ##
+ # Compute the power of the set by multiplying the number of
+ # blue, green, and red cubes.
+ #
+ # @return [Integer] power of the set
+ def power
+ return @power if @power
+
+ @power = (blue || 1) * (green || 1) * (red || 1)
+ end
+ end
+ end
+ end
+end
diff --git a/lib/puzzles/2023/day03/README.md b/lib/puzzles/2023/day03/README.md
new file mode 100644
index 0000000..cd15a89
--- /dev/null
+++ b/lib/puzzles/2023/day03/README.md
@@ -0,0 +1,69 @@
+# [Day 3: Gear Ratios](https://adventofcode.com/2023/day/3)
+
+## Part One
+
+You and the Elf eventually reach a [gondola lift](https://en.wikipedia.org/wiki/Gondola_lift) station; he says the gondola lift will take you up to the water source, but this is as far as he can bring you. You go inside.
+
+It doesn't take long to find the gondolas, but there seems to be a problem: they're not moving.
+
+"Aaah!"
+
+You turn around to see a slightly-greasy Elf with a wrench and a look of surprise. "Sorry, I wasn't expecting anyone! The gondola lift isn't working right now; it'll still be a while before I can fix it." You offer to help.
+
+The engineer explains that an engine part seems to be missing from the engine, but nobody can figure out which one. If you can **add up all the part numbers** in the engine schematic, it should be easy to work out which part is missing.
+
+The engine schematic (your puzzle input) consists of a visual representation of the engine. There are lots of numbers and symbols you don't really understand, but apparently **any number adjacent to a symbol**, even diagonally, is a "part number" and should be included in your sum. (Periods (`.`) do not count as a symbol.)
+
+Here is an example engine schematic:
+
+```
+467..114..
+...*......
+..35..633.
+......#...
+617*......
+.....+.58.
+..592.....
+......755.
+...$.*....
+.664.598..
+```
+
+In this schematic, two numbers are **not** part numbers because they are not adjacent to a symbol: `114` (top right) and `58` (middle right). Every other number is adjacent to a symbol and so **is** a part number; their sum is **`4361`**.
+
+Of course, the actual engine schematic is much larger. **What is the sum of all of the part numbers in the engine schematic?**
+
+Your puzzle answer was `532445`.
+
+## Part Two
+
+The engineer finds the missing part and installs it in the engine! As the engine springs to life, you jump in the closest gondola, finally ready to ascend to the water source.
+
+You don't seem to be going very fast, though. Maybe something is still wrong? Fortunately, the gondola has a phone labeled "help", so you pick it up and the engineer answers.
+
+Before you can explain the situation, she suggests that you look out the window. There stands the engineer, holding a phone in one hand and waving with the other. You're going so slowly that you haven't even left the station. You exit the gondola.
+
+The missing part wasn't the only issue - one of the gears in the engine is wrong. A **gear** is any `*` symbol that is adjacent to **exactly two part numbers**. Its **gear ratio** is the result of multiplying those two numbers together.
+
+This time, you need to find the gear ratio of every gear and add them all up so that the engineer can figure out which gear needs to be replaced.
+
+Consider the same engine schematic again:
+
+```
+467..114..
+...*......
+..35..633.
+......#...
+617*......
+.....+.58.
+..592.....
+......755.
+...$.*....
+.664.598..
+```
+
+In this schematic, there are **two** gears. The first is in the top left; it has part numbers `467` and `35`, so its gear ratio is `16345`. The second gear is in the lower right; its gear ratio is `451490`. (The `*` adjacent to `617` is **not** a gear because it is only adjacent to one part number.) Adding up all of the gear ratios produces **`467835`**.
+
+**What is the sum of all of the gear ratios in your engine schematic?**
+
+Your puzzle answer was `79842967`.
diff --git a/lib/puzzles/2023/day03/day03.rb b/lib/puzzles/2023/day03/day03.rb
new file mode 100644
index 0000000..00ec0d8
--- /dev/null
+++ b/lib/puzzles/2023/day03/day03.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+require_relative "part1"
+require_relative "part2"
+
+module AdventOfCode
+ module Puzzles2023
+ ##
+ # {include:file:lib/puzzles/2023/day03/README.md}
+ module Day03
+ end
+ end
+end
diff --git a/lib/puzzles/2023/day03/gear.rb b/lib/puzzles/2023/day03/gear.rb
new file mode 100644
index 0000000..ae092a7
--- /dev/null
+++ b/lib/puzzles/2023/day03/gear.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+module AdventOfCode
+ module Puzzles2023
+ module Day03
+ ##
+ # Class for representing a gear
+ class Gear
+ ##
+ # @return [String] symbol of the gear
+ attr_reader :symbol
+
+ ##
+ # @return [String] index of the gear
+ attr_reader :index
+
+ ##
+ # @return [Array] array of numbers of the gear
+ attr_reader :numbers
+
+ ##
+ # @param symbol [String] symbol of the gear
+ # @param index [String] index of the gear
+ # @param numbers [Array] array of numbers of the gear
+ def initialize(symbol:, index:, numbers:)
+ @symbol = symbol
+ @index = index
+ @numbers = numbers
+ end
+
+ ##
+ # Get the ratio of the gear.
+ #
+ # @return [Integer] ratio of the gear
+ def ratio
+ @ratio ||= numbers.reduce(:*)
+ end
+ end
+ end
+ end
+end
diff --git a/test/puzzles/2023/day03/input.txt b/lib/puzzles/2023/day03/input.txt
similarity index 100%
rename from test/puzzles/2023/day03/input.txt
rename to lib/puzzles/2023/day03/input.txt
diff --git a/lib/puzzles/2023/day03.rb b/lib/puzzles/2023/day03/part1.rb
similarity index 65%
rename from lib/puzzles/2023/day03.rb
rename to lib/puzzles/2023/day03/part1.rb
index d370e39..bf747d2 100644
--- a/lib/puzzles/2023/day03.rb
+++ b/lib/puzzles/2023/day03/part1.rb
@@ -2,50 +2,26 @@
module AdventOfCode
module Puzzles2023
- ##
- # Advent of Code 2023 - Day 3
- # https://adventofcode.com/2023/day/3
module Day03
##
- # Class for representing a gear
- class Gear
- attr_reader :symbol, :index, :numbers
-
- ##
- # @param symbol [String] symbol of the gear
- # @param index [String] index of the gear
- # @param numbers [Array] array of numbers of the gear
- def initialize(symbol:, index:, numbers:)
- @symbol = symbol
- @index = index
- @numbers = numbers
- end
-
- ##
- # Get the ratio of the gear.
- #
- # @return [Integer] ratio of the gear
- def ratio
- @ratio ||= numbers.reduce(:*)
- end
- end
-
- ##
- # Class for solving Day 03 - Part 1 puzzle
+ # Class for solving Day 3 - Part 1 puzzle
class Part1
##
# An array of hashes with the indexes and numbers of the
# numbers in each line.
+ # @return [Array>] matrix of number indexes and numbers
attr_reader :numbers
##
# An array of hashes with the indexes of the symbols and
# the current symbol in each line.
+ # @return [Array>] matrix of symbol indexes and symbols
attr_reader :symbols
##
# @param file [String] path to input file
- def initialize(file:)
+ def initialize(file: nil)
+ file ||= "#{File.dirname(__FILE__)}/input.txt"
file_contents = File.readlines(file, chomp: true)
@symbols = extract_symbols(file_contents)
@numbers = extract_numbers(file_contents)
@@ -53,6 +29,8 @@ def initialize(file:)
##
# Get the sum of the engine part numbers.
+ #
+ # @return [Integer] sum of the engine part numbers
def answer
part_numbers.sum
end
@@ -161,66 +139,6 @@ def adjacent_rows_symbols(symbols, row_index)
symbols.values_at(*adjacent_symbol_rows).map(&:keys).flatten.uniq
end
end
-
- ##
- # Class for solving Day 03 - Part 2 puzzle
- class Part2 < Part1
- ##
- # Array of Gear objects
- attr_reader :gears
-
- ##
- # @param file [String] path to input file
- def initialize(file:)
- super
- @gears = find_gears
- end
-
- ##
- # Get the sum of the ratios of the gears.
- def answer
- gears.sum(&:ratio)
- end
-
- protected
-
- def find_gears
- gears = []
- symbols.each_with_index do |row_symbols, row_index|
- row_symbols.each do |symbol_index, symbol|
- adjacent_numbers = find_adjacent_numbers(row_index, symbol_index)
- if adjacent_numbers.length == 2
- gears << Gear.new(symbol:, index: "#{row_index}:#{symbol_index}", numbers: adjacent_numbers)
- end
- end
- end
- gears
- end
-
- ##
- # Find the numbers next to the given symbol.
- #
- # @param row_index [Integer] index of the row to check
- # @param symbol_index [Integer] index of the symbol to check
- #
- # @return [Array] array of numbers next to the symbol
- def find_adjacent_numbers(row_index, symbol_index)
- # Adjacent rows
- adjacent_number_rows = adjacent_rows_range(row_index, numbers.length - 1)
-
- # Check numbers next to the current symbol
- adjacent_numbers = []
- adjacent_number_rows.each do |number_row_idx|
- numbers[number_row_idx].each do |number_idx, number|
- # Indexes occupied by the current number
- number_limits = number_idx - 1..number_idx + number.length
- adjacent_numbers << number.to_i if number_limits.include?(symbol_index)
- end
- end
-
- adjacent_numbers
- end
- end
end
end
end
diff --git a/lib/puzzles/2023/day03/part2.rb b/lib/puzzles/2023/day03/part2.rb
new file mode 100644
index 0000000..298071c
--- /dev/null
+++ b/lib/puzzles/2023/day03/part2.rb
@@ -0,0 +1,77 @@
+# frozen_string_literal: true
+
+require_relative "part1"
+require_relative "gear"
+
+module AdventOfCode
+ module Puzzles2023
+ module Day03
+ ##
+ # Class for solving Day 3 - Part 2 puzzle
+ class Part2 < Part1
+ ##
+ # Array of Gear objects
+ # @return [Array] array of gears
+ attr_reader :gears
+
+ ##
+ # @param file [String] path to input file
+ def initialize(file: nil)
+ super(file:)
+ @gears = find_gears
+ end
+
+ ##
+ # Get the sum of the ratios of the gears.
+ #
+ # @return [Integer] sum of the ratios of the gears
+ def answer
+ gears.sum(&:ratio)
+ end
+
+ protected
+
+ ##
+ # Find the gears in the puzzle.
+ #
+ # @return [Array] array of gears
+ def find_gears
+ gears = []
+ symbols.each_with_index do |row_symbols, row_index|
+ row_symbols.each do |symbol_index, symbol|
+ adjacent_numbers = find_adjacent_numbers(row_index, symbol_index)
+ if adjacent_numbers.length == 2
+ gears << Gear.new(symbol:, index: "#{row_index}:#{symbol_index}", numbers: adjacent_numbers)
+ end
+ end
+ end
+ gears
+ end
+
+ ##
+ # Find the numbers next to the given symbol.
+ #
+ # @param row_index [Integer] index of the row to check
+ # @param symbol_index [Integer] index of the symbol to check
+ #
+ # @return [Array] array of numbers next to the symbol
+ def find_adjacent_numbers(row_index, symbol_index)
+ # Adjacent rows
+ adjacent_number_rows = adjacent_rows_range(row_index, numbers.length - 1)
+
+ # Check numbers next to the current symbol
+ adjacent_numbers = []
+ adjacent_number_rows.each do |number_row_idx|
+ numbers[number_row_idx].each do |number_idx, number|
+ # Indexes occupied by the current number
+ number_limits = number_idx - 1..number_idx + number.length
+ adjacent_numbers << number.to_i if number_limits.include?(symbol_index)
+ end
+ end
+
+ adjacent_numbers
+ end
+ end
+ end
+ end
+end
diff --git a/lib/puzzles/2023/day04.rb b/lib/puzzles/2023/day04.rb
deleted file mode 100644
index da6c206..0000000
--- a/lib/puzzles/2023/day04.rb
+++ /dev/null
@@ -1,139 +0,0 @@
-# frozen_string_literal: true
-
-module AdventOfCode
- module Puzzles2023
- ##
- # Advent of Code 2023 - Day 4
- # https://adventofcode.com/2023/day/4
- module Day04
- ##
- # Class for representing a card
- class Card
- attr_reader :id, :owned, :winning
-
- ##
- # @param id [Integer] id of the card
- # @param owned [Array] array of owned numbers
- # @param winning [Array] array of winning numbers
- def initialize(id:, owned:, winning:)
- @id = id
- @owned = owned
- @winning = winning
- end
-
- ##
- # Get the matches of the card.
- # That is, the numbers that are both owned and winning.
- #
- # @return [Array] array of matches
- def matches
- return @matches unless @matches.nil?
-
- @matches = winning.intersection(owned)
- end
- end
-
- ##
- # Class for solving Day 04 - Part 1 puzzle
- class Part1
- attr_reader :cards
-
- ##
- # @param file [String] path to input file
- def initialize(file:)
- file_contents = File.readlines(file, chomp: true)
- @cards = extract_cards(file_contents)
- end
-
- ##
- # Get the sum of the points of the cards.
- def answer
- points.sum
- end
-
- ##
- # Get the points of the cards.
- #
- # The points are calculated as 2^(n-1), where n is the number
- # of matches of the card.
- # If the card has no matches, it gets 0 points.
- #
- # @return [Array] array of points
- def points
- cards.map do |card|
- matches = card.matches.length
- matches.zero? ? 0 : (2**(matches - 1)).to_i
- end
- end
-
- protected
-
- ##
- # Method to extract cards from file contents.
- #
- # @param file_contents [Array] array of file lines
- #
- # @return [Array] array of cards
- def extract_cards(file_contents)
- cards = []
- file_contents.each do |line|
- match = %r{Card\s+(?\d+): (?[0-9 ]+) \| (?[0-9 ]+)}.match(line)
- id = match[:id].to_i
- owned = match[:owned].split.map(&:to_i)
- winning = match[:winning].split.map(&:to_i)
- cards << Card.new(id:, owned:, winning:)
- end
- cards
- end
- end
-
- ##
- # Class for solving Day 04 - Part 2 puzzle
- class Part2 < Part1
- ##
- # Get the total number of cards.
- #
- # @return [Integer] total number of cards
- def answer
- total_cards.sum { |c| c[:copies] }
- end
-
- ##
- # Array of cards, with each card having a copies attribute.
- #
- # @return [Array>] array of cards with copies counter
- def total_cards
- # Initialize every element of the array with one copy of each card
- cards_h = cards.map { |card| { card:, copies: 1 } }
-
- # Count cards
- cards_h.each do |item|
- # This is the number of copies of the card that we already have
- current_copies = item[:copies]
-
- # New cards that are won by the card
- won_cards = get_matching_cards(card: item[:card])
- won_cards.each do |won_card|
- cards_h[won_card.id - 1][:copies] += current_copies
- end
- end
-
- cards_h
- end
-
- protected
-
- ##
- # Get the cards that are won by the card.
- # That is, one copy of the following `n` cards, where `n` is the
- # number of matches of the card.
- #
- # @return [Array] array of card ids
- def get_matching_cards(card:)
- number_of_copies = card.matches.length
- cards[card.id, number_of_copies] || []
- end
- end
- end
- end
-end
diff --git a/lib/puzzles/2023/day04/README.md b/lib/puzzles/2023/day04/README.md
new file mode 100644
index 0000000..bfcd4f2
--- /dev/null
+++ b/lib/puzzles/2023/day04/README.md
@@ -0,0 +1,77 @@
+# [Day 4: Scratchcards](https://adventofcode.com/2023/day/4)
+
+## Part One
+
+The gondola takes you up. Strangely, though, the ground doesn't seem to be coming with you; you're not climbing a mountain. As the circle of Snow Island recedes below you, an entire new landmass suddenly appears above you! The gondola carries you to the surface of the new island and lurches into the station.
+
+As you exit the gondola, the first thing you notice is that the air here is much **warmer** than it was on Snow Island. It's also quite **humid**. Is this where the water source is?
+
+The next thing you notice is an Elf sitting on the floor across the station in what seems to be a pile of colorful square cards.
+
+"Oh! Hello!" The Elf excitedly runs over to you. "How may I be of service?" You ask about water sources.
+
+"I'm not sure; I just operate the gondola lift. That does sound like something we'd have, though - this is **Island Island**, after all! I bet the **gardener** would know. He's on a different island, though - er, the small kind surrounded by water, not the floating kind. We really need to come up with a better naming scheme. Tell you what: if you can help me with something quick, I'll let you **borrow my boat** and you can go visit the gardener. I got all these [scratchcards](https://en.wikipedia.org/wiki/Scratchcard) as a gift, but I can't figure out what I've won."
+
+The Elf leads you over to the pile of colorful cards. There, you discover dozens of scratchcards, all with their opaque covering already scratched off. Picking one up, it looks like each card has two lists of numbers separated by a vertical bar (|): a list of **winning numbers** and then a list of **numbers you have**. You organize the information into a table (your puzzle input).
+
+As far as the Elf has been able to figure out, you have to figure out which of the **numbers you have** appear in the list of **winning numbers**. The first match makes the card worth **one point** and each match after the first **doubles** the point value of that card.
+
+For example:
+
+```
+Card 1: 41 48 83 86 17 | 83 86 6 31 17 9 48 53
+Card 2: 13 32 20 16 61 | 61 30 68 82 17 32 24 19
+Card 3: 1 21 53 59 44 | 69 82 63 72 16 21 14 1
+Card 4: 41 92 73 84 69 | 59 84 76 51 58 5 54 83
+Card 5: 87 83 26 28 32 | 88 30 70 12 93 22 82 36
+Card 6: 31 18 13 56 72 | 74 77 10 23 35 67 36 11
+```
+
+In the above example, card 1 has five winning numbers (`41`, `48`, `83`, `86`, and `17`) and eight numbers you have (`83`, `86`, `6`, `31`, `17`, `9`, `48`, and `53`). Of the numbers you have, four of them (`48`, `83`, `17`, and `86`) are winning numbers! That means card 1 is worth **`8`** points (1 for the first match, then doubled three times for each of the three matches after the first).
+
+- Card 2 has two winning numbers (`32` and `61`), so it is worth **`2`** points.
+- Card 3 has two winning numbers (`1` and `21`), so it is worth **`2`** points.
+- Card 4 has one winning number (`84`), so it is worth 1 point.
+- Card 5 has no winning numbers, so it is worth no points.
+- Card 6 has no winning numbers, so it is worth no points.
+
+So, in this example, the Elf's pile of scratchcards is worth **`13`** points.
+
+Take a seat in the large pile of colorful cards. **How many points are they worth in total?**
+
+Your puzzle answer was `22897`.
+
+## Part Two
+
+Just as you're about to report your findings to the Elf, one of you realizes that the rules have actually been printed on the back of every card this whole time.
+
+There's no such thing as "points". Instead, scratchcards only cause you to **win more scratchcards** equal to the number of winning numbers you have.
+
+Specifically, you win **copies** of the scratchcards below the winning card equal to the number of matches. So, if card 10 were to have 5 matching numbers, you would win one copy each of cards 11, 12, 13, 14, and 15.
+
+Copies of scratchcards are scored like normal scratchcards and have the **same card number** as the card they copied. So, if you win a copy of card 10 and it has 5 matching numbers, it would then win a copy of the same cards that the original card 10 won: cards 11, 12, 13, 14, and 15. This process repeats until none of the copies cause you to win any more cards. (Cards will never make you copy a card past the end of the table.)
+
+This time, the above example goes differently:
+
+```
+Card 1: 41 48 83 86 17 | 83 86 6 31 17 9 48 53
+Card 2: 13 32 20 16 61 | 61 30 68 82 17 32 24 19
+Card 3: 1 21 53 59 44 | 69 82 63 72 16 21 14 1
+Card 4: 41 92 73 84 69 | 59 84 76 51 58 5 54 83
+Card 5: 87 83 26 28 32 | 88 30 70 12 93 22 82 36
+Card 6: 31 18 13 56 72 | 74 77 10 23 35 67 36 11
+```
+
+- Card 1 has four matching numbers, so you win one copy each of the next four cards: cards 2, 3, 4, and 5.
+- Your original card 2 has two matching numbers, so you win one copy each of cards 3 and 4.
+- Your copy of card 2 also wins one copy each of cards 3 and 4.
+- Your four instances of card 3 (one original and three copies) have two matching numbers, so you win **four** copies each of cards 4 and 5.
+- Your eight instances of card 4 (one original and seven copies) have one matching number, so you win **eight** copies of card 5.
+- Your fourteen instances of card 5 (one original and thirteen copies) have no matching numbers and win no more cards.
+- Your one instance of card 6 (one original) has no matching numbers and wins no more cards.
+
+Once all of the originals and copies have been processed, you end up with **`1`** instance of card 1, **`2`** instances of card 2, **`4`** instances of card 3, **`8`** instances of card 4, **`14`** instances of card 5, and **`1`** instance of card 6. In total, this example pile of scratchcards causes you to ultimately have **`30`** scratchcards!
+
+Process all of the original and copied scratchcards until no more scratchcards are won. Including the original set of scratchcards, **how many total scratchcards do you end up with?**
+
+Your puzzle answer was `5095824`.
diff --git a/lib/puzzles/2023/day04/card.rb b/lib/puzzles/2023/day04/card.rb
new file mode 100644
index 0000000..27f0e33
--- /dev/null
+++ b/lib/puzzles/2023/day04/card.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+
+module AdventOfCode
+ module Puzzles2023
+ module Day04
+ ##
+ # Class for representing a card
+ class Card
+ ##
+ # @return [Integer] id of the card
+ attr_reader :id
+
+ ##
+ # @return [Array] array of owned numbers
+ attr_reader :owned
+
+ ##
+ # @return [Array] array of winning numbers
+ attr_reader :winning
+
+ ##
+ # @param id [Integer] id of the card
+ # @param owned [Array] array of owned numbers
+ # @param winning [Array] array of winning numbers
+ def initialize(id:, owned:, winning:)
+ @id = id
+ @owned = owned
+ @winning = winning
+ end
+
+ ##
+ # Get the matches of the card.
+ # That is, the numbers that are both owned and winning.
+ #
+ # @return [Array] array of matches
+ def matches
+ return @matches unless @matches.nil?
+
+ @matches = winning.intersection(owned)
+ end
+ end
+ end
+ end
+end
diff --git a/lib/puzzles/2023/day04/day04.rb b/lib/puzzles/2023/day04/day04.rb
new file mode 100644
index 0000000..270c4f5
--- /dev/null
+++ b/lib/puzzles/2023/day04/day04.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+require_relative "part1"
+require_relative "part2"
+
+module AdventOfCode
+ module Puzzles2023
+ ##
+ # {include:file:lib/puzzles/2023/day04/README.md}
+ module Day04
+ end
+ end
+end
diff --git a/test/puzzles/2023/day04/input.txt b/lib/puzzles/2023/day04/input.txt
similarity index 100%
rename from test/puzzles/2023/day04/input.txt
rename to lib/puzzles/2023/day04/input.txt
diff --git a/lib/puzzles/2023/day04/part1.rb b/lib/puzzles/2023/day04/part1.rb
new file mode 100644
index 0000000..b83c40e
--- /dev/null
+++ b/lib/puzzles/2023/day04/part1.rb
@@ -0,0 +1,64 @@
+# frozen_string_literal: true
+
+require_relative "card"
+
+module AdventOfCode
+ module Puzzles2023
+ module Day04
+ ##
+ # Class for solving Day 04 - Part 1 puzzle
+ class Part1
+ ##
+ # @return [Array] array of cards
+ attr_reader :cards
+
+ ##
+ # @param file [String] path to input file
+ def initialize(file: nil)
+ file ||= "#{File.dirname(__FILE__)}/input.txt"
+ file_contents = File.readlines(file, chomp: true)
+ parse_cards(file_contents)
+ end
+
+ ##
+ # Get the sum of the points of the cards.
+ def answer
+ points.sum
+ end
+
+ ##
+ # Get the points of the cards.
+ #
+ # The points are calculated as 2^(n-1), where n is the number
+ # of matches of the card.
+ # If the card has no matches, it gets 0 points.
+ #
+ # @return [Array] array of points
+ def points
+ cards.map do |card|
+ matches = card.matches.length
+ matches.zero? ? 0 : (2**(matches - 1)).to_i
+ end
+ end
+
+ protected
+
+ ##
+ # Method to parse cards from file contents.
+ #
+ # @param file_contents [Array] array of file lines
+ #
+ # @return [Array] array of cards
+ def parse_cards(file_contents)
+ @cards = file_contents.map do |line|
+ match = %r{Card\s+(?\d+): (?[0-9 ]+) \| (?[0-9 ]+)}.match(line)
+ id = match[:id].to_i
+ owned = match[:owned].split.map(&:to_i)
+ winning = match[:winning].split.map(&:to_i)
+ Card.new(id:, owned:, winning:)
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/puzzles/2023/day04/part2.rb b/lib/puzzles/2023/day04/part2.rb
new file mode 100644
index 0000000..5acf7ad
--- /dev/null
+++ b/lib/puzzles/2023/day04/part2.rb
@@ -0,0 +1,57 @@
+# frozen_string_literal: true
+
+require_relative "part1"
+
+module AdventOfCode
+ module Puzzles2023
+ module Day04
+ ##
+ # Class for solving Day 04 - Part 2 puzzle
+ class Part2 < Part1
+ ##
+ # Get the total number of cards.
+ #
+ # @return [Integer] total number of cards
+ def answer
+ total_cards.sum { |c| c[:copies] }
+ end
+
+ ##
+ # Array of cards, with each card having a copies attribute.
+ #
+ # @return [Array>] array of cards with copies counter
+ def total_cards
+ # Initialize every element of the array with one copy of each card
+ cards_h = cards.map { |card| { card:, copies: 1 } }
+
+ # Count cards
+ cards_h.each do |item|
+ # This is the number of copies of the card that we already have
+ current_copies = item[:copies]
+
+ # New cards that are won by the card
+ won_cards = get_matching_cards(card: item[:card])
+ won_cards.each do |won_card|
+ cards_h[won_card.id - 1][:copies] += current_copies
+ end
+ end
+
+ cards_h
+ end
+
+ protected
+
+ ##
+ # Get the cards that are won by the card.
+ # That is, one copy of the following `n` cards, where `n` is the
+ # number of matches of the card.
+ #
+ # @return [Array] array of card ids
+ def get_matching_cards(card:)
+ number_of_copies = card.matches.length
+ cards[card.id, number_of_copies] || []
+ end
+ end
+ end
+ end
+end
diff --git a/lib/puzzles/2023/day05.rb b/lib/puzzles/2023/day05.rb
deleted file mode 100644
index b31dda7..0000000
--- a/lib/puzzles/2023/day05.rb
+++ /dev/null
@@ -1,299 +0,0 @@
-# frozen_string_literal: true
-
-module AdventOfCode
- module Puzzles2023
- ##
- # Advent of Code 2023 - Day 5
- # https://adventofcode.com/2023/day/5
- module Day05
- ##
- # Class for representing a relation.
- class Relation
- ##
- # @param source [Range] range of source values
- # @param destination [Range] range of destination values
- def initialize(source:, destination:)
- @source = source
- @destination = destination
- end
-
- ##
- # Convert a source value to destination value.
- # If value is not in source range, return nil.
- #
- # @param value [Integer] source value to convert
- #
- # @return [Integer, nil] destination value or nil if value is not in source range
- def convert(value:)
- # Is value in source range?
- return unless @source.include?(value)
-
- # Then, convert to destination range
- @destination.begin + (value - @source.begin)
- end
- end
-
- ##
- # Class for converting values from source to destination.
- class Converter
- attr_reader :source, :destination
-
- ##
- # @param source [Symbol] source of the conversion
- # @param destination [Symbol] destination of the conversion
- # @param relations [Array] array of relations
- def initialize(source:, destination:, relations:)
- @source = source
- @destination = destination
- @relations = relations
- end
-
- ##
- # Convert a value from source to destination.
- #
- # @param value [Integer] value to convert
- #
- # @return [Integer] converted value
- def convert(value:)
- # Find value in maps
- @relations.each do |relation|
- if (result = relation.convert(value:))
- return result
- end
- end
- value
- end
- end
-
- ##
- # Class for representing an almanac.
- # An almanac is a collection of converters.
- class Almanac
- ##
- # @param file [String] path to input file
- # @param reverse [Boolean] if true, the conversion is done from destination to source
- def initialize(file:, reverse: false)
- @targets = reverse ? %i[destination source] : %i[source destination]
- parse_file(file)
- end
-
- ##
- # Convert a source value to a destination.
- #
- # @param value [Integer] value to convert
- # @param from [Symbol] source of the conversion
- # @param to [Symbol] destination of the conversion
- #
- # @return [Integer] converted value
- def convert(value:, from:, to:)
- converter = converters[from]
- value = converter.convert(value:)
- return value if converter.destination == to
-
- convert(value:, from: converter.destination, to:)
- end
-
- protected
-
- ##
- # Hash of converters.
- # Keys are source symbols, values are Converter objects.
- #
- # @return [Hash] hash of converters
- attr_reader :converters
-
- ##
- # Contains symbols: :source, :destination,
- # ordered by the direction of the conversion.
- #
- # @return [Array] array of symbols
- attr_reader :targets
-
- ##
- # Build the converters from the file.
- #
- # @param file [String] path to input file
- def parse_file(file)
- file_contents = File.readlines(file, chomp: true)
-
- # Remove seeds line
- file_contents.shift
-
- parse_maps(file_contents)
- end
-
- ##
- # Parse a header line.
- # A header line is a line that contains the source and destination of the conversion.
- # It is in the form: source-to-destination map:
- # If line is not a header line, return nil.
- #
- # @param line [String] line to parse
- #
- # @return [Array, nil] array of symbols or nil if line is not a header line
- def parse_header(line)
- match = line.match(%r{(?