Advent of Code 2024 - Day 14

Read some comments here and on Reddit to get some ideas and eventually tried finding an horizontal line of at least 10 robots. Should have found it by myself. Anyway, it was a cool one.

#!/usr/bin/env elixir

# AoC 2024. day 14.

to_i = &String.to_integer/1

advance = fn robots, sec ->
  Enum.map(robots, fn robot ->
    %{robot |
      x: Integer.mod(robot.x+sec*robot.vx,101),
      y: Integer.mod(robot.y+sec*robot.vy,103) }
  end)
  # sorting is used in part 2
  |> Enum.sort(fn o1, o2 -> o1.y < o2.y || o1.y == o2.y && o1.x <= o2.x end)
end

count = fn robots ->
  Enum.reduce(robots, {0,0,0,0}, fn robot, {q1,q2,q3,q4} ->
    cond do
      robot.x < 50 && robot.y < 51 -> {q1+1,q2,q3,q4}
      robot.x > 50 && robot.y < 51 -> {q1,q2+1,q3,q4}
      robot.x < 50 && robot.y > 51 -> {q1,q2,q3+1,q4}
      robot.x > 50 && robot.y > 51 -> {q1,q2,q3,q4+1}
      true -> {q1,q2,q3,q4}
    end
  end)
end

###########################################################
# Setup

robots = File.stream!("../day14.txt")
  |> Stream.map(fn s -> Regex.run(~r/^p=(\d+),(\d+) v=(-?\d+),(-?\d+)/, s, capture: :all_but_first) end)
  |> Enum.reduce([], fn [x, y, vx, vy], lst -> [%{x: to_i.(x), y: to_i.(y), vx: to_i.(vx), vy: to_i.(vy)} | lst] end)
  # sorting is used in part 2
  |> Enum.sort(fn o1, o2 -> o1.y < o2.y || o1.y == o2.y && o1.x <= o2.x end)

###########################################################
# Part 1

start = System.monotonic_time(:microsecond)
robots
|> advance.(100)
|> count.()
|> then(fn {q1,q2,q3,q4} -> q1*q2*q3*q4 end)
|> IO.inspect(label: "Part 1")
elapsed = System.monotonic_time(:microsecond) - start
IO.puts "Job done in #{elapsed} µs"

###########################################################
# Part 2

defmodule M do
  @min_aligned 10
  def horizontal_line(robots), do: horizontal_line(robots, 0, nil)
  defp horizontal_line([], aligned, _prev), do: aligned >= @min_aligned
  defp horizontal_line([r | robots], 0, nil), do: horizontal_line(robots, 0, r)
  defp horizontal_line([r | robots], aligned, prev) do
    cond do
      aligned >= @min_aligned -> true
      r.y == prev.y && r.x == prev.x+1 -> horizontal_line(robots, aligned+1, r)
      true -> horizontal_line(robots, 0, r)
    end
  end
end

display = fn robots, x0, y0, width, height ->
  robots = Map.new(robots, fn r -> {{r.x,r.y},{r.vx,r.vy}} end)
  for y <- y0..y0+height, x <- x0..x0+width, reduce: y0 do
    prev_y -> if y != prev_y, do: IO.puts("")
      if robots[{x,y}] do
        IO.write "#"
      else
        IO.write "."
      end
      y
  end
  IO.puts ""
end

start = System.monotonic_time(:microsecond)
Stream.iterate({robots, 0}, fn {robots, time} ->
  robots = advance.(robots, 1)
  {robots, time+1}
end)
|> Stream.drop_while(fn {robots, _time} ->
  !M.horizontal_line(robots)
end)
|> Enum.at(0)
|> then(fn {robots, time} -> display.(robots, 0, 0, 101, 103) ; time end)
|> IO.inspect(label: "Part 2")
elapsed = System.monotonic_time(:microsecond) - start
IO.puts "Job done in #{elapsed} µs"

I think the trick today is to find a configuration in which no two robots are in the same cell. The author of AoC must have had a starting grid to which he applied velocities in reverse to get our puzzle input, and chances are after each time step there will be at least two robots in one cell by chance. At least that’s what worked for me: advent-of-code-2024/lib/advent_of_code2024/day14.ex at main · ibarakaiev/advent-of-code-2024 · GitHub.

