feat/grand-exchange #110

Merged
sickday merged 7 commits from feat/grand-exchange into main 2026-08-04 13:41:26 +00:00
Owner
No description provided.
The pure half of the Grand Exchange: what an offer is, when two of them
cross, and the one packet the client is told about it on.

The offer's goods leave the player when the offer is placed rather than
when it fills, which is the rule the trade screens are already built on
and it is here for the same reason -- after that the book holds
everything and no sequence of clicks can spend it twice. Everything owed
back rides in the offer's own collection slot.

Four statuses, and the client chose them: 465's scripts ask exactly
ISOFFEREMPTY, ISOFFERADDING, ISOFFERSTABLE and ISOFFERFINISHED, and the
client answers by comparing state & 7 against 0, 1, 2 and 5. The gaps
between them are not ours to fill. Direction is bit 3.

Opcode 216 is twenty fixed bytes and the only new wire format in the
feature -- every click on 465 arrives as an ordinary if_button, because
the client runs a component's onOp script *and still sends* the button
packet. An empty slot is not a shorter packet: the client peeks at the
state byte, and a zero there makes it skip eighteen bytes it never reads.

Matching runs in two passes, real offers first and a market maker for
what is left, and a pair transacts at the *resting* offer's price. That
is not a courtesy -- without it, bidding well is punished and every
player learns to bid exactly the guide price, which is the one behaviour
that stops a market having prices at all.

Money conservation is a property test on both sides: a buy never gives
back more than it escrowed, and a sell hands back every unit as either
coins or goods.
Group 465 is the exchange and 402 the Collection Box. 465 navigates
itself: script 806 picks the panel from varbit 4439, script 805 writes
it, and neither that nor the buy/sell choice in 4397 is ever
transmitted. So the server opens the interface, sends the slots, and
answers clicks -- it must not try to hide or show anything.

Which panel is which came off the switcher script rather than the
layout, and the layout says the opposite: 465:24 is the *setup* panel
because it owns Confirm, and 465:15 is the *status* panel because it
owns Abort offer. A test asserts Confirm's parent against the cache, so
believing the geometry again would go red.

The finding the module turns on: a script-built component has no click
mask. CC_SETOP calls Widget.setAction, which sets the label and nothing
else, and no mask opcode appears anywhere in the exchange scripts. The
client then splits -- a menu entry is drawn whenever onOp is set, mask
or no mask, but the *packet* is only sent when getWidgetClickMask allows
the op, and for a script-built component that value comes from
IF_SETEVENTS alone. So enable/0 is load-bearing: without it every button
looks live and silently does its client-side half only. This is the
other half of what the shop's grid taught, and the halves disagree
because they are two different code paths.

All thirteen ops in 773 and the one in 819 are op index 1, and the
children are created by a single monotonic counter into one parent, so
the child index in `sub` is the entire message. The two `+1` buttons are
a spinner arrow and a preset button on different handler scripts, which
is why the label cannot tell them apart.

Enums rather than arithmetic: 245 names the overview boxes, 1011 the
collection boxes, 150 the per-slot containers -- and 150's ids skip from
523 to 539, which a test pins so nobody replaces it with a range.
Binds the booth and the clerk, opens 465 and 402, sends all eight slots,
and lights every control -- then logs each click with the reading this
server would give it.

Entry points come from the cache as usual. 10060 and 10061 are both named
"Grand Exchange booth" and carry different verbs, Bank/Collect on one and
Exchange/Collect on the other, so a list keyed on the name binds the
wrong one and a list keyed on the id rots. The clerks carry Exchange,
History and Sets; only Exchange is answered and the other two fall
through, because those screens are not built.

All eight slots are sent even when every one is empty. The client keeps
whatever it was last told, so a slot nobody mentions is still drawn from
a previous session -- eight empty slots is how a clean screen is said.

The offer lifecycle is deliberately absent. How a player names the item
is the one fact in this feature that is not in the cache: a sell is an
inventory click and a buy goes through 464's search, and neither binding
can be shown from here -- 466 looks like the inventory side at 231x329
and nothing proves it. So this module is an instrument first. Every
button on 465 runs a client script whether or not the server hears about
it, which means the screen animates and the numbers move even for a
control the server has no idea exists; the log is the only place where a
live control and an inert one look different, and without it a sitting
cannot tell "the button sent nothing" from "the server ignored it" --
two faults with opposite fixes.

Choose item is enabled rather than left dark, on purpose: whether that
click arrives at all, and what it carries, is exactly the observation
this module was written to make.
A live sitting against a real client, and four findings that no amount of
reading the cache would have produced.

