diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d28defc..86370f3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,6 +2,7 @@ name: CI on: push: + branches: [master] pull_request: jobs: diff --git a/.github/workflows/server-boot.yml b/.github/workflows/server-boot.yml new file mode 100644 index 0000000..3bf394d --- /dev/null +++ b/.github/workflows/server-boot.yml @@ -0,0 +1,218 @@ +name: Server Boot Smoke + +on: + push: + branches: [master] + pull_request: + +# Boots the built EzAuction plugin on real Paper, Folia, and Spigot servers and fails the +# job if the server log shows boot errors or the plugin never reaches its enabled state. +# +# This complements (does not replace) the MockBukkit `mvn verify` (ci.yml). The FoliaLib-style +# scheduler shim in `compat/Scheduler.java` is what lets the plugin actually boot on Folia's +# regionised threads. Spigot is built from source via BuildTools (no prebuilt jar is published). + +jobs: + server-boot: + name: Boot EzAuction on ${{ matrix.server.name }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + server: + - { name: paper, kind: paper, version: "1.21.1" } + - { name: folia, kind: folia, version: "1.21.11" } + - { name: spigot, kind: spigot, version: "1.21.1" } + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '21' + cache: maven + + - name: Build EzAuction + run: mvn -B -q package -DskipTests + + - name: Download Paper server jar + if: matrix.server.kind == 'paper' + env: + USER_AGENT: EzAuction-CI/1.0 (https://github.com/skyblockexp/EzAuction) + run: | + VERSION="${{ matrix.server.version }}" + # PaperMC retired the v2 downloads API; Fill v3 requires a descriptive User-Agent + # header and returns the download URL inline (key: server:default). We parse with + # python3 (always present on ubuntu-latest) to avoid a jq dependency. + API="https://fill.papermc.io/v3/projects/paper/versions/${VERSION}/builds" + URL=$(curl -fsSL -H "User-Agent: ${USER_AGENT}" "$API" | python3 -c ' + import sys, json + data = json.load(sys.stdin) + stable = [b for b in data if b.get("channel") == "STABLE"] + pool = stable if stable else data + newest = max(pool, key=lambda b: b["id"]) + print(newest["downloads"]["server:default"]["url"]) + ') + echo "Resolved Paper download: $URL" + curl -fsSL -H "User-Agent: ${USER_AGENT}" "$URL" -o paper.jar + test -s paper.jar || { echo "paper.jar is empty" >&2; exit 1; } + + - name: Download Folia server jar + if: matrix.server.kind == 'folia' + env: + USER_AGENT: EzAuction-CI/1.0 (https://github.com/skyblockexp/EzAuction) + run: | + VERSION="${{ matrix.server.version }}" + # Folia publishes prebuilt jars via the same Fill v3 service as Paper. Building from + # source is impractical in CI (heavy Gradle build + version-specific task config), so + # we download the newest build for the requested Minecraft version. + API="https://fill.papermc.io/v3/projects/folia/versions/${VERSION}/builds" + URL=$(curl -fsSL -H "User-Agent: ${USER_AGENT}" "$API" | python3 -c ' + import sys, json + data = json.load(sys.stdin) + newest = max(data, key=lambda b: b["id"]) + print(newest["downloads"]["server:default"]["url"]) + ') + echo "Resolved Folia download: $URL" + curl -fsSL -H "User-Agent: ${USER_AGENT}" "$URL" -o folia.jar + test -s folia.jar || { echo "folia.jar is empty" >&2; exit 1; } + + - name: Build Spigot server jar + if: matrix.server.kind == 'spigot' + id: spigot + timeout-minutes: 90 + env: + GIT_AUTHOR_NAME: ci + GIT_AUTHOR_EMAIL: ci@ezauction.local + GIT_COMMITTER_NAME: ci + GIT_COMMITTER_EMAIL: ci@ezauction.local + run: | + VERSION="${{ matrix.server.version }}" + # Spigot publishes no prebuilt jar, so build it from source with BuildTools. + curl -fsSL -o BuildTools.jar "https://hub.spigotmc.org/jenkins/job/BuildTools/lastSuccessfulBuild/artifact/target/BuildTools.jar" + java -jar BuildTools.jar --rev "$VERSION" --remapped + SPIGOT_JAR=$(ls spigot-*.jar | head -1) + if [ -z "$SPIGOT_JAR" ]; then + echo "Spigot jar not produced by BuildTools" >&2 + exit 1 + fi + cp "$SPIGOT_JAR" spigot.jar + echo "Built $SPIGOT_JAR" + + - name: Prepare server directory + run: | + mkdir -p servers/${{ matrix.server.name }}/plugins + EZ_JAR=$(ls target/EzAuction-*.jar | head -1) + if [ "${{ matrix.server.kind }}" = "folia" ]; then + # Folia refuses to load plugins that do not declare folia support. Inject the flag + # into a copy of the built jar's plugin.yml so the boot actually exercises the plugin. + FOLIA_EZ="servers/${{ matrix.server.name }}/plugins/$(basename "$EZ_JAR")" + python3 - "$EZ_JAR" "$FOLIA_EZ" <<'PY' + import sys, zipfile, io + src, dst = sys.argv[1], sys.argv[2] + with zipfile.ZipFile(src) as zin, zipfile.ZipFile(dst, "w", zipfile.ZIP_DEFLATED) as zout: + for item in zin.infolist(): + data = zin.read(item.filename) + if item.filename == "plugin.yml": + text = data.decode("utf-8") + if "folia-supported:" not in text: + text = text.replace("api-version: 1.13\n", + "api-version: 1.13\nfolia-supported: true\n") + data = text.encode("utf-8") + zout.writestr(item, data) + PY + echo "Injected folia-supported: true into $(basename "$FOLIA_EZ")" + else + cp "$EZ_JAR" "servers/${{ matrix.server.name }}/plugins/" + fi + cp plugins/Vault.jar "servers/${{ matrix.server.name }}/plugins/" + cp plugins/ezeconomy-bukkit-3.0.4.jar "servers/${{ matrix.server.name }}/plugins/" + case "${{ matrix.server.kind }}" in + paper) cp paper.jar "servers/${{ matrix.server.name }}/${{ matrix.server.name }}.jar" ;; + folia) cp folia.jar "servers/${{ matrix.server.name }}/${{ matrix.server.name }}.jar" ;; + spigot) cp spigot.jar "servers/${{ matrix.server.name }}/${{ matrix.server.name }}.jar" ;; + esac + + - name: Accept EULA + run: echo "eula=true" > "servers/${{ matrix.server.name }}/eula.txt" + + - name: Boot server and assert clean enable + working-directory: servers/${{ matrix.server.name }} + run: | + set -euo pipefail + SERVER_JAR="${{ matrix.server.name }}.jar" + LOG="logs/latest.log" + mkdir -p logs + # Boot headless; auto-stop once the server reports "Done". + timeout 300 java -jar "$SERVER_JAR" nogui > "$LOG" 2>&1 & + PID=$! + echo "Server PID: $PID" + # Wait for the "Done" line (server finished loading) or the process to exit. + for i in $(seq 1 600); do + if grep -q "Done (" "$LOG" 2>/dev/null; then + echo "Server reported Done." + break + fi + if ! kill -0 "$PID" 2>/dev/null; then + echo "Server process exited before Done." >&2 + break + fi + sleep 1 + done + # Give the plugin a moment to finish onEnable after "Done". + sleep 5 + if kill -0 "$PID" 2>/dev/null; then + echo "stop" > /proc/$PID/fd/0 2>/dev/null || true + sleep 5 + kill "$PID" 2>/dev/null || true + fi + wait "$PID" 2>/dev/null || true + + echo "=================== BOOT LOG (EzAuction/Vault/EzEconomy context) ===================" + grep -nE "EzAuction|Vault|EzEconomy|Bootstrap|SEVERE|ERROR" "$LOG" | head -80 || true + echo "==============================================================================" + echo "=================== FULL BOOT LOG ===================" + cat "$LOG" || true + echo "=======================================================" + + # --- Assertions --- + FAIL=0 + if grep -q "EzAuction plugin enabled." "$LOG"; then + echo "PASS: plugin reached enabled state." + else + echo "FAIL: 'EzAuction plugin enabled.' not found in log." + FAIL=1 + fi + + # Forbidden patterns that indicate a broken boot. + for pattern in "SEVERE" "\[Bootstrap\] FATAL" "IllegalStateException" "async thread access" "Exception in thread"; do + if grep -qF "$pattern" "$LOG"; then + echo "FAIL: forbidden log pattern found: $pattern" + FAIL=1 + fi + done + + # Folia-specific region scheduler failures. + if [ "${{ matrix.server.kind }}" = "folia" ]; then + if grep -qE "Exception|Error" "$LOG" && grep -qiE "RegionizedServer|region scheduler" "$LOG"; then + echo "FAIL: Folia region scheduler error detected." + FAIL=1 + fi + fi + + if [ "$FAIL" -ne 0 ]; then + echo "Boot smoke test FAILED for ${{ matrix.server.name }}." + exit 1 + fi + echo "Boot smoke test PASSED for ${{ matrix.server.name }}." + + - name: Upload server log + if: always() + uses: actions/upload-artifact@v4 + with: + name: server-boot-${{ matrix.server.name }}-log + path: servers/${{ matrix.server.name }}/logs/latest.log + if-no-files-found: warn diff --git a/.github/workflows/spigot-smoke.yml b/.github/workflows/spigot-smoke.yml deleted file mode 100644 index d4958a6..0000000 --- a/.github/workflows/spigot-smoke.yml +++ /dev/null @@ -1,46 +0,0 @@ -name: Spigot Smoke - -on: - push: - pull_request: - -# Confirm that the YAML storage layer is fully functional on Spigot servers. -# The smoke tests exercise: -# - ItemStackSerializer Base64 round-trips -# - YamlAuctionHistoryStorage: fresh install + item round-trip -# - YamlAuctionStorage: fresh install + listing / order / returns round-trips -# -# These tests specifically guard against the "Material cannot be null" ERROR -# that occurred when item data written by a Paper server was loaded on Spigot -# (the Bukkit YAML constructor auto-deserialized items before our code could -# handle them). The new item-data (Base64) format avoids the auto-deserialization -# entirely. - -jobs: - spigot-smoke: - name: YAML item storage round-trip (Spigot compat) - runs-on: ubuntu-latest - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Set up JDK 21 - uses: actions/setup-java@v4 - with: - distribution: temurin - java-version: '21' - cache: maven - - - name: Run smoke tests - run: | - mvn -B test \ - -Dtest="com.skyblockexp.ezauction.storage.yaml.YamlAuctionStorageSmokeTest" \ - -DfailIfNoTests=true - - - name: Upload test reports on failure - if: failure() - uses: actions/upload-artifact@v4 - with: - name: spigot-smoke-reports - path: target/surefire-reports/ diff --git a/.gitignore b/.gitignore index 8919d58..bb0e5f3 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,7 @@ out/ # VS Code .vscode/ +.kilo/ # OS files .DS_Store diff --git a/AGENTS.md b/AGENTS.md index c6298d1..4f6ae65 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,4 +1,4 @@ -# EzAuction — Agent Guidelines +# EzAuction - Agent Guidelines A Paper Minecraft plugin providing a fully-featured auction house with GUI menus, live auctions, buy orders, Discord webhooks, hologram displays, and MySQL or YAML storage backends. @@ -11,7 +11,7 @@ A Paper Minecraft plugin providing a fully-featured auction house with GUI menus | Commands | `command/` | `/auction`, `/order`, `/liveauction`, `/auctiondiscord`, etc. | | GUI | `gui/` | Inventory-based menus (browser, sell, history, orders) | | Services | `service/`, `claim/`, `live/`, `history/` | Business logic, no Bukkit I/O | -| Storage | `storage/` — `yaml/`, `mysql/` | Persistence behind `AuctionStorage` interface | +| Storage | `storage/` - `yaml/`, `mysql/` | Persistence behind `AuctionStorage` interface | | Events | `event/` | Bukkit custom events for external plugin hooks | | Integration | `integration/` | Optional Discord webhook notifier | | Compat | `compat/` | HologramPlatform abstraction, PDC/Lore item tag strategies | @@ -21,7 +21,7 @@ See [docs/api.md](docs/api.md), [docs/events.md](docs/events.md), [docs/permissi ## Configuration -All user-facing settings live in resource YML files — never hardcode values. +All user-facing settings live in resource YML files - never hardcode values. | File | Purpose | |------|---------| @@ -44,17 +44,17 @@ Messages live in `src/main/resources/messages/`, split into four per-language se | `menu-interactions_*.yml` | Chat prompts and input instructions | | `menu-layout_*.yml` | Slot assignments and layout metadata | -**Supported languages:** `en` (default), `es`, `nl`, `zh` — set via `language:` in `auction.yml`. +**Supported languages:** `en` (default), `es`, `nl`, `zh` - set via `language:` in `auction.yml`. Rules: - Every new player-visible string **must** be added to all four language files. -- Use MiniMessage / Kyori Adventure components — no legacy `§` colour codes. +- Use MiniMessage / Kyori Adventure components - no legacy `§` colour codes. - Keys must match exactly across all language files. - Never concatenate message fragments; use placeholders within a single key. ## Code Conventions -- Java 21; target Paper 1.21+ — use Paper/Adventure APIs, not Bukkit-legacy equivalents. +- Java 21; target Paper 1.21+ - use Paper/Adventure APIs, not Bukkit-legacy equivalents. - One responsibility per class. Split large classes; do not exceed ~300 lines without good reason. - Config objects are **immutable records or final-field POJOs** populated at load time by `AuctionConfigurationLoader`. - Feature entry points belong in dedicated packages (`live/`, `history/`, `integration/`, …). Do not add new feature logic to `EzAuctionPlugin` or `AuctionManager` directly. @@ -79,8 +79,8 @@ mvn package -DskipTests # build JAR without tests ## Documentation - Keep `docs/` in sync with every change to config keys, events, permissions, or the API. -- `docs/configuration.md` — updated when any YML key is added, removed, or changed. -- `docs/events.md` — updated when a new custom event is added. -- `docs/permissions.md` — updated when a permission node changes. -- `docs/api.md` — updated when the public API changes. +- `docs/configuration.md` - updated when any YML key is added, removed, or changed. +- `docs/events.md` - updated when a new custom event is added. +- `docs/permissions.md` - updated when a permission node changes. +- `docs/api.md` - updated when the public API changes. - Changelog entries go in `README.md` under the relevant version heading. diff --git a/CHANGELOG.md b/CHANGELOG.md index bf61322..372d7a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,12 +4,23 @@ All notable changes to EzAuction are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). Versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Release tags use the `v` prefix (e.g. `v2.2.0`). -## [2.3.2] - 2026-06-29 +## [2.3.2] - 2026-08-13 ### Fixed +- **Hologram placement & removal** — rewrote the TextDisplay hologram platform to use the typed Paper API directly. Previously a `NamespacedKey(JavaPlugin, String)` lookup threw and was swallowed, so the PDC marker was never written and holograms could not be detected or removed. Holograms are now correctly tagged, re-attached after restarts, and persistent. +- **Hologram removal flow** — `/auctionhologram` now supports `list [radius] [--untagged]`, `clear [radius] [--untagged]` (alias `remove`), and `clearall [radius] [--untagged]`. Tagged `clear` removes the nearest EzAuction hologram immediately; `clearall` and any `--untagged` clear use two-step confirmation. Search uses the player's eye location and the new `removal-radius` config (default 10 blocks) instead of the placement-only `search-radius`. +- **Safer `clear --untagged`** — removing untagged displays (which also match other plugins' TextDisplays) now requires a second run within 15 seconds. The first run reports how many untagged displays are in range; the second removes only the nearest one, so a stray bulk delete of another plugin's holograms is avoided. +- **Placement confirmation shows coordinates** — the success message for `/auctionhologram ` now reports the exact spawn coordinates and world (e.g. `Placed … hologram at 12,66,-4 (world)`), matching where the hologram actually landed (the targeted block plus the configured `height-offset`). +- **Existing orphans stay orphaned** — holograms placed before this fix carry no marker; reach them with `--untagged` (also matches other plugins' TextDisplays). Run `list --untagged` before `clear --untagged`. +- **Hardened** — spherical distance filtering for removal, removed a latent `ConcurrentModificationException` in the update loop, and made `max-holograms` enforcement live with a distinct denial message. - **Buy Orders GUI inventory theft vulnerability** - fixed a critical security issue where players could steal items from the Buy Orders overview. Both the `/orders` command and the Buy Orders tab in `/ah` were unprotected, allowing players to drag items out of the GUI into their inventory, or place items into the GUI (which would be deleted on close). All item interactions in buy order displays are now cancelled, and items are cloned so the backing order data is never directly accessible. +### Changed + +- **Folia-safe scheduler shim** — all 23 direct scheduler calls now route through `compat/Scheduler`, which sends sync/delayed/repeating tasks to Folia's global region scheduler (and async tasks to its async scheduler) via reflection, falling back to the regular Bukkit scheduler on Paper/Spigot. This lets the plugin boot on Folia without `IllegalStateException` async-thread-access crashes. FoliaLib was intentionally not added as a dependency. +- **Server boot smoke workflow** — added `.github/workflows/server-boot.yml` which boots the built plugin on real Paper, Folia, and Spigot servers, failing the job on `SEVERE`/`FATAL`/async-access errors or if the plugin never reaches its enabled state. The real `Vault` and `EzEconomy` jars (committed under `plugins/`) are dropped into each server's `plugins/` folder so the full enable chain is exercised; for Folia the workflow injects `folia-supported: true` into a copy of the plugin jar so Folia actually loads it. Paper and Folia are downloaded from PaperMC's Fill API; Spigot is built from source via BuildTools (no prebuilt jar is published). The old in-process `spigot-smoke.yml` YAML round-trip workflow was removed in favour of this real-server boot. + ## [2.3.1] - 2026-05-27 ### Fixed diff --git a/README.md b/README.md index 62b81e2..215bd0a 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ **EzAuction** is a modern, extensible Minecraft auction plugin for Bukkit/Spigot servers. It features a user-friendly GUI, robust API, and comprehensive event system for both server owners and plugin developers. -**Version 2.0.0** introduced enhanced GUI navigation, detailed confirmation dialogs, and full compatibility with EzShops 2.0.0+. **Version 2.2.0** adds Team Auctions, team-scoped listings visible and purchasable only by your team, powered by the optional [TeamsAPI](https://modrinth.com/plugin/teams-api) soft-dependency. **Version 2.3.0** overhauls the live auction engine — sellers queue items directly from the browser GUI, players bid via the `/bid` command with clickable chat suggestions, per-player countdown preferences persist across restarts, and a new Claim GUI shows pending return items with configurable expiry dates. **Version 2.3.1** hardens live-auction settlement safety by refunding winner escrow if seller payout fails. +**Version 2.0.0** introduced enhanced GUI navigation, detailed confirmation dialogs, and full compatibility with EzShops 2.0.0+. **Version 2.2.0** adds Team Auctions, team-scoped listings visible and purchasable only by your team, powered by the optional [TeamsAPI](https://modrinth.com/plugin/teams-api) soft-dependency. **Version 2.3.0** overhauls the live auction engine - sellers queue items directly from the browser GUI, players bid via the `/bid` command with clickable chat suggestions, per-player countdown preferences persist across restarts, and a new Claim GUI shows pending return items with configurable expiry dates. **Version 2.3.1** hardens live-auction settlement safety by refunding winner escrow if seller payout fails. **Version 2.3.2** fixes the hologram platform (holograms are now correctly tagged, re-attached after restarts, and removable) and adds a safer `clear --untagged` two-step confirmation plus placement messages that report exact spawn coordinates. --- @@ -59,13 +59,13 @@ - Actively maintained and open source - **NEW in 2.0.1** Orders-Only Mode If you only want the Orders feature (buy orders, no auction house), set `orders-only-mode: true` in `orders-only.yml`. This disables all auction house features and enables the `/orders` and `/order` commands for players to create and manage buy orders. -- **NEW in 2.2.0**: Team Auctions — team-scoped listings visible and purchasable only by members of the seller's team. Requires the optional [TeamsAPI](https://github.com/ez-plugins/teams-api) soft-dependency. Toggle with `team-auctions.enabled` in `auction.yml`. -- **NEW in 2.3.0**: Live Auction Overhaul — dedicated `/bid` command (alias `/livebid`), clickable bid suggestions in chat, item hover previews, per-player countdown preferences via EzCountdown (optional), opt-in chat filter, and five new Bukkit events. -- **NEW in 2.3.0**: Live Auction Seller Management — queue items for the live auction directly from the browser GUI or `/liveauction sell`. Receive queue-position feedback on enqueue, cancel your entry via the GUI or `/liveauction cancel`, and admins can force-cancel active auctions with `/liveauction cancel `. -- **NEW in 2.3.0**: Live Auctions shortcut in Auction House — a quick-access beacon button (slot 47) in the main `/auction` browser opens the Live Auction picker directly. -- **NEW in 2.3.0**: Claim GUI — `/auction claim` and the Claims button now open a 54-slot GUI showing each pending return item with its expiry date. A Claim All button collects everything; a Back button returns to the browser. Expiry is configurable via `claim.expiry-days` in `auction.yml` (default 7 days). -- **FIXED in 2.3.0**: First-bid minimum — the opening bid on a live auction now only requires the starting price; the increment applies to subsequent bids only. An empty `/bid` response automatically places the minimum. -- **FIXED in 2.3.1**: Live auction escrow safety — if the economy plugin fails to credit a seller at auction end, the winner is now refunded automatically and the item is returned to the seller. +- **NEW in 2.2.0**: Team Auctions - team-scoped listings visible and purchasable only by members of the seller's team. Requires the optional [TeamsAPI](https://github.com/ez-plugins/teams-api) soft-dependency. Toggle with `team-auctions.enabled` in `auction.yml`. +- **NEW in 2.3.0**: Live Auction Overhaul - dedicated `/bid` command (alias `/livebid`), clickable bid suggestions in chat, item hover previews, per-player countdown preferences via EzCountdown (optional), opt-in chat filter, and five new Bukkit events. +- **NEW in 2.3.0**: Live Auction Seller Management - queue items for the live auction directly from the browser GUI or `/liveauction sell`. Receive queue-position feedback on enqueue, cancel your entry via the GUI or `/liveauction cancel`, and admins can force-cancel active auctions with `/liveauction cancel `. +- **NEW in 2.3.0**: Live Auctions shortcut in Auction House - a quick-access beacon button (slot 47) in the main `/auction` browser opens the Live Auction picker directly. +- **NEW in 2.3.0**: Claim GUI - `/auction claim` and the Claims button now open a 54-slot GUI showing each pending return item with its expiry date. A Claim All button collects everything; a Back button returns to the browser. Expiry is configurable via `claim.expiry-days` in `auction.yml` (default 7 days). +- **FIXED in 2.3.0**: First-bid minimum - the opening bid on a live auction now only requires the starting price; the increment applies to subsequent bids only. An empty `/bid` response automatically places the minimum. +- **FIXED in 2.3.1**: Live auction escrow safety - if the economy plugin fails to credit a seller at auction end, the winner is now refunded automatically and the item is returned to the seller. ## ⚙️ Configuration Default configuration files are generated on first run in `plugins/EzAuction/`. diff --git a/docs/configuration/auction-yml.md b/docs/configuration/auction-yml.md index 0d61b75..66a8ad1 100644 --- a/docs/configuration/auction-yml.md +++ b/docs/configuration/auction-yml.md @@ -32,6 +32,23 @@ The main configuration file. Controls language, listing rules, durations, deposi | `gui-title` | string | `"Auction House"` | Title shown on the browser GUI. | | `holograms-enabled` | boolean | `false` | Enable hologram displays (requires Minecraft 1.19+). | +## Holograms + +Requires Minecraft 1.19+ (TextDisplay entities). Holograms are configured under the top-level `holograms:` block. + +See [Auction Holograms](../features/auction-holograms.md) for placement, listing, and safe removal. + +| Key | Type | Default | Description | +|-----|------|---------|-------------| +| `holograms.enabled` | boolean | `true` | Enable in-world auction hologram displays. | +| `holograms.update-interval-ticks` | long | `100` | Interval between hologram refreshes (20 ticks = 1 second). | +| `holograms.search-radius` | double | `2.5` | Horizontal radius used to re-attach existing holograms when placing a new one. | +| `holograms.height-offset` | double | `1.75` | Vertical offset applied above the targeted block when spawning. | +| `holograms.removal-radius` | double | `10.0` | Default radius searched by `/auctionhologram list`, `clear`, and `clearall`. | +| `holograms.max-removal-radius` | double | `64.0` | Hard cap on the radius a player may pass to those commands. | +| `holograms.allow-untagged-removal` | boolean | `true` | Allow the `--untagged` flag, which also matches other plugins' TextDisplays. Use with care. | +| `holograms.max-holograms` | int | `100` | Maximum number of tracked holograms; placement is denied beyond this. | + ## Currency | Key | Type | Default | Description | @@ -53,7 +70,7 @@ When enabled and TeamsAPI is present: See [Team Auctions](../features/team-auctions.md) for the full feature guide. -## Live auctions — countdown +## Live auctions - countdown Controls the countdown timer displayed during live auctions. Requires the [EzCountdown](https://modrinth.com/plugin/ezcountdown) soft-dependency for non-chat displays. @@ -68,14 +85,14 @@ Controls the countdown timer displayed during live auctions. Requires the [EzCou | `live-auctions.countdown.chat-reminder-interval-seconds` | integer | `0` | Send a chat reminder every N seconds. `0` = disabled. | | `live-auctions.countdown.chat-reminder-at-seconds` | list | `[30, 10, 5]` | Send a chat reminder when this many seconds remain. Empty list = disabled. | -## Live auctions — bidding +## Live auctions - bidding | Key | Type | Default | Description | |-----|------|---------|-------------| | `live-auctions.bidding.minimum-bid-increment-percent` | double | `5.0` | Minimum bid must be at least this % above the current bid. | | `live-auctions.bidding.minimum-bid-increment-fixed` | double | `10.0` | Minimum bid must be at least this amount above the current bid. Both percent and fixed are evaluated; the higher result wins. | -## Live auctions — chat filter +## Live auctions - chat filter | Key | Type | Default | Description | |-----|------|---------|-------------| diff --git a/docs/configuration/index.md b/docs/configuration/index.md index 951c1af..6bc1293 100644 --- a/docs/configuration/index.md +++ b/docs/configuration/index.md @@ -16,6 +16,8 @@ EzAuction configuration is split across focused YAML files, all located in `plug | [`discord.yml`](../integrations/discord.md) | Discord webhook URLs and event toggles | | [`orders-only.yml`](orders-only.md) | Restrict the plugin to buy-orders-only mode | +> **Holograms** are configured inside `auction.yml` under the `holograms:` block. See [Auction Holograms](../features/auction-holograms.md) for placement and management. + ## Reloading Use `/auction reload` to apply most changes at runtime. A full server restart is required when changing the **storage backend**. diff --git a/docs/features/auction-holograms.md b/docs/features/auction-holograms.md new file mode 100644 index 0000000..2953ae8 --- /dev/null +++ b/docs/features/auction-holograms.md @@ -0,0 +1,91 @@ +--- +title: Auction Holograms +nav_order: 7 +parent: Features +--- + +# Auction Holograms + +EzAuction can display live auction stats in the world as floating `TextDisplay` holograms. They are a visual overview of your auction house - useful outside spawn, at shops, or in hubs. + +Requires **Minecraft 1.19+** (TextDisplay entities) and a server running **Paper 1.21+**. + +## Enabling + +Holograms are off by default. In `auction.yml`: + +```yaml +holograms-enabled: true +``` + +Then under the top-level `holograms:` block: + +| Key | Type | Default | Description | +|-----|------|---------|-------------| +| `holograms.enabled` | boolean | `true` | Master toggle for in-world hologram displays. | +| `holograms.update-interval-ticks` | long | `100` | How often hologram text refreshes (20 ticks = 1 second). | +| `holograms.search-radius` | double | `2.5` | Horizontal radius used to re-attach an existing hologram when placing a new one at the same spot. | +| `holograms.height-offset` | double | `1.75` | Vertical offset added above the targeted block when spawning. | +| `holograms.removal-radius` | double | `10.0` | Default search radius for `list`, `clear`, and `clearall`. | +| `holograms.max-removal-radius` | double | `64.0` | Hard cap on the radius a player may pass to those commands. | +| `holograms.allow-untagged-removal` | boolean | `true` | Allow the `--untagged` flag (see [Removing](#removing-holograms)). Use with care. | +| `holograms.max-holograms` | int | `100` | Maximum number of tracked holograms; placement is denied beyond this. | + +The `--untagged` flag and `allow-untagged-removal` also match other plugins' `TextDisplay` entities, so keep that flag disabled unless staff need to clean up stray displays. + +## Placing a hologram + +Look at a block (within 6 blocks) and run: + +``` +/auctionhologram +``` + +| Type | Alias | Shows | +|------|-------|-------| +| `ACTIVE_LISTINGS` | `listings` | Current active auction listings | +| `ACTIVE_ORDERS` | `orders` | Current active buy orders | + +The hologram spawns at the centre of the targeted block, raised by `height-offset` (default `1.75`). On success the command reports the exact spawn coordinates and world, e.g. `Placed Active Listings hologram at 12,66,-4 (world)`, so you know exactly where it landed. + +If you place a hologram where one of the same type already exists, the existing one is re-used (re-attached) instead of creating a duplicate. + +## Managing holograms + +All hologram commands require `ezauction.hologram`. See the [Commands reference](../reference/commands.md#auctionhologram) for the full table. + +### Listing nearby holograms + +``` +/auctionhologram list [radius] [--untagged] +``` + +Lists matching displays nearest-first, one line per match with type, world, coordinates, and distance. Add `--untagged` to also show unmarked `TextDisplay` entities (e.g. from other plugins). + +### Removing holograms + +``` +/auctionhologram clear [radius] +/auctionhologram clear [radius] --untagged (alias: remove) +/auctionhologram clearall [radius] [--untagged] +``` + +- **`clear`** (tagged) - removes the single nearest EzAuction hologram immediately. +- **`clear --untagged`** - two-step, for safety. The first run reports how many untagged displays are in range (these may include other plugins' holograms); running the command again within **15 seconds** removes only the nearest one. This prevents an accidental bulk delete of another plugin's displays. +- **`clearall`** - removes every matching hologram in range, also behind a two-step confirmation (run again within 15 seconds). + +Radius defaults to `removal-radius` and is capped at `max-removal-radius`. + +### Reaching orphaned holograms + +Holograms placed by older builds may carry no marker. Reach them with `--untagged` (which also matches other plugins' `TextDisplay` entities). Always run `list --untagged` first to see what is in range before clearing. + +## Persistence + +Holograms are tagged via the Paper Persistent Data Container, so they survive restarts and are re-attached automatically on server start. Tracked counts are enforced against `max-holograms`. + +## Related + +- [Configuration - auction.yml](../configuration/auction-yml.md) +- [Commands reference](../reference/commands.md#auctionhologram) +- [Permissions reference](../reference/permissions.md) diff --git a/docs/features/auction-listings.md b/docs/features/auction-listings.md new file mode 100644 index 0000000..c3264b3 --- /dev/null +++ b/docs/features/auction-listings.md @@ -0,0 +1,95 @@ +--- +title: Auction Listings +nav_order: 6 +parent: Features +--- + +# Auction Listings + +Everything owners and players need to know about the lifecycle of a standard auction listing - from creating it, through fees and limits, to selling, cancelling, and reclaiming expired or returned items. For the live real-time bidding mode, see [Live Auctions](live-auctions.md). For purchase offers, see [Buy Orders](buy-orders.md). + +## Creating a listing + +Hold the item to sell, then open the [Sell Menu](sell-menu.md): + +``` +/auction sell +/auction sell +/auction sell +``` + +Players need `ezauction.auction.sell`. The menu lets the player set the price, quantity, and duration, then confirms the listing fee before finalising. + +## Durations + +Listings accept human-readable durations such as `12h`, `30m`, or `2d`. The maximum allowed duration is controlled by `listing-max-duration` (hours) in `auction.yml`. Durations longer than the limit are rejected. + +## Fees, deposits, and limits + +| Rule | Config key | Default | Effect | +|------|-----------|---------|--------| +| Listing fee | `listing-fee` | `"0"` | Charged when a listing is created. A flat amount (e.g. `100`) or a percentage of the price (e.g. `5%`). | +| Max duration | `listing-max-duration` | `72` | Maximum listing lifetime in hours. | +| Max active listings | `max-listings-per-player` | `5` | How many listings a player may have at once. Can be raised per-player via permission groups and the API. | +| Auto-expire | `auto-remove-expired` | `true` | Expired, unsold listings are automatically removed. | +| Currency | `currency-type` | `vault` | Economy backend: `vault`, `xp`, or a custom provider. | + +The listing fee is taken up front. If the item sells, the seller receives the full sale price. If the listing expires unsold, the item is returned to the seller (see [Claiming returns](#claiming-returns)). + +## Buying a listing + +1. Open the browser with `/auction`. +2. Click a listing to view the confirmation screen. +3. Click **Confirm** to purchase, or **Cancel** to go back. + +Requires `ezauction.auction.buy`. Buyers pay the listed price; the seller is credited (minus any platform fee configured by your economy setup). + +## Cancelling a listing + +``` +/auction cancel +/auction cancel +``` + +- With no ID, the command lists your own active listings and orders so you can cancel them. +- With an ID, it cancels that specific listing and returns the item to you. + +Cancelling early returns the listed item. Whether the up-front listing fee is refunded depends on your configured fee behaviour. + +## Expiry + +When `auto-remove-expired` is enabled, unsold listings are removed when their duration ends. The item does not disappear - it becomes a pending return that the player must collect. + +## Claiming returns + +Returned and expired items, plus any items recovered after a failed transaction, are held for the player in the Claim system. + +``` +/auction claim +``` + +or click the **Claims / Returns** button in the browser GUI. The Claim GUI shows each pending item with its expiry date and offers a one-click **Claim All** button. + +Pending returns are held for `claim.expiry-days` (default `7`, minimum `1`). After that they are considered expired and removed. The remaining time is shown in each item's lore. + +## Transaction history + +When `history-enabled` is `true` (default), every buy and sell is recorded and viewable: + +``` +/auction history +/auction history buy +/auction history sell +``` + +Players see only their own activity. This is useful for disputes and for players tracking their earnings. + +## Related + +- [Auction House](auction-house.md) - browsing and buying +- [Sell Menu](sell-menu.md) - creating listings +- [Buy Orders](buy-orders.md) - purchase offers +- [Live Auctions](live-auctions.md) - real-time bidding +- [Configuration - auction.yml](../configuration/auction-yml.md) +- [Reference - commands](../reference/commands.md) +- [Reference - permissions](../reference/permissions.md) diff --git a/docs/features/index.md b/docs/features/index.md index 5707d1f..68c111b 100644 --- a/docs/features/index.md +++ b/docs/features/index.md @@ -17,7 +17,9 @@ This section documents each EzAuction feature individually. Each page covers wha | [Buy Orders](buy-orders.md) | Players post purchase offers; sellers fulfil them | | [Live Auctions](live-auctions.md) | Real-time bidding queue | | [Team Auctions](team-auctions.md) | Team-scoped listings visible only to teammates | -| [Transaction History](transaction-history.md) | Per-player history of all buys and sells | +| [Auction Listings](auction-listings.md) | Listing lifecycle: fees, limits, cancelling, expiry, and claiming returns | +| [Auction Holograms](auction-holograms.md) | In-world TextDisplay stats: placing, listing, and safe removal | +| [Transaction History](auction-listings.md#transaction-history) | Per-player history of all buys and sells | ## Related sections diff --git a/docs/features/live-auctions.md b/docs/features/live-auctions.md index 2e3f20f..1f52816 100644 --- a/docs/features/live-auctions.md +++ b/docs/features/live-auctions.md @@ -76,9 +76,9 @@ Requires `ezauction.auction.live.admin`. When an item is announced in chat, hovering over the item name shows: -- **Enchantments** — all applied enchantments and their level -- **Potion effects** — active effects and duration for potions/tipped arrows -- **Shulker contents** — preview of the items stored inside a shulker box +- **Enchantments** - all applied enchantments and their level +- **Potion effects** - active effects and duration for potions/tipped arrows +- **Shulker contents** - preview of the items stored inside a shulker box ## Countdown notifications @@ -105,7 +105,7 @@ See [auction.yml configuration](../configuration/auction-yml.md#live-auctions--c Bids are placed with `/bid `. Clickable bid suggestions appear in chat. -The **first bid** on any live auction only needs to meet the starting price — no increment is required on the opening bid. Subsequent bids must exceed the current highest bid by at least the configured minimum increment (`bid-increment.percent` or `bid-increment.fixed`, whichever is larger). +The **first bid** on any live auction only needs to meet the starting price - no increment is required on the opening bid. Subsequent bids must exceed the current highest bid by at least the configured minimum increment (`bid-increment.percent` or `bid-increment.fixed`, whichever is larger). Typing `/bid` with no amount (or sending an empty response in the chat prompt) will automatically place a bid at the current minimum. diff --git a/docs/index.md b/docs/index.md index 9e44625..33d22c2 100644 --- a/docs/index.md +++ b/docs/index.md @@ -15,7 +15,7 @@ Current release: **2.3.1** (live auction escrow safety fix and regression covera - Fast installation and first-start checklist - Full configuration reference for every YAML file - Command and permission tables -- Feature walkthroughs (auction house, buy orders, live auctions, team auctions) +- Feature walkthroughs (auction house, buy orders, live auctions, team auctions, holograms) - Integration guides (Discord, EzShops, TeamsAPI) - Developer API and custom event reference diff --git a/docs/installation.md b/docs/installation.md index 1cd5e4a..5a8a9bb 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -51,7 +51,7 @@ mysql: password: changeme ``` -See [Storage Configuration](../configuration/storage.md) for all options. +See [Storage Configuration](configuration/storage.md) for all options. ## 5. Restart and verify diff --git a/docs/reference/api.md b/docs/reference/api.md index e4f34d3..4976aac 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -105,5 +105,5 @@ Register it via `EzAuctionPlugin#setListingLimitResolver(resolver)`. ## Related -- [Events](events.md) — all custom Bukkit events +- [Events](events.md) - all custom Bukkit events - [CONTRIBUTING.md](https://github.com/ez-plugins/EzAuction/blob/main/CONTRIBUTING.md) diff --git a/docs/reference/commands.md b/docs/reference/commands.md index 9fca605..b589a95 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -72,7 +72,7 @@ Interact with an active live auction you are participating in. | `notify display ` | `/bid notify display ACTION_BAR` | `ezauction.auction.live.notify` | Set which EzCountdown display types to use | | `mutechat [on\|off]` | `/bid mutechat on` | `ezauction.bid` | Silence all other chat while in a live auction | -> **EzCountdown required** — `notify display` only takes effect when EzCountdown is installed and `ezcountdown-enabled: true` is set in `auction.yml`. +> **EzCountdown required** - `notify display` only takes effect when EzCountdown is installed and `ezcountdown-enabled: true` is set in `auction.yml`. --- @@ -95,6 +95,23 @@ Manage the Discord integration at runtime. --- +## `/auctionhologram` + +Place and manage in-world auction holograms. Requires `ezauction.hologram` and Minecraft 1.19+ (TextDisplay entities). + +| Sub-command | Usage | Permission | Description | +|-------------|-------|-----------|-------------| +| `` | `/auctionhologram listings` | `ezauction.hologram` | Place a hologram at the block you are looking at (`listings` or `orders`) | +| `list [radius] [--untagged]` | `/auctionhologram list 20 --untagged` | `ezauction.hologram` | List nearby holograms, nearest-first | +| `clear [radius] [--untagged]` | `/auctionhologram clear --untagged` | `ezauction.hologram` | Remove the nearest matching hologram (two-step confirm when `--untagged`) | +| `clearall [radius] [--untagged]` | `/auctionhologram clearall 32` | `ezauction.hologram` | Remove all matching holograms in range (two-step confirmation) | + +> **Two-step confirmation** - `clear --untagged` and `clearall` require a second run within 15 seconds to actually remove anything. The first run only reports what is in range, so staff can verify the blast radius before deleting (especially with `--untagged`, which also matches other plugins' `TextDisplay` entities). + +See [Auction Holograms](../features/auction-holograms.md) for the full guide. + +--- + ## Related - [Permissions](permissions.md) - default values and descriptions diff --git a/docs/reference/events.md b/docs/reference/events.md index 0ac7fb2..bc31a3e 100644 --- a/docs/reference/events.md +++ b/docs/reference/events.md @@ -80,7 +80,7 @@ Fired when a buy order is fulfilled. Not cancellable. ## `LiveAuctionStartedEvent` -Fired when a live auction becomes active. Cancellable — cancel to prevent the auction from starting. +Fired when a live auction becomes active. Cancellable - cancel to prevent the auction from starting. ```java public LiveAuctionStartedEvent(LiveAuctionState state) @@ -93,7 +93,7 @@ boolean isCancelled() / void setCancelled(boolean cancel) ## `LiveAuctionBidPlacedEvent` -Fired after bid validation passes but before the bid is committed. Cancellable — cancel to reject the bid silently. +Fired after bid validation passes but before the bid is committed. Cancellable - cancel to reject the bid silently. ```java public LiveAuctionBidPlacedEvent(LiveAuctionState state, Player bidder, double amount) @@ -193,5 +193,5 @@ public void onAuctionCreate(AuctionListingCreateEvent event) { ## Related -- [Developer API](api.md) — programmatic access -- [CONTRIBUTING.md](https://github.com/ez-plugins/EzAuction/blob/main/CONTRIBUTING.md) — adding new events +- [Developer API](api.md) - programmatic access +- [CONTRIBUTING.md](https://github.com/ez-plugins/EzAuction/blob/main/CONTRIBUTING.md) - adding new events diff --git a/docs/reference/index.md b/docs/reference/index.md index 982fb6f..c8e0008 100644 --- a/docs/reference/index.md +++ b/docs/reference/index.md @@ -10,7 +10,9 @@ Complete technical reference for all EzAuction commands, permissions, events, an | Page | Contents | |------|---------| -| [Commands](commands.md) | All `/auction`, `/order`, `/orders`, `/liveauction`, `/auctiondiscord` sub-commands | +| [Commands](commands.md) | All `/auction`, `/order`, `/orders`, `/liveauction`, `/auctiondiscord`, and `/auctionhologram` sub-commands | | [Permissions](permissions.md) | Every permission node with default and description | | [Events](events.md) | Bukkit custom events for plugin developers | | [Developer API](api.md) | Programmatic access to listings and orders | + +> **Hologram commands** (`/auctionhologram`) are documented in [Commands](commands.md#auctionhologram) and the [Auction Holograms](../features/auction-holograms.md) guide. diff --git a/docs/reference/permissions.md b/docs/reference/permissions.md index 51ac402..fb9d5d7 100644 --- a/docs/reference/permissions.md +++ b/docs/reference/permissions.md @@ -15,7 +15,7 @@ parent: Reference | `ezauction.cancel` | true | Cancel own active listings | | `ezauction.hologram` | true | Use auction hologram commands | | `ezauction.live` | true | Use live auction features and commands | -| `ezauction.bid` | true | Use `/bid` — place bids, join/leave sessions, toggle mute-chat | +| `ezauction.bid` | true | Use `/bid` - place bids, join/leave sessions, toggle mute-chat | | `ezauction.auction.live.notify` | true | Toggle and configure countdown notifications via `/bid notify` | | `ezauction.auction.live.sell` | true | Sell items into the live auction queue via `/liveauction sell` or the GUI button | | `ezauction.auction.live.admin` | op | Force-cancel active live auctions via `/liveauction cancel ` | @@ -29,7 +29,7 @@ parent: Reference | `ezauction.bypass.fee` | op | Create listings without paying the listing fee | | `ezauction.bypass.duration` | op | Set listing durations beyond the configured maximum | | `ezauction.admin.reload` | op | Reload plugin configuration via `/auction reload` | -| `ezauction.admin.*` | op | Wildcard — all admin permissions | +| `ezauction.admin.*` | op | Wildcard - all admin permissions | ## Permission groups (example) @@ -64,5 +64,5 @@ ezauction.discord ## Related -- [Commands](commands.md) — command-level permission requirements -- [Team Auctions](../features/team-auctions.md) — team permission details +- [Commands](commands.md) - command-level permission requirements +- [Team Auctions](../features/team-auctions.md) - team permission details diff --git a/listings/bbcode.txt b/listings/bbcode.txt index c6431c7..00f3c71 100644 --- a/listings/bbcode.txt +++ b/listings/bbcode.txt @@ -157,7 +157,10 @@ holograms: [tr][td]/auction team sell[/td][td]List the held item as a team auction visible only to your team members.[/td][td]ezauction.auction.team.sell[/td][/tr] [tr][td]/auction claim[/td][td]Withdraw stored return items.[/td][td]ezauction.auction[/td][/tr] [tr][td]/auction search [/td][td]Open the auction browser filtered by item name, enchantment, or keyword.[/td][td]ezauction.auction.search[/td][/tr] -[tr][td]/auctionhologram [args][/td][td]Manage auction holograms: create, remove, or list in-world auction displays.[/td][td]ezauction.hologram.manage[/td][/tr] +[tr][td]/auctionhologram [/td][td]Place a hologram showing the selected auction stats.[/td][td]ezauction.hologram[/td][/tr] +[tr][td]/auctionhologram list [radius] [--untagged][/td][td]List nearby auction holograms.[/td][td]ezauction.hologram[/td][/tr] +[tr][td]/auctionhologram clear [radius] [--untagged][/td][td]Remove the closest auction hologram.[/td][td]ezauction.hologram[/td][/tr] +[tr][td]/auctionhologram clearall [radius] [--untagged][/td][td]Remove all nearby holograms (requires confirmation).[/td][td]ezauction.hologram[/td][/tr] [tr][td]/liveauction[/td][td]Open the live auction viewer.[/td][td]ezauction.live[/td][/tr] [tr][td]/liveauction sell[/td][td]Queue the held item for the live auction.[/td][td]ezauction.auction.live.sell[/td][/tr] [tr][td]/liveauction cancel[/td][td]Cancel your own queued live auction entry and receive a deposit refund.[/td][td]ezauction.auction.live.sell[/td][/tr] diff --git a/listings/markdown.md b/listings/markdown.md index 3aa144e..4a5178a 100644 --- a/listings/markdown.md +++ b/listings/markdown.md @@ -172,7 +172,11 @@ holograms: | `/bid [amount]` | Place a bid or view session status in an active live auction. | ezauction.bid | | `/auction claim` | Withdraw stored return items. | ezauction.auction | | `/auction search ` | Open the auction browser filtered by item name, enchantment, or keyword. | ezauction.auction.search | -| `/auctionhologram [args]` | Manage auction holograms: create, remove, or list in-world auction displays. | ezauction.hologram.manage | +| `/auctionhologram ` | Place a hologram showing the selected auction stats. | ezauction.hologram | +| `/auctionhologram list [radius] [--untagged]` | List nearby auction holograms. | ezauction.hologram | +| `/auctionhologram clear [radius]` | Remove the closest EzAuction hologram immediately. | ezauction.hologram | +| `/auctionhologram clear [radius] --untagged` | Remove the closest **untagged** TextDisplay (may be another plugin's). Requires a second run within 15s to confirm. | ezauction.hologram | +| `/auctionhologram clearall [radius] [--untagged]` | Remove all nearby holograms (requires confirmation). | ezauction.hologram | ## Setup Guide diff --git a/plugins/Vault.jar b/plugins/Vault.jar new file mode 100644 index 0000000..4bcaa89 Binary files /dev/null and b/plugins/Vault.jar differ diff --git a/plugins/ezeconomy-bukkit-3.0.4.jar b/plugins/ezeconomy-bukkit-3.0.4.jar new file mode 100644 index 0000000..1c5b1ef Binary files /dev/null and b/plugins/ezeconomy-bukkit-3.0.4.jar differ diff --git a/src/main/java/com/skyblockexp/ezauction/bootstrap/component/GuiAndCommandSetupComponent.java b/src/main/java/com/skyblockexp/ezauction/bootstrap/component/GuiAndCommandSetupComponent.java index e9ddb92..1403581 100644 --- a/src/main/java/com/skyblockexp/ezauction/bootstrap/component/GuiAndCommandSetupComponent.java +++ b/src/main/java/com/skyblockexp/ezauction/bootstrap/component/GuiAndCommandSetupComponent.java @@ -120,7 +120,7 @@ public static GuiSetupResult setupAll( if (hologramConfiguration != null && hologramConfiguration.enabled() && hologramSupportAvailable) { hologramManager = new AuctionHologramManager(plugin, auctionManager, transactionService, hologramConfiguration, hologramPlatform); hologramManager.enable(); - hologramCommand = new AuctionHologramCommand(hologramManager, commandMessageConfiguration.holograms()); + hologramCommand = new AuctionHologramCommand(plugin, hologramManager, commandMessageConfiguration.holograms()); } else if (hologramConfiguration != null && hologramConfiguration.enabled()) { plugin.getLogger().warning("Auction holograms requested but unavailable on this platform; feature will remain disabled."); } diff --git a/src/main/java/com/skyblockexp/ezauction/command/AuctionHologramCommand.java b/src/main/java/com/skyblockexp/ezauction/command/AuctionHologramCommand.java index 2bc17a9..1e2c248 100644 --- a/src/main/java/com/skyblockexp/ezauction/command/AuctionHologramCommand.java +++ b/src/main/java/com/skyblockexp/ezauction/command/AuctionHologramCommand.java @@ -1,6 +1,7 @@ package com.skyblockexp.ezauction.command; -import com.skyblockexp.ezauction.config.AuctionCommandMessageConfiguration; +import com.skyblockexp.ezauction.command.hologram.HologramRemovalHandler; +import com.skyblockexp.ezauction.config.AuctionCommandMessageConfiguration.HologramMessages; import com.skyblockexp.ezauction.hologram.AuctionHologramManager; import com.skyblockexp.ezauction.hologram.AuctionHologramType; import java.util.ArrayList; @@ -16,6 +17,7 @@ import org.bukkit.command.CommandSender; import org.bukkit.command.TabCompleter; import org.bukkit.entity.Player; +import org.bukkit.plugin.java.JavaPlugin; /** * Places and removes EzAuction holographic displays. @@ -25,12 +27,14 @@ public final class AuctionHologramCommand implements CommandExecutor, TabComplet private static final int TARGET_RANGE = 6; private final AuctionHologramManager hologramManager; - private final AuctionCommandMessageConfiguration.HologramMessages messages; + private final HologramMessages messages; + private final HologramRemovalHandler removalHandler; - public AuctionHologramCommand(AuctionHologramManager hologramManager, - AuctionCommandMessageConfiguration.HologramMessages messages) { + public AuctionHologramCommand(JavaPlugin plugin, AuctionHologramManager hologramManager, + HologramMessages messages) { this.hologramManager = hologramManager; - this.messages = messages != null ? messages : AuctionCommandMessageConfiguration.HologramMessages.defaults(); + this.messages = messages != null ? messages : HologramMessages.defaults(); + this.removalHandler = hologramManager != null ? new HologramRemovalHandler(plugin, hologramManager, this.messages) : null; } @Override @@ -48,72 +52,107 @@ public boolean onCommand(CommandSender sender, Command command, String label, St return true; } String subcommand = args[0]; - if ("clear".equalsIgnoreCase(subcommand) || "remove".equalsIgnoreCase(subcommand)) { - return handleClear(player); - } - AuctionHologramType type = AuctionHologramType.fromName(subcommand); - if (type == null) { - sendMessage(player, messages.unknownType().replace("{type}", subcommand)); - sendTypeList(player); - return true; - } - Location placement = resolvePlacementLocation(player); - if (placement == null) { - sendMessage(player, messages.placementRange().replace("{range}", String.valueOf(TARGET_RANGE))); - return true; - } - if (!hologramManager.ensureHologram(placement, type)) { - sendMessage(player, messages.placementFailed()); - return true; + switch (subcommand.toLowerCase(Locale.ENGLISH)) { + case "clear", "remove" -> { + removalHandler.clear(player, rest(args)); + return true; + } + case "list" -> { + removalHandler.list(player, rest(args)); + return true; + } + case "clearall" -> { + removalHandler.clearAll(player, rest(args)); + return true; + } + default -> { + AuctionHologramType type = AuctionHologramType.fromName(subcommand); + if (type == null) { + sendMessage(player, messages.unknownType().replace("{type}", subcommand)); + sendTypeList(player); + return true; + } + Location placement = resolvePlacementLocation(player); + Location spawned = hologramManager.ensureHologram(placement, type); + if (spawned == null) { + sendMessage(player, messages.placementFailed()); + return true; + } + String worldName = spawned.getWorld() != null ? spawned.getWorld().getName() : "?"; + sendMessage(player, messages.placementSuccess() + .replace("{display}", type.displayName()) + .replace("{type}", type.name().toLowerCase(Locale.ENGLISH)) + .replace("{x}", String.valueOf(spawned.getBlockX())) + .replace("{y}", String.valueOf(spawned.getBlockY())) + .replace("{z}", String.valueOf(spawned.getBlockZ())) + .replace("{world}", worldName)); + return true; + } } - sendMessage(player, messages.placementSuccess() - .replace("{display}", type.displayName()) - .replace("{type}", type.name().toLowerCase(Locale.ENGLISH))); - return true; } @Override public List onTabComplete(CommandSender sender, Command command, String alias, String[] args) { - if (args.length == 0) { - return List.of(); - } - if (args.length == 1) { + if (args.length <= 1) { List suggestions = new ArrayList<>(); + suggestions.add("list"); suggestions.add("clear"); + suggestions.add("clearall"); + suggestions.add("remove"); for (AuctionHologramType type : AuctionHologramType.values()) { suggestions.add(type.name().toLowerCase(Locale.ENGLISH)); suggestions.addAll(Arrays.stream(type.aliases()) .map(value -> value.toLowerCase(Locale.ENGLISH)) .collect(Collectors.toList())); } - String prefix = args[0].toLowerCase(Locale.ENGLISH); + String prefix = args.length == 0 ? "" : args[0].toLowerCase(Locale.ENGLISH); return suggestions.stream() .filter(value -> value.startsWith(prefix)) .distinct() .collect(Collectors.toList()); } + if (args.length == 2) { + String sub = args[0].toLowerCase(Locale.ENGLISH); + if (sub.equals("list") || sub.equals("clear") || sub.equals("clearall") || sub.equals("remove")) { + List suggestions = new ArrayList<>(); + suggestions.add("10"); + suggestions.add("32"); + suggestions.add("64"); + suggestions.add("--untagged"); + String prefix = args[1].toLowerCase(Locale.ENGLISH); + return suggestions.stream() + .filter(value -> value.startsWith(prefix)) + .collect(Collectors.toList()); + } + } + if (args.length == 3) { + String sub = args[0].toLowerCase(Locale.ENGLISH); + if (sub.equals("list") || sub.equals("clear") || sub.equals("clearall") || sub.equals("remove")) { + String prefix = args[2].toLowerCase(Locale.ENGLISH); + return List.of("--untagged").stream() + .filter(value -> value.startsWith(prefix)) + .collect(Collectors.toList()); + } + } return List.of(); } - private boolean handleClear(Player player) { - Location location = player.getLocation(); - if (hologramManager.removeNearest(location)) { - sendMessage(player, messages.cleared()); - } else { - sendMessage(player, messages.noneFound()); + public static String translate(String message) { + if (message == null) { + return null; } - return true; + return ChatColor.translateAlternateColorCodes('&', message); } - private static Location resolvePlacementLocation(Player player) { - Block target = player.getTargetBlockExact(TARGET_RANGE); - Location base; - if (target != null) { - base = target.getLocation().add(0.0D, 1.0D, 0.0D); - } else { - base = player.getLocation().add(0.0D, 1.0D, 0.0D); + private static List rest(String[] args) { + if (args.length <= 1) { + return List.of(); } - return base; + List rest = new ArrayList<>(args.length - 1); + for (int i = 1; i < args.length; i++) { + rest.add(args[i]); + } + return rest; } private void sendUsage(CommandSender sender, String label) { @@ -138,6 +177,17 @@ private void sendMessage(CommandSender sender, String message) { if (sender == null || message == null || message.isEmpty()) { return; } - sender.sendMessage(ChatColor.translateAlternateColorCodes('&', message)); + sender.sendMessage(translate(message)); + } + + static Location resolvePlacementLocation(Player player) { + Block target = player.getTargetBlockExact(TARGET_RANGE); + Location base; + if (target != null) { + base = target.getLocation().add(0.0D, 1.0D, 0.0D); + } else { + base = player.getLocation().add(0.0D, 1.0D, 0.0D); + } + return base; } } diff --git a/src/main/java/com/skyblockexp/ezauction/command/BidCommand.java b/src/main/java/com/skyblockexp/ezauction/command/BidCommand.java index 3ded205..a6310b4 100644 --- a/src/main/java/com/skyblockexp/ezauction/command/BidCommand.java +++ b/src/main/java/com/skyblockexp/ezauction/command/BidCommand.java @@ -31,13 +31,13 @@ * *

