feat: Trading #109

Merged
sickday merged 5 commits from feat/trade into main 2026-08-03 19:21:30 +00:00
Owner
No description provided.
The pure layer first, because it is where every anti-duplication rule lives
and it is testable without a client, a second player or a tick.

The rule the design rests on is that an offered item has already left the
backpack: offering is a move rather than a promise, and completion delivers
each side's own offer to the other. After an offer nobody holds the item, so
no sequence of clicks, disconnects or desynchronised packets can spend it
twice. Everything else here keeps that true — an offer never exceeds what is
held, a removal never exceeds what was offered, and neither is answered once
the confirm screen is up.

swap?/2 takes two independent views rather than one, and requires that each
names the other. One side's word is never enough to move two players' items,
which is the pure-layer half of the authentication the sister project does
with a pid comparison.

changed/2 clears both acceptances and drops both screens, not just the
changer's. A version that only reset the side that changed would be worse
than no rule at all: it is exactly the accept-then-swap trick.

The tests are written as attacks rather than as behaviour, and the two
properties are the anti-duplication argument at this layer — for every input,
an offer never exceeds what is held and a removal never exceeds what was
offered.
The offer screen builds itself. 335:2's on_load names script 755 and hands it
this player's two components and the partner's two, so binding the interface
is what creates the slots — the opposite of the shop, where the grid ships
empty and its builder has to be invoked. The reason is worth keeping: a
screen whose contents the server chooses at open time cannot have its builder
in an on_load, because an on_load takes no arguments from the server.

Both offers are inventory 90. The partner's is the same id with bit 15 set,
because the client's INVOTHER opcodes are the ordinary ones with 32768 added,
looking the container up in the same store. So the partner's offer rides the
existing container packets and no protocol work was needed at all. Sending it
as a plain 90 is the bug the constant exists to prevent: it overwrites the
viewer's own panel with their partner's items and the screen mirrors itself.

The op labels split the way the shop's did — script 763 labels this player's
offer panel for free, and the backpack side is painted by the same generic
painter the shop's sell side uses. Offer-X is sent as an empty string, which
is how an op is omitted on a painter-built grid: the client shows no action
whose text is blank, and the mask does not gate a grid with an onOp handler.

One address is a guess and says so in the moduledoc. 335:31 is written with
the partner's name because it is the only full-width text strip at the top,
but nothing in the cache states it — no script writes it and no listener
names it, unlike every other number here. It is the first thing to check in a
sitting and it is cosmetic either way.

The tests re-read the cache rather than asserting the constants back: the
panels this module addresses are the panels the interface hands its builder,
in that order.
An offered item leaves the backpack at the moment it is offered, so a trade
needs somewhere for it to be that is neither the pack nor the partner's. Two
more containers on World.Inventory, riding the diff-and-flush the other four
already ride.

The partner's is the same inventory id with bit 15 set. The client's INVOTHER
opcodes are the ordinary ones with 32768 added, looking the container up in the
same store, so the partner's offer travels on the existing container packets
and no protocol work was needed. Sending it as a plain 90 is the bug the
constant exists to prevent: it overwrites the viewer's own panel with their
partner's items and the screen mirrors itself.

trading? is the one new condition in flush_one/2, and it is not tidiness. The
other four containers are always meaningful — a player always has a backpack —
but an empty offer sent to a client with no trade open clears inventory 90 on a
screen that is not there, and the partner's would do it under an id nothing is
reading.

Both stack everything, as a bank does: the trade window shows five iron
platebodies as one icon with a five on it, which is also why the room gate on
the far side cannot count offer slots.
The rest of it: the request handshake, the offer and confirm screens, the
acceptances, the exchange and every way a trade ends.

The swap is the barrier report's fourth tenant, and the distinction worth
keeping is that the other three are things that happened while this is a claim.
A player who has accepted the confirm screen reports their own offer and their
partner's seat; the world matches the reports after every player has moved and
hands each offer to the other side. So no session ever performs half a swap,
because no session performs any of it — there is no in-flight window where one
side has committed and the other has not, which is the window a write-ahead
journal exists to close. It also deletes the initiator asymmetry, and with it
the simultaneous-accept hang that has to be fixed separately once a completion
has one. The {low, high} in matches/1 is deduplication of a symmetric fold and
is deliberately not that initiator returning.

Items move only where two independent layers agree: the seats, which only the
world knows, and the accounts, which only the sessions stated. A forged or
desynchronised half satisfies neither on its own.

Somebody has to remember a trade request and it cannot be either session — one
may not call the other and neither may call the world. So the world holds them,
with an expiry, and a match consumes both: without that, a finished trade
reopens the moment either player clicks anything.

The bug the suite caught is a contract-shape one. Contract.Trade.accept/2
answers given that this player has just accepted, and the first wiring called it
from the partner's acceptance too, so one click from either side advanced a
player who had accepted nothing to the screen that arms the swap. Each side now
asks only when it already accepted, and both ask independently the moment they
can see two — which is also why two clicks in the same tick advance both rather
than hanging both.

Offer-X and Remove-X are answered rather than omitted, because script 763 sets
Remove-X unconditionally and a mask does not gate a script-built grid: an op
that appears must work. Scripts 1216 and 1217 are the value tooltip and nothing
in the cache calls them, so the server does.

The teardown has one entry point, Modal.close/1, because it is the only close in
the game that moves items — and why the trade ended travels on the trade, since
none of the five callers knows. A delivery that will not fit goes to the bank
rather than the floor: nothing here may destroy an item and a floor pile can be
walked away from.

Autosave is skipped for the length of a trade, which is what makes a server
crash a clean revert rather than a loss, and the logout save returns the offer
first. The residual risk is single-sided loss on a delivery, which is the
correct trade: a loss is recoverable by an operator and a duplication is not.

The tests seat both players in one process, so every cross-session send lands in
the test's mailbox and is applied by hand. That is the design made visible
rather than a shortcut around it, and the swap they exercise is the world's own
pure matcher. Most of them are written as attacks, and the two that matter count
items across the whole pair before and after.
feat: Trading
All checks were successful
ci / gates (pull_request) Successful in 48s
build / image (push) Successful in 29s
ci / gates (push) Successful in 53s
3581b17ba6
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!109
No description provided.