Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
02c6698268 | ||
|
|
bc2f9d8e25 | ||
|
|
8b3eaad253 | ||
|
|
29413d36b3 | ||
|
|
b6bbf9183a | ||
|
|
b2ba4d5821 | ||
|
|
6ea5bc16f4 | ||
|
|
4518478ea1 | ||
|
|
23e8fcec6e | ||
|
|
11e3ad1720 | ||
|
|
8a9640d992 | ||
|
|
3be3e43072 |
@@ -17,7 +17,7 @@ jobs:
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 17
|
||||
java-version: 25
|
||||
cache: gradle
|
||||
|
||||
- name: Validate conventional commits
|
||||
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 17
|
||||
java-version: 25
|
||||
cache: gradle
|
||||
|
||||
- name: Set up Node.js
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Spigot Base
|
||||
|
||||
A Spigot 26.2 plugin providing progression-gated player bases and quality-of-life unlocks.
|
||||
A Purpur 26.2 plugin providing progression-gated player bases and quality-of-life unlocks.
|
||||
|
||||
Players begin by breaking Survival-mode grass blocks or dirt. The progression paths provide a persistent base, navigation particles, expanding base bounds, controlled flight, personal teleportation, and visitor teleportation.
|
||||
|
||||
@@ -8,8 +8,8 @@ The approved behavior is specified in the [OKF design bundle](design/index.md).
|
||||
|
||||
## Requirements
|
||||
|
||||
- Spigot 26.2
|
||||
- Java 17 or newer
|
||||
- Purpur 26.2 build 2618
|
||||
- Java 25 or newer
|
||||
|
||||
## Build
|
||||
|
||||
@@ -26,8 +26,12 @@ The plugin JAR is written to `build/libs/`.
|
||||
/base (alias: /home)
|
||||
/gotobase <player> (alias: /visit <player>)
|
||||
/basesettings (alias: /homesettings)
|
||||
/basesettings ui
|
||||
/basesettings status
|
||||
/basesettings upgrade
|
||||
/basesettings pocket upgrade
|
||||
/basesettings pocket mobs <hostile|passive> <enable|disable>
|
||||
/basesettings pocket type <void|nether|overworld> <subtype>
|
||||
/basesettings visitors <allowed|blocked>
|
||||
/basesettings navigation <enable|disable>
|
||||
/basesettings flight <enable|disable>
|
||||
@@ -36,12 +40,18 @@ The plugin JAR is written to `build/libs/`.
|
||||
/basesettings bossbar <enable|disable>
|
||||
```
|
||||
|
||||
`/basesettings` and `/basesettings ui` open Minecraft's native dialog dashboard. The dashboard summarizes Base and Pocket Base progression, provides unlocked setting controls and purchase confirmations, and marks unavailable controls as locked. `/basesettings status` retains the detailed chat report.
|
||||
|
||||
Navigation particles appear only in the base's world and when the player is more than 25 blocks beyond the current base border.
|
||||
|
||||
After 250 Survival-mode block placements anywhere by default, the spawnable overlay can mark nearby dark hostile-mob spawning surfaces inside the player's base with owner-only red particles. The threshold is configurable.
|
||||
|
||||
`/base` has a stationary warm-up. Looking around is allowed, while movement between blocks, damage, teleportation, world changes, death, logout, and conflicting teleport commands cancel it without consuming the cooldown.
|
||||
|
||||
Base IV owners can purchase and expand a persistent Pocket Base with `/basesettings pocket upgrade`. Activating a complete diamond-block portal frame inside the normal base with flint and steel opens one public entrance to the owner's grass platform in a private void world. Players and non-player living mobs can travel through the entrance and active return portal; items, projectiles, and vehicles are not transported. The owner can move the return portal by building and igniting another complete frame inside the unlocked Pocket Base boundary; only the newly activated return portal remains functional. Natural hostile and passive mob spawning is disabled by default. Owners can control each category independently with `/basesettings pocket mobs <hostile|passive> <enable|disable>`.
|
||||
|
||||
Owners can change biome metadata without altering Pocket Base blocks, entities, inventories, or portals by using `/basesettings pocket type <void|nether|overworld> <subtype>`. Void uses `the_void`; Nether and Overworld accept their compatible vanilla biome names, such as `crimson_forest` or `plains`. Each change defaults to 16 netherite blocks, is configurable, and affects applicable natural mob selection when spawning is enabled. The Pocket Base remains a void-generated normal-environment world; dimension-specific mechanics do not change.
|
||||
|
||||
## Administration
|
||||
|
||||
The `spigotbase.admin` permission is granted to server operators by default. `/homeadmin` aliases `/baseadmin`.
|
||||
|
||||
+5
-5
@@ -9,23 +9,23 @@ version = providers.gradleProperty("releaseVersion")
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
maven("https://hub.spigotmc.org/nexus/content/repositories/snapshots/")
|
||||
maven("https://repo.purpurmc.org/snapshots/")
|
||||
}
|
||||
|
||||
java {
|
||||
toolchain {
|
||||
languageVersion = JavaLanguageVersion.of(17)
|
||||
languageVersion = JavaLanguageVersion.of(25)
|
||||
}
|
||||
}
|
||||
|
||||
tasks.withType<JavaCompile>().configureEach {
|
||||
options.compilerArgs.addAll(listOf("-Xlint:all", "-Werror"))
|
||||
options.compilerArgs.addAll(listOf("-Xlint:all,-deprecation,-removal", "-Werror"))
|
||||
}
|
||||
|
||||
dependencies {
|
||||
compileOnly("org.spigotmc:spigot-api:26.2-R0.1-SNAPSHOT")
|
||||
compileOnly("org.purpurmc.purpur:purpur-api:26.2.build.2618-stable")
|
||||
|
||||
testImplementation("org.spigotmc:spigot-api:26.2-R0.1-SNAPSHOT")
|
||||
testImplementation("org.purpurmc.purpur:purpur-api:26.2.build.2618-stable")
|
||||
testImplementation(platform("org.junit:junit-bom:5.13.4"))
|
||||
testImplementation("org.junit.jupiter:junit-jupiter")
|
||||
testImplementation("org.mockito:mockito-core:5.18.0")
|
||||
|
||||
@@ -91,3 +91,61 @@ description: Chronological record of material decisions affecting the Spigot Bas
|
||||
- Used the live size-tier radius so expansion and relocation immediately update the visibility threshold.
|
||||
- Added exact-boundary and controller integration coverage.
|
||||
- Verified the change with `./gradlew clean check jar`.
|
||||
|
||||
## 2026-08-24 — Pocket Base mob-spawning control
|
||||
|
||||
- Disabled natural hostile and passive mob spawning by default in new and existing Pocket Bases without a saved preference.
|
||||
- Added independent persisted owner settings through `/basesettings pocket mobs <hostile|passive> <enable|disable>`, contextual autocomplete, and status reporting.
|
||||
- Migrated each legacy combined mob-spawning preference to both categories to preserve existing behavior.
|
||||
- Reapplied both saved spawn flags when Pocket Base worlds load and restored both prior world settings when persistence fails.
|
||||
- Verified the feature with `./gradlew clean check jar`.
|
||||
|
||||
## 2026-08-24 — Relocatable Pocket Base return portals
|
||||
|
||||
- Allowed Pocket Base owners to activate a complete return portal frame with flint and steel anywhere inside their unlocked pocket boundary.
|
||||
- Persisted one active return portal location while retaining the generated portal as the default for existing Pocket Bases.
|
||||
- Made only the selected intact frame functional without removing old frame blocks, preserving the void-return safety fallback.
|
||||
- Verified the feature with `./gradlew clean check jar`.
|
||||
|
||||
## 2026-08-24 — Purchasable Pocket Base biomes
|
||||
|
||||
- Added `/basesettings pocket type <void|nether|overworld> <subtype>` with type-aware autocomplete and validation.
|
||||
- Defaulted new and unconfigured Pocket Bases to `void/the_void` and persisted each owner's selected biome.
|
||||
- Applied biome metadata across unlocked columns and new expansion rings without changing blocks, entities, inventories, portals, progression, or mob-spawning preferences.
|
||||
- Added an atomic, configurable biome-change payment that defaults to 16 netherite blocks and restores the prior biome and payment on failure.
|
||||
- Verified the feature with `./gradlew clean check jar`.
|
||||
|
||||
## 2026-08-24 — Native base settings dialog
|
||||
|
||||
- Changed `/basesettings` and `/homesettings` with no arguments to open a native Minecraft dialog dashboard, with explicit `ui` subcommands providing the same behavior.
|
||||
- Added progression summaries, current and locked setting states, toggles, Pocket Base controls, biome selection, and confirmations for Base IV, Pocket Base, and biome purchases.
|
||||
- Retained the detailed chat report under `/basesettings status` and kept dialog actions routed through the existing validated command behavior.
|
||||
- Added dialog routing, autocomplete, alias, non-player, generated-control, locked-state, and purchase-confirmation coverage.
|
||||
- Verified the feature with `./gradlew clean check jar`.
|
||||
|
||||
## 2026-08-24 — Purpur dialog runtime compatibility
|
||||
|
||||
- Replaced the Spigot-only Bungee dialog types that were absent from production with Purpur's supported Paper dialog API.
|
||||
- Compiled and tested against the exact Purpur 26.2 build 2618 API with Java 25, and updated both CI workflows to use Java 25.
|
||||
- Preserved unit-test coverage through server-independent dialog specifications and adapted Bukkit test fixtures to Purpur's registry-aware API behavior.
|
||||
- Verified 128 tests and the Java 25 plugin artifact with `./gradlew clean check jar`.
|
||||
|
||||
## 2026-08-25 — Static dialog command actions
|
||||
|
||||
- Replaced macro command templates with static run-command actions for fixed dialog buttons.
|
||||
- Added regression coverage for the generated run-command action and its unchanged command payload.
|
||||
- Verified `/basesettings` opens without command exceptions on a local Purpur 26.2 build 2618 server.
|
||||
- Verified the plugin artifact with `./gradlew clean check jar`.
|
||||
|
||||
## 2026-08-24 — Pocket Base mob portal travel
|
||||
|
||||
- Added five-tick collision scanning that transports non-player living mobs through active Pocket Base entrance and return portals.
|
||||
- Reused safe player destinations and server-spawn fallback behavior while excluding items, projectiles, vehicles, and other non-living entities.
|
||||
- Applied the existing two-second portal cooldown to mobs to prevent immediate repeated transport.
|
||||
- Verified the feature and Java 25 plugin artifact with `./gradlew clean check jar`.
|
||||
|
||||
## 2026-08-28 — Safe Pocket Base fall returns
|
||||
|
||||
- Cleared accumulated fall distance whenever portal or void-return travel teleports an entity, preventing carried-over fall damage at the destination.
|
||||
- Added regression coverage for players falling out of a Pocket Base and returning to the server spawn.
|
||||
- Verified the fix and plugin artifact with `./gradlew clean check jar`.
|
||||
|
||||
@@ -20,3 +20,9 @@ description: Catalog of user stories for the Spigot Base plugin.
|
||||
12. [US-012: Build and release the plugin](us-012-build-and-release-plugin.md)
|
||||
13. [US-013: Visualize the base border](us-013-visualize-base-border.md)
|
||||
14. [US-014: Highlight spawnable blocks](us-014-highlight-spawnable-blocks.md)
|
||||
15. [US-015: Unlock a Pocket Base](us-015-unlock-a-pocket-base.md)
|
||||
16. [US-016: Build and use Pocket Base portals](us-016-build-and-use-pocket-base-portals.md)
|
||||
17. [US-017: Expand a Pocket Base](us-017-expand-a-pocket-base.md)
|
||||
18. [US-018: Control Pocket Base mob spawning](us-018-control-pocket-base-mob-spawning.md)
|
||||
19. [US-019: Change a Pocket Base biome](us-019-change-a-pocket-base-biome.md)
|
||||
20. [US-020: Control a base through a dialog UI](us-020-control-base-through-dialog-ui.md)
|
||||
|
||||
@@ -21,7 +21,8 @@ As a **player with Base III**, I want to open my base to visitors so that other
|
||||
- [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.
|
||||
@@ -36,3 +37,5 @@ As a **player with Base III**, I want to open my base to visitors so that other
|
||||
- [US-005: Unlock Base III teleportation](us-005-unlock-base-teleportation.md)
|
||||
- [US-006: Reduce the base teleport warm-up](us-006-reduce-teleport-warmup.md)
|
||||
- [US-007: Reduce the base teleport cooldown](us-007-reduce-teleport-cooldown.md)
|
||||
- [US-015: Unlock a Pocket Base](us-015-unlock-a-pocket-base.md)
|
||||
- [US-016: Build and use Pocket Base portals](us-016-build-and-use-pocket-base-portals.md)
|
||||
|
||||
@@ -11,7 +11,7 @@ As a **player**, I want to inspect my progression and receive timely milestone f
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] `/basesettings` and `/basesettings status` show the player's Base, Base Size, Base Flight, Teleport Warm-up, and Teleport Cooldown paths plus current visitor, navigation, flight, and boss-bar settings.
|
||||
- [x] `/basesettings status` shows the player's Base, Base Size, Base Flight, Teleport Warm-up, and Teleport Cooldown paths plus current visitor, navigation, flight, and boss-bar settings.
|
||||
- [x] Each path identifies earned levels, unmet prerequisites, current progress, the next threshold, and the next reward.
|
||||
- [x] Locked secondary paths clearly identify Base I or another sequential level as their prerequisite.
|
||||
- [x] Relevant qualifying activity briefly displays a configurable progress boss bar for the active milestone.
|
||||
@@ -19,7 +19,7 @@ As a **player**, I want to inspect my progression and receive timely milestone f
|
||||
- [x] The automatic boss bar disappears after a configurable number of seconds.
|
||||
- [x] `/basesettings bossbar enable` enables automatic progress boss bars idempotently, and `/basesettings bossbar disable` disables them idempotently.
|
||||
- [x] Invalid boss-bar arguments show command usage, and `enable` and `disable` are offered through autocomplete.
|
||||
- [x] Disabling automatic boss bars does not prevent `/basesettings` from displaying progress.
|
||||
- [x] Disabling automatic boss bars does not prevent `/basesettings status` from displaying progress.
|
||||
- [x] The boss-bar preference persists across reconnects and restarts.
|
||||
- [x] Each newly unlocked level displays prominent full-screen title and subtitle text describing the reward.
|
||||
- [x] Unlock notifications occur once per earned level and do not repeat after reconnecting or restarting.
|
||||
|
||||
@@ -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,30 @@
|
||||
---
|
||||
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)
|
||||
@@ -0,0 +1,46 @@
|
||||
---
|
||||
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.
|
||||
|
||||
## 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)
|
||||
@@ -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,34 @@
|
||||
---
|
||||
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] 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)
|
||||
@@ -1 +1,5 @@
|
||||
plugins {
|
||||
id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
|
||||
}
|
||||
|
||||
rootProject.name = "spigot-base"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package games.dmg.spigotbase;
|
||||
|
||||
import io.papermc.paper.dialog.Dialog;
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
@@ -15,25 +16,58 @@ import org.bukkit.inventory.PlayerInventory;
|
||||
|
||||
final class BaseSettingsCommand implements CommandExecutor, TabCompleter {
|
||||
private static final List<String> SETTINGS = List.of(
|
||||
"status", "upgrade", "visitors", "navigation", "flight", "border", "spawnable",
|
||||
"bossbar"
|
||||
"ui", "status", "upgrade", "pocket", "visitors", "navigation", "flight", "border",
|
||||
"spawnable", "bossbar"
|
||||
);
|
||||
private static final List<String> VISITOR_MODES = List.of("allowed", "blocked");
|
||||
private static final List<String> ENABLE_MODES = List.of("enable", "disable");
|
||||
private static final List<String> POCKET_WORLD_TYPES = List.of(
|
||||
"void", "nether", "overworld"
|
||||
);
|
||||
private final BaseStateManager stateManager;
|
||||
private final PluginSettingsProvider settings;
|
||||
private final TeleportPolicy teleportPolicy;
|
||||
private final BaseFlightController flightController;
|
||||
private final PocketBaseManager pocketBases;
|
||||
private final DialogProvider dialogProvider;
|
||||
|
||||
BaseSettingsCommand(
|
||||
BaseStateManager stateManager,
|
||||
PluginSettingsProvider settings,
|
||||
BaseFlightController flightController
|
||||
) {
|
||||
this(stateManager, settings, flightController, null);
|
||||
}
|
||||
|
||||
BaseSettingsCommand(
|
||||
BaseStateManager stateManager,
|
||||
PluginSettingsProvider settings,
|
||||
BaseFlightController flightController,
|
||||
PocketBaseManager pocketBases
|
||||
) {
|
||||
this(
|
||||
stateManager,
|
||||
settings,
|
||||
flightController,
|
||||
pocketBases,
|
||||
(owner, pocket) -> new BaseSettingsDialogFactory(settings.current())
|
||||
.create(owner, pocket)
|
||||
);
|
||||
}
|
||||
|
||||
BaseSettingsCommand(
|
||||
BaseStateManager stateManager,
|
||||
PluginSettingsProvider settings,
|
||||
BaseFlightController flightController,
|
||||
PocketBaseManager pocketBases,
|
||||
DialogProvider dialogProvider
|
||||
) {
|
||||
this.stateManager = stateManager;
|
||||
this.settings = settings;
|
||||
this.teleportPolicy = new TeleportPolicy(settings);
|
||||
this.flightController = flightController;
|
||||
this.pocketBases = pocketBases;
|
||||
this.dialogProvider = dialogProvider;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -44,13 +78,29 @@ final class BaseSettingsCommand implements CommandExecutor, TabCompleter {
|
||||
}
|
||||
PlayerState state = stateManager.player(player.getUniqueId(), player.getName());
|
||||
if (arguments.length == 0
|
||||
|| arguments.length == 1 && arguments[0].equalsIgnoreCase("status")) {
|
||||
|| arguments.length == 1 && arguments[0].equalsIgnoreCase("ui")) {
|
||||
showDialog(player, state);
|
||||
return true;
|
||||
}
|
||||
if (arguments.length == 1 && arguments[0].equalsIgnoreCase("status")) {
|
||||
showStatus(player, state);
|
||||
return true;
|
||||
}
|
||||
if (arguments.length == 1 && arguments[0].equalsIgnoreCase("upgrade")) {
|
||||
return purchaseVisitorAccess(player, state);
|
||||
}
|
||||
if (arguments.length == 2 && arguments[0].equalsIgnoreCase("pocket")
|
||||
&& arguments[1].equalsIgnoreCase("upgrade")) {
|
||||
return purchasePocketUpgrade(player, state);
|
||||
}
|
||||
if (arguments.length == 4 && arguments[0].equalsIgnoreCase("pocket")
|
||||
&& arguments[1].equalsIgnoreCase("mobs")) {
|
||||
return updatePocketMobSpawning(player, arguments[2], arguments[3]);
|
||||
}
|
||||
if (arguments.length == 4 && arguments[0].equalsIgnoreCase("pocket")
|
||||
&& arguments[1].equalsIgnoreCase("type")) {
|
||||
return purchasePocketBiome(player, arguments[2], arguments[3]);
|
||||
}
|
||||
if (arguments.length == 2 && arguments[0].equalsIgnoreCase("visitors")) {
|
||||
return updateVisitors(player, state, arguments[1]);
|
||||
}
|
||||
@@ -114,6 +164,154 @@ final class BaseSettingsCommand implements CommandExecutor, TabCompleter {
|
||||
return true;
|
||||
}
|
||||
|
||||
private boolean purchasePocketUpgrade(Player player, PlayerState owner) {
|
||||
if (pocketBases == null) {
|
||||
player.sendMessage(ChatColor.RED + "Pocket Bases are currently unavailable.");
|
||||
return true;
|
||||
}
|
||||
if (owner.baseLevel() < 4 || owner.base().isEmpty()) {
|
||||
player.sendMessage(ChatColor.RED
|
||||
+ "Establish Base IV before purchasing a Pocket Base.");
|
||||
return true;
|
||||
}
|
||||
PocketBaseState current = pocketBases.state(owner.playerId());
|
||||
int price = current.level() == 0
|
||||
? settings.current().pocketBaseUnlockCost()
|
||||
: settings.current().pocketBaseUpgradeCost();
|
||||
Material currency = Material.valueOf(settings.current().pocketBaseCurrencyMaterial());
|
||||
PlayerInventory inventory = player.getInventory();
|
||||
if (countCurrency(inventory, currency) < price) {
|
||||
player.sendMessage(ChatColor.RED + "Pocket Base "
|
||||
+ (current.level() == 0 ? "I" : "level " + (current.level() + 1))
|
||||
+ " costs " + price + " "
|
||||
+ currency.name().toLowerCase(java.util.Locale.ROOT) + ".");
|
||||
return true;
|
||||
}
|
||||
ItemStack[] snapshot = cloneContents(inventory.getStorageContents());
|
||||
removeCurrency(inventory, currency, price);
|
||||
final PocketBaseState upgraded;
|
||||
try {
|
||||
upgraded = pocketBases.upgrade(owner.playerId());
|
||||
} catch (IOException | RuntimeException exception) {
|
||||
inventory.setStorageContents(snapshot);
|
||||
player.sendMessage(ChatColor.RED
|
||||
+ "The Pocket Base upgrade failed; your payment was restored.");
|
||||
return true;
|
||||
}
|
||||
player.sendTitle(
|
||||
ChatColor.GOLD + "Pocket Base " + upgraded.level() + " Unlocked",
|
||||
ChatColor.YELLOW + "Build a portal inside your base",
|
||||
settings.current().titleFadeInTicks(),
|
||||
settings.current().titleStayTicks(),
|
||||
settings.current().titleFadeOutTicks()
|
||||
);
|
||||
player.sendMessage(ChatColor.GREEN + "Pocket Base level " + upgraded.level()
|
||||
+ " unlocked for " + price + " "
|
||||
+ currency.name().toLowerCase(java.util.Locale.ROOT) + ".");
|
||||
return true;
|
||||
}
|
||||
|
||||
private boolean purchasePocketBiome(
|
||||
Player player,
|
||||
String worldTypeName,
|
||||
String biomeName
|
||||
) {
|
||||
if (pocketBases == null) {
|
||||
player.sendMessage(ChatColor.RED + "Pocket Bases are currently unavailable.");
|
||||
return true;
|
||||
}
|
||||
PocketBaseState current = pocketBases.state(player.getUniqueId());
|
||||
if (current.level() < 1) {
|
||||
player.sendMessage(ChatColor.RED + "Pocket Base I is still locked.");
|
||||
return true;
|
||||
}
|
||||
PocketBaseBiome biome = PocketBaseWorldType.fromCommand(worldTypeName)
|
||||
.flatMap(worldType -> PocketBaseBiome.fromCommand(worldType, biomeName))
|
||||
.orElse(null);
|
||||
if (biome == null) {
|
||||
player.sendMessage(ChatColor.RED + "That Pocket Base type and subtype are invalid.");
|
||||
return true;
|
||||
}
|
||||
if (biome == current.biome()) {
|
||||
player.sendMessage(ChatColor.RED + "Your Pocket Base already uses "
|
||||
+ biome.commandName() + ".");
|
||||
return true;
|
||||
}
|
||||
int price = settings.current().pocketBaseBiomeChangeCost();
|
||||
Material currency = Material.valueOf(
|
||||
settings.current().pocketBaseBiomeCurrencyMaterial()
|
||||
);
|
||||
PlayerInventory inventory = player.getInventory();
|
||||
if (countCurrency(inventory, currency) < price) {
|
||||
player.sendMessage(ChatColor.RED + "Changing Pocket Base biome costs "
|
||||
+ price + " " + currency.name().toLowerCase(Locale.ROOT) + ".");
|
||||
return true;
|
||||
}
|
||||
ItemStack[] snapshot = cloneContents(inventory.getStorageContents());
|
||||
removeCurrency(inventory, currency, price);
|
||||
final PocketBaseState updated;
|
||||
try {
|
||||
updated = pocketBases.setBiome(player.getUniqueId(), biome);
|
||||
} catch (IOException | RuntimeException exception) {
|
||||
inventory.setStorageContents(snapshot);
|
||||
player.sendMessage(ChatColor.RED
|
||||
+ "The Pocket Base biome change failed; your payment was restored.");
|
||||
return true;
|
||||
}
|
||||
player.sendMessage(ChatColor.GREEN + "Pocket Base biome changed to "
|
||||
+ updated.biome().commandName() + " for " + price + " "
|
||||
+ currency.name().toLowerCase(Locale.ROOT) + ".");
|
||||
return true;
|
||||
}
|
||||
|
||||
private boolean updatePocketMobSpawning(
|
||||
Player player,
|
||||
String category,
|
||||
String mode
|
||||
) {
|
||||
if (pocketBases == null) {
|
||||
player.sendMessage(ChatColor.RED + "Pocket Bases are currently unavailable.");
|
||||
return true;
|
||||
}
|
||||
PocketBaseState current = pocketBases.state(player.getUniqueId());
|
||||
if (current.level() < 1) {
|
||||
player.sendMessage(ChatColor.RED + "Pocket Base I is still locked.");
|
||||
return true;
|
||||
}
|
||||
Boolean enabled = enabledMode(mode);
|
||||
if (enabled == null) {
|
||||
sendUsage(player);
|
||||
return true;
|
||||
}
|
||||
final PocketBaseState updated;
|
||||
final boolean settingEnabled;
|
||||
try {
|
||||
if (category.equalsIgnoreCase("hostile")) {
|
||||
updated = pocketBases.setHostileMobSpawning(
|
||||
player.getUniqueId(), enabled
|
||||
);
|
||||
settingEnabled = updated.hostileMobSpawningEnabled();
|
||||
} else if (category.equalsIgnoreCase("passive")) {
|
||||
updated = pocketBases.setPassiveMobSpawning(
|
||||
player.getUniqueId(), enabled
|
||||
);
|
||||
settingEnabled = updated.passiveMobSpawningEnabled();
|
||||
} else {
|
||||
sendUsage(player);
|
||||
return true;
|
||||
}
|
||||
} catch (IOException | RuntimeException exception) {
|
||||
player.sendMessage(ChatColor.RED
|
||||
+ "The Pocket Base mob-spawning setting could not be changed.");
|
||||
return true;
|
||||
}
|
||||
player.sendMessage(ChatColor.YELLOW + "Pocket Base "
|
||||
+ category.toLowerCase(Locale.ROOT) + " mob spawning is now "
|
||||
+ (settingEnabled ? ChatColor.GREEN + "enabled" : ChatColor.RED + "disabled")
|
||||
+ ChatColor.YELLOW + ".");
|
||||
return true;
|
||||
}
|
||||
|
||||
private boolean updateVisitors(Player player, PlayerState state, String mode) {
|
||||
if (state.baseLevel() < 4) {
|
||||
player.sendMessage(ChatColor.RED + "Base IV visitor access is still locked.");
|
||||
@@ -267,6 +465,13 @@ final class BaseSettingsCommand implements CommandExecutor, TabCompleter {
|
||||
return true;
|
||||
}
|
||||
|
||||
private void showDialog(Player player, PlayerState state) {
|
||||
PocketBaseState pocket = pocketBases == null
|
||||
? PocketBaseState.locked(state.playerId())
|
||||
: pocketBases.state(state.playerId());
|
||||
player.showDialog(dialogProvider.create(state, pocket));
|
||||
}
|
||||
|
||||
private void showStatus(Player player, PlayerState state) {
|
||||
player.sendMessage(ChatColor.GOLD + "=== Base Progress ===");
|
||||
showBasePath(player, state);
|
||||
@@ -274,6 +479,7 @@ final class BaseSettingsCommand implements CommandExecutor, TabCompleter {
|
||||
showFlightPath(player, state);
|
||||
showWarmupPath(player, state);
|
||||
showCooldownPath(player, state);
|
||||
showPocketPath(player, state);
|
||||
int spawnableThreshold = settings.current().spawnableOverlayUnlockPlacements();
|
||||
player.sendMessage(ChatColor.YELLOW + "Spawnable Overlay: " + ChatColor.GRAY
|
||||
+ state.totalBlocksPlaced() + "/" + spawnableThreshold + " placements; "
|
||||
@@ -344,6 +550,24 @@ final class BaseSettingsCommand implements CommandExecutor, TabCompleter {
|
||||
+ DurationFormatter.friendly(teleportPolicy.warmup(state)) + "; " + detail);
|
||||
}
|
||||
|
||||
private void showPocketPath(Player player, PlayerState owner) {
|
||||
PocketBaseState pocket = pocketBases == null
|
||||
? PocketBaseState.locked(owner.playerId())
|
||||
: pocketBases.state(owner.playerId());
|
||||
int price = pocket.level() == 0
|
||||
? settings.current().pocketBaseUnlockCost()
|
||||
: settings.current().pocketBaseUpgradeCost();
|
||||
player.sendMessage(colorForPrerequisite(owner.baseLevel() >= 4 && owner.base().isPresent())
|
||||
+ "Pocket Base " + pocket.level() + ": " + ChatColor.GRAY + price + " "
|
||||
+ settings.current().pocketBaseCurrencyMaterial().toLowerCase(Locale.ROOT)
|
||||
+ " → /basesettings pocket upgrade; type="
|
||||
+ pocket.biome().worldType().commandName() + "/" + pocket.biome().commandName()
|
||||
+ "; hostile spawning="
|
||||
+ (pocket.hostileMobSpawningEnabled() ? "enabled" : "disabled")
|
||||
+ "; passive spawning="
|
||||
+ (pocket.passiveMobSpawningEnabled() ? "enabled" : "disabled"));
|
||||
}
|
||||
|
||||
private void showCooldownPath(Player player, PlayerState state) {
|
||||
String detail = switch (state.cooldownLevel()) {
|
||||
case 0 -> state.blocksBrokenInBase() + "/" + settings.current().firstCooldownBreaks();
|
||||
@@ -373,12 +597,44 @@ final class BaseSettingsCommand implements CommandExecutor, TabCompleter {
|
||||
List<String> modes = arguments[0].equalsIgnoreCase("visitors")
|
||||
? VISITOR_MODES
|
||||
: switch (arguments[0].toLowerCase(Locale.ROOT)) {
|
||||
case "pocket" -> List.of("upgrade", "mobs", "type");
|
||||
case "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("mobs")) {
|
||||
return List.of("hostile", "passive").stream()
|
||||
.filter(category -> category.startsWith(prefix))
|
||||
.toList();
|
||||
}
|
||||
if (arguments[1].equalsIgnoreCase("type")) {
|
||||
return POCKET_WORLD_TYPES.stream()
|
||||
.filter(type -> type.startsWith(prefix))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
if (arguments.length == 4 && arguments[0].equalsIgnoreCase("pocket")) {
|
||||
String prefix = arguments[3].toLowerCase(Locale.ROOT);
|
||||
if (arguments[1].equalsIgnoreCase("mobs")
|
||||
&& (arguments[2].equalsIgnoreCase("hostile")
|
||||
|| arguments[2].equalsIgnoreCase("passive"))) {
|
||||
return ENABLE_MODES.stream()
|
||||
.filter(mode -> mode.startsWith(prefix))
|
||||
.toList();
|
||||
}
|
||||
if (arguments[1].equalsIgnoreCase("type")) {
|
||||
return PocketBaseWorldType.fromCommand(arguments[2])
|
||||
.map(PocketBaseBiome::commandNames)
|
||||
.orElse(List.of())
|
||||
.stream()
|
||||
.filter(biome -> biome.startsWith(prefix))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
return List.of();
|
||||
}
|
||||
|
||||
@@ -436,9 +692,17 @@ final class BaseSettingsCommand implements CommandExecutor, TabCompleter {
|
||||
return met ? ChatColor.YELLOW : ChatColor.RED;
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
interface DialogProvider {
|
||||
Dialog create(PlayerState owner, PocketBaseState pocket);
|
||||
}
|
||||
|
||||
private static void sendUsage(Player player) {
|
||||
player.sendMessage(ChatColor.RED + "Usage: /basesettings "
|
||||
+ "[status|upgrade|visitors <allowed|blocked>|navigation <enable|disable>"
|
||||
+ "[ui|status|upgrade|pocket upgrade|pocket 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,437 @@
|
||||
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),
|
||||
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())
|
||||
),
|
||||
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 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) {
|
||||
DialogType type;
|
||||
if (specification instanceof ListSpec list) {
|
||||
List<Dialog> dialogs = list.dialogs().stream()
|
||||
.map(BaseSettingsDialogFactory::render)
|
||||
.toList();
|
||||
type = DialogType.dialogList(
|
||||
RegistrySet.valueSet(RegistryKey.DIALOG, dialogs),
|
||||
null,
|
||||
list.columns(),
|
||||
list.buttonWidth()
|
||||
);
|
||||
} else if (specification instanceof MultiSpec multi) {
|
||||
List<ActionButton> actions = multi.actions().stream()
|
||||
.map(BaseSettingsDialogFactory::render)
|
||||
.toList();
|
||||
type = DialogType.multiAction(actions, null, multi.columns());
|
||||
} else if (specification instanceof ConfirmationSpec confirmation) {
|
||||
ActionButton cancel = ActionButton.builder(Component.text("Cancel")).build();
|
||||
type = DialogType.confirmation(render(confirmation.confirm()), cancel);
|
||||
} else if (specification instanceof NoticeSpec notice) {
|
||||
type = notice.action() == null
|
||||
? DialogType.notice()
|
||||
: 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 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();
|
||||
}
|
||||
}
|
||||
@@ -83,6 +83,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 +101,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"
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
package games.dmg.spigotbase;
|
||||
|
||||
public record BlockPosition(int x, int y, int z) {
|
||||
}
|
||||
@@ -37,11 +37,17 @@ public record PluginSettings(
|
||||
long thirdTeleportCooldownSeconds,
|
||||
long fourthTeleportCooldownSeconds,
|
||||
int visitorUnlockDiamondCost,
|
||||
int pocketBaseUnlockCost,
|
||||
int pocketBaseUpgradeCost,
|
||||
int pocketBaseBiomeChangeCost,
|
||||
Set<String> baseUnlockMaterials,
|
||||
String stoneExpansionMaterial,
|
||||
String deepslateExpansionMaterial,
|
||||
String obsidianExpansionMaterial,
|
||||
String visitorCurrencyMaterial,
|
||||
String pocketBaseCurrencyMaterial,
|
||||
String pocketBaseBiomeCurrencyMaterial,
|
||||
String pocketBasePortalFrameMaterial,
|
||||
Set<String> cooldownExcludedMaterials,
|
||||
int bossBarDurationTicks,
|
||||
int navigationParticleCount,
|
||||
@@ -79,6 +85,9 @@ public record PluginSettings(
|
||||
private static final long DEFAULT_THIRD_TELEPORT_COOLDOWN_SECONDS = 3_600L;
|
||||
private static final long DEFAULT_FOURTH_TELEPORT_COOLDOWN_SECONDS = 1_800L;
|
||||
private static final int DEFAULT_VISITOR_UNLOCK_DIAMOND_COST = 128;
|
||||
private static final int DEFAULT_POCKET_BASE_UNLOCK_COST = 64;
|
||||
private static final int DEFAULT_POCKET_BASE_UPGRADE_COST = 16;
|
||||
private static final int DEFAULT_POCKET_BASE_BIOME_CHANGE_COST = 16;
|
||||
private static final int DEFAULT_BOSS_BAR_DURATION_TICKS = 60;
|
||||
private static final int DEFAULT_NAVIGATION_PARTICLE_COUNT = 5;
|
||||
private static final int DEFAULT_TITLE_FADE_IN_TICKS = 10;
|
||||
@@ -140,6 +149,9 @@ public record PluginSettings(
|
||||
throw new IllegalArgumentException("teleport cooldown durations must decrease");
|
||||
}
|
||||
requirePositive(visitorUnlockDiamondCost, "visitor-unlock-diamond-cost");
|
||||
requirePositive(pocketBaseUnlockCost, "pocket-base-unlock-cost");
|
||||
requirePositive(pocketBaseUpgradeCost, "pocket-base-upgrade-cost");
|
||||
requirePositive(pocketBaseBiomeChangeCost, "pocket-base-biome-change-cost");
|
||||
baseUnlockMaterials = normalizedSet(baseUnlockMaterials, "base-unlock-materials", false);
|
||||
stoneExpansionMaterial = normalizedName(stoneExpansionMaterial, "stone-expansion-material");
|
||||
deepslateExpansionMaterial = normalizedName(
|
||||
@@ -151,6 +163,15 @@ public record PluginSettings(
|
||||
visitorCurrencyMaterial = normalizedName(
|
||||
visitorCurrencyMaterial, "visitor-currency-material"
|
||||
);
|
||||
pocketBaseCurrencyMaterial = normalizedName(
|
||||
pocketBaseCurrencyMaterial, "pocket-base-currency-material"
|
||||
);
|
||||
pocketBaseBiomeCurrencyMaterial = normalizedName(
|
||||
pocketBaseBiomeCurrencyMaterial, "pocket-base-biome-currency-material"
|
||||
);
|
||||
pocketBasePortalFrameMaterial = normalizedName(
|
||||
pocketBasePortalFrameMaterial, "pocket-base-portal-frame-material"
|
||||
);
|
||||
cooldownExcludedMaterials = normalizedSet(
|
||||
cooldownExcludedMaterials, "cooldown-excluded-materials", true
|
||||
);
|
||||
@@ -214,11 +235,21 @@ public record PluginSettings(
|
||||
DEFAULT_FOURTH_TELEPORT_COOLDOWN_SECONDS
|
||||
),
|
||||
integer(values, "visitor-unlock-diamond-cost", DEFAULT_VISITOR_UNLOCK_DIAMOND_COST),
|
||||
integer(values, "pocket-base-unlock-cost", DEFAULT_POCKET_BASE_UNLOCK_COST),
|
||||
integer(values, "pocket-base-upgrade-cost", DEFAULT_POCKET_BASE_UPGRADE_COST),
|
||||
integer(
|
||||
values,
|
||||
"pocket-base-biome-change-cost",
|
||||
DEFAULT_POCKET_BASE_BIOME_CHANGE_COST
|
||||
),
|
||||
stringSet(values, "base-unlock-materials", Set.of("GRASS_BLOCK", "DIRT")),
|
||||
string(values, "stone-expansion-material", "STONE"),
|
||||
string(values, "deepslate-expansion-material", "DEEPSLATE"),
|
||||
string(values, "obsidian-expansion-material", "OBSIDIAN"),
|
||||
string(values, "visitor-currency-material", "DIAMOND"),
|
||||
string(values, "pocket-base-currency-material", "DIAMOND_BLOCK"),
|
||||
string(values, "pocket-base-biome-currency-material", "NETHERITE_BLOCK"),
|
||||
string(values, "pocket-base-portal-frame-material", "DIAMOND_BLOCK"),
|
||||
stringSet(values, "cooldown-excluded-materials", Set.of()),
|
||||
integer(values, "boss-bar-duration-ticks", DEFAULT_BOSS_BAR_DURATION_TICKS),
|
||||
integer(values, "navigation-particle-count", DEFAULT_NAVIGATION_PARTICLE_COUNT),
|
||||
|
||||
@@ -13,7 +13,8 @@ final class PluginSettingsValidator {
|
||||
Stream.of(
|
||||
settings.stoneExpansionMaterial(),
|
||||
settings.deepslateExpansionMaterial(),
|
||||
settings.obsidianExpansionMaterial()
|
||||
settings.obsidianExpansionMaterial(),
|
||||
settings.pocketBasePortalFrameMaterial()
|
||||
)
|
||||
).forEach(name -> {
|
||||
Material material = Material.matchMaterial(name);
|
||||
@@ -26,10 +27,19 @@ final class PluginSettingsValidator {
|
||||
throw new IllegalArgumentException(name + " is not a known material");
|
||||
}
|
||||
}
|
||||
Material currency = Material.matchMaterial(settings.visitorCurrencyMaterial());
|
||||
if (currency == null || currency.isAir()) {
|
||||
throw new IllegalArgumentException("visitor-currency-material is invalid");
|
||||
}
|
||||
validateCurrency(settings.visitorCurrencyMaterial(), "visitor-currency-material");
|
||||
validateCurrency(settings.pocketBaseCurrencyMaterial(), "pocket-base-currency-material");
|
||||
validateCurrency(
|
||||
settings.pocketBaseBiomeCurrencyMaterial(),
|
||||
"pocket-base-biome-currency-material"
|
||||
);
|
||||
return settings;
|
||||
}
|
||||
|
||||
private static void validateCurrency(String name, String setting) {
|
||||
Material currency = Material.matchMaterial(name);
|
||||
if (currency == null || currency.isAir()) {
|
||||
throw new IllegalArgumentException(setting + " is invalid");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
package games.dmg.spigotbase;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Optional;
|
||||
|
||||
public enum PocketBaseBiome {
|
||||
THE_VOID(PocketBaseWorldType.VOID),
|
||||
|
||||
NETHER_WASTES(PocketBaseWorldType.NETHER),
|
||||
SOUL_SAND_VALLEY(PocketBaseWorldType.NETHER),
|
||||
CRIMSON_FOREST(PocketBaseWorldType.NETHER),
|
||||
WARPED_FOREST(PocketBaseWorldType.NETHER),
|
||||
BASALT_DELTAS(PocketBaseWorldType.NETHER),
|
||||
|
||||
OCEAN(PocketBaseWorldType.OVERWORLD),
|
||||
PLAINS(PocketBaseWorldType.OVERWORLD),
|
||||
DESERT(PocketBaseWorldType.OVERWORLD),
|
||||
WINDSWEPT_HILLS(PocketBaseWorldType.OVERWORLD),
|
||||
FOREST(PocketBaseWorldType.OVERWORLD),
|
||||
TAIGA(PocketBaseWorldType.OVERWORLD),
|
||||
SWAMP(PocketBaseWorldType.OVERWORLD),
|
||||
MANGROVE_SWAMP(PocketBaseWorldType.OVERWORLD),
|
||||
RIVER(PocketBaseWorldType.OVERWORLD),
|
||||
FROZEN_OCEAN(PocketBaseWorldType.OVERWORLD),
|
||||
FROZEN_RIVER(PocketBaseWorldType.OVERWORLD),
|
||||
SNOWY_PLAINS(PocketBaseWorldType.OVERWORLD),
|
||||
MUSHROOM_FIELDS(PocketBaseWorldType.OVERWORLD),
|
||||
BEACH(PocketBaseWorldType.OVERWORLD),
|
||||
JUNGLE(PocketBaseWorldType.OVERWORLD),
|
||||
SPARSE_JUNGLE(PocketBaseWorldType.OVERWORLD),
|
||||
DEEP_OCEAN(PocketBaseWorldType.OVERWORLD),
|
||||
STONY_SHORE(PocketBaseWorldType.OVERWORLD),
|
||||
SNOWY_BEACH(PocketBaseWorldType.OVERWORLD),
|
||||
BIRCH_FOREST(PocketBaseWorldType.OVERWORLD),
|
||||
DARK_FOREST(PocketBaseWorldType.OVERWORLD),
|
||||
PALE_GARDEN(PocketBaseWorldType.OVERWORLD),
|
||||
SNOWY_TAIGA(PocketBaseWorldType.OVERWORLD),
|
||||
OLD_GROWTH_PINE_TAIGA(PocketBaseWorldType.OVERWORLD),
|
||||
WINDSWEPT_FOREST(PocketBaseWorldType.OVERWORLD),
|
||||
SAVANNA(PocketBaseWorldType.OVERWORLD),
|
||||
SAVANNA_PLATEAU(PocketBaseWorldType.OVERWORLD),
|
||||
BADLANDS(PocketBaseWorldType.OVERWORLD),
|
||||
WOODED_BADLANDS(PocketBaseWorldType.OVERWORLD),
|
||||
WARM_OCEAN(PocketBaseWorldType.OVERWORLD),
|
||||
LUKEWARM_OCEAN(PocketBaseWorldType.OVERWORLD),
|
||||
COLD_OCEAN(PocketBaseWorldType.OVERWORLD),
|
||||
DEEP_LUKEWARM_OCEAN(PocketBaseWorldType.OVERWORLD),
|
||||
DEEP_COLD_OCEAN(PocketBaseWorldType.OVERWORLD),
|
||||
DEEP_FROZEN_OCEAN(PocketBaseWorldType.OVERWORLD),
|
||||
SUNFLOWER_PLAINS(PocketBaseWorldType.OVERWORLD),
|
||||
WINDSWEPT_GRAVELLY_HILLS(PocketBaseWorldType.OVERWORLD),
|
||||
FLOWER_FOREST(PocketBaseWorldType.OVERWORLD),
|
||||
ICE_SPIKES(PocketBaseWorldType.OVERWORLD),
|
||||
OLD_GROWTH_BIRCH_FOREST(PocketBaseWorldType.OVERWORLD),
|
||||
OLD_GROWTH_SPRUCE_TAIGA(PocketBaseWorldType.OVERWORLD),
|
||||
WINDSWEPT_SAVANNA(PocketBaseWorldType.OVERWORLD),
|
||||
ERODED_BADLANDS(PocketBaseWorldType.OVERWORLD),
|
||||
BAMBOO_JUNGLE(PocketBaseWorldType.OVERWORLD),
|
||||
DRIPSTONE_CAVES(PocketBaseWorldType.OVERWORLD),
|
||||
LUSH_CAVES(PocketBaseWorldType.OVERWORLD),
|
||||
DEEP_DARK(PocketBaseWorldType.OVERWORLD),
|
||||
SULFUR_CAVES(PocketBaseWorldType.OVERWORLD),
|
||||
MEADOW(PocketBaseWorldType.OVERWORLD),
|
||||
GROVE(PocketBaseWorldType.OVERWORLD),
|
||||
SNOWY_SLOPES(PocketBaseWorldType.OVERWORLD),
|
||||
FROZEN_PEAKS(PocketBaseWorldType.OVERWORLD),
|
||||
JAGGED_PEAKS(PocketBaseWorldType.OVERWORLD),
|
||||
STONY_PEAKS(PocketBaseWorldType.OVERWORLD),
|
||||
CHERRY_GROVE(PocketBaseWorldType.OVERWORLD);
|
||||
|
||||
private final PocketBaseWorldType worldType;
|
||||
|
||||
PocketBaseBiome(PocketBaseWorldType worldType) {
|
||||
this.worldType = worldType;
|
||||
}
|
||||
|
||||
public PocketBaseWorldType worldType() {
|
||||
return worldType;
|
||||
}
|
||||
|
||||
public String commandName() {
|
||||
return name().toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
static Optional<PocketBaseBiome> fromCommand(
|
||||
PocketBaseWorldType worldType,
|
||||
String value
|
||||
) {
|
||||
try {
|
||||
PocketBaseBiome biome = valueOf(value.toUpperCase(Locale.ROOT));
|
||||
return biome.worldType == worldType ? Optional.of(biome) : Optional.empty();
|
||||
} catch (IllegalArgumentException exception) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
static List<String> commandNames(PocketBaseWorldType worldType) {
|
||||
return Arrays.stream(values())
|
||||
.filter(biome -> biome.worldType == worldType)
|
||||
.map(PocketBaseBiome::commandName)
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
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 Map<UUID, Long> cooldownUntil = new HashMap<>();
|
||||
|
||||
PocketBaseController(
|
||||
Server server,
|
||||
BaseStateManager baseStates,
|
||||
BaseBoundsService baseBounds,
|
||||
PocketBaseManager pocketBases,
|
||||
PluginSettingsProvider settings,
|
||||
Logger logger
|
||||
) {
|
||||
this.server = server;
|
||||
this.baseStates = baseStates;
|
||||
this.baseBounds = baseBounds;
|
||||
this.pocketBases = pocketBases;
|
||||
this.settings = settings;
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
@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()));
|
||||
}
|
||||
}
|
||||
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<PocketPortalLocation> portal = PocketPortalGeometry.findFrame(
|
||||
position(clicked),
|
||||
world.getUID(),
|
||||
world.getName(),
|
||||
candidate -> block(world, candidate).getType() == frameMaterial,
|
||||
candidate -> isAir(block(world, candidate).getType())
|
||||
);
|
||||
if (portal.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
Optional<UUID> pocketOwner = pocketBases.ownerForPocketWorld(world.getUID());
|
||||
if (pocketOwner.isPresent()) {
|
||||
if (!pocketOwner.orElseThrow().equals(player.getUniqueId())) {
|
||||
return;
|
||||
}
|
||||
if (PocketPortalGeometry.frameBlocks(portal.orElseThrow()).stream()
|
||||
.anyMatch(candidate -> !pocketBases.policy().contains(
|
||||
pocket.level(), candidate.x(), candidate.z()
|
||||
))) {
|
||||
player.sendMessage(ChatColor.RED
|
||||
+ "The complete return portal must be inside your Pocket Base boundary.");
|
||||
event.setCancelled(true);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
pocketBases.activateReturnPortal(player.getUniqueId(), portal.orElseThrow());
|
||||
} catch (IOException | RuntimeException exception) {
|
||||
logger.log(Level.SEVERE, "Could not activate Pocket Base return portal", exception);
|
||||
player.sendMessage(ChatColor.RED
|
||||
+ "The Pocket Base return portal could not be activated.");
|
||||
event.setCancelled(true);
|
||||
return;
|
||||
}
|
||||
event.setCancelled(true);
|
||||
player.sendMessage(ChatColor.GREEN + "Pocket Base return portal activated.");
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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;
|
||||
}
|
||||
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) {
|
||||
World world = server.getWorld(portal.worldId());
|
||||
if (world == null) {
|
||||
return false;
|
||||
}
|
||||
Material frame = Material.valueOf(settings.current().pocketBasePortalFrameMaterial());
|
||||
return PocketPortalGeometry.frameBlocks(portal).stream()
|
||||
.allMatch(position -> block(world, position).getType() == frame);
|
||||
}
|
||||
|
||||
private void showPortal(PocketPortalLocation portal) {
|
||||
World world = server.getWorld(portal.worldId());
|
||||
if (world == null || !isIntact(portal)) {
|
||||
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 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 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,169 @@
|
||||
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 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 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,96 @@
|
||||
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
|
||||
) {
|
||||
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;
|
||||
biome = biome == null ? PocketBaseBiome.THE_VOID : biome;
|
||||
if (level == 0 && (entrance.isPresent() || returnPortal.isPresent())) {
|
||||
throw new IllegalArgumentException("a locked Pocket Base cannot have a portal");
|
||||
}
|
||||
}
|
||||
|
||||
public PocketBaseState(
|
||||
UUID ownerId,
|
||||
int level,
|
||||
Optional<PocketPortalLocation> entrance
|
||||
) {
|
||||
this(
|
||||
ownerId, level, entrance, Optional.empty(), false, false,
|
||||
PocketBaseBiome.THE_VOID
|
||||
);
|
||||
}
|
||||
|
||||
public static PocketBaseState locked(UUID ownerId) {
|
||||
return new PocketBaseState(
|
||||
ownerId, 0, Optional.empty(), Optional.empty(), false, false,
|
||||
PocketBaseBiome.THE_VOID
|
||||
);
|
||||
}
|
||||
|
||||
public PocketBaseState withLevel(int newLevel) {
|
||||
return new PocketBaseState(
|
||||
ownerId, newLevel, entrance, returnPortal, hostileMobSpawningEnabled,
|
||||
passiveMobSpawningEnabled, biome
|
||||
);
|
||||
}
|
||||
|
||||
public PocketBaseState withEntrance(PocketPortalLocation portal) {
|
||||
return new PocketBaseState(
|
||||
ownerId, level, Optional.of(portal), returnPortal,
|
||||
hostileMobSpawningEnabled, passiveMobSpawningEnabled, biome
|
||||
);
|
||||
}
|
||||
|
||||
public PocketBaseState withoutEntrance() {
|
||||
return new PocketBaseState(
|
||||
ownerId, level, Optional.empty(), returnPortal, hostileMobSpawningEnabled,
|
||||
passiveMobSpawningEnabled, biome
|
||||
);
|
||||
}
|
||||
|
||||
public PocketBaseState withReturnPortal(PocketPortalLocation portal) {
|
||||
return new PocketBaseState(
|
||||
ownerId, level, entrance, Optional.of(portal), hostileMobSpawningEnabled,
|
||||
passiveMobSpawningEnabled, biome
|
||||
);
|
||||
}
|
||||
|
||||
public PocketBaseState withHostileMobSpawningEnabled(boolean enabled) {
|
||||
return new PocketBaseState(
|
||||
ownerId, level, entrance, returnPortal, enabled,
|
||||
passiveMobSpawningEnabled, biome
|
||||
);
|
||||
}
|
||||
|
||||
public PocketBaseState withPassiveMobSpawningEnabled(boolean enabled) {
|
||||
return new PocketBaseState(
|
||||
ownerId, level, entrance, returnPortal, hostileMobSpawningEnabled,
|
||||
enabled, biome
|
||||
);
|
||||
}
|
||||
|
||||
public PocketBaseState withBiome(PocketBaseBiome selectedBiome) {
|
||||
return new PocketBaseState(
|
||||
ownerId, level, entrance, returnPortal, hostileMobSpawningEnabled,
|
||||
passiveMobSpawningEnabled, selectedBiome
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package games.dmg.spigotbase;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.function.UnaryOperator;
|
||||
|
||||
final class PocketBaseStateManager {
|
||||
private final YamlPocketBaseRepository repository;
|
||||
private final Map<UUID, PocketBaseState> states;
|
||||
|
||||
PocketBaseStateManager(YamlPocketBaseRepository repository) throws IOException {
|
||||
this.repository = repository;
|
||||
this.states = new HashMap<>(repository.load());
|
||||
}
|
||||
|
||||
PocketBaseState state(UUID ownerId) {
|
||||
return states.getOrDefault(ownerId, PocketBaseState.locked(ownerId));
|
||||
}
|
||||
|
||||
Map<UUID, PocketBaseState> knownStates() {
|
||||
return Map.copyOf(states);
|
||||
}
|
||||
|
||||
PocketBaseState updateAndSave(
|
||||
UUID ownerId,
|
||||
UnaryOperator<PocketBaseState> operation
|
||||
) throws IOException {
|
||||
PocketBaseState updated = operation.apply(state(ownerId));
|
||||
if (!updated.ownerId().equals(ownerId)) {
|
||||
throw new IllegalArgumentException("Pocket Base owner ID cannot change");
|
||||
}
|
||||
Map<UUID, PocketBaseState> proposed = new HashMap<>(states);
|
||||
proposed.put(ownerId, updated);
|
||||
repository.save(proposed);
|
||||
states.clear();
|
||||
states.putAll(proposed);
|
||||
return updated;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
package games.dmg.spigotbase;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Function;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.Registry;
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.WorldCreator;
|
||||
import org.bukkit.block.Biome;
|
||||
|
||||
final class PocketBaseWorldService {
|
||||
private static final int PLATFORM_Y = 64;
|
||||
private static final String WORLD_PREFIX = "spigot_base_pocket_";
|
||||
private final Server server;
|
||||
private final PluginSettingsProvider settings;
|
||||
private final PocketBasePolicy policy;
|
||||
private final Function<PocketBaseBiome, Biome> biomeResolver;
|
||||
private final Map<UUID, UUID> ownersByWorld = new HashMap<>();
|
||||
|
||||
PocketBaseWorldService(Server server, PluginSettingsProvider settings) {
|
||||
this(server, settings, biome -> resolveBiome(server, biome));
|
||||
}
|
||||
|
||||
PocketBaseWorldService(
|
||||
Server server,
|
||||
PluginSettingsProvider settings,
|
||||
Function<PocketBaseBiome, Biome> biomeResolver
|
||||
) {
|
||||
this.server = server;
|
||||
this.settings = settings;
|
||||
this.policy = new PocketBasePolicy(settings);
|
||||
this.biomeResolver = biomeResolver;
|
||||
}
|
||||
|
||||
void loadExisting(Map<UUID, PocketBaseState> states) {
|
||||
for (PocketBaseState state : states.values()) {
|
||||
if (state.level() > 0) {
|
||||
setBiome(state.ownerId(), state.level(), state.biome());
|
||||
setMobSpawning(
|
||||
state.ownerId(),
|
||||
state.hostileMobSpawningEnabled(),
|
||||
state.passiveMobSpawningEnabled()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
World expand(UUID ownerId, int previousLevel, int newLevel) {
|
||||
return expand(ownerId, previousLevel, newLevel, PocketBaseBiome.THE_VOID);
|
||||
}
|
||||
|
||||
World expand(
|
||||
UUID ownerId,
|
||||
int previousLevel,
|
||||
int newLevel,
|
||||
PocketBaseBiome biome
|
||||
) {
|
||||
World world = ensureWorld(ownerId);
|
||||
int newHalfSize = policy.size(newLevel) / 2;
|
||||
int previousHalfSize = previousLevel == 0 ? 0 : policy.size(previousLevel) / 2;
|
||||
for (int x = -newHalfSize; x < newHalfSize; x++) {
|
||||
for (int z = -newHalfSize; z < newHalfSize; z++) {
|
||||
boolean previouslyUnlocked = previousLevel > 0
|
||||
&& x >= -previousHalfSize && x < previousHalfSize
|
||||
&& z >= -previousHalfSize && z < previousHalfSize;
|
||||
if (previouslyUnlocked) {
|
||||
continue;
|
||||
}
|
||||
for (int y = PLATFORM_Y - 3; y < PLATFORM_Y; y++) {
|
||||
world.getBlockAt(x, y, z).setType(Material.DIRT, false);
|
||||
}
|
||||
world.getBlockAt(x, PLATFORM_Y, z).setType(Material.GRASS_BLOCK, false);
|
||||
}
|
||||
}
|
||||
applyBiome(world, previousLevel, newLevel, biome);
|
||||
if (previousLevel == 0) {
|
||||
createReturnPortal(world);
|
||||
world.setSpawnLocation(0, PLATFORM_Y + 1, 5);
|
||||
}
|
||||
world.save();
|
||||
return world;
|
||||
}
|
||||
|
||||
Optional<UUID> ownerForWorld(UUID worldId) {
|
||||
return Optional.ofNullable(ownersByWorld.get(worldId));
|
||||
}
|
||||
|
||||
World world(UUID ownerId) {
|
||||
return ensureWorld(ownerId);
|
||||
}
|
||||
|
||||
void setMobSpawning(
|
||||
UUID ownerId,
|
||||
boolean hostileEnabled,
|
||||
boolean passiveEnabled
|
||||
) {
|
||||
ensureWorld(ownerId).setSpawnFlags(hostileEnabled, passiveEnabled);
|
||||
}
|
||||
|
||||
void setBiome(UUID ownerId, int level, PocketBaseBiome biome) {
|
||||
World world = ensureWorld(ownerId);
|
||||
applyBiome(world, 0, level, biome);
|
||||
world.save();
|
||||
}
|
||||
|
||||
Location arrival(UUID ownerId) {
|
||||
return new Location(ensureWorld(ownerId), 0.5, PLATFORM_Y + 1, 5.5, 180.0f, 0.0f);
|
||||
}
|
||||
|
||||
PocketPortalLocation returnPortal(UUID ownerId) {
|
||||
World world = ensureWorld(ownerId);
|
||||
return new PocketPortalLocation(
|
||||
world.getUID(), world.getName(), -2, PLATFORM_Y + 1, 0, PocketPortalAxis.X
|
||||
);
|
||||
}
|
||||
|
||||
boolean returnPortalIsIntact(UUID ownerId, PocketPortalLocation portal) {
|
||||
World world = ensureWorld(ownerId);
|
||||
if (!portal.worldId().equals(world.getUID())) {
|
||||
return false;
|
||||
}
|
||||
Material frame = Material.valueOf(settings.current().pocketBasePortalFrameMaterial());
|
||||
return PocketPortalGeometry.frameBlocks(portal).stream()
|
||||
.allMatch(position -> world.getBlockAt(
|
||||
position.x(), position.y(), position.z()
|
||||
).getType() == frame);
|
||||
}
|
||||
|
||||
private World ensureWorld(UUID ownerId) {
|
||||
String name = worldName(ownerId);
|
||||
World world = server.getWorld(name);
|
||||
boolean created = world == null;
|
||||
if (created) {
|
||||
WorldCreator creator = new WorldCreator(NamespacedKey.minecraft(name))
|
||||
.environment(World.Environment.NORMAL)
|
||||
.generator(new VoidPocketChunkGenerator())
|
||||
.generateStructures(false);
|
||||
world = server.createWorld(creator);
|
||||
}
|
||||
if (world == null) {
|
||||
throw new IllegalStateException("Could not create Pocket Base world " + name);
|
||||
}
|
||||
if (created) {
|
||||
world.setSpawnFlags(false, false);
|
||||
}
|
||||
ownersByWorld.put(world.getUID(), ownerId);
|
||||
return world;
|
||||
}
|
||||
|
||||
private void applyBiome(
|
||||
World world,
|
||||
int previousLevel,
|
||||
int newLevel,
|
||||
PocketBaseBiome selectedBiome
|
||||
) {
|
||||
int newHalfSize = policy.size(newLevel) / 2;
|
||||
int previousHalfSize = previousLevel == 0 ? 0 : policy.size(previousLevel) / 2;
|
||||
Biome biome = biomeResolver.apply(selectedBiome);
|
||||
for (int x = -newHalfSize; x < newHalfSize; x += 4) {
|
||||
for (int z = -newHalfSize; z < newHalfSize; z += 4) {
|
||||
boolean previouslyUnlocked = previousLevel > 0
|
||||
&& x >= -previousHalfSize && x < previousHalfSize
|
||||
&& z >= -previousHalfSize && z < previousHalfSize;
|
||||
if (previouslyUnlocked) {
|
||||
continue;
|
||||
}
|
||||
for (int y = world.getMinHeight(); y < world.getMaxHeight(); y += 4) {
|
||||
world.setBiome(x, y, z, biome);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static Biome resolveBiome(Server server, PocketBaseBiome selectedBiome) {
|
||||
Registry<Biome> biomeRegistry = server.getRegistry(Biome.class);
|
||||
if (biomeRegistry == null) {
|
||||
throw new IllegalStateException("Biome registry is unavailable");
|
||||
}
|
||||
return biomeRegistry.getOrThrow(
|
||||
NamespacedKey.minecraft(selectedBiome.commandName())
|
||||
);
|
||||
}
|
||||
|
||||
private void createReturnPortal(World world) {
|
||||
PocketPortalLocation portal = new PocketPortalLocation(
|
||||
world.getUID(), world.getName(), -2, PLATFORM_Y + 1, 0, PocketPortalAxis.X
|
||||
);
|
||||
Material frame = Material.valueOf(settings.current().pocketBasePortalFrameMaterial());
|
||||
for (BlockPosition position : PocketPortalGeometry.frameBlocks(portal)) {
|
||||
world.getBlockAt(position.x(), position.y(), position.z()).setType(frame, false);
|
||||
}
|
||||
}
|
||||
|
||||
private static String worldName(UUID ownerId) {
|
||||
return WORLD_PREFIX + ownerId.toString().replace("-", "");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package games.dmg.spigotbase;
|
||||
|
||||
import java.util.Locale;
|
||||
import java.util.Optional;
|
||||
|
||||
public enum PocketBaseWorldType {
|
||||
VOID,
|
||||
NETHER,
|
||||
OVERWORLD;
|
||||
|
||||
public String commandName() {
|
||||
return name().toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
static Optional<PocketBaseWorldType> fromCommand(String value) {
|
||||
try {
|
||||
return Optional.of(valueOf(value.toUpperCase(Locale.ROOT)));
|
||||
} catch (IllegalArgumentException exception) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package games.dmg.spigotbase;
|
||||
|
||||
public enum PocketPortalAxis {
|
||||
X,
|
||||
Z
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package games.dmg.spigotbase;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Predicate;
|
||||
|
||||
public final class PocketPortalGeometry {
|
||||
private PocketPortalGeometry() {
|
||||
}
|
||||
|
||||
public static Optional<PocketPortalLocation> findFrame(
|
||||
BlockPosition clicked,
|
||||
UUID worldId,
|
||||
String worldName,
|
||||
Predicate<BlockPosition> isFrameMaterial,
|
||||
Predicate<BlockPosition> isOpenInterior
|
||||
) {
|
||||
for (PocketPortalAxis axis : PocketPortalAxis.values()) {
|
||||
int clickedHorizontal = axis == PocketPortalAxis.X ? clicked.x() : clicked.z();
|
||||
for (int horizontalOffset = 0; horizontalOffset < 4; horizontalOffset++) {
|
||||
for (int verticalOffset = 0; verticalOffset < 5; verticalOffset++) {
|
||||
int originHorizontal = clickedHorizontal - horizontalOffset;
|
||||
int originY = clicked.y() - verticalOffset;
|
||||
PocketPortalLocation candidate = axis == PocketPortalAxis.X
|
||||
? new PocketPortalLocation(
|
||||
worldId, worldName, originHorizontal, originY, clicked.z(), axis
|
||||
)
|
||||
: new PocketPortalLocation(
|
||||
worldId, worldName, clicked.x(), originY, originHorizontal, axis
|
||||
);
|
||||
if (frameBlocks(candidate).stream().allMatch(isFrameMaterial)
|
||||
&& interiorBlocks(candidate).stream().allMatch(isOpenInterior)) {
|
||||
return Optional.of(candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
public static List<BlockPosition> frameBlocks(PocketPortalLocation portal) {
|
||||
List<BlockPosition> blocks = new ArrayList<>(14);
|
||||
for (int horizontal = 0; horizontal < 4; horizontal++) {
|
||||
blocks.add(position(portal, horizontal, 0));
|
||||
blocks.add(position(portal, horizontal, 4));
|
||||
}
|
||||
for (int vertical = 1; vertical < 4; vertical++) {
|
||||
blocks.add(position(portal, 0, vertical));
|
||||
blocks.add(position(portal, 3, vertical));
|
||||
}
|
||||
return List.copyOf(blocks);
|
||||
}
|
||||
|
||||
public static List<BlockPosition> interiorBlocks(PocketPortalLocation portal) {
|
||||
List<BlockPosition> blocks = new ArrayList<>(6);
|
||||
for (int horizontal = 1; horizontal < 3; horizontal++) {
|
||||
for (int vertical = 1; vertical < 4; vertical++) {
|
||||
blocks.add(position(portal, horizontal, vertical));
|
||||
}
|
||||
}
|
||||
return List.copyOf(blocks);
|
||||
}
|
||||
|
||||
public static boolean isFrame(PocketPortalLocation portal, BlockPosition position) {
|
||||
return frameBlocks(portal).contains(position);
|
||||
}
|
||||
|
||||
public static boolean isInterior(PocketPortalLocation portal, BlockPosition position) {
|
||||
return interiorBlocks(portal).contains(position);
|
||||
}
|
||||
|
||||
private static BlockPosition position(
|
||||
PocketPortalLocation portal,
|
||||
int horizontal,
|
||||
int vertical
|
||||
) {
|
||||
return portal.axis() == PocketPortalAxis.X
|
||||
? new BlockPosition(portal.x() + horizontal, portal.y() + vertical, portal.z())
|
||||
: new BlockPosition(portal.x(), portal.y() + vertical, portal.z() + horizontal);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package games.dmg.spigotbase;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public record PocketPortalLocation(
|
||||
UUID worldId,
|
||||
String worldName,
|
||||
int x,
|
||||
int y,
|
||||
int z,
|
||||
PocketPortalAxis axis
|
||||
) {
|
||||
public PocketPortalLocation {
|
||||
if (worldId == null) {
|
||||
throw new IllegalArgumentException("portal world ID is required");
|
||||
}
|
||||
if (worldName == null || worldName.isBlank()) {
|
||||
throw new IllegalArgumentException("portal world name is required");
|
||||
}
|
||||
if (axis == null) {
|
||||
throw new IllegalArgumentException("portal axis is required");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,11 +16,22 @@ final class SetBaseCommand implements CommandExecutor {
|
||||
private final BaseStateManager stateManager;
|
||||
private final BaseService baseService;
|
||||
private final Clock clock;
|
||||
private final PocketBaseController pocketBaseController;
|
||||
|
||||
SetBaseCommand(BaseStateManager stateManager, BaseService baseService, Clock clock) {
|
||||
this(stateManager, baseService, clock, null);
|
||||
}
|
||||
|
||||
SetBaseCommand(
|
||||
BaseStateManager stateManager,
|
||||
BaseService baseService,
|
||||
Clock clock,
|
||||
PocketBaseController pocketBaseController
|
||||
) {
|
||||
this.stateManager = stateManager;
|
||||
this.baseService = baseService;
|
||||
this.clock = clock;
|
||||
this.pocketBaseController = pocketBaseController;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -47,6 +58,10 @@ final class SetBaseCommand implements CommandExecutor {
|
||||
player.sendMessage(ChatColor.RED + "Your current world is unavailable.");
|
||||
return true;
|
||||
}
|
||||
if (pocketBaseController != null && pocketBaseController.isPocketWorld(world.getUID())) {
|
||||
player.sendMessage(ChatColor.RED + "You cannot set your normal base inside a Pocket Base.");
|
||||
return true;
|
||||
}
|
||||
BaseLocation base = new BaseLocation(
|
||||
world.getUID(), world.getName(), location.getBlockX(), location.getBlockY(),
|
||||
location.getBlockZ(), location.getYaw(), location.getPitch()
|
||||
@@ -55,6 +70,9 @@ final class SetBaseCommand implements CommandExecutor {
|
||||
player.getUniqueId(), player.getName(), current -> baseService.setBase(current, base, now)
|
||||
);
|
||||
stateManager.saveIfDirty();
|
||||
if (pocketBaseController != null) {
|
||||
pocketBaseController.deactivateForRelocation(player.getUniqueId());
|
||||
}
|
||||
player.sendMessage(ChatColor.GREEN + "Base set at " + base.x() + ", " + base.y() + ", " + base.z() + ".");
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ public final class SpigotBasePlugin extends JavaPlugin {
|
||||
private BaseProgressListener progressListener;
|
||||
private BaseFlightController flightController;
|
||||
private BaseTeleportManager teleportManager;
|
||||
private PocketBaseManager pocketBaseManager;
|
||||
private PocketBaseController pocketBaseController;
|
||||
|
||||
@Override
|
||||
public void onEnable() {
|
||||
@@ -27,7 +29,18 @@ 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();
|
||||
} catch (RuntimeException | IOException exception) {
|
||||
getLogger().log(Level.SEVERE, "Could not initialize Spigot Base", exception);
|
||||
getServer().getPluginManager().disablePlugin(this);
|
||||
return;
|
||||
@@ -62,15 +75,27 @@ public final class SpigotBasePlugin extends JavaPlugin {
|
||||
new SafeBaseDestination(),
|
||||
Clock.systemUTC()
|
||||
);
|
||||
pocketBaseController = new PocketBaseController(
|
||||
getServer(),
|
||||
stateManager,
|
||||
boundsService,
|
||||
pocketBaseManager,
|
||||
settingsProvider,
|
||||
getLogger()
|
||||
);
|
||||
getServer().getPluginManager().registerEvents(progressListener, this);
|
||||
getServer().getPluginManager().registerEvents(teleportManager, this);
|
||||
getServer().getPluginManager().registerEvents(pocketBaseController, this);
|
||||
|
||||
command("setbase").setExecutor(new SetBaseCommand(stateManager, baseService, Clock.systemUTC()));
|
||||
command("setbase").setExecutor(new SetBaseCommand(
|
||||
stateManager, baseService, Clock.systemUTC(), pocketBaseController
|
||||
));
|
||||
command("base").setExecutor(new BaseCommand(teleportManager));
|
||||
BaseSettingsCommand settingsCommand = new BaseSettingsCommand(
|
||||
stateManager,
|
||||
settingsProvider,
|
||||
flightController
|
||||
flightController,
|
||||
pocketBaseManager
|
||||
);
|
||||
command("basesettings").setExecutor(settingsCommand);
|
||||
command("basesettings").setTabCompleter(settingsCommand);
|
||||
@@ -107,6 +132,7 @@ public final class SpigotBasePlugin extends JavaPlugin {
|
||||
10L
|
||||
);
|
||||
getServer().getScheduler().runTaskTimer(this, flightController, 5L, 5L);
|
||||
getServer().getScheduler().runTaskTimer(this, pocketBaseController, 5L, 5L);
|
||||
getServer().getScheduler().runTaskTimer(this, stateManager::saveIfDirty, 600L, 600L);
|
||||
getLogger().info("Spigot Base enabled.");
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package games.dmg.spigotbase;
|
||||
|
||||
import org.bukkit.generator.ChunkGenerator;
|
||||
|
||||
final class VoidPocketChunkGenerator extends ChunkGenerator {
|
||||
@Override
|
||||
public boolean shouldGenerateNoise() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean shouldGenerateSurface() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean shouldGenerateCaves() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean shouldGenerateDecorations() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean shouldGenerateMobs() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean shouldGenerateStructures() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
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);
|
||||
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)
|
||||
);
|
||||
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 + ".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)
|
||||
);
|
||||
}
|
||||
Path temporary = Files.createTempFile(parent, "spigot-base-pocket-", ".yml");
|
||||
try {
|
||||
yaml.save(temporary.toFile());
|
||||
try {
|
||||
Files.move(
|
||||
temporary,
|
||||
stateFile,
|
||||
StandardCopyOption.REPLACE_EXISTING,
|
||||
StandardCopyOption.ATOMIC_MOVE
|
||||
);
|
||||
} catch (IOException atomicMoveFailure) {
|
||||
Files.move(temporary, stateFile, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
} finally {
|
||||
Files.deleteIfExists(temporary);
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean loadMobPreference(
|
||||
YamlConfiguration yaml,
|
||||
String path,
|
||||
String categoryKey
|
||||
) {
|
||||
return yaml.getBoolean(
|
||||
path + "." + categoryKey,
|
||||
yaml.getBoolean(path + ".mob-spawning-enabled", false)
|
||||
);
|
||||
}
|
||||
|
||||
private static PocketBaseBiome loadBiome(YamlConfiguration yaml, String path) {
|
||||
String worldTypeName = yaml.getString(path + ".world-type");
|
||||
String biomeName = yaml.getString(path + ".biome");
|
||||
if (worldTypeName == null || biomeName == null) {
|
||||
return PocketBaseBiome.THE_VOID;
|
||||
}
|
||||
return PocketBaseWorldType.fromCommand(worldTypeName)
|
||||
.flatMap(worldType -> PocketBaseBiome.fromCommand(worldType, biomeName))
|
||||
.orElse(PocketBaseBiome.THE_VOID);
|
||||
}
|
||||
|
||||
private static Optional<PocketPortalLocation> loadPortal(
|
||||
YamlConfiguration yaml,
|
||||
String path
|
||||
) {
|
||||
if (!yaml.isConfigurationSection(path)) {
|
||||
return Optional.empty();
|
||||
}
|
||||
String worldId = yaml.getString(path + ".world-id");
|
||||
String worldName = yaml.getString(path + ".world-name");
|
||||
String axis = yaml.getString(path + ".axis");
|
||||
if (worldId == null || worldName == null || axis == null
|
||||
|| !yaml.isInt(path + ".x") || !yaml.isInt(path + ".y")
|
||||
|| !yaml.isInt(path + ".z")) {
|
||||
throw new IllegalArgumentException("invalid Pocket Base portal");
|
||||
}
|
||||
return Optional.of(new PocketPortalLocation(
|
||||
UUID.fromString(worldId),
|
||||
worldName,
|
||||
yaml.getInt(path + ".x"),
|
||||
yaml.getInt(path + ".y"),
|
||||
yaml.getInt(path + ".z"),
|
||||
PocketPortalAxis.valueOf(axis)
|
||||
));
|
||||
}
|
||||
|
||||
private static void savePortal(
|
||||
YamlConfiguration yaml,
|
||||
String path,
|
||||
PocketPortalLocation portal
|
||||
) {
|
||||
yaml.set(path + ".world-id", portal.worldId().toString());
|
||||
yaml.set(path + ".world-name", portal.worldName());
|
||||
yaml.set(path + ".x", portal.x());
|
||||
yaml.set(path + ".y", portal.y());
|
||||
yaml.set(path + ".z", portal.z());
|
||||
yaml.set(path + ".axis", portal.axis().name());
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,14 @@ fourth-teleport-cooldown-seconds: 1800
|
||||
visitor-unlock-diamond-cost: 128
|
||||
visitor-currency-material: DIAMOND
|
||||
|
||||
# Pocket Base
|
||||
pocket-base-unlock-cost: 64
|
||||
pocket-base-upgrade-cost: 16
|
||||
pocket-base-currency-material: DIAMOND_BLOCK
|
||||
pocket-base-biome-change-cost: 16
|
||||
pocket-base-biome-currency-material: NETHERITE_BLOCK
|
||||
pocket-base-portal-frame-material: DIAMOND_BLOCK
|
||||
|
||||
# Spawnable block overlay
|
||||
spawnable-overlay-unlock-placements: 250
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ commands:
|
||||
aliases: [visit]
|
||||
basesettings:
|
||||
description: View progression, upgrade, and manage base settings.
|
||||
usage: /basesettings [status|upgrade|visitors|navigation|flight|border|spawnable|bossbar]
|
||||
usage: /basesettings [ui|status|upgrade|pocket|visitors|navigation|flight|border|spawnable|bossbar]
|
||||
aliases: [homesettings]
|
||||
baseadmin:
|
||||
description: Administer Spigot Base.
|
||||
|
||||
@@ -6,10 +6,12 @@ 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;
|
||||
@@ -18,12 +20,29 @@ import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.function.UnaryOperator;
|
||||
import org.bukkit.ChatColor;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.PlayerInventory;
|
||||
import net.kyori.adventure.dialog.DialogLike;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
final class BaseSettingsCommandTest {
|
||||
@Test
|
||||
void rejectsNonPlayerExecution() {
|
||||
CommandSender sender = mock(CommandSender.class);
|
||||
BaseSettingsCommand command = new BaseSettingsCommand(
|
||||
mock(BaseStateManager.class),
|
||||
new PluginSettingsProvider(PluginSettings.from(Map.of())),
|
||||
mock(BaseFlightController.class)
|
||||
);
|
||||
|
||||
command.onCommand(sender, null, "basesettings", new String[0]);
|
||||
|
||||
verify(sender).sendMessage("Only players have base progression.");
|
||||
}
|
||||
|
||||
@Test
|
||||
void spawnableEnableIsIdempotentAfterUnlock() {
|
||||
UUID playerId = UUID.randomUUID();
|
||||
@@ -89,6 +108,52 @@ final class BaseSettingsCommandTest {
|
||||
List.of("allowed"),
|
||||
command.onTabComplete(null, null, "basesettings", new String[] {"visitors", "a"})
|
||||
);
|
||||
assertEquals(
|
||||
List.of("upgrade"),
|
||||
command.onTabComplete(null, null, "basesettings", new String[] {"pocket", "u"})
|
||||
);
|
||||
assertEquals(
|
||||
List.of("mobs"),
|
||||
command.onTabComplete(null, null, "basesettings", new String[] {"pocket", "m"})
|
||||
);
|
||||
assertEquals(
|
||||
List.of("type"),
|
||||
command.onTabComplete(null, null, "basesettings", new String[] {"pocket", "t"})
|
||||
);
|
||||
assertEquals(
|
||||
List.of("nether"),
|
||||
command.onTabComplete(
|
||||
null, null, "basesettings", new String[] {"pocket", "type", "n"}
|
||||
)
|
||||
);
|
||||
assertEquals(
|
||||
List.of("crimson_forest"),
|
||||
command.onTabComplete(
|
||||
null,
|
||||
null,
|
||||
"basesettings",
|
||||
new String[] {"pocket", "type", "nether", "c"}
|
||||
)
|
||||
);
|
||||
assertEquals(
|
||||
List.of("hostile"),
|
||||
command.onTabComplete(
|
||||
null, null, "basesettings", new String[] {"pocket", "mobs", "h"}
|
||||
)
|
||||
);
|
||||
assertEquals(
|
||||
List.of("passive"),
|
||||
command.onTabComplete(
|
||||
null, null, "basesettings", new String[] {"pocket", "mobs", "p"}
|
||||
)
|
||||
);
|
||||
assertEquals(
|
||||
List.of("enable"),
|
||||
command.onTabComplete(
|
||||
null, null, "basesettings",
|
||||
new String[] {"pocket", "mobs", "hostile", "e"}
|
||||
)
|
||||
);
|
||||
for (String setting : List.of("navigation", "flight", "border", "spawnable", "bossbar")) {
|
||||
assertEquals(
|
||||
List.of("disable"),
|
||||
@@ -107,8 +172,8 @@ final class BaseSettingsCommandTest {
|
||||
|
||||
assertEquals(
|
||||
List.of(
|
||||
"status", "upgrade", "visitors", "navigation", "flight", "border",
|
||||
"spawnable", "bossbar"
|
||||
"ui", "status", "upgrade", "pocket", "visitors", "navigation", "flight",
|
||||
"border", "spawnable", "bossbar"
|
||||
),
|
||||
command.onTabComplete(null, null, "basesettings", new String[] {""})
|
||||
);
|
||||
@@ -121,10 +186,12 @@ final class BaseSettingsCommandTest {
|
||||
when(player.getUniqueId()).thenReturn(playerId);
|
||||
when(player.getName()).thenReturn("Host");
|
||||
PlayerInventory inventory = mock(PlayerInventory.class);
|
||||
ItemStack firstStack = item(Material.DIAMOND, 64);
|
||||
ItemStack secondStack = item(Material.DIAMOND, 64);
|
||||
when(player.getInventory()).thenReturn(inventory);
|
||||
when(inventory.getStorageContents()).thenReturn(new ItemStack[] {
|
||||
new ItemStack(Material.DIAMOND, 64),
|
||||
new ItemStack(Material.DIAMOND, 64)
|
||||
firstStack,
|
||||
secondStack
|
||||
});
|
||||
|
||||
PlayerState current = PlayerState.newPlayer(playerId, "Host")
|
||||
@@ -155,6 +222,276 @@ final class BaseSettingsCommandTest {
|
||||
assertTrue(updated.get().visitorsEnabled());
|
||||
}
|
||||
|
||||
@Test
|
||||
void pocketUpgradePurchasesFirstLevelAfterBaseIv() throws Exception {
|
||||
UUID playerId = UUID.randomUUID();
|
||||
Player player = mock(Player.class);
|
||||
when(player.getUniqueId()).thenReturn(playerId);
|
||||
when(player.getName()).thenReturn("Builder");
|
||||
PlayerInventory inventory = mock(PlayerInventory.class);
|
||||
ItemStack currency = item(Material.DIAMOND_BLOCK, 64);
|
||||
when(player.getInventory()).thenReturn(inventory);
|
||||
when(inventory.getStorageContents()).thenReturn(new ItemStack[] {currency});
|
||||
PlayerState owner = PlayerState.newPlayer(playerId, "Builder")
|
||||
.withAdministrativeLevels(4, 0, 0, 0, 0, false, false, true)
|
||||
.withBase(
|
||||
new BaseLocation(UUID.randomUUID(), "world", 0, 64, 0, 0, 0),
|
||||
Instant.EPOCH
|
||||
);
|
||||
BaseStateManager stateManager = mock(BaseStateManager.class);
|
||||
when(stateManager.player(playerId, "Builder")).thenReturn(owner);
|
||||
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
|
||||
when(pocketBases.state(playerId)).thenReturn(PocketBaseState.locked(playerId));
|
||||
when(pocketBases.upgrade(playerId)).thenReturn(
|
||||
new PocketBaseState(playerId, 1, java.util.Optional.empty())
|
||||
);
|
||||
BaseSettingsCommand command = new BaseSettingsCommand(
|
||||
stateManager,
|
||||
new PluginSettingsProvider(PluginSettings.from(Map.of())),
|
||||
mock(BaseFlightController.class),
|
||||
pocketBases
|
||||
);
|
||||
|
||||
command.onCommand(player, null, "basesettings", new String[] {"pocket", "upgrade"});
|
||||
|
||||
verify(pocketBases).upgrade(playerId);
|
||||
verify(inventory).setStorageContents(any(ItemStack[].class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void pocketOwnerCanPurchaseBiomeChangeWithConfiguredCurrency() throws Exception {
|
||||
UUID playerId = UUID.randomUUID();
|
||||
Player player = mock(Player.class);
|
||||
when(player.getUniqueId()).thenReturn(playerId);
|
||||
when(player.getName()).thenReturn("Builder");
|
||||
PlayerInventory inventory = mock(PlayerInventory.class);
|
||||
ItemStack currency = item(Material.NETHERITE_BLOCK, 16);
|
||||
when(player.getInventory()).thenReturn(inventory);
|
||||
when(inventory.getStorageContents()).thenReturn(new ItemStack[] {currency});
|
||||
BaseStateManager stateManager = mock(BaseStateManager.class);
|
||||
when(stateManager.player(playerId, "Builder"))
|
||||
.thenReturn(PlayerState.newPlayer(playerId, "Builder"));
|
||||
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
|
||||
PocketBaseState current = new PocketBaseState(playerId, 1, java.util.Optional.empty());
|
||||
when(pocketBases.state(playerId)).thenReturn(current);
|
||||
when(pocketBases.setBiome(playerId, PocketBaseBiome.CRIMSON_FOREST))
|
||||
.thenReturn(current.withBiome(PocketBaseBiome.CRIMSON_FOREST));
|
||||
BaseSettingsCommand command = new BaseSettingsCommand(
|
||||
stateManager,
|
||||
new PluginSettingsProvider(PluginSettings.from(Map.of())),
|
||||
mock(BaseFlightController.class),
|
||||
pocketBases
|
||||
);
|
||||
|
||||
command.onCommand(player, null, "basesettings", new String[] {
|
||||
"pocket", "type", "nether", "crimson_forest"
|
||||
});
|
||||
|
||||
verify(pocketBases).setBiome(playerId, PocketBaseBiome.CRIMSON_FOREST);
|
||||
verify(inventory).setStorageContents(any(ItemStack[].class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void selectingCurrentPocketBiomeDoesNotCharge() throws Exception {
|
||||
UUID playerId = UUID.randomUUID();
|
||||
Player player = mock(Player.class);
|
||||
when(player.getUniqueId()).thenReturn(playerId);
|
||||
when(player.getName()).thenReturn("Builder");
|
||||
PlayerInventory inventory = mock(PlayerInventory.class);
|
||||
when(player.getInventory()).thenReturn(inventory);
|
||||
BaseStateManager stateManager = mock(BaseStateManager.class);
|
||||
when(stateManager.player(playerId, "Builder"))
|
||||
.thenReturn(PlayerState.newPlayer(playerId, "Builder"));
|
||||
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
|
||||
when(pocketBases.state(playerId)).thenReturn(
|
||||
new PocketBaseState(playerId, 1, java.util.Optional.empty())
|
||||
);
|
||||
BaseSettingsCommand command = new BaseSettingsCommand(
|
||||
stateManager,
|
||||
new PluginSettingsProvider(PluginSettings.from(Map.of())),
|
||||
mock(BaseFlightController.class),
|
||||
pocketBases
|
||||
);
|
||||
|
||||
command.onCommand(player, null, "basesettings", new String[] {
|
||||
"pocket", "type", "void", "the_void"
|
||||
});
|
||||
|
||||
verify(pocketBases, never()).setBiome(
|
||||
org.mockito.ArgumentMatchers.eq(playerId),
|
||||
org.mockito.ArgumentMatchers.any(PocketBaseBiome.class)
|
||||
);
|
||||
verify(inventory, never()).setStorageContents(any(ItemStack[].class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void pocketOwnerCanEnableHostileMobSpawningWithoutChangingPassive() throws Exception {
|
||||
UUID playerId = UUID.randomUUID();
|
||||
Player player = mock(Player.class);
|
||||
when(player.getUniqueId()).thenReturn(playerId);
|
||||
when(player.getName()).thenReturn("Builder");
|
||||
BaseStateManager stateManager = mock(BaseStateManager.class);
|
||||
when(stateManager.player(playerId, "Builder"))
|
||||
.thenReturn(PlayerState.newPlayer(playerId, "Builder"));
|
||||
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
|
||||
PocketBaseState current = new PocketBaseState(
|
||||
playerId, 1, java.util.Optional.empty(), java.util.Optional.empty(),
|
||||
false, true, PocketBaseBiome.THE_VOID
|
||||
);
|
||||
when(pocketBases.state(playerId)).thenReturn(current);
|
||||
when(pocketBases.setHostileMobSpawning(playerId, true)).thenReturn(
|
||||
current.withHostileMobSpawningEnabled(true)
|
||||
);
|
||||
BaseSettingsCommand command = new BaseSettingsCommand(
|
||||
stateManager,
|
||||
new PluginSettingsProvider(PluginSettings.from(Map.of())),
|
||||
mock(BaseFlightController.class),
|
||||
pocketBases
|
||||
);
|
||||
|
||||
command.onCommand(player, null, "basesettings", new String[] {
|
||||
"pocket", "mobs", "hostile", "enable"
|
||||
});
|
||||
|
||||
verify(pocketBases).setHostileMobSpawning(playerId, true);
|
||||
verify(pocketBases, never()).setPassiveMobSpawning(playerId, true);
|
||||
verify(player).sendMessage(org.mockito.ArgumentMatchers.<String>argThat(
|
||||
message -> message.contains("hostile mob spawning") && message.contains("enabled")
|
||||
));
|
||||
}
|
||||
|
||||
@Test
|
||||
void failedPocketMobSpawningChangeReportsOnlyFailure() throws Exception {
|
||||
UUID playerId = UUID.randomUUID();
|
||||
Player player = mock(Player.class);
|
||||
when(player.getUniqueId()).thenReturn(playerId);
|
||||
when(player.getName()).thenReturn("Builder");
|
||||
BaseStateManager stateManager = mock(BaseStateManager.class);
|
||||
when(stateManager.player(playerId, "Builder"))
|
||||
.thenReturn(PlayerState.newPlayer(playerId, "Builder"));
|
||||
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
|
||||
when(pocketBases.state(playerId)).thenReturn(
|
||||
new PocketBaseState(playerId, 1, java.util.Optional.empty())
|
||||
);
|
||||
when(pocketBases.setPassiveMobSpawning(playerId, true))
|
||||
.thenThrow(new IOException("save failed"));
|
||||
BaseSettingsCommand command = new BaseSettingsCommand(
|
||||
stateManager,
|
||||
new PluginSettingsProvider(PluginSettings.from(Map.of())),
|
||||
mock(BaseFlightController.class),
|
||||
pocketBases
|
||||
);
|
||||
|
||||
command.onCommand(player, null, "basesettings", new String[] {
|
||||
"pocket", "mobs", "passive", "enable"
|
||||
});
|
||||
|
||||
verify(player).sendMessage(org.mockito.ArgumentMatchers.<String>argThat(
|
||||
message -> message.contains("could not be changed")
|
||||
));
|
||||
verify(player, never()).sendMessage(org.mockito.ArgumentMatchers.<String>argThat(
|
||||
message -> message.contains("is now")
|
||||
));
|
||||
}
|
||||
|
||||
@Test
|
||||
void lockedPlayerCannotEnablePocketMobSpawning() throws Exception {
|
||||
UUID playerId = UUID.randomUUID();
|
||||
Player player = mock(Player.class);
|
||||
when(player.getUniqueId()).thenReturn(playerId);
|
||||
when(player.getName()).thenReturn("Builder");
|
||||
BaseStateManager stateManager = mock(BaseStateManager.class);
|
||||
when(stateManager.player(playerId, "Builder"))
|
||||
.thenReturn(PlayerState.newPlayer(playerId, "Builder"));
|
||||
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
|
||||
when(pocketBases.state(playerId)).thenReturn(PocketBaseState.locked(playerId));
|
||||
BaseSettingsCommand command = new BaseSettingsCommand(
|
||||
stateManager,
|
||||
new PluginSettingsProvider(PluginSettings.from(Map.of())),
|
||||
mock(BaseFlightController.class),
|
||||
pocketBases
|
||||
);
|
||||
|
||||
command.onCommand(player, null, "basesettings", new String[] {
|
||||
"pocket", "mobs", "hostile", "enable"
|
||||
});
|
||||
|
||||
verify(pocketBases, never()).setHostileMobSpawning(playerId, true);
|
||||
verify(player).sendMessage(org.mockito.ArgumentMatchers.<String>argThat(
|
||||
message -> message.contains("Pocket Base I") && message.contains("locked")
|
||||
));
|
||||
}
|
||||
|
||||
@Test
|
||||
void failedPocketUpgradeRestoresPayment() throws Exception {
|
||||
UUID playerId = UUID.randomUUID();
|
||||
Player player = mock(Player.class);
|
||||
when(player.getUniqueId()).thenReturn(playerId);
|
||||
when(player.getName()).thenReturn("Builder");
|
||||
PlayerInventory inventory = mock(PlayerInventory.class);
|
||||
ItemStack currency = item(Material.DIAMOND_BLOCK, 64);
|
||||
when(player.getInventory()).thenReturn(inventory);
|
||||
when(inventory.getStorageContents()).thenReturn(new ItemStack[] {currency});
|
||||
PlayerState owner = PlayerState.newPlayer(playerId, "Builder")
|
||||
.withAdministrativeLevels(4, 0, 0, 0, 0, false, false, true)
|
||||
.withBase(
|
||||
new BaseLocation(UUID.randomUUID(), "world", 0, 64, 0, 0, 0),
|
||||
Instant.EPOCH
|
||||
);
|
||||
BaseStateManager stateManager = mock(BaseStateManager.class);
|
||||
when(stateManager.player(playerId, "Builder")).thenReturn(owner);
|
||||
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
|
||||
when(pocketBases.state(playerId)).thenReturn(PocketBaseState.locked(playerId));
|
||||
when(pocketBases.upgrade(playerId)).thenThrow(new IOException("save failed"));
|
||||
BaseSettingsCommand command = new BaseSettingsCommand(
|
||||
stateManager,
|
||||
new PluginSettingsProvider(PluginSettings.from(Map.of())),
|
||||
mock(BaseFlightController.class),
|
||||
pocketBases
|
||||
);
|
||||
|
||||
command.onCommand(player, null, "basesettings", new String[] {"pocket", "upgrade"});
|
||||
|
||||
ArgumentCaptor<ItemStack[]> contents = ArgumentCaptor.forClass(ItemStack[].class);
|
||||
verify(inventory, times(2)).setStorageContents(contents.capture());
|
||||
ItemStack[] restored = contents.getAllValues().get(1);
|
||||
assertEquals(Material.DIAMOND_BLOCK, restored[0].getType());
|
||||
assertEquals(64, restored[0].getAmount());
|
||||
}
|
||||
|
||||
@Test
|
||||
void 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")
|
||||
));
|
||||
}
|
||||
|
||||
@Test
|
||||
void bossbarEnableIsIdempotent() {
|
||||
UUID playerId = UUID.randomUUID();
|
||||
@@ -204,7 +541,7 @@ final class BaseSettingsCommandTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void defaultAndStatusDisplayTheFullProgressReport() {
|
||||
void defaultAndUiOpenDialogWhileStatusDisplaysTheFullProgressReport() {
|
||||
UUID playerId = UUID.randomUUID();
|
||||
Player player = mock(Player.class);
|
||||
when(player.getUniqueId()).thenReturn(playerId);
|
||||
@@ -216,21 +553,25 @@ final class BaseSettingsCommandTest {
|
||||
BaseSettingsCommand command = new BaseSettingsCommand(
|
||||
stateManager,
|
||||
new PluginSettingsProvider(PluginSettings.from(Map.of())),
|
||||
mock(BaseFlightController.class)
|
||||
mock(BaseFlightController.class),
|
||||
null,
|
||||
(owner, pocket) -> null
|
||||
);
|
||||
|
||||
command.onCommand(player, null, "basesettings", new String[0]);
|
||||
command.onCommand(player, null, "homesettings", new String[] {"ui"});
|
||||
command.onCommand(player, null, "basesettings", new String[] {"status"});
|
||||
|
||||
verify(player, times(2)).sendMessage(ChatColor.GOLD + "=== Base Progress ===");
|
||||
verify(player, times(2)).sendMessage(
|
||||
verify(player, times(2)).showDialog((DialogLike) null);
|
||||
verify(player).sendMessage(ChatColor.GOLD + "=== Base Progress ===");
|
||||
verify(player).sendMessage(
|
||||
org.mockito.ArgumentMatchers.<String>argThat(message ->
|
||||
message.contains("Spawnable Overlay:")
|
||||
&& message.contains("0/250 placements")
|
||||
&& message.contains("locked")
|
||||
)
|
||||
);
|
||||
verify(player, times(2)).sendMessage(
|
||||
verify(player).sendMessage(
|
||||
org.mockito.ArgumentMatchers.<String>argThat(message ->
|
||||
message.contains("Settings: visitors=")
|
||||
&& message.contains("navigation=")
|
||||
@@ -242,6 +583,18 @@ final class BaseSettingsCommandTest {
|
||||
);
|
||||
}
|
||||
|
||||
private static ItemStack item(Material material, int amount) {
|
||||
ItemStack item = mock(ItemStack.class);
|
||||
ItemStack copy = mock(ItemStack.class);
|
||||
when(item.getType()).thenReturn(material);
|
||||
when(item.getAmount()).thenReturn(amount);
|
||||
when(item.clone()).thenReturn(copy);
|
||||
when(copy.getType()).thenReturn(material);
|
||||
when(copy.getAmount()).thenReturn(amount);
|
||||
when(copy.clone()).thenReturn(copy);
|
||||
return item;
|
||||
}
|
||||
|
||||
private static CommandResult execute(PlayerState current, String... arguments) {
|
||||
Player player = mock(Player.class);
|
||||
when(player.getUniqueId()).thenReturn(current.playerId());
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
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 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
|
||||
);
|
||||
|
||||
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"));
|
||||
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 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,64 @@
|
||||
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 {
|
||||
@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);
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,31 @@ final class PluginSettingsTest {
|
||||
assertEquals(250, settings.spawnableOverlayUnlockPlacements());
|
||||
assertEquals(10_800L, settings.initialTeleportCooldownSeconds());
|
||||
assertEquals(128, settings.visitorUnlockDiamondCost());
|
||||
assertEquals(64, settings.pocketBaseUnlockCost());
|
||||
assertEquals(16, settings.pocketBaseUpgradeCost());
|
||||
assertEquals("DIAMOND_BLOCK", settings.pocketBaseCurrencyMaterial());
|
||||
assertEquals("DIAMOND_BLOCK", settings.pocketBasePortalFrameMaterial());
|
||||
assertEquals(16, settings.pocketBaseBiomeChangeCost());
|
||||
assertEquals("NETHERITE_BLOCK", settings.pocketBaseBiomeCurrencyMaterial());
|
||||
}
|
||||
|
||||
@Test
|
||||
void pocketBaseCostsAndMaterialsAreConfigurable() {
|
||||
PluginSettings settings = PluginSettings.from(Map.of(
|
||||
"pocket-base-unlock-cost", 32,
|
||||
"pocket-base-upgrade-cost", 8,
|
||||
"pocket-base-currency-material", "EMERALD_BLOCK",
|
||||
"pocket-base-portal-frame-material", "GOLD_BLOCK",
|
||||
"pocket-base-biome-change-cost", 12,
|
||||
"pocket-base-biome-currency-material", "NETHERITE_INGOT"
|
||||
));
|
||||
|
||||
assertEquals(32, settings.pocketBaseUnlockCost());
|
||||
assertEquals(8, settings.pocketBaseUpgradeCost());
|
||||
assertEquals("EMERALD_BLOCK", settings.pocketBaseCurrencyMaterial());
|
||||
assertEquals("GOLD_BLOCK", settings.pocketBasePortalFrameMaterial());
|
||||
assertEquals(12, settings.pocketBaseBiomeChangeCost());
|
||||
assertEquals("NETHERITE_INGOT", settings.pocketBaseBiomeCurrencyMaterial());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -0,0 +1,438 @@
|
||||
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.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 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 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 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,169 @@
|
||||
package games.dmg.spigotbase;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
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 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());
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,17 @@ final class TeleportPolicyTest {
|
||||
assertEquals(Duration.ZERO, policy.warmup(player.withTeleportLevels(3, 0)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void visitorWarmupHasOneSecondMinimum() {
|
||||
PlayerState player = baseThreePlayer();
|
||||
|
||||
assertEquals(Duration.ofSeconds(30), policy.visitorWarmup(player));
|
||||
assertEquals(
|
||||
Duration.ofSeconds(1),
|
||||
policy.visitorWarmup(player.withTeleportLevels(3, 0))
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void cooldownLevelsUseApprovedDurations() {
|
||||
PlayerState player = baseThreePlayer();
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package games.dmg.spigotbase;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
final class YamlPocketBaseRepositoryTest {
|
||||
@TempDir
|
||||
Path temporaryDirectory;
|
||||
|
||||
@Test
|
||||
void roundTripsLevelsAndActiveEntrances() throws Exception {
|
||||
UUID ownerId = UUID.randomUUID();
|
||||
UUID worldId = UUID.randomUUID();
|
||||
PocketBaseState expected = new PocketBaseState(
|
||||
ownerId,
|
||||
7,
|
||||
Optional.of(new PocketPortalLocation(
|
||||
worldId, "world", 10, 65, -4, PocketPortalAxis.X
|
||||
)),
|
||||
Optional.of(new PocketPortalLocation(
|
||||
worldId, "pocket", 20, 65, 12, PocketPortalAxis.Z
|
||||
)),
|
||||
true,
|
||||
false,
|
||||
PocketBaseBiome.CRIMSON_FOREST
|
||||
);
|
||||
YamlPocketBaseRepository repository = new YamlPocketBaseRepository(
|
||||
temporaryDirectory.resolve("pocket-bases.yml")
|
||||
);
|
||||
|
||||
repository.save(Map.of(ownerId, expected));
|
||||
|
||||
assertEquals(expected, repository.load().get(ownerId));
|
||||
}
|
||||
|
||||
@Test
|
||||
void existingRecordWithoutMobPreferenceDefaultsToDisabled() throws Exception {
|
||||
UUID ownerId = UUID.randomUUID();
|
||||
Path stateFile = temporaryDirectory.resolve("pocket-bases.yml");
|
||||
Files.writeString(stateFile, "owners:\n " + ownerId + ":\n level: 1\n");
|
||||
YamlPocketBaseRepository repository = new YamlPocketBaseRepository(stateFile);
|
||||
|
||||
PocketBaseState loaded = repository.load().get(ownerId);
|
||||
|
||||
assertFalse(loaded.hostileMobSpawningEnabled());
|
||||
assertFalse(loaded.passiveMobSpawningEnabled());
|
||||
assertTrue(loaded.returnPortal().isEmpty());
|
||||
assertEquals(PocketBaseBiome.THE_VOID, loaded.biome());
|
||||
}
|
||||
|
||||
@Test
|
||||
void migratesLegacyMobPreferenceToBothCategories() throws Exception {
|
||||
UUID ownerId = UUID.randomUUID();
|
||||
Path stateFile = temporaryDirectory.resolve("pocket-bases.yml");
|
||||
Files.writeString(stateFile, "owners:\n " + ownerId
|
||||
+ ":\n level: 1\n mob-spawning-enabled: true\n");
|
||||
YamlPocketBaseRepository repository = new YamlPocketBaseRepository(stateFile);
|
||||
|
||||
PocketBaseState loaded = repository.load().get(ownerId);
|
||||
|
||||
assertTrue(loaded.hostileMobSpawningEnabled());
|
||||
assertTrue(loaded.passiveMobSpawningEnabled());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user