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/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/.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..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" @@ -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/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:.*:.*")) } } 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" +}