Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ad930c7ad | ||
|
|
28dd5422d5 | ||
|
|
ecb07d48ad | ||
|
|
c42ba6888a | ||
|
|
61e7a01374 | ||
|
|
e0b1a25c5e | ||
|
|
f4511f9286 | ||
|
|
02c6698268 | ||
|
|
bc2f9d8e25 | ||
|
|
8b3eaad253 | ||
|
|
29413d36b3 |
@@ -17,7 +17,7 @@ jobs:
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 17
|
||||
java-version: 25
|
||||
cache: gradle
|
||||
|
||||
- name: Validate conventional commits
|
||||
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 17
|
||||
java-version: 25
|
||||
cache: gradle
|
||||
|
||||
- name: Set up Node.js
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Spigot Base
|
||||
|
||||
A Spigot 26.2 plugin providing progression-gated player bases and quality-of-life unlocks.
|
||||
A Purpur 26.2 plugin providing progression-gated player bases and quality-of-life unlocks.
|
||||
|
||||
Players begin by breaking Survival-mode grass blocks or dirt. The progression paths provide a persistent base, navigation particles, expanding base bounds, controlled flight, personal teleportation, and visitor teleportation.
|
||||
|
||||
@@ -8,8 +8,8 @@ The approved behavior is specified in the [OKF design bundle](design/index.md).
|
||||
|
||||
## Requirements
|
||||
|
||||
- Spigot 26.2
|
||||
- Java 17 or newer
|
||||
- Purpur 26.2 build 2618
|
||||
- Java 25 or newer
|
||||
|
||||
## Build
|
||||
|
||||
@@ -30,6 +30,7 @@ The plugin JAR is written to `build/libs/`.
|
||||
/basesettings status
|
||||
/basesettings upgrade
|
||||
/basesettings pocket upgrade
|
||||
/basesettings pocket flight <enable|disable>
|
||||
/basesettings pocket mobs <hostile|passive> <enable|disable>
|
||||
/basesettings pocket type <void|nether|overworld> <subtype>
|
||||
/basesettings visitors <allowed|blocked>
|
||||
@@ -40,15 +41,17 @@ The plugin JAR is written to `build/libs/`.
|
||||
/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.
|
||||
|
||||
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. 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.
|
||||
|
||||
|
||||
+5
-5
@@ -9,23 +9,23 @@ version = providers.gradleProperty("releaseVersion")
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
maven("https://hub.spigotmc.org/nexus/content/repositories/snapshots/")
|
||||
maven("https://repo.purpurmc.org/snapshots/")
|
||||
}
|
||||
|
||||
java {
|
||||
toolchain {
|
||||
languageVersion = JavaLanguageVersion.of(17)
|
||||
languageVersion = JavaLanguageVersion.of(25)
|
||||
}
|
||||
}
|
||||
|
||||
tasks.withType<JavaCompile>().configureEach {
|
||||
options.compilerArgs.addAll(listOf("-Xlint:all", "-Werror"))
|
||||
options.compilerArgs.addAll(listOf("-Xlint:all,-deprecation,-removal", "-Werror"))
|
||||
}
|
||||
|
||||
dependencies {
|
||||
compileOnly("org.spigotmc:spigot-api:26.2-R0.1-SNAPSHOT")
|
||||
compileOnly("org.purpurmc.purpur:purpur-api:26.2.build.2618-stable")
|
||||
|
||||
testImplementation("org.spigotmc:spigot-api:26.2-R0.1-SNAPSHOT")
|
||||
testImplementation("org.purpurmc.purpur:purpur-api:26.2.build.2618-stable")
|
||||
testImplementation(platform("org.junit:junit-bom:5.13.4"))
|
||||
testImplementation("org.junit.jupiter:junit-jupiter")
|
||||
testImplementation("org.mockito:mockito-core:5.18.0")
|
||||
|
||||
@@ -122,3 +122,58 @@ description: Chronological record of material decisions affecting the Spigot Bas
|
||||
- Retained the detailed chat report under `/basesettings status` and kept dialog actions routed through the existing validated command behavior.
|
||||
- Added dialog routing, autocomplete, alias, non-player, generated-control, locked-state, and purchase-confirmation coverage.
|
||||
- Verified the feature with `./gradlew clean check jar`.
|
||||
|
||||
## 2026-08-24 — Purpur dialog runtime compatibility
|
||||
|
||||
- Replaced the Spigot-only Bungee dialog types that were absent from production with Purpur's supported Paper dialog API.
|
||||
- Compiled and tested against the exact Purpur 26.2 build 2618 API with Java 25, and updated both CI workflows to use Java 25.
|
||||
- Preserved unit-test coverage through server-independent dialog specifications and adapted Bukkit test fixtures to Purpur's registry-aware API behavior.
|
||||
- Verified 128 tests and the Java 25 plugin artifact with `./gradlew clean check jar`.
|
||||
|
||||
## 2026-08-25 — Static dialog command actions
|
||||
|
||||
- Replaced macro command templates with static run-command actions for fixed dialog buttons.
|
||||
- Added regression coverage for the generated run-command action and its unchanged command payload.
|
||||
- Verified `/basesettings` opens without command exceptions on a local Purpur 26.2 build 2618 server.
|
||||
- Verified the plugin artifact with `./gradlew clean check jar`.
|
||||
|
||||
## 2026-08-24 — Pocket Base mob portal travel
|
||||
|
||||
- Added five-tick collision scanning that transports non-player living mobs through active Pocket Base entrance and return portals.
|
||||
- Reused safe player destinations and server-spawn fallback behavior while excluding items, projectiles, vehicles, and other non-living entities.
|
||||
- Applied the existing two-second portal cooldown to mobs to prevent immediate repeated transport.
|
||||
- Verified the feature and Java 25 plugin artifact with `./gradlew clean check jar`.
|
||||
|
||||
## 2026-08-28 — Safe Pocket Base fall returns
|
||||
|
||||
- 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.
|
||||
- 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`.
|
||||
|
||||
@@ -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)
|
||||
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)
|
||||
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
|
||||
|
||||
- [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] 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.
|
||||
|
||||
@@ -11,6 +11,10 @@ As a **player with Base III**, I want to open my base to visitors so that other
|
||||
|
||||
## 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] `/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.
|
||||
|
||||
@@ -12,6 +12,7 @@ As a **player**, I want to inspect my progression and receive timely milestone f
|
||||
## 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 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] 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.
|
||||
@@ -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-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-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] 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 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] 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.
|
||||
@@ -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-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-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)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
type: User Story
|
||||
title: "US-012: Build and release the plugin"
|
||||
description: Give maintainers repeatable Spigot builds, automated verification, and versioned Gitea releases.
|
||||
description: Give maintainers repeatable Purpur builds, automated verification, and versioned Gitea releases.
|
||||
status: done
|
||||
---
|
||||
|
||||
@@ -11,7 +11,7 @@ As a **plugin maintainer**, I want automated builds and releases modeled on Trig
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] The Gradle project compiles against Spigot API `26.2-R0.1-SNAPSHOT` using a Java 17 toolchain.
|
||||
- [x] The Gradle project compiles against Purpur API `26.2.build.2618-stable` using a Java 25 toolchain.
|
||||
- [x] Compiler lint warnings fail the build.
|
||||
- [x] Automated JUnit 5 tests run as part of the Gradle check lifecycle.
|
||||
- [x] Pushes and pull requests build and test the plugin in Gitea Actions.
|
||||
|
||||
@@ -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-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-023: Craft and use a Pocket Base Keystone](us-023-craft-pocket-base-keystone.md)
|
||||
|
||||
@@ -19,8 +19,13 @@ As a **Pocket Base owner**, I want to connect my normal base to my Pocket Base w
|
||||
- [x] Activating another valid entrance deactivates the previous entrance without removing its frame blocks.
|
||||
- [x] Breaking any required frame block immediately deactivates the entrance.
|
||||
- [x] Any player can use an active entrance without a separate visitor setting, warm-up, or cooldown.
|
||||
- [x] Non-player living mobs entering an active entrance portal are teleported into that Pocket Base.
|
||||
- [x] Portal cooldown handling prevents transported mobs from immediately bouncing back.
|
||||
- [x] Items, projectiles, vehicles, and other non-living entities are not transported.
|
||||
- [x] Visitors can build, break blocks, open containers, and otherwise interact normally inside the Pocket Base.
|
||||
- [x] The generated return portal sends players safely to the owner's active entrance.
|
||||
- [x] Non-player living mobs entering the active, intact return portal are teleported safely outside the normal-world entrance.
|
||||
- [x] Mob return travel falls back safely to the server spawn when the owner has no valid normal-world entrance.
|
||||
- [x] Only the owner can activate a complete portal frame inside their own Pocket Base with flint and steel.
|
||||
- [x] Every required return-frame block must be within the owner's unlocked Pocket Base boundary.
|
||||
- [x] Each Pocket Base has at most one active return portal, and activating another valid frame moves the active return location without removing the old frame.
|
||||
@@ -28,9 +33,11 @@ As a **Pocket Base owner**, I want to connect my normal base to my Pocket Base w
|
||||
- [x] Breaking the active return frame disables return travel through it.
|
||||
- [x] Return travel falls back to the server spawn when the owner has no valid normal-world entrance.
|
||||
- [x] Falling below Y -64 performs the same safe return without void damage.
|
||||
- [x] Portal and void-return travel clears accumulated fall distance so players do not take carried-over fall damage after landing at the destination.
|
||||
- [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] 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
|
||||
|
||||
@@ -38,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-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-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,11 +16,16 @@ 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] 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] 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] Purchases require confirmation and continue using existing validation, payment, and persistence logic.
|
||||
- [x] `ui` is offered through command autocomplete.
|
||||
- [x] Non-player execution remains safely rejected.
|
||||
- [x] Automated tests verify dialog routing and generated controls.
|
||||
- [x] Fixed dialog commands use Purpur static run-command actions rather than macro templates.
|
||||
- [x] A rendering regression test verifies fixed command buttons do not throw `No variables in macro`.
|
||||
- [x] The dialog uses Purpur's supported Paper dialog API and opens on the production Purpur 26.2 runtime without missing-class or command exceptions.
|
||||
|
||||
## Related
|
||||
|
||||
@@ -29,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-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-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 +1,5 @@
|
||||
plugins {
|
||||
id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
|
||||
}
|
||||
|
||||
rootProject.name = "spigot-base"
|
||||
|
||||
@@ -1,23 +1,38 @@
|
||||
package games.dmg.spigotbase;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.logging.Level;
|
||||
import java.util.logging.Logger;
|
||||
import org.bukkit.ChatColor;
|
||||
import org.bukkit.GameMode;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.Server;
|
||||
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;
|
||||
|
||||
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 BaseStateManager stateManager;
|
||||
private final SecondaryProgressionService progressionService;
|
||||
private final BaseBoundsService boundsService;
|
||||
private final PluginSettingsProvider settings;
|
||||
private final PocketBaseManager pocketBases;
|
||||
private final Logger logger;
|
||||
private final Set<UUID> grantedFlight = new HashSet<>();
|
||||
private final Set<UUID> warned = new HashSet<>();
|
||||
private final Map<UUID, UUID> portalFlight = new HashMap<>();
|
||||
|
||||
BaseFlightController(
|
||||
Server server,
|
||||
@@ -25,12 +40,85 @@ final class BaseFlightController implements Runnable {
|
||||
SecondaryProgressionService progressionService,
|
||||
BaseBoundsService boundsService,
|
||||
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.stateManager = stateManager;
|
||||
this.progressionService = progressionService;
|
||||
this.boundsService = boundsService;
|
||||
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
|
||||
@@ -44,6 +132,7 @@ final class BaseFlightController implements Runnable {
|
||||
}
|
||||
grantedFlight.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) {
|
||||
@@ -66,6 +155,7 @@ final class BaseFlightController implements Runnable {
|
||||
}
|
||||
grantedFlight.clear();
|
||||
warned.clear();
|
||||
portalFlight.clear();
|
||||
}
|
||||
|
||||
private PlayerState observeElytra(Player player, PlayerState state) {
|
||||
@@ -89,8 +179,22 @@ final class BaseFlightController implements Runnable {
|
||||
}
|
||||
|
||||
private void applyFlight(Player player, PlayerState state) {
|
||||
if (player.getGameMode() != GameMode.SURVIVAL
|
||||
|| !state.flightEnabled() || state.flightLevel() < 1 || state.base().isEmpty()) {
|
||||
if (player.getGameMode() != GameMode.SURVIVAL) {
|
||||
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);
|
||||
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) {
|
||||
int y = player.getLocation().getBlockY();
|
||||
if (state.flightLevel() == 3) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package games.dmg.spigotbase;
|
||||
|
||||
import io.papermc.paper.dialog.Dialog;
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
@@ -28,6 +29,7 @@ final class BaseSettingsCommand implements CommandExecutor, TabCompleter {
|
||||
private final TeleportPolicy teleportPolicy;
|
||||
private final BaseFlightController flightController;
|
||||
private final PocketBaseManager pocketBases;
|
||||
private final DialogProvider dialogProvider;
|
||||
|
||||
BaseSettingsCommand(
|
||||
BaseStateManager stateManager,
|
||||
@@ -42,12 +44,30 @@ final class BaseSettingsCommand implements CommandExecutor, TabCompleter {
|
||||
PluginSettingsProvider settings,
|
||||
BaseFlightController flightController,
|
||||
PocketBaseManager pocketBases
|
||||
) {
|
||||
this(
|
||||
stateManager,
|
||||
settings,
|
||||
flightController,
|
||||
pocketBases,
|
||||
(owner, pocket) -> new BaseSettingsDialogFactory(settings.current())
|
||||
.create(owner, pocket)
|
||||
);
|
||||
}
|
||||
|
||||
BaseSettingsCommand(
|
||||
BaseStateManager stateManager,
|
||||
PluginSettingsProvider settings,
|
||||
BaseFlightController flightController,
|
||||
PocketBaseManager pocketBases,
|
||||
DialogProvider dialogProvider
|
||||
) {
|
||||
this.stateManager = stateManager;
|
||||
this.settings = settings;
|
||||
this.teleportPolicy = new TeleportPolicy(settings);
|
||||
this.flightController = flightController;
|
||||
this.pocketBases = pocketBases;
|
||||
this.dialogProvider = dialogProvider;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -73,6 +93,10 @@ final class BaseSettingsCommand implements CommandExecutor, TabCompleter {
|
||||
&& arguments[1].equalsIgnoreCase("upgrade")) {
|
||||
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")
|
||||
&& arguments[1].equalsIgnoreCase("mobs")) {
|
||||
return updatePocketMobSpawning(player, arguments[2], arguments[3]);
|
||||
@@ -244,6 +268,35 @@ final class BaseSettingsCommand implements CommandExecutor, TabCompleter {
|
||||
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(
|
||||
Player player,
|
||||
String category,
|
||||
@@ -449,7 +502,7 @@ final class BaseSettingsCommand implements CommandExecutor, TabCompleter {
|
||||
PocketBaseState pocket = pocketBases == null
|
||||
? PocketBaseState.locked(state.playerId())
|
||||
: pocketBases.state(state.playerId());
|
||||
player.showDialog(new BaseSettingsDialogFactory(settings.current()).create(state, pocket));
|
||||
player.showDialog(dialogProvider.create(state, pocket));
|
||||
}
|
||||
|
||||
private void showStatus(Player player, PlayerState state) {
|
||||
@@ -545,7 +598,11 @@ final class BaseSettingsCommand implements CommandExecutor, TabCompleter {
|
||||
+ "; hostile spawning="
|
||||
+ (pocket.hostileMobSpawningEnabled() ? "enabled" : "disabled")
|
||||
+ "; 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) {
|
||||
@@ -577,7 +634,7 @@ final class BaseSettingsCommand implements CommandExecutor, TabCompleter {
|
||||
List<String> modes = arguments[0].equalsIgnoreCase("visitors")
|
||||
? VISITOR_MODES
|
||||
: 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;
|
||||
default -> List.of();
|
||||
};
|
||||
@@ -586,6 +643,11 @@ final class BaseSettingsCommand implements CommandExecutor, TabCompleter {
|
||||
}
|
||||
if (arguments.length == 3 && arguments[0].equalsIgnoreCase("pocket")) {
|
||||
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")) {
|
||||
return List.of("hostile", "passive").stream()
|
||||
.filter(category -> category.startsWith(prefix))
|
||||
@@ -672,9 +734,14 @@ final class BaseSettingsCommand implements CommandExecutor, TabCompleter {
|
||||
return met ? ChatColor.YELLOW : ChatColor.RED;
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
interface DialogProvider {
|
||||
Dialog create(PlayerState owner, PocketBaseState pocket);
|
||||
}
|
||||
|
||||
private static void sendUsage(Player player) {
|
||||
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>"
|
||||
+ "|pocket type <void|nether|overworld> <subtype>"
|
||||
+ "|visitors <allowed|blocked>|navigation <enable|disable>"
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
package games.dmg.spigotbase;
|
||||
|
||||
import io.papermc.paper.dialog.Dialog;
|
||||
import io.papermc.paper.registry.RegistryKey;
|
||||
import io.papermc.paper.registry.data.dialog.ActionButton;
|
||||
import io.papermc.paper.registry.data.dialog.DialogBase;
|
||||
import io.papermc.paper.registry.data.dialog.action.DialogAction;
|
||||
import io.papermc.paper.registry.data.dialog.body.DialogBody;
|
||||
import io.papermc.paper.registry.data.dialog.type.DialogType;
|
||||
import io.papermc.paper.registry.set.RegistrySet;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import net.md_5.bungee.api.chat.BaseComponent;
|
||||
import net.md_5.bungee.api.chat.TextComponent;
|
||||
import net.md_5.bungee.api.dialog.ConfirmationDialog;
|
||||
import net.md_5.bungee.api.dialog.Dialog;
|
||||
import net.md_5.bungee.api.dialog.DialogBase;
|
||||
import net.md_5.bungee.api.dialog.DialogListDialog;
|
||||
import net.md_5.bungee.api.dialog.MultiActionDialog;
|
||||
import net.md_5.bungee.api.dialog.NoticeDialog;
|
||||
import net.md_5.bungee.api.dialog.action.ActionButton;
|
||||
import net.md_5.bungee.api.dialog.action.RunCommandAction;
|
||||
import net.md_5.bungee.api.dialog.body.PlainMessageBody;
|
||||
import net.kyori.adventure.text.Component;
|
||||
import net.kyori.adventure.text.event.ClickEvent;
|
||||
|
||||
final class BaseSettingsDialogFactory {
|
||||
private static final int DIALOG_WIDTH = 420;
|
||||
@@ -24,8 +23,18 @@ final class BaseSettingsDialogFactory {
|
||||
}
|
||||
|
||||
Dialog create(PlayerState owner, PocketBaseState pocket) {
|
||||
return new DialogListDialog(
|
||||
base(
|
||||
return render(specification(owner, pocket));
|
||||
}
|
||||
|
||||
DialogSpec specification(PlayerState owner, PocketBaseState pocket) {
|
||||
List<DialogSpec> dialogs = List.of(
|
||||
generalSettings(owner),
|
||||
progressReport(),
|
||||
baseUpgrade(owner),
|
||||
pocketSettings(owner, pocket)
|
||||
);
|
||||
return new ListSpec(
|
||||
content(
|
||||
"Base Settings",
|
||||
"Base Settings",
|
||||
"Base " + owner.baseLevel() + "/4 • Size " + owner.sizeLevel() + "/3"
|
||||
@@ -33,95 +42,91 @@ final class BaseSettingsDialogFactory {
|
||||
+ "Pocket Base " + pocket.level() + " • "
|
||||
+ humanize(pocket.biome().commandName())
|
||||
),
|
||||
List.of(
|
||||
generalSettings(owner),
|
||||
progressReport(),
|
||||
baseUpgrade(owner),
|
||||
pocketSettings(owner, pocket)
|
||||
),
|
||||
null,
|
||||
dialogs,
|
||||
2,
|
||||
190
|
||||
);
|
||||
}
|
||||
|
||||
private Dialog generalSettings(PlayerState owner) {
|
||||
return new MultiActionDialog(
|
||||
base(
|
||||
private DialogSpec generalSettings(PlayerState owner) {
|
||||
List<ButtonSpec> actions = List.of(
|
||||
toggle(
|
||||
"Visitors",
|
||||
owner.baseLevel() >= 4,
|
||||
owner.visitorsEnabled(),
|
||||
"Allowed",
|
||||
"Blocked",
|
||||
"basesettings visitors "
|
||||
+ (owner.visitorsEnabled() ? "blocked" : "allowed")
|
||||
),
|
||||
toggle(
|
||||
"Navigation",
|
||||
owner.baseLevel() >= 2,
|
||||
owner.navigationEnabled(),
|
||||
"Enabled",
|
||||
"Disabled",
|
||||
"basesettings navigation " + mode(!owner.navigationEnabled())
|
||||
),
|
||||
toggle(
|
||||
"Flight",
|
||||
owner.flightLevel() >= 1,
|
||||
owner.flightEnabled(),
|
||||
"Enabled",
|
||||
"Disabled",
|
||||
"basesettings flight " + mode(!owner.flightEnabled())
|
||||
),
|
||||
toggle(
|
||||
"Border",
|
||||
owner.baseLevel() >= 1 && owner.base().isPresent(),
|
||||
owner.borderEnabled(),
|
||||
"Enabled",
|
||||
"Disabled",
|
||||
"basesettings border " + mode(!owner.borderEnabled())
|
||||
),
|
||||
toggle(
|
||||
"Spawnable Overlay",
|
||||
owner.baseLevel() >= 1
|
||||
&& owner.base().isPresent()
|
||||
&& owner.totalBlocksPlaced()
|
||||
>= settings.spawnableOverlayUnlockPlacements(),
|
||||
owner.spawnableOverlayEnabled(),
|
||||
"Enabled",
|
||||
"Disabled",
|
||||
"basesettings spawnable " + mode(!owner.spawnableOverlayEnabled())
|
||||
),
|
||||
toggle(
|
||||
"Progress Boss Bar",
|
||||
true,
|
||||
owner.bossBarEnabled(),
|
||||
"Enabled",
|
||||
"Disabled",
|
||||
"basesettings bossbar " + mode(!owner.bossBarEnabled())
|
||||
)
|
||||
);
|
||||
return new MultiSpec(
|
||||
content(
|
||||
"Base Controls",
|
||||
"Base Controls",
|
||||
"Select a setting to change it. Locked controls remain visible and explain "
|
||||
+ "their requirement in chat when selected."
|
||||
),
|
||||
List.of(
|
||||
toggle(
|
||||
"Visitors",
|
||||
owner.baseLevel() >= 4,
|
||||
owner.visitorsEnabled(),
|
||||
"Allowed",
|
||||
"Blocked",
|
||||
"basesettings visitors "
|
||||
+ (owner.visitorsEnabled() ? "blocked" : "allowed")
|
||||
),
|
||||
toggle(
|
||||
"Navigation",
|
||||
owner.baseLevel() >= 2,
|
||||
owner.navigationEnabled(),
|
||||
"Enabled",
|
||||
"Disabled",
|
||||
"basesettings navigation "
|
||||
+ mode(!owner.navigationEnabled())
|
||||
),
|
||||
toggle(
|
||||
"Flight",
|
||||
owner.flightLevel() >= 1,
|
||||
owner.flightEnabled(),
|
||||
"Enabled",
|
||||
"Disabled",
|
||||
"basesettings flight " + mode(!owner.flightEnabled())
|
||||
),
|
||||
toggle(
|
||||
"Border",
|
||||
owner.baseLevel() >= 1 && owner.base().isPresent(),
|
||||
owner.borderEnabled(),
|
||||
"Enabled",
|
||||
"Disabled",
|
||||
"basesettings border " + mode(!owner.borderEnabled())
|
||||
),
|
||||
toggle(
|
||||
"Spawnable Overlay",
|
||||
owner.baseLevel() >= 1
|
||||
&& owner.base().isPresent()
|
||||
&& owner.totalBlocksPlaced()
|
||||
>= settings.spawnableOverlayUnlockPlacements(),
|
||||
owner.spawnableOverlayEnabled(),
|
||||
"Enabled",
|
||||
"Disabled",
|
||||
"basesettings spawnable " + mode(!owner.spawnableOverlayEnabled())
|
||||
),
|
||||
toggle(
|
||||
"Progress Boss Bar",
|
||||
true,
|
||||
owner.bossBarEnabled(),
|
||||
"Enabled",
|
||||
"Disabled",
|
||||
"basesettings bossbar " + mode(!owner.bossBarEnabled())
|
||||
)
|
||||
),
|
||||
2,
|
||||
null
|
||||
actions,
|
||||
2
|
||||
);
|
||||
}
|
||||
|
||||
private Dialog progressReport() {
|
||||
return new NoticeDialog(base(
|
||||
"Progress Report",
|
||||
"Progress Report",
|
||||
"Display the detailed progression report in chat."
|
||||
)).action(commandButton("Show in Chat", "basesettings status"));
|
||||
private DialogSpec progressReport() {
|
||||
return new NoticeSpec(
|
||||
content(
|
||||
"Progress Report",
|
||||
"Progress Report",
|
||||
"Display the detailed progression report in chat."
|
||||
),
|
||||
commandButton("Show in Chat", "basesettings status")
|
||||
);
|
||||
}
|
||||
|
||||
private Dialog baseUpgrade(PlayerState owner) {
|
||||
private DialogSpec baseUpgrade(PlayerState owner) {
|
||||
if (owner.baseLevel() >= 4) {
|
||||
return notice("Base IV", "Base IV and visitor access are already unlocked.");
|
||||
}
|
||||
@@ -131,17 +136,23 @@ final class BaseSettingsDialogFactory {
|
||||
"Unlock and establish Base III before purchasing Base IV visitor access."
|
||||
);
|
||||
}
|
||||
int price = settings.visitorUnlockDiamondCost();
|
||||
return confirmation(
|
||||
"Unlock Base IV",
|
||||
"Purchase visitor access for " + price + " diamonds?",
|
||||
"Purchase visitor access for " + settings.visitorUnlockDiamondCost()
|
||||
+ " diamonds?",
|
||||
"basesettings upgrade"
|
||||
);
|
||||
}
|
||||
|
||||
private Dialog pocketSettings(PlayerState owner, PocketBaseState pocket) {
|
||||
return new DialogListDialog(
|
||||
base(
|
||||
private DialogSpec pocketSettings(PlayerState owner, PocketBaseState pocket) {
|
||||
List<DialogSpec> dialogs = List.of(
|
||||
pocketUpgrade(owner, pocket),
|
||||
pocketFlightSettings(pocket),
|
||||
pocketMobSettings(pocket),
|
||||
pocketBiomeSettings(pocket)
|
||||
);
|
||||
return new ListSpec(
|
||||
content(
|
||||
"Pocket Base",
|
||||
"Pocket Base " + pocket.level(),
|
||||
pocket.level() == 0
|
||||
@@ -149,20 +160,18 @@ final class BaseSettingsDialogFactory {
|
||||
: "Biome: " + pocket.biome().worldType().commandName() + "/"
|
||||
+ pocket.biome().commandName() + "\nHostile mobs: "
|
||||
+ enabled(pocket.hostileMobSpawningEnabled()) + " • Passive mobs: "
|
||||
+ enabled(pocket.passiveMobSpawningEnabled())
|
||||
+ enabled(pocket.passiveMobSpawningEnabled()) + "\nFlight: "
|
||||
+ (pocket.flightUnlocked()
|
||||
? (pocket.flightEnabled() ? "Enabled" : "Disabled")
|
||||
: "Complete a raid to unlock")
|
||||
),
|
||||
List.of(
|
||||
pocketUpgrade(owner, pocket),
|
||||
pocketMobSettings(pocket),
|
||||
pocketBiomeSettings(pocket)
|
||||
),
|
||||
null,
|
||||
dialogs,
|
||||
1,
|
||||
260
|
||||
);
|
||||
}
|
||||
|
||||
private Dialog pocketUpgrade(PlayerState owner, PocketBaseState pocket) {
|
||||
private DialogSpec pocketUpgrade(PlayerState owner, PocketBaseState pocket) {
|
||||
if (owner.baseLevel() < 4 || owner.base().isEmpty()) {
|
||||
return notice(
|
||||
"Pocket Upgrade — Locked",
|
||||
@@ -183,57 +192,82 @@ final class BaseSettingsDialogFactory {
|
||||
);
|
||||
}
|
||||
|
||||
private Dialog pocketMobSettings(PocketBaseState pocket) {
|
||||
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) {
|
||||
if (pocket.level() < 1) {
|
||||
return notice(
|
||||
"Mob Spawning — Locked",
|
||||
"Unlock Pocket Base I to control natural mob spawning."
|
||||
);
|
||||
}
|
||||
return new MultiActionDialog(
|
||||
base(
|
||||
List<ButtonSpec> actions = List.of(
|
||||
toggle(
|
||||
"Hostile Mobs",
|
||||
true,
|
||||
pocket.hostileMobSpawningEnabled(),
|
||||
"Enabled",
|
||||
"Disabled",
|
||||
"basesettings pocket mobs hostile "
|
||||
+ mode(!pocket.hostileMobSpawningEnabled())
|
||||
),
|
||||
toggle(
|
||||
"Passive Mobs",
|
||||
true,
|
||||
pocket.passiveMobSpawningEnabled(),
|
||||
"Enabled",
|
||||
"Disabled",
|
||||
"basesettings pocket mobs passive "
|
||||
+ mode(!pocket.passiveMobSpawningEnabled())
|
||||
)
|
||||
);
|
||||
return new MultiSpec(
|
||||
content(
|
||||
"Mob Spawning",
|
||||
"Mob Spawning",
|
||||
"Control natural spawning independently for each mob category."
|
||||
),
|
||||
List.of(
|
||||
toggle(
|
||||
"Hostile Mobs",
|
||||
true,
|
||||
pocket.hostileMobSpawningEnabled(),
|
||||
"Enabled",
|
||||
"Disabled",
|
||||
"basesettings pocket mobs hostile "
|
||||
+ mode(!pocket.hostileMobSpawningEnabled())
|
||||
),
|
||||
toggle(
|
||||
"Passive Mobs",
|
||||
true,
|
||||
pocket.passiveMobSpawningEnabled(),
|
||||
"Enabled",
|
||||
"Disabled",
|
||||
"basesettings pocket mobs passive "
|
||||
+ mode(!pocket.passiveMobSpawningEnabled())
|
||||
)
|
||||
),
|
||||
1,
|
||||
null
|
||||
actions,
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
private Dialog pocketBiomeSettings(PocketBaseState pocket) {
|
||||
private DialogSpec pocketBiomeSettings(PocketBaseState pocket) {
|
||||
if (pocket.level() < 1) {
|
||||
return notice(
|
||||
"Change Biome — Locked",
|
||||
"Unlock Pocket Base I to purchase biome changes."
|
||||
);
|
||||
}
|
||||
List<Dialog> worldTypes = Arrays.stream(PocketBaseWorldType.values())
|
||||
List<DialogSpec> worldTypes = Arrays.stream(PocketBaseWorldType.values())
|
||||
.map(type -> pocketBiomeType(pocket, type))
|
||||
.map(Dialog.class::cast)
|
||||
.toList();
|
||||
return new DialogListDialog(
|
||||
base(
|
||||
return new ListSpec(
|
||||
content(
|
||||
"Change Biome",
|
||||
"Change Biome",
|
||||
"Current biome: " + pocket.biome().worldType().commandName() + "/"
|
||||
@@ -242,35 +276,32 @@ final class BaseSettingsDialogFactory {
|
||||
+ humanize(settings.pocketBaseBiomeCurrencyMaterial()) + "."
|
||||
),
|
||||
worldTypes,
|
||||
null,
|
||||
1,
|
||||
220
|
||||
);
|
||||
}
|
||||
|
||||
private DialogListDialog pocketBiomeType(
|
||||
private DialogSpec pocketBiomeType(
|
||||
PocketBaseState pocket,
|
||||
PocketBaseWorldType worldType
|
||||
) {
|
||||
List<Dialog> biomes = Arrays.stream(PocketBaseBiome.values())
|
||||
List<DialogSpec> biomes = Arrays.stream(PocketBaseBiome.values())
|
||||
.filter(biome -> biome.worldType() == worldType)
|
||||
.map(biome -> biomeConfirmation(pocket, biome))
|
||||
.map(Dialog.class::cast)
|
||||
.toList();
|
||||
return new DialogListDialog(
|
||||
base(
|
||||
return new ListSpec(
|
||||
content(
|
||||
humanize(worldType.commandName()),
|
||||
humanize(worldType.commandName()) + " Biomes",
|
||||
"Choose a biome subtype. The active biome is marked as selected."
|
||||
),
|
||||
biomes,
|
||||
null,
|
||||
2,
|
||||
190
|
||||
);
|
||||
}
|
||||
|
||||
private Dialog biomeConfirmation(PocketBaseState pocket, PocketBaseBiome biome) {
|
||||
private DialogSpec biomeConfirmation(PocketBaseState pocket, PocketBaseBiome biome) {
|
||||
String title = humanize(biome.commandName())
|
||||
+ (pocket.biome() == biome ? " — Selected" : "");
|
||||
if (pocket.biome() == biome) {
|
||||
@@ -285,16 +316,18 @@ final class BaseSettingsDialogFactory {
|
||||
);
|
||||
}
|
||||
|
||||
private static Dialog confirmation(String title, String message, String command) {
|
||||
return new ConfirmationDialog(base(title, title, message))
|
||||
.yes(commandButton("Confirm", command));
|
||||
private static DialogSpec confirmation(String title, String message, String command) {
|
||||
return new ConfirmationSpec(
|
||||
content(title, title, message),
|
||||
commandButton("Confirm", command)
|
||||
);
|
||||
}
|
||||
|
||||
private static Dialog notice(String title, String message) {
|
||||
return new NoticeDialog(base(title, title, message));
|
||||
private static DialogSpec notice(String title, String message) {
|
||||
return new NoticeSpec(content(title, title, message), null);
|
||||
}
|
||||
|
||||
private static ActionButton toggle(
|
||||
private static ButtonSpec toggle(
|
||||
String name,
|
||||
boolean unlocked,
|
||||
boolean active,
|
||||
@@ -306,29 +339,125 @@ final class BaseSettingsDialogFactory {
|
||||
String tooltip = unlocked
|
||||
? "Click to change this setting."
|
||||
: "This setting has not been unlocked yet.";
|
||||
return new ActionButton(
|
||||
text(name + ": " + state),
|
||||
text(tooltip),
|
||||
190,
|
||||
new RunCommandAction("/" + command)
|
||||
);
|
||||
return new ButtonSpec(name + ": " + state, tooltip, 190, "/" + command);
|
||||
}
|
||||
|
||||
private static ActionButton commandButton(String label, String command) {
|
||||
return new ActionButton(text(label), new RunCommandAction("/" + command));
|
||||
private static ButtonSpec commandButton(String label, String command) {
|
||||
return new ButtonSpec(label, "", 150, "/" + command);
|
||||
}
|
||||
|
||||
private static DialogBase base(String title, String externalTitle, String message) {
|
||||
return new DialogBase(text(title))
|
||||
.externalTitle(text(externalTitle))
|
||||
.body(List.of(new PlainMessageBody(text(message), DIALOG_WIDTH)))
|
||||
private static DialogContent content(
|
||||
String title,
|
||||
String externalTitle,
|
||||
String message
|
||||
) {
|
||||
return new DialogContent(title, externalTitle, message);
|
||||
}
|
||||
|
||||
private static Dialog render(DialogSpec specification) {
|
||||
return render(specification, true);
|
||||
}
|
||||
|
||||
private static Dialog render(DialogSpec specification, boolean root) {
|
||||
DialogType type;
|
||||
ActionButton back = root ? null : backButton();
|
||||
if (specification instanceof ListSpec list) {
|
||||
List<Dialog> dialogs = list.dialogs().stream()
|
||||
.map(child -> render(child, false))
|
||||
.toList();
|
||||
type = DialogType.dialogList(
|
||||
RegistrySet.valueSet(RegistryKey.DIALOG, dialogs),
|
||||
back,
|
||||
list.columns(),
|
||||
list.buttonWidth()
|
||||
);
|
||||
} else if (specification instanceof MultiSpec multi) {
|
||||
List<ActionButton> actions = multi.actions().stream()
|
||||
.map(BaseSettingsDialogFactory::render)
|
||||
.toList();
|
||||
type = DialogType.multiAction(actions, back, multi.columns());
|
||||
} else if (specification instanceof ConfirmationSpec confirmation) {
|
||||
type = DialogType.confirmation(
|
||||
render(confirmation.confirm()),
|
||||
root ? ActionButton.builder(Component.text("Cancel")).build() : back
|
||||
);
|
||||
} else if (specification instanceof NoticeSpec notice) {
|
||||
type = notice.action() == null
|
||||
? (root ? DialogType.notice() : DialogType.notice(back))
|
||||
: DialogType.notice(render(notice.action()));
|
||||
} else {
|
||||
throw new IllegalArgumentException("Unsupported dialog specification");
|
||||
}
|
||||
DialogContent content = specification.content();
|
||||
DialogBase base = DialogBase.builder(Component.text(content.title()))
|
||||
.externalTitle(Component.text(content.externalTitle()))
|
||||
.body(List.of(DialogBody.plainMessage(
|
||||
Component.text(content.message()), DIALOG_WIDTH
|
||||
)))
|
||||
.canCloseWithEscape(true)
|
||||
.pause(false)
|
||||
.afterAction(DialogBase.AfterAction.CLOSE);
|
||||
.afterAction(DialogBase.DialogAfterAction.CLOSE)
|
||||
.build();
|
||||
return Dialog.create(factory -> factory.empty().base(base).type(type));
|
||||
}
|
||||
|
||||
private static BaseComponent text(String value) {
|
||||
return new TextComponent(value);
|
||||
private static ActionButton backButton() {
|
||||
return render(backButtonSpec());
|
||||
}
|
||||
|
||||
static ButtonSpec backButtonSpec() {
|
||||
return commandButton("Back", "basesettings ui");
|
||||
}
|
||||
|
||||
private static ActionButton render(ButtonSpec button) {
|
||||
ActionButton.Builder builder = ActionButton.builder(Component.text(button.label()))
|
||||
.width(button.width())
|
||||
.action(DialogAction.staticAction(commandClick(button.command())));
|
||||
if (!button.tooltip().isEmpty()) {
|
||||
builder.tooltip(Component.text(button.tooltip()));
|
||||
}
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
static ClickEvent<ClickEvent.Payload.Text> commandClick(String command) {
|
||||
return ClickEvent.runCommand(command);
|
||||
}
|
||||
|
||||
sealed interface DialogSpec permits ListSpec, MultiSpec, ConfirmationSpec, NoticeSpec {
|
||||
DialogContent content();
|
||||
}
|
||||
|
||||
record DialogContent(String title, String externalTitle, String message) {
|
||||
}
|
||||
|
||||
record ListSpec(
|
||||
DialogContent content,
|
||||
List<DialogSpec> dialogs,
|
||||
int columns,
|
||||
int buttonWidth
|
||||
) implements DialogSpec {
|
||||
}
|
||||
|
||||
record MultiSpec(
|
||||
DialogContent content,
|
||||
List<ButtonSpec> actions,
|
||||
int columns
|
||||
) implements DialogSpec {
|
||||
}
|
||||
|
||||
record ConfirmationSpec(
|
||||
DialogContent content,
|
||||
ButtonSpec confirm
|
||||
) implements DialogSpec {
|
||||
}
|
||||
|
||||
record NoticeSpec(
|
||||
DialogContent content,
|
||||
ButtonSpec action
|
||||
) implements DialogSpec {
|
||||
}
|
||||
|
||||
record ButtonSpec(String label, String tooltip, int width, String command) {
|
||||
}
|
||||
|
||||
private static String mode(boolean active) {
|
||||
|
||||
@@ -34,6 +34,7 @@ final class BaseTeleportManager implements Listener {
|
||||
private final VisitorPolicy visitorPolicy;
|
||||
private final SafeBaseDestination destinationFinder;
|
||||
private final Clock clock;
|
||||
private final TeleportParticles particles;
|
||||
private final Map<UUID, Request> requests = new HashMap<>();
|
||||
|
||||
BaseTeleportManager(
|
||||
@@ -44,6 +45,20 @@ final class BaseTeleportManager implements Listener {
|
||||
SafeBaseDestination destinationFinder,
|
||||
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.stateManager = stateManager;
|
||||
this.policy = policy;
|
||||
@@ -138,6 +153,7 @@ final class BaseTeleportManager implements Listener {
|
||||
return;
|
||||
}
|
||||
requests.put(player.getUniqueId(), request);
|
||||
particles.start(player.getUniqueId(), origin, seconds);
|
||||
request.task = Bukkit.getScheduler().runTaskTimer(plugin, () -> tick(player, request), 0L, 20L);
|
||||
player.sendMessage(ChatColor.YELLOW + "Stand still for " + seconds + " seconds to " + purpose + ".");
|
||||
}
|
||||
@@ -147,6 +163,7 @@ final class BaseTeleportManager implements Listener {
|
||||
request.cancelTask();
|
||||
}
|
||||
requests.clear();
|
||||
particles.clear();
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
@@ -204,6 +221,9 @@ final class BaseTeleportManager implements Listener {
|
||||
|
||||
private void tick(Player player, Request request) {
|
||||
if (!player.isOnline() || requests.get(player.getUniqueId()) != request) {
|
||||
if (requests.remove(player.getUniqueId(), request)) {
|
||||
particles.cancel(player.getUniqueId());
|
||||
}
|
||||
request.cancelTask();
|
||||
return;
|
||||
}
|
||||
@@ -225,18 +245,22 @@ final class BaseTeleportManager implements Listener {
|
||||
BaseLocation base = request.destination;
|
||||
World world = Bukkit.getWorld(base.worldId());
|
||||
if (world == null) {
|
||||
particles.cancel(player.getUniqueId());
|
||||
player.sendMessage(ChatColor.RED + "The destination world is not currently available.");
|
||||
return;
|
||||
}
|
||||
Optional<Location> destination = destinationFinder.find(world, base);
|
||||
if (destination.isEmpty()) {
|
||||
particles.cancel(player.getUniqueId());
|
||||
player.sendMessage(ChatColor.RED + "No safe location could be found at the base.");
|
||||
return;
|
||||
}
|
||||
if (!player.teleport(destination.orElseThrow(), PlayerTeleportEvent.TeleportCause.PLUGIN)) {
|
||||
particles.cancel(player.getUniqueId());
|
||||
player.sendMessage(ChatColor.RED + "The base teleport was prevented.");
|
||||
return;
|
||||
}
|
||||
particles.complete(player.getUniqueId());
|
||||
Instant completedAt = clock.instant();
|
||||
stateManager.update(
|
||||
player.getUniqueId(),
|
||||
@@ -260,6 +284,7 @@ final class BaseTeleportManager implements Listener {
|
||||
return;
|
||||
}
|
||||
request.cancelTask();
|
||||
particles.cancel(player.getUniqueId());
|
||||
if (message != null) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.logging.Level;
|
||||
import java.util.logging.Logger;
|
||||
import org.bukkit.ChatColor;
|
||||
@@ -15,6 +16,7 @@ import org.bukkit.Server;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.block.Block;
|
||||
import org.bukkit.entity.Entity;
|
||||
import org.bukkit.entity.Mob;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
@@ -26,6 +28,7 @@ import org.bukkit.event.entity.EntityDamageEvent;
|
||||
import org.bukkit.event.player.PlayerInteractEvent;
|
||||
import org.bukkit.event.player.PlayerMoveEvent;
|
||||
import org.bukkit.event.player.PlayerTeleportEvent;
|
||||
import org.bukkit.util.BoundingBox;
|
||||
|
||||
final class PocketBaseController implements Listener, Runnable {
|
||||
private static final int VOID_RETURN_Y = -64;
|
||||
@@ -36,6 +39,7 @@ final class PocketBaseController implements Listener, Runnable {
|
||||
private final PocketBaseManager pocketBases;
|
||||
private final PluginSettingsProvider settings;
|
||||
private final Logger logger;
|
||||
private final BaseFlightController flightController;
|
||||
private final Map<UUID, Long> cooldownUntil = new HashMap<>();
|
||||
|
||||
PocketBaseController(
|
||||
@@ -45,6 +49,18 @@ final class PocketBaseController implements Listener, Runnable {
|
||||
PocketBaseManager pocketBases,
|
||||
PluginSettingsProvider settings,
|
||||
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.baseStates = baseStates;
|
||||
@@ -52,6 +68,7 @@ final class PocketBaseController implements Listener, Runnable {
|
||||
this.pocketBases = pocketBases;
|
||||
this.settings = settings;
|
||||
this.logger = logger;
|
||||
this.flightController = flightController;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -62,7 +79,10 @@ final class PocketBaseController implements Listener, Runnable {
|
||||
if (state.level() > 0 && pocketBases.returnPortalIsIntact(state.ownerId())) {
|
||||
showPortal(pocketBases.returnPortal(state.ownerId()));
|
||||
}
|
||||
state.flightPortal().filter(portal -> isIntact(portal, Material.GOLD_BLOCK))
|
||||
.ifPresent(portal -> showPortal(portal, Material.GOLD_BLOCK));
|
||||
}
|
||||
scanMobPortals();
|
||||
long now = System.nanoTime();
|
||||
cooldownUntil.entrySet().removeIf(entry -> entry.getValue() <= now);
|
||||
}
|
||||
@@ -84,41 +104,57 @@ final class PocketBaseController implements Listener, Runnable {
|
||||
Material frameMaterial = Material.valueOf(
|
||||
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());
|
||||
if (pocketOwner.isPresent()) {
|
||||
if (!pocketOwner.orElseThrow().equals(player.getUniqueId())) {
|
||||
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()
|
||||
.anyMatch(candidate -> !pocketBases.policy().contains(
|
||||
pocket.level(), candidate.x(), candidate.z()
|
||||
))) {
|
||||
player.sendMessage(ChatColor.RED
|
||||
+ "The complete return portal must be inside your Pocket Base boundary.");
|
||||
player.sendMessage(ChatColor.RED + "The complete "
|
||||
+ (activatingFlightPortal ? "flight" : "return")
|
||||
+ " portal must be inside your Pocket Base boundary.");
|
||||
event.setCancelled(true);
|
||||
return;
|
||||
}
|
||||
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) {
|
||||
logger.log(Level.SEVERE, "Could not activate Pocket Base return portal", exception);
|
||||
player.sendMessage(ChatColor.RED
|
||||
+ "The Pocket Base return portal could not be activated.");
|
||||
logger.log(Level.SEVERE, "Could not activate Pocket Base "
|
||||
+ (activatingFlightPortal ? "flight" : "return") + " portal", exception);
|
||||
player.sendMessage(ChatColor.RED + "The Pocket Base "
|
||||
+ (activatingFlightPortal ? "flight" : "return")
|
||||
+ " portal could not be activated.");
|
||||
event.setCancelled(true);
|
||||
return;
|
||||
}
|
||||
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;
|
||||
}
|
||||
PlayerState owner = baseStates.player(player.getUniqueId(), player.getName());
|
||||
@@ -157,6 +193,13 @@ final class PocketBaseController implements Listener, Runnable {
|
||||
deactivate(state.ownerId());
|
||||
}
|
||||
}
|
||||
if (state.flightPortal().isPresent()) {
|
||||
PocketPortalLocation portal = state.flightPortal().orElseThrow();
|
||||
if (portal.worldId().equals(worldId)
|
||||
&& PocketPortalGeometry.isFrame(portal, broken)) {
|
||||
deactivateFlightPortal(state.ownerId());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,6 +238,18 @@ final class PocketBaseController implements Listener, Runnable {
|
||||
pocketBases.returnPortal(ownerId), position(destination)
|
||||
)) {
|
||||
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;
|
||||
}
|
||||
@@ -235,23 +290,62 @@ final class PocketBaseController implements Listener, Runnable {
|
||||
deactivate(ownerId);
|
||||
}
|
||||
|
||||
private void leavePocket(Player player, UUID ownerId) {
|
||||
void scanMobPortals() {
|
||||
for (PocketBaseState state : pocketBases.knownStates().values()) {
|
||||
if (state.entrance().isPresent()) {
|
||||
PocketPortalLocation entrance = state.entrance().orElseThrow();
|
||||
if (isIntact(entrance)) {
|
||||
transportMobs(
|
||||
entrance,
|
||||
mob -> teleport(mob, pocketBases.pocketArrival(state.ownerId()))
|
||||
);
|
||||
}
|
||||
}
|
||||
if (state.level() > 0 && pocketBases.returnPortalIsIntact(state.ownerId())) {
|
||||
transportMobs(
|
||||
pocketBases.returnPortal(state.ownerId()),
|
||||
mob -> leavePocket(mob, state.ownerId())
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void transportMobs(
|
||||
PocketPortalLocation portal,
|
||||
Consumer<Mob> transport
|
||||
) {
|
||||
World world = server.getWorld(portal.worldId());
|
||||
if (world == null) {
|
||||
return;
|
||||
}
|
||||
for (Entity entity : world.getNearbyEntities(
|
||||
portalBounds(portal),
|
||||
candidate -> candidate instanceof Mob
|
||||
)) {
|
||||
if (entity instanceof Mob mob && !onCooldown(mob)) {
|
||||
transport.accept(mob);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void leavePocket(Entity entity, UUID ownerId) {
|
||||
PocketBaseState state = pocketBases.state(ownerId);
|
||||
if (state.entrance().isPresent() && isIntact(state.entrance().orElseThrow())) {
|
||||
teleport(player, outsidePortal(state.entrance().orElseThrow()));
|
||||
teleport(entity, outsidePortal(state.entrance().orElseThrow()));
|
||||
return;
|
||||
}
|
||||
World fallbackWorld = server.getWorlds().get(0);
|
||||
teleport(player, fallbackWorld.getSpawnLocation());
|
||||
teleport(entity, fallbackWorld.getSpawnLocation());
|
||||
}
|
||||
|
||||
private void teleport(Player player, Location destination) {
|
||||
cooldownUntil.put(player.getUniqueId(), System.nanoTime() + PORTAL_COOLDOWN_NANOS);
|
||||
player.teleport(destination, PlayerTeleportEvent.TeleportCause.PLUGIN);
|
||||
private void teleport(Entity entity, Location destination) {
|
||||
cooldownUntil.put(entity.getUniqueId(), System.nanoTime() + PORTAL_COOLDOWN_NANOS);
|
||||
entity.setFallDistance(0.0F);
|
||||
entity.teleport(destination, PlayerTeleportEvent.TeleportCause.PLUGIN);
|
||||
}
|
||||
|
||||
private boolean onCooldown(Player player) {
|
||||
return cooldownUntil.getOrDefault(player.getUniqueId(), 0L) > System.nanoTime();
|
||||
private boolean onCooldown(Entity entity) {
|
||||
return cooldownUntil.getOrDefault(entity.getUniqueId(), 0L) > System.nanoTime();
|
||||
}
|
||||
|
||||
private void validateEntrances() {
|
||||
@@ -273,18 +367,26 @@ final class PocketBaseController implements Listener, Runnable {
|
||||
}
|
||||
|
||||
private boolean isIntact(PocketPortalLocation portal) {
|
||||
World world = server.getWorld(portal.worldId());
|
||||
if (world == null) {
|
||||
return false;
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
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());
|
||||
if (world == null || !isIntact(portal)) {
|
||||
if (world == null || !isIntact(portal, frame)) {
|
||||
return;
|
||||
}
|
||||
for (BlockPosition position : PocketPortalGeometry.interiorBlocks(portal)) {
|
||||
@@ -302,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) {
|
||||
try {
|
||||
pocketBases.deactivateEntrance(ownerId);
|
||||
@@ -320,6 +430,37 @@ final class PocketBaseController implements Listener, Runnable {
|
||||
: new Location(world, portal.x() + 1.5, portal.y() + 1.0, portal.z() + 2.0);
|
||||
}
|
||||
|
||||
private static BoundingBox portalBounds(PocketPortalLocation portal) {
|
||||
return portal.axis() == PocketPortalAxis.X
|
||||
? new BoundingBox(
|
||||
portal.x() + 1.0,
|
||||
portal.y() + 1.0,
|
||||
portal.z(),
|
||||
portal.x() + 3.0,
|
||||
portal.y() + 4.0,
|
||||
portal.z() + 1.0
|
||||
)
|
||||
: new BoundingBox(
|
||||
portal.x(),
|
||||
portal.y() + 1.0,
|
||||
portal.z() + 1.0,
|
||||
portal.x() + 1.0,
|
||||
portal.y() + 4.0,
|
||||
portal.z() + 3.0
|
||||
);
|
||||
}
|
||||
|
||||
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) {
|
||||
return material == Material.AIR || material == Material.CAVE_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(
|
||||
UUID ownerId,
|
||||
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)
|
||||
throws IOException {
|
||||
PocketBaseState current = states.state(ownerId);
|
||||
|
||||
@@ -10,7 +10,10 @@ public record PocketBaseState(
|
||||
Optional<PocketPortalLocation> returnPortal,
|
||||
boolean hostileMobSpawningEnabled,
|
||||
boolean passiveMobSpawningEnabled,
|
||||
PocketBaseBiome biome
|
||||
PocketBaseBiome biome,
|
||||
boolean flightUnlocked,
|
||||
Optional<PocketPortalLocation> flightPortal,
|
||||
boolean flightEnabled
|
||||
) {
|
||||
public PocketBaseState {
|
||||
if (ownerId == null) {
|
||||
@@ -21,10 +24,49 @@ public record PocketBaseState(
|
||||
}
|
||||
entrance = entrance == null ? Optional.empty() : entrance;
|
||||
returnPortal = returnPortal == null ? Optional.empty() : returnPortal;
|
||||
flightPortal = flightPortal == null ? Optional.empty() : flightPortal;
|
||||
biome = biome == null ? PocketBaseBiome.THE_VOID : biome;
|
||||
if (level == 0 && (entrance.isPresent() || returnPortal.isPresent())) {
|
||||
throw new IllegalArgumentException("a locked Pocket Base cannot have a portal");
|
||||
if (level == 0 && (entrance.isPresent() || returnPortal.isPresent()
|
||||
|| 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(
|
||||
@@ -34,63 +76,88 @@ public record PocketBaseState(
|
||||
) {
|
||||
this(
|
||||
ownerId, level, entrance, Optional.empty(), false, false,
|
||||
PocketBaseBiome.THE_VOID
|
||||
PocketBaseBiome.THE_VOID, false, Optional.empty(), false
|
||||
);
|
||||
}
|
||||
|
||||
public static PocketBaseState locked(UUID ownerId) {
|
||||
return new PocketBaseState(
|
||||
ownerId, 0, Optional.empty(), Optional.empty(), false, false,
|
||||
PocketBaseBiome.THE_VOID
|
||||
PocketBaseBiome.THE_VOID, false, Optional.empty(), false
|
||||
);
|
||||
}
|
||||
|
||||
public PocketBaseState withLevel(int newLevel) {
|
||||
return new PocketBaseState(
|
||||
ownerId, newLevel, entrance, returnPortal, hostileMobSpawningEnabled,
|
||||
passiveMobSpawningEnabled, biome
|
||||
);
|
||||
return copy(newLevel, entrance, returnPortal, hostileMobSpawningEnabled,
|
||||
passiveMobSpawningEnabled, biome, flightUnlocked, flightPortal, flightEnabled);
|
||||
}
|
||||
|
||||
public PocketBaseState withEntrance(PocketPortalLocation portal) {
|
||||
return new PocketBaseState(
|
||||
ownerId, level, Optional.of(portal), returnPortal,
|
||||
hostileMobSpawningEnabled, passiveMobSpawningEnabled, biome
|
||||
);
|
||||
return copy(level, Optional.of(portal), returnPortal, hostileMobSpawningEnabled,
|
||||
passiveMobSpawningEnabled, biome, flightUnlocked, flightPortal, flightEnabled);
|
||||
}
|
||||
|
||||
public PocketBaseState withoutEntrance() {
|
||||
return new PocketBaseState(
|
||||
ownerId, level, Optional.empty(), returnPortal, hostileMobSpawningEnabled,
|
||||
passiveMobSpawningEnabled, biome
|
||||
);
|
||||
return copy(level, Optional.empty(), returnPortal, hostileMobSpawningEnabled,
|
||||
passiveMobSpawningEnabled, biome, flightUnlocked, flightPortal, flightEnabled);
|
||||
}
|
||||
|
||||
public PocketBaseState withReturnPortal(PocketPortalLocation portal) {
|
||||
return new PocketBaseState(
|
||||
ownerId, level, entrance, Optional.of(portal), hostileMobSpawningEnabled,
|
||||
passiveMobSpawningEnabled, biome
|
||||
);
|
||||
return copy(level, entrance, Optional.of(portal), hostileMobSpawningEnabled,
|
||||
passiveMobSpawningEnabled, biome, flightUnlocked, flightPortal, flightEnabled);
|
||||
}
|
||||
|
||||
public PocketBaseState withHostileMobSpawningEnabled(boolean enabled) {
|
||||
return new PocketBaseState(
|
||||
ownerId, level, entrance, returnPortal, enabled,
|
||||
passiveMobSpawningEnabled, biome
|
||||
);
|
||||
return copy(level, entrance, returnPortal, enabled, passiveMobSpawningEnabled,
|
||||
biome, flightUnlocked, flightPortal, flightEnabled);
|
||||
}
|
||||
|
||||
public PocketBaseState withPassiveMobSpawningEnabled(boolean enabled) {
|
||||
return new PocketBaseState(
|
||||
ownerId, level, entrance, returnPortal, hostileMobSpawningEnabled,
|
||||
enabled, biome
|
||||
);
|
||||
return copy(level, entrance, returnPortal, hostileMobSpawningEnabled, enabled,
|
||||
biome, flightUnlocked, flightPortal, flightEnabled);
|
||||
}
|
||||
|
||||
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(
|
||||
ownerId, level, entrance, returnPortal, hostileMobSpawningEnabled,
|
||||
passiveMobSpawningEnabled, selectedBiome
|
||||
ownerId, newLevel, newEntrance, newReturnPortal, hostileEnabled, passiveEnabled,
|
||||
selectedBiome, unlockedFlight, newFlightPortal, enabledFlight
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,7 +137,7 @@ final class PocketBaseWorldService {
|
||||
World world = server.getWorld(name);
|
||||
boolean created = world == null;
|
||||
if (created) {
|
||||
WorldCreator creator = new WorldCreator(name)
|
||||
WorldCreator creator = new WorldCreator(NamespacedKey.minecraft(name))
|
||||
.environment(World.Environment.NORMAL)
|
||||
.generator(new VoidPocketChunkGenerator())
|
||||
.generateStructures(false);
|
||||
|
||||
@@ -5,6 +5,7 @@ import java.time.Clock;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.logging.Level;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.command.PluginCommand;
|
||||
import org.bukkit.plugin.java.JavaPlugin;
|
||||
|
||||
@@ -16,6 +17,7 @@ public final class SpigotBasePlugin extends JavaPlugin {
|
||||
private BaseTeleportManager teleportManager;
|
||||
private PocketBaseManager pocketBaseManager;
|
||||
private PocketBaseController pocketBaseController;
|
||||
private PocketBaseKeystoneService keystoneService;
|
||||
|
||||
@Override
|
||||
public void onEnable() {
|
||||
@@ -40,6 +42,17 @@ public final class SpigotBasePlugin extends JavaPlugin {
|
||||
settingsProvider
|
||||
);
|
||||
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) {
|
||||
getLogger().log(Level.SEVERE, "Could not initialize Spigot Base", exception);
|
||||
getServer().getPluginManager().disablePlugin(this);
|
||||
@@ -64,7 +77,8 @@ public final class SpigotBasePlugin extends JavaPlugin {
|
||||
settingsProvider
|
||||
);
|
||||
flightController = new BaseFlightController(
|
||||
getServer(), stateManager, secondaryProgressionService, boundsService, settingsProvider
|
||||
getServer(), stateManager, secondaryProgressionService, boundsService,
|
||||
settingsProvider, pocketBaseManager, getLogger()
|
||||
);
|
||||
VisitorPolicy visitorPolicy = new VisitorPolicy();
|
||||
teleportManager = new BaseTeleportManager(
|
||||
@@ -81,11 +95,14 @@ public final class SpigotBasePlugin extends JavaPlugin {
|
||||
boundsService,
|
||||
pocketBaseManager,
|
||||
settingsProvider,
|
||||
getLogger()
|
||||
getLogger(),
|
||||
flightController
|
||||
);
|
||||
getServer().getPluginManager().registerEvents(progressListener, this);
|
||||
getServer().getPluginManager().registerEvents(flightController, this);
|
||||
getServer().getPluginManager().registerEvents(teleportManager, this);
|
||||
getServer().getPluginManager().registerEvents(pocketBaseController, this);
|
||||
getServer().getPluginManager().registerEvents(keystoneService, this);
|
||||
|
||||
command("setbase").setExecutor(new SetBaseCommand(
|
||||
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);
|
||||
String path = "owners." + key;
|
||||
int level = yaml.getInt(path + ".level", 0);
|
||||
boolean flightUnlocked = yaml.getBoolean(
|
||||
path + ".flight-unlocked", false
|
||||
);
|
||||
PocketBaseState state = new PocketBaseState(
|
||||
ownerId,
|
||||
level,
|
||||
@@ -46,7 +49,10 @@ public final class YamlPocketBaseRepository {
|
||||
loadPortal(yaml, path + ".return-portal"),
|
||||
loadMobPreference(yaml, path, "hostile-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);
|
||||
} catch (IllegalArgumentException ignored) {
|
||||
@@ -73,12 +79,17 @@ public final class YamlPocketBaseRepository {
|
||||
path + ".passive-mob-spawning-enabled",
|
||||
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 + ".biome", state.biome().commandName());
|
||||
state.entrance().ifPresent(portal -> savePortal(yaml, path + ".entrance", portal));
|
||||
state.returnPortal().ifPresent(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");
|
||||
try {
|
||||
|
||||
@@ -12,11 +12,14 @@ import static org.mockito.Mockito.when;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.logging.Logger;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.raid.RaidFinishEvent;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.PlayerInventory;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
@@ -47,6 +50,7 @@ final class BaseFlightControllerTest {
|
||||
PlayerInventory inventory = mock(PlayerInventory.class);
|
||||
|
||||
doReturn(List.of(player)).when(server).getOnlinePlayers();
|
||||
when(server.getPlayer(playerId)).thenReturn(player);
|
||||
when(player.getUniqueId()).thenReturn(playerId);
|
||||
when(player.getName()).thenReturn("Alex");
|
||||
when(player.getGameMode()).thenReturn(org.bukkit.GameMode.SURVIVAL);
|
||||
@@ -62,6 +66,353 @@ final class BaseFlightControllerTest {
|
||||
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
|
||||
void walkingInWarningBufferDoesNotShowLeavingBaseWarning() {
|
||||
when(player.isFlying()).thenReturn(false);
|
||||
|
||||
@@ -15,6 +15,7 @@ import java.io.IOException;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.function.UnaryOperator;
|
||||
@@ -24,7 +25,7 @@ import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.PlayerInventory;
|
||||
import net.md_5.bungee.api.dialog.Dialog;
|
||||
import net.kyori.adventure.dialog.DialogLike;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
@@ -112,6 +113,16 @@ final class BaseSettingsCommandTest {
|
||||
List.of("upgrade"),
|
||||
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(
|
||||
List.of("mobs"),
|
||||
command.onTabComplete(null, null, "basesettings", new String[] {"pocket", "m"})
|
||||
@@ -186,10 +197,12 @@ final class BaseSettingsCommandTest {
|
||||
when(player.getUniqueId()).thenReturn(playerId);
|
||||
when(player.getName()).thenReturn("Host");
|
||||
PlayerInventory inventory = mock(PlayerInventory.class);
|
||||
ItemStack firstStack = item(Material.DIAMOND, 64);
|
||||
ItemStack secondStack = item(Material.DIAMOND, 64);
|
||||
when(player.getInventory()).thenReturn(inventory);
|
||||
when(inventory.getStorageContents()).thenReturn(new ItemStack[] {
|
||||
new ItemStack(Material.DIAMOND, 64),
|
||||
new ItemStack(Material.DIAMOND, 64)
|
||||
firstStack,
|
||||
secondStack
|
||||
});
|
||||
|
||||
PlayerState current = PlayerState.newPlayer(playerId, "Host")
|
||||
@@ -227,10 +240,9 @@ final class BaseSettingsCommandTest {
|
||||
when(player.getUniqueId()).thenReturn(playerId);
|
||||
when(player.getName()).thenReturn("Builder");
|
||||
PlayerInventory inventory = mock(PlayerInventory.class);
|
||||
ItemStack currency = item(Material.DIAMOND_BLOCK, 64);
|
||||
when(player.getInventory()).thenReturn(inventory);
|
||||
when(inventory.getStorageContents()).thenReturn(new ItemStack[] {
|
||||
new ItemStack(Material.DIAMOND_BLOCK, 64)
|
||||
});
|
||||
when(inventory.getStorageContents()).thenReturn(new ItemStack[] {currency});
|
||||
PlayerState owner = PlayerState.newPlayer(playerId, "Builder")
|
||||
.withAdministrativeLevels(4, 0, 0, 0, 0, false, false, true)
|
||||
.withBase(
|
||||
@@ -264,10 +276,9 @@ final class BaseSettingsCommandTest {
|
||||
when(player.getUniqueId()).thenReturn(playerId);
|
||||
when(player.getName()).thenReturn("Builder");
|
||||
PlayerInventory inventory = mock(PlayerInventory.class);
|
||||
ItemStack currency = item(Material.NETHERITE_BLOCK, 16);
|
||||
when(player.getInventory()).thenReturn(inventory);
|
||||
when(inventory.getStorageContents()).thenReturn(new ItemStack[] {
|
||||
new ItemStack(Material.NETHERITE_BLOCK, 16)
|
||||
});
|
||||
when(inventory.getStorageContents()).thenReturn(new ItemStack[] {currency});
|
||||
BaseStateManager stateManager = mock(BaseStateManager.class);
|
||||
when(stateManager.player(playerId, "Builder"))
|
||||
.thenReturn(PlayerState.newPlayer(playerId, "Builder"));
|
||||
@@ -429,10 +440,9 @@ final class BaseSettingsCommandTest {
|
||||
when(player.getUniqueId()).thenReturn(playerId);
|
||||
when(player.getName()).thenReturn("Builder");
|
||||
PlayerInventory inventory = mock(PlayerInventory.class);
|
||||
ItemStack currency = item(Material.DIAMOND_BLOCK, 64);
|
||||
when(player.getInventory()).thenReturn(inventory);
|
||||
when(inventory.getStorageContents()).thenReturn(new ItemStack[] {
|
||||
new ItemStack(Material.DIAMOND_BLOCK, 64)
|
||||
});
|
||||
when(inventory.getStorageContents()).thenReturn(new ItemStack[] {currency});
|
||||
PlayerState owner = PlayerState.newPlayer(playerId, "Builder")
|
||||
.withAdministrativeLevels(4, 0, 0, 0, 0, false, false, true)
|
||||
.withBase(
|
||||
@@ -460,6 +470,41 @@ final class BaseSettingsCommandTest {
|
||||
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
|
||||
void statusDisplaysPocketMobSpawningPreferences() {
|
||||
UUID playerId = UUID.randomUUID();
|
||||
@@ -490,6 +535,7 @@ final class BaseSettingsCommandTest {
|
||||
&& message.contains("type=void/the_void")
|
||||
&& message.contains("hostile spawning=enabled")
|
||||
&& message.contains("passive spawning=disabled")
|
||||
&& message.contains("flight=complete a raid to unlock")
|
||||
));
|
||||
}
|
||||
|
||||
@@ -554,14 +600,16 @@ final class BaseSettingsCommandTest {
|
||||
BaseSettingsCommand command = new BaseSettingsCommand(
|
||||
stateManager,
|
||||
new PluginSettingsProvider(PluginSettings.from(Map.of())),
|
||||
mock(BaseFlightController.class)
|
||||
mock(BaseFlightController.class),
|
||||
null,
|
||||
(owner, pocket) -> null
|
||||
);
|
||||
|
||||
command.onCommand(player, null, "basesettings", new String[0]);
|
||||
command.onCommand(player, null, "homesettings", new String[] {"ui"});
|
||||
command.onCommand(player, null, "basesettings", new String[] {"status"});
|
||||
|
||||
verify(player, times(2)).showDialog(any(Dialog.class));
|
||||
verify(player, times(2)).showDialog((DialogLike) null);
|
||||
verify(player).sendMessage(ChatColor.GOLD + "=== Base Progress ===");
|
||||
verify(player).sendMessage(
|
||||
org.mockito.ArgumentMatchers.<String>argThat(message ->
|
||||
@@ -582,6 +630,18 @@ final class BaseSettingsCommandTest {
|
||||
);
|
||||
}
|
||||
|
||||
private static ItemStack item(Material material, int amount) {
|
||||
ItemStack item = mock(ItemStack.class);
|
||||
ItemStack copy = mock(ItemStack.class);
|
||||
when(item.getType()).thenReturn(material);
|
||||
when(item.getAmount()).thenReturn(amount);
|
||||
when(item.clone()).thenReturn(copy);
|
||||
when(copy.getType()).thenReturn(material);
|
||||
when(copy.getAmount()).thenReturn(amount);
|
||||
when(copy.clone()).thenReturn(copy);
|
||||
return item;
|
||||
}
|
||||
|
||||
private static CommandResult execute(PlayerState current, String... arguments) {
|
||||
Player player = mock(Player.class);
|
||||
when(player.getUniqueId()).thenReturn(current.playerId());
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package games.dmg.spigotbase;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
@@ -9,17 +10,33 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import net.md_5.bungee.api.dialog.ConfirmationDialog;
|
||||
import net.md_5.bungee.api.dialog.Dialog;
|
||||
import net.md_5.bungee.api.dialog.DialogListDialog;
|
||||
import net.md_5.bungee.api.dialog.MultiActionDialog;
|
||||
import net.md_5.bungee.api.dialog.NoticeDialog;
|
||||
import net.md_5.bungee.api.dialog.action.Action;
|
||||
import net.md_5.bungee.api.dialog.action.ActionButton;
|
||||
import net.md_5.bungee.api.dialog.action.RunCommandAction;
|
||||
import net.kyori.adventure.text.event.ClickEvent;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
final class BaseSettingsDialogFactoryTest {
|
||||
@Test
|
||||
void createsStaticRunCommandActionWithoutMacroVariables() {
|
||||
ClickEvent<ClickEvent.Payload.Text> action = BaseSettingsDialogFactory.commandClick(
|
||||
"/basesettings status"
|
||||
);
|
||||
|
||||
assertEquals(ClickEvent.Action.RUN_COMMAND, action.action());
|
||||
ClickEvent.Payload.Text payload = assertInstanceOf(
|
||||
ClickEvent.Payload.Text.class,
|
||||
action.payload()
|
||||
);
|
||||
assertEquals("/basesettings status", payload.value());
|
||||
}
|
||||
|
||||
@Test
|
||||
void createsBackButtonThatReturnsToMainSettingsDashboard() {
|
||||
BaseSettingsDialogFactory.ButtonSpec back =
|
||||
BaseSettingsDialogFactory.backButtonSpec();
|
||||
|
||||
assertEquals("Back", back.label());
|
||||
assertEquals("/basesettings ui", back.command());
|
||||
}
|
||||
|
||||
@Test
|
||||
void createsControlsForEveryBaseSettingAndConfirmedPurchase() {
|
||||
UUID playerId = UUID.randomUUID();
|
||||
@@ -39,15 +56,26 @@ final class BaseSettingsDialogFactoryTest {
|
||||
Optional.empty(),
|
||||
true,
|
||||
false,
|
||||
PocketBaseBiome.PLAINS
|
||||
PocketBaseBiome.PLAINS,
|
||||
true
|
||||
);
|
||||
|
||||
Dialog dialog = new BaseSettingsDialogFactory(
|
||||
PluginSettings.from(Map.of())
|
||||
).create(owner, pocket);
|
||||
BaseSettingsDialogFactory.DialogSpec specification =
|
||||
new BaseSettingsDialogFactory(PluginSettings.from(Map.of()))
|
||||
.specification(owner, pocket);
|
||||
|
||||
DialogListDialog root = assertInstanceOf(DialogListDialog.class, dialog);
|
||||
assertTrue(root.getBase().body().get(0).toString().contains("Base 4/4"));
|
||||
BaseSettingsDialogFactory.ListSpec root = assertInstanceOf(
|
||||
BaseSettingsDialogFactory.ListSpec.class,
|
||||
specification
|
||||
);
|
||||
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);
|
||||
assertTrue(commands.contains("/basesettings status"));
|
||||
assertTrue(commands.contains("/basesettings visitors blocked"));
|
||||
@@ -57,6 +85,7 @@ final class BaseSettingsDialogFactoryTest {
|
||||
assertTrue(commands.contains("/basesettings spawnable disable"));
|
||||
assertTrue(commands.contains("/basesettings bossbar disable"));
|
||||
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 passive enable"));
|
||||
assertTrue(commands.contains("/basesettings pocket type nether crimson_forest"));
|
||||
@@ -72,23 +101,19 @@ final class BaseSettingsDialogFactoryTest {
|
||||
Instant.EPOCH
|
||||
);
|
||||
|
||||
DialogListDialog root = assertInstanceOf(
|
||||
DialogListDialog.class,
|
||||
new BaseSettingsDialogFactory(PluginSettings.from(Map.of())).create(
|
||||
BaseSettingsDialogFactory.ListSpec root = assertInstanceOf(
|
||||
BaseSettingsDialogFactory.ListSpec.class,
|
||||
new BaseSettingsDialogFactory(PluginSettings.from(Map.of())).specification(
|
||||
owner,
|
||||
PocketBaseState.locked(playerId)
|
||||
)
|
||||
);
|
||||
ConfirmationDialog upgrade = assertInstanceOf(
|
||||
ConfirmationDialog.class,
|
||||
BaseSettingsDialogFactory.ConfirmationSpec upgrade = assertInstanceOf(
|
||||
BaseSettingsDialogFactory.ConfirmationSpec.class,
|
||||
root.dialogs().get(2)
|
||||
);
|
||||
|
||||
RunCommandAction action = assertInstanceOf(
|
||||
RunCommandAction.class,
|
||||
upgrade.yes().action()
|
||||
);
|
||||
assertTrue(action.template().equals("/basesettings upgrade"));
|
||||
assertEquals("/basesettings upgrade", upgrade.confirm().command());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -96,20 +121,19 @@ final class BaseSettingsDialogFactoryTest {
|
||||
UUID playerId = UUID.randomUUID();
|
||||
PlayerState owner = PlayerState.newPlayer(playerId, "Newcomer");
|
||||
|
||||
DialogListDialog root = assertInstanceOf(
|
||||
DialogListDialog.class,
|
||||
new BaseSettingsDialogFactory(PluginSettings.from(Map.of())).create(
|
||||
BaseSettingsDialogFactory.ListSpec root = assertInstanceOf(
|
||||
BaseSettingsDialogFactory.ListSpec.class,
|
||||
new BaseSettingsDialogFactory(PluginSettings.from(Map.of())).specification(
|
||||
owner,
|
||||
PocketBaseState.locked(playerId)
|
||||
)
|
||||
);
|
||||
MultiActionDialog controls = assertInstanceOf(
|
||||
MultiActionDialog.class,
|
||||
BaseSettingsDialogFactory.MultiSpec controls = assertInstanceOf(
|
||||
BaseSettingsDialogFactory.MultiSpec.class,
|
||||
root.dialogs().get(0)
|
||||
);
|
||||
List<String> labels = controls.actions().stream()
|
||||
.map(ActionButton::label)
|
||||
.map(Object::toString)
|
||||
.map(BaseSettingsDialogFactory.ButtonSpec::label)
|
||||
.toList();
|
||||
|
||||
assertTrue(labels.stream().anyMatch(label -> label.contains("Visitors: Locked")));
|
||||
@@ -119,34 +143,28 @@ final class BaseSettingsDialogFactoryTest {
|
||||
assertTrue(labels.stream().anyMatch(label -> label.contains("Spawnable Overlay: Locked")));
|
||||
}
|
||||
|
||||
private static List<String> commands(Dialog dialog) {
|
||||
private static List<String> commands(
|
||||
BaseSettingsDialogFactory.DialogSpec specification
|
||||
) {
|
||||
List<String> commands = new ArrayList<>();
|
||||
collect(dialog, commands);
|
||||
collect(specification, commands);
|
||||
return commands;
|
||||
}
|
||||
|
||||
private static void collect(Dialog dialog, List<String> commands) {
|
||||
if (dialog instanceof DialogListDialog list) {
|
||||
private static void collect(
|
||||
BaseSettingsDialogFactory.DialogSpec specification,
|
||||
List<String> commands
|
||||
) {
|
||||
if (specification instanceof BaseSettingsDialogFactory.ListSpec list) {
|
||||
list.dialogs().forEach(child -> collect(child, commands));
|
||||
collect(list.exitAction(), commands);
|
||||
} else if (dialog instanceof MultiActionDialog actions) {
|
||||
actions.actions().forEach(button -> collect(button, commands));
|
||||
collect(actions.exitAction(), commands);
|
||||
} else if (dialog instanceof ConfirmationDialog confirmation) {
|
||||
collect(confirmation.yes(), commands);
|
||||
collect(confirmation.no(), commands);
|
||||
} else if (dialog instanceof NoticeDialog notice) {
|
||||
collect(notice.action(), commands);
|
||||
}
|
||||
}
|
||||
|
||||
private static void collect(ActionButton button, List<String> commands) {
|
||||
if (button == null) {
|
||||
return;
|
||||
}
|
||||
Action action = button.action();
|
||||
if (action instanceof RunCommandAction command) {
|
||||
commands.add(command.template());
|
||||
} else if (specification instanceof BaseSettingsDialogFactory.MultiSpec multi) {
|
||||
multi.actions().forEach(button -> commands.add(button.command()));
|
||||
} else if (specification
|
||||
instanceof BaseSettingsDialogFactory.ConfirmationSpec confirmation) {
|
||||
commands.add(confirmation.confirm().command());
|
||||
} else if (specification instanceof BaseSettingsDialogFactory.NoticeSpec notice
|
||||
&& notice.action() != null) {
|
||||
commands.add(notice.action().command());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,82 @@ import org.bukkit.plugin.Plugin;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
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
|
||||
void visitFromAnotherWorldIsRejectedBeforeWarmupAndCooldownChecks() {
|
||||
UUID visitorId = UUID.randomUUID();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package games.dmg.spigotbase;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
@@ -8,20 +9,28 @@ import static org.mockito.Mockito.when;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.logging.Logger;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.block.Block;
|
||||
import org.bukkit.entity.Entity;
|
||||
import org.bukkit.entity.Mob;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.block.Action;
|
||||
import org.bukkit.event.block.BlockBreakEvent;
|
||||
import org.bukkit.event.block.BlockPlaceEvent;
|
||||
import org.bukkit.event.player.PlayerInteractEvent;
|
||||
import org.bukkit.event.player.PlayerMoveEvent;
|
||||
import org.bukkit.event.player.PlayerTeleportEvent;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.util.BoundingBox;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
final class PocketBaseControllerTest {
|
||||
@@ -37,6 +46,7 @@ final class PocketBaseControllerTest {
|
||||
World world = mock(World.class);
|
||||
Block clicked = block(world, -2, 66, 0, Material.DIAMOND_BLOCK);
|
||||
PlayerInteractEvent event = mock(PlayerInteractEvent.class);
|
||||
ItemStack activator = item(Material.FLINT_AND_STEEL);
|
||||
BaseStateManager baseStates = mock(BaseStateManager.class);
|
||||
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
|
||||
PluginSettings settings = PluginSettings.from(Map.of());
|
||||
@@ -49,7 +59,7 @@ final class PocketBaseControllerTest {
|
||||
when(event.getPlayer()).thenReturn(owner);
|
||||
when(event.getAction()).thenReturn(Action.RIGHT_CLICK_BLOCK);
|
||||
when(event.getClickedBlock()).thenReturn(clicked);
|
||||
when(event.getItem()).thenReturn(new ItemStack(Material.FLINT_AND_STEEL));
|
||||
when(event.getItem()).thenReturn(activator);
|
||||
when(world.getUID()).thenReturn(worldId);
|
||||
when(world.getName()).thenReturn("world");
|
||||
when(world.getBlockAt(anyInt(), anyInt(), anyInt())).thenAnswer(invocation -> {
|
||||
@@ -98,6 +108,7 @@ final class PocketBaseControllerTest {
|
||||
World world = mock(World.class);
|
||||
Block clicked = block(world, 8, 67, 8, Material.DIAMOND_BLOCK);
|
||||
PlayerInteractEvent event = mock(PlayerInteractEvent.class);
|
||||
ItemStack activator = item(Material.FLINT_AND_STEEL);
|
||||
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
|
||||
PluginSettings settings = PluginSettings.from(Map.of());
|
||||
|
||||
@@ -105,7 +116,7 @@ final class PocketBaseControllerTest {
|
||||
when(event.getPlayer()).thenReturn(owner);
|
||||
when(event.getAction()).thenReturn(Action.RIGHT_CLICK_BLOCK);
|
||||
when(event.getClickedBlock()).thenReturn(clicked);
|
||||
when(event.getItem()).thenReturn(new ItemStack(Material.FLINT_AND_STEEL));
|
||||
when(event.getItem()).thenReturn(activator);
|
||||
when(world.getUID()).thenReturn(worldId);
|
||||
when(world.getName()).thenReturn("pocket");
|
||||
when(world.getBlockAt(anyInt(), anyInt(), anyInt())).thenAnswer(invocation -> {
|
||||
@@ -140,6 +151,62 @@ final class PocketBaseControllerTest {
|
||||
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
|
||||
void returnFrameOutsideUnlockedPocketBoundaryIsRejected() throws Exception {
|
||||
UUID ownerId = UUID.randomUUID();
|
||||
@@ -152,6 +219,7 @@ final class PocketBaseControllerTest {
|
||||
World world = mock(World.class);
|
||||
Block clicked = block(world, 30, 67, 8, Material.DIAMOND_BLOCK);
|
||||
PlayerInteractEvent event = mock(PlayerInteractEvent.class);
|
||||
ItemStack activator = item(Material.FLINT_AND_STEEL);
|
||||
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
|
||||
PluginSettings settings = PluginSettings.from(Map.of());
|
||||
|
||||
@@ -159,7 +227,7 @@ final class PocketBaseControllerTest {
|
||||
when(event.getPlayer()).thenReturn(owner);
|
||||
when(event.getAction()).thenReturn(Action.RIGHT_CLICK_BLOCK);
|
||||
when(event.getClickedBlock()).thenReturn(clicked);
|
||||
when(event.getItem()).thenReturn(new ItemStack(Material.FLINT_AND_STEEL));
|
||||
when(event.getItem()).thenReturn(activator);
|
||||
when(world.getUID()).thenReturn(worldId);
|
||||
when(world.getName()).thenReturn("pocket");
|
||||
when(world.getBlockAt(anyInt(), anyInt(), anyInt())).thenAnswer(invocation -> {
|
||||
@@ -194,6 +262,39 @@ final class PocketBaseControllerTest {
|
||||
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
|
||||
void placementOutsideUnlockedPocketBoundaryIsCancelled() {
|
||||
UUID ownerId = UUID.randomUUID();
|
||||
@@ -227,6 +328,254 @@ final class PocketBaseControllerTest {
|
||||
verify(event).setCancelled(true);
|
||||
}
|
||||
|
||||
@Test
|
||||
void activeEntranceTransportsMobsOnceButNotOtherEntities() {
|
||||
UUID ownerId = UUID.randomUUID();
|
||||
UUID worldId = UUID.randomUUID();
|
||||
PocketPortalLocation portal = new PocketPortalLocation(
|
||||
worldId, "world", -2, 64, 0, PocketPortalAxis.X
|
||||
);
|
||||
PocketBaseState state = new PocketBaseState(ownerId, 1, Optional.of(portal));
|
||||
Server server = mock(Server.class);
|
||||
World world = intactPortalWorld(worldId, portal);
|
||||
Mob mob = mock(Mob.class);
|
||||
Entity item = mock(Entity.class);
|
||||
Location arrival = new Location(mock(World.class), 0.5, 65.0, 5.5);
|
||||
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
|
||||
|
||||
when(mob.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
when(item.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
when(world.getNearbyEntities(any(BoundingBox.class), any()))
|
||||
.thenReturn(List.of(mob, item));
|
||||
when(server.getWorld(worldId)).thenReturn(world);
|
||||
when(pocketBases.knownStates()).thenReturn(Map.of(ownerId, state));
|
||||
when(pocketBases.pocketArrival(ownerId)).thenReturn(arrival);
|
||||
PocketBaseController controller = controller(server, pocketBases);
|
||||
|
||||
controller.scanMobPortals();
|
||||
controller.scanMobPortals();
|
||||
|
||||
verify(mob).teleport(arrival, PlayerTeleportEvent.TeleportCause.PLUGIN);
|
||||
verify(item, never()).teleport(
|
||||
any(Location.class),
|
||||
any(PlayerTeleportEvent.TeleportCause.class)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void activeReturnPortalTransportsMobOutsideIntactEntrance() {
|
||||
UUID ownerId = UUID.randomUUID();
|
||||
UUID normalWorldId = UUID.randomUUID();
|
||||
UUID pocketWorldId = UUID.randomUUID();
|
||||
PocketPortalLocation entrance = new PocketPortalLocation(
|
||||
normalWorldId, "world", -2, 64, 0, PocketPortalAxis.X
|
||||
);
|
||||
PocketPortalLocation returnPortal = new PocketPortalLocation(
|
||||
pocketWorldId, "pocket", 8, 65, 8, PocketPortalAxis.Z
|
||||
);
|
||||
PocketBaseState state = new PocketBaseState(ownerId, 1, Optional.of(entrance));
|
||||
Server server = mock(Server.class);
|
||||
World normalWorld = intactPortalWorld(normalWorldId, entrance);
|
||||
World pocketWorld = intactPortalWorld(pocketWorldId, returnPortal);
|
||||
Mob mob = mock(Mob.class);
|
||||
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
|
||||
|
||||
when(mob.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
when(normalWorld.getNearbyEntities(any(BoundingBox.class), any()))
|
||||
.thenReturn(List.of());
|
||||
when(pocketWorld.getNearbyEntities(any(BoundingBox.class), any()))
|
||||
.thenReturn(List.of(mob));
|
||||
when(server.getWorld(normalWorldId)).thenReturn(normalWorld);
|
||||
when(server.getWorld(pocketWorldId)).thenReturn(pocketWorld);
|
||||
when(pocketBases.knownStates()).thenReturn(Map.of(ownerId, state));
|
||||
when(pocketBases.state(ownerId)).thenReturn(state);
|
||||
when(pocketBases.returnPortalIsIntact(ownerId)).thenReturn(true);
|
||||
when(pocketBases.returnPortal(ownerId)).thenReturn(returnPortal);
|
||||
PocketBaseController controller = controller(server, pocketBases);
|
||||
|
||||
controller.scanMobPortals();
|
||||
|
||||
verify(mob).teleport(
|
||||
new Location(normalWorld, 0.0, 65.0, 1.5),
|
||||
PlayerTeleportEvent.TeleportCause.PLUGIN
|
||||
);
|
||||
}
|
||||
|
||||
@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
|
||||
void voidReturnClearsAccumulatedPlayerFallDistance() {
|
||||
UUID ownerId = UUID.randomUUID();
|
||||
UUID pocketWorldId = UUID.randomUUID();
|
||||
World pocketWorld = mock(World.class);
|
||||
World fallbackWorld = mock(World.class);
|
||||
Location destination = new Location(pocketWorld, 0.5, -65.0, 0.5);
|
||||
Location spawn = new Location(fallbackWorld, 10.5, 70.0, 10.5);
|
||||
Player player = mock(Player.class);
|
||||
PlayerMoveEvent event = mock(PlayerMoveEvent.class);
|
||||
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
|
||||
Server server = mock(Server.class);
|
||||
|
||||
when(pocketWorld.getUID()).thenReturn(pocketWorldId);
|
||||
when(player.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
when(event.getPlayer()).thenReturn(player);
|
||||
when(event.getTo()).thenReturn(destination);
|
||||
when(server.getWorlds()).thenReturn(List.of(fallbackWorld));
|
||||
when(fallbackWorld.getSpawnLocation()).thenReturn(spawn);
|
||||
when(pocketBases.ownerForPocketWorld(pocketWorldId)).thenReturn(Optional.of(ownerId));
|
||||
when(pocketBases.state(ownerId)).thenReturn(
|
||||
new PocketBaseState(ownerId, 1, Optional.empty())
|
||||
);
|
||||
PocketBaseController controller = controller(server, pocketBases);
|
||||
|
||||
controller.onMove(event);
|
||||
|
||||
verify(player).setFallDistance(0.0F);
|
||||
verify(player).teleport(spawn, PlayerTeleportEvent.TeleportCause.PLUGIN);
|
||||
}
|
||||
|
||||
@Test
|
||||
void mobReturnFallsBackToServerSpawnWithoutValidEntrance() {
|
||||
UUID ownerId = UUID.randomUUID();
|
||||
UUID pocketWorldId = UUID.randomUUID();
|
||||
PocketPortalLocation returnPortal = new PocketPortalLocation(
|
||||
pocketWorldId, "pocket", 8, 65, 8, PocketPortalAxis.Z
|
||||
);
|
||||
PocketBaseState state = new PocketBaseState(ownerId, 1, Optional.empty());
|
||||
Server server = mock(Server.class);
|
||||
World pocketWorld = intactPortalWorld(pocketWorldId, returnPortal);
|
||||
World fallbackWorld = mock(World.class);
|
||||
Location spawn = new Location(fallbackWorld, 10.5, 70.0, 10.5);
|
||||
Mob mob = mock(Mob.class);
|
||||
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
|
||||
|
||||
when(mob.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
when(pocketWorld.getNearbyEntities(any(BoundingBox.class), any()))
|
||||
.thenReturn(List.of(mob));
|
||||
when(server.getWorld(pocketWorldId)).thenReturn(pocketWorld);
|
||||
when(server.getWorlds()).thenReturn(List.of(fallbackWorld));
|
||||
when(fallbackWorld.getSpawnLocation()).thenReturn(spawn);
|
||||
when(pocketBases.knownStates()).thenReturn(Map.of(ownerId, state));
|
||||
when(pocketBases.state(ownerId)).thenReturn(state);
|
||||
when(pocketBases.returnPortalIsIntact(ownerId)).thenReturn(true);
|
||||
when(pocketBases.returnPortal(ownerId)).thenReturn(returnPortal);
|
||||
PocketBaseController controller = controller(server, pocketBases);
|
||||
|
||||
controller.scanMobPortals();
|
||||
|
||||
verify(mob).teleport(
|
||||
spawn,
|
||||
PlayerTeleportEvent.TeleportCause.PLUGIN
|
||||
);
|
||||
}
|
||||
|
||||
private static PocketBaseController controller(
|
||||
Server server,
|
||||
PocketBaseManager pocketBases
|
||||
) {
|
||||
PluginSettings settings = PluginSettings.from(Map.of());
|
||||
return new PocketBaseController(
|
||||
server,
|
||||
mock(BaseStateManager.class),
|
||||
new BaseBoundsService(settings),
|
||||
pocketBases,
|
||||
new PluginSettingsProvider(settings),
|
||||
Logger.getAnonymousLogger()
|
||||
);
|
||||
}
|
||||
|
||||
private static World intactPortalWorld(
|
||||
UUID worldId,
|
||||
PocketPortalLocation portal
|
||||
) {
|
||||
World world = mock(World.class);
|
||||
Set<BlockPosition> frame = new HashSet<>(PocketPortalGeometry.frameBlocks(portal));
|
||||
when(world.getUID()).thenReturn(worldId);
|
||||
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.DIAMOND_BLOCK : Material.AIR
|
||||
);
|
||||
});
|
||||
return world;
|
||||
}
|
||||
|
||||
private static ItemStack item(Material material) {
|
||||
ItemStack item = mock(ItemStack.class);
|
||||
when(item.getType()).thenReturn(material);
|
||||
return item;
|
||||
}
|
||||
|
||||
private static Block block(
|
||||
World world,
|
||||
int x,
|
||||
|
||||
@@ -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.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.inOrder;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
@@ -58,6 +59,32 @@ final class PocketBaseManagerTest {
|
||||
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
|
||||
void persistsAndAppliesHostileMobSpawningWithoutChangingPassive() throws Exception {
|
||||
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,
|
||||
false,
|
||||
PocketBaseBiome.CRIMSON_FOREST
|
||||
PocketBaseBiome.CRIMSON_FOREST,
|
||||
true,
|
||||
Optional.of(new PocketPortalLocation(
|
||||
worldId, "pocket", 4, 65, 4, PocketPortalAxis.X
|
||||
)),
|
||||
false
|
||||
);
|
||||
YamlPocketBaseRepository repository = new YamlPocketBaseRepository(
|
||||
temporaryDirectory.resolve("pocket-bases.yml")
|
||||
@@ -55,6 +60,21 @@ final class YamlPocketBaseRepositoryTest {
|
||||
assertFalse(loaded.passiveMobSpawningEnabled());
|
||||
assertTrue(loaded.returnPortal().isEmpty());
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user