XFsm

DialyzerTestCodecov

XFsm is a declarative finite state machine library for Elixir.

It uses event-driven programming, state machines and actors to handle complex logic in predictable and robust ways.

It provides very easy to use APIs which makes looking at a declaration very easy to understand.

Installation

The package can be installed by adding xfsm to your list of dependencies in mix.exs:

def deps do
  [
    {:xfsm, "~> 0.6.0-rc.4"}
  ]
end

Documentation

API documentation is available at https://hexdocs.pm/xfsm

Create a simple machine

defmodule Counter do
  use XFsm.Actor
  use XFsm.Machine

  import XFsm.Actions

  context %{count: 0}

  root do
    on :inc do
      action :assign, &inc/1
    end

    on :dec do
      action :assign, &dec/1
    end

    on :set do
      action :assign, &set/1
    end
  end

  @spec inc(XFsm.action_arg()) :: map()
  def inc(arg) do
    %{context: ctx} = arg

    %{count: ctx.count + 1}
  end

  @spec dec(XFsm.action_arg()) :: map()
  def dec(arg) do
    %{context: ctx} = arg

    %{count: ctx.count - 1}
  end

  @spec set(XFsm.action_arg()) :: map()
  def set(arg) do
    %{event: event} = arg

    %{count: event.value}
  end
end

alias XFsm.Actor

{:ok, pid} = Counter.start_link()

Actor.subscribe(pid, fn %{context: %{count: count}} ->
  IO.puts(count)
end)

Actor.send(pid, %{type: :inc})
# logs 1

Actor.send(pid, %{type: :dec})
# logs 0

Actor.send(pid, %{type: :set, value: 10})
# logs 10
---
title: Counter machine
---
flowchart LR

inc --> machine
dec --> machine
set --> machine
machine --- inc
machine --- dec
machine --- set

machine@{shape: diam, label: "Machine"}
dec@{shape: event, label: "DEC\nassigns(...)"}
inc@{shape: event, label: "INC\nassigns(...)"}
set@{shape: event, label: "SET\nassigns(...)"}

Create a complex machine

