4 Commits
Author SHA1 Message Date
dmg 1ad930c7ad fix(pocket-base): handle keystone right-clicks in air
Release / release (push) Successful in 2m56s
CI / build (push) Successful in 1m31s
2026-09-06 22:14:18 -04:00
dmg 28dd5422d5 feat(teleport): add warmup particles and fading departure clouds
Release / release (push) Successful in 2m56s
CI / build (push) Successful in 1m35s
2026-09-06 21:11:24 -04:00
dmg ecb07d48ad feat(pocket-base): start flying on flight portal entry
Release / release (push) Successful in 5m2s
CI / build (push) Successful in 1m28s
2026-09-06 20:52:25 -04:00
dmg c42ba6888a fix(pocket-base): accept recovery compass metadata
Release / release (push) Successful in 2m47s
CI / build (push) Successful in 1m17s
2026-08-29 14:51:56 -04:00
16 changed files with 433 additions and 17 deletions
+2 -2
View File
@@ -47,9 +47,9 @@ Navigation particles appear only in the base's world and when the player is more
After 250 Survival-mode block placements anywhere by default, the spawnable overlay can mark nearby dark hostile-mob spawning surfaces inside the player's base with owner-only red particles. The threshold is configurable. After 250 Survival-mode block placements anywhere by default, the spawnable overlay can mark nearby dark hostile-mob spawning surfaces inside the player's base with owner-only red particles. The threshold is configurable.
`/base` has a stationary warm-up. Looking around is allowed, while movement between blocks, damage, teleportation, world changes, death, logout, and conflicting teleport commands cancel it without consuming the cooldown. `/base` has a stationary warm-up. Looking around is allowed, while movement between blocks, damage, teleportation, world changes, death, logout, and conflicting teleport commands cancel it without consuming the cooldown. Home and visitor warm-ups surround the player with nearby-visible floating particles that grow denser as departure approaches. Successful warmed-up teleports leave a cloud at the departure point that thins out over two seconds; cancellation or failure stops emission without a departure cloud.
Base IV owners can purchase and expand a persistent Pocket Base with `/basesettings pocket upgrade`. Activating a complete diamond-block portal frame inside the normal base with flint and steel opens one public entrance to the owner's grass platform in a private void world. Players and non-player living mobs can travel through the entrance and active return portal; items, projectiles, and vehicles are not transported. The owner can move the return portal by building and igniting another complete frame inside the unlocked Pocket Base boundary; only the newly activated return portal remains functional. Natural hostile and passive mob spawning is disabled by default. Owners can control each category independently with `/basesettings pocket mobs <hostile|passive> <enable|disable>`. An owner who wins a raid inside their own Pocket Base permanently unlocks owner-only Survival flight across the full build height and within the current Pocket Base boundary plus a 16-block warning buffer on every side. The owner can control that automatic privilege with `/basesettings pocket flight <enable|disable>`. After the raid unlock, the owner can ignite a complete gold-block portal frame inside the unlocked boundary; any 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. Base IV owners can purchase and expand a persistent Pocket Base with `/basesettings pocket upgrade`. Activating a complete diamond-block portal frame inside the normal base with flint and steel opens one public entrance to the owner's grass platform in a private void world. Players and non-player living mobs can travel through the entrance and active return portal; items, projectiles, and vehicles are not transported. The owner can move the return portal by building and igniting another complete frame inside the unlocked Pocket Base boundary; only the newly activated return portal remains functional. Natural hostile and passive mob spawning is disabled by default. Owners can control each category independently with `/basesettings pocket mobs <hostile|passive> <enable|disable>`. An owner who wins a raid inside their own Pocket Base permanently unlocks owner-only Survival flight across the full build height and within the current Pocket Base boundary plus a 16-block warning buffer on every side. The owner can control that automatic privilege with `/basesettings pocket flight <enable|disable>`. After the raid unlock, the owner can ignite a complete gold-block portal frame inside the unlocked boundary; any Survival player who passes through receives temporary flight within the same boundary and buffer until leaving the Pocket Base. Entering the flight portal immediately starts the player flying and displays “Pocket dimension flight enabled!” once, without repeating while they stand inside the portal. The owner's automatic-flight preference does not affect portal-granted flight.
Pocket Base owners can also craft a reusable **Pocket Base Keystone**. Its shaped recipe uses an Ender Pearl in the center, Netherite Ingots on the four cardinal slots, and Obsidian in the four corners. Right-clicking the keystone outside a Pocket Base instantly records the player's location and takes them to their own Pocket Base. Right-clicking it again inside their own Pocket Base returns them to that exact location when the world and destination remain available and safe. Saved returns survive reconnects and server restarts. A transferred keystone only works for a player who has unlocked their own Pocket Base. Pocket Base owners can also craft a reusable **Pocket Base Keystone**. Its shaped recipe uses an Ender Pearl in the center, Netherite Ingots on the four cardinal slots, and Obsidian in the four corners. Right-clicking the keystone outside a Pocket Base instantly records the player's location and takes them to their own Pocket Base. Right-clicking it again inside their own Pocket Base returns them to that exact location when the world and destination remain available and safe. Saved returns survive reconnects and server restarts. A transferred keystone only works for a player who has unlocked their own Pocket Base.
+5
View File
@@ -165,6 +165,11 @@ description: Chronological record of material decisions affecting the Spigot Bas
- Restored Back buttons from nested native dialog screens to the main Base Settings dashboard. - 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`. - Verified 149 tests and the Java 25 plugin artifact with `./gradlew clean check jar`.
## 2026-08-29 — Recovery Compass metadata compatibility
- Accepted Purpur's valid generic item metadata when creating the Pocket Base Keystone instead of requiring the legacy Compass-specific metadata subtype.
- Added regression coverage and verified 158 tests and the Java 25 plugin artifact with `./gradlew clean check jar`.
## 2026-08-29 — Pocket Base Keystone travel ## 2026-08-29 — Pocket Base Keystone travel
- Added a reusable Recovery Compass keystone recipe with an Ender Pearl center, cardinal Netherite Ingots, and Obsidian corners. - Added a reusable Recovery Compass keystone recipe with an Ender Pearl center, cardinal Netherite Ingots, and Obsidian corners.
@@ -11,6 +11,11 @@ As a **player with Base II**, I want to earn `/base` so that I can return safely
## Acceptance criteria ## Acceptance criteria
- [x] Warm-up particles float around the player, are visible nearby, and grow denser with warm-up progress.
- [x] Successful warmed-up teleports leave a departure cloud that thins out over two seconds.
- [x] Cancellation stops particle emission and failed teleports create no departure cloud.
- [x] Particle effects are bounded, cleaned up on shutdown, and regression-tested without changing teleport timing, safety, or cooldowns.
- [x] Base III requires Base II, an established base, and a configurable 200 qualifying block placements inside the base. - [x] Base III requires Base II, an established base, and a configurable 200 qualifying block placements inside the base.
- [x] Only placements made in Survival mode and within the base's current horizontal and vertical bounds count. - [x] Only placements made in Survival mode and within the base's current horizontal and vertical bounds count.
- [x] Player-placed blocks and replacement of previously broken blocks may contribute repeatedly. - [x] Player-placed blocks and replacement of previously broken blocks may contribute repeatedly.
@@ -11,6 +11,10 @@ As a **player with Base III**, I want to open my base to visitors so that other
## Acceptance criteria ## Acceptance criteria
- [x] Visitor warm-ups share the nearby-visible, progressively denser floating particles used by home teleports.
- [x] Successful visitor teleports leave a two-second fading departure cloud; cancellation or failure stops emission without a departure cloud.
- [x] Effects are bounded and cleaned up on shutdown, with regression coverage preserving visitor timing, safety, and cooldowns.
- [x] Base IV requires Base III and an established base. - [x] Base IV requires Base III and an established base.
- [x] `/basesettings upgrade` offers the Base IV purchase for a configurable price that defaults to 128 diamonds, replacing `/base upgrade` and `/home upgrade`. - [x] `/basesettings upgrade` offers the Base IV purchase for a configurable price that defaults to 128 diamonds, replacing `/base upgrade` and `/home upgrade`.
- [x] A successful purchase removes the complete price atomically from the player's direct inventory. - [x] A successful purchase removes the complete price atomically from the player's direct inventory.
@@ -11,6 +11,10 @@ As a **Pocket Base owner**, I want an activated flight portal inside my Pocket B
## Acceptance criteria ## Acceptance criteria
- [x] Entering the active flight portal immediately starts eligible Survival players flying without double-jumping.
- [x] Each portal entry sends “Pocket dimension flight enabled!” without repeated messages while standing in the portal.
- [x] Immediate takeoff preserves existing flight boundaries and expiry rules, with regression tests.
- [x] The owner must first unlock Pocket Base flight by winning a raid in their own Pocket Base. - [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] 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] Every required frame block must be inside the owner's currently unlocked Pocket Base boundary at activation.
@@ -12,6 +12,7 @@ As a **Pocket Base owner**, I want to craft a reusable keystone that instantly t
## Acceptance criteria ## Acceptance criteria
- [x] The Pocket Base Keystone is a distinct Recovery Compass identified by persistent item metadata rather than its display name alone. - [x] The Pocket Base Keystone is a distinct Recovery Compass identified by persistent item metadata rather than its display name alone.
- [x] Keystone creation accepts the valid generic item metadata supplied for Recovery Compasses by the supported Purpur runtime.
- [x] Its shaped recipe places an Ender Pearl in the center, Netherite Ingots on the four cardinal slots, and Obsidian in the four corners. - [x] Its shaped recipe places an Ender Pearl in the center, Netherite Ingots on the four cardinal slots, and Obsidian in the four corners.
- [x] Only a player who has unlocked Pocket Base I can complete the recipe or use a keystone. - [x] Only a player who has unlocked Pocket Base I can complete the recipe or use a keystone.
- [x] Right-clicking a keystone outside a Pocket Base instantly teleports the player to their own Pocket Base arrival point. - [x] Right-clicking a keystone outside a Pocket Base instantly teleports the player to their own Pocket Base arrival point.
@@ -23,6 +24,15 @@ As a **Pocket Base owner**, I want to craft a reusable keystone that instantly t
- [x] Keystone travel clears accumulated fall distance and does not change existing portal travel behavior. - [x] Keystone travel clears accumulated fall distance and does not change existing portal travel behavior.
- [x] Automated tests cover recipe shape, crafting and use eligibility, metadata authentication, entry, return, persistence, and unavailable or unsafe destinations. - [x] Automated tests cover recipe shape, crafting and use eligibility, metadata authentication, entry, return, persistence, and unavailable or unsafe destinations.
- [x] Keystone entry and return work when right-clicking air or blocks, regardless of facing direction.
- [x] Vanilla no-action events are handled without bypassing explicit item-use restrictions.
- [x] Regression tests cover air and block interactions for entry and return, and denied item use.
## Verification
- `./gradlew clean check jar` passes, including event-dispatch regression coverage for air/block entry and return and explicit item-use denial.
- Live-server verification remains pending deployment.
## Related ## Related
- [US-015: Unlock a Pocket Base](us-015-unlock-a-pocket-base.md) - [US-015: Unlock a Pocket Base](us-015-unlock-a-pocket-base.md)
@@ -107,7 +107,18 @@ final class BaseFlightController implements Runnable, Listener {
} }
void grantPocketPortalFlight(Player player, UUID ownerId) { void grantPocketPortalFlight(Player player, UUID ownerId) {
if (player.getGameMode() != GameMode.SURVIVAL || pocketBases == null
|| !pocketBases.ownerForPocketWorld(player.getWorld().getUID())
.filter(ownerId::equals).isPresent()) {
return;
}
portalFlight.put(player.getUniqueId(), ownerId); portalFlight.put(player.getUniqueId(), ownerId);
if (applyPocketFlight(player, ownerId)) {
player.setFlying(true);
player.sendMessage(ChatColor.GREEN + "Pocket dimension flight enabled!");
} else {
portalFlight.remove(player.getUniqueId());
}
} }
@Override @Override
@@ -220,14 +231,14 @@ final class BaseFlightController implements Runnable, Listener {
} }
} }
private void applyPocketFlight(Player player, UUID ownerId) { private boolean applyPocketFlight(Player player, UUID ownerId) {
PocketBaseState pocket = pocketBases.state(ownerId); PocketBaseState pocket = pocketBases.state(ownerId);
boolean ownerFlight = ownerId.equals(player.getUniqueId()) boolean ownerFlight = ownerId.equals(player.getUniqueId())
&& pocket.flightUnlocked() && pocket.flightEnabled(); && pocket.flightUnlocked() && pocket.flightEnabled();
boolean temporaryFlight = ownerId.equals(portalFlight.get(player.getUniqueId())); boolean temporaryFlight = ownerId.equals(portalFlight.get(player.getUniqueId()));
if ((!ownerFlight && !temporaryFlight) || pocket.level() < 1) { if ((!ownerFlight && !temporaryFlight) || pocket.level() < 1) {
removeGrantedFlight(player); removeGrantedFlight(player);
return; return false;
} }
int halfSize = pocketBases.policy().size(pocket.level()) / 2; int halfSize = pocketBases.policy().size(pocket.level()) / 2;
int x = player.getLocation().getBlockX(); int x = player.getLocation().getBlockX();
@@ -237,7 +248,7 @@ final class BaseFlightController implements Runnable, Listener {
|| z < -halfSize - POCKET_FLIGHT_BUFFER || z < -halfSize - POCKET_FLIGHT_BUFFER
|| z >= halfSize + POCKET_FLIGHT_BUFFER) { || z >= halfSize + POCKET_FLIGHT_BUFFER) {
removeGrantedFlight(player); removeGrantedFlight(player);
return; return false;
} }
if (!player.getAllowFlight()) { if (!player.getAllowFlight()) {
player.setAllowFlight(true); player.setAllowFlight(true);
@@ -257,6 +268,7 @@ final class BaseFlightController implements Runnable, Listener {
} else { } else {
warned.remove(player.getUniqueId()); warned.remove(player.getUniqueId());
} }
return true;
} }
private void clearPortalFlight(Player player) { private void clearPortalFlight(Player player) {
@@ -34,6 +34,7 @@ final class BaseTeleportManager implements Listener {
private final VisitorPolicy visitorPolicy; private final VisitorPolicy visitorPolicy;
private final SafeBaseDestination destinationFinder; private final SafeBaseDestination destinationFinder;
private final Clock clock; private final Clock clock;
private final TeleportParticles particles;
private final Map<UUID, Request> requests = new HashMap<>(); private final Map<UUID, Request> requests = new HashMap<>();
BaseTeleportManager( BaseTeleportManager(
@@ -44,6 +45,20 @@ final class BaseTeleportManager implements Listener {
SafeBaseDestination destinationFinder, SafeBaseDestination destinationFinder,
Clock clock Clock clock
) { ) {
this(plugin, stateManager, policy, visitorPolicy, destinationFinder, clock,
new TeleportParticles(plugin));
}
BaseTeleportManager(
Plugin plugin,
BaseStateManager stateManager,
TeleportPolicy policy,
VisitorPolicy visitorPolicy,
SafeBaseDestination destinationFinder,
Clock clock,
TeleportParticles particles
) {
this.particles = particles;
this.plugin = plugin; this.plugin = plugin;
this.stateManager = stateManager; this.stateManager = stateManager;
this.policy = policy; this.policy = policy;
@@ -138,6 +153,7 @@ final class BaseTeleportManager implements Listener {
return; return;
} }
requests.put(player.getUniqueId(), request); requests.put(player.getUniqueId(), request);
particles.start(player.getUniqueId(), origin, seconds);
request.task = Bukkit.getScheduler().runTaskTimer(plugin, () -> tick(player, request), 0L, 20L); request.task = Bukkit.getScheduler().runTaskTimer(plugin, () -> tick(player, request), 0L, 20L);
player.sendMessage(ChatColor.YELLOW + "Stand still for " + seconds + " seconds to " + purpose + "."); player.sendMessage(ChatColor.YELLOW + "Stand still for " + seconds + " seconds to " + purpose + ".");
} }
@@ -147,6 +163,7 @@ final class BaseTeleportManager implements Listener {
request.cancelTask(); request.cancelTask();
} }
requests.clear(); requests.clear();
particles.clear();
} }
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
@@ -204,6 +221,9 @@ final class BaseTeleportManager implements Listener {
private void tick(Player player, Request request) { private void tick(Player player, Request request) {
if (!player.isOnline() || requests.get(player.getUniqueId()) != request) { if (!player.isOnline() || requests.get(player.getUniqueId()) != request) {
if (requests.remove(player.getUniqueId(), request)) {
particles.cancel(player.getUniqueId());
}
request.cancelTask(); request.cancelTask();
return; return;
} }
@@ -225,18 +245,22 @@ final class BaseTeleportManager implements Listener {
BaseLocation base = request.destination; BaseLocation base = request.destination;
World world = Bukkit.getWorld(base.worldId()); World world = Bukkit.getWorld(base.worldId());
if (world == null) { if (world == null) {
particles.cancel(player.getUniqueId());
player.sendMessage(ChatColor.RED + "The destination world is not currently available."); player.sendMessage(ChatColor.RED + "The destination world is not currently available.");
return; return;
} }
Optional<Location> destination = destinationFinder.find(world, base); Optional<Location> destination = destinationFinder.find(world, base);
if (destination.isEmpty()) { if (destination.isEmpty()) {
particles.cancel(player.getUniqueId());
player.sendMessage(ChatColor.RED + "No safe location could be found at the base."); player.sendMessage(ChatColor.RED + "No safe location could be found at the base.");
return; return;
} }
if (!player.teleport(destination.orElseThrow(), PlayerTeleportEvent.TeleportCause.PLUGIN)) { if (!player.teleport(destination.orElseThrow(), PlayerTeleportEvent.TeleportCause.PLUGIN)) {
particles.cancel(player.getUniqueId());
player.sendMessage(ChatColor.RED + "The base teleport was prevented."); player.sendMessage(ChatColor.RED + "The base teleport was prevented.");
return; return;
} }
particles.complete(player.getUniqueId());
Instant completedAt = clock.instant(); Instant completedAt = clock.instant();
stateManager.update( stateManager.update(
player.getUniqueId(), player.getUniqueId(),
@@ -260,6 +284,7 @@ final class BaseTeleportManager implements Listener {
return; return;
} }
request.cancelTask(); request.cancelTask();
particles.cancel(player.getUniqueId());
if (message != null) { if (message != null) {
player.sendMessage(ChatColor.RED + message); player.sendMessage(ChatColor.RED + message);
} }
@@ -246,7 +246,8 @@ final class PocketBaseController implements Listener, Runnable {
if (isIntact(portal, Material.GOLD_BLOCK) if (isIntact(portal, Material.GOLD_BLOCK)
&& PocketPortalGeometry.isInterior( && PocketPortalGeometry.isInterior(
portal, position(destination) portal, position(destination)
)) { ) && (event.getFrom() == null
|| !PocketPortalGeometry.isInterior(portal, position(event.getFrom())))) {
flightController.grantPocketPortalFlight(player, ownerId); flightController.grantPocketPortalFlight(player, ownerId);
} }
} }
@@ -17,6 +17,7 @@ import org.bukkit.block.Block;
import org.bukkit.NamespacedKey; import org.bukkit.NamespacedKey;
import org.bukkit.Server; import org.bukkit.Server;
import org.bukkit.entity.Player; import org.bukkit.entity.Player;
import org.bukkit.event.Event;
import org.bukkit.event.EventHandler; import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority; import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener; import org.bukkit.event.Listener;
@@ -27,7 +28,6 @@ import org.bukkit.event.player.PlayerTeleportEvent;
import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.ShapedRecipe; import org.bukkit.inventory.ShapedRecipe;
import org.bukkit.inventory.meta.ItemMeta; import org.bukkit.inventory.meta.ItemMeta;
import org.bukkit.inventory.meta.CompassMeta;
import org.bukkit.persistence.PersistentDataType; import org.bukkit.persistence.PersistentDataType;
final class PocketBaseKeystoneService implements Listener { final class PocketBaseKeystoneService implements Listener {
@@ -73,13 +73,18 @@ final class PocketBaseKeystoneService implements Listener {
} }
} }
@EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true) @EventHandler(priority = EventPriority.HIGH)
public void onUse(PlayerInteractEvent event) { public void onUse(PlayerInteractEvent event) {
Action action = event.getAction(); Action action = event.getAction();
if ((action != Action.RIGHT_CLICK_AIR && action != Action.RIGHT_CLICK_BLOCK) if ((action != Action.RIGHT_CLICK_AIR && action != Action.RIGHT_CLICK_BLOCK)
|| !isKeystone(event.getItem(), markerKey)) { || !isKeystone(event.getItem(), markerKey)) {
return; return;
} }
// Vanilla no-action air clicks can deny block use without denying item use.
// Accept those events, but respect explicit item-use denial by other plugins.
if (event.useItemInHand() == Event.Result.DENY) {
return;
}
event.setCancelled(true); event.setCancelled(true);
Player player = event.getPlayer(); Player player = event.getPlayer();
UUID playerId = player.getUniqueId(); UUID playerId = player.getUniqueId();
@@ -201,8 +206,13 @@ final class PocketBaseKeystoneService implements Listener {
static ItemStack createKeystone(NamespacedKey markerKey) { static ItemStack createKeystone(NamespacedKey markerKey) {
ItemStack item = new ItemStack(Material.RECOVERY_COMPASS); ItemStack item = new ItemStack(Material.RECOVERY_COMPASS);
decorateKeystone(item, markerKey);
return item;
}
static void decorateKeystone(ItemStack item, NamespacedKey markerKey) {
ItemMeta metadata = item.getItemMeta(); ItemMeta metadata = item.getItemMeta();
if (!(metadata instanceof CompassMeta)) { if (metadata == null) {
throw new IllegalStateException("Recovery Compass metadata is unavailable"); throw new IllegalStateException("Recovery Compass metadata is unavailable");
} }
metadata.displayName(Component.text( metadata.displayName(Component.text(
@@ -218,7 +228,6 @@ final class PocketBaseKeystoneService implements Listener {
markerKey, PersistentDataType.BYTE, (byte) 1 markerKey, PersistentDataType.BYTE, (byte) 1
); );
item.setItemMeta(metadata); item.setItemMeta(metadata);
return item;
} }
static RecipeSpec recipeSpecification() { static RecipeSpec recipeSpecification() {
@@ -0,0 +1,86 @@
package games.dmg.spigotbase;
import java.util.HashMap;
import java.util.Iterator;
import java.util.Map;
import java.util.UUID;
import org.bukkit.Location;
import org.bukkit.Particle;
import org.bukkit.plugin.Plugin;
import org.bukkit.scheduler.BukkitTask;
/** Cosmetic, main-thread effects; never controls teleport timing or eligibility. */
final class TeleportParticles {
private final Plugin plugin;
private final Map<UUID, Effect> effects = new HashMap<>();
private BukkitTask task;
TeleportParticles(Plugin plugin) {
this.plugin = plugin;
}
void start(UUID playerId, Location origin, int seconds) {
effects.put(playerId, new Effect(origin.clone(), Math.max(1L, seconds * 10L)));
if (task == null) {
task = plugin.getServer().getScheduler().runTaskTimer(plugin, this::tick, 0L, 2L);
}
}
void complete(UUID playerId) {
Effect effect = effects.get(playerId);
if (effect != null) {
effect.fading = true;
effect.age = 0;
}
}
void cancel(UUID playerId) {
effects.remove(playerId);
stopIfEmpty();
}
void clear() {
effects.clear();
stopIfEmpty();
}
private void tick() {
Iterator<Effect> iterator = effects.values().iterator();
while (iterator.hasNext()) {
Effect effect = iterator.next();
int count = effect.fading
? Math.max(1, 12 - (int) (effect.age * 12 / 20))
: 2 + (int) (10 * Math.min(1.0, (double) effect.age / effect.duration));
double angle = effect.age * 0.3;
Location point = effect.origin.clone().add(
Math.cos(angle) * 0.65, 1.0 + Math.sin(angle * 0.5) * 0.45,
Math.sin(angle) * 0.65
);
point.getWorld().spawnParticle(Particle.END_ROD, point, count, 0.3, 0.45, 0.3, 0.01);
effect.age++;
if (effect.fading && effect.age >= 20) {
iterator.remove();
}
}
stopIfEmpty();
}
private void stopIfEmpty() {
if (effects.isEmpty() && task != null) {
task.cancel();
task = null;
}
}
private static final class Effect {
private final Location origin;
private final long duration;
private long age;
private boolean fading;
private Effect(Location origin, long duration) {
this.origin = origin;
this.duration = duration;
}
}
}
@@ -232,10 +232,28 @@ final class BaseFlightControllerTest {
new PocketBasePolicy(PluginSettings.from(Map.of())) new PocketBasePolicy(PluginSettings.from(Map.of()))
); );
when(player.getGameMode()).thenReturn(org.bukkit.GameMode.CREATIVE);
pocketController.grantPocketPortalFlight(player, ownerId);
verify(player, never()).setFlying(true);
verify(player, never()).sendMessage(anyString());
when(player.getGameMode()).thenReturn(org.bukkit.GameMode.SURVIVAL);
when(location.getBlockX()).thenReturn(10000);
pocketController.grantPocketPortalFlight(player, ownerId);
verify(player, never()).setFlying(true);
verify(player, never()).sendMessage(anyString());
when(location.getBlockX()).thenReturn(0);
pocketController.grantPocketPortalFlight(player, ownerId); pocketController.grantPocketPortalFlight(player, ownerId);
pocketController.run();
verify(player).setAllowFlight(true); verify(player).setAllowFlight(true);
verify(player).setFlying(true);
verify(player).sendMessage(org.bukkit.ChatColor.GREEN + "Pocket dimension flight enabled!");
when(player.getAllowFlight()).thenReturn(true);
pocketController.grantPocketPortalFlight(player, ownerId);
verify(player, times(2)).setFlying(true);
verify(player, times(2)).sendMessage(org.bukkit.ChatColor.GREEN + "Pocket dimension flight enabled!");
} }
@Test @Test
@@ -19,6 +19,82 @@ import org.bukkit.plugin.Plugin;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
final class BaseTeleportManagerTest { final class BaseTeleportManagerTest {
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.CsvSource({
"false,true,false", "true,true,false", "false,false,false", "true,false,false",
"false,false,true", "true,false,true"
})
void particlesFollowHomeAndVisitorTeleportOutcome(boolean visit, boolean succeeds, boolean cancelled) {
UUID id = UUID.randomUUID();
UUID worldId = UUID.randomUUID();
World world = mock(World.class);
when(world.getUID()).thenReturn(worldId);
org.bukkit.Location origin = new org.bukkit.Location(world, 0, 64, 0);
org.bukkit.Location target = new org.bukkit.Location(world, 20, 64, 20);
Player player = mock(Player.class);
when(player.getUniqueId()).thenReturn(id);
when(player.getName()).thenReturn("Player");
when(player.getWorld()).thenReturn(world);
when(player.getLocation()).thenReturn(origin);
when(player.isOnline()).thenReturn(true);
when(player.teleport(eq(target), any(org.bukkit.event.player.PlayerTeleportEvent.TeleportCause.class)))
.thenReturn(succeeds);
PlayerState owner = PlayerState.newPlayer(visit ? UUID.randomUUID() : id, "Owner")
.withGrassAndDirtProgress(500, 4)
.withBase(new BaseLocation(worldId, "world", 20, 64, 20, 0, 0), Instant.EPOCH)
.withVisitorsEnabled(true);
BaseStateManager states = mock(BaseStateManager.class);
when(states.player(id, "Player")).thenReturn(owner);
TeleportPolicy policy = mock(TeleportPolicy.class);
when(policy.warmup(owner)).thenReturn(Duration.ofSeconds(1));
when(policy.visitorWarmup(owner)).thenReturn(Duration.ofSeconds(1));
when(policy.cooldown(owner)).thenReturn(Duration.ZERO);
VisitorPolicy visitors = mock(VisitorPolicy.class);
SafeBaseDestination finder = mock(SafeBaseDestination.class);
when(finder.find(world, owner.base().orElseThrow())).thenReturn(Optional.of(target));
TeleportParticles particles = mock(TeleportParticles.class);
Plugin plugin = mock(Plugin.class);
org.bukkit.scheduler.BukkitScheduler scheduler = mock(org.bukkit.scheduler.BukkitScheduler.class);
when(scheduler.runTaskTimer(eq(plugin), any(Runnable.class), eq(0L), eq(20L)))
.thenReturn(mock(org.bukkit.scheduler.BukkitTask.class));
try (org.mockito.MockedStatic<org.bukkit.Bukkit> bukkit = org.mockito.Mockito.mockStatic(org.bukkit.Bukkit.class)) {
bukkit.when(org.bukkit.Bukkit::getScheduler).thenReturn(scheduler);
bukkit.when(() -> org.bukkit.Bukkit.getWorld(worldId)).thenReturn(world);
BaseTeleportManager manager = new BaseTeleportManager(plugin, states, policy,
visitors, finder, Clock.systemUTC(), particles);
if (visit) manager.startVisit(player, owner); else manager.start(player);
verify(particles).start(id, origin, 1);
org.mockito.ArgumentCaptor<Runnable> tick = org.mockito.ArgumentCaptor.forClass(Runnable.class);
verify(scheduler).runTaskTimer(eq(plugin), tick.capture(), eq(0L), eq(20L));
tick.getValue().run();
org.mockito.Mockito.verify(player, org.mockito.Mockito.never()).teleport(eq(target),
any(org.bukkit.event.player.PlayerTeleportEvent.TeleportCause.class));
if (cancelled) {
org.bukkit.event.player.PlayerQuitEvent quit = mock(org.bukkit.event.player.PlayerQuitEvent.class);
when(quit.getPlayer()).thenReturn(player);
manager.onQuit(quit);
verify(particles).cancel(id);
org.mockito.Mockito.verify(particles, org.mockito.Mockito.never()).complete(id);
org.mockito.Mockito.verify(states, org.mockito.Mockito.never()).saveIfDirty();
manager.cancelAll();
verify(particles).clear();
return;
}
tick.getValue().run();
if (succeeds) {
verify(particles).complete(id);
org.mockito.Mockito.verify(particles, org.mockito.Mockito.never()).cancel(id);
verify(states).saveIfDirty();
} else {
verify(particles).cancel(id);
org.mockito.Mockito.verify(particles, org.mockito.Mockito.never()).complete(id);
org.mockito.Mockito.verify(states, org.mockito.Mockito.never()).saveIfDirty();
}
manager.cancelAll();
verify(particles).clear();
}
}
@Test @Test
void visitFromAnotherWorldIsRejectedBeforeWarmupAndCooldownChecks() { void visitFromAnotherWorldIsRejectedBeforeWarmupAndCooldownChecks() {
UUID visitorId = UUID.randomUUID(); UUID visitorId = UUID.randomUUID();
@@ -456,6 +456,15 @@ final class PocketBaseControllerTest {
controller.onMove(event); controller.onMove(event);
verify(flightController).grantPocketPortalFlight(guest, ownerId); verify(flightController).grantPocketPortalFlight(guest, ownerId);
when(event.getFrom()).thenReturn(destination);
controller.onMove(event);
verify(flightController).grantPocketPortalFlight(guest, ownerId);
when(event.getFrom()).thenReturn(new Location(pocketWorld, 5, 66, 8));
controller.onMove(event);
verify(flightController, org.mockito.Mockito.times(2))
.grantPocketPortalFlight(guest, ownerId);
} }
@Test @Test
@@ -20,7 +20,11 @@ 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.Player; import org.bukkit.entity.Player;
import org.bukkit.event.Event;
import org.bukkit.event.EventHandler;
import org.bukkit.event.block.Action; import org.bukkit.event.block.Action;
import org.bukkit.plugin.Plugin;
import org.bukkit.plugin.RegisteredListener;
import org.bukkit.event.inventory.PrepareItemCraftEvent; import org.bukkit.event.inventory.PrepareItemCraftEvent;
import org.bukkit.event.player.PlayerInteractEvent; import org.bukkit.event.player.PlayerInteractEvent;
import org.bukkit.event.player.PlayerTeleportEvent; import org.bukkit.event.player.PlayerTeleportEvent;
@@ -32,8 +36,26 @@ import org.bukkit.inventory.meta.ItemMeta;
import org.bukkit.persistence.PersistentDataContainer; import org.bukkit.persistence.PersistentDataContainer;
import org.bukkit.persistence.PersistentDataType; import org.bukkit.persistence.PersistentDataType;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.EnumSource;
final class PocketBaseKeystoneServiceTest { final class PocketBaseKeystoneServiceTest {
@Test
void createsKeystoneWithGenericRecoveryCompassMetadata() {
NamespacedKey key = NamespacedKey.minecraft("pocket_base_keystone");
ItemStack item = mock(ItemStack.class);
ItemMeta genericMetadata = mock(ItemMeta.class);
PersistentDataContainer data = mock(PersistentDataContainer.class);
when(item.getItemMeta()).thenReturn(genericMetadata);
when(genericMetadata.getPersistentDataContainer()).thenReturn(data);
PocketBaseKeystoneService.decorateKeystone(item, key);
verify(data).set(key, PersistentDataType.BYTE, (byte) 1);
verify(item).setItemMeta(genericMetadata);
}
@Test @Test
void recipeUsesNetheriteOnCardinalsAndObsidianInCorners() { void recipeUsesNetheriteOnCardinalsAndObsidianInCorners() {
PocketBaseKeystoneService.RecipeSpec recipe = PocketBaseKeystoneService.RecipeSpec recipe =
@@ -106,8 +128,9 @@ final class PocketBaseKeystoneServiceTest {
); );
} }
@Test @ParameterizedTest
void unlockedPlayerEntersOwnPocketAndRecordsExactReturnDestination() throws Exception { @EnumSource(value = Action.class, names = {"RIGHT_CLICK_AIR", "RIGHT_CLICK_BLOCK"})
void unlockedPlayerEntersOwnPocketAndRecordsExactReturnDestination(Action action) throws Exception {
NamespacedKey key = NamespacedKey.minecraft("pocket_base_keystone"); NamespacedKey key = NamespacedKey.minecraft("pocket_base_keystone");
UUID playerId = UUID.randomUUID(); UUID playerId = UUID.randomUUID();
UUID worldId = UUID.randomUUID(); UUID worldId = UUID.randomUUID();
@@ -139,7 +162,10 @@ final class PocketBaseKeystoneServiceTest {
when(player.teleport(arrival, PlayerTeleportEvent.TeleportCause.PLUGIN)) when(player.teleport(arrival, PlayerTeleportEvent.TeleportCause.PLUGIN))
.thenReturn(true); .thenReturn(true);
service.onUse(event); when(event.getAction()).thenReturn(action);
when(event.isCancelled()).thenReturn(action == Action.RIGHT_CLICK_AIR);
when(event.useItemInHand()).thenReturn(Event.Result.DEFAULT);
dispatchUse(service, event);
verify(returns).record(playerId, new KeystoneReturnLocation( verify(returns).record(playerId, new KeystoneReturnLocation(
worldId, "world", 12.25, 70.5, -4.75, 123.0F, -15.5F worldId, "world", 12.25, 70.5, -4.75, 123.0F, -15.5F
@@ -149,8 +175,9 @@ final class PocketBaseKeystoneServiceTest {
verify(event).setCancelled(true); verify(event).setCancelled(true);
} }
@Test @ParameterizedTest
void playerReturnsFromOwnPocketToRecordedSafeDestination() { @EnumSource(value = Action.class, names = {"RIGHT_CLICK_AIR", "RIGHT_CLICK_BLOCK"})
void playerReturnsFromOwnPocketToRecordedSafeDestination(Action action) throws Exception {
NamespacedKey key = NamespacedKey.minecraft("pocket_base_keystone"); NamespacedKey key = NamespacedKey.minecraft("pocket_base_keystone");
UUID playerId = UUID.randomUUID(); UUID playerId = UUID.randomUUID();
UUID pocketWorldId = UUID.randomUUID(); UUID pocketWorldId = UUID.randomUUID();
@@ -201,7 +228,10 @@ final class PocketBaseKeystoneServiceTest {
PlayerTeleportEvent.TeleportCause.PLUGIN PlayerTeleportEvent.TeleportCause.PLUGIN
)).thenReturn(true); )).thenReturn(true);
service.onUse(event); when(event.getAction()).thenReturn(action);
when(event.isCancelled()).thenReturn(action == Action.RIGHT_CLICK_AIR);
when(event.useItemInHand()).thenReturn(Event.Result.DEFAULT);
dispatchUse(service, event);
verify(player).teleport( verify(player).teleport(
new Location(returnWorld, 12.25, 70.5, -4.75, 123.0F, -15.5F), new Location(returnWorld, 12.25, 70.5, -4.75, 123.0F, -15.5F),
@@ -308,6 +338,51 @@ final class PocketBaseKeystoneServiceTest {
assertFalse(PocketBaseKeystoneService.isKeystone(renamedOnly, key)); assertFalse(PocketBaseKeystoneService.isKeystone(renamedOnly, key));
} }
@ParameterizedTest
@EnumSource(value = Action.class, names = {"RIGHT_CLICK_AIR", "RIGHT_CLICK_BLOCK"})
void explicitItemUseDenialPreventsKeystoneTravel(Action action) throws Exception {
NamespacedKey key = NamespacedKey.minecraft("pocket_base_keystone");
Player player = mock(Player.class);
UUID playerId = UUID.randomUUID();
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
KeystoneReturnStore returns = mock(KeystoneReturnStore.class);
PocketBaseKeystoneService service = new PocketBaseKeystoneService(
mock(Server.class), pocketBases, returns, key, Logger.getAnonymousLogger()
);
PlayerInteractEvent event = mock(PlayerInteractEvent.class);
ItemStack keystone = markedKeystone(key);
when(event.getAction()).thenReturn(action);
when(event.getItem()).thenReturn(keystone);
when(event.getPlayer()).thenReturn(player);
when(event.isCancelled()).thenReturn(true);
when(event.useItemInHand()).thenReturn(Event.Result.DENY);
when(player.getUniqueId()).thenReturn(playerId);
when(pocketBases.state(playerId)).thenReturn(
new PocketBaseState(playerId, 0, Optional.empty())
);
dispatchUse(service, event);
verify(pocketBases, never()).state(any(UUID.class));
verify(returns, never()).record(any(UUID.class), any(KeystoneReturnLocation.class));
verify(player, never()).teleport(
any(Location.class), any(PlayerTeleportEvent.TeleportCause.class)
);
verify(event, never()).setCancelled(true);
}
private static void dispatchUse(
PocketBaseKeystoneService service, PlayerInteractEvent event
) throws Exception {
EventHandler handler = PocketBaseKeystoneService.class
.getMethod("onUse", PlayerInteractEvent.class).getAnnotation(EventHandler.class);
RegisteredListener listener = new RegisteredListener(
service, (ignored, dispatched) -> service.onUse((PlayerInteractEvent) dispatched),
handler.priority(), mock(Plugin.class), handler.ignoreCancelled()
);
listener.callEvent(event);
}
private static ItemStack markedKeystone(NamespacedKey key) { private static ItemStack markedKeystone(NamespacedKey key) {
ItemStack item = mock(ItemStack.class); ItemStack item = mock(ItemStack.class);
ItemMeta metadata = mock(ItemMeta.class); ItemMeta metadata = mock(ItemMeta.class);
@@ -0,0 +1,77 @@
package games.dmg.spigotbase;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import java.util.UUID;
import org.bukkit.Location;
import org.bukkit.Particle;
import org.bukkit.Server;
import org.bukkit.World;
import org.bukkit.plugin.Plugin;
import org.bukkit.scheduler.BukkitScheduler;
import org.bukkit.scheduler.BukkitTask;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
final class TeleportParticlesTest {
@Test
void warmupIntensifiesThenFadesAtOriginalLocationAndStops() {
Plugin plugin = mock(Plugin.class);
Server server = mock(Server.class);
BukkitScheduler scheduler = mock(BukkitScheduler.class);
BukkitTask task = mock(BukkitTask.class);
World world = mock(World.class);
when(plugin.getServer()).thenReturn(server);
when(server.getScheduler()).thenReturn(scheduler);
when(scheduler.runTaskTimer(eq(plugin), any(Runnable.class), eq(0L), eq(2L)))
.thenReturn(task);
TeleportParticles effects = new TeleportParticles(plugin);
UUID id = UUID.randomUUID();
Location origin = new Location(world, 10, 64, 20);
effects.start(id, origin, 2);
origin.setX(999);
ArgumentCaptor<Runnable> runnable = ArgumentCaptor.forClass(Runnable.class);
verify(scheduler).runTaskTimer(eq(plugin), runnable.capture(), eq(0L), eq(2L));
Runnable tick = runnable.getValue();
for (int i = 0; i < 21; i++) tick.run();
ArgumentCaptor<Integer> counts = ArgumentCaptor.forClass(Integer.class);
verify(world, times(21)).spawnParticle(eq(Particle.END_ROD), any(Location.class),
counts.capture().intValue(), anyDouble(), anyDouble(), anyDouble(), anyDouble());
assertTrue(counts.getAllValues().getLast() > counts.getAllValues().getFirst());
clearInvocations(world);
effects.complete(id);
for (int i = 0; i < 21; i++) tick.run();
ArgumentCaptor<Location> locations = ArgumentCaptor.forClass(Location.class);
counts = ArgumentCaptor.forClass(Integer.class);
verify(world, times(20)).spawnParticle(eq(Particle.END_ROD), locations.capture(),
counts.capture().intValue(), anyDouble(), anyDouble(), anyDouble(), anyDouble());
assertTrue(counts.getAllValues().getFirst() > counts.getAllValues().getLast());
assertTrue(locations.getAllValues().stream().allMatch(p -> Math.abs(p.getX() - 10) < 2));
verify(task).cancel();
}
@Test
void cancellationAndShutdownStopWithoutAfterglow() {
Plugin plugin = mock(Plugin.class);
Server server = mock(Server.class);
BukkitScheduler scheduler = mock(BukkitScheduler.class);
BukkitTask task = mock(BukkitTask.class);
World world = mock(World.class);
when(plugin.getServer()).thenReturn(server);
when(server.getScheduler()).thenReturn(scheduler);
when(scheduler.runTaskTimer(eq(plugin), any(Runnable.class), eq(0L), eq(2L)))
.thenReturn(task);
TeleportParticles effects = new TeleportParticles(plugin);
UUID id = UUID.randomUUID();
effects.start(id, new Location(world, 0, 64, 0), 30);
effects.cancel(id);
effects.complete(id);
verify(task).cancel();
effects.start(id, new Location(world, 0, 64, 0), 30);
effects.clear();
verify(task, times(2)).cancel();
verifyNoInteractions(world);
}
}