Build FollowBot · Read-only tasks, then plugin integration

Build FollowBot · 2. Tasks and plugin integration

Keep the same bot: expose its task manager, choose priorities and let a plugin extend the run.

After this lesson

You can add a task to your follower, let a plugin insert work safely and explain how a handled result affects every lower-priority task.

Build it yourself · selected, product-specific fragments · no finished bot download. Each checkpoint explains what to implement and how to check it.

Continue YourFollowBot, not another isolated bot#

Keep the project and lifecycle from part 1. Your host already owns the session; now move decisions into tasks instead of growing a giant Tick method.

An ITask is not discovered and run independently. Your bot or an enabled plugin creates it, registers it with a TaskManager and supplies its Start/Tick/Run/Stop callbacks.

Design the priority list before writing behavior#

Register the base tasks with TaskManager.Add and check each boolean result. Give them unique, stable Names: a plugin locates an insertion point by Name, not C# type.

Build the list before PluginManager.Start; start the tasks after plugins have inserted theirs. Changing this order can leave inserted tasks without initialization.

UntilHandled stops the pass at the first true result. On the next pass, evaluation starts again from the top. This is cooperative priority, not parallel execution or automatic interruption of a long Run method.

IdleReportTask should return false when its timer is not due. A fallback that always returns true is not required: the host still yields even if all tasks return false.

design sketch
Session guard and context invalidation (in the host, every Tick)
  ↓ ready pass / hook_ingame
LeaderStatusTask       — report a changed leader or area, then become idle
[optional CombatTask]  — only after deliberate routine integration
FollowLeaderTask       — one bounded follow decision
IdleReportTask         — throttled diagnostics, no input
Check your result

A leader-change report can take one turn, but does not starve FollowLeaderTask on every turn afterward.

Write one read-only task first#

This small task is a worked example, not a finished follower. Func<Player> means a function that obtains the leader when called; Action<string> means a function that writes a report. Neither stores a Player for the next turn.

Build the constructor call as new LeaderStatusTask(() => FindVisibleLeader(), text => Log.Info(text)). FindVisibleLeader is the selected fragment in part 3. Until you add it, use () => null: the task compiles and stays idle, so you can finish the host first.

  1. Register one instance with TaskManager.Add before plugin startup and check the result. Keep the stable Name: insertion points use that name.
  2. Run reports only a changed name or area and returns true for that one turn. The next unchanged call returns false, allowing lower tasks to run.
  3. Start, Stop and follow.context-reset clear values. The host must still send the reset on an observed context change; this task does not discover every transition by itself.
  4. Next write your own IdleReportTask with a persistent WaitTimer field. Return false while not due. Use the inventory recipe for a complete timer-backed task.

Where this goes: LeaderStatusTask.cs beside your bot. Imports: System, System.Threading.Tasks, DreamPoeBot.Loki.Bot, DreamPoeBot.Loki.Game, DreamPoeBot.Loki.Game.Objects. Supply a fresh-player query and a logging callback in its constructor.

C# · selected fragment
internal sealed class LeaderStatusTask : ITask
{
    private readonly Func<Player> _findLeader;
    private readonly Action<string> _report;
    private string _previousName, _previousArea;
    private uint _previousHash;
    public LeaderStatusTask(Func<Player> findLeader, Action<string> report)
    { _findLeader = findLeader; _report = report; }
    public string Name => "LeaderStatusTask";
    public string Description => "Report a changed visible leader or area once.";
    public string Author => "Your name";
    public string Version => "1.0.0";
    private void Reset() { _previousName = null; _previousArea = null; _previousHash = 0; }
    public void Start() => Reset();
    public void Stop() => Reset();
    public void Tick()
    {
        if (!LokiPoe.IsInGame || LokiPoe.Me == null || LokiPoe.Me.IsDead ||
            LokiPoe.InstanceInfo.IsGamePaused) Reset();
    }
    public Task<bool> Run()
    {
        if (!LokiPoe.IsInGame || LokiPoe.Me == null || LokiPoe.Me.IsDead ||
            LokiPoe.InstanceInfo.IsGamePaused) { Reset(); return Task.FromResult(false); }
        var leader = _findLeader();
        var hash = LokiPoe.LocalData.AreaHash;
        var area = LokiPoe.CurrentWorldArea?.Id;
        if (leader == null || hash == 0 || string.IsNullOrEmpty(area))
        { Reset(); return Task.FromResult(false); }
        if (leader.Name == _previousName && hash == _previousHash && area == _previousArea)
            return Task.FromResult(false);
        _report("[LeaderStatus] " + leader.Name + " in " + area);
        _previousName = leader.Name; _previousHash = hash; _previousArea = area;
        return Task.FromResult(true);
    }
    public MessageResult Message(Message message)
    {
        if (message.Id != "follow.context-reset") return MessageResult.Unprocessed;
        Reset();
        return MessageResult.Processed;
    }
    public Task<LogicResult> Logic(Logic logic) => Task.FromResult(LogicResult.Unprovided);
}
Check your result

With a fresh leader query: first observation → true, unchanged observation → false, changed area → true. With () => null: always false, no log.

Make it yours

Temporarily make LeaderStatusTask return true every time. Predict which lower task becomes silent, observe that with read-only logs, then fix the idle path.

Expose the task manager and forward requests#

