feat(exchange): an offer is a call, not six clicks #142

Merged
sickday merged 2 commits from feat/exchange-offer-api into main 2026-08-06 16:05:10 +00:00
Owner

World.Content.Exchange.place_offer/3 places one offer and takes its escrow,
with no screen anywhere in it. free_slot/1 is promoted beside it. confirm/1
becomes the screen's thin layer over the same function, keeping only the gate
that is genuinely a screen's — that somebody chose an item at all — and the
slot-in-use check moves below the split so there is one implementation of it.

The bug this came out of

Bot.Goals.Restock drove the interface: a slot's Sell, the sell-side pack,
the price guide, Confirm. Watched on a live world, the whole leg reads:

opened the Grand Exchange                                    ok
465,6  op 1  → refused  {:op_disabled, 1}
465,7  op 1  sub=4      → overview slot 0, View/open
467,0  op 1  → refused  {:op_disabled, 1}          ← names the item
465,24 sub=11 price guide → price 0 -> 0, item=nil qty=0 price=0
465,27 Confirm          → offer refused — "Choose an item to trade first."

…and then the goal walked home reporting success. Every trip it had ever made
did this.
A green suite, a design pass, a sitting and a merge all went by
without it, because nothing on the server distinguishes "three clicks landed"
from "three clicks were refused" if the caller never looks.

467:0 is the click that names the item, and a real client sends it with a
child index — measured directly, by clicking a pot in the sell-side pack on a
real client: 467,0 op 1 sub=1 item=1931. The goal sent sub: nil, which falls
through World.Interfaces.Button.authorise/1 to the component's static cache
mask
; the exchange's controls are made clickable at runtime by enable/0,
which the guard has no way to learn about. The guard was right. The bot was
wrong.

Why the child index is not the fix

A bot never reads a screen. It holds no interface state, so its clicks cannot
be derived from anything the server sent — they are a client transcribed by hand
into a goal, free to drift from the real one with nothing comparing them. That
is not a test of the interface; it is a second implementation of it, filed
somewhere nobody looks.

Adding the missing sub would have fixed this instance and left the mechanism
intact for the next one.

So a non-client caller gets the operation instead. Every rule still
applies — eight slots, tradability, price, quantity, having the goods — because
they were always in Contract.Exchange, and the screen was only ever one route
to them. Bot.Act.exchange_offer/2 and exchange_collect/1 sit on the
{:bot, …} shelf beside relocate, and the session runs them against its own
player through the rewrite/2 seam the test host already uses.

What the goal looks like now

travelling → selling → confirming → leaving. Gone: sell_child/0,
control/1, child_for/2, sell_grid_component/0, setup_panel_component/0,
confirm_component/0, the Screen alias, the :listing phase, @open_grace,
@screen_grace and the listed? flag — about 65 lines net.

Gained: :confirming, which reads the offer back out of snapshot.exchange
rather than assuming its emissions landed. That phase is the direct lesson of
the bug above.

It still walks to the booth. Standing at the exchange is not enforced by
place_offer/3 — it is not enforced for a human either, where the gate is that
the screen only opens at a booth — but a fleet exists to make the world look
inhabited, so the goal hops until it is on the booth's own tile before it offers.

Verified in a live world

Hot-reloaded into a running server, a trader seeded with 24 yew logs:

[world]    logseller2 travels 3163,3487,0 -> 3164,3487,0
[exchange] logseller2: offer refused — You have nothing to collect.
[economy]  logseller2 placed sell slot=0 24 x Yew logs (1515) at 160 each
[economy]  exchange tick=8420 24 x 1515 at 160 buyer=:market seller=logseller2
[exchange] logseller2 collected 1 slot(s) to the inventory
           {coins 3840, yew logs 0, exchange slots 0}

24 × 160 = 3840, exact; the slot released. That is the restock leg completing
for the first time.

Tests

