Powered by AppSignal & Oban Pro
Would you like to see your link here? Contact us

Day 17

2022/elixir/day17.livemd

Day 17

Mix.install([
  {:kino, "~> 0.8.0"}
])

Puzzle Input

area = Kino.Input.textarea("Puzzle Input")
puzzle_input = Kino.Input.read(area)
example_input = ">>><<><>><<<>><>>><<<>>><<<><<<>><>><<>>"

Common

input = example_input
jets =
  input
  |> String.codepoints()
  |> Enum.map(fn
    ">" -> :right
    "<" -> :left
  end)
defmodule Rock do
  @enforce_keys [:tiles, :origin]
  defstruct [:tiles, :origin]

  def new(tiles, origin \\ {0, 0}), do: %Rock{tiles: tiles, origin: origin}

  def tiles(%Rock{} = rock) do
    rock |> tiles_stream() |> Enum.to_list()
  end

  def tiles_stream(%Rock{} = rock) do
    Stream.map(rock.tiles, &amp;relate_tile_to_origin(&amp;1, rock.origin))
  end

  def offset(%Rock{} = rock, {x, y}) do
    {originX, originY} = rock.origin
    %{rock | origin: {originX + x, originY + y}}
  end

  def left(%Rock{} = rock) do
    elem(rock.origin, 0)
  end

  def right(%Rock{} = rock) do
    rock
    |> tiles_stream()
    |> Stream.map(&amp;elem(&amp;1, 0))
    |> Enum.max()
  end

  def top(%Rock{} = rock) do
    rock
    |> tiles_stream()
    |> Stream.map(&amp;elem(&amp;1, 1))
    |> Enum.max()
  end

  def bottom(%Rock{} = rock) do
    elem(rock.origin, 1)
  end

  def relate_tile_to_origin(tile, origin) do
    {tileX, tileY} = tile
    {originX, originY} = origin

    {originX + tileX, originY + tileY}
  end

  def plank(), do: Rock.new([{0, 0}, {1, 0}, {2, 0}, {3, 0}])

  def cross(),
    do:
      Rock.new([
        # first row
        {1, 0},
        # second row
        {0, 1},
        {1, 1},
        {2, 1},
        # third row
        {1, 2}
      ])

  def l(),
    do:
      Rock.new([
        {2, 2},
        {2, 1},
        {0, 0},
        {1, 0},
        {2, 0}
      ])

  def i(),
    do:
      Rock.new([
        {0, 0},
        {0, 1},
        {0, 2},
        {0, 3}
      ])

  def block(),
    do:
      Rock.new([
        # first row
        {0, 0},
        {1, 0},
        # second row
        {0, 1},
        {1, 1}
      ])
end
ExUnit.start(autorun: false)

defmodule RockTests do
  use ExUnit.Case, async: true

  test "offset rock" do
    rock = Rock.new([{0, 0}], {0, 0})

    assert Rock.offset(rock, {1, 1}) == %Rock{origin: {1, 1}, tiles: [{0, 0}]}
  end

  test "bounds" do
    rock =
      Rock.new([
        # first row
        {1, 0},
        # second row
        {0, 1},
        {1, 1},
        {2, 1},
        # third row
        {1, 2}
      ])

    assert Rock.left(rock) == 0
    assert Rock.right(rock) == 2
    assert Rock.top(rock) == 2
    assert Rock.bottom(rock) == 0
  end

  test "tiles" do
    rock =
      Rock.new(
        [
          # first row
          {1, 0},
          # second row
          {0, 1},
          {1, 1},
          {2, 1},
          # third row
          {1, 2}
        ],
        {3, 5}
      )

    assert Rock.tiles(rock) == [{4, 5}, {3, 6}, {4, 6}, {5, 6}, {4, 7}]
  end
end

ExUnit.run()
defmodule Rotation do
  defstruct [:elements, :current]

  def new(elements, current \\ 0) do
    %Rotation{
      current: current,
      elements: elements
    }
  end

  def next(%Rotation{} = rotation) do
    element = elem(rotation.elements, rotation.current)
    {element, %{rotation | current: rem(rotation.current + 1, tuple_size(rotation.elements))}}
  end

  def rock_rotation() do
    Rotation.new({
      Rock.plank(),
      Rock.cross(),
      Rock.l(),
      Rock.i(),
      Rock.block()
    })
  end