Sub-command overview:

*
    - *
  • {@code /bid} — shows status (active session) or lists active auctions
  • - *
  • {@code /bid } — places a bid on the player's currently selected auction
  • - *
  • {@code /bid select } — joins a live auction by its ID
  • - *
  • {@code /bid leave} — leaves the current live auction session
  • - *
  • {@code /bid notify [on|off]} — toggles notification preferences
  • - *
  • {@code /bid notify display } — sets EzCountdown display types
  • - *
  • {@code /bid mutechat [on|off]} — toggles chat filter while in auction
  • + *
  • {@code /bid} - shows status (active session) or lists active auctions
  • + *
  • {@code /bid } - places a bid on the player's currently selected auction
  • + *
  • {@code /bid select } - joins a live auction by its ID
  • + *
  • {@code /bid leave} - leaves the current live auction session
  • + *
  • {@code /bid notify [on|off]} - toggles notification preferences
  • + *
  • {@code /bid notify display } - sets EzCountdown display types
  • + *
  • {@code /bid mutechat [on|off]} - toggles chat filter while in auction
  • *
*/ public final class BidCommand implements CommandExecutor, TabCompleter { @@ -110,7 +110,7 @@ private void handleNoArgs(Player player) { liveAuctionService.getActiveAuction(activeId.get()).ifPresentOrElse( state -> bidMenu.open(player, activeId.get()), () -> { - // Session stale — auto-clean + // Session stale - auto-clean sessionRegistry.leave(player.getUniqueId()); openPickerOrList(player); }); @@ -153,7 +153,7 @@ private void handleBid(Player player, String amountStr) { if (active.size() == 1) { auctionId = active.iterator().next().id(); } else { - // Multiple auctions — player must select one first + // Multiple auctions - player must select one first player.sendMessage(colorize(messages.selectPrompt())); listAuctions(player, active); return; @@ -168,7 +168,7 @@ private void handleBid(Player player, String amountStr) { private void handleSelect(Player player, String[] args) { if (args.length < 2) { - // No ID given — open picker + // No ID given - open picker openPickerOrList(player); return; } diff --git a/src/main/java/com/skyblockexp/ezauction/command/hologram/HologramRemovalHandler.java b/src/main/java/com/skyblockexp/ezauction/command/hologram/HologramRemovalHandler.java new file mode 100644 index 0000000..75d6336 --- /dev/null +++ b/src/main/java/com/skyblockexp/ezauction/command/hologram/HologramRemovalHandler.java @@ -0,0 +1,258 @@ +package com.skyblockexp.ezauction.command.hologram; + +import com.skyblockexp.ezauction.command.AuctionHologramCommand; +import com.skyblockexp.ezauction.config.AuctionCommandMessageConfiguration.HologramMessages; +import com.skyblockexp.ezauction.hologram.AuctionHologramManager; +import com.skyblockexp.ezauction.hologram.AuctionHologramManager.HologramMatch; +import com.skyblockexp.ezauction.hologram.AuctionHologramType; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.entity.Player; +import org.bukkit.plugin.java.JavaPlugin; + +/** + * Handles the {@code list}, {@code clear} and {@code clearall} subcommands of {@code /auctionhologram}. + * + *

Search origin is the player's eye location so the vertical placement offset of holograms is + * covered. Removal radii default to the configured {@code removal-radius} and are clamped to + * {@code max-removal-radius}. + */ +public final class HologramRemovalHandler { + + private static final long CONFIRM_WINDOW_MILLIS = 15_000L; + + private final JavaPlugin plugin; + private final AuctionHologramManager manager; + private final HologramMessages messages; + private final Map pendingConfirmations = new ConcurrentHashMap<>(); + + public HologramRemovalHandler(JavaPlugin plugin, AuctionHologramManager manager, HologramMessages messages) { + this.plugin = plugin; + this.manager = manager; + this.messages = messages != null ? messages : HologramMessages.defaults(); + } + + /** + * Lists nearby holograms, nearest-first, one line per match. + */ + public void list(Player player, List args) { + ParseResult parsed = parse(args, player); + if (parsed.denied() != null) { + player.sendMessage(parsed.denied()); + return; + } + Location eye = player.getEyeLocation(); + List matches = manager.findNearby(eye, parsed.radius(), parsed.includeUntagged()); + if (matches.isEmpty()) { + player.sendMessage(AuctionHologramCommand.translate(messages.listEmpty())); + return; + } + player.sendMessage(AuctionHologramCommand.translate(messages.listHeading())); + int index = 0; + for (HologramMatch match : matches) { + index++; + Location loc = match.display().location(); + String typeLabel = match.type() != null ? match.type().name().toLowerCase(Locale.ENGLISH) + : messages.unknownTypeLabel(); + String worldName = loc != null && loc.getWorld() != null ? loc.getWorld().getName() : "?"; + double x = loc != null ? loc.getX() : 0.0D; + double y = loc != null ? loc.getY() : 0.0D; + double z = loc != null ? loc.getZ() : 0.0D; + String line = messages.listEntry() + .replace("{index}", String.valueOf(index)) + .replace("{type}", typeLabel) + .replace("{world}", worldName) + .replace("{x}", formatCoord(x)) + .replace("{y}", formatCoord(y)) + .replace("{z}", formatCoord(z)) + .replace("{distance}", String.format(Locale.US, "%.1f", match.distance())); + player.sendMessage(AuctionHologramCommand.translate(line)); + } + } + + /** + * Removes the single nearest hologram. When untagged displays are included, the removal becomes a + * two-step flow: the first invocation reports the count and arms a confirmation, the second within + * the window removes only the nearest untagged display. + */ + public void clear(Player player, List args) { + ParseResult parsed = parse(args, player); + if (parsed.denied() != null) { + player.sendMessage(parsed.denied()); + return; + } + if (parsed.includeUntagged()) { + clearUntagged(player, parsed); + return; + } + Location eye = player.getEyeLocation(); + List matches = manager.findNearby(eye, parsed.radius(), parsed.includeUntagged()); + if (matches.isEmpty()) { + player.sendMessage(AuctionHologramCommand.translate(messages.noneFound())); + return; + } + HologramMatch nearest = matches.get(0); + manager.remove(nearest); + String typeLabel = nearest.type() != null ? nearest.type().name().toLowerCase(Locale.ENGLISH) + : messages.unknownTypeLabel(); + String message = messages.clearedDetailed() + .replace("{type}", typeLabel) + .replace("{distance}", String.format(Locale.US, "%.1f", nearest.distance())); + player.sendMessage(AuctionHologramCommand.translate(message)); + } + + private void clearUntagged(Player player, ParseResult parsed) { + UUID id = player.getUniqueId(); + PendingClearAll pending = pendingConfirmations.get(id); + if (pending != null && !pending.isExpired()) { + pendingConfirmations.remove(id); + List matches = pending.matches(); + if (matches.isEmpty()) { + player.sendMessage(AuctionHologramCommand.translate(messages.noneFound())); + return; + } + HologramMatch nearest = matches.get(0); + manager.remove(nearest); + String typeLabel = nearest.type() != null ? nearest.type().name().toLowerCase(Locale.ENGLISH) + : messages.unknownTypeLabel(); + String message = messages.clearedDetailed() + .replace("{type}", typeLabel) + .replace("{distance}", String.format(Locale.US, "%.1f", nearest.distance())); + player.sendMessage(AuctionHologramCommand.translate(message)); + return; + } + Location eye = player.getEyeLocation(); + List matches = manager.findNearby(eye, parsed.radius(), true); + if (matches.isEmpty()) { + player.sendMessage(AuctionHologramCommand.translate(messages.noneFound())); + return; + } + pendingConfirmations.put(id, new PendingClearAll(matches)); + String message = messages.untaggedClearConfirm() + .replace("{count}", String.valueOf(matches.size())) + .replace("{radius}", String.format(Locale.US, "%.1f", parsed.radius())); + player.sendMessage(AuctionHologramCommand.translate(message)); + } + + /** + * Two-step removal: first invocation reports the count, second within the window removes them. + */ + public void clearAll(Player player, List args) { + ParseResult parsed = parse(args, player); + if (parsed.denied() != null) { + player.sendMessage(parsed.denied()); + return; + } + UUID id = player.getUniqueId(); + PendingClearAll pending = pendingConfirmations.get(id); + if (pending != null && !pending.isExpired()) { + pendingConfirmations.remove(id); + int removed = manager.removeAll(pending.matches()); + String message = messages.clearallDone().replace("{count}", String.valueOf(removed)); + player.sendMessage(AuctionHologramCommand.translate(message)); + return; + } + Location eye = player.getEyeLocation(); + List matches = manager.findNearby(eye, parsed.radius(), parsed.includeUntagged()); + if (matches.isEmpty()) { + player.sendMessage(AuctionHologramCommand.translate(messages.noneFound())); + return; + } + pendingConfirmations.put(id, new PendingClearAll(matches)); + String message = messages.clearallConfirm() + .replace("{count}", String.valueOf(matches.size())) + .replace("{radius}", String.format(Locale.US, "%.1f", parsed.radius())) + .replace("{label}", player.getName()); + player.sendMessage(AuctionHologramCommand.translate(message)); + } + + /** + * Clears any pending confirmation state when a player quits. + */ + public void onPlayerQuit(Player player) { + if (player != null) { + pendingConfirmations.remove(player.getUniqueId()); + } + } + + /** + * Expires stale confirmation entries. Call periodically from the plugin. + */ + public void expireStale() { + long now = System.currentTimeMillis(); + pendingConfirmations.entrySet().removeIf(entry -> entry.getValue().createdAt() + CONFIRM_WINDOW_MILLIS < now); + } + + private ParseResult parse(List args, Player player) { + boolean includeUntagged = false; + Double radius = null; + for (String arg : args) { + if ("--untagged".equalsIgnoreCase(arg)) { + includeUntagged = true; + continue; + } + try { + radius = Double.parseDouble(arg); + } catch (NumberFormatException ex) { + return ParseResult.denied(messages.usage().replace("{label}", player.getName())); + } + } + if (includeUntagged && !manager.allowUntaggedRemoval()) { + return ParseResult.denied(messages.untaggedDisabled()); + } + double maxRadius = manager.maxRemovalRadius(); + if (radius != null && radius > maxRadius) { + return ParseResult.denied(messages.invalidRadius() + .replace("{input}", String.valueOf(radius)) + .replace("{max}", String.format(Locale.US, "%.1f", maxRadius))); + } + if (radius != null && radius <= 0.0D) { + return ParseResult.denied(messages.invalidRadius() + .replace("{input}", String.valueOf(radius)) + .replace("{max}", String.format(Locale.US, "%.1f", maxRadius))); + } + double effectiveRadius = radius != null ? radius : manager.removalRadius(); + return ParseResult.allowed(effectiveRadius, includeUntagged); + } + + private static String formatCoord(double value) { + return String.format(Locale.US, "%.1f", value); + } + + private record ParseResult(boolean allowed, double radius, boolean includeUntagged, String denied) { + static ParseResult allowed(double radius, boolean includeUntagged) { + return new ParseResult(true, radius, includeUntagged, null); + } + + static ParseResult denied(String message) { + return new ParseResult(false, 0.0D, false, message); + } + } + + private static final class PendingClearAll { + private final List matches; + private final long createdAt; + + private PendingClearAll(List matches) { + this.matches = matches; + this.createdAt = System.currentTimeMillis(); + } + + private List matches() { + return matches; + } + + private long createdAt() { + return createdAt; + } + + private boolean isExpired() { + return createdAt + CONFIRM_WINDOW_MILLIS < System.currentTimeMillis(); + } + } +} diff --git a/src/main/java/com/skyblockexp/ezauction/compat/Scheduler.java b/src/main/java/com/skyblockexp/ezauction/compat/Scheduler.java new file mode 100644 index 0000000..0f46af4 --- /dev/null +++ b/src/main/java/com/skyblockexp/ezauction/compat/Scheduler.java @@ -0,0 +1,150 @@ +package com.skyblockexp.ezauction.compat; + +import java.lang.reflect.Method; +import java.util.function.Consumer; +import org.bukkit.Bukkit; +import org.bukkit.plugin.Plugin; +import org.bukkit.scheduler.BukkitTask; + +/** + * Folia-safe scheduler facade. + * + *

Routes sync/delayed/repeating tasks to the global region scheduler on Folia and to the + * regular Bukkit scheduler everywhere else, so the plugin boots cleanly on regionised servers + * without throwing {@code IllegalStateException} ("async thread access" / no global + * BukkitScheduler). Async tasks are routed to the Folia async scheduler.

+ * + *

The FoliaLib third-party library is intentionally not used as a dependency; the + * detection and routing is done by reflection so the plugin keeps zero extra runtime + * dependencies. On non-Folia servers every method is a thin pass-through to the Bukkit + * scheduler and therefore a no-op from a behaviour standpoint.

+ * + *

All methods return a real {@link BukkitTask} (on Folia this is a + * {@code ScheduledTask} which is a {@code BukkitTask} subtype) so callers can cancel timers as + * before.

+ */ +public final class Scheduler { + + private static final boolean FOLIA = detectFolia(); + + private static final Method GLOBAL_RUN; + private static final Method GLOBAL_RUN_DELAYED; + private static final Method GLOBAL_RUN_FIXED_RATE; + private static final Method ASYNC_RUN_NOW; + + static { + Method gr = null, grd = null, grfr = null, arn = null; + if (FOLIA) { + try { + Class serverClass = Bukkit.getServer().getClass(); + Class consumer = java.util.function.Consumer.class; + gr = serverClass.getMethod("getGlobalRegionScheduler") + .invoke(Bukkit.getServer()).getClass() + .getMethod("run", Plugin.class, consumer); + grd = serverClass.getMethod("getGlobalRegionScheduler") + .invoke(Bukkit.getServer()).getClass() + .getMethod("runDelayed", Plugin.class, consumer, long.class); + grfr = serverClass.getMethod("getGlobalRegionScheduler") + .invoke(Bukkit.getServer()).getClass() + .getMethod("runAtFixedRate", Plugin.class, consumer, long.class, long.class); + arn = serverClass.getMethod("getAsyncScheduler") + .invoke(Bukkit.getServer()).getClass() + .getMethod("runNow", Plugin.class, consumer); + } catch (ReflectiveOperationException e) { + throw new ExceptionInInitializerError(e); + } + } + GLOBAL_RUN = gr; + GLOBAL_RUN_DELAYED = grd; + GLOBAL_RUN_FIXED_RATE = grfr; + ASYNC_RUN_NOW = arn; + } + + private Scheduler() { + } + + /** + * @return {@code true} if the running server is Folia (regionised) and exposes the + * threaded-regions scheduler API. + */ + public static boolean isFolia() { + return FOLIA; + } + + /** + * Run a task on the main/global region thread as soon as possible. + */ + public static BukkitTask run(Plugin plugin, Runnable task) { + if (FOLIA) { + return (BukkitTask) invoke(GLOBAL_RUN, globalScheduler(), plugin, (Consumer) t -> task.run()); + } + return Bukkit.getScheduler().runTask(plugin, task); + } + + /** + * Run a task on the main/global region thread after the given delay in ticks. + */ + public static BukkitTask runLater(Plugin plugin, Runnable task, long delayTicks) { + if (FOLIA) { + return (BukkitTask) invoke(GLOBAL_RUN_DELAYED, globalScheduler(), plugin, (Consumer) t -> task.run(), delayTicks); + } + return Bukkit.getScheduler().runTaskLater(plugin, task, delayTicks); + } + + /** + * Run a repeating task on the main/global region thread. + */ + public static BukkitTask runTimer(Plugin plugin, Runnable task, long delayTicks, long periodTicks) { + if (FOLIA) { + return (BukkitTask) invoke(GLOBAL_RUN_FIXED_RATE, globalScheduler(), plugin, (Consumer) t -> task.run(), delayTicks, periodTicks); + } + return Bukkit.getScheduler().runTaskTimer(plugin, task, delayTicks, periodTicks); + } + + /** + * Run a task asynchronously (off the tick thread) as soon as possible. + */ + public static BukkitTask runAsync(Plugin plugin, Runnable task) { + if (FOLIA) { + return (BukkitTask) invoke(ASYNC_RUN_NOW, asyncScheduler(), plugin, (Consumer) t -> task.run()); + } + return Bukkit.getScheduler().runTaskAsynchronously(plugin, task); + } + + private static Object globalScheduler() { + try { + return Bukkit.getServer().getClass() + .getMethod("getGlobalRegionScheduler") + .invoke(Bukkit.getServer()); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException("Folia detected but getGlobalRegionScheduler unavailable", e); + } + } + + private static Object asyncScheduler() { + try { + return Bukkit.getServer().getClass() + .getMethod("getAsyncScheduler") + .invoke(Bukkit.getServer()); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException("Folia detected but getAsyncScheduler unavailable", e); + } + } + + private static Object invoke(Method method, Object target, Object... args) { + try { + return method.invoke(target, args); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException("Failed to schedule Folia task", e); + } + } + + private static boolean detectFolia() { + try { + Class.forName("io.papermc.paper.threadedregions.RegionizedServer"); + return true; + } catch (ClassNotFoundException e) { + return false; + } + } +} diff --git a/src/main/java/com/skyblockexp/ezauction/compat/TextDisplayHologramPlatform.java b/src/main/java/com/skyblockexp/ezauction/compat/TextDisplayHologramPlatform.java index fdb65f8..66d48ea 100644 --- a/src/main/java/com/skyblockexp/ezauction/compat/TextDisplayHologramPlatform.java +++ b/src/main/java/com/skyblockexp/ezauction/compat/TextDisplayHologramPlatform.java @@ -1,48 +1,41 @@ package com.skyblockexp.ezauction.compat; -import java.lang.reflect.Method; import java.util.Collection; +import java.util.Comparator; import java.util.List; import java.util.UUID; import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer; +import org.bukkit.Color; import org.bukkit.Location; import org.bukkit.NamespacedKey; +import org.bukkit.persistence.PersistentDataType; import org.bukkit.World; +import org.bukkit.entity.Display; import org.bukkit.entity.Entity; +import org.bukkit.entity.TextDisplay; import org.bukkit.plugin.java.JavaPlugin; /** * TextDisplay-backed hologram implementation for 1.19+ servers. + * + *

The project targets Paper 1.21+ / Java 21, so {@link TextDisplay}, {@link Display.Billboard}, + * {@link TextDisplay.TextAlignment} and the persistent data container are all available at compile + * time. Direct typed calls are used instead of reflection, which previously hid marker and + * configuration failures behind swallowed exceptions. */ public final class TextDisplayHologramPlatform implements HologramPlatform { private final JavaPlugin plugin; + private final NamespacedKey markerKey; public TextDisplayHologramPlatform(JavaPlugin plugin) { this.plugin = plugin; + this.markerKey = new NamespacedKey(plugin, "auction_hologram_type"); } - /** - * Checks if TextDisplay is supported on this server version. - * Uses reflection to verify presence of required classes and methods. - */ @Override public boolean isSupported() { - try { - Class textDisplayClass = Class.forName("org.bukkit.entity.TextDisplay"); - textDisplayClass.getMethod("text", Component.class); - return true; - } catch (ClassNotFoundException | NoSuchMethodException e) { - try { - Class textDisplayClass = Class.forName("org.bukkit.entity.TextDisplay"); - textDisplayClass.getMethod("setText", String.class); - return true; - } catch (Exception ignored) { - } - } catch (Exception e) { - } - return false; + return true; } @Override @@ -55,14 +48,12 @@ public HologramDisplay spawn(Location location) { return null; } try { - Class textDisplayClass = Class.forName("org.bukkit.entity.TextDisplay"); - Entity entity = world.spawn(location, (Class) textDisplayClass); - if (textDisplayClass.isInstance(entity)) { - return new TextDisplayWrapper(entity); - } + TextDisplay entity = world.spawn(location, TextDisplay.class); + return new TextDisplayWrapper(entity); } catch (Exception e) { + plugin.getLogger().warning("Failed to spawn auction hologram TextDisplay: " + e.getMessage()); + return null; } - return null; } @Override @@ -70,17 +61,10 @@ public Collection findDisplays(World world) { if (world == null) { return List.of(); } - try { - Class textDisplayClass = Class.forName("org.bukkit.entity.TextDisplay"); - return world.getEntitiesByClass((Class) textDisplayClass) - .stream() - .map(entity -> textDisplayClass.isInstance(entity) ? new TextDisplayWrapper(entity) : null) - .filter(e -> e != null) - .map(HologramDisplay.class::cast) - .toList(); - } catch (Exception e) { - } - return List.of(); + return world.getEntitiesByClass(TextDisplay.class).stream() + .map(TextDisplayWrapper::new) + .map(HologramDisplay.class::cast) + .toList(); } @Override @@ -92,39 +76,40 @@ public Collection findNearby(Location location, double radius) if (world == null) { return List.of(); } - try { - Class textDisplayClass = Class.forName("org.bukkit.entity.TextDisplay"); - return world.getNearbyEntities(location, radius, radius, radius, entity -> textDisplayClass.isInstance(entity)) - .stream() - .map(entity -> textDisplayClass.isInstance(entity) ? new TextDisplayWrapper(entity) : null) - .filter(e -> e != null) - .map(HologramDisplay.class::cast) - .toList(); - } catch (Exception e) { - } - return List.of(); + double box = Math.max(0.0D, radius); + List boxed = world.getNearbyEntities(location, box, box, box, + entity -> entity instanceof TextDisplay) + .stream() + .map(entity -> (TextDisplay) entity) + .map(TextDisplayWrapper::new) + .map(HologramDisplay.class::cast) + .toList(); + if (box >= Double.MAX_VALUE / 2.0D) { + return boxed; + } + double squared = radius * radius; + return boxed.stream() + .filter(display -> { + Location loc = display.location(); + return loc != null && loc.getWorld() == world + && loc.distanceSquared(location) <= squared; + }) + .sorted(Comparator.comparingDouble(display -> { + Location loc = display.location(); + return loc == null ? Double.MAX_VALUE : loc.distanceSquared(location); + })) + .toList(); } @Override public boolean isDisplayEntity(Entity entity) { - try { - Class textDisplayClass = Class.forName("org.bukkit.entity.TextDisplay"); - return textDisplayClass.isInstance(entity); - } catch (Exception e) { - return false; - } + return entity instanceof TextDisplay; } @Override public HologramDisplay wrap(Entity entity) { - try { - if (entity != null) { - Class textDisplayClass = Class.forName("org.bukkit.entity.TextDisplay"); - if (textDisplayClass.isInstance(entity)) { - return new TextDisplayWrapper(entity); - } - } - } catch (Exception e) { + if (entity instanceof TextDisplay textDisplay) { + return new TextDisplayWrapper(textDisplay); } return null; } @@ -134,41 +119,18 @@ public void configureBase(HologramDisplay display) { if (!(display instanceof TextDisplayWrapper wrapper)) { return; } - Entity handle = wrapper.handle(); - if (handle == null) { + TextDisplay entity = wrapper.handle(); + if (entity == null) { return; } - try { - // Use reflection for all method calls - Class displayClass = handle.getClass(); - Method setBillboard = displayClass.getMethod("setBillboard", displayClass.getClass().getClassLoader().loadClass("org.bukkit.entity.Display$Billboard")); - Object centerBillboard = displayClass.getClass().getClassLoader().loadClass("org.bukkit.entity.Display$Billboard").getField("CENTER").get(null); - setBillboard.invoke(handle, centerBillboard); - - Method setPersistent = displayClass.getMethod("setPersistent", boolean.class); - setPersistent.invoke(handle, true); - - Method setShadowed = displayClass.getMethod("setShadowed", boolean.class); - setShadowed.invoke(handle, false); - - Method setSeeThrough = displayClass.getMethod("setSeeThrough", boolean.class); - setSeeThrough.invoke(handle, true); - - Method setAlignment = displayClass.getMethod("setAlignment", displayClass.getClass().getClassLoader().loadClass("org.bukkit.entity.TextDisplay$TextAlignment")); - Object centerAlignment = displayClass.getClass().getClassLoader().loadClass("org.bukkit.entity.TextDisplay$TextAlignment").getField("CENTER").get(null); - setAlignment.invoke(handle, centerAlignment); - - Method setViewRange = displayClass.getMethod("setViewRange", float.class); - setViewRange.invoke(handle, 48.0F); - - Method setLineWidth = displayClass.getMethod("setLineWidth", int.class); - setLineWidth.invoke(handle, 160); - - Method setBackgroundColor = displayClass.getMethod("setBackgroundColor", int.class); - setBackgroundColor.invoke(handle, 0); - } catch (Exception e) { - // Some methods may not exist on legacy versions - } + entity.setBillboard(Display.Billboard.CENTER); + entity.setPersistent(true); + entity.setShadowed(false); + entity.setSeeThrough(true); + entity.setAlignment(TextDisplay.TextAlignment.CENTER); + entity.setViewRange(48.0F); + entity.setLineWidth(160); + entity.setBackgroundColor(Color.fromARGB(0)); } @Override @@ -176,21 +138,11 @@ public void setMarker(HologramDisplay display, String key, String value) { if (!(display instanceof TextDisplayWrapper wrapper) || key == null || value == null) { return; } - Entity handle = wrapper.handle(); - if (handle == null) { + TextDisplay entity = wrapper.handle(); + if (entity == null) { return; } - try { - Method getPDC = handle.getClass().getMethod("getPersistentDataContainer"); - Object container = getPDC.invoke(handle); - Class namespacedKeyClass = Class.forName("org.bukkit.NamespacedKey"); - Object namespacedKey = namespacedKeyClass.getConstructor(JavaPlugin.class, String.class).newInstance(plugin, key); - Class persistentDataTypeClass = Class.forName("org.bukkit.persistence.PersistentDataType"); - Object stringType = persistentDataTypeClass.getField("STRING").get(null); - Method setMethod = container.getClass().getMethod("set", namespacedKeyClass, persistentDataTypeClass, Object.class); - setMethod.invoke(container, namespacedKey, stringType, value); - } catch (Exception e) { - } + entity.getPersistentDataContainer().set(markerKeyFor(key), PersistentDataType.STRING, value); } @Override @@ -198,98 +150,58 @@ public String getMarker(HologramDisplay display, String key) { if (!(display instanceof TextDisplayWrapper wrapper) || key == null) { return null; } - Entity handle = wrapper.handle(); - if (handle == null) { + TextDisplay entity = wrapper.handle(); + if (entity == null) { return null; } - try { - Method getPDC = handle.getClass().getMethod("getPersistentDataContainer"); - Object container = getPDC.invoke(handle); - Class namespacedKeyClass = Class.forName("org.bukkit.NamespacedKey"); - Object namespacedKey = namespacedKeyClass.getConstructor(JavaPlugin.class, String.class).newInstance(plugin, key); - Class persistentDataTypeClass = Class.forName("org.bukkit.persistence.PersistentDataType"); - Object stringType = persistentDataTypeClass.getField("STRING").get(null); - Method getMethod = container.getClass().getMethod("get", namespacedKeyClass, persistentDataTypeClass); - Object result = getMethod.invoke(container, namespacedKey, stringType); - return result != null ? result.toString() : null; - } catch (Exception e) { + return entity.getPersistentDataContainer().get(markerKeyFor(key), PersistentDataType.STRING); + } + + private NamespacedKey markerKeyFor(String key) { + if (key.equals(markerKey.getKey())) { + return markerKey; } - return null; + return new NamespacedKey(plugin, key); } private static final class TextDisplayWrapper implements HologramDisplay { - private final Entity display; + private final TextDisplay display; - private TextDisplayWrapper(Entity display) { + private TextDisplayWrapper(TextDisplay display) { this.display = display; } - private Entity handle() { + private TextDisplay handle() { return display; } @Override public UUID uniqueId() { - try { - Method getUniqueId = display.getClass().getMethod("getUniqueId"); - return (UUID) getUniqueId.invoke(display); - } catch (Exception e) { - return null; - } + return display.getUniqueId(); } @Override public Location location() { - try { - Method getLocation = display.getClass().getMethod("getLocation"); - return (Location) getLocation.invoke(display); - } catch (Exception e) { - return null; - } + return display.getLocation(); } @Override public boolean isValid() { - try { - Method isValid = display.getClass().getMethod("isValid"); - Method isDead = display.getClass().getMethod("isDead"); - return (Boolean) isValid.invoke(display) && !(Boolean) isDead.invoke(display); - } catch (Exception e) { - return false; - } + return display.isValid() && !display.isDead(); } @Override public void remove() { - try { - Method remove = display.getClass().getMethod("remove"); - remove.invoke(display); - } catch (Exception e) { - } + display.remove(); } - /** - * Sets the text for the hologram display, using reflection to support both modern and legacy APIs. - * Falls back to legacy string serialization if necessary. - */ @Override public void setText(Component text) { if (text == null) { return; } - try { - Method textMethod = display.getClass().getMethod("text", Component.class); - textMethod.invoke(display, text); - } catch (NoSuchMethodException ex) { - try { - Method setTextMethod = display.getClass().getMethod("setText", String.class); - String legacy = LegacyComponentSerializer.legacySection().serialize(text); - setTextMethod.invoke(display, legacy); - } catch (Exception ignored) { - } - } catch (Exception ex) { - } + display.text(text); } } } diff --git a/src/main/java/com/skyblockexp/ezauction/config/AuctionCommandMessageConfiguration.java b/src/main/java/com/skyblockexp/ezauction/config/AuctionCommandMessageConfiguration.java index dc0ac2b..b383f8f 100644 --- a/src/main/java/com/skyblockexp/ezauction/config/AuctionCommandMessageConfiguration.java +++ b/src/main/java/com/skyblockexp/ezauction/config/AuctionCommandMessageConfiguration.java @@ -442,7 +442,19 @@ public record HologramMessages( String noneFound, String usage, String typesHeading, - String typesEntry) { + String typesEntry, + String listHeading, + String listEntry, + String listEmpty, + String clearedDetailed, + String clearallConfirm, + String clearallDone, + String clearallExpired, + String invalidRadius, + String untaggedDisabled, + String unknownTypeLabel, + String maxHologramsReached, + String untaggedClearConfirm) { private static final String DEFAULT_PLAYERS_ONLY = "&cOnly players may manage auction holograms."; private static final String DEFAULT_DISABLED = "&cAuction holograms are disabled in the configuration."; @@ -452,12 +464,35 @@ public record HologramMessages( private static final String DEFAULT_PLACEMENT_FAILED = "&cFailed to place the auction hologram. Ensure the chunk is loaded."; private static final String DEFAULT_PLACEMENT_SUCCESS = - "&aPlaced an auction hologram for &6{display}&a."; + "&aPlaced &6{display} &ahologram at &e{x},{y},{z} &a(&e{world}&a)."; + private static final String DEFAULT_UNTAGGED_CLEAR_CONFIRM = + "&eFound &6{count} &euntagged display(s) within &6{radius} &eblocks (may include other" + + " plugins' holograms). Run &6/auctionhologram clear --untagged &eagain within 15s to" + + " remove the nearest."; private static final String DEFAULT_CLEARED = "&eRemoved the nearest auction hologram."; private static final String DEFAULT_NONE_FOUND = "&cNo auction hologram found nearby."; - private static final String DEFAULT_USAGE = "&eUsage: /{label} "; + private static final String DEFAULT_USAGE = "&eUsage: /{label} "; private static final String DEFAULT_TYPES_HEADING = "&6Available hologram types:"; private static final String DEFAULT_TYPES_ENTRY = "&7- &6{name}&7 ({display})"; + private static final String DEFAULT_LIST_HEADING = "&6Nearby auction holograms:"; + private static final String DEFAULT_LIST_ENTRY = + "&7#{index} &6{type} &7@ &e{world} {x},{y},{z} &7(&e{distance}&7 blocks)"; + private static final String DEFAULT_LIST_EMPTY = "&cNo auction holograms found nearby."; + private static final String DEFAULT_CLEARED_DETAILED = + "&eRemoved &6{type} &eauction hologram &7(&e{distance}&7 blocks away)."; + private static final String DEFAULT_CLEARALL_CONFIRM = + "&eFound &6{count} &eauction hologram(s) within &6{radius} &eblocks of {label}." + + " Run the command again within 15s to remove them."; + private static final String DEFAULT_CLEARALL_DONE = "&aRemoved &6{count} &aauction hologram(s)."; + private static final String DEFAULT_CLEARALL_EXPIRED = + "&cConfirmation window expired. Run &6/list &cagain, then re-run your clear command."; + private static final String DEFAULT_INVALID_RADIUS = + "&cInvalid radius '&6{input}&c'. Use a number up to &6{max}&c blocks."; + private static final String DEFAULT_UNTAGGED_DISABLED = + "&cUntagged removal is disabled in the configuration (&6allow-untagged-removal&c)."; + private static final String DEFAULT_UNKNOWN_TYPE_LABEL = "unknown"; + private static final String DEFAULT_MAX_HOLOGRAMS_REACHED = + "&cMaximum of &6{max} &cauction holograms reached. Placement denied."; public HologramMessages { playersOnly = normalize(playersOnly, DEFAULT_PLAYERS_ONLY); @@ -471,12 +506,28 @@ public record HologramMessages( usage = normalize(usage, DEFAULT_USAGE); typesHeading = normalize(typesHeading, DEFAULT_TYPES_HEADING); typesEntry = normalize(typesEntry, DEFAULT_TYPES_ENTRY); + listHeading = normalize(listHeading, DEFAULT_LIST_HEADING); + listEntry = normalize(listEntry, DEFAULT_LIST_ENTRY); + listEmpty = normalize(listEmpty, DEFAULT_LIST_EMPTY); + clearedDetailed = normalize(clearedDetailed, DEFAULT_CLEARED_DETAILED); + clearallConfirm = normalize(clearallConfirm, DEFAULT_CLEARALL_CONFIRM); + clearallDone = normalize(clearallDone, DEFAULT_CLEARALL_DONE); + clearallExpired = normalize(clearallExpired, DEFAULT_CLEARALL_EXPIRED); + invalidRadius = normalize(invalidRadius, DEFAULT_INVALID_RADIUS); + untaggedDisabled = normalize(untaggedDisabled, DEFAULT_UNTAGGED_DISABLED); + unknownTypeLabel = normalize(unknownTypeLabel, DEFAULT_UNKNOWN_TYPE_LABEL); + maxHologramsReached = normalize(maxHologramsReached, DEFAULT_MAX_HOLOGRAMS_REACHED); + untaggedClearConfirm = normalize(untaggedClearConfirm, DEFAULT_UNTAGGED_CLEAR_CONFIRM); } public static HologramMessages defaults() { return new HologramMessages(DEFAULT_PLAYERS_ONLY, DEFAULT_DISABLED, DEFAULT_UNKNOWN_TYPE, DEFAULT_PLACEMENT_RANGE, DEFAULT_PLACEMENT_FAILED, DEFAULT_PLACEMENT_SUCCESS, DEFAULT_CLEARED, - DEFAULT_NONE_FOUND, DEFAULT_USAGE, DEFAULT_TYPES_HEADING, DEFAULT_TYPES_ENTRY); + DEFAULT_NONE_FOUND, DEFAULT_USAGE, DEFAULT_TYPES_HEADING, DEFAULT_TYPES_ENTRY, + DEFAULT_LIST_HEADING, DEFAULT_LIST_ENTRY, DEFAULT_LIST_EMPTY, DEFAULT_CLEARED_DETAILED, + DEFAULT_CLEARALL_CONFIRM, DEFAULT_CLEARALL_DONE, DEFAULT_CLEARALL_EXPIRED, DEFAULT_INVALID_RADIUS, + DEFAULT_UNTAGGED_DISABLED, DEFAULT_UNKNOWN_TYPE_LABEL, DEFAULT_MAX_HOLOGRAMS_REACHED, + DEFAULT_UNTAGGED_CLEAR_CONFIRM); } public static HologramMessages from(ConfigurationSection section) { @@ -494,7 +545,19 @@ public static HologramMessages from(ConfigurationSection section) { section.getString("none-found"), section.getString("usage"), section.getString("types-heading"), - section.getString("types-entry")); + section.getString("types-entry"), + section.getString("list-heading"), + section.getString("list-entry"), + section.getString("list-empty"), + section.getString("cleared-detailed"), + section.getString("clearall-confirm"), + section.getString("clearall-done"), + section.getString("clearall-expired"), + section.getString("invalid-radius"), + section.getString("untagged-disabled"), + section.getString("unknown-type-label"), + section.getString("max-holograms-reached"), + section.getString("untagged-clear-confirm")); } } } diff --git a/src/main/java/com/skyblockexp/ezauction/config/AuctionConfigurationLoader.java b/src/main/java/com/skyblockexp/ezauction/config/AuctionConfigurationLoader.java index 0c4fc10..3849fb8 100644 --- a/src/main/java/com/skyblockexp/ezauction/config/AuctionConfigurationLoader.java +++ b/src/main/java/com/skyblockexp/ezauction/config/AuctionConfigurationLoader.java @@ -90,7 +90,7 @@ public static AuctionConfiguration load(JavaPlugin plugin) { ConfigurationSection displaySection = getSection(baseConfiguration, "display"); AuctionDisplayConfiguration displayConfiguration = AuctionDisplayConfiguration.from(displaySection); - // Apply date format globally — DateUtil is a static utility + // Apply date format globally - DateUtil is a static utility com.skyblockexp.ezauction.util.DateUtil.setDateFormat(displayConfiguration.dateFormat()); boolean debug = baseConfiguration != null && baseConfiguration.isBoolean("debug") && baseConfiguration.getBoolean("debug"); diff --git a/src/main/java/com/skyblockexp/ezauction/config/AuctionDisplayConfiguration.java b/src/main/java/com/skyblockexp/ezauction/config/AuctionDisplayConfiguration.java index 71d0e5e..b484309 100644 --- a/src/main/java/com/skyblockexp/ezauction/config/AuctionDisplayConfiguration.java +++ b/src/main/java/com/skyblockexp/ezauction/config/AuctionDisplayConfiguration.java @@ -3,7 +3,7 @@ import org.bukkit.configuration.ConfigurationSection; /** - * Configuration for display formatting — date/time patterns and currency templates. + * Configuration for display formatting - date/time patterns and currency templates. * *

Loaded from the {@code display:} section of {@code auction.yml}.

*/ diff --git a/src/main/java/com/skyblockexp/ezauction/config/AuctionHologramConfiguration.java b/src/main/java/com/skyblockexp/ezauction/config/AuctionHologramConfiguration.java index b20c683..24fac2d 100644 --- a/src/main/java/com/skyblockexp/ezauction/config/AuctionHologramConfiguration.java +++ b/src/main/java/com/skyblockexp/ezauction/config/AuctionHologramConfiguration.java @@ -12,11 +12,17 @@ public final class AuctionHologramConfiguration { private static final long DEFAULT_UPDATE_INTERVAL_TICKS = 20L * 5L; private static final double DEFAULT_SEARCH_RADIUS = 2.5D; private static final double DEFAULT_HEIGHT_OFFSET = 1.75D; + private static final double DEFAULT_REMOVAL_RADIUS = 10.0D; + private static final double DEFAULT_MAX_REMOVAL_RADIUS = 64.0D; + private static final boolean DEFAULT_ALLOW_UNTAGGED_REMOVAL = true; private final boolean enabled; private final long updateIntervalTicks; private final double searchRadius; private final double heightOffset; + private final double removalRadius; + private final double maxRemovalRadius; + private final boolean allowUntaggedRemoval; // Hologram advanced config fields private static final boolean DEFAULT_REQUIRE_PERMISSION = false; private static final String DEFAULT_VIEW_PERMISSION = "ezauction.hologram.view"; @@ -33,22 +39,18 @@ public final class AuctionHologramConfiguration { public AuctionHologramConfiguration(boolean enabled, long updateIntervalTicks, double searchRadius, double heightOffset) { - this.enabled = enabled; - this.updateIntervalTicks = sanitizeInterval(updateIntervalTicks); - this.searchRadius = sanitizeSearchRadius(searchRadius); - this.heightOffset = sanitizeHeightOffset(heightOffset); - this.maxHolograms = DEFAULT_MAX_HOLOGRAMS; - this.batchUpdate = DEFAULT_BATCH_UPDATE; - this.requirePermission = DEFAULT_REQUIRE_PERMISSION; - this.viewPermission = DEFAULT_VIEW_PERMISSION; - this.proximityLimit = DEFAULT_PROXIMITY_LIMIT; - this.proximityDistance = DEFAULT_PROXIMITY_DISTANCE; + this(enabled, updateIntervalTicks, searchRadius, heightOffset, DEFAULT_MAX_HOLOGRAMS, DEFAULT_BATCH_UPDATE, + DEFAULT_REQUIRE_PERMISSION, DEFAULT_VIEW_PERMISSION, DEFAULT_PROXIMITY_LIMIT, DEFAULT_PROXIMITY_DISTANCE, + DEFAULT_REMOVAL_RADIUS, DEFAULT_MAX_REMOVAL_RADIUS, DEFAULT_ALLOW_UNTAGGED_REMOVAL); } public boolean enabled() { return enabled; } public long updateIntervalTicks() { return updateIntervalTicks; } public double searchRadius() { return searchRadius; } public double heightOffset() { return heightOffset; } + public double removalRadius() { return removalRadius; } + public double maxRemovalRadius() { return maxRemovalRadius; } + public boolean allowUntaggedRemoval() { return allowUntaggedRemoval; } public int maxHolograms() { return maxHolograms; } public boolean batchUpdate() { return batchUpdate; } public boolean requirePermission() { return requirePermission; } @@ -59,7 +61,8 @@ public AuctionHologramConfiguration(boolean enabled, long updateIntervalTicks, d public AuctionHologramConfiguration(boolean enabled, long updateIntervalTicks, double searchRadius, double heightOffset, int maxHolograms, boolean batchUpdate, boolean requirePermission, String viewPermission, - boolean proximityLimit, double proximityDistance) { + boolean proximityLimit, double proximityDistance, + double removalRadius, double maxRemovalRadius, boolean allowUntaggedRemoval) { this.enabled = enabled; this.updateIntervalTicks = sanitizeInterval(updateIntervalTicks); this.searchRadius = sanitizeSearchRadius(searchRadius); @@ -70,12 +73,16 @@ public AuctionHologramConfiguration(boolean enabled, long updateIntervalTicks, d this.viewPermission = viewPermission != null ? viewPermission : DEFAULT_VIEW_PERMISSION; this.proximityLimit = proximityLimit; this.proximityDistance = proximityDistance > 0.0D ? proximityDistance : DEFAULT_PROXIMITY_DISTANCE; + this.removalRadius = sanitizeRemovalRadius(removalRadius); + this.maxRemovalRadius = sanitizeRemovalRadius(maxRemovalRadius); + this.allowUntaggedRemoval = allowUntaggedRemoval; } public static AuctionHologramConfiguration defaults() { return new AuctionHologramConfiguration(DEFAULT_ENABLED, DEFAULT_UPDATE_INTERVAL_TICKS, DEFAULT_SEARCH_RADIUS, DEFAULT_HEIGHT_OFFSET, DEFAULT_MAX_HOLOGRAMS, DEFAULT_BATCH_UPDATE, - DEFAULT_REQUIRE_PERMISSION, DEFAULT_VIEW_PERMISSION, DEFAULT_PROXIMITY_LIMIT, DEFAULT_PROXIMITY_DISTANCE); + DEFAULT_REQUIRE_PERMISSION, DEFAULT_VIEW_PERMISSION, DEFAULT_PROXIMITY_LIMIT, DEFAULT_PROXIMITY_DISTANCE, + DEFAULT_REMOVAL_RADIUS, DEFAULT_MAX_REMOVAL_RADIUS, DEFAULT_ALLOW_UNTAGGED_REMOVAL); } public static AuctionHologramConfiguration from(ConfigurationSection section) { @@ -98,7 +105,10 @@ public static AuctionHologramConfiguration from(ConfigurationSection section) { String viewPermission = section.getString("view-permission", DEFAULT_VIEW_PERMISSION); boolean proximityLimit = section.getBoolean("proximity-limit", DEFAULT_PROXIMITY_LIMIT); double proximityDistance = section.getDouble("proximity-distance", DEFAULT_PROXIMITY_DISTANCE); - return new AuctionHologramConfiguration(enabled, updateInterval, searchRadius, heightOffset, maxHolograms, batchUpdate, requirePermission, viewPermission, proximityLimit, proximityDistance); + double removalRadius = section.getDouble("removal-radius", DEFAULT_REMOVAL_RADIUS); + double maxRemovalRadius = section.getDouble("max-removal-radius", DEFAULT_MAX_REMOVAL_RADIUS); + boolean allowUntaggedRemoval = section.getBoolean("allow-untagged-removal", DEFAULT_ALLOW_UNTAGGED_REMOVAL); + return new AuctionHologramConfiguration(enabled, updateInterval, searchRadius, heightOffset, maxHolograms, batchUpdate, requirePermission, viewPermission, proximityLimit, proximityDistance, removalRadius, maxRemovalRadius, allowUntaggedRemoval); } @Override @@ -108,6 +118,9 @@ public String toString() { ", updateIntervalTicks=" + updateIntervalTicks + ", searchRadius=" + searchRadius + ", heightOffset=" + heightOffset + + ", removalRadius=" + removalRadius + + ", maxRemovalRadius=" + maxRemovalRadius + + ", allowUntaggedRemoval=" + allowUntaggedRemoval + ", maxHolograms=" + maxHolograms + ", batchUpdate=" + batchUpdate + ", requirePermission=" + requirePermission + @@ -126,6 +139,9 @@ public boolean equals(Object o) { updateIntervalTicks == that.updateIntervalTicks && Double.compare(that.searchRadius, searchRadius) == 0 && Double.compare(that.heightOffset, heightOffset) == 0 && + Double.compare(that.removalRadius, removalRadius) == 0 && + Double.compare(that.maxRemovalRadius, maxRemovalRadius) == 0 && + allowUntaggedRemoval == that.allowUntaggedRemoval && maxHolograms == that.maxHolograms && batchUpdate == that.batchUpdate && requirePermission == that.requirePermission && @@ -136,7 +152,7 @@ public boolean equals(Object o) { @Override public int hashCode() { - return Objects.hash(enabled, updateIntervalTicks, searchRadius, heightOffset, maxHolograms, batchUpdate, requirePermission, viewPermission, proximityLimit, proximityDistance); + return Objects.hash(enabled, updateIntervalTicks, searchRadius, heightOffset, removalRadius, maxRemovalRadius, allowUntaggedRemoval, maxHolograms, batchUpdate, requirePermission, viewPermission, proximityLimit, proximityDistance); } private static long sanitizeInterval(long intervalTicks) { @@ -153,6 +169,13 @@ private static double sanitizeSearchRadius(double radius) { return radius; } + private static double sanitizeRemovalRadius(double radius) { + if (!(Double.isFinite(radius)) || radius <= 0.0D) { + return DEFAULT_REMOVAL_RADIUS; + } + return radius; + } + private static double sanitizeHeightOffset(double offset) { if (!Double.isFinite(offset)) { return DEFAULT_HEIGHT_OFFSET; diff --git a/src/main/java/com/skyblockexp/ezauction/gui/menu/AuctionMenu.java b/src/main/java/com/skyblockexp/ezauction/gui/menu/AuctionMenu.java index 7ef1170..cc4aad6 100644 --- a/src/main/java/com/skyblockexp/ezauction/gui/menu/AuctionMenu.java +++ b/src/main/java/com/skyblockexp/ezauction/gui/menu/AuctionMenu.java @@ -1,4 +1,5 @@ package com.skyblockexp.ezauction.gui.menu; +import com.skyblockexp.ezauction.compat.Scheduler; import com.skyblockexp.ezauction.compat.ItemTagStorage; import com.skyblockexp.ezauction.config.AuctionMenuConfiguration; @@ -190,7 +191,7 @@ public void onAsyncPlayerChat(AsyncPlayerChatEvent event) { } event.setCancelled(true); searchHandler.removePendingSearchInput(playerId); - plugin.getServer().getScheduler().runTask(plugin, + Scheduler.run(plugin, () -> searchHandler.handleSearchInput(event.getPlayer(), prompt, event.getMessage())); } @@ -233,7 +234,7 @@ public void onInventoryClose(InventoryCloseEvent event) { if (!player.isOnline()) { return; } - plugin.getServer().getScheduler().runTask(plugin, + Scheduler.run(plugin, () -> clickHandler.reopenPreviewOrigin(player, holder)); } @@ -296,7 +297,7 @@ private BrowserMenuContext buildContext(ItemStack browserFiller, ItemStack confi } // ------------------------------------------------------------------------- - // Inner sort enums — kept for backward compatibility with any callers that + // Inner sort enums - kept for backward compatibility with any callers that // reference AuctionMenu.ListingSort / AuctionMenu.OrderSort by name. // They delegate entirely to the canonical gui.view enums. // ------------------------------------------------------------------------- diff --git a/src/main/java/com/skyblockexp/ezauction/gui/menu/AuctionOrderMenu.java b/src/main/java/com/skyblockexp/ezauction/gui/menu/AuctionOrderMenu.java index 0aefc58..d1d57ab 100644 --- a/src/main/java/com/skyblockexp/ezauction/gui/menu/AuctionOrderMenu.java +++ b/src/main/java/com/skyblockexp/ezauction/gui/menu/AuctionOrderMenu.java @@ -1,4 +1,5 @@ package com.skyblockexp.ezauction.gui.menu; +import com.skyblockexp.ezauction.compat.Scheduler; import com.skyblockexp.ezauction.service.AuctionManager; import com.skyblockexp.ezauction.model.AuctionOperationResult; @@ -704,7 +705,7 @@ public void onAsyncPlayerChat(AsyncPlayerChatEvent event) { pendingQuantityInputs.remove(playerId); OrderMenuState targetState = priceState != null ? priceState : quantityState; boolean updatingPrice = priceState != null; - plugin.getServer().getScheduler().runTask(plugin, + Scheduler.run(plugin, () -> handleChatInput(event.getPlayer(), targetState, event.getMessage(), updatingPrice)); } diff --git a/src/main/java/com/skyblockexp/ezauction/gui/menu/AuctionSellMenu.java b/src/main/java/com/skyblockexp/ezauction/gui/menu/AuctionSellMenu.java index 60c5249..6b6a8bb 100644 --- a/src/main/java/com/skyblockexp/ezauction/gui/menu/AuctionSellMenu.java +++ b/src/main/java/com/skyblockexp/ezauction/gui/menu/AuctionSellMenu.java @@ -1,4 +1,5 @@ package com.skyblockexp.ezauction.gui.menu; +import com.skyblockexp.ezauction.compat.Scheduler; import com.skyblockexp.ezauction.gui.state.SellMenuState; import com.skyblockexp.ezauction.gui.holder.SellMenuHolder; @@ -629,7 +630,7 @@ public void handleAsyncPlayerChat(AsyncPlayerChatEvent event) { event.setCancelled(true); pendingPriceInputs.remove(playerId); - plugin.getServer().getScheduler().runTask(plugin, + Scheduler.run(plugin, () -> handleCustomPriceInput(event.getPlayer(), state, event.getMessage())); } diff --git a/src/main/java/com/skyblockexp/ezauction/gui/menu/BidMenu.java b/src/main/java/com/skyblockexp/ezauction/gui/menu/BidMenu.java index 111b40d..9d29be0 100644 --- a/src/main/java/com/skyblockexp/ezauction/gui/menu/BidMenu.java +++ b/src/main/java/com/skyblockexp/ezauction/gui/menu/BidMenu.java @@ -1,4 +1,5 @@ package com.skyblockexp.ezauction.gui.menu; +import com.skyblockexp.ezauction.compat.Scheduler; import com.skyblockexp.ezauction.model.AuctionOperationResult; import com.skyblockexp.ezauction.live.LiveAuctionService; @@ -36,15 +37,15 @@ * *

Layout (row × col, 0-indexed):

*
    - *
  • Slot 13 — auctioned item (centre top area)
  • - *
  • Slot 15 — status pane (current bid, top bidder, time remaining)
  • - *
  • Slot 28 — "Minimum bid" button (lime terracotta)
  • - *
  • Slot 29 — "+25%" button (lime concrete)
  • - *
  • Slot 30 — "+50%" button (lime stained glass)
  • - *
  • Slot 31 — "×2 current bid" button (gold ingot)
  • - *
  • Slot 33 — "Custom amount" button (yellow stained glass) — prompts via chat
  • - *
  • Slot 49 — "Leave auction" button (red stained glass)
  • - *
  • All others — gray stained glass pane filler
  • + *
  • Slot 13 - auctioned item (centre top area)
  • + *
  • Slot 15 - status pane (current bid, top bidder, time remaining)
  • + *
  • Slot 28 - "Minimum bid" button (lime terracotta)
  • + *
  • Slot 29 - "+25%" button (lime concrete)
  • + *
  • Slot 30 - "+50%" button (lime stained glass)
  • + *
  • Slot 31 - "×2 current bid" button (gold ingot)
  • + *
  • Slot 33 - "Custom amount" button (yellow stained glass) - prompts via chat
  • + *
  • Slot 49 - "Leave auction" button (red stained glass)
  • + *
  • All others - gray stained glass pane filler
  • *
* *

The menu auto-refreshes every 40 ticks while open. Custom-amount chat input is handled @@ -112,7 +113,7 @@ public void open(Player player, String auctionId) { BidMenuHolder existing = openMenus.remove(player.getUniqueId()); if (existing != null) existing.cancelRefresh(); - String title = ChatColor.DARK_RED + "" + ChatColor.BOLD + "Live Auction — Bid"; + String title = ChatColor.DARK_RED + "" + ChatColor.BOLD + "Live Auction - Bid"; Inventory inv = Bukkit.createInventory(null, SIZE, title); BidMenuHolder holder = new BidMenuHolder(player.getUniqueId(), auctionId, inv); openMenus.put(player.getUniqueId(), holder); @@ -121,12 +122,12 @@ public void open(Player player, String auctionId) { player.openInventory(inv); // Schedule periodic refresh - BukkitTask task = Bukkit.getScheduler().runTaskTimer(plugin, () -> { + BukkitTask task = Scheduler.runTimer(plugin, () -> { BidMenuHolder h = openMenus.get(player.getUniqueId()); if (h == null || !h.auctionId.equals(auctionId)) return; Optional refreshed = liveAuctionService.getActiveAuction(auctionId); if (refreshed.isEmpty()) { - // Auction ended — close inventory on main thread (we're already on main thread via runTaskTimer) + // Auction ended - close inventory on main thread (we're already on main thread via runTaskTimer) openMenus.remove(player.getUniqueId()); h.cancelRefresh(); player.closeInventory(); @@ -337,7 +338,7 @@ public void onInventoryClick(InventoryClickEvent event) { player.closeInventory(); liveAuctionService.leaveAuction(player); } - default -> { /* filler — ignore */ } + default -> { /* filler - ignore */ } } } @@ -369,7 +370,7 @@ public void onAsyncPlayerChat(AsyncPlayerChatEvent event) { event.setCancelled(true); String input = event.getMessage().trim(); - Bukkit.getScheduler().runTask(plugin, () -> { + Scheduler.run(plugin, () -> { if ("cancel".equalsIgnoreCase(input)) { open(player, auctionId); return; @@ -381,7 +382,7 @@ public void onAsyncPlayerChat(AsyncPlayerChatEvent event) { } LiveAuctionState state = stateOpt.get(); if (input.isEmpty()) { - // Empty input — bid the minimum required amount. + // Empty input - bid the minimum required amount. double minimum = state.minimumNextBid(bidIncrementPct, bidIncrementFixed); placeBid(player, auctionId, minimum, state); return; diff --git a/src/main/java/com/skyblockexp/ezauction/gui/menu/ClaimMenu.java b/src/main/java/com/skyblockexp/ezauction/gui/menu/ClaimMenu.java index 50cd2aa..2f88fcb 100644 --- a/src/main/java/com/skyblockexp/ezauction/gui/menu/ClaimMenu.java +++ b/src/main/java/com/skyblockexp/ezauction/gui/menu/ClaimMenu.java @@ -1,4 +1,5 @@ package com.skyblockexp.ezauction.gui.menu; +import com.skyblockexp.ezauction.compat.Scheduler; import com.skyblockexp.ezauction.service.AuctionManager; import com.skyblockexp.ezauction.model.AuctionOperationResult; @@ -32,10 +33,10 @@ * *

Layout:

*
    - *
  • Slots 0-44 — pending items (first 45 shown; each item lore shows expiry date)
  • - *
  • Slot 45 — Back button (returns to main auction browser)
  • - *
  • Slot 49 — Claim All button
  • - *
  • Other bottom-row slots — gray pane filler
  • + *
  • Slots 0-44 - pending items (first 45 shown; each item lore shows expiry date)
  • + *
  • Slot 45 - Back button (returns to main auction browser)
  • + *
  • Slot 49 - Claim All button
  • + *
  • Other bottom-row slots - gray pane filler
  • *
*/ public final class ClaimMenu implements Listener { @@ -187,7 +188,7 @@ public void onInventoryClick(InventoryClickEvent event) { switch (slot) { case SLOT_BACK -> { player.closeInventory(); - Bukkit.getScheduler().runTask(plugin, () -> auctionMenu.openBrowser(player)); + Scheduler.run(plugin, () -> auctionMenu.openBrowser(player)); } case SLOT_CLAIM_ALL -> { AuctionOperationResult result = auctionManager.claimReturnItems(player); @@ -195,7 +196,7 @@ public void onInventoryClick(InventoryClickEvent event) { player.sendMessage(result.message()); } // Refresh or close if all claimed - Bukkit.getScheduler().runTask(plugin, () -> { + Scheduler.run(plugin, () -> { if (auctionManager.countPendingReturnItems(player.getUniqueId()) > 0) { player.closeInventory(); open(player); @@ -204,7 +205,7 @@ public void onInventoryClick(InventoryClickEvent event) { } }); } - default -> { /* item slots — no action for now */ } + default -> { /* item slots - no action for now */ } } } diff --git a/src/main/java/com/skyblockexp/ezauction/gui/menu/LiveAuctionMenu.java b/src/main/java/com/skyblockexp/ezauction/gui/menu/LiveAuctionMenu.java index e4677ad..421896f 100644 --- a/src/main/java/com/skyblockexp/ezauction/gui/menu/LiveAuctionMenu.java +++ b/src/main/java/com/skyblockexp/ezauction/gui/menu/LiveAuctionMenu.java @@ -1,4 +1,5 @@ package com.skyblockexp.ezauction.gui.menu; +import com.skyblockexp.ezauction.compat.Scheduler; import com.skyblockexp.ezauction.gui.holder.SellMenuHolder; import com.skyblockexp.ezauction.model.AuctionListing; @@ -181,7 +182,7 @@ private void populateInventory(Inventory inventory, UUID viewerUuid) { inventory.setItem(SELL_SLOT, sellBtn); } - // Cancel-queue button — only shown if viewer has a queued entry + // Cancel-queue button - only shown if viewer has a queued entry if (viewerUuid != null && liveAuctionService.hasQueuedEntry(viewerUuid)) { ItemStack cancelBtn = createButton(Material.RED_DYE, ChatColor.RED + messages.cancelButtonLabel(), List.of(ChatColor.GRAY + messages.cancelButtonLore())); @@ -398,16 +399,16 @@ public void onInventoryClick(InventoryClickEvent event) { case ACTION_REFRESH -> reopenAsync(player); case ACTION_BROWSE -> { player.closeInventory(); - plugin.getServer().getScheduler().runTask(plugin, () -> auctionMenu.openBrowser(player)); + Scheduler.run(plugin, () -> auctionMenu.openBrowser(player)); } case ACTION_BACK -> { player.closeInventory(); - plugin.getServer().getScheduler().runTask(plugin, () -> auctionMenu.openBrowser(player)); + Scheduler.run(plugin, () -> auctionMenu.openBrowser(player)); } case ACTION_SELL -> { if (player.hasPermission("ezauction.auction.live.sell") && sellMenu != null) { player.closeInventory(); - plugin.getServer().getScheduler().runTask(plugin, () -> { + Scheduler.run(plugin, () -> { LiveSellContext.mark(player.getUniqueId()); sellMenu.openSellMenu(player, Target.LIVE); }); diff --git a/src/main/java/com/skyblockexp/ezauction/gui/menu/browser/BrowserClickHandler.java b/src/main/java/com/skyblockexp/ezauction/gui/menu/browser/BrowserClickHandler.java index bbe8af0..c568ed8 100644 --- a/src/main/java/com/skyblockexp/ezauction/gui/menu/browser/BrowserClickHandler.java +++ b/src/main/java/com/skyblockexp/ezauction/gui/menu/browser/BrowserClickHandler.java @@ -1,4 +1,5 @@ package com.skyblockexp.ezauction.gui.menu.browser; +import com.skyblockexp.ezauction.compat.Scheduler; import com.skyblockexp.ezauction.gui.holder.BrowserMenuHolder; import com.skyblockexp.ezauction.gui.holder.ConfirmMenuHolder; @@ -104,7 +105,7 @@ public void handleBrowserClick(Player player, BrowserMenuHolder holder, String a player.closeInventory(); ClaimMenu claimMenu = claimMenuSupplier.get(); if (claimMenu != null) { - Bukkit.getScheduler().runTask(ctx.plugin, () -> claimMenu.open(player)); + Scheduler.run(ctx.plugin, () -> claimMenu.open(player)); } else { player.performCommand("auction claim"); } @@ -114,7 +115,7 @@ public void handleBrowserClick(Player player, BrowserMenuHolder holder, String a player.closeInventory(); LiveAuctionPickerMenu liveAuctionPickerMenu = livePickerMenuSupplier.get(); if (liveAuctionPickerMenu != null) { - Bukkit.getScheduler().runTask(ctx.plugin, () -> liveAuctionPickerMenu.open(player)); + Scheduler.run(ctx.plugin, () -> liveAuctionPickerMenu.open(player)); } else { player.performCommand("liveauction"); } @@ -497,6 +498,6 @@ private void sendMessage(Player player, String message) { } private void runSync(Runnable runnable) { - ctx.plugin.getServer().getScheduler().runTask(ctx.plugin, runnable); + Scheduler.run(ctx.plugin, runnable); } } diff --git a/src/main/java/com/skyblockexp/ezauction/hologram/AuctionHologramManager.java b/src/main/java/com/skyblockexp/ezauction/hologram/AuctionHologramManager.java index 800de5c..de52eb4 100644 --- a/src/main/java/com/skyblockexp/ezauction/hologram/AuctionHologramManager.java +++ b/src/main/java/com/skyblockexp/ezauction/hologram/AuctionHologramManager.java @@ -1,14 +1,19 @@ package com.skyblockexp.ezauction.hologram; +import com.skyblockexp.ezauction.compat.Scheduler; import com.skyblockexp.ezauction.service.AuctionManager; import com.skyblockexp.ezauction.transaction.AuctionTransactionService; import com.skyblockexp.ezauction.compat.HologramDisplay; import com.skyblockexp.ezauction.compat.HologramPlatform; import com.skyblockexp.ezauction.config.AuctionHologramConfiguration; +import java.util.ArrayList; import java.util.Collection; +import java.util.Comparator; import java.util.EnumMap; import java.util.HashMap; +import java.util.Iterator; import java.util.Locale; +import java.util.List; import java.util.Map; import java.util.Objects; import java.util.UUID; @@ -51,7 +56,7 @@ public void enable() { markerKey = "auction_hologram_type"; attachExistingDisplays(); long interval = configuration.updateIntervalTicks(); - updateTask = plugin.getServer().getScheduler().runTaskTimer(plugin, this::updateTrackedDisplays, 40L, + updateTask = Scheduler.runTimer(plugin, this::updateTrackedDisplays, 40L, Math.max(20L, interval)); } @@ -64,13 +69,13 @@ public void disable() { markerKey = null; } - public boolean ensureHologram(Location location, AuctionHologramType type) { + public Location ensureHologram(Location location, AuctionHologramType type) { if (location == null || type == null) { - return false; + return null; } World world = location.getWorld(); if (world == null) { - return false; + return null; } Location spawnLocation = normalizeLocation(location.clone()); spawnLocation.add(0.0D, configuration.heightOffset(), 0.0D); @@ -79,7 +84,7 @@ public boolean ensureHologram(Location location, AuctionHologramType type) { if (existing != null) { registerDisplay(existing, type); updateDisplay(existing, type); - return true; + return spawnLocation; } int chunkX = spawnLocation.getBlockX() >> 4; @@ -87,56 +92,118 @@ public boolean ensureHologram(Location location, AuctionHologramType type) { if (!world.isChunkLoaded(chunkX, chunkZ)) { world.loadChunk(chunkX, chunkZ); if (!world.isChunkLoaded(chunkX, chunkZ)) { - return false; + return null; } } // Enforce max hologram count if (trackedDisplays.size() >= configuration.maxHolograms()) { plugin.getLogger().warning("Max auction hologram count reached (" + configuration.maxHolograms() + "). Placement denied."); - return false; + return null; } HologramDisplay created = hologramPlatform.spawn(spawnLocation); if (created == null) { - return false; + return null; } hologramPlatform.configureBase(created); markDisplay(created, type); registerDisplay(created, type); updateDisplay(created, type); - return true; + return spawnLocation; } - public boolean removeNearest(Location location) { - if (location == null) { - return false; + /** + * Finds EzAuction holograms near the given origin, sorted by ascending distance. + * + * @param origin search centre; use the player's eye location so the vertical + * placement offset is covered + * @param radius spherical search radius in blocks + * @param includeUntagged when {@code true}, also returns untagged {@code TextDisplay} entities + * @return matches sorted nearest-first; never {@code null} + */ + public List findNearby(Location origin, double radius, boolean includeUntagged) { + if (origin == null) { + return List.of(); } - World world = location.getWorld(); + World world = origin.getWorld(); if (world == null) { - return false; + return List.of(); } - Location searchLocation = normalizeLocation(location.clone()); - double radius = configuration.searchRadius(); - Collection nearby = hologramPlatform.findNearby(searchLocation, radius); - HologramDisplay closest = null; - double closestDistance = Double.MAX_VALUE; - for (HologramDisplay display : nearby) { + double effectiveRadius = radius > 0.0D ? radius : configuration.removalRadius(); + List matches = new ArrayList<>(); + for (HologramDisplay display : hologramPlatform.findNearby(origin, effectiveRadius)) { AuctionHologramType type = readType(display); - if (type == null) { + if (type == null && !includeUntagged) { continue; } - double distance = display.location().distanceSquared(searchLocation); - if (distance < closestDistance) { - closestDistance = distance; - closest = display; + double distance = distanceTo(display, origin); + if (distance >= 0.0D) { + matches.add(new HologramMatch(display, type, distance)); } } - if (closest == null) { + matches.sort(Comparator.comparingDouble(HologramMatch::distance)); + return matches; + } + + public double removalRadius() { + return configuration.removalRadius(); + } + + public double maxRemovalRadius() { + return configuration.maxRemovalRadius(); + } + + public boolean allowUntaggedRemoval() { + return configuration.allowUntaggedRemoval(); + } + + /** + * Removes a single matched hologram. Untracks it by unique id then removes the entity. + * + * @return {@code true} if the display was valid and removed + */ + public boolean remove(HologramMatch match) { + if (match == null || match.display() == null) { return false; } - trackedDisplays.remove(closest.uniqueId()); - closest.remove(); - return true; + UUID id = match.display().uniqueId(); + if (id != null) { + trackedDisplays.remove(id); + } + if (match.display().isValid()) { + match.display().remove(); + return true; + } + return false; + } + + /** + * Removes all matched holograms. + * + * @return the number of displays actually removed + */ + public int removeAll(Collection matches) { + if (matches == null || matches.isEmpty()) { + return 0; + } + int removed = 0; + for (HologramMatch match : matches) { + if (remove(match)) { + removed++; + } + } + return removed; + } + + private double distanceTo(HologramDisplay display, Location origin) { + Location location = display.location(); + if (location == null || origin.getWorld() == null || location.getWorld() == null) { + return -1.0D; + } + if (!location.getWorld().equals(origin.getWorld())) { + return -1.0D; + } + return location.distance(origin); } public Map trackedCounts() { @@ -181,10 +248,12 @@ private void updateTrackedDisplays() { // Update only a subset per tick for scalability int batchSize = Math.max(1, trackedDisplays.size() / 10); int count = 0; - for (Map.Entry entry : trackedDisplays.entrySet()) { + Iterator> iterator = trackedDisplays.entrySet().iterator(); + while (iterator.hasNext()) { + Map.Entry entry = iterator.next(); HologramDisplay display = entry.getValue().display(); if (!isDisplayValid(display)) { - trackedDisplays.remove(entry.getKey()); + iterator.remove(); continue; } try { @@ -287,4 +356,14 @@ private Location normalizeLocation(Location location) { private record HologramEntry(HologramDisplay display, AuctionHologramType type) { } + + /** + * A single hologram discovered by a removal or listing search. + * + * @param display the underlying display entity + * @param type the EzAuction hologram type, or {@code null} for an untagged display + * @param distance distance in blocks from the search origin + */ + public record HologramMatch(HologramDisplay display, AuctionHologramType type, double distance) { + } } diff --git a/src/main/java/com/skyblockexp/ezauction/integration/DiscordWebhookNotifier.java b/src/main/java/com/skyblockexp/ezauction/integration/DiscordWebhookNotifier.java index 77b8583..db4d1e1 100644 --- a/src/main/java/com/skyblockexp/ezauction/integration/DiscordWebhookNotifier.java +++ b/src/main/java/com/skyblockexp/ezauction/integration/DiscordWebhookNotifier.java @@ -128,7 +128,7 @@ public void sendAuctionStart(String item, String quantity, String seller, String dispatch(json); } else { dispatch(buildPlainPayload( - "**New Auction Listed** — " + item + " (" + quantity + ") by **" + seller + "**New Auction Listed** - " + item + " (" + quantity + ") by **" + seller + "** for " + price + " | Duration: " + duration + " | ID: " + listingId )); } @@ -148,7 +148,7 @@ public void sendAuctionEnd(String item, String seller, String price, String list dispatch(json); } else { dispatch(buildPlainPayload( - "**Auction Ended** — " + item + " by **" + seller + "** sold for " + price + "**Auction Ended** - " + item + " by **" + seller + "** sold for " + price + " | ID: " + listingId )); } @@ -168,7 +168,7 @@ public void sendAuctionBid(String item, String bidder, String amount, String lis dispatch(json); } else { dispatch(buildPlainPayload( - "**New Bid** — **" + bidder + "** bid " + amount + " on " + item + "**New Bid** - **" + bidder + "** bid " + amount + " on " + item + " | ID: " + listingId )); } @@ -187,7 +187,7 @@ public void sendAuctionCancel(String item, String seller, String listingId) { dispatch(json); } else { dispatch(buildPlainPayload( - "**Auction Cancelled** — " + item + " by **" + seller + "** | ID: " + listingId + "**Auction Cancelled** - " + item + " by **" + seller + "** | ID: " + listingId )); } } @@ -205,7 +205,7 @@ public void sendAuctionExpiry(String item, String seller, String listingId) { dispatch(json); } else { dispatch(buildPlainPayload( - "**Auction Expired** — " + item + " by **" + seller + "** (no buyer) | ID: " + listingId + "**Auction Expired** - " + item + " by **" + seller + "** (no buyer) | ID: " + listingId )); } } @@ -214,7 +214,7 @@ public void sendAuctionExpiry(String item, String seller, String listingId) { public void sendTestMessage(String initiatedBy) { if (useEmbeds) { String json = buildEmbedPayload( - "EzAuction — Webhook Test", + "EzAuction - Webhook Test", "This test message was triggered by **" + escapeJson(initiatedBy) + "**. " + "If you can see this, your webhook is configured correctly!", 5793266, // #588FEF soft blue @@ -224,7 +224,7 @@ public void sendTestMessage(String initiatedBy) { dispatch(json); } else { dispatch(buildPlainPayload( - "EzAuction webhook test initiated by " + initiatedBy + " — webhook is working." + "EzAuction webhook test initiated by " + initiatedBy + " - webhook is working." )); } } @@ -240,7 +240,7 @@ private int colorFor(String event) { /** Returns a JSON fragment representing a single embed field. */ private static String field(String name, String value, boolean inline) { return "{\"name\":\"" + escapeJson(name) + "\"," - + "\"value\":\"" + escapeJson(value != null ? value : "—") + "\"," + + "\"value\":\"" + escapeJson(value != null ? value : "-") + "\"," + "\"inline\":" + inline + "}"; } @@ -331,7 +331,7 @@ private void dispatch(String jsonPayload) { } else if (response.statusCode() < 200 || response.statusCode() >= 300) { plugin.getLogger().warning( "[EzAuction] Webhook returned HTTP " + response.statusCode() - + " — check that the webhook URL is valid and the bot has" + + " - check that the webhook URL is valid and the bot has" + " permission to post in the channel."); } }); diff --git a/src/main/java/com/skyblockexp/ezauction/integration/EzCountdownIntegration.java b/src/main/java/com/skyblockexp/ezauction/integration/EzCountdownIntegration.java index cfd5e8d..974f075 100644 --- a/src/main/java/com/skyblockexp/ezauction/integration/EzCountdownIntegration.java +++ b/src/main/java/com/skyblockexp/ezauction/integration/EzCountdownIntegration.java @@ -13,8 +13,8 @@ * *

All public methods guard against EzCountdown being absent or not yet initialised. * Any {@link Throwable} thrown by the underlying API is caught and treated as "not available". - * EzCountdown types are only referenced inside method bodies — never in field declarations or - * method signatures — so this class loads safely even when EzCountdown is not installed.

+ * EzCountdown types are only referenced inside method bodies - never in field declarations or + * method signatures - so this class loads safely even when EzCountdown is not installed.

*/ public final class EzCountdownIntegration { @@ -34,7 +34,7 @@ public EzCountdownIntegration(JavaPlugin plugin, boolean configEnabled, boolean this.autoDetect = autoDetect; } - /** Convenience constructor — auto-detect defaults to {@code false} for backward compatibility. */ + /** Convenience constructor - auto-detect defaults to {@code false} for backward compatibility. */ public EzCountdownIntegration(JavaPlugin plugin, boolean configEnabled) { this(plugin, configEnabled, false); } diff --git a/src/main/java/com/skyblockexp/ezauction/live/LiveAuctionService.java b/src/main/java/com/skyblockexp/ezauction/live/LiveAuctionService.java index 96fb857..a07b9d5 100644 --- a/src/main/java/com/skyblockexp/ezauction/live/LiveAuctionService.java +++ b/src/main/java/com/skyblockexp/ezauction/live/LiveAuctionService.java @@ -1,4 +1,5 @@ package com.skyblockexp.ezauction.live; +import com.skyblockexp.ezauction.compat.Scheduler; import com.skyblockexp.ezauction.model.AuctionListing; import com.skyblockexp.ezauction.model.AuctionOperationResult; @@ -109,8 +110,7 @@ public void enable() { if (!configuration.enabled()) return; if (!configuration.queueEnabled() || !configuration.displayInChat()) return; long interval = Math.max(1L, configuration.announcementIntervalTicks()); - announcementTask = plugin.getServer().getScheduler() - .runTaskTimer(plugin, this::announceNext, interval, interval); + announcementTask = Scheduler.runTimer(plugin, this::announceNext, interval, interval); enabled = true; } @@ -312,8 +312,7 @@ public AuctionOperationResult bid(Player player, String auctionId, double amount state.extendExpiry(newExpiry); BukkitTask oldTask = endTasks.remove(state.id()); if (oldTask != null) oldTask.cancel(); - BukkitTask newEndTask = plugin.getServer().getScheduler() - .runTaskLater(plugin, () -> endAuction(state), minFinish * 20L); + BukkitTask newEndTask = Scheduler.runLater(plugin, () -> endAuction(state), minFinish * 20L); endTasks.put(state.id(), newEndTask); } @@ -379,7 +378,7 @@ public void leaveAuction(Player player) { /** * Registers a callback that is invoked (on the main thread) with the listing ID whenever a - * live auction fully resolves — whether sold or returned unsold. Use this to remove the + * live auction fully resolves - whether sold or returned unsold. Use this to remove the * corresponding entry from the normal listing store and persist the change. */ public void setListingResolutionCallback(Consumer callback) { @@ -430,8 +429,7 @@ private void startActiveAuction(LiveAuctionEntry entry) { } // Schedule end task - BukkitTask endTask = plugin.getServer().getScheduler() - .runTaskLater(plugin, () -> endAuction(state), durationSeconds * 20L); + BukkitTask endTask = Scheduler.runLater(plugin, () -> endAuction(state), durationSeconds * 20L); endTasks.put(state.id(), endTask); // Schedule chat reminders @@ -446,7 +444,7 @@ private void scheduleReminders(LiveAuctionState state, long durationSeconds) { for (long secondsMark : cfg.chatReminderAtSeconds()) { if (secondsMark > 0 && secondsMark < durationSeconds) { long delayTicks = (durationSeconds - secondsMark) * 20L; - BukkitTask t = plugin.getServer().getScheduler().runTaskLater(plugin, () -> { + BukkitTask t = Scheduler.runLater(plugin, () -> { if (activeAuctions.containsKey(state.id())) { sendReminderToAll(state, secondsMark); } @@ -459,7 +457,7 @@ private void scheduleReminders(LiveAuctionState state, long durationSeconds) { long intervalSeconds = cfg.chatReminderIntervalSeconds(); if (intervalSeconds > 0) { long intervalTicks = intervalSeconds * 20L; - BukkitTask t = plugin.getServer().getScheduler().runTaskTimer(plugin, () -> { + BukkitTask t = Scheduler.runTimer(plugin, () -> { if (activeAuctions.containsKey(state.id())) { sendReminderToAll(state, state.secondsRemaining()); } @@ -501,11 +499,11 @@ private void endAuction(LiveAuctionState state) { } if (!creditResult.success()) { - // Economy rejected the deposit — without an explicit refund the winner's escrowed + // Economy rejected the deposit - without an explicit refund the winner's escrowed // funds would be permanently lost from the economy. plugin.getLogger().warning("Live auction " + state.id() + ": failed to credit seller " + state.entry().sellerId() - + " — refunding winner. Reason: " + creditResult.message()); + + " - refunding winner. Reason: " + creditResult.message()); transactionService.refundOrderBuyer(winner, winAmount); // Return the item to the seller since the sale could not complete ItemStack returnItem = state.listing().item(); @@ -544,7 +542,7 @@ private void endAuction(LiveAuctionState state) { broadcastToAll(endMsg); } } else { - // No bids — return item to the seller + // No bids - return item to the seller ItemStack item = state.listing().item(); if (item != null) { UUID sellerId = state.entry().sellerId(); diff --git a/src/main/java/com/skyblockexp/ezauction/live/model/LiveAuctionState.java b/src/main/java/com/skyblockexp/ezauction/live/model/LiveAuctionState.java index bad21ed..6e078c1 100644 --- a/src/main/java/com/skyblockexp/ezauction/live/model/LiveAuctionState.java +++ b/src/main/java/com/skyblockexp/ezauction/live/model/LiveAuctionState.java @@ -135,7 +135,7 @@ public boolean isExpired() { * @return minimum next bid (always strictly greater than currentBid when any increment is set) */ public double minimumNextBid(double pct, double fixed) { - // First bid only needs to meet the starting price — no increment required. + // First bid only needs to meet the starting price - no increment required. if (!hasBids()) return currentBid; double base = currentBid; double byPercent = pct > 0 ? base * (1.0 + pct / 100.0) : base; diff --git a/src/main/java/com/skyblockexp/ezauction/live/preferences/PlayerLivePreferencesManager.java b/src/main/java/com/skyblockexp/ezauction/live/preferences/PlayerLivePreferencesManager.java index 3106e77..13b6ffd 100644 --- a/src/main/java/com/skyblockexp/ezauction/live/preferences/PlayerLivePreferencesManager.java +++ b/src/main/java/com/skyblockexp/ezauction/live/preferences/PlayerLivePreferencesManager.java @@ -1,4 +1,5 @@ package com.skyblockexp.ezauction.live.preferences; +import com.skyblockexp.ezauction.compat.Scheduler; import org.bukkit.plugin.java.JavaPlugin; @@ -41,7 +42,7 @@ public void loadAll() { /** * Returns the preferences for the given player, loading from storage if not yet cached. - * Never returns null — falls back to {@link PlayerLivePreferences#defaults(UUID)}. + * Never returns null - falls back to {@link PlayerLivePreferences#defaults(UUID)}. */ public PlayerLivePreferences getPreferences(UUID playerId) { return cache.computeIfAbsent(playerId, id -> { @@ -61,7 +62,7 @@ public PlayerLivePreferences getPreferences(UUID playerId) { */ public void savePreferences(PlayerLivePreferences preferences) { cache.put(preferences.playerId(), preferences); - plugin.getServer().getScheduler().runTaskAsynchronously(plugin, () -> { + Scheduler.runAsync(plugin, () -> { try { storage.save(preferences); } catch (Exception ex) { diff --git a/src/main/java/com/skyblockexp/ezauction/service/AuctionExpiryService.java b/src/main/java/com/skyblockexp/ezauction/service/AuctionExpiryService.java index 7b304b2..9de6211 100644 --- a/src/main/java/com/skyblockexp/ezauction/service/AuctionExpiryService.java +++ b/src/main/java/com/skyblockexp/ezauction/service/AuctionExpiryService.java @@ -1,5 +1,6 @@ package com.skyblockexp.ezauction.service; +import com.skyblockexp.ezauction.compat.Scheduler; import com.skyblockexp.ezauction.transaction.AuctionTransactionService; import com.skyblockexp.ezauction.model.AuctionListing; @@ -72,7 +73,7 @@ private void startExpiryTask() { if (expiryTask != null) { expiryTask.cancel(); } - expiryTask = plugin.getServer().getScheduler().runTaskTimer(plugin, this::purgeExpiredEntries, 20L, 20L * 60L); + expiryTask = Scheduler.runTimer(plugin, this::purgeExpiredEntries, 20L, 20L * 60L); } public void purgeExpiredEntries() { diff --git a/src/main/java/com/skyblockexp/ezauction/service/AuctionListingService.java b/src/main/java/com/skyblockexp/ezauction/service/AuctionListingService.java index 9b53473..bc78594 100644 --- a/src/main/java/com/skyblockexp/ezauction/service/AuctionListingService.java +++ b/src/main/java/com/skyblockexp/ezauction/service/AuctionListingService.java @@ -283,7 +283,7 @@ public AuctionOperationResult purchaseListing(Player buyer, String listingId) { /** * Places a bid on the given listing. Currently this method only fires an event and - * notifies listeners — it does not mutate the listing state or handle payments. + * notifies listeners - it does not mutate the listing state or handle payments. * This provides a safe hook for implementing a full bid workflow later. */ public AuctionOperationResult placeBid(Player bidder, String listingId, double amount) { diff --git a/src/main/java/com/skyblockexp/ezauction/storage/mysql/MysqlAuctionListingStorage.java b/src/main/java/com/skyblockexp/ezauction/storage/mysql/MysqlAuctionListingStorage.java index 560581e..52c4169 100644 --- a/src/main/java/com/skyblockexp/ezauction/storage/mysql/MysqlAuctionListingStorage.java +++ b/src/main/java/com/skyblockexp/ezauction/storage/mysql/MysqlAuctionListingStorage.java @@ -78,7 +78,7 @@ public boolean initialize() { try { statement.executeUpdate("ALTER TABLE `" + listingsTable + "` ADD COLUMN team_uuid CHAR(36) NULL"); } catch (SQLException ignored) { - // Column already exists — safe to ignore + // Column already exists - safe to ignore } statement.executeUpdate("CREATE TABLE IF NOT EXISTS `" + ordersTable + "` (" + "id VARCHAR(36) NOT NULL PRIMARY KEY," diff --git a/src/main/java/com/skyblockexp/ezauction/transaction/AuctionTransactionService.java b/src/main/java/com/skyblockexp/ezauction/transaction/AuctionTransactionService.java index 0f3a8d9..1617b4d 100644 --- a/src/main/java/com/skyblockexp/ezauction/transaction/AuctionTransactionService.java +++ b/src/main/java/com/skyblockexp/ezauction/transaction/AuctionTransactionService.java @@ -25,7 +25,7 @@ public class AuctionTransactionService { private final Economy economy; private final AuctionBackendMessages.EconomyMessages messages; private final AuctionBackendMessages.FallbackMessages fallbackMessages; - /** Optional currency display template — e.g. {@code "{amount} coins"}. Null/empty = use Vault/default. */ + /** Optional currency display template - e.g. {@code "{amount} coins"}. Null/empty = use Vault/default. */ private volatile String currencyTemplate = null; /** Optional number format for the {@code {amount}} placeholder inside the template. */ private volatile java.text.DecimalFormat decimalFormat = null; @@ -192,7 +192,7 @@ public void refundListingDeposit(UUID sellerId, double deposit) { /** * Configures an optional currency display override. When set, the {@code template} is used - * instead of Vault's {@code economy.format()} — replace {@code {amount}} with the formatted number. + * instead of Vault's {@code economy.format()} - replace {@code {amount}} with the formatted number. * * @param template e.g. {@code "{amount} coins"} or {@code "$&a{amount}"}. Null/blank = disable override. * @param numberPattern {@link java.text.DecimalFormat} pattern for {@code {amount}}, e.g. {@code "#,##0.00"}. diff --git a/src/main/java/com/skyblockexp/ezauction/update/SpigotUpdateChecker.java b/src/main/java/com/skyblockexp/ezauction/update/SpigotUpdateChecker.java index 3f316f4..7ea15e2 100644 --- a/src/main/java/com/skyblockexp/ezauction/update/SpigotUpdateChecker.java +++ b/src/main/java/com/skyblockexp/ezauction/update/SpigotUpdateChecker.java @@ -1,4 +1,5 @@ package com.skyblockexp.ezauction.update; +import com.skyblockexp.ezauction.compat.Scheduler; import java.io.BufferedReader; import java.io.InputStreamReader; @@ -18,7 +19,7 @@ public SpigotUpdateChecker(JavaPlugin plugin, int resourceId) { } public void checkForUpdates() { - plugin.getServer().getScheduler().runTaskAsynchronously(plugin, () -> { + Scheduler.runAsync(plugin, () -> { try { String latestVersion = fetchLatestVersion(); if (latestVersion == null || latestVersion.isBlank()) { diff --git a/src/main/java/com/skyblockexp/ezauction/util/DateUtil.java b/src/main/java/com/skyblockexp/ezauction/util/DateUtil.java index 9ceee5a..b4aa1f3 100644 --- a/src/main/java/com/skyblockexp/ezauction/util/DateUtil.java +++ b/src/main/java/com/skyblockexp/ezauction/util/DateUtil.java @@ -25,7 +25,7 @@ public static void setDateFormat(String pattern) { try { dateFormatter = DateTimeFormatter.ofPattern(pattern); } catch (IllegalArgumentException ignored) { - // Invalid pattern — keep the existing formatter + // Invalid pattern - keep the existing formatter } } diff --git a/src/main/resources/auction.yml b/src/main/resources/auction.yml index 4acf462..0452843 100644 --- a/src/main/resources/auction.yml +++ b/src/main/resources/auction.yml @@ -27,6 +27,12 @@ holograms: search-radius: 2.5 # Vertical offset applied when spawning holograms above the target block. height-offset: 1.75 + # Default radius (in blocks) searched by /auctionhologram list|clear|clearall. + removal-radius: 10.0 + # Hard cap on the radius a player may pass to the removal commands. + max-removal-radius: 64.0 + # Allow removing untagged TextDisplay entities via the --untagged flag. + allow-untagged-removal: true live-auctions: enabled: true # When enabled, new listings are pushed into a live announcement queue that can be consumed sequentially. diff --git a/src/main/resources/discord.yml b/src/main/resources/discord.yml index ab9c545..ffc4145 100644 --- a/src/main/resources/discord.yml +++ b/src/main/resources/discord.yml @@ -48,7 +48,7 @@ message-format: # Both can be active at the same time. webhook: enabled: false - # Full Discord webhook URL. Keep this secret — treat it like a password. + # Full Discord webhook URL. Keep this secret - treat it like a password. url: null # Override the display name shown in Discord (null = use webhook's configured name). username: "EzAuction" diff --git a/src/main/resources/messages/messages_en.yml b/src/main/resources/messages/messages_en.yml index f01242a..c0a6ae6 100644 --- a/src/main/resources/messages/messages_en.yml +++ b/src/main/resources/messages/messages_en.yml @@ -59,12 +59,24 @@ holograms: unknown-type: "&cUnknown hologram type '{type}'." placement-range: "&cLook at a block within {range} blocks to place the hologram." placement-failed: "&cFailed to place the auction hologram. Ensure the chunk is loaded." - placement-success: "&aPlaced an auction hologram for &6{display}&a." + placement-success: "&aPlaced &6{display} &ahologram at &e{x},{y},{z} &a(&e{world}&a)." cleared: "&eRemoved the nearest auction hologram." none-found: "&cNo auction hologram found nearby." - usage: "&eUsage: /{label} " + usage: "&eUsage: /{label} " types-heading: "&6Available hologram types:" types-entry: "&7- &6{name}&7 ({display})" + list-heading: "&6Nearby auction holograms:" + list-entry: "&7#{index} &6{type} &7@ &e{world} {x},{y},{z} &7(&e{distance}&7 blocks)" + list-empty: "&cNo auction holograms found nearby." + cleared-detailed: "&eRemoved &6{type} &eauction hologram &7(&e{distance}&7 blocks away)." + clearall-confirm: "&eFound &6{count} &eauction hologram(s) within &6{radius} &eblocks of {label}. Run the command again within 15s to remove them." + clearall-done: "&aRemoved &6{count} &aauction hologram(s)." + clearall-expired: "&cConfirmation window expired. Run &6/list &cagain, then re-run your clear command." + invalid-radius: "&cInvalid radius '&6{input}&c'. Use a number up to &6{max}&c blocks." + untagged-disabled: "&cUntagged removal is disabled in the configuration (&6allow-untagged-removal&c)." + unknown-type-label: "unknown" + max-holograms-reached: "&cMaximum of &6{max} &cauction holograms reached. Placement denied." + untagged-clear-confirm: "&eFound &6{count} &euntagged display(s) within &6{radius} &eblocks (may include other plugins' holograms). Run &6/auctionhologram clear --untagged &eagain within 15s to remove the nearest." backend: fallback: unknown-item: "Unknown item" diff --git a/src/main/resources/messages/messages_es.yml b/src/main/resources/messages/messages_es.yml index dc1b262..c440602 100644 --- a/src/main/resources/messages/messages_es.yml +++ b/src/main/resources/messages/messages_es.yml @@ -59,12 +59,24 @@ holograms: unknown-type: "&cTipo de holograma desconocido '{type}'." placement-range: "&cMira un bloque dentro de {range} bloques para colocar el holograma." placement-failed: "&cNo se pudo colocar el holograma de subasta. Asegúrate de que el chunk esté cargado." - placement-success: "&aSe colocó un holograma de subasta para &6{display}&a." + placement-success: "&aSe colocó el holograma &6{display} &aen &e{x},{y},{z} &a(&e{world}&a)." cleared: "&eSe eliminó el holograma de subasta más cercano." none-found: "&cNo se encontró ningún holograma de subasta cercano." - usage: "&eUso: /{label} " + usage: "&eUso: /{label} " types-heading: "&6Tipos de holograma disponibles:" types-entry: "&7- &6{name}&7 ({display})" + list-heading: "&6Hologramas de subasta cercanos:" + list-entry: "&7#{index} &6{type} &7@ &e{world} {x},{y},{z} &7(&e{distance}&7 bloques)" + list-empty: "&cNo se encontraron hologramas de subasta cercanos." + cleared-detailed: "&eSe eliminó el holograma de subasta &6{type} &7(&e{distance}&7 bloques de distancia)." + clearall-confirm: "&eSe encontraron &6{count} &ehologramas de subasta dentro de &6{radius} &ebloques de {label}. Ejecuta el comando de nuevo en 15s para eliminarlos." + clearall-done: "&aSe eliminaron &6{count} &ahologramas de subasta." + clearall-expired: "&cLa ventana de confirmación expiró. Ejecuta &6/list &cde nuevo y luego repite el comando de eliminación." + invalid-radius: "&cRadio inválido '&6{input}&c'. Usa un número hasta &6{max}&c bloques." + untagged-disabled: "&cLa eliminación sin etiqueta está desactivada en la configuración (&6allow-untagged-removal&c)." + unknown-type-label: "desconocido" + max-holograms-reached: "&cSe alcanzó el máximo de &6{max} &chologramas de subasta. Colocación denegada." + untagged-clear-confirm: "&eSe encontraron &6{count} &edisplay(s) sin etiqueta dentro de &6{radius} &ebloques (pueden incluir hologramas de otros plugins). Ejecuta &6/auctionhologram clear --untagged &edenuevo en 15s para eliminar el más cercano." backend: fallback: unknown-item: "Artículo desconocido" diff --git a/src/main/resources/messages/messages_nl.yml b/src/main/resources/messages/messages_nl.yml index 66cd0f7..a6182a1 100644 --- a/src/main/resources/messages/messages_nl.yml +++ b/src/main/resources/messages/messages_nl.yml @@ -59,12 +59,24 @@ holograms: unknown-type: "&cOnbekend hologramtype '{type}'." placement-range: "&cKijk naar een blok binnen {range} blokken om het hologram te plaatsen." placement-failed: "&cHet veilinghologram kon niet worden geplaatst. Zorg dat de chunk is geladen." - placement-success: "&aVeilinghologram geplaatst voor &6{display}&a." + placement-success: "&aVeilinghologram &6{display} &ageplaatst op &e{x},{y},{z} &a(&e{world}&a)." cleared: "&eHet dichtstbijzijnde veilinghologram is verwijderd." none-found: "&cGeen veilinghologram in de buurt gevonden." - usage: "&eGebruik: /{label} " + usage: "&eGebruik: /{label} " types-heading: "&6Beschikbare hologramtypen:" types-entry: "&7- &6{name}&7 ({display})" + list-heading: "&6Veilinghologrammen in de buurt:" + list-entry: "&7#{index} &6{type} &7@ &e{world} {x},{y},{z} &7(&e{distance}&7 blokken)" + list-empty: "&cGeen veilinghologrammen in de buurt gevonden." + cleared-detailed: "&eVerwijderd &6{type} &eveilinghologram &7(&e{distance}&7 blokken verderop)." + clearall-confirm: "&eGevonden &6{count} &eveilinghologram(men) binnen &6{radius} &eblokken van {label}. Voer het commando binnen 15s opnieuw uit om ze te verwijderen." + clearall-done: "&aVerwijderd &6{count} &aveilinghologram(men)." + clearall-expired: "&cBevestigingsvenster verlopen. Voer &6/list &copnieuw uit en doe daarna je verwijder-commando opnieuw." + invalid-radius: "&cOngeldige straal '&6{input}&c'. Gebruik een getal tot &6{max}&c blokken." + untagged-disabled: "&cVerwijderen zonder label is uitgeschakeld in de configuratie (&6allow-untagged-removal&c)." + unknown-type-label: "onbekend" + max-holograms-reached: "&cMaximum van &6{max} &cveilinghologrammen bereikt. Plaatsen geweigerd." + untagged-clear-confirm: "&eGevonden &6{count} &elabelvrije display(s) binnen &6{radius} &eblokken (kunnen hologrammen van andere plugins bevatten). Voer &6/auctionhologram clear --untagged &ebinnen 15s opnieuw uit om de dichtstbijzijnde te verwijderen." backend: fallback: unknown-item: "Onbekend item" diff --git a/src/main/resources/messages/messages_zh.yml b/src/main/resources/messages/messages_zh.yml index 762a21b..333ace9 100644 --- a/src/main/resources/messages/messages_zh.yml +++ b/src/main/resources/messages/messages_zh.yml @@ -59,12 +59,24 @@ holograms: unknown-type: "&c未知的全息影像类型 '{type}'。" placement-range: "&c请看向 {range} 格范围内的方块以放置全息影像。" placement-failed: "&c放置拍卖全息影像失败。请确认区块已加载。" - placement-success: "&a已为 &6{display}&a 放置拍卖全息影像。" + placement-success: "&a已在 &e{x},{y},{z} &a(&e{world}&a) 放置 &6{display}&a 全息影像。" cleared: "&e已移除最近的拍卖全息影像。" none-found: "&c附近没有找到拍卖全息影像。" - usage: "&e用法:/{label} " + usage: "&e用法:/{label} " types-heading: "&6可用的全息影像类型:" types-entry: "&7- &6{name}&7 ({display})" + list-heading: "&6附近的拍卖全息影像:" + list-entry: "&7#{index} &6{type} &7@ &e{world} {x},{y},{z} &7(&e{distance}&7 格)" + list-empty: "&c附近没有找到拍卖全息影像。" + cleared-detailed: "&e已移除 &6{type} &e拍卖全息影像 &7(距离 &e{distance}&7 格)。" + clearall-confirm: "&e在 &6{label} &e周围 &6{radius} &e格内找到 &6{count} &e个拍卖全息影像。请在 15 秒内再次运行命令以移除它们。" + clearall-done: "&a已移除 &6{count} &a个拍卖全息影像。" + clearall-expired: "&c确认窗口已过期。请重新运行 &6/list &c,然后再次执行你的清除命令。" + invalid-radius: "&c无效的半径 '&6{input}&c'。请使用最多 &6{max}&c 格的数字。" + untagged-disabled: "&c未在配置中启用无标记移除(&6allow-untagged-removal&c)。" + unknown-type-label: "未知" + max-holograms-reached: "&c已达到最多 &6{max} &c个拍卖全息影像。放置被拒绝。" + untagged-clear-confirm: "&e在 &6{radius} &e格内找到 &6{count} &e个无标记显示(可能包含其他插件的全息影像)。请在 15 秒内再次运行 &6/auctionhologram clear --untagged &e以移除最近的显示。" backend: fallback: unknown-item: "未知物品" diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 5085cd8..521bae2 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -28,7 +28,9 @@ commands: description: Place or remove EzAuction holographic displays. usage: | / - Place an auction hologram showing the selected data. - / clear - Remove the closest auction hologram. + / list [radius] [--untagged] - List nearby auction holograms. + / clear [radius] [--untagged] - Remove the closest auction hologram. + / clearall [radius] [--untagged] - Remove all nearby holograms (requires confirmation). permission: ezauction.hologram permission-message: You do not have permission to manage auction holograms. liveauction: @@ -57,7 +59,7 @@ commands: permission: ezauction.auction.order permission-message: You do not have permission to use this command. bid: - description: Interact with active live auctions — place bids, join/leave, and manage notifications. + description: Interact with active live auctions - place bids, join/leave, and manage notifications. usage: | / - Open the live auction picker or show your active auction. / - Place a bid on your current live auction. diff --git a/src/test/java/com/skyblockexp/ezauction/command/AuctionHologramCommandTest.java b/src/test/java/com/skyblockexp/ezauction/command/AuctionHologramCommandTest.java new file mode 100644 index 0000000..8a60d74 --- /dev/null +++ b/src/test/java/com/skyblockexp/ezauction/command/AuctionHologramCommandTest.java @@ -0,0 +1,276 @@ +package com.skyblockexp.ezauction.command; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.skyblockexp.ezauction.compat.HologramDisplay; +import com.skyblockexp.ezauction.compat.TextDisplayHologramPlatform; +import com.skyblockexp.ezauction.config.AuctionCommandMessageConfiguration.HologramMessages; +import com.skyblockexp.ezauction.config.AuctionHologramConfiguration; +import com.skyblockexp.ezauction.hologram.AuctionHologramManager; +import com.skyblockexp.ezauction.hologram.AuctionHologramType; +import com.skyblockexp.ezauction.service.AuctionManager; +import com.skyblockexp.ezauction.transaction.AuctionTransactionService; +import java.util.List; +import java.util.Locale; +import org.bukkit.Location; +import org.bukkit.command.Command; +import org.bukkit.command.CommandSender; +import org.bukkit.plugin.java.JavaPlugin; +import org.mockbukkit.mockbukkit.command.MessageTarget; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockbukkit.mockbukkit.MockBukkit; +import org.mockbukkit.mockbukkit.ServerMock; +import org.mockbukkit.mockbukkit.entity.PlayerMock; + +class AuctionHologramCommandTest { + + private static final String MARKER_KEY = "auction_hologram_type"; + + private ServerMock server; + private HologramMessages messages; + private TextDisplayHologramPlatform platform; + private JavaPlugin plugin; + + @BeforeEach + void setUp() { + server = MockBukkit.getOrCreateMock(); + plugin = MockBukkit.createMockPlugin("EzAuctionTest"); + platform = new TextDisplayHologramPlatform(plugin); + messages = HologramMessages.defaults(); + } + + private AuctionHologramManager newManager() { + AuctionManager auctionManager = mock(AuctionManager.class); + AuctionTransactionService transactionService = mock(AuctionTransactionService.class); + AuctionHologramConfiguration configuration = AuctionHologramConfiguration.defaults(); + AuctionHologramManager manager = new AuctionHologramManager(plugin, auctionManager, + transactionService, configuration, platform); + manager.enable(); + return manager; + } + + private Command command() { + return mock(Command.class); + } + + @Test + void nonPlayerSenderGetsPlayersOnly() { + AuctionHologramCommand cmd = new AuctionHologramCommand(plugin, newManager(), messages); + CommandSender console = server.getConsoleSender(); + cmd.onCommand(console, command(), "auctionhologram", new String[]{"list"}); + assertTrue(((MessageTarget) console).nextMessage().contains("Only players may manage auction holograms")); + } + + @Test + void nullManagerReportsDisabled() { + AuctionHologramCommand cmd = new AuctionHologramCommand(plugin, null, messages); + PlayerMock player = server.addPlayer(); + cmd.onCommand(player, command(), "auctionhologram", new String[]{"list"}); + assertTrue(player.nextMessage().contains("Auction holograms are disabled")); + } + + @Test + void noArgsShowsUsage() { + AuctionHologramCommand cmd = new AuctionHologramCommand(plugin, newManager(), messages); + PlayerMock player = server.addPlayer(); + cmd.onCommand(player, command(), "auctionhologram", new String[0]); + assertTrue(player.nextMessage().contains("Usage: /auctionhologram")); + } + + @Test + void unknownTypeShowsUnknownTypeAndTypeList() { + AuctionHologramCommand cmd = new AuctionHologramCommand(plugin, newManager(), messages); + PlayerMock player = server.addPlayer(); + cmd.onCommand(player, command(), "auctionhologram", new String[]{"bogus"}); + String first = player.nextMessage(); + assertTrue(first.contains("Unknown hologram type 'bogus'"), "got: " + first); + assertTrue(player.nextMessage().contains("Available hologram types:")); + } + + @Test + void clearDelegatesToHandler() { + AuctionHologramManager manager = newManager(); + AuctionHologramCommand cmd = new AuctionHologramCommand(plugin, manager, messages); + PlayerMock player = server.addPlayer(); + Location origin = player.getEyeLocation(); + HologramDisplay display = platform.spawn(origin.clone().add(1.0D, 0.0D, 0.0D)); + platform.setMarker(display, MARKER_KEY, "ACTIVE_LISTINGS"); + + cmd.onCommand(player, command(), "auctionhologram", new String[]{"clear"}); + assertTrue(player.nextMessage().contains("Removed")); + assertEquals(0, manager.findNearby(origin, 10.0D, false).size()); + } + + @Test + void listDelegatesToHandler() { + AuctionHologramManager manager = newManager(); + AuctionHologramCommand cmd = new AuctionHologramCommand(plugin, manager, messages); + PlayerMock player = server.addPlayer(); + Location origin = player.getEyeLocation(); + HologramDisplay display = platform.spawn(origin.clone().add(1.0D, 0.0D, 0.0D)); + platform.setMarker(display, MARKER_KEY, "ACTIVE_LISTINGS"); + + cmd.onCommand(player, command(), "auctionhologram", new String[]{"list"}); + assertTrue(player.nextMessage().contains("Nearby auction holograms")); + assertTrue(player.nextMessage().contains("active_listings")); + } + + @Test + void clearallDelegatesToHandlerTwoStep() { + AuctionHologramManager manager = newManager(); + AuctionHologramCommand cmd = new AuctionHologramCommand(plugin, manager, messages); + PlayerMock player = server.addPlayer(); + Location origin = player.getEyeLocation(); + HologramDisplay display = platform.spawn(origin.clone().add(1.0D, 0.0D, 0.0D)); + platform.setMarker(display, MARKER_KEY, "ACTIVE_LISTINGS"); + + cmd.onCommand(player, command(), "auctionhologram", new String[]{"clearall"}); + assertTrue(player.nextMessage().contains("within")); + cmd.onCommand(player, command(), "auctionhologram", new String[]{"clearall"}); + assertTrue(player.nextMessage().contains("Removed")); + assertEquals(0, manager.findNearby(origin, 10.0D, false).size()); + } + + @Test + void placementSuccessReportsCoordinates() { + AuctionHologramManager manager = newManager(); + AuctionHologramCommand cmd = new AuctionHologramCommand(plugin, manager, messages); + PlayerMock player = server.addPlayer(); + Location origin = player.getLocation(); + + cmd.onCommand(player, command(), "auctionhologram", new String[]{"listings"}); + String message = player.nextMessage(); + assertTrue(message.contains("Placed"), "got: " + message); + assertTrue(message.contains(player.getWorld().getName()), "got: " + message); + assertFalse(manager.findNearby(origin, 8.0D, false).isEmpty()); + } + + @Test + void placementFailedReportsFailure() { + AuctionHologramManager manager = mock(AuctionHologramManager.class); + when(manager.ensureHologram(any(Location.class), any(AuctionHologramType.class))).thenReturn(null); + AuctionHologramCommand cmd = new AuctionHologramCommand(plugin, manager, messages); + PlayerMock player = server.addPlayer(); + + cmd.onCommand(player, command(), "auctionhologram", new String[]{"listings"}); + assertTrue(player.nextMessage().contains("Failed to place the auction hologram")); + } + + @Test + void clearWithRadiusArgUsesRestLoop() { + AuctionHologramManager manager = newManager(); + AuctionHologramCommand cmd = new AuctionHologramCommand(plugin, manager, messages); + PlayerMock player = server.addPlayer(); + Location origin = player.getEyeLocation(); + HologramDisplay display = platform.spawn(origin.clone().add(1.0D, 0.0D, 0.0D)); + platform.setMarker(display, MARKER_KEY, "ACTIVE_LISTINGS"); + + cmd.onCommand(player, command(), "auctionhologram", new String[]{"clear", "5"}); + assertTrue(player.nextMessage().contains("Removed")); + assertEquals(0, manager.findNearby(origin, 10.0D, false).size()); + } + + @Test + void listWithRadiusArgUsesRestLoop() { + AuctionHologramManager manager = newManager(); + AuctionHologramCommand cmd = new AuctionHologramCommand(plugin, manager, messages); + PlayerMock player = server.addPlayer(); + Location origin = player.getEyeLocation(); + HologramDisplay display = platform.spawn(origin.clone().add(1.0D, 0.0D, 0.0D)); + platform.setMarker(display, MARKER_KEY, "ACTIVE_LISTINGS"); + + cmd.onCommand(player, command(), "auctionhologram", new String[]{"list", "5"}); + assertTrue(player.nextMessage().contains("Nearby auction holograms")); + assertTrue(player.nextMessage().contains("active_listings")); + } + + @Test + void translateConvertsAmpersandToColorCode() { + String translated = AuctionHologramCommand.translate("&cOnly players may manage"); + assertTrue(translated.contains("§c")); + assertTrue(translated.contains("Only players may manage")); + } + + @Test + void translateReturnsNullForNullInput() { + assertEquals(null, AuctionHologramCommand.translate(null)); + } + + @Test + void resolvePlacementLocationFallsBackToPlayerWhenNoTarget() { + AuctionHologramCommand cmd = new AuctionHologramCommand(plugin, newManager(), messages); + PlayerMock player = server.addPlayer(); + Location placement = AuctionHologramCommand.resolvePlacementLocation(player); + assertNotNull(placement); + assertEquals(player.getLocation().getY() + 1.0D, placement.getY(), 0.001D); + } + + @Test + void tabCompleteAtRootListsSubcommandsAndTypes() { + AuctionHologramCommand cmd = new AuctionHologramCommand(plugin, newManager(), messages); + PlayerMock player = server.addPlayer(); + List suggestions = cmd.onTabComplete(player, command(), "auctionhologram", new String[0]); + assertTrue(suggestions.contains("list")); + assertTrue(suggestions.contains("clear")); + assertTrue(suggestions.contains("clearall")); + assertTrue(suggestions.contains("remove")); + for (AuctionHologramType type : AuctionHologramType.values()) { + assertTrue(suggestions.contains(type.name().toLowerCase(Locale.ENGLISH)), "missing type " + type); + } + } + + @Test + void tabCompletePrefixFilters() { + AuctionHologramCommand cmd = new AuctionHologramCommand(plugin, newManager(), messages); + PlayerMock player = server.addPlayer(); + List suggestions = cmd.onTabComplete(player, command(), "auctionhologram", new String[]{"c"}); + assertTrue(suggestions.contains("clear")); + assertTrue(suggestions.contains("clearall")); + assertFalse(suggestions.contains("list")); + } + + @Test + void tabCompleteRadiusSuggestionsForRemovalSubcommands() { + AuctionHologramCommand cmd = new AuctionHologramCommand(plugin, newManager(), messages); + PlayerMock player = server.addPlayer(); + List suggestions = cmd.onTabComplete(player, command(), "auctionhologram", + new String[]{"clear", "--"}); + assertTrue(suggestions.contains("--untagged")); + assertFalse(suggestions.contains("10")); + assertFalse(suggestions.contains("list")); + } + + @Test + void tabCompleteUntaggedFlagAtThirdPosition() { + AuctionHologramCommand cmd = new AuctionHologramCommand(plugin, newManager(), messages); + PlayerMock player = server.addPlayer(); + List suggestions = cmd.onTabComplete(player, command(), "auctionhologram", + new String[]{"clear", "10", "--un"}); + assertTrue(suggestions.contains("--untagged")); + } + + @Test + void tabCompleteUnknownSubcommandYieldsNoSuggestions() { + AuctionHologramCommand cmd = new AuctionHologramCommand(plugin, newManager(), messages); + PlayerMock player = server.addPlayer(); + List suggestions = cmd.onTabComplete(player, command(), "auctionhologram", + new String[]{"bogus", "1"}); + assertTrue(suggestions.isEmpty()); + } + + @Test + void tabCompleteBeyondThirdPositionYieldsNoSuggestions() { + AuctionHologramCommand cmd = new AuctionHologramCommand(plugin, newManager(), messages); + PlayerMock player = server.addPlayer(); + List suggestions = cmd.onTabComplete(player, command(), "auctionhologram", + new String[]{"clear", "1", "2", "3"}); + assertTrue(suggestions.isEmpty()); + } +} diff --git a/src/test/java/com/skyblockexp/ezauction/command/hologram/HologramRemovalHandlerTest.java b/src/test/java/com/skyblockexp/ezauction/command/hologram/HologramRemovalHandlerTest.java new file mode 100644 index 0000000..4145a52 --- /dev/null +++ b/src/test/java/com/skyblockexp/ezauction/command/hologram/HologramRemovalHandlerTest.java @@ -0,0 +1,348 @@ +package com.skyblockexp.ezauction.command.hologram; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; + +import com.skyblockexp.ezauction.compat.HologramDisplay; +import com.skyblockexp.ezauction.compat.HologramPlatform; +import com.skyblockexp.ezauction.compat.TextDisplayHologramPlatform; +import com.skyblockexp.ezauction.config.AuctionCommandMessageConfiguration.HologramMessages; +import com.skyblockexp.ezauction.config.AuctionHologramConfiguration; +import com.skyblockexp.ezauction.hologram.AuctionHologramManager; +import com.skyblockexp.ezauction.hologram.AuctionHologramType; +import com.skyblockexp.ezauction.service.AuctionManager; +import com.skyblockexp.ezauction.transaction.AuctionTransactionService; +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import org.bukkit.Location; +import org.bukkit.plugin.java.JavaPlugin; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockbukkit.mockbukkit.MockBukkit; +import org.mockbukkit.mockbukkit.ServerMock; +import org.mockbukkit.mockbukkit.entity.PlayerMock; + +class HologramRemovalHandlerTest { + + private static final String MARKER_KEY = "auction_hologram_type"; + + private ServerMock server; + private HologramRemovalHandler handler; + private AuctionHologramManager manager; + private HologramPlatform platform; + private HologramMessages messages; + + @BeforeEach + void setUp() { + server = MockBukkit.getOrCreateMock(); + JavaPlugin plugin = MockBukkit.createMockPlugin("EzAuctionTest"); + platform = new TextDisplayHologramPlatform(plugin); + AuctionManager auctionManager = mock(AuctionManager.class); + AuctionTransactionService transactionService = mock(AuctionTransactionService.class); + AuctionHologramConfiguration configuration = AuctionHologramConfiguration.defaults(); + manager = new AuctionHologramManager(plugin, auctionManager, transactionService, configuration, platform); + manager.enable(); + messages = HologramMessages.defaults(); + handler = new HologramRemovalHandler(plugin, manager, messages); + } + + @Test + void clearRemovesNearestAndSendsDetailedMessage() { + PlayerMock player = server.addPlayer(); + Location origin = player.getEyeLocation(); + HologramDisplay display = platform.spawn(origin.clone().add(1.0D, 0.0D, 0.0D)); + platform.setMarker(display, MARKER_KEY, "ACTIVE_LISTINGS"); + + handler.clear(player, List.of()); + + assertTrue(player.nextMessage().contains("Removed")); + assertEquals(0, manager.findNearby(origin, 10.0D, false).size()); + } + + @Test + void clearWithNothingInRangeSendsNoneFound() { + PlayerMock player = server.addPlayer(); + handler.clear(player, List.of()); + assertTrue(player.nextMessage().contains("No auction hologram found nearby") || player.nextMessage().toLowerCase().contains("none")); + } + + @Test + void clearUntaggedRequiresSecondInvocationAndRemovesNearest() { + PlayerMock player = server.addPlayer(); + Location origin = player.getEyeLocation(); + HologramDisplay near = platform.spawn(origin.clone().add(1.0D, 0.0D, 0.0D)); + HologramDisplay far = platform.spawn(origin.clone().add(3.0D, 0.0D, 0.0D)); + + handler.clear(player, List.of("--untagged")); + String first = player.nextMessage(); + assertTrue(first.contains("untagged") || first.contains("display(s)"), "first call should confirm, not remove: " + first); + assertEquals(2, manager.findNearby(origin, 10.0D, true).size()); + + handler.clear(player, List.of("--untagged")); + String second = player.nextMessage(); + assertTrue(second.contains("Removed"), "second call should remove the nearest: " + second); + List remaining = manager.findNearby(origin, 10.0D, true); + assertEquals(1, remaining.size()); + assertEquals(far.uniqueId(), remaining.get(0).display().uniqueId()); + } + + @Test + void clearUntaggedWithNothingInRangeSendsNoneFound() { + PlayerMock player = server.addPlayer(); + handler.clear(player, List.of("--untagged")); + assertTrue(player.nextMessage().contains("No auction hologram") || player.nextMessage().toLowerCase().contains("none")); + } + + @Test + void clearallRequiresSecondInvocation() { + PlayerMock player = server.addPlayer(); + Location origin = player.getEyeLocation(); + HologramDisplay display = platform.spawn(origin.clone().add(1.0D, 0.0D, 0.0D)); + platform.setMarker(display, MARKER_KEY, "ACTIVE_LISTINGS"); + + handler.clearAll(player, List.of()); + String first = player.nextMessage(); + assertTrue(first.contains("within"), "first call should report the count, not remove: " + first); + + handler.clearAll(player, List.of()); + String second = player.nextMessage(); + assertTrue(second.contains("Removed"), "second call should remove: " + second); + assertEquals(0, manager.findNearby(origin, 10.0D, false).size()); + } + + @Test + void invalidRadiusProducesInvalidRadiusMessage() { + PlayerMock player = server.addPlayer(); + handler.clear(player, List.of("abc")); + assertTrue(player.nextMessage().contains("Usage"), + "non-numeric radius must reject with usage, not unknown-type"); + } + + @Test + void listShowsEntriesThenEmptyAfterRemoval() { + PlayerMock player = server.addPlayer(); + Location origin = player.getEyeLocation(); + HologramDisplay display = platform.spawn(origin.clone().add(1.0D, 0.0D, 0.0D)); + platform.setMarker(display, MARKER_KEY, "ACTIVE_LISTINGS"); + + handler.list(player, List.of()); + String heading = player.nextMessage(); + assertTrue(heading.contains("Nearby auction holograms")); + String entry = player.nextMessage(); + assertTrue(entry.contains("active_listings")); + } + + @Test + void listWithNoDisplaysSendsEmptyMessage() { + PlayerMock player = server.addPlayer(); + handler.list(player, List.of()); + assertTrue(player.nextMessage().contains("No auction holograms found nearby")); + } + + @Test + void listUntaggedShowsUnknownTypeLabel() { + PlayerMock player = server.addPlayer(); + Location origin = player.getEyeLocation(); + HologramDisplay untagged = platform.spawn(origin.clone().add(1.0D, 0.0D, 0.0D)); + + handler.list(player, List.of("--untagged")); + player.nextMessage(); // heading + String entry = player.nextMessage(); + assertTrue(entry.toLowerCase().contains("unknown"), "untagged display should use the unknown label: " + entry); + assertTrue(untagged.isValid()); + } + + @Test + void clearWithRadiusAboveMaximumIsRejected() { + PlayerMock player = server.addPlayer(); + handler.clear(player, List.of("999")); + assertTrue(player.nextMessage().contains("Invalid radius")); + } + + @Test + void clearWithNonPositiveRadiusIsRejected() { + PlayerMock player = server.addPlayer(); + handler.clear(player, List.of("-3")); + assertTrue(player.nextMessage().contains("Invalid radius")); + } + + @Test + void clearUntaggedRejectedWhenDisabledInConfiguration() { + JavaPlugin plugin = MockBukkit.createMockPlugin("EzAuctionTest"); + AuctionManager auctionManager = mock(AuctionManager.class); + AuctionTransactionService transactionService = mock(AuctionTransactionService.class); + AuctionHologramConfiguration configuration = new AuctionHologramConfiguration( + true, 100L, 2.5D, 1.75D, 100, true, false, "ezauction.hologram.view", + false, 32.0D, 10.0D, 64.0D, false); + AuctionHologramManager disabledManager = new AuctionHologramManager(plugin, auctionManager, + transactionService, configuration, platform); + disabledManager.enable(); + HologramRemovalHandler disabledHandler = new HologramRemovalHandler(plugin, disabledManager, messages); + + PlayerMock player = server.addPlayer(); + disabledHandler.clear(player, List.of("--untagged")); + assertTrue(player.nextMessage().contains("Untagged removal is disabled")); + } + + @Test + void onPlayerQuitClearsPendingConfirmation() { + PlayerMock player = server.addPlayer(); + Location origin = player.getEyeLocation(); + platform.spawn(origin.clone().add(1.0D, 0.0D, 0.0D)); + platform.spawn(origin.clone().add(2.0D, 0.0D, 0.0D)); + + handler.clear(player, List.of("--untagged")); + player.nextMessage(); // confirm + handler.onPlayerQuit(player); + + // second invocation after quit must re-arm, not remove + handler.clear(player, List.of("--untagged")); + String reArmed = player.nextMessage(); + assertTrue(reArmed.contains("untagged display(s)"), "quit should clear pending, forcing re-arm: " + reArmed); + assertEquals(2, manager.findNearby(origin, 10.0D, true).size()); + } + + @Test + void expireStaleRemovesExpiredConfirmation() throws Exception { + PlayerMock player = server.addPlayer(); + Location origin = player.getEyeLocation(); + HologramDisplay display = platform.spawn(origin.clone().add(1.0D, 0.0D, 0.0D)); + List matches = manager.findNearby(origin, 10.0D, true); + injectStalePending(player, matches); + assertEquals(1, pendingSize()); + + handler.expireStale(); + assertEquals(0, pendingSize()); + assertTrue(display.isValid()); + } + + @Test + void expiredClearUntaggedConfirmationReArms() throws Exception { + PlayerMock player = server.addPlayer(); + Location origin = player.getEyeLocation(); + platform.spawn(origin.clone().add(1.0D, 0.0D, 0.0D)); + platform.spawn(origin.clone().add(2.0D, 0.0D, 0.0D)); + List matches = manager.findNearby(origin, 10.0D, true); + injectStalePending(player, matches); + + handler.clear(player, List.of("--untagged")); + String reArmed = player.nextMessage(); + assertTrue(reArmed.contains("untagged display(s)"), "expired pending must re-arm, not remove: " + reArmed); + assertEquals(2, manager.findNearby(origin, 10.0D, true).size()); + } + + @Test + void expiredClearAllConfirmationReArms() throws Exception { + PlayerMock player = server.addPlayer(); + Location origin = player.getEyeLocation(); + HologramDisplay display = platform.spawn(origin.clone().add(1.0D, 0.0D, 0.0D)); + platform.setMarker(display, MARKER_KEY, "ACTIVE_LISTINGS"); + List matches = manager.findNearby(origin, 10.0D, false); + injectStalePending(player, matches); + + handler.clearAll(player, List.of()); + String reArmed = player.nextMessage(); + assertTrue(reArmed.contains("auction hologram(s) within"), "expired pending must re-arm, not remove: " + reArmed); + assertEquals(1, manager.findNearby(origin, 10.0D, false).size()); + } + + @Test + void clearUntaggedNonExpiredPendingEmptyMatchesSendsNoneFound() throws Exception { + PlayerMock player = server.addPlayer(); + List matches = manager.findNearby(player.getEyeLocation(), 10.0D, true); + injectNonExpiredPending(player, matches); + + handler.clear(player, List.of("--untagged")); + assertTrue(player.nextMessage().contains("No auction hologram found nearby")); + } + + @Test + void clearUntaggedRemovesUntaggedNearestUsesUnknownLabel() throws Exception { + PlayerMock player = server.addPlayer(); + Location origin = player.getEyeLocation(); + HologramDisplay untaggedNear = platform.spawn(origin.clone().add(1.0D, 0.0D, 0.0D)); + HologramDisplay taggedFar = platform.spawn(origin.clone().add(3.0D, 0.0D, 0.0D)); + platform.setMarker(taggedFar, MARKER_KEY, "ACTIVE_LISTINGS"); + + handler.clear(player, List.of("--untagged")); + player.nextMessage(); // confirm + + handler.clear(player, List.of("--untagged")); + String removed = player.nextMessage(); + assertTrue(removed.contains("Removed")); + assertTrue(removed.toLowerCase().contains("unknown"), + "nearest untagged should use the unknown label: " + removed); + List remaining = manager.findNearby(origin, 10.0D, true); + assertEquals(1, remaining.size()); + assertEquals(taggedFar.uniqueId(), remaining.get(0).display().uniqueId()); + } + + @Test + void clearUntaggedSecondStepTaggedNearestUsesTypeName() throws Exception { + PlayerMock player = server.addPlayer(); + Location origin = player.getEyeLocation(); + HologramDisplay taggedNear = platform.spawn(origin.clone().add(1.0D, 0.0D, 0.0D)); + platform.setMarker(taggedNear, MARKER_KEY, "ACTIVE_LISTINGS"); + HologramDisplay taggedFar = platform.spawn(origin.clone().add(3.0D, 0.0D, 0.0D)); + platform.setMarker(taggedFar, MARKER_KEY, "ACTIVE_ORDERS"); + + handler.clear(player, List.of("--untagged")); + player.nextMessage(); // confirm + + handler.clear(player, List.of("--untagged")); + String removed = player.nextMessage(); + assertTrue(removed.contains("Removed")); + assertTrue(removed.contains("active_listings"), + "nearest tagged should use the type name: " + removed); + List remaining = manager.findNearby(origin, 10.0D, true); + assertEquals(1, remaining.size()); + assertEquals(taggedFar.uniqueId(), remaining.get(0).display().uniqueId()); + } + + @SuppressWarnings("unchecked") + private int pendingSize() throws Exception { + Field field = HologramRemovalHandler.class.getDeclaredField("pendingConfirmations"); + field.setAccessible(true); + Map map = (Map) field.get(handler); + return map.size(); + } + + private void injectStalePending(PlayerMock player, List matches) throws Exception { + Field field = HologramRemovalHandler.class.getDeclaredField("pendingConfirmations"); + field.setAccessible(true); + Map map = (Map) field.get(handler); + + Class pendingClass = Class.forName( + "com.skyblockexp.ezauction.command.hologram.HologramRemovalHandler$PendingClearAll"); + Constructor ctor = pendingClass.getDeclaredConstructor(List.class); + ctor.setAccessible(true); + Object pending = ctor.newInstance(matches); + Field createdAt = pendingClass.getDeclaredField("createdAt"); + createdAt.setAccessible(true); + createdAt.set(pending, 0L); + map.put(player.getUniqueId(), pending); + assertFalse(((boolean) pendingClass.getDeclaredMethod("isExpired").invoke(pending))); + } + + private void injectNonExpiredPending(PlayerMock player, List matches) throws Exception { + Field field = HologramRemovalHandler.class.getDeclaredField("pendingConfirmations"); + field.setAccessible(true); + Map map = (Map) field.get(handler); + + Class pendingClass = Class.forName( + "com.skyblockexp.ezauction.command.hologram.HologramRemovalHandler$PendingClearAll"); + Constructor ctor = pendingClass.getDeclaredConstructor(List.class); + ctor.setAccessible(true); + Object pending = ctor.newInstance(matches); + Field createdAt = pendingClass.getDeclaredField("createdAt"); + createdAt.setAccessible(true); + createdAt.set(pending, System.currentTimeMillis()); + map.put(player.getUniqueId(), pending); + assertFalse(((boolean) pendingClass.getDeclaredMethod("isExpired").invoke(pending))); + } +} diff --git a/src/test/java/com/skyblockexp/ezauction/compat/TestPlugin.java b/src/test/java/com/skyblockexp/ezauction/compat/TestPlugin.java new file mode 100644 index 0000000..4f498c6 --- /dev/null +++ b/src/test/java/com/skyblockexp/ezauction/compat/TestPlugin.java @@ -0,0 +1,10 @@ +package com.skyblockexp.ezauction.compat; + +import org.bukkit.plugin.java.JavaPlugin; + +/** + * Minimal {@link JavaPlugin} used by compatibility tests that need a real plugin instance for + * {@link org.bukkit.NamespacedKey} construction. + */ +public final class TestPlugin extends JavaPlugin { +} diff --git a/src/test/java/com/skyblockexp/ezauction/compat/TextDisplayHologramPlatformTest.java b/src/test/java/com/skyblockexp/ezauction/compat/TextDisplayHologramPlatformTest.java new file mode 100644 index 0000000..07a6637 --- /dev/null +++ b/src/test/java/com/skyblockexp/ezauction/compat/TextDisplayHologramPlatformTest.java @@ -0,0 +1,110 @@ +package com.skyblockexp.ezauction.compat; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; + +import com.skyblockexp.ezauction.compat.HologramDisplay; +import org.bukkit.Location; +import org.bukkit.entity.TextDisplay; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockbukkit.mockbukkit.MockBukkit; +import org.mockbukkit.mockbukkit.ServerMock; +import org.mockbukkit.mockbukkit.entity.PlayerMock; + +class TextDisplayHologramPlatformTest { + + private ServerMock server; + private TextDisplayHologramPlatform platform; + + @BeforeEach + void setUp() { + server = MockBukkit.getOrCreateMock(); + platform = new TextDisplayHologramPlatform(MockBukkit.createMockPlugin("EzAuctionTest")); + } + + @Test + void isSupportedReturnsTrue() { + assertTrue(platform.isSupported()); + } + + @Test + void markerRoundTripReturnsTypeName() { + PlayerMock player = server.addPlayer(); + Location location = player.getLocation(); + HologramDisplay display = platform.spawn(location); + assertNotNull(display, "spawn should produce a TextDisplay-backed hologram"); + + platform.setMarker(display, "auction_hologram_type", "ACTIVE_LISTINGS"); + String marker = platform.getMarker(display, "auction_hologram_type"); + + assertEquals("ACTIVE_LISTINGS", marker, "marker must round-trip through the PDC"); + } + + @Test + void untaggedDisplayHasNoMarker() { + PlayerMock player = server.addPlayer(); + HologramDisplay display = platform.spawn(player.getLocation()); + assertNull(platform.getMarker(display, "auction_hologram_type")); + } + + @Test + void findNearbyReturnsSpawnedDisplaySortedByDistance() { + PlayerMock player = server.addPlayer(); + Location origin = player.getEyeLocation(); + HologramDisplay near = platform.spawn(origin.clone().add(1.0D, 0.0D, 0.0D)); + platform.setMarker(near, "auction_hologram_type", "ACTIVE_LISTINGS"); + platform.spawn(origin.clone().add(5.0D, 0.0D, 0.0D)); + + var nearby = platform.findNearby(origin, 10.0D); + assertEquals(2, nearby.size()); + assertTrue(nearby.stream().anyMatch(d -> d.uniqueId().equals(near.uniqueId()))); + } + + @Test + void isDisplayEntityTrueForTextDisplayFalseOtherwise() { + PlayerMock player = server.addPlayer(); + TextDisplay textDisplay = player.getWorld().spawn(player.getLocation(), TextDisplay.class); + assertTrue(platform.isDisplayEntity(textDisplay)); + assertFalse(platform.isDisplayEntity(player)); + } + + @Test + void wrapReturnsWrapperForTextDisplayAndNullOtherwise() { + PlayerMock player = server.addPlayer(); + TextDisplay textDisplay = player.getWorld().spawn(player.getLocation(), TextDisplay.class); + assertNotNull(platform.wrap(textDisplay)); + assertNull(platform.wrap(player)); + } + + @Test + void configureBaseNullSafeForNonWrapper() { + HologramDisplay notWrapper = mock(HologramDisplay.class); + platform.configureBase(notWrapper); + platform.configureBase(null); + } + + @Test + void setMarkerNullSafeForNonWrapper() { + HologramDisplay notWrapper = mock(HologramDisplay.class); + platform.setMarker(notWrapper, "auction_hologram_type", "ACTIVE_LISTINGS"); + platform.setMarker(notWrapper, null, "ACTIVE_LISTINGS"); + platform.setMarker(notWrapper, "auction_hologram_type", null); + } + + @Test + void getMarkerNullSafeForNonWrapper() { + HologramDisplay notWrapper = mock(HologramDisplay.class); + assertNull(platform.getMarker(notWrapper, "auction_hologram_type")); + assertNull(platform.getMarker(notWrapper, null)); + } + + @Test + void spawnNullLocationReturnsNull() { + assertNull(platform.spawn(null)); + } +} diff --git a/src/test/java/com/skyblockexp/ezauction/config/AuctionConfigurationLoaderTest.java b/src/test/java/com/skyblockexp/ezauction/config/AuctionConfigurationLoaderTest.java index 4757845..bdd183d 100644 --- a/src/test/java/com/skyblockexp/ezauction/config/AuctionConfigurationLoaderTest.java +++ b/src/test/java/com/skyblockexp/ezauction/config/AuctionConfigurationLoaderTest.java @@ -50,7 +50,7 @@ void loadUsesLocalizedCommandMessagesWhenAvailable() throws IOException { AuctionMessageConfiguration guiMessages = configuration.messageConfiguration(); assertEquals("&cSolo los jugadores pueden usar la casa de subastas.", general.consoleOnly()); - assertEquals("&eUso: /{label} ", holograms.usage()); + assertEquals("&eUso: /{label} ", holograms.usage()); assertEquals("&cNo se pudo encontrar ese listado.", guiMessages.browser().listingNotFound()); } @@ -71,4 +71,28 @@ void backendMessagesFallbackToEnglishWhenMissing() throws IOException { assertEquals(AuctionBackendMessages.defaults().listing().creation().playersOnly(), backendMessages.listing().creation().playersOnly()); } + + @Test + void hologramRemovalKeysAreParsed() throws IOException { + JavaPlugin plugin = mock(JavaPlugin.class); + when(plugin.getDataFolder()).thenReturn(tempDir.toFile()); + when(plugin.getLogger()).thenReturn(Logger.getLogger("AuctionConfigurationLoaderTest")); + + String hologramsYml = """ + holograms: + enabled: true + removal-radius: 12.0 + max-removal-radius: 50.0 + allow-untagged-removal: false + """; + Files.createDirectories(tempDir.resolve("messages")); + Files.writeString(tempDir.resolve("auction.yml"), hologramsYml); + + AuctionConfiguration configuration = AuctionConfigurationLoader.load(plugin); + AuctionHologramConfiguration holograms = configuration.hologramConfiguration(); + + assertEquals(12.0D, holograms.removalRadius()); + assertEquals(50.0D, holograms.maxRemovalRadius()); + org.junit.jupiter.api.Assertions.assertFalse(holograms.allowUntaggedRemoval()); + } } diff --git a/src/test/java/com/skyblockexp/ezauction/feature/AllCommandsFeatureTest.java b/src/test/java/com/skyblockexp/ezauction/feature/AllCommandsFeatureTest.java index dc54d78..e256b4f 100644 --- a/src/test/java/com/skyblockexp/ezauction/feature/AllCommandsFeatureTest.java +++ b/src/test/java/com/skyblockexp/ezauction/feature/AllCommandsFeatureTest.java @@ -48,7 +48,7 @@ import com.skyblockexp.ezauction.transaction.AuctionTransactionService; /** - * Feature-level tests covering every command and subcommand — both happy-path + * Feature-level tests covering every command and subcommand - both happy-path * (succeeds as expected) and key sad-path (permission denied, bad input) cases. * *

Each nested class corresponds to one command. Tests use MockBukkit for a @@ -118,7 +118,7 @@ private PlayerMock opPlayer(String name) { private PlayerMock noPermPlayer(String name) { PlayerMock p = server.addPlayer(name); - // Non-op player — only gets default permissions from MockBukkit + // Non-op player - only gets default permissions from MockBukkit return p; } @@ -127,7 +127,7 @@ private Command mockCommand() { } // ====================================================================== - // /auction (no args) — open browser GUI + // /auction (no args) - open browser GUI // ====================================================================== @Nested class AuctionNoArgs { @@ -605,7 +605,7 @@ void reload_withPermission_sendsSuccessMessage() { boolean handled = cmd.onCommand(sender, mockCommand(), "auction", new String[]{"reload"}); assertTrue(handled); - // Registry is null in test context — message will contain "not initialized" or similar + // Registry is null in test context - message will contain "not initialized" or similar verify(sender).sendMessage(any(String.class)); } @@ -623,7 +623,7 @@ void reload_withoutPermission_sendsPermissionDeniedMessage() { } // ====================================================================== - // /auction — usage + // /auction - usage // ====================================================================== @Nested class AuctionUnknownSubcommand { diff --git a/src/test/java/com/skyblockexp/ezauction/hologram/AuctionHologramManagerRemovalTest.java b/src/test/java/com/skyblockexp/ezauction/hologram/AuctionHologramManagerRemovalTest.java new file mode 100644 index 0000000..b8029ac --- /dev/null +++ b/src/test/java/com/skyblockexp/ezauction/hologram/AuctionHologramManagerRemovalTest.java @@ -0,0 +1,291 @@ +package com.skyblockexp.ezauction.hologram; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; + +import com.skyblockexp.ezauction.compat.HologramDisplay; +import com.skyblockexp.ezauction.compat.HologramPlatform; +import com.skyblockexp.ezauction.compat.TextDisplayHologramPlatform; +import com.skyblockexp.ezauction.config.AuctionHologramConfiguration; +import com.skyblockexp.ezauction.service.AuctionManager; +import com.skyblockexp.ezauction.transaction.AuctionTransactionService; +import java.util.List; +import java.util.Map; +import org.bukkit.Location; +import org.bukkit.entity.Entity; +import org.bukkit.entity.TextDisplay; +import org.bukkit.plugin.java.JavaPlugin; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockbukkit.mockbukkit.MockBukkit; +import org.mockbukkit.mockbukkit.ServerMock; +import org.mockbukkit.mockbukkit.entity.PlayerMock; +import org.mockbukkit.mockbukkit.scheduler.BukkitSchedulerMock; + +class AuctionHologramManagerRemovalTest { + + private static final String MARKER_KEY = "auction_hologram_type"; + + private ServerMock server; + private AuctionHologramManager manager; + private HologramPlatform platform; + + @BeforeEach + void setUp() { + server = MockBukkit.getOrCreateMock(); + AuctionManager auctionManager = mock(AuctionManager.class); + AuctionTransactionService transactionService = mock(AuctionTransactionService.class); + JavaPlugin plugin = MockBukkit.createMockPlugin("EzAuctionTest"); + platform = new TextDisplayHologramPlatform(plugin); + AuctionHologramConfiguration configuration = AuctionHologramConfiguration.defaults(); + manager = new AuctionHologramManager(plugin, auctionManager, transactionService, configuration, platform); + manager.enable(); + } + + @Test + void findNearbyReturnsTaggedSortedAndExcludesUntaggedByDefault() { + PlayerMock player = server.addPlayer(); + Location origin = player.getEyeLocation(); + HologramDisplay taggedNear = platform.spawn(origin.clone().add(1.0D, 0.0D, 0.0D)); + platform.setMarker(taggedNear, MARKER_KEY, AuctionHologramType.ACTIVE_LISTINGS.name()); + HologramDisplay untagged = platform.spawn(origin.clone().add(2.0D, 0.0D, 0.0D)); + + List matches = manager.findNearby(origin, 10.0D, false); + assertEquals(1, matches.size()); + assertEquals(AuctionHologramType.ACTIVE_LISTINGS, matches.get(0).type()); + + List withUntagged = manager.findNearby(origin, 10.0D, true); + assertEquals(2, withUntagged.size()); + assertTrue(withUntagged.stream().anyMatch(m -> m.type() == null + && m.display().uniqueId().equals(untagged.uniqueId()))); + } + + @Test + void removeRemovesNearestAndUntracks() { + PlayerMock player = server.addPlayer(); + Location origin = player.getEyeLocation(); + HologramDisplay display = platform.spawn(origin.clone().add(1.0D, 0.0D, 0.0D)); + platform.setMarker(display, MARKER_KEY, AuctionHologramType.ACTIVE_ORDERS.name()); + + List matches = manager.findNearby(origin, 10.0D, false); + assertEquals(1, matches.size()); + assertTrue(manager.remove(matches.get(0))); + + List after = manager.findNearby(origin, 10.0D, false); + assertEquals(0, after.size()); + } + + @Test + void removeAllRemovesEveryMatch() { + PlayerMock player = server.addPlayer(); + Location origin = player.getEyeLocation(); + HologramDisplay a = platform.spawn(origin.clone().add(1.0D, 0.0D, 0.0D)); + HologramDisplay b = platform.spawn(origin.clone().add(2.0D, 0.0D, 0.0D)); + platform.setMarker(a, MARKER_KEY, AuctionHologramType.ACTIVE_LISTINGS.name()); + platform.setMarker(b, MARKER_KEY, AuctionHologramType.ACTIVE_ORDERS.name()); + + List matches = manager.findNearby(origin, 10.0D, false); + int removed = manager.removeAll(matches); + assertEquals(2, removed); + } + + @Test + void ensureHologramReturnsSpawnLocationOnSuccess() { + PlayerMock player = server.addPlayer(); + Location base = new Location(player.getWorld(), 1000.0D, 64.0D, 1000.0D).add(0.0D, 1.0D, 0.0D); + + Location spawned = manager.ensureHologram(base, AuctionHologramType.ACTIVE_LISTINGS); + assertTrue(spawned != null, "ensureHologram should return the spawn location on success"); + assertEquals(base.getBlockX(), spawned.getBlockX()); + assertEquals(0.5D, spawned.getX() - spawned.getBlockX(), 0.001D); + assertEquals(base.getBlockY() + (int) AuctionHologramConfiguration.defaults().heightOffset(), spawned.getBlockY()); + assertEquals(base.getBlockZ(), spawned.getBlockZ()); + manager.findNearby(spawned, 1.0D, false).forEach(manager::remove); + } + + @Test + void ensureHologramReturnsNullOnNullInput() { + assertTrue(manager.ensureHologram(null, AuctionHologramType.ACTIVE_LISTINGS) == null); + assertTrue(manager.ensureHologram(new org.bukkit.Location(null, 0, 0, 0), + AuctionHologramType.ACTIVE_LISTINGS) == null); + } + + @Test + void nothingInRangeReturnsEmpty() { + PlayerMock player = server.addPlayer(); + Location origin = player.getEyeLocation(); + List matches = manager.findNearby(origin, 10.0D, false); + assertTrue(matches.isEmpty()); + } + + @Test + void disableClearsTrackedState() { + PlayerMock player = server.addPlayer(); + Location origin = player.getEyeLocation(); + HologramDisplay display = platform.spawn(origin.clone().add(1.0D, 0.0D, 0.0D)); + platform.setMarker(display, MARKER_KEY, AuctionHologramType.ACTIVE_LISTINGS.name()); + manager.findNearby(origin, 10.0D, false); + + manager.disable(); + assertTrue(manager.findNearby(origin, 10.0D, false).isEmpty()); + assertTrue(manager.trackedCounts().isEmpty()); + } + + @Test + void removeNullMatchReturnsFalse() { + assertFalse(manager.remove(null)); + } + + @Test + void removeInvalidDisplayReturnsFalse() { + PlayerMock player = server.addPlayer(); + Location origin = player.getEyeLocation(); + HologramDisplay display = platform.spawn(origin.clone().add(1.0D, 0.0D, 0.0D)); + platform.setMarker(display, MARKER_KEY, AuctionHologramType.ACTIVE_LISTINGS.name()); + List matches = manager.findNearby(origin, 10.0D, false); + assertEquals(1, matches.size()); + + display.remove(); + assertFalse(manager.remove(matches.get(0))); + assertFalse(display.isValid()); + } + + @Test + void removeAllEmptyAndNullReturnsZero() { + assertEquals(0, manager.removeAll(List.of())); + assertEquals(0, manager.removeAll(null)); + } + + @Test + void trackedCountsReflectsTypes() { + PlayerMock player = server.addPlayer(); + Location origin = player.getEyeLocation(); + HologramDisplay listings = platform.spawn(origin.clone().add(1.0D, 0.0D, 0.0D)); + platform.setMarker(listings, MARKER_KEY, AuctionHologramType.ACTIVE_LISTINGS.name()); + HologramDisplay orders = platform.spawn(origin.clone().add(2.0D, 0.0D, 0.0D)); + platform.setMarker(orders, MARKER_KEY, AuctionHologramType.ACTIVE_ORDERS.name()); + manager.findNearby(origin, 10.0D, false); + + Map counts = manager.trackedCounts(); + assertEquals(1, counts.get(AuctionHologramType.ACTIVE_LISTINGS)); + assertEquals(1, counts.get(AuctionHologramType.ACTIVE_ORDERS)); + } + + @Test + void isHologramEntityTrueForTextDisplay() { + PlayerMock player = server.addPlayer(); + HologramDisplay display = platform.spawn(player.getLocation()); + Entity entity = player.getWorld().getEntitiesByClass(TextDisplay.class).iterator().next(); + assertTrue(manager.isHologramEntity(entity)); + } + + @Test + void readTypeFromEntityReturnsType() { + PlayerMock player = server.addPlayer(); + Location origin = player.getEyeLocation(); + HologramDisplay display = platform.spawn(origin.clone().add(1.0D, 0.0D, 0.0D)); + platform.setMarker(display, MARKER_KEY, AuctionHologramType.ACTIVE_LISTINGS.name()); + Entity entity = player.getWorld().getEntitiesByClass(TextDisplay.class).iterator().next(); + + assertEquals(AuctionHologramType.ACTIVE_LISTINGS, manager.readType(entity)); + } + + @Test + void getConfigurationReturnsConfig() { + assertSame(manager.getConfiguration(), manager.getConfiguration()); + assertNotNull(manager.getConfiguration()); + } + + @Test + void ensureHologramReusesExistingSameType() { + PlayerMock player = server.addPlayer(); + Location base = new Location(player.getWorld(), 2000.0D, 64.0D, 2000.0D).add(0.0D, 1.0D, 0.0D); + HologramDisplay existing = platform.spawn(base.clone()); + platform.setMarker(existing, MARKER_KEY, AuctionHologramType.ACTIVE_LISTINGS.name()); + + Location first = manager.ensureHologram(base, AuctionHologramType.ACTIVE_LISTINGS); + int before = player.getWorld().getEntitiesByClass(TextDisplay.class).size(); + Location second = manager.ensureHologram(base, AuctionHologramType.ACTIVE_LISTINGS); + int after = player.getWorld().getEntitiesByClass(TextDisplay.class).size(); + + assertNotNull(first); + assertNotNull(second); + assertEquals(before, after); + } + + @Test + void ensureHologramEnforcesMaxHolograms() { + AuctionHologramManager limited = managerWithMaxHolograms(1); + PlayerMock player = server.addPlayer(); + Location first = new Location(player.getWorld(), 3000.0D, 64.0D, 3000.0D).add(0.0D, 1.0D, 0.0D); + Location second = new Location(player.getWorld(), 3005.0D, 64.0D, 3005.0D).add(0.0D, 1.0D, 0.0D); + + assertNotNull(limited.ensureHologram(first, AuctionHologramType.ACTIVE_LISTINGS)); + assertNull(limited.ensureHologram(second, AuctionHologramType.ACTIVE_ORDERS)); + } + + @Test + void updateTrackedDisplaysRunsOnTick() { + PlayerMock player = server.addPlayer(); + Location origin = player.getEyeLocation(); + HologramDisplay display = platform.spawn(origin.clone().add(1.0D, 0.0D, 0.0D)); + platform.setMarker(display, MARKER_KEY, AuctionHologramType.ACTIVE_LISTINGS.name()); + manager.findNearby(origin, 10.0D, false); + + ((BukkitSchedulerMock) server.getScheduler()).performTicks(5); + assertEquals(1, manager.findNearby(origin, 10.0D, false).size()); + manager.disable(); + } + + @Test + void updateTrackedDisplaysRunsOnTickNonBatch() { + AuctionHologramManager nonBatch = managerWithBatchUpdate(false); + PlayerMock player = server.addPlayer(); + Location origin = player.getEyeLocation(); + HologramDisplay display = platform.spawn(origin.clone().add(1.0D, 0.0D, 0.0D)); + platform.setMarker(display, MARKER_KEY, AuctionHologramType.ACTIVE_LISTINGS.name()); + nonBatch.findNearby(origin, 10.0D, false); + + ((BukkitSchedulerMock) server.getScheduler()).performTicks(5); + assertEquals(1, nonBatch.findNearby(origin, 10.0D, false).size()); + nonBatch.disable(); + } + + @Test + void findNearbyNullOriginReturnsEmpty() { + assertTrue(manager.findNearby(null, 10.0D, false).isEmpty()); + } + + private AuctionHologramManager managerWithMaxHolograms(int max) { + AuctionManager auctionManager = mock(AuctionManager.class); + AuctionTransactionService transactionService = mock(AuctionTransactionService.class); + JavaPlugin plugin = MockBukkit.createMockPlugin("EzAuctionTest"); + HologramPlatform plat = new TextDisplayHologramPlatform(plugin); + AuctionHologramConfiguration configuration = new AuctionHologramConfiguration( + true, 100L, 2.5D, 1.75D, max, true, false, "ezauction.hologram.view", + false, 32.0D, 10.0D, 64.0D, true); + AuctionHologramManager mgr = new AuctionHologramManager(plugin, auctionManager, + transactionService, configuration, plat); + mgr.enable(); + return mgr; + } + + private AuctionHologramManager managerWithBatchUpdate(boolean batch) { + AuctionManager auctionManager = mock(AuctionManager.class); + AuctionTransactionService transactionService = mock(AuctionTransactionService.class); + JavaPlugin plugin = MockBukkit.createMockPlugin("EzAuctionTest"); + HologramPlatform plat = new TextDisplayHologramPlatform(plugin); + AuctionHologramConfiguration configuration = new AuctionHologramConfiguration( + true, 100L, 2.5D, 1.75D, 100, batch, false, "ezauction.hologram.view", + false, 32.0D, 10.0D, 64.0D, true); + AuctionHologramManager mgr = new AuctionHologramManager(plugin, auctionManager, + transactionService, configuration, plat); + mgr.enable(); + return mgr; + } +} diff --git a/src/test/java/com/skyblockexp/ezauction/live/LiveAuctionServiceBidTest.java b/src/test/java/com/skyblockexp/ezauction/live/LiveAuctionServiceBidTest.java index a9b604c..fbf7e1d 100644 --- a/src/test/java/com/skyblockexp/ezauction/live/LiveAuctionServiceBidTest.java +++ b/src/test/java/com/skyblockexp/ezauction/live/LiveAuctionServiceBidTest.java @@ -153,7 +153,7 @@ void bid_secondBidOnlyWithdrawsDelta() throws Exception { when(transactionService.withdrawBuyer(any(), anyDouble())) .thenReturn(AuctionOperationResult.success("ok")); - // Second bid — only delta (140 - 115 = 25) should be withdrawn + // Second bid - only delta (140 - 115 = 25) should be withdrawn service.bid(bidder, "auction-1", 140.0); verify(transactionService).withdrawBuyer(bidder, 25.0); diff --git a/src/test/java/com/skyblockexp/ezauction/live/LiveAuctionStateTest.java b/src/test/java/com/skyblockexp/ezauction/live/LiveAuctionStateTest.java index 3ffa97f..4cd93ed 100644 --- a/src/test/java/com/skyblockexp/ezauction/live/LiveAuctionStateTest.java +++ b/src/test/java/com/skyblockexp/ezauction/live/LiveAuctionStateTest.java @@ -105,7 +105,7 @@ void minimumNextBidAfterFirstBid_HigherWins() { @Test void minimumNextBidPercent_NoIncrement_ReturnsCurrentBid() { LiveAuctionState state = makeState(100.0); - // Both zero — minimum = current bid (no requirement) + // Both zero - minimum = current bid (no requirement) assertEquals(100.0, state.minimumNextBid(0, 0), 0.001); }