GetTaskManager lets a plugin retrieve the same manager used by your run. Creating a second manager here would make insertion appear successful without changing the actual bot.

DPB supplies Message, AddOutput, SendMessage and ProvideLogic. The string GetTaskManager is an integration convention. ITaskManagerHolder found in some external projects is their interface, not a required DPB interface to copy.

Unknown messages still reach tasks. A handled bot query stays Processed even if no task handles it. Logic forwarding preserves UntilHandled semantics.

Where this goes: Helpers in your FollowBot class. Its Message method returns RouteMessage(_tasks, this, message); its Logic method returns RouteLogic(_tasks, logic). _tasks is the single TaskManager owned by the host.

C# · selected fragment
public static MessageResult RouteMessage(TaskManager tasks, IMessageHandler sender, Message message)
{
    var handled = false;
    if (message.Id == "GetTaskManager")
    {
        message.AddOutput(sender, tasks);
        handled = true;
    }
    if (tasks.SendMessage(TaskGroup.Enabled, message) == MessageResult.Processed)
        handled = true;
    return handled ? MessageResult.Processed : MessageResult.Unprocessed;
}

public static Task<LogicResult> RouteLogic(TaskManager tasks, Logic logic) =>
    tasks.ProvideLogic(TaskGroup.Enabled, RunBehavior.UntilHandled, logic);
Check your result

A probe asking for GetTaskManager receives the exact live manager instance. An unknown message with no interested task remains Unprocessed.

Let a plugin add one responsibility#

The insertion point here is FollowLeaderTask, the name you chose in the host. It is not guaranteed to exist in any other bot. If the query, anchor or insertion fails, log that the plugin is incompatible and leave the bot list unchanged.

The inserted task might report an inventory condition or a follower status. It returns false when idle so following still gets a turn.

On a normal host restart, Reset removes the old list before PluginManager.Start runs again. Require Stop/Start when enabling/disabling this plugin; do not demonstrate hot insertion without explicit task initialization and cleanup rules.

Where this goes: A helper in your own IPlugin + IStartStopEvents implementation. Call it from Start with BotManager.Current, a new read-only ITask with a unique Name, and this.

C# · selected fragment
public static bool InsertBeforeFollow(IBot bot, ITask task, object sender)
{
    if (bot == null || task == null) return false;
    var query = new Message("GetTaskManager", sender);
    if (bot.Message(query) != MessageResult.Processed) return false;
    var tasks = query.GetOutput<TaskManager>();
    if (tasks == null || tasks.GetTaskByName("FollowLeaderTask") == null) return false;
    // AddBefore can fail (including duplicate names). Never silently ignore it.
    return tasks.AddBefore(task, "FollowLeaderTask");
}
Check your result

One plugin task appears before FollowLeaderTask. A duplicate Name is rejected, not silently added twice.

Keep in mind

A successful GetTaskManager query alone does not make every existing plugin compatible. Task names, hook meanings, dependencies and lifecycle expectations must also agree.

Put combat at a deliberate priority — later#

Many real bots call a routine with hook_combat; some also configure it through messages such as SetLeash. Check the selected routine's documented contract before adding those messages.

Provided means combat handled this turn, so FollowLeaderTask must wait. Unprovided allows following. A routine that never yields or always reports Provided can starve follow behavior.

AcademyRoutine only understands its academy.* lesson IDs; it will not become compatible just by selecting it here. Either implement the agreed hook in your own routine or deliberately adapt a compatible one. Do not rename an ID and assume all semantics now match.

Where this goes: Inside an OPTIONAL CombatTask.Run, return the result of AskForCombat with the routine retained for this session. Do not register this task for the first follow-only milestone.

C# · selected fragment
public static async Task<bool> AskForCombat(IRoutine routine, object sender)
{
    if (routine == null) return false;
    return await routine.Logic(new Logic("hook_combat", sender)) == LogicResult.Provided;
}

Reset everyone from one context decision#

  1. In the host Tick, detect unavailable game state, death, pause, changed area hash or zone ID. Invalidate the current follow intent before task or plugin work.
  2. For loaded areas, compare hash plus zone ID, never just the area display name. Ignore hash zero. Treat an observed loading screen as invalidation even if the next identifiers are equal.
  3. Publish your own message, for example follow.context-reset, with a documented meaning. Each stateful task handles it by dropping old target values and pending movement; return Processed only when understood.
  4. FollowLeaderTask also invalidates when the party leader changes or disappears. A latched movement fault must not be cleared merely by a target update.
Keep in mind

Messages are synchronous. Do not perform travel inside a Message handler. Area events/cache classes in existing bot libraries are implemented by those libraries; they are not automatically available in a fresh DPB project.

Checkpoint 2: prove cooperation#

  1. Use three read-only tasks: first idle, second handled, third diagnostic. Confirm the third is skipped only on the handled pass.
  2. Ask for GetTaskManager from a test plugin; insert a task before FollowLeaderTask and verify the execution order.
  3. Try a missing anchor and a duplicate task Name. Both must fail without a second active task.
  4. Send follow.context-reset and verify old context is cleared. Restart the bot and confirm the plugin task exists once, not twice.
  5. Keep combat disabled. Continue into part 3 using the SAME bot, same manager and same task names.

Keep building

Look up a specific DPB1 API →