Defining Routes and Tickets in Ticket to Ride with Elixir Macros

By Angelo Lakra on


A while back I decided that using macros to define the routes and tickets for my implementation of ticket to ride in Elixir would be a good idea because of the data structure that I had envisioned at the time.

The macro usage looks like this:

defmodule TtrCore.Board.Routes do
  use TtrCore.Board.Router

  defroute Atlanta, to: Charleston,  distance: 2
  defroute Atlanta, to: Miami,       distance: 5, trains: [:passenger]
  defroute Atlanta, to: Raleigh,     distance: 2, trains: [:any, :any]
  defroute Atlanta, to: Nashville,   distance: 1
  defroute Atlanta, to: New.Orleans, distance: 4, trains: [:box, :tanker]
  defroute Boston,  to: Montreal,    distance: 2, trains: [:any, :any]
  defroute Boston,  to: New.York,    distance: 1, trains: [:coal, :box]
  
  # And more routes...
end

I realized that my implementation was far too complex for the calculations needed of the game, so I scrapped the data structure for a much flatter and simpler one.

Here's the original:

defmodule TicketToRide.Router do
  alias TicketToRide.{NoOriginFoundError,
                      NoDestinationSpecifiedError,
                      NoDistanceSpecifiedError}

  defmodule Origin do
    defstruct [
      name: nil,
      destinations: %{}
    ]
  end

  defmodule Destination do
    defstruct [
      name: nil,
      distance: 0,
      trains: MapSet.new
    ]
  end

  defmacro __using__(_opts) do
    quote do
      import unquote(__MODULE__)
      Module.register_attribute __MODULE__, :origins, accumulate: false, persist: false
      Module.put_attribute __MODULE__, :origins, %{}
      @before_compile unquote(__MODULE__)
    end
  end

  # API

  defmacro defroute(name, args \\ []) do
    quote do
      @origins Map.merge(@origins, update_origins(@origins, unquote(name), unquote(args)))
    end
  end

  defmacro __before_compile__(_env) do
    quote do
      def all, do: @origins
      def get(name), do: Map.fetch(@origins, name)
    end
  end

  def update_origins(origins, name, args) do
    source = get(origins, name, autocreate: true)

    destination = get(origins, args[:to], autocreate: true)
    destination_options = [
      to: name,
      distance: args[:distance],
      trains: args[:trains]
    ]

    %{ name => update(source, args),
       args[:to] => update(destination, destination_options) }
  end

  # Private

  defp get(origins, name, opts \\ [autocreate: false]) do
    case Map.fetch(origins, name) do
      {:ok, origin} -> origin
      :error -> get_on_error(name, opts[:autocreate])
    end
  end

  defp get_on_error(name, autocreate) do
    if autocreate do
      %Origin{name: name}
    else
      raise NoOriginFoundError, name: name
    end
  end

  defp update(origin, args) do
    destination_name = extract_destination_name(origin, args)
    trains = extract_trains(args)

    destination = update_destination(origin, destination_name, trains, args)
    destinations = Map.put(origin.destinations, destination_name, destination)

    %{origin | destinations: destinations}
  end

  defp update_destination(origin, destination, trains, args) do
    case Map.fetch(origin.destinations, destination) do
      {:ok, dest} ->
        %{dest | trains: MapSet.union(dest.trains, trains)}
      :error ->
        distance = extract_distance(origin, args)
        %Destination{name: destination, distance: distance, trains: trains}
    end
  end

  defp extract_destination_name(origin, args) do
    case args[:to] do
      nil -> raise NoDestinationSpecifiedError, from: origin.name
      destination -> destination
    end
  end

  defp extract_distance(origin, args) do
    case args[:distance] do
      nil -> raise NoDistanceSpecifiedError, from: origin.name, to: args[:to]
      distance -> distance
    end
  end

  defp extract_trains(args) do
    case args[:trains] do
      nil -> MapSet.new([:any])
      trains -> MapSet.new(trains)
    end
  end
end

And the new:

defmodule TtrCore.Board.Router do
  @moduledoc false

  alias TtrCore.Board.Route

  defmacro __using__(_opts) do
    quote do
      import unquote(__MODULE__)
      Module.register_attribute __MODULE__, :routes, accumulate: true, persist: true
      @before_compile unquote(__MODULE__)
    end
  end

  defmacro defroute(from, args \\ []) do
    to       = args[:to]
    distance = args[:distance]
    trains   = args[:trains] || [:any]

    quote do
      Enum.each(unquote(trains), fn train ->
        @routes {unquote(from), unquote(to), unquote(distance), train}
      end)
    end
  end

  defmacro __before_compile__(_env) do
    quote do
      @spec get_routes() :: [Route.t]
      def get_routes, do: @routes

      @spec get_claimable_routes([Route.t]) :: [Route.t]
      def get_claimable_routes(claimed), do: @routes -- claimed
    end
  end
end

The main changes were around the data structure to build up the module attribute @routes. I was using a nested map within a list and now I am justing using a list of tuples.

The reason I was trying to use a nested map was because I wanted to map every city to a list of possible destinations. This sounded like a good idea for figuring out which cities on the map are connected to another to calculate the final score, but turned out to be irrelevant for most of the game.

There were also features for custom error checking at compile time. If I did not follow the format of the macro, the macro would throw me a relevant error message about what I did wrong. But since the data is simple, the compiler's AST checking was more than adequate to indicate where I went awry.

I only needed to calculate the longest route and ticket points when I calculated the final score. In both the old and new implementation, a map/reduce would be needed to calculate which player got the longest route. I reasoned that less data complexity would lead to less algorithmic complexity.

And that's why I changed it.