feat: Shops #108

Merged
sickday merged 8 commits from feat/shops into main 2026-08-03 16:07:11 +00:00
Owner
No description provided.
`Cache.Script` has decoded the CS2 opcode stream since the settings tab
needed it, but nothing could print one, so every interface job so far has
built a scratch listing tool and thrown it away.

This is the printer over that decoder: a table of 539 opcode names and
`mix revenant.script.dis`, which takes script ids and optionally follows
their calls.

The names are cosmetic and the module says so — the number and the operand
are read from the operator's own cache, so a name that a later revision
renamed costs legibility and cannot misreport what a script does. An
opcode the table does not know prints as its number.

Three annotations, all of them this task's reading rather than a claim the
cache makes: an operand that looks like a component hash, the placeholder a
listener carries for its own component, and a branch's absolute destination
— a jump operand is relative to the next instruction, so a listing that
prints it raw cannot be followed.
The shop is the first screen here whose components do not exist until the
server asks for them: group 300 ships with an empty grid, and script 1074
creates one child per stock slot at open. That forces the packet order —
bind, run the script, then set the masks, because the masks address
children the script has just created.

The sell side had no derivation for a while, and the reason turned out to
be structural. No `Sell` string exists anywhere in this cache: not in a
widget's baked actions, not in any of the 1,940 client scripts, and the
client's own action setter is reachable only from the script interpreter,
so no packet can set an op at all. The labels travel on the wire instead —
script 149 is a generic inventory painter that takes five op labels as
string arguments, paints a child per slot, and registers its own redraw.
Two era-adjacent servers make the identical call, which is what makes the
argument order corroborated rather than merely derived.

`Sell 50` is left out. Both of those servers send it, and this cache's own
buy side has no `Buy 50` where a 2020 cache does, so the fifth option
arrived somewhere between the two. The client here accepts five, so that
is a claim about the game rather than about the cache, and it is the one
number on this screen that wants a dated source.

The tests re-read the cache rather than asserting the constants back:
the grid the script fills is the grid this module addresses, and every op
enabled is one the script actually labels.
The screen needed something behind it. Three pieces: the table, the rule
set that prices it, and the shelf everybody shares.

No shop price is stored. Each shop states three percentages and the cache
states every item's value, so a price is arithmetic: value moved by how far
stock has drifted from the shop's default, from a higher starting
percentage when buying than when selling. Verified against a published
worked example rather than asserted — a steel axe at Bob's is 200 and 196
buying, 120 and 116 selling, and all four reproduce exactly.

Percentages are in tenths of one per cent. One of these five shops changes
by 0.1% per rune against five thousand of available drift, so a
whole-percent unit would round its entire behaviour to nothing.

The shelf is a public ETS table under TableKeeper rather than a process,
because a session may not call the world — a call from a seated player
deadlocks the tick barrier. So a purchase never reads and then writes: it
decrements optimistically and repairs its own overdraw, which is the ground
item compare-and-swap arriving at a counter. Forty concurrent buyers
reaching for a shelf of fifty are told they took fifty between them, and
the shelf settles at zero rather than below it.

Every item name in the table was ambiguous — a noted copy, a bank
placeholder, and for four of them a second real item. They are narrowed by
the note and placeholder fields, then by the published price (a shop price
is value times the sell percentage, so an Air rune at 4 coins is the id
worth 4, not the four quest copies worth 1), then by tables already shipped
here: firemaking pins the tinderbox, crafting the chisel, fletching the
knife. Shears rests on the structure of those three rejects and says so.

An item absent from the cache is dropped, which is the era gate working —
one pack row went that way. Every other pack row is dropped on purpose: the
live stock list is today's, not 2017's, and pack availability is not
something the wiki dates.
Four pieces of plumbing, none of them interesting on its own.

The stock mirror is a fourth container on World.Inventory, and it is the
first one that is not the player's — it is this viewer's copy of world
state, kept in the same struct so the diff that keeps the other three
honest keeps it honest too. Its inventory id travels with it because the id
is the shop's rather than fixed, and a mirror with no id is simply not
flushed. Closing forgets the sync record as well as the contents: the next
shop is a different shelf under the same id, and a diff between two shops
is nonsense.

