Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
745d09e5a6 | ||
|
|
1ad930c7ad | ||
|
|
28dd5422d5 | ||
|
|
ecb07d48ad | ||
|
|
c42ba6888a | ||
|
|
61e7a01374 | ||
|
|
e0b1a25c5e | ||
|
|
f4511f9286 | ||
|
|
02c6698268 | ||
|
|
bc2f9d8e25 | ||
|
|
8b3eaad253 | ||
|
|
29413d36b3 | ||
|
|
b6bbf9183a | ||
|
|
b2ba4d5821 | ||
|
|
6ea5bc16f4 | ||
|
|
4518478ea1 |
@@ -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
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# spigot-base agent entrypoint
|
||||
|
||||
The canonical stories, engineering guidance, and **all process documents** are in the private [SoMC OKF wiki](https://git.garvis.dev/dmg/somc-okf/src/branch/main/index.md).
|
||||
|
||||
Before work, read the sibling `../somc-okf/index.md`, `../somc-okf/processes/index.md`, `../somc-okf/projects/spigot-base/index.md`, `engineering.md` in that project section, and relevant `../somc-okf/user-stories/spigot-base/` stories. Also follow the parent workspace `AGENTS.md` when present.
|
||||
|
||||
For standalone checkouts, start at the [project page](https://git.garvis.dev/dmg/somc-okf/src/branch/main/projects/spigot-base/index.md) and [shared process](https://git.garvis.dev/dmg/somc-okf/src/branch/main/processes/development.md). Obtain wiki access before feature work; do not recreate a local knowledge bundle. Source builds do not require private wiki access.
|
||||
|
||||
Development follows [Development cycle](https://git.garvis.dev/dmg/somc-okf/src/branch/main/runbooks/development-cycle.md): approved stories, failing tests, passing implementation, verification, then source/wiki commit and push. GitOps updates are committed locally **without pushing**; only [Do release](https://git.garvis.dev/dmg/somc-okf/src/branch/main/runbooks/do-release.md) authorizes a reviewed GitOps push.
|
||||
@@ -1,15 +1,15 @@
|
||||
# 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.
|
||||
|
||||
The approved behavior is specified in the [OKF design bundle](design/index.md).
|
||||
The approved behavior is specified in the [SoMC OKF wiki](https://git.garvis.dev/dmg/somc-okf/src/branch/main/projects/spigot-base/index.md).
|
||||
|
||||
## Requirements
|
||||
|
||||
- Spigot 26.2
|
||||
- Java 17 or newer
|
||||
- Purpur 26.2 build 2618
|
||||
- Java 25 or newer
|
||||
|
||||
## Build
|
||||
|
||||
@@ -26,10 +26,13 @@ The plugin JAR is written to `build/libs/`.
|
||||
/base (alias: /home)
|
||||
/gotobase <player> (alias: /visit <player>)
|
||||
/basesettings (alias: /homesettings)
|
||||
/basesettings ui
|
||||
/basesettings status
|
||||
/basesettings upgrade
|
||||
/basesettings pocket upgrade
|
||||
/basesettings pocket mobs <enable|disable>
|
||||
/basesettings pocket flight <enable|disable>
|
||||
/basesettings pocket mobs <hostile|passive> <enable|disable>
|
||||
/basesettings pocket type <void|nether|overworld> <subtype>
|
||||
/basesettings visitors <allowed|blocked>
|
||||
/basesettings navigation <enable|disable>
|
||||
/basesettings flight <enable|disable>
|
||||
@@ -38,13 +41,19 @@ The plugin JAR is written to `build/libs/`.
|
||||
/basesettings bossbar <enable|disable>
|
||||
```
|
||||
|
||||
`/basesettings` and `/basesettings ui` open Minecraft's native dialog dashboard. The dashboard summarizes Base and Pocket Base progression, provides unlocked setting controls and purchase confirmations, marks unavailable controls as locked, and provides Back buttons from nested screens. `/basesettings status` retains the detailed chat report.
|
||||
|
||||
Navigation particles appear only in the base's world and when the player is more than 25 blocks beyond the current base border.
|
||||
|
||||
After 250 Survival-mode block placements anywhere by default, the spawnable overlay can mark nearby dark hostile-mob spawning surfaces inside the player's base with owner-only red particles. The threshold is configurable.
|
||||
|
||||
`/base` has a stationary warm-up. Looking around is allowed, while movement between blocks, damage, teleportation, world changes, death, logout, and conflicting teleport commands cancel it without consuming the cooldown.
|
||||
`/base` has a stationary warm-up. Looking around is allowed, while movement between blocks, damage, teleportation, world changes, death, logout, and conflicting teleport commands cancel it without consuming the cooldown. Home and visitor warm-ups surround the player with nearby-visible floating particles that grow denser as departure approaches. Successful warmed-up teleports leave a cloud at the departure point that thins out over two seconds; cancellation or failure stops emission without a departure cloud.
|
||||
|
||||
Base IV owners can purchase and expand a persistent Pocket Base with `/basesettings pocket upgrade`. Activating a complete diamond-block portal frame inside the normal base with flint and steel opens one public entrance to the owner's grass platform in a private void world. Natural hostile and passive mob spawning is disabled by default and can be controlled by the owner with `/basesettings pocket mobs <enable|disable>`.
|
||||
Base IV owners can purchase and expand a persistent Pocket Base with `/basesettings pocket upgrade`. Activating a complete diamond-block portal frame inside the normal base with flint and steel opens one public entrance to the owner's grass platform in a private void world. Players and non-player living mobs can travel through the entrance and active return portal; items, projectiles, and vehicles are not transported. The owner can move the return portal by building and igniting another complete frame inside the unlocked Pocket Base boundary; only the newly activated return portal remains functional. Natural hostile and passive mob spawning is disabled by default. Owners can control each category independently with `/basesettings pocket mobs <hostile|passive> <enable|disable>`. An owner who wins a raid inside their own Pocket Base permanently unlocks owner-only Survival flight across the full build height and within the current Pocket Base boundary plus a 16-block warning buffer on every side. The owner can control that automatic privilege with `/basesettings pocket flight <enable|disable>`. After the raid unlock, the owner can ignite a complete gold-block portal frame inside the unlocked boundary; any Survival player who passes through receives temporary flight within the same boundary and buffer until leaving the Pocket Base. Entering the flight portal immediately starts the player flying and displays “Pocket dimension flight enabled!” once, without repeating while they stand inside the portal. The owner's automatic-flight preference does not affect portal-granted flight.
|
||||
|
||||
Pocket Base owners can also craft a reusable **Pocket Base Keystone**. Its shaped recipe uses an Ender Pearl in the center, Netherite Ingots on the four cardinal slots, and Obsidian in the four corners. Right-clicking the keystone outside a Pocket Base instantly records the player's location and takes them to their own Pocket Base. Right-clicking it again inside their own Pocket Base returns them to that exact location when the world and destination remain available and safe. Saved returns survive reconnects and server restarts. A transferred keystone only works for a player who has unlocked their own Pocket Base.
|
||||
|
||||
Owners can change biome metadata without altering Pocket Base blocks, entities, inventories, or portals by using `/basesettings pocket type <void|nether|overworld> <subtype>`. Void uses `the_void`; Nether and Overworld accept their compatible vanilla biome names, such as `crimson_forest` or `plains`. Each change defaults to 16 netherite blocks, is configurable, and affects applicable natural mob selection when spawning is enabled. The Pocket Base remains a void-generated normal-environment world; dimension-specific mechanics do not change.
|
||||
|
||||
## Administration
|
||||
|
||||
|
||||
+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")
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
---
|
||||
type: Index
|
||||
title: Spigot Base Design
|
||||
description: Entry point for the Spigot Base OKF knowledge bundle.
|
||||
okf_version: "0.1"
|
||||
---
|
||||
|
||||
# Spigot Base Design
|
||||
|
||||
This bundle documents the progression-gated quality-of-life features, player bases, flight, teleportation, administration, persistence, and delivery requirements for the Spigot Base plugin.
|
||||
|
||||
## Explore
|
||||
|
||||
- [User stories](user-stories/index.md)
|
||||
- [Design log](log.md)
|
||||
-100
@@ -1,100 +0,0 @@
|
||||
---
|
||||
type: Log
|
||||
title: Spigot Base Design Log
|
||||
description: Chronological record of material decisions affecting the Spigot Base design.
|
||||
---
|
||||
|
||||
# Spigot Base Design Log
|
||||
|
||||
## 2026-08-09 — Initial progression design
|
||||
|
||||
- Base progression is sequential: Base I establishes a base, Base II provides navigation, Base III provides personal teleportation, and Base IV permits visitor teleportation.
|
||||
- Every secondary progression path requires Base I.
|
||||
- A base is cylindrical and begins with a 10-block radius and a vertical extent 25 blocks above and below its set Y coordinate.
|
||||
- Only Survival-mode activity contributes progress; repeatedly placing and breaking blocks is permitted.
|
||||
- Flight is limited to the base, includes a five-block horizontal warning buffer, and can be toggled by the player.
|
||||
- Player-facing administration will initially use commands rather than an inventory UI.
|
||||
- The build and release pipeline will follow the neighboring Trigger Spawn project pattern.
|
||||
|
||||
## 2026-08-09 — Implementation started
|
||||
|
||||
- Approved implementation begins with the tested Gradle/Spigot foundation, validated configuration, and durable player state.
|
||||
- Feature work will proceed incrementally using tests before implementation where practical.
|
||||
|
||||
## 2026-08-09 — Core implementation checkpoint
|
||||
|
||||
- Implemented UUID-keyed YAML state, validated configuration, Base I–IV progression, base sizing, particle navigation, controlled base flight, personal and visitor teleportation, progress feedback, and initial administrative commands.
|
||||
- Added Gradle, Spigot metadata, Gitea CI/release workflows, and project documentation based on Trigger Spawn.
|
||||
- Verified the implementation with `./gradlew clean check jar`: 34 tests passed and the plugin JAR was produced successfully.
|
||||
- User stories remain in progress pending live-server integration verification and completion of runtime administrative configuration editing.
|
||||
|
||||
## 2026-08-09 — Navigation preference fix
|
||||
|
||||
- Added explicit, idempotent `/basenavigation on` and `/basenavigation off` modes with autocomplete while retaining no-argument toggling.
|
||||
- Preserved a disabled navigation preference during later grass-or-dirt progression instead of forcing navigation back on.
|
||||
|
||||
## 2026-08-09 — Initial release scope completed
|
||||
|
||||
- Added live, validated, persisted numeric configuration updates through `/baseadmin config`.
|
||||
- Added administrative progress-counter updates, prerequisite-aware level changes, cooldown clearing, and cascading path resets.
|
||||
- Made progression materials, visitor currency, boss-bar duration, particle count, and title timing configurable.
|
||||
- Preserved unknown forward-compatible YAML fields for retained player records while continuing to reject invalid progression state.
|
||||
- Completed and verified all approved user stories with the automated Gradle check lifecycle.
|
||||
|
||||
## 2026-08-10 — Player command aliases
|
||||
|
||||
- Added `/sethome` for `/setbase`, `/home` for `/base`, and `/visit` for `/gotobase`.
|
||||
- Visitor autocomplete remains available for eligible offline bases through the `/visit` alias.
|
||||
- Verified the aliases and plugin build with `./gradlew clean check jar`.
|
||||
|
||||
## 2026-08-10 — Toggle command autocomplete
|
||||
|
||||
- Added explicit, idempotent `on` and `off` modes and autocomplete to base flight and visitor access while retaining no-argument toggling.
|
||||
- Added `/homenavigation`, `/homeflight`, and `/homevisitors` aliases with the same autocomplete as their canonical commands.
|
||||
- Generalized toggle preference resolution for navigation, flight, and visitor access.
|
||||
- Verified the implementation with `./gradlew clean check jar`.
|
||||
|
||||
## 2026-08-10 — Unified base settings command
|
||||
|
||||
- Replaced the separate progress, navigation, flight, and visitor commands with `/basesettings` and its `/homesettings` alias.
|
||||
- Added nested autocomplete for status, Base IV upgrades, visitor access, navigation, flight, and progress boss-bar settings.
|
||||
- Moved the Base IV purchase from `/base upgrade` to `/basesettings upgrade`, leaving `/base` and `/home` dedicated to personal teleportation.
|
||||
- Preserved the full progression report as the default `/basesettings` response and as `/basesettings status`.
|
||||
- Verified the breaking command restructure with `./gradlew clean check jar`.
|
||||
|
||||
## 2026-08-10 — Administrative command autocomplete and reset safety
|
||||
|
||||
- Added `/homeadmin` as an alias for `/baseadmin` and replaced the administrative `progress` subcommand with `status`.
|
||||
- Added permission-aware, context-sensitive autocomplete for subcommands, online and known offline players, paths, levels, counters, cooldown types, reset confirmation, and numeric configuration keys.
|
||||
- Removed the unsafe unconfirmed base reset path; complete resets now require `reset <player> all confirm`.
|
||||
- Tightened argument validation and replaced implementation-specific enum errors with player-facing usage guidance.
|
||||
- Verified the administrative revision with `./gradlew clean check jar`.
|
||||
|
||||
## 2026-08-10 — Base border visualization
|
||||
|
||||
- Added persisted, owner-only base border particles controlled by `/basesettings border enable|disable` with contextual autocomplete.
|
||||
- Rendered a bounded nearby arc at the player's current height using the current base radius while respecting world and vertical bounds.
|
||||
- Added border preference reporting to player and administrative status output and safe defaults for existing state files.
|
||||
- Verified the feature with `./gradlew clean check jar`.
|
||||
|
||||
## 2026-08-10 — Spawnable block overlay
|
||||
|
||||
- Added persistent progression for Survival-mode block placements anywhere, with existing in-base placement counts used as a safe migration minimum.
|
||||
- Added `/basesettings spawnable enable|disable` after a configurable 250-placement unlock and reported its progress and preference in player and admin status output.
|
||||
- Added admin controls for total placement progress, the unlock threshold, and each unlocked player's overlay preference.
|
||||
- Added owner-only red particles for nearby dark spawning surfaces inside base bounds, using bounded incremental scanning and rendering budgets.
|
||||
- Verified the feature with `./gradlew clean check jar`.
|
||||
|
||||
## 2026-08-10 — Navigation visibility buffer
|
||||
|
||||
- Limited enabled base navigation particles to the base's world and positions more than 25 horizontal blocks beyond the current base border.
|
||||
- Used the live size-tier radius so expansion and relocation immediately update the visibility threshold.
|
||||
- Added exact-boundary and controller integration coverage.
|
||||
- Verified the change with `./gradlew clean check jar`.
|
||||
|
||||
## 2026-08-24 — Pocket Base mob-spawning control
|
||||
|
||||
- Disabled natural hostile and passive mob spawning by default in new and existing Pocket Bases without a saved preference.
|
||||
- Added the persisted owner setting `/basesettings pocket mobs enable|disable`, contextual autocomplete, and status reporting.
|
||||
- Reapplied saved spawn flags when Pocket Base worlds load and restored the prior world setting when persistence fails.
|
||||
- Verified the feature with `./gradlew clean check jar`.
|
||||
@@ -1,26 +0,0 @@
|
||||
---
|
||||
type: Index
|
||||
title: Spigot Base User Stories
|
||||
description: Catalog of user stories for the Spigot Base plugin.
|
||||
---
|
||||
|
||||
# Spigot Base User Stories
|
||||
|
||||
1. [US-001: Unlock and establish Base I](us-001-unlock-and-establish-base.md)
|
||||
2. [US-002: Unlock Base II navigation](us-002-unlock-base-navigation.md)
|
||||
3. [US-003: Expand the base](us-003-expand-the-base.md)
|
||||
4. [US-004: Unlock and control base flight](us-004-unlock-and-control-base-flight.md)
|
||||
5. [US-005: Unlock Base III teleportation](us-005-unlock-base-teleportation.md)
|
||||
6. [US-006: Reduce the base teleport warm-up](us-006-reduce-teleport-warmup.md)
|
||||
7. [US-007: Reduce the base teleport cooldown](us-007-reduce-teleport-cooldown.md)
|
||||
8. [US-008: Unlock Base IV visitor access](us-008-unlock-visitor-access.md)
|
||||
9. [US-009: View progression and unlock notifications](us-009-view-progression-and-notifications.md)
|
||||
10. [US-010: Administer player progression](us-010-administer-player-progression.md)
|
||||
11. [US-011: Configure and persist progression](us-011-configure-and-persist-progression.md)
|
||||
12. [US-012: Build and release the plugin](us-012-build-and-release-plugin.md)
|
||||
13. [US-013: Visualize the base border](us-013-visualize-base-border.md)
|
||||
14. [US-014: Highlight spawnable blocks](us-014-highlight-spawnable-blocks.md)
|
||||
15. [US-015: Unlock a Pocket Base](us-015-unlock-a-pocket-base.md)
|
||||
16. [US-016: Build and use Pocket Base portals](us-016-build-and-use-pocket-base-portals.md)
|
||||
17. [US-017: Expand a Pocket Base](us-017-expand-a-pocket-base.md)
|
||||
18. [US-018: Control Pocket Base mob spawning](us-018-control-pocket-base-mob-spawning.md)
|
||||
@@ -1,33 +0,0 @@
|
||||
---
|
||||
type: User Story
|
||||
title: "US-001: Unlock and establish Base I"
|
||||
description: Let players earn and establish a persistent personal base by breaking dirt and grass blocks.
|
||||
status: done
|
||||
---
|
||||
|
||||
# US-001: Unlock and establish Base I
|
||||
|
||||
As a **player**, I want to earn and set a personal base so that later quality-of-life progression has a location associated with it.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] Only blocks broken while the player is in Survival mode contribute progression.
|
||||
- [x] Breaking `GRASS_BLOCK` or `DIRT` contributes one unit of Base I progress.
|
||||
- [x] Other dirt-like materials, including coarse dirt, rooted dirt, podzol, mycelium, and dirt paths, do not contribute by default.
|
||||
- [x] Player-placed blocks may contribute when broken; natural-generation detection is not required.
|
||||
- [x] Base I unlocks when the player reaches the configured threshold, which defaults to 250 qualifying blocks.
|
||||
- [x] `/setbase` is unavailable before Base I and explains the unmet requirement.
|
||||
- [x] `/sethome` is an alias for `/setbase` with identical behavior.
|
||||
- [x] After Base I unlocks, `/setbase` records the player's current world and block location as the center of a cylindrical base.
|
||||
- [x] The initial cylinder has a configurable 10-block horizontal radius and extends a configurable 25 blocks above and 25 blocks below the set Y coordinate.
|
||||
- [x] The first successful `/setbase` is immediately available.
|
||||
- [x] A successful relocation starts a configurable elapsed-time cooldown that defaults to 24 hours.
|
||||
- [x] Failed attempts do not start or extend the relocation cooldown.
|
||||
- [x] Progress and base state are associated with the player's UUID and survive restarts.
|
||||
|
||||
## Related
|
||||
|
||||
- [US-002: Unlock Base II navigation](us-002-unlock-base-navigation.md)
|
||||
- [US-003: Expand the base](us-003-expand-the-base.md)
|
||||
- [US-004: Unlock and control base flight](us-004-unlock-and-control-base-flight.md)
|
||||
- [US-005: Unlock Base III teleportation](us-005-unlock-base-teleportation.md)
|
||||
@@ -1,30 +0,0 @@
|
||||
---
|
||||
type: User Story
|
||||
title: "US-002: Unlock Base II navigation"
|
||||
description: Let players earn toggleable particle guidance toward their established base.
|
||||
status: done
|
||||
---
|
||||
|
||||
# US-002: Unlock Base II navigation
|
||||
|
||||
As a **player with Base I**, I want visual guidance toward my base so that I can find it without relying on coordinates.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] Base II requires Base I and an established base.
|
||||
- [x] Base II unlocks at a configurable cumulative grass-or-dirt threshold that defaults to 500 blocks, 250 more than Base I.
|
||||
- [x] Base II provides particle-based navigation and does not grant or require a physical compass item.
|
||||
- [x] While enabled and in the base's world, particles are drawn along the ground only when the player is horizontally more than 25 blocks beyond the current base border.
|
||||
- [x] No navigation particles appear inside the base, exactly 25 blocks beyond its border, or anywhere within that buffer.
|
||||
- [x] Expansion and relocation update the navigation visibility threshold immediately.
|
||||
- [x] Particle generation is bounded to avoid excessive server or client load.
|
||||
- [x] A player in another world receives a clear message instead of a misleading particle direction.
|
||||
- [x] `/basesettings navigation enable` enables guidance idempotently after Base II is unlocked, and `/basesettings navigation disable` disables it idempotently.
|
||||
- [x] Invalid navigation arguments show command usage, and `enable` and `disable` are offered through autocomplete.
|
||||
- [x] Grass-or-dirt progress after Base II preserves the player's selected navigation preference.
|
||||
- [x] The navigation preference persists across reconnects and restarts.
|
||||
|
||||
## Related
|
||||
|
||||
- [US-001: Unlock and establish Base I](us-001-unlock-and-establish-base.md)
|
||||
- [US-009: View progression and unlock notifications](us-009-view-progression-and-notifications.md)
|
||||
@@ -1,29 +0,0 @@
|
||||
---
|
||||
type: User Story
|
||||
title: "US-003: Expand the base"
|
||||
description: Let players expand their base radius through sequential stone, deepslate, and obsidian mining milestones.
|
||||
status: done
|
||||
---
|
||||
|
||||
# US-003: Expand the base
|
||||
|
||||
As a **player with Base I**, I want mining milestones to expand my base so that more of my build receives base benefits.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] Base-size progression is unavailable until Base I is unlocked.
|
||||
- [x] Only qualifying blocks broken in Survival mode count.
|
||||
- [x] The first expansion requires a configurable 500 `STONE` and changes the radius from 10 to 25 blocks by default.
|
||||
- [x] After the stone tier, the second expansion requires a separate configurable 1,000 `DEEPSLATE` and changes the radius from 25 to 75 blocks by default.
|
||||
- [x] After the deepslate tier, the third expansion requires a separate configurable 1,000 `OBSIDIAN` and changes the radius from 75 to 150 blocks by default.
|
||||
- [x] Stone, deepslate, and obsidian variants do not count by default.
|
||||
- [x] Materials mined before their sequential tier becomes active do not count toward that later tier.
|
||||
- [x] Player-placed qualifying blocks may be mined repeatedly for progress.
|
||||
- [x] Expansions affect the cylinder's horizontal radius without independently changing its vertical bounds.
|
||||
- [x] Relocating the base preserves earned size tiers and counters.
|
||||
|
||||
## Related
|
||||
|
||||
- [US-001: Unlock and establish Base I](us-001-unlock-and-establish-base.md)
|
||||
- [US-004: Unlock and control base flight](us-004-unlock-and-control-base-flight.md)
|
||||
- [US-011: Configure and persist progression](us-011-configure-and-persist-progression.md)
|
||||
@@ -1,36 +0,0 @@
|
||||
---
|
||||
type: User Story
|
||||
title: "US-004: Unlock and control base flight"
|
||||
description: Let players permanently unlock and toggle increasingly broad flight within their personal base.
|
||||
status: done
|
||||
---
|
||||
|
||||
# US-004: Unlock and control base flight
|
||||
|
||||
As a **player with Base I**, I want to unlock controlled flight around my base so that I can build large structures more conveniently.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] Flight progression is unavailable until Base I is unlocked and a base is established.
|
||||
- [x] The plugin observes elytra held directly in the player's inventory and armor equipment while the player is in Survival mode.
|
||||
- [x] Elytra inside shulker boxes, bundles, or other nested containers do not count.
|
||||
- [x] Observing one elytra permanently unlocks Flight I, two simultaneous elytra unlock Flight II, and three simultaneous elytra unlock Flight III.
|
||||
- [x] A player observed with enough elytra for a later tier receives all unmet preceding flight tiers.
|
||||
- [x] Elytra are not consumed, and losing them later does not revoke an earned tier.
|
||||
- [x] Flight I permits plugin-granted flight within the current horizontal base radius and from 25 blocks below through 25 blocks above base Y by default.
|
||||
- [x] Flight II expands the vertical range to 100 blocks below and above base Y by default.
|
||||
- [x] Flight III expands the vertical range to the world's minimum and maximum build heights.
|
||||
- [x] A configurable five-block horizontal warning buffer extends beyond the current base radius.
|
||||
- [x] Plugin-granted flight remains active in the warning buffer, and a prominent on-screen notice that the player is leaving the base appears only while the player is actively flying.
|
||||
- [x] Passing beyond the warning buffer removes only flight granted by this plugin.
|
||||
- [x] Flight is not granted outside the unlocked vertical range.
|
||||
- [x] `/basesettings flight enable` enables unlocked flight idempotently, and `/basesettings flight disable` disables it idempotently.
|
||||
- [x] Invalid flight arguments show command usage, and `enable` and `disable` are offered through autocomplete.
|
||||
- [x] The flight toggle persists across reconnects and restarts.
|
||||
- [x] The plugin handles teleportation, world changes, game-mode changes, death, logout, and plugin shutdown without leaving unintended flight enabled.
|
||||
|
||||
## Related
|
||||
|
||||
- [US-001: Unlock and establish Base I](us-001-unlock-and-establish-base.md)
|
||||
- [US-003: Expand the base](us-003-expand-the-base.md)
|
||||
- [US-009: View progression and unlock notifications](us-009-view-progression-and-notifications.md)
|
||||
@@ -1,33 +0,0 @@
|
||||
---
|
||||
type: User Story
|
||||
title: "US-005: Unlock Base III teleportation"
|
||||
description: Let players earn a safe, stationary-warm-up teleport to their personal base.
|
||||
status: done
|
||||
---
|
||||
|
||||
# US-005: Unlock Base III teleportation
|
||||
|
||||
As a **player with Base II**, I want to earn `/base` so that I can return safely to my established base.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] Base III requires Base II, an established base, and a configurable 200 qualifying block placements inside the base.
|
||||
- [x] Only placements made in Survival mode and within the base's current horizontal and vertical bounds count.
|
||||
- [x] Player-placed blocks and replacement of previously broken blocks may contribute repeatedly.
|
||||
- [x] Base III unlocks `/base` with a configurable 30-second warm-up and three-hour cooldown by default.
|
||||
- [x] `/home` aliases the teleport-only `/base` command.
|
||||
- [x] Looking around without changing block coordinates does not cancel the warm-up.
|
||||
- [x] Changing block X, Y, or Z, taking damage, teleporting, changing worlds, dying, disconnecting, or starting a conflicting teleport cancels the warm-up.
|
||||
- [x] Cancellation clearly informs the player and does not consume the cooldown.
|
||||
- [x] The destination is a safe standing location at or near the recorded base center.
|
||||
- [x] An unavailable or unsafe destination is reported clearly and does not consume the cooldown.
|
||||
- [x] Only a completed teleport starts the cooldown.
|
||||
- [x] Cooldowns use real elapsed time and continue while the player is offline.
|
||||
|
||||
## Related
|
||||
|
||||
- [US-001: Unlock and establish Base I](us-001-unlock-and-establish-base.md)
|
||||
- [US-002: Unlock Base II navigation](us-002-unlock-base-navigation.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-008: Unlock Base IV visitor access](us-008-unlock-visitor-access.md)
|
||||
@@ -1,28 +0,0 @@
|
||||
---
|
||||
type: User Story
|
||||
title: "US-006: Reduce the base teleport warm-up"
|
||||
description: Let players reduce and eventually eliminate their base teleport warm-up by building inside their base.
|
||||
status: done
|
||||
---
|
||||
|
||||
# US-006: Reduce the base teleport warm-up
|
||||
|
||||
As a **player with Base III**, I want continued building to shorten my `/base` warm-up so that returning home becomes increasingly convenient.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] Warm-up progression requires Base III.
|
||||
- [x] The qualifying placement counter from the Base III unlock is retained and used cumulatively.
|
||||
- [x] Only Survival-mode placements inside the base's current horizontal and vertical bounds count.
|
||||
- [x] A configurable 200 total placements grants the initial 30-second warm-up by default.
|
||||
- [x] A configurable 1,000 total placements reduces the warm-up from 30 to 15 seconds by default.
|
||||
- [x] A configurable 2,000 total placements reduces the warm-up from 15 to 5 seconds by default.
|
||||
- [x] A configurable 12,000 total placements removes the warm-up by default.
|
||||
- [x] Each tier must be earned sequentially, and progress is preserved when the base is relocated.
|
||||
- [x] An instant warm-up does not bypass destination safety validation or cooldown enforcement.
|
||||
|
||||
## Related
|
||||
|
||||
- [US-005: Unlock Base III teleportation](us-005-unlock-base-teleportation.md)
|
||||
- [US-008: Unlock Base IV visitor access](us-008-unlock-visitor-access.md)
|
||||
- [US-011: Configure and persist progression](us-011-configure-and-persist-progression.md)
|
||||
@@ -1,29 +0,0 @@
|
||||
---
|
||||
type: User Story
|
||||
title: "US-007: Reduce the base teleport cooldown"
|
||||
description: Let players reduce and eventually eliminate their base teleport cooldown by breaking blocks inside their base.
|
||||
status: done
|
||||
---
|
||||
|
||||
# US-007: Reduce the base teleport cooldown
|
||||
|
||||
As a **player with Base III**, I want work performed inside my base to shorten the `/base` cooldown so that I can return more frequently.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] Cooldown-reduction progress requires Base III.
|
||||
- [x] Any block broken in Survival mode inside the base's current horizontal and vertical bounds contributes unless excluded by configuration.
|
||||
- [x] Player-placed blocks may be broken repeatedly for progress.
|
||||
- [x] The default cooldown is three hours before any cooldown-reduction milestone.
|
||||
- [x] A configurable 1,000 cumulative blocks reduces the cooldown from three to two hours by default.
|
||||
- [x] A configurable 2,000 cumulative blocks reduces the cooldown from two hours to one hour by default.
|
||||
- [x] A configurable 3,000 cumulative blocks reduces the cooldown from one hour to 30 minutes by default.
|
||||
- [x] A configurable 5,000 cumulative blocks removes the cooldown by default.
|
||||
- [x] Each tier is sequential and relocating the base preserves earned tiers and progress.
|
||||
- [x] An instant cooldown still enforces the applicable teleport warm-up and destination safety checks.
|
||||
|
||||
## Related
|
||||
|
||||
- [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-008: Unlock Base IV visitor access](us-008-unlock-visitor-access.md)
|
||||
@@ -1,41 +0,0 @@
|
||||
---
|
||||
type: User Story
|
||||
title: "US-008: Unlock Base IV visitor access"
|
||||
description: Let players spend diamonds to permit controlled, cooldown-limited visitor teleports to their base.
|
||||
status: done
|
||||
---
|
||||
|
||||
# US-008: Unlock Base IV visitor access
|
||||
|
||||
As a **player with Base III**, I want to open my base to visitors so that other players can conveniently join me there under my control.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] Base IV requires Base III and an established base.
|
||||
- [x] `/basesettings upgrade` offers the Base IV purchase for a configurable price that defaults to 128 diamonds, replacing `/base upgrade` and `/home upgrade`.
|
||||
- [x] A successful purchase removes the complete price atomically from the player's direct inventory.
|
||||
- [x] Insufficient funds, an invalid state, or a failed persistence operation does not consume any diamonds or grant Base IV.
|
||||
- [x] `/basesettings visitors allowed` permits visitor access idempotently for a Base IV owner, and `/basesettings visitors blocked` blocks it idempotently.
|
||||
- [x] Invalid visitor arguments show command usage, and `allowed` and `blocked` are offered through autocomplete.
|
||||
- [x] The visitor-access preference persists across reconnects and restarts.
|
||||
- [x] `/gotobase <owner>` autocompletes bases that the requesting player is currently eligible to visit.
|
||||
- [x] `/visit <owner>` aliases `/gotobase <owner>` with identical autocomplete, including eligible bases whose owners are offline.
|
||||
- [x] Enabled bases remain visitable while their owners are offline.
|
||||
- [x] `/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.
|
||||
- [x] Each visitor has an independent cooldown for each destination owner.
|
||||
- [x] On successful teleport, the visitor cooldown duration is captured from the destination owner's current cooldown tier.
|
||||
- [x] A later owner cooldown upgrade applies to future visits without rewriting a cooldown already in progress.
|
||||
- [x] Visiting another base does not consume or modify the owner's personal `/base` cooldown or the visitor's personal `/base` cooldown.
|
||||
- [x] Disabling visits prevents new requests but does not interrupt a teleport that has already completed.
|
||||
|
||||
## Related
|
||||
|
||||
- [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)
|
||||
@@ -1,33 +0,0 @@
|
||||
---
|
||||
type: User Story
|
||||
title: "US-009: View progression and unlock notifications"
|
||||
description: Give players clear command, boss-bar, and full-screen feedback about their progression.
|
||||
status: done
|
||||
---
|
||||
|
||||
# US-009: View progression and unlock notifications
|
||||
|
||||
As a **player**, I want to inspect my progression and receive timely milestone feedback so that I understand what I can unlock next.
|
||||
|
||||
## 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] Each path identifies earned levels, unmet prerequisites, current progress, the next threshold, and the next reward.
|
||||
- [x] Locked secondary paths clearly identify Base I or another sequential level as their prerequisite.
|
||||
- [x] Relevant qualifying activity briefly displays a configurable progress boss bar for the active milestone.
|
||||
- [x] Boss-bar text and fill accurately represent the current count and threshold and never exceed 100 percent.
|
||||
- [x] The automatic boss bar disappears after a configurable number of seconds.
|
||||
- [x] `/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] The boss-bar preference persists across reconnects and restarts.
|
||||
- [x] Each newly unlocked level displays prominent full-screen title and subtitle text describing the reward.
|
||||
- [x] Unlock notifications occur once per earned level and do not repeat after reconnecting or restarting.
|
||||
- [x] `/homesettings` aliases `/basesettings` with identical behavior and autocomplete.
|
||||
- [x] Superseded `/baseprogress`, `/basenavigation`, `/baseflight`, and `/basevisitors` commands and their aliases are no longer registered.
|
||||
|
||||
## Related
|
||||
|
||||
- [US-001: Unlock and establish Base I](us-001-unlock-and-establish-base.md)
|
||||
- [US-004: Unlock and control base flight](us-004-unlock-and-control-base-flight.md)
|
||||
- [US-010: Administer player progression](us-010-administer-player-progression.md)
|
||||
@@ -1,35 +0,0 @@
|
||||
---
|
||||
type: User Story
|
||||
title: "US-010: Administer player progression"
|
||||
description: Let administrators inspect and safely modify player progression and live progression settings through commands.
|
||||
status: done
|
||||
---
|
||||
|
||||
# US-010: Administer player progression
|
||||
|
||||
As a **server administrator**, I want command-based progression controls so that I can inspect players, correct state, and tune requirements without an inventory UI.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] Administrative commands require `spigotbase.admin`, which server operators receive by default.
|
||||
- [x] Administrative player arguments safely resolve online players and previously known offline players.
|
||||
- [x] Player state remains keyed by UUID while retaining the latest known name for lookup and display.
|
||||
- [x] `/baseadmin status <player>` displays the player's base, counters, earned path levels, active cooldowns, toggles, and visitor settings.
|
||||
- [x] `/baseadmin setlevel <player> <path> <level>` sets an earned path level while enforcing or explicitly granting required preceding levels.
|
||||
- [x] `/baseadmin setprogress <player> <path> <amount>` updates the selected counter and consistently evaluates reached tiers.
|
||||
- [x] `/baseadmin reset <player> <size|flight|warmup|cooldown>` resets a selected path without silently leaving benefits that require it.
|
||||
- [x] `/baseadmin reset <player> base` is rejected and directs the administrator to the confirmed complete-reset command.
|
||||
- [x] `/baseadmin reset <player> all` removes the player's base, progression, active cooldowns, and plugin preferences after confirmation.
|
||||
- [x] Administrators can clear personal and visitor cooldowns independently.
|
||||
- [x] Administrative commands can update configured block requirements, warm-ups, and cooldowns for each level using validated values.
|
||||
- [x] Runtime configuration changes are persisted for subsequent restarts.
|
||||
- [x] Lowered progression requirements are evaluated for a player on their next relevant action rather than immediately updating every stored player.
|
||||
- [x] Every successful mutation reports exactly what changed, and invalid requests make no partial changes.
|
||||
- [x] `/homeadmin` aliases `/baseadmin` with identical permission requirements and autocomplete.
|
||||
- [x] Permission-aware autocomplete offers subcommands, online and known offline players, progression paths, valid levels, progress counters, cooldown types, reset paths and confirmation, and numeric configuration keys.
|
||||
- [x] Invalid and extra arguments show friendly usage without exposing implementation-specific enum errors or modifying state.
|
||||
|
||||
## Related
|
||||
|
||||
- [US-009: View progression and unlock notifications](us-009-view-progression-and-notifications.md)
|
||||
- [US-011: Configure and persist progression](us-011-configure-and-persist-progression.md)
|
||||
@@ -1,30 +0,0 @@
|
||||
---
|
||||
type: User Story
|
||||
title: "US-011: Configure and persist progression"
|
||||
description: Give operators validated configuration and durable, defensive storage for all base progression behavior.
|
||||
status: done
|
||||
---
|
||||
|
||||
# US-011: Configure and persist progression
|
||||
|
||||
As a **server operator**, I want progression behavior to be configurable and durable so that the plugin remains predictable across restarts and server changes.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] Configuration supports all qualifying materials, progression thresholds, radii, vertical flight limits, warning-buffer distance, warm-ups, cooldowns, prices, particle settings, and notification durations.
|
||||
- [x] Default values match the progression requirements documented by the related user stories.
|
||||
- [x] Thresholds and levels are validated as nonnegative, representable, and sequentially coherent where required.
|
||||
- [x] Radii, vertical ranges, durations, and prices reject unsafe or nonsensical values.
|
||||
- [x] Invalid required configuration prevents partial plugin initialization and produces a clear server log message.
|
||||
- [x] UUID-keyed state persists latest known names, base locations, counters, levels, relocation times, teleport times, visitor cooldowns, navigation preferences, flight preferences, boss-bar preferences, and visitor settings.
|
||||
- [x] Cooldowns and relocation limits use real elapsed timestamps and continue while players are offline.
|
||||
- [x] State is saved safely so a failed write does not replace valid persisted state with a partial document.
|
||||
- [x] Corrupt, unknown, or invalid records are handled defensively and cannot silently grant progression or privileges.
|
||||
- [x] Unknown forward-compatible configuration and state fields are preserved where practical.
|
||||
|
||||
## Related
|
||||
|
||||
- [US-001: Unlock and establish Base I](us-001-unlock-and-establish-base.md)
|
||||
- [US-003: Expand the base](us-003-expand-the-base.md)
|
||||
- [US-010: Administer player progression](us-010-administer-player-progression.md)
|
||||
- [US-012: Build and release the plugin](us-012-build-and-release-plugin.md)
|
||||
@@ -1,27 +0,0 @@
|
||||
---
|
||||
type: User Story
|
||||
title: "US-012: Build and release the plugin"
|
||||
description: Give maintainers repeatable Spigot builds, automated verification, and versioned Gitea releases.
|
||||
status: done
|
||||
---
|
||||
|
||||
# US-012: Build and release the plugin
|
||||
|
||||
As a **plugin maintainer**, I want automated builds and releases modeled on Trigger Spawn so that tested, correctly versioned artifacts can be distributed consistently.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] The Gradle project compiles against Spigot API `26.2-R0.1-SNAPSHOT` using a Java 17 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.
|
||||
- [x] Pull requests validate conventional commit messages.
|
||||
- [x] CI stores a development JAR as a workflow artifact.
|
||||
- [x] Main-branch conventional commits drive semantic versioning.
|
||||
- [x] A successful release builds a versioned JAR and attaches it to the corresponding Gitea release.
|
||||
- [x] Build files, Gradle wrapper, workflows, and release behavior follow `../spigot-trigger-spawn/` where applicable while using Spigot Base names and identifiers.
|
||||
|
||||
## Related
|
||||
|
||||
- [US-011: Configure and persist progression](us-011-configure-and-persist-progression.md)
|
||||
- [User-story catalog](index.md)
|
||||
@@ -1,31 +0,0 @@
|
||||
---
|
||||
type: User Story
|
||||
title: "US-013: Visualize the base border"
|
||||
description: Let players display a bounded, owner-only particle arc along the current edge of their established base.
|
||||
status: done
|
||||
---
|
||||
|
||||
# US-013: Visualize the base border
|
||||
|
||||
As a **player with Base I**, I want to display a particle effect at my base border so that I can understand where base benefits begin and end while building.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] Border visualization requires Base I and an established base.
|
||||
- [x] `/basesettings border enable` enables visualization idempotently, and `/basesettings border disable` disables it idempotently.
|
||||
- [x] `border`, `enable`, and `disable` are offered through contextual autocomplete.
|
||||
- [x] Border visualization is disabled by default and the preference persists across reconnects and restarts.
|
||||
- [x] Older state files without the preference load it as disabled.
|
||||
- [x] `/basesettings status` and `/baseadmin status` display the border preference.
|
||||
- [x] Particles are visible only to the base owner.
|
||||
- [x] Particles trace the current circular horizontal boundary at the player's current Y-level and follow base expansion or relocation immediately.
|
||||
- [x] Particles render only while the player is in the base world, within the base's vertical range, and reasonably close to the boundary.
|
||||
- [x] Particle generation is bounded to avoid excessive server and client load.
|
||||
- [x] A complete administrative reset disables border visualization.
|
||||
|
||||
## Related
|
||||
|
||||
- [US-001: Unlock and establish Base I](us-001-unlock-and-establish-base.md)
|
||||
- [US-003: Expand the base](us-003-expand-the-base.md)
|
||||
- [US-009: View progression and unlock notifications](us-009-view-progression-and-notifications.md)
|
||||
- [US-010: Administer player progression](us-010-administer-player-progression.md)
|
||||
@@ -1,34 +0,0 @@
|
||||
---
|
||||
type: User Story
|
||||
title: "US-014: Highlight spawnable blocks"
|
||||
description: Unlock an owner-only red particle overlay for nearby hostile-mob spawnable blocks after 250 placements anywhere.
|
||||
status: done
|
||||
---
|
||||
|
||||
# US-014: Highlight spawnable blocks
|
||||
|
||||
As a **player with an established base**, I want to highlight nearby blocks where hostile mobs can spawn so that I can find lighting gaps in my base.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] Every block placed in Survival mode anywhere contributes to a persistent total-placement counter.
|
||||
- [x] The overlay unlocks permanently at a configurable 250 total placements by default.
|
||||
- [x] Existing players begin with at least their persisted in-base placement count because those placements are known to qualify.
|
||||
- [x] `/basesettings spawnable enable` enables the unlocked overlay idempotently, and `/basesettings spawnable disable` disables it idempotently.
|
||||
- [x] `spawnable`, `enable`, and `disable` are offered through contextual autocomplete.
|
||||
- [x] Enabling requires both the placement unlock and an established base; the persisted preference defaults to disabled.
|
||||
- [x] `/basesettings status` and `/baseadmin status` display placement progress, unlock state, and preference.
|
||||
- [x] Administrators can change a player's total-placement counter through `/baseadmin setprogress`.
|
||||
- [x] Administrators can persistently change the required placement threshold through `/baseadmin config spawnable-overlay-unlock-placements`.
|
||||
- [x] Administrators can enable or disable an unlocked player's overlay through `/baseadmin setsetting <player> spawnable <enable|disable>`.
|
||||
- [x] Owner-only red particles mark nearby candidate hostile-mob spawning surfaces inside the current base bounds.
|
||||
- [x] Candidate surfaces have block light zero, a solid spawning surface, and two passable blocks above them.
|
||||
- [x] Scanning is bounded to 16 horizontal blocks, eight vertical blocks, and a fixed per-update work and particle budget.
|
||||
- [x] A complete administrative reset clears total-placement progress and disables the overlay.
|
||||
|
||||
## Related
|
||||
|
||||
- [US-001: Unlock and establish Base I](us-001-unlock-and-establish-base.md)
|
||||
- [US-009: View progression and notifications](us-009-view-progression-and-notifications.md)
|
||||
- [US-010: Administer player progression](us-010-administer-player-progression.md)
|
||||
- [US-011: Configure and persist progression](us-011-configure-and-persist-progression.md)
|
||||
@@ -1,30 +0,0 @@
|
||||
---
|
||||
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)
|
||||
@@ -1,35 +0,0 @@
|
||||
---
|
||||
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] 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] Return travel falls back to the server spawn when the owner has no valid active entrance.
|
||||
- [x] Falling below Y -64 performs the same safe return without void damage.
|
||||
- [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)
|
||||
@@ -1,28 +0,0 @@
|
||||
---
|
||||
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)
|
||||
@@ -1,28 +0,0 @@
|
||||
---
|
||||
type: User Story
|
||||
title: "US-018: Control Pocket Base mob spawning"
|
||||
description: Let Pocket Base owners control natural mob spawning in their private world, with spawning disabled by default.
|
||||
status: done
|
||||
---
|
||||
|
||||
# US-018: Control Pocket Base mob spawning
|
||||
|
||||
As a **Pocket Base owner**, I want to enable or disable natural mob spawning in my Pocket Base so that I can choose whether the private world supports normal mob activity.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] Natural mob spawning is 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 enable|disable` to control natural mob spawning in their own Pocket Base.
|
||||
- [x] The setting controls both hostile and passive natural spawning only in the owner's Pocket Base.
|
||||
- [x] The command is unavailable until Pocket Base I is unlocked.
|
||||
- [x] The command and its modes are offered through contextual autocomplete.
|
||||
- [x] `/basesettings status` displays the current Pocket Base mob-spawning setting.
|
||||
- [x] The preference persists across restarts and is reapplied when the Pocket Base world loads.
|
||||
- [x] Disabling natural spawning does not remove existing mobs or prevent explicitly spawned or summoned mobs.
|
||||
- [x] Persistence or world-application failures produce a clear failure message without reporting 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)
|
||||
@@ -1 +1,5 @@
|
||||
plugins {
|
||||
id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
|
||||
}
|
||||
|
||||
rootProject.name = "spigot-base"
|
||||
|
||||
@@ -1,23 +1,38 @@
|
||||
package games.dmg.spigotbase;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.logging.Level;
|
||||
import java.util.logging.Logger;
|
||||
import org.bukkit.ChatColor;
|
||||
import org.bukkit.GameMode;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.entity.PlayerDeathEvent;
|
||||
import org.bukkit.event.player.PlayerQuitEvent;
|
||||
import org.bukkit.event.raid.RaidFinishEvent;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
|
||||
final class BaseFlightController implements Runnable {
|
||||
final class BaseFlightController implements Runnable, Listener {
|
||||
private static final int POCKET_FLIGHT_BUFFER = 16;
|
||||
private final Server server;
|
||||
private final BaseStateManager stateManager;
|
||||
private final SecondaryProgressionService progressionService;
|
||||
private final BaseBoundsService boundsService;
|
||||
private final PluginSettingsProvider settings;
|
||||
private final PocketBaseManager pocketBases;
|
||||
private final Logger logger;
|
||||
private final Set<UUID> grantedFlight = new HashSet<>();
|
||||
private final Set<UUID> warned = new HashSet<>();
|
||||
private final Map<UUID, UUID> portalFlight = new HashMap<>();
|
||||
|
||||
BaseFlightController(
|
||||
Server server,
|
||||
@@ -25,12 +40,85 @@ final class BaseFlightController implements Runnable {
|
||||
SecondaryProgressionService progressionService,
|
||||
BaseBoundsService boundsService,
|
||||
PluginSettingsProvider settings
|
||||
) {
|
||||
this(server, stateManager, progressionService, boundsService, settings, null,
|
||||
Logger.getLogger(BaseFlightController.class.getName()));
|
||||
}
|
||||
|
||||
BaseFlightController(
|
||||
Server server,
|
||||
BaseStateManager stateManager,
|
||||
SecondaryProgressionService progressionService,
|
||||
BaseBoundsService boundsService,
|
||||
PluginSettingsProvider settings,
|
||||
PocketBaseManager pocketBases,
|
||||
Logger logger
|
||||
) {
|
||||
this.server = server;
|
||||
this.stateManager = stateManager;
|
||||
this.progressionService = progressionService;
|
||||
this.boundsService = boundsService;
|
||||
this.settings = settings;
|
||||
this.pocketBases = pocketBases;
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
@EventHandler
|
||||
public void onRaidFinish(RaidFinishEvent event) {
|
||||
if (pocketBases == null) {
|
||||
return;
|
||||
}
|
||||
Optional<UUID> owner = pocketBases.ownerForPocketWorld(event.getWorld().getUID());
|
||||
if (owner.isEmpty() || event.getWinners().stream().noneMatch(
|
||||
winner -> winner.getUniqueId().equals(owner.orElseThrow()))) {
|
||||
return;
|
||||
}
|
||||
UUID ownerId = owner.orElseThrow();
|
||||
if (pocketBases.state(ownerId).flightUnlocked()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
pocketBases.unlockFlight(ownerId);
|
||||
} catch (IOException | RuntimeException exception) {
|
||||
logger.log(Level.SEVERE, "Could not unlock Pocket Base flight", exception);
|
||||
return;
|
||||
}
|
||||
event.getWinners().stream()
|
||||
.filter(winner -> winner.getUniqueId().equals(ownerId))
|
||||
.findFirst()
|
||||
.orElseThrow()
|
||||
.sendTitle(
|
||||
ChatColor.GOLD + "Pocket Base Flight Unlocked",
|
||||
ChatColor.YELLOW + "Flight is now available in your Pocket Base",
|
||||
settings.current().titleFadeInTicks(),
|
||||
settings.current().titleStayTicks(),
|
||||
settings.current().titleFadeOutTicks()
|
||||
);
|
||||
}
|
||||
|
||||
@EventHandler
|
||||
public void onDeath(PlayerDeathEvent event) {
|
||||
clearPortalFlight(event.getEntity());
|
||||
}
|
||||
|
||||
@EventHandler
|
||||
public void onQuit(PlayerQuitEvent event) {
|
||||
clearPortalFlight(event.getPlayer());
|
||||
}
|
||||
|
||||
void grantPocketPortalFlight(Player player, UUID ownerId) {
|
||||
if (player.getGameMode() != GameMode.SURVIVAL || pocketBases == null
|
||||
|| !pocketBases.ownerForPocketWorld(player.getWorld().getUID())
|
||||
.filter(ownerId::equals).isPresent()) {
|
||||
return;
|
||||
}
|
||||
portalFlight.put(player.getUniqueId(), ownerId);
|
||||
if (applyPocketFlight(player, ownerId)) {
|
||||
player.setFlying(true);
|
||||
player.sendMessage(ChatColor.GREEN + "Pocket dimension flight enabled!");
|
||||
} else {
|
||||
portalFlight.remove(player.getUniqueId());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -44,6 +132,7 @@ final class BaseFlightController implements Runnable {
|
||||
}
|
||||
grantedFlight.removeIf(id -> server.getPlayer(id) == null);
|
||||
warned.removeIf(id -> server.getPlayer(id) == null);
|
||||
portalFlight.keySet().removeIf(id -> server.getPlayer(id) == null);
|
||||
}
|
||||
|
||||
void removeGrantedFlight(Player player) {
|
||||
@@ -66,6 +155,7 @@ final class BaseFlightController implements Runnable {
|
||||
}
|
||||
grantedFlight.clear();
|
||||
warned.clear();
|
||||
portalFlight.clear();
|
||||
}
|
||||
|
||||
private PlayerState observeElytra(Player player, PlayerState state) {
|
||||
@@ -89,8 +179,22 @@ final class BaseFlightController implements Runnable {
|
||||
}
|
||||
|
||||
private void applyFlight(Player player, PlayerState state) {
|
||||
if (player.getGameMode() != GameMode.SURVIVAL
|
||||
|| !state.flightEnabled() || state.flightLevel() < 1 || state.base().isEmpty()) {
|
||||
if (player.getGameMode() != GameMode.SURVIVAL) {
|
||||
portalFlight.remove(player.getUniqueId());
|
||||
removeGrantedFlight(player);
|
||||
return;
|
||||
}
|
||||
if (pocketBases != null) {
|
||||
Optional<UUID> pocketOwner = pocketBases.ownerForPocketWorld(
|
||||
player.getWorld().getUID()
|
||||
);
|
||||
if (pocketOwner.isPresent()) {
|
||||
applyPocketFlight(player, pocketOwner.orElseThrow());
|
||||
return;
|
||||
}
|
||||
}
|
||||
portalFlight.remove(player.getUniqueId());
|
||||
if (!state.flightEnabled() || state.flightLevel() < 1 || state.base().isEmpty()) {
|
||||
removeGrantedFlight(player);
|
||||
return;
|
||||
}
|
||||
@@ -127,6 +231,51 @@ final class BaseFlightController implements Runnable {
|
||||
}
|
||||
}
|
||||
|
||||
private boolean applyPocketFlight(Player player, UUID ownerId) {
|
||||
PocketBaseState pocket = pocketBases.state(ownerId);
|
||||
boolean ownerFlight = ownerId.equals(player.getUniqueId())
|
||||
&& pocket.flightUnlocked() && pocket.flightEnabled();
|
||||
boolean temporaryFlight = ownerId.equals(portalFlight.get(player.getUniqueId()));
|
||||
if ((!ownerFlight && !temporaryFlight) || pocket.level() < 1) {
|
||||
removeGrantedFlight(player);
|
||||
return false;
|
||||
}
|
||||
int halfSize = pocketBases.policy().size(pocket.level()) / 2;
|
||||
int x = player.getLocation().getBlockX();
|
||||
int z = player.getLocation().getBlockZ();
|
||||
if (x < -halfSize - POCKET_FLIGHT_BUFFER
|
||||
|| x >= halfSize + POCKET_FLIGHT_BUFFER
|
||||
|| z < -halfSize - POCKET_FLIGHT_BUFFER
|
||||
|| z >= halfSize + POCKET_FLIGHT_BUFFER) {
|
||||
removeGrantedFlight(player);
|
||||
return false;
|
||||
}
|
||||
if (!player.getAllowFlight()) {
|
||||
player.setAllowFlight(true);
|
||||
grantedFlight.add(player.getUniqueId());
|
||||
}
|
||||
boolean inBuffer = !pocketBases.policy().contains(pocket.level(), x, z);
|
||||
if (inBuffer && player.isFlying()) {
|
||||
if (warned.add(player.getUniqueId())) {
|
||||
player.sendTitle(
|
||||
ChatColor.RED + "Leaving Your Pocket Base",
|
||||
ChatColor.YELLOW + "Turn back before Pocket Base flight ends",
|
||||
0,
|
||||
Math.min(30, settings.current().titleStayTicks()),
|
||||
settings.current().titleFadeOutTicks()
|
||||
);
|
||||
}
|
||||
} else {
|
||||
warned.remove(player.getUniqueId());
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private void clearPortalFlight(Player player) {
|
||||
portalFlight.remove(player.getUniqueId());
|
||||
removeGrantedFlight(player);
|
||||
}
|
||||
|
||||
private boolean withinVerticalRange(Player player, PlayerState state, BaseLocation base) {
|
||||
int y = player.getLocation().getBlockY();
|
||||
if (state.flightLevel() == 3) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package games.dmg.spigotbase;
|
||||
|
||||
import io.papermc.paper.dialog.Dialog;
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
@@ -15,16 +16,20 @@ import org.bukkit.inventory.PlayerInventory;
|
||||
|
||||
final class BaseSettingsCommand implements CommandExecutor, TabCompleter {
|
||||
private static final List<String> SETTINGS = List.of(
|
||||
"status", "upgrade", "pocket", "visitors", "navigation", "flight", "border",
|
||||
"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,
|
||||
@@ -39,12 +44,30 @@ final class BaseSettingsCommand implements CommandExecutor, TabCompleter {
|
||||
PluginSettingsProvider settings,
|
||||
BaseFlightController flightController,
|
||||
PocketBaseManager pocketBases
|
||||
) {
|
||||
this(
|
||||
stateManager,
|
||||
settings,
|
||||
flightController,
|
||||
pocketBases,
|
||||
(owner, pocket) -> new BaseSettingsDialogFactory(settings.current())
|
||||
.create(owner, pocket)
|
||||
);
|
||||
}
|
||||
|
||||
BaseSettingsCommand(
|
||||
BaseStateManager stateManager,
|
||||
PluginSettingsProvider settings,
|
||||
BaseFlightController flightController,
|
||||
PocketBaseManager pocketBases,
|
||||
DialogProvider dialogProvider
|
||||
) {
|
||||
this.stateManager = stateManager;
|
||||
this.settings = settings;
|
||||
this.teleportPolicy = new TeleportPolicy(settings);
|
||||
this.flightController = flightController;
|
||||
this.pocketBases = pocketBases;
|
||||
this.dialogProvider = dialogProvider;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -55,7 +78,11 @@ 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;
|
||||
}
|
||||
@@ -67,8 +94,16 @@ final class BaseSettingsCommand implements CommandExecutor, TabCompleter {
|
||||
return purchasePocketUpgrade(player, state);
|
||||
}
|
||||
if (arguments.length == 3 && arguments[0].equalsIgnoreCase("pocket")
|
||||
&& arguments[1].equalsIgnoreCase("flight")) {
|
||||
return updatePocketFlight(player, arguments[2]);
|
||||
}
|
||||
if (arguments.length == 4 && arguments[0].equalsIgnoreCase("pocket")
|
||||
&& arguments[1].equalsIgnoreCase("mobs")) {
|
||||
return updatePocketMobSpawning(player, arguments[2]);
|
||||
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]);
|
||||
@@ -180,7 +215,93 @@ final class BaseSettingsCommand implements CommandExecutor, TabCompleter {
|
||||
return true;
|
||||
}
|
||||
|
||||
private boolean updatePocketMobSpawning(Player player, String mode) {
|
||||
private boolean purchasePocketBiome(
|
||||
Player player,
|
||||
String worldTypeName,
|
||||
String biomeName
|
||||
) {
|
||||
if (pocketBases == null) {
|
||||
player.sendMessage(ChatColor.RED + "Pocket Bases are currently unavailable.");
|
||||
return true;
|
||||
}
|
||||
PocketBaseState current = pocketBases.state(player.getUniqueId());
|
||||
if (current.level() < 1) {
|
||||
player.sendMessage(ChatColor.RED + "Pocket Base I is still locked.");
|
||||
return true;
|
||||
}
|
||||
PocketBaseBiome biome = PocketBaseWorldType.fromCommand(worldTypeName)
|
||||
.flatMap(worldType -> PocketBaseBiome.fromCommand(worldType, biomeName))
|
||||
.orElse(null);
|
||||
if (biome == null) {
|
||||
player.sendMessage(ChatColor.RED + "That Pocket Base type and subtype are invalid.");
|
||||
return true;
|
||||
}
|
||||
if (biome == current.biome()) {
|
||||
player.sendMessage(ChatColor.RED + "Your Pocket Base already uses "
|
||||
+ biome.commandName() + ".");
|
||||
return true;
|
||||
}
|
||||
int price = settings.current().pocketBaseBiomeChangeCost();
|
||||
Material currency = Material.valueOf(
|
||||
settings.current().pocketBaseBiomeCurrencyMaterial()
|
||||
);
|
||||
PlayerInventory inventory = player.getInventory();
|
||||
if (countCurrency(inventory, currency) < price) {
|
||||
player.sendMessage(ChatColor.RED + "Changing Pocket Base biome costs "
|
||||
+ price + " " + currency.name().toLowerCase(Locale.ROOT) + ".");
|
||||
return true;
|
||||
}
|
||||
ItemStack[] snapshot = cloneContents(inventory.getStorageContents());
|
||||
removeCurrency(inventory, currency, price);
|
||||
final PocketBaseState updated;
|
||||
try {
|
||||
updated = pocketBases.setBiome(player.getUniqueId(), biome);
|
||||
} catch (IOException | RuntimeException exception) {
|
||||
inventory.setStorageContents(snapshot);
|
||||
player.sendMessage(ChatColor.RED
|
||||
+ "The Pocket Base biome change failed; your payment was restored.");
|
||||
return true;
|
||||
}
|
||||
player.sendMessage(ChatColor.GREEN + "Pocket Base biome changed to "
|
||||
+ updated.biome().commandName() + " for " + price + " "
|
||||
+ currency.name().toLowerCase(Locale.ROOT) + ".");
|
||||
return true;
|
||||
}
|
||||
|
||||
private boolean updatePocketFlight(Player player, String mode) {
|
||||
if (pocketBases == null) {
|
||||
player.sendMessage(ChatColor.RED + "Pocket Bases are currently unavailable.");
|
||||
return true;
|
||||
}
|
||||
PocketBaseState current = pocketBases.state(player.getUniqueId());
|
||||
if (!current.flightUnlocked()) {
|
||||
player.sendMessage(ChatColor.RED
|
||||
+ "Win a raid in your Pocket Base before enabling automatic flight.");
|
||||
return true;
|
||||
}
|
||||
Boolean enabled = enabledMode(mode);
|
||||
if (enabled == null) {
|
||||
sendUsage(player);
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
current = pocketBases.setFlightEnabled(player.getUniqueId(), enabled);
|
||||
} catch (IOException | RuntimeException exception) {
|
||||
player.sendMessage(ChatColor.RED
|
||||
+ "The Pocket Base flight setting could not be changed.");
|
||||
return true;
|
||||
}
|
||||
player.sendMessage(ChatColor.YELLOW + "Pocket Base automatic flight is now "
|
||||
+ (current.flightEnabled() ? ChatColor.GREEN + "enabled" : ChatColor.RED + "disabled")
|
||||
+ ChatColor.YELLOW + ".");
|
||||
return true;
|
||||
}
|
||||
|
||||
private boolean updatePocketMobSpawning(
|
||||
Player player,
|
||||
String category,
|
||||
String mode
|
||||
) {
|
||||
if (pocketBases == null) {
|
||||
player.sendMessage(ChatColor.RED + "Pocket Bases are currently unavailable.");
|
||||
return true;
|
||||
@@ -196,17 +317,30 @@ final class BaseSettingsCommand implements CommandExecutor, TabCompleter {
|
||||
return true;
|
||||
}
|
||||
final PocketBaseState updated;
|
||||
final boolean settingEnabled;
|
||||
try {
|
||||
updated = pocketBases.setMobSpawning(player.getUniqueId(), enabled);
|
||||
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 mob spawning is now "
|
||||
+ (updated.mobSpawningEnabled()
|
||||
? ChatColor.GREEN + "enabled"
|
||||
: ChatColor.RED + "disabled")
|
||||
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;
|
||||
}
|
||||
@@ -364,6 +498,13 @@ final class BaseSettingsCommand implements CommandExecutor, TabCompleter {
|
||||
return true;
|
||||
}
|
||||
|
||||
private void showDialog(Player player, PlayerState state) {
|
||||
PocketBaseState pocket = pocketBases == null
|
||||
? PocketBaseState.locked(state.playerId())
|
||||
: pocketBases.state(state.playerId());
|
||||
player.showDialog(dialogProvider.create(state, pocket));
|
||||
}
|
||||
|
||||
private void showStatus(Player player, PlayerState state) {
|
||||
player.sendMessage(ChatColor.GOLD + "=== Base Progress ===");
|
||||
showBasePath(player, state);
|
||||
@@ -452,8 +593,16 @@ final class BaseSettingsCommand implements CommandExecutor, TabCompleter {
|
||||
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; mob spawning="
|
||||
+ (pocket.mobSpawningEnabled() ? "enabled" : "disabled"));
|
||||
+ " → /basesettings pocket upgrade; type="
|
||||
+ pocket.biome().worldType().commandName() + "/" + pocket.biome().commandName()
|
||||
+ "; hostile spawning="
|
||||
+ (pocket.hostileMobSpawningEnabled() ? "enabled" : "disabled")
|
||||
+ "; passive spawning="
|
||||
+ (pocket.passiveMobSpawningEnabled() ? "enabled" : "disabled")
|
||||
+ "; flight="
|
||||
+ (pocket.flightUnlocked()
|
||||
? "automatic " + (pocket.flightEnabled() ? "enabled" : "disabled")
|
||||
: "complete a raid to unlock"));
|
||||
}
|
||||
|
||||
private void showCooldownPath(Player player, PlayerState state) {
|
||||
@@ -485,17 +634,48 @@ 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");
|
||||
case "pocket" -> List.of("upgrade", "flight", "mobs", "type");
|
||||
case "navigation", "flight", "border", "spawnable", "bossbar" -> ENABLE_MODES;
|
||||
default -> List.of();
|
||||
};
|
||||
String prefix = arguments[1].toLowerCase(Locale.ROOT);
|
||||
return modes.stream().filter(mode -> mode.startsWith(prefix)).toList();
|
||||
}
|
||||
if (arguments.length == 3 && arguments[0].equalsIgnoreCase("pocket")
|
||||
&& arguments[1].equalsIgnoreCase("mobs")) {
|
||||
if (arguments.length == 3 && arguments[0].equalsIgnoreCase("pocket")) {
|
||||
String prefix = arguments[2].toLowerCase(Locale.ROOT);
|
||||
return ENABLE_MODES.stream().filter(mode -> mode.startsWith(prefix)).toList();
|
||||
if (arguments[1].equalsIgnoreCase("flight")) {
|
||||
return ENABLE_MODES.stream()
|
||||
.filter(mode -> mode.startsWith(prefix))
|
||||
.toList();
|
||||
}
|
||||
if (arguments[1].equalsIgnoreCase("mobs")) {
|
||||
return List.of("hostile", "passive").stream()
|
||||
.filter(category -> category.startsWith(prefix))
|
||||
.toList();
|
||||
}
|
||||
if (arguments[1].equalsIgnoreCase("type")) {
|
||||
return POCKET_WORLD_TYPES.stream()
|
||||
.filter(type -> type.startsWith(prefix))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
if (arguments.length == 4 && arguments[0].equalsIgnoreCase("pocket")) {
|
||||
String prefix = arguments[3].toLowerCase(Locale.ROOT);
|
||||
if (arguments[1].equalsIgnoreCase("mobs")
|
||||
&& (arguments[2].equalsIgnoreCase("hostile")
|
||||
|| arguments[2].equalsIgnoreCase("passive"))) {
|
||||
return ENABLE_MODES.stream()
|
||||
.filter(mode -> mode.startsWith(prefix))
|
||||
.toList();
|
||||
}
|
||||
if (arguments[1].equalsIgnoreCase("type")) {
|
||||
return PocketBaseWorldType.fromCommand(arguments[2])
|
||||
.map(PocketBaseBiome::commandNames)
|
||||
.orElse(List.of())
|
||||
.stream()
|
||||
.filter(biome -> biome.startsWith(prefix))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
return List.of();
|
||||
}
|
||||
@@ -554,9 +734,16 @@ 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|pocket upgrade|pocket mobs <enable|disable>"
|
||||
+ "[ui|status|upgrade|pocket upgrade|pocket flight <enable|disable>|pocket mobs "
|
||||
+ "<hostile|passive> <enable|disable>"
|
||||
+ "|pocket type <void|nether|overworld> <subtype>"
|
||||
+ "|visitors <allowed|blocked>|navigation <enable|disable>"
|
||||
+ "|flight <enable|disable>|border <enable|disable>"
|
||||
+ "|spawnable <enable|disable>|bossbar <enable|disable>]");
|
||||
|
||||
@@ -0,0 +1,482 @@
|
||||
package games.dmg.spigotbase;
|
||||
|
||||
import io.papermc.paper.dialog.Dialog;
|
||||
import io.papermc.paper.registry.RegistryKey;
|
||||
import io.papermc.paper.registry.data.dialog.ActionButton;
|
||||
import io.papermc.paper.registry.data.dialog.DialogBase;
|
||||
import io.papermc.paper.registry.data.dialog.action.DialogAction;
|
||||
import io.papermc.paper.registry.data.dialog.body.DialogBody;
|
||||
import io.papermc.paper.registry.data.dialog.type.DialogType;
|
||||
import io.papermc.paper.registry.set.RegistrySet;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import net.kyori.adventure.text.Component;
|
||||
import net.kyori.adventure.text.event.ClickEvent;
|
||||
|
||||
final class BaseSettingsDialogFactory {
|
||||
private static final int DIALOG_WIDTH = 420;
|
||||
private final PluginSettings settings;
|
||||
|
||||
BaseSettingsDialogFactory(PluginSettings settings) {
|
||||
this.settings = settings;
|
||||
}
|
||||
|
||||
Dialog create(PlayerState owner, PocketBaseState pocket) {
|
||||
return render(specification(owner, pocket));
|
||||
}
|
||||
|
||||
DialogSpec specification(PlayerState owner, PocketBaseState pocket) {
|
||||
List<DialogSpec> dialogs = List.of(
|
||||
generalSettings(owner),
|
||||
progressReport(),
|
||||
baseUpgrade(owner),
|
||||
pocketSettings(owner, pocket)
|
||||
);
|
||||
return new ListSpec(
|
||||
content(
|
||||
"Base Settings",
|
||||
"Base Settings",
|
||||
"Base " + owner.baseLevel() + "/4 • Size " + owner.sizeLevel() + "/3"
|
||||
+ " • Flight " + owner.flightLevel() + "/3\n"
|
||||
+ "Pocket Base " + pocket.level() + " • "
|
||||
+ humanize(pocket.biome().commandName())
|
||||
),
|
||||
dialogs,
|
||||
2,
|
||||
190
|
||||
);
|
||||
}
|
||||
|
||||
private DialogSpec generalSettings(PlayerState owner) {
|
||||
List<ButtonSpec> actions = List.of(
|
||||
toggle(
|
||||
"Visitors",
|
||||
owner.baseLevel() >= 4,
|
||||
owner.visitorsEnabled(),
|
||||
"Allowed",
|
||||
"Blocked",
|
||||
"basesettings visitors "
|
||||
+ (owner.visitorsEnabled() ? "blocked" : "allowed")
|
||||
),
|
||||
toggle(
|
||||
"Navigation",
|
||||
owner.baseLevel() >= 2,
|
||||
owner.navigationEnabled(),
|
||||
"Enabled",
|
||||
"Disabled",
|
||||
"basesettings navigation " + mode(!owner.navigationEnabled())
|
||||
),
|
||||
toggle(
|
||||
"Flight",
|
||||
owner.flightLevel() >= 1,
|
||||
owner.flightEnabled(),
|
||||
"Enabled",
|
||||
"Disabled",
|
||||
"basesettings flight " + mode(!owner.flightEnabled())
|
||||
),
|
||||
toggle(
|
||||
"Border",
|
||||
owner.baseLevel() >= 1 && owner.base().isPresent(),
|
||||
owner.borderEnabled(),
|
||||
"Enabled",
|
||||
"Disabled",
|
||||
"basesettings border " + mode(!owner.borderEnabled())
|
||||
),
|
||||
toggle(
|
||||
"Spawnable Overlay",
|
||||
owner.baseLevel() >= 1
|
||||
&& owner.base().isPresent()
|
||||
&& owner.totalBlocksPlaced()
|
||||
>= settings.spawnableOverlayUnlockPlacements(),
|
||||
owner.spawnableOverlayEnabled(),
|
||||
"Enabled",
|
||||
"Disabled",
|
||||
"basesettings spawnable " + mode(!owner.spawnableOverlayEnabled())
|
||||
),
|
||||
toggle(
|
||||
"Progress Boss Bar",
|
||||
true,
|
||||
owner.bossBarEnabled(),
|
||||
"Enabled",
|
||||
"Disabled",
|
||||
"basesettings bossbar " + mode(!owner.bossBarEnabled())
|
||||
)
|
||||
);
|
||||
return new MultiSpec(
|
||||
content(
|
||||
"Base Controls",
|
||||
"Base Controls",
|
||||
"Select a setting to change it. Locked controls remain visible and explain "
|
||||
+ "their requirement in chat when selected."
|
||||
),
|
||||
actions,
|
||||
2
|
||||
);
|
||||
}
|
||||
|
||||
private DialogSpec progressReport() {
|
||||
return new NoticeSpec(
|
||||
content(
|
||||
"Progress Report",
|
||||
"Progress Report",
|
||||
"Display the detailed progression report in chat."
|
||||
),
|
||||
commandButton("Show in Chat", "basesettings status")
|
||||
);
|
||||
}
|
||||
|
||||
private DialogSpec baseUpgrade(PlayerState owner) {
|
||||
if (owner.baseLevel() >= 4) {
|
||||
return notice("Base IV", "Base IV and visitor access are already unlocked.");
|
||||
}
|
||||
if (owner.baseLevel() < 3 || owner.base().isEmpty()) {
|
||||
return notice(
|
||||
"Base IV — Locked",
|
||||
"Unlock and establish Base III before purchasing Base IV visitor access."
|
||||
);
|
||||
}
|
||||
return confirmation(
|
||||
"Unlock Base IV",
|
||||
"Purchase visitor access for " + settings.visitorUnlockDiamondCost()
|
||||
+ " diamonds?",
|
||||
"basesettings upgrade"
|
||||
);
|
||||
}
|
||||
|
||||
private DialogSpec pocketSettings(PlayerState owner, PocketBaseState pocket) {
|
||||
List<DialogSpec> dialogs = List.of(
|
||||
pocketUpgrade(owner, pocket),
|
||||
pocketFlightSettings(pocket),
|
||||
pocketMobSettings(pocket),
|
||||
pocketBiomeSettings(pocket)
|
||||
);
|
||||
return new ListSpec(
|
||||
content(
|
||||
"Pocket Base",
|
||||
"Pocket Base " + pocket.level(),
|
||||
pocket.level() == 0
|
||||
? "Pocket Base is locked."
|
||||
: "Biome: " + pocket.biome().worldType().commandName() + "/"
|
||||
+ pocket.biome().commandName() + "\nHostile mobs: "
|
||||
+ enabled(pocket.hostileMobSpawningEnabled()) + " • Passive mobs: "
|
||||
+ enabled(pocket.passiveMobSpawningEnabled()) + "\nFlight: "
|
||||
+ (pocket.flightUnlocked()
|
||||
? (pocket.flightEnabled() ? "Enabled" : "Disabled")
|
||||
: "Complete a raid to unlock")
|
||||
),
|
||||
dialogs,
|
||||
1,
|
||||
260
|
||||
);
|
||||
}
|
||||
|
||||
private DialogSpec pocketUpgrade(PlayerState owner, PocketBaseState pocket) {
|
||||
if (owner.baseLevel() < 4 || owner.base().isEmpty()) {
|
||||
return notice(
|
||||
"Pocket Upgrade — Locked",
|
||||
"Establish Base IV before purchasing a Pocket Base."
|
||||
);
|
||||
}
|
||||
int price = pocket.level() == 0
|
||||
? settings.pocketBaseUnlockCost()
|
||||
: settings.pocketBaseUpgradeCost();
|
||||
String currency = humanize(settings.pocketBaseCurrencyMaterial());
|
||||
String title = pocket.level() == 0
|
||||
? "Unlock Pocket Base I"
|
||||
: "Upgrade to Pocket Base " + (pocket.level() + 1);
|
||||
return confirmation(
|
||||
title,
|
||||
"Purchase this upgrade for " + price + " " + currency + "?",
|
||||
"basesettings pocket upgrade"
|
||||
);
|
||||
}
|
||||
|
||||
private DialogSpec pocketFlightSettings(PocketBaseState pocket) {
|
||||
if (!pocket.flightUnlocked()) {
|
||||
return notice(
|
||||
"Pocket Flight — Locked",
|
||||
"Win a raid in your Pocket Base to unlock automatic flight."
|
||||
);
|
||||
}
|
||||
return new MultiSpec(
|
||||
content(
|
||||
"Pocket Flight",
|
||||
"Pocket Flight",
|
||||
"Control automatic owner flight. Flight granted by the gold portal "
|
||||
+ "is unaffected."
|
||||
),
|
||||
List.of(toggle(
|
||||
"Automatic Flight",
|
||||
true,
|
||||
pocket.flightEnabled(),
|
||||
"Enabled",
|
||||
"Disabled",
|
||||
"basesettings pocket flight " + mode(!pocket.flightEnabled())
|
||||
)),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
private DialogSpec pocketMobSettings(PocketBaseState pocket) {
|
||||
if (pocket.level() < 1) {
|
||||
return notice(
|
||||
"Mob Spawning — Locked",
|
||||
"Unlock Pocket Base I to control natural mob spawning."
|
||||
);
|
||||
}
|
||||
List<ButtonSpec> actions = List.of(
|
||||
toggle(
|
||||
"Hostile Mobs",
|
||||
true,
|
||||
pocket.hostileMobSpawningEnabled(),
|
||||
"Enabled",
|
||||
"Disabled",
|
||||
"basesettings pocket mobs hostile "
|
||||
+ mode(!pocket.hostileMobSpawningEnabled())
|
||||
),
|
||||
toggle(
|
||||
"Passive Mobs",
|
||||
true,
|
||||
pocket.passiveMobSpawningEnabled(),
|
||||
"Enabled",
|
||||
"Disabled",
|
||||
"basesettings pocket mobs passive "
|
||||
+ mode(!pocket.passiveMobSpawningEnabled())
|
||||
)
|
||||
);
|
||||
return new MultiSpec(
|
||||
content(
|
||||
"Mob Spawning",
|
||||
"Mob Spawning",
|
||||
"Control natural spawning independently for each mob category."
|
||||
),
|
||||
actions,
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
private DialogSpec pocketBiomeSettings(PocketBaseState pocket) {
|
||||
if (pocket.level() < 1) {
|
||||
return notice(
|
||||
"Change Biome — Locked",
|
||||
"Unlock Pocket Base I to purchase biome changes."
|
||||
);
|
||||
}
|
||||
List<DialogSpec> worldTypes = Arrays.stream(PocketBaseWorldType.values())
|
||||
.map(type -> pocketBiomeType(pocket, type))
|
||||
.toList();
|
||||
return new ListSpec(
|
||||
content(
|
||||
"Change Biome",
|
||||
"Change Biome",
|
||||
"Current biome: " + pocket.biome().worldType().commandName() + "/"
|
||||
+ pocket.biome().commandName() + "\nEach change costs "
|
||||
+ settings.pocketBaseBiomeChangeCost() + " "
|
||||
+ humanize(settings.pocketBaseBiomeCurrencyMaterial()) + "."
|
||||
),
|
||||
worldTypes,
|
||||
1,
|
||||
220
|
||||
);
|
||||
}
|
||||
|
||||
private DialogSpec pocketBiomeType(
|
||||
PocketBaseState pocket,
|
||||
PocketBaseWorldType worldType
|
||||
) {
|
||||
List<DialogSpec> biomes = Arrays.stream(PocketBaseBiome.values())
|
||||
.filter(biome -> biome.worldType() == worldType)
|
||||
.map(biome -> biomeConfirmation(pocket, biome))
|
||||
.toList();
|
||||
return new ListSpec(
|
||||
content(
|
||||
humanize(worldType.commandName()),
|
||||
humanize(worldType.commandName()) + " Biomes",
|
||||
"Choose a biome subtype. The active biome is marked as selected."
|
||||
),
|
||||
biomes,
|
||||
2,
|
||||
190
|
||||
);
|
||||
}
|
||||
|
||||
private DialogSpec biomeConfirmation(PocketBaseState pocket, PocketBaseBiome biome) {
|
||||
String title = humanize(biome.commandName())
|
||||
+ (pocket.biome() == biome ? " — Selected" : "");
|
||||
if (pocket.biome() == biome) {
|
||||
return notice(title, "This is your Pocket Base's current biome.");
|
||||
}
|
||||
return confirmation(
|
||||
title,
|
||||
"Change the Pocket Base biome for " + settings.pocketBaseBiomeChangeCost()
|
||||
+ " " + humanize(settings.pocketBaseBiomeCurrencyMaterial()) + "?",
|
||||
"basesettings pocket type " + biome.worldType().commandName() + " "
|
||||
+ biome.commandName()
|
||||
);
|
||||
}
|
||||
|
||||
private static DialogSpec confirmation(String title, String message, String command) {
|
||||
return new ConfirmationSpec(
|
||||
content(title, title, message),
|
||||
commandButton("Confirm", command)
|
||||
);
|
||||
}
|
||||
|
||||
private static DialogSpec notice(String title, String message) {
|
||||
return new NoticeSpec(content(title, title, message), null);
|
||||
}
|
||||
|
||||
private static ButtonSpec toggle(
|
||||
String name,
|
||||
boolean unlocked,
|
||||
boolean active,
|
||||
String activeLabel,
|
||||
String inactiveLabel,
|
||||
String command
|
||||
) {
|
||||
String state = unlocked ? (active ? activeLabel : inactiveLabel) : "Locked";
|
||||
String tooltip = unlocked
|
||||
? "Click to change this setting."
|
||||
: "This setting has not been unlocked yet.";
|
||||
return new ButtonSpec(name + ": " + state, tooltip, 190, "/" + command);
|
||||
}
|
||||
|
||||
private static ButtonSpec commandButton(String label, String command) {
|
||||
return new ButtonSpec(label, "", 150, "/" + command);
|
||||
}
|
||||
|
||||
private static DialogContent content(
|
||||
String title,
|
||||
String externalTitle,
|
||||
String message
|
||||
) {
|
||||
return new DialogContent(title, externalTitle, message);
|
||||
}
|
||||
|
||||
private static Dialog render(DialogSpec specification) {
|
||||
return render(specification, true);
|
||||
}
|
||||
|
||||
private static Dialog render(DialogSpec specification, boolean root) {
|
||||
DialogType type;
|
||||
ActionButton back = root ? null : backButton();
|
||||
if (specification instanceof ListSpec list) {
|
||||
List<Dialog> dialogs = list.dialogs().stream()
|
||||
.map(child -> render(child, false))
|
||||
.toList();
|
||||
type = DialogType.dialogList(
|
||||
RegistrySet.valueSet(RegistryKey.DIALOG, dialogs),
|
||||
back,
|
||||
list.columns(),
|
||||
list.buttonWidth()
|
||||
);
|
||||
} else if (specification instanceof MultiSpec multi) {
|
||||
List<ActionButton> actions = multi.actions().stream()
|
||||
.map(BaseSettingsDialogFactory::render)
|
||||
.toList();
|
||||
type = DialogType.multiAction(actions, back, multi.columns());
|
||||
} else if (specification instanceof ConfirmationSpec confirmation) {
|
||||
type = DialogType.confirmation(
|
||||
render(confirmation.confirm()),
|
||||
root ? ActionButton.builder(Component.text("Cancel")).build() : back
|
||||
);
|
||||
} else if (specification instanceof NoticeSpec notice) {
|
||||
type = notice.action() == null
|
||||
? (root ? DialogType.notice() : DialogType.notice(back))
|
||||
: DialogType.notice(render(notice.action()));
|
||||
} else {
|
||||
throw new IllegalArgumentException("Unsupported dialog specification");
|
||||
}
|
||||
DialogContent content = specification.content();
|
||||
DialogBase base = DialogBase.builder(Component.text(content.title()))
|
||||
.externalTitle(Component.text(content.externalTitle()))
|
||||
.body(List.of(DialogBody.plainMessage(
|
||||
Component.text(content.message()), DIALOG_WIDTH
|
||||
)))
|
||||
.canCloseWithEscape(true)
|
||||
.pause(false)
|
||||
.afterAction(DialogBase.DialogAfterAction.CLOSE)
|
||||
.build();
|
||||
return Dialog.create(factory -> factory.empty().base(base).type(type));
|
||||
}
|
||||
|
||||
private static ActionButton backButton() {
|
||||
return render(backButtonSpec());
|
||||
}
|
||||
|
||||
static ButtonSpec backButtonSpec() {
|
||||
return commandButton("Back", "basesettings ui");
|
||||
}
|
||||
|
||||
private static ActionButton render(ButtonSpec button) {
|
||||
ActionButton.Builder builder = ActionButton.builder(Component.text(button.label()))
|
||||
.width(button.width())
|
||||
.action(DialogAction.staticAction(commandClick(button.command())));
|
||||
if (!button.tooltip().isEmpty()) {
|
||||
builder.tooltip(Component.text(button.tooltip()));
|
||||
}
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
static ClickEvent<ClickEvent.Payload.Text> commandClick(String command) {
|
||||
return ClickEvent.runCommand(command);
|
||||
}
|
||||
|
||||
sealed interface DialogSpec permits ListSpec, MultiSpec, ConfirmationSpec, NoticeSpec {
|
||||
DialogContent content();
|
||||
}
|
||||
|
||||
record DialogContent(String title, String externalTitle, String message) {
|
||||
}
|
||||
|
||||
record ListSpec(
|
||||
DialogContent content,
|
||||
List<DialogSpec> dialogs,
|
||||
int columns,
|
||||
int buttonWidth
|
||||
) implements DialogSpec {
|
||||
}
|
||||
|
||||
record MultiSpec(
|
||||
DialogContent content,
|
||||
List<ButtonSpec> actions,
|
||||
int columns
|
||||
) implements DialogSpec {
|
||||
}
|
||||
|
||||
record ConfirmationSpec(
|
||||
DialogContent content,
|
||||
ButtonSpec confirm
|
||||
) implements DialogSpec {
|
||||
}
|
||||
|
||||
record NoticeSpec(
|
||||
DialogContent content,
|
||||
ButtonSpec action
|
||||
) implements DialogSpec {
|
||||
}
|
||||
|
||||
record ButtonSpec(String label, String tooltip, int width, String command) {
|
||||
}
|
||||
|
||||
private static String mode(boolean active) {
|
||||
return active ? "enable" : "disable";
|
||||
}
|
||||
|
||||
private static String enabled(boolean active) {
|
||||
return active ? "enabled" : "disabled";
|
||||
}
|
||||
|
||||
private static String humanize(String value) {
|
||||
String normalized = value.toLowerCase(Locale.ROOT).replace('_', ' ');
|
||||
StringBuilder result = new StringBuilder(normalized.length());
|
||||
boolean capitalize = true;
|
||||
for (int index = 0; index < normalized.length(); index++) {
|
||||
char character = normalized.charAt(index);
|
||||
result.append(capitalize ? Character.toUpperCase(character) : character);
|
||||
capitalize = character == ' ';
|
||||
}
|
||||
return result.toString();
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,7 @@ final class BaseTeleportManager implements Listener {
|
||||
private final VisitorPolicy visitorPolicy;
|
||||
private final SafeBaseDestination destinationFinder;
|
||||
private final Clock clock;
|
||||
private final TeleportParticles particles;
|
||||
private final Map<UUID, Request> requests = new HashMap<>();
|
||||
|
||||
BaseTeleportManager(
|
||||
@@ -44,6 +45,20 @@ final class BaseTeleportManager implements Listener {
|
||||
SafeBaseDestination destinationFinder,
|
||||
Clock clock
|
||||
) {
|
||||
this(plugin, stateManager, policy, visitorPolicy, destinationFinder, clock,
|
||||
new TeleportParticles(plugin));
|
||||
}
|
||||
|
||||
BaseTeleportManager(
|
||||
Plugin plugin,
|
||||
BaseStateManager stateManager,
|
||||
TeleportPolicy policy,
|
||||
VisitorPolicy visitorPolicy,
|
||||
SafeBaseDestination destinationFinder,
|
||||
Clock clock,
|
||||
TeleportParticles particles
|
||||
) {
|
||||
this.particles = particles;
|
||||
this.plugin = plugin;
|
||||
this.stateManager = stateManager;
|
||||
this.policy = policy;
|
||||
@@ -138,6 +153,7 @@ final class BaseTeleportManager implements Listener {
|
||||
return;
|
||||
}
|
||||
requests.put(player.getUniqueId(), request);
|
||||
particles.start(player.getUniqueId(), origin, seconds);
|
||||
request.task = Bukkit.getScheduler().runTaskTimer(plugin, () -> tick(player, request), 0L, 20L);
|
||||
player.sendMessage(ChatColor.YELLOW + "Stand still for " + seconds + " seconds to " + purpose + ".");
|
||||
}
|
||||
@@ -147,6 +163,7 @@ final class BaseTeleportManager implements Listener {
|
||||
request.cancelTask();
|
||||
}
|
||||
requests.clear();
|
||||
particles.clear();
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
@@ -204,6 +221,9 @@ final class BaseTeleportManager implements Listener {
|
||||
|
||||
private void tick(Player player, Request request) {
|
||||
if (!player.isOnline() || requests.get(player.getUniqueId()) != request) {
|
||||
if (requests.remove(player.getUniqueId(), request)) {
|
||||
particles.cancel(player.getUniqueId());
|
||||
}
|
||||
request.cancelTask();
|
||||
return;
|
||||
}
|
||||
@@ -225,18 +245,22 @@ final class BaseTeleportManager implements Listener {
|
||||
BaseLocation base = request.destination;
|
||||
World world = Bukkit.getWorld(base.worldId());
|
||||
if (world == null) {
|
||||
particles.cancel(player.getUniqueId());
|
||||
player.sendMessage(ChatColor.RED + "The destination world is not currently available.");
|
||||
return;
|
||||
}
|
||||
Optional<Location> destination = destinationFinder.find(world, base);
|
||||
if (destination.isEmpty()) {
|
||||
particles.cancel(player.getUniqueId());
|
||||
player.sendMessage(ChatColor.RED + "No safe location could be found at the base.");
|
||||
return;
|
||||
}
|
||||
if (!player.teleport(destination.orElseThrow(), PlayerTeleportEvent.TeleportCause.PLUGIN)) {
|
||||
particles.cancel(player.getUniqueId());
|
||||
player.sendMessage(ChatColor.RED + "The base teleport was prevented.");
|
||||
return;
|
||||
}
|
||||
particles.complete(player.getUniqueId());
|
||||
Instant completedAt = clock.instant();
|
||||
stateManager.update(
|
||||
player.getUniqueId(),
|
||||
@@ -260,6 +284,7 @@ final class BaseTeleportManager implements Listener {
|
||||
return;
|
||||
}
|
||||
request.cancelTask();
|
||||
particles.cancel(player.getUniqueId());
|
||||
if (message != null) {
|
||||
player.sendMessage(ChatColor.RED + message);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package games.dmg.spigotbase;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
|
||||
public record KeystoneReturnLocation(
|
||||
UUID worldId,
|
||||
String worldName,
|
||||
double x,
|
||||
double y,
|
||||
double z,
|
||||
float yaw,
|
||||
float pitch
|
||||
) {
|
||||
public KeystoneReturnLocation {
|
||||
Objects.requireNonNull(worldId, "worldId");
|
||||
Objects.requireNonNull(worldName, "worldName");
|
||||
if (worldName.isBlank()) {
|
||||
throw new IllegalArgumentException("worldName must not be blank");
|
||||
}
|
||||
if (!Double.isFinite(x) || !Double.isFinite(y) || !Double.isFinite(z)
|
||||
|| !Float.isFinite(yaw) || !Float.isFinite(pitch)) {
|
||||
throw new IllegalArgumentException("return coordinates must be finite");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package games.dmg.spigotbase;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
final class KeystoneReturnStore {
|
||||
private final YamlKeystoneReturnRepository repository;
|
||||
private final Map<UUID, KeystoneReturnLocation> destinations;
|
||||
|
||||
KeystoneReturnStore(YamlKeystoneReturnRepository repository) throws IOException {
|
||||
this.repository = repository;
|
||||
this.destinations = new HashMap<>(repository.load());
|
||||
}
|
||||
|
||||
Optional<KeystoneReturnLocation> destination(UUID playerId) {
|
||||
return Optional.ofNullable(destinations.get(playerId));
|
||||
}
|
||||
|
||||
void record(UUID playerId, KeystoneReturnLocation destination) throws IOException {
|
||||
KeystoneReturnLocation previous = destinations.put(playerId, destination);
|
||||
try {
|
||||
repository.save(destinations);
|
||||
} catch (IOException exception) {
|
||||
if (previous == null) {
|
||||
destinations.remove(playerId);
|
||||
} else {
|
||||
destinations.put(playerId, previous);
|
||||
}
|
||||
throw exception;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -39,12 +39,14 @@ public record PluginSettings(
|
||||
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,
|
||||
@@ -85,6 +87,7 @@ public record PluginSettings(
|
||||
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;
|
||||
@@ -148,6 +151,7 @@ public record PluginSettings(
|
||||
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(
|
||||
@@ -162,6 +166,9 @@ public record PluginSettings(
|
||||
pocketBaseCurrencyMaterial = normalizedName(
|
||||
pocketBaseCurrencyMaterial, "pocket-base-currency-material"
|
||||
);
|
||||
pocketBaseBiomeCurrencyMaterial = normalizedName(
|
||||
pocketBaseBiomeCurrencyMaterial, "pocket-base-biome-currency-material"
|
||||
);
|
||||
pocketBasePortalFrameMaterial = normalizedName(
|
||||
pocketBasePortalFrameMaterial, "pocket-base-portal-frame-material"
|
||||
);
|
||||
@@ -230,12 +237,18 @@ public record PluginSettings(
|
||||
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),
|
||||
|
||||
@@ -29,6 +29,10 @@ final class PluginSettingsValidator {
|
||||
}
|
||||
validateCurrency(settings.visitorCurrencyMaterial(), "visitor-currency-material");
|
||||
validateCurrency(settings.pocketBaseCurrencyMaterial(), "pocket-base-currency-material");
|
||||
validateCurrency(
|
||||
settings.pocketBaseBiomeCurrencyMaterial(),
|
||||
"pocket-base-biome-currency-material"
|
||||
);
|
||||
return settings;
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.logging.Level;
|
||||
import java.util.logging.Logger;
|
||||
import org.bukkit.ChatColor;
|
||||
@@ -15,6 +16,7 @@ import org.bukkit.Server;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.block.Block;
|
||||
import org.bukkit.entity.Entity;
|
||||
import org.bukkit.entity.Mob;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
@@ -26,6 +28,7 @@ import org.bukkit.event.entity.EntityDamageEvent;
|
||||
import org.bukkit.event.player.PlayerInteractEvent;
|
||||
import org.bukkit.event.player.PlayerMoveEvent;
|
||||
import org.bukkit.event.player.PlayerTeleportEvent;
|
||||
import org.bukkit.util.BoundingBox;
|
||||
|
||||
final class PocketBaseController implements Listener, Runnable {
|
||||
private static final int VOID_RETURN_Y = -64;
|
||||
@@ -36,6 +39,7 @@ final class PocketBaseController implements Listener, Runnable {
|
||||
private final PocketBaseManager pocketBases;
|
||||
private final PluginSettingsProvider settings;
|
||||
private final Logger logger;
|
||||
private final BaseFlightController flightController;
|
||||
private final Map<UUID, Long> cooldownUntil = new HashMap<>();
|
||||
|
||||
PocketBaseController(
|
||||
@@ -45,6 +49,18 @@ final class PocketBaseController implements Listener, Runnable {
|
||||
PocketBaseManager pocketBases,
|
||||
PluginSettingsProvider settings,
|
||||
Logger logger
|
||||
) {
|
||||
this(server, baseStates, baseBounds, pocketBases, settings, logger, null);
|
||||
}
|
||||
|
||||
PocketBaseController(
|
||||
Server server,
|
||||
BaseStateManager baseStates,
|
||||
BaseBoundsService baseBounds,
|
||||
PocketBaseManager pocketBases,
|
||||
PluginSettingsProvider settings,
|
||||
Logger logger,
|
||||
BaseFlightController flightController
|
||||
) {
|
||||
this.server = server;
|
||||
this.baseStates = baseStates;
|
||||
@@ -52,6 +68,7 @@ final class PocketBaseController implements Listener, Runnable {
|
||||
this.pocketBases = pocketBases;
|
||||
this.settings = settings;
|
||||
this.logger = logger;
|
||||
this.flightController = flightController;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -62,7 +79,10 @@ final class PocketBaseController implements Listener, Runnable {
|
||||
if (state.level() > 0 && pocketBases.returnPortalIsIntact(state.ownerId())) {
|
||||
showPortal(pocketBases.returnPortal(state.ownerId()));
|
||||
}
|
||||
state.flightPortal().filter(portal -> isIntact(portal, Material.GOLD_BLOCK))
|
||||
.ifPresent(portal -> showPortal(portal, Material.GOLD_BLOCK));
|
||||
}
|
||||
scanMobPortals();
|
||||
long now = System.nanoTime();
|
||||
cooldownUntil.entrySet().removeIf(entry -> entry.getValue() <= now);
|
||||
}
|
||||
@@ -79,25 +99,68 @@ final class PocketBaseController implements Listener, Runnable {
|
||||
if (pocket.level() < 1) {
|
||||
return;
|
||||
}
|
||||
PlayerState owner = baseStates.player(player.getUniqueId(), player.getName());
|
||||
if (owner.baseLevel() < 4 || owner.base().isEmpty()) {
|
||||
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())
|
||||
);
|
||||
Optional<UUID> pocketOwner = pocketBases.ownerForPocketWorld(world.getUID());
|
||||
if (pocketOwner.isPresent()) {
|
||||
if (!pocketOwner.orElseThrow().equals(player.getUniqueId())) {
|
||||
return;
|
||||
}
|
||||
Optional<PocketPortalLocation> flightPortal = pocket.flightUnlocked()
|
||||
? findFrame(clicked, Material.GOLD_BLOCK)
|
||||
: Optional.empty();
|
||||
boolean activatingFlightPortal = flightPortal.isPresent();
|
||||
Optional<PocketPortalLocation> portal = activatingFlightPortal
|
||||
? flightPortal
|
||||
: findFrame(clicked, frameMaterial);
|
||||
if (portal.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
if (PocketPortalGeometry.frameBlocks(portal.orElseThrow()).stream()
|
||||
.anyMatch(candidate -> !pocketBases.policy().contains(
|
||||
pocket.level(), candidate.x(), candidate.z()
|
||||
))) {
|
||||
player.sendMessage(ChatColor.RED + "The complete "
|
||||
+ (activatingFlightPortal ? "flight" : "return")
|
||||
+ " portal must be inside your Pocket Base boundary.");
|
||||
event.setCancelled(true);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (activatingFlightPortal) {
|
||||
pocketBases.activateFlightPortal(
|
||||
player.getUniqueId(), portal.orElseThrow()
|
||||
);
|
||||
} else {
|
||||
pocketBases.activateReturnPortal(
|
||||
player.getUniqueId(), portal.orElseThrow()
|
||||
);
|
||||
}
|
||||
} catch (IOException | RuntimeException exception) {
|
||||
logger.log(Level.SEVERE, "Could not activate Pocket Base "
|
||||
+ (activatingFlightPortal ? "flight" : "return") + " portal", exception);
|
||||
player.sendMessage(ChatColor.RED + "The Pocket Base "
|
||||
+ (activatingFlightPortal ? "flight" : "return")
|
||||
+ " portal could not be activated.");
|
||||
event.setCancelled(true);
|
||||
return;
|
||||
}
|
||||
event.setCancelled(true);
|
||||
player.sendMessage(ChatColor.GREEN + "Pocket Base "
|
||||
+ (activatingFlightPortal ? "flight" : "return") + " portal activated.");
|
||||
return;
|
||||
}
|
||||
Optional<PocketPortalLocation> portal = findFrame(clicked, frameMaterial);
|
||||
if (portal.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
PlayerState owner = baseStates.player(player.getUniqueId(), player.getName());
|
||||
if (owner.baseLevel() < 4 || owner.base().isEmpty()) {
|
||||
return;
|
||||
}
|
||||
BaseArea area = baseBounds.area(owner);
|
||||
if (PocketPortalGeometry.frameBlocks(portal.orElseThrow()).stream().anyMatch(candidate ->
|
||||
!area.contains(world.getUID(), candidate.x(), candidate.y(), candidate.z()))) {
|
||||
@@ -130,6 +193,13 @@ final class PocketBaseController implements Listener, Runnable {
|
||||
deactivate(state.ownerId());
|
||||
}
|
||||
}
|
||||
if (state.flightPortal().isPresent()) {
|
||||
PocketPortalLocation portal = state.flightPortal().orElseThrow();
|
||||
if (portal.worldId().equals(worldId)
|
||||
&& PocketPortalGeometry.isFrame(portal, broken)) {
|
||||
deactivateFlightPortal(state.ownerId());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,6 +238,18 @@ final class PocketBaseController implements Listener, Runnable {
|
||||
pocketBases.returnPortal(ownerId), position(destination)
|
||||
)) {
|
||||
leavePocket(player, ownerId);
|
||||
return;
|
||||
}
|
||||
PocketBaseState state = pocketBases.state(ownerId);
|
||||
if (flightController != null && state.flightPortal().isPresent()) {
|
||||
PocketPortalLocation portal = state.flightPortal().orElseThrow();
|
||||
if (isIntact(portal, Material.GOLD_BLOCK)
|
||||
&& PocketPortalGeometry.isInterior(
|
||||
portal, position(destination)
|
||||
) && (event.getFrom() == null
|
||||
|| !PocketPortalGeometry.isInterior(portal, position(event.getFrom())))) {
|
||||
flightController.grantPocketPortalFlight(player, ownerId);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -208,23 +290,62 @@ final class PocketBaseController implements Listener, Runnable {
|
||||
deactivate(ownerId);
|
||||
}
|
||||
|
||||
private void leavePocket(Player player, UUID ownerId) {
|
||||
void scanMobPortals() {
|
||||
for (PocketBaseState state : pocketBases.knownStates().values()) {
|
||||
if (state.entrance().isPresent()) {
|
||||
PocketPortalLocation entrance = state.entrance().orElseThrow();
|
||||
if (isIntact(entrance)) {
|
||||
transportMobs(
|
||||
entrance,
|
||||
mob -> teleport(mob, pocketBases.pocketArrival(state.ownerId()))
|
||||
);
|
||||
}
|
||||
}
|
||||
if (state.level() > 0 && pocketBases.returnPortalIsIntact(state.ownerId())) {
|
||||
transportMobs(
|
||||
pocketBases.returnPortal(state.ownerId()),
|
||||
mob -> leavePocket(mob, state.ownerId())
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void transportMobs(
|
||||
PocketPortalLocation portal,
|
||||
Consumer<Mob> transport
|
||||
) {
|
||||
World world = server.getWorld(portal.worldId());
|
||||
if (world == null) {
|
||||
return;
|
||||
}
|
||||
for (Entity entity : world.getNearbyEntities(
|
||||
portalBounds(portal),
|
||||
candidate -> candidate instanceof Mob
|
||||
)) {
|
||||
if (entity instanceof Mob mob && !onCooldown(mob)) {
|
||||
transport.accept(mob);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void leavePocket(Entity entity, UUID ownerId) {
|
||||
PocketBaseState state = pocketBases.state(ownerId);
|
||||
if (state.entrance().isPresent() && isIntact(state.entrance().orElseThrow())) {
|
||||
teleport(player, outsidePortal(state.entrance().orElseThrow()));
|
||||
teleport(entity, outsidePortal(state.entrance().orElseThrow()));
|
||||
return;
|
||||
}
|
||||
World fallbackWorld = server.getWorlds().get(0);
|
||||
teleport(player, fallbackWorld.getSpawnLocation());
|
||||
teleport(entity, fallbackWorld.getSpawnLocation());
|
||||
}
|
||||
|
||||
private void teleport(Player player, Location destination) {
|
||||
cooldownUntil.put(player.getUniqueId(), System.nanoTime() + PORTAL_COOLDOWN_NANOS);
|
||||
player.teleport(destination, PlayerTeleportEvent.TeleportCause.PLUGIN);
|
||||
private void teleport(Entity entity, Location destination) {
|
||||
cooldownUntil.put(entity.getUniqueId(), System.nanoTime() + PORTAL_COOLDOWN_NANOS);
|
||||
entity.setFallDistance(0.0F);
|
||||
entity.teleport(destination, PlayerTeleportEvent.TeleportCause.PLUGIN);
|
||||
}
|
||||
|
||||
private boolean onCooldown(Player player) {
|
||||
return cooldownUntil.getOrDefault(player.getUniqueId(), 0L) > System.nanoTime();
|
||||
private boolean onCooldown(Entity entity) {
|
||||
return cooldownUntil.getOrDefault(entity.getUniqueId(), 0L) > System.nanoTime();
|
||||
}
|
||||
|
||||
private void validateEntrances() {
|
||||
@@ -246,18 +367,26 @@ final class PocketBaseController implements Listener, Runnable {
|
||||
}
|
||||
|
||||
private boolean isIntact(PocketPortalLocation portal) {
|
||||
World world = server.getWorld(portal.worldId());
|
||||
if (world == null) {
|
||||
return false;
|
||||
}
|
||||
Material frame = Material.valueOf(settings.current().pocketBasePortalFrameMaterial());
|
||||
return PocketPortalGeometry.frameBlocks(portal).stream()
|
||||
return isIntact(portal, frame);
|
||||
}
|
||||
|
||||
private boolean isIntact(PocketPortalLocation portal, Material frame) {
|
||||
World world = server.getWorld(portal.worldId());
|
||||
return world != null && PocketPortalGeometry.frameBlocks(portal).stream()
|
||||
.allMatch(position -> block(world, position).getType() == frame);
|
||||
}
|
||||
|
||||
private void showPortal(PocketPortalLocation portal) {
|
||||
showPortal(
|
||||
portal,
|
||||
Material.valueOf(settings.current().pocketBasePortalFrameMaterial())
|
||||
);
|
||||
}
|
||||
|
||||
private void showPortal(PocketPortalLocation portal, Material frame) {
|
||||
World world = server.getWorld(portal.worldId());
|
||||
if (world == null || !isIntact(portal)) {
|
||||
if (world == null || !isIntact(portal, frame)) {
|
||||
return;
|
||||
}
|
||||
for (BlockPosition position : PocketPortalGeometry.interiorBlocks(portal)) {
|
||||
@@ -275,6 +404,14 @@ final class PocketBaseController implements Listener, Runnable {
|
||||
}
|
||||
}
|
||||
|
||||
private void deactivateFlightPortal(UUID ownerId) {
|
||||
try {
|
||||
pocketBases.deactivateFlightPortal(ownerId);
|
||||
} catch (IOException exception) {
|
||||
logger.log(Level.SEVERE, "Could not deactivate Pocket Base flight portal", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private void deactivate(UUID ownerId) {
|
||||
try {
|
||||
pocketBases.deactivateEntrance(ownerId);
|
||||
@@ -293,6 +430,37 @@ final class PocketBaseController implements Listener, Runnable {
|
||||
: new Location(world, portal.x() + 1.5, portal.y() + 1.0, portal.z() + 2.0);
|
||||
}
|
||||
|
||||
private static BoundingBox portalBounds(PocketPortalLocation portal) {
|
||||
return portal.axis() == PocketPortalAxis.X
|
||||
? new BoundingBox(
|
||||
portal.x() + 1.0,
|
||||
portal.y() + 1.0,
|
||||
portal.z(),
|
||||
portal.x() + 3.0,
|
||||
portal.y() + 4.0,
|
||||
portal.z() + 1.0
|
||||
)
|
||||
: new BoundingBox(
|
||||
portal.x(),
|
||||
portal.y() + 1.0,
|
||||
portal.z() + 1.0,
|
||||
portal.x() + 1.0,
|
||||
portal.y() + 4.0,
|
||||
portal.z() + 3.0
|
||||
);
|
||||
}
|
||||
|
||||
private Optional<PocketPortalLocation> findFrame(Block clicked, Material material) {
|
||||
World world = clicked.getWorld();
|
||||
return PocketPortalGeometry.findFrame(
|
||||
position(clicked),
|
||||
world.getUID(),
|
||||
world.getName(),
|
||||
candidate -> block(world, candidate).getType() == material,
|
||||
candidate -> isAir(block(world, candidate).getType())
|
||||
);
|
||||
}
|
||||
|
||||
private static boolean isAir(Material material) {
|
||||
return material == Material.AIR || material == Material.CAVE_AIR
|
||||
|| material == Material.VOID_AIR;
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
package games.dmg.spigotbase;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.logging.Level;
|
||||
import java.util.logging.Logger;
|
||||
import net.kyori.adventure.text.Component;
|
||||
import net.kyori.adventure.text.format.NamedTextColor;
|
||||
import org.bukkit.ChatColor;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.block.Block;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.Event;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.block.Action;
|
||||
import org.bukkit.event.inventory.PrepareItemCraftEvent;
|
||||
import org.bukkit.event.player.PlayerInteractEvent;
|
||||
import org.bukkit.event.player.PlayerTeleportEvent;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.ShapedRecipe;
|
||||
import org.bukkit.inventory.meta.ItemMeta;
|
||||
import org.bukkit.persistence.PersistentDataType;
|
||||
|
||||
final class PocketBaseKeystoneService implements Listener {
|
||||
private static final Set<Material> RETURN_HAZARDS = Set.of(
|
||||
Material.LAVA,
|
||||
Material.FIRE,
|
||||
Material.SOUL_FIRE,
|
||||
Material.MAGMA_BLOCK,
|
||||
Material.CACTUS,
|
||||
Material.CAMPFIRE,
|
||||
Material.SOUL_CAMPFIRE,
|
||||
Material.POWDER_SNOW
|
||||
);
|
||||
private final Server server;
|
||||
private final PocketBaseManager pocketBases;
|
||||
private final KeystoneReturnStore returns;
|
||||
private final NamespacedKey markerKey;
|
||||
private final Logger logger;
|
||||
|
||||
PocketBaseKeystoneService(
|
||||
Server server,
|
||||
PocketBaseManager pocketBases,
|
||||
KeystoneReturnStore returns,
|
||||
NamespacedKey markerKey,
|
||||
Logger logger
|
||||
) {
|
||||
this.server = server;
|
||||
this.pocketBases = pocketBases;
|
||||
this.returns = returns;
|
||||
this.markerKey = markerKey;
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGH)
|
||||
public void onPrepareCraft(PrepareItemCraftEvent event) {
|
||||
if (!(event.getRecipe() instanceof ShapedRecipe shaped)
|
||||
|| !shaped.getKey().equals(markerKey)
|
||||
|| !(event.getView().getPlayer() instanceof Player player)) {
|
||||
return;
|
||||
}
|
||||
if (pocketBases.state(player.getUniqueId()).level() < 1) {
|
||||
event.getInventory().setResult(null);
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.HIGH)
|
||||
public void onUse(PlayerInteractEvent event) {
|
||||
Action action = event.getAction();
|
||||
if ((action != Action.RIGHT_CLICK_AIR && action != Action.RIGHT_CLICK_BLOCK)
|
||||
|| !isKeystone(event.getItem(), markerKey)) {
|
||||
return;
|
||||
}
|
||||
// Vanilla no-action air clicks can deny block use without denying item use.
|
||||
// Accept those events, but respect explicit item-use denial by other plugins.
|
||||
if (event.useItemInHand() == Event.Result.DENY) {
|
||||
return;
|
||||
}
|
||||
event.setCancelled(true);
|
||||
Player player = event.getPlayer();
|
||||
UUID playerId = player.getUniqueId();
|
||||
if (pocketBases.state(playerId).level() < 1) {
|
||||
player.sendMessage(ChatColor.RED
|
||||
+ "Unlock Pocket Base I before using a Pocket Base Keystone.");
|
||||
return;
|
||||
}
|
||||
Optional<UUID> currentPocket = pocketBases.ownerForPocketWorld(
|
||||
player.getWorld().getUID()
|
||||
);
|
||||
if (currentPocket.isPresent()) {
|
||||
if (!currentPocket.orElseThrow().equals(playerId)) {
|
||||
player.sendMessage(ChatColor.RED
|
||||
+ "You can only use a keystone to leave your own Pocket Base.");
|
||||
return;
|
||||
}
|
||||
returnToSavedDestination(player);
|
||||
return;
|
||||
}
|
||||
Location origin = player.getLocation();
|
||||
World originWorld = origin.getWorld();
|
||||
if (originWorld == null) {
|
||||
player.sendMessage(ChatColor.RED + "Your return location could not be recorded.");
|
||||
return;
|
||||
}
|
||||
KeystoneReturnLocation destination = new KeystoneReturnLocation(
|
||||
originWorld.getUID(),
|
||||
originWorld.getName(),
|
||||
origin.getX(),
|
||||
origin.getY(),
|
||||
origin.getZ(),
|
||||
origin.getYaw(),
|
||||
origin.getPitch()
|
||||
);
|
||||
try {
|
||||
returns.record(playerId, destination);
|
||||
} catch (IOException exception) {
|
||||
logger.log(Level.SEVERE, "Could not save a Pocket Base Keystone return", exception);
|
||||
player.sendMessage(ChatColor.RED
|
||||
+ "Your return location could not be saved, so you were not teleported.");
|
||||
return;
|
||||
}
|
||||
teleport(player, pocketBases.pocketArrival(playerId));
|
||||
}
|
||||
|
||||
private void returnToSavedDestination(Player player) {
|
||||
Optional<KeystoneReturnLocation> saved = returns.destination(player.getUniqueId());
|
||||
if (saved.isEmpty()) {
|
||||
player.sendMessage(ChatColor.RED + "No Pocket Base Keystone return is recorded.");
|
||||
return;
|
||||
}
|
||||
KeystoneReturnLocation destination = saved.orElseThrow();
|
||||
World world = server.getWorld(destination.worldId());
|
||||
if (world == null) {
|
||||
player.sendMessage(ChatColor.RED + "Your recorded return world is unavailable.");
|
||||
return;
|
||||
}
|
||||
Location location = new Location(
|
||||
world,
|
||||
destination.x(),
|
||||
destination.y(),
|
||||
destination.z(),
|
||||
destination.yaw(),
|
||||
destination.pitch()
|
||||
);
|
||||
if (!isSafe(location)) {
|
||||
player.sendMessage(ChatColor.RED + "Your recorded return location is not safe.");
|
||||
return;
|
||||
}
|
||||
teleport(player, location);
|
||||
}
|
||||
|
||||
private static void teleport(Player player, Location destination) {
|
||||
float previousFallDistance = player.getFallDistance();
|
||||
player.setFallDistance(0.0F);
|
||||
if (!player.teleport(destination, PlayerTeleportEvent.TeleportCause.PLUGIN)) {
|
||||
player.setFallDistance(previousFallDistance);
|
||||
player.sendMessage(ChatColor.RED + "Pocket Base Keystone travel failed.");
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isSafe(Location location) {
|
||||
World world = location.getWorld();
|
||||
int x = location.getBlockX();
|
||||
int y = location.getBlockY();
|
||||
int z = location.getBlockZ();
|
||||
if (world == null || y <= world.getMinHeight() || y + 1 >= world.getMaxHeight()) {
|
||||
return false;
|
||||
}
|
||||
Block ground = world.getBlockAt(x, y - 1, z);
|
||||
Block feet = world.getBlockAt(x, y, z);
|
||||
Block head = world.getBlockAt(x, y + 1, z);
|
||||
return feet.isPassable() && head.isPassable()
|
||||
&& !feet.isLiquid() && !head.isLiquid()
|
||||
&& !ground.isPassable() && !ground.isLiquid()
|
||||
&& !RETURN_HAZARDS.contains(ground.getType())
|
||||
&& !RETURN_HAZARDS.contains(feet.getType())
|
||||
&& !RETURN_HAZARDS.contains(head.getType());
|
||||
}
|
||||
|
||||
static boolean isKeystone(ItemStack item, NamespacedKey markerKey) {
|
||||
if (item == null || item.getType() != Material.RECOVERY_COMPASS) {
|
||||
return false;
|
||||
}
|
||||
ItemMeta metadata = item.getItemMeta();
|
||||
if (metadata == null) {
|
||||
return false;
|
||||
}
|
||||
Byte marker = metadata.getPersistentDataContainer().get(
|
||||
markerKey, PersistentDataType.BYTE
|
||||
);
|
||||
return marker != null && marker == (byte) 1;
|
||||
}
|
||||
|
||||
void registerRecipe() {
|
||||
server.addRecipe(recipe(markerKey, createKeystone(markerKey)));
|
||||
}
|
||||
|
||||
static ItemStack createKeystone(NamespacedKey markerKey) {
|
||||
ItemStack item = new ItemStack(Material.RECOVERY_COMPASS);
|
||||
decorateKeystone(item, markerKey);
|
||||
return item;
|
||||
}
|
||||
|
||||
static void decorateKeystone(ItemStack item, NamespacedKey markerKey) {
|
||||
ItemMeta metadata = item.getItemMeta();
|
||||
if (metadata == null) {
|
||||
throw new IllegalStateException("Recovery Compass metadata is unavailable");
|
||||
}
|
||||
metadata.displayName(Component.text(
|
||||
"Pocket Base Keystone", NamedTextColor.LIGHT_PURPLE
|
||||
));
|
||||
metadata.lore(List.of(
|
||||
Component.text(
|
||||
"Right-click to enter your Pocket Base.", NamedTextColor.GRAY
|
||||
),
|
||||
Component.text("Use it again inside to return.", NamedTextColor.GRAY)
|
||||
));
|
||||
metadata.getPersistentDataContainer().set(
|
||||
markerKey, PersistentDataType.BYTE, (byte) 1
|
||||
);
|
||||
item.setItemMeta(metadata);
|
||||
}
|
||||
|
||||
static RecipeSpec recipeSpecification() {
|
||||
return new RecipeSpec(
|
||||
"ONO", "NEN", "ONO", Material.OBSIDIAN,
|
||||
Material.NETHERITE_INGOT, Material.ENDER_PEARL
|
||||
);
|
||||
}
|
||||
|
||||
static ShapedRecipe recipe(NamespacedKey key, ItemStack result) {
|
||||
RecipeSpec specification = recipeSpecification();
|
||||
ShapedRecipe recipe = new ShapedRecipe(key, result);
|
||||
recipe.shape(specification.top(), specification.middle(), specification.bottom());
|
||||
recipe.setIngredient('O', specification.obsidian());
|
||||
recipe.setIngredient('N', specification.netherite());
|
||||
recipe.setIngredient('E', specification.enderPearl());
|
||||
return recipe;
|
||||
}
|
||||
|
||||
record RecipeSpec(
|
||||
String top,
|
||||
String middle,
|
||||
String bottom,
|
||||
Material obsidian,
|
||||
Material netherite,
|
||||
Material enderPearl
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,7 @@ final class PocketBaseManager {
|
||||
PocketBaseState current = states.state(ownerId);
|
||||
int nextLevel = Math.addExact(current.level(), 1);
|
||||
policy.size(nextLevel);
|
||||
worlds.expand(ownerId, current.level(), nextLevel);
|
||||
worlds.expand(ownerId, current.level(), nextLevel, current.biome());
|
||||
return states.updateAndSave(ownerId, state -> state.withLevel(nextLevel));
|
||||
}
|
||||
|
||||
@@ -60,20 +60,122 @@ final class PocketBaseManager {
|
||||
}
|
||||
}
|
||||
|
||||
PocketBaseState setMobSpawning(UUID ownerId, boolean enabled) throws IOException {
|
||||
PocketBaseState activateFlightPortal(
|
||||
UUID ownerId,
|
||||
PocketPortalLocation portal
|
||||
) throws IOException {
|
||||
PocketBaseState current = states.state(ownerId);
|
||||
if (!current.flightUnlocked()) {
|
||||
throw new IllegalStateException("Pocket Base flight is still locked");
|
||||
}
|
||||
return states.updateAndSave(ownerId, state -> state.withFlightPortal(portal));
|
||||
}
|
||||
|
||||
void deactivateFlightPortal(UUID ownerId) throws IOException {
|
||||
PocketBaseState current = states.state(ownerId);
|
||||
if (current.flightPortal().isPresent()) {
|
||||
states.updateAndSave(ownerId, PocketBaseState::withoutFlightPortal);
|
||||
}
|
||||
}
|
||||
|
||||
PocketBaseState activateReturnPortal(
|
||||
UUID ownerId,
|
||||
PocketPortalLocation portal
|
||||
) throws IOException {
|
||||
PocketBaseState current = states.state(ownerId);
|
||||
if (current.level() < 1) {
|
||||
throw new IllegalStateException("Pocket Base I is still locked");
|
||||
}
|
||||
worlds.setMobSpawning(ownerId, enabled);
|
||||
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 {
|
||||
return states.updateAndSave(
|
||||
ownerId,
|
||||
state -> state.withMobSpawningEnabled(enabled)
|
||||
);
|
||||
worlds.setBiome(ownerId, current.level(), biome);
|
||||
return states.updateAndSave(ownerId, state -> state.withBiome(biome));
|
||||
} catch (IOException | RuntimeException exception) {
|
||||
try {
|
||||
worlds.setMobSpawning(ownerId, current.mobSpawningEnabled());
|
||||
worlds.setBiome(ownerId, current.level(), current.biome());
|
||||
} catch (RuntimeException rollbackFailure) {
|
||||
exception.addSuppressed(rollbackFailure);
|
||||
}
|
||||
throw exception;
|
||||
}
|
||||
}
|
||||
|
||||
PocketBaseState unlockFlight(UUID ownerId) throws IOException {
|
||||
PocketBaseState current = states.state(ownerId);
|
||||
if (current.level() < 1) {
|
||||
throw new IllegalStateException("Pocket Base I is still locked");
|
||||
}
|
||||
if (current.flightUnlocked()) {
|
||||
return current;
|
||||
}
|
||||
return states.updateAndSave(ownerId, PocketBaseState::withFlightUnlocked);
|
||||
}
|
||||
|
||||
PocketBaseState setFlightEnabled(UUID ownerId, boolean enabled) throws IOException {
|
||||
PocketBaseState current = states.state(ownerId);
|
||||
if (!current.flightUnlocked()) {
|
||||
throw new IllegalStateException("Pocket Base flight is still locked");
|
||||
}
|
||||
if (current.flightEnabled() == enabled) {
|
||||
return current;
|
||||
}
|
||||
return states.updateAndSave(
|
||||
ownerId, state -> state.withFlightEnabled(enabled)
|
||||
);
|
||||
}
|
||||
|
||||
PocketBaseState setHostileMobSpawning(UUID ownerId, boolean enabled)
|
||||
throws IOException {
|
||||
PocketBaseState current = states.state(ownerId);
|
||||
return setMobSpawning(
|
||||
current,
|
||||
enabled,
|
||||
current.passiveMobSpawningEnabled(),
|
||||
state -> state.withHostileMobSpawningEnabled(enabled)
|
||||
);
|
||||
}
|
||||
|
||||
PocketBaseState setPassiveMobSpawning(UUID ownerId, boolean enabled)
|
||||
throws IOException {
|
||||
PocketBaseState current = states.state(ownerId);
|
||||
return setMobSpawning(
|
||||
current,
|
||||
current.hostileMobSpawningEnabled(),
|
||||
enabled,
|
||||
state -> state.withPassiveMobSpawningEnabled(enabled)
|
||||
);
|
||||
}
|
||||
|
||||
private PocketBaseState setMobSpawning(
|
||||
PocketBaseState current,
|
||||
boolean hostileEnabled,
|
||||
boolean passiveEnabled,
|
||||
java.util.function.UnaryOperator<PocketBaseState> update
|
||||
) throws IOException {
|
||||
if (current.level() < 1) {
|
||||
throw new IllegalStateException("Pocket Base I is still locked");
|
||||
}
|
||||
UUID ownerId = current.ownerId();
|
||||
worlds.setMobSpawning(ownerId, hostileEnabled, passiveEnabled);
|
||||
try {
|
||||
return states.updateAndSave(ownerId, update);
|
||||
} catch (IOException | RuntimeException exception) {
|
||||
try {
|
||||
worlds.setMobSpawning(
|
||||
ownerId,
|
||||
current.hostileMobSpawningEnabled(),
|
||||
current.passiveMobSpawningEnabled()
|
||||
);
|
||||
} catch (RuntimeException rollbackFailure) {
|
||||
exception.addSuppressed(rollbackFailure);
|
||||
}
|
||||
@@ -90,11 +192,13 @@ final class PocketBaseManager {
|
||||
}
|
||||
|
||||
PocketPortalLocation returnPortal(UUID ownerId) {
|
||||
return worlds.returnPortal(ownerId);
|
||||
return states.state(ownerId).returnPortal().orElseGet(
|
||||
() -> worlds.returnPortal(ownerId)
|
||||
);
|
||||
}
|
||||
|
||||
boolean returnPortalIsIntact(UUID ownerId) {
|
||||
return worlds.returnPortalIsIntact(ownerId);
|
||||
return worlds.returnPortalIsIntact(ownerId, returnPortal(ownerId));
|
||||
}
|
||||
|
||||
World pocketWorld(UUID ownerId) {
|
||||
|
||||
@@ -7,7 +7,13 @@ public record PocketBaseState(
|
||||
UUID ownerId,
|
||||
int level,
|
||||
Optional<PocketPortalLocation> entrance,
|
||||
boolean mobSpawningEnabled
|
||||
Optional<PocketPortalLocation> returnPortal,
|
||||
boolean hostileMobSpawningEnabled,
|
||||
boolean passiveMobSpawningEnabled,
|
||||
PocketBaseBiome biome,
|
||||
boolean flightUnlocked,
|
||||
Optional<PocketPortalLocation> flightPortal,
|
||||
boolean flightEnabled
|
||||
) {
|
||||
public PocketBaseState {
|
||||
if (ownerId == null) {
|
||||
@@ -17,9 +23,50 @@ public record PocketBaseState(
|
||||
throw new IllegalArgumentException("Pocket Base level must not be negative");
|
||||
}
|
||||
entrance = entrance == null ? Optional.empty() : entrance;
|
||||
if (level == 0 && entrance.isPresent()) {
|
||||
throw new IllegalArgumentException("a locked Pocket Base cannot have an entrance");
|
||||
returnPortal = returnPortal == null ? Optional.empty() : returnPortal;
|
||||
flightPortal = flightPortal == null ? Optional.empty() : flightPortal;
|
||||
biome = biome == null ? PocketBaseBiome.THE_VOID : biome;
|
||||
if (level == 0 && (entrance.isPresent() || returnPortal.isPresent()
|
||||
|| flightPortal.isPresent() || flightUnlocked || flightEnabled)) {
|
||||
throw new IllegalArgumentException("a locked Pocket Base cannot have unlocks or portals");
|
||||
}
|
||||
if (!flightUnlocked && (flightPortal.isPresent() || flightEnabled)) {
|
||||
throw new IllegalArgumentException(
|
||||
"Pocket Base flight must be unlocked before it is enabled or shared"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public PocketBaseState(
|
||||
UUID ownerId,
|
||||
int level,
|
||||
Optional<PocketPortalLocation> entrance,
|
||||
Optional<PocketPortalLocation> returnPortal,
|
||||
boolean hostileMobSpawningEnabled,
|
||||
boolean passiveMobSpawningEnabled,
|
||||
PocketBaseBiome biome,
|
||||
boolean flightUnlocked
|
||||
) {
|
||||
this(
|
||||
ownerId, level, entrance, returnPortal, hostileMobSpawningEnabled,
|
||||
passiveMobSpawningEnabled, biome, flightUnlocked, Optional.empty(),
|
||||
flightUnlocked
|
||||
);
|
||||
}
|
||||
|
||||
public PocketBaseState(
|
||||
UUID ownerId,
|
||||
int level,
|
||||
Optional<PocketPortalLocation> entrance,
|
||||
Optional<PocketPortalLocation> returnPortal,
|
||||
boolean hostileMobSpawningEnabled,
|
||||
boolean passiveMobSpawningEnabled,
|
||||
PocketBaseBiome biome
|
||||
) {
|
||||
this(
|
||||
ownerId, level, entrance, returnPortal, hostileMobSpawningEnabled,
|
||||
passiveMobSpawningEnabled, biome, false, Optional.empty(), false
|
||||
);
|
||||
}
|
||||
|
||||
public PocketBaseState(
|
||||
@@ -27,26 +74,90 @@ public record PocketBaseState(
|
||||
int level,
|
||||
Optional<PocketPortalLocation> entrance
|
||||
) {
|
||||
this(ownerId, level, entrance, false);
|
||||
this(
|
||||
ownerId, level, entrance, Optional.empty(), false, false,
|
||||
PocketBaseBiome.THE_VOID, false, Optional.empty(), false
|
||||
);
|
||||
}
|
||||
|
||||
public static PocketBaseState locked(UUID ownerId) {
|
||||
return new PocketBaseState(ownerId, 0, Optional.empty(), false);
|
||||
return new PocketBaseState(
|
||||
ownerId, 0, Optional.empty(), Optional.empty(), false, false,
|
||||
PocketBaseBiome.THE_VOID, false, Optional.empty(), false
|
||||
);
|
||||
}
|
||||
|
||||
public PocketBaseState withLevel(int newLevel) {
|
||||
return new PocketBaseState(ownerId, newLevel, entrance, mobSpawningEnabled);
|
||||
return copy(newLevel, entrance, returnPortal, hostileMobSpawningEnabled,
|
||||
passiveMobSpawningEnabled, biome, flightUnlocked, flightPortal, flightEnabled);
|
||||
}
|
||||
|
||||
public PocketBaseState withEntrance(PocketPortalLocation portal) {
|
||||
return new PocketBaseState(ownerId, level, Optional.of(portal), mobSpawningEnabled);
|
||||
return copy(level, Optional.of(portal), returnPortal, hostileMobSpawningEnabled,
|
||||
passiveMobSpawningEnabled, biome, flightUnlocked, flightPortal, flightEnabled);
|
||||
}
|
||||
|
||||
public PocketBaseState withoutEntrance() {
|
||||
return new PocketBaseState(ownerId, level, Optional.empty(), mobSpawningEnabled);
|
||||
return copy(level, Optional.empty(), returnPortal, hostileMobSpawningEnabled,
|
||||
passiveMobSpawningEnabled, biome, flightUnlocked, flightPortal, flightEnabled);
|
||||
}
|
||||
|
||||
public PocketBaseState withMobSpawningEnabled(boolean enabled) {
|
||||
return new PocketBaseState(ownerId, level, entrance, enabled);
|
||||
public PocketBaseState withReturnPortal(PocketPortalLocation portal) {
|
||||
return copy(level, entrance, Optional.of(portal), hostileMobSpawningEnabled,
|
||||
passiveMobSpawningEnabled, biome, flightUnlocked, flightPortal, flightEnabled);
|
||||
}
|
||||
|
||||
public PocketBaseState withHostileMobSpawningEnabled(boolean enabled) {
|
||||
return copy(level, entrance, returnPortal, enabled, passiveMobSpawningEnabled,
|
||||
biome, flightUnlocked, flightPortal, flightEnabled);
|
||||
}
|
||||
|
||||
public PocketBaseState withPassiveMobSpawningEnabled(boolean enabled) {
|
||||
return copy(level, entrance, returnPortal, hostileMobSpawningEnabled, enabled,
|
||||
biome, flightUnlocked, flightPortal, flightEnabled);
|
||||
}
|
||||
|
||||
public PocketBaseState withBiome(PocketBaseBiome selectedBiome) {
|
||||
return copy(level, entrance, returnPortal, hostileMobSpawningEnabled,
|
||||
passiveMobSpawningEnabled, selectedBiome, flightUnlocked, flightPortal,
|
||||
flightEnabled);
|
||||
}
|
||||
|
||||
public PocketBaseState withFlightUnlocked() {
|
||||
return copy(level, entrance, returnPortal, hostileMobSpawningEnabled,
|
||||
passiveMobSpawningEnabled, biome, true, flightPortal, true);
|
||||
}
|
||||
|
||||
public PocketBaseState withFlightEnabled(boolean enabled) {
|
||||
return copy(level, entrance, returnPortal, hostileMobSpawningEnabled,
|
||||
passiveMobSpawningEnabled, biome, flightUnlocked, flightPortal, enabled);
|
||||
}
|
||||
|
||||
public PocketBaseState withFlightPortal(PocketPortalLocation portal) {
|
||||
return copy(level, entrance, returnPortal, hostileMobSpawningEnabled,
|
||||
passiveMobSpawningEnabled, biome, flightUnlocked, Optional.of(portal),
|
||||
flightEnabled);
|
||||
}
|
||||
|
||||
public PocketBaseState withoutFlightPortal() {
|
||||
return copy(level, entrance, returnPortal, hostileMobSpawningEnabled,
|
||||
passiveMobSpawningEnabled, biome, flightUnlocked, Optional.empty(), flightEnabled);
|
||||
}
|
||||
|
||||
private PocketBaseState copy(
|
||||
int newLevel,
|
||||
Optional<PocketPortalLocation> newEntrance,
|
||||
Optional<PocketPortalLocation> newReturnPortal,
|
||||
boolean hostileEnabled,
|
||||
boolean passiveEnabled,
|
||||
PocketBaseBiome selectedBiome,
|
||||
boolean unlockedFlight,
|
||||
Optional<PocketPortalLocation> newFlightPortal,
|
||||
boolean enabledFlight
|
||||
) {
|
||||
return new PocketBaseState(
|
||||
ownerId, newLevel, newEntrance, newReturnPortal, hostileEnabled, passiveEnabled,
|
||||
selectedBiome, unlockedFlight, newFlightPortal, enabledFlight
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,11 +4,15 @@ 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;
|
||||
@@ -16,23 +20,47 @@ final class PocketBaseWorldService {
|
||||
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) {
|
||||
setMobSpawning(state.ownerId(), state.mobSpawningEnabled());
|
||||
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;
|
||||
@@ -50,6 +78,7 @@ final class PocketBaseWorldService {
|
||||
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);
|
||||
@@ -66,8 +95,18 @@ final class PocketBaseWorldService {
|
||||
return ensureWorld(ownerId);
|
||||
}
|
||||
|
||||
void setMobSpawning(UUID ownerId, boolean enabled) {
|
||||
ensureWorld(ownerId).setSpawnFlags(enabled, enabled);
|
||||
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) {
|
||||
@@ -81,9 +120,11 @@ final class PocketBaseWorldService {
|
||||
);
|
||||
}
|
||||
|
||||
boolean returnPortalIsIntact(UUID ownerId) {
|
||||
PocketPortalLocation portal = returnPortal(ownerId);
|
||||
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(
|
||||
@@ -96,7 +137,7 @@ final class PocketBaseWorldService {
|
||||
World world = server.getWorld(name);
|
||||
boolean created = world == null;
|
||||
if (created) {
|
||||
WorldCreator creator = new WorldCreator(name)
|
||||
WorldCreator creator = new WorldCreator(NamespacedKey.minecraft(name))
|
||||
.environment(World.Environment.NORMAL)
|
||||
.generator(new VoidPocketChunkGenerator())
|
||||
.generateStructures(false);
|
||||
@@ -112,6 +153,40 @@ final class PocketBaseWorldService {
|
||||
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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import java.time.Clock;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.logging.Level;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.command.PluginCommand;
|
||||
import org.bukkit.plugin.java.JavaPlugin;
|
||||
|
||||
@@ -16,6 +17,7 @@ public final class SpigotBasePlugin extends JavaPlugin {
|
||||
private BaseTeleportManager teleportManager;
|
||||
private PocketBaseManager pocketBaseManager;
|
||||
private PocketBaseController pocketBaseController;
|
||||
private PocketBaseKeystoneService keystoneService;
|
||||
|
||||
@Override
|
||||
public void onEnable() {
|
||||
@@ -40,6 +42,17 @@ public final class SpigotBasePlugin extends JavaPlugin {
|
||||
settingsProvider
|
||||
);
|
||||
pocketBaseManager.loadWorlds();
|
||||
NamespacedKey keystoneKey = new NamespacedKey(this, "pocket_base_keystone");
|
||||
keystoneService = new PocketBaseKeystoneService(
|
||||
getServer(),
|
||||
pocketBaseManager,
|
||||
new KeystoneReturnStore(new YamlKeystoneReturnRepository(
|
||||
getDataFolder().toPath().resolve("keystone-returns.yml")
|
||||
)),
|
||||
keystoneKey,
|
||||
getLogger()
|
||||
);
|
||||
keystoneService.registerRecipe();
|
||||
} catch (RuntimeException | IOException exception) {
|
||||
getLogger().log(Level.SEVERE, "Could not initialize Spigot Base", exception);
|
||||
getServer().getPluginManager().disablePlugin(this);
|
||||
@@ -64,7 +77,8 @@ public final class SpigotBasePlugin extends JavaPlugin {
|
||||
settingsProvider
|
||||
);
|
||||
flightController = new BaseFlightController(
|
||||
getServer(), stateManager, secondaryProgressionService, boundsService, settingsProvider
|
||||
getServer(), stateManager, secondaryProgressionService, boundsService,
|
||||
settingsProvider, pocketBaseManager, getLogger()
|
||||
);
|
||||
VisitorPolicy visitorPolicy = new VisitorPolicy();
|
||||
teleportManager = new BaseTeleportManager(
|
||||
@@ -81,11 +95,14 @@ public final class SpigotBasePlugin extends JavaPlugin {
|
||||
boundsService,
|
||||
pocketBaseManager,
|
||||
settingsProvider,
|
||||
getLogger()
|
||||
getLogger(),
|
||||
flightController
|
||||
);
|
||||
getServer().getPluginManager().registerEvents(progressListener, this);
|
||||
getServer().getPluginManager().registerEvents(flightController, this);
|
||||
getServer().getPluginManager().registerEvents(teleportManager, this);
|
||||
getServer().getPluginManager().registerEvents(pocketBaseController, this);
|
||||
getServer().getPluginManager().registerEvents(keystoneService, this);
|
||||
|
||||
command("setbase").setExecutor(new SetBaseCommand(
|
||||
stateManager, baseService, Clock.systemUTC(), pocketBaseController
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
package games.dmg.spigotbase;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Iterator;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.Particle;
|
||||
import org.bukkit.plugin.Plugin;
|
||||
import org.bukkit.scheduler.BukkitTask;
|
||||
|
||||
/** Cosmetic, main-thread effects; never controls teleport timing or eligibility. */
|
||||
final class TeleportParticles {
|
||||
private final Plugin plugin;
|
||||
private final Map<UUID, Effect> effects = new HashMap<>();
|
||||
private BukkitTask task;
|
||||
|
||||
TeleportParticles(Plugin plugin) {
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
void start(UUID playerId, Location origin, int seconds) {
|
||||
effects.put(playerId, new Effect(origin.clone(), Math.max(1L, seconds * 10L)));
|
||||
if (task == null) {
|
||||
task = plugin.getServer().getScheduler().runTaskTimer(plugin, this::tick, 0L, 2L);
|
||||
}
|
||||
}
|
||||
|
||||
void complete(UUID playerId) {
|
||||
Effect effect = effects.get(playerId);
|
||||
if (effect != null) {
|
||||
effect.fading = true;
|
||||
effect.age = 0;
|
||||
}
|
||||
}
|
||||
|
||||
void cancel(UUID playerId) {
|
||||
effects.remove(playerId);
|
||||
stopIfEmpty();
|
||||
}
|
||||
|
||||
void clear() {
|
||||
effects.clear();
|
||||
stopIfEmpty();
|
||||
}
|
||||
|
||||
private void tick() {
|
||||
Iterator<Effect> iterator = effects.values().iterator();
|
||||
while (iterator.hasNext()) {
|
||||
Effect effect = iterator.next();
|
||||
int count = effect.fading
|
||||
? Math.max(1, 12 - (int) (effect.age * 12 / 20))
|
||||
: 2 + (int) (10 * Math.min(1.0, (double) effect.age / effect.duration));
|
||||
double angle = effect.age * 0.3;
|
||||
Location point = effect.origin.clone().add(
|
||||
Math.cos(angle) * 0.65, 1.0 + Math.sin(angle * 0.5) * 0.45,
|
||||
Math.sin(angle) * 0.65
|
||||
);
|
||||
point.getWorld().spawnParticle(Particle.END_ROD, point, count, 0.3, 0.45, 0.3, 0.01);
|
||||
effect.age++;
|
||||
if (effect.fading && effect.age >= 20) {
|
||||
iterator.remove();
|
||||
}
|
||||
}
|
||||
stopIfEmpty();
|
||||
}
|
||||
|
||||
private void stopIfEmpty() {
|
||||
if (effects.isEmpty() && task != null) {
|
||||
task.cancel();
|
||||
task = null;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class Effect {
|
||||
private final Location origin;
|
||||
private final long duration;
|
||||
private long age;
|
||||
private boolean fading;
|
||||
|
||||
private Effect(Location origin, long duration) {
|
||||
this.origin = origin;
|
||||
this.duration = duration;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package games.dmg.spigotbase;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.configuration.ConfigurationSection;
|
||||
import org.bukkit.configuration.InvalidConfigurationException;
|
||||
import org.bukkit.configuration.file.YamlConfiguration;
|
||||
|
||||
public final class YamlKeystoneReturnRepository {
|
||||
private final Path stateFile;
|
||||
|
||||
public YamlKeystoneReturnRepository(Path stateFile) {
|
||||
this.stateFile = stateFile;
|
||||
}
|
||||
|
||||
public Map<UUID, KeystoneReturnLocation> load() throws IOException {
|
||||
if (!Files.exists(stateFile)) {
|
||||
return Map.of();
|
||||
}
|
||||
YamlConfiguration yaml = new YamlConfiguration();
|
||||
try {
|
||||
yaml.load(stateFile.toFile());
|
||||
} catch (InvalidConfigurationException exception) {
|
||||
throw new IOException("Pocket Base Keystone returns are not valid YAML", exception);
|
||||
}
|
||||
Map<UUID, KeystoneReturnLocation> destinations = new HashMap<>();
|
||||
ConfigurationSection players = yaml.getConfigurationSection("players");
|
||||
if (players == null) {
|
||||
return destinations;
|
||||
}
|
||||
for (String key : players.getKeys(false)) {
|
||||
try {
|
||||
String path = "players." + key;
|
||||
String worldId = yaml.getString(path + ".world-id");
|
||||
String worldName = yaml.getString(path + ".world-name");
|
||||
if (worldId == null || worldName == null) {
|
||||
continue;
|
||||
}
|
||||
destinations.put(UUID.fromString(key), new KeystoneReturnLocation(
|
||||
UUID.fromString(worldId),
|
||||
worldName,
|
||||
yaml.getDouble(path + ".x"),
|
||||
yaml.getDouble(path + ".y"),
|
||||
yaml.getDouble(path + ".z"),
|
||||
(float) yaml.getDouble(path + ".yaw"),
|
||||
(float) yaml.getDouble(path + ".pitch")
|
||||
));
|
||||
} catch (IllegalArgumentException ignored) {
|
||||
// Invalid records do not provide a return destination.
|
||||
}
|
||||
}
|
||||
return destinations;
|
||||
}
|
||||
|
||||
public void save(Map<UUID, KeystoneReturnLocation> destinations) throws IOException {
|
||||
Path parent = stateFile.toAbsolutePath().getParent();
|
||||
if (parent != null) {
|
||||
Files.createDirectories(parent);
|
||||
}
|
||||
YamlConfiguration yaml = new YamlConfiguration();
|
||||
for (Map.Entry<UUID, KeystoneReturnLocation> entry : destinations.entrySet()) {
|
||||
String path = "players." + entry.getKey();
|
||||
KeystoneReturnLocation destination = entry.getValue();
|
||||
yaml.set(path + ".world-id", destination.worldId().toString());
|
||||
yaml.set(path + ".world-name", destination.worldName());
|
||||
yaml.set(path + ".x", destination.x());
|
||||
yaml.set(path + ".y", destination.y());
|
||||
yaml.set(path + ".z", destination.z());
|
||||
yaml.set(path + ".yaw", destination.yaw());
|
||||
yaml.set(path + ".pitch", destination.pitch());
|
||||
}
|
||||
Path temporary = Files.createTempFile(parent, "spigot-base-keystone-", ".yml");
|
||||
try {
|
||||
yaml.save(temporary.toFile());
|
||||
try {
|
||||
Files.move(
|
||||
temporary,
|
||||
stateFile,
|
||||
StandardCopyOption.REPLACE_EXISTING,
|
||||
StandardCopyOption.ATOMIC_MOVE
|
||||
);
|
||||
} catch (IOException atomicMoveFailure) {
|
||||
Files.move(temporary, stateFile, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
} finally {
|
||||
Files.deleteIfExists(temporary);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -39,11 +39,20 @@ public final class YamlPocketBaseRepository {
|
||||
UUID ownerId = UUID.fromString(key);
|
||||
String path = "owners." + key;
|
||||
int level = yaml.getInt(path + ".level", 0);
|
||||
boolean flightUnlocked = yaml.getBoolean(
|
||||
path + ".flight-unlocked", false
|
||||
);
|
||||
PocketBaseState state = new PocketBaseState(
|
||||
ownerId,
|
||||
level,
|
||||
loadPortal(yaml, path + ".entrance"),
|
||||
yaml.getBoolean(path + ".mob-spawning-enabled", false)
|
||||
loadPortal(yaml, path + ".return-portal"),
|
||||
loadMobPreference(yaml, path, "hostile-mob-spawning-enabled"),
|
||||
loadMobPreference(yaml, path, "passive-mob-spawning-enabled"),
|
||||
loadBiome(yaml, path),
|
||||
flightUnlocked,
|
||||
loadPortal(yaml, path + ".flight-portal"),
|
||||
yaml.getBoolean(path + ".flight-enabled", flightUnlocked)
|
||||
);
|
||||
states.put(ownerId, state);
|
||||
} catch (IllegalArgumentException ignored) {
|
||||
@@ -62,8 +71,25 @@ public final class YamlPocketBaseRepository {
|
||||
for (PocketBaseState state : states.values()) {
|
||||
String path = "owners." + state.ownerId();
|
||||
yaml.set(path + ".level", state.level());
|
||||
yaml.set(path + ".mob-spawning-enabled", state.mobSpawningEnabled());
|
||||
yaml.set(
|
||||
path + ".hostile-mob-spawning-enabled",
|
||||
state.hostileMobSpawningEnabled()
|
||||
);
|
||||
yaml.set(
|
||||
path + ".passive-mob-spawning-enabled",
|
||||
state.passiveMobSpawningEnabled()
|
||||
);
|
||||
yaml.set(path + ".flight-unlocked", state.flightUnlocked());
|
||||
yaml.set(path + ".flight-enabled", state.flightEnabled());
|
||||
yaml.set(path + ".world-type", state.biome().worldType().commandName());
|
||||
yaml.set(path + ".biome", state.biome().commandName());
|
||||
state.entrance().ifPresent(portal -> savePortal(yaml, path + ".entrance", portal));
|
||||
state.returnPortal().ifPresent(portal ->
|
||||
savePortal(yaml, path + ".return-portal", portal)
|
||||
);
|
||||
state.flightPortal().ifPresent(portal ->
|
||||
savePortal(yaml, path + ".flight-portal", portal)
|
||||
);
|
||||
}
|
||||
Path temporary = Files.createTempFile(parent, "spigot-base-pocket-", ".yml");
|
||||
try {
|
||||
@@ -83,6 +109,28 @@ public final class YamlPocketBaseRepository {
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
@@ -96,7 +144,7 @@ public final class YamlPocketBaseRepository {
|
||||
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 entrance");
|
||||
throw new IllegalArgumentException("invalid Pocket Base portal");
|
||||
}
|
||||
return Optional.of(new PocketPortalLocation(
|
||||
UUID.fromString(worldId),
|
||||
|
||||
@@ -50,6 +50,8 @@ visitor-currency-material: DIAMOND
|
||||
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
|
||||
|
||||
@@ -19,7 +19,7 @@ commands:
|
||||
aliases: [visit]
|
||||
basesettings:
|
||||
description: View progression, upgrade, and manage base settings.
|
||||
usage: /basesettings [status|upgrade|pocket|visitors|navigation|flight|border|spawnable|bossbar]
|
||||
usage: /basesettings [ui|status|upgrade|pocket|visitors|navigation|flight|border|spawnable|bossbar]
|
||||
aliases: [homesettings]
|
||||
baseadmin:
|
||||
description: Administer Spigot Base.
|
||||
|
||||
@@ -12,11 +12,14 @@ import static org.mockito.Mockito.when;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.logging.Logger;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.raid.RaidFinishEvent;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.PlayerInventory;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
@@ -47,6 +50,7 @@ final class BaseFlightControllerTest {
|
||||
PlayerInventory inventory = mock(PlayerInventory.class);
|
||||
|
||||
doReturn(List.of(player)).when(server).getOnlinePlayers();
|
||||
when(server.getPlayer(playerId)).thenReturn(player);
|
||||
when(player.getUniqueId()).thenReturn(playerId);
|
||||
when(player.getName()).thenReturn("Alex");
|
||||
when(player.getGameMode()).thenReturn(org.bukkit.GameMode.SURVIVAL);
|
||||
@@ -62,6 +66,353 @@ final class BaseFlightControllerTest {
|
||||
when(player.getAllowFlight()).thenReturn(true);
|
||||
}
|
||||
|
||||
@Test
|
||||
void ownerWinningRaidInOwnPocketBaseUnlocksFlight() throws Exception {
|
||||
UUID pocketWorldId = UUID.randomUUID();
|
||||
World pocketWorld = mock(World.class);
|
||||
Player winner = mock(Player.class);
|
||||
RaidFinishEvent event = mock(RaidFinishEvent.class);
|
||||
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
|
||||
PocketBaseState locked = new PocketBaseState(playerId, 1, Optional.empty());
|
||||
PocketBaseState unlocked = locked.withFlightUnlocked();
|
||||
BaseFlightController pocketController = new BaseFlightController(
|
||||
server,
|
||||
stateManager,
|
||||
new SecondaryProgressionService(PluginSettings.from(Map.of())),
|
||||
new BaseBoundsService(PluginSettings.from(Map.of())),
|
||||
new PluginSettingsProvider(PluginSettings.from(Map.of())),
|
||||
pocketBases,
|
||||
Logger.getAnonymousLogger()
|
||||
);
|
||||
|
||||
when(event.getWorld()).thenReturn(pocketWorld);
|
||||
when(event.getWinners()).thenReturn(List.of(winner));
|
||||
when(pocketWorld.getUID()).thenReturn(pocketWorldId);
|
||||
when(winner.getUniqueId()).thenReturn(playerId);
|
||||
when(pocketBases.ownerForPocketWorld(pocketWorldId)).thenReturn(Optional.of(playerId));
|
||||
when(pocketBases.state(playerId)).thenReturn(locked);
|
||||
when(pocketBases.unlockFlight(playerId)).thenReturn(unlocked);
|
||||
|
||||
pocketController.onRaidFinish(event);
|
||||
|
||||
verify(pocketBases).unlockFlight(playerId);
|
||||
verify(winner).sendTitle(anyString(), anyString(), anyInt(), anyInt(), anyInt());
|
||||
}
|
||||
|
||||
@Test
|
||||
void raidDoesNotUnlockFlightWhenPocketOwnerIsNotAWinner() throws Exception {
|
||||
UUID pocketWorldId = UUID.randomUUID();
|
||||
UUID ownerId = UUID.randomUUID();
|
||||
World pocketWorld = mock(World.class);
|
||||
Player visitor = mock(Player.class);
|
||||
RaidFinishEvent event = mock(RaidFinishEvent.class);
|
||||
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
|
||||
BaseFlightController pocketController = new BaseFlightController(
|
||||
server,
|
||||
stateManager,
|
||||
new SecondaryProgressionService(PluginSettings.from(Map.of())),
|
||||
new BaseBoundsService(PluginSettings.from(Map.of())),
|
||||
new PluginSettingsProvider(PluginSettings.from(Map.of())),
|
||||
pocketBases,
|
||||
Logger.getAnonymousLogger()
|
||||
);
|
||||
|
||||
when(event.getWorld()).thenReturn(pocketWorld);
|
||||
when(event.getWinners()).thenReturn(List.of(visitor));
|
||||
when(pocketWorld.getUID()).thenReturn(pocketWorldId);
|
||||
when(visitor.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
when(pocketBases.ownerForPocketWorld(pocketWorldId)).thenReturn(Optional.of(ownerId));
|
||||
|
||||
pocketController.onRaidFinish(event);
|
||||
|
||||
verify(pocketBases, never()).unlockFlight(ownerId);
|
||||
}
|
||||
|
||||
@Test
|
||||
void unlockedOwnerReceivesFlightInsidePocketBaseBuffer() {
|
||||
UUID pocketWorldId = UUID.randomUUID();
|
||||
World pocketWorld = mock(World.class);
|
||||
Location location = mock(Location.class);
|
||||
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
|
||||
PocketBaseState pocket = new PocketBaseState(playerId, 1, Optional.empty())
|
||||
.withFlightUnlocked();
|
||||
PlayerState noNormalFlight = PlayerState.newPlayer(playerId, "Alex");
|
||||
BaseFlightController pocketController = new BaseFlightController(
|
||||
server,
|
||||
stateManager,
|
||||
new SecondaryProgressionService(PluginSettings.from(Map.of())),
|
||||
new BaseBoundsService(PluginSettings.from(Map.of())),
|
||||
new PluginSettingsProvider(PluginSettings.from(Map.of())),
|
||||
pocketBases,
|
||||
Logger.getAnonymousLogger()
|
||||
);
|
||||
|
||||
when(stateManager.player(playerId, "Alex")).thenReturn(noNormalFlight);
|
||||
when(player.getWorld()).thenReturn(pocketWorld);
|
||||
when(player.getLocation()).thenReturn(location);
|
||||
when(player.getAllowFlight()).thenReturn(false);
|
||||
when(pocketWorld.getUID()).thenReturn(pocketWorldId);
|
||||
when(location.getX()).thenReturn(40.5);
|
||||
when(location.getZ()).thenReturn(0.5);
|
||||
when(location.getBlockX()).thenReturn(40);
|
||||
when(location.getBlockZ()).thenReturn(0);
|
||||
when(pocketBases.ownerForPocketWorld(pocketWorldId)).thenReturn(Optional.of(playerId));
|
||||
when(pocketBases.state(playerId)).thenReturn(pocket);
|
||||
when(pocketBases.policy()).thenReturn(
|
||||
new PocketBasePolicy(PluginSettings.from(Map.of()))
|
||||
);
|
||||
|
||||
pocketController.run();
|
||||
|
||||
verify(player).setAllowFlight(true);
|
||||
}
|
||||
|
||||
@Test
|
||||
void disabledAutomaticPocketBaseFlightDoesNotGrantOwnerFlight() {
|
||||
UUID pocketWorldId = UUID.randomUUID();
|
||||
World pocketWorld = mock(World.class);
|
||||
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
|
||||
PocketBaseState pocket = new PocketBaseState(playerId, 1, Optional.empty())
|
||||
.withFlightUnlocked()
|
||||
.withFlightEnabled(false);
|
||||
BaseFlightController pocketController = new BaseFlightController(
|
||||
server,
|
||||
stateManager,
|
||||
new SecondaryProgressionService(PluginSettings.from(Map.of())),
|
||||
new BaseBoundsService(PluginSettings.from(Map.of())),
|
||||
new PluginSettingsProvider(PluginSettings.from(Map.of())),
|
||||
pocketBases,
|
||||
Logger.getAnonymousLogger()
|
||||
);
|
||||
|
||||
when(stateManager.player(playerId, "Alex")).thenReturn(
|
||||
PlayerState.newPlayer(playerId, "Alex")
|
||||
);
|
||||
when(player.getWorld()).thenReturn(pocketWorld);
|
||||
when(player.getAllowFlight()).thenReturn(false);
|
||||
when(pocketWorld.getUID()).thenReturn(pocketWorldId);
|
||||
when(pocketBases.ownerForPocketWorld(pocketWorldId)).thenReturn(Optional.of(playerId));
|
||||
when(pocketBases.state(playerId)).thenReturn(pocket);
|
||||
|
||||
pocketController.run();
|
||||
|
||||
verify(player, never()).setAllowFlight(true);
|
||||
}
|
||||
|
||||
@Test
|
||||
void flightPortalTemporarilyGrantsGuestPocketBaseFlight() {
|
||||
UUID ownerId = UUID.randomUUID();
|
||||
UUID pocketWorldId = UUID.randomUUID();
|
||||
World pocketWorld = mock(World.class);
|
||||
Location location = mock(Location.class);
|
||||
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
|
||||
PocketBaseState pocket = new PocketBaseState(ownerId, 1, Optional.empty())
|
||||
.withFlightUnlocked()
|
||||
.withFlightEnabled(false);
|
||||
BaseFlightController pocketController = new BaseFlightController(
|
||||
server,
|
||||
stateManager,
|
||||
new SecondaryProgressionService(PluginSettings.from(Map.of())),
|
||||
new BaseBoundsService(PluginSettings.from(Map.of())),
|
||||
new PluginSettingsProvider(PluginSettings.from(Map.of())),
|
||||
pocketBases,
|
||||
Logger.getAnonymousLogger()
|
||||
);
|
||||
|
||||
when(stateManager.player(playerId, "Alex")).thenReturn(
|
||||
PlayerState.newPlayer(playerId, "Alex")
|
||||
);
|
||||
when(player.getWorld()).thenReturn(pocketWorld);
|
||||
when(player.getLocation()).thenReturn(location);
|
||||
when(player.getAllowFlight()).thenReturn(false);
|
||||
when(pocketWorld.getUID()).thenReturn(pocketWorldId);
|
||||
when(pocketBases.ownerForPocketWorld(pocketWorldId)).thenReturn(Optional.of(ownerId));
|
||||
when(pocketBases.state(ownerId)).thenReturn(pocket);
|
||||
when(pocketBases.policy()).thenReturn(
|
||||
new PocketBasePolicy(PluginSettings.from(Map.of()))
|
||||
);
|
||||
|
||||
when(player.getGameMode()).thenReturn(org.bukkit.GameMode.CREATIVE);
|
||||
pocketController.grantPocketPortalFlight(player, ownerId);
|
||||
verify(player, never()).setFlying(true);
|
||||
verify(player, never()).sendMessage(anyString());
|
||||
|
||||
when(player.getGameMode()).thenReturn(org.bukkit.GameMode.SURVIVAL);
|
||||
when(location.getBlockX()).thenReturn(10000);
|
||||
pocketController.grantPocketPortalFlight(player, ownerId);
|
||||
verify(player, never()).setFlying(true);
|
||||
verify(player, never()).sendMessage(anyString());
|
||||
|
||||
when(location.getBlockX()).thenReturn(0);
|
||||
pocketController.grantPocketPortalFlight(player, ownerId);
|
||||
|
||||
verify(player).setAllowFlight(true);
|
||||
verify(player).setFlying(true);
|
||||
verify(player).sendMessage(org.bukkit.ChatColor.GREEN + "Pocket dimension flight enabled!");
|
||||
|
||||
when(player.getAllowFlight()).thenReturn(true);
|
||||
pocketController.grantPocketPortalFlight(player, ownerId);
|
||||
verify(player, times(2)).setFlying(true);
|
||||
verify(player, times(2)).sendMessage(org.bukkit.ChatColor.GREEN + "Pocket dimension flight enabled!");
|
||||
}
|
||||
|
||||
@Test
|
||||
void portalFlightEndsAfterGuestLeavesPocketBase() {
|
||||
UUID ownerId = UUID.randomUUID();
|
||||
UUID pocketWorldId = UUID.randomUUID();
|
||||
UUID normalWorldId = UUID.randomUUID();
|
||||
World pocketWorld = mock(World.class);
|
||||
World normalWorld = mock(World.class);
|
||||
Location location = mock(Location.class);
|
||||
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
|
||||
PocketBaseState pocket = new PocketBaseState(ownerId, 1, Optional.empty())
|
||||
.withFlightUnlocked()
|
||||
.withFlightEnabled(false);
|
||||
BaseFlightController pocketController = new BaseFlightController(
|
||||
server,
|
||||
stateManager,
|
||||
new SecondaryProgressionService(PluginSettings.from(Map.of())),
|
||||
new BaseBoundsService(PluginSettings.from(Map.of())),
|
||||
new PluginSettingsProvider(PluginSettings.from(Map.of())),
|
||||
pocketBases,
|
||||
Logger.getAnonymousLogger()
|
||||
);
|
||||
|
||||
when(stateManager.player(playerId, "Alex")).thenReturn(
|
||||
PlayerState.newPlayer(playerId, "Alex")
|
||||
);
|
||||
when(player.getWorld()).thenReturn(pocketWorld, normalWorld);
|
||||
when(player.getLocation()).thenReturn(location);
|
||||
when(player.getAllowFlight()).thenReturn(false);
|
||||
when(pocketWorld.getUID()).thenReturn(pocketWorldId);
|
||||
when(normalWorld.getUID()).thenReturn(normalWorldId);
|
||||
when(pocketBases.ownerForPocketWorld(pocketWorldId)).thenReturn(Optional.of(ownerId));
|
||||
when(pocketBases.ownerForPocketWorld(normalWorldId)).thenReturn(Optional.empty());
|
||||
when(pocketBases.state(ownerId)).thenReturn(pocket);
|
||||
when(pocketBases.policy()).thenReturn(
|
||||
new PocketBasePolicy(PluginSettings.from(Map.of()))
|
||||
);
|
||||
|
||||
pocketController.grantPocketPortalFlight(player, ownerId);
|
||||
pocketController.run();
|
||||
pocketController.run();
|
||||
|
||||
verify(player).setFlying(false);
|
||||
verify(player).setAllowFlight(false);
|
||||
}
|
||||
|
||||
@Test
|
||||
void flyingInPocketBaseBufferShowsLeavingWarning() {
|
||||
UUID pocketWorldId = UUID.randomUUID();
|
||||
World pocketWorld = mock(World.class);
|
||||
Location location = mock(Location.class);
|
||||
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
|
||||
PocketBaseState pocket = new PocketBaseState(playerId, 1, Optional.empty())
|
||||
.withFlightUnlocked();
|
||||
BaseFlightController pocketController = new BaseFlightController(
|
||||
server,
|
||||
stateManager,
|
||||
new SecondaryProgressionService(PluginSettings.from(Map.of())),
|
||||
new BaseBoundsService(PluginSettings.from(Map.of())),
|
||||
new PluginSettingsProvider(PluginSettings.from(Map.of())),
|
||||
pocketBases,
|
||||
Logger.getAnonymousLogger()
|
||||
);
|
||||
|
||||
when(stateManager.player(playerId, "Alex")).thenReturn(
|
||||
PlayerState.newPlayer(playerId, "Alex")
|
||||
);
|
||||
when(player.getWorld()).thenReturn(pocketWorld);
|
||||
when(player.getLocation()).thenReturn(location);
|
||||
when(player.isFlying()).thenReturn(true);
|
||||
when(pocketWorld.getUID()).thenReturn(pocketWorldId);
|
||||
when(location.getBlockX()).thenReturn(40);
|
||||
when(location.getBlockZ()).thenReturn(0);
|
||||
when(pocketBases.ownerForPocketWorld(pocketWorldId)).thenReturn(Optional.of(playerId));
|
||||
when(pocketBases.state(playerId)).thenReturn(pocket);
|
||||
when(pocketBases.policy()).thenReturn(
|
||||
new PocketBasePolicy(PluginSettings.from(Map.of()))
|
||||
);
|
||||
|
||||
pocketController.run();
|
||||
|
||||
verify(player).sendTitle(anyString(), anyString(), anyInt(), anyInt(), anyInt());
|
||||
}
|
||||
|
||||
@Test
|
||||
void pocketBaseFlightEndsBeyondSixteenBlockBuffer() {
|
||||
UUID pocketWorldId = UUID.randomUUID();
|
||||
World pocketWorld = mock(World.class);
|
||||
Location location = mock(Location.class);
|
||||
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
|
||||
PocketBaseState pocket = new PocketBaseState(playerId, 1, Optional.empty())
|
||||
.withFlightUnlocked();
|
||||
BaseFlightController pocketController = new BaseFlightController(
|
||||
server,
|
||||
stateManager,
|
||||
new SecondaryProgressionService(PluginSettings.from(Map.of())),
|
||||
new BaseBoundsService(PluginSettings.from(Map.of())),
|
||||
new PluginSettingsProvider(PluginSettings.from(Map.of())),
|
||||
pocketBases,
|
||||
Logger.getAnonymousLogger()
|
||||
);
|
||||
|
||||
when(stateManager.player(playerId, "Alex")).thenReturn(
|
||||
PlayerState.newPlayer(playerId, "Alex")
|
||||
);
|
||||
when(player.getWorld()).thenReturn(pocketWorld);
|
||||
when(player.getLocation()).thenReturn(location);
|
||||
when(player.getAllowFlight()).thenReturn(false);
|
||||
when(pocketWorld.getUID()).thenReturn(pocketWorldId);
|
||||
when(location.getBlockX()).thenReturn(40, 48);
|
||||
when(location.getBlockZ()).thenReturn(0);
|
||||
when(pocketBases.ownerForPocketWorld(pocketWorldId)).thenReturn(Optional.of(playerId));
|
||||
when(pocketBases.state(playerId)).thenReturn(pocket);
|
||||
when(pocketBases.policy()).thenReturn(
|
||||
new PocketBasePolicy(PluginSettings.from(Map.of()))
|
||||
);
|
||||
|
||||
pocketController.run();
|
||||
pocketController.run();
|
||||
|
||||
verify(player).setFlying(false);
|
||||
verify(player).setAllowFlight(false);
|
||||
}
|
||||
|
||||
@Test
|
||||
void visitorDoesNotReceiveUnlockedOwnersPocketBaseFlight() {
|
||||
UUID ownerId = UUID.randomUUID();
|
||||
UUID pocketWorldId = UUID.randomUUID();
|
||||
World pocketWorld = mock(World.class);
|
||||
Location location = mock(Location.class);
|
||||
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
|
||||
PocketBaseState pocket = new PocketBaseState(ownerId, 1, Optional.empty())
|
||||
.withFlightUnlocked();
|
||||
BaseFlightController pocketController = new BaseFlightController(
|
||||
server,
|
||||
stateManager,
|
||||
new SecondaryProgressionService(PluginSettings.from(Map.of())),
|
||||
new BaseBoundsService(PluginSettings.from(Map.of())),
|
||||
new PluginSettingsProvider(PluginSettings.from(Map.of())),
|
||||
pocketBases,
|
||||
Logger.getAnonymousLogger()
|
||||
);
|
||||
|
||||
when(stateManager.player(playerId, "Alex")).thenReturn(
|
||||
PlayerState.newPlayer(playerId, "Alex")
|
||||
);
|
||||
when(player.getWorld()).thenReturn(pocketWorld);
|
||||
when(player.getLocation()).thenReturn(location);
|
||||
when(player.getAllowFlight()).thenReturn(false);
|
||||
when(pocketWorld.getUID()).thenReturn(pocketWorldId);
|
||||
when(pocketBases.ownerForPocketWorld(pocketWorldId)).thenReturn(Optional.of(ownerId));
|
||||
when(pocketBases.state(ownerId)).thenReturn(pocket);
|
||||
|
||||
pocketController.run();
|
||||
|
||||
verify(player, never()).setAllowFlight(true);
|
||||
}
|
||||
|
||||
@Test
|
||||
void walkingInWarningBufferDoesNotShowLeavingBaseWarning() {
|
||||
when(player.isFlying()).thenReturn(false);
|
||||
|
||||
@@ -15,18 +15,35 @@ import java.io.IOException;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.function.UnaryOperator;
|
||||
import org.bukkit.ChatColor;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.PlayerInventory;
|
||||
import net.kyori.adventure.dialog.DialogLike;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
final class BaseSettingsCommandTest {
|
||||
@Test
|
||||
void rejectsNonPlayerExecution() {
|
||||
CommandSender sender = mock(CommandSender.class);
|
||||
BaseSettingsCommand command = new BaseSettingsCommand(
|
||||
mock(BaseStateManager.class),
|
||||
new PluginSettingsProvider(PluginSettings.from(Map.of())),
|
||||
mock(BaseFlightController.class)
|
||||
);
|
||||
|
||||
command.onCommand(sender, null, "basesettings", new String[0]);
|
||||
|
||||
verify(sender).sendMessage("Only players have base progression.");
|
||||
}
|
||||
|
||||
@Test
|
||||
void spawnableEnableIsIdempotentAfterUnlock() {
|
||||
UUID playerId = UUID.randomUUID();
|
||||
@@ -96,14 +113,56 @@ final class BaseSettingsCommandTest {
|
||||
List.of("upgrade"),
|
||||
command.onTabComplete(null, null, "basesettings", new String[] {"pocket", "u"})
|
||||
);
|
||||
assertEquals(
|
||||
List.of("flight"),
|
||||
command.onTabComplete(null, null, "basesettings", new String[] {"pocket", "f"})
|
||||
);
|
||||
assertEquals(
|
||||
List.of("disable"),
|
||||
command.onTabComplete(
|
||||
null, null, "basesettings", new String[] {"pocket", "flight", "d"}
|
||||
)
|
||||
);
|
||||
assertEquals(
|
||||
List.of("mobs"),
|
||||
command.onTabComplete(null, null, "basesettings", new String[] {"pocket", "m"})
|
||||
);
|
||||
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", "e"}
|
||||
null, null, "basesettings",
|
||||
new String[] {"pocket", "mobs", "hostile", "e"}
|
||||
)
|
||||
);
|
||||
for (String setting : List.of("navigation", "flight", "border", "spawnable", "bossbar")) {
|
||||
@@ -124,8 +183,8 @@ final class BaseSettingsCommandTest {
|
||||
|
||||
assertEquals(
|
||||
List.of(
|
||||
"status", "upgrade", "pocket", "visitors", "navigation", "flight", "border",
|
||||
"spawnable", "bossbar"
|
||||
"ui", "status", "upgrade", "pocket", "visitors", "navigation", "flight",
|
||||
"border", "spawnable", "bossbar"
|
||||
),
|
||||
command.onTabComplete(null, null, "basesettings", new String[] {""})
|
||||
);
|
||||
@@ -138,10 +197,12 @@ final class BaseSettingsCommandTest {
|
||||
when(player.getUniqueId()).thenReturn(playerId);
|
||||
when(player.getName()).thenReturn("Host");
|
||||
PlayerInventory inventory = mock(PlayerInventory.class);
|
||||
ItemStack firstStack = item(Material.DIAMOND, 64);
|
||||
ItemStack secondStack = item(Material.DIAMOND, 64);
|
||||
when(player.getInventory()).thenReturn(inventory);
|
||||
when(inventory.getStorageContents()).thenReturn(new ItemStack[] {
|
||||
new ItemStack(Material.DIAMOND, 64),
|
||||
new ItemStack(Material.DIAMOND, 64)
|
||||
firstStack,
|
||||
secondStack
|
||||
});
|
||||
|
||||
PlayerState current = PlayerState.newPlayer(playerId, "Host")
|
||||
@@ -179,10 +240,9 @@ final class BaseSettingsCommandTest {
|
||||
when(player.getUniqueId()).thenReturn(playerId);
|
||||
when(player.getName()).thenReturn("Builder");
|
||||
PlayerInventory inventory = mock(PlayerInventory.class);
|
||||
ItemStack currency = item(Material.DIAMOND_BLOCK, 64);
|
||||
when(player.getInventory()).thenReturn(inventory);
|
||||
when(inventory.getStorageContents()).thenReturn(new ItemStack[] {
|
||||
new ItemStack(Material.DIAMOND_BLOCK, 64)
|
||||
});
|
||||
when(inventory.getStorageContents()).thenReturn(new ItemStack[] {currency});
|
||||
PlayerState owner = PlayerState.newPlayer(playerId, "Builder")
|
||||
.withAdministrativeLevels(4, 0, 0, 0, 0, false, false, true)
|
||||
.withBase(
|
||||
@@ -210,20 +270,52 @@ final class BaseSettingsCommandTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void pocketOwnerCanEnableNaturalMobSpawning() throws Exception {
|
||||
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(), false)
|
||||
);
|
||||
when(pocketBases.setMobSpawning(playerId, true)).thenReturn(
|
||||
new PocketBaseState(playerId, 1, java.util.Optional.empty(), true)
|
||||
new PocketBaseState(playerId, 1, java.util.Optional.empty())
|
||||
);
|
||||
BaseSettingsCommand command = new BaseSettingsCommand(
|
||||
stateManager,
|
||||
@@ -232,13 +324,50 @@ final class BaseSettingsCommandTest {
|
||||
pocketBases
|
||||
);
|
||||
|
||||
command.onCommand(
|
||||
player, null, "basesettings", new String[] {"pocket", "mobs", "enable"}
|
||||
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
|
||||
);
|
||||
|
||||
verify(pocketBases).setMobSpawning(playerId, true);
|
||||
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("mob spawning") && message.contains("enabled")
|
||||
message -> message.contains("hostile mob spawning") && message.contains("enabled")
|
||||
));
|
||||
}
|
||||
|
||||
@@ -253,9 +382,9 @@ final class BaseSettingsCommandTest {
|
||||
.thenReturn(PlayerState.newPlayer(playerId, "Builder"));
|
||||
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
|
||||
when(pocketBases.state(playerId)).thenReturn(
|
||||
new PocketBaseState(playerId, 1, java.util.Optional.empty(), false)
|
||||
new PocketBaseState(playerId, 1, java.util.Optional.empty())
|
||||
);
|
||||
when(pocketBases.setMobSpawning(playerId, true))
|
||||
when(pocketBases.setPassiveMobSpawning(playerId, true))
|
||||
.thenThrow(new IOException("save failed"));
|
||||
BaseSettingsCommand command = new BaseSettingsCommand(
|
||||
stateManager,
|
||||
@@ -264,9 +393,9 @@ final class BaseSettingsCommandTest {
|
||||
pocketBases
|
||||
);
|
||||
|
||||
command.onCommand(
|
||||
player, null, "basesettings", new String[] {"pocket", "mobs", "enable"}
|
||||
);
|
||||
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")
|
||||
@@ -294,11 +423,11 @@ final class BaseSettingsCommandTest {
|
||||
pocketBases
|
||||
);
|
||||
|
||||
command.onCommand(
|
||||
player, null, "basesettings", new String[] {"pocket", "mobs", "enable"}
|
||||
);
|
||||
command.onCommand(player, null, "basesettings", new String[] {
|
||||
"pocket", "mobs", "hostile", "enable"
|
||||
});
|
||||
|
||||
verify(pocketBases, never()).setMobSpawning(playerId, true);
|
||||
verify(pocketBases, never()).setHostileMobSpawning(playerId, true);
|
||||
verify(player).sendMessage(org.mockito.ArgumentMatchers.<String>argThat(
|
||||
message -> message.contains("Pocket Base I") && message.contains("locked")
|
||||
));
|
||||
@@ -311,10 +440,9 @@ final class BaseSettingsCommandTest {
|
||||
when(player.getUniqueId()).thenReturn(playerId);
|
||||
when(player.getName()).thenReturn("Builder");
|
||||
PlayerInventory inventory = mock(PlayerInventory.class);
|
||||
ItemStack currency = item(Material.DIAMOND_BLOCK, 64);
|
||||
when(player.getInventory()).thenReturn(inventory);
|
||||
when(inventory.getStorageContents()).thenReturn(new ItemStack[] {
|
||||
new ItemStack(Material.DIAMOND_BLOCK, 64)
|
||||
});
|
||||
when(inventory.getStorageContents()).thenReturn(new ItemStack[] {currency});
|
||||
PlayerState owner = PlayerState.newPlayer(playerId, "Builder")
|
||||
.withAdministrativeLevels(4, 0, 0, 0, 0, false, false, true)
|
||||
.withBase(
|
||||
@@ -343,7 +471,42 @@ final class BaseSettingsCommandTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void statusDisplaysPocketMobSpawningPreference() {
|
||||
void ownerCanDisableAutomaticPocketBaseFlight() throws Exception {
|
||||
UUID playerId = UUID.randomUUID();
|
||||
Player player = mock(Player.class);
|
||||
when(player.getUniqueId()).thenReturn(playerId);
|
||||
when(player.getName()).thenReturn("Builder");
|
||||
BaseStateManager stateManager = mock(BaseStateManager.class);
|
||||
when(stateManager.player(playerId, "Builder")).thenReturn(
|
||||
PlayerState.newPlayer(playerId, "Builder")
|
||||
);
|
||||
PocketBaseState enabled = new PocketBaseState(playerId, 1, Optional.empty())
|
||||
.withFlightUnlocked();
|
||||
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
|
||||
when(pocketBases.state(playerId)).thenReturn(enabled);
|
||||
when(pocketBases.setFlightEnabled(playerId, false)).thenReturn(
|
||||
enabled.withFlightEnabled(false)
|
||||
);
|
||||
BaseSettingsCommand command = new BaseSettingsCommand(
|
||||
stateManager,
|
||||
new PluginSettingsProvider(PluginSettings.from(Map.of())),
|
||||
mock(BaseFlightController.class),
|
||||
pocketBases
|
||||
);
|
||||
|
||||
command.onCommand(
|
||||
player, null, "basesettings",
|
||||
new String[] {"pocket", "flight", "disable"}
|
||||
);
|
||||
|
||||
verify(pocketBases).setFlightEnabled(playerId, false);
|
||||
verify(player).sendMessage(org.mockito.ArgumentMatchers.<String>argThat(
|
||||
message -> message.contains("automatic flight") && message.contains("disabled")
|
||||
));
|
||||
}
|
||||
|
||||
@Test
|
||||
void statusDisplaysPocketMobSpawningPreferences() {
|
||||
UUID playerId = UUID.randomUUID();
|
||||
Player player = mock(Player.class);
|
||||
when(player.getUniqueId()).thenReturn(playerId);
|
||||
@@ -353,7 +516,10 @@ final class BaseSettingsCommandTest {
|
||||
.thenReturn(PlayerState.newPlayer(playerId, "Builder"));
|
||||
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
|
||||
when(pocketBases.state(playerId)).thenReturn(
|
||||
new PocketBaseState(playerId, 1, java.util.Optional.empty(), false)
|
||||
new PocketBaseState(
|
||||
playerId, 1, java.util.Optional.empty(), java.util.Optional.empty(),
|
||||
true, false, PocketBaseBiome.THE_VOID
|
||||
)
|
||||
);
|
||||
BaseSettingsCommand command = new BaseSettingsCommand(
|
||||
stateManager,
|
||||
@@ -366,7 +532,10 @@ final class BaseSettingsCommandTest {
|
||||
|
||||
verify(player).sendMessage(org.mockito.ArgumentMatchers.<String>argThat(
|
||||
message -> message.contains("Pocket Base 1")
|
||||
&& message.contains("mob spawning=disabled")
|
||||
&& message.contains("type=void/the_void")
|
||||
&& message.contains("hostile spawning=enabled")
|
||||
&& message.contains("passive spawning=disabled")
|
||||
&& message.contains("flight=complete a raid to unlock")
|
||||
));
|
||||
}
|
||||
|
||||
@@ -419,7 +588,7 @@ final class BaseSettingsCommandTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void defaultAndStatusDisplayTheFullProgressReport() {
|
||||
void defaultAndUiOpenDialogWhileStatusDisplaysTheFullProgressReport() {
|
||||
UUID playerId = UUID.randomUUID();
|
||||
Player player = mock(Player.class);
|
||||
when(player.getUniqueId()).thenReturn(playerId);
|
||||
@@ -431,21 +600,25 @@ final class BaseSettingsCommandTest {
|
||||
BaseSettingsCommand command = new BaseSettingsCommand(
|
||||
stateManager,
|
||||
new PluginSettingsProvider(PluginSettings.from(Map.of())),
|
||||
mock(BaseFlightController.class)
|
||||
mock(BaseFlightController.class),
|
||||
null,
|
||||
(owner, pocket) -> null
|
||||
);
|
||||
|
||||
command.onCommand(player, null, "basesettings", new String[0]);
|
||||
command.onCommand(player, null, "homesettings", new String[] {"ui"});
|
||||
command.onCommand(player, null, "basesettings", new String[] {"status"});
|
||||
|
||||
verify(player, times(2)).sendMessage(ChatColor.GOLD + "=== Base Progress ===");
|
||||
verify(player, times(2)).sendMessage(
|
||||
verify(player, times(2)).showDialog((DialogLike) null);
|
||||
verify(player).sendMessage(ChatColor.GOLD + "=== Base Progress ===");
|
||||
verify(player).sendMessage(
|
||||
org.mockito.ArgumentMatchers.<String>argThat(message ->
|
||||
message.contains("Spawnable Overlay:")
|
||||
&& message.contains("0/250 placements")
|
||||
&& message.contains("locked")
|
||||
)
|
||||
);
|
||||
verify(player, times(2)).sendMessage(
|
||||
verify(player).sendMessage(
|
||||
org.mockito.ArgumentMatchers.<String>argThat(message ->
|
||||
message.contains("Settings: visitors=")
|
||||
&& message.contains("navigation=")
|
||||
@@ -457,6 +630,18 @@ final class BaseSettingsCommandTest {
|
||||
);
|
||||
}
|
||||
|
||||
private static ItemStack item(Material material, int amount) {
|
||||
ItemStack item = mock(ItemStack.class);
|
||||
ItemStack copy = mock(ItemStack.class);
|
||||
when(item.getType()).thenReturn(material);
|
||||
when(item.getAmount()).thenReturn(amount);
|
||||
when(item.clone()).thenReturn(copy);
|
||||
when(copy.getType()).thenReturn(material);
|
||||
when(copy.getAmount()).thenReturn(amount);
|
||||
when(copy.clone()).thenReturn(copy);
|
||||
return item;
|
||||
}
|
||||
|
||||
private static CommandResult execute(PlayerState current, String... arguments) {
|
||||
Player player = mock(Player.class);
|
||||
when(player.getUniqueId()).thenReturn(current.playerId());
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
package games.dmg.spigotbase;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import net.kyori.adventure.text.event.ClickEvent;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
final class BaseSettingsDialogFactoryTest {
|
||||
@Test
|
||||
void createsStaticRunCommandActionWithoutMacroVariables() {
|
||||
ClickEvent<ClickEvent.Payload.Text> action = BaseSettingsDialogFactory.commandClick(
|
||||
"/basesettings status"
|
||||
);
|
||||
|
||||
assertEquals(ClickEvent.Action.RUN_COMMAND, action.action());
|
||||
ClickEvent.Payload.Text payload = assertInstanceOf(
|
||||
ClickEvent.Payload.Text.class,
|
||||
action.payload()
|
||||
);
|
||||
assertEquals("/basesettings status", payload.value());
|
||||
}
|
||||
|
||||
@Test
|
||||
void createsBackButtonThatReturnsToMainSettingsDashboard() {
|
||||
BaseSettingsDialogFactory.ButtonSpec back =
|
||||
BaseSettingsDialogFactory.backButtonSpec();
|
||||
|
||||
assertEquals("Back", back.label());
|
||||
assertEquals("/basesettings ui", back.command());
|
||||
}
|
||||
|
||||
@Test
|
||||
void createsControlsForEveryBaseSettingAndConfirmedPurchase() {
|
||||
UUID playerId = UUID.randomUUID();
|
||||
PlayerState owner = PlayerState.newPlayer(playerId, "Builder")
|
||||
.withAdministrativeLevels(4, 2, 2, 1, 1, true, true, true)
|
||||
.withBase(
|
||||
new BaseLocation(UUID.randomUUID(), "world", 10, 64, 20, 10, 20),
|
||||
Instant.EPOCH
|
||||
)
|
||||
.withBorderEnabled(true)
|
||||
.withTotalBlocksPlaced(250)
|
||||
.withSpawnableOverlayEnabled(true);
|
||||
PocketBaseState pocket = new PocketBaseState(
|
||||
playerId,
|
||||
1,
|
||||
Optional.empty(),
|
||||
Optional.empty(),
|
||||
true,
|
||||
false,
|
||||
PocketBaseBiome.PLAINS,
|
||||
true
|
||||
);
|
||||
|
||||
BaseSettingsDialogFactory.DialogSpec specification =
|
||||
new BaseSettingsDialogFactory(PluginSettings.from(Map.of()))
|
||||
.specification(owner, pocket);
|
||||
|
||||
BaseSettingsDialogFactory.ListSpec root = assertInstanceOf(
|
||||
BaseSettingsDialogFactory.ListSpec.class,
|
||||
specification
|
||||
);
|
||||
assertTrue(root.content().message().contains("Base 4/4"));
|
||||
BaseSettingsDialogFactory.ListSpec pocketSettings = assertInstanceOf(
|
||||
BaseSettingsDialogFactory.ListSpec.class,
|
||||
root.dialogs().get(3)
|
||||
);
|
||||
assertTrue(pocketSettings.content().message().contains(
|
||||
"Flight: Enabled"
|
||||
));
|
||||
List<String> commands = commands(root);
|
||||
assertTrue(commands.contains("/basesettings status"));
|
||||
assertTrue(commands.contains("/basesettings visitors blocked"));
|
||||
assertTrue(commands.contains("/basesettings navigation disable"));
|
||||
assertTrue(commands.contains("/basesettings flight disable"));
|
||||
assertTrue(commands.contains("/basesettings border disable"));
|
||||
assertTrue(commands.contains("/basesettings spawnable disable"));
|
||||
assertTrue(commands.contains("/basesettings bossbar disable"));
|
||||
assertTrue(commands.contains("/basesettings pocket upgrade"));
|
||||
assertTrue(commands.contains("/basesettings pocket flight disable"));
|
||||
assertTrue(commands.contains("/basesettings pocket mobs hostile disable"));
|
||||
assertTrue(commands.contains("/basesettings pocket mobs passive enable"));
|
||||
assertTrue(commands.contains("/basesettings pocket type nether crimson_forest"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void requiresConfirmationForBaseIvPurchase() {
|
||||
UUID playerId = UUID.randomUUID();
|
||||
PlayerState owner = PlayerState.newPlayer(playerId, "Builder")
|
||||
.withAdministrativeLevels(3, 0, 0, 0, 0, false, false, false)
|
||||
.withBase(
|
||||
new BaseLocation(UUID.randomUUID(), "world", 0, 64, 0, 0, 0),
|
||||
Instant.EPOCH
|
||||
);
|
||||
|
||||
BaseSettingsDialogFactory.ListSpec root = assertInstanceOf(
|
||||
BaseSettingsDialogFactory.ListSpec.class,
|
||||
new BaseSettingsDialogFactory(PluginSettings.from(Map.of())).specification(
|
||||
owner,
|
||||
PocketBaseState.locked(playerId)
|
||||
)
|
||||
);
|
||||
BaseSettingsDialogFactory.ConfirmationSpec upgrade = assertInstanceOf(
|
||||
BaseSettingsDialogFactory.ConfirmationSpec.class,
|
||||
root.dialogs().get(2)
|
||||
);
|
||||
|
||||
assertEquals("/basesettings upgrade", upgrade.confirm().command());
|
||||
}
|
||||
|
||||
@Test
|
||||
void marksUnavailableControlsAsLocked() {
|
||||
UUID playerId = UUID.randomUUID();
|
||||
PlayerState owner = PlayerState.newPlayer(playerId, "Newcomer");
|
||||
|
||||
BaseSettingsDialogFactory.ListSpec root = assertInstanceOf(
|
||||
BaseSettingsDialogFactory.ListSpec.class,
|
||||
new BaseSettingsDialogFactory(PluginSettings.from(Map.of())).specification(
|
||||
owner,
|
||||
PocketBaseState.locked(playerId)
|
||||
)
|
||||
);
|
||||
BaseSettingsDialogFactory.MultiSpec controls = assertInstanceOf(
|
||||
BaseSettingsDialogFactory.MultiSpec.class,
|
||||
root.dialogs().get(0)
|
||||
);
|
||||
List<String> labels = controls.actions().stream()
|
||||
.map(BaseSettingsDialogFactory.ButtonSpec::label)
|
||||
.toList();
|
||||
|
||||
assertTrue(labels.stream().anyMatch(label -> label.contains("Visitors: Locked")));
|
||||
assertTrue(labels.stream().anyMatch(label -> label.contains("Navigation: Locked")));
|
||||
assertTrue(labels.stream().anyMatch(label -> label.contains("Flight: Locked")));
|
||||
assertTrue(labels.stream().anyMatch(label -> label.contains("Border: Locked")));
|
||||
assertTrue(labels.stream().anyMatch(label -> label.contains("Spawnable Overlay: Locked")));
|
||||
}
|
||||
|
||||
private static List<String> commands(
|
||||
BaseSettingsDialogFactory.DialogSpec specification
|
||||
) {
|
||||
List<String> commands = new ArrayList<>();
|
||||
collect(specification, commands);
|
||||
return commands;
|
||||
}
|
||||
|
||||
private static void collect(
|
||||
BaseSettingsDialogFactory.DialogSpec specification,
|
||||
List<String> commands
|
||||
) {
|
||||
if (specification instanceof BaseSettingsDialogFactory.ListSpec list) {
|
||||
list.dialogs().forEach(child -> collect(child, commands));
|
||||
} else if (specification instanceof BaseSettingsDialogFactory.MultiSpec multi) {
|
||||
multi.actions().forEach(button -> commands.add(button.command()));
|
||||
} else if (specification
|
||||
instanceof BaseSettingsDialogFactory.ConfirmationSpec confirmation) {
|
||||
commands.add(confirmation.confirm().command());
|
||||
} else if (specification instanceof BaseSettingsDialogFactory.NoticeSpec notice
|
||||
&& notice.action() != null) {
|
||||
commands.add(notice.action().command());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,82 @@ import org.bukkit.plugin.Plugin;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
final class BaseTeleportManagerTest {
|
||||
@org.junit.jupiter.params.ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.CsvSource({
|
||||
"false,true,false", "true,true,false", "false,false,false", "true,false,false",
|
||||
"false,false,true", "true,false,true"
|
||||
})
|
||||
void particlesFollowHomeAndVisitorTeleportOutcome(boolean visit, boolean succeeds, boolean cancelled) {
|
||||
UUID id = UUID.randomUUID();
|
||||
UUID worldId = UUID.randomUUID();
|
||||
World world = mock(World.class);
|
||||
when(world.getUID()).thenReturn(worldId);
|
||||
org.bukkit.Location origin = new org.bukkit.Location(world, 0, 64, 0);
|
||||
org.bukkit.Location target = new org.bukkit.Location(world, 20, 64, 20);
|
||||
Player player = mock(Player.class);
|
||||
when(player.getUniqueId()).thenReturn(id);
|
||||
when(player.getName()).thenReturn("Player");
|
||||
when(player.getWorld()).thenReturn(world);
|
||||
when(player.getLocation()).thenReturn(origin);
|
||||
when(player.isOnline()).thenReturn(true);
|
||||
when(player.teleport(eq(target), any(org.bukkit.event.player.PlayerTeleportEvent.TeleportCause.class)))
|
||||
.thenReturn(succeeds);
|
||||
PlayerState owner = PlayerState.newPlayer(visit ? UUID.randomUUID() : id, "Owner")
|
||||
.withGrassAndDirtProgress(500, 4)
|
||||
.withBase(new BaseLocation(worldId, "world", 20, 64, 20, 0, 0), Instant.EPOCH)
|
||||
.withVisitorsEnabled(true);
|
||||
BaseStateManager states = mock(BaseStateManager.class);
|
||||
when(states.player(id, "Player")).thenReturn(owner);
|
||||
TeleportPolicy policy = mock(TeleportPolicy.class);
|
||||
when(policy.warmup(owner)).thenReturn(Duration.ofSeconds(1));
|
||||
when(policy.visitorWarmup(owner)).thenReturn(Duration.ofSeconds(1));
|
||||
when(policy.cooldown(owner)).thenReturn(Duration.ZERO);
|
||||
VisitorPolicy visitors = mock(VisitorPolicy.class);
|
||||
SafeBaseDestination finder = mock(SafeBaseDestination.class);
|
||||
when(finder.find(world, owner.base().orElseThrow())).thenReturn(Optional.of(target));
|
||||
TeleportParticles particles = mock(TeleportParticles.class);
|
||||
Plugin plugin = mock(Plugin.class);
|
||||
org.bukkit.scheduler.BukkitScheduler scheduler = mock(org.bukkit.scheduler.BukkitScheduler.class);
|
||||
when(scheduler.runTaskTimer(eq(plugin), any(Runnable.class), eq(0L), eq(20L)))
|
||||
.thenReturn(mock(org.bukkit.scheduler.BukkitTask.class));
|
||||
try (org.mockito.MockedStatic<org.bukkit.Bukkit> bukkit = org.mockito.Mockito.mockStatic(org.bukkit.Bukkit.class)) {
|
||||
bukkit.when(org.bukkit.Bukkit::getScheduler).thenReturn(scheduler);
|
||||
bukkit.when(() -> org.bukkit.Bukkit.getWorld(worldId)).thenReturn(world);
|
||||
BaseTeleportManager manager = new BaseTeleportManager(plugin, states, policy,
|
||||
visitors, finder, Clock.systemUTC(), particles);
|
||||
if (visit) manager.startVisit(player, owner); else manager.start(player);
|
||||
verify(particles).start(id, origin, 1);
|
||||
org.mockito.ArgumentCaptor<Runnable> tick = org.mockito.ArgumentCaptor.forClass(Runnable.class);
|
||||
verify(scheduler).runTaskTimer(eq(plugin), tick.capture(), eq(0L), eq(20L));
|
||||
tick.getValue().run();
|
||||
org.mockito.Mockito.verify(player, org.mockito.Mockito.never()).teleport(eq(target),
|
||||
any(org.bukkit.event.player.PlayerTeleportEvent.TeleportCause.class));
|
||||
if (cancelled) {
|
||||
org.bukkit.event.player.PlayerQuitEvent quit = mock(org.bukkit.event.player.PlayerQuitEvent.class);
|
||||
when(quit.getPlayer()).thenReturn(player);
|
||||
manager.onQuit(quit);
|
||||
verify(particles).cancel(id);
|
||||
org.mockito.Mockito.verify(particles, org.mockito.Mockito.never()).complete(id);
|
||||
org.mockito.Mockito.verify(states, org.mockito.Mockito.never()).saveIfDirty();
|
||||
manager.cancelAll();
|
||||
verify(particles).clear();
|
||||
return;
|
||||
}
|
||||
tick.getValue().run();
|
||||
if (succeeds) {
|
||||
verify(particles).complete(id);
|
||||
org.mockito.Mockito.verify(particles, org.mockito.Mockito.never()).cancel(id);
|
||||
verify(states).saveIfDirty();
|
||||
} else {
|
||||
verify(particles).cancel(id);
|
||||
org.mockito.Mockito.verify(particles, org.mockito.Mockito.never()).complete(id);
|
||||
org.mockito.Mockito.verify(states, org.mockito.Mockito.never()).saveIfDirty();
|
||||
}
|
||||
manager.cancelAll();
|
||||
verify(particles).clear();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void visitFromAnotherWorldIsRejectedBeforeWarmupAndCooldownChecks() {
|
||||
UUID visitorId = UUID.randomUUID();
|
||||
|
||||
@@ -25,6 +25,8 @@ final class PluginSettingsTest {
|
||||
assertEquals(16, settings.pocketBaseUpgradeCost());
|
||||
assertEquals("DIAMOND_BLOCK", settings.pocketBaseCurrencyMaterial());
|
||||
assertEquals("DIAMOND_BLOCK", settings.pocketBasePortalFrameMaterial());
|
||||
assertEquals(16, settings.pocketBaseBiomeChangeCost());
|
||||
assertEquals("NETHERITE_BLOCK", settings.pocketBaseBiomeCurrencyMaterial());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -33,13 +35,17 @@ final class PluginSettingsTest {
|
||||
"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-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
|
||||
|
||||
@@ -1,26 +1,36 @@
|
||||
package games.dmg.spigotbase;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.logging.Logger;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.block.Block;
|
||||
import org.bukkit.entity.Entity;
|
||||
import org.bukkit.entity.Mob;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.block.Action;
|
||||
import org.bukkit.event.block.BlockBreakEvent;
|
||||
import org.bukkit.event.block.BlockPlaceEvent;
|
||||
import org.bukkit.event.player.PlayerInteractEvent;
|
||||
import org.bukkit.event.player.PlayerMoveEvent;
|
||||
import org.bukkit.event.player.PlayerTeleportEvent;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.util.BoundingBox;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
final class PocketBaseControllerTest {
|
||||
@@ -36,6 +46,7 @@ final class PocketBaseControllerTest {
|
||||
World world = mock(World.class);
|
||||
Block clicked = block(world, -2, 66, 0, Material.DIAMOND_BLOCK);
|
||||
PlayerInteractEvent event = mock(PlayerInteractEvent.class);
|
||||
ItemStack activator = item(Material.FLINT_AND_STEEL);
|
||||
BaseStateManager baseStates = mock(BaseStateManager.class);
|
||||
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
|
||||
PluginSettings settings = PluginSettings.from(Map.of());
|
||||
@@ -48,7 +59,7 @@ final class PocketBaseControllerTest {
|
||||
when(event.getPlayer()).thenReturn(owner);
|
||||
when(event.getAction()).thenReturn(Action.RIGHT_CLICK_BLOCK);
|
||||
when(event.getClickedBlock()).thenReturn(clicked);
|
||||
when(event.getItem()).thenReturn(new ItemStack(Material.FLINT_AND_STEEL));
|
||||
when(event.getItem()).thenReturn(activator);
|
||||
when(world.getUID()).thenReturn(worldId);
|
||||
when(world.getName()).thenReturn("world");
|
||||
when(world.getBlockAt(anyInt(), anyInt(), anyInt())).thenAnswer(invocation -> {
|
||||
@@ -85,6 +96,205 @@ final class PocketBaseControllerTest {
|
||||
verify(event).setCancelled(true);
|
||||
}
|
||||
|
||||
@Test
|
||||
void ownerCanActivateCompleteReturnFrameInsideOwnPocketBase() throws Exception {
|
||||
UUID ownerId = UUID.randomUUID();
|
||||
UUID worldId = UUID.randomUUID();
|
||||
PocketPortalLocation portal = new PocketPortalLocation(
|
||||
worldId, "pocket", 8, 65, 8, PocketPortalAxis.X
|
||||
);
|
||||
Set<BlockPosition> frame = new HashSet<>(PocketPortalGeometry.frameBlocks(portal));
|
||||
Player owner = mock(Player.class);
|
||||
World world = mock(World.class);
|
||||
Block clicked = block(world, 8, 67, 8, Material.DIAMOND_BLOCK);
|
||||
PlayerInteractEvent event = mock(PlayerInteractEvent.class);
|
||||
ItemStack activator = item(Material.FLINT_AND_STEEL);
|
||||
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
|
||||
PluginSettings settings = PluginSettings.from(Map.of());
|
||||
|
||||
when(owner.getUniqueId()).thenReturn(ownerId);
|
||||
when(event.getPlayer()).thenReturn(owner);
|
||||
when(event.getAction()).thenReturn(Action.RIGHT_CLICK_BLOCK);
|
||||
when(event.getClickedBlock()).thenReturn(clicked);
|
||||
when(event.getItem()).thenReturn(activator);
|
||||
when(world.getUID()).thenReturn(worldId);
|
||||
when(world.getName()).thenReturn("pocket");
|
||||
when(world.getBlockAt(anyInt(), anyInt(), anyInt())).thenAnswer(invocation -> {
|
||||
BlockPosition position = new BlockPosition(
|
||||
invocation.getArgument(0), invocation.getArgument(1), invocation.getArgument(2)
|
||||
);
|
||||
return block(
|
||||
world,
|
||||
position.x(),
|
||||
position.y(),
|
||||
position.z(),
|
||||
frame.contains(position) ? Material.DIAMOND_BLOCK : Material.AIR
|
||||
);
|
||||
});
|
||||
when(pocketBases.ownerForPocketWorld(worldId)).thenReturn(Optional.of(ownerId));
|
||||
when(pocketBases.state(ownerId)).thenReturn(
|
||||
new PocketBaseState(ownerId, 1, Optional.empty())
|
||||
);
|
||||
when(pocketBases.policy()).thenReturn(new PocketBasePolicy(settings));
|
||||
PocketBaseController controller = new PocketBaseController(
|
||||
mock(Server.class),
|
||||
mock(BaseStateManager.class),
|
||||
new BaseBoundsService(settings),
|
||||
pocketBases,
|
||||
new PluginSettingsProvider(settings),
|
||||
Logger.getAnonymousLogger()
|
||||
);
|
||||
|
||||
controller.onActivate(event);
|
||||
|
||||
verify(pocketBases).activateReturnPortal(ownerId, portal);
|
||||
verify(event).setCancelled(true);
|
||||
}
|
||||
|
||||
@Test
|
||||
void ownerCanActivateGoldFlightPortalAfterRaidUnlock() throws Exception {
|
||||
UUID ownerId = UUID.randomUUID();
|
||||
UUID worldId = UUID.randomUUID();
|
||||
PocketPortalLocation portal = new PocketPortalLocation(
|
||||
worldId, "pocket", 8, 65, 8, PocketPortalAxis.X
|
||||
);
|
||||
Set<BlockPosition> frame = new HashSet<>(PocketPortalGeometry.frameBlocks(portal));
|
||||
Player owner = mock(Player.class);
|
||||
World world = mock(World.class);
|
||||
Block clicked = block(world, 8, 67, 8, Material.GOLD_BLOCK);
|
||||
PlayerInteractEvent event = mock(PlayerInteractEvent.class);
|
||||
ItemStack activator = item(Material.FLINT_AND_STEEL);
|
||||
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
|
||||
PluginSettings settings = PluginSettings.from(Map.of());
|
||||
PocketBaseState state = new PocketBaseState(ownerId, 1, Optional.empty())
|
||||
.withFlightUnlocked();
|
||||
|
||||
when(owner.getUniqueId()).thenReturn(ownerId);
|
||||
when(event.getPlayer()).thenReturn(owner);
|
||||
when(event.getAction()).thenReturn(Action.RIGHT_CLICK_BLOCK);
|
||||
when(event.getClickedBlock()).thenReturn(clicked);
|
||||
when(event.getItem()).thenReturn(activator);
|
||||
when(world.getUID()).thenReturn(worldId);
|
||||
when(world.getName()).thenReturn("pocket");
|
||||
when(world.getBlockAt(anyInt(), anyInt(), anyInt())).thenAnswer(invocation -> {
|
||||
BlockPosition position = new BlockPosition(
|
||||
invocation.getArgument(0), invocation.getArgument(1), invocation.getArgument(2)
|
||||
);
|
||||
return block(
|
||||
world,
|
||||
position.x(),
|
||||
position.y(),
|
||||
position.z(),
|
||||
frame.contains(position) ? Material.GOLD_BLOCK : Material.AIR
|
||||
);
|
||||
});
|
||||
when(pocketBases.ownerForPocketWorld(worldId)).thenReturn(Optional.of(ownerId));
|
||||
when(pocketBases.state(ownerId)).thenReturn(state);
|
||||
when(pocketBases.policy()).thenReturn(new PocketBasePolicy(settings));
|
||||
PocketBaseController controller = new PocketBaseController(
|
||||
mock(Server.class),
|
||||
mock(BaseStateManager.class),
|
||||
new BaseBoundsService(settings),
|
||||
pocketBases,
|
||||
new PluginSettingsProvider(settings),
|
||||
Logger.getAnonymousLogger(),
|
||||
mock(BaseFlightController.class)
|
||||
);
|
||||
|
||||
controller.onActivate(event);
|
||||
|
||||
verify(pocketBases).activateFlightPortal(ownerId, portal);
|
||||
verify(event).setCancelled(true);
|
||||
}
|
||||
|
||||
@Test
|
||||
void returnFrameOutsideUnlockedPocketBoundaryIsRejected() throws Exception {
|
||||
UUID ownerId = UUID.randomUUID();
|
||||
UUID worldId = UUID.randomUUID();
|
||||
PocketPortalLocation portal = new PocketPortalLocation(
|
||||
worldId, "pocket", 30, 65, 8, PocketPortalAxis.X
|
||||
);
|
||||
Set<BlockPosition> frame = new HashSet<>(PocketPortalGeometry.frameBlocks(portal));
|
||||
Player owner = mock(Player.class);
|
||||
World world = mock(World.class);
|
||||
Block clicked = block(world, 30, 67, 8, Material.DIAMOND_BLOCK);
|
||||
PlayerInteractEvent event = mock(PlayerInteractEvent.class);
|
||||
ItemStack activator = item(Material.FLINT_AND_STEEL);
|
||||
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
|
||||
PluginSettings settings = PluginSettings.from(Map.of());
|
||||
|
||||
when(owner.getUniqueId()).thenReturn(ownerId);
|
||||
when(event.getPlayer()).thenReturn(owner);
|
||||
when(event.getAction()).thenReturn(Action.RIGHT_CLICK_BLOCK);
|
||||
when(event.getClickedBlock()).thenReturn(clicked);
|
||||
when(event.getItem()).thenReturn(activator);
|
||||
when(world.getUID()).thenReturn(worldId);
|
||||
when(world.getName()).thenReturn("pocket");
|
||||
when(world.getBlockAt(anyInt(), anyInt(), anyInt())).thenAnswer(invocation -> {
|
||||
BlockPosition position = new BlockPosition(
|
||||
invocation.getArgument(0), invocation.getArgument(1), invocation.getArgument(2)
|
||||
);
|
||||
return block(
|
||||
world,
|
||||
position.x(),
|
||||
position.y(),
|
||||
position.z(),
|
||||
frame.contains(position) ? Material.DIAMOND_BLOCK : Material.AIR
|
||||
);
|
||||
});
|
||||
when(pocketBases.ownerForPocketWorld(worldId)).thenReturn(Optional.of(ownerId));
|
||||
when(pocketBases.state(ownerId)).thenReturn(
|
||||
new PocketBaseState(ownerId, 1, Optional.empty())
|
||||
);
|
||||
when(pocketBases.policy()).thenReturn(new PocketBasePolicy(settings));
|
||||
PocketBaseController controller = new PocketBaseController(
|
||||
mock(Server.class),
|
||||
mock(BaseStateManager.class),
|
||||
new BaseBoundsService(settings),
|
||||
pocketBases,
|
||||
new PluginSettingsProvider(settings),
|
||||
Logger.getAnonymousLogger()
|
||||
);
|
||||
|
||||
controller.onActivate(event);
|
||||
|
||||
verify(pocketBases, never()).activateReturnPortal(ownerId, portal);
|
||||
verify(event).setCancelled(true);
|
||||
}
|
||||
|
||||
@Test
|
||||
void breakingActiveFlightPortalFrameDeactivatesIt() throws Exception {
|
||||
UUID ownerId = UUID.randomUUID();
|
||||
UUID worldId = UUID.randomUUID();
|
||||
PocketPortalLocation flightPortal = new PocketPortalLocation(
|
||||
worldId, "pocket", 8, 65, 8, PocketPortalAxis.X
|
||||
);
|
||||
PocketBaseState state = new PocketBaseState(ownerId, 1, Optional.empty())
|
||||
.withFlightUnlocked()
|
||||
.withFlightPortal(flightPortal);
|
||||
World world = mock(World.class);
|
||||
BlockBreakEvent event = mock(BlockBreakEvent.class);
|
||||
Block broken = block(world, 8, 65, 8, Material.GOLD_BLOCK);
|
||||
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
|
||||
PluginSettings settings = PluginSettings.from(Map.of());
|
||||
|
||||
when(world.getUID()).thenReturn(worldId);
|
||||
when(event.getBlock()).thenReturn(broken);
|
||||
when(pocketBases.knownStates()).thenReturn(Map.of(ownerId, state));
|
||||
PocketBaseController controller = new PocketBaseController(
|
||||
mock(Server.class),
|
||||
mock(BaseStateManager.class),
|
||||
new BaseBoundsService(settings),
|
||||
pocketBases,
|
||||
new PluginSettingsProvider(settings),
|
||||
Logger.getAnonymousLogger()
|
||||
);
|
||||
|
||||
controller.onBreak(event);
|
||||
|
||||
verify(pocketBases).deactivateFlightPortal(ownerId);
|
||||
}
|
||||
|
||||
@Test
|
||||
void placementOutsideUnlockedPocketBoundaryIsCancelled() {
|
||||
UUID ownerId = UUID.randomUUID();
|
||||
@@ -118,6 +328,254 @@ final class PocketBaseControllerTest {
|
||||
verify(event).setCancelled(true);
|
||||
}
|
||||
|
||||
@Test
|
||||
void activeEntranceTransportsMobsOnceButNotOtherEntities() {
|
||||
UUID ownerId = UUID.randomUUID();
|
||||
UUID worldId = UUID.randomUUID();
|
||||
PocketPortalLocation portal = new PocketPortalLocation(
|
||||
worldId, "world", -2, 64, 0, PocketPortalAxis.X
|
||||
);
|
||||
PocketBaseState state = new PocketBaseState(ownerId, 1, Optional.of(portal));
|
||||
Server server = mock(Server.class);
|
||||
World world = intactPortalWorld(worldId, portal);
|
||||
Mob mob = mock(Mob.class);
|
||||
Entity item = mock(Entity.class);
|
||||
Location arrival = new Location(mock(World.class), 0.5, 65.0, 5.5);
|
||||
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
|
||||
|
||||
when(mob.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
when(item.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
when(world.getNearbyEntities(any(BoundingBox.class), any()))
|
||||
.thenReturn(List.of(mob, item));
|
||||
when(server.getWorld(worldId)).thenReturn(world);
|
||||
when(pocketBases.knownStates()).thenReturn(Map.of(ownerId, state));
|
||||
when(pocketBases.pocketArrival(ownerId)).thenReturn(arrival);
|
||||
PocketBaseController controller = controller(server, pocketBases);
|
||||
|
||||
controller.scanMobPortals();
|
||||
controller.scanMobPortals();
|
||||
|
||||
verify(mob).teleport(arrival, PlayerTeleportEvent.TeleportCause.PLUGIN);
|
||||
verify(item, never()).teleport(
|
||||
any(Location.class),
|
||||
any(PlayerTeleportEvent.TeleportCause.class)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void activeReturnPortalTransportsMobOutsideIntactEntrance() {
|
||||
UUID ownerId = UUID.randomUUID();
|
||||
UUID normalWorldId = UUID.randomUUID();
|
||||
UUID pocketWorldId = UUID.randomUUID();
|
||||
PocketPortalLocation entrance = new PocketPortalLocation(
|
||||
normalWorldId, "world", -2, 64, 0, PocketPortalAxis.X
|
||||
);
|
||||
PocketPortalLocation returnPortal = new PocketPortalLocation(
|
||||
pocketWorldId, "pocket", 8, 65, 8, PocketPortalAxis.Z
|
||||
);
|
||||
PocketBaseState state = new PocketBaseState(ownerId, 1, Optional.of(entrance));
|
||||
Server server = mock(Server.class);
|
||||
World normalWorld = intactPortalWorld(normalWorldId, entrance);
|
||||
World pocketWorld = intactPortalWorld(pocketWorldId, returnPortal);
|
||||
Mob mob = mock(Mob.class);
|
||||
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
|
||||
|
||||
when(mob.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
when(normalWorld.getNearbyEntities(any(BoundingBox.class), any()))
|
||||
.thenReturn(List.of());
|
||||
when(pocketWorld.getNearbyEntities(any(BoundingBox.class), any()))
|
||||
.thenReturn(List.of(mob));
|
||||
when(server.getWorld(normalWorldId)).thenReturn(normalWorld);
|
||||
when(server.getWorld(pocketWorldId)).thenReturn(pocketWorld);
|
||||
when(pocketBases.knownStates()).thenReturn(Map.of(ownerId, state));
|
||||
when(pocketBases.state(ownerId)).thenReturn(state);
|
||||
when(pocketBases.returnPortalIsIntact(ownerId)).thenReturn(true);
|
||||
when(pocketBases.returnPortal(ownerId)).thenReturn(returnPortal);
|
||||
PocketBaseController controller = controller(server, pocketBases);
|
||||
|
||||
controller.scanMobPortals();
|
||||
|
||||
verify(mob).teleport(
|
||||
new Location(normalWorld, 0.0, 65.0, 1.5),
|
||||
PlayerTeleportEvent.TeleportCause.PLUGIN
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void activeFlightPortalGrantsTemporaryFlightToGuest() {
|
||||
UUID ownerId = UUID.randomUUID();
|
||||
UUID guestId = UUID.randomUUID();
|
||||
UUID pocketWorldId = UUID.randomUUID();
|
||||
PocketPortalLocation flightPortal = new PocketPortalLocation(
|
||||
pocketWorldId, "pocket", 8, 65, 8, PocketPortalAxis.X
|
||||
);
|
||||
World pocketWorld = mock(World.class);
|
||||
Set<BlockPosition> frame = new HashSet<>(
|
||||
PocketPortalGeometry.frameBlocks(flightPortal)
|
||||
);
|
||||
Location destination = new Location(pocketWorld, 9.5, 66.0, 8.5);
|
||||
Player guest = mock(Player.class);
|
||||
PlayerMoveEvent event = mock(PlayerMoveEvent.class);
|
||||
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
|
||||
BaseFlightController flightController = mock(BaseFlightController.class);
|
||||
Server server = mock(Server.class);
|
||||
PluginSettings settings = PluginSettings.from(Map.of());
|
||||
PocketBaseState state = new PocketBaseState(ownerId, 1, Optional.empty())
|
||||
.withFlightUnlocked()
|
||||
.withFlightPortal(flightPortal);
|
||||
|
||||
when(pocketWorld.getUID()).thenReturn(pocketWorldId);
|
||||
when(pocketWorld.getBlockAt(anyInt(), anyInt(), anyInt())).thenAnswer(invocation -> {
|
||||
BlockPosition position = new BlockPosition(
|
||||
invocation.getArgument(0), invocation.getArgument(1), invocation.getArgument(2)
|
||||
);
|
||||
return block(
|
||||
pocketWorld,
|
||||
position.x(),
|
||||
position.y(),
|
||||
position.z(),
|
||||
frame.contains(position) ? Material.GOLD_BLOCK : Material.AIR
|
||||
);
|
||||
});
|
||||
when(server.getWorld(pocketWorldId)).thenReturn(pocketWorld);
|
||||
when(guest.getUniqueId()).thenReturn(guestId);
|
||||
when(event.getPlayer()).thenReturn(guest);
|
||||
when(event.getTo()).thenReturn(destination);
|
||||
when(pocketBases.ownerForPocketWorld(pocketWorldId)).thenReturn(Optional.of(ownerId));
|
||||
when(pocketBases.state(ownerId)).thenReturn(state);
|
||||
PocketBaseController controller = new PocketBaseController(
|
||||
server,
|
||||
mock(BaseStateManager.class),
|
||||
new BaseBoundsService(settings),
|
||||
pocketBases,
|
||||
new PluginSettingsProvider(settings),
|
||||
Logger.getAnonymousLogger(),
|
||||
flightController
|
||||
);
|
||||
|
||||
controller.onMove(event);
|
||||
|
||||
verify(flightController).grantPocketPortalFlight(guest, ownerId);
|
||||
|
||||
when(event.getFrom()).thenReturn(destination);
|
||||
controller.onMove(event);
|
||||
verify(flightController).grantPocketPortalFlight(guest, ownerId);
|
||||
|
||||
when(event.getFrom()).thenReturn(new Location(pocketWorld, 5, 66, 8));
|
||||
controller.onMove(event);
|
||||
verify(flightController, org.mockito.Mockito.times(2))
|
||||
.grantPocketPortalFlight(guest, ownerId);
|
||||
}
|
||||
|
||||
@Test
|
||||
void voidReturnClearsAccumulatedPlayerFallDistance() {
|
||||
UUID ownerId = UUID.randomUUID();
|
||||
UUID pocketWorldId = UUID.randomUUID();
|
||||
World pocketWorld = mock(World.class);
|
||||
World fallbackWorld = mock(World.class);
|
||||
Location destination = new Location(pocketWorld, 0.5, -65.0, 0.5);
|
||||
Location spawn = new Location(fallbackWorld, 10.5, 70.0, 10.5);
|
||||
Player player = mock(Player.class);
|
||||
PlayerMoveEvent event = mock(PlayerMoveEvent.class);
|
||||
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
|
||||
Server server = mock(Server.class);
|
||||
|
||||
when(pocketWorld.getUID()).thenReturn(pocketWorldId);
|
||||
when(player.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
when(event.getPlayer()).thenReturn(player);
|
||||
when(event.getTo()).thenReturn(destination);
|
||||
when(server.getWorlds()).thenReturn(List.of(fallbackWorld));
|
||||
when(fallbackWorld.getSpawnLocation()).thenReturn(spawn);
|
||||
when(pocketBases.ownerForPocketWorld(pocketWorldId)).thenReturn(Optional.of(ownerId));
|
||||
when(pocketBases.state(ownerId)).thenReturn(
|
||||
new PocketBaseState(ownerId, 1, Optional.empty())
|
||||
);
|
||||
PocketBaseController controller = controller(server, pocketBases);
|
||||
|
||||
controller.onMove(event);
|
||||
|
||||
verify(player).setFallDistance(0.0F);
|
||||
verify(player).teleport(spawn, PlayerTeleportEvent.TeleportCause.PLUGIN);
|
||||
}
|
||||
|
||||
@Test
|
||||
void mobReturnFallsBackToServerSpawnWithoutValidEntrance() {
|
||||
UUID ownerId = UUID.randomUUID();
|
||||
UUID pocketWorldId = UUID.randomUUID();
|
||||
PocketPortalLocation returnPortal = new PocketPortalLocation(
|
||||
pocketWorldId, "pocket", 8, 65, 8, PocketPortalAxis.Z
|
||||
);
|
||||
PocketBaseState state = new PocketBaseState(ownerId, 1, Optional.empty());
|
||||
Server server = mock(Server.class);
|
||||
World pocketWorld = intactPortalWorld(pocketWorldId, returnPortal);
|
||||
World fallbackWorld = mock(World.class);
|
||||
Location spawn = new Location(fallbackWorld, 10.5, 70.0, 10.5);
|
||||
Mob mob = mock(Mob.class);
|
||||
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
|
||||
|
||||
when(mob.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
when(pocketWorld.getNearbyEntities(any(BoundingBox.class), any()))
|
||||
.thenReturn(List.of(mob));
|
||||
when(server.getWorld(pocketWorldId)).thenReturn(pocketWorld);
|
||||
when(server.getWorlds()).thenReturn(List.of(fallbackWorld));
|
||||
when(fallbackWorld.getSpawnLocation()).thenReturn(spawn);
|
||||
when(pocketBases.knownStates()).thenReturn(Map.of(ownerId, state));
|
||||
when(pocketBases.state(ownerId)).thenReturn(state);
|
||||
when(pocketBases.returnPortalIsIntact(ownerId)).thenReturn(true);
|
||||
when(pocketBases.returnPortal(ownerId)).thenReturn(returnPortal);
|
||||
PocketBaseController controller = controller(server, pocketBases);
|
||||
|
||||
controller.scanMobPortals();
|
||||
|
||||
verify(mob).teleport(
|
||||
spawn,
|
||||
PlayerTeleportEvent.TeleportCause.PLUGIN
|
||||
);
|
||||
}
|
||||
|
||||
private static PocketBaseController controller(
|
||||
Server server,
|
||||
PocketBaseManager pocketBases
|
||||
) {
|
||||
PluginSettings settings = PluginSettings.from(Map.of());
|
||||
return new PocketBaseController(
|
||||
server,
|
||||
mock(BaseStateManager.class),
|
||||
new BaseBoundsService(settings),
|
||||
pocketBases,
|
||||
new PluginSettingsProvider(settings),
|
||||
Logger.getAnonymousLogger()
|
||||
);
|
||||
}
|
||||
|
||||
private static World intactPortalWorld(
|
||||
UUID worldId,
|
||||
PocketPortalLocation portal
|
||||
) {
|
||||
World world = mock(World.class);
|
||||
Set<BlockPosition> frame = new HashSet<>(PocketPortalGeometry.frameBlocks(portal));
|
||||
when(world.getUID()).thenReturn(worldId);
|
||||
when(world.getBlockAt(anyInt(), anyInt(), anyInt())).thenAnswer(invocation -> {
|
||||
BlockPosition position = new BlockPosition(
|
||||
invocation.getArgument(0), invocation.getArgument(1), invocation.getArgument(2)
|
||||
);
|
||||
return block(
|
||||
world,
|
||||
position.x(),
|
||||
position.y(),
|
||||
position.z(),
|
||||
frame.contains(position) ? Material.DIAMOND_BLOCK : Material.AIR
|
||||
);
|
||||
});
|
||||
return world;
|
||||
}
|
||||
|
||||
private static ItemStack item(Material material) {
|
||||
ItemStack item = mock(ItemStack.class);
|
||||
when(item.getType()).thenReturn(material);
|
||||
return item;
|
||||
}
|
||||
|
||||
private static Block block(
|
||||
World world,
|
||||
int x,
|
||||
|
||||
@@ -0,0 +1,396 @@
|
||||
package games.dmg.spigotbase;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.logging.Logger;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.block.Block;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.Event;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.block.Action;
|
||||
import org.bukkit.plugin.Plugin;
|
||||
import org.bukkit.plugin.RegisteredListener;
|
||||
import org.bukkit.event.inventory.PrepareItemCraftEvent;
|
||||
import org.bukkit.event.player.PlayerInteractEvent;
|
||||
import org.bukkit.event.player.PlayerTeleportEvent;
|
||||
import org.bukkit.inventory.CraftingInventory;
|
||||
import org.bukkit.inventory.InventoryView;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.ShapedRecipe;
|
||||
import org.bukkit.inventory.meta.ItemMeta;
|
||||
import org.bukkit.persistence.PersistentDataContainer;
|
||||
import org.bukkit.persistence.PersistentDataType;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.EnumSource;
|
||||
|
||||
final class PocketBaseKeystoneServiceTest {
|
||||
@Test
|
||||
void createsKeystoneWithGenericRecoveryCompassMetadata() {
|
||||
NamespacedKey key = NamespacedKey.minecraft("pocket_base_keystone");
|
||||
ItemStack item = mock(ItemStack.class);
|
||||
ItemMeta genericMetadata = mock(ItemMeta.class);
|
||||
PersistentDataContainer data = mock(PersistentDataContainer.class);
|
||||
|
||||
when(item.getItemMeta()).thenReturn(genericMetadata);
|
||||
when(genericMetadata.getPersistentDataContainer()).thenReturn(data);
|
||||
|
||||
PocketBaseKeystoneService.decorateKeystone(item, key);
|
||||
|
||||
verify(data).set(key, PersistentDataType.BYTE, (byte) 1);
|
||||
verify(item).setItemMeta(genericMetadata);
|
||||
}
|
||||
|
||||
@Test
|
||||
void recipeUsesNetheriteOnCardinalsAndObsidianInCorners() {
|
||||
PocketBaseKeystoneService.RecipeSpec recipe =
|
||||
PocketBaseKeystoneService.recipeSpecification();
|
||||
|
||||
assertEquals("ONO", recipe.top());
|
||||
assertEquals("NEN", recipe.middle());
|
||||
assertEquals("ONO", recipe.bottom());
|
||||
assertEquals(Material.OBSIDIAN, recipe.obsidian());
|
||||
assertEquals(Material.NETHERITE_INGOT, recipe.netherite());
|
||||
assertEquals(Material.ENDER_PEARL, recipe.enderPearl());
|
||||
}
|
||||
|
||||
@Test
|
||||
void lockedPlayersCannotCompleteTheKeystoneRecipe() {
|
||||
NamespacedKey key = NamespacedKey.minecraft("pocket_base_keystone");
|
||||
UUID playerId = UUID.randomUUID();
|
||||
Player player = mock(Player.class);
|
||||
PrepareItemCraftEvent event = mock(PrepareItemCraftEvent.class);
|
||||
InventoryView view = mock(InventoryView.class);
|
||||
CraftingInventory inventory = mock(CraftingInventory.class);
|
||||
ShapedRecipe recipe = mock(ShapedRecipe.class);
|
||||
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
|
||||
PocketBaseKeystoneService service = new PocketBaseKeystoneService(
|
||||
mock(org.bukkit.Server.class), pocketBases, mock(KeystoneReturnStore.class),
|
||||
key, Logger.getAnonymousLogger()
|
||||
);
|
||||
|
||||
when(player.getUniqueId()).thenReturn(playerId);
|
||||
when(event.getView()).thenReturn(view);
|
||||
when(view.getPlayer()).thenReturn(player);
|
||||
when(event.getInventory()).thenReturn(inventory);
|
||||
when(event.getRecipe()).thenReturn(recipe);
|
||||
when(recipe.getKey()).thenReturn(key);
|
||||
when(pocketBases.state(playerId)).thenReturn(
|
||||
new PocketBaseState(playerId, 0, Optional.empty())
|
||||
);
|
||||
|
||||
service.onPrepareCraft(event);
|
||||
|
||||
verify(inventory).setResult(null);
|
||||
}
|
||||
|
||||
@Test
|
||||
void lockedPlayerCannotUseATransferredKeystone() throws Exception {
|
||||
NamespacedKey key = NamespacedKey.minecraft("pocket_base_keystone");
|
||||
UUID playerId = UUID.randomUUID();
|
||||
Player player = mock(Player.class);
|
||||
PlayerInteractEvent event = mock(PlayerInteractEvent.class);
|
||||
ItemStack keystone = markedKeystone(key);
|
||||
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
|
||||
KeystoneReturnStore returns = mock(KeystoneReturnStore.class);
|
||||
PocketBaseKeystoneService service = new PocketBaseKeystoneService(
|
||||
mock(Server.class), pocketBases, returns, key, Logger.getAnonymousLogger()
|
||||
);
|
||||
|
||||
when(player.getUniqueId()).thenReturn(playerId);
|
||||
when(event.getPlayer()).thenReturn(player);
|
||||
when(event.getAction()).thenReturn(Action.RIGHT_CLICK_AIR);
|
||||
when(event.getItem()).thenReturn(keystone);
|
||||
when(pocketBases.state(playerId)).thenReturn(
|
||||
new PocketBaseState(playerId, 0, Optional.empty())
|
||||
);
|
||||
|
||||
service.onUse(event);
|
||||
|
||||
verify(returns, never()).record(any(UUID.class), any(KeystoneReturnLocation.class));
|
||||
verify(player, never()).teleport(
|
||||
any(Location.class), any(PlayerTeleportEvent.TeleportCause.class)
|
||||
);
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@EnumSource(value = Action.class, names = {"RIGHT_CLICK_AIR", "RIGHT_CLICK_BLOCK"})
|
||||
void unlockedPlayerEntersOwnPocketAndRecordsExactReturnDestination(Action action) throws Exception {
|
||||
NamespacedKey key = NamespacedKey.minecraft("pocket_base_keystone");
|
||||
UUID playerId = UUID.randomUUID();
|
||||
UUID worldId = UUID.randomUUID();
|
||||
Player player = mock(Player.class);
|
||||
World world = mock(World.class);
|
||||
Location origin = new Location(world, 12.25, 70.5, -4.75, 123.0F, -15.5F);
|
||||
Location arrival = new Location(mock(World.class), 0.5, 65.0, 0.5);
|
||||
PlayerInteractEvent event = mock(PlayerInteractEvent.class);
|
||||
ItemStack keystone = markedKeystone(key);
|
||||
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
|
||||
KeystoneReturnStore returns = mock(KeystoneReturnStore.class);
|
||||
PocketBaseKeystoneService service = new PocketBaseKeystoneService(
|
||||
mock(Server.class), pocketBases, returns, key, Logger.getAnonymousLogger()
|
||||
);
|
||||
|
||||
when(player.getUniqueId()).thenReturn(playerId);
|
||||
when(player.getLocation()).thenReturn(origin);
|
||||
when(player.getWorld()).thenReturn(world);
|
||||
when(world.getUID()).thenReturn(worldId);
|
||||
when(world.getName()).thenReturn("world");
|
||||
when(event.getPlayer()).thenReturn(player);
|
||||
when(event.getAction()).thenReturn(Action.RIGHT_CLICK_AIR);
|
||||
when(event.getItem()).thenReturn(keystone);
|
||||
when(pocketBases.state(playerId)).thenReturn(
|
||||
new PocketBaseState(playerId, 1, Optional.empty())
|
||||
);
|
||||
when(pocketBases.ownerForPocketWorld(worldId)).thenReturn(Optional.empty());
|
||||
when(pocketBases.pocketArrival(playerId)).thenReturn(arrival);
|
||||
when(player.teleport(arrival, PlayerTeleportEvent.TeleportCause.PLUGIN))
|
||||
.thenReturn(true);
|
||||
|
||||
when(event.getAction()).thenReturn(action);
|
||||
when(event.isCancelled()).thenReturn(action == Action.RIGHT_CLICK_AIR);
|
||||
when(event.useItemInHand()).thenReturn(Event.Result.DEFAULT);
|
||||
dispatchUse(service, event);
|
||||
|
||||
verify(returns).record(playerId, new KeystoneReturnLocation(
|
||||
worldId, "world", 12.25, 70.5, -4.75, 123.0F, -15.5F
|
||||
));
|
||||
verify(player).teleport(arrival, PlayerTeleportEvent.TeleportCause.PLUGIN);
|
||||
verify(player).setFallDistance(0.0F);
|
||||
verify(event).setCancelled(true);
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@EnumSource(value = Action.class, names = {"RIGHT_CLICK_AIR", "RIGHT_CLICK_BLOCK"})
|
||||
void playerReturnsFromOwnPocketToRecordedSafeDestination(Action action) throws Exception {
|
||||
NamespacedKey key = NamespacedKey.minecraft("pocket_base_keystone");
|
||||
UUID playerId = UUID.randomUUID();
|
||||
UUID pocketWorldId = UUID.randomUUID();
|
||||
UUID returnWorldId = UUID.randomUUID();
|
||||
Player player = mock(Player.class);
|
||||
World pocketWorld = mock(World.class);
|
||||
World returnWorld = mock(World.class);
|
||||
Block ground = mock(Block.class);
|
||||
Block feet = mock(Block.class);
|
||||
Block head = mock(Block.class);
|
||||
PlayerInteractEvent event = mock(PlayerInteractEvent.class);
|
||||
ItemStack keystone = markedKeystone(key);
|
||||
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
|
||||
KeystoneReturnStore returns = mock(KeystoneReturnStore.class);
|
||||
Server server = mock(Server.class);
|
||||
KeystoneReturnLocation saved = new KeystoneReturnLocation(
|
||||
returnWorldId, "world", 12.25, 70.5, -4.75, 123.0F, -15.5F
|
||||
);
|
||||
PocketBaseKeystoneService service = new PocketBaseKeystoneService(
|
||||
server, pocketBases, returns, key, Logger.getAnonymousLogger()
|
||||
);
|
||||
|
||||
when(player.getUniqueId()).thenReturn(playerId);
|
||||
when(player.getWorld()).thenReturn(pocketWorld);
|
||||
when(pocketWorld.getUID()).thenReturn(pocketWorldId);
|
||||
when(event.getPlayer()).thenReturn(player);
|
||||
when(event.getAction()).thenReturn(Action.RIGHT_CLICK_AIR);
|
||||
when(event.getItem()).thenReturn(keystone);
|
||||
when(pocketBases.state(playerId)).thenReturn(
|
||||
new PocketBaseState(playerId, 1, Optional.empty())
|
||||
);
|
||||
when(pocketBases.ownerForPocketWorld(pocketWorldId))
|
||||
.thenReturn(Optional.of(playerId));
|
||||
when(returns.destination(playerId)).thenReturn(Optional.of(saved));
|
||||
when(server.getWorld(returnWorldId)).thenReturn(returnWorld);
|
||||
when(returnWorld.getMinHeight()).thenReturn(-64);
|
||||
when(returnWorld.getMaxHeight()).thenReturn(320);
|
||||
when(returnWorld.getBlockAt(12, 69, -5)).thenReturn(ground);
|
||||
when(returnWorld.getBlockAt(12, 70, -5)).thenReturn(feet);
|
||||
when(returnWorld.getBlockAt(12, 71, -5)).thenReturn(head);
|
||||
when(ground.getType()).thenReturn(Material.STONE);
|
||||
when(feet.getType()).thenReturn(Material.AIR);
|
||||
when(head.getType()).thenReturn(Material.AIR);
|
||||
when(feet.isPassable()).thenReturn(true);
|
||||
when(head.isPassable()).thenReturn(true);
|
||||
when(player.teleport(
|
||||
new Location(returnWorld, 12.25, 70.5, -4.75, 123.0F, -15.5F),
|
||||
PlayerTeleportEvent.TeleportCause.PLUGIN
|
||||
)).thenReturn(true);
|
||||
|
||||
when(event.getAction()).thenReturn(action);
|
||||
when(event.isCancelled()).thenReturn(action == Action.RIGHT_CLICK_AIR);
|
||||
when(event.useItemInHand()).thenReturn(Event.Result.DEFAULT);
|
||||
dispatchUse(service, event);
|
||||
|
||||
verify(player).teleport(
|
||||
new Location(returnWorld, 12.25, 70.5, -4.75, 123.0F, -15.5F),
|
||||
PlayerTeleportEvent.TeleportCause.PLUGIN
|
||||
);
|
||||
verify(player).setFallDistance(0.0F);
|
||||
}
|
||||
|
||||
@Test
|
||||
void unavailableAndUnsafeReturnsDoNotTeleportThePlayer() {
|
||||
NamespacedKey key = NamespacedKey.minecraft("pocket_base_keystone");
|
||||
UUID missingPocketWorldId = UUID.randomUUID();
|
||||
UUID unsafePocketWorldId = UUID.randomUUID();
|
||||
UUID missingPlayerId = UUID.randomUUID();
|
||||
UUID unsafePlayerId = UUID.randomUUID();
|
||||
UUID missingWorldId = UUID.randomUUID();
|
||||
UUID unsafeWorldId = UUID.randomUUID();
|
||||
World missingPocketWorld = mock(World.class);
|
||||
World unsafePocketWorld = mock(World.class);
|
||||
World unsafeWorld = mock(World.class);
|
||||
Player missingPlayer = mock(Player.class);
|
||||
Player unsafePlayer = mock(Player.class);
|
||||
PlayerInteractEvent missingEvent = mock(PlayerInteractEvent.class);
|
||||
PlayerInteractEvent unsafeEvent = mock(PlayerInteractEvent.class);
|
||||
ItemStack missingKeystone = markedKeystone(key);
|
||||
ItemStack unsafeKeystone = markedKeystone(key);
|
||||
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
|
||||
KeystoneReturnStore returns = mock(KeystoneReturnStore.class);
|
||||
Server server = mock(Server.class);
|
||||
PocketBaseKeystoneService service = new PocketBaseKeystoneService(
|
||||
server, pocketBases, returns, key, Logger.getAnonymousLogger()
|
||||
);
|
||||
|
||||
when(missingPocketWorld.getUID()).thenReturn(missingPocketWorldId);
|
||||
when(unsafePocketWorld.getUID()).thenReturn(unsafePocketWorldId);
|
||||
when(missingPlayer.getUniqueId()).thenReturn(missingPlayerId);
|
||||
when(missingPlayer.getWorld()).thenReturn(missingPocketWorld);
|
||||
when(unsafePlayer.getUniqueId()).thenReturn(unsafePlayerId);
|
||||
when(unsafePlayer.getWorld()).thenReturn(unsafePocketWorld);
|
||||
when(missingEvent.getPlayer()).thenReturn(missingPlayer);
|
||||
when(missingEvent.getAction()).thenReturn(Action.RIGHT_CLICK_AIR);
|
||||
when(missingEvent.getItem()).thenReturn(missingKeystone);
|
||||
when(unsafeEvent.getPlayer()).thenReturn(unsafePlayer);
|
||||
when(unsafeEvent.getAction()).thenReturn(Action.RIGHT_CLICK_AIR);
|
||||
when(unsafeEvent.getItem()).thenReturn(unsafeKeystone);
|
||||
when(pocketBases.state(missingPlayerId)).thenReturn(
|
||||
new PocketBaseState(missingPlayerId, 1, Optional.empty())
|
||||
);
|
||||
when(pocketBases.state(unsafePlayerId)).thenReturn(
|
||||
new PocketBaseState(unsafePlayerId, 1, Optional.empty())
|
||||
);
|
||||
when(pocketBases.ownerForPocketWorld(missingPocketWorldId))
|
||||
.thenReturn(Optional.of(missingPlayerId));
|
||||
when(pocketBases.ownerForPocketWorld(unsafePocketWorldId))
|
||||
.thenReturn(Optional.of(unsafePlayerId));
|
||||
when(returns.destination(missingPlayerId)).thenReturn(Optional.of(
|
||||
new KeystoneReturnLocation(
|
||||
missingWorldId, "missing", 0.5, 65.0, 0.5, 0.0F, 0.0F
|
||||
)
|
||||
));
|
||||
when(returns.destination(unsafePlayerId)).thenReturn(Optional.of(
|
||||
new KeystoneReturnLocation(
|
||||
unsafeWorldId, "unsafe", 0.5, 65.0, 0.5, 0.0F, 0.0F
|
||||
)
|
||||
));
|
||||
when(server.getWorld(unsafeWorldId)).thenReturn(unsafeWorld);
|
||||
when(unsafeWorld.getMinHeight()).thenReturn(-64);
|
||||
when(unsafeWorld.getMaxHeight()).thenReturn(320);
|
||||
Block unsafeBlock = mock(Block.class);
|
||||
when(unsafeBlock.getType()).thenReturn(Material.AIR);
|
||||
when(unsafeWorld.getBlockAt(anyInt(), anyInt(), anyInt()))
|
||||
.thenReturn(unsafeBlock);
|
||||
|
||||
service.onUse(missingEvent);
|
||||
service.onUse(unsafeEvent);
|
||||
|
||||
verify(missingPlayer, never()).teleport(
|
||||
any(Location.class), any(PlayerTeleportEvent.TeleportCause.class)
|
||||
);
|
||||
verify(unsafePlayer, never()).teleport(
|
||||
any(Location.class), any(PlayerTeleportEvent.TeleportCause.class)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void authenticatesKeystonesByPersistentMetadataInsteadOfTheirName() {
|
||||
NamespacedKey key = NamespacedKey.minecraft("pocket_base_keystone");
|
||||
ItemStack marked = mock(ItemStack.class);
|
||||
ItemMeta markedMeta = mock(ItemMeta.class);
|
||||
PersistentDataContainer markedData = mock(PersistentDataContainer.class);
|
||||
ItemStack renamedOnly = mock(ItemStack.class);
|
||||
ItemMeta renamedMeta = mock(ItemMeta.class);
|
||||
PersistentDataContainer renamedData = mock(PersistentDataContainer.class);
|
||||
|
||||
when(marked.getType()).thenReturn(Material.RECOVERY_COMPASS);
|
||||
when(marked.getItemMeta()).thenReturn(markedMeta);
|
||||
when(markedMeta.getPersistentDataContainer()).thenReturn(markedData);
|
||||
when(markedData.get(key, PersistentDataType.BYTE)).thenReturn((byte) 1);
|
||||
when(renamedOnly.getType()).thenReturn(Material.RECOVERY_COMPASS);
|
||||
when(renamedOnly.getItemMeta()).thenReturn(renamedMeta);
|
||||
when(renamedMeta.getPersistentDataContainer()).thenReturn(renamedData);
|
||||
|
||||
assertTrue(PocketBaseKeystoneService.isKeystone(marked, key));
|
||||
assertFalse(PocketBaseKeystoneService.isKeystone(renamedOnly, key));
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@EnumSource(value = Action.class, names = {"RIGHT_CLICK_AIR", "RIGHT_CLICK_BLOCK"})
|
||||
void explicitItemUseDenialPreventsKeystoneTravel(Action action) throws Exception {
|
||||
NamespacedKey key = NamespacedKey.minecraft("pocket_base_keystone");
|
||||
Player player = mock(Player.class);
|
||||
UUID playerId = UUID.randomUUID();
|
||||
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
|
||||
KeystoneReturnStore returns = mock(KeystoneReturnStore.class);
|
||||
PocketBaseKeystoneService service = new PocketBaseKeystoneService(
|
||||
mock(Server.class), pocketBases, returns, key, Logger.getAnonymousLogger()
|
||||
);
|
||||
PlayerInteractEvent event = mock(PlayerInteractEvent.class);
|
||||
ItemStack keystone = markedKeystone(key);
|
||||
when(event.getAction()).thenReturn(action);
|
||||
when(event.getItem()).thenReturn(keystone);
|
||||
when(event.getPlayer()).thenReturn(player);
|
||||
when(event.isCancelled()).thenReturn(true);
|
||||
when(event.useItemInHand()).thenReturn(Event.Result.DENY);
|
||||
when(player.getUniqueId()).thenReturn(playerId);
|
||||
when(pocketBases.state(playerId)).thenReturn(
|
||||
new PocketBaseState(playerId, 0, Optional.empty())
|
||||
);
|
||||
|
||||
dispatchUse(service, event);
|
||||
|
||||
verify(pocketBases, never()).state(any(UUID.class));
|
||||
verify(returns, never()).record(any(UUID.class), any(KeystoneReturnLocation.class));
|
||||
verify(player, never()).teleport(
|
||||
any(Location.class), any(PlayerTeleportEvent.TeleportCause.class)
|
||||
);
|
||||
verify(event, never()).setCancelled(true);
|
||||
}
|
||||
|
||||
private static void dispatchUse(
|
||||
PocketBaseKeystoneService service, PlayerInteractEvent event
|
||||
) throws Exception {
|
||||
EventHandler handler = PocketBaseKeystoneService.class
|
||||
.getMethod("onUse", PlayerInteractEvent.class).getAnnotation(EventHandler.class);
|
||||
RegisteredListener listener = new RegisteredListener(
|
||||
service, (ignored, dispatched) -> service.onUse((PlayerInteractEvent) dispatched),
|
||||
handler.priority(), mock(Plugin.class), handler.ignoreCancelled()
|
||||
);
|
||||
listener.callEvent(event);
|
||||
}
|
||||
|
||||
private static ItemStack markedKeystone(NamespacedKey key) {
|
||||
ItemStack item = mock(ItemStack.class);
|
||||
ItemMeta metadata = mock(ItemMeta.class);
|
||||
PersistentDataContainer data = mock(PersistentDataContainer.class);
|
||||
when(item.getType()).thenReturn(Material.RECOVERY_COMPASS);
|
||||
when(item.getItemMeta()).thenReturn(metadata);
|
||||
when(metadata.getPersistentDataContainer()).thenReturn(data);
|
||||
when(data.get(key, PersistentDataType.BYTE)).thenReturn((byte) 1);
|
||||
return item;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
package games.dmg.spigotbase;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.inOrder;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
@@ -16,9 +18,80 @@ import org.mockito.InOrder;
|
||||
|
||||
final class PocketBaseManagerTest {
|
||||
@Test
|
||||
void persistsAndAppliesMobSpawningPreference() throws Exception {
|
||||
void relocatedReturnPortalOverridesGeneratedDefault() {
|
||||
UUID ownerId = UUID.randomUUID();
|
||||
PocketBaseState current = new PocketBaseState(ownerId, 1, Optional.empty(), false);
|
||||
PocketPortalLocation relocated = new PocketPortalLocation(
|
||||
UUID.randomUUID(), "pocket", 20, 65, 12, PocketPortalAxis.Z
|
||||
);
|
||||
PocketBaseStateManager states = mock(PocketBaseStateManager.class);
|
||||
PocketBaseWorldService worlds = mock(PocketBaseWorldService.class);
|
||||
when(states.state(ownerId)).thenReturn(new PocketBaseState(
|
||||
ownerId, 1, Optional.empty(), Optional.of(relocated), false, false,
|
||||
PocketBaseBiome.THE_VOID
|
||||
));
|
||||
PocketBaseManager manager = new PocketBaseManager(
|
||||
states,
|
||||
worlds,
|
||||
new PluginSettingsProvider(PluginSettings.from(Map.of()))
|
||||
);
|
||||
|
||||
assertEquals(relocated, manager.returnPortal(ownerId));
|
||||
}
|
||||
|
||||
@Test
|
||||
void existingStateWithoutRelocationUsesGeneratedReturnPortal() {
|
||||
UUID ownerId = UUID.randomUUID();
|
||||
PocketPortalLocation generated = new PocketPortalLocation(
|
||||
UUID.randomUUID(), "pocket", -2, 65, 0, PocketPortalAxis.X
|
||||
);
|
||||
PocketBaseStateManager states = mock(PocketBaseStateManager.class);
|
||||
PocketBaseWorldService worlds = mock(PocketBaseWorldService.class);
|
||||
when(states.state(ownerId)).thenReturn(
|
||||
new PocketBaseState(ownerId, 1, Optional.empty())
|
||||
);
|
||||
when(worlds.returnPortal(ownerId)).thenReturn(generated);
|
||||
PocketBaseManager manager = new PocketBaseManager(
|
||||
states,
|
||||
worlds,
|
||||
new PluginSettingsProvider(PluginSettings.from(Map.of()))
|
||||
);
|
||||
|
||||
assertEquals(generated, manager.returnPortal(ownerId));
|
||||
}
|
||||
|
||||
@Test
|
||||
void persistsPocketBaseFlightUnlock() throws Exception {
|
||||
UUID ownerId = UUID.randomUUID();
|
||||
PocketBaseState current = new PocketBaseState(ownerId, 1, Optional.empty());
|
||||
PocketBaseStateManager states = mock(PocketBaseStateManager.class);
|
||||
when(states.state(ownerId)).thenReturn(current);
|
||||
when(states.updateAndSave(org.mockito.ArgumentMatchers.eq(ownerId),
|
||||
org.mockito.ArgumentMatchers.any())).thenAnswer(invocation -> {
|
||||
@SuppressWarnings("unchecked")
|
||||
UnaryOperator<PocketBaseState> operation = invocation.getArgument(1);
|
||||
return operation.apply(current);
|
||||
});
|
||||
PocketBaseManager manager = new PocketBaseManager(
|
||||
states,
|
||||
mock(PocketBaseWorldService.class),
|
||||
new PluginSettingsProvider(PluginSettings.from(Map.of()))
|
||||
);
|
||||
|
||||
PocketBaseState updated = manager.unlockFlight(ownerId);
|
||||
|
||||
assertTrue(updated.flightUnlocked());
|
||||
verify(states).updateAndSave(
|
||||
org.mockito.ArgumentMatchers.eq(ownerId), org.mockito.ArgumentMatchers.any()
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void persistsAndAppliesHostileMobSpawningWithoutChangingPassive() throws Exception {
|
||||
UUID ownerId = UUID.randomUUID();
|
||||
PocketBaseState current = new PocketBaseState(
|
||||
ownerId, 1, Optional.empty(), Optional.empty(), false, true,
|
||||
PocketBaseBiome.THE_VOID
|
||||
);
|
||||
PocketBaseStateManager states = mock(PocketBaseStateManager.class);
|
||||
PocketBaseWorldService worlds = mock(PocketBaseWorldService.class);
|
||||
when(states.state(ownerId)).thenReturn(current);
|
||||
@@ -34,19 +107,50 @@ final class PocketBaseManagerTest {
|
||||
new PluginSettingsProvider(PluginSettings.from(Map.of()))
|
||||
);
|
||||
|
||||
manager.setMobSpawning(ownerId, true);
|
||||
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);
|
||||
order.verify(worlds).setMobSpawning(ownerId, true, true);
|
||||
order.verify(states).updateAndSave(
|
||||
org.mockito.ArgumentMatchers.eq(ownerId), org.mockito.ArgumentMatchers.any()
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void restoresWorldSettingWhenPersistenceFails() throws Exception {
|
||||
void persistsBiomeAfterApplyingItToTheUnlockedArea() throws Exception {
|
||||
UUID ownerId = UUID.randomUUID();
|
||||
PocketBaseState current = new PocketBaseState(ownerId, 1, Optional.empty(), false);
|
||||
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);
|
||||
@@ -58,9 +162,35 @@ final class PocketBaseManagerTest {
|
||||
new PluginSettingsProvider(PluginSettings.from(Map.of()))
|
||||
);
|
||||
|
||||
assertThrows(IOException.class, () -> manager.setMobSpawning(ownerId, true));
|
||||
assertThrows(IOException.class,
|
||||
() -> manager.setBiome(ownerId, PocketBaseBiome.DESERT));
|
||||
|
||||
verify(worlds).setMobSpawning(ownerId, true);
|
||||
verify(worlds).setMobSpawning(ownerId, false);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
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;
|
||||
@@ -29,9 +31,12 @@ final class PocketBaseWorldServiceTest {
|
||||
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()))
|
||||
new PluginSettingsProvider(PluginSettings.from(Map.of())),
|
||||
ignored -> null
|
||||
);
|
||||
|
||||
service.expand(ownerId, 0, 1);
|
||||
@@ -46,6 +51,33 @@ final class PocketBaseWorldServiceTest {
|
||||
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();
|
||||
@@ -55,15 +87,45 @@ final class PocketBaseWorldServiceTest {
|
||||
when(world.getUID()).thenReturn(UUID.randomUUID());
|
||||
PocketBaseWorldService service = new PocketBaseWorldService(
|
||||
server,
|
||||
new PluginSettingsProvider(PluginSettings.from(Map.of()))
|
||||
new PluginSettingsProvider(PluginSettings.from(Map.of())),
|
||||
ignored -> null
|
||||
);
|
||||
|
||||
service.loadExisting(Map.of(
|
||||
ownerId,
|
||||
new PocketBaseState(ownerId, 1, Optional.empty(), true)
|
||||
new PocketBaseState(
|
||||
ownerId, 1, Optional.empty(), Optional.empty(), true, false,
|
||||
PocketBaseBiome.THE_VOID
|
||||
)
|
||||
));
|
||||
|
||||
verify(world).setSpawnFlags(true, true);
|
||||
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
|
||||
@@ -75,9 +137,12 @@ final class PocketBaseWorldServiceTest {
|
||||
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()))
|
||||
new PluginSettingsProvider(PluginSettings.from(Map.of())),
|
||||
ignored -> null
|
||||
);
|
||||
Block existingCenter = world.getBlockAt(0, 64, 0);
|
||||
|
||||
@@ -87,6 +152,8 @@ final class PocketBaseWorldServiceTest {
|
||||
.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) {
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package games.dmg.spigotbase;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.util.UUID;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.Particle;
|
||||
import org.bukkit.Server;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.plugin.Plugin;
|
||||
import org.bukkit.scheduler.BukkitScheduler;
|
||||
import org.bukkit.scheduler.BukkitTask;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
final class TeleportParticlesTest {
|
||||
@Test
|
||||
void warmupIntensifiesThenFadesAtOriginalLocationAndStops() {
|
||||
Plugin plugin = mock(Plugin.class);
|
||||
Server server = mock(Server.class);
|
||||
BukkitScheduler scheduler = mock(BukkitScheduler.class);
|
||||
BukkitTask task = mock(BukkitTask.class);
|
||||
World world = mock(World.class);
|
||||
when(plugin.getServer()).thenReturn(server);
|
||||
when(server.getScheduler()).thenReturn(scheduler);
|
||||
when(scheduler.runTaskTimer(eq(plugin), any(Runnable.class), eq(0L), eq(2L)))
|
||||
.thenReturn(task);
|
||||
TeleportParticles effects = new TeleportParticles(plugin);
|
||||
UUID id = UUID.randomUUID();
|
||||
Location origin = new Location(world, 10, 64, 20);
|
||||
effects.start(id, origin, 2);
|
||||
origin.setX(999);
|
||||
ArgumentCaptor<Runnable> runnable = ArgumentCaptor.forClass(Runnable.class);
|
||||
verify(scheduler).runTaskTimer(eq(plugin), runnable.capture(), eq(0L), eq(2L));
|
||||
Runnable tick = runnable.getValue();
|
||||
for (int i = 0; i < 21; i++) tick.run();
|
||||
ArgumentCaptor<Integer> counts = ArgumentCaptor.forClass(Integer.class);
|
||||
verify(world, times(21)).spawnParticle(eq(Particle.END_ROD), any(Location.class),
|
||||
counts.capture().intValue(), anyDouble(), anyDouble(), anyDouble(), anyDouble());
|
||||
assertTrue(counts.getAllValues().getLast() > counts.getAllValues().getFirst());
|
||||
clearInvocations(world);
|
||||
effects.complete(id);
|
||||
for (int i = 0; i < 21; i++) tick.run();
|
||||
ArgumentCaptor<Location> locations = ArgumentCaptor.forClass(Location.class);
|
||||
counts = ArgumentCaptor.forClass(Integer.class);
|
||||
verify(world, times(20)).spawnParticle(eq(Particle.END_ROD), locations.capture(),
|
||||
counts.capture().intValue(), anyDouble(), anyDouble(), anyDouble(), anyDouble());
|
||||
assertTrue(counts.getAllValues().getFirst() > counts.getAllValues().getLast());
|
||||
assertTrue(locations.getAllValues().stream().allMatch(p -> Math.abs(p.getX() - 10) < 2));
|
||||
verify(task).cancel();
|
||||
}
|
||||
|
||||
@Test
|
||||
void cancellationAndShutdownStopWithoutAfterglow() {
|
||||
Plugin plugin = mock(Plugin.class);
|
||||
Server server = mock(Server.class);
|
||||
BukkitScheduler scheduler = mock(BukkitScheduler.class);
|
||||
BukkitTask task = mock(BukkitTask.class);
|
||||
World world = mock(World.class);
|
||||
when(plugin.getServer()).thenReturn(server);
|
||||
when(server.getScheduler()).thenReturn(scheduler);
|
||||
when(scheduler.runTaskTimer(eq(plugin), any(Runnable.class), eq(0L), eq(2L)))
|
||||
.thenReturn(task);
|
||||
TeleportParticles effects = new TeleportParticles(plugin);
|
||||
UUID id = UUID.randomUUID();
|
||||
effects.start(id, new Location(world, 0, 64, 0), 30);
|
||||
effects.cancel(id);
|
||||
effects.complete(id);
|
||||
verify(task).cancel();
|
||||
effects.start(id, new Location(world, 0, 64, 0), 30);
|
||||
effects.clear();
|
||||
verify(task, times(2)).cancel();
|
||||
verifyNoInteractions(world);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package games.dmg.spigotbase;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
final class YamlKeystoneReturnRepositoryTest {
|
||||
@TempDir
|
||||
Path temporaryDirectory;
|
||||
|
||||
@Test
|
||||
void roundTripsExactPlayerReturnDestinations() throws Exception {
|
||||
UUID playerId = UUID.randomUUID();
|
||||
KeystoneReturnLocation expected = new KeystoneReturnLocation(
|
||||
UUID.randomUUID(), "world", 12.25, 70.5, -4.75, 123.0F, -15.5F
|
||||
);
|
||||
YamlKeystoneReturnRepository repository = new YamlKeystoneReturnRepository(
|
||||
temporaryDirectory.resolve("keystone-returns.yml")
|
||||
);
|
||||
|
||||
repository.save(Map.of(playerId, expected));
|
||||
|
||||
assertEquals(expected, repository.load().get(playerId));
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package games.dmg.spigotbase;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
@@ -25,7 +26,17 @@ final class YamlPocketBaseRepositoryTest {
|
||||
Optional.of(new PocketPortalLocation(
|
||||
worldId, "world", 10, 65, -4, PocketPortalAxis.X
|
||||
)),
|
||||
true
|
||||
Optional.of(new PocketPortalLocation(
|
||||
worldId, "pocket", 20, 65, 12, PocketPortalAxis.Z
|
||||
)),
|
||||
true,
|
||||
false,
|
||||
PocketBaseBiome.CRIMSON_FOREST,
|
||||
true,
|
||||
Optional.of(new PocketPortalLocation(
|
||||
worldId, "pocket", 4, 65, 4, PocketPortalAxis.X
|
||||
)),
|
||||
false
|
||||
);
|
||||
YamlPocketBaseRepository repository = new YamlPocketBaseRepository(
|
||||
temporaryDirectory.resolve("pocket-bases.yml")
|
||||
@@ -43,6 +54,40 @@ final class YamlPocketBaseRepositoryTest {
|
||||
Files.writeString(stateFile, "owners:\n " + ownerId + ":\n level: 1\n");
|
||||
YamlPocketBaseRepository repository = new YamlPocketBaseRepository(stateFile);
|
||||
|
||||
assertFalse(repository.load().get(ownerId).mobSpawningEnabled());
|
||||
PocketBaseState loaded = repository.load().get(ownerId);
|
||||
|
||||
assertFalse(loaded.hostileMobSpawningEnabled());
|
||||
assertFalse(loaded.passiveMobSpawningEnabled());
|
||||
assertTrue(loaded.returnPortal().isEmpty());
|
||||
assertEquals(PocketBaseBiome.THE_VOID, loaded.biome());
|
||||
assertFalse(loaded.flightUnlocked());
|
||||
}
|
||||
|
||||
@Test
|
||||
void existingRaidUnlockDefaultsOwnerFlightToEnabled() throws Exception {
|
||||
UUID ownerId = UUID.randomUUID();
|
||||
Path stateFile = temporaryDirectory.resolve("pocket-bases.yml");
|
||||
Files.writeString(stateFile, "owners:\n " + ownerId
|
||||
+ ":\n level: 1\n flight-unlocked: true\n");
|
||||
YamlPocketBaseRepository repository = new YamlPocketBaseRepository(stateFile);
|
||||
|
||||
PocketBaseState loaded = repository.load().get(ownerId);
|
||||
|
||||
assertTrue(loaded.flightEnabled());
|
||||
assertTrue(loaded.flightPortal().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void migratesLegacyMobPreferenceToBothCategories() throws Exception {
|
||||
UUID ownerId = UUID.randomUUID();
|
||||
Path stateFile = temporaryDirectory.resolve("pocket-bases.yml");
|
||||
Files.writeString(stateFile, "owners:\n " + ownerId
|
||||
+ ":\n level: 1\n mob-spawning-enabled: true\n");
|
||||
YamlPocketBaseRepository repository = new YamlPocketBaseRepository(stateFile);
|
||||
|
||||
PocketBaseState loaded = repository.load().get(ownerId);
|
||||
|
||||
assertTrue(loaded.hostileMobSpawningEnabled());
|
||||
assertTrue(loaded.passiveMobSpawningEnabled());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user