24 Commits
Author SHA1 Message Date
dmg 1ad930c7ad fix(pocket-base): handle keystone right-clicks in air
Release / release (push) Successful in 2m56s
CI / build (push) Successful in 1m31s
2026-09-06 22:14:18 -04:00
dmg 28dd5422d5 feat(teleport): add warmup particles and fading departure clouds
Release / release (push) Successful in 2m56s
CI / build (push) Successful in 1m35s
2026-09-06 21:11:24 -04:00
dmg ecb07d48ad feat(pocket-base): start flying on flight portal entry
Release / release (push) Successful in 5m2s
CI / build (push) Successful in 1m28s
2026-09-06 20:52:25 -04:00
dmg c42ba6888a fix(pocket-base): accept recovery compass metadata
Release / release (push) Successful in 2m47s
CI / build (push) Successful in 1m17s
2026-08-29 14:51:56 -04:00
dmg 61e7a01374 feat(pocket-base): add reusable keystone travel
Release / release (push) Successful in 3m2s
CI / build (push) Successful in 1m23s
2026-08-29 14:43:12 -04:00
dmg e0b1a25c5e feat(pocket-base): share flight through gold portals
Release / release (push) Successful in 2m50s
CI / build (push) Successful in 1m15s
2026-08-29 00:20:40 -04:00
dmg f4511f9286 feat(pocket-base): unlock flight by completing raids 2026-08-29 00:04:46 -04:00
dmg 02c6698268 fix(pocket-base): clear fall distance after portal travel
Release / release (push) Successful in 3m9s
CI / build (push) Successful in 1m27s
2026-08-28 23:50:08 -04:00
dmg bc2f9d8e25 fix(settings): use static dialog command actions
Release / release (push) Successful in 2m55s
CI / build (push) Successful in 1m16s
2026-08-25 19:06:05 -04:00
dmg 8b3eaad253 feat(pocket-base): allow mobs through portals
Release / release (push) Successful in 3m0s
CI / build (push) Successful in 1m24s
2026-08-24 13:10:53 -04:00
dmg 29413d36b3 fix(settings): use Purpur dialog API
Release / release (push) Successful in 5m29s
CI / build (push) Successful in 1m28s
2026-08-24 11:25:01 -04:00
dmg b6bbf9183a feat(settings): add native base control dialog
Release / release (push) Successful in 2m45s
CI / build (push) Successful in 1m18s
2026-08-24 10:54:26 -04:00
dmg b2ba4d5821 feat(pocket-base): split hostile and passive mob spawning
Release / release (push) Successful in 2m50s
CI / build (push) Successful in 1m13s
2026-08-24 10:41:44 -04:00
dmg 6ea5bc16f4 feat(pocket-base): add purchasable biome changes
Release / release (push) Successful in 2m54s
CI / build (push) Successful in 1m16s
2026-08-24 10:32:32 -04:00
dmg 4518478ea1 feat(pocket-base): allow return portal relocation
Release / release (push) Successful in 2m39s
CI / build (push) Successful in 1m13s
2026-08-24 10:06:25 -04:00
dmg 23e8fcec6e feat(pocket-base): add mob spawning toggle
Release / release (push) Successful in 2m46s
CI / build (push) Successful in 1m28s
2026-08-24 09:57:48 -04:00
dmg 11e3ad1720 feat(pocket-base): add persistent pocket dimensions
Release / release (push) Successful in 2m37s
CI / build (push) Successful in 1m14s
2026-08-21 23:16:04 -04:00
dmg 8a9640d992 fix(teleport): require same dimension for visits
Release / release (push) Successful in 2m24s
CI / build (push) Successful in 1m2s
2026-08-21 22:36:10 -04:00
dmg 3be3e43072 fix(teleport): enforce visitor warm-up
Release / release (push) Successful in 2m28s
CI / build (push) Successful in 1m3s
2026-08-21 22:28:34 -04:00
dmg 9692df9101 fix(flight): show boundary warning only while flying
Release / release (push) Successful in 4m20s
CI / build (push) Successful in 1m13s
2026-08-21 22:23:01 -04:00
dmg f36c40d5aa fix(navigation): hide guidance near base
Release / release (push) Successful in 2m22s
CI / build (push) Successful in 1m0s
2026-08-10 22:51:19 -04:00
dmg 259ea9410c feat(spawning): add spawnable block overlay
CI / build (push) Successful in 1m3s
Release / release (push) Successful in 2m16s
2026-08-10 22:46:02 -04:00
dmg 26aeb8c416 feat(border): add base boundary particles
CI / build (push) Successful in 1m1s
Release / release (push) Successful in 2m18s
2026-08-10 22:12:16 -04:00
dmg 4278e08bf3 feat(commands): unify settings and admin commands
Release / release (push) Successful in 2m28s
CI / build (push) Successful in 1m3s
2026-08-10 20:44:34 -04:00
108 changed files with 9095 additions and 764 deletions
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+36 -14
View File
@@ -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
@@ -24,28 +24,50 @@ The plugin JAR is written to `build/libs/`.
```text
/setbase (alias: /sethome)
/base (alias: /home)
/base upgrade (alias: /home upgrade)
/basenavigation [on|off] (alias: /homenavigation)
/baseflight [on|off] (alias: /homeflight)
/basevisitors [on|off] (alias: /homevisitors)
/gotobase <player> (alias: /visit <player>)
/baseprogress
/baseprogress bossbar
/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>
/basesettings border <enable|disable>
/basesettings spawnable <enable|disable>
/basesettings bossbar <enable|disable>
```
`/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.
`/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. Home and visitor warm-ups surround the player with nearby-visible floating particles that grow denser as departure approaches. Successful warmed-up teleports leave a cloud at the departure point that thins out over two seconds; cancellation or failure stops emission without a departure cloud.
Base IV owners can purchase and expand a persistent Pocket Base with `/basesettings pocket upgrade`. Activating a complete diamond-block portal frame inside the normal base with flint and steel opens one public entrance to the owner's grass platform in a private void world. Players and non-player living mobs can travel through the entrance and active return portal; items, projectiles, and vehicles are not transported. The owner can move the return portal by building and igniting another complete frame inside the unlocked Pocket Base boundary; only the newly activated return portal remains functional. Natural hostile and passive mob spawning is disabled by default. Owners can control each category independently with `/basesettings pocket mobs <hostile|passive> <enable|disable>`. An owner who wins a raid inside their own Pocket Base permanently unlocks owner-only Survival flight across the full build height and within the current Pocket Base boundary plus a 16-block warning buffer on every side. The owner can control that automatic privilege with `/basesettings pocket flight <enable|disable>`. After the raid unlock, the owner can ignite a complete gold-block portal frame inside the unlocked boundary; any Survival player who passes through receives temporary flight within the same boundary and buffer until leaving the Pocket Base. Entering the flight portal immediately starts the player flying and displays “Pocket dimension flight enabled!” once, without repeating while they stand inside the portal. The owner's automatic-flight preference does not affect portal-granted flight.
Pocket Base owners can also craft a reusable **Pocket Base Keystone**. Its shaped recipe uses an Ender Pearl in the center, Netherite Ingots on the four cardinal slots, and Obsidian in the four corners. Right-clicking the keystone outside a Pocket Base instantly records the player's location and takes them to their own Pocket Base. Right-clicking it again inside their own Pocket Base returns them to that exact location when the world and destination remain available and safe. Saved returns survive reconnects and server restarts. A transferred keystone only works for a player who has unlocked their own Pocket Base.
Owners can change biome metadata without altering Pocket Base blocks, entities, inventories, or portals by using `/basesettings pocket type <void|nether|overworld> <subtype>`. Void uses `the_void`; Nether and Overworld accept their compatible vanilla biome names, such as `crimson_forest` or `plains`. Each change defaults to 16 netherite blocks, is configurable, and affects applicable natural mob selection when spawning is enabled. The Pocket Base remains a void-generated normal-environment world; dimension-specific mechanics do not change.
## Administration
The `spigotbase.admin` permission is granted to server operators by default.
The `spigotbase.admin` permission is granted to server operators by default. `/homeadmin` aliases `/baseadmin`.
```text
/baseadmin progress <player>
/baseadmin status <player>
/baseadmin setlevel <player> <base|size|flight|warmup|cooldown> <level>
/baseadmin setprogress <player> <grass_dirt|stone|deepslate|obsidian|placements|base_breaks> <amount>
/baseadmin setprogress <player> <grass_dirt|stone|deepslate|obsidian|placements|total_placements|base_breaks> <amount>
/baseadmin setsetting <player> spawnable <enable|disable>
/baseadmin config <numeric-key> <value>
# e.g. /baseadmin config spawnable-overlay-unlock-placements 250
/baseadmin clearcooldown <player> [personal|visitor|all]
/baseadmin reset <player> <base|size|flight|warmup|cooldown>
/baseadmin reset <player> <size|flight|warmup|cooldown>
/baseadmin reset <player> all confirm
```
+5 -5
View File
@@ -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")
+124
View File
@@ -53,3 +53,127 @@ description: Chronological record of material decisions affecting the Spigot Bas
- Added `/homenavigation`, `/homeflight`, and `/homevisitors` aliases with the same autocomplete as their canonical commands.
- Generalized toggle preference resolution for navigation, flight, and visitor access.
- Verified the implementation with `./gradlew clean check jar`.
## 2026-08-10 — Unified base settings command
- Replaced the separate progress, navigation, flight, and visitor commands with `/basesettings` and its `/homesettings` alias.
- Added nested autocomplete for status, Base IV upgrades, visitor access, navigation, flight, and progress boss-bar settings.
- Moved the Base IV purchase from `/base upgrade` to `/basesettings upgrade`, leaving `/base` and `/home` dedicated to personal teleportation.
- Preserved the full progression report as the default `/basesettings` response and as `/basesettings status`.
- Verified the breaking command restructure with `./gradlew clean check jar`.
## 2026-08-10 — Administrative command autocomplete and reset safety
- Added `/homeadmin` as an alias for `/baseadmin` and replaced the administrative `progress` subcommand with `status`.
- Added permission-aware, context-sensitive autocomplete for subcommands, online and known offline players, paths, levels, counters, cooldown types, reset confirmation, and numeric configuration keys.
- Removed the unsafe unconfirmed base reset path; complete resets now require `reset <player> all confirm`.
- Tightened argument validation and replaced implementation-specific enum errors with player-facing usage guidance.
- Verified the administrative revision with `./gradlew clean check jar`.
## 2026-08-10 — Base border visualization
- Added persisted, owner-only base border particles controlled by `/basesettings border enable|disable` with contextual autocomplete.
- Rendered a bounded nearby arc at the player's current height using the current base radius while respecting world and vertical bounds.
- Added border preference reporting to player and administrative status output and safe defaults for existing state files.
- Verified the feature with `./gradlew clean check jar`.
## 2026-08-10 — Spawnable block overlay
- Added persistent progression for Survival-mode block placements anywhere, with existing in-base placement counts used as a safe migration minimum.
- Added `/basesettings spawnable enable|disable` after a configurable 250-placement unlock and reported its progress and preference in player and admin status output.
- Added admin controls for total placement progress, the unlock threshold, and each unlocked player's overlay preference.
- Added owner-only red particles for nearby dark spawning surfaces inside base bounds, using bounded incremental scanning and rendering budgets.
- Verified the feature with `./gradlew clean check jar`.
## 2026-08-10 — Navigation visibility buffer
- Limited enabled base navigation particles to the base's world and positions more than 25 horizontal blocks beyond the current base border.
- 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`.
+11
View File
@@ -18,3 +18,14 @@ description: Catalog of user stories for the Spigot Base plugin.
10. [US-010: Administer player progression](us-010-administer-player-progression.md)
11. [US-011: Configure and persist progression](us-011-configure-and-persist-progression.md)
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)
@@ -14,13 +14,13 @@ As a **player with Base I**, I want visual guidance toward my base so that I can
- [x] Base II requires Base I and an established base.
- [x] Base II unlocks at a configurable cumulative grass-or-dirt threshold that defaults to 500 blocks, 250 more than Base I.
- [x] Base II provides particle-based navigation and does not grant or require a physical compass item.
- [x] While enabled and in the base's world, particles are drawn along the ground to indicate the direction toward the base.
- [x] While enabled and in the base's world, particles are drawn along the ground only when the player is horizontally more than 25 blocks beyond the current base border.
- [x] No navigation particles appear inside the base, exactly 25 blocks beyond its border, or anywhere within that buffer.
- [x] Expansion and relocation update the navigation visibility threshold immediately.
- [x] Particle generation is bounded to avoid excessive server or client load.
- [x] A player in another world receives a clear message instead of a misleading particle direction.
- [x] `/basenavigation` toggles guidance on and off after Base II is unlocked.
- [x] `/basenavigation on` enables guidance idempotently and `/basenavigation off` disables it idempotently.
- [x] Invalid navigation arguments show command usage, and `on` and `off` are offered through autocomplete.
- [x] `/homenavigation` aliases `/basenavigation` with identical behavior and autocomplete.
- [x] `/basesettings navigation enable` enables guidance idempotently after Base II is unlocked, and `/basesettings navigation disable` disables it idempotently.
- [x] Invalid navigation arguments show command usage, and `enable` and `disable` are offered through autocomplete.
- [x] Grass-or-dirt progress after Base II preserves the player's selected navigation preference.
- [x] The navigation preference persists across reconnects and restarts.
@@ -21,12 +21,11 @@ As a **player with Base I**, I want to unlock controlled flight around my base s
- [x] Flight II expands the vertical range to 100 blocks below and above base Y by default.
- [x] Flight III expands the vertical range to the world's minimum and maximum build heights.
- [x] A configurable five-block horizontal warning buffer extends beyond the current base radius.
- [x] Plugin-granted flight remains active in the warning buffer and displays prominent on-screen notice that the player is leaving the base.
- [x] Plugin-granted flight remains active in the warning buffer, and a prominent on-screen notice that the player is leaving the base appears only while the player is actively flying.
- [x] Passing beyond the warning buffer removes only flight granted by this plugin.
- [x] Flight is not granted outside the unlocked vertical range.
- [x] `/baseflight` toggles the player's unlocked base flight on and off.
- [x] `/baseflight on` enables flight idempotently and `/baseflight off` disables it idempotently, with `on` and `off` offered through autocomplete.
- [x] `/homeflight` aliases `/baseflight` with identical behavior and autocomplete.
- [x] `/basesettings flight enable` enables unlocked flight idempotently, and `/basesettings flight disable` disables it idempotently.
- [x] Invalid flight arguments show command usage, and `enable` and `disable` are offered through autocomplete.
- [x] The flight toggle persists across reconnects and restarts.
- [x] The plugin handles teleportation, world changes, game-mode changes, death, logout, and plugin shutdown without leaving unintended flight enabled.
@@ -11,11 +11,16 @@ As a **player with Base II**, I want to earn `/base` so that I can return safely
## Acceptance criteria
- [x] Warm-up particles float around the player, are visible nearby, and grow denser with warm-up progress.
- [x] Successful warmed-up teleports leave a departure cloud that thins out over two seconds.
- [x] Cancellation stops particle emission and failed teleports create no departure cloud.
- [x] Particle effects are bounded, cleaned up on shutdown, and regression-tested without changing teleport timing, safety, or cooldowns.
- [x] Base III requires Base II, an established base, and a configurable 200 qualifying block placements inside the base.
- [x] Only placements made in Survival mode and within the base's current horizontal and vertical bounds count.
- [x] Player-placed blocks and replacement of previously broken blocks may contribute repeatedly.
- [x] Base III unlocks `/base` with a configurable 30-second warm-up and three-hour cooldown by default.
- [x] `/home` is an alias for `/base`, including `/home upgrade`.
- [x] `/home` aliases the teleport-only `/base` command.
- [x] Looking around without changing block coordinates does not cancel the warm-up.
- [x] Changing block X, Y, or Z, taking damage, teleporting, changing worlds, dying, disconnecting, or starting a conflicting teleport cancels the warm-up.
- [x] Cancellation clearly informs the player and does not consume the cooldown.
@@ -11,18 +11,22 @@ As a **player with Base III**, I want to open my base to visitors so that other
## Acceptance criteria
- [x] Visitor warm-ups share the nearby-visible, progressively denser floating particles used by home teleports.
- [x] Successful visitor teleports leave a two-second fading departure cloud; cancellation or failure stops emission without a departure cloud.
- [x] Effects are bounded and cleaned up on shutdown, with regression coverage preserving visitor timing, safety, and cooldowns.
- [x] Base IV requires Base III and an established base.
- [x] `/base upgrade` offers the Base IV purchase for a configurable price that defaults to 128 diamonds.
- [x] `/basesettings upgrade` offers the Base IV purchase for a configurable price that defaults to 128 diamonds, replacing `/base upgrade` and `/home upgrade`.
- [x] A successful purchase removes the complete price atomically from the player's direct inventory.
- [x] Insufficient funds, an invalid state, or a failed persistence operation does not consume any diamonds or grant Base IV.
- [x] `/basevisitors` lets a Base IV owner toggle visitor access on and off.
- [x] `/basevisitors on` enables access idempotently and `/basevisitors off` disables it idempotently, with `on` and `off` offered through autocomplete.
- [x] `/homevisitors` aliases `/basevisitors` with identical behavior and autocomplete.
- [x] `/basesettings visitors allowed` permits visitor access idempotently for a Base IV owner, and `/basesettings visitors blocked` blocks it idempotently.
- [x] Invalid visitor arguments show command usage, and `allowed` and `blocked` are offered through autocomplete.
- [x] The visitor-access preference persists across reconnects and restarts.
- [x] `/gotobase <owner>` autocompletes bases that the requesting player is currently eligible to visit.
- [x] `/visit <owner>` aliases `/gotobase <owner>` with identical autocomplete, including eligible bases whose owners are offline.
- [x] Enabled bases remain visitable while their owners are offline.
- [x] A visitor teleport uses the destination owner's current warm-up tier.
- [x] `/visit` and `/gotobase` require the visitor to be in the destination base's recorded world; cross-dimension requests are rejected before warm-up with a clear message and without consuming cooldown.
- [x] A visitor teleport uses the destination owner's current warm-up tier, but always requires at least a one-second stationary warm-up; `/visit` and `/gotobase` never teleport instantly.
- [x] Looking around is permitted, while movement between block coordinates, damage, teleportation, world change, death, logout, or a conflicting teleport cancels the visitor warm-up.
- [x] Cancellation or destination failure does not consume a visitor cooldown.
- [x] A safe destination is resolved at or near the owner's recorded base center.
@@ -37,3 +41,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,20 +11,25 @@ As a **player**, I want to inspect my progression and receive timely milestone f
## Acceptance criteria
- [x] `/baseprogress` shows the player's Base, Base Size, Base Flight, Teleport Warm-up, and Teleport Cooldown paths.
- [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.
- [x] Boss-bar text and fill accurately represent the current count and threshold and never exceed 100 percent.
- [x] The automatic boss bar disappears after a configurable number of seconds.
- [x] `/baseprogress bossbar` toggles automatic progress boss bars on and off.
- [x] Disabling automatic boss bars does not prevent `/baseprogress` from displaying progress.
- [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 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.
- [x] `/homesettings` aliases `/basesettings` with identical behavior and autocomplete.
- [x] Superseded `/baseprogress`, `/basenavigation`, `/baseflight`, and `/basevisitors` commands and their aliases are no longer registered.
## Related
- [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)
@@ -14,16 +14,20 @@ As a **server administrator**, I want command-based progression controls so that
- [x] Administrative commands require `spigotbase.admin`, which server operators receive by default.
- [x] Administrative player arguments safely resolve online players and previously known offline players.
- [x] Player state remains keyed by UUID while retaining the latest known name for lookup and display.
- [x] `/baseadmin progress <player>` displays the player's base, counters, earned path levels, active cooldowns, toggles, and visitor settings.
- [x] `/baseadmin status <player>` displays the player's base, counters, earned path levels, active cooldowns, toggles, and visitor settings.
- [x] `/baseadmin setlevel <player> <path> <level>` sets an earned path level while enforcing or explicitly granting required preceding levels.
- [x] `/baseadmin setprogress <player> <path> <amount>` updates the selected counter and consistently evaluates reached tiers.
- [x] `/baseadmin reset <player> <path>` resets a selected path without silently leaving benefits that require it.
- [x] `/baseadmin reset <player> <size|flight|warmup|cooldown>` resets a selected path without silently leaving benefits that require it.
- [x] `/baseadmin reset <player> base` is rejected and directs the administrator to the confirmed complete-reset command.
- [x] `/baseadmin reset <player> all` removes the player's base, progression, active cooldowns, and plugin preferences after confirmation.
- [x] Administrators can clear personal and visitor cooldowns independently.
- [x] Administrative commands can update configured block requirements, warm-ups, and cooldowns for each level using validated values.
- [x] Runtime configuration changes are persisted for subsequent restarts.
- [x] Lowered progression requirements are evaluated for a player on their next relevant action rather than immediately updating every stored player.
- [x] Every successful mutation reports exactly what changed, and invalid requests make no partial changes.
- [x] `/homeadmin` aliases `/baseadmin` with identical permission requirements and autocomplete.
- [x] Permission-aware autocomplete offers subcommands, online and known offline players, progression paths, valid levels, progress counters, cooldown types, reset paths and confirmation, and numeric configuration keys.
- [x] Invalid and extra arguments show friendly usage without exposing implementation-specific enum errors or modifying state.
## Related
@@ -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-013: Visualize the base border"
description: Let players display a bounded, owner-only particle arc along the current edge of their established base.
status: done
---
# US-013: Visualize the base border
As a **player with Base I**, I want to display a particle effect at my base border so that I can understand where base benefits begin and end while building.
## Acceptance criteria
- [x] Border visualization requires Base I and an established base.
- [x] `/basesettings border enable` enables visualization idempotently, and `/basesettings border disable` disables it idempotently.
- [x] `border`, `enable`, and `disable` are offered through contextual autocomplete.
- [x] Border visualization is disabled by default and the preference persists across reconnects and restarts.
- [x] Older state files without the preference load it as disabled.
- [x] `/basesettings status` and `/baseadmin status` display the border preference.
- [x] Particles are visible only to the base owner.
- [x] Particles trace the current circular horizontal boundary at the player's current Y-level and follow base expansion or relocation immediately.
- [x] Particles render only while the player is in the base world, within the base's vertical range, and reasonably close to the boundary.
- [x] Particle generation is bounded to avoid excessive server and client load.
- [x] A complete administrative reset disables border visualization.
## Related
- [US-001: Unlock and establish Base I](us-001-unlock-and-establish-base.md)
- [US-003: Expand the base](us-003-expand-the-base.md)
- [US-009: View progression and unlock notifications](us-009-view-progression-and-notifications.md)
- [US-010: Administer player progression](us-010-administer-player-progression.md)
@@ -0,0 +1,34 @@
---
type: User Story
title: "US-014: Highlight spawnable blocks"
description: Unlock an owner-only red particle overlay for nearby hostile-mob spawnable blocks after 250 placements anywhere.
status: done
---
# US-014: Highlight spawnable blocks
As a **player with an established base**, I want to highlight nearby blocks where hostile mobs can spawn so that I can find lighting gaps in my base.
## Acceptance criteria
- [x] Every block placed in Survival mode anywhere contributes to a persistent total-placement counter.
- [x] The overlay unlocks permanently at a configurable 250 total placements by default.
- [x] Existing players begin with at least their persisted in-base placement count because those placements are known to qualify.
- [x] `/basesettings spawnable enable` enables the unlocked overlay idempotently, and `/basesettings spawnable disable` disables it idempotently.
- [x] `spawnable`, `enable`, and `disable` are offered through contextual autocomplete.
- [x] Enabling requires both the placement unlock and an established base; the persisted preference defaults to disabled.
- [x] `/basesettings status` and `/baseadmin status` display placement progress, unlock state, and preference.
- [x] Administrators can change a player's total-placement counter through `/baseadmin setprogress`.
- [x] Administrators can persistently change the required placement threshold through `/baseadmin config spawnable-overlay-unlock-placements`.
- [x] Administrators can enable or disable an unlocked player's overlay through `/baseadmin setsetting <player> spawnable <enable|disable>`.
- [x] Owner-only red particles mark nearby candidate hostile-mob spawning surfaces inside the current base bounds.
- [x] Candidate surfaces have block light zero, a solid spawning surface, and two passable blocks above them.
- [x] Scanning is bounded to 16 horizontal blocks, eight vertical blocks, and a fixed per-update work and particle budget.
- [x] A complete administrative reset clears total-placement progress and disables the overlay.
## Related
- [US-001: Unlock and establish Base I](us-001-unlock-and-establish-base.md)
- [US-009: View progression and notifications](us-009-view-progression-and-notifications.md)
- [US-010: Administer player progression](us-010-administer-player-progression.md)
- [US-011: Configure and persist progression](us-011-configure-and-persist-progression.md)
@@ -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,39 @@
---
type: User Story
title: "US-022: Share Pocket Base flight through a portal"
description: Let an owner build a gold-block portal that temporarily grants controlled Pocket Base flight to players who pass through it.
status: done
---
# US-022: Share Pocket Base flight through a portal
As a **Pocket Base owner**, I want an activated flight portal inside my Pocket Base so that I can temporarily share controlled flight with guests without changing my own default-flight preference.
## Acceptance criteria
- [x] Entering the active flight portal immediately starts eligible Survival players flying without double-jumping.
- [x] Each portal entry sends “Pocket dimension flight enabled!” without repeated messages while standing in the portal.
- [x] Immediate takeoff preserves existing flight boundaries and expiry rules, with regression tests.
- [x] The owner must first unlock Pocket Base flight by winning a raid in their own Pocket Base.
- [x] Only the owner can activate a complete 4-by-5 gold-block frame with flint and steel as the active flight portal.
- [x] Every required frame block must be inside the owner's currently unlocked Pocket Base boundary at activation.
- [x] Each Pocket Base has at most one active flight portal, and activating another valid frame moves the active location without removing the old frame.
- [x] The active flight portal uses custom particles and collision without vanilla Nether portal blocks.
- [x] Breaking any required block in the active frame deactivates the flight portal.
- [x] Any player, including the owner or a guest, who passes through the active intact flight portal receives temporary Pocket Base flight.
- [x] Portal-granted flight remains independent of the owner's default Pocket Base flight preference.
- [x] Portal-granted flight spans the world's full build height and the current Pocket Base boundary plus 16 blocks on every side.
- [x] An actively flying player receives a warning inside the 16-block buffer, and flight ends beyond it.
- [x] Expanding the Pocket Base automatically expands the portal-granted flight area.
- [x] Portal-granted flight ends when the player leaves the Pocket Base, dies, disconnects, or changes out of Survival mode.
- [x] The active flight portal location and owner default-flight preference persist across restarts.
- [x] Existing saved Pocket Bases default to no flight portal while preserving raid-earned owner flight as enabled.
## Related
- [US-011: Configure and persist progression](us-011-configure-and-persist-progression.md)
- [US-016: Build and use Pocket Base portals](us-016-build-and-use-pocket-base-portals.md)
- [US-017: Expand a Pocket Base](us-017-expand-a-pocket-base.md)
- [US-020: Control a base through a dialog UI](us-020-control-base-through-dialog-ui.md)
- [US-021: Unlock Pocket Base flight](us-021-unlock-pocket-base-flight.md)
@@ -0,0 +1,39 @@
---
type: User Story
title: "US-023: Craft and use a Pocket Base Keystone"
description: Let Pocket Base owners craft a reusable item for instant round-trip travel between their current location and their private dimension.
status: done
---
# US-023: Craft and use a Pocket Base Keystone
As a **Pocket Base owner**, I want to craft a reusable keystone that instantly takes me into my Pocket Base and back so that I can access my private dimension without finding a portal.
## Acceptance criteria
- [x] The Pocket Base Keystone is a distinct Recovery Compass identified by persistent item metadata rather than its display name alone.
- [x] Keystone creation accepts the valid generic item metadata supplied for Recovery Compasses by the supported Purpur runtime.
- [x] Its shaped recipe places an Ender Pearl in the center, Netherite Ingots on the four cardinal slots, and Obsidian in the four corners.
- [x] Only a player who has unlocked Pocket Base I can complete the recipe or use a keystone.
- [x] Right-clicking a keystone outside a Pocket Base instantly teleports the player to their own Pocket Base arrival point.
- [x] Entering records the player's exact prior world, position, yaw, and pitch as the return destination.
- [x] Right-clicking a keystone inside the player's own Pocket Base returns them safely to the recorded destination.
- [x] Return destinations persist across logout and server restart.
- [x] Missing worlds and unsafe destinations fail safely without replacing or discarding the recorded return destination.
- [x] A keystone is reusable, has no warm-up or cooldown, and always accesses the user's own unlocked Pocket Base even when transferred between players.
- [x] Keystone travel clears accumulated fall distance and does not change existing portal travel behavior.
- [x] Automated tests cover recipe shape, crafting and use eligibility, metadata authentication, entry, return, persistence, and unavailable or unsafe destinations.
- [x] Keystone entry and return work when right-clicking air or blocks, regardless of facing direction.
- [x] Vanilla no-action events are handled without bypassing explicit item-use restrictions.
- [x] Regression tests cover air and block interactions for entry and return, and denied item use.
## Verification
- `./gradlew clean check jar` passes, including event-dispatch regression coverage for air/block entry and return and explicit item-use denial.
- Live-server verification remains pending deployment.
## Related
- [US-015: Unlock a Pocket Base](us-015-unlock-a-pocket-base.md)
- [US-016: Build and use Pocket Base portals](us-016-build-and-use-pocket-base-portals.md)
+4
View File
@@ -1 +1,5 @@
plugins {
id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
}
rootProject.name = "spigot-base"
@@ -92,6 +92,7 @@ public final class AdminProgressionService {
long deepslate = player.deepslateBroken();
long obsidian = player.obsidianBroken();
long placements = player.blocksPlacedInBase();
long totalPlacements = player.totalBlocksPlaced();
long baseBreaks = player.blocksBrokenInBase();
switch (counter) {
case GRASS_DIRT -> grassDirt = amount;
@@ -99,6 +100,7 @@ public final class AdminProgressionService {
case DEEPSLATE -> deepslate = amount;
case OBSIDIAN -> obsidian = amount;
case PLACEMENTS -> placements = amount;
case TOTAL_PLACEMENTS -> totalPlacements = amount;
case BASE_BREAKS -> baseBreaks = amount;
}
PluginSettings configured = settings.current();
@@ -147,7 +149,11 @@ public final class AdminProgressionService {
}
PlayerState updated = player.withProgressCounters(
grassDirt, stone, deepslate, obsidian, placements, baseBreaks
);
).withTotalBlocksPlaced(totalPlacements);
if (totalPlacements < configured.spawnableOverlayUnlockPlacements()
&& updated.spawnableOverlayEnabled()) {
updated = updated.withSpawnableOverlayEnabled(false);
}
return updated.withAdministrativeLevels(
base,
size,
@@ -1,17 +1,38 @@
package games.dmg.spigotbase;
import java.util.Arrays;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
import java.util.TreeSet;
import org.bukkit.Bukkit;
import org.bukkit.ChatColor;
import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender;
import org.bukkit.command.TabCompleter;
import org.bukkit.entity.Player;
import org.bukkit.plugin.java.JavaPlugin;
final class BaseAdminCommand implements CommandExecutor {
final class BaseAdminCommand implements CommandExecutor, TabCompleter {
private static final List<String> SUBCOMMANDS = List.of(
"status", "setlevel", "setprogress", "setsetting", "clearcooldown", "reset", "config"
);
private static final List<String> PATHS = List.of(
"base", "size", "flight", "warmup", "cooldown"
);
private static final List<String> COUNTERS = List.of(
"grass_dirt", "stone", "deepslate", "obsidian", "placements", "total_placements",
"base_breaks"
);
private static final List<String> SETTINGS = List.of("spawnable");
private static final List<String> ENABLE_MODES = List.of("enable", "disable");
private static final List<String> COOLDOWN_TYPES = List.of("personal", "visitor", "all");
private static final List<String> RESET_PATHS = List.of(
"size", "flight", "warmup", "cooldown", "all"
);
private static final List<String> LEVELS_THREE = List.of("0", "1", "2", "3");
private static final List<String> LEVELS_FOUR = List.of("0", "1", "2", "3", "4");
private final JavaPlugin plugin;
private final BaseStateManager stateManager;
private final AdminProgressionService progressionService;
@@ -48,9 +69,10 @@ final class BaseAdminCommand implements CommandExecutor {
return true;
}
return switch (arguments[0].toLowerCase(Locale.ROOT)) {
case "progress" -> showProgress(sender, target.orElseThrow());
case "status" -> showProgress(sender, target.orElseThrow(), arguments);
case "setlevel" -> setLevel(sender, target.orElseThrow(), arguments);
case "setprogress" -> setProgress(sender, target.orElseThrow(), arguments);
case "setsetting" -> setSetting(sender, target.orElseThrow(), arguments);
case "clearcooldown" -> clearCooldown(sender, target.orElseThrow(), arguments);
case "reset" -> reset(sender, target.orElseThrow(), arguments);
default -> {
@@ -60,7 +82,15 @@ final class BaseAdminCommand implements CommandExecutor {
};
}
private boolean showProgress(CommandSender sender, PlayerState player) {
private boolean showProgress(
CommandSender sender,
PlayerState player,
String[] arguments
) {
if (arguments.length != 2) {
sender.sendMessage(ChatColor.RED + "Usage: /baseadmin status <player>");
return true;
}
sender.sendMessage(ChatColor.GOLD + "=== " + player.latestName() + " Base Progress ===");
sender.sendMessage(ChatColor.YELLOW + "Levels: base=" + player.baseLevel()
+ " size=" + player.sizeLevel() + " flight=" + player.flightLevel()
@@ -68,11 +98,17 @@ final class BaseAdminCommand implements CommandExecutor {
sender.sendMessage(ChatColor.GRAY + "Grass/dirt=" + player.grassAndDirtBroken()
+ " stone=" + player.stoneBroken() + " deepslate=" + player.deepslateBroken()
+ " obsidian=" + player.obsidianBroken());
int spawnableThreshold = settingsProvider.current()
.spawnableOverlayUnlockPlacements();
sender.sendMessage(ChatColor.GRAY + "In-base placements=" + player.blocksPlacedInBase()
+ " total placements=" + player.totalBlocksPlaced()
+ " breaks=" + player.blocksBrokenInBase());
sender.sendMessage(ChatColor.GRAY + "Spawnable overlay="
+ (player.totalBlocksPlaced() >= spawnableThreshold ? "unlocked" : "locked")
+ " (" + player.totalBlocksPlaced() + "/" + spawnableThreshold + ")");
sender.sendMessage(ChatColor.GRAY + "Toggles: navigation=" + player.navigationEnabled()
+ " flight=" + player.flightEnabled() + " bossbar=" + player.bossBarEnabled()
+ " visitors=" + player.visitorsEnabled());
+ " flight=" + player.flightEnabled() + " border=" + player.borderEnabled()
+ " spawnable=" + player.spawnableOverlayEnabled() + " bossbar=" + player.bossBarEnabled() + " visitors=" + player.visitorsEnabled());
sender.sendMessage(ChatColor.GRAY + "Base: " + player.base()
.map(base -> base.worldName() + " " + base.x() + "," + base.y() + "," + base.z())
.orElse("not set"));
@@ -89,8 +125,16 @@ final class BaseAdminCommand implements CommandExecutor {
sender.sendMessage(ChatColor.RED + "Usage: /baseadmin setlevel <player> <path> <level>");
return true;
}
final ProgressionPath path;
try {
path = ProgressionPath.valueOf(arguments[2].toUpperCase(Locale.ROOT));
} catch (IllegalArgumentException exception) {
sender.sendMessage(ChatColor.RED
+ "Usage: /baseadmin setlevel <player> "
+ "<base|size|flight|warmup|cooldown> <level>");
return true;
}
try {
ProgressionPath path = ProgressionPath.valueOf(arguments[2].toUpperCase(Locale.ROOT));
int level = Integer.parseInt(arguments[3]);
PlayerState updated = stateManager.update(
target.playerId(),
@@ -100,6 +144,8 @@ final class BaseAdminCommand implements CommandExecutor {
stateManager.saveIfDirty();
sender.sendMessage(ChatColor.GREEN + "Set " + updated.latestName() + "'s "
+ path.name().toLowerCase(Locale.ROOT) + " level to " + level + ".");
} catch (NumberFormatException exception) {
sender.sendMessage(ChatColor.RED + "The level must be an integer.");
} catch (IllegalArgumentException exception) {
sender.sendMessage(ChatColor.RED + exception.getMessage());
}
@@ -116,10 +162,19 @@ final class BaseAdminCommand implements CommandExecutor {
+ "Usage: /baseadmin setprogress <player> <counter> <amount>");
return true;
}
final ProgressCounter counter;
try {
ProgressCounter counter = ProgressCounter.valueOf(
counter = ProgressCounter.valueOf(
arguments[2].toUpperCase(Locale.ROOT).replace('-', '_')
);
} catch (IllegalArgumentException exception) {
sender.sendMessage(ChatColor.RED
+ "Usage: /baseadmin setprogress <player> "
+ "<grass_dirt|stone|deepslate|obsidian|placements|total_placements|base_breaks> "
+ "<amount>");
return true;
}
try {
long amount = Long.parseLong(arguments[3]);
PlayerState updated = stateManager.update(
target.playerId(),
@@ -137,12 +192,50 @@ final class BaseAdminCommand implements CommandExecutor {
return true;
}
private boolean setSetting(
CommandSender sender,
PlayerState target,
String[] arguments
) {
if (arguments.length != 4
|| !arguments[2].equalsIgnoreCase("spawnable")
|| !ENABLE_MODES.contains(arguments[3].toLowerCase(Locale.ROOT))) {
sender.sendMessage(ChatColor.RED + "Usage: /baseadmin setsetting <player> "
+ "spawnable <enable|disable>");
return true;
}
boolean enabled = arguments[3].equalsIgnoreCase("enable");
if (enabled && target.totalBlocksPlaced()
< settingsProvider.current().spawnableOverlayUnlockPlacements()) {
sender.sendMessage(ChatColor.RED + "That player has not unlocked the spawnable overlay.");
return true;
}
if (enabled && (target.baseLevel() < 1 || target.base().isEmpty())) {
sender.sendMessage(ChatColor.RED + "That player does not have an established base.");
return true;
}
PlayerState updated = stateManager.update(
target.playerId(),
target.latestName(),
current -> current.withSpawnableOverlayEnabled(enabled)
);
stateManager.saveIfDirty();
sender.sendMessage(ChatColor.GREEN + "Set " + updated.latestName()
+ "'s spawnable overlay to " + (enabled ? "enabled" : "disabled") + ".");
return true;
}
private boolean clearCooldown(
CommandSender sender,
PlayerState target,
String[] arguments
) {
String selection = arguments.length >= 3
if (arguments.length < 2 || arguments.length > 3) {
sender.sendMessage(ChatColor.RED
+ "Usage: /baseadmin clearcooldown <player> [personal|visitor|all]");
return true;
}
String selection = arguments.length == 3
? arguments[2].toLowerCase(Locale.ROOT)
: "all";
if (!Arrays.asList("personal", "visitor", "all").contains(selection)) {
@@ -186,8 +279,26 @@ final class BaseAdminCommand implements CommandExecutor {
sender.sendMessage(ChatColor.GREEN + "Reset all progression for " + target.latestName() + ".");
return true;
}
if (arguments.length != 3) {
sender.sendMessage(ChatColor.RED
+ "Usage: /baseadmin reset <player> <size|flight|warmup|cooldown|all> [confirm]");
return true;
}
if (arguments[2].equalsIgnoreCase("base")) {
sender.sendMessage(ChatColor.RED
+ "Resetting base removes all progression. Use: /baseadmin reset "
+ target.latestName() + " all confirm");
return true;
}
final ProgressionPath path;
try {
path = ProgressionPath.valueOf(arguments[2].toUpperCase(Locale.ROOT));
} catch (IllegalArgumentException exception) {
sender.sendMessage(ChatColor.RED
+ "Usage: /baseadmin reset <player> <size|flight|warmup|cooldown|all> [confirm]");
return true;
}
try {
ProgressionPath path = ProgressionPath.valueOf(arguments[2].toUpperCase(Locale.ROOT));
stateManager.update(
target.playerId(),
target.latestName(),
@@ -239,6 +350,80 @@ final class BaseAdminCommand implements CommandExecutor {
return true;
}
@Override
public List<String> onTabComplete(
CommandSender sender,
Command command,
String alias,
String[] arguments
) {
if (!sender.hasPermission("spigotbase.admin")) {
return List.of();
}
if (arguments.length == 1) {
String prefix = arguments[0].toLowerCase(Locale.ROOT);
return SUBCOMMANDS.stream()
.filter(subcommand -> subcommand.startsWith(prefix))
.toList();
}
if (arguments.length == 2 && arguments[0].equalsIgnoreCase("config")) {
String prefix = arguments[1].toLowerCase(Locale.ROOT);
return plugin.getConfig().getValues(false).entrySet().stream()
.filter(entry -> entry.getValue() instanceof Number)
.map(java.util.Map.Entry::getKey)
.filter(key -> key.toLowerCase(Locale.ROOT).startsWith(prefix))
.sorted(String.CASE_INSENSITIVE_ORDER)
.toList();
}
if (arguments.length == 2) {
String prefix = arguments[1].toLowerCase(Locale.ROOT);
TreeSet<String> names = new TreeSet<>(String.CASE_INSENSITIVE_ORDER);
stateManager.knownPlayers().values().stream()
.map(PlayerState::latestName)
.forEach(names::add);
if (plugin.getServer() != null) {
plugin.getServer().getOnlinePlayers().stream()
.map(Player::getName)
.forEach(names::add);
}
return names.stream()
.filter(name -> name.toLowerCase(Locale.ROOT).startsWith(prefix))
.toList();
}
if (arguments.length == 3) {
List<String> options = switch (arguments[0].toLowerCase(Locale.ROOT)) {
case "setlevel" -> PATHS;
case "setprogress" -> COUNTERS;
case "setsetting" -> SETTINGS;
case "clearcooldown" -> COOLDOWN_TYPES;
case "reset" -> RESET_PATHS;
default -> List.of();
};
return complete(options, arguments[2]);
}
if (arguments.length == 4 && arguments[0].equalsIgnoreCase("setsetting")) {
return complete(ENABLE_MODES, arguments[3]);
}
if (arguments.length == 4 && arguments[0].equalsIgnoreCase("setlevel")) {
List<String> levels = switch (arguments[2].toLowerCase(Locale.ROOT)) {
case "base", "cooldown" -> LEVELS_FOUR;
case "size", "flight", "warmup" -> LEVELS_THREE;
default -> List.of();
};
return complete(levels, arguments[3]);
}
if (arguments.length == 4 && arguments[0].equalsIgnoreCase("reset")
&& arguments[2].equalsIgnoreCase("all")) {
return complete(List.of("confirm"), arguments[3]);
}
return List.of();
}
private static List<String> complete(List<String> options, String input) {
String prefix = input.toLowerCase(Locale.ROOT);
return options.stream().filter(option -> option.startsWith(prefix)).toList();
}
private Optional<PlayerState> resolve(String name) {
Player online = Bukkit.getPlayerExact(name);
if (online != null) {
@@ -249,7 +434,7 @@ final class BaseAdminCommand implements CommandExecutor {
private static void sendUsage(CommandSender sender) {
sender.sendMessage(ChatColor.YELLOW + "Usage: /baseadmin "
+ "<progress|setlevel|setprogress|clearcooldown|reset> <player> ...");
+ "<status|setlevel|setprogress|setsetting|clearcooldown|reset> <player> ...");
sender.sendMessage(ChatColor.YELLOW + " /baseadmin config <key> <integer>");
}
}
@@ -0,0 +1,62 @@
package games.dmg.spigotbase;
import org.bukkit.Location;
import org.bukkit.Particle;
import org.bukkit.Server;
import org.bukkit.entity.Player;
final class BaseBorderController implements Runnable {
private final Server server;
private final BaseStateManager stateManager;
private final BaseBoundsService boundsService;
BaseBorderController(
Server server,
BaseStateManager stateManager,
BaseBoundsService boundsService
) {
this.server = server;
this.stateManager = stateManager;
this.boundsService = boundsService;
}
@Override
public void run() {
for (Player player : server.getOnlinePlayers()) {
PlayerState state = stateManager.player(player.getUniqueId(), player.getName());
if (!state.borderEnabled() || state.base().isEmpty()) {
continue;
}
BaseLocation base = state.base().orElseThrow();
if (!player.getWorld().getUID().equals(base.worldId())) {
continue;
}
Location playerLocation = player.getLocation();
long minimumY = (long) base.y() - boundsService.verticalRange(state);
long maximumY = (long) base.y() + boundsService.verticalRange(state);
if (playerLocation.getY() < minimumY || playerLocation.getY() > maximumY) {
continue;
}
for (BaseBorderGeometry.Point point : BaseBorderGeometry.visiblePoints(
base.x() + 0.5,
base.z() + 0.5,
boundsService.radius(state),
playerLocation.getX(),
playerLocation.getZ()
)) {
Location particle = new Location(
player.getWorld(),
point.x(),
playerLocation.getY() + 0.15,
point.z()
);
player.spawnParticle(
Particle.END_ROD,
particle,
1,
0.0, 0.0, 0.0, 0.0
);
}
}
}
}
@@ -0,0 +1,58 @@
package games.dmg.spigotbase;
import java.util.ArrayList;
import java.util.List;
final class BaseBorderGeometry {
static final int MAX_PARTICLES = 64;
static final double VISIBILITY_DISTANCE = 32.0;
private static final double PARTICLE_SPACING = 1.0;
private BaseBorderGeometry() {
}
static List<Point> visiblePoints(
double centerX,
double centerZ,
int radius,
double playerX,
double playerZ
) {
if (radius <= 0) {
throw new IllegalArgumentException("border radius must be positive");
}
double playerRadius = Math.hypot(playerX - centerX, playerZ - centerZ);
if (Math.abs(playerRadius - radius) > VISIBILITY_DISTANCE) {
return List.of();
}
double circumference = 2.0 * Math.PI * radius;
int count = Math.min(MAX_PARTICLES, Math.max(1, (int) Math.ceil(
circumference / PARTICLE_SPACING
)));
double centerAngle = Math.atan2(playerZ - centerZ, playerX - centerX);
double angleStep = circumference <= MAX_PARTICLES * PARTICLE_SPACING
? 2.0 * Math.PI / count
: PARTICLE_SPACING / radius;
double startAngle = circumference <= MAX_PARTICLES * PARTICLE_SPACING
? 0.0
: centerAngle - angleStep * (count - 1) / 2.0;
List<Point> points = new ArrayList<>(count);
double visibilitySquared = VISIBILITY_DISTANCE * VISIBILITY_DISTANCE;
for (int index = 0; index < count; index++) {
double angle = startAngle + angleStep * index;
double x = centerX + radius * Math.cos(angle);
double z = centerZ + radius * Math.sin(angle);
double deltaX = x - playerX;
double deltaZ = z - playerZ;
if (deltaX * deltaX + deltaZ * deltaZ <= visibilitySquared) {
points.add(new Point(x, z));
}
}
return List.copyOf(points);
}
record Point(double x, double z) {
}
}
@@ -1,31 +1,16 @@
package games.dmg.spigotbase;
import java.io.IOException;
import org.bukkit.ChatColor;
import org.bukkit.Material;
import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.PlayerInventory;
final class BaseCommand implements CommandExecutor {
private final BaseTeleportManager teleportManager;
private final BaseStateManager stateManager;
private final VisitorPolicy visitorPolicy;
private final PluginSettingsProvider settings;
BaseCommand(
BaseTeleportManager teleportManager,
BaseStateManager stateManager,
VisitorPolicy visitorPolicy,
PluginSettingsProvider settings
) {
BaseCommand(BaseTeleportManager teleportManager) {
this.teleportManager = teleportManager;
this.stateManager = stateManager;
this.visitorPolicy = visitorPolicy;
this.settings = settings;
}
@Override
@@ -38,95 +23,7 @@ final class BaseCommand implements CommandExecutor {
teleportManager.start(player);
return true;
}
if (arguments.length == 1 && arguments[0].equalsIgnoreCase("upgrade")) {
purchaseVisitorAccess(player);
return true;
}
player.sendMessage(ChatColor.RED + "Usage: /base [upgrade]");
player.sendMessage(ChatColor.RED + "Usage: /base");
return true;
}
private void purchaseVisitorAccess(Player player) {
PlayerState state = stateManager.player(player.getUniqueId(), player.getName());
if (!visitorPolicy.canPurchase(state)) {
player.sendMessage(ChatColor.RED + (state.baseLevel() >= 4
? "Base IV is already unlocked."
: "You must unlock Base III before purchasing Base IV."));
return;
}
int price = settings.current().visitorUnlockDiamondCost();
Material currency = Material.valueOf(settings.current().visitorCurrencyMaterial());
PlayerInventory inventory = player.getInventory();
if (countCurrency(inventory, currency) < price) {
player.sendMessage(ChatColor.RED + "Base IV costs " + price + " "
+ currency.name().toLowerCase(java.util.Locale.ROOT) + ".");
return;
}
ItemStack[] snapshot = cloneContents(inventory.getStorageContents());
removeCurrency(inventory, currency, price);
try {
stateManager.updateAndSave(
player.getUniqueId(),
player.getName(),
current -> current.withBaseLevel(4).withVisitorsEnabled(true)
);
} catch (IOException | RuntimeException exception) {
inventory.setStorageContents(snapshot);
player.sendMessage(ChatColor.RED + "The upgrade could not be saved; your diamonds were restored.");
return;
}
player.sendTitle(
ChatColor.GOLD + "Base IV Unlocked",
ChatColor.YELLOW + "Visitors may now teleport to your base",
settings.current().titleFadeInTicks(),
settings.current().titleStayTicks(),
settings.current().titleFadeOutTicks()
);
player.sendMessage(ChatColor.GREEN + "Base IV unlocked for " + price + " "
+ currency.name().toLowerCase(java.util.Locale.ROOT) + ".");
}
private static int countCurrency(PlayerInventory inventory, Material currency) {
int count = 0;
for (ItemStack item : inventory.getStorageContents()) {
if (item != null && item.getType() == currency) {
count += item.getAmount();
}
}
return count;
}
private static void removeCurrency(
PlayerInventory inventory,
Material currency,
int amount
) {
ItemStack[] contents = inventory.getStorageContents();
int remaining = amount;
for (int index = 0; index < contents.length && remaining > 0; index++) {
ItemStack item = contents[index];
if (item == null || item.getType() != currency) {
continue;
}
int removed = Math.min(remaining, item.getAmount());
remaining -= removed;
int newAmount = item.getAmount() - removed;
if (newAmount == 0) {
contents[index] = null;
} else {
ItemStack reduced = item.clone();
reduced.setAmount(newAmount);
contents[index] = reduced;
}
}
inventory.setStorageContents(contents);
}
private static ItemStack[] cloneContents(ItemStack[] contents) {
ItemStack[] copy = new ItemStack[contents.length];
for (int index = 0; index < contents.length; index++) {
copy[index] = contents[index] == null ? null : contents[index].clone();
}
return copy;
}
}
@@ -1,68 +0,0 @@
package games.dmg.spigotbase;
import java.util.List;
import java.util.Locale;
import org.bukkit.ChatColor;
import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender;
import org.bukkit.command.TabCompleter;
import org.bukkit.entity.Player;
final class BaseFlightCommand implements CommandExecutor, TabCompleter {
private static final List<String> MODES = List.of("on", "off");
private final BaseStateManager stateManager;
private final BaseFlightController controller;
BaseFlightCommand(BaseStateManager stateManager, BaseFlightController controller) {
this.stateManager = stateManager;
this.controller = controller;
}
@Override
public boolean onCommand(CommandSender sender, Command command, String label, String[] arguments) {
if (!(sender instanceof Player player)) {
sender.sendMessage("Only players can use base flight.");
return true;
}
PlayerState state = stateManager.player(player.getUniqueId(), player.getName());
if (state.flightLevel() < 1) {
player.sendMessage(ChatColor.RED + "Base flight is still locked.");
return true;
}
final boolean enabled;
try {
enabled = TogglePreference.resolve(state.flightEnabled(), arguments);
} catch (IllegalArgumentException exception) {
player.sendMessage(ChatColor.RED + "Usage: /baseflight [on|off]");
return true;
}
state = stateManager.update(
player.getUniqueId(),
player.getName(),
current -> current.withFlightEnabled(enabled)
);
if (!state.flightEnabled()) {
controller.removeGrantedFlight(player);
}
stateManager.saveIfDirty();
player.sendMessage(ChatColor.YELLOW + "Base flight is now "
+ (state.flightEnabled() ? ChatColor.GREEN + "on" : ChatColor.RED + "off")
+ ChatColor.YELLOW + ".");
return true;
}
@Override
public List<String> onTabComplete(
CommandSender sender,
Command command,
String alias,
String[] arguments
) {
if (arguments.length != 1) {
return List.of();
}
String prefix = arguments[0].toLowerCase(Locale.ROOT);
return MODES.stream().filter(mode -> mode.startsWith(prefix)).toList();
}
}
@@ -1,23 +1,38 @@
package games.dmg.spigotbase;
import java.io.IOException;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.logging.Level;
import java.util.logging.Logger;
import org.bukkit.ChatColor;
import org.bukkit.GameMode;
import org.bukkit.Material;
import org.bukkit.Server;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.entity.PlayerDeathEvent;
import org.bukkit.event.player.PlayerQuitEvent;
import org.bukkit.event.raid.RaidFinishEvent;
import org.bukkit.inventory.ItemStack;
final class BaseFlightController implements Runnable {
final class BaseFlightController implements Runnable, Listener {
private static final int POCKET_FLIGHT_BUFFER = 16;
private final Server server;
private final BaseStateManager stateManager;
private final SecondaryProgressionService progressionService;
private final BaseBoundsService boundsService;
private final PluginSettingsProvider settings;
private final PocketBaseManager pocketBases;
private final Logger logger;
private final Set<UUID> grantedFlight = new HashSet<>();
private final Set<UUID> warned = new HashSet<>();
private final Map<UUID, UUID> portalFlight = new HashMap<>();
BaseFlightController(
Server server,
@@ -25,12 +40,85 @@ final class BaseFlightController implements Runnable {
SecondaryProgressionService progressionService,
BaseBoundsService boundsService,
PluginSettingsProvider settings
) {
this(server, stateManager, progressionService, boundsService, settings, null,
Logger.getLogger(BaseFlightController.class.getName()));
}
BaseFlightController(
Server server,
BaseStateManager stateManager,
SecondaryProgressionService progressionService,
BaseBoundsService boundsService,
PluginSettingsProvider settings,
PocketBaseManager pocketBases,
Logger logger
) {
this.server = server;
this.stateManager = stateManager;
this.progressionService = progressionService;
this.boundsService = boundsService;
this.settings = settings;
this.pocketBases = pocketBases;
this.logger = logger;
}
@EventHandler
public void onRaidFinish(RaidFinishEvent event) {
if (pocketBases == null) {
return;
}
Optional<UUID> owner = pocketBases.ownerForPocketWorld(event.getWorld().getUID());
if (owner.isEmpty() || event.getWinners().stream().noneMatch(
winner -> winner.getUniqueId().equals(owner.orElseThrow()))) {
return;
}
UUID ownerId = owner.orElseThrow();
if (pocketBases.state(ownerId).flightUnlocked()) {
return;
}
try {
pocketBases.unlockFlight(ownerId);
} catch (IOException | RuntimeException exception) {
logger.log(Level.SEVERE, "Could not unlock Pocket Base flight", exception);
return;
}
event.getWinners().stream()
.filter(winner -> winner.getUniqueId().equals(ownerId))
.findFirst()
.orElseThrow()
.sendTitle(
ChatColor.GOLD + "Pocket Base Flight Unlocked",
ChatColor.YELLOW + "Flight is now available in your Pocket Base",
settings.current().titleFadeInTicks(),
settings.current().titleStayTicks(),
settings.current().titleFadeOutTicks()
);
}
@EventHandler
public void onDeath(PlayerDeathEvent event) {
clearPortalFlight(event.getEntity());
}
@EventHandler
public void onQuit(PlayerQuitEvent event) {
clearPortalFlight(event.getPlayer());
}
void grantPocketPortalFlight(Player player, UUID ownerId) {
if (player.getGameMode() != GameMode.SURVIVAL || pocketBases == null
|| !pocketBases.ownerForPocketWorld(player.getWorld().getUID())
.filter(ownerId::equals).isPresent()) {
return;
}
portalFlight.put(player.getUniqueId(), ownerId);
if (applyPocketFlight(player, ownerId)) {
player.setFlying(true);
player.sendMessage(ChatColor.GREEN + "Pocket dimension flight enabled!");
} else {
portalFlight.remove(player.getUniqueId());
}
}
@Override
@@ -44,6 +132,7 @@ final class BaseFlightController implements Runnable {
}
grantedFlight.removeIf(id -> server.getPlayer(id) == null);
warned.removeIf(id -> server.getPlayer(id) == null);
portalFlight.keySet().removeIf(id -> server.getPlayer(id) == null);
}
void removeGrantedFlight(Player player) {
@@ -66,6 +155,7 @@ final class BaseFlightController implements Runnable {
}
grantedFlight.clear();
warned.clear();
portalFlight.clear();
}
private PlayerState observeElytra(Player player, PlayerState state) {
@@ -80,7 +170,7 @@ final class BaseFlightController implements Runnable {
stateManager.saveIfDirty();
player.sendTitle(
ChatColor.GOLD + "Base Flight " + roman(updated.flightLevel()) + " Unlocked",
ChatColor.YELLOW + "Use /baseflight to toggle flight",
ChatColor.YELLOW + "Use /basesettings flight enable to enable flight",
settings.current().titleFadeInTicks(),
settings.current().titleStayTicks(),
settings.current().titleFadeOutTicks()
@@ -89,8 +179,22 @@ final class BaseFlightController implements Runnable {
}
private void applyFlight(Player player, PlayerState state) {
if (player.getGameMode() != GameMode.SURVIVAL
|| !state.flightEnabled() || state.flightLevel() < 1 || state.base().isEmpty()) {
if (player.getGameMode() != GameMode.SURVIVAL) {
portalFlight.remove(player.getUniqueId());
removeGrantedFlight(player);
return;
}
if (pocketBases != null) {
Optional<UUID> pocketOwner = pocketBases.ownerForPocketWorld(
player.getWorld().getUID()
);
if (pocketOwner.isPresent()) {
applyPocketFlight(player, pocketOwner.orElseThrow());
return;
}
}
portalFlight.remove(player.getUniqueId());
if (!state.flightEnabled() || state.flightLevel() < 1 || state.base().isEmpty()) {
removeGrantedFlight(player);
return;
}
@@ -112,7 +216,7 @@ final class BaseFlightController implements Runnable {
player.setAllowFlight(true);
grantedFlight.add(player.getUniqueId());
}
if (distanceSquared > (double) radius * radius) {
if (distanceSquared > (double) radius * radius && player.isFlying()) {
if (warned.add(player.getUniqueId())) {
player.sendTitle(
ChatColor.RED + "Leaving Your Base",
@@ -127,6 +231,51 @@ final class BaseFlightController implements Runnable {
}
}
private boolean applyPocketFlight(Player player, UUID ownerId) {
PocketBaseState pocket = pocketBases.state(ownerId);
boolean ownerFlight = ownerId.equals(player.getUniqueId())
&& pocket.flightUnlocked() && pocket.flightEnabled();
boolean temporaryFlight = ownerId.equals(portalFlight.get(player.getUniqueId()));
if ((!ownerFlight && !temporaryFlight) || pocket.level() < 1) {
removeGrantedFlight(player);
return false;
}
int halfSize = pocketBases.policy().size(pocket.level()) / 2;
int x = player.getLocation().getBlockX();
int z = player.getLocation().getBlockZ();
if (x < -halfSize - POCKET_FLIGHT_BUFFER
|| x >= halfSize + POCKET_FLIGHT_BUFFER
|| z < -halfSize - POCKET_FLIGHT_BUFFER
|| z >= halfSize + POCKET_FLIGHT_BUFFER) {
removeGrantedFlight(player);
return false;
}
if (!player.getAllowFlight()) {
player.setAllowFlight(true);
grantedFlight.add(player.getUniqueId());
}
boolean inBuffer = !pocketBases.policy().contains(pocket.level(), x, z);
if (inBuffer && player.isFlying()) {
if (warned.add(player.getUniqueId())) {
player.sendTitle(
ChatColor.RED + "Leaving Your Pocket Base",
ChatColor.YELLOW + "Turn back before Pocket Base flight ends",
0,
Math.min(30, settings.current().titleStayTicks()),
settings.current().titleFadeOutTicks()
);
}
} else {
warned.remove(player.getUniqueId());
}
return true;
}
private void clearPortalFlight(Player player) {
portalFlight.remove(player.getUniqueId());
removeGrantedFlight(player);
}
private boolean withinVerticalRange(Player player, PlayerState state, BaseLocation base) {
int y = player.getLocation().getBlockY();
if (state.flightLevel() == 3) {
@@ -1,73 +0,0 @@
package games.dmg.spigotbase;
import java.util.List;
import java.util.Locale;
import org.bukkit.ChatColor;
import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender;
import org.bukkit.command.TabCompleter;
import org.bukkit.entity.Player;
final class BaseNavigationCommand implements CommandExecutor, TabCompleter {
private static final List<String> MODES = List.of("on", "off");
private final BaseStateManager stateManager;
BaseNavigationCommand(BaseStateManager stateManager) {
this.stateManager = stateManager;
}
@Override
public boolean onCommand(CommandSender sender, Command command, String label, String[] arguments) {
if (!(sender instanceof Player player)) {
sender.sendMessage("Only players can use base navigation.");
return true;
}
PlayerState state = stateManager.player(player.getUniqueId(), player.getName());
if (state.baseLevel() < 2) {
player.sendMessage(ChatColor.RED + "Base II navigation is still locked.");
return true;
}
if (state.base().isEmpty()) {
player.sendMessage(ChatColor.RED + "Set your base before enabling navigation.");
return true;
}
final boolean enabled;
try {
enabled = TogglePreference.resolve(state.navigationEnabled(), arguments);
} catch (IllegalArgumentException exception) {
player.sendMessage(ChatColor.RED + "Usage: /basenavigation [on|off]");
return true;
}
state = stateManager.update(
player.getUniqueId(),
player.getName(),
current -> current.withNavigationEnabled(enabled)
);
stateManager.saveIfDirty();
player.sendMessage(ChatColor.YELLOW + "Base navigation is now "
+ (state.navigationEnabled() ? ChatColor.GREEN + "on" : ChatColor.RED + "off")
+ ChatColor.YELLOW + ".");
if (state.navigationEnabled()
&& !player.getWorld().getUID().equals(state.base().orElseThrow().worldId())) {
player.sendMessage(ChatColor.RED + "Your base is in another world: "
+ state.base().orElseThrow().worldName() + ".");
}
return true;
}
@Override
public List<String> onTabComplete(
CommandSender sender,
Command command,
String alias,
String[] arguments
) {
if (arguments.length != 1) {
return List.of();
}
String prefix = arguments[0].toLowerCase(Locale.ROOT);
return MODES.stream().filter(mode -> mode.startsWith(prefix)).toList();
}
}
@@ -10,6 +10,7 @@ final class BaseNavigationController implements Runnable {
private final Server server;
private final BaseStateManager stateManager;
private final PluginSettingsProvider settings;
private final BaseBoundsService boundsService;
BaseNavigationController(
Server server,
@@ -19,6 +20,7 @@ final class BaseNavigationController implements Runnable {
this.server = server;
this.stateManager = stateManager;
this.settings = settings;
this.boundsService = new BaseBoundsService(settings);
}
@Override
@@ -32,7 +34,16 @@ final class BaseNavigationController implements Runnable {
if (!player.getWorld().getUID().equals(base.worldId())) {
continue;
}
Location origin = player.getLocation().clone().add(0.0, 0.15, 0.0);
Location playerLocation = player.getLocation();
if (!BaseNavigationVisibility.isBeyondBorderBuffer(
base,
boundsService.radius(state),
playerLocation.getX(),
playerLocation.getZ()
)) {
continue;
}
Location origin = playerLocation.clone().add(0.0, 0.15, 0.0);
Vector direction = new Vector(
base.x() + 0.5 - origin.getX(),
0.0,
@@ -0,0 +1,26 @@
package games.dmg.spigotbase;
final class BaseNavigationVisibility {
static final int BORDER_BUFFER = 25;
private BaseNavigationVisibility() {
}
static boolean isBeyondBorderBuffer(
BaseLocation base,
int radius,
double playerX,
double playerZ
) {
if (base == null) {
throw new IllegalArgumentException("base is required");
}
if (radius <= 0) {
throw new IllegalArgumentException("base radius must be positive");
}
double deltaX = playerX - (base.x() + 0.5);
double deltaZ = playerZ - (base.z() + 0.5);
double minimumDistance = radius + BORDER_BUFFER;
return deltaX * deltaX + deltaZ * deltaZ > minimumDistance * minimumDistance;
}
}
@@ -1,121 +0,0 @@
package games.dmg.spigotbase;
import org.bukkit.ChatColor;
import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
final class BaseProgressCommand implements CommandExecutor {
private final BaseStateManager stateManager;
private final PluginSettingsProvider settings;
private final TeleportPolicy teleportPolicy;
BaseProgressCommand(BaseStateManager stateManager, PluginSettingsProvider settings) {
this.stateManager = stateManager;
this.settings = settings;
this.teleportPolicy = new TeleportPolicy(settings);
}
@Override
public boolean onCommand(CommandSender sender, Command command, String label, String[] arguments) {
if (!(sender instanceof Player player)) {
sender.sendMessage("Only players have base progression.");
return true;
}
PlayerState state = stateManager.player(player.getUniqueId(), player.getName());
if (arguments.length == 1 && arguments[0].equalsIgnoreCase("bossbar")) {
state = stateManager.update(
player.getUniqueId(),
player.getName(),
current -> current.withBossBarEnabled(!current.bossBarEnabled())
);
stateManager.saveIfDirty();
player.sendMessage(ChatColor.YELLOW + "Automatic progress boss bars are now "
+ (state.bossBarEnabled() ? ChatColor.GREEN + "on" : ChatColor.RED + "off")
+ ChatColor.YELLOW + ".");
return true;
}
player.sendMessage(ChatColor.GOLD + "=== Base Progress ===");
showBasePath(player, state);
showSizePath(player, state);
showFlightPath(player, state);
showWarmupPath(player, state);
showCooldownPath(player, state);
player.sendMessage(ChatColor.GRAY + "Boss bars: " + (state.bossBarEnabled() ? "on" : "off"));
state.base().ifPresentOrElse(
base -> player.sendMessage(ChatColor.GRAY + "Base: " + base.worldName() + " "
+ base.x() + ", " + base.y() + ", " + base.z()),
() -> player.sendMessage(ChatColor.GRAY + "Base: not set")
);
return true;
}
private void showBasePath(Player player, PlayerState state) {
String detail = switch (state.baseLevel()) {
case 0 -> state.grassAndDirtBroken() + "/" + settings.current().baseUnlockBlocks()
+ " grass or dirt → /setbase";
case 1 -> state.grassAndDirtBroken() + "/" + settings.current().navigationUnlockBlocks()
+ " grass or dirt → navigation";
case 2 -> state.blocksPlacedInBase() + "/" + settings.current().teleportUnlockPlacements()
+ " placements → /base";
case 3 -> settings.current().visitorUnlockDiamondCost() + " diamonds → visitor access";
case 4 -> "complete; visitor access unlocked";
default -> "invalid";
};
player.sendMessage(ChatColor.YELLOW + "Base " + state.baseLevel() + "/4: "
+ ChatColor.GRAY + detail);
}
private void showSizePath(Player player, PlayerState state) {
String detail = switch (state.sizeLevel()) {
case 0 -> state.stoneBroken() + "/" + settings.current().stoneExpansionBlocks() + " stone";
case 1 -> state.deepslateBroken() + "/" + settings.current().deepslateExpansionBlocks() + " deepslate";
case 2 -> state.obsidianBroken() + "/" + settings.current().obsidianExpansionBlocks() + " obsidian";
case 3 -> "complete; 150-block radius by default";
default -> "invalid";
};
player.sendMessage(colorForPrerequisite(state.baseLevel() >= 1) + "Base Size "
+ state.sizeLevel() + "/3: " + ChatColor.GRAY + detail);
}
private void showFlightPath(Player player, PlayerState state) {
String detail = state.flightLevel() >= 3
? "complete; world build height"
: (state.flightLevel() + 1) + " simultaneous elytra required";
player.sendMessage(colorForPrerequisite(state.baseLevel() >= 1) + "Base Flight "
+ state.flightLevel() + "/3: " + ChatColor.GRAY + detail
+ "; toggle=" + (state.flightEnabled() ? "on" : "off"));
}
private void showWarmupPath(Player player, PlayerState state) {
String detail = switch (state.warmupLevel()) {
case 0 -> state.blocksPlacedInBase() + "/" + settings.current().secondWarmupPlacements();
case 1 -> state.blocksPlacedInBase() + "/" + settings.current().thirdWarmupPlacements();
case 2 -> state.blocksPlacedInBase() + "/" + settings.current().instantWarmupPlacements();
case 3 -> "complete";
default -> "invalid";
};
player.sendMessage(colorForPrerequisite(state.baseLevel() >= 3) + "Teleport Warm-up "
+ state.warmupLevel() + "/3: " + ChatColor.GRAY
+ DurationFormatter.friendly(teleportPolicy.warmup(state)) + "; " + detail);
}
private void showCooldownPath(Player player, PlayerState state) {
String detail = switch (state.cooldownLevel()) {
case 0 -> state.blocksBrokenInBase() + "/" + settings.current().firstCooldownBreaks();
case 1 -> state.blocksBrokenInBase() + "/" + settings.current().secondCooldownBreaks();
case 2 -> state.blocksBrokenInBase() + "/" + settings.current().thirdCooldownBreaks();
case 3 -> state.blocksBrokenInBase() + "/" + settings.current().instantCooldownBreaks();
case 4 -> "complete";
default -> "invalid";
};
player.sendMessage(colorForPrerequisite(state.baseLevel() >= 3) + "Teleport Cooldown "
+ state.cooldownLevel() + "/4: " + ChatColor.GRAY
+ DurationFormatter.friendly(teleportPolicy.cooldown(state)) + "; " + detail);
}
private static ChatColor colorForPrerequisite(boolean met) {
return met ? ChatColor.YELLOW : ChatColor.RED;
}
}
@@ -24,6 +24,7 @@ final class BaseProgressListener implements Listener {
private final BaseProgressionService baseProgressionService;
private final SecondaryProgressionService secondaryProgressionService;
private final TeleportProgressionService teleportProgressionService;
private final SpawnableOverlayProgressionService spawnableOverlayProgressionService;
private final BaseBoundsService boundsService;
private final PluginSettingsProvider settings;
private final Map<UUID, BossBar> activeBossBars = new HashMap<>();
@@ -34,6 +35,7 @@ final class BaseProgressListener implements Listener {
BaseProgressionService baseProgressionService,
SecondaryProgressionService secondaryProgressionService,
TeleportProgressionService teleportProgressionService,
SpawnableOverlayProgressionService spawnableOverlayProgressionService,
BaseBoundsService boundsService,
PluginSettingsProvider settings
) {
@@ -42,6 +44,7 @@ final class BaseProgressListener implements Listener {
this.baseProgressionService = baseProgressionService;
this.secondaryProgressionService = secondaryProgressionService;
this.teleportProgressionService = teleportProgressionService;
this.spawnableOverlayProgressionService = spawnableOverlayProgressionService;
this.boundsService = boundsService;
this.settings = settings;
}
@@ -97,26 +100,43 @@ final class BaseProgressListener implements Listener {
return;
}
PlayerState before = stateManager.player(player.getUniqueId(), player.getName());
if (!isInsideBase(
before,
event.getBlockPlaced().getWorld().getUID(),
event.getBlockPlaced().getX(),
event.getBlockPlaced().getY(),
event.getBlockPlaced().getZ())) {
return;
}
boolean insideBase = isInsideBase(
before,
event.getBlockPlaced().getWorld().getUID(),
event.getBlockPlaced().getX(),
event.getBlockPlaced().getY(),
event.getBlockPlaced().getZ()
);
ProgressionUpdate[] updateHolder = new ProgressionUpdate[1];
SpawnableOverlayProgressionUpdate[] overlayHolder =
new SpawnableOverlayProgressionUpdate[1];
PlayerState state = stateManager.update(player.getUniqueId(), player.getName(), current -> {
ProgressionUpdate update = teleportProgressionService.recordPlacement(current);
SpawnableOverlayProgressionUpdate overlay =
spawnableOverlayProgressionService.recordPlacement(current);
ProgressionUpdate update = insideBase
? teleportProgressionService.recordPlacement(overlay.player())
: ProgressionUpdate.unchanged(overlay.player());
overlayHolder[0] = overlay;
updateHolder[0] = update;
return update.player();
});
ProgressionUpdate update = updateHolder[0];
SpawnableOverlayProgressionUpdate overlay = overlayHolder[0];
announceUnlock(player, update);
if (hasUnlock(update)) {
if (overlay.unlocked()) {
sendUnlockTitle(
player,
ChatColor.GOLD + "Spawnable Overlay Unlocked",
ChatColor.YELLOW + "/basesettings spawnable enable is now available"
);
player.sendMessage(ChatColor.GREEN
+ "You unlocked spawnable block highlighting! Use "
+ "/basesettings spawnable enable.");
}
if (hasUnlock(update) || overlay.unlocked()) {
stateManager.saveIfDirty();
}
if (state.bossBarEnabled() && state.baseLevel() >= 2) {
if (insideBase && state.bossBarEnabled() && state.baseLevel() >= 2) {
showProgress(player, warmupDisplay(state));
}
}
@@ -149,7 +169,7 @@ final class BaseProgressListener implements Listener {
int level = update.player().baseLevel();
String subtitle = switch (level) {
case 1 -> "/setbase is now available";
case 2 -> "/basenavigation is now available";
case 2 -> "/basesettings navigation enable is now available";
case 3 -> "/base is now available";
case 4 -> "Visitors can now travel to your base";
default -> "A new base benefit is available";
@@ -0,0 +1,751 @@
package games.dmg.spigotbase;
import io.papermc.paper.dialog.Dialog;
import java.io.IOException;
import java.util.List;
import java.util.Locale;
import org.bukkit.ChatColor;
import org.bukkit.Material;
import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender;
import org.bukkit.command.TabCompleter;
import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.PlayerInventory;
final class BaseSettingsCommand implements CommandExecutor, TabCompleter {
private static final List<String> SETTINGS = List.of(
"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
public boolean onCommand(CommandSender sender, Command command, String label, String[] arguments) {
if (!(sender instanceof Player player)) {
sender.sendMessage("Only players have base progression.");
return true;
}
PlayerState state = stateManager.player(player.getUniqueId(), player.getName());
if (arguments.length == 0
|| 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]);
}
if (arguments.length == 2 && arguments[0].equalsIgnoreCase("navigation")) {
return updateNavigation(player, state, arguments[1]);
}
if (arguments.length == 2 && arguments[0].equalsIgnoreCase("flight")) {
return updateFlight(player, state, arguments[1]);
}
if (arguments.length == 2 && arguments[0].equalsIgnoreCase("border")) {
return updateBorder(player, state, arguments[1]);
}
if (arguments.length == 2 && arguments[0].equalsIgnoreCase("spawnable")) {
return updateSpawnableOverlay(player, state, arguments[1]);
}
if (arguments.length == 2 && arguments[0].equalsIgnoreCase("bossbar")) {
return updateBossBar(player, arguments[1]);
}
sendUsage(player);
return true;
}
private boolean purchaseVisitorAccess(Player player, PlayerState state) {
if (!new VisitorPolicy().canPurchase(state)) {
player.sendMessage(ChatColor.RED + (state.baseLevel() >= 4
? "Base IV is already unlocked."
: "You must unlock Base III before purchasing Base IV."));
return true;
}
int price = settings.current().visitorUnlockDiamondCost();
Material currency = Material.valueOf(settings.current().visitorCurrencyMaterial());
PlayerInventory inventory = player.getInventory();
if (countCurrency(inventory, currency) < price) {
player.sendMessage(ChatColor.RED + "Base IV costs " + price + " "
+ currency.name().toLowerCase(java.util.Locale.ROOT) + ".");
return true;
}
ItemStack[] snapshot = cloneContents(inventory.getStorageContents());
removeCurrency(inventory, currency, price);
try {
stateManager.updateAndSave(
player.getUniqueId(),
player.getName(),
current -> current.withBaseLevel(4).withVisitorsEnabled(true)
);
} catch (IOException | RuntimeException exception) {
inventory.setStorageContents(snapshot);
player.sendMessage(ChatColor.RED
+ "The upgrade could not be saved; your diamonds were restored.");
return true;
}
player.sendTitle(
ChatColor.GOLD + "Base IV Unlocked",
ChatColor.YELLOW + "Visitors may now teleport to your base",
settings.current().titleFadeInTicks(),
settings.current().titleStayTicks(),
settings.current().titleFadeOutTicks()
);
player.sendMessage(ChatColor.GREEN + "Base IV unlocked for " + price + " "
+ currency.name().toLowerCase(java.util.Locale.ROOT) + ".");
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.");
return true;
}
final boolean enabled;
if (mode.equalsIgnoreCase("allowed")) {
enabled = true;
} else if (mode.equalsIgnoreCase("blocked")) {
enabled = false;
} else {
sendUsage(player);
return true;
}
state = stateManager.update(
player.getUniqueId(),
player.getName(),
current -> current.withVisitorsEnabled(enabled)
);
stateManager.saveIfDirty();
player.sendMessage(ChatColor.YELLOW + "Visitor teleports are now "
+ (state.visitorsEnabled() ? ChatColor.GREEN + "allowed" : ChatColor.RED + "blocked")
+ ChatColor.YELLOW + ".");
return true;
}
private boolean updateNavigation(Player player, PlayerState state, String mode) {
if (state.baseLevel() < 2) {
player.sendMessage(ChatColor.RED + "Base II navigation is still locked.");
return true;
}
if (state.base().isEmpty()) {
player.sendMessage(ChatColor.RED + "Set your base before enabling navigation.");
return true;
}
Boolean enabled = enabledMode(mode);
if (enabled == null) {
sendUsage(player);
return true;
}
state = stateManager.update(
player.getUniqueId(),
player.getName(),
current -> current.withNavigationEnabled(enabled)
);
stateManager.saveIfDirty();
player.sendMessage(ChatColor.YELLOW + "Base navigation is now "
+ (state.navigationEnabled() ? ChatColor.GREEN + "enabled" : ChatColor.RED + "disabled")
+ ChatColor.YELLOW + ".");
if (state.navigationEnabled()
&& !player.getWorld().getUID().equals(state.base().orElseThrow().worldId())) {
player.sendMessage(ChatColor.RED + "Your base is in another world: "
+ state.base().orElseThrow().worldName() + ".");
}
return true;
}
private boolean updateFlight(Player player, PlayerState state, String mode) {
if (state.flightLevel() < 1) {
player.sendMessage(ChatColor.RED + "Base flight is still locked.");
return true;
}
Boolean enabled = enabledMode(mode);
if (enabled == null) {
sendUsage(player);
return true;
}
state = stateManager.update(
player.getUniqueId(),
player.getName(),
current -> current.withFlightEnabled(enabled)
);
if (!state.flightEnabled()) {
flightController.removeGrantedFlight(player);
}
stateManager.saveIfDirty();
player.sendMessage(ChatColor.YELLOW + "Base flight is now "
+ (state.flightEnabled() ? ChatColor.GREEN + "enabled" : ChatColor.RED + "disabled")
+ ChatColor.YELLOW + ".");
return true;
}
private boolean updateBorder(Player player, PlayerState state, String mode) {
if (state.baseLevel() < 1 || state.base().isEmpty()) {
player.sendMessage(ChatColor.RED
+ "Establish Base I before enabling border visualization.");
return true;
}
Boolean enabled = enabledMode(mode);
if (enabled == null) {
sendUsage(player);
return true;
}
state = stateManager.update(
player.getUniqueId(),
player.getName(),
current -> current.withBorderEnabled(enabled)
);
stateManager.saveIfDirty();
player.sendMessage(ChatColor.YELLOW + "Base border visualization is now "
+ (state.borderEnabled() ? ChatColor.GREEN + "enabled" : ChatColor.RED + "disabled")
+ ChatColor.YELLOW + ".");
return true;
}
private boolean updateSpawnableOverlay(Player player, PlayerState state, String mode) {
if (state.totalBlocksPlaced() < settings.current().spawnableOverlayUnlockPlacements()) {
player.sendMessage(ChatColor.RED + "Spawnable highlighting unlocks after "
+ settings.current().spawnableOverlayUnlockPlacements() + " total placements.");
return true;
}
if (state.baseLevel() < 1 || state.base().isEmpty()) {
player.sendMessage(ChatColor.RED
+ "Establish Base I before enabling spawnable highlighting.");
return true;
}
Boolean enabled = enabledMode(mode);
if (enabled == null) {
sendUsage(player);
return true;
}
state = stateManager.update(
player.getUniqueId(),
player.getName(),
current -> current.withSpawnableOverlayEnabled(enabled)
);
stateManager.saveIfDirty();
player.sendMessage(ChatColor.YELLOW + "Spawnable block highlighting is now "
+ (state.spawnableOverlayEnabled()
? ChatColor.GREEN + "enabled"
: ChatColor.RED + "disabled")
+ ChatColor.YELLOW + ".");
return true;
}
private boolean updateBossBar(Player player, String mode) {
Boolean enabled = enabledMode(mode);
if (enabled == null) {
sendUsage(player);
return true;
}
PlayerState state = stateManager.update(
player.getUniqueId(),
player.getName(),
current -> current.withBossBarEnabled(enabled)
);
stateManager.saveIfDirty();
player.sendMessage(ChatColor.YELLOW + "Automatic progress boss bars are now "
+ (state.bossBarEnabled() ? ChatColor.GREEN + "enabled" : ChatColor.RED + "disabled")
+ ChatColor.YELLOW + ".");
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);
showSizePath(player, state);
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; "
+ (state.totalBlocksPlaced() >= spawnableThreshold ? "unlocked" : "locked")
+ "; setting=" + (state.spawnableOverlayEnabled() ? "enabled" : "disabled"));
player.sendMessage(ChatColor.GRAY + "Settings: visitors="
+ (state.visitorsEnabled() ? "allowed" : "blocked")
+ " navigation=" + (state.navigationEnabled() ? "enabled" : "disabled")
+ " flight=" + (state.flightEnabled() ? "enabled" : "disabled")
+ " border=" + (state.borderEnabled() ? "enabled" : "disabled")
+ " spawnable=" + (state.spawnableOverlayEnabled() ? "enabled" : "disabled")
+ " bossbar=" + (state.bossBarEnabled() ? "enabled" : "disabled"));
state.base().ifPresentOrElse(
base -> player.sendMessage(ChatColor.GRAY + "Base: " + base.worldName() + " "
+ base.x() + ", " + base.y() + ", " + base.z()),
() -> player.sendMessage(ChatColor.GRAY + "Base: not set")
);
}
private void showBasePath(Player player, PlayerState state) {
String detail = switch (state.baseLevel()) {
case 0 -> state.grassAndDirtBroken() + "/" + settings.current().baseUnlockBlocks()
+ " grass or dirt → /setbase";
case 1 -> state.grassAndDirtBroken() + "/" + settings.current().navigationUnlockBlocks()
+ " grass or dirt → navigation";
case 2 -> state.blocksPlacedInBase() + "/" + settings.current().teleportUnlockPlacements()
+ " placements → /base";
case 3 -> settings.current().visitorUnlockDiamondCost()
+ " diamonds → /basesettings upgrade";
case 4 -> "complete; visitor access unlocked";
default -> "invalid";
};
player.sendMessage(ChatColor.YELLOW + "Base " + state.baseLevel() + "/4: "
+ ChatColor.GRAY + detail);
}
private void showSizePath(Player player, PlayerState state) {
String detail = switch (state.sizeLevel()) {
case 0 -> state.stoneBroken() + "/" + settings.current().stoneExpansionBlocks() + " stone";
case 1 -> state.deepslateBroken() + "/" + settings.current().deepslateExpansionBlocks() + " deepslate";
case 2 -> state.obsidianBroken() + "/" + settings.current().obsidianExpansionBlocks() + " obsidian";
case 3 -> "complete; 150-block radius by default";
default -> "invalid";
};
player.sendMessage(colorForPrerequisite(state.baseLevel() >= 1) + "Base Size "
+ state.sizeLevel() + "/3: " + ChatColor.GRAY + detail);
}
private void showFlightPath(Player player, PlayerState state) {
String detail = state.flightLevel() >= 3
? "complete; world build height"
: (state.flightLevel() + 1) + " simultaneous elytra required";
player.sendMessage(colorForPrerequisite(state.baseLevel() >= 1) + "Base Flight "
+ state.flightLevel() + "/3: " + ChatColor.GRAY + detail
+ "; setting=" + (state.flightEnabled() ? "enabled" : "disabled"));
}
private void showWarmupPath(Player player, PlayerState state) {
String detail = switch (state.warmupLevel()) {
case 0 -> state.blocksPlacedInBase() + "/" + settings.current().secondWarmupPlacements();
case 1 -> state.blocksPlacedInBase() + "/" + settings.current().thirdWarmupPlacements();
case 2 -> state.blocksPlacedInBase() + "/" + settings.current().instantWarmupPlacements();
case 3 -> "complete";
default -> "invalid";
};
player.sendMessage(colorForPrerequisite(state.baseLevel() >= 3) + "Teleport Warm-up "
+ state.warmupLevel() + "/3: " + ChatColor.GRAY
+ 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();
case 1 -> state.blocksBrokenInBase() + "/" + settings.current().secondCooldownBreaks();
case 2 -> state.blocksBrokenInBase() + "/" + settings.current().thirdCooldownBreaks();
case 3 -> state.blocksBrokenInBase() + "/" + settings.current().instantCooldownBreaks();
case 4 -> "complete";
default -> "invalid";
};
player.sendMessage(colorForPrerequisite(state.baseLevel() >= 3) + "Teleport Cooldown "
+ state.cooldownLevel() + "/4: " + ChatColor.GRAY
+ DurationFormatter.friendly(teleportPolicy.cooldown(state)) + "; " + detail);
}
@Override
public List<String> onTabComplete(
CommandSender sender,
Command command,
String alias,
String[] arguments
) {
if (arguments.length == 1) {
String prefix = arguments[0].toLowerCase(Locale.ROOT);
return SETTINGS.stream().filter(setting -> setting.startsWith(prefix)).toList();
}
if (arguments.length == 2) {
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();
}
private static int countCurrency(PlayerInventory inventory, Material currency) {
int count = 0;
for (ItemStack item : inventory.getStorageContents()) {
if (item != null && item.getType() == currency) {
count += item.getAmount();
}
}
return count;
}
private static void removeCurrency(PlayerInventory inventory, Material currency, int amount) {
ItemStack[] contents = inventory.getStorageContents();
int remaining = amount;
for (int index = 0; index < contents.length && remaining > 0; index++) {
ItemStack item = contents[index];
if (item == null || item.getType() != currency) {
continue;
}
int removed = Math.min(remaining, item.getAmount());
remaining -= removed;
int newAmount = item.getAmount() - removed;
if (newAmount == 0) {
contents[index] = null;
} else {
ItemStack reduced = item.clone();
reduced.setAmount(newAmount);
contents[index] = reduced;
}
}
inventory.setStorageContents(contents);
}
private static ItemStack[] cloneContents(ItemStack[] contents) {
ItemStack[] copy = new ItemStack[contents.length];
for (int index = 0; index < contents.length; index++) {
copy[index] = contents[index] == null ? null : contents[index].clone();
}
return copy;
}
private static Boolean enabledMode(String mode) {
if (mode.equalsIgnoreCase("enable")) {
return true;
}
if (mode.equalsIgnoreCase("disable")) {
return false;
}
return null;
}
private static ChatColor colorForPrerequisite(boolean met) {
return met ? ChatColor.YELLOW : ChatColor.RED;
}
@FunctionalInterface
interface DialogProvider {
Dialog create(PlayerState owner, PocketBaseState pocket);
}
private static void sendUsage(Player player) {
player.sendMessage(ChatColor.RED + "Usage: /basesettings "
+ "[ui|status|upgrade|pocket upgrade|pocket 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();
}
}
@@ -34,6 +34,7 @@ final class BaseTeleportManager implements Listener {
private final VisitorPolicy visitorPolicy;
private final SafeBaseDestination destinationFinder;
private final Clock clock;
private final TeleportParticles particles;
private final Map<UUID, Request> requests = new HashMap<>();
BaseTeleportManager(
@@ -44,6 +45,20 @@ final class BaseTeleportManager implements Listener {
SafeBaseDestination destinationFinder,
Clock clock
) {
this(plugin, stateManager, policy, visitorPolicy, destinationFinder, clock,
new TeleportParticles(plugin));
}
BaseTeleportManager(
Plugin plugin,
BaseStateManager stateManager,
TeleportPolicy policy,
VisitorPolicy visitorPolicy,
SafeBaseDestination destinationFinder,
Clock clock,
TeleportParticles particles
) {
this.particles = particles;
this.plugin = plugin;
this.stateManager = stateManager;
this.policy = policy;
@@ -83,6 +98,13 @@ final class BaseTeleportManager implements Listener {
visitor.sendMessage(ChatColor.RED + "That base is not accepting visitors.");
return;
}
BaseLocation destination = owner.base().orElseThrow();
if (!visitor.getWorld().getUID().equals(destination.worldId())) {
visitor.sendMessage(
ChatColor.RED + "You must be in the same dimension as that base to visit it."
);
return;
}
PlayerState visitorState = stateManager.player(visitor.getUniqueId(), visitor.getName());
Optional<Duration> remaining = visitorPolicy.remaining(
visitorState, owner.playerId(), clock.instant()
@@ -94,8 +116,8 @@ final class BaseTeleportManager implements Listener {
}
begin(
visitor,
owner.base().orElseThrow(),
policy.warmup(owner),
destination,
policy.visitorWarmup(owner),
owner.playerId(),
policy.cooldown(owner),
"visit " + owner.latestName() + "'s base"
@@ -131,6 +153,7 @@ final class BaseTeleportManager implements Listener {
return;
}
requests.put(player.getUniqueId(), request);
particles.start(player.getUniqueId(), origin, seconds);
request.task = Bukkit.getScheduler().runTaskTimer(plugin, () -> tick(player, request), 0L, 20L);
player.sendMessage(ChatColor.YELLOW + "Stand still for " + seconds + " seconds to " + purpose + ".");
}
@@ -140,6 +163,7 @@ final class BaseTeleportManager implements Listener {
request.cancelTask();
}
requests.clear();
particles.clear();
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
@@ -197,6 +221,9 @@ final class BaseTeleportManager implements Listener {
private void tick(Player player, Request request) {
if (!player.isOnline() || requests.get(player.getUniqueId()) != request) {
if (requests.remove(player.getUniqueId(), request)) {
particles.cancel(player.getUniqueId());
}
request.cancelTask();
return;
}
@@ -218,18 +245,22 @@ final class BaseTeleportManager implements Listener {
BaseLocation base = request.destination;
World world = Bukkit.getWorld(base.worldId());
if (world == null) {
particles.cancel(player.getUniqueId());
player.sendMessage(ChatColor.RED + "The destination world is not currently available.");
return;
}
Optional<Location> destination = destinationFinder.find(world, base);
if (destination.isEmpty()) {
particles.cancel(player.getUniqueId());
player.sendMessage(ChatColor.RED + "No safe location could be found at the base.");
return;
}
if (!player.teleport(destination.orElseThrow(), PlayerTeleportEvent.TeleportCause.PLUGIN)) {
particles.cancel(player.getUniqueId());
player.sendMessage(ChatColor.RED + "The base teleport was prevented.");
return;
}
particles.complete(player.getUniqueId());
Instant completedAt = clock.instant();
stateManager.update(
player.getUniqueId(),
@@ -253,6 +284,7 @@ final class BaseTeleportManager implements Listener {
return;
}
request.cancelTask();
particles.cancel(player.getUniqueId());
if (message != null) {
player.sendMessage(ChatColor.RED + message);
}
@@ -1,63 +0,0 @@
package games.dmg.spigotbase;
import java.util.List;
import java.util.Locale;
import org.bukkit.ChatColor;
import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender;
import org.bukkit.command.TabCompleter;
import org.bukkit.entity.Player;
final class BaseVisitorsCommand implements CommandExecutor, TabCompleter {
private static final List<String> MODES = List.of("on", "off");
private final BaseStateManager stateManager;
BaseVisitorsCommand(BaseStateManager stateManager) {
this.stateManager = stateManager;
}
@Override
public boolean onCommand(CommandSender sender, Command command, String label, String[] arguments) {
if (!(sender instanceof Player player)) {
sender.sendMessage("Only players can manage base visitors.");
return true;
}
PlayerState state = stateManager.player(player.getUniqueId(), player.getName());
if (state.baseLevel() < 4) {
player.sendMessage(ChatColor.RED + "Base IV visitor access is still locked.");
return true;
}
final boolean enabled;
try {
enabled = TogglePreference.resolve(state.visitorsEnabled(), arguments);
} catch (IllegalArgumentException exception) {
player.sendMessage(ChatColor.RED + "Usage: /basevisitors [on|off]");
return true;
}
state = stateManager.update(
player.getUniqueId(),
player.getName(),
current -> current.withVisitorsEnabled(enabled)
);
stateManager.saveIfDirty();
player.sendMessage(ChatColor.YELLOW + "Visitor teleports are now "
+ (state.visitorsEnabled() ? ChatColor.GREEN + "on" : ChatColor.RED + "off")
+ ChatColor.YELLOW + ".");
return true;
}
@Override
public List<String> onTabComplete(
CommandSender sender,
Command command,
String alias,
String[] arguments
) {
if (arguments.length != 1) {
return List.of();
}
String prefix = arguments[0].toLowerCase(Locale.ROOT);
return MODES.stream().filter(mode -> mode.startsWith(prefix)).toList();
}
}
@@ -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;
}
}
}
@@ -26,7 +26,10 @@ public record PlayerState(
boolean visitorsEnabled,
Optional<Instant> lastBaseSet,
Optional<Instant> lastBaseTeleport,
Map<UUID, Instant> visitorCooldownUntil
Map<UUID, Instant> visitorCooldownUntil,
boolean borderEnabled,
long totalBlocksPlaced,
boolean spawnableOverlayEnabled
) {
public PlayerState {
if (playerId == null) {
@@ -53,6 +56,7 @@ public record PlayerState(
requireNonNegative(obsidianBroken, "obsidian broken");
requireNonNegative(blocksPlacedInBase, "blocks placed in base");
requireNonNegative(blocksBrokenInBase, "blocks broken in base");
requireNonNegative(totalBlocksPlaced, "total blocks placed");
if (baseLevel == 0 && (sizeLevel > 0 || flightLevel > 0)) {
throw new IllegalArgumentException("secondary progression requires Base I");
@@ -69,12 +73,83 @@ public record PlayerState(
if (baseLevel < 4 && visitorsEnabled) {
throw new IllegalArgumentException("visitor access requires Base IV");
}
if (borderEnabled && (baseLevel < 1 || base.isEmpty())) {
throw new IllegalArgumentException("border visualization requires an established Base I");
}
if (spawnableOverlayEnabled && (baseLevel < 1 || base.isEmpty())) {
throw new IllegalArgumentException("spawnable overlay requires an established Base I");
}
if (visitorCooldownUntil.entrySet().stream().anyMatch(entry ->
entry.getKey() == null || entry.getValue() == null)) {
throw new IllegalArgumentException("visitor cooldowns must be complete");
}
}
public PlayerState(
UUID playerId,
String latestName,
Optional<BaseLocation> base,
int baseLevel,
int sizeLevel,
int flightLevel,
int warmupLevel,
int cooldownLevel,
long grassAndDirtBroken,
long stoneBroken,
long deepslateBroken,
long obsidianBroken,
long blocksPlacedInBase,
long blocksBrokenInBase,
boolean navigationEnabled,
boolean flightEnabled,
boolean bossBarEnabled,
boolean visitorsEnabled,
Optional<Instant> lastBaseSet,
Optional<Instant> lastBaseTeleport,
Map<UUID, Instant> visitorCooldownUntil
) {
this(
playerId, latestName, base, baseLevel, sizeLevel, flightLevel,
warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled,
lastBaseSet, lastBaseTeleport, visitorCooldownUntil, false, 0, false
);
}
public PlayerState(
UUID playerId,
String latestName,
Optional<BaseLocation> base,
int baseLevel,
int sizeLevel,
int flightLevel,
int warmupLevel,
int cooldownLevel,
long grassAndDirtBroken,
long stoneBroken,
long deepslateBroken,
long obsidianBroken,
long blocksPlacedInBase,
long blocksBrokenInBase,
boolean navigationEnabled,
boolean flightEnabled,
boolean bossBarEnabled,
boolean visitorsEnabled,
Optional<Instant> lastBaseSet,
Optional<Instant> lastBaseTeleport,
Map<UUID, Instant> visitorCooldownUntil,
boolean borderEnabled
) {
this(
playerId, latestName, base, baseLevel, sizeLevel, flightLevel,
warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled,
lastBaseSet, lastBaseTeleport, visitorCooldownUntil, borderEnabled, 0, false
);
}
public static PlayerState newPlayer(UUID playerId, String latestName) {
return new PlayerState(
playerId,
@@ -93,7 +168,8 @@ public record PlayerState(
warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled,
lastBaseSet, lastBaseTeleport, visitorCooldownUntil
lastBaseSet, lastBaseTeleport, visitorCooldownUntil, borderEnabled,
totalBlocksPlaced, spawnableOverlayEnabled
);
}
@@ -103,7 +179,8 @@ public record PlayerState(
warmupLevel, cooldownLevel, count, stoneBroken, deepslateBroken,
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled,
lastBaseSet, lastBaseTeleport, visitorCooldownUntil
lastBaseSet, lastBaseTeleport, visitorCooldownUntil, borderEnabled,
totalBlocksPlaced, spawnableOverlayEnabled
);
}
@@ -113,7 +190,8 @@ public record PlayerState(
warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
navigationEnabled, flightEnabled, enabled, visitorsEnabled,
lastBaseSet, lastBaseTeleport, visitorCooldownUntil
lastBaseSet, lastBaseTeleport, visitorCooldownUntil, borderEnabled,
totalBlocksPlaced, spawnableOverlayEnabled
);
}
@@ -123,7 +201,8 @@ public record PlayerState(
warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
enabled, flightEnabled, bossBarEnabled, visitorsEnabled,
lastBaseSet, lastBaseTeleport, visitorCooldownUntil
lastBaseSet, lastBaseTeleport, visitorCooldownUntil, borderEnabled,
totalBlocksPlaced, spawnableOverlayEnabled
);
}
@@ -133,7 +212,8 @@ public record PlayerState(
warmupLevel, cooldownLevel, grassAndDirtBroken, stone, deepslate,
obsidian, blocksPlacedInBase, blocksBrokenInBase,
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled,
lastBaseSet, lastBaseTeleport, visitorCooldownUntil
lastBaseSet, lastBaseTeleport, visitorCooldownUntil, borderEnabled,
totalBlocksPlaced, spawnableOverlayEnabled
);
}
@@ -143,7 +223,8 @@ public record PlayerState(
warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
navigationEnabled, enabled, bossBarEnabled, visitorsEnabled,
lastBaseSet, lastBaseTeleport, visitorCooldownUntil
lastBaseSet, lastBaseTeleport, visitorCooldownUntil, borderEnabled,
totalBlocksPlaced, spawnableOverlayEnabled
);
}
@@ -163,7 +244,8 @@ public record PlayerState(
newWarmupLevel, newCooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
obsidianBroken, placements, breaks,
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled,
lastBaseSet, lastBaseTeleport, visitorCooldownUntil
lastBaseSet, lastBaseTeleport, visitorCooldownUntil, borderEnabled,
totalBlocksPlaced, spawnableOverlayEnabled
);
}
@@ -179,7 +261,8 @@ public record PlayerState(
warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled,
lastBaseSet, Optional.of(usedAt), visitorCooldownUntil
lastBaseSet, Optional.of(usedAt), visitorCooldownUntil, borderEnabled,
totalBlocksPlaced, spawnableOverlayEnabled
);
}
@@ -189,7 +272,8 @@ public record PlayerState(
warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled,
lastBaseSet, lastBaseTeleport, visitorCooldownUntil
lastBaseSet, lastBaseTeleport, visitorCooldownUntil, borderEnabled,
totalBlocksPlaced, spawnableOverlayEnabled
);
}
@@ -206,7 +290,8 @@ public record PlayerState(
warmupLevel, cooldownLevel, grassDirt, stone, deepslate,
obsidian, placements, baseBreaks,
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled,
lastBaseSet, lastBaseTeleport, visitorCooldownUntil
lastBaseSet, lastBaseTeleport, visitorCooldownUntil, borderEnabled,
totalBlocksPlaced, spawnableOverlayEnabled
);
}
@@ -225,7 +310,10 @@ public record PlayerState(
newWarmupLevel, newCooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
newNavigationEnabled, newFlightEnabled, bossBarEnabled, newVisitorsEnabled,
lastBaseSet, lastBaseTeleport, visitorCooldownUntil
lastBaseSet, lastBaseTeleport, visitorCooldownUntil,
borderEnabled && newBaseLevel >= 1 && base.isPresent(),
totalBlocksPlaced,
spawnableOverlayEnabled && newBaseLevel >= 1 && base.isPresent()
);
}
@@ -235,7 +323,41 @@ public record PlayerState(
warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
navigationEnabled, flightEnabled, bossBarEnabled, enabled,
lastBaseSet, lastBaseTeleport, visitorCooldownUntil
lastBaseSet, lastBaseTeleport, visitorCooldownUntil, borderEnabled,
totalBlocksPlaced, spawnableOverlayEnabled
);
}
public PlayerState withBorderEnabled(boolean enabled) {
return new PlayerState(
playerId, latestName, base, baseLevel, sizeLevel, flightLevel,
warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled,
lastBaseSet, lastBaseTeleport, visitorCooldownUntil, enabled,
totalBlocksPlaced, spawnableOverlayEnabled
);
}
public PlayerState withTotalBlocksPlaced(long count) {
return new PlayerState(
playerId, latestName, base, baseLevel, sizeLevel, flightLevel,
warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled,
lastBaseSet, lastBaseTeleport, visitorCooldownUntil, borderEnabled,
count, spawnableOverlayEnabled
);
}
public PlayerState withSpawnableOverlayEnabled(boolean enabled) {
return new PlayerState(
playerId, latestName, base, baseLevel, sizeLevel, flightLevel,
warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled,
lastBaseSet, lastBaseTeleport, visitorCooldownUntil, borderEnabled,
totalBlocksPlaced, enabled
);
}
@@ -247,7 +369,8 @@ public record PlayerState(
warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled,
lastBaseSet, lastBaseTeleport, cooldowns
lastBaseSet, lastBaseTeleport, cooldowns, borderEnabled,
totalBlocksPlaced, spawnableOverlayEnabled
);
}
@@ -257,7 +380,8 @@ public record PlayerState(
warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled,
lastBaseSet, Optional.empty(), visitorCooldownUntil
lastBaseSet, Optional.empty(), visitorCooldownUntil, borderEnabled,
totalBlocksPlaced, spawnableOverlayEnabled
);
}
@@ -267,7 +391,8 @@ public record PlayerState(
warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled,
lastBaseSet, lastBaseTeleport, Map.of()
lastBaseSet, lastBaseTeleport, Map.of(), borderEnabled,
totalBlocksPlaced, spawnableOverlayEnabled
);
}
@@ -277,7 +402,8 @@ public record PlayerState(
warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled,
Optional.of(setAt), lastBaseTeleport, visitorCooldownUntil
Optional.of(setAt), lastBaseTeleport, visitorCooldownUntil, borderEnabled,
totalBlocksPlaced, spawnableOverlayEnabled
);
}
@@ -21,6 +21,7 @@ public record PluginSettings(
int flightWarningBuffer,
int secondFlightVerticalRange,
int teleportUnlockPlacements,
int spawnableOverlayUnlockPlacements,
int secondWarmupPlacements,
int thirdWarmupPlacements,
int instantWarmupPlacements,
@@ -36,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,
@@ -62,6 +69,7 @@ public record PluginSettings(
private static final int DEFAULT_FLIGHT_WARNING_BUFFER = 5;
private static final int DEFAULT_SECOND_FLIGHT_VERTICAL_RANGE = 100;
private static final int DEFAULT_TELEPORT_UNLOCK_PLACEMENTS = 200;
private static final int DEFAULT_SPAWNABLE_OVERLAY_UNLOCK_PLACEMENTS = 250;
private static final int DEFAULT_SECOND_WARMUP_PLACEMENTS = 1_000;
private static final int DEFAULT_THIRD_WARMUP_PLACEMENTS = 2_000;
private static final int DEFAULT_INSTANT_WARMUP_PLACEMENTS = 12_000;
@@ -77,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;
@@ -110,6 +121,7 @@ public record PluginSettings(
throw new IllegalArgumentException("second-flight-vertical-range must exceed the initial range");
}
requirePositive(teleportUnlockPlacements, "teleport-unlock-placements");
requirePositive(spawnableOverlayUnlockPlacements, "spawnable-overlay-unlock-placements");
if (secondWarmupPlacements <= teleportUnlockPlacements
|| thirdWarmupPlacements <= secondWarmupPlacements
|| instantWarmupPlacements <= thirdWarmupPlacements) {
@@ -137,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(
@@ -148,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
);
@@ -175,6 +199,11 @@ public record PluginSettings(
integer(values, "flight-warning-buffer", DEFAULT_FLIGHT_WARNING_BUFFER),
integer(values, "second-flight-vertical-range", DEFAULT_SECOND_FLIGHT_VERTICAL_RANGE),
integer(values, "teleport-unlock-placements", DEFAULT_TELEPORT_UNLOCK_PLACEMENTS),
integer(
values,
"spawnable-overlay-unlock-placements",
DEFAULT_SPAWNABLE_OVERLAY_UNLOCK_PLACEMENTS
),
integer(values, "second-warmup-placements", DEFAULT_SECOND_WARMUP_PLACEMENTS),
integer(values, "third-warmup-placements", DEFAULT_THIRD_WARMUP_PLACEMENTS),
integer(values, "instant-warmup-placements", DEFAULT_INSTANT_WARMUP_PLACEMENTS),
@@ -206,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,480 @@
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)
) && (event.getFrom() == null
|| !PocketPortalGeometry.isInterior(portal, position(event.getFrom())))) {
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,259 @@
package games.dmg.spigotbase;
import java.io.IOException;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.logging.Level;
import java.util.logging.Logger;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.ChatColor;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.World;
import org.bukkit.block.Block;
import org.bukkit.NamespacedKey;
import org.bukkit.Server;
import org.bukkit.entity.Player;
import org.bukkit.event.Event;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.block.Action;
import org.bukkit.event.inventory.PrepareItemCraftEvent;
import org.bukkit.event.player.PlayerInteractEvent;
import org.bukkit.event.player.PlayerTeleportEvent;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.ShapedRecipe;
import org.bukkit.inventory.meta.ItemMeta;
import org.bukkit.persistence.PersistentDataType;
final class PocketBaseKeystoneService implements Listener {
private static final Set<Material> RETURN_HAZARDS = Set.of(
Material.LAVA,
Material.FIRE,
Material.SOUL_FIRE,
Material.MAGMA_BLOCK,
Material.CACTUS,
Material.CAMPFIRE,
Material.SOUL_CAMPFIRE,
Material.POWDER_SNOW
);
private final Server server;
private final PocketBaseManager pocketBases;
private final KeystoneReturnStore returns;
private final NamespacedKey markerKey;
private final Logger logger;
PocketBaseKeystoneService(
Server server,
PocketBaseManager pocketBases,
KeystoneReturnStore returns,
NamespacedKey markerKey,
Logger logger
) {
this.server = server;
this.pocketBases = pocketBases;
this.returns = returns;
this.markerKey = markerKey;
this.logger = logger;
}
@EventHandler(priority = EventPriority.HIGH)
public void onPrepareCraft(PrepareItemCraftEvent event) {
if (!(event.getRecipe() instanceof ShapedRecipe shaped)
|| !shaped.getKey().equals(markerKey)
|| !(event.getView().getPlayer() instanceof Player player)) {
return;
}
if (pocketBases.state(player.getUniqueId()).level() < 1) {
event.getInventory().setResult(null);
}
}
@EventHandler(priority = EventPriority.HIGH)
public void onUse(PlayerInteractEvent event) {
Action action = event.getAction();
if ((action != Action.RIGHT_CLICK_AIR && action != Action.RIGHT_CLICK_BLOCK)
|| !isKeystone(event.getItem(), markerKey)) {
return;
}
// Vanilla no-action air clicks can deny block use without denying item use.
// Accept those events, but respect explicit item-use denial by other plugins.
if (event.useItemInHand() == Event.Result.DENY) {
return;
}
event.setCancelled(true);
Player player = event.getPlayer();
UUID playerId = player.getUniqueId();
if (pocketBases.state(playerId).level() < 1) {
player.sendMessage(ChatColor.RED
+ "Unlock Pocket Base I before using a Pocket Base Keystone.");
return;
}
Optional<UUID> currentPocket = pocketBases.ownerForPocketWorld(
player.getWorld().getUID()
);
if (currentPocket.isPresent()) {
if (!currentPocket.orElseThrow().equals(playerId)) {
player.sendMessage(ChatColor.RED
+ "You can only use a keystone to leave your own Pocket Base.");
return;
}
returnToSavedDestination(player);
return;
}
Location origin = player.getLocation();
World originWorld = origin.getWorld();
if (originWorld == null) {
player.sendMessage(ChatColor.RED + "Your return location could not be recorded.");
return;
}
KeystoneReturnLocation destination = new KeystoneReturnLocation(
originWorld.getUID(),
originWorld.getName(),
origin.getX(),
origin.getY(),
origin.getZ(),
origin.getYaw(),
origin.getPitch()
);
try {
returns.record(playerId, destination);
} catch (IOException exception) {
logger.log(Level.SEVERE, "Could not save a Pocket Base Keystone return", exception);
player.sendMessage(ChatColor.RED
+ "Your return location could not be saved, so you were not teleported.");
return;
}
teleport(player, pocketBases.pocketArrival(playerId));
}
private void returnToSavedDestination(Player player) {
Optional<KeystoneReturnLocation> saved = returns.destination(player.getUniqueId());
if (saved.isEmpty()) {
player.sendMessage(ChatColor.RED + "No Pocket Base Keystone return is recorded.");
return;
}
KeystoneReturnLocation destination = saved.orElseThrow();
World world = server.getWorld(destination.worldId());
if (world == null) {
player.sendMessage(ChatColor.RED + "Your recorded return world is unavailable.");
return;
}
Location location = new Location(
world,
destination.x(),
destination.y(),
destination.z(),
destination.yaw(),
destination.pitch()
);
if (!isSafe(location)) {
player.sendMessage(ChatColor.RED + "Your recorded return location is not safe.");
return;
}
teleport(player, location);
}
private static void teleport(Player player, Location destination) {
float previousFallDistance = player.getFallDistance();
player.setFallDistance(0.0F);
if (!player.teleport(destination, PlayerTeleportEvent.TeleportCause.PLUGIN)) {
player.setFallDistance(previousFallDistance);
player.sendMessage(ChatColor.RED + "Pocket Base Keystone travel failed.");
}
}
private static boolean isSafe(Location location) {
World world = location.getWorld();
int x = location.getBlockX();
int y = location.getBlockY();
int z = location.getBlockZ();
if (world == null || y <= world.getMinHeight() || y + 1 >= world.getMaxHeight()) {
return false;
}
Block ground = world.getBlockAt(x, y - 1, z);
Block feet = world.getBlockAt(x, y, z);
Block head = world.getBlockAt(x, y + 1, z);
return feet.isPassable() && head.isPassable()
&& !feet.isLiquid() && !head.isLiquid()
&& !ground.isPassable() && !ground.isLiquid()
&& !RETURN_HAZARDS.contains(ground.getType())
&& !RETURN_HAZARDS.contains(feet.getType())
&& !RETURN_HAZARDS.contains(head.getType());
}
static boolean isKeystone(ItemStack item, NamespacedKey markerKey) {
if (item == null || item.getType() != Material.RECOVERY_COMPASS) {
return false;
}
ItemMeta metadata = item.getItemMeta();
if (metadata == null) {
return false;
}
Byte marker = metadata.getPersistentDataContainer().get(
markerKey, PersistentDataType.BYTE
);
return marker != null && marker == (byte) 1;
}
void registerRecipe() {
server.addRecipe(recipe(markerKey, createKeystone(markerKey)));
}
static ItemStack createKeystone(NamespacedKey markerKey) {
ItemStack item = new ItemStack(Material.RECOVERY_COMPASS);
decorateKeystone(item, markerKey);
return item;
}
static void decorateKeystone(ItemStack item, NamespacedKey markerKey) {
ItemMeta metadata = item.getItemMeta();
if (metadata == null) {
throw new IllegalStateException("Recovery Compass metadata is unavailable");
}
metadata.displayName(Component.text(
"Pocket Base Keystone", NamedTextColor.LIGHT_PURPLE
));
metadata.lore(List.of(
Component.text(
"Right-click to enter your Pocket Base.", NamedTextColor.GRAY
),
Component.text("Use it again inside to return.", NamedTextColor.GRAY)
));
metadata.getPersistentDataContainer().set(
markerKey, PersistentDataType.BYTE, (byte) 1
);
item.setItemMeta(metadata);
}
static RecipeSpec recipeSpecification() {
return new RecipeSpec(
"ONO", "NEN", "ONO", Material.OBSIDIAN,
Material.NETHERITE_INGOT, Material.ENDER_PEARL
);
}
static ShapedRecipe recipe(NamespacedKey key, ItemStack result) {
RecipeSpec specification = recipeSpecification();
ShapedRecipe recipe = new ShapedRecipe(key, result);
recipe.shape(specification.top(), specification.middle(), specification.bottom());
recipe.setIngredient('O', specification.obsidian());
recipe.setIngredient('N', specification.netherite());
recipe.setIngredient('E', specification.enderPearl());
return recipe;
}
record RecipeSpec(
String top,
String middle,
String bottom,
Material obsidian,
Material netherite,
Material enderPearl
) {
}
}
@@ -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");
}
}
}
@@ -6,5 +6,6 @@ public enum ProgressCounter {
DEEPSLATE,
OBSIDIAN,
PLACEMENTS,
TOTAL_PLACEMENTS,
BASE_BREAKS
}
@@ -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;
}
@@ -0,0 +1,38 @@
package games.dmg.spigotbase;
import org.bukkit.block.Block;
import org.bukkit.block.BlockFace;
import org.bukkit.block.data.Bisected;
import org.bukkit.block.data.type.Slab;
import org.bukkit.block.data.type.Stairs;
final class SpawnableBlockPolicy {
private SpawnableBlockPolicy() {
}
static boolean isCandidate(Block surface) {
if (!hasSpawnableTop(surface)) {
return false;
}
Block above = surface.getRelative(BlockFace.UP);
Block secondAbove = above.getRelative(BlockFace.UP);
return isEmpty(above)
&& isEmpty(secondAbove)
&& above.getLightFromBlocks() == 0
&& above.getLightFromSky() <= 7;
}
private static boolean hasSpawnableTop(Block block) {
if (block.getBlockData() instanceof Slab slab) {
return slab.getType() == Slab.Type.TOP || slab.getType() == Slab.Type.DOUBLE;
}
if (block.getBlockData() instanceof Stairs stairs) {
return stairs.getHalf() == Bisected.Half.TOP;
}
return block.getType().isOccluding();
}
private static boolean isEmpty(Block block) {
return block.isPassable() && !block.isLiquid();
}
}
@@ -0,0 +1,185 @@
package games.dmg.spigotbase;
import java.util.ArrayDeque;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import org.bukkit.Color;
import org.bukkit.Location;
import org.bukkit.Particle;
import org.bukkit.Server;
import org.bukkit.World;
import org.bukkit.block.Block;
import org.bukkit.entity.Player;
final class SpawnableOverlayController implements Runnable {
static final int HORIZONTAL_RADIUS = 16;
static final int VERTICAL_RADIUS = 8;
static final int WORK_BUDGET = 2_048;
static final int PARTICLE_BUDGET = 128;
private static final int PENDING_BUDGET = PARTICLE_BUDGET * 4;
private static final int WIDTH = HORIZONTAL_RADIUS * 2 + 1;
private static final int HEIGHT = VERTICAL_RADIUS * 2 + 1;
private static final int TOTAL_POSITIONS = WIDTH * WIDTH * HEIGHT;
private static final Particle.DustOptions RED_DUST =
new Particle.DustOptions(Color.RED, 1.0F);
private final Server server;
private final BaseStateManager stateManager;
private final BaseBoundsService boundsService;
private final PluginSettingsProvider settings;
private final Map<UUID, ScanState> scans = new HashMap<>();
SpawnableOverlayController(
Server server,
BaseStateManager stateManager,
BaseBoundsService boundsService,
PluginSettingsProvider settings
) {
this.server = server;
this.stateManager = stateManager;
this.boundsService = boundsService;
this.settings = settings;
}
@Override
public void run() {
Set<UUID> active = new HashSet<>();
for (Player player : server.getOnlinePlayers()) {
PlayerState playerState = stateManager.player(
player.getUniqueId(), player.getName()
);
if (!eligible(player, playerState)) {
scans.remove(player.getUniqueId());
continue;
}
active.add(player.getUniqueId());
updateAndRender(player, playerState);
}
scans.keySet().removeIf(playerId -> !active.contains(playerId));
}
private boolean eligible(Player player, PlayerState state) {
return state.spawnableOverlayEnabled()
&& state.totalBlocksPlaced() >= settings.current()
.spawnableOverlayUnlockPlacements()
&& state.base().isPresent()
&& player.getWorld().getUID().equals(state.base().orElseThrow().worldId());
}
private void updateAndRender(Player player, PlayerState state) {
Location location = player.getLocation();
BaseLocation base = state.base().orElseThrow();
int centerX = location.getBlockX();
int centerY = location.getBlockY() - 1;
int centerZ = location.getBlockZ();
ScanOrigin origin = new ScanOrigin(
player.getWorld().getUID(), centerX, centerY, centerZ,
base.x(), base.y(), base.z(), boundsService.radius(state)
);
ScanState scan = scans.get(player.getUniqueId());
if (scan == null || !scan.origin.near(origin)) {
scan = new ScanState(origin);
scans.put(player.getUniqueId(), scan);
}
scan(player.getWorld(), boundsService.area(state), scan);
render(player, scan);
}
private static void scan(World world, BaseArea base, ScanState scan) {
int work = Math.min(WORK_BUDGET, TOTAL_POSITIONS);
int minimumWorldY = world.getMinHeight();
int maximumWorldY = world.getMaxHeight();
UUID worldId = world.getUID();
for (int offset = 0; offset < work; offset++) {
int index = (scan.cursor + offset) % TOTAL_POSITIONS;
int verticalIndex = index % HEIGHT;
int horizontalIndex = index / HEIGHT;
int zIndex = horizontalIndex % WIDTH;
int xIndex = horizontalIndex / WIDTH;
int x = scan.origin.centerX + xIndex - HORIZONTAL_RADIUS;
int y = scan.origin.centerY + verticalIndex - VERTICAL_RADIUS;
int z = scan.origin.centerZ + zIndex - HORIZONTAL_RADIUS;
BlockPosition position = new BlockPosition(x, y, z);
scan.spawnable.remove(position);
int deltaX = x - scan.origin.centerX;
int deltaZ = z - scan.origin.centerZ;
if (deltaX * deltaX + deltaZ * deltaZ
> HORIZONTAL_RADIUS * HORIZONTAL_RADIUS
|| y < minimumWorldY
|| y >= maximumWorldY
|| !base.contains(worldId, x, y, z)) {
continue;
}
Block surface = world.getBlockAt(x, y, z);
if (SpawnableBlockPolicy.isCandidate(surface)) {
scan.spawnable.add(position);
if (scan.pending.size() < PENDING_BUDGET) {
scan.pending.addLast(position);
}
}
}
scan.cursor = (scan.cursor + work) % TOTAL_POSITIONS;
}
private static void render(Player player, ScanState scan) {
for (int attempted = 0; attempted < PARTICLE_BUDGET; attempted++) {
BlockPosition position = scan.pending.pollFirst();
if (position == null) {
return;
}
if (!scan.spawnable.contains(position)) {
continue;
}
player.spawnParticle(
Particle.DUST,
new Location(
player.getWorld(),
position.x + 0.5,
position.y + 1.05,
position.z + 0.5
),
1,
RED_DUST
);
}
}
private record BlockPosition(int x, int y, int z) {
}
private record ScanOrigin(
UUID worldId,
int centerX,
int centerY,
int centerZ,
int baseX,
int baseY,
int baseZ,
int baseRadius
) {
boolean near(ScanOrigin other) {
return worldId.equals(other.worldId)
&& Math.abs(centerX - other.centerX) <= 4
&& Math.abs(centerY - other.centerY) <= 4
&& Math.abs(centerZ - other.centerZ) <= 4
&& baseX == other.baseX
&& baseY == other.baseY
&& baseZ == other.baseZ
&& baseRadius == other.baseRadius;
}
}
private static final class ScanState {
private final ScanOrigin origin;
private final Set<BlockPosition> spawnable = new HashSet<>();
private final ArrayDeque<BlockPosition> pending = new ArrayDeque<>();
private int cursor;
private ScanState(ScanOrigin origin) {
this.origin = origin;
}
}
}
@@ -0,0 +1,19 @@
package games.dmg.spigotbase;
final class SpawnableOverlayProgressionService {
private final PluginSettingsProvider settings;
SpawnableOverlayProgressionService(PluginSettingsProvider settings) {
this.settings = settings;
}
SpawnableOverlayProgressionUpdate recordPlacement(PlayerState player) {
long previous = player.totalBlocksPlaced();
long updated = previous == Long.MAX_VALUE ? Long.MAX_VALUE : previous + 1;
int threshold = settings.current().spawnableOverlayUnlockPlacements();
return new SpawnableOverlayProgressionUpdate(
player.withTotalBlocksPlaced(updated),
previous < threshold && updated >= threshold
);
}
}
@@ -0,0 +1,9 @@
package games.dmg.spigotbase;
record SpawnableOverlayProgressionUpdate(PlayerState player, boolean unlocked) {
SpawnableOverlayProgressionUpdate {
if (player == null) {
throw new IllegalArgumentException("player is required");
}
}
}
@@ -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;
@@ -46,11 +72,13 @@ public final class SpigotBasePlugin extends JavaPlugin {
progressionService,
secondaryProgressionService,
teleportProgressionService,
new SpawnableOverlayProgressionService(settingsProvider),
boundsService,
settingsProvider
);
flightController = new BaseFlightController(
getServer(), stateManager, secondaryProgressionService, boundsService, settingsProvider
getServer(), stateManager, secondaryProgressionService, boundsService,
settingsProvider, pocketBaseManager, getLogger()
);
VisitorPolicy visitorPolicy = new VisitorPolicy();
teleportManager = new BaseTeleportManager(
@@ -61,36 +89,44 @@ 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("base").setExecutor(
new BaseCommand(teleportManager, stateManager, visitorPolicy, settingsProvider)
command("setbase").setExecutor(new SetBaseCommand(
stateManager, baseService, Clock.systemUTC(), pocketBaseController
));
command("base").setExecutor(new BaseCommand(teleportManager));
BaseSettingsCommand settingsCommand = new BaseSettingsCommand(
stateManager,
settingsProvider,
flightController,
pocketBaseManager
);
command("baseprogress").setExecutor(
new BaseProgressCommand(stateManager, settingsProvider)
);
BaseNavigationCommand navigationCommand = new BaseNavigationCommand(stateManager);
command("basenavigation").setExecutor(navigationCommand);
command("basenavigation").setTabCompleter(navigationCommand);
BaseFlightCommand flightCommand = new BaseFlightCommand(stateManager, flightController);
command("baseflight").setExecutor(flightCommand);
command("baseflight").setTabCompleter(flightCommand);
BaseVisitorsCommand visitorsCommand = new BaseVisitorsCommand(stateManager);
command("basevisitors").setExecutor(visitorsCommand);
command("basevisitors").setTabCompleter(visitorsCommand);
command("basesettings").setExecutor(settingsCommand);
command("basesettings").setTabCompleter(settingsCommand);
GoToBaseCommand goToBaseCommand = new GoToBaseCommand(stateManager, teleportManager);
command("gotobase").setExecutor(goToBaseCommand);
command("gotobase").setTabCompleter(goToBaseCommand);
command("baseadmin").setExecutor(
new BaseAdminCommand(
this,
stateManager,
new AdminProgressionService(settingsProvider),
settingsProvider
)
BaseAdminCommand adminCommand = new BaseAdminCommand(
this,
stateManager,
new AdminProgressionService(settingsProvider),
settingsProvider
);
command("baseadmin").setExecutor(adminCommand);
command("baseadmin").setTabCompleter(adminCommand);
getServer().getScheduler().runTaskTimer(
this,
@@ -98,7 +134,22 @@ public final class SpigotBasePlugin extends JavaPlugin {
10L,
10L
);
getServer().getScheduler().runTaskTimer(
this,
new BaseBorderController(getServer(), stateManager, boundsService),
10L,
10L
);
getServer().getScheduler().runTaskTimer(
this,
new SpawnableOverlayController(
getServer(), stateManager, boundsService, settingsProvider
),
10L,
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,86 @@
package games.dmg.spigotbase;
import java.util.HashMap;
import java.util.Iterator;
import java.util.Map;
import java.util.UUID;
import org.bukkit.Location;
import org.bukkit.Particle;
import org.bukkit.plugin.Plugin;
import org.bukkit.scheduler.BukkitTask;
/** Cosmetic, main-thread effects; never controls teleport timing or eligibility. */
final class TeleportParticles {
private final Plugin plugin;
private final Map<UUID, Effect> effects = new HashMap<>();
private BukkitTask task;
TeleportParticles(Plugin plugin) {
this.plugin = plugin;
}
void start(UUID playerId, Location origin, int seconds) {
effects.put(playerId, new Effect(origin.clone(), Math.max(1L, seconds * 10L)));
if (task == null) {
task = plugin.getServer().getScheduler().runTaskTimer(plugin, this::tick, 0L, 2L);
}
}
void complete(UUID playerId) {
Effect effect = effects.get(playerId);
if (effect != null) {
effect.fading = true;
effect.age = 0;
}
}
void cancel(UUID playerId) {
effects.remove(playerId);
stopIfEmpty();
}
void clear() {
effects.clear();
stopIfEmpty();
}
private void tick() {
Iterator<Effect> iterator = effects.values().iterator();
while (iterator.hasNext()) {
Effect effect = iterator.next();
int count = effect.fading
? Math.max(1, 12 - (int) (effect.age * 12 / 20))
: 2 + (int) (10 * Math.min(1.0, (double) effect.age / effect.duration));
double angle = effect.age * 0.3;
Location point = effect.origin.clone().add(
Math.cos(angle) * 0.65, 1.0 + Math.sin(angle * 0.5) * 0.45,
Math.sin(angle) * 0.65
);
point.getWorld().spawnParticle(Particle.END_ROD, point, count, 0.3, 0.45, 0.3, 0.01);
effect.age++;
if (effect.fading && effect.age >= 20) {
iterator.remove();
}
}
stopIfEmpty();
}
private void stopIfEmpty() {
if (effects.isEmpty() && task != null) {
task.cancel();
task = null;
}
}
private static final class Effect {
private final Location origin;
private final long duration;
private long age;
private boolean fading;
private Effect(Location origin, long duration) {
this.origin = origin;
this.duration = duration;
}
}
}
@@ -25,6 +25,13 @@ public final class TeleportPolicy {
};
}
public Duration visitorWarmup(PlayerState owner) {
Duration warmup = warmup(owner);
return warmup.compareTo(Duration.ofSeconds(1)) < 0
? Duration.ofSeconds(1)
: warmup;
}
public Duration cooldown(PlayerState player) {
return switch (player.cooldownLevel()) {
case 0 -> Duration.ofSeconds(settings.current().initialTeleportCooldownSeconds());
@@ -1,22 +0,0 @@
package games.dmg.spigotbase;
import java.util.Locale;
final class TogglePreference {
private TogglePreference() {
}
static boolean resolve(boolean current, String[] arguments) {
if (arguments.length == 0) {
return !current;
}
if (arguments.length != 1) {
throw new IllegalArgumentException("expected zero or one argument");
}
return switch (arguments[0].toLowerCase(Locale.ROOT)) {
case "on" -> true;
case "off" -> false;
default -> throw new IllegalArgumentException("expected on or off");
};
}
}
@@ -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;
}
}
@@ -127,7 +127,10 @@ public final class YamlBaseStateRepository {
yaml.getBoolean(path + ".visitors-enabled", false),
instant(yaml, path + ".last-base-set-epoch-millis"),
instant(yaml, path + ".last-base-teleport-epoch-millis"),
loadVisitorCooldowns(yaml, path + ".visitor-cooldowns")
loadVisitorCooldowns(yaml, path + ".visitor-cooldowns"),
yaml.getBoolean(path + ".border-enabled", false),
totalPlacementCount(yaml, path),
yaml.getBoolean(path + ".spawnable-overlay-enabled", false)
);
players.put(playerId, player);
} catch (IllegalArgumentException ignored) {
@@ -194,6 +197,13 @@ public final class YamlBaseStateRepository {
return yaml.getLong(path);
}
private static long totalPlacementCount(YamlConfiguration yaml, String playerPath) {
return Math.max(
count(yaml, playerPath + ".total-blocks-placed"),
count(yaml, playerPath + ".blocks-placed-in-base")
);
}
private static Optional<Instant> instant(YamlConfiguration yaml, String path) {
if (!yaml.isLong(path) && !yaml.isInt(path)) {
return Optional.empty();
@@ -222,6 +232,9 @@ public final class YamlBaseStateRepository {
yaml.set(path + ".flight-enabled", player.flightEnabled());
yaml.set(path + ".boss-bar-enabled", player.bossBarEnabled());
yaml.set(path + ".visitors-enabled", player.visitorsEnabled());
yaml.set(path + ".border-enabled", player.borderEnabled());
yaml.set(path + ".total-blocks-placed", player.totalBlocksPlaced());
yaml.set(path + ".spawnable-overlay-enabled", player.spawnableOverlayEnabled());
yaml.set(
path + ".last-base-set-epoch-millis",
player.lastBaseSet().map(Instant::toEpochMilli).orElse(null)
@@ -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());
}
}
+11
View File
@@ -46,6 +46,17 @@ 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
# Progress and notification presentation
cooldown-excluded-materials: []
boss-bar-duration-ticks: 60
+7 -17
View File
@@ -10,31 +10,21 @@ commands:
usage: /setbase
aliases: [sethome]
base:
description: Teleport to or upgrade your base.
usage: /base [upgrade]
description: Teleport to your base.
usage: /base
aliases: [home]
basenavigation:
description: Toggle particle navigation toward your base.
usage: /basenavigation [on|off]
aliases: [homenavigation]
baseflight:
description: Toggle flight within your base.
usage: /baseflight [on|off]
aliases: [homeflight]
basevisitors:
description: Toggle visitor access to your base.
usage: /basevisitors [on|off]
aliases: [homevisitors]
gotobase:
description: Visit an available player base.
usage: /gotobase <player>
aliases: [visit]
baseprogress:
description: View progression or toggle progress boss bars.
usage: /baseprogress [bossbar]
basesettings:
description: View progression, upgrade, and manage base settings.
usage: /basesettings [ui|status|upgrade|pocket|visitors|navigation|flight|border|spawnable|bossbar]
aliases: [homesettings]
baseadmin:
description: Administer Spigot Base.
usage: /baseadmin <subcommand>
aliases: [homeadmin]
permission: spigotbase.admin
permissions:
spigotbase.admin:
@@ -11,6 +11,19 @@ final class AdminProgressCounterTest {
new PluginSettingsProvider(PluginSettings.from(Map.of()))
);
@Test
void settingTotalPlacementCounterUnlocksSpawnableOverlay() {
PlayerState player = PlayerState.newPlayer(UUID.randomUUID(), "Alex");
PlayerState updated = service.setProgress(
player,
ProgressCounter.TOTAL_PLACEMENTS,
250
);
assertEquals(250, updated.totalBlocksPlaced());
}
@Test
void settingPlacementCounterEvaluatesBaseAndWarmupTiers() {
PlayerState player = PlayerState.newPlayer(UUID.randomUUID(), "Alex");
@@ -4,6 +4,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.time.Instant;
import java.util.UUID;
import org.junit.jupiter.api.Test;
@@ -36,6 +37,40 @@ final class AdminProgressionServiceTest {
assertFalse(updated.visitorsEnabled());
}
@Test
void loweringBaseDisablesBorderVisualization() {
UUID playerId = UUID.randomUUID();
PlayerState player = PlayerState.newPlayer(playerId, "Alex")
.withAdministrativeLevels(1, 0, 0, 0, 0, false, false, false)
.withBase(
new BaseLocation(UUID.randomUUID(), "world", 0, 64, 0, 0, 0),
Instant.EPOCH
)
.withBorderEnabled(true);
PlayerState updated = service.setLevel(player, ProgressionPath.BASE, 0);
assertFalse(updated.borderEnabled());
}
@Test
void completeResetClearsSpawnableOverlayProgressAndPreference() {
UUID playerId = UUID.randomUUID();
PlayerState player = PlayerState.newPlayer(playerId, "Alex")
.withAdministrativeLevels(1, 0, 0, 0, 0, false, false, false)
.withBase(
new BaseLocation(UUID.randomUUID(), "world", 0, 64, 0, 0, 0),
Instant.EPOCH
)
.withTotalBlocksPlaced(250)
.withSpawnableOverlayEnabled(true);
PlayerState updated = service.resetPath(player, ProgressionPath.BASE);
assertEquals(0, updated.totalBlocksPlaced());
assertFalse(updated.spawnableOverlayEnabled());
}
@Test
void rejectsUnknownLevel() {
PlayerState player = PlayerState.newPlayer(UUID.randomUUID(), "Alex");
@@ -0,0 +1,323 @@
package games.dmg.spigotbase;
import static org.junit.jupiter.api.Assertions.assertEquals;
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.mockStatic;
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.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.Bukkit;
import org.bukkit.ChatColor;
import org.bukkit.command.CommandSender;
import org.bukkit.configuration.file.FileConfiguration;
import org.bukkit.plugin.java.JavaPlugin;
import org.junit.jupiter.api.Test;
import org.mockito.MockedStatic;
final class BaseAdminCommandTest {
@Test
void adminCanEnableUnlockedSpawnableOverlayForPlayer() {
CommandSender sender = mock(CommandSender.class);
when(sender.hasPermission("spigotbase.admin")).thenReturn(true);
UUID playerId = UUID.randomUUID();
PlayerState target = PlayerState.newPlayer(playerId, "Builder")
.withAdministrativeLevels(1, 0, 0, 0, 0, false, false, false)
.withBase(
new BaseLocation(UUID.randomUUID(), "world", 0, 64, 0, 0, 0),
Instant.EPOCH
)
.withTotalBlocksPlaced(250);
AtomicReference<PlayerState> updated = new AtomicReference<>();
BaseStateManager stateManager = mock(BaseStateManager.class);
when(stateManager.findByName("Builder")).thenReturn(Optional.of(target));
when(stateManager.update(any(), anyString(), any())).thenAnswer(invocation -> {
@SuppressWarnings("unchecked")
UnaryOperator<PlayerState> operation = invocation.getArgument(2);
PlayerState result = operation.apply(target);
updated.set(result);
return result;
});
BaseAdminCommand command = new BaseAdminCommand(
mock(JavaPlugin.class),
stateManager,
new AdminProgressionService(
new PluginSettingsProvider(PluginSettings.from(Map.of()))
),
new PluginSettingsProvider(PluginSettings.from(Map.of()))
);
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class)) {
bukkit.when(() -> Bukkit.getPlayerExact("Builder")).thenReturn(null);
command.onCommand(sender, null, "baseadmin",
new String[] {"setsetting", "Builder", "spawnable", "enable"});
}
assertTrue(updated.get().spawnableOverlayEnabled());
}
@Test
void invalidPathShowsFriendlyUsage() {
CommandSender sender = mock(CommandSender.class);
when(sender.hasPermission("spigotbase.admin")).thenReturn(true);
PlayerState target = PlayerState.newPlayer(UUID.randomUUID(), "Builder");
BaseStateManager stateManager = mock(BaseStateManager.class);
when(stateManager.findByName("Builder")).thenReturn(Optional.of(target));
BaseAdminCommand command = new BaseAdminCommand(
mock(JavaPlugin.class),
stateManager,
mock(AdminProgressionService.class),
new PluginSettingsProvider(PluginSettings.from(Map.of()))
);
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class)) {
bukkit.when(() -> Bukkit.getPlayerExact("Builder")).thenReturn(null);
command.onCommand(sender, null, "baseadmin",
new String[] {"setlevel", "Builder", "unknown", "1"});
}
verify(sender).sendMessage(ChatColor.RED
+ "Usage: /baseadmin setlevel <player> <base|size|flight|warmup|cooldown> <level>");
verify(stateManager, never()).update(any(), anyString(), any());
}
@Test
void autocompletesValidLevelsAndResetConfirmation() {
CommandSender sender = mock(CommandSender.class);
when(sender.hasPermission("spigotbase.admin")).thenReturn(true);
BaseAdminCommand command = new BaseAdminCommand(
mock(JavaPlugin.class),
mock(BaseStateManager.class),
mock(AdminProgressionService.class),
new PluginSettingsProvider(PluginSettings.from(Map.of()))
);
assertEquals(
List.of("0", "1", "2", "3", "4"),
command.onTabComplete(sender, null, "baseadmin",
new String[] {"setlevel", "Builder", "base", ""})
);
assertEquals(
List.of("0", "1", "2", "3"),
command.onTabComplete(sender, null, "baseadmin",
new String[] {"setlevel", "Builder", "flight", ""})
);
assertEquals(
List.of("confirm"),
command.onTabComplete(sender, null, "baseadmin",
new String[] {"reset", "Builder", "all", ""})
);
}
@Test
void autocompletesCommandSpecificOptions() {
CommandSender sender = mock(CommandSender.class);
when(sender.hasPermission("spigotbase.admin")).thenReturn(true);
BaseAdminCommand command = new BaseAdminCommand(
mock(JavaPlugin.class),
mock(BaseStateManager.class),
mock(AdminProgressionService.class),
new PluginSettingsProvider(PluginSettings.from(Map.of()))
);
assertEquals(
List.of("base", "size", "flight", "warmup", "cooldown"),
command.onTabComplete(sender, null, "baseadmin",
new String[] {"setlevel", "Builder", ""})
);
assertEquals(
List.of(
"grass_dirt", "stone", "deepslate", "obsidian", "placements",
"total_placements", "base_breaks"
),
command.onTabComplete(sender, null, "baseadmin",
new String[] {"setprogress", "Builder", ""})
);
assertEquals(
List.of("spawnable"),
command.onTabComplete(sender, null, "baseadmin",
new String[] {"setsetting", "Builder", ""})
);
assertEquals(
List.of("enable"),
command.onTabComplete(sender, null, "baseadmin",
new String[] {"setsetting", "Builder", "spawnable", "e"})
);
assertEquals(
List.of("personal", "visitor", "all"),
command.onTabComplete(sender, null, "baseadmin",
new String[] {"clearcooldown", "Builder", ""})
);
assertEquals(
List.of("size", "flight", "warmup", "cooldown", "all"),
command.onTabComplete(sender, null, "baseadmin",
new String[] {"reset", "Builder", ""})
);
}
@Test
void adminCanChangeSpawnableOverlayUnlockThreshold() {
CommandSender sender = mock(CommandSender.class);
when(sender.hasPermission("spigotbase.admin")).thenReturn(true);
JavaPlugin plugin = mock(JavaPlugin.class);
FileConfiguration configuration = mock(FileConfiguration.class);
when(plugin.getConfig()).thenReturn(configuration);
when(configuration.get("spawnable-overlay-unlock-placements")).thenReturn(250);
when(configuration.getValues(false)).thenReturn(Map.of(
"spawnable-overlay-unlock-placements", 250
));
PluginSettingsProvider settings = new PluginSettingsProvider(
PluginSettings.from(Map.of())
);
BaseAdminCommand command = new BaseAdminCommand(
plugin,
mock(BaseStateManager.class),
mock(AdminProgressionService.class),
settings
);
try (MockedStatic<PluginSettingsValidator> validator =
mockStatic(PluginSettingsValidator.class)) {
validator.when(() -> PluginSettingsValidator.validateMaterials(
any(PluginSettings.class)
)).thenAnswer(invocation -> invocation.getArgument(0));
command.onCommand(sender, null, "baseadmin", new String[] {
"config", "spawnable-overlay-unlock-placements", "500"
});
}
verify(configuration).set("spawnable-overlay-unlock-placements", 500L);
verify(plugin).saveConfig();
assertEquals(500, settings.current().spawnableOverlayUnlockPlacements());
}
@Test
void autocompletesNumericConfigurationKeys() {
CommandSender sender = mock(CommandSender.class);
when(sender.hasPermission("spigotbase.admin")).thenReturn(true);
JavaPlugin plugin = mock(JavaPlugin.class);
FileConfiguration configuration = mock(FileConfiguration.class);
when(plugin.getConfig()).thenReturn(configuration);
when(configuration.getValues(false)).thenReturn(Map.of(
"base-unlock-blocks", 250,
"visitor-currency-material", "DIAMOND",
"title-stay-ticks", 70
));
BaseAdminCommand command = new BaseAdminCommand(
plugin,
mock(BaseStateManager.class),
mock(AdminProgressionService.class),
new PluginSettingsProvider(PluginSettings.from(Map.of()))
);
assertEquals(
List.of("base-unlock-blocks"),
command.onTabComplete(sender, null, "baseadmin", new String[] {"config", "base"})
);
}
@Test
void autocompletesKnownOfflinePlayers() {
CommandSender sender = mock(CommandSender.class);
when(sender.hasPermission("spigotbase.admin")).thenReturn(true);
BaseStateManager stateManager = mock(BaseStateManager.class);
PlayerState alex = PlayerState.newPlayer(UUID.randomUUID(), "Alex");
PlayerState blake = PlayerState.newPlayer(UUID.randomUUID(), "Blake");
when(stateManager.knownPlayers()).thenReturn(Map.of(
alex.playerId(), alex,
blake.playerId(), blake
));
BaseAdminCommand command = new BaseAdminCommand(
mock(JavaPlugin.class),
stateManager,
mock(AdminProgressionService.class),
new PluginSettingsProvider(PluginSettings.from(Map.of()))
);
assertEquals(
List.of("Alex", "Blake"),
command.onTabComplete(sender, null, "baseadmin", new String[] {"status", ""})
);
}
@Test
void autocompletesAdminSubcommandsForPermittedSenders() {
CommandSender sender = mock(CommandSender.class);
when(sender.hasPermission("spigotbase.admin")).thenReturn(true);
BaseAdminCommand command = new BaseAdminCommand(
mock(JavaPlugin.class),
mock(BaseStateManager.class),
mock(AdminProgressionService.class),
new PluginSettingsProvider(PluginSettings.from(Map.of()))
);
assertEquals(
List.of(
"status", "setlevel", "setprogress", "setsetting", "clearcooldown", "reset",
"config"
),
command.onTabComplete(sender, null, "baseadmin", new String[] {""})
);
}
@Test
void resetBaseRequiresConfirmedCompleteReset() {
CommandSender sender = mock(CommandSender.class);
when(sender.hasPermission("spigotbase.admin")).thenReturn(true);
PlayerState target = PlayerState.newPlayer(UUID.randomUUID(), "Builder");
BaseStateManager stateManager = mock(BaseStateManager.class);
when(stateManager.findByName("Builder")).thenReturn(Optional.of(target));
BaseAdminCommand command = new BaseAdminCommand(
mock(JavaPlugin.class),
stateManager,
mock(AdminProgressionService.class),
new PluginSettingsProvider(PluginSettings.from(Map.of()))
);
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class)) {
bukkit.when(() -> Bukkit.getPlayerExact("Builder")).thenReturn(null);
command.onCommand(
sender,
null,
"baseadmin",
new String[] {"reset", "Builder", "base"}
);
}
verify(stateManager, never()).update(any(), anyString(), any());
verify(sender).sendMessage(ChatColor.RED
+ "Resetting base removes all progression. Use: /baseadmin reset Builder all confirm");
}
@Test
void statusDisplaysKnownPlayerProgress() {
CommandSender sender = mock(CommandSender.class);
when(sender.hasPermission("spigotbase.admin")).thenReturn(true);
PlayerState target = PlayerState.newPlayer(UUID.randomUUID(), "Builder");
BaseStateManager stateManager = mock(BaseStateManager.class);
when(stateManager.findByName("Builder")).thenReturn(Optional.of(target));
BaseAdminCommand command = new BaseAdminCommand(
mock(JavaPlugin.class),
stateManager,
mock(AdminProgressionService.class),
new PluginSettingsProvider(PluginSettings.from(Map.of()))
);
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class)) {
bukkit.when(() -> Bukkit.getPlayerExact("Builder")).thenReturn(null);
command.onCommand(sender, null, "baseadmin", new String[] {"status", "Builder"});
}
verify(sender).sendMessage(ChatColor.GOLD + "=== Builder Base Progress ===");
}
}
@@ -0,0 +1,92 @@
package games.dmg.spigotbase;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.atLeastOnce;
import static org.mockito.Mockito.doReturn;
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.Map;
import java.util.UUID;
import org.bukkit.Location;
import org.bukkit.Particle;
import org.bukkit.Server;
import org.bukkit.World;
import org.bukkit.entity.Player;
import org.junit.jupiter.api.Test;
final class BaseBorderControllerTest {
@Test
void doesNotRenderOutsideTheBaseVerticalRange() {
UUID playerId = UUID.randomUUID();
UUID worldId = UUID.randomUUID();
PlayerState state = PlayerState.newPlayer(playerId, "Builder")
.withAdministrativeLevels(1, 0, 0, 0, 0, false, false, false)
.withBase(new BaseLocation(worldId, "world", 0, 64, 0, 0, 0), Instant.EPOCH)
.withBorderEnabled(true);
World world = mock(World.class);
when(world.getUID()).thenReturn(worldId);
Player player = mock(Player.class);
when(player.getUniqueId()).thenReturn(playerId);
when(player.getName()).thenReturn("Builder");
when(player.getWorld()).thenReturn(world);
when(player.getLocation()).thenReturn(new Location(world, 10.5, 100.0, 0.5));
Server server = mock(Server.class);
doReturn(java.util.List.of(player)).when(server).getOnlinePlayers();
BaseStateManager stateManager = mock(BaseStateManager.class);
when(stateManager.player(playerId, "Builder")).thenReturn(state);
new BaseBorderController(
server,
stateManager,
new BaseBoundsService(PluginSettings.from(Map.of()))
).run();
verify(player, never()).spawnParticle(
eq(Particle.END_ROD),
any(Location.class),
eq(1),
eq(0.0), eq(0.0), eq(0.0), eq(0.0)
);
}
@Test
void ownerSeesParticlesAtCurrentHeightNearEnabledBorder() {
UUID playerId = UUID.randomUUID();
UUID worldId = UUID.randomUUID();
PlayerState state = PlayerState.newPlayer(playerId, "Builder")
.withAdministrativeLevels(1, 0, 0, 0, 0, false, false, false)
.withBase(new BaseLocation(worldId, "world", 0, 64, 0, 0, 0), Instant.EPOCH)
.withBorderEnabled(true);
World world = mock(World.class);
when(world.getUID()).thenReturn(worldId);
Player player = mock(Player.class);
when(player.getUniqueId()).thenReturn(playerId);
when(player.getName()).thenReturn("Builder");
when(player.getWorld()).thenReturn(world);
when(player.getLocation()).thenReturn(new Location(world, 10.5, 70.0, 0.5));
Server server = mock(Server.class);
doReturn(java.util.List.of(player)).when(server).getOnlinePlayers();
BaseStateManager stateManager = mock(BaseStateManager.class);
when(stateManager.player(playerId, "Builder")).thenReturn(state);
new BaseBorderController(
server,
stateManager,
new BaseBoundsService(PluginSettings.from(Map.of()))
).run();
verify(player, atLeastOnce()).spawnParticle(
eq(Particle.END_ROD),
any(Location.class),
eq(1),
eq(0.0), eq(0.0), eq(0.0), eq(0.0)
);
}
}
@@ -0,0 +1,27 @@
package games.dmg.spigotbase;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.List;
import org.junit.jupiter.api.Test;
final class BaseBorderGeometryTest {
@Test
void producesBoundedPointsOnTheCurrentCircularBoundary() {
List<BaseBorderGeometry.Point> points = BaseBorderGeometry.visiblePoints(
0.5, 0.5, 150, 150.5, 0.5
);
assertFalse(points.isEmpty());
assertTrue(points.size() <= BaseBorderGeometry.MAX_PARTICLES);
assertTrue(points.stream().allMatch(point ->
Math.abs(Math.hypot(point.x() - 0.5, point.z() - 0.5) - 150.0) < 0.0001
));
}
@Test
void omitsParticlesWhenPlayerIsFarFromBoundary() {
assertTrue(BaseBorderGeometry.visiblePoints(0.5, 0.5, 10, 100.5, 0.5).isEmpty());
}
}
@@ -1,59 +0,0 @@
package games.dmg.spigotbase;
import static org.junit.jupiter.api.Assertions.assertEquals;
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.when;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.UnaryOperator;
import org.bukkit.entity.Player;
import org.junit.jupiter.api.Test;
final class BaseFlightCommandTest {
@Test
void autocompletesExplicitModes() {
BaseFlightCommand command = new BaseFlightCommand(
mock(BaseStateManager.class),
mock(BaseFlightController.class)
);
assertEquals(
List.of("off"),
command.onTabComplete(null, null, "baseflight", new String[] {"of"})
);
}
@Test
void explicitOnIsIdempotent() {
UUID playerId = UUID.randomUUID();
Player player = mock(Player.class);
when(player.getUniqueId()).thenReturn(playerId);
when(player.getName()).thenReturn("Builder");
PlayerState current = PlayerState.newPlayer(playerId, "Builder")
.withAdministrativeLevels(1, 0, 1, 0, 0, false, true, false);
AtomicReference<PlayerState> updated = new AtomicReference<>();
BaseStateManager stateManager = mock(BaseStateManager.class);
when(stateManager.player(playerId, "Builder")).thenReturn(current);
when(stateManager.update(any(), anyString(), any())).thenAnswer(invocation -> {
@SuppressWarnings("unchecked")
UnaryOperator<PlayerState> operation = invocation.getArgument(2);
PlayerState result = operation.apply(current);
updated.set(result);
return result;
});
BaseFlightCommand command = new BaseFlightCommand(
stateManager,
mock(BaseFlightController.class)
);
command.onCommand(player, null, "baseflight", new String[] {"on"});
assertTrue(updated.get().flightEnabled());
}
}
@@ -0,0 +1,444 @@
package games.dmg.spigotbase;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.doReturn;
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.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;
import org.junit.jupiter.api.Test;
final class BaseFlightControllerTest {
private final UUID playerId = UUID.randomUUID();
private final UUID worldId = UUID.randomUUID();
private final Server server = mock(Server.class);
private final BaseStateManager stateManager = mock(BaseStateManager.class);
private final Player player = mock(Player.class);
private final PlayerState state = PlayerState.newPlayer(playerId, "Alex")
.withGrassAndDirtProgress(250, 1)
.withBase(new BaseLocation(worldId, "world", 0, 64, 0, 0, 0), Instant.EPOCH)
.withFlightLevel(1, true);
private final BaseFlightController controller = new BaseFlightController(
server,
stateManager,
new SecondaryProgressionService(PluginSettings.from(Map.of())),
new BaseBoundsService(PluginSettings.from(Map.of())),
new PluginSettingsProvider(PluginSettings.from(Map.of()))
);
@BeforeEach
void setUp() {
World world = mock(World.class);
Location location = mock(Location.class);
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);
when(player.getInventory()).thenReturn(inventory);
when(inventory.getStorageContents()).thenReturn(new ItemStack[0]);
when(stateManager.player(playerId, "Alex")).thenReturn(state);
when(player.getWorld()).thenReturn(world);
when(world.getUID()).thenReturn(worldId);
when(player.getLocation()).thenReturn(location);
when(location.getX()).thenReturn(12.5);
when(location.getZ()).thenReturn(0.5);
when(location.getBlockY()).thenReturn(64);
when(player.getAllowFlight()).thenReturn(true);
}
@Test
void ownerWinningRaidInOwnPocketBaseUnlocksFlight() throws Exception {
UUID pocketWorldId = UUID.randomUUID();
World pocketWorld = mock(World.class);
Player winner = mock(Player.class);
RaidFinishEvent event = mock(RaidFinishEvent.class);
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
PocketBaseState locked = new PocketBaseState(playerId, 1, Optional.empty());
PocketBaseState unlocked = locked.withFlightUnlocked();
BaseFlightController pocketController = new BaseFlightController(
server,
stateManager,
new SecondaryProgressionService(PluginSettings.from(Map.of())),
new BaseBoundsService(PluginSettings.from(Map.of())),
new PluginSettingsProvider(PluginSettings.from(Map.of())),
pocketBases,
Logger.getAnonymousLogger()
);
when(event.getWorld()).thenReturn(pocketWorld);
when(event.getWinners()).thenReturn(List.of(winner));
when(pocketWorld.getUID()).thenReturn(pocketWorldId);
when(winner.getUniqueId()).thenReturn(playerId);
when(pocketBases.ownerForPocketWorld(pocketWorldId)).thenReturn(Optional.of(playerId));
when(pocketBases.state(playerId)).thenReturn(locked);
when(pocketBases.unlockFlight(playerId)).thenReturn(unlocked);
pocketController.onRaidFinish(event);
verify(pocketBases).unlockFlight(playerId);
verify(winner).sendTitle(anyString(), anyString(), anyInt(), anyInt(), anyInt());
}
@Test
void raidDoesNotUnlockFlightWhenPocketOwnerIsNotAWinner() throws Exception {
UUID pocketWorldId = UUID.randomUUID();
UUID ownerId = UUID.randomUUID();
World pocketWorld = mock(World.class);
Player visitor = mock(Player.class);
RaidFinishEvent event = mock(RaidFinishEvent.class);
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
BaseFlightController pocketController = new BaseFlightController(
server,
stateManager,
new SecondaryProgressionService(PluginSettings.from(Map.of())),
new BaseBoundsService(PluginSettings.from(Map.of())),
new PluginSettingsProvider(PluginSettings.from(Map.of())),
pocketBases,
Logger.getAnonymousLogger()
);
when(event.getWorld()).thenReturn(pocketWorld);
when(event.getWinners()).thenReturn(List.of(visitor));
when(pocketWorld.getUID()).thenReturn(pocketWorldId);
when(visitor.getUniqueId()).thenReturn(UUID.randomUUID());
when(pocketBases.ownerForPocketWorld(pocketWorldId)).thenReturn(Optional.of(ownerId));
pocketController.onRaidFinish(event);
verify(pocketBases, never()).unlockFlight(ownerId);
}
@Test
void unlockedOwnerReceivesFlightInsidePocketBaseBuffer() {
UUID pocketWorldId = UUID.randomUUID();
World pocketWorld = mock(World.class);
Location location = mock(Location.class);
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
PocketBaseState pocket = new PocketBaseState(playerId, 1, Optional.empty())
.withFlightUnlocked();
PlayerState noNormalFlight = PlayerState.newPlayer(playerId, "Alex");
BaseFlightController pocketController = new BaseFlightController(
server,
stateManager,
new SecondaryProgressionService(PluginSettings.from(Map.of())),
new BaseBoundsService(PluginSettings.from(Map.of())),
new PluginSettingsProvider(PluginSettings.from(Map.of())),
pocketBases,
Logger.getAnonymousLogger()
);
when(stateManager.player(playerId, "Alex")).thenReturn(noNormalFlight);
when(player.getWorld()).thenReturn(pocketWorld);
when(player.getLocation()).thenReturn(location);
when(player.getAllowFlight()).thenReturn(false);
when(pocketWorld.getUID()).thenReturn(pocketWorldId);
when(location.getX()).thenReturn(40.5);
when(location.getZ()).thenReturn(0.5);
when(location.getBlockX()).thenReturn(40);
when(location.getBlockZ()).thenReturn(0);
when(pocketBases.ownerForPocketWorld(pocketWorldId)).thenReturn(Optional.of(playerId));
when(pocketBases.state(playerId)).thenReturn(pocket);
when(pocketBases.policy()).thenReturn(
new PocketBasePolicy(PluginSettings.from(Map.of()))
);
pocketController.run();
verify(player).setAllowFlight(true);
}
@Test
void disabledAutomaticPocketBaseFlightDoesNotGrantOwnerFlight() {
UUID pocketWorldId = UUID.randomUUID();
World pocketWorld = mock(World.class);
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
PocketBaseState pocket = new PocketBaseState(playerId, 1, Optional.empty())
.withFlightUnlocked()
.withFlightEnabled(false);
BaseFlightController pocketController = new BaseFlightController(
server,
stateManager,
new SecondaryProgressionService(PluginSettings.from(Map.of())),
new BaseBoundsService(PluginSettings.from(Map.of())),
new PluginSettingsProvider(PluginSettings.from(Map.of())),
pocketBases,
Logger.getAnonymousLogger()
);
when(stateManager.player(playerId, "Alex")).thenReturn(
PlayerState.newPlayer(playerId, "Alex")
);
when(player.getWorld()).thenReturn(pocketWorld);
when(player.getAllowFlight()).thenReturn(false);
when(pocketWorld.getUID()).thenReturn(pocketWorldId);
when(pocketBases.ownerForPocketWorld(pocketWorldId)).thenReturn(Optional.of(playerId));
when(pocketBases.state(playerId)).thenReturn(pocket);
pocketController.run();
verify(player, never()).setAllowFlight(true);
}
@Test
void flightPortalTemporarilyGrantsGuestPocketBaseFlight() {
UUID ownerId = UUID.randomUUID();
UUID pocketWorldId = UUID.randomUUID();
World pocketWorld = mock(World.class);
Location location = mock(Location.class);
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
PocketBaseState pocket = new PocketBaseState(ownerId, 1, Optional.empty())
.withFlightUnlocked()
.withFlightEnabled(false);
BaseFlightController pocketController = new BaseFlightController(
server,
stateManager,
new SecondaryProgressionService(PluginSettings.from(Map.of())),
new BaseBoundsService(PluginSettings.from(Map.of())),
new PluginSettingsProvider(PluginSettings.from(Map.of())),
pocketBases,
Logger.getAnonymousLogger()
);
when(stateManager.player(playerId, "Alex")).thenReturn(
PlayerState.newPlayer(playerId, "Alex")
);
when(player.getWorld()).thenReturn(pocketWorld);
when(player.getLocation()).thenReturn(location);
when(player.getAllowFlight()).thenReturn(false);
when(pocketWorld.getUID()).thenReturn(pocketWorldId);
when(pocketBases.ownerForPocketWorld(pocketWorldId)).thenReturn(Optional.of(ownerId));
when(pocketBases.state(ownerId)).thenReturn(pocket);
when(pocketBases.policy()).thenReturn(
new PocketBasePolicy(PluginSettings.from(Map.of()))
);
when(player.getGameMode()).thenReturn(org.bukkit.GameMode.CREATIVE);
pocketController.grantPocketPortalFlight(player, ownerId);
verify(player, never()).setFlying(true);
verify(player, never()).sendMessage(anyString());
when(player.getGameMode()).thenReturn(org.bukkit.GameMode.SURVIVAL);
when(location.getBlockX()).thenReturn(10000);
pocketController.grantPocketPortalFlight(player, ownerId);
verify(player, never()).setFlying(true);
verify(player, never()).sendMessage(anyString());
when(location.getBlockX()).thenReturn(0);
pocketController.grantPocketPortalFlight(player, ownerId);
verify(player).setAllowFlight(true);
verify(player).setFlying(true);
verify(player).sendMessage(org.bukkit.ChatColor.GREEN + "Pocket dimension flight enabled!");
when(player.getAllowFlight()).thenReturn(true);
pocketController.grantPocketPortalFlight(player, ownerId);
verify(player, times(2)).setFlying(true);
verify(player, times(2)).sendMessage(org.bukkit.ChatColor.GREEN + "Pocket dimension flight enabled!");
}
@Test
void portalFlightEndsAfterGuestLeavesPocketBase() {
UUID ownerId = UUID.randomUUID();
UUID pocketWorldId = UUID.randomUUID();
UUID normalWorldId = UUID.randomUUID();
World pocketWorld = mock(World.class);
World normalWorld = mock(World.class);
Location location = mock(Location.class);
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
PocketBaseState pocket = new PocketBaseState(ownerId, 1, Optional.empty())
.withFlightUnlocked()
.withFlightEnabled(false);
BaseFlightController pocketController = new BaseFlightController(
server,
stateManager,
new SecondaryProgressionService(PluginSettings.from(Map.of())),
new BaseBoundsService(PluginSettings.from(Map.of())),
new PluginSettingsProvider(PluginSettings.from(Map.of())),
pocketBases,
Logger.getAnonymousLogger()
);
when(stateManager.player(playerId, "Alex")).thenReturn(
PlayerState.newPlayer(playerId, "Alex")
);
when(player.getWorld()).thenReturn(pocketWorld, normalWorld);
when(player.getLocation()).thenReturn(location);
when(player.getAllowFlight()).thenReturn(false);
when(pocketWorld.getUID()).thenReturn(pocketWorldId);
when(normalWorld.getUID()).thenReturn(normalWorldId);
when(pocketBases.ownerForPocketWorld(pocketWorldId)).thenReturn(Optional.of(ownerId));
when(pocketBases.ownerForPocketWorld(normalWorldId)).thenReturn(Optional.empty());
when(pocketBases.state(ownerId)).thenReturn(pocket);
when(pocketBases.policy()).thenReturn(
new PocketBasePolicy(PluginSettings.from(Map.of()))
);
pocketController.grantPocketPortalFlight(player, ownerId);
pocketController.run();
pocketController.run();
verify(player).setFlying(false);
verify(player).setAllowFlight(false);
}
@Test
void flyingInPocketBaseBufferShowsLeavingWarning() {
UUID pocketWorldId = UUID.randomUUID();
World pocketWorld = mock(World.class);
Location location = mock(Location.class);
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
PocketBaseState pocket = new PocketBaseState(playerId, 1, Optional.empty())
.withFlightUnlocked();
BaseFlightController pocketController = new BaseFlightController(
server,
stateManager,
new SecondaryProgressionService(PluginSettings.from(Map.of())),
new BaseBoundsService(PluginSettings.from(Map.of())),
new PluginSettingsProvider(PluginSettings.from(Map.of())),
pocketBases,
Logger.getAnonymousLogger()
);
when(stateManager.player(playerId, "Alex")).thenReturn(
PlayerState.newPlayer(playerId, "Alex")
);
when(player.getWorld()).thenReturn(pocketWorld);
when(player.getLocation()).thenReturn(location);
when(player.isFlying()).thenReturn(true);
when(pocketWorld.getUID()).thenReturn(pocketWorldId);
when(location.getBlockX()).thenReturn(40);
when(location.getBlockZ()).thenReturn(0);
when(pocketBases.ownerForPocketWorld(pocketWorldId)).thenReturn(Optional.of(playerId));
when(pocketBases.state(playerId)).thenReturn(pocket);
when(pocketBases.policy()).thenReturn(
new PocketBasePolicy(PluginSettings.from(Map.of()))
);
pocketController.run();
verify(player).sendTitle(anyString(), anyString(), anyInt(), anyInt(), anyInt());
}
@Test
void pocketBaseFlightEndsBeyondSixteenBlockBuffer() {
UUID pocketWorldId = UUID.randomUUID();
World pocketWorld = mock(World.class);
Location location = mock(Location.class);
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
PocketBaseState pocket = new PocketBaseState(playerId, 1, Optional.empty())
.withFlightUnlocked();
BaseFlightController pocketController = new BaseFlightController(
server,
stateManager,
new SecondaryProgressionService(PluginSettings.from(Map.of())),
new BaseBoundsService(PluginSettings.from(Map.of())),
new PluginSettingsProvider(PluginSettings.from(Map.of())),
pocketBases,
Logger.getAnonymousLogger()
);
when(stateManager.player(playerId, "Alex")).thenReturn(
PlayerState.newPlayer(playerId, "Alex")
);
when(player.getWorld()).thenReturn(pocketWorld);
when(player.getLocation()).thenReturn(location);
when(player.getAllowFlight()).thenReturn(false);
when(pocketWorld.getUID()).thenReturn(pocketWorldId);
when(location.getBlockX()).thenReturn(40, 48);
when(location.getBlockZ()).thenReturn(0);
when(pocketBases.ownerForPocketWorld(pocketWorldId)).thenReturn(Optional.of(playerId));
when(pocketBases.state(playerId)).thenReturn(pocket);
when(pocketBases.policy()).thenReturn(
new PocketBasePolicy(PluginSettings.from(Map.of()))
);
pocketController.run();
pocketController.run();
verify(player).setFlying(false);
verify(player).setAllowFlight(false);
}
@Test
void visitorDoesNotReceiveUnlockedOwnersPocketBaseFlight() {
UUID ownerId = UUID.randomUUID();
UUID pocketWorldId = UUID.randomUUID();
World pocketWorld = mock(World.class);
Location location = mock(Location.class);
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
PocketBaseState pocket = new PocketBaseState(ownerId, 1, Optional.empty())
.withFlightUnlocked();
BaseFlightController pocketController = new BaseFlightController(
server,
stateManager,
new SecondaryProgressionService(PluginSettings.from(Map.of())),
new BaseBoundsService(PluginSettings.from(Map.of())),
new PluginSettingsProvider(PluginSettings.from(Map.of())),
pocketBases,
Logger.getAnonymousLogger()
);
when(stateManager.player(playerId, "Alex")).thenReturn(
PlayerState.newPlayer(playerId, "Alex")
);
when(player.getWorld()).thenReturn(pocketWorld);
when(player.getLocation()).thenReturn(location);
when(player.getAllowFlight()).thenReturn(false);
when(pocketWorld.getUID()).thenReturn(pocketWorldId);
when(pocketBases.ownerForPocketWorld(pocketWorldId)).thenReturn(Optional.of(ownerId));
when(pocketBases.state(ownerId)).thenReturn(pocket);
pocketController.run();
verify(player, never()).setAllowFlight(true);
}
@Test
void walkingInWarningBufferDoesNotShowLeavingBaseWarning() {
when(player.isFlying()).thenReturn(false);
controller.run();
verify(player, never()).sendTitle(anyString(), anyString(), anyInt(), anyInt(), anyInt());
}
@Test
void flyingInWarningBufferShowsLeavingBaseWarning() {
when(player.isFlying()).thenReturn(true);
controller.run();
verify(player).sendTitle(anyString(), anyString(), anyInt(), anyInt(), anyInt());
}
@Test
void warningCanAppearAgainAfterPlayerStopsAndResumesFlyingInBuffer() {
when(player.isFlying()).thenReturn(true, false, true);
controller.run();
controller.run();
controller.run();
verify(player, times(2)).sendTitle(anyString(), anyString(), anyInt(), anyInt(), anyInt());
}
}
@@ -0,0 +1,60 @@
package games.dmg.spigotbase;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doReturn;
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.time.Instant;
import java.util.Map;
import java.util.UUID;
import org.bukkit.Location;
import org.bukkit.Particle;
import org.bukkit.Server;
import org.bukkit.World;
import org.bukkit.entity.Player;
import org.junit.jupiter.api.Test;
final class BaseNavigationControllerTest {
@Test
void showsParticlesOnlyMoreThanTwentyFiveBlocksBeyondBorder() {
UUID playerId = UUID.randomUUID();
UUID worldId = UUID.randomUUID();
PlayerState state = PlayerState.newPlayer(playerId, "Builder")
.withAdministrativeLevels(2, 0, 0, 0, 0, true, false, false)
.withBase(new BaseLocation(worldId, "world", 0, 64, 0, 0, 0), Instant.EPOCH);
World world = mock(World.class);
when(world.getUID()).thenReturn(worldId);
Player player = mock(Player.class);
when(player.getUniqueId()).thenReturn(playerId);
when(player.getName()).thenReturn("Builder");
when(player.getWorld()).thenReturn(world);
when(player.getLocation()).thenReturn(new Location(world, 35.5, 64.0, 0.5));
Server server = mock(Server.class);
doReturn(java.util.List.of(player)).when(server).getOnlinePlayers();
BaseStateManager stateManager = mock(BaseStateManager.class);
when(stateManager.player(playerId, "Builder")).thenReturn(state);
PluginSettingsProvider settings = new PluginSettingsProvider(
PluginSettings.from(Map.of())
);
new BaseNavigationController(server, stateManager, settings).run();
verify(player, never()).spawnParticle(
eq(Particle.END_ROD), any(Location.class), eq(1),
eq(0.0), eq(0.0), eq(0.0), eq(0.0)
);
when(player.getLocation()).thenReturn(new Location(world, 35.51, 64.0, 0.5));
new BaseNavigationController(server, stateManager, settings).run();
verify(player, times(settings.current().navigationParticleCount())).spawnParticle(
eq(Particle.END_ROD), any(Location.class), eq(1),
eq(0.0), eq(0.0), eq(0.0), eq(0.0)
);
}
}
@@ -0,0 +1,26 @@
package games.dmg.spigotbase;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.UUID;
import org.junit.jupiter.api.Test;
final class BaseNavigationVisibilityTest {
private static final BaseLocation BASE = new BaseLocation(
UUID.randomUUID(), "world", 0, 64, 0, 0, 0
);
@Test
void hidesNavigationAtAndWithinTwentyFiveBlocksBeyondBorder() {
assertFalse(BaseNavigationVisibility.isBeyondBorderBuffer(BASE, 10, 35.5, 0.5));
assertFalse(BaseNavigationVisibility.isBeyondBorderBuffer(BASE, 10, 20.5, 0.5));
}
@Test
void showsNavigationMoreThanTwentyFiveBlocksBeyondCurrentBorder() {
assertTrue(BaseNavigationVisibility.isBeyondBorderBuffer(BASE, 10, 35.51, 0.5));
assertFalse(BaseNavigationVisibility.isBeyondBorderBuffer(BASE, 75, 100.5, 0.5));
assertTrue(BaseNavigationVisibility.isBeyondBorderBuffer(BASE, 75, 100.51, 0.5));
}
}
@@ -0,0 +1,66 @@
package games.dmg.spigotbase;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.UnaryOperator;
import org.bukkit.GameMode;
import org.bukkit.World;
import org.bukkit.block.Block;
import org.bukkit.entity.Player;
import org.bukkit.event.block.BlockPlaceEvent;
import org.bukkit.plugin.Plugin;
import org.junit.jupiter.api.Test;
final class BaseProgressListenerTest {
@Test
void survivalPlacementOutsideBaseCountsTowardOverlayUnlock() {
UUID playerId = UUID.randomUUID();
PlayerState current = PlayerState.newPlayer(playerId, "Builder");
AtomicReference<PlayerState> updated = new AtomicReference<>();
BaseStateManager stateManager = mock(BaseStateManager.class);
when(stateManager.player(playerId, "Builder")).thenReturn(current);
when(stateManager.update(any(), anyString(), any())).thenAnswer(invocation -> {
@SuppressWarnings("unchecked")
UnaryOperator<PlayerState> operation = invocation.getArgument(2);
PlayerState result = operation.apply(current);
updated.set(result);
return result;
});
Player player = mock(Player.class);
when(player.getUniqueId()).thenReturn(playerId);
when(player.getName()).thenReturn("Builder");
when(player.getGameMode()).thenReturn(GameMode.SURVIVAL);
World world = mock(World.class);
when(world.getUID()).thenReturn(UUID.randomUUID());
Block block = mock(Block.class);
when(block.getWorld()).thenReturn(world);
BlockPlaceEvent event = mock(BlockPlaceEvent.class);
when(event.getPlayer()).thenReturn(player);
when(event.getBlockPlaced()).thenReturn(block);
PluginSettingsProvider settings = new PluginSettingsProvider(
PluginSettings.from(Map.of())
);
BaseProgressListener listener = new BaseProgressListener(
mock(Plugin.class),
stateManager,
mock(BaseProgressionService.class),
mock(SecondaryProgressionService.class),
mock(TeleportProgressionService.class),
new SpawnableOverlayProgressionService(settings),
new BaseBoundsService(settings),
settings
);
listener.onBlockPlace(event);
assertEquals(1, updated.get().totalBlocksPlaced());
assertEquals(0, updated.get().blocksPlacedInBase());
}
}
@@ -0,0 +1,673 @@
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.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();
PlayerState current = PlayerState.newPlayer(playerId, "Builder")
.withAdministrativeLevels(1, 0, 0, 0, 0, false, false, false)
.withBase(
new BaseLocation(UUID.randomUUID(), "world", 0, 64, 0, 0, 0),
Instant.EPOCH
)
.withTotalBlocksPlaced(250);
CommandResult result = execute(current, "spawnable", "enable");
assertTrue(result.updated().spawnableOverlayEnabled());
}
@Test
void borderEnableIsIdempotent() {
UUID playerId = UUID.randomUUID();
PlayerState current = PlayerState.newPlayer(playerId, "Builder")
.withAdministrativeLevels(1, 0, 0, 0, 0, false, false, false)
.withBase(
new BaseLocation(UUID.randomUUID(), "world", 0, 64, 0, 0, 0),
Instant.EPOCH
);
CommandResult result = execute(current, "border", "enable");
assertTrue(result.updated().borderEnabled());
}
@Test
void invalidArgumentsShowUsage() {
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"));
BaseSettingsCommand command = new BaseSettingsCommand(
stateManager,
new PluginSettingsProvider(PluginSettings.from(Map.of())),
mock(BaseFlightController.class)
);
command.onCommand(player, null, "basesettings", new String[] {"unknown"});
verify(player).sendMessage(org.mockito.ArgumentMatchers.<String>argThat(
message -> message.contains("Usage: /basesettings")
));
}
@Test
void autocompletesValuesForEachSetting() {
BaseSettingsCommand command = new BaseSettingsCommand(
mock(BaseStateManager.class),
new PluginSettingsProvider(PluginSettings.from(Map.of())),
mock(BaseFlightController.class)
);
assertEquals(
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"),
command.onTabComplete(null, null, "basesettings", new String[] {setting, "d"})
);
}
}
@Test
void autocompletesSettingNames() {
BaseSettingsCommand command = new BaseSettingsCommand(
mock(BaseStateManager.class),
new PluginSettingsProvider(PluginSettings.from(Map.of())),
mock(BaseFlightController.class)
);
assertEquals(
List.of(
"ui", "status", "upgrade", "pocket", "visitors", "navigation", "flight",
"border", "spawnable", "bossbar"
),
command.onTabComplete(null, null, "basesettings", new String[] {""})
);
}
@Test
void upgradePurchasesBaseIv() throws Exception {
UUID playerId = UUID.randomUUID();
Player player = mock(Player.class);
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[] {
firstStack,
secondStack
});
PlayerState current = PlayerState.newPlayer(playerId, "Host")
.withAdministrativeLevels(3, 0, 0, 0, 0, false, false, false)
.withBase(
new BaseLocation(UUID.randomUUID(), "world", 0, 64, 0, 0, 0),
Instant.EPOCH
);
AtomicReference<PlayerState> updated = new AtomicReference<>();
BaseStateManager stateManager = mock(BaseStateManager.class);
when(stateManager.player(playerId, "Host")).thenReturn(current);
when(stateManager.updateAndSave(any(), anyString(), any())).thenAnswer(invocation -> {
@SuppressWarnings("unchecked")
UnaryOperator<PlayerState> operation = invocation.getArgument(2);
PlayerState result = operation.apply(current);
updated.set(result);
return result;
});
BaseSettingsCommand command = new BaseSettingsCommand(
stateManager,
new PluginSettingsProvider(PluginSettings.from(Map.of())),
mock(BaseFlightController.class)
);
command.onCommand(player, null, "basesettings", new String[] {"upgrade"});
assertEquals(4, updated.get().baseLevel());
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();
PlayerState current = PlayerState.newPlayer(playerId, "Miner");
CommandResult result = execute(current, "bossbar", "enable");
assertTrue(result.updated().bossBarEnabled());
}
@Test
void flightDisableIsIdempotentAndRemovesGrantedFlight() {
UUID playerId = UUID.randomUUID();
PlayerState current = PlayerState.newPlayer(playerId, "Pilot")
.withAdministrativeLevels(1, 0, 1, 0, 0, false, true, false);
CommandResult result = execute(current, "flight", "disable");
assertFalse(result.updated().flightEnabled());
verify(result.flightController()).removeGrantedFlight(any(Player.class));
}
@Test
void navigationDisableIsIdempotent() {
UUID playerId = UUID.randomUUID();
PlayerState current = PlayerState.newPlayer(playerId, "Explorer")
.withAdministrativeLevels(2, 0, 0, 0, 0, true, false, false)
.withBase(
new BaseLocation(UUID.randomUUID(), "world", 0, 64, 0, 0, 0),
Instant.EPOCH
);
CommandResult result = execute(current, "navigation", "disable");
assertFalse(result.updated().navigationEnabled());
}
@Test
void visitorsAllowedIsIdempotent() {
UUID playerId = UUID.randomUUID();
PlayerState current = PlayerState.newPlayer(playerId, "Host")
.withAdministrativeLevels(4, 0, 0, 0, 0, false, false, true);
CommandResult result = execute(current, "visitors", "allowed");
assertTrue(result.updated().visitorsEnabled());
}
@Test
void defaultAndUiOpenDialogWhileStatusDisplaysTheFullProgressReport() {
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"));
BaseSettingsCommand command = new BaseSettingsCommand(
stateManager,
new PluginSettingsProvider(PluginSettings.from(Map.of())),
mock(BaseFlightController.class),
null,
(owner, pocket) -> null
);
command.onCommand(player, null, "basesettings", new String[0]);
command.onCommand(player, null, "homesettings", new String[] {"ui"});
command.onCommand(player, null, "basesettings", new String[] {"status"});
verify(player, times(2)).showDialog((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).sendMessage(
org.mockito.ArgumentMatchers.<String>argThat(message ->
message.contains("Settings: visitors=")
&& message.contains("navigation=")
&& message.contains("flight=")
&& message.contains("border=")
&& message.contains("spawnable=")
&& message.contains("bossbar=")
)
);
}
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());
when(player.getName()).thenReturn(current.latestName());
AtomicReference<PlayerState> updated = new AtomicReference<>();
BaseStateManager stateManager = mock(BaseStateManager.class);
when(stateManager.player(current.playerId(), current.latestName())).thenReturn(current);
when(stateManager.update(any(), anyString(), any())).thenAnswer(invocation -> {
@SuppressWarnings("unchecked")
UnaryOperator<PlayerState> operation = invocation.getArgument(2);
PlayerState result = operation.apply(current);
updated.set(result);
return result;
});
BaseFlightController flightController = mock(BaseFlightController.class);
BaseSettingsCommand command = new BaseSettingsCommand(
stateManager,
new PluginSettingsProvider(PluginSettings.from(Map.of())),
flightController
);
command.onCommand(player, null, "basesettings", arguments);
return new CommandResult(updated.get(), flightController);
}
private record CommandResult(PlayerState updated, BaseFlightController flightController) {
}
}
@@ -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());
}
}
}
@@ -0,0 +1,140 @@
package games.dmg.spigotbase;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.Optional;
import java.util.UUID;
import org.bukkit.ChatColor;
import org.bukkit.World;
import org.bukkit.entity.Player;
import org.bukkit.plugin.Plugin;
import org.junit.jupiter.api.Test;
final class BaseTeleportManagerTest {
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.CsvSource({
"false,true,false", "true,true,false", "false,false,false", "true,false,false",
"false,false,true", "true,false,true"
})
void particlesFollowHomeAndVisitorTeleportOutcome(boolean visit, boolean succeeds, boolean cancelled) {
UUID id = UUID.randomUUID();
UUID worldId = UUID.randomUUID();
World world = mock(World.class);
when(world.getUID()).thenReturn(worldId);
org.bukkit.Location origin = new org.bukkit.Location(world, 0, 64, 0);
org.bukkit.Location target = new org.bukkit.Location(world, 20, 64, 20);
Player player = mock(Player.class);
when(player.getUniqueId()).thenReturn(id);
when(player.getName()).thenReturn("Player");
when(player.getWorld()).thenReturn(world);
when(player.getLocation()).thenReturn(origin);
when(player.isOnline()).thenReturn(true);
when(player.teleport(eq(target), any(org.bukkit.event.player.PlayerTeleportEvent.TeleportCause.class)))
.thenReturn(succeeds);
PlayerState owner = PlayerState.newPlayer(visit ? UUID.randomUUID() : id, "Owner")
.withGrassAndDirtProgress(500, 4)
.withBase(new BaseLocation(worldId, "world", 20, 64, 20, 0, 0), Instant.EPOCH)
.withVisitorsEnabled(true);
BaseStateManager states = mock(BaseStateManager.class);
when(states.player(id, "Player")).thenReturn(owner);
TeleportPolicy policy = mock(TeleportPolicy.class);
when(policy.warmup(owner)).thenReturn(Duration.ofSeconds(1));
when(policy.visitorWarmup(owner)).thenReturn(Duration.ofSeconds(1));
when(policy.cooldown(owner)).thenReturn(Duration.ZERO);
VisitorPolicy visitors = mock(VisitorPolicy.class);
SafeBaseDestination finder = mock(SafeBaseDestination.class);
when(finder.find(world, owner.base().orElseThrow())).thenReturn(Optional.of(target));
TeleportParticles particles = mock(TeleportParticles.class);
Plugin plugin = mock(Plugin.class);
org.bukkit.scheduler.BukkitScheduler scheduler = mock(org.bukkit.scheduler.BukkitScheduler.class);
when(scheduler.runTaskTimer(eq(plugin), any(Runnable.class), eq(0L), eq(20L)))
.thenReturn(mock(org.bukkit.scheduler.BukkitTask.class));
try (org.mockito.MockedStatic<org.bukkit.Bukkit> bukkit = org.mockito.Mockito.mockStatic(org.bukkit.Bukkit.class)) {
bukkit.when(org.bukkit.Bukkit::getScheduler).thenReturn(scheduler);
bukkit.when(() -> org.bukkit.Bukkit.getWorld(worldId)).thenReturn(world);
BaseTeleportManager manager = new BaseTeleportManager(plugin, states, policy,
visitors, finder, Clock.systemUTC(), particles);
if (visit) manager.startVisit(player, owner); else manager.start(player);
verify(particles).start(id, origin, 1);
org.mockito.ArgumentCaptor<Runnable> tick = org.mockito.ArgumentCaptor.forClass(Runnable.class);
verify(scheduler).runTaskTimer(eq(plugin), tick.capture(), eq(0L), eq(20L));
tick.getValue().run();
org.mockito.Mockito.verify(player, org.mockito.Mockito.never()).teleport(eq(target),
any(org.bukkit.event.player.PlayerTeleportEvent.TeleportCause.class));
if (cancelled) {
org.bukkit.event.player.PlayerQuitEvent quit = mock(org.bukkit.event.player.PlayerQuitEvent.class);
when(quit.getPlayer()).thenReturn(player);
manager.onQuit(quit);
verify(particles).cancel(id);
org.mockito.Mockito.verify(particles, org.mockito.Mockito.never()).complete(id);
org.mockito.Mockito.verify(states, org.mockito.Mockito.never()).saveIfDirty();
manager.cancelAll();
verify(particles).clear();
return;
}
tick.getValue().run();
if (succeeds) {
verify(particles).complete(id);
org.mockito.Mockito.verify(particles, org.mockito.Mockito.never()).cancel(id);
verify(states).saveIfDirty();
} else {
verify(particles).cancel(id);
org.mockito.Mockito.verify(particles, org.mockito.Mockito.never()).complete(id);
org.mockito.Mockito.verify(states, org.mockito.Mockito.never()).saveIfDirty();
}
manager.cancelAll();
verify(particles).clear();
}
}
@Test
void visitFromAnotherWorldIsRejectedBeforeWarmupAndCooldownChecks() {
UUID visitorId = UUID.randomUUID();
UUID ownerId = UUID.randomUUID();
UUID baseWorldId = UUID.randomUUID();
BaseStateManager stateManager = mock(BaseStateManager.class);
TeleportPolicy teleportPolicy = mock(TeleportPolicy.class);
VisitorPolicy visitorPolicy = mock(VisitorPolicy.class);
Player visitor = mock(Player.class);
World visitorWorld = mock(World.class);
PlayerState owner = PlayerState.newPlayer(ownerId, "Owner")
.withGrassAndDirtProgress(500, 4)
.withBase(
new BaseLocation(baseWorldId, "world", 0, 64, 0, 0, 0),
Instant.EPOCH
)
.withVisitorsEnabled(true);
PlayerState visitorState = PlayerState.newPlayer(visitorId, "Visitor");
BaseTeleportManager manager = new BaseTeleportManager(
mock(Plugin.class),
stateManager,
teleportPolicy,
visitorPolicy,
mock(SafeBaseDestination.class),
Clock.systemUTC()
);
when(visitor.getUniqueId()).thenReturn(visitorId);
when(visitor.getName()).thenReturn("Visitor");
when(visitor.getWorld()).thenReturn(visitorWorld);
when(visitorWorld.getUID()).thenReturn(UUID.randomUUID());
when(stateManager.player(visitorId, "Visitor")).thenReturn(visitorState);
when(visitorPolicy.remaining(eq(visitorState), eq(ownerId), any()))
.thenReturn(Optional.of(Duration.ofSeconds(1)));
manager.startVisit(visitor, owner);
verify(visitor).sendMessage(
ChatColor.RED + "You must be in the same dimension as that base to visit it."
);
verifyNoInteractions(stateManager, teleportPolicy, visitorPolicy);
}
}
@@ -1,53 +0,0 @@
package games.dmg.spigotbase;
import static org.junit.jupiter.api.Assertions.assertEquals;
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.when;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.UnaryOperator;
import org.bukkit.entity.Player;
import org.junit.jupiter.api.Test;
final class BaseVisitorsCommandTest {
@Test
void autocompletesExplicitModes() {
BaseVisitorsCommand command = new BaseVisitorsCommand(mock(BaseStateManager.class));
assertEquals(
List.of("on"),
command.onTabComplete(null, null, "basevisitors", new String[] {"on"})
);
}
@Test
void explicitOnIsIdempotent() {
UUID playerId = UUID.randomUUID();
Player player = mock(Player.class);
when(player.getUniqueId()).thenReturn(playerId);
when(player.getName()).thenReturn("Host");
PlayerState current = PlayerState.newPlayer(playerId, "Host")
.withAdministrativeLevels(4, 0, 0, 0, 0, false, false, true);
AtomicReference<PlayerState> updated = new AtomicReference<>();
BaseStateManager stateManager = mock(BaseStateManager.class);
when(stateManager.player(playerId, "Host")).thenReturn(current);
when(stateManager.update(any(), anyString(), any())).thenAnswer(invocation -> {
@SuppressWarnings("unchecked")
UnaryOperator<PlayerState> operation = invocation.getArgument(2);
PlayerState result = operation.apply(current);
updated.set(result);
return result;
});
BaseVisitorsCommand command = new BaseVisitorsCommand(stateManager);
command.onCommand(player, null, "basevisitors", new String[] {"on"});
assertTrue(updated.get().visitorsEnabled());
}
}
@@ -1,13 +1,48 @@
package games.dmg.spigotbase;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.time.Instant;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import org.junit.jupiter.api.Test;
final class PlayerStateTest {
@Test
void spawnableOverlayProgressAndPreferenceCanBeUpdated() {
PlayerState player = PlayerState.newPlayer(UUID.randomUUID(), "Alex")
.withAdministrativeLevels(1, 0, 0, 0, 0, false, false, false)
.withBase(
new BaseLocation(UUID.randomUUID(), "world", 0, 64, 0, 0, 0),
Instant.EPOCH
);
PlayerState updated = player
.withTotalBlocksPlaced(250)
.withSpawnableOverlayEnabled(true);
assertTrue(updated.spawnableOverlayEnabled());
assertTrue(updated.totalBlocksPlaced() == 250);
}
@Test
void borderVisualizationIsDisabledByDefaultAndCanBeEnabled() {
UUID playerId = UUID.randomUUID();
PlayerState player = PlayerState.newPlayer(playerId, "Alex");
PlayerState established = player
.withAdministrativeLevels(1, 0, 0, 0, 0, false, false, false)
.withBase(
new BaseLocation(UUID.randomUUID(), "world", 0, 64, 0, 0, 0),
Instant.EPOCH
);
assertFalse(player.borderEnabled());
assertTrue(established.withBorderEnabled(true).borderEnabled());
}
@Test
void rejectsLevelsOutsideKnownRanges() {
UUID playerId = UUID.randomUUID();
@@ -1,6 +1,7 @@
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.assertNotNull;
import java.io.InputStream;
@@ -10,6 +11,34 @@ import org.junit.jupiter.api.Test;
import org.yaml.snakeyaml.Yaml;
final class PluginMetadataTest {
@Test
void homeadminAliasesBaseadmin() {
Map<?, ?> commands = commands();
Map<?, ?> admin = (Map<?, ?>) commands.get("baseadmin");
assertEquals(List.of("homeadmin"), admin.get("aliases"));
}
@Test
void doesNotDeclareSupersededSettingsCommands() {
Map<?, ?> commands = commands();
for (String command : List.of(
"baseprogress", "basenavigation", "baseflight", "basevisitors"
)) {
assertFalse(commands.containsKey(command), command);
}
}
@Test
void homesettingsAliasesBasesettings() {
Map<?, ?> commands = commands();
Map<?, ?> settings = (Map<?, ?>) commands.get("basesettings");
assertNotNull(settings);
assertEquals(List.of("homesettings"), settings.get("aliases"));
}
@Test
void sethomeAliasesSetbase() {
Map<?, ?> commands = commands();
@@ -26,30 +55,6 @@ final class PluginMetadataTest {
assertEquals(List.of("home"), base.get("aliases"));
}
@Test
void homevisitorsAliasesBasevisitors() {
Map<?, ?> commands = commands();
Map<?, ?> visitors = (Map<?, ?>) commands.get("basevisitors");
assertEquals(List.of("homevisitors"), visitors.get("aliases"));
}
@Test
void homeflightAliasesBaseflight() {
Map<?, ?> commands = commands();
Map<?, ?> flight = (Map<?, ?>) commands.get("baseflight");
assertEquals(List.of("homeflight"), flight.get("aliases"));
}
@Test
void homenavigationAliasesBasenavigation() {
Map<?, ?> commands = commands();
Map<?, ?> navigation = (Map<?, ?>) commands.get("basenavigation");
assertEquals(List.of("homenavigation"), navigation.get("aliases"));
}
@Test
void visitAliasesGotobase() {
Map<?, ?> commands = commands();
@@ -70,8 +75,7 @@ final class PluginMetadataTest {
Map<?, ?> commands = (Map<?, ?>) plugin.get("commands");
for (String command : new String[] {
"setbase", "base", "basenavigation", "baseflight",
"basevisitors", "gotobase", "baseprogress", "baseadmin"
"setbase", "base", "gotobase", "basesettings", "baseadmin"
}) {
assertNotNull(commands.get(command), command);
}
@@ -18,8 +18,34 @@ final class PluginSettingsTest {
assertEquals(86_400L, settings.relocationCooldownSeconds());
assertEquals(5, settings.flightWarningBuffer());
assertEquals(200, settings.teleportUnlockPlacements());
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,594 @@
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);
when(event.getFrom()).thenReturn(destination);
controller.onMove(event);
verify(flightController).grantPocketPortalFlight(guest, ownerId);
when(event.getFrom()).thenReturn(new Location(pocketWorld, 5, 66, 8));
controller.onMove(event);
verify(flightController, org.mockito.Mockito.times(2))
.grantPocketPortalFlight(guest, ownerId);
}
@Test
void voidReturnClearsAccumulatedPlayerFallDistance() {
UUID ownerId = UUID.randomUUID();
UUID pocketWorldId = UUID.randomUUID();
World pocketWorld = mock(World.class);
World fallbackWorld = mock(World.class);
Location destination = new Location(pocketWorld, 0.5, -65.0, 0.5);
Location spawn = new Location(fallbackWorld, 10.5, 70.0, 10.5);
Player player = mock(Player.class);
PlayerMoveEvent event = mock(PlayerMoveEvent.class);
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
Server server = mock(Server.class);
when(pocketWorld.getUID()).thenReturn(pocketWorldId);
when(player.getUniqueId()).thenReturn(UUID.randomUUID());
when(event.getPlayer()).thenReturn(player);
when(event.getTo()).thenReturn(destination);
when(server.getWorlds()).thenReturn(List.of(fallbackWorld));
when(fallbackWorld.getSpawnLocation()).thenReturn(spawn);
when(pocketBases.ownerForPocketWorld(pocketWorldId)).thenReturn(Optional.of(ownerId));
when(pocketBases.state(ownerId)).thenReturn(
new PocketBaseState(ownerId, 1, Optional.empty())
);
PocketBaseController controller = controller(server, pocketBases);
controller.onMove(event);
verify(player).setFallDistance(0.0F);
verify(player).teleport(spawn, PlayerTeleportEvent.TeleportCause.PLUGIN);
}
@Test
void mobReturnFallsBackToServerSpawnWithoutValidEntrance() {
UUID ownerId = UUID.randomUUID();
UUID pocketWorldId = UUID.randomUUID();
PocketPortalLocation returnPortal = new PocketPortalLocation(
pocketWorldId, "pocket", 8, 65, 8, PocketPortalAxis.Z
);
PocketBaseState state = new PocketBaseState(ownerId, 1, Optional.empty());
Server server = mock(Server.class);
World pocketWorld = intactPortalWorld(pocketWorldId, returnPortal);
World fallbackWorld = mock(World.class);
Location spawn = new Location(fallbackWorld, 10.5, 70.0, 10.5);
Mob mob = mock(Mob.class);
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
when(mob.getUniqueId()).thenReturn(UUID.randomUUID());
when(pocketWorld.getNearbyEntities(any(BoundingBox.class), any()))
.thenReturn(List.of(mob));
when(server.getWorld(pocketWorldId)).thenReturn(pocketWorld);
when(server.getWorlds()).thenReturn(List.of(fallbackWorld));
when(fallbackWorld.getSpawnLocation()).thenReturn(spawn);
when(pocketBases.knownStates()).thenReturn(Map.of(ownerId, state));
when(pocketBases.state(ownerId)).thenReturn(state);
when(pocketBases.returnPortalIsIntact(ownerId)).thenReturn(true);
when(pocketBases.returnPortal(ownerId)).thenReturn(returnPortal);
PocketBaseController controller = controller(server, pocketBases);
controller.scanMobPortals();
verify(mob).teleport(
spawn,
PlayerTeleportEvent.TeleportCause.PLUGIN
);
}
private static PocketBaseController controller(
Server server,
PocketBaseManager pocketBases
) {
PluginSettings settings = PluginSettings.from(Map.of());
return new PocketBaseController(
server,
mock(BaseStateManager.class),
new BaseBoundsService(settings),
pocketBases,
new PluginSettingsProvider(settings),
Logger.getAnonymousLogger()
);
}
private static World intactPortalWorld(
UUID worldId,
PocketPortalLocation portal
) {
World world = mock(World.class);
Set<BlockPosition> frame = new HashSet<>(PocketPortalGeometry.frameBlocks(portal));
when(world.getUID()).thenReturn(worldId);
when(world.getBlockAt(anyInt(), anyInt(), anyInt())).thenAnswer(invocation -> {
BlockPosition position = new BlockPosition(
invocation.getArgument(0), invocation.getArgument(1), invocation.getArgument(2)
);
return block(
world,
position.x(),
position.y(),
position.z(),
frame.contains(position) ? Material.DIAMOND_BLOCK : Material.AIR
);
});
return world;
}
private static ItemStack item(Material material) {
ItemStack item = mock(ItemStack.class);
when(item.getType()).thenReturn(material);
return item;
}
private static Block block(
World world,
int x,
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,396 @@
package games.dmg.spigotbase;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.Optional;
import java.util.UUID;
import java.util.logging.Logger;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.NamespacedKey;
import org.bukkit.Server;
import org.bukkit.World;
import org.bukkit.block.Block;
import org.bukkit.entity.Player;
import org.bukkit.event.Event;
import org.bukkit.event.EventHandler;
import org.bukkit.event.block.Action;
import org.bukkit.plugin.Plugin;
import org.bukkit.plugin.RegisteredListener;
import org.bukkit.event.inventory.PrepareItemCraftEvent;
import org.bukkit.event.player.PlayerInteractEvent;
import org.bukkit.event.player.PlayerTeleportEvent;
import org.bukkit.inventory.CraftingInventory;
import org.bukkit.inventory.InventoryView;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.ShapedRecipe;
import org.bukkit.inventory.meta.ItemMeta;
import org.bukkit.persistence.PersistentDataContainer;
import org.bukkit.persistence.PersistentDataType;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.EnumSource;
final class PocketBaseKeystoneServiceTest {
@Test
void createsKeystoneWithGenericRecoveryCompassMetadata() {
NamespacedKey key = NamespacedKey.minecraft("pocket_base_keystone");
ItemStack item = mock(ItemStack.class);
ItemMeta genericMetadata = mock(ItemMeta.class);
PersistentDataContainer data = mock(PersistentDataContainer.class);
when(item.getItemMeta()).thenReturn(genericMetadata);
when(genericMetadata.getPersistentDataContainer()).thenReturn(data);
PocketBaseKeystoneService.decorateKeystone(item, key);
verify(data).set(key, PersistentDataType.BYTE, (byte) 1);
verify(item).setItemMeta(genericMetadata);
}
@Test
void recipeUsesNetheriteOnCardinalsAndObsidianInCorners() {
PocketBaseKeystoneService.RecipeSpec recipe =
PocketBaseKeystoneService.recipeSpecification();
assertEquals("ONO", recipe.top());
assertEquals("NEN", recipe.middle());
assertEquals("ONO", recipe.bottom());
assertEquals(Material.OBSIDIAN, recipe.obsidian());
assertEquals(Material.NETHERITE_INGOT, recipe.netherite());
assertEquals(Material.ENDER_PEARL, recipe.enderPearl());
}
@Test
void lockedPlayersCannotCompleteTheKeystoneRecipe() {
NamespacedKey key = NamespacedKey.minecraft("pocket_base_keystone");
UUID playerId = UUID.randomUUID();
Player player = mock(Player.class);
PrepareItemCraftEvent event = mock(PrepareItemCraftEvent.class);
InventoryView view = mock(InventoryView.class);
CraftingInventory inventory = mock(CraftingInventory.class);
ShapedRecipe recipe = mock(ShapedRecipe.class);
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
PocketBaseKeystoneService service = new PocketBaseKeystoneService(
mock(org.bukkit.Server.class), pocketBases, mock(KeystoneReturnStore.class),
key, Logger.getAnonymousLogger()
);
when(player.getUniqueId()).thenReturn(playerId);
when(event.getView()).thenReturn(view);
when(view.getPlayer()).thenReturn(player);
when(event.getInventory()).thenReturn(inventory);
when(event.getRecipe()).thenReturn(recipe);
when(recipe.getKey()).thenReturn(key);
when(pocketBases.state(playerId)).thenReturn(
new PocketBaseState(playerId, 0, Optional.empty())
);
service.onPrepareCraft(event);
verify(inventory).setResult(null);
}
@Test
void lockedPlayerCannotUseATransferredKeystone() throws Exception {
NamespacedKey key = NamespacedKey.minecraft("pocket_base_keystone");
UUID playerId = UUID.randomUUID();
Player player = mock(Player.class);
PlayerInteractEvent event = mock(PlayerInteractEvent.class);
ItemStack keystone = markedKeystone(key);
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
KeystoneReturnStore returns = mock(KeystoneReturnStore.class);
PocketBaseKeystoneService service = new PocketBaseKeystoneService(
mock(Server.class), pocketBases, returns, key, Logger.getAnonymousLogger()
);
when(player.getUniqueId()).thenReturn(playerId);
when(event.getPlayer()).thenReturn(player);
when(event.getAction()).thenReturn(Action.RIGHT_CLICK_AIR);
when(event.getItem()).thenReturn(keystone);
when(pocketBases.state(playerId)).thenReturn(
new PocketBaseState(playerId, 0, Optional.empty())
);
service.onUse(event);
verify(returns, never()).record(any(UUID.class), any(KeystoneReturnLocation.class));
verify(player, never()).teleport(
any(Location.class), any(PlayerTeleportEvent.TeleportCause.class)
);
}
@ParameterizedTest
@EnumSource(value = Action.class, names = {"RIGHT_CLICK_AIR", "RIGHT_CLICK_BLOCK"})
void unlockedPlayerEntersOwnPocketAndRecordsExactReturnDestination(Action action) throws Exception {
NamespacedKey key = NamespacedKey.minecraft("pocket_base_keystone");
UUID playerId = UUID.randomUUID();
UUID worldId = UUID.randomUUID();
Player player = mock(Player.class);
World world = mock(World.class);
Location origin = new Location(world, 12.25, 70.5, -4.75, 123.0F, -15.5F);
Location arrival = new Location(mock(World.class), 0.5, 65.0, 0.5);
PlayerInteractEvent event = mock(PlayerInteractEvent.class);
ItemStack keystone = markedKeystone(key);
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
KeystoneReturnStore returns = mock(KeystoneReturnStore.class);
PocketBaseKeystoneService service = new PocketBaseKeystoneService(
mock(Server.class), pocketBases, returns, key, Logger.getAnonymousLogger()
);
when(player.getUniqueId()).thenReturn(playerId);
when(player.getLocation()).thenReturn(origin);
when(player.getWorld()).thenReturn(world);
when(world.getUID()).thenReturn(worldId);
when(world.getName()).thenReturn("world");
when(event.getPlayer()).thenReturn(player);
when(event.getAction()).thenReturn(Action.RIGHT_CLICK_AIR);
when(event.getItem()).thenReturn(keystone);
when(pocketBases.state(playerId)).thenReturn(
new PocketBaseState(playerId, 1, Optional.empty())
);
when(pocketBases.ownerForPocketWorld(worldId)).thenReturn(Optional.empty());
when(pocketBases.pocketArrival(playerId)).thenReturn(arrival);
when(player.teleport(arrival, PlayerTeleportEvent.TeleportCause.PLUGIN))
.thenReturn(true);
when(event.getAction()).thenReturn(action);
when(event.isCancelled()).thenReturn(action == Action.RIGHT_CLICK_AIR);
when(event.useItemInHand()).thenReturn(Event.Result.DEFAULT);
dispatchUse(service, event);
verify(returns).record(playerId, new KeystoneReturnLocation(
worldId, "world", 12.25, 70.5, -4.75, 123.0F, -15.5F
));
verify(player).teleport(arrival, PlayerTeleportEvent.TeleportCause.PLUGIN);
verify(player).setFallDistance(0.0F);
verify(event).setCancelled(true);
}
@ParameterizedTest
@EnumSource(value = Action.class, names = {"RIGHT_CLICK_AIR", "RIGHT_CLICK_BLOCK"})
void playerReturnsFromOwnPocketToRecordedSafeDestination(Action action) throws Exception {
NamespacedKey key = NamespacedKey.minecraft("pocket_base_keystone");
UUID playerId = UUID.randomUUID();
UUID pocketWorldId = UUID.randomUUID();
UUID returnWorldId = UUID.randomUUID();
Player player = mock(Player.class);
World pocketWorld = mock(World.class);
World returnWorld = mock(World.class);
Block ground = mock(Block.class);
Block feet = mock(Block.class);
Block head = mock(Block.class);
PlayerInteractEvent event = mock(PlayerInteractEvent.class);
ItemStack keystone = markedKeystone(key);
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
KeystoneReturnStore returns = mock(KeystoneReturnStore.class);
Server server = mock(Server.class);
KeystoneReturnLocation saved = new KeystoneReturnLocation(
returnWorldId, "world", 12.25, 70.5, -4.75, 123.0F, -15.5F
);
PocketBaseKeystoneService service = new PocketBaseKeystoneService(
server, pocketBases, returns, key, Logger.getAnonymousLogger()
);
when(player.getUniqueId()).thenReturn(playerId);
when(player.getWorld()).thenReturn(pocketWorld);
when(pocketWorld.getUID()).thenReturn(pocketWorldId);
when(event.getPlayer()).thenReturn(player);
when(event.getAction()).thenReturn(Action.RIGHT_CLICK_AIR);
when(event.getItem()).thenReturn(keystone);
when(pocketBases.state(playerId)).thenReturn(
new PocketBaseState(playerId, 1, Optional.empty())
);
when(pocketBases.ownerForPocketWorld(pocketWorldId))
.thenReturn(Optional.of(playerId));
when(returns.destination(playerId)).thenReturn(Optional.of(saved));
when(server.getWorld(returnWorldId)).thenReturn(returnWorld);
when(returnWorld.getMinHeight()).thenReturn(-64);
when(returnWorld.getMaxHeight()).thenReturn(320);
when(returnWorld.getBlockAt(12, 69, -5)).thenReturn(ground);
when(returnWorld.getBlockAt(12, 70, -5)).thenReturn(feet);
when(returnWorld.getBlockAt(12, 71, -5)).thenReturn(head);
when(ground.getType()).thenReturn(Material.STONE);
when(feet.getType()).thenReturn(Material.AIR);
when(head.getType()).thenReturn(Material.AIR);
when(feet.isPassable()).thenReturn(true);
when(head.isPassable()).thenReturn(true);
when(player.teleport(
new Location(returnWorld, 12.25, 70.5, -4.75, 123.0F, -15.5F),
PlayerTeleportEvent.TeleportCause.PLUGIN
)).thenReturn(true);
when(event.getAction()).thenReturn(action);
when(event.isCancelled()).thenReturn(action == Action.RIGHT_CLICK_AIR);
when(event.useItemInHand()).thenReturn(Event.Result.DEFAULT);
dispatchUse(service, event);
verify(player).teleport(
new Location(returnWorld, 12.25, 70.5, -4.75, 123.0F, -15.5F),
PlayerTeleportEvent.TeleportCause.PLUGIN
);
verify(player).setFallDistance(0.0F);
}
@Test
void unavailableAndUnsafeReturnsDoNotTeleportThePlayer() {
NamespacedKey key = NamespacedKey.minecraft("pocket_base_keystone");
UUID missingPocketWorldId = UUID.randomUUID();
UUID unsafePocketWorldId = UUID.randomUUID();
UUID missingPlayerId = UUID.randomUUID();
UUID unsafePlayerId = UUID.randomUUID();
UUID missingWorldId = UUID.randomUUID();
UUID unsafeWorldId = UUID.randomUUID();
World missingPocketWorld = mock(World.class);
World unsafePocketWorld = mock(World.class);
World unsafeWorld = mock(World.class);
Player missingPlayer = mock(Player.class);
Player unsafePlayer = mock(Player.class);
PlayerInteractEvent missingEvent = mock(PlayerInteractEvent.class);
PlayerInteractEvent unsafeEvent = mock(PlayerInteractEvent.class);
ItemStack missingKeystone = markedKeystone(key);
ItemStack unsafeKeystone = markedKeystone(key);
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
KeystoneReturnStore returns = mock(KeystoneReturnStore.class);
Server server = mock(Server.class);
PocketBaseKeystoneService service = new PocketBaseKeystoneService(
server, pocketBases, returns, key, Logger.getAnonymousLogger()
);
when(missingPocketWorld.getUID()).thenReturn(missingPocketWorldId);
when(unsafePocketWorld.getUID()).thenReturn(unsafePocketWorldId);
when(missingPlayer.getUniqueId()).thenReturn(missingPlayerId);
when(missingPlayer.getWorld()).thenReturn(missingPocketWorld);
when(unsafePlayer.getUniqueId()).thenReturn(unsafePlayerId);
when(unsafePlayer.getWorld()).thenReturn(unsafePocketWorld);
when(missingEvent.getPlayer()).thenReturn(missingPlayer);
when(missingEvent.getAction()).thenReturn(Action.RIGHT_CLICK_AIR);
when(missingEvent.getItem()).thenReturn(missingKeystone);
when(unsafeEvent.getPlayer()).thenReturn(unsafePlayer);
when(unsafeEvent.getAction()).thenReturn(Action.RIGHT_CLICK_AIR);
when(unsafeEvent.getItem()).thenReturn(unsafeKeystone);
when(pocketBases.state(missingPlayerId)).thenReturn(
new PocketBaseState(missingPlayerId, 1, Optional.empty())
);
when(pocketBases.state(unsafePlayerId)).thenReturn(
new PocketBaseState(unsafePlayerId, 1, Optional.empty())
);
when(pocketBases.ownerForPocketWorld(missingPocketWorldId))
.thenReturn(Optional.of(missingPlayerId));
when(pocketBases.ownerForPocketWorld(unsafePocketWorldId))
.thenReturn(Optional.of(unsafePlayerId));
when(returns.destination(missingPlayerId)).thenReturn(Optional.of(
new KeystoneReturnLocation(
missingWorldId, "missing", 0.5, 65.0, 0.5, 0.0F, 0.0F
)
));
when(returns.destination(unsafePlayerId)).thenReturn(Optional.of(
new KeystoneReturnLocation(
unsafeWorldId, "unsafe", 0.5, 65.0, 0.5, 0.0F, 0.0F
)
));
when(server.getWorld(unsafeWorldId)).thenReturn(unsafeWorld);
when(unsafeWorld.getMinHeight()).thenReturn(-64);
when(unsafeWorld.getMaxHeight()).thenReturn(320);
Block unsafeBlock = mock(Block.class);
when(unsafeBlock.getType()).thenReturn(Material.AIR);
when(unsafeWorld.getBlockAt(anyInt(), anyInt(), anyInt()))
.thenReturn(unsafeBlock);
service.onUse(missingEvent);
service.onUse(unsafeEvent);
verify(missingPlayer, never()).teleport(
any(Location.class), any(PlayerTeleportEvent.TeleportCause.class)
);
verify(unsafePlayer, never()).teleport(
any(Location.class), any(PlayerTeleportEvent.TeleportCause.class)
);
}
@Test
void authenticatesKeystonesByPersistentMetadataInsteadOfTheirName() {
NamespacedKey key = NamespacedKey.minecraft("pocket_base_keystone");
ItemStack marked = mock(ItemStack.class);
ItemMeta markedMeta = mock(ItemMeta.class);
PersistentDataContainer markedData = mock(PersistentDataContainer.class);
ItemStack renamedOnly = mock(ItemStack.class);
ItemMeta renamedMeta = mock(ItemMeta.class);
PersistentDataContainer renamedData = mock(PersistentDataContainer.class);
when(marked.getType()).thenReturn(Material.RECOVERY_COMPASS);
when(marked.getItemMeta()).thenReturn(markedMeta);
when(markedMeta.getPersistentDataContainer()).thenReturn(markedData);
when(markedData.get(key, PersistentDataType.BYTE)).thenReturn((byte) 1);
when(renamedOnly.getType()).thenReturn(Material.RECOVERY_COMPASS);
when(renamedOnly.getItemMeta()).thenReturn(renamedMeta);
when(renamedMeta.getPersistentDataContainer()).thenReturn(renamedData);
assertTrue(PocketBaseKeystoneService.isKeystone(marked, key));
assertFalse(PocketBaseKeystoneService.isKeystone(renamedOnly, key));
}
@ParameterizedTest
@EnumSource(value = Action.class, names = {"RIGHT_CLICK_AIR", "RIGHT_CLICK_BLOCK"})
void explicitItemUseDenialPreventsKeystoneTravel(Action action) throws Exception {
NamespacedKey key = NamespacedKey.minecraft("pocket_base_keystone");
Player player = mock(Player.class);
UUID playerId = UUID.randomUUID();
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
KeystoneReturnStore returns = mock(KeystoneReturnStore.class);
PocketBaseKeystoneService service = new PocketBaseKeystoneService(
mock(Server.class), pocketBases, returns, key, Logger.getAnonymousLogger()
);
PlayerInteractEvent event = mock(PlayerInteractEvent.class);
ItemStack keystone = markedKeystone(key);
when(event.getAction()).thenReturn(action);
when(event.getItem()).thenReturn(keystone);
when(event.getPlayer()).thenReturn(player);
when(event.isCancelled()).thenReturn(true);
when(event.useItemInHand()).thenReturn(Event.Result.DENY);
when(player.getUniqueId()).thenReturn(playerId);
when(pocketBases.state(playerId)).thenReturn(
new PocketBaseState(playerId, 0, Optional.empty())
);
dispatchUse(service, event);
verify(pocketBases, never()).state(any(UUID.class));
verify(returns, never()).record(any(UUID.class), any(KeystoneReturnLocation.class));
verify(player, never()).teleport(
any(Location.class), any(PlayerTeleportEvent.TeleportCause.class)
);
verify(event, never()).setCancelled(true);
}
private static void dispatchUse(
PocketBaseKeystoneService service, PlayerInteractEvent event
) throws Exception {
EventHandler handler = PocketBaseKeystoneService.class
.getMethod("onUse", PlayerInteractEvent.class).getAnnotation(EventHandler.class);
RegisteredListener listener = new RegisteredListener(
service, (ignored, dispatched) -> service.onUse((PlayerInteractEvent) dispatched),
handler.priority(), mock(Plugin.class), handler.ignoreCancelled()
);
listener.callEvent(event);
}
private static ItemStack markedKeystone(NamespacedKey key) {
ItemStack item = mock(ItemStack.class);
ItemMeta metadata = mock(ItemMeta.class);
PersistentDataContainer data = mock(PersistentDataContainer.class);
when(item.getType()).thenReturn(Material.RECOVERY_COMPASS);
when(item.getItemMeta()).thenReturn(metadata);
when(metadata.getPersistentDataContainer()).thenReturn(data);
when(data.get(key, PersistentDataType.BYTE)).thenReturn((byte) 1);
return item;
}
}
@@ -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,48 @@
package games.dmg.spigotbase;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import org.bukkit.block.Block;
import org.bukkit.block.BlockFace;
import org.bukkit.block.data.type.Slab;
import org.junit.jupiter.api.Test;
final class SpawnableBlockPolicyTest {
@Test
void acceptsDarkSolidSurfaceWithTwoPassableBlocksAbove() {
Block surface = mock(Block.class);
Block above = mock(Block.class);
Block secondAbove = mock(Block.class);
Slab slab = mock(Slab.class);
when(slab.getType()).thenReturn(Slab.Type.TOP);
when(surface.getBlockData()).thenReturn(slab);
when(surface.getRelative(BlockFace.UP)).thenReturn(above);
when(above.getRelative(BlockFace.UP)).thenReturn(secondAbove);
when(above.isPassable()).thenReturn(true);
when(secondAbove.isPassable()).thenReturn(true);
when(above.getLightFromBlocks()).thenReturn((byte) 0);
when(above.getLightFromSky()).thenReturn((byte) 0);
assertTrue(SpawnableBlockPolicy.isCandidate(surface));
}
@Test
void rejectsBlockLitSurface() {
Block surface = mock(Block.class);
Block above = mock(Block.class);
Block secondAbove = mock(Block.class);
Slab slab = mock(Slab.class);
when(slab.getType()).thenReturn(Slab.Type.TOP);
when(surface.getBlockData()).thenReturn(slab);
when(surface.getRelative(BlockFace.UP)).thenReturn(above);
when(above.getRelative(BlockFace.UP)).thenReturn(secondAbove);
when(above.isPassable()).thenReturn(true);
when(secondAbove.isPassable()).thenReturn(true);
when(above.getLightFromBlocks()).thenReturn((byte) 1);
assertFalse(SpawnableBlockPolicy.isCandidate(surface));
}
}

Some files were not shown because too many files have changed in this diff Show More