defmodule TicTacToe do
  use XFsm.Actor
  use XFsm.Machine

  defmodule Board do
    defstruct squares: {nil, nil, nil, nil, nil, nil, nil, nil, nil}

    @tags [:x, :o]

    def square(%__MODULE__{} = board, index)
        when is_integer(index) and index >= 1 and index <= 9 do
      elem(board.squares, index - 1)
    end

    def empty?(%__MODULE__{} = board, index), do: square(board, index) == nil

    def has_empty_square?(%__MODULE__{} = board) do
      Enum.any?(0..8, &(elem(board.squares, &1) == nil))
    end

    def draw?(%__MODULE__{} = board) do
      not has_empty_square?(board) and
        not (won?(board, :x) or won?(board, :o))
    end

    def won?(%__MODULE__{} = board, tag) when tag in @tags do
      %{squares: squares} = board

      siblings?(:x, squares, tag) or
        siblings?(:y, squares, tag) or
        siblings?(:z, squares, tag)
    end

    def put(%__MODULE__{} = board, index, tag)
        when is_integer(index) and index >= 1 and index <= 9 and tag in @tags do
      case empty?(board, index) do
        false ->
          board

        true ->
          squares = put_elem(board.squares, index - 1, tag)

          %__MODULE__{board | squares: squares}
      end
    end

    def to_iodata(%__MODULE__{} = board) do
      Enum.reduce(0..2, [], fn i, acc ->
        row = Enum.map_join(0..2, "|", &(elem(board.squares, i * 3 + &1) || "-"))

        Enum.concat(acc, ["|", row, "|\n"])
      end)
    end

    defp siblings?(:x, squares, tag) do
      Enum.reduce_while(0..2, false, fn i, acc ->
        fun? = &(elem(squares, i * 3 + &1) == tag)

        case Enum.all?(0..2, fun?) do
          true -> {:halt, true}
          false -> {:cont, acc}
        end
      end)
    end

    defp siblings?(:y, squares, tag) do
      Enum.reduce_while(0..2, false, fn i, acc ->
        fun? = &(elem(squares, i + &1 * 3) == tag)

        case Enum.all?(0..2, fun?) do
          true -> {:halt, true}
          false -> {:cont, acc}
        end
      end)
    end

    defp siblings?(:z, squares, tag) do
      fun? = &(elem(squares, &1) == tag)

      Enum.all?(0..8//4, fun?) or Enum.all?(2..6//2, fun?)
    end
  end

  initial :x
  context %{input: i}, do: context_from_input(i)

  state :x do
    on :move do
      target :o
      guard :can_move?, %{player: :x}
      action :assign, &make_move/1
    end
  end

  state :o do
    on :move do
      target :x
      guard :can_move?, %{player: :o}
      action :assign, &make_move/1
    end
  end

  state :end do
    # You most likely want to persist the game state and terminate the
    # process, assuming you're building an online multiplayer game.
  end

  root do
    always do
      target :end
      guard :won?, %{player: :o}
      action :assign, %{winner: :o}
    end

    always do
      target :end
      guard :won?, %{player: :x}
      action :assign, %{winner: :x}
    end

    always do
      target :end
      guard :drawn?
    end
  end

  def can_move?(
        %{context: %{x: x}, event: %{ref: x, square: s}} = arg,
        %{player: :x}
      )
      when is_integer(s) and s >= 1 and s <= 9 do
    %{context: %{board: board}, event: %{square: square}} = arg

    Board.empty?(board, square)
  end

  def can_move?(
        %{context: %{o: o}, event: %{ref: o, square: s}} = arg,
        %{player: :o}
      )
      when is_integer(s) and s >= 1 and s <= 9 do
    %{context: %{board: board}, event: %{square: square}} = arg

    Board.empty?(board, square)
  end

  def won?(
        %{self: %{state: _}, context: %{board: _}} = arg,
        %{player: player} = params
      )
      when player in [:x, :o] do
    %{self: %{state: state}, context: %{board: board}} = arg
    %{player: player} = params

    state != :end and Board.won?(board, player)
  end

  def drawn?(%{self: %{state: _}, context: %{board: _}} = arg) do
    %{self: %{state: state}, context: %{board: board}} = arg

    state != :end and Board.draw?(board)
  end

  def make_move(%{context: %{x: x} = c, event: %{ref: x, square: s}})
      when is_integer(s) and s >= 1 and s <= 9 do
    %{board: board} = c
    board = Board.put(board, s, :x)

    %{c | board: board}
  end

  def make_move(%{context: %{o: o} = c, event: %{ref: o, square: s}})
      when is_integer(s) and s >= 1 and s <= 9 do
    %{board: board} = c
    board = Board.put(board, s, :o)

    %{c | board: board}
  end

  defp context_from_input(%{x: x, o: o})
       when is_reference(x) and is_reference(o) do
    %{x: x, o: o, winner: :none, board: %Board{}}
  end
end

alias XFsm.Actor

# We're using references so that a player does not make a move for the other.
# These can be user id assuming you're building an online multiplayer game.
x = make_ref()
o = make_ref()

{:ok, pid} = TicTacToe.start_link(input: %{x: x, o: o})

Actor.subscribe(pid, fn %{context: %{board: b, winner: w}} ->
  iodata = TicTacToe.Board.to_iodata(b)

  IO.puts([iodata | ["\n", "winner: ", Atom.to_string(w)]])
end)

:ok = Actor.send(pid, %{type: :move, ref: x, square: 5})
:ok = Actor.send(pid, %{type: :move, ref: o, square: 3})
:ok = Actor.send(pid, %{type: :move, ref: x, square: 6})
:ok = Actor.send(pid, %{type: :move, ref: o, square: 1})
:ok = Actor.send(pid, %{type: :move, ref: x, square: 4})

XFsm.Actor.snapshot(pid)
stateDiagram-v2

state x_if_end <<choice>>
state o_if_end <<choice>>

state "player x" as x
state "player o" as o

[*] --> x
x --> x_if_end
x_if_end --> end: if pos = draw or win
x --> o: move

o --> o_if_end
o_if_end --> end: if pos = draw or win
o --> x: move

License

MIT