2 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
16 changed files with 601 additions and 252 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
+4 -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
@@ -48,7 +48,7 @@ After 250 Survival-mode block placements anywhere by default, the spawnable over
`/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")
+14
View File
@@ -122,3 +122,17 @@ description: Chronological record of material decisions affecting the Spigot Bas
- Retained the detailed chat report under `/basesettings status` and kept dialog actions routed through the existing validated command behavior. - 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. - Added dialog routing, autocomplete, alias, non-player, generated-control, locked-state, and purchase-confirmation coverage.
- Verified the feature with `./gradlew clean check jar`. - 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,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.
@@ -2,7 +2,7 @@
type: User Story type: User Story
title: "US-020: Control a base through a dialog UI" title: "US-020: Control a base through a dialog UI"
description: Let players inspect and control their base through Minecraft's native dialog interface. description: Let players inspect and control their base through Minecraft's native dialog interface.
status: done status: in-progress
--- ---
# US-020: Control a base through a dialog UI # US-020: Control a base through a dialog UI
@@ -21,6 +21,7 @@ As a **player**, I want a dialog-based base dashboard so that I can inspect prog
- [x] `ui` is offered through command autocomplete. - [x] `ui` is offered through command autocomplete.
- [x] Non-player execution remains safely rejected. - [x] Non-player execution remains safely rejected.
- [x] Automated tests verify dialog routing and generated controls. - [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 ## Related
+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;
@@ -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
@@ -449,7 +469,7 @@ final class BaseSettingsCommand implements CommandExecutor, TabCompleter {
PocketBaseState pocket = pocketBases == null PocketBaseState pocket = pocketBases == null
? PocketBaseState.locked(state.playerId()) ? PocketBaseState.locked(state.playerId())
: pocketBases.state(state.playerId()); : pocketBases.state(state.playerId());
player.showDialog(new BaseSettingsDialogFactory(settings.current()).create(state, pocket)); player.showDialog(dialogProvider.create(state, pocket));
} }
private void showStatus(Player player, PlayerState state) { private void showStatus(Player player, PlayerState state) {
@@ -672,6 +692,11 @@ 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 "
+ "[ui|status|upgrade|pocket upgrade|pocket mobs " + "[ui|status|upgrade|pocket upgrade|pocket mobs "
@@ -1,19 +1,17 @@
package games.dmg.spigotbase; 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.Arrays;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
import net.md_5.bungee.api.chat.BaseComponent; import net.kyori.adventure.text.Component;
import net.md_5.bungee.api.chat.TextComponent;
import net.md_5.bungee.api.dialog.ConfirmationDialog;
import net.md_5.bungee.api.dialog.Dialog;
import net.md_5.bungee.api.dialog.DialogBase;
import net.md_5.bungee.api.dialog.DialogListDialog;
import net.md_5.bungee.api.dialog.MultiActionDialog;
import net.md_5.bungee.api.dialog.NoticeDialog;
import net.md_5.bungee.api.dialog.action.ActionButton;
import net.md_5.bungee.api.dialog.action.RunCommandAction;
import net.md_5.bungee.api.dialog.body.PlainMessageBody;
final class BaseSettingsDialogFactory { final class BaseSettingsDialogFactory {
private static final int DIALOG_WIDTH = 420; private static final int DIALOG_WIDTH = 420;
@@ -24,8 +22,18 @@ final class BaseSettingsDialogFactory {
} }
Dialog create(PlayerState owner, PocketBaseState pocket) { Dialog create(PlayerState owner, PocketBaseState pocket) {
return new DialogListDialog( return render(specification(owner, pocket));
base( }
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 Settings", "Base Settings",
"Base " + owner.baseLevel() + "/4 • Size " + owner.sizeLevel() + "/3" "Base " + owner.baseLevel() + "/4 • Size " + owner.sizeLevel() + "/3"
@@ -33,95 +41,91 @@ final class BaseSettingsDialogFactory {
+ "Pocket Base " + pocket.level() + "" + "Pocket Base " + pocket.level() + ""
+ humanize(pocket.biome().commandName()) + humanize(pocket.biome().commandName())
), ),
List.of( dialogs,
generalSettings(owner),
progressReport(),
baseUpgrade(owner),
pocketSettings(owner, pocket)
),
null,
2, 2,
190 190
); );
} }
private Dialog generalSettings(PlayerState owner) { private DialogSpec generalSettings(PlayerState owner) {
return new MultiActionDialog( List<ButtonSpec> actions = List.of(
base( 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",
"Base Controls", "Base Controls",
"Select a setting to change it. Locked controls remain visible and explain " "Select a setting to change it. Locked controls remain visible and explain "
+ "their requirement in chat when selected." + "their requirement in chat when selected."
), ),
List.of( actions,
toggle( 2
"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())
)
),
2,
null
); );
} }
private Dialog progressReport() { private DialogSpec progressReport() {
return new NoticeDialog(base( return new NoticeSpec(
"Progress Report", content(
"Progress Report", "Progress Report",
"Display the detailed progression report in chat." "Progress Report",
)).action(commandButton("Show in Chat", "basesettings status")); "Display the detailed progression report in chat."
),
commandButton("Show in Chat", "basesettings status")
);
} }
private Dialog baseUpgrade(PlayerState owner) { private DialogSpec baseUpgrade(PlayerState owner) {
if (owner.baseLevel() >= 4) { if (owner.baseLevel() >= 4) {
return notice("Base IV", "Base IV and visitor access are already unlocked."); return notice("Base IV", "Base IV and visitor access are already unlocked.");
} }
@@ -131,17 +135,22 @@ final class BaseSettingsDialogFactory {
"Unlock and establish Base III before purchasing Base IV visitor access." "Unlock and establish Base III before purchasing Base IV visitor access."
); );
} }
int price = settings.visitorUnlockDiamondCost();
return confirmation( return confirmation(
"Unlock Base IV", "Unlock Base IV",
"Purchase visitor access for " + price + " diamonds?", "Purchase visitor access for " + settings.visitorUnlockDiamondCost()
+ " diamonds?",
"basesettings upgrade" "basesettings upgrade"
); );
} }
private Dialog pocketSettings(PlayerState owner, PocketBaseState pocket) { private DialogSpec pocketSettings(PlayerState owner, PocketBaseState pocket) {
return new DialogListDialog( List<DialogSpec> dialogs = List.of(
base( pocketUpgrade(owner, pocket),
pocketMobSettings(pocket),
pocketBiomeSettings(pocket)
);
return new ListSpec(
content(
"Pocket Base", "Pocket Base",
"Pocket Base " + pocket.level(), "Pocket Base " + pocket.level(),
pocket.level() == 0 pocket.level() == 0
@@ -151,18 +160,13 @@ final class BaseSettingsDialogFactory {
+ enabled(pocket.hostileMobSpawningEnabled()) + " • Passive mobs: " + enabled(pocket.hostileMobSpawningEnabled()) + " • Passive mobs: "
+ enabled(pocket.passiveMobSpawningEnabled()) + enabled(pocket.passiveMobSpawningEnabled())
), ),
List.of( dialogs,
pocketUpgrade(owner, pocket),
pocketMobSettings(pocket),
pocketBiomeSettings(pocket)
),
null,
1, 1,
260 260
); );
} }
private Dialog pocketUpgrade(PlayerState owner, PocketBaseState pocket) { private DialogSpec pocketUpgrade(PlayerState owner, PocketBaseState pocket) {
if (owner.baseLevel() < 4 || owner.base().isEmpty()) { if (owner.baseLevel() < 4 || owner.base().isEmpty()) {
return notice( return notice(
"Pocket Upgrade — Locked", "Pocket Upgrade — Locked",
@@ -183,57 +187,56 @@ final class BaseSettingsDialogFactory {
); );
} }
private Dialog pocketMobSettings(PocketBaseState pocket) { private DialogSpec pocketMobSettings(PocketBaseState pocket) {
if (pocket.level() < 1) { if (pocket.level() < 1) {
return notice( return notice(
"Mob Spawning — Locked", "Mob Spawning — Locked",
"Unlock Pocket Base I to control natural mob spawning." "Unlock Pocket Base I to control natural mob spawning."
); );
} }
return new MultiActionDialog( List<ButtonSpec> actions = List.of(
base( 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",
"Mob Spawning", "Mob Spawning",
"Control natural spawning independently for each mob category." "Control natural spawning independently for each mob category."
), ),
List.of( actions,
toggle( 1
"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())
)
),
1,
null
); );
} }
private Dialog pocketBiomeSettings(PocketBaseState pocket) { private DialogSpec pocketBiomeSettings(PocketBaseState pocket) {
if (pocket.level() < 1) { if (pocket.level() < 1) {
return notice( return notice(
"Change Biome — Locked", "Change Biome — Locked",
"Unlock Pocket Base I to purchase biome changes." "Unlock Pocket Base I to purchase biome changes."
); );
} }
List<Dialog> worldTypes = Arrays.stream(PocketBaseWorldType.values()) List<DialogSpec> worldTypes = Arrays.stream(PocketBaseWorldType.values())
.map(type -> pocketBiomeType(pocket, type)) .map(type -> pocketBiomeType(pocket, type))
.map(Dialog.class::cast)
.toList(); .toList();
return new DialogListDialog( return new ListSpec(
base( content(
"Change Biome", "Change Biome",
"Change Biome", "Change Biome",
"Current biome: " + pocket.biome().worldType().commandName() + "/" "Current biome: " + pocket.biome().worldType().commandName() + "/"
@@ -242,35 +245,32 @@ final class BaseSettingsDialogFactory {
+ humanize(settings.pocketBaseBiomeCurrencyMaterial()) + "." + humanize(settings.pocketBaseBiomeCurrencyMaterial()) + "."
), ),
worldTypes, worldTypes,
null,
1, 1,
220 220
); );
} }
private DialogListDialog pocketBiomeType( private DialogSpec pocketBiomeType(
PocketBaseState pocket, PocketBaseState pocket,
PocketBaseWorldType worldType PocketBaseWorldType worldType
) { ) {
List<Dialog> biomes = Arrays.stream(PocketBaseBiome.values()) List<DialogSpec> biomes = Arrays.stream(PocketBaseBiome.values())
.filter(biome -> biome.worldType() == worldType) .filter(biome -> biome.worldType() == worldType)
.map(biome -> biomeConfirmation(pocket, biome)) .map(biome -> biomeConfirmation(pocket, biome))
.map(Dialog.class::cast)
.toList(); .toList();
return new DialogListDialog( return new ListSpec(
base( content(
humanize(worldType.commandName()), humanize(worldType.commandName()),
humanize(worldType.commandName()) + " Biomes", humanize(worldType.commandName()) + " Biomes",
"Choose a biome subtype. The active biome is marked as selected." "Choose a biome subtype. The active biome is marked as selected."
), ),
biomes, biomes,
null,
2, 2,
190 190
); );
} }
private Dialog biomeConfirmation(PocketBaseState pocket, PocketBaseBiome biome) { private DialogSpec biomeConfirmation(PocketBaseState pocket, PocketBaseBiome biome) {
String title = humanize(biome.commandName()) String title = humanize(biome.commandName())
+ (pocket.biome() == biome ? " — Selected" : ""); + (pocket.biome() == biome ? " — Selected" : "");
if (pocket.biome() == biome) { if (pocket.biome() == biome) {
@@ -285,16 +285,18 @@ final class BaseSettingsDialogFactory {
); );
} }
private static Dialog confirmation(String title, String message, String command) { private static DialogSpec confirmation(String title, String message, String command) {
return new ConfirmationDialog(base(title, title, message)) return new ConfirmationSpec(
.yes(commandButton("Confirm", command)); content(title, title, message),
commandButton("Confirm", command)
);
} }
private static Dialog notice(String title, String message) { private static DialogSpec notice(String title, String message) {
return new NoticeDialog(base(title, title, message)); return new NoticeSpec(content(title, title, message), null);
} }
private static ActionButton toggle( private static ButtonSpec toggle(
String name, String name,
boolean unlocked, boolean unlocked,
boolean active, boolean active,
@@ -306,29 +308,106 @@ final class BaseSettingsDialogFactory {
String tooltip = unlocked String tooltip = unlocked
? "Click to change this setting." ? "Click to change this setting."
: "This setting has not been unlocked yet."; : "This setting has not been unlocked yet.";
return new ActionButton( return new ButtonSpec(name + ": " + state, tooltip, 190, "/" + command);
text(name + ": " + state),
text(tooltip),
190,
new RunCommandAction("/" + command)
);
} }
private static ActionButton commandButton(String label, String command) { private static ButtonSpec commandButton(String label, String command) {
return new ActionButton(text(label), new RunCommandAction("/" + command)); return new ButtonSpec(label, "", 150, "/" + command);
} }
private static DialogBase base(String title, String externalTitle, String message) { private static DialogContent content(
return new DialogBase(text(title)) String title,
.externalTitle(text(externalTitle)) String externalTitle,
.body(List.of(new PlainMessageBody(text(message), DIALOG_WIDTH))) 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) .canCloseWithEscape(true)
.pause(false) .pause(false)
.afterAction(DialogBase.AfterAction.CLOSE); .afterAction(DialogBase.DialogAfterAction.CLOSE)
.build();
return Dialog.create(factory -> factory.empty().base(base).type(type));
} }
private static BaseComponent text(String value) { private static ActionButton render(ButtonSpec button) {
return new TextComponent(value); 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) { private static String mode(boolean active) {
@@ -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);
@@ -24,7 +24,7 @@ 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.md_5.bungee.api.dialog.Dialog; 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;
@@ -186,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")
@@ -227,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(
@@ -264,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"));
@@ -429,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(
@@ -554,14 +553,16 @@ 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, "homesettings", new String[] {"ui"});
command.onCommand(player, null, "basesettings", new String[] {"status"}); command.onCommand(player, null, "basesettings", new String[] {"status"});
verify(player, times(2)).showDialog(any(Dialog.class)); verify(player, times(2)).showDialog((DialogLike) null);
verify(player).sendMessage(ChatColor.GOLD + "=== Base Progress ==="); verify(player).sendMessage(ChatColor.GOLD + "=== Base Progress ===");
verify(player).sendMessage( verify(player).sendMessage(
org.mockito.ArgumentMatchers.<String>argThat(message -> org.mockito.ArgumentMatchers.<String>argThat(message ->
@@ -582,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());
@@ -1,5 +1,6 @@
package games.dmg.spigotbase; 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.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -9,14 +10,6 @@ import java.util.List;
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 net.md_5.bungee.api.dialog.ConfirmationDialog;
import net.md_5.bungee.api.dialog.Dialog;
import net.md_5.bungee.api.dialog.DialogListDialog;
import net.md_5.bungee.api.dialog.MultiActionDialog;
import net.md_5.bungee.api.dialog.NoticeDialog;
import net.md_5.bungee.api.dialog.action.Action;
import net.md_5.bungee.api.dialog.action.ActionButton;
import net.md_5.bungee.api.dialog.action.RunCommandAction;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
final class BaseSettingsDialogFactoryTest { final class BaseSettingsDialogFactoryTest {
@@ -42,12 +35,15 @@ final class BaseSettingsDialogFactoryTest {
PocketBaseBiome.PLAINS PocketBaseBiome.PLAINS
); );
Dialog dialog = new BaseSettingsDialogFactory( BaseSettingsDialogFactory.DialogSpec specification =
PluginSettings.from(Map.of()) new BaseSettingsDialogFactory(PluginSettings.from(Map.of()))
).create(owner, pocket); .specification(owner, pocket);
DialogListDialog root = assertInstanceOf(DialogListDialog.class, dialog); BaseSettingsDialogFactory.ListSpec root = assertInstanceOf(
assertTrue(root.getBase().body().get(0).toString().contains("Base 4/4")); BaseSettingsDialogFactory.ListSpec.class,
specification
);
assertTrue(root.content().message().contains("Base 4/4"));
List<String> commands = commands(root); List<String> commands = commands(root);
assertTrue(commands.contains("/basesettings status")); assertTrue(commands.contains("/basesettings status"));
assertTrue(commands.contains("/basesettings visitors blocked")); assertTrue(commands.contains("/basesettings visitors blocked"));
@@ -72,23 +68,19 @@ final class BaseSettingsDialogFactoryTest {
Instant.EPOCH Instant.EPOCH
); );
DialogListDialog root = assertInstanceOf( BaseSettingsDialogFactory.ListSpec root = assertInstanceOf(
DialogListDialog.class, BaseSettingsDialogFactory.ListSpec.class,
new BaseSettingsDialogFactory(PluginSettings.from(Map.of())).create( new BaseSettingsDialogFactory(PluginSettings.from(Map.of())).specification(
owner, owner,
PocketBaseState.locked(playerId) PocketBaseState.locked(playerId)
) )
); );
ConfirmationDialog upgrade = assertInstanceOf( BaseSettingsDialogFactory.ConfirmationSpec upgrade = assertInstanceOf(
ConfirmationDialog.class, BaseSettingsDialogFactory.ConfirmationSpec.class,
root.dialogs().get(2) root.dialogs().get(2)
); );
RunCommandAction action = assertInstanceOf( assertEquals("/basesettings upgrade", upgrade.confirm().command());
RunCommandAction.class,
upgrade.yes().action()
);
assertTrue(action.template().equals("/basesettings upgrade"));
} }
@Test @Test
@@ -96,20 +88,19 @@ final class BaseSettingsDialogFactoryTest {
UUID playerId = UUID.randomUUID(); UUID playerId = UUID.randomUUID();
PlayerState owner = PlayerState.newPlayer(playerId, "Newcomer"); PlayerState owner = PlayerState.newPlayer(playerId, "Newcomer");
DialogListDialog root = assertInstanceOf( BaseSettingsDialogFactory.ListSpec root = assertInstanceOf(
DialogListDialog.class, BaseSettingsDialogFactory.ListSpec.class,
new BaseSettingsDialogFactory(PluginSettings.from(Map.of())).create( new BaseSettingsDialogFactory(PluginSettings.from(Map.of())).specification(
owner, owner,
PocketBaseState.locked(playerId) PocketBaseState.locked(playerId)
) )
); );
MultiActionDialog controls = assertInstanceOf( BaseSettingsDialogFactory.MultiSpec controls = assertInstanceOf(
MultiActionDialog.class, BaseSettingsDialogFactory.MultiSpec.class,
root.dialogs().get(0) root.dialogs().get(0)
); );
List<String> labels = controls.actions().stream() List<String> labels = controls.actions().stream()
.map(ActionButton::label) .map(BaseSettingsDialogFactory.ButtonSpec::label)
.map(Object::toString)
.toList(); .toList();
assertTrue(labels.stream().anyMatch(label -> label.contains("Visitors: Locked"))); assertTrue(labels.stream().anyMatch(label -> label.contains("Visitors: Locked")));
@@ -119,34 +110,28 @@ final class BaseSettingsDialogFactoryTest {
assertTrue(labels.stream().anyMatch(label -> label.contains("Spawnable Overlay: Locked"))); assertTrue(labels.stream().anyMatch(label -> label.contains("Spawnable Overlay: Locked")));
} }
private static List<String> commands(Dialog dialog) { private static List<String> commands(
BaseSettingsDialogFactory.DialogSpec specification
) {
List<String> commands = new ArrayList<>(); List<String> commands = new ArrayList<>();
collect(dialog, commands); collect(specification, commands);
return commands; return commands;
} }
private static void collect(Dialog dialog, List<String> commands) { private static void collect(
if (dialog instanceof DialogListDialog list) { BaseSettingsDialogFactory.DialogSpec specification,
List<String> commands
) {
if (specification instanceof BaseSettingsDialogFactory.ListSpec list) {
list.dialogs().forEach(child -> collect(child, commands)); list.dialogs().forEach(child -> collect(child, commands));
collect(list.exitAction(), commands); } else if (specification instanceof BaseSettingsDialogFactory.MultiSpec multi) {
} else if (dialog instanceof MultiActionDialog actions) { multi.actions().forEach(button -> commands.add(button.command()));
actions.actions().forEach(button -> collect(button, commands)); } else if (specification
collect(actions.exitAction(), commands); instanceof BaseSettingsDialogFactory.ConfirmationSpec confirmation) {
} else if (dialog instanceof ConfirmationDialog confirmation) { commands.add(confirmation.confirm().command());
collect(confirmation.yes(), commands); } else if (specification instanceof BaseSettingsDialogFactory.NoticeSpec notice
collect(confirmation.no(), commands); && notice.action() != null) {
} else if (dialog instanceof NoticeDialog notice) { commands.add(notice.action().command());
collect(notice.action(), commands);
}
}
private static void collect(ActionButton button, List<String> commands) {
if (button == null) {
return;
}
Action action = button.action();
if (action instanceof RunCommandAction command) {
commands.add(command.template());
} }
} }
} }
@@ -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,