How do I... Add randomness?
The bot army is meant to imitate real users, but what good is it if each bot is doing the exact same thing? If each bot uses the same behavior tree template, how do you make each bot act uniquely? The answer, obviously, is to include a little randomness in your bots.
The bot army gives you quite a few ways to do this.
Generating random values
First off, you'll probably need a way to generate random data so that all of your
write actions will be unique. Elixir has two main ways of doing this. You can get a
random value between 1 and n (inclusive) with :rand.uniform(n)
(this looks a little
weird because it is actually Erlang), and you can choose a random element from a list
with Enum.random([1,2,3,4])
(Enum.random(1..4)
is equivalent).
Here is a quick and dirty random string generator that makes random strings of random lengths.
def random_string(length \\ :rand.uniform(100)) do
fn -> Enum.random(?a..?z) end
|> Stream.repeatedly()
|> Enum.take(n)
|> to_string()
end
You may want to look into the StreamData library for more (and better) random value generators. Remember that you can use default parameters as a good place to use random values.
Compile-time vs. run-time considerations
When using random functions, you need to be careful to know if that function will run every time you think it will. Depending on where a random function is in your code, it might only get ran once at compile time, or it might get ran each time the execution path reaches it. Here are some examples:
# in helpers
def get_random_number, do: :rand.unique(10)
def tree do
random_1 = :rand.unique(10)
# functionally equivalent:
random_2 = get_random_number()
Node.repeat_n(
random_1,
Node.sequence([
action(BotArmy.Actions, :log, [random_2]),
action(MyActions, :log_a_random_number),
action(MyActions, :log_n_or_random_number),
action(BotArmy.Actions, :wait, [get_random_number()]),
action(BotArmy.Actions, :wait, [0, 10])
])
)
end
# in actions
def log_a_random_number(_context) do
random_3 = get_random_number()
Logger.info(random_3)
end
def log_n_or_random_number(_context, n \\ get_random_number()) do
Logger.info(n)
end
A bot's tree only gets ran once, so random_1
and random_2
will only get set to a
random number once (per bot). This means the first :log
action will log the same
random number each time it loops.
:log_a_random_number
will log a different number each time because it gets called
every time the bot enters that action. The same goes for :log_n_or_random_number
(remember that default parameters get executed if needed each time their function is
executed).
The first :wait
will wait the same time every loop (remember the tree definition
does not call any actions, it only describes how to call them, which means the
arguments get evaluated when the tree gets evaluated).
By contrast, the second :wait
will have a different wait time every loop, because
the random selection within that range happens in the action.
Randomizing the trees
Making each bot do different things at different times, is where behavior trees really shine.
You can use Node.random
and Node.random_weighted
to give bots a chance to
"choose" which subtree to enter.
A good pattern for creating real-user-like bots is to build a tree that logs in, then enters a continual loop that switches between various subtrees. You can gate those subtrees to check for required conditions, and you can make each subtree loop for a while before bouncing back up.
In order to make each subtree repeat, you could use a fixed number in
Node.repeat_n
, or you could supply a random n as noted above. You can also use
BotArmy.Actions.succeed_rate
to randomize the chance to loop again.
For example, a succeed rate of 0.75 would "choose" to loop three out of four times on
average. This might seem unintuitive, especially if you are trying to control the
number of loops. It helps to think of it as looking at a real user who has just
finished one type of action, and guessing what the likelihood is that that user would
decide to do the same type of action again.
Tweaking all of these approaches is what will make your bots feel organic. Consider how your real users decide what to do next. Remember that real users are a lot slower than bots, so add appropriate random "idle" periods. And don't forget to also have your bots check the state of the world periodically to respond to new data instead of pure randomness.
Here is an example of all of these techniques:
def tree do
Node.sequence([
log_in_tree(),
# Main loop
Node.repeat_until_fail(
Node.sequence([
# Check high priority actions first, but move on regardless of outcome
Node.always_succeed(check_for_and_respond_to_new_notifications_tree()),
# We don't want the outcome of any subtree to break the main loop
Node.always_succeed(
Node.random_weighted([
{read_some_posts_tree(), 10},
{make_some_replies_tree(), 5},
{start_a_new_post_tree(), 2},
# Go "idle" for up to 5 minutes
{action(BotArmy.Actions, :wait, [0, 60 * 5]), 1}
])
),
# take a short breather (up to 10 seconds)
action(BotArmy.Actions, :wait, [0, 10]),
# Maybe log out (5% of the time)
# This is the only outcome that can exit main loop
# We negate the outcome because if logout succeeds, we need to fail in order
# to exit the loop, but if log out fails, we need to succeed to keep looping
Node.negate(
Node.sequence([
action(BotArmy.Actions, :succeed_rate, [0.05]),
action(User, :log_out)
])
)
])
)
])
end
# example subtree
def make_some_replies_tree() do
Node.repeat_until_fail(
Node.sequence([
# leave subtree right away if it isn't actionable
action(Posts, :do_any_exist?),
action(Posts, :select_random_post),
action(Posts, :reply_to_selected_post),
# take a breath
action(BotArmy.Actions, :wait, [5]),
# loop 70% of the time
action(BotArmy.Actions, :succeed_rate, [0.7])
])
)
end
Repeatability
Random is great, but you might be concerned about being able to repeat a specific test run exactly. Luckily, the random functions described above all work off of a seed, so if you supply a (random) seed at runtime, you can repeat the tests by using the same seed:
# set a seed before doing any random calls
:rand.seed(:exsplus, {1, 2, 3})
Enum.map(1..5, fn _ -> :rand.uniform(10) end)
# [4, 3, 8, 1, 6]
# if you use the seed again, you get the same results
:rand.seed(:exsplus, {1, 2, 3})
Enum.map(1..5, fn _ -> :rand.uniform(10) end)
# [4, 3, 8, 1, 6]
# a different seed gives different results
:rand.seed(:exsplus, {9, 9, 9})
Enum.map(1..5, fn _ -> :rand.uniform(10) end)
# [9, 10, 3, 4, 8]
Keep in mind that external factors that you can't control (like the state of the system under test, or latency in your requests) might also affect your tests, even if the random generator is doing the same thing.