From e59b91e1fb0809e82ba0e7779d75dd2e19af08e5 Mon Sep 17 00:00:00 2001 From: xcutoboo Date: Thu, 28 May 2026 18:42:10 -0600 Subject: [PATCH 1/3] 2026.1.0 polish pass Builds out the public surface of 2026.1.0 into something a server owner or a minigame plugin can actually depend on. Marketplace + release automation - Hangar, Modrinth, and Polymart publish workflows wired up. Body syncs from README via raw.githubusercontent for the auto platforms; Polymart soft-fails so the lane never blocks a release. - mythicrod-api publishes through JitPack. Setup docs cover the coord and the v2026.1.1 onward note. - Release Please tracks the changelog and the gradle.properties version marker. Tier visuals - Tier-coded catch celebrations: per-player particles and sound for every tier, helix animation on top tiers (throttled to once per 4 seconds per player so it never floods). - Cut the duplicate weight-based catch effect block that was double-firing alongside the tier helper. Developer API - MythicRodAPI.createRod(tier). One-call rod creation for basic, advanced, legendary, mythic. Returns a Result so unknown tiers do not throw. - MythicRodAPI.previewEligibleDrops(uuid, biomeKey). What the player would roll at that biome, after biome and permission filters. Snapshot list. - MythicRodBiteEvent. Cancellable event when a fish bites a MythicRod-tagged rod. Cancelling cascades to the source PlayerFishEvent so the catch never resolves. - Mythic rod tier all the way through: RodFactory preset, PaperMythicRodAPI switch case, FishingListener permission check, RodMenu button at slot 16 with the visuals toggle moved to row-3 centre, ConfigManager luck multiplier (default 2.0), config.yml entry, mythicrod.rod.mythic permission, en_US and ja_JP locale entries, docs/permissions.md and rods.md updates. - PlaceholderAPI expansion (soft dep). Tokens: %mythicrod_total%, per-tier counts, %mythicrod_rod_tier%, %mythicrod_version%. Documented at docs/placeholders.md. - /mythicrod showcase admin command. Triggers a tier celebration on the sender, useful for clips and support repros. - /mythicrod status grows PAPI status, provider count, and total-catches lines so one screenshot answers most support tickets. Code health - Convert javadoc HTML to actual markdown across the //// blocks that landed on Java 23, drop {@code} for backtick spans. - Pull repeated string literals (identifier, weight, amount, field, value, biome, locale, status, tier strings, translation keys) into ARG_* and TR_* constants in BrigadierCommandManager and LanguageSwitchMenu. - Import the FQNs we kept spelling out inline (Locale, Level, Location, Server, World, InventoryClickEvent). - Drop three stale @SuppressWarnings('unused') on helpers that are very much used. - Pull the eligibility check in /mythicrod drops preview into a small helper so the loop body is trivial to read. - Unnamed pattern variables on catches whose exception is never read. - parse() in UpdateChecker returns an empty array instead of null; callers check length. - assertNotNull instead of assertTrue(x != null) in the bundled locale parity test. - Pin exact versions and force --only-binary :all: on the mkdocs pip install in the pages workflow. Plus: README catch-up to mention 4 rod tiers, the new API methods, and the PlaceholderAPI tokens. --- .github/workflows/pages.yml | 10 +- .github/workflows/release-please.yml | 31 +++ .release-please-manifest.json | 3 + README.md | 52 ++++- build.gradle.kts | 3 + docs/developer-api/events.md | 23 +- docs/developer-api/examples.md | 34 +++ docs/developer-api/rods.md | 28 ++- docs/developer-api/setup.md | 34 ++- docs/developer-api/stats.md | 114 +++++++--- docs/permissions.md | 1 + docs/placeholders.md | 53 +++++ gradle.properties | 1 + gradle/libs.versions.toml | 4 +- gradle/verification-metadata.xml | 25 ++ jitpack.yml | 4 + mkdocs.yml | 1 + mythicrod-api/build.gradle.kts | 38 ++++ mythicrod-api/gradle.lockfile | 2 +- .../mythicrod/api/ExternalDropProvider.java | 2 + .../xcutiboo/mythicrod/api/MythicRodAPI.java | 51 +++++ .../mythicrod/api/PlayerStatSnapshot.java | 2 + .../io/xcutiboo/mythicrod/api/Result.java | 3 + .../mythicrod/api/drop/DropCatalog.java | 2 + .../xcutiboo/mythicrod/api/package-info.java | 4 +- .../mythicrod/config/ConfigManager.java | 4 + .../mythicrod/constants/PermissionNodes.java | 3 +- .../xcutiboo/mythicrod/drops/DropManager.java | 16 +- .../mythicrod/drops/DropSelector.java | 10 +- .../mythicrod/metrics/StatisticsManager.java | 36 ++- .../xcutiboo/mythicrod/stats/PlayerStats.java | 2 +- .../src/main/resources/config.yml | 6 +- mythicrod-paper/gradle.lockfile | 1 + .../xcutiboo/mythicrod/paper/MythicRod.java | 19 +- .../paper/api/MythicRodServices.java | 2 + .../paper/api/PaperMythicRodAPI.java | 45 +++- .../commands/BrigadierCommandManager.java | 214 +++++++++++------- .../paper/events/MythicRodBiteEvent.java | 87 +++++++ .../paper/events/MythicRodFishCatchEvent.java | 7 +- .../paper/events/MythicRodReloadEvent.java | 2 + .../events/MythicRodRewardRollEvent.java | 15 ++ .../events/MythicRodStatsUpdateEvent.java | 4 +- .../paper/fishing/FishingListener.java | 117 +++------- .../mythicrod/paper/gui/menus/ConfigMenu.java | 5 +- .../paper/gui/menus/EditDropMenu.java | 11 +- .../paper/gui/menus/LanguageSwitchMenu.java | 8 +- .../paper/gui/menus/MainHubMenu.java | 6 +- .../mythicrod/paper/gui/menus/RodMenu.java | 24 +- .../mythicrod/paper/gui/menus/StatsMenu.java | 2 +- .../integration/MythicRodPlaceholders.java | 102 +++++++++ .../mythicrod/paper/item/ItemBuilder.java | 3 +- .../mythicrod/paper/item/RodFactory.java | 22 +- .../paper/platform/PaperLocation.java | 6 +- .../mythicrod/paper/platform/PaperWorld.java | 3 +- .../scheduler/FoliaSchedulerService.java | 5 +- .../mythicrod/paper/update/UpdateChecker.java | 8 +- .../paper/util/TierVisualEffects.java | 168 ++++++++++++++ .../src/main/resources/lang/en_US.yml | 16 +- .../src/main/resources/lang/ja_JP.yml | 16 +- .../src/main/resources/paper-plugin.yml | 9 + .../api/PaperMythicRodAPICreateRodTest.java | 46 ++++ .../config/BundledLocaleParityTest.java | 5 +- release-please-config.json | 20 ++ 63 files changed, 1308 insertions(+), 292 deletions(-) create mode 100644 .github/workflows/release-please.yml create mode 100644 .release-please-manifest.json create mode 100644 docs/placeholders.md create mode 100644 jitpack.yml create mode 100644 mythicrod-paper/src/main/java/io/xcutiboo/mythicrod/paper/events/MythicRodBiteEvent.java create mode 100644 mythicrod-paper/src/main/java/io/xcutiboo/mythicrod/paper/integration/MythicRodPlaceholders.java create mode 100644 mythicrod-paper/src/main/java/io/xcutiboo/mythicrod/paper/util/TierVisualEffects.java create mode 100644 mythicrod-paper/src/test/java/io/xcutiboo/mythicrod/paper/api/PaperMythicRodAPICreateRodTest.java create mode 100644 release-please-config.json diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 819d43e..342be5a 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -30,12 +30,14 @@ jobs: python-version: '3.12' - name: Install MkDocs Material + # Pin exact versions and force binary wheels: Sonar S8544/S8541 + # (unpinned dependencies + setup-script execution risk). run: | python -m pip install --upgrade pip - pip install \ - "mkdocs-material>=9.5" \ - "mkdocs-static-i18n>=1.2" \ - "mkdocs-git-revision-date-localized-plugin>=1.2" + pip install --only-binary :all: \ + "mkdocs-material==9.5.45" \ + "mkdocs-static-i18n==1.2.3" \ + "mkdocs-git-revision-date-localized-plugin==1.2.9" - name: Configure Pages uses: actions/configure-pages@v5 diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml new file mode 100644 index 0000000..24c97fd --- /dev/null +++ b/.github/workflows/release-please.yml @@ -0,0 +1,31 @@ +name: Release Please + +on: + push: + branches: [master] + +permissions: + contents: write + pull-requests: write + +jobs: + release-please: + runs-on: ubuntu-latest + steps: + - uses: googleapis/release-please-action@v4 + with: + release-type: simple + package-name: MythicRod + include-v-in-tag: true + changelog-types: | + [ + {"type":"feat","section":"Features","hidden":false}, + {"type":"fix","section":"Bug fixes","hidden":false}, + {"type":"perf","section":"Performance","hidden":false}, + {"type":"deps","section":"Dependencies","hidden":false}, + {"type":"docs","section":"Documentation","hidden":false}, + {"type":"refactor","section":"Refactoring","hidden":false}, + {"type":"chore","section":"Chores","hidden":true}, + {"type":"test","section":"Tests","hidden":true}, + {"type":"ci","section":"CI","hidden":true} + ] diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 0000000..52a29fa --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "2026.1.0" +} diff --git a/README.md b/README.md index 54d0d1e..def55ce 100644 --- a/README.md +++ b/README.md @@ -4,23 +4,52 @@ [![Release](https://img.shields.io/github/v/release/xcutiboo/MythicRod?style=flat-square)](https://github.com/xcutiboo/MythicRod/releases) [![Paper 26.1.2](https://img.shields.io/badge/Paper-26.1.2-dca13a?style=flat-square)](https://papermc.io) [![Folia](https://img.shields.io/badge/Folia-region--ready-835516?style=flat-square)](https://papermc.io/software/folia) [![Discord](https://img.shields.io/badge/Discord-chat-5865F2?style=flat-square)](https://discord.gg/MDwtUgxX9U) -Weighted loot tables with biome filters, permission gates, an in-game +Weighted fishing drops with biome rules, permission gates, an in-game drop editor, per-player statistics, and a small public API for Paper and Folia. + + + ## Features - Weighted drop tables with biome and permission filters -- In-game GUI editor for drop entries +- In-game GUI editor for drop entries (live, no reload) - Per-player and global statistics plus a leaderboard -- Three rod tiers (basic / advanced / legendary) gated by permission +- Four rod tiers (basic / advanced / legendary / mythic), each behind + its own permission +- Tier-coded catch celebrations (per-player particles + sound, helix + animation on top tiers, throttled to once per 4 seconds) - Folia region-aware schedulers -- Public Java API for other plugins -- Crowdin localization (English and Japanese shipped) +- Public Java API for downstream plugins: `createRod(tier)`, + `previewEligibleDrops(uuid, biome)`, external drop providers, + stats lookup + leaderboard, five Paper events (bite, roll, catch, + stats, reload) +- PlaceholderAPI tokens for scoreboards and chat (`%mythicrod_total%`, + `%mythicrod_legendary%`, `%mythicrod_rod_tier%`, ...) +- Crowdin localisation (English and Japanese ship in the jar) - Optional Nexo integration for custom items +## Preview + + + + + + + + + + + + + + + + + ## Quick install Drop the jar in `plugins/`, start the server, then `/mythicrod reload` @@ -34,6 +63,15 @@ Everything else lives on the docs site: - Commands and permissions - Configuration reference - Drop table format -- Developer API -- Localization workflow +- Developer API (events, drop providers, rod factory, stats) +- PlaceholderAPI token reference +- Localisation workflow - Release runbook + +## Community + +- [Discord](https://discord.gg/MDwtUgxX9U) for support and feature + discussion. +- [Crowdin](https://crowdin.com/project/mythicrod) for translations. +- Issues and pull requests on + [GitHub](https://github.com/xcutiboo/MythicRod). diff --git a/build.gradle.kts b/build.gradle.kts index 346ee17..521b4f3 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -34,6 +34,9 @@ subprojects { maven("https://repo.papermc.io/repository/maven-public/") { name = "papermc" } + maven("https://repo.extendedclip.com/content/repositories/placeholderapi/") { + name = "placeholderapi" + } } java { diff --git a/docs/developer-api/events.md b/docs/developer-api/events.md index 2bad50d..3d9a644 100644 --- a/docs/developer-api/events.md +++ b/docs/developer-api/events.md @@ -1,15 +1,36 @@ # Paper events -MythicRod publishes four Paper events. All live under +MythicRod publishes five Paper events. All live under `io.xcutiboo.mythicrod.paper.events`. | Event | Cancellable | Thread | | --- | --- | --- | +| `MythicRodBiteEvent` | yes | player-owner | | `MythicRodRewardRollEvent` | no | player-owner | | `MythicRodFishCatchEvent` | yes | player-owner | | `MythicRodStatsUpdateEvent` | no | stats writer | | `MythicRodReloadEvent` | no | reload-caller | +## `MythicRodBiteEvent` + +Fires when a fish bites a MythicRod rod (`PlayerFishEvent.State.BITE`). +Only fires for rods MythicRod recognises - vanilla rods never trigger +it. Use this for skill-check minigames, custom bite cues, or to +short-circuit the catch when the player is missing a buff item. + +```java +@EventHandler(ignoreCancelled = true) +public void onBite(MythicRodBiteEvent event) { + Player player = event.getPlayer(); + Location at = event.getHook().getLocation(); + player.spawnParticle(Particle.BUBBLE_POP, at, 8, 0.3, 0.1, 0.3, 0); + skillCheck.start(player, () -> event.setCancelled(true)); +} +``` + +Cancelling the event cancels the underlying `PlayerFishEvent`, so the +catch never resolves. + ## `MythicRodRewardRollEvent` Fires once MythicRod has identified eligible drops but **before** it has diff --git a/docs/developer-api/examples.md b/docs/developer-api/examples.md index 77f5aca..17ca2f1 100644 --- a/docs/developer-api/examples.md +++ b/docs/developer-api/examples.md @@ -120,4 +120,38 @@ public Optional rodTier(ItemStack rod) { } ``` +## Minigame: "what could I catch here?" preview + +`previewEligibleDrops(UUID, biomeKey)` returns the drop table after +biome and permission filters. Use it in tutorial overlays, fishing +spot UIs, or scoreboard tickers without running an actual catch. + +```java +import io.xcutiboo.mythicrod.api.MythicRodAPI; +import io.xcutiboo.mythicrod.api.platform.PlatformDrop; +import io.xcutiboo.mythicrod.paper.api.MythicRodServices; +import org.bukkit.entity.Player; + +public void showPreview(Player player) { + String biomeKey = player.getWorld() + .getBiome(player.getLocation()) + .getKey() + .toString(); + + MythicRodServices.find().ifPresent(api -> { + var eligible = api.previewEligibleDrops(player.getUniqueId(), biomeKey); + int total = eligible.stream().mapToInt(PlatformDrop::getWeight).sum(); + eligible.forEach(drop -> { + double share = total == 0 ? 0.0 : (drop.getWeight() * 100.0 / total); + player.sendMessage("§7- " + drop.getIdentifier() + + " §8(" + String.format("%.1f", share) + "%§8)"); + }); + }); +} +``` + +Pass `null` as the biome to ignore biome filters. The list is an +immutable snapshot; reloads do not retroactively change a returned +list. + [← Developer API](../developer-api.md) diff --git a/docs/developer-api/rods.md b/docs/developer-api/rods.md index 73a3741..33ec877 100644 --- a/docs/developer-api/rods.md +++ b/docs/developer-api/rods.md @@ -6,16 +6,31 @@ PersistentDataContainer keys: | Key | Type | Meaning | | --- | --- | --- | | `mythicrod:custom_rod` | byte (1) | This item is a MythicRod rod | -| `mythicrod:rod_tier` | string | One of `basic`, `advanced`, `legendary` | +| `mythicrod:rod_tier` | string | One of `basic`, `advanced`, `legendary`, `mythic` | The display name or lore is never used as identity. A survival player cannot rename a vanilla rod into a MythicRod rod. ## Creating a rod from your plugin -If you want to hand out a MythicRod-compatible rod from your own code, -use the API's item factory instead of building an `ItemStack` by hand. -That route keeps your rod compatible with future internal changes: +The cleanest path is `MythicRodAPI.createRod(tier)`. It returns the +fully-tagged rod with display name, lore, glow, and unbreakable flag +matching MythicRod's built-in presets. + +```java +MythicRodAPI api = MythicRodServices.require(); +PlatformItem rod = api.createRod("advanced").orElseThrow(); +player.getInventory().addItem(((PaperPlatformItem) rod).getItemStack()); +``` + +Valid tiers (case-insensitive): `basic`, `advanced`, `legendary`, +`mythic`. An unknown tier returns a `Result.failure` rather than +throwing. + +### Manual rod creation (legacy path) + +If you need to override the preset, build the rod through the item +factory and write the PDC keys yourself: ```java MythicRodAPI api = MythicRodServices.require(); @@ -29,9 +44,8 @@ meta.getPersistentDataContainer().set(rodTier, PersistentDataType.STRING, "advan rod.setItemMeta(meta); ``` -This is rare. Most integrations should hand off to MythicRod's own -`/mythicrod give` flow or use an `ExternalDropProvider` for tier-aware -rewards. +Reach for the manual path only when the built-in preset does not fit; +otherwise prefer `createRod(tier)`. ## Detecting a MythicRod rod diff --git a/docs/developer-api/setup.md b/docs/developer-api/setup.md index 3df8eb3..216061c 100644 --- a/docs/developer-api/setup.md +++ b/docs/developer-api/setup.md @@ -1,21 +1,43 @@ # Setup -## Gradle (Kotlin DSL) +The cleanest way to depend on MythicRod is through JitPack, which +builds the `mythicrod-api` artifact straight from the public GitHub +tag. No file copies, no jar wrangling. + +## Gradle (Kotlin DSL) - JitPack ```kotlin repositories { mavenCentral() maven("https://repo.papermc.io/repository/maven-public/") + maven("https://jitpack.io") } dependencies { compileOnly("io.papermc.paper:paper-api:26.1.2.build.64-stable") - compileOnly(files("libs/MythicRod-Paper-2026.1.0.jar")) + // JitPack treats the api module as the project artifact, so pin + // the repo not a submodule. Available from v2026.1.1 onwards. + compileOnly("com.github.xcutiboo:MythicRod:v2026.1.1") } ``` -Drop the released MythicRod jar into your project's `libs/` folder. -Keep it `compileOnly`. Never shade or relocate it. +The first resolution for a new MythicRod tag triggers a one-time +JitPack build (1-5 minutes). Subsequent consumers get the cached +artifact. For development you can also pin +`com.github.xcutiboo:MythicRod:master-SNAPSHOT`. + +## Gradle (Kotlin DSL) - jar drop + +If you cannot reach JitPack from your build host, drop the released +MythicRod jar into your project's `libs/` folder and keep it +`compileOnly`. Never shade or relocate it. + +```kotlin +dependencies { + compileOnly("io.papermc.paper:paper-api:26.1.2.build.64-stable") + compileOnly(files("libs/MythicRod-Paper-2026.1.0.jar")) +} +``` ## Gradle (Groovy) @@ -60,7 +82,9 @@ dependencies: required: false ``` -Handle the missing-service case cleanly when MythicRod is optional. +If MythicRod is an optional dependency, return early when the +service lookup is null. A server admin may have removed MythicRod +between restarts even when the declared load order says otherwise. ## Plugin version compatibility diff --git a/docs/developer-api/stats.md b/docs/developer-api/stats.md index 41d6e33..08cbdc1 100644 --- a/docs/developer-api/stats.md +++ b/docs/developer-api/stats.md @@ -1,81 +1,123 @@ # Stats API MythicRod tracks per-player fishing statistics in -`plugins/MythicRod/statistics.yml`. Access them through three methods -on `MythicRodAPI`. - -## Read a single player's snapshot +`plugins/MythicRod/statistics.yml`. Three methods on `MythicRodAPI` +expose them, and every call completes on a MythicRod-owned async +thread so the main thread is never blocked. + +## What you get back + +Each lookup returns a `PlayerStatSnapshot`. It's an immutable Java +record, safe to pass around and to log. Every field is filled in; +nothing is `null`. + +| Field | Type | What it is | +| --- | --- | --- | +| `playerUuid()` | `UUID` | Player identity. Always present. | +| `playerName()` | `String` | Last name MythicRod saw for this UUID. Empty for unseen players. | +| `totalCaught()` | `int` | Total custom drops the player has caught. | +| `commonCaught()` | `int` | Catches that resolved to the `common` tier. | +| `uncommonCaught()` | `int` | Catches in the `uncommon` tier. | +| `rareCaught()` | `int` | Catches in the `rare` tier. | +| `legendaryCaught()` | `int` | Catches in the `legendary` tier. | +| `basicRodUses()` | `int` | Times the player has cast with a `basic` rod. | +| `advancedRodUses()` | `int` | Casts with an `advanced` rod. | +| `legendaryRodUses()` | `int` | Casts with a `legendary` rod. | +| `lastFished()` | `Instant` | When the player last caught anything. `Instant.EPOCH` if never. | +| `snapshotTime()` | `Instant` | When this snapshot was taken. | + +All counter fields are `>= 0`; the record's constructor rejects +negative values, so you never have to defend against them. + +## Reading a single player ```java api.getPlayerStats(player.getUniqueId()).thenAccept(snap -> { - long total = snap.totalCaught(); - int rare = snap.rareCaught(); - int legendary = snap.legendaryCaught(); + int total = snap.totalCaught(); + int rare = snap.rareCaught(); + int legendary = snap.legendaryCaught(); + Instant lastSeen = snap.lastFished(); + // snap.playerName(), snap.basicRodUses(), etc. }); ``` `getPlayerStats(UUID)` returns a `CompletableFuture`. -For an unknown UUID, the future completes with -`PlayerStatSnapshot.empty(uuid, "")` rather than failing. +For a UUID MythicRod has never seen, the future completes with +`PlayerStatSnapshot.empty(uuid, "")` rather than failing, so a missing +player is the same shape as a zeroed one. ## Leaderboards ```java import io.xcutiboo.mythicrod.api.PlayerStatSnapshot.StatType; -api.getTopPlayers(StatType.TOTAL_CAUGHT, 10).thenAccept(top -> { - for (PlayerStatSnapshot row : top) { - // row.playerName(), row.totalCaught(), row.lastFished() +api.getTopPlayers(StatType.TOTAL_CAUGHT, 10).thenAccept(rows -> { + for (PlayerStatSnapshot row : rows) { + String name = row.playerName(); + int total = row.totalCaught(); + Instant when = row.lastFished(); + // build leaderboard UI from here } }); ``` -| `StatType` | Sort order | +| `StatType` value | Sort order | | --- | --- | | `TOTAL_CAUGHT` | total catches, descending | -| `RARE_CAUGHT` | rare catches, descending | -| `LEGENDARY_CAUGHT` | legendary catches, descending | -| `LAST_FISHED` | most-recent catch first | +| `RARE_CAUGHT` | rare-tier catches, descending | +| `LEGENDARY_CAUGHT` | legendary-tier catches, descending | +| `LAST_FISHED` | most recent activity first | -`limit` is clamped to `1..100`. +`limit` is clamped to `1..100`. Ask for what you can render; a request +for `0` or `200` is silently rounded into range, never an error. -## Force a flush +## Force a flush before a backup ```java api.flushAllStats().thenRun(() -> { - // Stats are now durable on disk. + // Stats are now durable on disk; safe to snapshot statistics.yml. }); ``` -Useful right before a backup. MythicRod also flushes automatically on -plugin shutdown and on the configured cadence -(`timers.stats-save-interval-seconds`). +MythicRod also flushes automatically on plugin shutdown and on the +configured cadence (`timers.stats-save-interval-seconds`). Manual +flushes are only useful right before backup snapshots or for tests. -## Stat updates as events +## Reacting to updates as they happen -If you need a push notification rather than a poll, listen for -`MythicRodStatsUpdateEvent`. It carries the updated snapshot and the -catch tier that triggered the update. See -[events](events.md). +Polling works, but if you want a push, listen for +`MythicRodStatsUpdateEvent`. It carries the new snapshot and the tier +that triggered the change, so you can render leaderboards reactively +without busy-loops. See [events](events.md). ## Thread contract -All three methods complete on a MythicRod-owned async thread. Schedule -back to the right owner before touching Bukkit state. See -[Folia threading](folia-threading.md). +Every future completes on a MythicRod-owned async thread. Before +touching Bukkit state from inside `thenAccept` / `thenRun`, hop back +to the owner thread: + +```java +api.getPlayerStats(player.getUniqueId()).thenAccept(snap -> { + player.getScheduler().run(plugin, task -> { + player.sendMessage("You've caught " + snap.totalCaught() + " fish!"); + }, null); +}); +``` + +See [Folia threading](folia-threading.md) for the full table of which +scheduler owns each Bukkit object. -## What `statistics: false` does +## When the operator turns statistics off -When the `features.statistics.enabled` config flag is off: +When `features.statistics.enabled` is set to `false` in config: - Catch events do not increment counters. - `MythicRodStatsUpdateEvent` does not fire. - `getPlayerStats(...)` and `getTopPlayers(...)` still return whatever - was last persisted to disk. They do not error out. + was last persisted to disk; they do not throw. - `flushAllStats()` is a no-op. -Treat statistics as opt-in for admins. Plugins that rely on them should -either degrade gracefully or warn the operator when statistics are -disabled. +If your integration depends on live stats, log a friendly notice on +enable when the flag is off so the admin knows to flip it. Don't crash. [← Developer API](../developer-api.md) diff --git a/docs/permissions.md b/docs/permissions.md index 9acbe5d..3f324c2 100644 --- a/docs/permissions.md +++ b/docs/permissions.md @@ -15,6 +15,7 @@ | `mythicrod.drops.legendary` | `op` | Receive `legendary` drops | | `mythicrod.rod.advanced` | `op` | Use the advanced rod tier | | `mythicrod.rod.legendary` | `op` | Use the legendary rod tier | +| `mythicrod.rod.mythic` | `op` | Use the mythic rod tier (top of the loot ladder) | | `mythicrod.admin.reload` | `op` | Reload runtime data | | `mythicrod.admin.give` | `op` | Give MythicRod items | | `mythicrod.admin.config` | `op` | Edit drops, config, run `stats reset` | diff --git a/docs/placeholders.md b/docs/placeholders.md new file mode 100644 index 0000000..8c390b1 --- /dev/null +++ b/docs/placeholders.md @@ -0,0 +1,53 @@ +--- +title: Placeholders +--- + +# PlaceholderAPI tokens + +MythicRod registers a PlaceholderAPI expansion when PAPI is on the +server. Use the tokens below in any plugin that resolves placeholders +(scoreboard, tab list, hologram, chat formatter). + +PAPI is a **soft dependency**. If you do not have it installed, nothing +breaks - the expansion just stays unregistered. + +## Token reference + +| Token | Returns | +| --- | --- | +| `%mythicrod_total%` | Lifetime catches | +| `%mythicrod_common%` | Common-tier catches | +| `%mythicrod_uncommon%` | Uncommon-tier catches | +| `%mythicrod_rare%` | Rare-tier catches | +| `%mythicrod_legendary%` | Legendary-tier catches | +| `%mythicrod_rod_tier%` | Selected rod tier (`basic`, `advanced`, `legendary`, `mythic`) | +| `%mythicrod_version%` | Running MythicRod plugin version | + +All numeric tokens return integers as strings. Catch tokens return an +empty string when the player has no persisted stats yet (first login, +or a server that has just been wiped). `%mythicrod_rod_tier%` returns +`basic` when the player has not opened the rod menu yet. + +`%mythicrod_version%` is the only token that works without a player +context - safe to use in plugin status panels. + +## Example: scoreboard + +```yaml +# config.yml for a scoreboard plugin +lines: + - '&6&l⚓ MythicRod' + - '' + - '&7Caught: &f%mythicrod_total%' + - '&7Legendary: &6%mythicrod_legendary%' + - '&7Rod: &b%mythicrod_rod_tier%' +``` + +## Example: chat formatter + +```yaml +# config.yml for a chat plugin +format: '&7[&6%mythicrod_legendary%&7] %1$s: %2$s' +``` + +[← Back to docs home](./) diff --git a/gradle.properties b/gradle.properties index ac899f9..0d0a40b 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,3 +1,4 @@ +# x-release-please-version version=2026.1.0 paperVersion=26.1.2 hangarProjectId=MythicRod diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a679435..0dccb1c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -8,7 +8,7 @@ junit-platform = "6.1.0" # Gradle Plugins shadow = "9.4.1" run-paper = "3.0.2" -hangar-publish = "0.1.2" +hangar-publish = "0.1.4" sonarqube = "7.3.0.8198" # Dependencies @@ -17,6 +17,7 @@ adventure-minimessage = "5.1.1" adventure-legacy = "5.1.1" bstats = "3.2.1" caffeine = "3.2.4" +placeholderapi = "2.11.6" # Build java = "25" @@ -35,6 +36,7 @@ jetbrains-annotations = { module = "org.jetbrains:annotations", version.ref = "j # Runtime libraries caffeine = { module = "com.github.ben-manes.caffeine:caffeine", version.ref = "caffeine" } bstats-bukkit = { module = "org.bstats:bstats-bukkit", version.ref = "bstats" } +placeholderapi = { module = "me.clip:placeholderapi", version.ref = "placeholderapi" } # Adventure test helpers adventure-text-minimessage = { module = "net.kyori:adventure-text-minimessage", version.ref = "adventure-minimessage" } diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index c6cb584..e5ae898 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -254,6 +254,11 @@ + + + + + @@ -283,6 +288,11 @@ + + + + + @@ -647,6 +657,11 @@ + + + + + @@ -660,6 +675,11 @@ + + + + + @@ -668,6 +688,11 @@ + + + + + diff --git a/jitpack.yml b/jitpack.yml new file mode 100644 index 0000000..e13889c --- /dev/null +++ b/jitpack.yml @@ -0,0 +1,4 @@ +jdk: + - openjdk25 +install: + - ./gradlew :mythicrod-api:publishToMavenLocal -x test --no-daemon diff --git a/mkdocs.yml b/mkdocs.yml index 91ce68b..58ee8c2 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -93,6 +93,7 @@ nav: - Rods: rods.md - Loot tables: loot-tables.md - Troubleshooting: troubleshooting.md + - Placeholders: placeholders.md - Localization: - Overview: localization.md - Crowdin workflow: localization/crowdin.md diff --git a/mythicrod-api/build.gradle.kts b/mythicrod-api/build.gradle.kts index a2a934b..24c9be9 100644 --- a/mythicrod-api/build.gradle.kts +++ b/mythicrod-api/build.gradle.kts @@ -1,5 +1,43 @@ +plugins { + `maven-publish` +} + dependencies { compileOnly(libs.jetbrains.annotations) testImplementation(libs.junit.jupiter) + testCompileOnly(libs.jetbrains.annotations) +} + +java { + withSourcesJar() + withJavadocJar() +} + +publishing { + publications { + register("maven") { + from(components["java"]) + groupId = "io.xcutiboo" + artifactId = "mythicrod-api" + version = project.version.toString() + + pom { + name.set("MythicRod API") + description.set("Public integration surface for MythicRod (Paper / Folia).") + url.set("https://github.com/xcutiboo/MythicRod") + licenses { + license { + name.set("MIT") + url.set("https://github.com/xcutiboo/MythicRod/blob/master/LICENSE") + } + } + scm { + url.set("https://github.com/xcutiboo/MythicRod") + connection.set("scm:git:git://github.com/xcutiboo/MythicRod.git") + developerConnection.set("scm:git:ssh://git@github.com:xcutiboo/MythicRod.git") + } + } + } + } } diff --git a/mythicrod-api/gradle.lockfile b/mythicrod-api/gradle.lockfile index f060a21..dc0ce8f 100644 --- a/mythicrod-api/gradle.lockfile +++ b/mythicrod-api/gradle.lockfile @@ -6,7 +6,7 @@ org.jacoco:org.jacoco.agent:0.8.14=jacocoAgent,jacocoAnt org.jacoco:org.jacoco.ant:0.8.14=jacocoAnt org.jacoco:org.jacoco.core:0.8.14=jacocoAnt org.jacoco:org.jacoco.report:0.8.14=jacocoAnt -org.jetbrains:annotations:26.1.0=compileClasspath +org.jetbrains:annotations:26.1.0=compileClasspath,testCompileClasspath org.jspecify:jspecify:1.0.0=testCompileClasspath org.junit.jupiter:junit-jupiter-api:6.1.0=testCompileClasspath,testRuntimeClasspath org.junit.jupiter:junit-jupiter-engine:6.1.0=testRuntimeClasspath diff --git a/mythicrod-api/src/main/java/io/xcutiboo/mythicrod/api/ExternalDropProvider.java b/mythicrod-api/src/main/java/io/xcutiboo/mythicrod/api/ExternalDropProvider.java index 5cb9164..dee8515 100644 --- a/mythicrod-api/src/main/java/io/xcutiboo/mythicrod/api/ExternalDropProvider.java +++ b/mythicrod-api/src/main/java/io/xcutiboo/mythicrod/api/ExternalDropProvider.java @@ -1,5 +1,6 @@ package io.xcutiboo.mythicrod.api; +import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -22,6 +23,7 @@ /// Use `MythicRodAPI#getItemFactory()` or `MythicRodAPI#createItem(String, int)` /// to construct MythicRod-compatible items instead of depending on Paper /// implementation classes directly. +@ApiStatus.AvailableSince("2026.1.0") public interface ExternalDropProvider { /// Returns the stable key for this provider. diff --git a/mythicrod-api/src/main/java/io/xcutiboo/mythicrod/api/MythicRodAPI.java b/mythicrod-api/src/main/java/io/xcutiboo/mythicrod/api/MythicRodAPI.java index ba41cdb..0b932ca 100644 --- a/mythicrod-api/src/main/java/io/xcutiboo/mythicrod/api/MythicRodAPI.java +++ b/mythicrod-api/src/main/java/io/xcutiboo/mythicrod/api/MythicRodAPI.java @@ -5,13 +5,17 @@ import java.util.UUID; import java.util.concurrent.CompletableFuture; +import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import io.xcutiboo.mythicrod.api.PlayerStatSnapshot.StatType; import io.xcutiboo.mythicrod.api.drop.DropCatalog; +import io.xcutiboo.mythicrod.api.platform.PlatformDrop; import io.xcutiboo.mythicrod.api.platform.PlatformItem; import io.xcutiboo.mythicrod.api.platform.PlatformItemFactory; +import org.jetbrains.annotations.Nullable; + /// Public integration contract for MythicRod. /// /// ## Thread Safety @@ -38,6 +42,7 @@ /// /// Methods on this interface are stable unless the changelog calls out a major /// version break. Platform-specific implementation classes remain internal. +@ApiStatus.AvailableSince("2026.1.0") public interface MythicRodAPI { /// Returns the running plugin version string, such as `"2026.1.0"`. @@ -143,6 +148,52 @@ default Result createItem(@NotNull String identifier, int amount) int limit ); + /// Creates a MythicRod fishing rod of the given tier, fully tagged with + /// MythicRod's PDC marker and tier metadata so the catch listener and + /// statistics pipeline pick it up. + /// + /// Valid tier names are `"basic"`, `"advanced"`, `"legendary"`, and + /// `"mythic"`, matched case-insensitively. The rod's display name, lore, + /// glow, and unbreakable flag follow MythicRod's built-in presets for that + /// tier. + /// + /// Prefer this over building an `ItemStack` and writing PDC keys by hand: + /// future internal changes to the rod marker stay invisible to the caller. + /// + /// @param tier `"basic"`, `"advanced"`, `"legendary"`, or `"mythic"` + /// (case-insensitive). + /// @return success result with the tagged rod, or failure when the tier is + /// unknown. + @ApiStatus.AvailableSince("2026.1.0") + @NotNull + Result createRod(@NotNull String tier); + + /// Returns the drops the given online player would be eligible to roll at + /// the given biome, after biome filters and permission filters are applied. + /// + /// Returns an empty list when the player is offline or no drops are + /// eligible. The returned list is an immutable snapshot of the current + /// drop table - subsequent reloads do not retroactively change it. + /// + /// Intended for minigame UIs, tutorial overlays, and "what could I catch + /// here?" inspections. The actual roll is still resolved at catch time and + /// can be biased or replaced by `MythicRodRewardRollEvent`. + /// + /// @param playerId UUID of the player to check eligibility for. Must + /// belong to a currently-online player. + /// @param biomeKey Biome key such as `"minecraft:ocean"`, or `null` to + /// ignore biome filters. + /// @return immutable list of eligible drops. Empty when the player is + /// offline or has no eligible drops. + @ApiStatus.AvailableSince("2026.1.0") + @NotNull + @SuppressWarnings("java:S1452") + // The wildcard mirrors DropCatalog#getDrops so MythicRod can return its + // concrete CustomDrop list without forcing a defensive copy on every call. + List previewEligibleDrops( + @NotNull UUID playerId, + @Nullable String biomeKey); + /// Flushes all in-memory player statistics to persistent storage. /// /// This is called automatically on plugin shutdown but may be invoked diff --git a/mythicrod-api/src/main/java/io/xcutiboo/mythicrod/api/PlayerStatSnapshot.java b/mythicrod-api/src/main/java/io/xcutiboo/mythicrod/api/PlayerStatSnapshot.java index 9ca4fe7..d627411 100644 --- a/mythicrod-api/src/main/java/io/xcutiboo/mythicrod/api/PlayerStatSnapshot.java +++ b/mythicrod-api/src/main/java/io/xcutiboo/mythicrod/api/PlayerStatSnapshot.java @@ -4,6 +4,7 @@ import java.util.Objects; import java.util.UUID; +import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; /// Immutable snapshot of a player's MythicRod fishing statistics. @@ -27,6 +28,7 @@ /// @param lastFished timestamp of the player's most recent catch, or `Instant.EPOCH` /// if they have never fished /// @param snapshotTime when this snapshot was taken +@ApiStatus.AvailableSince("2026.1.0") public record PlayerStatSnapshot( @NotNull UUID playerUuid, @NotNull String playerName, diff --git a/mythicrod-api/src/main/java/io/xcutiboo/mythicrod/api/Result.java b/mythicrod-api/src/main/java/io/xcutiboo/mythicrod/api/Result.java index 0b28c8a..8e0b49a 100644 --- a/mythicrod-api/src/main/java/io/xcutiboo/mythicrod/api/Result.java +++ b/mythicrod-api/src/main/java/io/xcutiboo/mythicrod/api/Result.java @@ -1,5 +1,7 @@ package io.xcutiboo.mythicrod.api; +import org.jetbrains.annotations.ApiStatus; + /// Small success/failure container used by MythicRod's public API. /// /// A successful result carries a value. A failed result carries a human-readable @@ -7,6 +9,7 @@ /// value. /// /// @param wrapped success value type +@ApiStatus.AvailableSince("2026.1.0") public final class Result { private final boolean success; private final T value; diff --git a/mythicrod-api/src/main/java/io/xcutiboo/mythicrod/api/drop/DropCatalog.java b/mythicrod-api/src/main/java/io/xcutiboo/mythicrod/api/drop/DropCatalog.java index c9c6261..dbcff22 100644 --- a/mythicrod-api/src/main/java/io/xcutiboo/mythicrod/api/drop/DropCatalog.java +++ b/mythicrod-api/src/main/java/io/xcutiboo/mythicrod/api/drop/DropCatalog.java @@ -3,6 +3,7 @@ import java.util.List; import java.util.Set; +import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import io.xcutiboo.mythicrod.api.platform.PlatformDrop; @@ -11,6 +12,7 @@ /// /// The catalog reflects the plugin's active in-memory configuration. Returned /// collections are snapshots intended for inspection, not mutation. +@ApiStatus.AvailableSince("2026.1.0") public interface DropCatalog { /// Returns all currently registered category keys. diff --git a/mythicrod-api/src/main/java/io/xcutiboo/mythicrod/api/package-info.java b/mythicrod-api/src/main/java/io/xcutiboo/mythicrod/api/package-info.java index 2b808b1..782d649 100644 --- a/mythicrod-api/src/main/java/io/xcutiboo/mythicrod/api/package-info.java +++ b/mythicrod-api/src/main/java/io/xcutiboo/mythicrod/api/package-info.java @@ -6,7 +6,7 @@ /// {@link io.xcutiboo.mythicrod.api.drop.DropCatalog}. Platform-neutral value /// types live in {@link io.xcutiboo.mythicrod.api.platform}. /// -/// The Paper runtime publishes {@code MythicRodAPI} through Bukkit's -/// {@code ServicesManager}. Future-backed methods complete on MythicRod-owned +/// The Paper runtime publishes `MythicRodAPI` through Bukkit's +/// `ServicesManager`. Future-backed methods complete on MythicRod-owned /// async threads and must be rescheduled before touching platform state. package io.xcutiboo.mythicrod.api; diff --git a/mythicrod-common/src/main/java/io/xcutiboo/mythicrod/config/ConfigManager.java b/mythicrod-common/src/main/java/io/xcutiboo/mythicrod/config/ConfigManager.java index 0419d65..19b2c28 100644 --- a/mythicrod-common/src/main/java/io/xcutiboo/mythicrod/config/ConfigManager.java +++ b/mythicrod-common/src/main/java/io/xcutiboo/mythicrod/config/ConfigManager.java @@ -50,6 +50,7 @@ public class ConfigManager { private double basicRodLuckMultiplier = 1.0D; private double advancedRodLuckMultiplier = 1.25D; private double legendaryRodLuckMultiplier = 1.5D; + private double mythicRodLuckMultiplier = 2.0D; private int statsSaveInterval = 600; private String language = DEFAULT_LANGUAGE; private String profile = DEFAULT_PROFILE; @@ -110,6 +111,7 @@ private void validateAndCache() { basicRodLuckMultiplier = resolveRodLuckMultiplier("basic", 1.0D); advancedRodLuckMultiplier = resolveRodLuckMultiplier("advanced", 1.25D); legendaryRodLuckMultiplier = resolveRodLuckMultiplier("legendary", 1.5D); + mythicRodLuckMultiplier = resolveRodLuckMultiplier("mythic", 2.0D); prefix = config.getString("ui.prefix", DEFAULT_PREFIX); if (prefix == null || prefix.isEmpty()) { @@ -198,6 +200,7 @@ private void resetToDefaults() { basicRodLuckMultiplier = 1.0D; advancedRodLuckMultiplier = 1.25D; legendaryRodLuckMultiplier = 1.5D; + mythicRodLuckMultiplier = 2.0D; language = DEFAULT_LANGUAGE; profile = DEFAULT_PROFILE; statsSaveInterval = 600; @@ -288,6 +291,7 @@ public double getRodLuckMultiplier(String tier) { return switch (tier.trim().toLowerCase(Locale.ROOT)) { case "advanced" -> advancedRodLuckMultiplier; case "legendary" -> legendaryRodLuckMultiplier; + case "mythic" -> mythicRodLuckMultiplier; default -> basicRodLuckMultiplier; }; } diff --git a/mythicrod-common/src/main/java/io/xcutiboo/mythicrod/constants/PermissionNodes.java b/mythicrod-common/src/main/java/io/xcutiboo/mythicrod/constants/PermissionNodes.java index 01af2f3..b5aaeda 100644 --- a/mythicrod-common/src/main/java/io/xcutiboo/mythicrod/constants/PermissionNodes.java +++ b/mythicrod-common/src/main/java/io/xcutiboo/mythicrod/constants/PermissionNodes.java @@ -1,6 +1,6 @@ package io.xcutiboo.mythicrod.constants; -/// Permission node constants mirrored in {@code paper-plugin.yml}. +/// Permission node constants mirrored in `paper-plugin.yml`. public final class PermissionNodes { private PermissionNodes() {} @@ -24,4 +24,5 @@ private PermissionNodes() {} public static final String ROD_ADVANCED = "mythicrod.rod.advanced"; public static final String ROD_LEGENDARY = "mythicrod.rod.legendary"; + public static final String ROD_MYTHIC = "mythicrod.rod.mythic"; } diff --git a/mythicrod-common/src/main/java/io/xcutiboo/mythicrod/drops/DropManager.java b/mythicrod-common/src/main/java/io/xcutiboo/mythicrod/drops/DropManager.java index def7744..75eee56 100644 --- a/mythicrod-common/src/main/java/io/xcutiboo/mythicrod/drops/DropManager.java +++ b/mythicrod-common/src/main/java/io/xcutiboo/mythicrod/drops/DropManager.java @@ -25,12 +25,12 @@ /// Manages fishing drop tables loaded from configuration. /// -/// Thread safety: The canonical drop table is held in an +/// **Thread safety:** The canonical drop table is held in an /// {@link AtomicReference} so that a reload (which builds a completely new map) /// is published atomically. Readers always see either the old complete map or /// the new complete map, never a partially-populated one. Individual category /// lists use {@link CopyOnWriteArrayList} so that in-place mutations -/// ({@code updateDrop}, {@code deleteDrop}) are safe to perform while concurrent +/// (`updateDrop`, `deleteDrop`) are safe to perform while concurrent /// readers iterate over the list. public class DropManager implements DropCatalog { private static final String KEY_AMOUNT = "amount"; @@ -608,7 +608,7 @@ private int sanitizeAmount(String id, int amount) { /// /// @param player the fishing player (permission + biome checks) /// @param biomeName current biome key string - /// @param luckMultiplier from {@code MythicRodRewardRollEvent}, clamped to at least 0.01 + /// @param luckMultiplier from `MythicRodRewardRollEvent`, clamped to at least 0.01 public CustomDrop getRandomDrop(PlatformPlayer player, String biomeName, double luckMultiplier) { if (player == null || !player.isOnline()) { fine(() -> "Skipping reward roll because player is null or offline"); @@ -743,7 +743,7 @@ public void awaitAsyncPersistenceOperations() { /// Update an existing drop with new properties. /// /// Thread safe: the category list is a {@link CopyOnWriteArrayList}, so - /// {@code set()} is atomic and concurrent readers are unaffected. + /// `set()` is atomic and concurrent readers are unaffected. /// /// @param dropId The drop identifier /// @param category The category name @@ -781,7 +781,7 @@ public void updateDrop(String dropId, String category, int weight, int amount, /// Adds a drop to a category from a {@link EditableDropFields} value object. /// - /// Returns {@code null} when the category or fields are invalid. + /// Returns `null` when the category or fields are invalid. public CustomDrop addDrop(String category, EditableDropFields fields) { if (category == null || category.isBlank() || fields == null) { return null; @@ -837,7 +837,7 @@ public CustomDrop addDrop(String category, EditableDropFields fields) { /// drops with the same material. The GUI passes the live {@link CustomDrop} /// object it opened so only that row is replaced. /// - /// @return {@code true} when the selected drop was still present + /// @return `true` when the selected drop was still present public boolean updateDrop(CustomDrop targetDrop, String category, int weight, int amount, String customName, List lore, boolean glowing) { if (targetDrop == null) return false; @@ -858,7 +858,7 @@ public boolean updateDrop(CustomDrop targetDrop, String category, int weight, in /// Replaces the selected drop instance with the supplied {@link EditableDropFields}. /// - /// @return {@code true} when the target was still present and the new fields validated + /// @return `true` when the target was still present and the new fields validated public boolean updateDrop(CustomDrop targetDrop, String category, EditableDropFields fields) { if (!hasUpdatableArguments(targetDrop, category, fields)) return false; String normalizedIdentifier = normalizeEditedIdentifier(fields.identifier()); @@ -930,7 +930,7 @@ public void deleteDrop(String dropId, String category) { /// Deletes the exact drop instance selected by the GUI. /// - /// @return {@code true} when the selected drop was still present + /// @return `true` when the selected drop was still present public boolean deleteDrop(CustomDrop targetDrop, String category) { if (targetDrop == null || category == null || category.isBlank()) { return false; diff --git a/mythicrod-common/src/main/java/io/xcutiboo/mythicrod/drops/DropSelector.java b/mythicrod-common/src/main/java/io/xcutiboo/mythicrod/drops/DropSelector.java index 8005dff..8cdd9a8 100644 --- a/mythicrod-common/src/main/java/io/xcutiboo/mythicrod/drops/DropSelector.java +++ b/mythicrod-common/src/main/java/io/xcutiboo/mythicrod/drops/DropSelector.java @@ -12,7 +12,7 @@ /// Thread-safe drop selector using weighted random selection. /// /// Thread safety: All methods are stateless with respect to mutable shared data. -/// {@code ThreadLocalRandom.current()} is called per-invocation (never stored as a field) +/// `ThreadLocalRandom.current()` is called per-invocation (never stored as a field) /// to guarantee correct behaviour across Folia region threads. @RequiredArgsConstructor public class DropSelector { @@ -46,7 +46,7 @@ public void setUseBiomeSpecificDrops(boolean use) { /// Selects a drop with luck-modified weights. /// - /// A {@code luckMultiplier} > 1.0 scales up the effective weight of drops + /// A `luckMultiplier` > 1.0 scales up the effective weight of drops /// whose base weight is ≤ 5 (rare / legendary tier), making them relatively /// more probable without touching common or uncommon weights. A multiplier /// of 1.0 reproduces identical behaviour to the no-luck overload. @@ -100,7 +100,7 @@ public CustomDrop selectDrop(List drops, PlatformPlayer player, Stri /// /// @param drops Full drop pool. /// @param player Player being evaluated. - /// @param biomeName Current biome key, or {@code null} if unavailable. + /// @param biomeName Current biome key, or `null` if unavailable. /// @return Immutable snapshot of eligible drops. public List getEligibleDrops(List drops, PlatformPlayer player, String biomeName) { if (drops == null || drops.isEmpty()) { @@ -165,12 +165,12 @@ private boolean matchesBiome(CustomDrop drop, String biomeName) { /// weights. It still builds short-lived arrays for each selection because /// the eligible set depends on player, biome and luck context. /// - /// Thread safety: {@code ThreadLocalRandom.current()} is called per + /// Thread safety: `ThreadLocalRandom.current()` is called per /// invocation so each Folia region thread uses its own random source. /// /// @param drops list of eligible drops /// @param luckMultiplier multiplier applied to rare drop weights - /// @return the selected drop, or {@code null} if all weights are zero + /// @return the selected drop, or `null` if all weights are zero private CustomDrop selectWeightedRandomOptimized(List drops, double luckMultiplier) { final int n = drops.size(); diff --git a/mythicrod-common/src/main/java/io/xcutiboo/mythicrod/metrics/StatisticsManager.java b/mythicrod-common/src/main/java/io/xcutiboo/mythicrod/metrics/StatisticsManager.java index 3b0d4f7..9844296 100644 --- a/mythicrod-common/src/main/java/io/xcutiboo/mythicrod/metrics/StatisticsManager.java +++ b/mythicrod-common/src/main/java/io/xcutiboo/mythicrod/metrics/StatisticsManager.java @@ -32,20 +32,18 @@ /// Manages in-memory player fishing statistics with Caffeine TTL-bounded caching. /// -///

