Bots emulate button presses instead of calling operations #155

Closed
opened 2026-08-07 12:14:51 +00:00 by sickday · 1 comment
Owner

Bots synthesize client button presses to reach behaviour that already exists
as a function. They should call the function.

The guideline

A bot never emulates a button press, an item op, or any other client
packet. It calls the operation directly.

A bot has no socket and no client. Nothing renders an interface for it, so
nothing can observe the click — and the in-game effect is identical
whether the press was synthesized on the backend or not. A synthesized click
is therefore pure cost: it is a second, hand-written client that nothing
compares against the real one, so it is free to drift.

That drift has already shipped once. Goals.Restock sent the sell-side pack
click without the child index a real client carries, Button.authorise/1
refused it as {:op_disabled, 1}, and the two clicks behind it ran against an
offer holding no item — the goal reported success and listed nothing on
every trip it ever made
, with a green suite behind it. It was found by
watching a live world and diffing against the operator's own client.

So: put the rules in Contract.* or in the operation, and give the operation
two callers — the client's click handler, and the bot.

Realism survives this, because realism is what an observer can see. The
bot still pays every observable cost — the level gate, the runes, the
animation, the delay, the wilderness ceiling — because all of that lives
inside the operation. What it skips is only the part nobody could ever have
seen.

Where a request tuple is still right

When the behaviour genuinely is the click: a walk, a loc action, an NPC
action, a player action. Those are world interactions with routes,
approaches and reach behind them, not interface state. Bot.Act should keep
emitting request tuples for exactly those and nothing else.

What is already done

Bot.Act.arm_autocast/2 was fixed on feat/ancient-and-lunar-magic. It used
to send the two clicks a person makes — the combat tab's Choose spell, then
the cell inside the picker it opens — and the first of those was pure
theatre: it opened interface 201 on a screen that does not exist, for a
reader that does not exist, so that a later click could write a varbit.

It now emits {:bot, {:arm_autocast, slot}} and the session calls
World.Player.Combat.arm_autocast/3, which is where the gates now live. The
same branch added the operations the two new spellbooks need —
Bot.Act.spellbook/2, Bot.Act.teleport/2 and Bot.Act.lunar/3 — each a
{:bot, op} onto an operation that already existed.

Extracting the operation found a bug that the click path had: nothing
gated the picked slot against the player's spellbook. Standard slots run
1–20 and ancient 31–46, so they do not collide and nothing would have raised
— a standard-book player could arm Ice Barrage, the varbit would take it, and
the cast would then read it back as nothing on every swing. A spell armed but
never cast is the worst of the three outcomes, because it looks like the
picker worked.

What is left

Four in Bot.Act, all synthesizing packets:

function what it sends operation to call instead
equip/4, use_item/4 {:item_op, …} on interface 149 World.Content.Item.interact/4 — already exists
deposit_all/2 one bank side-inventory click per item id none yet; the behaviour is inside the bank's click handler
button/6 the generic escape hatch every other one is built on — remove once nothing needs it

Care needed, per row

  • use_item/4 is how a bot eats and drinks, and that path deliberately
    runs the whole of World.Content.Consume — the three clocks and the attack
    delay included. Item.interact/4 is the operation behind it, so calling it
    directly keeps all of that; this is simply the row where "skip the click" is
    most likely to quietly skip a rule too, so it wants a test that asserts the
    clocks, not just the item moving.
  • deposit_all/2 has no operation yet. Op 7 on interface 15
    component 3 is the cache's Deposit-All, and its semantics — deposit every
    copy of this id, leave anything unnamed alone — are what keeps a
    gatherer's tool in the pack without a withdraw round trip. That behaviour
    needs lifting out of the click handler before the bot can call it, and it is
    the one item here that is real work rather than a re-point.
  • button/6 should be deleted, not kept as a fallback. A generic
    click-builder is how the next offender gets written.

Acceptance

  • Bot.Act emits {:bot, op} for everything that is interface state, and
    request tuples only for walks, loc/NPC/player actions.
  • grep -rn 'if_button\|item_op' lib/revenant/bot/ returns nothing.
  • Every operation lifted out of a click handler keeps its gates, and each has
    a test asserting a refusal, not only the happy path — a refusal is what
    a bot silently skips when the rule stays in the handler.
  • No goal test asserts a synthesized packet shape where an operation is what
    actually happens.
