feat(bots): traders that answer a message and complete a trade #134
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/trader-bots"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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
screens accepted,
[{995, 22}]and[{1351, 2}]delivered;49,978 → 49,999, arithmetic exact;
how much for a rune scimitar→ "Rune scimitar: I pay 17920, I sell at33280",
buying addy plate→ "I'll sell you one for 21632",selling yews→ "I'll pay 112 coins each";the quote.
Engine changes it needed, and they were both one
send/2A bot is the engine's fourth seat and could not see two things a player can:
that somebody clicked
Trade withon it, and that somebody sent it a privatemessage.
World.trade_request/4now sends{:trade_requested, index, username}beside the chatbox line, and
World.Social.Requests.private_message/3sends{:chat_heard, :private, from, packed}beside the two packets.Network.Session.Gameignores both in explicit clauses — it still has nocatch-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/2raised on a one-byte payload, and had since it was written.unsmart/1peeked the first byte in a function head and then chose between aone-byte and a two-byte length inside a
caseguarded on that peek, so<<128>>— which promises a 16-bit length and carries no second byte — matched no clause
and raised
CaseClauseErrorinstead 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?/1took the option text and answered true forthe string
"Exchange". Twelve NPC definitions in this cache carry that verb andfour 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 idscarry, so
clerk?/1is now a question about the NPC andclerk?/2about the NPCand the verb. The test asserts the counts against
Cache.Npc.all/0rather thanagainst 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 thatalready 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/1cleared it — so the half that isrefused, 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
:pitchingphase, and a player who clicks
Trade within that window has the requestdelivered, 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
:advertisingdirectly, which is the one phase that does consume therequest. What made it visible was that the two tests disagreed.
handle_phase/4now moves requests into the goal's ownaskedqueue beforedispatching 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.ItemValuesis the one answer to what is this worth, replacing threecopies of the same four lines in the market maker, the price-guide button and the
merchant.
priv/data/item_values.exsis 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.
0already 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/2truncates andsell_price/2rounds up, so withbuy_percent < sell_percenta round trip always loses the player money and thereis 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/3and 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 inone 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
(
Bonesis three;Adamant platebodyis two, one valued at 100 against theother's 16,640). The tie-break is the cache's own
ge_searchable?— obj opcode65, which the exchange's item search filters on — and it settles 426 of the1,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 modeis not valid UTF-8, and a Unicode regex raises on theitem table.
Depends on #133
Bot.Goals.Restocktravels to the Grand Exchange booth. On this cache that boothwas indexed one plane above the tile it is clicked from, so the goal derived
niland failed fast with:no_booth— which is what "the Grand Exchange cannotbe entered on foot" was. With #133 applied,
Bot.Sites.exchange/0derives%{id: 10061, position: {0, 3164, 3487}, action: 1, stand: {0, 3164, 3487}}andthe 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/2passedfalsefor stackable on every seed item, which wastrue 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
{:chat_heard, channel, ...}shape admitsit and only
:privateis sent; a merchant that reads "selling yew logs"out loud needs one more
send/2.popular stock from the exchange is not built, so a merchant's shelf only
ever comes from players.
Bot.Brain.advance/3callsinit/1on every entry into a goal, which is what makes the pitch and theexchange stand re-derive rather than persist — the good half — and also
throws away
wants. Self-healing: the player re-asks.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.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.6101f5a93fb2ece67003