Astral Realms Documentation Help

Shops

A shop is a single un-blacklisted Container block (chest, barrel, shulker box, …) claimed by a player as either a BUY or SELL point. Every shop is backed by a PlayerShop row, a live OAK_WALL_SIGN placed on the container's facing side, and a packet-only floating-item hologram. ShopService is the single read/write path for all shop state — lookup, creation, buying, selling, deletion, and the periodic sign refresh.

Creating a shop

Creation starts in ShopListener#onCreate, which listens to PlayerInteractEvent at LOW priority.

Creation gates

All of the following must hold, or the handler returns without cancelling the event and without any message — meaning a normal vanilla right-click (e.g. opening the container) proceeds untouched:

Gate

Check

Right-click, main hand

Action.RIGHT_CLICK_BLOCK and EquipmentSlot.HAND (not off-hand)

Holding an item

PlayerInteractEvent#hasItem()

Sneaking

player.isSneaking()

Container block

block.getState() instanceof Container

Not blacklisted

block.getType() not in blacklisted-containers

Town permission

the chunk's claiming town grants the player BLOCK_PLACE at that location — no claiming town at all also fails this gate

Item allowed

item.getType() not in ignored-items

No existing shop

ShopService#findByLocation finds nothing at that block already

Two more checks run after the event is cancelled and can message the player:

  • Block in front of the container. If the container's Directional facing has a non-empty block in front of it (where the sign needs to go), creation fails with CREATION_FAILED_BLOCK_IN_FRONT.

  • PlayerShopPreCreateEvent. A cancellable pre-create event is fired with the player, block, location, and held item. If a listener cancels it, the handler silently returns (no message).

Only after all of the above does the plugin open the playershops-creation dialog with item (ItemStackPlaceholder) and location context values. The dialog's confirm action is create-playershop, which collects the price, ShopType, and (if the block is inside a town) whether the shop is townShared and/or townEconomy-routed, and calls ShopService#create.

ShopService#create

create runs the remaining checks, in order, each of which can abort creation:

  1. Claiming town exists. TownAPI#findTownByLocation must resolve a town — if the block isn't inside any town's claim, creation aborts (AstralTown's own ERROR_OCCURRED message is shown, not a PSMessages one).

  2. Per-player limit. repository.countByOwner(player) must be below the player's configured limit — otherwise LIMIT_REACHED (with %limit%).

  3. Location free. repository.existsAt re-checks for a shop at that exact location — otherwise SHOP_ALREADY_EXISTS.

  4. PlayerShopCreateEvent. A second cancellable event, fired on the main thread with the constructed (not-yet-persisted) PlayerShop. If cancelled, CREATION_CANCELLED is sent. AstralTown listens to this event and cancels it when the player lacks the CREATE_PLAYER_SHOP town permission — so a shop can pass the initial BLOCK_PLACE gate in ShopListener and still be rejected here. townEconomy shops are additionally gated on BANK_WITHDRAW inside create-playershop itself, before ShopService#create is even called.

Only once all four pass is the shop saved (ShopRepository#save, populating the UUID and chunk caches) and its sign and hologram created.

Shop types

ShopType is a two-value enum stored as an ordinal (0 = BUY, 1 = SELL):

Type

Direction

Funds flow

BUY

The owner stocks the container; players buy from it.

Player → shop.economyAccount()

SELL

The owner leaves space in the container; players sell into it.

shop.economyAccount() → player

shop.economyAccount() resolves to the shop's economyAccount field if set (a town's UUID, when townEconomy is enabled), or to ownerId otherwise — see Town integration. The type can be changed later via edit-playershop from the shop's management menu.

Sign and hologram display

ShopService#createDisplay (called once at creation) spawns both the sign and the hologram; both are refreshed independently afterwards.

The sign