Bots synthesize client button presses to reach behaviour that already exists as a function. They should call the function. ## The guideline **A bot never emulates a button press, an item op, or any other client packet. It calls the operation directly.** A bot has no socket and no client. Nothing renders an interface for it, so **nothing can observe the click** — and the in-game effect is identical whether the press was synthesized on the backend or not. A synthesized click is therefore pure cost: it is a second, hand-written client that nothing compares against the real one, so it is free to drift. That drift has already shipped once. `Goals.Restock` sent the sell-side pack click without the child index a real client carries, `Button.authorise/1` refused it as `{:op_disabled, 1}`, and the two clicks behind it ran against an offer holding no item — the goal reported success and **listed nothing on every trip it ever made**, with a green suite behind it. It was found by watching a live world and diffing against the operator's own client. So: put the rules in `Contract.*` or in the operation, and give the operation two callers — the client's click handler, and the bot. **Realism survives this**, because realism is *what an observer can see*. The bot still pays every observable cost — the level gate, the runes, the animation, the delay, the wilderness ceiling — because all of that lives inside the operation. What it skips is only the part nobody could ever have seen. ### Where a request tuple is still right When the behaviour genuinely **is** the click: a walk, a loc action, an NPC action, a player action. Those are world interactions with routes, approaches and reach behind them, not interface state. `Bot.Act` should keep emitting request tuples for exactly those and nothing else. ## What is already done `Bot.Act.arm_autocast/2` was fixed on `feat/ancient-and-lunar-magic`. It used to send the two clicks a person makes — the combat tab's *Choose spell*, then the cell inside the picker it opens — and the first of those was pure theatre: it opened interface `201` on a screen that does not exist, for a reader that does not exist, so that a later click could write a varbit. It now emits `{:bot, {:arm_autocast, slot}}` and the session calls `World.Player.Combat.arm_autocast/3`, which is where the gates now live. The same branch added the operations the two new spellbooks need — `Bot.Act.spellbook/2`, `Bot.Act.teleport/2` and `Bot.Act.lunar/3` — each a `{:bot, op}` onto an operation that already existed. Extracting the operation **found a bug that the click path had**: nothing gated the picked slot against the player's spellbook. Standard slots run 1–20 and ancient 31–46, so they do not collide and nothing would have raised — a standard-book player could arm Ice Barrage, the varbit would take it, and the cast would then read it back as nothing on every swing. A spell armed but never cast is the worst of the three outcomes, because it looks like the picker worked. ## What is left Four in `Bot.Act`, all synthesizing packets: | function | what it sends | operation to call instead | | --- | --- | --- | | `equip/4`, `use_item/4` | `{:item_op, …}` on interface 149 | `World.Content.Item.interact/4` — **already exists** | | `deposit_all/2` | one bank side-inventory click per item id | none yet; the behaviour is inside the bank's click handler | | `button/6` | the generic escape hatch every other one is built on | — remove once nothing needs it | ### Care needed, per row * **`use_item/4` is how a bot eats and drinks**, and that path deliberately runs the whole of `World.Content.Consume` — the three clocks and the attack delay included. `Item.interact/4` is the operation behind it, so calling it directly keeps all of that; this is simply the row where "skip the click" is most likely to quietly skip a rule too, so it wants a test that asserts the clocks, not just the item moving. * **`deposit_all/2` has no operation yet.** Op `7` on interface `15` component `3` is the cache's Deposit-All, and its semantics — deposit every copy of *this* id, leave anything unnamed alone — are what keeps a gatherer's tool in the pack without a withdraw round trip. That behaviour needs lifting out of the click handler before the bot can call it, and it is the one item here that is real work rather than a re-point. * **`button/6` should be deleted, not kept as a fallback.** A generic click-builder is how the next offender gets written. ## Acceptance * `Bot.Act` emits `{:bot, op}` for everything that is interface state, and request tuples only for walks, loc/NPC/player actions. * `grep -rn 'if_button\|item_op' lib/revenant/bot/` returns nothing. * Every operation lifted out of a click handler keeps its gates, and each has a test asserting a **refusal**, not only the happy path — a refusal is what a bot silently skips when the rule stays in the handler. * No goal test asserts a synthesized packet shape where an operation is what actually happens.
Author
Owner
Moved to https://forge.home.arpa/Revenant/Server/issues/50
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Revenant/Server#155
No description provided.