feat(bots): traders that answer a message and complete a trade #134

Merged
sickday merged 7 commits from feat/trader-bots into main 2026-08-06 13:31:08 +00:00
Owner

A merchant bot that stands at a bank, advertises, answers a private message
about what it pays for a thing, and completes an ordinary two-screen trade with
a real player. Six commits, four of them fixes to engine code the feature was
the first caller of.

Verified through the world and then sat with on a live server, where it gave
up four more defects in half an hour — the sitting is what most of the fixes
below came from.

What works, in a live world

  • a trade request answered, two bronze axes bought for 22 coins, both
    screens accepted, [{995, 22}] and [{1351, 2}] delivered;
  • the same axe sold back for 21 with 1 coin change — purse 50,000 →
    49,978 → 49,999, arithmetic exact;
  • how much for a rune scimitar"Rune scimitar: I pay 17920, I sell at
    33280"
    , buying addy plate"I'll sell you one for 21632",
    selling yews"I'll pay 112 coins each";
  • a Santa hat refused by name, and refused again when the offer changed under
    the quote.

Engine changes it needed, and they were both one send/2

A bot is the engine's fourth seat and could not see two things a player can:
that somebody clicked Trade with on it, and that somebody sent it a private
message. World.trade_request/4 now sends {:trade_requested, index, username}
beside the chatbox line, and World.Social.Requests.private_message/3 sends
{:chat_heard, :private, from, packed} beside the two packets.
Network.Session.Game ignores both in explicit clauses — it still has no
catch-all handle_info/2, deliberately.

The payload stays packed between world and session and is unpacked in
Bot.Session, so a human's private message costs what it always did.

Four fixes that are not the trader's

Chat.unpack/2 raised on a one-byte payload, and had since it was written.
unsmart/1 peeked the first byte in a function head and then chose between a
one-byte and a two-byte length inside a case guarded on that peek, so <<128>>
— which promises a 16-bit length and carries no second byte — matched no clause
and raised CaseClauseError instead of returning {:error, :malformed}.
Reachable from anything unpacking a payload it did not author.