end
ExUnit.start(autorun: false)

defmodule RotationTests do
  use ExUnit.Case, async: true

  test "rotates" do
    rotation = Rotation.rock_rotation()

    assert {%Rock{} = first, %Rotation{} = rotation} = Rotation.next(rotation)
    assert {%Rock{} = second, %Rotation{}} = Rotation.next(rotation)
    assert first != second
  end
end

ExUnit.run()
defmodule Chamber do
  @width 7
  defstruct [:top, :tiles, :columns]

  def new(), do: %Chamber{top: 0, tiles: MapSet.new(), columns: %{}}

  def empty_at?(%Chamber{} = chamber, {x, y}) do
    cond do
      x < 0 || x >= @width ->
        false

      y < 0 ->
        false

      :else ->
        top_column_tile = Map.get(chamber.columns, x, -1)
        top_column_tile < y
    end
  end

  def add_tile(%Chamber{} = chamber, tile) do
    {x, y} = tile

    %{
      chamber
      | tiles: MapSet.put(chamber.tiles, tile),
        top: max(chamber.top, y + 1),
        columns: Map.update(chamber.columns, x, y, &amp;max(&amp;1, y))
    }
  end

  def top(%Chamber{top: top}), do: top

  def garbage_collect(%Chamber{} = chamber), do: %{chamber | tiles: MapSet.new()}
end
ExUnit.start(autorun: false)

defmodule ChamberTests do
  use ExUnit.Case, async: true

  test "tracks top" do
    chamber = Chamber.new()

    chamber = chamber |> Chamber.add_tile({0, 0}) |> Chamber.add_tile({0, 1})

    assert Chamber.top(chamber) == 2
  end

  test "probes tiles" do
    chamber = Chamber.new()
    assert Chamber.empty_at?(chamber, {0, 0})

    chamber = Chamber.add_tile(chamber, {0, 0})
    refute Chamber.empty_at?(chamber, {0, 0})
  end
end

ExUnit.run()
defmodule Simulation do
  @spawn_x_offset 2
  @spawn_y_offset 3

  defstruct [:jets_rotation, :rocks_rotation, :falling_rock, :chamber, :stopped_rocks_count]

  def new(jets_rotation, rocks_rotation) do
    %Simulation{
      jets_rotation: jets_rotation,
      rocks_rotation: rocks_rotation,
      falling_rock: nil,
      chamber: Chamber.new(),
      stopped_rocks_count: 0
    }
  end

  def tick(%Simulation{} = simulation) do
    simulation
    |> spawn_rock_if_needed()
    |> jet_rock_if_possible()
    |> lower_or_set_rock()
  end

  def spawn_rock_if_needed(%Simulation{} = simulation) do
    if simulation.falling_rock != nil do
      simulation
    else
      origin = {@spawn_x_offset, Chamber.top(simulation.chamber) + @spawn_y_offset}
      {rock, rotation} = Rotation.next(simulation.rocks_rotation)

      %{simulation | rocks_rotation: rotation, falling_rock: %{rock | origin: origin}}
    end
  end

  def jet_rock_if_possible(%Simulation{} = simulation) do
    {jet, rotation} = Rotation.next(simulation.jets_rotation)
    simulation = %{simulation | jets_rotation: rotation}

    rock_offset =
      case jet do
        :left -> {-1, 0}
        :right -> {1, 0}
      end

    jetted_rock = Rock.offset(simulation.falling_rock, rock_offset)

    rock_fits_in_chamber? =
      Enum.all?(Rock.tiles(jetted_rock), &amp;Chamber.empty_at?(simulation.chamber, &amp;1))

    if rock_fits_in_chamber? do
      %{simulation | falling_rock: jetted_rock}
    else
      simulation
    end
  end

  def lower_or_set_rock(%Simulation{} = simulation) do
    lowered_rock = Rock.offset(simulation.falling_rock, {0, -1})

    rock_fits_in_chamber? =
      Enum.all?(Rock.tiles(lowered_rock), &amp;Chamber.empty_at?(simulation.chamber, &amp;1))

    if rock_fits_in_chamber? do
      %{simulation | falling_rock: lowered_rock}
    else
      chamber =
        simulation.falling_rock
        |> Rock.tiles()
        |> Enum.reduce(simulation.chamber, &amp;Chamber.add_tile(&amp;2, &amp;1))

      %{
        simulation
        | falling_rock: nil,
          chamber: chamber,
          stopped_rocks_count: simulation.stopped_rocks_count + 1
      }
    end
  end

  def print(%Simulation{chamber: chamber} = simulation) do
    falling_tiles = (simulation.falling_rock || Rock.new([])) |> Rock.tiles() |> MapSet.new()

    top_falling_tile =
      if Enum.empty?(falling_tiles),
        do: 0,
        else: falling_tiles |> Stream.map(&amp;elem(&amp;1, 1)) |> Enum.max()

    height = max(chamber.top, top_falling_tile)
    width = 7

    codepoints =
      for y <- height..-1, x <- -1..width do
        tile = {x, y}

        cond do
          y == -1 and x == -1 ->
            "+"

          y == -1 and x == width ->
            "+"

          y == -1 ->
            "-"

          x == width or x == -1 ->
            "|"

          tile in chamber.tiles ->
            "#"

          tile in falling_tiles ->
            "@"

          :empty ->
            "."
        end
      end

    Enum.chunk_every(codepoints, width + 2)
  end