Four new, in World.Content.ExchangeTest, all without a screen: that
place_offer/3 escrows byte-identically to a Confirm click (asserted by
running both and comparing the claims), that a buy takes coins the same way,
that every gate still refuses — bad_price, bad_quantity, untradeable,
shortfall, bad_slot — and that the eight-slot cap holds against a caller
asking for an occupied slot directly.

Suite green on main @ 923fb6a (rebased onto it after the runecrafting merge
landed mid-flight): 3365 tests, 1456 doctests, 59 properties, 0 failures. Credo
clean. mix format --check-formatted and mix dialyzer fail identically on a
stashed clean main on this machine — that is #131, not this branch.

Follow-up, not in this change

The Grand Exchange clerk is still unreachable: it stands on the desk block
and the booth counter is a wall loc on the shared edge, so
Flags.interactable_from?/4's edge_open? refuses it. Pressing Exchange on
a clerk does nothing at all. That is the ordinary wall rule applied to an NPC
behind a counter, it affects bankers the same way, and it wants an engine
decision rather than a Grand Exchange fix.

`World.Content.Exchange.place_offer/3` places one offer and takes its escrow, with no screen anywhere in it. `free_slot/1` is promoted beside it. `confirm/1` becomes the screen's thin layer over the same function, keeping only the gate that is genuinely a screen's — that somebody chose an item at all — and the slot-in-use check moves below the split so there is one implementation of it. ## The bug this came out of `Bot.Goals.Restock` drove the interface: a slot's `Sell`, the sell-side pack, the price guide, `Confirm`. Watched on a live world, the whole leg reads: ``` opened the Grand Exchange ok 465,6 op 1 → refused {:op_disabled, 1} 465,7 op 1 sub=4 → overview slot 0, View/open 467,0 op 1 → refused {:op_disabled, 1} ← names the item 465,24 sub=11 price guide → price 0 -> 0, item=nil qty=0 price=0 465,27 Confirm → offer refused — "Choose an item to trade first." ``` …and then the goal walked home reporting success. **Every trip it had ever made did this.** A green suite, a design pass, a sitting and a merge all went by without it, because nothing on the server distinguishes "three clicks landed" from "three clicks were refused" if the caller never looks. `467:0` is the click that names the item, and a real client sends it with a child index — measured directly, by clicking a pot in the sell-side pack on a real client: `467,0 op 1 sub=1 item=1931`. The goal sent `sub: nil`, which falls through `World.Interfaces.Button.authorise/1` to the component's **static cache mask**; the exchange's controls are made clickable at runtime by `enable/0`, which the guard has no way to learn about. The guard was right. The bot was wrong. ## Why the child index is not the fix A bot never *reads* a screen. It holds no interface state, so its clicks cannot be derived from anything the server sent — they are a client transcribed by hand into a goal, free to drift from the real one with nothing comparing them. That is not a test of the interface; it is a second implementation of it, filed somewhere nobody looks. Adding the missing `sub` would have fixed this instance and left the mechanism intact for the next one. So a non-client caller gets the **operation** instead. Every rule still applies — eight slots, tradability, price, quantity, having the goods — because they were always in `Contract.Exchange`, and the screen was only ever one route to them. `Bot.Act.exchange_offer/2` and `exchange_collect/1` sit on the `{:bot, …}` shelf beside `relocate`, and the session runs them against its own player through the `rewrite/2` seam the test host already uses. ## What the goal looks like now `travelling → selling → confirming → leaving`. Gone: `sell_child/0`, `control/1`, `child_for/2`, `sell_grid_component/0`, `setup_panel_component/0`, `confirm_component/0`, the `Screen` alias, the `:listing` phase, `@open_grace`, `@screen_grace` and the `listed?` flag — about 65 lines net. Gained: `:confirming`, which **reads the offer back out of `snapshot.exchange`** rather than assuming its emissions landed. That phase is the direct lesson of the bug above. It still walks to the booth. Standing at the exchange is not enforced by `place_offer/3` — it is not enforced for a human either, where the gate is that the screen only opens at a booth — but a fleet exists to make the world look inhabited, so the goal hops until it is on the booth's own tile before it offers. ## Verified in a live world Hot-reloaded into a running server, a trader seeded with 24 yew logs: ``` [world] logseller2 travels 3163,3487,0 -> 3164,3487,0 [exchange] logseller2: offer refused — You have nothing to collect. [economy] logseller2 placed sell slot=0 24 x Yew logs (1515) at 160 each [economy] exchange tick=8420 24 x 1515 at 160 buyer=:market seller=logseller2 [exchange] logseller2 collected 1 slot(s) to the inventory {coins 3840, yew logs 0, exchange slots 0} ``` 24 × 160 = 3840, exact; the slot released. That is the restock leg completing for the first time. ## Tests Four new, in `World.Content.ExchangeTest`, all without a screen: that `place_offer/3` escrows byte-identically to a `Confirm` click (asserted by running both and comparing the claims), that a buy takes coins the same way, that every gate still refuses — `bad_price`, `bad_quantity`, `untradeable`, `shortfall`, `bad_slot` — and that the eight-slot cap holds against a caller asking for an occupied slot directly. Suite green on `main` @ `923fb6a` (rebased onto it after the runecrafting merge landed mid-flight): **3365 tests, 1456 doctests, 59 properties, 0 failures**. Credo clean. `mix format --check-formatted` and `mix dialyzer` fail identically on a stashed clean `main` on this machine — that is #131, not this branch. ## Follow-up, not in this change The Grand Exchange **clerk** is still unreachable: it stands on the desk block and the booth counter is a wall loc on the shared edge, so `Flags.interactable_from?/4`'s `edge_open?` refuses it. Pressing `Exchange` on a clerk does nothing at all. That is the ordinary wall rule applied to an NPC behind a counter, it affects bankers the same way, and it wants an engine decision rather than a Grand Exchange fix.
feat(exchange): an offer is a call, not six clicks
All checks were successful
ci / gates (pull_request) Successful in 55s
ccd60b8370
`World.Content.Exchange.place_offer/3` places one offer and takes its
escrow, with no screen anywhere in it, and `free_slot/1` is promoted beside
it. `confirm/1` becomes the screen's thin layer over the same function --
it keeps the one gate only a screen has, that somebody chose an item at
all, and everything else moves below the split.

