Defining Routes and Tickets in Ticket to Ride with Elixir Macros
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.