Powered by AppSignal & Oban Pro

Day 09

2022/elixir/day09.livemd

Day 09

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

Puzzle Input

area = Kino.Input.textarea("Puzzle Input")
puzzle_input = Kino.Input.read(area)
example_input = """
R 5
U 8
L 8
D 3
R 17
D 10
L 25
U 20
"""

Common

1..1 |> Enum.to_list()
defmodule Physics do
  defstruct tails: [{0, 0}], head: {0, 0}

  @touch_distance 1

  def new(tails \\ 1),
    do: %Physics{
      tails: Enum.map(1..tails, fn _ -> {0, 0} end)
    }

  def move(ps, move) do
    ps
    |> move_head(move)
    |> move_tails()
  end

  def move_head(%Physics{} = ps, move) do
    %{ps | head: move_point(ps.head, move)}
  end

  def move_tails(%Physics{} = ps) do
    tails = Enum.scan(ps.tails, ps.head, &move_tail(&2, &1))
    %{ps | tails: tails}
  end

  def move_tail(head, tail) do
    if touches?(tail, head) do
      tail
    else
      move_point(tail, {direction(head, tail), 1})
    end
  end

  def move_point({x, y} = point, {direction, distance}) do
    case direction do
      :up ->
        {x, y + distance}

      :down ->
        {x, y - distance}

      :left ->
        {x - distance, y}

      :right ->
        {x + distance, y}

      :up_left ->
        point
        |> move_point({:up, distance})
        |> move_point({:left, distance})

      :up_right ->
        point
        |> move_point({:up, distance})
        |> move_point({:right, distance})

      :down_left ->
        point
        |> move_point({:down, distance})
        |> move_point({:left, distance})

      :down_right ->
        point
        |> move_point({:down, distance})
        |> move_point({:right, distance})
    end
  end

  def direction({headX, headY} = head, {tailX, tailY} = tail) do
    cond do
      head == tail ->
        :same

      headY == tailY ->
        if headX > tailX, do: :right, else: :left

      headX == tailX ->
        if headY > tailY, do: :up, else: :down

      headX > tailX ->
        if headY > tailY, do: :up_right, else: :down_right

      headX < tailX ->
        if headY > tailY, do: :up_left, else: :down_left
    end
  end

  def touches?({headX, headY}, {tailX, tailY}) do
    abs(headX - tailX) <= @touch_distance && abs(headY - tailY) <= @touch_distance
  end

  def head(%Physics{} = ps) do
    ps.head
  end

  def tails(%Physics{} = ps) do
    ps.tails
  end
end
ExUnit.start(autorun: false)

defmodule PhysicsTests do
  use ExUnit.Case, async: true

  test "physics moves head" do
    ps = Physics.new()

    assert ps |> Physics.move({:up, 2}) |> Physics.head() == {0, 2}
    assert ps |> Physics.move({:down, 2}) |> Physics.head() == {0, -2}
    assert ps |> Physics.move({:left, 2}) |> Physics.head() == {-2, 0}
    assert ps |> Physics.move({:right, 2}) |> Physics.head() == {2, 0}
  end

  test "tail stays if touches head vertically" do
    ps = Physics.new()

    assert ps |> Physics.move({:up, 1}) |> Physics.tails() == [{0, 0}]
    assert ps |> Physics.move({:down, 1}) |> Physics.tails() == [{0, 0}]
  end

  test "tail stays if touches head horizontally" do
    ps = Physics.new()

    assert ps |> Physics.move({:right, 1}) |> Physics.tails() == [{0, 0}]
    assert ps |> Physics.move({:left, 1}) |> Physics.tails() == [{0, 0}]
  end

  test "tail stays if touches head diagonally" do
    ps = Physics.new()

    assert ps |> Physics.move({:right, 1}) |> Physics.move({:up, 1}) |> Physics.tails() == [
             {0, 0}
           ]

    assert ps |> Physics.move({:left, 1}) |> Physics.move({:up, 1}) |> Physics.tails() == [{0, 0}]

    assert ps |> Physics.move({:right, 1}) |> Physics.move({:down, 1}) |> Physics.tails() == [
             {0, 0}
           ]

    assert ps |> Physics.move({:left, 1}) |> Physics.move({:down, 1}) |> Physics.tails() == [
             {0, 0}
           ]
  end

  test "tail catches head horizontally" do
    ps = Physics.new()

    assert ps |> Physics.move({:right, 2}) |> Physics.tails() == [{1, 0}]
    assert ps |> Physics.move({:left, 2}) |> Physics.tails() == [{-1, 0}]
  end

  test "tail catches head vertically" do
    ps = Physics.new()

    assert ps |> Physics.move({:up, 2}) |> Physics.tails() == [{0, 1}]
    assert ps |> Physics.move({:down, 2}) |> Physics.tails() == [{0, -1}]
  end

  test "tail catches head diagonally" do
    ps = Physics.new()

    assert ps |> Physics.move({:right, 1}) |> Physics.move({:up, 2}) |> Physics.tails() == [
             {1, 1}
           ]

    assert ps |> Physics.move({:up, 1}) |> Physics.move({:right, 2}) |> Physics.tails() == [
             {1, 1}
           ]
  end
end

ExUnit.run()
moves =
  puzzle_input
  |> String.split("\n", trim: true)
  |> Stream.map(fn line ->
    [direction, distance] = String.split(line, " ", parts: 2)

    formatted_direction =
      case direction do
        "R" -> :right
        "U" -> :up
        "L" -> :left
        "D" -> :down
      end

    formatted_distance = distance |> Integer.parse() |> elem(0)

    {formatted_direction, formatted_distance}
  end)
step_moves =
  moves
  |> Stream.flat_map(fn {direction, distance} ->
    [{direction, 1}] |> Stream.cycle() |> Stream.take(distance)
  end)

Part One

step_moves
|> Stream.scan(Physics.new(), &Physics.move(&2, &1))
|> Enum.flat_map(&Physics.tails/1)
|> MapSet.new()
|> MapSet.size()

Part Two

step_moves
|> Stream.scan(Physics.new(9), &Physics.move(&2, &1))
|> Stream.map(fn ps -> ps |> Physics.tails() |> List.last() end)
|> MapSet.new()
|> MapSet.size()