← Bot Army Cookbook

How do I... Repeat actions?

You probably won't get far in writing a bot before you need some kind of a loop. Long-running bots run in a loop by their very nature, and even linear integration tests often need to repeat certain actions or sequences. The bot army provides a number of ways to build loops and repeat actions.

When a tree "finishes"

When a bot gets to the end of its behavior tree, it will start again from the beginning. The only way to actually make a bot stop is to have an action return an outcome of :done or {:error, reason}. Be aware that when running a load test, the runner will start a new bot in its place. Also note that the integration test runner automatically wraps your trees with a "done" action after it to prevent them from repeating.

# alternates logging in and making a post forever
# if either action fails, it will start from the top again
def tree do
  Node.sequence([
    log_in(),
    make_post()
  ])
end

Sometimes you might want this, but other times you may only want a portion of the tree to repeat.

Using the repeat_n node

You can nest any action or subtree under the repeat_n node, and it will be repeated the specified number of times, regardless of its outcome. Note the done action to stop the bot.

# logs in, then makes 5 posts, then stops
def tree do
  Node.sequence([
    log_in(),
    Node.repeat_n(5, make_post()),
    action(BotArmy.Actions, :done)
  ])
end

This is the most straightforward approach, but sometimes you want to repeat steps conditionally (more like a "while loop").

Using the :continue outcome

Bots traverse their behavior trees based on :fail and :succeed node outcomes. But there is actually another outcome that tells the behavior tree to stay on the same node it is already on: :continue.

You can make an action repeat simply by returning that. You would probably want to put some logic in there to make it eventually return a different outcome, otherwise your bot would repeat that action forever.

While this works fine, it shifts the repeat logic into the action instead of the tree, which makes the action less atomic, and thus less flexible and reusable. Controlling loops via the tree is generally a better approach, plus you can repeat full subtrees instead of a single action.

Conditional repeats

The main approach handling repeat logic in the tree is to use one of repeat_until_fail or repeat_until_succeed. These can be a little tricky to use, and might require a subtree with some logic in it (see the conditionally run actions recipe), but the basic concept is simple:

# logs in, then likes posts until none are left
def tree do
  Node.sequence([
    log_in(),
    Node.repeat_until_fail(
      Node.sequence([
        select_next_post(),
        Node.always_succeed(like_selected_post())
      ])
    ),
    action(BotArmy.Actions, :done)
  ])
end

Notice how this uses always_succeed to ensure that only select_next_post will break the loop.

Limiting retries

You might have noticed in the previous example that if select_next_post never fails, you will be stuck in an infinite loop. Sometimes you don't know if an action will eventually fail (or succeed), especially when you are doing validation style actions, so you need a way limit how many times you loop.

One approach is to store a counter in the bot's context, and to be sure to increment it every time you loop, and also to break the loop if it goes over your limit. Keep in mind that actions should be as simple and atomic as possible, so this is a slippery slope, but you can encapsulate this retry logic in a helper like this:

def with_max_retries(success?, max_retries, context)
    when is_boolean(success?) and
         is_integer(max_retries) and
         max_retries > 0 do
  attempts = Map.get(context, :attempts, max_retries)
  cond do
    success? ->
      {:succeed, attempts: max_retries}

    attempts > 0 ->
      # optional, give some time before trying again
      Process.sleep(500)
      {:continue, attempts: attempts - 1}

    :else ->
      {:fail, attempts: max_retries}
  end
end

You can use it in your actions by giving it a boolean condition and letting it decide what outcome to return:

def validate_has_n_posts(context, expected_num) do
  post_count = get_posts() |> Enum.count()

  (post_count == expected_num)
  |> with_max_retries(5, context)
end