3 Commits
Author SHA1 Message Date
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
19 changed files with 975 additions and 49 deletions
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
uses: actions/setup-java@v4 uses: actions/setup-java@v4
with: with:
distribution: temurin distribution: temurin
java-version: 17 java-version: 25
cache: gradle cache: gradle
- name: Validate conventional commits - name: Validate conventional commits
+1 -1
View File
@@ -21,7 +21,7 @@ jobs:
uses: actions/setup-java@v4 uses: actions/setup-java@v4
with: with:
distribution: temurin distribution: temurin
java-version: 17 java-version: 25
cache: gradle cache: gradle
- name: Set up Node.js - name: Set up Node.js
+7 -4
View File
@@ -1,6 +1,6 @@
# Spigot Base # 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. Players begin by breaking Survival-mode grass blocks or dirt. The progression paths provide a persistent base, navigation particles, expanding base bounds, controlled flight, personal teleportation, and visitor teleportation.
@@ -8,8 +8,8 @@ The approved behavior is specified in the [OKF design bundle](design/index.md).
## Requirements ## Requirements
- Spigot 26.2 - Purpur 26.2 build 2618
- Java 17 or newer - Java 25 or newer
## Build ## Build
@@ -26,6 +26,7 @@ The plugin JAR is written to `build/libs/`.
/base (alias: /home) /base (alias: /home)
/gotobase <player> (alias: /visit <player>) /gotobase <player> (alias: /visit <player>)
/basesettings (alias: /homesettings) /basesettings (alias: /homesettings)
/basesettings ui
/basesettings status /basesettings status
/basesettings upgrade /basesettings upgrade
/basesettings pocket upgrade /basesettings pocket upgrade
@@ -39,13 +40,15 @@ The plugin JAR is written to `build/libs/`.
/basesettings bossbar <enable|disable> /basesettings bossbar <enable|disable>
``` ```
`/basesettings` and `/basesettings ui` open Minecraft's native dialog dashboard. The dashboard summarizes Base and Pocket Base progression, provides unlocked setting controls and purchase confirmations, and marks unavailable controls as locked. `/basesettings status` retains the detailed chat report.
Navigation particles appear only in the base's world and when the player is more than 25 blocks beyond the current base border. 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. After 250 Survival-mode block placements anywhere by default, the spawnable overlay can mark nearby dark hostile-mob spawning surfaces inside the player's base with owner-only red particles. The threshold is configurable.
`/base` has a stationary warm-up. Looking around is allowed, while movement between blocks, damage, teleportation, world changes, death, logout, and conflicting teleport commands cancel it without consuming the cooldown. `/base` has a stationary warm-up. Looking around is allowed, while movement between blocks, damage, teleportation, world changes, death, logout, and conflicting teleport commands cancel it without consuming the cooldown.
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. 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>`. Base IV owners can purchase and expand a persistent Pocket Base with `/basesettings pocket upgrade`. Activating a complete diamond-block portal frame inside the normal base with flint and steel opens one public entrance to the owner's grass platform in a private void world. Players and non-player living mobs can travel through the entrance and active return portal; items, projectiles, and vehicles are not transported. The owner can move the return portal by building and igniting another complete frame inside the unlocked Pocket Base boundary; only the newly activated return portal remains functional. Natural hostile and passive mob spawning is disabled by default. Owners can control each category independently with `/basesettings pocket mobs <hostile|passive> <enable|disable>`.
Owners can change biome metadata without altering Pocket Base blocks, entities, inventories, or portals by using `/basesettings pocket type <void|nether|overworld> <subtype>`. Void uses `the_void`; Nether and Overworld accept their compatible vanilla biome names, such as `crimson_forest` or `plains`. Each change defaults to 16 netherite blocks, is configurable, and affects applicable natural mob selection when spawning is enabled. The Pocket Base remains a void-generated normal-environment world; dimension-specific mechanics do not change. 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.
+5 -5
View File
@@ -9,23 +9,23 @@ version = providers.gradleProperty("releaseVersion")
repositories { repositories {
mavenCentral() mavenCentral()
maven("https://hub.spigotmc.org/nexus/content/repositories/snapshots/") maven("https://repo.purpurmc.org/snapshots/")
} }
java { java {
toolchain { toolchain {
languageVersion = JavaLanguageVersion.of(17) languageVersion = JavaLanguageVersion.of(25)
} }
} }
tasks.withType<JavaCompile>().configureEach { tasks.withType<JavaCompile>().configureEach {
options.compilerArgs.addAll(listOf("-Xlint:all", "-Werror")) options.compilerArgs.addAll(listOf("-Xlint:all,-deprecation,-removal", "-Werror"))
} }
dependencies { 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(platform("org.junit:junit-bom:5.13.4"))
testImplementation("org.junit.jupiter:junit-jupiter") testImplementation("org.junit.jupiter:junit-jupiter")
testImplementation("org.mockito:mockito-core:5.18.0") testImplementation("org.mockito:mockito-core:5.18.0")
+22
View File
@@ -114,3 +114,25 @@ description: Chronological record of material decisions affecting the Spigot Bas
- Applied biome metadata across unlocked columns and new expansion rings without changing blocks, entities, inventories, portals, progression, or mob-spawning preferences. - Applied biome metadata across unlocked columns and new expansion rings without changing blocks, entities, inventories, portals, progression, or mob-spawning preferences.
- Added an atomic, configurable biome-change payment that defaults to 16 netherite blocks and restores the prior biome and payment on failure. - Added an atomic, configurable biome-change payment that defaults to 16 netherite blocks and restores the prior biome and payment on failure.
- Verified the feature with `./gradlew clean check jar`. - Verified the feature with `./gradlew clean check jar`.
## 2026-08-24 — Native base settings dialog
- Changed `/basesettings` and `/homesettings` with no arguments to open a native Minecraft dialog dashboard, with explicit `ui` subcommands providing the same behavior.
- Added progression summaries, current and locked setting states, toggles, Pocket Base controls, biome selection, and confirmations for Base IV, Pocket Base, and biome purchases.
- Retained the detailed chat report under `/basesettings status` and kept dialog actions routed through the existing validated command behavior.
- Added dialog routing, autocomplete, alias, non-player, generated-control, locked-state, and purchase-confirmation coverage.
- Verified the feature with `./gradlew clean check jar`.
## 2026-08-24 — Purpur dialog runtime compatibility
- Replaced the Spigot-only Bungee dialog types that were absent from production with Purpur's supported Paper dialog API.
- Compiled and tested against the exact Purpur 26.2 build 2618 API with Java 25, and updated both CI workflows to use Java 25.
- Preserved unit-test coverage through server-independent dialog specifications and adapted Bukkit test fixtures to Purpur's registry-aware API behavior.
- Verified 128 tests and the Java 25 plugin artifact with `./gradlew clean check jar`.
## 2026-08-24 — Pocket Base mob portal travel
- Added five-tick collision scanning that transports non-player living mobs through active Pocket Base entrance and return portals.
- Reused safe player destinations and server-spawn fallback behavior while excluding items, projectiles, vehicles, and other non-living entities.
- Applied the existing two-second portal cooldown to mobs to prevent immediate repeated transport.
- Verified the feature and Java 25 plugin artifact with `./gradlew clean check jar`.
+1
View File
@@ -25,3 +25,4 @@ description: Catalog of user stories for the Spigot Base plugin.
17. [US-017: Expand a Pocket Base](us-017-expand-a-pocket-base.md) 17. [US-017: Expand a Pocket Base](us-017-expand-a-pocket-base.md)
18. [US-018: Control Pocket Base mob spawning](us-018-control-pocket-base-mob-spawning.md) 18. [US-018: Control Pocket Base mob spawning](us-018-control-pocket-base-mob-spawning.md)
19. [US-019: Change a Pocket Base biome](us-019-change-a-pocket-base-biome.md) 19. [US-019: Change a Pocket Base biome](us-019-change-a-pocket-base-biome.md)
20. [US-020: Control a base through a dialog UI](us-020-control-base-through-dialog-ui.md)
@@ -11,7 +11,7 @@ As a **player**, I want to inspect my progression and receive timely milestone f
## Acceptance criteria ## Acceptance criteria
- [x] `/basesettings` and `/basesettings status` show the player's Base, Base Size, Base Flight, Teleport Warm-up, and Teleport Cooldown paths plus current visitor, navigation, flight, and boss-bar settings. - [x] `/basesettings status` shows the player's Base, Base Size, Base Flight, Teleport Warm-up, and Teleport Cooldown paths plus current visitor, navigation, flight, and boss-bar settings.
- [x] Each path identifies earned levels, unmet prerequisites, current progress, the next threshold, and the next reward. - [x] 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] 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] Relevant qualifying activity briefly displays a configurable progress boss bar for the active milestone.
@@ -19,7 +19,7 @@ As a **player**, I want to inspect my progression and receive timely milestone f
- [x] The automatic boss bar disappears after a configurable number of seconds. - [x] The automatic boss bar disappears after a configurable number of seconds.
- [x] `/basesettings bossbar enable` enables automatic progress boss bars idempotently, and `/basesettings bossbar disable` disables them idempotently. - [x] `/basesettings bossbar enable` enables automatic progress boss bars idempotently, and `/basesettings bossbar disable` disables them idempotently.
- [x] Invalid boss-bar arguments show command usage, and `enable` and `disable` are offered through autocomplete. - [x] Invalid boss-bar arguments show command usage, and `enable` and `disable` are offered through autocomplete.
- [x] Disabling automatic boss bars does not prevent `/basesettings` from displaying progress. - [x] Disabling automatic boss bars does not prevent `/basesettings status` from displaying progress.
- [x] The boss-bar preference persists across reconnects and restarts. - [x] 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] Each newly unlocked level displays prominent full-screen title and subtitle text describing the reward.
- [x] Unlock notifications occur once per earned level and do not repeat after reconnecting or restarting. - [x] Unlock notifications occur once per earned level and do not repeat after reconnecting or restarting.
@@ -1,7 +1,7 @@
--- ---
type: User Story type: User Story
title: "US-012: Build and release the plugin" title: "US-012: Build and release the plugin"
description: Give maintainers repeatable Spigot builds, automated verification, and versioned Gitea releases. description: Give maintainers repeatable Purpur builds, automated verification, and versioned Gitea releases.
status: done status: done
--- ---
@@ -11,7 +11,7 @@ As a **plugin maintainer**, I want automated builds and releases modeled on Trig
## Acceptance criteria ## Acceptance criteria
- [x] The Gradle project compiles against Spigot API `26.2-R0.1-SNAPSHOT` using a Java 17 toolchain. - [x] The Gradle project compiles against Purpur API `26.2.build.2618-stable` using a Java 25 toolchain.
- [x] Compiler lint warnings fail the build. - [x] Compiler lint warnings fail the build.
- [x] Automated JUnit 5 tests run as part of the Gradle check lifecycle. - [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] Pushes and pull requests build and test the plugin in Gitea Actions.
@@ -19,8 +19,13 @@ As a **Pocket Base owner**, I want to connect my normal base to my Pocket Base w
- [x] Activating another valid entrance deactivates the previous entrance without removing its frame blocks. - [x] Activating another valid entrance deactivates the previous entrance without removing its frame blocks.
- [x] Breaking any required frame block immediately deactivates the entrance. - [x] Breaking any required frame block immediately deactivates the entrance.
- [x] Any player can use an active entrance without a separate visitor setting, warm-up, or cooldown. - [x] Any player can use an active entrance without a separate visitor setting, warm-up, or cooldown.
- [x] Non-player living mobs entering an active entrance portal are teleported into that Pocket Base.
- [x] Portal cooldown handling prevents transported mobs from immediately bouncing back.
- [x] Items, projectiles, vehicles, and other non-living entities are not transported.
- [x] Visitors can build, break blocks, open containers, and otherwise interact normally inside the Pocket Base. - [x] Visitors can build, break blocks, open containers, and otherwise interact normally inside the Pocket Base.
- [x] The generated return portal sends players safely to the owner's active entrance. - [x] The generated return portal sends players safely to the owner's active entrance.
- [x] Non-player living mobs entering the active, intact return portal are teleported safely outside the normal-world entrance.
- [x] Mob return travel falls back safely to the server spawn when the owner has no valid normal-world entrance.
- [x] Only the owner can activate a complete portal frame inside their own Pocket Base with flint and steel. - [x] Only the owner can activate a complete portal frame inside their own Pocket Base with flint and steel.
- [x] Every required return-frame block must be within the owner's unlocked Pocket Base boundary. - [x] Every required return-frame block must be within the owner's unlocked Pocket Base boundary.
- [x] Each Pocket Base has at most one active return portal, and activating another valid frame moves the active return location without removing the old frame. - [x] Each Pocket Base has at most one active return portal, and activating another valid frame moves the active return location without removing the old frame.
@@ -0,0 +1,32 @@
---
type: User Story
title: "US-020: Control a base through a dialog UI"
description: Let players inspect and control their base through Minecraft's native dialog interface.
status: in-progress
---
# US-020: Control a base through a dialog UI
As a **player**, I want a dialog-based base dashboard so that I can inspect progression and manage my base without remembering command arguments.
## Acceptance criteria
- [x] `/basesettings` and `/basesettings ui` open the base-control dialog.
- [x] `/homesettings` and `/homesettings ui` behave identically.
- [x] `/basesettings status` retains the chat-based progression report.
- [x] The dialog summarizes current Base and Pocket Base progression.
- [x] Dialog controls cover visitors, navigation, flight, border, spawnable overlay, boss bar, Pocket Base mob spawning, upgrades, and biome selection when applicable.
- [x] Controls indicate their current or locked state.
- [x] Purchases require confirmation and continue using existing validation, payment, and persistence logic.
- [x] `ui` is offered through command autocomplete.
- [x] Non-player execution remains safely rejected.
- [x] Automated tests verify dialog routing and generated controls.
- [ ] The dialog uses Purpur's supported Paper dialog API and opens on the production Purpur 26.2 runtime without missing-class or command exceptions.
## Related
- [US-008: Unlock Base IV visitor access](us-008-unlock-visitor-access.md)
- [US-009: View progression and unlock notifications](us-009-view-progression-and-notifications.md)
- [US-015: Unlock a Pocket Base](us-015-unlock-a-pocket-base.md)
- [US-018: Control Pocket Base mob spawning](us-018-control-pocket-base-mob-spawning.md)
- [US-019: Change a Pocket Base biome](us-019-change-a-pocket-base-biome.md)
+4
View File
@@ -1 +1,5 @@
plugins {
id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
}
rootProject.name = "spigot-base" rootProject.name = "spigot-base"
@@ -1,5 +1,6 @@
package games.dmg.spigotbase; package games.dmg.spigotbase;
import io.papermc.paper.dialog.Dialog;
import java.io.IOException; import java.io.IOException;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
@@ -15,7 +16,7 @@ import org.bukkit.inventory.PlayerInventory;
final class BaseSettingsCommand implements CommandExecutor, TabCompleter { final class BaseSettingsCommand implements CommandExecutor, TabCompleter {
private static final List<String> SETTINGS = List.of( private static final List<String> SETTINGS = List.of(
"status", "upgrade", "pocket", "visitors", "navigation", "flight", "border", "ui", "status", "upgrade", "pocket", "visitors", "navigation", "flight", "border",
"spawnable", "bossbar" "spawnable", "bossbar"
); );
private static final List<String> VISITOR_MODES = List.of("allowed", "blocked"); private static final List<String> VISITOR_MODES = List.of("allowed", "blocked");
@@ -28,6 +29,7 @@ final class BaseSettingsCommand implements CommandExecutor, TabCompleter {
private final TeleportPolicy teleportPolicy; private final TeleportPolicy teleportPolicy;
private final BaseFlightController flightController; private final BaseFlightController flightController;
private final PocketBaseManager pocketBases; private final PocketBaseManager pocketBases;
private final DialogProvider dialogProvider;
BaseSettingsCommand( BaseSettingsCommand(
BaseStateManager stateManager, BaseStateManager stateManager,
@@ -42,12 +44,30 @@ final class BaseSettingsCommand implements CommandExecutor, TabCompleter {
PluginSettingsProvider settings, PluginSettingsProvider settings,
BaseFlightController flightController, BaseFlightController flightController,
PocketBaseManager pocketBases 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.stateManager = stateManager;
this.settings = settings; this.settings = settings;
this.teleportPolicy = new TeleportPolicy(settings); this.teleportPolicy = new TeleportPolicy(settings);
this.flightController = flightController; this.flightController = flightController;
this.pocketBases = pocketBases; this.pocketBases = pocketBases;
this.dialogProvider = dialogProvider;
} }
@Override @Override
@@ -58,7 +78,11 @@ final class BaseSettingsCommand implements CommandExecutor, TabCompleter {
} }
PlayerState state = stateManager.player(player.getUniqueId(), player.getName()); PlayerState state = stateManager.player(player.getUniqueId(), player.getName());
if (arguments.length == 0 if (arguments.length == 0
|| arguments.length == 1 && arguments[0].equalsIgnoreCase("status")) { || arguments.length == 1 && arguments[0].equalsIgnoreCase("ui")) {
showDialog(player, state);
return true;
}
if (arguments.length == 1 && arguments[0].equalsIgnoreCase("status")) {
showStatus(player, state); showStatus(player, state);
return true; return true;
} }
@@ -441,6 +465,13 @@ final class BaseSettingsCommand implements CommandExecutor, TabCompleter {
return true; 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) { private void showStatus(Player player, PlayerState state) {
player.sendMessage(ChatColor.GOLD + "=== Base Progress ==="); player.sendMessage(ChatColor.GOLD + "=== Base Progress ===");
showBasePath(player, state); showBasePath(player, state);
@@ -661,9 +692,14 @@ final class BaseSettingsCommand implements CommandExecutor, TabCompleter {
return met ? ChatColor.YELLOW : ChatColor.RED; return met ? ChatColor.YELLOW : ChatColor.RED;
} }
@FunctionalInterface
interface DialogProvider {
Dialog create(PlayerState owner, PocketBaseState pocket);
}
private static void sendUsage(Player player) { private static void sendUsage(Player player) {
player.sendMessage(ChatColor.RED + "Usage: /basesettings " player.sendMessage(ChatColor.RED + "Usage: /basesettings "
+ "[status|upgrade|pocket upgrade|pocket mobs " + "[ui|status|upgrade|pocket upgrade|pocket mobs "
+ "<hostile|passive> <enable|disable>" + "<hostile|passive> <enable|disable>"
+ "|pocket type <void|nether|overworld> <subtype>" + "|pocket type <void|nether|overworld> <subtype>"
+ "|visitors <allowed|blocked>|navigation <enable|disable>" + "|visitors <allowed|blocked>|navigation <enable|disable>"
@@ -0,0 +1,432 @@
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;
final class BaseSettingsDialogFactory {
private static final int DIALOG_WIDTH = 420;
private final PluginSettings settings;
BaseSettingsDialogFactory(PluginSettings settings) {
this.settings = settings;
}
Dialog create(PlayerState owner, PocketBaseState pocket) {
return render(specification(owner, pocket));
}
DialogSpec specification(PlayerState owner, PocketBaseState pocket) {
List<DialogSpec> dialogs = List.of(
generalSettings(owner),
progressReport(),
baseUpgrade(owner),
pocketSettings(owner, pocket)
);
return new ListSpec(
content(
"Base Settings",
"Base Settings",
"Base " + owner.baseLevel() + "/4 • Size " + owner.sizeLevel() + "/3"
+ " • Flight " + owner.flightLevel() + "/3\n"
+ "Pocket Base " + pocket.level() + ""
+ humanize(pocket.biome().commandName())
),
dialogs,
2,
190
);
}
private DialogSpec generalSettings(PlayerState owner) {
List<ButtonSpec> actions = List.of(
toggle(
"Visitors",
owner.baseLevel() >= 4,
owner.visitorsEnabled(),
"Allowed",
"Blocked",
"basesettings visitors "
+ (owner.visitorsEnabled() ? "blocked" : "allowed")
),
toggle(
"Navigation",
owner.baseLevel() >= 2,
owner.navigationEnabled(),
"Enabled",
"Disabled",
"basesettings navigation " + mode(!owner.navigationEnabled())
),
toggle(
"Flight",
owner.flightLevel() >= 1,
owner.flightEnabled(),
"Enabled",
"Disabled",
"basesettings flight " + mode(!owner.flightEnabled())
),
toggle(
"Border",
owner.baseLevel() >= 1 && owner.base().isPresent(),
owner.borderEnabled(),
"Enabled",
"Disabled",
"basesettings border " + mode(!owner.borderEnabled())
),
toggle(
"Spawnable Overlay",
owner.baseLevel() >= 1
&& owner.base().isPresent()
&& owner.totalBlocksPlaced()
>= settings.spawnableOverlayUnlockPlacements(),
owner.spawnableOverlayEnabled(),
"Enabled",
"Disabled",
"basesettings spawnable " + mode(!owner.spawnableOverlayEnabled())
),
toggle(
"Progress Boss Bar",
true,
owner.bossBarEnabled(),
"Enabled",
"Disabled",
"basesettings bossbar " + mode(!owner.bossBarEnabled())
)
);
return new MultiSpec(
content(
"Base Controls",
"Base Controls",
"Select a setting to change it. Locked controls remain visible and explain "
+ "their requirement in chat when selected."
),
actions,
2
);
}
private DialogSpec progressReport() {
return new NoticeSpec(
content(
"Progress Report",
"Progress Report",
"Display the detailed progression report in chat."
),
commandButton("Show in Chat", "basesettings status")
);
}
private DialogSpec baseUpgrade(PlayerState owner) {
if (owner.baseLevel() >= 4) {
return notice("Base IV", "Base IV and visitor access are already unlocked.");
}
if (owner.baseLevel() < 3 || owner.base().isEmpty()) {
return notice(
"Base IV — Locked",
"Unlock and establish Base III before purchasing Base IV visitor access."
);
}
return confirmation(
"Unlock Base IV",
"Purchase visitor access for " + settings.visitorUnlockDiamondCost()
+ " diamonds?",
"basesettings upgrade"
);
}
private DialogSpec pocketSettings(PlayerState owner, PocketBaseState pocket) {
List<DialogSpec> dialogs = List.of(
pocketUpgrade(owner, pocket),
pocketMobSettings(pocket),
pocketBiomeSettings(pocket)
);
return new ListSpec(
content(
"Pocket Base",
"Pocket Base " + pocket.level(),
pocket.level() == 0
? "Pocket Base is locked."
: "Biome: " + pocket.biome().worldType().commandName() + "/"
+ pocket.biome().commandName() + "\nHostile mobs: "
+ enabled(pocket.hostileMobSpawningEnabled()) + " • Passive mobs: "
+ enabled(pocket.passiveMobSpawningEnabled())
),
dialogs,
1,
260
);
}
private DialogSpec pocketUpgrade(PlayerState owner, PocketBaseState pocket) {
if (owner.baseLevel() < 4 || owner.base().isEmpty()) {
return notice(
"Pocket Upgrade — Locked",
"Establish Base IV before purchasing a Pocket Base."
);
}
int price = pocket.level() == 0
? settings.pocketBaseUnlockCost()
: settings.pocketBaseUpgradeCost();
String currency = humanize(settings.pocketBaseCurrencyMaterial());
String title = pocket.level() == 0
? "Unlock Pocket Base I"
: "Upgrade to Pocket Base " + (pocket.level() + 1);
return confirmation(
title,
"Purchase this upgrade for " + price + " " + currency + "?",
"basesettings pocket upgrade"
);
}
private DialogSpec pocketMobSettings(PocketBaseState pocket) {
if (pocket.level() < 1) {
return notice(
"Mob Spawning — Locked",
"Unlock Pocket Base I to control natural mob spawning."
);
}
List<ButtonSpec> actions = List.of(
toggle(
"Hostile Mobs",
true,
pocket.hostileMobSpawningEnabled(),
"Enabled",
"Disabled",
"basesettings pocket mobs hostile "
+ mode(!pocket.hostileMobSpawningEnabled())
),
toggle(
"Passive Mobs",
true,
pocket.passiveMobSpawningEnabled(),
"Enabled",
"Disabled",
"basesettings pocket mobs passive "
+ mode(!pocket.passiveMobSpawningEnabled())
)
);
return new MultiSpec(
content(
"Mob Spawning",
"Mob Spawning",
"Control natural spawning independently for each mob category."
),
actions,
1
);
}
private DialogSpec pocketBiomeSettings(PocketBaseState pocket) {
if (pocket.level() < 1) {
return notice(
"Change Biome — Locked",
"Unlock Pocket Base I to purchase biome changes."
);
}
List<DialogSpec> worldTypes = Arrays.stream(PocketBaseWorldType.values())
.map(type -> pocketBiomeType(pocket, type))
.toList();
return new ListSpec(
content(
"Change Biome",
"Change Biome",
"Current biome: " + pocket.biome().worldType().commandName() + "/"
+ pocket.biome().commandName() + "\nEach change costs "
+ settings.pocketBaseBiomeChangeCost() + " "
+ humanize(settings.pocketBaseBiomeCurrencyMaterial()) + "."
),
worldTypes,
1,
220
);
}
private DialogSpec pocketBiomeType(
PocketBaseState pocket,
PocketBaseWorldType worldType
) {
List<DialogSpec> biomes = Arrays.stream(PocketBaseBiome.values())
.filter(biome -> biome.worldType() == worldType)
.map(biome -> biomeConfirmation(pocket, biome))
.toList();
return new ListSpec(
content(
humanize(worldType.commandName()),
humanize(worldType.commandName()) + " Biomes",
"Choose a biome subtype. The active biome is marked as selected."
),
biomes,
2,
190
);
}
private DialogSpec biomeConfirmation(PocketBaseState pocket, PocketBaseBiome biome) {
String title = humanize(biome.commandName())
+ (pocket.biome() == biome ? " — Selected" : "");
if (pocket.biome() == biome) {
return notice(title, "This is your Pocket Base's current biome.");
}
return confirmation(
title,
"Change the Pocket Base biome for " + settings.pocketBaseBiomeChangeCost()
+ " " + humanize(settings.pocketBaseBiomeCurrencyMaterial()) + "?",
"basesettings pocket type " + biome.worldType().commandName() + " "
+ biome.commandName()
);
}
private static DialogSpec confirmation(String title, String message, String command) {
return new ConfirmationSpec(
content(title, title, message),
commandButton("Confirm", command)
);
}
private static DialogSpec notice(String title, String message) {
return new NoticeSpec(content(title, title, message), null);
}
private static ButtonSpec toggle(
String name,
boolean unlocked,
boolean active,
String activeLabel,
String inactiveLabel,
String command
) {
String state = unlocked ? (active ? activeLabel : inactiveLabel) : "Locked";
String tooltip = unlocked
? "Click to change this setting."
: "This setting has not been unlocked yet.";
return new ButtonSpec(name + ": " + state, tooltip, 190, "/" + command);
}
private static ButtonSpec commandButton(String label, String command) {
return new ButtonSpec(label, "", 150, "/" + command);
}
private static DialogContent content(
String title,
String externalTitle,
String message
) {
return new DialogContent(title, externalTitle, message);
}
private static Dialog render(DialogSpec specification) {
DialogType type;
if (specification instanceof ListSpec list) {
List<Dialog> dialogs = list.dialogs().stream()
.map(BaseSettingsDialogFactory::render)
.toList();
type = DialogType.dialogList(
RegistrySet.valueSet(RegistryKey.DIALOG, dialogs),
null,
list.columns(),
list.buttonWidth()
);
} else if (specification instanceof MultiSpec multi) {
List<ActionButton> actions = multi.actions().stream()
.map(BaseSettingsDialogFactory::render)
.toList();
type = DialogType.multiAction(actions, null, multi.columns());
} else if (specification instanceof ConfirmationSpec confirmation) {
ActionButton cancel = ActionButton.builder(Component.text("Cancel")).build();
type = DialogType.confirmation(render(confirmation.confirm()), cancel);
} else if (specification instanceof NoticeSpec notice) {
type = notice.action() == null
? DialogType.notice()
: DialogType.notice(render(notice.action()));
} else {
throw new IllegalArgumentException("Unsupported dialog specification");
}
DialogContent content = specification.content();
DialogBase base = DialogBase.builder(Component.text(content.title()))
.externalTitle(Component.text(content.externalTitle()))
.body(List.of(DialogBody.plainMessage(
Component.text(content.message()), DIALOG_WIDTH
)))
.canCloseWithEscape(true)
.pause(false)
.afterAction(DialogBase.DialogAfterAction.CLOSE)
.build();
return Dialog.create(factory -> factory.empty().base(base).type(type));
}
private static ActionButton render(ButtonSpec button) {
ActionButton.Builder builder = ActionButton.builder(Component.text(button.label()))
.width(button.width())
.action(DialogAction.commandTemplate(button.command()));
if (!button.tooltip().isEmpty()) {
builder.tooltip(Component.text(button.tooltip()));
}
return builder.build();
}
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();
}
}
@@ -5,6 +5,7 @@ import java.util.HashMap;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.UUID; import java.util.UUID;
import java.util.function.Consumer;
import java.util.logging.Level; import java.util.logging.Level;
import java.util.logging.Logger; import java.util.logging.Logger;
import org.bukkit.ChatColor; import org.bukkit.ChatColor;
@@ -15,6 +16,7 @@ import org.bukkit.Server;
import org.bukkit.World; import org.bukkit.World;
import org.bukkit.block.Block; import org.bukkit.block.Block;
import org.bukkit.entity.Entity; import org.bukkit.entity.Entity;
import org.bukkit.entity.Mob;
import org.bukkit.entity.Player; import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler; import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority; import org.bukkit.event.EventPriority;
@@ -26,6 +28,7 @@ import org.bukkit.event.entity.EntityDamageEvent;
import org.bukkit.event.player.PlayerInteractEvent; import org.bukkit.event.player.PlayerInteractEvent;
import org.bukkit.event.player.PlayerMoveEvent; import org.bukkit.event.player.PlayerMoveEvent;
import org.bukkit.event.player.PlayerTeleportEvent; import org.bukkit.event.player.PlayerTeleportEvent;
import org.bukkit.util.BoundingBox;
final class PocketBaseController implements Listener, Runnable { final class PocketBaseController implements Listener, Runnable {
private static final int VOID_RETURN_Y = -64; private static final int VOID_RETURN_Y = -64;
@@ -63,6 +66,7 @@ final class PocketBaseController implements Listener, Runnable {
showPortal(pocketBases.returnPortal(state.ownerId())); showPortal(pocketBases.returnPortal(state.ownerId()));
} }
} }
scanMobPortals();
long now = System.nanoTime(); long now = System.nanoTime();
cooldownUntil.entrySet().removeIf(entry -> entry.getValue() <= now); cooldownUntil.entrySet().removeIf(entry -> entry.getValue() <= now);
} }
@@ -235,23 +239,61 @@ final class PocketBaseController implements Listener, Runnable {
deactivate(ownerId); deactivate(ownerId);
} }
private void leavePocket(Player player, UUID ownerId) { void scanMobPortals() {
for (PocketBaseState state : pocketBases.knownStates().values()) {
if (state.entrance().isPresent()) {
PocketPortalLocation entrance = state.entrance().orElseThrow();
if (isIntact(entrance)) {
transportMobs(
entrance,
mob -> teleport(mob, pocketBases.pocketArrival(state.ownerId()))
);
}
}
if (state.level() > 0 && pocketBases.returnPortalIsIntact(state.ownerId())) {
transportMobs(
pocketBases.returnPortal(state.ownerId()),
mob -> leavePocket(mob, state.ownerId())
);
}
}
}
private void transportMobs(
PocketPortalLocation portal,
Consumer<Mob> transport
) {
World world = server.getWorld(portal.worldId());
if (world == null) {
return;
}
for (Entity entity : world.getNearbyEntities(
portalBounds(portal),
candidate -> candidate instanceof Mob
)) {
if (entity instanceof Mob mob && !onCooldown(mob)) {
transport.accept(mob);
}
}
}
private void leavePocket(Entity entity, UUID ownerId) {
PocketBaseState state = pocketBases.state(ownerId); PocketBaseState state = pocketBases.state(ownerId);
if (state.entrance().isPresent() && isIntact(state.entrance().orElseThrow())) { if (state.entrance().isPresent() && isIntact(state.entrance().orElseThrow())) {
teleport(player, outsidePortal(state.entrance().orElseThrow())); teleport(entity, outsidePortal(state.entrance().orElseThrow()));
return; return;
} }
World fallbackWorld = server.getWorlds().get(0); World fallbackWorld = server.getWorlds().get(0);
teleport(player, fallbackWorld.getSpawnLocation()); teleport(entity, fallbackWorld.getSpawnLocation());
} }
private void teleport(Player player, Location destination) { private void teleport(Entity entity, Location destination) {
cooldownUntil.put(player.getUniqueId(), System.nanoTime() + PORTAL_COOLDOWN_NANOS); cooldownUntil.put(entity.getUniqueId(), System.nanoTime() + PORTAL_COOLDOWN_NANOS);
player.teleport(destination, PlayerTeleportEvent.TeleportCause.PLUGIN); entity.teleport(destination, PlayerTeleportEvent.TeleportCause.PLUGIN);
} }
private boolean onCooldown(Player player) { private boolean onCooldown(Entity entity) {
return cooldownUntil.getOrDefault(player.getUniqueId(), 0L) > System.nanoTime(); return cooldownUntil.getOrDefault(entity.getUniqueId(), 0L) > System.nanoTime();
} }
private void validateEntrances() { private void validateEntrances() {
@@ -320,6 +362,26 @@ final class PocketBaseController implements Listener, Runnable {
: new Location(world, portal.x() + 1.5, portal.y() + 1.0, portal.z() + 2.0); : new Location(world, portal.x() + 1.5, portal.y() + 1.0, portal.z() + 2.0);
} }
private static BoundingBox portalBounds(PocketPortalLocation portal) {
return portal.axis() == PocketPortalAxis.X
? new BoundingBox(
portal.x() + 1.0,
portal.y() + 1.0,
portal.z(),
portal.x() + 3.0,
portal.y() + 4.0,
portal.z() + 1.0
)
: new BoundingBox(
portal.x(),
portal.y() + 1.0,
portal.z() + 1.0,
portal.x() + 1.0,
portal.y() + 4.0,
portal.z() + 3.0
);
}
private static boolean isAir(Material material) { private static boolean isAir(Material material) {
return material == Material.AIR || material == Material.CAVE_AIR return material == Material.AIR || material == Material.CAVE_AIR
|| material == Material.VOID_AIR; || material == Material.VOID_AIR;
@@ -137,7 +137,7 @@ final class PocketBaseWorldService {
World world = server.getWorld(name); World world = server.getWorld(name);
boolean created = world == null; boolean created = world == null;
if (created) { if (created) {
WorldCreator creator = new WorldCreator(name) WorldCreator creator = new WorldCreator(NamespacedKey.minecraft(name))
.environment(World.Environment.NORMAL) .environment(World.Environment.NORMAL)
.generator(new VoidPocketChunkGenerator()) .generator(new VoidPocketChunkGenerator())
.generateStructures(false); .generateStructures(false);
+1 -1
View File
@@ -19,7 +19,7 @@ commands:
aliases: [visit] aliases: [visit]
basesettings: basesettings:
description: View progression, upgrade, and manage base settings. description: View progression, upgrade, and manage base settings.
usage: /basesettings [status|upgrade|pocket|visitors|navigation|flight|border|spawnable|bossbar] usage: /basesettings [ui|status|upgrade|pocket|visitors|navigation|flight|border|spawnable|bossbar]
aliases: [homesettings] aliases: [homesettings]
baseadmin: baseadmin:
description: Administer Spigot Base. description: Administer Spigot Base.
@@ -20,13 +20,29 @@ import java.util.concurrent.atomic.AtomicReference;
import java.util.function.UnaryOperator; import java.util.function.UnaryOperator;
import org.bukkit.ChatColor; import org.bukkit.ChatColor;
import org.bukkit.Material; import org.bukkit.Material;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player; import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.PlayerInventory; import org.bukkit.inventory.PlayerInventory;
import net.kyori.adventure.dialog.DialogLike;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor; import org.mockito.ArgumentCaptor;
final class BaseSettingsCommandTest { 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 @Test
void spawnableEnableIsIdempotentAfterUnlock() { void spawnableEnableIsIdempotentAfterUnlock() {
UUID playerId = UUID.randomUUID(); UUID playerId = UUID.randomUUID();
@@ -156,8 +172,8 @@ final class BaseSettingsCommandTest {
assertEquals( assertEquals(
List.of( List.of(
"status", "upgrade", "pocket", "visitors", "navigation", "flight", "border", "ui", "status", "upgrade", "pocket", "visitors", "navigation", "flight",
"spawnable", "bossbar" "border", "spawnable", "bossbar"
), ),
command.onTabComplete(null, null, "basesettings", new String[] {""}) command.onTabComplete(null, null, "basesettings", new String[] {""})
); );
@@ -170,10 +186,12 @@ final class BaseSettingsCommandTest {
when(player.getUniqueId()).thenReturn(playerId); when(player.getUniqueId()).thenReturn(playerId);
when(player.getName()).thenReturn("Host"); when(player.getName()).thenReturn("Host");
PlayerInventory inventory = mock(PlayerInventory.class); PlayerInventory inventory = mock(PlayerInventory.class);
ItemStack firstStack = item(Material.DIAMOND, 64);
ItemStack secondStack = item(Material.DIAMOND, 64);
when(player.getInventory()).thenReturn(inventory); when(player.getInventory()).thenReturn(inventory);
when(inventory.getStorageContents()).thenReturn(new ItemStack[] { when(inventory.getStorageContents()).thenReturn(new ItemStack[] {
new ItemStack(Material.DIAMOND, 64), firstStack,
new ItemStack(Material.DIAMOND, 64) secondStack
}); });
PlayerState current = PlayerState.newPlayer(playerId, "Host") PlayerState current = PlayerState.newPlayer(playerId, "Host")
@@ -211,10 +229,9 @@ final class BaseSettingsCommandTest {
when(player.getUniqueId()).thenReturn(playerId); when(player.getUniqueId()).thenReturn(playerId);
when(player.getName()).thenReturn("Builder"); when(player.getName()).thenReturn("Builder");
PlayerInventory inventory = mock(PlayerInventory.class); PlayerInventory inventory = mock(PlayerInventory.class);
ItemStack currency = item(Material.DIAMOND_BLOCK, 64);
when(player.getInventory()).thenReturn(inventory); when(player.getInventory()).thenReturn(inventory);
when(inventory.getStorageContents()).thenReturn(new ItemStack[] { when(inventory.getStorageContents()).thenReturn(new ItemStack[] {currency});
new ItemStack(Material.DIAMOND_BLOCK, 64)
});
PlayerState owner = PlayerState.newPlayer(playerId, "Builder") PlayerState owner = PlayerState.newPlayer(playerId, "Builder")
.withAdministrativeLevels(4, 0, 0, 0, 0, false, false, true) .withAdministrativeLevels(4, 0, 0, 0, 0, false, false, true)
.withBase( .withBase(
@@ -248,10 +265,9 @@ final class BaseSettingsCommandTest {
when(player.getUniqueId()).thenReturn(playerId); when(player.getUniqueId()).thenReturn(playerId);
when(player.getName()).thenReturn("Builder"); when(player.getName()).thenReturn("Builder");
PlayerInventory inventory = mock(PlayerInventory.class); PlayerInventory inventory = mock(PlayerInventory.class);
ItemStack currency = item(Material.NETHERITE_BLOCK, 16);
when(player.getInventory()).thenReturn(inventory); when(player.getInventory()).thenReturn(inventory);
when(inventory.getStorageContents()).thenReturn(new ItemStack[] { when(inventory.getStorageContents()).thenReturn(new ItemStack[] {currency});
new ItemStack(Material.NETHERITE_BLOCK, 16)
});
BaseStateManager stateManager = mock(BaseStateManager.class); BaseStateManager stateManager = mock(BaseStateManager.class);
when(stateManager.player(playerId, "Builder")) when(stateManager.player(playerId, "Builder"))
.thenReturn(PlayerState.newPlayer(playerId, "Builder")); .thenReturn(PlayerState.newPlayer(playerId, "Builder"));
@@ -413,10 +429,9 @@ final class BaseSettingsCommandTest {
when(player.getUniqueId()).thenReturn(playerId); when(player.getUniqueId()).thenReturn(playerId);
when(player.getName()).thenReturn("Builder"); when(player.getName()).thenReturn("Builder");
PlayerInventory inventory = mock(PlayerInventory.class); PlayerInventory inventory = mock(PlayerInventory.class);
ItemStack currency = item(Material.DIAMOND_BLOCK, 64);
when(player.getInventory()).thenReturn(inventory); when(player.getInventory()).thenReturn(inventory);
when(inventory.getStorageContents()).thenReturn(new ItemStack[] { when(inventory.getStorageContents()).thenReturn(new ItemStack[] {currency});
new ItemStack(Material.DIAMOND_BLOCK, 64)
});
PlayerState owner = PlayerState.newPlayer(playerId, "Builder") PlayerState owner = PlayerState.newPlayer(playerId, "Builder")
.withAdministrativeLevels(4, 0, 0, 0, 0, false, false, true) .withAdministrativeLevels(4, 0, 0, 0, 0, false, false, true)
.withBase( .withBase(
@@ -526,7 +541,7 @@ final class BaseSettingsCommandTest {
} }
@Test @Test
void defaultAndStatusDisplayTheFullProgressReport() { void defaultAndUiOpenDialogWhileStatusDisplaysTheFullProgressReport() {
UUID playerId = UUID.randomUUID(); UUID playerId = UUID.randomUUID();
Player player = mock(Player.class); Player player = mock(Player.class);
when(player.getUniqueId()).thenReturn(playerId); when(player.getUniqueId()).thenReturn(playerId);
@@ -538,21 +553,25 @@ final class BaseSettingsCommandTest {
BaseSettingsCommand command = new BaseSettingsCommand( BaseSettingsCommand command = new BaseSettingsCommand(
stateManager, stateManager,
new PluginSettingsProvider(PluginSettings.from(Map.of())), new PluginSettingsProvider(PluginSettings.from(Map.of())),
mock(BaseFlightController.class) mock(BaseFlightController.class),
null,
(owner, pocket) -> null
); );
command.onCommand(player, null, "basesettings", new String[0]); command.onCommand(player, null, "basesettings", new String[0]);
command.onCommand(player, null, "homesettings", new String[] {"ui"});
command.onCommand(player, null, "basesettings", new String[] {"status"}); command.onCommand(player, null, "basesettings", new String[] {"status"});
verify(player, times(2)).sendMessage(ChatColor.GOLD + "=== Base Progress ==="); verify(player, times(2)).showDialog((DialogLike) null);
verify(player, times(2)).sendMessage( verify(player).sendMessage(ChatColor.GOLD + "=== Base Progress ===");
verify(player).sendMessage(
org.mockito.ArgumentMatchers.<String>argThat(message -> org.mockito.ArgumentMatchers.<String>argThat(message ->
message.contains("Spawnable Overlay:") message.contains("Spawnable Overlay:")
&& message.contains("0/250 placements") && message.contains("0/250 placements")
&& message.contains("locked") && message.contains("locked")
) )
); );
verify(player, times(2)).sendMessage( verify(player).sendMessage(
org.mockito.ArgumentMatchers.<String>argThat(message -> org.mockito.ArgumentMatchers.<String>argThat(message ->
message.contains("Settings: visitors=") message.contains("Settings: visitors=")
&& message.contains("navigation=") && message.contains("navigation=")
@@ -564,6 +583,18 @@ final class BaseSettingsCommandTest {
); );
} }
private static ItemStack item(Material material, int amount) {
ItemStack item = mock(ItemStack.class);
ItemStack copy = mock(ItemStack.class);
when(item.getType()).thenReturn(material);
when(item.getAmount()).thenReturn(amount);
when(item.clone()).thenReturn(copy);
when(copy.getType()).thenReturn(material);
when(copy.getAmount()).thenReturn(amount);
when(copy.clone()).thenReturn(copy);
return item;
}
private static CommandResult execute(PlayerState current, String... arguments) { private static CommandResult execute(PlayerState current, String... arguments) {
Player player = mock(Player.class); Player player = mock(Player.class);
when(player.getUniqueId()).thenReturn(current.playerId()); when(player.getUniqueId()).thenReturn(current.playerId());
@@ -0,0 +1,137 @@
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 org.junit.jupiter.api.Test;
final class BaseSettingsDialogFactoryTest {
@Test
void createsControlsForEveryBaseSettingAndConfirmedPurchase() {
UUID playerId = UUID.randomUUID();
PlayerState owner = PlayerState.newPlayer(playerId, "Builder")
.withAdministrativeLevels(4, 2, 2, 1, 1, true, true, true)
.withBase(
new BaseLocation(UUID.randomUUID(), "world", 10, 64, 20, 10, 20),
Instant.EPOCH
)
.withBorderEnabled(true)
.withTotalBlocksPlaced(250)
.withSpawnableOverlayEnabled(true);
PocketBaseState pocket = new PocketBaseState(
playerId,
1,
Optional.empty(),
Optional.empty(),
true,
false,
PocketBaseBiome.PLAINS
);
BaseSettingsDialogFactory.DialogSpec specification =
new BaseSettingsDialogFactory(PluginSettings.from(Map.of()))
.specification(owner, pocket);
BaseSettingsDialogFactory.ListSpec root = assertInstanceOf(
BaseSettingsDialogFactory.ListSpec.class,
specification
);
assertTrue(root.content().message().contains("Base 4/4"));
List<String> commands = commands(root);
assertTrue(commands.contains("/basesettings status"));
assertTrue(commands.contains("/basesettings visitors blocked"));
assertTrue(commands.contains("/basesettings navigation disable"));
assertTrue(commands.contains("/basesettings flight disable"));
assertTrue(commands.contains("/basesettings border disable"));
assertTrue(commands.contains("/basesettings spawnable disable"));
assertTrue(commands.contains("/basesettings bossbar disable"));
assertTrue(commands.contains("/basesettings pocket upgrade"));
assertTrue(commands.contains("/basesettings pocket mobs hostile disable"));
assertTrue(commands.contains("/basesettings pocket mobs passive enable"));
assertTrue(commands.contains("/basesettings pocket type nether crimson_forest"));
}
@Test
void requiresConfirmationForBaseIvPurchase() {
UUID playerId = UUID.randomUUID();
PlayerState owner = PlayerState.newPlayer(playerId, "Builder")
.withAdministrativeLevels(3, 0, 0, 0, 0, false, false, false)
.withBase(
new BaseLocation(UUID.randomUUID(), "world", 0, 64, 0, 0, 0),
Instant.EPOCH
);
BaseSettingsDialogFactory.ListSpec root = assertInstanceOf(
BaseSettingsDialogFactory.ListSpec.class,
new BaseSettingsDialogFactory(PluginSettings.from(Map.of())).specification(
owner,
PocketBaseState.locked(playerId)
)
);
BaseSettingsDialogFactory.ConfirmationSpec upgrade = assertInstanceOf(
BaseSettingsDialogFactory.ConfirmationSpec.class,
root.dialogs().get(2)
);
assertEquals("/basesettings upgrade", upgrade.confirm().command());
}
@Test
void marksUnavailableControlsAsLocked() {
UUID playerId = UUID.randomUUID();
PlayerState owner = PlayerState.newPlayer(playerId, "Newcomer");
BaseSettingsDialogFactory.ListSpec root = assertInstanceOf(
BaseSettingsDialogFactory.ListSpec.class,
new BaseSettingsDialogFactory(PluginSettings.from(Map.of())).specification(
owner,
PocketBaseState.locked(playerId)
)
);
BaseSettingsDialogFactory.MultiSpec controls = assertInstanceOf(
BaseSettingsDialogFactory.MultiSpec.class,
root.dialogs().get(0)
);
List<String> labels = controls.actions().stream()
.map(BaseSettingsDialogFactory.ButtonSpec::label)
.toList();
assertTrue(labels.stream().anyMatch(label -> label.contains("Visitors: Locked")));
assertTrue(labels.stream().anyMatch(label -> label.contains("Navigation: Locked")));
assertTrue(labels.stream().anyMatch(label -> label.contains("Flight: Locked")));
assertTrue(labels.stream().anyMatch(label -> label.contains("Border: Locked")));
assertTrue(labels.stream().anyMatch(label -> label.contains("Spawnable Overlay: Locked")));
}
private static List<String> commands(
BaseSettingsDialogFactory.DialogSpec specification
) {
List<String> commands = new ArrayList<>();
collect(specification, commands);
return commands;
}
private static void collect(
BaseSettingsDialogFactory.DialogSpec specification,
List<String> commands
) {
if (specification instanceof BaseSettingsDialogFactory.ListSpec list) {
list.dialogs().forEach(child -> collect(child, commands));
} else if (specification instanceof BaseSettingsDialogFactory.MultiSpec multi) {
multi.actions().forEach(button -> commands.add(button.command()));
} else if (specification
instanceof BaseSettingsDialogFactory.ConfirmationSpec confirmation) {
commands.add(confirmation.confirm().command());
} else if (specification instanceof BaseSettingsDialogFactory.NoticeSpec notice
&& notice.action() != null) {
commands.add(notice.action().command());
}
}
}
@@ -1,5 +1,6 @@
package games.dmg.spigotbase; package games.dmg.spigotbase;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never; import static org.mockito.Mockito.never;
@@ -8,20 +9,26 @@ import static org.mockito.Mockito.when;
import java.time.Instant; import java.time.Instant;
import java.util.HashSet; import java.util.HashSet;
import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.Set; import java.util.Set;
import java.util.UUID; import java.util.UUID;
import java.util.logging.Logger; import java.util.logging.Logger;
import org.bukkit.Location;
import org.bukkit.Material; import org.bukkit.Material;
import org.bukkit.Server; import org.bukkit.Server;
import org.bukkit.World; import org.bukkit.World;
import org.bukkit.block.Block; import org.bukkit.block.Block;
import org.bukkit.entity.Entity;
import org.bukkit.entity.Mob;
import org.bukkit.entity.Player; import org.bukkit.entity.Player;
import org.bukkit.event.block.Action; import org.bukkit.event.block.Action;
import org.bukkit.event.block.BlockPlaceEvent; import org.bukkit.event.block.BlockPlaceEvent;
import org.bukkit.event.player.PlayerInteractEvent; import org.bukkit.event.player.PlayerInteractEvent;
import org.bukkit.event.player.PlayerTeleportEvent;
import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.ItemStack;
import org.bukkit.util.BoundingBox;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
final class PocketBaseControllerTest { final class PocketBaseControllerTest {
@@ -37,6 +44,7 @@ final class PocketBaseControllerTest {
World world = mock(World.class); World world = mock(World.class);
Block clicked = block(world, -2, 66, 0, Material.DIAMOND_BLOCK); Block clicked = block(world, -2, 66, 0, Material.DIAMOND_BLOCK);
PlayerInteractEvent event = mock(PlayerInteractEvent.class); PlayerInteractEvent event = mock(PlayerInteractEvent.class);
ItemStack activator = item(Material.FLINT_AND_STEEL);
BaseStateManager baseStates = mock(BaseStateManager.class); BaseStateManager baseStates = mock(BaseStateManager.class);
PocketBaseManager pocketBases = mock(PocketBaseManager.class); PocketBaseManager pocketBases = mock(PocketBaseManager.class);
PluginSettings settings = PluginSettings.from(Map.of()); PluginSettings settings = PluginSettings.from(Map.of());
@@ -49,7 +57,7 @@ final class PocketBaseControllerTest {
when(event.getPlayer()).thenReturn(owner); when(event.getPlayer()).thenReturn(owner);
when(event.getAction()).thenReturn(Action.RIGHT_CLICK_BLOCK); when(event.getAction()).thenReturn(Action.RIGHT_CLICK_BLOCK);
when(event.getClickedBlock()).thenReturn(clicked); when(event.getClickedBlock()).thenReturn(clicked);
when(event.getItem()).thenReturn(new ItemStack(Material.FLINT_AND_STEEL)); when(event.getItem()).thenReturn(activator);
when(world.getUID()).thenReturn(worldId); when(world.getUID()).thenReturn(worldId);
when(world.getName()).thenReturn("world"); when(world.getName()).thenReturn("world");
when(world.getBlockAt(anyInt(), anyInt(), anyInt())).thenAnswer(invocation -> { when(world.getBlockAt(anyInt(), anyInt(), anyInt())).thenAnswer(invocation -> {
@@ -98,6 +106,7 @@ final class PocketBaseControllerTest {
World world = mock(World.class); World world = mock(World.class);
Block clicked = block(world, 8, 67, 8, Material.DIAMOND_BLOCK); Block clicked = block(world, 8, 67, 8, Material.DIAMOND_BLOCK);
PlayerInteractEvent event = mock(PlayerInteractEvent.class); PlayerInteractEvent event = mock(PlayerInteractEvent.class);
ItemStack activator = item(Material.FLINT_AND_STEEL);
PocketBaseManager pocketBases = mock(PocketBaseManager.class); PocketBaseManager pocketBases = mock(PocketBaseManager.class);
PluginSettings settings = PluginSettings.from(Map.of()); PluginSettings settings = PluginSettings.from(Map.of());
@@ -105,7 +114,7 @@ final class PocketBaseControllerTest {
when(event.getPlayer()).thenReturn(owner); when(event.getPlayer()).thenReturn(owner);
when(event.getAction()).thenReturn(Action.RIGHT_CLICK_BLOCK); when(event.getAction()).thenReturn(Action.RIGHT_CLICK_BLOCK);
when(event.getClickedBlock()).thenReturn(clicked); when(event.getClickedBlock()).thenReturn(clicked);
when(event.getItem()).thenReturn(new ItemStack(Material.FLINT_AND_STEEL)); when(event.getItem()).thenReturn(activator);
when(world.getUID()).thenReturn(worldId); when(world.getUID()).thenReturn(worldId);
when(world.getName()).thenReturn("pocket"); when(world.getName()).thenReturn("pocket");
when(world.getBlockAt(anyInt(), anyInt(), anyInt())).thenAnswer(invocation -> { when(world.getBlockAt(anyInt(), anyInt(), anyInt())).thenAnswer(invocation -> {
@@ -152,6 +161,7 @@ final class PocketBaseControllerTest {
World world = mock(World.class); World world = mock(World.class);
Block clicked = block(world, 30, 67, 8, Material.DIAMOND_BLOCK); Block clicked = block(world, 30, 67, 8, Material.DIAMOND_BLOCK);
PlayerInteractEvent event = mock(PlayerInteractEvent.class); PlayerInteractEvent event = mock(PlayerInteractEvent.class);
ItemStack activator = item(Material.FLINT_AND_STEEL);
PocketBaseManager pocketBases = mock(PocketBaseManager.class); PocketBaseManager pocketBases = mock(PocketBaseManager.class);
PluginSettings settings = PluginSettings.from(Map.of()); PluginSettings settings = PluginSettings.from(Map.of());
@@ -159,7 +169,7 @@ final class PocketBaseControllerTest {
when(event.getPlayer()).thenReturn(owner); when(event.getPlayer()).thenReturn(owner);
when(event.getAction()).thenReturn(Action.RIGHT_CLICK_BLOCK); when(event.getAction()).thenReturn(Action.RIGHT_CLICK_BLOCK);
when(event.getClickedBlock()).thenReturn(clicked); when(event.getClickedBlock()).thenReturn(clicked);
when(event.getItem()).thenReturn(new ItemStack(Material.FLINT_AND_STEEL)); when(event.getItem()).thenReturn(activator);
when(world.getUID()).thenReturn(worldId); when(world.getUID()).thenReturn(worldId);
when(world.getName()).thenReturn("pocket"); when(world.getName()).thenReturn("pocket");
when(world.getBlockAt(anyInt(), anyInt(), anyInt())).thenAnswer(invocation -> { when(world.getBlockAt(anyInt(), anyInt(), anyInt())).thenAnswer(invocation -> {
@@ -227,6 +237,157 @@ final class PocketBaseControllerTest {
verify(event).setCancelled(true); 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 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( private static Block block(
World world, World world,
int x, int x,