feat(pocket-base): share flight through gold portals
Release / release (push) Successful in 2m50s
CI / build (push) Successful in 1m15s

This commit is contained in:
dmg
2026-08-29 00:20:40 -04:00
parent f4511f9286
commit e0b1a25c5e
21 changed files with 744 additions and 80 deletions
+3 -2
View File
@@ -30,6 +30,7 @@ The plugin JAR is written to `build/libs/`.
/basesettings status
/basesettings upgrade
/basesettings pocket upgrade
/basesettings pocket flight <enable|disable>
/basesettings pocket mobs <hostile|passive> <enable|disable>
/basesettings pocket type <void|nether|overworld> <subtype>
/basesettings visitors <allowed|blocked>
@@ -40,7 +41,7 @@ The plugin JAR is written to `build/libs/`.
/basesettings bossbar <enable|disable>
```
`/basesettings` and `/basesettings ui` open Minecraft's native dialog dashboard. The dashboard summarizes Base and Pocket Base progression, provides unlocked setting controls and purchase confirmations, and marks unavailable controls as locked. `/basesettings status` retains the detailed chat report.
`/basesettings` and `/basesettings ui` open Minecraft's native dialog dashboard. The dashboard summarizes Base and Pocket Base progression, provides unlocked setting controls and purchase confirmations, marks unavailable controls as locked, and provides Back buttons from nested screens. `/basesettings status` retains the detailed chat report.
Navigation particles appear only in the base's world and when the player is more than 25 blocks beyond the current base border.
@@ -48,7 +49,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 IV owners can purchase and expand a persistent Pocket Base with `/basesettings pocket upgrade`. Activating a complete diamond-block portal frame inside the normal base with flint and steel opens one public entrance to the owner's grass platform in a private void world. Players and non-player living mobs can travel through the entrance and active return portal; items, projectiles, and vehicles are not transported. The owner can move the return portal by building and igniting another complete frame inside the unlocked Pocket Base boundary; only the newly activated return portal remains functional. Natural hostile and passive mob spawning is disabled by default. Owners can control each category independently with `/basesettings pocket mobs <hostile|passive> <enable|disable>`. An owner who wins a raid inside their own Pocket Base permanently unlocks owner-only Survival flight across the full build height and within the current Pocket Base boundary plus a 16-block warning buffer on every side.
Base IV owners can purchase and expand a persistent Pocket Base with `/basesettings pocket upgrade`. Activating a complete diamond-block portal frame inside the normal base with flint and steel opens one public entrance to the owner's grass platform in a private void world. Players and non-player living mobs can travel through the entrance and active return portal; items, projectiles, and vehicles are not transported. The owner can move the return portal by building and igniting another complete frame inside the unlocked Pocket Base boundary; only the newly activated return portal remains functional. Natural hostile and passive mob spawning is disabled by default. Owners can control each category independently with `/basesettings pocket mobs <hostile|passive> <enable|disable>`. An owner who wins a raid inside their own Pocket Base permanently unlocks owner-only Survival flight across the full build height and within the current Pocket Base boundary plus a 16-block warning buffer on every side. The owner can control that automatic privilege with `/basesettings pocket flight <enable|disable>`. After the raid unlock, the owner can ignite a complete gold-block portal frame inside the unlocked boundary; any player who passes through receives temporary flight within the same boundary and buffer until leaving the Pocket Base. The owner's automatic-flight preference does not affect portal-granted flight.
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.
+8
View File
@@ -156,3 +156,11 @@ description: Chronological record of material decisions affecting the Spigot Bas
- Limited Pocket Base flight to its expanding horizontal boundary plus a 16-block warning buffer while allowing the world's full build height.
- Persisted unlocks with safe locked defaults for existing records and exposed progress through chat status and the native settings dialog.
- Verified 140 tests and the Java 25 plugin artifact with `./gradlew clean check jar`.
## 2026-08-28 — Shared Pocket Base flight portals
- Added one persistent owner-activated gold-block flight portal per Pocket Base after the raid unlock.
- Granted owners and guests temporary controlled flight after portal collision, independently of the owner's persisted automatic-flight preference.
- Applied the expanding Pocket Base boundary plus a 16-block warning buffer to both automatic and portal-granted flight.
- Restored Back buttons from nested native dialog screens to the main Base Settings dashboard.
- Verified 149 tests and the Java 25 plugin artifact with `./gradlew clean check jar`.
+1
View File
@@ -27,3 +27,4 @@ description: Catalog of user stories for the Spigot Base plugin.
19. [US-019: Change a Pocket Base biome](us-019-change-a-pocket-base-biome.md)
20. [US-020: Control a base through a dialog UI](us-020-control-base-through-dialog-ui.md)
21. [US-021: Unlock Pocket Base flight](us-021-unlock-pocket-base-flight.md)
22. [US-022: Share Pocket Base flight through a portal](us-022-share-pocket-base-flight-through-a-portal.md)
@@ -18,6 +18,7 @@ As a **server operator**, I want progression behavior to be configurable and dur
- [x] Invalid required configuration prevents partial plugin initialization and produces a clear server log message.
- [x] UUID-keyed state persists latest known names, base locations, counters, levels, relocation times, teleport times, visitor cooldowns, navigation preferences, flight preferences, boss-bar preferences, and visitor settings.
- [x] UUID-keyed Pocket Base state persists raid-earned flight unlocks, with missing legacy values defaulting safely to locked.
- [x] Pocket Base state persists the owner automatic-flight preference and active flight portal, with safe defaults for legacy records.
- [x] Cooldowns and relocation limits use real elapsed timestamps and continue while players are offline.
- [x] State is saved safely so a failed write does not replace valid persisted state with a partial document.
- [x] Corrupt, unknown, or invalid records are handled defensively and cannot silently grant progression or privileges.
@@ -30,3 +31,4 @@ As a **server operator**, I want progression behavior to be configurable and dur
- [US-010: Administer player progression](us-010-administer-player-progression.md)
- [US-012: Build and release the plugin](us-012-build-and-release-plugin.md)
- [US-021: Unlock Pocket Base flight](us-021-unlock-pocket-base-flight.md)
- [US-022: Share Pocket Base flight through a portal](us-022-share-pocket-base-flight-through-a-portal.md)
@@ -37,6 +37,7 @@ As a **Pocket Base owner**, I want to connect my normal base to my Pocket Base w
- [x] Players who disconnect inside a Pocket Base remain there when they reconnect.
- [x] Moving the normal base deactivates its entrance while preserving the Pocket Base world and its contents.
- [x] Portal ownership and the active entrance location persist through restarts.
- [x] A separate active gold-block portal inside an owner's Pocket Base can grant temporary controlled flight after raid-earned flight is unlocked.
## Related
@@ -44,3 +45,4 @@ As a **Pocket Base owner**, I want to connect my normal base to my Pocket Base w
- [US-008: Unlock Base IV visitor access](us-008-unlock-visitor-access.md)
- [US-015: Unlock a Pocket Base](us-015-unlock-a-pocket-base.md)
- [US-017: Expand a Pocket Base](us-017-expand-a-pocket-base.md)
- [US-022: Share Pocket Base flight through a portal](us-022-share-pocket-base-flight-through-a-portal.md)
@@ -16,7 +16,8 @@ As a **player**, I want a dialog-based base dashboard so that I can inspect prog
- [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] The Pocket Base dialog shows whether owner flight still requires a successful raid or has been unlocked.
- [x] The Pocket Base dialog shows whether owner flight still requires a successful raid and lets an unlocked owner enable or disable automatic Pocket Base flight.
- [x] Nested dialog screens provide Back buttons that return to the main Base Settings dashboard.
- [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.
@@ -34,3 +35,4 @@ As a **player**, I want a dialog-based base dashboard so that I can inspect prog
- [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)
- [US-021: Unlock Pocket Base flight](us-021-unlock-pocket-base-flight.md)
- [US-022: Share Pocket Base flight through a portal](us-022-share-pocket-base-flight-through-a-portal.md)
@@ -17,7 +17,7 @@ As a **Pocket Base owner**, I want to earn flight by completing a raid in my Poc
- [x] Defeated, stopped, expired, and abandoned raids do not unlock flight.
- [x] Unlocking Pocket Base flight is free and does not consume blocks or other currency.
- [x] The owner receives a prominent one-time unlock notification after the qualifying raid.
- [x] In Survival mode, an owner with the unlock receives plugin-granted flight while inside their own Pocket Base.
- [x] In Survival mode, an owner with the unlock can enable or disable automatic plugin-granted flight inside their own Pocket Base without affecting portal-granted temporary flight.
- [x] Pocket Base flight spans the world's full build height and the current unlocked horizontal boundary plus 16 blocks on every side.
- [x] An actively flying owner receives a warning after leaving the unlocked Pocket Base boundary while remaining inside the 16-block buffer.
- [x] Moving beyond the 16-block buffer removes only flight granted by this plugin.
@@ -35,3 +35,4 @@ As a **Pocket Base owner**, I want to earn flight by completing a raid in my Poc
- [US-015: Unlock a Pocket Base](us-015-unlock-a-pocket-base.md)
- [US-017: Expand a Pocket Base](us-017-expand-a-pocket-base.md)
- [US-020: Control a base through a dialog UI](us-020-control-base-through-dialog-ui.md)
- [US-022: Share Pocket Base flight through a portal](us-022-share-pocket-base-flight-through-a-portal.md)
@@ -0,0 +1,35 @@
---
type: User Story
title: "US-022: Share Pocket Base flight through a portal"
description: Let an owner build a gold-block portal that temporarily grants controlled Pocket Base flight to players who pass through it.
status: done
---
# US-022: Share Pocket Base flight through a portal
As a **Pocket Base owner**, I want an activated flight portal inside my Pocket Base so that I can temporarily share controlled flight with guests without changing my own default-flight preference.
## Acceptance criteria
- [x] The owner must first unlock Pocket Base flight by winning a raid in their own Pocket Base.
- [x] Only the owner can activate a complete 4-by-5 gold-block frame with flint and steel as the active flight portal.
- [x] Every required frame block must be inside the owner's currently unlocked Pocket Base boundary at activation.
- [x] Each Pocket Base has at most one active flight portal, and activating another valid frame moves the active location without removing the old frame.
- [x] The active flight portal uses custom particles and collision without vanilla Nether portal blocks.
- [x] Breaking any required block in the active frame deactivates the flight portal.
- [x] Any player, including the owner or a guest, who passes through the active intact flight portal receives temporary Pocket Base flight.
- [x] Portal-granted flight remains independent of the owner's default Pocket Base flight preference.
- [x] Portal-granted flight spans the world's full build height and the current Pocket Base boundary plus 16 blocks on every side.
- [x] An actively flying player receives a warning inside the 16-block buffer, and flight ends beyond it.
- [x] Expanding the Pocket Base automatically expands the portal-granted flight area.
- [x] Portal-granted flight ends when the player leaves the Pocket Base, dies, disconnects, or changes out of Survival mode.
- [x] The active flight portal location and owner default-flight preference persist across restarts.
- [x] Existing saved Pocket Bases default to no flight portal while preserving raid-earned owner flight as enabled.
## Related
- [US-011: Configure and persist progression](us-011-configure-and-persist-progression.md)
- [US-016: Build and use Pocket Base portals](us-016-build-and-use-pocket-base-portals.md)
- [US-017: Expand a Pocket Base](us-017-expand-a-pocket-base.md)
- [US-020: Control a base through a dialog UI](us-020-control-base-through-dialog-ui.md)
- [US-021: Unlock Pocket Base flight](us-021-unlock-pocket-base-flight.md)
@@ -1,7 +1,9 @@
package games.dmg.spigotbase;
import java.io.IOException;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
@@ -14,6 +16,8 @@ import org.bukkit.Server;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.entity.PlayerDeathEvent;
import org.bukkit.event.player.PlayerQuitEvent;
import org.bukkit.event.raid.RaidFinishEvent;
import org.bukkit.inventory.ItemStack;
@@ -28,6 +32,7 @@ final class BaseFlightController implements Runnable, Listener {
private final Logger logger;
private final Set<UUID> grantedFlight = new HashSet<>();
private final Set<UUID> warned = new HashSet<>();
private final Map<UUID, UUID> portalFlight = new HashMap<>();
BaseFlightController(
Server server,
@@ -91,6 +96,20 @@ final class BaseFlightController implements Runnable, Listener {
);
}
@EventHandler
public void onDeath(PlayerDeathEvent event) {
clearPortalFlight(event.getEntity());
}
@EventHandler
public void onQuit(PlayerQuitEvent event) {
clearPortalFlight(event.getPlayer());
}
void grantPocketPortalFlight(Player player, UUID ownerId) {
portalFlight.put(player.getUniqueId(), ownerId);
}
@Override
public void run() {
for (Player player : server.getOnlinePlayers()) {
@@ -102,6 +121,7 @@ final class BaseFlightController implements Runnable, Listener {
}
grantedFlight.removeIf(id -> server.getPlayer(id) == null);
warned.removeIf(id -> server.getPlayer(id) == null);
portalFlight.keySet().removeIf(id -> server.getPlayer(id) == null);
}
void removeGrantedFlight(Player player) {
@@ -124,6 +144,7 @@ final class BaseFlightController implements Runnable, Listener {
}
grantedFlight.clear();
warned.clear();
portalFlight.clear();
}
private PlayerState observeElytra(Player player, PlayerState state) {
@@ -148,6 +169,7 @@ final class BaseFlightController implements Runnable, Listener {
private void applyFlight(Player player, PlayerState state) {
if (player.getGameMode() != GameMode.SURVIVAL) {
portalFlight.remove(player.getUniqueId());
removeGrantedFlight(player);
return;
}
@@ -160,6 +182,7 @@ final class BaseFlightController implements Runnable, Listener {
return;
}
}
portalFlight.remove(player.getUniqueId());
if (!state.flightEnabled() || state.flightLevel() < 1 || state.base().isEmpty()) {
removeGrantedFlight(player);
return;
@@ -199,8 +222,10 @@ final class BaseFlightController implements Runnable, Listener {
private void applyPocketFlight(Player player, UUID ownerId) {
PocketBaseState pocket = pocketBases.state(ownerId);
if (!ownerId.equals(player.getUniqueId()) || !pocket.flightUnlocked()
|| pocket.level() < 1) {
boolean ownerFlight = ownerId.equals(player.getUniqueId())
&& pocket.flightUnlocked() && pocket.flightEnabled();
boolean temporaryFlight = ownerId.equals(portalFlight.get(player.getUniqueId()));
if ((!ownerFlight && !temporaryFlight) || pocket.level() < 1) {
removeGrantedFlight(player);
return;
}
@@ -234,6 +259,11 @@ final class BaseFlightController implements Runnable, Listener {
}
}
private void clearPortalFlight(Player player) {
portalFlight.remove(player.getUniqueId());
removeGrantedFlight(player);
}
private boolean withinVerticalRange(Player player, PlayerState state, BaseLocation base) {
int y = player.getLocation().getBlockY();
if (state.flightLevel() == 3) {
@@ -93,6 +93,10 @@ final class BaseSettingsCommand implements CommandExecutor, TabCompleter {
&& arguments[1].equalsIgnoreCase("upgrade")) {
return purchasePocketUpgrade(player, state);
}
if (arguments.length == 3 && arguments[0].equalsIgnoreCase("pocket")
&& arguments[1].equalsIgnoreCase("flight")) {
return updatePocketFlight(player, arguments[2]);
}
if (arguments.length == 4 && arguments[0].equalsIgnoreCase("pocket")
&& arguments[1].equalsIgnoreCase("mobs")) {
return updatePocketMobSpawning(player, arguments[2], arguments[3]);
@@ -264,6 +268,35 @@ final class BaseSettingsCommand implements CommandExecutor, TabCompleter {
return true;
}
private boolean updatePocketFlight(Player player, String mode) {
if (pocketBases == null) {
player.sendMessage(ChatColor.RED + "Pocket Bases are currently unavailable.");
return true;
}
PocketBaseState current = pocketBases.state(player.getUniqueId());
if (!current.flightUnlocked()) {
player.sendMessage(ChatColor.RED
+ "Win a raid in your Pocket Base before enabling automatic flight.");
return true;
}
Boolean enabled = enabledMode(mode);
if (enabled == null) {
sendUsage(player);
return true;
}
try {
current = pocketBases.setFlightEnabled(player.getUniqueId(), enabled);
} catch (IOException | RuntimeException exception) {
player.sendMessage(ChatColor.RED
+ "The Pocket Base flight setting could not be changed.");
return true;
}
player.sendMessage(ChatColor.YELLOW + "Pocket Base automatic flight is now "
+ (current.flightEnabled() ? ChatColor.GREEN + "enabled" : ChatColor.RED + "disabled")
+ ChatColor.YELLOW + ".");
return true;
}
private boolean updatePocketMobSpawning(
Player player,
String category,
@@ -567,7 +600,9 @@ final class BaseSettingsCommand implements CommandExecutor, TabCompleter {
+ "; passive spawning="
+ (pocket.passiveMobSpawningEnabled() ? "enabled" : "disabled")
+ "; flight="
+ (pocket.flightUnlocked() ? "unlocked" : "complete a raid to unlock"));
+ (pocket.flightUnlocked()
? "automatic " + (pocket.flightEnabled() ? "enabled" : "disabled")
: "complete a raid to unlock"));
}
private void showCooldownPath(Player player, PlayerState state) {
@@ -599,7 +634,7 @@ final class BaseSettingsCommand implements CommandExecutor, TabCompleter {
List<String> modes = arguments[0].equalsIgnoreCase("visitors")
? VISITOR_MODES
: switch (arguments[0].toLowerCase(Locale.ROOT)) {
case "pocket" -> List.of("upgrade", "mobs", "type");
case "pocket" -> List.of("upgrade", "flight", "mobs", "type");
case "navigation", "flight", "border", "spawnable", "bossbar" -> ENABLE_MODES;
default -> List.of();
};
@@ -608,6 +643,11 @@ final class BaseSettingsCommand implements CommandExecutor, TabCompleter {
}
if (arguments.length == 3 && arguments[0].equalsIgnoreCase("pocket")) {
String prefix = arguments[2].toLowerCase(Locale.ROOT);
if (arguments[1].equalsIgnoreCase("flight")) {
return ENABLE_MODES.stream()
.filter(mode -> mode.startsWith(prefix))
.toList();
}
if (arguments[1].equalsIgnoreCase("mobs")) {
return List.of("hostile", "passive").stream()
.filter(category -> category.startsWith(prefix))
@@ -701,7 +741,7 @@ final class BaseSettingsCommand implements CommandExecutor, TabCompleter {
private static void sendUsage(Player player) {
player.sendMessage(ChatColor.RED + "Usage: /basesettings "
+ "[ui|status|upgrade|pocket upgrade|pocket mobs "
+ "[ui|status|upgrade|pocket upgrade|pocket flight <enable|disable>|pocket mobs "
+ "<hostile|passive> <enable|disable>"
+ "|pocket type <void|nether|overworld> <subtype>"
+ "|visitors <allowed|blocked>|navigation <enable|disable>"
@@ -147,6 +147,7 @@ final class BaseSettingsDialogFactory {
private DialogSpec pocketSettings(PlayerState owner, PocketBaseState pocket) {
List<DialogSpec> dialogs = List.of(
pocketUpgrade(owner, pocket),
pocketFlightSettings(pocket),
pocketMobSettings(pocket),
pocketBiomeSettings(pocket)
);
@@ -161,7 +162,7 @@ final class BaseSettingsDialogFactory {
+ enabled(pocket.hostileMobSpawningEnabled()) + " • Passive mobs: "
+ enabled(pocket.passiveMobSpawningEnabled()) + "\nFlight: "
+ (pocket.flightUnlocked()
? "Unlocked"
? (pocket.flightEnabled() ? "Enabled" : "Disabled")
: "Complete a raid to unlock")
),
dialogs,
@@ -191,6 +192,32 @@ final class BaseSettingsDialogFactory {
);
}
private DialogSpec pocketFlightSettings(PocketBaseState pocket) {
if (!pocket.flightUnlocked()) {
return notice(
"Pocket Flight — Locked",
"Win a raid in your Pocket Base to unlock automatic flight."
);
}
return new MultiSpec(
content(
"Pocket Flight",
"Pocket Flight",
"Control automatic owner flight. Flight granted by the gold portal "
+ "is unaffected."
),
List.of(toggle(
"Automatic Flight",
true,
pocket.flightEnabled(),
"Enabled",
"Disabled",
"basesettings pocket flight " + mode(!pocket.flightEnabled())
)),
1
);
}
private DialogSpec pocketMobSettings(PocketBaseState pocket) {
if (pocket.level() < 1) {
return notice(
@@ -328,14 +355,19 @@ final class BaseSettingsDialogFactory {
}
private static Dialog render(DialogSpec specification) {
return render(specification, true);
}
private static Dialog render(DialogSpec specification, boolean root) {
DialogType type;
ActionButton back = root ? null : backButton();
if (specification instanceof ListSpec list) {
List<Dialog> dialogs = list.dialogs().stream()
.map(BaseSettingsDialogFactory::render)
.map(child -> render(child, false))
.toList();
type = DialogType.dialogList(
RegistrySet.valueSet(RegistryKey.DIALOG, dialogs),
null,
back,
list.columns(),
list.buttonWidth()
);
@@ -343,13 +375,15 @@ final class BaseSettingsDialogFactory {
List<ActionButton> actions = multi.actions().stream()
.map(BaseSettingsDialogFactory::render)
.toList();
type = DialogType.multiAction(actions, null, multi.columns());
type = DialogType.multiAction(actions, back, multi.columns());
} else if (specification instanceof ConfirmationSpec confirmation) {
ActionButton cancel = ActionButton.builder(Component.text("Cancel")).build();
type = DialogType.confirmation(render(confirmation.confirm()), cancel);
type = DialogType.confirmation(
render(confirmation.confirm()),
root ? ActionButton.builder(Component.text("Cancel")).build() : back
);
} else if (specification instanceof NoticeSpec notice) {
type = notice.action() == null
? DialogType.notice()
? (root ? DialogType.notice() : DialogType.notice(back))
: DialogType.notice(render(notice.action()));
} else {
throw new IllegalArgumentException("Unsupported dialog specification");
@@ -367,6 +401,14 @@ final class BaseSettingsDialogFactory {
return Dialog.create(factory -> factory.empty().base(base).type(type));
}
private static ActionButton backButton() {
return render(backButtonSpec());
}
static ButtonSpec backButtonSpec() {
return commandButton("Back", "basesettings ui");
}
private static ActionButton render(ButtonSpec button) {
ActionButton.Builder builder = ActionButton.builder(Component.text(button.label()))
.width(button.width())
@@ -39,6 +39,7 @@ final class PocketBaseController implements Listener, Runnable {
private final PocketBaseManager pocketBases;
private final PluginSettingsProvider settings;
private final Logger logger;
private final BaseFlightController flightController;
private final Map<UUID, Long> cooldownUntil = new HashMap<>();
PocketBaseController(
@@ -48,6 +49,18 @@ final class PocketBaseController implements Listener, Runnable {
PocketBaseManager pocketBases,
PluginSettingsProvider settings,
Logger logger
) {
this(server, baseStates, baseBounds, pocketBases, settings, logger, null);
}
PocketBaseController(
Server server,
BaseStateManager baseStates,
BaseBoundsService baseBounds,
PocketBaseManager pocketBases,
PluginSettingsProvider settings,
Logger logger,
BaseFlightController flightController
) {
this.server = server;
this.baseStates = baseStates;
@@ -55,6 +68,7 @@ final class PocketBaseController implements Listener, Runnable {
this.pocketBases = pocketBases;
this.settings = settings;
this.logger = logger;
this.flightController = flightController;
}
@Override
@@ -65,6 +79,8 @@ final class PocketBaseController implements Listener, Runnable {
if (state.level() > 0 && pocketBases.returnPortalIsIntact(state.ownerId())) {
showPortal(pocketBases.returnPortal(state.ownerId()));
}
state.flightPortal().filter(portal -> isIntact(portal, Material.GOLD_BLOCK))
.ifPresent(portal -> showPortal(portal, Material.GOLD_BLOCK));
}
scanMobPortals();
long now = System.nanoTime();
@@ -88,41 +104,57 @@ final class PocketBaseController implements Listener, Runnable {
Material frameMaterial = Material.valueOf(
settings.current().pocketBasePortalFrameMaterial()
);
Optional<PocketPortalLocation> portal = PocketPortalGeometry.findFrame(
position(clicked),
world.getUID(),
world.getName(),
candidate -> block(world, candidate).getType() == frameMaterial,
candidate -> isAir(block(world, candidate).getType())
);
if (portal.isEmpty()) {
return;
}
Optional<UUID> pocketOwner = pocketBases.ownerForPocketWorld(world.getUID());
if (pocketOwner.isPresent()) {
if (!pocketOwner.orElseThrow().equals(player.getUniqueId())) {
return;
}
Optional<PocketPortalLocation> flightPortal = pocket.flightUnlocked()
? findFrame(clicked, Material.GOLD_BLOCK)
: Optional.empty();
boolean activatingFlightPortal = flightPortal.isPresent();
Optional<PocketPortalLocation> portal = activatingFlightPortal
? flightPortal
: findFrame(clicked, frameMaterial);
if (portal.isEmpty()) {
return;
}
if (PocketPortalGeometry.frameBlocks(portal.orElseThrow()).stream()
.anyMatch(candidate -> !pocketBases.policy().contains(
pocket.level(), candidate.x(), candidate.z()
))) {
player.sendMessage(ChatColor.RED
+ "The complete return portal must be inside your Pocket Base boundary.");
player.sendMessage(ChatColor.RED + "The complete "
+ (activatingFlightPortal ? "flight" : "return")
+ " portal must be inside your Pocket Base boundary.");
event.setCancelled(true);
return;
}
try {
pocketBases.activateReturnPortal(player.getUniqueId(), portal.orElseThrow());
if (activatingFlightPortal) {
pocketBases.activateFlightPortal(
player.getUniqueId(), portal.orElseThrow()
);
} else {
pocketBases.activateReturnPortal(
player.getUniqueId(), portal.orElseThrow()
);
}
} catch (IOException | RuntimeException exception) {
logger.log(Level.SEVERE, "Could not activate Pocket Base return portal", exception);
player.sendMessage(ChatColor.RED
+ "The Pocket Base return portal could not be activated.");
logger.log(Level.SEVERE, "Could not activate Pocket Base "
+ (activatingFlightPortal ? "flight" : "return") + " portal", exception);
player.sendMessage(ChatColor.RED + "The Pocket Base "
+ (activatingFlightPortal ? "flight" : "return")
+ " portal could not be activated.");
event.setCancelled(true);
return;
}
event.setCancelled(true);
player.sendMessage(ChatColor.GREEN + "Pocket Base return portal activated.");
player.sendMessage(ChatColor.GREEN + "Pocket Base "
+ (activatingFlightPortal ? "flight" : "return") + " portal activated.");
return;
}
Optional<PocketPortalLocation> portal = findFrame(clicked, frameMaterial);
if (portal.isEmpty()) {
return;
}
PlayerState owner = baseStates.player(player.getUniqueId(), player.getName());
@@ -161,6 +193,13 @@ final class PocketBaseController implements Listener, Runnable {
deactivate(state.ownerId());
}
}
if (state.flightPortal().isPresent()) {
PocketPortalLocation portal = state.flightPortal().orElseThrow();
if (portal.worldId().equals(worldId)
&& PocketPortalGeometry.isFrame(portal, broken)) {
deactivateFlightPortal(state.ownerId());
}
}
}
}
@@ -199,6 +238,17 @@ final class PocketBaseController implements Listener, Runnable {
pocketBases.returnPortal(ownerId), position(destination)
)) {
leavePocket(player, ownerId);
return;
}
PocketBaseState state = pocketBases.state(ownerId);
if (flightController != null && state.flightPortal().isPresent()) {
PocketPortalLocation portal = state.flightPortal().orElseThrow();
if (isIntact(portal, Material.GOLD_BLOCK)
&& PocketPortalGeometry.isInterior(
portal, position(destination)
)) {
flightController.grantPocketPortalFlight(player, ownerId);
}
}
return;
}
@@ -316,18 +366,26 @@ final class PocketBaseController implements Listener, Runnable {
}
private boolean isIntact(PocketPortalLocation portal) {
World world = server.getWorld(portal.worldId());
if (world == null) {
return false;
}
Material frame = Material.valueOf(settings.current().pocketBasePortalFrameMaterial());
return PocketPortalGeometry.frameBlocks(portal).stream()
return isIntact(portal, frame);
}
private boolean isIntact(PocketPortalLocation portal, Material frame) {
World world = server.getWorld(portal.worldId());
return world != null && PocketPortalGeometry.frameBlocks(portal).stream()
.allMatch(position -> block(world, position).getType() == frame);
}
private void showPortal(PocketPortalLocation portal) {
showPortal(
portal,
Material.valueOf(settings.current().pocketBasePortalFrameMaterial())
);
}
private void showPortal(PocketPortalLocation portal, Material frame) {
World world = server.getWorld(portal.worldId());
if (world == null || !isIntact(portal)) {
if (world == null || !isIntact(portal, frame)) {
return;
}
for (BlockPosition position : PocketPortalGeometry.interiorBlocks(portal)) {
@@ -345,6 +403,14 @@ final class PocketBaseController implements Listener, Runnable {
}
}
private void deactivateFlightPortal(UUID ownerId) {
try {
pocketBases.deactivateFlightPortal(ownerId);
} catch (IOException exception) {
logger.log(Level.SEVERE, "Could not deactivate Pocket Base flight portal", exception);
}
}
private void deactivate(UUID ownerId) {
try {
pocketBases.deactivateEntrance(ownerId);
@@ -383,6 +449,17 @@ final class PocketBaseController implements Listener, Runnable {
);
}
private Optional<PocketPortalLocation> findFrame(Block clicked, Material material) {
World world = clicked.getWorld();
return PocketPortalGeometry.findFrame(
position(clicked),
world.getUID(),
world.getName(),
candidate -> block(world, candidate).getType() == material,
candidate -> isAir(block(world, candidate).getType())
);
}
private static boolean isAir(Material material) {
return material == Material.AIR || material == Material.CAVE_AIR
|| material == Material.VOID_AIR;
@@ -60,6 +60,24 @@ final class PocketBaseManager {
}
}
PocketBaseState activateFlightPortal(
UUID ownerId,
PocketPortalLocation portal
) throws IOException {
PocketBaseState current = states.state(ownerId);
if (!current.flightUnlocked()) {
throw new IllegalStateException("Pocket Base flight is still locked");
}
return states.updateAndSave(ownerId, state -> state.withFlightPortal(portal));
}
void deactivateFlightPortal(UUID ownerId) throws IOException {
PocketBaseState current = states.state(ownerId);
if (current.flightPortal().isPresent()) {
states.updateAndSave(ownerId, PocketBaseState::withoutFlightPortal);
}
}
PocketBaseState activateReturnPortal(
UUID ownerId,
PocketPortalLocation portal
@@ -103,6 +121,19 @@ final class PocketBaseManager {
return states.updateAndSave(ownerId, PocketBaseState::withFlightUnlocked);
}
PocketBaseState setFlightEnabled(UUID ownerId, boolean enabled) throws IOException {
PocketBaseState current = states.state(ownerId);
if (!current.flightUnlocked()) {
throw new IllegalStateException("Pocket Base flight is still locked");
}
if (current.flightEnabled() == enabled) {
return current;
}
return states.updateAndSave(
ownerId, state -> state.withFlightEnabled(enabled)
);
}
PocketBaseState setHostileMobSpawning(UUID ownerId, boolean enabled)
throws IOException {
PocketBaseState current = states.state(ownerId);
@@ -11,7 +11,9 @@ public record PocketBaseState(
boolean hostileMobSpawningEnabled,
boolean passiveMobSpawningEnabled,
PocketBaseBiome biome,
boolean flightUnlocked
boolean flightUnlocked,
Optional<PocketPortalLocation> flightPortal,
boolean flightEnabled
) {
public PocketBaseState {
if (ownerId == null) {
@@ -22,10 +24,34 @@ public record PocketBaseState(
}
entrance = entrance == null ? Optional.empty() : entrance;
returnPortal = returnPortal == null ? Optional.empty() : returnPortal;
flightPortal = flightPortal == null ? Optional.empty() : flightPortal;
biome = biome == null ? PocketBaseBiome.THE_VOID : biome;
if (level == 0 && (entrance.isPresent() || returnPortal.isPresent())) {
throw new IllegalArgumentException("a locked Pocket Base cannot have a portal");
if (level == 0 && (entrance.isPresent() || returnPortal.isPresent()
|| flightPortal.isPresent() || flightUnlocked || flightEnabled)) {
throw new IllegalArgumentException("a locked Pocket Base cannot have unlocks or portals");
}
if (!flightUnlocked && (flightPortal.isPresent() || flightEnabled)) {
throw new IllegalArgumentException(
"Pocket Base flight must be unlocked before it is enabled or shared"
);
}
}
public PocketBaseState(
UUID ownerId,
int level,
Optional<PocketPortalLocation> entrance,
Optional<PocketPortalLocation> returnPortal,
boolean hostileMobSpawningEnabled,
boolean passiveMobSpawningEnabled,
PocketBaseBiome biome,
boolean flightUnlocked
) {
this(
ownerId, level, entrance, returnPortal, hostileMobSpawningEnabled,
passiveMobSpawningEnabled, biome, flightUnlocked, Optional.empty(),
flightUnlocked
);
}
public PocketBaseState(
@@ -39,7 +65,7 @@ public record PocketBaseState(
) {
this(
ownerId, level, entrance, returnPortal, hostileMobSpawningEnabled,
passiveMobSpawningEnabled, biome, false
passiveMobSpawningEnabled, biome, false, Optional.empty(), false
);
}
@@ -50,70 +76,88 @@ public record PocketBaseState(
) {
this(
ownerId, level, entrance, Optional.empty(), false, false,
PocketBaseBiome.THE_VOID, false
PocketBaseBiome.THE_VOID, false, Optional.empty(), false
);
}
public static PocketBaseState locked(UUID ownerId) {
return new PocketBaseState(
ownerId, 0, Optional.empty(), Optional.empty(), false, false,
PocketBaseBiome.THE_VOID, false
PocketBaseBiome.THE_VOID, false, Optional.empty(), false
);
}
public PocketBaseState withLevel(int newLevel) {
return new PocketBaseState(
ownerId, newLevel, entrance, returnPortal, hostileMobSpawningEnabled,
passiveMobSpawningEnabled, biome, flightUnlocked
);
return copy(newLevel, entrance, returnPortal, hostileMobSpawningEnabled,
passiveMobSpawningEnabled, biome, flightUnlocked, flightPortal, flightEnabled);
}
public PocketBaseState withEntrance(PocketPortalLocation portal) {
return new PocketBaseState(
ownerId, level, Optional.of(portal), returnPortal,
hostileMobSpawningEnabled, passiveMobSpawningEnabled, biome, flightUnlocked
);
return copy(level, Optional.of(portal), returnPortal, hostileMobSpawningEnabled,
passiveMobSpawningEnabled, biome, flightUnlocked, flightPortal, flightEnabled);
}
public PocketBaseState withoutEntrance() {
return new PocketBaseState(
ownerId, level, Optional.empty(), returnPortal, hostileMobSpawningEnabled,
passiveMobSpawningEnabled, biome, flightUnlocked
);
return copy(level, Optional.empty(), returnPortal, hostileMobSpawningEnabled,
passiveMobSpawningEnabled, biome, flightUnlocked, flightPortal, flightEnabled);
}
public PocketBaseState withReturnPortal(PocketPortalLocation portal) {
return new PocketBaseState(
ownerId, level, entrance, Optional.of(portal), hostileMobSpawningEnabled,
passiveMobSpawningEnabled, biome, flightUnlocked
);
return copy(level, entrance, Optional.of(portal), hostileMobSpawningEnabled,
passiveMobSpawningEnabled, biome, flightUnlocked, flightPortal, flightEnabled);
}
public PocketBaseState withHostileMobSpawningEnabled(boolean enabled) {
return new PocketBaseState(
ownerId, level, entrance, returnPortal, enabled,
passiveMobSpawningEnabled, biome, flightUnlocked
);
return copy(level, entrance, returnPortal, enabled, passiveMobSpawningEnabled,
biome, flightUnlocked, flightPortal, flightEnabled);
}
public PocketBaseState withPassiveMobSpawningEnabled(boolean enabled) {
return new PocketBaseState(
ownerId, level, entrance, returnPortal, hostileMobSpawningEnabled,
enabled, biome, flightUnlocked
);
return copy(level, entrance, returnPortal, hostileMobSpawningEnabled, enabled,
biome, flightUnlocked, flightPortal, flightEnabled);
}
public PocketBaseState withBiome(PocketBaseBiome selectedBiome) {
return new PocketBaseState(
ownerId, level, entrance, returnPortal, hostileMobSpawningEnabled,
passiveMobSpawningEnabled, selectedBiome, flightUnlocked
);
return copy(level, entrance, returnPortal, hostileMobSpawningEnabled,
passiveMobSpawningEnabled, selectedBiome, flightUnlocked, flightPortal,
flightEnabled);
}
public PocketBaseState withFlightUnlocked() {
return copy(level, entrance, returnPortal, hostileMobSpawningEnabled,
passiveMobSpawningEnabled, biome, true, flightPortal, true);
}
public PocketBaseState withFlightEnabled(boolean enabled) {
return copy(level, entrance, returnPortal, hostileMobSpawningEnabled,
passiveMobSpawningEnabled, biome, flightUnlocked, flightPortal, enabled);
}
public PocketBaseState withFlightPortal(PocketPortalLocation portal) {
return copy(level, entrance, returnPortal, hostileMobSpawningEnabled,
passiveMobSpawningEnabled, biome, flightUnlocked, Optional.of(portal),
flightEnabled);
}
public PocketBaseState withoutFlightPortal() {
return copy(level, entrance, returnPortal, hostileMobSpawningEnabled,
passiveMobSpawningEnabled, biome, flightUnlocked, Optional.empty(), flightEnabled);
}
private PocketBaseState copy(
int newLevel,
Optional<PocketPortalLocation> newEntrance,
Optional<PocketPortalLocation> newReturnPortal,
boolean hostileEnabled,
boolean passiveEnabled,
PocketBaseBiome selectedBiome,
boolean unlockedFlight,
Optional<PocketPortalLocation> newFlightPortal,
boolean enabledFlight
) {
return new PocketBaseState(
ownerId, level, entrance, returnPortal, hostileMobSpawningEnabled,
passiveMobSpawningEnabled, biome, true
ownerId, newLevel, newEntrance, newReturnPortal, hostileEnabled, passiveEnabled,
selectedBiome, unlockedFlight, newFlightPortal, enabledFlight
);
}
}
@@ -82,7 +82,8 @@ public final class SpigotBasePlugin extends JavaPlugin {
boundsService,
pocketBaseManager,
settingsProvider,
getLogger()
getLogger(),
flightController
);
getServer().getPluginManager().registerEvents(progressListener, this);
getServer().getPluginManager().registerEvents(flightController, this);
@@ -39,6 +39,9 @@ public final class YamlPocketBaseRepository {
UUID ownerId = UUID.fromString(key);
String path = "owners." + key;
int level = yaml.getInt(path + ".level", 0);
boolean flightUnlocked = yaml.getBoolean(
path + ".flight-unlocked", false
);
PocketBaseState state = new PocketBaseState(
ownerId,
level,
@@ -47,7 +50,9 @@ public final class YamlPocketBaseRepository {
loadMobPreference(yaml, path, "hostile-mob-spawning-enabled"),
loadMobPreference(yaml, path, "passive-mob-spawning-enabled"),
loadBiome(yaml, path),
yaml.getBoolean(path + ".flight-unlocked", false)
flightUnlocked,
loadPortal(yaml, path + ".flight-portal"),
yaml.getBoolean(path + ".flight-enabled", flightUnlocked)
);
states.put(ownerId, state);
} catch (IllegalArgumentException ignored) {
@@ -75,12 +80,16 @@ public final class YamlPocketBaseRepository {
state.passiveMobSpawningEnabled()
);
yaml.set(path + ".flight-unlocked", state.flightUnlocked());
yaml.set(path + ".flight-enabled", state.flightEnabled());
yaml.set(path + ".world-type", state.biome().worldType().commandName());
yaml.set(path + ".biome", state.biome().commandName());
state.entrance().ifPresent(portal -> savePortal(yaml, path + ".entrance", portal));
state.returnPortal().ifPresent(portal ->
savePortal(yaml, path + ".return-portal", portal)
);
state.flightPortal().ifPresent(portal ->
savePortal(yaml, path + ".flight-portal", portal)
);
}
Path temporary = Files.createTempFile(parent, "spigot-base-pocket-", ".yml");
try {
@@ -167,6 +167,122 @@ final class BaseFlightControllerTest {
verify(player).setAllowFlight(true);
}
@Test
void disabledAutomaticPocketBaseFlightDoesNotGrantOwnerFlight() {
UUID pocketWorldId = UUID.randomUUID();
World pocketWorld = mock(World.class);
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
PocketBaseState pocket = new PocketBaseState(playerId, 1, Optional.empty())
.withFlightUnlocked()
.withFlightEnabled(false);
BaseFlightController pocketController = new BaseFlightController(
server,
stateManager,
new SecondaryProgressionService(PluginSettings.from(Map.of())),
new BaseBoundsService(PluginSettings.from(Map.of())),
new PluginSettingsProvider(PluginSettings.from(Map.of())),
pocketBases,
Logger.getAnonymousLogger()
);
when(stateManager.player(playerId, "Alex")).thenReturn(
PlayerState.newPlayer(playerId, "Alex")
);
when(player.getWorld()).thenReturn(pocketWorld);
when(player.getAllowFlight()).thenReturn(false);
when(pocketWorld.getUID()).thenReturn(pocketWorldId);
when(pocketBases.ownerForPocketWorld(pocketWorldId)).thenReturn(Optional.of(playerId));
when(pocketBases.state(playerId)).thenReturn(pocket);
pocketController.run();
verify(player, never()).setAllowFlight(true);
}
@Test
void flightPortalTemporarilyGrantsGuestPocketBaseFlight() {
UUID ownerId = UUID.randomUUID();
UUID pocketWorldId = UUID.randomUUID();
World pocketWorld = mock(World.class);
Location location = mock(Location.class);
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
PocketBaseState pocket = new PocketBaseState(ownerId, 1, Optional.empty())
.withFlightUnlocked()
.withFlightEnabled(false);
BaseFlightController pocketController = new BaseFlightController(
server,
stateManager,
new SecondaryProgressionService(PluginSettings.from(Map.of())),
new BaseBoundsService(PluginSettings.from(Map.of())),
new PluginSettingsProvider(PluginSettings.from(Map.of())),
pocketBases,
Logger.getAnonymousLogger()
);
when(stateManager.player(playerId, "Alex")).thenReturn(
PlayerState.newPlayer(playerId, "Alex")
);
when(player.getWorld()).thenReturn(pocketWorld);
when(player.getLocation()).thenReturn(location);
when(player.getAllowFlight()).thenReturn(false);
when(pocketWorld.getUID()).thenReturn(pocketWorldId);
when(pocketBases.ownerForPocketWorld(pocketWorldId)).thenReturn(Optional.of(ownerId));
when(pocketBases.state(ownerId)).thenReturn(pocket);
when(pocketBases.policy()).thenReturn(
new PocketBasePolicy(PluginSettings.from(Map.of()))
);
pocketController.grantPocketPortalFlight(player, ownerId);
pocketController.run();
verify(player).setAllowFlight(true);
}
@Test
void portalFlightEndsAfterGuestLeavesPocketBase() {
UUID ownerId = UUID.randomUUID();
UUID pocketWorldId = UUID.randomUUID();
UUID normalWorldId = UUID.randomUUID();
World pocketWorld = mock(World.class);
World normalWorld = mock(World.class);
Location location = mock(Location.class);
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
PocketBaseState pocket = new PocketBaseState(ownerId, 1, Optional.empty())
.withFlightUnlocked()
.withFlightEnabled(false);
BaseFlightController pocketController = new BaseFlightController(
server,
stateManager,
new SecondaryProgressionService(PluginSettings.from(Map.of())),
new BaseBoundsService(PluginSettings.from(Map.of())),
new PluginSettingsProvider(PluginSettings.from(Map.of())),
pocketBases,
Logger.getAnonymousLogger()
);
when(stateManager.player(playerId, "Alex")).thenReturn(
PlayerState.newPlayer(playerId, "Alex")
);
when(player.getWorld()).thenReturn(pocketWorld, normalWorld);
when(player.getLocation()).thenReturn(location);
when(player.getAllowFlight()).thenReturn(false);
when(pocketWorld.getUID()).thenReturn(pocketWorldId);
when(normalWorld.getUID()).thenReturn(normalWorldId);
when(pocketBases.ownerForPocketWorld(pocketWorldId)).thenReturn(Optional.of(ownerId));
when(pocketBases.ownerForPocketWorld(normalWorldId)).thenReturn(Optional.empty());
when(pocketBases.state(ownerId)).thenReturn(pocket);
when(pocketBases.policy()).thenReturn(
new PocketBasePolicy(PluginSettings.from(Map.of()))
);
pocketController.grantPocketPortalFlight(player, ownerId);
pocketController.run();
pocketController.run();
verify(player).setFlying(false);
verify(player).setAllowFlight(false);
}
@Test
void flyingInPocketBaseBufferShowsLeavingWarning() {
UUID pocketWorldId = UUID.randomUUID();
@@ -15,6 +15,7 @@ import java.io.IOException;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.UnaryOperator;
@@ -112,6 +113,16 @@ final class BaseSettingsCommandTest {
List.of("upgrade"),
command.onTabComplete(null, null, "basesettings", new String[] {"pocket", "u"})
);
assertEquals(
List.of("flight"),
command.onTabComplete(null, null, "basesettings", new String[] {"pocket", "f"})
);
assertEquals(
List.of("disable"),
command.onTabComplete(
null, null, "basesettings", new String[] {"pocket", "flight", "d"}
)
);
assertEquals(
List.of("mobs"),
command.onTabComplete(null, null, "basesettings", new String[] {"pocket", "m"})
@@ -459,6 +470,41 @@ final class BaseSettingsCommandTest {
assertEquals(64, restored[0].getAmount());
}
@Test
void ownerCanDisableAutomaticPocketBaseFlight() throws Exception {
UUID playerId = UUID.randomUUID();
Player player = mock(Player.class);
when(player.getUniqueId()).thenReturn(playerId);
when(player.getName()).thenReturn("Builder");
BaseStateManager stateManager = mock(BaseStateManager.class);
when(stateManager.player(playerId, "Builder")).thenReturn(
PlayerState.newPlayer(playerId, "Builder")
);
PocketBaseState enabled = new PocketBaseState(playerId, 1, Optional.empty())
.withFlightUnlocked();
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
when(pocketBases.state(playerId)).thenReturn(enabled);
when(pocketBases.setFlightEnabled(playerId, false)).thenReturn(
enabled.withFlightEnabled(false)
);
BaseSettingsCommand command = new BaseSettingsCommand(
stateManager,
new PluginSettingsProvider(PluginSettings.from(Map.of())),
mock(BaseFlightController.class),
pocketBases
);
command.onCommand(
player, null, "basesettings",
new String[] {"pocket", "flight", "disable"}
);
verify(pocketBases).setFlightEnabled(playerId, false);
verify(player).sendMessage(org.mockito.ArgumentMatchers.<String>argThat(
message -> message.contains("automatic flight") && message.contains("disabled")
));
}
@Test
void statusDisplaysPocketMobSpawningPreferences() {
UUID playerId = UUID.randomUUID();
@@ -28,6 +28,15 @@ final class BaseSettingsDialogFactoryTest {
assertEquals("/basesettings status", payload.value());
}
@Test
void createsBackButtonThatReturnsToMainSettingsDashboard() {
BaseSettingsDialogFactory.ButtonSpec back =
BaseSettingsDialogFactory.backButtonSpec();
assertEquals("Back", back.label());
assertEquals("/basesettings ui", back.command());
}
@Test
void createsControlsForEveryBaseSettingAndConfirmedPurchase() {
UUID playerId = UUID.randomUUID();
@@ -47,7 +56,8 @@ final class BaseSettingsDialogFactoryTest {
Optional.empty(),
true,
false,
PocketBaseBiome.PLAINS
PocketBaseBiome.PLAINS,
true
);
BaseSettingsDialogFactory.DialogSpec specification =
@@ -64,7 +74,7 @@ final class BaseSettingsDialogFactoryTest {
root.dialogs().get(3)
);
assertTrue(pocketSettings.content().message().contains(
"Flight: Complete a raid to unlock"
"Flight: Enabled"
));
List<String> commands = commands(root);
assertTrue(commands.contains("/basesettings status"));
@@ -75,6 +85,7 @@ final class BaseSettingsDialogFactoryTest {
assertTrue(commands.contains("/basesettings spawnable disable"));
assertTrue(commands.contains("/basesettings bossbar disable"));
assertTrue(commands.contains("/basesettings pocket upgrade"));
assertTrue(commands.contains("/basesettings pocket flight disable"));
assertTrue(commands.contains("/basesettings pocket mobs hostile disable"));
assertTrue(commands.contains("/basesettings pocket mobs passive enable"));
assertTrue(commands.contains("/basesettings pocket type nether crimson_forest"));
@@ -24,6 +24,7 @@ import org.bukkit.entity.Entity;
import org.bukkit.entity.Mob;
import org.bukkit.entity.Player;
import org.bukkit.event.block.Action;
import org.bukkit.event.block.BlockBreakEvent;
import org.bukkit.event.block.BlockPlaceEvent;
import org.bukkit.event.player.PlayerInteractEvent;
import org.bukkit.event.player.PlayerMoveEvent;
@@ -150,6 +151,62 @@ final class PocketBaseControllerTest {
verify(event).setCancelled(true);
}
@Test
void ownerCanActivateGoldFlightPortalAfterRaidUnlock() throws Exception {
UUID ownerId = UUID.randomUUID();
UUID worldId = UUID.randomUUID();
PocketPortalLocation portal = new PocketPortalLocation(
worldId, "pocket", 8, 65, 8, PocketPortalAxis.X
);
Set<BlockPosition> frame = new HashSet<>(PocketPortalGeometry.frameBlocks(portal));
Player owner = mock(Player.class);
World world = mock(World.class);
Block clicked = block(world, 8, 67, 8, Material.GOLD_BLOCK);
PlayerInteractEvent event = mock(PlayerInteractEvent.class);
ItemStack activator = item(Material.FLINT_AND_STEEL);
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
PluginSettings settings = PluginSettings.from(Map.of());
PocketBaseState state = new PocketBaseState(ownerId, 1, Optional.empty())
.withFlightUnlocked();
when(owner.getUniqueId()).thenReturn(ownerId);
when(event.getPlayer()).thenReturn(owner);
when(event.getAction()).thenReturn(Action.RIGHT_CLICK_BLOCK);
when(event.getClickedBlock()).thenReturn(clicked);
when(event.getItem()).thenReturn(activator);
when(world.getUID()).thenReturn(worldId);
when(world.getName()).thenReturn("pocket");
when(world.getBlockAt(anyInt(), anyInt(), anyInt())).thenAnswer(invocation -> {
BlockPosition position = new BlockPosition(
invocation.getArgument(0), invocation.getArgument(1), invocation.getArgument(2)
);
return block(
world,
position.x(),
position.y(),
position.z(),
frame.contains(position) ? Material.GOLD_BLOCK : Material.AIR
);
});
when(pocketBases.ownerForPocketWorld(worldId)).thenReturn(Optional.of(ownerId));
when(pocketBases.state(ownerId)).thenReturn(state);
when(pocketBases.policy()).thenReturn(new PocketBasePolicy(settings));
PocketBaseController controller = new PocketBaseController(
mock(Server.class),
mock(BaseStateManager.class),
new BaseBoundsService(settings),
pocketBases,
new PluginSettingsProvider(settings),
Logger.getAnonymousLogger(),
mock(BaseFlightController.class)
);
controller.onActivate(event);
verify(pocketBases).activateFlightPortal(ownerId, portal);
verify(event).setCancelled(true);
}
@Test
void returnFrameOutsideUnlockedPocketBoundaryIsRejected() throws Exception {
UUID ownerId = UUID.randomUUID();
@@ -205,6 +262,39 @@ final class PocketBaseControllerTest {
verify(event).setCancelled(true);
}
@Test
void breakingActiveFlightPortalFrameDeactivatesIt() throws Exception {
UUID ownerId = UUID.randomUUID();
UUID worldId = UUID.randomUUID();
PocketPortalLocation flightPortal = new PocketPortalLocation(
worldId, "pocket", 8, 65, 8, PocketPortalAxis.X
);
PocketBaseState state = new PocketBaseState(ownerId, 1, Optional.empty())
.withFlightUnlocked()
.withFlightPortal(flightPortal);
World world = mock(World.class);
BlockBreakEvent event = mock(BlockBreakEvent.class);
Block broken = block(world, 8, 65, 8, Material.GOLD_BLOCK);
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
PluginSettings settings = PluginSettings.from(Map.of());
when(world.getUID()).thenReturn(worldId);
when(event.getBlock()).thenReturn(broken);
when(pocketBases.knownStates()).thenReturn(Map.of(ownerId, state));
PocketBaseController controller = new PocketBaseController(
mock(Server.class),
mock(BaseStateManager.class),
new BaseBoundsService(settings),
pocketBases,
new PluginSettingsProvider(settings),
Logger.getAnonymousLogger()
);
controller.onBreak(event);
verify(pocketBases).deactivateFlightPortal(ownerId);
}
@Test
void placementOutsideUnlockedPocketBoundaryIsCancelled() {
UUID ownerId = UUID.randomUUID();
@@ -311,6 +401,63 @@ final class PocketBaseControllerTest {
);
}
@Test
void activeFlightPortalGrantsTemporaryFlightToGuest() {
UUID ownerId = UUID.randomUUID();
UUID guestId = UUID.randomUUID();
UUID pocketWorldId = UUID.randomUUID();
PocketPortalLocation flightPortal = new PocketPortalLocation(
pocketWorldId, "pocket", 8, 65, 8, PocketPortalAxis.X
);
World pocketWorld = mock(World.class);
Set<BlockPosition> frame = new HashSet<>(
PocketPortalGeometry.frameBlocks(flightPortal)
);
Location destination = new Location(pocketWorld, 9.5, 66.0, 8.5);
Player guest = mock(Player.class);
PlayerMoveEvent event = mock(PlayerMoveEvent.class);
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
BaseFlightController flightController = mock(BaseFlightController.class);
Server server = mock(Server.class);
PluginSettings settings = PluginSettings.from(Map.of());
PocketBaseState state = new PocketBaseState(ownerId, 1, Optional.empty())
.withFlightUnlocked()
.withFlightPortal(flightPortal);
when(pocketWorld.getUID()).thenReturn(pocketWorldId);
when(pocketWorld.getBlockAt(anyInt(), anyInt(), anyInt())).thenAnswer(invocation -> {
BlockPosition position = new BlockPosition(
invocation.getArgument(0), invocation.getArgument(1), invocation.getArgument(2)
);
return block(
pocketWorld,
position.x(),
position.y(),
position.z(),
frame.contains(position) ? Material.GOLD_BLOCK : Material.AIR
);
});
when(server.getWorld(pocketWorldId)).thenReturn(pocketWorld);
when(guest.getUniqueId()).thenReturn(guestId);
when(event.getPlayer()).thenReturn(guest);
when(event.getTo()).thenReturn(destination);
when(pocketBases.ownerForPocketWorld(pocketWorldId)).thenReturn(Optional.of(ownerId));
when(pocketBases.state(ownerId)).thenReturn(state);
PocketBaseController controller = new PocketBaseController(
server,
mock(BaseStateManager.class),
new BaseBoundsService(settings),
pocketBases,
new PluginSettingsProvider(settings),
Logger.getAnonymousLogger(),
flightController
);
controller.onMove(event);
verify(flightController).grantPocketPortalFlight(guest, ownerId);
}
@Test
void voidReturnClearsAccumulatedPlayerFallDistance() {
UUID ownerId = UUID.randomUUID();
@@ -32,7 +32,11 @@ final class YamlPocketBaseRepositoryTest {
true,
false,
PocketBaseBiome.CRIMSON_FOREST,
true
true,
Optional.of(new PocketPortalLocation(
worldId, "pocket", 4, 65, 4, PocketPortalAxis.X
)),
false
);
YamlPocketBaseRepository repository = new YamlPocketBaseRepository(
temporaryDirectory.resolve("pocket-bases.yml")
@@ -59,6 +63,20 @@ final class YamlPocketBaseRepositoryTest {
assertFalse(loaded.flightUnlocked());
}
@Test
void existingRaidUnlockDefaultsOwnerFlightToEnabled() throws Exception {
UUID ownerId = UUID.randomUUID();
Path stateFile = temporaryDirectory.resolve("pocket-bases.yml");
Files.writeString(stateFile, "owners:\n " + ownerId
+ ":\n level: 1\n flight-unlocked: true\n");
YamlPocketBaseRepository repository = new YamlPocketBaseRepository(stateFile);
PocketBaseState loaded = repository.load().get(ownerId);
assertTrue(loaded.flightEnabled());
assertTrue(loaded.flightPortal().isEmpty());
}
@Test
void migratesLegacyMobPreferenceToBothCategories() throws Exception {
UUID ownerId = UUID.randomUUID();