**The server owns the navigation varbits.** Clicking a slot box runs
script 805, which writes varbit 4439 -- the viewed slot -- itself. But
4439 lives on varp 375, and 803 registers IF_SETONVARTRANSMIT against
exactly that varp: the next commit carries the server's value, the
handler fires 806, and the panel snaps back. In play that reads as the
setup screen flashing for a tick and reverting, and nothing on either
side logs anything, because the client did as it was told and the server
never knew a screen had changed. Both varbits are now mirrored on the
click. Back is the same bug pointing the other way, and fixing the first
one creates it.

**The item search is a chatbox the server only starts.** Choose item is
script 489, which plays a sound and returns -- so the work is ours.
Running client script 750 hides 162:32, positions 162:33 and hooks
IF_SETONKEY, and the client then filters its own item names as they are
typed; no list crosses the wire and no keystroke reaches us. Its first
integer is the tradeable-only flag, which is obj opcode 65 -- the field
Cache.Obj already documents as "the Grand Exchange search filter, not
may be traded", written before anything consumed it. This is its
consumer. The era gate fired here too: a later revision's copy of that
script takes three integers where this cache's takes two, so a call
lifted from a newer server would push the prompt into the wrong argument
and open a blank search.

The pick returns on c2s 124, a bare u16, already in the length table and
previously unanswered. It is deliberately not the typed-number path the
bank and trade screens use.

**Buy opens the search directly**, because that is what the live game
does; Choose item exists for changing your mind, not for starting. Sell
does not, and the asymmetry is the point -- a buy names an item that may
be nowhere near the player, a sell names one already in the backpack.

**enable/0 enumerates the interface instead of naming hosts.** Three
attempts to name the right parent and child were wrong -- the slot box's
handler was derived as child 2 and is 3 and 4 -- and every wrong guess
presents as total silence, which is also what a missing mask looks like.
The two cannot be told apart from a log, so the guess is gone: every
component of 465 and 402 is enabled, and the click log states the
answer. A child with no op is never offered, so a generous mask costs
only the packets that carry it.

Verified live: buy and sell on any slot, back, the search, and a pick
returning Blue partyhat by id.
feat: quantity, price and the item, on the varps that draw them
Some checks failed
ci / gates (pull_request) Failing after 51s
b57f8d1d8e
The setup panel is drawn entirely from varps this server owns. Script
773 registers an IF_SETONVARTRANSMIT on 1151, 563 and 1043 and redraws
from them, so a control changes server state, writes a varp, and the
screen follows -- nothing is held client-side and nothing is read back.

    varp 1151      the item
    varbit 4396    the quantity     (varp 563, bits 0-30)
    varbit 4397    buy or sell      (varp 563, bit 31)
    varbit 4398    price per item   (varp 1043, bits 0-30)

Which field is which came out of script 779, the one that *draws* them,
and the first attempt did not: assigning meaning from the transmit
trigger list put the item and the quantity the wrong way round, which
renders as an item box that will not hold its item and a quantity in the
thousands -- the item id, drawn where the count belongs. A trigger list
says which varps matter, never what they mean. GET_VARP 1151 feeding
OC_NAME is the line that settles it, because OC_NAME takes an object.

An empty item slot is -1 and not 0. Obj 0 is a real definition called
Dwarf remains, so a buy screen cleared the honest way opens showing a
pile of bones.

Both money fields are 31-bit varbits, so each is clamped below 2^31 - 1;
an overflowing price would wrap negative and the panel would draw it.
Percentage steps round towards a change, because five per cent of a
one-coin guide price is zero and a button that visibly does nothing is
worse than one that moves by the least it can.

Choosing an item seeds quantity 1 and the cache's own value as the
price, and Buy opens the search directly -- Choose item is for changing
your mind, not for starting. Typed quantity and price ride the same
integer-input path the bank and trade screens use, tagged :exchange.

Verified live: Blue partyhat at 1,131 x 15, the client's own total
agreeing at 16,965.
The first table in this schema that is not a fact about a player. Every
other one describes the account -- where it stands, what it wears, what
it knows -- and is written from a World.Player snapshot when a session
saves. An offer has no snapshot to ride: it changes on a tick when its
owner may be logged out, it belongs to the world rather than to any
session, and it must be durable at the moment it changes rather than at
the next autosave. An offer the book has taken coins for and the
database has never heard of is the one failure this feature cannot come
back from.

So Database.ExchangeWriter is a second writer with the same
mailbox-is-the-queue reasoning as the one next door: the world is a
single serialized sender, so receive order is write order. The tick may
not write directly, because a synchronous round trip in the world
process adds the database's latency to every player's frame, and a
database that is merely slow would present as the world stalling.

`placed` persists because it is a sequence and not a time. It decides
which of two offers is resting, and therefore the price a fill happens
at, so the order has to be total across a restart -- two offers placed
on the same tick would share a timestamp and the tie would be broken
differently on every boot. World.Exchanges.restore/1 seeds the counter
past the highest stored value for that reason and no other.