Clicks join the if_button chain after the bank and fall through to it,
which is where every other screen's buttons already go.

Closing is a fourth modal slot rather than a reuse of the bank's. It is the
same two frame slots for the same reason — main plus the inventory tab, so
half a shop cannot be left bound over the pack — but it also has to forget
the mirror, which a bank has no equivalent of.

Buying takes from the shelf first and selling takes from the backpack
first. That asymmetry is deliberate: each direction mutates the contended
side first, and which side is contended differs. A purchase that then fails
to fit in the pack puts the stock back.
The five shops were correct and unreachable. The operator's NPC dump holds
17,906 spawns and not one city shopkeeper: 294 definitions carry a Trade
action, 186 of those are spawned, and every one of them is peripheral. Around
the Lumbridge general store the dump has a goblin and three combat tutors.

The ids to fix that with do not port. An era-adjacent server and the live
wiki agree that the Lumbridge keeper is NPC 2813; in this cache 2813 is a
Piglet, 2812 is a Pig, and the reference's Aubury is a Monk of Zamorak. So
the coordinates are ported and the ids are ours, which is the rule the
landmark work established from the other direction. Every coordinate was
checked against the operator's own map: walkable, no existing spawn on the
tile, and an NPC id with no existing spawn anywhere, so nothing here
duplicates a creature already in the world. The world goes from 17,906 to
17,911 and the pinned count does not move.

What the cache cannot say is recorded rather than guessed: all ten of its
Shop keeper definitions carry the same name and the same two actions, so
which one belongs to which store is not derivable. A distinct id is assigned
per store and the spawn is what identifies the shop. Assistants are not
spawned — a second keeper per store would double that arbitrary choice and
buy nothing.

The rows live in shops.exs and are appended by the loader rather than written
into npc_spawns.exs, because that file is a dump and a re-extract would
either drop hand-written rows or duplicate them. Wander is 0 for all five,
the same pin already applied to anything carrying a Bank action: a keeper who
strolls off the counter is out of reach of the option that opens the shop.
Every one of them looked correct on screen.

The stock grid was drawn in the shelf table's hash order rather than the
shop's own. The shelf is a set, so match_object hands back whatever order
hashing gives — arbitrary, different per shop, and it *changes* when a
general store gains a row somebody sold into it. The grid looked entirely
plausible; only the click log gave it away, reporting the fifth declared
item in the second slot. The declared list drives the order now and the
table only supplies counts, with sold-in items following in id order so a
general store's grid is stable too.

Talk-to opened the shop. The clause asked whether the NPC keeps one instead
of whether the option was Trade, which is what the banker clause directly
above it does, so every option on a keeper opened a shop: Bob's Talk-to and
Repair, Aubury's Teleport. The verb is now half the question and it comes
from the cache rather than a list.

Worth recording why the suite missed that: every shop test asked whether
the right shop opens for a keeper, and none asked whether the wrong thing
opens as well. A test that only walks the intended path cannot see a gate
that is too wide.

Value quoted a price at a shop that would refuse the sale, which read as a
shop willing to buy something it will not. It now refuses with the same
message the sale itself would give.

And a shop priced at one coin said "1 coins".
fix: make a purchase and a sale observable
All checks were successful
ci / gates (pull_request) Successful in 52s
7c6ca62d2e
Both success paths logged nothing. Only the click was recorded, so a live
sitting could see that somebody clicked Buy 10 and not what they got, what
it cost, or whether the per-unit price walk happened at all.

That is the bank's finding shipped again two features later: a silent
success path makes a working mechanism unfalsifiable from the log, and a
broken one indistinguishable from a working one on screen. Every purchase
and sale now reports what moved, what it cost and what is left on the
shelf, and every refusal reports its reason.
Merge branch 'main' into feat/shops
All checks were successful
ci / gates (pull_request) Successful in 47s
build / image (push) Successful in 27s
ci / gates (push) Successful in 48s
2ad53963a6
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!108
No description provided.