Selling, Buying & Claiming
This page walks the three things every player does with the auction house: list an item, buy a listing, recover an expired listing. Each flow has a distinct happy path and a small set of exits.
Sell
/ah sell <price>
│
▼
ItemService.sell
│
├── DISABLED_WORLD (group not in commands-enabled-groups)
├── EMPTY_HAND (main hand empty)
├── INVALID_PRICE (price out of [1, 1_000_000_000])
├── BLACKLISTED_ITEM (material on blacklist)
├── SELL_FAILED_LISTING_LIMIT (active listings == maximum)
└── SELL_FAILED_INSUFFICIENT_FUNDS (cannot pay sell-tax)
│
▼
Remove item from inventory
│
▼
EconomyService.withdraw(seller, price * sell-tax)
│
▼
new AuctionItem(player, stack, category, price)
│
▼
ItemRepository.save
├── DB INSERT
├── ItemUpdatedPacket(uuid) → auctionhouse.items
└── Caffeine cache.put(uuid, item)
│
▼
SELL_SUCCESS ──► AnalyticsService.log("auctionhouse-listed", …)
The sell tax is always taken on listing — refunded automatically if the save fails, but not refunded when the listing later expires (use the claim flow to recover the item).
Buy
Menu click ──► [buy-item] %item_id%
│
▼
BuyItemAction
│
▼
ItemService.buy(buyer, item)
│
├── BUY_FAILED_OWN_ITEM (buyer is seller)
├── BUY_FAILED_UNAVAILABLE (status ≠ LISTED)
├── EXPIRED_ITEM (TTL elapsed)
│
▼
Redis SET auctionhouse:locks:<itemId> NX EX 30
│
│ (another server / thread already buying? → BUY_FAILED_UNAVAILABLE)
│
▼
re-fetch & re-validate state under lock
│
├── BUY_FAILED_INSUFFICIENT_FUNDS
│
▼
EconomyService.transfer(buyer → seller, price)
│
▼
ItemRepository.delete ──► ItemDeletedPacket(uuid) → all servers
│
▼
MailboxService.giveOrAdd(buyer, itemStack)
│
│ (delivery failure → refund seller, log + UNEXPECTED_ERROR)
│
▼
TransactionService.save(new AuctionTransaction(item, buyer))
│
▼
BUY_SUCCESS (buyer) + BUY_SUCCESS_SELLER (seller, if online anywhere on the network)
│
▼
AnalyticsService.log("auctionhouse-purchased", …)
│
▼
Redis DEL auctionhouse:locks:<itemId>
The mailbox hop is intentional — buyers may be on a different server (or offline) when the purchase goes through; the item lands in their mailbox and they collect it on next login.
Expire (passive)
Every 15 seconds a scheduled task scans the cache for items whose status == LISTED && createdAt + time-to-live < now(). Each match:
Redis SET auctionhouse:locks:<itemId> NX EX 30
│
│ (already locked → skip, retry next cycle)
│
▼
re-fetch & re-validate
│
▼
AuctionItem.status(EXPIRED) AuctionItem.expiredAt(now)
│
▼
ItemRepository.save ──► ItemUpdatedPacket(uuid)
│
▼
notify seller (if online): "Your listing has expired."
│
▼
Redis DEL …
The item stays in the database as EXPIRED. No money moves; the seller is expected to claim it back.
Claim (recovering an expired item)
Menu click ──► [claim-item] %item_id%
│
▼
ClaimItemAction
│
▼
ItemService.claim(seller, item)
│
├── CLAIM_FAILED_NOT_FOUND (cache + DB miss)
├── CLAIM_FAILED_NOT_CLAIMABLE (status ≠ EXPIRED)
│
▼
Optimistic invalidate from local cache
│
▼
Redis lock
│
▼
ItemRepository.delete ──► ItemDeletedPacket(uuid)
│
│ (deletion order matters: delete before give to avoid duplication on retry)
│
▼
MailboxService.giveOrAdd(seller, itemStack)
│
▼
CLAIM_SUCCESS
CANCEL_* keys exist in messages.yml for symmetry — at the time of writing there is no user-accessible "cancel listing" path. Cancellation has to go through admin tooling or the developer API.
Locking semantics
All three mutating flows (buy, claim, expire) take the same Redis lock:
key = "auctionhouse:locks:" + itemId
SET key "1" NX EX 30
The mutation is always re-validated after acquiring the lock — the state may have changed between the menu click and the lock acquisition.
Last modified: 25 July 2026