end
jets_rotation = jets |> List.to_tuple() |> Rotation.new()
simulation = Simulation.new(jets_rotation, Rotation.rock_rotation())
simulation
|> Simulation.tick()
|> Simulation.tick()
|> Simulation.tick()
|> Simulation.tick()
|> Simulation.tick()
|> Simulation.tick()
|> Simulation.tick()
|> Simulation.tick()
|> Simulation.tick()
|> Simulation.tick()
|> Simulation.tick()
|> Simulation.tick()
|> Simulation.tick()
|> Simulation.tick()
|> Simulation.tick()
|> Simulation.tick()
|> Simulation.tick()
|> Simulation.tick()
|> Simulation.tick()
|> Simulation.tick()
|> Simulation.tick()
|> Simulation.tick()
|> Simulation.tick()
|> Simulation.tick()
|> Simulation.tick()
|> Simulation.tick()
|> Simulation.tick()
|> Simulation.tick()
|> Simulation.tick()
|> Simulation.tick()
|> Simulation.tick()
|> Simulation.tick()
|> Simulation.tick()
|> Simulation.tick()
|> Simulation.tick()
|> Simulation.tick()
|> Simulation.tick()
|> Simulation.tick()
|> Simulation.tick()
|> Simulation.tick()
|> Simulation.tick()
|> Simulation.tick()
|> Simulation.spawn_rock_if_needed()
|> Simulation.print()
|> Enum.intersperse("\n")
|> IO.puts()

Part One

defmodule PartOne do
  def run(%Simulation{stopped_rocks_count: 2022} = simulation) do
    simulation
  end

  def run(simulation) do
    simulation |> Simulation.tick() |> run()
  end
end
simulation |> PartOne.run() |> then(&amp; &amp;1.chamber.top)

Part Two

defmodule PartTwo do
  @elephant_factor 1_000_000_000_000

  def run(%Simulation{stopped_rocks_count: @elephant_factor} = simulation, _count) do
    simulation
  end

  def run(simulation, count \\ 0) do
    simulation =
      if rem(count, 10000) == 0 do
        IO.inspect(simulation.stopped_rocks_count / @elephant_factor * 100, label: :progress)
        %{simulation | chamber: Chamber.garbage_collect(simulation.chamber)}
      else
        simulation
      end

    simulation |> Simulation.tick() |> run(count + 1)
  end
end
# simulation |> PartTwo.run() |> then(& &1.chamber.top)