The two enumerated columns are mapped explicitly rather than through
String.to_existing_atom/1, which is the obvious way to write it and the
wrong one. That function raises unless the atom happens to be interned
at the moment of the call, and the moment of this call is World.init/1 --
before most of the modules that would have interned :stable are loaded.
The failure is a boot-time raise naming neither the column nor the
table: the whole book loads as empty and the log says only "not an
already existing atom". The explicit map also refuses a value outside
the set, which turns a corrupt row into one skipped offer rather than a
world that will not start.

Only an account can hold an offer. The book is keyed by identity, which
is either {:account, id} or {:name, canonical}, and the second is a
player seated without a row -- tests, the bench, the admin panel. It is
persisted as a no-op, the same answer every other path here gives.
feat: the offer lifecycle, and the six controls a client found
All checks were successful
ci / gates (pull_request) Successful in 51s
build / image (push) Successful in 31s
ci / gates (push) Successful in 54s
109d506631
Confirm, abort, collect, the matching pass in the tick, and the
sell-side pack. The screens have been working since the setup panel
landed; this is everything behind them.

Nothing here calls the world, because nothing in a session may. The
three things a player can do to the book -- place, abort, collect --
leave as claims on the barrier report, the fifth tenant, and the world
folds them after every player has taken their tick. The answer comes
back as an op. That is the trade screens' design for the trade screens'
reason: the world performing both halves means no session ever performs
half of one.

The escrow leaves at Confirm and not at the fill, so between the click
and the fold there is no moment where the player holds the coins and
the book expects them. The player's row is saved on the same breath,
which is an ordering rather than an optimisation: the offer becomes
durable about a tick later, and without the save the two facts land in
the wrong order and a crash between them reloads a player who has the
coins and an offer that also has them.

Matching runs only on a tick where a claim arrived. The book changes
only through claims and guide prices cannot move while the server is
up, so a book nobody touched can produce no fill it would not already
have produced. The cost on a quiet tick is one comparison.

State went where the 32-key cliff allowed and both homes are right
anyway: the claim in pending_world, the eight-slot mirror on
World.Account, since an offer belongs to neither a session nor a seat.

The market maker's floor is now two coins. It would otherwise sell a
red partyhat to anyone bidding a coin, out of nothing -- this cache
prices 6,563 items at 0, 4,356 at exactly 1 and 9,094 above, and a 1 is
the cache declining to price something never sold in a shop. The floor
catches the unpriced and not the valuable: a Santa hat is valued 160
here, so it can still be minted at 160, and closing that needs a real
price table.

Also fixed: the offer type was closed, and an Elixir map type is closed
unless it says otherwise. A spec naming three of ten keys means "a map
with three keys", which an offer is not, so the book could not legally
call the contract at all. Dialyzer had been reporting it as
invalid_contract and no_return on functions that were fine, never
naming the type; the gate had been red for a release with every test
passing. It is now clean.

A live sitting then found six controls the cache never names, on a
screen that renders identically whether or not the server answers them.
The catch-all click log found all six, each as an unmapped-click line
repeated while a player clicked again:

  * Offer did not start a sale. It is how a sale begins -- it claims the
    next free slot, opens the setup panel and names the item.
  * 465:23, the status panel's collect boxes, were unmapped. Script 816,
    invoked by 803 with its local 9.
  * Abort offer acted as View. The slot boxes are the one place on 465
    that puts two ops on one child, and only a right-click menu can show
    it; everywhere else the child index is the whole message.
  * Collecting the last of a payout releases the slot, leaving the
    client drawing a setup panel for a slot the book no longer has --
    with varp 1151 at 0, and obj 0 is a real definition called Dwarf
    remains. The panel can be invalidated by the world and not only by a
    click, so the viewing varbit is reset when that happens.
  * 465:6, the overview's Collect, was unmapped. Two ops on one
    component: to inventory at 1, to bank at 2.
  * 402:3 and 402:4, the Collection Box's Inventory and Bank, were
    unmapped. Here the destination is the component rather than the op,
    which is the same act addressed two ways on one screen.

The destination rides the claim rather than session state, because the
goods come back on a later tick and by then the click is long gone.

Two log lines also lied and are corrected: the collect-all read
"nothing this server maps" while succeeding, and a payout sent to the
bank on purpose logged the overflow warning. A warning that fires on
the ordinary path trains the reader to skip the one that matters.

Left deliberately: 383 history, which is a subsystem rather than a
binding -- the interface has no on_load at all, and an offer is deleted
from the book when its last payout is collected, so there is nothing to
show without a new persisted record.
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!110
No description provided.