updateSign resolves the sign block as the block relative to the container in its Directional facing direction. If that block isn't already a WallSign and is empty, it is turned into an OAK_WALL_SIGN facing the same way. The sign's front side is then rendered from the sign config list (a ComponentWrapperList, evaluated against a PlaceholderContainer carrying the shop's own %shop_*% placeholders plus a direct %stock%):

sign: - "Seller: %shop_ownerName%" - "Stock: %stock%" - "Price: $%shop_price%" - "%shop_typeFormatted%"

sign may declare at most 4 lines — a longer list throws at render time. %stock% renders as:

  • BUY shops: ContainerUtils#countItems of the traded item currently in the container, or OUT_OF_STOCK when zero.

  • SELL shops: ContainerUtils#countEmptySlots for the container (fully-empty slots only — a slot already holding a partial stack of the same item does not count toward remaining space), or FULL when zero.

The sign is refreshed after every buy/sell, after the container's inventory is closed (ContainerListener#onInventoryClose, single or double chest), and on the periodic chunk-tick sweep — so restocking a BUY shop or emptying a SELL shop by hand through the vanilla inventory UI updates the sign as soon as the owner closes it.

Available %shop_*% sub-keys used above and elsewhere (the shop is a ComplexPlaceholder under namespace shop): id, owner, ownerName, price, material, type, typeFormatted, item, location, townShared, townEconomy. Full reference: Placeholders.

The hologram

A PacketItem (a packet-only, per-viewer entity from AstralPacketFramework — no real entity is spawned) is placed at the container's location offset by (+0.5, +0.85, +0.5), holding a clone of the traded item (amount forced to 1) with gravity disabled. If that item's meta is an EnchantmentStorageMeta (an enchanted book) holding exactly one stored enchantment, the hologram gets a visible custom name — the enchantment's translatable name plus its level rendered as a roman numeral (e.g. "Efficiency V") — so single-enchant books are readable at a glance without needing to hover.

Holograms are not persistent entities: they exist only in memory (Map<UUID, PacketItem> in ShopService) and are re-spawned on chunk load / despawned on chunk unload — see Chunk lifecycle.

Interacting with a shop

InteractionListener handles two entry points, both resolving the shop via ShopService#findByLocation (which matches a click on the container or on its sign — findContainer walks a clicked WallSign back to its neighboring container).

  • PlayerInteractEvent. If the clicking player hasPermission on the shop (owner, playershops.admin, or a permitted town member — see Protections):

    • Sneak + right-click cancels the event and opens the playershops-main management menu (MenuService#openShopMenu), pre-loaded with the shop and its ShopStats.

    • Any other interaction is left alone (normal vanilla behavior — e.g. a plain right-click opens the container itself, since ContainerListener also does not cancel opens for a permitted player).

    If the player does not have permission, the event is always cancelled and, depending on shop.type(), ShopService#buy or ShopService#sell (the 3-argument overload) runs. That overload does not transact directly — it computes the current stock/available space and, if non-zero, opens the playershops-buy/playershops-sell menu with the shop and that quantity. The buyer picks an amount there, which runs buy-playershop-item/sell-playershop-item to execute the trade (see Buying and selling). If the BUY shop is out of stock, CANNOT_BUY_OUT_OF_STOCK is sent instead of a menu; if the SELL shop's container has no free slots, CHEST_FULL; if the SELL shop's container has space but the player is carrying none of the traded item, NOT_ENOUGH_ITEMS_TO_SELL.

  • PlayerOpenSignEvent. Fired when a player attempts to open the sign's text editor. The event is always cancelled. If the opener is the owner, the backing container's inventory is opened directly instead (so an owner "editing" the sign is redirected straight to restocking the chest). Otherwise it falls back to the same ShopService#buy/ShopService#sell menu-opening path as above, using the sign's block.

Buying and selling

The 4-argument ShopService#buy(player, shop, chestBlock, amount) and ShopService#sell(player, shop, chestBlock, amount) overloads (invoked by buy-playershop-item/sell-playershop-item with the amount chosen in the menu) both follow the same shape:

  1. Re-check. Stock/space/inventory contents are re-counted against the menu's amount in case they changed since the menu opened (amount <= 0 is a silent no-op).

  2. Economy transfer. EconomyService#transfer moves shop.price() * amount between the player and shop.economyAccount() — player → shop for a buy, shop → player for a sell. A missing EconomyService sends UNEXPECTED_ERROR; a failed transfer sends INSUFFICIENT_FUNDS (buyer's balance, on buy) or INSUFFICIENT_FUNDS_OWNER (shop/town balance, on sell).

  3. Main-thread re-check. On a successful transfer, the handler hops back to the main thread and re-verifies stock/space/inventory again, atomically, immediately before touching items — guarding against changes made between the (async) economy call and this point. A failure here refunds the transfer (see below) and sends the matching message (CANNOT_BUY_OUT_OF_STOCK, CHEST_FULL, or NOT_ENOUGH_ITEMS_TO_SELL).

  4. Move items.

    • Buy: ContainerUtils#removeItems pulls the items from the container; each resulting stack is delivered via MailboxService#giveOrAdd (inventory space permitting, otherwise the player's mailbox) — PURCHASE_SUCCESS is sent with %price%, the item, and %quantity%.

    • Sell: ContainerUtils#removeItems pulls the items from the player's inventory and ContainerUtils#addItems inserts them into the container; any leftovers addItem couldn't fit (a residual safety net against a race or partial-stack rounding) are mailed back to the player via MailboxService. SELL_SUCCESS is sent the same way.

    • Either failure path (a thrown exception during the item move) refunds the transfer and sends UNEXPECTED_ERROR.

  5. Owner notification. If the owner's PSPlayerData#recapEnabled (synced via AstralSync) is true, a chat message (BUY_NOTIFICATION/SELL_NOTIFICATION) is sent to them regardless of whether they're online — see Activity Recaps.

  6. Sign refresh + transaction record. updateSign runs, then TransactionService#recordTransaction persists a transactions row (shopId, buyer, quantity, unit price) for history and recap purposes.

Refunds

refundBuyer/refundSeller reverse a transfer that must be undone (stock/space/inventory changed between steps 2 and 3, or an exception during step 4). Each first attempts the reverse EconomyService#transfer call; if that call throws or returns false, it falls back to a raw EconomyService#deposit on the party that's owed money, to guarantee they're made whole even if the counter-party's balance can't be debited a second time. If that also fails, a CRITICAL line is logged — this is the one path where a refund can be permanently lost and needs manual admin intervention.

Per-player shop limits

PSConfiguration#limit(player) starts from limits.default and raises it to the value of every entry in limits.permissions whose permission node the player holds (highest wins, not additive):

limits: default: 0 permissions: example.permission.one: 5 example.permission.two: 10

With the shipped defaults a player with neither permission node has a limit of 0 — i.e. cannot create any shop until granted one of the limits.permissions nodes (or a higher default). The limit is enforced in ShopService#create against repository.countByOwner, before the location-exists check, via LIMIT_REACHED. See Configuration.

Protections

All owner-equivalent checks below go through PlayerShop#hasPermission(player): true for the shop's owner, for anyone holding playershops.admin, or — if the shop is townShared — for a town member holding USE_PLAYER_SHOPS at the shop's location. Note this governs management access, not trading: buying and selling are open to any player regardless of hasPermission (see Interacting with a shop).

Protection

Listener

Behavior

Deletion

ShopListener#onDelete (BlockBreakEvent)

Breaking a shop's container is cancelled with NO_PERMISSION_DELETE unless hasPermission. On an authorized break, the shop's location is added to a 10-second Caffeine delete cache before ShopService#delete runs.

Sign drop suppression

ShopListener#onBlockDrop (BlockDropItemEvent)

If the broken block's location is in the delete cache, any OAK_SIGN/OAK_WALL_SIGN drops from the break are stripped out of the event's item list, then the cache entry is invalidated — so deleting a shop doesn't also drop its sign.

Block-place-near

ShopListener#onBlockPlace

Placing another non-blacklisted container block immediately north/east/south/west of an existing shop's container is cancelled with NO_PERMISSION_PLACE_NEAR unless the placer hasPermission on that neighboring shop.

Hopper-under-shop

ShopListener#onHopperPlace

Placing a HOPPER directly beneath a shop's container (getRelative(0, 1, 0) resolves to the shop) is silently cancelled unless hasPermission.

Container-open cancel

ContainerListener#onInventoryOpen/#onDoubleCheckInventory

Opening the shop's container inventory (including either half of a double chest) is silently cancelled for a player without hasPermission.

Town integration

  • Existence. A shop can only ever be created on land claimed by a town at all — TownAPI#findTownByLocation must resolve, independent of townShared/townEconomy. See ShopService#create.

  • townShared. When true, PlayerShop#hasPermission additionally grants town members holding USE_PLAYER_SHOPS the same owner-equivalent access as the owner: opening the playershops-main menu, breaking/deleting the shop, opening its raw container, and bypassing the block-place-near/hopper guards. It does not change who can buy or sell — that's already open to everyone.

  • townEconomy. When true, economyAccount is set to the claiming town's UUID at creation (CreateShopAction) or edit time (EditShopAction), and shop.economyAccount() then resolves to that town instead of the owner — so every buy/sell transfer in Buying and selling moves funds through the town's bank account instead of the owner's personal balance. Enabling it requires the requesting player to hold BANK_WITHDRAW on the town at that location (checked in both create-playershop and edit-playershop); failing that check sends NO_WITHDRAWAL_PERMISSION and the request is dropped, regardless of the shop's ShopType.

  • CREATE_PLAYER_SHOP permission. Beyond the BLOCK_PLACE gate checked directly in ShopListener#onCreate, AstralTown separately listens to PlayerShopCreateEvent and cancels it (silently, which then surfaces as CREATION_CANCELLED from ShopService#create) when the player lacks CREATE_PLAYER_SHOP on the claiming town. See Roles & Permissions.

Chunk lifecycle

Signs are ordinary world blocks and persist with the chunk automatically. Holograms (PacketItems) are not — ShopService keeps them in sync with chunk load state, and ChunkTickTask periodically refreshes signs.

  • loadChunk (ChunkLoadEvent, and once at startup for every already-loaded chunk in an updates.enabled-worlds world): a no-op if the chunk's world isn't in updates.enabled-worlds. Otherwise ShopRepository#buildChunkCache rebuilds the chunk → shop-ids index from the in-memory shop cache (populated once at startup for the current server's server_id only), and a hologram is spawned for every shop found in the chunk.

  • unloadChunk (ChunkUnloadEvent): ShopRepository#invalidateChunkCache drops the chunk's index entry and returns the shops that were in it; each one's hologram is despawned and removed from the tracking map. The same handler also calls ChunkTickTask#onChunkUnload to evict the chunk from the tick task's processed-chunk tracking, preventing unbounded growth.

  • tickChunk (ChunkTickTask, a repeating task scheduled every updates.interval-ticks ticks, started 100 ticks after enable — and only if the current server's group is in updates.enabled-groups): round-robins up to updates.chunks-per-tick not-yet-processed loaded chunks per run, across each configured world, calling ShopRepository#findByChunk (cache read, no DB hit) then updateSign for every shop found. Each world tracks its own processed-chunk set; once every currently loaded chunk in that world has been swept, the set clears and the cycle restarts — this keeps a single tick cheap regardless of how many chunks are loaded.

updates.enabled-worlds, enabled-groups, and chunks-per-tick are captured once when ShopService constructs its ChunkTickTask at plugin enable — /ps reload reloads config.yml but does not reconstruct ShopService, so changes to those specific keys need a server restart to take effect. limits, sign, blacklisted-containers, ignored-items, and recap-interval are all read live off plugin.configuration() and do apply immediately on reload.

Last modified: 25 July 2026