The Grand Exchange opened for anyone who clicked a Tool Leprechaun.
World.Content.Exchange.clerk?/1 took the option text and answered true for
the string "Exchange". Twelve NPC definitions in this cache carry that verb and
four are clerks; the rest are the Tool Leprechaun (45 of which stand in the
world's farming patches), the Flax keeper, a Void Knight and TzHaar-Ket-Keh.
Every one of them opened the exchange to a player clicking their own perfectly
ordinary option. The discriminator is Sets, which exactly the four clerk ids
carry, so clerk?/1 is now a question about the NPC and clerk?/2 about the NPC
and the verb. The test asserts the counts against Cache.Npc.all/0 rather than
against a list, so a renumbered definition fails there instead of shipping.

A trade did not end when a partner walked away. Nothing in this engine closes
a modal when a player moves — not a bank, not a shop, not a make screen. Trade is
where it is worst rather than where it is unique, because an offered item has
already left the backpack, so an abandoned screen is goods in limbo. The gate
went into present/1, the per-tick is this trade still valid check that
already existed for this class of problem: proximity sits beside the seat check,
both sides notice independently, and no message crosses.

The staring outlived the trade. Arriving beside somebody to trade sets a
sticky face-entity, and only close/1 cleared it — so the half that is
refused, which never opens a screen at all, left the player facing somebody
they never traded with, permanently. Found on a merchant, where it is most
visible, and true of every trade.

The finding worth keeping: an event a goal ignores is one nobody sees again

The session empties its inbox on every brain step, whether or not the goal
looked at it. That is right for overheard chat — a line answered twice is worse
than a line answered once — and it silently destroyed the feature for trade
requests: a bot's brain starts after a 1.2–4.2 second stagger in its :pitching
phase, and a player who clicks Trade with in that window has the request
delivered, recorded, and then read and discarded by a step with no use for it.
Nothing logged on either side, no refusal sent, and the world's pending request
sits for its full 100-tick life waiting for a second click that never comes.

The world test caught it and a hand-built snapshot could not have: the phase test
drove :advertising directly, which is the one phase that does consume the
request. What made it visible was that the two tests disagreed.

handle_phase/4 now moves requests into the goal's own asked queue before
dispatching to a phase, so every phase queues what it cannot answer. The queue is
bounded at four and ages out at 60 ticks, because a seat is reused and answering
a two-minute-old ask opens a trade with whoever logged into that slot next.

Pricing

World.ItemValues is the one answer to what is this worth, replacing three
copies of the same four lines in the market maker, the price-guide button and the
merchant. priv/data/item_values.exs is 124 rows and every one is a refusal
no corrected values, deliberately: a corrected value is a market price somebody
invented, where a refusal is the true statement that nothing here knows one. 0
already means decline everywhere it lands.

The rows are the discontinued holiday items and 3rd age, scanned out of the cache
by name and then pinned by id in the test, which re-reads each name and fails
if the cache moved underneath it. What the scan showed is why the maker was
unsafe in a way its floor could not catch: this cache prices a Santa hat at
160
and a red partyhat at 1.

Contract.Merchant.buy_price/2 truncates and sell_price/2 rounds up, so with
buy_percent < sell_percent a round trip always loses the player money and there
is no crossing to clamp — property-tested over every value to ten million and
every tuning the config permits. The merchant gives change, stated by
quote_sell/3 and offered back in the same trade.

Resolving a name

The one place never recover an id by name is unavoidably broken, because a
player types words. What makes it safe is that the answer is never acted on
silently — every reply names the item and the number back, and every quote is
gated by World.ItemValues — so a wrong match is a sentence a human corrects in
one more line rather than a trade that already happened.

Two measurements shaped it: after dropping the three shadow definitions every
item carries, 1,083 of 6,926 names are still carried by more than one id
(Bones is three; Adamant platebody is two, one valued at 100 against the
other's 16,640). The tie-break is the cache's own ge_searchable? — obj opcode
65, which the exchange's item search filters on — and it settles 426 of the
1,083
. The rest stay ambiguous and the merchant asks which one?, because
picking the lowest id is right most of the time and catastrophically wrong the
one time the two are a bronze dagger and a dragon dagger.

The lexicon folds bytes rather than codepoints: this cache's strings are
latin-1, Grubs à la mode is not valid UTF-8, and a Unicode regex raises on the
item table.

Depends on #133

Bot.Goals.Restock travels to the Grand Exchange booth. On this cache that booth
was indexed one plane above the tile it is clicked from, so the goal derived
nil and failed fast with :no_booth — which is what "the Grand Exchange cannot
be entered on foot" was. With #133 applied, Bot.Sites.exchange/0 derives
%{id: 10061, position: {0, 3164, 3487}, action: 1, stand: {0, 3164, 3487}} and
the leg has somewhere to go. Merge #133 first.

The restock leg has still never run past its first click and wants a sitting.

Also fixed on the way

Bot.Session.give/2 passed false for stackable on every seed item, which was
true and harmless for every kind whose seed is an axe. A trader's seed is 50,000
coins, which landed as 28 slots holding one coin each: a merchant that reads as
funded and cannot buy a bronze axe. It now reads Cache.Obj.stackable?.

Known gaps

  • Public chat is not heard. The {:chat_heard, channel, ...} shape admits
    it and only :private is sent; a merchant that reads "selling yew logs"
    out loud needs one more send/2.
  • Nothing buys. Restock lists surplus and collects payouts; restocking
    popular stock from the exchange is not built, so a merchant's shelf only
    ever comes from players.
  • A chain hop forgets what people asked for. Bot.Brain.advance/3 calls
    init/1 on every entry into a goal, which is what makes the pitch and the
    exchange stand re-derive rather than persist — the good half — and also
    throws away wants. Self-healing: the player re-asks.
  • The clerk cannot be reached, and after #133 that is a knowable refusal
    rather than a mystery: it stands on the desk block with the booth counter, a
    wall loc, between it and the only open tile. The booth is the door.
A merchant bot that stands at a bank, advertises, answers a private message about what it pays for a thing, and completes an ordinary two-screen trade with a real player. Six commits, four of them fixes to engine code the feature was the first caller of. Verified through the world and then **sat with on a live server**, where it gave up four more defects in half an hour — the sitting is what most of the fixes below came from. ## What works, in a live world * a trade request answered, two bronze axes bought for **22 coins**, both screens accepted, `[{995, 22}]` and `[{1351, 2}]` delivered; * the same axe **sold back for 21 with 1 coin change** — purse 50,000 → 49,978 → 49,999, arithmetic exact; * `how much for a rune scimitar` → *"Rune scimitar: I pay 17920, I sell at 33280"*, `buying addy plate` → *"I'll sell you one for 21632"*, `selling yews` → *"I'll pay 112 coins each"*; * a Santa hat refused by name, and refused again when the offer changed under the quote. ## Engine changes it needed, and they were both one `send/2` A bot is the engine's fourth seat and could not see two things a player can: that somebody clicked `Trade with` on it, and that somebody sent it a private message. `World.trade_request/4` now sends `{:trade_requested, index, username}` beside the chatbox line, and `World.Social.Requests.private_message/3` sends `{:chat_heard, :private, from, packed}` beside the two packets. `Network.Session.Game` ignores both in explicit clauses — it still has no catch-all `handle_info/2`, deliberately. The payload stays **packed** between world and session and is unpacked in `Bot.Session`, so a human's private message costs what it always did. ## Four fixes that are not the trader's **`Chat.unpack/2` raised on a one-byte payload, and had since it was written.** `unsmart/1` peeked the first byte in a function head and then chose between a one-byte and a two-byte length inside a `case` guarded on that peek, so `<<128>>` — which promises a 16-bit length and carries no second byte — matched no clause and raised `CaseClauseError` instead of returning `{:error, :malformed}`. Reachable from anything unpacking a payload it did not author. **The Grand Exchange opened for anyone who clicked a Tool Leprechaun.** `World.Content.Exchange.clerk?/1` took the *option text* and answered true for the string `"Exchange"`. Twelve NPC definitions in this cache carry that verb and **four are clerks**; the rest are the Tool Leprechaun (45 of which stand in the world's farming patches), the Flax keeper, a Void Knight and TzHaar-Ket-Keh. Every one of them opened the exchange to a player clicking their own perfectly ordinary option. The discriminator is `Sets`, which exactly the four clerk ids carry, so `clerk?/1` is now a question about the NPC and `clerk?/2` about the NPC *and* the verb. The test asserts the counts against `Cache.Npc.all/0` rather than against a list, so a renumbered definition fails there instead of shipping. **A trade did not end when a partner walked away.** Nothing in this engine closes a modal when a player moves — not a bank, not a shop, not a make screen. Trade is where it is worst rather than where it is unique, because an offered item has already left the backpack, so an abandoned screen is goods in limbo. The gate went into `present/1`, the per-tick *is this trade still valid* check that already existed for this class of problem: proximity sits beside the seat check, both sides notice independently, and no message crosses. **The staring outlived the trade.** Arriving beside somebody to trade sets a *sticky* face-entity, and only `close/1` cleared it — so the half that is **refused**, which never opens a screen at all, left the player facing somebody they never traded with, permanently. Found on a merchant, where it is most visible, and true of every trade. ## The finding worth keeping: an event a goal ignores is one nobody sees again The session empties its inbox on **every** brain step, whether or not the goal looked at it. That is right for overheard chat — a line answered twice is worse than a line answered once — and it silently destroyed the feature for trade requests: a bot's brain starts after a 1.2–4.2 second stagger in its `:pitching` phase, and a player who clicks `Trade with` in that window has the request delivered, recorded, and then read and discarded by a step with no use for it. Nothing logged on either side, no refusal sent, and the world's pending request sits for its full 100-tick life waiting for a second click that never comes. The world test caught it and a hand-built snapshot could not have: the phase test drove `:advertising` directly, which is the one phase that *does* consume the request. What made it visible was that the two tests disagreed. `handle_phase/4` now moves requests into the goal's own `asked` queue **before** dispatching to a phase, so every phase queues what it cannot answer. The queue is bounded at four and ages out at 60 ticks, because a seat is reused and answering a two-minute-old ask opens a trade with whoever logged into that slot next. ## Pricing `World.ItemValues` is the one answer to *what is this worth*, replacing three copies of the same four lines in the market maker, the price-guide button and the merchant. `priv/data/item_values.exs` is **124 rows and every one is a refusal** — no corrected values, deliberately: a corrected value is a market price somebody invented, where a refusal is the true statement that nothing here knows one. `0` already means *decline* everywhere it lands. The rows are the discontinued holiday items and 3rd age, scanned out of the cache by name and then **pinned by id** in the test, which re-reads each name and fails if the cache moved underneath it. What the scan showed is why the maker was unsafe in a way its floor could not catch: this cache prices a **Santa hat at 160** and a **red partyhat at 1**. `Contract.Merchant.buy_price/2` truncates and `sell_price/2` rounds up, so with `buy_percent < sell_percent` a round trip always loses the player money and there is no crossing to clamp — property-tested over every value to ten million and every tuning the config permits. The merchant **gives change**, stated by `quote_sell/3` and offered back in the same trade. ## Resolving a name The one place *never recover an id by name* is unavoidably broken, because a player types words. What makes it safe is that the answer is never acted on silently — every reply names the item and the number back, and every quote is gated by `World.ItemValues` — so a wrong match is a sentence a human corrects in one more line rather than a trade that already happened. Two measurements shaped it: after dropping the three shadow definitions every item carries, **1,083 of 6,926 names are still carried by more than one id** (`Bones` is three; `Adamant platebody` is two, one valued at 100 against the other's 16,640). The tie-break is the cache's own `ge_searchable?` — obj opcode `65`, which the exchange's item search filters on — and it settles **426 of the 1,083**. The rest stay ambiguous and the merchant asks *which one?*, because picking the lowest id is right most of the time and catastrophically wrong the one time the two are a bronze dagger and a dragon dagger. The lexicon folds **bytes rather than codepoints**: this cache's strings are latin-1, `Grubs à la mode` is not valid UTF-8, and a Unicode regex raises on the item table. ## Depends on #133 `Bot.Goals.Restock` travels to the Grand Exchange booth. On this cache that booth was indexed one plane above the tile it is clicked from, so the goal derived `nil` and failed fast with `:no_booth` — which is what "the Grand Exchange cannot be entered on foot" was. With #133 applied, `Bot.Sites.exchange/0` derives `%{id: 10061, position: {0, 3164, 3487}, action: 1, stand: {0, 3164, 3487}}` and the leg has somewhere to go. **Merge #133 first.** The restock leg has still never run past its first click and wants a sitting. ## Also fixed on the way `Bot.Session.give/2` passed `false` for stackable on every seed item, which was true and harmless for every kind whose seed is an axe. A trader's seed is 50,000 coins, which landed as 28 slots holding one coin each: a merchant that reads as funded and cannot buy a bronze axe. It now reads `Cache.Obj.stackable?`. ## Known gaps * **Public chat is not heard.** The `{:chat_heard, channel, ...}` shape admits it and only `:private` is sent; a merchant that reads *"selling yew logs"* out loud needs one more `send/2`. * **Nothing buys.** Restock lists surplus and collects payouts; restocking popular stock *from* the exchange is not built, so a merchant's shelf only ever comes from players. * **A chain hop forgets what people asked for.** `Bot.Brain.advance/3` calls `init/1` on every entry into a goal, which is what makes the pitch and the exchange stand re-derive rather than persist — the good half — and also throws away `wants`. Self-healing: the player re-asks. * **The clerk cannot be reached**, and after #133 that is a knowable refusal rather than a mystery: it stands on the desk block with the booth counter, a wall loc, between it and the only open tile. The booth is the door.
A single byte with the high bit set promises a 16-bit length and carries
no second byte. The two cases were split by a guard on a peeked byte
inside a `case`, so the two-byte clause was unreachable for a one-byte
payload and nothing matched at all — a CaseClauseError rather than
{:error, :malformed}.

Reachable from anything that unpacks bytes it did not write.
clerk?/1 took the option text and answered true for "Exchange". Twelve
NPC definitions in this cache carry that verb and four are Grand
Exchange Clerks; the rest are the Tool Leprechaun — 45 of which stand in
the world's farming patches — the Flax keeper, a Void Knight and
TzHaar-Ket-Keh. Every one of them opened the exchange to a player
clicking their own ordinary option.

The tell is "Sets", which exactly the four clerk ids carry and nothing
else in the cache does. clerk?/1 is now a question about the NPC and
clerk?/2 about the NPC and the verb, so a clerk's Talk-to, History and
Sets are not this screen either.

The test asserts the counts against Cache.Npc.all/0 rather than against
a list, so a renumbered definition fails there instead of shipping.
::botgen trader raises a merchant that stands at a bank, advertises,
answers a private message with what it pays and what it charges, and
completes an ordinary two-screen trade with a real player.

Two engine seams, each one send/2 beside bytes a bot cannot read: a
trade request and a private message reach a human as drawn text and
nothing else, so each is restated as a term. Network.Session.Game
ignores both explicitly — it still has no catch-all — and Bot.Session
records them in a bounded inbox Bot.Perception folds into the snapshot.

The finding is what that exposed. The session empties its inbox on every
brain step whether or not the goal looked, so a Trade with clicked during
the brain's startup stagger was recorded, read by :pitching and gone,
with no refusal sent and nothing logged on either side. Requests are
queued into the goal's own state before any phase runs.

World.ItemValues replaces three copies of what is this worth — the market
maker, the price guide, the merchant. Its 124 rows are all refusals: a
corrected value would be a market price somebody invented, where a
refusal is the true statement that nothing here knows one. This cache
prices a Santa hat at 160 against a red partyhat's 1, and only the second
was safe, by the accident of the maker's floor of two.

Contract.Merchant holds "a round trip must lose the player money" by
rounding rather than a clamp: a buy truncates, a sell rounds up, so with
buy_percent < sell_percent the two cannot meet. The only degenerate case
is an item too cheap to pay a coin for, which is a refusal.

Name resolution is the one place "never recover an id by name" is
broken, unavoidably, and it is made safe by measurement: 1,083 of 6,926
names are carried by more than one id after the shadow definitions are
dropped, and the cache's own ge_searchable? settles 426 of them while
the rest are asked back as "which one?".

Also: Bot.Session.give/2 passed false for stackable, so a 50,000-coin
float seeded as one coin.
Three defects a live sitting found and no test could.

World.MovementAuthority.nearby/3 answers %{index:, tile:} and nothing
else — it is the position table and holds no identity — so matching
%{id: id} straight off the row compiles, satisfies Dialyzer (both are
maps) and silently never matches. The goal stood on the clerk's doorstep
and reported :no_clerk five times. The id comes from
World.TickSnapshot.npc/1, which is what Bot.Goals.Fish already does.

The clerk's Exchange option is op 3, not op 1 — op 1 is Talk-to. Read
the slot out of the definition's own action list rather than writing a
number down.

And the derived stand was diagonal from the clerk, which an NPC click
refuses. Bot.Sites now prefers an orthogonal neighbour.
Two defects a live sitting found, both about a trade that is over on the
server and not on the screen.

Nothing in this engine closes a modal when a player moves -- not a bank,
not a shop, not a make screen -- so a player could walk to the far side
of the world with a trade open on both sides and their offer in neither
backpack. present/1 already runs every tick as the is-this-trade-still-
valid gate, so proximity belongs there beside the seat check: both sides
notice for themselves and tear their own half down, which is why no
message crosses. @apart is chosen rather than sourced and says so.

And arriving beside somebody to trade sets a sticky face-entity, which
only close/1 was clearing. A refused half never opens a screen at all --
walked over, asked, told the other player is busy -- so partner_ended/2's
no-trade branch was returning the player untouched and leaving them
facing, permanently, somebody they never traded with. It now stops the
facing and says nothing, which is what being turned down looks like.
fix(bots): restock at the Grand Exchange booth, not at the clerk
All checks were successful
ci / gates (pull_request) Successful in 55s
6101f5a93f
The clerk was the wrong target and the note that led there was wrong:
"the booths are defined and never placed" is false. Loc 10061, the Grand
Exchange booth, is the one loc definition in this cache carrying an
Exchange action, it is placed four times on plane 1, and
World.Player.Advance has always routed it through Exchange.booth?/2 -- so
the server has never lacked a handler for the door a player actually
uses.

The clerk, meanwhile, is the one thing at the exchange that cannot be
clicked: it stands on a solid desk loc with three of its four orthogonal
neighbours blocked, and Contract.Reach refuses it from every tile within
six. The four desk locs around it are null definitions with no actions,
so the desk is not a route either.

Bot.Sites now derives an exchange booth the way it derives a bank -- a
loc carrying the verb, placed, with a standable tile beside it -- and
Restock clicks it as an object action. Exchange.booth_action?/1 is the
verb test for a caller holding an action list rather than an id and slot.

On this world it derives nil, because not one tile within five of any of
the four booths is floored, so the goal fails fast with :no_booth rather
than looping at a doorstep it can never enter. That is a map question and
not this feature's: the same square has 293 floored tiles at plane 1
against 4096 at plane 0, so plane-1 floor data exists there and does not
cover the booths.
sickday force-pushed feat/trader-bots from 6101f5a93f
All checks were successful
ci / gates (pull_request) Successful in 55s
to b2ece67003
All checks were successful
ci / gates (pull_request) Successful in 56s
2026-08-06 13:24:14 +00:00
Compare
Merge branch 'main' into feat/trader-bots
All checks were successful
ci / gates (pull_request) Successful in 55s
build / image (push) Successful in 35s
ci / gates (push) Successful in 59s
375e847525
Sign in to join this conversation.
No reviewers
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!134
No description provided.