i usually never feed chatgpt a full requirement. When im lazy and want to print a grid i give it an empty function with desired parameters and ask it to implement. Usually i know how to do it already, but im so dam lazy and chatgpt make it correct in at least 60% of the time … which is better than my fooling around for 10mins … :wink: other times though, weak times, i just copy my complete solution and ask it to find the bug or fix a problem. That only works in 10% (or less) of the times i do it. I need to stop that **** …

visualization ftw

EDIT: recording with resource monitoring :wink: i used all 16 logical cores to produce the frames

link (Dropbox)

Final screenshot

Basically just garbled mess before that last frame

actually im pretty happy with the code for part2 … it uses infinite stream range and uses all of the machines logical cores

I originally tried searching for clusters of half the bots, but that took waaay too long, even after memoizing.

Checking for a number of bots surrounded by 8 other bots gets something, but not the right answer.

Finally I broke down and implemented the popular solution of looking for a contiguous row of 10 robots:

defmodule Day14.Part2 do
  @width 101
  @height 103

  defp parse(str) do
    str
    |> String.split("\n", trim: true)
    |> Enum.map(&parse_bot/1)
  end

  defp parse_bot(line) do
    Regex.scan(~r/-?\d+,-?\d+/, line)
    |> Enum.flat_map(fn [pair] ->
      [
        String.split(pair, ",")
        |> Enum.map(&String.to_integer/1)
        |> List.to_tuple()
      ]
    end)
  end

  defp tick([{p_x, p_y}, {v_x, v_y} = velocity] = _bot) do
    [{Integer.mod(p_x + v_x, @width), Integer.mod(p_y + v_y, @height)}, velocity]
  end

  defp tick([bot | _bots] = bots) when is_list(bot) do
    bots
    |> Enum.map(&tick/1)
  end

  defp contiguous?([]), do: true

  defp contiguous?([_bot]), do: true

  defp contiguous?([[{x, _y}, _v] = _bot, [{n_x, _n_y}, _n_v] = neighbor | bots]) do
    x == n_x - 1 and contiguous?([neighbor | bots])
  end

  defp contains_contiguous_row?(bots, count) when length(bots) < count, do: false

  defp contains_contiguous_row?([_bot | bots_t] = bots, count) when length(bots) >= count do
    bots
    |> Enum.slice(0..(count - 1))
    |> contiguous?() or contains_contiguous_row?(bots_t, count)
  end

  defp tree?(bots) do
    {[{_x, min_y}, _v], [{_n_x, max_y}, _n_v]} =
      bots
      |> Enum.min_max_by(fn [{_x, y}, _vector] -> y end)

    Stream.iterate(min_y, &(&1 + 1))
    |> Enum.reduce_while(
      false,
      fn y, false ->
        ten_in_a_row? =
          bots
          |> Stream.filter(fn [{_b_x, b_y}, _v] -> y == b_y end)
          |> Enum.sort_by(fn [{x, _y}, _v] -> x end)
          |> contains_contiguous_row?(10)

        cond do
          ten_in_a_row? -> {:halt, true}
          y >= max_y -> {:halt, false}
          true -> {:cont, false}
        end
      end
    )
  end

  defp find_tree(bots) do
    Stream.iterate(bots, &tick/1)
    |> Enum.reduce_while(
      0,
      fn bots, ticks ->
        if tree?(bots) do
          {:halt, ticks}
        else
          {:cont, ticks + 1}
        end
      end
    )
  end

  def solve() do
    File.read!("lib/advent_of_code/year/2024/day/14/input.txt")
    |> parse()
    |> find_tree()
    |> IO.puts()
  end
end

I didn’t love this one. I think I prefer when the final condition is unambiguous from the text.

Part 1 was straightforward. Just a single mod calculation.

Part 2 was less straightforward. My approach is to use a sliding 7x7 window. I compute a total image score as the sum of the squares of the number of robots in each window. (This is related to an entropy or variance calculation but it’s not the same). Intuitively, when the robots are spread out randomly, most windows will have roughly the same score. Only when the robots are clustered together will we get higher window scores.

