Price of Immutability

Hi all,

I am trying to come up with a Room struct which is able to have paths in between them. This is what i have so far:

defmodule Room do
  defstruct name: "", description: "", paths: %{}

  def go(room, direction) do
    Map.get(room.paths, direction, nil)
  end

  def add_paths(room, paths) do
    new_paths = Map.merge(room.paths, paths)
    %{room | paths: new_paths}
  end

end

Test

defmodule RoomTest do
  use ExUnit.Case

  test "map" do
    start = %Room{name: "Start", description: "You can go west and down a hole."}
    west = %Room{name: "Trees", description: "There are trees here, you can go east."}
    down = %Room{name: "Dungeon", description: "It's dark down here, you can go up"}
    start = Room.add_paths(start, %{'west' => west, 'down' => down})
    west = Room.add_paths(west, %{'east' => start})
    down = Room.add_paths(down, %{'up' => start})
    assert Room.go(start, 'west') == west
    assert Room.go(start, 'west').go('east') == start
    assert Room.go(start, 'down').go('up') == start
  end
end

Test fails because the method call start = Room.add_paths(start, %{'west' => west, 'down' => down}) would have recorded an “old version” of west and down rooms by the end of the test execution.

Any suggestion how this kinda work is done in Elixir?

A couple of things are wrong, they might help you fix this code.

1- you are using charlist (using single quote strings) and binaries (double quote strings). You should only use the latter. Also, think about using atoms instead.

2- That looks suspicious/object-ish to me. Room.go(…).go(…) will not work.

This would be correct:
start |> Room.go('west') |> Room.go('east')

Separate the data itself from the relations. You could have two data structures - a map of room ids to rooms and a set for holding edges between room ids.

3 Likes

My own room module looks like this:

defmodule Adventure.Rooms do
  import Supervisor.Spec
  alias Adventure.Entity.Room

  @rooms_array [
    %Room{
             id: :living_room,
          title: "Living Room",
    description: "You are in the living-room of a wizards house.\nThere is a wizard snoring loudly on the couch.",
          exits: [ west: {"door", :garden },
                     up: {"stairway", :attic } ]},

    %Room{
             id: :garden,
          title: "Garden",
    description: "You are in a beautiful garden. There is a well in front of you.",
          exits: [ east: {"door", :living_room } ]},

    %Room{
             id: :attic,
          title: "Attic",
    description: "You are in the attic of the wizards house.\nThere is a giant welding torch in the corner.",
          exits: [ down: {"ladder", :living_room } ]}
  ]

  def start_link() do
    children = Enum.map(@rooms_array, fn(room_map) ->
      worker(Room, [room_map], id: room_map.id)
    end)

    Supervisor.start_link(children, strategy: :one_for_one)
  end
end

The room structure is:

defstruct id: :nowhere,
       title: "In The Ether",
 description: "You are in a formless gray space.\nThe silence is deafening",
       exits: []

This is simply data storage. The user’s location is actually tracked as part of the global game state using the id of the room that the player is in. That code looks like this:

def apply_action(game, %{action: :go, direction: direction}) do
  current_room = Map.whereis_name(game.location)

  exits = Exits.exit_list(current_room)

  move_player(game, Keyword.get(exits, direction, :none))
end

defp move_player(game, {_description, destination}) do
  room = Adventure.Map.whereis_name(destination)
  {%Adventure.Game{game | location: destination}, [ Adventure.System.Renderer.render_room(room) ]}
end

defp move_player(game, :none) do
  { game, [ "You cannot move in that direction." ] }
end
1 Like

I also wonder if using the erlang built-in (di)graph module would be useful too.

2 Likes

Thank you all again for the generous contribution.
My take away from this exercise is:

  1. Its probably a good idea not to put structs in other structs as the instance may become “stale” along the way (sneakily)
  2. Immutability forces us to keep structs and relationship between the structs separate (separation of concerns?)

As usual comments and corrections are welcome

Tweaked code after feedbacks

defmodule Room do
  defstruct name: "", description: "", paths: %{}

  def go(room, rooms, direction) when is_atom(room) do
    paths = (Map.fetch!(rooms, room)).paths
    Map.fetch!(paths,direction)
  end

  def add_paths(room, paths) do
    new_paths = Map.merge(room.paths, paths)
    %{room | paths: new_paths}
  end
end
defmodule RoomTest do
  use ExUnit.Case
  doctest Room

  test "room construction" do
    gold = %Room{name: "GoldRoom", description: "This room has gold in it you can grab. There's a door to the north."}
    assert gold.name == "GoldRoom"
    assert gold.paths == %{}
  end

  test "room paths" do
    center = %Room{name: "Center", description: "Test room in the center."}
    north = %Room{name: "North", description: "Test room in the south."}
    south = %Room{name: "South", description: "Test room in the south."}
    center = Room.add_paths(center, %{"north" => :north, "south" => :south})

    rooms = %{center: center, north: north, south: south}

    assert :center |> Room.go(rooms, "north") == :north
    assert :center |> Room.go(rooms, "south") == :south
  end

  test "map" do
    start = %Room{name: "Start", description: "You can go west and down a hole."}
    west = %Room{name: "Trees", description: "There are trees here, you can go east."}
    down = %Room{name: "Dungeon", description: "It's dark down here, you can go up"}

    start = Room.add_paths(start, %{"west" => :west, "down" => :down})
    west = Room.add_paths(west, %{"east" => :start})
    down = Room.add_paths(down, %{"up" => :start})

    rooms = %{start: start, west: west, down: down}

    assert :start |> Room.go(rooms, "west") == :west
    assert :start |> Room.go(rooms, "west") |> Room.go(rooms, "east") == :start
    assert :start |> Room.go(rooms, "down") |> Room.go(rooms, "up") == :start
  end
end

Well, it is perfectly fine to create a collection of Rooms and access them this way. Such a collection could be a list, it could be a map, or it could be something else, like an Array or Matrix struct.

But creating a Room whose exits point to other rooms does not make sense if you want those rooms to point back. Immutability means that we cannot create cyclic data structures (An exception is immutability in a lazily evaluated language, which allows you to ‘tie the knot’).