- **Fixed** Critical cross-server duplication where a sold item reappeared in the seller's items (multi-server / Redis). When `purchased-item.give-item: true`, a purchase set the database row to `DELETED` before broadcasting the `ItemBought` event; on every other server the `ItemBoughtListener` re-fetched the item via `selectItem` (which filters out `DELETED` rows), got `null`, and bailed out **before** removing the listing from its in-memory store. The ghost listing therefore survived on all non-buyer servers and the seller still saw it in `/ah selling`. The listener now removes the listing from in-memory storage **unconditionally** (mirroring `ItemRemovedListener`), independently of the database lookup. `ItemBoughtMessage` was enriched with the seller UUID/name, item display, price and buyer name so the real-time seller notification (including `%buyer%`, which was already broken on remote servers) works without a database round-trip
- **Fixed** Purchased items not appearing on other servers with `purchased-item.give-item: false` (multi-server / Redis). The `ItemBoughtListener` removed the item from `LISTED` on remote servers but never added it to their in-memory `PURCHASED` store, so a buyer switching servers could not see their purchase until a restart. Remote servers now re-add the item to `PURCHASED` (the buyer-server purchase already did this locally; the buyer-server ignores its own message). For `give-item: true` the message flags `givenToBuyer` so remote servers skip the (always-null) database lookup entirely
- **Fixed** A crashed/timed-out purchase could leave an item permanently un-buyable in multi-server (Redis) setups. The lock key `auction:lock:<id>` expires via TTL but the item hash field `state=LOCKED` persisted (up to the item TTL, ~30 days), so `checkAvailability` reported the item as locked forever. Lock state now keys on the lock key's existence: a `LOCKED` state with no live lock key is treated as a stale lock and recovered. To prevent this recovery from ever enabling a double-purchase, `PurchaseService` now re-validates authoritative database state **under the lock** before charging — a row that is already `DELETED` or has a buyer aborts the purchase with nothing withdrawn
- **Fixed** Expiration is now cluster-aware in multi-server (Redis) setups. Previously `ExpireService` moved items `LISTED -> EXPIRED` purely locally with no cluster coordination, so a lingering ghost of an item sold on another server could be resurrected as an `EXPIRED` item for the seller (and claimed back = duplication). In distributed setups the `LISTED -> EXPIRED` transition now locks the item, re-reads authoritative database state (skipping items sold elsewhere), performs the move, and broadcasts it so all nodes converge. Single-server setups keep the original fast path
- **Fixed** `ItemRepository` could overwrite a sold/removed item when expiring. The `LISTED -> EXPIRED` update is now guarded with `WHERE storage_type = 'LISTED'`, so an expiration `UPDATE` can never clobber a row another server already set to `DELETED`/`PURCHASED`
- **Added** DonutAuction migration - migrate data from the DonutAuction plugin (by EliVB) to zAuctionHouse V4 using `/ah admin migrate donutauction confirm` (aliases `donut`, `donutsmp`, `da`). Reads `plugins/DonutAuction/ah.data` directly, so no database configuration is needed; active auctions are imported as listed items and past-expiry auctions as reclaimable expired items, while unclaimed pending payments (money, not items) are skipped. Legacy Base64 itemstacks are deserialized through a hardened class allowlist to guard against malicious data files
- **Fixed** Cross-server purchases could fail with `IllegalArgumentException: name cannot be null` (multi-server / Redis). When a buyer bought an item whose seller had never logged into that specific backend node, `getSellerName()` returned `null` and was passed straight to the economy provider's `withdraw()` call (and to the `%seller%` placeholder in the purchase notifications), aborting the purchase. The seller name is now resolved from the shared database before the economy call, falling back to the seller's UUID string (with a console warning to verify all servers share the same MySQL database) when even that lookup fails, so a `null` name can never reach downstream code
- **Fixed** Purchases failing or charging incorrectly under CAPITALISM (VAT-style) tax - the balance check before buying only verified the buyer could afford the listed price, not the price plus the CAPITALISM tax added on top, so a player holding exactly the listed amount passed the check and the tax-inclusive withdrawal could then fail. `PurchaseService` and the `ZAUCTIONHOUSE_LISTED_ITEMS` button now check the tax-inclusive required balance when CAPITALISM tax is enabled
- **Fixed** Items getting stuck in the "being purchased" state after a purchase failed for insufficient funds - the item was marked `IS_BEING_PURCHASED` before the funds check but only unlocked (not status-restored) on failure, so in multi-server (Redis) setups it stayed stuck and could no longer be bought. The status is now restored and broadcast to the cluster before unlocking
- **Fixed** Money loss on economy errors during a purchase or claim - buyer withdraw, seller deposit and claim deposits are now wrapped in error handling: if the buyer is charged but the seller deposit throws, the buyer is automatically refunded; failed claim deposits are logged and skipped instead of silently losing the money
- **Fixed** Expired listings could still be displayed and counted in the auction house. Because expiration is processed lazily, an item could remain `AVAILABLE` past its expiry (especially while a cache was stale) and keep showing in the main list, in the per-category counts, and in the `%zauctionhouse_listed_items%` placeholder. A single `Item.isActivelyListed()` predicate (available **and** not expired) is now applied at every display chokepoint, and interacting with a listing that has already expired now moves it to the player's expired items instead of opening a confirm inventory or re-showing it
- **Fixed** The selling, expired, purchased, listed and combined item-list buttons ignored their inventory's `player-inventory` setting - paginated items and the empty-slot placeholder were always rendered into the chest GUI even when the inventory was configured as a player inventory. These buttons now render items into the player's own inventory area when configured (PR #25)
- **Fixed** Auction search button always showed a hardcoded English `None` in `%search_query%` when no search was active, ignoring the server language. The `ZAUCTIONHOUSE_SEARCH` button now reads a configurable `none-value` option on the search button in `inventories/auction.yml`, with a localized default per language (`None` / `Aucune` / `Ninguna` / `Nessuna` / `ไม่มี`), so the empty-search text is translatable and customizable
- **Added** Configurable `%search_active%` value on the auction search button - the `ZAUCTIONHOUSE_SEARCH` button now reads `active-value` (default `true`) and `inactive-value` (default `false`) options in `inventories/auction.yml`, so the value returned by `%search_active%` when a search is / isn't active can be customized (e.g. `Enabled`/`Disabled`, `Yes`/`No`) instead of the hardcoded `true`/`false`
- **Changed** Updated the required zMenu API to `1.1.1.6` - make sure your server runs zMenu `1.1.1.6` or newer
Developer information
Projects on Modrinth are automatically available through a Maven repository for use with JVM build tools such as Gradle.
maven { name = "Modrinth"; url = "https://api.modrinth.com/maven" }