I searched for the image with the max score across all t = 100, 1,000, 10,000 until I found a Christmas tree.

This is not golfed.

defmodule Aoc2024.Day14 do
  def part1(file, x_max, y_max) do
    robots = file |> File.read!() |> String.split("\n", trim: true) |> Enum.map(&parse_line/1)

    x_mid = div(x_max, 2)
    y_mid = div(y_max, 2)
    t = 100

    robots
    |> Enum.map(fn {px, py, vx, vy} ->
      [Integer.mod(px + vx * t, x_max), Integer.mod(py + vy * t, y_max)]
    end)
    |> Enum.frequencies_by(fn [x, y] -> [compare(x, x_mid), compare(y, y_mid)] end)
    |> Enum.reject(fn {xy, _} -> :eq in xy end)
    |> Enum.product_by(fn {_, v} -> v end)
  end

  def part2(file, x_max, y_max) do
    robots = file |> File.read!() |> String.split("\n", trim: true) |> Enum.map(&parse_line/1)

    :ets.new(:cache, [:public, :set, :named_table])

    grid = for x <- 0..(x_max - 1), y <- 0..(y_max - 1), into: %{}, do: {{x, y}, 0}
    :ets.insert(:cache, {:grid, grid})

    {s_max, _, image} =
      Enum.reduce_while(
        Stream.iterate(1, &(&1 + 1)),
        {robots, {0, 0, nil}},
        fn t, {acc, {t_max, s_max, image}} ->
          acc =
            Enum.map(acc, fn {px, py, vx, vy} ->
              {Integer.mod(px + vx, x_max), Integer.mod(py + vy, y_max), vx, vy}
            end)

          if t > 10_000 do
            {:halt, {t_max, s_max, image}}
          else
            s = score(acc, x_max, y_max)

            if s > s_max do
              {t, s} |> dbg
              {:cont, {acc, {t, s, acc}}}
            else
              {:cont, {acc, {t_max, s_max, image}}}
            end
          end
        end
      )

    print_image(image, x_max, y_max)
    s_max
  end

  @line ~r/p=(\d+),(\d+) v=(-?\d+),(-?\d+)/
  def parse_line(line) do
    Regex.scan(@line, line, capture: :all_but_first)
    |> hd
    |> Enum.map(&String.to_integer/1)
    |> List.to_tuple()
  end

  def compare(a, b) when is_integer(a) and is_integer(b) do
    cond do
      a < b -> :lt
      a == b -> :eq
      a > b -> :gt
    end
  end

  def score(robots, x_max, y_max) do
    [grid: grid] = :ets.lookup(:cache, :grid)
    size = 7
    s = div(size, 2)

    grid =
      robots
      |> Enum.reduce(grid, fn {x, y, _, _}, acc -> Map.put(acc, {x, y}, 1) end)

    for x <- s..(x_max - 1 - s)//size, y <- s..(y_max - 1 - s)//size, reduce: 0 do
      sum ->
        window_sum =
          for(dx <- -s..s, dy <- -s..s, reduce: 0, do: (sum -> sum + grid[{x + dx, y + dy}]))

        sum + window_sum ** 2
    end
  end

  def print_image(robots, x_max, y_max) do
    [grid: grid] = :ets.lookup(:cache, :grid)

    grid =
      robots
      |> Enum.reduce(grid, fn {x, y, _, _}, acc -> Map.update!(acc, {x, y}, &(&1 + 1)) end)

    for y <- 0..(y_max - 1) do
      for x <- 0..(x_max - 1) do
        v = grid[{x, y}]

        if v == 0 do
          "."
        else
          Integer.to_string(v)
        end
      end
      |> Enum.join("")
    end
    |> Enum.join("\n")
    |> IO.puts()
  end
end

1 Like

I didn’t want to stumble in the dark for part 2 so I went here for help. :dotted_line_face:
I tried out your approach.

