Astral Realms Documentation Help

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
  • NX means "only set if not exists" — a server racing for the same item gets a no-op.

  • EX 30 is a safety net for a crashed holder. 30 seconds is comfortably more than any of these paths take in practice.

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