Thread Safety

-///
    -///
  • The active-players cache uses Caffeine and is safe for concurrent access.
  • -///
  • The dirty set uses {@link ConcurrentHashMap} as a set.
  • -///
  • {@link #saveAll()} is intended to be called from the async scheduler.
  • -///
  • {@link #getStats(UUID)} and {@link #recordCatch} may be called from -/// any entity region thread.
  • -///
+/// ## Thread Safety +/// - The active-players cache uses Caffeine and is safe for concurrent access. +/// - The dirty set uses {@link ConcurrentHashMap} as a set. +/// - {@link #saveAll()} is intended to be called from the async scheduler. +/// - {@link #getStats(UUID)} and {@link #recordCatch} may be called from +/// any entity region thread. /// -///

Cache Design

+/// ## Cache Design /// Stats for online players are kept indefinitely while they are active. /// After login/access, entries expire from the cache after /// {@value #EXPIRE_AFTER_ACCESS_MINUTES} minutes of no access. -/// This prevents {@code OutOfMemoryError} from unbounded accumulation. +/// This prevents `OutOfMemoryError` from unbounded accumulation. public final class StatisticsManager { private static final int EXPIRE_AFTER_ACCESS_MINUTES = 30; @@ -106,11 +104,11 @@ public void onRemoval(@Nullable UUID uuid, @Nullable PlayerStats stats, @NotNull } /// Path to the statistics YAML file inside the plugin data folder. - /// All player stats are stored here under {@code players..*}. + /// All player stats are stored here under `players..*`. private static final String STATS_FILENAME = "statistics.yml"; /// Live reference to the statistics YAML configuration. - /// Guarded by {@code this} for write access; readers see either the old + /// Guarded by `this` for write access; readers see either the old /// reference or the new one swapped during reload, never a half-built object. @SuppressWarnings("java:S3077") private volatile PlatformConfiguration statsConfig; @@ -141,7 +139,7 @@ public long getTotalCatches() { return totalCatchesGlobal.get(); } - /// Returns the top {@code limit} players by total catches. + /// Returns the top `limit` players by total catches. /// Used by GUI menus that do not have async context. /// /// @param limit Max entries to return. @@ -173,14 +171,14 @@ public PlayerStats getOrCreate(@NotNull UUID uuid) { }); } - /// Returns the {@link PlayerStats} for the given UUID, or {@code null} + /// Returns the {@link PlayerStats} for the given UUID, or `null` /// if no entry has been created for this player yet. /// /// Unlike {@link #getOrCreate}, this does not create a new entry. /// Used by the API layer for read-only queries. /// /// @param uuid Player UUID. - /// @return Existing stats, or {@code null}. + /// @return Existing stats, or `null`. @Nullable public PlayerStats getStats(@NotNull UUID uuid) { PlayerStats cachedStats = statsCache.getIfPresent(uuid); @@ -238,7 +236,7 @@ public void recordRodUse(@NotNull UUID uuid, @NotNull String rodTier) { /// the next persistence flush wipes the on-disk row. /// /// @param uuid Player UUID. - /// @return {@code true} if a stats entry existed to reset; {@code false} when + /// @return `true` if a stats entry existed to reset; `false` when /// no in-memory or on-disk entry was present. public boolean resetStats(@NotNull UUID uuid) { PlayerStats inMemory = statsCache.getIfPresent(uuid); @@ -332,10 +330,10 @@ public void unloadPlayer(@NotNull UUID uuid) { statsCache.invalidate(uuid); } - /// Persists a single player's stats to the {@code statistics.yml} file. + /// Persists a single player's stats to the `statistics.yml` file. /// /// Must be called from an async thread, never from the main server thread. - /// All writes are guarded by a {@code synchronized} block on {@code this} so + /// All writes are guarded by a `synchronized` block on `this` so /// concurrent saves from the Caffeine eviction listener don't corrupt the file. private boolean persistStats(@NotNull UUID uuid, @NotNull PlayerStats stats) { try { @@ -461,7 +459,7 @@ private Map snapshotPersistedStats() { return persistedStats; } - /// Loads (or creates) the {@code statistics.yml} config from disk. + /// Loads (or creates) the `statistics.yml` config from disk. private @NotNull PlatformConfiguration loadStatsConfig() { File statsFile = new File(runtime.getDataFolder(), STATS_FILENAME); if (!runtime.getDataFolder().exists()) { diff --git a/mythicrod-common/src/main/java/io/xcutiboo/mythicrod/stats/PlayerStats.java b/mythicrod-common/src/main/java/io/xcutiboo/mythicrod/stats/PlayerStats.java index 3348f73..3954804 100644 --- a/mythicrod-common/src/main/java/io/xcutiboo/mythicrod/stats/PlayerStats.java +++ b/mythicrod-common/src/main/java/io/xcutiboo/mythicrod/stats/PlayerStats.java @@ -54,7 +54,7 @@ public PlayerStats(@NotNull UUID playerUuid, @NotNull String playerName) { public int getAdvancedRodUses() { return advancedRodUses.get(); } public int getLegendaryRodUses() { return legendaryRodUses.get(); } - /// Returns the epoch-millisecond timestamp of the last catch, or {@code 0} if never. + /// Returns the epoch-millisecond timestamp of the last catch, or `0` if never. public long getLastFished() { return lastFished.get(); } /// Returns non-zero catch counts by rarity tier, ordered from rarest to common. diff --git a/mythicrod-common/src/main/resources/config.yml b/mythicrod-common/src/main/resources/config.yml index 0de4026..18837bb 100644 --- a/mythicrod-common/src/main/resources/config.yml +++ b/mythicrod-common/src/main/resources/config.yml @@ -83,6 +83,10 @@ features: basic: 1.0 advanced: 1.25 legendary: 1.5 + # Mythic rod tier - top-of-tier reward. Gate behind a permission node + # (mythicrod.rod.mythic) so it stays a sink for prestige progression + # rather than something every player holds. + mythic: 2.0 permissions: # Require specific permissions for certain drops @@ -104,7 +108,7 @@ features: # TIMERS: Performance intervals (in seconds) ################################################################################ timers: - # Save player statistics to disk (range: 60-3600s) + # Flush in-memory stats to disk every N seconds (clamped to 60-3600s). # Recommended: # lightweight: 300-600s (5-10 min) # balanced: 600-1200s (10-20 min) diff --git a/mythicrod-paper/gradle.lockfile b/mythicrod-paper/gradle.lockfile index 69232f0..e6b7922 100644 --- a/mythicrod-paper/gradle.lockfile +++ b/mythicrod-paper/gradle.lockfile @@ -18,6 +18,7 @@ commons-codec:commons-codec:1.16.0=testRuntimeClasspath io.papermc.paper:paper-api:26.1.2.build.64-stable=compileClasspath,testCompileClasspath,testRuntimeClasspath it.unimi.dsi:fastutil:8.5.18=compileClasspath,testCompileClasspath,testRuntimeClasspath javax.inject:javax.inject:1=compileClasspath,testCompileClasspath,testRuntimeClasspath +me.clip:placeholderapi:2.11.6=compileClasspath net.kyori:adventure-api:4.26.1=compileClasspath net.kyori:adventure-api:5.1.1=testCompileClasspath,testRuntimeClasspath net.kyori:adventure-bom:4.26.1=compileClasspath diff --git a/mythicrod-paper/src/main/java/io/xcutiboo/mythicrod/paper/MythicRod.java b/mythicrod-paper/src/main/java/io/xcutiboo/mythicrod/paper/MythicRod.java index 48b3caf..4098f74 100644 --- a/mythicrod-paper/src/main/java/io/xcutiboo/mythicrod/paper/MythicRod.java +++ b/mythicrod-paper/src/main/java/io/xcutiboo/mythicrod/paper/MythicRod.java @@ -22,6 +22,7 @@ import io.xcutiboo.mythicrod.drops.DropManager; import io.xcutiboo.mythicrod.internal.runtime.MythicRodRuntime; import io.xcutiboo.mythicrod.metrics.StatisticsManager; +import io.xcutiboo.mythicrod.paper.item.RodFactory; import io.xcutiboo.mythicrod.paper.api.PaperMythicRodAPI; import io.xcutiboo.mythicrod.paper.commands.BrigadierCommandManager; import io.xcutiboo.mythicrod.paper.data.PlayerDataService; @@ -96,6 +97,7 @@ public void onEnable() { bootstrapGuiAndApi(); initializeMetrics(); initializeUpdateChecker(); + initializePlaceholderApi(); announceStartup(start); } catch (Exception e) { logger.error("Failed to enable MythicRod", e); @@ -167,7 +169,8 @@ private void bootstrapGuiAndApi() { dropManager, statisticsManager, platformScheduler, - platformServer.getItemFactory() + platformServer.getItemFactory(), + new RodFactory(this) ); // Bukkit ServicesManager registration lets third-party plugins resolve // the API without compile-time dependency on our internal classes. @@ -418,6 +421,20 @@ private void initializeUpdateChecker() { updateChecker.start(); } + /// Registers the MythicRod PlaceholderAPI expansion when PAPI is loaded. + /// PAPI is a soft dependency; missing it is fine and quiet. + private void initializePlaceholderApi() { + if (super.getServer().getPluginManager().getPlugin("PlaceholderAPI") == null) { + return; + } + try { + new io.xcutiboo.mythicrod.paper.integration.MythicRodPlaceholders(this, api).register(); + logger.info("Registered PlaceholderAPI expansion: %mythicrod_*%"); + } catch (RuntimeException | LinkageError e) { + logger.warn("Could not register the MythicRod PlaceholderAPI expansion", e); + } + } + private void validateConfiguredParticles() { if (configManager == null) { return; diff --git a/mythicrod-paper/src/main/java/io/xcutiboo/mythicrod/paper/api/MythicRodServices.java b/mythicrod-paper/src/main/java/io/xcutiboo/mythicrod/paper/api/MythicRodServices.java index a5669b3..3219920 100644 --- a/mythicrod-paper/src/main/java/io/xcutiboo/mythicrod/paper/api/MythicRodServices.java +++ b/mythicrod-paper/src/main/java/io/xcutiboo/mythicrod/paper/api/MythicRodServices.java @@ -6,6 +6,7 @@ import org.bukkit.Server; import org.bukkit.plugin.RegisteredServiceProvider; import org.bukkit.plugin.ServicesManager; +import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import io.xcutiboo.mythicrod.api.MythicRodAPI; @@ -18,6 +19,7 @@ /// Lookups are valid after MythicRod has enabled and before it disables. A /// missing service means MythicRod is not installed, not enabled yet, or is /// shutting down. +@ApiStatus.AvailableSince("2026.1.0") public final class MythicRodServices { private MythicRodServices() { diff --git a/mythicrod-paper/src/main/java/io/xcutiboo/mythicrod/paper/api/PaperMythicRodAPI.java b/mythicrod-paper/src/main/java/io/xcutiboo/mythicrod/paper/api/PaperMythicRodAPI.java index c0e2c25..38757a3 100644 --- a/mythicrod-paper/src/main/java/io/xcutiboo/mythicrod/paper/api/PaperMythicRodAPI.java +++ b/mythicrod-paper/src/main/java/io/xcutiboo/mythicrod/paper/api/PaperMythicRodAPI.java @@ -18,14 +18,23 @@ import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; + import io.xcutiboo.mythicrod.api.ExternalDropProvider; import io.xcutiboo.mythicrod.api.MythicRodAPI; import io.xcutiboo.mythicrod.api.PlayerStatSnapshot; +import io.xcutiboo.mythicrod.api.Result; import io.xcutiboo.mythicrod.api.drop.DropCatalog; +import io.xcutiboo.mythicrod.api.platform.PlatformDrop; import io.xcutiboo.mythicrod.api.platform.PlatformItem; import io.xcutiboo.mythicrod.api.platform.PlatformItemFactory; import io.xcutiboo.mythicrod.api.platform.PlatformPlayer; import io.xcutiboo.mythicrod.api.platform.PlatformScheduler; +import io.xcutiboo.mythicrod.paper.item.RodFactory; +import io.xcutiboo.mythicrod.paper.platform.PaperItem; +import io.xcutiboo.mythicrod.paper.platform.PaperPlayer; import io.xcutiboo.mythicrod.drops.CustomDrop; import io.xcutiboo.mythicrod.drops.DropConfigurationRecord; import io.xcutiboo.mythicrod.drops.DropManager; @@ -62,6 +71,7 @@ public class PaperMythicRodAPI implements MythicRodAPI { private final StatisticsManager statisticsManager; private final PlatformScheduler scheduler; private final PlatformItemFactory itemFactory; + private final RodFactory rodFactory; private final ConcurrentHashMap externalProviders = new ConcurrentHashMap<>(); @@ -72,13 +82,15 @@ public PaperMythicRodAPI( @NotNull DropManager dropManager, @NotNull StatisticsManager statisticsManager, @NotNull PlatformScheduler scheduler, - @NotNull PlatformItemFactory itemFactory) { + @NotNull PlatformItemFactory itemFactory, + @NotNull RodFactory rodFactory) { this.version = version; this.logger = logger; this.dropManager = dropManager; this.statisticsManager = statisticsManager; this.scheduler = scheduler; this.itemFactory = itemFactory; + this.rodFactory = rodFactory; } /// Internal reward-selection result used by the Paper fishing pipeline. @@ -138,6 +150,35 @@ public List getExternalDropProviders() { return List.copyOf(externalProviders.values()); } + @Override + @NotNull + public Result createRod(@NotNull String tier) { + ItemStack rod = switch (tier.toLowerCase(Locale.ROOT)) { + case "basic" -> rodFactory.createBasicRod(); + case "advanced" -> rodFactory.createAdvancedRod(); + case "legendary" -> rodFactory.createLegendaryRod(); + case "mythic" -> rodFactory.createMythicRod(); + default -> null; + }; + if (rod == null) { + return Result.failure("Unknown rod tier '" + tier + + "'. Valid tiers: basic, advanced, legendary, mythic."); + } + return Result.success(new PaperItem(rod)); + } + + @Override + @NotNull + public List previewEligibleDrops( + @NotNull UUID playerId, + @Nullable String biomeKey) { + Player bukkitPlayer = Bukkit.getPlayer(playerId); + if (bukkitPlayer == null) { + return List.of(); + } + return List.copyOf(dropManager.getEligibleDrops(new PaperPlayer(bukkitPlayer), biomeKey)); + } + public double getBaseRewardWeight(@NotNull PlatformPlayer player, @Nullable String biomeName) { double totalWeight = 0.0D; @@ -448,7 +489,7 @@ private int tierToWeight(@Nullable String tier) { } /// Synchronous snapshot for the given player, suitable for event handlers - /// already running on the player's owner thread. Returns {@code null} when + /// already running on the player's owner thread. Returns `null` when /// no in-memory or on-disk entry exists. @Nullable public PlayerStatSnapshot snapshotFor(@NotNull UUID playerId) { diff --git a/mythicrod-paper/src/main/java/io/xcutiboo/mythicrod/paper/commands/BrigadierCommandManager.java b/mythicrod-paper/src/main/java/io/xcutiboo/mythicrod/paper/commands/BrigadierCommandManager.java index fb92fd9..542849d 100644 --- a/mythicrod-paper/src/main/java/io/xcutiboo/mythicrod/paper/commands/BrigadierCommandManager.java +++ b/mythicrod-paper/src/main/java/io/xcutiboo/mythicrod/paper/commands/BrigadierCommandManager.java @@ -69,6 +69,19 @@ public class BrigadierCommandManager { private static final String KEY_LIMIT = "limit"; private static final String KEY_CATEGORY = "category"; private static final String KEY_DROPS = "drops"; + private static final String ARG_IDENTIFIER = "identifier"; + private static final String ARG_WEIGHT = "weight"; + private static final String ARG_AMOUNT = "amount"; + private static final String ARG_FIELD = "field"; + private static final String ARG_VALUE = "value"; + private static final String ARG_BIOME = "biome"; + private static final String ARG_LOCALE = "locale"; + private static final String ARG_STATUS = "status"; + private static final String ARG_CATEGORIES = "categories"; + private static final String TIER_ADVANCED = "advanced"; + private static final String TR_GENERAL_ENABLED = "general.enabled"; + private static final String TR_GENERAL_DISABLED = "general.disabled"; + private static final String TR_DROP_NOT_FOUND = "command.drop.not-found"; private static final String TR_GENERAL_ERROR = "general.error"; private static final String TR_PLAYER_ONLY = "general.player_only"; private static final String KEY_ERROR = "error"; @@ -187,7 +200,7 @@ private LiteralCommandNode buildMythicRodCommand() { .executes(this::executeDrops) .then(Commands.literal("preview") .requires(source -> source.getSender().hasPermission(PermissionNodes.ADMIN_DEBUG)) - .then(Commands.argument("biome", StringArgumentType.greedyString()) + .then(Commands.argument(ARG_BIOME, StringArgumentType.greedyString()) .suggests(this::suggestBiomes) .executes(this::executeDropsPreview))) .then(Commands.argument(KEY_CATEGORY, StringArgumentType.word()) @@ -198,27 +211,32 @@ private LiteralCommandNode buildMythicRodCommand() { .then(Commands.literal("add") .then(Commands.argument(KEY_CATEGORY, StringArgumentType.word()) .suggests(this::suggestDropCategories) - .then(Commands.argument("identifier", StringArgumentType.string()) - .then(Commands.argument("weight", IntegerArgumentType.integer(1, 10000)) - .then(Commands.argument("amount", IntegerArgumentType.integer(1, 64)) + .then(Commands.argument(ARG_IDENTIFIER, StringArgumentType.string()) + .then(Commands.argument(ARG_WEIGHT, IntegerArgumentType.integer(1, 10000)) + .then(Commands.argument(ARG_AMOUNT, IntegerArgumentType.integer(1, 64)) .executes(this::executeDropAdd)))))) .then(Commands.literal("remove") .then(Commands.argument(KEY_CATEGORY, StringArgumentType.word()) .suggests(this::suggestDropCategories) - .then(Commands.argument("identifier", StringArgumentType.string()) + .then(Commands.argument(ARG_IDENTIFIER, StringArgumentType.string()) .executes(this::executeDropRemove)))) .then(Commands.literal("set") .then(Commands.argument(KEY_CATEGORY, StringArgumentType.word()) .suggests(this::suggestDropCategories) - .then(Commands.argument("identifier", StringArgumentType.string()) - .then(Commands.argument("field", StringArgumentType.word()) + .then(Commands.argument(ARG_IDENTIFIER, StringArgumentType.string()) + .then(Commands.argument(ARG_FIELD, StringArgumentType.word()) .suggests(this::suggestDropFields) - .then(Commands.argument("value", StringArgumentType.greedyString()) + .then(Commands.argument(ARG_VALUE, StringArgumentType.greedyString()) .executes(this::executeDropSet))))))) .then(Commands.literal("debug") .requires(source -> source.getSender().hasPermission(PermissionNodes.ADMIN_DEBUG)) .executes(this::executeDebug)) - .then(Commands.literal("status") + .then(Commands.literal("showcase") + .requires(source -> source.getSender().hasPermission(PermissionNodes.ADMIN_DEBUG)) + .then(Commands.argument("tier", StringArgumentType.word()) + .suggests(this::suggestRodTiers) + .executes(this::executeShowcase))) + .then(Commands.literal(ARG_STATUS) .requires(source -> source.getSender().hasPermission(PermissionNodes.ADMIN_DEBUG)) .executes(this::executeStatus)) .then(Commands.literal("validate") @@ -227,7 +245,7 @@ private LiteralCommandNode buildMythicRodCommand() { .then(Commands.literal("testroll") .requires(source -> source.getSender().hasPermission(PermissionNodes.ADMIN_DEBUG)) .executes(this::executeTestRoll) - .then(Commands.argument("biome", StringArgumentType.string()) + .then(Commands.argument(ARG_BIOME, StringArgumentType.string()) .suggests(this::suggestBiomes) .executes(this::executeTestRoll) .then(Commands.argument(KEY_COUNT, IntegerArgumentType.integer(1, 10000)) @@ -319,7 +337,7 @@ private LiteralArgumentBuilder buildConfigSubtree(String lit .suggests(this::suggestRewardDeliveryModes) .executes(this::executeDeliveryModeConfig))) .then(Commands.literal("language") - .then(Commands.argument("locale", StringArgumentType.word()) + .then(Commands.argument(ARG_LOCALE, StringArgumentType.word()) .suggests(this::suggestAvailableLanguages) .executes(this::executeLanguageConfig))) .then(Commands.literal("stats-save-interval") @@ -419,8 +437,8 @@ private int executeGive(CommandContext context) { private ItemStack buildRodForTier(String tier) { return switch (tier.toLowerCase(Locale.ROOT)) { - case "basic" -> rodFactory.createBasicRod(); - case "advanced" -> rodFactory.createAdvancedRod(); + case TIER_BASIC -> rodFactory.createBasicRod(); + case TIER_ADVANCED -> rodFactory.createAdvancedRod(); case TIER_LEGENDARY -> rodFactory.createLegendaryRod(); default -> null; }; @@ -527,12 +545,12 @@ private void sendConfigLine(CommandSender sender, String settingKey, String valu sendMessage(sender, tr(sender, "command.config.line", Map.of( "setting", tr(sender, settingKey), - "value", value + ARG_VALUE, value ))); } private String configStatus(CommandSender sender, boolean enabled) { - return tr(sender, enabled ? "general.enabled" : "general.disabled"); + return tr(sender, enabled ? TR_GENERAL_ENABLED : TR_GENERAL_DISABLED); } private int executeBooleanConfig( @@ -554,7 +572,7 @@ private int executeBooleanConfig( sendMessage(sender, tr(sender, "command.config.boolean-set", Map.of( "setting", tr(sender, settingKey), - "value", configStatus(sender, newValue) + ARG_VALUE, configStatus(sender, newValue) ))); playSuccessSound(sender); return Command.SINGLE_SUCCESS; @@ -608,13 +626,13 @@ private int executeLanguageConfig(CommandContext context) { CommandSender sender = context.getSource().getSender(); ConfigManager config = plugin.getConfigManager(); String previousLocale = config.getLanguage(); - String requested = StringArgumentType.getString(context, "locale"); + String requested = StringArgumentType.getString(context, ARG_LOCALE); List available = plugin.getLanguageManager().getAvailableLanguages(); if (!available.contains(requested) || !config.setLanguage(requested)) { sendMessage(sender, tr(sender, "command.config.invalid-language", Map.of( - "locale", requested, + ARG_LOCALE, requested, "available", String.join(", ", available) ))); playErrorSound(sender); @@ -631,13 +649,13 @@ private int executeLanguageConfig(CommandContext context) { plugin.getGUIManager().invalidateOpenMenusForReload(); } sendMessage(sender, tr(sender, "command.config.language-set", - Map.of("locale", requested))); + Map.of(ARG_LOCALE, requested))); playSuccessSound(sender); return Command.SINGLE_SUCCESS; } catch (IOException | RuntimeException e) { config.setLanguage(previousLocale); - sendMessage(sender, tr(sender, "command.config.save-failed", - Map.of("error", e.getMessage() != null ? e.getMessage() : e.getClass().getSimpleName()))); + sendMessage(sender, tr(sender, TR_CONFIG_SAVE_FAILED, + Map.of(KEY_ERROR, e.getMessage() != null ? e.getMessage() : e.getClass().getSimpleName()))); playErrorSound(sender); plugin.getLogger().log(Level.SEVERE, "Failed to update language", e); return 0; @@ -1073,7 +1091,7 @@ private void sendUnknownDropCategory(CommandSender sender, String category) { sendMessage(sender, tr(sender, "drops.category-not-found", Map.of(KEY_CATEGORY, category))); sendMessage(sender, tr(sender, "drops.available-categories", - Map.of("categories", String.join(", ", sortedDropCategoryIds())))); + Map.of(ARG_CATEGORIES, String.join(", ", sortedDropCategoryIds())))); sendMessage(sender, tr(sender, "drops.category-help")); } @@ -1095,8 +1113,8 @@ private void displayDropCategory(CommandSender sender, String category) { sendMessage(sender, tr(sender, "drops.drop-entry", Map.of( "name", name, - "weight", weight, - "amount", String.valueOf(drop.getAmount()) + ARG_WEIGHT, weight, + ARG_AMOUNT, String.valueOf(drop.getAmount()) ))); } } @@ -1135,8 +1153,8 @@ private CompletableFuture suggestRodTiers( @SuppressWarnings("unused") CommandContext context, SuggestionsBuilder builder ) { - builder.suggest("basic"); - builder.suggest("advanced"); + builder.suggest(TIER_BASIC); + builder.suggest(TIER_ADVANCED); builder.suggest(TIER_LEGENDARY); return builder.buildFuture(); } @@ -1544,7 +1562,7 @@ private int executeTestRoll(CommandContext context) { Player player = requirePlayer(sender); if (player == null) return 0; - String biomeArg = optionalStringArg(context, "biome"); + String biomeArg = optionalStringArg(context, ARG_BIOME); int count = Math.clamp(optionalIntArg(context, KEY_COUNT, 100), 1, 10000); String biome = resolveTestRollBiome(player, biomeArg); @@ -1604,7 +1622,7 @@ private CompletableFuture suggestDropFields( @SuppressWarnings("unused") CommandContext context, SuggestionsBuilder builder ) { - for (String f : List.of("weight", "amount", "permission", "glow", "name")) { + for (String f : List.of(ARG_WEIGHT, ARG_AMOUNT, "permission", "glow", "name")) { builder.suggest(f); } return builder.buildFuture(); @@ -1614,9 +1632,9 @@ private int executeDropAdd(CommandContext context) { CommandSender sender = context.getSource().getSender(); try { String category = StringArgumentType.getString(context, KEY_CATEGORY).toLowerCase(Locale.ROOT); - String identifier = StringArgumentType.getString(context, "identifier").trim(); - int weight = IntegerArgumentType.getInteger(context, "weight"); - int amount = IntegerArgumentType.getInteger(context, "amount"); + String identifier = StringArgumentType.getString(context, ARG_IDENTIFIER).trim(); + int weight = IntegerArgumentType.getInteger(context, ARG_WEIGHT); + int amount = IntegerArgumentType.getInteger(context, ARG_AMOUNT); io.xcutiboo.mythicrod.drops.EditableDropFields fields = new io.xcutiboo.mythicrod.drops.EditableDropFields( @@ -1626,13 +1644,13 @@ private int executeDropAdd(CommandContext context) { CustomDrop drop = plugin.getDropManager().addDrop(category, fields); if (drop == null) { sendMessage(sender, tr(sender, "command.drop.invalid", - Map.of("identifier", identifier))); + Map.of(ARG_IDENTIFIER, identifier))); playErrorSound(sender); return 0; } plugin.getDropManager().saveDrops(); sendMessage(sender, tr(sender, "command.drop.added", - Map.of("identifier", identifier, "category", category))); + Map.of(ARG_IDENTIFIER, identifier, KEY_CATEGORY, category))); playSuccessSound(sender); return Command.SINGLE_SUCCESS; } catch (RuntimeException e) { @@ -1647,24 +1665,24 @@ private int executeDropRemove(CommandContext context) { CommandSender sender = context.getSource().getSender(); try { String category = StringArgumentType.getString(context, KEY_CATEGORY).toLowerCase(Locale.ROOT); - String identifier = StringArgumentType.getString(context, "identifier").trim(); + String identifier = StringArgumentType.getString(context, ARG_IDENTIFIER).trim(); CustomDrop target = findDropInCategory(category, identifier); if (target == null) { - sendMessage(sender, tr(sender, "command.drop.not-found", - Map.of("identifier", identifier, "category", category))); + sendMessage(sender, tr(sender, TR_DROP_NOT_FOUND, + Map.of(ARG_IDENTIFIER, identifier, KEY_CATEGORY, category))); playErrorSound(sender); return 0; } boolean removed = plugin.getDropManager().deleteDrop(target, category); if (!removed) { - sendMessage(sender, tr(sender, "command.drop.not-found", - Map.of("identifier", identifier, "category", category))); + sendMessage(sender, tr(sender, TR_DROP_NOT_FOUND, + Map.of(ARG_IDENTIFIER, identifier, KEY_CATEGORY, category))); playErrorSound(sender); return 0; } plugin.getDropManager().saveDrops(); sendMessage(sender, tr(sender, "command.drop.removed", - Map.of("identifier", identifier, "category", category))); + Map.of(ARG_IDENTIFIER, identifier, KEY_CATEGORY, category))); playSuccessSound(sender); return Command.SINGLE_SUCCESS; } catch (RuntimeException e) { @@ -1679,14 +1697,14 @@ private int executeDropSet(CommandContext context) { CommandSender sender = context.getSource().getSender(); try { String category = StringArgumentType.getString(context, KEY_CATEGORY).toLowerCase(Locale.ROOT); - String identifier = StringArgumentType.getString(context, "identifier").trim(); - String field = StringArgumentType.getString(context, "field").toLowerCase(Locale.ROOT); - String value = StringArgumentType.getString(context, "value"); + String identifier = StringArgumentType.getString(context, ARG_IDENTIFIER).trim(); + String field = StringArgumentType.getString(context, ARG_FIELD).toLowerCase(Locale.ROOT); + String value = StringArgumentType.getString(context, ARG_VALUE); CustomDrop target = findDropInCategory(category, identifier); if (target == null) { - sendMessage(sender, tr(sender, "command.drop.not-found", - Map.of("identifier", identifier, "category", category))); + sendMessage(sender, tr(sender, TR_DROP_NOT_FOUND, + Map.of(ARG_IDENTIFIER, identifier, KEY_CATEGORY, category))); playErrorSound(sender); return 0; } @@ -1699,21 +1717,21 @@ private int executeDropSet(CommandContext context) { try { switch (field) { - case "weight" -> weight = Integer.parseInt(value.trim()); - case "amount" -> amount = Integer.parseInt(value.trim()); + case ARG_WEIGHT -> weight = Integer.parseInt(value.trim()); + case ARG_AMOUNT -> amount = Integer.parseInt(value.trim()); case "name" -> customName = value; case "permission" -> permission = value.isBlank() ? null : value.trim(); case "glow" -> glow = Boolean.parseBoolean(value.trim()); default -> { sendMessage(sender, tr(sender, "command.drop.unknown-field", - Map.of("field", field))); + Map.of(ARG_FIELD, field))); playErrorSound(sender); return 0; } } } catch (NumberFormatException _) { sendMessage(sender, tr(sender, "command.drop.bad-value", - Map.of("field", field, "value", value))); + Map.of(ARG_FIELD, field, ARG_VALUE, value))); playErrorSound(sender); return 0; } @@ -1727,14 +1745,14 @@ private int executeDropSet(CommandContext context) { ); boolean updated = plugin.getDropManager().updateDrop(target, category, next); if (!updated) { - sendMessage(sender, tr(sender, "command.drop.not-found", - Map.of("identifier", identifier, "category", category))); + sendMessage(sender, tr(sender, TR_DROP_NOT_FOUND, + Map.of(ARG_IDENTIFIER, identifier, KEY_CATEGORY, category))); playErrorSound(sender); return 0; } plugin.getDropManager().saveDrops(); sendMessage(sender, tr(sender, "command.drop.updated", - Map.of("identifier", identifier, "field", field, "value", value))); + Map.of(ARG_IDENTIFIER, identifier, ARG_FIELD, field, ARG_VALUE, value))); playSuccessSound(sender); return Command.SINGLE_SUCCESS; } catch (RuntimeException e) { @@ -1762,7 +1780,7 @@ private int executeRodSelect(CommandContext context) { String tier = StringArgumentType.getString(context, "tier").toLowerCase(Locale.ROOT); String permission = switch (tier) { case TIER_BASIC -> PermissionNodes.GUI; - case "advanced" -> PermissionNodes.ROD_ADVANCED; + case TIER_ADVANCED -> PermissionNodes.ROD_ADVANCED; case TIER_LEGENDARY -> PermissionNodes.ROD_LEGENDARY; default -> null; }; @@ -1904,19 +1922,19 @@ private int executeEffectsSet(CommandContext context) { private int executeDropsPreview(CommandContext context) { CommandSender sender = context.getSource().getSender(); try { - String biome = StringArgumentType.getString(context, "biome").trim(); + String biome = StringArgumentType.getString(context, ARG_BIOME).trim(); NamespacedKey biomeKey = parseRegistryKey(biome); if (biomeKey == null || RegistryAccess.registryAccess().getRegistry(RegistryKey.BIOME).get(biomeKey) == null) { sendMessage(sender, tr(sender, "command.drops-preview.invalid-biome", - Map.of("biome", biome))); + Map.of(ARG_BIOME, biome))); playErrorSound(sender); return 0; } String biomeId = biomeKey.asString(); sendMessage(sender, tr(sender, "command.drops-preview.header", - Map.of("biome", biomeId))); + Map.of(ARG_BIOME, biomeId))); int rows = 0; long totalWeight = 0L; @@ -1930,18 +1948,10 @@ private int executeDropsPreview(CommandContext context) { && plugin.getConfigManager().usePermissions(); for (Map.Entry> entry : categories.entrySet()) { for (CustomDrop drop : entry.getValue()) { - List biomes = drop.getBiomes(); - if (biomes != null && !biomes.isEmpty() && !biomes.contains(biomeId)) { - continue; + if (isEligibleForPreview(sender, drop, biomeId, filterByPermission)) { + eligible.add(new EligibleDrop(entry.getKey(), drop)); + totalWeight += Math.max(0, drop.getWeight()); } - if (filterByPermission) { - String required = drop.getPermission(); - if (required != null && !required.isEmpty() && !sender.hasPermission(required)) { - continue; - } - } - eligible.add(new EligibleDrop(entry.getKey(), drop)); - totalWeight += Math.max(0, drop.getWeight()); } } eligible.sort(Comparator.comparingInt((EligibleDrop e) -> e.drop().getWeight()).reversed()); @@ -1949,15 +1959,15 @@ private int executeDropsPreview(CommandContext context) { for (EligibleDrop entry : eligible) { if (rows++ >= 12) { sendMessage(sender, tr(sender, "command.drops-preview.truncated", - Map.of("count", String.valueOf(eligible.size() - 12)))); + Map.of(KEY_COUNT, String.valueOf(eligible.size() - 12)))); break; } double share = totalWeight == 0 ? 0.0 : (entry.drop().getWeight() * 100.0 / totalWeight); sendMessage(sender, tr(sender, "command.drops-preview.row", Map.of( - "category", entry.category(), - "identifier", entry.drop().getIdentifier(), - "weight", String.valueOf(entry.drop().getWeight()), + KEY_CATEGORY, entry.category(), + ARG_IDENTIFIER, entry.drop().getIdentifier(), + ARG_WEIGHT, String.valueOf(entry.drop().getWeight()), "share", String.format(Locale.ROOT, "%.1f", share) ))); } @@ -1966,8 +1976,8 @@ private int executeDropsPreview(CommandContext context) { } else { sendMessage(sender, tr(sender, "command.drops-preview.footer", Map.of( - "count", String.valueOf(eligible.size()), - "weight", String.valueOf(totalWeight)))); + KEY_COUNT, String.valueOf(eligible.size()), + ARG_WEIGHT, String.valueOf(totalWeight)))); } playSuccessSound(sender); return Command.SINGLE_SUCCESS; @@ -1981,6 +1991,24 @@ private int executeDropsPreview(CommandContext context) { private record EligibleDrop(String category, CustomDrop drop) {} + private static boolean isEligibleForPreview( + CommandSender sender, + CustomDrop drop, + String biomeId, + boolean filterByPermission) { + List biomes = drop.getBiomes(); + if (biomes != null && !biomes.isEmpty() && !biomes.contains(biomeId)) { + return false; + } + if (filterByPermission) { + String required = drop.getPermission(); + if (required != null && !required.isEmpty() && !sender.hasPermission(required)) { + return false; + } + } + return true; + } + private int executeStatus(CommandContext context) { CommandSender sender = context.getSource().getSender(); try { @@ -2005,9 +2033,16 @@ private int executeStatus(CommandContext context) { : "en_US"; boolean nexoOn = plugin.getPlatformServer() != null && plugin.getPlatformServer().isNexoEnabled(); + boolean papiOn = Bukkit.getPluginManager().getPlugin("PlaceholderAPI") != null; int trackedPlayers = plugin.getStatisticsManager() != null ? plugin.getStatisticsManager().getAllStats().size() : 0; + long totalCatches = plugin.getStatisticsManager() != null + ? plugin.getStatisticsManager().getTotalCatches() + : 0L; + int externalProviders = plugin.getApiFacade() != null + ? plugin.getApiFacade().getExternalDropProviders().size() + : 0; sendMessage(sender, tr(sender, "command.status.version", Map.of("version", version))); @@ -2015,17 +2050,23 @@ private int executeStatus(CommandContext context) { Map.of("mode", mode, "minecraft", paperVersion))); sendMessage(sender, tr(sender, "command.status.drops", Map.of( - "categories", String.valueOf(categoryCount), - "drops", String.valueOf(dropCount)))); + ARG_CATEGORIES, String.valueOf(categoryCount), + KEY_DROPS, String.valueOf(dropCount)))); sendMessage(sender, tr(sender, "command.status.language", Map.of( "active", activeLocale, "loaded", String.valueOf(locales.size()), "list", String.join(", ", locales)))); sendMessage(sender, tr(sender, "command.status.nexo", - Map.of("status", tr(sender, nexoOn ? "general.enabled" : "general.disabled")))); + Map.of(ARG_STATUS, tr(sender, nexoOn ? TR_GENERAL_ENABLED : TR_GENERAL_DISABLED)))); + sendMessage(sender, tr(sender, "command.status.placeholderapi", + Map.of(ARG_STATUS, tr(sender, papiOn ? TR_GENERAL_ENABLED : TR_GENERAL_DISABLED)))); + sendMessage(sender, tr(sender, "command.status.providers", + Map.of(KEY_COUNT, String.valueOf(externalProviders)))); sendMessage(sender, tr(sender, "command.status.stats", Map.of("players", String.valueOf(trackedPlayers)))); + sendMessage(sender, tr(sender, "command.status.catches", + Map.of(KEY_COUNT, String.valueOf(totalCatches)))); return Command.SINGLE_SUCCESS; } catch (RuntimeException e) { sendMessage(sender, tr(sender, TR_GENERAL_ERROR)); @@ -2035,6 +2076,25 @@ private int executeStatus(CommandContext context) { } } + private int executeShowcase(CommandContext context) { + CommandSender sender = context.getSource().getSender(); + Player player = requirePlayer(sender); + if (player == null) return 0; + String tier = StringArgumentType.getString(context, "tier").toLowerCase(Locale.ROOT); + switch (tier) { + case TIER_BASIC, TIER_ADVANCED, "rare", "uncommon", "common", + TIER_LEGENDARY, "mythic" -> { /* allowed */ } + default -> { + sendMessage(sender, tr(sender, "command.effects.invalid", Map.of("mode", tier))); + playErrorSound(sender); + return 0; + } + } + io.xcutiboo.mythicrod.paper.util.TierVisualEffects.playCatch(plugin, player, tier); + sendMessage(sender, tr(sender, "command.drops-preview.header", Map.of(ARG_BIOME, tier))); + return Command.SINGLE_SUCCESS; + } + private int executeDebug(CommandContext context) { try { CommandSender sender = context.getSource().getSender(); @@ -2055,13 +2115,13 @@ private int executeDebug(CommandContext context) { : 0L; sendMessage(sender, tr(sender, "command.debug.runtime", Map.of( - "categories", String.valueOf(categoryCount), + ARG_CATEGORIES, String.valueOf(categoryCount), KEY_DROPS, String.valueOf(dropCount), "players", String.valueOf(trackedPlayers), "catches", String.valueOf(totalCatches) ))); sendMessage(sender, tr(sender, "command.debug.folia-support", - Map.of("status", tr(sender, plugin.isFoliaRuntime() ? "general.enabled" : "general.disabled")))); + Map.of(ARG_STATUS, tr(sender, plugin.isFoliaRuntime() ? TR_GENERAL_ENABLED : TR_GENERAL_DISABLED)))); return Command.SINGLE_SUCCESS; } catch (Exception e) { diff --git a/mythicrod-paper/src/main/java/io/xcutiboo/mythicrod/paper/events/MythicRodBiteEvent.java b/mythicrod-paper/src/main/java/io/xcutiboo/mythicrod/paper/events/MythicRodBiteEvent.java new file mode 100644 index 0000000..46f9e0a --- /dev/null +++ b/mythicrod-paper/src/main/java/io/xcutiboo/mythicrod/paper/events/MythicRodBiteEvent.java @@ -0,0 +1,87 @@ +package io.xcutiboo.mythicrod.paper.events; + +import org.bukkit.entity.FishHook; +import org.bukkit.entity.Player; +import org.bukkit.event.Cancellable; +import org.bukkit.event.Event; +import org.bukkit.event.HandlerList; +import org.bukkit.event.player.PlayerFishEvent; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; + +/// Fires when a fish bites a MythicRod hook, before the catch resolves. +/// +/// Mirrors the vanilla `PlayerFishEvent` with state `BITE`, but only for +/// rods MythicRod recognises. External plugins use this to trigger a +/// skill-check minigame, play a custom bite cue, or short-circuit the +/// catch when the player isn't holding the right item. +/// +/// Cancelling this event cancels the underlying `PlayerFishEvent`, so the +/// fish drops the hook and the catch never resolves. The vanilla bobber +/// animation already played by the time the event fires; cancelling does +/// not roll that back. +/// +/// ## Thread contract +/// +/// Fired on the same thread that delivered the underlying +/// `PlayerFishEvent` — main on Paper, the player's region thread on +/// Folia. Treat it as a normal Bukkit event handler thread. +@ApiStatus.AvailableSince("2026.1.0") +public final class MythicRodBiteEvent extends Event implements Cancellable { + + private static final HandlerList HANDLERS = new HandlerList(); + + private final Player player; + private final FishHook hook; + private final PlayerFishEvent source; + private boolean cancelled; + + public MythicRodBiteEvent(@NotNull Player player, + @NotNull FishHook hook, + @NotNull PlayerFishEvent source) { + this.player = player; + this.hook = hook; + this.source = source; + } + + /// The player whose rod the fish bit. + @NotNull + public Player getPlayer() { + return player; + } + + /// The fishing hook entity. Use this to inspect the hook location, the + /// hooked entity (if any), or to apply visual cues at the bobber. + @NotNull + public FishHook getHook() { + return hook; + } + + /// The underlying Bukkit event. Use this only when you need fields the + /// MythicRod event does not expose. Calling `setCancelled(true)` on the + /// source has the same effect as cancelling this event. + @NotNull + public PlayerFishEvent getSourceEvent() { + return source; + } + + @Override + public boolean isCancelled() { + return cancelled; + } + + @Override + public void setCancelled(boolean cancel) { + this.cancelled = cancel; + source.setCancelled(cancel); + } + + @Override + public @NotNull HandlerList getHandlers() { + return HANDLERS; + } + + public static @NotNull HandlerList getHandlerList() { + return HANDLERS; + } +} diff --git a/mythicrod-paper/src/main/java/io/xcutiboo/mythicrod/paper/events/MythicRodFishCatchEvent.java b/mythicrod-paper/src/main/java/io/xcutiboo/mythicrod/paper/events/MythicRodFishCatchEvent.java index 8521a62..ee0874a 100644 --- a/mythicrod-paper/src/main/java/io/xcutiboo/mythicrod/paper/events/MythicRodFishCatchEvent.java +++ b/mythicrod-paper/src/main/java/io/xcutiboo/mythicrod/paper/events/MythicRodFishCatchEvent.java @@ -7,6 +7,7 @@ import org.bukkit.event.Event; import org.bukkit.event.HandlerList; import org.bukkit.inventory.ItemStack; +import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import io.xcutiboo.mythicrod.api.platform.PlatformDrop; @@ -42,6 +43,7 @@ /// } /// } /// ``` +@ApiStatus.AvailableSince("2026.1.0") public final class MythicRodFishCatchEvent extends Event implements Cancellable { private static final HandlerList HANDLER_LIST = new HandlerList(); @@ -72,8 +74,11 @@ public Player getPlayer() { return player; } - /// Returns the selected reward descriptor. + /// Returns the selected reward descriptor as the internal `CustomDrop` + /// type. Experimental: the internal representation may change. Use + /// `getDropView()` when you only need stable read-only metadata. @NotNull + @ApiStatus.Experimental public CustomDrop getDrop() { return drop; } diff --git a/mythicrod-paper/src/main/java/io/xcutiboo/mythicrod/paper/events/MythicRodReloadEvent.java b/mythicrod-paper/src/main/java/io/xcutiboo/mythicrod/paper/events/MythicRodReloadEvent.java index 86378cd..5253ab7 100644 --- a/mythicrod-paper/src/main/java/io/xcutiboo/mythicrod/paper/events/MythicRodReloadEvent.java +++ b/mythicrod-paper/src/main/java/io/xcutiboo/mythicrod/paper/events/MythicRodReloadEvent.java @@ -2,6 +2,7 @@ import org.bukkit.event.Event; import org.bukkit.event.HandlerList; +import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; /// Fires once MythicRod has finished an atomic configuration and drop-table @@ -18,6 +19,7 @@ /// /// The event is not cancellable. Reloads always complete before the event /// fires, so cancelling would have no meaningful effect. +@ApiStatus.AvailableSince("2026.1.0") public final class MythicRodReloadEvent extends Event { private static final HandlerList HANDLERS = new HandlerList(); diff --git a/mythicrod-paper/src/main/java/io/xcutiboo/mythicrod/paper/events/MythicRodRewardRollEvent.java b/mythicrod-paper/src/main/java/io/xcutiboo/mythicrod/paper/events/MythicRodRewardRollEvent.java index 2700a25..647a421 100644 --- a/mythicrod-paper/src/main/java/io/xcutiboo/mythicrod/paper/events/MythicRodRewardRollEvent.java +++ b/mythicrod-paper/src/main/java/io/xcutiboo/mythicrod/paper/events/MythicRodRewardRollEvent.java @@ -5,6 +5,7 @@ import org.bukkit.entity.Player; import org.bukkit.event.Event; import org.bukkit.event.HandlerList; +import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -39,6 +40,7 @@ /// Fired from the same player-owned execution path as the fishing event. On /// ordinary Paper this is the synchronous event thread. On Folia this is the /// owning region thread. +@ApiStatus.AvailableSince("2026.1.0") public final class MythicRodRewardRollEvent extends Event { private static final HandlerList HANDLER_LIST = new HandlerList(); @@ -108,13 +110,26 @@ public void setLuckMultiplier(double multiplier) { /// /// MythicRod will use this drop if it is non-null after the event completes. /// + /// This entry point takes the internal `CustomDrop` type and is therefore + /// considered experimental. The signature may change when the internal + /// drop representation is refactored. Prefer `setLuckMultiplier(double)` + /// or the post-roll `MythicRodFishCatchEvent#setRewardItem(ItemStack)` + /// path when you only need to influence the reward and do not need to + /// pin a specific configured drop. + /// /// @param drop drop to force, or `null` to clear any forced drop + @ApiStatus.Experimental public void forceDrop(@Nullable CustomDrop drop) { this.forcedDrop = drop; } + /// Returns the forced drop as the internal `CustomDrop` type. Experimental + /// because it exposes the internal representation; for stable inspection, + /// use `getForcedDropView()`. + /// /// @return forced drop set by an external plugin, or `null` when normal selection should proceed @Nullable + @ApiStatus.Experimental public CustomDrop getForcedDrop() { return forcedDrop; } diff --git a/mythicrod-paper/src/main/java/io/xcutiboo/mythicrod/paper/events/MythicRodStatsUpdateEvent.java b/mythicrod-paper/src/main/java/io/xcutiboo/mythicrod/paper/events/MythicRodStatsUpdateEvent.java index 50c93c0..fb43c51 100644 --- a/mythicrod-paper/src/main/java/io/xcutiboo/mythicrod/paper/events/MythicRodStatsUpdateEvent.java +++ b/mythicrod-paper/src/main/java/io/xcutiboo/mythicrod/paper/events/MythicRodStatsUpdateEvent.java @@ -5,6 +5,7 @@ import org.bukkit.event.Event; import org.bukkit.event.HandlerList; +import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import io.xcutiboo.mythicrod.api.PlayerStatSnapshot; @@ -21,6 +22,7 @@ /// event thread on ordinary Paper and the player's region thread on Folia. /// Asynchronous work scheduled from a handler must be re-dispatched to the /// correct owner before touching world or inventory state. +@ApiStatus.AvailableSince("2026.1.0") public final class MythicRodStatsUpdateEvent extends Event { private static final HandlerList HANDLER_LIST = new HandlerList(); @@ -45,7 +47,7 @@ public UUID getPlayerId() { return playerId; } - /// Rarity tier that was incremented: one of {@code common}, {@code uncommon}, {@code rare}, {@code legendary}. + /// Rarity tier that was incremented: one of `common`, `uncommon`, `rare`, `legendary`. @NotNull public String getTier() { return tier; diff --git a/mythicrod-paper/src/main/java/io/xcutiboo/mythicrod/paper/fishing/FishingListener.java b/mythicrod-paper/src/main/java/io/xcutiboo/mythicrod/paper/fishing/FishingListener.java index 5abe950..8f12225 100644 --- a/mythicrod-paper/src/main/java/io/xcutiboo/mythicrod/paper/fishing/FishingListener.java +++ b/mythicrod-paper/src/main/java/io/xcutiboo/mythicrod/paper/fishing/FishingListener.java @@ -58,6 +58,7 @@ public class FishingListener implements Listener { private static final String TIER_BASIC = "basic"; private static final String TIER_ADVANCED = "advanced"; private static final String TIER_LEGENDARY = "legendary"; + private static final String TIER_MYTHIC = "mythic"; private final MythicRod plugin; private final RodFactory rodFactory; @@ -70,12 +71,33 @@ public FishingListener(MythicRod plugin) { @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) public void onPlayerFish(PlayerFishEvent event) { try { + if (event.getState() == PlayerFishEvent.State.BITE) { + dispatchBiteEvent(event); + return; + } handlePlayerFish(event); } catch (RuntimeException e) { plugin.getLogger().log(Level.SEVERE, "Error processing fishing catch event", e); } } + /// Republishes the Bukkit BITE state as a MythicRod-typed event so + /// external plugins can react to the bite without registering their own + /// PlayerFishEvent handler. Only fires for MythicRod-tagged rods. + private void dispatchBiteEvent(PlayerFishEvent event) { + Player player = event.getPlayer(); + if (player == null) return; + if (!(event.getHook() instanceof org.bukkit.entity.FishHook hook)) return; + ItemStack rodItem = player.getInventory().getItemInMainHand(); + if (!rodFactory.isCustomRod(rodItem)) { + ItemStack offHand = player.getInventory().getItemInOffHand(); + if (!rodFactory.isCustomRod(offHand)) return; + } + io.xcutiboo.mythicrod.paper.events.MythicRodBiteEvent biteEvent = + new io.xcutiboo.mythicrod.paper.events.MythicRodBiteEvent(player, hook, event); + plugin.getServer().getPluginManager().callEvent(biteEvent); + } + private void handlePlayerFish(PlayerFishEvent event) { if (event.getState() != PlayerFishEvent.State.CAUGHT_FISH) return; Player player = event.getPlayer(); @@ -203,6 +225,7 @@ private boolean isAllowedRodTier(Player player, String tier) { return switch (normalizeRodTier(tier)) { case TIER_ADVANCED -> player != null && player.hasPermission(PermissionNodes.ROD_ADVANCED); case TIER_LEGENDARY -> player != null && player.hasPermission(PermissionNodes.ROD_LEGENDARY); + case TIER_MYTHIC -> player != null && player.hasPermission(PermissionNodes.ROD_MYTHIC); case TIER_BASIC -> true; default -> false; }; @@ -413,7 +436,6 @@ private void removeCaughtItemThen(Player player, Item caughtItem, Runnable conti } } - @SuppressWarnings("unused") private Consumer foliaTask(Runnable action) { return task -> action.run(); } @@ -669,11 +691,13 @@ private void spawnCatchEffects(Player player, Location hookLocation, CustomDrop int weight = drop.getWeight(); if (shouldShowParticles(player)) { - spawnRarityParticles(player, effectLocation, weight); + spawnHookSplash(player, effectLocation); + io.xcutiboo.mythicrod.paper.util.TierVisualEffects.playCatch(plugin, player, drop.getTier()); } if (plugin.getConfigManager().useSounds()) { - playRaritySounds(player, effectLocation, weight); + player.playSound(effectLocation, Sound.ENTITY_FISHING_BOBBER_SPLASH, + SoundCategory.PLAYERS, 0.8F, 1.0F); } if (debugMode) { @@ -689,83 +713,16 @@ private boolean shouldShowParticles(Player player) { || !plugin.getPlayerDataService().hasReducedEffects(player)); } - private void spawnRarityParticles(Player player, Location hookLocation, int weight) { - spawnParticle(player, hookLocation, plugin.getConfigManager().getCatchParticle(), Particle.SPLASH, 30, 0.3D, 0.15D); - spawnParticle(player, hookLocation.clone().add(0.0D, 0.3D, 0.0D), plugin.getConfigManager().getBubbleParticle(), Particle.BUBBLE_POP, 15, 0.2D, 0.05D); - - if (weight <= 1) { - Location playerLoc = player.getLocation().add(0.0D, 2.0D, 0.0D); - player.spawnParticle(Particle.TOTEM_OF_UNDYING, playerLoc, 50, 1.0D, 0.5D, 1.0D, 0.3D); - player.spawnParticle(Particle.END_ROD, playerLoc, 30, 0.8D, 0.3D, 0.8D, 0.1D); - player.spawnParticle(Particle.HAPPY_VILLAGER, playerLoc, 20, 0.5D, 0.3D, 0.5D, 0.1D); - } else if (weight <= 5) { - Location playerLoc = player.getLocation().add(0.0D, 1.5D, 0.0D); - player.spawnParticle(Particle.HAPPY_VILLAGER, playerLoc, 15, 0.4D, 0.3D, 0.4D, 0.05D); - player.spawnParticle(Particle.END_ROD, hookLocation, 10, 0.3D, 0.3D, 0.3D, 0.05D); - } else if (weight <= 15) { - spawnParticle(player, player.getLocation().add(0.0D, 1.5D, 0.0D), plugin.getConfigManager().getSuccessParticle(), Particle.HAPPY_VILLAGER, 10, 0.35D, 0.04D); - } else { - spawnParticle(player, player.getLocation().add(0.0D, 1.5D, 0.0D), plugin.getConfigManager().getSuccessParticle(), Particle.HAPPY_VILLAGER, 5, 0.3D, 0.02D); - } - } - - private void playRaritySounds(Player player, Location hookLocation, int weight) { - player.playSound(hookLocation, Sound.ENTITY_FISHING_BOBBER_SPLASH, SoundCategory.PLAYERS, 0.8F, 1.0F); - player.playSound(hookLocation, Sound.ENTITY_FISHING_BOBBER_RETRIEVE, SoundCategory.PLAYERS, 0.6F, 1.1F); - - if (weight <= 1) { - playLegendarySounds(player, hookLocation); - } else if (weight <= 5) { - playRareSounds(player, hookLocation); - } else if (weight <= 15) { - playUncommonSounds(player, hookLocation); - } - } - - private void playLegendarySounds(Player player, Location hookLocation) { - player.playSound(hookLocation, Sound.BLOCK_BEACON_POWER_SELECT, SoundCategory.PLAYERS, 0.7F, 1.5F); - player.playSound(hookLocation, Sound.ENTITY_ENDER_DRAGON_GROWL, SoundCategory.PLAYERS, 0.3F, 1.8F); - - plugin.getPlatformScheduler().runForPlayerDelayed( - new PaperPlayer(player), - () -> { - if (player.isOnline()) { - player.playSound(player, Sound.UI_TOAST_CHALLENGE_COMPLETE, SoundCategory.PLAYERS, 0.8F, 1.0F); - player.playSound(player, Sound.ENTITY_PLAYER_LEVELUP, SoundCategory.PLAYERS, 0.6F, 1.2F); - player.playSound(player, Sound.BLOCK_NOTE_BLOCK_CHIME, SoundCategory.PLAYERS, 0.5F, 2.0F); - } - }, - 5L - ); - } - - private void playRareSounds(Player player, Location hookLocation) { - player.playSound(hookLocation, Sound.BLOCK_NOTE_BLOCK_PLING, SoundCategory.PLAYERS, 0.6F, 2.0F); - - plugin.getPlatformScheduler().runForPlayerDelayed( - new PaperPlayer(player), - () -> { - if (player.isOnline()) { - player.playSound(player, Sound.ENTITY_PLAYER_LEVELUP, SoundCategory.PLAYERS, 0.5F, 1.5F); - player.playSound(player, Sound.BLOCK_NOTE_BLOCK_BELL, SoundCategory.PLAYERS, 0.4F, 1.5F); - } - }, - 4L - ); - } - - private void playUncommonSounds(Player player, Location hookLocation) { - player.playSound(hookLocation, Sound.BLOCK_NOTE_BLOCK_PLING, SoundCategory.PLAYERS, 0.4F, 1.8F); - - plugin.getPlatformScheduler().runForPlayerDelayed( - new PaperPlayer(player), - () -> { - if (player.isOnline()) { - player.playSound(player, Sound.ENTITY_EXPERIENCE_ORB_PICKUP, SoundCategory.PLAYERS, 0.4F, 1.2F); - } - }, - 3L - ); + /// Always-on splash + bubble cue at the bobber location. The + /// tier-specific feedback (color, helix, tier sound) is owned by + /// TierVisualEffects so the two cues never compete. + private void spawnHookSplash(Player player, Location hookLocation) { + spawnParticle(player, hookLocation, + plugin.getConfigManager().getCatchParticle(), Particle.SPLASH, + 30, 0.3D, 0.15D); + spawnParticle(player, hookLocation.clone().add(0.0D, 0.3D, 0.0D), + plugin.getConfigManager().getBubbleParticle(), Particle.BUBBLE_POP, + 15, 0.2D, 0.05D); } private void spawnParticle(Player player, Location location, String particleName, Particle fallback, int count, double offset, double extra) { diff --git a/mythicrod-paper/src/main/java/io/xcutiboo/mythicrod/paper/gui/menus/ConfigMenu.java b/mythicrod-paper/src/main/java/io/xcutiboo/mythicrod/paper/gui/menus/ConfigMenu.java index 61a2ff2..edfe2d4 100644 --- a/mythicrod-paper/src/main/java/io/xcutiboo/mythicrod/paper/gui/menus/ConfigMenu.java +++ b/mythicrod-paper/src/main/java/io/xcutiboo/mythicrod/paper/gui/menus/ConfigMenu.java @@ -7,6 +7,7 @@ import org.bukkit.Material; import org.bukkit.entity.Player; +import org.bukkit.event.inventory.InventoryClickEvent; import org.bukkit.inventory.ItemStack; import io.xcutiboo.mythicrod.paper.MythicRod; @@ -158,7 +159,7 @@ private void placeParticleSettings() { }); } - private void cycleParticleForEvent(org.bukkit.event.inventory.InventoryClickEvent event) { + private void cycleParticleForEvent(InventoryClickEvent event) { boolean shift = event.isShiftClick(); boolean right = event.isRightClick(); if (shift && !right) { @@ -281,7 +282,7 @@ private String intervalCadenceLine(int seconds) { return tr("gui.config.save_interval.infrequent"); } - private int nextStatsInterval(int current, org.bukkit.event.inventory.InventoryClickEvent event) { + private int nextStatsInterval(int current, InventoryClickEvent event) { int change = event.isShiftClick() ? 300 : 60; if (event.isLeftClick()) return Math.min(3600, current + change); if (event.isRightClick()) return Math.max(60, current - change); diff --git a/mythicrod-paper/src/main/java/io/xcutiboo/mythicrod/paper/gui/menus/EditDropMenu.java b/mythicrod-paper/src/main/java/io/xcutiboo/mythicrod/paper/gui/menus/EditDropMenu.java index 6e98a7c..795dca6 100644 --- a/mythicrod-paper/src/main/java/io/xcutiboo/mythicrod/paper/gui/menus/EditDropMenu.java +++ b/mythicrod-paper/src/main/java/io/xcutiboo/mythicrod/paper/gui/menus/EditDropMenu.java @@ -35,16 +35,15 @@ /// GUI menu for editing individual drop properties in-game. /// /// Context keys (set via {@link #setContext(Map)} before {@link #open()}): -///
    -///
  • {@code "drop"} - {@link CustomDrop} to edit (required)
  • -///
  • {@code "category"} - {@link String} category name (required)
  • -///
+/// - `"drop"` - {@link CustomDrop} to edit (required) +/// - `"category"` - {@link String} category name (required) /// /// Usage: -///
{@code
+///
+/// ```java
 /// plugin.getGUIManager().openMenu(player, "editdrop",
 ///     Map.of("drop", drop, "category", category));
-/// }
+/// ``` public class EditDropMenu extends BaseMenu { private static final String CTX_AMOUNT = "amount"; diff --git a/mythicrod-paper/src/main/java/io/xcutiboo/mythicrod/paper/gui/menus/LanguageSwitchMenu.java b/mythicrod-paper/src/main/java/io/xcutiboo/mythicrod/paper/gui/menus/LanguageSwitchMenu.java index 02f0266..4d7e840 100644 --- a/mythicrod-paper/src/main/java/io/xcutiboo/mythicrod/paper/gui/menus/LanguageSwitchMenu.java +++ b/mythicrod-paper/src/main/java/io/xcutiboo/mythicrod/paper/gui/menus/LanguageSwitchMenu.java @@ -30,6 +30,8 @@ public class LanguageSwitchMenu extends BaseMenu { 10, 11, 12, 14, 15, 16, 19, 20, 21, 23, 24, 25 }; + private static final String TR_LANG_PREFIX = "gui.language.languages."; + public LanguageSwitchMenu(MythicRod plugin, Player player) { super(plugin, player); } @@ -132,7 +134,7 @@ private String textureFor(String locale) { } private Component resolveDisplayName(String locale) { - String key = "gui.language.languages." + curatedKey(locale) + ".name"; + String key = TR_LANG_PREFIX + curatedKey(locale) + ".name"; String value = plugin.getLanguageManager().tr(key); if (value.equals(key)) { return Component.text(locale.toUpperCase(Locale.ROOT)); @@ -141,7 +143,7 @@ private Component resolveDisplayName(String locale) { } private Component resolveDescription(String locale) { - String key = "gui.language.languages." + curatedKey(locale) + ".description"; + String key = TR_LANG_PREFIX + curatedKey(locale) + ".description"; String value = plugin.getLanguageManager().tr(key); if (value.equals(key)) { return Component.text(plugin.getLanguageManager().tr( @@ -152,7 +154,7 @@ private Component resolveDescription(String locale) { } private Component resolveRegion(String locale) { - String key = "gui.language.languages." + curatedKey(locale) + ".region"; + String key = TR_LANG_PREFIX + curatedKey(locale) + ".region"; String value = plugin.getLanguageManager().tr(key); if (value.equals(key)) { return Component.text(plugin.getLanguageManager().tr( diff --git a/mythicrod-paper/src/main/java/io/xcutiboo/mythicrod/paper/gui/menus/MainHubMenu.java b/mythicrod-paper/src/main/java/io/xcutiboo/mythicrod/paper/gui/menus/MainHubMenu.java index 97eb711..5153a23 100644 --- a/mythicrod-paper/src/main/java/io/xcutiboo/mythicrod/paper/gui/menus/MainHubMenu.java +++ b/mythicrod-paper/src/main/java/io/xcutiboo/mythicrod/paper/gui/menus/MainHubMenu.java @@ -1,9 +1,11 @@ package io.xcutiboo.mythicrod.paper.gui.menus; import java.util.Map; +import java.util.logging.Level; import org.bukkit.Material; import org.bukkit.entity.Player; +import org.bukkit.event.inventory.InventoryClickEvent; import org.bukkit.inventory.ItemStack; import io.xcutiboo.mythicrod.paper.MythicRod; @@ -147,7 +149,7 @@ private void placeReloadButton() { setItem(33, item, this::onReloadClick); } - private void onReloadClick(org.bukkit.event.inventory.InventoryClickEvent event) { + private void onReloadClick(InventoryClickEvent event) { if (!event.isShiftClick()) { playErrorSound(); sendMessage(tr("gui.main.reload_confirm")); @@ -168,7 +170,7 @@ private void onReloadClick(org.bukkit.event.inventory.InventoryClickEvent event) } catch (Exception e) { playErrorSound(); sendMessage(tr("gui.main.reload_failed")); - plugin.getLogger().log(java.util.logging.Level.SEVERE, "Error reloading from GUI", e); + plugin.getLogger().log(Level.SEVERE, "Error reloading from GUI", e); } } diff --git a/mythicrod-paper/src/main/java/io/xcutiboo/mythicrod/paper/gui/menus/RodMenu.java b/mythicrod-paper/src/main/java/io/xcutiboo/mythicrod/paper/gui/menus/RodMenu.java index e87cbb0..020337c 100644 --- a/mythicrod-paper/src/main/java/io/xcutiboo/mythicrod/paper/gui/menus/RodMenu.java +++ b/mythicrod-paper/src/main/java/io/xcutiboo/mythicrod/paper/gui/menus/RodMenu.java @@ -15,6 +15,7 @@ public class RodMenu extends BaseMenu { private static final String TIER_BASIC = "basic"; private static final String TIER_ADVANCED = "advanced"; private static final String TIER_LEGENDARY = "legendary"; + private static final String TIER_MYTHIC = "mythic"; private static final String CTX_MULTIPLIER = "multiplier"; private static final String TR_MULTIPLIER = "gui.rod.multiplier"; private static final String TR_ALREADY_SELECTED = "gui.rod.already_selected"; @@ -46,6 +47,7 @@ protected void build() { placeBasicTier(player, currentTier); placeAdvancedTier(player, currentTier); placeLegendaryTier(player, currentTier); + placeMythicTier(player, currentTier); placeVisualEffectsToggle(player, globalEffectsEnabled, reducedEffects); placeBackButton(player); placeCloseButton(player); @@ -121,6 +123,26 @@ private void placeLegendaryTier(Player player, String currentTier) { "gui.rod.legendary.label", "gui.rod.legendary.selected", "gui.rod.legendary.locked")); } + private void placeMythicTier(Player player, String currentTier) { + ItemStack mythicRod = new ItemBuilder(Material.FISHING_ROD) + .name(tr("gui.rod.mythic.name")) + .glow(currentTier.equals(TIER_MYTHIC)) + .lore( + tr("gui.rod.mythic.lore1"), + "", + tr("gui.rod.mythic.lore2"), + tr(TR_MULTIPLIER, Map.of(CTX_MULTIPLIER, formatMultiplier(TIER_MYTHIC))), + tr("gui.rod.mythic.lore3"), + tr("gui.rod.mythic.lore4"), + "", + currentTier.equals(TIER_MYTHIC) ? tr("gui.rod.mythic.equipped") : tr("gui.rod.mythic.click") + ) + .build(); + setItem(16, mythicRod, () -> selectGatedTier( + player, currentTier, TIER_MYTHIC, PermissionNodes.ROD_MYTHIC, + "gui.rod.mythic.label", "gui.rod.mythic.selected", "gui.rod.mythic.locked")); + } + private void selectGatedTier(Player player, String currentTier, String targetTier, String permission, String labelKey, String selectedKey, String lockedKey) { if (currentTier.equals(targetTier)) { @@ -153,7 +175,7 @@ private void placeVisualEffectsToggle(Player player, boolean globalEffectsEnable ) .glow(globalEffectsEnabled && !reducedEffects) .build(); - setItem(16, visualEffects, () -> { + setItem(22, visualEffects, () -> { playClickSound(); if (!globalEffectsEnabled) { playErrorSound(); diff --git a/mythicrod-paper/src/main/java/io/xcutiboo/mythicrod/paper/gui/menus/StatsMenu.java b/mythicrod-paper/src/main/java/io/xcutiboo/mythicrod/paper/gui/menus/StatsMenu.java index 629d72f..fe82d4d 100644 --- a/mythicrod-paper/src/main/java/io/xcutiboo/mythicrod/paper/gui/menus/StatsMenu.java +++ b/mythicrod-paper/src/main/java/io/xcutiboo/mythicrod/paper/gui/menus/StatsMenu.java @@ -22,7 +22,7 @@ /// for personal stats and falls back to a temporary zeroed view for first-time players, /// while using /// {@link io.xcutiboo.mythicrod.metrics.StatisticsManager#getTopFishers(int)} -/// for the leaderboard (returns {@code List} sorted by totalCaught). +/// for the leaderboard (returns `List` sorted by totalCaught). public class StatsMenu extends BaseMenu { private static final String CTX_COUNT = "count"; diff --git a/mythicrod-paper/src/main/java/io/xcutiboo/mythicrod/paper/integration/MythicRodPlaceholders.java b/mythicrod-paper/src/main/java/io/xcutiboo/mythicrod/paper/integration/MythicRodPlaceholders.java new file mode 100644 index 0000000..2911417 --- /dev/null +++ b/mythicrod-paper/src/main/java/io/xcutiboo/mythicrod/paper/integration/MythicRodPlaceholders.java @@ -0,0 +1,102 @@ +package io.xcutiboo.mythicrod.paper.integration; + +import java.util.Locale; +import java.util.UUID; + +import org.bukkit.OfflinePlayer; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import io.xcutiboo.mythicrod.paper.MythicRod; +import io.xcutiboo.mythicrod.paper.api.PaperMythicRodAPI; +import io.xcutiboo.mythicrod.stats.PlayerStats; +import me.clip.placeholderapi.expansion.PlaceholderExpansion; + +/// PlaceholderAPI expansion exposing MythicRod stats as `%mythicrod_*%` +/// tokens for scoreboards, tab lists, hologram plugins, and chat +/// formatters. +/// +/// Available tokens (all return strings, never null): +/// +/// - `%mythicrod_total%` - lifetime catches +/// - `%mythicrod_common%` / `%mythicrod_uncommon%` / `%mythicrod_rare%` +/// / `%mythicrod_legendary%` - catches by tier +/// - `%mythicrod_rod_tier%` - the player's selected default rod tier +/// (basic, advanced, legendary, mythic). Returns "basic" when no +/// choice has been made. +/// - `%mythicrod_version%` - running plugin version +/// +/// Returns the empty string when the player is offline and has no +/// persisted stats yet, except `%mythicrod_version%` which always +/// resolves. +public final class MythicRodPlaceholders extends PlaceholderExpansion { + + private final MythicRod plugin; + private final PaperMythicRodAPI api; + + public MythicRodPlaceholders(@NotNull MythicRod plugin, @NotNull PaperMythicRodAPI api) { + this.plugin = plugin; + this.api = api; + } + + @Override + public @NotNull String getIdentifier() { + return "mythicrod"; + } + + @Override + public @NotNull String getAuthor() { + return "xcutiboo"; + } + + @Override + public @NotNull String getVersion() { + return api.getVersion(); + } + + @Override + public boolean persist() { + return true; + } + + @Override + public @Nullable String onRequest(@Nullable OfflinePlayer player, @NotNull String params) { + String token = params.toLowerCase(Locale.ROOT); + if ("version".equals(token)) { + return api.getVersion(); + } + if (player == null) { + return ""; + } + UUID id = player.getUniqueId(); + if ("rod_tier".equals(token)) { + return readRodTier(id); + } + PlayerStats stats = plugin.getStatisticsManager() != null + ? plugin.getStatisticsManager().getStats(id) + : null; + if (stats == null) { + return ""; + } + return switch (token) { + case "total" -> String.valueOf(stats.getTotalCaught()); + case "common" -> String.valueOf(stats.getCommonCaught()); + case "uncommon" -> String.valueOf(stats.getUncommonCaught()); + case "rare" -> String.valueOf(stats.getRareCaught()); + case "legendary" -> String.valueOf(stats.getLegendaryCaught()); + default -> null; + }; + } + + private @NotNull String readRodTier(@NotNull UUID id) { + if (plugin.getPlayerDataService() == null) { + return "basic"; + } + org.bukkit.entity.Player online = plugin.getServer().getPlayer(id); + if (online == null) { + return "basic"; + } + String tier = plugin.getPlayerDataService().getRodTier(online); + return tier != null && !tier.isBlank() ? tier : "basic"; + } +} diff --git a/mythicrod-paper/src/main/java/io/xcutiboo/mythicrod/paper/item/ItemBuilder.java b/mythicrod-paper/src/main/java/io/xcutiboo/mythicrod/paper/item/ItemBuilder.java index 5f7919d..bc92637 100644 --- a/mythicrod-paper/src/main/java/io/xcutiboo/mythicrod/paper/item/ItemBuilder.java +++ b/mythicrod-paper/src/main/java/io/xcutiboo/mythicrod/paper/item/ItemBuilder.java @@ -2,6 +2,7 @@ import java.util.ArrayList; import java.util.List; +import java.util.Locale; import java.util.Map; import org.bukkit.Material; @@ -137,7 +138,7 @@ private void applyEnchantmentEntry(ItemEnchantments.Builder builder, Registry registry, Map.Entry entry) { if (entry.getKey() == null || entry.getValue() == null) return; - String enchantName = entry.getKey().toLowerCase(java.util.Locale.ROOT); + String enchantName = entry.getKey().toLowerCase(Locale.ROOT); NamespacedKey key = enchantName.contains(":") ? NamespacedKey.fromString(enchantName) : NamespacedKey.minecraft(enchantName); diff --git a/mythicrod-paper/src/main/java/io/xcutiboo/mythicrod/paper/item/RodFactory.java b/mythicrod-paper/src/main/java/io/xcutiboo/mythicrod/paper/item/RodFactory.java index adc36f1..fa419f6 100644 --- a/mythicrod-paper/src/main/java/io/xcutiboo/mythicrod/paper/item/RodFactory.java +++ b/mythicrod-paper/src/main/java/io/xcutiboo/mythicrod/paper/item/RodFactory.java @@ -1,6 +1,7 @@ package io.xcutiboo.mythicrod.paper.item; import java.util.Arrays; +import java.util.Locale; import org.bukkit.Material; import org.bukkit.NamespacedKey; @@ -76,6 +77,25 @@ public ItemStack createLegendaryRod() { ); } + public ItemStack createMythicRod() { + return createRod( + "mythic", + "MythicRod", + new String[]{ + "Prestige rod. Gate behind a", + "permission node, reward grinders.", + "", + "Tier: Mythic", + "✦✦✦✦ " + formatMultiplier("mythic") + LORE_RARE_LUCK_SUFFIX, + "✦✦✦✦ Requires mythic rod access", + "✦✦✦✦ Top of the loot ladder", + "✦✦✦✦ Unbreakable" + }, + true, + true + ); + } + /// Creates a rod item and stores the MythicRod marker plus tier in its PDC. public ItemStack createRod(String tier, String name, String[] lore) { return createRod(tier, name, lore, false, false); @@ -121,6 +141,6 @@ private String formatMultiplier(String tier) { double multiplier = plugin.getConfigManager() != null ? plugin.getConfigManager().getRodLuckMultiplier(tier) : 1.0D; - return String.format(java.util.Locale.ROOT, "%.2f", multiplier); + return String.format(Locale.ROOT, "%.2f", multiplier); } } diff --git a/mythicrod-paper/src/main/java/io/xcutiboo/mythicrod/paper/platform/PaperLocation.java b/mythicrod-paper/src/main/java/io/xcutiboo/mythicrod/paper/platform/PaperLocation.java index 1d2df55..2756d32 100644 --- a/mythicrod-paper/src/main/java/io/xcutiboo/mythicrod/paper/platform/PaperLocation.java +++ b/mythicrod-paper/src/main/java/io/xcutiboo/mythicrod/paper/platform/PaperLocation.java @@ -1,6 +1,8 @@ package io.xcutiboo.mythicrod.paper.platform; import org.bukkit.Location; +import org.bukkit.Server; +import org.bukkit.World; import io.xcutiboo.mythicrod.api.platform.PlatformLocation; @@ -24,11 +26,11 @@ public static PlatformLocation fromBukkit(Location location) { ); } - public static Location toBukkit(PlatformLocation platformLocation, org.bukkit.Server server) { + public static Location toBukkit(PlatformLocation platformLocation, Server server) { if (platformLocation == null) { return null; } - org.bukkit.World world = server.getWorld(platformLocation.getWorldName()); + World world = server.getWorld(platformLocation.getWorldName()); if (world == null) { return null; } diff --git a/mythicrod-paper/src/main/java/io/xcutiboo/mythicrod/paper/platform/PaperWorld.java b/mythicrod-paper/src/main/java/io/xcutiboo/mythicrod/paper/platform/PaperWorld.java index 3f43f1c..10d5074 100644 --- a/mythicrod-paper/src/main/java/io/xcutiboo/mythicrod/paper/platform/PaperWorld.java +++ b/mythicrod-paper/src/main/java/io/xcutiboo/mythicrod/paper/platform/PaperWorld.java @@ -1,5 +1,6 @@ package io.xcutiboo.mythicrod.paper.platform; +import org.bukkit.Location; import org.bukkit.World; import org.bukkit.inventory.ItemStack; @@ -23,7 +24,7 @@ public String getName() { public void dropItem(PlatformLocation location, PlatformItem item) { if (location == null || item == null) return; - org.bukkit.Location bukkitLoc = new org.bukkit.Location( + Location bukkitLoc = new Location( world, location.getX(), location.getY(), location.getZ(), location.getYaw(), location.getPitch() diff --git a/mythicrod-paper/src/main/java/io/xcutiboo/mythicrod/paper/scheduler/FoliaSchedulerService.java b/mythicrod-paper/src/main/java/io/xcutiboo/mythicrod/paper/scheduler/FoliaSchedulerService.java index 3d88bdc..d07acb2 100644 --- a/mythicrod-paper/src/main/java/io/xcutiboo/mythicrod/paper/scheduler/FoliaSchedulerService.java +++ b/mythicrod-paper/src/main/java/io/xcutiboo/mythicrod/paper/scheduler/FoliaSchedulerService.java @@ -6,6 +6,7 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; import java.util.function.Consumer; +import java.util.logging.Level; import org.bukkit.Bukkit; import org.bukkit.Location; @@ -58,7 +59,7 @@ public void cancelPluginTasks() { try { task.cancel(); } catch (RuntimeException e) { - plugin.getLogger().log(java.util.logging.Level.WARNING, + plugin.getLogger().log(Level.WARNING, "Failed to cancel a scheduled MythicRod task", e); } } @@ -267,12 +268,10 @@ private PaperTask track(Object nativeTask, AtomicReference taskRef) { return platformTask; } - @SuppressWarnings("unused") private Consumer foliaTask(Runnable task) { return scheduledTask -> task.run(); } - @SuppressWarnings("unused") private Consumer foliaOneShot(AtomicReference taskRef, Runnable task) { return scheduledTask -> runOneShot(taskRef, task); } diff --git a/mythicrod-paper/src/main/java/io/xcutiboo/mythicrod/paper/update/UpdateChecker.java b/mythicrod-paper/src/main/java/io/xcutiboo/mythicrod/paper/update/UpdateChecker.java index 884c4c2..be59a90 100644 --- a/mythicrod-paper/src/main/java/io/xcutiboo/mythicrod/paper/update/UpdateChecker.java +++ b/mythicrod-paper/src/main/java/io/xcutiboo/mythicrod/paper/update/UpdateChecker.java @@ -84,7 +84,7 @@ private void runCheck() { } else { log.debug("Update check: GitHub returned HTTP {} for the latest-release endpoint.", resp.statusCode()); } - } catch (InterruptedException e) { + } catch (InterruptedException _) { Thread.currentThread().interrupt(); } catch (RuntimeException | IOException e) { log.debug("Update check failed: {}", e.getMessage()); @@ -97,7 +97,7 @@ private boolean isNewerThanCurrent(String latest) { } int[] cur = parse(currentVersion); int[] rem = parse(latest); - if (cur == null || rem == null) { + if (cur.length == 0 || rem.length == 0) { return !latest.equals(currentVersion); } for (int i = 0; i < cur.length; i++) { @@ -109,7 +109,7 @@ private boolean isNewerThanCurrent(String latest) { private static int[] parse(String version) { Matcher m = VERSION.matcher(version); - if (!m.find()) return null; + if (!m.find()) return new int[0]; return new int[] { Integer.parseInt(m.group(1)), Integer.parseInt(m.group(2)), @@ -128,7 +128,7 @@ void schedule() { () -> { if (!cancelled) runCheck(); }, INITIAL_DELAY_SECONDS * 20L, REPEAT_INTERVAL_SECONDS * 20L); - } catch (UnsupportedOperationException folia) { + } catch (UnsupportedOperationException _) { // Folia does not have a global Bukkit scheduler; fall back to the async scheduler. plugin.getServer().getAsyncScheduler().runAtFixedRate( plugin, diff --git a/mythicrod-paper/src/main/java/io/xcutiboo/mythicrod/paper/util/TierVisualEffects.java b/mythicrod-paper/src/main/java/io/xcutiboo/mythicrod/paper/util/TierVisualEffects.java new file mode 100644 index 0000000..ac577ff --- /dev/null +++ b/mythicrod-paper/src/main/java/io/xcutiboo/mythicrod/paper/util/TierVisualEffects.java @@ -0,0 +1,168 @@ +package io.xcutiboo.mythicrod.paper.util; + +import java.util.Locale; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; + +import org.bukkit.Color; +import org.bukkit.Location; +import org.bukkit.Particle; +import org.bukkit.Sound; +import org.bukkit.entity.Player; +import org.bukkit.plugin.java.JavaPlugin; +import org.jetbrains.annotations.NotNull; + +/// Tier-coded particle and sound feedback for catch events. +/// +/// The palette follows the loot-tier convention every player has +/// learned across the last two decades of role-playing games: +/// white-common, green-uncommon, blue-rare, purple-legendary, +/// orange-mythic. Each tier pairs one hero particle with one short +/// sound; legendary and mythic add a brief rising-helix animation +/// for spectacle without breaking the budget (player-scoped packets +/// only, capped at one second). +/// +/// Calls fire only at the player who caught the drop. Other players +/// nearby never receive the packets, so the cost stays bounded no +/// matter how busy the lake is. +public final class TierVisualEffects { + + private static final Color COMMON = Color.fromRGB(0xFF, 0xFF, 0xFF); + private static final Color UNCOMMON = Color.fromRGB(0x55, 0xFF, 0x55); + private static final Color RARE = Color.fromRGB(0x55, 0x55, 0xFF); + private static final Color LEGENDARY = Color.fromRGB(0xAA, 0x00, 0xAA); + private static final Color MYTHIC = Color.fromRGB(0xFF, 0xAA, 0x00); + + private static final float DUST_SIZE = 1.2f; + private static final int HELIX_TICKS = 20; // 1 second of spectacle. + private static final int HELIX_POINTS = 3; // particles per tick. + private static final double HELIX_RADIUS = 0.7; + private static final double HELIX_LIFT = 0.12; // y rise per tick. + private static final long HELIX_COOLDOWN_MS = 4_000L; + + /// Per-player timestamp of the last helix animation. Used to throttle + /// the heavy cue so back-to-back legendary catches don't bury one + /// celebration under the next. The map is bounded by online-player + /// count and entries naturally fall out of relevance after logout. + private static final Map LAST_HELIX = new ConcurrentHashMap<>(); + + private TierVisualEffects() { + } + + /// Plays the catch celebration tied to the given tier at the + /// player's current location. Falls back to the common cue when + /// the tier name is unknown. Legendary and mythic schedule a + /// short rising-helix animation on the player's owner thread. + public static void playCatch(@NotNull JavaPlugin plugin, @NotNull Player player, @NotNull String tier) { + Location at = player.getLocation().add(0, 1.0, 0); + switch (tier.toLowerCase(Locale.ROOT)) { + case "uncommon" -> uncommon(player, at); + case "rare" -> rare(player, at); + case "legendary" -> { + legendaryBurst(player, at); + if (claimHelixSlot(player)) { + helix(plugin, player, LEGENDARY, MYTHIC); + } + } + case "mythic", "mythical" -> { + mythicBurst(player, at); + if (claimHelixSlot(player)) { + helix(plugin, player, MYTHIC, COMMON); + } + } + default -> common(player, at); + } + } + + /// Returns true at most once per HELIX_COOLDOWN_MS per player. + /// Throttles the heavy cue so back-to-back top-tier catches do + /// not pile helix animations on top of each other. + private static boolean claimHelixSlot(Player player) { + long now = System.currentTimeMillis(); + Long previous = LAST_HELIX.get(player.getUniqueId()); + if (previous != null && now - previous < HELIX_COOLDOWN_MS) { + return false; + } + LAST_HELIX.put(player.getUniqueId(), now); + return true; + } + + private static void common(Player player, Location at) { + player.spawnParticle(Particle.HAPPY_VILLAGER, at, 6, 0.4, 0.3, 0.4, 0); + player.playSound(at, Sound.ENTITY_ITEM_PICKUP, 0.6f, 1.0f); + } + + private static void uncommon(Player player, Location at) { + player.spawnParticle(Particle.END_ROD, at, 8, 0.35, 0.4, 0.35, 0.02); + Particle.DustOptions dust = new Particle.DustOptions(UNCOMMON, DUST_SIZE); + player.spawnParticle(Particle.DUST, at, 12, 0.4, 0.4, 0.4, 0, dust); + player.playSound(at, Sound.ENTITY_EXPERIENCE_ORB_PICKUP, 0.7f, 1.2f); + } + + private static void rare(Player player, Location at) { + Particle.DustOptions dust = new Particle.DustOptions(RARE, DUST_SIZE); + player.spawnParticle(Particle.DUST, at, 20, 0.5, 0.5, 0.5, 0, dust); + player.spawnParticle(Particle.ENCHANT, at, 30, 0.5, 0.5, 0.5, 0.5); + player.playSound(at, Sound.BLOCK_NOTE_BLOCK_BELL, 0.8f, 1.0f); + } + + private static void legendaryBurst(Player player, Location at) { + Particle.DustTransition transition = + new Particle.DustTransition(LEGENDARY, MYTHIC, DUST_SIZE + 0.3f); + player.spawnParticle(Particle.DUST_COLOR_TRANSITION, at, 30, + 0.6, 0.7, 0.6, 0, transition); + player.spawnParticle(Particle.TOTEM_OF_UNDYING, at, 12, + 0.4, 0.5, 0.4, 0.3); + player.playSound(at, Sound.UI_TOAST_CHALLENGE_COMPLETE, 1.0f, 1.0f); + } + + private static void mythicBurst(Player player, Location at) { + Particle.DustTransition transition = + new Particle.DustTransition(MYTHIC, COMMON, DUST_SIZE + 0.5f); + player.spawnParticle(Particle.DUST_COLOR_TRANSITION, at, 40, + 0.7, 0.8, 0.7, 0, transition); + player.spawnParticle(Particle.TOTEM_OF_UNDYING, at, 20, + 0.5, 0.6, 0.5, 0.5); + player.playSound(at, Sound.UI_TOAST_CHALLENGE_COMPLETE, 1.0f, 1.0f); + player.playSound(at, Sound.ENTITY_ENDER_DRAGON_GROWL, 0.35f, 0.8f); + } + + /// Schedules a rising-helix animation around the player on the + /// player's owner thread. Uses Particle.DUST_COLOR_TRANSITION so + /// the trail crossfades from start to end while it rises. + private static void helix(JavaPlugin plugin, Player player, Color from, Color to) { + Particle.DustTransition transition = + new Particle.DustTransition(from, to, DUST_SIZE); + AtomicInteger ticks = new AtomicInteger(0); + player.getScheduler().runAtFixedRate(plugin, task -> { + if (!player.isOnline()) { + task.cancel(); + return; + } + int tick = ticks.getAndIncrement(); + if (tick >= HELIX_TICKS) { + task.cancel(); + return; + } + spawnHelixSlice(player, transition, tick); + }, () -> { }, 1L, 1L); + } + + /// Spawns one slice of a rising helix around the player. Two + /// points per tick on opposite sides give the double-helix look + /// without doubling the particle count. + private static void spawnHelixSlice(Player player, Particle.DustTransition transition, int tick) { + Location base = player.getLocation(); + for (int p = 0; p < HELIX_POINTS; p++) { + double angle = (tick * 0.45) + (p * (2 * Math.PI / HELIX_POINTS)); + double x = base.getX() + HELIX_RADIUS * Math.cos(angle); + double z = base.getZ() + HELIX_RADIUS * Math.sin(angle); + double y = base.getY() + 0.2 + (tick * HELIX_LIFT); + player.spawnParticle(Particle.DUST_COLOR_TRANSITION, x, y, z, + 1, 0, 0, 0, 0, transition); + } + } + +} diff --git a/mythicrod-paper/src/main/resources/lang/en_US.yml b/mythicrod-paper/src/main/resources/lang/en_US.yml index 3bb53f8..6feded7 100644 --- a/mythicrod-paper/src/main/resources/lang/en_US.yml +++ b/mythicrod-paper/src/main/resources/lang/en_US.yml @@ -51,7 +51,7 @@ command: locked: 'You lack permission for tier %tier%.' give: tier-missing: 'Tier cannot be empty.' - invalid-tier: 'Invalid tier %tier%. Use basic, advanced, or legendary.' + invalid-tier: 'Invalid tier %tier%. Use basic, advanced, legendary, or mythic.' rod-creation-failed: 'Failed to create the requested MythicRod.' target-offline: 'Player %player% went offline.' inventory-full: 'Player %player% has no free inventory slot for this MythicRod.' @@ -90,7 +90,10 @@ command: drops: 'Drops: %drops% across %categories% categories' language: 'Language: %active% (%loaded% loaded: %list%)' nexo: 'Nexo integration: %status%' + placeholderapi: 'PlaceholderAPI: %status%' + providers: 'External drop providers: %count%' stats: 'Tracked players: %players%' + catches: 'Total catches: %count%' debug: header: '=== MythicRod Debug Info ===' runtime: 'Runtime: %drops% drops in %categories% categories, %players% tracked players, %catches% catches since reload' @@ -760,6 +763,17 @@ gui: click: '▶ Click to make this your default' selected: '✓ Default fishing tier set to Legendary. Cast with a vanilla rod to use it.' locked: '✗ You need permission to use the Legendary tier.' + mythic: + label: 'Mythic' + name: 'Mythic Rod' + lore1: 'Prestige tier - top of the loot ladder' + lore2: 'Active when you cast with a vanilla rod' + lore3: 'Requires mythicrod.rod.mythic' + lore4: 'Gate this for endgame grinders only' + equipped: '✓ Your default tier' + click: '▶ Click to make this your default' + selected: '✓ Default fishing tier set to Mythic. Cast with a vanilla rod to use it.' + locked: '✗ You need permission to use the Mythic tier.' effects: name: 'Visual Effects' lore1: 'Controls personal particles for' diff --git a/mythicrod-paper/src/main/resources/lang/ja_JP.yml b/mythicrod-paper/src/main/resources/lang/ja_JP.yml index e8e246d..c8f949d 100644 --- a/mythicrod-paper/src/main/resources/lang/ja_JP.yml +++ b/mythicrod-paper/src/main/resources/lang/ja_JP.yml @@ -51,7 +51,7 @@ command: locked: 'ティア %tier% を使用する権限がありません。' give: tier-missing: 'ティアを空にはできません。' - invalid-tier: '無効なティア %tier% です。basicadvancedlegendary を使用してください。' + invalid-tier: '無効なティア %tier% です。basicadvancedlegendarymythic を使用してください。' rod-creation-failed: '指定された MythicRod を作成できませんでした。' target-offline: 'プレイヤー %player% はオフラインになりました。' inventory-full: 'プレイヤー %player% のインベントリに MythicRod を受け取る空き枠がありません。' @@ -90,7 +90,10 @@ command: drops: 'ドロップ: %drops%%categories% カテゴリ)' language: '言語: %active%(読み込み済み %loaded%: %list%)' nexo: 'Nexo 連携: %status%' + placeholderapi: 'PlaceholderAPI: %status%' + providers: '外部ドロッププロバイダ: %count%' stats: '追跡中のプレイヤー: %players%' + catches: '合計釣果: %count%' debug: header: '=== MythicRod デバッグ情報 ===' runtime: '実行情報: %drops% ドロップ / %categories% カテゴリ, %players% 記録プレイヤー, リロード後 %catches% 釣果' @@ -758,6 +761,17 @@ gui: click: '▶ クリックしてデフォルトに設定' selected: 'デフォルトの釣りティアを レジェンダリー に設定しました。通常のロッドで釣ると適用されます。' locked: 'レジェンダリーティアを使う権限がありません。' + mythic: + label: 'ミシック' + name: 'ミシックロッド' + lore1: 'プレステージティア - 最上位の釣果' + lore2: '通常のロッドでキャストするときに有効' + lore3: 'mythicrod.rod.mythic が必要' + lore4: 'エンドゲーム勢向けにゲートしてください' + equipped: '✓ デフォルトティア' + click: '▶ クリックしてデフォルトに設定' + selected: 'デフォルトの釣りティアを ミシック に設定しました。通常のロッドで釣ると適用されます。' + locked: 'ミシックティアを使う権限がありません。' effects: name: '表示演出' lore1: 'メニューと釣果演出の' diff --git a/mythicrod-paper/src/main/resources/paper-plugin.yml b/mythicrod-paper/src/main/resources/paper-plugin.yml index 8fd5132..111611e 100644 --- a/mythicrod-paper/src/main/resources/paper-plugin.yml +++ b/mythicrod-paper/src/main/resources/paper-plugin.yml @@ -19,6 +19,10 @@ dependencies: load: BEFORE required: false join-classpath: true + PlaceholderAPI: + load: BEFORE + required: false + join-classpath: true permissions: mythicrod.*: @@ -116,6 +120,7 @@ permissions: children: mythicrod.rod.advanced: true mythicrod.rod.legendary: true + mythicrod.rod.mythic: true mythicrod.rod.advanced: description: Use advanced rod tier @@ -124,3 +129,7 @@ permissions: mythicrod.rod.legendary: description: Use legendary rod tier default: op + + mythicrod.rod.mythic: + description: Use mythic rod tier (top of the loot ladder) + default: op diff --git a/mythicrod-paper/src/test/java/io/xcutiboo/mythicrod/paper/api/PaperMythicRodAPICreateRodTest.java b/mythicrod-paper/src/test/java/io/xcutiboo/mythicrod/paper/api/PaperMythicRodAPICreateRodTest.java new file mode 100644 index 0000000..a5cba9e --- /dev/null +++ b/mythicrod-paper/src/test/java/io/xcutiboo/mythicrod/paper/api/PaperMythicRodAPICreateRodTest.java @@ -0,0 +1,46 @@ +package io.xcutiboo.mythicrod.paper.api; + +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 org.junit.jupiter.api.Test; + +import io.xcutiboo.mythicrod.api.Result; +import io.xcutiboo.mythicrod.api.platform.PlatformItem; + +class PaperMythicRodAPICreateRodTest { + + @Test + void unknownTierReturnsFailureBeforeTouchingFactory() { + PaperMythicRodAPI api = new PaperMythicRodAPI( + "test-version", + java.util.logging.Logger.getAnonymousLogger(), + null, null, null, null, null + ); + + Result result = api.createRod("god-tier"); + + assertNotNull(result); + assertFalse(result.isSuccess()); + assertTrue(result.getError().contains("Unknown rod tier")); + assertTrue(result.getError().contains("basic")); + assertTrue(result.getError().contains("advanced")); + assertTrue(result.getError().contains("legendary")); + assertTrue(result.getError().contains("mythic")); + } + + @Test + void unknownTierEchoesTheOriginalCasingInTheErrorMessage() { + PaperMythicRodAPI api = new PaperMythicRodAPI( + "test-version", + java.util.logging.Logger.getAnonymousLogger(), + null, null, null, null, null + ); + + Result result = api.createRod("Diamond-Tier"); + + assertFalse(result.isSuccess()); + assertTrue(result.getError().contains("Diamond-Tier")); + } +} diff --git a/mythicrod-paper/src/test/java/io/xcutiboo/mythicrod/paper/internal/config/BundledLocaleParityTest.java b/mythicrod-paper/src/test/java/io/xcutiboo/mythicrod/paper/internal/config/BundledLocaleParityTest.java index c22c2e1..f4a145e 100644 --- a/mythicrod-paper/src/test/java/io/xcutiboo/mythicrod/paper/internal/config/BundledLocaleParityTest.java +++ b/mythicrod-paper/src/test/java/io/xcutiboo/mythicrod/paper/internal/config/BundledLocaleParityTest.java @@ -20,6 +20,7 @@ import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; /// Compile-time guard that every bundled locale matches `en_US.yml` in @@ -87,7 +88,7 @@ void placeholderTokensAreIdenticalAcrossLocales() { private static YamlConfiguration loadBundledLocale(String locale) { URL url = BundledLocaleParityTest.class.getClassLoader() .getResource("lang/" + locale + ".yml"); - assertTrue(url != null, () -> "bundled locale not on test classpath: " + locale); + assertNotNull(url, () -> "bundled locale not on test classpath: " + locale); Path path = toPath(url); return YamlConfiguration.loadConfiguration(path.toFile()); } @@ -95,7 +96,7 @@ private static YamlConfiguration loadBundledLocale(String locale) { private static Path toPath(URL url) { try { return Paths.get(url.toURI()); - } catch (Exception exception) { + } catch (Exception _) { return new File(url.getFile()).toPath(); } } diff --git a/release-please-config.json b/release-please-config.json new file mode 100644 index 0000000..e2c886e --- /dev/null +++ b/release-please-config.json @@ -0,0 +1,20 @@ +{ + "release-type": "simple", + "packages": { + ".": { + "release-type": "simple", + "include-v-in-tag": true, + "bump-minor-pre-major": false, + "bump-patch-for-minor-pre-major": false, + "draft": false, + "prerelease": false, + "extra-files": [ + { + "type": "generic", + "path": "gradle.properties" + } + ] + } + }, + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json" +} From 695c37187f81d7bdfded56e507d7571214711ed2 Mon Sep 17 00:00:00 2001 From: xcutoboo Date: Sat, 30 May 2026 13:24:55 -0600 Subject: [PATCH 2/3] shade caffeine and stop firing marketplace workflows on every retag Two real-world fixes after the first test-server enable. 1. Caffeine shading. The stats manager builds a Caffeine cache on enable. Caffeine generates its strategy classes (SSLA, FDA, FDAMS and friends) by reflection at runtime, so shadow's minimize() saw no static reference and stripped them. The plugin crashed with ClassNotFoundException: com.github.benmanes.caffeine.cache.SSLA Relocate caffeine to io.xcutiboo.mythicrod.shaded.caffeine so we do not fight other plugins that also shade it, and exempt the caffeine artifact from minimize() so the reflective classes survive into the published jar. The jar grows from 745 KB to 1.4 MB; that is the real caffeine footprint. 2. Marketplace publish triggers. Hangar, Modrinth, and Polymart were on push: tags: [v*]. Force-retagging master during the initial-phase rebuilds fired all three every time. Hangar soft-passed on duplicateNameAndPlatform and Modrinth deduped via API, so nothing was actually republished, but the CI log noise looked bad. Gate them on release: published instead. Retagging is free now; the publishes only run when a real GitHub release goes out. workflow_dispatch stays as the manual escape hatch. --- .github/workflows/publish-hangar.yml | 5 +- .github/workflows/publish-modrinth.yml | 28 +++++- .github/workflows/publish-polymart.yml | 129 +++++++++++++++++++++++++ mythicrod-paper/build.gradle.kts | 8 +- 4 files changed, 164 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/publish-polymart.yml diff --git a/.github/workflows/publish-hangar.yml b/.github/workflows/publish-hangar.yml index 78b018e..609a67b 100644 --- a/.github/workflows/publish-hangar.yml +++ b/.github/workflows/publish-hangar.yml @@ -1,8 +1,9 @@ name: Publish to Hangar on: - push: - tags: ['v*'] + # Publish only on a real GitHub release. Bare tag pushes do not fire this. + release: + types: [published] workflow_dispatch: permissions: diff --git a/.github/workflows/publish-modrinth.yml b/.github/workflows/publish-modrinth.yml index dc1f4c9..617cde5 100644 --- a/.github/workflows/publish-modrinth.yml +++ b/.github/workflows/publish-modrinth.yml @@ -1,8 +1,9 @@ name: Publish to Modrinth on: - push: - tags: ['v*'] + # Publish only on a real GitHub release. Bare tag pushes do not fire this. + release: + types: [published] workflow_dispatch: permissions: @@ -73,8 +74,29 @@ jobs: echo "jar_publish=$jar_publish" } >> "$GITHUB_OUTPUT" - - name: Publish to Modrinth + - name: Check Modrinth for an existing version if: env.MODRINTH_TOKEN != '' && steps.meta.outputs.jar_publish == 'true' + id: dedupe + env: + VERSION: ${{ steps.meta.outputs.version }} + run: | + set -euo pipefail + existing=$(curl -fsSL \ + -H "User-Agent: xcutiboo/MythicRod CI" \ + "https://api.modrinth.com/v2/project/mythicrod/version/${VERSION}" \ + -o /dev/null -w "%{http_code}" || true) + if [[ "$existing" == "200" ]]; then + echo "Modrinth already has version $VERSION; skipping upload." + echo "skip=true" >> "$GITHUB_OUTPUT" + else + echo "skip=false" >> "$GITHUB_OUTPUT" + fi + + - name: Publish to Modrinth + if: | + env.MODRINTH_TOKEN != '' + && steps.meta.outputs.jar_publish == 'true' + && steps.dedupe.outputs.skip == 'false' uses: Kir-Antipov/mc-publish@v3.3 with: modrinth-id: mythicrod diff --git a/.github/workflows/publish-polymart.yml b/.github/workflows/publish-polymart.yml new file mode 100644 index 0000000..d247efa --- /dev/null +++ b/.github/workflows/publish-polymart.yml @@ -0,0 +1,129 @@ +name: Publish to Polymart + +on: + # Publish only on a real GitHub release. Bare tag pushes do not fire this. + release: + types: [published] + workflow_dispatch: + +permissions: + contents: read + +jobs: + publish: + runs-on: ubuntu-latest + env: + POLYMART_API_TOKEN: ${{ secrets.POLYMART_API_TOKEN }} + POLYMART_RESOURCE_ID: ${{ vars.POLYMART_RESOURCE_ID }} + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Validate Gradle wrapper + uses: gradle/actions/wrapper-validation@ed408507eac070d1f99cc633dbcf757c94c7933a # v4 + + - name: Set up JDK 25 + uses: actions/setup-java@v5 + with: + java-version: '25' + distribution: 'temurin' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@ed408507eac070d1f99cc633dbcf757c94c7933a # v4 + + - name: Make Gradle wrapper executable + run: chmod +x ./gradlew + + - name: Skip when not configured + if: env.POLYMART_API_TOKEN == '' || env.POLYMART_RESOURCE_ID == '' + run: | + echo "POLYMART_API_TOKEN secret or POLYMART_RESOURCE_ID variable is not set; skipping Polymart publish." + exit 0 + + - name: Build shadow jar + if: env.POLYMART_API_TOKEN != '' && env.POLYMART_RESOURCE_ID != '' + run: ./gradlew :mythicrod-paper:shadowJar --stacktrace --no-daemon + + - name: Resolve release metadata + if: env.POLYMART_API_TOKEN != '' && env.POLYMART_RESOURCE_ID != '' + id: meta + env: + REF_NAME: ${{ github.ref_name }} + run: | + set -euo pipefail + version="${REF_NAME#v}" + if [[ -z "$version" || "$version" == "$REF_NAME" ]]; then + version=$(./gradlew -q :mythicrod-paper:properties | awk -F': *' '/^version:/{print $2; exit}') + fi + jar=$(ls mythicrod-paper/build/libs/MythicRod-Paper-*.jar | grep -v -- '-dev' | head -1) + beta=0 + snapshot=0 + case "$version" in + *-rc*|*-beta*|*-alpha*) beta=1 ;; + *-snapshot*|*-SNAPSHOT*) snapshot=1 ;; + esac + { + echo "version=$version" + echo "jar=$jar" + echo "beta=$beta" + echo "snapshot=$snapshot" + } >> "$GITHUB_OUTPUT" + + - name: Check Polymart for an existing version + if: env.POLYMART_API_TOKEN != '' && env.POLYMART_RESOURCE_ID != '' + id: dedupe + env: + VERSION: ${{ steps.meta.outputs.version }} + run: | + set -euo pipefail + existing=$(curl -fsSL "https://api.voxel.shop/v1/getResourceUpdates?resource_id=${POLYMART_RESOURCE_ID}" \ + | jq -r --arg v "$VERSION" '.response.updates[]? | select(.version == $v) | .id' | head -1) + if [[ -n "$existing" ]]; then + echo "Polymart already has update id $existing for version $VERSION; skipping upload." + echo "skip=true" >> "$GITHUB_OUTPUT" + else + echo "skip=false" >> "$GITHUB_OUTPUT" + fi + + - name: Upload version to Polymart + if: | + env.POLYMART_API_TOKEN != '' + && env.POLYMART_RESOURCE_ID != '' + && steps.dedupe.outputs.skip == 'false' + # Polymart's voxel.shop rebrand is in open beta and the + # postUpdate API has been returning NO_KEY for valid keys. + # Treat the upload as soft so a transient Polymart auth + # bug cannot block the rest of the release lane (Hangar, + # Modrinth, GitHub Release, JitPack). + continue-on-error: true + env: + VERSION: ${{ steps.meta.outputs.version }} + JAR: ${{ steps.meta.outputs.jar }} + BETA: ${{ steps.meta.outputs.beta }} + SNAPSHOT: ${{ steps.meta.outputs.snapshot }} + run: | + set -euo pipefail + message=$(awk '/^## \['"$VERSION"'\]/{flag=1; next} /^## \[/{flag=0} flag' CHANGELOG.md \ + | sed '/./,$!d' | head -200) + if [[ -z "$message" ]]; then + message="See https://github.com/xcutiboo/MythicRod/releases/tag/v${VERSION}" + fi + response=$(curl -fsS -X POST "https://api.voxel.shop/v1/postUpdate" \ + -F "api_key=${POLYMART_API_TOKEN}" \ + -F "resource_id=${POLYMART_RESOURCE_ID}" \ + -F "version=${VERSION}" \ + -F "title=MythicRod ${VERSION}" \ + -F "message=${message}" \ + -F "beta=${BETA}" \ + -F "snapshot=${SNAPSHOT}" \ + -F "file=@${JAR}") + echo "$response" + status=$(echo "$response" | jq -r '.response.success // false') + if [[ "$status" != "true" ]]; then + echo "Polymart rejected the upload." + echo "$response" | jq -r '.response.errors[]?' || true + exit 1 + fi diff --git a/mythicrod-paper/build.gradle.kts b/mythicrod-paper/build.gradle.kts index 2ba60cf..cc16a9c 100644 --- a/mythicrod-paper/build.gradle.kts +++ b/mythicrod-paper/build.gradle.kts @@ -13,6 +13,7 @@ dependencies { compileOnly(libs.paper.api) compileOnly(libs.lombok) + compileOnly(libs.placeholderapi) annotationProcessor(libs.lombok) @@ -47,8 +48,9 @@ tasks { archiveBaseName.set("MythicRod-Paper") archiveClassifier.set("") - // Paper provides Adventure; bStats is the only bundled runtime library. + // Paper provides Adventure; bStats + Caffeine are bundled runtime libs. relocate("org.bstats", "io.xcutiboo.mythicrod.shaded.bstats") + relocate("com.github.benmanes.caffeine", "io.xcutiboo.mythicrod.shaded.caffeine") // Include common module and all dependencies configurations = listOf(project.configurations.runtimeClasspath.get()) @@ -58,6 +60,10 @@ tasks { minimize { // Keep bStats; reflection means minimize cannot see the entry points. exclude(dependency("org.bstats:.*:.*")) + // Caffeine generates cache strategy classes (SSLA, SSLR, SSMS, ...) + // via reflection at runtime. minimize() drops them as "unused" + // unless the entire artifact is held. + exclude(dependency("com.github.ben-manes.caffeine:.*:.*")) } } From bde76754cca519fb4bf4e1413eb8e95040e90963 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 24 May 2026 08:14:25 +0000 Subject: [PATCH 3/3] Bump org.spigotmc:spigot-api Bumps org.spigotmc:spigot-api from 1.21.11-R0.1-SNAPSHOT to 26.1.2-R0.1-SNAPSHOT. --- updated-dependencies: - dependency-name: org.spigotmc:spigot-api dependency-version: 26.1.2-R0.1-SNAPSHOT dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0dccb1c..85ef8b5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,6 @@ [versions] paper = "26.1.2.build.64-stable" -spigot = "1.21.11-R0.1-SNAPSHOT" +spigot = "26.1.2-R0.1-SNAPSHOT" jetbrains-annotations = "26.1.0" junit = "6.1.0" junit-platform = "6.1.0"