defmodule Aoc2024.Solutions.Y24.Day14 do
  alias AoC.Input

  @x_max if Mix.env() == :test, do: 11, else: 101
  @y_max if Mix.env() == :test, do: 7, else: 103

  @tree [{50, 51}, {49, 51}, {51, 51}, {48, 52}, {49, 52}, {50, 52}, {51, 52}, {52, 52}]

  def parse(input, _part) do
    input
    |> Input.stream!(trim: true)
    |> Enum.map(fn line ->
      ~r/-?\d+(\.\d+)?/
      |> Regex.scan(line)
      |> Enum.reduce({}, fn [e], acc ->
        Tuple.append(acc, String.to_integer(e))
      end)
    end)
  end

  def part_one(problem) do
    {q1, q2, q3, q4} =
      problem
      |> Enum.map(&move_robot(&1, 100))
      |> Enum.filter(fn {x, y, _xv, _yv} -> x != div(@x_max, 2) and y != div(@y_max, 2) end)
      |> Enum.reduce({0, 0, 0, 0}, fn {x, y, _xv, _yv}, {q1, q2, q3, q4} ->
        cond do
          x < div(@x_max, 2) and y < div(@y_max, 2) ->
            {q1 + 1, q2, q3, q4}

          x < div(@x_max, 2) and y > div(@y_max, 2) ->
            {q1, q2 + 1, q3, q4}

          x > div(@x_max, 2) and y < div(@y_max, 2) ->
            {q1, q2, q3 + 1, q4}

          x > div(@x_max, 2) and y > div(@y_max, 2) ->
            {q1, q2, q3, q4 + 1}
        end
      end)

    q1 * q2 * q3 * q4
  end

  def part_two(problem) do
    iterate_until(problem, 0)
  end

  defp iterate_until(robot_positions, solution) do
    robot_positions = Enum.map(robot_positions, &move_robot(&1, 1))
    positions = Enum.map(robot_positions, fn {x, y, _xv, _yv} -> {x, y} end)

    if Enum.all?(@tree, &(&1 in positions)),
      do: solution + 1,
      else: iterate_until(robot_positions, solution + 1)
  end

  defp move_robot(solution, 0), do: solution

  defp move_robot({x, y, xv, yv}, seconds) do
    x = rem(x + xv, @x_max)
    x = if x < 0, do: @x_max + x, else: x
    y = rem(y + yv, @y_max)
    y = if y < 0, do: @y_max + y, else: y
    move_robot({x, y, xv, yv}, seconds - 1)
  end
end

I got the correct solution with [{50, 51}, {49, 51}, {51, 51}, {48, 52}, {49, 52}, {50, 52}, {51, 52}, {52, 52}] i.e. the middle 3 and under it 5 is the condition and it worked! :cowboy_hat_face:

1 Like

Haven’t tried part 2 yet, but I’m very pleased that I got part 1 without any false starts and refactors. I do have a suspicion that were I to try and parallelize some of the logic I’d have problems b/c there are some steps where a function takes a value from an ETS table, then either replaces it or leaves it off the table depending on that value. If another process were to attempt to take the value for the same key before that first process finished I think I’d be in trouble. For this problem it was not an issue.

