7 Commits
Author SHA1 Message Date
dmg 1ad930c7ad fix(pocket-base): handle keystone right-clicks in air
Release / release (push) Successful in 2m56s
CI / build (push) Successful in 1m31s
2026-09-06 22:14:18 -04:00
dmg 28dd5422d5 feat(teleport): add warmup particles and fading departure clouds
Release / release (push) Successful in 2m56s
CI / build (push) Successful in 1m35s
2026-09-06 21:11:24 -04:00
dmg ecb07d48ad feat(pocket-base): start flying on flight portal entry
Release / release (push) Successful in 5m2s
CI / build (push) Successful in 1m28s
2026-09-06 20:52:25 -04:00
dmg c42ba6888a fix(pocket-base): accept recovery compass metadata
Release / release (push) Successful in 2m47s
CI / build (push) Successful in 1m17s
2026-08-29 14:51:56 -04:00
dmg 61e7a01374 feat(pocket-base): add reusable keystone travel
Release / release (push) Successful in 3m2s
CI / build (push) Successful in 1m23s
2026-08-29 14:43:12 -04:00
dmg e0b1a25c5e feat(pocket-base): share flight through gold portals
Release / release (push) Successful in 2m50s
CI / build (push) Successful in 1m15s
2026-08-29 00:20:40 -04:00
dmg f4511f9286 feat(pocket-base): unlock flight by completing raids 2026-08-29 00:04:46 -04:00
37 changed files with 2421 additions and 75 deletions
+6 -3
View File
@@ -30,6 +30,7 @@ The plugin JAR is written to `build/libs/`.
/basesettings status /basesettings status
/basesettings upgrade /basesettings upgrade
/basesettings pocket upgrade /basesettings pocket upgrade
/basesettings pocket flight <enable|disable>
/basesettings pocket mobs <hostile|passive> <enable|disable> /basesettings pocket mobs <hostile|passive> <enable|disable>
/basesettings pocket type <void|nether|overworld> <subtype> /basesettings pocket type <void|nether|overworld> <subtype>
/basesettings visitors <allowed|blocked> /basesettings visitors <allowed|blocked>
@@ -40,15 +41,17 @@ The plugin JAR is written to `build/libs/`.
/basesettings bossbar <enable|disable> /basesettings bossbar <enable|disable>
``` ```
`/basesettings` and `/basesettings ui` open Minecraft's native dialog dashboard. The dashboard summarizes Base and Pocket Base progression, provides unlocked setting controls and purchase confirmations, and marks unavailable controls as locked. `/basesettings status` retains the detailed chat report. `/basesettings` and `/basesettings ui` open Minecraft's native dialog dashboard. The dashboard summarizes Base and Pocket Base progression, provides unlocked setting controls and purchase confirmations, marks unavailable controls as locked, and provides Back buttons from nested screens. `/basesettings status` retains the detailed chat report.
Navigation particles appear only in the base's world and when the player is more than 25 blocks beyond the current base border. Navigation particles appear only in the base's world and when the player is more than 25 blocks beyond the current base border.
After 250 Survival-mode block placements anywhere by default, the spawnable overlay can mark nearby dark hostile-mob spawning surfaces inside the player's base with owner-only red particles. The threshold is configurable. After 250 Survival-mode block placements anywhere by default, the spawnable overlay can mark nearby dark hostile-mob spawning surfaces inside the player's base with owner-only red particles. The threshold is configurable.
`/base` has a stationary warm-up. Looking around is allowed, while movement between blocks, damage, teleportation, world changes, death, logout, and conflicting teleport commands cancel it without consuming the cooldown. `/base` has a stationary warm-up. Looking around is allowed, while movement between blocks, damage, teleportation, world changes, death, logout, and conflicting teleport commands cancel it without consuming the cooldown. Home and visitor warm-ups surround the player with nearby-visible floating particles that grow denser as departure approaches. Successful warmed-up teleports leave a cloud at the departure point that thins out over two seconds; cancellation or failure stops emission without a departure cloud.
Base IV owners can purchase and expand a persistent Pocket Base with `/basesettings pocket upgrade`. Activating a complete diamond-block portal frame inside the normal base with flint and steel opens one public entrance to the owner's grass platform in a private void world. Players and non-player living mobs can travel through the entrance and active return portal; items, projectiles, and vehicles are not transported. The owner can move the return portal by building and igniting another complete frame inside the unlocked Pocket Base boundary; only the newly activated return portal remains functional. Natural hostile and passive mob spawning is disabled by default. Owners can control each category independently with `/basesettings pocket mobs <hostile|passive> <enable|disable>`. Base IV owners can purchase and expand a persistent Pocket Base with `/basesettings pocket upgrade`. Activating a complete diamond-block portal frame inside the normal base with flint and steel opens one public entrance to the owner's grass platform in a private void world. Players and non-player living mobs can travel through the entrance and active return portal; items, projectiles, and vehicles are not transported. The owner can move the return portal by building and igniting another complete frame inside the unlocked Pocket Base boundary; only the newly activated return portal remains functional. Natural hostile and passive mob spawning is disabled by default. Owners can control each category independently with `/basesettings pocket mobs <hostile|passive> <enable|disable>`. An owner who wins a raid inside their own Pocket Base permanently unlocks owner-only Survival flight across the full build height and within the current Pocket Base boundary plus a 16-block warning buffer on every side. The owner can control that automatic privilege with `/basesettings pocket flight <enable|disable>`. After the raid unlock, the owner can ignite a complete gold-block portal frame inside the unlocked boundary; any Survival player who passes through receives temporary flight within the same boundary and buffer until leaving the Pocket Base. Entering the flight portal immediately starts the player flying and displays “Pocket dimension flight enabled!” once, without repeating while they stand inside the portal. The owner's automatic-flight preference does not affect portal-granted flight.
Pocket Base owners can also craft a reusable **Pocket Base Keystone**. Its shaped recipe uses an Ender Pearl in the center, Netherite Ingots on the four cardinal slots, and Obsidian in the four corners. Right-clicking the keystone outside a Pocket Base instantly records the player's location and takes them to their own Pocket Base. Right-clicking it again inside their own Pocket Base returns them to that exact location when the world and destination remain available and safe. Saved returns survive reconnects and server restarts. A transferred keystone only works for a player who has unlocked their own Pocket Base.
Owners can change biome metadata without altering Pocket Base blocks, entities, inventories, or portals by using `/basesettings pocket type <void|nether|overworld> <subtype>`. Void uses `the_void`; Nether and Overworld accept their compatible vanilla biome names, such as `crimson_forest` or `plains`. Each change defaults to 16 netherite blocks, is configurable, and affects applicable natural mob selection when spawning is enabled. The Pocket Base remains a void-generated normal-environment world; dimension-specific mechanics do not change. Owners can change biome metadata without altering Pocket Base blocks, entities, inventories, or portals by using `/basesettings pocket type <void|nether|overworld> <subtype>`. Void uses `the_void`; Nether and Overworld accept their compatible vanilla biome names, such as `crimson_forest` or `plains`. Each change defaults to 16 netherite blocks, is configurable, and affects applicable natural mob selection when spawning is enabled. The Pocket Base remains a void-generated normal-environment world; dimension-specific mechanics do not change.
+28
View File
@@ -149,3 +149,31 @@ description: Chronological record of material decisions affecting the Spigot Bas
- Cleared accumulated fall distance whenever portal or void-return travel teleports an entity, preventing carried-over fall damage at the destination. - Cleared accumulated fall distance whenever portal or void-return travel teleports an entity, preventing carried-over fall damage at the destination.
- Added regression coverage for players falling out of a Pocket Base and returning to the server spawn. - Added regression coverage for players falling out of a Pocket Base and returning to the server spawn.
- Verified the fix and plugin artifact with `./gradlew clean check jar`. - Verified the fix and plugin artifact with `./gradlew clean check jar`.
## 2026-08-28 — Raid-earned Pocket Base flight
- Made a successful raid victory by the owner inside their own Pocket Base permanently unlock owner-only Survival flight.
- Limited Pocket Base flight to its expanding horizontal boundary plus a 16-block warning buffer while allowing the world's full build height.
- Persisted unlocks with safe locked defaults for existing records and exposed progress through chat status and the native settings dialog.
- Verified 140 tests and the Java 25 plugin artifact with `./gradlew clean check jar`.
## 2026-08-28 — Shared Pocket Base flight portals
- Added one persistent owner-activated gold-block flight portal per Pocket Base after the raid unlock.
- Granted owners and guests temporary controlled flight after portal collision, independently of the owner's persisted automatic-flight preference.
- Applied the expanding Pocket Base boundary plus a 16-block warning buffer to both automatic and portal-granted flight.
- Restored Back buttons from nested native dialog screens to the main Base Settings dashboard.
- Verified 149 tests and the Java 25 plugin artifact with `./gradlew clean check jar`.
## 2026-08-29 — Recovery Compass metadata compatibility
- Accepted Purpur's valid generic item metadata when creating the Pocket Base Keystone instead of requiring the legacy Compass-specific metadata subtype.
- Added regression coverage and verified 158 tests and the Java 25 plugin artifact with `./gradlew clean check jar`.
## 2026-08-29 — Pocket Base Keystone travel
- Added a reusable Recovery Compass keystone recipe with an Ender Pearl center, cardinal Netherite Ingots, and Obsidian corners.
- Limited crafting and use to Pocket Base owners while allowing transferred keystones to work for each eligible holder's own Pocket Base.
- Added instant entry and safe return travel with exact, durable return destinations and fall-distance clearing.
- Rejected unavailable and unsafe returns without discarding the saved destination.
- Verified 157 tests and the Java 25 plugin artifact with `./gradlew clean check jar`.
+3
View File
@@ -26,3 +26,6 @@ description: Catalog of user stories for the Spigot Base plugin.
18. [US-018: Control Pocket Base mob spawning](us-018-control-pocket-base-mob-spawning.md) 18. [US-018: Control Pocket Base mob spawning](us-018-control-pocket-base-mob-spawning.md)
19. [US-019: Change a Pocket Base biome](us-019-change-a-pocket-base-biome.md) 19. [US-019: Change a Pocket Base biome](us-019-change-a-pocket-base-biome.md)
20. [US-020: Control a base through a dialog UI](us-020-control-base-through-dialog-ui.md) 20. [US-020: Control a base through a dialog UI](us-020-control-base-through-dialog-ui.md)
21. [US-021: Unlock Pocket Base flight](us-021-unlock-pocket-base-flight.md)
22. [US-022: Share Pocket Base flight through a portal](us-022-share-pocket-base-flight-through-a-portal.md)
23. [US-023: Craft and use a Pocket Base Keystone](us-023-craft-pocket-base-keystone.md)
@@ -11,6 +11,11 @@ As a **player with Base II**, I want to earn `/base` so that I can return safely
## Acceptance criteria ## Acceptance criteria
- [x] Warm-up particles float around the player, are visible nearby, and grow denser with warm-up progress.
- [x] Successful warmed-up teleports leave a departure cloud that thins out over two seconds.
- [x] Cancellation stops particle emission and failed teleports create no departure cloud.
- [x] Particle effects are bounded, cleaned up on shutdown, and regression-tested without changing teleport timing, safety, or cooldowns.
- [x] Base III requires Base II, an established base, and a configurable 200 qualifying block placements inside the base. - [x] Base III requires Base II, an established base, and a configurable 200 qualifying block placements inside the base.
- [x] Only placements made in Survival mode and within the base's current horizontal and vertical bounds count. - [x] Only placements made in Survival mode and within the base's current horizontal and vertical bounds count.
- [x] Player-placed blocks and replacement of previously broken blocks may contribute repeatedly. - [x] Player-placed blocks and replacement of previously broken blocks may contribute repeatedly.
@@ -11,6 +11,10 @@ As a **player with Base III**, I want to open my base to visitors so that other
## Acceptance criteria ## Acceptance criteria
- [x] Visitor warm-ups share the nearby-visible, progressively denser floating particles used by home teleports.
- [x] Successful visitor teleports leave a two-second fading departure cloud; cancellation or failure stops emission without a departure cloud.
- [x] Effects are bounded and cleaned up on shutdown, with regression coverage preserving visitor timing, safety, and cooldowns.
- [x] Base IV requires Base III and an established base. - [x] Base IV requires Base III and an established base.
- [x] `/basesettings upgrade` offers the Base IV purchase for a configurable price that defaults to 128 diamonds, replacing `/base upgrade` and `/home upgrade`. - [x] `/basesettings upgrade` offers the Base IV purchase for a configurable price that defaults to 128 diamonds, replacing `/base upgrade` and `/home upgrade`.
- [x] A successful purchase removes the complete price atomically from the player's direct inventory. - [x] A successful purchase removes the complete price atomically from the player's direct inventory.
@@ -12,6 +12,7 @@ As a **player**, I want to inspect my progression and receive timely milestone f
## Acceptance criteria ## Acceptance criteria
- [x] `/basesettings status` shows the player's Base, Base Size, Base Flight, Teleport Warm-up, and Teleport Cooldown paths plus current visitor, navigation, flight, and boss-bar settings. - [x] `/basesettings status` shows the player's Base, Base Size, Base Flight, Teleport Warm-up, and Teleport Cooldown paths plus current visitor, navigation, flight, and boss-bar settings.
- [x] `/basesettings status` shows whether Pocket Base flight still requires a successful owner raid or has been unlocked.
- [x] Each path identifies earned levels, unmet prerequisites, current progress, the next threshold, and the next reward. - [x] Each path identifies earned levels, unmet prerequisites, current progress, the next threshold, and the next reward.
- [x] Locked secondary paths clearly identify Base I or another sequential level as their prerequisite. - [x] Locked secondary paths clearly identify Base I or another sequential level as their prerequisite.
- [x] Relevant qualifying activity briefly displays a configurable progress boss bar for the active milestone. - [x] Relevant qualifying activity briefly displays a configurable progress boss bar for the active milestone.
@@ -31,3 +32,4 @@ As a **player**, I want to inspect my progression and receive timely milestone f
- [US-001: Unlock and establish Base I](us-001-unlock-and-establish-base.md) - [US-001: Unlock and establish Base I](us-001-unlock-and-establish-base.md)
- [US-004: Unlock and control base flight](us-004-unlock-and-control-base-flight.md) - [US-004: Unlock and control base flight](us-004-unlock-and-control-base-flight.md)
- [US-010: Administer player progression](us-010-administer-player-progression.md) - [US-010: Administer player progression](us-010-administer-player-progression.md)
- [US-021: Unlock Pocket Base flight](us-021-unlock-pocket-base-flight.md)
@@ -17,6 +17,8 @@ As a **server operator**, I want progression behavior to be configurable and dur
- [x] Radii, vertical ranges, durations, and prices reject unsafe or nonsensical values. - [x] Radii, vertical ranges, durations, and prices reject unsafe or nonsensical values.
- [x] Invalid required configuration prevents partial plugin initialization and produces a clear server log message. - [x] Invalid required configuration prevents partial plugin initialization and produces a clear server log message.
- [x] UUID-keyed state persists latest known names, base locations, counters, levels, relocation times, teleport times, visitor cooldowns, navigation preferences, flight preferences, boss-bar preferences, and visitor settings. - [x] UUID-keyed state persists latest known names, base locations, counters, levels, relocation times, teleport times, visitor cooldowns, navigation preferences, flight preferences, boss-bar preferences, and visitor settings.
- [x] UUID-keyed Pocket Base state persists raid-earned flight unlocks, with missing legacy values defaulting safely to locked.
- [x] Pocket Base state persists the owner automatic-flight preference and active flight portal, with safe defaults for legacy records.
- [x] Cooldowns and relocation limits use real elapsed timestamps and continue while players are offline. - [x] Cooldowns and relocation limits use real elapsed timestamps and continue while players are offline.
- [x] State is saved safely so a failed write does not replace valid persisted state with a partial document. - [x] State is saved safely so a failed write does not replace valid persisted state with a partial document.
- [x] Corrupt, unknown, or invalid records are handled defensively and cannot silently grant progression or privileges. - [x] Corrupt, unknown, or invalid records are handled defensively and cannot silently grant progression or privileges.
@@ -28,3 +30,5 @@ As a **server operator**, I want progression behavior to be configurable and dur
- [US-003: Expand the base](us-003-expand-the-base.md) - [US-003: Expand the base](us-003-expand-the-base.md)
- [US-010: Administer player progression](us-010-administer-player-progression.md) - [US-010: Administer player progression](us-010-administer-player-progression.md)
- [US-012: Build and release the plugin](us-012-build-and-release-plugin.md) - [US-012: Build and release the plugin](us-012-build-and-release-plugin.md)
- [US-021: Unlock Pocket Base flight](us-021-unlock-pocket-base-flight.md)
- [US-022: Share Pocket Base flight through a portal](us-022-share-pocket-base-flight-through-a-portal.md)
@@ -28,3 +28,4 @@ As a **player with an established Base IV**, I want to unlock a persistent Pocke
- [US-011: Configure and persist progression](us-011-configure-and-persist-progression.md) - [US-011: Configure and persist progression](us-011-configure-and-persist-progression.md)
- [US-016: Build and use Pocket Base portals](us-016-build-and-use-pocket-base-portals.md) - [US-016: Build and use Pocket Base portals](us-016-build-and-use-pocket-base-portals.md)
- [US-017: Expand a Pocket Base](us-017-expand-a-pocket-base.md) - [US-017: Expand a Pocket Base](us-017-expand-a-pocket-base.md)
- [US-023: Craft and use a Pocket Base Keystone](us-023-craft-pocket-base-keystone.md)
@@ -37,6 +37,7 @@ As a **Pocket Base owner**, I want to connect my normal base to my Pocket Base w
- [x] Players who disconnect inside a Pocket Base remain there when they reconnect. - [x] Players who disconnect inside a Pocket Base remain there when they reconnect.
- [x] Moving the normal base deactivates its entrance while preserving the Pocket Base world and its contents. - [x] Moving the normal base deactivates its entrance while preserving the Pocket Base world and its contents.
- [x] Portal ownership and the active entrance location persist through restarts. - [x] Portal ownership and the active entrance location persist through restarts.
- [x] A separate active gold-block portal inside an owner's Pocket Base can grant temporary controlled flight after raid-earned flight is unlocked.
## Related ## Related
@@ -44,3 +45,5 @@ As a **Pocket Base owner**, I want to connect my normal base to my Pocket Base w
- [US-008: Unlock Base IV visitor access](us-008-unlock-visitor-access.md) - [US-008: Unlock Base IV visitor access](us-008-unlock-visitor-access.md)
- [US-015: Unlock a Pocket Base](us-015-unlock-a-pocket-base.md) - [US-015: Unlock a Pocket Base](us-015-unlock-a-pocket-base.md)
- [US-017: Expand a Pocket Base](us-017-expand-a-pocket-base.md) - [US-017: Expand a Pocket Base](us-017-expand-a-pocket-base.md)
- [US-022: Share Pocket Base flight through a portal](us-022-share-pocket-base-flight-through-a-portal.md)
- [US-023: Craft and use a Pocket Base Keystone](us-023-craft-pocket-base-keystone.md)
@@ -16,6 +16,8 @@ As a **player**, I want a dialog-based base dashboard so that I can inspect prog
- [x] `/basesettings status` retains the chat-based progression report. - [x] `/basesettings status` retains the chat-based progression report.
- [x] The dialog summarizes current Base and Pocket Base progression. - [x] The dialog summarizes current Base and Pocket Base progression.
- [x] Dialog controls cover visitors, navigation, flight, border, spawnable overlay, boss bar, Pocket Base mob spawning, upgrades, and biome selection when applicable. - [x] Dialog controls cover visitors, navigation, flight, border, spawnable overlay, boss bar, Pocket Base mob spawning, upgrades, and biome selection when applicable.
- [x] The Pocket Base dialog shows whether owner flight still requires a successful raid and lets an unlocked owner enable or disable automatic Pocket Base flight.
- [x] Nested dialog screens provide Back buttons that return to the main Base Settings dashboard.
- [x] Controls indicate their current or locked state. - [x] Controls indicate their current or locked state.
- [x] Purchases require confirmation and continue using existing validation, payment, and persistence logic. - [x] Purchases require confirmation and continue using existing validation, payment, and persistence logic.
- [x] `ui` is offered through command autocomplete. - [x] `ui` is offered through command autocomplete.
@@ -32,3 +34,5 @@ As a **player**, I want a dialog-based base dashboard so that I can inspect prog
- [US-015: Unlock a Pocket Base](us-015-unlock-a-pocket-base.md) - [US-015: Unlock a Pocket Base](us-015-unlock-a-pocket-base.md)
- [US-018: Control Pocket Base mob spawning](us-018-control-pocket-base-mob-spawning.md) - [US-018: Control Pocket Base mob spawning](us-018-control-pocket-base-mob-spawning.md)
- [US-019: Change a Pocket Base biome](us-019-change-a-pocket-base-biome.md) - [US-019: Change a Pocket Base biome](us-019-change-a-pocket-base-biome.md)
- [US-021: Unlock Pocket Base flight](us-021-unlock-pocket-base-flight.md)
- [US-022: Share Pocket Base flight through a portal](us-022-share-pocket-base-flight-through-a-portal.md)
@@ -0,0 +1,38 @@
---
type: User Story
title: "US-021: Unlock Pocket Base flight"
description: Let a Pocket Base owner permanently earn controlled flight by winning a raid in their own pocket world.
status: done
---
# US-021: Unlock Pocket Base flight
As a **Pocket Base owner**, I want to earn flight by completing a raid in my Pocket Base so that I can build and travel safely throughout my expanding pocket world.
## Acceptance criteria
- [x] Pocket Base flight begins locked for new owners and existing saved Pocket Bases.
- [x] A successful raid completed in a Pocket Base permanently unlocks flight only when that Pocket Base's owner is listed among the raid winners.
- [x] Raids completed outside the owner's Pocket Base and raids the owner does not win do not unlock flight.
- [x] Defeated, stopped, expired, and abandoned raids do not unlock flight.
- [x] Unlocking Pocket Base flight is free and does not consume blocks or other currency.
- [x] The owner receives a prominent one-time unlock notification after the qualifying raid.
- [x] In Survival mode, an owner with the unlock can enable or disable automatic plugin-granted flight inside their own Pocket Base without affecting portal-granted temporary flight.
- [x] Pocket Base flight spans the world's full build height and the current unlocked horizontal boundary plus 16 blocks on every side.
- [x] An actively flying owner receives a warning after leaving the unlocked Pocket Base boundary while remaining inside the 16-block buffer.
- [x] Moving beyond the 16-block buffer removes only flight granted by this plugin.
- [x] Pocket Base expansion automatically expands the corresponding flight area.
- [x] Visitors do not receive the owner's Pocket Base flight privilege.
- [x] Teleportation, world changes, game-mode changes, death, logout, and plugin shutdown do not leave unintended flight enabled.
- [x] The unlock persists across reconnects and restarts.
- [x] Chat status and the native settings dialog show whether Pocket Base flight is locked behind a successful raid or unlocked.
## Related
- [US-004: Unlock and control base flight](us-004-unlock-and-control-base-flight.md)
- [US-009: View progression and unlock notifications](us-009-view-progression-and-notifications.md)
- [US-011: Configure and persist progression](us-011-configure-and-persist-progression.md)
- [US-015: Unlock a Pocket Base](us-015-unlock-a-pocket-base.md)
- [US-017: Expand a Pocket Base](us-017-expand-a-pocket-base.md)
- [US-020: Control a base through a dialog UI](us-020-control-base-through-dialog-ui.md)
- [US-022: Share Pocket Base flight through a portal](us-022-share-pocket-base-flight-through-a-portal.md)
@@ -0,0 +1,39 @@
---
type: User Story
title: "US-022: Share Pocket Base flight through a portal"
description: Let an owner build a gold-block portal that temporarily grants controlled Pocket Base flight to players who pass through it.
status: done
---
# US-022: Share Pocket Base flight through a portal
As a **Pocket Base owner**, I want an activated flight portal inside my Pocket Base so that I can temporarily share controlled flight with guests without changing my own default-flight preference.
## Acceptance criteria
- [x] Entering the active flight portal immediately starts eligible Survival players flying without double-jumping.
- [x] Each portal entry sends “Pocket dimension flight enabled!” without repeated messages while standing in the portal.
- [x] Immediate takeoff preserves existing flight boundaries and expiry rules, with regression tests.
- [x] The owner must first unlock Pocket Base flight by winning a raid in their own Pocket Base.
- [x] Only the owner can activate a complete 4-by-5 gold-block frame with flint and steel as the active flight portal.
- [x] Every required frame block must be inside the owner's currently unlocked Pocket Base boundary at activation.
- [x] Each Pocket Base has at most one active flight portal, and activating another valid frame moves the active location without removing the old frame.
- [x] The active flight portal uses custom particles and collision without vanilla Nether portal blocks.
- [x] Breaking any required block in the active frame deactivates the flight portal.
- [x] Any player, including the owner or a guest, who passes through the active intact flight portal receives temporary Pocket Base flight.
- [x] Portal-granted flight remains independent of the owner's default Pocket Base flight preference.
- [x] Portal-granted flight spans the world's full build height and the current Pocket Base boundary plus 16 blocks on every side.
- [x] An actively flying player receives a warning inside the 16-block buffer, and flight ends beyond it.
- [x] Expanding the Pocket Base automatically expands the portal-granted flight area.
- [x] Portal-granted flight ends when the player leaves the Pocket Base, dies, disconnects, or changes out of Survival mode.
- [x] The active flight portal location and owner default-flight preference persist across restarts.
- [x] Existing saved Pocket Bases default to no flight portal while preserving raid-earned owner flight as enabled.
## Related
- [US-011: Configure and persist progression](us-011-configure-and-persist-progression.md)
- [US-016: Build and use Pocket Base portals](us-016-build-and-use-pocket-base-portals.md)
- [US-017: Expand a Pocket Base](us-017-expand-a-pocket-base.md)
- [US-020: Control a base through a dialog UI](us-020-control-base-through-dialog-ui.md)
- [US-021: Unlock Pocket Base flight](us-021-unlock-pocket-base-flight.md)
@@ -0,0 +1,39 @@
---
type: User Story
title: "US-023: Craft and use a Pocket Base Keystone"
description: Let Pocket Base owners craft a reusable item for instant round-trip travel between their current location and their private dimension.
status: done
---
# US-023: Craft and use a Pocket Base Keystone
As a **Pocket Base owner**, I want to craft a reusable keystone that instantly takes me into my Pocket Base and back so that I can access my private dimension without finding a portal.
## Acceptance criteria
- [x] The Pocket Base Keystone is a distinct Recovery Compass identified by persistent item metadata rather than its display name alone.
- [x] Keystone creation accepts the valid generic item metadata supplied for Recovery Compasses by the supported Purpur runtime.
- [x] Its shaped recipe places an Ender Pearl in the center, Netherite Ingots on the four cardinal slots, and Obsidian in the four corners.
- [x] Only a player who has unlocked Pocket Base I can complete the recipe or use a keystone.
- [x] Right-clicking a keystone outside a Pocket Base instantly teleports the player to their own Pocket Base arrival point.
- [x] Entering records the player's exact prior world, position, yaw, and pitch as the return destination.
- [x] Right-clicking a keystone inside the player's own Pocket Base returns them safely to the recorded destination.
- [x] Return destinations persist across logout and server restart.
- [x] Missing worlds and unsafe destinations fail safely without replacing or discarding the recorded return destination.
- [x] A keystone is reusable, has no warm-up or cooldown, and always accesses the user's own unlocked Pocket Base even when transferred between players.
- [x] Keystone travel clears accumulated fall distance and does not change existing portal travel behavior.
- [x] Automated tests cover recipe shape, crafting and use eligibility, metadata authentication, entry, return, persistence, and unavailable or unsafe destinations.
- [x] Keystone entry and return work when right-clicking air or blocks, regardless of facing direction.
- [x] Vanilla no-action events are handled without bypassing explicit item-use restrictions.
- [x] Regression tests cover air and block interactions for entry and return, and denied item use.
## Verification
- `./gradlew clean check jar` passes, including event-dispatch regression coverage for air/block entry and return and explicit item-use denial.
- Live-server verification remains pending deployment.
## Related
- [US-015: Unlock a Pocket Base](us-015-unlock-a-pocket-base.md)
- [US-016: Build and use Pocket Base portals](us-016-build-and-use-pocket-base-portals.md)
@@ -1,23 +1,38 @@
package games.dmg.spigotbase; package games.dmg.spigotbase;
import java.io.IOException;
import java.util.HashMap;
import java.util.HashSet; import java.util.HashSet;
import java.util.Map;
import java.util.Optional;
import java.util.Set; import java.util.Set;
import java.util.UUID; import java.util.UUID;
import java.util.logging.Level;
import java.util.logging.Logger;
import org.bukkit.ChatColor; import org.bukkit.ChatColor;
import org.bukkit.GameMode; import org.bukkit.GameMode;
import org.bukkit.Material; import org.bukkit.Material;
import org.bukkit.Server; import org.bukkit.Server;
import org.bukkit.entity.Player; import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.entity.PlayerDeathEvent;
import org.bukkit.event.player.PlayerQuitEvent;
import org.bukkit.event.raid.RaidFinishEvent;
import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.ItemStack;
final class BaseFlightController implements Runnable { final class BaseFlightController implements Runnable, Listener {
private static final int POCKET_FLIGHT_BUFFER = 16;
private final Server server; private final Server server;
private final BaseStateManager stateManager; private final BaseStateManager stateManager;
private final SecondaryProgressionService progressionService; private final SecondaryProgressionService progressionService;
private final BaseBoundsService boundsService; private final BaseBoundsService boundsService;
private final PluginSettingsProvider settings; private final PluginSettingsProvider settings;
private final PocketBaseManager pocketBases;
private final Logger logger;
private final Set<UUID> grantedFlight = new HashSet<>(); private final Set<UUID> grantedFlight = new HashSet<>();
private final Set<UUID> warned = new HashSet<>(); private final Set<UUID> warned = new HashSet<>();
private final Map<UUID, UUID> portalFlight = new HashMap<>();
BaseFlightController( BaseFlightController(
Server server, Server server,
@@ -25,12 +40,85 @@ final class BaseFlightController implements Runnable {
SecondaryProgressionService progressionService, SecondaryProgressionService progressionService,
BaseBoundsService boundsService, BaseBoundsService boundsService,
PluginSettingsProvider settings PluginSettingsProvider settings
) {
this(server, stateManager, progressionService, boundsService, settings, null,
Logger.getLogger(BaseFlightController.class.getName()));
}
BaseFlightController(
Server server,
BaseStateManager stateManager,
SecondaryProgressionService progressionService,
BaseBoundsService boundsService,
PluginSettingsProvider settings,
PocketBaseManager pocketBases,
Logger logger
) { ) {
this.server = server; this.server = server;
this.stateManager = stateManager; this.stateManager = stateManager;
this.progressionService = progressionService; this.progressionService = progressionService;
this.boundsService = boundsService; this.boundsService = boundsService;
this.settings = settings; this.settings = settings;
this.pocketBases = pocketBases;
this.logger = logger;
}
@EventHandler
public void onRaidFinish(RaidFinishEvent event) {
if (pocketBases == null) {
return;
}
Optional<UUID> owner = pocketBases.ownerForPocketWorld(event.getWorld().getUID());
if (owner.isEmpty() || event.getWinners().stream().noneMatch(
winner -> winner.getUniqueId().equals(owner.orElseThrow()))) {
return;
}
UUID ownerId = owner.orElseThrow();
if (pocketBases.state(ownerId).flightUnlocked()) {
return;
}
try {
pocketBases.unlockFlight(ownerId);
} catch (IOException | RuntimeException exception) {
logger.log(Level.SEVERE, "Could not unlock Pocket Base flight", exception);
return;
}
event.getWinners().stream()
.filter(winner -> winner.getUniqueId().equals(ownerId))
.findFirst()
.orElseThrow()
.sendTitle(
ChatColor.GOLD + "Pocket Base Flight Unlocked",
ChatColor.YELLOW + "Flight is now available in your Pocket Base",
settings.current().titleFadeInTicks(),
settings.current().titleStayTicks(),
settings.current().titleFadeOutTicks()
);
}
@EventHandler
public void onDeath(PlayerDeathEvent event) {
clearPortalFlight(event.getEntity());
}
@EventHandler
public void onQuit(PlayerQuitEvent event) {
clearPortalFlight(event.getPlayer());
}
void grantPocketPortalFlight(Player player, UUID ownerId) {
if (player.getGameMode() != GameMode.SURVIVAL || pocketBases == null
|| !pocketBases.ownerForPocketWorld(player.getWorld().getUID())
.filter(ownerId::equals).isPresent()) {
return;
}
portalFlight.put(player.getUniqueId(), ownerId);
if (applyPocketFlight(player, ownerId)) {
player.setFlying(true);
player.sendMessage(ChatColor.GREEN + "Pocket dimension flight enabled!");
} else {
portalFlight.remove(player.getUniqueId());
}
} }
@Override @Override
@@ -44,6 +132,7 @@ final class BaseFlightController implements Runnable {
} }
grantedFlight.removeIf(id -> server.getPlayer(id) == null); grantedFlight.removeIf(id -> server.getPlayer(id) == null);
warned.removeIf(id -> server.getPlayer(id) == null); warned.removeIf(id -> server.getPlayer(id) == null);
portalFlight.keySet().removeIf(id -> server.getPlayer(id) == null);
} }
void removeGrantedFlight(Player player) { void removeGrantedFlight(Player player) {
@@ -66,6 +155,7 @@ final class BaseFlightController implements Runnable {
} }
grantedFlight.clear(); grantedFlight.clear();
warned.clear(); warned.clear();
portalFlight.clear();
} }
private PlayerState observeElytra(Player player, PlayerState state) { private PlayerState observeElytra(Player player, PlayerState state) {
@@ -89,8 +179,22 @@ final class BaseFlightController implements Runnable {
} }
private void applyFlight(Player player, PlayerState state) { private void applyFlight(Player player, PlayerState state) {
if (player.getGameMode() != GameMode.SURVIVAL if (player.getGameMode() != GameMode.SURVIVAL) {
|| !state.flightEnabled() || state.flightLevel() < 1 || state.base().isEmpty()) { portalFlight.remove(player.getUniqueId());
removeGrantedFlight(player);
return;
}
if (pocketBases != null) {
Optional<UUID> pocketOwner = pocketBases.ownerForPocketWorld(
player.getWorld().getUID()
);
if (pocketOwner.isPresent()) {
applyPocketFlight(player, pocketOwner.orElseThrow());
return;
}
}
portalFlight.remove(player.getUniqueId());
if (!state.flightEnabled() || state.flightLevel() < 1 || state.base().isEmpty()) {
removeGrantedFlight(player); removeGrantedFlight(player);
return; return;
} }
@@ -127,6 +231,51 @@ final class BaseFlightController implements Runnable {
} }
} }
private boolean applyPocketFlight(Player player, UUID ownerId) {
PocketBaseState pocket = pocketBases.state(ownerId);
boolean ownerFlight = ownerId.equals(player.getUniqueId())
&& pocket.flightUnlocked() && pocket.flightEnabled();
boolean temporaryFlight = ownerId.equals(portalFlight.get(player.getUniqueId()));
if ((!ownerFlight && !temporaryFlight) || pocket.level() < 1) {
removeGrantedFlight(player);
return false;
}
int halfSize = pocketBases.policy().size(pocket.level()) / 2;
int x = player.getLocation().getBlockX();
int z = player.getLocation().getBlockZ();
if (x < -halfSize - POCKET_FLIGHT_BUFFER
|| x >= halfSize + POCKET_FLIGHT_BUFFER
|| z < -halfSize - POCKET_FLIGHT_BUFFER
|| z >= halfSize + POCKET_FLIGHT_BUFFER) {
removeGrantedFlight(player);
return false;
}
if (!player.getAllowFlight()) {
player.setAllowFlight(true);
grantedFlight.add(player.getUniqueId());
}
boolean inBuffer = !pocketBases.policy().contains(pocket.level(), x, z);
if (inBuffer && player.isFlying()) {
if (warned.add(player.getUniqueId())) {
player.sendTitle(
ChatColor.RED + "Leaving Your Pocket Base",
ChatColor.YELLOW + "Turn back before Pocket Base flight ends",
0,
Math.min(30, settings.current().titleStayTicks()),
settings.current().titleFadeOutTicks()
);
}
} else {
warned.remove(player.getUniqueId());
}
return true;
}
private void clearPortalFlight(Player player) {
portalFlight.remove(player.getUniqueId());
removeGrantedFlight(player);
}
private boolean withinVerticalRange(Player player, PlayerState state, BaseLocation base) { private boolean withinVerticalRange(Player player, PlayerState state, BaseLocation base) {
int y = player.getLocation().getBlockY(); int y = player.getLocation().getBlockY();
if (state.flightLevel() == 3) { if (state.flightLevel() == 3) {
@@ -93,6 +93,10 @@ final class BaseSettingsCommand implements CommandExecutor, TabCompleter {
&& arguments[1].equalsIgnoreCase("upgrade")) { && arguments[1].equalsIgnoreCase("upgrade")) {
return purchasePocketUpgrade(player, state); return purchasePocketUpgrade(player, state);
} }
if (arguments.length == 3 && arguments[0].equalsIgnoreCase("pocket")
&& arguments[1].equalsIgnoreCase("flight")) {
return updatePocketFlight(player, arguments[2]);
}
if (arguments.length == 4 && arguments[0].equalsIgnoreCase("pocket") if (arguments.length == 4 && arguments[0].equalsIgnoreCase("pocket")
&& arguments[1].equalsIgnoreCase("mobs")) { && arguments[1].equalsIgnoreCase("mobs")) {
return updatePocketMobSpawning(player, arguments[2], arguments[3]); return updatePocketMobSpawning(player, arguments[2], arguments[3]);
@@ -264,6 +268,35 @@ final class BaseSettingsCommand implements CommandExecutor, TabCompleter {
return true; return true;
} }
private boolean updatePocketFlight(Player player, String mode) {
if (pocketBases == null) {
player.sendMessage(ChatColor.RED + "Pocket Bases are currently unavailable.");
return true;
}
PocketBaseState current = pocketBases.state(player.getUniqueId());
if (!current.flightUnlocked()) {
player.sendMessage(ChatColor.RED
+ "Win a raid in your Pocket Base before enabling automatic flight.");
return true;
}
Boolean enabled = enabledMode(mode);
if (enabled == null) {
sendUsage(player);
return true;
}
try {
current = pocketBases.setFlightEnabled(player.getUniqueId(), enabled);
} catch (IOException | RuntimeException exception) {
player.sendMessage(ChatColor.RED
+ "The Pocket Base flight setting could not be changed.");
return true;
}
player.sendMessage(ChatColor.YELLOW + "Pocket Base automatic flight is now "
+ (current.flightEnabled() ? ChatColor.GREEN + "enabled" : ChatColor.RED + "disabled")
+ ChatColor.YELLOW + ".");
return true;
}
private boolean updatePocketMobSpawning( private boolean updatePocketMobSpawning(
Player player, Player player,
String category, String category,
@@ -565,7 +598,11 @@ final class BaseSettingsCommand implements CommandExecutor, TabCompleter {
+ "; hostile spawning=" + "; hostile spawning="
+ (pocket.hostileMobSpawningEnabled() ? "enabled" : "disabled") + (pocket.hostileMobSpawningEnabled() ? "enabled" : "disabled")
+ "; passive spawning=" + "; passive spawning="
+ (pocket.passiveMobSpawningEnabled() ? "enabled" : "disabled")); + (pocket.passiveMobSpawningEnabled() ? "enabled" : "disabled")
+ "; flight="
+ (pocket.flightUnlocked()
? "automatic " + (pocket.flightEnabled() ? "enabled" : "disabled")
: "complete a raid to unlock"));
} }
private void showCooldownPath(Player player, PlayerState state) { private void showCooldownPath(Player player, PlayerState state) {
@@ -597,7 +634,7 @@ final class BaseSettingsCommand implements CommandExecutor, TabCompleter {
List<String> modes = arguments[0].equalsIgnoreCase("visitors") List<String> modes = arguments[0].equalsIgnoreCase("visitors")
? VISITOR_MODES ? VISITOR_MODES
: switch (arguments[0].toLowerCase(Locale.ROOT)) { : switch (arguments[0].toLowerCase(Locale.ROOT)) {
case "pocket" -> List.of("upgrade", "mobs", "type"); case "pocket" -> List.of("upgrade", "flight", "mobs", "type");
case "navigation", "flight", "border", "spawnable", "bossbar" -> ENABLE_MODES; case "navigation", "flight", "border", "spawnable", "bossbar" -> ENABLE_MODES;
default -> List.of(); default -> List.of();
}; };
@@ -606,6 +643,11 @@ final class BaseSettingsCommand implements CommandExecutor, TabCompleter {
} }
if (arguments.length == 3 && arguments[0].equalsIgnoreCase("pocket")) { if (arguments.length == 3 && arguments[0].equalsIgnoreCase("pocket")) {
String prefix = arguments[2].toLowerCase(Locale.ROOT); String prefix = arguments[2].toLowerCase(Locale.ROOT);
if (arguments[1].equalsIgnoreCase("flight")) {
return ENABLE_MODES.stream()
.filter(mode -> mode.startsWith(prefix))
.toList();
}
if (arguments[1].equalsIgnoreCase("mobs")) { if (arguments[1].equalsIgnoreCase("mobs")) {
return List.of("hostile", "passive").stream() return List.of("hostile", "passive").stream()
.filter(category -> category.startsWith(prefix)) .filter(category -> category.startsWith(prefix))
@@ -699,7 +741,7 @@ final class BaseSettingsCommand implements CommandExecutor, TabCompleter {
private static void sendUsage(Player player) { private static void sendUsage(Player player) {
player.sendMessage(ChatColor.RED + "Usage: /basesettings " player.sendMessage(ChatColor.RED + "Usage: /basesettings "
+ "[ui|status|upgrade|pocket upgrade|pocket mobs " + "[ui|status|upgrade|pocket upgrade|pocket flight <enable|disable>|pocket mobs "
+ "<hostile|passive> <enable|disable>" + "<hostile|passive> <enable|disable>"
+ "|pocket type <void|nether|overworld> <subtype>" + "|pocket type <void|nether|overworld> <subtype>"
+ "|visitors <allowed|blocked>|navigation <enable|disable>" + "|visitors <allowed|blocked>|navigation <enable|disable>"
@@ -147,6 +147,7 @@ final class BaseSettingsDialogFactory {
private DialogSpec pocketSettings(PlayerState owner, PocketBaseState pocket) { private DialogSpec pocketSettings(PlayerState owner, PocketBaseState pocket) {
List<DialogSpec> dialogs = List.of( List<DialogSpec> dialogs = List.of(
pocketUpgrade(owner, pocket), pocketUpgrade(owner, pocket),
pocketFlightSettings(pocket),
pocketMobSettings(pocket), pocketMobSettings(pocket),
pocketBiomeSettings(pocket) pocketBiomeSettings(pocket)
); );
@@ -159,7 +160,10 @@ final class BaseSettingsDialogFactory {
: "Biome: " + pocket.biome().worldType().commandName() + "/" : "Biome: " + pocket.biome().worldType().commandName() + "/"
+ pocket.biome().commandName() + "\nHostile mobs: " + pocket.biome().commandName() + "\nHostile mobs: "
+ enabled(pocket.hostileMobSpawningEnabled()) + " • Passive mobs: " + enabled(pocket.hostileMobSpawningEnabled()) + " • Passive mobs: "
+ enabled(pocket.passiveMobSpawningEnabled()) + enabled(pocket.passiveMobSpawningEnabled()) + "\nFlight: "
+ (pocket.flightUnlocked()
? (pocket.flightEnabled() ? "Enabled" : "Disabled")
: "Complete a raid to unlock")
), ),
dialogs, dialogs,
1, 1,
@@ -188,6 +192,32 @@ final class BaseSettingsDialogFactory {
); );
} }
private DialogSpec pocketFlightSettings(PocketBaseState pocket) {
if (!pocket.flightUnlocked()) {
return notice(
"Pocket Flight — Locked",
"Win a raid in your Pocket Base to unlock automatic flight."
);
}
return new MultiSpec(
content(
"Pocket Flight",
"Pocket Flight",
"Control automatic owner flight. Flight granted by the gold portal "
+ "is unaffected."
),
List.of(toggle(
"Automatic Flight",
true,
pocket.flightEnabled(),
"Enabled",
"Disabled",
"basesettings pocket flight " + mode(!pocket.flightEnabled())
)),
1
);
}
private DialogSpec pocketMobSettings(PocketBaseState pocket) { private DialogSpec pocketMobSettings(PocketBaseState pocket) {
if (pocket.level() < 1) { if (pocket.level() < 1) {
return notice( return notice(
@@ -325,14 +355,19 @@ final class BaseSettingsDialogFactory {
} }
private static Dialog render(DialogSpec specification) { private static Dialog render(DialogSpec specification) {
return render(specification, true);
}
private static Dialog render(DialogSpec specification, boolean root) {
DialogType type; DialogType type;
ActionButton back = root ? null : backButton();
if (specification instanceof ListSpec list) { if (specification instanceof ListSpec list) {
List<Dialog> dialogs = list.dialogs().stream() List<Dialog> dialogs = list.dialogs().stream()
.map(BaseSettingsDialogFactory::render) .map(child -> render(child, false))
.toList(); .toList();
type = DialogType.dialogList( type = DialogType.dialogList(
RegistrySet.valueSet(RegistryKey.DIALOG, dialogs), RegistrySet.valueSet(RegistryKey.DIALOG, dialogs),
null, back,
list.columns(), list.columns(),
list.buttonWidth() list.buttonWidth()
); );
@@ -340,13 +375,15 @@ final class BaseSettingsDialogFactory {
List<ActionButton> actions = multi.actions().stream() List<ActionButton> actions = multi.actions().stream()
.map(BaseSettingsDialogFactory::render) .map(BaseSettingsDialogFactory::render)
.toList(); .toList();
type = DialogType.multiAction(actions, null, multi.columns()); type = DialogType.multiAction(actions, back, multi.columns());
} else if (specification instanceof ConfirmationSpec confirmation) { } else if (specification instanceof ConfirmationSpec confirmation) {
ActionButton cancel = ActionButton.builder(Component.text("Cancel")).build(); type = DialogType.confirmation(
type = DialogType.confirmation(render(confirmation.confirm()), cancel); render(confirmation.confirm()),
root ? ActionButton.builder(Component.text("Cancel")).build() : back
);
} else if (specification instanceof NoticeSpec notice) { } else if (specification instanceof NoticeSpec notice) {
type = notice.action() == null type = notice.action() == null
? DialogType.notice() ? (root ? DialogType.notice() : DialogType.notice(back))
: DialogType.notice(render(notice.action())); : DialogType.notice(render(notice.action()));
} else { } else {
throw new IllegalArgumentException("Unsupported dialog specification"); throw new IllegalArgumentException("Unsupported dialog specification");
@@ -364,6 +401,14 @@ final class BaseSettingsDialogFactory {
return Dialog.create(factory -> factory.empty().base(base).type(type)); return Dialog.create(factory -> factory.empty().base(base).type(type));
} }
private static ActionButton backButton() {
return render(backButtonSpec());
}
static ButtonSpec backButtonSpec() {
return commandButton("Back", "basesettings ui");
}
private static ActionButton render(ButtonSpec button) { private static ActionButton render(ButtonSpec button) {
ActionButton.Builder builder = ActionButton.builder(Component.text(button.label())) ActionButton.Builder builder = ActionButton.builder(Component.text(button.label()))
.width(button.width()) .width(button.width())
@@ -34,6 +34,7 @@ final class BaseTeleportManager implements Listener {
private final VisitorPolicy visitorPolicy; private final VisitorPolicy visitorPolicy;
private final SafeBaseDestination destinationFinder; private final SafeBaseDestination destinationFinder;
private final Clock clock; private final Clock clock;
private final TeleportParticles particles;
private final Map<UUID, Request> requests = new HashMap<>(); private final Map<UUID, Request> requests = new HashMap<>();
BaseTeleportManager( BaseTeleportManager(
@@ -44,6 +45,20 @@ final class BaseTeleportManager implements Listener {
SafeBaseDestination destinationFinder, SafeBaseDestination destinationFinder,
Clock clock Clock clock
) { ) {
this(plugin, stateManager, policy, visitorPolicy, destinationFinder, clock,
new TeleportParticles(plugin));
}
BaseTeleportManager(
Plugin plugin,
BaseStateManager stateManager,
TeleportPolicy policy,
VisitorPolicy visitorPolicy,
SafeBaseDestination destinationFinder,
Clock clock,
TeleportParticles particles
) {
this.particles = particles;
this.plugin = plugin; this.plugin = plugin;
this.stateManager = stateManager; this.stateManager = stateManager;
this.policy = policy; this.policy = policy;
@@ -138,6 +153,7 @@ final class BaseTeleportManager implements Listener {
return; return;
} }
requests.put(player.getUniqueId(), request); requests.put(player.getUniqueId(), request);
particles.start(player.getUniqueId(), origin, seconds);
request.task = Bukkit.getScheduler().runTaskTimer(plugin, () -> tick(player, request), 0L, 20L); request.task = Bukkit.getScheduler().runTaskTimer(plugin, () -> tick(player, request), 0L, 20L);
player.sendMessage(ChatColor.YELLOW + "Stand still for " + seconds + " seconds to " + purpose + "."); player.sendMessage(ChatColor.YELLOW + "Stand still for " + seconds + " seconds to " + purpose + ".");
} }
@@ -147,6 +163,7 @@ final class BaseTeleportManager implements Listener {
request.cancelTask(); request.cancelTask();
} }
requests.clear(); requests.clear();
particles.clear();
} }
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
@@ -204,6 +221,9 @@ final class BaseTeleportManager implements Listener {
private void tick(Player player, Request request) { private void tick(Player player, Request request) {
if (!player.isOnline() || requests.get(player.getUniqueId()) != request) { if (!player.isOnline() || requests.get(player.getUniqueId()) != request) {
if (requests.remove(player.getUniqueId(), request)) {
particles.cancel(player.getUniqueId());
}
request.cancelTask(); request.cancelTask();
return; return;
} }
@@ -225,18 +245,22 @@ final class BaseTeleportManager implements Listener {
BaseLocation base = request.destination; BaseLocation base = request.destination;
World world = Bukkit.getWorld(base.worldId()); World world = Bukkit.getWorld(base.worldId());
if (world == null) { if (world == null) {
particles.cancel(player.getUniqueId());
player.sendMessage(ChatColor.RED + "The destination world is not currently available."); player.sendMessage(ChatColor.RED + "The destination world is not currently available.");
return; return;
} }
Optional<Location> destination = destinationFinder.find(world, base); Optional<Location> destination = destinationFinder.find(world, base);
if (destination.isEmpty()) { if (destination.isEmpty()) {
particles.cancel(player.getUniqueId());
player.sendMessage(ChatColor.RED + "No safe location could be found at the base."); player.sendMessage(ChatColor.RED + "No safe location could be found at the base.");
return; return;
} }
if (!player.teleport(destination.orElseThrow(), PlayerTeleportEvent.TeleportCause.PLUGIN)) { if (!player.teleport(destination.orElseThrow(), PlayerTeleportEvent.TeleportCause.PLUGIN)) {
particles.cancel(player.getUniqueId());
player.sendMessage(ChatColor.RED + "The base teleport was prevented."); player.sendMessage(ChatColor.RED + "The base teleport was prevented.");
return; return;
} }
particles.complete(player.getUniqueId());
Instant completedAt = clock.instant(); Instant completedAt = clock.instant();
stateManager.update( stateManager.update(
player.getUniqueId(), player.getUniqueId(),
@@ -260,6 +284,7 @@ final class BaseTeleportManager implements Listener {
return; return;
} }
request.cancelTask(); request.cancelTask();
particles.cancel(player.getUniqueId());
if (message != null) { if (message != null) {
player.sendMessage(ChatColor.RED + message); player.sendMessage(ChatColor.RED + message);
} }
@@ -0,0 +1,26 @@
package games.dmg.spigotbase;
import java.util.Objects;
import java.util.UUID;
public record KeystoneReturnLocation(
UUID worldId,
String worldName,
double x,
double y,
double z,
float yaw,
float pitch
) {
public KeystoneReturnLocation {
Objects.requireNonNull(worldId, "worldId");
Objects.requireNonNull(worldName, "worldName");
if (worldName.isBlank()) {
throw new IllegalArgumentException("worldName must not be blank");
}
if (!Double.isFinite(x) || !Double.isFinite(y) || !Double.isFinite(z)
|| !Float.isFinite(yaw) || !Float.isFinite(pitch)) {
throw new IllegalArgumentException("return coordinates must be finite");
}
}
}
@@ -0,0 +1,35 @@
package games.dmg.spigotbase;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
final class KeystoneReturnStore {
private final YamlKeystoneReturnRepository repository;
private final Map<UUID, KeystoneReturnLocation> destinations;
KeystoneReturnStore(YamlKeystoneReturnRepository repository) throws IOException {
this.repository = repository;
this.destinations = new HashMap<>(repository.load());
}
Optional<KeystoneReturnLocation> destination(UUID playerId) {
return Optional.ofNullable(destinations.get(playerId));
}
void record(UUID playerId, KeystoneReturnLocation destination) throws IOException {
KeystoneReturnLocation previous = destinations.put(playerId, destination);
try {
repository.save(destinations);
} catch (IOException exception) {
if (previous == null) {
destinations.remove(playerId);
} else {
destinations.put(playerId, previous);
}
throw exception;
}
}
}
@@ -39,6 +39,7 @@ final class PocketBaseController implements Listener, Runnable {
private final PocketBaseManager pocketBases; private final PocketBaseManager pocketBases;
private final PluginSettingsProvider settings; private final PluginSettingsProvider settings;
private final Logger logger; private final Logger logger;
private final BaseFlightController flightController;
private final Map<UUID, Long> cooldownUntil = new HashMap<>(); private final Map<UUID, Long> cooldownUntil = new HashMap<>();
PocketBaseController( PocketBaseController(
@@ -48,6 +49,18 @@ final class PocketBaseController implements Listener, Runnable {
PocketBaseManager pocketBases, PocketBaseManager pocketBases,
PluginSettingsProvider settings, PluginSettingsProvider settings,
Logger logger Logger logger
) {
this(server, baseStates, baseBounds, pocketBases, settings, logger, null);
}
PocketBaseController(
Server server,
BaseStateManager baseStates,
BaseBoundsService baseBounds,
PocketBaseManager pocketBases,
PluginSettingsProvider settings,
Logger logger,
BaseFlightController flightController
) { ) {
this.server = server; this.server = server;
this.baseStates = baseStates; this.baseStates = baseStates;
@@ -55,6 +68,7 @@ final class PocketBaseController implements Listener, Runnable {
this.pocketBases = pocketBases; this.pocketBases = pocketBases;
this.settings = settings; this.settings = settings;
this.logger = logger; this.logger = logger;
this.flightController = flightController;
} }
@Override @Override
@@ -65,6 +79,8 @@ final class PocketBaseController implements Listener, Runnable {
if (state.level() > 0 && pocketBases.returnPortalIsIntact(state.ownerId())) { if (state.level() > 0 && pocketBases.returnPortalIsIntact(state.ownerId())) {
showPortal(pocketBases.returnPortal(state.ownerId())); showPortal(pocketBases.returnPortal(state.ownerId()));
} }
state.flightPortal().filter(portal -> isIntact(portal, Material.GOLD_BLOCK))
.ifPresent(portal -> showPortal(portal, Material.GOLD_BLOCK));
} }
scanMobPortals(); scanMobPortals();
long now = System.nanoTime(); long now = System.nanoTime();
@@ -88,41 +104,57 @@ final class PocketBaseController implements Listener, Runnable {
Material frameMaterial = Material.valueOf( Material frameMaterial = Material.valueOf(
settings.current().pocketBasePortalFrameMaterial() settings.current().pocketBasePortalFrameMaterial()
); );
Optional<PocketPortalLocation> portal = PocketPortalGeometry.findFrame(
position(clicked),
world.getUID(),
world.getName(),
candidate -> block(world, candidate).getType() == frameMaterial,
candidate -> isAir(block(world, candidate).getType())
);
if (portal.isEmpty()) {
return;
}
Optional<UUID> pocketOwner = pocketBases.ownerForPocketWorld(world.getUID()); Optional<UUID> pocketOwner = pocketBases.ownerForPocketWorld(world.getUID());
if (pocketOwner.isPresent()) { if (pocketOwner.isPresent()) {
if (!pocketOwner.orElseThrow().equals(player.getUniqueId())) { if (!pocketOwner.orElseThrow().equals(player.getUniqueId())) {
return; return;
} }
Optional<PocketPortalLocation> flightPortal = pocket.flightUnlocked()
? findFrame(clicked, Material.GOLD_BLOCK)
: Optional.empty();
boolean activatingFlightPortal = flightPortal.isPresent();
Optional<PocketPortalLocation> portal = activatingFlightPortal
? flightPortal
: findFrame(clicked, frameMaterial);
if (portal.isEmpty()) {
return;
}
if (PocketPortalGeometry.frameBlocks(portal.orElseThrow()).stream() if (PocketPortalGeometry.frameBlocks(portal.orElseThrow()).stream()
.anyMatch(candidate -> !pocketBases.policy().contains( .anyMatch(candidate -> !pocketBases.policy().contains(
pocket.level(), candidate.x(), candidate.z() pocket.level(), candidate.x(), candidate.z()
))) { ))) {
player.sendMessage(ChatColor.RED player.sendMessage(ChatColor.RED + "The complete "
+ "The complete return portal must be inside your Pocket Base boundary."); + (activatingFlightPortal ? "flight" : "return")
+ " portal must be inside your Pocket Base boundary.");
event.setCancelled(true); event.setCancelled(true);
return; return;
} }
try { try {
pocketBases.activateReturnPortal(player.getUniqueId(), portal.orElseThrow()); if (activatingFlightPortal) {
pocketBases.activateFlightPortal(
player.getUniqueId(), portal.orElseThrow()
);
} else {
pocketBases.activateReturnPortal(
player.getUniqueId(), portal.orElseThrow()
);
}
} catch (IOException | RuntimeException exception) { } catch (IOException | RuntimeException exception) {
logger.log(Level.SEVERE, "Could not activate Pocket Base return portal", exception); logger.log(Level.SEVERE, "Could not activate Pocket Base "
player.sendMessage(ChatColor.RED + (activatingFlightPortal ? "flight" : "return") + " portal", exception);
+ "The Pocket Base return portal could not be activated."); player.sendMessage(ChatColor.RED + "The Pocket Base "
+ (activatingFlightPortal ? "flight" : "return")
+ " portal could not be activated.");
event.setCancelled(true); event.setCancelled(true);
return; return;
} }
event.setCancelled(true); event.setCancelled(true);
player.sendMessage(ChatColor.GREEN + "Pocket Base return portal activated."); player.sendMessage(ChatColor.GREEN + "Pocket Base "
+ (activatingFlightPortal ? "flight" : "return") + " portal activated.");
return;
}
Optional<PocketPortalLocation> portal = findFrame(clicked, frameMaterial);
if (portal.isEmpty()) {
return; return;
} }
PlayerState owner = baseStates.player(player.getUniqueId(), player.getName()); PlayerState owner = baseStates.player(player.getUniqueId(), player.getName());
@@ -161,6 +193,13 @@ final class PocketBaseController implements Listener, Runnable {
deactivate(state.ownerId()); deactivate(state.ownerId());
} }
} }
if (state.flightPortal().isPresent()) {
PocketPortalLocation portal = state.flightPortal().orElseThrow();
if (portal.worldId().equals(worldId)
&& PocketPortalGeometry.isFrame(portal, broken)) {
deactivateFlightPortal(state.ownerId());
}
}
} }
} }
@@ -199,6 +238,18 @@ final class PocketBaseController implements Listener, Runnable {
pocketBases.returnPortal(ownerId), position(destination) pocketBases.returnPortal(ownerId), position(destination)
)) { )) {
leavePocket(player, ownerId); leavePocket(player, ownerId);
return;
}
PocketBaseState state = pocketBases.state(ownerId);
if (flightController != null && state.flightPortal().isPresent()) {
PocketPortalLocation portal = state.flightPortal().orElseThrow();
if (isIntact(portal, Material.GOLD_BLOCK)
&& PocketPortalGeometry.isInterior(
portal, position(destination)
) && (event.getFrom() == null
|| !PocketPortalGeometry.isInterior(portal, position(event.getFrom())))) {
flightController.grantPocketPortalFlight(player, ownerId);
}
} }
return; return;
} }
@@ -316,18 +367,26 @@ final class PocketBaseController implements Listener, Runnable {
} }
private boolean isIntact(PocketPortalLocation portal) { private boolean isIntact(PocketPortalLocation portal) {
World world = server.getWorld(portal.worldId());
if (world == null) {
return false;
}
Material frame = Material.valueOf(settings.current().pocketBasePortalFrameMaterial()); Material frame = Material.valueOf(settings.current().pocketBasePortalFrameMaterial());
return PocketPortalGeometry.frameBlocks(portal).stream() return isIntact(portal, frame);
}
private boolean isIntact(PocketPortalLocation portal, Material frame) {
World world = server.getWorld(portal.worldId());
return world != null && PocketPortalGeometry.frameBlocks(portal).stream()
.allMatch(position -> block(world, position).getType() == frame); .allMatch(position -> block(world, position).getType() == frame);
} }
private void showPortal(PocketPortalLocation portal) { private void showPortal(PocketPortalLocation portal) {
showPortal(
portal,
Material.valueOf(settings.current().pocketBasePortalFrameMaterial())
);
}
private void showPortal(PocketPortalLocation portal, Material frame) {
World world = server.getWorld(portal.worldId()); World world = server.getWorld(portal.worldId());
if (world == null || !isIntact(portal)) { if (world == null || !isIntact(portal, frame)) {
return; return;
} }
for (BlockPosition position : PocketPortalGeometry.interiorBlocks(portal)) { for (BlockPosition position : PocketPortalGeometry.interiorBlocks(portal)) {
@@ -345,6 +404,14 @@ final class PocketBaseController implements Listener, Runnable {
} }
} }
private void deactivateFlightPortal(UUID ownerId) {
try {
pocketBases.deactivateFlightPortal(ownerId);
} catch (IOException exception) {
logger.log(Level.SEVERE, "Could not deactivate Pocket Base flight portal", exception);
}
}
private void deactivate(UUID ownerId) { private void deactivate(UUID ownerId) {
try { try {
pocketBases.deactivateEntrance(ownerId); pocketBases.deactivateEntrance(ownerId);
@@ -383,6 +450,17 @@ final class PocketBaseController implements Listener, Runnable {
); );
} }
private Optional<PocketPortalLocation> findFrame(Block clicked, Material material) {
World world = clicked.getWorld();
return PocketPortalGeometry.findFrame(
position(clicked),
world.getUID(),
world.getName(),
candidate -> block(world, candidate).getType() == material,
candidate -> isAir(block(world, candidate).getType())
);
}
private static boolean isAir(Material material) { private static boolean isAir(Material material) {
return material == Material.AIR || material == Material.CAVE_AIR return material == Material.AIR || material == Material.CAVE_AIR
|| material == Material.VOID_AIR; || material == Material.VOID_AIR;
@@ -0,0 +1,259 @@
package games.dmg.spigotbase;
import java.io.IOException;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.logging.Level;
import java.util.logging.Logger;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.ChatColor;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.World;
import org.bukkit.block.Block;
import org.bukkit.NamespacedKey;
import org.bukkit.Server;
import org.bukkit.entity.Player;
import org.bukkit.event.Event;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.block.Action;
import org.bukkit.event.inventory.PrepareItemCraftEvent;
import org.bukkit.event.player.PlayerInteractEvent;
import org.bukkit.event.player.PlayerTeleportEvent;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.ShapedRecipe;
import org.bukkit.inventory.meta.ItemMeta;
import org.bukkit.persistence.PersistentDataType;
final class PocketBaseKeystoneService implements Listener {
private static final Set<Material> RETURN_HAZARDS = Set.of(
Material.LAVA,
Material.FIRE,
Material.SOUL_FIRE,
Material.MAGMA_BLOCK,
Material.CACTUS,
Material.CAMPFIRE,
Material.SOUL_CAMPFIRE,
Material.POWDER_SNOW
);
private final Server server;
private final PocketBaseManager pocketBases;
private final KeystoneReturnStore returns;
private final NamespacedKey markerKey;
private final Logger logger;
PocketBaseKeystoneService(
Server server,
PocketBaseManager pocketBases,
KeystoneReturnStore returns,
NamespacedKey markerKey,
Logger logger
) {
this.server = server;
this.pocketBases = pocketBases;
this.returns = returns;
this.markerKey = markerKey;
this.logger = logger;
}
@EventHandler(priority = EventPriority.HIGH)
public void onPrepareCraft(PrepareItemCraftEvent event) {
if (!(event.getRecipe() instanceof ShapedRecipe shaped)
|| !shaped.getKey().equals(markerKey)
|| !(event.getView().getPlayer() instanceof Player player)) {
return;
}
if (pocketBases.state(player.getUniqueId()).level() < 1) {
event.getInventory().setResult(null);
}
}
@EventHandler(priority = EventPriority.HIGH)
public void onUse(PlayerInteractEvent event) {
Action action = event.getAction();
if ((action != Action.RIGHT_CLICK_AIR && action != Action.RIGHT_CLICK_BLOCK)
|| !isKeystone(event.getItem(), markerKey)) {
return;
}
// Vanilla no-action air clicks can deny block use without denying item use.
// Accept those events, but respect explicit item-use denial by other plugins.
if (event.useItemInHand() == Event.Result.DENY) {
return;
}
event.setCancelled(true);
Player player = event.getPlayer();
UUID playerId = player.getUniqueId();
if (pocketBases.state(playerId).level() < 1) {
player.sendMessage(ChatColor.RED
+ "Unlock Pocket Base I before using a Pocket Base Keystone.");
return;
}
Optional<UUID> currentPocket = pocketBases.ownerForPocketWorld(
player.getWorld().getUID()
);
if (currentPocket.isPresent()) {
if (!currentPocket.orElseThrow().equals(playerId)) {
player.sendMessage(ChatColor.RED
+ "You can only use a keystone to leave your own Pocket Base.");
return;
}
returnToSavedDestination(player);
return;
}
Location origin = player.getLocation();
World originWorld = origin.getWorld();
if (originWorld == null) {
player.sendMessage(ChatColor.RED + "Your return location could not be recorded.");
return;
}
KeystoneReturnLocation destination = new KeystoneReturnLocation(
originWorld.getUID(),
originWorld.getName(),
origin.getX(),
origin.getY(),
origin.getZ(),
origin.getYaw(),
origin.getPitch()
);
try {
returns.record(playerId, destination);
} catch (IOException exception) {
logger.log(Level.SEVERE, "Could not save a Pocket Base Keystone return", exception);
player.sendMessage(ChatColor.RED
+ "Your return location could not be saved, so you were not teleported.");
return;
}
teleport(player, pocketBases.pocketArrival(playerId));
}
private void returnToSavedDestination(Player player) {
Optional<KeystoneReturnLocation> saved = returns.destination(player.getUniqueId());
if (saved.isEmpty()) {
player.sendMessage(ChatColor.RED + "No Pocket Base Keystone return is recorded.");
return;
}
KeystoneReturnLocation destination = saved.orElseThrow();
World world = server.getWorld(destination.worldId());
if (world == null) {
player.sendMessage(ChatColor.RED + "Your recorded return world is unavailable.");
return;
}
Location location = new Location(
world,
destination.x(),
destination.y(),
destination.z(),
destination.yaw(),
destination.pitch()
);
if (!isSafe(location)) {
player.sendMessage(ChatColor.RED + "Your recorded return location is not safe.");
return;
}
teleport(player, location);
}
private static void teleport(Player player, Location destination) {
float previousFallDistance = player.getFallDistance();
player.setFallDistance(0.0F);
if (!player.teleport(destination, PlayerTeleportEvent.TeleportCause.PLUGIN)) {
player.setFallDistance(previousFallDistance);
player.sendMessage(ChatColor.RED + "Pocket Base Keystone travel failed.");
}
}
private static boolean isSafe(Location location) {
World world = location.getWorld();
int x = location.getBlockX();
int y = location.getBlockY();
int z = location.getBlockZ();
if (world == null || y <= world.getMinHeight() || y + 1 >= world.getMaxHeight()) {
return false;
}
Block ground = world.getBlockAt(x, y - 1, z);
Block feet = world.getBlockAt(x, y, z);
Block head = world.getBlockAt(x, y + 1, z);
return feet.isPassable() && head.isPassable()
&& !feet.isLiquid() && !head.isLiquid()
&& !ground.isPassable() && !ground.isLiquid()
&& !RETURN_HAZARDS.contains(ground.getType())
&& !RETURN_HAZARDS.contains(feet.getType())
&& !RETURN_HAZARDS.contains(head.getType());
}
static boolean isKeystone(ItemStack item, NamespacedKey markerKey) {
if (item == null || item.getType() != Material.RECOVERY_COMPASS) {
return false;
}
ItemMeta metadata = item.getItemMeta();
if (metadata == null) {
return false;
}
Byte marker = metadata.getPersistentDataContainer().get(
markerKey, PersistentDataType.BYTE
);
return marker != null && marker == (byte) 1;
}
void registerRecipe() {
server.addRecipe(recipe(markerKey, createKeystone(markerKey)));
}
static ItemStack createKeystone(NamespacedKey markerKey) {
ItemStack item = new ItemStack(Material.RECOVERY_COMPASS);
decorateKeystone(item, markerKey);
return item;
}
static void decorateKeystone(ItemStack item, NamespacedKey markerKey) {
ItemMeta metadata = item.getItemMeta();
if (metadata == null) {
throw new IllegalStateException("Recovery Compass metadata is unavailable");
}
metadata.displayName(Component.text(
"Pocket Base Keystone", NamedTextColor.LIGHT_PURPLE
));
metadata.lore(List.of(
Component.text(
"Right-click to enter your Pocket Base.", NamedTextColor.GRAY
),
Component.text("Use it again inside to return.", NamedTextColor.GRAY)
));
metadata.getPersistentDataContainer().set(
markerKey, PersistentDataType.BYTE, (byte) 1
);
item.setItemMeta(metadata);
}
static RecipeSpec recipeSpecification() {
return new RecipeSpec(
"ONO", "NEN", "ONO", Material.OBSIDIAN,
Material.NETHERITE_INGOT, Material.ENDER_PEARL
);
}
static ShapedRecipe recipe(NamespacedKey key, ItemStack result) {
RecipeSpec specification = recipeSpecification();
ShapedRecipe recipe = new ShapedRecipe(key, result);
recipe.shape(specification.top(), specification.middle(), specification.bottom());
recipe.setIngredient('O', specification.obsidian());
recipe.setIngredient('N', specification.netherite());
recipe.setIngredient('E', specification.enderPearl());
return recipe;
}
record RecipeSpec(
String top,
String middle,
String bottom,
Material obsidian,
Material netherite,
Material enderPearl
) {
}
}
@@ -60,6 +60,24 @@ final class PocketBaseManager {
} }
} }
PocketBaseState activateFlightPortal(
UUID ownerId,
PocketPortalLocation portal
) throws IOException {
PocketBaseState current = states.state(ownerId);
if (!current.flightUnlocked()) {
throw new IllegalStateException("Pocket Base flight is still locked");
}
return states.updateAndSave(ownerId, state -> state.withFlightPortal(portal));
}
void deactivateFlightPortal(UUID ownerId) throws IOException {
PocketBaseState current = states.state(ownerId);
if (current.flightPortal().isPresent()) {
states.updateAndSave(ownerId, PocketBaseState::withoutFlightPortal);
}
}
PocketBaseState activateReturnPortal( PocketBaseState activateReturnPortal(
UUID ownerId, UUID ownerId,
PocketPortalLocation portal PocketPortalLocation portal
@@ -92,6 +110,30 @@ final class PocketBaseManager {
} }
} }
PocketBaseState unlockFlight(UUID ownerId) throws IOException {
PocketBaseState current = states.state(ownerId);
if (current.level() < 1) {
throw new IllegalStateException("Pocket Base I is still locked");
}
if (current.flightUnlocked()) {
return current;
}
return states.updateAndSave(ownerId, PocketBaseState::withFlightUnlocked);
}
PocketBaseState setFlightEnabled(UUID ownerId, boolean enabled) throws IOException {
PocketBaseState current = states.state(ownerId);
if (!current.flightUnlocked()) {
throw new IllegalStateException("Pocket Base flight is still locked");
}
if (current.flightEnabled() == enabled) {
return current;
}
return states.updateAndSave(
ownerId, state -> state.withFlightEnabled(enabled)
);
}
PocketBaseState setHostileMobSpawning(UUID ownerId, boolean enabled) PocketBaseState setHostileMobSpawning(UUID ownerId, boolean enabled)
throws IOException { throws IOException {
PocketBaseState current = states.state(ownerId); PocketBaseState current = states.state(ownerId);
@@ -10,7 +10,10 @@ public record PocketBaseState(
Optional<PocketPortalLocation> returnPortal, Optional<PocketPortalLocation> returnPortal,
boolean hostileMobSpawningEnabled, boolean hostileMobSpawningEnabled,
boolean passiveMobSpawningEnabled, boolean passiveMobSpawningEnabled,
PocketBaseBiome biome PocketBaseBiome biome,
boolean flightUnlocked,
Optional<PocketPortalLocation> flightPortal,
boolean flightEnabled
) { ) {
public PocketBaseState { public PocketBaseState {
if (ownerId == null) { if (ownerId == null) {
@@ -21,10 +24,49 @@ public record PocketBaseState(
} }
entrance = entrance == null ? Optional.empty() : entrance; entrance = entrance == null ? Optional.empty() : entrance;
returnPortal = returnPortal == null ? Optional.empty() : returnPortal; returnPortal = returnPortal == null ? Optional.empty() : returnPortal;
flightPortal = flightPortal == null ? Optional.empty() : flightPortal;
biome = biome == null ? PocketBaseBiome.THE_VOID : biome; biome = biome == null ? PocketBaseBiome.THE_VOID : biome;
if (level == 0 && (entrance.isPresent() || returnPortal.isPresent())) { if (level == 0 && (entrance.isPresent() || returnPortal.isPresent()
throw new IllegalArgumentException("a locked Pocket Base cannot have a portal"); || flightPortal.isPresent() || flightUnlocked || flightEnabled)) {
throw new IllegalArgumentException("a locked Pocket Base cannot have unlocks or portals");
} }
if (!flightUnlocked && (flightPortal.isPresent() || flightEnabled)) {
throw new IllegalArgumentException(
"Pocket Base flight must be unlocked before it is enabled or shared"
);
}
}
public PocketBaseState(
UUID ownerId,
int level,
Optional<PocketPortalLocation> entrance,
Optional<PocketPortalLocation> returnPortal,
boolean hostileMobSpawningEnabled,
boolean passiveMobSpawningEnabled,
PocketBaseBiome biome,
boolean flightUnlocked
) {
this(
ownerId, level, entrance, returnPortal, hostileMobSpawningEnabled,
passiveMobSpawningEnabled, biome, flightUnlocked, Optional.empty(),
flightUnlocked
);
}
public PocketBaseState(
UUID ownerId,
int level,
Optional<PocketPortalLocation> entrance,
Optional<PocketPortalLocation> returnPortal,
boolean hostileMobSpawningEnabled,
boolean passiveMobSpawningEnabled,
PocketBaseBiome biome
) {
this(
ownerId, level, entrance, returnPortal, hostileMobSpawningEnabled,
passiveMobSpawningEnabled, biome, false, Optional.empty(), false
);
} }
public PocketBaseState( public PocketBaseState(
@@ -34,63 +76,88 @@ public record PocketBaseState(
) { ) {
this( this(
ownerId, level, entrance, Optional.empty(), false, false, ownerId, level, entrance, Optional.empty(), false, false,
PocketBaseBiome.THE_VOID PocketBaseBiome.THE_VOID, false, Optional.empty(), false
); );
} }
public static PocketBaseState locked(UUID ownerId) { public static PocketBaseState locked(UUID ownerId) {
return new PocketBaseState( return new PocketBaseState(
ownerId, 0, Optional.empty(), Optional.empty(), false, false, ownerId, 0, Optional.empty(), Optional.empty(), false, false,
PocketBaseBiome.THE_VOID PocketBaseBiome.THE_VOID, false, Optional.empty(), false
); );
} }
public PocketBaseState withLevel(int newLevel) { public PocketBaseState withLevel(int newLevel) {
return new PocketBaseState( return copy(newLevel, entrance, returnPortal, hostileMobSpawningEnabled,
ownerId, newLevel, entrance, returnPortal, hostileMobSpawningEnabled, passiveMobSpawningEnabled, biome, flightUnlocked, flightPortal, flightEnabled);
passiveMobSpawningEnabled, biome
);
} }
public PocketBaseState withEntrance(PocketPortalLocation portal) { public PocketBaseState withEntrance(PocketPortalLocation portal) {
return new PocketBaseState( return copy(level, Optional.of(portal), returnPortal, hostileMobSpawningEnabled,
ownerId, level, Optional.of(portal), returnPortal, passiveMobSpawningEnabled, biome, flightUnlocked, flightPortal, flightEnabled);
hostileMobSpawningEnabled, passiveMobSpawningEnabled, biome
);
} }
public PocketBaseState withoutEntrance() { public PocketBaseState withoutEntrance() {
return new PocketBaseState( return copy(level, Optional.empty(), returnPortal, hostileMobSpawningEnabled,
ownerId, level, Optional.empty(), returnPortal, hostileMobSpawningEnabled, passiveMobSpawningEnabled, biome, flightUnlocked, flightPortal, flightEnabled);
passiveMobSpawningEnabled, biome
);
} }
public PocketBaseState withReturnPortal(PocketPortalLocation portal) { public PocketBaseState withReturnPortal(PocketPortalLocation portal) {
return new PocketBaseState( return copy(level, entrance, Optional.of(portal), hostileMobSpawningEnabled,
ownerId, level, entrance, Optional.of(portal), hostileMobSpawningEnabled, passiveMobSpawningEnabled, biome, flightUnlocked, flightPortal, flightEnabled);
passiveMobSpawningEnabled, biome
);
} }
public PocketBaseState withHostileMobSpawningEnabled(boolean enabled) { public PocketBaseState withHostileMobSpawningEnabled(boolean enabled) {
return new PocketBaseState( return copy(level, entrance, returnPortal, enabled, passiveMobSpawningEnabled,
ownerId, level, entrance, returnPortal, enabled, biome, flightUnlocked, flightPortal, flightEnabled);
passiveMobSpawningEnabled, biome
);
} }
public PocketBaseState withPassiveMobSpawningEnabled(boolean enabled) { public PocketBaseState withPassiveMobSpawningEnabled(boolean enabled) {
return new PocketBaseState( return copy(level, entrance, returnPortal, hostileMobSpawningEnabled, enabled,
ownerId, level, entrance, returnPortal, hostileMobSpawningEnabled, biome, flightUnlocked, flightPortal, flightEnabled);
enabled, biome
);
} }
public PocketBaseState withBiome(PocketBaseBiome selectedBiome) { public PocketBaseState withBiome(PocketBaseBiome selectedBiome) {
return copy(level, entrance, returnPortal, hostileMobSpawningEnabled,
passiveMobSpawningEnabled, selectedBiome, flightUnlocked, flightPortal,
flightEnabled);
}
public PocketBaseState withFlightUnlocked() {
return copy(level, entrance, returnPortal, hostileMobSpawningEnabled,
passiveMobSpawningEnabled, biome, true, flightPortal, true);
}
public PocketBaseState withFlightEnabled(boolean enabled) {
return copy(level, entrance, returnPortal, hostileMobSpawningEnabled,
passiveMobSpawningEnabled, biome, flightUnlocked, flightPortal, enabled);
}
public PocketBaseState withFlightPortal(PocketPortalLocation portal) {
return copy(level, entrance, returnPortal, hostileMobSpawningEnabled,
passiveMobSpawningEnabled, biome, flightUnlocked, Optional.of(portal),
flightEnabled);
}
public PocketBaseState withoutFlightPortal() {
return copy(level, entrance, returnPortal, hostileMobSpawningEnabled,
passiveMobSpawningEnabled, biome, flightUnlocked, Optional.empty(), flightEnabled);
}
private PocketBaseState copy(
int newLevel,
Optional<PocketPortalLocation> newEntrance,
Optional<PocketPortalLocation> newReturnPortal,
boolean hostileEnabled,
boolean passiveEnabled,
PocketBaseBiome selectedBiome,
boolean unlockedFlight,
Optional<PocketPortalLocation> newFlightPortal,
boolean enabledFlight
) {
return new PocketBaseState( return new PocketBaseState(
ownerId, level, entrance, returnPortal, hostileMobSpawningEnabled, ownerId, newLevel, newEntrance, newReturnPortal, hostileEnabled, passiveEnabled,
passiveMobSpawningEnabled, selectedBiome selectedBiome, unlockedFlight, newFlightPortal, enabledFlight
); );
} }
} }
@@ -5,6 +5,7 @@ import java.time.Clock;
import java.util.Map; import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.logging.Level; import java.util.logging.Level;
import org.bukkit.NamespacedKey;
import org.bukkit.command.PluginCommand; import org.bukkit.command.PluginCommand;
import org.bukkit.plugin.java.JavaPlugin; import org.bukkit.plugin.java.JavaPlugin;
@@ -16,6 +17,7 @@ public final class SpigotBasePlugin extends JavaPlugin {
private BaseTeleportManager teleportManager; private BaseTeleportManager teleportManager;
private PocketBaseManager pocketBaseManager; private PocketBaseManager pocketBaseManager;
private PocketBaseController pocketBaseController; private PocketBaseController pocketBaseController;
private PocketBaseKeystoneService keystoneService;
@Override @Override
public void onEnable() { public void onEnable() {
@@ -40,6 +42,17 @@ public final class SpigotBasePlugin extends JavaPlugin {
settingsProvider settingsProvider
); );
pocketBaseManager.loadWorlds(); pocketBaseManager.loadWorlds();
NamespacedKey keystoneKey = new NamespacedKey(this, "pocket_base_keystone");
keystoneService = new PocketBaseKeystoneService(
getServer(),
pocketBaseManager,
new KeystoneReturnStore(new YamlKeystoneReturnRepository(
getDataFolder().toPath().resolve("keystone-returns.yml")
)),
keystoneKey,
getLogger()
);
keystoneService.registerRecipe();
} catch (RuntimeException | IOException exception) { } catch (RuntimeException | IOException exception) {
getLogger().log(Level.SEVERE, "Could not initialize Spigot Base", exception); getLogger().log(Level.SEVERE, "Could not initialize Spigot Base", exception);
getServer().getPluginManager().disablePlugin(this); getServer().getPluginManager().disablePlugin(this);
@@ -64,7 +77,8 @@ public final class SpigotBasePlugin extends JavaPlugin {
settingsProvider settingsProvider
); );
flightController = new BaseFlightController( flightController = new BaseFlightController(
getServer(), stateManager, secondaryProgressionService, boundsService, settingsProvider getServer(), stateManager, secondaryProgressionService, boundsService,
settingsProvider, pocketBaseManager, getLogger()
); );
VisitorPolicy visitorPolicy = new VisitorPolicy(); VisitorPolicy visitorPolicy = new VisitorPolicy();
teleportManager = new BaseTeleportManager( teleportManager = new BaseTeleportManager(
@@ -81,11 +95,14 @@ public final class SpigotBasePlugin extends JavaPlugin {
boundsService, boundsService,
pocketBaseManager, pocketBaseManager,
settingsProvider, settingsProvider,
getLogger() getLogger(),
flightController
); );
getServer().getPluginManager().registerEvents(progressListener, this); getServer().getPluginManager().registerEvents(progressListener, this);
getServer().getPluginManager().registerEvents(flightController, this);
getServer().getPluginManager().registerEvents(teleportManager, this); getServer().getPluginManager().registerEvents(teleportManager, this);
getServer().getPluginManager().registerEvents(pocketBaseController, this); getServer().getPluginManager().registerEvents(pocketBaseController, this);
getServer().getPluginManager().registerEvents(keystoneService, this);
command("setbase").setExecutor(new SetBaseCommand( command("setbase").setExecutor(new SetBaseCommand(
stateManager, baseService, Clock.systemUTC(), pocketBaseController stateManager, baseService, Clock.systemUTC(), pocketBaseController
@@ -0,0 +1,86 @@
package games.dmg.spigotbase;
import java.util.HashMap;
import java.util.Iterator;
import java.util.Map;
import java.util.UUID;
import org.bukkit.Location;
import org.bukkit.Particle;
import org.bukkit.plugin.Plugin;
import org.bukkit.scheduler.BukkitTask;
/** Cosmetic, main-thread effects; never controls teleport timing or eligibility. */
final class TeleportParticles {
private final Plugin plugin;
private final Map<UUID, Effect> effects = new HashMap<>();
private BukkitTask task;
TeleportParticles(Plugin plugin) {
this.plugin = plugin;
}
void start(UUID playerId, Location origin, int seconds) {
effects.put(playerId, new Effect(origin.clone(), Math.max(1L, seconds * 10L)));
if (task == null) {
task = plugin.getServer().getScheduler().runTaskTimer(plugin, this::tick, 0L, 2L);
}
}
void complete(UUID playerId) {
Effect effect = effects.get(playerId);
if (effect != null) {
effect.fading = true;
effect.age = 0;
}
}
void cancel(UUID playerId) {
effects.remove(playerId);
stopIfEmpty();
}
void clear() {
effects.clear();
stopIfEmpty();
}
private void tick() {
Iterator<Effect> iterator = effects.values().iterator();
while (iterator.hasNext()) {
Effect effect = iterator.next();
int count = effect.fading
? Math.max(1, 12 - (int) (effect.age * 12 / 20))
: 2 + (int) (10 * Math.min(1.0, (double) effect.age / effect.duration));
double angle = effect.age * 0.3;
Location point = effect.origin.clone().add(
Math.cos(angle) * 0.65, 1.0 + Math.sin(angle * 0.5) * 0.45,
Math.sin(angle) * 0.65
);
point.getWorld().spawnParticle(Particle.END_ROD, point, count, 0.3, 0.45, 0.3, 0.01);
effect.age++;
if (effect.fading && effect.age >= 20) {
iterator.remove();
}
}
stopIfEmpty();
}
private void stopIfEmpty() {
if (effects.isEmpty() && task != null) {
task.cancel();
task = null;
}
}
private static final class Effect {
private final Location origin;
private final long duration;
private long age;
private boolean fading;
private Effect(Location origin, long duration) {
this.origin = origin;
this.duration = duration;
}
}
}
@@ -0,0 +1,94 @@
package games.dmg.spigotbase;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import org.bukkit.configuration.ConfigurationSection;
import org.bukkit.configuration.InvalidConfigurationException;
import org.bukkit.configuration.file.YamlConfiguration;
public final class YamlKeystoneReturnRepository {
private final Path stateFile;
public YamlKeystoneReturnRepository(Path stateFile) {
this.stateFile = stateFile;
}
public Map<UUID, KeystoneReturnLocation> load() throws IOException {
if (!Files.exists(stateFile)) {
return Map.of();
}
YamlConfiguration yaml = new YamlConfiguration();
try {
yaml.load(stateFile.toFile());
} catch (InvalidConfigurationException exception) {
throw new IOException("Pocket Base Keystone returns are not valid YAML", exception);
}
Map<UUID, KeystoneReturnLocation> destinations = new HashMap<>();
ConfigurationSection players = yaml.getConfigurationSection("players");
if (players == null) {
return destinations;
}
for (String key : players.getKeys(false)) {
try {
String path = "players." + key;
String worldId = yaml.getString(path + ".world-id");
String worldName = yaml.getString(path + ".world-name");
if (worldId == null || worldName == null) {
continue;
}
destinations.put(UUID.fromString(key), new KeystoneReturnLocation(
UUID.fromString(worldId),
worldName,
yaml.getDouble(path + ".x"),
yaml.getDouble(path + ".y"),
yaml.getDouble(path + ".z"),
(float) yaml.getDouble(path + ".yaw"),
(float) yaml.getDouble(path + ".pitch")
));
} catch (IllegalArgumentException ignored) {
// Invalid records do not provide a return destination.
}
}
return destinations;
}
public void save(Map<UUID, KeystoneReturnLocation> destinations) throws IOException {
Path parent = stateFile.toAbsolutePath().getParent();
if (parent != null) {
Files.createDirectories(parent);
}
YamlConfiguration yaml = new YamlConfiguration();
for (Map.Entry<UUID, KeystoneReturnLocation> entry : destinations.entrySet()) {
String path = "players." + entry.getKey();
KeystoneReturnLocation destination = entry.getValue();
yaml.set(path + ".world-id", destination.worldId().toString());
yaml.set(path + ".world-name", destination.worldName());
yaml.set(path + ".x", destination.x());
yaml.set(path + ".y", destination.y());
yaml.set(path + ".z", destination.z());
yaml.set(path + ".yaw", destination.yaw());
yaml.set(path + ".pitch", destination.pitch());
}
Path temporary = Files.createTempFile(parent, "spigot-base-keystone-", ".yml");
try {
yaml.save(temporary.toFile());
try {
Files.move(
temporary,
stateFile,
StandardCopyOption.REPLACE_EXISTING,
StandardCopyOption.ATOMIC_MOVE
);
} catch (IOException atomicMoveFailure) {
Files.move(temporary, stateFile, StandardCopyOption.REPLACE_EXISTING);
}
} finally {
Files.deleteIfExists(temporary);
}
}
}
@@ -39,6 +39,9 @@ public final class YamlPocketBaseRepository {
UUID ownerId = UUID.fromString(key); UUID ownerId = UUID.fromString(key);
String path = "owners." + key; String path = "owners." + key;
int level = yaml.getInt(path + ".level", 0); int level = yaml.getInt(path + ".level", 0);
boolean flightUnlocked = yaml.getBoolean(
path + ".flight-unlocked", false
);
PocketBaseState state = new PocketBaseState( PocketBaseState state = new PocketBaseState(
ownerId, ownerId,
level, level,
@@ -46,7 +49,10 @@ public final class YamlPocketBaseRepository {
loadPortal(yaml, path + ".return-portal"), loadPortal(yaml, path + ".return-portal"),
loadMobPreference(yaml, path, "hostile-mob-spawning-enabled"), loadMobPreference(yaml, path, "hostile-mob-spawning-enabled"),
loadMobPreference(yaml, path, "passive-mob-spawning-enabled"), loadMobPreference(yaml, path, "passive-mob-spawning-enabled"),
loadBiome(yaml, path) loadBiome(yaml, path),
flightUnlocked,
loadPortal(yaml, path + ".flight-portal"),
yaml.getBoolean(path + ".flight-enabled", flightUnlocked)
); );
states.put(ownerId, state); states.put(ownerId, state);
} catch (IllegalArgumentException ignored) { } catch (IllegalArgumentException ignored) {
@@ -73,12 +79,17 @@ public final class YamlPocketBaseRepository {
path + ".passive-mob-spawning-enabled", path + ".passive-mob-spawning-enabled",
state.passiveMobSpawningEnabled() state.passiveMobSpawningEnabled()
); );
yaml.set(path + ".flight-unlocked", state.flightUnlocked());
yaml.set(path + ".flight-enabled", state.flightEnabled());
yaml.set(path + ".world-type", state.biome().worldType().commandName()); yaml.set(path + ".world-type", state.biome().worldType().commandName());
yaml.set(path + ".biome", state.biome().commandName()); yaml.set(path + ".biome", state.biome().commandName());
state.entrance().ifPresent(portal -> savePortal(yaml, path + ".entrance", portal)); state.entrance().ifPresent(portal -> savePortal(yaml, path + ".entrance", portal));
state.returnPortal().ifPresent(portal -> state.returnPortal().ifPresent(portal ->
savePortal(yaml, path + ".return-portal", portal) savePortal(yaml, path + ".return-portal", portal)
); );
state.flightPortal().ifPresent(portal ->
savePortal(yaml, path + ".flight-portal", portal)
);
} }
Path temporary = Files.createTempFile(parent, "spigot-base-pocket-", ".yml"); Path temporary = Files.createTempFile(parent, "spigot-base-pocket-", ".yml");
try { try {
@@ -12,11 +12,14 @@ import static org.mockito.Mockito.when;
import java.time.Instant; import java.time.Instant;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional;
import java.util.UUID; import java.util.UUID;
import java.util.logging.Logger;
import org.bukkit.Location; import org.bukkit.Location;
import org.bukkit.Server; import org.bukkit.Server;
import org.bukkit.World; import org.bukkit.World;
import org.bukkit.entity.Player; import org.bukkit.entity.Player;
import org.bukkit.event.raid.RaidFinishEvent;
import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.PlayerInventory; import org.bukkit.inventory.PlayerInventory;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
@@ -47,6 +50,7 @@ final class BaseFlightControllerTest {
PlayerInventory inventory = mock(PlayerInventory.class); PlayerInventory inventory = mock(PlayerInventory.class);
doReturn(List.of(player)).when(server).getOnlinePlayers(); doReturn(List.of(player)).when(server).getOnlinePlayers();
when(server.getPlayer(playerId)).thenReturn(player);
when(player.getUniqueId()).thenReturn(playerId); when(player.getUniqueId()).thenReturn(playerId);
when(player.getName()).thenReturn("Alex"); when(player.getName()).thenReturn("Alex");
when(player.getGameMode()).thenReturn(org.bukkit.GameMode.SURVIVAL); when(player.getGameMode()).thenReturn(org.bukkit.GameMode.SURVIVAL);
@@ -62,6 +66,353 @@ final class BaseFlightControllerTest {
when(player.getAllowFlight()).thenReturn(true); when(player.getAllowFlight()).thenReturn(true);
} }
@Test
void ownerWinningRaidInOwnPocketBaseUnlocksFlight() throws Exception {
UUID pocketWorldId = UUID.randomUUID();
World pocketWorld = mock(World.class);
Player winner = mock(Player.class);
RaidFinishEvent event = mock(RaidFinishEvent.class);
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
PocketBaseState locked = new PocketBaseState(playerId, 1, Optional.empty());
PocketBaseState unlocked = locked.withFlightUnlocked();
BaseFlightController pocketController = new BaseFlightController(
server,
stateManager,
new SecondaryProgressionService(PluginSettings.from(Map.of())),
new BaseBoundsService(PluginSettings.from(Map.of())),
new PluginSettingsProvider(PluginSettings.from(Map.of())),
pocketBases,
Logger.getAnonymousLogger()
);
when(event.getWorld()).thenReturn(pocketWorld);
when(event.getWinners()).thenReturn(List.of(winner));
when(pocketWorld.getUID()).thenReturn(pocketWorldId);
when(winner.getUniqueId()).thenReturn(playerId);
when(pocketBases.ownerForPocketWorld(pocketWorldId)).thenReturn(Optional.of(playerId));
when(pocketBases.state(playerId)).thenReturn(locked);
when(pocketBases.unlockFlight(playerId)).thenReturn(unlocked);
pocketController.onRaidFinish(event);
verify(pocketBases).unlockFlight(playerId);
verify(winner).sendTitle(anyString(), anyString(), anyInt(), anyInt(), anyInt());
}
@Test
void raidDoesNotUnlockFlightWhenPocketOwnerIsNotAWinner() throws Exception {
UUID pocketWorldId = UUID.randomUUID();
UUID ownerId = UUID.randomUUID();
World pocketWorld = mock(World.class);
Player visitor = mock(Player.class);
RaidFinishEvent event = mock(RaidFinishEvent.class);
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
BaseFlightController pocketController = new BaseFlightController(
server,
stateManager,
new SecondaryProgressionService(PluginSettings.from(Map.of())),
new BaseBoundsService(PluginSettings.from(Map.of())),
new PluginSettingsProvider(PluginSettings.from(Map.of())),
pocketBases,
Logger.getAnonymousLogger()
);
when(event.getWorld()).thenReturn(pocketWorld);
when(event.getWinners()).thenReturn(List.of(visitor));
when(pocketWorld.getUID()).thenReturn(pocketWorldId);
when(visitor.getUniqueId()).thenReturn(UUID.randomUUID());
when(pocketBases.ownerForPocketWorld(pocketWorldId)).thenReturn(Optional.of(ownerId));
pocketController.onRaidFinish(event);
verify(pocketBases, never()).unlockFlight(ownerId);
}
@Test
void unlockedOwnerReceivesFlightInsidePocketBaseBuffer() {
UUID pocketWorldId = UUID.randomUUID();
World pocketWorld = mock(World.class);
Location location = mock(Location.class);
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
PocketBaseState pocket = new PocketBaseState(playerId, 1, Optional.empty())
.withFlightUnlocked();
PlayerState noNormalFlight = PlayerState.newPlayer(playerId, "Alex");
BaseFlightController pocketController = new BaseFlightController(
server,
stateManager,
new SecondaryProgressionService(PluginSettings.from(Map.of())),
new BaseBoundsService(PluginSettings.from(Map.of())),
new PluginSettingsProvider(PluginSettings.from(Map.of())),
pocketBases,
Logger.getAnonymousLogger()
);
when(stateManager.player(playerId, "Alex")).thenReturn(noNormalFlight);
when(player.getWorld()).thenReturn(pocketWorld);
when(player.getLocation()).thenReturn(location);
when(player.getAllowFlight()).thenReturn(false);
when(pocketWorld.getUID()).thenReturn(pocketWorldId);
when(location.getX()).thenReturn(40.5);
when(location.getZ()).thenReturn(0.5);
when(location.getBlockX()).thenReturn(40);
when(location.getBlockZ()).thenReturn(0);
when(pocketBases.ownerForPocketWorld(pocketWorldId)).thenReturn(Optional.of(playerId));
when(pocketBases.state(playerId)).thenReturn(pocket);
when(pocketBases.policy()).thenReturn(
new PocketBasePolicy(PluginSettings.from(Map.of()))
);
pocketController.run();
verify(player).setAllowFlight(true);
}
@Test
void disabledAutomaticPocketBaseFlightDoesNotGrantOwnerFlight() {
UUID pocketWorldId = UUID.randomUUID();
World pocketWorld = mock(World.class);
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
PocketBaseState pocket = new PocketBaseState(playerId, 1, Optional.empty())
.withFlightUnlocked()
.withFlightEnabled(false);
BaseFlightController pocketController = new BaseFlightController(
server,
stateManager,
new SecondaryProgressionService(PluginSettings.from(Map.of())),
new BaseBoundsService(PluginSettings.from(Map.of())),
new PluginSettingsProvider(PluginSettings.from(Map.of())),
pocketBases,
Logger.getAnonymousLogger()
);
when(stateManager.player(playerId, "Alex")).thenReturn(
PlayerState.newPlayer(playerId, "Alex")
);
when(player.getWorld()).thenReturn(pocketWorld);
when(player.getAllowFlight()).thenReturn(false);
when(pocketWorld.getUID()).thenReturn(pocketWorldId);
when(pocketBases.ownerForPocketWorld(pocketWorldId)).thenReturn(Optional.of(playerId));
when(pocketBases.state(playerId)).thenReturn(pocket);
pocketController.run();
verify(player, never()).setAllowFlight(true);
}
@Test
void flightPortalTemporarilyGrantsGuestPocketBaseFlight() {
UUID ownerId = UUID.randomUUID();
UUID pocketWorldId = UUID.randomUUID();
World pocketWorld = mock(World.class);
Location location = mock(Location.class);
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
PocketBaseState pocket = new PocketBaseState(ownerId, 1, Optional.empty())
.withFlightUnlocked()
.withFlightEnabled(false);
BaseFlightController pocketController = new BaseFlightController(
server,
stateManager,
new SecondaryProgressionService(PluginSettings.from(Map.of())),
new BaseBoundsService(PluginSettings.from(Map.of())),
new PluginSettingsProvider(PluginSettings.from(Map.of())),
pocketBases,
Logger.getAnonymousLogger()
);
when(stateManager.player(playerId, "Alex")).thenReturn(
PlayerState.newPlayer(playerId, "Alex")
);
when(player.getWorld()).thenReturn(pocketWorld);
when(player.getLocation()).thenReturn(location);
when(player.getAllowFlight()).thenReturn(false);
when(pocketWorld.getUID()).thenReturn(pocketWorldId);
when(pocketBases.ownerForPocketWorld(pocketWorldId)).thenReturn(Optional.of(ownerId));
when(pocketBases.state(ownerId)).thenReturn(pocket);
when(pocketBases.policy()).thenReturn(
new PocketBasePolicy(PluginSettings.from(Map.of()))
);
when(player.getGameMode()).thenReturn(org.bukkit.GameMode.CREATIVE);
pocketController.grantPocketPortalFlight(player, ownerId);
verify(player, never()).setFlying(true);
verify(player, never()).sendMessage(anyString());
when(player.getGameMode()).thenReturn(org.bukkit.GameMode.SURVIVAL);
when(location.getBlockX()).thenReturn(10000);
pocketController.grantPocketPortalFlight(player, ownerId);
verify(player, never()).setFlying(true);
verify(player, never()).sendMessage(anyString());
when(location.getBlockX()).thenReturn(0);
pocketController.grantPocketPortalFlight(player, ownerId);
verify(player).setAllowFlight(true);
verify(player).setFlying(true);
verify(player).sendMessage(org.bukkit.ChatColor.GREEN + "Pocket dimension flight enabled!");
when(player.getAllowFlight()).thenReturn(true);
pocketController.grantPocketPortalFlight(player, ownerId);
verify(player, times(2)).setFlying(true);
verify(player, times(2)).sendMessage(org.bukkit.ChatColor.GREEN + "Pocket dimension flight enabled!");
}
@Test
void portalFlightEndsAfterGuestLeavesPocketBase() {
UUID ownerId = UUID.randomUUID();
UUID pocketWorldId = UUID.randomUUID();
UUID normalWorldId = UUID.randomUUID();
World pocketWorld = mock(World.class);
World normalWorld = mock(World.class);
Location location = mock(Location.class);
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
PocketBaseState pocket = new PocketBaseState(ownerId, 1, Optional.empty())
.withFlightUnlocked()
.withFlightEnabled(false);
BaseFlightController pocketController = new BaseFlightController(
server,
stateManager,
new SecondaryProgressionService(PluginSettings.from(Map.of())),
new BaseBoundsService(PluginSettings.from(Map.of())),
new PluginSettingsProvider(PluginSettings.from(Map.of())),
pocketBases,
Logger.getAnonymousLogger()
);
when(stateManager.player(playerId, "Alex")).thenReturn(
PlayerState.newPlayer(playerId, "Alex")
);
when(player.getWorld()).thenReturn(pocketWorld, normalWorld);
when(player.getLocation()).thenReturn(location);
when(player.getAllowFlight()).thenReturn(false);
when(pocketWorld.getUID()).thenReturn(pocketWorldId);
when(normalWorld.getUID()).thenReturn(normalWorldId);
when(pocketBases.ownerForPocketWorld(pocketWorldId)).thenReturn(Optional.of(ownerId));
when(pocketBases.ownerForPocketWorld(normalWorldId)).thenReturn(Optional.empty());
when(pocketBases.state(ownerId)).thenReturn(pocket);
when(pocketBases.policy()).thenReturn(
new PocketBasePolicy(PluginSettings.from(Map.of()))
);
pocketController.grantPocketPortalFlight(player, ownerId);
pocketController.run();
pocketController.run();
verify(player).setFlying(false);
verify(player).setAllowFlight(false);
}
@Test
void flyingInPocketBaseBufferShowsLeavingWarning() {
UUID pocketWorldId = UUID.randomUUID();
World pocketWorld = mock(World.class);
Location location = mock(Location.class);
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
PocketBaseState pocket = new PocketBaseState(playerId, 1, Optional.empty())
.withFlightUnlocked();
BaseFlightController pocketController = new BaseFlightController(
server,
stateManager,
new SecondaryProgressionService(PluginSettings.from(Map.of())),
new BaseBoundsService(PluginSettings.from(Map.of())),
new PluginSettingsProvider(PluginSettings.from(Map.of())),
pocketBases,
Logger.getAnonymousLogger()
);
when(stateManager.player(playerId, "Alex")).thenReturn(
PlayerState.newPlayer(playerId, "Alex")
);
when(player.getWorld()).thenReturn(pocketWorld);
when(player.getLocation()).thenReturn(location);
when(player.isFlying()).thenReturn(true);
when(pocketWorld.getUID()).thenReturn(pocketWorldId);
when(location.getBlockX()).thenReturn(40);
when(location.getBlockZ()).thenReturn(0);
when(pocketBases.ownerForPocketWorld(pocketWorldId)).thenReturn(Optional.of(playerId));
when(pocketBases.state(playerId)).thenReturn(pocket);
when(pocketBases.policy()).thenReturn(
new PocketBasePolicy(PluginSettings.from(Map.of()))
);
pocketController.run();
verify(player).sendTitle(anyString(), anyString(), anyInt(), anyInt(), anyInt());
}
@Test
void pocketBaseFlightEndsBeyondSixteenBlockBuffer() {
UUID pocketWorldId = UUID.randomUUID();
World pocketWorld = mock(World.class);
Location location = mock(Location.class);
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
PocketBaseState pocket = new PocketBaseState(playerId, 1, Optional.empty())
.withFlightUnlocked();
BaseFlightController pocketController = new BaseFlightController(
server,
stateManager,
new SecondaryProgressionService(PluginSettings.from(Map.of())),
new BaseBoundsService(PluginSettings.from(Map.of())),
new PluginSettingsProvider(PluginSettings.from(Map.of())),
pocketBases,
Logger.getAnonymousLogger()
);
when(stateManager.player(playerId, "Alex")).thenReturn(
PlayerState.newPlayer(playerId, "Alex")
);
when(player.getWorld()).thenReturn(pocketWorld);
when(player.getLocation()).thenReturn(location);
when(player.getAllowFlight()).thenReturn(false);
when(pocketWorld.getUID()).thenReturn(pocketWorldId);
when(location.getBlockX()).thenReturn(40, 48);
when(location.getBlockZ()).thenReturn(0);
when(pocketBases.ownerForPocketWorld(pocketWorldId)).thenReturn(Optional.of(playerId));
when(pocketBases.state(playerId)).thenReturn(pocket);
when(pocketBases.policy()).thenReturn(
new PocketBasePolicy(PluginSettings.from(Map.of()))
);
pocketController.run();
pocketController.run();
verify(player).setFlying(false);
verify(player).setAllowFlight(false);
}
@Test
void visitorDoesNotReceiveUnlockedOwnersPocketBaseFlight() {
UUID ownerId = UUID.randomUUID();
UUID pocketWorldId = UUID.randomUUID();
World pocketWorld = mock(World.class);
Location location = mock(Location.class);
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
PocketBaseState pocket = new PocketBaseState(ownerId, 1, Optional.empty())
.withFlightUnlocked();
BaseFlightController pocketController = new BaseFlightController(
server,
stateManager,
new SecondaryProgressionService(PluginSettings.from(Map.of())),
new BaseBoundsService(PluginSettings.from(Map.of())),
new PluginSettingsProvider(PluginSettings.from(Map.of())),
pocketBases,
Logger.getAnonymousLogger()
);
when(stateManager.player(playerId, "Alex")).thenReturn(
PlayerState.newPlayer(playerId, "Alex")
);
when(player.getWorld()).thenReturn(pocketWorld);
when(player.getLocation()).thenReturn(location);
when(player.getAllowFlight()).thenReturn(false);
when(pocketWorld.getUID()).thenReturn(pocketWorldId);
when(pocketBases.ownerForPocketWorld(pocketWorldId)).thenReturn(Optional.of(ownerId));
when(pocketBases.state(ownerId)).thenReturn(pocket);
pocketController.run();
verify(player, never()).setAllowFlight(true);
}
@Test @Test
void walkingInWarningBufferDoesNotShowLeavingBaseWarning() { void walkingInWarningBufferDoesNotShowLeavingBaseWarning() {
when(player.isFlying()).thenReturn(false); when(player.isFlying()).thenReturn(false);
@@ -15,6 +15,7 @@ import java.io.IOException;
import java.time.Instant; import java.time.Instant;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.atomic.AtomicReference;
import java.util.function.UnaryOperator; import java.util.function.UnaryOperator;
@@ -112,6 +113,16 @@ final class BaseSettingsCommandTest {
List.of("upgrade"), List.of("upgrade"),
command.onTabComplete(null, null, "basesettings", new String[] {"pocket", "u"}) command.onTabComplete(null, null, "basesettings", new String[] {"pocket", "u"})
); );
assertEquals(
List.of("flight"),
command.onTabComplete(null, null, "basesettings", new String[] {"pocket", "f"})
);
assertEquals(
List.of("disable"),
command.onTabComplete(
null, null, "basesettings", new String[] {"pocket", "flight", "d"}
)
);
assertEquals( assertEquals(
List.of("mobs"), List.of("mobs"),
command.onTabComplete(null, null, "basesettings", new String[] {"pocket", "m"}) command.onTabComplete(null, null, "basesettings", new String[] {"pocket", "m"})
@@ -459,6 +470,41 @@ final class BaseSettingsCommandTest {
assertEquals(64, restored[0].getAmount()); assertEquals(64, restored[0].getAmount());
} }
@Test
void ownerCanDisableAutomaticPocketBaseFlight() throws Exception {
UUID playerId = UUID.randomUUID();
Player player = mock(Player.class);
when(player.getUniqueId()).thenReturn(playerId);
when(player.getName()).thenReturn("Builder");
BaseStateManager stateManager = mock(BaseStateManager.class);
when(stateManager.player(playerId, "Builder")).thenReturn(
PlayerState.newPlayer(playerId, "Builder")
);
PocketBaseState enabled = new PocketBaseState(playerId, 1, Optional.empty())
.withFlightUnlocked();
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
when(pocketBases.state(playerId)).thenReturn(enabled);
when(pocketBases.setFlightEnabled(playerId, false)).thenReturn(
enabled.withFlightEnabled(false)
);
BaseSettingsCommand command = new BaseSettingsCommand(
stateManager,
new PluginSettingsProvider(PluginSettings.from(Map.of())),
mock(BaseFlightController.class),
pocketBases
);
command.onCommand(
player, null, "basesettings",
new String[] {"pocket", "flight", "disable"}
);
verify(pocketBases).setFlightEnabled(playerId, false);
verify(player).sendMessage(org.mockito.ArgumentMatchers.<String>argThat(
message -> message.contains("automatic flight") && message.contains("disabled")
));
}
@Test @Test
void statusDisplaysPocketMobSpawningPreferences() { void statusDisplaysPocketMobSpawningPreferences() {
UUID playerId = UUID.randomUUID(); UUID playerId = UUID.randomUUID();
@@ -489,6 +535,7 @@ final class BaseSettingsCommandTest {
&& message.contains("type=void/the_void") && message.contains("type=void/the_void")
&& message.contains("hostile spawning=enabled") && message.contains("hostile spawning=enabled")
&& message.contains("passive spawning=disabled") && message.contains("passive spawning=disabled")
&& message.contains("flight=complete a raid to unlock")
)); ));
} }
@@ -28,6 +28,15 @@ final class BaseSettingsDialogFactoryTest {
assertEquals("/basesettings status", payload.value()); assertEquals("/basesettings status", payload.value());
} }
@Test
void createsBackButtonThatReturnsToMainSettingsDashboard() {
BaseSettingsDialogFactory.ButtonSpec back =
BaseSettingsDialogFactory.backButtonSpec();
assertEquals("Back", back.label());
assertEquals("/basesettings ui", back.command());
}
@Test @Test
void createsControlsForEveryBaseSettingAndConfirmedPurchase() { void createsControlsForEveryBaseSettingAndConfirmedPurchase() {
UUID playerId = UUID.randomUUID(); UUID playerId = UUID.randomUUID();
@@ -47,7 +56,8 @@ final class BaseSettingsDialogFactoryTest {
Optional.empty(), Optional.empty(),
true, true,
false, false,
PocketBaseBiome.PLAINS PocketBaseBiome.PLAINS,
true
); );
BaseSettingsDialogFactory.DialogSpec specification = BaseSettingsDialogFactory.DialogSpec specification =
@@ -59,6 +69,13 @@ final class BaseSettingsDialogFactoryTest {
specification specification
); );
assertTrue(root.content().message().contains("Base 4/4")); assertTrue(root.content().message().contains("Base 4/4"));
BaseSettingsDialogFactory.ListSpec pocketSettings = assertInstanceOf(
BaseSettingsDialogFactory.ListSpec.class,
root.dialogs().get(3)
);
assertTrue(pocketSettings.content().message().contains(
"Flight: Enabled"
));
List<String> commands = commands(root); List<String> commands = commands(root);
assertTrue(commands.contains("/basesettings status")); assertTrue(commands.contains("/basesettings status"));
assertTrue(commands.contains("/basesettings visitors blocked")); assertTrue(commands.contains("/basesettings visitors blocked"));
@@ -68,6 +85,7 @@ final class BaseSettingsDialogFactoryTest {
assertTrue(commands.contains("/basesettings spawnable disable")); assertTrue(commands.contains("/basesettings spawnable disable"));
assertTrue(commands.contains("/basesettings bossbar disable")); assertTrue(commands.contains("/basesettings bossbar disable"));
assertTrue(commands.contains("/basesettings pocket upgrade")); assertTrue(commands.contains("/basesettings pocket upgrade"));
assertTrue(commands.contains("/basesettings pocket flight disable"));
assertTrue(commands.contains("/basesettings pocket mobs hostile disable")); assertTrue(commands.contains("/basesettings pocket mobs hostile disable"));
assertTrue(commands.contains("/basesettings pocket mobs passive enable")); assertTrue(commands.contains("/basesettings pocket mobs passive enable"));
assertTrue(commands.contains("/basesettings pocket type nether crimson_forest")); assertTrue(commands.contains("/basesettings pocket type nether crimson_forest"));
@@ -19,6 +19,82 @@ import org.bukkit.plugin.Plugin;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
final class BaseTeleportManagerTest { final class BaseTeleportManagerTest {
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.CsvSource({
"false,true,false", "true,true,false", "false,false,false", "true,false,false",
"false,false,true", "true,false,true"
})
void particlesFollowHomeAndVisitorTeleportOutcome(boolean visit, boolean succeeds, boolean cancelled) {
UUID id = UUID.randomUUID();
UUID worldId = UUID.randomUUID();
World world = mock(World.class);
when(world.getUID()).thenReturn(worldId);
org.bukkit.Location origin = new org.bukkit.Location(world, 0, 64, 0);
org.bukkit.Location target = new org.bukkit.Location(world, 20, 64, 20);
Player player = mock(Player.class);
when(player.getUniqueId()).thenReturn(id);
when(player.getName()).thenReturn("Player");
when(player.getWorld()).thenReturn(world);
when(player.getLocation()).thenReturn(origin);
when(player.isOnline()).thenReturn(true);
when(player.teleport(eq(target), any(org.bukkit.event.player.PlayerTeleportEvent.TeleportCause.class)))
.thenReturn(succeeds);
PlayerState owner = PlayerState.newPlayer(visit ? UUID.randomUUID() : id, "Owner")
.withGrassAndDirtProgress(500, 4)
.withBase(new BaseLocation(worldId, "world", 20, 64, 20, 0, 0), Instant.EPOCH)
.withVisitorsEnabled(true);
BaseStateManager states = mock(BaseStateManager.class);
when(states.player(id, "Player")).thenReturn(owner);
TeleportPolicy policy = mock(TeleportPolicy.class);
when(policy.warmup(owner)).thenReturn(Duration.ofSeconds(1));
when(policy.visitorWarmup(owner)).thenReturn(Duration.ofSeconds(1));
when(policy.cooldown(owner)).thenReturn(Duration.ZERO);
VisitorPolicy visitors = mock(VisitorPolicy.class);
SafeBaseDestination finder = mock(SafeBaseDestination.class);
when(finder.find(world, owner.base().orElseThrow())).thenReturn(Optional.of(target));
TeleportParticles particles = mock(TeleportParticles.class);
Plugin plugin = mock(Plugin.class);
org.bukkit.scheduler.BukkitScheduler scheduler = mock(org.bukkit.scheduler.BukkitScheduler.class);
when(scheduler.runTaskTimer(eq(plugin), any(Runnable.class), eq(0L), eq(20L)))
.thenReturn(mock(org.bukkit.scheduler.BukkitTask.class));
try (org.mockito.MockedStatic<org.bukkit.Bukkit> bukkit = org.mockito.Mockito.mockStatic(org.bukkit.Bukkit.class)) {
bukkit.when(org.bukkit.Bukkit::getScheduler).thenReturn(scheduler);
bukkit.when(() -> org.bukkit.Bukkit.getWorld(worldId)).thenReturn(world);
BaseTeleportManager manager = new BaseTeleportManager(plugin, states, policy,
visitors, finder, Clock.systemUTC(), particles);
if (visit) manager.startVisit(player, owner); else manager.start(player);
verify(particles).start(id, origin, 1);
org.mockito.ArgumentCaptor<Runnable> tick = org.mockito.ArgumentCaptor.forClass(Runnable.class);
verify(scheduler).runTaskTimer(eq(plugin), tick.capture(), eq(0L), eq(20L));
tick.getValue().run();
org.mockito.Mockito.verify(player, org.mockito.Mockito.never()).teleport(eq(target),
any(org.bukkit.event.player.PlayerTeleportEvent.TeleportCause.class));
if (cancelled) {
org.bukkit.event.player.PlayerQuitEvent quit = mock(org.bukkit.event.player.PlayerQuitEvent.class);
when(quit.getPlayer()).thenReturn(player);
manager.onQuit(quit);
verify(particles).cancel(id);
org.mockito.Mockito.verify(particles, org.mockito.Mockito.never()).complete(id);
org.mockito.Mockito.verify(states, org.mockito.Mockito.never()).saveIfDirty();
manager.cancelAll();
verify(particles).clear();
return;
}
tick.getValue().run();
if (succeeds) {
verify(particles).complete(id);
org.mockito.Mockito.verify(particles, org.mockito.Mockito.never()).cancel(id);
verify(states).saveIfDirty();
} else {
verify(particles).cancel(id);
org.mockito.Mockito.verify(particles, org.mockito.Mockito.never()).complete(id);
org.mockito.Mockito.verify(states, org.mockito.Mockito.never()).saveIfDirty();
}
manager.cancelAll();
verify(particles).clear();
}
}
@Test @Test
void visitFromAnotherWorldIsRejectedBeforeWarmupAndCooldownChecks() { void visitFromAnotherWorldIsRejectedBeforeWarmupAndCooldownChecks() {
UUID visitorId = UUID.randomUUID(); UUID visitorId = UUID.randomUUID();
@@ -24,6 +24,7 @@ import org.bukkit.entity.Entity;
import org.bukkit.entity.Mob; import org.bukkit.entity.Mob;
import org.bukkit.entity.Player; import org.bukkit.entity.Player;
import org.bukkit.event.block.Action; import org.bukkit.event.block.Action;
import org.bukkit.event.block.BlockBreakEvent;
import org.bukkit.event.block.BlockPlaceEvent; import org.bukkit.event.block.BlockPlaceEvent;
import org.bukkit.event.player.PlayerInteractEvent; import org.bukkit.event.player.PlayerInteractEvent;
import org.bukkit.event.player.PlayerMoveEvent; import org.bukkit.event.player.PlayerMoveEvent;
@@ -150,6 +151,62 @@ final class PocketBaseControllerTest {
verify(event).setCancelled(true); verify(event).setCancelled(true);
} }
@Test
void ownerCanActivateGoldFlightPortalAfterRaidUnlock() throws Exception {
UUID ownerId = UUID.randomUUID();
UUID worldId = UUID.randomUUID();
PocketPortalLocation portal = new PocketPortalLocation(
worldId, "pocket", 8, 65, 8, PocketPortalAxis.X
);
Set<BlockPosition> frame = new HashSet<>(PocketPortalGeometry.frameBlocks(portal));
Player owner = mock(Player.class);
World world = mock(World.class);
Block clicked = block(world, 8, 67, 8, Material.GOLD_BLOCK);
PlayerInteractEvent event = mock(PlayerInteractEvent.class);
ItemStack activator = item(Material.FLINT_AND_STEEL);
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
PluginSettings settings = PluginSettings.from(Map.of());
PocketBaseState state = new PocketBaseState(ownerId, 1, Optional.empty())
.withFlightUnlocked();
when(owner.getUniqueId()).thenReturn(ownerId);
when(event.getPlayer()).thenReturn(owner);
when(event.getAction()).thenReturn(Action.RIGHT_CLICK_BLOCK);
when(event.getClickedBlock()).thenReturn(clicked);
when(event.getItem()).thenReturn(activator);
when(world.getUID()).thenReturn(worldId);
when(world.getName()).thenReturn("pocket");
when(world.getBlockAt(anyInt(), anyInt(), anyInt())).thenAnswer(invocation -> {
BlockPosition position = new BlockPosition(
invocation.getArgument(0), invocation.getArgument(1), invocation.getArgument(2)
);
return block(
world,
position.x(),
position.y(),
position.z(),
frame.contains(position) ? Material.GOLD_BLOCK : Material.AIR
);
});
when(pocketBases.ownerForPocketWorld(worldId)).thenReturn(Optional.of(ownerId));
when(pocketBases.state(ownerId)).thenReturn(state);
when(pocketBases.policy()).thenReturn(new PocketBasePolicy(settings));
PocketBaseController controller = new PocketBaseController(
mock(Server.class),
mock(BaseStateManager.class),
new BaseBoundsService(settings),
pocketBases,
new PluginSettingsProvider(settings),
Logger.getAnonymousLogger(),
mock(BaseFlightController.class)
);
controller.onActivate(event);
verify(pocketBases).activateFlightPortal(ownerId, portal);
verify(event).setCancelled(true);
}
@Test @Test
void returnFrameOutsideUnlockedPocketBoundaryIsRejected() throws Exception { void returnFrameOutsideUnlockedPocketBoundaryIsRejected() throws Exception {
UUID ownerId = UUID.randomUUID(); UUID ownerId = UUID.randomUUID();
@@ -205,6 +262,39 @@ final class PocketBaseControllerTest {
verify(event).setCancelled(true); verify(event).setCancelled(true);
} }
@Test
void breakingActiveFlightPortalFrameDeactivatesIt() throws Exception {
UUID ownerId = UUID.randomUUID();
UUID worldId = UUID.randomUUID();
PocketPortalLocation flightPortal = new PocketPortalLocation(
worldId, "pocket", 8, 65, 8, PocketPortalAxis.X
);
PocketBaseState state = new PocketBaseState(ownerId, 1, Optional.empty())
.withFlightUnlocked()
.withFlightPortal(flightPortal);
World world = mock(World.class);
BlockBreakEvent event = mock(BlockBreakEvent.class);
Block broken = block(world, 8, 65, 8, Material.GOLD_BLOCK);
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
PluginSettings settings = PluginSettings.from(Map.of());
when(world.getUID()).thenReturn(worldId);
when(event.getBlock()).thenReturn(broken);
when(pocketBases.knownStates()).thenReturn(Map.of(ownerId, state));
PocketBaseController controller = new PocketBaseController(
mock(Server.class),
mock(BaseStateManager.class),
new BaseBoundsService(settings),
pocketBases,
new PluginSettingsProvider(settings),
Logger.getAnonymousLogger()
);
controller.onBreak(event);
verify(pocketBases).deactivateFlightPortal(ownerId);
}
@Test @Test
void placementOutsideUnlockedPocketBoundaryIsCancelled() { void placementOutsideUnlockedPocketBoundaryIsCancelled() {
UUID ownerId = UUID.randomUUID(); UUID ownerId = UUID.randomUUID();
@@ -311,6 +401,72 @@ final class PocketBaseControllerTest {
); );
} }
@Test
void activeFlightPortalGrantsTemporaryFlightToGuest() {
UUID ownerId = UUID.randomUUID();
UUID guestId = UUID.randomUUID();
UUID pocketWorldId = UUID.randomUUID();
PocketPortalLocation flightPortal = new PocketPortalLocation(
pocketWorldId, "pocket", 8, 65, 8, PocketPortalAxis.X
);
World pocketWorld = mock(World.class);
Set<BlockPosition> frame = new HashSet<>(
PocketPortalGeometry.frameBlocks(flightPortal)
);
Location destination = new Location(pocketWorld, 9.5, 66.0, 8.5);
Player guest = mock(Player.class);
PlayerMoveEvent event = mock(PlayerMoveEvent.class);
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
BaseFlightController flightController = mock(BaseFlightController.class);
Server server = mock(Server.class);
PluginSettings settings = PluginSettings.from(Map.of());
PocketBaseState state = new PocketBaseState(ownerId, 1, Optional.empty())
.withFlightUnlocked()
.withFlightPortal(flightPortal);
when(pocketWorld.getUID()).thenReturn(pocketWorldId);
when(pocketWorld.getBlockAt(anyInt(), anyInt(), anyInt())).thenAnswer(invocation -> {
BlockPosition position = new BlockPosition(
invocation.getArgument(0), invocation.getArgument(1), invocation.getArgument(2)
);
return block(
pocketWorld,
position.x(),
position.y(),
position.z(),
frame.contains(position) ? Material.GOLD_BLOCK : Material.AIR
);
});
when(server.getWorld(pocketWorldId)).thenReturn(pocketWorld);
when(guest.getUniqueId()).thenReturn(guestId);
when(event.getPlayer()).thenReturn(guest);
when(event.getTo()).thenReturn(destination);
when(pocketBases.ownerForPocketWorld(pocketWorldId)).thenReturn(Optional.of(ownerId));
when(pocketBases.state(ownerId)).thenReturn(state);
PocketBaseController controller = new PocketBaseController(
server,
mock(BaseStateManager.class),
new BaseBoundsService(settings),
pocketBases,
new PluginSettingsProvider(settings),
Logger.getAnonymousLogger(),
flightController
);
controller.onMove(event);
verify(flightController).grantPocketPortalFlight(guest, ownerId);
when(event.getFrom()).thenReturn(destination);
controller.onMove(event);
verify(flightController).grantPocketPortalFlight(guest, ownerId);
when(event.getFrom()).thenReturn(new Location(pocketWorld, 5, 66, 8));
controller.onMove(event);
verify(flightController, org.mockito.Mockito.times(2))
.grantPocketPortalFlight(guest, ownerId);
}
@Test @Test
void voidReturnClearsAccumulatedPlayerFallDistance() { void voidReturnClearsAccumulatedPlayerFallDistance() {
UUID ownerId = UUID.randomUUID(); UUID ownerId = UUID.randomUUID();
@@ -0,0 +1,396 @@
package games.dmg.spigotbase;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.Optional;
import java.util.UUID;
import java.util.logging.Logger;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.NamespacedKey;
import org.bukkit.Server;
import org.bukkit.World;
import org.bukkit.block.Block;
import org.bukkit.entity.Player;
import org.bukkit.event.Event;
import org.bukkit.event.EventHandler;
import org.bukkit.event.block.Action;
import org.bukkit.plugin.Plugin;
import org.bukkit.plugin.RegisteredListener;
import org.bukkit.event.inventory.PrepareItemCraftEvent;
import org.bukkit.event.player.PlayerInteractEvent;
import org.bukkit.event.player.PlayerTeleportEvent;
import org.bukkit.inventory.CraftingInventory;
import org.bukkit.inventory.InventoryView;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.ShapedRecipe;
import org.bukkit.inventory.meta.ItemMeta;
import org.bukkit.persistence.PersistentDataContainer;
import org.bukkit.persistence.PersistentDataType;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.EnumSource;
final class PocketBaseKeystoneServiceTest {
@Test
void createsKeystoneWithGenericRecoveryCompassMetadata() {
NamespacedKey key = NamespacedKey.minecraft("pocket_base_keystone");
ItemStack item = mock(ItemStack.class);
ItemMeta genericMetadata = mock(ItemMeta.class);
PersistentDataContainer data = mock(PersistentDataContainer.class);
when(item.getItemMeta()).thenReturn(genericMetadata);
when(genericMetadata.getPersistentDataContainer()).thenReturn(data);
PocketBaseKeystoneService.decorateKeystone(item, key);
verify(data).set(key, PersistentDataType.BYTE, (byte) 1);
verify(item).setItemMeta(genericMetadata);
}
@Test
void recipeUsesNetheriteOnCardinalsAndObsidianInCorners() {
PocketBaseKeystoneService.RecipeSpec recipe =
PocketBaseKeystoneService.recipeSpecification();
assertEquals("ONO", recipe.top());
assertEquals("NEN", recipe.middle());
assertEquals("ONO", recipe.bottom());
assertEquals(Material.OBSIDIAN, recipe.obsidian());
assertEquals(Material.NETHERITE_INGOT, recipe.netherite());
assertEquals(Material.ENDER_PEARL, recipe.enderPearl());
}
@Test
void lockedPlayersCannotCompleteTheKeystoneRecipe() {
NamespacedKey key = NamespacedKey.minecraft("pocket_base_keystone");
UUID playerId = UUID.randomUUID();
Player player = mock(Player.class);
PrepareItemCraftEvent event = mock(PrepareItemCraftEvent.class);
InventoryView view = mock(InventoryView.class);
CraftingInventory inventory = mock(CraftingInventory.class);
ShapedRecipe recipe = mock(ShapedRecipe.class);
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
PocketBaseKeystoneService service = new PocketBaseKeystoneService(
mock(org.bukkit.Server.class), pocketBases, mock(KeystoneReturnStore.class),
key, Logger.getAnonymousLogger()
);
when(player.getUniqueId()).thenReturn(playerId);
when(event.getView()).thenReturn(view);
when(view.getPlayer()).thenReturn(player);
when(event.getInventory()).thenReturn(inventory);
when(event.getRecipe()).thenReturn(recipe);
when(recipe.getKey()).thenReturn(key);
when(pocketBases.state(playerId)).thenReturn(
new PocketBaseState(playerId, 0, Optional.empty())
);
service.onPrepareCraft(event);
verify(inventory).setResult(null);
}
@Test
void lockedPlayerCannotUseATransferredKeystone() throws Exception {
NamespacedKey key = NamespacedKey.minecraft("pocket_base_keystone");
UUID playerId = UUID.randomUUID();
Player player = mock(Player.class);
PlayerInteractEvent event = mock(PlayerInteractEvent.class);
ItemStack keystone = markedKeystone(key);
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
KeystoneReturnStore returns = mock(KeystoneReturnStore.class);
PocketBaseKeystoneService service = new PocketBaseKeystoneService(
mock(Server.class), pocketBases, returns, key, Logger.getAnonymousLogger()
);
when(player.getUniqueId()).thenReturn(playerId);
when(event.getPlayer()).thenReturn(player);
when(event.getAction()).thenReturn(Action.RIGHT_CLICK_AIR);
when(event.getItem()).thenReturn(keystone);
when(pocketBases.state(playerId)).thenReturn(
new PocketBaseState(playerId, 0, Optional.empty())
);
service.onUse(event);
verify(returns, never()).record(any(UUID.class), any(KeystoneReturnLocation.class));
verify(player, never()).teleport(
any(Location.class), any(PlayerTeleportEvent.TeleportCause.class)
);
}
@ParameterizedTest
@EnumSource(value = Action.class, names = {"RIGHT_CLICK_AIR", "RIGHT_CLICK_BLOCK"})
void unlockedPlayerEntersOwnPocketAndRecordsExactReturnDestination(Action action) throws Exception {
NamespacedKey key = NamespacedKey.minecraft("pocket_base_keystone");
UUID playerId = UUID.randomUUID();
UUID worldId = UUID.randomUUID();
Player player = mock(Player.class);
World world = mock(World.class);
Location origin = new Location(world, 12.25, 70.5, -4.75, 123.0F, -15.5F);
Location arrival = new Location(mock(World.class), 0.5, 65.0, 0.5);
PlayerInteractEvent event = mock(PlayerInteractEvent.class);
ItemStack keystone = markedKeystone(key);
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
KeystoneReturnStore returns = mock(KeystoneReturnStore.class);
PocketBaseKeystoneService service = new PocketBaseKeystoneService(
mock(Server.class), pocketBases, returns, key, Logger.getAnonymousLogger()
);
when(player.getUniqueId()).thenReturn(playerId);
when(player.getLocation()).thenReturn(origin);
when(player.getWorld()).thenReturn(world);
when(world.getUID()).thenReturn(worldId);
when(world.getName()).thenReturn("world");
when(event.getPlayer()).thenReturn(player);
when(event.getAction()).thenReturn(Action.RIGHT_CLICK_AIR);
when(event.getItem()).thenReturn(keystone);
when(pocketBases.state(playerId)).thenReturn(
new PocketBaseState(playerId, 1, Optional.empty())
);
when(pocketBases.ownerForPocketWorld(worldId)).thenReturn(Optional.empty());
when(pocketBases.pocketArrival(playerId)).thenReturn(arrival);
when(player.teleport(arrival, PlayerTeleportEvent.TeleportCause.PLUGIN))
.thenReturn(true);
when(event.getAction()).thenReturn(action);
when(event.isCancelled()).thenReturn(action == Action.RIGHT_CLICK_AIR);
when(event.useItemInHand()).thenReturn(Event.Result.DEFAULT);
dispatchUse(service, event);
verify(returns).record(playerId, new KeystoneReturnLocation(
worldId, "world", 12.25, 70.5, -4.75, 123.0F, -15.5F
));
verify(player).teleport(arrival, PlayerTeleportEvent.TeleportCause.PLUGIN);
verify(player).setFallDistance(0.0F);
verify(event).setCancelled(true);
}
@ParameterizedTest
@EnumSource(value = Action.class, names = {"RIGHT_CLICK_AIR", "RIGHT_CLICK_BLOCK"})
void playerReturnsFromOwnPocketToRecordedSafeDestination(Action action) throws Exception {
NamespacedKey key = NamespacedKey.minecraft("pocket_base_keystone");
UUID playerId = UUID.randomUUID();
UUID pocketWorldId = UUID.randomUUID();
UUID returnWorldId = UUID.randomUUID();
Player player = mock(Player.class);
World pocketWorld = mock(World.class);
World returnWorld = mock(World.class);
Block ground = mock(Block.class);
Block feet = mock(Block.class);
Block head = mock(Block.class);
PlayerInteractEvent event = mock(PlayerInteractEvent.class);
ItemStack keystone = markedKeystone(key);
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
KeystoneReturnStore returns = mock(KeystoneReturnStore.class);
Server server = mock(Server.class);
KeystoneReturnLocation saved = new KeystoneReturnLocation(
returnWorldId, "world", 12.25, 70.5, -4.75, 123.0F, -15.5F
);
PocketBaseKeystoneService service = new PocketBaseKeystoneService(
server, pocketBases, returns, key, Logger.getAnonymousLogger()
);
when(player.getUniqueId()).thenReturn(playerId);
when(player.getWorld()).thenReturn(pocketWorld);
when(pocketWorld.getUID()).thenReturn(pocketWorldId);
when(event.getPlayer()).thenReturn(player);
when(event.getAction()).thenReturn(Action.RIGHT_CLICK_AIR);
when(event.getItem()).thenReturn(keystone);
when(pocketBases.state(playerId)).thenReturn(
new PocketBaseState(playerId, 1, Optional.empty())
);
when(pocketBases.ownerForPocketWorld(pocketWorldId))
.thenReturn(Optional.of(playerId));
when(returns.destination(playerId)).thenReturn(Optional.of(saved));
when(server.getWorld(returnWorldId)).thenReturn(returnWorld);
when(returnWorld.getMinHeight()).thenReturn(-64);
when(returnWorld.getMaxHeight()).thenReturn(320);
when(returnWorld.getBlockAt(12, 69, -5)).thenReturn(ground);
when(returnWorld.getBlockAt(12, 70, -5)).thenReturn(feet);
when(returnWorld.getBlockAt(12, 71, -5)).thenReturn(head);
when(ground.getType()).thenReturn(Material.STONE);
when(feet.getType()).thenReturn(Material.AIR);
when(head.getType()).thenReturn(Material.AIR);
when(feet.isPassable()).thenReturn(true);
when(head.isPassable()).thenReturn(true);
when(player.teleport(
new Location(returnWorld, 12.25, 70.5, -4.75, 123.0F, -15.5F),
PlayerTeleportEvent.TeleportCause.PLUGIN
)).thenReturn(true);
when(event.getAction()).thenReturn(action);
when(event.isCancelled()).thenReturn(action == Action.RIGHT_CLICK_AIR);
when(event.useItemInHand()).thenReturn(Event.Result.DEFAULT);
dispatchUse(service, event);
verify(player).teleport(
new Location(returnWorld, 12.25, 70.5, -4.75, 123.0F, -15.5F),
PlayerTeleportEvent.TeleportCause.PLUGIN
);
verify(player).setFallDistance(0.0F);
}
@Test
void unavailableAndUnsafeReturnsDoNotTeleportThePlayer() {
NamespacedKey key = NamespacedKey.minecraft("pocket_base_keystone");
UUID missingPocketWorldId = UUID.randomUUID();
UUID unsafePocketWorldId = UUID.randomUUID();
UUID missingPlayerId = UUID.randomUUID();
UUID unsafePlayerId = UUID.randomUUID();
UUID missingWorldId = UUID.randomUUID();
UUID unsafeWorldId = UUID.randomUUID();
World missingPocketWorld = mock(World.class);
World unsafePocketWorld = mock(World.class);
World unsafeWorld = mock(World.class);
Player missingPlayer = mock(Player.class);
Player unsafePlayer = mock(Player.class);
PlayerInteractEvent missingEvent = mock(PlayerInteractEvent.class);
PlayerInteractEvent unsafeEvent = mock(PlayerInteractEvent.class);
ItemStack missingKeystone = markedKeystone(key);
ItemStack unsafeKeystone = markedKeystone(key);
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
KeystoneReturnStore returns = mock(KeystoneReturnStore.class);
Server server = mock(Server.class);
PocketBaseKeystoneService service = new PocketBaseKeystoneService(
server, pocketBases, returns, key, Logger.getAnonymousLogger()
);
when(missingPocketWorld.getUID()).thenReturn(missingPocketWorldId);
when(unsafePocketWorld.getUID()).thenReturn(unsafePocketWorldId);
when(missingPlayer.getUniqueId()).thenReturn(missingPlayerId);
when(missingPlayer.getWorld()).thenReturn(missingPocketWorld);
when(unsafePlayer.getUniqueId()).thenReturn(unsafePlayerId);
when(unsafePlayer.getWorld()).thenReturn(unsafePocketWorld);
when(missingEvent.getPlayer()).thenReturn(missingPlayer);
when(missingEvent.getAction()).thenReturn(Action.RIGHT_CLICK_AIR);
when(missingEvent.getItem()).thenReturn(missingKeystone);
when(unsafeEvent.getPlayer()).thenReturn(unsafePlayer);
when(unsafeEvent.getAction()).thenReturn(Action.RIGHT_CLICK_AIR);
when(unsafeEvent.getItem()).thenReturn(unsafeKeystone);
when(pocketBases.state(missingPlayerId)).thenReturn(
new PocketBaseState(missingPlayerId, 1, Optional.empty())
);
when(pocketBases.state(unsafePlayerId)).thenReturn(
new PocketBaseState(unsafePlayerId, 1, Optional.empty())
);
when(pocketBases.ownerForPocketWorld(missingPocketWorldId))
.thenReturn(Optional.of(missingPlayerId));
when(pocketBases.ownerForPocketWorld(unsafePocketWorldId))
.thenReturn(Optional.of(unsafePlayerId));
when(returns.destination(missingPlayerId)).thenReturn(Optional.of(
new KeystoneReturnLocation(
missingWorldId, "missing", 0.5, 65.0, 0.5, 0.0F, 0.0F
)
));
when(returns.destination(unsafePlayerId)).thenReturn(Optional.of(
new KeystoneReturnLocation(
unsafeWorldId, "unsafe", 0.5, 65.0, 0.5, 0.0F, 0.0F
)
));
when(server.getWorld(unsafeWorldId)).thenReturn(unsafeWorld);
when(unsafeWorld.getMinHeight()).thenReturn(-64);
when(unsafeWorld.getMaxHeight()).thenReturn(320);
Block unsafeBlock = mock(Block.class);
when(unsafeBlock.getType()).thenReturn(Material.AIR);
when(unsafeWorld.getBlockAt(anyInt(), anyInt(), anyInt()))
.thenReturn(unsafeBlock);
service.onUse(missingEvent);
service.onUse(unsafeEvent);
verify(missingPlayer, never()).teleport(
any(Location.class), any(PlayerTeleportEvent.TeleportCause.class)
);
verify(unsafePlayer, never()).teleport(
any(Location.class), any(PlayerTeleportEvent.TeleportCause.class)
);
}
@Test
void authenticatesKeystonesByPersistentMetadataInsteadOfTheirName() {
NamespacedKey key = NamespacedKey.minecraft("pocket_base_keystone");
ItemStack marked = mock(ItemStack.class);
ItemMeta markedMeta = mock(ItemMeta.class);
PersistentDataContainer markedData = mock(PersistentDataContainer.class);
ItemStack renamedOnly = mock(ItemStack.class);
ItemMeta renamedMeta = mock(ItemMeta.class);
PersistentDataContainer renamedData = mock(PersistentDataContainer.class);
when(marked.getType()).thenReturn(Material.RECOVERY_COMPASS);
when(marked.getItemMeta()).thenReturn(markedMeta);
when(markedMeta.getPersistentDataContainer()).thenReturn(markedData);
when(markedData.get(key, PersistentDataType.BYTE)).thenReturn((byte) 1);
when(renamedOnly.getType()).thenReturn(Material.RECOVERY_COMPASS);
when(renamedOnly.getItemMeta()).thenReturn(renamedMeta);
when(renamedMeta.getPersistentDataContainer()).thenReturn(renamedData);
assertTrue(PocketBaseKeystoneService.isKeystone(marked, key));
assertFalse(PocketBaseKeystoneService.isKeystone(renamedOnly, key));
}
@ParameterizedTest
@EnumSource(value = Action.class, names = {"RIGHT_CLICK_AIR", "RIGHT_CLICK_BLOCK"})
void explicitItemUseDenialPreventsKeystoneTravel(Action action) throws Exception {
NamespacedKey key = NamespacedKey.minecraft("pocket_base_keystone");
Player player = mock(Player.class);
UUID playerId = UUID.randomUUID();
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
KeystoneReturnStore returns = mock(KeystoneReturnStore.class);
PocketBaseKeystoneService service = new PocketBaseKeystoneService(
mock(Server.class), pocketBases, returns, key, Logger.getAnonymousLogger()
);
PlayerInteractEvent event = mock(PlayerInteractEvent.class);
ItemStack keystone = markedKeystone(key);
when(event.getAction()).thenReturn(action);
when(event.getItem()).thenReturn(keystone);
when(event.getPlayer()).thenReturn(player);
when(event.isCancelled()).thenReturn(true);
when(event.useItemInHand()).thenReturn(Event.Result.DENY);
when(player.getUniqueId()).thenReturn(playerId);
when(pocketBases.state(playerId)).thenReturn(
new PocketBaseState(playerId, 0, Optional.empty())
);
dispatchUse(service, event);
verify(pocketBases, never()).state(any(UUID.class));
verify(returns, never()).record(any(UUID.class), any(KeystoneReturnLocation.class));
verify(player, never()).teleport(
any(Location.class), any(PlayerTeleportEvent.TeleportCause.class)
);
verify(event, never()).setCancelled(true);
}
private static void dispatchUse(
PocketBaseKeystoneService service, PlayerInteractEvent event
) throws Exception {
EventHandler handler = PocketBaseKeystoneService.class
.getMethod("onUse", PlayerInteractEvent.class).getAnnotation(EventHandler.class);
RegisteredListener listener = new RegisteredListener(
service, (ignored, dispatched) -> service.onUse((PlayerInteractEvent) dispatched),
handler.priority(), mock(Plugin.class), handler.ignoreCancelled()
);
listener.callEvent(event);
}
private static ItemStack markedKeystone(NamespacedKey key) {
ItemStack item = mock(ItemStack.class);
ItemMeta metadata = mock(ItemMeta.class);
PersistentDataContainer data = mock(PersistentDataContainer.class);
when(item.getType()).thenReturn(Material.RECOVERY_COMPASS);
when(item.getItemMeta()).thenReturn(metadata);
when(metadata.getPersistentDataContainer()).thenReturn(data);
when(data.get(key, PersistentDataType.BYTE)).thenReturn((byte) 1);
return item;
}
}
@@ -2,6 +2,7 @@ package games.dmg.spigotbase;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
@@ -58,6 +59,32 @@ final class PocketBaseManagerTest {
assertEquals(generated, manager.returnPortal(ownerId)); assertEquals(generated, manager.returnPortal(ownerId));
} }
@Test
void persistsPocketBaseFlightUnlock() throws Exception {
UUID ownerId = UUID.randomUUID();
PocketBaseState current = new PocketBaseState(ownerId, 1, Optional.empty());
PocketBaseStateManager states = mock(PocketBaseStateManager.class);
when(states.state(ownerId)).thenReturn(current);
when(states.updateAndSave(org.mockito.ArgumentMatchers.eq(ownerId),
org.mockito.ArgumentMatchers.any())).thenAnswer(invocation -> {
@SuppressWarnings("unchecked")
UnaryOperator<PocketBaseState> operation = invocation.getArgument(1);
return operation.apply(current);
});
PocketBaseManager manager = new PocketBaseManager(
states,
mock(PocketBaseWorldService.class),
new PluginSettingsProvider(PluginSettings.from(Map.of()))
);
PocketBaseState updated = manager.unlockFlight(ownerId);
assertTrue(updated.flightUnlocked());
verify(states).updateAndSave(
org.mockito.ArgumentMatchers.eq(ownerId), org.mockito.ArgumentMatchers.any()
);
}
@Test @Test
void persistsAndAppliesHostileMobSpawningWithoutChangingPassive() throws Exception { void persistsAndAppliesHostileMobSpawningWithoutChangingPassive() throws Exception {
UUID ownerId = UUID.randomUUID(); UUID ownerId = UUID.randomUUID();
@@ -0,0 +1,77 @@
package games.dmg.spigotbase;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import java.util.UUID;
import org.bukkit.Location;
import org.bukkit.Particle;
import org.bukkit.Server;
import org.bukkit.World;
import org.bukkit.plugin.Plugin;
import org.bukkit.scheduler.BukkitScheduler;
import org.bukkit.scheduler.BukkitTask;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
final class TeleportParticlesTest {
@Test
void warmupIntensifiesThenFadesAtOriginalLocationAndStops() {
Plugin plugin = mock(Plugin.class);
Server server = mock(Server.class);
BukkitScheduler scheduler = mock(BukkitScheduler.class);
BukkitTask task = mock(BukkitTask.class);
World world = mock(World.class);
when(plugin.getServer()).thenReturn(server);
when(server.getScheduler()).thenReturn(scheduler);
when(scheduler.runTaskTimer(eq(plugin), any(Runnable.class), eq(0L), eq(2L)))
.thenReturn(task);
TeleportParticles effects = new TeleportParticles(plugin);
UUID id = UUID.randomUUID();
Location origin = new Location(world, 10, 64, 20);
effects.start(id, origin, 2);
origin.setX(999);
ArgumentCaptor<Runnable> runnable = ArgumentCaptor.forClass(Runnable.class);
verify(scheduler).runTaskTimer(eq(plugin), runnable.capture(), eq(0L), eq(2L));
Runnable tick = runnable.getValue();
for (int i = 0; i < 21; i++) tick.run();
ArgumentCaptor<Integer> counts = ArgumentCaptor.forClass(Integer.class);
verify(world, times(21)).spawnParticle(eq(Particle.END_ROD), any(Location.class),
counts.capture().intValue(), anyDouble(), anyDouble(), anyDouble(), anyDouble());
assertTrue(counts.getAllValues().getLast() > counts.getAllValues().getFirst());
clearInvocations(world);
effects.complete(id);
for (int i = 0; i < 21; i++) tick.run();
ArgumentCaptor<Location> locations = ArgumentCaptor.forClass(Location.class);
counts = ArgumentCaptor.forClass(Integer.class);
verify(world, times(20)).spawnParticle(eq(Particle.END_ROD), locations.capture(),
counts.capture().intValue(), anyDouble(), anyDouble(), anyDouble(), anyDouble());
assertTrue(counts.getAllValues().getFirst() > counts.getAllValues().getLast());
assertTrue(locations.getAllValues().stream().allMatch(p -> Math.abs(p.getX() - 10) < 2));
verify(task).cancel();
}
@Test
void cancellationAndShutdownStopWithoutAfterglow() {
Plugin plugin = mock(Plugin.class);
Server server = mock(Server.class);
BukkitScheduler scheduler = mock(BukkitScheduler.class);
BukkitTask task = mock(BukkitTask.class);
World world = mock(World.class);
when(plugin.getServer()).thenReturn(server);
when(server.getScheduler()).thenReturn(scheduler);
when(scheduler.runTaskTimer(eq(plugin), any(Runnable.class), eq(0L), eq(2L)))
.thenReturn(task);
TeleportParticles effects = new TeleportParticles(plugin);
UUID id = UUID.randomUUID();
effects.start(id, new Location(world, 0, 64, 0), 30);
effects.cancel(id);
effects.complete(id);
verify(task).cancel();
effects.start(id, new Location(world, 0, 64, 0), 30);
effects.clear();
verify(task, times(2)).cancel();
verifyNoInteractions(world);
}
}
@@ -0,0 +1,29 @@
package games.dmg.spigotbase;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.nio.file.Path;
import java.util.Map;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
final class YamlKeystoneReturnRepositoryTest {
@TempDir
Path temporaryDirectory;
@Test
void roundTripsExactPlayerReturnDestinations() throws Exception {
UUID playerId = UUID.randomUUID();
KeystoneReturnLocation expected = new KeystoneReturnLocation(
UUID.randomUUID(), "world", 12.25, 70.5, -4.75, 123.0F, -15.5F
);
YamlKeystoneReturnRepository repository = new YamlKeystoneReturnRepository(
temporaryDirectory.resolve("keystone-returns.yml")
);
repository.save(Map.of(playerId, expected));
assertEquals(expected, repository.load().get(playerId));
}
}
@@ -31,7 +31,12 @@ final class YamlPocketBaseRepositoryTest {
)), )),
true, true,
false, false,
PocketBaseBiome.CRIMSON_FOREST PocketBaseBiome.CRIMSON_FOREST,
true,
Optional.of(new PocketPortalLocation(
worldId, "pocket", 4, 65, 4, PocketPortalAxis.X
)),
false
); );
YamlPocketBaseRepository repository = new YamlPocketBaseRepository( YamlPocketBaseRepository repository = new YamlPocketBaseRepository(
temporaryDirectory.resolve("pocket-bases.yml") temporaryDirectory.resolve("pocket-bases.yml")
@@ -55,6 +60,21 @@ final class YamlPocketBaseRepositoryTest {
assertFalse(loaded.passiveMobSpawningEnabled()); assertFalse(loaded.passiveMobSpawningEnabled());
assertTrue(loaded.returnPortal().isEmpty()); assertTrue(loaded.returnPortal().isEmpty());
assertEquals(PocketBaseBiome.THE_VOID, loaded.biome()); assertEquals(PocketBaseBiome.THE_VOID, loaded.biome());
assertFalse(loaded.flightUnlocked());
}
@Test
void existingRaidUnlockDefaultsOwnerFlightToEnabled() throws Exception {
UUID ownerId = UUID.randomUUID();
Path stateFile = temporaryDirectory.resolve("pocket-bases.yml");
Files.writeString(stateFile, "owners:\n " + ownerId
+ ":\n level: 1\n flight-unlocked: true\n");
YamlPocketBaseRepository repository = new YamlPocketBaseRepository(stateFile);
PocketBaseState loaded = repository.load().get(ownerId);
assertTrue(loaded.flightEnabled());
assertTrue(loaded.flightPortal().isEmpty());
} }
@Test @Test