28 Commits
Author SHA1 Message Date
dmg 745d09e5a6 chore(knowledge): move canonical docs to shared SoMC wiki
CI / build (push) Successful in 2m0s
Release / release (push) Successful in 2m50s
2026-09-09 23:17:54 -04:00
dmg 1ad930c7ad fix(pocket-base): handle keystone right-clicks in air
Release / release (push) Successful in 2m56s
CI / build (push) Successful in 1m31s
2026-09-06 22:14:18 -04:00
dmg 28dd5422d5 feat(teleport): add warmup particles and fading departure clouds
Release / release (push) Successful in 2m56s
CI / build (push) Successful in 1m35s
2026-09-06 21:11:24 -04:00
dmg ecb07d48ad feat(pocket-base): start flying on flight portal entry
Release / release (push) Successful in 5m2s
CI / build (push) Successful in 1m28s
2026-09-06 20:52:25 -04:00
dmg c42ba6888a fix(pocket-base): accept recovery compass metadata
Release / release (push) Successful in 2m47s
CI / build (push) Successful in 1m17s
2026-08-29 14:51:56 -04:00
dmg 61e7a01374 feat(pocket-base): add reusable keystone travel
Release / release (push) Successful in 3m2s
CI / build (push) Successful in 1m23s
2026-08-29 14:43:12 -04:00
dmg e0b1a25c5e feat(pocket-base): share flight through gold portals
Release / release (push) Successful in 2m50s
CI / build (push) Successful in 1m15s
2026-08-29 00:20:40 -04:00
dmg f4511f9286 feat(pocket-base): unlock flight by completing raids 2026-08-29 00:04:46 -04:00
dmg 02c6698268 fix(pocket-base): clear fall distance after portal travel
Release / release (push) Successful in 3m9s
CI / build (push) Successful in 1m27s
2026-08-28 23:50:08 -04:00
dmg bc2f9d8e25 fix(settings): use static dialog command actions
Release / release (push) Successful in 2m55s
CI / build (push) Successful in 1m16s
2026-08-25 19:06:05 -04:00
dmg 8b3eaad253 feat(pocket-base): allow mobs through portals
Release / release (push) Successful in 3m0s
CI / build (push) Successful in 1m24s
2026-08-24 13:10:53 -04:00
dmg 29413d36b3 fix(settings): use Purpur dialog API
Release / release (push) Successful in 5m29s
CI / build (push) Successful in 1m28s
2026-08-24 11:25:01 -04:00
dmg b6bbf9183a feat(settings): add native base control dialog
Release / release (push) Successful in 2m45s
CI / build (push) Successful in 1m18s
2026-08-24 10:54:26 -04:00
dmg b2ba4d5821 feat(pocket-base): split hostile and passive mob spawning
Release / release (push) Successful in 2m50s
CI / build (push) Successful in 1m13s
2026-08-24 10:41:44 -04:00
dmg 6ea5bc16f4 feat(pocket-base): add purchasable biome changes
Release / release (push) Successful in 2m54s
CI / build (push) Successful in 1m16s
2026-08-24 10:32:32 -04:00
dmg 4518478ea1 feat(pocket-base): allow return portal relocation
Release / release (push) Successful in 2m39s
CI / build (push) Successful in 1m13s
2026-08-24 10:06:25 -04:00
dmg 23e8fcec6e feat(pocket-base): add mob spawning toggle
Release / release (push) Successful in 2m46s
CI / build (push) Successful in 1m28s
2026-08-24 09:57:48 -04:00
dmg 11e3ad1720 feat(pocket-base): add persistent pocket dimensions
Release / release (push) Successful in 2m37s
CI / build (push) Successful in 1m14s
2026-08-21 23:16:04 -04:00
dmg 8a9640d992 fix(teleport): require same dimension for visits
Release / release (push) Successful in 2m24s
CI / build (push) Successful in 1m2s
2026-08-21 22:36:10 -04:00
dmg 3be3e43072 fix(teleport): enforce visitor warm-up
Release / release (push) Successful in 2m28s
CI / build (push) Successful in 1m3s
2026-08-21 22:28:34 -04:00
dmg 9692df9101 fix(flight): show boundary warning only while flying
Release / release (push) Successful in 4m20s
CI / build (push) Successful in 1m13s
2026-08-21 22:23:01 -04:00
dmg f36c40d5aa fix(navigation): hide guidance near base
Release / release (push) Successful in 2m22s
CI / build (push) Successful in 1m0s
2026-08-10 22:51:19 -04:00
dmg 259ea9410c feat(spawning): add spawnable block overlay
CI / build (push) Successful in 1m3s
Release / release (push) Successful in 2m16s
2026-08-10 22:46:02 -04:00
dmg 26aeb8c416 feat(border): add base boundary particles
CI / build (push) Successful in 1m1s
Release / release (push) Successful in 2m18s
2026-08-10 22:12:16 -04:00
dmg 4278e08bf3 feat(commands): unify settings and admin commands
Release / release (push) Successful in 2m28s
CI / build (push) Successful in 1m3s
2026-08-10 20:44:34 -04:00
dmg 5c9dc5b361 feat(commands): add toggle autocomplete aliases
Release / release (push) Successful in 2m17s
CI / build (push) Successful in 59s
2026-08-10 20:07:07 -04:00
dmg 00aa7566ad feat(commands): add base command aliases
Release / release (push) Successful in 2m21s
CI / build (push) Successful in 53s
2026-08-10 19:58:09 -04:00
dmg 84e013ac11 fix(navigation): preserve disabled preference
Release / release (push) Successful in 1m59s
CI / build (push) Successful in 48s
2026-08-09 15:38:40 -04:00
101 changed files with 8591 additions and 907 deletions
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 17
java-version: 25
cache: gradle
- name: Validate conventional commits
+1 -1
View File
@@ -21,7 +21,7 @@ jobs:
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 17
java-version: 25
cache: gradle
- name: Set up Node.js
+9
View File
@@ -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.
+40 -18
View File
@@ -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
@@ -22,30 +22,52 @@ The plugin JAR is written to `build/libs/`.
## Player commands
```text
/setbase
/base
/base upgrade
/basenavigation
/baseflight
/basevisitors
/gotobase <player>
/baseprogress
/baseprogress bossbar
/setbase (alias: /sethome)
/base (alias: /home)
/gotobase <player> (alias: /visit <player>)
/basesettings (alias: /homesettings)
/basesettings ui
/basesettings status
/basesettings upgrade
/basesettings pocket upgrade
/basesettings pocket flight <enable|disable>
/basesettings pocket mobs <hostile|passive> <enable|disable>
/basesettings pocket type <void|nether|overworld> <subtype>
/basesettings visitors <allowed|blocked>
/basesettings navigation <enable|disable>
/basesettings flight <enable|disable>
/basesettings border <enable|disable>
/basesettings spawnable <enable|disable>
/basesettings bossbar <enable|disable>
```
`/base` has a stationary warm-up. Looking around is allowed, while movement between blocks, damage, teleportation, world changes, death, logout, and conflicting teleport commands cancel it without consuming the cooldown.
`/basesettings` and `/basesettings ui` open Minecraft's native dialog dashboard. The dashboard summarizes Base and Pocket Base progression, provides unlocked setting controls and purchase confirmations, marks unavailable controls as locked, and provides Back buttons from nested screens. `/basesettings status` retains the detailed chat report.
Navigation particles appear only in the base's world and when the player is more than 25 blocks beyond the current base border.
After 250 Survival-mode block placements anywhere by default, the spawnable overlay can mark nearby dark hostile-mob spawning surfaces inside the player's base with owner-only red particles. The threshold is configurable.
`/base` has a stationary warm-up. Looking around is allowed, while movement between blocks, damage, teleportation, world changes, death, logout, and conflicting teleport commands cancel it without consuming the cooldown. Home and visitor warm-ups surround the player with nearby-visible floating particles that grow denser as departure approaches. Successful warmed-up teleports leave a cloud at the departure point that thins out over two seconds; cancellation or failure stops emission without a departure cloud.
Base IV owners can purchase and expand a persistent Pocket Base with `/basesettings pocket upgrade`. Activating a complete diamond-block portal frame inside the normal base with flint and steel opens one public entrance to the owner's grass platform in a private void world. Players and non-player living mobs can travel through the entrance and active return portal; items, projectiles, and vehicles are not transported. The owner can move the return portal by building and igniting another complete frame inside the unlocked Pocket Base boundary; only the newly activated return portal remains functional. Natural hostile and passive mob spawning is disabled by default. Owners can control each category independently with `/basesettings pocket mobs <hostile|passive> <enable|disable>`. An owner who wins a raid inside their own Pocket Base permanently unlocks owner-only Survival flight across the full build height and within the current Pocket Base boundary plus a 16-block warning buffer on every side. The owner can control that automatic privilege with `/basesettings pocket flight <enable|disable>`. After the raid unlock, the owner can ignite a complete gold-block portal frame inside the unlocked boundary; any Survival player who passes through receives temporary flight within the same boundary and buffer until leaving the Pocket Base. Entering the flight portal immediately starts the player flying and displays “Pocket dimension flight enabled!” once, without repeating while they stand inside the portal. The owner's automatic-flight preference does not affect portal-granted flight.
Pocket Base owners can also craft a reusable **Pocket Base Keystone**. Its shaped recipe uses an Ender Pearl in the center, Netherite Ingots on the four cardinal slots, and Obsidian in the four corners. Right-clicking the keystone outside a Pocket Base instantly records the player's location and takes them to their own Pocket Base. Right-clicking it again inside their own Pocket Base returns them to that exact location when the world and destination remain available and safe. Saved returns survive reconnects and server restarts. A transferred keystone only works for a player who has unlocked their own Pocket Base.
Owners can change biome metadata without altering Pocket Base blocks, entities, inventories, or portals by using `/basesettings pocket type <void|nether|overworld> <subtype>`. Void uses `the_void`; Nether and Overworld accept their compatible vanilla biome names, such as `crimson_forest` or `plains`. Each change defaults to 16 netherite blocks, is configurable, and affects applicable natural mob selection when spawning is enabled. The Pocket Base remains a void-generated normal-environment world; dimension-specific mechanics do not change.
## Administration
The `spigotbase.admin` permission is granted to server operators by default.
The `spigotbase.admin` permission is granted to server operators by default. `/homeadmin` aliases `/baseadmin`.
```text
/baseadmin progress <player>
/baseadmin status <player>
/baseadmin setlevel <player> <base|size|flight|warmup|cooldown> <level>
/baseadmin setprogress <player> <grass_dirt|stone|deepslate|obsidian|placements|base_breaks> <amount>
/baseadmin setprogress <player> <grass_dirt|stone|deepslate|obsidian|placements|total_placements|base_breaks> <amount>
/baseadmin setsetting <player> spawnable <enable|disable>
/baseadmin config <numeric-key> <value>
# e.g. /baseadmin config spawnable-overlay-unlock-placements 250
/baseadmin clearcooldown <player> [personal|visitor|all]
/baseadmin reset <player> <base|size|flight|warmup|cooldown>
/baseadmin reset <player> <size|flight|warmup|cooldown>
/baseadmin reset <player> all confirm
```
+5 -5
View File
@@ -9,23 +9,23 @@ version = providers.gradleProperty("releaseVersion")
repositories {
mavenCentral()
maven("https://hub.spigotmc.org/nexus/content/repositories/snapshots/")
maven("https://repo.purpurmc.org/snapshots/")
}
java {
toolchain {
languageVersion = JavaLanguageVersion.of(17)
languageVersion = JavaLanguageVersion.of(25)
}
}
tasks.withType<JavaCompile>().configureEach {
options.compilerArgs.addAll(listOf("-Xlint:all", "-Werror"))
options.compilerArgs.addAll(listOf("-Xlint:all,-deprecation,-removal", "-Werror"))
}
dependencies {
compileOnly("org.spigotmc:spigot-api:26.2-R0.1-SNAPSHOT")
compileOnly("org.purpurmc.purpur:purpur-api:26.2.build.2618-stable")
testImplementation("org.spigotmc:spigot-api:26.2-R0.1-SNAPSHOT")
testImplementation("org.purpurmc.purpur:purpur-api:26.2.build.2618-stable")
testImplementation(platform("org.junit:junit-bom:5.13.4"))
testImplementation("org.junit.jupiter:junit-jupiter")
testImplementation("org.mockito:mockito-core:5.18.0")
-15
View File
@@ -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)
-37
View File
@@ -1,37 +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 IIV 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 — 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.
-20
View File
@@ -1,20 +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)
@@ -1,32 +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] 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,26 +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 to indicate the direction toward the base.
- [x] Particle generation is bounded to avoid excessive server or client load.
- [x] A player in another world receives a clear message instead of a misleading particle direction.
- [x] `/basenavigation` toggles guidance on and off after Base II is unlocked.
- [x] 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,35 +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 displays prominent on-screen notice that the player is leaving the base.
- [x] Passing beyond the warning buffer removes only flight granted by this plugin.
- [x] Flight is not granted outside the unlocked vertical range.
- [x] `/baseflight` toggles the player's unlocked base flight on and off.
- [x] 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,32 +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] 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,36 +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] `/base upgrade` offers the Base IV purchase for a configurable price that defaults to 128 diamonds.
- [x] A successful purchase removes the complete price atomically from the player's direct inventory.
- [x] Insufficient funds, an invalid state, or a failed persistence operation does not consume any diamonds or grant Base IV.
- [x] `/basevisitors` lets a Base IV owner toggle visitor access on and off.
- [x] The visitor-access preference persists across reconnects and restarts.
- [x] `/gotobase <owner>` autocompletes bases that the requesting player is currently eligible to visit.
- [x] Enabled bases remain visitable while their owners are offline.
- [x] A visitor teleport uses the destination owner's current warm-up tier.
- [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)
@@ -1,30 +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] `/baseprogress` shows the player's Base, Base Size, Base Flight, Teleport Warm-up, and Teleport Cooldown paths.
- [x] Each path identifies earned levels, unmet prerequisites, current progress, the next threshold, and the next reward.
- [x] Locked secondary paths clearly identify Base I or another sequential level as their prerequisite.
- [x] Relevant qualifying activity briefly displays a configurable progress boss bar for the active milestone.
- [x] Boss-bar text and fill accurately represent the current count and threshold and never exceed 100 percent.
- [x] The automatic boss bar disappears after a configurable number of seconds.
- [x] `/baseprogress bossbar` toggles automatic progress boss bars on and off.
- [x] Disabling automatic boss bars does not prevent `/baseprogress` from displaying progress.
- [x] 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.
## 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,31 +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 progress <player>` displays the player's base, counters, earned path levels, active cooldowns, toggles, and visitor settings.
- [x] `/baseadmin setlevel <player> <path> <level>` sets an earned path level while enforcing or explicitly granting required preceding levels.
- [x] `/baseadmin setprogress <player> <path> <amount>` updates the selected counter and consistently evaluates reached tiers.
- [x] `/baseadmin reset <player> <path>` resets a selected path without silently leaving benefits that require it.
- [x] `/baseadmin reset <player> 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.
## 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)
+4
View File
@@ -1 +1,5 @@
plugins {
id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
}
rootProject.name = "spigot-base"
@@ -92,6 +92,7 @@ public final class AdminProgressionService {
long deepslate = player.deepslateBroken();
long obsidian = player.obsidianBroken();
long placements = player.blocksPlacedInBase();
long totalPlacements = player.totalBlocksPlaced();
long baseBreaks = player.blocksBrokenInBase();
switch (counter) {
case GRASS_DIRT -> grassDirt = amount;
@@ -99,6 +100,7 @@ public final class AdminProgressionService {
case DEEPSLATE -> deepslate = amount;
case OBSIDIAN -> obsidian = amount;
case PLACEMENTS -> placements = amount;
case TOTAL_PLACEMENTS -> totalPlacements = amount;
case BASE_BREAKS -> baseBreaks = amount;
}
PluginSettings configured = settings.current();
@@ -147,7 +149,11 @@ public final class AdminProgressionService {
}
PlayerState updated = player.withProgressCounters(
grassDirt, stone, deepslate, obsidian, placements, baseBreaks
);
).withTotalBlocksPlaced(totalPlacements);
if (totalPlacements < configured.spawnableOverlayUnlockPlacements()
&& updated.spawnableOverlayEnabled()) {
updated = updated.withSpawnableOverlayEnabled(false);
}
return updated.withAdministrativeLevels(
base,
size,
@@ -1,17 +1,38 @@
package games.dmg.spigotbase;
import java.util.Arrays;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
import java.util.TreeSet;
import org.bukkit.Bukkit;
import org.bukkit.ChatColor;
import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender;
import org.bukkit.command.TabCompleter;
import org.bukkit.entity.Player;
import org.bukkit.plugin.java.JavaPlugin;
final class BaseAdminCommand implements CommandExecutor {
final class BaseAdminCommand implements CommandExecutor, TabCompleter {
private static final List<String> SUBCOMMANDS = List.of(
"status", "setlevel", "setprogress", "setsetting", "clearcooldown", "reset", "config"
);
private static final List<String> PATHS = List.of(
"base", "size", "flight", "warmup", "cooldown"
);
private static final List<String> COUNTERS = List.of(
"grass_dirt", "stone", "deepslate", "obsidian", "placements", "total_placements",
"base_breaks"
);
private static final List<String> SETTINGS = List.of("spawnable");
private static final List<String> ENABLE_MODES = List.of("enable", "disable");
private static final List<String> COOLDOWN_TYPES = List.of("personal", "visitor", "all");
private static final List<String> RESET_PATHS = List.of(
"size", "flight", "warmup", "cooldown", "all"
);
private static final List<String> LEVELS_THREE = List.of("0", "1", "2", "3");
private static final List<String> LEVELS_FOUR = List.of("0", "1", "2", "3", "4");
private final JavaPlugin plugin;
private final BaseStateManager stateManager;
private final AdminProgressionService progressionService;
@@ -48,9 +69,10 @@ final class BaseAdminCommand implements CommandExecutor {
return true;
}
return switch (arguments[0].toLowerCase(Locale.ROOT)) {
case "progress" -> showProgress(sender, target.orElseThrow());
case "status" -> showProgress(sender, target.orElseThrow(), arguments);
case "setlevel" -> setLevel(sender, target.orElseThrow(), arguments);
case "setprogress" -> setProgress(sender, target.orElseThrow(), arguments);
case "setsetting" -> setSetting(sender, target.orElseThrow(), arguments);
case "clearcooldown" -> clearCooldown(sender, target.orElseThrow(), arguments);
case "reset" -> reset(sender, target.orElseThrow(), arguments);
default -> {
@@ -60,7 +82,15 @@ final class BaseAdminCommand implements CommandExecutor {
};
}
private boolean showProgress(CommandSender sender, PlayerState player) {
private boolean showProgress(
CommandSender sender,
PlayerState player,
String[] arguments
) {
if (arguments.length != 2) {
sender.sendMessage(ChatColor.RED + "Usage: /baseadmin status <player>");
return true;
}
sender.sendMessage(ChatColor.GOLD + "=== " + player.latestName() + " Base Progress ===");
sender.sendMessage(ChatColor.YELLOW + "Levels: base=" + player.baseLevel()
+ " size=" + player.sizeLevel() + " flight=" + player.flightLevel()
@@ -68,11 +98,17 @@ final class BaseAdminCommand implements CommandExecutor {
sender.sendMessage(ChatColor.GRAY + "Grass/dirt=" + player.grassAndDirtBroken()
+ " stone=" + player.stoneBroken() + " deepslate=" + player.deepslateBroken()
+ " obsidian=" + player.obsidianBroken());
int spawnableThreshold = settingsProvider.current()
.spawnableOverlayUnlockPlacements();
sender.sendMessage(ChatColor.GRAY + "In-base placements=" + player.blocksPlacedInBase()
+ " total placements=" + player.totalBlocksPlaced()
+ " breaks=" + player.blocksBrokenInBase());
sender.sendMessage(ChatColor.GRAY + "Spawnable overlay="
+ (player.totalBlocksPlaced() >= spawnableThreshold ? "unlocked" : "locked")
+ " (" + player.totalBlocksPlaced() + "/" + spawnableThreshold + ")");
sender.sendMessage(ChatColor.GRAY + "Toggles: navigation=" + player.navigationEnabled()
+ " flight=" + player.flightEnabled() + " bossbar=" + player.bossBarEnabled()
+ " visitors=" + player.visitorsEnabled());
+ " flight=" + player.flightEnabled() + " border=" + player.borderEnabled()
+ " spawnable=" + player.spawnableOverlayEnabled() + " bossbar=" + player.bossBarEnabled() + " visitors=" + player.visitorsEnabled());
sender.sendMessage(ChatColor.GRAY + "Base: " + player.base()
.map(base -> base.worldName() + " " + base.x() + "," + base.y() + "," + base.z())
.orElse("not set"));
@@ -89,8 +125,16 @@ final class BaseAdminCommand implements CommandExecutor {
sender.sendMessage(ChatColor.RED + "Usage: /baseadmin setlevel <player> <path> <level>");
return true;
}
final ProgressionPath path;
try {
path = ProgressionPath.valueOf(arguments[2].toUpperCase(Locale.ROOT));
} catch (IllegalArgumentException exception) {
sender.sendMessage(ChatColor.RED
+ "Usage: /baseadmin setlevel <player> "
+ "<base|size|flight|warmup|cooldown> <level>");
return true;
}
try {
ProgressionPath path = ProgressionPath.valueOf(arguments[2].toUpperCase(Locale.ROOT));
int level = Integer.parseInt(arguments[3]);
PlayerState updated = stateManager.update(
target.playerId(),
@@ -100,6 +144,8 @@ final class BaseAdminCommand implements CommandExecutor {
stateManager.saveIfDirty();
sender.sendMessage(ChatColor.GREEN + "Set " + updated.latestName() + "'s "
+ path.name().toLowerCase(Locale.ROOT) + " level to " + level + ".");
} catch (NumberFormatException exception) {
sender.sendMessage(ChatColor.RED + "The level must be an integer.");
} catch (IllegalArgumentException exception) {
sender.sendMessage(ChatColor.RED + exception.getMessage());
}
@@ -116,10 +162,19 @@ final class BaseAdminCommand implements CommandExecutor {
+ "Usage: /baseadmin setprogress <player> <counter> <amount>");
return true;
}
final ProgressCounter counter;
try {
ProgressCounter counter = ProgressCounter.valueOf(
counter = ProgressCounter.valueOf(
arguments[2].toUpperCase(Locale.ROOT).replace('-', '_')
);
} catch (IllegalArgumentException exception) {
sender.sendMessage(ChatColor.RED
+ "Usage: /baseadmin setprogress <player> "
+ "<grass_dirt|stone|deepslate|obsidian|placements|total_placements|base_breaks> "
+ "<amount>");
return true;
}
try {
long amount = Long.parseLong(arguments[3]);
PlayerState updated = stateManager.update(
target.playerId(),
@@ -137,12 +192,50 @@ final class BaseAdminCommand implements CommandExecutor {
return true;
}
private boolean setSetting(
CommandSender sender,
PlayerState target,
String[] arguments
) {
if (arguments.length != 4
|| !arguments[2].equalsIgnoreCase("spawnable")
|| !ENABLE_MODES.contains(arguments[3].toLowerCase(Locale.ROOT))) {
sender.sendMessage(ChatColor.RED + "Usage: /baseadmin setsetting <player> "
+ "spawnable <enable|disable>");
return true;
}
boolean enabled = arguments[3].equalsIgnoreCase("enable");
if (enabled && target.totalBlocksPlaced()
< settingsProvider.current().spawnableOverlayUnlockPlacements()) {
sender.sendMessage(ChatColor.RED + "That player has not unlocked the spawnable overlay.");
return true;
}
if (enabled && (target.baseLevel() < 1 || target.base().isEmpty())) {
sender.sendMessage(ChatColor.RED + "That player does not have an established base.");
return true;
}
PlayerState updated = stateManager.update(
target.playerId(),
target.latestName(),
current -> current.withSpawnableOverlayEnabled(enabled)
);
stateManager.saveIfDirty();
sender.sendMessage(ChatColor.GREEN + "Set " + updated.latestName()
+ "'s spawnable overlay to " + (enabled ? "enabled" : "disabled") + ".");
return true;
}
private boolean clearCooldown(
CommandSender sender,
PlayerState target,
String[] arguments
) {
String selection = arguments.length >= 3
if (arguments.length < 2 || arguments.length > 3) {
sender.sendMessage(ChatColor.RED
+ "Usage: /baseadmin clearcooldown <player> [personal|visitor|all]");
return true;
}
String selection = arguments.length == 3
? arguments[2].toLowerCase(Locale.ROOT)
: "all";
if (!Arrays.asList("personal", "visitor", "all").contains(selection)) {
@@ -186,8 +279,26 @@ final class BaseAdminCommand implements CommandExecutor {
sender.sendMessage(ChatColor.GREEN + "Reset all progression for " + target.latestName() + ".");
return true;
}
if (arguments.length != 3) {
sender.sendMessage(ChatColor.RED
+ "Usage: /baseadmin reset <player> <size|flight|warmup|cooldown|all> [confirm]");
return true;
}
if (arguments[2].equalsIgnoreCase("base")) {
sender.sendMessage(ChatColor.RED
+ "Resetting base removes all progression. Use: /baseadmin reset "
+ target.latestName() + " all confirm");
return true;
}
final ProgressionPath path;
try {
path = ProgressionPath.valueOf(arguments[2].toUpperCase(Locale.ROOT));
} catch (IllegalArgumentException exception) {
sender.sendMessage(ChatColor.RED
+ "Usage: /baseadmin reset <player> <size|flight|warmup|cooldown|all> [confirm]");
return true;
}
try {
ProgressionPath path = ProgressionPath.valueOf(arguments[2].toUpperCase(Locale.ROOT));
stateManager.update(
target.playerId(),
target.latestName(),
@@ -239,6 +350,80 @@ final class BaseAdminCommand implements CommandExecutor {
return true;
}
@Override
public List<String> onTabComplete(
CommandSender sender,
Command command,
String alias,
String[] arguments
) {
if (!sender.hasPermission("spigotbase.admin")) {
return List.of();
}
if (arguments.length == 1) {
String prefix = arguments[0].toLowerCase(Locale.ROOT);
return SUBCOMMANDS.stream()
.filter(subcommand -> subcommand.startsWith(prefix))
.toList();
}
if (arguments.length == 2 && arguments[0].equalsIgnoreCase("config")) {
String prefix = arguments[1].toLowerCase(Locale.ROOT);
return plugin.getConfig().getValues(false).entrySet().stream()
.filter(entry -> entry.getValue() instanceof Number)
.map(java.util.Map.Entry::getKey)
.filter(key -> key.toLowerCase(Locale.ROOT).startsWith(prefix))
.sorted(String.CASE_INSENSITIVE_ORDER)
.toList();
}
if (arguments.length == 2) {
String prefix = arguments[1].toLowerCase(Locale.ROOT);
TreeSet<String> names = new TreeSet<>(String.CASE_INSENSITIVE_ORDER);
stateManager.knownPlayers().values().stream()
.map(PlayerState::latestName)
.forEach(names::add);
if (plugin.getServer() != null) {
plugin.getServer().getOnlinePlayers().stream()
.map(Player::getName)
.forEach(names::add);
}
return names.stream()
.filter(name -> name.toLowerCase(Locale.ROOT).startsWith(prefix))
.toList();
}
if (arguments.length == 3) {
List<String> options = switch (arguments[0].toLowerCase(Locale.ROOT)) {
case "setlevel" -> PATHS;
case "setprogress" -> COUNTERS;
case "setsetting" -> SETTINGS;
case "clearcooldown" -> COOLDOWN_TYPES;
case "reset" -> RESET_PATHS;
default -> List.of();
};
return complete(options, arguments[2]);
}
if (arguments.length == 4 && arguments[0].equalsIgnoreCase("setsetting")) {
return complete(ENABLE_MODES, arguments[3]);
}
if (arguments.length == 4 && arguments[0].equalsIgnoreCase("setlevel")) {
List<String> levels = switch (arguments[2].toLowerCase(Locale.ROOT)) {
case "base", "cooldown" -> LEVELS_FOUR;
case "size", "flight", "warmup" -> LEVELS_THREE;
default -> List.of();
};
return complete(levels, arguments[3]);
}
if (arguments.length == 4 && arguments[0].equalsIgnoreCase("reset")
&& arguments[2].equalsIgnoreCase("all")) {
return complete(List.of("confirm"), arguments[3]);
}
return List.of();
}
private static List<String> complete(List<String> options, String input) {
String prefix = input.toLowerCase(Locale.ROOT);
return options.stream().filter(option -> option.startsWith(prefix)).toList();
}
private Optional<PlayerState> resolve(String name) {
Player online = Bukkit.getPlayerExact(name);
if (online != null) {
@@ -249,7 +434,7 @@ final class BaseAdminCommand implements CommandExecutor {
private static void sendUsage(CommandSender sender) {
sender.sendMessage(ChatColor.YELLOW + "Usage: /baseadmin "
+ "<progress|setlevel|setprogress|clearcooldown|reset> <player> ...");
+ "<status|setlevel|setprogress|setsetting|clearcooldown|reset> <player> ...");
sender.sendMessage(ChatColor.YELLOW + " /baseadmin config <key> <integer>");
}
}
@@ -0,0 +1,62 @@
package games.dmg.spigotbase;
import org.bukkit.Location;
import org.bukkit.Particle;
import org.bukkit.Server;
import org.bukkit.entity.Player;
final class BaseBorderController implements Runnable {
private final Server server;
private final BaseStateManager stateManager;
private final BaseBoundsService boundsService;
BaseBorderController(
Server server,
BaseStateManager stateManager,
BaseBoundsService boundsService
) {
this.server = server;
this.stateManager = stateManager;
this.boundsService = boundsService;
}
@Override
public void run() {
for (Player player : server.getOnlinePlayers()) {
PlayerState state = stateManager.player(player.getUniqueId(), player.getName());
if (!state.borderEnabled() || state.base().isEmpty()) {
continue;
}
BaseLocation base = state.base().orElseThrow();
if (!player.getWorld().getUID().equals(base.worldId())) {
continue;
}
Location playerLocation = player.getLocation();
long minimumY = (long) base.y() - boundsService.verticalRange(state);
long maximumY = (long) base.y() + boundsService.verticalRange(state);
if (playerLocation.getY() < minimumY || playerLocation.getY() > maximumY) {
continue;
}
for (BaseBorderGeometry.Point point : BaseBorderGeometry.visiblePoints(
base.x() + 0.5,
base.z() + 0.5,
boundsService.radius(state),
playerLocation.getX(),
playerLocation.getZ()
)) {
Location particle = new Location(
player.getWorld(),
point.x(),
playerLocation.getY() + 0.15,
point.z()
);
player.spawnParticle(
Particle.END_ROD,
particle,
1,
0.0, 0.0, 0.0, 0.0
);
}
}
}
}
@@ -0,0 +1,58 @@
package games.dmg.spigotbase;
import java.util.ArrayList;
import java.util.List;
final class BaseBorderGeometry {
static final int MAX_PARTICLES = 64;
static final double VISIBILITY_DISTANCE = 32.0;
private static final double PARTICLE_SPACING = 1.0;
private BaseBorderGeometry() {
}
static List<Point> visiblePoints(
double centerX,
double centerZ,
int radius,
double playerX,
double playerZ
) {
if (radius <= 0) {
throw new IllegalArgumentException("border radius must be positive");
}
double playerRadius = Math.hypot(playerX - centerX, playerZ - centerZ);
if (Math.abs(playerRadius - radius) > VISIBILITY_DISTANCE) {
return List.of();
}
double circumference = 2.0 * Math.PI * radius;
int count = Math.min(MAX_PARTICLES, Math.max(1, (int) Math.ceil(
circumference / PARTICLE_SPACING
)));
double centerAngle = Math.atan2(playerZ - centerZ, playerX - centerX);
double angleStep = circumference <= MAX_PARTICLES * PARTICLE_SPACING
? 2.0 * Math.PI / count
: PARTICLE_SPACING / radius;
double startAngle = circumference <= MAX_PARTICLES * PARTICLE_SPACING
? 0.0
: centerAngle - angleStep * (count - 1) / 2.0;
List<Point> points = new ArrayList<>(count);
double visibilitySquared = VISIBILITY_DISTANCE * VISIBILITY_DISTANCE;
for (int index = 0; index < count; index++) {
double angle = startAngle + angleStep * index;
double x = centerX + radius * Math.cos(angle);
double z = centerZ + radius * Math.sin(angle);
double deltaX = x - playerX;
double deltaZ = z - playerZ;
if (deltaX * deltaX + deltaZ * deltaZ <= visibilitySquared) {
points.add(new Point(x, z));
}
}
return List.copyOf(points);
}
record Point(double x, double z) {
}
}
@@ -1,31 +1,16 @@
package games.dmg.spigotbase;
import java.io.IOException;
import org.bukkit.ChatColor;
import org.bukkit.Material;
import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.PlayerInventory;
final class BaseCommand implements CommandExecutor {
private final BaseTeleportManager teleportManager;
private final BaseStateManager stateManager;
private final VisitorPolicy visitorPolicy;
private final PluginSettingsProvider settings;
BaseCommand(
BaseTeleportManager teleportManager,
BaseStateManager stateManager,
VisitorPolicy visitorPolicy,
PluginSettingsProvider settings
) {
BaseCommand(BaseTeleportManager teleportManager) {
this.teleportManager = teleportManager;
this.stateManager = stateManager;
this.visitorPolicy = visitorPolicy;
this.settings = settings;
}
@Override
@@ -38,95 +23,7 @@ final class BaseCommand implements CommandExecutor {
teleportManager.start(player);
return true;
}
if (arguments.length == 1 && arguments[0].equalsIgnoreCase("upgrade")) {
purchaseVisitorAccess(player);
return true;
}
player.sendMessage(ChatColor.RED + "Usage: /base [upgrade]");
player.sendMessage(ChatColor.RED + "Usage: /base");
return true;
}
private void purchaseVisitorAccess(Player player) {
PlayerState state = stateManager.player(player.getUniqueId(), player.getName());
if (!visitorPolicy.canPurchase(state)) {
player.sendMessage(ChatColor.RED + (state.baseLevel() >= 4
? "Base IV is already unlocked."
: "You must unlock Base III before purchasing Base IV."));
return;
}
int price = settings.current().visitorUnlockDiamondCost();
Material currency = Material.valueOf(settings.current().visitorCurrencyMaterial());
PlayerInventory inventory = player.getInventory();
if (countCurrency(inventory, currency) < price) {
player.sendMessage(ChatColor.RED + "Base IV costs " + price + " "
+ currency.name().toLowerCase(java.util.Locale.ROOT) + ".");
return;
}
ItemStack[] snapshot = cloneContents(inventory.getStorageContents());
removeCurrency(inventory, currency, price);
try {
stateManager.updateAndSave(
player.getUniqueId(),
player.getName(),
current -> current.withBaseLevel(4).withVisitorsEnabled(true)
);
} catch (IOException | RuntimeException exception) {
inventory.setStorageContents(snapshot);
player.sendMessage(ChatColor.RED + "The upgrade could not be saved; your diamonds were restored.");
return;
}
player.sendTitle(
ChatColor.GOLD + "Base IV Unlocked",
ChatColor.YELLOW + "Visitors may now teleport to your base",
settings.current().titleFadeInTicks(),
settings.current().titleStayTicks(),
settings.current().titleFadeOutTicks()
);
player.sendMessage(ChatColor.GREEN + "Base IV unlocked for " + price + " "
+ currency.name().toLowerCase(java.util.Locale.ROOT) + ".");
}
private static int countCurrency(PlayerInventory inventory, Material currency) {
int count = 0;
for (ItemStack item : inventory.getStorageContents()) {
if (item != null && item.getType() == currency) {
count += item.getAmount();
}
}
return count;
}
private static void removeCurrency(
PlayerInventory inventory,
Material currency,
int amount
) {
ItemStack[] contents = inventory.getStorageContents();
int remaining = amount;
for (int index = 0; index < contents.length && remaining > 0; index++) {
ItemStack item = contents[index];
if (item == null || item.getType() != currency) {
continue;
}
int removed = Math.min(remaining, item.getAmount());
remaining -= removed;
int newAmount = item.getAmount() - removed;
if (newAmount == 0) {
contents[index] = null;
} else {
ItemStack reduced = item.clone();
reduced.setAmount(newAmount);
contents[index] = reduced;
}
}
inventory.setStorageContents(contents);
}
private static ItemStack[] cloneContents(ItemStack[] contents) {
ItemStack[] copy = new ItemStack[contents.length];
for (int index = 0; index < contents.length; index++) {
copy[index] = contents[index] == null ? null : contents[index].clone();
}
return copy;
}
}
@@ -1,43 +0,0 @@
package games.dmg.spigotbase;
import org.bukkit.ChatColor;
import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
final class BaseFlightCommand implements CommandExecutor {
private final BaseStateManager stateManager;
private final BaseFlightController controller;
BaseFlightCommand(BaseStateManager stateManager, BaseFlightController controller) {
this.stateManager = stateManager;
this.controller = controller;
}
@Override
public boolean onCommand(CommandSender sender, Command command, String label, String[] arguments) {
if (!(sender instanceof Player player)) {
sender.sendMessage("Only players can use base flight.");
return true;
}
PlayerState state = stateManager.player(player.getUniqueId(), player.getName());
if (state.flightLevel() < 1) {
player.sendMessage(ChatColor.RED + "Base flight is still locked.");
return true;
}
state = stateManager.update(
player.getUniqueId(),
player.getName(),
current -> current.withFlightEnabled(!current.flightEnabled())
);
if (!state.flightEnabled()) {
controller.removeGrantedFlight(player);
}
stateManager.saveIfDirty();
player.sendMessage(ChatColor.YELLOW + "Base flight is now "
+ (state.flightEnabled() ? ChatColor.GREEN + "on" : ChatColor.RED + "off")
+ ChatColor.YELLOW + ".");
return true;
}
}
@@ -1,23 +1,38 @@
package games.dmg.spigotbase;
import java.io.IOException;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.logging.Level;
import java.util.logging.Logger;
import org.bukkit.ChatColor;
import org.bukkit.GameMode;
import org.bukkit.Material;
import org.bukkit.Server;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.entity.PlayerDeathEvent;
import org.bukkit.event.player.PlayerQuitEvent;
import org.bukkit.event.raid.RaidFinishEvent;
import org.bukkit.inventory.ItemStack;
final class BaseFlightController implements Runnable {
final class BaseFlightController implements Runnable, Listener {
private static final int POCKET_FLIGHT_BUFFER = 16;
private final Server server;
private final BaseStateManager stateManager;
private final SecondaryProgressionService progressionService;
private final BaseBoundsService boundsService;
private final PluginSettingsProvider settings;
private final PocketBaseManager pocketBases;
private final Logger logger;
private final Set<UUID> grantedFlight = new HashSet<>();
private final Set<UUID> warned = new HashSet<>();
private final Map<UUID, UUID> portalFlight = new HashMap<>();
BaseFlightController(
Server server,
@@ -25,12 +40,85 @@ final class BaseFlightController implements Runnable {
SecondaryProgressionService progressionService,
BaseBoundsService boundsService,
PluginSettingsProvider settings
) {
this(server, stateManager, progressionService, boundsService, settings, null,
Logger.getLogger(BaseFlightController.class.getName()));
}
BaseFlightController(
Server server,
BaseStateManager stateManager,
SecondaryProgressionService progressionService,
BaseBoundsService boundsService,
PluginSettingsProvider settings,
PocketBaseManager pocketBases,
Logger logger
) {
this.server = server;
this.stateManager = stateManager;
this.progressionService = progressionService;
this.boundsService = boundsService;
this.settings = settings;
this.pocketBases = pocketBases;
this.logger = logger;
}
@EventHandler
public void onRaidFinish(RaidFinishEvent event) {
if (pocketBases == null) {
return;
}
Optional<UUID> owner = pocketBases.ownerForPocketWorld(event.getWorld().getUID());
if (owner.isEmpty() || event.getWinners().stream().noneMatch(
winner -> winner.getUniqueId().equals(owner.orElseThrow()))) {
return;
}
UUID ownerId = owner.orElseThrow();
if (pocketBases.state(ownerId).flightUnlocked()) {
return;
}
try {
pocketBases.unlockFlight(ownerId);
} catch (IOException | RuntimeException exception) {
logger.log(Level.SEVERE, "Could not unlock Pocket Base flight", exception);
return;
}
event.getWinners().stream()
.filter(winner -> winner.getUniqueId().equals(ownerId))
.findFirst()
.orElseThrow()
.sendTitle(
ChatColor.GOLD + "Pocket Base Flight Unlocked",
ChatColor.YELLOW + "Flight is now available in your Pocket Base",
settings.current().titleFadeInTicks(),
settings.current().titleStayTicks(),
settings.current().titleFadeOutTicks()
);
}
@EventHandler
public void onDeath(PlayerDeathEvent event) {
clearPortalFlight(event.getEntity());
}
@EventHandler
public void onQuit(PlayerQuitEvent event) {
clearPortalFlight(event.getPlayer());
}
void grantPocketPortalFlight(Player player, UUID ownerId) {
if (player.getGameMode() != GameMode.SURVIVAL || pocketBases == null
|| !pocketBases.ownerForPocketWorld(player.getWorld().getUID())
.filter(ownerId::equals).isPresent()) {
return;
}
portalFlight.put(player.getUniqueId(), ownerId);
if (applyPocketFlight(player, ownerId)) {
player.setFlying(true);
player.sendMessage(ChatColor.GREEN + "Pocket dimension flight enabled!");
} else {
portalFlight.remove(player.getUniqueId());
}
}
@Override
@@ -44,6 +132,7 @@ final class BaseFlightController implements Runnable {
}
grantedFlight.removeIf(id -> server.getPlayer(id) == null);
warned.removeIf(id -> server.getPlayer(id) == null);
portalFlight.keySet().removeIf(id -> server.getPlayer(id) == null);
}
void removeGrantedFlight(Player player) {
@@ -66,6 +155,7 @@ final class BaseFlightController implements Runnable {
}
grantedFlight.clear();
warned.clear();
portalFlight.clear();
}
private PlayerState observeElytra(Player player, PlayerState state) {
@@ -80,7 +170,7 @@ final class BaseFlightController implements Runnable {
stateManager.saveIfDirty();
player.sendTitle(
ChatColor.GOLD + "Base Flight " + roman(updated.flightLevel()) + " Unlocked",
ChatColor.YELLOW + "Use /baseflight to toggle flight",
ChatColor.YELLOW + "Use /basesettings flight enable to enable flight",
settings.current().titleFadeInTicks(),
settings.current().titleStayTicks(),
settings.current().titleFadeOutTicks()
@@ -89,8 +179,22 @@ final class BaseFlightController implements Runnable {
}
private void applyFlight(Player player, PlayerState state) {
if (player.getGameMode() != GameMode.SURVIVAL
|| !state.flightEnabled() || state.flightLevel() < 1 || state.base().isEmpty()) {
if (player.getGameMode() != GameMode.SURVIVAL) {
portalFlight.remove(player.getUniqueId());
removeGrantedFlight(player);
return;
}
if (pocketBases != null) {
Optional<UUID> pocketOwner = pocketBases.ownerForPocketWorld(
player.getWorld().getUID()
);
if (pocketOwner.isPresent()) {
applyPocketFlight(player, pocketOwner.orElseThrow());
return;
}
}
portalFlight.remove(player.getUniqueId());
if (!state.flightEnabled() || state.flightLevel() < 1 || state.base().isEmpty()) {
removeGrantedFlight(player);
return;
}
@@ -112,7 +216,7 @@ final class BaseFlightController implements Runnable {
player.setAllowFlight(true);
grantedFlight.add(player.getUniqueId());
}
if (distanceSquared > (double) radius * radius) {
if (distanceSquared > (double) radius * radius && player.isFlying()) {
if (warned.add(player.getUniqueId())) {
player.sendTitle(
ChatColor.RED + "Leaving Your Base",
@@ -127,6 +231,51 @@ final class BaseFlightController implements Runnable {
}
}
private boolean applyPocketFlight(Player player, UUID ownerId) {
PocketBaseState pocket = pocketBases.state(ownerId);
boolean ownerFlight = ownerId.equals(player.getUniqueId())
&& pocket.flightUnlocked() && pocket.flightEnabled();
boolean temporaryFlight = ownerId.equals(portalFlight.get(player.getUniqueId()));
if ((!ownerFlight && !temporaryFlight) || pocket.level() < 1) {
removeGrantedFlight(player);
return false;
}
int halfSize = pocketBases.policy().size(pocket.level()) / 2;
int x = player.getLocation().getBlockX();
int z = player.getLocation().getBlockZ();
if (x < -halfSize - POCKET_FLIGHT_BUFFER
|| x >= halfSize + POCKET_FLIGHT_BUFFER
|| z < -halfSize - POCKET_FLIGHT_BUFFER
|| z >= halfSize + POCKET_FLIGHT_BUFFER) {
removeGrantedFlight(player);
return false;
}
if (!player.getAllowFlight()) {
player.setAllowFlight(true);
grantedFlight.add(player.getUniqueId());
}
boolean inBuffer = !pocketBases.policy().contains(pocket.level(), x, z);
if (inBuffer && player.isFlying()) {
if (warned.add(player.getUniqueId())) {
player.sendTitle(
ChatColor.RED + "Leaving Your Pocket Base",
ChatColor.YELLOW + "Turn back before Pocket Base flight ends",
0,
Math.min(30, settings.current().titleStayTicks()),
settings.current().titleFadeOutTicks()
);
}
} else {
warned.remove(player.getUniqueId());
}
return true;
}
private void clearPortalFlight(Player player) {
portalFlight.remove(player.getUniqueId());
removeGrantedFlight(player);
}
private boolean withinVerticalRange(Player player, PlayerState state, BaseLocation base) {
int y = player.getLocation().getBlockY();
if (state.flightLevel() == 3) {
@@ -1,47 +0,0 @@
package games.dmg.spigotbase;
import org.bukkit.ChatColor;
import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
final class BaseNavigationCommand implements CommandExecutor {
private final BaseStateManager stateManager;
BaseNavigationCommand(BaseStateManager stateManager) {
this.stateManager = stateManager;
}
@Override
public boolean onCommand(CommandSender sender, Command command, String label, String[] arguments) {
if (!(sender instanceof Player player)) {
sender.sendMessage("Only players can use base navigation.");
return true;
}
PlayerState state = stateManager.player(player.getUniqueId(), player.getName());
if (state.baseLevel() < 2) {
player.sendMessage(ChatColor.RED + "Base II navigation is still locked.");
return true;
}
if (state.base().isEmpty()) {
player.sendMessage(ChatColor.RED + "Set your base before enabling navigation.");
return true;
}
state = stateManager.update(
player.getUniqueId(),
player.getName(),
current -> current.withNavigationEnabled(!current.navigationEnabled())
);
stateManager.saveIfDirty();
player.sendMessage(ChatColor.YELLOW + "Base navigation is now "
+ (state.navigationEnabled() ? ChatColor.GREEN + "on" : ChatColor.RED + "off")
+ ChatColor.YELLOW + ".");
if (state.navigationEnabled()
&& !player.getWorld().getUID().equals(state.base().orElseThrow().worldId())) {
player.sendMessage(ChatColor.RED + "Your base is in another world: "
+ state.base().orElseThrow().worldName() + ".");
}
return true;
}
}
@@ -10,6 +10,7 @@ final class BaseNavigationController implements Runnable {
private final Server server;
private final BaseStateManager stateManager;
private final PluginSettingsProvider settings;
private final BaseBoundsService boundsService;
BaseNavigationController(
Server server,
@@ -19,6 +20,7 @@ final class BaseNavigationController implements Runnable {
this.server = server;
this.stateManager = stateManager;
this.settings = settings;
this.boundsService = new BaseBoundsService(settings);
}
@Override
@@ -32,7 +34,16 @@ final class BaseNavigationController implements Runnable {
if (!player.getWorld().getUID().equals(base.worldId())) {
continue;
}
Location origin = player.getLocation().clone().add(0.0, 0.15, 0.0);
Location playerLocation = player.getLocation();
if (!BaseNavigationVisibility.isBeyondBorderBuffer(
base,
boundsService.radius(state),
playerLocation.getX(),
playerLocation.getZ()
)) {
continue;
}
Location origin = playerLocation.clone().add(0.0, 0.15, 0.0);
Vector direction = new Vector(
base.x() + 0.5 - origin.getX(),
0.0,
@@ -0,0 +1,26 @@
package games.dmg.spigotbase;
final class BaseNavigationVisibility {
static final int BORDER_BUFFER = 25;
private BaseNavigationVisibility() {
}
static boolean isBeyondBorderBuffer(
BaseLocation base,
int radius,
double playerX,
double playerZ
) {
if (base == null) {
throw new IllegalArgumentException("base is required");
}
if (radius <= 0) {
throw new IllegalArgumentException("base radius must be positive");
}
double deltaX = playerX - (base.x() + 0.5);
double deltaZ = playerZ - (base.z() + 0.5);
double minimumDistance = radius + BORDER_BUFFER;
return deltaX * deltaX + deltaZ * deltaZ > minimumDistance * minimumDistance;
}
}
@@ -1,121 +0,0 @@
package games.dmg.spigotbase;
import org.bukkit.ChatColor;
import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
final class BaseProgressCommand implements CommandExecutor {
private final BaseStateManager stateManager;
private final PluginSettingsProvider settings;
private final TeleportPolicy teleportPolicy;
BaseProgressCommand(BaseStateManager stateManager, PluginSettingsProvider settings) {
this.stateManager = stateManager;
this.settings = settings;
this.teleportPolicy = new TeleportPolicy(settings);
}
@Override
public boolean onCommand(CommandSender sender, Command command, String label, String[] arguments) {
if (!(sender instanceof Player player)) {
sender.sendMessage("Only players have base progression.");
return true;
}
PlayerState state = stateManager.player(player.getUniqueId(), player.getName());
if (arguments.length == 1 && arguments[0].equalsIgnoreCase("bossbar")) {
state = stateManager.update(
player.getUniqueId(),
player.getName(),
current -> current.withBossBarEnabled(!current.bossBarEnabled())
);
stateManager.saveIfDirty();
player.sendMessage(ChatColor.YELLOW + "Automatic progress boss bars are now "
+ (state.bossBarEnabled() ? ChatColor.GREEN + "on" : ChatColor.RED + "off")
+ ChatColor.YELLOW + ".");
return true;
}
player.sendMessage(ChatColor.GOLD + "=== Base Progress ===");
showBasePath(player, state);
showSizePath(player, state);
showFlightPath(player, state);
showWarmupPath(player, state);
showCooldownPath(player, state);
player.sendMessage(ChatColor.GRAY + "Boss bars: " + (state.bossBarEnabled() ? "on" : "off"));
state.base().ifPresentOrElse(
base -> player.sendMessage(ChatColor.GRAY + "Base: " + base.worldName() + " "
+ base.x() + ", " + base.y() + ", " + base.z()),
() -> player.sendMessage(ChatColor.GRAY + "Base: not set")
);
return true;
}
private void showBasePath(Player player, PlayerState state) {
String detail = switch (state.baseLevel()) {
case 0 -> state.grassAndDirtBroken() + "/" + settings.current().baseUnlockBlocks()
+ " grass or dirt → /setbase";
case 1 -> state.grassAndDirtBroken() + "/" + settings.current().navigationUnlockBlocks()
+ " grass or dirt → navigation";
case 2 -> state.blocksPlacedInBase() + "/" + settings.current().teleportUnlockPlacements()
+ " placements → /base";
case 3 -> settings.current().visitorUnlockDiamondCost() + " diamonds → visitor access";
case 4 -> "complete; visitor access unlocked";
default -> "invalid";
};
player.sendMessage(ChatColor.YELLOW + "Base " + state.baseLevel() + "/4: "
+ ChatColor.GRAY + detail);
}
private void showSizePath(Player player, PlayerState state) {
String detail = switch (state.sizeLevel()) {
case 0 -> state.stoneBroken() + "/" + settings.current().stoneExpansionBlocks() + " stone";
case 1 -> state.deepslateBroken() + "/" + settings.current().deepslateExpansionBlocks() + " deepslate";
case 2 -> state.obsidianBroken() + "/" + settings.current().obsidianExpansionBlocks() + " obsidian";
case 3 -> "complete; 150-block radius by default";
default -> "invalid";
};
player.sendMessage(colorForPrerequisite(state.baseLevel() >= 1) + "Base Size "
+ state.sizeLevel() + "/3: " + ChatColor.GRAY + detail);
}
private void showFlightPath(Player player, PlayerState state) {
String detail = state.flightLevel() >= 3
? "complete; world build height"
: (state.flightLevel() + 1) + " simultaneous elytra required";
player.sendMessage(colorForPrerequisite(state.baseLevel() >= 1) + "Base Flight "
+ state.flightLevel() + "/3: " + ChatColor.GRAY + detail
+ "; toggle=" + (state.flightEnabled() ? "on" : "off"));
}
private void showWarmupPath(Player player, PlayerState state) {
String detail = switch (state.warmupLevel()) {
case 0 -> state.blocksPlacedInBase() + "/" + settings.current().secondWarmupPlacements();
case 1 -> state.blocksPlacedInBase() + "/" + settings.current().thirdWarmupPlacements();
case 2 -> state.blocksPlacedInBase() + "/" + settings.current().instantWarmupPlacements();
case 3 -> "complete";
default -> "invalid";
};
player.sendMessage(colorForPrerequisite(state.baseLevel() >= 3) + "Teleport Warm-up "
+ state.warmupLevel() + "/3: " + ChatColor.GRAY
+ DurationFormatter.friendly(teleportPolicy.warmup(state)) + "; " + detail);
}
private void showCooldownPath(Player player, PlayerState state) {
String detail = switch (state.cooldownLevel()) {
case 0 -> state.blocksBrokenInBase() + "/" + settings.current().firstCooldownBreaks();
case 1 -> state.blocksBrokenInBase() + "/" + settings.current().secondCooldownBreaks();
case 2 -> state.blocksBrokenInBase() + "/" + settings.current().thirdCooldownBreaks();
case 3 -> state.blocksBrokenInBase() + "/" + settings.current().instantCooldownBreaks();
case 4 -> "complete";
default -> "invalid";
};
player.sendMessage(colorForPrerequisite(state.baseLevel() >= 3) + "Teleport Cooldown "
+ state.cooldownLevel() + "/4: " + ChatColor.GRAY
+ DurationFormatter.friendly(teleportPolicy.cooldown(state)) + "; " + detail);
}
private static ChatColor colorForPrerequisite(boolean met) {
return met ? ChatColor.YELLOW : ChatColor.RED;
}
}
@@ -24,6 +24,7 @@ final class BaseProgressListener implements Listener {
private final BaseProgressionService baseProgressionService;
private final SecondaryProgressionService secondaryProgressionService;
private final TeleportProgressionService teleportProgressionService;
private final SpawnableOverlayProgressionService spawnableOverlayProgressionService;
private final BaseBoundsService boundsService;
private final PluginSettingsProvider settings;
private final Map<UUID, BossBar> activeBossBars = new HashMap<>();
@@ -34,6 +35,7 @@ final class BaseProgressListener implements Listener {
BaseProgressionService baseProgressionService,
SecondaryProgressionService secondaryProgressionService,
TeleportProgressionService teleportProgressionService,
SpawnableOverlayProgressionService spawnableOverlayProgressionService,
BaseBoundsService boundsService,
PluginSettingsProvider settings
) {
@@ -42,6 +44,7 @@ final class BaseProgressListener implements Listener {
this.baseProgressionService = baseProgressionService;
this.secondaryProgressionService = secondaryProgressionService;
this.teleportProgressionService = teleportProgressionService;
this.spawnableOverlayProgressionService = spawnableOverlayProgressionService;
this.boundsService = boundsService;
this.settings = settings;
}
@@ -97,26 +100,43 @@ final class BaseProgressListener implements Listener {
return;
}
PlayerState before = stateManager.player(player.getUniqueId(), player.getName());
if (!isInsideBase(
before,
event.getBlockPlaced().getWorld().getUID(),
event.getBlockPlaced().getX(),
event.getBlockPlaced().getY(),
event.getBlockPlaced().getZ())) {
return;
}
boolean insideBase = isInsideBase(
before,
event.getBlockPlaced().getWorld().getUID(),
event.getBlockPlaced().getX(),
event.getBlockPlaced().getY(),
event.getBlockPlaced().getZ()
);
ProgressionUpdate[] updateHolder = new ProgressionUpdate[1];
SpawnableOverlayProgressionUpdate[] overlayHolder =
new SpawnableOverlayProgressionUpdate[1];
PlayerState state = stateManager.update(player.getUniqueId(), player.getName(), current -> {
ProgressionUpdate update = teleportProgressionService.recordPlacement(current);
SpawnableOverlayProgressionUpdate overlay =
spawnableOverlayProgressionService.recordPlacement(current);
ProgressionUpdate update = insideBase
? teleportProgressionService.recordPlacement(overlay.player())
: ProgressionUpdate.unchanged(overlay.player());
overlayHolder[0] = overlay;
updateHolder[0] = update;
return update.player();
});
ProgressionUpdate update = updateHolder[0];
SpawnableOverlayProgressionUpdate overlay = overlayHolder[0];
announceUnlock(player, update);
if (hasUnlock(update)) {
if (overlay.unlocked()) {
sendUnlockTitle(
player,
ChatColor.GOLD + "Spawnable Overlay Unlocked",
ChatColor.YELLOW + "/basesettings spawnable enable is now available"
);
player.sendMessage(ChatColor.GREEN
+ "You unlocked spawnable block highlighting! Use "
+ "/basesettings spawnable enable.");
}
if (hasUnlock(update) || overlay.unlocked()) {
stateManager.saveIfDirty();
}
if (state.bossBarEnabled() && state.baseLevel() >= 2) {
if (insideBase && state.bossBarEnabled() && state.baseLevel() >= 2) {
showProgress(player, warmupDisplay(state));
}
}
@@ -149,7 +169,7 @@ final class BaseProgressListener implements Listener {
int level = update.player().baseLevel();
String subtitle = switch (level) {
case 1 -> "/setbase is now available";
case 2 -> "/basenavigation is now available";
case 2 -> "/basesettings navigation enable is now available";
case 3 -> "/base is now available";
case 4 -> "Visitors can now travel to your base";
default -> "A new base benefit is available";
@@ -25,7 +25,7 @@ public final class BaseProgressionService {
}
boolean unlocked = baseLevel != previousLevel;
PlayerState updated = player.withGrassAndDirtProgress(count, baseLevel);
if (baseLevel >= 2 && !updated.navigationEnabled()) {
if (unlocked && baseLevel == 2) {
updated = updated.withNavigationEnabled(true);
}
return new ProgressionUpdate(updated, unlocked, false, false, false, false);
@@ -0,0 +1,751 @@
package games.dmg.spigotbase;
import io.papermc.paper.dialog.Dialog;
import java.io.IOException;
import java.util.List;
import java.util.Locale;
import org.bukkit.ChatColor;
import org.bukkit.Material;
import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender;
import org.bukkit.command.TabCompleter;
import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.PlayerInventory;
final class BaseSettingsCommand implements CommandExecutor, TabCompleter {
private static final List<String> SETTINGS = List.of(
"ui", "status", "upgrade", "pocket", "visitors", "navigation", "flight", "border",
"spawnable", "bossbar"
);
private static final List<String> VISITOR_MODES = List.of("allowed", "blocked");
private static final List<String> ENABLE_MODES = List.of("enable", "disable");
private static final List<String> POCKET_WORLD_TYPES = List.of(
"void", "nether", "overworld"
);
private final BaseStateManager stateManager;
private final PluginSettingsProvider settings;
private final TeleportPolicy teleportPolicy;
private final BaseFlightController flightController;
private final PocketBaseManager pocketBases;
private final DialogProvider dialogProvider;
BaseSettingsCommand(
BaseStateManager stateManager,
PluginSettingsProvider settings,
BaseFlightController flightController
) {
this(stateManager, settings, flightController, null);
}
BaseSettingsCommand(
BaseStateManager stateManager,
PluginSettingsProvider settings,
BaseFlightController flightController,
PocketBaseManager pocketBases
) {
this(
stateManager,
settings,
flightController,
pocketBases,
(owner, pocket) -> new BaseSettingsDialogFactory(settings.current())
.create(owner, pocket)
);
}
BaseSettingsCommand(
BaseStateManager stateManager,
PluginSettingsProvider settings,
BaseFlightController flightController,
PocketBaseManager pocketBases,
DialogProvider dialogProvider
) {
this.stateManager = stateManager;
this.settings = settings;
this.teleportPolicy = new TeleportPolicy(settings);
this.flightController = flightController;
this.pocketBases = pocketBases;
this.dialogProvider = dialogProvider;
}
@Override
public boolean onCommand(CommandSender sender, Command command, String label, String[] arguments) {
if (!(sender instanceof Player player)) {
sender.sendMessage("Only players have base progression.");
return true;
}
PlayerState state = stateManager.player(player.getUniqueId(), player.getName());
if (arguments.length == 0
|| arguments.length == 1 && arguments[0].equalsIgnoreCase("ui")) {
showDialog(player, state);
return true;
}
if (arguments.length == 1 && arguments[0].equalsIgnoreCase("status")) {
showStatus(player, state);
return true;
}
if (arguments.length == 1 && arguments[0].equalsIgnoreCase("upgrade")) {
return purchaseVisitorAccess(player, state);
}
if (arguments.length == 2 && arguments[0].equalsIgnoreCase("pocket")
&& arguments[1].equalsIgnoreCase("upgrade")) {
return purchasePocketUpgrade(player, state);
}
if (arguments.length == 3 && arguments[0].equalsIgnoreCase("pocket")
&& arguments[1].equalsIgnoreCase("flight")) {
return updatePocketFlight(player, arguments[2]);
}
if (arguments.length == 4 && arguments[0].equalsIgnoreCase("pocket")
&& arguments[1].equalsIgnoreCase("mobs")) {
return updatePocketMobSpawning(player, arguments[2], arguments[3]);
}
if (arguments.length == 4 && arguments[0].equalsIgnoreCase("pocket")
&& arguments[1].equalsIgnoreCase("type")) {
return purchasePocketBiome(player, arguments[2], arguments[3]);
}
if (arguments.length == 2 && arguments[0].equalsIgnoreCase("visitors")) {
return updateVisitors(player, state, arguments[1]);
}
if (arguments.length == 2 && arguments[0].equalsIgnoreCase("navigation")) {
return updateNavigation(player, state, arguments[1]);
}
if (arguments.length == 2 && arguments[0].equalsIgnoreCase("flight")) {
return updateFlight(player, state, arguments[1]);
}
if (arguments.length == 2 && arguments[0].equalsIgnoreCase("border")) {
return updateBorder(player, state, arguments[1]);
}
if (arguments.length == 2 && arguments[0].equalsIgnoreCase("spawnable")) {
return updateSpawnableOverlay(player, state, arguments[1]);
}
if (arguments.length == 2 && arguments[0].equalsIgnoreCase("bossbar")) {
return updateBossBar(player, arguments[1]);
}
sendUsage(player);
return true;
}
private boolean purchaseVisitorAccess(Player player, PlayerState state) {
if (!new VisitorPolicy().canPurchase(state)) {
player.sendMessage(ChatColor.RED + (state.baseLevel() >= 4
? "Base IV is already unlocked."
: "You must unlock Base III before purchasing Base IV."));
return true;
}
int price = settings.current().visitorUnlockDiamondCost();
Material currency = Material.valueOf(settings.current().visitorCurrencyMaterial());
PlayerInventory inventory = player.getInventory();
if (countCurrency(inventory, currency) < price) {
player.sendMessage(ChatColor.RED + "Base IV costs " + price + " "
+ currency.name().toLowerCase(java.util.Locale.ROOT) + ".");
return true;
}
ItemStack[] snapshot = cloneContents(inventory.getStorageContents());
removeCurrency(inventory, currency, price);
try {
stateManager.updateAndSave(
player.getUniqueId(),
player.getName(),
current -> current.withBaseLevel(4).withVisitorsEnabled(true)
);
} catch (IOException | RuntimeException exception) {
inventory.setStorageContents(snapshot);
player.sendMessage(ChatColor.RED
+ "The upgrade could not be saved; your diamonds were restored.");
return true;
}
player.sendTitle(
ChatColor.GOLD + "Base IV Unlocked",
ChatColor.YELLOW + "Visitors may now teleport to your base",
settings.current().titleFadeInTicks(),
settings.current().titleStayTicks(),
settings.current().titleFadeOutTicks()
);
player.sendMessage(ChatColor.GREEN + "Base IV unlocked for " + price + " "
+ currency.name().toLowerCase(java.util.Locale.ROOT) + ".");
return true;
}
private boolean purchasePocketUpgrade(Player player, PlayerState owner) {
if (pocketBases == null) {
player.sendMessage(ChatColor.RED + "Pocket Bases are currently unavailable.");
return true;
}
if (owner.baseLevel() < 4 || owner.base().isEmpty()) {
player.sendMessage(ChatColor.RED
+ "Establish Base IV before purchasing a Pocket Base.");
return true;
}
PocketBaseState current = pocketBases.state(owner.playerId());
int price = current.level() == 0
? settings.current().pocketBaseUnlockCost()
: settings.current().pocketBaseUpgradeCost();
Material currency = Material.valueOf(settings.current().pocketBaseCurrencyMaterial());
PlayerInventory inventory = player.getInventory();
if (countCurrency(inventory, currency) < price) {
player.sendMessage(ChatColor.RED + "Pocket Base "
+ (current.level() == 0 ? "I" : "level " + (current.level() + 1))
+ " costs " + price + " "
+ currency.name().toLowerCase(java.util.Locale.ROOT) + ".");
return true;
}
ItemStack[] snapshot = cloneContents(inventory.getStorageContents());
removeCurrency(inventory, currency, price);
final PocketBaseState upgraded;
try {
upgraded = pocketBases.upgrade(owner.playerId());
} catch (IOException | RuntimeException exception) {
inventory.setStorageContents(snapshot);
player.sendMessage(ChatColor.RED
+ "The Pocket Base upgrade failed; your payment was restored.");
return true;
}
player.sendTitle(
ChatColor.GOLD + "Pocket Base " + upgraded.level() + " Unlocked",
ChatColor.YELLOW + "Build a portal inside your base",
settings.current().titleFadeInTicks(),
settings.current().titleStayTicks(),
settings.current().titleFadeOutTicks()
);
player.sendMessage(ChatColor.GREEN + "Pocket Base level " + upgraded.level()
+ " unlocked for " + price + " "
+ currency.name().toLowerCase(java.util.Locale.ROOT) + ".");
return true;
}
private boolean purchasePocketBiome(
Player player,
String worldTypeName,
String biomeName
) {
if (pocketBases == null) {
player.sendMessage(ChatColor.RED + "Pocket Bases are currently unavailable.");
return true;
}
PocketBaseState current = pocketBases.state(player.getUniqueId());
if (current.level() < 1) {
player.sendMessage(ChatColor.RED + "Pocket Base I is still locked.");
return true;
}
PocketBaseBiome biome = PocketBaseWorldType.fromCommand(worldTypeName)
.flatMap(worldType -> PocketBaseBiome.fromCommand(worldType, biomeName))
.orElse(null);
if (biome == null) {
player.sendMessage(ChatColor.RED + "That Pocket Base type and subtype are invalid.");
return true;
}
if (biome == current.biome()) {
player.sendMessage(ChatColor.RED + "Your Pocket Base already uses "
+ biome.commandName() + ".");
return true;
}
int price = settings.current().pocketBaseBiomeChangeCost();
Material currency = Material.valueOf(
settings.current().pocketBaseBiomeCurrencyMaterial()
);
PlayerInventory inventory = player.getInventory();
if (countCurrency(inventory, currency) < price) {
player.sendMessage(ChatColor.RED + "Changing Pocket Base biome costs "
+ price + " " + currency.name().toLowerCase(Locale.ROOT) + ".");
return true;
}
ItemStack[] snapshot = cloneContents(inventory.getStorageContents());
removeCurrency(inventory, currency, price);
final PocketBaseState updated;
try {
updated = pocketBases.setBiome(player.getUniqueId(), biome);
} catch (IOException | RuntimeException exception) {
inventory.setStorageContents(snapshot);
player.sendMessage(ChatColor.RED
+ "The Pocket Base biome change failed; your payment was restored.");
return true;
}
player.sendMessage(ChatColor.GREEN + "Pocket Base biome changed to "
+ updated.biome().commandName() + " for " + price + " "
+ currency.name().toLowerCase(Locale.ROOT) + ".");
return true;
}
private boolean updatePocketFlight(Player player, String mode) {
if (pocketBases == null) {
player.sendMessage(ChatColor.RED + "Pocket Bases are currently unavailable.");
return true;
}
PocketBaseState current = pocketBases.state(player.getUniqueId());
if (!current.flightUnlocked()) {
player.sendMessage(ChatColor.RED
+ "Win a raid in your Pocket Base before enabling automatic flight.");
return true;
}
Boolean enabled = enabledMode(mode);
if (enabled == null) {
sendUsage(player);
return true;
}
try {
current = pocketBases.setFlightEnabled(player.getUniqueId(), enabled);
} catch (IOException | RuntimeException exception) {
player.sendMessage(ChatColor.RED
+ "The Pocket Base flight setting could not be changed.");
return true;
}
player.sendMessage(ChatColor.YELLOW + "Pocket Base automatic flight is now "
+ (current.flightEnabled() ? ChatColor.GREEN + "enabled" : ChatColor.RED + "disabled")
+ ChatColor.YELLOW + ".");
return true;
}
private boolean updatePocketMobSpawning(
Player player,
String category,
String mode
) {
if (pocketBases == null) {
player.sendMessage(ChatColor.RED + "Pocket Bases are currently unavailable.");
return true;
}
PocketBaseState current = pocketBases.state(player.getUniqueId());
if (current.level() < 1) {
player.sendMessage(ChatColor.RED + "Pocket Base I is still locked.");
return true;
}
Boolean enabled = enabledMode(mode);
if (enabled == null) {
sendUsage(player);
return true;
}
final PocketBaseState updated;
final boolean settingEnabled;
try {
if (category.equalsIgnoreCase("hostile")) {
updated = pocketBases.setHostileMobSpawning(
player.getUniqueId(), enabled
);
settingEnabled = updated.hostileMobSpawningEnabled();
} else if (category.equalsIgnoreCase("passive")) {
updated = pocketBases.setPassiveMobSpawning(
player.getUniqueId(), enabled
);
settingEnabled = updated.passiveMobSpawningEnabled();
} else {
sendUsage(player);
return true;
}
} catch (IOException | RuntimeException exception) {
player.sendMessage(ChatColor.RED
+ "The Pocket Base mob-spawning setting could not be changed.");
return true;
}
player.sendMessage(ChatColor.YELLOW + "Pocket Base "
+ category.toLowerCase(Locale.ROOT) + " mob spawning is now "
+ (settingEnabled ? ChatColor.GREEN + "enabled" : ChatColor.RED + "disabled")
+ ChatColor.YELLOW + ".");
return true;
}
private boolean updateVisitors(Player player, PlayerState state, String mode) {
if (state.baseLevel() < 4) {
player.sendMessage(ChatColor.RED + "Base IV visitor access is still locked.");
return true;
}
final boolean enabled;
if (mode.equalsIgnoreCase("allowed")) {
enabled = true;
} else if (mode.equalsIgnoreCase("blocked")) {
enabled = false;
} else {
sendUsage(player);
return true;
}
state = stateManager.update(
player.getUniqueId(),
player.getName(),
current -> current.withVisitorsEnabled(enabled)
);
stateManager.saveIfDirty();
player.sendMessage(ChatColor.YELLOW + "Visitor teleports are now "
+ (state.visitorsEnabled() ? ChatColor.GREEN + "allowed" : ChatColor.RED + "blocked")
+ ChatColor.YELLOW + ".");
return true;
}
private boolean updateNavigation(Player player, PlayerState state, String mode) {
if (state.baseLevel() < 2) {
player.sendMessage(ChatColor.RED + "Base II navigation is still locked.");
return true;
}
if (state.base().isEmpty()) {
player.sendMessage(ChatColor.RED + "Set your base before enabling navigation.");
return true;
}
Boolean enabled = enabledMode(mode);
if (enabled == null) {
sendUsage(player);
return true;
}
state = stateManager.update(
player.getUniqueId(),
player.getName(),
current -> current.withNavigationEnabled(enabled)
);
stateManager.saveIfDirty();
player.sendMessage(ChatColor.YELLOW + "Base navigation is now "
+ (state.navigationEnabled() ? ChatColor.GREEN + "enabled" : ChatColor.RED + "disabled")
+ ChatColor.YELLOW + ".");
if (state.navigationEnabled()
&& !player.getWorld().getUID().equals(state.base().orElseThrow().worldId())) {
player.sendMessage(ChatColor.RED + "Your base is in another world: "
+ state.base().orElseThrow().worldName() + ".");
}
return true;
}
private boolean updateFlight(Player player, PlayerState state, String mode) {
if (state.flightLevel() < 1) {
player.sendMessage(ChatColor.RED + "Base flight is still locked.");
return true;
}
Boolean enabled = enabledMode(mode);
if (enabled == null) {
sendUsage(player);
return true;
}
state = stateManager.update(
player.getUniqueId(),
player.getName(),
current -> current.withFlightEnabled(enabled)
);
if (!state.flightEnabled()) {
flightController.removeGrantedFlight(player);
}
stateManager.saveIfDirty();
player.sendMessage(ChatColor.YELLOW + "Base flight is now "
+ (state.flightEnabled() ? ChatColor.GREEN + "enabled" : ChatColor.RED + "disabled")
+ ChatColor.YELLOW + ".");
return true;
}
private boolean updateBorder(Player player, PlayerState state, String mode) {
if (state.baseLevel() < 1 || state.base().isEmpty()) {
player.sendMessage(ChatColor.RED
+ "Establish Base I before enabling border visualization.");
return true;
}
Boolean enabled = enabledMode(mode);
if (enabled == null) {
sendUsage(player);
return true;
}
state = stateManager.update(
player.getUniqueId(),
player.getName(),
current -> current.withBorderEnabled(enabled)
);
stateManager.saveIfDirty();
player.sendMessage(ChatColor.YELLOW + "Base border visualization is now "
+ (state.borderEnabled() ? ChatColor.GREEN + "enabled" : ChatColor.RED + "disabled")
+ ChatColor.YELLOW + ".");
return true;
}
private boolean updateSpawnableOverlay(Player player, PlayerState state, String mode) {
if (state.totalBlocksPlaced() < settings.current().spawnableOverlayUnlockPlacements()) {
player.sendMessage(ChatColor.RED + "Spawnable highlighting unlocks after "
+ settings.current().spawnableOverlayUnlockPlacements() + " total placements.");
return true;
}
if (state.baseLevel() < 1 || state.base().isEmpty()) {
player.sendMessage(ChatColor.RED
+ "Establish Base I before enabling spawnable highlighting.");
return true;
}
Boolean enabled = enabledMode(mode);
if (enabled == null) {
sendUsage(player);
return true;
}
state = stateManager.update(
player.getUniqueId(),
player.getName(),
current -> current.withSpawnableOverlayEnabled(enabled)
);
stateManager.saveIfDirty();
player.sendMessage(ChatColor.YELLOW + "Spawnable block highlighting is now "
+ (state.spawnableOverlayEnabled()
? ChatColor.GREEN + "enabled"
: ChatColor.RED + "disabled")
+ ChatColor.YELLOW + ".");
return true;
}
private boolean updateBossBar(Player player, String mode) {
Boolean enabled = enabledMode(mode);
if (enabled == null) {
sendUsage(player);
return true;
}
PlayerState state = stateManager.update(
player.getUniqueId(),
player.getName(),
current -> current.withBossBarEnabled(enabled)
);
stateManager.saveIfDirty();
player.sendMessage(ChatColor.YELLOW + "Automatic progress boss bars are now "
+ (state.bossBarEnabled() ? ChatColor.GREEN + "enabled" : ChatColor.RED + "disabled")
+ ChatColor.YELLOW + ".");
return true;
}
private void showDialog(Player player, PlayerState state) {
PocketBaseState pocket = pocketBases == null
? PocketBaseState.locked(state.playerId())
: pocketBases.state(state.playerId());
player.showDialog(dialogProvider.create(state, pocket));
}
private void showStatus(Player player, PlayerState state) {
player.sendMessage(ChatColor.GOLD + "=== Base Progress ===");
showBasePath(player, state);
showSizePath(player, state);
showFlightPath(player, state);
showWarmupPath(player, state);
showCooldownPath(player, state);
showPocketPath(player, state);
int spawnableThreshold = settings.current().spawnableOverlayUnlockPlacements();
player.sendMessage(ChatColor.YELLOW + "Spawnable Overlay: " + ChatColor.GRAY
+ state.totalBlocksPlaced() + "/" + spawnableThreshold + " placements; "
+ (state.totalBlocksPlaced() >= spawnableThreshold ? "unlocked" : "locked")
+ "; setting=" + (state.spawnableOverlayEnabled() ? "enabled" : "disabled"));
player.sendMessage(ChatColor.GRAY + "Settings: visitors="
+ (state.visitorsEnabled() ? "allowed" : "blocked")
+ " navigation=" + (state.navigationEnabled() ? "enabled" : "disabled")
+ " flight=" + (state.flightEnabled() ? "enabled" : "disabled")
+ " border=" + (state.borderEnabled() ? "enabled" : "disabled")
+ " spawnable=" + (state.spawnableOverlayEnabled() ? "enabled" : "disabled")
+ " bossbar=" + (state.bossBarEnabled() ? "enabled" : "disabled"));
state.base().ifPresentOrElse(
base -> player.sendMessage(ChatColor.GRAY + "Base: " + base.worldName() + " "
+ base.x() + ", " + base.y() + ", " + base.z()),
() -> player.sendMessage(ChatColor.GRAY + "Base: not set")
);
}
private void showBasePath(Player player, PlayerState state) {
String detail = switch (state.baseLevel()) {
case 0 -> state.grassAndDirtBroken() + "/" + settings.current().baseUnlockBlocks()
+ " grass or dirt → /setbase";
case 1 -> state.grassAndDirtBroken() + "/" + settings.current().navigationUnlockBlocks()
+ " grass or dirt → navigation";
case 2 -> state.blocksPlacedInBase() + "/" + settings.current().teleportUnlockPlacements()
+ " placements → /base";
case 3 -> settings.current().visitorUnlockDiamondCost()
+ " diamonds → /basesettings upgrade";
case 4 -> "complete; visitor access unlocked";
default -> "invalid";
};
player.sendMessage(ChatColor.YELLOW + "Base " + state.baseLevel() + "/4: "
+ ChatColor.GRAY + detail);
}
private void showSizePath(Player player, PlayerState state) {
String detail = switch (state.sizeLevel()) {
case 0 -> state.stoneBroken() + "/" + settings.current().stoneExpansionBlocks() + " stone";
case 1 -> state.deepslateBroken() + "/" + settings.current().deepslateExpansionBlocks() + " deepslate";
case 2 -> state.obsidianBroken() + "/" + settings.current().obsidianExpansionBlocks() + " obsidian";
case 3 -> "complete; 150-block radius by default";
default -> "invalid";
};
player.sendMessage(colorForPrerequisite(state.baseLevel() >= 1) + "Base Size "
+ state.sizeLevel() + "/3: " + ChatColor.GRAY + detail);
}
private void showFlightPath(Player player, PlayerState state) {
String detail = state.flightLevel() >= 3
? "complete; world build height"
: (state.flightLevel() + 1) + " simultaneous elytra required";
player.sendMessage(colorForPrerequisite(state.baseLevel() >= 1) + "Base Flight "
+ state.flightLevel() + "/3: " + ChatColor.GRAY + detail
+ "; setting=" + (state.flightEnabled() ? "enabled" : "disabled"));
}
private void showWarmupPath(Player player, PlayerState state) {
String detail = switch (state.warmupLevel()) {
case 0 -> state.blocksPlacedInBase() + "/" + settings.current().secondWarmupPlacements();
case 1 -> state.blocksPlacedInBase() + "/" + settings.current().thirdWarmupPlacements();
case 2 -> state.blocksPlacedInBase() + "/" + settings.current().instantWarmupPlacements();
case 3 -> "complete";
default -> "invalid";
};
player.sendMessage(colorForPrerequisite(state.baseLevel() >= 3) + "Teleport Warm-up "
+ state.warmupLevel() + "/3: " + ChatColor.GRAY
+ DurationFormatter.friendly(teleportPolicy.warmup(state)) + "; " + detail);
}
private void showPocketPath(Player player, PlayerState owner) {
PocketBaseState pocket = pocketBases == null
? PocketBaseState.locked(owner.playerId())
: pocketBases.state(owner.playerId());
int price = pocket.level() == 0
? settings.current().pocketBaseUnlockCost()
: settings.current().pocketBaseUpgradeCost();
player.sendMessage(colorForPrerequisite(owner.baseLevel() >= 4 && owner.base().isPresent())
+ "Pocket Base " + pocket.level() + ": " + ChatColor.GRAY + price + " "
+ settings.current().pocketBaseCurrencyMaterial().toLowerCase(Locale.ROOT)
+ " → /basesettings pocket upgrade; type="
+ pocket.biome().worldType().commandName() + "/" + pocket.biome().commandName()
+ "; hostile spawning="
+ (pocket.hostileMobSpawningEnabled() ? "enabled" : "disabled")
+ "; passive spawning="
+ (pocket.passiveMobSpawningEnabled() ? "enabled" : "disabled")
+ "; flight="
+ (pocket.flightUnlocked()
? "automatic " + (pocket.flightEnabled() ? "enabled" : "disabled")
: "complete a raid to unlock"));
}
private void showCooldownPath(Player player, PlayerState state) {
String detail = switch (state.cooldownLevel()) {
case 0 -> state.blocksBrokenInBase() + "/" + settings.current().firstCooldownBreaks();
case 1 -> state.blocksBrokenInBase() + "/" + settings.current().secondCooldownBreaks();
case 2 -> state.blocksBrokenInBase() + "/" + settings.current().thirdCooldownBreaks();
case 3 -> state.blocksBrokenInBase() + "/" + settings.current().instantCooldownBreaks();
case 4 -> "complete";
default -> "invalid";
};
player.sendMessage(colorForPrerequisite(state.baseLevel() >= 3) + "Teleport Cooldown "
+ state.cooldownLevel() + "/4: " + ChatColor.GRAY
+ DurationFormatter.friendly(teleportPolicy.cooldown(state)) + "; " + detail);
}
@Override
public List<String> onTabComplete(
CommandSender sender,
Command command,
String alias,
String[] arguments
) {
if (arguments.length == 1) {
String prefix = arguments[0].toLowerCase(Locale.ROOT);
return SETTINGS.stream().filter(setting -> setting.startsWith(prefix)).toList();
}
if (arguments.length == 2) {
List<String> modes = arguments[0].equalsIgnoreCase("visitors")
? VISITOR_MODES
: switch (arguments[0].toLowerCase(Locale.ROOT)) {
case "pocket" -> List.of("upgrade", "flight", "mobs", "type");
case "navigation", "flight", "border", "spawnable", "bossbar" -> ENABLE_MODES;
default -> List.of();
};
String prefix = arguments[1].toLowerCase(Locale.ROOT);
return modes.stream().filter(mode -> mode.startsWith(prefix)).toList();
}
if (arguments.length == 3 && arguments[0].equalsIgnoreCase("pocket")) {
String prefix = arguments[2].toLowerCase(Locale.ROOT);
if (arguments[1].equalsIgnoreCase("flight")) {
return ENABLE_MODES.stream()
.filter(mode -> mode.startsWith(prefix))
.toList();
}
if (arguments[1].equalsIgnoreCase("mobs")) {
return List.of("hostile", "passive").stream()
.filter(category -> category.startsWith(prefix))
.toList();
}
if (arguments[1].equalsIgnoreCase("type")) {
return POCKET_WORLD_TYPES.stream()
.filter(type -> type.startsWith(prefix))
.toList();
}
}
if (arguments.length == 4 && arguments[0].equalsIgnoreCase("pocket")) {
String prefix = arguments[3].toLowerCase(Locale.ROOT);
if (arguments[1].equalsIgnoreCase("mobs")
&& (arguments[2].equalsIgnoreCase("hostile")
|| arguments[2].equalsIgnoreCase("passive"))) {
return ENABLE_MODES.stream()
.filter(mode -> mode.startsWith(prefix))
.toList();
}
if (arguments[1].equalsIgnoreCase("type")) {
return PocketBaseWorldType.fromCommand(arguments[2])
.map(PocketBaseBiome::commandNames)
.orElse(List.of())
.stream()
.filter(biome -> biome.startsWith(prefix))
.toList();
}
}
return List.of();
}
private static int countCurrency(PlayerInventory inventory, Material currency) {
int count = 0;
for (ItemStack item : inventory.getStorageContents()) {
if (item != null && item.getType() == currency) {
count += item.getAmount();
}
}
return count;
}
private static void removeCurrency(PlayerInventory inventory, Material currency, int amount) {
ItemStack[] contents = inventory.getStorageContents();
int remaining = amount;
for (int index = 0; index < contents.length && remaining > 0; index++) {
ItemStack item = contents[index];
if (item == null || item.getType() != currency) {
continue;
}
int removed = Math.min(remaining, item.getAmount());
remaining -= removed;
int newAmount = item.getAmount() - removed;
if (newAmount == 0) {
contents[index] = null;
} else {
ItemStack reduced = item.clone();
reduced.setAmount(newAmount);
contents[index] = reduced;
}
}
inventory.setStorageContents(contents);
}
private static ItemStack[] cloneContents(ItemStack[] contents) {
ItemStack[] copy = new ItemStack[contents.length];
for (int index = 0; index < contents.length; index++) {
copy[index] = contents[index] == null ? null : contents[index].clone();
}
return copy;
}
private static Boolean enabledMode(String mode) {
if (mode.equalsIgnoreCase("enable")) {
return true;
}
if (mode.equalsIgnoreCase("disable")) {
return false;
}
return null;
}
private static ChatColor colorForPrerequisite(boolean met) {
return met ? ChatColor.YELLOW : ChatColor.RED;
}
@FunctionalInterface
interface DialogProvider {
Dialog create(PlayerState owner, PocketBaseState pocket);
}
private static void sendUsage(Player player) {
player.sendMessage(ChatColor.RED + "Usage: /basesettings "
+ "[ui|status|upgrade|pocket upgrade|pocket flight <enable|disable>|pocket mobs "
+ "<hostile|passive> <enable|disable>"
+ "|pocket type <void|nether|overworld> <subtype>"
+ "|visitors <allowed|blocked>|navigation <enable|disable>"
+ "|flight <enable|disable>|border <enable|disable>"
+ "|spawnable <enable|disable>|bossbar <enable|disable>]");
}
}
@@ -0,0 +1,482 @@
package games.dmg.spigotbase;
import io.papermc.paper.dialog.Dialog;
import io.papermc.paper.registry.RegistryKey;
import io.papermc.paper.registry.data.dialog.ActionButton;
import io.papermc.paper.registry.data.dialog.DialogBase;
import io.papermc.paper.registry.data.dialog.action.DialogAction;
import io.papermc.paper.registry.data.dialog.body.DialogBody;
import io.papermc.paper.registry.data.dialog.type.DialogType;
import io.papermc.paper.registry.set.RegistrySet;
import java.util.Arrays;
import java.util.List;
import java.util.Locale;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.event.ClickEvent;
final class BaseSettingsDialogFactory {
private static final int DIALOG_WIDTH = 420;
private final PluginSettings settings;
BaseSettingsDialogFactory(PluginSettings settings) {
this.settings = settings;
}
Dialog create(PlayerState owner, PocketBaseState pocket) {
return render(specification(owner, pocket));
}
DialogSpec specification(PlayerState owner, PocketBaseState pocket) {
List<DialogSpec> dialogs = List.of(
generalSettings(owner),
progressReport(),
baseUpgrade(owner),
pocketSettings(owner, pocket)
);
return new ListSpec(
content(
"Base Settings",
"Base Settings",
"Base " + owner.baseLevel() + "/4 • Size " + owner.sizeLevel() + "/3"
+ " • Flight " + owner.flightLevel() + "/3\n"
+ "Pocket Base " + pocket.level() + ""
+ humanize(pocket.biome().commandName())
),
dialogs,
2,
190
);
}
private DialogSpec generalSettings(PlayerState owner) {
List<ButtonSpec> actions = List.of(
toggle(
"Visitors",
owner.baseLevel() >= 4,
owner.visitorsEnabled(),
"Allowed",
"Blocked",
"basesettings visitors "
+ (owner.visitorsEnabled() ? "blocked" : "allowed")
),
toggle(
"Navigation",
owner.baseLevel() >= 2,
owner.navigationEnabled(),
"Enabled",
"Disabled",
"basesettings navigation " + mode(!owner.navigationEnabled())
),
toggle(
"Flight",
owner.flightLevel() >= 1,
owner.flightEnabled(),
"Enabled",
"Disabled",
"basesettings flight " + mode(!owner.flightEnabled())
),
toggle(
"Border",
owner.baseLevel() >= 1 && owner.base().isPresent(),
owner.borderEnabled(),
"Enabled",
"Disabled",
"basesettings border " + mode(!owner.borderEnabled())
),
toggle(
"Spawnable Overlay",
owner.baseLevel() >= 1
&& owner.base().isPresent()
&& owner.totalBlocksPlaced()
>= settings.spawnableOverlayUnlockPlacements(),
owner.spawnableOverlayEnabled(),
"Enabled",
"Disabled",
"basesettings spawnable " + mode(!owner.spawnableOverlayEnabled())
),
toggle(
"Progress Boss Bar",
true,
owner.bossBarEnabled(),
"Enabled",
"Disabled",
"basesettings bossbar " + mode(!owner.bossBarEnabled())
)
);
return new MultiSpec(
content(
"Base Controls",
"Base Controls",
"Select a setting to change it. Locked controls remain visible and explain "
+ "their requirement in chat when selected."
),
actions,
2
);
}
private DialogSpec progressReport() {
return new NoticeSpec(
content(
"Progress Report",
"Progress Report",
"Display the detailed progression report in chat."
),
commandButton("Show in Chat", "basesettings status")
);
}
private DialogSpec baseUpgrade(PlayerState owner) {
if (owner.baseLevel() >= 4) {
return notice("Base IV", "Base IV and visitor access are already unlocked.");
}
if (owner.baseLevel() < 3 || owner.base().isEmpty()) {
return notice(
"Base IV — Locked",
"Unlock and establish Base III before purchasing Base IV visitor access."
);
}
return confirmation(
"Unlock Base IV",
"Purchase visitor access for " + settings.visitorUnlockDiamondCost()
+ " diamonds?",
"basesettings upgrade"
);
}
private DialogSpec pocketSettings(PlayerState owner, PocketBaseState pocket) {
List<DialogSpec> dialogs = List.of(
pocketUpgrade(owner, pocket),
pocketFlightSettings(pocket),
pocketMobSettings(pocket),
pocketBiomeSettings(pocket)
);
return new ListSpec(
content(
"Pocket Base",
"Pocket Base " + pocket.level(),
pocket.level() == 0
? "Pocket Base is locked."
: "Biome: " + pocket.biome().worldType().commandName() + "/"
+ pocket.biome().commandName() + "\nHostile mobs: "
+ enabled(pocket.hostileMobSpawningEnabled()) + " • Passive mobs: "
+ enabled(pocket.passiveMobSpawningEnabled()) + "\nFlight: "
+ (pocket.flightUnlocked()
? (pocket.flightEnabled() ? "Enabled" : "Disabled")
: "Complete a raid to unlock")
),
dialogs,
1,
260
);
}
private DialogSpec pocketUpgrade(PlayerState owner, PocketBaseState pocket) {
if (owner.baseLevel() < 4 || owner.base().isEmpty()) {
return notice(
"Pocket Upgrade — Locked",
"Establish Base IV before purchasing a Pocket Base."
);
}
int price = pocket.level() == 0
? settings.pocketBaseUnlockCost()
: settings.pocketBaseUpgradeCost();
String currency = humanize(settings.pocketBaseCurrencyMaterial());
String title = pocket.level() == 0
? "Unlock Pocket Base I"
: "Upgrade to Pocket Base " + (pocket.level() + 1);
return confirmation(
title,
"Purchase this upgrade for " + price + " " + currency + "?",
"basesettings pocket upgrade"
);
}
private DialogSpec pocketFlightSettings(PocketBaseState pocket) {
if (!pocket.flightUnlocked()) {
return notice(
"Pocket Flight — Locked",
"Win a raid in your Pocket Base to unlock automatic flight."
);
}
return new MultiSpec(
content(
"Pocket Flight",
"Pocket Flight",
"Control automatic owner flight. Flight granted by the gold portal "
+ "is unaffected."
),
List.of(toggle(
"Automatic Flight",
true,
pocket.flightEnabled(),
"Enabled",
"Disabled",
"basesettings pocket flight " + mode(!pocket.flightEnabled())
)),
1
);
}
private DialogSpec pocketMobSettings(PocketBaseState pocket) {
if (pocket.level() < 1) {
return notice(
"Mob Spawning — Locked",
"Unlock Pocket Base I to control natural mob spawning."
);
}
List<ButtonSpec> actions = List.of(
toggle(
"Hostile Mobs",
true,
pocket.hostileMobSpawningEnabled(),
"Enabled",
"Disabled",
"basesettings pocket mobs hostile "
+ mode(!pocket.hostileMobSpawningEnabled())
),
toggle(
"Passive Mobs",
true,
pocket.passiveMobSpawningEnabled(),
"Enabled",
"Disabled",
"basesettings pocket mobs passive "
+ mode(!pocket.passiveMobSpawningEnabled())
)
);
return new MultiSpec(
content(
"Mob Spawning",
"Mob Spawning",
"Control natural spawning independently for each mob category."
),
actions,
1
);
}
private DialogSpec pocketBiomeSettings(PocketBaseState pocket) {
if (pocket.level() < 1) {
return notice(
"Change Biome — Locked",
"Unlock Pocket Base I to purchase biome changes."
);
}
List<DialogSpec> worldTypes = Arrays.stream(PocketBaseWorldType.values())
.map(type -> pocketBiomeType(pocket, type))
.toList();
return new ListSpec(
content(
"Change Biome",
"Change Biome",
"Current biome: " + pocket.biome().worldType().commandName() + "/"
+ pocket.biome().commandName() + "\nEach change costs "
+ settings.pocketBaseBiomeChangeCost() + " "
+ humanize(settings.pocketBaseBiomeCurrencyMaterial()) + "."
),
worldTypes,
1,
220
);
}
private DialogSpec pocketBiomeType(
PocketBaseState pocket,
PocketBaseWorldType worldType
) {
List<DialogSpec> biomes = Arrays.stream(PocketBaseBiome.values())
.filter(biome -> biome.worldType() == worldType)
.map(biome -> biomeConfirmation(pocket, biome))
.toList();
return new ListSpec(
content(
humanize(worldType.commandName()),
humanize(worldType.commandName()) + " Biomes",
"Choose a biome subtype. The active biome is marked as selected."
),
biomes,
2,
190
);
}
private DialogSpec biomeConfirmation(PocketBaseState pocket, PocketBaseBiome biome) {
String title = humanize(biome.commandName())
+ (pocket.biome() == biome ? " — Selected" : "");
if (pocket.biome() == biome) {
return notice(title, "This is your Pocket Base's current biome.");
}
return confirmation(
title,
"Change the Pocket Base biome for " + settings.pocketBaseBiomeChangeCost()
+ " " + humanize(settings.pocketBaseBiomeCurrencyMaterial()) + "?",
"basesettings pocket type " + biome.worldType().commandName() + " "
+ biome.commandName()
);
}
private static DialogSpec confirmation(String title, String message, String command) {
return new ConfirmationSpec(
content(title, title, message),
commandButton("Confirm", command)
);
}
private static DialogSpec notice(String title, String message) {
return new NoticeSpec(content(title, title, message), null);
}
private static ButtonSpec toggle(
String name,
boolean unlocked,
boolean active,
String activeLabel,
String inactiveLabel,
String command
) {
String state = unlocked ? (active ? activeLabel : inactiveLabel) : "Locked";
String tooltip = unlocked
? "Click to change this setting."
: "This setting has not been unlocked yet.";
return new ButtonSpec(name + ": " + state, tooltip, 190, "/" + command);
}
private static ButtonSpec commandButton(String label, String command) {
return new ButtonSpec(label, "", 150, "/" + command);
}
private static DialogContent content(
String title,
String externalTitle,
String message
) {
return new DialogContent(title, externalTitle, message);
}
private static Dialog render(DialogSpec specification) {
return render(specification, true);
}
private static Dialog render(DialogSpec specification, boolean root) {
DialogType type;
ActionButton back = root ? null : backButton();
if (specification instanceof ListSpec list) {
List<Dialog> dialogs = list.dialogs().stream()
.map(child -> render(child, false))
.toList();
type = DialogType.dialogList(
RegistrySet.valueSet(RegistryKey.DIALOG, dialogs),
back,
list.columns(),
list.buttonWidth()
);
} else if (specification instanceof MultiSpec multi) {
List<ActionButton> actions = multi.actions().stream()
.map(BaseSettingsDialogFactory::render)
.toList();
type = DialogType.multiAction(actions, back, multi.columns());
} else if (specification instanceof ConfirmationSpec confirmation) {
type = DialogType.confirmation(
render(confirmation.confirm()),
root ? ActionButton.builder(Component.text("Cancel")).build() : back
);
} else if (specification instanceof NoticeSpec notice) {
type = notice.action() == null
? (root ? DialogType.notice() : DialogType.notice(back))
: DialogType.notice(render(notice.action()));
} else {
throw new IllegalArgumentException("Unsupported dialog specification");
}
DialogContent content = specification.content();
DialogBase base = DialogBase.builder(Component.text(content.title()))
.externalTitle(Component.text(content.externalTitle()))
.body(List.of(DialogBody.plainMessage(
Component.text(content.message()), DIALOG_WIDTH
)))
.canCloseWithEscape(true)
.pause(false)
.afterAction(DialogBase.DialogAfterAction.CLOSE)
.build();
return Dialog.create(factory -> factory.empty().base(base).type(type));
}
private static ActionButton backButton() {
return render(backButtonSpec());
}
static ButtonSpec backButtonSpec() {
return commandButton("Back", "basesettings ui");
}
private static ActionButton render(ButtonSpec button) {
ActionButton.Builder builder = ActionButton.builder(Component.text(button.label()))
.width(button.width())
.action(DialogAction.staticAction(commandClick(button.command())));
if (!button.tooltip().isEmpty()) {
builder.tooltip(Component.text(button.tooltip()));
}
return builder.build();
}
static ClickEvent<ClickEvent.Payload.Text> commandClick(String command) {
return ClickEvent.runCommand(command);
}
sealed interface DialogSpec permits ListSpec, MultiSpec, ConfirmationSpec, NoticeSpec {
DialogContent content();
}
record DialogContent(String title, String externalTitle, String message) {
}
record ListSpec(
DialogContent content,
List<DialogSpec> dialogs,
int columns,
int buttonWidth
) implements DialogSpec {
}
record MultiSpec(
DialogContent content,
List<ButtonSpec> actions,
int columns
) implements DialogSpec {
}
record ConfirmationSpec(
DialogContent content,
ButtonSpec confirm
) implements DialogSpec {
}
record NoticeSpec(
DialogContent content,
ButtonSpec action
) implements DialogSpec {
}
record ButtonSpec(String label, String tooltip, int width, String command) {
}
private static String mode(boolean active) {
return active ? "enable" : "disable";
}
private static String enabled(boolean active) {
return active ? "enabled" : "disabled";
}
private static String humanize(String value) {
String normalized = value.toLowerCase(Locale.ROOT).replace('_', ' ');
StringBuilder result = new StringBuilder(normalized.length());
boolean capitalize = true;
for (int index = 0; index < normalized.length(); index++) {
char character = normalized.charAt(index);
result.append(capitalize ? Character.toUpperCase(character) : character);
capitalize = character == ' ';
}
return result.toString();
}
}
@@ -34,6 +34,7 @@ final class BaseTeleportManager implements Listener {
private final VisitorPolicy visitorPolicy;
private final SafeBaseDestination destinationFinder;
private final Clock clock;
private final TeleportParticles particles;
private final Map<UUID, Request> requests = new HashMap<>();
BaseTeleportManager(
@@ -44,6 +45,20 @@ final class BaseTeleportManager implements Listener {
SafeBaseDestination destinationFinder,
Clock clock
) {
this(plugin, stateManager, policy, visitorPolicy, destinationFinder, clock,
new TeleportParticles(plugin));
}
BaseTeleportManager(
Plugin plugin,
BaseStateManager stateManager,
TeleportPolicy policy,
VisitorPolicy visitorPolicy,
SafeBaseDestination destinationFinder,
Clock clock,
TeleportParticles particles
) {
this.particles = particles;
this.plugin = plugin;
this.stateManager = stateManager;
this.policy = policy;
@@ -83,6 +98,13 @@ final class BaseTeleportManager implements Listener {
visitor.sendMessage(ChatColor.RED + "That base is not accepting visitors.");
return;
}
BaseLocation destination = owner.base().orElseThrow();
if (!visitor.getWorld().getUID().equals(destination.worldId())) {
visitor.sendMessage(
ChatColor.RED + "You must be in the same dimension as that base to visit it."
);
return;
}
PlayerState visitorState = stateManager.player(visitor.getUniqueId(), visitor.getName());
Optional<Duration> remaining = visitorPolicy.remaining(
visitorState, owner.playerId(), clock.instant()
@@ -94,8 +116,8 @@ final class BaseTeleportManager implements Listener {
}
begin(
visitor,
owner.base().orElseThrow(),
policy.warmup(owner),
destination,
policy.visitorWarmup(owner),
owner.playerId(),
policy.cooldown(owner),
"visit " + owner.latestName() + "'s base"
@@ -131,6 +153,7 @@ final class BaseTeleportManager implements Listener {
return;
}
requests.put(player.getUniqueId(), request);
particles.start(player.getUniqueId(), origin, seconds);
request.task = Bukkit.getScheduler().runTaskTimer(plugin, () -> tick(player, request), 0L, 20L);
player.sendMessage(ChatColor.YELLOW + "Stand still for " + seconds + " seconds to " + purpose + ".");
}
@@ -140,6 +163,7 @@ final class BaseTeleportManager implements Listener {
request.cancelTask();
}
requests.clear();
particles.clear();
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
@@ -189,7 +213,7 @@ final class BaseTeleportManager implements Listener {
public void onCommand(PlayerCommandPreprocessEvent event) {
String command = event.getMessage().toLowerCase(Locale.ROOT).split("\\s+", 2)[0];
if (command.equals("/base") || command.equals("/gotobase")
|| command.equals("/spawn") || command.equals("/home")
|| command.equals("/visit") || command.equals("/spawn") || command.equals("/home")
|| command.equals("/tp") || command.equals("/teleport")) {
cancel(event.getPlayer(), "Base teleport cancelled by another teleport command.");
}
@@ -197,6 +221,9 @@ final class BaseTeleportManager implements Listener {
private void tick(Player player, Request request) {
if (!player.isOnline() || requests.get(player.getUniqueId()) != request) {
if (requests.remove(player.getUniqueId(), request)) {
particles.cancel(player.getUniqueId());
}
request.cancelTask();
return;
}
@@ -218,18 +245,22 @@ final class BaseTeleportManager implements Listener {
BaseLocation base = request.destination;
World world = Bukkit.getWorld(base.worldId());
if (world == null) {
particles.cancel(player.getUniqueId());
player.sendMessage(ChatColor.RED + "The destination world is not currently available.");
return;
}
Optional<Location> destination = destinationFinder.find(world, base);
if (destination.isEmpty()) {
particles.cancel(player.getUniqueId());
player.sendMessage(ChatColor.RED + "No safe location could be found at the base.");
return;
}
if (!player.teleport(destination.orElseThrow(), PlayerTeleportEvent.TeleportCause.PLUGIN)) {
particles.cancel(player.getUniqueId());
player.sendMessage(ChatColor.RED + "The base teleport was prevented.");
return;
}
particles.complete(player.getUniqueId());
Instant completedAt = clock.instant();
stateManager.update(
player.getUniqueId(),
@@ -253,6 +284,7 @@ final class BaseTeleportManager implements Listener {
return;
}
request.cancelTask();
particles.cancel(player.getUniqueId());
if (message != null) {
player.sendMessage(ChatColor.RED + message);
}
@@ -1,38 +0,0 @@
package games.dmg.spigotbase;
import org.bukkit.ChatColor;
import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
final class BaseVisitorsCommand implements CommandExecutor {
private final BaseStateManager stateManager;
BaseVisitorsCommand(BaseStateManager stateManager) {
this.stateManager = stateManager;
}
@Override
public boolean onCommand(CommandSender sender, Command command, String label, String[] arguments) {
if (!(sender instanceof Player player)) {
sender.sendMessage("Only players can manage base visitors.");
return true;
}
PlayerState state = stateManager.player(player.getUniqueId(), player.getName());
if (state.baseLevel() < 4) {
player.sendMessage(ChatColor.RED + "Base IV visitor access is still locked.");
return true;
}
state = stateManager.update(
player.getUniqueId(),
player.getName(),
current -> current.withVisitorsEnabled(!current.visitorsEnabled())
);
stateManager.saveIfDirty();
player.sendMessage(ChatColor.YELLOW + "Visitor teleports are now "
+ (state.visitorsEnabled() ? ChatColor.GREEN + "on" : ChatColor.RED + "off")
+ ChatColor.YELLOW + ".");
return true;
}
}
@@ -0,0 +1,4 @@
package games.dmg.spigotbase;
public record BlockPosition(int x, int y, int z) {
}
@@ -0,0 +1,26 @@
package games.dmg.spigotbase;
import java.util.Objects;
import java.util.UUID;
public record KeystoneReturnLocation(
UUID worldId,
String worldName,
double x,
double y,
double z,
float yaw,
float pitch
) {
public KeystoneReturnLocation {
Objects.requireNonNull(worldId, "worldId");
Objects.requireNonNull(worldName, "worldName");
if (worldName.isBlank()) {
throw new IllegalArgumentException("worldName must not be blank");
}
if (!Double.isFinite(x) || !Double.isFinite(y) || !Double.isFinite(z)
|| !Float.isFinite(yaw) || !Float.isFinite(pitch)) {
throw new IllegalArgumentException("return coordinates must be finite");
}
}
}
@@ -0,0 +1,35 @@
package games.dmg.spigotbase;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
final class KeystoneReturnStore {
private final YamlKeystoneReturnRepository repository;
private final Map<UUID, KeystoneReturnLocation> destinations;
KeystoneReturnStore(YamlKeystoneReturnRepository repository) throws IOException {
this.repository = repository;
this.destinations = new HashMap<>(repository.load());
}
Optional<KeystoneReturnLocation> destination(UUID playerId) {
return Optional.ofNullable(destinations.get(playerId));
}
void record(UUID playerId, KeystoneReturnLocation destination) throws IOException {
KeystoneReturnLocation previous = destinations.put(playerId, destination);
try {
repository.save(destinations);
} catch (IOException exception) {
if (previous == null) {
destinations.remove(playerId);
} else {
destinations.put(playerId, previous);
}
throw exception;
}
}
}
@@ -26,7 +26,10 @@ public record PlayerState(
boolean visitorsEnabled,
Optional<Instant> lastBaseSet,
Optional<Instant> lastBaseTeleport,
Map<UUID, Instant> visitorCooldownUntil
Map<UUID, Instant> visitorCooldownUntil,
boolean borderEnabled,
long totalBlocksPlaced,
boolean spawnableOverlayEnabled
) {
public PlayerState {
if (playerId == null) {
@@ -53,6 +56,7 @@ public record PlayerState(
requireNonNegative(obsidianBroken, "obsidian broken");
requireNonNegative(blocksPlacedInBase, "blocks placed in base");
requireNonNegative(blocksBrokenInBase, "blocks broken in base");
requireNonNegative(totalBlocksPlaced, "total blocks placed");
if (baseLevel == 0 && (sizeLevel > 0 || flightLevel > 0)) {
throw new IllegalArgumentException("secondary progression requires Base I");
@@ -69,12 +73,83 @@ public record PlayerState(
if (baseLevel < 4 && visitorsEnabled) {
throw new IllegalArgumentException("visitor access requires Base IV");
}
if (borderEnabled && (baseLevel < 1 || base.isEmpty())) {
throw new IllegalArgumentException("border visualization requires an established Base I");
}
if (spawnableOverlayEnabled && (baseLevel < 1 || base.isEmpty())) {
throw new IllegalArgumentException("spawnable overlay requires an established Base I");
}
if (visitorCooldownUntil.entrySet().stream().anyMatch(entry ->
entry.getKey() == null || entry.getValue() == null)) {
throw new IllegalArgumentException("visitor cooldowns must be complete");
}
}
public PlayerState(
UUID playerId,
String latestName,
Optional<BaseLocation> base,
int baseLevel,
int sizeLevel,
int flightLevel,
int warmupLevel,
int cooldownLevel,
long grassAndDirtBroken,
long stoneBroken,
long deepslateBroken,
long obsidianBroken,
long blocksPlacedInBase,
long blocksBrokenInBase,
boolean navigationEnabled,
boolean flightEnabled,
boolean bossBarEnabled,
boolean visitorsEnabled,
Optional<Instant> lastBaseSet,
Optional<Instant> lastBaseTeleport,
Map<UUID, Instant> visitorCooldownUntil
) {
this(
playerId, latestName, base, baseLevel, sizeLevel, flightLevel,
warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled,
lastBaseSet, lastBaseTeleport, visitorCooldownUntil, false, 0, false
);
}
public PlayerState(
UUID playerId,
String latestName,
Optional<BaseLocation> base,
int baseLevel,
int sizeLevel,
int flightLevel,
int warmupLevel,
int cooldownLevel,
long grassAndDirtBroken,
long stoneBroken,
long deepslateBroken,
long obsidianBroken,
long blocksPlacedInBase,
long blocksBrokenInBase,
boolean navigationEnabled,
boolean flightEnabled,
boolean bossBarEnabled,
boolean visitorsEnabled,
Optional<Instant> lastBaseSet,
Optional<Instant> lastBaseTeleport,
Map<UUID, Instant> visitorCooldownUntil,
boolean borderEnabled
) {
this(
playerId, latestName, base, baseLevel, sizeLevel, flightLevel,
warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled,
lastBaseSet, lastBaseTeleport, visitorCooldownUntil, borderEnabled, 0, false
);
}
public static PlayerState newPlayer(UUID playerId, String latestName) {
return new PlayerState(
playerId,
@@ -93,7 +168,8 @@ public record PlayerState(
warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled,
lastBaseSet, lastBaseTeleport, visitorCooldownUntil
lastBaseSet, lastBaseTeleport, visitorCooldownUntil, borderEnabled,
totalBlocksPlaced, spawnableOverlayEnabled
);
}
@@ -103,7 +179,8 @@ public record PlayerState(
warmupLevel, cooldownLevel, count, stoneBroken, deepslateBroken,
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled,
lastBaseSet, lastBaseTeleport, visitorCooldownUntil
lastBaseSet, lastBaseTeleport, visitorCooldownUntil, borderEnabled,
totalBlocksPlaced, spawnableOverlayEnabled
);
}
@@ -113,7 +190,8 @@ public record PlayerState(
warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
navigationEnabled, flightEnabled, enabled, visitorsEnabled,
lastBaseSet, lastBaseTeleport, visitorCooldownUntil
lastBaseSet, lastBaseTeleport, visitorCooldownUntil, borderEnabled,
totalBlocksPlaced, spawnableOverlayEnabled
);
}
@@ -123,7 +201,8 @@ public record PlayerState(
warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
enabled, flightEnabled, bossBarEnabled, visitorsEnabled,
lastBaseSet, lastBaseTeleport, visitorCooldownUntil
lastBaseSet, lastBaseTeleport, visitorCooldownUntil, borderEnabled,
totalBlocksPlaced, spawnableOverlayEnabled
);
}
@@ -133,7 +212,8 @@ public record PlayerState(
warmupLevel, cooldownLevel, grassAndDirtBroken, stone, deepslate,
obsidian, blocksPlacedInBase, blocksBrokenInBase,
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled,
lastBaseSet, lastBaseTeleport, visitorCooldownUntil
lastBaseSet, lastBaseTeleport, visitorCooldownUntil, borderEnabled,
totalBlocksPlaced, spawnableOverlayEnabled
);
}
@@ -143,7 +223,8 @@ public record PlayerState(
warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
navigationEnabled, enabled, bossBarEnabled, visitorsEnabled,
lastBaseSet, lastBaseTeleport, visitorCooldownUntil
lastBaseSet, lastBaseTeleport, visitorCooldownUntil, borderEnabled,
totalBlocksPlaced, spawnableOverlayEnabled
);
}
@@ -163,7 +244,8 @@ public record PlayerState(
newWarmupLevel, newCooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
obsidianBroken, placements, breaks,
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled,
lastBaseSet, lastBaseTeleport, visitorCooldownUntil
lastBaseSet, lastBaseTeleport, visitorCooldownUntil, borderEnabled,
totalBlocksPlaced, spawnableOverlayEnabled
);
}
@@ -179,7 +261,8 @@ public record PlayerState(
warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled,
lastBaseSet, Optional.of(usedAt), visitorCooldownUntil
lastBaseSet, Optional.of(usedAt), visitorCooldownUntil, borderEnabled,
totalBlocksPlaced, spawnableOverlayEnabled
);
}
@@ -189,7 +272,8 @@ public record PlayerState(
warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled,
lastBaseSet, lastBaseTeleport, visitorCooldownUntil
lastBaseSet, lastBaseTeleport, visitorCooldownUntil, borderEnabled,
totalBlocksPlaced, spawnableOverlayEnabled
);
}
@@ -206,7 +290,8 @@ public record PlayerState(
warmupLevel, cooldownLevel, grassDirt, stone, deepslate,
obsidian, placements, baseBreaks,
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled,
lastBaseSet, lastBaseTeleport, visitorCooldownUntil
lastBaseSet, lastBaseTeleport, visitorCooldownUntil, borderEnabled,
totalBlocksPlaced, spawnableOverlayEnabled
);
}
@@ -225,7 +310,10 @@ public record PlayerState(
newWarmupLevel, newCooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
newNavigationEnabled, newFlightEnabled, bossBarEnabled, newVisitorsEnabled,
lastBaseSet, lastBaseTeleport, visitorCooldownUntil
lastBaseSet, lastBaseTeleport, visitorCooldownUntil,
borderEnabled && newBaseLevel >= 1 && base.isPresent(),
totalBlocksPlaced,
spawnableOverlayEnabled && newBaseLevel >= 1 && base.isPresent()
);
}
@@ -235,7 +323,41 @@ public record PlayerState(
warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
navigationEnabled, flightEnabled, bossBarEnabled, enabled,
lastBaseSet, lastBaseTeleport, visitorCooldownUntil
lastBaseSet, lastBaseTeleport, visitorCooldownUntil, borderEnabled,
totalBlocksPlaced, spawnableOverlayEnabled
);
}
public PlayerState withBorderEnabled(boolean enabled) {
return new PlayerState(
playerId, latestName, base, baseLevel, sizeLevel, flightLevel,
warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled,
lastBaseSet, lastBaseTeleport, visitorCooldownUntil, enabled,
totalBlocksPlaced, spawnableOverlayEnabled
);
}
public PlayerState withTotalBlocksPlaced(long count) {
return new PlayerState(
playerId, latestName, base, baseLevel, sizeLevel, flightLevel,
warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled,
lastBaseSet, lastBaseTeleport, visitorCooldownUntil, borderEnabled,
count, spawnableOverlayEnabled
);
}
public PlayerState withSpawnableOverlayEnabled(boolean enabled) {
return new PlayerState(
playerId, latestName, base, baseLevel, sizeLevel, flightLevel,
warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled,
lastBaseSet, lastBaseTeleport, visitorCooldownUntil, borderEnabled,
totalBlocksPlaced, enabled
);
}
@@ -247,7 +369,8 @@ public record PlayerState(
warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled,
lastBaseSet, lastBaseTeleport, cooldowns
lastBaseSet, lastBaseTeleport, cooldowns, borderEnabled,
totalBlocksPlaced, spawnableOverlayEnabled
);
}
@@ -257,7 +380,8 @@ public record PlayerState(
warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled,
lastBaseSet, Optional.empty(), visitorCooldownUntil
lastBaseSet, Optional.empty(), visitorCooldownUntil, borderEnabled,
totalBlocksPlaced, spawnableOverlayEnabled
);
}
@@ -267,7 +391,8 @@ public record PlayerState(
warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled,
lastBaseSet, lastBaseTeleport, Map.of()
lastBaseSet, lastBaseTeleport, Map.of(), borderEnabled,
totalBlocksPlaced, spawnableOverlayEnabled
);
}
@@ -277,7 +402,8 @@ public record PlayerState(
warmupLevel, cooldownLevel, grassAndDirtBroken, stoneBroken, deepslateBroken,
obsidianBroken, blocksPlacedInBase, blocksBrokenInBase,
navigationEnabled, flightEnabled, bossBarEnabled, visitorsEnabled,
Optional.of(setAt), lastBaseTeleport, visitorCooldownUntil
Optional.of(setAt), lastBaseTeleport, visitorCooldownUntil, borderEnabled,
totalBlocksPlaced, spawnableOverlayEnabled
);
}
@@ -21,6 +21,7 @@ public record PluginSettings(
int flightWarningBuffer,
int secondFlightVerticalRange,
int teleportUnlockPlacements,
int spawnableOverlayUnlockPlacements,
int secondWarmupPlacements,
int thirdWarmupPlacements,
int instantWarmupPlacements,
@@ -36,11 +37,17 @@ public record PluginSettings(
long thirdTeleportCooldownSeconds,
long fourthTeleportCooldownSeconds,
int visitorUnlockDiamondCost,
int pocketBaseUnlockCost,
int pocketBaseUpgradeCost,
int pocketBaseBiomeChangeCost,
Set<String> baseUnlockMaterials,
String stoneExpansionMaterial,
String deepslateExpansionMaterial,
String obsidianExpansionMaterial,
String visitorCurrencyMaterial,
String pocketBaseCurrencyMaterial,
String pocketBaseBiomeCurrencyMaterial,
String pocketBasePortalFrameMaterial,
Set<String> cooldownExcludedMaterials,
int bossBarDurationTicks,
int navigationParticleCount,
@@ -62,6 +69,7 @@ public record PluginSettings(
private static final int DEFAULT_FLIGHT_WARNING_BUFFER = 5;
private static final int DEFAULT_SECOND_FLIGHT_VERTICAL_RANGE = 100;
private static final int DEFAULT_TELEPORT_UNLOCK_PLACEMENTS = 200;
private static final int DEFAULT_SPAWNABLE_OVERLAY_UNLOCK_PLACEMENTS = 250;
private static final int DEFAULT_SECOND_WARMUP_PLACEMENTS = 1_000;
private static final int DEFAULT_THIRD_WARMUP_PLACEMENTS = 2_000;
private static final int DEFAULT_INSTANT_WARMUP_PLACEMENTS = 12_000;
@@ -77,6 +85,9 @@ public record PluginSettings(
private static final long DEFAULT_THIRD_TELEPORT_COOLDOWN_SECONDS = 3_600L;
private static final long DEFAULT_FOURTH_TELEPORT_COOLDOWN_SECONDS = 1_800L;
private static final int DEFAULT_VISITOR_UNLOCK_DIAMOND_COST = 128;
private static final int DEFAULT_POCKET_BASE_UNLOCK_COST = 64;
private static final int DEFAULT_POCKET_BASE_UPGRADE_COST = 16;
private static final int DEFAULT_POCKET_BASE_BIOME_CHANGE_COST = 16;
private static final int DEFAULT_BOSS_BAR_DURATION_TICKS = 60;
private static final int DEFAULT_NAVIGATION_PARTICLE_COUNT = 5;
private static final int DEFAULT_TITLE_FADE_IN_TICKS = 10;
@@ -110,6 +121,7 @@ public record PluginSettings(
throw new IllegalArgumentException("second-flight-vertical-range must exceed the initial range");
}
requirePositive(teleportUnlockPlacements, "teleport-unlock-placements");
requirePositive(spawnableOverlayUnlockPlacements, "spawnable-overlay-unlock-placements");
if (secondWarmupPlacements <= teleportUnlockPlacements
|| thirdWarmupPlacements <= secondWarmupPlacements
|| instantWarmupPlacements <= thirdWarmupPlacements) {
@@ -137,6 +149,9 @@ public record PluginSettings(
throw new IllegalArgumentException("teleport cooldown durations must decrease");
}
requirePositive(visitorUnlockDiamondCost, "visitor-unlock-diamond-cost");
requirePositive(pocketBaseUnlockCost, "pocket-base-unlock-cost");
requirePositive(pocketBaseUpgradeCost, "pocket-base-upgrade-cost");
requirePositive(pocketBaseBiomeChangeCost, "pocket-base-biome-change-cost");
baseUnlockMaterials = normalizedSet(baseUnlockMaterials, "base-unlock-materials", false);
stoneExpansionMaterial = normalizedName(stoneExpansionMaterial, "stone-expansion-material");
deepslateExpansionMaterial = normalizedName(
@@ -148,6 +163,15 @@ public record PluginSettings(
visitorCurrencyMaterial = normalizedName(
visitorCurrencyMaterial, "visitor-currency-material"
);
pocketBaseCurrencyMaterial = normalizedName(
pocketBaseCurrencyMaterial, "pocket-base-currency-material"
);
pocketBaseBiomeCurrencyMaterial = normalizedName(
pocketBaseBiomeCurrencyMaterial, "pocket-base-biome-currency-material"
);
pocketBasePortalFrameMaterial = normalizedName(
pocketBasePortalFrameMaterial, "pocket-base-portal-frame-material"
);
cooldownExcludedMaterials = normalizedSet(
cooldownExcludedMaterials, "cooldown-excluded-materials", true
);
@@ -175,6 +199,11 @@ public record PluginSettings(
integer(values, "flight-warning-buffer", DEFAULT_FLIGHT_WARNING_BUFFER),
integer(values, "second-flight-vertical-range", DEFAULT_SECOND_FLIGHT_VERTICAL_RANGE),
integer(values, "teleport-unlock-placements", DEFAULT_TELEPORT_UNLOCK_PLACEMENTS),
integer(
values,
"spawnable-overlay-unlock-placements",
DEFAULT_SPAWNABLE_OVERLAY_UNLOCK_PLACEMENTS
),
integer(values, "second-warmup-placements", DEFAULT_SECOND_WARMUP_PLACEMENTS),
integer(values, "third-warmup-placements", DEFAULT_THIRD_WARMUP_PLACEMENTS),
integer(values, "instant-warmup-placements", DEFAULT_INSTANT_WARMUP_PLACEMENTS),
@@ -206,11 +235,21 @@ public record PluginSettings(
DEFAULT_FOURTH_TELEPORT_COOLDOWN_SECONDS
),
integer(values, "visitor-unlock-diamond-cost", DEFAULT_VISITOR_UNLOCK_DIAMOND_COST),
integer(values, "pocket-base-unlock-cost", DEFAULT_POCKET_BASE_UNLOCK_COST),
integer(values, "pocket-base-upgrade-cost", DEFAULT_POCKET_BASE_UPGRADE_COST),
integer(
values,
"pocket-base-biome-change-cost",
DEFAULT_POCKET_BASE_BIOME_CHANGE_COST
),
stringSet(values, "base-unlock-materials", Set.of("GRASS_BLOCK", "DIRT")),
string(values, "stone-expansion-material", "STONE"),
string(values, "deepslate-expansion-material", "DEEPSLATE"),
string(values, "obsidian-expansion-material", "OBSIDIAN"),
string(values, "visitor-currency-material", "DIAMOND"),
string(values, "pocket-base-currency-material", "DIAMOND_BLOCK"),
string(values, "pocket-base-biome-currency-material", "NETHERITE_BLOCK"),
string(values, "pocket-base-portal-frame-material", "DIAMOND_BLOCK"),
stringSet(values, "cooldown-excluded-materials", Set.of()),
integer(values, "boss-bar-duration-ticks", DEFAULT_BOSS_BAR_DURATION_TICKS),
integer(values, "navigation-particle-count", DEFAULT_NAVIGATION_PARTICLE_COUNT),
@@ -13,7 +13,8 @@ final class PluginSettingsValidator {
Stream.of(
settings.stoneExpansionMaterial(),
settings.deepslateExpansionMaterial(),
settings.obsidianExpansionMaterial()
settings.obsidianExpansionMaterial(),
settings.pocketBasePortalFrameMaterial()
)
).forEach(name -> {
Material material = Material.matchMaterial(name);
@@ -26,10 +27,19 @@ final class PluginSettingsValidator {
throw new IllegalArgumentException(name + " is not a known material");
}
}
Material currency = Material.matchMaterial(settings.visitorCurrencyMaterial());
if (currency == null || currency.isAir()) {
throw new IllegalArgumentException("visitor-currency-material is invalid");
}
validateCurrency(settings.visitorCurrencyMaterial(), "visitor-currency-material");
validateCurrency(settings.pocketBaseCurrencyMaterial(), "pocket-base-currency-material");
validateCurrency(
settings.pocketBaseBiomeCurrencyMaterial(),
"pocket-base-biome-currency-material"
);
return settings;
}
private static void validateCurrency(String name, String setting) {
Material currency = Material.matchMaterial(name);
if (currency == null || currency.isAir()) {
throw new IllegalArgumentException(setting + " is invalid");
}
}
}
@@ -0,0 +1,105 @@
package games.dmg.spigotbase;
import java.util.Arrays;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
public enum PocketBaseBiome {
THE_VOID(PocketBaseWorldType.VOID),
NETHER_WASTES(PocketBaseWorldType.NETHER),
SOUL_SAND_VALLEY(PocketBaseWorldType.NETHER),
CRIMSON_FOREST(PocketBaseWorldType.NETHER),
WARPED_FOREST(PocketBaseWorldType.NETHER),
BASALT_DELTAS(PocketBaseWorldType.NETHER),
OCEAN(PocketBaseWorldType.OVERWORLD),
PLAINS(PocketBaseWorldType.OVERWORLD),
DESERT(PocketBaseWorldType.OVERWORLD),
WINDSWEPT_HILLS(PocketBaseWorldType.OVERWORLD),
FOREST(PocketBaseWorldType.OVERWORLD),
TAIGA(PocketBaseWorldType.OVERWORLD),
SWAMP(PocketBaseWorldType.OVERWORLD),
MANGROVE_SWAMP(PocketBaseWorldType.OVERWORLD),
RIVER(PocketBaseWorldType.OVERWORLD),
FROZEN_OCEAN(PocketBaseWorldType.OVERWORLD),
FROZEN_RIVER(PocketBaseWorldType.OVERWORLD),
SNOWY_PLAINS(PocketBaseWorldType.OVERWORLD),
MUSHROOM_FIELDS(PocketBaseWorldType.OVERWORLD),
BEACH(PocketBaseWorldType.OVERWORLD),
JUNGLE(PocketBaseWorldType.OVERWORLD),
SPARSE_JUNGLE(PocketBaseWorldType.OVERWORLD),
DEEP_OCEAN(PocketBaseWorldType.OVERWORLD),
STONY_SHORE(PocketBaseWorldType.OVERWORLD),
SNOWY_BEACH(PocketBaseWorldType.OVERWORLD),
BIRCH_FOREST(PocketBaseWorldType.OVERWORLD),
DARK_FOREST(PocketBaseWorldType.OVERWORLD),
PALE_GARDEN(PocketBaseWorldType.OVERWORLD),
SNOWY_TAIGA(PocketBaseWorldType.OVERWORLD),
OLD_GROWTH_PINE_TAIGA(PocketBaseWorldType.OVERWORLD),
WINDSWEPT_FOREST(PocketBaseWorldType.OVERWORLD),
SAVANNA(PocketBaseWorldType.OVERWORLD),
SAVANNA_PLATEAU(PocketBaseWorldType.OVERWORLD),
BADLANDS(PocketBaseWorldType.OVERWORLD),
WOODED_BADLANDS(PocketBaseWorldType.OVERWORLD),
WARM_OCEAN(PocketBaseWorldType.OVERWORLD),
LUKEWARM_OCEAN(PocketBaseWorldType.OVERWORLD),
COLD_OCEAN(PocketBaseWorldType.OVERWORLD),
DEEP_LUKEWARM_OCEAN(PocketBaseWorldType.OVERWORLD),
DEEP_COLD_OCEAN(PocketBaseWorldType.OVERWORLD),
DEEP_FROZEN_OCEAN(PocketBaseWorldType.OVERWORLD),
SUNFLOWER_PLAINS(PocketBaseWorldType.OVERWORLD),
WINDSWEPT_GRAVELLY_HILLS(PocketBaseWorldType.OVERWORLD),
FLOWER_FOREST(PocketBaseWorldType.OVERWORLD),
ICE_SPIKES(PocketBaseWorldType.OVERWORLD),
OLD_GROWTH_BIRCH_FOREST(PocketBaseWorldType.OVERWORLD),
OLD_GROWTH_SPRUCE_TAIGA(PocketBaseWorldType.OVERWORLD),
WINDSWEPT_SAVANNA(PocketBaseWorldType.OVERWORLD),
ERODED_BADLANDS(PocketBaseWorldType.OVERWORLD),
BAMBOO_JUNGLE(PocketBaseWorldType.OVERWORLD),
DRIPSTONE_CAVES(PocketBaseWorldType.OVERWORLD),
LUSH_CAVES(PocketBaseWorldType.OVERWORLD),
DEEP_DARK(PocketBaseWorldType.OVERWORLD),
SULFUR_CAVES(PocketBaseWorldType.OVERWORLD),
MEADOW(PocketBaseWorldType.OVERWORLD),
GROVE(PocketBaseWorldType.OVERWORLD),
SNOWY_SLOPES(PocketBaseWorldType.OVERWORLD),
FROZEN_PEAKS(PocketBaseWorldType.OVERWORLD),
JAGGED_PEAKS(PocketBaseWorldType.OVERWORLD),
STONY_PEAKS(PocketBaseWorldType.OVERWORLD),
CHERRY_GROVE(PocketBaseWorldType.OVERWORLD);
private final PocketBaseWorldType worldType;
PocketBaseBiome(PocketBaseWorldType worldType) {
this.worldType = worldType;
}
public PocketBaseWorldType worldType() {
return worldType;
}
public String commandName() {
return name().toLowerCase(Locale.ROOT);
}
static Optional<PocketBaseBiome> fromCommand(
PocketBaseWorldType worldType,
String value
) {
try {
PocketBaseBiome biome = valueOf(value.toUpperCase(Locale.ROOT));
return biome.worldType == worldType ? Optional.of(biome) : Optional.empty();
} catch (IllegalArgumentException exception) {
return Optional.empty();
}
}
static List<String> commandNames(PocketBaseWorldType worldType) {
return Arrays.stream(values())
.filter(biome -> biome.worldType == worldType)
.map(PocketBaseBiome::commandName)
.toList();
}
}
@@ -0,0 +1,480 @@
package games.dmg.spigotbase;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.function.Consumer;
import java.util.logging.Level;
import java.util.logging.Logger;
import org.bukkit.ChatColor;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.Particle;
import org.bukkit.Server;
import org.bukkit.World;
import org.bukkit.block.Block;
import org.bukkit.entity.Entity;
import org.bukkit.entity.Mob;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.block.Action;
import org.bukkit.event.block.BlockBreakEvent;
import org.bukkit.event.block.BlockPlaceEvent;
import org.bukkit.event.entity.EntityDamageEvent;
import org.bukkit.event.player.PlayerInteractEvent;
import org.bukkit.event.player.PlayerMoveEvent;
import org.bukkit.event.player.PlayerTeleportEvent;
import org.bukkit.util.BoundingBox;
final class PocketBaseController implements Listener, Runnable {
private static final int VOID_RETURN_Y = -64;
private static final long PORTAL_COOLDOWN_NANOS = 2_000_000_000L;
private final Server server;
private final BaseStateManager baseStates;
private final BaseBoundsService baseBounds;
private final PocketBaseManager pocketBases;
private final PluginSettingsProvider settings;
private final Logger logger;
private final BaseFlightController flightController;
private final Map<UUID, Long> cooldownUntil = new HashMap<>();
PocketBaseController(
Server server,
BaseStateManager baseStates,
BaseBoundsService baseBounds,
PocketBaseManager pocketBases,
PluginSettingsProvider settings,
Logger logger
) {
this(server, baseStates, baseBounds, pocketBases, settings, logger, null);
}
PocketBaseController(
Server server,
BaseStateManager baseStates,
BaseBoundsService baseBounds,
PocketBaseManager pocketBases,
PluginSettingsProvider settings,
Logger logger,
BaseFlightController flightController
) {
this.server = server;
this.baseStates = baseStates;
this.baseBounds = baseBounds;
this.pocketBases = pocketBases;
this.settings = settings;
this.logger = logger;
this.flightController = flightController;
}
@Override
public void run() {
validateEntrances();
for (PocketBaseState state : pocketBases.knownStates().values()) {
state.entrance().ifPresent(this::showPortal);
if (state.level() > 0 && pocketBases.returnPortalIsIntact(state.ownerId())) {
showPortal(pocketBases.returnPortal(state.ownerId()));
}
state.flightPortal().filter(portal -> isIntact(portal, Material.GOLD_BLOCK))
.ifPresent(portal -> showPortal(portal, Material.GOLD_BLOCK));
}
scanMobPortals();
long now = System.nanoTime();
cooldownUntil.entrySet().removeIf(entry -> entry.getValue() <= now);
}
@EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true)
public void onActivate(PlayerInteractEvent event) {
if (event.getAction() != Action.RIGHT_CLICK_BLOCK || event.getClickedBlock() == null
|| event.getItem() == null
|| event.getItem().getType() != Material.FLINT_AND_STEEL) {
return;
}
Player player = event.getPlayer();
PocketBaseState pocket = pocketBases.state(player.getUniqueId());
if (pocket.level() < 1) {
return;
}
Block clicked = event.getClickedBlock();
World world = clicked.getWorld();
Material frameMaterial = Material.valueOf(
settings.current().pocketBasePortalFrameMaterial()
);
Optional<UUID> pocketOwner = pocketBases.ownerForPocketWorld(world.getUID());
if (pocketOwner.isPresent()) {
if (!pocketOwner.orElseThrow().equals(player.getUniqueId())) {
return;
}
Optional<PocketPortalLocation> flightPortal = pocket.flightUnlocked()
? findFrame(clicked, Material.GOLD_BLOCK)
: Optional.empty();
boolean activatingFlightPortal = flightPortal.isPresent();
Optional<PocketPortalLocation> portal = activatingFlightPortal
? flightPortal
: findFrame(clicked, frameMaterial);
if (portal.isEmpty()) {
return;
}
if (PocketPortalGeometry.frameBlocks(portal.orElseThrow()).stream()
.anyMatch(candidate -> !pocketBases.policy().contains(
pocket.level(), candidate.x(), candidate.z()
))) {
player.sendMessage(ChatColor.RED + "The complete "
+ (activatingFlightPortal ? "flight" : "return")
+ " portal must be inside your Pocket Base boundary.");
event.setCancelled(true);
return;
}
try {
if (activatingFlightPortal) {
pocketBases.activateFlightPortal(
player.getUniqueId(), portal.orElseThrow()
);
} else {
pocketBases.activateReturnPortal(
player.getUniqueId(), portal.orElseThrow()
);
}
} catch (IOException | RuntimeException exception) {
logger.log(Level.SEVERE, "Could not activate Pocket Base "
+ (activatingFlightPortal ? "flight" : "return") + " portal", exception);
player.sendMessage(ChatColor.RED + "The Pocket Base "
+ (activatingFlightPortal ? "flight" : "return")
+ " portal could not be activated.");
event.setCancelled(true);
return;
}
event.setCancelled(true);
player.sendMessage(ChatColor.GREEN + "Pocket Base "
+ (activatingFlightPortal ? "flight" : "return") + " portal activated.");
return;
}
Optional<PocketPortalLocation> portal = findFrame(clicked, frameMaterial);
if (portal.isEmpty()) {
return;
}
PlayerState owner = baseStates.player(player.getUniqueId(), player.getName());
if (owner.baseLevel() < 4 || owner.base().isEmpty()) {
return;
}
BaseArea area = baseBounds.area(owner);
if (PocketPortalGeometry.frameBlocks(portal.orElseThrow()).stream().anyMatch(candidate ->
!area.contains(world.getUID(), candidate.x(), candidate.y(), candidate.z()))) {
player.sendMessage(ChatColor.RED
+ "The complete Pocket Base portal must be inside your base.");
event.setCancelled(true);
return;
}
try {
pocketBases.activateEntrance(player.getUniqueId(), portal.orElseThrow());
} catch (IOException | RuntimeException exception) {
logger.log(Level.SEVERE, "Could not activate Pocket Base entrance", exception);
player.sendMessage(ChatColor.RED + "The Pocket Base portal could not be activated.");
event.setCancelled(true);
return;
}
event.setCancelled(true);
player.sendMessage(ChatColor.GREEN + "Pocket Base portal activated.");
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onBreak(BlockBreakEvent event) {
BlockPosition broken = position(event.getBlock());
UUID worldId = event.getBlock().getWorld().getUID();
for (PocketBaseState state : pocketBases.knownStates().values()) {
if (state.entrance().isPresent()) {
PocketPortalLocation portal = state.entrance().orElseThrow();
if (portal.worldId().equals(worldId)
&& PocketPortalGeometry.isFrame(portal, broken)) {
deactivate(state.ownerId());
}
}
if (state.flightPortal().isPresent()) {
PocketPortalLocation portal = state.flightPortal().orElseThrow();
if (portal.worldId().equals(worldId)
&& PocketPortalGeometry.isFrame(portal, broken)) {
deactivateFlightPortal(state.ownerId());
}
}
}
}
@EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true)
public void onPlace(BlockPlaceEvent event) {
Block block = event.getBlockPlaced();
Optional<UUID> ownerId = pocketBases.ownerForPocketWorld(block.getWorld().getUID());
if (ownerId.isEmpty()) {
return;
}
PocketBaseState state = pocketBases.state(ownerId.orElseThrow());
if (!pocketBases.policy().contains(state.level(), block.getX(), block.getZ())) {
event.setCancelled(true);
event.getPlayer().sendMessage(ChatColor.RED
+ "That block is outside the unlocked Pocket Base boundary.");
}
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onMove(PlayerMoveEvent event) {
Location destination = event.getTo();
if (destination == null || onCooldown(event.getPlayer())) {
return;
}
Player player = event.getPlayer();
World world = destination.getWorld();
if (world == null) {
return;
}
Optional<UUID> pocketOwner = pocketBases.ownerForPocketWorld(world.getUID());
if (pocketOwner.isPresent()) {
UUID ownerId = pocketOwner.orElseThrow();
if (destination.getY() < VOID_RETURN_Y
|| pocketBases.returnPortalIsIntact(ownerId)
&& PocketPortalGeometry.isInterior(
pocketBases.returnPortal(ownerId), position(destination)
)) {
leavePocket(player, ownerId);
return;
}
PocketBaseState state = pocketBases.state(ownerId);
if (flightController != null && state.flightPortal().isPresent()) {
PocketPortalLocation portal = state.flightPortal().orElseThrow();
if (isIntact(portal, Material.GOLD_BLOCK)
&& PocketPortalGeometry.isInterior(
portal, position(destination)
) && (event.getFrom() == null
|| !PocketPortalGeometry.isInterior(portal, position(event.getFrom())))) {
flightController.grantPocketPortalFlight(player, ownerId);
}
}
return;
}
BlockPosition position = position(destination);
for (PocketBaseState state : pocketBases.knownStates().values()) {
if (state.entrance().isEmpty()) {
continue;
}
PocketPortalLocation portal = state.entrance().orElseThrow();
if (portal.worldId().equals(world.getUID())
&& isIntact(portal)
&& PocketPortalGeometry.isInterior(portal, position)) {
teleport(player, pocketBases.pocketArrival(state.ownerId()));
return;
}
}
}
@EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true)
public void onVoidDamage(EntityDamageEvent event) {
Entity entity = event.getEntity();
if (event.getCause() != EntityDamageEvent.DamageCause.VOID
|| !(entity instanceof Player player)) {
return;
}
Optional<UUID> owner = pocketBases.ownerForPocketWorld(player.getWorld().getUID());
if (owner.isPresent()) {
event.setCancelled(true);
leavePocket(player, owner.orElseThrow());
}
}
boolean isPocketWorld(UUID worldId) {
return pocketBases.ownerForPocketWorld(worldId).isPresent();
}
void deactivateForRelocation(UUID ownerId) {
deactivate(ownerId);
}
void scanMobPortals() {
for (PocketBaseState state : pocketBases.knownStates().values()) {
if (state.entrance().isPresent()) {
PocketPortalLocation entrance = state.entrance().orElseThrow();
if (isIntact(entrance)) {
transportMobs(
entrance,
mob -> teleport(mob, pocketBases.pocketArrival(state.ownerId()))
);
}
}
if (state.level() > 0 && pocketBases.returnPortalIsIntact(state.ownerId())) {
transportMobs(
pocketBases.returnPortal(state.ownerId()),
mob -> leavePocket(mob, state.ownerId())
);
}
}
}
private void transportMobs(
PocketPortalLocation portal,
Consumer<Mob> transport
) {
World world = server.getWorld(portal.worldId());
if (world == null) {
return;
}
for (Entity entity : world.getNearbyEntities(
portalBounds(portal),
candidate -> candidate instanceof Mob
)) {
if (entity instanceof Mob mob && !onCooldown(mob)) {
transport.accept(mob);
}
}
}
private void leavePocket(Entity entity, UUID ownerId) {
PocketBaseState state = pocketBases.state(ownerId);
if (state.entrance().isPresent() && isIntact(state.entrance().orElseThrow())) {
teleport(entity, outsidePortal(state.entrance().orElseThrow()));
return;
}
World fallbackWorld = server.getWorlds().get(0);
teleport(entity, fallbackWorld.getSpawnLocation());
}
private void teleport(Entity entity, Location destination) {
cooldownUntil.put(entity.getUniqueId(), System.nanoTime() + PORTAL_COOLDOWN_NANOS);
entity.setFallDistance(0.0F);
entity.teleport(destination, PlayerTeleportEvent.TeleportCause.PLUGIN);
}
private boolean onCooldown(Entity entity) {
return cooldownUntil.getOrDefault(entity.getUniqueId(), 0L) > System.nanoTime();
}
private void validateEntrances() {
for (PocketBaseState state : pocketBases.knownStates().values()) {
if (state.entrance().isEmpty()) {
continue;
}
PlayerState owner = baseStates.knownPlayers().get(state.ownerId());
PocketPortalLocation portal = state.entrance().orElseThrow();
if (owner == null || owner.baseLevel() < 4 || owner.base().isEmpty()
|| !isIntact(portal)
|| PocketPortalGeometry.frameBlocks(portal).stream().anyMatch(position ->
!baseBounds.area(owner).contains(
portal.worldId(), position.x(), position.y(), position.z()
))) {
deactivate(state.ownerId());
}
}
}
private boolean isIntact(PocketPortalLocation portal) {
Material frame = Material.valueOf(settings.current().pocketBasePortalFrameMaterial());
return isIntact(portal, frame);
}
private boolean isIntact(PocketPortalLocation portal, Material frame) {
World world = server.getWorld(portal.worldId());
return world != null && PocketPortalGeometry.frameBlocks(portal).stream()
.allMatch(position -> block(world, position).getType() == frame);
}
private void showPortal(PocketPortalLocation portal) {
showPortal(
portal,
Material.valueOf(settings.current().pocketBasePortalFrameMaterial())
);
}
private void showPortal(PocketPortalLocation portal, Material frame) {
World world = server.getWorld(portal.worldId());
if (world == null || !isIntact(portal, frame)) {
return;
}
for (BlockPosition position : PocketPortalGeometry.interiorBlocks(portal)) {
world.spawnParticle(
Particle.PORTAL,
position.x() + 0.5,
position.y() + 0.5,
position.z() + 0.5,
4,
0.25,
0.35,
0.25,
0.02
);
}
}
private void deactivateFlightPortal(UUID ownerId) {
try {
pocketBases.deactivateFlightPortal(ownerId);
} catch (IOException exception) {
logger.log(Level.SEVERE, "Could not deactivate Pocket Base flight portal", exception);
}
}
private void deactivate(UUID ownerId) {
try {
pocketBases.deactivateEntrance(ownerId);
} catch (IOException exception) {
logger.log(Level.SEVERE, "Could not deactivate Pocket Base entrance", exception);
}
}
private Location outsidePortal(PocketPortalLocation portal) {
World world = server.getWorld(portal.worldId());
if (world == null) {
return server.getWorlds().get(0).getSpawnLocation();
}
return portal.axis() == PocketPortalAxis.X
? new Location(world, portal.x() + 2.0, portal.y() + 1.0, portal.z() + 1.5)
: new Location(world, portal.x() + 1.5, portal.y() + 1.0, portal.z() + 2.0);
}
private static BoundingBox portalBounds(PocketPortalLocation portal) {
return portal.axis() == PocketPortalAxis.X
? new BoundingBox(
portal.x() + 1.0,
portal.y() + 1.0,
portal.z(),
portal.x() + 3.0,
portal.y() + 4.0,
portal.z() + 1.0
)
: new BoundingBox(
portal.x(),
portal.y() + 1.0,
portal.z() + 1.0,
portal.x() + 1.0,
portal.y() + 4.0,
portal.z() + 3.0
);
}
private Optional<PocketPortalLocation> findFrame(Block clicked, Material material) {
World world = clicked.getWorld();
return PocketPortalGeometry.findFrame(
position(clicked),
world.getUID(),
world.getName(),
candidate -> block(world, candidate).getType() == material,
candidate -> isAir(block(world, candidate).getType())
);
}
private static boolean isAir(Material material) {
return material == Material.AIR || material == Material.CAVE_AIR
|| material == Material.VOID_AIR;
}
private static Block block(World world, BlockPosition position) {
return world.getBlockAt(position.x(), position.y(), position.z());
}
private static BlockPosition position(Block block) {
return new BlockPosition(block.getX(), block.getY(), block.getZ());
}
private static BlockPosition position(Location location) {
return new BlockPosition(location.getBlockX(), location.getBlockY(), location.getBlockZ());
}
}
@@ -0,0 +1,259 @@
package games.dmg.spigotbase;
import java.io.IOException;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.logging.Level;
import java.util.logging.Logger;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.ChatColor;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.World;
import org.bukkit.block.Block;
import org.bukkit.NamespacedKey;
import org.bukkit.Server;
import org.bukkit.entity.Player;
import org.bukkit.event.Event;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.block.Action;
import org.bukkit.event.inventory.PrepareItemCraftEvent;
import org.bukkit.event.player.PlayerInteractEvent;
import org.bukkit.event.player.PlayerTeleportEvent;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.ShapedRecipe;
import org.bukkit.inventory.meta.ItemMeta;
import org.bukkit.persistence.PersistentDataType;
final class PocketBaseKeystoneService implements Listener {
private static final Set<Material> RETURN_HAZARDS = Set.of(
Material.LAVA,
Material.FIRE,
Material.SOUL_FIRE,
Material.MAGMA_BLOCK,
Material.CACTUS,
Material.CAMPFIRE,
Material.SOUL_CAMPFIRE,
Material.POWDER_SNOW
);
private final Server server;
private final PocketBaseManager pocketBases;
private final KeystoneReturnStore returns;
private final NamespacedKey markerKey;
private final Logger logger;
PocketBaseKeystoneService(
Server server,
PocketBaseManager pocketBases,
KeystoneReturnStore returns,
NamespacedKey markerKey,
Logger logger
) {
this.server = server;
this.pocketBases = pocketBases;
this.returns = returns;
this.markerKey = markerKey;
this.logger = logger;
}
@EventHandler(priority = EventPriority.HIGH)
public void onPrepareCraft(PrepareItemCraftEvent event) {
if (!(event.getRecipe() instanceof ShapedRecipe shaped)
|| !shaped.getKey().equals(markerKey)
|| !(event.getView().getPlayer() instanceof Player player)) {
return;
}
if (pocketBases.state(player.getUniqueId()).level() < 1) {
event.getInventory().setResult(null);
}
}
@EventHandler(priority = EventPriority.HIGH)
public void onUse(PlayerInteractEvent event) {
Action action = event.getAction();
if ((action != Action.RIGHT_CLICK_AIR && action != Action.RIGHT_CLICK_BLOCK)
|| !isKeystone(event.getItem(), markerKey)) {
return;
}
// Vanilla no-action air clicks can deny block use without denying item use.
// Accept those events, but respect explicit item-use denial by other plugins.
if (event.useItemInHand() == Event.Result.DENY) {
return;
}
event.setCancelled(true);
Player player = event.getPlayer();
UUID playerId = player.getUniqueId();
if (pocketBases.state(playerId).level() < 1) {
player.sendMessage(ChatColor.RED
+ "Unlock Pocket Base I before using a Pocket Base Keystone.");
return;
}
Optional<UUID> currentPocket = pocketBases.ownerForPocketWorld(
player.getWorld().getUID()
);
if (currentPocket.isPresent()) {
if (!currentPocket.orElseThrow().equals(playerId)) {
player.sendMessage(ChatColor.RED
+ "You can only use a keystone to leave your own Pocket Base.");
return;
}
returnToSavedDestination(player);
return;
}
Location origin = player.getLocation();
World originWorld = origin.getWorld();
if (originWorld == null) {
player.sendMessage(ChatColor.RED + "Your return location could not be recorded.");
return;
}
KeystoneReturnLocation destination = new KeystoneReturnLocation(
originWorld.getUID(),
originWorld.getName(),
origin.getX(),
origin.getY(),
origin.getZ(),
origin.getYaw(),
origin.getPitch()
);
try {
returns.record(playerId, destination);
} catch (IOException exception) {
logger.log(Level.SEVERE, "Could not save a Pocket Base Keystone return", exception);
player.sendMessage(ChatColor.RED
+ "Your return location could not be saved, so you were not teleported.");
return;
}
teleport(player, pocketBases.pocketArrival(playerId));
}
private void returnToSavedDestination(Player player) {
Optional<KeystoneReturnLocation> saved = returns.destination(player.getUniqueId());
if (saved.isEmpty()) {
player.sendMessage(ChatColor.RED + "No Pocket Base Keystone return is recorded.");
return;
}
KeystoneReturnLocation destination = saved.orElseThrow();
World world = server.getWorld(destination.worldId());
if (world == null) {
player.sendMessage(ChatColor.RED + "Your recorded return world is unavailable.");
return;
}
Location location = new Location(
world,
destination.x(),
destination.y(),
destination.z(),
destination.yaw(),
destination.pitch()
);
if (!isSafe(location)) {
player.sendMessage(ChatColor.RED + "Your recorded return location is not safe.");
return;
}
teleport(player, location);
}
private static void teleport(Player player, Location destination) {
float previousFallDistance = player.getFallDistance();
player.setFallDistance(0.0F);
if (!player.teleport(destination, PlayerTeleportEvent.TeleportCause.PLUGIN)) {
player.setFallDistance(previousFallDistance);
player.sendMessage(ChatColor.RED + "Pocket Base Keystone travel failed.");
}
}
private static boolean isSafe(Location location) {
World world = location.getWorld();
int x = location.getBlockX();
int y = location.getBlockY();
int z = location.getBlockZ();
if (world == null || y <= world.getMinHeight() || y + 1 >= world.getMaxHeight()) {
return false;
}
Block ground = world.getBlockAt(x, y - 1, z);
Block feet = world.getBlockAt(x, y, z);
Block head = world.getBlockAt(x, y + 1, z);
return feet.isPassable() && head.isPassable()
&& !feet.isLiquid() && !head.isLiquid()
&& !ground.isPassable() && !ground.isLiquid()
&& !RETURN_HAZARDS.contains(ground.getType())
&& !RETURN_HAZARDS.contains(feet.getType())
&& !RETURN_HAZARDS.contains(head.getType());
}
static boolean isKeystone(ItemStack item, NamespacedKey markerKey) {
if (item == null || item.getType() != Material.RECOVERY_COMPASS) {
return false;
}
ItemMeta metadata = item.getItemMeta();
if (metadata == null) {
return false;
}
Byte marker = metadata.getPersistentDataContainer().get(
markerKey, PersistentDataType.BYTE
);
return marker != null && marker == (byte) 1;
}
void registerRecipe() {
server.addRecipe(recipe(markerKey, createKeystone(markerKey)));
}
static ItemStack createKeystone(NamespacedKey markerKey) {
ItemStack item = new ItemStack(Material.RECOVERY_COMPASS);
decorateKeystone(item, markerKey);
return item;
}
static void decorateKeystone(ItemStack item, NamespacedKey markerKey) {
ItemMeta metadata = item.getItemMeta();
if (metadata == null) {
throw new IllegalStateException("Recovery Compass metadata is unavailable");
}
metadata.displayName(Component.text(
"Pocket Base Keystone", NamedTextColor.LIGHT_PURPLE
));
metadata.lore(List.of(
Component.text(
"Right-click to enter your Pocket Base.", NamedTextColor.GRAY
),
Component.text("Use it again inside to return.", NamedTextColor.GRAY)
));
metadata.getPersistentDataContainer().set(
markerKey, PersistentDataType.BYTE, (byte) 1
);
item.setItemMeta(metadata);
}
static RecipeSpec recipeSpecification() {
return new RecipeSpec(
"ONO", "NEN", "ONO", Material.OBSIDIAN,
Material.NETHERITE_INGOT, Material.ENDER_PEARL
);
}
static ShapedRecipe recipe(NamespacedKey key, ItemStack result) {
RecipeSpec specification = recipeSpecification();
ShapedRecipe recipe = new ShapedRecipe(key, result);
recipe.shape(specification.top(), specification.middle(), specification.bottom());
recipe.setIngredient('O', specification.obsidian());
recipe.setIngredient('N', specification.netherite());
recipe.setIngredient('E', specification.enderPearl());
return recipe;
}
record RecipeSpec(
String top,
String middle,
String bottom,
Material obsidian,
Material netherite,
Material enderPearl
) {
}
}
@@ -0,0 +1,211 @@
package games.dmg.spigotbase;
import java.io.IOException;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import org.bukkit.Location;
import org.bukkit.World;
final class PocketBaseManager {
private final PocketBaseStateManager states;
private final PocketBaseWorldService worlds;
private final PocketBasePolicy policy;
PocketBaseManager(
PocketBaseStateManager states,
PocketBaseWorldService worlds,
PluginSettingsProvider settings
) {
this.states = states;
this.worlds = worlds;
this.policy = new PocketBasePolicy(settings);
}
void loadWorlds() {
worlds.loadExisting(states.knownStates());
}
PocketBaseState state(UUID ownerId) {
return states.state(ownerId);
}
Map<UUID, PocketBaseState> knownStates() {
return states.knownStates();
}
PocketBaseState upgrade(UUID ownerId) throws IOException {
PocketBaseState current = states.state(ownerId);
int nextLevel = Math.addExact(current.level(), 1);
policy.size(nextLevel);
worlds.expand(ownerId, current.level(), nextLevel, current.biome());
return states.updateAndSave(ownerId, state -> state.withLevel(nextLevel));
}
PocketBaseState activateEntrance(
UUID ownerId,
PocketPortalLocation portal
) throws IOException {
PocketBaseState current = states.state(ownerId);
if (current.level() < 1) {
throw new IllegalStateException("Pocket Base I is still locked");
}
return states.updateAndSave(ownerId, state -> state.withEntrance(portal));
}
void deactivateEntrance(UUID ownerId) throws IOException {
PocketBaseState current = states.state(ownerId);
if (current.entrance().isPresent()) {
states.updateAndSave(ownerId, PocketBaseState::withoutEntrance);
}
}
PocketBaseState activateFlightPortal(
UUID ownerId,
PocketPortalLocation portal
) throws IOException {
PocketBaseState current = states.state(ownerId);
if (!current.flightUnlocked()) {
throw new IllegalStateException("Pocket Base flight is still locked");
}
return states.updateAndSave(ownerId, state -> state.withFlightPortal(portal));
}
void deactivateFlightPortal(UUID ownerId) throws IOException {
PocketBaseState current = states.state(ownerId);
if (current.flightPortal().isPresent()) {
states.updateAndSave(ownerId, PocketBaseState::withoutFlightPortal);
}
}
PocketBaseState activateReturnPortal(
UUID ownerId,
PocketPortalLocation portal
) throws IOException {
PocketBaseState current = states.state(ownerId);
if (current.level() < 1) {
throw new IllegalStateException("Pocket Base I is still locked");
}
return states.updateAndSave(ownerId, state -> state.withReturnPortal(portal));
}
PocketBaseState setBiome(UUID ownerId, PocketBaseBiome biome) throws IOException {
PocketBaseState current = states.state(ownerId);
if (current.level() < 1) {
throw new IllegalStateException("Pocket Base I is still locked");
}
if (current.biome() == biome) {
throw new IllegalArgumentException("Pocket Base already uses that biome");
}
try {
worlds.setBiome(ownerId, current.level(), biome);
return states.updateAndSave(ownerId, state -> state.withBiome(biome));
} catch (IOException | RuntimeException exception) {
try {
worlds.setBiome(ownerId, current.level(), current.biome());
} catch (RuntimeException rollbackFailure) {
exception.addSuppressed(rollbackFailure);
}
throw exception;
}
}
PocketBaseState unlockFlight(UUID ownerId) throws IOException {
PocketBaseState current = states.state(ownerId);
if (current.level() < 1) {
throw new IllegalStateException("Pocket Base I is still locked");
}
if (current.flightUnlocked()) {
return current;
}
return states.updateAndSave(ownerId, PocketBaseState::withFlightUnlocked);
}
PocketBaseState setFlightEnabled(UUID ownerId, boolean enabled) throws IOException {
PocketBaseState current = states.state(ownerId);
if (!current.flightUnlocked()) {
throw new IllegalStateException("Pocket Base flight is still locked");
}
if (current.flightEnabled() == enabled) {
return current;
}
return states.updateAndSave(
ownerId, state -> state.withFlightEnabled(enabled)
);
}
PocketBaseState setHostileMobSpawning(UUID ownerId, boolean enabled)
throws IOException {
PocketBaseState current = states.state(ownerId);
return setMobSpawning(
current,
enabled,
current.passiveMobSpawningEnabled(),
state -> state.withHostileMobSpawningEnabled(enabled)
);
}
PocketBaseState setPassiveMobSpawning(UUID ownerId, boolean enabled)
throws IOException {
PocketBaseState current = states.state(ownerId);
return setMobSpawning(
current,
current.hostileMobSpawningEnabled(),
enabled,
state -> state.withPassiveMobSpawningEnabled(enabled)
);
}
private PocketBaseState setMobSpawning(
PocketBaseState current,
boolean hostileEnabled,
boolean passiveEnabled,
java.util.function.UnaryOperator<PocketBaseState> update
) throws IOException {
if (current.level() < 1) {
throw new IllegalStateException("Pocket Base I is still locked");
}
UUID ownerId = current.ownerId();
worlds.setMobSpawning(ownerId, hostileEnabled, passiveEnabled);
try {
return states.updateAndSave(ownerId, update);
} catch (IOException | RuntimeException exception) {
try {
worlds.setMobSpawning(
ownerId,
current.hostileMobSpawningEnabled(),
current.passiveMobSpawningEnabled()
);
} catch (RuntimeException rollbackFailure) {
exception.addSuppressed(rollbackFailure);
}
throw exception;
}
}
Optional<UUID> ownerForPocketWorld(UUID worldId) {
return worlds.ownerForWorld(worldId);
}
Location pocketArrival(UUID ownerId) {
return worlds.arrival(ownerId);
}
PocketPortalLocation returnPortal(UUID ownerId) {
return states.state(ownerId).returnPortal().orElseGet(
() -> worlds.returnPortal(ownerId)
);
}
boolean returnPortalIsIntact(UUID ownerId) {
return worlds.returnPortalIsIntact(ownerId, returnPortal(ownerId));
}
World pocketWorld(UUID ownerId) {
return worlds.world(ownerId);
}
PocketBasePolicy policy() {
return policy;
}
}
@@ -0,0 +1,31 @@
package games.dmg.spigotbase;
public final class PocketBasePolicy {
private final PluginSettingsProvider settings;
public PocketBasePolicy(PluginSettings settings) {
this(new PluginSettingsProvider(settings));
}
public PocketBasePolicy(PluginSettingsProvider settings) {
this.settings = settings;
}
public int size(int level) {
if (level < 1) {
throw new IllegalArgumentException("Pocket Base level must be positive");
}
return Math.addExact(64, Math.multiplyExact(32, level - 1));
}
public int price(PocketBaseState state) {
return state.level() == 0
? settings.current().pocketBaseUnlockCost()
: settings.current().pocketBaseUpgradeCost();
}
public boolean contains(int level, int x, int z) {
int halfSize = size(level) / 2;
return x >= -halfSize && x < halfSize && z >= -halfSize && z < halfSize;
}
}
@@ -0,0 +1,163 @@
package games.dmg.spigotbase;
import java.util.Optional;
import java.util.UUID;
public record PocketBaseState(
UUID ownerId,
int level,
Optional<PocketPortalLocation> entrance,
Optional<PocketPortalLocation> returnPortal,
boolean hostileMobSpawningEnabled,
boolean passiveMobSpawningEnabled,
PocketBaseBiome biome,
boolean flightUnlocked,
Optional<PocketPortalLocation> flightPortal,
boolean flightEnabled
) {
public PocketBaseState {
if (ownerId == null) {
throw new IllegalArgumentException("owner ID is required");
}
if (level < 0) {
throw new IllegalArgumentException("Pocket Base level must not be negative");
}
entrance = entrance == null ? Optional.empty() : entrance;
returnPortal = returnPortal == null ? Optional.empty() : returnPortal;
flightPortal = flightPortal == null ? Optional.empty() : flightPortal;
biome = biome == null ? PocketBaseBiome.THE_VOID : biome;
if (level == 0 && (entrance.isPresent() || returnPortal.isPresent()
|| flightPortal.isPresent() || flightUnlocked || flightEnabled)) {
throw new IllegalArgumentException("a locked Pocket Base cannot have unlocks or portals");
}
if (!flightUnlocked && (flightPortal.isPresent() || flightEnabled)) {
throw new IllegalArgumentException(
"Pocket Base flight must be unlocked before it is enabled or shared"
);
}
}
public PocketBaseState(
UUID ownerId,
int level,
Optional<PocketPortalLocation> entrance,
Optional<PocketPortalLocation> returnPortal,
boolean hostileMobSpawningEnabled,
boolean passiveMobSpawningEnabled,
PocketBaseBiome biome,
boolean flightUnlocked
) {
this(
ownerId, level, entrance, returnPortal, hostileMobSpawningEnabled,
passiveMobSpawningEnabled, biome, flightUnlocked, Optional.empty(),
flightUnlocked
);
}
public PocketBaseState(
UUID ownerId,
int level,
Optional<PocketPortalLocation> entrance,
Optional<PocketPortalLocation> returnPortal,
boolean hostileMobSpawningEnabled,
boolean passiveMobSpawningEnabled,
PocketBaseBiome biome
) {
this(
ownerId, level, entrance, returnPortal, hostileMobSpawningEnabled,
passiveMobSpawningEnabled, biome, false, Optional.empty(), false
);
}
public PocketBaseState(
UUID ownerId,
int level,
Optional<PocketPortalLocation> entrance
) {
this(
ownerId, level, entrance, Optional.empty(), false, false,
PocketBaseBiome.THE_VOID, false, Optional.empty(), false
);
}
public static PocketBaseState locked(UUID ownerId) {
return new PocketBaseState(
ownerId, 0, Optional.empty(), Optional.empty(), false, false,
PocketBaseBiome.THE_VOID, false, Optional.empty(), false
);
}
public PocketBaseState withLevel(int newLevel) {
return copy(newLevel, entrance, returnPortal, hostileMobSpawningEnabled,
passiveMobSpawningEnabled, biome, flightUnlocked, flightPortal, flightEnabled);
}
public PocketBaseState withEntrance(PocketPortalLocation portal) {
return copy(level, Optional.of(portal), returnPortal, hostileMobSpawningEnabled,
passiveMobSpawningEnabled, biome, flightUnlocked, flightPortal, flightEnabled);
}
public PocketBaseState withoutEntrance() {
return copy(level, Optional.empty(), returnPortal, hostileMobSpawningEnabled,
passiveMobSpawningEnabled, biome, flightUnlocked, flightPortal, flightEnabled);
}
public PocketBaseState withReturnPortal(PocketPortalLocation portal) {
return copy(level, entrance, Optional.of(portal), hostileMobSpawningEnabled,
passiveMobSpawningEnabled, biome, flightUnlocked, flightPortal, flightEnabled);
}
public PocketBaseState withHostileMobSpawningEnabled(boolean enabled) {
return copy(level, entrance, returnPortal, enabled, passiveMobSpawningEnabled,
biome, flightUnlocked, flightPortal, flightEnabled);
}
public PocketBaseState withPassiveMobSpawningEnabled(boolean enabled) {
return copy(level, entrance, returnPortal, hostileMobSpawningEnabled, enabled,
biome, flightUnlocked, flightPortal, flightEnabled);
}
public PocketBaseState withBiome(PocketBaseBiome selectedBiome) {
return copy(level, entrance, returnPortal, hostileMobSpawningEnabled,
passiveMobSpawningEnabled, selectedBiome, flightUnlocked, flightPortal,
flightEnabled);
}
public PocketBaseState withFlightUnlocked() {
return copy(level, entrance, returnPortal, hostileMobSpawningEnabled,
passiveMobSpawningEnabled, biome, true, flightPortal, true);
}
public PocketBaseState withFlightEnabled(boolean enabled) {
return copy(level, entrance, returnPortal, hostileMobSpawningEnabled,
passiveMobSpawningEnabled, biome, flightUnlocked, flightPortal, enabled);
}
public PocketBaseState withFlightPortal(PocketPortalLocation portal) {
return copy(level, entrance, returnPortal, hostileMobSpawningEnabled,
passiveMobSpawningEnabled, biome, flightUnlocked, Optional.of(portal),
flightEnabled);
}
public PocketBaseState withoutFlightPortal() {
return copy(level, entrance, returnPortal, hostileMobSpawningEnabled,
passiveMobSpawningEnabled, biome, flightUnlocked, Optional.empty(), flightEnabled);
}
private PocketBaseState copy(
int newLevel,
Optional<PocketPortalLocation> newEntrance,
Optional<PocketPortalLocation> newReturnPortal,
boolean hostileEnabled,
boolean passiveEnabled,
PocketBaseBiome selectedBiome,
boolean unlockedFlight,
Optional<PocketPortalLocation> newFlightPortal,
boolean enabledFlight
) {
return new PocketBaseState(
ownerId, newLevel, newEntrance, newReturnPortal, hostileEnabled, passiveEnabled,
selectedBiome, unlockedFlight, newFlightPortal, enabledFlight
);
}
}
@@ -0,0 +1,41 @@
package games.dmg.spigotbase;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import java.util.function.UnaryOperator;
final class PocketBaseStateManager {
private final YamlPocketBaseRepository repository;
private final Map<UUID, PocketBaseState> states;
PocketBaseStateManager(YamlPocketBaseRepository repository) throws IOException {
this.repository = repository;
this.states = new HashMap<>(repository.load());
}
PocketBaseState state(UUID ownerId) {
return states.getOrDefault(ownerId, PocketBaseState.locked(ownerId));
}
Map<UUID, PocketBaseState> knownStates() {
return Map.copyOf(states);
}
PocketBaseState updateAndSave(
UUID ownerId,
UnaryOperator<PocketBaseState> operation
) throws IOException {
PocketBaseState updated = operation.apply(state(ownerId));
if (!updated.ownerId().equals(ownerId)) {
throw new IllegalArgumentException("Pocket Base owner ID cannot change");
}
Map<UUID, PocketBaseState> proposed = new HashMap<>(states);
proposed.put(ownerId, updated);
repository.save(proposed);
states.clear();
states.putAll(proposed);
return updated;
}
}
@@ -0,0 +1,203 @@
package games.dmg.spigotbase;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.function.Function;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.NamespacedKey;
import org.bukkit.Registry;
import org.bukkit.Server;
import org.bukkit.World;
import org.bukkit.WorldCreator;
import org.bukkit.block.Biome;
final class PocketBaseWorldService {
private static final int PLATFORM_Y = 64;
private static final String WORLD_PREFIX = "spigot_base_pocket_";
private final Server server;
private final PluginSettingsProvider settings;
private final PocketBasePolicy policy;
private final Function<PocketBaseBiome, Biome> biomeResolver;
private final Map<UUID, UUID> ownersByWorld = new HashMap<>();
PocketBaseWorldService(Server server, PluginSettingsProvider settings) {
this(server, settings, biome -> resolveBiome(server, biome));
}
PocketBaseWorldService(
Server server,
PluginSettingsProvider settings,
Function<PocketBaseBiome, Biome> biomeResolver
) {
this.server = server;
this.settings = settings;
this.policy = new PocketBasePolicy(settings);
this.biomeResolver = biomeResolver;
}
void loadExisting(Map<UUID, PocketBaseState> states) {
for (PocketBaseState state : states.values()) {
if (state.level() > 0) {
setBiome(state.ownerId(), state.level(), state.biome());
setMobSpawning(
state.ownerId(),
state.hostileMobSpawningEnabled(),
state.passiveMobSpawningEnabled()
);
}
}
}
World expand(UUID ownerId, int previousLevel, int newLevel) {
return expand(ownerId, previousLevel, newLevel, PocketBaseBiome.THE_VOID);
}
World expand(
UUID ownerId,
int previousLevel,
int newLevel,
PocketBaseBiome biome
) {
World world = ensureWorld(ownerId);
int newHalfSize = policy.size(newLevel) / 2;
int previousHalfSize = previousLevel == 0 ? 0 : policy.size(previousLevel) / 2;
for (int x = -newHalfSize; x < newHalfSize; x++) {
for (int z = -newHalfSize; z < newHalfSize; z++) {
boolean previouslyUnlocked = previousLevel > 0
&& x >= -previousHalfSize && x < previousHalfSize
&& z >= -previousHalfSize && z < previousHalfSize;
if (previouslyUnlocked) {
continue;
}
for (int y = PLATFORM_Y - 3; y < PLATFORM_Y; y++) {
world.getBlockAt(x, y, z).setType(Material.DIRT, false);
}
world.getBlockAt(x, PLATFORM_Y, z).setType(Material.GRASS_BLOCK, false);
}
}
applyBiome(world, previousLevel, newLevel, biome);
if (previousLevel == 0) {
createReturnPortal(world);
world.setSpawnLocation(0, PLATFORM_Y + 1, 5);
}
world.save();
return world;
}
Optional<UUID> ownerForWorld(UUID worldId) {
return Optional.ofNullable(ownersByWorld.get(worldId));
}
World world(UUID ownerId) {
return ensureWorld(ownerId);
}
void setMobSpawning(
UUID ownerId,
boolean hostileEnabled,
boolean passiveEnabled
) {
ensureWorld(ownerId).setSpawnFlags(hostileEnabled, passiveEnabled);
}
void setBiome(UUID ownerId, int level, PocketBaseBiome biome) {
World world = ensureWorld(ownerId);
applyBiome(world, 0, level, biome);
world.save();
}
Location arrival(UUID ownerId) {
return new Location(ensureWorld(ownerId), 0.5, PLATFORM_Y + 1, 5.5, 180.0f, 0.0f);
}
PocketPortalLocation returnPortal(UUID ownerId) {
World world = ensureWorld(ownerId);
return new PocketPortalLocation(
world.getUID(), world.getName(), -2, PLATFORM_Y + 1, 0, PocketPortalAxis.X
);
}
boolean returnPortalIsIntact(UUID ownerId, PocketPortalLocation portal) {
World world = ensureWorld(ownerId);
if (!portal.worldId().equals(world.getUID())) {
return false;
}
Material frame = Material.valueOf(settings.current().pocketBasePortalFrameMaterial());
return PocketPortalGeometry.frameBlocks(portal).stream()
.allMatch(position -> world.getBlockAt(
position.x(), position.y(), position.z()
).getType() == frame);
}
private World ensureWorld(UUID ownerId) {
String name = worldName(ownerId);
World world = server.getWorld(name);
boolean created = world == null;
if (created) {
WorldCreator creator = new WorldCreator(NamespacedKey.minecraft(name))
.environment(World.Environment.NORMAL)
.generator(new VoidPocketChunkGenerator())
.generateStructures(false);
world = server.createWorld(creator);
}
if (world == null) {
throw new IllegalStateException("Could not create Pocket Base world " + name);
}
if (created) {
world.setSpawnFlags(false, false);
}
ownersByWorld.put(world.getUID(), ownerId);
return world;
}
private void applyBiome(
World world,
int previousLevel,
int newLevel,
PocketBaseBiome selectedBiome
) {
int newHalfSize = policy.size(newLevel) / 2;
int previousHalfSize = previousLevel == 0 ? 0 : policy.size(previousLevel) / 2;
Biome biome = biomeResolver.apply(selectedBiome);
for (int x = -newHalfSize; x < newHalfSize; x += 4) {
for (int z = -newHalfSize; z < newHalfSize; z += 4) {
boolean previouslyUnlocked = previousLevel > 0
&& x >= -previousHalfSize && x < previousHalfSize
&& z >= -previousHalfSize && z < previousHalfSize;
if (previouslyUnlocked) {
continue;
}
for (int y = world.getMinHeight(); y < world.getMaxHeight(); y += 4) {
world.setBiome(x, y, z, biome);
}
}
}
}
private static Biome resolveBiome(Server server, PocketBaseBiome selectedBiome) {
Registry<Biome> biomeRegistry = server.getRegistry(Biome.class);
if (biomeRegistry == null) {
throw new IllegalStateException("Biome registry is unavailable");
}
return biomeRegistry.getOrThrow(
NamespacedKey.minecraft(selectedBiome.commandName())
);
}
private void createReturnPortal(World world) {
PocketPortalLocation portal = new PocketPortalLocation(
world.getUID(), world.getName(), -2, PLATFORM_Y + 1, 0, PocketPortalAxis.X
);
Material frame = Material.valueOf(settings.current().pocketBasePortalFrameMaterial());
for (BlockPosition position : PocketPortalGeometry.frameBlocks(portal)) {
world.getBlockAt(position.x(), position.y(), position.z()).setType(frame, false);
}
}
private static String worldName(UUID ownerId) {
return WORLD_PREFIX + ownerId.toString().replace("-", "");
}
}
@@ -0,0 +1,22 @@
package games.dmg.spigotbase;
import java.util.Locale;
import java.util.Optional;
public enum PocketBaseWorldType {
VOID,
NETHER,
OVERWORLD;
public String commandName() {
return name().toLowerCase(Locale.ROOT);
}
static Optional<PocketBaseWorldType> fromCommand(String value) {
try {
return Optional.of(valueOf(value.toUpperCase(Locale.ROOT)));
} catch (IllegalArgumentException exception) {
return Optional.empty();
}
}
}
@@ -0,0 +1,6 @@
package games.dmg.spigotbase;
public enum PocketPortalAxis {
X,
Z
}
@@ -0,0 +1,83 @@
package games.dmg.spigotbase;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import java.util.function.Predicate;
public final class PocketPortalGeometry {
private PocketPortalGeometry() {
}
public static Optional<PocketPortalLocation> findFrame(
BlockPosition clicked,
UUID worldId,
String worldName,
Predicate<BlockPosition> isFrameMaterial,
Predicate<BlockPosition> isOpenInterior
) {
for (PocketPortalAxis axis : PocketPortalAxis.values()) {
int clickedHorizontal = axis == PocketPortalAxis.X ? clicked.x() : clicked.z();
for (int horizontalOffset = 0; horizontalOffset < 4; horizontalOffset++) {
for (int verticalOffset = 0; verticalOffset < 5; verticalOffset++) {
int originHorizontal = clickedHorizontal - horizontalOffset;
int originY = clicked.y() - verticalOffset;
PocketPortalLocation candidate = axis == PocketPortalAxis.X
? new PocketPortalLocation(
worldId, worldName, originHorizontal, originY, clicked.z(), axis
)
: new PocketPortalLocation(
worldId, worldName, clicked.x(), originY, originHorizontal, axis
);
if (frameBlocks(candidate).stream().allMatch(isFrameMaterial)
&& interiorBlocks(candidate).stream().allMatch(isOpenInterior)) {
return Optional.of(candidate);
}
}
}
}
return Optional.empty();
}
public static List<BlockPosition> frameBlocks(PocketPortalLocation portal) {
List<BlockPosition> blocks = new ArrayList<>(14);
for (int horizontal = 0; horizontal < 4; horizontal++) {
blocks.add(position(portal, horizontal, 0));
blocks.add(position(portal, horizontal, 4));
}
for (int vertical = 1; vertical < 4; vertical++) {
blocks.add(position(portal, 0, vertical));
blocks.add(position(portal, 3, vertical));
}
return List.copyOf(blocks);
}
public static List<BlockPosition> interiorBlocks(PocketPortalLocation portal) {
List<BlockPosition> blocks = new ArrayList<>(6);
for (int horizontal = 1; horizontal < 3; horizontal++) {
for (int vertical = 1; vertical < 4; vertical++) {
blocks.add(position(portal, horizontal, vertical));
}
}
return List.copyOf(blocks);
}
public static boolean isFrame(PocketPortalLocation portal, BlockPosition position) {
return frameBlocks(portal).contains(position);
}
public static boolean isInterior(PocketPortalLocation portal, BlockPosition position) {
return interiorBlocks(portal).contains(position);
}
private static BlockPosition position(
PocketPortalLocation portal,
int horizontal,
int vertical
) {
return portal.axis() == PocketPortalAxis.X
? new BlockPosition(portal.x() + horizontal, portal.y() + vertical, portal.z())
: new BlockPosition(portal.x(), portal.y() + vertical, portal.z() + horizontal);
}
}
@@ -0,0 +1,24 @@
package games.dmg.spigotbase;
import java.util.UUID;
public record PocketPortalLocation(
UUID worldId,
String worldName,
int x,
int y,
int z,
PocketPortalAxis axis
) {
public PocketPortalLocation {
if (worldId == null) {
throw new IllegalArgumentException("portal world ID is required");
}
if (worldName == null || worldName.isBlank()) {
throw new IllegalArgumentException("portal world name is required");
}
if (axis == null) {
throw new IllegalArgumentException("portal axis is required");
}
}
}
@@ -6,5 +6,6 @@ public enum ProgressCounter {
DEEPSLATE,
OBSIDIAN,
PLACEMENTS,
TOTAL_PLACEMENTS,
BASE_BREAKS
}
@@ -16,11 +16,22 @@ final class SetBaseCommand implements CommandExecutor {
private final BaseStateManager stateManager;
private final BaseService baseService;
private final Clock clock;
private final PocketBaseController pocketBaseController;
SetBaseCommand(BaseStateManager stateManager, BaseService baseService, Clock clock) {
this(stateManager, baseService, clock, null);
}
SetBaseCommand(
BaseStateManager stateManager,
BaseService baseService,
Clock clock,
PocketBaseController pocketBaseController
) {
this.stateManager = stateManager;
this.baseService = baseService;
this.clock = clock;
this.pocketBaseController = pocketBaseController;
}
@Override
@@ -47,6 +58,10 @@ final class SetBaseCommand implements CommandExecutor {
player.sendMessage(ChatColor.RED + "Your current world is unavailable.");
return true;
}
if (pocketBaseController != null && pocketBaseController.isPocketWorld(world.getUID())) {
player.sendMessage(ChatColor.RED + "You cannot set your normal base inside a Pocket Base.");
return true;
}
BaseLocation base = new BaseLocation(
world.getUID(), world.getName(), location.getBlockX(), location.getBlockY(),
location.getBlockZ(), location.getYaw(), location.getPitch()
@@ -55,6 +70,9 @@ final class SetBaseCommand implements CommandExecutor {
player.getUniqueId(), player.getName(), current -> baseService.setBase(current, base, now)
);
stateManager.saveIfDirty();
if (pocketBaseController != null) {
pocketBaseController.deactivateForRelocation(player.getUniqueId());
}
player.sendMessage(ChatColor.GREEN + "Base set at " + base.x() + ", " + base.y() + ", " + base.z() + ".");
return true;
}
@@ -0,0 +1,38 @@
package games.dmg.spigotbase;
import org.bukkit.block.Block;
import org.bukkit.block.BlockFace;
import org.bukkit.block.data.Bisected;
import org.bukkit.block.data.type.Slab;
import org.bukkit.block.data.type.Stairs;
final class SpawnableBlockPolicy {
private SpawnableBlockPolicy() {
}
static boolean isCandidate(Block surface) {
if (!hasSpawnableTop(surface)) {
return false;
}
Block above = surface.getRelative(BlockFace.UP);
Block secondAbove = above.getRelative(BlockFace.UP);
return isEmpty(above)
&& isEmpty(secondAbove)
&& above.getLightFromBlocks() == 0
&& above.getLightFromSky() <= 7;
}
private static boolean hasSpawnableTop(Block block) {
if (block.getBlockData() instanceof Slab slab) {
return slab.getType() == Slab.Type.TOP || slab.getType() == Slab.Type.DOUBLE;
}
if (block.getBlockData() instanceof Stairs stairs) {
return stairs.getHalf() == Bisected.Half.TOP;
}
return block.getType().isOccluding();
}
private static boolean isEmpty(Block block) {
return block.isPassable() && !block.isLiquid();
}
}
@@ -0,0 +1,185 @@
package games.dmg.spigotbase;
import java.util.ArrayDeque;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import org.bukkit.Color;
import org.bukkit.Location;
import org.bukkit.Particle;
import org.bukkit.Server;
import org.bukkit.World;
import org.bukkit.block.Block;
import org.bukkit.entity.Player;
final class SpawnableOverlayController implements Runnable {
static final int HORIZONTAL_RADIUS = 16;
static final int VERTICAL_RADIUS = 8;
static final int WORK_BUDGET = 2_048;
static final int PARTICLE_BUDGET = 128;
private static final int PENDING_BUDGET = PARTICLE_BUDGET * 4;
private static final int WIDTH = HORIZONTAL_RADIUS * 2 + 1;
private static final int HEIGHT = VERTICAL_RADIUS * 2 + 1;
private static final int TOTAL_POSITIONS = WIDTH * WIDTH * HEIGHT;
private static final Particle.DustOptions RED_DUST =
new Particle.DustOptions(Color.RED, 1.0F);
private final Server server;
private final BaseStateManager stateManager;
private final BaseBoundsService boundsService;
private final PluginSettingsProvider settings;
private final Map<UUID, ScanState> scans = new HashMap<>();
SpawnableOverlayController(
Server server,
BaseStateManager stateManager,
BaseBoundsService boundsService,
PluginSettingsProvider settings
) {
this.server = server;
this.stateManager = stateManager;
this.boundsService = boundsService;
this.settings = settings;
}
@Override
public void run() {
Set<UUID> active = new HashSet<>();
for (Player player : server.getOnlinePlayers()) {
PlayerState playerState = stateManager.player(
player.getUniqueId(), player.getName()
);
if (!eligible(player, playerState)) {
scans.remove(player.getUniqueId());
continue;
}
active.add(player.getUniqueId());
updateAndRender(player, playerState);
}
scans.keySet().removeIf(playerId -> !active.contains(playerId));
}
private boolean eligible(Player player, PlayerState state) {
return state.spawnableOverlayEnabled()
&& state.totalBlocksPlaced() >= settings.current()
.spawnableOverlayUnlockPlacements()
&& state.base().isPresent()
&& player.getWorld().getUID().equals(state.base().orElseThrow().worldId());
}
private void updateAndRender(Player player, PlayerState state) {
Location location = player.getLocation();
BaseLocation base = state.base().orElseThrow();
int centerX = location.getBlockX();
int centerY = location.getBlockY() - 1;
int centerZ = location.getBlockZ();
ScanOrigin origin = new ScanOrigin(
player.getWorld().getUID(), centerX, centerY, centerZ,
base.x(), base.y(), base.z(), boundsService.radius(state)
);
ScanState scan = scans.get(player.getUniqueId());
if (scan == null || !scan.origin.near(origin)) {
scan = new ScanState(origin);
scans.put(player.getUniqueId(), scan);
}
scan(player.getWorld(), boundsService.area(state), scan);
render(player, scan);
}
private static void scan(World world, BaseArea base, ScanState scan) {
int work = Math.min(WORK_BUDGET, TOTAL_POSITIONS);
int minimumWorldY = world.getMinHeight();
int maximumWorldY = world.getMaxHeight();
UUID worldId = world.getUID();
for (int offset = 0; offset < work; offset++) {
int index = (scan.cursor + offset) % TOTAL_POSITIONS;
int verticalIndex = index % HEIGHT;
int horizontalIndex = index / HEIGHT;
int zIndex = horizontalIndex % WIDTH;
int xIndex = horizontalIndex / WIDTH;
int x = scan.origin.centerX + xIndex - HORIZONTAL_RADIUS;
int y = scan.origin.centerY + verticalIndex - VERTICAL_RADIUS;
int z = scan.origin.centerZ + zIndex - HORIZONTAL_RADIUS;
BlockPosition position = new BlockPosition(x, y, z);
scan.spawnable.remove(position);
int deltaX = x - scan.origin.centerX;
int deltaZ = z - scan.origin.centerZ;
if (deltaX * deltaX + deltaZ * deltaZ
> HORIZONTAL_RADIUS * HORIZONTAL_RADIUS
|| y < minimumWorldY
|| y >= maximumWorldY
|| !base.contains(worldId, x, y, z)) {
continue;
}
Block surface = world.getBlockAt(x, y, z);
if (SpawnableBlockPolicy.isCandidate(surface)) {
scan.spawnable.add(position);
if (scan.pending.size() < PENDING_BUDGET) {
scan.pending.addLast(position);
}
}
}
scan.cursor = (scan.cursor + work) % TOTAL_POSITIONS;
}
private static void render(Player player, ScanState scan) {
for (int attempted = 0; attempted < PARTICLE_BUDGET; attempted++) {
BlockPosition position = scan.pending.pollFirst();
if (position == null) {
return;
}
if (!scan.spawnable.contains(position)) {
continue;
}
player.spawnParticle(
Particle.DUST,
new Location(
player.getWorld(),
position.x + 0.5,
position.y + 1.05,
position.z + 0.5
),
1,
RED_DUST
);
}
}
private record BlockPosition(int x, int y, int z) {
}
private record ScanOrigin(
UUID worldId,
int centerX,
int centerY,
int centerZ,
int baseX,
int baseY,
int baseZ,
int baseRadius
) {
boolean near(ScanOrigin other) {
return worldId.equals(other.worldId)
&& Math.abs(centerX - other.centerX) <= 4
&& Math.abs(centerY - other.centerY) <= 4
&& Math.abs(centerZ - other.centerZ) <= 4
&& baseX == other.baseX
&& baseY == other.baseY
&& baseZ == other.baseZ
&& baseRadius == other.baseRadius;
}
}
private static final class ScanState {
private final ScanOrigin origin;
private final Set<BlockPosition> spawnable = new HashSet<>();
private final ArrayDeque<BlockPosition> pending = new ArrayDeque<>();
private int cursor;
private ScanState(ScanOrigin origin) {
this.origin = origin;
}
}
}
@@ -0,0 +1,19 @@
package games.dmg.spigotbase;
final class SpawnableOverlayProgressionService {
private final PluginSettingsProvider settings;
SpawnableOverlayProgressionService(PluginSettingsProvider settings) {
this.settings = settings;
}
SpawnableOverlayProgressionUpdate recordPlacement(PlayerState player) {
long previous = player.totalBlocksPlaced();
long updated = previous == Long.MAX_VALUE ? Long.MAX_VALUE : previous + 1;
int threshold = settings.current().spawnableOverlayUnlockPlacements();
return new SpawnableOverlayProgressionUpdate(
player.withTotalBlocksPlaced(updated),
previous < threshold && updated >= threshold
);
}
}
@@ -0,0 +1,9 @@
package games.dmg.spigotbase;
record SpawnableOverlayProgressionUpdate(PlayerState player, boolean unlocked) {
SpawnableOverlayProgressionUpdate {
if (player == null) {
throw new IllegalArgumentException("player is required");
}
}
}
@@ -5,6 +5,7 @@ import java.time.Clock;
import java.util.Map;
import java.util.Objects;
import java.util.logging.Level;
import org.bukkit.NamespacedKey;
import org.bukkit.command.PluginCommand;
import org.bukkit.plugin.java.JavaPlugin;
@@ -14,6 +15,9 @@ public final class SpigotBasePlugin extends JavaPlugin {
private BaseProgressListener progressListener;
private BaseFlightController flightController;
private BaseTeleportManager teleportManager;
private PocketBaseManager pocketBaseManager;
private PocketBaseController pocketBaseController;
private PocketBaseKeystoneService keystoneService;
@Override
public void onEnable() {
@@ -27,7 +31,29 @@ public final class SpigotBasePlugin extends JavaPlugin {
new YamlBaseStateRepository(getDataFolder().toPath().resolve("state.yml")),
getLogger()
);
} catch (IllegalArgumentException | IOException exception) {
PocketBaseStateManager pocketStates = new PocketBaseStateManager(
new YamlPocketBaseRepository(
getDataFolder().toPath().resolve("pocket-bases.yml")
)
);
pocketBaseManager = new PocketBaseManager(
pocketStates,
new PocketBaseWorldService(getServer(), settingsProvider),
settingsProvider
);
pocketBaseManager.loadWorlds();
NamespacedKey keystoneKey = new NamespacedKey(this, "pocket_base_keystone");
keystoneService = new PocketBaseKeystoneService(
getServer(),
pocketBaseManager,
new KeystoneReturnStore(new YamlKeystoneReturnRepository(
getDataFolder().toPath().resolve("keystone-returns.yml")
)),
keystoneKey,
getLogger()
);
keystoneService.registerRecipe();
} catch (RuntimeException | IOException exception) {
getLogger().log(Level.SEVERE, "Could not initialize Spigot Base", exception);
getServer().getPluginManager().disablePlugin(this);
return;
@@ -46,11 +72,13 @@ public final class SpigotBasePlugin extends JavaPlugin {
progressionService,
secondaryProgressionService,
teleportProgressionService,
new SpawnableOverlayProgressionService(settingsProvider),
boundsService,
settingsProvider
);
flightController = new BaseFlightController(
getServer(), stateManager, secondaryProgressionService, boundsService, settingsProvider
getServer(), stateManager, secondaryProgressionService, boundsService,
settingsProvider, pocketBaseManager, getLogger()
);
VisitorPolicy visitorPolicy = new VisitorPolicy();
teleportManager = new BaseTeleportManager(
@@ -61,30 +89,44 @@ public final class SpigotBasePlugin extends JavaPlugin {
new SafeBaseDestination(),
Clock.systemUTC()
);
pocketBaseController = new PocketBaseController(
getServer(),
stateManager,
boundsService,
pocketBaseManager,
settingsProvider,
getLogger(),
flightController
);
getServer().getPluginManager().registerEvents(progressListener, this);
getServer().getPluginManager().registerEvents(flightController, this);
getServer().getPluginManager().registerEvents(teleportManager, this);
getServer().getPluginManager().registerEvents(pocketBaseController, this);
getServer().getPluginManager().registerEvents(keystoneService, this);
command("setbase").setExecutor(new SetBaseCommand(stateManager, baseService, Clock.systemUTC()));
command("base").setExecutor(
new BaseCommand(teleportManager, stateManager, visitorPolicy, settingsProvider)
command("setbase").setExecutor(new SetBaseCommand(
stateManager, baseService, Clock.systemUTC(), pocketBaseController
));
command("base").setExecutor(new BaseCommand(teleportManager));
BaseSettingsCommand settingsCommand = new BaseSettingsCommand(
stateManager,
settingsProvider,
flightController,
pocketBaseManager
);
command("baseprogress").setExecutor(
new BaseProgressCommand(stateManager, settingsProvider)
);
command("basenavigation").setExecutor(new BaseNavigationCommand(stateManager));
command("baseflight").setExecutor(new BaseFlightCommand(stateManager, flightController));
command("basevisitors").setExecutor(new BaseVisitorsCommand(stateManager));
command("basesettings").setExecutor(settingsCommand);
command("basesettings").setTabCompleter(settingsCommand);
GoToBaseCommand goToBaseCommand = new GoToBaseCommand(stateManager, teleportManager);
command("gotobase").setExecutor(goToBaseCommand);
command("gotobase").setTabCompleter(goToBaseCommand);
command("baseadmin").setExecutor(
new BaseAdminCommand(
this,
stateManager,
new AdminProgressionService(settingsProvider),
settingsProvider
)
BaseAdminCommand adminCommand = new BaseAdminCommand(
this,
stateManager,
new AdminProgressionService(settingsProvider),
settingsProvider
);
command("baseadmin").setExecutor(adminCommand);
command("baseadmin").setTabCompleter(adminCommand);
getServer().getScheduler().runTaskTimer(
this,
@@ -92,7 +134,22 @@ public final class SpigotBasePlugin extends JavaPlugin {
10L,
10L
);
getServer().getScheduler().runTaskTimer(
this,
new BaseBorderController(getServer(), stateManager, boundsService),
10L,
10L
);
getServer().getScheduler().runTaskTimer(
this,
new SpawnableOverlayController(
getServer(), stateManager, boundsService, settingsProvider
),
10L,
10L
);
getServer().getScheduler().runTaskTimer(this, flightController, 5L, 5L);
getServer().getScheduler().runTaskTimer(this, pocketBaseController, 5L, 5L);
getServer().getScheduler().runTaskTimer(this, stateManager::saveIfDirty, 600L, 600L);
getLogger().info("Spigot Base enabled.");
}
@@ -0,0 +1,86 @@
package games.dmg.spigotbase;
import java.util.HashMap;
import java.util.Iterator;
import java.util.Map;
import java.util.UUID;
import org.bukkit.Location;
import org.bukkit.Particle;
import org.bukkit.plugin.Plugin;
import org.bukkit.scheduler.BukkitTask;
/** Cosmetic, main-thread effects; never controls teleport timing or eligibility. */
final class TeleportParticles {
private final Plugin plugin;
private final Map<UUID, Effect> effects = new HashMap<>();
private BukkitTask task;
TeleportParticles(Plugin plugin) {
this.plugin = plugin;
}
void start(UUID playerId, Location origin, int seconds) {
effects.put(playerId, new Effect(origin.clone(), Math.max(1L, seconds * 10L)));
if (task == null) {
task = plugin.getServer().getScheduler().runTaskTimer(plugin, this::tick, 0L, 2L);
}
}
void complete(UUID playerId) {
Effect effect = effects.get(playerId);
if (effect != null) {
effect.fading = true;
effect.age = 0;
}
}
void cancel(UUID playerId) {
effects.remove(playerId);
stopIfEmpty();
}
void clear() {
effects.clear();
stopIfEmpty();
}
private void tick() {
Iterator<Effect> iterator = effects.values().iterator();
while (iterator.hasNext()) {
Effect effect = iterator.next();
int count = effect.fading
? Math.max(1, 12 - (int) (effect.age * 12 / 20))
: 2 + (int) (10 * Math.min(1.0, (double) effect.age / effect.duration));
double angle = effect.age * 0.3;
Location point = effect.origin.clone().add(
Math.cos(angle) * 0.65, 1.0 + Math.sin(angle * 0.5) * 0.45,
Math.sin(angle) * 0.65
);
point.getWorld().spawnParticle(Particle.END_ROD, point, count, 0.3, 0.45, 0.3, 0.01);
effect.age++;
if (effect.fading && effect.age >= 20) {
iterator.remove();
}
}
stopIfEmpty();
}
private void stopIfEmpty() {
if (effects.isEmpty() && task != null) {
task.cancel();
task = null;
}
}
private static final class Effect {
private final Location origin;
private final long duration;
private long age;
private boolean fading;
private Effect(Location origin, long duration) {
this.origin = origin;
this.duration = duration;
}
}
}
@@ -25,6 +25,13 @@ public final class TeleportPolicy {
};
}
public Duration visitorWarmup(PlayerState owner) {
Duration warmup = warmup(owner);
return warmup.compareTo(Duration.ofSeconds(1)) < 0
? Duration.ofSeconds(1)
: warmup;
}
public Duration cooldown(PlayerState player) {
return switch (player.cooldownLevel()) {
case 0 -> Duration.ofSeconds(settings.current().initialTeleportCooldownSeconds());
@@ -0,0 +1,35 @@
package games.dmg.spigotbase;
import org.bukkit.generator.ChunkGenerator;
final class VoidPocketChunkGenerator extends ChunkGenerator {
@Override
public boolean shouldGenerateNoise() {
return false;
}
@Override
public boolean shouldGenerateSurface() {
return false;
}
@Override
public boolean shouldGenerateCaves() {
return false;
}
@Override
public boolean shouldGenerateDecorations() {
return false;
}
@Override
public boolean shouldGenerateMobs() {
return false;
}
@Override
public boolean shouldGenerateStructures() {
return false;
}
}
@@ -127,7 +127,10 @@ public final class YamlBaseStateRepository {
yaml.getBoolean(path + ".visitors-enabled", false),
instant(yaml, path + ".last-base-set-epoch-millis"),
instant(yaml, path + ".last-base-teleport-epoch-millis"),
loadVisitorCooldowns(yaml, path + ".visitor-cooldowns")
loadVisitorCooldowns(yaml, path + ".visitor-cooldowns"),
yaml.getBoolean(path + ".border-enabled", false),
totalPlacementCount(yaml, path),
yaml.getBoolean(path + ".spawnable-overlay-enabled", false)
);
players.put(playerId, player);
} catch (IllegalArgumentException ignored) {
@@ -194,6 +197,13 @@ public final class YamlBaseStateRepository {
return yaml.getLong(path);
}
private static long totalPlacementCount(YamlConfiguration yaml, String playerPath) {
return Math.max(
count(yaml, playerPath + ".total-blocks-placed"),
count(yaml, playerPath + ".blocks-placed-in-base")
);
}
private static Optional<Instant> instant(YamlConfiguration yaml, String path) {
if (!yaml.isLong(path) && !yaml.isInt(path)) {
return Optional.empty();
@@ -222,6 +232,9 @@ public final class YamlBaseStateRepository {
yaml.set(path + ".flight-enabled", player.flightEnabled());
yaml.set(path + ".boss-bar-enabled", player.bossBarEnabled());
yaml.set(path + ".visitors-enabled", player.visitorsEnabled());
yaml.set(path + ".border-enabled", player.borderEnabled());
yaml.set(path + ".total-blocks-placed", player.totalBlocksPlaced());
yaml.set(path + ".spawnable-overlay-enabled", player.spawnableOverlayEnabled());
yaml.set(
path + ".last-base-set-epoch-millis",
player.lastBaseSet().map(Instant::toEpochMilli).orElse(null)
@@ -0,0 +1,94 @@
package games.dmg.spigotbase;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import org.bukkit.configuration.ConfigurationSection;
import org.bukkit.configuration.InvalidConfigurationException;
import org.bukkit.configuration.file.YamlConfiguration;
public final class YamlKeystoneReturnRepository {
private final Path stateFile;
public YamlKeystoneReturnRepository(Path stateFile) {
this.stateFile = stateFile;
}
public Map<UUID, KeystoneReturnLocation> load() throws IOException {
if (!Files.exists(stateFile)) {
return Map.of();
}
YamlConfiguration yaml = new YamlConfiguration();
try {
yaml.load(stateFile.toFile());
} catch (InvalidConfigurationException exception) {
throw new IOException("Pocket Base Keystone returns are not valid YAML", exception);
}
Map<UUID, KeystoneReturnLocation> destinations = new HashMap<>();
ConfigurationSection players = yaml.getConfigurationSection("players");
if (players == null) {
return destinations;
}
for (String key : players.getKeys(false)) {
try {
String path = "players." + key;
String worldId = yaml.getString(path + ".world-id");
String worldName = yaml.getString(path + ".world-name");
if (worldId == null || worldName == null) {
continue;
}
destinations.put(UUID.fromString(key), new KeystoneReturnLocation(
UUID.fromString(worldId),
worldName,
yaml.getDouble(path + ".x"),
yaml.getDouble(path + ".y"),
yaml.getDouble(path + ".z"),
(float) yaml.getDouble(path + ".yaw"),
(float) yaml.getDouble(path + ".pitch")
));
} catch (IllegalArgumentException ignored) {
// Invalid records do not provide a return destination.
}
}
return destinations;
}
public void save(Map<UUID, KeystoneReturnLocation> destinations) throws IOException {
Path parent = stateFile.toAbsolutePath().getParent();
if (parent != null) {
Files.createDirectories(parent);
}
YamlConfiguration yaml = new YamlConfiguration();
for (Map.Entry<UUID, KeystoneReturnLocation> entry : destinations.entrySet()) {
String path = "players." + entry.getKey();
KeystoneReturnLocation destination = entry.getValue();
yaml.set(path + ".world-id", destination.worldId().toString());
yaml.set(path + ".world-name", destination.worldName());
yaml.set(path + ".x", destination.x());
yaml.set(path + ".y", destination.y());
yaml.set(path + ".z", destination.z());
yaml.set(path + ".yaw", destination.yaw());
yaml.set(path + ".pitch", destination.pitch());
}
Path temporary = Files.createTempFile(parent, "spigot-base-keystone-", ".yml");
try {
yaml.save(temporary.toFile());
try {
Files.move(
temporary,
stateFile,
StandardCopyOption.REPLACE_EXISTING,
StandardCopyOption.ATOMIC_MOVE
);
} catch (IOException atomicMoveFailure) {
Files.move(temporary, stateFile, StandardCopyOption.REPLACE_EXISTING);
}
} finally {
Files.deleteIfExists(temporary);
}
}
}
@@ -0,0 +1,171 @@
package games.dmg.spigotbase;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import org.bukkit.configuration.ConfigurationSection;
import org.bukkit.configuration.InvalidConfigurationException;
import org.bukkit.configuration.file.YamlConfiguration;
public final class YamlPocketBaseRepository {
private final Path stateFile;
public YamlPocketBaseRepository(Path stateFile) {
this.stateFile = stateFile;
}
public Map<UUID, PocketBaseState> load() throws IOException {
if (!Files.exists(stateFile)) {
return Map.of();
}
YamlConfiguration yaml = new YamlConfiguration();
try {
yaml.load(stateFile.toFile());
} catch (InvalidConfigurationException exception) {
throw new IOException("Pocket Base state is not valid YAML", exception);
}
Map<UUID, PocketBaseState> states = new HashMap<>();
ConfigurationSection owners = yaml.getConfigurationSection("owners");
if (owners == null) {
return states;
}
for (String key : owners.getKeys(false)) {
try {
UUID ownerId = UUID.fromString(key);
String path = "owners." + key;
int level = yaml.getInt(path + ".level", 0);
boolean flightUnlocked = yaml.getBoolean(
path + ".flight-unlocked", false
);
PocketBaseState state = new PocketBaseState(
ownerId,
level,
loadPortal(yaml, path + ".entrance"),
loadPortal(yaml, path + ".return-portal"),
loadMobPreference(yaml, path, "hostile-mob-spawning-enabled"),
loadMobPreference(yaml, path, "passive-mob-spawning-enabled"),
loadBiome(yaml, path),
flightUnlocked,
loadPortal(yaml, path + ".flight-portal"),
yaml.getBoolean(path + ".flight-enabled", flightUnlocked)
);
states.put(ownerId, state);
} catch (IllegalArgumentException ignored) {
// Invalid records grant no Pocket Base progression.
}
}
return states;
}
public void save(Map<UUID, PocketBaseState> states) throws IOException {
Path parent = stateFile.toAbsolutePath().getParent();
if (parent != null) {
Files.createDirectories(parent);
}
YamlConfiguration yaml = new YamlConfiguration();
for (PocketBaseState state : states.values()) {
String path = "owners." + state.ownerId();
yaml.set(path + ".level", state.level());
yaml.set(
path + ".hostile-mob-spawning-enabled",
state.hostileMobSpawningEnabled()
);
yaml.set(
path + ".passive-mob-spawning-enabled",
state.passiveMobSpawningEnabled()
);
yaml.set(path + ".flight-unlocked", state.flightUnlocked());
yaml.set(path + ".flight-enabled", state.flightEnabled());
yaml.set(path + ".world-type", state.biome().worldType().commandName());
yaml.set(path + ".biome", state.biome().commandName());
state.entrance().ifPresent(portal -> savePortal(yaml, path + ".entrance", portal));
state.returnPortal().ifPresent(portal ->
savePortal(yaml, path + ".return-portal", portal)
);
state.flightPortal().ifPresent(portal ->
savePortal(yaml, path + ".flight-portal", portal)
);
}
Path temporary = Files.createTempFile(parent, "spigot-base-pocket-", ".yml");
try {
yaml.save(temporary.toFile());
try {
Files.move(
temporary,
stateFile,
StandardCopyOption.REPLACE_EXISTING,
StandardCopyOption.ATOMIC_MOVE
);
} catch (IOException atomicMoveFailure) {
Files.move(temporary, stateFile, StandardCopyOption.REPLACE_EXISTING);
}
} finally {
Files.deleteIfExists(temporary);
}
}
private static boolean loadMobPreference(
YamlConfiguration yaml,
String path,
String categoryKey
) {
return yaml.getBoolean(
path + "." + categoryKey,
yaml.getBoolean(path + ".mob-spawning-enabled", false)
);
}
private static PocketBaseBiome loadBiome(YamlConfiguration yaml, String path) {
String worldTypeName = yaml.getString(path + ".world-type");
String biomeName = yaml.getString(path + ".biome");
if (worldTypeName == null || biomeName == null) {
return PocketBaseBiome.THE_VOID;
}
return PocketBaseWorldType.fromCommand(worldTypeName)
.flatMap(worldType -> PocketBaseBiome.fromCommand(worldType, biomeName))
.orElse(PocketBaseBiome.THE_VOID);
}
private static Optional<PocketPortalLocation> loadPortal(
YamlConfiguration yaml,
String path
) {
if (!yaml.isConfigurationSection(path)) {
return Optional.empty();
}
String worldId = yaml.getString(path + ".world-id");
String worldName = yaml.getString(path + ".world-name");
String axis = yaml.getString(path + ".axis");
if (worldId == null || worldName == null || axis == null
|| !yaml.isInt(path + ".x") || !yaml.isInt(path + ".y")
|| !yaml.isInt(path + ".z")) {
throw new IllegalArgumentException("invalid Pocket Base portal");
}
return Optional.of(new PocketPortalLocation(
UUID.fromString(worldId),
worldName,
yaml.getInt(path + ".x"),
yaml.getInt(path + ".y"),
yaml.getInt(path + ".z"),
PocketPortalAxis.valueOf(axis)
));
}
private static void savePortal(
YamlConfiguration yaml,
String path,
PocketPortalLocation portal
) {
yaml.set(path + ".world-id", portal.worldId().toString());
yaml.set(path + ".world-name", portal.worldName());
yaml.set(path + ".x", portal.x());
yaml.set(path + ".y", portal.y());
yaml.set(path + ".z", portal.z());
yaml.set(path + ".axis", portal.axis().name());
}
}
+11
View File
@@ -46,6 +46,17 @@ fourth-teleport-cooldown-seconds: 1800
visitor-unlock-diamond-cost: 128
visitor-currency-material: DIAMOND
# Pocket Base
pocket-base-unlock-cost: 64
pocket-base-upgrade-cost: 16
pocket-base-currency-material: DIAMOND_BLOCK
pocket-base-biome-change-cost: 16
pocket-base-biome-currency-material: NETHERITE_BLOCK
pocket-base-portal-frame-material: DIAMOND_BLOCK
# Spawnable block overlay
spawnable-overlay-unlock-placements: 250
# Progress and notification presentation
cooldown-excluded-materials: []
boss-bar-duration-ticks: 60
+10 -14
View File
@@ -8,27 +8,23 @@ commands:
setbase:
description: Set your unlocked personal base.
usage: /setbase
aliases: [sethome]
base:
description: Teleport to or upgrade your base.
usage: /base [upgrade]
basenavigation:
description: Toggle particle navigation toward your base.
usage: /basenavigation
baseflight:
description: Toggle flight within your base.
usage: /baseflight
basevisitors:
description: Toggle visitor access to your base.
usage: /basevisitors
description: Teleport to your base.
usage: /base
aliases: [home]
gotobase:
description: Visit an available player base.
usage: /gotobase <player>
baseprogress:
description: View progression or toggle progress boss bars.
usage: /baseprogress [bossbar]
aliases: [visit]
basesettings:
description: View progression, upgrade, and manage base settings.
usage: /basesettings [ui|status|upgrade|pocket|visitors|navigation|flight|border|spawnable|bossbar]
aliases: [homesettings]
baseadmin:
description: Administer Spigot Base.
usage: /baseadmin <subcommand>
aliases: [homeadmin]
permission: spigotbase.admin
permissions:
spigotbase.admin:
@@ -11,6 +11,19 @@ final class AdminProgressCounterTest {
new PluginSettingsProvider(PluginSettings.from(Map.of()))
);
@Test
void settingTotalPlacementCounterUnlocksSpawnableOverlay() {
PlayerState player = PlayerState.newPlayer(UUID.randomUUID(), "Alex");
PlayerState updated = service.setProgress(
player,
ProgressCounter.TOTAL_PLACEMENTS,
250
);
assertEquals(250, updated.totalBlocksPlaced());
}
@Test
void settingPlacementCounterEvaluatesBaseAndWarmupTiers() {
PlayerState player = PlayerState.newPlayer(UUID.randomUUID(), "Alex");
@@ -4,6 +4,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.time.Instant;
import java.util.UUID;
import org.junit.jupiter.api.Test;
@@ -36,6 +37,40 @@ final class AdminProgressionServiceTest {
assertFalse(updated.visitorsEnabled());
}
@Test
void loweringBaseDisablesBorderVisualization() {
UUID playerId = UUID.randomUUID();
PlayerState player = PlayerState.newPlayer(playerId, "Alex")
.withAdministrativeLevels(1, 0, 0, 0, 0, false, false, false)
.withBase(
new BaseLocation(UUID.randomUUID(), "world", 0, 64, 0, 0, 0),
Instant.EPOCH
)
.withBorderEnabled(true);
PlayerState updated = service.setLevel(player, ProgressionPath.BASE, 0);
assertFalse(updated.borderEnabled());
}
@Test
void completeResetClearsSpawnableOverlayProgressAndPreference() {
UUID playerId = UUID.randomUUID();
PlayerState player = PlayerState.newPlayer(playerId, "Alex")
.withAdministrativeLevels(1, 0, 0, 0, 0, false, false, false)
.withBase(
new BaseLocation(UUID.randomUUID(), "world", 0, 64, 0, 0, 0),
Instant.EPOCH
)
.withTotalBlocksPlaced(250)
.withSpawnableOverlayEnabled(true);
PlayerState updated = service.resetPath(player, ProgressionPath.BASE);
assertEquals(0, updated.totalBlocksPlaced());
assertFalse(updated.spawnableOverlayEnabled());
}
@Test
void rejectsUnknownLevel() {
PlayerState player = PlayerState.newPlayer(UUID.randomUUID(), "Alex");
@@ -0,0 +1,323 @@
package games.dmg.spigotbase;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.UnaryOperator;
import org.bukkit.Bukkit;
import org.bukkit.ChatColor;
import org.bukkit.command.CommandSender;
import org.bukkit.configuration.file.FileConfiguration;
import org.bukkit.plugin.java.JavaPlugin;
import org.junit.jupiter.api.Test;
import org.mockito.MockedStatic;
final class BaseAdminCommandTest {
@Test
void adminCanEnableUnlockedSpawnableOverlayForPlayer() {
CommandSender sender = mock(CommandSender.class);
when(sender.hasPermission("spigotbase.admin")).thenReturn(true);
UUID playerId = UUID.randomUUID();
PlayerState target = PlayerState.newPlayer(playerId, "Builder")
.withAdministrativeLevels(1, 0, 0, 0, 0, false, false, false)
.withBase(
new BaseLocation(UUID.randomUUID(), "world", 0, 64, 0, 0, 0),
Instant.EPOCH
)
.withTotalBlocksPlaced(250);
AtomicReference<PlayerState> updated = new AtomicReference<>();
BaseStateManager stateManager = mock(BaseStateManager.class);
when(stateManager.findByName("Builder")).thenReturn(Optional.of(target));
when(stateManager.update(any(), anyString(), any())).thenAnswer(invocation -> {
@SuppressWarnings("unchecked")
UnaryOperator<PlayerState> operation = invocation.getArgument(2);
PlayerState result = operation.apply(target);
updated.set(result);
return result;
});
BaseAdminCommand command = new BaseAdminCommand(
mock(JavaPlugin.class),
stateManager,
new AdminProgressionService(
new PluginSettingsProvider(PluginSettings.from(Map.of()))
),
new PluginSettingsProvider(PluginSettings.from(Map.of()))
);
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class)) {
bukkit.when(() -> Bukkit.getPlayerExact("Builder")).thenReturn(null);
command.onCommand(sender, null, "baseadmin",
new String[] {"setsetting", "Builder", "spawnable", "enable"});
}
assertTrue(updated.get().spawnableOverlayEnabled());
}
@Test
void invalidPathShowsFriendlyUsage() {
CommandSender sender = mock(CommandSender.class);
when(sender.hasPermission("spigotbase.admin")).thenReturn(true);
PlayerState target = PlayerState.newPlayer(UUID.randomUUID(), "Builder");
BaseStateManager stateManager = mock(BaseStateManager.class);
when(stateManager.findByName("Builder")).thenReturn(Optional.of(target));
BaseAdminCommand command = new BaseAdminCommand(
mock(JavaPlugin.class),
stateManager,
mock(AdminProgressionService.class),
new PluginSettingsProvider(PluginSettings.from(Map.of()))
);
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class)) {
bukkit.when(() -> Bukkit.getPlayerExact("Builder")).thenReturn(null);
command.onCommand(sender, null, "baseadmin",
new String[] {"setlevel", "Builder", "unknown", "1"});
}
verify(sender).sendMessage(ChatColor.RED
+ "Usage: /baseadmin setlevel <player> <base|size|flight|warmup|cooldown> <level>");
verify(stateManager, never()).update(any(), anyString(), any());
}
@Test
void autocompletesValidLevelsAndResetConfirmation() {
CommandSender sender = mock(CommandSender.class);
when(sender.hasPermission("spigotbase.admin")).thenReturn(true);
BaseAdminCommand command = new BaseAdminCommand(
mock(JavaPlugin.class),
mock(BaseStateManager.class),
mock(AdminProgressionService.class),
new PluginSettingsProvider(PluginSettings.from(Map.of()))
);
assertEquals(
List.of("0", "1", "2", "3", "4"),
command.onTabComplete(sender, null, "baseadmin",
new String[] {"setlevel", "Builder", "base", ""})
);
assertEquals(
List.of("0", "1", "2", "3"),
command.onTabComplete(sender, null, "baseadmin",
new String[] {"setlevel", "Builder", "flight", ""})
);
assertEquals(
List.of("confirm"),
command.onTabComplete(sender, null, "baseadmin",
new String[] {"reset", "Builder", "all", ""})
);
}
@Test
void autocompletesCommandSpecificOptions() {
CommandSender sender = mock(CommandSender.class);
when(sender.hasPermission("spigotbase.admin")).thenReturn(true);
BaseAdminCommand command = new BaseAdminCommand(
mock(JavaPlugin.class),
mock(BaseStateManager.class),
mock(AdminProgressionService.class),
new PluginSettingsProvider(PluginSettings.from(Map.of()))
);
assertEquals(
List.of("base", "size", "flight", "warmup", "cooldown"),
command.onTabComplete(sender, null, "baseadmin",
new String[] {"setlevel", "Builder", ""})
);
assertEquals(
List.of(
"grass_dirt", "stone", "deepslate", "obsidian", "placements",
"total_placements", "base_breaks"
),
command.onTabComplete(sender, null, "baseadmin",
new String[] {"setprogress", "Builder", ""})
);
assertEquals(
List.of("spawnable"),
command.onTabComplete(sender, null, "baseadmin",
new String[] {"setsetting", "Builder", ""})
);
assertEquals(
List.of("enable"),
command.onTabComplete(sender, null, "baseadmin",
new String[] {"setsetting", "Builder", "spawnable", "e"})
);
assertEquals(
List.of("personal", "visitor", "all"),
command.onTabComplete(sender, null, "baseadmin",
new String[] {"clearcooldown", "Builder", ""})
);
assertEquals(
List.of("size", "flight", "warmup", "cooldown", "all"),
command.onTabComplete(sender, null, "baseadmin",
new String[] {"reset", "Builder", ""})
);
}
@Test
void adminCanChangeSpawnableOverlayUnlockThreshold() {
CommandSender sender = mock(CommandSender.class);
when(sender.hasPermission("spigotbase.admin")).thenReturn(true);
JavaPlugin plugin = mock(JavaPlugin.class);
FileConfiguration configuration = mock(FileConfiguration.class);
when(plugin.getConfig()).thenReturn(configuration);
when(configuration.get("spawnable-overlay-unlock-placements")).thenReturn(250);
when(configuration.getValues(false)).thenReturn(Map.of(
"spawnable-overlay-unlock-placements", 250
));
PluginSettingsProvider settings = new PluginSettingsProvider(
PluginSettings.from(Map.of())
);
BaseAdminCommand command = new BaseAdminCommand(
plugin,
mock(BaseStateManager.class),
mock(AdminProgressionService.class),
settings
);
try (MockedStatic<PluginSettingsValidator> validator =
mockStatic(PluginSettingsValidator.class)) {
validator.when(() -> PluginSettingsValidator.validateMaterials(
any(PluginSettings.class)
)).thenAnswer(invocation -> invocation.getArgument(0));
command.onCommand(sender, null, "baseadmin", new String[] {
"config", "spawnable-overlay-unlock-placements", "500"
});
}
verify(configuration).set("spawnable-overlay-unlock-placements", 500L);
verify(plugin).saveConfig();
assertEquals(500, settings.current().spawnableOverlayUnlockPlacements());
}
@Test
void autocompletesNumericConfigurationKeys() {
CommandSender sender = mock(CommandSender.class);
when(sender.hasPermission("spigotbase.admin")).thenReturn(true);
JavaPlugin plugin = mock(JavaPlugin.class);
FileConfiguration configuration = mock(FileConfiguration.class);
when(plugin.getConfig()).thenReturn(configuration);
when(configuration.getValues(false)).thenReturn(Map.of(
"base-unlock-blocks", 250,
"visitor-currency-material", "DIAMOND",
"title-stay-ticks", 70
));
BaseAdminCommand command = new BaseAdminCommand(
plugin,
mock(BaseStateManager.class),
mock(AdminProgressionService.class),
new PluginSettingsProvider(PluginSettings.from(Map.of()))
);
assertEquals(
List.of("base-unlock-blocks"),
command.onTabComplete(sender, null, "baseadmin", new String[] {"config", "base"})
);
}
@Test
void autocompletesKnownOfflinePlayers() {
CommandSender sender = mock(CommandSender.class);
when(sender.hasPermission("spigotbase.admin")).thenReturn(true);
BaseStateManager stateManager = mock(BaseStateManager.class);
PlayerState alex = PlayerState.newPlayer(UUID.randomUUID(), "Alex");
PlayerState blake = PlayerState.newPlayer(UUID.randomUUID(), "Blake");
when(stateManager.knownPlayers()).thenReturn(Map.of(
alex.playerId(), alex,
blake.playerId(), blake
));
BaseAdminCommand command = new BaseAdminCommand(
mock(JavaPlugin.class),
stateManager,
mock(AdminProgressionService.class),
new PluginSettingsProvider(PluginSettings.from(Map.of()))
);
assertEquals(
List.of("Alex", "Blake"),
command.onTabComplete(sender, null, "baseadmin", new String[] {"status", ""})
);
}
@Test
void autocompletesAdminSubcommandsForPermittedSenders() {
CommandSender sender = mock(CommandSender.class);
when(sender.hasPermission("spigotbase.admin")).thenReturn(true);
BaseAdminCommand command = new BaseAdminCommand(
mock(JavaPlugin.class),
mock(BaseStateManager.class),
mock(AdminProgressionService.class),
new PluginSettingsProvider(PluginSettings.from(Map.of()))
);
assertEquals(
List.of(
"status", "setlevel", "setprogress", "setsetting", "clearcooldown", "reset",
"config"
),
command.onTabComplete(sender, null, "baseadmin", new String[] {""})
);
}
@Test
void resetBaseRequiresConfirmedCompleteReset() {
CommandSender sender = mock(CommandSender.class);
when(sender.hasPermission("spigotbase.admin")).thenReturn(true);
PlayerState target = PlayerState.newPlayer(UUID.randomUUID(), "Builder");
BaseStateManager stateManager = mock(BaseStateManager.class);
when(stateManager.findByName("Builder")).thenReturn(Optional.of(target));
BaseAdminCommand command = new BaseAdminCommand(
mock(JavaPlugin.class),
stateManager,
mock(AdminProgressionService.class),
new PluginSettingsProvider(PluginSettings.from(Map.of()))
);
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class)) {
bukkit.when(() -> Bukkit.getPlayerExact("Builder")).thenReturn(null);
command.onCommand(
sender,
null,
"baseadmin",
new String[] {"reset", "Builder", "base"}
);
}
verify(stateManager, never()).update(any(), anyString(), any());
verify(sender).sendMessage(ChatColor.RED
+ "Resetting base removes all progression. Use: /baseadmin reset Builder all confirm");
}
@Test
void statusDisplaysKnownPlayerProgress() {
CommandSender sender = mock(CommandSender.class);
when(sender.hasPermission("spigotbase.admin")).thenReturn(true);
PlayerState target = PlayerState.newPlayer(UUID.randomUUID(), "Builder");
BaseStateManager stateManager = mock(BaseStateManager.class);
when(stateManager.findByName("Builder")).thenReturn(Optional.of(target));
BaseAdminCommand command = new BaseAdminCommand(
mock(JavaPlugin.class),
stateManager,
mock(AdminProgressionService.class),
new PluginSettingsProvider(PluginSettings.from(Map.of()))
);
try (MockedStatic<Bukkit> bukkit = mockStatic(Bukkit.class)) {
bukkit.when(() -> Bukkit.getPlayerExact("Builder")).thenReturn(null);
command.onCommand(sender, null, "baseadmin", new String[] {"status", "Builder"});
}
verify(sender).sendMessage(ChatColor.GOLD + "=== Builder Base Progress ===");
}
}
@@ -0,0 +1,92 @@
package games.dmg.spigotbase;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.atLeastOnce;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.time.Instant;
import java.util.Map;
import java.util.UUID;
import org.bukkit.Location;
import org.bukkit.Particle;
import org.bukkit.Server;
import org.bukkit.World;
import org.bukkit.entity.Player;
import org.junit.jupiter.api.Test;
final class BaseBorderControllerTest {
@Test
void doesNotRenderOutsideTheBaseVerticalRange() {
UUID playerId = UUID.randomUUID();
UUID worldId = UUID.randomUUID();
PlayerState state = PlayerState.newPlayer(playerId, "Builder")
.withAdministrativeLevels(1, 0, 0, 0, 0, false, false, false)
.withBase(new BaseLocation(worldId, "world", 0, 64, 0, 0, 0), Instant.EPOCH)
.withBorderEnabled(true);
World world = mock(World.class);
when(world.getUID()).thenReturn(worldId);
Player player = mock(Player.class);
when(player.getUniqueId()).thenReturn(playerId);
when(player.getName()).thenReturn("Builder");
when(player.getWorld()).thenReturn(world);
when(player.getLocation()).thenReturn(new Location(world, 10.5, 100.0, 0.5));
Server server = mock(Server.class);
doReturn(java.util.List.of(player)).when(server).getOnlinePlayers();
BaseStateManager stateManager = mock(BaseStateManager.class);
when(stateManager.player(playerId, "Builder")).thenReturn(state);
new BaseBorderController(
server,
stateManager,
new BaseBoundsService(PluginSettings.from(Map.of()))
).run();
verify(player, never()).spawnParticle(
eq(Particle.END_ROD),
any(Location.class),
eq(1),
eq(0.0), eq(0.0), eq(0.0), eq(0.0)
);
}
@Test
void ownerSeesParticlesAtCurrentHeightNearEnabledBorder() {
UUID playerId = UUID.randomUUID();
UUID worldId = UUID.randomUUID();
PlayerState state = PlayerState.newPlayer(playerId, "Builder")
.withAdministrativeLevels(1, 0, 0, 0, 0, false, false, false)
.withBase(new BaseLocation(worldId, "world", 0, 64, 0, 0, 0), Instant.EPOCH)
.withBorderEnabled(true);
World world = mock(World.class);
when(world.getUID()).thenReturn(worldId);
Player player = mock(Player.class);
when(player.getUniqueId()).thenReturn(playerId);
when(player.getName()).thenReturn("Builder");
when(player.getWorld()).thenReturn(world);
when(player.getLocation()).thenReturn(new Location(world, 10.5, 70.0, 0.5));
Server server = mock(Server.class);
doReturn(java.util.List.of(player)).when(server).getOnlinePlayers();
BaseStateManager stateManager = mock(BaseStateManager.class);
when(stateManager.player(playerId, "Builder")).thenReturn(state);
new BaseBorderController(
server,
stateManager,
new BaseBoundsService(PluginSettings.from(Map.of()))
).run();
verify(player, atLeastOnce()).spawnParticle(
eq(Particle.END_ROD),
any(Location.class),
eq(1),
eq(0.0), eq(0.0), eq(0.0), eq(0.0)
);
}
}
@@ -0,0 +1,27 @@
package games.dmg.spigotbase;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.List;
import org.junit.jupiter.api.Test;
final class BaseBorderGeometryTest {
@Test
void producesBoundedPointsOnTheCurrentCircularBoundary() {
List<BaseBorderGeometry.Point> points = BaseBorderGeometry.visiblePoints(
0.5, 0.5, 150, 150.5, 0.5
);
assertFalse(points.isEmpty());
assertTrue(points.size() <= BaseBorderGeometry.MAX_PARTICLES);
assertTrue(points.stream().allMatch(point ->
Math.abs(Math.hypot(point.x() - 0.5, point.z() - 0.5) - 150.0) < 0.0001
));
}
@Test
void omitsParticlesWhenPlayerIsFarFromBoundary() {
assertTrue(BaseBorderGeometry.visiblePoints(0.5, 0.5, 10, 100.5, 0.5).isEmpty());
}
}
@@ -0,0 +1,444 @@
package games.dmg.spigotbase;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.logging.Logger;
import org.bukkit.Location;
import org.bukkit.Server;
import org.bukkit.World;
import org.bukkit.entity.Player;
import org.bukkit.event.raid.RaidFinishEvent;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.PlayerInventory;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
final class BaseFlightControllerTest {
private final UUID playerId = UUID.randomUUID();
private final UUID worldId = UUID.randomUUID();
private final Server server = mock(Server.class);
private final BaseStateManager stateManager = mock(BaseStateManager.class);
private final Player player = mock(Player.class);
private final PlayerState state = PlayerState.newPlayer(playerId, "Alex")
.withGrassAndDirtProgress(250, 1)
.withBase(new BaseLocation(worldId, "world", 0, 64, 0, 0, 0), Instant.EPOCH)
.withFlightLevel(1, true);
private final BaseFlightController controller = new BaseFlightController(
server,
stateManager,
new SecondaryProgressionService(PluginSettings.from(Map.of())),
new BaseBoundsService(PluginSettings.from(Map.of())),
new PluginSettingsProvider(PluginSettings.from(Map.of()))
);
@BeforeEach
void setUp() {
World world = mock(World.class);
Location location = mock(Location.class);
PlayerInventory inventory = mock(PlayerInventory.class);
doReturn(List.of(player)).when(server).getOnlinePlayers();
when(server.getPlayer(playerId)).thenReturn(player);
when(player.getUniqueId()).thenReturn(playerId);
when(player.getName()).thenReturn("Alex");
when(player.getGameMode()).thenReturn(org.bukkit.GameMode.SURVIVAL);
when(player.getInventory()).thenReturn(inventory);
when(inventory.getStorageContents()).thenReturn(new ItemStack[0]);
when(stateManager.player(playerId, "Alex")).thenReturn(state);
when(player.getWorld()).thenReturn(world);
when(world.getUID()).thenReturn(worldId);
when(player.getLocation()).thenReturn(location);
when(location.getX()).thenReturn(12.5);
when(location.getZ()).thenReturn(0.5);
when(location.getBlockY()).thenReturn(64);
when(player.getAllowFlight()).thenReturn(true);
}
@Test
void ownerWinningRaidInOwnPocketBaseUnlocksFlight() throws Exception {
UUID pocketWorldId = UUID.randomUUID();
World pocketWorld = mock(World.class);
Player winner = mock(Player.class);
RaidFinishEvent event = mock(RaidFinishEvent.class);
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
PocketBaseState locked = new PocketBaseState(playerId, 1, Optional.empty());
PocketBaseState unlocked = locked.withFlightUnlocked();
BaseFlightController pocketController = new BaseFlightController(
server,
stateManager,
new SecondaryProgressionService(PluginSettings.from(Map.of())),
new BaseBoundsService(PluginSettings.from(Map.of())),
new PluginSettingsProvider(PluginSettings.from(Map.of())),
pocketBases,
Logger.getAnonymousLogger()
);
when(event.getWorld()).thenReturn(pocketWorld);
when(event.getWinners()).thenReturn(List.of(winner));
when(pocketWorld.getUID()).thenReturn(pocketWorldId);
when(winner.getUniqueId()).thenReturn(playerId);
when(pocketBases.ownerForPocketWorld(pocketWorldId)).thenReturn(Optional.of(playerId));
when(pocketBases.state(playerId)).thenReturn(locked);
when(pocketBases.unlockFlight(playerId)).thenReturn(unlocked);
pocketController.onRaidFinish(event);
verify(pocketBases).unlockFlight(playerId);
verify(winner).sendTitle(anyString(), anyString(), anyInt(), anyInt(), anyInt());
}
@Test
void raidDoesNotUnlockFlightWhenPocketOwnerIsNotAWinner() throws Exception {
UUID pocketWorldId = UUID.randomUUID();
UUID ownerId = UUID.randomUUID();
World pocketWorld = mock(World.class);
Player visitor = mock(Player.class);
RaidFinishEvent event = mock(RaidFinishEvent.class);
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
BaseFlightController pocketController = new BaseFlightController(
server,
stateManager,
new SecondaryProgressionService(PluginSettings.from(Map.of())),
new BaseBoundsService(PluginSettings.from(Map.of())),
new PluginSettingsProvider(PluginSettings.from(Map.of())),
pocketBases,
Logger.getAnonymousLogger()
);
when(event.getWorld()).thenReturn(pocketWorld);
when(event.getWinners()).thenReturn(List.of(visitor));
when(pocketWorld.getUID()).thenReturn(pocketWorldId);
when(visitor.getUniqueId()).thenReturn(UUID.randomUUID());
when(pocketBases.ownerForPocketWorld(pocketWorldId)).thenReturn(Optional.of(ownerId));
pocketController.onRaidFinish(event);
verify(pocketBases, never()).unlockFlight(ownerId);
}
@Test
void unlockedOwnerReceivesFlightInsidePocketBaseBuffer() {
UUID pocketWorldId = UUID.randomUUID();
World pocketWorld = mock(World.class);
Location location = mock(Location.class);
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
PocketBaseState pocket = new PocketBaseState(playerId, 1, Optional.empty())
.withFlightUnlocked();
PlayerState noNormalFlight = PlayerState.newPlayer(playerId, "Alex");
BaseFlightController pocketController = new BaseFlightController(
server,
stateManager,
new SecondaryProgressionService(PluginSettings.from(Map.of())),
new BaseBoundsService(PluginSettings.from(Map.of())),
new PluginSettingsProvider(PluginSettings.from(Map.of())),
pocketBases,
Logger.getAnonymousLogger()
);
when(stateManager.player(playerId, "Alex")).thenReturn(noNormalFlight);
when(player.getWorld()).thenReturn(pocketWorld);
when(player.getLocation()).thenReturn(location);
when(player.getAllowFlight()).thenReturn(false);
when(pocketWorld.getUID()).thenReturn(pocketWorldId);
when(location.getX()).thenReturn(40.5);
when(location.getZ()).thenReturn(0.5);
when(location.getBlockX()).thenReturn(40);
when(location.getBlockZ()).thenReturn(0);
when(pocketBases.ownerForPocketWorld(pocketWorldId)).thenReturn(Optional.of(playerId));
when(pocketBases.state(playerId)).thenReturn(pocket);
when(pocketBases.policy()).thenReturn(
new PocketBasePolicy(PluginSettings.from(Map.of()))
);
pocketController.run();
verify(player).setAllowFlight(true);
}
@Test
void disabledAutomaticPocketBaseFlightDoesNotGrantOwnerFlight() {
UUID pocketWorldId = UUID.randomUUID();
World pocketWorld = mock(World.class);
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
PocketBaseState pocket = new PocketBaseState(playerId, 1, Optional.empty())
.withFlightUnlocked()
.withFlightEnabled(false);
BaseFlightController pocketController = new BaseFlightController(
server,
stateManager,
new SecondaryProgressionService(PluginSettings.from(Map.of())),
new BaseBoundsService(PluginSettings.from(Map.of())),
new PluginSettingsProvider(PluginSettings.from(Map.of())),
pocketBases,
Logger.getAnonymousLogger()
);
when(stateManager.player(playerId, "Alex")).thenReturn(
PlayerState.newPlayer(playerId, "Alex")
);
when(player.getWorld()).thenReturn(pocketWorld);
when(player.getAllowFlight()).thenReturn(false);
when(pocketWorld.getUID()).thenReturn(pocketWorldId);
when(pocketBases.ownerForPocketWorld(pocketWorldId)).thenReturn(Optional.of(playerId));
when(pocketBases.state(playerId)).thenReturn(pocket);
pocketController.run();
verify(player, never()).setAllowFlight(true);
}
@Test
void flightPortalTemporarilyGrantsGuestPocketBaseFlight() {
UUID ownerId = UUID.randomUUID();
UUID pocketWorldId = UUID.randomUUID();
World pocketWorld = mock(World.class);
Location location = mock(Location.class);
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
PocketBaseState pocket = new PocketBaseState(ownerId, 1, Optional.empty())
.withFlightUnlocked()
.withFlightEnabled(false);
BaseFlightController pocketController = new BaseFlightController(
server,
stateManager,
new SecondaryProgressionService(PluginSettings.from(Map.of())),
new BaseBoundsService(PluginSettings.from(Map.of())),
new PluginSettingsProvider(PluginSettings.from(Map.of())),
pocketBases,
Logger.getAnonymousLogger()
);
when(stateManager.player(playerId, "Alex")).thenReturn(
PlayerState.newPlayer(playerId, "Alex")
);
when(player.getWorld()).thenReturn(pocketWorld);
when(player.getLocation()).thenReturn(location);
when(player.getAllowFlight()).thenReturn(false);
when(pocketWorld.getUID()).thenReturn(pocketWorldId);
when(pocketBases.ownerForPocketWorld(pocketWorldId)).thenReturn(Optional.of(ownerId));
when(pocketBases.state(ownerId)).thenReturn(pocket);
when(pocketBases.policy()).thenReturn(
new PocketBasePolicy(PluginSettings.from(Map.of()))
);
when(player.getGameMode()).thenReturn(org.bukkit.GameMode.CREATIVE);
pocketController.grantPocketPortalFlight(player, ownerId);
verify(player, never()).setFlying(true);
verify(player, never()).sendMessage(anyString());
when(player.getGameMode()).thenReturn(org.bukkit.GameMode.SURVIVAL);
when(location.getBlockX()).thenReturn(10000);
pocketController.grantPocketPortalFlight(player, ownerId);
verify(player, never()).setFlying(true);
verify(player, never()).sendMessage(anyString());
when(location.getBlockX()).thenReturn(0);
pocketController.grantPocketPortalFlight(player, ownerId);
verify(player).setAllowFlight(true);
verify(player).setFlying(true);
verify(player).sendMessage(org.bukkit.ChatColor.GREEN + "Pocket dimension flight enabled!");
when(player.getAllowFlight()).thenReturn(true);
pocketController.grantPocketPortalFlight(player, ownerId);
verify(player, times(2)).setFlying(true);
verify(player, times(2)).sendMessage(org.bukkit.ChatColor.GREEN + "Pocket dimension flight enabled!");
}
@Test
void portalFlightEndsAfterGuestLeavesPocketBase() {
UUID ownerId = UUID.randomUUID();
UUID pocketWorldId = UUID.randomUUID();
UUID normalWorldId = UUID.randomUUID();
World pocketWorld = mock(World.class);
World normalWorld = mock(World.class);
Location location = mock(Location.class);
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
PocketBaseState pocket = new PocketBaseState(ownerId, 1, Optional.empty())
.withFlightUnlocked()
.withFlightEnabled(false);
BaseFlightController pocketController = new BaseFlightController(
server,
stateManager,
new SecondaryProgressionService(PluginSettings.from(Map.of())),
new BaseBoundsService(PluginSettings.from(Map.of())),
new PluginSettingsProvider(PluginSettings.from(Map.of())),
pocketBases,
Logger.getAnonymousLogger()
);
when(stateManager.player(playerId, "Alex")).thenReturn(
PlayerState.newPlayer(playerId, "Alex")
);
when(player.getWorld()).thenReturn(pocketWorld, normalWorld);
when(player.getLocation()).thenReturn(location);
when(player.getAllowFlight()).thenReturn(false);
when(pocketWorld.getUID()).thenReturn(pocketWorldId);
when(normalWorld.getUID()).thenReturn(normalWorldId);
when(pocketBases.ownerForPocketWorld(pocketWorldId)).thenReturn(Optional.of(ownerId));
when(pocketBases.ownerForPocketWorld(normalWorldId)).thenReturn(Optional.empty());
when(pocketBases.state(ownerId)).thenReturn(pocket);
when(pocketBases.policy()).thenReturn(
new PocketBasePolicy(PluginSettings.from(Map.of()))
);
pocketController.grantPocketPortalFlight(player, ownerId);
pocketController.run();
pocketController.run();
verify(player).setFlying(false);
verify(player).setAllowFlight(false);
}
@Test
void flyingInPocketBaseBufferShowsLeavingWarning() {
UUID pocketWorldId = UUID.randomUUID();
World pocketWorld = mock(World.class);
Location location = mock(Location.class);
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
PocketBaseState pocket = new PocketBaseState(playerId, 1, Optional.empty())
.withFlightUnlocked();
BaseFlightController pocketController = new BaseFlightController(
server,
stateManager,
new SecondaryProgressionService(PluginSettings.from(Map.of())),
new BaseBoundsService(PluginSettings.from(Map.of())),
new PluginSettingsProvider(PluginSettings.from(Map.of())),
pocketBases,
Logger.getAnonymousLogger()
);
when(stateManager.player(playerId, "Alex")).thenReturn(
PlayerState.newPlayer(playerId, "Alex")
);
when(player.getWorld()).thenReturn(pocketWorld);
when(player.getLocation()).thenReturn(location);
when(player.isFlying()).thenReturn(true);
when(pocketWorld.getUID()).thenReturn(pocketWorldId);
when(location.getBlockX()).thenReturn(40);
when(location.getBlockZ()).thenReturn(0);
when(pocketBases.ownerForPocketWorld(pocketWorldId)).thenReturn(Optional.of(playerId));
when(pocketBases.state(playerId)).thenReturn(pocket);
when(pocketBases.policy()).thenReturn(
new PocketBasePolicy(PluginSettings.from(Map.of()))
);
pocketController.run();
verify(player).sendTitle(anyString(), anyString(), anyInt(), anyInt(), anyInt());
}
@Test
void pocketBaseFlightEndsBeyondSixteenBlockBuffer() {
UUID pocketWorldId = UUID.randomUUID();
World pocketWorld = mock(World.class);
Location location = mock(Location.class);
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
PocketBaseState pocket = new PocketBaseState(playerId, 1, Optional.empty())
.withFlightUnlocked();
BaseFlightController pocketController = new BaseFlightController(
server,
stateManager,
new SecondaryProgressionService(PluginSettings.from(Map.of())),
new BaseBoundsService(PluginSettings.from(Map.of())),
new PluginSettingsProvider(PluginSettings.from(Map.of())),
pocketBases,
Logger.getAnonymousLogger()
);
when(stateManager.player(playerId, "Alex")).thenReturn(
PlayerState.newPlayer(playerId, "Alex")
);
when(player.getWorld()).thenReturn(pocketWorld);
when(player.getLocation()).thenReturn(location);
when(player.getAllowFlight()).thenReturn(false);
when(pocketWorld.getUID()).thenReturn(pocketWorldId);
when(location.getBlockX()).thenReturn(40, 48);
when(location.getBlockZ()).thenReturn(0);
when(pocketBases.ownerForPocketWorld(pocketWorldId)).thenReturn(Optional.of(playerId));
when(pocketBases.state(playerId)).thenReturn(pocket);
when(pocketBases.policy()).thenReturn(
new PocketBasePolicy(PluginSettings.from(Map.of()))
);
pocketController.run();
pocketController.run();
verify(player).setFlying(false);
verify(player).setAllowFlight(false);
}
@Test
void visitorDoesNotReceiveUnlockedOwnersPocketBaseFlight() {
UUID ownerId = UUID.randomUUID();
UUID pocketWorldId = UUID.randomUUID();
World pocketWorld = mock(World.class);
Location location = mock(Location.class);
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
PocketBaseState pocket = new PocketBaseState(ownerId, 1, Optional.empty())
.withFlightUnlocked();
BaseFlightController pocketController = new BaseFlightController(
server,
stateManager,
new SecondaryProgressionService(PluginSettings.from(Map.of())),
new BaseBoundsService(PluginSettings.from(Map.of())),
new PluginSettingsProvider(PluginSettings.from(Map.of())),
pocketBases,
Logger.getAnonymousLogger()
);
when(stateManager.player(playerId, "Alex")).thenReturn(
PlayerState.newPlayer(playerId, "Alex")
);
when(player.getWorld()).thenReturn(pocketWorld);
when(player.getLocation()).thenReturn(location);
when(player.getAllowFlight()).thenReturn(false);
when(pocketWorld.getUID()).thenReturn(pocketWorldId);
when(pocketBases.ownerForPocketWorld(pocketWorldId)).thenReturn(Optional.of(ownerId));
when(pocketBases.state(ownerId)).thenReturn(pocket);
pocketController.run();
verify(player, never()).setAllowFlight(true);
}
@Test
void walkingInWarningBufferDoesNotShowLeavingBaseWarning() {
when(player.isFlying()).thenReturn(false);
controller.run();
verify(player, never()).sendTitle(anyString(), anyString(), anyInt(), anyInt(), anyInt());
}
@Test
void flyingInWarningBufferShowsLeavingBaseWarning() {
when(player.isFlying()).thenReturn(true);
controller.run();
verify(player).sendTitle(anyString(), anyString(), anyInt(), anyInt(), anyInt());
}
@Test
void warningCanAppearAgainAfterPlayerStopsAndResumesFlyingInBuffer() {
when(player.isFlying()).thenReturn(true, false, true);
controller.run();
controller.run();
controller.run();
verify(player, times(2)).sendTitle(anyString(), anyString(), anyInt(), anyInt(), anyInt());
}
}
@@ -0,0 +1,60 @@
package games.dmg.spigotbase;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.time.Instant;
import java.util.Map;
import java.util.UUID;
import org.bukkit.Location;
import org.bukkit.Particle;
import org.bukkit.Server;
import org.bukkit.World;
import org.bukkit.entity.Player;
import org.junit.jupiter.api.Test;
final class BaseNavigationControllerTest {
@Test
void showsParticlesOnlyMoreThanTwentyFiveBlocksBeyondBorder() {
UUID playerId = UUID.randomUUID();
UUID worldId = UUID.randomUUID();
PlayerState state = PlayerState.newPlayer(playerId, "Builder")
.withAdministrativeLevels(2, 0, 0, 0, 0, true, false, false)
.withBase(new BaseLocation(worldId, "world", 0, 64, 0, 0, 0), Instant.EPOCH);
World world = mock(World.class);
when(world.getUID()).thenReturn(worldId);
Player player = mock(Player.class);
when(player.getUniqueId()).thenReturn(playerId);
when(player.getName()).thenReturn("Builder");
when(player.getWorld()).thenReturn(world);
when(player.getLocation()).thenReturn(new Location(world, 35.5, 64.0, 0.5));
Server server = mock(Server.class);
doReturn(java.util.List.of(player)).when(server).getOnlinePlayers();
BaseStateManager stateManager = mock(BaseStateManager.class);
when(stateManager.player(playerId, "Builder")).thenReturn(state);
PluginSettingsProvider settings = new PluginSettingsProvider(
PluginSettings.from(Map.of())
);
new BaseNavigationController(server, stateManager, settings).run();
verify(player, never()).spawnParticle(
eq(Particle.END_ROD), any(Location.class), eq(1),
eq(0.0), eq(0.0), eq(0.0), eq(0.0)
);
when(player.getLocation()).thenReturn(new Location(world, 35.51, 64.0, 0.5));
new BaseNavigationController(server, stateManager, settings).run();
verify(player, times(settings.current().navigationParticleCount())).spawnParticle(
eq(Particle.END_ROD), any(Location.class), eq(1),
eq(0.0), eq(0.0), eq(0.0), eq(0.0)
);
}
}
@@ -1,6 +1,7 @@
package games.dmg.spigotbase;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.Map;
@@ -21,4 +22,18 @@ final class BaseNavigationProgressionTest {
assertTrue(update.player().navigationEnabled());
assertTrue(update.unlockedBaseLevel());
}
@Test
void laterProgressPreservesDisabledNavigationPreference() {
BaseProgressionService service =
new BaseProgressionService(PluginSettings.from(Map.of()));
PlayerState player = PlayerState.newPlayer(UUID.randomUUID(), "Alex")
.withGrassAndDirtProgress(500, 2)
.withNavigationEnabled(false);
ProgressionUpdate update = service.recordGrassOrDirtBreak(player);
assertFalse(update.player().navigationEnabled());
assertFalse(update.unlockedBaseLevel());
}
}
@@ -0,0 +1,26 @@
package games.dmg.spigotbase;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.UUID;
import org.junit.jupiter.api.Test;
final class BaseNavigationVisibilityTest {
private static final BaseLocation BASE = new BaseLocation(
UUID.randomUUID(), "world", 0, 64, 0, 0, 0
);
@Test
void hidesNavigationAtAndWithinTwentyFiveBlocksBeyondBorder() {
assertFalse(BaseNavigationVisibility.isBeyondBorderBuffer(BASE, 10, 35.5, 0.5));
assertFalse(BaseNavigationVisibility.isBeyondBorderBuffer(BASE, 10, 20.5, 0.5));
}
@Test
void showsNavigationMoreThanTwentyFiveBlocksBeyondCurrentBorder() {
assertTrue(BaseNavigationVisibility.isBeyondBorderBuffer(BASE, 10, 35.51, 0.5));
assertFalse(BaseNavigationVisibility.isBeyondBorderBuffer(BASE, 75, 100.5, 0.5));
assertTrue(BaseNavigationVisibility.isBeyondBorderBuffer(BASE, 75, 100.51, 0.5));
}
}
@@ -0,0 +1,66 @@
package games.dmg.spigotbase;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.UnaryOperator;
import org.bukkit.GameMode;
import org.bukkit.World;
import org.bukkit.block.Block;
import org.bukkit.entity.Player;
import org.bukkit.event.block.BlockPlaceEvent;
import org.bukkit.plugin.Plugin;
import org.junit.jupiter.api.Test;
final class BaseProgressListenerTest {
@Test
void survivalPlacementOutsideBaseCountsTowardOverlayUnlock() {
UUID playerId = UUID.randomUUID();
PlayerState current = PlayerState.newPlayer(playerId, "Builder");
AtomicReference<PlayerState> updated = new AtomicReference<>();
BaseStateManager stateManager = mock(BaseStateManager.class);
when(stateManager.player(playerId, "Builder")).thenReturn(current);
when(stateManager.update(any(), anyString(), any())).thenAnswer(invocation -> {
@SuppressWarnings("unchecked")
UnaryOperator<PlayerState> operation = invocation.getArgument(2);
PlayerState result = operation.apply(current);
updated.set(result);
return result;
});
Player player = mock(Player.class);
when(player.getUniqueId()).thenReturn(playerId);
when(player.getName()).thenReturn("Builder");
when(player.getGameMode()).thenReturn(GameMode.SURVIVAL);
World world = mock(World.class);
when(world.getUID()).thenReturn(UUID.randomUUID());
Block block = mock(Block.class);
when(block.getWorld()).thenReturn(world);
BlockPlaceEvent event = mock(BlockPlaceEvent.class);
when(event.getPlayer()).thenReturn(player);
when(event.getBlockPlaced()).thenReturn(block);
PluginSettingsProvider settings = new PluginSettingsProvider(
PluginSettings.from(Map.of())
);
BaseProgressListener listener = new BaseProgressListener(
mock(Plugin.class),
stateManager,
mock(BaseProgressionService.class),
mock(SecondaryProgressionService.class),
mock(TeleportProgressionService.class),
new SpawnableOverlayProgressionService(settings),
new BaseBoundsService(settings),
settings
);
listener.onBlockPlace(event);
assertEquals(1, updated.get().totalBlocksPlaced());
assertEquals(0, updated.get().blocksPlacedInBase());
}
}
@@ -0,0 +1,673 @@
package games.dmg.spigotbase;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.io.IOException;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.UnaryOperator;
import org.bukkit.ChatColor;
import org.bukkit.Material;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.PlayerInventory;
import net.kyori.adventure.dialog.DialogLike;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
final class BaseSettingsCommandTest {
@Test
void rejectsNonPlayerExecution() {
CommandSender sender = mock(CommandSender.class);
BaseSettingsCommand command = new BaseSettingsCommand(
mock(BaseStateManager.class),
new PluginSettingsProvider(PluginSettings.from(Map.of())),
mock(BaseFlightController.class)
);
command.onCommand(sender, null, "basesettings", new String[0]);
verify(sender).sendMessage("Only players have base progression.");
}
@Test
void spawnableEnableIsIdempotentAfterUnlock() {
UUID playerId = UUID.randomUUID();
PlayerState current = PlayerState.newPlayer(playerId, "Builder")
.withAdministrativeLevels(1, 0, 0, 0, 0, false, false, false)
.withBase(
new BaseLocation(UUID.randomUUID(), "world", 0, 64, 0, 0, 0),
Instant.EPOCH
)
.withTotalBlocksPlaced(250);
CommandResult result = execute(current, "spawnable", "enable");
assertTrue(result.updated().spawnableOverlayEnabled());
}
@Test
void borderEnableIsIdempotent() {
UUID playerId = UUID.randomUUID();
PlayerState current = PlayerState.newPlayer(playerId, "Builder")
.withAdministrativeLevels(1, 0, 0, 0, 0, false, false, false)
.withBase(
new BaseLocation(UUID.randomUUID(), "world", 0, 64, 0, 0, 0),
Instant.EPOCH
);
CommandResult result = execute(current, "border", "enable");
assertTrue(result.updated().borderEnabled());
}
@Test
void invalidArgumentsShowUsage() {
UUID playerId = UUID.randomUUID();
Player player = mock(Player.class);
when(player.getUniqueId()).thenReturn(playerId);
when(player.getName()).thenReturn("Builder");
BaseStateManager stateManager = mock(BaseStateManager.class);
when(stateManager.player(playerId, "Builder"))
.thenReturn(PlayerState.newPlayer(playerId, "Builder"));
BaseSettingsCommand command = new BaseSettingsCommand(
stateManager,
new PluginSettingsProvider(PluginSettings.from(Map.of())),
mock(BaseFlightController.class)
);
command.onCommand(player, null, "basesettings", new String[] {"unknown"});
verify(player).sendMessage(org.mockito.ArgumentMatchers.<String>argThat(
message -> message.contains("Usage: /basesettings")
));
}
@Test
void autocompletesValuesForEachSetting() {
BaseSettingsCommand command = new BaseSettingsCommand(
mock(BaseStateManager.class),
new PluginSettingsProvider(PluginSettings.from(Map.of())),
mock(BaseFlightController.class)
);
assertEquals(
List.of("allowed"),
command.onTabComplete(null, null, "basesettings", new String[] {"visitors", "a"})
);
assertEquals(
List.of("upgrade"),
command.onTabComplete(null, null, "basesettings", new String[] {"pocket", "u"})
);
assertEquals(
List.of("flight"),
command.onTabComplete(null, null, "basesettings", new String[] {"pocket", "f"})
);
assertEquals(
List.of("disable"),
command.onTabComplete(
null, null, "basesettings", new String[] {"pocket", "flight", "d"}
)
);
assertEquals(
List.of("mobs"),
command.onTabComplete(null, null, "basesettings", new String[] {"pocket", "m"})
);
assertEquals(
List.of("type"),
command.onTabComplete(null, null, "basesettings", new String[] {"pocket", "t"})
);
assertEquals(
List.of("nether"),
command.onTabComplete(
null, null, "basesettings", new String[] {"pocket", "type", "n"}
)
);
assertEquals(
List.of("crimson_forest"),
command.onTabComplete(
null,
null,
"basesettings",
new String[] {"pocket", "type", "nether", "c"}
)
);
assertEquals(
List.of("hostile"),
command.onTabComplete(
null, null, "basesettings", new String[] {"pocket", "mobs", "h"}
)
);
assertEquals(
List.of("passive"),
command.onTabComplete(
null, null, "basesettings", new String[] {"pocket", "mobs", "p"}
)
);
assertEquals(
List.of("enable"),
command.onTabComplete(
null, null, "basesettings",
new String[] {"pocket", "mobs", "hostile", "e"}
)
);
for (String setting : List.of("navigation", "flight", "border", "spawnable", "bossbar")) {
assertEquals(
List.of("disable"),
command.onTabComplete(null, null, "basesettings", new String[] {setting, "d"})
);
}
}
@Test
void autocompletesSettingNames() {
BaseSettingsCommand command = new BaseSettingsCommand(
mock(BaseStateManager.class),
new PluginSettingsProvider(PluginSettings.from(Map.of())),
mock(BaseFlightController.class)
);
assertEquals(
List.of(
"ui", "status", "upgrade", "pocket", "visitors", "navigation", "flight",
"border", "spawnable", "bossbar"
),
command.onTabComplete(null, null, "basesettings", new String[] {""})
);
}
@Test
void upgradePurchasesBaseIv() throws Exception {
UUID playerId = UUID.randomUUID();
Player player = mock(Player.class);
when(player.getUniqueId()).thenReturn(playerId);
when(player.getName()).thenReturn("Host");
PlayerInventory inventory = mock(PlayerInventory.class);
ItemStack firstStack = item(Material.DIAMOND, 64);
ItemStack secondStack = item(Material.DIAMOND, 64);
when(player.getInventory()).thenReturn(inventory);
when(inventory.getStorageContents()).thenReturn(new ItemStack[] {
firstStack,
secondStack
});
PlayerState current = PlayerState.newPlayer(playerId, "Host")
.withAdministrativeLevels(3, 0, 0, 0, 0, false, false, false)
.withBase(
new BaseLocation(UUID.randomUUID(), "world", 0, 64, 0, 0, 0),
Instant.EPOCH
);
AtomicReference<PlayerState> updated = new AtomicReference<>();
BaseStateManager stateManager = mock(BaseStateManager.class);
when(stateManager.player(playerId, "Host")).thenReturn(current);
when(stateManager.updateAndSave(any(), anyString(), any())).thenAnswer(invocation -> {
@SuppressWarnings("unchecked")
UnaryOperator<PlayerState> operation = invocation.getArgument(2);
PlayerState result = operation.apply(current);
updated.set(result);
return result;
});
BaseSettingsCommand command = new BaseSettingsCommand(
stateManager,
new PluginSettingsProvider(PluginSettings.from(Map.of())),
mock(BaseFlightController.class)
);
command.onCommand(player, null, "basesettings", new String[] {"upgrade"});
assertEquals(4, updated.get().baseLevel());
assertTrue(updated.get().visitorsEnabled());
}
@Test
void pocketUpgradePurchasesFirstLevelAfterBaseIv() throws Exception {
UUID playerId = UUID.randomUUID();
Player player = mock(Player.class);
when(player.getUniqueId()).thenReturn(playerId);
when(player.getName()).thenReturn("Builder");
PlayerInventory inventory = mock(PlayerInventory.class);
ItemStack currency = item(Material.DIAMOND_BLOCK, 64);
when(player.getInventory()).thenReturn(inventory);
when(inventory.getStorageContents()).thenReturn(new ItemStack[] {currency});
PlayerState owner = PlayerState.newPlayer(playerId, "Builder")
.withAdministrativeLevels(4, 0, 0, 0, 0, false, false, true)
.withBase(
new BaseLocation(UUID.randomUUID(), "world", 0, 64, 0, 0, 0),
Instant.EPOCH
);
BaseStateManager stateManager = mock(BaseStateManager.class);
when(stateManager.player(playerId, "Builder")).thenReturn(owner);
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
when(pocketBases.state(playerId)).thenReturn(PocketBaseState.locked(playerId));
when(pocketBases.upgrade(playerId)).thenReturn(
new PocketBaseState(playerId, 1, java.util.Optional.empty())
);
BaseSettingsCommand command = new BaseSettingsCommand(
stateManager,
new PluginSettingsProvider(PluginSettings.from(Map.of())),
mock(BaseFlightController.class),
pocketBases
);
command.onCommand(player, null, "basesettings", new String[] {"pocket", "upgrade"});
verify(pocketBases).upgrade(playerId);
verify(inventory).setStorageContents(any(ItemStack[].class));
}
@Test
void pocketOwnerCanPurchaseBiomeChangeWithConfiguredCurrency() throws Exception {
UUID playerId = UUID.randomUUID();
Player player = mock(Player.class);
when(player.getUniqueId()).thenReturn(playerId);
when(player.getName()).thenReturn("Builder");
PlayerInventory inventory = mock(PlayerInventory.class);
ItemStack currency = item(Material.NETHERITE_BLOCK, 16);
when(player.getInventory()).thenReturn(inventory);
when(inventory.getStorageContents()).thenReturn(new ItemStack[] {currency});
BaseStateManager stateManager = mock(BaseStateManager.class);
when(stateManager.player(playerId, "Builder"))
.thenReturn(PlayerState.newPlayer(playerId, "Builder"));
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
PocketBaseState current = new PocketBaseState(playerId, 1, java.util.Optional.empty());
when(pocketBases.state(playerId)).thenReturn(current);
when(pocketBases.setBiome(playerId, PocketBaseBiome.CRIMSON_FOREST))
.thenReturn(current.withBiome(PocketBaseBiome.CRIMSON_FOREST));
BaseSettingsCommand command = new BaseSettingsCommand(
stateManager,
new PluginSettingsProvider(PluginSettings.from(Map.of())),
mock(BaseFlightController.class),
pocketBases
);
command.onCommand(player, null, "basesettings", new String[] {
"pocket", "type", "nether", "crimson_forest"
});
verify(pocketBases).setBiome(playerId, PocketBaseBiome.CRIMSON_FOREST);
verify(inventory).setStorageContents(any(ItemStack[].class));
}
@Test
void selectingCurrentPocketBiomeDoesNotCharge() throws Exception {
UUID playerId = UUID.randomUUID();
Player player = mock(Player.class);
when(player.getUniqueId()).thenReturn(playerId);
when(player.getName()).thenReturn("Builder");
PlayerInventory inventory = mock(PlayerInventory.class);
when(player.getInventory()).thenReturn(inventory);
BaseStateManager stateManager = mock(BaseStateManager.class);
when(stateManager.player(playerId, "Builder"))
.thenReturn(PlayerState.newPlayer(playerId, "Builder"));
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
when(pocketBases.state(playerId)).thenReturn(
new PocketBaseState(playerId, 1, java.util.Optional.empty())
);
BaseSettingsCommand command = new BaseSettingsCommand(
stateManager,
new PluginSettingsProvider(PluginSettings.from(Map.of())),
mock(BaseFlightController.class),
pocketBases
);
command.onCommand(player, null, "basesettings", new String[] {
"pocket", "type", "void", "the_void"
});
verify(pocketBases, never()).setBiome(
org.mockito.ArgumentMatchers.eq(playerId),
org.mockito.ArgumentMatchers.any(PocketBaseBiome.class)
);
verify(inventory, never()).setStorageContents(any(ItemStack[].class));
}
@Test
void pocketOwnerCanEnableHostileMobSpawningWithoutChangingPassive() throws Exception {
UUID playerId = UUID.randomUUID();
Player player = mock(Player.class);
when(player.getUniqueId()).thenReturn(playerId);
when(player.getName()).thenReturn("Builder");
BaseStateManager stateManager = mock(BaseStateManager.class);
when(stateManager.player(playerId, "Builder"))
.thenReturn(PlayerState.newPlayer(playerId, "Builder"));
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
PocketBaseState current = new PocketBaseState(
playerId, 1, java.util.Optional.empty(), java.util.Optional.empty(),
false, true, PocketBaseBiome.THE_VOID
);
when(pocketBases.state(playerId)).thenReturn(current);
when(pocketBases.setHostileMobSpawning(playerId, true)).thenReturn(
current.withHostileMobSpawningEnabled(true)
);
BaseSettingsCommand command = new BaseSettingsCommand(
stateManager,
new PluginSettingsProvider(PluginSettings.from(Map.of())),
mock(BaseFlightController.class),
pocketBases
);
command.onCommand(player, null, "basesettings", new String[] {
"pocket", "mobs", "hostile", "enable"
});
verify(pocketBases).setHostileMobSpawning(playerId, true);
verify(pocketBases, never()).setPassiveMobSpawning(playerId, true);
verify(player).sendMessage(org.mockito.ArgumentMatchers.<String>argThat(
message -> message.contains("hostile mob spawning") && message.contains("enabled")
));
}
@Test
void failedPocketMobSpawningChangeReportsOnlyFailure() throws Exception {
UUID playerId = UUID.randomUUID();
Player player = mock(Player.class);
when(player.getUniqueId()).thenReturn(playerId);
when(player.getName()).thenReturn("Builder");
BaseStateManager stateManager = mock(BaseStateManager.class);
when(stateManager.player(playerId, "Builder"))
.thenReturn(PlayerState.newPlayer(playerId, "Builder"));
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
when(pocketBases.state(playerId)).thenReturn(
new PocketBaseState(playerId, 1, java.util.Optional.empty())
);
when(pocketBases.setPassiveMobSpawning(playerId, true))
.thenThrow(new IOException("save failed"));
BaseSettingsCommand command = new BaseSettingsCommand(
stateManager,
new PluginSettingsProvider(PluginSettings.from(Map.of())),
mock(BaseFlightController.class),
pocketBases
);
command.onCommand(player, null, "basesettings", new String[] {
"pocket", "mobs", "passive", "enable"
});
verify(player).sendMessage(org.mockito.ArgumentMatchers.<String>argThat(
message -> message.contains("could not be changed")
));
verify(player, never()).sendMessage(org.mockito.ArgumentMatchers.<String>argThat(
message -> message.contains("is now")
));
}
@Test
void lockedPlayerCannotEnablePocketMobSpawning() throws Exception {
UUID playerId = UUID.randomUUID();
Player player = mock(Player.class);
when(player.getUniqueId()).thenReturn(playerId);
when(player.getName()).thenReturn("Builder");
BaseStateManager stateManager = mock(BaseStateManager.class);
when(stateManager.player(playerId, "Builder"))
.thenReturn(PlayerState.newPlayer(playerId, "Builder"));
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
when(pocketBases.state(playerId)).thenReturn(PocketBaseState.locked(playerId));
BaseSettingsCommand command = new BaseSettingsCommand(
stateManager,
new PluginSettingsProvider(PluginSettings.from(Map.of())),
mock(BaseFlightController.class),
pocketBases
);
command.onCommand(player, null, "basesettings", new String[] {
"pocket", "mobs", "hostile", "enable"
});
verify(pocketBases, never()).setHostileMobSpawning(playerId, true);
verify(player).sendMessage(org.mockito.ArgumentMatchers.<String>argThat(
message -> message.contains("Pocket Base I") && message.contains("locked")
));
}
@Test
void failedPocketUpgradeRestoresPayment() throws Exception {
UUID playerId = UUID.randomUUID();
Player player = mock(Player.class);
when(player.getUniqueId()).thenReturn(playerId);
when(player.getName()).thenReturn("Builder");
PlayerInventory inventory = mock(PlayerInventory.class);
ItemStack currency = item(Material.DIAMOND_BLOCK, 64);
when(player.getInventory()).thenReturn(inventory);
when(inventory.getStorageContents()).thenReturn(new ItemStack[] {currency});
PlayerState owner = PlayerState.newPlayer(playerId, "Builder")
.withAdministrativeLevels(4, 0, 0, 0, 0, false, false, true)
.withBase(
new BaseLocation(UUID.randomUUID(), "world", 0, 64, 0, 0, 0),
Instant.EPOCH
);
BaseStateManager stateManager = mock(BaseStateManager.class);
when(stateManager.player(playerId, "Builder")).thenReturn(owner);
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
when(pocketBases.state(playerId)).thenReturn(PocketBaseState.locked(playerId));
when(pocketBases.upgrade(playerId)).thenThrow(new IOException("save failed"));
BaseSettingsCommand command = new BaseSettingsCommand(
stateManager,
new PluginSettingsProvider(PluginSettings.from(Map.of())),
mock(BaseFlightController.class),
pocketBases
);
command.onCommand(player, null, "basesettings", new String[] {"pocket", "upgrade"});
ArgumentCaptor<ItemStack[]> contents = ArgumentCaptor.forClass(ItemStack[].class);
verify(inventory, times(2)).setStorageContents(contents.capture());
ItemStack[] restored = contents.getAllValues().get(1);
assertEquals(Material.DIAMOND_BLOCK, restored[0].getType());
assertEquals(64, restored[0].getAmount());
}
@Test
void ownerCanDisableAutomaticPocketBaseFlight() throws Exception {
UUID playerId = UUID.randomUUID();
Player player = mock(Player.class);
when(player.getUniqueId()).thenReturn(playerId);
when(player.getName()).thenReturn("Builder");
BaseStateManager stateManager = mock(BaseStateManager.class);
when(stateManager.player(playerId, "Builder")).thenReturn(
PlayerState.newPlayer(playerId, "Builder")
);
PocketBaseState enabled = new PocketBaseState(playerId, 1, Optional.empty())
.withFlightUnlocked();
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
when(pocketBases.state(playerId)).thenReturn(enabled);
when(pocketBases.setFlightEnabled(playerId, false)).thenReturn(
enabled.withFlightEnabled(false)
);
BaseSettingsCommand command = new BaseSettingsCommand(
stateManager,
new PluginSettingsProvider(PluginSettings.from(Map.of())),
mock(BaseFlightController.class),
pocketBases
);
command.onCommand(
player, null, "basesettings",
new String[] {"pocket", "flight", "disable"}
);
verify(pocketBases).setFlightEnabled(playerId, false);
verify(player).sendMessage(org.mockito.ArgumentMatchers.<String>argThat(
message -> message.contains("automatic flight") && message.contains("disabled")
));
}
@Test
void statusDisplaysPocketMobSpawningPreferences() {
UUID playerId = UUID.randomUUID();
Player player = mock(Player.class);
when(player.getUniqueId()).thenReturn(playerId);
when(player.getName()).thenReturn("Builder");
BaseStateManager stateManager = mock(BaseStateManager.class);
when(stateManager.player(playerId, "Builder"))
.thenReturn(PlayerState.newPlayer(playerId, "Builder"));
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
when(pocketBases.state(playerId)).thenReturn(
new PocketBaseState(
playerId, 1, java.util.Optional.empty(), java.util.Optional.empty(),
true, false, PocketBaseBiome.THE_VOID
)
);
BaseSettingsCommand command = new BaseSettingsCommand(
stateManager,
new PluginSettingsProvider(PluginSettings.from(Map.of())),
mock(BaseFlightController.class),
pocketBases
);
command.onCommand(player, null, "basesettings", new String[] {"status"});
verify(player).sendMessage(org.mockito.ArgumentMatchers.<String>argThat(
message -> message.contains("Pocket Base 1")
&& message.contains("type=void/the_void")
&& message.contains("hostile spawning=enabled")
&& message.contains("passive spawning=disabled")
&& message.contains("flight=complete a raid to unlock")
));
}
@Test
void bossbarEnableIsIdempotent() {
UUID playerId = UUID.randomUUID();
PlayerState current = PlayerState.newPlayer(playerId, "Miner");
CommandResult result = execute(current, "bossbar", "enable");
assertTrue(result.updated().bossBarEnabled());
}
@Test
void flightDisableIsIdempotentAndRemovesGrantedFlight() {
UUID playerId = UUID.randomUUID();
PlayerState current = PlayerState.newPlayer(playerId, "Pilot")
.withAdministrativeLevels(1, 0, 1, 0, 0, false, true, false);
CommandResult result = execute(current, "flight", "disable");
assertFalse(result.updated().flightEnabled());
verify(result.flightController()).removeGrantedFlight(any(Player.class));
}
@Test
void navigationDisableIsIdempotent() {
UUID playerId = UUID.randomUUID();
PlayerState current = PlayerState.newPlayer(playerId, "Explorer")
.withAdministrativeLevels(2, 0, 0, 0, 0, true, false, false)
.withBase(
new BaseLocation(UUID.randomUUID(), "world", 0, 64, 0, 0, 0),
Instant.EPOCH
);
CommandResult result = execute(current, "navigation", "disable");
assertFalse(result.updated().navigationEnabled());
}
@Test
void visitorsAllowedIsIdempotent() {
UUID playerId = UUID.randomUUID();
PlayerState current = PlayerState.newPlayer(playerId, "Host")
.withAdministrativeLevels(4, 0, 0, 0, 0, false, false, true);
CommandResult result = execute(current, "visitors", "allowed");
assertTrue(result.updated().visitorsEnabled());
}
@Test
void defaultAndUiOpenDialogWhileStatusDisplaysTheFullProgressReport() {
UUID playerId = UUID.randomUUID();
Player player = mock(Player.class);
when(player.getUniqueId()).thenReturn(playerId);
when(player.getName()).thenReturn("Builder");
BaseStateManager stateManager = mock(BaseStateManager.class);
when(stateManager.player(playerId, "Builder"))
.thenReturn(PlayerState.newPlayer(playerId, "Builder"));
BaseSettingsCommand command = new BaseSettingsCommand(
stateManager,
new PluginSettingsProvider(PluginSettings.from(Map.of())),
mock(BaseFlightController.class),
null,
(owner, pocket) -> null
);
command.onCommand(player, null, "basesettings", new String[0]);
command.onCommand(player, null, "homesettings", new String[] {"ui"});
command.onCommand(player, null, "basesettings", new String[] {"status"});
verify(player, times(2)).showDialog((DialogLike) null);
verify(player).sendMessage(ChatColor.GOLD + "=== Base Progress ===");
verify(player).sendMessage(
org.mockito.ArgumentMatchers.<String>argThat(message ->
message.contains("Spawnable Overlay:")
&& message.contains("0/250 placements")
&& message.contains("locked")
)
);
verify(player).sendMessage(
org.mockito.ArgumentMatchers.<String>argThat(message ->
message.contains("Settings: visitors=")
&& message.contains("navigation=")
&& message.contains("flight=")
&& message.contains("border=")
&& message.contains("spawnable=")
&& message.contains("bossbar=")
)
);
}
private static ItemStack item(Material material, int amount) {
ItemStack item = mock(ItemStack.class);
ItemStack copy = mock(ItemStack.class);
when(item.getType()).thenReturn(material);
when(item.getAmount()).thenReturn(amount);
when(item.clone()).thenReturn(copy);
when(copy.getType()).thenReturn(material);
when(copy.getAmount()).thenReturn(amount);
when(copy.clone()).thenReturn(copy);
return item;
}
private static CommandResult execute(PlayerState current, String... arguments) {
Player player = mock(Player.class);
when(player.getUniqueId()).thenReturn(current.playerId());
when(player.getName()).thenReturn(current.latestName());
AtomicReference<PlayerState> updated = new AtomicReference<>();
BaseStateManager stateManager = mock(BaseStateManager.class);
when(stateManager.player(current.playerId(), current.latestName())).thenReturn(current);
when(stateManager.update(any(), anyString(), any())).thenAnswer(invocation -> {
@SuppressWarnings("unchecked")
UnaryOperator<PlayerState> operation = invocation.getArgument(2);
PlayerState result = operation.apply(current);
updated.set(result);
return result;
});
BaseFlightController flightController = mock(BaseFlightController.class);
BaseSettingsCommand command = new BaseSettingsCommand(
stateManager,
new PluginSettingsProvider(PluginSettings.from(Map.of())),
flightController
);
command.onCommand(player, null, "basesettings", arguments);
return new CommandResult(updated.get(), flightController);
}
private record CommandResult(PlayerState updated, BaseFlightController flightController) {
}
}
@@ -0,0 +1,170 @@
package games.dmg.spigotbase;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import net.kyori.adventure.text.event.ClickEvent;
import org.junit.jupiter.api.Test;
final class BaseSettingsDialogFactoryTest {
@Test
void createsStaticRunCommandActionWithoutMacroVariables() {
ClickEvent<ClickEvent.Payload.Text> action = BaseSettingsDialogFactory.commandClick(
"/basesettings status"
);
assertEquals(ClickEvent.Action.RUN_COMMAND, action.action());
ClickEvent.Payload.Text payload = assertInstanceOf(
ClickEvent.Payload.Text.class,
action.payload()
);
assertEquals("/basesettings status", payload.value());
}
@Test
void createsBackButtonThatReturnsToMainSettingsDashboard() {
BaseSettingsDialogFactory.ButtonSpec back =
BaseSettingsDialogFactory.backButtonSpec();
assertEquals("Back", back.label());
assertEquals("/basesettings ui", back.command());
}
@Test
void createsControlsForEveryBaseSettingAndConfirmedPurchase() {
UUID playerId = UUID.randomUUID();
PlayerState owner = PlayerState.newPlayer(playerId, "Builder")
.withAdministrativeLevels(4, 2, 2, 1, 1, true, true, true)
.withBase(
new BaseLocation(UUID.randomUUID(), "world", 10, 64, 20, 10, 20),
Instant.EPOCH
)
.withBorderEnabled(true)
.withTotalBlocksPlaced(250)
.withSpawnableOverlayEnabled(true);
PocketBaseState pocket = new PocketBaseState(
playerId,
1,
Optional.empty(),
Optional.empty(),
true,
false,
PocketBaseBiome.PLAINS,
true
);
BaseSettingsDialogFactory.DialogSpec specification =
new BaseSettingsDialogFactory(PluginSettings.from(Map.of()))
.specification(owner, pocket);
BaseSettingsDialogFactory.ListSpec root = assertInstanceOf(
BaseSettingsDialogFactory.ListSpec.class,
specification
);
assertTrue(root.content().message().contains("Base 4/4"));
BaseSettingsDialogFactory.ListSpec pocketSettings = assertInstanceOf(
BaseSettingsDialogFactory.ListSpec.class,
root.dialogs().get(3)
);
assertTrue(pocketSettings.content().message().contains(
"Flight: Enabled"
));
List<String> commands = commands(root);
assertTrue(commands.contains("/basesettings status"));
assertTrue(commands.contains("/basesettings visitors blocked"));
assertTrue(commands.contains("/basesettings navigation disable"));
assertTrue(commands.contains("/basesettings flight disable"));
assertTrue(commands.contains("/basesettings border disable"));
assertTrue(commands.contains("/basesettings spawnable disable"));
assertTrue(commands.contains("/basesettings bossbar disable"));
assertTrue(commands.contains("/basesettings pocket upgrade"));
assertTrue(commands.contains("/basesettings pocket flight disable"));
assertTrue(commands.contains("/basesettings pocket mobs hostile disable"));
assertTrue(commands.contains("/basesettings pocket mobs passive enable"));
assertTrue(commands.contains("/basesettings pocket type nether crimson_forest"));
}
@Test
void requiresConfirmationForBaseIvPurchase() {
UUID playerId = UUID.randomUUID();
PlayerState owner = PlayerState.newPlayer(playerId, "Builder")
.withAdministrativeLevels(3, 0, 0, 0, 0, false, false, false)
.withBase(
new BaseLocation(UUID.randomUUID(), "world", 0, 64, 0, 0, 0),
Instant.EPOCH
);
BaseSettingsDialogFactory.ListSpec root = assertInstanceOf(
BaseSettingsDialogFactory.ListSpec.class,
new BaseSettingsDialogFactory(PluginSettings.from(Map.of())).specification(
owner,
PocketBaseState.locked(playerId)
)
);
BaseSettingsDialogFactory.ConfirmationSpec upgrade = assertInstanceOf(
BaseSettingsDialogFactory.ConfirmationSpec.class,
root.dialogs().get(2)
);
assertEquals("/basesettings upgrade", upgrade.confirm().command());
}
@Test
void marksUnavailableControlsAsLocked() {
UUID playerId = UUID.randomUUID();
PlayerState owner = PlayerState.newPlayer(playerId, "Newcomer");
BaseSettingsDialogFactory.ListSpec root = assertInstanceOf(
BaseSettingsDialogFactory.ListSpec.class,
new BaseSettingsDialogFactory(PluginSettings.from(Map.of())).specification(
owner,
PocketBaseState.locked(playerId)
)
);
BaseSettingsDialogFactory.MultiSpec controls = assertInstanceOf(
BaseSettingsDialogFactory.MultiSpec.class,
root.dialogs().get(0)
);
List<String> labels = controls.actions().stream()
.map(BaseSettingsDialogFactory.ButtonSpec::label)
.toList();
assertTrue(labels.stream().anyMatch(label -> label.contains("Visitors: Locked")));
assertTrue(labels.stream().anyMatch(label -> label.contains("Navigation: Locked")));
assertTrue(labels.stream().anyMatch(label -> label.contains("Flight: Locked")));
assertTrue(labels.stream().anyMatch(label -> label.contains("Border: Locked")));
assertTrue(labels.stream().anyMatch(label -> label.contains("Spawnable Overlay: Locked")));
}
private static List<String> commands(
BaseSettingsDialogFactory.DialogSpec specification
) {
List<String> commands = new ArrayList<>();
collect(specification, commands);
return commands;
}
private static void collect(
BaseSettingsDialogFactory.DialogSpec specification,
List<String> commands
) {
if (specification instanceof BaseSettingsDialogFactory.ListSpec list) {
list.dialogs().forEach(child -> collect(child, commands));
} else if (specification instanceof BaseSettingsDialogFactory.MultiSpec multi) {
multi.actions().forEach(button -> commands.add(button.command()));
} else if (specification
instanceof BaseSettingsDialogFactory.ConfirmationSpec confirmation) {
commands.add(confirmation.confirm().command());
} else if (specification instanceof BaseSettingsDialogFactory.NoticeSpec notice
&& notice.action() != null) {
commands.add(notice.action().command());
}
}
}
@@ -0,0 +1,140 @@
package games.dmg.spigotbase;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.Optional;
import java.util.UUID;
import org.bukkit.ChatColor;
import org.bukkit.World;
import org.bukkit.entity.Player;
import org.bukkit.plugin.Plugin;
import org.junit.jupiter.api.Test;
final class BaseTeleportManagerTest {
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.CsvSource({
"false,true,false", "true,true,false", "false,false,false", "true,false,false",
"false,false,true", "true,false,true"
})
void particlesFollowHomeAndVisitorTeleportOutcome(boolean visit, boolean succeeds, boolean cancelled) {
UUID id = UUID.randomUUID();
UUID worldId = UUID.randomUUID();
World world = mock(World.class);
when(world.getUID()).thenReturn(worldId);
org.bukkit.Location origin = new org.bukkit.Location(world, 0, 64, 0);
org.bukkit.Location target = new org.bukkit.Location(world, 20, 64, 20);
Player player = mock(Player.class);
when(player.getUniqueId()).thenReturn(id);
when(player.getName()).thenReturn("Player");
when(player.getWorld()).thenReturn(world);
when(player.getLocation()).thenReturn(origin);
when(player.isOnline()).thenReturn(true);
when(player.teleport(eq(target), any(org.bukkit.event.player.PlayerTeleportEvent.TeleportCause.class)))
.thenReturn(succeeds);
PlayerState owner = PlayerState.newPlayer(visit ? UUID.randomUUID() : id, "Owner")
.withGrassAndDirtProgress(500, 4)
.withBase(new BaseLocation(worldId, "world", 20, 64, 20, 0, 0), Instant.EPOCH)
.withVisitorsEnabled(true);
BaseStateManager states = mock(BaseStateManager.class);
when(states.player(id, "Player")).thenReturn(owner);
TeleportPolicy policy = mock(TeleportPolicy.class);
when(policy.warmup(owner)).thenReturn(Duration.ofSeconds(1));
when(policy.visitorWarmup(owner)).thenReturn(Duration.ofSeconds(1));
when(policy.cooldown(owner)).thenReturn(Duration.ZERO);
VisitorPolicy visitors = mock(VisitorPolicy.class);
SafeBaseDestination finder = mock(SafeBaseDestination.class);
when(finder.find(world, owner.base().orElseThrow())).thenReturn(Optional.of(target));
TeleportParticles particles = mock(TeleportParticles.class);
Plugin plugin = mock(Plugin.class);
org.bukkit.scheduler.BukkitScheduler scheduler = mock(org.bukkit.scheduler.BukkitScheduler.class);
when(scheduler.runTaskTimer(eq(plugin), any(Runnable.class), eq(0L), eq(20L)))
.thenReturn(mock(org.bukkit.scheduler.BukkitTask.class));
try (org.mockito.MockedStatic<org.bukkit.Bukkit> bukkit = org.mockito.Mockito.mockStatic(org.bukkit.Bukkit.class)) {
bukkit.when(org.bukkit.Bukkit::getScheduler).thenReturn(scheduler);
bukkit.when(() -> org.bukkit.Bukkit.getWorld(worldId)).thenReturn(world);
BaseTeleportManager manager = new BaseTeleportManager(plugin, states, policy,
visitors, finder, Clock.systemUTC(), particles);
if (visit) manager.startVisit(player, owner); else manager.start(player);
verify(particles).start(id, origin, 1);
org.mockito.ArgumentCaptor<Runnable> tick = org.mockito.ArgumentCaptor.forClass(Runnable.class);
verify(scheduler).runTaskTimer(eq(plugin), tick.capture(), eq(0L), eq(20L));
tick.getValue().run();
org.mockito.Mockito.verify(player, org.mockito.Mockito.never()).teleport(eq(target),
any(org.bukkit.event.player.PlayerTeleportEvent.TeleportCause.class));
if (cancelled) {
org.bukkit.event.player.PlayerQuitEvent quit = mock(org.bukkit.event.player.PlayerQuitEvent.class);
when(quit.getPlayer()).thenReturn(player);
manager.onQuit(quit);
verify(particles).cancel(id);
org.mockito.Mockito.verify(particles, org.mockito.Mockito.never()).complete(id);
org.mockito.Mockito.verify(states, org.mockito.Mockito.never()).saveIfDirty();
manager.cancelAll();
verify(particles).clear();
return;
}
tick.getValue().run();
if (succeeds) {
verify(particles).complete(id);
org.mockito.Mockito.verify(particles, org.mockito.Mockito.never()).cancel(id);
verify(states).saveIfDirty();
} else {
verify(particles).cancel(id);
org.mockito.Mockito.verify(particles, org.mockito.Mockito.never()).complete(id);
org.mockito.Mockito.verify(states, org.mockito.Mockito.never()).saveIfDirty();
}
manager.cancelAll();
verify(particles).clear();
}
}
@Test
void visitFromAnotherWorldIsRejectedBeforeWarmupAndCooldownChecks() {
UUID visitorId = UUID.randomUUID();
UUID ownerId = UUID.randomUUID();
UUID baseWorldId = UUID.randomUUID();
BaseStateManager stateManager = mock(BaseStateManager.class);
TeleportPolicy teleportPolicy = mock(TeleportPolicy.class);
VisitorPolicy visitorPolicy = mock(VisitorPolicy.class);
Player visitor = mock(Player.class);
World visitorWorld = mock(World.class);
PlayerState owner = PlayerState.newPlayer(ownerId, "Owner")
.withGrassAndDirtProgress(500, 4)
.withBase(
new BaseLocation(baseWorldId, "world", 0, 64, 0, 0, 0),
Instant.EPOCH
)
.withVisitorsEnabled(true);
PlayerState visitorState = PlayerState.newPlayer(visitorId, "Visitor");
BaseTeleportManager manager = new BaseTeleportManager(
mock(Plugin.class),
stateManager,
teleportPolicy,
visitorPolicy,
mock(SafeBaseDestination.class),
Clock.systemUTC()
);
when(visitor.getUniqueId()).thenReturn(visitorId);
when(visitor.getName()).thenReturn("Visitor");
when(visitor.getWorld()).thenReturn(visitorWorld);
when(visitorWorld.getUID()).thenReturn(UUID.randomUUID());
when(stateManager.player(visitorId, "Visitor")).thenReturn(visitorState);
when(visitorPolicy.remaining(eq(visitorState), eq(ownerId), any()))
.thenReturn(Optional.of(Duration.ofSeconds(1)));
manager.startVisit(visitor, owner);
verify(visitor).sendMessage(
ChatColor.RED + "You must be in the same dimension as that base to visit it."
);
verifyNoInteractions(stateManager, teleportPolicy, visitorPolicy);
}
}
@@ -1,13 +1,48 @@
package games.dmg.spigotbase;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.time.Instant;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import org.junit.jupiter.api.Test;
final class PlayerStateTest {
@Test
void spawnableOverlayProgressAndPreferenceCanBeUpdated() {
PlayerState player = PlayerState.newPlayer(UUID.randomUUID(), "Alex")
.withAdministrativeLevels(1, 0, 0, 0, 0, false, false, false)
.withBase(
new BaseLocation(UUID.randomUUID(), "world", 0, 64, 0, 0, 0),
Instant.EPOCH
);
PlayerState updated = player
.withTotalBlocksPlaced(250)
.withSpawnableOverlayEnabled(true);
assertTrue(updated.spawnableOverlayEnabled());
assertTrue(updated.totalBlocksPlaced() == 250);
}
@Test
void borderVisualizationIsDisabledByDefaultAndCanBeEnabled() {
UUID playerId = UUID.randomUUID();
PlayerState player = PlayerState.newPlayer(playerId, "Alex");
PlayerState established = player
.withAdministrativeLevels(1, 0, 0, 0, 0, false, false, false)
.withBase(
new BaseLocation(UUID.randomUUID(), "world", 0, 64, 0, 0, 0),
Instant.EPOCH
);
assertFalse(player.borderEnabled());
assertTrue(established.withBorderEnabled(true).borderEnabled());
}
@Test
void rejectsLevelsOutsideKnownRanges() {
UUID playerId = UUID.randomUUID();
@@ -1,14 +1,68 @@
package games.dmg.spigotbase;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import java.io.InputStream;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.yaml.snakeyaml.Yaml;
final class PluginMetadataTest {
@Test
void homeadminAliasesBaseadmin() {
Map<?, ?> commands = commands();
Map<?, ?> admin = (Map<?, ?>) commands.get("baseadmin");
assertEquals(List.of("homeadmin"), admin.get("aliases"));
}
@Test
void doesNotDeclareSupersededSettingsCommands() {
Map<?, ?> commands = commands();
for (String command : List.of(
"baseprogress", "basenavigation", "baseflight", "basevisitors"
)) {
assertFalse(commands.containsKey(command), command);
}
}
@Test
void homesettingsAliasesBasesettings() {
Map<?, ?> commands = commands();
Map<?, ?> settings = (Map<?, ?>) commands.get("basesettings");
assertNotNull(settings);
assertEquals(List.of("homesettings"), settings.get("aliases"));
}
@Test
void sethomeAliasesSetbase() {
Map<?, ?> commands = commands();
Map<?, ?> setbase = (Map<?, ?>) commands.get("setbase");
assertEquals(List.of("sethome"), setbase.get("aliases"));
}
@Test
void homeAliasesBase() {
Map<?, ?> commands = commands();
Map<?, ?> base = (Map<?, ?>) commands.get("base");
assertEquals(List.of("home"), base.get("aliases"));
}
@Test
void visitAliasesGotobase() {
Map<?, ?> commands = commands();
Map<?, ?> gotobase = (Map<?, ?>) commands.get("gotobase");
assertEquals(List.of("visit"), gotobase.get("aliases"));
}
@Test
void declaresPluginEntrypointCommandsAndPermissions() {
InputStream stream = getClass().getClassLoader().getResourceAsStream("plugin.yml");
@@ -21,8 +75,7 @@ final class PluginMetadataTest {
Map<?, ?> commands = (Map<?, ?>) plugin.get("commands");
for (String command : new String[] {
"setbase", "base", "basenavigation", "baseflight",
"basevisitors", "gotobase", "baseprogress", "baseadmin"
"setbase", "base", "gotobase", "basesettings", "baseadmin"
}) {
assertNotNull(commands.get(command), command);
}
@@ -30,4 +83,11 @@ final class PluginMetadataTest {
Map<?, ?> permissions = (Map<?, ?>) plugin.get("permissions");
assertNotNull(permissions.get("spigotbase.admin"));
}
private Map<?, ?> commands() {
InputStream stream = getClass().getClassLoader().getResourceAsStream("plugin.yml");
assertNotNull(stream);
Map<?, ?> plugin = new Yaml().load(stream);
return (Map<?, ?>) plugin.get("commands");
}
}
@@ -18,8 +18,34 @@ final class PluginSettingsTest {
assertEquals(86_400L, settings.relocationCooldownSeconds());
assertEquals(5, settings.flightWarningBuffer());
assertEquals(200, settings.teleportUnlockPlacements());
assertEquals(250, settings.spawnableOverlayUnlockPlacements());
assertEquals(10_800L, settings.initialTeleportCooldownSeconds());
assertEquals(128, settings.visitorUnlockDiamondCost());
assertEquals(64, settings.pocketBaseUnlockCost());
assertEquals(16, settings.pocketBaseUpgradeCost());
assertEquals("DIAMOND_BLOCK", settings.pocketBaseCurrencyMaterial());
assertEquals("DIAMOND_BLOCK", settings.pocketBasePortalFrameMaterial());
assertEquals(16, settings.pocketBaseBiomeChangeCost());
assertEquals("NETHERITE_BLOCK", settings.pocketBaseBiomeCurrencyMaterial());
}
@Test
void pocketBaseCostsAndMaterialsAreConfigurable() {
PluginSettings settings = PluginSettings.from(Map.of(
"pocket-base-unlock-cost", 32,
"pocket-base-upgrade-cost", 8,
"pocket-base-currency-material", "EMERALD_BLOCK",
"pocket-base-portal-frame-material", "GOLD_BLOCK",
"pocket-base-biome-change-cost", 12,
"pocket-base-biome-currency-material", "NETHERITE_INGOT"
));
assertEquals(32, settings.pocketBaseUnlockCost());
assertEquals(8, settings.pocketBaseUpgradeCost());
assertEquals("EMERALD_BLOCK", settings.pocketBaseCurrencyMaterial());
assertEquals("GOLD_BLOCK", settings.pocketBasePortalFrameMaterial());
assertEquals(12, settings.pocketBaseBiomeChangeCost());
assertEquals("NETHERITE_INGOT", settings.pocketBaseBiomeCurrencyMaterial());
}
@Test
@@ -0,0 +1,594 @@
package games.dmg.spigotbase;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.time.Instant;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.logging.Logger;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.Server;
import org.bukkit.World;
import org.bukkit.block.Block;
import org.bukkit.entity.Entity;
import org.bukkit.entity.Mob;
import org.bukkit.entity.Player;
import org.bukkit.event.block.Action;
import org.bukkit.event.block.BlockBreakEvent;
import org.bukkit.event.block.BlockPlaceEvent;
import org.bukkit.event.player.PlayerInteractEvent;
import org.bukkit.event.player.PlayerMoveEvent;
import org.bukkit.event.player.PlayerTeleportEvent;
import org.bukkit.inventory.ItemStack;
import org.bukkit.util.BoundingBox;
import org.junit.jupiter.api.Test;
final class PocketBaseControllerTest {
@Test
void ownerCanActivateCompleteFrameInsideNormalBase() throws Exception {
UUID ownerId = UUID.randomUUID();
UUID worldId = UUID.randomUUID();
PocketPortalLocation portal = new PocketPortalLocation(
worldId, "world", -2, 64, 0, PocketPortalAxis.X
);
Set<BlockPosition> frame = new HashSet<>(PocketPortalGeometry.frameBlocks(portal));
Player owner = mock(Player.class);
World world = mock(World.class);
Block clicked = block(world, -2, 66, 0, Material.DIAMOND_BLOCK);
PlayerInteractEvent event = mock(PlayerInteractEvent.class);
ItemStack activator = item(Material.FLINT_AND_STEEL);
BaseStateManager baseStates = mock(BaseStateManager.class);
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
PluginSettings settings = PluginSettings.from(Map.of());
PlayerState ownerState = PlayerState.newPlayer(ownerId, "Owner")
.withAdministrativeLevels(4, 0, 0, 0, 0, false, false, true)
.withBase(new BaseLocation(worldId, "world", 0, 64, 0, 0, 0), Instant.EPOCH);
when(owner.getUniqueId()).thenReturn(ownerId);
when(owner.getName()).thenReturn("Owner");
when(event.getPlayer()).thenReturn(owner);
when(event.getAction()).thenReturn(Action.RIGHT_CLICK_BLOCK);
when(event.getClickedBlock()).thenReturn(clicked);
when(event.getItem()).thenReturn(activator);
when(world.getUID()).thenReturn(worldId);
when(world.getName()).thenReturn("world");
when(world.getBlockAt(anyInt(), anyInt(), anyInt())).thenAnswer(invocation -> {
BlockPosition position = new BlockPosition(
invocation.getArgument(0), invocation.getArgument(1), invocation.getArgument(2)
);
return block(
world,
position.x(),
position.y(),
position.z(),
frame.contains(position) ? Material.DIAMOND_BLOCK : Material.AIR
);
});
when(baseStates.player(ownerId, "Owner")).thenReturn(ownerState);
when(pocketBases.state(ownerId)).thenReturn(
new PocketBaseState(ownerId, 1, Optional.empty())
);
when(pocketBases.activateEntrance(ownerId, portal)).thenReturn(
new PocketBaseState(ownerId, 1, Optional.of(portal))
);
PocketBaseController controller = new PocketBaseController(
mock(Server.class),
baseStates,
new BaseBoundsService(settings),
pocketBases,
new PluginSettingsProvider(settings),
Logger.getAnonymousLogger()
);
controller.onActivate(event);
verify(pocketBases).activateEntrance(ownerId, portal);
verify(event).setCancelled(true);
}
@Test
void ownerCanActivateCompleteReturnFrameInsideOwnPocketBase() throws Exception {
UUID ownerId = UUID.randomUUID();
UUID worldId = UUID.randomUUID();
PocketPortalLocation portal = new PocketPortalLocation(
worldId, "pocket", 8, 65, 8, PocketPortalAxis.X
);
Set<BlockPosition> frame = new HashSet<>(PocketPortalGeometry.frameBlocks(portal));
Player owner = mock(Player.class);
World world = mock(World.class);
Block clicked = block(world, 8, 67, 8, Material.DIAMOND_BLOCK);
PlayerInteractEvent event = mock(PlayerInteractEvent.class);
ItemStack activator = item(Material.FLINT_AND_STEEL);
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
PluginSettings settings = PluginSettings.from(Map.of());
when(owner.getUniqueId()).thenReturn(ownerId);
when(event.getPlayer()).thenReturn(owner);
when(event.getAction()).thenReturn(Action.RIGHT_CLICK_BLOCK);
when(event.getClickedBlock()).thenReturn(clicked);
when(event.getItem()).thenReturn(activator);
when(world.getUID()).thenReturn(worldId);
when(world.getName()).thenReturn("pocket");
when(world.getBlockAt(anyInt(), anyInt(), anyInt())).thenAnswer(invocation -> {
BlockPosition position = new BlockPosition(
invocation.getArgument(0), invocation.getArgument(1), invocation.getArgument(2)
);
return block(
world,
position.x(),
position.y(),
position.z(),
frame.contains(position) ? Material.DIAMOND_BLOCK : Material.AIR
);
});
when(pocketBases.ownerForPocketWorld(worldId)).thenReturn(Optional.of(ownerId));
when(pocketBases.state(ownerId)).thenReturn(
new PocketBaseState(ownerId, 1, Optional.empty())
);
when(pocketBases.policy()).thenReturn(new PocketBasePolicy(settings));
PocketBaseController controller = new PocketBaseController(
mock(Server.class),
mock(BaseStateManager.class),
new BaseBoundsService(settings),
pocketBases,
new PluginSettingsProvider(settings),
Logger.getAnonymousLogger()
);
controller.onActivate(event);
verify(pocketBases).activateReturnPortal(ownerId, portal);
verify(event).setCancelled(true);
}
@Test
void ownerCanActivateGoldFlightPortalAfterRaidUnlock() throws Exception {
UUID ownerId = UUID.randomUUID();
UUID worldId = UUID.randomUUID();
PocketPortalLocation portal = new PocketPortalLocation(
worldId, "pocket", 8, 65, 8, PocketPortalAxis.X
);
Set<BlockPosition> frame = new HashSet<>(PocketPortalGeometry.frameBlocks(portal));
Player owner = mock(Player.class);
World world = mock(World.class);
Block clicked = block(world, 8, 67, 8, Material.GOLD_BLOCK);
PlayerInteractEvent event = mock(PlayerInteractEvent.class);
ItemStack activator = item(Material.FLINT_AND_STEEL);
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
PluginSettings settings = PluginSettings.from(Map.of());
PocketBaseState state = new PocketBaseState(ownerId, 1, Optional.empty())
.withFlightUnlocked();
when(owner.getUniqueId()).thenReturn(ownerId);
when(event.getPlayer()).thenReturn(owner);
when(event.getAction()).thenReturn(Action.RIGHT_CLICK_BLOCK);
when(event.getClickedBlock()).thenReturn(clicked);
when(event.getItem()).thenReturn(activator);
when(world.getUID()).thenReturn(worldId);
when(world.getName()).thenReturn("pocket");
when(world.getBlockAt(anyInt(), anyInt(), anyInt())).thenAnswer(invocation -> {
BlockPosition position = new BlockPosition(
invocation.getArgument(0), invocation.getArgument(1), invocation.getArgument(2)
);
return block(
world,
position.x(),
position.y(),
position.z(),
frame.contains(position) ? Material.GOLD_BLOCK : Material.AIR
);
});
when(pocketBases.ownerForPocketWorld(worldId)).thenReturn(Optional.of(ownerId));
when(pocketBases.state(ownerId)).thenReturn(state);
when(pocketBases.policy()).thenReturn(new PocketBasePolicy(settings));
PocketBaseController controller = new PocketBaseController(
mock(Server.class),
mock(BaseStateManager.class),
new BaseBoundsService(settings),
pocketBases,
new PluginSettingsProvider(settings),
Logger.getAnonymousLogger(),
mock(BaseFlightController.class)
);
controller.onActivate(event);
verify(pocketBases).activateFlightPortal(ownerId, portal);
verify(event).setCancelled(true);
}
@Test
void returnFrameOutsideUnlockedPocketBoundaryIsRejected() throws Exception {
UUID ownerId = UUID.randomUUID();
UUID worldId = UUID.randomUUID();
PocketPortalLocation portal = new PocketPortalLocation(
worldId, "pocket", 30, 65, 8, PocketPortalAxis.X
);
Set<BlockPosition> frame = new HashSet<>(PocketPortalGeometry.frameBlocks(portal));
Player owner = mock(Player.class);
World world = mock(World.class);
Block clicked = block(world, 30, 67, 8, Material.DIAMOND_BLOCK);
PlayerInteractEvent event = mock(PlayerInteractEvent.class);
ItemStack activator = item(Material.FLINT_AND_STEEL);
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
PluginSettings settings = PluginSettings.from(Map.of());
when(owner.getUniqueId()).thenReturn(ownerId);
when(event.getPlayer()).thenReturn(owner);
when(event.getAction()).thenReturn(Action.RIGHT_CLICK_BLOCK);
when(event.getClickedBlock()).thenReturn(clicked);
when(event.getItem()).thenReturn(activator);
when(world.getUID()).thenReturn(worldId);
when(world.getName()).thenReturn("pocket");
when(world.getBlockAt(anyInt(), anyInt(), anyInt())).thenAnswer(invocation -> {
BlockPosition position = new BlockPosition(
invocation.getArgument(0), invocation.getArgument(1), invocation.getArgument(2)
);
return block(
world,
position.x(),
position.y(),
position.z(),
frame.contains(position) ? Material.DIAMOND_BLOCK : Material.AIR
);
});
when(pocketBases.ownerForPocketWorld(worldId)).thenReturn(Optional.of(ownerId));
when(pocketBases.state(ownerId)).thenReturn(
new PocketBaseState(ownerId, 1, Optional.empty())
);
when(pocketBases.policy()).thenReturn(new PocketBasePolicy(settings));
PocketBaseController controller = new PocketBaseController(
mock(Server.class),
mock(BaseStateManager.class),
new BaseBoundsService(settings),
pocketBases,
new PluginSettingsProvider(settings),
Logger.getAnonymousLogger()
);
controller.onActivate(event);
verify(pocketBases, never()).activateReturnPortal(ownerId, portal);
verify(event).setCancelled(true);
}
@Test
void breakingActiveFlightPortalFrameDeactivatesIt() throws Exception {
UUID ownerId = UUID.randomUUID();
UUID worldId = UUID.randomUUID();
PocketPortalLocation flightPortal = new PocketPortalLocation(
worldId, "pocket", 8, 65, 8, PocketPortalAxis.X
);
PocketBaseState state = new PocketBaseState(ownerId, 1, Optional.empty())
.withFlightUnlocked()
.withFlightPortal(flightPortal);
World world = mock(World.class);
BlockBreakEvent event = mock(BlockBreakEvent.class);
Block broken = block(world, 8, 65, 8, Material.GOLD_BLOCK);
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
PluginSettings settings = PluginSettings.from(Map.of());
when(world.getUID()).thenReturn(worldId);
when(event.getBlock()).thenReturn(broken);
when(pocketBases.knownStates()).thenReturn(Map.of(ownerId, state));
PocketBaseController controller = new PocketBaseController(
mock(Server.class),
mock(BaseStateManager.class),
new BaseBoundsService(settings),
pocketBases,
new PluginSettingsProvider(settings),
Logger.getAnonymousLogger()
);
controller.onBreak(event);
verify(pocketBases).deactivateFlightPortal(ownerId);
}
@Test
void placementOutsideUnlockedPocketBoundaryIsCancelled() {
UUID ownerId = UUID.randomUUID();
UUID worldId = UUID.randomUUID();
World world = mock(World.class);
Block placed = block(world, 32, 65, 0, Material.STONE);
Player player = mock(Player.class);
BlockPlaceEvent event = mock(BlockPlaceEvent.class);
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
PluginSettings settings = PluginSettings.from(Map.of());
when(world.getUID()).thenReturn(worldId);
when(event.getBlockPlaced()).thenReturn(placed);
when(event.getPlayer()).thenReturn(player);
when(pocketBases.ownerForPocketWorld(worldId)).thenReturn(Optional.of(ownerId));
when(pocketBases.state(ownerId)).thenReturn(
new PocketBaseState(ownerId, 1, Optional.empty())
);
when(pocketBases.policy()).thenReturn(new PocketBasePolicy(settings));
PocketBaseController controller = new PocketBaseController(
mock(Server.class),
mock(BaseStateManager.class),
new BaseBoundsService(settings),
pocketBases,
new PluginSettingsProvider(settings),
Logger.getAnonymousLogger()
);
controller.onPlace(event);
verify(event).setCancelled(true);
}
@Test
void activeEntranceTransportsMobsOnceButNotOtherEntities() {
UUID ownerId = UUID.randomUUID();
UUID worldId = UUID.randomUUID();
PocketPortalLocation portal = new PocketPortalLocation(
worldId, "world", -2, 64, 0, PocketPortalAxis.X
);
PocketBaseState state = new PocketBaseState(ownerId, 1, Optional.of(portal));
Server server = mock(Server.class);
World world = intactPortalWorld(worldId, portal);
Mob mob = mock(Mob.class);
Entity item = mock(Entity.class);
Location arrival = new Location(mock(World.class), 0.5, 65.0, 5.5);
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
when(mob.getUniqueId()).thenReturn(UUID.randomUUID());
when(item.getUniqueId()).thenReturn(UUID.randomUUID());
when(world.getNearbyEntities(any(BoundingBox.class), any()))
.thenReturn(List.of(mob, item));
when(server.getWorld(worldId)).thenReturn(world);
when(pocketBases.knownStates()).thenReturn(Map.of(ownerId, state));
when(pocketBases.pocketArrival(ownerId)).thenReturn(arrival);
PocketBaseController controller = controller(server, pocketBases);
controller.scanMobPortals();
controller.scanMobPortals();
verify(mob).teleport(arrival, PlayerTeleportEvent.TeleportCause.PLUGIN);
verify(item, never()).teleport(
any(Location.class),
any(PlayerTeleportEvent.TeleportCause.class)
);
}
@Test
void activeReturnPortalTransportsMobOutsideIntactEntrance() {
UUID ownerId = UUID.randomUUID();
UUID normalWorldId = UUID.randomUUID();
UUID pocketWorldId = UUID.randomUUID();
PocketPortalLocation entrance = new PocketPortalLocation(
normalWorldId, "world", -2, 64, 0, PocketPortalAxis.X
);
PocketPortalLocation returnPortal = new PocketPortalLocation(
pocketWorldId, "pocket", 8, 65, 8, PocketPortalAxis.Z
);
PocketBaseState state = new PocketBaseState(ownerId, 1, Optional.of(entrance));
Server server = mock(Server.class);
World normalWorld = intactPortalWorld(normalWorldId, entrance);
World pocketWorld = intactPortalWorld(pocketWorldId, returnPortal);
Mob mob = mock(Mob.class);
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
when(mob.getUniqueId()).thenReturn(UUID.randomUUID());
when(normalWorld.getNearbyEntities(any(BoundingBox.class), any()))
.thenReturn(List.of());
when(pocketWorld.getNearbyEntities(any(BoundingBox.class), any()))
.thenReturn(List.of(mob));
when(server.getWorld(normalWorldId)).thenReturn(normalWorld);
when(server.getWorld(pocketWorldId)).thenReturn(pocketWorld);
when(pocketBases.knownStates()).thenReturn(Map.of(ownerId, state));
when(pocketBases.state(ownerId)).thenReturn(state);
when(pocketBases.returnPortalIsIntact(ownerId)).thenReturn(true);
when(pocketBases.returnPortal(ownerId)).thenReturn(returnPortal);
PocketBaseController controller = controller(server, pocketBases);
controller.scanMobPortals();
verify(mob).teleport(
new Location(normalWorld, 0.0, 65.0, 1.5),
PlayerTeleportEvent.TeleportCause.PLUGIN
);
}
@Test
void activeFlightPortalGrantsTemporaryFlightToGuest() {
UUID ownerId = UUID.randomUUID();
UUID guestId = UUID.randomUUID();
UUID pocketWorldId = UUID.randomUUID();
PocketPortalLocation flightPortal = new PocketPortalLocation(
pocketWorldId, "pocket", 8, 65, 8, PocketPortalAxis.X
);
World pocketWorld = mock(World.class);
Set<BlockPosition> frame = new HashSet<>(
PocketPortalGeometry.frameBlocks(flightPortal)
);
Location destination = new Location(pocketWorld, 9.5, 66.0, 8.5);
Player guest = mock(Player.class);
PlayerMoveEvent event = mock(PlayerMoveEvent.class);
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
BaseFlightController flightController = mock(BaseFlightController.class);
Server server = mock(Server.class);
PluginSettings settings = PluginSettings.from(Map.of());
PocketBaseState state = new PocketBaseState(ownerId, 1, Optional.empty())
.withFlightUnlocked()
.withFlightPortal(flightPortal);
when(pocketWorld.getUID()).thenReturn(pocketWorldId);
when(pocketWorld.getBlockAt(anyInt(), anyInt(), anyInt())).thenAnswer(invocation -> {
BlockPosition position = new BlockPosition(
invocation.getArgument(0), invocation.getArgument(1), invocation.getArgument(2)
);
return block(
pocketWorld,
position.x(),
position.y(),
position.z(),
frame.contains(position) ? Material.GOLD_BLOCK : Material.AIR
);
});
when(server.getWorld(pocketWorldId)).thenReturn(pocketWorld);
when(guest.getUniqueId()).thenReturn(guestId);
when(event.getPlayer()).thenReturn(guest);
when(event.getTo()).thenReturn(destination);
when(pocketBases.ownerForPocketWorld(pocketWorldId)).thenReturn(Optional.of(ownerId));
when(pocketBases.state(ownerId)).thenReturn(state);
PocketBaseController controller = new PocketBaseController(
server,
mock(BaseStateManager.class),
new BaseBoundsService(settings),
pocketBases,
new PluginSettingsProvider(settings),
Logger.getAnonymousLogger(),
flightController
);
controller.onMove(event);
verify(flightController).grantPocketPortalFlight(guest, ownerId);
when(event.getFrom()).thenReturn(destination);
controller.onMove(event);
verify(flightController).grantPocketPortalFlight(guest, ownerId);
when(event.getFrom()).thenReturn(new Location(pocketWorld, 5, 66, 8));
controller.onMove(event);
verify(flightController, org.mockito.Mockito.times(2))
.grantPocketPortalFlight(guest, ownerId);
}
@Test
void voidReturnClearsAccumulatedPlayerFallDistance() {
UUID ownerId = UUID.randomUUID();
UUID pocketWorldId = UUID.randomUUID();
World pocketWorld = mock(World.class);
World fallbackWorld = mock(World.class);
Location destination = new Location(pocketWorld, 0.5, -65.0, 0.5);
Location spawn = new Location(fallbackWorld, 10.5, 70.0, 10.5);
Player player = mock(Player.class);
PlayerMoveEvent event = mock(PlayerMoveEvent.class);
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
Server server = mock(Server.class);
when(pocketWorld.getUID()).thenReturn(pocketWorldId);
when(player.getUniqueId()).thenReturn(UUID.randomUUID());
when(event.getPlayer()).thenReturn(player);
when(event.getTo()).thenReturn(destination);
when(server.getWorlds()).thenReturn(List.of(fallbackWorld));
when(fallbackWorld.getSpawnLocation()).thenReturn(spawn);
when(pocketBases.ownerForPocketWorld(pocketWorldId)).thenReturn(Optional.of(ownerId));
when(pocketBases.state(ownerId)).thenReturn(
new PocketBaseState(ownerId, 1, Optional.empty())
);
PocketBaseController controller = controller(server, pocketBases);
controller.onMove(event);
verify(player).setFallDistance(0.0F);
verify(player).teleport(spawn, PlayerTeleportEvent.TeleportCause.PLUGIN);
}
@Test
void mobReturnFallsBackToServerSpawnWithoutValidEntrance() {
UUID ownerId = UUID.randomUUID();
UUID pocketWorldId = UUID.randomUUID();
PocketPortalLocation returnPortal = new PocketPortalLocation(
pocketWorldId, "pocket", 8, 65, 8, PocketPortalAxis.Z
);
PocketBaseState state = new PocketBaseState(ownerId, 1, Optional.empty());
Server server = mock(Server.class);
World pocketWorld = intactPortalWorld(pocketWorldId, returnPortal);
World fallbackWorld = mock(World.class);
Location spawn = new Location(fallbackWorld, 10.5, 70.0, 10.5);
Mob mob = mock(Mob.class);
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
when(mob.getUniqueId()).thenReturn(UUID.randomUUID());
when(pocketWorld.getNearbyEntities(any(BoundingBox.class), any()))
.thenReturn(List.of(mob));
when(server.getWorld(pocketWorldId)).thenReturn(pocketWorld);
when(server.getWorlds()).thenReturn(List.of(fallbackWorld));
when(fallbackWorld.getSpawnLocation()).thenReturn(spawn);
when(pocketBases.knownStates()).thenReturn(Map.of(ownerId, state));
when(pocketBases.state(ownerId)).thenReturn(state);
when(pocketBases.returnPortalIsIntact(ownerId)).thenReturn(true);
when(pocketBases.returnPortal(ownerId)).thenReturn(returnPortal);
PocketBaseController controller = controller(server, pocketBases);
controller.scanMobPortals();
verify(mob).teleport(
spawn,
PlayerTeleportEvent.TeleportCause.PLUGIN
);
}
private static PocketBaseController controller(
Server server,
PocketBaseManager pocketBases
) {
PluginSettings settings = PluginSettings.from(Map.of());
return new PocketBaseController(
server,
mock(BaseStateManager.class),
new BaseBoundsService(settings),
pocketBases,
new PluginSettingsProvider(settings),
Logger.getAnonymousLogger()
);
}
private static World intactPortalWorld(
UUID worldId,
PocketPortalLocation portal
) {
World world = mock(World.class);
Set<BlockPosition> frame = new HashSet<>(PocketPortalGeometry.frameBlocks(portal));
when(world.getUID()).thenReturn(worldId);
when(world.getBlockAt(anyInt(), anyInt(), anyInt())).thenAnswer(invocation -> {
BlockPosition position = new BlockPosition(
invocation.getArgument(0), invocation.getArgument(1), invocation.getArgument(2)
);
return block(
world,
position.x(),
position.y(),
position.z(),
frame.contains(position) ? Material.DIAMOND_BLOCK : Material.AIR
);
});
return world;
}
private static ItemStack item(Material material) {
ItemStack item = mock(ItemStack.class);
when(item.getType()).thenReturn(material);
return item;
}
private static Block block(
World world,
int x,
int y,
int z,
Material material
) {
Block block = mock(Block.class);
when(block.getWorld()).thenReturn(world);
when(block.getX()).thenReturn(x);
when(block.getY()).thenReturn(y);
when(block.getZ()).thenReturn(z);
when(block.getType()).thenReturn(material);
return block;
}
}
@@ -0,0 +1,396 @@
package games.dmg.spigotbase;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.Optional;
import java.util.UUID;
import java.util.logging.Logger;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.NamespacedKey;
import org.bukkit.Server;
import org.bukkit.World;
import org.bukkit.block.Block;
import org.bukkit.entity.Player;
import org.bukkit.event.Event;
import org.bukkit.event.EventHandler;
import org.bukkit.event.block.Action;
import org.bukkit.plugin.Plugin;
import org.bukkit.plugin.RegisteredListener;
import org.bukkit.event.inventory.PrepareItemCraftEvent;
import org.bukkit.event.player.PlayerInteractEvent;
import org.bukkit.event.player.PlayerTeleportEvent;
import org.bukkit.inventory.CraftingInventory;
import org.bukkit.inventory.InventoryView;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.ShapedRecipe;
import org.bukkit.inventory.meta.ItemMeta;
import org.bukkit.persistence.PersistentDataContainer;
import org.bukkit.persistence.PersistentDataType;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.EnumSource;
final class PocketBaseKeystoneServiceTest {
@Test
void createsKeystoneWithGenericRecoveryCompassMetadata() {
NamespacedKey key = NamespacedKey.minecraft("pocket_base_keystone");
ItemStack item = mock(ItemStack.class);
ItemMeta genericMetadata = mock(ItemMeta.class);
PersistentDataContainer data = mock(PersistentDataContainer.class);
when(item.getItemMeta()).thenReturn(genericMetadata);
when(genericMetadata.getPersistentDataContainer()).thenReturn(data);
PocketBaseKeystoneService.decorateKeystone(item, key);
verify(data).set(key, PersistentDataType.BYTE, (byte) 1);
verify(item).setItemMeta(genericMetadata);
}
@Test
void recipeUsesNetheriteOnCardinalsAndObsidianInCorners() {
PocketBaseKeystoneService.RecipeSpec recipe =
PocketBaseKeystoneService.recipeSpecification();
assertEquals("ONO", recipe.top());
assertEquals("NEN", recipe.middle());
assertEquals("ONO", recipe.bottom());
assertEquals(Material.OBSIDIAN, recipe.obsidian());
assertEquals(Material.NETHERITE_INGOT, recipe.netherite());
assertEquals(Material.ENDER_PEARL, recipe.enderPearl());
}
@Test
void lockedPlayersCannotCompleteTheKeystoneRecipe() {
NamespacedKey key = NamespacedKey.minecraft("pocket_base_keystone");
UUID playerId = UUID.randomUUID();
Player player = mock(Player.class);
PrepareItemCraftEvent event = mock(PrepareItemCraftEvent.class);
InventoryView view = mock(InventoryView.class);
CraftingInventory inventory = mock(CraftingInventory.class);
ShapedRecipe recipe = mock(ShapedRecipe.class);
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
PocketBaseKeystoneService service = new PocketBaseKeystoneService(
mock(org.bukkit.Server.class), pocketBases, mock(KeystoneReturnStore.class),
key, Logger.getAnonymousLogger()
);
when(player.getUniqueId()).thenReturn(playerId);
when(event.getView()).thenReturn(view);
when(view.getPlayer()).thenReturn(player);
when(event.getInventory()).thenReturn(inventory);
when(event.getRecipe()).thenReturn(recipe);
when(recipe.getKey()).thenReturn(key);
when(pocketBases.state(playerId)).thenReturn(
new PocketBaseState(playerId, 0, Optional.empty())
);
service.onPrepareCraft(event);
verify(inventory).setResult(null);
}
@Test
void lockedPlayerCannotUseATransferredKeystone() throws Exception {
NamespacedKey key = NamespacedKey.minecraft("pocket_base_keystone");
UUID playerId = UUID.randomUUID();
Player player = mock(Player.class);
PlayerInteractEvent event = mock(PlayerInteractEvent.class);
ItemStack keystone = markedKeystone(key);
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
KeystoneReturnStore returns = mock(KeystoneReturnStore.class);
PocketBaseKeystoneService service = new PocketBaseKeystoneService(
mock(Server.class), pocketBases, returns, key, Logger.getAnonymousLogger()
);
when(player.getUniqueId()).thenReturn(playerId);
when(event.getPlayer()).thenReturn(player);
when(event.getAction()).thenReturn(Action.RIGHT_CLICK_AIR);
when(event.getItem()).thenReturn(keystone);
when(pocketBases.state(playerId)).thenReturn(
new PocketBaseState(playerId, 0, Optional.empty())
);
service.onUse(event);
verify(returns, never()).record(any(UUID.class), any(KeystoneReturnLocation.class));
verify(player, never()).teleport(
any(Location.class), any(PlayerTeleportEvent.TeleportCause.class)
);
}
@ParameterizedTest
@EnumSource(value = Action.class, names = {"RIGHT_CLICK_AIR", "RIGHT_CLICK_BLOCK"})
void unlockedPlayerEntersOwnPocketAndRecordsExactReturnDestination(Action action) throws Exception {
NamespacedKey key = NamespacedKey.minecraft("pocket_base_keystone");
UUID playerId = UUID.randomUUID();
UUID worldId = UUID.randomUUID();
Player player = mock(Player.class);
World world = mock(World.class);
Location origin = new Location(world, 12.25, 70.5, -4.75, 123.0F, -15.5F);
Location arrival = new Location(mock(World.class), 0.5, 65.0, 0.5);
PlayerInteractEvent event = mock(PlayerInteractEvent.class);
ItemStack keystone = markedKeystone(key);
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
KeystoneReturnStore returns = mock(KeystoneReturnStore.class);
PocketBaseKeystoneService service = new PocketBaseKeystoneService(
mock(Server.class), pocketBases, returns, key, Logger.getAnonymousLogger()
);
when(player.getUniqueId()).thenReturn(playerId);
when(player.getLocation()).thenReturn(origin);
when(player.getWorld()).thenReturn(world);
when(world.getUID()).thenReturn(worldId);
when(world.getName()).thenReturn("world");
when(event.getPlayer()).thenReturn(player);
when(event.getAction()).thenReturn(Action.RIGHT_CLICK_AIR);
when(event.getItem()).thenReturn(keystone);
when(pocketBases.state(playerId)).thenReturn(
new PocketBaseState(playerId, 1, Optional.empty())
);
when(pocketBases.ownerForPocketWorld(worldId)).thenReturn(Optional.empty());
when(pocketBases.pocketArrival(playerId)).thenReturn(arrival);
when(player.teleport(arrival, PlayerTeleportEvent.TeleportCause.PLUGIN))
.thenReturn(true);
when(event.getAction()).thenReturn(action);
when(event.isCancelled()).thenReturn(action == Action.RIGHT_CLICK_AIR);
when(event.useItemInHand()).thenReturn(Event.Result.DEFAULT);
dispatchUse(service, event);
verify(returns).record(playerId, new KeystoneReturnLocation(
worldId, "world", 12.25, 70.5, -4.75, 123.0F, -15.5F
));
verify(player).teleport(arrival, PlayerTeleportEvent.TeleportCause.PLUGIN);
verify(player).setFallDistance(0.0F);
verify(event).setCancelled(true);
}
@ParameterizedTest
@EnumSource(value = Action.class, names = {"RIGHT_CLICK_AIR", "RIGHT_CLICK_BLOCK"})
void playerReturnsFromOwnPocketToRecordedSafeDestination(Action action) throws Exception {
NamespacedKey key = NamespacedKey.minecraft("pocket_base_keystone");
UUID playerId = UUID.randomUUID();
UUID pocketWorldId = UUID.randomUUID();
UUID returnWorldId = UUID.randomUUID();
Player player = mock(Player.class);
World pocketWorld = mock(World.class);
World returnWorld = mock(World.class);
Block ground = mock(Block.class);
Block feet = mock(Block.class);
Block head = mock(Block.class);
PlayerInteractEvent event = mock(PlayerInteractEvent.class);
ItemStack keystone = markedKeystone(key);
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
KeystoneReturnStore returns = mock(KeystoneReturnStore.class);
Server server = mock(Server.class);
KeystoneReturnLocation saved = new KeystoneReturnLocation(
returnWorldId, "world", 12.25, 70.5, -4.75, 123.0F, -15.5F
);
PocketBaseKeystoneService service = new PocketBaseKeystoneService(
server, pocketBases, returns, key, Logger.getAnonymousLogger()
);
when(player.getUniqueId()).thenReturn(playerId);
when(player.getWorld()).thenReturn(pocketWorld);
when(pocketWorld.getUID()).thenReturn(pocketWorldId);
when(event.getPlayer()).thenReturn(player);
when(event.getAction()).thenReturn(Action.RIGHT_CLICK_AIR);
when(event.getItem()).thenReturn(keystone);
when(pocketBases.state(playerId)).thenReturn(
new PocketBaseState(playerId, 1, Optional.empty())
);
when(pocketBases.ownerForPocketWorld(pocketWorldId))
.thenReturn(Optional.of(playerId));
when(returns.destination(playerId)).thenReturn(Optional.of(saved));
when(server.getWorld(returnWorldId)).thenReturn(returnWorld);
when(returnWorld.getMinHeight()).thenReturn(-64);
when(returnWorld.getMaxHeight()).thenReturn(320);
when(returnWorld.getBlockAt(12, 69, -5)).thenReturn(ground);
when(returnWorld.getBlockAt(12, 70, -5)).thenReturn(feet);
when(returnWorld.getBlockAt(12, 71, -5)).thenReturn(head);
when(ground.getType()).thenReturn(Material.STONE);
when(feet.getType()).thenReturn(Material.AIR);
when(head.getType()).thenReturn(Material.AIR);
when(feet.isPassable()).thenReturn(true);
when(head.isPassable()).thenReturn(true);
when(player.teleport(
new Location(returnWorld, 12.25, 70.5, -4.75, 123.0F, -15.5F),
PlayerTeleportEvent.TeleportCause.PLUGIN
)).thenReturn(true);
when(event.getAction()).thenReturn(action);
when(event.isCancelled()).thenReturn(action == Action.RIGHT_CLICK_AIR);
when(event.useItemInHand()).thenReturn(Event.Result.DEFAULT);
dispatchUse(service, event);
verify(player).teleport(
new Location(returnWorld, 12.25, 70.5, -4.75, 123.0F, -15.5F),
PlayerTeleportEvent.TeleportCause.PLUGIN
);
verify(player).setFallDistance(0.0F);
}
@Test
void unavailableAndUnsafeReturnsDoNotTeleportThePlayer() {
NamespacedKey key = NamespacedKey.minecraft("pocket_base_keystone");
UUID missingPocketWorldId = UUID.randomUUID();
UUID unsafePocketWorldId = UUID.randomUUID();
UUID missingPlayerId = UUID.randomUUID();
UUID unsafePlayerId = UUID.randomUUID();
UUID missingWorldId = UUID.randomUUID();
UUID unsafeWorldId = UUID.randomUUID();
World missingPocketWorld = mock(World.class);
World unsafePocketWorld = mock(World.class);
World unsafeWorld = mock(World.class);
Player missingPlayer = mock(Player.class);
Player unsafePlayer = mock(Player.class);
PlayerInteractEvent missingEvent = mock(PlayerInteractEvent.class);
PlayerInteractEvent unsafeEvent = mock(PlayerInteractEvent.class);
ItemStack missingKeystone = markedKeystone(key);
ItemStack unsafeKeystone = markedKeystone(key);
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
KeystoneReturnStore returns = mock(KeystoneReturnStore.class);
Server server = mock(Server.class);
PocketBaseKeystoneService service = new PocketBaseKeystoneService(
server, pocketBases, returns, key, Logger.getAnonymousLogger()
);
when(missingPocketWorld.getUID()).thenReturn(missingPocketWorldId);
when(unsafePocketWorld.getUID()).thenReturn(unsafePocketWorldId);
when(missingPlayer.getUniqueId()).thenReturn(missingPlayerId);
when(missingPlayer.getWorld()).thenReturn(missingPocketWorld);
when(unsafePlayer.getUniqueId()).thenReturn(unsafePlayerId);
when(unsafePlayer.getWorld()).thenReturn(unsafePocketWorld);
when(missingEvent.getPlayer()).thenReturn(missingPlayer);
when(missingEvent.getAction()).thenReturn(Action.RIGHT_CLICK_AIR);
when(missingEvent.getItem()).thenReturn(missingKeystone);
when(unsafeEvent.getPlayer()).thenReturn(unsafePlayer);
when(unsafeEvent.getAction()).thenReturn(Action.RIGHT_CLICK_AIR);
when(unsafeEvent.getItem()).thenReturn(unsafeKeystone);
when(pocketBases.state(missingPlayerId)).thenReturn(
new PocketBaseState(missingPlayerId, 1, Optional.empty())
);
when(pocketBases.state(unsafePlayerId)).thenReturn(
new PocketBaseState(unsafePlayerId, 1, Optional.empty())
);
when(pocketBases.ownerForPocketWorld(missingPocketWorldId))
.thenReturn(Optional.of(missingPlayerId));
when(pocketBases.ownerForPocketWorld(unsafePocketWorldId))
.thenReturn(Optional.of(unsafePlayerId));
when(returns.destination(missingPlayerId)).thenReturn(Optional.of(
new KeystoneReturnLocation(
missingWorldId, "missing", 0.5, 65.0, 0.5, 0.0F, 0.0F
)
));
when(returns.destination(unsafePlayerId)).thenReturn(Optional.of(
new KeystoneReturnLocation(
unsafeWorldId, "unsafe", 0.5, 65.0, 0.5, 0.0F, 0.0F
)
));
when(server.getWorld(unsafeWorldId)).thenReturn(unsafeWorld);
when(unsafeWorld.getMinHeight()).thenReturn(-64);
when(unsafeWorld.getMaxHeight()).thenReturn(320);
Block unsafeBlock = mock(Block.class);
when(unsafeBlock.getType()).thenReturn(Material.AIR);
when(unsafeWorld.getBlockAt(anyInt(), anyInt(), anyInt()))
.thenReturn(unsafeBlock);
service.onUse(missingEvent);
service.onUse(unsafeEvent);
verify(missingPlayer, never()).teleport(
any(Location.class), any(PlayerTeleportEvent.TeleportCause.class)
);
verify(unsafePlayer, never()).teleport(
any(Location.class), any(PlayerTeleportEvent.TeleportCause.class)
);
}
@Test
void authenticatesKeystonesByPersistentMetadataInsteadOfTheirName() {
NamespacedKey key = NamespacedKey.minecraft("pocket_base_keystone");
ItemStack marked = mock(ItemStack.class);
ItemMeta markedMeta = mock(ItemMeta.class);
PersistentDataContainer markedData = mock(PersistentDataContainer.class);
ItemStack renamedOnly = mock(ItemStack.class);
ItemMeta renamedMeta = mock(ItemMeta.class);
PersistentDataContainer renamedData = mock(PersistentDataContainer.class);
when(marked.getType()).thenReturn(Material.RECOVERY_COMPASS);
when(marked.getItemMeta()).thenReturn(markedMeta);
when(markedMeta.getPersistentDataContainer()).thenReturn(markedData);
when(markedData.get(key, PersistentDataType.BYTE)).thenReturn((byte) 1);
when(renamedOnly.getType()).thenReturn(Material.RECOVERY_COMPASS);
when(renamedOnly.getItemMeta()).thenReturn(renamedMeta);
when(renamedMeta.getPersistentDataContainer()).thenReturn(renamedData);
assertTrue(PocketBaseKeystoneService.isKeystone(marked, key));
assertFalse(PocketBaseKeystoneService.isKeystone(renamedOnly, key));
}
@ParameterizedTest
@EnumSource(value = Action.class, names = {"RIGHT_CLICK_AIR", "RIGHT_CLICK_BLOCK"})
void explicitItemUseDenialPreventsKeystoneTravel(Action action) throws Exception {
NamespacedKey key = NamespacedKey.minecraft("pocket_base_keystone");
Player player = mock(Player.class);
UUID playerId = UUID.randomUUID();
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
KeystoneReturnStore returns = mock(KeystoneReturnStore.class);
PocketBaseKeystoneService service = new PocketBaseKeystoneService(
mock(Server.class), pocketBases, returns, key, Logger.getAnonymousLogger()
);
PlayerInteractEvent event = mock(PlayerInteractEvent.class);
ItemStack keystone = markedKeystone(key);
when(event.getAction()).thenReturn(action);
when(event.getItem()).thenReturn(keystone);
when(event.getPlayer()).thenReturn(player);
when(event.isCancelled()).thenReturn(true);
when(event.useItemInHand()).thenReturn(Event.Result.DENY);
when(player.getUniqueId()).thenReturn(playerId);
when(pocketBases.state(playerId)).thenReturn(
new PocketBaseState(playerId, 0, Optional.empty())
);
dispatchUse(service, event);
verify(pocketBases, never()).state(any(UUID.class));
verify(returns, never()).record(any(UUID.class), any(KeystoneReturnLocation.class));
verify(player, never()).teleport(
any(Location.class), any(PlayerTeleportEvent.TeleportCause.class)
);
verify(event, never()).setCancelled(true);
}
private static void dispatchUse(
PocketBaseKeystoneService service, PlayerInteractEvent event
) throws Exception {
EventHandler handler = PocketBaseKeystoneService.class
.getMethod("onUse", PlayerInteractEvent.class).getAnnotation(EventHandler.class);
RegisteredListener listener = new RegisteredListener(
service, (ignored, dispatched) -> service.onUse((PlayerInteractEvent) dispatched),
handler.priority(), mock(Plugin.class), handler.ignoreCancelled()
);
listener.callEvent(event);
}
private static ItemStack markedKeystone(NamespacedKey key) {
ItemStack item = mock(ItemStack.class);
ItemMeta metadata = mock(ItemMeta.class);
PersistentDataContainer data = mock(PersistentDataContainer.class);
when(item.getType()).thenReturn(Material.RECOVERY_COMPASS);
when(item.getItemMeta()).thenReturn(metadata);
when(metadata.getPersistentDataContainer()).thenReturn(data);
when(data.get(key, PersistentDataType.BYTE)).thenReturn((byte) 1);
return item;
}
}
@@ -0,0 +1,196 @@
package games.dmg.spigotbase;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.io.IOException;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.function.UnaryOperator;
import org.junit.jupiter.api.Test;
import org.mockito.InOrder;
final class PocketBaseManagerTest {
@Test
void relocatedReturnPortalOverridesGeneratedDefault() {
UUID ownerId = UUID.randomUUID();
PocketPortalLocation relocated = new PocketPortalLocation(
UUID.randomUUID(), "pocket", 20, 65, 12, PocketPortalAxis.Z
);
PocketBaseStateManager states = mock(PocketBaseStateManager.class);
PocketBaseWorldService worlds = mock(PocketBaseWorldService.class);
when(states.state(ownerId)).thenReturn(new PocketBaseState(
ownerId, 1, Optional.empty(), Optional.of(relocated), false, false,
PocketBaseBiome.THE_VOID
));
PocketBaseManager manager = new PocketBaseManager(
states,
worlds,
new PluginSettingsProvider(PluginSettings.from(Map.of()))
);
assertEquals(relocated, manager.returnPortal(ownerId));
}
@Test
void existingStateWithoutRelocationUsesGeneratedReturnPortal() {
UUID ownerId = UUID.randomUUID();
PocketPortalLocation generated = new PocketPortalLocation(
UUID.randomUUID(), "pocket", -2, 65, 0, PocketPortalAxis.X
);
PocketBaseStateManager states = mock(PocketBaseStateManager.class);
PocketBaseWorldService worlds = mock(PocketBaseWorldService.class);
when(states.state(ownerId)).thenReturn(
new PocketBaseState(ownerId, 1, Optional.empty())
);
when(worlds.returnPortal(ownerId)).thenReturn(generated);
PocketBaseManager manager = new PocketBaseManager(
states,
worlds,
new PluginSettingsProvider(PluginSettings.from(Map.of()))
);
assertEquals(generated, manager.returnPortal(ownerId));
}
@Test
void persistsPocketBaseFlightUnlock() throws Exception {
UUID ownerId = UUID.randomUUID();
PocketBaseState current = new PocketBaseState(ownerId, 1, Optional.empty());
PocketBaseStateManager states = mock(PocketBaseStateManager.class);
when(states.state(ownerId)).thenReturn(current);
when(states.updateAndSave(org.mockito.ArgumentMatchers.eq(ownerId),
org.mockito.ArgumentMatchers.any())).thenAnswer(invocation -> {
@SuppressWarnings("unchecked")
UnaryOperator<PocketBaseState> operation = invocation.getArgument(1);
return operation.apply(current);
});
PocketBaseManager manager = new PocketBaseManager(
states,
mock(PocketBaseWorldService.class),
new PluginSettingsProvider(PluginSettings.from(Map.of()))
);
PocketBaseState updated = manager.unlockFlight(ownerId);
assertTrue(updated.flightUnlocked());
verify(states).updateAndSave(
org.mockito.ArgumentMatchers.eq(ownerId), org.mockito.ArgumentMatchers.any()
);
}
@Test
void persistsAndAppliesHostileMobSpawningWithoutChangingPassive() throws Exception {
UUID ownerId = UUID.randomUUID();
PocketBaseState current = new PocketBaseState(
ownerId, 1, Optional.empty(), Optional.empty(), false, true,
PocketBaseBiome.THE_VOID
);
PocketBaseStateManager states = mock(PocketBaseStateManager.class);
PocketBaseWorldService worlds = mock(PocketBaseWorldService.class);
when(states.state(ownerId)).thenReturn(current);
when(states.updateAndSave(org.mockito.ArgumentMatchers.eq(ownerId),
org.mockito.ArgumentMatchers.any())).thenAnswer(invocation -> {
@SuppressWarnings("unchecked")
UnaryOperator<PocketBaseState> operation = invocation.getArgument(1);
return operation.apply(current);
});
PocketBaseManager manager = new PocketBaseManager(
states,
worlds,
new PluginSettingsProvider(PluginSettings.from(Map.of()))
);
PocketBaseState updated = manager.setHostileMobSpawning(ownerId, true);
assertEquals(true, updated.hostileMobSpawningEnabled());
assertEquals(true, updated.passiveMobSpawningEnabled());
InOrder order = inOrder(worlds, states);
order.verify(worlds).setMobSpawning(ownerId, true, true);
order.verify(states).updateAndSave(
org.mockito.ArgumentMatchers.eq(ownerId), org.mockito.ArgumentMatchers.any()
);
}
@Test
void persistsBiomeAfterApplyingItToTheUnlockedArea() throws Exception {
UUID ownerId = UUID.randomUUID();
PocketBaseState current = new PocketBaseState(ownerId, 2, Optional.empty());
PocketBaseStateManager states = mock(PocketBaseStateManager.class);
PocketBaseWorldService worlds = mock(PocketBaseWorldService.class);
when(states.state(ownerId)).thenReturn(current);
when(states.updateAndSave(org.mockito.ArgumentMatchers.eq(ownerId),
org.mockito.ArgumentMatchers.any())).thenAnswer(invocation -> {
@SuppressWarnings("unchecked")
UnaryOperator<PocketBaseState> operation = invocation.getArgument(1);
return operation.apply(current);
});
PocketBaseManager manager = new PocketBaseManager(
states,
worlds,
new PluginSettingsProvider(PluginSettings.from(Map.of()))
);
PocketBaseState updated = manager.setBiome(ownerId, PocketBaseBiome.WARPED_FOREST);
assertEquals(PocketBaseBiome.WARPED_FOREST, updated.biome());
InOrder order = inOrder(worlds, states);
order.verify(worlds).setBiome(ownerId, 2, PocketBaseBiome.WARPED_FOREST);
order.verify(states).updateAndSave(
org.mockito.ArgumentMatchers.eq(ownerId), org.mockito.ArgumentMatchers.any()
);
}
@Test
void restoresPreviousBiomeWhenPersistenceFails() throws Exception {
UUID ownerId = UUID.randomUUID();
PocketBaseState current = new PocketBaseState(ownerId, 1, Optional.empty());
PocketBaseStateManager states = mock(PocketBaseStateManager.class);
PocketBaseWorldService worlds = mock(PocketBaseWorldService.class);
when(states.state(ownerId)).thenReturn(current);
when(states.updateAndSave(org.mockito.ArgumentMatchers.eq(ownerId),
org.mockito.ArgumentMatchers.any())).thenThrow(new IOException("save failed"));
PocketBaseManager manager = new PocketBaseManager(
states,
worlds,
new PluginSettingsProvider(PluginSettings.from(Map.of()))
);
assertThrows(IOException.class,
() -> manager.setBiome(ownerId, PocketBaseBiome.DESERT));
verify(worlds).setBiome(ownerId, 1, PocketBaseBiome.DESERT);
verify(worlds).setBiome(ownerId, 1, PocketBaseBiome.THE_VOID);
}
@Test
void restoresBothWorldSettingsWhenPersistenceFails() throws Exception {
UUID ownerId = UUID.randomUUID();
PocketBaseState current = new PocketBaseState(
ownerId, 1, Optional.empty(), Optional.empty(), true, false,
PocketBaseBiome.THE_VOID
);
PocketBaseStateManager states = mock(PocketBaseStateManager.class);
PocketBaseWorldService worlds = mock(PocketBaseWorldService.class);
when(states.state(ownerId)).thenReturn(current);
when(states.updateAndSave(org.mockito.ArgumentMatchers.eq(ownerId),
org.mockito.ArgumentMatchers.any())).thenThrow(new IOException("save failed"));
PocketBaseManager manager = new PocketBaseManager(
states,
worlds,
new PluginSettingsProvider(PluginSettings.from(Map.of()))
);
assertThrows(IOException.class,
() -> manager.setPassiveMobSpawning(ownerId, true));
verify(worlds).setMobSpawning(ownerId, true, true);
verify(worlds).setMobSpawning(ownerId, true, false);
}
}
@@ -0,0 +1,34 @@
package games.dmg.spigotbase;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.Map;
import java.util.UUID;
import org.junit.jupiter.api.Test;
final class PocketBasePolicyTest {
private final PocketBasePolicy policy = new PocketBasePolicy(PluginSettings.from(Map.of()));
@Test
void levelsExpandBySixteenBlocksOnEverySide() {
assertEquals(64, policy.size(1));
assertEquals(96, policy.size(2));
assertEquals(128, policy.size(3));
}
@Test
void unlockAndLaterLevelsUseTheirConfiguredPrices() {
assertEquals(64, policy.price(PocketBaseState.locked(UUID.randomUUID())));
assertEquals(16, policy.price(new PocketBaseState(UUID.randomUUID(), 1, java.util.Optional.empty())));
}
@Test
void buildBoundaryMatchesUnlockedPlatform() {
assertTrue(policy.contains(1, -32, -32));
assertTrue(policy.contains(1, 31, 31));
assertFalse(policy.contains(1, -33, 0));
assertFalse(policy.contains(1, 32, 0));
}
}
@@ -0,0 +1,173 @@
package games.dmg.spigotbase;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import org.bukkit.Material;
import org.bukkit.Server;
import org.bukkit.World;
import org.bukkit.WorldCreator;
import org.bukkit.block.Block;
import org.junit.jupiter.api.Test;
final class PocketBaseWorldServiceTest {
@Test
void firstLevelGeneratesGrassPlatformAndReturnPortal() {
UUID ownerId = UUID.randomUUID();
UUID worldId = UUID.randomUUID();
Server server = mock(Server.class);
World world = mock(World.class);
Map<BlockPosition, Block> blocks = blocks(world);
when(server.createWorld(any(WorldCreator.class))).thenReturn(world);
when(world.getUID()).thenReturn(worldId);
when(world.getName()).thenReturn("pocket");
when(world.getMinHeight()).thenReturn(0);
when(world.getMaxHeight()).thenReturn(8);
PocketBaseWorldService service = new PocketBaseWorldService(
server,
new PluginSettingsProvider(PluginSettings.from(Map.of())),
ignored -> null
);
service.expand(ownerId, 0, 1);
verify(blocks.get(new BlockPosition(-32, 64, -32)))
.setType(Material.GRASS_BLOCK, false);
verify(blocks.get(new BlockPosition(31, 61, 31))).setType(Material.DIRT, false);
verify(blocks.get(new BlockPosition(-2, 65, 0)))
.setType(Material.DIAMOND_BLOCK, false);
verify(world).setSpawnLocation(0, 65, 5);
verify(world).setSpawnFlags(false, false);
assertEquals(ownerId, service.ownerForWorld(worldId).orElseThrow());
}
@Test
void checksRelocatedReturnPortalFrameAtItsSavedLocation() {
UUID ownerId = UUID.randomUUID();
UUID worldId = UUID.randomUUID();
Server server = mock(Server.class);
World world = mock(World.class);
Map<BlockPosition, Block> blocks = blocks(world);
when(server.getWorld(org.mockito.ArgumentMatchers.anyString())).thenReturn(world);
when(world.getUID()).thenReturn(worldId);
PocketPortalLocation portal = new PocketPortalLocation(
worldId, "pocket", 20, 65, 12, PocketPortalAxis.Z
);
for (BlockPosition position : PocketPortalGeometry.frameBlocks(portal)) {
when(world.getBlockAt(position.x(), position.y(), position.z()).getType())
.thenReturn(Material.DIAMOND_BLOCK);
}
PocketBaseWorldService service = new PocketBaseWorldService(
server,
new PluginSettingsProvider(PluginSettings.from(Map.of()))
);
assertTrue(service.returnPortalIsIntact(ownerId, portal));
when(world.getBlockAt(20, 65, 12).getType()).thenReturn(Material.AIR);
assertFalse(service.returnPortalIsIntact(ownerId, portal));
}
@Test
void reappliesSavedMobSpawningPreferenceWhenWorldLoads() {
UUID ownerId = UUID.randomUUID();
Server server = mock(Server.class);
World world = mock(World.class);
when(server.getWorld(org.mockito.ArgumentMatchers.anyString())).thenReturn(world);
when(world.getUID()).thenReturn(UUID.randomUUID());
PocketBaseWorldService service = new PocketBaseWorldService(
server,
new PluginSettingsProvider(PluginSettings.from(Map.of())),
ignored -> null
);
service.loadExisting(Map.of(
ownerId,
new PocketBaseState(
ownerId, 1, Optional.empty(), Optional.empty(), true, false,
PocketBaseBiome.THE_VOID
)
));
verify(world).setSpawnFlags(true, false);
}
@Test
void appliesSelectedBiomeAcrossUnlockedColumnsWithoutChangingBlocks() {
UUID ownerId = UUID.randomUUID();
Server server = mock(Server.class);
World world = mock(World.class);
when(server.getWorld(org.mockito.ArgumentMatchers.anyString())).thenReturn(world);
when(world.getUID()).thenReturn(UUID.randomUUID());
when(world.getMinHeight()).thenReturn(0);
when(world.getMaxHeight()).thenReturn(8);
PocketBaseWorldService service = new PocketBaseWorldService(
server,
new PluginSettingsProvider(PluginSettings.from(Map.of())),
ignored -> null
);
service.setBiome(ownerId, 1, PocketBaseBiome.CRIMSON_FOREST);
verify(world).setBiome(-32, 0, -32, null);
verify(world).setBiome(28, 4, 28, null);
verify(world, never()).getBlockAt(
org.mockito.ArgumentMatchers.anyInt(),
org.mockito.ArgumentMatchers.anyInt(),
org.mockito.ArgumentMatchers.anyInt()
);
}
@Test
void laterLevelAddsRingWithoutOverwritingExistingTerrain() {
UUID ownerId = UUID.randomUUID();
Server server = mock(Server.class);
World world = mock(World.class);
Map<BlockPosition, Block> blocks = blocks(world);
when(server.createWorld(any(WorldCreator.class))).thenReturn(world);
when(world.getUID()).thenReturn(UUID.randomUUID());
when(world.getName()).thenReturn("pocket");
when(world.getMinHeight()).thenReturn(0);
when(world.getMaxHeight()).thenReturn(8);
PocketBaseWorldService service = new PocketBaseWorldService(
server,
new PluginSettingsProvider(PluginSettings.from(Map.of())),
ignored -> null
);
Block existingCenter = world.getBlockAt(0, 64, 0);
service.expand(ownerId, 1, 2);
verify(blocks.get(new BlockPosition(47, 64, 47)))
.setType(Material.GRASS_BLOCK, false);
verify(existingCenter, never())
.setType(any(Material.class), org.mockito.ArgumentMatchers.anyBoolean());
verify(world).setBiome(44, 4, 44, null);
verify(world, never()).setBiome(0, 0, 0, null);
}
private static Map<BlockPosition, Block> blocks(World world) {
Map<BlockPosition, Block> blocks = new HashMap<>();
when(world.getBlockAt(
org.mockito.ArgumentMatchers.anyInt(),
org.mockito.ArgumentMatchers.anyInt(),
org.mockito.ArgumentMatchers.anyInt()
)).thenAnswer(invocation -> {
BlockPosition position = new BlockPosition(
invocation.getArgument(0), invocation.getArgument(1), invocation.getArgument(2)
);
return blocks.computeIfAbsent(position, ignored -> mock(Block.class));
});
return blocks;
}
}
@@ -0,0 +1,42 @@
package games.dmg.spigotbase;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.HashSet;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import org.junit.jupiter.api.Test;
final class PocketPortalGeometryTest {
@Test
void findsStandardFrameContainingIgnitedBlock() {
UUID worldId = UUID.randomUUID();
PocketPortalLocation expected = new PocketPortalLocation(
worldId, "world", 10, 64, 20, PocketPortalAxis.X
);
Set<BlockPosition> frame = new HashSet<>(PocketPortalGeometry.frameBlocks(expected));
Optional<PocketPortalLocation> found = PocketPortalGeometry.findFrame(
new BlockPosition(10, 66, 20),
worldId,
"world",
frame::contains,
ignored -> true
);
assertEquals(expected, found.orElseThrow());
}
@Test
void identifiesPortalInteriorAndRequiredFrameBlocks() {
PocketPortalLocation portal = new PocketPortalLocation(
UUID.randomUUID(), "world", 0, 64, 0, PocketPortalAxis.Z
);
assertTrue(PocketPortalGeometry.isInterior(portal, new BlockPosition(0, 65, 1)));
assertTrue(PocketPortalGeometry.isFrame(portal, new BlockPosition(0, 68, 3)));
assertEquals(14, PocketPortalGeometry.frameBlocks(portal).size());
}
}
@@ -0,0 +1,48 @@
package games.dmg.spigotbase;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import org.bukkit.block.Block;
import org.bukkit.block.BlockFace;
import org.bukkit.block.data.type.Slab;
import org.junit.jupiter.api.Test;
final class SpawnableBlockPolicyTest {
@Test
void acceptsDarkSolidSurfaceWithTwoPassableBlocksAbove() {
Block surface = mock(Block.class);
Block above = mock(Block.class);
Block secondAbove = mock(Block.class);
Slab slab = mock(Slab.class);
when(slab.getType()).thenReturn(Slab.Type.TOP);
when(surface.getBlockData()).thenReturn(slab);
when(surface.getRelative(BlockFace.UP)).thenReturn(above);
when(above.getRelative(BlockFace.UP)).thenReturn(secondAbove);
when(above.isPassable()).thenReturn(true);
when(secondAbove.isPassable()).thenReturn(true);
when(above.getLightFromBlocks()).thenReturn((byte) 0);
when(above.getLightFromSky()).thenReturn((byte) 0);
assertTrue(SpawnableBlockPolicy.isCandidate(surface));
}
@Test
void rejectsBlockLitSurface() {
Block surface = mock(Block.class);
Block above = mock(Block.class);
Block secondAbove = mock(Block.class);
Slab slab = mock(Slab.class);
when(slab.getType()).thenReturn(Slab.Type.TOP);
when(surface.getBlockData()).thenReturn(slab);
when(surface.getRelative(BlockFace.UP)).thenReturn(above);
when(above.getRelative(BlockFace.UP)).thenReturn(secondAbove);
when(above.isPassable()).thenReturn(true);
when(secondAbove.isPassable()).thenReturn(true);
when(above.getLightFromBlocks()).thenReturn((byte) 1);
assertFalse(SpawnableBlockPolicy.isCandidate(surface));
}
}
@@ -0,0 +1,85 @@
package games.dmg.spigotbase;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.atLeastOnce;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.time.Instant;
import java.util.Map;
import java.util.UUID;
import org.bukkit.Location;
import org.bukkit.Particle;
import org.bukkit.Server;
import org.bukkit.World;
import org.bukkit.block.Block;
import org.bukkit.block.BlockFace;
import org.bukkit.block.data.type.Slab;
import org.bukkit.entity.Player;
import org.junit.jupiter.api.Test;
final class SpawnableOverlayControllerTest {
@Test
void ownerReceivesBoundedRedParticlesForNearbyCandidates() {
UUID playerId = UUID.randomUUID();
UUID worldId = UUID.randomUUID();
PlayerState state = PlayerState.newPlayer(playerId, "Builder")
.withAdministrativeLevels(1, 0, 0, 0, 0, false, false, false)
.withBase(new BaseLocation(worldId, "world", 0, 64, 0, 0, 0), Instant.EPOCH)
.withTotalBlocksPlaced(250)
.withSpawnableOverlayEnabled(true);
Block surface = mock(Block.class);
Block above = mock(Block.class);
Block secondAbove = mock(Block.class);
Slab slab = mock(Slab.class);
when(slab.getType()).thenReturn(Slab.Type.TOP);
when(surface.getBlockData()).thenReturn(slab);
when(surface.getRelative(BlockFace.UP)).thenReturn(above);
when(above.getRelative(BlockFace.UP)).thenReturn(secondAbove);
when(above.isPassable()).thenReturn(true);
when(secondAbove.isPassable()).thenReturn(true);
when(above.getLightFromBlocks()).thenReturn((byte) 0);
when(above.getLightFromSky()).thenReturn((byte) 0);
World world = mock(World.class);
when(world.getUID()).thenReturn(worldId);
when(world.getMinHeight()).thenReturn(-64);
when(world.getMaxHeight()).thenReturn(320);
when(world.getBlockAt(anyInt(), anyInt(), anyInt()))
.thenReturn(surface);
Player player = mock(Player.class);
when(player.getUniqueId()).thenReturn(playerId);
when(player.getName()).thenReturn("Builder");
when(player.getWorld()).thenReturn(world);
when(player.getLocation()).thenReturn(new Location(world, 0.5, 65.0, 0.5));
Server server = mock(Server.class);
doReturn(java.util.List.of(player)).when(server).getOnlinePlayers();
BaseStateManager stateManager = mock(BaseStateManager.class);
when(stateManager.player(playerId, "Builder")).thenReturn(state);
PluginSettingsProvider settings = new PluginSettingsProvider(
PluginSettings.from(Map.of())
);
SpawnableOverlayController controller = new SpawnableOverlayController(
server,
stateManager,
new BaseBoundsService(settings),
settings
);
controller.run();
controller.run();
controller.run();
verify(player, atLeastOnce()).spawnParticle(
eq(Particle.DUST),
any(Location.class),
eq(1),
any(Particle.DustOptions.class)
);
}
}
@@ -0,0 +1,24 @@
package games.dmg.spigotbase;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.Map;
import java.util.UUID;
import org.junit.jupiter.api.Test;
final class SpawnableOverlayProgressionServiceTest {
@Test
void twoHundredFiftiethPlacementUnlocksOverlay() {
PlayerState player = PlayerState.newPlayer(UUID.randomUUID(), "Builder")
.withTotalBlocksPlaced(249);
SpawnableOverlayProgressionService service = new SpawnableOverlayProgressionService(
new PluginSettingsProvider(PluginSettings.from(Map.of()))
);
SpawnableOverlayProgressionUpdate update = service.recordPlacement(player);
assertEquals(250, update.player().totalBlocksPlaced());
assertTrue(update.unlocked());
}
}
@@ -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);
}
}
@@ -22,6 +22,17 @@ final class TeleportPolicyTest {
assertEquals(Duration.ZERO, policy.warmup(player.withTeleportLevels(3, 0)));
}
@Test
void visitorWarmupHasOneSecondMinimum() {
PlayerState player = baseThreePlayer();
assertEquals(Duration.ofSeconds(30), policy.visitorWarmup(player));
assertEquals(
Duration.ofSeconds(1),
policy.visitorWarmup(player.withTeleportLevels(3, 0))
);
}
@Test
void cooldownLevelsUseApprovedDurations() {
PlayerState player = baseThreePlayer();
@@ -28,7 +28,10 @@ final class YamlBaseStateRepositoryTest {
true, true, false, true,
Optional.of(Instant.ofEpochMilli(1_750_000_000_000L)),
Optional.of(Instant.ofEpochMilli(1_750_000_100_000L)),
Map.of(ownerId, Instant.ofEpochMilli(1_750_001_000_000L))
Map.of(ownerId, Instant.ofEpochMilli(1_750_001_000_000L)),
true,
2_500,
true
);
PersistentState expected = new PersistentState(Map.of(playerId, player));
YamlBaseStateRepository repository =
@@ -39,6 +42,44 @@ final class YamlBaseStateRepositoryTest {
assertEquals(expected, repository.load());
}
@Test
void olderStateUsesKnownInBasePlacementsAsTotalPlacementMinimum() throws Exception {
UUID playerId = UUID.randomUUID();
Path stateFile = temporaryDirectory.resolve("state.yml");
Files.writeString(stateFile, """
players:
%s:
name: Alex
blocks-placed-in-base: 225
""".formatted(playerId));
PlayerState player = new YamlBaseStateRepository(stateFile)
.load()
.players()
.get(playerId);
assertEquals(225, player.totalBlocksPlaced());
assertFalse(player.spawnableOverlayEnabled());
}
@Test
void olderStateWithoutBorderPreferenceDefaultsToDisabled() throws Exception {
UUID playerId = UUID.randomUUID();
Path stateFile = temporaryDirectory.resolve("state.yml");
Files.writeString(stateFile, """
players:
%s:
name: Alex
""".formatted(playerId));
PlayerState player = new YamlBaseStateRepository(stateFile)
.load()
.players()
.get(playerId);
assertFalse(player.borderEnabled());
}
@Test
void invalidRecordsCannotGrantProgression() throws Exception {
UUID playerId = UUID.randomUUID();
@@ -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));
}
}

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