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 |
|
Holding an item |
|
Sneaking |
|
Container block |
|
Not blacklisted |
|
Town permission | the chunk's claiming town grants the player |
Item allowed |
|
No existing shop |
|
Two more checks run after the event is cancelled and can message the player:
Block in front of the container. If the container's
Directionalfacing has a non-empty block in front of it (where the sign needs to go), creation fails withCREATION_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:
Claiming town exists.
TownAPI#findTownByLocationmust resolve a town — if the block isn't inside any town's claim, creation aborts (AstralTown's ownERROR_OCCURREDmessage is shown, not aPSMessagesone).Per-player limit.
repository.countByOwner(player)must be below the player's configured limit — otherwiseLIMIT_REACHED(with%limit%).Location free.
repository.existsAtre-checks for a shop at that exact location — otherwiseSHOP_ALREADY_EXISTS.PlayerShopCreateEvent. A second cancellable event, fired on the main thread with the constructed (not-yet-persisted)PlayerShop. If cancelled,CREATION_CANCELLEDis sent. AstralTown listens to this event and cancels it when the player lacks theCREATE_PLAYER_SHOPtown permission — so a shop can pass the initialBLOCK_PLACEgate inShopListenerand still be rejected here.townEconomyshops are additionally gated onBANK_WITHDRAWinsidecreate-playershopitself, beforeShopService#createis 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 |
|---|---|---|
| The owner stocks the container; players buy from it. | Player → |
| The owner leaves space in the container; players sell into it. |
|
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 may declare at most 4 lines — a longer list throws at render time. %stock% renders as:
BUY shops:
ContainerUtils#countItemsof the traded item currently in the container, orOUT_OF_STOCKwhen zero.SELL shops:
ContainerUtils#countEmptySlotsfor the container (fully-empty slots only — a slot already holding a partial stack of the same item does not count toward remaining space), orFULLwhen 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 playerhasPermissionon the shop (owner,playershops.admin, or a permitted town member — see Protections):Sneak + right-click cancels the event and opens the
playershops-mainmanagement menu (MenuService#openShopMenu), pre-loaded with the shop and itsShopStats.Any other interaction is left alone (normal vanilla behavior — e.g. a plain right-click opens the container itself, since
ContainerListeneralso 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#buyorShopService#sell(the 3-argument overload) runs. That overload does not transact directly — it computes the current stock/available space and, if non-zero, opens theplayershops-buy/playershops-sellmenu with the shop and that quantity. The buyer picks an amount there, which runsbuy-playershop-item/sell-playershop-itemto execute the trade (see Buying and selling). If the BUY shop is out of stock,CANNOT_BUY_OUT_OF_STOCKis 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 sameShopService#buy/ShopService#sellmenu-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:
Re-check. Stock/space/inventory contents are re-counted against the menu's
amountin case they changed since the menu opened (amount <= 0is a silent no-op).Economy transfer.
EconomyService#transfermovesshop.price() * amountbetween the player andshop.economyAccount()— player → shop for a buy, shop → player for a sell. A missingEconomyServicesendsUNEXPECTED_ERROR; a failed transfer sendsINSUFFICIENT_FUNDS(buyer's balance, on buy) orINSUFFICIENT_FUNDS_OWNER(shop/town balance, on sell).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, orNOT_ENOUGH_ITEMS_TO_SELL).Move items.
Buy:
ContainerUtils#removeItemspulls the items from the container; each resulting stack is delivered viaMailboxService#giveOrAdd(inventory space permitting, otherwise the player's mailbox) —PURCHASE_SUCCESSis sent with%price%, the item, and%quantity%.Sell:
ContainerUtils#removeItemspulls the items from the player's inventory andContainerUtils#addItemsinserts them into the container; any leftoversaddItemcouldn't fit (a residual safety net against a race or partial-stack rounding) are mailed back to the player viaMailboxService.SELL_SUCCESSis sent the same way.Either failure path (a thrown exception during the item move) refunds the transfer and sends
UNEXPECTED_ERROR.
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.Sign refresh + transaction record.
updateSignruns, thenTransactionService#recordTransactionpersists atransactionsrow (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):
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 |
| Breaking a shop's container is cancelled with |
Sign drop suppression |
| If the broken block's location is in the delete cache, any |
Block-place-near |
| Placing another non-blacklisted container block immediately north/east/south/west of an existing shop's container is cancelled with |
Hopper-under-shop |
| Placing a |
Container-open cancel |
| Opening the shop's container inventory (including either half of a double chest) is silently cancelled for a player without |
Town integration
Existence. A shop can only ever be created on land claimed by a town at all —
TownAPI#findTownByLocationmust resolve, independent oftownShared/townEconomy. SeeShopService#create.townShared. When true,PlayerShop#hasPermissionadditionally grants town members holdingUSE_PLAYER_SHOPSthe same owner-equivalent access as the owner: opening theplayershops-mainmenu, 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,economyAccountis set to the claiming town's UUID at creation (CreateShopAction) or edit time (EditShopAction), andshop.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 holdBANK_WITHDRAWon the town at that location (checked in bothcreate-playershopandedit-playershop); failing that check sendsNO_WITHDRAWAL_PERMISSIONand the request is dropped, regardless of the shop'sShopType.CREATE_PLAYER_SHOPpermission. Beyond theBLOCK_PLACEgate checked directly inShopListener#onCreate, AstralTown separately listens toPlayerShopCreateEventand cancels it (silently, which then surfaces asCREATION_CANCELLEDfromShopService#create) when the player lacksCREATE_PLAYER_SHOPon 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 anupdates.enabled-worldsworld): a no-op if the chunk's world isn't inupdates.enabled-worlds. OtherwiseShopRepository#buildChunkCacherebuilds the chunk → shop-ids index from the in-memory shop cache (populated once at startup for the current server'sserver_idonly), and a hologram is spawned for every shop found in the chunk.unloadChunk(ChunkUnloadEvent):ShopRepository#invalidateChunkCachedrops 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 callsChunkTickTask#onChunkUnloadto evict the chunk from the tick task's processed-chunk tracking, preventing unbounded growth.tickChunk(ChunkTickTask, a repeating task scheduled everyupdates.interval-ticksticks, started 100 ticks after enable — and only if the current server's group is inupdates.enabled-groups): round-robins up toupdates.chunks-per-ticknot-yet-processed loaded chunks per run, across each configured world, callingShopRepository#findByChunk(cache read, no DB hit) thenupdateSignfor 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.