Compare commits

..
6 Commits
Author SHA1 Message Date
dmg ffecd38e99 fix(base): support Purpur 26.3
Release / release (push) Successful in 2m0s
CI / build (push) Successful in 2m1s
2026-10-16 23:53:40 +00:00
dmg 5949eff7fd fix(base): support Purpur 26.3
CI / build (pull_request) Successful in 2m11s
CI / build (push) Successful in 2m8s
2026-10-16 19:50:38 -04:00
dmg 0fe6a14451 feat(base): discover pocket base travel recipes
Release / release (push) Successful in 4m59s
CI / build (push) Successful in 1m14s
2026-09-20 17:00:41 -04:00
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
33 changed files with 3920 additions and 25 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.
+11 -5
View File
@@ -1,14 +1,16 @@
# Spigot Base
# Purpur Base
A Purpur 26.2 plugin providing progression-gated player bases and quality-of-life unlocks.
A Purpur 26.3 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
- Purpur 26.2 build 2618
- Purpur 26.3 build 2651
- Java 25 or newer
## Build
@@ -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 2651 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.
+61 -2
View File
@@ -1,3 +1,6 @@
import java.net.URI
import java.security.MessageDigest
plugins {
java
}
@@ -23,9 +26,9 @@ tasks.withType<JavaCompile>().configureEach {
}
dependencies {
compileOnly("org.purpurmc.purpur:purpur-api:26.2.build.2618-stable")
compileOnly("org.purpurmc.purpur:purpur-api:26.3.build.2651-stable")
testImplementation("org.purpurmc.purpur:purpur-api:26.2.build.2618-stable")
testImplementation("org.purpurmc.purpur:purpur-api:26.3.build.2651-stable")
testImplementation(platform("org.junit:junit-bom:5.13.4"))
testImplementation("org.junit.jupiter:junit-jupiter")
testImplementation("org.mockito:mockito-core:5.18.0")
@@ -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.3-2651.jar") }
outputs.file(launcher)
doLast {
val file = launcher.get().asFile
file.parentFile.mkdirs()
val connection = URI("https://api.purpurmc.org/v2/purpur/26.3/2651/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 == "0677c1febecfbc2e5b0a7f76abd647b70a1f38d3cbdf3dd6a82e401b4262a598") {
"Purpur 2651 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.3-2651.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();
}
}
@@ -29,6 +29,7 @@ final class BaseSettingsCommand implements CommandExecutor, TabCompleter {
private final TeleportPolicy teleportPolicy;
private final BaseFlightController flightController;
private final PocketBaseManager pocketBases;
private final PocketBaseRecipeDiscovery recipeDiscovery;
private final DialogProvider dialogProvider;
BaseSettingsCommand(
@@ -44,12 +45,26 @@ final class BaseSettingsCommand implements CommandExecutor, TabCompleter {
PluginSettingsProvider settings,
BaseFlightController flightController,
PocketBaseManager pocketBases
) {
this(
stateManager, settings, flightController, pocketBases,
(PocketBaseRecipeDiscovery) null
);
}
BaseSettingsCommand(
BaseStateManager stateManager,
PluginSettingsProvider settings,
BaseFlightController flightController,
PocketBaseManager pocketBases,
PocketBaseRecipeDiscovery recipeDiscovery
) {
this(
stateManager,
settings,
flightController,
pocketBases,
recipeDiscovery,
(owner, pocket) -> new BaseSettingsDialogFactory(settings.current())
.create(owner, pocket)
);
@@ -61,12 +76,24 @@ final class BaseSettingsCommand implements CommandExecutor, TabCompleter {
BaseFlightController flightController,
PocketBaseManager pocketBases,
DialogProvider dialogProvider
) {
this(stateManager, settings, flightController, pocketBases, null, dialogProvider);
}
BaseSettingsCommand(
BaseStateManager stateManager,
PluginSettingsProvider settings,
BaseFlightController flightController,
PocketBaseManager pocketBases,
PocketBaseRecipeDiscovery recipeDiscovery,
DialogProvider dialogProvider
) {
this.stateManager = stateManager;
this.settings = settings;
this.teleportPolicy = new TeleportPolicy(settings);
this.flightController = flightController;
this.pocketBases = pocketBases;
this.recipeDiscovery = recipeDiscovery;
this.dialogProvider = dialogProvider;
}
@@ -202,6 +229,9 @@ final class BaseSettingsCommand implements CommandExecutor, TabCompleter {
+ "The Pocket Base upgrade failed; your payment was restored.");
return true;
}
if (recipeDiscovery != null) {
recipeDiscovery.discoverCreation(player);
}
player.sendTitle(
ChatColor.GOLD + "Pocket Base " + upgraded.level() + " Unlocked",
ChatColor.YELLOW + "Build a portal inside your base",
@@ -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);
}
}
}
@@ -5,6 +5,8 @@ import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.function.BooleanSupplier;
import java.util.function.Predicate;
import java.util.logging.Level;
import java.util.logging.Logger;
import net.kyori.adventure.text.Component;
@@ -26,8 +28,10 @@ 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.inventory.meta.ItemMeta;
import org.bukkit.inventory.recipe.CraftingBookCategory;
import org.bukkit.persistence.PersistentDataType;
final class PocketBaseKeystoneService implements Listener {
@@ -46,6 +50,8 @@ final class PocketBaseKeystoneService implements Listener {
private final KeystoneReturnStore returns;
private final NamespacedKey markerKey;
private final Logger logger;
private BooleanSupplier recipesAvailable = () -> true;
private Predicate<Recipe> recipeControlled = this::ourRecipe;
PocketBaseKeystoneService(
Server server,
@@ -61,18 +67,30 @@ final class PocketBaseKeystoneService implements Listener {
this.logger = logger;
}
void setRecipesAvailable(BooleanSupplier available) {
this.recipesAvailable = available;
}
void setRecipeControl(Predicate<Recipe> controlled) {
this.recipeControlled = controlled;
}
@EventHandler(priority = EventPriority.HIGH)
public void onPrepareCraft(PrepareItemCraftEvent event) {
if (!(event.getRecipe() instanceof ShapedRecipe shaped)
|| !shaped.getKey().equals(markerKey)
|| !(event.getView().getPlayer() instanceof Player player)) {
if (!(event.getView().getPlayer() instanceof Player player)
|| !recipeControlled.test(event.getRecipe())) {
return;
}
if (pocketBases.state(player.getUniqueId()).level() < 1) {
if (!recipesAvailable.getAsBoolean()
|| pocketBases.state(player.getUniqueId()).level() < 1) {
event.getInventory().setResult(null);
}
}
private boolean ourRecipe(Recipe recipe) {
return recipe instanceof ShapedRecipe shaped && shaped.getKey().equals(markerKey);
}
@EventHandler(priority = EventPriority.HIGH)
public void onUse(PlayerInteractEvent event) {
Action action = event.getAction();
@@ -200,8 +218,12 @@ final class PocketBaseKeystoneService implements Listener {
return marker != null && marker == (byte) 1;
}
ShapedRecipe recipe() {
return recipe(markerKey, createKeystone(markerKey));
}
void registerRecipe() {
server.addRecipe(recipe(markerKey, createKeystone(markerKey)));
server.addRecipe(recipe());
}
static ItemStack createKeystone(NamespacedKey markerKey) {
@@ -244,6 +266,7 @@ final class PocketBaseKeystoneService implements Listener {
recipe.setIngredient('O', specification.obsidian());
recipe.setIngredient('N', specification.netherite());
recipe.setIngredient('E', specification.enderPearl());
recipe.setCategory(CraftingBookCategory.EQUIPMENT);
return recipe;
}
@@ -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,117 @@
package games.dmg.spigotbase;
import java.util.List;
import java.util.Objects;
import java.util.logging.Level;
import java.util.logging.Logger;
import org.bukkit.NamespacedKey;
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.PlayerJoinEvent;
import org.bukkit.inventory.ItemStack;
/** Awards recipe-book knowledge; crafting handlers remain the authority. */
final class PocketBaseRecipeDiscovery implements Listener {
private final PocketBaseRecipeRegistry registry;
private final PocketBaseManager pockets;
private final PocketBaseSharingCompass sharing;
private final List<NamespacedKey> creationKeys;
private final NamespacedKey rechargeKey;
private final Logger logger;
PocketBaseRecipeDiscovery(
PocketBaseRecipeRegistry registry,
PocketBaseManager pockets,
PocketBaseSharingCompass sharing,
NamespacedKey keystoneKey,
NamespacedKey sharingCreationKey,
NamespacedKey rechargeKey,
Logger logger
) {
this.registry = Objects.requireNonNull(registry, "registry");
this.pockets = Objects.requireNonNull(pockets, "pockets");
this.sharing = Objects.requireNonNull(sharing, "sharing");
this.creationKeys = List.of(keystoneKey, sharingCreationKey);
this.rechargeKey = Objects.requireNonNull(rechargeKey, "recharge key");
this.logger = Objects.requireNonNull(logger, "logger");
}
void reconcileOnline(Iterable<? extends Player> players) {
for (Player player : players) {
reconcile(player);
}
}
@EventHandler(priority = EventPriority.MONITOR)
public void onJoin(PlayerJoinEvent event) {
reconcile(event.getPlayer());
}
void reconcile(Player player) {
try {
sharing.normalizeCarried(player);
if (!registry.isAvailable()) {
return;
}
if (pockets.state(player.getUniqueId()).level() > 0) {
for (NamespacedKey key : creationKeys) {
awardSafely(player, key);
}
}
if (carriesAuthenticCompass(player)) {
awardSafely(player, rechargeKey);
}
} catch (RuntimeException exception) {
logger.log(Level.WARNING,
"Could not reconcile Pocket Base recipe discovery for " + player.getUniqueId(),
exception);
}
}
void discoverCreation(Player player) {
if (!registry.isAvailable()) {
return;
}
for (NamespacedKey key : creationKeys) {
awardSafely(player, key);
}
}
void discoverRecharge(Player player) {
if (registry.isAvailable()) {
awardSafely(player, rechargeKey);
}
}
private boolean carriesAuthenticCompass(Player player) {
for (ItemStack item : player.getInventory().getStorageContents()) {
if (sharing.isAuthentic(item)) {
return true;
}
}
return sharing.isAuthentic(player.getInventory().getItemInOffHand());
}
private void awardSafely(Player player, NamespacedKey key) {
try {
award(player, key);
} catch (RuntimeException exception) {
logger.log(Level.WARNING,
"Could not discover Pocket Base recipe " + key + " for "
+ player.getUniqueId(), exception);
}
}
private void award(Player player, NamespacedKey key) {
if (player.hasDiscoveredRecipe(key)) {
return;
}
player.discoverRecipe(key);
if (!player.hasDiscoveredRecipe(key)) {
logger.warning("Pocket Base recipe discovery postcondition was not met for "
+ player.getUniqueId() + " and " + key + "; a later reconciliation will retry");
}
}
}
@@ -0,0 +1,240 @@
package games.dmg.spigotbase;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import java.util.logging.Level;
import java.util.logging.Logger;
import org.bukkit.Keyed;
import org.bukkit.NamespacedKey;
import org.bukkit.Server;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.Recipe;
import org.bukkit.inventory.RecipeChoice;
import org.bukkit.inventory.ShapedRecipe;
/** Owns only recipes successfully added during this plugin lifecycle. */
final class PocketBaseRecipeRegistry implements AutoCloseable {
private static final int REQUIRED_RECIPE_COUNT = 3;
private final Server server;
private final List<Recipe> recipes;
private final Logger logger;
private final Map<NamespacedKey, Recipe> owned = new LinkedHashMap<>();
private boolean attempted;
private boolean available;
PocketBaseRecipeRegistry(Server server, List<? extends Recipe> recipes, Logger logger) {
this.server = Objects.requireNonNull(server, "server");
this.recipes = List.copyOf(recipes);
this.logger = Objects.requireNonNull(logger, "logger");
if (this.recipes.size() != REQUIRED_RECIPE_COUNT) {
throw new IllegalArgumentException("Pocket Base travel requires exactly three recipes");
}
Set<NamespacedKey> keys = new HashSet<>();
String lifecycle = UUID.randomUUID().toString();
for (Recipe recipe : this.recipes) {
if (!(recipe instanceof Keyed keyed) || !keys.add(keyed.getKey())) {
throw new IllegalArgumentException("Pocket Base travel recipes require three unique keys");
}
if (recipe instanceof ShapedRecipe shaped) {
shaped.setGroup("spigotbase-owned-" + lifecycle + "-" + keyed.getKey());
}
}
}
boolean register() {
if (attempted) {
return available;
}
attempted = true;
try {
for (Recipe recipe : recipes) {
NamespacedKey key = ((Keyed) recipe).getKey();
boolean added;
try {
added = server.addRecipe(recipe);
} catch (RuntimeException exception) {
try {
if (sameDefinition(recipe, server.getRecipe(key))) {
owned.put(key, recipe);
}
} catch (RuntimeException inspectionFailure) {
exception.addSuppressed(inspectionFailure);
}
throw exception;
}
if (!added) {
logger.warning("Pocket Base travel recipe registration is incomplete at "
+ key + "; recipe discovery is disabled for this lifecycle");
rollback();
return false;
}
owned.put(key, recipe);
}
available = true;
return true;
} catch (RuntimeException exception) {
logger.log(Level.SEVERE,
"Pocket Base travel recipe registration failed; recipe discovery is disabled for this lifecycle",
exception);
rollback();
return false;
}
}
boolean isAvailable() {
if (!available) {
return false;
}
for (var entry : owned.entrySet()) {
try {
if (!sameDefinition(entry.getValue(), server.getRecipe(entry.getKey()))) {
available = false;
return false;
}
} catch (RuntimeException exception) {
logger.log(Level.WARNING,
"Could not verify active Pocket Base travel recipe " + entry.getKey(),
exception);
return false;
}
}
return owned.size() == REQUIRED_RECIPE_COUNT;
}
boolean controls(Recipe candidate) {
if (!(candidate instanceof Keyed keyed)) {
return false;
}
Recipe definition = owned.get(keyed.getKey());
if (!sameDefinition(definition, candidate)) {
return false;
}
try {
boolean controls = sameDefinition(definition, server.getRecipe(keyed.getKey()));
if (!controls) {
available = false;
}
return controls;
} catch (RuntimeException exception) {
available = false;
logger.log(Level.WARNING,
"Could not verify active Pocket Base travel recipe " + keyed.getKey()
+ "; keeping its handler fail-closed",
exception);
return true;
}
}
private void rollback() {
available = false;
for (var entry : Map.copyOf(owned).entrySet()) {
NamespacedKey key = entry.getKey();
Recipe active;
try {
active = server.getRecipe(key);
} catch (RuntimeException exception) {
logger.log(Level.SEVERE,
"Could not verify owned Pocket Base travel recipe " + key, exception);
continue;
}
if (!sameDefinition(entry.getValue(), active)) {
owned.remove(key);
continue;
}
try {
if (server.removeRecipe(key)) {
owned.remove(key);
}
} catch (RuntimeException exception) {
logger.log(Level.SEVERE, "Could not remove owned Pocket Base travel recipe " + key,
exception);
}
}
}
private static boolean sameDefinition(Recipe expected, Recipe actual) {
if (expected == actual) {
return true;
}
if (!(expected instanceof ShapedRecipe expectedShaped)
|| !(actual instanceof ShapedRecipe actualShaped)) {
return false;
}
if (!expectedShaped.getKey().equals(actualShaped.getKey())
|| !expectedShaped.getResult().equals(actualShaped.getResult())
|| !sameIngredientGrid(expectedShaped, actualShaped)
|| !expectedShaped.getGroup().equals(actualShaped.getGroup())
|| expectedShaped.getCategory() != actualShaped.getCategory()) {
return false;
}
return true;
}
private static boolean sameIngredientGrid(
ShapedRecipe expected, ShapedRecipe actual) {
String[] expectedShape = expected.getShape();
String[] actualShape = actual.getShape();
if (expectedShape.length != actualShape.length) {
return false;
}
Map<Character, RecipeChoice> expectedChoices = expected.getChoiceMap();
Map<Character, RecipeChoice> actualChoices = actual.getChoiceMap();
for (int row = 0; row < expectedShape.length; row++) {
if (expectedShape[row].length() != actualShape[row].length()) {
return false;
}
for (int column = 0; column < expectedShape[row].length(); column++) {
RecipeChoice expectedChoice = expectedChoices.get(
expectedShape[row].charAt(column));
RecipeChoice actualChoice = actualChoices.get(
actualShape[row].charAt(column));
if (!sameChoice(expectedChoice, actualChoice)) {
return false;
}
}
}
return true;
}
private static boolean sameChoice(RecipeChoice expected, RecipeChoice actual) {
Set<String> expectedMaterials = materialKeys(expected);
Set<String> actualMaterials = materialKeys(actual);
if (expectedMaterials != null || actualMaterials != null) {
return Objects.equals(expectedMaterials, actualMaterials);
}
if (expected instanceof RecipeChoice.ExactChoice expectedItems
&& actual instanceof RecipeChoice.ExactChoice actualItems) {
List<ItemStack> expectedChoices = expectedItems.getChoices();
List<ItemStack> actualChoices = actualItems.getChoices();
return expectedChoices.equals(actualChoices);
}
return Objects.equals(expected, actual);
}
private static Set<String> materialKeys(RecipeChoice choice) {
Set<String> keys = new HashSet<>();
if (choice instanceof RecipeChoice.MaterialChoice materials) {
for (var material : materials.getChoices()) {
keys.add(material.getKey().asString());
}
return Set.copyOf(keys);
}
if (choice instanceof RecipeChoice.ItemTypeChoice itemTypes) {
for (var itemType : itemTypes.itemTypes().values()) {
keys.add(itemType.key().asString());
}
return Set.copyOf(keys);
}
return null;
}
@Override
public void close() {
rollback();
}
}
@@ -0,0 +1,411 @@
package games.dmg.spigotbase;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import java.util.function.BooleanSupplier;
import java.util.function.IntSupplier;
import java.util.function.Predicate;
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.PlayerRecipeBookClickEvent;
import org.bukkit.event.player.PlayerTeleportEvent;
import org.bukkit.inventory.EquipmentSlot;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.Recipe;
import org.bukkit.inventory.RecipeChoice;
import org.bukkit.inventory.ShapedRecipe;
import org.bukkit.inventory.recipe.CraftingBookCategory;
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<>();
private PocketBaseRecipeDiscovery recipeDiscovery;
private BooleanSupplier recipesAvailable = () -> true;
private Predicate<Recipe> recipeControlled = this::ourRecipe;
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 setRecipeDiscovery(PocketBaseRecipeDiscovery discovery) {
this.recipeDiscovery = discovery;
}
void setRecipesAvailable(BooleanSupplier available) {
this.recipesAvailable = available;
}
void setRecipeControl(Predicate<Recipe> controlled) {
this.recipeControlled = controlled;
}
List<ShapedRecipe> recipes() {
var creation = new ShapedRecipe(key, representative("Creation"));
creation.shape("DED", "ECE", "DED");
creation.setIngredient('D', Material.DIAMOND_BLOCK);
creation.setIngredient('E', Material.ECHO_SHARD);
creation.setIngredient('C', Material.END_CRYSTAL);
creation.setCategory(CraftingBookCategory.EQUIPMENT);
var recharge = new ShapedRecipe(rechargeKey, representative("Recharge"));
recharge.shape("PPP", "PCP", "PPP");
recharge.setIngredient('P', Material.ENDER_PEARL);
recharge.setIngredient('C', new RecipeChoice.MaterialChoice(Material.COMPASS));
recharge.setCategory(CraftingBookCategory.EQUIPMENT);
return List.of(creation, recharge);
}
void registerRecipes() {
recipes().forEach(server::addRecipe);
}
private static ItemStack representative(String purpose) {
ItemStack item = new ItemStack(Material.COMPASS);
var meta = item.getItemMeta();
meta.displayName(Component.text(
"Pocket Base Sharing Compass — " + purpose + " Representative"));
meta.lore(List.of(
Component.text("Non-authoritative recipe-book display only."),
Component.text("Only an authenticated bound Sharing Compass is usable.")
));
item.setItemMeta(meta);
return item;
}
@EventHandler(priority = EventPriority.LOWEST)
public void onRecipeBookClick(PlayerRecipeBookClickEvent event) {
if (event.getOriginalRecipe() instanceof Keyed keyed
&& rechargeKey.equals(keyed.getKey())
&& recipeControlled.test(event.getOriginalRecipe())) {
normalizeStorage(event.getPlayer());
}
}
@EventHandler(priority = EventPriority.HIGHEST)
public void onPrepare(PrepareItemCraftEvent event) {
if (!(event.getView().getPlayer() instanceof Player player)
|| !recipeControlled.test(event.getRecipe())
|| empty(event.getInventory().getResult())) return;
// Only definitions still owned by this lifecycle are intercepted.
if (!recipesAvailable.getAsBoolean()) {
event.getInventory().setResult(null);
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 (!recipeControlled.test(event.getRecipe())) return;
event.setCancelled(true);
if (!recipesAvailable.getAsBoolean()) {
event.getInventory().setResult(null);
return;
}
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);
if (creation && output != null && recipeDiscovery != null) {
recipeDiscovery.discoverRecharge(player);
}
}
@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;
ItemStack normalized = normalizeAuthentic(item);
if (normalized != item) {
player.getInventory().setItem(event.getHand(), normalized);
}
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());
if (recipeDiscovery != null) {
recipeDiscovery.discoverRecharge(player);
}
}
} 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;
}
boolean isAuthentic(ItemStack item) {
return state(item) != null;
}
ItemStack normalizeAuthentic(ItemStack item) {
if (state(item) == null) {
return item;
}
var meta = item.getItemMeta();
if (!meta.hasCustomName()) {
return item;
}
Component visibleTitle = meta.customName();
ItemStack normalized = item.clone();
var normalizedMeta = normalized.getItemMeta();
normalizedMeta.itemName(visibleTitle);
normalizedMeta.customName(null);
normalized.setItemMeta(normalizedMeta);
return normalized;
}
void normalizeCarried(Player player) {
normalizeStorage(player);
var inventory = player.getInventory();
ItemStack offhand = inventory.getItemInOffHand();
ItemStack normalizedOffhand = normalizeAuthentic(offhand);
if (normalizedOffhand != offhand) {
inventory.setItem(EquipmentSlot.OFF_HAND, normalizedOffhand);
}
}
private void normalizeStorage(Player player) {
var inventory = player.getInventory();
ItemStack[] storage = inventory.getStorageContents();
for (int slot = 0; slot < storage.length; slot++) {
ItemStack normalized = normalizeAuthentic(storage[slot]);
if (normalized != storage[slot]) {
inventory.setItem(slot, normalized);
}
}
}
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) {
ItemStack updated = normalizeAuthentic(item);
if (updated == item) {
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.itemName(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);
}
@@ -2,11 +2,13 @@ package games.dmg.spigotbase;
import java.io.IOException;
import java.time.Clock;
import java.util.ArrayList;
import java.util.Map;
import java.util.Objects;
import java.util.logging.Level;
import org.bukkit.NamespacedKey;
import org.bukkit.command.PluginCommand;
import org.bukkit.inventory.Recipe;
import org.bukkit.plugin.java.JavaPlugin;
public final class SpigotBasePlugin extends JavaPlugin {
@@ -18,6 +20,10 @@ public final class SpigotBasePlugin extends JavaPlugin {
private PocketBaseManager pocketBaseManager;
private PocketBaseController pocketBaseController;
private PocketBaseKeystoneService keystoneService;
private PocketBaseSharingCompass sharingCompass;
private PocketBaseRecipeRegistry recipeRegistry;
private PocketBaseRecipeDiscovery recipeDiscovery;
private BaseIdentificationController identificationController;
@Override
public void onEnable() {
@@ -52,7 +58,28 @@ public final class SpigotBasePlugin extends JavaPlugin {
keystoneKey,
getLogger()
);
keystoneService.registerRecipe();
sharingCompass = new PocketBaseSharingCompass(getServer(), pocketBaseManager,
new NamespacedKey(this, "sharing_compass"),
() -> java.util.concurrent.ThreadLocalRandom.current().nextInt(100));
var recipes = new ArrayList<Recipe>();
recipes.add(keystoneService.recipe());
recipes.addAll(sharingCompass.recipes());
recipeRegistry = new PocketBaseRecipeRegistry(getServer(), recipes, getLogger());
recipeRegistry.register();
keystoneService.setRecipesAvailable(recipeRegistry::isAvailable);
keystoneService.setRecipeControl(recipeRegistry::controls);
sharingCompass.setRecipesAvailable(recipeRegistry::isAvailable);
sharingCompass.setRecipeControl(recipeRegistry::controls);
recipeDiscovery = new PocketBaseRecipeDiscovery(
recipeRegistry,
pocketBaseManager,
sharingCompass,
keystoneKey,
new NamespacedKey(this, "sharing_compass"),
new NamespacedKey(this, "sharing_compass_recharge"),
getLogger()
);
sharingCompass.setRecipeDiscovery(recipeDiscovery);
} catch (RuntimeException | IOException exception) {
getLogger().log(Level.SEVERE, "Could not initialize Spigot Base", exception);
getServer().getPluginManager().disablePlugin(this);
@@ -66,6 +93,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 +138,9 @@ 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);
getServer().getPluginManager().registerEvents(recipeDiscovery, this);
recipeDiscovery.reconcileOnline(getServer().getOnlinePlayers());
command("setbase").setExecutor(new SetBaseCommand(
stateManager, baseService, Clock.systemUTC(), pocketBaseController
@@ -112,7 +150,8 @@ public final class SpigotBasePlugin extends JavaPlugin {
stateManager,
settingsProvider,
flightController,
pocketBaseManager
pocketBaseManager,
recipeDiscovery
);
command("basesettings").setExecutor(settingsCommand);
command("basesettings").setTabCompleter(settingsCommand);
@@ -156,6 +195,12 @@ public final class SpigotBasePlugin extends JavaPlugin {
@Override
public void onDisable() {
if (identificationController != null) {
identificationController.close();
}
if (recipeRegistry != null) {
recipeRegistry.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.entityIds());
} 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,57 @@
package games.dmg.spigotbase;
import static org.junit.jupiter.api.Assertions.*;
import io.netty.buffer.Unpooled;
import net.minecraft.core.registries.Registries;
import net.minecraft.network.FriendlyByteBuf;
import net.minecraft.network.protocol.game.ServerboundPlaceRecipePacket;
import net.minecraft.resources.Identifier;
import net.minecraft.resources.ResourceKey;
import net.minecraft.stats.ServerRecipeBook;
import net.minecraft.world.item.crafting.Recipe;
import net.minecraft.world.item.crafting.display.RecipeDisplayId;
import org.junit.jupiter.api.Test;
/** Native protocol/book checks pinned to the supported Purpur runtime. */
final class NativeRecipeBookTest {
@Test
void placeRecipePacketCodecPreservesAutofillSelectionAndMode() {
var expected = new ServerboundPlaceRecipePacket(
17, new RecipeDisplayId(42), true);
var buffer = new FriendlyByteBuf(Unpooled.buffer());
ServerboundPlaceRecipePacket.STREAM_CODEC.encode(buffer, expected);
var decoded = ServerboundPlaceRecipePacket.STREAM_CODEC.decode(buffer);
assertEquals(expected, decoded);
assertEquals(17, decoded.containerId());
assertEquals(42, decoded.recipe().index());
assertTrue(decoded.useMaxItems());
assertFalse(buffer.isReadable(), "The pinned codec must consume exactly one request");
buffer.release();
}
@Test
void serverRecipeBookKnowledgeIsIdempotentAndCopiesAcrossLifecycle() {
ResourceKey<Recipe<?>> vanilla = recipeKey("minecraft:wooden_pickaxe");
ResourceKey<Recipe<?>> keystone = recipeKey("spigotbase:pocket_base_keystone");
ServerRecipeBook book = new ServerRecipeBook((recipe, consumer) -> {});
book.add(vanilla);
book.add(keystone);
book.add(keystone);
assertEquals(2, book.known.size());
assertTrue(book.contains(vanilla));
assertTrue(book.contains(keystone));
ServerRecipeBook reenabled = new ServerRecipeBook((recipe, consumer) -> {});
reenabled.copyOverData(book);
assertTrue(reenabled.contains(vanilla));
assertTrue(reenabled.contains(keystone));
}
private static ResourceKey<Recipe<?>> recipeKey(String value) {
return ResourceKey.create(Registries.RECIPE, Identifier.parse(value));
}
}
@@ -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.WORLD), tags);
RegistryAccess.Frozen world = RegistryDataLoader.load(resources, lookups,
RegistryDataLoader.WORLD_REGISTRIES, Util.backgroundExecutor()).join();
layers = layers.replaceFrom(RegistryLayer.WORLD, world);
RegistryAccess.Frozen dimensions = RegistryDataLoader.load(resources,
Stream.concat(lookups.stream(), world.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;
}
}
File diff suppressed because it is too large. Load diff
@@ -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,85 @@
package games.dmg.spigotbase;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import java.nio.file.Path;
import java.util.ArrayList;
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.inventory.ShapedRecipe;
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();
var settingsArguments = new ArrayList<List<?>>();
try (var validator = mockStatic(PluginSettingsValidator.class);
var keystones = mockConstruction(PocketBaseKeystoneService.class,
(service, context) -> when(service.recipe()).thenReturn(mock(ShapedRecipe.class)));
var sharing = mockConstruction(PocketBaseSharingCompass.class,
(service, context) -> when(service.recipes()).thenReturn(
List.of(mock(ShapedRecipe.class), mock(ShapedRecipe.class))));
var registries = mockConstruction(PocketBaseRecipeRegistry.class,
(registry, context) -> when(registry.register()).thenReturn(true));
var discoveries = mockConstruction(PocketBaseRecipeDiscovery.class);
var settingsCommands = mockConstruction(BaseSettingsCommand.class,
(command, context) -> settingsArguments.add(context.arguments()));
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");
assertEquals(1, registries.constructed().size());
assertEquals(1, discoveries.constructed().size());
assertEquals(1, settingsCommands.constructed().size());
verify(registries.constructed().getFirst()).register();
verify(keystones.constructed().getFirst()).setRecipesAvailable(any());
verify(keystones.constructed().getFirst()).setRecipeControl(any());
verify(sharing.constructed().getFirst()).setRecipesAvailable(any());
verify(sharing.constructed().getFirst()).setRecipeControl(any());
verify(sharing.constructed().getFirst()).setRecipeDiscovery(discoveries.constructed().getFirst());
verify(discoveries.constructed().getFirst()).reconcileOnline(server.getOnlinePlayers());
verify(manager).registerEvents(discoveries.constructed().getFirst(), plugin);
verify(manager).registerEvents(sharing.constructed().getFirst(), plugin);
assertSame(discoveries.constructed().getFirst(), settingsArguments.getFirst().get(4));
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();
verify(registries.constructed().getFirst()).close();
assertEquals(1, packets.constructed().size());
verify(labels.constructed().getFirst()).forgetAll();
}
}
}
@@ -256,17 +256,20 @@ final class BaseSettingsCommandTest {
when(pocketBases.upgrade(playerId)).thenReturn(
new PocketBaseState(playerId, 1, java.util.Optional.empty())
);
PocketBaseRecipeDiscovery discovery = mock(PocketBaseRecipeDiscovery.class);
BaseSettingsCommand command = new BaseSettingsCommand(
stateManager,
new PluginSettingsProvider(PluginSettings.from(Map.of())),
mock(BaseFlightController.class),
pocketBases
pocketBases,
discovery
);
command.onCommand(player, null, "basesettings", new String[] {"pocket", "upgrade"});
verify(pocketBases).upgrade(playerId);
verify(inventory).setStorageContents(any(ItemStack[].class));
verify(discovery).discoverCreation(player);
}
@Test
@@ -454,15 +457,18 @@ final class BaseSettingsCommandTest {
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
when(pocketBases.state(playerId)).thenReturn(PocketBaseState.locked(playerId));
when(pocketBases.upgrade(playerId)).thenThrow(new IOException("save failed"));
PocketBaseRecipeDiscovery discovery = mock(PocketBaseRecipeDiscovery.class);
BaseSettingsCommand command = new BaseSettingsCommand(
stateManager,
new PluginSettingsProvider(PluginSettings.from(Map.of())),
mock(BaseFlightController.class),
pocketBases
pocketBases,
discovery
);
command.onCommand(player, null, "basesettings", new String[] {"pocket", "upgrade"});
verify(discovery, never()).discoverCreation(player);
ArgumentCaptor<ItemStack[]> contents = ArgumentCaptor.forClass(ItemStack[].class);
verify(inventory, times(2)).setStorageContents(contents.capture());
ItemStack[] restored = contents.getAllValues().get(1);
@@ -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);
}
}
@@ -99,6 +99,36 @@ final class PocketBaseKeystoneServiceTest {
verify(inventory).setResult(null);
}
@Test
void residualOwnedKeystoneRecipeFailsClosedWhenRegistrationGroupIsUnavailable() {
NamespacedKey key = NamespacedKey.minecraft("pocket_base_keystone");
UUID playerId = UUID.randomUUID();
Player player = mock(Player.class);
PrepareItemCraftEvent event = mock(PrepareItemCraftEvent.class);
InventoryView view = mock(InventoryView.class);
CraftingInventory inventory = mock(CraftingInventory.class);
ShapedRecipe recipe = mock(ShapedRecipe.class);
PocketBaseManager pocketBases = mock(PocketBaseManager.class);
PocketBaseKeystoneService service = new PocketBaseKeystoneService(
mock(Server.class), pocketBases, mock(KeystoneReturnStore.class),
key, Logger.getAnonymousLogger()
);
service.setRecipesAvailable(() -> false);
service.setRecipeControl(candidate -> true);
when(event.getView()).thenReturn(view);
when(view.getPlayer()).thenReturn(player);
when(event.getInventory()).thenReturn(inventory);
when(event.getRecipe()).thenReturn(recipe);
when(player.getUniqueId()).thenReturn(playerId);
when(pocketBases.state(playerId)).thenReturn(
new PocketBaseState(playerId, 1, Optional.empty())
);
service.onPrepareCraft(event);
verify(inventory).setResult(null);
}
@Test
void lockedPlayerCannotUseATransferredKeystone() throws Exception {
NamespacedKey key = NamespacedKey.minecraft("pocket_base_keystone");
@@ -0,0 +1,170 @@
package games.dmg.spigotbase;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.logging.Logger;
import org.bukkit.Bukkit;
import org.bukkit.NamespacedKey;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
import org.bukkit.event.player.PlayerJoinEvent;
import org.bukkit.inventory.Inventory;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.PlayerInventory;
import org.bukkit.plugin.java.JavaPlugin;
import org.junit.jupiter.api.Test;
final class PocketBaseRecipeDiscoveryTest {
private static final NamespacedKey KEYSTONE = NamespacedKey.minecraft("keystone");
private static final NamespacedKey CREATION = NamespacedKey.minecraft("sharing");
private static final NamespacedKey RECHARGE = NamespacedKey.minecraft("sharing_recharge");
@Test
void onlineReconciliationIsolatesPlayersAndRetriesMissingPostconditions() {
Player broken = mock(Player.class);
Player retrying = mock(Player.class);
UUID brokenId = UUID.randomUUID();
UUID retryingId = UUID.randomUUID();
PocketBaseManager pockets = mock(PocketBaseManager.class);
PocketBaseSharingCompass sharing = mock(PocketBaseSharingCompass.class);
PocketBaseRecipeRegistry registry = mock(PocketBaseRecipeRegistry.class);
when(registry.isAvailable()).thenReturn(true);
when(broken.getUniqueId()).thenReturn(brokenId);
when(retrying.getUniqueId()).thenReturn(retryingId);
when(pockets.state(brokenId)).thenReturn(new PocketBaseState(brokenId, 1, Optional.empty()));
when(pockets.state(retryingId)).thenReturn(new PocketBaseState(retryingId, 1, Optional.empty()));
when(broken.hasDiscoveredRecipe(any())).thenThrow(new IllegalStateException("broken book"));
when(retrying.hasDiscoveredRecipe(any())).thenReturn(false);
PocketBaseRecipeDiscovery discovery = new PocketBaseRecipeDiscovery(
registry, pockets, sharing, KEYSTONE, CREATION, RECHARGE,
Logger.getAnonymousLogger());
discovery.reconcileOnline(List.of(broken, retrying));
discovery.reconcileOnline(List.of(retrying));
verify(retrying, times(2)).discoverRecipe(KEYSTONE);
verify(retrying, times(2)).discoverRecipe(CREATION);
}
@Test
void completeCharacterResetPreservesPocketAndVanillaRecipeKnowledge() {
UUID playerId = UUID.randomUUID();
NamespacedKey vanilla = NamespacedKey.minecraft("wooden_pickaxe");
Set<NamespacedKey> known = new HashSet<>(Set.of(vanilla, KEYSTONE, CREATION, RECHARGE));
Player player = mock(Player.class);
PlayerInventory inventory = mock(PlayerInventory.class);
CommandSender administrator = mock(CommandSender.class);
BaseStateManager states = mock(BaseStateManager.class);
PocketBaseManager pockets = mock(PocketBaseManager.class);
PocketBaseSharingCompass sharing = mock(PocketBaseSharingCompass.class);
PocketBaseRecipeRegistry registry = mock(PocketBaseRecipeRegistry.class);
PlayerState character = PlayerState.newPlayer(playerId, "Builder");
when(administrator.hasPermission("spigotbase.admin")).thenReturn(true);
when(player.getUniqueId()).thenReturn(playerId);
when(player.getName()).thenReturn("Builder");
when(player.getInventory()).thenReturn(inventory);
when(inventory.getStorageContents()).thenReturn(new ItemStack[0]);
when(inventory.getItemInOffHand()).thenReturn(null);
when(states.player(playerId, "Builder")).thenReturn(character);
when(pockets.state(playerId)).thenReturn(new PocketBaseState(playerId, 1, Optional.empty()));
when(registry.isAvailable()).thenReturn(true);
when(player.hasDiscoveredRecipe(any())).thenAnswer(call -> known.contains(call.getArgument(0)));
BaseAdminCommand reset = new BaseAdminCommand(
mock(JavaPlugin.class), states, mock(AdminProgressionService.class),
new PluginSettingsProvider(PluginSettings.from(java.util.Map.of())));
PocketBaseRecipeDiscovery discovery = new PocketBaseRecipeDiscovery(
registry, pockets, sharing, KEYSTONE, CREATION, RECHARGE,
Logger.getAnonymousLogger());
try (var bukkit = mockStatic(Bukkit.class)) {
bukkit.when(() -> Bukkit.getPlayerExact("Builder")).thenReturn(player);
reset.onCommand(administrator, null, "baseadmin",
new String[] {"reset", "Builder", "all", "confirm"});
}
discovery.reconcile(player);
assertEquals(Set.of(vanilla, KEYSTONE, CREATION, RECHARGE), known);
verify(player, never()).undiscoverRecipe(any());
verify(player, never()).undiscoverRecipes(any());
verify(player, never()).discoverRecipe(any());
verify(pockets).state(playerId);
}
@Test
void joinReconciliationUsesOnlyStorageHotbarAndOffhandForAuthenticPossession() {
UUID playerId = UUID.randomUUID();
Player player = mock(Player.class);
PlayerInventory inventory = mock(PlayerInventory.class);
Inventory enderChest = mock(Inventory.class);
ItemStack authentic = mock(ItemStack.class);
ItemStack nameOnly = mock(ItemStack.class);
PocketBaseManager pockets = mock(PocketBaseManager.class);
PocketBaseSharingCompass sharing = mock(PocketBaseSharingCompass.class);
PocketBaseRecipeRegistry registry = mock(PocketBaseRecipeRegistry.class);
Set<NamespacedKey> known = new HashSet<>();
when(registry.isAvailable()).thenReturn(true);
when(player.getUniqueId()).thenReturn(playerId);
when(player.getInventory()).thenReturn(inventory);
when(player.getEnderChest()).thenReturn(enderChest);
when(player.getItemOnCursor()).thenReturn(authentic);
when(inventory.getArmorContents()).thenReturn(new ItemStack[] {authentic});
when(enderChest.getContents()).thenReturn(new ItemStack[] {authentic});
when(inventory.getStorageContents()).thenReturn(new ItemStack[] {nameOnly});
when(inventory.getItemInOffHand()).thenReturn(null, authentic);
when(sharing.isAuthentic(authentic)).thenReturn(true);
when(sharing.isAuthentic(nameOnly)).thenReturn(false);
when(pockets.state(playerId)).thenReturn(PocketBaseState.locked(playerId));
when(player.hasDiscoveredRecipe(any())).thenAnswer(call -> known.contains(call.getArgument(0)));
when(player.discoverRecipe(any())).thenAnswer(call -> known.add(call.getArgument(0)));
PocketBaseRecipeDiscovery discovery = new PocketBaseRecipeDiscovery(
registry, pockets, sharing, KEYSTONE, CREATION, RECHARGE,
Logger.getAnonymousLogger());
discovery.onJoin(new PlayerJoinEvent(player, "joined"));
assertTrue(known.isEmpty(), "Armor, cursor, Ender Chest and name-only items are excluded");
discovery.onJoin(new PlayerJoinEvent(player, "joined again"));
assertEquals(Set.of(RECHARGE), known, "An authentic offhand compass earns recharge knowledge");
verify(player, never()).getItemOnCursor();
verify(player, never()).getEnderChest();
verify(inventory, never()).getArmorContents();
}
@Test
void eligibleOwnerWithAuthenticStoredCompassDiscoversEachRecipeOnlyOnce() {
UUID playerId = UUID.randomUUID();
Player player = mock(Player.class);
PlayerInventory inventory = mock(PlayerInventory.class);
ItemStack authentic = mock(ItemStack.class);
PocketBaseManager pockets = mock(PocketBaseManager.class);
PocketBaseSharingCompass sharing = mock(PocketBaseSharingCompass.class);
PocketBaseRecipeRegistry registry = mock(PocketBaseRecipeRegistry.class);
Set<NamespacedKey> known = new HashSet<>();
when(registry.isAvailable()).thenReturn(true);
when(player.getUniqueId()).thenReturn(playerId);
when(player.getInventory()).thenReturn(inventory);
when(inventory.getStorageContents()).thenReturn(new ItemStack[] {authentic});
when(inventory.getItemInOffHand()).thenReturn(null);
when(sharing.isAuthentic(authentic)).thenReturn(true);
when(pockets.state(playerId)).thenReturn(new PocketBaseState(playerId, 1, Optional.empty()));
when(player.hasDiscoveredRecipe(any())).thenAnswer(call -> known.contains(call.getArgument(0)));
when(player.discoverRecipe(any())).thenAnswer(call -> known.add(call.getArgument(0)));
PocketBaseRecipeDiscovery discovery = new PocketBaseRecipeDiscovery(
registry, pockets, sharing, KEYSTONE, CREATION, RECHARGE,
Logger.getAnonymousLogger());
discovery.reconcile(player);
discovery.reconcile(player);
assertEquals(Set.of(KEYSTONE, CREATION, RECHARGE), known);
verify(player).discoverRecipe(KEYSTONE);
verify(player).discoverRecipe(CREATION);
verify(player).discoverRecipe(RECHARGE);
verify(player, never()).undiscoverRecipe(any());
}
}
@@ -0,0 +1,267 @@
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.logging.Logger;
import org.bukkit.Keyed;
import org.bukkit.NamespacedKey;
import org.bukkit.Server;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.Recipe;
import org.bukkit.inventory.RecipeChoice;
import org.bukkit.inventory.ShapedRecipe;
import org.junit.jupiter.api.Test;
final class PocketBaseRecipeRegistryTest {
@Test
void allThreeRegisterAsOneOwnedFailClosedGroupWithoutTouchingConflicts() {
NamespacedKey keystone = NamespacedKey.minecraft("keystone");
NamespacedKey creation = NamespacedKey.minecraft("sharing");
NamespacedKey recharge = NamespacedKey.minecraft("sharing_recharge");
Recipe first = recipe(keystone);
Recipe conflict = recipe(creation);
Recipe unattempted = recipe(recharge);
Server server = mock(Server.class);
when(server.addRecipe(first)).thenReturn(true);
when(server.addRecipe(conflict)).thenReturn(false);
when(server.getRecipe(keystone)).thenReturn(first);
when(server.removeRecipe(keystone)).thenReturn(true);
PocketBaseRecipeRegistry registry = new PocketBaseRecipeRegistry(
server, List.of(first, conflict, unattempted), Logger.getAnonymousLogger());
assertFalse(registry.register());
assertFalse(registry.isAvailable());
verify(server).removeRecipe(keystone);
verify(server, never()).removeRecipe(creation);
verify(server, never()).removeRecipe(recharge);
verify(server, never()).addRecipe(unattempted);
registry.close();
verify(server).removeRecipe(keystone);
Server successfulServer = mock(Server.class);
when(successfulServer.addRecipe(any())).thenReturn(true);
when(successfulServer.getRecipe(keystone)).thenReturn(first);
when(successfulServer.getRecipe(creation)).thenReturn(conflict);
when(successfulServer.getRecipe(recharge)).thenReturn(unattempted);
when(successfulServer.removeRecipe(any())).thenReturn(true);
PocketBaseRecipeRegistry successful = new PocketBaseRecipeRegistry(
successfulServer, List.of(first, conflict, unattempted), Logger.getAnonymousLogger());
assertTrue(successful.register());
assertTrue(successful.isAvailable());
successful.close();
verify(successfulServer).removeRecipe(keystone);
verify(successfulServer).removeRecipe(creation);
verify(successfulServer).removeRecipe(recharge);
}
@Test
void registrationDefinitionsReceiveUniqueLifecycleProvenanceGroups() {
ItemStack result = mock(ItemStack.class);
RecipeChoice choice = mock(RecipeChoice.class);
ShapedRecipe first = shapedRecipe(
NamespacedKey.minecraft("first"), result, choice);
ShapedRecipe second = shapedRecipe(
NamespacedKey.minecraft("second"), result, choice);
ShapedRecipe third = shapedRecipe(
NamespacedKey.minecraft("third"), result, choice);
new PocketBaseRecipeRegistry(
mock(Server.class), List.of(first, second, third), Logger.getAnonymousLogger());
var groups = org.mockito.ArgumentCaptor.forClass(String.class);
verify(first).setGroup(groups.capture());
verify(second).setGroup(groups.capture());
verify(third).setGroup(groups.capture());
assertEquals(3, groups.getAllValues().stream().distinct().count());
assertTrue(groups.getAllValues().stream()
.allMatch(group -> group.startsWith("spigotbase-owned-")));
}
@Test
void cleanupPreservesALaterForeignReplacementUnderAnOwnedKey() {
NamespacedKey replacedKey = NamespacedKey.minecraft("replaced");
NamespacedKey retainedKey = NamespacedKey.minecraft("retained");
NamespacedKey thirdKey = NamespacedKey.minecraft("third");
Recipe replaced = recipe(replacedKey);
Recipe replacement = recipe(replacedKey);
Recipe retained = recipe(retainedKey);
Recipe third = recipe(thirdKey);
Server server = mock(Server.class);
when(server.addRecipe(any())).thenReturn(true);
when(server.getRecipe(replacedKey)).thenReturn(replacement);
when(server.getRecipe(retainedKey)).thenReturn(retained);
when(server.getRecipe(thirdKey)).thenReturn(third);
when(server.removeRecipe(any())).thenReturn(true);
PocketBaseRecipeRegistry registry = new PocketBaseRecipeRegistry(
server, List.of(replaced, retained, third), Logger.getAnonymousLogger());
assertTrue(registry.register());
registry.close();
verify(server, never()).removeRecipe(replacedKey);
verify(server).removeRecipe(retainedKey);
verify(server).removeRecipe(thirdKey);
}
@Test
void cleanupRemovesAnActiveStructuralCopyOfTheOwnedDefinition() {
NamespacedKey firstKey = NamespacedKey.minecraft("structural");
ItemStack result = mock(ItemStack.class);
RecipeChoice choice = mock(RecipeChoice.class);
ShapedRecipe definition = shapedRecipe(firstKey, result, choice);
ShapedRecipe activeCopy = shapedRecipe(firstKey, result, choice);
Recipe second = recipe(NamespacedKey.minecraft("second"));
Recipe third = recipe(NamespacedKey.minecraft("third"));
Server server = mock(Server.class);
when(server.addRecipe(any())).thenReturn(true);
when(server.getRecipe(firstKey)).thenReturn(activeCopy);
when(server.getRecipe(((Keyed) second).getKey())).thenReturn(second);
when(server.getRecipe(((Keyed) third).getKey())).thenReturn(third);
when(server.removeRecipe(any())).thenReturn(true);
PocketBaseRecipeRegistry registry = new PocketBaseRecipeRegistry(
server, List.of(definition, second, third), Logger.getAnonymousLogger());
assertTrue(registry.register());
registry.close();
verify(server).removeRecipe(firstKey);
}
@Test
void runtimeSymbolRenamingStillMatchesTheOwnedIngredientGrid() {
NamespacedKey firstKey = NamespacedKey.minecraft("renamed-symbols");
ItemStack result = mock(ItemStack.class);
RecipeChoice firstChoice = mock(RecipeChoice.class);
RecipeChoice secondChoice = mock(RecipeChoice.class);
ShapedRecipe definition = shapedRecipe(firstKey, result,
new String[] {"ABA", "BAB", "ABA"},
Map.of('A', firstChoice, 'B', secondChoice));
ShapedRecipe activeCopy = shapedRecipe(firstKey, result,
new String[] {"abc", "def", "ghi"},
Map.of(
'a', firstChoice, 'b', secondChoice, 'c', firstChoice,
'd', secondChoice, 'e', firstChoice, 'f', secondChoice,
'g', firstChoice, 'h', secondChoice, 'i', firstChoice));
Recipe second = recipe(NamespacedKey.minecraft("second"));
Recipe third = recipe(NamespacedKey.minecraft("third"));
Server server = mock(Server.class);
when(server.addRecipe(any())).thenReturn(true);
when(server.getRecipe(firstKey)).thenReturn(activeCopy);
when(server.getRecipe(((Keyed) second).getKey())).thenReturn(second);
when(server.getRecipe(((Keyed) third).getKey())).thenReturn(third);
when(server.removeRecipe(any())).thenReturn(true);
PocketBaseRecipeRegistry registry = new PocketBaseRecipeRegistry(
server, List.of(definition, second, third), Logger.getAnonymousLogger());
assertTrue(registry.register());
assertTrue(registry.isAvailable());
assertTrue(registry.controls(activeCopy));
registry.close();
verify(server).removeRecipe(firstKey);
}
@Test
void failedOwnedCleanupIsRetriedOnDisableWithoutTouchingTheConflict() {
NamespacedKey owned = NamespacedKey.minecraft("owned");
NamespacedKey conflict = NamespacedKey.minecraft("conflict");
Recipe first = recipe(owned);
Recipe second = recipe(conflict);
Recipe third = recipe(NamespacedKey.minecraft("third"));
Server server = mock(Server.class);
when(server.addRecipe(first)).thenReturn(true);
when(server.addRecipe(second)).thenReturn(false);
when(server.getRecipe(owned)).thenReturn(first);
when(server.removeRecipe(owned))
.thenThrow(new IllegalStateException("temporary cleanup failure"))
.thenReturn(true);
PocketBaseRecipeRegistry registry = new PocketBaseRecipeRegistry(
server, List.of(first, second, third), Logger.getAnonymousLogger());
assertFalse(registry.register());
assertTrue(registry.controls(first),
"A failed-removal residual must remain controlled while active");
registry.close();
assertFalse(registry.controls(first));
verify(server, times(2)).removeRecipe(owned);
verify(server, never()).removeRecipe(conflict);
}
@Test
void falseRemovalResultRetainsOwnershipForDisableRetry() {
NamespacedKey owned = NamespacedKey.minecraft("false-removal");
Recipe first = recipe(owned);
Recipe conflict = recipe(NamespacedKey.minecraft("conflict"));
Recipe third = recipe(NamespacedKey.minecraft("third"));
Server server = mock(Server.class);
when(server.addRecipe(first)).thenReturn(true);
when(server.addRecipe(conflict)).thenReturn(false);
when(server.getRecipe(owned)).thenReturn(first);
when(server.removeRecipe(owned)).thenReturn(false, true);
PocketBaseRecipeRegistry registry = new PocketBaseRecipeRegistry(
server, List.of(first, conflict, third), Logger.getAnonymousLogger());
assertFalse(registry.register());
assertTrue(registry.controls(first));
registry.close();
assertFalse(registry.controls(first));
verify(server, times(2)).removeRecipe(owned);
}
@Test
void postInsertionRegistrationExceptionOwnsAndRollsBackTheInsertedRecipe() {
NamespacedKey firstKey = NamespacedKey.minecraft("first");
NamespacedKey insertedKey = NamespacedKey.minecraft("inserted-before-exception");
Recipe first = recipe(firstKey);
Recipe inserted = recipe(insertedKey);
Recipe third = recipe(NamespacedKey.minecraft("third"));
Server server = mock(Server.class);
when(server.addRecipe(first)).thenReturn(true);
when(server.addRecipe(inserted))
.thenThrow(new IllegalStateException("reload failed after insertion"));
when(server.getRecipe(firstKey)).thenReturn(first);
when(server.getRecipe(insertedKey)).thenReturn(inserted);
when(server.removeRecipe(any())).thenReturn(true);
PocketBaseRecipeRegistry registry = new PocketBaseRecipeRegistry(
server, List.of(first, inserted, third), Logger.getAnonymousLogger());
assertFalse(registry.register());
verify(server).removeRecipe(firstKey);
verify(server).removeRecipe(insertedKey);
verify(server, never()).addRecipe(third);
assertFalse(registry.controls(inserted));
}
private static ShapedRecipe shapedRecipe(
NamespacedKey key, ItemStack result, RecipeChoice choice) {
return shapedRecipe(key, result, new String[] {"A"}, Map.of('A', choice));
}
private static ShapedRecipe shapedRecipe(
NamespacedKey key,
ItemStack result,
String[] shape,
Map<Character, RecipeChoice> choices
) {
ShapedRecipe recipe = mock(ShapedRecipe.class);
when(recipe.getKey()).thenReturn(key);
when(recipe.getResult()).thenReturn(result);
when(recipe.getShape()).thenReturn(shape);
when(recipe.getGroup()).thenReturn("");
when(recipe.getCategory()).thenReturn(
org.bukkit.inventory.recipe.CraftingBookCategory.MISC);
when(recipe.getChoiceMap()).thenReturn(choices);
return recipe;
}
private static Recipe recipe(NamespacedKey key) {
Recipe recipe = mock(Recipe.class, withSettings().extraInterfaces(Keyed.class));
when(((Keyed) recipe).getKey()).thenReturn(key);
return recipe;
}
}
@@ -0,0 +1,73 @@
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.List;
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"));
assertNotNull(jar.getJarEntry("games/dmg/spigotbase/PocketBaseRecipeRegistry.class"));
assertNotNull(jar.getJarEntry("games/dmg/spigotbase/PocketBaseRecipeDiscovery.class"));
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");
}
List<String> forbiddenPrefixes = List.of(
"com/comphenix/protocol/",
"com/destroystokyo/paper/",
"io/papermc/",
"net/minecraft/",
"org/bukkit/",
"org/purpurmc/",
"org/spigotmc/",
"mockito-extensions/"
);
assertFalse(jar.stream().anyMatch(entry -> forbiddenPrefixes.stream().anyMatch(entry.getName()::startsWith)
|| entry.getName().endsWith("Test.class")
|| entry.getName().equals("games/dmg/spigotbase/NativeRuntime.class")));
}
}
@Test
void releaseAndCiAgreeWithThePurpurDistributionNameAndToolchain() throws Exception {
String build = Files.readString(Path.of("build.gradle.kts"));
assertTrue(build.contains("purpur-api:26.3.build.2651-stable"));
assertTrue(build.contains("purpur-26.3-2651.jar"));
assertTrue(build.contains("https://api.purpurmc.org/v2/purpur/26.3/2651/download"));
assertTrue(build.contains("0677c1febecfbc2e5b0a7f76abd647b70a1f38d3cbdf3dd6a82e401b4262a598"));
assertFalse(build.contains("26.2"));
assertFalse(build.contains("2618"));
assertFalse(build.contains("org.spigotmc:spigot-api"));
String readme = Files.readString(Path.of("README.md"));
assertTrue(readme.contains("Purpur 26.3 build 2651"));
assertFalse(readme.contains("Purpur 26.2"));
assertFalse(readme.contains("2618"));
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());
}
}