Compare commits

...
3 Commits
Author SHA1 Message Date
dmg 76f94f6c15 feat(base): add charged Pocket Base sharing compasses
Release / release (push) Successful in 4m43s
CI / build (push) Successful in 3m35s
2026-09-17 23:31:16 -04:00
dmg 682bd9850b feat(base): adopt Purpur distribution naming
CI / build (push) Successful in 2m46s
Release / release (push) Successful in 7m39s
BREAKING CHANGE: New release assets use purpur-base-<version>.jar. Runtime SpigotBase identity, data folders, permissions and persistent item keys remain unchanged. Replace the old JAR rather than loading both distributions.
2026-09-12 12:32:03 -04:00
dmg 0768a63038 feat(base): identify nearby bases with private owner labels
Release / release (push) Successful in 7m34s
CI / build (push) Successful in 2m47s
2026-09-12 11:42:37 -04:00
24 changed files with 2294 additions and 12 deletions

No files matched your search

+1 -1
View File
@@ -35,6 +35,6 @@ jobs:
- name: Upload development artifact
uses: actions/upload-artifact@v3
with:
name: spigot-base-${{ github.sha }}
name: purpur-base-${{ github.sha }}
path: build/libs/*.jar
if-no-files-found: error
+4 -4
View File
@@ -86,8 +86,8 @@ jobs:
if: steps.release.outputs.created == 'true'
uses: actions/upload-artifact@v3
with:
name: spigot-base-${{ steps.release.outputs.version }}
path: build/libs/spigot-base-${{ steps.release.outputs.version }}.jar
name: purpur-base-${{ steps.release.outputs.version }}
path: build/libs/purpur-base-${{ steps.release.outputs.version }}.jar
if-no-files-found: error
- name: Create Gitea release and upload plugin
@@ -101,7 +101,7 @@ jobs:
PREVIOUS_TAG: ${{ steps.previous_tag.outputs.value }}
run: |
api_url="${GITEA_SERVER_URL}/api/v1"
jar="build/libs/spigot-base-${VERSION}.jar"
jar="build/libs/purpur-base-${VERSION}.jar"
export RELEASE_BODY
if [ -n "$PREVIOUS_TAG" ]; then
RELEASE_BODY=$(git log --pretty='format:- %s (%h)' "${PREVIOUS_TAG}..HEAD")
@@ -138,4 +138,4 @@ jobs:
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/octet-stream" \
--data-binary "@${jar}" \
"${api_url}/repos/${REPOSITORY}/releases/${release_id}/assets?name=spigot-base-${VERSION}.jar"
"${api_url}/repos/${REPOSITORY}/releases/${release_id}/assets?name=purpur-base-${VERSION}.jar"
+3 -3
View File
@@ -1,9 +1,9 @@
# spigot-base agent entrypoint
# purpur-base agent entrypoint
The canonical stories, engineering guidance, and **all process documents** are in the private [SoMC OKF wiki](https://git.garvis.dev/dmg/somc-okf/src/branch/main/index.md).
Before work, read the sibling `../somc-okf/index.md`, `../somc-okf/processes/index.md`, `../somc-okf/projects/spigot-base/index.md`, `engineering.md` in that project section, and relevant `../somc-okf/user-stories/spigot-base/` stories. Also follow the parent workspace `AGENTS.md` when present.
Before work, read the sibling `../somc-okf/index.md`, `../somc-okf/processes/index.md`, `../somc-okf/projects/purpur-base/index.md`, `engineering.md` in that project section, and relevant `../somc-okf/user-stories/purpur-base/` stories. Also follow the parent workspace `AGENTS.md` when present.
For standalone checkouts, start at the [project page](https://git.garvis.dev/dmg/somc-okf/src/branch/main/projects/spigot-base/index.md) and [shared process](https://git.garvis.dev/dmg/somc-okf/src/branch/main/processes/development.md). Obtain wiki access before feature work; do not recreate a local knowledge bundle. Source builds do not require private wiki access.
For standalone checkouts, start at the [project page](https://git.garvis.dev/dmg/somc-okf/src/branch/main/projects/purpur-base/index.md) and [shared process](https://git.garvis.dev/dmg/somc-okf/src/branch/main/processes/development.md). Obtain wiki access before feature work; do not recreate a local knowledge bundle. Source builds do not require private wiki access.
Development follows [Development cycle](https://git.garvis.dev/dmg/somc-okf/src/branch/main/runbooks/development-cycle.md): approved stories, failing tests, passing implementation, verification, then source/wiki commit and push. GitOps updates are committed locally **without pushing**; only [Do release](https://git.garvis.dev/dmg/somc-okf/src/branch/main/runbooks/do-release.md) authorizes a reviewed GitOps push.
+9 -3
View File
@@ -1,10 +1,12 @@
# Spigot Base
# Purpur Base
A Purpur 26.2 plugin providing progression-gated player bases and quality-of-life unlocks.
Players begin by breaking Survival-mode grass blocks or dirt. The progression paths provide a persistent base, navigation particles, expanding base bounds, controlled flight, personal teleportation, and visitor teleportation.
The approved behavior is specified in the [SoMC OKF wiki](https://git.garvis.dev/dmg/somc-okf/src/branch/main/projects/spigot-base/index.md).
The approved behavior is specified in the [SoMC OKF wiki](https://git.garvis.dev/dmg/somc-okf/src/branch/main/projects/purpur-base/index.md).
The repository and new artifacts are named `purpur-base`. Runtime identity remains `SpigotBase`: existing `plugins/SpigotBase/` data, commands, `spigotbase.*` permissions, item keys and Java entrypoints remain compatible. Replace the old JAR when installing a new release; never load both distributions together. Historical `spigot-base-*` release assets are retained.
## Requirements
@@ -17,7 +19,7 @@ The approved behavior is specified in the [SoMC OKF wiki](https://git.garvis.dev
./gradlew clean check jar
```
The plugin JAR is written to `build/libs/`.
The plugin JAR is written to `build/libs/`. `check` includes native label packet tests against the SHA-256-verified Purpur 2618 runtime. The build downloads/patches that runtime under `build/label-runtime/`, loads its registries, and exercises codecs without starting a listening Minecraft server.
## Player commands
@@ -45,6 +47,10 @@ The plugin JAR is written to `build/libs/`.
`/setbase` and `/sethome` require the proposed normal base center (the player's block coordinates) to be at least 100 blocks horizontally from the current world's spawn by default. Height and base radius do not affect this check; exactly the minimum is allowed. Rejection reports the remaining distance rounded up and leaves the existing base and relocation cooldown unchanged. This is a placement-only rule: existing bases are never rechecked when players move or spawn changes, and Pocket Base restrictions are unchanged.
Visitors see private floating `Base: <owner>` labels above recorded normal-base centers, inside the actual base bounds and up to 16 blocks beyond the horizontal boundary (within the same world and vertical limits). Labels do not follow the visitor. On entry, the visitor briefly sees `Entering <owner>'s base` in their action bar. Standing inside or jittering across an edge does not repeat the message; leaving the vicinity rearms it. Coincident anchors use stable vertical spacing. Ownership comes from saved player names, with a UUID fallback, so offline owners and blocked visitor teleports do not hide nearby bases or grant teleport access.
Labels use client-only text displays with server-reserved IDs; they are never added to the world and do not load anchor chunks. Movement updates visibility, while a five-tick read-only refresh catches relocation, resets and boundary changes. Leaving range, disconnecting, changing worlds, respawning and disabling the plugin clean up label/session state. This is separate from owner-only borders and excludes Pocket Bases. An unsupported native packet layout is logged explicitly without disabling unrelated base features.
Navigation particles appear only in the base's world and when the player is more than 25 blocks beyond the current base border.
After 250 Survival-mode block placements anywhere by default, the spawnable overlay can mark nearby dark hostile-mob spawning surfaces inside the player's base with owner-only red particles. The threshold is configurable.
+59
View File
@@ -1,3 +1,6 @@
import java.net.URI
import java.security.MessageDigest
plugins {
java
}
@@ -35,8 +38,64 @@ dependencies {
tasks.test {
useJUnitPlatform()
dependsOn(tasks.jar)
systemProperty("distribution.jar", tasks.jar.get().archiveFile.get().asFile.absolutePath)
systemProperty("distribution.version", project.version.toString())
}
// Verify client-only label packets against the declared runtime, without starting a server.
val labelRuntime = layout.buildDirectory.dir("label-runtime")
val downloadLabelRuntime = tasks.register("downloadLabelRuntime") {
val launcher = labelRuntime.map { it.file("purpur-26.2-2618.jar") }
outputs.file(launcher)
doLast {
val file = launcher.get().asFile
file.parentFile.mkdirs()
val connection = URI("https://api.purpurmc.org/v2/purpur/26.2/2618/download").toURL().openConnection()
connection.connectTimeout = 30_000
connection.readTimeout = 120_000
val bytes = connection.getInputStream().use { it.readBytes() }
val digest = MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") { "%02x".format(it) }
check(digest == "4a32d046a118804d89ca74ba89b798c98f6d8d1f310c18077ac573597049de31") {
"Purpur 2618 checksum mismatch"
}
file.writeBytes(bytes)
}
}
val prepareLabelRuntime = tasks.register<JavaExec>("prepareLabelRuntime") {
dependsOn(downloadLabelRuntime)
javaLauncher = javaToolchains.launcherFor { languageVersion = JavaLanguageVersion.of(25) }
classpath = files(labelRuntime.map { it.file("purpur-26.2-2618.jar") })
mainClass = "io.papermc.paperclip.Main"
jvmArgs("-Dpaperclip.patchonly=true")
workingDir(labelRuntime)
outputs.dir(labelRuntime.map { it.dir("versions") })
outputs.dir(labelRuntime.map { it.dir("libraries") })
}
val nativeTest = sourceSets.create("nativeTest")
dependencies {
add(nativeTest.implementationConfigurationName, platform("org.junit:junit-bom:5.13.4"))
add(nativeTest.implementationConfigurationName, "org.junit.jupiter:junit-jupiter")
add(nativeTest.implementationConfigurationName, "org.mockito:mockito-core:5.18.0")
add(nativeTest.compileOnlyConfigurationName, "org.jetbrains:annotations:26.0.2")
add(nativeTest.compileOnlyConfigurationName, "org.checkerframework:checker-qual:3.49.2")
add(nativeTest.runtimeOnlyConfigurationName, "org.junit.platform:junit-platform-launcher")
}
val nativeRuntimeJars = files(fileTree(labelRuntime) {
include("versions/**/*.jar", "libraries/**/*.jar")
}).builtBy(prepareLabelRuntime)
nativeTest.compileClasspath += sourceSets.main.get().output + nativeRuntimeJars
nativeTest.runtimeClasspath += sourceSets.main.get().output + nativeRuntimeJars
val nativeLabelTest = tasks.register<Test>("nativeLabelTest") {
description = "Tests native label packet encoding without a listening server"
testClassesDirs = nativeTest.output.classesDirs
classpath = nativeTest.runtimeClasspath
useJUnitPlatform()
maxHeapSize = "1G"
workingDir(labelRuntime)
}
tasks.check { dependsOn(nativeLabelTest) }
val pluginVersion = version
tasks.processResources {
+1 -1
View File
@@ -2,4 +2,4 @@ plugins {
id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
}
rootProject.name = "spigot-base"
rootProject.name = "purpur-base"
@@ -0,0 +1,90 @@
package games.dmg.spigotbase;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Comparator;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/** Server-thread model: actual base bounds, stable anchors and per-visit notifications; no Bukkit or I/O. */
public final class BaseIdentification {
public record Base(UUID owner, String ownerName, BaseArea area) { }
public record Viewer(UUID id, UUID world, int x, int y, int z) { }
public record Label(UUID owner, String text, UUID world, double x, double y, double z, float viewRange) { }
public record Frame(Map<UUID, Label> labels, List<String> entries) { }
private record Origin(UUID world, int x, int y, int z) {
static Origin of(BaseLocation center) {
return new Origin(center.worldId(), center.x(), center.y(), center.z());
}
}
private record Prepared(Base base, Origin origin, String ownerName, Label label) { }
private record Visit(Origin origin, boolean announced) { }
private List<Base> bases = List.of();
private Map<UUID, List<Prepared>> byWorld = Map.of();
private final Map<UUID, Map<UUID, Visit>> visits = new HashMap<>();
public void setBases(Collection<Base> next) {
List<Base> sorted = next.stream().sorted(Comparator.comparing(base -> base.owner().toString())).toList();
if (sorted.equals(bases)) {
return;
}
Map<Origin, Integer> ranks = new HashMap<>();
Map<UUID, List<Prepared>> prepared = new HashMap<>();
for (Base base : sorted) {
BaseArea area = base.area();
BaseLocation center = area.center();
Origin origin = Origin.of(center);
int rank = ranks.merge(origin, 1, Integer::sum) - 1;
double offset = 2.25 + rank * 0.35;
String name = base.ownerName() == null || base.ownerName().isBlank() ? base.owner().toString() : base.ownerName();
float viewRange = (float) Math.max(16, Math.hypot((double) area.radius() + 16,
(double) area.verticalRange() + offset + 1) / 64);
Label label = new Label(base.owner(), "Base: " + name, center.worldId(),
center.x() + 0.5, center.y() + offset, center.z() + 0.5, viewRange);
prepared.computeIfAbsent(center.worldId(), ignored -> new ArrayList<>())
.add(new Prepared(base, origin, name, label));
}
prepared.replaceAll((world, values) -> List.copyOf(values));
bases = sorted;
byWorld = Map.copyOf(prepared);
}
public void forget(UUID viewer) { visits.remove(viewer); }
public void clear() { visits.clear(); bases = List.of(); byWorld = Map.of(); }
public Frame update(Viewer viewer) {
Map<UUID, Visit> previous = visits.getOrDefault(viewer.id(), Map.of());
Map<UUID, Visit> retained = new HashMap<>();
Map<UUID, Label> visible = new LinkedHashMap<>();
List<String> entries = new ArrayList<>();
for (Prepared prepared : byWorld.getOrDefault(viewer.world(), List.of())) {
Base base = prepared.base();
BaseArea area = base.area();
BaseLocation center = area.center();
if (base.owner().equals(viewer.id())) {
continue;
}
double distance = Math.hypot((double) viewer.x() - center.x(), (double) viewer.z() - center.z());
long vertical = Math.abs((long) viewer.y() - center.y());
if (distance > (double) area.radius() + 16 || vertical > (long) area.verticalRange() + 16) {
continue;
}
Visit old = previous.get(base.owner());
boolean announced = old != null && old.origin().equals(prepared.origin()) && old.announced();
if (!announced && area.contains(viewer.world(), viewer.x(), viewer.y(), viewer.z())) {
entries.add("Entering " + prepared.ownerName() + "'s base");
announced = true;
}
retained.put(base.owner(), new Visit(prepared.origin(), announced));
if (vertical <= area.verticalRange()) {
visible.put(base.owner(), prepared.label());
}
}
if (retained.isEmpty()) { visits.remove(viewer.id()); }
else { visits.put(viewer.id(), retained); }
return new Frame(Map.copyOf(visible), List.copyOf(entries));
}
}
@@ -0,0 +1,121 @@
package games.dmg.spigotbase;
import java.util.HashSet;
import java.util.Set;
import java.util.UUID;
import java.util.logging.Level;
import java.util.logging.Logger;
import net.kyori.adventure.text.Component;
import org.bukkit.Location;
import org.bukkit.Server;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerChangedWorldEvent;
import org.bukkit.event.player.PlayerMoveEvent;
import org.bukkit.event.player.PlayerQuitEvent;
import org.bukkit.event.player.PlayerRespawnEvent;
/** Read-only state projection. Polling catches administration/settings changes; moves catch entries between polls. */
final class BaseIdentificationController implements Runnable, Listener, AutoCloseable {
private final Server server;
private final BaseStateManager states;
private final BaseBoundsService bounds;
private final BukkitBaseLabels labels;
private final Logger logger;
private final BaseIdentification identification = new BaseIdentification();
private final Set<UUID> tracked = new HashSet<>();
private final Set<UUID> failed = new HashSet<>();
private boolean closed;
BaseIdentificationController(Server server, BaseStateManager states, BaseBoundsService bounds,
BukkitBaseLabels labels, Logger logger) {
this.server = server;
this.states = states;
this.bounds = bounds;
this.labels = labels;
this.logger = logger;
}
@Override
public void run() {
if (closed) { return; }
identification.setBases(states.knownPlayers().values().stream().filter(state -> state.base().isPresent())
.map(state -> new BaseIdentification.Base(state.playerId(), state.latestName(), bounds.area(state))).toList());
Set<UUID> online = new HashSet<>();
for (Player player : server.getOnlinePlayers()) {
online.add(player.getUniqueId());
update(player, player.getLocation());
}
for (UUID id : Set.copyOf(tracked)) {
if (!online.contains(id)) { forget(id); }
}
}
private void update(Player player, Location location) {
if (closed || !player.isOnline() || location == null || location.getWorld() == null
|| !location.getWorld().equals(player.getWorld())) { return; }
UUID id = player.getUniqueId();
tracked.add(id);
var frame = identification.update(new BaseIdentification.Viewer(id, location.getWorld().getUID(),
location.getBlockX(), location.getBlockY(), location.getBlockZ()));
if (!frame.entries().isEmpty()) {
player.sendActionBar(Component.text(String.join(" • ", frame.entries())));
}
try {
labels.render(player, frame.labels());
failed.remove(id);
} catch (RuntimeException exception) {
// Retained partial packet delivery is retried on the next update; avoid repeated log spam.
if (failed.add(id)) { logger.log(Level.WARNING, "Could not update private base labels for " + id, exception); }
}
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onMove(PlayerMoveEvent event) {
if (event.isCancelled() || event.getTo() == null) { return; }
Location from = event.getFrom(), to = event.getTo();
if (from.getWorld().equals(to.getWorld()) && (from.getBlockX() != to.getBlockX()
|| from.getBlockY() != to.getBlockY() || from.getBlockZ() != to.getBlockZ())) {
update(event.getPlayer(), to);
}
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onTeleport(org.bukkit.event.player.PlayerTeleportEvent event) { onMove(event); }
@EventHandler(priority = EventPriority.MONITOR)
public void onQuit(PlayerQuitEvent event) { forget(event.getPlayer().getUniqueId()); }
@EventHandler(priority = EventPriority.MONITOR)
public void onWorldChange(PlayerChangedWorldEvent event) { forget(event.getPlayer().getUniqueId()); }
@EventHandler(priority = EventPriority.MONITOR)
public void onRespawn(PlayerRespawnEvent event) {
try { labels.clear(event.getPlayer()); }
catch (RuntimeException exception) { logger.log(Level.WARNING, "Could not clear respawning player's base labels", exception); }
forget(event.getPlayer().getUniqueId());
}
private void forget(UUID id) {
labels.forget(id);
identification.forget(id);
tracked.remove(id);
failed.remove(id);
}
@Override
public void close() {
if (closed) { return; }
closed = true;
for (Player player : server.getOnlinePlayers()) {
try { labels.clear(player); }
catch (RuntimeException exception) { logger.log(Level.WARNING, "Could not remove private base labels on disable", exception); }
}
labels.forgetAll();
identification.clear();
tracked.clear();
failed.clear();
}
}
@@ -0,0 +1,63 @@
package games.dmg.spigotbase;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import org.bukkit.World;
import org.bukkit.entity.Player;
import org.bukkit.entity.TextDisplay;
/** Viewer-private, unspawned displays. All access is confined to the server thread. */
final class BukkitBaseLabels {
interface Packets {
int nextId(World world);
List<Object> spawn(TextDisplay display, BaseIdentification.Label label, int entityId);
Object remove(int entityId);
void send(Player viewer, Object packet);
}
private record Shown(BaseIdentification.Label label, int id, boolean complete) { }
private final Packets packets;
private final Map<UUID, Map<UUID, Shown>> viewers = new java.util.HashMap<>();
BukkitBaseLabels(Packets packets) { this.packets = packets; }
void render(Player viewer, Map<UUID, BaseIdentification.Label> labels) {
World world = viewer.getWorld();
UUID worldId = world.getUID();
Map<UUID, Shown> shown = viewers.computeIfAbsent(viewer.getUniqueId(), ignored -> new java.util.HashMap<>());
var iterator = shown.entrySet().iterator();
while (iterator.hasNext()) {
var entry = iterator.next();
Shown old = entry.getValue();
if (!old.label().world().equals(worldId)) {
// A dimension change already cleared the client world. Never destroy old IDs in a new world.
iterator.remove();
} else if (!old.complete() || !old.label().equals(labels.get(entry.getKey()))) {
packets.send(viewer, packets.remove(old.id()));
iterator.remove();
}
}
for (var label : labels.values()) {
if (!label.world().equals(worldId) || shown.containsKey(label.owner())) { continue; }
// createEntity does not spawn/register the entity or load its anchor chunk.
TextDisplay display = world.createEntity(new org.bukkit.Location(world, label.x(), label.y(), label.z()), TextDisplay.class);
display.setPersistent(false);
display.text(net.kyori.adventure.text.Component.text(label.text()));
display.setBillboard(org.bukkit.entity.Display.Billboard.CENTER);
display.setViewRange(label.viewRange());
display.setShadowed(true);
display.setLineWidth(512); // Keep UUID fallback names on one line at stacked anchors.
int id = packets.nextId(world);
List<Object> spawn = packets.spawn(display, label, id);
// Retain partial delivery so the next render/clear can remove it before retrying.
shown.put(label.owner(), new Shown(label, id, false));
for (Object packet : spawn) { packets.send(viewer, packet); }
shown.put(label.owner(), new Shown(label, id, true));
}
if (shown.isEmpty()) { viewers.remove(viewer.getUniqueId()); }
}
void clear(Player viewer) { render(viewer, Map.of()); }
void forget(UUID viewer) { viewers.remove(viewer); }
void forgetAll() { viewers.clear(); }
}
@@ -0,0 +1,88 @@
package games.dmg.spigotbase;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.List;
import java.util.UUID;
import org.bukkit.entity.Player;
import org.bukkit.entity.TextDisplay;
/** Declared-runtime packet boundary; no reflection targets or packet types come from player input. */
final class NativeBaseLabelPackets implements BukkitBaseLabels.Packets {
private final Constructor<?> addEntity;
private final Constructor<?> metadata;
private final Constructor<?> removeEntities;
private final Object zeroVelocity;
private final Method entityHandle;
private final Method entityType;
private final Method entityData;
private final Method nonDefaultValues;
private final Method playerHandle;
private final Field connection;
private final Method sendPacket;
NativeBaseLabelPackets() {
try {
Class<?> entity = Class.forName("net.minecraft.world.entity.Entity");
Class<?> type = Class.forName("net.minecraft.world.entity.EntityType");
Class<?> vector = Class.forName("net.minecraft.world.phys.Vec3");
Class<?> packet = Class.forName("net.minecraft.network.protocol.Packet");
entityHandle = Class.forName("org.bukkit.craftbukkit.entity.CraftEntity").getMethod("getHandle");
entityType = entity.getMethod("getType");
entityData = entity.getMethod("getEntityData");
nonDefaultValues = Class.forName("net.minecraft.network.syncher.SynchedEntityData").getMethod("getNonDefaultValues");
zeroVelocity = vector.getConstructor(double.class, double.class, double.class).newInstance(0.0, 0.0, 0.0);
addEntity = Class.forName("net.minecraft.network.protocol.game.ClientboundAddEntityPacket").getConstructor(
int.class, UUID.class, double.class, double.class, double.class, float.class, float.class,
type, int.class, vector, double.class);
metadata = Class.forName("net.minecraft.network.protocol.game.ClientboundSetEntityDataPacket")
.getConstructor(int.class, List.class);
removeEntities = Class.forName("net.minecraft.network.protocol.game.ClientboundRemoveEntitiesPacket")
.getConstructor(int[].class);
playerHandle = Class.forName("org.bukkit.craftbukkit.entity.CraftPlayer").getMethod("getHandle");
connection = Class.forName("net.minecraft.server.level.ServerPlayer").getField("connection");
sendPacket = Class.forName("net.minecraft.server.network.ServerCommonPacketListenerImpl").getMethod("send", packet);
} catch (ReflectiveOperationException exception) {
throw new IllegalStateException("Client-only base labels are unsupported on this server version", exception);
}
}
@Override
public int nextId(org.bukkit.World world) {
return org.bukkit.Bukkit.getUnsafe().nextEntityId(world);
}
@Override
public List<Object> spawn(TextDisplay entity, BaseIdentification.Label label, int entityId) {
try {
Object handle = entityHandle.invoke(entity);
Object values = nonDefaultValues.invoke(entityData.invoke(handle));
// Construct both packets before sending either, so an unsupported layout cannot leave a half-created label.
Object spawn = addEntity.newInstance(entityId, entity.getUniqueId(), label.x(), label.y(), label.z(),
0.0f, 0.0f, entityType.invoke(handle), 0, zeroVelocity, 0.0);
Object data = metadata.newInstance(entityId, values == null ? List.of() : values);
return List.of(spawn, data);
} catch (ReflectiveOperationException exception) {
throw new IllegalStateException("Could not encode a client-only base label", exception);
}
}
@Override
public Object remove(int entityId) {
try {
return removeEntities.newInstance((Object) new int[] {entityId});
} catch (ReflectiveOperationException exception) {
throw new IllegalStateException("Could not encode base label removal", exception);
}
}
@Override
public void send(Player viewer, Object packet) {
try {
sendPacket.invoke(connection.get(playerHandle.invoke(viewer)), packet);
} catch (ReflectiveOperationException exception) {
throw new IllegalStateException("Could not send a private base label packet", exception);
}
}
}
@@ -187,6 +187,10 @@ final class PocketBaseManager {
return worlds.ownerForWorld(worldId);
}
Optional<Location> availablePocketArrival(UUID ownerId) {
return state(ownerId).level() > 0 ? worlds.availableArrival(ownerId) : Optional.empty();
}
Location pocketArrival(UUID ownerId) {
return worlds.arrival(ownerId);
}
@@ -0,0 +1,298 @@
package games.dmg.spigotbase;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import java.util.function.IntSupplier;
import net.kyori.adventure.text.Component;
import org.bukkit.Keyed;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.NamespacedKey;
import org.bukkit.Server;
import org.bukkit.entity.Player;
import org.bukkit.event.Event;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.block.Action;
import org.bukkit.event.entity.PlayerDeathEvent;
import org.bukkit.event.inventory.ClickType;
import org.bukkit.event.inventory.CraftItemEvent;
import org.bukkit.event.inventory.InventoryType;
import org.bukkit.event.inventory.PrepareItemCraftEvent;
import org.bukkit.event.player.PlayerInteractEvent;
import org.bukkit.event.player.PlayerTeleportEvent;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.Recipe;
import org.bukkit.inventory.ShapedRecipe;
import org.bukkit.persistence.PersistentDataType;
/** Destination-bound guest access, separate from holder-bound Keystones. */
final class PocketBaseSharingCompass implements Listener {
private final Server server;
private final PocketBaseManager pockets;
private final NamespacedKey key;
private final NamespacedKey rechargeKey;
private final NamespacedKey ownerKey;
private final NamespacedKey chargesKey;
private final NamespacedKey identityKey;
private final NamespacedKey ownerNameKey;
private final IntSupplier roll;
private final Set<UUID> travelling = new HashSet<>();
PocketBaseSharingCompass(Server server, PocketBaseManager pockets, NamespacedKey key, IntSupplier roll) {
this.server = server;
this.pockets = pockets;
this.key = key;
this.rechargeKey = new NamespacedKey(key.getNamespace(), key.getKey() + "_recharge");
this.ownerKey = new NamespacedKey(key.getNamespace(), key.getKey() + "_owner");
this.chargesKey = new NamespacedKey(key.getNamespace(), key.getKey() + "_charges");
this.identityKey = new NamespacedKey(key.getNamespace(), key.getKey() + "_id");
this.ownerNameKey = new NamespacedKey(key.getNamespace(), key.getKey() + "_owner_name");
this.roll = roll;
}
void registerRecipes() {
var creation = new ShapedRecipe(key, new ItemStack(Material.COMPASS));
creation.shape("DED", "ECE", "DED");
creation.setIngredient('D', Material.DIAMOND_BLOCK);
creation.setIngredient('E', Material.ECHO_SHARD);
creation.setIngredient('C', Material.END_CRYSTAL);
server.addRecipe(creation);
var recharge = new ShapedRecipe(rechargeKey, new ItemStack(Material.COMPASS));
recharge.shape("PPP", "PCP", "PPP");
recharge.setIngredient('P', Material.ENDER_PEARL);
recharge.setIngredient('C', Material.COMPASS);
server.addRecipe(recharge);
}
@EventHandler(priority = EventPriority.HIGHEST)
public void onPrepare(PrepareItemCraftEvent event) {
if (!(event.getView().getPlayer() instanceof Player player)
|| !ourRecipe(event.getRecipe()) || empty(event.getInventory().getResult())) return;
// Only our candidate matrices/results are replaced; unrelated recipes remain untouched.
ItemStack[] matrix = event.getInventory().getMatrix();
if (rechargeCandidate(matrix)) {
event.getInventory().setResult(rechargeAllowed(matrix, player, event.getInventory().getLocation())
? withCharges(matrix[4], state(matrix[4]).charges() + 8) : null);
return;
}
if (matrix.length != 9 || !material(matrix[4], Material.END_CRYSTAL)) return;
event.getInventory().setResult(creationMatrix(matrix) && pockets.state(player.getUniqueId()).level() > 0
? create(player.getUniqueId(), player.getName()) : null);
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onCraft(CraftItemEvent event) {
if (!ourRecipe(event.getRecipe())) return;
event.setCancelled(true);
ItemStack[] matrix = event.getInventory().getMatrix();
boolean creation = creationMatrix(matrix);
boolean recharge = rechargeCandidate(matrix);
if (!creation && !recharge) {
event.getInventory().setResult(null);
return;
}
if (empty(event.getInventory().getResult()) || !(event.getWhoClicked() instanceof Player player)
|| event.getRawSlot() != 0 || event.getView().getType() != InventoryType.WORKBENCH) return;
if (creation ? pockets.state(player.getUniqueId()).level() < 1
: !rechargeAllowed(matrix, player, event.getInventory().getLocation())) return;
boolean shift = event.isShiftClick();
if (event.getClick() != ClickType.LEFT && event.getClick() != ClickType.RIGHT
&& event.getClick() != ClickType.SHIFT_LEFT && event.getClick() != ClickType.SHIFT_RIGHT) return;
int slot = shift ? player.getInventory().firstEmpty() : -1;
if (shift ? slot < 0 : !empty(event.getCursor())) return;
ItemStack output;
if (creation) output = create(player.getUniqueId(), player.getName());
else {
long proposed = state(matrix[4]).charges() + 8;
output = roll.getAsInt() < successChance(proposed) ? withCharges(matrix[4], proposed) : null;
}
for (int i = 0; i < matrix.length; i++) {
matrix[i].setAmount(matrix[i].getAmount() - 1);
if (matrix[i].getAmount() == 0) matrix[i] = null;
}
event.getInventory().setResult(null);
event.getInventory().setMatrix(matrix);
if (output == null) {
Location table = event.getInventory().getLocation();
// Native damage/block destruction and protection events, not a cosmetic effect.
// Consume first: an explosion may synchronously kill the crafter or close the menu.
Location center = new Location(table.getWorld(), table.getBlockX() + 0.5,
table.getBlockY() + 0.5, table.getBlockZ() + 0.5);
// The shorter overload excludes its source from damage; the crafter must remain eligible.
table.getWorld().createExplosion(player, center, 6.0F, false, true, false);
} else if (shift) player.getInventory().setItem(slot, output);
else event.getView().setCursor(output);
}
@EventHandler(priority = EventPriority.HIGHEST)
public void onUse(PlayerInteractEvent event) {
if ((event.getAction() != Action.RIGHT_CLICK_AIR && event.getAction() != Action.RIGHT_CLICK_BLOCK)
|| event.useItemInHand() == Event.Result.DENY || event.getHand() == null) return;
Player player = event.getPlayer();
ItemStack item = player.getInventory().getItem(event.getHand());
State state = state(item);
if (state == null || !item.equals(event.getItem())) return;
event.setCancelled(true);
if (state.charges() == 0 || pockets.state(state.owner()).level() < 1
|| pockets.ownerForPocketWorld(player.getWorld().getUID()).filter(state.owner()::equals).isPresent()) return;
var destination = pockets.availablePocketArrival(state.owner());
if (destination.isEmpty() || !travelling.add(player.getUniqueId())) return;
float fallDistance = player.getFallDistance();
boolean teleported = false;
try {
player.setFallDistance(0F);
teleported = player.teleport(destination.orElseThrow(), PlayerTeleportEvent.TeleportCause.PLUGIN);
if (teleported && !player.isDead() && player.getWorld().equals(destination.orElseThrow().getWorld())) {
spendCarriedCharge(player, state.identity());
}
} finally {
if (!teleported) player.setFallDistance(fallDistance);
travelling.remove(player.getUniqueId());
}
}
private void spendCarriedCharge(Player player, UUID identity) {
// Teleport listeners may move/remove the item, change the held slot, or trigger death.
// Never reinstall the pre-teleport snapshot over a replacement item or resurrect a drop.
ItemStack[] items = player.getInventory().getContents();
for (int slot = 0; slot < items.length; slot++) {
State current = state(items[slot]);
if (current != null && current.identity().equals(identity)) {
if (current.charges() > 0) player.getInventory().setItem(slot, withCharges(items[slot], current.charges() - 1));
return;
}
}
ItemStack cursor = player.getItemOnCursor();
State current = state(cursor);
if (current != null && current.identity().equals(identity) && current.charges() > 0) {
player.setItemOnCursor(withCharges(cursor, current.charges() - 1));
}
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onDeath(PlayerDeathEvent event) {
Player player = event.getEntity();
ItemStack[] carried = player.getInventory().getContents();
for (int i = 0; i < carried.length; i++) {
if (state(carried[i]) != null) player.getInventory().setItem(i, withCharges(carried[i], 0));
}
if (state(player.getItemOnCursor()) != null) player.setItemOnCursor(withCharges(player.getItemOnCursor(), 0));
discharge(event.getDrops());
discharge(event.getItemsToKeep());
}
private void discharge(List<ItemStack> items) {
for (int i = 0; i < items.size(); i++) {
if (state(items.get(i)) != null) items.set(i, withCharges(items.get(i), 0));
}
}
private boolean ourRecipe(Recipe recipe) {
return recipe instanceof Keyed keyed
&& (key.equals(keyed.getKey()) || rechargeKey.equals(keyed.getKey()));
}
private static int successChance(long proposed) {
return proposed >= 73 ? 10 : 100 - 10 * (int) ((proposed - 1) / 8);
}
private boolean rechargeCandidate(ItemStack[] matrix) {
if (matrix.length != 9 || !material(matrix[4], Material.COMPASS)) return false;
// Do not hijack ordinary map/recovery-compass recipes. Marked items still fail
// closed when their metadata or surrounding ingredients are malformed.
if (matrix[4].getItemMeta().getPersistentDataContainer().has(key)) return true;
for (int i = 0; i < 9; i++) if (i != 4 && !material(matrix[i], Material.ENDER_PEARL)) return false;
return true;
}
private boolean rechargeAllowed(ItemStack[] matrix, Player player, Location table) {
State state = matrix.length == 9 ? state(matrix[4]) : null;
if (state == null || state.charges() > Long.MAX_VALUE - 8 || table == null || table.getWorld() == null
|| !table.getWorld().equals(player.getWorld())
|| table.getBlock().getType() != Material.CRAFTING_TABLE
|| !pockets.ownerForPocketWorld(player.getWorld().getUID()).filter(state.owner()::equals).isPresent()
|| pockets.state(state.owner()).level() < 1) return false;
for (int i = 0; i < 9; i++) if (i != 4 && !material(matrix[i], Material.ENDER_PEARL)) return false;
return true;
}
private State state(ItemStack item) {
if (!material(item, Material.COMPASS) || item.getAmount() != 1) return null;
var meta = item.getItemMeta();
if (meta == null) return null;
var data = meta.getPersistentDataContainer();
try {
Byte marker = data.get(key, PersistentDataType.BYTE);
String owner = data.get(ownerKey, PersistentDataType.STRING);
Long charges = data.get(chargesKey, PersistentDataType.LONG);
String identity = data.get(identityKey, PersistentDataType.STRING);
if (marker == null || marker != 1 || owner == null || identity == null
|| charges == null || charges < 0) return null;
UUID ownerId = UUID.fromString(owner);
UUID itemId = UUID.fromString(identity);
if (!ownerId.toString().equals(owner) || !itemId.toString().equals(identity)) return null;
return new State(ownerId, charges, itemId);
} catch (IllegalArgumentException exception) {
return null;
}
}
private ItemStack withCharges(ItemStack item, long charges) {
var updated = item.clone();
var meta = updated.getItemMeta();
var data = meta.getPersistentDataContainer();
data.set(chargesKey, PersistentDataType.LONG, charges);
String owner = data.get(ownerKey, PersistentDataType.STRING);
String name = data.getOrDefault(ownerNameKey, PersistentDataType.STRING, owner);
String risk = charges > Long.MAX_VALUE - 8 ? "Next recharge: unavailable (numeric overflow)"
: "Next recharge: " + successChance(charges + 8) + "% success / "
+ (100 - successChance(charges + 8)) + "% explosion";
meta.lore(List.of(
Component.text("Destination: " + name + " (" + owner + ")"),
Component.text("Charges: " + charges),
Component.text("Right-click for entry only; no return trip."),
Component.text("Recharge: center of table + 8 ender pearls, inside destination."),
Component.text(risk)
));
updated.setItemMeta(meta);
return updated;
}
private record State(UUID owner, long charges, UUID identity) {}
private static boolean empty(ItemStack item) {
return item == null || item.getType().isAir() || item.getAmount() == 0;
}
private static boolean material(ItemStack item, Material material) {
return item != null && item.getAmount() > 0 && item.getType() == material;
}
private static boolean creationMatrix(ItemStack[] matrix) {
if (matrix.length != 9) return false;
for (int i = 0; i < 9; i++) {
if (!material(matrix[i], i == 4 ? Material.END_CRYSTAL
: i % 2 == 0 ? Material.DIAMOND_BLOCK : Material.ECHO_SHARD)) return false;
}
return true;
}
private ItemStack create(UUID owner, String name) {
ItemStack item = new ItemStack(Material.COMPASS);
var meta = item.getItemMeta();
var data = meta.getPersistentDataContainer();
data.set(key, PersistentDataType.BYTE, (byte) 1);
data.set(ownerKey, PersistentDataType.STRING, owner.toString());
data.set(ownerNameKey, PersistentDataType.STRING, name);
data.set(identityKey, PersistentDataType.STRING, UUID.randomUUID().toString());
data.set(chargesKey, PersistentDataType.LONG, 0L);
meta.displayName(Component.text("Pocket Base Sharing Compass"));
meta.setMaxStackSize(1);
item.setItemMeta(meta);
return withCharges(item, 0);
}
}
@@ -109,6 +109,13 @@ final class PocketBaseWorldService {
world.save();
}
Optional<Location> availableArrival(UUID ownerId) {
// A guest item must not recreate a missing world or grant an unlock.
World world = server.getWorld(worldName(ownerId));
return world == null ? Optional.empty()
: Optional.of(new Location(world, 0.5, PLATFORM_Y + 1, 5.5, 180.0f, 0.0f));
}
Location arrival(UUID ownerId) {
return new Location(ensureWorld(ownerId), 0.5, PLATFORM_Y + 1, 5.5, 180.0f, 0.0f);
}
@@ -18,6 +18,8 @@ public final class SpigotBasePlugin extends JavaPlugin {
private PocketBaseManager pocketBaseManager;
private PocketBaseController pocketBaseController;
private PocketBaseKeystoneService keystoneService;
private PocketBaseSharingCompass sharingCompass;
private BaseIdentificationController identificationController;
@Override
public void onEnable() {
@@ -53,6 +55,10 @@ public final class SpigotBasePlugin extends JavaPlugin {
getLogger()
);
keystoneService.registerRecipe();
sharingCompass = new PocketBaseSharingCompass(getServer(), pocketBaseManager,
new NamespacedKey(this, "sharing_compass"),
() -> java.util.concurrent.ThreadLocalRandom.current().nextInt(100));
sharingCompass.registerRecipes();
} catch (RuntimeException | IOException exception) {
getLogger().log(Level.SEVERE, "Could not initialize Spigot Base", exception);
getServer().getPluginManager().disablePlugin(this);
@@ -66,6 +72,14 @@ public final class SpigotBasePlugin extends JavaPlugin {
TeleportProgressionService teleportProgressionService =
new TeleportProgressionService(settingsProvider);
BaseBoundsService boundsService = new BaseBoundsService(settingsProvider);
try {
identificationController = new BaseIdentificationController(getServer(), stateManager, boundsService,
new BukkitBaseLabels(new NativeBaseLabelPackets()), getLogger());
getServer().getPluginManager().registerEvents(identificationController, this);
getServer().getScheduler().runTaskTimer(this, identificationController, 5L, 5L);
} catch (RuntimeException | LinkageError exception) {
getLogger().log(Level.SEVERE, "Nearby base identification is unavailable on this server; other base features remain enabled", exception);
}
progressListener = new BaseProgressListener(
this,
stateManager,
@@ -103,6 +117,7 @@ public final class SpigotBasePlugin extends JavaPlugin {
getServer().getPluginManager().registerEvents(teleportManager, this);
getServer().getPluginManager().registerEvents(pocketBaseController, this);
getServer().getPluginManager().registerEvents(keystoneService, this);
getServer().getPluginManager().registerEvents(sharingCompass, this);
command("setbase").setExecutor(new SetBaseCommand(
stateManager, baseService, Clock.systemUTC(), pocketBaseController
@@ -156,6 +171,9 @@ public final class SpigotBasePlugin extends JavaPlugin {
@Override
public void onDisable() {
if (identificationController != null) {
identificationController.close();
}
if (progressListener != null) {
progressListener.removeAllBossBars();
}
@@ -0,0 +1,153 @@
package games.dmg.spigotbase;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import io.netty.buffer.Unpooled;
import java.util.UUID;
import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.protocol.game.ClientboundAddEntityPacket;
import net.minecraft.network.protocol.game.ClientboundSetEntityDataPacket;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.entity.EntityTypes;
import org.bukkit.craftbukkit.CraftRegistry;
import org.bukkit.craftbukkit.CraftServer;
import org.bukkit.craftbukkit.entity.CraftTextDisplay;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
class NativeBaseLabelPacketsTest {
@BeforeAll
static void bootstrap() throws Exception {
NativeRuntime.bootstrap();
// Metadata encoding enters the server's item-obfuscation context even for text-only packets.
var global = mock(io.papermc.paper.configuration.GlobalConfiguration.class);
global.anticheat = mock(io.papermc.paper.configuration.GlobalConfiguration.Anticheat.class);
global.anticheat.obfuscation = mock(io.papermc.paper.configuration.GlobalConfiguration.Anticheat.Obfuscation.class);
global.anticheat.obfuscation.items = mock(io.papermc.paper.configuration.GlobalConfiguration.Anticheat.Obfuscation.Items.class);
var binding = mock(io.papermc.paper.util.sanitizer.ItemObfuscationBinding.class);
var level = io.papermc.paper.util.sanitizer.ItemObfuscationBinding.class.getField("level");
level.setAccessible(true);
level.set(binding, io.papermc.paper.util.sanitizer.ItemObfuscationSession.ObfuscationLevel.NONE);
global.anticheat.obfuscation.items.binding = binding;
var set = io.papermc.paper.configuration.GlobalConfiguration.class.getDeclaredMethod("set",
io.papermc.paper.configuration.GlobalConfiguration.class);
set.setAccessible(true);
set.invoke(null, global);
}
@Test
void removalRoundTripsAndDeliveryUsesOnlyTheSelectedPlayerConnection() throws Exception {
var packets = new NativeBaseLabelPackets();
var remove = assertInstanceOf(net.minecraft.network.protocol.game.ClientboundRemoveEntitiesPacket.class,
packets.remove(42));
var buffer = new net.minecraft.network.FriendlyByteBuf(Unpooled.buffer());
try {
net.minecraft.network.protocol.game.ClientboundRemoveEntitiesPacket.STREAM_CODEC.encode(buffer, remove);
var decoded = net.minecraft.network.protocol.game.ClientboundRemoveEntitiesPacket.STREAM_CODEC.decode(buffer);
assertEquals(java.util.List.of(42), decoded.getEntityIds());
} finally { buffer.release(); }
var player = mock(org.bukkit.craftbukkit.entity.CraftPlayer.class);
var handle = mock(net.minecraft.server.level.ServerPlayer.class);
handle.connection = mock(net.minecraft.server.network.ServerGamePacketListenerImpl.class);
when(player.getHandle()).thenReturn(handle);
packets.send(player, remove);
verify(handle.connection).send(remove);
verifyNoMoreInteractions(handle.connection);
}
private static ServerLevel nativeLevel() throws Exception {
var level = mock(ServerLevel.class);
var configuration = mock(org.purpurmc.purpur.PurpurWorldConfig.class);
configuration.drowningAirTicks = 300;
var field = net.minecraft.world.level.Level.class.getField("purpurConfig");
field.setAccessible(true);
field.set(level, configuration);
var spigot = mock(org.spigotmc.SpigotWorldConfig.class);
spigot.miscActivationRange = 32;
var spigotField = net.minecraft.world.level.Level.class.getField("spigotConfig");
spigotField.setAccessible(true);
spigotField.set(level, spigot);
var paper = mock(io.papermc.paper.configuration.WorldConfiguration.class);
paper.entities = mock(io.papermc.paper.configuration.WorldConfiguration.Entities.class);
paper.entities.spawning = mock(io.papermc.paper.configuration.WorldConfiguration.Entities.Spawning.class);
paper.entities.spawning.despawnTime = new it.unimi.dsi.fastutil.objects.Reference2ObjectOpenHashMap<>();
when(level.paperConfig()).thenReturn(paper);
return level;
}
@Test
void publicCreationAndIdReservationDoNotRegisterEntitiesOrLoadAnchorChunks() throws Exception {
var level = nativeLevel();
var world = mock(org.bukkit.craftbukkit.CraftWorld.class);
when(world.getHandle()).thenReturn(level);
when(level.getLevel()).thenReturn(level);
when(level.getMinecraftWorld()).thenReturn(level);
when(level.enabledFeatures()).thenReturn(net.minecraft.world.flag.FeatureFlags.VANILLA_SET);
when(world.isNormalWorld()).thenReturn(true);
when(world.isEnabled(any(io.papermc.paper.world.flag.FeatureDependant.class))).thenReturn(true);
when(level.getCraftServer()).thenReturn(mock(CraftServer.class));
when(level.getWorld()).thenReturn(world);
doCallRealMethod().when(world).createEntity(any(org.bukkit.Location.class), eq(org.bukkit.entity.TextDisplay.class));
doCallRealMethod().when(world).createEntity(any(org.bukkit.Location.class), eq(org.bukkit.entity.TextDisplay.class), eq(true));
var display = world.createEntity(new org.bukkit.Location(world, 100.5, 66.25, -99.5), org.bukkit.entity.TextDisplay.class);
assertInstanceOf(CraftTextDisplay.class, display);
assertThrows(IllegalStateException.class, display::getEntityId);
var chunkSource = mock(net.minecraft.server.level.ServerChunkCache.class);
var field = ServerLevel.class.getDeclaredField("chunkSource");
field.setAccessible(true);
field.set(level, chunkSource);
when(level.getNextEntityId()).thenCallRealMethod();
try (var bukkit = mockStatic(org.bukkit.Bukkit.class)) {
bukkit.when(org.bukkit.Bukkit::getUnsafe).thenReturn(org.bukkit.craftbukkit.util.CraftMagicNumbers.INSTANCE);
var packets = new NativeBaseLabelPackets();
int first = packets.nextId(world), second = packets.nextId(world);
assertNotEquals(0, first);
assertNotEquals(first, second);
}
verify(chunkSource, atLeast(2)).hasEntityWithId(anyInt());
verifyNoMoreInteractions(chunkSource);
assertTrue(mockingDetails(level).getInvocations().stream().noneMatch(call ->
call.getMethod().getName().matches("(getChunk.*|loadChunk.*|add.*Entity.*)")),
"Public creation and ID allocation must not load chunks or add entities");
}
@Test
void nativeSpawnAndMetadataRoundTripCarryCorrectTypeCoordinatesAndLiteralText() throws Exception {
var level = nativeLevel();
var nativeDisplay = new net.minecraft.world.entity.Display.TextDisplay(EntityTypes.TEXT_DISPLAY, level);
var display = new CraftTextDisplay(mock(CraftServer.class), nativeDisplay);
display.text(net.kyori.adventure.text.Component.text("Base: Alice"));
display.setBillboard(org.bukkit.entity.Display.Billboard.CENTER);
display.setViewRange(16);
display.setPersistent(false);
var description = new BaseIdentification.Label(UUID.randomUUID(), "Base: Alice", UUID.randomUUID(),
100.5, 66.25, -99.5, 16);
var packets = new NativeBaseLabelPackets().spawn(display, description, 42);
assertEquals(2, packets.size(), "A label needs native spawn and complete metadata packets");
var spawn = assertInstanceOf(ClientboundAddEntityPacket.class, packets.get(0));
var metadata = assertInstanceOf(ClientboundSetEntityDataPacket.class, packets.get(1));
var buffer = new RegistryFriendlyByteBuf(Unpooled.buffer(), CraftRegistry.getMinecraftRegistry());
try {
ClientboundAddEntityPacket.STREAM_CODEC.encode(buffer, spawn);
var decoded = ClientboundAddEntityPacket.STREAM_CODEC.decode(buffer);
assertEquals(EntityTypes.TEXT_DISPLAY, decoded.getType());
assertEquals(42, decoded.getId());
assertEquals(display.getUniqueId(), decoded.getUUID());
assertEquals(100.5, decoded.getX());
assertEquals(66.25, decoded.getY());
assertEquals(-99.5, decoded.getZ());
ClientboundSetEntityDataPacket.STREAM_CODEC.encode(buffer, metadata);
var decodedMetadata = ClientboundSetEntityDataPacket.STREAM_CODEC.decode(buffer);
assertEquals(42, decodedMetadata.id());
assertTrue(decodedMetadata.packedItems().stream().anyMatch(value ->
value.value() instanceof net.minecraft.network.chat.Component component
&& component.getString().equals("Base: Alice")));
} finally {
buffer.release();
}
verify(level, never()).addFreshEntity(nativeDisplay);
assertThrows(IllegalStateException.class, nativeDisplay::getId, "The entity remains unassigned/unspawned; only the packet has a reserved ID");
assertFalse(display.isPersistent());
}
}
@@ -0,0 +1,63 @@
package games.dmg.spigotbase;
import java.util.List;
import java.util.stream.Stream;
import net.minecraft.SharedConstants;
import net.minecraft.commands.Commands;
import net.minecraft.core.HolderLookup;
import net.minecraft.core.LayeredRegistryAccess;
import net.minecraft.core.Registry;
import net.minecraft.core.RegistryAccess;
import net.minecraft.resources.Identifier;
import net.minecraft.resources.RegistryDataLoader;
import net.minecraft.server.Bootstrap;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.RegistryLayer;
import net.minecraft.server.ReloadableServerResources;
import net.minecraft.server.packs.PackType;
import net.minecraft.server.packs.repository.ServerPacksSource;
import net.minecraft.server.packs.resources.MultiPackResourceManager;
import net.minecraft.server.permissions.LevelBasedPermissionSet;
import net.minecraft.tags.TagLoader;
import net.minecraft.util.Util;
import net.minecraft.world.flag.FeatureFlags;
import net.minecraft.world.level.DataPackConfig;
import net.minecraft.world.level.WorldDataConfiguration;
import org.bukkit.craftbukkit.CraftRegistry;
/** Loads vanilla registries/tags/components using the same path as the server's own tests. */
final class NativeRuntime {
private static boolean initialized;
private NativeRuntime() {}
static synchronized void bootstrap() throws Exception {
// Paper registries are process-global and reject loading a second time.
if (initialized) return;
SharedConstants.tryDetectVersion();
Bootstrap.bootStrap();
var flags = FeatureFlags.VANILLA_SET;
var packs = ServerPacksSource.createVanillaTrustedRepository();
MinecraftServer.configurePackRepository(packs, new WorldDataConfiguration(new DataPackConfig(
FeatureFlags.REGISTRY.toNames(flags).stream().map(Identifier::getPath).toList(), List.of()), flags), true, false);
try (var resources = new MultiPackResourceManager(PackType.SERVER_DATA, packs.openAllSelected())) {
LayeredRegistryAccess<RegistryLayer> layers = RegistryLayer.createRegistryAccess();
List<Registry.PendingTags<?>> tags = TagLoader.loadTagsForExistingRegistries(resources, layers.getLayer(RegistryLayer.STATIC));
List<HolderLookup.RegistryLookup<?>> lookups = TagLoader.buildUpdatedLookups(layers.getAccessForLoading(RegistryLayer.WORLDGEN), tags);
RegistryAccess.Frozen worldgen = RegistryDataLoader.load(resources, lookups,
RegistryDataLoader.WORLDGEN_REGISTRIES, Util.backgroundExecutor()).join();
layers = layers.replaceFrom(RegistryLayer.WORLDGEN, worldgen);
RegistryAccess.Frozen dimensions = RegistryDataLoader.load(resources,
Stream.concat(lookups.stream(), worldgen.listRegistries()).toList(),
RegistryDataLoader.DIMENSION_REGISTRIES, Util.backgroundExecutor()).join();
layers = layers.replaceFrom(RegistryLayer.DIMENSIONS, dimensions);
Class.forName(org.bukkit.Registry.class.getName());
var datapack = ReloadableServerResources.loadResources(resources, layers, tags, flags,
Commands.CommandSelection.DEDICATED, LevelBasedPermissionSet.ALL_PERMISSIONS,
Util.backgroundExecutor(), Runnable::run).join();
datapack.updateComponentsAndStaticRegistryTags();
CraftRegistry.setMinecraftRegistry(layers.compositeAccess().freeze());
}
initialized = true;
}
}
@@ -0,0 +1,825 @@
package games.dmg.spigotbase;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import java.util.*;
import java.util.function.IntSupplier;
import org.bukkit.*;
import org.bukkit.craftbukkit.inventory.CraftItemFactory;
import org.bukkit.craftbukkit.util.CraftMagicNumbers;
import org.bukkit.entity.Player;
import org.bukkit.event.Event;
import org.bukkit.event.EventHandler;
import org.bukkit.event.inventory.*;
import org.bukkit.event.block.Action;
import org.bukkit.event.player.PlayerInteractEvent;
import org.bukkit.event.player.PlayerTeleportEvent;
import org.bukkit.inventory.*;
import org.bukkit.persistence.PersistentDataType;
import org.bukkit.plugin.Plugin;
import org.bukkit.plugin.RegisteredListener;
import org.junit.jupiter.api.*;
import org.mockito.MockedStatic;
class SharingCompassEventsTest {
static final NamespacedKey KEY = new NamespacedKey("spigotbase", "sharing_compass");
final UUID owner = UUID.randomUUID();
final Server server = mock(Server.class);
final Player player = mock(Player.class);
final World world = mock(World.class);
final PocketBaseManager pockets = mock(PocketBaseManager.class);
final CraftingInventory inventory = mock(CraftingInventory.class);
final InventoryView view = mock(InventoryView.class);
final IntSupplier roll = mock(IntSupplier.class);
ItemStack[] matrix = new ItemStack[9];
ItemStack result;
ItemStack cursor;
final PlayerInventory carried = mock(PlayerInventory.class);
final ItemStack[] contents = new ItemStack[41];
MockedStatic<Bukkit> bukkit;
PocketBaseSharingCompass service;
@BeforeAll static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@BeforeEach void setup() {
bukkit = mockStatic(Bukkit.class);
bukkit.when(Bukkit::getItemFactory).thenReturn(CraftItemFactory.instance());
bukkit.when(Bukkit::getUnsafe).thenReturn(CraftMagicNumbers.INSTANCE);
when(player.getUniqueId()).thenReturn(owner);
when(player.getName()).thenReturn("Alice");
when(player.getWorld()).thenReturn(world);
when(world.getUID()).thenReturn(UUID.randomUUID());
when(view.getPlayer()).thenReturn(player);
when(view.getTopInventory()).thenReturn(inventory);
when(view.getType()).thenReturn(InventoryType.WORKBENCH);
when(view.getCursor()).thenAnswer(inv -> cursor);
doAnswer(inv -> { cursor = inv.getArgument(0); return null; }).when(view).setCursor(any());
when(player.getInventory()).thenReturn(carried);
when(carried.getContents()).thenAnswer(inv -> contents.clone());
when(player.getItemOnCursor()).thenAnswer(inv -> cursor);
doAnswer(inv -> { cursor = inv.getArgument(0); return null; }).when(player).setItemOnCursor(any());
when(carried.getItem(any(EquipmentSlot.class))).thenAnswer(inv -> contents[inv.getArgument(0) == EquipmentSlot.HAND ? 0 : 40]);
doAnswer(inv -> { contents[inv.getArgument(0) == EquipmentSlot.HAND ? 0 : 40] = inv.getArgument(1); return null; }).when(carried).setItem(any(EquipmentSlot.class), any());
when(carried.firstEmpty()).thenAnswer(inv -> { for (int i = 0; i < 36; i++) if (contents[i] == null) return i; return -1; });
doAnswer(inv -> { contents[(int) inv.getArgument(0)] = inv.getArgument(1); return null; }).when(carried).setItem(anyInt(), any());
when(inventory.getMatrix()).thenAnswer(inv -> Arrays.stream(matrix).map(i -> i == null ? null : i.clone()).toArray(ItemStack[]::new));
doAnswer(inv -> { matrix = inv.getArgument(0); return null; }).when(inventory).setMatrix(any(ItemStack[].class));
when(inventory.getRecipe()).thenAnswer(inv -> candidateRecipe());
when(inventory.getResult()).thenAnswer(inv -> result);
doAnswer(inv -> { result = inv.getArgument(0); return null; }).when(inventory).setResult(any());
when(pockets.state(owner)).thenReturn(new PocketBaseState(owner, 1, Optional.empty()));
service = new PocketBaseSharingCompass(server, pockets, KEY, roll);
}
@AfterEach void close() { bukkit.close(); }
@Test void registersTheExactCreationRecipe() {
service.registerRecipes();
var recipes = org.mockito.ArgumentCaptor.forClass(Recipe.class);
verify(server, atLeastOnce()).addRecipe(recipes.capture());
var recipe = recipes.getAllValues().stream().filter(r -> r instanceof ShapedRecipe shaped && shaped.getKey().equals(KEY)).findFirst().orElseThrow();
var shaped = (ShapedRecipe) recipe;
assertArrayEquals(new String[]{"DED", "ECE", "DED"}, shaped.getShape());
assertEquals(Material.DIAMOND_BLOCK, shaped.getIngredientMap().get('D').getType());
assertEquals(Material.ECHO_SHARD, shaped.getIngredientMap().get('E').getType());
assertEquals(Material.END_CRYSTAL, shaped.getIngredientMap().get('C').getType());
}
static NamespacedKey field(String suffix) { return new NamespacedKey("spigotbase", "sharing_compass_" + suffix); }
static long charges(ItemStack item) { return item.getItemMeta().getPersistentDataContainer().get(field("charges"), PersistentDataType.LONG); }
ItemStack compass(long charges) throws Exception {
cursor = null;
creationMatrix();
dispatch(take(ClickType.LEFT));
var item = cursor.clone();
cursor = null;
var meta = item.getItemMeta();
meta.getPersistentDataContainer().set(field("charges"), PersistentDataType.LONG, charges);
item.setItemMeta(meta);
return item;
}
void rechargeMatrix(ItemStack item) {
for (int i = 0; i < 9; i++) matrix[i] = i == 4 ? item : new ItemStack(Material.ENDER_PEARL, 16);
when(pockets.ownerForPocketWorld(world.getUID())).thenReturn(Optional.of(owner));
when(inventory.getLocation()).thenReturn(new Location(world, 10, 65, -9));
var table = mock(org.bukkit.block.Block.class);
when(table.getType()).thenReturn(Material.CRAFTING_TABLE);
when(world.getBlockAt(anyInt(), anyInt(), anyInt())).thenReturn(table);
when(world.getBlockAt(any(Location.class))).thenReturn(table);
}
@Test void safeRechargePreviewAndCommitRequireExactPearlsAndBoundTableWorld() throws Exception {
var item = compass(0);
rechargeMatrix(item);
preview();
assertNotNull(result, "Recharge must have a result preview");
verifyNoInteractions(roll);
assertEquals(0, charges(matrix[4]));
assertEquals(16, matrix[0].getAmount());
when(pockets.ownerForPocketWorld(world.getUID())).thenReturn(Optional.of(UUID.randomUUID()));
preview();
assertNull(result);
dispatch(take(ClickType.LEFT));
assertNull(cursor);
when(pockets.ownerForPocketWorld(world.getUID())).thenReturn(Optional.of(owner));
matrix[0] = new ItemStack(Material.SNOWBALL);
preview();
assertNull(result);
dispatch(take(ClickType.LEFT));
assertNull(cursor);
matrix[0] = new ItemStack(Material.ENDER_PEARL, 16);
preview();
dispatch(take(ClickType.LEFT));
assertNotNull(cursor);
assertEquals(8, charges(cursor));
assertEquals(item.getItemMeta().getPersistentDataContainer().get(field("id"), PersistentDataType.STRING),
cursor.getItemMeta().getPersistentDataContainer().get(field("id"), PersistentDataType.STRING));
assertNull(matrix[4]);
for (int i = 0; i < 9; i++) if (i != 4) assertEquals(15, matrix[i].getAmount());
verify(roll).getAsInt();
verify(world, never()).createExplosion(any(), any(Location.class), anyFloat(), anyBoolean(), anyBoolean(), anyBoolean());
}
static java.util.stream.Stream<org.junit.jupiter.params.provider.Arguments> rechargeBoundaries() {
var cases = new ArrayList<org.junit.jupiter.params.provider.Arguments>();
long[] totals = {8, 9, 16, 17, 24, 25, 32, 33, 40, 41, 48, 49, 56, 57, 64, 65, 72, 73, 80, 81, 1024, Long.MAX_VALUE};
for (long total : totals) {
int chance = total >= 73 ? 10 : 100 - 10 * (int) ((total - 1) / 8);
cases.add(org.junit.jupiter.params.provider.Arguments.of(total - 8, chance - 1, true));
if (chance < 100) cases.add(org.junit.jupiter.params.provider.Arguments.of(total - 8, chance, false));
}
return cases.stream();
}
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.MethodSource("rechargeBoundaries")
void eachCommittedRechargeRollsAtCurrentChargeBoundaryAndDestroysOnFailure(long current, int random, boolean succeeds) throws Exception {
rechargeMatrix(compass(current));
for (int n = 0; n < 3; n++) preview();
verifyNoInteractions(roll);
when(roll.getAsInt()).thenReturn(random);
// A denied native explosion is not retried or replaced and never refunds the failed transaction.
when(world.createExplosion(any(), any(Location.class), anyFloat(), anyBoolean(), anyBoolean(), anyBoolean())).thenReturn(false);
var event = take(ClickType.LEFT);
dispatch(event);
assertTrue(event.isCancelled());
verify(roll).getAsInt();
assertNull(matrix[4], "The input compass leaves the grid on either outcome");
for (int i = 0; i < 9; i++) if (i != 4) assertEquals(15, matrix[i].getAmount());
if (succeeds) {
assertNotNull(cursor);
assertEquals(current + 8, charges(cursor));
verify(world, never()).createExplosion(any(), any(Location.class), anyFloat(), anyBoolean(), anyBoolean(), anyBoolean());
} else {
assertNull(cursor, "Failed recharge must destroy the compass, not produce charges");
verify(world).createExplosion(player, new Location(world, 10.5, 65.5, -8.5), 6.0F, false, true, false);
}
dispatch(event);
dispatch(take(ClickType.SHIFT_LEFT));
verify(roll).getAsInt();
if (!succeeds) verify(world, times(1)).createExplosion(any(), any(Location.class), anyFloat(), anyBoolean(), anyBoolean(), anyBoolean());
}
@Test void rechargeHasRegisteredTableRecipe() {
service.registerRecipes();
var recipes = org.mockito.ArgumentCaptor.forClass(Recipe.class);
verify(server, times(2)).addRecipe(recipes.capture());
var shaped = recipes.getAllValues().stream().filter(r -> r instanceof ShapedRecipe s && !s.getKey().equals(KEY))
.map(ShapedRecipe.class::cast).findFirst().orElseThrow();
assertArrayEquals(new String[]{"PPP", "PCP", "PPP"}, shaped.getShape());
assertEquals(Material.ENDER_PEARL, shaped.getIngredientMap().get('P').getType());
assertEquals(Material.COMPASS, shaped.getIngredientMap().get('C').getType());
}
void successfulTeleport(Location destination) {
when(player.teleport(destination, PlayerTeleportEvent.TeleportCause.PLUGIN)).thenAnswer(inv -> {
when(player.getWorld()).thenReturn(destination.getWorld());
return true;
});
}
PlayerInteractEvent use(Action action, EquipmentSlot hand) {
return new PlayerInteractEvent(player, action, carried.getItem(hand), null, org.bukkit.block.BlockFace.SELF, hand);
}
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.EnumSource(value = Action.class, names = {"RIGHT_CLICK_AIR", "RIGHT_CLICK_BLOCK"})
void transferredCompassEntersBoundOwnerOnlyConsumesAfterSuccessAndNeverReturns(Action action) throws Exception {
var item = compass(2);
UUID guest = UUID.randomUUID();
when(player.getUniqueId()).thenReturn(guest);
when(pockets.state(guest)).thenReturn(PocketBaseState.locked(guest));
var destinationWorld = mock(World.class);
when(destinationWorld.getUID()).thenReturn(UUID.randomUUID());
when(pockets.ownerForPocketWorld(destinationWorld.getUID())).thenReturn(Optional.of(owner));
var destination = new Location(destinationWorld, .5, 65, 5.5);
when(pockets.availablePocketArrival(owner)).thenReturn(Optional.of(destination));
contents[0] = item;
when(player.getFallDistance()).thenReturn(12F);
// Teleport's native result represents the outcome after PlayerTeleportEvent protection handling.
when(player.teleport(destination, PlayerTeleportEvent.TeleportCause.PLUGIN)).thenAnswer(inv -> {
assertEquals(2, charges(contents[0]), "Charge cannot be spent before successful teleport");
var teleport = new PlayerTeleportEvent(player, new Location(world, 0, 70, 0), destination, PlayerTeleportEvent.TeleportCause.PLUGIN);
teleport.setCancelled(true);
return !teleport.isCancelled();
});
dispatch(use(action, EquipmentSlot.HAND));
assertEquals(2, charges(contents[0]));
verify(player).setFallDistance(12F);
successfulTeleport(destination);
dispatch(use(action, EquipmentSlot.HAND));
assertEquals(1, charges(contents[0]), "Guest travel must spend exactly one charge");
assertEquals(owner.toString(), contents[0].getItemMeta().getPersistentDataContainer().get(field("owner"), PersistentDataType.STRING));
when(player.getWorld()).thenReturn(destinationWorld);
dispatch(use(action, EquipmentSlot.HAND));
assertEquals(1, charges(contents[0]));
verify(player, times(2)).teleport(destination, PlayerTeleportEvent.TeleportCause.PLUGIN);
}
@Test void unavailableEmptyAndDeniedTravelNeverSpendAndOffHandCanEnter() throws Exception {
contents[40] = compass(1);
var destination = new Location(mock(World.class), 0, 65, 0);
when(pockets.availablePocketArrival(owner)).thenReturn(Optional.empty());
dispatch(use(Action.RIGHT_CLICK_AIR, EquipmentSlot.OFF_HAND));
assertEquals(1, charges(contents[40]));
when(pockets.availablePocketArrival(owner)).thenReturn(Optional.of(destination));
var denied = use(Action.RIGHT_CLICK_BLOCK, EquipmentSlot.OFF_HAND);
denied.setUseItemInHand(Event.Result.DENY);
dispatch(denied);
assertEquals(1, charges(contents[40]));
verify(player, never()).teleport(any(Location.class), any(PlayerTeleportEvent.TeleportCause.class));
successfulTeleport(destination);
dispatch(use(Action.RIGHT_CLICK_AIR, EquipmentSlot.OFF_HAND));
assertEquals(0, charges(contents[40]));
dispatch(use(Action.RIGHT_CLICK_AIR, EquipmentSlot.OFF_HAND));
verify(player).teleport(destination, PlayerTeleportEvent.TeleportCause.PLUGIN);
when(player.getWorld()).thenReturn(world);
rechargeMatrix(contents[40]);
when(roll.getAsInt()).thenReturn(99);
dispatch(take(ClickType.LEFT));
assertEquals(8, charges(cursor), "Spending back to zero makes recharge 100% safe");
}
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(strings = {"inventory", "cursor", "removed", "death", "redirect", "reentrant"})
void teleportCallbacksCannotDuplicateResurrectOrOverwriteItems(String callback) throws Exception {
var item = compass(8);
contents[0] = item;
var replacement = new ItemStack(Material.DIAMOND, 7);
var destinationWorld = mock(World.class);
var destination = new Location(destinationWorld, 0, 65, 0);
when(pockets.availablePocketArrival(owner)).thenReturn(Optional.of(destination));
var calls = new java.util.concurrent.atomic.AtomicInteger();
var drops = new ArrayList<ItemStack>();
when(player.teleport(destination, PlayerTeleportEvent.TeleportCause.PLUGIN)).thenAnswer(inv -> {
if (calls.incrementAndGet() == 1) {
switch (callback) {
case "inventory" -> { contents[12] = contents[0]; contents[0] = replacement; }
case "cursor" -> { cursor = contents[0]; contents[0] = replacement; }
case "removed" -> contents[0] = replacement;
case "death" -> {
drops.add(contents[0].clone());
dispatch(new org.bukkit.event.entity.PlayerDeathEvent(player, mock(org.bukkit.damage.DamageSource.class),
drops, 0, net.kyori.adventure.text.Component.text("death"), true));
contents[0] = null; // Native death clears the inventory after the event.
when(player.isDead()).thenReturn(true);
}
case "redirect" -> { /* Protection redirects travel outside the bound Pocket Base. */ }
case "reentrant" -> dispatch(use(Action.RIGHT_CLICK_AIR, EquipmentSlot.HAND));
default -> throw new AssertionError(callback);
}
}
if (!callback.equals("redirect")) when(player.getWorld()).thenReturn(destinationWorld);
return true;
});
dispatch(use(Action.RIGHT_CLICK_AIR, EquipmentSlot.HAND));
switch (callback) {
case "inventory" -> { assertEquals(replacement, contents[0]); assertEquals(7, charges(contents[12])); }
case "cursor" -> { assertEquals(replacement, contents[0]); assertEquals(7, charges(cursor)); }
case "removed" -> assertEquals(replacement, contents[0], "Never recreate a removed compass over another item");
case "death" -> { assertNull(contents[0], "Never resurrect a discharged/dropped compass"); assertEquals(0, charges(drops.getFirst())); }
case "redirect" -> assertEquals(8, charges(contents[0]), "No bound-destination entry means no charge spent");
case "reentrant" -> { assertEquals(1, calls.get(), "A teleport callback must not reuse the pending item"); assertEquals(7, charges(contents[0])); }
default -> throw new AssertionError(callback);
}
}
@Test void visibleInstructionsAndRiskRefreshWithChargesAndKeepOwnerIdentity() throws Exception {
var item = compass(0);
var lore = item.getItemMeta().lore();
assertNotNull(lore);
assertTrue(lore.contains(net.kyori.adventure.text.Component.text("Charges: 0")), "Current charges must be visible");
assertTrue(lore.contains(net.kyori.adventure.text.Component.text("Destination: Alice (" + owner + ")")));
assertTrue(lore.contains(net.kyori.adventure.text.Component.text("Recharge: center of table + 8 ender pearls, inside destination.")));
assertTrue(lore.contains(net.kyori.adventure.text.Component.text("Next recharge: 100% success / 0% explosion")));
assertTrue(lore.contains(net.kyori.adventure.text.Component.text("Right-click for entry only; no return trip.")));
rechargeMatrix(item);
dispatch(take(ClickType.LEFT));
assertTrue(cursor.getItemMeta().lore().contains(net.kyori.adventure.text.Component.text("Charges: 8")));
assertTrue(cursor.getItemMeta().lore().contains(net.kyori.adventure.text.Component.text("Next recharge: 90% success / 10% explosion")));
}
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(booleans = {false, true})
void deathDischargesCarriedDroppedAndKeptCompassesWithoutChangingNormalDeath(boolean keepInventory) throws Exception {
var item = compass(80);
contents[0] = item.clone();
contents[40] = item.clone();
contents[12] = new ItemStack(Material.DIAMOND, 12);
cursor = item.clone();
var drops = new ArrayList<ItemStack>(List.of(item.clone(), new ItemStack(Material.DIAMOND, 12)));
var death = new org.bukkit.event.entity.PlayerDeathEvent(player, mock(org.bukkit.damage.DamageSource.class),
drops, 17, net.kyori.adventure.text.Component.text("ordinary death"), true);
death.setKeepInventory(keepInventory);
death.getItemsToKeep().add(item.clone());
dispatch(death);
assertEquals(0, charges(contents[0]), "Death empties carried compasses");
assertEquals(0, charges(contents[40]));
assertEquals(0, charges(cursor));
assertEquals(0, charges(drops.getFirst()));
assertEquals(0, charges(death.getItemsToKeep().getFirst()));
assertEquals(owner.toString(), drops.getFirst().getItemMeta().getPersistentDataContainer().get(field("owner"), PersistentDataType.STRING));
assertEquals(keepInventory, death.getKeepInventory());
assertEquals(2, drops.size());
assertEquals(12, drops.get(1).getAmount());
assertEquals(12, contents[12].getAmount());
assertEquals(17, death.getDroppedExp());
assertFalse(death.isCancelled());
assertTrue(drops.getFirst().getItemMeta().lore().contains(net.kyori.adventure.text.Component.text("Charges: 0")));
}
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(strings = {"missing-id", "bad-id", "short-owner", "bad-owner", "negative", "wrong-charge-type", "missing-charges", "wrong-marker-type", "wrong-version", "stacked", "name-only"})
void malformedItemsCannotTravelOrRecharge(String corruption) throws Exception {
var item = compass(5);
var meta = item.getItemMeta();
var data = meta.getPersistentDataContainer();
switch (corruption) {
case "missing-id" -> data.remove(field("id"));
case "bad-id" -> data.set(field("id"), PersistentDataType.STRING, "broken");
case "short-owner" -> data.set(field("owner"), PersistentDataType.STRING, "1-1-1-1-1");
case "bad-owner" -> data.set(field("owner"), PersistentDataType.STRING, "broken");
case "negative" -> data.set(field("charges"), PersistentDataType.LONG, -1L);
case "wrong-charge-type" -> data.set(field("charges"), PersistentDataType.STRING, "5");
case "missing-charges" -> data.remove(field("charges"));
case "wrong-marker-type" -> data.set(KEY, PersistentDataType.INTEGER, 1);
case "wrong-version" -> data.set(KEY, PersistentDataType.BYTE, (byte) 2);
case "stacked" -> item.setAmount(2);
case "name-only" -> { for (var key : new HashSet<>(data.getKeys())) data.remove(key); }
default -> throw new AssertionError(corruption);
}
item.setItemMeta(meta);
var destination = new Location(mock(World.class), 0, 65, 0);
when(pockets.availablePocketArrival(any())).thenReturn(Optional.of(destination));
when(player.teleport(destination, PlayerTeleportEvent.TeleportCause.PLUGIN)).thenReturn(true);
contents[0] = item;
dispatch(use(Action.RIGHT_CLICK_AIR, EquipmentSlot.HAND));
verify(player, never()).teleport(any(Location.class), any(PlayerTeleportEvent.TeleportCause.class));
rechargeMatrix(item);
preview();
assertNull(result);
dispatch(take(ClickType.LEFT));
assertNull(cursor);
verifyNoInteractions(roll);
assertEquals(16, matrix[0].getAmount());
}
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(longs = {Long.MAX_VALUE, Long.MAX_VALUE - 7})
void numericOverflowRefusesRechargeBeforeRollingOrPaying(long charges) throws Exception {
rechargeMatrix(compass(charges));
preview();
assertNull(result, "Overflow is invalid arithmetic, not a wrapped free recharge");
dispatch(take(ClickType.SHIFT_LEFT));
assertNull(contents[0]);
assertEquals(charges, charges(matrix[4]));
assertEquals(16, matrix[0].getAmount());
verifyNoInteractions(roll);
}
@Test void nativeItemPersistenceAcrossFreshServiceAndGuestTransferKeepsSingleIdentityAndCharges() throws Exception {
var item = compass(1024);
byte[] saved = item.serializeAsBytes();
var restored = ItemStack.deserializeBytes(saved);
assertEquals(item, restored);
assertEquals(1, restored.getMaxStackSize());
service = new PocketBaseSharingCompass(server, pockets, KEY, roll);
when(player.getUniqueId()).thenReturn(UUID.randomUUID());
var destination = new Location(mock(World.class), 0, 65, 0);
when(pockets.availablePocketArrival(owner)).thenReturn(Optional.of(destination));
successfulTeleport(destination);
contents[0] = restored;
dispatch(use(Action.RIGHT_CLICK_AIR, EquipmentSlot.HAND));
assertEquals(1023, charges(contents[0]));
var afterLogout = ItemStack.deserializeBytes(contents[0].serializeAsBytes());
assertEquals(1023, charges(afterLogout));
assertEquals(owner.toString(), afterLogout.getItemMeta().getPersistentDataContainer().get(field("owner"), PersistentDataType.STRING));
assertEquals(item.getItemMeta().getPersistentDataContainer().get(field("id"), PersistentDataType.STRING),
afterLogout.getItemMeta().getPersistentDataContainer().get(field("id"), PersistentDataType.STRING));
assertEquals(1, Arrays.stream(contents).filter(Objects::nonNull).count());
}
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.EnumSource(value = ClickType.class, names = {"LEFT", "RIGHT", "SHIFT_LEFT", "SHIFT_RIGHT"})
void eachResultTakingModePaysForOneRechargeAndNeverDuplicatesDestroyedResults(ClickType click) throws Exception {
rechargeMatrix(compass(0));
when(roll.getAsInt()).thenReturn(0, 99);
var first = take(click);
dispatch(first);
var charged = click.isShiftClick() ? contents[0] : cursor;
assertNotNull(charged);
assertEquals(8, charges(charged));
dispatch(first);
verify(roll).getAsInt();
contents[0] = null;
cursor = null;
matrix[4] = charged;
for (int i = 0; i < 5; i++) preview();
verify(roll).getAsInt();
var second = take(click);
dispatch(second);
dispatch(second);
dispatch(take(click));
assertNull(matrix[4]);
assertNull(cursor);
assertTrue(Arrays.stream(contents).allMatch(Objects::isNull));
for (int i = 0; i < 9; i++) if (i != 4) assertEquals(14, matrix[i].getAmount());
verify(roll, times(2)).getAsInt();
verify(world).createExplosion(player, new Location(world, 10.5, 65.5, -8.5), 6F, false, true, false);
}
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.EnumSource(value = ClickType.class, names = {"NUMBER_KEY", "SWAP_OFFHAND", "DROP", "CONTROL_DROP", "DOUBLE_CLICK", "MIDDLE"})
void unsupportedBulkActionsCannotBypassPayment(ClickType click) throws Exception {
rechargeMatrix(compass(8));
dispatch(take(click));
assertEquals(8, charges(matrix[4]));
assertEquals(16, matrix[0].getAmount());
assertNull(cursor);
verifyNoInteractions(roll);
}
@Test void cancelledCraftsAndFullDestinationsDoNotRollOrPay() throws Exception {
rechargeMatrix(compass(8));
var cancelled = take(ClickType.LEFT);
cancelled.setCancelled(true);
dispatch(cancelled);
cursor = new ItemStack(Material.STONE);
dispatch(take(ClickType.LEFT));
cursor = null;
Arrays.fill(contents, new ItemStack(Material.STONE, 64));
dispatch(take(ClickType.SHIFT_LEFT));
assertEquals(8, charges(matrix[4]));
assertEquals(16, matrix[0].getAmount());
verifyNoInteractions(roll);
verify(world, never()).createExplosion(any(), any(Location.class), anyFloat(), anyBoolean(), anyBoolean(), anyBoolean());
}
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.EnumSource(value = Material.class, names = {"PAPER", "ECHO_SHARD"})
void ordinaryCompassRecipesAreNotHijacked(Material surround) throws Exception {
for (int i = 0; i < 9; i++) matrix[i] = new ItemStack(i == 4 ? Material.COMPASS : surround);
result = new ItemStack(surround == Material.PAPER ? Material.MAP : Material.RECOVERY_COMPASS);
var vanillaResult = result.clone();
preview();
assertEquals(vanillaResult, result, "Maps and recovery compasses keep their vanilla recipes");
var craft = take(ClickType.LEFT);
dispatch(craft);
assertFalse(craft.isCancelled());
}
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(booleans = {false, true})
void nativeExplosionAdapterKeepsCrafterDamageEligibleAndHonorsProtectionCancellation(boolean cancelled) throws Exception {
rechargeMatrix(compass(8));
var level = mock(net.minecraft.server.level.ServerLevel.class);
var nativeWorld = mock(org.bukkit.craftbukkit.CraftWorld.class);
var worldField = org.bukkit.craftbukkit.CraftWorld.class.getDeclaredField("world");
worldField.setAccessible(true);
worldField.set(nativeWorld, level);
var crafter = mock(org.bukkit.craftbukkit.entity.CraftPlayer.class);
var handle = mock(net.minecraft.server.level.ServerPlayer.class);
when(crafter.getHandle()).thenReturn(handle);
when(crafter.getUniqueId()).thenReturn(owner);
when(crafter.getWorld()).thenReturn(nativeWorld);
when(view.getPlayer()).thenReturn(crafter);
UUID worldId = world.getUID();
var tableBlock = world.getBlockAt(new Location(world, 10, 65, -9));
when(nativeWorld.getUID()).thenReturn(worldId);
when(nativeWorld.getBlockAt(any(Location.class))).thenReturn(tableBlock);
var table = new Location(nativeWorld, 10, 65, -9);
when(inventory.getLocation()).thenReturn(table);
var damageSources = mock(net.minecraft.world.damagesource.DamageSources.class);
var damageSource = mock(net.minecraft.world.damagesource.DamageSource.class);
when(level.damageSources()).thenReturn(damageSources);
when(damageSources.explosion(handle, handle)).thenReturn(damageSource);
var seen = new java.util.concurrent.atomic.AtomicReference<net.minecraft.world.level.ServerExplosion>();
when(level.explode0(any(), any(), isNull(), anyDouble(), anyDouble(), anyDouble(), anyFloat(), anyBoolean(), any(), any(), any(), any(), any(), any())).thenAnswer(inv -> {
var nativeExplosion = new net.minecraft.world.level.ServerExplosion(level, inv.getArgument(0), inv.getArgument(1), null,
new net.minecraft.world.phys.Vec3(inv.getArgument(3), inv.getArgument(4), inv.getArgument(5)),
inv.getArgument(6), inv.getArgument(7), net.minecraft.world.level.Explosion.BlockInteraction.DESTROY);
java.util.function.Consumer<net.minecraft.world.level.ServerExplosion> configure = inv.getArgument(13);
if (configure != null) configure.accept(nativeExplosion);
// The world/protection boundary delivers a real cancellable event. The plugin must not retry it.
var explosionEvent = new org.bukkit.event.entity.EntityExplodeEvent(crafter, table.clone().add(.5, .5, .5),
new ArrayList<>(), 1F, ExplosionResult.DESTROY);
new RegisteredListener(new org.bukkit.event.Listener() {}, (ignored, event) -> ((org.bukkit.event.entity.EntityExplodeEvent) event).setCancelled(cancelled),
org.bukkit.event.EventPriority.HIGH, mock(Plugin.class), false).callEvent(explosionEvent);
nativeExplosion.wasCanceled = explosionEvent.isCancelled();
seen.set(nativeExplosion);
return nativeExplosion;
});
doCallRealMethod().when(nativeWorld).createExplosion(any(Location.class), anyFloat(), anyBoolean(), anyBoolean(), any());
doCallRealMethod().when(nativeWorld).createExplosion(anyDouble(), anyDouble(), anyDouble(), anyFloat(), anyBoolean(), anyBoolean(), any());
doCallRealMethod().when(nativeWorld).createExplosion(any(org.bukkit.entity.Entity.class), any(Location.class), anyFloat(), anyBoolean(), anyBoolean(), anyBoolean());
when(roll.getAsInt()).thenReturn(99);
var event = take(ClickType.LEFT);
dispatch(event);
var explosion = seen.get();
assertNotNull(explosion);
assertFalse(explosion.excludeSourceFromDamage, "The crafter must not be made immune by the native overload");
assertEquals(6F, explosion.radius());
assertEquals(new net.minecraft.world.phys.Vec3(10.5, 65.5, -8.5), explosion.center());
assertSame(handle, explosion.getDirectSourceEntity());
assertSame(handle, explosion.getIndirectSourceEntity());
assertEquals(cancelled, explosion.wasCanceled);
assertNull(matrix[4]);
assertNull(cursor);
assertEquals(15, matrix[0].getAmount());
dispatch(event);
verify(roll).getAsInt();
verify(level).explode0(any(), any(), isNull(), anyDouble(), anyDouble(), anyDouble(), anyFloat(), eq(false), any(), any(), any(), any(), any(), any());
}
@Test void sharingAndKeystoneKeepIndependentOwnersRecipesAndReturnRecords() throws Exception {
var returns = mock(KeystoneReturnStore.class);
var keystoneKey = new NamespacedKey("spigotbase", "pocket_base_keystone");
var keystones = new PocketBaseKeystoneService(server, pockets, returns, keystoneKey,
java.util.logging.Logger.getAnonymousLogger());
contents[0] = compass(2);
UUID guest = UUID.randomUUID();
when(player.getUniqueId()).thenReturn(guest);
when(pockets.state(guest)).thenReturn(new PocketBaseState(guest, 1, Optional.empty()));
World bound = mock(World.class);
UUID boundId = UUID.randomUUID();
when(bound.getUID()).thenReturn(boundId);
when(pockets.ownerForPocketWorld(boundId)).thenReturn(Optional.of(owner));
var destination = new Location(bound, .5, 65, 5.5);
when(pockets.availablePocketArrival(owner)).thenReturn(Optional.of(destination));
successfulTeleport(destination);
var sharingUse = use(Action.RIGHT_CLICK_AIR, EquipmentSlot.HAND);
dispatchTo(keystones, sharingUse);
dispatch(sharingUse);
assertEquals(1, charges(contents[0]));
verifyNoInteractions(returns);
verify(pockets, never()).pocketArrival(any());
var sharing = contents[0].clone();
var keystone = PocketBaseKeystoneService.createKeystone(keystoneKey);
contents[0] = keystone;
var visitorReturn = use(Action.RIGHT_CLICK_AIR, EquipmentSlot.HAND);
dispatchTo(keystones, visitorReturn);
dispatch(visitorReturn);
verifyNoInteractions(returns); // A holder-bound Keystone cannot leave somebody else's base.
verify(player).teleport(any(Location.class), any(PlayerTeleportEvent.TeleportCause.class));
when(player.getWorld()).thenReturn(world);
var origin = new Location(world, 13, 70, 9, 35, 12);
when(player.getLocation()).thenReturn(origin);
when(world.getName()).thenReturn("overworld");
var ownPocket = new Location(mock(World.class), .5, 65, 5.5);
when(pockets.pocketArrival(guest)).thenReturn(ownPocket);
successfulTeleport(ownPocket);
var ownEntry = use(Action.RIGHT_CLICK_BLOCK, EquipmentSlot.HAND);
dispatchTo(keystones, ownEntry);
dispatch(ownEntry);
verify(returns).record(eq(guest), any(KeystoneReturnLocation.class));
verify(pockets).pocketArrival(guest);
assertEquals(keystone, contents[0]);
assertEquals(1, charges(sharing));
assertEquals(owner.toString(), sharing.getItemMeta().getPersistentDataContainer().get(field("owner"), PersistentDataType.STRING));
var recipe = PocketBaseKeystoneService.recipe(keystoneKey, keystone);
when(inventory.getRecipe()).thenReturn(recipe);
result = keystone.clone();
var prepare = new PrepareItemCraftEvent(inventory, view, false);
dispatchTo(keystones, prepare);
dispatch(prepare);
assertEquals(keystone, result);
}
@Test void sharingGuestRetainsOrdinaryInteractionsAndExistingPortalReturn() throws Exception {
var settings = PluginSettings.from(Map.of());
var controller = new PocketBaseController(server, mock(BaseStateManager.class),
new BaseBoundsService(settings), pockets, new PluginSettingsProvider(settings),
java.util.logging.Logger.getAnonymousLogger());
var item = compass(2);
contents[0] = item;
UUID guest = UUID.randomUUID();
when(player.getUniqueId()).thenReturn(guest);
when(pockets.state(guest)).thenReturn(PocketBaseState.locked(guest));
when(pockets.ownerForPocketWorld(world.getUID())).thenReturn(Optional.of(owner));
when(pockets.policy()).thenReturn(new PocketBasePolicy(settings));
var block = mock(org.bukkit.block.Block.class);
when(block.getWorld()).thenReturn(world);
when(block.getX()).thenReturn(10);
when(block.getY()).thenReturn(65);
when(block.getZ()).thenReturn(10);
var place = new org.bukkit.event.block.BlockPlaceEvent(block, mock(org.bukkit.block.BlockState.class), block,
new ItemStack(Material.STONE), player, true, EquipmentSlot.HAND);
dispatchTo(controller, place);
dispatch(place);
assertFalse(place.isCancelled());
var breaking = new org.bukkit.event.block.BlockBreakEvent(block, player);
dispatchTo(controller, breaking);
dispatch(breaking);
assertFalse(breaking.isCancelled());
var open = new InventoryOpenEvent(view);
dispatchTo(controller, open);
dispatch(open);
assertFalse(open.isCancelled());
contents[0] = null;
var interaction = use(Action.RIGHT_CLICK_BLOCK, EquipmentSlot.HAND);
dispatchTo(controller, interaction);
dispatch(interaction);
assertNotEquals(Event.Result.DENY, interaction.useItemInHand());
contents[0] = item;
var pocketReturn = new PocketPortalLocation(world.getUID(), "pocket", -2, 65, 0, PocketPortalAxis.X);
when(pockets.returnPortalIsIntact(owner)).thenReturn(true);
when(pockets.returnPortal(owner)).thenReturn(pocketReturn);
var outside = mock(World.class);
var spawn = new Location(outside, 3, 80, 3);
when(server.getWorlds()).thenReturn(List.of(outside));
when(outside.getSpawnLocation()).thenReturn(spawn);
successfulTeleport(spawn);
var move = new org.bukkit.event.player.PlayerMoveEvent(player,
new Location(world, .5, 66, 5.5), new Location(world, .5, 66, .5));
dispatchTo(controller, move);
dispatch(move);
verify(player).teleport(spawn, PlayerTeleportEvent.TeleportCause.PLUGIN);
assertEquals(2, charges(contents[0]), "Existing portal returns do not spend compass charges");
}
@Test void failedRechargeConsumesBeforeSynchronousDeathAndNeverRestoresDestroyedCompass() throws Exception {
when(roll.getAsInt()).thenReturn(99);
contents[0] = compass(40);
// compass() uses the crafting fixture; rebuild the transaction after creating carried items.
rechargeMatrix(compass(8));
var drops = new ArrayList<ItemStack>();
when(world.createExplosion(eq(player), any(Location.class), eq(6F), eq(false), eq(true), eq(false))).thenAnswer(inv -> {
assertNull(matrix[4], "The destroyed compass must be gone before explosion callbacks");
assertNull(result);
for (int i = 0; i < 9; i++) if (i != 4) assertEquals(15, matrix[i].getAmount());
drops.add(contents[0].clone());
dispatch(new org.bukkit.event.entity.PlayerDeathEvent(player, mock(org.bukkit.damage.DamageSource.class),
drops, 0, net.kyori.adventure.text.Component.text("exploded"), true));
contents[0] = null;
return true;
});
var craft = take(ClickType.SHIFT_LEFT);
dispatch(craft);
dispatch(craft);
assertNull(cursor);
assertTrue(Arrays.stream(contents).allMatch(Objects::isNull));
assertEquals(0, charges(drops.getFirst()));
assertEquals(1, drops.size());
verify(roll).getAsInt();
}
@Test void cancelledDeathLeavesCarriedCompassesAndDropsAlone() throws Exception {
contents[0] = compass(80);
var drops = new ArrayList<ItemStack>(List.of(contents[0].clone()));
var event = new org.bukkit.event.entity.PlayerDeathEvent(player, mock(org.bukkit.damage.DamageSource.class),
drops, 10, net.kyori.adventure.text.Component.text("prevented"), true);
event.setCancelled(true);
dispatch(event);
assertEquals(80, charges(contents[0]));
assertEquals(80, charges(drops.getFirst()));
}
void creationMatrix() {
for (int i = 0; i < 9; i++) matrix[i] = new ItemStack(i == 4 ? Material.END_CRYSTAL : i % 2 == 0 ? Material.DIAMOND_BLOCK : Material.ECHO_SHARD);
}
void dispatch(Event event) throws Exception { dispatchTo(service, event); }
void dispatchTo(org.bukkit.event.Listener listener, Event event) throws Exception {
for (var method : listener.getClass().getMethods()) {
var annotation = method.getAnnotation(EventHandler.class);
if (annotation != null && method.getParameterTypes()[0].isInstance(event)) {
new RegisteredListener(listener, (ignored, e) -> {
try { method.invoke(listener, e); } catch (ReflectiveOperationException ex) { throw new org.bukkit.event.EventException(ex); }
}, annotation.priority(), mock(Plugin.class), annotation.ignoreCancelled()).callEvent(event);
}
}
}
ShapedRecipe candidateRecipe() {
if (matrix[4] != null && matrix[4].getType() == Material.COMPASS && matrix[0] != null) {
if (matrix[0].getType() == Material.PAPER) {
return new ShapedRecipe(NamespacedKey.minecraft("map"), new ItemStack(Material.MAP));
}
if (matrix[0].getType() == Material.ECHO_SHARD) {
return new ShapedRecipe(NamespacedKey.minecraft("recovery_compass"), new ItemStack(Material.RECOVERY_COMPASS));
}
}
boolean creation = matrix[4] != null && matrix[4].getType() == Material.END_CRYSTAL;
return new ShapedRecipe(creation ? KEY : field("recharge"), new ItemStack(Material.COMPASS));
}
void preview() throws Exception {
// Vanilla resolves the recipe/result before PrepareItemCraftEvent is delivered.
result = candidateRecipe().getResult();
dispatch(new PrepareItemCraftEvent(inventory, view, false));
}
CraftItemEvent take(ClickType click) throws Exception {
preview();
return new CraftItemEvent(candidateRecipe(), view, InventoryType.SlotType.RESULT, 0, click,
click.isShiftClick() ? InventoryAction.MOVE_TO_OTHER_INVENTORY : InventoryAction.PICKUP_ALL);
}
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(booleans = {false, true})
void suppressedRecipePreviewAndResultCannotBeResurrected(boolean recharge) throws Exception {
if (recharge) rechargeMatrix(compass(8)); else creationMatrix();
result = null; // A preceding protection listener removed the vanilla preview.
dispatch(new PrepareItemCraftEvent(inventory, view, false));
assertNull(result, "Do not restore a result suppressed by another plugin");
var event = take(ClickType.LEFT);
result = null; // Also recheck the current result at transaction commit.
dispatch(event);
assertNull(cursor);
assertNotNull(matrix[4]);
verifyNoInteractions(roll);
}
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(booleans = {false, true})
void staleOwnedRecipeCannotFallThroughToVanillaAfterIngredientsChange(boolean recharge) throws Exception {
if (recharge) rechargeMatrix(compass(8)); else creationMatrix();
var event = take(ClickType.LEFT);
assertNotNull(result);
matrix[4] = null; // Another listener changed the matrix after native recipe resolution.
dispatch(event);
assertTrue(event.isCancelled(), "An owned result must never fall through to vanilla payment");
assertNull(result, "Remove a stale owned preview rather than returning an unpaid result");
assertNull(cursor);
verifyNoInteractions(roll);
}
@Test void foreignRecipeWithSameIngredientsIsNotReplacedOrManuallyCrafted() throws Exception {
creationMatrix();
var foreign = new ShapedRecipe(new NamespacedKey("other", "recipe"), new ItemStack(Material.DIAMOND));
when(inventory.getRecipe()).thenReturn(foreign);
result = foreign.getResult();
dispatch(new PrepareItemCraftEvent(inventory, view, false));
assertEquals(Material.DIAMOND, result.getType());
var event = new CraftItemEvent(foreign, view, InventoryType.SlotType.RESULT, 0,
ClickType.LEFT, InventoryAction.PICKUP_ALL);
dispatch(event);
assertFalse(event.isCancelled());
assertNull(cursor);
assertNotNull(matrix[4]);
}
@Test void takingCreationPaysOnceBindsCrafterAndRejectsLockedAndCancelledCrafts() throws Exception {
creationMatrix();
preview();
var cancelled = take(ClickType.LEFT);
cancelled.setCancelled(true);
dispatch(cancelled);
assertNull(cursor);
assertEquals(1, matrix[0].getAmount());
var event = take(ClickType.LEFT);
dispatch(event);
assertNotNull(cursor, "Taking the result must commit one paid compass");
assertTrue(event.isCancelled(), "Vanilla result-taking must not also produce an item");
assertTrue(Arrays.stream(matrix).allMatch(Objects::isNull));
assertEquals(owner.toString(), cursor.getItemMeta().getPersistentDataContainer().get(new NamespacedKey("spigotbase", "sharing_compass_owner"), PersistentDataType.STRING));
var paid = cursor.clone();
dispatch(take(ClickType.SHIFT_LEFT));
assertTrue(Arrays.stream(contents).allMatch(Objects::isNull));
assertEquals(paid, cursor);
cursor = null;
creationMatrix();
when(pockets.state(owner)).thenReturn(PocketBaseState.locked(owner));
dispatch(take(ClickType.LEFT));
assertNull(cursor);
assertEquals(1, matrix[4].getAmount());
}
@Test void unlockedOwnerGetsEmptyMetadataBoundPreviewFromExactRecipeOnly() throws Exception {
creationMatrix();
preview();
assertNotNull(result, "The approved recipe must preview a sharing compass");
assertEquals(Material.COMPASS, result.getType());
var data = result.getItemMeta().getPersistentDataContainer();
assertEquals((byte) 1, data.get(KEY, PersistentDataType.BYTE));
assertEquals(owner.toString(), data.get(new NamespacedKey("spigotbase", "sharing_compass_owner"), PersistentDataType.STRING));
assertEquals(0L, data.get(new NamespacedKey("spigotbase", "sharing_compass_charges"), PersistentDataType.LONG));
verifyNoInteractions(roll);
assertEquals(1, matrix[4].getAmount());
matrix[0] = new ItemStack(Material.DIAMOND);
preview();
assertNull(result);
creationMatrix();
when(pockets.state(owner)).thenReturn(PocketBaseState.locked(owner));
preview();
assertNull(result);
}
}
@@ -0,0 +1 @@
mock-maker-inline
@@ -0,0 +1,157 @@
package games.dmg.spigotbase;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.logging.Logger;
import net.kyori.adventure.text.Component;
import org.bukkit.Location;
import org.bukkit.Server;
import org.bukkit.World;
import org.bukkit.entity.Player;
import org.bukkit.event.player.PlayerChangedWorldEvent;
import org.bukkit.event.player.PlayerMoveEvent;
import org.bukkit.event.player.PlayerQuitEvent;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
class BaseIdentificationControllerTest {
private final UUID owner = UUID.randomUUID(), visitor = UUID.randomUUID(), worldId = UUID.randomUUID();
private final Server server = mock(Server.class);
private final World world = mock(World.class);
private final Player player = mock(Player.class);
private final BaseStateManager states = mock(BaseStateManager.class);
private final BukkitBaseLabels labels = mock(BukkitBaseLabels.class);
private final PlayerState initial = PlayerState.newPlayer(owner, "Alice")
.withAdministrativeLevels(1, 0, 0, 0, 0, false, false, false)
.withBase(new BaseLocation(worldId, "world", 100, 64, -100, 0, 0), Instant.EPOCH);
private final BaseIdentificationController controller = new BaseIdentificationController(server, states,
new BaseBoundsService(PluginSettings.from(Map.of())), labels, Logger.getAnonymousLogger());
BaseIdentificationControllerTest() {
when(world.getUID()).thenReturn(worldId);
when(player.getUniqueId()).thenReturn(visitor);
when(player.getWorld()).thenReturn(world);
when(player.isOnline()).thenReturn(true);
when(player.getLocation()).thenReturn(at(100, 64));
doReturn(List.of(player)).when(server).getOnlinePlayers();
when(states.knownPlayers()).thenReturn(Map.of(owner, initial));
}
@Test
@SuppressWarnings("unchecked")
void readsOfflineOwnersWithoutPermissionsOrStateMutationAndSendsOnePrivateEntry() {
controller.run();
controller.run();
var frames = ArgumentCaptor.forClass(Map.class);
verify(labels, times(2)).render(eq(player), frames.capture());
var frame = (Map<UUID, BaseIdentification.Label>) frames.getValue();
assertEquals("Base: Alice", frame.get(owner).text());
assertEquals(100.5, frame.get(owner).x());
verify(player).sendActionBar(Component.text("Entering Alice's base"));
verify(player, never()).sendMessage(anyString());
verify(player, never()).hasPermission(anyString());
verify(states, times(2)).knownPlayers();
verifyNoMoreInteractions(states);
verify(server, times(2)).getOnlinePlayers();
verifyNoMoreInteractions(server); // No offline profile, disk, world or chunk lookup.
}
@Test
@SuppressWarnings("unchecked")
void pollsUpdatedExpansionsRelocationsResetsAndKeepsTheOwnerExcluded() {
when(player.getLocation()).thenReturn(at(200, 64));
controller.run();
verify(labels).render(player, Map.of());
var expanded = initial.withAdministrativeLevels(1, 3, 0, 0, 0, false, false, false);
when(states.knownPlayers()).thenReturn(Map.of(owner, expanded));
controller.run();
var frames = ArgumentCaptor.forClass(Map.class);
verify(labels, times(2)).render(eq(player), frames.capture());
assertTrue(frames.getValue().containsKey(owner));
var moved = expanded.withBase(new BaseLocation(worldId, "world", 500, 64, -100, 0, 0), Instant.EPOCH);
when(states.knownPlayers()).thenReturn(Map.of(owner, moved));
controller.run();
verify(labels, times(2)).render(player, Map.of());
when(states.knownPlayers()).thenReturn(Map.of());
controller.run();
verify(labels, times(3)).render(player, Map.of());
when(states.knownPlayers()).thenReturn(Map.of(owner, initial));
when(player.getUniqueId()).thenReturn(owner);
when(player.getLocation()).thenReturn(at(100, 64));
controller.run();
verify(labels, times(4)).render(player, Map.of());
}
@Test
void moveRoutingIgnoresCancellationAndEntryJitterButCatchesEntryBetweenPolls() {
when(player.getLocation()).thenReturn(at(126, 64));
controller.run();
var cancelled = new PlayerMoveEvent(player, at(126, 64), at(100, 64));
cancelled.setCancelled(true);
controller.onMove(cancelled);
verify(player, never()).sendActionBar(any(Component.class));
controller.onMove(new PlayerMoveEvent(player, at(126, 64), at(100, 64)));
controller.onMove(new PlayerMoveEvent(player, at(100, 64), at(111, 64)));
controller.onMove(new PlayerMoveEvent(player, at(111, 64), at(100, 64)));
verify(player).sendActionBar(Component.text("Entering Alice's base"));
}
@Test
void disconnectWorldChangeAndDisableCleanUpAndReleaseVisitSuppression() {
controller.run();
controller.onQuit(new PlayerQuitEvent(player, Component.empty()));
verify(labels).forget(visitor);
controller.run();
verify(player, times(2)).sendActionBar(Component.text("Entering Alice's base"));
controller.onWorldChange(new PlayerChangedWorldEvent(player, world));
verify(labels, times(2)).forget(visitor);
controller.run();
verify(player, times(3)).sendActionBar(Component.text("Entering Alice's base"));
controller.close();
verify(labels).clear(player);
verify(labels).forgetAll();
clearInvocations(labels, player, states, server);
controller.run();
verifyNoInteractions(labels, player, states, server);
}
@Test
void teleportHasItsOwnBukkitHandlerAndNotifiesOnSameWorldEntry() throws Exception {
when(player.getLocation()).thenReturn(at(300, 64));
controller.run();
var teleport = new org.bukkit.event.player.PlayerTeleportEvent(player, at(300, 64), at(100, 64));
teleport.setCancelled(true);
controller.onTeleport(teleport);
verify(player, never()).sendActionBar(any(Component.class));
teleport.setCancelled(false);
controller.onTeleport(teleport);
verify(player).sendActionBar(Component.text("Entering Alice's base"));
var handler = BaseIdentificationController.class.getMethod("onTeleport",
org.bukkit.event.player.PlayerTeleportEvent.class).getAnnotation(org.bukkit.event.EventHandler.class);
assertNotNull(handler, "Teleport has a separate HandlerList from ordinary movement");
assertTrue(handler.ignoreCancelled());
assertEquals(org.bukkit.event.EventPriority.MONITOR, handler.priority());
}
@Test
void respawnAndMissingQuitEventsCannotRetainLabelsOrVisitSessions() {
controller.run();
var respawn = mock(org.bukkit.event.player.PlayerRespawnEvent.class);
when(respawn.getPlayer()).thenReturn(player);
controller.onRespawn(respawn);
verify(labels).clear(player);
verify(labels).forget(visitor);
controller.run();
verify(player, times(2)).sendActionBar(Component.text("Entering Alice's base"));
doReturn(List.of()).when(server).getOnlinePlayers();
controller.run();
verify(labels, times(2)).forget(visitor);
}
private Location at(int x, int y) { return new Location(world, x, y, -100); }
}
@@ -0,0 +1,75 @@
package games.dmg.spigotbase;
import static org.junit.jupiter.api.Assertions.*;
import java.util.List;
import java.util.UUID;
import org.junit.jupiter.api.Test;
class BaseIdentificationTest {
private final UUID world = UUID.randomUUID(), owner = UUID.randomUUID(), visitor = UUID.randomUUID();
private final BaseLocation center = new BaseLocation(world, "world", 100, 64, -100, 0, 0);
@Test
void labelUsesActualCircularBoundsAndVerticalLimitsButStaysAtRecordedCenter() {
var identification = new BaseIdentification();
identification.setBases(List.of(new BaseIdentification.Base(owner, "Alice", new BaseArea(center, 10, 25))));
assertTrue(identification.update(viewer(owner, 100, 64, -100)).labels().isEmpty(), "No own-base label");
var near = identification.update(viewer(visitor, 126, 64, -100));
assertEquals(1, near.labels().size(), "Exactly 16 blocks beyond the boundary is visible");
var label = near.labels().get(owner);
assertEquals("Base: Alice", label.text());
assertEquals(world, label.world());
assertEquals(100.5, label.x());
assertEquals(66.25, label.y());
assertEquals(-99.5, label.z());
assertEquals(label, identification.update(viewer(visitor, 100, 64, -100)).labels().get(owner),
"Moving the visitor cannot move the label anchor");
assertTrue(identification.update(viewer(visitor, 127, 64, -100)).labels().isEmpty());
assertTrue(identification.update(viewer(visitor, 126, 64, -74)).labels().isEmpty(), "Circular, not square");
assertFalse(identification.update(viewer(visitor, 100, 89, -100)).labels().isEmpty());
assertTrue(identification.update(viewer(visitor, 100, 90, -100)).labels().isEmpty());
assertTrue(identification.update(new BaseIdentification.Viewer(visitor, UUID.randomUUID(), 100, 64, -100)).labels().isEmpty());
}
@Test
void entryNotificationsRearmOnlyAfterLeavingTheVicinity() {
var identification = new BaseIdentification();
identification.setBases(List.of(new BaseIdentification.Base(owner, "Alice", new BaseArea(center, 10, 25))));
assertTrue(identification.update(viewer(visitor, 115, 64, -100)).entries().isEmpty());
assertEquals(List.of("Entering Alice's base"), identification.update(viewer(visitor, 110, 64, -100)).entries());
assertTrue(identification.update(viewer(visitor, 100, 64, -100)).entries().isEmpty());
identification.update(viewer(visitor, 111, 64, -100));
assertTrue(identification.update(viewer(visitor, 110, 64, -100)).entries().isEmpty());
identification.update(viewer(visitor, 100, 90, -100));
assertTrue(identification.update(viewer(visitor, 100, 89, -100)).entries().isEmpty(), "Vertical edge jitter also stays quiet");
identification.update(viewer(visitor, 127, 64, -100));
assertEquals(List.of("Entering Alice's base"), identification.update(viewer(visitor, 100, 64, -100)).entries());
identification.forget(visitor);
assertEquals(List.of("Entering Alice's base"), identification.update(viewer(visitor, 100, 64, -100)).entries());
}
@Test
void coincidentOwnersHaveDistinctStableLabelsAndExpansionUsesCurrentBounds() {
UUID other = UUID.randomUUID();
var identification = new BaseIdentification();
var first = new BaseIdentification.Base(owner, "Alice", new BaseArea(center, 10, 25));
var second = new BaseIdentification.Base(other, "Bob", new BaseArea(center, 10, 25));
identification.setBases(List.of(first, second));
var frame = identification.update(viewer(visitor, 100, 64, -100));
assertEquals(2, frame.labels().size());
assertNotEquals(frame.labels().get(owner).y(), frame.labels().get(other).y());
assertEquals(100.5, frame.labels().get(other).x());
identification.setBases(List.of(second, first));
assertEquals(frame.labels(), identification.update(viewer(visitor, 100, 64, -100)).labels(), "Input iteration order cannot move labels");
identification.setBases(List.of(new BaseIdentification.Base(owner, "Alice", new BaseArea(center, 150, Integer.MAX_VALUE))));
assertFalse(identification.update(viewer(visitor, 266, -64, -100)).labels().isEmpty());
assertTrue(identification.update(viewer(visitor, 267, -64, -100)).labels().isEmpty());
identification.setBases(List.of());
assertTrue(identification.update(viewer(visitor, 100, 64, -100)).labels().isEmpty());
}
private BaseIdentification.Viewer viewer(UUID id, int x, int y, int z) {
return new BaseIdentification.Viewer(id, world, x, y, z);
}
}
@@ -0,0 +1,62 @@
package games.dmg.spigotbase;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import java.nio.file.Path;
import java.util.List;
import java.util.logging.Logger;
import org.bukkit.Server;
import org.bukkit.command.PluginCommand;
import org.bukkit.configuration.file.YamlConfiguration;
import org.bukkit.plugin.PluginManager;
import org.bukkit.scheduler.BukkitScheduler;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.ArgumentCaptor;
class BaseIdentificationWiringTest {
@TempDir Path directory;
@Test
void enableRegistersAndSchedulesIdentificationAndDisableClosesIt() {
var plugin = mock(SpigotBasePlugin.class);
var server = mock(Server.class);
var manager = mock(PluginManager.class);
var scheduler = mock(BukkitScheduler.class);
when(plugin.getServer()).thenReturn(server);
when(plugin.getName()).thenReturn("SpigotBase");
when(plugin.namespace()).thenReturn("spigotbase");
when(plugin.getLogger()).thenReturn(Logger.getAnonymousLogger());
when(plugin.getConfig()).thenReturn(new YamlConfiguration());
when(plugin.getDataFolder()).thenReturn(directory.toFile());
when(plugin.getCommand(anyString())).thenReturn(mock(PluginCommand.class));
when(server.getPluginManager()).thenReturn(manager);
when(server.getScheduler()).thenReturn(scheduler);
doReturn(List.of()).when(server).getOnlinePlayers();
doCallRealMethod().when(plugin).onEnable();
doCallRealMethod().when(plugin).onDisable();
try (var validator = mockStatic(PluginSettingsValidator.class);
var keystones = mockConstruction(PocketBaseKeystoneService.class);
var sharing = mockConstruction(PocketBaseSharingCompass.class);
var packets = mockConstruction(NativeBaseLabelPackets.class);
var labels = mockConstruction(BukkitBaseLabels.class)) {
validator.when(() -> PluginSettingsValidator.validateMaterials(any())).thenAnswer(call -> call.getArgument(0));
plugin.onEnable();
assertEquals(1, keystones.constructed().size());
assertEquals(1, sharing.constructed().size(), "Enable must install guest compasses alongside Keystones");
verify(sharing.constructed().getFirst()).registerRecipes();
verify(manager).registerEvents(sharing.constructed().getFirst(), plugin);
verify(manager, never()).disablePlugin(plugin);
var tasks = ArgumentCaptor.forClass(Runnable.class);
verify(scheduler, atLeastOnce()).runTaskTimer(eq(plugin), tasks.capture(), anyLong(), anyLong());
var identification = tasks.getAllValues().stream().filter(BaseIdentificationController.class::isInstance).toList();
assertEquals(1, identification.size(), "Enable must install the actual label controller");
verify(manager).registerEvents((BaseIdentificationController) identification.getFirst(), plugin);
identification.getFirst().run();
plugin.onDisable();
assertEquals(1, packets.constructed().size());
verify(labels.constructed().getFirst()).forgetAll();
}
}
}
@@ -0,0 +1,108 @@
package games.dmg.spigotbase;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import net.kyori.adventure.text.Component;
import org.bukkit.Location;
import org.bukkit.World;
import org.bukkit.entity.Display;
import org.bukkit.entity.Player;
import org.bukkit.entity.TextDisplay;
import org.junit.jupiter.api.Test;
class BukkitBaseLabelsTest {
private final World world = mock(World.class);
private final Player viewer = mock(Player.class);
private final TextDisplay display = mock(TextDisplay.class);
private final BukkitBaseLabels.Packets packets = mock(BukkitBaseLabels.Packets.class);
private final UUID owner = UUID.randomUUID();
private final BaseIdentification.Label label;
private final Object spawn = new Object(), metadata = new Object(), remove = new Object();
private final BukkitBaseLabels labels = new BukkitBaseLabels(packets);
BukkitBaseLabelsTest() {
UUID worldId = UUID.randomUUID();
when(world.getUID()).thenReturn(worldId);
when(viewer.getUniqueId()).thenReturn(UUID.randomUUID());
when(viewer.getWorld()).thenReturn(world);
label = new BaseIdentification.Label(owner, "Base: Alice", worldId, 100.5, 66.25, -99.5, 16);
when(world.createEntity(any(Location.class), eq(TextDisplay.class))).thenReturn(display);
when(packets.nextId(world)).thenReturn(42, 43, 44);
when(packets.spawn(eq(display), any(), anyInt())).thenReturn(List.of(spawn, metadata));
when(packets.remove(anyInt())).thenReturn(remove);
}
@Test
void createsOnlyPrivateUnspawnedDisplaysAtTheAnchorAndDoesNotRepeatStableFrames() {
labels.render(viewer, Map.of(owner, label));
labels.render(viewer, Map.of(owner, label));
verify(world).createEntity(new Location(world, 100.5, 66.25, -99.5), TextDisplay.class);
verify(display).text(Component.text("Base: Alice"));
verify(display).setBillboard(Display.Billboard.CENTER);
verify(display).setViewRange(16);
verify(display).setLineWidth(512); // UUID fallback names must fit on one stacked label line.
verify(display).setPersistent(false);
verify(packets).nextId(world);
verify(packets).spawn(display, label, 42);
var order = inOrder(packets);
order.verify(packets).send(viewer, spawn);
order.verify(packets).send(viewer, metadata);
verify(world, atLeastOnce()).getUID();
verifyNoMoreInteractions(world); // No spawn, chunk lookup, chunk load or world entity registration.
verify(viewer, never()).getLocation(); // Anchor does not follow the visitor.
verify(display, never()).getEntityId(); // Unspawned displays have no assigned native ID.
}
@Test
void removesOnExitReplacesChangedLabelsAndClearsOnDisable() {
labels.render(viewer, Map.of(owner, label));
var changed = new BaseIdentification.Label(owner, "Base: Renamed", label.world(), 200.5, 66.25, -99.5, 16);
labels.render(viewer, Map.of(owner, changed));
verify(packets).remove(42);
verify(packets).spawn(display, changed, 43);
labels.render(viewer, Map.of());
verify(packets).remove(43);
labels.render(viewer, Map.of(owner, label));
labels.clear(viewer);
verify(packets).remove(44);
clearInvocations(packets);
labels.clear(viewer);
verifyNoInteractions(packets);
}
@Test
void worldTransitionAndDisconnectForgetWithoutSendingOldIdsIntoNewConnections() {
labels.render(viewer, Map.of(owner, label));
var other = mock(World.class);
when(other.getUID()).thenReturn(UUID.randomUUID());
when(viewer.getWorld()).thenReturn(other);
clearInvocations(packets);
labels.render(viewer, Map.of(owner, label));
verifyNoInteractions(packets);
when(viewer.getWorld()).thenReturn(world);
labels.render(viewer, Map.of(owner, label));
labels.forget(viewer.getUniqueId());
clearInvocations(packets);
labels.clear(viewer);
verifyNoInteractions(packets);
}
@Test
void failedPartialSpawnIsRemovedBeforeRetryRatherThanLeakingOrDuplicating() {
doThrow(new IllegalStateException("closed channel")).doNothing().when(packets).send(viewer, metadata);
assertThrows(IllegalStateException.class, () -> labels.render(viewer, Map.of(owner, label)));
labels.render(viewer, Map.of(owner, label));
var order = inOrder(packets);
order.verify(packets).send(viewer, spawn);
order.verify(packets).send(viewer, metadata);
order.verify(packets).remove(42);
order.verify(packets).send(viewer, remove);
order.verify(packets).spawn(display, label, 43);
order.verify(packets).send(viewer, spawn);
order.verify(packets).send(viewer, metadata);
}
}
@@ -0,0 +1,49 @@
package games.dmg.spigotbase;
import static org.junit.jupiter.api.Assertions.*;
import java.io.DataInputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
import java.util.jar.JarFile;
import org.junit.jupiter.api.Test;
import org.yaml.snakeyaml.Yaml;
/** Distribution branding is deliberately independent of persistent/runtime plugin identity. */
class PurpurDistributionTest {
@Test
void packagesJava25UnderNewNameWithoutChangingRuntimeIdentityOrBundlingTheApi() throws Exception {
Path artifact = Path.of(System.getProperty("distribution.jar"));
String version = System.getProperty("distribution.version");
assertEquals("purpur-base-" + version + ".jar", artifact.getFileName().toString());
try (var jar = new JarFile(artifact.toFile())) {
Map<?, ?> metadata = new Yaml().load(jar.getInputStream(jar.getJarEntry("plugin.yml")));
assertEquals(version, metadata.get("version"));
assertEquals("SpigotBase", metadata.get("name"), "Do not orphan the existing plugin data folder or default namespace");
assertEquals("games.dmg.spigotbase.SpigotBasePlugin", metadata.get("main"));
assertTrue(((Map<?, ?>) metadata.get("permissions")).containsKey("spigotbase.admin"));
try (var bytecode = new DataInputStream(jar.getInputStream(
jar.getJarEntry("games/dmg/spigotbase/SpigotBasePlugin.class")))) {
assertEquals(0xCAFEBABE, bytecode.readInt());
bytecode.readUnsignedShort();
assertEquals(69, bytecode.readUnsignedShort(), "Plugin bytecode must target Java 25");
}
assertFalse(jar.stream().anyMatch(entry -> entry.getName().startsWith("org/bukkit/")
|| entry.getName().startsWith("org/purpurmc/") || entry.getName().endsWith("Test.class")));
}
}
@Test
void releaseAndCiAgreeWithThePurpurDistributionNameAndToolchain() throws Exception {
String build = Files.readString(Path.of("build.gradle.kts"));
assertTrue(build.contains("purpur-api:26.2.build.2618-stable"));
assertFalse(build.contains("org.spigotmc:spigot-api"));
for (String workflow : new String[] {"ci.yml", "release.yml"}) {
String content = Files.readString(Path.of(".gitea/workflows", workflow));
assertTrue(content.contains("java-version: 25"));
assertTrue(content.contains("purpur-base-"), workflow + " must publish the renamed distribution");
assertFalse(content.contains("spigot-base-"), workflow + " must not look for the old JAR filename");
}
}
}
@@ -0,0 +1,35 @@
package games.dmg.spigotbase;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import java.nio.file.Path;
import java.util.Map;
import java.util.UUID;
import org.bukkit.Location;
import org.bukkit.Server;
import org.bukkit.World;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class SharingCompassDestinationTest {
@TempDir Path directory;
@Test void destinationUsesPersistedUnlockAndLoadedWorldWithoutCreatingMissingWorlds() throws Exception {
UUID owner = UUID.randomUUID();
var repository = new YamlPocketBaseRepository(directory.resolve("pockets.yml"));
var states = new PocketBaseStateManager(repository);
states.updateAndSave(owner, state -> state.withLevel(1));
var server = mock(Server.class);
var settings = new PluginSettingsProvider(PluginSettings.from(Map.of()));
var worlds = new PocketBaseWorldService(server, settings);
var manager = new PocketBaseManager(new PocketBaseStateManager(repository), worlds, settings);
assertTrue(manager.availablePocketArrival(owner).isEmpty());
verify(server, never()).createWorld(any());
var world = mock(World.class);
when(server.getWorld("spigot_base_pocket_" + owner.toString().replace("-", ""))).thenReturn(world);
assertEquals(new Location(world, .5, 65, 5.5, 180F, 0F), manager.availablePocketArrival(owner).orElseThrow());
assertTrue(manager.availablePocketArrival(UUID.randomUUID()).isEmpty());
verify(server, never()).createWorld(any());
}
}