A dynamic economy for ShopGUI+
How the ShopGUI+ fork moves sell prices with supply and demand, stays correct across threads and downtime, and can't be arbitraged.
On this page 6 sections
ShopGUI+ is one of the most widely used shop plugins, and it has fixed prices. Sell something and you get the same amount whether you’re the first person to sell it today or the thousandth. On a server where players can farm an item endlessly, that price only ever means one thing: how fast the economy inflates.
The fork adds a dynamic sell price per item, and a watch list so players can follow the prices they care about.
Four states
Each dynamic item moves through a small state machine:
- Deficit. Selling pushes the price down a little per unit sold, then it grows back toward base on a timer.
- Base. An evaluation window. If too few units sell during it, the item is under-supplied and moves to surplus.
- Surplus. The price climbs on a timer toward its maximum, rewarding whoever brings the item back to market. Enough sales trigger a correction.
- Correction. The price decays back down to base.
Lore placeholders show the base price, a trend arrow and colour, the change as an amount and a percentage, and the current state, so players can read the market straight off the item. Menus repaint in place when a price moves instead of reopening, which means no flicker and no fresh inventory window a dupe could slip through.
Correct across threads
On Folia, players on different region threads can sell the same item in the same instant. A plain price -= step read-modify-write would lose sales.
Instead, each item’s state is an immutable snapshot behind an atomic reference, and a sale is a compare-and-set that applies the transition to whatever the current snapshot is. Two concurrent sales can’t both read the same old price; one retries against the other’s result. A separate dirty flag is claimed the same way, so the flusher writes each change to the database exactly once. The common case, checking whether an item even needs a timer step, is a single volatile read before any compare-and-set happens.
Correct across downtime
Price movement is timed, which raises an awkward question: what happens when the server is off for a week, or stalls for ten seconds?
Rather than ticking the machine once per period and hoping every tick arrives, the engine applies every elapsed period at once, in closed form. Growth over n periods is one power, and the number of periods until a state boundary is one logarithm, so a server that’s been down a week lands exactly where it would have been, and a lag spike causes no drift. A cap on transitions per step keeps a pathological config from spinning.
Prices are persisted in batched upserts on a timer, with the flush interval documented as the most a crash can lose. If the initial load from the database fails, all writes are suspended, so a bad boot can’t overwrite everyone’s saved prices with defaults.
Linked prices
Some items are obviously made of others. A block of coal is nine coal, and it’d be strange for the two to drift apart.
A linked item’s price is the sum of its ingredients’ prices, times a multiplier, plus an offset. Selling a coal block counts as selling nine coal toward coal’s own state. Links can chain, so they’re resolved in topological layers, and a config with a cycle (A links to B links to A) is caught by an iterative depth-first search that can’t overflow the stack. The offending items fall back to a static price with a warning instead of taking the server down. A short cache means painting a page of linked items walks the graph once.
Can’t be arbitraged
A dynamic sell price next to a fixed buy price is an arbitrage machine waiting to happen: wait for surplus, buy from the shop, sell back to the shop for more. So on load, any dynamic maximum above the item’s buy price is capped at it, and if the buy price sits at or below the dynamic minimum, dynamic pricing is simply switched off for that item.
The same pass fixed rounding, which was quietly exploitable in the original: buy prices now round up with a minimum of one unit, and sell prices round down. Rounding both sides the same way meant splitting a trade into single items could turn a profit, and rounding down could make a single-item purchase free.
A malformed dynamic-pricing block is logged and leaves the item static, on the principle that a typo should never be able to run a price away.
Watching the market
/shop watch opens a watch list where players pin the items they care about. They get a chat alert when an item changes state, at each step of a surplus climb, and when it hits its maximum. Alerts are computed on the pricing thread, which touches no player and no inventory until it has hopped onto the recipient’s own region thread. Admins can reset one price or a whole shop, optionally without alerting watchers.