`Bot.Goals.Restock` drove the interface: a slot's `Sell`, the sell-side
pack, the price guide, `Confirm`. It sent the pack click without the child
index a real client carries, so `Button.authorise/1` refused it as
`{:op_disabled, 1}`, the price guide then read `item=nil` and computed
`price 0 -> 0`, and `Confirm` was refused with "Choose an item to trade
first". The goal reported success and listed nothing on every trip it ever
made, with a green suite behind it. Found by watching a live world, which
is the only place it was visible.

The child index is not the fix. A bot never *reads* a screen, so its
clicks are not derived from anything the server sent -- they are a second,
hand-written client living in a goal, drifting from the real one with
nothing comparing them. Fidelity to the client buys a test only when the
clicks come from the client.

So the goal states offers instead, through `Bot.Act.exchange_offer/2` and
`exchange_collect/1` on the `{:bot, ...}` shelf beside `relocate`. Every
rule still applies -- eight slots, tradability, price, quantity, having
the goods -- because they were always in `Contract.Exchange`.

It still walks to the booth, because a fleet exists to make the world look
inhabited, and `:confirming` reads the offer back out of the snapshot
rather than assuming three clicks landed. The whole screen layer goes:
`sell_child/0`, `control/1`, `child_for/2`, three component lookups, the
`:listing` phase and two grace counters.
sickday force-pushed feat/exchange-offer-api from ccd60b8370
All checks were successful
ci / gates (pull_request) Successful in 55s
to 0232c9c012
All checks were successful
ci / gates (pull_request) Successful in 56s
2026-08-06 15:38:40 +00:00
Compare
Merge branch 'main' into feat/exchange-offer-api
All checks were successful
ci / gates (pull_request) Successful in 1m2s
build / image (push) Successful in 31s
ci / gates (push) Successful in 1m1s
a27c08e1f2
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!142
No description provided.