defmodule Day14 do
  alias Day14.{Robot, Board}

  @real File.read!(__DIR__ <> "/input.txt") |> String.trim()

  @re ~r/p=(\d+),(\d+) v=(-*\d+),(-*\d+)/

  def parse(input) do
    Regex.scan(@re, input, capture: :all_but_first)
    |> Enum.map(fn robot -> Enum.map(robot, &String.to_integer/1) end)
  end

  def run(input \\ :real, board_x \\ 101, board_y \\ 103)
  def run(:real, board_x, board_y), do: run(@real, board_x, board_y)

  def run(input, board_x, board_y) do
    Robot.init_roster()
    Board.new(board_x, board_y)

    input
    |> parse()
    |> Robot.init_bots()

    Robot.move_all(100)

    Board.search_quadrants() |> safety_factor() |> IO.inspect(label: :part_1)
  end

  def quit() do
    Board.close()
    Robot.shut_down_all()
  end

  defp safety_factor(groups) do
    groups
    |> Enum.map(fn {k, grp} -> {k, List.flatten(grp)} end)
    |> Enum.map(fn {k, grp} -> {k, grp |> Enum.flat_map(fn {_coord, bots} -> bots end)} end)
    |> Enum.reduce(1, fn {_, bots}, acc -> acc * length(bots) end)
  end

  defmodule Robot do
    def init_roster() do
      :ets.new(:robot_list, [:named_table, :ordered_set])
    end

    def init_bot(name, [px, py, vx, vy]) do
      :ets.insert(:robot_list, {name, %{px: px, py: py, vx: vx, vy: vy}})

      if :board not in :ets.all() do
        Board.new()
      end

      Board.add_to_spot(name, px, py)
    end

    def init_bots(robots) do
      robots
      |> Stream.with_index()
      |> Stream.each(fn {robot, ndx} ->
        name = "R2D#{ndx}"
        init_bot(name, robot)
      end)
      |> Stream.run()
    end

    def shut_down_all() do
      :ets.delete(:robot_list)
    end

    def shut_down_bot(robot) do
      :ets.take(:robot_list, robot)
    end

    def move_all(seconds) do
      Stream.iterate(0, &(&1 + 1))
      |> Stream.map(fn i -> :ets.slot(:robot_list, i) end)
      |> Stream.take_while(fn n -> n != :"$end_of_table" end)
      |> Stream.map(fn [{robot, state}] -> move_bot(robot, state, seconds) end)
      |> Stream.run()
    end

    defp move_bot(robot, state, seconds) do
      board_x = :ets.lookup_element(:board, :width, 2)
      board_y = :ets.lookup_element(:board, :height, 2)

      px =
        case rem(state.px + state.vx * seconds, board_x) do
          n when n >= 0 -> n
          n -> board_x + n
        end

      py =
        case rem(state.py + state.vy * seconds, board_y) do
          n when n >= 0 -> n
          n -> board_y + n
        end

      Board.remove_from_spot(robot, state.px, state.py)
      :ets.update_element(:robot_list, robot, {2, %{state | px: px, py: py}})
      Board.add_to_spot(robot, px, py)
    end
  end

  defmodule Board do
    def new(x \\ 101, y \\ 103) do
      :ets.new(:board, [:named_table, :ordered_set])

      :ets.insert(:board, [{:width, x}, {:height, y}])

      define_quadrants(x, y)
    end

    defp define_quadrants(x, y) do
      mid_x = div(x, 2)
      mid_y = div(y, 2)

      quadrants = [
        {:nw, {0..(mid_x - 1), 0..(mid_y - 1)}},
        {:ne, {(mid_x + 1)..x, 0..(mid_y - 1)}},
        {:sw, {0..(mid_x - 1), (mid_y + 1)..y}},
        {:se, {(mid_x + 1)..x, (mid_y + 1)..y}}
      ]

      :ets.insert(:board, quadrants)
    end

    def remove_from_spot(occupant, x, y) do
      case :ets.take(:board, {x, y}) do
        [{{_x, _y}, [^occupant]}] -> true
        [{{^x, ^y}, occupants}] -> :ets.insert(:board, {{x, y}, occupants -- [occupant]})
        [] -> true
      end
    end

    def add_to_spot(occupant, x, y) do
      case :ets.take(:board, {x, y}) do
        [{{x, y}, occupants}] -> :ets.insert(:board, {{x, y}, [occupant | occupants]})
        [] -> :ets.insert(:board, {{x, y}, [occupant]})
      end
    end

    def search_quadrants() do
      Stream.iterate(0, &(&1 + 1))
      |> Stream.map(fn i -> :ets.slot(:board, i) end)
      |> Stream.take_while(fn res -> res != :"$end_of_table" end)
      |> Stream.filter(fn [{k, _v}] -> k not in [:width, :height, :nw, :sw, :ne, :se] end)
      |> Enum.group_by(fn [{{x, y}, _robots}] -> quadrant(x, y) end)
      |> Enum.reject(fn {k, _grp} -> k == nil end)
    end

    defp quadrant(x, y) do
      [:nw, :ne, :sw, :se]
      |> Enum.find(fn q ->
        case :ets.lookup_element(:board, q, 2) do
          {x1..x2//_, y1..y2//_} when x1 <= x and x2 >= x and (y1 <= y and y2 >= y) -> true
          _ -> false
        end
      end)
    end

    def close() do
      :ets.delete(:board)
    end
  end
end

PS Sorry to everyone who moved on two weeks ago. I finally have some time and enjoy the puzzles more than the competition aspect. Hope no one minds the necro bumps of these threads.

PPS I haven’t checked the thread but did people actually implement edge detection and pattern recognition for finding the undefined christmas tree for part 2? I really think I’m just going to make it print the board every second and watch it until I see a tree. :lol:

2 Likes

I think you should read the thread before posting, as you are mostly repeating what has already been said… Maybe that’s the nature of AoC though, everyone wants to share their code/experience, which is likely similar since we’re all doing the same puzzle.

1 Like

The “safety factor” from part 1 was sufficient for my case - it had a significantly lower value for the “Christmas tree” pattern than for the random messes on either side.

1 Like

Do you mean my comment about part 2 is repeating what has been said or do you mean generally my solutions across the threads are not unique or interesting because of how similar they are to other solutions? If the latter I think that’s a bit uncharitable. As you said most of the time the puzzles have a fairly clear method of solving and the individual implementations will tend to coalesce around that method. That said, I think the slight differences are potentially helpful for historical reference as people consulting these threads from Google searches in the future may learn different tools Elixir provides that they were not aware of. For instance, at least for this day’s puzzle my solution to part 1 is logically identical to almost every other solution because it is a very straight forward math problem. I think my solution has value, however, as I chose to (unnecessarily) set up the problem in what I think of as a more BEAM-ish approach to managing state. I hope that future readers find that helpful.

I hope people don’t mind us chiming in with our experiences/solutions well after the fact, because I
came here to share a unique approach to Part Two which proved quite effective. I had no clear idea
what the Christmas tree would look like, but I figured it would probably involve statistically unlikely
clustering of the robots in both x and y coordinates. So I came up with the following measure.

  @fuzz 5  # Defines binning of locations to look for robot clusters
  def find_interesting(robots, dims, count, threshold) do
    {w, h} = dims
    rcount = Enum.count(robots)
    avg_ctx = @fuzz * rcount / w
    avg_cty = @fuzz * rcount / h
    thresh_ctx = avg_ctx + threshold * :math.sqrt(avg_ctx)
    thresh_cty = avg_ctx + threshold * :math.sqrt(avg_cty)
    Enum.filter(1..count, fn t ->
      robots = Robots.move_robots(robots, dims, t)
      {cts_x, cts_y} = Enum.reduce(robots, {%{}, %{}}, fn {{x, y}, _v}, {cx, cy} ->
        {Map.update(cx, div(x, @fuzz), 1, fn c -> c + 1 end),
          Map.update(cy, div(y, @fuzz), 1, fn c -> c + 1 end)}
      end)
      Enum.any?(cts_x, fn {_x, c} -> c > thresh_ctx end) and 
        Enum.any?(cts_y, fn {_y, c} -> c > thresh_cty end)
    end)
  end

It worked beautifully on my input data with a threshold of 4 (roughly speaking, the threshold here is
the number of standard deviations away from the mean of robot counts in a horizontal or vertical
stripe, assuming Poisson counting statistics for the robots landing in the stripe).

1 Like

Part 2 very dissatisfying. I tried a couple of approaches that I was clearly not smart enough to implement. I tried checking that a high percentage of bots were contained within a triangle using barycentric coordinates to confirm presence within the triangle. I think I either set the threshold too high or screwed up the implementation b/c it would run forever without finding it.

I then tried to find a state where a high percentage of bots had mirror image bots across a line of reflection. I think I screwed this up by again setting the threshold too high.

Finally I just looked for a state where at least 20 bots were in at least one column and 20 bots were in at least one row. That worked but feels like blind luck.

EDIT: Yeah, setting the threshold to a super majority of bots was way off. Setting it to 20% of the bots having mirrored bots is good enough. I think the problem was worded such that it is misleading to say “most of the robots” arrange into the Christmas tree. Pretty sure my tree has fewer than half the bots in it.

EDIT 2: Got my barycentric triangle method working. It requires a slightly higher threshold than the mirrored bots method to avoid false positive.

def find_tree() do
      Stream.repeatedly(fn -> Robot.move_all(1) end)
      |> Enum.reduce_while(1, fn :ok, time ->
        if tree_shape?() do
          {:halt, time}
        else
          {:cont, time + 1}
        end
      end)
    end

    defp tree_shape?() do
      min_bots =
        case :ets.lookup_element(:board, :bot_count, 2, false) do
          false ->
            x = Robot.bot_count()
            :ets.insert(:board, {:bot_count, x})
            0.4 * x

          x ->
            0.4 * x
        end

      # min_bots in symmetric pattern bounded by isosceles triangle
      # triangle has area == min_bots and assume ht == base
      # bots inside triangle have mirror or are on center line
      botspots =
        all_occupants()
        |> Enum.map(fn {pos, _name} -> pos end)
        |> MapSet.new()

      [ht, wd] = [:height, :width] |> Enum.map(fn k -> :ets.lookup_element(:board, k, 2) end)
      reflect = 50
      triangle = [{reflect, 30}, {30, ht - 30}, {wd - 30, ht - 30}]

      # botspots |> long_line?()
      botspots
      |> Stream.filter(fn pos ->
        # has_mirror?(pos, reflect, botspots)

        in_triangle?(pos, triangle)
      end)
      |> Enum.reduce_while(0, fn _pos, acc ->
        print_board()

        if acc < min_bots do
          {:cont, acc + 1}
        else
          {:halt, acc}
        end
      end)
      |> Kernel.>=(min_bots)
    end

    @doc "Use barycentric coordinates (alpha, beta, gamma) to determine if point lies within
    the bounding triangle (simplex). The barycentric coordinates with any value of zero are on the
    simplex lines. Any negative values are outside the simplex. The values are the ratio of the 
    area of the sub triangle formed by the point and any two vertices of the simplex to the area
    of the simplex."
    def in_triangle?({px, py}, [{ax, ay}, {bx, by}, {cx, cy}]) do
      area_triangle = abs(ax * (by - cy) + bx * (cy - ay) + cx * (ay - by)) / 2
      alpha = abs((bx - px) * (cy - py) - (cx - px) * (by - py)) / (2 * area_triangle)
      beta = abs((cx - px) * (ay - py) - (ax - px) * (cy - py)) / (2 * area_triangle)
      gamma = 1 - alpha - beta

      [alpha, beta, gamma] |> Enum.all?(fn n -> n >= 0 and n <= 1 end)
    end

    def has_mirror?({reflect, _y}, reflect, _botspots), do: true

    def has_mirror?({x, y}, reflect, botspots) do
      opposite = reflect * 2 - x

      MapSet.member?(botspots, {opposite, y})
    end

    def long_line?(botspots) do
      print_board()

      botspots
      |> Enum.group_by(fn {x, _} -> x end)
      |> Enum.any?(fn {_, v} -> length(v) > 20 end) and
        botspots
        |> Enum.group_by(fn {_, y} -> y end)
        |> Enum.any?(fn {_, v} -> length(v) > 20 end)
    end
1 Like

Can’t edit my post further due to time limit.

EDIT 3: Thinking about this this morning again, I’m wondering if anyone tried a pure math approach? I’m thinking if you calculate the time for each bot to individually get to the midline, then calculate the cycle for each bot to return to midline, you can find a common time with some minimum number of bots in the midline.

(time_0 * vx) + start_x % width == mid_x 
# gives time to reach midline first by solving for time_0
(time_c * vx) + mid_x % width == mid_x 
# gives time to cycle back to midline by solving for time_c
# find a subset of bots_all of size i where i is the threshold number of bots specified such that subset_bots(0..i) satisfies:
time_00 + n_0 * time_c0 == time_01 + n_1 * time_c1 ... == time_0i + n_i * time_ci  for bots 0..i

I’ve forgotten (or possibly never knew) the math on how to solve that last bit though.