Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c42ba6888a | ||
|
|
61e7a01374 | ||
|
|
e0b1a25c5e | ||
|
|
f4511f9286 | ||
|
|
02c6698268 | ||
|
|
bc2f9d8e25 | ||
|
|
8b3eaad253 | ||
|
|
29413d36b3 | ||
|
|
b6bbf9183a | ||
|
|
b2ba4d5821 | ||
|
|
6ea5bc16f4 | ||
|
|
4518478ea1 | ||
|
|
23e8fcec6e | ||
|
|
11e3ad1720 |
@@ -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
|
||||
|
||||
@@ -26,8 +26,13 @@ The plugin JAR is written to `build/libs/`.
|
||||
/base (alias: /home)
|
||||
/gotobase <player> (alias: /visit <player>)
|
||||
/basesettings (alias: /homesettings)
|
||||
/basesettings ui
|
||||
/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>
|
||||
/basesettings navigation <enable|disable>
|
||||
/basesettings flight <enable|disable>
|
||||
@@ -36,12 +41,20 @@ 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, 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 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 player who passes through receives temporary flight within the same boundary and buffer until leaving the Pocket Base. 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.
|
||||
|
||||
## Administration
|
||||
|
||||
The `spigotbase.admin` permission is granted to server operators by default. `/homeadmin` aliases `/baseadmin`.
|
||||
|
||||
+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")
|
||||
|
||||
@@ -91,3 +91,89 @@ description: Chronological record of material decisions affecting the Spigot Bas
|
||||
- Used the live size-tier radius so expansion and relocation immediately update the visibility threshold.
|
||||
- Added exact-boundary and controller integration coverage.
|
||||
- Verified the change with `./gradlew clean check jar`.
|
||||
|
||||
## 2026-08-24 — Pocket Base mob-spawning control
|
||||
|
||||
- Disabled natural hostile and passive mob spawning by default in new and existing Pocket Bases without a saved preference.
|
||||
- Added independent persisted owner settings through `/basesettings pocket mobs <hostile|passive> <enable|disable>`, contextual autocomplete, and status reporting.
|
||||
- Migrated each legacy combined mob-spawning preference to both categories to preserve existing behavior.
|
||||
- Reapplied both saved spawn flags when Pocket Base worlds load and restored both prior world settings when persistence fails.
|
||||
- Verified the feature with `./gradlew clean check jar`.
|
||||
|
||||
## 2026-08-24 — Relocatable Pocket Base return portals
|
||||
|
||||
- Allowed Pocket Base owners to activate a complete return portal frame with flint and steel anywhere inside their unlocked pocket boundary.
|
||||
- Persisted one active return portal location while retaining the generated portal as the default for existing Pocket Bases.
|
||||
- Made only the selected intact frame functional without removing old frame blocks, preserving the void-return safety fallback.
|
||||
- Verified the feature with `./gradlew clean check jar`.
|
||||
|
||||
## 2026-08-24 — Purchasable Pocket Base biomes
|
||||
|
||||
- Added `/basesettings pocket type <void|nether|overworld> <subtype>` with type-aware autocomplete and validation.
|
||||
- Defaulted new and unconfigured Pocket Bases to `void/the_void` and persisted each owner's selected biome.
|
||||
- Applied biome metadata across unlocked columns and new expansion rings without changing blocks, entities, inventories, portals, progression, or mob-spawning preferences.
|
||||
- Added an atomic, configurable biome-change payment that defaults to 16 netherite blocks and restores the prior biome and payment on failure.
|
||||
- Verified the feature with `./gradlew clean check jar`.
|
||||
|
||||
## 2026-08-24 — Native base settings dialog
|
||||
|
||||
- Changed `/basesettings` and `/homesettings` with no arguments to open a native Minecraft dialog dashboard, with explicit `ui` subcommands providing the same behavior.
|
||||
- Added progression summaries, current and locked setting states, toggles, Pocket Base controls, biome selection, and confirmations for Base IV, Pocket Base, and biome purchases.
|
||||
- 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`.
|
||||
|
||||
@@ -20,3 +20,12 @@ description: Catalog of user stories for the Spigot Base plugin.
|
||||
12. [US-012: Build and release the plugin](us-012-build-and-release-plugin.md)
|
||||
13. [US-013: Visualize the base border](us-013-visualize-base-border.md)
|
||||
14. [US-014: Highlight spawnable blocks](us-014-highlight-spawnable-blocks.md)
|
||||
15. [US-015: Unlock a Pocket Base](us-015-unlock-a-pocket-base.md)
|
||||
16. [US-016: Build and use Pocket Base portals](us-016-build-and-use-pocket-base-portals.md)
|
||||
17. [US-017: Expand a Pocket Base](us-017-expand-a-pocket-base.md)
|
||||
18. [US-018: Control Pocket Base mob spawning](us-018-control-pocket-base-mob-spawning.md)
|
||||
19. [US-019: Change a Pocket Base biome](us-019-change-a-pocket-base-biome.md)
|
||||
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)
|
||||
|
||||
@@ -37,3 +37,5 @@ As a **player with Base III**, I want to open my base to visitors so that other
|
||||
- [US-005: Unlock Base III teleportation](us-005-unlock-base-teleportation.md)
|
||||
- [US-006: Reduce the base teleport warm-up](us-006-reduce-teleport-warmup.md)
|
||||
- [US-007: Reduce the base teleport cooldown](us-007-reduce-teleport-cooldown.md)
|
||||
- [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)
|
||||
|
||||
@@ -11,7 +11,8 @@ As a **player**, I want to inspect my progression and receive timely milestone f
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] `/basesettings` and `/basesettings status` show the player's Base, Base Size, Base Flight, Teleport Warm-up, and Teleport Cooldown paths plus current visitor, navigation, flight, and boss-bar settings.
|
||||
- [x] `/basesettings status` shows the player's Base, Base Size, Base Flight, Teleport Warm-up, and Teleport Cooldown paths plus current visitor, navigation, flight, and boss-bar settings.
|
||||
- [x] `/basesettings status` shows whether Pocket Base flight still requires a successful owner raid or has been unlocked.
|
||||
- [x] Each path identifies earned levels, unmet prerequisites, current progress, the next threshold, and the next reward.
|
||||
- [x] 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.
|
||||
@@ -19,7 +20,7 @@ As a **player**, I want to inspect my progression and receive timely milestone f
|
||||
- [x] The automatic boss bar disappears after a configurable number of seconds.
|
||||
- [x] `/basesettings bossbar enable` enables automatic progress boss bars idempotently, and `/basesettings bossbar disable` disables them idempotently.
|
||||
- [x] Invalid boss-bar arguments show command usage, and `enable` and `disable` are offered through autocomplete.
|
||||
- [x] Disabling automatic boss bars does not prevent `/basesettings` from displaying progress.
|
||||
- [x] Disabling automatic boss bars does not prevent `/basesettings status` from displaying progress.
|
||||
- [x] The boss-bar preference persists across reconnects and restarts.
|
||||
- [x] Each newly unlocked level displays prominent full-screen title and subtitle text describing the reward.
|
||||
- [x] Unlock notifications occur once per earned level and do not repeat after reconnecting or restarting.
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
---
|
||||
type: User Story
|
||||
title: "US-015: Unlock a Pocket Base"
|
||||
description: Let Base IV owners purchase a persistent private void world that serves as the foundation for portal access and expansion.
|
||||
status: done
|
||||
---
|
||||
|
||||
# US-015: Unlock a Pocket Base
|
||||
|
||||
As a **player with an established Base IV**, I want to unlock a persistent Pocket Base so that I can build in a personal space beyond the normal world.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] Pocket Base progression requires an established Base IV.
|
||||
- [x] `/basesettings pocket upgrade` unlocks Pocket Base I and is offered through contextual autocomplete.
|
||||
- [x] Unlocking atomically charges a configurable currency and amount that default to 64 diamond blocks.
|
||||
- [x] Insufficient funds or failed persistence or world creation does not consume currency or grant Pocket Base I.
|
||||
- [x] Pocket Base I creates a persistent owner-specific void world keyed by the owner's UUID.
|
||||
- [x] The initial platform is 64 by 64 blocks with one grass layer over three dirt layers.
|
||||
- [x] The pocket world provides a safe arrival location and a generated return portal.
|
||||
- [x] Pocket Base worlds, contents, ownership, and progression persist through reconnects, restarts, and normal-base relocation.
|
||||
- [x] `/basesettings status` displays the current Pocket Base level and the next upgrade requirement.
|
||||
|
||||
## Related
|
||||
|
||||
- [US-008: Unlock Base IV visitor access](us-008-unlock-visitor-access.md)
|
||||
- [US-009: View progression and notifications](us-009-view-progression-and-notifications.md)
|
||||
- [US-011: Configure and persist progression](us-011-configure-and-persist-progression.md)
|
||||
- [US-016: Build and use Pocket Base portals](us-016-build-and-use-pocket-base-portals.md)
|
||||
- [US-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)
|
||||
@@ -0,0 +1,49 @@
|
||||
---
|
||||
type: User Story
|
||||
title: "US-016: Build and use Pocket Base portals"
|
||||
description: Let an owner create one public portal between their normal base and persistent Pocket Base.
|
||||
status: done
|
||||
---
|
||||
|
||||
# US-016: Build and use Pocket Base portals
|
||||
|
||||
As a **Pocket Base owner**, I want to connect my normal base to my Pocket Base with a public portal so that I and other players can move between them naturally.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] The entrance uses a standard 4-by-5 outer frame with a 2-by-3 interior and a configurable frame material that defaults to diamond blocks.
|
||||
- [x] Only the Pocket Base owner can activate a complete entrance frame with flint and steel.
|
||||
- [x] Every required frame block must be within the owner's current normal-base bounds when the entrance is activated.
|
||||
- [x] Custom particles and collision provide portal-like visuals and travel without relying on vanilla Nether portal blocks.
|
||||
- [x] Each owner has at most one active entrance portal.
|
||||
- [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.
|
||||
- [x] Relocated return portals persist through restarts, while existing Pocket Bases use the generated portal as their default.
|
||||
- [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
|
||||
|
||||
- [US-003: Expand the base](us-003-expand-the-base.md)
|
||||
- [US-008: Unlock Base IV visitor access](us-008-unlock-visitor-access.md)
|
||||
- [US-015: Unlock a Pocket Base](us-015-unlock-a-pocket-base.md)
|
||||
- [US-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)
|
||||
@@ -0,0 +1,28 @@
|
||||
---
|
||||
type: User Story
|
||||
title: "US-017: Expand a Pocket Base"
|
||||
description: Let Pocket Base owners purchase unlimited levels that add permanent buildable terrain around their pocket world.
|
||||
status: done
|
||||
---
|
||||
|
||||
# US-017: Expand a Pocket Base
|
||||
|
||||
As a **Pocket Base owner**, I want to purchase additional buildable space so that my pocket world can grow with my projects.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] Using `/basesettings pocket upgrade` after Pocket Base I purchases the next Pocket Base level.
|
||||
- [x] Each additional level atomically charges a configurable currency and amount that default to 16 diamond blocks.
|
||||
- [x] Insufficient funds or failed persistence or terrain generation does not consume currency or grant a level.
|
||||
- [x] Each level adds 16 blocks on every side, producing Level I at 64 by 64, Level II at 96 by 96, Level III at 128 by 128, and size `64 + 32 × (level - 1)` thereafter.
|
||||
- [x] Newly unlocked terrain has one grass layer over three dirt layers.
|
||||
- [x] Expansion preserves all existing blocks, inventories, and entities in previously unlocked terrain.
|
||||
- [x] Owners and visitors cannot place blocks outside the currently unlocked horizontal boundary.
|
||||
- [x] Pocket Base progression has no plugin-configured level cap, subject only to Minecraft coordinate and integer limits.
|
||||
- [x] Pocket Base levels, dimensions, contents, and build boundaries persist through restarts.
|
||||
|
||||
## Related
|
||||
|
||||
- [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-016: Build and use Pocket Base portals](us-016-build-and-use-pocket-base-portals.md)
|
||||
@@ -0,0 +1,31 @@
|
||||
---
|
||||
type: User Story
|
||||
title: "US-018: Control Pocket Base mob spawning"
|
||||
description: Let Pocket Base owners control hostile and passive natural mob spawning independently in their private world, with both disabled by default.
|
||||
status: done
|
||||
---
|
||||
|
||||
# US-018: Control Pocket Base mob spawning
|
||||
|
||||
As a **Pocket Base owner**, I want to enable or disable hostile and passive natural mob spawning independently in my Pocket Base so that I can choose which kinds of normal mob activity its private world supports.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] Hostile and passive natural mob spawning are both disabled by default in every new Pocket Base and in existing Pocket Bases that have no saved preference.
|
||||
- [x] A Pocket Base owner can use `/basesettings pocket mobs hostile enable|disable` to control hostile natural mob spawning in their own Pocket Base.
|
||||
- [x] A Pocket Base owner can use `/basesettings pocket mobs passive enable|disable` to control passive natural mob spawning in their own Pocket Base.
|
||||
- [x] Changing one mob category does not change the other category.
|
||||
- [x] The commands are unavailable until Pocket Base I is unlocked.
|
||||
- [x] The commands, categories, and modes are offered through contextual autocomplete.
|
||||
- [x] `/basesettings status` displays the current hostile and passive Pocket Base mob-spawning settings.
|
||||
- [x] Both preferences persist across restarts and are reapplied when the Pocket Base world loads.
|
||||
- [x] Existing saved `mob-spawning-enabled` values migrate to both category preferences, preserving prior behavior.
|
||||
- [x] Disabling either natural-spawning category does not remove existing mobs or prevent explicitly spawned or summoned mobs.
|
||||
- [x] Persistence or world-application failures produce a clear failure message, restore both prior world flags, and do not report a successful change.
|
||||
|
||||
## 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)
|
||||
- [US-017: Expand a Pocket Base](us-017-expand-a-pocket-base.md)
|
||||
- [US-019: Change a Pocket Base biome](us-019-change-a-pocket-base-biome.md)
|
||||
@@ -0,0 +1,31 @@
|
||||
---
|
||||
type: User Story
|
||||
title: "US-019: Change a Pocket Base biome"
|
||||
description: Let Pocket Base owners purchase biome changes that alter natural mob selection without changing their builds.
|
||||
status: done
|
||||
---
|
||||
|
||||
# US-019: Change a Pocket Base biome
|
||||
|
||||
As a **Pocket Base owner**, I want to change my Pocket Base biome so that I can control its biome-specific atmosphere and natural mob selection without rebuilding it.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] A Pocket Base owner can use `/basesettings pocket type <void|nether|overworld> <subtype>` to select a compatible biome.
|
||||
- [x] Void supports `the_void`; Nether supports its vanilla biome subtypes; and Overworld supports its vanilla biome subtypes.
|
||||
- [x] New and previously unconfigured Pocket Bases default to the void type and `the_void` subtype.
|
||||
- [x] Each change atomically charges a configurable currency and amount that default to 16 netherite blocks.
|
||||
- [x] Selecting the active subtype or providing an invalid type/subtype combination is rejected without charging.
|
||||
- [x] Changing subtypes within the same type incurs the configured cost.
|
||||
- [x] The selected biome is applied across every unlocked Pocket Base column without changing blocks, entities, inventories, portals, level, or the mob-spawning preference.
|
||||
- [x] Newly unlocked terrain receives the selected biome.
|
||||
- [x] When natural mob spawning is enabled, Minecraft can use the selected biome for applicable natural mob selection.
|
||||
- [x] The selected type and subtype persist through restarts and appear in `/basesettings status`.
|
||||
- [x] The command, types, and compatible subtypes are offered through contextual autocomplete.
|
||||
- [x] Failed biome application or persistence restores the prior biome and does not consume payment.
|
||||
|
||||
## Related
|
||||
|
||||
- [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-018: Control Pocket Base mob spawning](us-018-control-pocket-base-mob-spawning.md)
|
||||
@@ -0,0 +1,38 @@
|
||||
---
|
||||
type: User Story
|
||||
title: "US-020: Control a base through a dialog UI"
|
||||
description: Let players inspect and control their base through Minecraft's native dialog interface.
|
||||
status: done
|
||||
---
|
||||
|
||||
# US-020: Control a base through a dialog UI
|
||||
|
||||
As a **player**, I want a dialog-based base dashboard so that I can inspect progression and manage my base without remembering command arguments.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] `/basesettings` and `/basesettings ui` open the base-control dialog.
|
||||
- [x] `/homesettings` and `/homesettings ui` behave identically.
|
||||
- [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
|
||||
|
||||
- [US-008: Unlock Base IV visitor access](us-008-unlock-visitor-access.md)
|
||||
- [US-009: View progression and unlock notifications](us-009-view-progression-and-notifications.md)
|
||||
- [US-015: Unlock a Pocket Base](us-015-unlock-a-pocket-base.md)
|
||||
- [US-018: Control Pocket Base mob spawning](us-018-control-pocket-base-mob-spawning.md)
|
||||
- [US-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,35 @@
|
||||
---
|
||||
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] 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,30 @@
|
||||
---
|
||||
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.
|
||||
|
||||
## 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,74 @@ 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) {
|
||||
portalFlight.put(player.getUniqueId(), ownerId);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -44,6 +121,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 +144,7 @@ final class BaseFlightController implements Runnable {
|
||||
}
|
||||
grantedFlight.clear();
|
||||
warned.clear();
|
||||
portalFlight.clear();
|
||||
}
|
||||
|
||||
private PlayerState observeElytra(Player player, PlayerState state) {
|
||||
@@ -89,8 +168,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 +220,50 @@ final class BaseFlightController implements Runnable {
|
||||
}
|
||||
}
|
||||
|
||||
private void 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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -15,25 +16,58 @@ import org.bukkit.inventory.PlayerInventory;
|
||||
|
||||
final class BaseSettingsCommand implements CommandExecutor, TabCompleter {
|
||||
private static final List<String> SETTINGS = List.of(
|
||||
"status", "upgrade", "visitors", "navigation", "flight", "border", "spawnable",
|
||||
"bossbar"
|
||||
"ui", "status", "upgrade", "pocket", "visitors", "navigation", "flight", "border",
|
||||
"spawnable", "bossbar"
|
||||
);
|
||||
private static final List<String> VISITOR_MODES = List.of("allowed", "blocked");
|
||||
private static final List<String> ENABLE_MODES = List.of("enable", "disable");
|
||||
private static final List<String> POCKET_WORLD_TYPES = List.of(
|
||||
"void", "nether", "overworld"
|
||||
);
|
||||
private final BaseStateManager stateManager;
|
||||
private final PluginSettingsProvider settings;
|
||||
private final TeleportPolicy teleportPolicy;
|
||||
private final BaseFlightController flightController;
|
||||
private final PocketBaseManager pocketBases;
|
||||
private final DialogProvider dialogProvider;
|
||||
|
||||
BaseSettingsCommand(
|
||||
BaseStateManager stateManager,
|
||||
PluginSettingsProvider settings,
|
||||
BaseFlightController flightController
|
||||
) {
|
||||
this(stateManager, settings, flightController, null);
|
||||
}
|
||||
|
||||
BaseSettingsCommand(
|
||||
BaseStateManager stateManager,
|
||||
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
|
||||
@@ -44,13 +78,33 @@ final class BaseSettingsCommand implements CommandExecutor, TabCompleter {
|
||||
}
|
||||
PlayerState state = stateManager.player(player.getUniqueId(), player.getName());
|
||||
if (arguments.length == 0
|
||||
|| arguments.length == 1 && arguments[0].equalsIgnoreCase("status")) {
|
||||
|| arguments.length == 1 && arguments[0].equalsIgnoreCase("ui")) {
|
||||
showDialog(player, state);
|
||||
return true;
|
||||
}
|
||||
if (arguments.length == 1 && arguments[0].equalsIgnoreCase("status")) {
|
||||
showStatus(player, state);
|
||||
return true;
|
||||
}
|
||||
if (arguments.length == 1 && arguments[0].equalsIgnoreCase("upgrade")) {
|
||||
return purchaseVisitorAccess(player, state);
|
||||
}
|
||||
if (arguments.length == 2 && arguments[0].equalsIgnoreCase("pocket")
|
||||
&& 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]);
|
||||
}
|
||||
if (arguments.length == 4 && arguments[0].equalsIgnoreCase("pocket")
|
||||
&& arguments[1].equalsIgnoreCase("type")) {
|
||||
return purchasePocketBiome(player, arguments[2], arguments[3]);
|
||||
}
|
||||
if (arguments.length == 2 && arguments[0].equalsIgnoreCase("visitors")) {
|
||||
return updateVisitors(player, state, arguments[1]);
|
||||
}
|
||||
@@ -114,6 +168,183 @@ final class BaseSettingsCommand implements CommandExecutor, TabCompleter {
|
||||
return true;
|
||||
}
|
||||
|
||||
private boolean purchasePocketUpgrade(Player player, PlayerState owner) {
|
||||
if (pocketBases == null) {
|
||||
player.sendMessage(ChatColor.RED + "Pocket Bases are currently unavailable.");
|
||||
return true;
|
||||
}
|
||||
if (owner.baseLevel() < 4 || owner.base().isEmpty()) {
|
||||
player.sendMessage(ChatColor.RED
|
||||
+ "Establish Base IV before purchasing a Pocket Base.");
|
||||
return true;
|
||||
}
|
||||
PocketBaseState current = pocketBases.state(owner.playerId());
|
||||
int price = current.level() == 0
|
||||
? settings.current().pocketBaseUnlockCost()
|
||||
: settings.current().pocketBaseUpgradeCost();
|
||||
Material currency = Material.valueOf(settings.current().pocketBaseCurrencyMaterial());
|
||||
PlayerInventory inventory = player.getInventory();
|
||||
if (countCurrency(inventory, currency) < price) {
|
||||
player.sendMessage(ChatColor.RED + "Pocket Base "
|
||||
+ (current.level() == 0 ? "I" : "level " + (current.level() + 1))
|
||||
+ " costs " + price + " "
|
||||
+ currency.name().toLowerCase(java.util.Locale.ROOT) + ".");
|
||||
return true;
|
||||
}
|
||||
ItemStack[] snapshot = cloneContents(inventory.getStorageContents());
|
||||
removeCurrency(inventory, currency, price);
|
||||
final PocketBaseState upgraded;
|
||||
try {
|
||||
upgraded = pocketBases.upgrade(owner.playerId());
|
||||
} catch (IOException | RuntimeException exception) {
|
||||
inventory.setStorageContents(snapshot);
|
||||
player.sendMessage(ChatColor.RED
|
||||
+ "The Pocket Base upgrade failed; your payment was restored.");
|
||||
return true;
|
||||
}
|
||||
player.sendTitle(
|
||||
ChatColor.GOLD + "Pocket Base " + upgraded.level() + " Unlocked",
|
||||
ChatColor.YELLOW + "Build a portal inside your base",
|
||||
settings.current().titleFadeInTicks(),
|
||||
settings.current().titleStayTicks(),
|
||||
settings.current().titleFadeOutTicks()
|
||||
);
|
||||
player.sendMessage(ChatColor.GREEN + "Pocket Base level " + upgraded.level()
|
||||
+ " unlocked for " + price + " "
|
||||
+ currency.name().toLowerCase(java.util.Locale.ROOT) + ".");
|
||||
return true;
|
||||
}
|
||||
|
||||
private boolean purchasePocketBiome(
|
||||
Player player,
|
||||
String worldTypeName,
|
||||
String biomeName
|
||||
) {
|
||||
if (pocketBases == null) {
|
||||
player.sendMessage(ChatColor.RED + "Pocket Bases are currently unavailable.");
|
||||
return true;
|
||||
}
|
||||
PocketBaseState current = pocketBases.state(player.getUniqueId());
|
||||
if (current.level() < 1) {
|
||||
player.sendMessage(ChatColor.RED + "Pocket Base I is still locked.");
|
||||
return true;
|
||||
}
|
||||
PocketBaseBiome biome = PocketBaseWorldType.fromCommand(worldTypeName)
|
||||
.flatMap(worldType -> PocketBaseBiome.fromCommand(worldType, biomeName))
|
||||
.orElse(null);
|
||||
if (biome == null) {
|
||||
player.sendMessage(ChatColor.RED + "That Pocket Base type and subtype are invalid.");
|
||||
return true;
|
||||
}
|
||||
if (biome == current.biome()) {
|
||||
player.sendMessage(ChatColor.RED + "Your Pocket Base already uses "
|
||||
+ biome.commandName() + ".");
|
||||
return true;
|
||||
}
|
||||
int price = settings.current().pocketBaseBiomeChangeCost();
|
||||
Material currency = Material.valueOf(
|
||||
settings.current().pocketBaseBiomeCurrencyMaterial()
|
||||
);
|
||||
PlayerInventory inventory = player.getInventory();
|
||||
if (countCurrency(inventory, currency) < price) {
|
||||
player.sendMessage(ChatColor.RED + "Changing Pocket Base biome costs "
|
||||
+ price + " " + currency.name().toLowerCase(Locale.ROOT) + ".");
|
||||
return true;
|
||||
}
|
||||
ItemStack[] snapshot = cloneContents(inventory.getStorageContents());
|
||||
removeCurrency(inventory, currency, price);
|
||||
final PocketBaseState updated;
|
||||
try {
|
||||
updated = pocketBases.setBiome(player.getUniqueId(), biome);
|
||||
} catch (IOException | RuntimeException exception) {
|
||||
inventory.setStorageContents(snapshot);
|
||||
player.sendMessage(ChatColor.RED
|
||||
+ "The Pocket Base biome change failed; your payment was restored.");
|
||||
return true;
|
||||
}
|
||||
player.sendMessage(ChatColor.GREEN + "Pocket Base biome changed to "
|
||||
+ updated.biome().commandName() + " for " + price + " "
|
||||
+ currency.name().toLowerCase(Locale.ROOT) + ".");
|
||||
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,
|
||||
String mode
|
||||
) {
|
||||
if (pocketBases == null) {
|
||||
player.sendMessage(ChatColor.RED + "Pocket Bases are currently unavailable.");
|
||||
return true;
|
||||
}
|
||||
PocketBaseState current = pocketBases.state(player.getUniqueId());
|
||||
if (current.level() < 1) {
|
||||
player.sendMessage(ChatColor.RED + "Pocket Base I is still locked.");
|
||||
return true;
|
||||
}
|
||||
Boolean enabled = enabledMode(mode);
|
||||
if (enabled == null) {
|
||||
sendUsage(player);
|
||||
return true;
|
||||
}
|
||||
final PocketBaseState updated;
|
||||
final boolean settingEnabled;
|
||||
try {
|
||||
if (category.equalsIgnoreCase("hostile")) {
|
||||
updated = pocketBases.setHostileMobSpawning(
|
||||
player.getUniqueId(), enabled
|
||||
);
|
||||
settingEnabled = updated.hostileMobSpawningEnabled();
|
||||
} else if (category.equalsIgnoreCase("passive")) {
|
||||
updated = pocketBases.setPassiveMobSpawning(
|
||||
player.getUniqueId(), enabled
|
||||
);
|
||||
settingEnabled = updated.passiveMobSpawningEnabled();
|
||||
} else {
|
||||
sendUsage(player);
|
||||
return true;
|
||||
}
|
||||
} catch (IOException | RuntimeException exception) {
|
||||
player.sendMessage(ChatColor.RED
|
||||
+ "The Pocket Base mob-spawning setting could not be changed.");
|
||||
return true;
|
||||
}
|
||||
player.sendMessage(ChatColor.YELLOW + "Pocket Base "
|
||||
+ category.toLowerCase(Locale.ROOT) + " mob spawning is now "
|
||||
+ (settingEnabled ? ChatColor.GREEN + "enabled" : ChatColor.RED + "disabled")
|
||||
+ ChatColor.YELLOW + ".");
|
||||
return true;
|
||||
}
|
||||
|
||||
private boolean updateVisitors(Player player, PlayerState state, String mode) {
|
||||
if (state.baseLevel() < 4) {
|
||||
player.sendMessage(ChatColor.RED + "Base IV visitor access is still locked.");
|
||||
@@ -267,6 +498,13 @@ final class BaseSettingsCommand implements CommandExecutor, TabCompleter {
|
||||
return true;
|
||||
}
|
||||
|
||||
private void showDialog(Player player, PlayerState state) {
|
||||
PocketBaseState pocket = pocketBases == null
|
||||
? PocketBaseState.locked(state.playerId())
|
||||
: pocketBases.state(state.playerId());
|
||||
player.showDialog(dialogProvider.create(state, pocket));
|
||||
}
|
||||
|
||||
private void showStatus(Player player, PlayerState state) {
|
||||
player.sendMessage(ChatColor.GOLD + "=== Base Progress ===");
|
||||
showBasePath(player, state);
|
||||
@@ -274,6 +512,7 @@ final class BaseSettingsCommand implements CommandExecutor, TabCompleter {
|
||||
showFlightPath(player, state);
|
||||
showWarmupPath(player, state);
|
||||
showCooldownPath(player, state);
|
||||
showPocketPath(player, state);
|
||||
int spawnableThreshold = settings.current().spawnableOverlayUnlockPlacements();
|
||||
player.sendMessage(ChatColor.YELLOW + "Spawnable Overlay: " + ChatColor.GRAY
|
||||
+ state.totalBlocksPlaced() + "/" + spawnableThreshold + " placements; "
|
||||
@@ -344,6 +583,28 @@ final class BaseSettingsCommand implements CommandExecutor, TabCompleter {
|
||||
+ DurationFormatter.friendly(teleportPolicy.warmup(state)) + "; " + detail);
|
||||
}
|
||||
|
||||
private void showPocketPath(Player player, PlayerState owner) {
|
||||
PocketBaseState pocket = pocketBases == null
|
||||
? PocketBaseState.locked(owner.playerId())
|
||||
: pocketBases.state(owner.playerId());
|
||||
int price = pocket.level() == 0
|
||||
? settings.current().pocketBaseUnlockCost()
|
||||
: settings.current().pocketBaseUpgradeCost();
|
||||
player.sendMessage(colorForPrerequisite(owner.baseLevel() >= 4 && owner.base().isPresent())
|
||||
+ "Pocket Base " + pocket.level() + ": " + ChatColor.GRAY + price + " "
|
||||
+ settings.current().pocketBaseCurrencyMaterial().toLowerCase(Locale.ROOT)
|
||||
+ " → /basesettings pocket upgrade; type="
|
||||
+ pocket.biome().worldType().commandName() + "/" + pocket.biome().commandName()
|
||||
+ "; hostile spawning="
|
||||
+ (pocket.hostileMobSpawningEnabled() ? "enabled" : "disabled")
|
||||
+ "; passive spawning="
|
||||
+ (pocket.passiveMobSpawningEnabled() ? "enabled" : "disabled")
|
||||
+ "; flight="
|
||||
+ (pocket.flightUnlocked()
|
||||
? "automatic " + (pocket.flightEnabled() ? "enabled" : "disabled")
|
||||
: "complete a raid to unlock"));
|
||||
}
|
||||
|
||||
private void showCooldownPath(Player player, PlayerState state) {
|
||||
String detail = switch (state.cooldownLevel()) {
|
||||
case 0 -> state.blocksBrokenInBase() + "/" + settings.current().firstCooldownBreaks();
|
||||
@@ -373,12 +634,49 @@ 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", "flight", "mobs", "type");
|
||||
case "navigation", "flight", "border", "spawnable", "bossbar" -> ENABLE_MODES;
|
||||
default -> List.of();
|
||||
};
|
||||
String prefix = arguments[1].toLowerCase(Locale.ROOT);
|
||||
return modes.stream().filter(mode -> mode.startsWith(prefix)).toList();
|
||||
}
|
||||
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))
|
||||
.toList();
|
||||
}
|
||||
if (arguments[1].equalsIgnoreCase("type")) {
|
||||
return POCKET_WORLD_TYPES.stream()
|
||||
.filter(type -> type.startsWith(prefix))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
if (arguments.length == 4 && arguments[0].equalsIgnoreCase("pocket")) {
|
||||
String prefix = arguments[3].toLowerCase(Locale.ROOT);
|
||||
if (arguments[1].equalsIgnoreCase("mobs")
|
||||
&& (arguments[2].equalsIgnoreCase("hostile")
|
||||
|| arguments[2].equalsIgnoreCase("passive"))) {
|
||||
return ENABLE_MODES.stream()
|
||||
.filter(mode -> mode.startsWith(prefix))
|
||||
.toList();
|
||||
}
|
||||
if (arguments[1].equalsIgnoreCase("type")) {
|
||||
return PocketBaseWorldType.fromCommand(arguments[2])
|
||||
.map(PocketBaseBiome::commandNames)
|
||||
.orElse(List.of())
|
||||
.stream()
|
||||
.filter(biome -> biome.startsWith(prefix))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
return List.of();
|
||||
}
|
||||
|
||||
@@ -436,9 +734,17 @@ 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 "
|
||||
+ "[status|upgrade|visitors <allowed|blocked>|navigation <enable|disable>"
|
||||
+ "[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>"
|
||||
+ "|flight <enable|disable>|border <enable|disable>"
|
||||
+ "|spawnable <enable|disable>|bossbar <enable|disable>]");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,482 @@
|
||||
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.kyori.adventure.text.Component;
|
||||
import net.kyori.adventure.text.event.ClickEvent;
|
||||
|
||||
final class BaseSettingsDialogFactory {
|
||||
private static final int DIALOG_WIDTH = 420;
|
||||
private final PluginSettings settings;
|
||||
|
||||
BaseSettingsDialogFactory(PluginSettings settings) {
|
||||
this.settings = settings;
|
||||
}
|
||||
|
||||
Dialog create(PlayerState owner, PocketBaseState pocket) {
|
||||
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"
|
||||
+ " • Flight " + owner.flightLevel() + "/3\n"
|
||||
+ "Pocket Base " + pocket.level() + " • "
|
||||
+ humanize(pocket.biome().commandName())
|
||||
),
|
||||
dialogs,
|
||||
2,
|
||||
190
|
||||
);
|
||||
}
|
||||
|
||||
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."
|
||||
),
|
||||
actions,
|
||||
2
|
||||
);
|
||||
}
|
||||
|
||||
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 DialogSpec baseUpgrade(PlayerState owner) {
|
||||
if (owner.baseLevel() >= 4) {
|
||||
return notice("Base IV", "Base IV and visitor access are already unlocked.");
|
||||
}
|
||||
if (owner.baseLevel() < 3 || owner.base().isEmpty()) {
|
||||
return notice(
|
||||
"Base IV — Locked",
|
||||
"Unlock and establish Base III before purchasing Base IV visitor access."
|
||||
);
|
||||
}
|
||||
return confirmation(
|
||||
"Unlock Base IV",
|
||||
"Purchase visitor access for " + settings.visitorUnlockDiamondCost()
|
||||
+ " diamonds?",
|
||||
"basesettings upgrade"
|
||||
);
|
||||
}
|
||||
|
||||
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
|
||||
? "Pocket Base is locked."
|
||||
: "Biome: " + pocket.biome().worldType().commandName() + "/"
|
||||
+ pocket.biome().commandName() + "\nHostile mobs: "
|
||||
+ enabled(pocket.hostileMobSpawningEnabled()) + " • Passive mobs: "
|
||||
+ enabled(pocket.passiveMobSpawningEnabled()) + "\nFlight: "
|
||||
+ (pocket.flightUnlocked()
|
||||
? (pocket.flightEnabled() ? "Enabled" : "Disabled")
|
||||
: "Complete a raid to unlock")
|
||||
),
|
||||
dialogs,
|
||||
1,
|
||||
260
|
||||
);
|
||||
}
|
||||
|
||||
private DialogSpec pocketUpgrade(PlayerState owner, PocketBaseState pocket) {
|
||||
if (owner.baseLevel() < 4 || owner.base().isEmpty()) {
|
||||
return notice(
|
||||
"Pocket Upgrade — Locked",
|
||||
"Establish Base IV before purchasing a Pocket Base."
|
||||
);
|
||||
}
|
||||
int price = pocket.level() == 0
|
||||
? settings.pocketBaseUnlockCost()
|
||||
: settings.pocketBaseUpgradeCost();
|
||||
String currency = humanize(settings.pocketBaseCurrencyMaterial());
|
||||
String title = pocket.level() == 0
|
||||
? "Unlock Pocket Base I"
|
||||
: "Upgrade to Pocket Base " + (pocket.level() + 1);
|
||||
return confirmation(
|
||||
title,
|
||||
"Purchase this upgrade for " + price + " " + currency + "?",
|
||||
"basesettings pocket upgrade"
|
||||
);
|
||||
}
|
||||
|
||||
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."
|
||||
);
|
||||
}
|
||||
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."
|
||||
),
|
||||
actions,
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
private DialogSpec pocketBiomeSettings(PocketBaseState pocket) {
|
||||
if (pocket.level() < 1) {
|
||||
return notice(
|
||||
"Change Biome — Locked",
|
||||
"Unlock Pocket Base I to purchase biome changes."
|
||||
);
|
||||
}
|
||||
List<DialogSpec> worldTypes = Arrays.stream(PocketBaseWorldType.values())
|
||||
.map(type -> pocketBiomeType(pocket, type))
|
||||
.toList();
|
||||
return new ListSpec(
|
||||
content(
|
||||
"Change Biome",
|
||||
"Change Biome",
|
||||
"Current biome: " + pocket.biome().worldType().commandName() + "/"
|
||||
+ pocket.biome().commandName() + "\nEach change costs "
|
||||
+ settings.pocketBaseBiomeChangeCost() + " "
|
||||
+ humanize(settings.pocketBaseBiomeCurrencyMaterial()) + "."
|
||||
),
|
||||
worldTypes,
|
||||
1,
|
||||
220
|
||||
);
|
||||
}
|
||||
|
||||
private DialogSpec pocketBiomeType(
|
||||
PocketBaseState pocket,
|
||||
PocketBaseWorldType worldType
|
||||
) {
|
||||
List<DialogSpec> biomes = Arrays.stream(PocketBaseBiome.values())
|
||||
.filter(biome -> biome.worldType() == worldType)
|
||||
.map(biome -> biomeConfirmation(pocket, biome))
|
||||
.toList();
|
||||
return new ListSpec(
|
||||
content(
|
||||
humanize(worldType.commandName()),
|
||||
humanize(worldType.commandName()) + " Biomes",
|
||||
"Choose a biome subtype. The active biome is marked as selected."
|
||||
),
|
||||
biomes,
|
||||
2,
|
||||
190
|
||||
);
|
||||
}
|
||||
|
||||
private DialogSpec biomeConfirmation(PocketBaseState pocket, PocketBaseBiome biome) {
|
||||
String title = humanize(biome.commandName())
|
||||
+ (pocket.biome() == biome ? " — Selected" : "");
|
||||
if (pocket.biome() == biome) {
|
||||
return notice(title, "This is your Pocket Base's current biome.");
|
||||
}
|
||||
return confirmation(
|
||||
title,
|
||||
"Change the Pocket Base biome for " + settings.pocketBaseBiomeChangeCost()
|
||||
+ " " + humanize(settings.pocketBaseBiomeCurrencyMaterial()) + "?",
|
||||
"basesettings pocket type " + biome.worldType().commandName() + " "
|
||||
+ biome.commandName()
|
||||
);
|
||||
}
|
||||
|
||||
private static DialogSpec confirmation(String title, String message, String command) {
|
||||
return new ConfirmationSpec(
|
||||
content(title, title, message),
|
||||
commandButton("Confirm", command)
|
||||
);
|
||||
}
|
||||
|
||||
private static DialogSpec notice(String title, String message) {
|
||||
return new NoticeSpec(content(title, title, message), null);
|
||||
}
|
||||
|
||||
private static ButtonSpec toggle(
|
||||
String name,
|
||||
boolean unlocked,
|
||||
boolean active,
|
||||
String activeLabel,
|
||||
String inactiveLabel,
|
||||
String command
|
||||
) {
|
||||
String state = unlocked ? (active ? activeLabel : inactiveLabel) : "Locked";
|
||||
String tooltip = unlocked
|
||||
? "Click to change this setting."
|
||||
: "This setting has not been unlocked yet.";
|
||||
return new ButtonSpec(name + ": " + state, tooltip, 190, "/" + command);
|
||||
}
|
||||
|
||||
private static ButtonSpec commandButton(String label, String command) {
|
||||
return new ButtonSpec(label, "", 150, "/" + command);
|
||||
}
|
||||
|
||||
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.DialogAfterAction.CLOSE)
|
||||
.build();
|
||||
return Dialog.create(factory -> factory.empty().base(base).type(type));
|
||||
}
|
||||
|
||||
private static ActionButton backButton() {
|
||||
return render(backButtonSpec());
|
||||
}
|
||||
|
||||
static ButtonSpec backButtonSpec() {
|
||||
return commandButton("Back", "basesettings ui");
|
||||
}
|
||||
|
||||
private static ActionButton render(ButtonSpec button) {
|
||||
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) {
|
||||
return active ? "enable" : "disable";
|
||||
}
|
||||
|
||||
private static String enabled(boolean active) {
|
||||
return active ? "enabled" : "disabled";
|
||||
}
|
||||
|
||||
private static String humanize(String value) {
|
||||
String normalized = value.toLowerCase(Locale.ROOT).replace('_', ' ');
|
||||
StringBuilder result = new StringBuilder(normalized.length());
|
||||
boolean capitalize = true;
|
||||
for (int index = 0; index < normalized.length(); index++) {
|
||||
char character = normalized.charAt(index);
|
||||
result.append(capitalize ? Character.toUpperCase(character) : character);
|
||||
capitalize = character == ' ';
|
||||
}
|
||||
return result.toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
package games.dmg.spigotbase;
|
||||
|
||||
public record BlockPosition(int x, int y, int z) {
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -37,11 +37,17 @@ public record PluginSettings(
|
||||
long thirdTeleportCooldownSeconds,
|
||||
long fourthTeleportCooldownSeconds,
|
||||
int visitorUnlockDiamondCost,
|
||||
int pocketBaseUnlockCost,
|
||||
int pocketBaseUpgradeCost,
|
||||
int pocketBaseBiomeChangeCost,
|
||||
Set<String> baseUnlockMaterials,
|
||||
String stoneExpansionMaterial,
|
||||
String deepslateExpansionMaterial,
|
||||
String obsidianExpansionMaterial,
|
||||
String visitorCurrencyMaterial,
|
||||
String pocketBaseCurrencyMaterial,
|
||||
String pocketBaseBiomeCurrencyMaterial,
|
||||
String pocketBasePortalFrameMaterial,
|
||||
Set<String> cooldownExcludedMaterials,
|
||||
int bossBarDurationTicks,
|
||||
int navigationParticleCount,
|
||||
@@ -79,6 +85,9 @@ public record PluginSettings(
|
||||
private static final long DEFAULT_THIRD_TELEPORT_COOLDOWN_SECONDS = 3_600L;
|
||||
private static final long DEFAULT_FOURTH_TELEPORT_COOLDOWN_SECONDS = 1_800L;
|
||||
private static final int DEFAULT_VISITOR_UNLOCK_DIAMOND_COST = 128;
|
||||
private static final int DEFAULT_POCKET_BASE_UNLOCK_COST = 64;
|
||||
private static final int DEFAULT_POCKET_BASE_UPGRADE_COST = 16;
|
||||
private static final int DEFAULT_POCKET_BASE_BIOME_CHANGE_COST = 16;
|
||||
private static final int DEFAULT_BOSS_BAR_DURATION_TICKS = 60;
|
||||
private static final int DEFAULT_NAVIGATION_PARTICLE_COUNT = 5;
|
||||
private static final int DEFAULT_TITLE_FADE_IN_TICKS = 10;
|
||||
@@ -140,6 +149,9 @@ public record PluginSettings(
|
||||
throw new IllegalArgumentException("teleport cooldown durations must decrease");
|
||||
}
|
||||
requirePositive(visitorUnlockDiamondCost, "visitor-unlock-diamond-cost");
|
||||
requirePositive(pocketBaseUnlockCost, "pocket-base-unlock-cost");
|
||||
requirePositive(pocketBaseUpgradeCost, "pocket-base-upgrade-cost");
|
||||
requirePositive(pocketBaseBiomeChangeCost, "pocket-base-biome-change-cost");
|
||||
baseUnlockMaterials = normalizedSet(baseUnlockMaterials, "base-unlock-materials", false);
|
||||
stoneExpansionMaterial = normalizedName(stoneExpansionMaterial, "stone-expansion-material");
|
||||
deepslateExpansionMaterial = normalizedName(
|
||||
@@ -151,6 +163,15 @@ public record PluginSettings(
|
||||
visitorCurrencyMaterial = normalizedName(
|
||||
visitorCurrencyMaterial, "visitor-currency-material"
|
||||
);
|
||||
pocketBaseCurrencyMaterial = normalizedName(
|
||||
pocketBaseCurrencyMaterial, "pocket-base-currency-material"
|
||||
);
|
||||
pocketBaseBiomeCurrencyMaterial = normalizedName(
|
||||
pocketBaseBiomeCurrencyMaterial, "pocket-base-biome-currency-material"
|
||||
);
|
||||
pocketBasePortalFrameMaterial = normalizedName(
|
||||
pocketBasePortalFrameMaterial, "pocket-base-portal-frame-material"
|
||||
);
|
||||
cooldownExcludedMaterials = normalizedSet(
|
||||
cooldownExcludedMaterials, "cooldown-excluded-materials", true
|
||||
);
|
||||
@@ -214,11 +235,21 @@ public record PluginSettings(
|
||||
DEFAULT_FOURTH_TELEPORT_COOLDOWN_SECONDS
|
||||
),
|
||||
integer(values, "visitor-unlock-diamond-cost", DEFAULT_VISITOR_UNLOCK_DIAMOND_COST),
|
||||
integer(values, "pocket-base-unlock-cost", DEFAULT_POCKET_BASE_UNLOCK_COST),
|
||||
integer(values, "pocket-base-upgrade-cost", DEFAULT_POCKET_BASE_UPGRADE_COST),
|
||||
integer(
|
||||
values,
|
||||
"pocket-base-biome-change-cost",
|
||||
DEFAULT_POCKET_BASE_BIOME_CHANGE_COST
|
||||
),
|
||||
stringSet(values, "base-unlock-materials", Set.of("GRASS_BLOCK", "DIRT")),
|
||||
string(values, "stone-expansion-material", "STONE"),
|
||||
string(values, "deepslate-expansion-material", "DEEPSLATE"),
|
||||
string(values, "obsidian-expansion-material", "OBSIDIAN"),
|
||||
string(values, "visitor-currency-material", "DIAMOND"),
|
||||
string(values, "pocket-base-currency-material", "DIAMOND_BLOCK"),
|
||||
string(values, "pocket-base-biome-currency-material", "NETHERITE_BLOCK"),
|
||||
string(values, "pocket-base-portal-frame-material", "DIAMOND_BLOCK"),
|
||||
stringSet(values, "cooldown-excluded-materials", Set.of()),
|
||||
integer(values, "boss-bar-duration-ticks", DEFAULT_BOSS_BAR_DURATION_TICKS),
|
||||
integer(values, "navigation-particle-count", DEFAULT_NAVIGATION_PARTICLE_COUNT),
|
||||
|
||||
@@ -13,7 +13,8 @@ final class PluginSettingsValidator {
|
||||
Stream.of(
|
||||
settings.stoneExpansionMaterial(),
|
||||
settings.deepslateExpansionMaterial(),
|
||||
settings.obsidianExpansionMaterial()
|
||||
settings.obsidianExpansionMaterial(),
|
||||
settings.pocketBasePortalFrameMaterial()
|
||||
)
|
||||
).forEach(name -> {
|
||||
Material material = Material.matchMaterial(name);
|
||||
@@ -26,10 +27,19 @@ final class PluginSettingsValidator {
|
||||
throw new IllegalArgumentException(name + " is not a known material");
|
||||
}
|
||||
}
|
||||
Material currency = Material.matchMaterial(settings.visitorCurrencyMaterial());
|
||||
if (currency == null || currency.isAir()) {
|
||||
throw new IllegalArgumentException("visitor-currency-material is invalid");
|
||||
}
|
||||
validateCurrency(settings.visitorCurrencyMaterial(), "visitor-currency-material");
|
||||
validateCurrency(settings.pocketBaseCurrencyMaterial(), "pocket-base-currency-material");
|
||||
validateCurrency(
|
||||
settings.pocketBaseBiomeCurrencyMaterial(),
|
||||
"pocket-base-biome-currency-material"
|
||||
);
|
||||
return settings;
|
||||
}
|
||||
|
||||
private static void validateCurrency(String name, String setting) {
|
||||
Material currency = Material.matchMaterial(name);
|
||||
if (currency == null || currency.isAir()) {
|
||||
throw new IllegalArgumentException(setting + " is invalid");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
package games.dmg.spigotbase;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Optional;
|
||||
|
||||
public enum PocketBaseBiome {
|
||||
THE_VOID(PocketBaseWorldType.VOID),
|
||||
|
||||
NETHER_WASTES(PocketBaseWorldType.NETHER),
|
||||
SOUL_SAND_VALLEY(PocketBaseWorldType.NETHER),
|
||||
CRIMSON_FOREST(PocketBaseWorldType.NETHER),
|
||||
WARPED_FOREST(PocketBaseWorldType.NETHER),
|
||||
BASALT_DELTAS(PocketBaseWorldType.NETHER),
|
||||
|
||||
OCEAN(PocketBaseWorldType.OVERWORLD),
|
||||
PLAINS(PocketBaseWorldType.OVERWORLD),
|
||||
DESERT(PocketBaseWorldType.OVERWORLD),
|
||||
WINDSWEPT_HILLS(PocketBaseWorldType.OVERWORLD),
|
||||
FOREST(PocketBaseWorldType.OVERWORLD),
|
||||
TAIGA(PocketBaseWorldType.OVERWORLD),
|
||||
SWAMP(PocketBaseWorldType.OVERWORLD),
|
||||
MANGROVE_SWAMP(PocketBaseWorldType.OVERWORLD),
|
||||
RIVER(PocketBaseWorldType.OVERWORLD),
|
||||
FROZEN_OCEAN(PocketBaseWorldType.OVERWORLD),
|
||||
FROZEN_RIVER(PocketBaseWorldType.OVERWORLD),
|
||||
SNOWY_PLAINS(PocketBaseWorldType.OVERWORLD),
|
||||
MUSHROOM_FIELDS(PocketBaseWorldType.OVERWORLD),
|
||||
BEACH(PocketBaseWorldType.OVERWORLD),
|
||||
JUNGLE(PocketBaseWorldType.OVERWORLD),
|
||||
SPARSE_JUNGLE(PocketBaseWorldType.OVERWORLD),
|
||||
DEEP_OCEAN(PocketBaseWorldType.OVERWORLD),
|
||||
STONY_SHORE(PocketBaseWorldType.OVERWORLD),
|
||||
SNOWY_BEACH(PocketBaseWorldType.OVERWORLD),
|
||||
BIRCH_FOREST(PocketBaseWorldType.OVERWORLD),
|
||||
DARK_FOREST(PocketBaseWorldType.OVERWORLD),
|
||||
PALE_GARDEN(PocketBaseWorldType.OVERWORLD),
|
||||
SNOWY_TAIGA(PocketBaseWorldType.OVERWORLD),
|
||||
OLD_GROWTH_PINE_TAIGA(PocketBaseWorldType.OVERWORLD),
|
||||
WINDSWEPT_FOREST(PocketBaseWorldType.OVERWORLD),
|
||||
SAVANNA(PocketBaseWorldType.OVERWORLD),
|
||||
SAVANNA_PLATEAU(PocketBaseWorldType.OVERWORLD),
|
||||
BADLANDS(PocketBaseWorldType.OVERWORLD),
|
||||
WOODED_BADLANDS(PocketBaseWorldType.OVERWORLD),
|
||||
WARM_OCEAN(PocketBaseWorldType.OVERWORLD),
|
||||
LUKEWARM_OCEAN(PocketBaseWorldType.OVERWORLD),
|
||||
COLD_OCEAN(PocketBaseWorldType.OVERWORLD),
|
||||
DEEP_LUKEWARM_OCEAN(PocketBaseWorldType.OVERWORLD),
|
||||
DEEP_COLD_OCEAN(PocketBaseWorldType.OVERWORLD),
|
||||
DEEP_FROZEN_OCEAN(PocketBaseWorldType.OVERWORLD),
|
||||
SUNFLOWER_PLAINS(PocketBaseWorldType.OVERWORLD),
|
||||
WINDSWEPT_GRAVELLY_HILLS(PocketBaseWorldType.OVERWORLD),
|
||||
FLOWER_FOREST(PocketBaseWorldType.OVERWORLD),
|
||||
ICE_SPIKES(PocketBaseWorldType.OVERWORLD),
|
||||
OLD_GROWTH_BIRCH_FOREST(PocketBaseWorldType.OVERWORLD),
|
||||
OLD_GROWTH_SPRUCE_TAIGA(PocketBaseWorldType.OVERWORLD),
|
||||
WINDSWEPT_SAVANNA(PocketBaseWorldType.OVERWORLD),
|
||||
ERODED_BADLANDS(PocketBaseWorldType.OVERWORLD),
|
||||
BAMBOO_JUNGLE(PocketBaseWorldType.OVERWORLD),
|
||||
DRIPSTONE_CAVES(PocketBaseWorldType.OVERWORLD),
|
||||
LUSH_CAVES(PocketBaseWorldType.OVERWORLD),
|
||||
DEEP_DARK(PocketBaseWorldType.OVERWORLD),
|
||||
SULFUR_CAVES(PocketBaseWorldType.OVERWORLD),
|
||||
MEADOW(PocketBaseWorldType.OVERWORLD),
|
||||
GROVE(PocketBaseWorldType.OVERWORLD),
|
||||
SNOWY_SLOPES(PocketBaseWorldType.OVERWORLD),
|
||||
FROZEN_PEAKS(PocketBaseWorldType.OVERWORLD),
|
||||
JAGGED_PEAKS(PocketBaseWorldType.OVERWORLD),
|
||||
STONY_PEAKS(PocketBaseWorldType.OVERWORLD),
|
||||
CHERRY_GROVE(PocketBaseWorldType.OVERWORLD);
|
||||
|
||||
private final PocketBaseWorldType worldType;
|
||||
|
||||
PocketBaseBiome(PocketBaseWorldType worldType) {
|
||||
this.worldType = worldType;
|
||||
}
|
||||
|
||||
public PocketBaseWorldType worldType() {
|
||||
return worldType;
|
||||
}
|
||||
|
||||
public String commandName() {
|
||||
return name().toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
static Optional<PocketBaseBiome> fromCommand(
|
||||
PocketBaseWorldType worldType,
|
||||
String value
|
||||
) {
|
||||
try {
|
||||
PocketBaseBiome biome = valueOf(value.toUpperCase(Locale.ROOT));
|
||||
return biome.worldType == worldType ? Optional.of(biome) : Optional.empty();
|
||||
} catch (IllegalArgumentException exception) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
static List<String> commandNames(PocketBaseWorldType worldType) {
|
||||
return Arrays.stream(values())
|
||||
.filter(biome -> biome.worldType == worldType)
|
||||
.map(PocketBaseBiome::commandName)
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,479 @@
|
||||
package games.dmg.spigotbase;
|
||||
|
||||
import java.io.IOException;
|
||||
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;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.Particle;
|
||||
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;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.block.Action;
|
||||
import org.bukkit.event.block.BlockBreakEvent;
|
||||
import org.bukkit.event.block.BlockPlaceEvent;
|
||||
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;
|
||||
private static final long PORTAL_COOLDOWN_NANOS = 2_000_000_000L;
|
||||
private final Server server;
|
||||
private final BaseStateManager baseStates;
|
||||
private final BaseBoundsService baseBounds;
|
||||
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(
|
||||
Server server,
|
||||
BaseStateManager baseStates,
|
||||
BaseBoundsService baseBounds,
|
||||
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;
|
||||
this.baseBounds = baseBounds;
|
||||
this.pocketBases = pocketBases;
|
||||
this.settings = settings;
|
||||
this.logger = logger;
|
||||
this.flightController = flightController;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
validateEntrances();
|
||||
for (PocketBaseState state : pocketBases.knownStates().values()) {
|
||||
state.entrance().ifPresent(this::showPortal);
|
||||
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);
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true)
|
||||
public void onActivate(PlayerInteractEvent event) {
|
||||
if (event.getAction() != Action.RIGHT_CLICK_BLOCK || event.getClickedBlock() == null
|
||||
|| event.getItem() == null
|
||||
|| event.getItem().getType() != Material.FLINT_AND_STEEL) {
|
||||
return;
|
||||
}
|
||||
Player player = event.getPlayer();
|
||||
PocketBaseState pocket = pocketBases.state(player.getUniqueId());
|
||||
if (pocket.level() < 1) {
|
||||
return;
|
||||
}
|
||||
Block clicked = event.getClickedBlock();
|
||||
World world = clicked.getWorld();
|
||||
Material frameMaterial = Material.valueOf(
|
||||
settings.current().pocketBasePortalFrameMaterial()
|
||||
);
|
||||
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 "
|
||||
+ (activatingFlightPortal ? "flight" : "return")
|
||||
+ " portal must be inside your Pocket Base boundary.");
|
||||
event.setCancelled(true);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
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 "
|
||||
+ (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 "
|
||||
+ (activatingFlightPortal ? "flight" : "return") + " portal activated.");
|
||||
return;
|
||||
}
|
||||
Optional<PocketPortalLocation> portal = findFrame(clicked, frameMaterial);
|
||||
if (portal.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
PlayerState owner = baseStates.player(player.getUniqueId(), player.getName());
|
||||
if (owner.baseLevel() < 4 || owner.base().isEmpty()) {
|
||||
return;
|
||||
}
|
||||
BaseArea area = baseBounds.area(owner);
|
||||
if (PocketPortalGeometry.frameBlocks(portal.orElseThrow()).stream().anyMatch(candidate ->
|
||||
!area.contains(world.getUID(), candidate.x(), candidate.y(), candidate.z()))) {
|
||||
player.sendMessage(ChatColor.RED
|
||||
+ "The complete Pocket Base portal must be inside your base.");
|
||||
event.setCancelled(true);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
pocketBases.activateEntrance(player.getUniqueId(), portal.orElseThrow());
|
||||
} catch (IOException | RuntimeException exception) {
|
||||
logger.log(Level.SEVERE, "Could not activate Pocket Base entrance", exception);
|
||||
player.sendMessage(ChatColor.RED + "The Pocket Base portal could not be activated.");
|
||||
event.setCancelled(true);
|
||||
return;
|
||||
}
|
||||
event.setCancelled(true);
|
||||
player.sendMessage(ChatColor.GREEN + "Pocket Base portal activated.");
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void onBreak(BlockBreakEvent event) {
|
||||
BlockPosition broken = position(event.getBlock());
|
||||
UUID worldId = event.getBlock().getWorld().getUID();
|
||||
for (PocketBaseState state : pocketBases.knownStates().values()) {
|
||||
if (state.entrance().isPresent()) {
|
||||
PocketPortalLocation portal = state.entrance().orElseThrow();
|
||||
if (portal.worldId().equals(worldId)
|
||||
&& PocketPortalGeometry.isFrame(portal, broken)) {
|
||||
deactivate(state.ownerId());
|
||||
}
|
||||
}
|
||||
if (state.flightPortal().isPresent()) {
|
||||
PocketPortalLocation portal = state.flightPortal().orElseThrow();
|
||||
if (portal.worldId().equals(worldId)
|
||||
&& PocketPortalGeometry.isFrame(portal, broken)) {
|
||||
deactivateFlightPortal(state.ownerId());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true)
|
||||
public void onPlace(BlockPlaceEvent event) {
|
||||
Block block = event.getBlockPlaced();
|
||||
Optional<UUID> ownerId = pocketBases.ownerForPocketWorld(block.getWorld().getUID());
|
||||
if (ownerId.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
PocketBaseState state = pocketBases.state(ownerId.orElseThrow());
|
||||
if (!pocketBases.policy().contains(state.level(), block.getX(), block.getZ())) {
|
||||
event.setCancelled(true);
|
||||
event.getPlayer().sendMessage(ChatColor.RED
|
||||
+ "That block is outside the unlocked Pocket Base boundary.");
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void onMove(PlayerMoveEvent event) {
|
||||
Location destination = event.getTo();
|
||||
if (destination == null || onCooldown(event.getPlayer())) {
|
||||
return;
|
||||
}
|
||||
Player player = event.getPlayer();
|
||||
World world = destination.getWorld();
|
||||
if (world == null) {
|
||||
return;
|
||||
}
|
||||
Optional<UUID> pocketOwner = pocketBases.ownerForPocketWorld(world.getUID());
|
||||
if (pocketOwner.isPresent()) {
|
||||
UUID ownerId = pocketOwner.orElseThrow();
|
||||
if (destination.getY() < VOID_RETURN_Y
|
||||
|| pocketBases.returnPortalIsIntact(ownerId)
|
||||
&& PocketPortalGeometry.isInterior(
|
||||
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)
|
||||
)) {
|
||||
flightController.grantPocketPortalFlight(player, ownerId);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
BlockPosition position = position(destination);
|
||||
for (PocketBaseState state : pocketBases.knownStates().values()) {
|
||||
if (state.entrance().isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
PocketPortalLocation portal = state.entrance().orElseThrow();
|
||||
if (portal.worldId().equals(world.getUID())
|
||||
&& isIntact(portal)
|
||||
&& PocketPortalGeometry.isInterior(portal, position)) {
|
||||
teleport(player, pocketBases.pocketArrival(state.ownerId()));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true)
|
||||
public void onVoidDamage(EntityDamageEvent event) {
|
||||
Entity entity = event.getEntity();
|
||||
if (event.getCause() != EntityDamageEvent.DamageCause.VOID
|
||||
|| !(entity instanceof Player player)) {
|
||||
return;
|
||||
}
|
||||
Optional<UUID> owner = pocketBases.ownerForPocketWorld(player.getWorld().getUID());
|
||||
if (owner.isPresent()) {
|
||||
event.setCancelled(true);
|
||||
leavePocket(player, owner.orElseThrow());
|
||||
}
|
||||
}
|
||||
|
||||
boolean isPocketWorld(UUID worldId) {
|
||||
return pocketBases.ownerForPocketWorld(worldId).isPresent();
|
||||
}
|
||||
|
||||
void deactivateForRelocation(UUID ownerId) {
|
||||
deactivate(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(entity, outsidePortal(state.entrance().orElseThrow()));
|
||||
return;
|
||||
}
|
||||
World fallbackWorld = server.getWorlds().get(0);
|
||||
teleport(entity, fallbackWorld.getSpawnLocation());
|
||||
}
|
||||
|
||||
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(Entity entity) {
|
||||
return cooldownUntil.getOrDefault(entity.getUniqueId(), 0L) > System.nanoTime();
|
||||
}
|
||||
|
||||
private void validateEntrances() {
|
||||
for (PocketBaseState state : pocketBases.knownStates().values()) {
|
||||
if (state.entrance().isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
PlayerState owner = baseStates.knownPlayers().get(state.ownerId());
|
||||
PocketPortalLocation portal = state.entrance().orElseThrow();
|
||||
if (owner == null || owner.baseLevel() < 4 || owner.base().isEmpty()
|
||||
|| !isIntact(portal)
|
||||
|| PocketPortalGeometry.frameBlocks(portal).stream().anyMatch(position ->
|
||||
!baseBounds.area(owner).contains(
|
||||
portal.worldId(), position.x(), position.y(), position.z()
|
||||
))) {
|
||||
deactivate(state.ownerId());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isIntact(PocketPortalLocation portal) {
|
||||
Material frame = Material.valueOf(settings.current().pocketBasePortalFrameMaterial());
|
||||
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, frame)) {
|
||||
return;
|
||||
}
|
||||
for (BlockPosition position : PocketPortalGeometry.interiorBlocks(portal)) {
|
||||
world.spawnParticle(
|
||||
Particle.PORTAL,
|
||||
position.x() + 0.5,
|
||||
position.y() + 0.5,
|
||||
position.z() + 0.5,
|
||||
4,
|
||||
0.25,
|
||||
0.35,
|
||||
0.25,
|
||||
0.02
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
} catch (IOException exception) {
|
||||
logger.log(Level.SEVERE, "Could not deactivate Pocket Base entrance", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private Location outsidePortal(PocketPortalLocation portal) {
|
||||
World world = server.getWorld(portal.worldId());
|
||||
if (world == null) {
|
||||
return server.getWorlds().get(0).getSpawnLocation();
|
||||
}
|
||||
return portal.axis() == PocketPortalAxis.X
|
||||
? new Location(world, portal.x() + 2.0, portal.y() + 1.0, portal.z() + 1.5)
|
||||
: 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;
|
||||
}
|
||||
|
||||
private static Block block(World world, BlockPosition position) {
|
||||
return world.getBlockAt(position.x(), position.y(), position.z());
|
||||
}
|
||||
|
||||
private static BlockPosition position(Block block) {
|
||||
return new BlockPosition(block.getX(), block.getY(), block.getZ());
|
||||
}
|
||||
|
||||
private static BlockPosition position(Location location) {
|
||||
return new BlockPosition(location.getBlockX(), location.getBlockY(), location.getBlockZ());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
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.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, ignoreCancelled = true)
|
||||
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;
|
||||
}
|
||||
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
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
package games.dmg.spigotbase;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.World;
|
||||
|
||||
final class PocketBaseManager {
|
||||
private final PocketBaseStateManager states;
|
||||
private final PocketBaseWorldService worlds;
|
||||
private final PocketBasePolicy policy;
|
||||
|
||||
PocketBaseManager(
|
||||
PocketBaseStateManager states,
|
||||
PocketBaseWorldService worlds,
|
||||
PluginSettingsProvider settings
|
||||
) {
|
||||
this.states = states;
|
||||
this.worlds = worlds;
|
||||
this.policy = new PocketBasePolicy(settings);
|
||||
}
|
||||
|
||||
void loadWorlds() {
|
||||
worlds.loadExisting(states.knownStates());
|
||||
}
|
||||
|
||||
PocketBaseState state(UUID ownerId) {
|
||||
return states.state(ownerId);
|
||||
}
|
||||
|
||||
Map<UUID, PocketBaseState> knownStates() {
|
||||
return states.knownStates();
|
||||
}
|
||||
|
||||
PocketBaseState upgrade(UUID ownerId) throws IOException {
|
||||
PocketBaseState current = states.state(ownerId);
|
||||
int nextLevel = Math.addExact(current.level(), 1);
|
||||
policy.size(nextLevel);
|
||||
worlds.expand(ownerId, current.level(), nextLevel, current.biome());
|
||||
return states.updateAndSave(ownerId, state -> state.withLevel(nextLevel));
|
||||
}
|
||||
|
||||
PocketBaseState activateEntrance(
|
||||
UUID ownerId,
|
||||
PocketPortalLocation portal
|
||||
) throws IOException {
|
||||
PocketBaseState current = states.state(ownerId);
|
||||
if (current.level() < 1) {
|
||||
throw new IllegalStateException("Pocket Base I is still locked");
|
||||
}
|
||||
return states.updateAndSave(ownerId, state -> state.withEntrance(portal));
|
||||
}
|
||||
|
||||
void deactivateEntrance(UUID ownerId) throws IOException {
|
||||
PocketBaseState current = states.state(ownerId);
|
||||
if (current.entrance().isPresent()) {
|
||||
states.updateAndSave(ownerId, PocketBaseState::withoutEntrance);
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
) throws IOException {
|
||||
PocketBaseState current = states.state(ownerId);
|
||||
if (current.level() < 1) {
|
||||
throw new IllegalStateException("Pocket Base I is still locked");
|
||||
}
|
||||
return states.updateAndSave(ownerId, state -> state.withReturnPortal(portal));
|
||||
}
|
||||
|
||||
PocketBaseState setBiome(UUID ownerId, PocketBaseBiome biome) throws IOException {
|
||||
PocketBaseState current = states.state(ownerId);
|
||||
if (current.level() < 1) {
|
||||
throw new IllegalStateException("Pocket Base I is still locked");
|
||||
}
|
||||
if (current.biome() == biome) {
|
||||
throw new IllegalArgumentException("Pocket Base already uses that biome");
|
||||
}
|
||||
try {
|
||||
worlds.setBiome(ownerId, current.level(), biome);
|
||||
return states.updateAndSave(ownerId, state -> state.withBiome(biome));
|
||||
} catch (IOException | RuntimeException exception) {
|
||||
try {
|
||||
worlds.setBiome(ownerId, current.level(), current.biome());
|
||||
} catch (RuntimeException rollbackFailure) {
|
||||
exception.addSuppressed(rollbackFailure);
|
||||
}
|
||||
throw exception;
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
return setMobSpawning(
|
||||
current,
|
||||
enabled,
|
||||
current.passiveMobSpawningEnabled(),
|
||||
state -> state.withHostileMobSpawningEnabled(enabled)
|
||||
);
|
||||
}
|
||||
|
||||
PocketBaseState setPassiveMobSpawning(UUID ownerId, boolean enabled)
|
||||
throws IOException {
|
||||
PocketBaseState current = states.state(ownerId);
|
||||
return setMobSpawning(
|
||||
current,
|
||||
current.hostileMobSpawningEnabled(),
|
||||
enabled,
|
||||
state -> state.withPassiveMobSpawningEnabled(enabled)
|
||||
);
|
||||
}
|
||||
|
||||
private PocketBaseState setMobSpawning(
|
||||
PocketBaseState current,
|
||||
boolean hostileEnabled,
|
||||
boolean passiveEnabled,
|
||||
java.util.function.UnaryOperator<PocketBaseState> update
|
||||
) throws IOException {
|
||||
if (current.level() < 1) {
|
||||
throw new IllegalStateException("Pocket Base I is still locked");
|
||||
}
|
||||
UUID ownerId = current.ownerId();
|
||||
worlds.setMobSpawning(ownerId, hostileEnabled, passiveEnabled);
|
||||
try {
|
||||
return states.updateAndSave(ownerId, update);
|
||||
} catch (IOException | RuntimeException exception) {
|
||||
try {
|
||||
worlds.setMobSpawning(
|
||||
ownerId,
|
||||
current.hostileMobSpawningEnabled(),
|
||||
current.passiveMobSpawningEnabled()
|
||||
);
|
||||
} catch (RuntimeException rollbackFailure) {
|
||||
exception.addSuppressed(rollbackFailure);
|
||||
}
|
||||
throw exception;
|
||||
}
|
||||
}
|
||||
|
||||
Optional<UUID> ownerForPocketWorld(UUID worldId) {
|
||||
return worlds.ownerForWorld(worldId);
|
||||
}
|
||||
|
||||
Location pocketArrival(UUID ownerId) {
|
||||
return worlds.arrival(ownerId);
|
||||
}
|
||||
|
||||
PocketPortalLocation returnPortal(UUID ownerId) {
|
||||
return states.state(ownerId).returnPortal().orElseGet(
|
||||
() -> worlds.returnPortal(ownerId)
|
||||
);
|
||||
}
|
||||
|
||||
boolean returnPortalIsIntact(UUID ownerId) {
|
||||
return worlds.returnPortalIsIntact(ownerId, returnPortal(ownerId));
|
||||
}
|
||||
|
||||
World pocketWorld(UUID ownerId) {
|
||||
return worlds.world(ownerId);
|
||||
}
|
||||
|
||||
PocketBasePolicy policy() {
|
||||
return policy;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package games.dmg.spigotbase;
|
||||
|
||||
public final class PocketBasePolicy {
|
||||
private final PluginSettingsProvider settings;
|
||||
|
||||
public PocketBasePolicy(PluginSettings settings) {
|
||||
this(new PluginSettingsProvider(settings));
|
||||
}
|
||||
|
||||
public PocketBasePolicy(PluginSettingsProvider settings) {
|
||||
this.settings = settings;
|
||||
}
|
||||
|
||||
public int size(int level) {
|
||||
if (level < 1) {
|
||||
throw new IllegalArgumentException("Pocket Base level must be positive");
|
||||
}
|
||||
return Math.addExact(64, Math.multiplyExact(32, level - 1));
|
||||
}
|
||||
|
||||
public int price(PocketBaseState state) {
|
||||
return state.level() == 0
|
||||
? settings.current().pocketBaseUnlockCost()
|
||||
: settings.current().pocketBaseUpgradeCost();
|
||||
}
|
||||
|
||||
public boolean contains(int level, int x, int z) {
|
||||
int halfSize = size(level) / 2;
|
||||
return x >= -halfSize && x < halfSize && z >= -halfSize && z < halfSize;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package games.dmg.spigotbase;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
public record PocketBaseState(
|
||||
UUID ownerId,
|
||||
int level,
|
||||
Optional<PocketPortalLocation> entrance,
|
||||
Optional<PocketPortalLocation> returnPortal,
|
||||
boolean hostileMobSpawningEnabled,
|
||||
boolean passiveMobSpawningEnabled,
|
||||
PocketBaseBiome biome,
|
||||
boolean flightUnlocked,
|
||||
Optional<PocketPortalLocation> flightPortal,
|
||||
boolean flightEnabled
|
||||
) {
|
||||
public PocketBaseState {
|
||||
if (ownerId == null) {
|
||||
throw new IllegalArgumentException("owner ID is required");
|
||||
}
|
||||
if (level < 0) {
|
||||
throw new IllegalArgumentException("Pocket Base level must not be negative");
|
||||
}
|
||||
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()
|
||||
|| 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(
|
||||
UUID ownerId,
|
||||
int level,
|
||||
Optional<PocketPortalLocation> entrance
|
||||
) {
|
||||
this(
|
||||
ownerId, level, entrance, Optional.empty(), false, false,
|
||||
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, false, Optional.empty(), false
|
||||
);
|
||||
}
|
||||
|
||||
public PocketBaseState withLevel(int newLevel) {
|
||||
return copy(newLevel, entrance, returnPortal, hostileMobSpawningEnabled,
|
||||
passiveMobSpawningEnabled, biome, flightUnlocked, flightPortal, flightEnabled);
|
||||
}
|
||||
|
||||
public PocketBaseState withEntrance(PocketPortalLocation portal) {
|
||||
return copy(level, Optional.of(portal), returnPortal, hostileMobSpawningEnabled,
|
||||
passiveMobSpawningEnabled, biome, flightUnlocked, flightPortal, flightEnabled);
|
||||
}
|
||||
|
||||
public PocketBaseState withoutEntrance() {
|
||||
return copy(level, Optional.empty(), returnPortal, hostileMobSpawningEnabled,
|
||||
passiveMobSpawningEnabled, biome, flightUnlocked, flightPortal, flightEnabled);
|
||||
}
|
||||
|
||||
public PocketBaseState withReturnPortal(PocketPortalLocation portal) {
|
||||
return copy(level, entrance, Optional.of(portal), hostileMobSpawningEnabled,
|
||||
passiveMobSpawningEnabled, biome, flightUnlocked, flightPortal, flightEnabled);
|
||||
}
|
||||
|
||||
public PocketBaseState withHostileMobSpawningEnabled(boolean enabled) {
|
||||
return copy(level, entrance, returnPortal, enabled, passiveMobSpawningEnabled,
|
||||
biome, flightUnlocked, flightPortal, flightEnabled);
|
||||
}
|
||||
|
||||
public PocketBaseState withPassiveMobSpawningEnabled(boolean enabled) {
|
||||
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, newLevel, newEntrance, newReturnPortal, hostileEnabled, passiveEnabled,
|
||||
selectedBiome, unlockedFlight, newFlightPortal, enabledFlight
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package games.dmg.spigotbase;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.function.UnaryOperator;
|
||||
|
||||
final class PocketBaseStateManager {
|
||||
private final YamlPocketBaseRepository repository;
|
||||
private final Map<UUID, PocketBaseState> states;
|
||||
|
||||
PocketBaseStateManager(YamlPocketBaseRepository repository) throws IOException {
|
||||
this.repository = repository;
|
||||
this.states = new HashMap<>(repository.load());
|
||||
}
|
||||
|
||||
PocketBaseState state(UUID ownerId) {
|
||||
return states.getOrDefault(ownerId, PocketBaseState.locked(ownerId));
|
||||
}
|
||||
|
||||
Map<UUID, PocketBaseState> knownStates() {
|
||||
return Map.copyOf(states);
|
||||
}
|
||||
|
||||
PocketBaseState updateAndSave(
|
||||
UUID ownerId,
|
||||
UnaryOperator<PocketBaseState> operation
|
||||
) throws IOException {
|
||||
PocketBaseState updated = operation.apply(state(ownerId));
|
||||
if (!updated.ownerId().equals(ownerId)) {
|
||||
throw new IllegalArgumentException("Pocket Base owner ID cannot change");
|
||||
}
|
||||
Map<UUID, PocketBaseState> proposed = new HashMap<>(states);
|
||||
proposed.put(ownerId, updated);
|
||||
repository.save(proposed);
|
||||
states.clear();
|
||||
states.putAll(proposed);
|
||||
return updated;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
package games.dmg.spigotbase;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Function;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.Registry;
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.WorldCreator;
|
||||
import org.bukkit.block.Biome;
|
||||
|
||||
final class PocketBaseWorldService {
|
||||
private static final int PLATFORM_Y = 64;
|
||||
private static final String WORLD_PREFIX = "spigot_base_pocket_";
|
||||
private final Server server;
|
||||
private final PluginSettingsProvider settings;
|
||||
private final PocketBasePolicy policy;
|
||||
private final Function<PocketBaseBiome, Biome> biomeResolver;
|
||||
private final Map<UUID, UUID> ownersByWorld = new HashMap<>();
|
||||
|
||||
PocketBaseWorldService(Server server, PluginSettingsProvider settings) {
|
||||
this(server, settings, biome -> resolveBiome(server, biome));
|
||||
}
|
||||
|
||||
PocketBaseWorldService(
|
||||
Server server,
|
||||
PluginSettingsProvider settings,
|
||||
Function<PocketBaseBiome, Biome> biomeResolver
|
||||
) {
|
||||
this.server = server;
|
||||
this.settings = settings;
|
||||
this.policy = new PocketBasePolicy(settings);
|
||||
this.biomeResolver = biomeResolver;
|
||||
}
|
||||
|
||||
void loadExisting(Map<UUID, PocketBaseState> states) {
|
||||
for (PocketBaseState state : states.values()) {
|
||||
if (state.level() > 0) {
|
||||
setBiome(state.ownerId(), state.level(), state.biome());
|
||||
setMobSpawning(
|
||||
state.ownerId(),
|
||||
state.hostileMobSpawningEnabled(),
|
||||
state.passiveMobSpawningEnabled()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
World expand(UUID ownerId, int previousLevel, int newLevel) {
|
||||
return expand(ownerId, previousLevel, newLevel, PocketBaseBiome.THE_VOID);
|
||||
}
|
||||
|
||||
World expand(
|
||||
UUID ownerId,
|
||||
int previousLevel,
|
||||
int newLevel,
|
||||
PocketBaseBiome biome
|
||||
) {
|
||||
World world = ensureWorld(ownerId);
|
||||
int newHalfSize = policy.size(newLevel) / 2;
|
||||
int previousHalfSize = previousLevel == 0 ? 0 : policy.size(previousLevel) / 2;
|
||||
for (int x = -newHalfSize; x < newHalfSize; x++) {
|
||||
for (int z = -newHalfSize; z < newHalfSize; z++) {
|
||||
boolean previouslyUnlocked = previousLevel > 0
|
||||
&& x >= -previousHalfSize && x < previousHalfSize
|
||||
&& z >= -previousHalfSize && z < previousHalfSize;
|
||||
if (previouslyUnlocked) {
|
||||
continue;
|
||||
}
|
||||
for (int y = PLATFORM_Y - 3; y < PLATFORM_Y; y++) {
|
||||
world.getBlockAt(x, y, z).setType(Material.DIRT, false);
|
||||
}
|
||||
world.getBlockAt(x, PLATFORM_Y, z).setType(Material.GRASS_BLOCK, false);
|
||||
}
|
||||
}
|
||||
applyBiome(world, previousLevel, newLevel, biome);
|
||||
if (previousLevel == 0) {
|
||||
createReturnPortal(world);
|
||||
world.setSpawnLocation(0, PLATFORM_Y + 1, 5);
|
||||
}
|
||||
world.save();
|
||||
return world;
|
||||
}
|
||||
|
||||
Optional<UUID> ownerForWorld(UUID worldId) {
|
||||
return Optional.ofNullable(ownersByWorld.get(worldId));
|
||||
}
|
||||
|
||||
World world(UUID ownerId) {
|
||||
return ensureWorld(ownerId);
|
||||
}
|
||||
|
||||
void setMobSpawning(
|
||||
UUID ownerId,
|
||||
boolean hostileEnabled,
|
||||
boolean passiveEnabled
|
||||
) {
|
||||
ensureWorld(ownerId).setSpawnFlags(hostileEnabled, passiveEnabled);
|
||||
}
|
||||
|
||||
void setBiome(UUID ownerId, int level, PocketBaseBiome biome) {
|
||||
World world = ensureWorld(ownerId);
|
||||
applyBiome(world, 0, level, biome);
|
||||
world.save();
|
||||
}
|
||||
|
||||
Location arrival(UUID ownerId) {
|
||||
return new Location(ensureWorld(ownerId), 0.5, PLATFORM_Y + 1, 5.5, 180.0f, 0.0f);
|
||||
}
|
||||
|
||||
PocketPortalLocation returnPortal(UUID ownerId) {
|
||||
World world = ensureWorld(ownerId);
|
||||
return new PocketPortalLocation(
|
||||
world.getUID(), world.getName(), -2, PLATFORM_Y + 1, 0, PocketPortalAxis.X
|
||||
);
|
||||
}
|
||||
|
||||
boolean returnPortalIsIntact(UUID ownerId, PocketPortalLocation portal) {
|
||||
World world = ensureWorld(ownerId);
|
||||
if (!portal.worldId().equals(world.getUID())) {
|
||||
return false;
|
||||
}
|
||||
Material frame = Material.valueOf(settings.current().pocketBasePortalFrameMaterial());
|
||||
return PocketPortalGeometry.frameBlocks(portal).stream()
|
||||
.allMatch(position -> world.getBlockAt(
|
||||
position.x(), position.y(), position.z()
|
||||
).getType() == frame);
|
||||
}
|
||||
|
||||
private World ensureWorld(UUID ownerId) {
|
||||
String name = worldName(ownerId);
|
||||
World world = server.getWorld(name);
|
||||
boolean created = world == null;
|
||||
if (created) {
|
||||
WorldCreator creator = new WorldCreator(NamespacedKey.minecraft(name))
|
||||
.environment(World.Environment.NORMAL)
|
||||
.generator(new VoidPocketChunkGenerator())
|
||||
.generateStructures(false);
|
||||
world = server.createWorld(creator);
|
||||
}
|
||||
if (world == null) {
|
||||
throw new IllegalStateException("Could not create Pocket Base world " + name);
|
||||
}
|
||||
if (created) {
|
||||
world.setSpawnFlags(false, false);
|
||||
}
|
||||
ownersByWorld.put(world.getUID(), ownerId);
|
||||
return world;
|
||||
}
|
||||
|
||||
private void applyBiome(
|
||||
World world,
|
||||
int previousLevel,
|
||||
int newLevel,
|
||||
PocketBaseBiome selectedBiome
|
||||
) {
|
||||
int newHalfSize = policy.size(newLevel) / 2;
|
||||
int previousHalfSize = previousLevel == 0 ? 0 : policy.size(previousLevel) / 2;
|
||||
Biome biome = biomeResolver.apply(selectedBiome);
|
||||
for (int x = -newHalfSize; x < newHalfSize; x += 4) {
|
||||
for (int z = -newHalfSize; z < newHalfSize; z += 4) {
|
||||
boolean previouslyUnlocked = previousLevel > 0
|
||||
&& x >= -previousHalfSize && x < previousHalfSize
|
||||
&& z >= -previousHalfSize && z < previousHalfSize;
|
||||
if (previouslyUnlocked) {
|
||||
continue;
|
||||
}
|
||||
for (int y = world.getMinHeight(); y < world.getMaxHeight(); y += 4) {
|
||||
world.setBiome(x, y, z, biome);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static Biome resolveBiome(Server server, PocketBaseBiome selectedBiome) {
|
||||
Registry<Biome> biomeRegistry = server.getRegistry(Biome.class);
|
||||
if (biomeRegistry == null) {
|
||||
throw new IllegalStateException("Biome registry is unavailable");
|
||||
}
|
||||
return biomeRegistry.getOrThrow(
|
||||
NamespacedKey.minecraft(selectedBiome.commandName())
|
||||
);
|
||||
}
|
||||
|
||||
private void createReturnPortal(World world) {
|
||||
PocketPortalLocation portal = new PocketPortalLocation(
|
||||
world.getUID(), world.getName(), -2, PLATFORM_Y + 1, 0, PocketPortalAxis.X
|
||||
);
|
||||
Material frame = Material.valueOf(settings.current().pocketBasePortalFrameMaterial());
|
||||
for (BlockPosition position : PocketPortalGeometry.frameBlocks(portal)) {
|
||||
world.getBlockAt(position.x(), position.y(), position.z()).setType(frame, false);
|
||||
}
|
||||
}
|
||||
|
||||
private static String worldName(UUID ownerId) {
|
||||
return WORLD_PREFIX + ownerId.toString().replace("-", "");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package games.dmg.spigotbase;
|
||||
|
||||
import java.util.Locale;
|
||||
import java.util.Optional;
|
||||
|
||||
public enum PocketBaseWorldType {
|
||||
VOID,
|
||||
NETHER,
|
||||
OVERWORLD;
|
||||
|
||||
public String commandName() {
|
||||
return name().toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
static Optional<PocketBaseWorldType> fromCommand(String value) {
|
||||
try {
|
||||
return Optional.of(valueOf(value.toUpperCase(Locale.ROOT)));
|
||||
} catch (IllegalArgumentException exception) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package games.dmg.spigotbase;
|
||||
|
||||
public enum PocketPortalAxis {
|
||||
X,
|
||||
Z
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package games.dmg.spigotbase;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Predicate;
|
||||
|
||||
public final class PocketPortalGeometry {
|
||||
private PocketPortalGeometry() {
|
||||
}
|
||||
|
||||
public static Optional<PocketPortalLocation> findFrame(
|
||||
BlockPosition clicked,
|
||||
UUID worldId,
|
||||
String worldName,
|
||||
Predicate<BlockPosition> isFrameMaterial,
|
||||
Predicate<BlockPosition> isOpenInterior
|
||||
) {
|
||||
for (PocketPortalAxis axis : PocketPortalAxis.values()) {
|
||||
int clickedHorizontal = axis == PocketPortalAxis.X ? clicked.x() : clicked.z();
|
||||
for (int horizontalOffset = 0; horizontalOffset < 4; horizontalOffset++) {
|
||||
for (int verticalOffset = 0; verticalOffset < 5; verticalOffset++) {
|
||||
int originHorizontal = clickedHorizontal - horizontalOffset;
|
||||
int originY = clicked.y() - verticalOffset;
|
||||
PocketPortalLocation candidate = axis == PocketPortalAxis.X
|
||||
? new PocketPortalLocation(
|
||||
worldId, worldName, originHorizontal, originY, clicked.z(), axis
|
||||
)
|
||||
: new PocketPortalLocation(
|
||||
worldId, worldName, clicked.x(), originY, originHorizontal, axis
|
||||
);
|
||||
if (frameBlocks(candidate).stream().allMatch(isFrameMaterial)
|
||||
&& interiorBlocks(candidate).stream().allMatch(isOpenInterior)) {
|
||||
return Optional.of(candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
public static List<BlockPosition> frameBlocks(PocketPortalLocation portal) {
|
||||
List<BlockPosition> blocks = new ArrayList<>(14);
|
||||
for (int horizontal = 0; horizontal < 4; horizontal++) {
|
||||
blocks.add(position(portal, horizontal, 0));
|
||||
blocks.add(position(portal, horizontal, 4));
|
||||
}
|
||||
for (int vertical = 1; vertical < 4; vertical++) {
|
||||
blocks.add(position(portal, 0, vertical));
|
||||
blocks.add(position(portal, 3, vertical));
|
||||
}
|
||||
return List.copyOf(blocks);
|
||||
}
|
||||
|
||||
public static List<BlockPosition> interiorBlocks(PocketPortalLocation portal) {
|
||||
List<BlockPosition> blocks = new ArrayList<>(6);
|
||||
for (int horizontal = 1; horizontal < 3; horizontal++) {
|
||||
for (int vertical = 1; vertical < 4; vertical++) {
|
||||
blocks.add(position(portal, horizontal, vertical));
|
||||
}
|
||||
}
|
||||
return List.copyOf(blocks);
|
||||
}
|
||||
|
||||
public static boolean isFrame(PocketPortalLocation portal, BlockPosition position) {
|
||||
return frameBlocks(portal).contains(position);
|
||||
}
|
||||
|
||||
public static boolean isInterior(PocketPortalLocation portal, BlockPosition position) {
|
||||
return interiorBlocks(portal).contains(position);
|
||||
}
|
||||
|
||||
private static BlockPosition position(
|
||||
PocketPortalLocation portal,
|
||||
int horizontal,
|
||||
int vertical
|
||||
) {
|
||||
return portal.axis() == PocketPortalAxis.X
|
||||
? new BlockPosition(portal.x() + horizontal, portal.y() + vertical, portal.z())
|
||||
: new BlockPosition(portal.x(), portal.y() + vertical, portal.z() + horizontal);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package games.dmg.spigotbase;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public record PocketPortalLocation(
|
||||
UUID worldId,
|
||||
String worldName,
|
||||
int x,
|
||||
int y,
|
||||
int z,
|
||||
PocketPortalAxis axis
|
||||
) {
|
||||
public PocketPortalLocation {
|
||||
if (worldId == null) {
|
||||
throw new IllegalArgumentException("portal world ID is required");
|
||||
}
|
||||
if (worldName == null || worldName.isBlank()) {
|
||||
throw new IllegalArgumentException("portal world name is required");
|
||||
}
|
||||
if (axis == null) {
|
||||
throw new IllegalArgumentException("portal axis is required");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,11 +16,22 @@ final class SetBaseCommand implements CommandExecutor {
|
||||
private final BaseStateManager stateManager;
|
||||
private final BaseService baseService;
|
||||
private final Clock clock;
|
||||
private final PocketBaseController pocketBaseController;
|
||||
|
||||
SetBaseCommand(BaseStateManager stateManager, BaseService baseService, Clock clock) {
|
||||
this(stateManager, baseService, clock, null);
|
||||
}
|
||||
|
||||
SetBaseCommand(
|
||||
BaseStateManager stateManager,
|
||||
BaseService baseService,
|
||||
Clock clock,
|
||||
PocketBaseController pocketBaseController
|
||||
) {
|
||||
this.stateManager = stateManager;
|
||||
this.baseService = baseService;
|
||||
this.clock = clock;
|
||||
this.pocketBaseController = pocketBaseController;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -47,6 +58,10 @@ final class SetBaseCommand implements CommandExecutor {
|
||||
player.sendMessage(ChatColor.RED + "Your current world is unavailable.");
|
||||
return true;
|
||||
}
|
||||
if (pocketBaseController != null && pocketBaseController.isPocketWorld(world.getUID())) {
|
||||
player.sendMessage(ChatColor.RED + "You cannot set your normal base inside a Pocket Base.");
|
||||
return true;
|
||||
}
|
||||
BaseLocation base = new BaseLocation(
|
||||
world.getUID(), world.getName(), location.getBlockX(), location.getBlockY(),
|
||||
location.getBlockZ(), location.getYaw(), location.getPitch()
|
||||
@@ -55,6 +70,9 @@ final class SetBaseCommand implements CommandExecutor {
|
||||
player.getUniqueId(), player.getName(), current -> baseService.setBase(current, base, now)
|
||||
);
|
||||
stateManager.saveIfDirty();
|
||||
if (pocketBaseController != null) {
|
||||
pocketBaseController.deactivateForRelocation(player.getUniqueId());
|
||||
}
|
||||
player.sendMessage(ChatColor.GREEN + "Base set at " + base.x() + ", " + base.y() + ", " + base.z() + ".");
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -14,6 +15,9 @@ public final class SpigotBasePlugin extends JavaPlugin {
|
||||
private BaseProgressListener progressListener;
|
||||
private BaseFlightController flightController;
|
||||
private BaseTeleportManager teleportManager;
|
||||
private PocketBaseManager pocketBaseManager;
|
||||
private PocketBaseController pocketBaseController;
|
||||
private PocketBaseKeystoneService keystoneService;
|
||||
|
||||
@Override
|
||||
public void onEnable() {
|
||||
@@ -27,7 +31,29 @@ public final class SpigotBasePlugin extends JavaPlugin {
|
||||
new YamlBaseStateRepository(getDataFolder().toPath().resolve("state.yml")),
|
||||
getLogger()
|
||||
);
|
||||
} catch (IllegalArgumentException | IOException exception) {
|
||||
PocketBaseStateManager pocketStates = new PocketBaseStateManager(
|
||||
new YamlPocketBaseRepository(
|
||||
getDataFolder().toPath().resolve("pocket-bases.yml")
|
||||
)
|
||||
);
|
||||
pocketBaseManager = new PocketBaseManager(
|
||||
pocketStates,
|
||||
new PocketBaseWorldService(getServer(), settingsProvider),
|
||||
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);
|
||||
return;
|
||||
@@ -51,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(
|
||||
@@ -62,15 +89,30 @@ public final class SpigotBasePlugin extends JavaPlugin {
|
||||
new SafeBaseDestination(),
|
||||
Clock.systemUTC()
|
||||
);
|
||||
pocketBaseController = new PocketBaseController(
|
||||
getServer(),
|
||||
stateManager,
|
||||
boundsService,
|
||||
pocketBaseManager,
|
||||
settingsProvider,
|
||||
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()));
|
||||
command("setbase").setExecutor(new SetBaseCommand(
|
||||
stateManager, baseService, Clock.systemUTC(), pocketBaseController
|
||||
));
|
||||
command("base").setExecutor(new BaseCommand(teleportManager));
|
||||
BaseSettingsCommand settingsCommand = new BaseSettingsCommand(
|
||||
stateManager,
|
||||
settingsProvider,
|
||||
flightController
|
||||
flightController,
|
||||
pocketBaseManager
|
||||
);
|
||||
command("basesettings").setExecutor(settingsCommand);
|
||||
command("basesettings").setTabCompleter(settingsCommand);
|
||||
@@ -107,6 +149,7 @@ public final class SpigotBasePlugin extends JavaPlugin {
|
||||
10L
|
||||
);
|
||||
getServer().getScheduler().runTaskTimer(this, flightController, 5L, 5L);
|
||||
getServer().getScheduler().runTaskTimer(this, pocketBaseController, 5L, 5L);
|
||||
getServer().getScheduler().runTaskTimer(this, stateManager::saveIfDirty, 600L, 600L);
|
||||
getLogger().info("Spigot Base enabled.");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package games.dmg.spigotbase;
|
||||
|
||||
import org.bukkit.generator.ChunkGenerator;
|
||||
|
||||
final class VoidPocketChunkGenerator extends ChunkGenerator {
|
||||
@Override
|
||||
public boolean shouldGenerateNoise() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean shouldGenerateSurface() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean shouldGenerateCaves() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean shouldGenerateDecorations() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean shouldGenerateMobs() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean shouldGenerateStructures() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
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.Optional;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.configuration.ConfigurationSection;
|
||||
import org.bukkit.configuration.InvalidConfigurationException;
|
||||
import org.bukkit.configuration.file.YamlConfiguration;
|
||||
|
||||
public final class YamlPocketBaseRepository {
|
||||
private final Path stateFile;
|
||||
|
||||
public YamlPocketBaseRepository(Path stateFile) {
|
||||
this.stateFile = stateFile;
|
||||
}
|
||||
|
||||
public Map<UUID, PocketBaseState> 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 state is not valid YAML", exception);
|
||||
}
|
||||
Map<UUID, PocketBaseState> states = new HashMap<>();
|
||||
ConfigurationSection owners = yaml.getConfigurationSection("owners");
|
||||
if (owners == null) {
|
||||
return states;
|
||||
}
|
||||
for (String key : owners.getKeys(false)) {
|
||||
try {
|
||||
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,
|
||||
loadPortal(yaml, path + ".entrance"),
|
||||
loadPortal(yaml, path + ".return-portal"),
|
||||
loadMobPreference(yaml, path, "hostile-mob-spawning-enabled"),
|
||||
loadMobPreference(yaml, path, "passive-mob-spawning-enabled"),
|
||||
loadBiome(yaml, path),
|
||||
flightUnlocked,
|
||||
loadPortal(yaml, path + ".flight-portal"),
|
||||
yaml.getBoolean(path + ".flight-enabled", flightUnlocked)
|
||||
);
|
||||
states.put(ownerId, state);
|
||||
} catch (IllegalArgumentException ignored) {
|
||||
// Invalid records grant no Pocket Base progression.
|
||||
}
|
||||
}
|
||||
return states;
|
||||
}
|
||||
|
||||
public void save(Map<UUID, PocketBaseState> states) throws IOException {
|
||||
Path parent = stateFile.toAbsolutePath().getParent();
|
||||
if (parent != null) {
|
||||
Files.createDirectories(parent);
|
||||
}
|
||||
YamlConfiguration yaml = new YamlConfiguration();
|
||||
for (PocketBaseState state : states.values()) {
|
||||
String path = "owners." + state.ownerId();
|
||||
yaml.set(path + ".level", state.level());
|
||||
yaml.set(
|
||||
path + ".hostile-mob-spawning-enabled",
|
||||
state.hostileMobSpawningEnabled()
|
||||
);
|
||||
yaml.set(
|
||||
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 {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean loadMobPreference(
|
||||
YamlConfiguration yaml,
|
||||
String path,
|
||||
String categoryKey
|
||||
) {
|
||||
return yaml.getBoolean(
|
||||
path + "." + categoryKey,
|
||||
yaml.getBoolean(path + ".mob-spawning-enabled", false)
|
||||
);
|
||||
}
|
||||
|
||||
private static PocketBaseBiome loadBiome(YamlConfiguration yaml, String path) {
|
||||
String worldTypeName = yaml.getString(path + ".world-type");
|
||||
String biomeName = yaml.getString(path + ".biome");
|
||||
if (worldTypeName == null || biomeName == null) {
|
||||
return PocketBaseBiome.THE_VOID;
|
||||
}
|
||||
return PocketBaseWorldType.fromCommand(worldTypeName)
|
||||
.flatMap(worldType -> PocketBaseBiome.fromCommand(worldType, biomeName))
|
||||
.orElse(PocketBaseBiome.THE_VOID);
|
||||
}
|
||||
|
||||
private static Optional<PocketPortalLocation> loadPortal(
|
||||
YamlConfiguration yaml,
|
||||
String path
|
||||
) {
|
||||
if (!yaml.isConfigurationSection(path)) {
|
||||
return Optional.empty();
|
||||
}
|
||||
String worldId = yaml.getString(path + ".world-id");
|
||||
String worldName = yaml.getString(path + ".world-name");
|
||||
String axis = yaml.getString(path + ".axis");
|
||||
if (worldId == null || worldName == null || axis == null
|
||||
|| !yaml.isInt(path + ".x") || !yaml.isInt(path + ".y")
|
||||
|| !yaml.isInt(path + ".z")) {
|
||||
throw new IllegalArgumentException("invalid Pocket Base portal");
|
||||
}
|
||||
return Optional.of(new PocketPortalLocation(
|
||||
UUID.fromString(worldId),
|
||||
worldName,
|
||||
yaml.getInt(path + ".x"),
|
||||
yaml.getInt(path + ".y"),
|
||||
yaml.getInt(path + ".z"),
|
||||
PocketPortalAxis.valueOf(axis)
|
||||
));
|
||||
}
|
||||
|
||||
private static void savePortal(
|
||||
YamlConfiguration yaml,
|
||||
String path,
|
||||
PocketPortalLocation portal
|
||||
) {
|
||||
yaml.set(path + ".world-id", portal.worldId().toString());
|
||||
yaml.set(path + ".world-name", portal.worldName());
|
||||
yaml.set(path + ".x", portal.x());
|
||||
yaml.set(path + ".y", portal.y());
|
||||
yaml.set(path + ".z", portal.z());
|
||||
yaml.set(path + ".axis", portal.axis().name());
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,14 @@ fourth-teleport-cooldown-seconds: 1800
|
||||
visitor-unlock-diamond-cost: 128
|
||||
visitor-currency-material: DIAMOND
|
||||
|
||||
# Pocket Base
|
||||
pocket-base-unlock-cost: 64
|
||||
pocket-base-upgrade-cost: 16
|
||||
pocket-base-currency-material: DIAMOND_BLOCK
|
||||
pocket-base-biome-change-cost: 16
|
||||
pocket-base-biome-currency-material: NETHERITE_BLOCK
|
||||
pocket-base-portal-frame-material: DIAMOND_BLOCK
|
||||
|
||||
# Spawnable block overlay
|
||||
spawnable-overlay-unlock-placements: 250
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ commands:
|
||||
aliases: [visit]
|
||||
basesettings:
|
||||
description: View progression, upgrade, and manage base settings.
|
||||
usage: /basesettings [status|upgrade|visitors|navigation|flight|border|spawnable|bossbar]
|
||||
usage: /basesettings [ui|status|upgrade|pocket|visitors|navigation|flight|border|spawnable|bossbar]
|
||||
aliases: [homesettings]
|
||||
baseadmin:
|
||||
description: Administer Spigot Base.
|
||||
|
||||
@@ -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,335 @@ 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()))
|
||||
);
|
||||
|
||||
pocketController.grantPocketPortalFlight(player, ownerId);
|
||||
pocketController.run();
|
||||
|
||||
verify(player).setAllowFlight(true);
|
||||
}
|
||||
|
||||
@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);
|
||||
|
||||
@@ -6,24 +6,44 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
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;
|
||||
import org.bukkit.ChatColor;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.PlayerInventory;
|
||||
import net.kyori.adventure.dialog.DialogLike;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
final class BaseSettingsCommandTest {
|
||||
@Test
|
||||
void rejectsNonPlayerExecution() {
|
||||
CommandSender sender = mock(CommandSender.class);
|
||||
BaseSettingsCommand command = new BaseSettingsCommand(
|
||||
mock(BaseStateManager.class),
|
||||
new PluginSettingsProvider(PluginSettings.from(Map.of())),
|
||||
mock(BaseFlightController.class)
|
||||
);
|
||||
|
||||
command.onCommand(sender, null, "basesettings", new String[0]);
|
||||
|
||||
verify(sender).sendMessage("Only players have base progression.");
|
||||
}
|
||||
|
||||
@Test
|
||||
void spawnableEnableIsIdempotentAfterUnlock() {
|
||||
UUID playerId = UUID.randomUUID();
|
||||
@@ -89,6 +109,62 @@ final class BaseSettingsCommandTest {
|
||||
List.of("allowed"),
|
||||
command.onTabComplete(null, null, "basesettings", new String[] {"visitors", "a"})
|
||||
);
|
||||
assertEquals(
|
||||
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"})
|
||||
);
|
||||
assertEquals(
|
||||
List.of("type"),
|
||||
command.onTabComplete(null, null, "basesettings", new String[] {"pocket", "t"})
|
||||
);
|
||||
assertEquals(
|
||||
List.of("nether"),
|
||||
command.onTabComplete(
|
||||
null, null, "basesettings", new String[] {"pocket", "type", "n"}
|
||||
)
|
||||
);
|
||||
assertEquals(
|
||||
List.of("crimson_forest"),
|
||||
command.onTabComplete(
|
||||
null,
|
||||
null,
|
||||
"basesettings",
|
||||
new String[] {"pocket", "type", "nether", "c"}
|
||||
)
|
||||
);
|
||||
assertEquals(
|
||||
List.of("hostile"),
|
||||
command.onTabComplete(
|
||||
null, null, "basesettings", new String[] {"pocket", "mobs", "h"}
|
||||
)
|
||||
);
|
||||
assertEquals(
|
||||
List.of("passive"),
|
||||
command.onTabComplete(
|
||||
null, null, "basesettings", new String[] {"pocket", "mobs", "p"}
|
||||
)
|
||||
);
|
||||
assertEquals(
|
||||
List.of("enable"),
|
||||
command.onTabComplete(
|
||||
null, null, "basesettings",
|
||||
new String[] {"pocket", "mobs", "hostile", "e"}
|
||||
)
|
||||
);
|
||||
for (String setting : List.of("navigation", "flight", "border", "spawnable", "bossbar")) {
|
||||
assertEquals(
|
||||
List.of("disable"),
|
||||
@@ -107,8 +183,8 @@ final class BaseSettingsCommandTest {
|
||||
|
||||
assertEquals(
|
||||
List.of(
|
||||
"status", "upgrade", "visitors", "navigation", "flight", "border",
|
||||
"spawnable", "bossbar"
|
||||
"ui", "status", "upgrade", "pocket", "visitors", "navigation", "flight",
|
||||
"border", "spawnable", "bossbar"
|
||||
),
|
||||
command.onTabComplete(null, null, "basesettings", new String[] {""})
|
||||
);
|
||||
@@ -121,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")
|
||||
@@ -155,6 +233,312 @@ final class BaseSettingsCommandTest {
|
||||
assertTrue(updated.get().visitorsEnabled());
|
||||
}
|
||||
|
||||
@Test
|
||||
void pocketUpgradePurchasesFirstLevelAfterBaseIv() throws Exception {
|
||||
UUID playerId = UUID.randomUUID();
|
||||
Player player = mock(Player.class);
|
||||
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[] {currency});
|
||||
PlayerState owner = PlayerState.newPlayer(playerId, "Builder")
|
||||
.withAdministrativeLevels(4, 0, 0, 0, 0, false, false, true)
|
||||
.withBase(
|
||||
new BaseLocation(UUID.randomUUID(), "world", 0, 64, 0, 0, 0),
|
||||
Instant.EPOCH
|
||||
);
|
||||
BaseStateManager stateManager = mock(BaseStateManager.class);
|
||||
when(stateManager.player(playerId, "Builder")).thenReturn(owner);
|
||||
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
|
||||
when(pocketBases.state(playerId)).thenReturn(PocketBaseState.locked(playerId));
|
||||
when(pocketBases.upgrade(playerId)).thenReturn(
|
||||
new PocketBaseState(playerId, 1, java.util.Optional.empty())
|
||||
);
|
||||
BaseSettingsCommand command = new BaseSettingsCommand(
|
||||
stateManager,
|
||||
new PluginSettingsProvider(PluginSettings.from(Map.of())),
|
||||
mock(BaseFlightController.class),
|
||||
pocketBases
|
||||
);
|
||||
|
||||
command.onCommand(player, null, "basesettings", new String[] {"pocket", "upgrade"});
|
||||
|
||||
verify(pocketBases).upgrade(playerId);
|
||||
verify(inventory).setStorageContents(any(ItemStack[].class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void pocketOwnerCanPurchaseBiomeChangeWithConfiguredCurrency() throws Exception {
|
||||
UUID playerId = UUID.randomUUID();
|
||||
Player player = mock(Player.class);
|
||||
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[] {currency});
|
||||
BaseStateManager stateManager = mock(BaseStateManager.class);
|
||||
when(stateManager.player(playerId, "Builder"))
|
||||
.thenReturn(PlayerState.newPlayer(playerId, "Builder"));
|
||||
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
|
||||
PocketBaseState current = new PocketBaseState(playerId, 1, java.util.Optional.empty());
|
||||
when(pocketBases.state(playerId)).thenReturn(current);
|
||||
when(pocketBases.setBiome(playerId, PocketBaseBiome.CRIMSON_FOREST))
|
||||
.thenReturn(current.withBiome(PocketBaseBiome.CRIMSON_FOREST));
|
||||
BaseSettingsCommand command = new BaseSettingsCommand(
|
||||
stateManager,
|
||||
new PluginSettingsProvider(PluginSettings.from(Map.of())),
|
||||
mock(BaseFlightController.class),
|
||||
pocketBases
|
||||
);
|
||||
|
||||
command.onCommand(player, null, "basesettings", new String[] {
|
||||
"pocket", "type", "nether", "crimson_forest"
|
||||
});
|
||||
|
||||
verify(pocketBases).setBiome(playerId, PocketBaseBiome.CRIMSON_FOREST);
|
||||
verify(inventory).setStorageContents(any(ItemStack[].class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void selectingCurrentPocketBiomeDoesNotCharge() throws Exception {
|
||||
UUID playerId = UUID.randomUUID();
|
||||
Player player = mock(Player.class);
|
||||
when(player.getUniqueId()).thenReturn(playerId);
|
||||
when(player.getName()).thenReturn("Builder");
|
||||
PlayerInventory inventory = mock(PlayerInventory.class);
|
||||
when(player.getInventory()).thenReturn(inventory);
|
||||
BaseStateManager stateManager = mock(BaseStateManager.class);
|
||||
when(stateManager.player(playerId, "Builder"))
|
||||
.thenReturn(PlayerState.newPlayer(playerId, "Builder"));
|
||||
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
|
||||
when(pocketBases.state(playerId)).thenReturn(
|
||||
new PocketBaseState(playerId, 1, java.util.Optional.empty())
|
||||
);
|
||||
BaseSettingsCommand command = new BaseSettingsCommand(
|
||||
stateManager,
|
||||
new PluginSettingsProvider(PluginSettings.from(Map.of())),
|
||||
mock(BaseFlightController.class),
|
||||
pocketBases
|
||||
);
|
||||
|
||||
command.onCommand(player, null, "basesettings", new String[] {
|
||||
"pocket", "type", "void", "the_void"
|
||||
});
|
||||
|
||||
verify(pocketBases, never()).setBiome(
|
||||
org.mockito.ArgumentMatchers.eq(playerId),
|
||||
org.mockito.ArgumentMatchers.any(PocketBaseBiome.class)
|
||||
);
|
||||
verify(inventory, never()).setStorageContents(any(ItemStack[].class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void pocketOwnerCanEnableHostileMobSpawningWithoutChangingPassive() 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"));
|
||||
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
|
||||
PocketBaseState current = new PocketBaseState(
|
||||
playerId, 1, java.util.Optional.empty(), java.util.Optional.empty(),
|
||||
false, true, PocketBaseBiome.THE_VOID
|
||||
);
|
||||
when(pocketBases.state(playerId)).thenReturn(current);
|
||||
when(pocketBases.setHostileMobSpawning(playerId, true)).thenReturn(
|
||||
current.withHostileMobSpawningEnabled(true)
|
||||
);
|
||||
BaseSettingsCommand command = new BaseSettingsCommand(
|
||||
stateManager,
|
||||
new PluginSettingsProvider(PluginSettings.from(Map.of())),
|
||||
mock(BaseFlightController.class),
|
||||
pocketBases
|
||||
);
|
||||
|
||||
command.onCommand(player, null, "basesettings", new String[] {
|
||||
"pocket", "mobs", "hostile", "enable"
|
||||
});
|
||||
|
||||
verify(pocketBases).setHostileMobSpawning(playerId, true);
|
||||
verify(pocketBases, never()).setPassiveMobSpawning(playerId, true);
|
||||
verify(player).sendMessage(org.mockito.ArgumentMatchers.<String>argThat(
|
||||
message -> message.contains("hostile mob spawning") && message.contains("enabled")
|
||||
));
|
||||
}
|
||||
|
||||
@Test
|
||||
void failedPocketMobSpawningChangeReportsOnlyFailure() 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"));
|
||||
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
|
||||
when(pocketBases.state(playerId)).thenReturn(
|
||||
new PocketBaseState(playerId, 1, java.util.Optional.empty())
|
||||
);
|
||||
when(pocketBases.setPassiveMobSpawning(playerId, true))
|
||||
.thenThrow(new IOException("save failed"));
|
||||
BaseSettingsCommand command = new BaseSettingsCommand(
|
||||
stateManager,
|
||||
new PluginSettingsProvider(PluginSettings.from(Map.of())),
|
||||
mock(BaseFlightController.class),
|
||||
pocketBases
|
||||
);
|
||||
|
||||
command.onCommand(player, null, "basesettings", new String[] {
|
||||
"pocket", "mobs", "passive", "enable"
|
||||
});
|
||||
|
||||
verify(player).sendMessage(org.mockito.ArgumentMatchers.<String>argThat(
|
||||
message -> message.contains("could not be changed")
|
||||
));
|
||||
verify(player, never()).sendMessage(org.mockito.ArgumentMatchers.<String>argThat(
|
||||
message -> message.contains("is now")
|
||||
));
|
||||
}
|
||||
|
||||
@Test
|
||||
void lockedPlayerCannotEnablePocketMobSpawning() 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"));
|
||||
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
|
||||
when(pocketBases.state(playerId)).thenReturn(PocketBaseState.locked(playerId));
|
||||
BaseSettingsCommand command = new BaseSettingsCommand(
|
||||
stateManager,
|
||||
new PluginSettingsProvider(PluginSettings.from(Map.of())),
|
||||
mock(BaseFlightController.class),
|
||||
pocketBases
|
||||
);
|
||||
|
||||
command.onCommand(player, null, "basesettings", new String[] {
|
||||
"pocket", "mobs", "hostile", "enable"
|
||||
});
|
||||
|
||||
verify(pocketBases, never()).setHostileMobSpawning(playerId, true);
|
||||
verify(player).sendMessage(org.mockito.ArgumentMatchers.<String>argThat(
|
||||
message -> message.contains("Pocket Base I") && message.contains("locked")
|
||||
));
|
||||
}
|
||||
|
||||
@Test
|
||||
void failedPocketUpgradeRestoresPayment() throws Exception {
|
||||
UUID playerId = UUID.randomUUID();
|
||||
Player player = mock(Player.class);
|
||||
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[] {currency});
|
||||
PlayerState owner = PlayerState.newPlayer(playerId, "Builder")
|
||||
.withAdministrativeLevels(4, 0, 0, 0, 0, false, false, true)
|
||||
.withBase(
|
||||
new BaseLocation(UUID.randomUUID(), "world", 0, 64, 0, 0, 0),
|
||||
Instant.EPOCH
|
||||
);
|
||||
BaseStateManager stateManager = mock(BaseStateManager.class);
|
||||
when(stateManager.player(playerId, "Builder")).thenReturn(owner);
|
||||
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
|
||||
when(pocketBases.state(playerId)).thenReturn(PocketBaseState.locked(playerId));
|
||||
when(pocketBases.upgrade(playerId)).thenThrow(new IOException("save failed"));
|
||||
BaseSettingsCommand command = new BaseSettingsCommand(
|
||||
stateManager,
|
||||
new PluginSettingsProvider(PluginSettings.from(Map.of())),
|
||||
mock(BaseFlightController.class),
|
||||
pocketBases
|
||||
);
|
||||
|
||||
command.onCommand(player, null, "basesettings", new String[] {"pocket", "upgrade"});
|
||||
|
||||
ArgumentCaptor<ItemStack[]> contents = ArgumentCaptor.forClass(ItemStack[].class);
|
||||
verify(inventory, times(2)).setStorageContents(contents.capture());
|
||||
ItemStack[] restored = contents.getAllValues().get(1);
|
||||
assertEquals(Material.DIAMOND_BLOCK, restored[0].getType());
|
||||
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();
|
||||
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"));
|
||||
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
|
||||
when(pocketBases.state(playerId)).thenReturn(
|
||||
new PocketBaseState(
|
||||
playerId, 1, java.util.Optional.empty(), java.util.Optional.empty(),
|
||||
true, false, PocketBaseBiome.THE_VOID
|
||||
)
|
||||
);
|
||||
BaseSettingsCommand command = new BaseSettingsCommand(
|
||||
stateManager,
|
||||
new PluginSettingsProvider(PluginSettings.from(Map.of())),
|
||||
mock(BaseFlightController.class),
|
||||
pocketBases
|
||||
);
|
||||
|
||||
command.onCommand(player, null, "basesettings", new String[] {"status"});
|
||||
|
||||
verify(player).sendMessage(org.mockito.ArgumentMatchers.<String>argThat(
|
||||
message -> message.contains("Pocket Base 1")
|
||||
&& message.contains("type=void/the_void")
|
||||
&& message.contains("hostile spawning=enabled")
|
||||
&& message.contains("passive spawning=disabled")
|
||||
&& message.contains("flight=complete a raid to unlock")
|
||||
));
|
||||
}
|
||||
|
||||
@Test
|
||||
void bossbarEnableIsIdempotent() {
|
||||
UUID playerId = UUID.randomUUID();
|
||||
@@ -204,7 +588,7 @@ final class BaseSettingsCommandTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void defaultAndStatusDisplayTheFullProgressReport() {
|
||||
void defaultAndUiOpenDialogWhileStatusDisplaysTheFullProgressReport() {
|
||||
UUID playerId = UUID.randomUUID();
|
||||
Player player = mock(Player.class);
|
||||
when(player.getUniqueId()).thenReturn(playerId);
|
||||
@@ -216,21 +600,25 @@ 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)).sendMessage(ChatColor.GOLD + "=== Base Progress ===");
|
||||
verify(player, times(2)).sendMessage(
|
||||
verify(player, times(2)).showDialog((DialogLike) null);
|
||||
verify(player).sendMessage(ChatColor.GOLD + "=== Base Progress ===");
|
||||
verify(player).sendMessage(
|
||||
org.mockito.ArgumentMatchers.<String>argThat(message ->
|
||||
message.contains("Spawnable Overlay:")
|
||||
&& message.contains("0/250 placements")
|
||||
&& message.contains("locked")
|
||||
)
|
||||
);
|
||||
verify(player, times(2)).sendMessage(
|
||||
verify(player).sendMessage(
|
||||
org.mockito.ArgumentMatchers.<String>argThat(message ->
|
||||
message.contains("Settings: visitors=")
|
||||
&& message.contains("navigation=")
|
||||
@@ -242,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());
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
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;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
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();
|
||||
PlayerState owner = PlayerState.newPlayer(playerId, "Builder")
|
||||
.withAdministrativeLevels(4, 2, 2, 1, 1, true, true, true)
|
||||
.withBase(
|
||||
new BaseLocation(UUID.randomUUID(), "world", 10, 64, 20, 10, 20),
|
||||
Instant.EPOCH
|
||||
)
|
||||
.withBorderEnabled(true)
|
||||
.withTotalBlocksPlaced(250)
|
||||
.withSpawnableOverlayEnabled(true);
|
||||
PocketBaseState pocket = new PocketBaseState(
|
||||
playerId,
|
||||
1,
|
||||
Optional.empty(),
|
||||
Optional.empty(),
|
||||
true,
|
||||
false,
|
||||
PocketBaseBiome.PLAINS,
|
||||
true
|
||||
);
|
||||
|
||||
BaseSettingsDialogFactory.DialogSpec specification =
|
||||
new BaseSettingsDialogFactory(PluginSettings.from(Map.of()))
|
||||
.specification(owner, pocket);
|
||||
|
||||
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"));
|
||||
assertTrue(commands.contains("/basesettings navigation disable"));
|
||||
assertTrue(commands.contains("/basesettings flight disable"));
|
||||
assertTrue(commands.contains("/basesettings border disable"));
|
||||
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"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void requiresConfirmationForBaseIvPurchase() {
|
||||
UUID playerId = UUID.randomUUID();
|
||||
PlayerState owner = PlayerState.newPlayer(playerId, "Builder")
|
||||
.withAdministrativeLevels(3, 0, 0, 0, 0, false, false, false)
|
||||
.withBase(
|
||||
new BaseLocation(UUID.randomUUID(), "world", 0, 64, 0, 0, 0),
|
||||
Instant.EPOCH
|
||||
);
|
||||
|
||||
BaseSettingsDialogFactory.ListSpec root = assertInstanceOf(
|
||||
BaseSettingsDialogFactory.ListSpec.class,
|
||||
new BaseSettingsDialogFactory(PluginSettings.from(Map.of())).specification(
|
||||
owner,
|
||||
PocketBaseState.locked(playerId)
|
||||
)
|
||||
);
|
||||
BaseSettingsDialogFactory.ConfirmationSpec upgrade = assertInstanceOf(
|
||||
BaseSettingsDialogFactory.ConfirmationSpec.class,
|
||||
root.dialogs().get(2)
|
||||
);
|
||||
|
||||
assertEquals("/basesettings upgrade", upgrade.confirm().command());
|
||||
}
|
||||
|
||||
@Test
|
||||
void marksUnavailableControlsAsLocked() {
|
||||
UUID playerId = UUID.randomUUID();
|
||||
PlayerState owner = PlayerState.newPlayer(playerId, "Newcomer");
|
||||
|
||||
BaseSettingsDialogFactory.ListSpec root = assertInstanceOf(
|
||||
BaseSettingsDialogFactory.ListSpec.class,
|
||||
new BaseSettingsDialogFactory(PluginSettings.from(Map.of())).specification(
|
||||
owner,
|
||||
PocketBaseState.locked(playerId)
|
||||
)
|
||||
);
|
||||
BaseSettingsDialogFactory.MultiSpec controls = assertInstanceOf(
|
||||
BaseSettingsDialogFactory.MultiSpec.class,
|
||||
root.dialogs().get(0)
|
||||
);
|
||||
List<String> labels = controls.actions().stream()
|
||||
.map(BaseSettingsDialogFactory.ButtonSpec::label)
|
||||
.toList();
|
||||
|
||||
assertTrue(labels.stream().anyMatch(label -> label.contains("Visitors: Locked")));
|
||||
assertTrue(labels.stream().anyMatch(label -> label.contains("Navigation: Locked")));
|
||||
assertTrue(labels.stream().anyMatch(label -> label.contains("Flight: Locked")));
|
||||
assertTrue(labels.stream().anyMatch(label -> label.contains("Border: Locked")));
|
||||
assertTrue(labels.stream().anyMatch(label -> label.contains("Spawnable Overlay: Locked")));
|
||||
}
|
||||
|
||||
private static List<String> commands(
|
||||
BaseSettingsDialogFactory.DialogSpec specification
|
||||
) {
|
||||
List<String> commands = new ArrayList<>();
|
||||
collect(specification, commands);
|
||||
return commands;
|
||||
}
|
||||
|
||||
private static void collect(
|
||||
BaseSettingsDialogFactory.DialogSpec specification,
|
||||
List<String> commands
|
||||
) {
|
||||
if (specification instanceof BaseSettingsDialogFactory.ListSpec list) {
|
||||
list.dialogs().forEach(child -> collect(child, commands));
|
||||
} 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,31 @@ final class PluginSettingsTest {
|
||||
assertEquals(250, settings.spawnableOverlayUnlockPlacements());
|
||||
assertEquals(10_800L, settings.initialTeleportCooldownSeconds());
|
||||
assertEquals(128, settings.visitorUnlockDiamondCost());
|
||||
assertEquals(64, settings.pocketBaseUnlockCost());
|
||||
assertEquals(16, settings.pocketBaseUpgradeCost());
|
||||
assertEquals("DIAMOND_BLOCK", settings.pocketBaseCurrencyMaterial());
|
||||
assertEquals("DIAMOND_BLOCK", settings.pocketBasePortalFrameMaterial());
|
||||
assertEquals(16, settings.pocketBaseBiomeChangeCost());
|
||||
assertEquals("NETHERITE_BLOCK", settings.pocketBaseBiomeCurrencyMaterial());
|
||||
}
|
||||
|
||||
@Test
|
||||
void pocketBaseCostsAndMaterialsAreConfigurable() {
|
||||
PluginSettings settings = PluginSettings.from(Map.of(
|
||||
"pocket-base-unlock-cost", 32,
|
||||
"pocket-base-upgrade-cost", 8,
|
||||
"pocket-base-currency-material", "EMERALD_BLOCK",
|
||||
"pocket-base-portal-frame-material", "GOLD_BLOCK",
|
||||
"pocket-base-biome-change-cost", 12,
|
||||
"pocket-base-biome-currency-material", "NETHERITE_INGOT"
|
||||
));
|
||||
|
||||
assertEquals(32, settings.pocketBaseUnlockCost());
|
||||
assertEquals(8, settings.pocketBaseUpgradeCost());
|
||||
assertEquals("EMERALD_BLOCK", settings.pocketBaseCurrencyMaterial());
|
||||
assertEquals("GOLD_BLOCK", settings.pocketBasePortalFrameMaterial());
|
||||
assertEquals(12, settings.pocketBaseBiomeChangeCost());
|
||||
assertEquals("NETHERITE_INGOT", settings.pocketBaseBiomeCurrencyMaterial());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -0,0 +1,585 @@
|
||||
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;
|
||||
import static org.mockito.Mockito.verify;
|
||||
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 {
|
||||
@Test
|
||||
void ownerCanActivateCompleteFrameInsideNormalBase() throws Exception {
|
||||
UUID ownerId = UUID.randomUUID();
|
||||
UUID worldId = UUID.randomUUID();
|
||||
PocketPortalLocation portal = new PocketPortalLocation(
|
||||
worldId, "world", -2, 64, 0, PocketPortalAxis.X
|
||||
);
|
||||
Set<BlockPosition> frame = new HashSet<>(PocketPortalGeometry.frameBlocks(portal));
|
||||
Player owner = mock(Player.class);
|
||||
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());
|
||||
PlayerState ownerState = PlayerState.newPlayer(ownerId, "Owner")
|
||||
.withAdministrativeLevels(4, 0, 0, 0, 0, false, false, true)
|
||||
.withBase(new BaseLocation(worldId, "world", 0, 64, 0, 0, 0), Instant.EPOCH);
|
||||
|
||||
when(owner.getUniqueId()).thenReturn(ownerId);
|
||||
when(owner.getName()).thenReturn("Owner");
|
||||
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("world");
|
||||
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
|
||||
);
|
||||
});
|
||||
when(baseStates.player(ownerId, "Owner")).thenReturn(ownerState);
|
||||
when(pocketBases.state(ownerId)).thenReturn(
|
||||
new PocketBaseState(ownerId, 1, Optional.empty())
|
||||
);
|
||||
when(pocketBases.activateEntrance(ownerId, portal)).thenReturn(
|
||||
new PocketBaseState(ownerId, 1, Optional.of(portal))
|
||||
);
|
||||
PocketBaseController controller = new PocketBaseController(
|
||||
mock(Server.class),
|
||||
baseStates,
|
||||
new BaseBoundsService(settings),
|
||||
pocketBases,
|
||||
new PluginSettingsProvider(settings),
|
||||
Logger.getAnonymousLogger()
|
||||
);
|
||||
|
||||
controller.onActivate(event);
|
||||
|
||||
verify(pocketBases).activateEntrance(ownerId, portal);
|
||||
verify(event).setCancelled(true);
|
||||
}
|
||||
|
||||
@Test
|
||||
void ownerCanActivateCompleteReturnFrameInsideOwnPocketBase() 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.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());
|
||||
|
||||
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.DIAMOND_BLOCK : Material.AIR
|
||||
);
|
||||
});
|
||||
when(pocketBases.ownerForPocketWorld(worldId)).thenReturn(Optional.of(ownerId));
|
||||
when(pocketBases.state(ownerId)).thenReturn(
|
||||
new PocketBaseState(ownerId, 1, Optional.empty())
|
||||
);
|
||||
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()
|
||||
);
|
||||
|
||||
controller.onActivate(event);
|
||||
|
||||
verify(pocketBases).activateReturnPortal(ownerId, portal);
|
||||
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();
|
||||
UUID worldId = UUID.randomUUID();
|
||||
PocketPortalLocation portal = new PocketPortalLocation(
|
||||
worldId, "pocket", 30, 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, 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());
|
||||
|
||||
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.DIAMOND_BLOCK : Material.AIR
|
||||
);
|
||||
});
|
||||
when(pocketBases.ownerForPocketWorld(worldId)).thenReturn(Optional.of(ownerId));
|
||||
when(pocketBases.state(ownerId)).thenReturn(
|
||||
new PocketBaseState(ownerId, 1, Optional.empty())
|
||||
);
|
||||
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()
|
||||
);
|
||||
|
||||
controller.onActivate(event);
|
||||
|
||||
verify(pocketBases, never()).activateReturnPortal(ownerId, portal);
|
||||
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();
|
||||
UUID worldId = UUID.randomUUID();
|
||||
World world = mock(World.class);
|
||||
Block placed = block(world, 32, 65, 0, Material.STONE);
|
||||
Player player = mock(Player.class);
|
||||
BlockPlaceEvent event = mock(BlockPlaceEvent.class);
|
||||
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
|
||||
PluginSettings settings = PluginSettings.from(Map.of());
|
||||
|
||||
when(world.getUID()).thenReturn(worldId);
|
||||
when(event.getBlockPlaced()).thenReturn(placed);
|
||||
when(event.getPlayer()).thenReturn(player);
|
||||
when(pocketBases.ownerForPocketWorld(worldId)).thenReturn(Optional.of(ownerId));
|
||||
when(pocketBases.state(ownerId)).thenReturn(
|
||||
new PocketBaseState(ownerId, 1, Optional.empty())
|
||||
);
|
||||
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()
|
||||
);
|
||||
|
||||
controller.onPlace(event);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@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,
|
||||
int y,
|
||||
int z,
|
||||
Material material
|
||||
) {
|
||||
Block block = mock(Block.class);
|
||||
when(block.getWorld()).thenReturn(world);
|
||||
when(block.getX()).thenReturn(x);
|
||||
when(block.getY()).thenReturn(y);
|
||||
when(block.getZ()).thenReturn(z);
|
||||
when(block.getType()).thenReturn(material);
|
||||
return block;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
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.block.Action;
|
||||
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;
|
||||
|
||||
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)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void unlockedPlayerEntersOwnPocketAndRecordsExactReturnDestination() 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);
|
||||
|
||||
service.onUse(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);
|
||||
}
|
||||
|
||||
@Test
|
||||
void playerReturnsFromOwnPocketToRecordedSafeDestination() {
|
||||
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);
|
||||
|
||||
service.onUse(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));
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
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;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.function.UnaryOperator;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.InOrder;
|
||||
|
||||
final class PocketBaseManagerTest {
|
||||
@Test
|
||||
void relocatedReturnPortalOverridesGeneratedDefault() {
|
||||
UUID ownerId = UUID.randomUUID();
|
||||
PocketPortalLocation relocated = new PocketPortalLocation(
|
||||
UUID.randomUUID(), "pocket", 20, 65, 12, PocketPortalAxis.Z
|
||||
);
|
||||
PocketBaseStateManager states = mock(PocketBaseStateManager.class);
|
||||
PocketBaseWorldService worlds = mock(PocketBaseWorldService.class);
|
||||
when(states.state(ownerId)).thenReturn(new PocketBaseState(
|
||||
ownerId, 1, Optional.empty(), Optional.of(relocated), false, false,
|
||||
PocketBaseBiome.THE_VOID
|
||||
));
|
||||
PocketBaseManager manager = new PocketBaseManager(
|
||||
states,
|
||||
worlds,
|
||||
new PluginSettingsProvider(PluginSettings.from(Map.of()))
|
||||
);
|
||||
|
||||
assertEquals(relocated, manager.returnPortal(ownerId));
|
||||
}
|
||||
|
||||
@Test
|
||||
void existingStateWithoutRelocationUsesGeneratedReturnPortal() {
|
||||
UUID ownerId = UUID.randomUUID();
|
||||
PocketPortalLocation generated = new PocketPortalLocation(
|
||||
UUID.randomUUID(), "pocket", -2, 65, 0, PocketPortalAxis.X
|
||||
);
|
||||
PocketBaseStateManager states = mock(PocketBaseStateManager.class);
|
||||
PocketBaseWorldService worlds = mock(PocketBaseWorldService.class);
|
||||
when(states.state(ownerId)).thenReturn(
|
||||
new PocketBaseState(ownerId, 1, Optional.empty())
|
||||
);
|
||||
when(worlds.returnPortal(ownerId)).thenReturn(generated);
|
||||
PocketBaseManager manager = new PocketBaseManager(
|
||||
states,
|
||||
worlds,
|
||||
new PluginSettingsProvider(PluginSettings.from(Map.of()))
|
||||
);
|
||||
|
||||
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();
|
||||
PocketBaseState current = new PocketBaseState(
|
||||
ownerId, 1, Optional.empty(), Optional.empty(), false, true,
|
||||
PocketBaseBiome.THE_VOID
|
||||
);
|
||||
PocketBaseStateManager states = mock(PocketBaseStateManager.class);
|
||||
PocketBaseWorldService worlds = mock(PocketBaseWorldService.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,
|
||||
worlds,
|
||||
new PluginSettingsProvider(PluginSettings.from(Map.of()))
|
||||
);
|
||||
|
||||
PocketBaseState updated = manager.setHostileMobSpawning(ownerId, true);
|
||||
|
||||
assertEquals(true, updated.hostileMobSpawningEnabled());
|
||||
assertEquals(true, updated.passiveMobSpawningEnabled());
|
||||
InOrder order = inOrder(worlds, states);
|
||||
order.verify(worlds).setMobSpawning(ownerId, true, true);
|
||||
order.verify(states).updateAndSave(
|
||||
org.mockito.ArgumentMatchers.eq(ownerId), org.mockito.ArgumentMatchers.any()
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void persistsBiomeAfterApplyingItToTheUnlockedArea() throws Exception {
|
||||
UUID ownerId = UUID.randomUUID();
|
||||
PocketBaseState current = new PocketBaseState(ownerId, 2, Optional.empty());
|
||||
PocketBaseStateManager states = mock(PocketBaseStateManager.class);
|
||||
PocketBaseWorldService worlds = mock(PocketBaseWorldService.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,
|
||||
worlds,
|
||||
new PluginSettingsProvider(PluginSettings.from(Map.of()))
|
||||
);
|
||||
|
||||
PocketBaseState updated = manager.setBiome(ownerId, PocketBaseBiome.WARPED_FOREST);
|
||||
|
||||
assertEquals(PocketBaseBiome.WARPED_FOREST, updated.biome());
|
||||
InOrder order = inOrder(worlds, states);
|
||||
order.verify(worlds).setBiome(ownerId, 2, PocketBaseBiome.WARPED_FOREST);
|
||||
order.verify(states).updateAndSave(
|
||||
org.mockito.ArgumentMatchers.eq(ownerId), org.mockito.ArgumentMatchers.any()
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void restoresPreviousBiomeWhenPersistenceFails() throws Exception {
|
||||
UUID ownerId = UUID.randomUUID();
|
||||
PocketBaseState current = new PocketBaseState(ownerId, 1, Optional.empty());
|
||||
PocketBaseStateManager states = mock(PocketBaseStateManager.class);
|
||||
PocketBaseWorldService worlds = mock(PocketBaseWorldService.class);
|
||||
when(states.state(ownerId)).thenReturn(current);
|
||||
when(states.updateAndSave(org.mockito.ArgumentMatchers.eq(ownerId),
|
||||
org.mockito.ArgumentMatchers.any())).thenThrow(new IOException("save failed"));
|
||||
PocketBaseManager manager = new PocketBaseManager(
|
||||
states,
|
||||
worlds,
|
||||
new PluginSettingsProvider(PluginSettings.from(Map.of()))
|
||||
);
|
||||
|
||||
assertThrows(IOException.class,
|
||||
() -> manager.setBiome(ownerId, PocketBaseBiome.DESERT));
|
||||
|
||||
verify(worlds).setBiome(ownerId, 1, PocketBaseBiome.DESERT);
|
||||
verify(worlds).setBiome(ownerId, 1, PocketBaseBiome.THE_VOID);
|
||||
}
|
||||
|
||||
@Test
|
||||
void restoresBothWorldSettingsWhenPersistenceFails() throws Exception {
|
||||
UUID ownerId = UUID.randomUUID();
|
||||
PocketBaseState current = new PocketBaseState(
|
||||
ownerId, 1, Optional.empty(), Optional.empty(), true, false,
|
||||
PocketBaseBiome.THE_VOID
|
||||
);
|
||||
PocketBaseStateManager states = mock(PocketBaseStateManager.class);
|
||||
PocketBaseWorldService worlds = mock(PocketBaseWorldService.class);
|
||||
when(states.state(ownerId)).thenReturn(current);
|
||||
when(states.updateAndSave(org.mockito.ArgumentMatchers.eq(ownerId),
|
||||
org.mockito.ArgumentMatchers.any())).thenThrow(new IOException("save failed"));
|
||||
PocketBaseManager manager = new PocketBaseManager(
|
||||
states,
|
||||
worlds,
|
||||
new PluginSettingsProvider(PluginSettings.from(Map.of()))
|
||||
);
|
||||
|
||||
assertThrows(IOException.class,
|
||||
() -> manager.setPassiveMobSpawning(ownerId, true));
|
||||
|
||||
verify(worlds).setMobSpawning(ownerId, true, true);
|
||||
verify(worlds).setMobSpawning(ownerId, true, false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
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 java.util.Map;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
final class PocketBasePolicyTest {
|
||||
private final PocketBasePolicy policy = new PocketBasePolicy(PluginSettings.from(Map.of()));
|
||||
|
||||
@Test
|
||||
void levelsExpandBySixteenBlocksOnEverySide() {
|
||||
assertEquals(64, policy.size(1));
|
||||
assertEquals(96, policy.size(2));
|
||||
assertEquals(128, policy.size(3));
|
||||
}
|
||||
|
||||
@Test
|
||||
void unlockAndLaterLevelsUseTheirConfiguredPrices() {
|
||||
assertEquals(64, policy.price(PocketBaseState.locked(UUID.randomUUID())));
|
||||
assertEquals(16, policy.price(new PocketBaseState(UUID.randomUUID(), 1, java.util.Optional.empty())));
|
||||
}
|
||||
|
||||
@Test
|
||||
void buildBoundaryMatchesUnlockedPlatform() {
|
||||
assertTrue(policy.contains(1, -32, -32));
|
||||
assertTrue(policy.contains(1, 31, 31));
|
||||
assertFalse(policy.contains(1, -33, 0));
|
||||
assertFalse(policy.contains(1, 32, 0));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
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.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.WorldCreator;
|
||||
import org.bukkit.block.Block;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
final class PocketBaseWorldServiceTest {
|
||||
@Test
|
||||
void firstLevelGeneratesGrassPlatformAndReturnPortal() {
|
||||
UUID ownerId = UUID.randomUUID();
|
||||
UUID worldId = UUID.randomUUID();
|
||||
Server server = mock(Server.class);
|
||||
World world = mock(World.class);
|
||||
Map<BlockPosition, Block> blocks = blocks(world);
|
||||
when(server.createWorld(any(WorldCreator.class))).thenReturn(world);
|
||||
when(world.getUID()).thenReturn(worldId);
|
||||
when(world.getName()).thenReturn("pocket");
|
||||
when(world.getMinHeight()).thenReturn(0);
|
||||
when(world.getMaxHeight()).thenReturn(8);
|
||||
PocketBaseWorldService service = new PocketBaseWorldService(
|
||||
server,
|
||||
new PluginSettingsProvider(PluginSettings.from(Map.of())),
|
||||
ignored -> null
|
||||
);
|
||||
|
||||
service.expand(ownerId, 0, 1);
|
||||
|
||||
verify(blocks.get(new BlockPosition(-32, 64, -32)))
|
||||
.setType(Material.GRASS_BLOCK, false);
|
||||
verify(blocks.get(new BlockPosition(31, 61, 31))).setType(Material.DIRT, false);
|
||||
verify(blocks.get(new BlockPosition(-2, 65, 0)))
|
||||
.setType(Material.DIAMOND_BLOCK, false);
|
||||
verify(world).setSpawnLocation(0, 65, 5);
|
||||
verify(world).setSpawnFlags(false, false);
|
||||
assertEquals(ownerId, service.ownerForWorld(worldId).orElseThrow());
|
||||
}
|
||||
|
||||
@Test
|
||||
void checksRelocatedReturnPortalFrameAtItsSavedLocation() {
|
||||
UUID ownerId = UUID.randomUUID();
|
||||
UUID worldId = UUID.randomUUID();
|
||||
Server server = mock(Server.class);
|
||||
World world = mock(World.class);
|
||||
Map<BlockPosition, Block> blocks = blocks(world);
|
||||
when(server.getWorld(org.mockito.ArgumentMatchers.anyString())).thenReturn(world);
|
||||
when(world.getUID()).thenReturn(worldId);
|
||||
PocketPortalLocation portal = new PocketPortalLocation(
|
||||
worldId, "pocket", 20, 65, 12, PocketPortalAxis.Z
|
||||
);
|
||||
for (BlockPosition position : PocketPortalGeometry.frameBlocks(portal)) {
|
||||
when(world.getBlockAt(position.x(), position.y(), position.z()).getType())
|
||||
.thenReturn(Material.DIAMOND_BLOCK);
|
||||
}
|
||||
PocketBaseWorldService service = new PocketBaseWorldService(
|
||||
server,
|
||||
new PluginSettingsProvider(PluginSettings.from(Map.of()))
|
||||
);
|
||||
|
||||
assertTrue(service.returnPortalIsIntact(ownerId, portal));
|
||||
|
||||
when(world.getBlockAt(20, 65, 12).getType()).thenReturn(Material.AIR);
|
||||
assertFalse(service.returnPortalIsIntact(ownerId, portal));
|
||||
}
|
||||
|
||||
@Test
|
||||
void reappliesSavedMobSpawningPreferenceWhenWorldLoads() {
|
||||
UUID ownerId = UUID.randomUUID();
|
||||
Server server = mock(Server.class);
|
||||
World world = mock(World.class);
|
||||
when(server.getWorld(org.mockito.ArgumentMatchers.anyString())).thenReturn(world);
|
||||
when(world.getUID()).thenReturn(UUID.randomUUID());
|
||||
PocketBaseWorldService service = new PocketBaseWorldService(
|
||||
server,
|
||||
new PluginSettingsProvider(PluginSettings.from(Map.of())),
|
||||
ignored -> null
|
||||
);
|
||||
|
||||
service.loadExisting(Map.of(
|
||||
ownerId,
|
||||
new PocketBaseState(
|
||||
ownerId, 1, Optional.empty(), Optional.empty(), true, false,
|
||||
PocketBaseBiome.THE_VOID
|
||||
)
|
||||
));
|
||||
|
||||
verify(world).setSpawnFlags(true, false);
|
||||
}
|
||||
|
||||
@Test
|
||||
void appliesSelectedBiomeAcrossUnlockedColumnsWithoutChangingBlocks() {
|
||||
UUID ownerId = UUID.randomUUID();
|
||||
Server server = mock(Server.class);
|
||||
World world = mock(World.class);
|
||||
when(server.getWorld(org.mockito.ArgumentMatchers.anyString())).thenReturn(world);
|
||||
when(world.getUID()).thenReturn(UUID.randomUUID());
|
||||
when(world.getMinHeight()).thenReturn(0);
|
||||
when(world.getMaxHeight()).thenReturn(8);
|
||||
PocketBaseWorldService service = new PocketBaseWorldService(
|
||||
server,
|
||||
new PluginSettingsProvider(PluginSettings.from(Map.of())),
|
||||
ignored -> null
|
||||
);
|
||||
|
||||
service.setBiome(ownerId, 1, PocketBaseBiome.CRIMSON_FOREST);
|
||||
|
||||
verify(world).setBiome(-32, 0, -32, null);
|
||||
verify(world).setBiome(28, 4, 28, null);
|
||||
verify(world, never()).getBlockAt(
|
||||
org.mockito.ArgumentMatchers.anyInt(),
|
||||
org.mockito.ArgumentMatchers.anyInt(),
|
||||
org.mockito.ArgumentMatchers.anyInt()
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void laterLevelAddsRingWithoutOverwritingExistingTerrain() {
|
||||
UUID ownerId = UUID.randomUUID();
|
||||
Server server = mock(Server.class);
|
||||
World world = mock(World.class);
|
||||
Map<BlockPosition, Block> blocks = blocks(world);
|
||||
when(server.createWorld(any(WorldCreator.class))).thenReturn(world);
|
||||
when(world.getUID()).thenReturn(UUID.randomUUID());
|
||||
when(world.getName()).thenReturn("pocket");
|
||||
when(world.getMinHeight()).thenReturn(0);
|
||||
when(world.getMaxHeight()).thenReturn(8);
|
||||
PocketBaseWorldService service = new PocketBaseWorldService(
|
||||
server,
|
||||
new PluginSettingsProvider(PluginSettings.from(Map.of())),
|
||||
ignored -> null
|
||||
);
|
||||
Block existingCenter = world.getBlockAt(0, 64, 0);
|
||||
|
||||
service.expand(ownerId, 1, 2);
|
||||
|
||||
verify(blocks.get(new BlockPosition(47, 64, 47)))
|
||||
.setType(Material.GRASS_BLOCK, false);
|
||||
verify(existingCenter, never())
|
||||
.setType(any(Material.class), org.mockito.ArgumentMatchers.anyBoolean());
|
||||
verify(world).setBiome(44, 4, 44, null);
|
||||
verify(world, never()).setBiome(0, 0, 0, null);
|
||||
}
|
||||
|
||||
private static Map<BlockPosition, Block> blocks(World world) {
|
||||
Map<BlockPosition, Block> blocks = new HashMap<>();
|
||||
when(world.getBlockAt(
|
||||
org.mockito.ArgumentMatchers.anyInt(),
|
||||
org.mockito.ArgumentMatchers.anyInt(),
|
||||
org.mockito.ArgumentMatchers.anyInt()
|
||||
)).thenAnswer(invocation -> {
|
||||
BlockPosition position = new BlockPosition(
|
||||
invocation.getArgument(0), invocation.getArgument(1), invocation.getArgument(2)
|
||||
);
|
||||
return blocks.computeIfAbsent(position, ignored -> mock(Block.class));
|
||||
});
|
||||
return blocks;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package games.dmg.spigotbase;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
final class PocketPortalGeometryTest {
|
||||
@Test
|
||||
void findsStandardFrameContainingIgnitedBlock() {
|
||||
UUID worldId = UUID.randomUUID();
|
||||
PocketPortalLocation expected = new PocketPortalLocation(
|
||||
worldId, "world", 10, 64, 20, PocketPortalAxis.X
|
||||
);
|
||||
Set<BlockPosition> frame = new HashSet<>(PocketPortalGeometry.frameBlocks(expected));
|
||||
|
||||
Optional<PocketPortalLocation> found = PocketPortalGeometry.findFrame(
|
||||
new BlockPosition(10, 66, 20),
|
||||
worldId,
|
||||
"world",
|
||||
frame::contains,
|
||||
ignored -> true
|
||||
);
|
||||
|
||||
assertEquals(expected, found.orElseThrow());
|
||||
}
|
||||
|
||||
@Test
|
||||
void identifiesPortalInteriorAndRequiredFrameBlocks() {
|
||||
PocketPortalLocation portal = new PocketPortalLocation(
|
||||
UUID.randomUUID(), "world", 0, 64, 0, PocketPortalAxis.Z
|
||||
);
|
||||
|
||||
assertTrue(PocketPortalGeometry.isInterior(portal, new BlockPosition(0, 65, 1)));
|
||||
assertTrue(PocketPortalGeometry.isFrame(portal, new BlockPosition(0, 68, 3)));
|
||||
assertEquals(14, PocketPortalGeometry.frameBlocks(portal).size());
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
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 java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
final class YamlPocketBaseRepositoryTest {
|
||||
@TempDir
|
||||
Path temporaryDirectory;
|
||||
|
||||
@Test
|
||||
void roundTripsLevelsAndActiveEntrances() throws Exception {
|
||||
UUID ownerId = UUID.randomUUID();
|
||||
UUID worldId = UUID.randomUUID();
|
||||
PocketBaseState expected = new PocketBaseState(
|
||||
ownerId,
|
||||
7,
|
||||
Optional.of(new PocketPortalLocation(
|
||||
worldId, "world", 10, 65, -4, PocketPortalAxis.X
|
||||
)),
|
||||
Optional.of(new PocketPortalLocation(
|
||||
worldId, "pocket", 20, 65, 12, PocketPortalAxis.Z
|
||||
)),
|
||||
true,
|
||||
false,
|
||||
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")
|
||||
);
|
||||
|
||||
repository.save(Map.of(ownerId, expected));
|
||||
|
||||
assertEquals(expected, repository.load().get(ownerId));
|
||||
}
|
||||
|
||||
@Test
|
||||
void existingRecordWithoutMobPreferenceDefaultsToDisabled() throws Exception {
|
||||
UUID ownerId = UUID.randomUUID();
|
||||
Path stateFile = temporaryDirectory.resolve("pocket-bases.yml");
|
||||
Files.writeString(stateFile, "owners:\n " + ownerId + ":\n level: 1\n");
|
||||
YamlPocketBaseRepository repository = new YamlPocketBaseRepository(stateFile);
|
||||
|
||||
PocketBaseState loaded = repository.load().get(ownerId);
|
||||
|
||||
assertFalse(loaded.hostileMobSpawningEnabled());
|
||||
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
|
||||
void migratesLegacyMobPreferenceToBothCategories() throws Exception {
|
||||
UUID ownerId = UUID.randomUUID();
|
||||
Path stateFile = temporaryDirectory.resolve("pocket-bases.yml");
|
||||
Files.writeString(stateFile, "owners:\n " + ownerId
|
||||
+ ":\n level: 1\n mob-spawning-enabled: true\n");
|
||||
YamlPocketBaseRepository repository = new YamlPocketBaseRepository(stateFile);
|
||||
|
||||
PocketBaseState loaded = repository.load().get(ownerId);
|
||||
|
||||
assertTrue(loaded.hostileMobSpawningEnabled());
|
||||
assertTrue(loaded.passiveMobSpawningEnabled());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user